Compare commits

...
Author SHA1 Message Date
+1 f92d84fcf9 [Rebase] Rebase to vllm releases/v0.22.0 (#3891)
Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
Signed-off-by: ZhengWG <zwg0606@gmail.com>
Signed-off-by: david6666666 <530634352@qq.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Signed-off-by: nainiu258 <cperfect02@163.com>
Signed-off-by: nainiu258 <101917677+nainiu258@users.noreply.github.com>
Signed-off-by: JasonJ2021 <jasonj@zju.edu.cn>
Signed-off-by: Zhou Taichang <tzhouam@connect.ust.hk>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: skf <54565339+skf-1999@users.noreply.github.com>
Co-authored-by: Zheng Wengang <zwg0606@gmail.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: WeiQing Chen <40507679+david6666666@users.noreply.github.com>
Co-authored-by: SYLAR <125541396+lishunyang12@users.noreply.github.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: nainiu258 <101917677+nainiu258@users.noreply.github.com>
Co-authored-by: Jason <72191212+JasonJ2021@users.noreply.github.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-06-01 11:08:12 +08:00
d4c139508f [Feature] Add precomputed custom voices and Qwen3-TTS ref-context cache (#3492)
Signed-off-by: Sy03 <1370724210@qq.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Dan250124 <416947747@qq.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-05-31 17:51:24 +00:00
Qing AnandSamit 57e5f0fe69 [model, omni] feat: Qwen3-Omni Thinker LoRA for RL training (#3915)
Signed-off-by: Qing An <qa4@rice.edu>
Co-authored-by: Samit <285365963@qq.com>
2026-06-01 00:10:14 +08:00
ChenWenjing 5dfdf584dd [Feat]audio streaming input for async chunk (#3614)
Signed-off-by: CHEN <116010019@link.cuhk.edu.cn>
2026-05-31 17:39:25 +08:00
NumberWan 6f4bd3e45c [BugFix]Qwen-Image performance regression by using omni RMSNorm(RMSNorm backend) (#3933)
Signed-off-by: NumberWan <wantszkin2003@gmail.com>
2026-05-31 08:06:18 +08:00
Canlin Guo 412bf42a75 [Refactor] Unify _talker_mtp_forward across GPU and NPU model runners (#3476)
Signed-off-by: gcanlin <canlinguosdu@gmail.com>
2026-05-31 07:58:00 +08:00
zzh ffd9771c54 Fix hunyuan resolve stop token ids (#3896)
Signed-off-by: zzh <943967662@qq.com>
2026-05-31 07:47:36 +08:00
DMAN 11c4fcedd3 [Recipe] add mistralai voxtral tts recipe (#3498)
Signed-off-by: Dmaner <2663515256@qq.com>
2026-05-31 00:18:52 +08:00
wuhang b0f3672aec [Bugfix] Fix DiffusionWorker crash on SIGINT: ensure NCCL/ZMQ cleanup on shutdown (#3872)
Signed-off-by: WU Hang <whlbx@hotmail.com>
2026-05-31 00:13:23 +08:00
WeiQing Chen 2289478e3a [CI] Temporarily skip failing Bagel connector tests (#4005)
Signed-off-by: David Chen <530634352@qq.com>
2026-05-30 17:46:29 +08:00
Alex BrooksandHongsheng Liu 8d13b54383 [CI/Bugfix] Async Request ID Aliasing (#3953)
Signed-off-by: Alex Brooks <albrooks@redhat.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-05-30 16:59:42 +08:00
wuhangandAlicia 802a6242f0 [Bugfix][Tests] Remove unnecessary device map in tests init (#3958)
Signed-off-by: WU Hang <whlbx@hotmail.com>
Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com>
2026-05-30 16:18:48 +08:00
Yuanheng ZhaoandZheng Wengang ea6d811b58 [Config Refactor] Migrate Ming-flash-omni-2.0 Image-Gen deploy configs (#3975)
Signed-off-by: yuanheng <jonathan.zhaoyh@gmail.com>
Co-authored-by: Zheng Wengang <zwg0606@gmail.com>
2026-05-30 08:13:21 +00:00
WeiQing Chen 5994a0bbf5 docs: update WeChat QR code (#4003)
Signed-off-by: David Chen <530634352@qq.com>
2026-05-30 16:12:11 +08:00
Viacheslav KlimkovandYueqian Lin 914e92cf80 [Refactor][Qwen3-TTS] Extract reusable prompt-embeds builder and make tts_pad_embed a persistent buffer (#3992)
Signed-off-by: Viacheslav Klimkov <vklimkov@nvidia.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-05-30 16:11:00 +08:00
dengyunyang 3b317285c4 [HunyuanImage][Perf] adapt to deploy config changes (#3996)
Signed-off-by: dengyunyang <584797741@qq.com>
2026-05-30 14:48:50 +08:00
WeiQing Chen a3456752c5 [Diffusion] Support USP and VAE patch parallel for HunyuanVideo 1.5 (#3979)
Signed-off-by: david6666666 <530634352@qq.com>
2026-05-30 14:40:42 +08:00
Chendi.Xue f43bb124bc [CI][XPU]Fix sage_attn hard-code import for cuda (#3994)
Signed-off-by: Chendi Xue <chendi.xue@intel.com>
2026-05-30 09:43:14 +08:00
fywc 272e626100 [Feat] support cache-dit for SenseNova-U1 (#3906)
Signed-off-by: hanzheli <hanzheli@kuaishou.com>
2026-05-29 17:40:54 +00:00
knlnguyen1802andSamit 5377108438 [minor, fix] Allow passing class interface as custom pipeline argument (#2973)
Signed-off-by: knlnguyen1802 <knlnguyen1802@gmail.com>
Signed-off-by: Samit <285365963@qq.com>
Co-authored-by: Samit <285365963@qq.com>
2026-05-29 23:39:38 +08:00
nperraud 9fdb3bcb89 [Bugfix][Model] Qwen3-TTS: don't collapse 2D ref_code list when estimating prompt length (#3940)
Signed-off-by: nperraud <nathanael.perraudin@gmail.com>
2026-05-29 09:18:25 -06:00
Chen ChengandClaude Opus 4.7 f1c2caa3c7 [Perf][Qwen3-TTS] Restore Code2Wav cross-request batching (RFC #3163 P0) (#3322)
Signed-off-by: ischencheng <cheng21@seas.upenn.edu>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 08:48:58 -06:00
b690a10e3a [Feat] Support MagCache (#1287)
Signed-off-by: Lancer <maruixiang6688@gmail.com>
Co-authored-by: 汪志鹏 <wangzhipeng628@gmail.com>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
2026-05-29 22:26:23 +08:00
Aleksi VesantoandHongsheng Liu eba5617c95 [ROCm] Enable AITER backend with ring attention (#3511)
Signed-off-by: Aleksi Vesanto <avesanto@amd.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-05-29 22:20:29 +08:00
amy-why-3459andHongsheng Liu 2696751003 [Perf] Qwen3-Omni performance optimization (#3878)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-05-29 22:15:23 +08:00
Alicia 8c4a42b16c Temp skip TEST - Entrypoint Test with H100 (#3989)
Signed-off-by: Alicia <115451386+congw729@users.noreply.github.com>
2026-05-29 22:37:09 +12:00
Huanyu Yang 3457758114 [Bugfix] Fix IndexError in DistributedVaeExecutor when vae_patch_parallel_size < world_size (#3928)
Signed-off-by: QingZhou-YangHY <3868850350@qq.com>
2026-05-29 03:17:34 -07:00
WeiQing Chen 67bb9b4a12 [CI][Bugfix] Fix LTX audio-video warmup output typing (#3964)
Signed-off-by: david6666666 <530634352@qq.com>
2026-05-29 07:46:02 +00:00
boatmanandsphinxkkkbc 4d9262a6eb [Feat]Support voice clone for omnivoice in online serving & add seed parameter for reproducible (#3668)
Signed-off-by: sphinxkkkbc <binchengkang8@gmail.com>
Co-authored-by: sphinxkkkbc <binchengkang8@gmail.com>
2026-05-29 07:42:08 +00:00
c69ed4037d [feat] Support prompt embedding caching for diffusion model (#2962)
Signed-off-by: knlnguyen1802 <knlnguyen1802@gmail.com>
Co-authored-by: Samit <285365963@qq.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-05-29 15:36:03 +08:00
Alex Brooks 68a1d23e4d [Bugfix/Feature] Remove Hardcoded Flash Attention in Bagel & Support GQA in SDPA Backend (#3728)
Signed-off-by: Alex Brooks <albrooks@redhat.com>
2026-05-29 10:36:15 +08:00
Chen-Yo Sun 593c4c06aa [BugFix] Avoid Voxtral TTS loading error msg (#3951)
Signed-off-by: Chen-Yo Sun <chenyo.sun@mistral.ai>
2026-05-29 02:35:01 +00:00
NATURE 4987bec425 [bugfix] Solve Nightly / CI failed - tests/e2e/online_serving/test_bagel_expansion.py #3918 (#3936)
Signed-off-by: natureofnature <wzliu@connect.hku.hk>
2026-05-29 02:05:07 +00:00
amy-why-3459 5dd1d0a772 [BugFix] Fix prefix-caching issue (#3726)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-05-29 01:51:54 +00:00
ChenWenjingandGao Han 834abdaf8b [ci][nightly] Voxcpm2 performance benchmark (#3864)
Signed-off-by: CHEN <116010019@link.cuhk.edu.cn>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
2026-05-29 09:44:29 +08:00
Jason 5dcf18a984 [Docs] Add recipe for Helios (#3114)
Signed-off-by: JasonJ2021 <jasonj@zju.edu.cn>
2026-05-29 09:34:37 +08:00
Yuxiang Liang 900b4187a1 [XPU] Add CosyVoice3Model support on Intel XPU (#2325)
Signed-off-by: Yuxiang Liang <yuliang@habana.ai>
Signed-off-by: Yuxiang Liang <yuxiang.liang@intel.com>
2026-05-29 00:59:45 +00:00
wuli666andwuli666 8935439428 [Bugfix] Fix Triton SnakeBeta kernel for bf16/fp16 inputs (#3472)
Signed-off-by: wuli666 <djjpro975@gmail.com>
Co-authored-by: wuli666 <djjpro975@gmail.com>
2026-05-28 17:10:36 +00:00
Dakai An bb9f21d0db Fix Qwen3-omni accuracy degradation from deepstack inputs under torch.compile (#3885)
Signed-off-by: Dakai An <dakaian108@gmail.com>
2026-05-28 23:57:56 +08:00
knlnguyen1802andSamit 5eac50f22c [bugfix, rl] Fix sleep do not release full memory in custom pipeline (#3818)
Signed-off-by: knlnguyen1802 <knlnguyen1802@gmail.com>
Co-authored-by: Samit <285365963@qq.com>
2026-05-28 22:27:27 +08:00
126fb4b4a9 [Blackwell][1/N] Add SageAttention3 diffusion backend on blackwell(GB200/B200/RTX5090/PRO6000/DGX Spark available) (#3015)
Signed-off-by: lishunyang <lishunyang12@163.com>
Signed-off-by: WeiQing Chen <40507679+david6666666@users.noreply.github.com>
Co-authored-by: lishunyang <lishunyang12@163.com>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
2026-05-28 14:08:45 +00:00
zhumingjue138 8d9a4afed9 [Test] Add scenarios for L5 reliability test (#3729)
Signed-off-by: zhumingjue <zhumingjue@huawei.com>
2026-05-28 21:41:33 +08:00
WeiQing ChenandWeiQing Chen 1acd27c205 [CI][BugFix] Fix and Validate FP8 Z-Image quality gate (#3929)
Signed-off-by: WeiQing Chen <david6666666@users.noreply.github.com>
Co-authored-by: WeiQing Chen <david6666666@users.noreply.github.com>
2026-05-28 12:52:58 +00:00
nainiu258andHongsheng Liu 740ec608f4 [Docs]Add recipe for GLM-Image on 2x A800 GPUs and 1x A800 GPU (#2950)
Signed-off-by: nainiu258 <cperfect02@163.com>
Signed-off-by: nainiu258 <101917677+nainiu258@users.noreply.github.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-05-28 19:50:37 +08:00
Zeyu Huang | 黃澤宇 74258b18b7 [CI] Rectify L2~L4 Qwen Image Edit series tests (#3901) 2026-05-28 18:33:31 +08:00
208 changed files with 12701 additions and 3486 deletions

No files matched your search

+1 -1
View File
@@ -107,7 +107,7 @@ steps:
depends_on: upload-merge-pipeline
steps:
- label: "Entrypoint Test with H100"
timeout_in_minutes: 35
timeout_in_minutes: 60
commands:
- pytest -s -v tests/entrypoints/ -m "advanced_model and cuda and H100" --run-level "advanced_model"
agents:
+52 -60
View File
@@ -459,6 +459,52 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: ":full_moon: VoxCPM2 · Perf Test"
key: nightly-voxcpm2-performance
timeout_in_minutes: 180
commands:
- export BENCHMARK_DIR=tests/dfx/perf/results
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- |
set +e
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_voxcpm2.json
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
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
# Diffusion X2I suite: x2i / x2a / x2t and related non-video paths; x2v is only in "Diffusion X2V Model Test" below.
- group: ":card_index_dividers: Diffusion X2I(&A&T) Model Test"
key: nightly-diffusion-x2iat-group
@@ -624,7 +670,7 @@ steps:
- 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: 3
nvidia.com/gpu: 2
volumeMounts:
- name: devshm
mountPath: /dev/shm
@@ -843,8 +889,8 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image-Edit-2509"
key: nightly-diffusion-x2iat-performance-qwen-image-edit-2509
- label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image-Edit-2511"
key: nightly-diffusion-x2iat-performance-qwen-image-edit-2511
timeout_in_minutes: 180
commands:
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
@@ -852,7 +898,7 @@ steps:
- 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_qwen_image_edit_2509_vllm_omni.json
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_edit_2511_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"
@@ -987,60 +1033,6 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: ":full_moon: Diffusion X2I(&A&T) · HunyuanImage3 · DiT Perf Test"
key: nightly-hunyuan-image3-performance
soft_fail: true
timeout_in_minutes: 120
if: build.env("RUN_HUNYUAN_IMAGE3_PERF") == "1"
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_hunyuan_image_tp4_fp8.json
EXIT1=$$?
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_hunyuan_image_tp2_fp8_sp2.json
EXIT2=$$?
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_hunyuan_image_tp2_fp8_cfgp2.json
EXIT3=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$((EXIT1 | EXIT2 | EXIT3))
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: 4
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
# Diffusion x2v only (Wan, HunyuanVideo, …). x2i/x2a/x2t live in the X2I group above, not here.
- group: ":card_index_dividers: Diffusion X2V Model Test"
key: nightly-diffusion-x2v-group
@@ -1288,7 +1280,7 @@ steps:
- nightly-tts-performance
- nightly-diffusion-x2iat-performance-qwen-image
- nightly-diffusion-x2iat-performance-qwen-image-edit
- nightly-diffusion-x2iat-performance-qwen-image-edit-2509
- nightly-diffusion-x2iat-performance-qwen-image-edit-2511
- nightly-diffusion-x2iat-performance-qwen-image-layered
- nightly-diffusion-x2iat-performance-bagel
- nightly-diffusion-x2v-performance
@@ -1305,7 +1297,7 @@ steps:
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance-multi-replicas
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image-edit
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image-edit-2509
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image-edit-2511
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image-layered
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-bagel
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2v-performance
+1 -1
View File
@@ -183,7 +183,7 @@ steps:
depends_on: upload-ready-pipeline
commands:
- |
timeout 15m bash -c "
timeout 20m bash -c "
pytest -sv tests/distributed/ -m 'core_model and cuda' --run-level 'core_model'
"
agents:
View File
Whitespace-only changes.
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
#
# 2x2 timing matrix (backend x compile mode) for SAGE_ATTN_3 vs TORCH_SDPA,
# on HunyuanVideo-1.5 480p T2V and Wan 2.2 TI2V-5B. Run from the repo root:
#
# bash benchmarks/diffusion/bench_sage_attn3_matrix.sh
#
# Requires: sageattn3 installed (for SAGE_ATTN_3 rows) and a Blackwell GPU.
set -u
STEPS=${STEPS:-50}
SEED=${SEED:-42}
PROMPT=${PROMPT:-"A dog running across a field of golden wheat."}
OUT_DIR=${OUT_DIR:-/tmp/bench_sage3}
mkdir -p "$OUT_DIR"
declare -A TOTAL PER_STEP TEXT_ENC TRANSFORMER VAE
run() {
local name="$1"; shift
local model="$1"; shift
local h="$1"; shift
local w="$1"; shift
local f="$1"; shift
local g="$1"; shift
local log="$OUT_DIR/${name}.log"
echo "[$(date +%T)] Running $name ..."
env "$@" python examples/offline_inference/text_to_video/text_to_video.py \
--model "$model" \
--prompt "$PROMPT" \
--height "$h" --width "$w" --num-frames "$f" \
--num-inference-steps "$STEPS" --seed "$SEED" --guidance-scale "$g" \
--enable-diffusion-pipeline-profiler \
--output "$OUT_DIR/${name}.mp4" > "$log" 2>&1 || {
echo " FAILED — see $log"
TOTAL[$name]="FAIL"; PER_STEP[$name]="FAIL"
TEXT_ENC[$name]="-"; TRANSFORMER[$name]="-"; VAE[$name]="-"
return
}
local wait_ms
wait_ms=$(grep "add_req_and_wait=" "$log" | tail -1 | sed -nE 's/.*add_req_and_wait=([0-9.]+) ms.*/\1/p')
local total_s
total_s=$(grep -oE "Total generation time: [0-9.]+ seconds" "$log" | tail -1 | sed -nE 's/.*: ([0-9.]+) seconds.*/\1/p')
local text_enc_s
text_enc_s=$(grep -oE "text_encoder.forward took [0-9.]+s" "$log" | awk '{sub(/s$/,"",$NF); sum+=$NF} END {printf "%.3f", sum+0}')
local vae_s
vae_s=$(grep -oE "vae.decode took [0-9.]+s" "$log" | awk '{sub(/s$/,"",$NF); sum+=$NF} END {printf "%.3f", sum+0}')
local pipeline_s
pipeline_s=$(grep -oE "Pipeline\.forward took [0-9.]+s" "$log" | tail -1 | sed -nE 's/.*took ([0-9.]+)s.*/\1/p')
local transformer_s
transformer_s=$(awk "BEGIN {printf \"%.3f\", ${pipeline_s:-0} - ${vae_s:-0} - ${text_enc_s:-0}}")
TOTAL[$name]="${total_s:-0}"
PER_STEP[$name]=$(awk "BEGIN {printf \"%.3f\", ${wait_ms:-0} / 1000 / $STEPS}")
TEXT_ENC[$name]="$text_enc_s"
TRANSFORMER[$name]="$transformer_s"
VAE[$name]="$vae_s"
echo " total=${TOTAL[$name]}s per-step=${PER_STEP[$name]}s/it transformer=${TRANSFORMER[$name]}s vae=${VAE[$name]}s"
}
# ========== HunyuanVideo-1.5 480p T2V (480x832x33, CFG=6.0) ==========
HV_MODEL=hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v
run hv15_sdpa_eager "$HV_MODEL" 480 832 33 6.0 TORCH_COMPILE_DISABLE=1
run hv15_sdpa_compiled "$HV_MODEL" 480 832 33 6.0
run hv15_sage3_eager "$HV_MODEL" 480 832 33 6.0 TORCH_COMPILE_DISABLE=1 DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN_3
run hv15_sage3_compiled "$HV_MODEL" 480 832 33 6.0 DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN_3
# ========== Wan 2.2 TI2V-5B (704x1280x33, CFG=5.0) ==========
WAN_MODEL=Wan-AI/Wan2.2-TI2V-5B-Diffusers
run wan22_sdpa_eager "$WAN_MODEL" 704 1280 33 5.0 TORCH_COMPILE_DISABLE=1
run wan22_sdpa_compiled "$WAN_MODEL" 704 1280 33 5.0
run wan22_sage3_eager "$WAN_MODEL" 704 1280 33 5.0 TORCH_COMPILE_DISABLE=1 DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN_3
run wan22_sage3_compiled "$WAN_MODEL" 704 1280 33 5.0 DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN_3
print_table() {
local title="$1"; shift
echo
echo "$title (${STEPS} steps, seed=${SEED})"
echo
printf "| %-23s | %-9s | %-15s | %-12s | %-15s | %-14s |\n" \
"Config" "Total (s)" "Per-step (s/it)" "Text Enc (s)" "Transformer (s)" "VAE Decode (s)"
printf "| %-23s | %-9s | %-15s | %-12s | %-15s | %-14s |\n" \
"-----------------------" "---------" "---------------" "------------" "---------------" "--------------"
for row in "$@"; do
case "$row" in
*_sdpa_eager) label="SDPA + Eager" ;;
*_sdpa_compiled) label="SDPA + Compiled" ;;
*_sage3_eager) label="SAGE_ATTN_3 + Eager" ;;
*_sage3_compiled) label="SAGE_ATTN_3 + Compiled" ;;
esac
printf "| %-23s | %-9s | %-15s | %-12s | %-15s | %-14s |\n" \
"$label" "${TOTAL[$row]}" "${PER_STEP[$row]}" "${TEXT_ENC[$row]}" "${TRANSFORMER[$row]}" "${VAE[$row]}"
done
}
print_table "HunyuanVideo-1.5 480p T2V, 832x480x33" \
hv15_sdpa_eager hv15_sdpa_compiled hv15_sage3_eager hv15_sage3_compiled
print_table "Wan 2.2 TI2V-5B, 1280x704x33" \
wan22_sdpa_eager wan22_sdpa_compiled wan22_sage3_eager wan22_sage3_compiled
echo
echo "Logs: $OUT_DIR/*.log"
+3
View File
@@ -130,6 +130,9 @@ def build_bench_args(
if task_extra_body:
cmd += ["--extra-body", json.dumps(task_extra_body, separators=(",", ":"))]
if model_cfg.get("trust_remote_code"):
cmd.append("--trust-remote-code")
if concurrency is not None:
cmd += ["--max-concurrency", str(concurrency), "--request-rate", "inf"]
+7 -1
View File
@@ -14,6 +14,7 @@ models:
supported_tasks: [default_voice, voice_design]
backend: openai-audio-speech
endpoint: /v1/audio/speech
trust_remote_code: true
task_extra_body:
default_voice:
voice: Vivian
@@ -27,13 +28,18 @@ models:
supported_tasks: [voice_clone]
backend: openai-audio-speech
endpoint: /v1/audio/speech
trust_remote_code: true
task_extra_body:
voice_clone:
task_type: Base
openbmb/VoxCPM2:
supported_tasks: [voice_clone]
supported_tasks: [voice_clone, default_voice]
trust_remote_code: true
backend: openai-audio-speech
endpoint: /v1/audio/speech
trust_remote_code: true
task_extra_body:
voice_clone: {}
default_voice:
voice: default
+4 -1
View File
@@ -1,5 +1,8 @@
# Target vllm version: v0.21.0 (releases/v0.21.0 branch).
# The pinned VLLM_PRECOMPILED_WHEEL_COMMIT and VLLM_BASE_TAG below must
# be updated on every rebase to match the new upstream vllm commit/tag.
ARG VLLM_BASE_IMAGE=vllm/vllm-openai
ARG VLLM_BASE_TAG=v0.21.0
ARG VLLM_BASE_TAG=v0.22.0
FROM ${VLLM_BASE_IMAGE}:${VLLM_BASE_TAG}
ARG APP_DIR=/workspace/vllm-omni
WORKDIR ${APP_DIR}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 408 KiB

+1 -1
View File
@@ -47,7 +47,7 @@ th {
| `LongcatImagePipeline` | LongCat-Image | `meituan-longcat/LongCat-Image` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | ✅︎ | ✅︎ | | ✅︎ |
| `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | |
| `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | ✅︎ |
| `MammothModa2ForConditionalGeneration` | MammothModa2-Preview | `bytedance-research/MammothModa2-Preview` | ✅︎ | ✅︎ | | |
| `Flux2KleinPipeline` | FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B`, `black-forest-labs/FLUX.2-klein-9B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `FluxKontextPipeline` | FLUX.1-Kontext-dev | `black-forest-labs/FLUX.1-Kontext-dev` | ✅︎ | ✅︎ | | |
+38
View File
@@ -381,6 +381,44 @@ are cached in-process with a shared LRU so repeated requests with the same
all TTS model types; deleting a voice invalidates every model-type slot at
once.
### Precomputed Custom Voices
Qwen3-TTS Base and VoxCPM2 can load offline-precomputed voices at startup.
Generate a directory containing `custom_voice_manifest.json` plus one
`.safetensors` file per voice, then set the pipeline-wide deploy config field:
```yaml
custom_voice_dir: /path/to/custom_voices
```
Qwen3-TTS profiles are created with:
```bash
python examples/online_serving/text_to_speech/qwen3_tts/precompute_custom_voice.py \
--model Qwen/Qwen3-TTS-12Hz-1.7B-Base \
--voice-name alice \
--ref-audio /path/to/reference.wav \
--ref-text "Original transcript of the reference audio" \
--mode icl \
--output-dir /path/to/custom_voices
```
VoxCPM2 profiles are created with:
```bash
python examples/online_serving/text_to_speech/voxcpm2/precompute_custom_voice.py \
--model openbmb/VoxCPM2 \
--voice-name alice \
--ref-audio /path/to/reference.wav \
--mode ref_continuation \
--prompt-text "Original transcript of the reference audio" \
--output-dir /path/to/custom_voices
```
Only profiles whose safetensors payload can be loaded and validated are exposed
by `GET /v1/audio/voices`. Valid precomputed voices can be used in
`POST /v1/audio/speech` by passing `voice="alice"` without `ref_audio`.
**Configuration (environment variables):**
| Variable | Default | Description |
@@ -19,6 +19,8 @@ The full set of backends and their platform defaults is in the **Backend Options
| `FLASHINFER_ATTN` | Calls FlashInfer's dense `single_prefill_with_kv_cache` directly with `custom_mask` for non-causal masked attention. Used as Blackwell fallback when cuDNN is unavailable. Requires `flashinfer`. |
| `TORCH_SDPA` | PyTorch `scaled_dot_product_attention` with the default backend dispatcher. Most conservative; always available. |
| `SAGE_ATTN` | SageAttention 2.2 — INT8-quantized attention with FP16 accumulation. Lossy but typically visually indistinguishable on diffusion outputs. Requires `sageattention`. |
| `SAGE_ATTN_3` | Requires `sageattn3` from `SageAttention/sageattention3_blackwell`. CUDA only, intended for Blackwell GPUs, with GQA/MQA requests falling back to PyTorch SDPA. |
## Configuration
@@ -185,6 +187,29 @@ Quick check:
python -c "import sageattention; print(sageattention.__file__)"
```
## SageAttention3 Installation
vLLM-Omni expects SageAttention3 to be installed into the same Python environment as vLLM-Omni.
Build from source:
```bash
git clone https://github.com/thu-ml/SageAttention.git
cd SageAttention/sageattention3_blackwell
python setup.py install
```
Quick check:
```bash
python -c "import sageattn3; print(sageattn3.__file__)"
```
Notes:
- `SAGE_ATTN_3` is only selected on CUDA when `sageattn3` is importable and the GPU is Blackwell-class.
- SageAttention3's Blackwell kernel assumes `Hq == Hkv`. In vLLM-Omni, GQA/MQA diffusion requests fall back to PyTorch SDPA for correctness.
## Usage Examples
### Default (auto-route)
@@ -235,6 +260,20 @@ DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN python examples/offline_inference/text_to_
--output outputs/wan22_sage.mp4
```
### Enable SageAttention3
Example:
```bash
DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN_3 python examples/offline_inference/text_to_video/text_to_video.py \
--model hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v \
--prompt "A dog running across a field of golden wheat." \
--height 480 --width 832 --num-frames 33 \
--num-inference-steps 30 --seed 42 --guidance-scale 6.0 \
--tensor-parallel-size 2 \
--output outputs/hv15_sage3.mp4
```
### Mixed backends across roles
Use `FLASH_ATTN` for self-attention and `TORCH_SDPA` for cross-attention:
+2 -2
View File
@@ -129,7 +129,7 @@ The following tables show which models support each feature:
| **Qwen-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Qwen-Image-Edit-2509** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ (decode) | ✅ | ❌ | ❌ |
| **Qwen-Image-Layered** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **SenseNova-U1** | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **SenseNova-U1** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **Stable-Diffusion3.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Z-Image** | ✅ | ✅ | ✅ | ❓ | ✅ (TP=2 only) | ❌ | ✅ | ❌ | ✅ (decode) | ✅ | ❌ |
| **ERNIE-Image** | ❌ | ✅ | ✅ | ❓ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
@@ -147,7 +147,7 @@ The following tables show which models support each feature:
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ✅ | ❌ |
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
**Frame Interpolation Support**
@@ -329,6 +329,16 @@ Single-GPU serves now default to the uniproc executor (lower IPC overhead, the B
To opt out of chunked streaming, pass `--no-async-chunk` — the pipeline auto-dispatches to the end-to-end codec processor.
### Tuning stage 1 `max_num_seqs` per task type
The bundled `qwen3_tts.yaml` ships stage 1 (Code2Wav) at `max_num_seqs: 10`, tuned for Base voice cloning: stage-1 lifetimes are long (~3 s/req), so admitting up to 10 concurrent codec sequences lets requests progress in parallel in the scheduler — ~2× TTFA p95 at c=4 / c=8 (1× H100, 1.7B-Base, seed-tts) at an 8–12 % audio-throughput cost.
CustomVoice / VoiceDesign have much shorter stage-1 lifetimes (~50–200 ms) and are TTFA-optimal at `max_num_seqs: 1`. Override the default when serving those task types:
```bash
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base --omni \
--stage-overrides '{"1": {"max_num_seqs": 1}}'
```
### Sending requests
```bash
# CustomVoice with a predefined speaker
@@ -370,6 +380,31 @@ curl -X POST http://localhost:8091/v1/audio/voices \
```
Uploaded voices are then usable as `voice="custom_voice_1"` on subsequent requests.
### Precomputed custom voices
For reused Base voice-cloning speakers, precompute the reference artifacts once and load them at server startup:
```bash
python qwen3_tts/precompute_custom_voice.py \
--model Qwen/Qwen3-TTS-12Hz-1.7B-Base \
--voice-name alice \
--ref-audio /path/to/reference.wav \
--ref-text "Original transcript of the reference audio" \
--mode icl \
--output-dir /path/to/custom_voices
```
`--mode icl` stores both `speaker_embedding` and `ref_code`; `--mode xvec` stores only the speaker embedding. Add the output directory to a deploy config:
```yaml
custom_voice_dir: /path/to/custom_voices
```
Then start the server with that config and call the Speech API with only the voice name:
```bash
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base --omni --deploy-config /path/to/qwen3_tts_custom_voice.yaml
curl -X POST http://localhost:8091/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"input":"Hello from a precomputed voice.","voice":"alice","task_type":"Base"}' \
--output alice.wav
```
### Streaming PCM
```bash
curl -X POST http://localhost:8091/v1/audio/speech \
@@ -409,7 +444,8 @@ python qwen3_tts/gradio_fastrtc_demo.py --api-base http://localhost:8000
`qwen3_tts/batch_speech_client.py` issues many concurrent requests for throughput measurement.
### Notes
- Base voice cloning has per-request reference-audio cost; the uniproc default keeps IPC overhead off the critical path. See the executor-backend section above for background.
- Base voice cloning has uniproc-vs-mp tradeoffs depending on per-request reference audio cost; see the executor-backend section above.
- With async chunking, Qwen3-TTS Base voice cloning sends the full reference context in the first Code2Wav packet, then caches that prefix on the Code2Wav stage for follow-up chunks in the same request.
- `vllm_omni/deploy/qwen3_tts.yaml` is the default deploy config (loaded by HF `model_type`); per-stage runtime overrides are available via `--stage-N-<field> <value>`.
---
@@ -438,6 +474,23 @@ python voxcpm2/openai_speech_client.py \
```
The `ref_audio` field accepts local file paths (auto-base64), HTTP URLs, or `data:audio/wav;base64,...` data URIs.
### Precomputed custom voices
For repeated VoxCPM2 speakers, precompute the prompt cache and load it through `custom_voice_dir`:
```bash
python voxcpm2/precompute_custom_voice.py \
--model openbmb/VoxCPM2 \
--voice-name alice \
--ref-audio /path/to/reference.wav \
--mode ref_continuation \
--prompt-text "Original transcript of the reference audio" \
--output-dir /path/to/custom_voices
```
Add the output directory to the deploy config:
```yaml
custom_voice_dir: /path/to/custom_voices
```
After startup, `/v1/audio/voices` lists `alice`, and `/v1/audio/speech` can use `voice="alice"` without sending `ref_audio`.
### Gradio demo (gapless streaming via AudioWorklet)
```bash
python voxcpm2/gradio_demo.py
+1 -1
View File
@@ -74,7 +74,7 @@ Online:
vllm serve Qwen/Qwen-Image \
--omni \
--port 8000 \
--quantization-config '{"method":"gguf","gguf_model":"QuantStack/Qwen-Image-GGUF/Qwen_Image-Q4_K_M.gguf"}'
--diffusion-quantization-config '{"method":"gguf","gguf_model":"QuantStack/Qwen-Image-GGUF/Qwen_Image-Q4_K_M.gguf"}'
```
## Parameters
@@ -61,8 +61,8 @@ def parse_args():
parser.add_argument("--steps", type=int, default=50, help="Number of inference steps.")
parser.add_argument("--guidance-scale", type=float, default=5.0, help="Classifier-free guidance scale.")
parser.add_argument("--seed", type=int, default=42, help="Random seed.")
parser.add_argument("--height", type=int, default=1024, help="Output image height.")
parser.add_argument("--width", type=int, default=1024, help="Output image width.")
parser.add_argument("--height", type=int, default=None, help="Output image height.")
parser.add_argument("--width", type=int, default=None, help="Output image width.")
parser.add_argument("--vae-use-tiling", action="store_true", help="Enable VAE tiling.")
parser.add_argument(
"--bot-task",
@@ -229,7 +229,21 @@ def main():
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
ar_stop_token_ids = resolve_stop_token_ids(task=task, bot_task=bot_task, tokenizer=tokenizer)
if (args.height is None) != (args.width is None):
raise ValueError("--height and --width must both be specified or both omitted.")
user_specified_size = args.height is not None and args.width is not None
if args.modality in ("img2text", "text2text"):
ar_image_size = "auto"
elif user_specified_size:
ar_image_size = f"{args.width}x{args.height}"
else:
ar_image_size = None
ar_stop_token_ids = resolve_stop_token_ids(
task=task, bot_task=bot_task, tokenizer=tokenizer, image_size=ar_image_size
)
print(
f"[AR Config] task={task}, bot_task={bot_task}, image_size={ar_image_size}, stop_token_ids={ar_stop_token_ids}"
)
for sp in params_list:
if isinstance(sp, OmniDiffusionSamplingParams):
sp.num_inference_steps = args.steps
@@ -8,7 +8,7 @@ vLLM-Omni supports three deployment modes:
|------|--------------|--------|
| Thinker + Talker (omni-speech, default) | `vllm_omni/deploy/ming_flash_omni.yaml` | Text + Audio |
| Thinker only (multimodal understanding) | `vllm_omni/deploy/ming_flash_omni_thinker_only.yaml` | Text |
| Thinker + Imagegen (text-to-image / img2img) | `vllm_omni/model_executor/stage_configs/ming_flash_omni_dual.yaml` | Image (online-serving only at the moment) |
| Thinker + Imagegen (text-to-image / img2img) | `vllm_omni/deploy/ming_flash_omni_image.yaml` | Image (online-serving only at the moment) |
For standalone TTS (talker only), see the [Ming-flash-omni-TTS section in the Text-To-Speech hub](../text_to_speech/README.md#ming-flash-omni-tts).
@@ -37,6 +37,10 @@ python end2end.py --modality text2text \
# Custom resolution (image generation)
python end2end.py --prompt "A futuristic cityscape at sunset" \
--width 2048 --height 1024 --think
# Text-to-image with Cache-DiT acceleration
python end2end.py --prompt "A cute cat" \
--cache-backend cache_dit \
```
> **Note**: Default configuration works on a single **NVIDIA A100 (80GB)** or **H100** GPU.
@@ -135,6 +139,8 @@ python end2end.py \
| `--tensor-parallel-size` | 1 | Number of GPUs for tensor parallelism |
| `--enforce-eager` | False | Disable torch.compile |
| `--enable-cpu-offload` | False | Enable module-wise (sequential) CPU offload to reduce peak VRAM |
| `--cache-backend` | None | Set to `cache_dit` for Cache-DiT acceleration |
| `--enable-cache-dit-summary` | False | Print Cache-DiT cache statistics after generation |
## Reducing GPU Memory Usage
@@ -155,6 +155,18 @@ def parse_args():
"of extra CPU<->GPU transfers."
),
)
parser.add_argument(
"--cache-backend",
type=str,
default=None,
choices=["cache_dit"],
help="Cache backend to use for image generation acceleration.",
)
parser.add_argument(
"--enable-cache-dit-summary",
action="store_true",
help="Enable cache-dit summary logging after diffusion forward passes.",
)
nullify_stage_engine_defaults(parser)
return parser.parse_args()
@@ -182,6 +194,8 @@ def main():
tensor_parallel_size=args.tensor_parallel_size,
enforce_eager=args.enforce_eager,
enable_cpu_offload=args.enable_cpu_offload,
cache_backend=args.cache_backend,
enable_cache_dit_summary=args.enable_cache_dit_summary,
)
extra_args = {
@@ -225,6 +239,7 @@ def main():
print(f" Seed : {args.seed}")
print(f" Think mode : {args.think}")
print(f" TP size : {args.tensor_parallel_size}")
print(f" Cache backend : {args.cache_backend or 'none'}")
print(f"{'=' * 60}\n")
# Build prompt dict
@@ -271,6 +271,14 @@ python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
--lang zh
```
### Seed for Reproducibility
```bash
python examples/offline_inference/text_to_speech/omnivoice/end2end.py \
--model k2-fsa/OmniVoice \
--text "Hello, this is a test." \
--seed 42
```
### Notes
- Stage 0 (Generator): Qwen3-0.6B with 32-step iterative unmasking.
- Stage 1 (Decoder): HiggsAudioV2 RVQ + DAC at 24 kHz.
@@ -80,6 +80,12 @@ def run_e2e():
default=600,
help="Stage initialization timeout in seconds",
)
parser.add_argument(
"--seed",
type=int,
default=None,
help="Random seed for generation",
)
nullify_stage_engine_defaults(parser)
args = parser.parse_args()
@@ -122,7 +128,7 @@ def run_e2e():
if mm_processor_kwargs:
prompts["mm_processor_kwargs"] = mm_processor_kwargs
sampling_params_list = [OmniDiffusionSamplingParams()]
sampling_params_list = [OmniDiffusionSamplingParams(extra_args={"seed": args.seed})]
print(f"Generating speech for: {args.text}")
@@ -43,8 +43,8 @@ def _estimate_prompt_len(
"""
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,
from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import (
Qwen3TTSPromptEmbedsBuilder,
)
if model_name not in _cache:
@@ -124,7 +124,7 @@ def _estimate_prompt_len(
except Exception:
return None
return Qwen3TTSTalkerForConditionalGeneration.estimate_prompt_len_from_additional_information(
return Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
additional_information=additional_information,
task_type=task_type,
tokenize_prompt=lambda t: tok(t, padding=False)["input_ids"],
@@ -10,7 +10,7 @@ Please refer to [README.md](../../../README.md)
|------|---------------|--------|
| Thinker + Talker (omni-speech, default) | `vllm serve ... --omni` | Text + Audio |
| Thinker only (multimodal understanding) | `vllm serve ... --omni --deploy-config vllm_omni/deploy/ming_flash_omni_thinker_only.yaml` | Text |
| Thinker + Imagegen (text-to-image / img2img) | `vllm serve ... --omni --stage-configs-path vllm_omni/model_executor/stage_configs/ming_flash_omni_dual.yaml` | Image |
| Thinker + Imagegen (text-to-image / img2img) | `vllm serve ... --omni --deploy-config vllm_omni/deploy/ming_flash_omni_image.yaml` | Image |
For standalone TTS (talker only), see the [Ming-flash-omni-TTS section in the Text-To-Speech hub](../text_to_speech/README.md#ming-flash-omni-tts).
@@ -63,34 +63,27 @@ bash run_curl_multimodal_generation.sh use_image_gen
## Image generation (text-to-image)
Ming-flash-omni-2.0 also exposes an image-generation (diffusion) stage.
Launch with the dual-stage YAML, which adds an image-gen stage behind the
thinker:
Launch with the image deploy YAML, which adds an image-gen stage behind
the thinker:
```bash
vllm serve Jonathan1909/Ming-flash-omni-2.0 --omni \
--stage-configs-path vllm_omni/model_executor/stage_configs/ming_flash_omni_dual.yaml \
--deploy-config vllm_omni/deploy/ming_flash_omni_image.yaml \
--stage-init-timeout 1800 \
--port 8188
--init-timeout 1800 \
--port 8091
```
Then request image output by passing `"modalities": ["image"]`:
```bash
curl http://127.0.0.1:8188/v1/chat/completions \
curl -s http://127.0.0.1:8091/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "Jonathan1909/Ming-flash-omni-2.0",
"messages": [{"role": "user", "content": "Draw a beautiful girl with short black hair and red dress."}],
"messages": [{"role": "user", "content": "Please draw a cute cat."}],
"modalities": ["image"]
}' -o /tmp/ming_response.json
python - <<'PY'
import base64, json
r = json.load(open('/tmp/ming_response.json'))
url = r['choices'][0]['message']['content'][0]['image_url']['url']
open('/tmp/ming_gen.png', 'wb').write(base64.b64decode(url.split(',', 1)[1]))
print('saved /tmp/ming_gen.png')
PY
}' | jq -r '.choices[0].message.content[0].image_url.url | split(",")[1]' | base64 -d > ming_imagegen.png
```
### Optional knobs
@@ -109,7 +102,7 @@ Pass image-gen overrides via the diffusion-stage `sampling_params_list[1].extra_
Example with all the knobs:
```bash
curl http://127.0.0.1:8188/v1/chat/completions \
curl http://127.0.0.1:8091/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "Jonathan1909/Ming-flash-omni-2.0",
@@ -122,7 +115,7 @@ curl http://127.0.0.1:8188/v1/chat/completions \
"byte5_text":["理解与生成统一"]}}}
],
"messages": [{"role": "user", "content": "Draw a poster."}]
}' -o /tmp/ming_response.json
}' | jq -r '.choices[0].message.content[0].image_url.url | split(",")[1]' | base64 -d > ming_imagegen_knobs.png
```
### img2img (reference image)
@@ -142,9 +135,9 @@ into the diffusion stage as `extra[reference_image]`:
### GPU layout
The shipped `ming_flash_omni_dual.yaml` allocates the thinker on GPUs 0–3
The shipped `ming_flash_omni_image.yaml` allocates the thinker on GPUs 0–3
(TP=4) and the diffusion stage on GPU 4 (TP=1). Copy the YAML and edit
`runtime.devices` per stage to relocate; with fewer GPUs available, drop
`devices` per stage to relocate; with fewer GPUs available, drop
the thinker TP to 2 and run the diffusion stage on a free card. Image-gen
warmup takes roughly an extra 30–60 s on top of the thinker — set
`--stage-init-timeout 1800` if the default 300 s is too tight.
@@ -156,7 +149,7 @@ warmup takes roughly an extra 30–60 s on top of the thinker — set
| `["text"]` or omitted | Thinker only | Text |
| `["audio"]` | Thinker + Talker | Audio (speech) |
| `["text", "audio"]` | Thinker + Talker | Text + Audio |
| `["image"]` | Thinker + Imagegen (dual YAML) | Image (PNG, base64 in `choices[0].message.content`) |
| `["image"]` | Thinker + Imagegen (image deploy YAML) | Image (PNG, base64 in `choices[0].message.content`) |
For ready-to-copy curl examples (text / audio / multimodal input, SSE
streaming, reasoning mode), see the recipe at
@@ -103,7 +103,7 @@ echo ""
if [[ "$QUERY_TYPE" == "use_image_gen" ]]; then
# Image-gen branch: dual-stage diffusion endpoint, modalities=["image"].
# The shipped ming_flash_omni_dual.yaml runs the thinker on cards 0-3
# The shipped ming_flash_omni_image.yaml runs the thinker on cards 0-3
# (TP=4) and the diffusion stage on card 4 (TP=1). See the image-gen
# section in README.md for knobs and img2img.
out_path="${OUT_PATH:-/tmp/ming_imagegen.png}"
+1 -5
View File
@@ -18,10 +18,6 @@ Asynchronous chunk streaming operates as **enabled by default** within this bund
Additionally, NPU, ROCm, and XPU per-platform configuration deltas are deterministically merged from the
`platforms`: section of the corresponding YAML.
**Note:** The OpenAI-style **`/v1/realtime`** WebSocket interface (facilitating streaming PCM audio input alongside audio and transcription output)
is currently **unsupported** while the `async_chunk` configuration attribute is enabled.
It is requisite to instantiate the default omni architecture or utilize a deployment configuration specifying `async_chunk: false` to facilitate real-time streaming sessions.
To explicitly utilize a custom deployment YAML, mandate the configuration path accordingly:
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
@@ -289,7 +285,7 @@ python openai_realtime_client.py \
| `--num-requests` | `1` | Number of sequential sessions (see `--concurrency`) |
| `--concurrency` | `1` | Max concurrent WebSocket sessions when `--num-requests` > 1 |
Ensure the server is running **without** `async_chunk` if you use `/v1/realtime`, for example:
Ensure the server is running, for example:
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
@@ -6,11 +6,21 @@
vllm serve SenseNova/SenseNova-U1-8B-MoT --omni --port 8091
```
With Cache-DiT acceleration:
```bash
vllm serve SenseNova/SenseNova-U1-8B-MoT --omni --port 8091 \
--cache-backend cache_dit
```
Or use the convenience script:
```bash
cd examples/online_serving/sensenova_u1
bash run_server.sh
# Cache-DiT
CACHE_BACKEND=cache_dit bash run_server.sh
```
### Tensor Parallelism (TP)
@@ -3,10 +3,18 @@
MODEL="${MODEL:-SenseNova/SenseNova-U1-8B-MoT}"
PORT="${PORT:-8091}"
CACHE_BACKEND="${CACHE_BACKEND:-none}"
echo "Starting SenseNova-U1 server..."
echo "Model: $MODEL"
echo "Port: $PORT"
echo "Cache backend: $CACHE_BACKEND"
EXTRA_ARGS=()
if [ "$CACHE_BACKEND" != "none" ]; then
EXTRA_ARGS+=(--cache-backend "$CACHE_BACKEND")
fi
vllm serve "$MODEL" --omni \
--port "$PORT"
--port "$PORT" \
"${EXTRA_ARGS[@]}"
@@ -18,7 +18,7 @@ For the full list of supported architectures across all modalities, see
| GLM-TTS | `zai-org/GLM-TTS` | ✓ (`ref_audio`+`ref_text`, required) | ✓ (PCM stream) | — | ✓ |
| Ming-flash-omni-TTS | `Jonathan1909/Ming-flash-omni-2.0` | — (caption-controlled) | — | caption fields (`instructions`) | — |
| MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | ✓ (`ref_audio` required) | ✓ (PCM stream) | — | ✓ |
| OmniVoice | `k2-fsa/OmniVoice` | (offline only) | — | — | — |
| OmniVoice | `k2-fsa/OmniVoice` | ✓ | — | — | — |
| Qwen3-TTS | `Qwen/Qwen3-TTS-12Hz-1.7B-{CustomVoice,VoiceDesign,Base}` | ✓ (Base) | ✓ (PCM + WebSocket) | ✓ (presets + `/v1/audio/voices` upload) | ✓ (standard + FastRTC) |
| VoxCPM2 | `openbmb/VoxCPM2` | ✓ | ✓ (AudioWorklet via gradio) | — | ✓ |
| Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | ✓ (gated upstream) | ✓ | ✓ (presets) | ✓ |
@@ -321,16 +321,29 @@ vllm serve k2-fsa/OmniVoice --omni --port 8091 --trust-remote-code
### CLI client
```bash
cd examples/online_serving/text_to_speech/omnivoice
# Text-only (auto voice)
python speech_client.py --text "Hello, how are you?"
# Language hint
python speech_client.py --text "Bonjour, comment allez-vous?" --language French
# Voice cloning (reference audio + optional ref_text)
python speech_client.py \
--text "Bonjour, comment allez-vous?" \
--ref-audio /path/to/ref_audio.wav \
--ref-text "Bonjour, comment allez-vous?"
# Style instruction (voice design-style control)
python speech_client.py \
--text "Bonjour, comment allez-vous?" \
--language French \
--instructions "loud voice"
# Deterministic output with seed parameter
python speech_client.py --text "Hello, how are you?" --seed 42
```
The client supports `--api-base`, `--model`, `--text`, `--response-format`, `--language`, `--output`.
The client supports `--api-base`, `--model`, `--text`, `--response-format`, `--language`, `--voice`, `--ref-audio`, `--ref-text`, `--instructions`, `--seed`, and `--output`.
### Notes
- Voice cloning and voice design require offline inference; see the [offline OmniVoice section](../../offline_inference/text_to_speech/README.md#omnivoice).
---
## Qwen3-TTS
@@ -399,6 +412,31 @@ curl -X POST http://localhost:8091/v1/audio/voices \
```
Uploaded voices are then usable as `voice="custom_voice_1"` on subsequent requests.
### Precomputed custom voices
For reused Base voice-cloning speakers, precompute the reference artifacts once and load them at server startup:
```bash
python qwen3_tts/precompute_custom_voice.py \
--model Qwen/Qwen3-TTS-12Hz-1.7B-Base \
--voice-name alice \
--ref-audio /path/to/reference.wav \
--ref-text "Original transcript of the reference audio" \
--mode icl \
--output-dir /path/to/custom_voices
```
`--mode icl` stores both `speaker_embedding` and `ref_code`; `--mode xvec` stores only the speaker embedding. Add the output directory to a deploy config:
```yaml
custom_voice_dir: /path/to/custom_voices
```
Then start the server with that config and call the Speech API with only the voice name:
```bash
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base --omni --deploy-config /path/to/qwen3_tts_custom_voice.yaml
curl -X POST http://localhost:8091/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"input":"Hello from a precomputed voice.","voice":"alice","task_type":"Base"}' \
--output alice.wav
```
### Streaming PCM
```bash
curl -X POST http://localhost:8091/v1/audio/speech \
@@ -434,7 +472,8 @@ python qwen3_tts/streaming_speech_client.py --text "..." --simulate-stt --stt-de
`qwen3_tts/batch_speech_client.py` issues many concurrent requests for throughput measurement.
### Notes
- Base voice cloning has per-request reference-audio cost; the uniproc default keeps IPC overhead off the critical path. See the executor-backend section above for background.
- Base voice cloning has uniproc-vs-mp tradeoffs depending on per-request reference audio cost; see the executor-backend section above.
- With async chunking, Qwen3-TTS Base voice cloning sends the full reference context in the first Code2Wav packet, then caches that prefix on the Code2Wav stage for follow-up chunks in the same request.
- `vllm_omni/deploy/qwen3_tts.yaml` is the default deploy config (loaded by HF `model_type`); per-stage runtime overrides are available via `--stage-N-<field> <value>`.
---
@@ -461,6 +500,23 @@ python voxcpm2/openai_speech_client.py \
```
The `ref_audio` field accepts local file paths (auto-base64), HTTP URLs, or `data:audio/wav;base64,...` data URIs.
### Precomputed custom voices
For repeated VoxCPM2 speakers, precompute the prompt cache and load it through `custom_voice_dir`:
```bash
python voxcpm2/precompute_custom_voice.py \
--model openbmb/VoxCPM2 \
--voice-name alice \
--ref-audio /path/to/reference.wav \
--mode ref_continuation \
--prompt-text "Original transcript of the reference audio" \
--output-dir /path/to/custom_voices
```
Add the output directory to the deploy config:
```yaml
custom_voice_dir: /path/to/custom_voices
```
After startup, `/v1/audio/voices` lists `alice`, and `/v1/audio/speech` can use `voice="alice"` without sending `ref_audio`.
### Gradio demo (gapless streaming via AudioWorklet)
```bash
python voxcpm2/gradio_demo.py
@@ -12,6 +12,8 @@ Examples:
"""
import argparse
import base64
import os
import httpx
@@ -19,6 +21,24 @@ DEFAULT_API_BASE = "http://localhost:8091"
DEFAULT_API_KEY = "EMPTY"
def encode_audio_to_base64(audio_path: str) -> str:
"""Encode a local audio file to a base64 data URL."""
if not os.path.exists(audio_path):
raise FileNotFoundError(f"Audio file not found: {audio_path}")
ext = audio_path.lower().rsplit(".", 1)[-1]
mime = {
"wav": "audio/wav",
"mp3": "audio/mpeg",
"flac": "audio/flac",
"ogg": "audio/ogg",
}.get(ext, "audio/wav")
with open(audio_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
return f"data:{mime};base64,{b64}"
def run_tts(args) -> None:
"""Generate speech via /v1/audio/speech API."""
payload = {
@@ -26,16 +46,36 @@ def run_tts(args) -> None:
"input": args.text,
"response_format": args.response_format,
}
if args.seed is not None:
payload["extra_params"] = {}
payload["extra_params"]["seed"] = args.seed
if args.voice:
payload["voice"] = args.voice
if args.language:
payload["language"] = args.language
if args.ref_audio:
ref = args.ref_audio
if ref.startswith(("http://", "https://", "data:")):
payload["ref_audio"] = ref
else:
payload["ref_audio"] = encode_audio_to_base64(ref)
if args.ref_text:
payload["ref_text"] = args.ref_text
if args.instructions:
payload["instructions"] = args.instructions
print(f"Model: {args.model}")
print(f"Text: {args.text}")
if args.seed:
print(f"Seed: {args.seed}")
if args.voice:
print(f"Voice: {args.voice}")
if args.language:
print(f"Language: {args.language}")
print("Generating audio...")
@@ -45,7 +85,6 @@ def run_tts(args) -> None:
"Content-Type": "application/json",
"Authorization": f"Bearer {args.api_key}",
}
with httpx.Client(timeout=300.0) as client:
response = client.post(api_url, json=payload, headers=headers)
@@ -86,6 +125,30 @@ def main():
choices=["wav", "mp3", "flac", "pcm", "aac", "opus"],
help="Audio format (default: wav)",
)
parser.add_argument(
"--ref-audio",
type=str,
default=None,
help="Reference audio for voice cloning (local path, URL, or data: URI)",
)
parser.add_argument(
"--ref-text",
type=str,
default=None,
help="Reference text for voice cloning",
)
parser.add_argument(
"--instructions",
type=str,
default=None,
help="Voice style/emotion instructions",
)
parser.add_argument(
"--seed",
type=int,
default=None,
help="Random seed for generation, default: None for stochastic output)",
)
parser.add_argument("--output", "-o", default=None, help="Output file path")
args = parser.parse_args()
run_tts(args)
@@ -0,0 +1,243 @@
"""Pre-compute Qwen3-TTS custom voice profiles.
The generated directory can be passed to the server via
``custom_voice_dir`` in ``vllm_omni/deploy/qwen3_tts.yaml``. Requests can then
use ``/v1/audio/speech`` with ``voice="<name>"`` and no per-request ref_audio.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
import numpy as np
import torch
from safetensors import safe_open
from safetensors.torch import save_file
REPO_ROOT = Path(__file__).resolve().parents[4]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from vllm_omni.utils.custom_voice_io import safe_voice_stem # noqa: E402
MANIFEST_NAME = "custom_voice_manifest.json"
_SPEAKER_ENCODER_SAMPLE_RATE = 24000
_SPEAKER_ENCODER_NUM_MELS = 128
_SPEAKER_ENCODER_N_FFT = 1024
_SPEAKER_ENCODER_HOP_SIZE = 256
_SPEAKER_ENCODER_WIN_SIZE = 1024
_SPEAKER_ENCODER_FMIN = 0
_SPEAKER_ENCODER_FMAX = 12000
def _resolve_model_dir(model: str) -> str:
if os.path.isdir(model):
return model
from transformers.utils.hub import cached_file
config_path = cached_file(model, "config.json")
if config_path is None:
raise FileNotFoundError(f"Could not resolve config.json for {model}")
return os.path.dirname(config_path)
def _read_audio_mono(path: str) -> tuple[np.ndarray, int]:
import soundfile as sf
wav, sr = sf.read(path, dtype="float32")
if wav.ndim > 1:
wav = wav.mean(axis=-1)
return np.asarray(wav, dtype=np.float32), int(sr)
def _resample(wav: np.ndarray, sr: int, target_sr: int) -> np.ndarray:
if sr == target_sr:
return wav.astype(np.float32)
from vllm.multimodal.audio import AudioResampler
return AudioResampler(target_sr=target_sr).resample(wav.astype(np.float32), orig_sr=int(sr))
def _load_speaker_encoder(model_dir: str, device: torch.device):
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 Qwen3TTSSpeakerEncoder
config = Qwen3TTSConfig.from_pretrained(model_dir)
encoder = Qwen3TTSSpeakerEncoder(config.speaker_encoder_config)
index_path = Path(model_dir) / "model.safetensors.index.json"
state: dict[str, torch.Tensor] = {}
if index_path.exists():
index = json.loads(index_path.read_text(encoding="utf-8"))
weight_map = index.get("weight_map", {})
shard_names = sorted({v for k, v in weight_map.items() if k.startswith("speaker_encoder.")})
shard_paths = [Path(model_dir) / name for name in shard_names]
else:
shard_paths = sorted(Path(model_dir).glob("model*.safetensors"))
for shard in shard_paths:
with safe_open(str(shard), framework="pt", device="cpu") as f:
for key in f.keys():
if key.startswith("speaker_encoder."):
state[key.removeprefix("speaker_encoder.")] = f.get_tensor(key)
if not state:
raise RuntimeError(
f"No speaker_encoder.* tensors found under {model_dir}. "
"Use a Qwen3-TTS Base checkpoint for pre-computing custom voice profiles."
)
encoder.load_state_dict(state)
encoder.to(device=device, dtype=torch.bfloat16).eval()
return config, encoder
def _speaker_embedding(
encoder: torch.nn.Module,
speaker_encoder_config: object,
wav: np.ndarray,
sr: int,
device: torch.device,
) -> torch.Tensor:
from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import mel_spectrogram
target_sr = int(getattr(speaker_encoder_config, "sample_rate", _SPEAKER_ENCODER_SAMPLE_RATE))
mel_dim = int(getattr(speaker_encoder_config, "mel_dim", _SPEAKER_ENCODER_NUM_MELS))
if target_sr != _SPEAKER_ENCODER_SAMPLE_RATE or mel_dim != _SPEAKER_ENCODER_NUM_MELS:
raise ValueError(
"Unsupported Qwen3-TTS speaker encoder mel config for precompute: "
f"sample_rate={target_sr}, mel_dim={mel_dim}. "
f"Expected sample_rate={_SPEAKER_ENCODER_SAMPLE_RATE}, mel_dim={_SPEAKER_ENCODER_NUM_MELS} "
"to match the serving-time speaker embedding path."
)
wav = _resample(wav, sr, target_sr)
wav_t = torch.from_numpy(wav).to(device=device, dtype=torch.float32)
mels = mel_spectrogram(
wav_t.unsqueeze(0),
n_fft=_SPEAKER_ENCODER_N_FFT,
num_mels=_SPEAKER_ENCODER_NUM_MELS,
sampling_rate=_SPEAKER_ENCODER_SAMPLE_RATE,
hop_size=_SPEAKER_ENCODER_HOP_SIZE,
win_size=_SPEAKER_ENCODER_WIN_SIZE,
fmin=_SPEAKER_ENCODER_FMIN,
fmax=_SPEAKER_ENCODER_FMAX,
).transpose(1, 2)
with torch.inference_mode():
return encoder(mels.to(device=device, dtype=torch.bfloat16))[0].float().cpu().contiguous()
def _ref_code(model_dir: str, wav: np.ndarray, sr: int, device: torch.device) -> torch.Tensor:
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_tokenizer import Qwen3TTSTokenizer
tok = Qwen3TTSTokenizer.from_pretrained(
str(Path(model_dir) / "speech_tokenizer"),
torch_dtype=torch.bfloat16,
)
try:
del tok.model.decoder
tok.model.decoder = None
tok.model.encoder.to(device)
tok.device = device
except Exception:
tok.device = device
with torch.inference_mode():
enc = tok.encode(wav, sr=int(sr), return_dict=True)
codes = getattr(enc, "audio_codes", None)
if isinstance(codes, list):
codes = codes[0] if codes else None
if not isinstance(codes, torch.Tensor):
raise RuntimeError("Speech tokenizer did not return audio_codes")
if codes.ndim == 3:
codes = codes[0]
return codes.to(dtype=torch.int32, device="cpu").contiguous()
def _load_manifest(output_dir: Path, model: str, hidden_size: int) -> dict[str, Any]:
path = output_dir / MANIFEST_NAME
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
return {
"schema_version": 1,
"model_type": "qwen3_tts",
"model": model,
"hidden_size": hidden_size,
"voices": {},
}
def _write_voice(
*,
model: str,
output_dir: Path,
voice_name: str,
ref_audio: str,
ref_text: str | None,
mode: str,
speaker_description: str | None,
device: torch.device,
) -> None:
model_dir = _resolve_model_dir(model)
config, encoder = _load_speaker_encoder(model_dir, device)
wav, sr = _read_audio_mono(ref_audio)
if wav.size < 1024:
raise ValueError(f"Reference audio too short: {wav.size} samples")
tensors: dict[str, torch.Tensor] = {
"speaker_embedding": _speaker_embedding(encoder, config.speaker_encoder_config, wav, sr, device),
}
if mode == "icl":
if not ref_text or not ref_text.strip():
raise ValueError("--ref-text is required for --mode icl")
tensors["ref_code"] = _ref_code(model_dir, wav, sr, device)
output_dir.mkdir(parents=True, exist_ok=True)
filename = f"{safe_voice_stem(voice_name)}.safetensors"
save_file(tensors, str(output_dir / filename))
hidden_size = int(getattr(config.talker_config, "hidden_size", tensors["speaker_embedding"].numel()))
manifest = _load_manifest(output_dir, model, hidden_size)
entry: dict[str, Any] = {
"name": voice_name,
"file": filename,
"mode": mode,
"embedding_dim": int(tensors["speaker_embedding"].numel()),
}
if "ref_code" in tensors:
entry["ref_code_length"] = int(tensors["ref_code"].shape[0])
if ref_text:
entry["ref_text"] = ref_text
if speaker_description:
entry["speaker_description"] = speaker_description
manifest.setdefault("voices", {})[voice_name] = entry
(output_dir / MANIFEST_NAME).write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"Wrote {output_dir / filename}")
print(f"Updated {output_dir / MANIFEST_NAME}")
def main() -> None:
parser = argparse.ArgumentParser(description="Pre-compute Qwen3-TTS custom voice profile")
parser.add_argument("--model", required=True, help="Qwen3-TTS Base model path or Hugging Face ID")
parser.add_argument("--voice-name", required=True)
parser.add_argument("--ref-audio", required=True)
parser.add_argument("--ref-text", default=None)
parser.add_argument("--mode", choices=["xvec", "icl"], default="xvec")
parser.add_argument("--speaker-description", default=None)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
args = parser.parse_args()
_write_voice(
model=args.model,
output_dir=Path(args.output_dir),
voice_name=args.voice_name,
ref_audio=args.ref_audio,
ref_text=args.ref_text,
mode=args.mode,
speaker_description=args.speaker_description,
device=torch.device(args.device),
)
if __name__ == "__main__":
main()
@@ -0,0 +1,128 @@
"""Pre-compute VoxCPM2 custom voice profiles.
The generated directory can be passed to the server via
``custom_voice_dir`` in ``vllm_omni/deploy/voxcpm2.yaml``. Requests can then
use ``/v1/audio/speech`` with ``voice="<name>"`` and no per-request ref_audio.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
import torch
from safetensors.torch import save_file
REPO_ROOT = Path(__file__).resolve().parents[4]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from vllm_omni.utils.custom_voice_io import safe_voice_stem # noqa: E402
MANIFEST_NAME = "custom_voice_manifest.json"
def _load_tts(model: str, device: torch.device):
from vllm_omni.model_executor.models.voxcpm2.voxcpm2_import_utils import import_voxcpm2_core
VoxCPM = import_voxcpm2_core()
native = VoxCPM.from_pretrained(model, load_denoiser=False, optimize=False)
return native.tts_model.to(device).eval()
def _load_manifest(output_dir: Path, model: str) -> dict[str, Any]:
path = output_dir / MANIFEST_NAME
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
return {
"schema_version": 1,
"model_type": "voxcpm2",
"model": model,
"voices": {},
}
def _write_voice(
*,
model: str,
output_dir: Path,
voice_name: str,
ref_audio: str,
prompt_text: str | None,
mode: str,
speaker_description: str | None,
device: torch.device,
) -> None:
if mode in ("continuation", "ref_continuation") and not prompt_text:
raise ValueError("--prompt-text is required for continuation/ref_continuation modes")
tts = _load_tts(model, device)
tensors: dict[str, torch.Tensor] = {}
with torch.inference_mode():
if mode in ("reference", "ref_continuation"):
tensors["ref_audio_feat"] = tts._encode_wav(ref_audio, padding_mode="right").float().cpu().contiguous()
if mode in ("continuation", "ref_continuation"):
tensors["audio_feat"] = tts._encode_wav(ref_audio, padding_mode="left").float().cpu().contiguous()
output_dir.mkdir(parents=True, exist_ok=True)
filename = f"{safe_voice_stem(voice_name)}.safetensors"
save_file(tensors, str(output_dir / filename))
manifest = _load_manifest(output_dir, model)
entry: dict[str, Any] = {
"name": voice_name,
"file": filename,
"mode": mode,
}
if "ref_audio_feat" in tensors:
entry["ref_audio_feat_len"] = int(tensors["ref_audio_feat"].shape[0])
if "audio_feat" in tensors:
entry["audio_feat_len"] = int(tensors["audio_feat"].shape[0])
if prompt_text:
entry["prompt_text"] = prompt_text
if speaker_description:
entry["speaker_description"] = speaker_description
manifest.setdefault("voices", {})[voice_name] = entry
(output_dir / MANIFEST_NAME).write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"Wrote {output_dir / filename}")
print(f"Updated {output_dir / MANIFEST_NAME}")
def main() -> None:
parser = argparse.ArgumentParser(description="Pre-compute VoxCPM2 custom voice profile")
parser.add_argument("--model", default="openbmb/VoxCPM2", help="VoxCPM2 model path or Hugging Face ID")
parser.add_argument("--voice-name", required=True)
parser.add_argument("--ref-audio", required=True)
parser.add_argument(
"--prompt-text",
default=None,
help="Transcript of ref audio for continuation/ref_continuation modes",
)
parser.add_argument(
"--mode",
choices=["reference", "continuation", "ref_continuation"],
default="reference",
)
parser.add_argument("--speaker-description", default=None)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
args = parser.parse_args()
_write_voice(
model=args.model,
output_dir=Path(args.output_dir),
voice_name=args.voice_name,
ref_audio=args.ref_audio,
prompt_text=args.prompt_text,
mode=args.mode,
speaker_description=args.speaker_description,
device=torch.device(args.device),
)
if __name__ == "__main__":
main()
+112
View File
@@ -0,0 +1,112 @@
# GLM-Image for text-to-image and image editing
## Summary
- Vendor: Z.ai
- Model: `GLM/GLM-Image`
- Task: Text-to-image (T2I) and image-to-image
- Mode: Online serving with the OpenAI-compatible API
- Maintainer: Community
## When to use this recipe
Use this recipe when you want a known-good starting point for serving
`GLM/GLM-Image` with vLLM-Omni on **two 80 GB NVIDIA A800** GPUs (Ampere-class,
same default layout as the upstream **2×A100 80GB** example: Stage 0 AR on GPU 0,
Stage 1 diffusion on GPU 1) and validate the deployment with the existing
`examples/online_serving/glm_image` clients.
## References
- Upstream or canonical docs:
[`docs/user_guide/examples/online_serving/glm_image.md`](../../docs/user_guide/examples/online_serving/glm_image.md)
- Related issue or discussion:
[#2888](https://github.com/vllm-project/vllm-omni/pull/2888)
## Hardware Support
This recipe documents **dual-GPU** CUDA layouts on A800 80 GB
for the same software stack. Add more platforms (for example ROCm / NPU) as
community validation lands.
## GPU
### 2× A800 80GB
#### Environment
These versions were taken from a working **editable** install: activate `vllm-omni/.venv` (or your equivalent), then align `pip` / Git with the rows below when reproducing this recipe.
- OS: Linux
- Python: 3.12
- Driver / runtime: NVIDIA CUDA stack with **two** A800 80 GB GPUs visible (set `CUDA_VISIBLE_DEVICES` on your host if needed)
- vLLM: **0.19.0**
- vLLM-Omni: **0.19.0rc2.dev138+g38d5f2d53** (editable install from this repo; Git **`38d5f2d5`**, `git describe` ≈ **`v0.19.0rc1-138-g38d5f2d5`**)
- Transformers: **5.5.4** (same `.venv` as above; required so `glm_image` configs load for Stage 0)
#### Command
Start the server from the repository root:
```bash
vllm serve zai-org/GLM-Image --omni --port 8091
```
To use the bundled stage config explicitly (same default as above):
```bash
vllm serve zai-org/GLM-Image \
--omni \
--port 8091 \
--deploy-config vllm_omni/deploy/glm_image.yaml
```
#### Verification
Run one of the existing example clients after the server is ready:
```bash
curl -s http://172.18.69.133:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"role": "user", "content": "A beautiful landscape painting"}
],
"extra_body": {
"height": 1920,
"width": 1920,
"num_inference_steps": 50,
"true_cfg_scale": 1.5,
"seed": 42
}
}' | jq -r '.choices[0].message.content[0].image_url.url' | cut -d',' -f2- | base64 -d > land.png
```
After the command finishes, check for the output files:
```bash
ls output.png
```
#### Sample end-to-end metrics
One representative **offline** GLM-Image E2E run on this recipe’s **2× A800 80GB**.
Overall summary from the run’s metrics. Rough wall-time split: **Stage 0 (AR)** ~**25 s**,
**Stage 1 (diffusion)** ~**34 s** (see `e2e_stage_*_wall_time_ms` below).
| Field | Value |
| --- | ---: |
| e2e_requests | 1 |
| e2e_wall_time_ms | 61,148.679 |
| e2e_total_tokens | 1,300 |
| e2e_avg_time_per_request_ms | 61,148.679 |
| e2e_avg_tokens_per_s | 21.260 |
| e2e_stage_0_wall_time_ms | 24,708.760 |
| e2e_stage_1_wall_time_ms | 33,787.442 |
#### Notes
- Memory usage: Roughly **~38 GiB + KV** on Stage 0 (AR) and **~20 GiB** on Stage 1 (DiT+VAE) per the user guide; two 80 GB cards match the default split.
- Key flags: `--omni` is required; `--stage-configs-path` is optional unless you use a custom YAML (for example single-GPU).
- Keep **Transformers ≥ 5.5.1** (this recipe used **5.5.4**) so `glm_image` configs resolve; otherwise Stage 0 can fail at `ModelConfig` validation.
- Known limitations: This starter recipe follows the dual-GPU online path documented under `examples/online_serving/glm_image`. The first request may be slower due to warmup.
- Generation time: about **61 s** wall time end-to-end for the sample above (50 inference steps, 1024×1024).
+109
View File
@@ -0,0 +1,109 @@
# Helios for video generation
## Summary
- Vendor: Helios
- Model: `BestWishYsh/Helios-Base`, `BestWishYsh/Helios-Mid`, `BestWishYsh/Helios-Distilled`
- Task: Text-to-video, image-to-video, and video-to-video generation
- Mode: Offline inference with the bundled Helios example
- Maintainer: Community
## When to use this recipe
Use this recipe when you want a known-good starting point for running Helios
video generation with vLLM-Omni. The concrete command below focuses on
`BestWishYsh/Helios-Base` text-to-video generation on one NVIDIA H20 GPU. The
same example script also supports Helios-Mid, Helios-Distilled, image-to-video,
and video-to-video; see the linked example docs for those model-specific flags.
## References
- Upstream repository: <https://github.com/PKU-YuanGroup/Helios>
- Model weights:
<https://huggingface.co/BestWishYsh/Helios-Base>
- Upstream or canonical docs:
[`docs/user_guide/examples/offline_inference/helios.md`](../../docs/user_guide/examples/offline_inference/helios.md)
- Related example under `examples/`:
[`examples/offline_inference/helios/README.md`](../../examples/offline_inference/helios/README.md)
## Hardware Support
## GPU
### 1x NVIDIA H20
#### Environment
- OS: Ubuntu Linux x86_64
- Python: 3.12.12
- Driver / runtime: NVIDIA driver `580.126.20`, CUDA `13.0`
- Hardware: 1x NVIDIA H20 GPU from an 8x H20 host
- vLLM version: `0.19.0`
- vLLM-Omni version or commit: `a3903810`
#### Command
Run the baseline Helios-Base text-to-video example from the repository root:
```bash
cd examples/offline_inference/helios
python end2end.py \
--model BestWishYsh/Helios-Base \
--sample-type t2v \
--prompt "A dynamic time-lapse video showing the rapidly moving scenery from the window of a speeding train." \
--guidance-scale 5.0 \
--output helios_t2v_base.mp4
```
To use cache-dit acceleration, run on a vLLM-Omni checkout that includes Helios cache-dit support and enable the cache backend:
```bash
cd examples/offline_inference/helios
python end2end.py \
--cache-backend cache_dit \
--enable-cache-dit-summary \
--model BestWishYsh/Helios-Base \
--sample-type t2v \
--prompt "A dynamic time-lapse video showing the rapidly moving scenery from the window of a speeding train." \
--guidance-scale 5.0 \
--output helios_t2v_base.mp4
```
#### Verification
The script should print the resolved generation configuration, total generation
time, and output path. A successful run ends with output similar to:
```text
Total generation time: <seconds> seconds (<milliseconds> ms)
Saved generated video to helios_t2v_base.mp4
```
#### Important flags and stage configs
- `--model BestWishYsh/Helios-Base` selects the base Helios checkpoint used by
this recipe.
- `--sample-type t2v` selects text-to-video generation. Use `i2v` with
`--image-path` for image-to-video, or `v2v` with `--video-path` for
video-to-video.
- `--guidance-scale 5.0` matches the Helios-Base recommendation. Use
`--guidance-scale 1.0` for Helios-Distilled.
- `--cache-backend cache_dit` enables the cache-dit acceleration path.
- `--enable-cache-dit-summary` prints cache-dit summary information after
diffusion forward passes.
- No separate stage config is required for this offline recipe; the bundled
Helios example configures the pipeline through `end2end.py` arguments.
- For Helios-Mid, add `--is-enable-stage2`,
`--pyramid-num-inference-steps-list 20 20 20`, `--use-cfg-zero-star`,
`--use-zero-init`, and `--zero-steps 1`.
- For Helios-Distilled, add `--is-enable-stage2`,
`--pyramid-num-inference-steps-list 2 2 2`, and
`--is-amplify-first-chunk`.
#### Known limitations
- Helios generates video in 33-frame chunks. For best performance, set
`--num-frames` to a multiple of `33`; non-multiple values are rounded up to
the nearest multiple of `33`.
+4
View File
@@ -30,9 +30,11 @@ 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 |
| [`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 |
| [`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 |
| [`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-Edit.md`](./Qwen/Qwen-Image-Edit.md) | Text-guided single-image editing | 1x or 2x H200 141GB |
@@ -44,6 +46,8 @@ recipes/
| [`Wan-AI/Wan2.2-I2V.md`](./Wan-AI/Wan2.2-I2V.md) | Image-to-video serving (Wan2.2 14B) | 8x Ascend NPU (A2/A3) |
| [`Wan-AI/Wan2.2-S2V.md`](./Wan-AI/Wan2.2-S2V.md) | Speech-to-video serving (Wan2.2 14B) | 2x A100/H100 80GB |
| [`zai-org/GLM-TTS.md`](./zai-org/GLM-TTS.md) | Online serving for Chinese/English zero-shot voice-cloned TTS | 1x A40 48GB |
| [`GLM/GLM-Image.md`](./GLM/GLM-Image.md) | Online serving for image generation | 1x A800 80GB / 2x A800 80GB |
|
Within a single recipe file, include different hardware support sections such
as `GPU`, `ROCm`, and `NPU`, and add concrete tested configurations like
+107
View File
@@ -0,0 +1,107 @@
# Voxtral TTS for text-to-speech
## Summary
- Vendor: Mistral AI
- Model: `mistralai/Voxtral-4B-TTS-2603`
- Task: Text-to-speech with model-provided voice presets or voice cloning
- Mode: Online serving with the OpenAI-compatible `/v1/audio/speech` API
- Maintainer: Community
## When to use this recipe
Use this recipe when you want a known-good starting point for serving Voxtral TTS
models with vLLM-Omni and validate the deployment with the existing TTS client
examples in this repository.
## References
- Upstream model card: <https://huggingface.co/mistralai/Voxtral-4B-TTS-2603>
- Related examples under `examples/`:
[`examples/offline_inference/text_to_speech/voxtral_tts/end2end.py`](../../examples/offline_inference/text_to_speech/voxtral_tts/end2end.py)
[`examples/online_serving/text_to_speech/voxtral_tts/gradio_demo.py`](../../examples/online_serving/text_to_speech/voxtral_tts/gradio_demo.py)
- Related issue or discussion:
[RFC: add recipes folder](https://github.com/vllm-project/vllm-omni/issues/2645)
## Hardware Support
## GPU
### 1x RTX 4090 24GB
#### Environment
- OS: Ubuntu 22.04.5 LTS
- Python: 3.12.11
- PyTorch: 2.11.0+cu130
- Driver / runtime: CUDA 13.0
- GPU: NVIDIA GeForce RTX 4090, 24 GB
- vLLM version: 0.20.0
- vLLM-Omni version or commit: 0.20.0rc2.dev99+g857356d5b
- Required package: mistral_common >= 1.10.0
#### Command
Online:
```bash
vllm serve mistralai/Voxtral-4B-TTS-2603 \
--omni \
--port 8091
```
The deploy config is auto-loaded from `vllm_omni/deploy/voxtral_tts.yaml`
#### Verification
**Quick smoke test with curl:**
```bash
curl -X POST http://127.0.0.1:8091/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "mistralai/Voxtral-4B-TTS-2603",
"input": "Hello, this is Voxtral TTS running with vLLM-Omni.",
"voice": "casual_female",
"language": "English",
"response_format": "wav"
}' \
--output voxtral.wav
```
**Streaming audio:**
```bash
curl -X POST http://127.0.0.1:8091/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "mistralai/Voxtral-4B-TTS-2603",
"input": "Hello, this is Voxtral TTS streaming PCM.",
"voice": "casual_female",
"language": "English",
"stream": true,
"response_format": "pcm"
}' \
--output voxtral_stream.pcm
```
Convert the raw PCM output to WAV
```bash
ffmpeg -f s16le -ar 24000 -ac 1 -i voxtral_stream.pcm voxtral_stream.wav -y
ffprobe -hide_banner voxtral_stream.wav
```
#### Notes
- Memory usage: Stage 0 (`audio_generation`) ~18.95 GiB, Stage 1
(`audio_tokenizer`) ~1.55 GiB. The observed server-startup peak was
`21006 MiB / 24564 MiB` (`20.51 GiB / 23.99 GiB`, about 85.5%).
Both stages share GPU 0 via the deploy config (`gpu_memory_utilization: 0.8`
for Stage 0, `0.1` for Stage 1).
- Key flags: `--omni` is required. `--port 8091` is optional and only selects
the HTTP port.
- Known limitation: Voxtral TTS outputs mono audio at 24 kHz. And with the public
mistralai/Voxtral-4B-TTS-2603 checkpoint. running the
voice-cloning e2e test ([`Voice cloning (capability gated upstream)`](../../examples/offline_inference/text_to_speech/README.md#voice-cloning-capability-gated-upstream)
) 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.`
+6
View File
@@ -1,4 +1,10 @@
# Common dependencies for all platforms
# transformers: keep aligned with upstream vLLM constraint.
# vLLM pins ">= 4.56.0, != 5.0.*, != 5.1.*, != 5.2.*, != 5.3.*, != 5.4.*, != 5.5.0".
# 5.9.0 breaks models that define _keys_to_ignore_on_load_unexpected as a list
# (e.g. OpenMOSS-Team/MOSS-TTS-Nano) because it changed
# _adjust_missing_and_unexpected_keys from list+list to set|set.
transformers >= 4.56.0, != 5.0.*, != 5.1.*, != 5.2.*, != 5.3.*, != 5.4.*, != 5.5.0, < 5.9.0
av>=14.0.0
omegaconf>=2.3.0
diffusers==0.38.0
@@ -0,0 +1,92 @@
"""Unit tests for Omni AR streaming-session async placeholder handling."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
# Imports must run in this order: vllm_omni applies patches to vllm.v1.request before
# Request / StreamingUpdate are bound in this module. Ruff isort would reorder them.
# isort: off
import vllm_omni # noqa: F401 - import for side effects (patch vLLM)
from vllm.sampling_params import SamplingParams
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
# isort: on
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_scheduler(*, stage_id: int = 0) -> OmniARScheduler:
sched = OmniARScheduler.__new__(OmniARScheduler)
sched._new_prompt_len_snapshot = {}
sched.vllm_config = SimpleNamespace(model_config=SimpleNamespace(stage_id=stage_id))
sched.num_waiting_for_streaming_input = 0
sched.log_stats = False
sched.chunk_transfer_adapter = None
return sched
def _make_request() -> Request:
return Request(
request_id="req-ar-streaming-test",
prompt_token_ids=[1, 2, 3],
sampling_params=SamplingParams(max_tokens=8),
pooling_params=None,
arrival_time=100.0,
block_hasher=None,
)
def _make_update(prompt_token_ids: list[int] | None = None) -> StreamingUpdate:
return StreamingUpdate(
mm_features=None,
prompt_token_ids=[10, 20] if prompt_token_ids is None else prompt_token_ids,
max_tokens=32,
arrival_time=200.0,
sampling_params=SamplingParams(max_tokens=16),
)
def test_stage0_streaming_update_discards_outstanding_async_placeholder_token() -> None:
sched = _make_scheduler(stage_id=0)
session = _make_request()
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
session.append_output_token_ids([7, 8, 9])
session.num_computed_tokens = 6
session.num_output_placeholders = 1
session.spec_token_ids = [-1]
sched._update_request_as_session(session, _make_update([10, 20]))
assert session.discard_latest_async_tokens is True
assert session.num_output_placeholders == 0
assert session.spec_token_ids == []
# The async placeholder makes token 9 unconfirmed, so only 7 and 8 are
# carried into the next streaming prompt before the new chunk tokens.
assert session.prompt_token_ids == [1, 2, 3, 7, 8, 10, 20]
assert list(session._all_token_ids) == [1, 2, 3, 7, 8, 10, 20]
assert session._output_token_ids == []
assert session.num_prompt_tokens == 7
assert sched._new_prompt_len_snapshot[session.request_id] == 2
def test_stage0_streaming_update_keeps_all_computed_tokens_without_placeholder() -> None:
sched = _make_scheduler(stage_id=0)
session = _make_request()
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
session.append_output_token_ids([7, 8, 9])
session.num_computed_tokens = 6
session.num_output_placeholders = 0
sched._update_request_as_session(session, _make_update([10, 20]))
assert session.discard_latest_async_tokens is False
assert session.num_output_placeholders == 0
assert session.prompt_token_ids == [1, 2, 3, 7, 8, 9, 10, 20]
assert list(session._all_token_ids) == [1, 2, 3, 7, 8, 9, 10, 20]
assert session._output_token_ids == []
assert session.num_prompt_tokens == 8
assert sched._new_prompt_len_snapshot[session.request_id] == 2
@@ -1,6 +1,6 @@
"""Unit tests for OmniSchedulerMixin streaming session replacement.
"""Unit tests for generation streaming session replacement.
These tests pin the behavior of `_replace_session_with_streaming_update` against
These tests pin the behavior of `_update_request_as_session` against
current vLLM `Request` / `StreamingUpdate` (and Omni patches). When upgrading
vLLM, failures here should highlight incompatible changes to request state or
update payloads early.
@@ -19,19 +19,34 @@ import vllm_omni # noqa: F401 - import for side effects (patch vLLM)
from vllm.sampling_params import SamplingParams
from vllm.v1.engine import EngineCoreEventType
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
# isort: on
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _SchedulerStub(OmniSchedulerMixin):
"""Minimal scheduler surface required by OmniSchedulerMixin."""
class _SkippedWaitingStub:
def __contains__(self, request: Request) -> bool:
return False
class _ChunkTransferAdapterStub:
def __init__(self) -> None:
self.segment_finished_requests: set[str] = set()
class _SchedulerStub(OmniGenerationScheduler):
"""Minimal scheduler surface required by OmniGenerationScheduler."""
def __init__(self, *, log_stats: bool = False) -> None:
self.num_waiting_for_streaming_input = 0
self.log_stats = log_stats
self.chunk_transfer_adapter = _ChunkTransferAdapterStub()
self.skipped_waiting = _SkippedWaitingStub()
def _enqueue_waiting_request(self, session: Request) -> None:
raise AssertionError("unexpected enqueue for skipped_waiting miss")
def _make_request(**kwargs) -> Request:
@@ -71,7 +86,7 @@ class TestReplaceSessionWithStreamingUpdate:
update = _make_update(prompt_token_ids=[40, 41, 42])
sched.num_waiting_for_streaming_input = 3
sched._replace_session_with_streaming_update(session, update)
sched._update_request_as_session(session, update)
assert session._output_token_ids == []
assert list(session._all_token_ids) == [40, 41, 42]
@@ -88,7 +103,7 @@ class TestReplaceSessionWithStreamingUpdate:
session = _make_request()
session.status = RequestStatus.RUNNING
update = _make_update(prompt_token_ids=None)
sched._replace_session_with_streaming_update(session, update)
sched._update_request_as_session(session, update)
assert session.prompt_token_ids == ()
assert list(session._all_token_ids) == []
@@ -108,7 +123,7 @@ class TestReplaceSessionWithStreamingUpdate:
pytest.skip("StreamingUpdate has no additional_information (Omni patch inactive?)")
update = replace(base, additional_information=None)
sched._replace_session_with_streaming_update(session, update)
sched._update_request_as_session(session, update)
assert session.additional_information is None
def test_does_not_decrement_waiting_when_not_streaming_status(self) -> None:
@@ -116,14 +131,14 @@ class TestReplaceSessionWithStreamingUpdate:
session = _make_request()
session.status = RequestStatus.RUNNING
sched.num_waiting_for_streaming_input = 5
sched._replace_session_with_streaming_update(session, _make_update())
sched._update_request_as_session(session, _make_update())
assert sched.num_waiting_for_streaming_input == 5
def test_records_queued_event_when_log_stats_enabled(self) -> None:
sched = _SchedulerStub(log_stats=True)
session = _make_request()
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
sched._replace_session_with_streaming_update(session, _make_update())
sched._update_request_as_session(session, _make_update())
assert session.events
assert session.events[-1].type == EngineCoreEventType.QUEUED
+18 -1
View File
@@ -191,7 +191,16 @@ def test_performance_benchmark(omni_server, benchmark_params, request):
raise ValueError("The number of prompts does not match the QPS or max_concurrency")
args = ["--host", host, "--port", str(port)]
exclude_keys = {"request_rate", "baseline", "num_prompts", "max_concurrency", "task", "enabled", "eval_phase"}
exclude_keys = {
"request_rate",
"baseline",
"num_prompts",
"max_concurrency",
"task",
"enabled",
"eval_phase",
"trust_remote_code",
}
for key, value in params.items():
if key in exclude_keys or value is None:
@@ -207,6 +216,14 @@ def test_performance_benchmark(omni_server, benchmark_params, request):
elif not isinstance(value, bool):
args.extend([arg_name, str(value)])
for config in BENCHMARK_CONFIGS:
if config.get("test_name") != test_name:
continue
server_params = config.get("server_params") or {}
if server_params.get("trust_remote_code") or params.get("trust_remote_code"):
args.append("--trust-remote-code")
break
# QPS / request-rate sweep
for i, (qps, num_prompt) in enumerate(zip(qps_list, num_prompt_list)):
args = args + ["--request-rate", str(qps), "--num-prompts", str(num_prompt)]
@@ -223,8 +223,12 @@ def _get_open_port() -> int:
return s.getsockname()[1]
def _wait_for_port(host: str, port: int, timeout: int = 1200) -> None:
"""Block until the given host:port accepts connections or timeout expires."""
def _wait_for_port(host: str, port: int, timeout: int = 1200, proc: subprocess.Popen | None = None) -> None:
"""Block until the given host:port accepts connections or timeout expires.
If *proc* is provided, also monitors the process; raises RuntimeError
immediately if the server process exits before the port becomes available.
"""
start = time.time()
while time.time() - start < timeout:
try:
@@ -234,6 +238,10 @@ def _wait_for_port(host: str, port: int, timeout: int = 1200) -> None:
return
except Exception:
pass
if proc is not None:
ret = proc.poll()
if ret is not None:
raise RuntimeError(f"Server process exited with code {ret} before port {host}:{port} became ready")
time.sleep(2)
raise RuntimeError(f"Server did not start on {host}:{port} within {timeout}s")
@@ -331,7 +339,7 @@ class DiffusionServer:
env=env,
cwd=str(Path(__file__).parent.parent.parent.parent),
)
_wait_for_port(self.host, self.port)
_wait_for_port(self.host, self.port, proc=self.proc)
print(f"DiffusionServer ready on {self.host}:{self.port}")
def __enter__(self):
@@ -1,35 +1,33 @@
[
{
"test_name": "test_hunyuan_image_tp2_fp8_cfgp2",
"description": "TP=2 Quantization=fp8 CfgP=2 baseline",
"test_name": "test_hunyuan_image_tp2_cfgp2",
"description": "TP=2 CfgP=2 baseline",
"server_type": "vllm-omni",
"server_params": {
"model": "tencent/HunyuanImage-3.0-Instruct",
"serve_args": {
"deploy_config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml",
"tensor-parallel-size": 2,
"cfg-parallel-size": 2,
"quantization": "fp8",
"distributed-executor-backend": "mp",
"enforce-eager": true,
"enable-diffusion-pipeline-profiler": true
}
},
"benchmark_params": [
{
"name": "1024x1024_steps50",
"name": "1024x1024_steps8",
"dataset": "random",
"task": "t2i",
"width": 1024,
"height": 1024,
"num-inference-steps": 50,
"num-inference-steps": 8,
"num-prompts": 10,
"max-concurrency": 1,
"skip-performance-assertion": true,
"baseline": {
"throughput_qps": 0.1035,
"latency_p99": 9.9057,
"peak_memory_mb_max": 66470,
"peak_memory_mb_mean": 66470
"throughput_qps": 0.21,
"latency_p99": 4.7469,
"peak_memory_mb_max": 101100,
"peak_memory_mb_mean": 101100
}
}
]
@@ -1,35 +1,33 @@
[
{
"test_name": "test_hunyuan_image_tp2_fp8_sp2",
"description": "TP=2 Quantization=fp8 SP=2 baseline",
"test_name": "test_hunyuan_image_tp2_sp2",
"description": "TP=2 SP=2 baseline",
"server_type": "vllm-omni",
"server_params": {
"model": "tencent/HunyuanImage-3.0-Instruct",
"serve_args": {
"deploy-config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml",
"tensor-parallel-size": 2,
"usp": 2,
"quantization": "fp8",
"distributed-executor-backend": "mp",
"enforce-eager": true,
"ulysses-degree": 2,
"enable-diffusion-pipeline-profiler": true
}
},
"benchmark_params": [
{
"name": "1024x1024_steps50",
"name": "1024x1024_steps8",
"dataset": "random",
"task": "t2i",
"width": 1024,
"height": 1024,
"num-inference-steps": 50,
"num-inference-steps": 8,
"num-prompts": 10,
"max-concurrency": 1,
"skip-performance-assertion": true,
"baseline": {
"throughput_qps": 0.08,
"latency_p99": 12.0731,
"peak_memory_mb_max": 66314,
"peak_memory_mb_mean": 66314
"throughput_qps": 0.20,
"latency_p99": 5.1025,
"peak_memory_mb_max": 97402,
"peak_memory_mb_mean": 97402
}
}
]
@@ -1,34 +1,32 @@
[
{
"test_name": "test_hunyuan_image_tp4_fp8",
"description": "TP=4 Quantization=fp8 baseline",
"test_name": "test_hunyuan_image_tp4",
"description": "TP=4 baseline",
"server_type": "vllm-omni",
"server_params": {
"model": "tencent/HunyuanImage-3.0-Instruct",
"serve_args": {
"deploy-config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml",
"tensor-parallel-size": 4,
"quantization": "fp8",
"distributed-executor-backend": "mp",
"enforce-eager": true,
"enable-diffusion-pipeline-profiler": true
}
},
"benchmark_params": [
{
"name": "1024x1024_steps50",
"name": "1024x1024_steps8",
"dataset": "random",
"task": "t2i",
"width": 1024,
"height": 1024,
"num-inference-steps": 50,
"num-inference-steps": 8,
"num-prompts": 10,
"max-concurrency": 1,
"skip-performance-assertion": true,
"baseline": {
"throughput_qps": 0.08,
"latency_p99": 13.1227,
"peak_memory_mb_max": 46838,
"peak_memory_mb_mean": 46838
"throughput_qps": 0.21,
"latency_p99": 4.9235,
"peak_memory_mb_max": 57576,
"peak_memory_mb_mean": 57576
}
}
]
@@ -5,7 +5,8 @@
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"use_omni": false,
"extra_cli_args": [
"--no-enable-prefix-caching"
"--no-enable-prefix-caching",
"--skip-mm-profiling"
]
},
"benchmark_params": [
@@ -63,24 +63,6 @@
}
},
"benchmark_params": [
{
"name": "512x512_steps20_i2i_2img",
"dataset": "random",
"task": "i2i",
"width": 512,
"height": 512,
"num-inference-steps": 20,
"num-prompts": 10,
"max-concurrency": 1,
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.15,
"latency_mean": 10,
"peak_memory_mb_max": 55000,
"peak_memory_mb_mean": 55000
}
},
{
"name": "1536x1536_steps35_i2i_2img",
"dataset": "random",
@@ -61,23 +61,6 @@
}
},
"benchmark_params": [
{
"name": "512x512_steps20_i2i",
"dataset": "random",
"task": "i2i",
"width": 512,
"height": 512,
"num-inference-steps": 20,
"num-prompts": 10,
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.21,
"latency_mean": 10,
"peak_memory_mb_max": 56000,
"peak_memory_mb_mean": 56000
}
},
{
"name": "1536x1536_steps35_i2i",
"dataset": "random",
+10 -1
View File
@@ -17,7 +17,16 @@ def test_task_excluded_from_cli_args():
"percentile-metrics": "audio_rtf,audio_ttfp",
"baseline": {"mean_audio_rtf": [0.5]},
}
exclude_keys = {"request_rate", "baseline", "num_prompts", "max_concurrency", "task", "enabled", "eval_phase"}
exclude_keys = {
"request_rate",
"baseline",
"num_prompts",
"max_concurrency",
"task",
"enabled",
"eval_phase",
"trust_remote_code",
}
args = []
for key, value in params.items():
if key in exclude_keys or value is None:
+198
View File
@@ -0,0 +1,198 @@
[
{
"test_name": "test_voxcpm2",
"server_params": {
"model": "openbmb/VoxCPM2",
"trust_remote_code": true
},
"benchmark_params": [
{
"task": "voice_clone",
"eval_phase": "latency",
"dataset_name": "seed-tts",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "linyueqian/seed-tts-eval-subset",
"num_prompts": [
20
],
"max_concurrency": [
1
],
"seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
600
],
"median_audio_rtf": [
0.2
]
}
},
{
"task": "voice_clone",
"eval_phase": "throughput",
"dataset_name": "seed-tts",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "linyueqian/seed-tts-eval-subset",
"num_prompts": [
80
],
"max_concurrency": [
8
],
"seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
3500
],
"median_audio_rtf": [
0.85
],
"audio_throughput": [
8
]
}
},
{
"task": "voice_clone",
"eval_phase": "quality",
"enabled": false,
"dataset_name": "seed-tts",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "linyueqian/seed-tts-eval-subset",
"num_prompts": [
200
],
"max_concurrency": [
4
],
"seed_tts_locale": "en",
"seed_tts_wer_eval": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_audio_rtf": [
0.45
]
}
},
{
"task": "default_voice",
"eval_phase": "latency",
"dataset_name": "seed-tts-text",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [
20
],
"max_concurrency": [
1
],
"seed_tts_locale": "en",
"extra_body": {
"voice": "default"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
350
],
"median_audio_rtf": [
0.15
]
}
},
{
"task": "default_voice",
"eval_phase": "throughput",
"dataset_name": "seed-tts-text",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [
80
],
"max_concurrency": [
8
],
"seed_tts_locale": "en",
"extra_body": {
"voice": "default"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
3000
],
"median_audio_rtf": [
0.5
],
"audio_throughput": [
10
]
}
},
{
"task": "default_voice",
"eval_phase": "stress",
"dataset_name": "seed-tts-text",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [
100
],
"request_rate": [
2.0
],
"seed_tts_locale": "en",
"extra_body": {
"voice": "default"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
500
],
"median_audio_rtf": [
0.3
],
"audio_throughput": [
5
]
}
},
{
"task": "default_voice",
"eval_phase": "quality",
"enabled": false,
"dataset_name": "seed-tts-text",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [
200
],
"max_concurrency": [
4
],
"seed_tts_locale": "en",
"extra_body": {
"voice": "default"
},
"seed_tts_wer_eval": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_audio_rtf": [
0.35
]
}
}
]
}
]
+452 -25
View File
@@ -4,10 +4,16 @@ This module keeps fault injection callable from tests directly:
- GPU OOM (CUDA sidecar memory hog)
- process kill by pattern and signal
- post-ready hooks via ``fault_injector`` / ``omni_server_after_fault`` fixtures
Worker PID classification (``worker_pids`` in snapshots) is still used for logging /
markers; post-fault **process** cleanup assertions use the full captured
``tree_pids`` (serve root + descendants at fault time) — see
:func:`assert_no_server_tree_process_residual_and_gpu_release`.
"""
from __future__ import annotations
import concurrent.futures
import http.client
import json
import logging
@@ -27,6 +33,16 @@ import pytest
logger = logging.getLogger(__name__)
# Substrings matched against ``psutil.Process.name`` + argv text for classifying GPU
# workers under the test server tree. Extend per-suite via
# ``server.reliability_worker_markers_extra`` or replace via
# ``server.reliability_worker_markers`` (see ``_resolve_runtime_worker_markers``).
DEFAULT_RUNTIME_WORKER_MARKERS: tuple[str, ...] = (
"multiprocessing.spawn",
# Covers ``VLLM::Worker``, ``VLLM::StageEngineCoreProc_*`` (Omni stage engines), etc.
"VLLM::",
)
@dataclass
class OomHandle:
@@ -452,6 +468,61 @@ def _safe_proc_info(pid: int) -> tuple[str, str]:
return "<unknown>", "<unavailable>"
def _normalize_worker_marker_substrings(seq: Sequence[Any], *, context: str) -> tuple[str, ...]:
"""Return deduped non-empty marker substrings; raises if any entry is blank."""
out: list[str] = []
for item in seq:
s = str(item).strip()
if not s:
raise ValueError(f"{context}: worker marker must be a non-empty string, got {item!r}")
out.append(s)
return tuple(dict.fromkeys(out))
def _resolve_runtime_worker_markers(
server: Any,
injector_markers_extra: Sequence[str] | None,
) -> tuple[str, ...]:
"""Resolve which substrings classify worker PIDs for fault snapshots.
If ``server.reliability_worker_markers`` is set to a sequence, **only** those
markers are used (no defaults). Otherwise defaults from
:data:`DEFAULT_RUNTIME_WORKER_MARKERS` are merged with:
- ``server.reliability_worker_markers_extra`` when present, and
- ``injector_markers_extra`` from :func:`make_server_root_kill_fault_injector` /
:func:`make_server_tree_kill_fault_injector`.
"""
replace = getattr(server, "reliability_worker_markers", None)
if replace is not None:
return _normalize_worker_marker_substrings(replace, context="server.reliability_worker_markers")
merged: list[str] = []
merged.extend(DEFAULT_RUNTIME_WORKER_MARKERS)
extra_attr = getattr(server, "reliability_worker_markers_extra", None)
if extra_attr is not None:
merged.extend(
_normalize_worker_marker_substrings(extra_attr, context="server.reliability_worker_markers_extra")
)
if injector_markers_extra is not None:
merged.extend(
_normalize_worker_marker_substrings(
injector_markers_extra,
context="injector worker_markers_extra",
)
)
return tuple(dict.fromkeys(merged))
def _pid_looks_like_runtime_worker(pid: int, markers: Sequence[str]) -> bool:
"""True when process name or argv contains any resolved worker marker substring."""
if not markers:
return False
name, cmdline = _safe_proc_info(pid)
text = f"{name} {cmdline}"
return any(marker in text for marker in markers)
def _list_server_process_tree(server: Any) -> list[int]:
"""Return [root, descendants...] PIDs for the current test server instance."""
root_proc = getattr(server, "proc", None)
@@ -468,6 +539,29 @@ def _list_server_process_tree(server: Any) -> list[int]:
return [root_pid, *descendants]
def _pids_in_server_tree_substring_match(server: Any, pattern: str, *, limit: int) -> list[int]:
"""Return up to ``limit`` PIDs in the server tree whose name+cmdline contains ``pattern`` literally.
``pgrep -f`` matches the process command line used by procps and does not consult the
short process name; vLLM-Omni often exposes titles like ``VLLM::Worker`` or
``VLLM::StageEngineCoreProc_*`` via prctl/setproctitle in ``ps``/``name()`` only.
This helper keeps kill injection aligned with :func:`_safe_proc_info` / fault snapshots.
"""
if limit <= 0:
return []
tree = _list_server_process_tree(server)
if not tree or not pattern:
return []
hits: list[int] = []
for pid in tree:
if len(hits) >= limit:
break
name, cmdline = _safe_proc_info(int(pid))
if pattern in f"{name} {cmdline}":
hits.append(int(pid))
return hits
def _log_server_process_tree(server: Any) -> None:
"""Print server process tree for debugging fault injection targets."""
pids = _list_server_process_tree(server)
@@ -486,6 +580,315 @@ FaultInjector = Callable[[Any], None]
"""Callable invoked with the live ``OmniServer`` after it is ready (see ``omni_server_after_fault``)."""
def run_fault_injection_with_rate_load(
*,
submit_request: Callable[[], Any],
inject_fault: Callable[[], None],
num_requests: int = 10,
request_rate: float = 0.3,
submit_interval_sec: float | None = None,
completion_timeout_sec: float = 120.0,
) -> dict[str, Any]:
"""Submit requests with a fixed rate, then inject fault once an in-flight request is observed."""
if num_requests <= 0:
raise ValueError("num_requests must be > 0")
if request_rate <= 0:
raise ValueError("request_rate must be > 0")
interval_sec = submit_interval_sec if submit_interval_sec is not None else (1.0 / request_rate)
if interval_sec < 0:
raise ValueError("submit_interval_sec must be >= 0")
max_workers = min(max(2, num_requests), 8)
injected = False
futures: list[concurrent.futures.Future[Any]] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
for idx in range(num_requests):
futures.append(executor.submit(submit_request))
in_flight = any(not future.done() for future in futures)
if in_flight and not injected:
inject_fault()
injected = True
if idx != num_requests - 1 and interval_sec > 0:
time.sleep(interval_sec)
if not injected:
in_flight = any(not future.done() for future in futures)
if in_flight:
inject_fault()
injected = True
if not injected:
pytest.skip(
"no in-flight request observed before fault injection; "
"increase request cost or request rate for this environment"
)
done, pending = concurrent.futures.wait(
futures,
timeout=completion_timeout_sec,
return_when=concurrent.futures.ALL_COMPLETED,
)
assert not pending, (
"some in-flight-load requests did not converge after fault injection: "
f"pending={len(pending)} done={len(done)}"
)
failure_observed = False
completed = 0
success = 0
exceptions = 0
for future in futures:
completed += 1
try:
future.result()
success += 1
except Exception: # noqa: BLE001
exceptions += 1
failure_observed = True
return {
"num_submitted": num_requests,
"completed": completed,
"success": success,
"exceptions": exceptions,
"failure_observed": failure_observed,
"inflight_observed": True,
}
def list_alive_pids(pids: Sequence[int]) -> list[int]:
"""Return PIDs from ``pids`` that still exist in the kernel and are **not** zombies.
After SIGTERM/SIGKILL the serve child may exit before the test harness calls
``Popen.wait()``; until reaped, ``psutil.pid_exists`` stays true for the defunct
slot. Those PIDs must not count as "residual server processes" for leak checks.
"""
out: list[int] = []
for pid in pids:
pid_i = int(pid)
if not psutil.pid_exists(pid_i):
continue
try:
if psutil.Process(pid_i).status() == psutil.STATUS_ZOMBIE:
continue
except psutil.Error:
continue
out.append(pid_i)
return out
def query_gpu_compute_pid_used_memory_mb() -> dict[int, int] | None:
"""Query NVIDIA compute-process memory map (pid -> used MB).
Returns ``None`` when ``nvidia-smi`` is unavailable/unreadable on current host.
"""
out = subprocess.run(
["nvidia-smi", "--query-compute-apps=pid,used_memory", "--format=csv,noheader,nounits"],
check=False,
capture_output=True,
text=True,
)
if out.returncode != 0:
logger.warning("[reliability][gpu] nvidia-smi query failed: %s", out.stderr.strip())
return None
pid_to_mem_mb: dict[int, int] = {}
for line in out.stdout.splitlines():
row = line.strip()
if not row:
continue
pieces = [part.strip() for part in row.split(",", maxsplit=1)]
if len(pieces) != 2:
continue
pid_raw, mem_raw = pieces
if not pid_raw.isdigit():
continue
mem_digits = "".join(ch for ch in mem_raw if ch.isdigit())
if not mem_digits:
continue
pid_to_mem_mb[int(pid_raw)] = int(mem_digits)
return pid_to_mem_mb
def worker_residual_timeout_after_kill_signal(signal_name: str) -> float:
"""Wall-clock budget for fault-snapshot PIDs / GPU to clear after ``signal_name``."""
if signal_name == "SIGKILL":
return 30.0
if signal_name == "SIGINT":
return 120.0
if signal_name == "SIGTERM":
return 90.0
return 30.0
def assert_no_server_tree_process_residual_and_gpu_release(
server: Any,
*,
scenario: str,
timeout_sec: float = 30.0,
poll_interval_sec: float = 0.5,
) -> None:
"""Assert no live PIDs remain from the fault-time server tree and no GPU use by those PIDs.
Uses ``reliability_fault_snapshot["tree_pids"]`` (root + all descendants captured at
injection time), not the marker-filtered ``worker_pids`` subset — so helpers like
``multiprocessing.resource_tracker`` are included in the residual check.
**Zombie** PIDs (exited children not yet reaped by the test ``Popen``) are excluded
from the "alive" list; ``psutil.pid_exists`` alone would false-positive on them.
Note: if a child is reparented to PID 1 while staying alive, its PID is unchanged
and remains detected; if it **exits and a new unrelated process reuses the same
numeric PID**, this check may false-positive (rare on short windows).
"""
snapshot = getattr(server, "reliability_fault_snapshot", None)
if not isinstance(snapshot, dict):
pytest.fail(f"[{scenario}] missing reliability fault snapshot on server")
tree_pids = [int(pid) for pid in snapshot.get("tree_pids", [])]
if not tree_pids:
pytest.skip(f"[{scenario}] no server process tree PIDs captured for this run")
tree_pid_set = set(tree_pids)
deadline = time.monotonic() + timeout_sec
last_alive: list[int] = []
last_gpu_leaks: dict[int, int] = {}
while time.monotonic() < deadline:
last_alive = list_alive_pids(tree_pids)
gpu_map = query_gpu_compute_pid_used_memory_mb()
if gpu_map is None:
pytest.skip(f"[{scenario}] nvidia-smi unavailable; skip GPU release assertion")
last_gpu_leaks = {pid: mem_mb for pid, mem_mb in gpu_map.items() if pid in tree_pid_set and mem_mb > 0}
if not last_alive and not last_gpu_leaks:
return
time.sleep(poll_interval_sec)
assert not last_alive, f"[{scenario}] residual server-tree processes remain alive: {last_alive}"
assert not last_gpu_leaks, f"[{scenario}] server-tree PID GPU memory not released: {last_gpu_leaks}"
def assert_no_worker_residual_and_gpu_release(
server: Any,
*,
scenario: str,
timeout_sec: float = 30.0,
poll_interval_sec: float = 0.5,
) -> None:
"""Deprecated: use :func:`assert_no_server_tree_process_residual_and_gpu_release`."""
assert_no_server_tree_process_residual_and_gpu_release(
server,
scenario=scenario,
timeout_sec=timeout_sec,
poll_interval_sec=poll_interval_sec,
)
def _capture_server_fault_snapshot(
server: Any,
*,
worker_markers_extra: Sequence[str] | None = None,
) -> dict[str, list[int]]:
"""Capture current server process snapshot for post-fault assertions.
``tree_pids`` is ``[root, ...descendants]`` and is used by
:func:`assert_no_server_tree_process_residual_and_gpu_release`.
``worker_pids`` is the marker-filtered subset for debugging only.
"""
markers = _resolve_runtime_worker_markers(server, worker_markers_extra)
tree_pids = _list_server_process_tree(server)
root_pid = tree_pids[0] if tree_pids else None
worker_pids: list[int] = []
for pid in tree_pids:
if root_pid is not None and int(pid) == int(root_pid):
continue
if _pid_looks_like_runtime_worker(pid, markers):
worker_pids.append(pid)
snapshot = {
"root_pids": [root_pid] if root_pid is not None else [],
"tree_pids": tree_pids,
"worker_pids": worker_pids,
"worker_markers": list(markers),
}
setattr(server, "reliability_fault_snapshot", snapshot)
return snapshot
def make_server_root_kill_fault_injector(
*,
signal_name: str = "SIGKILL",
post_kill_wait_seconds: float = 0.0,
worker_markers_extra: Sequence[str] | None = None,
) -> FaultInjector:
"""Kill only the current server root process (simulate first strike on PID1-like process).
``worker_markers_extra`` is merged into worker PID detection for the fault snapshot
(after :data:`DEFAULT_RUNTIME_WORKER_MARKERS` unless
``server.reliability_worker_markers`` replaces the set entirely).
"""
def _inject(server: Any) -> None:
_log_server_process_tree(server)
snapshot = _capture_server_fault_snapshot(server, worker_markers_extra=worker_markers_extra)
root_pids = snapshot["root_pids"]
if not root_pids:
pytest.skip("no server root process found for root-kill injection")
root_pid = int(root_pids[0])
sig = getattr(signal, signal_name, None)
if sig is None:
raise ValueError(f"Unsupported signal_name: {signal_name}")
name, cmdline = _safe_proc_info(root_pid)
print(
f"[reliability][process-kill] root-kill pid={root_pid} name={name} signal={signal_name} cmdline={cmdline}",
flush=True,
)
os.kill(root_pid, sig)
if post_kill_wait_seconds > 0:
time.sleep(post_kill_wait_seconds)
return _inject
def make_server_tree_kill_fault_injector(
*,
signal_name: str = "SIGKILL",
post_kill_wait_seconds: float = 0.0,
inter_kill_wait_seconds: float = 0.2,
worker_markers_extra: Sequence[str] | None = None,
) -> FaultInjector:
"""Kill current server root first, then kill all remaining descendants.
See :func:`make_server_root_kill_fault_injector` for ``worker_markers_extra``.
"""
def _inject(server: Any) -> None:
_log_server_process_tree(server)
snapshot = _capture_server_fault_snapshot(server, worker_markers_extra=worker_markers_extra)
tree_pids = [int(pid) for pid in snapshot["tree_pids"]]
if not tree_pids:
pytest.skip("no server process tree found for tree-kill injection")
sig = getattr(signal, signal_name, None)
if sig is None:
raise ValueError(f"Unsupported signal_name: {signal_name}")
root_pid = tree_pids[0]
kill_order = [root_pid, *[pid for pid in tree_pids if pid != root_pid]]
for pid in kill_order:
if not psutil.pid_exists(pid):
continue
name, cmdline = _safe_proc_info(pid)
print(
f"[reliability][process-kill] tree-kill pid={pid} name={name} signal={signal_name} cmdline={cmdline}",
flush=True,
)
os.kill(pid, sig)
if inter_kill_wait_seconds > 0:
time.sleep(inter_kill_wait_seconds)
if post_kill_wait_seconds > 0:
time.sleep(post_kill_wait_seconds)
return _inject
def make_process_kill_fault_injector(
*,
grep_patterns: str | Sequence[str],
@@ -493,11 +896,18 @@ def make_process_kill_fault_injector(
limit: int = 1,
post_kill_wait_seconds: float = 0.0,
) -> FaultInjector:
"""Build a post-ready injector that kills processes matched by ``pgrep -f``.
"""Build a post-ready injector that kills processes matched by ordered ``grep_patterns``.
Tries each pattern in order until at least one PID is killed. If none match,
the returned callable issues ``pytest.skip`` (same behavior as the previous
inline reliability test).
Matching is tried in two phases per pattern (first hit wins):
1. **Server process tree** (``psutil``): literal substring match against
``Process.name()`` plus argv text. This matches how :func:`_safe_proc_info`
logs processes and catches vLLM-style titles (``VLLM::...``) that often do not
appear in ``pgrep -f``\'s command-line view.
2. **``pgrep -f``** (legacy): scoped to the server PID tree when it is known;
uses procps regular-expression rules for the pattern.
If neither phase finds a target, the returned callable issues ``pytest.skip``.
Args:
grep_patterns: One pattern or an ordered list of patterns.
@@ -514,9 +924,45 @@ def make_process_kill_fault_injector(
logger.warning(
"[reliability][process-kill] no server process tree found; fallback to global pgrep matching"
)
def _kill_and_wait(filtered: list[int], pattern: str, *, source: str) -> None:
sig = getattr(signal, signal_name, None)
if sig is None:
raise ValueError(f"Unsupported signal_name: {signal_name}")
for pid in filtered:
name, cmdline = _safe_proc_info(pid)
print(
f"[reliability][process-kill] killing pid={pid} name={name} signal={signal_name} "
f"source={source} cmdline={cmdline}",
flush=True,
)
os.kill(pid, sig)
print(
f"[reliability][process-kill] matched pattern={pattern!r} source={source} "
f"killed_pids={filtered} killed_count={len(filtered)}",
flush=True,
)
if post_kill_wait_seconds > 0:
print(
f"[reliability][process-kill] waiting {post_kill_wait_seconds:.2f}s after kill",
flush=True,
)
time.sleep(post_kill_wait_seconds)
for pattern in patterns:
print(
f"[reliability][process-kill] trying pattern={pattern} signal={signal_name} limit={limit}",
f"[reliability][process-kill] trying server_tree substring pattern={pattern!r} "
f"signal={signal_name} limit={limit}",
flush=True,
)
tree_hits = _pids_in_server_tree_substring_match(server, pattern, limit=limit)
if tree_hits:
_kill_and_wait(tree_hits, pattern, source="server_tree")
return
for pattern in patterns:
print(
f"[reliability][process-kill] trying pgrep -f pattern={pattern!r} signal={signal_name} limit={limit}",
flush=True,
)
pids = inject_process_kill(
@@ -535,26 +981,7 @@ def make_process_kill_fault_injector(
)
continue
if filtered:
sig = getattr(signal, signal_name, None)
if sig is None:
raise ValueError(f"Unsupported signal_name: {signal_name}")
for pid in filtered:
name, cmdline = _safe_proc_info(pid)
print(
f"[reliability][process-kill] killing pid={pid} name={name} signal={signal_name} cmdline={cmdline}",
flush=True,
)
os.kill(pid, sig)
print(
f"[reliability][process-kill] matched pattern={pattern} killed_pids={filtered} killed_count={len(filtered)}",
flush=True,
)
if post_kill_wait_seconds > 0:
print(
f"[reliability][process-kill] waiting {post_kill_wait_seconds:.2f}s after kill",
flush=True,
)
time.sleep(post_kill_wait_seconds)
_kill_and_wait(filtered, pattern, source="pgrep")
return
logger.warning(
"[reliability][process-kill] no process matched patterns=%s signal=%s limit=%s",
@@ -21,13 +21,19 @@ from tests.dfx.conftest import (
resolve_oom_device_spec,
)
from tests.dfx.reliability.helpers import (
FaultInjector,
assert_no_server_tree_process_residual_and_gpu_release,
extract_openai_error_contract_from_bytes,
get_health_raw,
inject_gpu_oom,
make_process_kill_fault_injector,
make_server_root_kill_fault_injector,
make_server_tree_kill_fault_injector,
post_chat_completions_raw,
post_json_raw,
run_fault_injection_with_rate_load,
stop_gpu_oom_hogs,
worker_residual_timeout_after_kill_signal,
)
from tests.helpers.mark import hardware_test
from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video
@@ -97,7 +103,73 @@ FAULT_ERROR_KEYWORDS = (
"500",
"503",
)
RUNTIME_WORKER_PATTERN = "VLLM::Worker"
PROCESS_KILL_ERROR_KEYWORDS = (
"timeout",
"did not complete within",
"connection",
"engine",
"orchestrator",
"dead",
"internal",
"500",
"503",
)
# Prefix match for vLLM-titled children (``VLLM::Worker``, ``VLLM::StageEngineCoreProc_*``, …).
# Must be a literal substring of ``name``/``cmdline`` — no trailing whitespace (would break match).
RUNTIME_WORKER_PATTERN = "VLLM::"
# RFC#2366 signal x target matrix:
# - worker: SIGTERM, SIGKILL
# - serve-root no-load: SIGINT; SIGTERM/SIGKILL skipped (vllm issue#43060)
# - serve-root with-load: SIGINT; SIGTERM skipped (issue#3683); SIGKILL skipped (issue#43060)
# - serve-tree no-load: SIGTERM/SIGKILL
# - serve-tree with-load: SIGKILL; SIGTERM skipped (issue#3683)
_SERVE_ROOT_NON_SIGINT_SKIP = pytest.mark.skip(reason="vllm issue#43060")
_WITH_LOAD_SIGTERM_SKIP = pytest.mark.skip(reason="issue#3683")
SERVE_SIGNAL_PARAMS = [
pytest.param("SIGTERM", id="sigterm"),
pytest.param("SIGINT", id="sigint"),
pytest.param("SIGKILL", id="sigkill", marks=_SERVE_ROOT_NON_SIGINT_SKIP),
]
SERVE_WITH_LOAD_SIGNAL_PARAMS = [
pytest.param("SIGTERM", id="sigterm", marks=_WITH_LOAD_SIGTERM_SKIP),
pytest.param("SIGINT", id="sigint", marks=_WITH_LOAD_SIGTERM_SKIP),
pytest.param("SIGKILL", id="sigkill", marks=_WITH_LOAD_SIGTERM_SKIP),
]
TREE_SIGNAL_PARAMS = [
pytest.param("SIGTERM", id="sigterm"),
pytest.param("SIGKILL", id="sigkill"),
]
TREE_WITH_LOAD_SIGNAL_PARAMS = [
pytest.param("SIGTERM", id="sigterm", marks=_WITH_LOAD_SIGTERM_SKIP),
pytest.param("SIGKILL", id="sigkill"),
]
WORKER_SIGNAL_FAULT_PARAMS = [
pytest.param(
make_process_kill_fault_injector(
grep_patterns=RUNTIME_WORKER_PATTERN,
signal_name="SIGTERM",
limit=1,
post_kill_wait_seconds=2.0,
),
id="runtime_process_chain_sigterm",
),
pytest.param(
make_process_kill_fault_injector(
grep_patterns=RUNTIME_WORKER_PATTERN,
signal_name="SIGKILL",
limit=1,
post_kill_wait_seconds=2.0,
),
id="runtime_process_chain_sigkill",
),
]
INFLIGHT_INJECTION_REQUEST_RATE = 0.3
INFLIGHT_INJECTION_REQUEST_COUNT = 10
# Post-fault chat probe: ``MAX_ELAPSED`` aligns with HTTP read timeout; assert allows +1s jitter
# when the client raises at timeout (e.g. ~30.03s).
_PROCESS_KILL_WORKER_CHAT_FF_HTTP_TIMEOUT_SEC = 30
_PROCESS_KILL_WORKER_CHAT_FF_MAX_ELAPSED_SEC = 30
class _HasServeArgs(Protocol):
@@ -194,6 +266,55 @@ def _looks_like_server_unreachable(exc: BaseException) -> bool:
return "connection refused" in msg or "actively refused" in msg
def _chat_request_config(omni_server: _HasServeArgs) -> dict[str, Any]:
messages = dummy_messages_from_mix_data(
system_prompt=_get_system_prompt(),
content_text="What is the capital of China? Answer in one short sentence.",
)
return {
"model": omni_server.model,
"messages": messages,
"stream": False,
"modalities": ["text"],
}
def _assert_post_fault_chat_fast_fail(host: str, port: int, *, model: str, scenario: str) -> None:
payload = {
"model": model,
"messages": [{"role": "user", "content": "post-fault fast-fail check"}],
"stream": False,
"modalities": ["text"],
}
start = time.monotonic()
try:
status, body = post_json_raw(host, port, "/v1/chat/completions", payload, timeout_sec=20)
elapsed = time.monotonic() - start
assert elapsed < 15, f"[{scenario} fast_fail] /v1/chat/completions did not fail fast: {elapsed:.2f}s"
assert status >= 500, (
f"[{scenario} fast_fail] expected server-side error after fault, got status={status}, body={body[:200]!r}"
)
except Exception: # noqa: BLE001
elapsed = time.monotonic() - start
assert elapsed < 15, f"[{scenario} fast_fail] exception was too slow after fault: {elapsed:.2f}s"
def _assert_post_fault_health_terminal(host: str, port: int, *, scenario: str) -> None:
deadline = time.monotonic() + 20.0
last_observation = ""
while time.monotonic() < deadline:
try:
status, body = get_health_raw(host, port, timeout_sec=5)
last_observation = f"http={status}, body={body[:200]!r}"
if status == 503:
return
except Exception as exc: # noqa: BLE001
last_observation = f"exception={exc!r}"
return
time.sleep(0.5)
pytest.fail(f"[{scenario} health] no terminal post-fault health observed: {last_observation}")
QWEN_PARAMS = create_reliability_omni_server_params(RELIABILITY_SCENARIOS, DEPLOY_CONFIGS_DIR)
@@ -354,17 +475,7 @@ def test_reliability_fault_gpu_oom_concurrent_pressure_failure(omni_server_funct
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize(
"fault_injector",
[
pytest.param(
make_process_kill_fault_injector(
grep_patterns="VLLM::Worker",
signal_name="SIGKILL",
limit=1,
post_kill_wait_seconds=2.0,
),
id="runtime_process_chain",
),
],
WORKER_SIGNAL_FAULT_PARAMS,
indirect=True,
)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
@@ -385,7 +496,7 @@ def test_reliability_fault_process_kill_request_failure(
try:
openai_client_function.send_omni_request(request_config, request_num=1)
except Exception as exc:
assert_fault_exception(exc, FAULT_ERROR_KEYWORDS)
assert_fault_exception(exc, PROCESS_KILL_ERROR_KEYWORDS)
else:
pytest.fail("expected request failure after process-kill injection")
@@ -394,48 +505,22 @@ def test_reliability_fault_process_kill_request_failure(
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize(
"fault_injector",
[
pytest.param(
make_process_kill_fault_injector(
grep_patterns="VLLM::Worker",
signal_name="SIGKILL",
limit=1,
post_kill_wait_seconds=2.0,
),
id="runtime_process_chain",
),
],
WORKER_SIGNAL_FAULT_PARAMS,
indirect=True,
)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_health_fast_fail_and_concurrent(
omni_server_after_fault_function,
) -> None:
"""Black-box: after worker SIGKILL, /health→503, chat fails fast, concurrent chat does not hang."""
"""Black-box: after worker process kill, chat fails fast, concurrent chat does not hang; /health→503 last.
``SIGTERM`` on a stage engine often exceeds a 15s client-visible path; ``SIGKILL`` is
typically quicker. See module constants ``_PROCESS_KILL_WORKER_CHAT_FF_*``.
"""
host = omni_server_after_fault_function.host
port = omni_server_after_fault_function.port
model = omni_server_after_fault_function.model
deadline = time.monotonic() + 20.0
last_observation = ""
saw_503 = False
health_final_status: int | None = None
health_final_body = b""
while time.monotonic() < deadline:
try:
status, body = get_health_raw(host, port, timeout_sec=5)
last_observation = f"http={status}, body={body[:200]!r}"
health_final_status, health_final_body = status, body
if status == 503:
saw_503 = True
break
except Exception as exc: # noqa: BLE001
last_observation = f"exception={exc!r}"
time.sleep(0.5)
assert saw_503, (
f"[process_kill health] expected /health to become 503 after fault injection, got {last_observation}"
)
payload = {
"model": model,
"messages": [{"role": "user", "content": "Say hello in one short sentence."}],
@@ -444,20 +529,28 @@ def test_reliability_fault_process_kill_health_fast_fail_and_concurrent(
}
ff_status: int | None = None
ff_body = b""
ff_exc: BaseException | None = None
start = time.monotonic()
try:
ff_status, ff_body = post_json_raw(host, port, "/v1/chat/completions", payload, timeout_sec=20)
ff_status, ff_body = post_json_raw(
host,
port,
"/v1/chat/completions",
payload,
timeout_sec=_PROCESS_KILL_WORKER_CHAT_FF_HTTP_TIMEOUT_SEC,
)
elapsed = time.monotonic() - start
assert elapsed < 15, f"[process_kill fast_fail] request did not fail fast after fault: {elapsed:.2f}s"
assert elapsed <= _PROCESS_KILL_WORKER_CHAT_FF_MAX_ELAPSED_SEC + 1.0, (
f"[process_kill fast_fail] request did not fail fast after fault: {elapsed:.2f}s"
)
assert ff_status >= 500, (
f"[process_kill fast_fail] expected server-side failure after fault, "
f"got status={ff_status}, body={ff_body[:200]!r}"
)
except Exception as exc:
ff_exc = exc
except Exception:
elapsed = time.monotonic() - start
assert elapsed < 15, f"[process_kill fast_fail] request exception was too slow after fault: {elapsed:.2f}s"
assert elapsed <= _PROCESS_KILL_WORKER_CHAT_FF_MAX_ELAPSED_SEC + 1.0, (
f"[process_kill fast_fail] request exception was too slow after fault: {elapsed:.2f}s"
)
payload_json = json.dumps(
{
@@ -490,32 +583,195 @@ def test_reliability_fault_process_kill_health_fast_fail_and_concurrent(
assert elapsed < 30, f"[process_kill concurrent] fault-time request convergence is too slow: {elapsed:.2f}s"
fault_observed = False
conc_debug: list[Any] = []
for future in done:
try:
status, body = future.result()
conc_debug.append((status, body[:200]))
if status >= 500:
fault_observed = True
except Exception as exc:
conc_debug.append(repr(exc))
assert_fault_exception(exc, PROCESS_KILL_ERROR_KEYWORDS)
fault_observed = True
# DEBUG: remove before merge
print(
health_final_status,
health_final_body[:200],
ff_status,
ff_body[:200],
ff_exc,
conc_debug,
)
assert fault_observed, (
"[process_kill concurrent] expected at least one request to fail after process-kill fault injection"
)
deadline = time.monotonic() + 20.0
last_observation = ""
final_health_status: int | None = None
while time.monotonic() < deadline:
try:
status, body = get_health_raw(host, port, timeout_sec=5)
last_observation = f"http={status}, body={body[:200]!r}"
final_health_status = status
if status == 503:
break
except Exception as exc: # noqa: BLE001
last_observation = f"exception={exc!r}"
time.sleep(0.5)
if final_health_status != 503:
pytest.skip("issue#3050")
assert final_health_status == 503, (
"[process_kill health] expected /health 503 after fatal fault, "
f"got status={final_health_status}, last_observation={last_observation}"
)
@pytest.mark.slow
@pytest.mark.skip(reason="issue#2327")
@pytest.mark.skip(reason="issue#3683")
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize(
"fault_injector",
WORKER_SIGNAL_FAULT_PARAMS,
indirect=True,
)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_worker_with_load_request_failure(
omni_server_function,
openai_client_function,
fault_injector: FaultInjector,
) -> None:
request_config = _chat_request_config(omni_server_function)
scenario = "kill_worker_with_load"
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: openai_client_function.send_omni_request(request_config, request_num=1),
inject_fault=lambda: fault_injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
completion_timeout_sec=120.0,
)
assert load_result["failure_observed"], (
f"[{scenario}] expected at least one load request failure after fault; load_result={load_result}"
)
host = omni_server_function.host
port = omni_server_function.port
_assert_post_fault_chat_fast_fail(host, port, model=omni_server_function.model, scenario=scenario)
_assert_post_fault_health_terminal(host, port, scenario=scenario)
@pytest.mark.slow
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize("signal_name", SERVE_SIGNAL_PARAMS)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_serve_root_no_load_fast_fail_and_cleanup(
omni_server_function,
signal_name: str,
) -> None:
"""Black-box: kill serve root without load; verify fast-fail/health/cleanup."""
scenario = f"kill_serve_root_no_load_{signal_name.lower()}"
injector = make_server_root_kill_fault_injector(signal_name=signal_name, post_kill_wait_seconds=2.0)
injector(omni_server_function)
host = omni_server_function.host
port = omni_server_function.port
_assert_post_fault_chat_fast_fail(host, port, model=omni_server_function.model, scenario=scenario)
_assert_post_fault_health_terminal(host, port, scenario=scenario)
assert_no_server_tree_process_residual_and_gpu_release(
omni_server_function,
scenario=scenario,
timeout_sec=worker_residual_timeout_after_kill_signal(signal_name),
)
@pytest.mark.slow
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize("signal_name", SERVE_WITH_LOAD_SIGNAL_PARAMS)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_serve_root_with_load_fast_fail_and_cleanup(
omni_server_function,
openai_client_function,
signal_name: str,
) -> None:
request_config = _chat_request_config(omni_server_function)
scenario = f"kill_serve_root_with_load_{signal_name.lower()}"
injector = make_server_root_kill_fault_injector(signal_name=signal_name, post_kill_wait_seconds=2.0)
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: openai_client_function.send_omni_request(request_config, request_num=1),
inject_fault=lambda: injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
completion_timeout_sec=300.0,
)
assert load_result["failure_observed"], (
f"[{scenario}] expected at least one load request failure after fault; load_result={load_result}"
)
host = omni_server_function.host
port = omni_server_function.port
_assert_post_fault_chat_fast_fail(host, port, model=omni_server_function.model, scenario=scenario)
_assert_post_fault_health_terminal(host, port, scenario=scenario)
assert_no_server_tree_process_residual_and_gpu_release(
omni_server_function,
scenario=scenario,
timeout_sec=worker_residual_timeout_after_kill_signal(signal_name),
)
@pytest.mark.slow
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize("signal_name", TREE_SIGNAL_PARAMS)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_tree_no_load_fast_fail_and_cleanup(
omni_server_function,
signal_name: str,
) -> None:
"""Black-box: kill server tree without load; verify fast-fail/health/cleanup."""
scenario = f"kill_serve_tree_no_load_{signal_name.lower()}"
injector = make_server_tree_kill_fault_injector(
signal_name=signal_name,
post_kill_wait_seconds=2.0,
inter_kill_wait_seconds=0.1,
)
injector(omni_server_function)
host = omni_server_function.host
port = omni_server_function.port
_assert_post_fault_chat_fast_fail(host, port, model=omni_server_function.model, scenario=scenario)
_assert_post_fault_health_terminal(host, port, scenario=scenario)
assert_no_server_tree_process_residual_and_gpu_release(
omni_server_function,
scenario=scenario,
timeout_sec=worker_residual_timeout_after_kill_signal(signal_name),
)
@pytest.mark.slow
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize("signal_name", TREE_WITH_LOAD_SIGNAL_PARAMS)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup(
omni_server_function,
openai_client_function,
signal_name: str,
) -> None:
request_config = _chat_request_config(omni_server_function)
scenario = f"kill_serve_tree_with_load_{signal_name.lower()}"
injector = make_server_tree_kill_fault_injector(
signal_name=signal_name,
post_kill_wait_seconds=2.0,
inter_kill_wait_seconds=0.1,
)
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: openai_client_function.send_omni_request(request_config, request_num=1),
inject_fault=lambda: injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
completion_timeout_sec=120.0,
)
assert load_result["failure_observed"], (
f"[{scenario}] expected at least one load request failure after fault; load_result={load_result}"
)
host = omni_server_function.host
port = omni_server_function.port
_assert_post_fault_chat_fast_fail(host, port, model=omni_server_function.model, scenario=scenario)
_assert_post_fault_health_terminal(host, port, scenario=scenario)
assert_no_server_tree_process_residual_and_gpu_release(
omni_server_function,
scenario=scenario,
timeout_sec=worker_residual_timeout_after_kill_signal(signal_name),
)
@pytest.mark.slow
@pytest.mark.skip(reason="OOM injection causes the serving instance to exit")
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
def test_reliability_fault_gpu_oom_state_converges_after_fault_removed(
+273 -51
View File
@@ -20,28 +20,25 @@ from tests.dfx.conftest import (
supports_video_generation,
)
from tests.dfx.reliability.helpers import (
FaultInjector,
assert_no_server_tree_process_residual_and_gpu_release,
get_health_raw,
inject_gpu_oom,
make_process_kill_fault_injector,
make_server_root_kill_fault_injector,
make_server_tree_kill_fault_injector,
run_fault_injection_with_rate_load,
stop_gpu_oom_hogs,
worker_residual_timeout_after_kill_signal,
)
from tests.helpers.mark import hardware_test
from tests.helpers.media import generate_synthetic_image
RELIABILITY_SCENARIOS: list[dict[str, Any]] = [
{
"test_name": "wan22_t2v_reliability_default",
"test_name": "wan22_i2v_reliability_default",
"server_params": {
"model": "Wan-AI/Wan2.2-T2V-A14B-Diffusers",
"server_args": [
"--num-gpus",
"1",
"--boundary-ratio",
"0.875",
"--flow-shift",
"5.0",
"--disable-log-stats",
],
"model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers",
},
}
]
@@ -73,9 +70,114 @@ PROCESS_KILL_ERROR_KEYWORDS = (
"500",
"503",
)
# RFC#2366 signal x target matrix (aligned with test_reliability_qwen3_omni.py):
# - worker: SIGTERM, SIGKILL
# - serve-root: SIGTERM, SIGINT; SIGKILL skipped (issue#3725)
# - serve-tree no-load: SIGTERM, SIGKILL
# - serve-tree with-load: SIGTERM, SIGKILL
_SERVE_ROOT_SIGKILL_SKIP = pytest.mark.skip(reason="issue#3725")
SERVE_SIGNAL_PARAMS = [
pytest.param("SIGTERM", id="sigterm"),
pytest.param("SIGINT", id="sigint"),
pytest.param("SIGKILL", id="sigkill", marks=_SERVE_ROOT_SIGKILL_SKIP),
]
TREE_SIGNAL_PARAMS = [
pytest.param("SIGTERM", id="sigterm"),
pytest.param("SIGKILL", id="sigkill"),
]
TREE_WITH_LOAD_SIGNAL_PARAMS = [
pytest.param("SIGTERM", id="sigterm"),
pytest.param("SIGKILL", id="sigkill"),
]
WORKER_SIGNAL_FAULT_PARAMS = [
pytest.param(
make_process_kill_fault_injector(
grep_patterns="vLLM-Omni::",
signal_name="SIGTERM",
limit=1,
post_kill_wait_seconds=2.0,
),
id="runtime_process_chain_sigterm",
),
pytest.param(
make_process_kill_fault_injector(
grep_patterns="vLLM-Omni::",
signal_name="SIGKILL",
limit=1,
post_kill_wait_seconds=2.0,
),
id="runtime_process_chain_sigkill",
),
]
WAN_PARAMS = create_reliability_omni_server_params(RELIABILITY_SCENARIOS, E2E_STAGE_CONFIGS_DIR)
DIFFUSION_VIDEO_PARAMS = [param for param in WAN_PARAMS if supports_video_generation(param.model)]
INFLIGHT_INJECTION_REQUEST_RATE = 0.3
INFLIGHT_INJECTION_REQUEST_COUNT = 10
def _video_request_config() -> dict[str, Any]:
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(1280, 720)['base64']}"
return {
"form_data": {
"prompt": "Generate a realistic road-driving video with camera motion.",
"width": 512,
"height": 512,
"fps": 8,
"num_frames": 8,
"guidance_scale": 1.0,
"flow_shift": 5.0,
"num_inference_steps": 4,
"seed": 42,
},
"image_reference": image_data_url,
"stream": False,
}
def _assert_post_fault_video_fast_fail(host: str, port: int, *, scenario: str) -> None:
url = f"http://{host}:{port}/v1/videos"
payload = {
"prompt": "post-fault fast-fail check",
"width": "512",
"height": "512",
"fps": "8",
"num_frames": "8",
"num_inference_steps": "4",
}
start = time.monotonic()
try:
response = requests.post(
url,
data=payload,
headers={"Accept": "application/json"},
timeout=20,
)
elapsed = time.monotonic() - start
assert elapsed < 15, f"[{scenario} fast_fail] /v1/videos did not fail fast: {elapsed:.2f}s"
assert response.status_code >= 500, (
f"[{scenario} fast_fail] expected server-side error after fault, "
f"got status={response.status_code}, body={response.text[:200]!r}"
)
except Exception: # noqa: BLE001
elapsed = time.monotonic() - start
assert elapsed < 15, f"[{scenario} fast_fail] exception was too slow after fault: {elapsed:.2f}s"
def _assert_post_fault_health_terminal(host: str, port: int, *, scenario: str) -> None:
deadline = time.monotonic() + 20.0
last_observation = ""
while time.monotonic() < deadline:
try:
status, body = get_health_raw(host, port, timeout_sec=5)
last_observation = f"http={status}, body={body[:200]!r}"
if status == 503:
return
except Exception as exc: # noqa: BLE001
last_observation = f"exception={exc!r}"
return
time.sleep(0.5)
pytest.fail(f"[{scenario} health] no terminal post-fault health observed: {last_observation}")
@pytest.mark.slow
@@ -122,40 +224,15 @@ def test_reliability_fault_gpu_oom_video_large_request_failure(omni_server_funct
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize(
"fault_injector",
[
pytest.param(
make_process_kill_fault_injector(
grep_patterns="multiprocessing.spawn",
signal_name="SIGKILL",
limit=1,
post_kill_wait_seconds=2.0,
),
id="runtime_process_chain",
),
],
WORKER_SIGNAL_FAULT_PARAMS,
indirect=True,
)
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_process_kill_video_request_failure(
def test_reliability_fault_process_kill_request_failure(
omni_server_after_fault_function,
openai_client_function,
) -> None:
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(1280, 720)['base64']}"
request_config = {
"form_data": {
"prompt": "Generate a realistic road-driving video with camera motion.",
"width": 512,
"height": 512,
"fps": 8,
"num_frames": 8,
"guidance_scale": 1.0,
"flow_shift": 5.0,
"num_inference_steps": 4,
"seed": 42,
},
"image_reference": image_data_url,
"stream": False,
}
request_config = _video_request_config()
try:
openai_client_function.send_video_diffusion_request(request_config, request_num=1)
except Exception as exc:
@@ -168,21 +245,11 @@ def test_reliability_fault_process_kill_video_request_failure(
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize(
"fault_injector",
[
pytest.param(
make_process_kill_fault_injector(
grep_patterns="multiprocessing.spawn",
signal_name="SIGKILL",
limit=1,
post_kill_wait_seconds=2.0,
),
id="runtime_process_chain",
),
],
WORKER_SIGNAL_FAULT_PARAMS,
indirect=True,
)
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_process_kill_video_health_fast_fail_and_concurrent(
def test_reliability_fault_process_kill_health_fast_fail_and_concurrent(
omni_server_after_fault_function,
openai_client_function,
) -> None:
@@ -290,6 +357,37 @@ def test_reliability_fault_process_kill_video_health_fast_fail_and_concurrent(
)
@pytest.mark.slow
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize(
"fault_injector",
WORKER_SIGNAL_FAULT_PARAMS,
indirect=True,
)
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_process_kill_worker_with_load_request_failure(
omni_server_function,
openai_client_function,
fault_injector: FaultInjector,
) -> None:
request_config = _video_request_config()
scenario = "kill_worker_with_load"
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: openai_client_function.send_video_diffusion_request(request_config, request_num=1),
inject_fault=lambda: fault_injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
completion_timeout_sec=120.0,
)
assert load_result["failure_observed"], (
f"[{scenario}] expected at least one load request failure after fault; load_result={load_result}"
)
host = omni_server_function.host
port = omni_server_function.port
_assert_post_fault_video_fast_fail(host, port, scenario=scenario)
_assert_post_fault_health_terminal(host, port, scenario=scenario)
@pytest.mark.slow
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.skip(reason="issue#2327")
@@ -409,3 +507,127 @@ def test_reliability_video_oom_recovers_after_fault_removed(
"unhealthy terminal state should fail fast on /v1/videos, "
f"got health={terminal_health}, request_status={recovery_status}, body={recovery_text_prefix!r}"
)
@pytest.mark.slow
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize("signal_name", SERVE_SIGNAL_PARAMS)
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_process_kill_serve_root_with_load_fast_fail_and_cleanup(
omni_server_function,
openai_client_function,
signal_name: str,
) -> None:
"""Black-box: during in-flight video requests, kill serve root and verify post-fault behavior/cleanup."""
request_config = _video_request_config()
scenario = f"kill_serve_root_with_load_{signal_name.lower()}"
injector = make_server_root_kill_fault_injector(signal_name=signal_name, post_kill_wait_seconds=2.0)
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: openai_client_function.send_video_diffusion_request(request_config, request_num=1),
inject_fault=lambda: injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
completion_timeout_sec=120.0,
)
assert load_result["failure_observed"], (
f"[{scenario}] expected at least one load request failure after fault; load_result={load_result}"
)
host = omni_server_function.host
port = omni_server_function.port
_assert_post_fault_video_fast_fail(host, port, scenario=scenario)
_assert_post_fault_health_terminal(host, port, scenario=scenario)
assert_no_server_tree_process_residual_and_gpu_release(
omni_server_function,
scenario=scenario,
timeout_sec=worker_residual_timeout_after_kill_signal(signal_name),
)
@pytest.mark.slow
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize("signal_name", SERVE_SIGNAL_PARAMS)
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_process_kill_serve_root_no_load_fast_fail_and_cleanup(
omni_server_function,
signal_name: str,
) -> None:
"""Black-box: kill serve root without load; verify fast-fail/health/cleanup."""
scenario = f"kill_serve_root_no_load_{signal_name.lower()}"
injector = make_server_root_kill_fault_injector(signal_name=signal_name, post_kill_wait_seconds=2.0)
injector(omni_server_function)
host = omni_server_function.host
port = omni_server_function.port
_assert_post_fault_video_fast_fail(host, port, scenario=scenario)
_assert_post_fault_health_terminal(host, port, scenario=scenario)
assert_no_server_tree_process_residual_and_gpu_release(
omni_server_function,
scenario=scenario,
timeout_sec=worker_residual_timeout_after_kill_signal(signal_name),
)
@pytest.mark.slow
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize("signal_name", TREE_SIGNAL_PARAMS)
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_process_kill_tree_no_load_fast_fail_and_cleanup(
omni_server_function,
signal_name: str,
) -> None:
"""Black-box: kill server tree without load; verify fast-fail/health/cleanup."""
scenario = f"kill_serve_tree_no_load_{signal_name.lower()}"
injector = make_server_tree_kill_fault_injector(
signal_name=signal_name,
post_kill_wait_seconds=2.0,
inter_kill_wait_seconds=0.1,
)
injector(omni_server_function)
host = omni_server_function.host
port = omni_server_function.port
_assert_post_fault_video_fast_fail(host, port, scenario=scenario)
_assert_post_fault_health_terminal(host, port, scenario=scenario)
assert_no_server_tree_process_residual_and_gpu_release(
omni_server_function,
scenario=scenario,
timeout_sec=worker_residual_timeout_after_kill_signal(signal_name),
)
@pytest.mark.slow
@pytest.mark.skipif(os.name == "nt", reason="process-kill injection helper is POSIX-only")
@pytest.mark.parametrize("signal_name", TREE_WITH_LOAD_SIGNAL_PARAMS)
@pytest.mark.parametrize("omni_server_function", DIFFUSION_VIDEO_PARAMS, indirect=True)
def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup(
omni_server_function,
openai_client_function,
signal_name: str,
) -> None:
"""Black-box: during in-flight video requests, kill server process tree and verify post-fault behavior/cleanup."""
request_config = _video_request_config()
scenario = f"kill_serve_tree_with_load_{signal_name.lower()}"
injector = make_server_tree_kill_fault_injector(
signal_name=signal_name,
post_kill_wait_seconds=2.0,
inter_kill_wait_seconds=0.1,
)
load_result = run_fault_injection_with_rate_load(
submit_request=lambda: openai_client_function.send_video_diffusion_request(request_config, request_num=1),
inject_fault=lambda: injector(omni_server_function),
num_requests=INFLIGHT_INJECTION_REQUEST_COUNT,
request_rate=INFLIGHT_INJECTION_REQUEST_RATE,
completion_timeout_sec=120.0,
)
assert load_result["failure_observed"], (
f"[{scenario}] expected at least one load request failure after fault; load_result={load_result}"
)
host = omni_server_function.host
port = omni_server_function.port
_assert_post_fault_video_fast_fail(host, port, scenario=scenario)
_assert_post_fault_health_terminal(host, port, scenario=scenario)
assert_no_server_tree_process_residual_and_gpu_release(
omni_server_function,
scenario=scenario,
timeout_sec=worker_residual_timeout_after_kill_signal(signal_name),
)
@@ -0,0 +1,131 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import importlib
import sys
import types
import pytest
import torch
from vllm.platforms import current_platform
SAGE_ATTN3_MODULE = "vllm_omni.diffusion.attention.backends.sage_attn3"
def load_sage_attn3_module(monkeypatch: pytest.MonkeyPatch, kernel_impl):
fake_module = types.ModuleType("sageattn3")
fake_module.sageattn3_blackwell = kernel_impl
monkeypatch.setitem(sys.modules, "sageattn3", fake_module)
sys.modules.pop(SAGE_ATTN3_MODULE, None)
return importlib.import_module(SAGE_ATTN3_MODULE)
def test_sage_attn3_forward_uses_blackwell_layout(monkeypatch: pytest.MonkeyPatch):
calls = {}
def fake_kernel(query, key, value, is_causal=False):
calls["query_shape"] = query.shape
calls["is_causal"] = is_causal
return query + key + value
backend_module = load_sage_attn3_module(monkeypatch, fake_kernel)
impl = backend_module.SageAttention3Impl(
num_heads=4,
head_size=64,
softmax_scale=1.0 / 8.0,
causal=False,
)
query = torch.randn(2, 8, 4, 64)
key = torch.randn(2, 8, 4, 64)
value = torch.randn(2, 8, 4, 64)
output = impl.forward_cuda(query, key, value)
assert calls["query_shape"] == (2, 4, 8, 64)
assert calls["is_causal"] is False
expected = (query.transpose(1, 2) + key.transpose(1, 2) + value.transpose(1, 2)).transpose(1, 2)
assert torch.allclose(output, expected)
def test_sage_attn3_falls_back_to_sdpa_for_gqa(monkeypatch: pytest.MonkeyPatch):
def fake_kernel(*args, **kwargs):
raise AssertionError("sageattn3_blackwell should not be used for GQA")
backend_module = load_sage_attn3_module(monkeypatch, fake_kernel)
sdpa_calls = {}
def fake_sdpa(query, key, value, **kwargs):
sdpa_calls["query_shape"] = query.shape
sdpa_calls["key_shape"] = key.shape
sdpa_calls["enable_gqa"] = kwargs["enable_gqa"]
return query + 1
monkeypatch.setattr(backend_module.F, "scaled_dot_product_attention", fake_sdpa)
impl = backend_module.SageAttention3Impl(
num_heads=4,
head_size=64,
softmax_scale=1.0 / 8.0,
causal=False,
)
query = torch.randn(2, 8, 4, 64)
key = torch.randn(2, 8, 2, 64)
value = torch.randn(2, 8, 2, 64)
output = impl.forward_cuda(query, key, value)
assert sdpa_calls["query_shape"] == (2, 4, 8, 64)
assert sdpa_calls["key_shape"] == (2, 2, 8, 64)
assert sdpa_calls["enable_gqa"] is True
expected = (query.permute(0, 2, 1, 3) + 1).permute(0, 2, 1, 3)
assert torch.allclose(output, expected)
@pytest.mark.skipif(not current_platform.is_cuda(), reason="sage_attn3 tests require CUDA platform")
def test_cuda_platform_selects_sage_attn3_alias(monkeypatch: pytest.MonkeyPatch):
from vllm.platforms.interface import DeviceCapability
from vllm_omni.diffusion.attention.backends.registry import DiffusionAttentionBackendEnum
from vllm_omni.diffusion.envs import PACKAGES_CHECKER
from vllm_omni.platforms.cuda import platform as cuda_platform_module
from vllm_omni.platforms.cuda.platform import CudaOmniPlatform
original_import_module = importlib.import_module
monkeypatch.setattr(
CudaOmniPlatform,
"get_device_capability",
classmethod(lambda cls, device_id=0: DeviceCapability(10, 0)),
)
monkeypatch.setattr(PACKAGES_CHECKER, "get_packages_info", lambda: {"has_flash_attn": False})
monkeypatch.setattr(
cuda_platform_module.importlib,
"import_module",
lambda module_name: object() if module_name == "sageattn3" else original_import_module(module_name),
)
backend_path = CudaOmniPlatform.get_diffusion_attn_backend_cls("SAGE_ATTN_3", head_size=64)
assert backend_path == DiffusionAttentionBackendEnum.SAGE_ATTN_3.get_path()
@pytest.mark.skipif(not current_platform.is_cuda(), reason="sage_attn3 tests require CUDA platform")
def test_cuda_platform_falls_back_when_sage_attn3_gpu_is_unsupported(monkeypatch: pytest.MonkeyPatch):
from vllm.platforms.interface import DeviceCapability
from vllm_omni.diffusion.attention.backends.registry import DiffusionAttentionBackendEnum
from vllm_omni.diffusion.envs import PACKAGES_CHECKER
from vllm_omni.platforms.cuda.platform import CudaOmniPlatform
monkeypatch.setattr(
CudaOmniPlatform,
"get_device_capability",
classmethod(lambda cls, device_id=0: DeviceCapability(9, 0)),
)
monkeypatch.setattr(PACKAGES_CHECKER, "get_packages_info", lambda: {"has_flash_attn": False})
backend_path = CudaOmniPlatform.get_diffusion_attn_backend_cls("SAGE_ATTN_3", head_size=64)
assert backend_path == DiffusionAttentionBackendEnum.TORCH_SDPA.get_path()
+148
View File
@@ -19,6 +19,7 @@ from vllm_omni.diffusion.cache.cache_dit_backend import (
CUSTOM_DIT_ENABLERS,
CacheDiTBackend,
)
from vllm_omni.diffusion.cache.magcache.backend import MagCacheBackend
from vllm_omni.diffusion.cache.selector import get_cache_backend
from vllm_omni.diffusion.cache.teacache.backend import TeaCacheBackend
from vllm_omni.diffusion.data import DiffusionCacheConfig
@@ -313,3 +314,150 @@ class TestCacheSelector:
"""Test getting invalid backend raises error."""
with pytest.raises(ValueError, match="Unsupported cache backend"):
get_cache_backend("invalid_backend", {})
class TestMagCacheBackend:
"""Test MagCacheBackend implementation."""
from vllm_omni.diffusion.cache.magcache.backend import MagCacheBackend
def test_init(self):
"""Test initialization."""
config = DiffusionCacheConfig(mag_threshold=0.1, mag_max_skip_steps=2, mag_calibrate=True)
backend = MagCacheBackend(config)
assert backend.config.mag_threshold == 0.1
assert backend.config.mag_max_skip_steps == 2
assert backend.enabled is False
@patch("vllm_omni.diffusion.cache.magcache.backend.apply_mag_cache_hook")
def test_enable(self, mock_apply_hook):
"""Test enabling MagCache on pipeline."""
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "FluxPipeline"
mock_transformer = Mock()
mock_transformer.__class__.__name__ = "FluxTransformer2DModel"
mock_pipeline.transformer = mock_transformer
mock_ratios = [1.0] * 28
config = DiffusionCacheConfig(
mag_ratios=mock_ratios,
)
backend = MagCacheBackend(config)
backend.enable(mock_pipeline)
assert backend.enabled is True
mock_apply_hook.assert_called_once()
call_args = mock_apply_hook.call_args
assert call_args[0][0] == mock_transformer
@patch("vllm_omni.diffusion.cache.magcache.backend.apply_mag_cache_hook")
def test_enable_with_calibration(self, mock_apply_hook):
"""Test enabling MagCache in calibration mode."""
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "FluxPipeline"
mock_transformer = Mock()
mock_transformer.__class__.__name__ = "FluxTransformer2DModel"
mock_pipeline.transformer = mock_transformer
config = DiffusionCacheConfig(
mag_calibrate=True,
)
backend = MagCacheBackend(config)
backend.enable(mock_pipeline)
assert backend.enabled is True
mock_apply_hook.assert_called_once()
def test_refresh(self):
"""Test refreshing MagCache state calls enable when not registered."""
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "FluxPipeline"
mock_transformer = Mock()
mock_transformer.__class__.__name__ = "FluxTransformer2DModel"
mock_pipeline.transformer = mock_transformer
mock_transformer.named_children = Mock(return_value=[])
mock_ratios = [1.0] * 28
config = DiffusionCacheConfig(
mag_ratios=mock_ratios,
)
backend = MagCacheBackend(config)
assert backend._registered is False
backend.refresh(mock_pipeline, num_inference_steps=50)
assert backend._registered is True
def test_is_enabled(self):
"""Test is_enabled method."""
mock_ratios = [1.0] * 28
config = DiffusionCacheConfig(mag_ratios=mock_ratios)
backend = MagCacheBackend(config)
assert backend.is_enabled() is False
def test_get_mag_cache_backend(self):
"""Test getting MagCache backend via selector."""
mock_ratios = [1.0] * 28
config_dict = {
"mag_ratios": mock_ratios,
"num_inference_steps": 28,
"threshold": 0.06,
"max_skip_steps": 3,
"retention_ratio": 0.2,
}
backend = get_cache_backend("mag_cache", config_dict)
assert backend is not None
assert isinstance(backend, MagCacheBackend)
assert backend.config.threshold == 0.06
@patch("vllm_omni.diffusion.cache.magcache.backend.apply_mag_cache_hook")
def test_enable_single_block(self, mock_apply_hook):
"""Test enabling MagCache on single transformer block."""
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "FluxPipeline"
mock_block = Mock()
mock_block.__class__.__name__ = "FluxTransformer2DModel"
mock_blocks = [mock_block]
mock_transformer = Mock()
mock_transformer.__class__.__name__ = "FluxTransformer2DModel"
mock_transformer.blocks = mock_blocks
mock_pipeline.transformer = mock_transformer
mock_ratios = [1.0] * 28
config = DiffusionCacheConfig(
mag_ratios=mock_ratios,
)
backend = MagCacheBackend(config)
backend.enable(mock_pipeline)
assert backend.enabled is True
mock_apply_hook.assert_called_once()
call_args = mock_apply_hook.call_args
assert call_args[0][0] == mock_transformer
@patch("vllm_omni.diffusion.cache.magcache.backend.apply_mag_cache_hook")
def test_enable_multi_block(self, mock_apply_hook):
"""Test enabling MagCache on multiple transformer blocks."""
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "FluxPipeline"
mock_blocks = [Mock() for _ in range(24)]
mock_transformer = Mock()
mock_transformer.__class__.__name__ = "FluxTransformer2DModel"
mock_transformer.blocks = mock_blocks
mock_pipeline.transformer = mock_transformer
mock_ratios = [1.0] * 28
config = DiffusionCacheConfig(
mag_ratios=mock_ratios,
)
backend = MagCacheBackend(config)
backend.enable(mock_pipeline)
assert backend.enabled is True
mock_apply_hook.assert_called_once()
+529
View File
@@ -0,0 +1,529 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Unit tests for prompt-embedding cache for diffusion pipelines.
Covers the module :mod:`vllm_omni.diffusion.cache.prompt_embed_cache`:
* :class:`PromptEmbedCache` LRU semantics, stats and thread-safety.
* :func:`_hashable` safe-input classification (scalars, torch dtype/device,
numpy scalars, nested containers, tensors/PIL bypass).
* :func:`install_prompt_embed_cache` / :func:`uninstall_prompt_embed_cache`
behaviour: caching, bypass on tensors, bypass on precomputed embeds,
idempotent install, restore on uninstall, detachment of cached tensors.
* :func:`resolve_prompt_embed_cache_config` env-var overrides.
"""
from __future__ import annotations
import threading
import pytest
import torch
from vllm_omni.diffusion.cache.prompt_embed_cache import (
_CACHE_MISS,
_NOT_HASHABLE,
PromptEmbedCache,
_build_key,
_detach_output,
_hashable,
install_prompt_embed_cache,
resolve_prompt_embed_cache_config,
uninstall_prompt_embed_cache,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# ---------------------------------------------------------------------------
# _hashable
# ---------------------------------------------------------------------------
class TestHashable:
"""Tests for the ``_hashable`` helper."""
@pytest.mark.parametrize(
"value",
[None, "prompt", "", 0, 1, -5, 3.14, True, False, b"bytes"],
)
def test_scalars_are_returned_unchanged(self, value):
assert _hashable(value) == value
def test_torch_device_is_normalized(self):
out = _hashable(torch.device("cpu"))
assert out == ("__torch_device__", "cpu")
assert hash(out) is not None # must actually be hashable
def test_torch_dtype_is_normalized(self):
out = _hashable(torch.float32)
assert out[0] == "__torch_dtype__"
assert "float32" in out[1]
assert hash(out) is not None
def test_numpy_scalar_is_unwrapped(self):
np = pytest.importorskip("numpy")
assert _hashable(np.int64(7)) == 7
assert _hashable(np.float32(1.5)) == pytest.approx(1.5)
def test_numpy_ndarray_is_not_hashable(self):
np = pytest.importorskip("numpy")
assert _hashable(np.array([1, 2, 3])) is _NOT_HASHABLE
def test_nested_list_and_tuple(self):
out = _hashable(["a", ("b", 1), [2, 3]])
assert out[0] == "__seq__"
# fully hashable
assert hash(out) is not None
def test_dict_is_normalized_by_sorted_keys(self):
a = _hashable({"b": 1, "a": 2})
b = _hashable({"a": 2, "b": 1})
assert a == b
assert hash(a) is not None
def test_tensor_is_not_hashable(self):
assert _hashable(torch.zeros(2)) is _NOT_HASHABLE
def test_list_containing_tensor_is_not_hashable(self):
assert _hashable(["a", torch.zeros(1)]) is _NOT_HASHABLE
def test_dict_containing_tensor_is_not_hashable(self):
assert _hashable({"x": torch.zeros(1)}) is _NOT_HASHABLE
def test_custom_object_is_not_hashable(self):
class Obj:
pass
assert _hashable(Obj()) is _NOT_HASHABLE
# ---------------------------------------------------------------------------
# _detach_output
# ---------------------------------------------------------------------------
class TestDetachOutput:
def test_detaches_tensor(self):
t = torch.zeros(2, requires_grad=True)
out = _detach_output(t)
assert isinstance(out, torch.Tensor)
assert not out.requires_grad
def test_detaches_inside_tuple(self):
t = torch.ones(1, requires_grad=True)
out = _detach_output((t, None))
assert isinstance(out, tuple)
assert not out[0].requires_grad
assert out[1] is None
def test_detaches_inside_list_and_dict(self):
t = torch.ones(1, requires_grad=True)
out = _detach_output({"a": [t]})
assert not out["a"][0].requires_grad
def test_preserves_namedtuple_type(self):
from collections import namedtuple
NT = namedtuple("NT", ["x", "y"])
t = torch.ones(1, requires_grad=True)
out = _detach_output(NT(x=t, y=1))
assert isinstance(out, NT)
assert not out.x.requires_grad
assert out.y == 1
def test_passthrough_for_non_tensor(self):
assert _detach_output("hi") == "hi"
assert _detach_output(None) is None
assert _detach_output(5) == 5
# ---------------------------------------------------------------------------
# PromptEmbedCache
# ---------------------------------------------------------------------------
class TestPromptEmbedCache:
def test_rejects_non_positive_max_size(self):
with pytest.raises(ValueError):
PromptEmbedCache(max_size=0)
with pytest.raises(ValueError):
PromptEmbedCache(max_size=-1)
def test_miss_returns_sentinel_and_bumps_miss_counter(self):
cache = PromptEmbedCache(max_size=2)
assert cache.get("k") is _CACHE_MISS
assert cache.stats()["misses"] == 1
assert cache.stats()["hits"] == 0
def test_put_then_get_returns_value_and_bumps_hit_counter(self):
cache = PromptEmbedCache(max_size=2)
cache.put("k", 42)
assert cache.get("k") == 42
assert cache.stats()["hits"] == 1
def test_none_is_a_cacheable_value(self):
cache = PromptEmbedCache(max_size=2)
cache.put("k", None)
# Distinguishable from a miss.
assert cache.get("k") is None
assert cache.stats()["hits"] == 1
def test_lru_eviction_keeps_recently_used(self):
cache = PromptEmbedCache(max_size=2)
cache.put("a", 1)
cache.put("b", 2)
# Access "a" to mark it MRU.
assert cache.get("a") == 1
cache.put("c", 3) # should evict "b"
assert cache.get("b") is _CACHE_MISS
assert cache.get("a") == 1
assert cache.get("c") == 3
assert cache.stats()["size"] == 2
def test_get_refreshes_recency(self):
cache = PromptEmbedCache(max_size=2)
cache.put("a", 1)
cache.put("b", 2)
# Hit on "a" → "b" becomes LRU.
cache.get("a")
cache.put("c", 3)
assert cache.get("b") is _CACHE_MISS
assert cache.get("a") == 1
def test_put_overwrites_existing_key(self):
cache = PromptEmbedCache(max_size=2)
cache.put("k", 1)
cache.put("k", 2)
assert cache.get("k") == 2
assert cache.stats()["size"] == 1
def test_clear_empties_store_but_keeps_counters(self):
cache = PromptEmbedCache(max_size=2)
cache.put("k", 1)
cache.get("k")
cache.clear()
assert cache.stats()["size"] == 0
# clear only drops entries, counters reflect prior activity.
assert cache.stats()["hits"] == 1
def test_stats_reports_expected_fields(self):
cache = PromptEmbedCache(max_size=4)
stats = cache.stats()
assert set(stats.keys()) == {"size", "max_size", "hits", "misses", "bypassed"}
assert stats["max_size"] == 4
assert stats["size"] == 0
def test_thread_safety_under_concurrent_writes(self):
cache = PromptEmbedCache(max_size=1024)
def worker(start: int) -> None:
for i in range(start, start + 200):
cache.put(f"k{i}", i)
cache.get(f"k{i}")
threads = [threading.Thread(target=worker, args=(i * 200,)) for i in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
stats = cache.stats()
# 4 * 200 = 800 writes, each followed by a successful read.
assert stats["hits"] == 800
assert stats["size"] == 800
# ---------------------------------------------------------------------------
# _build_key
# ---------------------------------------------------------------------------
def _make_sig(fn):
import inspect
return inspect.signature(fn)
class TestBuildKey:
def test_positional_and_keyword_collide(self):
def encode(prompt, device=None):
return None
sig = _make_sig(encode)
k1 = _build_key(sig, "m", ("hello",), {"device": "cpu"})
k2 = _build_key(sig, "m", (), {"prompt": "hello", "device": "cpu"})
assert k1 == k2
assert k1 is not None
def test_different_prompts_produce_different_keys(self):
def encode(prompt):
return None
sig = _make_sig(encode)
assert _build_key(sig, "m", ("a",), {}) != _build_key(sig, "m", ("b",), {})
def test_different_model_tags_produce_different_keys(self):
def encode(prompt):
return None
sig = _make_sig(encode)
assert _build_key(sig, "m1", ("a",), {}) != _build_key(sig, "m2", ("a",), {})
def test_tensor_argument_bypasses(self):
def encode(prompt, extra=None):
return None
sig = _make_sig(encode)
assert _build_key(sig, "m", ("hi",), {"extra": torch.zeros(1)}) is None
def test_precomputed_prompt_embeds_bypasses(self):
def encode(prompt=None, prompt_embeds=None):
return None
sig = _make_sig(encode)
# None → not a bypass.
assert _build_key(sig, "m", (), {"prompt": "hi"}) is not None
# A non-None precomputed embed triggers bypass even if it would
# otherwise be "hashable".
assert _build_key(sig, "m", (), {"prompt": "hi", "prompt_embeds": "x"}) is None
def test_invalid_binding_returns_none(self):
def encode(prompt):
return None
sig = _make_sig(encode)
# Unknown kwarg → TypeError on bind → bypass.
assert _build_key(sig, "m", (), {"nope": 1}) is None
# ---------------------------------------------------------------------------
# install_prompt_embed_cache / uninstall_prompt_embed_cache
# ---------------------------------------------------------------------------
class _FakePipeline:
"""Minimal pipeline with a signature-bearing ``encode_prompt``."""
def __init__(self):
self.call_count = 0
def encode_prompt(
self,
prompt: str,
device=None,
num_images_per_prompt: int = 1,
negative_prompt: str | None = None,
prompt_embeds=None,
negative_prompt_embeds=None,
):
self.call_count += 1
# Return a fresh tensor so identity changes between real calls.
return torch.tensor([float(self.call_count)])
class TestInstallAndUninstall:
def test_returns_none_when_pipeline_has_no_encode_prompt(self):
class Empty:
pass
assert install_prompt_embed_cache(Empty()) is None
def test_cache_hits_on_identical_args(self):
pipe = _FakePipeline()
cache = install_prompt_embed_cache(pipe, max_size=4)
assert cache is not None
out1 = pipe.encode_prompt("cat", device="cpu")
out2 = pipe.encode_prompt("cat", device="cpu")
# Second call must be served from cache (wrapped fn not invoked).
assert pipe.call_count == 1
# Cache returns the same tensor object.
assert torch.equal(out1, out2)
stats = cache.stats()
assert stats["hits"] == 1
assert stats["misses"] == 1
assert stats["bypassed"] == 0
def test_positional_and_keyword_share_cache_slot(self):
pipe = _FakePipeline()
cache = install_prompt_embed_cache(pipe)
pipe.encode_prompt("cat")
pipe.encode_prompt(prompt="cat")
assert pipe.call_count == 1
assert cache.stats()["hits"] == 1
def test_different_prompts_miss(self):
pipe = _FakePipeline()
cache = install_prompt_embed_cache(pipe)
pipe.encode_prompt("cat")
pipe.encode_prompt("dog")
assert pipe.call_count == 2
assert cache.stats()["misses"] == 2
def test_tensor_argument_bypasses_and_increments_counter(self):
pipe = _FakePipeline()
cache = install_prompt_embed_cache(pipe)
# Put a tensor into a non-precomputed-embed slot to trigger bypass.
pipe.encode_prompt("cat", device=torch.device("cpu"), num_images_per_prompt=1)
# Now pass a fake tensor via a harmless positional-like call: use
# the negative_prompt slot with an unhashable object.
class Unhashable:
pass
pipe.encode_prompt("cat", negative_prompt=Unhashable()) # type: ignore[arg-type]
pipe.encode_prompt("cat", negative_prompt=Unhashable()) # type: ignore[arg-type]
assert cache.stats()["bypassed"] == 2
# All three calls actually executed the underlying function.
assert pipe.call_count == 3
def test_precomputed_embeds_bypass_cache(self):
pipe = _FakePipeline()
cache = install_prompt_embed_cache(pipe)
pipe.encode_prompt("cat", prompt_embeds=torch.zeros(1))
pipe.encode_prompt("cat", prompt_embeds=torch.zeros(1))
assert cache.stats()["bypassed"] == 2
assert cache.stats()["hits"] == 0
assert pipe.call_count == 2
def test_install_is_idempotent(self):
pipe = _FakePipeline()
c1 = install_prompt_embed_cache(pipe, max_size=4)
c2 = install_prompt_embed_cache(pipe, max_size=16)
assert c1 is c2
# The second call must not re-wrap (max_size stays from first install).
assert c1.max_size == 4
def test_disabled_cache_passes_through(self):
pipe = _FakePipeline()
cache = install_prompt_embed_cache(pipe, enabled=False)
pipe.encode_prompt("cat")
pipe.encode_prompt("cat")
assert pipe.call_count == 2
assert cache.stats()["hits"] == 0
assert cache.stats()["misses"] == 0
def test_toggle_enabled_flag_takes_effect(self):
pipe = _FakePipeline()
cache = install_prompt_embed_cache(pipe, enabled=True)
pipe.encode_prompt("cat")
pipe.encode_prompt("cat")
assert cache.stats()["hits"] == 1
cache.enabled = False
pipe.encode_prompt("cat")
pipe.encode_prompt("cat")
# Cache-bypassed calls go through; hit count unchanged.
assert cache.stats()["hits"] == 1
def test_cached_tensor_is_detached(self):
class GradPipeline:
def encode_prompt(self, prompt):
return torch.ones(1, requires_grad=True)
pipe = GradPipeline()
install_prompt_embed_cache(pipe)
# First call populates the cache.
pipe.encode_prompt("cat")
# Second call returns the cached tensor which must be detached.
cached = pipe.encode_prompt("cat")
assert isinstance(cached, torch.Tensor)
assert not cached.requires_grad
def test_lru_eviction_through_wrapper(self):
pipe = _FakePipeline()
cache = install_prompt_embed_cache(pipe, max_size=2)
pipe.encode_prompt("a")
pipe.encode_prompt("b")
pipe.encode_prompt("c") # evicts "a"
pipe.encode_prompt("a") # miss again → re-run
assert pipe.call_count == 4
assert cache.stats()["size"] == 2
def test_uninstall_restores_original(self):
pipe = _FakePipeline()
original = pipe.encode_prompt
install_prompt_embed_cache(pipe)
assert pipe.encode_prompt is not original
uninstall_prompt_embed_cache(pipe)
# The bound method reference will differ, but identity of underlying
# function matches and wrapper attributes are gone.
assert not hasattr(pipe, "_prompt_embed_cache") or pipe._prompt_embed_cache is None
# After uninstall calls go straight to the real function.
pipe.encode_prompt("cat")
pipe.encode_prompt("cat")
assert pipe.call_count == 2
def test_uninstall_on_unwrapped_pipeline_is_noop(self):
class Empty:
def encode_prompt(self, prompt):
return None
pipe = Empty()
uninstall_prompt_embed_cache(pipe) # must not raise
# ---------------------------------------------------------------------------
# resolve_prompt_embed_cache_config
# ---------------------------------------------------------------------------
@pytest.fixture
def clean_env(monkeypatch):
monkeypatch.delenv("OMNI_DIFFUSION_PROMPT_EMBED_CACHE", raising=False)
monkeypatch.delenv("OMNI_DIFFUSION_PROMPT_EMBED_CACHE_SIZE", raising=False)
class TestResolveConfig:
def test_defaults_when_nothing_set(self, clean_env):
enable, size = resolve_prompt_embed_cache_config()
assert enable is False
assert size == 32
def test_explicit_args_used(self, clean_env):
enable, size = resolve_prompt_embed_cache_config(enable=True, max_size=8)
assert enable is True
assert size == 8
@pytest.mark.parametrize("raw", ["1", "true", "TRUE", "yes", "on"])
def test_env_enable_truthy(self, clean_env, monkeypatch, raw):
monkeypatch.setenv("OMNI_DIFFUSION_PROMPT_EMBED_CACHE", raw)
enable, _ = resolve_prompt_embed_cache_config(enable=False)
assert enable is True
@pytest.mark.parametrize("raw", ["0", "false", "FALSE", "no", "off"])
def test_env_enable_falsy_overrides_true(self, clean_env, monkeypatch, raw):
monkeypatch.setenv("OMNI_DIFFUSION_PROMPT_EMBED_CACHE", raw)
enable, _ = resolve_prompt_embed_cache_config(enable=True)
assert enable is False
def test_env_enable_invalid_leaves_arg_untouched(self, clean_env, monkeypatch):
monkeypatch.setenv("OMNI_DIFFUSION_PROMPT_EMBED_CACHE", "garbage")
enable, _ = resolve_prompt_embed_cache_config(enable=True)
assert enable is True
def test_env_size_overrides_arg(self, clean_env, monkeypatch):
monkeypatch.setenv("OMNI_DIFFUSION_PROMPT_EMBED_CACHE_SIZE", "7")
_, size = resolve_prompt_embed_cache_config(max_size=99)
assert size == 7
def test_env_size_zero_or_negative_ignored(self, clean_env, monkeypatch):
monkeypatch.setenv("OMNI_DIFFUSION_PROMPT_EMBED_CACHE_SIZE", "0")
_, size = resolve_prompt_embed_cache_config(max_size=12)
assert size == 12
monkeypatch.setenv("OMNI_DIFFUSION_PROMPT_EMBED_CACHE_SIZE", "-5")
_, size = resolve_prompt_embed_cache_config(max_size=12)
assert size == 12
def test_env_size_non_integer_ignored(self, clean_env, monkeypatch):
monkeypatch.setenv("OMNI_DIFFUSION_PROMPT_EMBED_CACHE_SIZE", "notanint")
_, size = resolve_prompt_embed_cache_config(max_size=12)
assert size == 12
def test_max_size_none_falls_back_to_default(self, clean_env):
_, size = resolve_prompt_embed_cache_config(enable=True, max_size=None)
assert size == 32
@@ -7,6 +7,9 @@ import torch
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_hunyuan import (
DistributedAutoencoderKLHunyuan,
)
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_hunyuan_video_15 import (
DistributedAutoencoderKLHunyuanVideo15,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -36,6 +39,33 @@ class _DummyDistributedAutoencoderKLHunyuan(DistributedAutoencoderKLHunyuan):
return b
class _DummyDistributedAutoencoderKLHunyuanVideo15(DistributedAutoencoderKLHunyuanVideo15):
def __init__(self):
torch.nn.Module.__init__(self)
self.tile_sample_min_height = 8
self.tile_sample_min_width = 12
self.tile_latent_min_height = 8
self.tile_latent_min_width = 12
self.tile_overlap_factor = 0.25
self.use_tiling = False
@property
def dtype(self) -> torch.dtype:
return torch.float32
def decoder(self, x: torch.Tensor) -> torch.Tensor:
return x + 10
def encoder(self, x: torch.Tensor) -> torch.Tensor:
return x + 20
def blend_v(self, _a: torch.Tensor, b: torch.Tensor, _blend_extent: int) -> torch.Tensor:
return b
def blend_h(self, _a: torch.Tensor, b: torch.Tensor, _blend_extent: int) -> torch.Tensor:
return b
def test_hunyuan_vae_use_tiling_aliases_spatial_tiling():
# Verify use_tiling property maps to use_spatial_tiling.
vae = _DummyDistributedAutoencoderKLHunyuan()
@@ -77,3 +107,43 @@ def test_hunyuan_vae_encode_tiles_round_trip():
assert grid_spec.tile_spec == {"blend_extent": 2, "row_limit": 6}
assert len(tile_tasks) == 4
assert torch.equal(output, x + 20)
def test_hunyuan_video15_vae_decode_tiles_round_trip():
vae = _DummyDistributedAutoencoderKLHunyuanVideo15()
z = torch.arange(192, dtype=torch.float32).reshape(1, 1, 1, 12, 16)
tile_tasks, grid_spec = vae.tile_split(z)
decoded_tiles = {task.grid_coord: vae.tile_exec(task) for task in tile_tasks}
output = vae.tile_merge(decoded_tiles, grid_spec)
assert grid_spec.split_dims == (3, 4)
assert grid_spec.grid_shape == (2, 2)
assert grid_spec.tile_spec == {
"blend_height": 2,
"blend_width": 3,
"row_limit_height": 6,
"row_limit_width": 9,
}
assert len(tile_tasks) == 4
assert torch.equal(output, z + 10)
def test_hunyuan_video15_vae_encode_tiles_round_trip():
vae = _DummyDistributedAutoencoderKLHunyuanVideo15()
x = torch.arange(192, dtype=torch.float32).reshape(1, 1, 1, 12, 16)
tile_tasks, grid_spec = vae.encode_tile_split(x)
encoded_tiles = {task.grid_coord: vae.encode_tile_exec(task) for task in tile_tasks}
output = vae.tile_merge(encoded_tiles, grid_spec)
assert grid_spec.split_dims == (3, 4)
assert grid_spec.grid_shape == (2, 2)
assert grid_spec.tile_spec == {
"blend_height": 2,
"blend_width": 3,
"row_limit_height": 6,
"row_limit_width": 9,
}
assert len(tile_tasks) == 4
assert torch.equal(output, x + 20)
@@ -56,10 +56,7 @@ def _make_generate_args(num_tokens=NUM_TOKENS, hidden_dim=HIDDEN_DIM, cfg=False)
packed_vae_token_indexes=torch.arange(2, seq_len, dtype=torch.long),
packed_seqlens=torch.tensor([seq_len], dtype=torch.int),
packed_position_ids=torch.arange(seq_len, dtype=torch.long),
packed_indexes=torch.arange(seq_len, dtype=torch.long),
past_key_values=NaiveCache(1),
key_values_lens=torch.tensor([0], dtype=torch.int),
packed_key_value_indexes=torch.zeros(0, dtype=torch.long),
num_timesteps=NUM_TIMESTEPS,
timestep_shift=1.0,
cfg_text_scale=1.0,
@@ -68,11 +65,8 @@ def _make_generate_args(num_tokens=NUM_TOKENS, hidden_dim=HIDDEN_DIM, cfg=False)
if cfg:
base |= dict(
cfg_text_scale=4.0,
cfg_text_packed_query_indexes=torch.arange(seq_len, dtype=torch.long),
cfg_text_packed_position_ids=torch.arange(seq_len, dtype=torch.long),
cfg_text_past_key_values=NaiveCache(1),
cfg_text_key_values_lens=torch.tensor([0], dtype=torch.int),
cfg_text_packed_key_value_indexes=torch.zeros(0, dtype=torch.long),
)
return base
@@ -124,6 +124,58 @@ def test_resolve_stop_token_ids_image_tasks_stop_on_ratio_range():
assert resolve_stop_token_ids(task="i2t", bot_task=None, tokenizer=tok) == [answer_id]
assert resolve_stop_token_ids(task="t2t", bot_task=None, tokenizer=tok) == [answer_id]
# image_size="auto" is equivalent to None (need_ratio=True).
for bot in ("think", "recaption", "think_recaption", "vanilla"):
assert resolve_stop_token_ids(task="t2i", bot_task=bot, tokenizer=tok, image_size="auto") == expected
assert resolve_stop_token_ids(task="it2i", bot_task=bot, tokenizer=tok, image_size="auto") == expected
def test_resolve_stop_token_ids_explicit_image_size_stops_on_terminator():
"""When image_size is an explicit "WxH" string, need_ratio=False: AR
already knows the size and stops at the cot terminator instead of the
ratio range.
Mirrors upstream logic where ``need_ratio`` is false when the user
supplies a target size, so ``final_stop_tokens`` is set to the
recaption/think terminator (not the ratio tokens).
"""
tok = FakeTokenizer()
end_of_think_id = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["</think>"]
end_of_recaption_id = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["</recaption>"]
# think_recaption / recaption -> [end_of_recaption]
for bot in ("think_recaption", "recaption"):
for task in ("t2i", "it2i"):
result = resolve_stop_token_ids(task=task, bot_task=bot, tokenizer=tok, image_size="1024x1024")
assert result == [end_of_recaption_id], (
f"task={task}, bot_task={bot}, image_size='1024x1024': expected [{end_of_recaption_id}], got {result}"
)
# think -> [end_of_think, end_of_recaption]
for task in ("t2i", "it2i"):
result = resolve_stop_token_ids(task=task, bot_task="think", tokenizer=tok, image_size="1024x1024")
assert result == [end_of_think_id, end_of_recaption_id], (
f"task={task}, bot_task='think', image_size='1024x1024': "
f"expected [{end_of_think_id}, {end_of_recaption_id}], got {result}"
)
# vanilla -> no early return, falls through to ratio range
start = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_0>"]
end = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_32>"]
other_start = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_33>"]
other_end = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_36>"]
expected_ratio = list(range(start, end + 1)) + list(range(other_start, other_end + 1))
for task in ("t2i", "it2i"):
result = resolve_stop_token_ids(task=task, bot_task="vanilla", tokenizer=tok, image_size="1024x1024")
assert result == expected_ratio, (
f"task={task}, bot_task='vanilla', image_size='1024x1024': expected ratio range, got {result}"
)
# Text tasks are unaffected by image_size.
answer_id = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<answer>"]
assert resolve_stop_token_ids(task="i2t", bot_task=None, tokenizer=tok, image_size="1024x1024") == [answer_id]
@pytest.mark.parametrize(
"task,bot_task",
@@ -17,7 +17,7 @@ AUDIO_MODEL = {
}
IMAGE_VIDEO_MODELS = {
"riverclouds/qwen_image_random": {"cuda": 2500, "rocm": 2100},
"riverclouds/qwen_image_random": {"cuda": 2200, "rocm": 2100},
}
MODELS = {**AUDIO_MODEL, **IMAGE_VIDEO_MODELS}
@@ -71,7 +71,7 @@ class QualityTestConfig:
prompt: str # generation prompt
max_lpips: float # fail threshold — higher = more lenient
model: str | None = None # HF model name
quantization: str | None = None # quantization method, e.g. "fp8"
quantization: str | dict[str, object] | None = None # quantization method/config, e.g. "fp8"
baseline_model: str | None = None # explicit BF16/local baseline path
quantized_model: str | None = None # explicit quantized/local model path
height: int = 1024
@@ -118,9 +118,57 @@ QUALITY_CONFIGS = [
QualityTestConfig(
id="fp8_z_image",
model="Tongyi-MAI/Z-Image-Turbo",
quantization="fp8",
quantization={
"method": "fp8",
"ignored_layers": [
"img_mlp",
*[
f"layers.{layer_id}.{suffix}"
for layer_id in range(15, 30)
for suffix in (
"attention.to_qkv",
"attention.to_out.0",
"feed_forward.w13",
"feed_forward.w2",
)
],
*[
f"model.layers.{layer_id}.{suffix}"
for layer_id in range(28, 36)
for suffix in (
"self_attn.q_proj",
"self_attn.k_proj",
"self_attn.v_proj",
"self_attn.o_proj",
"mlp.gate_proj",
"mlp.up_proj",
"mlp.down_proj",
)
],
],
},
task="t2i",
prompt="a cup of coffee on a wooden table, morning light",
prompt=(
"A breathtaking twilight scene atop a floating archipelago of crystalline islands suspended in an "
"endless nebula-drenched sky, where waterfalls of liquid starlight cascade from the edges of each "
"island into the cosmic abyss below. The largest island hosts an ancient, overgrown observatory "
"crafted from pearlescent white stone and living silverwood trees whose bioluminescent leaves pulse "
"with soft cerulean and violet light. At the observatory's center stands a solitary figure—a young "
"astronomer in flowing robes woven from woven moonlight and deep-space fabric, their face illuminated "
"by the glow of a holographic star chart hovering above an altar of polished obsidian. The sky swirls "
"with the birth of a new galaxy: ribbons of magenta and gold gas clouds twist around nascent stars, "
"while distant supernovae bloom like cosmic flowers in the far reaches of the void. Below, the abyss "
"reveals faint echoes of forgotten civilizations—ghostly silhouettes of submerged cities shimmering "
"in layers of atmospheric haze. Cinematic volumetric lighting cuts through the scene as twin moons—one "
"copper-hued, one opalescent—rise on opposite horizons, casting long, intersecting shadows across "
"moss-covered ruins and crystalline flora that refract light into prismatic halos. Hyper-detailed, "
"photorealistic rendering with the atmospheric depth of Roger Deakins' cinematography, the "
"architectural grandeur of Zaha Hadid, and the cosmic wonder of James Jean's illustrations. Shot on "
"a mythical 150mm lens with shallow depth of field, 8K resolution, Unreal Engine 5 realism, subsurface "
"scattering on organic elements, and ray-traced reflections dancing across every water droplet in the "
"starlight waterfalls. Ethereal, melancholic, and transcendent mood—like a moment of quiet revelation "
"at the edge of existence."
),
max_lpips=0.15,
num_inference_steps=20,
),
@@ -391,12 +439,6 @@ _OUTPUT_DIR = Path(os.environ["VLLM_OMNI_QUALITY_OUTPUT_DIR"]) if "VLLM_OMNI_QUA
def _quality_param(c: QualityTestConfig):
marks = list(_marks)
if c.id == "fp8_z_image":
marks.append(
pytest.mark.skip(
reason="Z-Image FP8 quality gate temporarily disabled: https://github.com/vllm-project/vllm-omni/issues/3531"
)
)
if c.id == "fp8_qwen_image":
marks.append(
pytest.mark.skip(reason="Qwen-Image FP8 quality gate temporarily disabled (see CI / issue tracker).")
@@ -0,0 +1,57 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from vllm_omni.diffusion import diffusion_engine
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def test_dummy_run_num_frames_uses_explicit_model_setting(monkeypatch: pytest.MonkeyPatch) -> None:
class JointAudioVideoModel:
dummy_run_num_frames = 2
monkeypatch.setattr(
diffusion_engine.DiffusionModelRegistry,
"_try_load_model_cls",
lambda model_class_name: JointAudioVideoModel,
)
assert diffusion_engine.get_dummy_run_num_frames("joint_audio_video", supports_audio_input=False) == 2
def test_dummy_run_num_frames_keeps_audio_output_default(monkeypatch: pytest.MonkeyPatch) -> None:
class AudioOutputModel:
support_audio_output = True
monkeypatch.setattr(
diffusion_engine.DiffusionModelRegistry,
"_try_load_model_cls",
lambda model_class_name: AudioOutputModel,
)
assert diffusion_engine.get_dummy_run_num_frames("audio_output", supports_audio_input=False) == 2
def test_dummy_run_num_frames_defaults_to_single_frame(monkeypatch: pytest.MonkeyPatch) -> None:
class VideoOnlyModel:
pass
monkeypatch.setattr(
diffusion_engine.DiffusionModelRegistry,
"_try_load_model_cls",
lambda model_class_name: VideoOnlyModel,
)
assert diffusion_engine.get_dummy_run_num_frames("video_only", supports_audio_input=False) == 1
def test_dummy_run_num_frames_uses_audio_input_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
diffusion_engine.DiffusionModelRegistry,
"_try_load_model_cls",
lambda model_class_name: None,
)
assert diffusion_engine.get_dummy_run_num_frames("unknown", supports_audio_input=True) == 2
@@ -52,6 +52,7 @@ def _make_runner(cache_backend, cache_backend_name: str, enable_cache_dit_summar
runner.pipeline = _DummyPipeline(output=SimpleNamespace(output="ok"))
runner.cache_backend = cache_backend
runner.offload_backend = None
runner.prompt_embed_cache = None
runner.od_config = SimpleNamespace(
cache_backend=cache_backend_name,
enable_cache_dit_summary=enable_cache_dit_summary,
@@ -300,8 +301,4 @@ def test_vllm_set_forward_context_implementation(monkeypatch):
"vllm_config",
),
),
("ir_op_priority", None),
("enable_torch_wrap", vllm_config.compilation_config.ir_enable_torch_wrap),
("enable_torch_wrap_exit", vllm_config.compilation_config.ir_enable_torch_wrap),
("ir_op_priority_exit", None),
], ERROR_MESSAGE
+3 -3
View File
@@ -85,7 +85,7 @@ class MockOmniRequestOutput:
BASE_HEIGHT = 512
BASE_WIDTH = 512
BASE_INFER_STEPS = 10
DELAY_BASE = 2
DELAY_BASE = 0.01
class MockDiffusionEngine:
@@ -93,7 +93,7 @@ class MockDiffusionEngine:
def simulate_step_delay(height, width, num_inference_steps) -> float:
return (height / BASE_HEIGHT) * (width / BASE_WIDTH) * (num_inference_steps / BASE_INFER_STEPS)
DELAY_BASE = 2
DELAY_BASE = 0.01
delay_scale = simulate_step_delay(
request.sampling_params.height, request.sampling_params.width, request.sampling_params.num_inference_steps
)
@@ -139,7 +139,7 @@ async def test_proc_process_request_with_batching_async_output():
assert len(results) == len(test_requests)
base_time = DELAY_BASE
time_gap_std = DELAY_BASE * 2 * 2 * 1 # height/width/steps infer time scale
eps = 0.5
eps = 0.1
for i, (res, elapsed_time) in enumerate(results):
assert res.request_id == test_requests[i]["request_id"]
assert isinstance(res, MockOmniRequestOutput)
@@ -269,6 +269,7 @@ def _load_mooncake_config(host: str, rpc_port: int, http_port: int) -> str:
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.diffusion
@pytest.mark.skip(reason="Skip failed CI issue 3977: https://github.com/vllm-project/vllm-omni/issues/3977")
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_bagel_text2img_mooncake_connector(run_level):
"""Test Bagel text2img with Mooncake connector for inter-stage communication."""
@@ -258,6 +258,7 @@ def _resolve_deploy_config(config_path: str, run_level: str) -> str:
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.diffusion
@pytest.mark.skip(reason="Skip failed CI issue 3977: https://github.com/vllm-project/vllm-omni/issues/3977")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"})
def test_bagel_img2img_shared_memory_connector(run_level):
"""Test Bagel img2img with shared memory connector."""
@@ -275,6 +276,7 @@ def test_bagel_img2img_shared_memory_connector(run_level):
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.diffusion
@pytest.mark.skip(reason="Skip failed CI issue 3977: https://github.com/vllm-project/vllm-omni/issues/3977")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"})
def test_bagel_text2img_shared_memory_connector(run_level):
"""Test Bagel text2img with shared memory connector."""
@@ -230,9 +230,11 @@ def test_send_single_request_struct_without_meta_does_not_crash(build_adapter, m
cleanup_calls = []
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: cleanup_calls.append((a, kw)))
adapter._send_single_request({"pooling_output": None, "request": request, "is_finished": False})
adapter._send_single_request(
{"pooling_output": None, "request": request, "is_finished": False, "is_segment_finished": False}
)
assert cleanup_calls == [] # no terminal cleanup; meta.finished is unobservable
assert cleanup_calls == [] # no terminal cleanup; meta.finished is false
def test_send_single_request_empty_struct_goes_on_wire(build_adapter, monkeypatch):
@@ -248,12 +250,50 @@ def test_send_single_request_empty_struct_goes_on_wire(build_adapter, monkeypatc
adapter.custom_process_next_stage_input_func = lambda **kwargs: OmniPayloadStruct()
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None)
adapter._send_single_request({"pooling_output": None, "request": request, "is_finished": False})
adapter._send_single_request(
{"pooling_output": None, "request": request, "is_finished": False, "is_segment_finished": False}
)
assert connector.put.called
sent_payload = connector.put.call_args.kwargs["data"]
assert isinstance(sent_payload, OmniPayloadStruct)
assert sent_payload.meta is None # confirms it's the empty struct on the wire
assert sent_payload.meta.finished.item() is False
assert sent_payload.meta.is_segment_finished.item() is False
def test_send_single_request_struct_preserves_segment_finished(build_adapter, monkeypatch):
adapter, connector = build_adapter(stage_id=1)
request = _req("req-segment", RequestStatus.WAITING, external_req_id="ext-segment")
adapter.custom_process_next_stage_input_func = lambda **kwargs: OmniPayloadStruct()
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None)
adapter._send_single_request(
{"pooling_output": None, "request": request, "is_finished": False, "is_segment_finished": True}
)
sent_payload = connector.put.call_args.kwargs["data"]
assert sent_payload.meta.finished.item() is False
assert sent_payload.meta.is_segment_finished.item() is True
def test_save_async_skips_stale_resumable_chunk_until_dedup_is_reset(build_adapter):
adapter, _ = build_adapter(stage_id=1)
request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream")
request.resumable = True
request.num_computed_tokens = 0
adapter.requests_num_chunks_sent["ext-stream"] = 111
adapter.save_async(pooling_output=None, request=request, is_segment_finished=False)
assert len(adapter._pending_save_reqs) == 0
assert adapter.requests_num_chunks_sent["ext-stream"] == 111
adapter.requests_num_chunks_sent.pop("ext-stream")
adapter.save_async(pooling_output=None, request=request, is_segment_finished=False)
assert len(adapter._pending_save_reqs) == 1
assert adapter.requests_num_chunks_sent["ext-stream"] == 0
def test_send_single_request_cleans_up_after_finished_payload(build_adapter, monkeypatch):
@@ -266,7 +306,9 @@ def test_send_single_request_cleans_up_after_finished_payload(build_adapter, mon
cleanup_calls = []
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: cleanup_calls.append((a, kw)))
adapter._send_single_request({"pooling_output": None, "request": request, "is_finished": True})
adapter._send_single_request(
{"pooling_output": None, "request": request, "is_finished": True, "is_segment_finished": True}
)
assert len(cleanup_calls) == 1
args, _ = cleanup_calls[0]
@@ -661,6 +703,7 @@ def test_generation_scheduler_calls_cleanup_on_finished(monkeypatch, mocker: Moc
kv_connector_output=None,
cudagraph_stats=None,
req_id_to_index={"req-s1": 0},
routed_experts=None,
routed_experts_dict=None,
)
@@ -747,6 +790,7 @@ def test_ar_scheduler_defers_cleanup_and_queues_save_on_finished(mocker: MockerF
cudagraph_stats=None,
req_id_to_index={"req-ar": 0},
kv_extracted_req_ids=None,
routed_experts=None,
routed_experts_dict=None,
)
@@ -68,13 +68,13 @@ def test_omni_coordinator_pub_coalescing_on_rapid_queue_updates():
sub_ctx = zmq.Context.instance()
sub = sub_ctx.socket(zmq.SUB)
sub.connect(pub_addr)
sub.connect(coordinator.pub_zmq_addr)
sub.setsockopt(zmq.SUBSCRIBE, b"")
time.sleep(0.3) # PUB/SUB slow-joiner
client = OmniCoordClientForStage(
router_addr,
coordinator.router_zmq_addr,
"tcp://stage:coalesce",
"tcp://stage:coalesce-out",
0,
@@ -131,7 +131,7 @@ def test_omni_coordinator_registration_broadcast():
sub_ctx = zmq.Context.instance()
sub = sub_ctx.socket(zmq.SUB)
sub.connect(pub_addr)
sub.connect(coordinator.pub_zmq_addr)
sub.setsockopt(zmq.SUBSCRIBE, b"")
# ZMQ PUB/SUB slow-joiner: allow SUB to connect before clients register.
@@ -139,9 +139,9 @@ def test_omni_coordinator_registration_broadcast():
# Create 3 stage clients; each auto-registers on init.
clients = [
OmniCoordClientForStage(router_addr, "tcp://stage:10001", "tcp://stage:10001-out", 0),
OmniCoordClientForStage(router_addr, "tcp://stage:10002", "tcp://stage:10002-out", 0),
OmniCoordClientForStage(router_addr, "tcp://stage:10003", "tcp://stage:10003-out", 1),
OmniCoordClientForStage(coordinator.router_zmq_addr, "tcp://stage:10001", "tcp://stage:10001-out", 0),
OmniCoordClientForStage(coordinator.router_zmq_addr, "tcp://stage:10002", "tcp://stage:10002-out", 0),
OmniCoordClientForStage(coordinator.router_zmq_addr, "tcp://stage:10003", "tcp://stage:10003-out", 1),
]
msg = _wait_for_replica_list(sub, expected_count=3)
@@ -183,19 +183,19 @@ def test_omni_coordinator_heartbeat_timeout_handling():
sub_ctx = zmq.Context.instance()
sub = sub_ctx.socket(zmq.SUB)
sub.connect(pub_addr)
sub.connect(coordinator.pub_zmq_addr)
sub.setsockopt(zmq.SUBSCRIBE, b"")
time.sleep(0.3)
# A and B: real clients that send heartbeats every 5s.
client_a = OmniCoordClientForStage(router_addr, "tcp://stage:a", "tcp://stage:a-out", 0)
client_b = OmniCoordClientForStage(router_addr, "tcp://stage:b", "tcp://stage:b-out", 0)
client_a = OmniCoordClientForStage(coordinator.router_zmq_addr, "tcp://stage:a", "tcp://stage:a-out", 0)
client_b = OmniCoordClientForStage(coordinator.router_zmq_addr, "tcp://stage:b", "tcp://stage:b-out", 0)
# C: raw DEALER that sends only registration, no heartbeat.
dealer_ctx = zmq.Context.instance()
dealer_c = dealer_ctx.socket(zmq.DEALER)
dealer_c.connect(router_addr)
dealer_c.connect(coordinator.router_zmq_addr)
reg_event = {
"input_addr": "tcp://stage:c",
"output_addr": "tcp://stage:c-out",
@@ -254,12 +254,12 @@ def test_omni_coordinator_replica_shutdown_handling():
sub_ctx = zmq.Context.instance()
sub = sub_ctx.socket(zmq.SUB)
sub.connect(pub_addr)
sub.connect(coordinator.pub_zmq_addr)
sub.setsockopt(zmq.SUBSCRIBE, b"")
time.sleep(0.3) # PUB/SUB slow-joiner
client = OmniCoordClientForStage(router_addr, "tcp://stage:shutdown", "tcp://stage:shutdown-out", 0)
client = OmniCoordClientForStage(coordinator.router_zmq_addr, "tcp://stage:shutdown", "tcp://stage:shutdown-out", 0)
msg = _wait_for_replica_list(sub, expected_count=1)
assert msg is not None
@@ -253,37 +253,6 @@ def _diffusers_output_single_image(accuracy_artifact_root: Path, qwen_bear_image
)
def _vllm_omni_output_multiple_image(
accuracy_artifact_root: Path,
qwen_bear_image: Image.Image,
rabbit_image: Image.Image,
) -> Image.Image:
output_dir = model_output_dir(accuracy_artifact_root, MULTIPLE_MODEL)
output_path = output_dir / "vllm_omni_multiple.png"
with OmniServer(model=MULTIPLE_MODEL, serve_args=SERVER_ARGS) as server:
output = _run_vllm_omni_image_edit(
omni_server=server,
prompt=PROMPT_MULTIPLE_IMAGE,
input_images=[qwen_bear_image, rabbit_image],
output_path=output_path,
)
return output
def _diffusers_output_multiple_image(
accuracy_artifact_root: Path, qwen_bear_image: Image.Image, rabbit_image: Image.Image
) -> Image.Image:
output_dir = model_output_dir(accuracy_artifact_root, MULTIPLE_MODEL)
output_path = output_dir / "diffusers_multiple.png"
return _run_diffusers_image_edit(
model=MULTIPLE_MODEL,
pipeline_class=QwenImageEditPlusPipeline,
prompt=PROMPT_MULTIPLE_IMAGE,
input_images=[qwen_bear_image, rabbit_image],
output_path=output_path,
)
@pytest.mark.benchmark
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_qwen_image_edit_single_matches_diffusers(
@@ -309,37 +278,6 @@ def test_qwen_image_edit_single_matches_diffusers(
)
@pytest.mark.benchmark
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.skip(
reason="Skipping as the second image seems to be ignored by the API. Will come back to this later after #2772 is merged."
)
def test_qwen_image_edit_multiple_matches_diffusers(
accuracy_artifact_root: Path,
qwen_bear_image: Image.Image,
rabbit_image: Image.Image,
) -> None:
vllm_image = _vllm_omni_output_multiple_image(
accuracy_artifact_root=accuracy_artifact_root,
qwen_bear_image=qwen_bear_image,
rabbit_image=rabbit_image,
)
diffusers_image = _diffusers_output_multiple_image(
accuracy_artifact_root=accuracy_artifact_root,
qwen_bear_image=qwen_bear_image,
rabbit_image=rabbit_image,
)
assert_similarity(
model_name=MULTIPLE_MODEL,
vllm_image=vllm_image,
diffusers_image=diffusers_image,
width=WIDTH,
height=HEIGHT,
ssim_threshold=SSIM_THRESHOLD,
psnr_threshold=PSNR_THRESHOLD,
)
@pytest.mark.benchmark
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_qwen_image_edit_2511_matches_diffusers_pixelwise(accuracy_artifact_root: Path) -> None:
@@ -21,10 +21,12 @@ Usage:
from __future__ import annotations
import asyncio
import uuid
from contextlib import ExitStack
import pytest
import torch
from tests.helpers.mark import hardware_test
from vllm_omni.entrypoints.async_omni import AsyncOmni
@@ -150,3 +152,101 @@ async def test_generate_after_list_loras_inline_mode():
assert last_output is not None
assert isinstance(last_output, OmniRequestOutput)
assert last_output.images, "Expected at least one generated image"
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.asyncio
async def test_sleep_memory_reclaimed_custom_pipeline():
"""sleep(level=1) must physically reclaim CuMemAllocator-tracked memory for
custom_pipeline.
Regression test for: custom pipelines constructed under ``with target_device:``
(CUDA default-device context) caused safetensors >=0.20.0 to use a
direct-to-GPU fast path (cudaMalloc via the driver API) that bypasses
CuMemAllocator, leaving weights invisible to sleep() and pinned in GPU
memory after the call.
The fix moves custom_pipeline init outside the CUDA context so all weights
go through the caching allocator and are therefore fully reclaimed by
sleep(level=1). A non-zero ``CuMemAllocator.get_current_usage()`` after
sleep is the direct signal that the bypass is still occurring.
"""
with ExitStack() as after:
engine = AsyncOmni(
model=MODEL,
custom_pipeline_args={"pipeline_class": CUSTOM_PIPELINE_CLASS},
worker_extension_cls=WORKER_EXTENSION_CLASS,
enforce_eager=True,
enable_sleep_mode=True,
)
after.callback(engine.shutdown)
assert not await engine.is_sleeping(), "Engine should be awake after creation"
# Measure global VRAM before sleep (driver view; includes inline worker
# thread since inline mode runs in the same process).
torch.accelerator.synchronize()
free_before, total = torch.cuda.mem_get_info()
used_before_gib = (total - free_before) / 1024**3
# Measure CuMemAllocator-tracked usage before sleep. In inline mode
# the worker runs in a thread pool inside this process, so the allocator
# singleton is shared and can be read directly.
allocator = None
tracked_before = 0
try:
from vllm.device_allocator.cumem import CuMemAllocator
allocator = CuMemAllocator.get_instance()
tracked_before = allocator.get_current_usage()
except Exception:
pass
# Put the engine to sleep; all weights should be offloaded via the pool.
acks = await engine.sleep(level=1)
await asyncio.sleep(0.5) # allow the CUDA driver to settle
torch.accelerator.synchronize()
# Measure after sleep.
free_after, _ = torch.cuda.mem_get_info()
used_after_gib = (total - free_after) / 1024**3
drop_gib = used_before_gib - used_after_gib
# --- Primary assertion: allocator reports zero tracked memory. ---
# If this fails it means weights were allocated outside the CuMem pool
# (safetensors direct-to-GPU bypass) — the exact regression this test
# is designed to catch.
if allocator is not None:
tracked_after = allocator.get_current_usage()
assert tracked_after == 0, (
f"CuMemAllocator still tracks {tracked_after / 1024**3:.3f} GiB "
f"after sleep(level=1) on custom_pipeline path "
f"(was {tracked_before / 1024**3:.3f} GiB before sleep). "
"Weights were allocated outside the CuMem pool via the "
"safetensors direct-to-GPU fast path — loader-context fix "
"may not be applied."
)
# --- Secondary assertion: physical VRAM or ACK freed_bytes confirms
# reclamation at the driver level.
total_freed_bytes = sum(
(ack.freed_bytes if hasattr(ack, "freed_bytes") else ack.get("freed_bytes", 0))
for ack in acks
if ack is not None
)
freed_gib = total_freed_bytes / 1024**3
assert freed_gib > 0 or drop_gib > 0, (
f"Expected GPU memory to be reclaimed after sleep(level=1) on "
f"custom_pipeline + enable_sleep_mode=True. "
f"CuMemAllocator tracked before={tracked_before / 1024**3:.3f} GiB, "
f"ACK freed={freed_gib:.3f} GiB, global VRAM drop={drop_gib:.3f} GiB."
)
# Engine must report it is sleeping.
assert await engine.is_sleeping()
# Wake up and confirm the engine is functional again.
await engine.wake_up()
assert not await engine.is_sleeping()
@@ -7,6 +7,9 @@ from contextlib import ExitStack
import pytest
from tests.e2e.offline_inference.custom_pipeline.qwen_image_pipeline_with_logprob import (
QwenImagePipelineWithLogProbForTest,
)
from tests.e2e.offline_inference.custom_pipeline.worker_extension import (
vLLMOmniColocateWorkerExtensionForTest,
)
@@ -14,9 +17,7 @@ from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.worker.diffusion_worker import CustomPipelineWorkerExtension
from vllm_omni.entrypoints.async_omni import AsyncOmni
CUSTOM_PIPELINE_CLASS = (
"tests.e2e.offline_inference.custom_pipeline.qwen_image_pipeline_with_logprob.QwenImagePipelineWithLogProbForTest"
)
CUSTOM_PIPELINE_CLASS = QwenImagePipelineWithLogProbForTest
WORKER_EXTENSION_CLASS = (
"tests.e2e.offline_inference.custom_pipeline.worker_extension.vLLMOmniColocateWorkerExtensionForTest"
)
@@ -167,3 +167,18 @@ def test_sensenova_u1_img2img(run_level):
generated_image = _generate_sensenova_u1_img2img(runner.omni, input_image)
if run_level == "advanced_model":
_validate_pixels(generated_image)
@pytest.mark.core_model
@pytest.mark.diffusion
@pytest.mark.cache
@hardware_test(res={"cuda": "H100"})
def test_sensenova_u1_img2img_cache_dit():
"""Test SenseNova-U1 img2img with Cache-DiT enabled."""
input_image = _load_input_image()
with OmniRunner(
"SenseNova/SenseNova-U1-8B-MoT",
stage_configs_path=None,
cache_backend="cache_dit",
) as runner:
_generate_sensenova_u1_img2img(runner.omni, input_image)
@@ -156,3 +156,17 @@ def test_sensenova_u1_text2img(run_level):
generated_image = _generate_sensenova_u1_image(runner.omni)
if run_level == "advanced_model":
_validate_pixels(generated_image)
@pytest.mark.core_model
@pytest.mark.diffusion
@pytest.mark.cache
@hardware_test(res={"cuda": "H100"})
def test_sensenova_u1_text2img_cache_dit():
"""Test SenseNova-U1 text2img with Cache-DiT enabled."""
with OmniRunner(
"SenseNova/SenseNova-U1-8B-MoT",
stage_configs_path=None,
cache_backend="cache_dit",
) as runner:
_generate_sensenova_u1_image(runner.omni)
@@ -18,6 +18,7 @@ import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
@@ -25,7 +26,18 @@ PROMPT = "A futuristic city skyline at twilight, cyberpunk style, ultra-detailed
NEGATIVE_PROMPT = "low quality, blurry, distorted, deformed, watermark"
SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"})
PARALLEL_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
PARALLEL_2_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
HSDP_2_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
BAGEL_CI_DEPLOY = get_deploy_config_path("ci/bagel.yaml")
BAGEL_PARALLEL_2_DEPLOY = modify_stage_config(
BAGEL_CI_DEPLOY,
updates={"stages": {0: {"devices": "0"}, 1: {"devices": "0,1"}}},
)
BAGEL_HSDP_2_DEPLOY = modify_stage_config(
BAGEL_CI_DEPLOY,
updates={"stages": {0: {"devices": "0"}, 1: {"devices": "0,1"}}},
)
def _get_diffusion_feature_cases(model: str):
@@ -63,6 +75,7 @@ def _get_diffusion_feature_cases(model: str):
pytest.param(
OmniServerParams(
model=model,
stage_config_path=BAGEL_PARALLEL_2_DEPLOY,
server_args=[
"--cache-backend",
"tea_cache",
@@ -71,31 +84,33 @@ def _get_diffusion_feature_cases(model: str):
],
),
id="parallel_cfg_2",
marks=PARALLEL_FEATURE_MARKS,
marks=PARALLEL_2_FEATURE_MARKS,
),
# Ulysses-SP degree=2 (2 GPUs)
pytest.param(
OmniServerParams(
model=model,
stage_config_path=BAGEL_PARALLEL_2_DEPLOY,
server_args=[
"--usp",
"2",
],
),
id="sp_ulysses_2",
marks=PARALLEL_FEATURE_MARKS,
marks=PARALLEL_2_FEATURE_MARKS,
),
# Ring-Attention degree=2 (2 GPUs)
pytest.param(
OmniServerParams(
model=model,
stage_config_path=BAGEL_PARALLEL_2_DEPLOY,
server_args=[
"--ring",
"2",
],
),
id="sp_ring_2",
marks=PARALLEL_FEATURE_MARKS,
marks=PARALLEL_2_FEATURE_MARKS,
),
# Layerwise Offloading (single-card)
pytest.param(
@@ -106,18 +121,19 @@ def _get_diffusion_feature_cases(model: str):
id="single_card_layerwise_offload",
marks=SINGLE_CARD_FEATURE_MARKS,
),
# Hybrid Sharded Data Parallel (4 GPUs)
# Hybrid Sharded Data Parallel (2 GPUs)
pytest.param(
OmniServerParams(
model=model,
stage_config_path=BAGEL_HSDP_2_DEPLOY,
server_args=[
"--use-hsdp",
"--hsdp-shard-size",
"4",
"2",
],
),
id="parallel_hsdp_4",
marks=PARALLEL_FEATURE_MARKS,
id="parallel_hsdp_2",
marks=HSDP_2_FEATURE_MARKS,
),
]
@@ -140,7 +156,7 @@ def test_bagel(
- Ulysses-SP (degree=2)
- Ring-Attention (degree=2)
- Layerwise Offloading
- Hybrid Sharded Data Parallel (size=4)
- Hybrid Sharded Data Parallel (size=2)
Validation is delegated to assert_diffusion_response in tests/helpers/assertions.py,
which checks output dimensions and basic correctness.
@@ -158,6 +174,7 @@ def test_bagel(
# Enable CFG for models that use classifier-free guidance
"negative_prompt": NEGATIVE_PROMPT,
"true_cfg_scale": 4.0,
"cfg_img_scale": 1.0,
"seed": 42,
},
}
@@ -74,6 +74,41 @@ class TestOmniVoiceTTS:
openai_client.send_audio_speech_request(request_config)
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
class TestOmniVoiceSeed:
"""E2E tests for OmniVoice Seed Params."""
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_speech_auto_voice_seed_deterministic(self, omni_server, openai_client) -> None:
cfg = {
"model": omni_server.model,
"input": get_prompt("text"),
"response_format": "wav",
"seed": 42,
"min_audio_bytes": _DEFAULT_MIN_AUDIO_BYTES,
}
r1 = openai_client.send_audio_speech_request(cfg)[0]
r2 = openai_client.send_audio_speech_request(cfg)[0]
assert r1.audio_bytes == r2.audio_bytes
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_speech_auto_voice_seed_non_deterministic(self, omni_server, openai_client) -> None:
cfg = {
"model": omni_server.model,
"input": get_prompt("text"),
"response_format": "wav",
"min_audio_bytes": _DEFAULT_MIN_AUDIO_BYTES,
}
cfg1 = {**cfg, "seed": 42}
cfg2 = {**cfg, "seed": 43}
r1 = openai_client.send_audio_speech_request(cfg1)[0]
r2 = openai_client.send_audio_speech_request(cfg2)[0]
assert r1.audio_bytes != r2.audio_bytes
@pytest.mark.skipif(not _HAS_VOICE_CLONE, reason="Voice cloning requires transformers>=5.3.0")
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
class TestOmniVoiceVoiceCloning:
@@ -61,7 +61,11 @@ test_params = [
model=model,
stage_config_path=default_path,
use_stage_cli=True,
server_args=["--no-async-chunk"],
server_args=[
"--no-async-chunk",
"--stage-overrides",
'{"0": {"enable_prefix_caching": true}, "1": {"enable_prefix_caching": true}}',
],
),
id="default",
),
@@ -5,8 +5,8 @@
Online serving tests for the Qwen-Image-Edit family (image-to-image via chat completions).
- ``test_single_image_to_image_001``: ``Qwen/Qwen-Image-Edit`` — one reference image, fixed 512×512.
- ``test_multi_images_to_image_001``: ``Qwen/Qwen-Image-Edit-2509`` — two reference images, fixed 512×512.
- ``test_different_sizes_001``: ``Qwen/Qwen-Image-Edit-2509`` only, ``advanced_model`` — mixed input
- ``test_multi_images_to_image_001``: ``Qwen/Qwen-Image-Edit-2511`` — two reference images, fixed 512×512.
- ``test_different_sizes_001``: ``Qwen/Qwen-Image-Edit-2511`` only, ``advanced_model`` — mixed input
resolutions; ``extra_body`` uses per-output ``width``/``height`` lists; the test client sends one
scalar-size request per list index in parallel and merges images (see ``OpenAIClientHandler.send_diffusion_request``).
@@ -53,7 +53,6 @@ def _get_diffusion_feature_cases(model: str):
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.diffusion
@pytest.mark.parametrize(
"omni_server",
@@ -83,15 +82,16 @@ def test_single_image_to_image_001(omni_server: OmniServer, openai_client: OpenA
openai_client.send_diffusion_request(request_config)
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.diffusion
@pytest.mark.parametrize(
"omni_server",
_get_diffusion_feature_cases("Qwen/Qwen-Image-Edit-2509"),
_get_diffusion_feature_cases("Qwen/Qwen-Image-Edit-2511"),
indirect=True,
)
def test_multi_images_to_image_001(omni_server: OmniServer, openai_client: OpenAIClientHandler):
"""Two-reference edit smoke for ``Qwen/Qwen-Image-Edit-2509``."""
"""Two-reference edit smoke for ``Qwen/Qwen-Image-Edit-2511``."""
image_data_url_list = [f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}" for _ in range(2)]
@@ -118,7 +118,7 @@ def test_multi_images_to_image_001(omni_server: OmniServer, openai_client: OpenA
@pytest.mark.diffusion
@pytest.mark.parametrize(
"omni_server",
_get_diffusion_feature_cases("Qwen/Qwen-Image-Edit-2509"),
_get_diffusion_feature_cases("Qwen/Qwen-Image-Edit-2511"),
indirect=True,
)
def test_different_sizes_001(omni_server: OmniServer, openai_client: OpenAIClientHandler):
@@ -2,7 +2,7 @@
Comprehensive tests of diffusion features that are available in online serving mode
and are supported by the following models:
- Qwen-Image-Edit: single image input
- Qwen-Image-Edit-2509: single image input and two image inputs
- Qwen-Image-Edit-2511: single image input and two image inputs
"""
import pytest
@@ -14,7 +14,7 @@ from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHand
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
EDIT_PROMPT = "Transform this modern, geometrist image into a Vincent van Gogh style impressionist painting."
SINGLE_EDIT_PROMPT_2509 = "Restyle this image into a Vincent van Gogh style impressionist painting."
SINGLE_EDIT_PROMPT_2511 = "Restyle this image into a Vincent van Gogh style impressionist painting."
MULTI_EDIT_PROMPT = (
"Transform the first image into a Dadaism collage art. "
"Transform the second image into a Vincent van Gogh style painting. "
@@ -34,7 +34,7 @@ def _get_diffusion_feature_cases(model: str):
model=model,
server_args=[
"--cache-backend",
"tea_cache", # [TODO] may consider changing to cache_dit after #1779 is resolved. Currently cache_dit and layerwise offload cannot work together.
"cache_dit",
"--enable-layerwise-offload",
],
),
@@ -59,7 +59,7 @@ def _get_diffusion_feature_cases(model: str):
model=model,
server_args=[
"--cache-backend",
"cache_dit",
"tea_cache",
"--ring",
"2",
],
@@ -145,7 +145,7 @@ def test_qwen_image_edit(omni_server: OmniServer, openai_client: OpenAIClientHan
indirect=True,
)
def test_qwen_image_edit_2511_single_image(omni_server: OmniServer, openai_client: OpenAIClientHandler):
"""Test Qwen-Image-Edit-2509 with a single image input.
"""Test Qwen-Image-Edit-2511 with a single image input.
Regression: with tea_cache enabled and zero_cond_t=True, the TeaCache
postprocess closure used the doubled temb (shape 2*batch) without halving
@@ -156,7 +156,7 @@ def test_qwen_image_edit_2511_single_image(omni_server: OmniServer, openai_clien
"""
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}"
messages = dummy_messages_from_mix_data(image_data_url=image_data_url, content_text=SINGLE_EDIT_PROMPT_2509)
messages = dummy_messages_from_mix_data(image_data_url=image_data_url, content_text=SINGLE_EDIT_PROMPT_2511)
request_config = {
"model": omni_server.model,
@@ -180,7 +180,7 @@ def test_qwen_image_edit_2511_single_image(omni_server: OmniServer, openai_clien
indirect=True,
)
def test_qwen_image_edit_2511_two_images(omni_server: OmniServer, openai_client: OpenAIClientHandler):
"""Test Qwen-Image-Edit-2509 with two image inputs."""
"""Test Qwen-Image-Edit-2511 with two image inputs."""
image_data_url_1 = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}"
image_data_url_2 = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}"
@@ -97,7 +97,7 @@ def _get_diffusion_feature_cases(model: str):
"--vae-patch-parallel-size",
"2",
"--vae-use-tiling",
"--quantization-config",
"--diffusion-quantization-config",
'{"method":"fp8"}',
],
),
+1
View File
@@ -964,6 +964,7 @@ async def test_handle_streaming_update_passes_prompt_text_to_stage_pool() -> Non
pool = RecordingPool()
orchestrator = object.__new__(Orchestrator)
orchestrator.async_chunk = False
orchestrator.request_states = {
"req-stream": OrchestratorRequestState(
request_id="req-stream",
@@ -44,7 +44,6 @@ realtime_server_params = [
model=MODEL,
stage_config_path=default_stage_config,
use_stage_cli=True,
server_args=["--no-async-chunk"],
),
id="default",
),
@@ -28,7 +28,7 @@ MODEL_BASE = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
MODEL_BASE_1_7B = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
# A synthetic 1024-dim speaker embedding (all 0.1 — not a real voice, but
# exercises the full code path through the talker's _build_prompt_embeds).
# exercises the full code path through Qwen3TTSPromptEmbedsBuilder.build_prompt_embeds).
DUMMY_EMBEDDING_1024 = [0.1] * 1024
DUMMY_EMBEDDING_2048 = [0.1] * 2048
@@ -363,3 +363,104 @@ def test_build_multistage_generation_inputs_custom_system_prompt(serving_chat):
f"custom system_prompt content must reach the rendered prompt; "
f"marker {marker!r} not found in prompt of length {len(out['prompt'])}"
)
def test_build_multistage_generation_inputs_sets_ar_stop_token_ids_with_explicit_size(serving_chat):
"""When height+width are provided with bot_task, the AR (llm) stage
must receive stop_token_ids from resolve_stop_token_ids so AR stops
at the correct terminator instead of generating until max_tokens.
Without this, AR would generate past the cot boundary and produce
garbage that the DiT bridge cannot parse.
"""
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
class FakeTokenizer:
SPECIAL = {
"<|startoftext|>": 1,
"<img>": 2,
"<recaption>": 4,
}
def convert_tokens_to_ids(self, tok: str) -> int:
return self.SPECIAL.get(tok, 0)
def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
return list(range(100, 100 + len(text)))
engine = SimpleNamespace(
stage_configs=[
SimpleNamespace(stage_type="llm", is_comprehension=True),
SimpleNamespace(stage_type="diffusion", is_comprehension=False),
],
default_sampling_params_list=[
SamplingParams(temperature=0.0),
OmniDiffusionSamplingParams(),
],
)
images = [Image.new("RGB", (32, 32), color="red")]
# With height+width and bot_task=think, ar_image_size="1024x768" ->
# need_ratio=False -> stop_token_ids=[end_of_think, end_of_recaption]
_, sampling_params_list = OmniOpenAIServingChat._build_multistage_generation_inputs(
serving_chat,
engine=engine,
prompt="draw a cat",
extra_body={"bot_task": "think"},
reference_images=images,
gen_params=OmniDiffusionSamplingParams(height=768, width=1024),
tokenizer=FakeTokenizer(),
)
# AR stage (index 0) must have stop_token_ids set.
ar_params = sampling_params_list[0]
assert ar_params.stop_token_ids is not None, (
"AR stage must have stop_token_ids set when height+width and bot_task are provided"
)
assert len(ar_params.stop_token_ids) > 0, "stop_token_ids must be non-empty"
# Diffusion stage (index 1) must NOT have stop_token_ids set.
diff_params = sampling_params_list[1]
assert getattr(diff_params, "stop_token_ids", None) is None, "Diffusion stage must not have stop_token_ids set"
def test_build_multistage_generation_inputs_no_stop_token_ids_without_size(serving_chat):
"""Without height+width, ar_image_size=None -> need_ratio=True ->
stop_token_ids is set to the ratio range (AR predicts ratio).
This still assigns stop_token_ids, but the set is the ratio range,
not the terminator.
Without bot_task at all, ar_stop_token_ids stays None and
stop_token_ids is not set on any stage.
"""
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
engine = SimpleNamespace(
stage_configs=[
SimpleNamespace(stage_type="llm", is_comprehension=True),
SimpleNamespace(stage_type="diffusion", is_comprehension=False),
],
default_sampling_params_list=[
SamplingParams(temperature=0.0),
OmniDiffusionSamplingParams(),
],
)
images = [Image.new("RGB", (32, 32), color="red")]
# No height/width, no bot_task -> ar_stop_token_ids=None ->
# stop_token_ids not set on any stage.
_, sampling_params_list = OmniOpenAIServingChat._build_multistage_generation_inputs(
serving_chat,
engine=engine,
prompt="draw a cat",
extra_body={},
reference_images=images,
gen_params=OmniDiffusionSamplingParams(),
)
# SamplingParams defaults stop_token_ids=[], not None.
# The key contract: it was NOT set by resolve_stop_token_ids,
# so it stays as the SamplingParams default (empty list).
assert sampling_params_list[0].stop_token_ids == [], (
"Without bot_task, AR stage stop_token_ids must be the default empty list"
)
@@ -1,9 +1,13 @@
# tests/entrypoints/openai/test_serving_speech.py
import asyncio
import base64
import hashlib
import io
import json
import logging
import os
import struct
import wave
from inspect import Signature, signature
from pathlib import Path
from types import SimpleNamespace
@@ -170,6 +174,27 @@ def create_mock_audio_output_for_test(
)
def _write_custom_voice_manifest(root: Path, *, model_type: str, voices: dict) -> None:
payload = {
"schema_version": 1,
"model_type": model_type,
"voices": voices,
}
(root / "custom_voice_manifest.json").write_text(json.dumps(payload), encoding="utf-8")
def _wav_data_url(samples: np.ndarray, sample_rate: int) -> str:
pcm = (np.clip(samples, -1.0, 1.0) * 32767).astype("<i2")
buf = io.BytesIO()
with wave.open(buf, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(sample_rate)
wav.writeframes(pcm.tobytes())
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:audio/wav;base64,{encoded}"
@pytest.fixture
def test_app(mocker: MockerFixture):
# Mock the engine client
@@ -972,6 +997,233 @@ class TestTTSMethods:
params = speech_server._build_tts_params(req)
assert "voice_clone_prompt" not in params
@pytest.mark.asyncio
async def test_resolve_ref_audio_reuses_decoded_audio_for_same_source(self, speech_server):
wav = np.linspace(-0.5, 0.5, 48000, dtype=np.float32)
ref_audio = _wav_data_url(wav, 24000)
speech_server.model_config.allowed_local_media_path = ""
speech_server.model_config.allowed_media_domains = None
first = await speech_server._resolve_ref_audio(ref_audio)
second = await speech_server._resolve_ref_audio(ref_audio)
assert first[1] == 24000
assert second[1] == 24000
assert first[0] is second[0]
assert first[0][0] == pytest.approx(float(wav[0]), abs=1e-4)
assert speech_server._get_resolved_ref_audio_artifact_key(
ref_audio
) == speech_server._make_ref_audio_artifact_cache_key(np.asarray(first[0], dtype=np.float32), 24000)
def test_precomputed_qwen3_voice_infers_base_without_ref_audio(self, speech_server):
"""Precomputed Qwen3 voices are reusable by name without per-request ref_audio."""
speech_server._tts_model_type = "qwen3_tts"
speech_server.precomputed_speakers = {
"alice": {
"name": "Alice",
"model_type": "qwen3_tts",
"mode": "icl",
"ref_text": "reference transcript",
"ref_code_length": 3,
}
}
speech_server.supported_speakers = {"alice"}
req = OpenAICreateSpeechRequest(input="Hello", voice="Alice")
assert speech_server._validate_tts_request(req) is None
assert req.task_type == "Base"
params = speech_server._build_tts_params(req)
assert params["task_type"] == ["Base"]
assert params["speaker"] == ["alice"]
assert params["x_vector_only_mode"] == [False]
assert params["ref_text"] == ["reference transcript"]
assert params["ref_code_length"] == [3]
assert "ref_audio" not in params
def test_uploaded_qwen3_voice_wins_over_same_named_precomputed_voice(self, speech_server, tmp_path):
"""Qwen3 uploaded voices should take precedence over same-name precomputed voices."""
from safetensors.torch import save_file
uploaded_path = tmp_path / "alice.safetensors"
save_file({"speaker_embedding": torch.tensor([0.1] * 4)}, str(uploaded_path))
speech_server._tts_model_type = "qwen3_tts"
speech_server.uploaded_speakers_dir = tmp_path
speech_server.uploaded_speakers = {
"alice": {
"name": "alice",
"created_at": 123,
"file_path": str(uploaded_path),
"embedding_source": "direct",
}
}
speech_server.precomputed_speakers = {
"alice": {
"name": "Alice",
"model_type": "qwen3_tts",
"mode": "icl",
"ref_text": "precomputed transcript",
"ref_code_length": 3,
}
}
req = OpenAICreateSpeechRequest(input="Hello", voice="Alice")
assert speech_server._validate_tts_request(req) is None
params = speech_server._build_tts_params(req)
assert params["speaker"] == ["alice"]
assert params["voice_created_at"] == [123]
assert params["task_type"] == ["Base"]
assert "voice_clone_prompt" in params
assert "ref_code_length" not in params
assert params.get("ref_text") != ["precomputed transcript"]
def test_precomputed_qwen3_missing_safetensors_is_not_registered(self, speech_server, tmp_path):
"""Manifest entries are not supported speakers unless their safetensors load."""
_write_custom_voice_manifest(
tmp_path,
model_type="qwen3_tts",
voices={"Alice": {"file": "missing.safetensors", "mode": "xvec", "embedding_dim": 4}},
)
speech_server._tts_model_type = "qwen3_tts"
speech_server.engine_client.model_config = SimpleNamespace(
hf_config=SimpleNamespace(
custom_voice_dir=str(tmp_path),
talker_config=SimpleNamespace(hidden_size=4),
)
)
profiles = speech_server._load_precomputed_speakers()
assert profiles == {}
speech_server.precomputed_speakers = profiles
speech_server.supported_speakers = set(profiles)
assert "alice" not in speech_server.supported_speakers
req = OpenAICreateSpeechRequest(input="Hello", voice="Alice")
assert speech_server._validate_tts_request(req) is not None
def test_precomputed_qwen3_icl_without_ref_code_is_not_registered(self, speech_server, tmp_path):
"""Qwen3 ICL profiles without ref_code cannot be exposed by the API layer."""
from safetensors.torch import save_file
save_file({"speaker_embedding": torch.arange(4, dtype=torch.float32)}, str(tmp_path / "alice.safetensors"))
_write_custom_voice_manifest(
tmp_path,
model_type="qwen3_tts",
voices={
"Alice": {
"file": "alice.safetensors",
"mode": "icl",
"ref_text": "reference transcript",
"embedding_dim": 4,
}
},
)
speech_server._tts_model_type = "qwen3_tts"
speech_server.engine_client.model_config = SimpleNamespace(
hf_config=SimpleNamespace(
custom_voice_dir=str(tmp_path),
talker_config=SimpleNamespace(hidden_size=4),
)
)
profiles = speech_server._load_precomputed_speakers()
assert profiles == {}
speech_server.precomputed_speakers = profiles
speech_server.supported_speakers = set(profiles)
assert "alice" not in speech_server.supported_speakers
req = OpenAICreateSpeechRequest(input="Hello", voice="Alice")
assert speech_server._validate_tts_request(req) is not None
def test_precomputed_voxcpm2_missing_safetensors_is_not_registered(self, speech_server, tmp_path):
"""VoxCPM2 must not advertise a manifest-only voice that cannot hit prompt cache."""
_write_custom_voice_manifest(
tmp_path,
model_type="voxcpm2",
voices={"Bob": {"file": "missing.safetensors", "mode": "reference", "ref_audio_feat_len": 2}},
)
speech_server._tts_model_type = "voxcpm2"
speech_server.engine_client.model_config = SimpleNamespace(
hf_config=SimpleNamespace(custom_voice_dir=str(tmp_path))
)
profiles = speech_server._load_precomputed_speakers()
assert profiles == {}
speech_server.precomputed_speakers = profiles
speech_server.supported_speakers = set(profiles)
assert "bob" not in speech_server.supported_speakers
req = OpenAICreateSpeechRequest(input="Hello", voice="Bob")
assert speech_server._validate_tts_request(req) is not None
assert speech_server._validate_tts_request(OpenAICreateSpeechRequest(input="Hello")) is None
def test_prepare_voxcpm2_rejects_supported_speaker_without_voice_profile(self, speech_server, mocker):
"""VoxCPM2 named voices must be uploaded or precomputed on the real request path."""
speech_server._tts_model_type = "voxcpm2"
speech_server.supported_speakers = {"bob", "default"}
speech_server.uploaded_speakers = {}
speech_server.precomputed_speakers = {}
speech_server.engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)]
speech_server.engine_client.generate = mocker.MagicMock(return_value="generator")
speech_server._build_voxcpm2_prompt = mocker.AsyncMock(
return_value={"prompt_token_ids": [1], "additional_information": {}}
)
with pytest.raises(ValueError, match="Invalid voice 'bob'"):
asyncio.run(speech_server._prepare_speech_generation(OpenAICreateSpeechRequest(input="Hello", voice="Bob")))
speech_server._build_voxcpm2_prompt.assert_not_awaited()
speech_server.engine_client.generate.assert_not_called()
def test_prepare_voxcpm2_accepts_default_voice(self, speech_server, mocker):
"""VoxCPM2 default voice preserves the built-in zero-shot request path."""
speech_server._tts_model_type = "voxcpm2"
speech_server.supported_speakers = {"default"}
speech_server.uploaded_speakers = {}
speech_server.precomputed_speakers = {}
speech_server.engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)]
speech_server.engine_client.generate = mocker.MagicMock(return_value=iter(()))
speech_server._build_voxcpm2_prompt = mocker.AsyncMock(
return_value={"prompt_token_ids": [1], "additional_information": {}}
)
asyncio.run(speech_server._prepare_speech_generation(OpenAICreateSpeechRequest(input="Hello", voice="default")))
speech_server._build_voxcpm2_prompt.assert_awaited_once()
speech_server.engine_client.generate.assert_called_once()
def test_prepare_voxcpm2_precomputed_voice_sets_model_cache_key(self, speech_server, mocker):
"""VoxCPM2 precomputed voices must carry voice metadata to the model cache lookup."""
speech_server._tts_model_type = "voxcpm2"
speech_server.supported_speakers = {"alice"}
speech_server.uploaded_speakers = {}
speech_server.precomputed_speakers = {
"alice": {
"name": "Alice",
"model_type": "voxcpm2",
"mode": "reference",
"ref_audio_feat_len": 2,
}
}
speech_server.engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)]
speech_server.engine_client.generate = mocker.MagicMock(return_value=iter(()))
speech_server._build_voxcpm2_prompt = mocker.AsyncMock(
return_value={
"prompt_token_ids": [1],
"additional_information": {
"voice_profile": speech_server.precomputed_speakers["alice"],
},
}
)
asyncio.run(speech_server._prepare_speech_generation(OpenAICreateSpeechRequest(input="Hello", voice="Alice")))
prompt = speech_server.engine_client.generate.call_args.kwargs["prompt"]
additional = prompt["additional_information"]
assert additional["voice_name"] == "alice"
assert additional["voice_created_at"] == 0
def test_build_tts_params(self, speech_server):
"""Test TTS parameter building."""
req = OpenAICreateSpeechRequest(input="Hello", voice="Ryan", language="English")
@@ -2497,6 +2749,95 @@ class TestTTSAsyncOffloading:
qwen3_tts_server._build_tts_params.assert_called_once()
qwen3_tts_server._estimate_prompt_len_async.assert_awaited_once()
def test_qwen3_repeated_ref_audio_hot_path_sends_cache_key_without_waveform(self, qwen3_tts_server):
"""After a ref artifact is marked ready, repeated requests avoid ref_audio payload IPC."""
wav_list = [0.0] * 48000
artifact_key = "a" * 40
ref_audio = "data:audio/wav;base64,same"
qwen3_tts_server._put_resolved_ref_audio(
hashlib.sha1(ref_audio.encode("utf-8")).hexdigest(),
wav_list,
24000,
artifact_key,
)
qwen3_tts_server._ref_audio_model_artifact_ready.add(artifact_key)
qwen3_tts_server._codec_frame_rate = 25.0
qwen3_tts_server._tts_tokenizer = lambda _text, padding=False: {"input_ids": list(range(10))}
qwen3_tts_server.engine_client.model_config.hf_config.talker_config = SimpleNamespace(
codec_language_id={},
spk_is_dialect={},
)
request = OpenAICreateSpeechRequest(
input="hello",
task_type="Base",
ref_audio=ref_audio,
ref_text="reference",
)
request_id, _generator, tts_params = asyncio.run(
qwen3_tts_server._prepare_speech_generation(request, request_id="req-hot")
)
assert request_id == "req-hot"
assert "ref_audio" not in tts_params
assert tts_params["_qwen3_tts_ref_audio_cache_key"] == [artifact_key]
assert tts_params["ref_code_length"] == [50]
prompt = qwen3_tts_server.engine_client.generate.call_args.kwargs["prompt"]
assert prompt["additional_information"] is tts_params
def test_qwen3_ref_audio_artifact_ready_is_evicted_with_resolve_cache(self, qwen3_tts_server):
qwen3_tts_server._ref_audio_resolve_cache_max_entries = 1
qwen3_tts_server._ref_audio_resolve_cache_max_bytes = 1_000_000
qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a")
qwen3_tts_server._ref_audio_model_artifact_ready.add("artifact-a")
qwen3_tts_server._put_resolved_ref_audio("ref-b", [0.0] * 8, 24000, "artifact-b")
assert "artifact-a" not in qwen3_tts_server._ref_audio_model_artifact_ready
assert "artifact-b" in {entry[3] for entry in qwen3_tts_server._ref_audio_resolve_cache.values()}
@pytest.mark.asyncio
async def test_generate_audio_chunks_discards_ref_audio_artifact_warmup_on_error(self, qwen3_tts_server):
async def failing_generator():
raise ValueError("boom")
yield # pragma: no cover
qwen3_tts_server._request_ref_audio_artifact_keys["req-fail"] = "artifact-fail"
with pytest.raises(ValueError, match="boom"):
await anext(qwen3_tts_server._generate_audio_chunks(failing_generator(), "req-fail"))
assert "req-fail" not in qwen3_tts_server._request_ref_audio_artifact_keys
assert "artifact-fail" not in qwen3_tts_server._ref_audio_model_artifact_ready
@pytest.mark.asyncio
async def test_generate_audio_chunks_discards_ref_audio_artifact_warmup_on_close(self, qwen3_tts_server):
async def pcm_generator():
yield SimpleNamespace(
multimodal_output={
"audio": torch.zeros(16, dtype=torch.float32),
"sr": 24000,
}
)
await asyncio.sleep(0)
qwen3_tts_server._request_ref_audio_artifact_keys["req-close"] = "artifact-close"
stream = qwen3_tts_server._generate_audio_chunks(pcm_generator(), "req-close")
assert await anext(stream)
await stream.aclose()
assert "req-close" not in qwen3_tts_server._request_ref_audio_artifact_keys
assert "artifact-close" not in qwen3_tts_server._ref_audio_model_artifact_ready
def test_qwen3_ref_audio_artifact_ready_requires_live_resolve_cache_entry(self, qwen3_tts_server):
qwen3_tts_server._request_ref_audio_artifact_keys["req-evicted"] = "artifact-evicted"
qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-evicted")
assert "req-evicted" not in qwen3_tts_server._request_ref_audio_artifact_keys
assert "artifact-evicted" not in qwen3_tts_server._ref_audio_model_artifact_ready
def test_shutdown_is_idempotent(self, mocker: MockerFixture):
"""Calling shutdown() twice should not raise."""
mocker.patch.object(OmniOpenAIServingSpeech, "_load_supported_speakers", return_value=set())
+180 -13
View File
@@ -1,12 +1,19 @@
import asyncio
import re
from types import SimpleNamespace
import pytest
from vllm.sampling_params import RequestOutputKind, SamplingParams
from tests.helpers.mark import hardware_test
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.entrypoints.async_omni import AsyncOmni
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
pytestmark = [pytest.mark.core_model]
DIFFUSION_MODEL = "riverclouds/qwen_image_random"
OMNI_MODEL = "Qwen/Qwen2.5-Omni-7B"
OMNI_STAGE_CONFIG = get_deploy_config_path("ci/qwen2_5_omni_thinker_only.yaml")
async def _noop(**kw):
@@ -59,6 +66,89 @@ def get_async_omni_instance(fake_add_request=_noop, fake_abort_request=_noop) ->
return omni
@pytest.mark.cpu
def test_generate_submits_randomized_id_to_engine():
"""Ensure the engine receives a UUID-suffixed ID, not the raw request ID"""
async def run():
submitted_ids = []
omni = get_async_omni_instance(fake_add_request=get_fake_add_request(submitted_ids))
req_id = "my-req-1"
async for _ in omni.generate(
prompt={"prompt": "test"},
request_id=req_id,
sampling_params_list=[SimpleNamespace()],
output_modalities=["text"],
):
pass
assert len(submitted_ids) == 1
assert submitted_ids[0] != req_id
assert submitted_ids[0].startswith(f"{req_id}-")
asyncio.run(run())
@pytest.mark.cpu
@pytest.mark.parametrize(
"req_ids,cancel_prefix,expected_cancel_count",
[
(["cancel-me"], "cancel-me", 1),
(["cancel-me", "cancel-me"], "cancel-me", 2),
(["cancel-hello", "cancel-hello-world"], "cancel-hello", 1),
],
)
def test_abort_handles_internal_request_mapping(req_ids: list[str], cancel_prefix: str, expected_cancel_count: int):
"""Ensure that abort() with the user-visible ID resolves correctly.
NOTE: In the case of concurrent / colliding request(s), all requests matching the
user provided request ID will be aborted."""
async def run():
aborted_batches = []
omni = get_async_omni_instance(
fake_abort_request=get_fake_abort(aborted_batches),
)
async def exhaust(agen):
async for _ in agen:
pass
tasks = []
for user_request_id in req_ids:
t = asyncio.create_task(
exhaust(
omni.generate(
prompt={"prompt": "test"},
request_id=user_request_id,
sampling_params_list=[SimpleNamespace()],
)
)
)
await asyncio.sleep(0)
tasks.append(t)
assert len(omni.request_states) == len(req_ids)
await omni.abort(cancel_prefix)
assert len(aborted_batches) == 1
aborted_ids = aborted_batches[0]
# Aborted requests will have fmt {ext_id}-{UUID} to avoid collisions
for rid in aborted_ids:
assert re.fullmatch(rf"{re.escape(cancel_prefix)}-[0-9a-f]+", rid)
assert len(aborted_ids) == expected_cancel_count
assert len(set(aborted_ids)) == expected_cancel_count
assert len(omni.request_states) == len(req_ids) - expected_cancel_count
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
asyncio.run(run())
@pytest.mark.cpu
def test_generate_accepts_request_after_repeated_cancellations():
async def run_test():
submitted_request_ids = []
@@ -91,22 +181,23 @@ def test_generate_accepts_request_after_repeated_cancellations():
await task
assert len(await collect_outputs("after-cancel")) == 1
assert submitted_request_ids == [
"baseline",
"cancel-0",
"cancel-1",
"cancel-2",
"after-cancel",
]
assert aborted_request_batches == [
["cancel-0"],
["cancel-1"],
["cancel-2"],
]
# Check prefixes instead of equality since generate will add
# a random numeric suffix to ensure the request ID is unique
expected_prefixes = ["baseline-", "cancel-0-", "cancel-1-", "cancel-2-", "after-cancel-"]
assert len(submitted_request_ids) == len(expected_prefixes)
for submitted, prefix in zip(submitted_request_ids, expected_prefixes):
assert submitted.startswith(prefix)
assert len(aborted_request_batches) == 3
for batch, prefix in zip(aborted_request_batches, ["cancel-0-", "cancel-1-", "cancel-2-"]):
assert len(batch) == 1
assert batch[0].startswith(prefix)
asyncio.run(run_test())
@pytest.mark.cpu
@pytest.mark.parametrize(
"output_kind", [RequestOutputKind.DELTA, RequestOutputKind.FINAL_ONLY, RequestOutputKind.CUMULATIVE]
)
@@ -132,3 +223,79 @@ def test_output_kind_is_preserved_with_explicit_sampling_params(output_kind):
asyncio.run(run())
assert captured_params[0].output_kind == output_kind
# End to end tests for ensuring internal manipulation of request ID
# in diffusion / Omni models don't leak back to the user.
#
# NOTE: It seems like we currently need the shutdowns here, otherwise
# running the tests sequentially seems to leave a zombie process in diffusion
# that can OOM the Omni tests.
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.omni
@pytest.mark.asyncio
@pytest.mark.parametrize(
"req_id",
["my-req-1", "img_gen-abc123", "chatcmpl-xyz"],
ids=["plain", "prefixed-img", "prefixed-chat"],
)
async def test_diffusion_generate_preserves_request_id(req_id):
"""Ensure diffusion model requests don't leak internal UUID-suffixed req id back to user."""
engine = AsyncOmni(model=DIFFUSION_MODEL)
try:
async for output in engine.generate("a white cat", request_id=req_id):
assert output.request_id == req_id
finally:
engine.shutdown()
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.omni
@pytest.mark.asyncio
@pytest.mark.parametrize(
"req_id",
["my-req-1", "img_gen-abc123", "chatcmpl-xyz"],
ids=["plain", "prefixed-img", "prefixed-chat"],
)
async def test_omni_generate_preserves_request_id(req_id):
"""Ensure omni model requests don't leak internal UUID-suffixed req id back to user."""
engine = AsyncOmni(model=OMNI_MODEL, stage_configs_path=OMNI_STAGE_CONFIG)
try:
async for output in engine.generate(
"Say hello in one word.",
request_id=req_id,
output_modalities=["text"],
):
assert output.request_id == req_id
finally:
engine.shutdown()
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.omni
@pytest.mark.asyncio
async def test_diffusion_generate_empty_request_id():
"""Empty request_id should get a generated internal ID, not stay empty (diffusion)"""
engine = AsyncOmni(model=DIFFUSION_MODEL)
try:
async for output in engine.generate("a white cat", request_id=""):
assert output.request_id != ""
finally:
engine.shutdown()
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.omni
@pytest.mark.asyncio
async def test_omni_generate_empty_request_id():
"""Empty request_id should get a generated internal ID, not stay empty (omni)"""
engine = AsyncOmni(model=OMNI_MODEL, stage_configs_path=OMNI_STAGE_CONFIG)
try:
async for output in engine.generate(
"Say hello in one word.",
request_id="",
output_modalities=["text"],
):
assert output.request_id != ""
finally:
engine.shutdown()
+21 -5
View File
@@ -16,6 +16,7 @@ from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError
from vllm_omni.engine.async_omni_engine import StageRuntimeInfo
from vllm_omni.engine.messages import ErrorMessage, OutputMessage
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.client_request_state import ClientRequestState
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.entrypoints.omni_base import OmniEngineDeadError
from vllm_omni.outputs import OmniRequestOutput
@@ -165,6 +166,14 @@ class FakeAsyncOmniEngine:
def _patch_engine(monkeypatch: pytest.MonkeyPatch, engine: FakeAsyncOmniEngine) -> None:
monkeypatch.setattr("vllm_omni.entrypoints.omni_base.AsyncOmniEngine", lambda *args, **kwargs: engine)
monkeypatch.setattr("vllm_omni.entrypoints.omni_base.omni_snapshot_download", lambda model: model)
# Don't add random UUIDs to requests calling .generate since we usually
# just want to check for present requests anyway, and would need to just
# strip the UUID. Explicit checks against the mapping are in tests for
# AsyncOmni, or explicitly set the client req states.
monkeypatch.setattr(
"vllm_omni.entrypoints.async_omni.AsyncOmni._get_unique_request_id",
staticmethod(lambda request_id: request_id),
)
def test_direct_omni_with_nullified_parser_only_nulls_untyped_override_fields(
@@ -623,16 +632,23 @@ async def test_async_omni_llm_diffusion_yields_text_stream_then_image(monkeypatc
async def test_async_omni_abort_forwards_to_engine(monkeypatch: pytest.MonkeyPatch):
engine = FakeAsyncOmniEngine(stage_metadata=THREE_STAGE_META)
_patch_engine(monkeypatch, engine)
app = AsyncOmni("dummy-model")
# Requests internally have a random UUID appended to the
# external ID to avoid collisions, so this also tests mapping
external_req_id = "req-1"
req_id = "req-1-12345678"
try:
app.request_states["req-1"] = object()
await app.abort("req-1")
app.request_states[req_id] = ClientRequestState(
request_id=req_id,
external_request_id=external_req_id,
)
await app.abort(external_req_id)
finally:
app.shutdown()
assert engine.aborted == [["req-1"]]
assert "req-1" not in app.request_states
assert engine.aborted == [[req_id]]
assert external_req_id not in app.request_states
@pytest.mark.asyncio
+88 -1
View File
@@ -4,7 +4,7 @@ import sys
import pytest
from pytest_mock import MockerFixture
from vllm_omni.entrypoints.stage_utils import set_stage_devices
from vllm_omni.entrypoints.stage_utils import _map_device_list, set_stage_devices
def _make_dummy_torch(call_log):
@@ -130,3 +130,90 @@ def test_set_stage_devices_npu_platform(mocker: MockerFixture, monkeypatch: pyte
set_stage_devices(stage_id=0, devices="0,1")
assert os.environ["ASCEND_RT_VISIBLE_DEVICES"] == "4,5"
# ---- _map_device_list unit tests ----
@pytest.mark.core_model
@pytest.mark.cpu
def test_map_device_list_idempotency():
"""Device IDs already in the visible set are returned as-is (idempotency)."""
result = _map_device_list(0, ["0", "1"], ["0", "1", "2", "3"])
assert result == ["0", "1"]
@pytest.mark.core_model
@pytest.mark.cpu
def test_map_device_list_idempotency_single():
"""Single device ID in visible set passes through."""
result = _map_device_list(1, ["1"], ["0", "1"])
assert result == ["1"]
@pytest.mark.core_model
@pytest.mark.cpu
def test_map_device_list_index_mapping():
"""Device IDs < num_visible not in set are treated as indices."""
result = _map_device_list(0, ["0", "1"], ["6", "7", "8", "9"])
assert result == ["6", "7"]
@pytest.mark.core_model
@pytest.mark.cpu
def test_map_device_list_physical_fallback():
"""Device IDs >= num_visible are treated as physical IDs and passed through."""
result = _map_device_list(1, ["2"], ["0", "1"])
assert result == ["2"]
@pytest.mark.core_model
@pytest.mark.cpu
def test_map_device_list_physical_fallback_multi():
"""Multiple device IDs >= num_visible all pass through as physical IDs."""
result = _map_device_list(1, ["2", "3"], ["0", "1"])
assert result == ["2", "3"]
@pytest.mark.core_model
@pytest.mark.cpu
def test_map_device_list_physical_fallback_mixed():
"""When some devices are >= num_visible but some < num_visible, index mapping applies for the subset."""
# "1" < num_visible(2) so index mapping applies: visible[1] = "5"
# "2" >= num_visible(2) so it's dropped via the partial mapping path
result = _map_device_list(1, ["1", "2"], ["0", "5"])
assert result == ["5"]
@pytest.mark.core_model
@pytest.mark.cpu
def test_map_device_list_index_with_gaps():
"""Index-based mapping works with non-contiguous visible device lists."""
result = _map_device_list(0, ["0", "1"], ["4", "5", "7"])
assert result == ["4", "5"]
@pytest.mark.core_model
@pytest.mark.cpu
def test_map_device_list_index_mapping_no_idempotency():
"""Device IDs < num_visible not in visible set map via index even when no ID matches literally."""
# "0","1" with visible ["2","3"]: idempotency check fails (none in set),
# but index mapping succeeds: visible[0]="2", visible[1]="3"
result = _map_device_list(0, ["0", "1"], ["2", "3"])
assert result == ["2", "3"]
@pytest.mark.core_model
@pytest.mark.cpu
def test_map_device_list_partial_mapping():
"""When only a subset can map, returns the mapped subset (no error)."""
result = _map_device_list(0, ["0", "1", "2"], ["5", "6"])
assert result == ["5", "6"]
@pytest.mark.core_model
@pytest.mark.cpu
def test_map_device_list_raises_on_non_numeric():
"""Non-numeric device IDs raise ValueError."""
with pytest.raises(ValueError, match="must be non-negative integers"):
_map_device_list(0, ["a"], ["0", "1"])
@@ -57,11 +57,11 @@ def wav_file(tmp_path: Path) -> Path:
def estimator_cache(monkeypatch):
"""
Bypass model loading by pre-populating the estimator cache, and shortcut
Qwen3TTSTalker.estimate_prompt_len_from_additional_information so that
the test directly observes the real _estimate_ref_code_len closure.
Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information
so that the test directly observes the real _estimate_ref_code_len closure.
"""
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
Qwen3TTSTalkerForConditionalGeneration,
from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import (
Qwen3TTSPromptEmbedsBuilder,
)
def _shortcut(**kwargs):
@@ -69,7 +69,7 @@ def estimator_cache(monkeypatch):
return kwargs["estimate_ref_code_len"](ref_audio)
monkeypatch.setattr(
Qwen3TTSTalkerForConditionalGeneration,
Qwen3TTSPromptEmbedsBuilder,
"estimate_prompt_len_from_additional_information",
staticmethod(_shortcut),
)
+32
View File
@@ -160,9 +160,41 @@ def _print_gpu_processes() -> None:
print("=" * 80)
def _cleanup_stale_device_locks() -> None:
"""Remove stale device-initialization lock files whose recorded PID is dead.
Lock files at ``/tmp/vllm_omni_device_*_init.lock`` may persist after a
crashed / killed test run and block subsequent orchestrator startups.
"""
import glob as _glob
for lock_file in _glob.glob("/tmp/vllm_omni_device_*_init.lock"):
try:
with open(lock_file) as fh:
content = fh.read().strip()
if not content:
continue
pid = int(content)
except (OSError, ValueError):
continue
try:
os.kill(pid, 0)
except ProcessLookupError:
print(f" Removing stale device lock {lock_file} (PID {pid} is dead)")
try:
os.unlink(lock_file)
except OSError:
pass
except PermissionError:
pass
def run_pre_test_cleanup() -> None:
print("Pre-test GPU status:")
_cleanup_stale_device_locks()
num_gpus = current_omni_platform.device_count()
if num_gpus > 0:
try:
+2 -2
View File
@@ -21,7 +21,7 @@ def cuda_marks(*, res: str, num_cards: int):
return marks
test_distributed = pytest.mark.distributed_cuda(num_cards=num_cards)
test_skipif = pytest.mark.skipif_cuda(
test_skipif = pytest.mark.skipif(
not current_platform.is_cuda() or (current_platform.device_count() < num_cards),
reason=f"Need at least {num_cards} CUDA GPUs to run the test.",
)
@@ -52,7 +52,7 @@ def xpu_marks(*, res: str, num_cards: int):
return marks
test_distributed = pytest.mark.distributed_xpu(num_cards=num_cards)
test_skipif = pytest.mark.skipif_xpu(
test_skipif = pytest.mark.skipif(
not current_platform.is_xpu() or (current_platform.device_count() < num_cards),
reason=f"Need at least {num_cards} XPUs to run the test.",
)
+117 -29
View File
@@ -305,40 +305,132 @@ class OmniServer:
time.sleep(2)
raise RuntimeError(f"Server failed to start within {max_wait} seconds")
@staticmethod
def _reap_zombie(proc: "psutil.Process") -> bool:
"""Reap a zombie child process via ``os.waitpid``.
``psutil.Process.wait()`` uses ``pidfd_open`` + ``poll()``, which
never fires for a zombie (the zombie is already dead and will not
change state). Since the test process is the parent, we can reap
the zombie directly with ``os.waitpid(pid, os.WNOHANG)`` and
retrieve its exit code.
Returns True if the process was a zombie and was reaped.
"""
if proc.status() != psutil.STATUS_ZOMBIE:
return False
try:
os.waitpid(proc.pid, os.WNOHANG)
except ChildProcessError:
pass
return True
@staticmethod
def _wait_or_reap(proc: "psutil.Process", timeout: int) -> None:
"""Wait for *proc* to exit, handling zombie state transparently.
When the process is already a zombie (e.g. orchestrator thread
hung during shutdown and the main process exited), ``pidfd_open``
+ ``poll()`` inside ``psutil`` will never see a state change.
Fall back to ``os.waitpid`` to reap the zombie.
"""
if OmniServer._reap_zombie(proc):
return
try:
proc.wait(timeout=timeout)
except psutil.TimeoutExpired:
OmniServer._reap_zombie(proc)
def _kill_process_tree(self, pid):
"""Kill the process tree rooted at *pid*.
Terminate the parent **first** so the OmniServer can gracefully shut
down its stage-engine children through the orchestrator. This avoids
the ``subprocess died unexpectedly`` ERROR that the APIServer monitor
thread logs when children are killed before the parent, which in turn
can cause CI watchdogs to false-trigger on the upstream ``Shutdown
initiated`` message.
When the parent does not exit within the grace period (e.g. CPU-
offloaded workers stuck in CUDA D-state), the method falls back to
killing children first so the parent can be reaped cleanly.
"""
try:
parent = psutil.Process(pid)
children = parent.children(recursive=True)
all_pids = [pid] + [child.pid for child in children]
for child in children:
try:
child.terminate()
except psutil.NoSuchProcess:
pass
_, still_alive = psutil.wait_procs(children, timeout=10)
for child in still_alive:
try:
child.kill()
except psutil.NoSuchProcess:
pass
# 1. Terminate the parent first — let it run its graceful
# shutdown cascade (orchestrator → stage pools → engine cores).
try:
parent.terminate()
parent.wait(timeout=10)
except (psutil.NoSuchProcess, psutil.TimeoutExpired):
except psutil.NoSuchProcess:
pass
# 2. Give the parent time to shut down its children cleanly.
parent_exited = False
try:
parent.wait(timeout=15)
parent_exited = True
except psutil.NoSuchProcess:
parent_exited = True
except psutil.TimeoutExpired:
parent_exited = OmniServer._reap_zombie(parent)
if not parent_exited:
# Parent is stuck — children (e.g. CPU-offloaded CFG workers)
# are likely in uninterruptible sleep. Kill children first
# so the parent can be reaped without lingering as a zombie.
for child in children:
try:
child.kill()
except psutil.NoSuchProcess:
pass
psutil.wait_procs(children, timeout=5)
try:
parent.kill()
except psutil.NoSuchProcess:
pass
OmniServer._wait_or_reap(parent, timeout=5)
else:
# Parent exited cleanly — clean up any remaining children.
for child in children:
try:
if child.is_running():
child.terminate()
except psutil.NoSuchProcess:
pass
gone, still_alive = psutil.wait_procs(children, timeout=10)
for child in still_alive:
try:
child.kill()
except psutil.NoSuchProcess:
pass
try:
if parent.is_running() and not OmniServer._reap_zombie(parent):
parent.kill()
parent.wait(timeout=10)
except psutil.NoSuchProcess:
pass
# 3. Final sweep — ``kill -9`` anything that escaped.
time.sleep(1)
alive_processes = []
for check_pid in all_pids:
if psutil.pid_exists(check_pid):
alive_processes.append(check_pid)
alive_processes: list[int] = []
for child in children:
try:
if child.is_running():
alive_processes.append(child.pid)
except psutil.NoSuchProcess:
pass
# Only count the parent as alive if it is NOT a zombie
# (zombies are already dead — just waiting to be reaped).
try:
if parent.is_running() and parent.status() != psutil.STATUS_ZOMBIE:
alive_processes.append(parent.pid)
except psutil.NoSuchProcess:
pass
if alive_processes:
print(f"Warning: Processes still alive: {alive_processes}")
@@ -531,10 +623,6 @@ class OmniServerStageCli(OmniServer):
if self.env_dict is not None:
env.update(self.env_dict)
devices = self.stage_runtime_devices.get(stage_id)
if devices:
self._set_stage_device_env(stage_id, env, devices, replica_id=replica_id)
cmd = self._build_stage_cmd(stage_id, headless=headless, replica_id=replica_id)
print(f"Launching OmniServerStageCli stage {stage_id} replica {replica_id}: {' '.join(cmd)}")
# Capture each subprocess's stdout+stderr to a per-stage log file so
@@ -1671,7 +1759,7 @@ class OpenAIClientHandler:
# Qwen3-TTS custom fields, forwarded via extra_body.
extra_body: dict[str, Any] = {}
# Keep this list aligned with vllm_omni.entrypoints.openai.protocol.audio params.
for key in ("task_type", "ref_text", "ref_audio", "language", "max_new_tokens"):
for key in ("task_type", "ref_text", "ref_audio", "language", "max_new_tokens", "seed"):
if key in request_config:
extra_body[key] = request_config[key]
@@ -2147,8 +2235,8 @@ class OmniRunner:
_cache = self._prompt_len_estimate_cache
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,
from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import (
Qwen3TTSPromptEmbedsBuilder,
)
if model_name not in _cache:
@@ -2160,7 +2248,7 @@ class OmniRunner:
tok, tcfg = _cache[model_name]
task_type = (additional_information.get("task_type") or ["CustomVoice"])[0]
return Qwen3TTSTalkerForConditionalGeneration.estimate_prompt_len_from_additional_information(
return Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
additional_information=additional_information,
task_type=task_type,
tokenize_prompt=lambda t: tok(t, padding=False)["input_ids"],
@@ -478,19 +478,29 @@ def test_compute_capture_sizes(kwargs, expected_in, not_expected):
"batch,channels,seq_len",
[(2, 64, 1000), (1, 32, 1), (1, 32, 7), (1, 32, 128), (1, 32, 1024), (1, 32, 4096)],
)
def test_snakebeta_triton_vs_eager(batch, channels, seq_len):
"""Fused Triton SnakeBeta kernel must match eager PyTorch output."""
@pytest.mark.parametrize(
"dtype,atol,rtol",
[
(torch.float32, 1e-5, 1e-5),
(torch.bfloat16, 1e-2, 1e-2),
(torch.float16, 1e-3, 1e-3),
],
ids=["fp32", "bf16", "fp16"],
)
def test_snakebeta_triton_vs_eager(batch, channels, seq_len, dtype, atol, rtol):
"""Fused Triton SnakeBeta kernel must match eager PyTorch output across dtypes."""
from vllm_omni.model_executor.models.common.snake_activation import SnakeBeta
if not SnakeBeta._init_triton():
pytest.skip("Triton not available")
torch.manual_seed(42)
snake = SnakeBeta(in_features=channels).to(DEVICE).eval()
x = torch.randn(batch, channels, seq_len, device=DEVICE)
snake = SnakeBeta(in_features=channels).to(DEVICE).to(dtype).eval()
x = torch.randn(batch, channels, seq_len, device=DEVICE, dtype=dtype)
with torch.no_grad():
eager_out = snake._eager_forward(x)
triton_out = snake._triton_forward(x)
torch.testing.assert_close(triton_out, eager_out, atol=1e-5, rtol=1e-5)
assert triton_out.dtype == x.dtype
torch.testing.assert_close(triton_out, eager_out, atol=atol, rtol=rtol)
@@ -25,6 +25,7 @@ class _FakeDecoder(nn.Module):
self.total_upsample = total_upsample
self.decode_calls: list[dict[str, int]] = []
self.batched_decode_calls: list[dict[str, int]] = []
self.decode_codes: list[torch.Tensor] = []
self.cudagraph_calls: list[dict[str, int | torch.device]] = []
def to(self, *args, **kwargs):
@@ -37,6 +38,7 @@ class _FakeDecoder(nn.Module):
chunk_size: int = 300,
left_context_size: int = 25,
) -> torch.Tensor:
self.decode_codes.append(codes.detach().cpu().clone())
self.decode_calls.append(
{
"chunk_size": chunk_size,
@@ -181,6 +183,136 @@ def test_forward_trims_trailing_padding_without_context():
torch.testing.assert_close(audio, expected)
def test_forward_reuses_cached_ref_context_for_followup_chunk():
model = _make_model()
first_codes = torch.tensor(
[
9,
8,
1,
2,
9,
8,
3,
4,
],
dtype=torch.long,
)
model.forward(
input_ids=first_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 2,
"ref_context_size": 2,
"ref_context_request_id": "rid",
"ref_context_included": True,
}
}
],
)
followup_codes = torch.tensor([5, 6, 7, 15, 16, 17], dtype=torch.long)
model.forward(
input_ids=followup_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 3,
"ref_context_size": 2,
"ref_context_request_id": "rid",
"ref_context_included": False,
"finished": torch.tensor(True),
}
}
],
)
torch.testing.assert_close(
model.decoder.decode_codes[-1],
torch.tensor(
[
[
[9, 8, 5, 6, 7],
[9, 8, 15, 16, 17],
]
],
dtype=torch.long,
),
)
assert "rid" not in model._ref_context_cache
def test_forward_fails_fast_when_ref_context_cache_is_missing():
model = _make_model()
followup_codes = torch.tensor([5, 6, 7, 15, 16, 17], dtype=torch.long)
with pytest.raises(ValueError, match="Missing Qwen3-TTS ref context cache"):
model.forward(
input_ids=followup_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 3,
"ref_context_size": 2,
"ref_context_request_id": "rid",
"ref_context_included": False,
}
}
],
)
def test_ref_context_cache_evicts_lru_entries_when_requests_abort():
model = _make_model()
model._ref_context_cache_max_entries = 2
first_codes = torch.tensor([9, 8, 1, 2, 9, 8, 3, 4], dtype=torch.long)
for rid in ("a", "b", "c"):
model.forward(
input_ids=first_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 2,
"ref_context_size": 2,
"ref_context_request_id": rid,
"ref_context_included": True,
}
}
],
)
assert list(model._ref_context_cache) == ["b", "c"]
assert model._ref_context_cache_bytes == sum(model._tensor_nbytes(t) for t in model._ref_context_cache.values())
def test_ref_context_cache_evicts_to_byte_cap():
model = _make_model()
model._ref_context_cache_max_entries = 100
model._ref_context_cache_max_bytes = 33
first_codes = torch.tensor([9, 8, 1, 2, 9, 8, 3, 4], dtype=torch.long)
for rid in ("a", "b"):
model.forward(
input_ids=first_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 2,
"ref_context_size": 2,
"ref_context_request_id": rid,
"ref_context_included": True,
}
}
],
)
assert list(model._ref_context_cache) == ["b"]
assert model._ref_context_cache_bytes == 32
def test_connector_codec_chunking_does_not_override_decode_chunking():
model = _make_model(
async_chunk=True,
@@ -2,36 +2,109 @@ from collections import OrderedDict
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import (
NORMALIZED_REF_AUDIO_KEY,
PRECOMPUTED_REF_CODE_KEY,
PRECOMPUTED_REF_IDS_KEY,
PRECOMPUTED_TEXT_IDS_KEY,
REF_AUDIO_CACHE_KEY,
Qwen3TTSPromptEmbedsBuilder,
)
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
_NORMALIZED_REF_AUDIO_KEY,
_PRECOMPUTED_REF_CODE_KEY,
_PRECOMPUTED_REF_IDS_KEY,
_PRECOMPUTED_TEXT_IDS_KEY,
Qwen3TTSTalkerForConditionalGeneration,
)
def _make_minimal_talker():
def _make_minimal_talker(
tts_pad_embed: torch.Tensor | None = None,
*,
build_prompt_embeds=None,
):
"""Construct a bare ``Qwen3TTSTalkerForConditionalGeneration`` for preprocess tests.
Skips ``__init__`` and ``load_weights`` (so we don't need real config /
checkpoint files) and pre-populates the minimum set of attributes the
preprocess / decode paths read: the persistent ``tts_pad`` buffer and a
stub :class:`Qwen3TTSPromptEmbedsBuilder` with a configurable
``build_prompt_embeds`` (defaults to raising, since most preprocess
tests don't exercise the builder).
"""
model = Qwen3TTSTalkerForConditionalGeneration.__new__(Qwen3TTSTalkerForConditionalGeneration)
model.talker_config = SimpleNamespace(codec_pad_id=7, num_code_groups=16)
model._ref_audio_artifact_cache_max_entries = 256
model._ref_audio_artifact_cache = OrderedDict()
if tts_pad_embed is None:
tts_pad_embed = torch.zeros((1, 4), dtype=torch.bfloat16)
model._tts_pad_embed = tts_pad_embed
def _default_raise(**_kwargs):
raise AssertionError("build_prompt_embeds was not stubbed in this test")
model._prompt_builder = SimpleNamespace(
build_prompt_embeds=build_prompt_embeds if build_prompt_embeds is not None else _default_raise,
)
return model
def _make_minimal_builder(
*,
config: SimpleNamespace | None = None,
talker_config: SimpleNamespace | None = None,
tts_pad_embed: torch.Tensor | None = None,
):
"""Construct a bare :class:`Qwen3TTSPromptEmbedsBuilder` instance.
Bypasses ``__init__`` so we don't need a real model checkpoint; tests
populate the attributes they actually exercise.
"""
builder = Qwen3TTSPromptEmbedsBuilder.__new__(Qwen3TTSPromptEmbedsBuilder)
builder._config = config or SimpleNamespace(
tts_bos_token_id=100,
tts_eos_token_id=101,
tts_pad_token_id=102,
)
builder._talker_config = talker_config or SimpleNamespace(
codec_nothink_id=10,
codec_think_bos_id=11,
codec_think_eos_id=12,
codec_think_id=13,
codec_language_id={},
codec_pad_id=7,
codec_bos_id=8,
num_code_groups=2,
spk_is_dialect={},
)
builder._model_path = ""
builder._text_embedding = None
builder._text_projection = lambda x: x
builder._codec_embed = lambda ids: torch.zeros((*ids.shape, 4), device=ids.device)
builder._residual_code_embeddings = lambda: []
builder._speaker_encoder = None
builder._tts_pad_embed_buffer = (
tts_pad_embed if tts_pad_embed is not None else torch.zeros((1, 4), dtype=torch.bfloat16)
)
builder._encode_ref_audio_batch_fn = lambda wavs, sr, *, device: (_ for _ in ()).throw(
AssertionError("encode_ref_audio_batch_fn not stubbed")
)
builder._speaker_cache = None
builder._text_tokenizer = None
builder._ref_audio_artifact_cache_max_entries = 256
builder._ref_audio_artifact_cache = OrderedDict()
builder._resampler_cache = OrderedDict()
builder._resampler_cache_max = 16
return builder
def test_single_token_prefill_uses_prefill_path():
model = _make_minimal_talker()
full_prompt_embeds = torch.arange(12, dtype=torch.float32).reshape(3, 4)
trailing_text = torch.ones((2, 4), dtype=torch.float32)
tts_pad = torch.full((1, 4), 0.5, dtype=torch.float32)
ref_code = torch.arange(32, dtype=torch.long).reshape(2, 16)
def fake_build_prompt_embeds(*, task_type, info_dict):
return full_prompt_embeds, trailing_text, tts_pad, 2, ref_code
return full_prompt_embeds, trailing_text, 2, ref_code
model._build_prompt_embeds = fake_build_prompt_embeds
model = _make_minimal_talker(build_prompt_embeds=fake_build_prompt_embeds)
input_ids = torch.tensor([123], dtype=torch.long)
out_ids, out_embeds, update = model.preprocess(
@@ -50,22 +123,22 @@ def test_single_token_prefill_uses_prefill_path():
assert update["meta"]["talker_text_offset"] == 0
assert update["meta"]["ref_code_len"] == 2
assert torch.equal(update["embed"]["prefill"], full_prompt_embeds)
assert torch.equal(update["embed"]["tts_pad"], tts_pad)
# ``tts_pad`` is no longer round-tripped through ``info_dict`` — it is
# precomputed once into ``model._tts_pad_embed`` at load time.
assert "tts_pad" not in update["embed"]
assert torch.equal(update["hidden_states"]["trailing_text"], trailing_text)
assert torch.equal(update["codes"]["ref"], ref_code)
assert update["codes"]["audio"].shape == (1, 16)
def test_single_token_prefill_can_be_inferred_from_token_progress():
model = _make_minimal_talker()
full_prompt_embeds = torch.arange(8, dtype=torch.float32).reshape(2, 4)
trailing_text = torch.ones((1, 4), dtype=torch.float32)
tts_pad = torch.zeros((1, 4), dtype=torch.float32)
def fake_build_prompt_embeds(*, task_type, info_dict):
return full_prompt_embeds, trailing_text, tts_pad, None, None
return full_prompt_embeds, trailing_text, None, None
model._build_prompt_embeds = fake_build_prompt_embeds
model = _make_minimal_talker(build_prompt_embeds=fake_build_prompt_embeds)
out_ids, out_embeds, update = model.preprocess(
input_ids=torch.tensor([123], dtype=torch.long),
@@ -82,7 +155,8 @@ def test_single_token_prefill_can_be_inferred_from_token_progress():
def test_decode_advances_trailing_text_by_offset_without_rewriting_tail():
model = _make_minimal_talker()
tts_pad = torch.full((1, 4), -1.0, dtype=torch.bfloat16)
model = _make_minimal_talker(tts_pad_embed=tts_pad)
def fake_embed_input_ids(input_ids):
return input_ids.to(torch.float32).reshape(1, 1, 1).expand(1, 1, 4)
@@ -90,7 +164,6 @@ def test_decode_advances_trailing_text_by_offset_without_rewriting_tail():
model.embed_input_ids = fake_embed_input_ids
trailing_text = torch.arange(12, dtype=torch.float32).reshape(3, 4)
last_hidden = torch.full((4,), 2.0, dtype=torch.float32)
tts_pad = torch.full((1, 4), -1.0, dtype=torch.float32)
out_ids, out_embeds, update = model.preprocess(
input_ids=torch.tensor([123], dtype=torch.long),
@@ -98,7 +171,6 @@ def test_decode_advances_trailing_text_by_offset_without_rewriting_tail():
text=["hello"],
task_type=["CustomVoice"],
hidden_states={"trailing_text": trailing_text, "last": last_hidden},
embed={"tts_pad": tts_pad},
meta={"talker_text_offset": 1},
_omni_is_prefill=False,
_omni_num_computed_tokens=2,
@@ -115,7 +187,8 @@ def test_decode_advances_trailing_text_by_offset_without_rewriting_tail():
def test_decode_advances_trailing_text_offset_across_multiple_steps():
model = _make_minimal_talker()
tts_pad = torch.full((1, 4), -1.0, dtype=torch.bfloat16)
model = _make_minimal_talker(tts_pad_embed=tts_pad)
def fake_embed_input_ids(input_ids):
return input_ids.to(torch.float32).reshape(1, 1, 1).expand(1, 1, 4)
@@ -124,7 +197,6 @@ def test_decode_advances_trailing_text_offset_across_multiple_steps():
trailing_text = torch.arange(8, dtype=torch.float32).reshape(2, 4)
state_tail = trailing_text
last_hidden = torch.full((4,), 2.0, dtype=torch.float32)
tts_pad = torch.full((1, 4), -1.0, dtype=torch.float32)
meta = {"talker_text_offset": 0}
seen_steps = []
@@ -135,7 +207,6 @@ def test_decode_advances_trailing_text_offset_across_multiple_steps():
text=["hello"],
task_type=["CustomVoice"],
hidden_states={"trailing_text": state_tail, "last": last_hidden},
embed={"tts_pad": tts_pad},
meta=meta,
_omni_is_prefill=False,
_omni_num_computed_tokens=2,
@@ -154,7 +225,8 @@ def test_decode_advances_trailing_text_offset_across_multiple_steps():
def test_decode_compacts_long_trailing_text_after_large_offset():
model = _make_minimal_talker()
tts_pad = torch.full((1, 4), -1.0, dtype=torch.bfloat16)
model = _make_minimal_talker(tts_pad_embed=tts_pad)
def fake_embed_input_ids(input_ids):
return input_ids.to(torch.float32).reshape(1, 1, 1).expand(1, 1, 4)
@@ -162,7 +234,6 @@ def test_decode_compacts_long_trailing_text_after_large_offset():
model.embed_input_ids = fake_embed_input_ids
trailing_text = torch.arange(130 * 4, dtype=torch.float32).reshape(130, 4)
last_hidden = torch.full((4,), 2.0, dtype=torch.float32)
tts_pad = torch.full((1, 4), -1.0, dtype=torch.float32)
_, _, update = model.preprocess(
input_ids=torch.tensor([123], dtype=torch.long),
@@ -170,7 +241,6 @@ def test_decode_compacts_long_trailing_text_after_large_offset():
text=["hello"],
task_type=["CustomVoice"],
hidden_states={"trailing_text": trailing_text, "last": last_hidden},
embed={"tts_pad": tts_pad},
meta={"talker_text_offset": 64},
_omni_is_prefill=False,
_omni_num_computed_tokens=2,
@@ -183,7 +253,8 @@ def test_decode_compacts_long_trailing_text_after_large_offset():
def test_decode_batch_preprocess_matches_decode_state_updates():
model = _make_minimal_talker()
tts_pad = torch.full((1, 4), -1.0, dtype=torch.bfloat16)
model = _make_minimal_talker(tts_pad_embed=tts_pad)
def fake_embed_input_ids(input_ids):
return input_ids.to(torch.float32).reshape(-1, 1, 1).expand(-1, 1, 4)
@@ -193,7 +264,6 @@ def test_decode_batch_preprocess_matches_decode_state_updates():
trailing_b = torch.arange(8, dtype=torch.float32).reshape(2, 4) + 100
last_a = torch.full((4,), 2.0, dtype=torch.float32)
last_b = torch.full((4,), 3.0, dtype=torch.float32)
tts_pad = torch.full((1, 4), -1.0, dtype=torch.float32)
out_ids, out_embeds, past_hidden, text_step, updates = model.preprocess_decode_batch(
input_ids=torch.tensor([101, 202], dtype=torch.long),
@@ -202,14 +272,12 @@ def test_decode_batch_preprocess_matches_decode_state_updates():
"text": ["hello"],
"task_type": ["Base"],
"hidden_states": {"trailing_text": trailing_a, "last": last_a},
"embed": {"tts_pad": tts_pad},
"meta": {"talker_text_offset": 1},
},
{
"text": ["world"],
"task_type": ["CustomVoice"],
"hidden_states": {"trailing_text": trailing_b, "last": last_b},
"embed": {"tts_pad": tts_pad},
"meta": {"talker_text_offset": 2},
},
],
@@ -228,53 +296,49 @@ def test_decode_batch_preprocess_matches_decode_state_updates():
assert updates[1]["hidden_states"]["trailing_text"].numel() == 0
def _stub_text_embedding(device_param: torch.nn.Parameter):
"""Build a lambda that emulates ``nn.Embedding`` for the ``_device()`` helper.
``Qwen3TTSPromptEmbedsBuilder._device()`` calls
``next(self._text_embedding.parameters()).device``, so the stub needs a
``parameters()`` attribute *and* must be callable as an embedding.
"""
fn = lambda ids: torch.ones((*ids.shape, 4), device=ids.device) # noqa: E731
fn.parameters = lambda: iter([device_param]) # type: ignore[attr-defined]
return fn
def test_base_voice_clone_normalizes_ref_audio_once_for_ref_code_and_speaker():
model = _make_minimal_talker()
builder = _make_minimal_builder()
device_param = torch.nn.Parameter(torch.empty(0))
model.parameters = lambda: iter([device_param])
model.config = SimpleNamespace(
tts_bos_token_id=100,
tts_eos_token_id=101,
tts_pad_token_id=102,
)
model.talker_config = SimpleNamespace(
codec_nothink_id=10,
codec_think_bos_id=11,
codec_think_eos_id=12,
codec_think_id=13,
codec_language_id={},
codec_pad_id=7,
codec_bos_id=8,
num_code_groups=2,
spk_is_dialect={},
)
builder._text_embedding = _stub_text_embedding(device_param)
builder._text_projection = lambda embeds: embeds
builder._codec_embed = lambda ids: torch.zeros((*ids.shape, 4), device=ids.device)
class FakeTokenizer:
def __call__(self, *_args, **_kwargs):
return {"input_ids": torch.arange(8, dtype=torch.long).reshape(1, -1)}
model._get_tokenizer = lambda: FakeTokenizer()
model.text_embedding = lambda ids: torch.ones((*ids.shape, 4), device=ids.device)
model.text_projection = lambda embeds: embeds
model.embed_input_ids = lambda ids: torch.zeros((*ids.shape, 4), device=ids.device)
model._generate_icl_prompt = lambda **kwargs: (
builder._text_tokenizer = FakeTokenizer()
builder._generate_icl_prompt = lambda **kwargs: (
torch.ones((1, 2, 4), device=kwargs["ref_code"].device),
torch.ones((1, 4), device=kwargs["ref_code"].device),
)
normalize_calls = []
ref_audio = np.arange(1024, dtype=np.float32)
model._normalize_ref_audio = lambda raw: normalize_calls.append(raw) or (ref_audio, 16000)
builder.normalize_ref_audio = lambda raw: normalize_calls.append(raw) or (ref_audio, 16000)
ref_audio_ids = []
model._encode_ref_audio_batch = lambda wavs, sr, *, device: (
builder._encode_ref_audio_batch_fn = lambda wavs, sr, *, device: (
[ref_audio_ids.append(id(w)) or torch.ones((2, 2), dtype=torch.long) for w in wavs]
)
model._extract_speaker_embedding = lambda wav, _sr: (
builder.extract_speaker_embedding = lambda wav, _sr: (
ref_audio_ids.append(id(wav)) or torch.ones(4, dtype=torch.bfloat16)
)
_prompt, _trailing, _pad, ref_code_len, ref_code = model._build_prompt_embeds(
_prompt, _trailing, ref_code_len, ref_code = builder.build_prompt_embeds(
task_type="Base",
info_dict={
"text": ["hello"],
@@ -291,11 +355,11 @@ def test_base_voice_clone_normalizes_ref_audio_once_for_ref_code_and_speaker():
def test_base_voice_clone_batch_preprocess_encodes_ref_code_by_sample_rate():
model = _make_minimal_talker()
builder = _make_minimal_builder()
wav1 = np.arange(2048, dtype=np.float32)
wav2 = np.arange(3072, dtype=np.float32)
normalize_calls = []
model._normalize_ref_audio = lambda raw: (
builder.normalize_ref_audio = lambda raw: (
normalize_calls.append(raw)
or (
wav1 if raw == "a.wav" else wav2,
@@ -312,7 +376,7 @@ def test_base_voice_clone_batch_preprocess_encodes_ref_code_by_sample_rate():
torch.full((3, 2), 22, dtype=torch.long),
]
model._encode_ref_audio_batch = _fake_encode_batch
builder._encode_ref_audio_batch_fn = _fake_encode_batch
class FakeTextTokenizer:
def __init__(self):
@@ -323,7 +387,7 @@ def test_base_voice_clone_batch_preprocess_encodes_ref_code_by_sample_rate():
return {"input_ids": [[idx + 1, idx + 2, idx + 3] for idx, _ in enumerate(texts)]}
text_tok = FakeTextTokenizer()
model._get_tokenizer = lambda: text_tok
builder._text_tokenizer = text_tok
buf = {
"r1": {
"task_type": ["Base"],
@@ -341,7 +405,7 @@ def test_base_voice_clone_batch_preprocess_encodes_ref_code_by_sample_rate():
},
}
model.preprocess_batch(
builder.preprocess_batch(
req_ids=["r1", "r2"],
model_intermediate_buffer=buf,
device=torch.device("cpu"),
@@ -353,30 +417,30 @@ def test_base_voice_clone_batch_preprocess_encodes_ref_code_by_sample_rate():
assert wavs[0] is wav1
assert wavs[1] is wav2
assert sr == 16000
assert torch.equal(buf["r1"]["codes"][_PRECOMPUTED_REF_CODE_KEY], torch.full((2, 2), 11))
assert torch.equal(buf["r2"]["codes"][_PRECOMPUTED_REF_CODE_KEY], torch.full((3, 2), 22))
assert buf["r1"][_NORMALIZED_REF_AUDIO_KEY][0] is wav1
assert buf["r2"][_NORMALIZED_REF_AUDIO_KEY][0] is wav2
assert torch.equal(buf["r1"]["codes"][PRECOMPUTED_REF_CODE_KEY], torch.full((2, 2), 11))
assert torch.equal(buf["r2"]["codes"][PRECOMPUTED_REF_CODE_KEY], torch.full((3, 2), 22))
assert buf["r1"][NORMALIZED_REF_AUDIO_KEY][0] is wav1
assert buf["r2"][NORMALIZED_REF_AUDIO_KEY][0] is wav2
assert len(text_tok.calls) == 2
assert torch.equal(buf["r1"][_PRECOMPUTED_TEXT_IDS_KEY], torch.tensor([1, 2, 3]))
assert torch.equal(buf["r2"][_PRECOMPUTED_TEXT_IDS_KEY], torch.tensor([2, 3, 4]))
assert torch.equal(buf["r1"][_PRECOMPUTED_REF_IDS_KEY], torch.tensor([1, 2, 3]))
assert torch.equal(buf["r2"][_PRECOMPUTED_REF_IDS_KEY], torch.tensor([2, 3, 4]))
assert torch.equal(buf["r1"][PRECOMPUTED_TEXT_IDS_KEY], torch.tensor([1, 2, 3]))
assert torch.equal(buf["r2"][PRECOMPUTED_TEXT_IDS_KEY], torch.tensor([2, 3, 4]))
assert torch.equal(buf["r1"][PRECOMPUTED_REF_IDS_KEY], torch.tensor([1, 2, 3]))
assert torch.equal(buf["r2"][PRECOMPUTED_REF_IDS_KEY], torch.tensor([2, 3, 4]))
def test_base_voice_clone_batch_preprocess_reuses_singleton_normalized_audio_without_speech_tokenizer():
model = _make_minimal_talker()
builder = _make_minimal_builder()
wav = np.arange(2048, dtype=np.float32)
model._normalize_ref_audio = lambda raw: (wav, 16000)
model._ensure_speech_tokenizer_loaded = lambda: (_ for _ in ()).throw(
AssertionError("singleton should not load speech tokenizer")
builder.normalize_ref_audio = lambda raw: (wav, 16000)
builder._encode_ref_audio_batch_fn = lambda wavs, sr, *, device: (_ for _ in ()).throw(
AssertionError("singleton should not call encode_ref_audio_batch")
)
class FakeTextTokenizer:
def __call__(self, texts, *, padding=False):
return {"input_ids": [[7, 8, 9] for _ in texts]}
model._get_tokenizer = lambda: FakeTextTokenizer()
builder._text_tokenizer = FakeTextTokenizer()
buf = {
"r1": {
"task_type": ["Base"],
@@ -387,22 +451,27 @@ def test_base_voice_clone_batch_preprocess_reuses_singleton_normalized_audio_wit
}
}
model.preprocess_batch(
builder.preprocess_batch(
req_ids=["r1"],
model_intermediate_buffer=buf,
device=torch.device("cpu"),
)
assert buf["r1"][_NORMALIZED_REF_AUDIO_KEY][0] is wav
assert torch.equal(buf["r1"][_PRECOMPUTED_TEXT_IDS_KEY], torch.tensor([7, 8, 9]))
assert torch.equal(buf["r1"][_PRECOMPUTED_REF_IDS_KEY], torch.tensor([7, 8, 9]))
assert buf["r1"][NORMALIZED_REF_AUDIO_KEY][0] is wav
assert torch.equal(buf["r1"][PRECOMPUTED_TEXT_IDS_KEY], torch.tensor([7, 8, 9]))
assert torch.equal(buf["r1"][PRECOMPUTED_REF_IDS_KEY], torch.tensor([7, 8, 9]))
def test_base_voice_clone_batch_preprocess_skips_after_initial_prefill_state_exists():
model = _make_minimal_talker()
model._normalize_ref_audio = lambda _raw: (_ for _ in ()).throw(AssertionError("normalize not expected"))
model._get_tokenizer = lambda: (_ for _ in ()).throw(AssertionError("tokenizer not expected"))
model._ensure_speech_tokenizer_loaded = lambda: (_ for _ in ()).throw(
builder = _make_minimal_builder()
builder.normalize_ref_audio = lambda _raw: (_ for _ in ()).throw(AssertionError("normalize not expected"))
class _RaisingTokenizer:
def __call__(self, *_args, **_kwargs):
raise AssertionError("tokenizer not expected")
builder._text_tokenizer = _RaisingTokenizer()
builder._encode_ref_audio_batch_fn = lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("speech tokenizer not expected")
)
buf = {
@@ -416,73 +485,200 @@ def test_base_voice_clone_batch_preprocess_skips_after_initial_prefill_state_exi
}
}
model.preprocess_batch(
builder.preprocess_batch(
req_ids=["r1"],
model_intermediate_buffer=buf,
device=torch.device("cpu"),
)
assert _PRECOMPUTED_TEXT_IDS_KEY not in buf["r1"]
assert _NORMALIZED_REF_AUDIO_KEY not in buf["r1"]
assert PRECOMPUTED_TEXT_IDS_KEY not in buf["r1"]
assert NORMALIZED_REF_AUDIO_KEY not in buf["r1"]
def test_base_voice_clone_batch_preprocess_uses_serving_artifact_cache_key_without_normalize():
builder = _make_minimal_builder()
ref_code = torch.full((2, 2), 5, dtype=torch.long)
builder.put_ref_audio_artifacts("same-ref", ref_code=ref_code)
builder.normalize_ref_audio = lambda _raw: (_ for _ in ()).throw(AssertionError("normalize not expected"))
builder._encode_ref_audio_batch_fn = lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("speech tokenizer not expected")
)
class FakeTextTokenizer:
def __call__(self, texts, *, padding=False):
return {"input_ids": [[7, 8, 9] for _ in texts]}
builder._text_tokenizer = FakeTextTokenizer()
buf = {
"r1": {
"task_type": ["Base"],
"text": ["one"],
"ref_audio": ["a.wav"],
"ref_text": ["hello"],
"x_vector_only_mode": [False],
REF_AUDIO_CACHE_KEY: ["same-ref"],
}
}
builder.preprocess_batch(
req_ids=["r1"],
model_intermediate_buffer=buf,
device=torch.device("cpu"),
)
assert torch.equal(buf["r1"]["codes"][PRECOMPUTED_REF_CODE_KEY], ref_code)
assert NORMALIZED_REF_AUDIO_KEY not in buf["r1"]
def test_base_voice_clone_uses_batched_ref_code_without_serial_encode():
model = _make_minimal_talker()
builder = _make_minimal_builder()
device_param = torch.nn.Parameter(torch.empty(0))
model.parameters = lambda: iter([device_param])
model.config = SimpleNamespace(
tts_bos_token_id=100,
tts_eos_token_id=101,
tts_pad_token_id=102,
)
model.talker_config = SimpleNamespace(
codec_nothink_id=10,
codec_think_bos_id=11,
codec_think_eos_id=12,
codec_think_id=13,
codec_language_id={},
codec_pad_id=7,
codec_bos_id=8,
num_code_groups=2,
spk_is_dialect={},
)
builder._text_embedding = _stub_text_embedding(device_param)
builder._text_projection = lambda embeds: embeds
builder._codec_embed = lambda ids: torch.zeros((*ids.shape, 4), device=ids.device)
class FakeTokenizer:
def __call__(self, *_args, **_kwargs):
return {"input_ids": torch.arange(8, dtype=torch.long).reshape(1, -1)}
model._get_tokenizer = lambda: FakeTokenizer()
model.text_embedding = lambda ids: torch.ones((*ids.shape, 4), device=ids.device)
model.text_projection = lambda embeds: embeds
model.embed_input_ids = lambda ids: torch.zeros((*ids.shape, 4), device=ids.device)
model._generate_icl_prompt = lambda **kwargs: (
builder._text_tokenizer = FakeTokenizer()
builder._generate_icl_prompt = lambda **kwargs: (
torch.ones((1, 2, 4), device=kwargs["ref_code"].device),
torch.ones((1, 4), device=kwargs["ref_code"].device),
)
ref_audio = np.arange(2048, dtype=np.float32)
ref_code = torch.arange(4, dtype=torch.long).reshape(2, 2)
model._normalize_ref_audio = lambda _raw: (_ for _ in ()).throw(AssertionError("serial normalize not expected"))
model._encode_ref_audio_to_code = lambda _wav, _sr: (_ for _ in ()).throw(
builder.normalize_ref_audio = lambda _raw: (_ for _ in ()).throw(AssertionError("serial normalize not expected"))
builder.encode_ref_audio_to_code = lambda _wav, _sr: (_ for _ in ()).throw(
AssertionError("serial encode not expected")
)
speaker_wav_ids = []
model._extract_speaker_embedding = lambda wav, _sr: (
builder.extract_speaker_embedding = lambda wav, _sr: (
speaker_wav_ids.append(id(wav)) or torch.ones(4, dtype=torch.bfloat16)
)
_prompt, _trailing, _pad, ref_code_len, out_ref_code = model._build_prompt_embeds(
_prompt, _trailing, ref_code_len, out_ref_code = builder.build_prompt_embeds(
task_type="Base",
info_dict={
"text": ["hello"],
"ref_audio": ["ref.wav"],
"ref_ids": torch.arange(8, dtype=torch.long).reshape(1, -1),
"non_streaming_mode": [False],
"codes": {_PRECOMPUTED_REF_CODE_KEY: ref_code},
_NORMALIZED_REF_AUDIO_KEY: (ref_audio, 16000),
"codes": {PRECOMPUTED_REF_CODE_KEY: ref_code},
NORMALIZED_REF_AUDIO_KEY: (ref_audio, 16000),
},
)
assert speaker_wav_ids == [id(ref_audio)]
assert ref_code_len == 2
assert torch.equal(out_ref_code, ref_code)
def test_voice_clone_prompt_artifacts_do_not_resolve_ref_audio_for_cache_fill():
builder = _make_minimal_builder()
device_param = torch.nn.Parameter(torch.empty(0))
builder._text_embedding = _stub_text_embedding(device_param)
builder._text_projection = lambda embeds: embeds
builder._codec_embed = lambda ids: torch.zeros((*ids.shape, 4), device=ids.device)
class FakeTokenizer:
def __call__(self, *_args, **_kwargs):
return {"input_ids": torch.arange(8, dtype=torch.long).reshape(1, -1)}
builder._text_tokenizer = FakeTokenizer()
builder._generate_icl_prompt = lambda **kwargs: (
torch.ones((1, 2, 4), device=kwargs["ref_code"].device),
torch.ones((1, 4), device=kwargs["ref_code"].device),
)
builder.normalize_ref_audio = lambda _raw: (_ for _ in ()).throw(AssertionError("normalize not expected"))
builder.encode_ref_audio_to_code = lambda _wav, _sr: (_ for _ in ()).throw(AssertionError("encode not expected"))
builder.extract_speaker_embedding = lambda _wav, _sr: (_ for _ in ()).throw(
AssertionError("speaker embedding not expected")
)
ref_code = torch.arange(4, dtype=torch.long).reshape(2, 2)
_prompt, _trailing, ref_code_len, out_ref_code = builder.build_prompt_embeds(
task_type="Base",
info_dict={
"text": ["hello"],
"ref_audio": ["ref.wav"],
"ref_ids": torch.arange(8, dtype=torch.long).reshape(1, -1),
"non_streaming_mode": [False],
"voice_clone_prompt": {
"ref_code": ref_code,
"ref_spk_embedding": torch.ones(4, dtype=torch.bfloat16),
"icl_mode": True,
},
},
)
assert ref_code_len == 2
assert torch.equal(out_ref_code, ref_code)
def test_ref_audio_artifact_only_uses_cache_without_ref_audio_payload():
builder = _make_minimal_builder()
device_param = torch.nn.Parameter(torch.empty(0))
builder._text_embedding = _stub_text_embedding(device_param)
builder._text_projection = lambda embeds: embeds
builder._codec_embed = lambda ids: torch.zeros((*ids.shape, 4), device=ids.device)
class FakeTokenizer:
def __call__(self, *_args, **_kwargs):
return {"input_ids": torch.arange(8, dtype=torch.long).reshape(1, -1)}
builder._text_tokenizer = FakeTokenizer()
builder._generate_icl_prompt = lambda **kwargs: (
torch.ones((1, 2, 4), device=kwargs["ref_code"].device),
torch.ones((1, 4), device=kwargs["ref_code"].device),
)
builder.normalize_ref_audio = lambda _raw: (_ for _ in ()).throw(AssertionError("normalize not expected"))
builder.encode_ref_audio_to_code = lambda _wav, _sr: (_ for _ in ()).throw(AssertionError("encode not expected"))
builder.extract_speaker_embedding = lambda _wav, _sr: (_ for _ in ()).throw(
AssertionError("speaker embedding not expected")
)
ref_code = torch.arange(4, dtype=torch.long).reshape(2, 2)
builder.put_ref_audio_artifacts(
"same-ref",
ref_code=ref_code,
ref_spk_embedding=torch.ones(4, dtype=torch.bfloat16),
)
_prompt, _trailing, ref_code_len, out_ref_code = builder.build_prompt_embeds(
task_type="Base",
info_dict={
"text": ["hello"],
"ref_ids": torch.arange(8, dtype=torch.long).reshape(1, -1),
"non_streaming_mode": [False],
REF_AUDIO_CACHE_KEY: ["same-ref"],
},
)
assert ref_code_len == 2
assert torch.equal(out_ref_code, ref_code)
def test_ref_audio_artifact_only_cache_miss_fails_fast():
builder = _make_minimal_builder()
device_param = torch.nn.Parameter(torch.empty(0))
builder._text_embedding = _stub_text_embedding(device_param)
builder._text_projection = lambda embeds: embeds
builder._codec_embed = lambda ids: torch.zeros((*ids.shape, 4), device=ids.device)
class FakeTokenizer:
def __call__(self, *_args, **_kwargs):
return {"input_ids": torch.arange(8, dtype=torch.long).reshape(1, -1)}
builder._text_tokenizer = FakeTokenizer()
with pytest.raises(RuntimeError, match="artifact cache miss"):
builder.build_prompt_embeds(
task_type="Base",
info_dict={
"text": ["hello"],
"ref_ids": torch.arange(8, dtype=torch.long).reshape(1, -1),
"non_streaming_mode": [False],
REF_AUDIO_CACHE_KEY: ["missing-ref"],
},
)
@@ -0,0 +1,150 @@
"""Regression tests for `estimate_prompt_len_from_additional_information`.
Pins the 2D `voice_clone_prompt.ref_code` shape behaviour. Applying the
singleton-batch unwrapper `_first(...)` to that value strips its outer
dimension and reports `len(ref_code) == num_codebooks` instead of
`num_frames`, which silently truncates `inputs_embeds` downstream in
`_build_prompt_embeds`.
"""
import pytest
from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import (
Qwen3TTSPromptEmbedsBuilder,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _fake_tokenize(text: str, **_kwargs):
return [0] * (8 + max(1, len(text.split())))
def test_estimate_prompt_len_uses_full_ref_code_length() -> None:
num_frames = 318
num_codebooks = 8
info = {
"task_type": ["Base"],
"text": ["hello"],
"ref_text": ["world"],
"voice_clone_prompt": [
{
"ref_spk_embedding": [0.0] * 512,
"ref_code": [[0] * num_codebooks for _ in range(num_frames)],
"icl_mode": True,
}
],
"non_streaming_mode": [True],
"language": ["English"],
}
est = Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
additional_information=info,
task_type="Base",
tokenize_prompt=_fake_tokenize,
codec_language_id={"english": 0},
spk_is_dialect=None,
)
# codec_lens = 1 + num_frames = 319; plus text-side and codec-prefix
# terms ~20. Would be ~30 if `_first` collapses ref_code to its first row.
assert est > 100, f"got {est}; expected ~339. Did `_first(ref_code)` collapse the 2D list again?"
def test_estimate_prompt_len_handles_1d_ref_code() -> None:
num_frames = 50
info = {
"task_type": ["Base"],
"text": ["hello"],
"ref_text": ["world"],
"voice_clone_prompt": [
{
"ref_spk_embedding": [0.0] * 512,
"ref_code": list(range(num_frames)),
"icl_mode": True,
}
],
"non_streaming_mode": [True],
"language": ["English"],
}
est = Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
additional_information=info,
task_type="Base",
tokenize_prompt=_fake_tokenize,
codec_language_id={"english": 0},
spk_is_dialect=None,
)
assert est > 50, f"got {est}; 1D ref_code must contribute its own length"
def test_estimate_prompt_len_uses_ref_code_length_without_ref_audio() -> None:
est = Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
additional_information={
"task_type": ["Base"],
"text": ["hello"],
"ref_text": ["reference transcript"],
"x_vector_only_mode": [False],
"ref_code_length": [6],
"_qwen3_tts_ref_audio_cache_key": ["same-ref"],
"language": ["English"],
},
task_type="Base",
tokenize_prompt=lambda _text: list(range(10)),
codec_language_id=None,
spk_is_dialect=None,
estimate_ref_code_len=lambda _ref_audio: None,
)
assert est == 15
def test_estimate_prompt_len_uses_list_of_int_ref_ids_from_voice_clone_prompt() -> None:
"""`voice_clone_prompt.ref_ids` as a list-of-int (the natural pre-tokenized shape)
must be read as a sequence, not unwrapped to its first element. Without the fix
`_first` collapsed it to an int, the `isinstance(list)` / `hasattr("shape")`
branches fell through, and `ref_ids_len` defaulted to 0 — silently dropping the
reference-token contribution to the prefix length budget.
"""
def make_info(num_ref_ids: int) -> dict:
return {
"task_type": ["Base"],
"text": ["hello"],
"ref_text": ["world"],
"voice_clone_prompt": [
{
"ref_spk_embedding": [0.0] * 512,
"ref_code": [[0] * 8 for _ in range(200)],
"ref_ids": list(range(num_ref_ids)),
"icl_mode": True,
}
],
"non_streaming_mode": [True],
"language": ["English"],
}
est_30 = Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
additional_information=make_info(30),
task_type="Base",
tokenize_prompt=_fake_tokenize,
codec_language_id={"english": 0},
spk_is_dialect=None,
)
est_50 = Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
additional_information=make_info(50),
task_type="Base",
tokenize_prompt=_fake_tokenize,
codec_language_id={"english": 0},
spk_is_dialect=None,
)
# Each additional ref_id contributes exactly one token to the prefix budget
# (the estimator strips a fixed 5-token tail from ref_ids regardless of length).
# Under the previous bug `_first` collapsed both lists to a single int, so both
# estimates would have been identical (delta = 0).
assert est_50 - est_30 == 20, (
f"got delta={est_50 - est_30}; expected 20 (= 50 - 30 extra ref_ids). "
f"Did `_first(ref_ids)` collapse the list to its first int?"
)
@@ -20,7 +20,7 @@ def _streaming_context() -> SimpleNamespace:
def test_get_streaming_talker_tokens_first_segment(_streaming_context: SimpleNamespace) -> None:
inc_p, inc_o, merged, thinker_in = q3._get_streaming_talker_tokens(
inc_p, inc_o = q3._get_streaming_talker_tokens(
"r1",
[1, 2],
[10, 11],
@@ -28,13 +28,11 @@ def test_get_streaming_talker_tokens_first_segment(_streaming_context: SimpleNam
)
assert inc_p == [1, 2]
assert inc_o == [10, 11]
assert merged == [1, 2, 10, 11]
assert thinker_in == [1, 2]
def test_get_streaming_talker_tokens_second_segment_accumulates(_streaming_context: SimpleNamespace) -> None:
q3._get_streaming_talker_tokens("r2", [1, 2], [10, 11], streaming_context=_streaming_context)
inc_p, inc_o, merged, thinker_in = q3._get_streaming_talker_tokens(
inc_p, inc_o = q3._get_streaming_talker_tokens(
"r2",
[1, 2, 3, 4],
[10, 11, 12, 13],
@@ -42,14 +40,12 @@ def test_get_streaming_talker_tokens_second_segment_accumulates(_streaming_conte
)
assert inc_p == [3, 4]
assert inc_o == [12, 13]
assert merged == [1, 2, 10, 3, 4, 12, 13]
assert thinker_in == [1, 2, 10, 3, 4]
def test_get_streaming_talker_tokens_new_prompt_len_snapshot_truncates(
_streaming_context: SimpleNamespace,
) -> None:
inc_p, inc_o, merged, thinker_in = q3._get_streaming_talker_tokens(
inc_p, inc_o = q3._get_streaming_talker_tokens(
"r3",
[1, 2, 3, 4, 5, 6],
[10],
@@ -58,8 +54,6 @@ def test_get_streaming_talker_tokens_new_prompt_len_snapshot_truncates(
)
assert inc_p == [1, 2, 3, 4]
assert inc_o == [10]
assert merged == [1, 2, 3, 4, 10]
assert thinker_in == [1, 2, 3, 4]
def test_get_streaming_talker_tokens_clear_state(_streaming_context: SimpleNamespace) -> None:
@@ -82,6 +76,72 @@ def test_get_streaming_codec_delta_len_increments_and_finishes(_streaming_contex
assert state.talker2code2wav_last_seq_len == 0
def test_streaming_input_prefill_chunk_is_cached() -> None:
transfer_manager = SimpleNamespace(_pending_streaming_prefills={})
request = SimpleNamespace(
external_req_id="rt-1",
output_token_ids=[100],
all_token_ids=[151644, 872, 100],
prompt_token_ids=[151644, 872],
resumable=True,
additional_information=None,
)
thinker_emb = torch.ones(2, 3)
thinker_hid = torch.full((2, 3), 2.0)
payload = q3._construct_thinker2talker_streaming_input_async_chunk(
False,
request,
thinker_emb,
thinker_hid,
transfer_manager,
)
assert payload is None
cached = transfer_manager._pending_streaming_prefills["rt-1"]
assert torch.equal(cached["embed"]["prefill"], thinker_emb)
assert torch.equal(cached["hidden_states"]["output"], thinker_hid)
assert cached["ids"]["all"] == request.all_token_ids
assert cached["ids"]["prompt"] == request.prompt_token_ids
def test_streaming_input_prefill_flushes_with_next_decode_chunk() -> None:
transfer_manager = SimpleNamespace(
_pending_streaming_prefills={
"rt-2": {
"embed": {"prefill": torch.ones(2, 3)},
"hidden_states": {"output": torch.full((2, 3), 2.0)},
"ids": {"all": [151644, 872, 100], "prompt": [151644, 872]},
}
}
)
request = SimpleNamespace(
external_req_id="rt-2",
output_token_ids=[101],
all_token_ids=[151644, 872, 100, 101],
prompt_token_ids=[151644, 872],
resumable=True,
additional_information=None,
)
thinker_emb = torch.full((1, 3), 3.0)
thinker_hid = torch.full((1, 3), 4.0)
payload = q3._construct_thinker2talker_streaming_input_async_chunk(
False,
request,
thinker_emb,
thinker_hid,
transfer_manager,
)
assert payload is not None
assert payload.embed.prefill.shape == (3, 3)
assert payload.hidden_states.output.shape == (3, 3)
assert payload.ids.all == [151644, 872, 100]
assert payload.ids.prompt == [151644, 872]
assert "rt-2" not in transfer_manager._pending_streaming_prefills
def test_talker2code2wav_full_payload_filters_by_output_token_ids() -> None:
request = SimpleNamespace(
request_id="codec",
@@ -269,11 +269,14 @@ def test_first_streaming_chunk_prepends_ref_code_context():
assert payload is not None
assert payload.meta.left_context_size == 2
assert payload.meta.ref_context_size == 2
assert payload.meta.ref_context_request_id == rid
assert payload.meta.ref_context_included is True
assert len(payload.codes.audio) == _Q * 12
def test_ref_code_context_applies_to_all_streaming_chunks():
"""ref_code is prepended as decoder context on every chunk, not just the first."""
def test_followup_ref_code_context_is_sent_as_metadata_handle():
"""Follow-up chunks keep full ref context semantically without resending it."""
tm = _tm()
rid = "r-ref2"
tm.code_prompt_token_ids[rid] = [_FRAME[:] for _ in range(35)]
@@ -289,9 +292,13 @@ def test_ref_code_context_applies_to_all_streaming_chunks():
)
assert payload is not None
# ref_code (2 frames) prepended as left context on second chunk too
# ref_code (2 frames) is represented in metadata so Code2Wav can restore it
# from its request-local cache. It must not be resent in codes.audio.
assert payload.meta.left_context_size == 10 + 2
assert len(payload.codes.audio) == _Q * (35 + 2)
assert payload.meta.ref_context_size == 2
assert payload.meta.ref_context_request_id == rid
assert payload.meta.ref_context_included is False
assert len(payload.codes.audio) == _Q * 35
def test_streaming_ref_code_context_is_bounded_for_batchable_shapes():
+55 -22
View File
@@ -164,7 +164,8 @@ def test_split_mixed_real_world():
# orchestrator-only keys never reach engine
assert "stage_init_timeout" not in engine
assert "deploy_config" not in engine
assert "async_chunk" not in engine
# async_chunk is in SHARED_FIELDS so it flows to both
assert "async_chunk" in engine
# ============================================================================
@@ -172,24 +173,47 @@ def test_split_mixed_real_world():
# ============================================================================
def test_user_typed_unclassified_warns(caplog):
def test_user_typed_unclassified_warns(monkeypatch):
"""If the user types a flag we can't route, warn — don't silently drop."""
raw = {"bogus_flag": "value", "max_num_seqs": 64}
with caplog.at_level(logging.WARNING, logger="vllm_omni.engine.arg_utils"):
import io
buf = io.StringIO()
vllm_logger = logging.getLogger("vllm")
orig_handlers = list(vllm_logger.handlers)
vllm_logger.handlers.clear()
handler = logging.StreamHandler(buf)
handler.setLevel(logging.WARNING)
vllm_logger.addHandler(handler)
try:
raw = {"bogus_flag": "value", "max_num_seqs": 64}
split_kwargs(raw, engine_cls=_FakeEngineArgs, user_typed={"bogus_flag"})
assert any("bogus_flag" in rec.message for rec in caplog.records), (
f"Expected warning mentioning 'bogus_flag', got: {[rec.message for rec in caplog.records]}"
)
assert "bogus_flag" in buf.getvalue(), f"Expected 'bogus_flag' in log output, got: {buf.getvalue()!r}"
finally:
vllm_logger.handlers.clear()
for h in orig_handlers:
vllm_logger.addHandler(h)
def test_unclassified_without_user_typed_silent(caplog):
def test_unclassified_without_user_typed_silent():
"""Without user_typed, unclassified keys drop silently (argparse defaults
for server flags shouldn't spam logs on every launch)."""
raw = {"host": "0.0.0.0", "port": 8091, "max_num_seqs": 64}
with caplog.at_level(logging.WARNING, logger="vllm_omni.engine.arg_utils"):
import io
vllm_logger = logging.getLogger("vllm")
orig_handlers = list(vllm_logger.handlers)
vllm_logger.handlers.clear()
buf = io.StringIO()
handler = logging.StreamHandler(buf)
handler.setLevel(logging.WARNING)
vllm_logger.addHandler(handler)
try:
raw = {"host": "0.0.0.0", "port": 8091, "max_num_seqs": 64}
split_kwargs(raw, engine_cls=_FakeEngineArgs, user_typed=None)
# No warnings because we don't know these were user-typed.
assert not any("host" in rec.message or "port" in rec.message for rec in caplog.records)
assert "host" not in buf.getvalue()
finally:
vllm_logger.handlers.clear()
for h in orig_handlers:
vllm_logger.addHandler(h)
# ============================================================================
@@ -290,7 +314,7 @@ def test_internal_blacklist_keys_derived_from_orchestrator():
# Spot-check expected entries
assert "stage_init_timeout" in blacklist
assert "deploy_config" in blacklist
assert "async_chunk" in blacklist
assert "async_chunk" not in blacklist # now in SHARED_FIELDS
# Shared fields must NOT appear — they flow to both orchestrator and engine
assert "model" not in blacklist
assert "log_stats" not in blacklist
@@ -339,18 +363,31 @@ def test_ambiguous_field_strict_raises():
split_kwargs({"deploy_config": "x"}, engine_cls=_AmbiguousEngine, strict=True)
def test_ambiguous_field_non_strict_routes_to_orchestrator(caplog):
def test_ambiguous_field_non_strict_routes_to_orchestrator():
"""strict=False logs ERROR but routes the ambiguous field to orchestrator."""
@dataclass
class _AmbiguousEngine:
deploy_config: str | None = None
with caplog.at_level(logging.ERROR, logger="vllm_omni.engine.arg_utils"):
import io
vllm_logger = logging.getLogger("vllm")
orig_handlers = list(vllm_logger.handlers)
vllm_logger.handlers.clear()
buf = io.StringIO()
handler = logging.StreamHandler(buf)
handler.setLevel(logging.ERROR)
vllm_logger.addHandler(handler)
try:
orch, engine = split_kwargs({"deploy_config": "x"}, engine_cls=_AmbiguousEngine, strict=False)
assert orch.deploy_config == "x"
assert "deploy_config" not in engine
assert any("both OrchestratorArgs" in r.message for r in caplog.records)
assert orch.deploy_config == "x"
assert "deploy_config" not in engine
assert "both OrchestratorArgs" in buf.getvalue()
finally:
vllm_logger.handlers.clear()
for h in orig_handlers:
vllm_logger.addHandler(h)
# Sentinel-default precedence invariants (#3035)
@@ -426,24 +463,20 @@ def test_help_text_preserves_default_after_nullify():
_OMNIENGINEARGS_USER_INPUT_FIELDS = frozenset(
{
"model_stage",
"model_arch",
"engine_output_type",
"hf_config_name",
"custom_process_next_stage_input_func",
"subtalker_sampling_params",
"async_chunk",
"omni_kv_config",
"quantization_config",
"worker_type",
"task_type",
"worker_cls",
"enable_sleep_mode",
"omni_master_address",
"omni_master_port",
"stage_configs_path",
"output_modalities",
"log_stats",
"custom_pipeline_args",
}
)
+97
View File
@@ -1027,6 +1027,9 @@ class TestDeployConfigLoading:
"async_scheduling",
"compilation_config",
"config_format",
"custom_voice_dir",
"data_parallel_size",
"devices",
"disable_hybrid_kv_cache_manager",
"enable_flashinfer_autotune",
"enforce_eager",
@@ -1068,6 +1071,32 @@ class TestDeployConfigLoading:
f"added={actual_fields - expected_fields}, removed={expected_fields - actual_fields}"
)
def test_custom_voice_dir_is_pipeline_wide_engine_arg(self, tmp_path):
import vllm_omni.model_executor.models.qwen3_tts.pipeline # noqa: F401
from vllm_omni.config.stage_config import load_deploy_config, merge_pipeline_deploy
deploy_path = tmp_path / "qwen3_tts_custom_voice.yaml"
custom_voice_dir = tmp_path / "voices"
deploy_path.write_text(
f"""
async_chunk: true
custom_voice_dir: {custom_voice_dir}
stages:
- stage_id: 0
devices: "0"
- stage_id: 1
devices: "0"
""",
encoding="utf-8",
)
deploy = load_deploy_config(deploy_path)
pipeline = _PIPELINE_REGISTRY["qwen3_tts"]
stages = merge_pipeline_deploy(pipeline, deploy)
assert deploy.custom_voice_dir == str(custom_voice_dir)
assert {s.yaml_engine_args.get("custom_voice_dir") for s in stages} == {str(custom_voice_dir)}
def test_load_qwen3_omni_moe_deploy_config(self):
deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
deploy = load_deploy_config(deploy_path)
@@ -1580,6 +1609,74 @@ class TestMingFlashOmniPipeline:
assert len(stages) == 1
assert stages[0].yaml_engine_args["model_arch"] == "MingFlashOmniForConditionalGeneration"
def test_image_pipeline_registered(self):
p = _PIPELINE_REGISTRY.get("ming_flash_omni_image")
assert p is not None
assert p.model_arch == "MingFlashOmniForConditionalGeneration"
assert len(p.stages) == 2
assert p.validate() == []
def test_image_thinker_stage(self):
s = _PIPELINE_REGISTRY["ming_flash_omni_image"].get_stage(0)
assert s.model_stage == "thinker"
assert s.execution_type == StageExecutionType.LLM_AR
assert s.input_sources == ()
assert s.final_output is False
assert s.owns_tokenizer is True
assert s.requires_multimodal_data is True
# Image variant exports hidden states for the diffusion stage.
assert s.engine_output_type == "latent"
assert s.hf_config_name == "thinker_config"
assert s.sampling_constraints["detokenize"] is False
assert s.prompt_expand_func is not None
def test_image_dit_stage(self):
s = _PIPELINE_REGISTRY["ming_flash_omni_image"].get_stage(1)
assert s.model_stage == "dit"
assert s.execution_type == StageExecutionType.DIFFUSION
assert s.input_sources == (0,)
assert s.final_output is True
assert s.final_output_type == "image"
assert s.hf_config_name == "image_gen_config"
assert s.model_arch == "MingImagePipeline"
assert s.custom_process_input_func is not None
def test_image_processor_wiring_resolves(self):
"""The prompt_expand_func and custom_process_input_func strings must point to real callables."""
thinker = _PIPELINE_REGISTRY["ming_flash_omni_image"].get_stage(0)
dit = _PIPELINE_REGISTRY["ming_flash_omni_image"].get_stage(1)
for ref in (thinker.prompt_expand_func, dit.custom_process_input_func):
module_path, _, attr = ref.rpartition(".")
module = importlib.import_module(module_path)
assert callable(getattr(module, attr))
def test_image_yaml_loads_and_merges(self):
"""deploy/ming_flash_omni_image.yaml parses and routes to the image pipeline."""
deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "ming_flash_omni_image.yaml"
if not deploy_path.exists():
pytest.skip("ming_flash_omni_image deploy yaml not found")
deploy = load_deploy_config(deploy_path)
assert len(deploy.stages) == 2
assert deploy.async_chunk is False
assert deploy.pipeline == "ming_flash_omni_image"
assert deploy.connectors is not None
assert "shared_memory_connector" in deploy.connectors
pipeline = _PIPELINE_REGISTRY["ming_flash_omni_image"]
stages = merge_pipeline_deploy(pipeline, deploy)
assert len(stages) == 2
# Stage 0 thinker: AR worker that emits latents.
assert stages[0].yaml_engine_args["model_arch"] == "MingFlashOmniForConditionalGeneration"
assert stages[0].yaml_engine_args["engine_output_type"] == "latent"
assert stages[0].yaml_extras["default_sampling_params"]["detokenize"] is False
assert stages[0].yaml_extras["prompt_expand_func"] is not None
# Stage 1 dit: diffusion stage with MingImagePipeline.
assert stages[1].yaml_engine_args["model_arch"] == "MingImagePipeline"
assert stages[1].custom_process_input_func is not None
assert stages[1].final_output is True
assert stages[1].final_output_type == "image"
class TestBaseConfigInheritance:
"""Test deploy YAML base_config inheritance."""
+283
View File
@@ -0,0 +1,283 @@
import json
from types import SimpleNamespace
import pytest
import torch
from safetensors.torch import save_file
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _write_manifest(root, *, model_type: str, voices: dict) -> None:
payload = {
"schema_version": 1,
"model_type": model_type,
"voices": voices,
}
(root / "custom_voice_manifest.json").write_text(json.dumps(payload), encoding="utf-8")
def test_qwen3_custom_voice_profiles_warm_speaker_cache(tmp_path):
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
Qwen3TTSTalkerForConditionalGeneration,
)
save_file(
{
"speaker_embedding": torch.arange(4, dtype=torch.float32),
"ref_code": torch.arange(6, dtype=torch.int32).reshape(3, 2),
},
str(tmp_path / "alice.safetensors"),
)
_write_manifest(
tmp_path,
model_type="qwen3_tts",
voices={
"Alice": {
"file": "alice.safetensors",
"mode": "icl",
"ref_text": "reference transcript",
"embedding_dim": 4,
}
},
)
model = Qwen3TTSTalkerForConditionalGeneration.__new__(Qwen3TTSTalkerForConditionalGeneration)
model.config = SimpleNamespace(
custom_voice_dir=str(tmp_path),
speaker_encoder_config=SimpleNamespace(enc_dim=4),
)
model._speaker_cache = SpeakerEmbeddingCache()
model._load_custom_voice_profiles()
key = model._speaker_cache.make_cache_key("alice", model_type="qwen3_tts_icl", created_at=0)
cached = model._speaker_cache.get(key)
assert cached is not None
assert cached["icl_mode"] is True
assert cached["ref_text"] == "reference transcript"
torch.testing.assert_close(cached["ref_spk_embedding"], torch.arange(4, dtype=torch.float32))
torch.testing.assert_close(cached["ref_code"], torch.arange(6, dtype=torch.int32).reshape(3, 2))
def test_qwen3_icl_profile_without_ref_code_is_not_downgraded(tmp_path):
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
Qwen3TTSTalkerForConditionalGeneration,
)
save_file({"speaker_embedding": torch.arange(4, dtype=torch.float32)}, str(tmp_path / "alice.safetensors"))
_write_manifest(
tmp_path,
model_type="qwen3_tts",
voices={
"Alice": {
"file": "alice.safetensors",
"mode": "icl",
"ref_text": "reference transcript",
"embedding_dim": 4,
}
},
)
model = Qwen3TTSTalkerForConditionalGeneration.__new__(Qwen3TTSTalkerForConditionalGeneration)
model.config = SimpleNamespace(
custom_voice_dir=str(tmp_path),
speaker_encoder_config=SimpleNamespace(enc_dim=4),
)
model._speaker_cache = SpeakerEmbeddingCache()
model._load_custom_voice_profiles()
icl_key = model._speaker_cache.make_cache_key("alice", model_type="qwen3_tts_icl", created_at=0)
xvec_key = model._speaker_cache.make_cache_key("alice", model_type="qwen3_tts_xvec", created_at=0)
assert model._speaker_cache.get(icl_key) is None
assert model._speaker_cache.get(xvec_key) is None
def test_qwen3_prompt_len_uses_precomputed_ref_code_length():
from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import (
Qwen3TTSPromptEmbedsBuilder,
)
prompt_len = Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
additional_information={
"text": ["hello"],
"task_type": ["Base"],
"speaker": ["alice"],
"x_vector_only_mode": [False],
"ref_code_length": [6],
"ref_text": ["reference transcript"],
},
task_type="Base",
tokenize_prompt=lambda _text: list(range(10)),
codec_language_id=None,
spk_is_dialect=None,
estimate_ref_code_len=lambda _ref_audio: None,
)
assert prompt_len == 15
def test_custom_voice_profile_file_must_stay_under_manifest_dir(tmp_path):
from vllm_omni.utils.speaker_cache import iter_custom_voice_profiles
_write_manifest(
tmp_path,
model_type="qwen3_tts",
voices={"Alice": {"file": "../alice.safetensors", "mode": "xvec"}},
)
assert iter_custom_voice_profiles(tmp_path, expected_model_type="qwen3_tts") == []
def test_custom_voice_profiles_drop_stale_provenance_metadata(tmp_path):
from vllm_omni.utils.speaker_cache import iter_custom_voice_profiles
save_file({"speaker_embedding": torch.arange(4, dtype=torch.float32)}, str(tmp_path / "alice.safetensors"))
_write_manifest(
tmp_path,
model_type="qwen3_tts",
voices={
"Alice": {
"file": "alice.safetensors",
"mode": "xvec",
"audio_codec_config_hash": "old-hash",
"audio_codec_version": "old-codec",
"model_revision": "old-revision",
}
},
)
profiles = iter_custom_voice_profiles(tmp_path, expected_model_type="qwen3_tts")
assert len(profiles) == 1
assert "audio_codec_config_hash" not in profiles[0]
assert "audio_codec_version" not in profiles[0]
assert "model_revision" not in profiles[0]
def test_voxcpm2_custom_voice_profiles_warm_full_prompt_cache(tmp_path):
from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import (
VoxCPM2TalkerForConditionalGeneration,
)
save_file(
{
"ref_audio_feat": torch.ones(2, 3, 4, dtype=torch.float32),
"audio_feat": torch.full((5, 3, 4), 2.0, dtype=torch.float32),
},
str(tmp_path / "bob.safetensors"),
)
_write_manifest(
tmp_path,
model_type="voxcpm2",
voices={
"Bob": {
"file": "bob.safetensors",
"mode": "ref_continuation",
"prompt_text": "prompt transcript",
"ref_audio_feat_len": 2,
"audio_feat_len": 5,
}
},
)
model = VoxCPM2TalkerForConditionalGeneration.__new__(VoxCPM2TalkerForConditionalGeneration)
model.config = SimpleNamespace(custom_voice_dir=str(tmp_path))
model._speaker_cache = SpeakerEmbeddingCache()
model._load_custom_voice_profiles()
key = model._speaker_cache.make_cache_key("bob", model_type="voxcpm2", created_at=0)
cached = model._speaker_cache.get(key)
assert cached is not None
assert cached["mode"] == "ref_continuation"
assert cached["prompt_text"] == "prompt transcript"
torch.testing.assert_close(cached["ref_audio_feat"], torch.ones(2, 3, 4))
torch.testing.assert_close(cached["audio_feat"], torch.full((5, 3, 4), 2.0))
def test_voxcpm2_custom_voice_profiles_skip_unloadable_manifest_entry(tmp_path):
from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import (
VoxCPM2TalkerForConditionalGeneration,
)
_write_manifest(
tmp_path,
model_type="voxcpm2",
voices={
"Bob": {
"file": "missing.safetensors",
"mode": "reference",
"ref_audio_feat_len": 2,
}
},
)
model = VoxCPM2TalkerForConditionalGeneration.__new__(VoxCPM2TalkerForConditionalGeneration)
model.config = SimpleNamespace(custom_voice_dir=str(tmp_path))
model._speaker_cache = SpeakerEmbeddingCache()
model._load_custom_voice_profiles()
key = model._speaker_cache.make_cache_key("bob", model_type="voxcpm2", created_at=0)
assert model._speaker_cache.get(key) is None
def test_voxcpm2_precomputed_voice_cache_miss_raises_before_zero_shot():
from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import (
VoxCPM2TalkerForConditionalGeneration,
)
model = VoxCPM2TalkerForConditionalGeneration.__new__(VoxCPM2TalkerForConditionalGeneration)
model.config = SimpleNamespace(bos_token_id=1)
model._speaker_cache = SpeakerEmbeddingCache()
model._get_multichar_zh_split = lambda: {}
model._get_or_create_state = lambda _req_id: SimpleNamespace()
with pytest.raises(ValueError, match="not loaded in the model cache"):
model.preprocess(
torch.tensor([1, 2], dtype=torch.long),
None,
request_id="req-1",
text_token_ids=[[2]],
voice_name=["bob"],
voice_created_at=0,
voice_profile={"mode": "reference"},
)
def test_voxcpm2_prompt_can_account_for_precomputed_profile_lengths():
from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import build_voxcpm2_prompt
class _Tokenizer:
bos_token_id = 1
unk_token_id = 0
def get_vocab(self):
return {}
def encode(self, text, add_special_tokens=True):
ids = [10 + i for i, _ in enumerate(text.split())]
return [self.bos_token_id, *ids] if add_special_tokens else ids
prompt = build_voxcpm2_prompt(
hf_config=SimpleNamespace(audio_vae_config={"sample_rate": 48000, "encoder_rates": [2]}, patch_size=3),
tokenizer=_Tokenizer(),
split_map={},
text="hello world",
voice_profile={
"mode": "ref_continuation",
"ref_audio_feat_len": 2,
"audio_feat_len": 5,
"prompt_text": "prompt words",
},
)
# text(2) + audio_start(1) + ref prefix(2 + ref_len) + prompt_text(2) + prompt_audio_len(5)
assert len(prompt["prompt_token_ids"]) == 14
additional = prompt["additional_information"]
assert additional["voice_profile"]["mode"] == "ref_continuation"
assert additional["prompt_text"] == ["prompt words"]
+8 -8
View File
@@ -161,11 +161,11 @@ def _make_runner_for_mimo(req_id="r_mimo"):
def test_talker_mtp_forward_cpu_updates_inputs_and_info(monkeypatch):
# Patch the module-level `set_forward_context` symbol used inside
# OmniGPUModelRunner._talker_mtp_forward.
# `_talker_mtp_forward` calls `current_omni_platform.set_forward_context`,
# which would otherwise dispatch to the real device implementation.
import vllm_omni.worker.gpu_model_runner as mod # Must be the same module that defines OmniGPUModelRunner
monkeypatch.setattr(mod, "set_forward_context", _noop_forward_context)
monkeypatch.setattr(mod.current_omni_platform, "set_forward_context", _noop_forward_context)
runner = _make_runner(req_ids=("r1", "r2"), hidden_size=4)
@@ -199,7 +199,7 @@ def test_talker_mtp_forward_cpu_updates_inputs_and_info(monkeypatch):
def test_talker_mtp_forward_cpu_empty_batch_noop(monkeypatch):
import vllm_omni.worker.gpu_model_runner as mod
monkeypatch.setattr(mod, "set_forward_context", _noop_forward_context)
monkeypatch.setattr(mod.current_omni_platform, "set_forward_context", _noop_forward_context)
runner = _make_runner(req_ids=("r1",), hidden_size=4)
@@ -215,7 +215,7 @@ def test_talker_mtp_forward_cpu_empty_batch_noop(monkeypatch):
def test_talker_mtp_forward_ignores_default_sampling_seed_without_request_marker(monkeypatch):
import vllm_omni.worker.gpu_model_runner as mod
monkeypatch.setattr(mod, "set_forward_context", _noop_forward_context)
monkeypatch.setattr(mod.current_omni_platform, "set_forward_context", _noop_forward_context)
runner = _make_runner(req_ids=("r1",), hidden_size=4)
runner.requests["r1"].sampling_params = SimpleNamespace(seed=42)
@@ -237,7 +237,7 @@ def test_talker_mtp_forward_ignores_default_sampling_seed_without_request_marker
def test_talker_mtp_forward_passes_qwen3_tts_subtalker_sampling_params_to_talker(monkeypatch):
import vllm_omni.worker.gpu_model_runner as mod
monkeypatch.setattr(mod, "set_forward_context", _noop_forward_context)
monkeypatch.setattr(mod.current_omni_platform, "set_forward_context", _noop_forward_context)
runner = _make_runner(req_ids=("r1",), hidden_size=4)
runner.requests["r1"].sampling_params = SimpleNamespace(
@@ -281,7 +281,7 @@ def test_talker_mtp_forward_passes_qwen3_tts_subtalker_sampling_params_to_talker
def test_talker_mtp_forward_keeps_explicit_seeded_requests_scalar(monkeypatch):
import vllm_omni.worker.gpu_model_runner as mod
monkeypatch.setattr(mod, "set_forward_context", _noop_forward_context)
monkeypatch.setattr(mod.current_omni_platform, "set_forward_context", _noop_forward_context)
runner = _make_runner(req_ids=("r1", "r2"), hidden_size=4)
runner.requests["r1"].sampling_params = SimpleNamespace(
@@ -322,7 +322,7 @@ def test_update_intermediate_buffer_writes_to_buffer_and_setattr(monkeypatch):
(forward path) and mirrors to additional_information_cpu setattr (backward compat)."""
import vllm_omni.worker.gpu_model_runner as mod
monkeypatch.setattr(mod, "set_forward_context", _noop_forward_context)
monkeypatch.setattr(mod.current_omni_platform, "set_forward_context", _noop_forward_context)
runner = _make_runner(req_ids=("r1",), hidden_size=4)
+3 -3
View File
@@ -250,7 +250,7 @@ def _collect_diffusion_records(input_dir: str) -> list[dict[str, Any]]:
def _sort_omni_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
by_date_desc = sorted(records, key=lambda r: (r.get("date") or ""), reverse=True)
by_date_desc = sorted(records, key=lambda r: r.get("date") or "", reverse=True)
return sorted(
by_date_desc,
key=lambda r: (
@@ -264,8 +264,8 @@ def _sort_omni_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _sort_diffusion_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
by_date_desc = sorted(records, key=lambda r: (r.get("date") or ""), reverse=True)
return sorted(by_date_desc, key=lambda r: (r.get("test_name") or ""))
by_date_desc = sorted(records, key=lambda r: r.get("date") or "", reverse=True)
return sorted(by_date_desc, key=lambda r: r.get("test_name") or "")
def _ensure_parent_dir(path: str) -> None:
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more