diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml
index d9a231595..00823951d 100644
--- a/.buildkite/pipeline.yml
+++ b/.buildkite/pipeline.yml
@@ -44,11 +44,19 @@ steps:
agents:
queue: "cpu_queue_premerge"
- # L4 Test — main+NIGHTLY=1 (scheduled), or PR with label nightly-test (e.g. add label then Rebuild)
+ # L4 Test — main+NIGHTLY=1 (scheduled), or PR with specific label (e.g. add label then Rebuild)
- label: "Upload Nightly Pipeline"
depends_on: image-build
key: upload-nightly-pipeline
- if: '(build.branch == "main" && build.env("NIGHTLY") == "1") || (build.branch != "main" && build.pull_request.labels includes "nightly-test")'
+ if: >-
+ (build.branch == "main" && build.env("NIGHTLY") == "1") ||
+ (build.branch != "main" && (
+ build.pull_request.labels includes "nightly-test" ||
+ build.pull_request.labels includes "omni-test" ||
+ build.pull_request.labels includes "tts-test" ||
+ build.pull_request.labels includes "diffusion-x2iat-test" ||
+ build.pull_request.labels includes "diffusion-x2v-test"
+ ))
commands:
- buildkite-agent pipeline upload .buildkite/test-nightly.yml
agents:
diff --git a/.buildkite/scripts/hardware_ci/run-amd-test.sh b/.buildkite/scripts/hardware_ci/run-amd-test.sh
index f56f23b5d..2707dfefe 100755
--- a/.buildkite/scripts/hardware_ci/run-amd-test.sh
+++ b/.buildkite/scripts/hardware_ci/run-amd-test.sh
@@ -64,12 +64,13 @@ while true; do
done
echo "--- Pulling container"
-## Temporary change to use AMD Docker Hub to store the vllm-ci image
+## Temporary change to use AMD Docker Hub to store the vllm-omni image
# to bypass the rate limit issue with ECR Public Gallery.
+# Images are now stored in a separate repository for vllm-omni, instead of vllm-ci.
# TODO: @tjtanaa point back to ECR Public Gallery
# once the amd agents are configured to use ECR Public Gallery.
# image_name="public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:${BUILDKITE_COMMIT}-rocm-omni"
-image_name="rocm/vllm-ci:${BUILDKITE_COMMIT}-rocm-omni"
+image_name="rocm/vllm-omni:${BUILDKITE_COMMIT}"
container_name="rocm_${BUILDKITE_COMMIT}_$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 10; echo)"
# TODO: @tjtanaa uncomment this once the amd agents are configured to use ECR Public Gallery.
diff --git a/.buildkite/test-amd.yaml b/.buildkite/test-amd.yaml
index e175385ff..0b9a3f47a 100644
--- a/.buildkite/test-amd.yaml
+++ b/.buildkite/test-amd.yaml
@@ -117,3 +117,17 @@ steps:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- pytest -s -v tests/e2e/online_serving/test_image_gen_edit.py
+
+
+- label: "Omni Sleep Mode Test"
+ timeout_in_minutes: 40
+ agent_pool: mi325_2
+ depends_on: amd-build
+ mirror_hardwares: [amdproduction]
+ grade: Blocking
+ commands:
+ - export GPU_ARCHS=gfx942
+ - export VLLM_LOGGING_LEVEL=DEBUG
+ - export VLLM_WORKER_MULTIPROC_METHOD=spawn
+ - export VLLM_TEST_CLEAN_GPU_MEMORY="1"
+ - pytest -s -v tests/e2e/offline_inference/test_omni_sleep_mode.py -m "advanced_model and omni and MI325" --run-level "advanced_model"
diff --git a/.buildkite/test-merge.yml b/.buildkite/test-merge.yml
index bc36c337d..cbb44ac69 100644
--- a/.buildkite/test-merge.yml
+++ b/.buildkite/test-merge.yml
@@ -382,6 +382,46 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
+ - label: "Omni Sleep Mode Test with H100"
+ timeout_in_minutes: 30
+ depends_on: upload-merge-pipeline
+ commands:
+ - export VLLM_TEST_CLEAN_GPU_MEMORY="1"
+ - pytest -s -v tests/e2e/offline_inference/test_omni_sleep_mode.py -m "advanced_model and H100 and omni" --run-level "advanced_model"
+ agents:
+ queue: "mithril-h100-pool"
+ plugins:
+ - kubernetes:
+ podSpec:
+ containers:
+ - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+ resources:
+ limits:
+ nvidia.com/gpu: 2
+ 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
+
- label: "Voxtral-TTS E2E Test"
timeout_in_minutes: 20
depends_on: upload-merge-pipeline
diff --git a/.buildkite/test-nightly-diffusion.yml b/.buildkite/test-nightly-diffusion.yml
deleted file mode 100644
index a520ca435..000000000
--- a/.buildkite/test-nightly-diffusion.yml
+++ /dev/null
@@ -1,377 +0,0 @@
-# Nightly diffusion GPU tests — appended to the main nightly build via
-# buildkite-agent pipeline upload .buildkite/test-nightly-diffusion.yml
-# from test-nightly.yml (step key: nightly-diffusion-model-test). Top-level groups are
-# foldable in the Buildkite UI (Other / Wan / Qwen-Image).
-env:
- VLLM_WORKER_MULTIPROC_METHOD: spawn
- HF_HUB_DOWNLOAD_TIMEOUT: 300
- HF_HUB_ETAG_TIMEOUT: 60
-
-steps:
- - group: ":card_index_dividers: Other Model Test"
- key: nightly-other-model-test-group
- steps:
- - label: ":full_moon: Diffusion · Other · Function Test with H100"
- timeout_in_minutes: 120
- # Shared nightly vs PR label conditional; referenced below as *nightly_or_pr_label
- if: &nightly_or_pr_label 'build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"'
- commands:
- - pytest -s -v tests/e2e/online_serving/test_*_expansion.py -k "not test_wan22_expansion and not test_wan_2_1_vace_expansion and not test_qwen_image" -m "advanced_model and diffusion and H100" --run-level "advanced_model"
- agents:
- queue: "mithril-h100-pool"
- plugins:
- - kubernetes:
- podSpec:
- containers:
- - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
- resources:
- limits:
- nvidia.com/gpu: 2
- 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
-
- - label: ":full_moon: Diffusion · Other · Function Test with L4"
- timeout_in_minutes: 60
- if: *nightly_or_pr_label
- commands:
- - pytest -s -v tests/e2e/online_serving/test_*_expansion.py -m "advanced_model and diffusion and L4" --run-level "advanced_model"
- agents:
- queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
- plugins:
- - docker#v5.2.0:
- image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
- always-pull: true
- propagate-environment: true
- shm-size: "8gb"
- environment:
- - "HF_HOME=/fsx/hf_cache"
- - "HF_TOKEN"
- volumes:
- - "/fsx/hf_cache:/fsx/hf_cache"
-
- - label: ":full_moon: Diffusion · Other · Doc Test"
- timeout_in_minutes: 60
- if: *nightly_or_pr_label
- commands:
- - export VLLM_TEST_CLEAN_GPU_MEMORY="1"
- - pytest -s -v tests/examples/online_serving/test_text_to_image.py tests/examples/offline_inference/test_text_to_image.py -m "advanced_model and example and H100" --run-level "advanced_model"
- agents:
- queue: "mithril-h100-pool"
- plugins:
- - kubernetes:
- podSpec:
- containers:
- - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
- resources:
- limits:
- nvidia.com/gpu: 2
- 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
-
- - group: ":card_index_dividers: Wan Series Model Test"
- key: nightly-wan-model-test-group
- steps:
- - label: ":full_moon: Diffusion · Wan · Function Test"
- timeout_in_minutes: 90
- if: *nightly_or_pr_label
- commands:
- - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py tests/e2e/online_serving/test_wan_2_1_vace_expansion.py -m "advanced_model" --run-level "advanced_model"
- agents:
- queue: "mithril-h100-pool"
- plugins:
- - kubernetes:
- podSpec:
- containers:
- - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
- resources:
- limits:
- nvidia.com/gpu: 2
- 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
-
- - label: ":full_moon: Diffusion · Wan · Accuracy Test"
- key: nightly-wan22-i2v-accuracy
- timeout_in_minutes: 180
- if: *nightly_or_pr_label
- commands:
- - pytest -s -v tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py --run-level advanced_model
- agents:
- queue: "mithril-h100-pool"
- plugins:
- - kubernetes:
- podSpec:
- containers:
- - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
- resources:
- limits:
- nvidia.com/gpu: 2
- 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
-
- - group: ":card_index_dividers: Qwen-Image Series Model Test"
- key: nightly-qwen-image-edit-group
- steps:
- - label: ":full_moon: Diffusion · Qwen-Image · Function Test with H100"
- timeout_in_minutes: 120
- if: *nightly_or_pr_label
- commands:
- - pytest -s -v tests/e2e/online_serving/test_qwen_image*_expansion.py -m "advanced_model and diffusion and H100" --run-level "advanced_model"
- agents:
- queue: "mithril-h100-pool"
- plugins:
- - kubernetes:
- podSpec:
- containers:
- - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
- resources:
- limits:
- nvidia.com/gpu: 2
- 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
-
- - label: ":full_moon: Diffusion · Qwen-Image · GEBench Accuracy Test"
- key: nightly-gebench-accuracy
- timeout_in_minutes: 60
- if: *nightly_or_pr_label
- commands:
- - pytest -s -v tests/e2e/accuracy/test_gebench_h100_smoke.py --run-level advanced_model --gebench-model Qwen/Qwen-Image-2512 --accuracy-judge-model QuantTrio/Qwen3-VL-30B-A3B-Instruct-AWQ --accuracy-gpu 0 --gebench-port 8093 --accuracy-workers 1
- - buildkite-agent artifact upload "tests/e2e/accuracy/artifacts/gebench_qwen-image-2512/summary*.json"
- 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
-
- - label: ":full_moon: Diffusion · Qwen-Image · GEdit-Bench Accuracy Test"
- key: nightly-gedit-bench-accuracy
- timeout_in_minutes: 60
- if: *nightly_or_pr_label
- commands:
- - pytest -s -v tests/e2e/accuracy/test_gedit_bench_h100_smoke.py --run-level advanced_model --gedit-model Qwen/Qwen-Image-Edit --accuracy-judge-model QuantTrio/Qwen3-VL-30B-A3B-Instruct-AWQ --accuracy-gpu 0 --gedit-port 8093 --gedit-samples-per-group 20 --accuracy-workers 1
- - buildkite-agent artifact upload "tests/e2e/accuracy/artifacts/gedit_scores_qwen-image-edit/qwen-image-edit_all_all_vie_score_*.csv"
- - buildkite-agent artifact upload "tests/e2e/accuracy/artifacts/gedit_scores_qwen-image-edit/qwen-image-edit_all_all_summary_*.json"
- 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: VLLM_HTTP_TIMEOUT_KEEP_ALIVE
- value: "120"
- - 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
-
- - label: ":full_moon: Diffusion · Qwen-Image · Perf Test"
- key: nightly-qwen-image-performance
- timeout_in_minutes: 180
- if: *nightly_or_pr_label
- commands:
- - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
- - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN
- - export CACHE_DIT_VERSION=1.3.0
- # [HACK]: run upload in the same command block as pytest.
- # Because `exit` aborts the entire commands list.
- - |
- set +e
- pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
- EXIT1=$$?
- pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --config-file tests/dfx/perf/tests/test_qwen_image_edit_vllm_omni.json
- EXIT2=$$?
- pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --config-file tests/dfx/perf/tests/test_qwen_image_edit_2509_vllm_omni.json
- EXIT3=$$?
- if [ $$EXIT1 -eq 0 ] || [ $$EXIT2 -eq 0 ] || [ $$EXIT3 -eq 0 ]; then
- buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
- buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
- fi
- 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
diff --git a/.buildkite/test-nightly.yml b/.buildkite/test-nightly.yml
index 31b3e1797..c33d7b4d1 100644
--- a/.buildkite/test-nightly.yml
+++ b/.buildkite/test-nightly.yml
@@ -7,12 +7,11 @@ steps:
# Group: collapses under one heading in the Buildkite UI; child steps still run in parallel.
- group: ":card_index_dividers: Omni Model Test"
key: nightly-omni-test-group
+ depends_on: upload-nightly-pipeline
+ if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "omni-test"
steps:
- - label: ":full_moon: Omni · Function Test with H100"
+ - label: ":full_moon: Omni · Function Test"
timeout_in_minutes: 90
- depends_on: upload-nightly-pipeline
- # Shared nightly vs PR label conditional; referenced below as *nightly_or_pr_label
- if: &nightly_or_pr_label 'build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"'
commands:
- pytest -s -v tests/e2e/online_serving/test_*_expansion.py -m "advanced_model and H100 and omni" --run-level "advanced_model"
agents:
@@ -49,31 +48,8 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- - label: ":full_moon: Omni · Function Test with L4"
- timeout_in_minutes: 90
- depends_on: upload-nightly-pipeline
- if: *nightly_or_pr_label
- commands:
- - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- - pytest -s -v tests/e2e/online_serving/test_*_expansion.py -m "advanced_model and L4 and omni" --run-level "advanced_model"
- agents:
- queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
- plugins:
- - docker#v5.2.0:
- image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
- always-pull: true
- propagate-environment: true
- shm-size: "8gb"
- environment:
- - "HF_HOME=/fsx/hf_cache"
- - "HF_TOKEN"
- volumes:
- - "/fsx/hf_cache:/fsx/hf_cache"
-
- label: ":full_moon: Omni · Doc Test with L4"
timeout_in_minutes: 90
- depends_on: upload-nightly-pipeline
- if: *nightly_or_pr_label
commands:
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- pytest -s -v tests/examples/ -m "advanced_model and omni and L4" --run-level "advanced_model"
@@ -93,8 +69,6 @@ steps:
- label: ":full_moon: Omni · Doc Test with H100"
timeout_in_minutes: 90
- depends_on: upload-nightly-pipeline
- if: *nightly_or_pr_label
commands:
- pytest -s -v tests/examples/ -m "advanced_model and omni and H100" --run-level "advanced_model"
agents:
@@ -134,13 +108,14 @@ steps:
- label: ":full_moon: Omni · Perf Test"
key: nightly-omni-performance
timeout_in_minutes: 180
- depends_on: upload-nightly-pipeline
- if: *nightly_or_pr_label
commands:
- export BENCHMARK_DIR=tests/dfx/perf/results
- - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- - pytest -s -v tests/dfx/perf/scripts/run_benchmark.py
- - buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
+ - |
+ set +e
+ pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_omni.json
+ EXIT=$$?
+ buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
+ exit $$EXIT
agents:
queue: "mithril-h100-pool"
plugins:
@@ -175,23 +150,448 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- # Dynamically appends steps from test-nightly-diffusion.yml into this build (same mechanism as
- # pipeline.yml → test-ready.yml / test-merge.yml / test-nightly.yml). Foldable groups stay in the
- # uploaded YAML (Other / Wan / Qwen-Image).
- - label: ":card_index_dividers: Diffusion Model Test"
- key: nightly-diffusion-model-test
+
+ - group: ":card_index_dividers: TTS Model Test"
+ key: nightly-tts-test-group
depends_on: upload-nightly-pipeline
- if: *nightly_or_pr_label
- commands:
- - buildkite-agent pipeline upload .buildkite/test-nightly-diffusion.yml
- agents:
- queue: "cpu_queue_premerge"
+ if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "tts-test"
+ steps:
+ - label: ":full_moon: TTS · Function Test"
+ timeout_in_minutes: 90
+ commands:
+ - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
+ - pytest -s -v tests/e2e/online_serving/test_*_expansion.py -m "advanced_model and L4 and omni" --run-level "advanced_model"
+ agents:
+ queue: "gpu_1_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
+ plugins:
+ - docker#v5.2.0:
+ image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+ always-pull: true
+ propagate-environment: true
+ shm-size: "8gb"
+ environment:
+ - "HF_HOME=/fsx/hf_cache"
+ - "HF_TOKEN"
+ volumes:
+ - "/fsx/hf_cache:/fsx/hf_cache"
+
+ - label: ":full_moon: TTS · Perf Test"
+ key: nightly-tts-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_tts.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
+ depends_on: upload-nightly-pipeline
+ if: >-
+ build.env("NIGHTLY") == "1" ||
+ build.pull_request.labels includes "nightly-test" ||
+ build.pull_request.labels includes "diffusion-x2iat-test"
+ steps:
+ - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with H100"
+ timeout_in_minutes: 120
+ commands:
+ - pytest -s -v tests/e2e/online_serving/test_*_expansion.py -k "not test_wan22_expansion and not test_wan_2_1_vace_expansion and not hunyuan" -m "advanced_model and diffusion and H100" --run-level "advanced_model"
+ agents:
+ queue: "mithril-h100-pool"
+ plugins:
+ - kubernetes:
+ podSpec:
+ containers:
+ - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+ resources:
+ limits:
+ nvidia.com/gpu: 2
+ 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
+
+ - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with L4"
+ timeout_in_minutes: 60
+ commands:
+ - pytest -s -v tests/e2e/online_serving/test_*_expansion.py -k "not test_wan22_expansion and not test_wan_2_1_vace_expansion and not hunyuan" -m "advanced_model and diffusion and L4" --run-level "advanced_model"
+ agents:
+ queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
+ plugins:
+ - docker#v5.2.0:
+ image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+ always-pull: true
+ propagate-environment: true
+ shm-size: "8gb"
+ environment:
+ - "HF_HOME=/fsx/hf_cache"
+ - "HF_TOKEN"
+ volumes:
+ - "/fsx/hf_cache:/fsx/hf_cache"
+
+ - label: ":full_moon: Diffusion X2I(&A&T) · Doc Test"
+ timeout_in_minutes: 60
+ commands:
+ - export VLLM_TEST_CLEAN_GPU_MEMORY="1"
+ - pytest -s -v tests/examples/*/test_text_to_image.py -m "advanced_model and example and H100" --run-level "advanced_model"
+ agents:
+ queue: "mithril-h100-pool"
+ plugins:
+ - kubernetes:
+ podSpec:
+ containers:
+ - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+ resources:
+ limits:
+ nvidia.com/gpu: 2
+ 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
+
+ - label: ":full_moon: Diffusion X2I(&A&T) · GEBench Accuracy Test"
+ timeout_in_minutes: 60
+ commands:
+ - pytest -s -v tests/e2e/accuracy/test_gebench_h100_smoke.py --run-level advanced_model --gebench-model Qwen/Qwen-Image-2512 --accuracy-judge-model QuantTrio/Qwen3-VL-30B-A3B-Instruct-AWQ --accuracy-gpu 0 --gebench-port 8093 --accuracy-workers 1
+ - buildkite-agent artifact upload "tests/e2e/accuracy/artifacts/gebench_qwen-image-2512/summary*.json"
+ 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
+
+ - label: ":full_moon: Diffusion X2I(&A&T) · GEdit-Bench Accuracy Test"
+ timeout_in_minutes: 60
+ commands:
+ - pytest -s -v tests/e2e/accuracy/test_gedit_bench_h100_smoke.py --run-level advanced_model --gedit-model Qwen/Qwen-Image-Edit --accuracy-judge-model QuantTrio/Qwen3-VL-30B-A3B-Instruct-AWQ --accuracy-gpu 0 --gedit-port 8093 --gedit-samples-per-group 20 --accuracy-workers 1
+ - buildkite-agent artifact upload "tests/e2e/accuracy/artifacts/gedit_scores_qwen-image-edit/qwen-image-edit_all_all_vie_score_*.csv"
+ - buildkite-agent artifact upload "tests/e2e/accuracy/artifacts/gedit_scores_qwen-image-edit/qwen-image-edit_all_all_summary_*.json"
+ 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: VLLM_HTTP_TIMEOUT_KEEP_ALIVE
+ value: "120"
+ - 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
+
+ - label: ":full_moon: Diffusion X2I(&A&T) · Accuracy Test"
+ timeout_in_minutes: 180
+ commands:
+ - pytest -s -v tests/e2e/accuracy/test_qwen_image*.py --run-level advanced_model
+ agents:
+ queue: "mithril-h100-pool"
+ plugins:
+ - kubernetes:
+ podSpec:
+ containers:
+ - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+ resources:
+ limits:
+ nvidia.com/gpu: 1
+ volumeMounts:
+ - name: devshm
+ mountPath: /dev/shm
+ - name: hf-cache
+ mountPath: /root/.cache/huggingface
+ env:
+ - name: HF_HOME
+ value: /root/.cache/huggingface
+ - name: VLLM_HTTP_TIMEOUT_KEEP_ALIVE
+ value: "120"
+ - 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
+
+ - label: ":full_moon: Diffusion X2I(&A&T) · Perf Test"
+ key: nightly-diffusion-x2iat-performance
+ timeout_in_minutes: 180
+ commands:
+ - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
+ - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN
+ - export CACHE_DIT_VERSION=1.3.0
+ # [HACK]: run upload in the same command block as pytest.
+ # Because `exit` aborts the entire commands list.
+ - |
+ set +e
+ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
+ EXIT1=$$?
+ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_edit_vllm_omni.json
+ EXIT2=$$?
+ 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
+ EXIT3=$$?
+ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json
+ EXIT4=$$?
+ 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 | EXIT4))
+ 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
+ depends_on: upload-nightly-pipeline
+ if: >-
+ build.env("NIGHTLY") == "1" ||
+ build.pull_request.labels includes "nightly-test" ||
+ build.pull_request.labels includes "diffusion-x2v-test"
+ steps:
+ - label: ":full_moon: Diffusion X2V · Function Test"
+ timeout_in_minutes: 90
+ commands:
+ - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py tests/e2e/online_serving/test_wan_2_1_vace_expansion.py tests/e2e/online_serving/test_hunyuan_video_15_expansion.py -m "advanced_model" --run-level "advanced_model"
+ agents:
+ queue: "mithril-h100-pool"
+ plugins:
+ - kubernetes:
+ podSpec:
+ containers:
+ - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+ resources:
+ limits:
+ nvidia.com/gpu: 2
+ 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
+
+ - label: ":full_moon: Diffusion X2V · Accuracy Test"
+ timeout_in_minutes: 180
+ commands:
+ - pytest -s -v tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py -m advanced_model --run-level advanced_model
+ agents:
+ queue: "mithril-h100-pool"
+ plugins:
+ - kubernetes:
+ podSpec:
+ containers:
+ - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+ resources:
+ limits:
+ nvidia.com/gpu: 2
+ 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
- label: ":bar_chart: Testcase Statistics"
key: nightly-testcase-statistics
timeout_in_minutes: 120
depends_on: upload-nightly-pipeline
- if: *nightly_or_pr_label
+ if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
commands:
- python tools/nightly/buildkite_testcase_statistics.py -o tests/dfx/perf/results/buildkite_testcase_statistics.html
- buildkite-agent artifact upload "tests/dfx/perf/results/*.html"
@@ -234,15 +634,17 @@ steps:
key: nightly-perf-distribution
depends_on:
- nightly-omni-performance
- - nightly-qwen-image-performance
+ - nightly-tts-performance
+ - nightly-diffusion-x2iat-performance
- nightly-testcase-statistics
if: build.env("NIGHTLY") == "1"
commands:
- pip install openpyxl
- export DEFAULT_INPUT_DIR=tests/dfx/perf/results
- export DEFAULT_OUTPUT_DIR=tests/dfx/perf/results
+ - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-tts-performance
- buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance
- - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-qwen-image-performance
+ - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance
- buildkite-agent artifact download "tests/dfx/perf/results/*.html" . --step nightly-testcase-statistics
- python tools/nightly/generate_nightly_perf_excel.py
- python tools/nightly/generate_nightly_perf_html.py
diff --git a/.buildkite/test-ready.yml b/.buildkite/test-ready.yml
index 2f749f0ee..3ca1747fe 100644
--- a/.buildkite/test-ready.yml
+++ b/.buildkite/test-ready.yml
@@ -295,6 +295,31 @@ steps:
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
+ - label: "VoxCPM E2E Test"
+ timeout_in_minutes: 20
+ depends_on: upload-ready-pipeline
+ commands:
+ - |
+ timeout 20m bash -c '
+ pip install voxcpm
+ export VLLM_LOGGING_LEVEL=DEBUG
+ export VLLM_WORKER_MULTIPROC_METHOD=spawn
+ pytest -s -v tests/e2e/offline_inference/test_voxcpm.py -m "core_model" --run-level "core_model"
+ '
+ agents:
+ queue: "gpu_1_queue"
+ plugins:
+ - docker#v5.2.0:
+ image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+ always-pull: true
+ propagate-environment: true
+ shm-size: "8gb"
+ environment:
+ - "HF_HOME=/fsx/hf_cache"
+ - "HF_TOKEN"
+ volumes:
+ - "/fsx/hf_cache:/fsx/hf_cache"
+
- label: "VoxCPM2 Native AR E2E Test"
timeout_in_minutes: 20
depends_on: upload-ready-pipeline
@@ -342,6 +367,33 @@ steps:
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
+ - label: "Qwen3-TTS Base E2E Test (ModelRunner V2)"
+ depends_on: upload-ready-pipeline
+ soft_fail:
+ - exit_status: 1
+ commands:
+ - |
+ timeout 20m bash -c '
+ export VLLM_LOGGING_LEVEL=DEBUG
+ export VLLM_WORKER_MULTIPROC_METHOD=spawn
+ export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
+ export VLLM_OMNI_USE_V2_RUNNER="1"
+ pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m "core_model" --run-level "core_model"
+ '
+ agents:
+ queue: "gpu_1_queue"
+ plugins:
+ - docker#v5.2.0:
+ image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+ always-pull: true
+ propagate-environment: true
+ shm-size: "8gb"
+ environment:
+ - "HF_HOME=/fsx/hf_cache"
+ - "HF_TOKEN"
+ volumes:
+ - "/fsx/hf_cache:/fsx/hf_cache"
+
- label: "Voxtral-TTS E2E Test"
timeout_in_minutes: 20
depends_on: upload-ready-pipeline
diff --git a/.buildkite/test-template-amd-omni.j2 b/.buildkite/test-template-amd-omni.j2
index f4c386a5f..78f47d1ae 100644
--- a/.buildkite/test-template-amd-omni.j2
+++ b/.buildkite/test-template-amd-omni.j2
@@ -3,7 +3,7 @@
Last synced: 2025-12-15
Modifications: Removed unused CUDA/NVIDIA logic, keeping only AMD tests
#}
-{% set docker_image_amd = "rocm/vllm-ci:$BUILDKITE_COMMIT-rocm-omni" %}
+{% set docker_image_amd = "rocm/vllm-omni:$BUILDKITE_COMMIT" %}
{% set default_working_dir = "/app/vllm-omni" %}
- group: "AMD Tests"
diff --git a/.claude/skills/vllm-omni-npu-upgrade/SKILL.md b/.claude/skills/vllm-omni-npu-upgrade/SKILL.md
new file mode 100644
index 000000000..1ef7ab393
--- /dev/null
+++ b/.claude/skills/vllm-omni-npu-upgrade/SKILL.md
@@ -0,0 +1,300 @@
+---
+name: vllm-omni-npu-model-runner-upgrade
+description: "Upgrade vllm-omni NPU model runners (OmniNPUModelRunner, NPUARModelRunner, NPUGenerationModelRunner) to align with the latest vllm-ascend NPUModelRunner while preserving omni-specific logic."
+---
+
+# vLLM-Omni NPU Model Runner Upgrade Skill
+
+## Overview
+
+This skill guides the process of upgrading vllm-omni's NPU model runners to align with the latest vllm-ascend codebase while preserving omni-specific enhancements. The NPU runners are designed to run omni multimodal models (like Qwen3-Omni, Bagel, MiMoAudio) on Ascend NPUs.
+
+## File Structure
+
+### NPU Model Runner Files
+```
+vllm-omni/vllm_omni/platforms/npu/worker/
+├── __init__.py
+├── npu_model_runner.py # OmniNPUModelRunner (base class)
+├── npu_ar_model_runner.py # NPUARModelRunner (autoregressive)
+├── npu_ar_worker.py # AR worker
+├── npu_generation_model_runner.py # NPUGenerationModelRunner (diffusion/non-AR)
+└── npu_generation_worker.py # Generation worker
+```
+
+### GPU Reference Files (for omni-specific logic sync)
+```
+vllm-omni/vllm_omni/worker/
+├── __init__.py
+├── gpu_model_runner.py # OmniGPUModelRunner
+├── gpu_ar_model_runner.py # GPUARModelRunner
+├── gpu_ar_worker.py
+├── gpu_generation_model_runner.py
+├── gpu_generation_worker.py
+├── mixins.py
+├── base.py
+└── gpu_memory_utils.py
+```
+
+### vllm-ascend Reference Files
+```
+vllm-ascend/vllm_ascend/worker/
+├── model_runner_v1.py # NPUModelRunner (base class to copy from)
+├── npu_input_batch.py
+├── block_table.py
+├── pcp_utils.py
+└── worker.py
+```
+
+## Inheritance Hierarchy
+
+```
+ GPUModelRunner (vllm)
+ |
+ +----------------+----------------+
+ | |
+ OmniGPUModelRunner NPUModelRunner (vllm-ascend)
+ (vllm_omni/worker) (vllm_ascend/worker)
+ | |
+ +----------- OmniNPUModelRunner --+
+ (multiple inheritance)
+ |
+ +---------------+---------------+
+ | |
+ NPUARModelRunner NPUGenerationModelRunner
+ (autoregressive) (non-autoregressive/diffusion)
+```
+
+## Omni-Specific Comment Markers
+
+Omni-specific logic is marked with comment blocks:
+```python
+# -------------------------------------- Omni-new -------------------------------------------------
+# ... omni-specific code ...
+# -------------------------------------- Omni-new -------------------------------------------------
+```
+
+Or simpler variations:
+```python
+# -------------------------------------- Omni-new -------------------------------------------------
+# ------------------------------------------------------------------------------------------------
+```
+
+**Important**:
+- Always preserve and add these markers when modifying code.
+- **The reference documents (`references/omni-specific-blocks.md`) may not be up-to-date.** Always grep for `Omni-new` in the GPU implementations to find the authoritative list of omni-specific blocks.
+- When you discover new omni-specific code that is not documented in the references, please update the reference files.
+
+## Key Methods Requiring Attention
+
+### OmniNPUModelRunner (npu_model_runner.py)
+
+| Method | Description | Omni-Specific Logic |
+|--------|-------------|---------------------|
+| `load_model` | Load model and initialize talker_mtp | Uses `ACLGraphWrapper` instead of `CUDAGraphWrapper`, initializes talker buffers |
+| `_dummy_run` | Warmup/profiling run | talker_mtp dummy forward, `extract_multimodal_outputs` |
+| `_model_forward` | Forward pass wrapper | Injects `model_kwargs_extra`, wraps with `OmniOutput`, NPU-specific graph updates |
+| `_talker_mtp_forward` | Talker MTP forward for Qwen3-Omni | Uses `set_ascend_forward_context` |
+
+### NPUARModelRunner (npu_ar_model_runner.py)
+
+| Method | Description | Omni-Specific Logic |
+|--------|-------------|---------------------|
+| `__init__` | Initialize with KV transfer manager | `OmniKVTransferManager` setup |
+| `execute_model` | Main inference entry | KV transfer handling, `_update_states` override, `extract_multimodal_outputs` |
+| `sample_tokens` | Token sampling | Hidden states extraction, multimodal outputs processing, `OmniModelRunnerOutput` |
+| `_resolve_global_request_id` | Request ID resolution | For disaggregated inference |
+
+### NPUGenerationModelRunner (npu_generation_model_runner.py)
+
+| Method | Description | Omni-Specific Logic |
+|--------|-------------|---------------------|
+| `_update_request_states` | Update request states for async chunk | async_chunk handling |
+| `execute_model` | Generation forward | async_chunk, `seq_token_counts`, `_run_generation_model` |
+| `sample_tokens` | Output processing | multimodal output packaging to `OmniModelRunnerOutput` |
+| `_dummy_run` | Dummy run override | model_kwargs initialization, multimodal extraction |
+| `_run_generation_model` | Run generation model | Calls `_model_forward` with sampler |
+
+## Upgrade Workflow
+
+### Step 1: Preparation
+
+1. **Identify target versions**(Use gh cli to check):
+ - We're using vllm-omni main branch
+ - Check the last release of vllm-omni
+ - Target vllm-ascend version(Just directly use the local latest vllm-ascend code)
+
+2. **Check GPU-side changes** (since last release):
+ ```bash
+ cd /root/vllm-workspace/vllm-omni
+ git log --oneline --since="" -- vllm_omni/worker/
+ ```
+
+3. **Read latest vllm-ascend code**:
+ - We don't track vllm-ascend changes - just directly use the latest code from `/root/vllm-workspace/vllm-ascend/vllm_ascend/worker/model_runner_v1.py`
+ - Copy the relevant methods and re-insert omni-specific blocks
+
+### Step 2: Analyze Omni-Specific Logic
+
+For each NPU model runner file:
+
+1. **Extract existing omni-specific blocks**:
+ ```bash
+ grep -n "Omni-new" vllm_omni/platforms/npu/worker/npu_model_runner.py
+ ```
+
+2. **Document each omni block**:
+ - Which method it belongs to
+ - What functionality it provides
+ - Dependencies on other omni code
+
+### Step 3: Update Base Class (OmniNPUModelRunner)
+
+**Note**: Always check the GPU implementation `gpu_model_runner.py` for any new omni logic not yet documented in references.
+
+1. **Read the latest vllm-ascend `NPUModelRunner.load_model`**
+2. **Copy the method, keeping the structure**
+3. **Re-insert omni-specific logic** (check GPU `gpu_model_runner.py` for authoritative list):
+ - Replace `CUDAGraphWrapper` with `ACLGraphWrapper`
+ - Keep talker_mtp initialization
+ - Preserve buffer allocations for talker
+ - Check for any new omni blocks added since last sync
+
+4. **Update `_dummy_run`**:
+ - Copy from vllm-ascend
+ - Compare with GPU `_dummy_run` for omni-specific blocks
+ - Re-insert all `Omni-new` marked code from GPU version
+
+5. **Update `_model_forward`**:
+ - Keep the omni wrapper logic
+ - Update NPU-specific parts (graph params, SP all-gather)
+ - Check GPU version for any new omni logic
+
+### Step 4: Update AR Model Runner
+
+1. **Compare with GPU `gpu_ar_model_runner.py`** for any new omni features
+2. **Copy `execute_model` from vllm-ascend**
+3. **Re-insert omni blocks** (reference `references/omni-specific-blocks.md`, but note it may be incomplete):
+ - **IMPORTANT**: Always check the GPU implementation `gpu_ar_model_runner.py` for all `Omni-new` marked code blocks
+ - The reference doc may not include newly added omni logic - treat it as a starting point, not exhaustive
+ - When discovering new omni code blocks, please update `references/omni-specific-blocks.md`
+ - Common omni blocks include but are not limited to: KV transfer, multimodal outputs, sampling_metadata handling, etc.
+
+4. **Update `sample_tokens`** (also compare with GPU implementation):
+ - Compare with `gpu_ar_model_runner.py`'s `sample_tokens` method
+ - Identify all `Omni-new` marked code blocks
+ - Ensure NPU version includes all omni-specific logic
+
+### Step 5: Update Generation Model Runner
+
+**Note**: Generation model runner may have unique omni logic for diffusion/non-AR models.
+
+1. **Compare with GPU `gpu_generation_model_runner.py`** - grep for all `Omni-new` blocks
+2. **Update `execute_model`**:
+ - Check GPU version for all omni-specific blocks
+ - Keep async_chunk handling
+ - Keep `seq_token_counts` injection
+ - Update forward/context setup from vllm-ascend
+ - Look for any new omni logic not documented in references
+
+3. **Update `_dummy_run`**:
+ - Copy from vllm-ascend base
+ - Compare with GPU `_dummy_run` if exists
+ - Re-insert all omni-specific logic
+
+### Step 6: Update Imports
+
+Check and update imports at the top of each file:
+
+```python
+# Common vllm-ascend imports
+from vllm_ascend.ascend_forward_context import get_forward_context, set_ascend_forward_context
+from vllm_ascend.attention.attention_v1 import AscendAttentionState
+from vllm_ascend.attention.utils import using_paged_attention
+from vllm_ascend.compilation.acl_graph import ACLGraphWrapper, update_full_graph_params
+from vllm_ascend.ops.rotary_embedding import update_cos_sin
+from vllm_ascend.utils import enable_sp, lmhead_tp_enable
+from vllm_ascend.worker.model_runner_v1 import SEQ_LEN_WITH_MAX_PA_WORKSPACE, NPUModelRunner
+
+# Omni-specific imports
+from vllm_omni.model_executor.models.output_templates import OmniOutput
+from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner
+from vllm_omni.outputs import OmniModelRunnerOutput
+from vllm_omni.distributed.omni_connectors.kv_transfer_manager import OmniKVTransferManager
+```
+
+### Step 7: Sync GPU-Side Omni Changes
+
+1. **Check recent GPU worker changes**:
+ ```bash
+ git diff .. -- vllm_omni/worker/gpu_model_runner.py
+ git diff .. -- vllm_omni/worker/gpu_ar_model_runner.py
+ ```
+
+2. **Identify new omni features** that need to be ported to NPU
+
+3. **Apply corresponding changes** to NPU runners
+
+### Step 8: Validation
+
+1. **Run type checking**:
+ ```bash
+ cd /root/vllm-workspace/vllm-omni
+ python -m py_compile vllm_omni/platforms/npu/worker/npu_model_runner.py
+ python -m py_compile vllm_omni/platforms/npu/worker/npu_ar_model_runner.py
+ python -m py_compile vllm_omni/platforms/npu/worker/npu_generation_model_runner.py
+ ```
+
+2. **Run import test**:
+ ```bash
+ python -c "from vllm_omni.platforms.npu.worker import *"
+ ```
+
+3. **Run model serving test** (if hardware available):
+ ```bash
+ vllm serve --trust-remote-code
+ ```
+
+## Common Pitfalls
+
+### 1. Forward Context Differences
+- GPU uses `set_forward_context`
+- NPU uses `set_ascend_forward_context`
+- Parameters may differ slightly
+
+### 2. Graph Wrapper Differences
+- GPU: `CUDAGraphWrapper`
+- NPU: `ACLGraphWrapper`
+- Constructor parameters may differ
+
+### 3. Buffer Creation
+- GPU: `_make_buffer` returns different structure
+- NPU: May need numpy=True/False parameter
+
+### 4. Attention Metadata
+- GPU: Uses vllm attention metadata builders
+- NPU: Uses `AscendCommonAttentionMetadata`
+
+### 5. Sampling
+- GPU: Uses vllm sampler
+- NPU: Uses `AscendSampler`
+
+## Checklist Before Commit
+
+- [ ] All omni-specific comment markers preserved
+- [ ] New omni logic from GPU side synced
+- [ ] Imports updated to latest vllm-ascend
+- [ ] No `CUDAGraphWrapper` references in NPU code
+- [ ] `set_ascend_forward_context` used instead of `set_forward_context`
+- [ ] `ACLGraphWrapper` used for talker_mtp wrapping
+- [ ] Type hints match vllm-ascend signatures
+- [ ] No duplicate code blocks
+- [ ] Python syntax valid (py_compile passes)
+
+## Reference Files for Comparison
+
+When upgrading, keep these files open for reference:
+
+1. **vllm-ascend NPUModelRunner**: `/root/vllm-workspace/vllm-ascend/vllm_ascend/worker/model_runner_v1.py`
+2. **vllm GPUModelRunner**: `/root/vllm-workspace/vllm/vllm/v1/worker/gpu_model_runner.py`
+3. **vllm-omni OmniGPUModelRunner**: `/root/vllm-workspace/vllm-omni/vllm_omni/worker/gpu_model_runner.py`
diff --git a/.claude/skills/vllm-omni-npu-upgrade/references/gpu-to-npu-translation.md b/.claude/skills/vllm-omni-npu-upgrade/references/gpu-to-npu-translation.md
new file mode 100644
index 000000000..89067d37b
--- /dev/null
+++ b/.claude/skills/vllm-omni-npu-upgrade/references/gpu-to-npu-translation.md
@@ -0,0 +1,335 @@
+# GPU to NPU Translation Patterns
+
+This document provides a quick reference for translating GPU code patterns to NPU equivalents when porting omni-specific logic.
+
+## Import Translations
+
+### Forward Context
+```python
+# GPU
+from vllm.forward_context import set_forward_context
+
+# NPU
+from vllm_ascend.ascend_forward_context import set_ascend_forward_context
+```
+
+### Graph Wrapper
+```python
+# GPU
+from vllm.compilation.cuda_graph import CUDAGraphWrapper
+
+# NPU
+from vllm_ascend.compilation.acl_graph import ACLGraphWrapper
+```
+
+### Attention State
+```python
+# GPU (no equivalent - uses FlashAttention states directly)
+
+# NPU
+from vllm_ascend.attention.attention_v1 import AscendAttentionState
+```
+
+### Utilities
+```python
+# GPU
+# (directly use torch.cuda functions)
+
+# NPU
+from vllm_ascend.utils import enable_sp, lmhead_tp_enable
+from vllm_ascend.ops.rotary_embedding import update_cos_sin
+```
+
+## Context Manager Translations
+
+### Forward Context Setup
+```python
+# GPU
+with set_forward_context(
+ attn_metadata,
+ self.vllm_config,
+ num_tokens=num_tokens_padded,
+ num_tokens_across_dp=num_tokens_across_dp,
+ cudagraph_runtime_mode=cudagraph_mode,
+ batch_descriptor=batch_desc,
+):
+ # forward pass
+
+# NPU
+with set_ascend_forward_context(
+ attn_metadata,
+ self.vllm_config,
+ num_tokens=num_tokens_padded,
+ num_tokens_across_dp=num_tokens_across_dp,
+ aclgraph_runtime_mode=cudagraph_mode, # Note: 'aclgraph' not 'cudagraph'
+ batch_descriptor=batch_desc,
+ num_actual_tokens=scheduler_output.total_num_scheduled_tokens,
+ model_instance=self.model,
+):
+ # forward pass
+```
+
+### Graph Capture Context
+```python
+# GPU
+from vllm.compilation.cuda_graph import graph_capture as cuda_graph_capture
+with cuda_graph_capture(self.device):
+ # capture
+
+# NPU
+from vllm_ascend.worker.model_runner_v1 import graph_capture
+with graph_capture(self.device):
+ # capture
+```
+
+## Graph Wrapper Usage
+
+### Creating Graph Wrapper
+```python
+# GPU
+if cudagraph_mode.has_full_cudagraphs() and has_separate_talker:
+ self.talker_mtp = CUDAGraphWrapper(
+ talker_mtp,
+ self.vllm_config,
+ runtime_mode=CUDAGraphMode.FULL
+ )
+
+# NPU
+if cudagraph_mode.has_full_cudagraphs() and has_separate_talker:
+ self.talker_mtp = ACLGraphWrapper(
+ talker_mtp,
+ self.vllm_config,
+ runtime_mode=CUDAGraphMode.FULL
+ )
+```
+
+### Checking Graph Wrapper Type
+```python
+# GPU
+if not isinstance(self.talker_mtp, CUDAGraphWrapper):
+ _cudagraph_mode = CUDAGraphMode.NONE
+
+# NPU
+if not isinstance(self.talker_mtp, ACLGraphWrapper):
+ _cudagraph_mode = CUDAGraphMode.NONE
+```
+
+## Device Operations
+
+### Synchronization
+```python
+# GPU
+torch.cuda.synchronize()
+
+# NPU
+torch.npu.synchronize()
+```
+
+### Stream Operations
+```python
+# GPU
+stream = torch.cuda.Stream(device=device)
+torch.cuda.current_stream()
+
+# NPU
+stream = torch.npu.Stream(device=device)
+torch.npu.current_stream()
+```
+
+## Attention Metadata
+
+### State Setting (NPU-specific)
+```python
+# GPU - handled internally by attention backends
+
+# NPU - explicit state setting required
+self.attn_state = AscendAttentionState.DecodeOnly
+if self.speculative_config and self.speculative_config.method == "mtp":
+ if self.vllm_config.model_config.use_mla:
+ self.attn_state = AscendAttentionState.SpecDecoding
+ else:
+ self.attn_state = AscendAttentionState.ChunkedPrefill
+```
+
+### Building Attention Metadata
+```python
+# GPU - uses vllm attention builders
+
+# NPU - may need additional parameters
+(attn_metadata, spec_decode_common_attn_metadata) = self._build_attention_metadata(
+ num_tokens=num_tokens_unpadded,
+ num_tokens_padded=num_tokens_padded,
+ num_reqs=num_reqs,
+ num_reqs_padded=num_reqs_padded,
+ max_query_len=max_num_scheduled_tokens,
+ ubatch_slices=ubatch_slices_attn,
+ logits_indices=logits_indices,
+ use_spec_decode=use_spec_decode,
+ num_scheduled_tokens=scheduler_output.num_scheduled_tokens,
+ num_scheduled_tokens_np=num_scheduled_tokens_np,
+ cascade_attn_prefix_lens=cascade_attn_prefix_lens,
+)
+```
+
+## Rotary Embedding
+
+### Update Cos/Sin Cache
+```python
+# GPU - typically handled inside attention
+
+# NPU - explicit update required before forward
+from vllm_ascend.ops.rotary_embedding import update_cos_sin
+update_cos_sin(positions)
+```
+
+## Sequence Parallelism
+
+### Enable SP Check
+```python
+# GPU - use vllm distributed utilities
+
+# NPU - use vllm-ascend wrapper
+from vllm_ascend.utils import enable_sp
+
+if enable_sp():
+ # sequence parallelism enabled
+```
+
+## Sampler
+
+### Sampler Type
+```python
+# GPU - uses vllm sampler
+self.sampler = Sampler()
+
+# NPU - uses AscendSampler
+from vllm_ascend.sample.sampler import AscendSampler
+self.sampler = AscendSampler()
+```
+
+## Input Batch
+
+### Batch Class
+```python
+# GPU
+from vllm.v1.worker.gpu_input_batch import InputBatch
+
+# NPU
+from vllm_ascend.worker.npu_input_batch import NPUInputBatch
+```
+
+## Graph Parameter Updates
+
+### Full Graph Params Update (NPU-specific)
+```python
+# GPU - not needed
+
+# NPU - required for FULL graph mode
+from vllm_ascend.compilation.acl_graph import update_full_graph_params
+
+forward_context = get_forward_context()
+if (
+ forward_context.cudagraph_runtime_mode == CUDAGraphMode.FULL
+ and not forward_context.capturing
+ and not self.use_sparse
+):
+ update_full_graph_params(
+ self.attn_backend,
+ self.update_stream,
+ forward_context,
+ num_tokens_padded,
+ self.vllm_config,
+ self.speculative_config,
+ positions.shape[0],
+ )
+```
+
+## Paged Attention Check
+
+```python
+# GPU - not typically needed
+
+# NPU
+from vllm_ascend.attention.utils import using_paged_attention
+
+if is_graph_capturing and using_paged_attention(num_tokens, self.vllm_config):
+ seq_lens = SEQ_LEN_WITH_MAX_PA_WORKSPACE
+```
+
+## Common Method Signature Differences
+
+### _dummy_run Parameters
+```python
+# GPU (v0.17.0)
+def _dummy_run(
+ self,
+ num_tokens: int,
+ cudagraph_runtime_mode: CUDAGraphMode | None = None,
+ force_attention: bool = False,
+ uniform_decode: bool = False,
+ allow_microbatching: bool = True,
+ skip_eplb: bool = False,
+ is_profile: bool = False,
+ create_mixed_batch: bool = False,
+ remove_lora: bool = True,
+ is_graph_capturing: bool = False,
+ num_active_loras: int = 0,
+) -> tuple[torch.Tensor, torch.Tensor]:
+
+# NPU (v0.17.0) - adds with_prefill, activate_lora
+def _dummy_run(
+ self,
+ num_tokens: int,
+ with_prefill: bool = False,
+ cudagraph_runtime_mode: CUDAGraphMode | None = None,
+ force_attention: bool = False,
+ uniform_decode: bool = False,
+ is_profile: bool = False,
+ create_mixed_batch: bool = False,
+ allow_microbatching: bool = True,
+ skip_eplb: bool = False,
+ remove_lora: bool = True,
+ activate_lora: bool = False,
+ is_graph_capturing: bool = False,
+ num_active_loras: int = 0,
+) -> tuple[torch.Tensor, torch.Tensor]:
+```
+
+### _model_forward Parameters
+```python
+# GPU - no num_tokens_padded
+def _model_forward(
+ self,
+ input_ids: torch.Tensor | None = None,
+ positions: torch.Tensor | None = None,
+ intermediate_tensors: IntermediateTensors | None = None,
+ inputs_embeds: torch.Tensor | None = None,
+ **model_kwargs: dict[str, Any],
+):
+
+# NPU - has num_tokens_padded as first parameter
+def _model_forward(
+ self,
+ num_tokens_padded: int,
+ input_ids: torch.Tensor | None = None,
+ positions: torch.Tensor | None = None,
+ intermediate_tensors: IntermediateTensors | None = None,
+ inputs_embeds: torch.Tensor | None = None,
+ **model_kwargs: dict[str, Any],
+):
+```
+
+## Quick Reference Table
+
+| Feature | GPU | NPU |
+|---------|-----|-----|
+| Graph wrapper | `CUDAGraphWrapper` | `ACLGraphWrapper` |
+| Forward context | `set_forward_context` | `set_ascend_forward_context` |
+| Runtime mode param | `cudagraph_runtime_mode` | `aclgraph_runtime_mode` |
+| Device sync | `torch.cuda.synchronize()` | `torch.npu.synchronize()` |
+| Stream | `torch.cuda.Stream` | `torch.npu.Stream` |
+| Current stream | `torch.cuda.current_stream()` | `torch.npu.current_stream()` |
+| Input batch | `InputBatch` | `NPUInputBatch` |
+| Sampler | `Sampler` | `AscendSampler` |
+| Attention state | N/A | `AscendAttentionState` |
+| RoPE update | N/A | `update_cos_sin()` |
diff --git a/.claude/skills/vllm-omni-npu-upgrade/references/omni-specific-blocks.md b/.claude/skills/vllm-omni-npu-upgrade/references/omni-specific-blocks.md
new file mode 100644
index 000000000..8c5d32ab4
--- /dev/null
+++ b/.claude/skills/vllm-omni-npu-upgrade/references/omni-specific-blocks.md
@@ -0,0 +1,374 @@
+# Omni-Specific Code Blocks Reference
+
+This document catalogs omni-specific code blocks in the NPU model runners, making it easier to identify what needs to be preserved during upgrades.
+
+> **IMPORTANT**: This document may not be complete or up-to-date!
+>
+> - Always grep for `Omni-new` in the GPU implementations (`vllm_omni/worker/`) to find the authoritative list
+> - New omni features may be added that are not yet documented here
+> - When you discover new omni-specific blocks during an upgrade, please update this document
+> - Last verified: Check git history for this file
+
+## OmniNPUModelRunner (npu_model_runner.py)
+
+### load_model - Talker MTP Initialization
+
+```python
+def load_model(self, *args, **kwargs) -> None:
+ NPUModelRunner.load_model(self, *args, **kwargs)
+ # Initialize enable_sp cache to avoid get_current_vllm_config() error
+ # in _pad_for_sequence_parallelism during execute_model.
+ # This is a workaround for vllm-ascend not passing vllm_config to enable_sp().
+ enable_sp(self.vllm_config)
+ # TODO move this model specific logic to a separate class
+ # TTS model IS the talker (no .talker sub-attr); use getattr to support both Omni and TTS.
+ talker_mtp = getattr(self.model, "talker_mtp", None)
+ if talker_mtp is not None:
+ self.talker_mtp = talker_mtp # type: ignore[assignment]
+ cudagraph_mode = self.compilation_config.cudagraph_mode
+ assert cudagraph_mode is not None
+ # Only wrap talker_mtp in CUDAGraphWrapper for Omni models that
+ # have a separate .talker sub-module. TTS models' code predictor
+ # has internal AR loops / torch.multinomial — not graph-safe.
+ has_separate_talker = getattr(self.model, "talker", None) is not None
+ if cudagraph_mode.has_full_cudagraphs() and has_separate_talker:
+ # NOTE: Use ACLGraphWrapper on NPU, not CUDAGraphWrapper
+ self.talker_mtp = ACLGraphWrapper(talker_mtp, self.vllm_config, runtime_mode=CUDAGraphMode.FULL)
+ # TTS exposes mtp_hidden_size; Omni uses hf_text_config.hidden_size.
+ hidden_size = int(
+ getattr(self.model, "mtp_hidden_size", 0) or getattr(self.model_config.hf_text_config, "hidden_size")
+ )
+ max_batch_size = max(self.max_num_reqs, self.compilation_config.max_cudagraph_capture_size)
+ self.talker_mtp_input_ids = self._make_buffer(max_batch_size, dtype=torch.int32)
+ self.talker_mtp_inputs_embeds = self._make_buffer(
+ max_batch_size, hidden_size, dtype=self.dtype, numpy=False
+ )
+ self.last_talker_hidden = self._make_buffer(max_batch_size, hidden_size, dtype=self.dtype, numpy=False)
+ self.text_step = self._make_buffer(max_batch_size, hidden_size, dtype=self.dtype, numpy=False)
+```
+
+### _dummy_run - Talker MTP Dummy Forward
+
+Location: Inside `set_ascend_forward_context` block, before main model forward
+
+```python
+# ---------------------------------------Omni-new----------------------------------------------
+if getattr(self.model, "talker", None) is not None and hasattr(self.model, "talker_mtp"):
+ num_tokens_padded_talker_mtp = num_tokens_padded
+ if num_tokens_padded_talker_mtp == self.max_num_tokens:
+ num_tokens_padded_talker_mtp = self.talker_mtp_input_ids.gpu.shape[0]
+ outputs = self.talker_mtp(
+ self.talker_mtp_input_ids.gpu[:num_tokens_padded_talker_mtp],
+ self.talker_mtp_inputs_embeds.gpu[:num_tokens_padded_talker_mtp],
+ self.last_talker_hidden.gpu[:num_tokens_padded_talker_mtp],
+ self.text_step.gpu[:num_tokens_padded_talker_mtp],
+ )
+ self.compilation_config.cache_dir = None
+# ---------------------------------------Omni-new----------------------------------------------
+```
+
+### _dummy_run - Extract Multimodal Outputs
+
+Location: After model forward, before dummy_compute_logits
+
+```python
+# ---------------------------------------Omni-new----------------------------------------------
+hidden_states, multimodal_outputs = self.extract_multimodal_outputs(hidden_states)
+# ---------------------------------------Omni-new----------------------------------------------
+```
+
+### _model_forward - Omni Output Wrapping
+
+```python
+def _model_forward(
+ self,
+ num_tokens_padded: int,
+ input_ids: torch.Tensor | None = None,
+ positions: torch.Tensor | None = None,
+ intermediate_tensors: IntermediateTensors | None = None,
+ inputs_embeds: torch.Tensor | None = None,
+ **model_kwargs: dict[str, Any],
+):
+ """Override to combine NPUModelRunner's signature with OmniGPUModelRunner's logic."""
+ # Omni-specific: build and inject extra model kwargs
+ model_kwargs_extra = self._build_model_kwargs_extra()
+
+ # Call the model forward (same as NPUModelRunner)
+ assert self.model is not None
+ model_output = self.model(
+ input_ids=input_ids,
+ positions=positions,
+ intermediate_tensors=intermediate_tensors,
+ inputs_embeds=inputs_embeds,
+ **model_kwargs,
+ **model_kwargs_extra,
+ )
+
+ # Omni-specific: wrap output if needed
+ if not isinstance(model_output, OmniOutput) and hasattr(self.model, "make_omni_output"):
+ model_output = self.model.make_omni_output(model_output, **model_kwargs_extra)
+
+ # Omni-specific: cache model output for later sample_tokens
+ self._omni_last_model_output = model_output
+
+ # NPU-specific: update full graph params (keep from vllm-ascend)
+ forward_context = get_forward_context()
+ # ... NPU graph update logic ...
+
+ # NPU-specific: all-gather for sequence parallelism (keep from vllm-ascend)
+ if get_forward_context().sp_enabled and not isinstance(model_output, IntermediateTensors):
+ model_output = self._all_gather_hidden_states_and_aux(model_output)
+
+ return model_output
+```
+
+---
+
+## NPUARModelRunner (npu_ar_model_runner.py)
+
+### __init__ - KV Transfer Manager
+
+```python
+def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.input_ids = self._make_buffer(self.max_num_tokens, dtype=torch.int32)
+ # each model stage has their own hidden size
+ self.hidden_size = self.model_config.hf_text_config.hidden_size
+ self.inputs_embeds = self._make_buffer(self.max_num_tokens, self.hidden_size, dtype=self.dtype, numpy=False)
+ # Initialize KV cache manager (preserve vllm_config fallback behavior)
+ self.kv_transfer_manager = OmniKVTransferManager.from_vllm_config(self.vllm_config, self.model_config)
+```
+
+### execute_model - KV Transfer Before Update States
+
+Location: At the very beginning of execute_model
+
+```python
+# -------------------------------------- Omni-new -------------------------------------------------
+# [Omni] Handle KV transfer BEFORE updating states (which removes finished requests)
+self.kv_extracted_req_ids = self.kv_transfer_manager.handle_finished_requests_kv_transfer(
+ finished_reqs=getattr(scheduler_output, "finished_requests_needing_kv_transfer", {}),
+ kv_caches=self.kv_caches,
+ block_size=self.cache_config.block_size,
+ cache_dtype=str(self.cache_config.cache_dtype),
+ request_id_resolver=self._resolve_global_request_id,
+)
+# -------------------------------------- Omni-new -------------------------------------------------
+```
+
+### execute_model - Custom _update_states Call
+
+Location: Inside synchronize_input_prep context
+
+```python
+# -------------------------------------- Omni-new -------------------------------------------------
+self._update_states(scheduler_output)
+# ------------------------------------------------------------------------------------------------
+```
+
+### execute_model - Extract Multimodal Outputs
+
+Location: In post process section, after hidden_states assignment
+
+```python
+# -------------------------------------- Omni-new -------------------------------------------------
+hidden_states, multimodal_outputs = self.extract_multimodal_outputs(hidden_states)
+
+if multimodal_outputs is not None:
+ keys_or_type = (
+ list(multimodal_outputs.keys())
+ if isinstance(multimodal_outputs, dict)
+ else type(multimodal_outputs)
+ )
+ logger.debug(f"[AR] execute_model: multimodal_outputs keys = {keys_or_type}")
+else:
+ logger.debug("[AR] execute_model: multimodal_outputs is None")
+# -------------------------------------- Omni-new -------------------------------------------------
+```
+
+### execute_model - Compute Logits with sampling_metadata
+
+Location: In both broadcast_pp_output True and False branches
+
+```python
+# -------------------------------------- Omni-new -------------------------------------------------
+# Try with sampling_metadata first; fall back to without for models that don't support it
+try:
+ logits = self.model.compute_logits(
+ sample_hidden_states, sampling_metadata=self.input_batch.sampling_metadata
+ )
+except TypeError:
+ logits = self.model.compute_logits(sample_hidden_states)
+# -------------------------------------- Omni-new -------------------------------------------------
+```
+
+### sample_tokens - KV Extracted Req IDs
+
+Location: At the beginning of sample_tokens
+
+```python
+# -------------------------------------- Omni-new -------------------------------------------------
+kv_extracted_req_ids = getattr(self, "kv_extracted_req_ids", None)
+self.kv_extracted_req_ids = None
+# -------------------------------------- Omni-new -------------------------------------------------
+```
+
+### sample_tokens - Process Additional Information and Build Output
+
+Location: After bookkeeping sync, replacing the original output construction
+
+```python
+# -------------------------------------- Omni-new -------------------------------------------------
+hidden_states_cpu = hidden_states.detach().to("cpu").contiguous()
+num_scheduled_tokens_np = getattr(self, "_omni_num_scheduled_tokens_np", None)
+if num_scheduled_tokens_np is None:
+ req_ids = self.input_batch.req_ids
+ num_scheduled_tokens_np = np.array(
+ [scheduler_output.num_scheduled_tokens[rid] for rid in req_ids],
+ dtype=np.int32,
+ )
+
+self._process_additional_information_updates(
+ hidden_states, multimodal_outputs, num_scheduled_tokens_np, scheduler_output
+)
+
+pooler_output: list[dict[str, object]] = []
+for rid in req_ids_output_copy:
+ idx = req_id_to_index_output_copy[rid]
+ start = int(self.query_start_loc.cpu[idx])
+ sched = int(num_scheduled_tokens_np[idx])
+ end = start + sched
+ hidden_slice = hidden_states_cpu[start:end]
+ payload: dict[str, object] = {"hidden": hidden_slice}
+ if isinstance(multimodal_outputs, dict) and multimodal_outputs:
+ # ... multimodal output slicing logic ...
+ pooler_output.append(payload)
+
+model_runner_output = OmniModelRunnerOutput(
+ req_ids=req_ids_output_copy,
+ req_id_to_index=req_id_to_index_output_copy,
+ sampled_token_ids=valid_sampled_token_ids,
+ logprobs=logprobs_lists,
+ prompt_logprobs_dict=prompt_logprobs_dict,
+ pooler_output=(pooler_output if self.vllm_config.model_config.engine_output_type != "text" else None),
+ kv_connector_output=kv_connector_output,
+)
+model_runner_output.kv_extracted_req_ids = kv_extracted_req_ids
+# -------------------------------------- Omni-new -------------------------------------------------
+```
+
+---
+
+## NPUGenerationModelRunner (npu_generation_model_runner.py)
+
+### execute_model - Async Chunk Update
+
+Location: Inside prepare input section, before synchronize_input_prep
+
+```python
+# -------------------------------------- Omni-new -------------------------------------------------
+if self.model_config.async_chunk and num_scheduled_tokens:
+ self._update_request_states(scheduler_output)
+# -------------------------------------- Omni-new -------------------------------------------------
+```
+
+### execute_model - Seq Token Counts
+
+Location: After _preprocess call
+
+```python
+# [Omni] Pass token counts per request for code2wav output slicing
+model_kwargs["seq_token_counts"] = tokens
+```
+
+### execute_model - Run Generation Model
+
+Location: Inside forward context
+
+```python
+# -------------------------------------- Omni-new -------------------------------------------------
+outputs = self._run_generation_model(
+ num_tokens_padded=num_tokens_padded,
+ input_ids=input_ids,
+ positions=positions,
+ intermediate_tensors=intermediate_tensors,
+ inputs_embeds=inputs_embeds,
+ model_kwargs=model_kwargs,
+ logits_indices=logits_indices,
+)
+_, multimodal_outputs = self.extract_multimodal_outputs(outputs)
+# -------------------------------------- Omni-new -------------------------------------------------
+```
+
+### sample_tokens - Multimodal Output Processing
+
+The entire sample_tokens method body is omni-specific for generation models:
+
+```python
+# -------------------------------------- Omni-new -------------------------------------------------
+pooler_output: list[object] = []
+if isinstance(multimodal_outputs, torch.Tensor):
+ # ... tensor handling ...
+elif isinstance(multimodal_outputs, list):
+ # ... list handling ...
+elif isinstance(multimodal_outputs, dict):
+ # ... dict handling per request ...
+else:
+ raise RuntimeError("Unsupported diffusion output type")
+# [Omni] Copy req_id mappings to avoid async scheduling mutation.
+req_ids_output_copy = self.input_batch.req_ids.copy()
+req_id_to_index_output_copy = self.input_batch.req_id_to_index.copy()
+output = OmniModelRunnerOutput(
+ req_ids=req_ids_output_copy,
+ req_id_to_index=req_id_to_index_output_copy,
+ sampled_token_ids=[],
+ logprobs=None,
+ prompt_logprobs_dict={},
+ pooler_output=pooler_output,
+ kv_connector_output=kv_connector_output,
+ num_nans_in_logits={},
+ ec_connector_output=ec_connector_output if self.supports_mm_inputs else None,
+)
+# -------------------------------------- Omni-new -------------------------------------------------
+```
+
+### _dummy_run - Model Kwargs Init and Multimodal Extract
+
+Location: Before model forward and after
+
+```python
+model_kwargs = self._init_model_kwargs() # Before forward
+
+# ... forward ...
+
+# -------------------------------------- Omni-new -------------------------------------------------
+hidden_states, _ = self.extract_multimodal_outputs(hidden_states)
+# -------------------------------------------------------------------------------------------------
+```
+
+---
+
+## ExecuteModelState Extension
+
+The `ExecuteModelState` NamedTuple is extended for omni:
+
+```python
+class ExecuteModelState(NamedTuple):
+ """Ephemeral cached state transferred between execute_model() and
+ sample_tokens(), after execute_model() returns None."""
+
+ scheduler_output: SchedulerOutput
+ logits: torch.Tensor
+ spec_decode_metadata: SpecDecodeMetadata | None
+ spec_decode_common_attn_metadata: AscendCommonAttentionMetadata | None
+ hidden_states: torch.Tensor
+ sample_hidden_states: torch.Tensor
+ aux_hidden_states: list[torch.Tensor] | None
+ attn_metadata: PerLayerAttnMetadata
+ positions: torch.Tensor
+ ec_connector_output: ECConnectorOutput | None
+ cudagraph_stats: CUDAGraphStat | None
+ multimodal_outputs: Any # <-- Omni extension
+```
+
+This extended state must be imported from `npu_ar_model_runner` in `npu_generation_model_runner`.
diff --git a/.claude/skills/vllm-omni-npu-upgrade/references/workflow-checklist.md b/.claude/skills/vllm-omni-npu-upgrade/references/workflow-checklist.md
new file mode 100644
index 000000000..4f184df0e
--- /dev/null
+++ b/.claude/skills/vllm-omni-npu-upgrade/references/workflow-checklist.md
@@ -0,0 +1,222 @@
+# NPU Model Runner Upgrade Workflow Checklist
+
+> **Note**: Reference documents (`omni-specific-blocks.md`) may not be complete. Always grep for `Omni-new` in GPU implementations to find all omni-specific code blocks. Update the reference docs when discovering new blocks.
+
+## Pre-Upgrade Preparation
+
+### 1. Version Information
+- [ ] Identify current vllm-omni version: `_________`
+- [ ] Identify target vllm-ascend version: `_________`
+- [ ] Identify target vllm version: `_________`
+- [ ] Last release date for GPU worker changes: `_________`
+
+### 2. Gather Git History
+```bash
+# GPU-side omni changes since last release
+cd /root/vllm-workspace/vllm-omni
+git log --oneline --since="YYYY-MM-DD" -- vllm_omni/worker/
+
+# vllm-ascend NPUModelRunner changes
+cd /root/vllm-workspace/vllm-ascend
+git log --oneline .. -- vllm_ascend/worker/model_runner_v1.py
+```
+
+### 3. Backup Current Files
+- [ ] Create backup of current NPU runners:
+ ```bash
+ cp -r vllm_omni/platforms/npu/worker vllm_omni/platforms/npu/worker.backup
+ ```
+
+---
+
+## OmniNPUModelRunner (npu_model_runner.py)
+
+### Read and Understand
+- [ ] Read current `npu_model_runner.py`
+- [ ] Read latest `vllm_ascend/worker/model_runner_v1.py`
+- [ ] Read latest `vllm_omni/worker/gpu_model_runner.py`
+
+### Method: load_model
+- [ ] Document existing omni-specific logic
+- [ ] Copy latest NPUModelRunner.load_model structure
+- [ ] Re-insert: `enable_sp(self.vllm_config)` call
+- [ ] Re-insert: talker_mtp detection and setup
+- [ ] Replace: `CUDAGraphWrapper` → `ACLGraphWrapper`
+- [ ] Re-insert: Buffer allocations (talker_mtp_input_ids, etc.)
+
+### Method: _dummy_run
+- [ ] Document existing omni-specific logic locations
+- [ ] Copy latest NPUModelRunner._dummy_run
+- [ ] Re-insert: talker_mtp dummy forward block (inside context)
+- [ ] Re-insert: `extract_multimodal_outputs` call
+- [ ] Verify: Comment markers are present
+
+### Method: _model_forward
+- [ ] Copy latest NPUModelRunner._model_forward structure
+- [ ] Re-insert: `_build_model_kwargs_extra()` call
+- [ ] Re-insert: OmniOutput wrapping logic
+- [ ] Re-insert: `_omni_last_model_output` caching
+- [ ] Keep: NPU graph params update
+- [ ] Keep: SP all-gather logic
+
+### Method: _talker_mtp_forward
+- [ ] Verify: Uses `set_ascend_forward_context`
+- [ ] Verify: Uses `ACLGraphWrapper` check
+- [ ] Sync any changes from GPU `_talker_mtp_forward`
+
+### Imports
+- [ ] Update vllm-ascend imports to latest paths
+- [ ] Verify all omni imports are present
+- [ ] Remove any deprecated imports
+
+---
+
+## NPUARModelRunner (npu_ar_model_runner.py)
+
+### Read and Understand
+- [ ] Read current `npu_ar_model_runner.py`
+- [ ] Read latest `vllm_ascend/worker/model_runner_v1.py` execute_model
+- [ ] Read latest `vllm_omni/worker/gpu_ar_model_runner.py`
+
+### Method: __init__
+- [ ] Sync any new initialization from GPU side
+- [ ] Keep: `OmniKVTransferManager` setup
+- [ ] Keep: Custom buffer allocations
+
+### Method: execute_model
+- [ ] Document all omni blocks with line numbers
+- [ ] Copy latest NPUModelRunner.execute_model structure
+- [ ] Re-insert: KV transfer handling (beginning)
+- [ ] Re-insert: Custom `_update_states` call
+- [ ] Re-insert: `extract_multimodal_outputs`
+- [ ] Re-insert: `compute_logits` with sampling_metadata try/except
+- [ ] Update: ExecuteModelState to include multimodal_outputs
+
+### Method: sample_tokens
+- [ ] Document all omni blocks
+- [ ] Copy latest NPUModelRunner.sample_tokens structure
+- [ ] Re-insert: `kv_extracted_req_ids` handling
+- [ ] Re-insert: Hidden states CPU copy
+- [ ] Re-insert: `_process_additional_information_updates`
+- [ ] Re-insert: `OmniModelRunnerOutput` construction
+
+### ExecuteModelState
+- [ ] Verify: `multimodal_outputs` field is present
+- [ ] Verify: Imported/used correctly in execute_model
+
+### Imports
+- [ ] Update all vllm-ascend imports
+- [ ] Keep omni-specific imports
+
+---
+
+## NPUGenerationModelRunner (npu_generation_model_runner.py)
+
+### Read and Understand
+- [ ] Read current `npu_generation_model_runner.py`
+- [ ] Read latest GPU `gpu_generation_model_runner.py`
+
+### Method: _update_request_states
+- [ ] Verify: async_chunk handling is correct
+- [ ] Sync any changes from GPU side
+
+### Method: execute_model
+- [ ] Document all omni blocks
+- [ ] Copy latest NPUModelRunner.execute_model base structure
+- [ ] Re-insert: async_chunk update logic
+- [ ] Re-insert: `seq_token_counts` injection
+- [ ] Re-insert: `_run_generation_model` call
+- [ ] Re-insert: `extract_multimodal_outputs`
+- [ ] Use: ExecuteModelState from npu_ar_model_runner
+
+### Method: sample_tokens
+- [ ] Keep: Entire omni multimodal output processing
+- [ ] Update: Any new output fields needed
+- [ ] Keep: `OmniModelRunnerOutput` construction
+
+### Method: _run_generation_model
+- [ ] Sync any changes from GPU side
+- [ ] Keep: `_model_forward` call with sampler
+
+### Method: _dummy_run
+- [ ] Copy latest NPUModelRunner._dummy_run
+- [ ] Re-insert: `model_kwargs = self._init_model_kwargs()`
+- [ ] Re-insert: `extract_multimodal_outputs` at end
+
+### Imports
+- [ ] Import ExecuteModelState from npu_ar_model_runner
+- [ ] Update vllm-ascend imports
+
+---
+
+## Post-Upgrade Validation
+
+### Syntax Validation
+- [ ] `python -m py_compile vllm_omni/platforms/npu/worker/npu_model_runner.py`
+- [ ] `python -m py_compile vllm_omni/platforms/npu/worker/npu_ar_model_runner.py`
+- [ ] `python -m py_compile vllm_omni/platforms/npu/worker/npu_generation_model_runner.py`
+
+### Import Validation
+- [ ] `python -c "from vllm_omni.platforms.npu.worker.npu_model_runner import OmniNPUModelRunner"`
+- [ ] `python -c "from vllm_omni.platforms.npu.worker.npu_ar_model_runner import NPUARModelRunner"`
+- [ ] `python -c "from vllm_omni.platforms.npu.worker.npu_generation_model_runner import NPUGenerationModelRunner"`
+
+### Comment Markers
+- [ ] Grep for "Omni-new" in all three files
+- [ ] Verify all omni blocks have closing markers
+
+### Code Review
+- [ ] No `CUDAGraphWrapper` references
+- [ ] All `set_forward_context` replaced with `set_ascend_forward_context`
+- [ ] Parameter names correct (`aclgraph_runtime_mode` not `cudagraph_runtime_mode`)
+- [ ] No duplicate code blocks
+- [ ] No missing imports
+
+---
+
+## Git Commit
+
+### Commit Message Template
+```
+[NPU] Upgrade model runners to align with vllm-ascend vX.Y.Z
+
+- Update OmniNPUModelRunner with latest NPUModelRunner base
+- Update NPUARModelRunner execute_model and sample_tokens
+- Update NPUGenerationModelRunner for async_chunk changes
+- Sync GPU-side omni changes from vX.Y.Z release
+- Preserve all omni-specific logic (marked with Omni-new comments)
+
+Changes from vllm-ascend:
+-
+
+Changes synced from GPU:
+-
+```
+
+### Files to Stage
+- [ ] `vllm_omni/platforms/npu/worker/npu_model_runner.py`
+- [ ] `vllm_omni/platforms/npu/worker/npu_ar_model_runner.py`
+- [ ] `vllm_omni/platforms/npu/worker/npu_generation_model_runner.py`
+- [ ] Any other modified files
+
+---
+
+## Troubleshooting
+
+### Import Errors
+- Check if vllm-ascend module paths have changed
+- Verify PYTHONPATH includes both vllm-ascend and vllm-omni
+
+### Type Errors
+- Check method signatures match between GPU and NPU
+- Verify NamedTuple fields match expected structure
+
+### Runtime Errors
+- Enable debug logging: `export VLLM_LOGGING_LEVEL=DEBUG`
+- Check graph capture issues: try `--enforce-eager`
+- Check attention issues: verify AscendAttentionState usage
+
+### Performance Regression
+- Compare with previous version on same model
+- Check if graph capture is working: look for ACLGraph logs
+- Verify SP/EP configurations are correct
diff --git a/.github/scripts/pr_reviewer.py b/.github/scripts/pr_reviewer.py
deleted file mode 100755
index da629a645..000000000
--- a/.github/scripts/pr_reviewer.py
+++ /dev/null
@@ -1,629 +0,0 @@
-#!/usr/bin/env python3
-"""
-PR Reviewer using GLM API for vllm-omni project.
-"""
-
-import json
-import logging
-import os
-import sys
-import time
-from dataclasses import dataclass
-from typing import Any, TypedDict
-
-import requests
-
-
-# Type definitions for API responses
-class PRDetails(TypedDict):
- """Type definition for GitHub PR details response."""
-
- title: str
- body: str
- number: int
- state: str
- user: dict[str, Any]
-
-
-class GLMMessage(TypedDict):
- """Type definition for GLM API message."""
-
- role: str
- content: str
-
-
-class GLMChoice(TypedDict):
- """Type definition for GLM API choice."""
-
- message: GLMMessage
- finish_reason: str
-
-
-class GLMResponse(TypedDict):
- """Type definition for GLM API response."""
-
- choices: list[GLMChoice]
- usage: dict[str, int] | None
-
-
-class GitHubComment(TypedDict):
- """Type definition for GitHub comment."""
-
- id: int
- body: str
- created_at: str
- user: dict[str, Any]
-
-
-# Configuration
-TRIGGER_PHRASE: str = "@vllm-omni-reviewer"
-DEFAULT_GLM_API_URL: str = "https://open.bigmodel.cn/api/paas/v4/chat/completions" # noqa: E501
-DEFAULT_GLM_MODEL: str = "glm-5"
-DEFAULT_COOLDOWN_MINUTES: int = 5
-DEFAULT_MAX_RETRIES: int = 3
-DEFAULT_RETRY_DELAY: float = 1.0
-MAX_DIFF_SIZE: int = 100_000 # Maximum diff size in characters
-
-
-@dataclass
-class Config:
- """Configuration for the PR reviewer."""
-
- glm_api_url: str
- glm_model: str
- cooldown_minutes: int
- max_retries: int
- retry_delay: float
- max_diff_size: int
-
-
-# Setup logging
-logging.basicConfig(
- level=logging.INFO,
- format="[PR Reviewer] %(asctime)s - %(levelname)s - %(message)s",
- datefmt="%Y-%m-%d %H:%M:%S",
-)
-logger: logging.Logger = logging.getLogger(__name__)
-
-
-def get_config() -> Config:
- """Load configuration from environment variables with defaults."""
- return Config(
- glm_api_url=os.getenv("GLM_API_URL", DEFAULT_GLM_API_URL),
- glm_model=os.getenv("GLM_MODEL", DEFAULT_GLM_MODEL),
- cooldown_minutes=int(
- os.getenv(
- "PR_REVIEWER_COOLDOWN_MINUTES",
- str(DEFAULT_COOLDOWN_MINUTES),
- )
- ),
- max_retries=int(
- os.getenv(
- "PR_REVIEWER_MAX_RETRIES",
- str(DEFAULT_MAX_RETRIES),
- )
- ),
- retry_delay=float(os.getenv("PR_REVIEWER_RETRY_DELAY", str(DEFAULT_RETRY_DELAY))),
- max_diff_size=int(os.getenv("PR_REVIEWER_MAX_DIFF_SIZE", str(MAX_DIFF_SIZE))), # noqa: E501
- )
-
-
-def get_env_var(name: str) -> str:
- """
- Get an environment variable or raise an error.
-
- Args:
- name: Name of the environment variable.
-
- Returns:
- The value of the environment variable.
-
- Raises:
- SystemExit: If the environment variable is not set.
- """
- value = os.environ.get(name)
- if not value:
- logger.error(f"Environment variable {name} is not set")
- sys.exit(1)
- return value
-
-
-def check_trigger(comment_body: str) -> bool:
- """
- Check if the comment contains the trigger phrase.
-
- Args:
- comment_body: The body of the comment to check.
-
- Returns:
- True if the trigger phrase is found, False otherwise.
- """
- return TRIGGER_PHRASE in comment_body
-
-
-def fetch_pr_diff(
- repo_name: str,
- pr_number: int,
- token: str,
- max_size: int = MAX_DIFF_SIZE,
-) -> str | None:
- """
- Fetch the diff for a pull request.
-
- Args:
- repo_name: The repository name in format "owner/repo".
- pr_number: The pull request number.
- token: GitHub authentication token.
- max_size: Maximum diff size in characters.
-
- Returns:
- The diff content as a string, or None if fetching failed.
- Returns empty string if diff is larger than max_size.
- """
- url: str = f"https://api.github.com/repos/{repo_name}/pulls/{pr_number}"
- headers: dict[str, str] = {
- "Authorization": f"Bearer {token}",
- "Accept": "application/vnd.github.v3.diff",
- }
-
- logger.info(f"Fetching PR diff from {url}")
- response = requests.get(url, headers=headers, timeout=30)
-
- if response.status_code == 200:
- diff: str = response.text
- if len(diff) > max_size:
- logger.warning(
- f"Diff size ({len(diff)} bytes) exceeds maximum "
- f"({max_size} bytes), truncating to first "
- f"{max_size} bytes"
- )
- return diff[:max_size] + "\n\n... [Diff truncated due to size] ..."
- logger.info(f"Successfully fetched diff ({len(diff)} bytes)")
- return diff
- else:
- logger.error(f"Failed to fetch PR diff: {response.status_code}")
- logger.error(f"Response: {response.text}")
- return None
-
-
-def fetch_pr_details(
- repo_name: str,
- pr_number: int,
- token: str,
-) -> PRDetails | None:
- """
- Fetch PR details including title and description.
-
- Args:
- repo_name: The repository name in format "owner/repo".
- pr_number: The pull request number.
- token: GitHub authentication token.
-
- Returns:
- A dictionary containing PR details, or None if fetching failed.
- """
- url: str = f"https://api.github.com/repos/{repo_name}/pulls/{pr_number}"
- headers: dict[str, str] = {
- "Authorization": f"Bearer {token}",
- "Accept": "application/vnd.github.v3+json",
- }
-
- logger.info(f"Fetching PR details from {url}")
- response = requests.get(url, headers=headers, timeout=30)
-
- if response.status_code == 200:
- return response.json()
- else:
- logger.error(f"Failed to fetch PR details: {response.status_code}")
- return None
-
-
-def build_review_prompt(pr_title: str, pr_description: str, diff: str) -> str:
- """
- Build the prompt for the GLM-4.7 API.
-
- Args:
- pr_title: The title of the pull request.
- pr_description: The description/body of the pull request.
- diff: The diff content of the pull request.
-
- Returns:
- The formatted prompt string for the API.
- """
- return f"""You are an expert code reviewer for the VLLM-Omni project. \
-Please review the following pull request:
-
-## Pull Request Details
-**Title:** {pr_title}
-
-**Description:**
-{pr_description if pr_description else "No description provided."}
-
-## Code Changes (Diff)
-{diff}
-
-## Review Guidelines
-
-Please provide a comprehensive code review with the following sections:
-
-### 1. Overview
-- Brief summary of the changes
-- Overall assessment (positive, neutral, or concerns)
-
-### 2. Code Quality
-- Code style and consistency
-- Potential bugs or edge cases
-- Performance considerations
-- Error handling
-
-### 3. Architecture & Design
-- Integration with existing codebase
-- Design patterns and best practices
-- Potential improvements
-
-### 4. Security & Safety
-- Security concerns (if any)
-- Resource management
-- Input validation
-
-### 5. Testing & Documentation
-- Test coverage considerations
-- Documentation completeness
-- Examples and usage clarity
-
-### 6. Specific Suggestions
-- Line-by-line specific feedback (use `file:line` format)
-- Concrete actionable suggestions
-- Code examples for improvements (if applicable)
-
-### 7. Approval Status
-- **LGTM** (Looks Good To Me) if the PR is ready to merge
-- **LGTM with suggestions** if the PR is good but has minor suggestions
-- **Changes requested** if significant changes are needed
-
-## Important Notes
-- Be constructive and helpful
-- Focus on objective technical feedback
-- Acknowledge good practices when you see them
-- Prioritize critical issues over nitpicks
-- If the diff is empty or minimal, acknowledge this and provide
- any relevant context-specific guidance
-
-Please format your response in Markdown with clear section headers.
-"""
-
-
-def validate_glm_response(data: dict[str, Any]) -> str | None:
- """
- Validate and extract content from GLM API response.
-
- Args:
- data: The response data from GLM API.
-
- Returns:
- The review content string if valid, None otherwise.
- """
- # Check if choices exists and is a non-empty list
- if "choices" not in data:
- logger.error("GLM API response missing 'choices' field")
- logger.error(f"Response structure: {json.dumps(data, indent=2)}")
- return None
-
- choices = data["choices"]
- if not isinstance(choices, list):
- logger.error(f"GLM API 'choices' is not a list: {type(choices)}")
- return None
-
- if len(choices) == 0:
- logger.error("GLM API 'choices' is an empty list")
- return None
-
- # Check if first choice has message
- try:
- first_choice = choices[0]
- if not isinstance(first_choice, dict):
- logger.error(f"GLM API choice is not a dict: {type(first_choice)}")
- return None
-
- if "message" not in first_choice:
- logger.error("GLM API choice missing 'message' field")
- logger.error(f"Choice structure: {json.dumps(first_choice, indent=2)}") # noqa: E501
- return None
-
- message = first_choice["message"]
- if not isinstance(message, dict):
- logger.error(f"GLM API message is not a dict: {type(message)}")
- return None
-
- if "content" not in message:
- logger.error("GLM API message missing 'content' field")
- logger.error(f"Message structure: {json.dumps(message, indent=2)}")
- return None
-
- content = message["content"]
- if not isinstance(content, str):
- logger.error(f"GLM API content is not a string: {type(content)}")
- return None
-
- return content
-
- except (KeyError, IndexError, TypeError) as e:
- logger.error(f"Failed to parse GLM API response: {e}")
- logger.error(f"Response: {json.dumps(data, indent=2)}")
- return None
-
-
-def call_glm_api(prompt: str, api_key: str, config: Config) -> str | None:
- """
- Call the GLM-4.7 API to get code review with retry logic.
-
- Args:
- prompt: The prompt to send to the API.
- api_key: The GLM API key.
- config: Configuration object.
-
- Returns:
- The review content as a string, or None if all retries failed.
- """
- headers: dict[str, str] = {
- "Authorization": f"Bearer {api_key}",
- "Content-Type": "application/json",
- }
-
- payload: dict[str, Any] = {
- "model": config.glm_model,
- "messages": [{"role": "user", "content": prompt}],
- "temperature": 0.3,
- "max_tokens": 32000,
- "top_p": 0.9,
- }
-
- last_error: str | None = None
-
- for attempt in range(config.max_retries):
- try:
- logger.info(f"Calling GLM API ({config.glm_model}) - Attempt {attempt + 1}/{config.max_retries}")
- response = requests.post(
- config.glm_api_url,
- headers=headers,
- json=payload,
- timeout=120,
- )
-
- if response.status_code == 200:
- data = response.json()
- review = validate_glm_response(data)
- if review:
- logger.info(f"Successfully received review ({len(review)} chars)") # noqa: E501
- return review
- else:
- last_error = "Failed to validate API response structure"
- logger.error(last_error)
- else:
- last_error = f"GLM API request failed: {response.status_code} - {response.text}"
- logger.error(last_error)
-
- except requests.exceptions.Timeout:
- last_error = f"GLM API request timed out (attempt {attempt + 1})"
- logger.error(last_error)
- except requests.exceptions.RequestException as e:
- last_error = f"GLM API request exception: {e}"
- logger.error(last_error)
- except json.JSONDecodeError as e:
- last_error = f"Failed to decode GLM API response as JSON: {e}"
- logger.error(last_error)
-
- # Exponential backoff before retry
- if attempt < config.max_retries - 1:
- wait_time: float = config.retry_delay * (2**attempt)
- logger.info(f"Waiting {wait_time}s before retry...") # noqa: E501
- time.sleep(wait_time)
-
- logger.error(
- f"All {config.max_retries} attempts failed. Last error: {last_error}" # noqa: E501
- )
- return None
-
-
-def check_cooldown( # noqa: E501
- repo_name: str,
- pr_number: int,
- token: str,
- cooldown_minutes: int,
-) -> bool:
- """
- Check if the PR is within the cooldown period.
-
- Args:
- repo_name: The repository name in format "owner/repo".
- pr_number: The pull request number.
- token: GitHub authentication token.
- cooldown_minutes: Cooldown period in minutes.
-
- Returns:
- True if within cooldown period (should skip), False otherwise.
- """
- from datetime import datetime, timedelta
-
- url: str = (
- f"https://api.github.com/repos/{repo_name}/issues/"
- f"{pr_number}/comments" # noqa: E501
- )
- headers: dict[str, str] = {
- "Authorization": f"Bearer {token}",
- "Accept": "application/vnd.github.v3+json",
- }
-
- logger.info(f"Checking cooldown period ({cooldown_minutes} minutes)")
- response = requests.get(url, headers=headers, timeout=30)
-
- if response.status_code != 200:
- logger.warning(f"Failed to check cooldown: {response.status_code}, proceeding with review")
- return False
-
- comments: list[dict[str, Any]] = response.json()
- cutoff_time: datetime = datetime.utcnow() - timedelta(minutes=cooldown_minutes) # noqa: E501
-
- for comment in reversed(comments):
- # Check if this is a bot comment
- body: str = comment.get("body", "")
- if "VLLM-Omni PR Review" in body or "PR Reviewer Bot" in body:
- created_at_str: str = comment.get("created_at", "")
- try:
- # Parse GitHub timestamp format
- created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
- created_at = created_at.replace(tzinfo=None)
- if created_at > cutoff_time:
- logger.info(f"PR is within cooldown period (last review: {created_at_str})")
- return True
- except ValueError:
- logger.warning(f"Failed to parse comment timestamp: {created_at_str}") # noqa: E501
- continue
-
- logger.info("PR is outside cooldown period, proceeding with review")
- return False
-
-
-def post_review_comment( # noqa: E501
- repo_name: str,
- pr_number: int,
- token: str,
- review: str,
-) -> bool:
- """
- Post the review as a comment on the PR.
-
- Args:
- repo_name: The repository name in format "owner/repo".
- pr_number: The pull request number.
- token: GitHub authentication token.
- review: The review content to post.
-
- Returns:
- True if posting succeeded, False otherwise.
- """
- url: str = (
- f"https://api.github.com/repos/{repo_name}/issues/"
- f"{pr_number}/comments" # noqa: E501
- )
- headers: dict[str, str] = {
- "Authorization": f"Bearer {token}",
- "Accept": "application/vnd.github.v3+json",
- }
-
- # Format the review comment
- comment_body: str = f"""## 🤖 VLLM-Omni PR Review
-
-{review}
-
----
-*This review was generated automatically by the VLLM-Omni PR Reviewer Bot
-using {os.getenv("GLM_MODEL", DEFAULT_GLM_MODEL)}.*
-"""
-
- payload: dict[str, str] = {"body": comment_body}
-
- logger.info(f"Posting review comment to PR #{pr_number}")
- response = requests.post(url, headers=headers, json=payload, timeout=30)
-
- if response.status_code == 201:
- logger.info("Successfully posted review comment")
- return True
- else:
- logger.error(f"Failed to post comment: {response.status_code}")
- logger.error(f"Response: {response.text}")
- return False
-
-
-def main() -> int:
- """
- Main entry point for the PR reviewer bot.
-
- Returns:
- 0 on success, 1 on error.
- """
- logger.info("VLLM-Omni PR Reviewer Bot starting...")
-
- # Load configuration
- config: Config = get_config()
- logger.info(
- f"Configuration: model={config.glm_model}, "
- f"cooldown={config.cooldown_minutes}min, "
- f"max_retries={config.max_retries}"
- )
-
- # Get environment variables
- token: str = get_env_var("GITHUB_TOKEN")
- api_key: str = get_env_var("GLM_API_KEY")
- repo_name: str = get_env_var("REPO_NAME")
- pr_number_str: str = get_env_var("PR_NUMBER")
- comment_body: str = get_env_var("COMMENT_BODY")
-
- try:
- pr_number: int = int(pr_number_str)
- except ValueError:
- logger.error(f"Invalid PR number: {pr_number_str}")
- return 1
-
- logger.info(f"Repository: {repo_name}")
- logger.info(f"PR Number: {pr_number}")
-
- # Check if the comment contains the trigger phrase
- if not check_trigger(comment_body):
- logger.info(
- f"Comment does not contain trigger phrase '{TRIGGER_PHRASE}', exiting" # noqa: E501
- )
- return 0
-
- logger.info("Trigger phrase detected! Starting review process...")
-
- # Check cooldown period
- if check_cooldown(repo_name, pr_number, token, config.cooldown_minutes):
- logger.info("Skipping review due to cooldown period")
- return 0
-
- # Fetch PR details
- logger.info("Step 1/4: Fetching PR details...")
- pr_details: PRDetails | None = fetch_pr_details(repo_name, pr_number, token) # noqa: E501
- if not pr_details:
- logger.error("Failed to fetch PR details")
- return 1
-
- pr_title: str = pr_details.get("title", "Unknown")
- pr_description: str = pr_details.get("body", "")
-
- logger.info(f"PR Title: {pr_title}")
-
- # Fetch PR diff
- logger.info("Step 2/4: Fetching PR diff...")
- diff: str | None = fetch_pr_diff(repo_name, pr_number, token, config.max_diff_size)
- if diff is None:
- logger.error("Failed to fetch PR diff")
- return 1
-
- if not diff:
- logger.warning("Warning: Empty diff - this might be a draft PR or no code changes")
-
- # Build prompt
- logger.info("Step 3/4: Building review prompt...")
- prompt: str = build_review_prompt(pr_title, pr_description, diff)
-
- # Call GLM API
- logger.info("Step 4/4: Calling GLM API...")
- review: str | None = call_glm_api(prompt, api_key, config)
- if not review:
- logger.error("Failed to get review from GLM API")
- return 1
-
- # Post review comment
- logger.info("Posting review comment...")
- if not post_review_comment(repo_name, pr_number, token, review):
- logger.error("Failed to post review comment")
- return 1
-
- logger.info("PR review completed successfully!")
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.github/workflows/pr-reviewer.yml b/.github/workflows/pr-reviewer.yml
deleted file mode 100644
index 8a41c6937..000000000
--- a/.github/workflows/pr-reviewer.yml
+++ /dev/null
@@ -1,62 +0,0 @@
-name: VLLM-Omni PR Reviewer
-
-on:
- issue_comment:
- types: [created]
-
-permissions:
- contents: read
- pull-requests: write
- issues: write
-
-jobs:
- pr-reviewer:
- name: Review Pull Request
- runs-on: ubuntu-latest
- timeout-minutes: 10
- # Only run when the comment is from a collaborator/owner/member
- if: |
- github.event_name == 'issue_comment' &&
- github.event.issue.pull_request != null &&
- contains(github.event.comment.body, '@vllm-omni-reviewer') &&
- (github.event.comment.author_association == 'MEMBER' ||
- github.event.comment.author_association == 'COLLABORATOR' ||
- github.event.comment.author_association == 'OWNER')
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6.0.2
- with:
- fetch-depth: 0
-
- - name: Set up Python
- uses: actions/setup-python@v6.2.0
- with:
- python-version: '3.11'
- cache: 'pip'
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip==24.0
- pip install requests==2.31.0 pyyaml==6.0.1
-
- - name: Run PR Reviewer
- id: reviewer
- env:
- GLM_API_KEY: ${{ secrets.GLM_API_KEY }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
- COMMENT_BODY: ${{ github.event.comment.body }}
- REPO_NAME: ${{ github.repository }}
- PR_REVIEWER_COOLDOWN_MINUTES: 5
- PR_REVIEWER_MAX_RETRIES: 3
- run: |
- python .github/scripts/pr_reviewer.py 2>&1 | tee "/tmp/pr_review_${PR_NUMBER}.log"
-
- - name: Upload review logs
- if: always()
- uses: actions/upload-artifact@v7.0.0
- with:
- name: pr-review-logs-${{ github.event.issue.number || github.event.pull_request.number }}
- path: /tmp/pr_review_*.log
- retention-days: 7
- if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index c0ee96806..a7cd8f74e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -174,6 +174,7 @@ CLAUDE.md
# Codex
AGENTS.md
+.codex
.codex/
# cursor
@@ -203,6 +204,7 @@ checkpoints/
# Cache directories
cache/
!vllm_omni/diffusion/cache/
+!tests/diffusion/cache/
.cache/
diffusion_cache/
kv_cache/
@@ -262,3 +264,6 @@ tmp_test
vllm_omni/_version.py
# output files
*.wav
+.worktrees/
+# CI overlay yamls materialized from tests/utils.py:_CI_OVERLAYS at test time
+tests/.ci_generated/
diff --git a/benchmarks/qwen3-tts/README.md b/benchmarks/qwen3-tts/README.md
index 9c01f29aa..a1c2ebe12 100644
--- a/benchmarks/qwen3-tts/README.md
+++ b/benchmarks/qwen3-tts/README.md
@@ -35,8 +35,8 @@ MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice bash run_benchmark.sh --async-only
# Use a Voice Clone model
MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-Base TASK_TYPE=Base bash run_benchmark.sh --async-only
-# Use bs16 config for higher throughput
-STAGE_CONFIG=vllm_omni/configs/qwen3_tts_bs16.yaml bash run_benchmark.sh --async-only
+# Use batch size 16 for higher throughput
+BATCH_SIZE=16 bash run_benchmark.sh --async-only
# Custom GPU, prompt count, concurrency levels
GPU_DEVICE=1 NUM_PROMPTS=20 CONCURRENCY="1 4" bash run_benchmark.sh
@@ -50,7 +50,8 @@ GPU_DEVICE=1 NUM_PROMPTS=20 CONCURRENCY="1 4" bash run_benchmark.sh
CUDA_VISIBLE_DEVICES=0 python -m vllm_omni.entrypoints.cli.main serve \
"Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice" \
--omni --host 127.0.0.1 --port 8000 \
- --stage-configs-path benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs1.yaml \
+ --deploy-config vllm_omni/deploy/qwen3_tts.yaml \
+ --stage-overrides '{"0":{"max_num_seqs":1,"gpu_memory_utilization":0.3,"max_num_batched_tokens":512},"1":{"max_num_seqs":1,"gpu_memory_utilization":0.3,"max_num_batched_tokens":8192}}' \
--trust-remote-code
```
@@ -84,16 +85,19 @@ python benchmarks/qwen3-tts/plot_results.py \
--output results/comparison.png
```
-## Stage Configs
+## Batch-size presets
-| Config | max_num_seqs | Description |
-|--------|:------------:|-------------|
-| `vllm_omni/configs/qwen3_tts_bs1.yaml` | 1 | Single-request processing (lowest latency) |
-| `vllm_omni/configs/qwen3_tts_bs16.yaml` | 16 | High-throughput concurrent processing |
+The bench script loads the bundled production deploy (`vllm_omni/deploy/qwen3_tts.yaml`) and layers per-stage budgets on top via `--stage-overrides`, driven by the `BATCH_SIZE` env var. Each batch size picks compatible per-stage `max_num_seqs`, `max_num_batched_tokens`, and `gpu_memory_utilization` defaults:
-All configs use a 2-stage pipeline (Talker -> Code2Wav) with `async_chunk` streaming enabled. The `SharedMemoryConnector` streams codec frames (25-frame chunks with 25-frame context overlap) between stages.
+| `BATCH_SIZE` | Description |
+|:--:|-------------|
+| `1` (default) | Single-request processing (lowest latency) |
+| `4` | Moderate-throughput concurrent processing |
+| `16` | High-throughput concurrent processing |
-The model is specified via the CLI `--model` flag (or `MODEL` env var), so the same configs work for both the 0.6B and 1.7B model variants.
+The 2-stage pipeline (Talker -> Code2Wav) runs with `async_chunk` streaming enabled via the prod deploy; the `SharedMemoryConnector` streams codec frames (25-frame chunks with 25-frame context overlap) between stages.
+
+The model is specified via the CLI `--model` flag (or `MODEL` env var), so the same bench script works for both the 0.6B and 1.7B model variants.
## Metrics
diff --git a/benchmarks/qwen3-tts/run_benchmark.sh b/benchmarks/qwen3-tts/run_benchmark.sh
index 283b6b844..8c3e46903 100755
--- a/benchmarks/qwen3-tts/run_benchmark.sh
+++ b/benchmarks/qwen3-tts/run_benchmark.sh
@@ -26,8 +26,8 @@
# # Use Voice Clone model
# MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-Base TASK_TYPE=Base bash run_benchmark.sh --async-only
#
-# # Use batch_size=4 config:
-# STAGE_CONFIG=vllm_omni/configs/qwen3_tts_bs4.yaml bash run_benchmark.sh --async-only
+# # Use batch_size=4:
+# BATCH_SIZE=4 bash run_benchmark.sh --async-only
#
# Environment variables:
# GPU_DEVICE - GPU index to use (default: 0)
@@ -35,9 +35,9 @@
# CONCURRENCY - Space-separated concurrency levels (default: "1 4 10")
# MODEL - Model name (default: Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice)
# PORT - Server port (default: 8000)
-# GPU_MEM_TALKER - gpu_memory_utilization for talker stage (default: 0.3)
-# GPU_MEM_CODE2WAV - gpu_memory_utilization for code2wav stage (default: 0.2)
-# STAGE_CONFIG - Path to stage config YAML (default: configs/qwen3_tts_bs1.yaml)
+# BATCH_SIZE - Per-stage ``max_num_seqs`` for both talker and code2wav (default: 1)
+# GPU_MEM_TALKER - gpu_memory_utilization for talker stage (default: 0.3 at bs=1, else 0.2)
+# GPU_MEM_CODE2WAV - gpu_memory_utilization for code2wav stage (default: 0.3 at bs=1, else 0.2)
# TASK_TYPE - Task type: CustomVoice, VoiceDesign, Base (default: CustomVoice)
set -euo pipefail
@@ -51,14 +51,36 @@ NUM_PROMPTS="${NUM_PROMPTS:-50}"
CONCURRENCY="${CONCURRENCY:-1 4 10}"
MODEL="${MODEL:-Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice}"
PORT="${PORT:-8000}"
-GPU_MEM_TALKER="${GPU_MEM_TALKER:-0.3}"
-GPU_MEM_CODE2WAV="${GPU_MEM_CODE2WAV:-0.2}"
+BATCH_SIZE="${BATCH_SIZE:-1}"
+DEFAULT_MEM=$([ "${BATCH_SIZE}" = "1" ] && echo "0.3" || echo "0.2")
+GPU_MEM_TALKER="${GPU_MEM_TALKER:-${DEFAULT_MEM}}"
+GPU_MEM_CODE2WAV="${GPU_MEM_CODE2WAV:-${DEFAULT_MEM}}"
NUM_WARMUPS="${NUM_WARMUPS:-3}"
-STAGE_CONFIG="${STAGE_CONFIG:-vllm_omni/configs/qwen3_tts_bs1.yaml}"
+DEPLOY_CONFIG="vllm_omni/deploy/qwen3_tts.yaml"
RESULT_DIR="${SCRIPT_DIR}/results"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
TASK_TYPE="${TASK_TYPE:-CustomVoice}"
+# Build --stage-overrides JSON from BATCH_SIZE + GPU_MEM_*.
+STAGE_OVERRIDES=$(
+ BATCH_SIZE="${BATCH_SIZE}" \
+ GPU_MEM_TALKER="${GPU_MEM_TALKER}" \
+ GPU_MEM_CODE2WAV="${GPU_MEM_CODE2WAV}" \
+ python - <<'PYEOF'
+import json, os
+bs = int(os.environ["BATCH_SIZE"])
+mem_t = float(os.environ["GPU_MEM_TALKER"])
+mem_c = float(os.environ["GPU_MEM_CODE2WAV"])
+# Prefill budget grows with batch size on both stages.
+talker_batched = 512 if bs <= 4 else 4096
+code2wav_batched = 8192 if bs <= 4 else 32768
+print(json.dumps({
+ "0": {"max_num_seqs": bs, "gpu_memory_utilization": mem_t, "max_num_batched_tokens": talker_batched},
+ "1": {"max_num_seqs": bs, "gpu_memory_utilization": mem_c, "max_num_batched_tokens": code2wav_batched},
+}))
+PYEOF
+)
+
# Parse args
RUN_ASYNC=true
RUN_HF=true
@@ -75,41 +97,27 @@ mkdir -p "${RESULT_DIR}"
echo "============================================================"
echo " Qwen3-TTS Benchmark"
echo "============================================================"
-echo " GPU: ${GPU_DEVICE}"
-echo " Model: ${MODEL}"
-echo " Prompts: ${NUM_PROMPTS}"
-echo " Concurrency: ${CONCURRENCY}"
-echo " Port: ${PORT}"
-echo " Stage config: ${STAGE_CONFIG}"
-echo " Results: ${RESULT_DIR}"
-echo " Task type: ${TASK_TYPE}"
+echo " GPU: ${GPU_DEVICE}"
+echo " Model: ${MODEL}"
+echo " Prompts: ${NUM_PROMPTS}"
+echo " Concurrency: ${CONCURRENCY}"
+echo " Port: ${PORT}"
+echo " Deploy config: ${DEPLOY_CONFIG}"
+echo " Batch size: ${BATCH_SIZE}"
+echo " GPU mem T/C: ${GPU_MEM_TALKER} / ${GPU_MEM_CODE2WAV}"
+echo " Results: ${RESULT_DIR}"
+echo " Task type: ${TASK_TYPE}"
echo "============================================================"
-# Prepare stage config with correct GPU device and memory settings
-prepare_config() {
- local config_template="$1"
- local config_name="$2"
- local output_path="${RESULT_DIR}/${config_name}_stage_config.yaml"
-
- # Use sed to patch GPU device and memory utilization
- sed \
- -e "s/devices: \"0\"/devices: \"${GPU_DEVICE}\"/g" \
- -e "s/gpu_memory_utilization: 0.3/gpu_memory_utilization: ${GPU_MEM_TALKER}/g" \
- -e "s/gpu_memory_utilization: 0.2/gpu_memory_utilization: ${GPU_MEM_CODE2WAV}/g" \
- "${config_template}" > "${output_path}"
-
- echo "${output_path}"
-}
-
# Start server and wait for it to be ready
start_server() {
- local stage_config="$1"
- local config_name="$2"
+ local config_name="$1"
local log_file="${RESULT_DIR}/server_${config_name}_${TIMESTAMP}.log"
echo ""
echo "Starting server with config: ${config_name}"
- echo " Stage config: ${stage_config}"
+ echo " Deploy config: ${DEPLOY_CONFIG}"
+ echo " Stage overrides: ${STAGE_OVERRIDES}"
echo " Log file: ${log_file}"
VLLM_WORKER_MULTIPROC_METHOD=spawn \
@@ -118,7 +126,8 @@ start_server() {
--omni \
--host 127.0.0.1 \
--port "${PORT}" \
- --stage-configs-path "${stage_config}" \
+ --deploy-config "${DEPLOY_CONFIG}" \
+ --stage-overrides "${STAGE_OVERRIDES}" \
--stage-init-timeout 120 \
--trust-remote-code \
--disable-log-stats \
@@ -175,17 +184,13 @@ trap 'stop_server' EXIT
# Run benchmark for a given config
run_bench() {
local config_name="$1"
- local config_template="$2"
echo ""
echo "============================================================"
echo " Benchmarking: ${config_name}"
echo "============================================================"
- local stage_config
- stage_config=$(prepare_config "${config_template}" "${config_name}")
-
- start_server "${stage_config}" "${config_name}"
+ start_server "${config_name}"
# Convert concurrency string to args
local conc_args=""
@@ -212,7 +217,7 @@ run_bench() {
# Run vllm-omni benchmark
if [ "${RUN_ASYNC}" = true ]; then
- run_bench "async_chunk" "${SCRIPT_DIR}/${STAGE_CONFIG}"
+ run_bench "async_chunk"
fi
# Run HuggingFace baseline benchmark
diff --git a/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs16.yaml b/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs16.yaml
deleted file mode 100644
index 2cc5cf535..000000000
--- a/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs16.yaml
+++ /dev/null
@@ -1,94 +0,0 @@
-# Qwen3-TTS max_num_seqs=16 config (streaming with async_chunk)
-# High-throughput concurrent request processing
-# 2-stage pipeline: Talker -> Code2Wav
-async_chunk: true
-stage_args:
- - stage_id: 0
- stage_type: llm
- is_comprehension: true
- runtime:
- devices: "0"
- engine_args:
- max_num_seqs: 16
- model_stage: qwen3_tts
- model_arch: Qwen3TTSTalkerForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- enforce_eager: false
- trust_remote_code: true
- async_scheduling: true
- enable_prefix_caching: false
- engine_output_type: latent
- gpu_memory_utilization: 0.3
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 4096
- max_model_len: 4096
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
- output_connectors:
- to_stage_1: connector_of_shared_memory
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: false
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 1
- stage_type: llm
- runtime:
- devices: "0"
- engine_args:
- max_num_seqs: 16
- model_stage: code2wav
- model_arch: Qwen3TTSCode2Wav
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: true
- enable_prefix_caching: false
- engine_output_type: audio
- gpu_memory_utilization: 0.2
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 16384
- max_model_len: 32768
- engine_input_source: [0]
- final_output: true
- final_output_type: audio
- input_connectors:
- from_stage_0: connector_of_shared_memory
- tts_args:
- max_instructions_length: 500
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: true
- repetition_penalty: 1.0
-
-runtime:
- enabled: true
- defaults:
- window_size: -1
- max_inflight: 16
-
- connectors:
- connector_of_shared_memory:
- name: SharedMemoryConnector
- extra:
- shm_threshold_bytes: 65536
- codec_streaming: true
- connector_get_sleep_s: 0.01
- connector_get_max_wait_first_chunk: 3000
- connector_get_max_wait: 300
- codec_chunk_frames: 25
- codec_left_context_frames: 25
-
- edges:
- - from: 0
- to: 1
- window_size: -1
diff --git a/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs4.yaml b/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs4.yaml
deleted file mode 100644
index 5de107d49..000000000
--- a/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs4.yaml
+++ /dev/null
@@ -1,94 +0,0 @@
-# Qwen3-TTS batch_size=4 config (streaming with async_chunk)
-# Enables concurrent request processing
-# 2-stage pipeline: Talker -> Code2Wav
-async_chunk: true
-stage_args:
- - stage_id: 0
- stage_type: llm
- is_comprehension: true
- runtime:
- devices: "0"
- engine_args:
- max_num_seqs: 4
- model_stage: qwen3_tts
- model_arch: Qwen3TTSTalkerForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- enforce_eager: false
- trust_remote_code: true
- async_scheduling: true
- enable_prefix_caching: false
- engine_output_type: latent
- gpu_memory_utilization: 0.3
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 512
- max_model_len: 4096
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
- output_connectors:
- to_stage_1: connector_of_shared_memory
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: false
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 1
- stage_type: llm
- runtime:
- devices: "0"
- engine_args:
- max_num_seqs: 4
- model_stage: code2wav
- model_arch: Qwen3TTSCode2Wav
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: true
- enable_prefix_caching: false
- engine_output_type: audio
- gpu_memory_utilization: 0.2
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 8192
- max_model_len: 32768
- engine_input_source: [0]
- final_output: true
- final_output_type: audio
- input_connectors:
- from_stage_0: connector_of_shared_memory
- tts_args:
- max_instructions_length: 500
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: true
- repetition_penalty: 1.0
-
-runtime:
- enabled: true
- defaults:
- window_size: -1
- max_inflight: 4
-
- connectors:
- connector_of_shared_memory:
- name: SharedMemoryConnector
- extra:
- shm_threshold_bytes: 65536
- codec_streaming: true
- connector_get_sleep_s: 0.01
- connector_get_max_wait_first_chunk: 3000
- connector_get_max_wait: 300
- codec_chunk_frames: 25
- codec_left_context_frames: 25
-
- edges:
- - from: 0
- to: 1
- window_size: -1
diff --git a/benchmarks/qwen3-tts/vllm_omni/run_async_chunk_benchmark.sh b/benchmarks/qwen3-tts/vllm_omni/run_async_chunk_benchmark.sh
index 61cf7757a..0ede359ea 100755
--- a/benchmarks/qwen3-tts/vllm_omni/run_async_chunk_benchmark.sh
+++ b/benchmarks/qwen3-tts/vllm_omni/run_async_chunk_benchmark.sh
@@ -31,8 +31,11 @@ PORT_OFF="${PORT_OFF:-8001}"
RESULT_DIR="${SCRIPT_DIR}/results"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
-STAGE_CONFIG_ON="vllm_omni/model_executor/stage_configs/qwen3_tts.yaml"
-STAGE_CONFIG_OFF="vllm_omni/model_executor/stage_configs/qwen3_tts_no_async_chunk.yaml"
+# The bundled ``vllm_omni/deploy/qwen3_tts.yaml`` is auto-loaded by the model
+# registry; no ``--deploy-config`` flag needed on the default (ON) path.
+# async_chunk OFF is selected by the ``--no-async-chunk`` CLI flag —
+# the single ``qwen3_tts`` pipeline dispatches to the end-to-end codec
+# processor when ``deploy.async_chunk`` is false.
mkdir -p "${RESULT_DIR}"
@@ -77,7 +80,6 @@ wait_for_server() {
echo ""
echo "[Phase 1] Starting async_chunk ON server on port ${PORT_ON}..."
CUDA_VISIBLE_DEVICES=${GPU_DEVICE} vllm-omni serve "${MODEL}" \
- --stage-configs-path "${STAGE_CONFIG_ON}" \
--host 0.0.0.0 --port "${PORT_ON}" \
--trust-remote-code --enforce-eager --omni \
> "${RESULT_DIR}/server_on_${TIMESTAMP}.log" 2>&1 &
@@ -104,7 +106,7 @@ sleep 5
echo ""
echo "[Phase 2] Starting async_chunk OFF server on port ${PORT_OFF}..."
CUDA_VISIBLE_DEVICES=${GPU_DEVICE} vllm-omni serve "${MODEL}" \
- --stage-configs-path "${STAGE_CONFIG_OFF}" \
+ --no-async-chunk \
--host 0.0.0.0 --port "${PORT_OFF}" \
--trust-remote-code --enforce-eager --omni \
> "${RESULT_DIR}/server_off_${TIMESTAMP}.log" 2>&1 &
diff --git a/benchmarks/voxcpm/README.md b/benchmarks/voxcpm/README.md
new file mode 100644
index 000000000..17f904101
--- /dev/null
+++ b/benchmarks/voxcpm/README.md
@@ -0,0 +1,119 @@
+# VoxCPM Benchmark
+
+This directory contains both:
+
+- online serving benchmark through the OpenAI-compatible `/v1/audio/speech` API
+- offline benchmark for `Omni` / `AsyncOmni`
+- full offline smoke-matrix orchestration
+
+Both benchmark paths report:
+
+- TTFP: time to first PCM packet
+- E2E latency
+- RTF: real-time factor (`e2e / audio_duration`)
+
+## Offline Benchmark
+
+Single offline benchmark run:
+
+```bash
+python benchmarks/voxcpm/vllm_omni/bench_tts_offline.py \
+ --model /path/to/voxcpm-model \
+ --stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm.yaml \
+ --text "This is a split-stage VoxCPM synthesis example running on vLLM Omni." \
+ --warmup-runs 1 \
+ --output-dir benchmarks/voxcpm/results/offline_single
+```
+
+Streaming offline benchmark:
+
+```bash
+python benchmarks/voxcpm/vllm_omni/bench_tts_offline.py \
+ --model /path/to/voxcpm-model \
+ --stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \
+ --text "This is a split-stage VoxCPM streaming example running on vLLM Omni." \
+ --warmup-runs 1 \
+ --output-dir benchmarks/voxcpm/results/offline_streaming
+```
+
+Full fixed offline matrix, equivalent to the old `examples/offline_inference/voxcpm/test.py`:
+
+```bash
+python benchmarks/voxcpm/vllm_omni/run_offline_matrix.py \
+ --model /path/to/voxcpm-model \
+ --ref-audio /path/to/reference.wav \
+ --ref-text "The exact transcript spoken in reference.wav." \
+ --output-root benchmarks/voxcpm/results/offline_matrix
+```
+
+The full matrix covers both routes:
+
+- streaming: `voxcpm_async_chunk.yaml`
+- sync: `voxcpm.yaml`
+
+And these six scenarios under each route:
+
+- warmup + single TTS
+- warmup + single voice cloning
+- warmup + batch TTS
+- warmup + batch voice cloning
+- cold single TTS
+- cold single voice cloning
+
+`bench_tts_offline.py` itself no longer writes `summary.json` / `results.json`; it prints TTFP / RTF inline and saves generated WAV files only. The matrix runner keeps only per-case `run.log`.
+
+## Start the Server
+
+Async-chunk:
+
+```bash
+vllm serve /path/to/voxcpm-model \
+ --stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \
+ --trust-remote-code \
+ --enforce-eager \
+ --omni \
+ --port 8091
+```
+
+Non-streaming:
+
+```bash
+vllm serve /path/to/voxcpm-model \
+ --stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm.yaml \
+ --trust-remote-code \
+ --enforce-eager \
+ --omni \
+ --port 8091
+```
+
+## Run the Benchmark
+
+```bash
+python benchmarks/voxcpm/vllm_omni/bench_tts_serve.py \
+ --host 127.0.0.1 \
+ --port 8091 \
+ --num-prompts 20 \
+ --max-concurrency 1 \
+ --result-dir /tmp/voxcpm_bench
+```
+
+Voice cloning benchmark:
+
+```bash
+python benchmarks/voxcpm/vllm_omni/bench_tts_serve.py \
+ --host 127.0.0.1 \
+ --port 8091 \
+ --num-prompts 10 \
+ --max-concurrency 1 \
+ --ref-audio https://example.com/reference.wav \
+ --ref-text "The exact transcript spoken in the reference audio." \
+ --result-dir /tmp/voxcpm_clone_bench
+```
+
+## Notes
+
+- The benchmark uses `stream=true` and `response_format=pcm` so TTFP is measured from the first audio packet.
+- `RTF < 1.0` means the server generates audio faster than real time.
+- For `voxcpm_async_chunk.yaml`, keep concurrency at `1`. This matches native VoxCPM streaming more closely.
+- Do not benchmark concurrent online streaming on `voxcpm_async_chunk.yaml`; use `voxcpm.yaml` for multi-request throughput runs.
+- For the offline matrix mode, `--ref-audio` and `--ref-text` are required because clone cases are part of the fixed coverage set.
diff --git a/benchmarks/voxcpm/vllm_omni/bench_tts_offline.py b/benchmarks/voxcpm/vllm_omni/bench_tts_offline.py
new file mode 100644
index 000000000..a3bad3e69
--- /dev/null
+++ b/benchmarks/voxcpm/vllm_omni/bench_tts_offline.py
@@ -0,0 +1,890 @@
+"""Offline VoxCPM benchmark for vLLM Omni.
+
+Supports both:
+- sync one-shot (Omni.generate)
+- streaming (AsyncOmni.generate with async_chunk config)
+- text-only synthesis
+- voice cloning
+- text/clone batch inputs from txt or jsonl
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import tempfile
+import time
+import uuid
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+import torch
+from vllm.utils.argparse_utils import FlexibleArgumentParser
+
+from vllm_omni import AsyncOmni, Omni
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+DEFAULT_STAGE_ASYNC = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm_async_chunk.yaml"
+DEFAULT_STAGE_SYNC = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm.yaml"
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class PromptSpec:
+ text: str
+ label: str
+ ref_audio: str | None = None
+ ref_text: str | None = None
+
+
+def _require_soundfile():
+ try:
+ import soundfile as sf # type: ignore
+ except ModuleNotFoundError as exc:
+ raise RuntimeError(
+ "soundfile is required to write VoxCPM benchmark WAV outputs. Install it with: pip install soundfile"
+ ) from exc
+ return sf
+
+
+def _build_prompt(
+ args,
+ *,
+ text: str,
+ ref_audio: str | None = None,
+ ref_text: str | None = None,
+ global_request_id: str | None = None,
+) -> dict[str, Any]:
+ additional_information: dict[str, list[Any]] = {
+ "text": [text],
+ "cfg_value": [args.cfg_value],
+ "inference_timesteps": [args.inference_timesteps],
+ "min_len": [args.min_len],
+ "max_new_tokens": [args.max_new_tokens],
+ }
+ if args.streaming_prefix_len is not None:
+ additional_information["streaming_prefix_len"] = [args.streaming_prefix_len]
+
+ if ref_audio:
+ additional_information["ref_audio"] = [ref_audio]
+ if ref_text:
+ additional_information["ref_text"] = [ref_text]
+ if global_request_id is not None:
+ additional_information["global_request_id"] = [global_request_id]
+
+ return {
+ "prompt_token_ids": [1],
+ "additional_information": additional_information,
+ }
+
+
+def _extract_audio_tensor(mm: dict[str, Any]) -> torch.Tensor:
+ audio = mm.get("audio", mm.get("model_outputs"))
+ if audio is None:
+ raise ValueError("No audio output found in multimodal output.")
+ if isinstance(audio, list):
+ parts = [torch.as_tensor(a).float().cpu().reshape(-1) for a in audio]
+ audio = torch.cat(parts, dim=-1) if parts else torch.zeros(0)
+ if not isinstance(audio, torch.Tensor):
+ audio = torch.as_tensor(audio)
+ return audio.float().cpu().reshape(-1)
+
+
+def _extract_sample_rate(mm: dict[str, Any]) -> int:
+ sr_raw = mm.get("sr", 24000)
+ if isinstance(sr_raw, list) and sr_raw:
+ sr_raw = sr_raw[-1]
+ if hasattr(sr_raw, "item"):
+ return int(sr_raw.item())
+ return int(sr_raw)
+
+
+def _emit_offline_metrics(
+ *,
+ request_id: str,
+ elapsed_s: float,
+ first_audio_elapsed: float | None,
+ audio_duration_s: float,
+) -> None:
+ metrics = {
+ "request_id": request_id,
+ "ttfp_ms": round(first_audio_elapsed * 1000.0, 3) if first_audio_elapsed is not None else None,
+ "audio_duration_s": round(audio_duration_s, 6),
+ "rtf": round(elapsed_s / audio_duration_s, 6) if audio_duration_s > 0 else None,
+ }
+ print(f"[OfflineMetrics] {metrics}")
+
+
+def _write_audio_tensor(output_path: Path, audio_tensor: Any, sample_rate: int) -> None:
+ sf = _require_soundfile()
+ if isinstance(audio_tensor, torch.Tensor):
+ audio_np = audio_tensor.float().cpu().clamp(-1.0, 1.0).numpy()
+ else:
+ audio_np = torch.as_tensor(audio_tensor).float().cpu().clamp(-1.0, 1.0).numpy()
+ sf.write(
+ output_path,
+ audio_np,
+ sample_rate,
+ format="WAV",
+ subtype="PCM_16",
+ )
+
+
+def _save_wav(mm: dict[str, Any], output_dir: Path, request_id: str) -> Path:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ output_path = output_dir / f"output_{request_id}.wav"
+ _write_audio_tensor(output_path, _extract_audio_tensor(mm), _extract_sample_rate(mm))
+ return output_path
+
+
+def _iter_request_multimodal_outputs(request_output: Any):
+ outputs = getattr(request_output, "outputs", None)
+ if outputs:
+ for output in outputs:
+ mm = getattr(output, "multimodal_output", None)
+ if isinstance(mm, dict):
+ yield mm
+
+ mm = getattr(request_output, "multimodal_output", None)
+ if isinstance(mm, dict):
+ yield mm
+
+
+def _read_non_empty_lines(path: str) -> list[str]:
+ with open(path, encoding="utf-8") as f:
+ return [line.strip() for line in f if line.strip()]
+
+
+def _load_prompt_specs(args) -> list[PromptSpec]:
+ specs: list[PromptSpec] = []
+
+ if args.txt_prompts is not None:
+ texts = _read_non_empty_lines(args.txt_prompts)
+ if not texts:
+ raise ValueError(f"No prompts found in {args.txt_prompts}")
+ for idx, text in enumerate(texts, start=1):
+ specs.append(
+ PromptSpec(
+ text=text,
+ label=f"item{idx:03d}",
+ ref_audio=args.ref_audio,
+ ref_text=args.ref_text,
+ )
+ )
+ return specs
+
+ if args.jsonl_prompts is not None:
+ with open(args.jsonl_prompts, encoding="utf-8") as f:
+ for line_no, raw_line in enumerate(f, start=1):
+ line = raw_line.strip()
+ if not line:
+ continue
+ try:
+ item = json.loads(line)
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"{args.jsonl_prompts}:{line_no} is not valid JSON: {exc}") from exc
+ if not isinstance(item, dict):
+ raise ValueError(f"{args.jsonl_prompts}:{line_no} must be a JSON object")
+
+ text = item.get("text")
+ if not isinstance(text, str) or not text.strip():
+ raise ValueError(f"{args.jsonl_prompts}:{line_no} requires non-empty string field 'text'")
+
+ ref_audio = item.get("ref_audio", args.ref_audio)
+ ref_text = item.get("ref_text", args.ref_text)
+ if (ref_audio is None) != (ref_text is None):
+ raise ValueError(
+ f"{args.jsonl_prompts}:{line_no} must provide both 'ref_audio' and 'ref_text' together"
+ )
+
+ specs.append(
+ PromptSpec(
+ text=text.strip(),
+ label=f"item{len(specs) + 1:03d}",
+ ref_audio=ref_audio,
+ ref_text=ref_text,
+ )
+ )
+
+ if not specs:
+ raise ValueError(f"No prompts found in {args.jsonl_prompts}")
+ return specs
+
+ specs.append(
+ PromptSpec(
+ text=args.text,
+ label="item001",
+ ref_audio=args.ref_audio,
+ ref_text=args.ref_text,
+ )
+ )
+ return specs
+
+
+def _build_prompt_for_spec(args, spec: PromptSpec, *, global_request_id: str | None = None) -> dict[str, Any]:
+ return _build_prompt(
+ args,
+ text=spec.text,
+ ref_audio=spec.ref_audio,
+ ref_text=spec.ref_text,
+ global_request_id=global_request_id,
+ )
+
+
+def _count_voice_clone_prompts(prompt_specs: list[PromptSpec]) -> int:
+ return sum(1 for spec in prompt_specs if spec.ref_audio is not None)
+
+
+def _get_warmup_specs(prompt_specs: list[PromptSpec]) -> list[PromptSpec]:
+ return prompt_specs[:1]
+
+
+def _extract_stream_finished(stage_output: Any) -> bool:
+ request_output = getattr(stage_output, "request_output", None)
+ request_finished = getattr(request_output, "finished", None)
+ if request_finished is not None:
+ return bool(request_finished)
+ return bool(getattr(stage_output, "finished", False))
+
+
+def _build_profiled_stage_config(
+ stage_configs_path: str,
+ profiler_dir: str,
+) -> str:
+ stage_config_path = Path(stage_configs_path)
+ yaml_text = stage_config_path.read_text(encoding="utf-8")
+ injected_lines: list[str] = []
+ injected_count = 0
+
+ for line in yaml_text.splitlines():
+ injected_lines.append(line)
+ if line.strip() != "engine_args:":
+ continue
+ indent = line[: len(line) - len(line.lstrip())]
+ child_indent = indent + " "
+ grandchild_indent = child_indent + " "
+ injected_lines.extend(
+ [
+ f"{child_indent}profiler_config:",
+ f'{grandchild_indent}profiler: "torch"',
+ f'{grandchild_indent}torch_profiler_dir: "{profiler_dir}"',
+ f"{grandchild_indent}torch_profiler_with_stack: true",
+ ]
+ )
+ injected_count += 1
+
+ if injected_count == 0:
+ raise ValueError(f"No engine_args block found in stage config: {stage_configs_path}")
+
+ tmp = tempfile.NamedTemporaryFile(
+ mode="w",
+ encoding="utf-8",
+ delete=False,
+ suffix=".yaml",
+ prefix=f"{stage_config_path.stem}_profile_",
+ )
+ tmp.write("\n".join(injected_lines) + "\n")
+ tmp.close()
+ return tmp.name
+
+
+def parse_args():
+ parser = FlexibleArgumentParser(
+ description="Offline split-stage VoxCPM inference with vLLM Omni (auto sync/streaming by stage config)"
+ )
+ parser.add_argument(
+ "--model",
+ type=str,
+ default=os.environ.get("VOXCPM_MODEL"),
+ help="Local VoxCPM model directory. Defaults to $VOXCPM_MODEL.",
+ )
+ parser.add_argument(
+ "--text",
+ type=str,
+ default="This is a split-stage VoxCPM synthesis example running on vLLM Omni.",
+ help="Text to synthesize. Ignored when --txt-prompts or --jsonl-prompts is used.",
+ )
+ parser.add_argument(
+ "--txt-prompts",
+ type=str,
+ default=None,
+ help="Path to a .txt file with one synthesis text per line.",
+ )
+ parser.add_argument(
+ "--jsonl-prompts",
+ type=str,
+ default=None,
+ help=(
+ "Path to a .jsonl file. Each line must contain at least {'text': ...}; "
+ "clone rows can also set ref_audio/ref_text, and ref_text must be the "
+ "real transcript of ref_audio."
+ ),
+ )
+ parser.add_argument(
+ "--ref-audio",
+ type=str,
+ default=None,
+ help=(
+ "Optional reference audio path for voice cloning. With --txt-prompts, "
+ "the same reference is applied to every line."
+ ),
+ )
+ parser.add_argument(
+ "--ref-text",
+ type=str,
+ default=None,
+ help=(
+ "Real transcript of the reference audio. Placeholder text or mismatched "
+ "text will usually produce noisy/electronic clone audio."
+ ),
+ )
+ parser.add_argument(
+ "--stage-configs-path",
+ type=str,
+ default=str(DEFAULT_STAGE_SYNC),
+ help="Stage config YAML path. Routing is selected only from this path.",
+ )
+ parser.add_argument(
+ "--cfg-value",
+ type=float,
+ default=2.0,
+ help="Classifier-free guidance value for VoxCPM.",
+ )
+ parser.add_argument(
+ "--inference-timesteps",
+ type=int,
+ default=10,
+ help="Number of inference timesteps.",
+ )
+ parser.add_argument(
+ "--min-len",
+ type=int,
+ default=2,
+ help="Minimum generated token length.",
+ )
+ parser.add_argument(
+ "--max-new-tokens",
+ type=int,
+ default=4096,
+ help="Maximum generated token length.",
+ )
+ parser.add_argument(
+ "--streaming-prefix-len",
+ type=int,
+ default=None,
+ help="VoxCPM streaming window (optional, streaming mode only).",
+ )
+ parser.add_argument(
+ "--output-dir",
+ type=str,
+ default=None,
+ help="Directory for output WAV files.",
+ )
+ parser.add_argument(
+ "--stage-init-timeout",
+ type=int,
+ default=600,
+ help="Stage initialization timeout in seconds.",
+ )
+ parser.add_argument(
+ "--log-stats",
+ dest="log_stats",
+ action="store_true",
+ help="Enable vLLM Omni stats logging.",
+ )
+ parser.add_argument(
+ "--no-log-stats",
+ dest="log_stats",
+ action="store_false",
+ help="Disable vLLM Omni stats logging.",
+ )
+ parser.set_defaults(log_stats=True)
+ parser.add_argument(
+ "--num-runs",
+ type=int,
+ default=1,
+ help="Number of full inference runs (same prompt each time). Default 1.",
+ )
+ parser.add_argument(
+ "--warmup-runs",
+ type=int,
+ default=0,
+ help=(
+ "Optional number of warmup passes before measured runs. Warmup uses only "
+ "the first prompt and does not save outputs."
+ ),
+ )
+ parser.add_argument(
+ "--enable-profiler",
+ action="store_true",
+ help=(
+ "Enable torch profiler for the configured stages. A temporary profiled "
+ "stage config is generated automatically."
+ ),
+ )
+ parser.add_argument(
+ "--profiler-dir",
+ type=str,
+ default=None,
+ help="Directory for profiler traces. Defaults to /profiler when profiling is enabled.",
+ )
+ parser.add_argument(
+ "--profiler-stages",
+ type=int,
+ nargs="*",
+ default=None,
+ help="Optional stage ids to profile. Defaults to all stages that have profiler_config.",
+ )
+ parser.add_argument(
+ "--profiler-wait-seconds",
+ type=float,
+ default=30.0,
+ help="Seconds to wait after stop_profile for trace files to flush.",
+ )
+ args = parser.parse_args()
+
+ if not args.model:
+ parser.error("--model is required unless $VOXCPM_MODEL is set")
+ if args.txt_prompts is not None and args.jsonl_prompts is not None:
+ parser.error("--txt-prompts and --jsonl-prompts are mutually exclusive")
+ if (args.ref_audio is None) != (args.ref_text is None):
+ parser.error("--ref-audio and --ref-text must be provided together")
+ if args.num_runs < 1:
+ parser.error("--num-runs must be >= 1")
+ if args.warmup_runs < 0:
+ parser.error("--warmup-runs must be >= 0")
+ if args.output_dir is None:
+ args.output_dir = (
+ "output_audio_streaming" if _is_streaming_stage_config(args.stage_configs_path) else "output_audio"
+ )
+ if args.enable_profiler and args.profiler_dir is None:
+ args.profiler_dir = str(Path(args.output_dir) / "profiler")
+ try:
+ args.prompt_specs = _load_prompt_specs(args)
+ except ValueError as exc:
+ parser.error(str(exc))
+
+ return args
+
+
+def _is_streaming_stage_config(stage_configs_path: str) -> bool:
+ cfg_name = Path(stage_configs_path).name.lower()
+ # Keep routing purely config-path based:
+ # - voxcpm.yaml => sync
+ # - voxcpm_async_chunk.yaml => streaming
+ return "async_chunk" in cfg_name
+
+
+async def _collect_streaming_audio(
+ omni: AsyncOmni,
+ args: Any,
+ spec: PromptSpec,
+ request_id: str,
+ *,
+ phase_label: str,
+ prompt_index: int,
+ prompt_count: int,
+ print_prompt: bool = False,
+) -> tuple[torch.Tensor, int, float, float | None]:
+ prompt = _build_prompt_for_spec(args, spec, global_request_id=request_id)
+ delta_chunks: list[torch.Tensor] = []
+ sample_rate = 24000
+ chunk_i = 0
+ prev_total_samples = 0
+ t_start = time.perf_counter()
+ first_audio_elapsed: float | None = None
+
+ if print_prompt:
+ print(f"---prompt---:{prompt}")
+
+ async for stage_output in omni.generate(prompt, request_id=request_id):
+ mm = getattr(stage_output, "multimodal_output", None)
+ if not isinstance(mm, dict):
+ ro = getattr(stage_output, "request_output", None)
+ if ro is None:
+ continue
+ mm = getattr(ro, "multimodal_output", None)
+ if not isinstance(mm, dict) and getattr(ro, "outputs", None):
+ seq = ro.outputs[0]
+ mm = getattr(seq, "multimodal_output", None)
+ if not isinstance(mm, dict):
+ continue
+ sample_rate = _extract_sample_rate(mm)
+ try:
+ w = _extract_audio_tensor(mm)
+ n = int(w.numel())
+ if n == 0:
+ continue
+ finished = _extract_stream_finished(stage_output)
+ if n > prev_total_samples:
+ delta = w.reshape(-1)[prev_total_samples:]
+ prev_total_samples = n
+ elif finished and n == prev_total_samples:
+ delta = w.reshape(-1)[:0]
+ else:
+ delta = w.reshape(-1)
+ prev_total_samples += int(delta.numel())
+ if int(delta.numel()) > 0:
+ delta_chunks.append(delta)
+ if first_audio_elapsed is None and int(delta.numel()) > 0:
+ first_audio_elapsed = time.perf_counter() - t_start
+ logger.info(
+ "%s prompt=%d/%d chunk=%d delta_samples=%d buf_len=%d finished=%s",
+ phase_label,
+ prompt_index + 1,
+ prompt_count,
+ chunk_i,
+ int(delta.numel()),
+ n,
+ finished,
+ )
+ chunk_i += 1
+ except ValueError:
+ if not _extract_stream_finished(stage_output):
+ logger.debug("skip non-audio partial output chunk=%d", chunk_i)
+
+ if not delta_chunks:
+ raise RuntimeError("No audio chunks received; check stage config and logs.")
+
+ audio_cat = torch.cat([c.reshape(-1) for c in delta_chunks], dim=0)
+ elapsed = time.perf_counter() - t_start
+ return audio_cat, sample_rate, elapsed, first_audio_elapsed
+
+
+async def _abort_streaming_residual_work(
+ omni: AsyncOmni,
+ request_id: str,
+ *,
+ settle_seconds: float = 0.1,
+) -> None:
+ """Stop any late stage-0 work once the final audio has been collected."""
+ await omni.engine.abort_async([request_id])
+ if settle_seconds > 0:
+ await asyncio.sleep(settle_seconds)
+
+
+async def _run_streaming_single(
+ omni: AsyncOmni,
+ args: Any,
+ spec: PromptSpec,
+ output_dir: Path,
+ request_id: str,
+ *,
+ run_index: int,
+ num_runs: int,
+ prompt_index: int,
+ prompt_count: int,
+) -> Path:
+ audio_cat, sample_rate, elapsed, first_audio_elapsed = await _collect_streaming_audio(
+ omni,
+ args,
+ spec,
+ request_id,
+ phase_label=f"run={run_index + 1}/{num_runs}",
+ prompt_index=prompt_index,
+ prompt_count=prompt_count,
+ print_prompt=(run_index == 0 and prompt_index == 0),
+ )
+ await _abort_streaming_residual_work(omni, request_id)
+ output_path = output_dir / f"output_run{run_index + 1}_{spec.label}.wav"
+ _write_audio_tensor(output_path, audio_cat, sample_rate)
+ audio_duration_s = float(audio_cat.numel()) / float(sample_rate) if sample_rate > 0 else 0.0
+ ttfp_text = f", ttfp={first_audio_elapsed:.2f}s" if first_audio_elapsed is not None else ""
+ rtf_text = f", rtf={elapsed / audio_duration_s:.3f}" if audio_duration_s > 0 else ""
+ print(
+ f"Saved (streaming) run {run_index + 1}/{num_runs}, "
+ f"prompt {prompt_index + 1}/{prompt_count}: {output_path} ({elapsed:.2f}s{ttfp_text}{rtf_text})"
+ )
+ _emit_offline_metrics(
+ request_id=request_id,
+ elapsed_s=elapsed,
+ first_audio_elapsed=first_audio_elapsed,
+ audio_duration_s=audio_duration_s,
+ )
+ return output_path
+
+
+async def _run_streaming_warmup(args, omni: AsyncOmni) -> None:
+ if args.warmup_runs == 0:
+ return
+
+ warmup_specs = _get_warmup_specs(args.prompt_specs)
+ print(
+ f"Warmup: {args.warmup_runs} run(s) using the first prompt "
+ f"({len(warmup_specs)} prompt(s)); outputs will be discarded."
+ )
+ for warmup_index in range(args.warmup_runs):
+ t_warmup = time.perf_counter()
+ tasks = []
+ request_ids: list[str] = []
+ for prompt_index, spec in enumerate(warmup_specs):
+ request_id = f"warmup_stream_{warmup_index + 1}_{spec.label}_{uuid.uuid4().hex[:8]}"
+ request_ids.append(request_id)
+ tasks.append(
+ _collect_streaming_audio(
+ omni,
+ args,
+ spec,
+ request_id,
+ phase_label=f"warmup={warmup_index + 1}/{args.warmup_runs}",
+ prompt_index=prompt_index,
+ prompt_count=len(warmup_specs),
+ )
+ )
+ results = await asyncio.gather(*tasks)
+ for request_id in request_ids:
+ await _abort_streaming_residual_work(omni, request_id)
+ total_samples = sum(int(audio.numel()) for audio, _, _, _ in results)
+ warmup_ttfps = [ttfp for _, _, _, ttfp in results if ttfp is not None]
+ ttfp_text = f", ttfp={min(warmup_ttfps):.2f}s" if warmup_ttfps else ""
+ print(
+ f"Warmup (streaming) {warmup_index + 1}/{args.warmup_runs} finished: "
+ f"{len(results)} prompt(s), {total_samples} sample(s) "
+ f"({time.perf_counter() - t_warmup:.2f}s{ttfp_text})"
+ )
+
+
+async def _run_streaming(args) -> list[Path]:
+ output_dir = Path(args.output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ omni = AsyncOmni(
+ model=args.model,
+ stage_configs_path=args.stage_configs_path,
+ log_stats=args.log_stats,
+ stage_init_timeout=args.stage_init_timeout,
+ )
+
+ await _run_streaming_warmup(args, omni)
+ profiler_started = False
+ if args.enable_profiler:
+ profile_prefix = f"voxcpm_streaming_{int(time.time())}"
+ stages_text = args.profiler_stages if args.profiler_stages is not None else "all-configured"
+ print(f"Starting profiler (streaming): stages={stages_text}, dir={args.profiler_dir}")
+ await omni.start_profile(profile_prefix=profile_prefix, stages=args.profiler_stages)
+ profiler_started = True
+ t_total = time.perf_counter()
+ total_elapsed = 0.0
+ paths: list[Path] = []
+ prompt_specs: list[PromptSpec] = args.prompt_specs
+ try:
+ for run in range(args.num_runs):
+ for prompt_index, spec in enumerate(prompt_specs):
+ request_id = f"stream_{run + 1}_{spec.label}_{uuid.uuid4().hex[:8]}"
+ paths.append(
+ await _run_streaming_single(
+ omni,
+ args,
+ spec,
+ output_dir,
+ request_id,
+ run_index=run,
+ num_runs=args.num_runs,
+ prompt_index=prompt_index,
+ prompt_count=len(prompt_specs),
+ )
+ )
+ total_elapsed = time.perf_counter() - t_total
+ finally:
+ if profiler_started:
+ print("Stopping profiler (streaming)...")
+ await omni.stop_profile(stages=args.profiler_stages)
+ if args.profiler_wait_seconds > 0:
+ print(f"Waiting {args.profiler_wait_seconds:.1f}s for profiler traces to flush...")
+ await asyncio.sleep(args.profiler_wait_seconds)
+
+ print(
+ f"All streaming runs finished: {args.num_runs} run(s), "
+ f"{len(prompt_specs)} prompt(s), {len(paths)} file(s) in {total_elapsed:.2f}s total"
+ )
+ return paths
+
+
+def _run_sync(args) -> list[Path]:
+ output_dir = Path(args.output_dir)
+
+ omni = Omni(
+ model=args.model,
+ stage_configs_path=args.stage_configs_path,
+ log_stats=args.log_stats,
+ stage_init_timeout=args.stage_init_timeout,
+ )
+
+ def _run_sync_single(
+ spec: PromptSpec,
+ *,
+ request_prefix: str,
+ save_outputs: bool,
+ run_index: int | None = None,
+ ) -> tuple[list[Path], int, float | None, float, float, str]:
+ global_request_id = f"{request_prefix}_{spec.label}"
+ prompt = _build_prompt_for_spec(args, spec, global_request_id=global_request_id)
+ if save_outputs and run_index == 0 and spec.label == "item001":
+ print(f"---prompt---:{prompt}")
+
+ saved_paths: list[Path] = []
+ output_count = 0
+ first_audio_elapsed: float | None = None
+ total_audio_duration_s = 0.0
+ metrics_request_id = global_request_id
+ t_start = time.perf_counter()
+ for stage_outputs in omni.generate(prompt):
+ request_output = stage_outputs.request_output
+ if request_output is None:
+ continue
+ request_output_id = getattr(request_output, "request_id", None)
+ if isinstance(request_output_id, str) and request_output_id:
+ metrics_request_id = request_output_id
+ for j, mm in enumerate(_iter_request_multimodal_outputs(request_output)):
+ output_count += 1
+ if first_audio_elapsed is None:
+ try:
+ audio_tensor = _extract_audio_tensor(mm)
+ if int(audio_tensor.numel()) > 0:
+ first_audio_elapsed = time.perf_counter() - t_start
+ total_audio_duration_s += float(audio_tensor.numel()) / float(_extract_sample_rate(mm))
+ except ValueError:
+ pass
+ else:
+ try:
+ audio_tensor = _extract_audio_tensor(mm)
+ total_audio_duration_s += float(audio_tensor.numel()) / float(_extract_sample_rate(mm))
+ except ValueError:
+ pass
+ if not save_outputs:
+ continue
+ save_stem = f"run{run_index + 1}_{spec.label}" if j == 0 else f"run{run_index + 1}_{spec.label}_{j}"
+ saved_paths.append(_save_wav(mm, output_dir, save_stem))
+
+ if output_count == 0:
+ raise RuntimeError("No output from Omni.generate")
+ elapsed_s = time.perf_counter() - t_start
+ return saved_paths, output_count, first_audio_elapsed, elapsed_s, total_audio_duration_s, metrics_request_id
+
+ if args.warmup_runs:
+ warmup_specs = _get_warmup_specs(args.prompt_specs)
+ print(
+ f"Warmup: {args.warmup_runs} run(s) using the first prompt "
+ f"({len(warmup_specs)} prompt(s)); outputs will be discarded."
+ )
+ for warmup_index in range(args.warmup_runs):
+ t_warmup = time.perf_counter()
+ _, output_count, first_audio_elapsed, elapsed_s, audio_duration_s, _ = _run_sync_single(
+ warmup_specs[0],
+ request_prefix=f"warmup_sync{warmup_index + 1}",
+ save_outputs=False,
+ )
+ ttfp_text = f", ttfp={first_audio_elapsed:.2f}s" if first_audio_elapsed is not None else ""
+ rtf_text = f", rtf={elapsed_s / audio_duration_s:.3f}" if audio_duration_s > 0 else ""
+ print(
+ f"Warmup (sync) {warmup_index + 1}/{args.warmup_runs} finished: "
+ f"{output_count} output(s) ({time.perf_counter() - t_warmup:.2f}s{ttfp_text}{rtf_text})"
+ )
+
+ profiler_started = False
+ if args.enable_profiler:
+ profile_prefix = f"voxcpm_sync_{int(time.time())}"
+ stages_text = args.profiler_stages if args.profiler_stages is not None else "all-configured"
+ print(f"Starting profiler (sync): stages={stages_text}, dir={args.profiler_dir}")
+ omni.start_profile(profile_prefix=profile_prefix, stages=args.profiler_stages)
+ profiler_started = True
+
+ t_total = time.perf_counter()
+ total_elapsed = 0.0
+ saved_paths: list[Path] = []
+ prompt_specs: list[PromptSpec] = args.prompt_specs
+ try:
+ for run in range(args.num_runs):
+ t_run = time.perf_counter()
+ run_paths: list[Path] = []
+ for prompt_index, spec in enumerate(prompt_specs):
+ prompt_paths, _, first_audio_elapsed, elapsed_s, audio_duration_s, metrics_request_id = (
+ _run_sync_single(
+ spec,
+ request_prefix=f"sync_run{run + 1}_{prompt_index + 1:03d}",
+ save_outputs=True,
+ run_index=run,
+ )
+ )
+ run_paths.extend(prompt_paths)
+ ttfp_text = f", ttfp={first_audio_elapsed:.2f}s" if first_audio_elapsed is not None else ""
+ rtf_text = f", rtf={elapsed_s / audio_duration_s:.3f}" if audio_duration_s > 0 else ""
+ print(
+ f"Saved (sync) run {run + 1}/{args.num_runs}, "
+ f"prompt {prompt_index + 1}/{len(prompt_specs)}: {len(prompt_paths)} file(s){ttfp_text}{rtf_text}"
+ )
+ _emit_offline_metrics(
+ request_id=metrics_request_id,
+ elapsed_s=elapsed_s,
+ first_audio_elapsed=first_audio_elapsed,
+ audio_duration_s=audio_duration_s,
+ )
+
+ saved_paths.extend(run_paths)
+ print(
+ f"Run {run + 1}/{args.num_runs} finished: {len(run_paths)} file(s) ({time.perf_counter() - t_run:.2f}s)"
+ )
+ for path in run_paths:
+ print(f" {path}")
+
+ total_elapsed = time.perf_counter() - t_total
+ finally:
+ if profiler_started:
+ print("Stopping profiler (sync)...")
+ omni.stop_profile(stages=args.profiler_stages)
+ if args.profiler_wait_seconds > 0:
+ print(f"Waiting {args.profiler_wait_seconds:.1f}s for profiler traces to flush...")
+ time.sleep(args.profiler_wait_seconds)
+
+ print(
+ f"All sync runs finished: {args.num_runs} run(s), "
+ f"{len(prompt_specs)} prompt(s), {len(saved_paths)} file(s) in {total_elapsed:.2f}s total"
+ )
+ return saved_paths
+
+
+def main(args) -> int:
+ logging.basicConfig(level=logging.INFO)
+ profiled_stage_config_path: str | None = None
+ original_stage_config_path = args.stage_configs_path
+ if args.enable_profiler:
+ Path(args.profiler_dir).mkdir(parents=True, exist_ok=True)
+ profiled_stage_config_path = _build_profiled_stage_config(
+ args.stage_configs_path,
+ str(Path(args.profiler_dir).resolve()),
+ )
+ args.stage_configs_path = profiled_stage_config_path
+
+ is_streaming = _is_streaming_stage_config(args.stage_configs_path)
+ voice_clone_count = _count_voice_clone_prompts(args.prompt_specs)
+ print(f"Model: {args.model}")
+ print(f"Stage config: {original_stage_config_path}")
+ print(f"Route: {'streaming' if is_streaming else 'sync'} (from stage-configs-path)")
+ print(f"Prompt count: {len(args.prompt_specs)}")
+ print("Batch mode: sequential (aligned with native VoxCPM)")
+ print(f"Warmup runs: {args.warmup_runs}")
+ print(f"Voice cloning prompts: {voice_clone_count}/{len(args.prompt_specs)}")
+ if args.enable_profiler:
+ print(f"Profiler: enabled (dir={args.profiler_dir}, stages={args.profiler_stages or 'all-configured'})")
+ print(f"Profiled stage config: {args.stage_configs_path}")
+ if voice_clone_count:
+ print("Voice cloning note: --ref-text/ref_text must match the spoken content of the reference audio.")
+ print(f"Num runs: {args.num_runs}")
+ try:
+ if is_streaming:
+ asyncio.run(_run_streaming(args))
+ else:
+ _run_sync(args)
+ finally:
+ if profiled_stage_config_path is not None and os.path.exists(profiled_stage_config_path):
+ os.unlink(profiled_stage_config_path)
+ return 0
+
+
+if __name__ == "__main__":
+ os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
+ raise SystemExit(main(parse_args()))
diff --git a/benchmarks/voxcpm/vllm_omni/bench_tts_serve.py b/benchmarks/voxcpm/vllm_omni/bench_tts_serve.py
new file mode 100644
index 000000000..816df3279
--- /dev/null
+++ b/benchmarks/voxcpm/vllm_omni/bench_tts_serve.py
@@ -0,0 +1,283 @@
+"""Benchmark VoxCPM via /v1/audio/speech.
+
+Reports TTFP (time to first packet), E2E latency, and RTF (real-time factor).
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import time
+from dataclasses import asdict, dataclass, field
+from datetime import datetime
+from pathlib import Path
+
+import aiohttp
+import numpy as np
+from tqdm.asyncio import tqdm
+
+DEFAULT_MODEL = "OpenBMB/VoxCPM1.5"
+DEFAULT_SAMPLE_RATE = 24000
+PROMPTS = [
+ "Hello, welcome to the VoxCPM speech benchmark.",
+ "This is a short benchmark prompt for online text-to-speech generation.",
+ "The quick brown fox jumps over the lazy dog near the riverbank.",
+ "Please remember to bring your identification documents tomorrow morning.",
+ "Learning a new language takes patience, practice, and curiosity.",
+ "This benchmark reports TTFP and RTF for the VoxCPM online serving path.",
+]
+
+
+@dataclass
+class RequestResult:
+ success: bool = False
+ ttfp: float = 0.0
+ e2e: float = 0.0
+ audio_bytes: int = 0
+ audio_duration: float = 0.0
+ rtf: float = 0.0
+ prompt: str = ""
+ error: str = ""
+
+
+@dataclass
+class BenchmarkResult:
+ concurrency: int = 0
+ num_prompts: int = 0
+ completed: int = 0
+ failed: int = 0
+ duration_s: float = 0.0
+ mean_ttfp_ms: float = 0.0
+ median_ttfp_ms: float = 0.0
+ p95_ttfp_ms: float = 0.0
+ mean_e2e_ms: float = 0.0
+ median_e2e_ms: float = 0.0
+ p95_e2e_ms: float = 0.0
+ mean_rtf: float = 0.0
+ median_rtf: float = 0.0
+ p95_rtf: float = 0.0
+ total_audio_duration_s: float = 0.0
+ request_throughput: float = 0.0
+ per_request: list[dict[str, float | str]] = field(default_factory=list)
+
+
+def pcm_bytes_to_duration(num_bytes: int, sample_rate: int = DEFAULT_SAMPLE_RATE, sample_width: int = 2) -> float:
+ num_samples = num_bytes / sample_width
+ return num_samples / sample_rate
+
+
+async def send_tts_request(
+ session: aiohttp.ClientSession,
+ api_url: str,
+ *,
+ model: str,
+ prompt: str,
+ ref_audio: str | None,
+ ref_text: str | None,
+ pbar: tqdm | None = None,
+) -> RequestResult:
+ payload: dict[str, object] = {
+ "model": model,
+ "input": prompt,
+ "stream": True,
+ "response_format": "pcm",
+ }
+ if ref_audio is not None:
+ payload["ref_audio"] = ref_audio
+ if ref_text is not None:
+ payload["ref_text"] = ref_text
+
+ result = RequestResult(prompt=prompt)
+ started_at = time.perf_counter()
+
+ try:
+ async with session.post(api_url, json=payload) as response:
+ if response.status != 200:
+ result.error = f"HTTP {response.status}: {await response.text()}"
+ return result
+
+ first_chunk = True
+ total_bytes = 0
+ async for chunk in response.content.iter_any():
+ if not chunk:
+ continue
+ if first_chunk:
+ result.ttfp = time.perf_counter() - started_at
+ first_chunk = False
+ total_bytes += len(chunk)
+
+ result.e2e = time.perf_counter() - started_at
+ result.audio_bytes = total_bytes
+ result.audio_duration = pcm_bytes_to_duration(total_bytes)
+ if result.audio_duration > 0:
+ result.rtf = result.e2e / result.audio_duration
+ result.success = True
+ except Exception as e:
+ result.error = str(e)
+ result.e2e = time.perf_counter() - started_at
+
+ if pbar is not None:
+ pbar.update(1)
+ return result
+
+
+async def run_benchmark(
+ *,
+ host: str,
+ port: int,
+ model: str,
+ num_prompts: int,
+ max_concurrency: int,
+ num_warmups: int,
+ ref_audio: str | None,
+ ref_text: str | None,
+) -> BenchmarkResult:
+ api_url = f"http://{host}:{port}/v1/audio/speech"
+ connector = aiohttp.TCPConnector(limit=max_concurrency, limit_per_host=max_concurrency, keepalive_timeout=60)
+ timeout = aiohttp.ClientTimeout(total=600)
+
+ async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
+ if num_warmups > 0:
+ print(f" Warming up with {num_warmups} requests...")
+ warmup_tasks = [
+ send_tts_request(
+ session,
+ api_url,
+ model=model,
+ prompt=PROMPTS[i % len(PROMPTS)],
+ ref_audio=ref_audio,
+ ref_text=ref_text,
+ )
+ for i in range(num_warmups)
+ ]
+ await asyncio.gather(*warmup_tasks)
+ print(" Warmup done.")
+
+ request_prompts = [PROMPTS[i % len(PROMPTS)] for i in range(num_prompts)]
+ semaphore = asyncio.Semaphore(max_concurrency)
+ pbar = tqdm(total=num_prompts, desc=f" concurrency={max_concurrency}")
+
+ async def limited_request(prompt: str) -> RequestResult:
+ async with semaphore:
+ return await send_tts_request(
+ session,
+ api_url,
+ model=model,
+ prompt=prompt,
+ ref_audio=ref_audio,
+ ref_text=ref_text,
+ pbar=pbar,
+ )
+
+ started_at = time.perf_counter()
+ results = await asyncio.gather(*[asyncio.create_task(limited_request(prompt)) for prompt in request_prompts])
+ duration = time.perf_counter() - started_at
+ pbar.close()
+
+ succeeded = [result for result in results if result.success]
+ bench = BenchmarkResult(
+ concurrency=max_concurrency,
+ num_prompts=num_prompts,
+ completed=len(succeeded),
+ failed=len(results) - len(succeeded),
+ duration_s=duration,
+ )
+
+ if not succeeded:
+ return bench
+
+ ttfps = np.array([result.ttfp * 1000 for result in succeeded], dtype=np.float64)
+ e2es = np.array([result.e2e * 1000 for result in succeeded], dtype=np.float64)
+ rtfs = np.array([result.rtf for result in succeeded], dtype=np.float64)
+ audio_durations = np.array([result.audio_duration for result in succeeded], dtype=np.float64)
+
+ bench.mean_ttfp_ms = float(np.mean(ttfps))
+ bench.median_ttfp_ms = float(np.median(ttfps))
+ bench.p95_ttfp_ms = float(np.percentile(ttfps, 95))
+ bench.mean_e2e_ms = float(np.mean(e2es))
+ bench.median_e2e_ms = float(np.median(e2es))
+ bench.p95_e2e_ms = float(np.percentile(e2es, 95))
+ bench.mean_rtf = float(np.mean(rtfs))
+ bench.median_rtf = float(np.median(rtfs))
+ bench.p95_rtf = float(np.percentile(rtfs, 95))
+ bench.total_audio_duration_s = float(np.sum(audio_durations))
+ bench.request_throughput = len(succeeded) / duration if duration > 0 else 0.0
+ bench.per_request = [
+ {
+ "prompt": result.prompt,
+ "ttfp_ms": result.ttfp * 1000,
+ "e2e_ms": result.e2e * 1000,
+ "rtf": result.rtf,
+ "audio_duration_s": result.audio_duration,
+ }
+ for result in succeeded
+ ]
+
+ return bench
+
+
+def print_summary(result: BenchmarkResult) -> None:
+ width = 54
+ print("")
+ print("=" * width)
+ print(f"{'VoxCPM Serving Benchmark':^{width}}")
+ print("=" * width)
+ print(f"concurrency : {result.concurrency}")
+ print(f"requests : {result.completed}/{result.num_prompts} succeeded")
+ print(f"wall time (s) : {result.duration_s:.3f}")
+ print(f"mean TTFP (ms) : {result.mean_ttfp_ms:.2f}")
+ print(f"p95 TTFP (ms) : {result.p95_ttfp_ms:.2f}")
+ print(f"mean E2E (ms) : {result.mean_e2e_ms:.2f}")
+ print(f"p95 E2E (ms) : {result.p95_e2e_ms:.2f}")
+ print(f"mean RTF : {result.mean_rtf:.3f}")
+ print(f"p95 RTF : {result.p95_rtf:.3f}")
+ print(f"request throughput : {result.request_throughput:.2f} req/s")
+ print("=" * width)
+
+
+async def main_async(args) -> None:
+ result_dir = Path(args.result_dir)
+ result_dir.mkdir(parents=True, exist_ok=True)
+
+ all_results: list[BenchmarkResult] = []
+ for concurrency in args.max_concurrency:
+ result = await run_benchmark(
+ host=args.host,
+ port=args.port,
+ model=args.model,
+ num_prompts=args.num_prompts,
+ max_concurrency=concurrency,
+ num_warmups=args.num_warmups,
+ ref_audio=args.ref_audio,
+ ref_text=args.ref_text,
+ )
+ print_summary(result)
+ all_results.append(result)
+
+ payload = {
+ "model": args.model,
+ "created_at": datetime.utcnow().isoformat() + "Z",
+ "results": [asdict(result) for result in all_results],
+ }
+ result_path = result_dir / "bench_tts_serve.json"
+ result_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
+ print(f"Saved results to: {result_path}")
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(description="Benchmark VoxCPM via /v1/audio/speech")
+ parser.add_argument("--host", default="127.0.0.1", help="Server host")
+ parser.add_argument("--port", type=int, default=8091, help="Server port")
+ parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name or path")
+ parser.add_argument("--num-prompts", type=int, default=20, help="Number of prompts to send")
+ parser.add_argument("--max-concurrency", type=int, nargs="+", default=[1], help="Concurrency levels to benchmark")
+ parser.add_argument("--num-warmups", type=int, default=3, help="Warmup request count")
+ parser.add_argument("--ref-audio", default=None, help="Reference audio URL or data URL for voice cloning")
+ parser.add_argument("--ref-text", default=None, help="Reference audio transcript for voice cloning")
+ parser.add_argument("--result-dir", default="results", help="Directory to save benchmark JSON")
+ return parser.parse_args()
+
+
+if __name__ == "__main__":
+ asyncio.run(main_async(parse_args()))
diff --git a/benchmarks/voxcpm/vllm_omni/run_offline_matrix.py b/benchmarks/voxcpm/vllm_omni/run_offline_matrix.py
new file mode 100644
index 000000000..cee46c0f8
--- /dev/null
+++ b/benchmarks/voxcpm/vllm_omni/run_offline_matrix.py
@@ -0,0 +1,303 @@
+"""Run the full offline VoxCPM smoke matrix.
+
+This script keeps the old `test.py` coverage, but delegates each case to
+`bench_tts_offline.py` so the benchmark runner itself stays focused on a
+single execution path.
+"""
+
+from __future__ import annotations
+
+import shlex
+import subprocess
+import sys
+import time
+from dataclasses import dataclass
+from pathlib import Path
+
+from vllm.utils.argparse_utils import FlexibleArgumentParser
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+BENCH_SCRIPT = Path(__file__).with_name("bench_tts_offline.py")
+DEFAULT_STAGE_ASYNC = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm_async_chunk.yaml"
+DEFAULT_STAGE_SYNC = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm.yaml"
+DEFAULT_OUTPUT_ROOT = BENCH_SCRIPT.parents[1] / "results" / "offline_matrix"
+
+SINGLE_TTS_TEXT = "This is a single text-to-speech smoke test for VoxCPM on vLLM Omni."
+SINGLE_CLONE_TEXT = "This sentence is synthesized with the cloned voice for validation."
+BATCH_TTS_TEXTS = [
+ "The first batch text-to-speech sample validates sequential batch execution.",
+ "The second batch text-to-speech sample checks another prompt in the same file.",
+ "The third batch text-to-speech sample completes the sequential batch path.",
+]
+BATCH_CLONE_TEXTS = [
+ "The first cloned sample validates sequential batch voice cloning.",
+ "The second cloned sample checks the same reference voice on another prompt.",
+ "The third cloned sample finishes the shared-reference clone batch path.",
+]
+
+
+@dataclass(frozen=True, slots=True)
+class ModeSpec:
+ name: str
+ stage_config: Path
+
+
+@dataclass(frozen=True, slots=True)
+class CaseSpec:
+ name: str
+ warmup_runs: int
+ prompt_kind: str
+ voice_clone: bool
+
+
+@dataclass(frozen=True, slots=True)
+class CaseResult:
+ mode: str
+ case: str
+ returncode: int
+ elapsed_s: float
+ output_dir: Path
+ log_path: Path
+
+ @property
+ def ok(self) -> bool:
+ return self.returncode == 0
+
+
+MODE_SPECS = [
+ ModeSpec(name="streaming", stage_config=DEFAULT_STAGE_ASYNC),
+ ModeSpec(name="sync", stage_config=DEFAULT_STAGE_SYNC),
+]
+
+CASE_SPECS = [
+ CaseSpec(name="warmup_single_tts", warmup_runs=1, prompt_kind="single", voice_clone=False),
+ CaseSpec(name="warmup_single_clone", warmup_runs=1, prompt_kind="single", voice_clone=True),
+ CaseSpec(name="warmup_batch_tts", warmup_runs=1, prompt_kind="batch", voice_clone=False),
+ CaseSpec(name="warmup_batch_clone", warmup_runs=1, prompt_kind="batch", voice_clone=True),
+ CaseSpec(name="cold_single_tts", warmup_runs=0, prompt_kind="single", voice_clone=False),
+ CaseSpec(name="cold_single_clone", warmup_runs=0, prompt_kind="single", voice_clone=True),
+]
+
+
+def _write_lines(path: Path, lines: list[str]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def _prepare_batch_inputs(output_root: Path) -> tuple[Path, Path]:
+ input_dir = output_root / "inputs"
+ batch_tts_path = input_dir / "batch_tts_prompts.txt"
+ batch_clone_path = input_dir / "batch_clone_prompts.txt"
+ _write_lines(batch_tts_path, BATCH_TTS_TEXTS)
+ _write_lines(batch_clone_path, BATCH_CLONE_TEXTS)
+ return batch_tts_path, batch_clone_path
+
+
+def _base_command(args, mode: ModeSpec, output_dir: Path) -> list[str]:
+ cmd = [
+ args.python,
+ str(BENCH_SCRIPT),
+ "--model",
+ args.model,
+ "--stage-configs-path",
+ str(mode.stage_config),
+ "--output-dir",
+ str(output_dir),
+ "--num-runs",
+ str(args.num_runs),
+ "--stage-init-timeout",
+ str(args.stage_init_timeout),
+ ]
+ cmd.append("--log-stats" if args.log_stats else "--no-log-stats")
+ cmd.extend(["--cfg-value", str(args.cfg_value)])
+ cmd.extend(["--inference-timesteps", str(args.inference_timesteps)])
+ cmd.extend(["--min-len", str(args.min_len)])
+ cmd.extend(["--max-new-tokens", str(args.max_new_tokens)])
+ if args.streaming_prefix_len is not None:
+ cmd.extend(["--streaming-prefix-len", str(args.streaming_prefix_len)])
+ if args.enable_profiler:
+ profiler_dir = Path(args.profiler_dir) if args.profiler_dir is not None else (output_dir / "profiler")
+ cmd.append("--enable-profiler")
+ cmd.extend(["--profiler-dir", str(profiler_dir)])
+ cmd.extend(["--profiler-wait-seconds", str(args.profiler_wait_seconds)])
+ if args.profiler_stages is not None:
+ cmd.append("--profiler-stages")
+ cmd.extend(str(stage_id) for stage_id in args.profiler_stages)
+ return cmd
+
+
+def _build_case_command(
+ args,
+ mode: ModeSpec,
+ case: CaseSpec,
+ *,
+ batch_tts_path: Path,
+ batch_clone_path: Path,
+ output_dir: Path,
+) -> list[str]:
+ cmd = _base_command(args, mode, output_dir)
+ cmd.extend(["--warmup-runs", str(case.warmup_runs)])
+ if case.prompt_kind == "single":
+ cmd.extend(["--text", SINGLE_CLONE_TEXT if case.voice_clone else SINGLE_TTS_TEXT])
+ else:
+ cmd.extend(["--txt-prompts", str(batch_clone_path if case.voice_clone else batch_tts_path)])
+ if case.voice_clone:
+ cmd.extend(["--ref-audio", args.ref_audio, "--ref-text", args.ref_text])
+ return cmd
+
+
+def _run_case(
+ args,
+ mode: ModeSpec,
+ case: CaseSpec,
+ *,
+ batch_tts_path: Path,
+ batch_clone_path: Path,
+ output_root: Path,
+) -> CaseResult:
+ case_output_dir = output_root / mode.name / case.name
+ case_output_dir.mkdir(parents=True, exist_ok=True)
+ case_log_path = case_output_dir / "run.log"
+ cmd = _build_case_command(
+ args,
+ mode,
+ case,
+ batch_tts_path=batch_tts_path,
+ batch_clone_path=batch_clone_path,
+ output_dir=case_output_dir,
+ )
+
+ print()
+ print("=" * 80)
+ print(f"[{mode.name}] {case.name}")
+ print(f"Output directory: {case_output_dir}")
+ print(shlex.join(cmd))
+
+ start = time.perf_counter()
+ with case_log_path.open("w", encoding="utf-8") as log_fp:
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ )
+ assert process.stdout is not None
+ for line in process.stdout:
+ print(line, end="")
+ log_fp.write(line)
+ process.wait()
+
+ elapsed_s = time.perf_counter() - start
+ status = "PASS" if (process.returncode or 0) == 0 else f"FAIL({process.returncode})"
+ print(f"[{mode.name}] {case.name} -> {status} ({elapsed_s:.2f}s)")
+ return CaseResult(
+ mode=mode.name,
+ case=case.name,
+ returncode=int(process.returncode or 0),
+ elapsed_s=elapsed_s,
+ output_dir=case_output_dir,
+ log_path=case_log_path,
+ )
+
+
+def parse_args():
+ parser = FlexibleArgumentParser(description="Run the full offline VoxCPM smoke matrix.")
+ parser.add_argument("--model", type=str, required=True, help="Local VoxCPM model directory.")
+ parser.add_argument("--ref-audio", type=str, required=True, help="Reference audio path for clone cases.")
+ parser.add_argument("--ref-text", type=str, required=True, help="Exact transcript spoken in --ref-audio.")
+ parser.add_argument("--output-root", type=str, default=str(DEFAULT_OUTPUT_ROOT), help="Root directory for outputs.")
+ parser.add_argument("--python", type=str, default=sys.executable, help="Python executable used to launch cases.")
+ parser.add_argument("--stage-init-timeout", type=int, default=600, help="Stage initialization timeout in seconds.")
+ parser.add_argument("--log-stats", dest="log_stats", action="store_true", help="Enable vLLM Omni stats logging.")
+ parser.add_argument(
+ "--no-log-stats",
+ dest="log_stats",
+ action="store_false",
+ help="Disable vLLM Omni stats logging.",
+ )
+ parser.set_defaults(log_stats=True)
+ parser.add_argument("--num-runs", type=int, default=1, help="Number of measured runs per case.")
+ parser.add_argument("--cfg-value", type=float, default=2.0, help="Classifier-free guidance value for VoxCPM.")
+ parser.add_argument("--inference-timesteps", type=int, default=10, help="Number of inference timesteps.")
+ parser.add_argument("--min-len", type=int, default=2, help="Minimum generated token length.")
+ parser.add_argument("--max-new-tokens", type=int, default=4096, help="Maximum generated token length.")
+ parser.add_argument(
+ "--streaming-prefix-len",
+ type=int,
+ default=None,
+ help="Optional VoxCPM streaming window passed to streaming cases.",
+ )
+ parser.add_argument("--enable-profiler", action="store_true", help="Enable torch profiler for each case.")
+ parser.add_argument(
+ "--profiler-dir",
+ type=str,
+ default=None,
+ help="Profiler output root. Defaults to /profiler.",
+ )
+ parser.add_argument(
+ "--profiler-stages",
+ type=int,
+ nargs="*",
+ default=None,
+ help="Optional stage ids to profile. Defaults to all configured stages.",
+ )
+ parser.add_argument(
+ "--profiler-wait-seconds",
+ type=float,
+ default=30.0,
+ help="Seconds to wait after stopping profiler for traces to flush.",
+ )
+ args = parser.parse_args()
+ if args.num_runs < 1:
+ parser.error("--num-runs must be >= 1")
+ return args
+
+
+def main(args) -> int:
+ output_root = Path(args.output_root)
+ output_root.mkdir(parents=True, exist_ok=True)
+ batch_tts_path, batch_clone_path = _prepare_batch_inputs(output_root)
+
+ print(f"Model: {args.model}")
+ print(f"Reference audio: {args.ref_audio}")
+ print(f"Reference text: {args.ref_text}")
+ print(f"Python: {args.python}")
+ print(f"Output root: {output_root}")
+ print(f"Cases: {len(MODE_SPECS) * len(CASE_SPECS)}")
+
+ results: list[CaseResult] = []
+ for mode in MODE_SPECS:
+ for case in CASE_SPECS:
+ results.append(
+ _run_case(
+ args,
+ mode,
+ case,
+ batch_tts_path=batch_tts_path,
+ batch_clone_path=batch_clone_path,
+ output_root=output_root,
+ )
+ )
+
+ failed = [result for result in results if not result.ok]
+ print()
+ print("=" * 80)
+ print("Summary:")
+ for result in results:
+ status = "PASS" if result.ok else f"FAIL({result.returncode})"
+ print(f"- [{result.mode}] {result.case}: {status} ({result.elapsed_s:.2f}s)")
+ print(f" output_dir={result.output_dir}")
+ print(f" log={result.log_path}")
+
+ print(f"Passed: {len(results) - len(failed)}/{len(results)}")
+ if failed:
+ print("Failed cases:")
+ for result in failed:
+ print(f"- [{result.mode}] {result.case}: see {result.log_path}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(parse_args()))
diff --git a/docker/Dockerfile.ci b/docker/Dockerfile.ci
index 2a98de1b8..9cbf89d0b 100644
--- a/docker/Dockerfile.ci
+++ b/docker/Dockerfile.ci
@@ -7,7 +7,7 @@ COPY . .
# Install system dependencies
RUN apt-get update && \
- apt-get install -y espeak-ng git sox libsox-fmt-all jq && \
+ apt-get install -y espeak-ng git jq && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
diff --git a/docker/Dockerfile.cuda b/docker/Dockerfile.cuda
index 6ed5b7d27..28e10f4fb 100644
--- a/docker/Dockerfile.cuda
+++ b/docker/Dockerfile.cuda
@@ -7,7 +7,7 @@ WORKDIR ${COMMON_WORKDIR}
# Step 1: Setup - Install system dependencies
RUN apt-get update && \
- apt-get install -y git sox libsox-fmt-all jq && \
+ apt-get install -y git jq && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
diff --git a/docker/Dockerfile.rocm b/docker/Dockerfile.rocm
index b34478389..a54aa3b79 100644
--- a/docker/Dockerfile.rocm
+++ b/docker/Dockerfile.rocm
@@ -21,7 +21,7 @@ WORKDIR ${COMMON_WORKDIR}
# Need to include ffmpeg because vllm rocm upstream docker image
# does not include it.
RUN apt-get update && \
- apt-get install -y espeak-ng ffmpeg git sox libsox-fmt-all jq && \
+ apt-get install -y espeak-ng ffmpeg git jq && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
diff --git a/docs/.nav.yml b/docs/.nav.yml
index 86ce4a3b0..b0022965a 100644
--- a/docs/.nav.yml
+++ b/docs/.nav.yml
@@ -64,6 +64,7 @@ nav:
- FP8: user_guide/diffusion/quantization/fp8.md
- Int8: user_guide/diffusion/quantization/int8.md
- GGUF: user_guide/diffusion/quantization/gguf.md
+ - Frame Interpolation: user_guide/diffusion/frame_interpolation.md
- Parallelism:
- Overview: user_guide/diffusion/parallelism/overview.md
- CFG Parallel: user_guide/diffusion/parallelism/cfg_parallel.md
@@ -80,7 +81,6 @@ nav:
- Developer Guide:
- General:
- contributing/README.md
- - pr_reviewer.md
- glob: contributing/*
flatten_single_child_sections: true
- Model Implementation:
@@ -97,6 +97,7 @@ nav:
- design/feature/disaggregated_inference.md
- design/feature/ray_based_execution.md
- design/feature/omni_connectors/
+ - design/feature/prefix_caching.md
- design/feature/cfg_parallel.md
- design/feature/expert_parallel.md
- design/feature/sequence_parallel.md
@@ -105,7 +106,7 @@ nav:
- design/feature/hsdp.md
- design/feature/cache_dit.md
- design/feature/teacache.md
- - design/feature/async_chunk_design.md
+ - design/feature/async_chunk.md
- design/feature/vae_parallel.md
- design/feature/diffusion_step_execution.md
- Module Design:
diff --git a/docs/api/README.md b/docs/api/README.md
index f65cbb525..0147f19e1 100644
--- a/docs/api/README.md
+++ b/docs/api/README.md
@@ -5,7 +5,7 @@
Main entry points for vLLM-Omni inference and serving.
- [vllm_omni.entrypoints.async_omni.AsyncOmni][]
-- [vllm_omni.entrypoints.cfg_companion_tracker.CfgCompanionTracker][]
+- [vllm_omni.engine.cfg_companion_tracker.CfgCompanionTracker][]
- [vllm_omni.entrypoints.cli.benchmark.base.OmniBenchmarkSubcommandBase][]
- [vllm_omni.entrypoints.cli.benchmark.main.OmniBenchmarkSubcommand][]
- [vllm_omni.entrypoints.cli.benchmark.serve.OmniBenchmarkServingSubcommand][]
diff --git a/docs/assets/WeChat.jpg b/docs/assets/WeChat.jpg
index 416439f7e..83252b756 100644
Binary files a/docs/assets/WeChat.jpg and b/docs/assets/WeChat.jpg differ
diff --git a/docs/configuration/README.md b/docs/configuration/README.md
index b5761a7f1..390176e9c 100644
--- a/docs/configuration/README.md
+++ b/docs/configuration/README.md
@@ -6,7 +6,7 @@ For options within a vLLM Engine. Please refer to [vLLM Configuration](https://d
Currently, the main options are maintained by stage configs for each model.
-For specific example, please refer to [Qwen2.5-omni stage config](stage_configs/qwen2_5_omni.yaml)
+For a specific example, see the [Qwen2.5-Omni deploy config](gh-file:vllm_omni/deploy/qwen2_5_omni.yaml). The matching frozen pipeline topology lives at [vllm_omni/model_executor/models/qwen2_5_omni/pipeline.py](gh-file:vllm_omni/model_executor/models/qwen2_5_omni/pipeline.py).
For introduction, please check [Introduction for stage config](./stage_configs.md)
diff --git a/docs/configuration/pd_disaggregation.md b/docs/configuration/pd_disaggregation.md
index 1cf6189e6..9196bdb02 100644
--- a/docs/configuration/pd_disaggregation.md
+++ b/docs/configuration/pd_disaggregation.md
@@ -11,7 +11,7 @@ deployment-specific values usually change per environment:
- connector backend and connector ports
- connector IPs or bootstrap addresses
-Start from the [default Qwen3-Omni stage config](gh-file:vllm_omni/model_executor/stage_configs/qwen3_omni_moe.yaml)
+Start from the [default Qwen3-Omni stage config](gh-file:vllm_omni/deploy/qwen3_omni_moe.yaml)
and copy it to your own file, for example `qwen3_omni_pd.yaml`. Then apply the
changes below.
@@ -145,19 +145,13 @@ Compared with the default Qwen3-Omni config:
```yaml
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
edges:
- from: 0
to: 1
- window_size: -1
- from: 1
to: 2
- window_size: -1
- from: 2
to: 3
- window_size: -1
```
## 4. Launch with your custom config
diff --git a/docs/configuration/stage_configs.md b/docs/configuration/stage_configs.md
index 95c42afcc..55b4053cc 100644
--- a/docs/configuration/stage_configs.md
+++ b/docs/configuration/stage_configs.md
@@ -3,7 +3,147 @@
In vLLM-Omni, the target model is separated into multiple stages, which are processed by different LLMEngines, DiffusionEngines or other types of engines. Depending on different types of stages, such as Autoregressive (AR) stage or Diffusion transformer (DiT) stage, each can choose corresponding schedulers, model workers to load with the Engines in a plug-in fashion.
!!! note
- Default stage config YAMLs (for example, `vllm_omni/model_executor/stage_configs/qwen2_5_omni.yaml` and `vllm_omni/model_executor/stage_configs/qwen3_omni_moe.yaml`) are bundled and loaded automatically when `stage_configs_path` is not provided. They have been verified to work on 1xH100 for Qwen2.5-Omni and 2xH100 for Qwen3-Omni.
+ Default deploy config YAMLs (for example, `vllm_omni/deploy/qwen2_5_omni.yaml`, `vllm_omni/deploy/qwen3_omni_moe.yaml`, and `vllm_omni/deploy/qwen3_tts.yaml`) are bundled and loaded automatically when neither `--stage-configs-path` nor `--deploy-config` is provided — the model registry resolves the right pipeline + deploy YAML by `model_type`. The bundled defaults have been verified on 1xH100 for Qwen2.5-Omni and 2xH100 for Qwen3-Omni. Models that have not yet migrated to the new schema continue to use the legacy `vllm_omni/model_executor/stage_configs/.yaml` files via `--stage-configs-path`.
+
+## New deploy schema reference
+
+The new deploy schema lives under `vllm_omni/deploy/` and is paired with a frozen `PipelineConfig` registered by the model's `pipeline.py`. Each deploy YAML has these top-level fields:
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `base_config` | str (path) | optional | — | Overlay parent (relative or absolute). `stages:` / `platforms:` deep-merged by stage_id; other scalars overlay-wins. Intended for user-authored overlays; prod yamls stay flat. |
+| `async_chunk` | bool | optional | `true` | Enable chunked streaming between stages. Pin to `false` if the pipeline runs end-to-end. |
+| `connectors` | dict | optional | `null` | Named connector specs (`{name, extra}`). Referenced by each stage's `input_connectors` / `output_connectors`. See [Connector schema](#connector-schema). |
+| `edges` | list | optional | `null` | Explicit edge list for the KV transfer graph. Auto-derived from stage inputs if omitted. |
+| `stages` | list | required | — | Per-stage engine args + wiring (see [Stage fields](#stage-fields)). |
+| `platforms` | dict | optional | `null` | Keyed by `npu` / `rocm` / `xpu`, each contains a `stages:` list with per-platform overrides applied on top of the CUDA defaults. |
+| `pipeline` | str | optional | `null` | Override the auto-detected pipeline registry key (used for structural variants like `qwen2_5_omni_thinker_only`). |
+| `trust_remote_code` | bool | optional | `true` | **Pipeline-wide.** Trust HF remote code on model load; applies to every stage. |
+| `distributed_executor_backend` | str | optional | `"mp"` | **Pipeline-wide.** Executor backend (`"mp"` or `"ray"`). |
+| `dtype` | str \| null | optional | `null` | **Pipeline-wide.** Model dtype for every stage. |
+| `quantization` | str \| null | optional | `null` | **Pipeline-wide.** Quantization method for every stage. |
+| `enable_prefix_caching` | bool | optional | `false` | **Pipeline-wide.** Prefix cache toggle applied to every stage. |
+| `enable_chunked_prefill` | bool \| null | optional | `null` | **Pipeline-wide.** Chunked prefill toggle applied to every stage. |
+| `data_parallel_size` | int | optional | `1` | **Pipeline-wide.** DP degree for every stage. |
+| `pipeline_parallel_size` | int | optional | `1` | **Pipeline-wide.** PP degree for every stage. |
+
+### Stage fields
+
+Each entry under `stages:` accepts any `StageDeployConfig` field directly (no nested `engine_args:`). Only fields whose value legitimately varies across stages live here; pipeline-wide settings (trust_remote_code, distributed_executor_backend, dtype, quantization, prefix/chunked prefill, DP/PP sizes) are declared at the top level and applied to every stage. Unknown keys fall through to `engine_extras:` and are forwarded to the engine.
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `stage_id` | int | required | — | Stage identity; matched against `PipelineConfig.stages[*].stage_id`. |
+| `max_num_seqs` | int | optional | `64` | Max concurrent sequences per stage. |
+| `gpu_memory_utilization` | float | optional | `0.9` | Per-stage memory budget. |
+| `tensor_parallel_size` | int | optional | `1` | TP degree for this stage. |
+| `enforce_eager` | bool | optional | `false` | Disable CUDA graphs. |
+| `max_num_batched_tokens` | int | optional | `32768` | Prefill budget. |
+| `max_model_len` | int \| null | optional | `null` | Per-stage context length (auto-sets `VLLM_ALLOW_LONG_MAX_MODEL_LEN=1` when larger than HF default). |
+| `async_scheduling` | bool \| null | optional | `null` | Per-stage async scheduling toggle. |
+| `devices` | str | optional | `"0"` | `CUDA_VISIBLE_DEVICES`-style device list. |
+| `output_connectors` | dict \| null | optional | `null` | Keyed by `to_stage_`; values are names registered under top-level `connectors:`. |
+| `input_connectors` | dict \| null | optional | `null` | Keyed by `from_stage_`; values are names registered under top-level `connectors:`. |
+| `default_sampling_params` | dict \| null | optional | `null` | Baseline sampling params. Deep-merged with pipeline `sampling_constraints` (pipeline wins). |
+| `engine_extras` | dict | optional | `{}` | Catch-all for keys not listed above; deep-merged across overlays. Also carries per-stage overrides of pipeline-wide settings (e.g. stage-specific `dtype`). |
+
+### Connector schema
+
+Each entry under top-level `connectors:` follows this shape:
+
+```yaml
+connectors:
+ :
+ name: # required — class registered in vllm_omni.distributed
+ extra: # optional — forwarded to the connector's __init__
+ :
+ ...
+```
+
+| Connector class | Use case | `extra` keys |
+|-----------------|----------|--------------|
+| `SharedMemoryConnector` | Same-host KV transfer between stages (default for bundled YAMLs). | `shm_threshold_bytes` (int, default `65536`). |
+| `MooncakeStoreConnector` | Cross-host KV transfer over TCP. Required for multi-node deployments. | `host`, `metadata_server`, `master`, `segment` (int bytes), `localbuf` (int bytes), `proto` (`"tcp"` / `"rdma"`). |
+
+A stage references a connector by name in its `input_connectors` / `output_connectors`:
+
+```yaml
+connectors:
+ shm:
+ name: SharedMemoryConnector
+
+stages:
+ - stage_id: 0
+ output_connectors: {to_stage_1: shm}
+ - stage_id: 1
+ input_connectors: {from_stage_0: shm}
+```
+
+### CLI flags introduced in this refactor
+
+| Flag | Description |
+|------|-------------|
+| `--deploy-config PATH` | Load a new-schema deploy YAML. Takes precedence over `--stage-configs-path`. **Optional** — when omitted, the bundled `vllm_omni/deploy/.yaml` is auto-loaded by the model registry. |
+| `--stage-overrides JSON` | Per-stage JSON overrides, e.g. `'{"0":{"gpu_memory_utilization":0.5}}'`. Per-stage values always win over global flags. |
+| `--async-chunk` / `--no-async-chunk` | Flip the deploy YAML's `async_chunk:` bool. Unset (default) leaves the YAML value in force. |
+| `--stage-configs-path` | **Deprecated.** Accepts legacy `stage_args` yamls and (auto-detected) new deploy yamls; emits a deprecation warning. Migrate to `--deploy-config`. To be removed in a follow-up PR. |
+
+### Precedence
+
+From highest to lowest:
+
+1. Per-stage flags (`--stage-overrides` JSON, `--stage--` if registered)
+2. Explicit global CLI flags (`--gpu-memory-utilization 0.85`, etc.)
+3. Platform section (`platforms.npu.stages`, etc.) on top of the base `stages:`
+4. Overlay YAML (via `base_config:`) on top of the base YAML
+5. Parser defaults
+
+### Worked override example
+
+Starting from the bundled `vllm_omni/deploy/qwen3_omni_moe.yaml`:
+
+```yaml
+# vllm_omni/deploy/qwen3_omni_moe.yaml (excerpt)
+async_chunk: true
+stages:
+ - stage_id: 0
+ gpu_memory_utilization: 0.9
+ max_num_seqs: 32
+ - stage_id: 1
+ gpu_memory_utilization: 0.7
+ max_num_seqs: 16
+```
+
+A user-authored overlay that inherits the base and overrides only stage 1:
+
+```yaml
+# my_overrides.yaml
+base_config: /path/to/vllm_omni/deploy/qwen3_omni_moe.yaml
+stages:
+ - stage_id: 1
+ gpu_memory_utilization: 0.5 # smaller GPU
+```
+
+Launched with both an explicit global flag and a per-stage override:
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
+ --deploy-config my_overrides.yaml \
+ --max-model-len 16384 \
+ --stage-overrides '{"0": {"max_num_seqs": 8}}'
+```
+
+Effective config per stage after the merge:
+
+| Stage | Field | Final value | Source |
+|-------|-------|-------------|--------|
+| 0 | `gpu_memory_utilization` | `0.9` | base YAML (overlay didn't touch stage 0) |
+| 0 | `max_num_seqs` | `8` | per-stage CLI (`--stage-overrides`) — wins over base `32` |
+| 0 | `max_model_len` | `16384` | global CLI |
+| 1 | `gpu_memory_utilization` | `0.5` | overlay YAML — wins over base `0.7` |
+| 1 | `max_num_seqs` | `16` | base YAML (overlay didn't touch this field) |
+| 1 | `max_model_len` | `16384` | global CLI |
+| 2 | (all defaults) | — | base YAML (no overrides apply) |
Therefore, as a core part of vLLM-Omni, the stage configs for a model have several main functions:
@@ -35,7 +175,7 @@ stage_args:
- stage_id: 0 # mark the unique id for each stage
runtime: # The disaggregated configuration
process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
+ devices: "0" # Logical device index for this stage (mapped through CUDA_VISIBLE_DEVICES / ASCEND_RT_VISIBLE_DEVICES if set)
engine_args: # Engine arguments for a certain engine
model_stage: thinker
max_num_seqs: 1
@@ -114,16 +254,12 @@ stage_args:
# Top-level runtime config (concise): default windows and stage edges
runtime:
enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
+
edges:
- from: 0 # thinker → talker: trigger only after receiving full input (-1)
to: 1
- window_size: -1
- from: 1 # talker → code2wav: trigger only after receiving full input (-1)
to: 2
- window_size: -1
```
@@ -155,7 +291,9 @@ Default: `true`
#### `runtime.devices`
-Visible devices for this stage, specified as a string. This controls which GPU devices are available to the stage process, similar to setting `CUDA_VISIBLE_DEVICES` or using `torch.cuda.set_device()`. For example, `"0"` uses GPU 0, `"1"` uses GPU 1, and `"0,1"` makes both GPUs 0 and 1 visible.
+Logical device indices for this stage, specified as a string. Values are **logical indices** (`0`, `1`, `2`, ...) — not physical GPU IDs — and are mapped through the platform's visibility env var (`CUDA_VISIBLE_DEVICES` on CUDA, `ASCEND_RT_VISIBLE_DEVICES` on NPU) before being applied via `torch.cuda.set_device()` (or the equivalent).
+
+Example: if `CUDA_VISIBLE_DEVICES=0,2,4` is set in the environment, then `devices: "0"` selects physical GPU 0 (the first visible), `devices: "1"` selects physical GPU 2, and `devices: "0,1"` makes physical GPUs 0 and 2 available to the stage. If no visibility env var is set, logical and physical IDs coincide.
Default: `"0"`
diff --git a/docs/configuration/stage_configs/qwen2_5_omni.yaml b/docs/configuration/stage_configs/qwen2_5_omni.yaml
deleted file mode 100644
index 690577b84..000000000
--- a/docs/configuration/stage_configs/qwen2_5_omni.yaml
+++ /dev/null
@@ -1,94 +0,0 @@
-# stage config for running qwen2.5-omni with AsyncOmniEngine + Orchestrator runtime.
-stage_args:
- - stage_id: 0
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
- engine_args:
- model_stage: thinker
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.8
- enforce_eager: true # Now we only support eager mode
- trust_remote_code: true
- engine_output_type: latent
- enable_prefix_caching: false
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
- - stage_id: 1
- runtime:
- process: true
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.8
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: latent
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen2_5_omni.thinker2talker
- default_sampling_params:
- temperature: 0.9
- top_p: 0.8
- top_k: 40
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
- stop_token_ids: [8294]
- - stage_id: 2
- runtime:
- process: true
- devices: "0" # Example: use a different GPU than the previous stage; use "0" if single GPU
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- gpu_memory_utilization: 0.15
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio
- engine_input_source: [1]
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
-# Top-level runtime config (concise): default windows and stage edges
-runtime:
- enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
- edges:
- - from: 0 # thinker → talker: trigger only after receiving full input (-1)
- to: 1
- window_size: -1
- - from: 1 # talker → code2wav: trigger only after receiving full input (-1)
- to: 2
- window_size: -1
diff --git a/docs/contributing/ci/CI_5levels.md b/docs/contributing/ci/CI_5levels.md
index 930603573..2452ef5d4 100644
--- a/docs/contributing/ci/CI_5levels.md
+++ b/docs/contributing/ci/CI_5levels.md
@@ -86,7 +86,8 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
/tests/e2e/online_serving/test_{model_name}_expansion.py
/tests/e2e/offline_inference/test_{model_name}_expansion.py
Performance:
- /tests/dfx/perf/tests/test.json
+ /tests/dfx/perf/tests/test_qwen_omni.json (Omni), test_tts.json (TTS),
+ and /tests/dfx/perf/tests/test_{diffusion_model}_vllm_omni.json (Diffusion)
Doc Test:
tests/example/online_serving/test_{model_name}.py
tests/example/offline_inference/test_{model_name}.py
@@ -230,8 +231,7 @@ vllm_omni/ tests/
│ ├── test_qwen3_omni_expansion.py
│ ├── test_mimo_audio.py
│ ├── test_image_gen_edit.py
- │ ├── test_images_generations_lora.py
- │ └── stage_configs/
+ │ └── test_images_generations_lora.py
└── offline_inference/ ✅
├── test_qwen2_5_omni.py
├── test_qwen3_omni.py
@@ -247,11 +247,12 @@ vllm_omni/ tests/
├── test_diffusion_layerwise_offload.py
├── test_diffusion_lora.py
├── test_sequence_parallel.py
- └── stage_configs/
- ├── qwen2_5_omni_ci.yaml
- ├── qwen3_omni_ci.yaml
- ├── bagel_*.yaml
- └── npu/, rocm/, etc.
+ └── stage_configs/ (legacy schema, still
+ ├── bagel_*.yaml present for unmigrated
+ └── npu/, rocm/, etc. models)
+
+# Migrated models (qwen3_omni_moe, qwen2_5_omni, qwen3_tts) live under
+# vllm_omni/deploy/ instead — see docs/configuration/stage_configs.md.
```
@@ -530,13 +531,13 @@ L4 level testing is a comprehensive quality audit before a version release. It e
### 3.2 Testing Content and Scope
- ***Full Functionality Testing***: Executes all test cases defined in `test_{model_name}_expansion.py`, covering all implemented features, positive flows, boundary conditions, and exception handling.
-- ***Performance Testing***: Uses the `tests/dfx/perf/tests/test.json` configuration file to drive performance testing tools for stress, load, and endurance tests, collecting metrics like throughput, response time, and resource utilization.
+- ***Performance Testing***: Uses `tests/dfx/perf/tests/test_qwen_omni.json`, `tests/dfx/perf/tests/test_tts.json`, and diffusion configs in the form `tests/dfx/perf/tests/test_*_vllm_omni.json` (passed to `run_benchmark.py` via `--test-config-file`) to drive performance testing tools for stress, load, and endurance tests, collecting metrics like throughput, response time, and resource utilization.
- ***Documentation Testing***: Verifies whether the example code provided to users is runnable and its results match the description.
### 3.3 Test Directory and Execution Files
- ***Functional Testing***: Same directories as L3.
-- ***Performance Test Configuration***: `tests/dfx/perf/tests/test.json`
+- ***Performance Test Configuration***: `tests/dfx/perf/tests/test_qwen_omni.json`, `tests/dfx/perf/tests/test_tts.json`, and diffusion configs `tests/dfx/perf/tests/test_*_vllm_omni.json` (e.g. `test_qwen_image_vllm_omni.json`)
- ***Documentation Example Tests***:
- - `tests/example/online_serving/test_{model_name}.py`
- `tests/example/offline_inference/test_{model_name}.py`
diff --git a/docs/contributing/ci/test_examples/l4_functionality_tests.inc.md b/docs/contributing/ci/test_examples/l4_functionality_tests.inc.md
index 69d6ad828..ab4deecd6 100644
--- a/docs/contributing/ci/test_examples/l4_functionality_tests.inc.md
+++ b/docs/contributing/ci/test_examples/l4_functionality_tests.inc.md
@@ -40,7 +40,7 @@ Currently all the features are available in online serving mode. Hence, only nee
- Test marks: always add `advanced_model` and `diffusion`. Add GPU-related marks if needed. Ref: [Markers for Tests](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/tests_markers/).
- To maximize code reuse, you may refer to
- `tests/conftest.py` for `omni_server` (running server in subprocess) and `openai_client` fixtures (sending requests and validating output), `generate_synthetic_image` and `assert_XXX_valid` helper.
- - `tests/utils.py` for `@hardware_test(...)` and `hardware_marks`.
+ - `tests/helpers/mark.py` for `@hardware_test(...)` and `hardware_marks`.
- [Parametrizing tests (pytest doc)](https://docs.pytest.org/en/stable/example/parametrize.html) to reuse test function implementation for different cases.
- Doc: add a concise docstring for each test function.
- Reference L4 test implementation: [tests/e2e/online_serving/test_qwen_image_edit_expansion.py](https://github.com/vllm-project/vllm-omni/blob/main/tests/e2e/online_serving/test_qwen_image_edit_expansion.py).
diff --git a/docs/contributing/ci/test_examples/l4_performance_tests.inc.md b/docs/contributing/ci/test_examples/l4_performance_tests.inc.md
index 8093e1459..f1f3073dc 100644
--- a/docs/contributing/ci/test_examples/l4_performance_tests.inc.md
+++ b/docs/contributing/ci/test_examples/l4_performance_tests.inc.md
@@ -1,4 +1,4 @@
-When you want to add L4-level ***performance test*** cases, you can refer to the following format for case addition in tests/dfx/perf/tests/test.json:
+When you want to add L4-level ***performance test*** cases, you can refer to the following format for case addition in `tests/dfx/perf/tests/test_qwen_omni.json`, `tests/dfx/perf/tests/test_tts.json`, or diffusion configs such as `tests/dfx/perf/tests/test_*_vllm_omni.json` (selected via `pytest ... run_benchmark.py --test-config-file `):
```JSON
{
diff --git a/docs/contributing/ci/test_guide.md b/docs/contributing/ci/test_guide.md
index 425f24332..08b2e3b4e 100644
--- a/docs/contributing/ci/test_guide.md
+++ b/docs/contributing/ci/test_guide.md
@@ -45,7 +45,6 @@ Our test scripts use the pytest framework. First, please use `git clone https://
=== "L3 level & L4 level"
```bash
- cd tests
pytest -s -v -m "advanced_model" --run-level=advanced_model
```
If you only want to run L3 test case, you can use:
@@ -60,9 +59,9 @@ Our test scripts use the pytest framework. First, please use `git clone https://
```bash
pytest -s -v -m "core_model and distributed_cuda and L4" --run-level=core_model
```
- Note: To run performance tests, use:
+ Note: To run performance tests (defaults to ``test_qwen_omni.json``; use ``--test-config-file tests/dfx/perf/tests/test_tts.json`` for TTS):
```bash
- pytest -s -v perf/scripts/run_benchmark.py
+ pytest -s -v tests/dfx/perf/scripts/run_benchmark.py
```
The latest L3 test commands for various test suites can be found in the [pipeline](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-merge.yml).
diff --git a/docs/contributing/ci/tests_markers.md b/docs/contributing/ci/tests_markers.md
index 7c1ba1c73..7628db284 100644
--- a/docs/contributing/ci/tests_markers.md
+++ b/docs/contributing/ci/tests_markers.md
@@ -38,7 +38,7 @@ Defined in `pyproject.toml`:
### Example usage for markers
```python
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
@pytest.mark.core_model
@pytest.mark.omni
@@ -53,7 +53,7 @@ def test_video_to_audio()
### Decorator: `@hardware_test`
-This decorator is intended to make hardware-aware, cross-platform test authoring easier and more robust for CI/CD environments. The `hardware_test` decorator in `vllm-omni/tests/utils.py` performs the following actions:
+This decorator is intended to make hardware-aware, cross-platform test authoring easier and more robust for CI/CD environments. The `hardware_test` decorator in `vllm-omni/tests/helpers/mark.py` performs the following actions:
1. **Applies platform and resource markers**
Adds the appropriate pytest markers for each specified hardware platform (e.g., `cuda`, `rocm`, `xpu`, `npu`) and resource type (e.g., `L4`, `H100`, `MI325`, `B60`, `A2`, `A3`).
@@ -105,7 +105,7 @@ This decorator is intended to make hardware-aware, cross-platform test authoring
`hardware_marks` returns a list of pytest mark objects with the same signature as `@hardware_test`. Use it when you need more flexibility, such as attaching hardware marks to individual `pytest.param` entries rather than an entire test function.
```python
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
MULTI_CARD_MARKS = hardware_marks(
res={"cuda": "H100", "rocm": "MI325", "npu": "A2"}, num_cards=2
@@ -133,9 +133,9 @@ If you want to add support for a new platform (e.g., "tpu" for a new accelerator
"distributed_tpu: Tests that require multiple TPU devices",
]
```
-2. **Implement a marker construction function for your platform** in `vllm-omni/tests/utils.py`:
+2. **Implement a marker construction function for your platform** in `vllm-omni/tests/helpers/mark.py`:
```python
- # In vllm-omni/tests/utils.py
+ # In vllm-omni/tests/helpers/mark.py
def tpu_marks(*, res: str, num_cards: int):
test_platform = pytest.mark.tpu
@@ -175,4 +175,4 @@ If you want to add support for a new platform (e.g., "tpu" for a new accelerator
- Plug into `hardware_marks`
- You're done: tests using `@hardware_test` or `hardware_marks` with your platform now automatically get the correct markers, distribution, and isolation!
-See code in `vllm-omni/tests/utils.py` for existing examples (`cuda_marks`, `rocm_marks`, `npu_marks`).
+See code in `vllm-omni/tests/helpers/mark.py` for existing examples (`cuda_marks`, `rocm_marks`, `npu_marks`).
diff --git a/docs/contributing/ci/tests_style.md b/docs/contributing/ci/tests_style.md
index 69d5b16d7..3a8cb0f12 100644
--- a/docs/contributing/ci/tests_style.md
+++ b/docs/contributing/ci/tests_style.md
@@ -135,8 +135,7 @@ vllm_omni/ tests/
│ ├── test_qwen3_omni_expansion.py
│ ├── test_mimo_audio.py
│ ├── test_image_gen_edit.py
- │ ├── test_images_generations_lora.py
- │ └── stage_configs/
+ │ └── test_images_generations_lora.py
└── offline_inference/ ✅
├── test_qwen2_5_omni.py
├── test_qwen3_omni.py
@@ -153,11 +152,12 @@ vllm_omni/ tests/
├── test_diffusion_lora.py
├── test_sequence_parallel.py
├── test_qwen_image_edit_expansion.py
- └── stage_configs/
- ├── qwen2_5_omni_ci.yaml
- ├── qwen3_omni_ci.yaml
- ├── bagel_*.yaml
+ └── stage_configs/ (legacy schema, still present
+ ├── bagel_*.yaml for unmigrated models)
└── npu/, rocm/, etc.
+
+# Migrated models (qwen3_omni_moe, qwen2_5_omni, qwen3_tts) live under
+# vllm_omni/deploy/ instead — see docs/configuration/stage_configs.md.
examples/ tests
│ └── examples
├── online_serving/ → ├── online_serving/
@@ -221,14 +221,13 @@ from pathlib import Path
import openai
import pytest
-from tests.conftest import (
- OmniServer,
- convert_audio_to_text,
+from tests.helpers.media import (
+ convert_audio_bytes_to_text,
cosine_similarity_text,
- dummy_messages_from_mix_data,
generate_synthetic_video,
- merge_base64_and_convert_to_text,
)
+from tests.helpers.runtime import OmniServer, dummy_messages_from_mix_data
+from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.platforms import current_omni_platform
# Edit: model name and stage config path
@@ -236,7 +235,7 @@ models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
#If you use the default configuration file, you can directly use the following address.
def get_default_config():
- return str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml")
+ return get_deploy_config_path("ci/qwen3_omni_moe.yaml")
#If you need to modify the configuration file, you can use modify_stage_config.
def get_chunk_config():
@@ -405,7 +404,7 @@ def test_mix_to_text_audio_001(client: openai.OpenAI, omni_server, request) -> N
# PURPOSE: Verify text and audio outputs convey the same information
# CUSTOMIZATION: Adjust similarity threshold (0.9) based on accuracy requirements
assert audio_data is not None, "No audio output is generated"
- audio_content = merge_base64_and_convert_to_text(audio_data)
+ audio_content = convert_audio_bytes_to_text(audio_data)
print(f"text content is: {text_content}")
print(f"audio content is: {audio_content}")
similarity = cosine_similarity_text(audio_content.lower(), text_content.lower())
@@ -428,7 +427,7 @@ from pathlib import Path
import pytest
from vllm.assets.video import VideoAsset
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
from ..multi_stages.conftest import OmniRunner
# Optional: set process start method for workers
diff --git a/docs/contributing/model/adding_omni_model.md b/docs/contributing/model/adding_omni_model.md
index a0619e338..1eaff1059 100644
--- a/docs/contributing/model/adding_omni_model.md
+++ b/docs/contributing/model/adding_omni_model.md
@@ -313,7 +313,7 @@ The registry uses lazy loading, so the model class is imported only when needed.
## Stage Configuration
-Create a YAML configuration file in `vllm_omni/model_executor/stage_configs/`. For a complete example, see the [Qwen3-Omni configuration file](gh-file:vllm_omni/model_executor/stage_configs/qwen3_omni_moe.yaml).
+Create a YAML configuration file in `vllm_omni/deploy/`. For a complete example, see the [Qwen3-Omni configuration file](gh-file:vllm_omni/deploy/qwen3_omni_moe.yaml).
### Key Configuration Fields
@@ -408,18 +408,17 @@ Understanding the data structures is crucial for implementing stage transitions:
**Input to your function:**
- `stage_list[source_stage_id].engine_outputs`: List of `EngineCoreOutput` objects
- - Each contains `outputs`: List of `RequestOutput` objects
- - Each `RequestOutput` has:
- - `token_ids`: Generated token IDs
- - `multimodal_output`: Dict with keys like `"code_predictor_codes"`, etc.
- - These are the hidden states or intermediate outputs from the model's forward pass
- - `prompt_token_ids`: Original prompt token IDs
+- - Each contains `outputs`: List of `RequestOutput` objects
+ - Each `RequestOutput` has:
+- - - `token_ids`: Generated token IDs
+ - `multimodal_output`: Dict with keys like `"code_predictor_codes"`, etc.These are the hidden states or intermediate outputs from the model's forward pass
+ - `prompt_token_ids`: Original prompt token IDs
**Output from your function:**
- Must return `list[OmniTokensPrompt]` where each `OmniTokensPrompt` contains:
- - `prompt_token_ids`: List[int] - Token IDs for the next stage
- - `additional_information`: Dict[str, Any] - Optional metadata (e.g., embeddings, hidden states)
- - `multi_modal_data`: Optional multimodal data if needed
+- - `prompt_token_ids`: List[int] - Token IDs for the next stage
+ - `additional_information`: Dict[str, Any] - Optional metadata (e.g., embeddings, hidden states)
+ - `multi_modal_data`: Optional multimodal data if needed
### How Model Outputs Are Stored
@@ -614,7 +613,7 @@ For a complete reference implementation, see:
- **Thinker**: `vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_thinker.py`
- **Talker**: `vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_talker.py`
- **Code2Wav**: `vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_code2wav.py`
-- **Stage config**: `vllm_omni/model_executor/stage_configs/qwen3_omni_moe.yaml`
+- **Stage config**: `vllm_omni/deploy/qwen3_omni_moe.yaml`
- **Input processors**: `vllm_omni/model_executor/stage_input_processors/qwen3_omni.py`
- **Registry**: `vllm_omni/model_executor/models/registry.py`
- **Testing**: `vllm_omni/tests/e2e/offline_inference/test_qwen3_omni.py`
diff --git a/docs/contributing/model/adding_tts_model.md b/docs/contributing/model/adding_tts_model.md
index e48ae5049..622064173 100644
--- a/docs/contributing/model/adding_tts_model.md
+++ b/docs/contributing/model/adding_tts_model.md
@@ -28,7 +28,7 @@ and can be placed on different devices. Qwen3-TTS has two stages:
Each stage is a separate model class configured independently via YAML. The two stages
are connected by the `async_chunk` framework, which enables inter-stage streaming for
-low first-packet latency (see [Async Chunk Design](../../design/feature/async_chunk_design.md)).
+low first-packet latency (see [Async Chunk Design](../../design/feature/async_chunk.md)).
### Without async_chunk (batch mode)
@@ -120,8 +120,18 @@ vllm_omni/model_executor/stage_configs/
| `models/qwen3_tts/qwen3_tts.py` | Unified model class |
| `models/qwen3_tts/qwen3_tts_code_predictor_vllm.py` | Stage 0 - optimized AR |
| `models/qwen3_tts/qwen3_tts_code2wav.py` | Stage 1 - decoder |
-| `stage_configs/qwen3_tts.yaml` | Stage config (async_chunk enabled) |
-| `stage_configs/qwen3_tts_batch.yaml` | Batch mode config |
+| `deploy/qwen3_tts.yaml` (new schema) | Deploy config (async_chunk enabled) — paired with `models/qwen3_tts/pipeline.py` for the frozen topology |
+
+> **Chunked vs end-to-end modes**: `qwen3_tts` registers a single
+> pipeline whose stage 1 declares alternate processor functions — an
+> `async_chunk_process_next_stage_input_func` (per-chunk streaming, used
+> when `deploy.async_chunk=True`) and a `sync_process_input_func`
+> (batch-end, used when `deploy.async_chunk=False`). The loader selects
+> one at merge time based on the bool, so `--no-async-chunk` alone
+> switches modes — no variant yaml or variant pipeline registration is
+> needed. Pipelines that only make sense in one mode (e.g.
+> `qwen3_omni_moe` is always chunked) can keep using the unconditional
+> `custom_process_*` fields.
| `stage_input_processors/qwen3_tts.py` | Stage transition processors |
## Step-by-Step Implementation
@@ -574,11 +584,12 @@ Adding a TTS model to vLLM-Omni involves:
| `models/qwen3_tts/qwen3_tts.py` | Unified model class |
| `models/qwen3_tts/qwen3_tts_code_predictor_vllm.py` | AR stage with vLLM fused ops |
| `models/qwen3_tts/qwen3_tts_code2wav.py` | Decoder stage with `chunked_decode_streaming()` |
-| `stage_configs/qwen3_tts.yaml` | Stage configuration |
+| `models/qwen3_tts/pipeline.py` | Frozen pipeline topology (registered at import time) |
+| `deploy/qwen3_tts.yaml` | Deploy config (user-editable, async_chunk + SharedMemoryConnector) |
| `stage_input_processors/qwen3_tts.py` | Stage transition processors |
For more information, see:
- [Architecture Overview](../../design/architecture_overview.md)
-- [Async Chunk Design](../../design/feature/async_chunk_design.md)
+- [Async Chunk Design](../../design/feature/async_chunk.md)
- [Stage Configuration Guide](../../configuration/stage_configs.md)
diff --git a/docs/contributing/profiling.md b/docs/contributing/profiling.md
index 418fb707a..a7b508302 100644
--- a/docs/contributing/profiling.md
+++ b/docs/contributing/profiling.md
@@ -127,19 +127,38 @@ Multi-stage omni serving:
```bash
vllm serve Qwen/Qwen2.5-Omni-7B \
--omni \
- --stage-configs-path qwen2_5_omni.yaml \
--port 8091
```
+(The default deploy config at `vllm_omni/deploy/qwen2_5_omni.yaml` is loaded automatically. Pass `--deploy-config /path/to/custom.yaml` to override.)
+
Single-stage diffusion serving with torch profiler:
```bash
vllm serve Wan-AI/Wan2.2-I2V-A14B-Diffusers \
--omni \
--port 8091 \
- --profiler-config '{"profiler": "torch", "torch_profiler_dir": "./vllm_profile"}'
+ --profiler-config '{
+ "profiler": "torch",
+ "torch_profiler_dir": "/tmp/vllm_profile_wan22_i2v",
+ "torch_profiler_with_stack": true,
+ "torch_profiler_with_flops": false,
+ "torch_profiler_use_gzip": true,
+ "torch_profiler_dump_cuda_time_total": true,
+ "torch_profiler_record_shapes": true,
+ "torch_profiler_with_memory": true,
+ "delay_iterations": 0,
+ "max_iterations": 0
+ }'
```
+Useful optional fields:
+
+- `torch_profiler_with_stack`: export `by_stack` operator views and stack text files.
+- `torch_profiler_record_shapes`: export `by_shape` operator views.
+- `torch_profiler_with_memory`: dump `memory_snapshot_rank*.pickle` when the backend supports memory history.
+- `torch_profiler_use_gzip`: write the trace as `trace_rank*.json.gz`.
+
Single-stage diffusion serving with Nsight Systems:
```bash
@@ -176,8 +195,11 @@ For mixed-stage pipelines, use explicit `stages` and pass the same stage list to
Torch profiler output:
-- Chrome/Perfetto traces under `torch_profiler_dir`
-- Optional aggregated CUDA-time tables under the same directory
+- Chrome/Perfetto trace: `trace_rank*.json` or `trace_rank*.json.gz`
+- Excel workbook: `ops_rank*.xlsx` with `summary`, and optional `by_shape` / `by_stack` sheets
+- Stack exports: `stacks_cpu_rank*.txt` and `stacks_cuda_rank*.txt` when stack capture is enabled
+- Memory snapshot: `memory_snapshot_rank*.pickle` when memory capture is enabled and supported by the backend
+- Optional aggregated CUDA-time tables under the same session directory
CUDA profiler / Nsight Systems output:
diff --git a/docs/design/feature/async_chunk_design.md b/docs/design/feature/async_chunk.md
similarity index 99%
rename from docs/design/feature/async_chunk_design.md
rename to docs/design/feature/async_chunk.md
index 45314a0ae..57b4209b8 100644
--- a/docs/design/feature/async_chunk_design.md
+++ b/docs/design/feature/async_chunk.md
@@ -1,4 +1,4 @@
-# Async Chunk Design
+# Async Chunk
## Table of Contents
@@ -88,8 +88,9 @@ The following diagram illustrates the **Async Chunk Architecture** for multi-sta
**Diagram Legend:**
+
| Step | Stage Type | Description |
-|:------:|:-----------:|:------------|
+|------|-----------|------------|
| `prefill` | Initialization | Context processing, KV cache initialization |
| `decode` | Autoregressive | Token-by-token generation in AR stages |
| `codes` | Audio Encoding | RVQ codec codes from Talker stage |
diff --git a/docs/design/feature/expert_parallel.md b/docs/design/feature/expert_parallel.md
index 9a7c4cdba..e05eec336 100644
--- a/docs/design/feature/expert_parallel.md
+++ b/docs/design/feature/expert_parallel.md
@@ -207,9 +207,9 @@ Complete examples in the codebase:
| Model | Path | Pattern | Notes |
|-------|------|---------|-------|
-| **HunyuanImage3.0** | `vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_image_3_transformer.py` | Standard EP | Full implementation with validation |
+| **HunyuanImage3.0** | `vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py` | Standard EP | Full implementation with validation |
| **EP Tests** | `vllm-omni/tests/e2e/offline_inference/test_expert_parallel.py` | E2E testing | EP correctness and performance |
-| **Constraint Tests** | `vllm-omni/tests/diffusion/models/hunyuan_image_3/test_hunyuan_fused_moe.py` | Unit testing | Validation logic |
+| **Constraint Tests** | `vllm-omni/tests/diffusion/models/hunyuan_image3/test_hunyuan_fused_moe.py` | Unit testing | Validation logic |
---
## Summary
diff --git a/docs/design/feature/prefix_caching.md b/docs/design/feature/prefix_caching.md
new file mode 100644
index 000000000..ebad8b691
--- /dev/null
+++ b/docs/design/feature/prefix_caching.md
@@ -0,0 +1,164 @@
+# Automatic Prefix Caching in Omni Models
+
+
+---
+
+## Table of Contents
+
+- [Overview](#overview)
+- [High-Level Approach](#high-level-approach)
+- [Example](#example)
+- [What About Multimodal Inputs?](#what-about-multimodal-inputs)
+
+---
+
+### Overview
+
+Prefix caching in the context of kv-cache management is a useful optimization for avoiding redundant computations. The main idea is that we store portions of the kv-cache from processed requests, so that we can reuse them if incoming requests have the same prefix as previous requests.
+
+vLLM manages the kv-cache as blocks, which represent a span of tokens of a fixed length. Blocks are hashable by the content that they contain, which typically means the tokens within the span, but also could be influenced by other factors, e.g., LoRA and multimodal data.
+
+vLLM implements automatic prefix caching for managing its kv-cache, which is best understood by reading the design document [here](https://docs.vllm.ai/en/latest/design/prefix_caching/). vLLM-Omni builds on top of the prefix caching mechanism in a noninvasive way to allow caching between stages in Omni pipelines. This typically means for a given stage we aim to support caching for the following:
+
+- The last hidden states produced by the stage
+- Model / stage specific multimodal data
+
+!!! note "Note 1"
+ This document describes vLLM-Omni's mechanism for caching tensor outputs that are meant to be passed between stages, when requests have common prefixes, similar to the way in which vLLM has prefix caching for the kv-cache. This works in conjunction with vLLM's multimodal encoder caching, but is distinct. See the final section for a concrete example for how they tie together in practice.
+
+### High-Level Approach
+!!! note "Note 2"
+ Prior to reading this section, it's recommended to take a look at the design documents in vLLM for [Automatic Prefix Caching](https://docs.vllm.ai/en/latest/features/automatic_prefix_caching/), which will make some of the concepts more clear.
+
+The main focus of vLLM-Omni's approach to prefix caching stage outputs is to build on vLLM's prefix caching in the least invasive way possible while minimizing impact for cache misses, and consuming a minimal amount of GPU memory. To understand the implementation, there are a few important things to note:
+
+- Between stages, device tensors are generally moved to CPU; this is important since we're just caching the outputs of stages, so it is okay to keep the entire cache on the CPU.
+
+- For a tensor to be considered cacheable, the first dimension (currently) needs to be the same as the token count, as it allows us to reuse block/slot mappings for our externally maintained tensor caches. This allows us to dynamically discover the tensors to be marked as cacheable outputs in each Omni model without having to explicitly specify cacheable output field names in every model.
+
+With this in mind, consider the set of blocks in a 2D layout, where the row represents the index of blocks being considered, and the columns represent the slots corresponding to tokens within each block. Since we know the `num_blocks` and `block_size` from our kv cache config, if we want to cache a tensor with feature size `D`, we can preallocate a CPU tensor of size `(num_blocks, block_size, D)`, and use the same block index and slot mapping to retrieve the corresponding feature vector.
+
+
+### Example
+!!! note "Note 3"
+ Prefix caching in vLLM-Omni currently is only supported on AutoRegressive stages with one kv-cache group. It can be enabled/disabled per-stage via the `enable_prefix_caching` parameter in the model's stage config.
+
+The way in which vLLM-Omni ties into vLLM's prefix caching is best understood by example. Say that we have the following:
+
+- `num_blocks=8`
+- `block_size=4`
+- `hidden_size=2`
+- A stage specific multimodal output tensor named `mm_feature` with feature dimension `16`
+
+The prefix cache flow is then outlined below.
+
+1. When the model is initialized, we can determine the `hidden_size` from the `ModelConfig`, and allocate a cache of size `(num_blocks, block_size, hidden_size)`.
+
+2. Say we process the request `The quick brown fox was tired and slept beneath the shady tree`, which is 12 tokens and evenly divides into 3 blocks as shown below.
+
+```
+ [ The quick brown fox ] [ was tired and slept ] [beneath the shady tree ]
+Block 1: |<--- block tokens ---->|
+Block 2: |<------- prefix ------>| |<--- block tokens --->|
+Block 3: |<------------------ prefix -------------------->| |<--- block tokens ---->|
+```
+
+When the request processes, we inspect the multimodal outputs and identify the `mm_feature` tensor, which will be of shape `(seq_len, feature_dim)`, i.e., `(12, 16)` in this example. We note that the first axis is dependent on the `seq_len` and add a new cache_tensor of shape `(num_blocks, block_size, feature_dim)` to our multimodal cache for tensors.
+
+
+3. If we lay out the cache as a 2D tensor of shape (`num_blocks`, `block_size`), we'll have something like the following:
+
+```
+0: [ The quick brown fox ]
+1: [ was tired and slept ]
+2: [beneath the shady tree ]
+3: [EMPTY]
+...
+7: [EMPTY]
+```
+
+Or, if we flatten it down to 1D,
+```
+0: The
+1: quick
+2: brown
+3: fox
+...
+11: tree
+12: [EMPTY]
+...
+```
+
+which we can think of as row indices into the hidden states tensor if we view it as the 2D shape `(num_blocks x block_size, feature_dim)`. That is, the analogous flattened (from 3D -> 2D) mapping of the cache for hidden states becomes the following.
+```
+0:
+1:
+2:
+3:
+...
+11:
+12: [EMPTY]
+...
+```
+
+Similarly, for the multimodal outputs cache, the flattened coordinates are the same, but the `mm_feature` maps to vectors of length `16` instead of the hidden size of `2`. Note that in practice, we may have multiple multimodal output tensors per forward pass, which may have different names and different feature dimensions.
+
+
+4. Now, say that we receive a new request `The quick brown fox jumped over the dog`.
+
+```
+ [ The quick brown fox ] [ jumped over the dog ]
+Block 1: |<--- block tokens ---->|
+Block 2: |<------- prefix ------>| |<--- block tokens --->|
+```
+
+Here, we will have a cache hit for `Block 1` which will be detected by vLLM based on the hash of the first block when it's handling the prefix caching on the kv-cache. As a result, when we get the output from the scheduler, we will see that `num_computed_tokens=4` (corresponding to the cached first block), and we only need to process the remaining 4 new tokens in the new prefill.
+
+Since we have the block indices / slot mappings from the kv cache manager, we can simply mirror the mappings and leverage the same indices for the cached hidden states and multimodal outputs. This allows us to look up the correct tensors from our externally maintained 3D caches.
+
+```
+0: [ The quick brown fox ] < already in the cache
+1: [ was tired and slept ]
+2: [beneath the shady tree ]
+3: [ jumped over the dog ] < added on the second request
+4: [EMPTY]
+...
+7: [EMPTY]
+...
+```
+
+Finally, to pass the full hidden states and multimodal outputs to the next stage, we simply concatenate the cached contents with the corresponding new tensors computed from the current forward call.
+
+
+### What About Multimodal Inputs?
+It's also useful to consider the case about how Omni prefix caching is handled when we have multimodal inputs that don't cleanly end on block boundaries, as well as how this works with multimodal encoder caching in vLLM. For example:
+
+```
+ [ Im0 Im1 Im2 Im3 ] [ Im4 Im5 foo ]
+Block 1: |<--- block tokens ---->|
+Block 2: |<------- prefix ------>| |<--- block tokens --->|
+```
+
+In this case, only `Block 1` will have outputs stored in the prefix tensor cache, because vLLM does not store partial blocks. This may appear to be a problem at first glance, because the multimodal input is fragmented across a new block that wasn't cached.
+
+In reality, this isn't a big problem for correctness, because vLLM also maintains an encoder cache for multimodal inputs. In other words, after the first pass, we'll have the following:
+
+- The Block 1 hash, which is used for prefix caching
+- The hash describing the image data starting at position 0 and with length 6
+- In vLLM's encoder cache, a mapping from the image hash above to the encoder output
+
+
+To understand what happens, say we get the following input as a second request:
+```
+ [ Im0 Im1 Im2 Im3 ] [ Im4 Im5 bar baz ]
+Block 1: |<--- block tokens ---->|
+Block 2: |<------- prefix ------>| |<--- block tokens --->|
+```
+
+First, the scheduler will check for a prefix cache hit, which we will see on `Block 1`. As a result, we will have 4 tokens marked as precomputed, and only see the remaining 4 tokens in the following prefill.
+
+Because we have multimodal data in a scheduled span that isn't fully precomputed, we still need to call the visual encoder. However, since we have the image hash and encoder cache, we will retrieve the encoder outputs for `Im4` and `Im5` as we create the multimodal embeddings.
+
+When we pass our multimodal tensors to the language model component in the same stage, we'll then expect the same outputs, because the prefix caching behaviors in vLLM-Omni / vLLM match, so the LLM will use vLLM's KV cache manager's prefix caching to correctly handle the attention information for `Block 1` while calculating the outputs for `Block 2`, giving us the correct results for processing `Block 2` with the context of `Block 1`.
+
+Finally, we look up the output hidden states/multimodal tensors corresponding to the prefix cache hit `Block 1` and concatenate it with the forward pass result to get the final result, which is expected to be identical to the full hidden states when prefix caching is disabled.
diff --git a/docs/design/feature/teacache.md b/docs/design/feature/teacache.md
index 9fa315cee..8577cff1f 100644
--- a/docs/design/feature/teacache.md
+++ b/docs/design/feature/teacache.md
@@ -326,9 +326,41 @@ for prompt in tqdm(prompts, desc="Collecting data"):
# Estimate coefficients
coeffs = estimator.estimate(poly_order=4)
-print(f"Estimated coefficients: {coeffs.tolist()}")
+print(f"Estimated coefficients: {coeffs}")
```
+Note: some models may require the vLLM context and config to be initialized to initialize vLLM modules. To this end, you may need a workaround like the following to be able to run coefficient estimation.
+```python
+from vllm_omni.diffusion.forward_context import set_forward_context
+from vllm_omni.diffusion.distributed.parallel_state import (
+ init_distributed_environment,
+ initialize_model_parallel,
+)
+from vllm.config import VllmConfig
+...
+
+if __name__ == "__main__":
+ os.environ["MASTER_ADDR"] = "localhost"
+ os.environ["MASTER_PORT"] = "8192"
+ os.environ["LOCAL_RANK"] = "0"
+ os.environ["RANK"] = "0"
+ os.environ["WORLD_SIZE"] = "1"
+
+ vllm_config = VllmConfig()
+ init_distributed_environment()
+ initialize_model_parallel()
+
+ # NOTE: you may have to pass an initialized OmniDiffusionConfig as a kwarg
+ # here to make current sp checks happy; if this is the case, just create one
+ # .from_kwargs() with the model name to get around this check for now,
+ # since your estimator subclass should handle the actual model configuration.
+ #
+ # This will be cleaned up in the future
+ with set_forward_context(vllm_config):
+
+```
+
+
**Data Statistics Guide:**
| Metric | Good Range | Warning Signs |
diff --git a/docs/design/figures/omni/E2EL_s_vllm_omni_vs_transformers.png b/docs/design/figures/omni/E2EL_s_vllm_omni_vs_transformers.png
new file mode 100644
index 000000000..15112d586
Binary files /dev/null and b/docs/design/figures/omni/E2EL_s_vllm_omni_vs_transformers.png differ
diff --git a/docs/design/figures/omni/Mean_AUDIO_RTF_Baseline_vs_Batch.png b/docs/design/figures/omni/Mean_AUDIO_RTF_Baseline_vs_Batch.png
new file mode 100644
index 000000000..2f0615f77
Binary files /dev/null and b/docs/design/figures/omni/Mean_AUDIO_RTF_Baseline_vs_Batch.png differ
diff --git a/docs/design/figures/omni/Mean_AUDIO_RTF_Batch_CUDA_Graph_vs_Async_Chunk.png b/docs/design/figures/omni/Mean_AUDIO_RTF_Batch_CUDA_Graph_vs_Async_Chunk.png
new file mode 100644
index 000000000..62d8bc79b
Binary files /dev/null and b/docs/design/figures/omni/Mean_AUDIO_RTF_Batch_CUDA_Graph_vs_Async_Chunk.png differ
diff --git a/docs/design/figures/omni/Mean_AUDIO_RTF_Batch_vs_Batch_CUDA_Graph.png b/docs/design/figures/omni/Mean_AUDIO_RTF_Batch_vs_Batch_CUDA_Graph.png
new file mode 100644
index 000000000..5838b4531
Binary files /dev/null and b/docs/design/figures/omni/Mean_AUDIO_RTF_Batch_vs_Batch_CUDA_Graph.png differ
diff --git a/docs/design/figures/omni/Mean_AUDIO_TTFP_ms_Baseline_vs_Batch.png b/docs/design/figures/omni/Mean_AUDIO_TTFP_ms_Baseline_vs_Batch.png
new file mode 100644
index 000000000..24be814b7
Binary files /dev/null and b/docs/design/figures/omni/Mean_AUDIO_TTFP_ms_Baseline_vs_Batch.png differ
diff --git a/docs/design/figures/omni/Mean_AUDIO_TTFP_ms_Batch_CUDA_Graph_vs_Async_Chunk.png b/docs/design/figures/omni/Mean_AUDIO_TTFP_ms_Batch_CUDA_Graph_vs_Async_Chunk.png
new file mode 100644
index 000000000..c8df58ebc
Binary files /dev/null and b/docs/design/figures/omni/Mean_AUDIO_TTFP_ms_Batch_CUDA_Graph_vs_Async_Chunk.png differ
diff --git a/docs/design/figures/omni/Mean_AUDIO_TTFP_ms_Batch_vs_Batch_CUDA_Graph.png b/docs/design/figures/omni/Mean_AUDIO_TTFP_ms_Batch_vs_Batch_CUDA_Graph.png
new file mode 100644
index 000000000..2d1a04e9c
Binary files /dev/null and b/docs/design/figures/omni/Mean_AUDIO_TTFP_ms_Batch_vs_Batch_CUDA_Graph.png differ
diff --git a/docs/design/figures/omni/Mean_E2EL_ms_Baseline_vs_Batch.png b/docs/design/figures/omni/Mean_E2EL_ms_Baseline_vs_Batch.png
new file mode 100644
index 000000000..e598b5434
Binary files /dev/null and b/docs/design/figures/omni/Mean_E2EL_ms_Baseline_vs_Batch.png differ
diff --git a/docs/design/figures/omni/Mean_E2EL_ms_Batch_CUDA_Graph_vs_Async_Chunk.png b/docs/design/figures/omni/Mean_E2EL_ms_Batch_CUDA_Graph_vs_Async_Chunk.png
new file mode 100644
index 000000000..54452013e
Binary files /dev/null and b/docs/design/figures/omni/Mean_E2EL_ms_Batch_CUDA_Graph_vs_Async_Chunk.png differ
diff --git a/docs/design/figures/omni/Mean_E2EL_ms_Batch_vs_Batch_CUDA_Graph.png b/docs/design/figures/omni/Mean_E2EL_ms_Batch_vs_Batch_CUDA_Graph.png
new file mode 100644
index 000000000..04c5ad739
Binary files /dev/null and b/docs/design/figures/omni/Mean_E2EL_ms_Batch_vs_Batch_CUDA_Graph.png differ
diff --git a/docs/design/figures/omni/RTF_vllm_omni_vs_transformers.png b/docs/design/figures/omni/RTF_vllm_omni_vs_transformers.png
new file mode 100644
index 000000000..d93ba0b2a
Binary files /dev/null and b/docs/design/figures/omni/RTF_vllm_omni_vs_transformers.png differ
diff --git a/docs/design/figures/omni/Summary_E2EL_ms_vs_features.png b/docs/design/figures/omni/Summary_E2EL_ms_vs_features.png
new file mode 100644
index 000000000..04087b591
Binary files /dev/null and b/docs/design/figures/omni/Summary_E2EL_ms_vs_features.png differ
diff --git a/docs/design/figures/omni/Summary_RTF_vs_features.png b/docs/design/figures/omni/Summary_RTF_vs_features.png
new file mode 100644
index 000000000..c2c8ad408
Binary files /dev/null and b/docs/design/figures/omni/Summary_RTF_vs_features.png differ
diff --git a/docs/design/figures/omni/Summary_TTFP_ms_vs_features.png b/docs/design/figures/omni/Summary_TTFP_ms_vs_features.png
new file mode 100644
index 000000000..3dcc1c553
Binary files /dev/null and b/docs/design/figures/omni/Summary_TTFP_ms_vs_features.png differ
diff --git a/docs/design/figures/omni/TTFP_s_vllm_omni_vs_transformers.png b/docs/design/figures/omni/TTFP_s_vllm_omni_vs_transformers.png
new file mode 100644
index 000000000..9a5b6c9bd
Binary files /dev/null and b/docs/design/figures/omni/TTFP_s_vllm_omni_vs_transformers.png differ
diff --git a/docs/design/figures/tts/Mean_AUDIO_RTF_vllm_omni_vs_transformers.png b/docs/design/figures/tts/Mean_AUDIO_RTF_vllm_omni_vs_transformers.png
new file mode 100644
index 000000000..68f0ef17e
Binary files /dev/null and b/docs/design/figures/tts/Mean_AUDIO_RTF_vllm_omni_vs_transformers.png differ
diff --git a/docs/design/figures/tts/Mean_AUDIO_TTFP_(ms)_vllm_omni_vs_transformers.png b/docs/design/figures/tts/Mean_AUDIO_TTFP_(ms)_vllm_omni_vs_transformers.png
new file mode 100644
index 000000000..44be96e96
Binary files /dev/null and b/docs/design/figures/tts/Mean_AUDIO_TTFP_(ms)_vllm_omni_vs_transformers.png differ
diff --git a/docs/design/figures/tts/Mean_E2EL_(ms)_vllm_omni_vs_transformers.png b/docs/design/figures/tts/Mean_E2EL_(ms)_vllm_omni_vs_transformers.png
new file mode 100644
index 000000000..2e5d1482b
Binary files /dev/null and b/docs/design/figures/tts/Mean_E2EL_(ms)_vllm_omni_vs_transformers.png differ
diff --git a/docs/design/figures/tts/Mean_mean_e2e_ms_baseline_vs_batch.png b/docs/design/figures/tts/Mean_mean_e2e_ms_baseline_vs_batch.png
new file mode 100644
index 000000000..04d8f0bac
Binary files /dev/null and b/docs/design/figures/tts/Mean_mean_e2e_ms_baseline_vs_batch.png differ
diff --git a/docs/design/figures/tts/Mean_mean_e2e_ms_batch_vs_cuda_graph.png b/docs/design/figures/tts/Mean_mean_e2e_ms_batch_vs_cuda_graph.png
new file mode 100644
index 000000000..eb85ec0dd
Binary files /dev/null and b/docs/design/figures/tts/Mean_mean_e2e_ms_batch_vs_cuda_graph.png differ
diff --git a/docs/design/figures/tts/Mean_mean_e2e_ms_cuda_graph_vs_async_chunk.png b/docs/design/figures/tts/Mean_mean_e2e_ms_cuda_graph_vs_async_chunk.png
new file mode 100644
index 000000000..6f0e0e252
Binary files /dev/null and b/docs/design/figures/tts/Mean_mean_e2e_ms_cuda_graph_vs_async_chunk.png differ
diff --git a/docs/design/figures/tts/Mean_mean_rtf_baseline_vs_batch.png b/docs/design/figures/tts/Mean_mean_rtf_baseline_vs_batch.png
new file mode 100644
index 000000000..89ea30a86
Binary files /dev/null and b/docs/design/figures/tts/Mean_mean_rtf_baseline_vs_batch.png differ
diff --git a/docs/design/figures/tts/Mean_mean_rtf_batch_vs_cuda_graph.png b/docs/design/figures/tts/Mean_mean_rtf_batch_vs_cuda_graph.png
new file mode 100644
index 000000000..2b207b889
Binary files /dev/null and b/docs/design/figures/tts/Mean_mean_rtf_batch_vs_cuda_graph.png differ
diff --git a/docs/design/figures/tts/Mean_mean_rtf_cuda_graph_vs_async_chunk.png b/docs/design/figures/tts/Mean_mean_rtf_cuda_graph_vs_async_chunk.png
new file mode 100644
index 000000000..f5f7ad72c
Binary files /dev/null and b/docs/design/figures/tts/Mean_mean_rtf_cuda_graph_vs_async_chunk.png differ
diff --git a/docs/design/figures/tts/Mean_mean_ttfp_ms_baseline_vs_batch.png b/docs/design/figures/tts/Mean_mean_ttfp_ms_baseline_vs_batch.png
new file mode 100644
index 000000000..6f8c1da4a
Binary files /dev/null and b/docs/design/figures/tts/Mean_mean_ttfp_ms_baseline_vs_batch.png differ
diff --git a/docs/design/figures/tts/Mean_mean_ttfp_ms_batch_vs_cuda_graph.png b/docs/design/figures/tts/Mean_mean_ttfp_ms_batch_vs_cuda_graph.png
new file mode 100644
index 000000000..b0fe1d02a
Binary files /dev/null and b/docs/design/figures/tts/Mean_mean_ttfp_ms_batch_vs_cuda_graph.png differ
diff --git a/docs/design/figures/tts/Mean_mean_ttfp_ms_cuda_graph_vs_async_chunk.png b/docs/design/figures/tts/Mean_mean_ttfp_ms_cuda_graph_vs_async_chunk.png
new file mode 100644
index 000000000..008ba9bf7
Binary files /dev/null and b/docs/design/figures/tts/Mean_mean_ttfp_ms_cuda_graph_vs_async_chunk.png differ
diff --git a/docs/design/figures/tts/Summary_mean_e2e_ms_vs_features.png b/docs/design/figures/tts/Summary_mean_e2e_ms_vs_features.png
new file mode 100644
index 000000000..7c65aa117
Binary files /dev/null and b/docs/design/figures/tts/Summary_mean_e2e_ms_vs_features.png differ
diff --git a/docs/design/figures/tts/Summary_mean_rtf_vs_features.png b/docs/design/figures/tts/Summary_mean_rtf_vs_features.png
new file mode 100644
index 000000000..71bb2c546
Binary files /dev/null and b/docs/design/figures/tts/Summary_mean_rtf_vs_features.png differ
diff --git a/docs/design/figures/tts/Summary_mean_ttfp_ms_vs_features.png b/docs/design/figures/tts/Summary_mean_ttfp_ms_vs_features.png
new file mode 100644
index 000000000..cef2546d6
Binary files /dev/null and b/docs/design/figures/tts/Summary_mean_ttfp_ms_vs_features.png differ
diff --git a/docs/design/qwen3_omni_tts_performance_optimization.md b/docs/design/qwen3_omni_tts_performance_optimization.md
new file mode 100644
index 000000000..2f18a1b1b
--- /dev/null
+++ b/docs/design/qwen3_omni_tts_performance_optimization.md
@@ -0,0 +1,539 @@
+# Speech Generation on vLLM-Omni: Performance Optimizations for Qwen3-Omni and Qwen3-TTS
+
+## Summary
+
+vLLM-Omni supports end-to-end serving for speech-generating models, including both **Qwen3-Omni** (multimodal understanding + speech) and **Qwen3-TTS** (text-to-speech). Despite their different architectures, both models share the same multi-stage pipeline design and benefit from the same set of stacked optimizations:
+
+1. **Batching** improves GPU utilization stage by stage and increases overall throughput.
+2. **CUDA Graph** reduces CPU launch overhead and decode-time jitter on stable shapes.
+3. **Async Chunk and Streaming Output** overlap compute and communication across stages and emit audio incrementally, improving both TTFP and E2E.
+
+### Model architectures
+
+**Qwen3-Omni** is a native multimodal model that understands text, audio, image, and video inputs, and generates both text and speech outputs. Its pipeline has three stages:
+
+- **Thinker**: multimodal understanding and text generation
+- **Talker (+ Talker-MTP / code predictor path)**: converts semantic/text representations into codec tokens
+- **Code2Wav**: decodes codec tokens into waveform audio
+
+**Qwen3-TTS** is a lightweight, high-quality text-to-speech model. Its pipeline has two stages:
+
+- **Talker (AR decoder)**: auto-regressively generates codec tokens from text input
+- **Code2Wav (vocoder)**: decodes codec tokens into waveform audio
+
+The optimizations described in this post apply to both models. We present results for each side by side.
+
+### vLLM-Omni vs HF Transformers
+
+Compared with **HF Transformers** (offline, single request), vLLM-Omni with the full optimization stack delivers dramatically lower latency and higher efficiency for both models.
+
+**Qwen3-Omni** (A100):
+
+
+
+| Metric | vLLM-Omni | HF Transformers | Improvement |
+| --- | --- | --- | --- |
+| E2E latency (s) | 23.78 | 336.10 | ~93% reduction |
+| TTFP (s) | 0.934 | 336.10 | ~99.7% reduction |
+| RTF | 0.32 | 3.776 | ~91% reduction (~12× faster) |
+
+- **E2E latency**: 23.78 s vs 336.10 s - **~93%** reduction
+- **TTFP**: 0.934 s vs 336.10 s - **~99.7%** reduction
+- **RTF**: 0.32 vs 3.776 - **~91%** reduction (~12x faster)
+
+**Qwen3-TTS** (H200, concurrency 1):
+
+
+
+| Metric | vLLM-Omni | HF Transformers | Improvement |
+| --- | --- | --- | --- |
+| E2E latency (ms) | 941 | 15,513 | ~94% reduction |
+| TTFP (ms) | 64 | 15,513 | ~99.6% reduction (242× faster) |
+| RTF | 0.16 | 2.64 | ~94% reduction (~16.5× faster) |
+
+- **E2E latency**: 941 ms vs 15,513 ms - **~94%** reduction
+- **TTFP**: 64 ms vs 15,513 ms - **~99.6%** reduction (242x faster)
+- **RTF**: 0.16 vs 2.64 - **~94%** reduction (~16.5x faster)
+
+### Stacked optimization summary
+
+Each optimization stacks on the previous one. The summary plots below show the cumulative effect at each step, with one line per concurrency level (1, 4, 10).
+
+**Qwen3-Omni** (A100):
+
+
+
+- **E2EL reduction**: ~74% at concurrency 10 (410,054 ms -> 104,901 ms); ~90% at concurrency 1 (426,529 ms -> 41,216 ms)
+- **TTFP reduction**: ~96% at concurrency 10 (409,705 ms -> 16,482 ms); ~99.7% at concurrency 1 (426,078 ms -> 1,164 ms)
+- **RTF reduction**: ~74% at concurrency 10 (2.83 -> 0.74); ~90% at concurrency 1 (2.08 -> 0.21)
+
+**Qwen3-TTS** (H200):
+
+
+
+- **E2EL reduction**: ~85% at concurrency 10 (12,141 ms -> 1,767 ms); ~29% at concurrency 1 (1,323 ms -> 941 ms)
+- **TTFP reduction**: ~96.5% at concurrency 10 (12,141 ms -> 425 ms); ~95% at concurrency 1 (1,323 ms -> 64 ms)
+- **RTF reduction**: ~86% at concurrency 10 (2.19 -> 0.31); ~30% at concurrency 1 (0.23 -> 0.16)
+
+**Benchmark environment:**
+
+| | Qwen3-Omni | Qwen3-TTS |
+| --- |-----------------------------| --- |
+| **GPU** | A100 | H200 |
+| **Model** | Qwen3-Omni-30B-A3B-Instruct | Qwen3-TTS-12Hz-1.7B-CustomVoice |
+| **vLLM** | v0.17.0 | v0.18.0 |
+| **vllm-omni** | commit 199f7832 | v0.18.0rc2 |
+| **CUDA** | 12.9 | 12.8 |
+
+This post walks through each optimization in the same order they are typically enabled in practice, then ends with deployment playbooks for both models.
+
+---
+
+## Pipeline Batching
+
+### How stage-wise batching works
+
+For both Qwen3-Omni and Qwen3-TTS, batching is a pipeline-level optimization:
+
+- Requests are grouped per stage using `runtime.max_batch_size`
+- Each stage executes batch inference with its own scheduler/worker
+- Stage outputs are routed to downstream stages with per-request mapping preserved
+
+**Batching strategy by stage:** The understanding and decode stages (Thinker for Omni, Talker for both) use **continuous batching**: requests can join and leave the batch over time. Code2Wav uses **static batching**: once a batch is formed, the stage runs the whole batch before starting the next. This matches the decode pattern of Code2Wav and keeps implementation simple while still improving throughput.
+
+### Batching results (Baseline vs. Batch)
+
+Batching alone greatly reduces E2EL and RTF across all concurrencies. The biggest gains appear at high concurrency where requests share GPU resources.
+
+**Qwen3-Omni** (A100):
+
+
+
+| Metric | Concurrency | Baseline | + Batch | Improvement |
+| --- | --- | --- | --- | --- |
+| E2EL (ms) | 1 | 426,529 | 307,719 | 1.4× |
+| E2EL (ms) | 4 | 407,213 | 376,934 | 1.1× |
+| E2EL (ms) | 10 | 410,054 | 234,844 | 1.7× |
+| TTFP (ms) | 1 | 426,078 | 307,262 | 1.4× |
+| TTFP (ms) | 4 | 406,843 | 376,466 | 1.1× |
+| TTFP (ms) | 10 | 409,705 | 234,557 | 1.7× |
+| RTF | 1 | 2.08 | 1.51 | 1.4× |
+| RTF | 4 | 2.55 | 1.83 | 1.4× |
+| RTF | 10 | 2.83 | 2.28 | 1.2× |
+
+At concurrency 10, E2EL drops from ~410 s to ~235 s; at concurrency 1, from ~427 s to ~308 s.
+
+**Qwen3-TTS** (H200):
+
+
+
+| Metric | Concurrency | Baseline | + Batch | Improvement |
+| --- | --- | --- | --- | --- |
+| E2EL (ms) | 1 | 1,323 | 1,339 | 1.0× |
+| E2EL (ms) | 4 | 5,171 | 1,471 | 3.5× |
+| E2EL (ms) | 10 | 12,141 | 1,705 | 7.1× |
+| RTF | 1 | 0.230 | 0.234 | 1.0× |
+| RTF | 4 | 0.908 | 0.255 | 3.6× |
+| RTF | 10 | 2.186 | 0.292 | 7.5× |
+| Throughput (audio-s/wall-s) | 10 | 3.99 | 33.53 | 8.4× |
+
+At concurrency 10, batching alone brings Qwen3-TTS RTF from 2.19 (slower than realtime) down to 0.29 (faster than realtime), and throughput from 4.0 to 33.5 audio-sec/wall-sec.
+
+---
+
+## CUDA Graph on the Critical Decode Path
+
+### Why CUDA Graph helps here
+
+In decode-heavy serving, repeatedly launching many small kernels from CPU can become a visible overhead. CUDA Graph reduces this overhead by capturing and replaying stable execution graphs.
+
+In stage configs, this is represented by `enforce_eager: false` for stages where graph capture is desired (Thinker/Talker), while Code2Wav keeps eager mode depending on stage behavior.
+
+### CUDA Graph results on top of batching
+
+**Qwen3-Omni** (A100):
+
+
+
+| Metric | Concurrency | Batch | + CUDA Graph | Improvement |
+| --- | --- | --- | --- | --- |
+| E2EL (ms) | 1 | 307,719 | 61,613 | 5.0× |
+| E2EL (ms) | 4 | 376,934 | 79,019 | 4.8× |
+| E2EL (ms) | 10 | 234,844 | 126,867 | 1.9× |
+| TTFP (ms) | 1 | 307,262 | 61,257 | 5.0× |
+| TTFP (ms) | 4 | 376,466 | 78,634 | 4.8× |
+| TTFP (ms) | 10 | 234,557 | 126,534 | 1.9× |
+| RTF | 1 | 1.51 | 0.32 | 4.7× |
+| RTF | 4 | 1.83 | 0.43 | 4.3× |
+| RTF | 10 | 2.28 | 0.90 | 2.5× |
+
+For the larger Qwen3-Omni model (30B-A3B), CUDA Graph provides a significant improvement. At concurrency 1, E2EL drops from ~308 s to ~62 s; at concurrency 10, from ~235 s to ~127 s.
+
+**Qwen3-TTS** (H200):
+
+
+
+| Metric | Concurrency | Batch | + CUDA Graph | Improvement |
+| --- | --- | --- | --- | --- |
+| E2EL (ms) | 1 | 1,339 | 733 | 1.8× |
+| E2EL (ms) | 4 | 1,471 | 987 | 1.5× |
+| E2EL (ms) | 10 | 1,705 | 1,197 | 1.4× |
+| RTF | 1 | 0.234 | 0.124 | 1.9× |
+| RTF | 10 | 0.292 | 0.203 | 1.4× |
+| Throughput (audio-s/wall-s) | 10 | 33.53 | 47.15 | 1.4× |
+
+At concurrency 1, CUDA Graph reduces E2EL from 1,339 ms to 733 ms and RTF from 0.234 to 0.124 - nearly a 2x improvement. The benefit is consistent across all concurrency levels.
+
+---
+
+## Async Chunk and Streaming Output: Earlier Audio and Cross-Stage Overlap
+
+### Why this step matters for first-packet latency
+
+Two mechanisms work together to improve user-visible latency:
+
+- **Streaming output**: audio streaming emits audio chunks as soon as they are decoded (lower **TTFP**). Without streaming, the client waits for larger buffers or end-of-sequence.
+- **Async chunk** is the main enabler for *earlier* audio: instead of handing off whole-request results between stages, each stage forwards **chunks** so the next stage can start as soon as the first chunk is ready. For Omni: Thinker -> Talker forwards hidden-state chunks; for both: Talker -> Code2Wav forwards codec chunks; Code2Wav decodes and emits packets incrementally. This **overlaps compute and communication** across stages and directly reduces time-to-first-audio-packet (TTFP) and end-to-end latency (E2EL).
+
+So in practice: streaming output defines *how* bytes are sent to the client; async chunk defines *when* the pipeline can produce the first bytes.
+
+**Dependency between the two:** Async chunk and audio streaming output are mutually dependent. Without async chunk, **audio streaming output cannot truly take effect**. Without audio streaming output, async chunk's **TTFP advantage is not fully realized**: the client would still wait for larger buffers or end-of-sequence instead of hearing the first packet as soon as it is ready. We therefore recommend enabling **both** on top of batching + CUDA Graph; the benchmarks in this post use both.
+
+### Results: Batch + CUDA Graph vs. Batch + CUDA Graph + Async Chunk + Streaming Output
+
+**Qwen3-Omni** (A100):
+
+
+
+| Metric | Concurrency | Batch + CG | + Async Chunk | Improvement |
+| --- | --- | --- | --- | --- |
+| E2EL (ms) | 1 | 61,613 | 41,216 | 1.5× |
+| E2EL (ms) | 4 | 79,019 | 67,584 | 1.2× |
+| E2EL (ms) | 10 | 126,867 | 104,901 | 1.2× |
+| TTFP (ms) | 1 | 61,257 | 1,164 | 53× |
+| TTFP (ms) | 4 | 78,634 | 3,152 | 24.9× |
+| TTFP (ms) | 10 | 126,534 | 16,482 | 7.7× |
+| RTF | 1 | 0.32 | 0.21 | 1.5× |
+| RTF | 4 | 0.43 | 0.34 | 1.3× |
+| RTF | 10 | 0.90 | 0.74 | 1.2× |
+
+Enabling both brings TTFP down sharply (concurrency 1: 61,257 ms -> 1,164 ms, **~98% reduction**; concurrency 4: 78,634 ms -> 3,152 ms, **~96% reduction**). E2EL and RTF also improve at every concurrency.
+
+**Qwen3-TTS** (H200):
+
+
+
+| Metric | Concurrency | Batch + CG | + Async Chunk | Improvement |
+| --- | --- | --- | --- | --- |
+| TTFP (ms) | 1 | 733 | **64** | **11.5×** |
+| TTFP (ms) | 4 | 987 | **119** | **8.3×** |
+| TTFP (ms) | 10 | 1,197 | **425** | **2.8×** |
+| E2EL (ms) | 1 | 733 | 941 | 0.8× |
+| E2EL (ms) | 10 | 1,197 | 1,767 | 0.7× |
+| RTF | 1 | 0.124 | 0.160 | 0.8× |
+| RTF | 10 | 0.203 | 0.314 | 0.6× |
+
+The TTFP improvement is the headline result for both models. For Qwen3-TTS at concurrency 1, users hear the first audio in **64 ms** instead of 733 ms - an **11.5x reduction**. For Qwen3-Omni at concurrency 1, TTFP drops from 61 s to 1.2 s - a **53x reduction**.
+
+### Why E2EL and RTF are higher with async chunk (TTS)
+
+The table above shows that enabling async chunk + streaming *increases* E2EL and RTF for TTS compared to CUDA Graph alone. This is expected - the two configurations optimize for fundamentally different metrics:
+
+- **CUDA Graph (no async chunk)** generates the entire audio end-to-end before returning. No chunking overhead, so total compute is minimized.
+- **Async Chunk + Streaming** splits the pipeline into incremental chunks, adding overhead from chunked transport, context overlap in Code2Wav (`codec_left_context_frames=25`), and smaller effective batch sizes per chunk.
+
+**The tradeoff is intentional.** Async chunk trades ~30% higher total compute for **11x faster time-to-first-audio**. For interactive applications (voice assistants, chatbots), TTFP determines perceived responsiveness. For offline batch processing, CUDA Graph without async chunk is the better choice.
+
+---
+
+## TTS-Specific: Code Predictor Re-prefill + `torch.compile`
+
+Qwen3-TTS has a **code predictor** - a small 5-layer transformer that generates residual codebook tokens (groups 1 through Q-1) autoregressively. Each AR step operates on very short sequences (2 to ~16 tokens).
+
+The naive approach uses a KV cache for this small transformer, similar to the main Talker. But the KV cache machinery (block tables, slot mappings, paged attention) introduces significant overhead relative to the tiny model. Two optimizations replace that:
+
+### Re-prefill (stateless forward, no KV cache)
+
+Instead of maintaining a KV cache across steps, the code predictor **re-feeds the full growing sequence** at each AR step using `F.scaled_dot_product_attention`. With sequences of at most ~16 tokens through 5 layers, the O(T^2) attention cost is negligible - and removing the KV cache machinery (block table management, `set_forward_context`, slot mapping) saves far more time than it costs.
+
+### `torch.compile` on the code predictor forward
+
+The 5-layer transformer forward pass launches ~60 small CUDA kernels per step. `torch.compile(mode="default", dynamic=True)` fuses these into fewer kernels via Inductor:
+
+```python
+self._compiled_model_fwd = torch.compile(
+ self.model.forward,
+ mode="default", # no Inductor CUDA graphs, avoids conflict with vLLM's CUDAGraphWrapper
+ dynamic=True, # sequence length grows each step (2, 3, ..., num_groups+1)
+)
+```
+
+`mode="default"` is used instead of `mode="reduce-overhead"` to avoid conflicts with vLLM's own CUDA graph capture on the main Talker model. `dynamic=True` handles the growing sequence length without recompilation.
+
+These optimizations are always-on in the current codebase - all Qwen3-TTS benchmark results in this post include them.
+
+---
+
+## TTS-Specific: Dynamic Initial Chunk for Faster First Audio
+
+In the async chunk pipeline, the standard `codec_chunk_frames` is 25 (each chunk = ~2 seconds of audio at 12 Hz). Waiting for 25 frames before forwarding the first chunk to Code2Wav adds unnecessary TTFP. The **initial codec chunk** optimization sends a smaller first chunk so Code2Wav can start decoding earlier.
+
+**Dynamic initial chunk sizing (default behavior):**
+
+Rather than using a fixed initial chunk size, vLLM-Omni dynamically selects it based on current server load. The initial chunk size is chosen from power-of-2 steps [2, 4, 8, 16] based on load factor (`active_requests / max_batch_size`):
+
+| Server load | Initial chunk frames | Rationale |
+| --- | --- | --- |
+| Low (e.g. 1/10 active) | **2** (~167 ms of audio) | Minimize TTFP when there's headroom |
+| Medium (e.g. 5/10 active) | **4-8** | Balance TTFP vs decode efficiency |
+| High (e.g. 10/10 active) | **16** | Larger first chunk to amortize decode cost |
+
+After the initial chunk, all subsequent chunks use the standard `codec_chunk_frames` (25) size.
+
+**How it works in the pipeline:**
+
+1. Talker generates codec tokens auto-regressively
+2. The stage input processor checks current load and picks an initial chunk size (e.g. **2 frames** at low load)
+3. After that many frames, the first chunk is forwarded to Code2Wav
+4. Code2Wav decodes this small chunk and emits the first audio packet
+5. Subsequent chunks use the standard 25-frame size for efficient batch decoding
+
+**Per-request override:** Clients can also set a fixed initial chunk size via the API:
+
+```json
+{"initial_codec_chunk_frames": 2}
+```
+
+This overrides the dynamic calculation for that request.
+
+**Config (server-side):**
+
+```yaml
+runtime:
+ connectors:
+ connector_of_shared_memory:
+ name: SharedMemoryConnector
+ extra:
+ codec_streaming: true
+ codec_chunk_frames: 25 # standard chunk size (~2s of audio)
+ codec_left_context_frames: 25
+ # initial chunk is computed dynamically by default
+ # set initial_codec_chunk_frames: 2 to force a fixed value
+```
+
+The 64 ms TTFP result reported above for Qwen3-TTS at concurrency 1 uses the dynamic initial chunk, which picks `initial_codec_chunk_frames=2` at low load. At higher concurrency the dynamic sizing increases the initial chunk to maintain decode efficiency.
+
+---
+
+## Live Demo: Streaming TTS over WebSocket
+
+vLLM-Omni supports real-time streaming audio output for Qwen3-TTS over WebSocket ([PR #1719](https://github.com/vllm-project/vllm-omni/pull/1719)). With `stream_audio: true`, the server sends chunked PCM audio frames as they are generated, so clients can start playback before full sentence synthesis completes.
+
+The WebSocket protocol uses `audio.start` / binary PCM chunks / `audio.done` framing per sentence:
+
+```json
+// Client sends:
+{"type":"session.config","voice":"Vivian","response_format":"pcm","stream_audio":true}
+{"type":"input.text","text":"Hello world. This is a streaming demo."}
+{"type":"input.done"}
+
+// Server streams back per sentence:
+{"type":"audio.start","sentence_index":0,"sentence_text":"Hello world.","format":"pcm","sample_rate":24000}
+
+
+...
+{"type":"audio.done","sentence_index":0,"total_bytes":96000,"error":false}
+{"type":"audio.start","sentence_index":1,"sentence_text":"This is a streaming demo.","format":"pcm","sample_rate":24000}
+
+...
+{"type":"audio.done","sentence_index":1,"total_bytes":72000,"error":false}
+{"type":"session.done","total_sentences":2}
+```
+
+VIDEO
+
+---
+
+## Deployment Playbook
+
+### Qwen3-Omni
+
+#### 1) Serve with the default 3-stage config
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct \
+ --omni \
+ --port 8091
+```
+
+Notes:
+
+- `runtime.max_batch_size` controls stage-level batching.
+- Thinker/Talker commonly use `enforce_eager: false` for CUDA Graph paths.
+- Code2Wav often remains eager (`enforce_eager: true`) depending on runtime behavior.
+
+#### 2) Enable async chunk
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct \
+ --omni \
+ --port 8091 \
+ --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml
+```
+
+#### 3) Key config knobs
+
+```yaml
+async_chunk: true
+stage_args:
+ - stage_id: 0 # thinker
+ runtime:
+ max_batch_size: 64
+ engine_args:
+ enforce_eager: false
+ max_num_batched_tokens: 32768
+ custom_process_next_stage_input_func: >-
+ vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk
+
+ - stage_id: 1 # talker
+ runtime:
+ max_batch_size: 64
+ engine_args:
+ enforce_eager: false
+ max_num_batched_tokens: 32768
+ custom_process_next_stage_input_func: >-
+ vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_async_chunk
+
+ - stage_id: 2 # code2wav
+ runtime:
+ max_batch_size: 64
+ engine_args:
+ enforce_eager: true
+ max_num_batched_tokens: 51200
+```
+
+#### Reproduce Qwen3-Omni benchmarks
+
+```bash
+vllm bench serve \
+ --dataset-name random \
+ --port ${PORT} \
+ --model ${MODEL_PATH} \
+ --endpoint /v1/chat/completions \
+ --backend openai-chat-omni \
+ --max-concurrency ${MAX_CONCURRENCY} \
+ --num-prompts ${NUM_PROMPTS} \
+ --random-input-len 2500 \
+ --ignore-eos \
+ --percentile-metrics ttft,tpot,itl,e2el,audio_ttfp,audio_rtf \
+ --random-output-len 900 \
+ --extra_body '{"modalities": ["text","audio"]}'
+```
+
+### Qwen3-TTS
+
+#### 1) Serve with async chunk (recommended)
+
+```bash
+vllm-omni serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
+ --omni \
+ --port 8000
+```
+
+The default config (`qwen3_tts.yaml`) enables the full optimization stack:
+
+- Batching with `max_batch_size: 10` on the Talker stage
+- CUDA Graph on the Talker (`enforce_eager: false`)
+- Async chunk with streaming transport
+
+#### 2) Serve without async chunk (for comparison)
+
+```bash
+vllm-omni serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
+ --omni \
+ --port 8000 \
+ --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts_no_async_chunk.yaml
+```
+
+#### 3) Key config knobs
+
+```yaml
+async_chunk: true
+stage_args:
+ - stage_id: 0 # Talker (AR decoder)
+ runtime:
+ max_batch_size: 10
+ engine_args:
+ enforce_eager: false
+ max_num_batched_tokens: 512
+ custom_process_next_stage_input_func: >-
+ vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
+
+ - stage_id: 1 # Code2Wav (vocoder)
+ runtime:
+ max_batch_size: 1
+ engine_args:
+ enforce_eager: true
+ max_num_batched_tokens: 8192
+
+runtime:
+ connectors:
+ connector_of_shared_memory:
+ name: SharedMemoryConnector
+ extra:
+ codec_streaming: true
+ codec_chunk_frames: 25
+ codec_left_context_frames: 25
+```
+
+#### Reproduce Qwen3-TTS benchmarks
+
+```bash
+GPU_DEVICE=0 \
+MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
+NUM_PROMPTS=50 \
+CONCURRENCY="1 4 10" \
+bash benchmarks/qwen3-tts/vllm_omni/run_stacked_benchmark.sh
+```
+
+This cycles through four configs (Baseline -> + Batch -> + CUDA Graph -> + Async Chunk + Streaming), benchmarks each at the specified concurrency levels, and generates all comparison figures automatically.
diff --git a/docs/features/sleep_mode.md b/docs/features/sleep_mode.md
index 41aa48c17..b4eec162d 100644
--- a/docs/features/sleep_mode.md
+++ b/docs/features/sleep_mode.md
@@ -1,39 +1,243 @@
-# Sleep Mode
+# Sleep Mode & ACK Protocol
-vLLM-Omni’s **Sleep Mode** allows you to temporarily release most GPU memory used by a model—such as model weights and key-value (KV) caches (for autoregressive models)—**without stopping the server or unloading the Docker container**.
+vLLM-Omni’s **Sleep Mode** allows you to temporarily release most GPU memory used by a model—such as model weights and key-value (KV) caches—**without stopping the server or unloading the Docker container**.
-This feature is inherited from [vLLM’s Sleep Mode](https://blog.vllm.ai/2025/10/26/sleep-mode.html), which provides zero-reload model switching for multi-model serving.
-
-It is especially useful in **RLHF**, **training**, or **cost-saving scenarios**, where GPU resources must be freed between inference workloads.
+This feature is inherited from [vLLM’s Sleep Mode](https://blog.vllm.ai/2025/10/26/sleep-mode.html) and extended with the **Omni ACK Protocol** to support multi-stage pipelines and heterogeneous hardware backends (NVIDIA, AMD, Intel, Huawei). It is especially useful in **RLHF**, **dynamic model switching**, or **cost-saving scenarios**.
---
-## Omni Model
+## 1. Feature Documentation
-Omni model inherit the feature from vLLM' Sleep Mode
+### Overview
+Omni Sleep Mode provides a mechanism to "sleep" specific model stages. When a stage enters sleep, its physical VRAM is reclaimed by the system, while the process state is preserved for rapid "wake-up" without full re-initialization.
-This means:
+### Sleep Levels
+We support two levels of hibernation to balance recovery speed and memory efficiency:
-- Support both Level 1 and Level 2 sleep, allow to release and reset both model weights and KV Cache
+| Level | Name | Mechanism | Recovery Speed | Memory Freed |
+| :--- | :--- | :--- | :--- | :--- |
+| **Level 1** | **Weight Offloading** | Offloads weights to Host CPU RAM. | **Fast** (DMA) | Substantial |
+| **Level 2** | **Full De-mapping** | Physically releases memory pages via VRAM scavenging. | **Moderate** | **Maximum** (up to 95%+) |
-## Diffusion Model Extension
+### Supported Platforms
-We added Sleep Mode support for **diffusion models**, which previously lacked this functionality.
-In diffusion pipelines, this currently only offloads **model weight memory**, as these models typically do not use KV caches.
+Omni Sleep Mode is optimized for high-performance computing backends:
-This means:
+* **NVIDIA**: Supported via Virtual Memory Management (VMM).
+* **AMD (ROCm)**: Fully supported with physical page de-mapping.
+* **Intel XPU**: Supported via Level Zero memory management.
+* **Huawei NPU**: Supported via Ascend memory scavenging.
-- Diffusion models can now enter Level 1 sleep.
-- Pipeline states (e.g., noise schedulers, buffers) remain intact after waking.
-- Useful for releasing VRAM between image generation or training cycles.
+### Hardware Requirements
+* **Memory Considerations**: System RAM must be sufficient to hold offloaded weights during sleep.
+* **TP Support**: Tensor Parallel groups synchronize sleep/wake transitions across all workers.
---
-## Enable sleep mode
-To enable sleep mode, set the `enable_sleep_mode` in `engine_args` to `True`
+## 2. Usage Examples
+
+### Python API Example
+You can programmatically control the lifecycle of stages using the `AsyncOmni` engine.
-Example:
```python
-omni = Omni(model=...,enable_sleep_mode=True)
+
+import asyncio
+from vllm_omni.entrypoints.async_omni import AsyncOmni
+
+async def run_sleep_demo():
+ # 1. initialization
+ engine = AsyncOmni(
+ model="ByteDance-Seed/BAGEL-7B-MoT",
+ enable_sleep_mode=True
+ )
+
+ # 2. sleep mode level2
+ acks = await engine.sleep(stage_ids=[0], level=2)
+ print(f"Freed {acks[0].freed_bytes / 1024**3:.2f} GiB on Stage 0")
+
+ # 3. wake up
+ await engine.wake_up(stage_ids=[0])
+
+if __name__ == "__main__":
+ asyncio.run(run_sleep_demo())
+
```
+
+### server command Example
+Start the server with sleep mode enabled:
+
+The first method
+
+```
+
+vllm serve ByteDance-Seed/BAGEL-7B-MoT \
+--omni \
+--enable-sleep-mode \
+--trust-remote-code \
+--gpu-memory-utilization 0.7
+
+```
+
+The second method
+
+```
+
+python3 -m vllm_omni.entrypoints.openai.api_server \
+ --model ByteDance-Seed/BAGEL-7B-MoT \
+ --omni \
+ --enable-sleep-mode \
+ --trust-remote-code \
+--gpu-memory-utilization 0.7
+
+```
+
+
+
+
+### Test Scenarios & Commands
+
+#### Scenario 1: LLM Engine Sleep
+
+Objective: Verify VRAM reclamation for Stage 0 (Thinker).
+
+Trigger sleep (Level 1 or Level 2) via client:
+
+```
+
+curl -X POST http://localhost:8000/v1/omni/sleep \
+ -H "Content-Type: application/json" \
+ -d '{"stage_ids": [0], "level": 2}'
+
+```
+
+Tip: Open a new terminal and run rocm-smi or nvidia-smi or to observe the immediate drop in VRAM usage.
+
+
+
+#### Scenario 2: Diffusion Sleep
+Objective: Verify VRAM reclamation for Stage 1 (Diffusion).
+
+Trigger sleep (Level 1 or Level 2) via client:
+
+```
+
+curl -X POST http://localhost:8000/v1/omni/sleep \
+ -H "Content-Type: application/json" \
+ -d '{"stage_ids": [1], "level": 2}'
+
+```
+
+
+
+#### Scenario 3: Multi-Stage Coordinated Stress Test
+Objective: Test concurrent sleep and rapid wake-up across multiple stages.
+
+Concurrent Sleep (Stage 0 & 1):
+
+```
+
+curl -X POST http://localhost:8000/v1/omni/sleep \
+ -H "Content-Type: application/json" \
+ -d '{"stage_ids": [0, 1], "level": 2}'
+
+```
+
+
+Rapid Wake-up:
+
+```
+
+curl -X POST http://localhost:8000/v1/omni/wakeup \
+ -H "Content-Type: application/json" \
+ -d '{"stage_ids": [0, 1]}'
+
+```
+
+
+#### Scenario 4: Full Lifecycle Memory Audit & Functional Integrity
+Objective: Audit the complete flow from Sleep to Wake-up followed by an Inference validation.
+
+Check Initial State: Observe baseline VRAM usage.
+
+Trigger Deep Sleep (Level 2):
+
+```
+
+curl -X POST http://localhost:8000/v1/omni/sleep \
+ -H "Content-Type: application/json" \
+ -d '{"stage_ids": [0], "level": 2}'
+
+```
+
+Wake-up Model:
+
+```
+
+curl -X POST http://localhost:8000/v1/omni/wakeup \
+ -H "Content-Type: application/json" \
+ -d '{"stage_ids": [0]}'
+
+```
+
+Verify Functional Integrity (Inference):
+Ensure the model still generates valid output after reloading weights.
+
+```
+
+curl -X POST http://localhost:8000/v1/images/generations \
+ -H "Content-Type: application/json" \
+ -d '{
+ "prompt": "A huge swimming pool, with many people swimming.",
+ "model": "ByteDance-Seed/BAGEL-7B-MoT",
+ "response_format": "b64_json",
+ "extra_body": {"sampling_params": {"num_inference_steps": 4, "seed": 42}}
+ }' > post.json
+
+```
+
+
+
+
+## 3. API Reference
+
+
+### Methods
+
+| Method | Arguments | Return Type | Description |
+| :--- | :--- | :--- | :--- |
+| **sleep** | `stage_ids: List[int], level: int` | `List[OmniACK]` | Triggers hibernation for specified stages. |
+| **wake_up** | `stage_ids: List[int]` | `List[OmniACK]` | Reloads weights and re-maps memory. |
+
+
+
+### OmniACK Dataclass Fields
+
+| Field | Type | Description |
+| :--- | :--- | :--- |
+| **task_id** | `str` | Unique identifier for the operation. |
+| **status** | `str` | `SUCCESS` or `ERROR`. |
+| **stage_id** | `int` | The ID of the stage that responded. |
+| **rank** | `int` | The rank ID within the Tensor Parallel group. |
+| **freed_bytes** | `int` | Actual amount of physical VRAM reclaimed. |
+| **metadata** | `dict` | Additional platform-specific metrics. |
+
+Metadata Field Analysis
+The metadata field is a dynamic dictionary containing hardware-specific telemetry and audit data, primarily used for verifying memory reclamation on various backends (e.g., AMD ROCm, NVIDIA CUDA).
+
+```
+"metadata": {
+ "source": "Platform_AMD_Instinct_MI300X",
+ "total_freed_gib": "78.57",
+ "rank_residual_gib": "2.07"
+}
+```
+
+#### Core Utility:
+**VRAM Reclamation Audit (total_freed_gib)**: Converts raw freed_bytes into human-readable GiB. It serves as the primary metric to verify that Level 2 sleep has successfully purged model weights from VRAM.
+
+**Residual & Fragmentation Monitoring (rank_residual_gib)**: Reports the remaining VRAM footprint after memory de-mapping. A low residual value (e.g., 2.07 GiB) confirms a successful "clean" state, ensuring the device is ready for high-memory co-located tasks like training or diffusion pipelines.
+
+**Backend Traceability (source)**: Identifies the underlying hardware driver or audit source. This is critical for debugging synchronization issues in multi-stage, distributed environments.
+
+**Performance Analytics (Roadmap)**: Future updates will include latency_ms (context-switch overhead) and cuda_graph_recalled (graph engine status) to optimize performance in high-frequency sleep/wake scenarios.
diff --git a/docs/getting_started/installation/gpu/rocm.inc.md b/docs/getting_started/installation/gpu/rocm.inc.md
index 1a683d174..5dfea8d2f 100644
--- a/docs/getting_started/installation/gpu/rocm.inc.md
+++ b/docs/getting_started/installation/gpu/rocm.inc.md
@@ -26,7 +26,7 @@ uv pip install vllm-omni
# Optional if want to run Qwen3 TTS
uv pip uninstall onnxruntime # should be removed before we can install onnxruntime-rocm
-uv pip install onnxruntime-rocm sox
+uv pip install onnxruntime-rocm
```
# --8<-- [end:pre-built-wheels]
diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md
index 0f9c8fff6..b4976b09c 100644
--- a/docs/models/supported_models.md
+++ b/docs/models/supported_models.md
@@ -47,6 +47,7 @@ th {
| `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` | ✅︎ | ✅︎ | | |
| `FluxPipeline` | FLUX.1-dev | `black-forest-labs/FLUX.1-dev` | ✅︎ | ✅︎ | | ✅︎ |
+| `FluxPipeline` | FLUX.1-schnell | `black-forest-labs/FLUX.1-schnell` | ✅︎ | ✅︎ | | ✅︎ |
| `OmniGen2Pipeline` | OmniGen2 | `OmniGen2/OmniGen2` | ✅︎ | ✅︎ | | ✅︎ |
| `StableAudioPipeline` | Stable-Audio-Open | `stabilityai/stable-audio-open-1.0` | ✅︎ | ✅︎ | | ✅︎ |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-CustomVoice | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
diff --git a/docs/pr_reviewer.md b/docs/pr_reviewer.md
deleted file mode 100644
index ad3235532..000000000
--- a/docs/pr_reviewer.md
+++ /dev/null
@@ -1,255 +0,0 @@
-# VLLM-Omni PR Reviewer
-
-## Overview
-
-The VLLM-Omni PR Reviewer is an automated code review bot powered by GLM-4.7 AI model. It helps maintain code quality by providing intelligent feedback on pull requests.
-
-## Features
-
-- **Intelligent Code Analysis**: Leverages GLM-4.7 for understanding code context and providing meaningful feedback
-- **Comprehensive Reviews**: Covers code quality, architecture, security, testing, and documentation
-- **Structured Output**: Provides well-formatted reviews with clear sections and actionable suggestions
-- **Rate Limiting**: Built-in cooldown mechanism to prevent excessive API usage
-- **Retry Logic**: Automatic retries with exponential backoff for transient API failures
-- **Defensive Parsing**: Robust validation of API responses to handle malformed data
-- **Cost Control**: Only repository members/collaborators/owners can trigger reviews
-
-## How to Use
-
-### Triggering a Review
-
-To trigger an automated PR review, mention the bot in a PR comment:
-
-```
-@vllm-omni-reviewer please review
-```
-
-Or include in your PR description:
-
-```
-@vllm-omni-reviewer
-```
-
-The bot will automatically review your changes and post a detailed comment.
-
-## What Gets Reviewed
-
-- **vLLM Architecture Compatibility**: Ensures changes align with vLLM's design patterns
-- **Multi-modal Integration**: Reviews audio, vision, and text processing implementations
-- **Performance Implications**: Analyzes impact on inference latency and throughput
-- **Code Quality**: Checks Python best practices, type hints, and documentation
-- **Security Considerations**: Identifies potential security vulnerabilities
-- **Testing Coverage**: Recommends additional test cases when needed
-
-## Review Output
-
-The bot posts a structured review comment with:
-
-- **Overview**: Brief summary of the PR's purpose
-- **Critical Issues (Must Fix)**: Blocking issues that need to be addressed
-- **Important Issues (Should Fix)**: Significant concerns that should be resolved
-- **Minor Issues & Suggestions**: Small improvements and optional suggestions
-- **Positive Aspects**: Highlights well-implemented features
-- **Performance Considerations**: Analysis of performance impact
-- **Testing Recommendations**: Suggestions for additional tests
-- **Overall Assessment**: Final recommendation (Approve/Request Changes/Needs Major Work)
-
-## Rate Limiting and Cooldown
-
-The bot includes a cooldown mechanism to prevent excessive API usage:
-
-- **Default cooldown**: 5 minutes between reviews per PR
-- **Configurable**: Can be adjusted via `PR_REVIEWER_COOLDOWN_MINUTES` environment variable
-- **Smart detection**: Checks for previous bot comments before starting a review
-
-If you trigger a review within the cooldown period, the bot will log a message and skip the review.
-
-## Architecture
-
-```
-┌─────────────────┐
-│ PR Comment │
-│ @vllm-omni- │
-│ reviewer │
-└────────┬────────┘
- │
- ▼
-┌─────────────────────────────────┐
-│ GitHub Actions Workflow │
-│ (.github/workflows/ │
-│ pr-reviewer.yml) │
-│ │
-│ - Python 3.11 │
-│ - requests==2.31.0 │
-│ - pyyaml==6.0.1 │
-└────────┬────────────────────────┘
- │
- ▼
-┌─────────────────────────────────┐
-│ PR Reviewer Script │
-│ (.github/scripts/ │
-│ pr_reviewer.py) │
-│ │
-│ 1. Check cooldown │
-│ 2. Fetch PR details & diff │
-│ 3. Build review prompt │
-│ 4. Call GLM-4.7 API │
-│ (with retry logic) │
-│ 5. Validate response │
-│ 6. Post review comment │
-└────────┬────────────────────────┘
- │
- ▼
-┌─────────────────────────────────┐
-│ GLM-4.7 API │
-│ (open.bigmodel.cn) │
-└─────────────────────────────────┘
-```
-
-1. **GitHub Actions Workflow** (`.github/workflows/pr-reviewer.yml`): Triggers on @mention
-2. **Python Script** (`.github/scripts/pr_reviewer.py`): Fetches PR data and calls GLM-4.7 API
-3. **GLM-4.7 API**: Provides intelligent code analysis
-
-## Testing
-
-### Testing the PR Reviewer Bot
-
-To test the PR reviewer bot before deploying to production:
-
-1. **Create a test PR** - Make a small, safe change (e.g., documentation update)
-2. **Open the PR** - Create a pull request with a descriptive title
-3. **Trigger the review** - Comment `@vllm-omni-reviewer` on the PR
-4. **Monitor results** - Check the Actions tab for workflow execution logs
-
-### Running Unit Tests
-
-The bot includes comprehensive unit tests that can be run locally:
-
-```bash
-# Run all tests
-pytest .github/tests/test_pr_reviewer.py -v
-
-# Run specific test
-pytest .github/tests/test_pr_reviewer.py::TestCheckTrigger -v
-
-# Run with coverage
-pytest .github/tests/test_pr_reviewer.py --cov=.github/scripts/pr_reviewer.py --cov-report=term-missing
-```
-
-### What to Look For
-
-When testing, verify that:
-- [ ] The workflow triggers on the `@vllm-omni-reviewer` comment
-- [ ] The cooldown mechanism works correctly
-- [ ] The GLM API call completes without errors (with retry if needed)
-- [ ] A review comment is posted to the PR
-- [ ] The review content is meaningful and well-structured
-- [ ] The cost is within the expected range (0.50-5 CNY)
-
-### Safe Test Changes
-
-For testing, consider making these types of safe changes:
-- Documentation updates (like adding this Testing section)
-- Comment improvements
-- README enhancements
-- Non-functional file additions
-
-### Example Test PR
-
-A good test PR might:
-- Update a documentation file
-- Add explanatory comments
-- Improve code formatting
-- Fix a minor typo
-
-These changes are safe to merge if the test is successful and won't affect functionality.
-
-## Troubleshooting
-
-### Bot Doesn't Respond
-
-1. **Check permissions** - Verify you have Owner/Member/Collaborator access
-2. **Check Actions tab** - Look for workflow execution and view logs
-3. **Check cooldown** - If another review was posted recently, wait for the cooldown period
-4. **Check API key** - Ensure `GLM_API_KEY` is configured in repository secrets
-
-### API Errors
-
-If the GLM API call fails:
-- Check the Actions tab for detailed error logs
-- Verify the `GLM_API_KEY` secret is correctly configured
-- Ensure sufficient API quota is available
-- The bot will automatically retry up to 3 times with exponential backoff
-
-### Review Seems Truncated
-
-If the review appears incomplete:
-- Large diffs may be truncated at 100,000 characters
-- Check the logs for truncation warnings
-- Consider breaking large PRs into smaller chunks
-
-## Configuration
-
-### Required Secrets
-
-The following secret must be configured in the repository settings:
-
-- `GLM_API_KEY` - Your GLM (BigModel) API key for accessing the GLM-4.7 API
-
-To add the secret:
-1. Go to repository Settings → Secrets and variables → Actions
-2. Click "New repository secret"
-3. Name: `GLM_API_KEY`
-4. Value: Your GLM API key
-
-### Optional Configuration
-
-The following optional environment variables can be set in the workflow file:
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `GLM_API_URL` | `https://open.bigmodel.cn/api/paas/v4/chat/completions` | GLM API endpoint |
-| `GLM_MODEL` | `glm-4.7` | Model to use for reviews |
-| `PR_REVIEWER_COOLDOWN_MINUTES` | `5` | Cooldown period between reviews |
-| `PR_REVIEWER_MAX_RETRIES` | `3` | Maximum API retry attempts |
-| `PR_REVIEWER_RETRY_DELAY` | `1.0` | Base delay for retry backoff (seconds) |
-| `PR_REVIEWER_MAX_DIFF_SIZE` | `100000` | Maximum diff size before truncation |
-
-### Workflow Customization
-
-The workflow can be customized in `.github/workflows/pr-reviewer.yml`:
-- Change Python version (default: 3.11)
-- Adjust timeout value (default: 10 minutes)
-- Modify trigger conditions
-- Add additional dependencies
-
-## Code Quality
-
-The PR reviewer script follows vllm-omni coding standards:
-
-- **Type hints**: All functions have complete type hints following mypy strict mode
-- **Logging**: Uses Python's logging module for structured logging
-- **Testing**: Comprehensive unit tests with pytest
-- **Pre-commit**: Script is checked by pre-commit hooks (flake8)
-
-## Cost Estimate
-
-| Component | Cost |
-|-----------|------|
-| GitHub Actions (public repo) | Free |
-| GLM API (glm-4.7) | ~0.50-5 CNY per PR (varies by size) |
-| Total (20 PRs/month) | ~10-100 CNY/month (~$2-15 USD) |
-
-## Contributing
-
-To improve the PR reviewer bot:
-
-1. Edit `.github/scripts/pr_reviewer.py` for logic changes
-2. Edit `.github/workflows/pr-reviewer.yml` for workflow changes
-3. Add tests to `.github/tests/test_pr_reviewer.py`
-4. Run `pre-commit run --files .github/scripts/pr_reviewer.py` to check code quality
-5. Test thoroughly with a test PR before deploying to production
-
-## License
-
-This bot is part of the VLLM-Omni project and follows the same license terms.
diff --git a/docs/serving/image_edit_api.md b/docs/serving/image_edit_api.md
index d254ac06a..79303e1a6 100644
--- a/docs/serving/image_edit_api.md
+++ b/docs/serving/image_edit_api.md
@@ -104,6 +104,8 @@ Content-Type: multipart/form-data
| `guidance_scale` | float | model defaults | Classifier-free guidance scale (typically 0.0-20.0) |
| `true_cfg_scale` | float | model defaults | True CFG scale (model-specific parameter, may be ignored if not supported) |
| `seed` | integer | null | Random seed for reproducibility |
+| `reference_image` | string or array | null | Reference image for inpainting |
+| `mask_image` | string or array | null | Mask for inpainting (white areas will be inpainted) |
### Response Format
diff --git a/docs/serving/speech_api.md b/docs/serving/speech_api.md
index ecbe8d9ac..8f78d6a20 100644
--- a/docs/serving/speech_api.md
+++ b/docs/serving/speech_api.md
@@ -15,28 +15,17 @@ Each server instance runs a single model (specified at startup via `vllm serve <
```bash
# Qwen3-TTS: CustomVoice model (predefined speakers)
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts.yaml \
+ --deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--omni \
--port 8091 \
--trust-remote-code \
--enforce-eager
# Fish Speech S2 Pro
-vllm-omni serve fishaudio/s2-pro \
- --stage-configs-path vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml \
- --omni \
- --port 8091 \
- --trust-remote-code \
- --enforce-eager \
- --gpu-memory-utilization 0.9
+vllm serve fishaudio/s2-pro --omni --port 8091
# Voxtral TTS
-vllm serve mistralai/Voxtral-4B-TTS-2603 \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxtral_tts.yaml \
- --omni \
- --port 8091 \
- --trust-remote-code \
- --enforce-eager
+vllm serve mistralai/Voxtral-4B-TTS-2603 --omni --port 8091
```
### Generate Speech
@@ -300,7 +289,7 @@ curl -X POST http://localhost:8091/v1/audio/speech \
```bash
# Start server with VoiceDesign model first
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts.yaml \
+ --deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--omni \
--port 8091 \
--trust-remote-code \
@@ -322,7 +311,7 @@ curl -X POST http://localhost:8091/v1/audio/speech \
```bash
# Start server with Base model first
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts.yaml \
+ --deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--omni \
--port 8091 \
--trust-remote-code \
@@ -517,15 +506,16 @@ for result in response.json()["results"]:
All items are fanned out to `generate()` concurrently. The engine's stage worker automatically batches them up to the configured `max_batch_size` and queues the rest — no client-side throttling needed.
-For best throughput, use a batch-optimized stage config with `max_batch_size > 1`:
+For best throughput, set both stages' `max_num_seqs` to ≥4 via `--stage-overrides`:
```bash
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml \
- --omni --port 8091 --trust-remote-code --enforce-eager
+ --omni --port 8091 --trust-remote-code --enforce-eager \
+ --stage-overrides '{"0":{"max_num_seqs":4,"gpu_memory_utilization":0.2},
+ "1":{"max_num_seqs":4,"gpu_memory_utilization":0.2}}'
```
-The default `qwen3_tts.yaml` uses `max_batch_size: 1` (single request). The `qwen3_tts_batch.yaml` config sets `max_batch_size: 4` for ~4x throughput.
+The bundled `qwen3_tts.yaml` uses `max_num_seqs: 1` (single request) on both stages. Bumping to 4 yields roughly 4× throughput on the talker and lets stage 1 batch chunks across in-flight requests.
## Supported Models
@@ -617,7 +607,7 @@ Enable debug logging:
```bash
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts.yaml \
+ --deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--omni \
--port 8091 \
--trust-remote-code \
diff --git a/docs/source/architecture/async-chunk-architecture.png b/docs/source/architecture/async-chunk-architecture.png
index 249de53bf..7b3e95e4d 100644
Binary files a/docs/source/architecture/async-chunk-architecture.png and b/docs/source/architecture/async-chunk-architecture.png differ
diff --git a/docs/source/architecture/vllm-omni-dataflow-between-stages.png b/docs/source/architecture/vllm-omni-dataflow-between-stages.png
index cdbc9a8b7..74abc81ff 100644
Binary files a/docs/source/architecture/vllm-omni-dataflow-between-stages.png and b/docs/source/architecture/vllm-omni-dataflow-between-stages.png differ
diff --git a/docs/user_guide/diffusion/cache_acceleration/cache_dit.md b/docs/user_guide/diffusion/cache_acceleration/cache_dit.md
index 824e8c930..eaaca84ad 100644
--- a/docs/user_guide/diffusion/cache_acceleration/cache_dit.md
+++ b/docs/user_guide/diffusion/cache_acceleration/cache_dit.md
@@ -283,3 +283,10 @@ Using Cache-DiT acceleration:
1. ✅ **Enable Cache-DiT** - Set `cache_backend="cache_dit"` to get 1.5x-3x speedup with optimized defaults
2. ✅ **(Optional) Customize** - Adjust `cache_config` parameters for specific speed/quality trade-offs
+
+---
+
+## Additional Resources
+
+- [Cache-DiT documentation](https://cache-dit.readthedocs.io/en/latest/)
+- [Cache-DiT API reference](https://cache-dit.readthedocs.io/en/latest/user_guide/CACHE_API/)
diff --git a/docs/user_guide/diffusion/frame_interpolation.md b/docs/user_guide/diffusion/frame_interpolation.md
new file mode 100644
index 000000000..349af50c5
--- /dev/null
+++ b/docs/user_guide/diffusion/frame_interpolation.md
@@ -0,0 +1,92 @@
+# Frame Interpolation
+
+## Overview
+
+vLLM-Omni supports post-generation frame interpolation for supported video
+diffusion pipelines. This feature inserts synthesized intermediate frames
+between adjacent generated frames to improve temporal smoothness without
+rerunning the diffusion denoising loop.
+
+Frame interpolation runs in the diffusion worker post-processing path instead
+of the API server encoding path. This allows the interpolation step to reuse
+the worker's current accelerator device and keeps the FastAPI event loop free
+from heavy synchronous PyTorch work.
+
+For an input video with `N` generated frames and interpolation exponent `exp`,
+the output frame count is:
+
+```text
+(N - 1) * 2**exp + 1
+```
+
+The output FPS is multiplied by `2**exp` so the clip duration remains close to
+the original generated video.
+
+## Supported Pipelines
+
+Frame interpolation is currently supported for:
+
+- `WanPipeline` (Wan2.2 text-to-video)
+- `WanImageToVideoPipeline`
+- `Wan22TI2VPipeline`
+
+## Request Parameters
+
+The video APIs `/v1/videos` and `/v1/videos/sync` accept:
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `enable_frame_interpolation` | bool | `false` | Enable post-generation frame interpolation |
+| `frame_interpolation_exp` | int | `1` | Interpolation exponent. `1=2x`, `2=4x`, etc. |
+| `frame_interpolation_scale` | float | `1.0` | RIFE inference scale |
+| `frame_interpolation_model_path` | str | `None` | Local directory or Hugging Face repo ID containing `flownet.pkl` |
+
+## Execution Flow
+
+For supported Wan2.2 pipelines, the execution order is:
+
+1. Diffusion worker finishes denoising and decodes the raw video tensor.
+2. Worker-side model-specific post-processing runs.
+3. If frame interpolation is enabled, RIFE interpolates the decoded video
+ tensor on the worker side and records a FPS multiplier in `custom_output`.
+4. The API server receives the already-interpolated video and only performs
+ MP4 export.
+
+This design keeps interpolation close to the generated tensor and avoids
+introducing another heavyweight GPU context in the API server process.
+
+## Example
+
+Start the server:
+
+```bash
+vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers --omni --port 8091
+```
+
+Run a sync request with interpolation enabled:
+
+```bash
+curl -X POST http://localhost:8091/v1/videos/sync \
+ -F "prompt=A dog running through a park" \
+ -F "num_frames=81" \
+ -F "width=832" \
+ -F "height=480" \
+ -F "fps=16" \
+ -F "num_inference_steps=40" \
+ -F "guidance_scale=1.0" \
+ -F "guidance_scale_2=1.0" \
+ -F "enable_frame_interpolation=true" \
+ -F "frame_interpolation_exp=1" \
+ -F "frame_interpolation_scale=1.0" \
+ -F "seed=42" \
+ -o sync_t2v_interpolated.mp4
+```
+
+## Notes
+
+- This is a post-processing feature. It does not modify the diffusion denoising
+ schedule.
+- Higher interpolation exponents increase post-processing time and memory usage.
+- If the interpolation model weights are not available locally,
+ `frame_interpolation_model_path` may point to a Hugging Face repo containing
+ `flownet.pkl`.
diff --git a/docs/user_guide/diffusion_features.md b/docs/user_guide/diffusion_features.md
index 31cd1500f..6e4a54a65 100644
--- a/docs/user_guide/diffusion_features.md
+++ b/docs/user_guide/diffusion_features.md
@@ -14,7 +14,7 @@ vLLM-Omni supports various advanced features for diffusion models:
- Acceleration: **cache methods**, **parallelism methods**, **startup optimizations**
- Memory optimization: **cpu offloading**, **quantization**
-- Extensions: **LoRA inference**
+- Extensions: **LoRA inference**, **frame interpolation**
- Execution modes: **step execution**
## Supported Features
@@ -69,6 +69,7 @@ Extension methods add specialized capabilities to diffusion models beyond standa
| Method | Description | Best For |
|--------|-------------|----------|
| **[LoRA Inference](diffusion/lora.md)** | Enables inference with Low-Rank Adaptation (LoRA) adapters weights | Reinforcement learning extensions |
+| **[Frame Interpolation](diffusion/frame_interpolation.md)** | Inserts intermediate video frames after generation for smoother motion | Video generation pipelines that need higher temporal smoothness |
### Execution Modes
@@ -108,13 +109,14 @@ The following tables show which models support each feature:
|-------|:----------:|:-----------:|:---------------------:|:--------------:|:-----------------:|:------:|:------------------------:|:--------------------:|:--------------:|:----------------:|
| **Bagel** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **FLUX.1-dev** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
+| **FLUX.1-schnell** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.2-klein** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.1-Kontext-dev** | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **FLUX.2-dev** | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **GLM-Image** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **HunyuanImage3** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ |
-| **LongCat-Image** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
-| **LongCat-Image-Edit** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
+| **LongCat-Image** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
+| **LongCat-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **MagiHuman** | ❌ | ❌ | ❌ | ❓ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **MammothModa2(T2I)** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Nextstep_1(T2I)** | ❓ | ❓ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
@@ -138,16 +140,21 @@ The following tables show which models support each feature:
|-------|:----------:|:-----------:|:---------------------:|:--------------:|:-----------------:|:------:|:------------------------:|:--------------------:|:--------------:|:----------------:|
| **Wan2.2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (encode/decode) | ❌ | ❌ |
| **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
-| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
+| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **Helios** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ❌ |
-| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
+| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
+
+**Frame Interpolation Support**
+
+- **Supported**: Wan2.2 text-to-video, image-to-video, and TI2V pipelines
+- **Not supported**: Wan2.1-VACE, LTX-2, Helios, HunyuanVideo-1.5, DreamID-Omni
### AudioGen
| Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization | 🔄Step Execution |
|-------|:----------:|:-----------:|:---------------------:|:--------------:|:-----------------:|:------:|:------------------------:|:--------------------:|:--------------:|:----------------:|
-| **Stable-Audio-Open** | ✅ | ❌ | ❓ | ❓ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
+| **Stable-Audio-Open** | ✅ | ❌ | ❓ | ❓ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ |
## Feature Compatibility
@@ -258,6 +265,7 @@ Measured on NVIDIA H800:
**Extensions:**
- **[LoRA Inference Guide](diffusion/lora.md)** - Low-Rank Adaptation for style customization and fine-tuning
+- **[Frame Interpolation Guide](diffusion/frame_interpolation.md)** - Worker-side post-generation video frame interpolation for smoother motion
**Execution Modes:**
diff --git a/docs/user_guide/examples/offline_inference/bagel.md b/docs/user_guide/examples/offline_inference/bagel.md
index e62668687..1fb4d4045 100644
--- a/docs/user_guide/examples/offline_inference/bagel.md
+++ b/docs/user_guide/examples/offline_inference/bagel.md
@@ -176,8 +176,6 @@ Example configuration for TP=2 on GPUs 0 and 1:
| Parameter | Value | Description |
| :-------------------- | :------ | :------------------------------- |
-| `window_size` | `-1` | Window size (-1 means unlimited) |
-| `max_inflight` | `1` | Maximum inflight requests |
| `shm_threshold_bytes` | `65536` | Shared memory threshold (64KB) |
## Using Mooncake Connector
diff --git a/docs/user_guide/examples/offline_inference/cosyvoice3.md b/docs/user_guide/examples/offline_inference/cosyvoice3.md
index ebb7c02ef..d0638f414 100644
--- a/docs/user_guide/examples/offline_inference/cosyvoice3.md
+++ b/docs/user_guide/examples/offline_inference/cosyvoice3.md
@@ -61,10 +61,17 @@ Key components live in `vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py
- Stage 0 uses `CosyVoice3LM` and outputs speech tokens + conditioning features.
- Stage 1 runs the flow model (DiT-based CFM) and HiFiGAN to synthesize waveform.
-Stage wiring is configured in `vllm_omni/model_executor/stage_configs/cosyvoice3.yaml`.
+Pipeline topology lives in `vllm_omni/model_executor/models/cosyvoice3/pipeline.py`;
+runtime tunables (batch size, memory limits, sampling) live in
+`vllm_omni/deploy/cosyvoice3.yaml`. The deploy config auto-loads by
+HF `model_type` and defaults to `async_chunk: true` (shared-memory
+streaming). Pass `--no-async-chunk` on `vllm serve` to switch to the
+legacy sync path where stage 1 runs `text2flow` over the full
+speech-token sequence.
- Stage 0 emits latent speech tokens.
-- Stage 1 consumes them via `custom_process_input_func` and outputs audio.
+- Stage 1 consumes them via `sync_process_input_func` (sync mode) or the
+ shared-memory connector (async-chunk mode) and outputs audio.
## Example materials
diff --git a/docs/user_guide/examples/offline_inference/mimo_audio.md b/docs/user_guide/examples/offline_inference/mimo_audio.md
index 4e8052697..1cba2f77d 100644
--- a/docs/user_guide/examples/offline_inference/mimo_audio.md
+++ b/docs/user_guide/examples/offline_inference/mimo_audio.md
@@ -38,7 +38,6 @@ Run a single sample for basic TTS:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type tts_sft
```
@@ -47,7 +46,6 @@ Run batch samples for basic TTS:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type tts_sft \
--num-prompts {batch_size}
@@ -65,7 +63,6 @@ Generate speech from text input:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type tts_sft \
--text "The weather is so nice today."
@@ -77,7 +74,6 @@ Generate speech with explicit voice style instructions:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type tts_sft_with_instruct \
--text "The weather is so nice today." \
@@ -90,7 +86,6 @@ Generate speech using an audio reference for voice cloning:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type tts_sft_with_audio \
--text "The weather is so nice today." \
@@ -103,7 +98,6 @@ Generate speech from text containing natural voice descriptions:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type tts_sft_with_natural_instruction \
--text "In a panting young male voice, he said: I can't run anymore, wait for me!"
@@ -115,7 +109,6 @@ Transcribe audio to text:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type audio_trancribing_sft \
--audio-path "./spoken_dialogue_assistant_turn_1.wav"
@@ -127,7 +120,6 @@ Understand and analyze audio content with text queries:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type audio_understanding_sft \
--text "Summarize the audio." \
@@ -140,7 +132,6 @@ Audio understanding with reasoning chain:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type audio_understanding_sft_with_thinking \
--text "Summarize the audio." \
@@ -153,7 +144,6 @@ Multi-turn dialogue with audio input and output:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type spoken_dialogue_sft_multiturn \
--audio-path "./prompt_speech_zh_m.wav"
@@ -167,7 +157,6 @@ Multi-turn dialogue converting speech to text:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type speech2text_dialogue_sft_multiturn
```
@@ -180,7 +169,6 @@ Multi-turn text-only dialogue:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type text_dialogue_sft_multiturn
```
diff --git a/docs/user_guide/examples/offline_inference/qwen3_tts.md b/docs/user_guide/examples/offline_inference/qwen3_tts.md
index 19fea4132..7226ac1fe 100644
--- a/docs/user_guide/examples/offline_inference/qwen3_tts.md
+++ b/docs/user_guide/examples/offline_inference/qwen3_tts.md
@@ -18,11 +18,11 @@ Please refer to the [stage configuration documentation](https://docs.vllm.ai/pro
### ROCm Dependencies
-You will need to install these two dependencies `onnxruntime-rocm` and `sox`.
+You will need to install the dependency `onnxruntime-rocm`.
```
pip uninstall onnxruntime # should be removed before we can install onnxruntime-rocm
-pip install onnxruntime-rocm sox
+pip install onnxruntime-rocm
```
## Quick Start
@@ -144,13 +144,13 @@ completes. This demonstrates that audio data is available progressively rather t
## Batched Decoding
-The Code2Wav stage (stage 1) supports batched decoding, where multiple requests are decoded in a single forward pass through the SpeechTokenizer. To use it, provide a stage config with `max_num_seqs > 1` and pass multiple prompts via `--txt-prompts` with a matching `--batch-size`.
+The Code2Wav stage (stage 1) supports batched decoding, where multiple requests are decoded in a single forward pass through the SpeechTokenizer. To use it, set `max_num_seqs > 1` on both stages via `--stage-overrides` and pass multiple prompts via `--txt-prompts` with a matching `--batch-size`.
```
python end2end.py --query-type CustomVoice \
--txt-prompts benchmark_prompts.txt \
--batch-size 4 \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml
+ --stage-overrides '{"0":{"max_num_seqs":4,"gpu_memory_utilization":0.2},"1":{"max_num_seqs":4,"gpu_memory_utilization":0.2}}'
```
**Important:** `--batch-size` must match a CUDA graph capture size (1, 2, 4, 8, 16...) because the Talker's code predictor KV cache is sized to `max_num_seqs`, and CUDA graphs pad the batch to the next capture size. Both stages need `max_num_seqs >= batch_size` in the stage config for batching to take effect. If only stage 1 has a higher `max_num_seqs`, it won't help — stage 1 can only batch chunks from requests that are in-flight simultaneously, which requires stage 0 to also process multiple requests concurrently.
diff --git a/docs/user_guide/examples/offline_inference/text_to_audio.md b/docs/user_guide/examples/offline_inference/text_to_audio.md
index 62a70e525..a31a4d7a4 100644
--- a/docs/user_guide/examples/offline_inference/text_to_audio.md
+++ b/docs/user_guide/examples/offline_inference/text_to_audio.md
@@ -29,6 +29,22 @@ python text_to_audio.py \
--output stable_audio_output.wav
```
+To reduce per-GPU memory for multi-GPU inference, launch with HSDP:
+
+```bash
+python text_to_audio.py \
+ --model stabilityai/stable-audio-open-1.0 \
+ --prompt "The sound of a hammer hitting a wooden surface" \
+ --negative-prompt "Low quality" \
+ --seed 42 \
+ --guidance-scale 7.0 \
+ --audio-length 10.0 \
+ --num-inference-steps 100 \
+ --use-hsdp \
+ --hsdp-shard-size 2 \
+ --output stable_audio_output.wav
+```
+
Key arguments:
- `--prompt`: text description (string).
@@ -37,6 +53,9 @@ Key arguments:
- `--guidance-scale`: classifier-free guidance scale.
- `--audio-length`: audio duration in seconds.
- `--num-inference-steps`: diffusion sampling steps.(more steps = higher quality, slower).
+- `--use-hsdp`: enable HSDP weight sharding for the Stable Audio DiT.
+- `--hsdp-shard-size`: number of GPUs used for HSDP sharding.
+- `--hsdp-replicate-size`: number of HSDP replica groups.
- `--output`: path to save the generated WAV file.
## Example materials
diff --git a/docs/user_guide/examples/offline_inference/voxtral_tts.md b/docs/user_guide/examples/offline_inference/voxtral_tts.md
index db4c2e26b..1a4f2c21b 100644
--- a/docs/user_guide/examples/offline_inference/voxtral_tts.md
+++ b/docs/user_guide/examples/offline_inference/voxtral_tts.md
@@ -13,28 +13,24 @@ When `mistral_common` has `SpeechRequest` support, prompt token IDs are built vi
```bash
# Basic single-prompt with cheerful_female voice preset
python3 examples/offline_inference/voxtral_tts/end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxtral_tts.yaml \
--write-audio --voice cheerful_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# 32 replicate prompts with cheerful_female voice preset
python3 examples/offline_inference/voxtral_tts/end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxtral_tts.yaml \
--num-prompts 32 --write-audio --voice cheerful_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# Streaming with neutral_female voice preset
python3 examples/offline_inference/voxtral_tts/end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxtral_tts.yaml \
--streaming --write-audio --voice neutral_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# 32 prompts, 8 concurrent requests per wave, streaming with neutral_female voice
python3 examples/offline_inference/voxtral_tts/end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxtral_tts.yaml \
--num-prompts 32 --concurrency 8 --streaming --write-audio --voice neutral_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
@@ -42,7 +38,6 @@ python3 examples/offline_inference/voxtral_tts/end2end.py \
# Short debug prompt with reference audio
# Note: Reference audio capability is not yet released.
python3 examples/offline_inference/voxtral_tts/end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxtral_tts.yaml \
--write-audio \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "This is a test message." \
@@ -57,7 +52,7 @@ python3 examples/offline_inference/voxtral_tts/end2end.py \
| `--text TEXT` | Text to synthesize (default: `"This is a test message."`) |
| `--audio-path PATH` | Path to reference audio file for voice cloning |
| `--output-dir DIR` | Directory to write output WAV files (default: `output_audio`) |
-| `--stage-configs-path PATH` | Path to stage configs YAML (currently it must be set for VoxtralTTS) |
+| `--deploy-config PATH` | Override the deploy config path. If unset, auto-loads `vllm_omni/deploy/voxtral_tts.yaml` from the HF `model_type`. |
| `--num-prompts N` | Number of replicate prompts to run for measuring performance (default: 1) |
| `--streaming` | Use streaming generation via `AsyncOmni` (default: blocking `Omni`) |
| `--concurrency N` | Max concurrent requests per wave (must be used with `--streaming`, must evenly divide `--num-prompts`) |
diff --git a/docs/user_guide/examples/offline_inference/x_to_video_audio.md b/docs/user_guide/examples/offline_inference/x_to_video_audio.md
index 8ea39d811..cec8d47c5 100644
--- a/docs/user_guide/examples/offline_inference/x_to_video_audio.md
+++ b/docs/user_guide/examples/offline_inference/x_to_video_audio.md
@@ -31,9 +31,9 @@ dreamid_omni/
```
### Run the Inference
-```
+```python
python x_to_video_audio.py \
- --model /xx/dreamid_omni \
+ --model /path/to/dreamid_omni \
--prompt "Two people walking together and singing happily" \
--image-path ./example0.png ./example1.png \
--audio-path ./example0.wav ./example1.wav \
@@ -43,11 +43,33 @@ python x_to_video_audio.py \
--num-inference-steps 45 \
--height 704 \
--width 1280 \
- --output dreamid_omni.mp4
+ --output out_dreamid_omni_twoip.mp4
```
In the current test scenario (2 images + 2 audio inputs), the VRAM requirement is 72GB, regardless of whether cfg-parallel is enabled or disabled.
The VRAM usage can be reduced by enabling CPU offload via --enable-cpu-offload.
+
+You could take reference images/audios from the test cases in the official repo: https://github.com/Guoxu1233/DreamID-Omni
+
+For example, single IP ref resources can be found under https://github.com/Guoxu1233/DreamID-Omni/tree/main/test_case/oneip, you could download them correspondingly to your local and use them for testing.
+
+```python
+# Example usage for oneip, ref media from the official repo DreamID-Omni
+python x_to_video_audio.py \
+ --model /path/to/dreamid_omni \
+ --prompt ": In the frame, a woman with black long hair is identified as .\n**Overall Environment/Scene**: A lively open-kitchen café at night; stove flames flare, steam rises, and warm pendant lights swing slightly as staff move behind her. The shot is an upper-body close-up.\n**Main Characters/Subjects Appearance**: is a young woman with thick dark wavy hair and a side part. She wears a fitted black top under a light apron, a thin gold chain necklace, and small stud earrings.\n**Main Characters/Subjects Actions**: tastes the sauce with a spoon, then turns her face toward the camera while still holding the spoon, her expression shifting from focused to conflicted.\n maintains eye contact, swallows as if choosing her words, and says, I keep telling myself I’m fine,but some nights it feels like I’m just performing calm." \
+ --image-path 9.png \
+ --audio-path 9.wav \
+ --video-negative-prompt "jitter, bad hands, blur, distortion" \
+ --audio-negative-prompt "robotic, muffled, echo, distorted" \
+ --cfg-parallel-size 2 \
+ --num-inference-steps 45 \
+ --height 704 \
+ --width 1280 \
+ --output out_dreamid_omni_oneip.mp4
+```
+
+
Key arguments:
- `--prompt`: text description (string).
- `--model`: path to the model local directory.
diff --git a/docs/user_guide/examples/online_serving/fish_speech.md b/docs/user_guide/examples/online_serving/fish_speech.md
index 7322d06aa..2a15ef44a 100644
--- a/docs/user_guide/examples/online_serving/fish_speech.md
+++ b/docs/user_guide/examples/online_serving/fish_speech.md
@@ -41,15 +41,11 @@ Features:
## Launch the Server
```bash
-vllm-omni serve fishaudio/s2-pro \
- --stage-configs-path vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml \
- --omni \
- --port 8091 \
- --trust-remote-code \
- --enforce-eager \
- --gpu-memory-utilization 0.9
+vllm serve fishaudio/s2-pro --omni --port 8091
```
+The deploy config is auto-loaded from `vllm_omni/deploy/fish_qwen3_omni.yaml`.
+
Or use the convenience script:
```bash
diff --git a/docs/user_guide/examples/online_serving/image_to_video.md b/docs/user_guide/examples/online_serving/image_to_video.md
index 00b67d74e..781f0c2a5 100644
--- a/docs/user_guide/examples/online_serving/image_to_video.md
+++ b/docs/user_guide/examples/online_serving/image_to_video.md
@@ -72,6 +72,9 @@ curl -X POST http://localhost:8091/v1/videos/sync \
-F "guidance_scale_2=1.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=12.0" \
+ -F "enable_frame_interpolation=true" \
+ -F "frame_interpolation_exp=1" \
+ -F "frame_interpolation_scale=1.0" \
-F "seed=42" \
-o sync_i2v_output.mp4
```
@@ -114,6 +117,9 @@ create_response=$(curl -s http://localhost:8091/v1/videos \
-F "guidance_scale_2=1.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=12.0" \
+ -F "enable_frame_interpolation=true" \
+ -F "frame_interpolation_exp=1" \
+ -F "frame_interpolation_scale=1.0" \
-F "seed=42")
video_id=$(echo "$create_response" | jq -r '.id')
@@ -172,9 +178,35 @@ curl -X POST http://localhost:8091/v1/videos \
-F "guidance_scale_2=1.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=12.0" \
+ -F "enable_frame_interpolation=true" \
+ -F "frame_interpolation_exp=1" \
+ -F "frame_interpolation_scale=1.0" \
-F "seed=42"
```
+Frame interpolation is also available for supported Wan2.2 I2V requests. See
+[Frame Interpolation](../../diffusion/frame_interpolation.md) for worker-side
+execution details and feature constraints.
+
+### Frame Interpolation Example
+
+```bash
+curl -X POST http://localhost:8091/v1/videos/sync \
+ -F "prompt=A bear playing with yarn, smooth motion" \
+ -F "input_reference=@/path/to/qwen-bear.png" \
+ -F "width=832" \
+ -F "height=480" \
+ -F "num_frames=33" \
+ -F "fps=16" \
+ -F "num_inference_steps=40" \
+ -F "guidance_scale=1.0" \
+ -F "guidance_scale_2=1.0" \
+ -F "enable_frame_interpolation=true" \
+ -F "frame_interpolation_exp=1" \
+ -F "frame_interpolation_scale=1.0" \
+ -o sync_i2v_interpolated.mp4
+```
+
## Create Response Format
`POST /v1/videos` returns a job record, not inline base64 video data.
diff --git a/docs/user_guide/examples/online_serving/mimo_audio.md b/docs/user_guide/examples/online_serving/mimo_audio.md
index 4737eca36..c8752f578 100644
--- a/docs/user_guide/examples/online_serving/mimo_audio.md
+++ b/docs/user_guide/examples/online_serving/mimo_audio.md
@@ -13,10 +13,10 @@ Please refer to [README.md](https://github.com/vllm-project/vllm-omni/tree/main/
```bash
export MIMO_AUDIO_TOKENIZER_PATH="XiaomiMiMo/MiMo-Audio-Tokenizer"
-vllm-omni serve XiaomiMiMo/MiMo-Audio-7B-Instruct --omni \
---served-model-name "MiMo-Audio-7B-Instruct" \
---port 18091 --stage-configs-path ./vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
---chat-template ./examples/online_serving/mimo_audio/chat_template.jinja
+vllm serve XiaomiMiMo/MiMo-Audio-7B-Instruct --omni \
+ --served-model-name "MiMo-Audio-7B-Instruct" \
+ --port 18091 \
+ --chat-template ./examples/online_serving/mimo_audio/chat_template.jinja
```
> ⚠️ **Important**
> **MiMo-Audio is not compatible with the default chat template.**
diff --git a/docs/user_guide/examples/online_serving/qwen3_omni.md b/docs/user_guide/examples/online_serving/qwen3_omni.md
index 6f6d9ae4a..611eb6fd3 100644
--- a/docs/user_guide/examples/online_serving/qwen3_omni.md
+++ b/docs/user_guide/examples/online_serving/qwen3_omni.md
@@ -18,12 +18,12 @@ vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
If you want to open async chunking for qwen3-omni, launch the server with command below
```bash
-vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --stage-configs-path /vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --deploy-config /vllm_omni/deploy/qwen3_omni_moe.yaml
```
If you have custom stage configs file, launch the server with command below
```bash
-vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --stage-configs-path /path/to/stage_configs_file
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --deploy-config /path/to/deploy_config_file
```
### Send Multi-modal Request
@@ -187,7 +187,7 @@ The script supports the following arguments:
- `--model`: Model name/path (default: Qwen/Qwen3-Omni-30B-A3B-Instruct)
- `--server-port`: Port for vLLM server (default: 8091)
- `--gradio-port`: Port for Gradio demo (default: 7861)
-- `--stage-configs-path`: Path to custom stage configs YAML file (optional)
+- `--deploy-config`: Path to custom deploy config YAML file (optional)
- `--server-host`: Host for vLLM server (default: 0.0.0.0)
- `--gradio-ip`: IP for Gradio demo (default: 127.0.0.1)
- `--share`: Share Gradio demo publicly (creates a public link)
@@ -202,7 +202,7 @@ vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
If you have custom stage configs file:
```bash
-vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --stage-configs-path /path/to/stage_configs_file
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --deploy-config /path/to/deploy_config_file
```
**Step 2: Run the Gradio demo**
diff --git a/docs/user_guide/examples/online_serving/qwen3_tts.md b/docs/user_guide/examples/online_serving/qwen3_tts.md
index 4e632d4c2..95f234f02 100644
--- a/docs/user_guide/examples/online_serving/qwen3_tts.md
+++ b/docs/user_guide/examples/online_serving/qwen3_tts.md
@@ -58,7 +58,7 @@ Then open http://localhost:7860 in your browser.
```bash
# CustomVoice model (predefined speakers)
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts.yaml \
+ --deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--omni \
--port 8091 \
--trust-remote-code \
@@ -66,7 +66,7 @@ vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
# VoiceDesign model
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts.yaml \
+ --deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--omni \
--port 8091 \
--trust-remote-code \
@@ -74,7 +74,7 @@ vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign \
# Base model (voice cloning)
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts.yaml \
+ --deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--omni \
--port 8091 \
--trust-remote-code \
diff --git a/docs/user_guide/examples/online_serving/text_to_video.md b/docs/user_guide/examples/online_serving/text_to_video.md
index 01e6d9d46..b918aac19 100644
--- a/docs/user_guide/examples/online_serving/text_to_video.md
+++ b/docs/user_guide/examples/online_serving/text_to_video.md
@@ -165,6 +165,9 @@ curl -X POST http://localhost:8091/v1/videos \
-F "guidance_scale_2=4.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=5.0" \
+ -F "enable_frame_interpolation=true" \
+ -F "frame_interpolation_exp=1" \
+ -F "frame_interpolation_scale=1.0" \
-F "seed=42"
```
@@ -187,6 +190,35 @@ curl -X POST http://localhost:8091/v1/videos \
| `flow_shift` | float | None | Scheduler flow shift (Wan2.2) |
| `seed` | int | None | Random seed (reproducible) |
| `lora` | object | None | LoRA configuration |
+| `enable_frame_interpolation` | bool | false | Enable RIFE frame interpolation before MP4 encoding |
+| `frame_interpolation_exp` | int | 1 | Interpolation exponent; 1=2x temporal resolution, 2=4x |
+| `frame_interpolation_scale` | float | 1.0 | RIFE inference scale; use 0.5 for high-resolution inputs |
+| `frame_interpolation_model_path` | str | None | Local directory or Hugging Face repo ID with `flownet.pkl`; defaults to `elfgum/RIFE-4.22.lite` |
+
+## Frame Interpolation
+
+Frame interpolation is an optional post-processing step for `/v1/videos` and
+`/v1/videos/sync`. It synthesizes intermediate frames between generated frames
+without rerunning the diffusion model. If the generated video has `N` frames,
+the interpolated output frame count is `(N - 1) * 2**exp + 1`. The encoder FPS
+is multiplied by `2**exp` so the output duration remains close to the original.
+
+Frame interpolation runs in the diffusion worker post-processing path instead of
+the API server encoding path, so it can reuse the worker's current accelerator
+device without blocking the FastAPI event loop.
+
+Example: generate 5 frames and interpolate to 9 frames:
+
+```bash
+curl -X POST http://localhost:8091/v1/videos/sync \
+ -F "prompt=A dog running through a park" \
+ -F "num_frames=5" \
+ -F "fps=8" \
+ -F "enable_frame_interpolation=true" \
+ -F "frame_interpolation_exp=1" \
+ -F "frame_interpolation_scale=1.0" \
+ -o sync_t2v_interpolated.mp4
+```
## Create Response Format
@@ -256,6 +288,14 @@ vllm serve Lightricks/LTX-2 --omni --port 8098 \
--enforce-eager --flow-shift 1.0 --boundary-ratio 1.0
```
+For multi-GPU memory reduction, you can enable HSDP:
+
+```bash
+vllm serve Lightricks/LTX-2 --omni --port 8098 \
+ --enforce-eager --flow-shift 1.0 --boundary-ratio 1.0 \
+ --use-hsdp --hsdp-shard-size 2
+```
+
#### Start with Optimization Presets
Use the LTX-2 startup script with built-in optimization presets:
diff --git a/examples/offline_inference/bagel/README.md b/examples/offline_inference/bagel/README.md
index 48517b1cd..3e653d0e3 100644
--- a/examples/offline_inference/bagel/README.md
+++ b/examples/offline_inference/bagel/README.md
@@ -173,8 +173,6 @@ Example configuration for TP=2 on GPUs 0 and 1:
| Parameter | Value | Description |
| :-------------------- | :------ | :------------------------------- |
-| `window_size` | `-1` | Window size (-1 means unlimited) |
-| `max_inflight` | `1` | Maximum inflight requests |
| `shm_threshold_bytes` | `65536` | Shared memory threshold (64KB) |
## Using Mooncake Connector
diff --git a/examples/offline_inference/cosyvoice3/README.md b/examples/offline_inference/cosyvoice3/README.md
index e16134e6e..704b49614 100644
--- a/examples/offline_inference/cosyvoice3/README.md
+++ b/examples/offline_inference/cosyvoice3/README.md
@@ -58,7 +58,14 @@ Key components live in `vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py
- Stage 0 uses `CosyVoice3LM` and outputs speech tokens + conditioning features.
- Stage 1 runs the flow model (DiT-based CFM) and HiFiGAN to synthesize waveform.
-Stage wiring is configured in `vllm_omni/model_executor/stage_configs/cosyvoice3.yaml`.
+Pipeline topology lives in `vllm_omni/model_executor/models/cosyvoice3/pipeline.py`;
+runtime tunables (batch size, memory limits, sampling) live in
+`vllm_omni/deploy/cosyvoice3.yaml`. The deploy config auto-loads by
+HF `model_type` and defaults to `async_chunk: true` (shared-memory
+streaming). Pass `--no-async-chunk` on `vllm serve` to switch to the
+legacy sync path where stage 1 runs `text2flow` over the full
+speech-token sequence.
- Stage 0 emits latent speech tokens.
-- Stage 1 consumes them via `custom_process_input_func` and outputs audio.
+- Stage 1 consumes them via `sync_process_input_func` (sync mode) or the
+ shared-memory connector (async-chunk mode) and outputs audio.
diff --git a/examples/offline_inference/cosyvoice3/verify_e2e_cosyvoice.py b/examples/offline_inference/cosyvoice3/verify_e2e_cosyvoice.py
index 6311bbc90..de2712891 100644
--- a/examples/offline_inference/cosyvoice3/verify_e2e_cosyvoice.py
+++ b/examples/offline_inference/cosyvoice3/verify_e2e_cosyvoice.py
@@ -24,7 +24,13 @@ def run_e2e():
required=True,
help="Path to CosyVoice3 model directory (e.g., pretrained_models/Fun-CosyVoice3-0.5B/).",
)
- parser.add_argument("--stage-config", type=str, default="vllm_omni/model_executor/stage_configs/cosyvoice3.yaml")
+ parser.add_argument(
+ "--deploy-config",
+ type=str,
+ default=None,
+ help="Override the deploy config path. If unset, auto-loads "
+ "vllm_omni/deploy/cosyvoice3.yaml based on the HF model_type.",
+ )
parser.add_argument("--prompt", type=str, default="Hello, this is a test of the CosyVoice system capability.")
parser.add_argument(
"--prompt-text",
@@ -43,18 +49,14 @@ def run_e2e():
if not os.path.exists(args.tokenizer):
raise FileNotFoundError(f"{args.tokenizer} does not exist!")
- # Ensure stage config exists
- if not os.path.exists(args.stage_config):
- raise FileNotFoundError(f"{args.stage_config} does not exist!")
+ if args.deploy_config is not None and not os.path.exists(args.deploy_config):
+ raise FileNotFoundError(f"{args.deploy_config} does not exist!")
print(f"Initializing cosyvoice E2E with model={args.model}")
- # Initialize Omni
- # This spins up the engine(s) based on the stage config
- # We pass trust_remote_code=True same as Qwen examples
omni = Omni(
model=args.model,
- stage_configs_path=args.stage_config,
+ deploy_config=args.deploy_config,
trust_remote_code=True,
tokenizer=args.tokenizer,
log_stats=True,
diff --git a/examples/offline_inference/glm_image/end2end.py b/examples/offline_inference/glm_image/end2end.py
index 13bcd23f5..7ae9478ba 100644
--- a/examples/offline_inference/glm_image/end2end.py
+++ b/examples/offline_inference/glm_image/end2end.py
@@ -57,22 +57,22 @@ GLM_IMAGE_EOS_TOKEN_ID = 16385 # eos_token_id from generation_config.json
GLM_IMAGE_VISION_VOCAB_SIZE = 16512 # top_k should be vision_vocab_size
-def compute_max_tokens(height: int, width: int, factor: int = 32) -> int:
+def compute_max_tokens(height: int, width: int, factor: int = 32, is_i2i: bool = False) -> int:
"""
Compute max_new_tokens for GLM-Image AR generation.
- GLM-Image generates tokens in this order for text-to-image:
- 1. Small preview image (half resolution in each dimension)
- 2. Large target image (full resolution)
- 3. EOS token
+ GLM-Image generation differs by mode:
+ - text-to-image (t2i): small preview + large target + EOS
+ - image-to-image (i2i): large target + EOS
Args:
height: Target image height in pixels
width: Target image width in pixels
factor: Downsampling factor (32 for GLM-Image AR output)
+ is_i2i: Whether the request is image-to-image mode
Returns:
- Total number of tokens to generate (small + large + EOS)
+ Total number of tokens to generate for the specified mode
"""
# Large image tokens (target resolution)
token_h = height // factor
@@ -80,11 +80,15 @@ def compute_max_tokens(height: int, width: int, factor: int = 32) -> int:
large_tokens = token_h * token_w
# Small preview tokens (half resolution in each dimension)
- small_h = token_h // 2
- small_w = token_w // 2
- small_tokens = small_h * small_w
+ import math
- # Total: small + large + EOS
+ ratio = token_h / token_w if token_w > 0 else 1.0
+ small_token_h = max(1, int(math.sqrt(ratio) * (factor // 2)))
+ small_token_w = max(1, int(math.sqrt(1 / ratio) * (factor // 2)))
+ small_tokens = small_token_h * small_token_w
+
+ if is_i2i:
+ return large_tokens + 1
return small_tokens + large_tokens + 1
@@ -282,14 +286,18 @@ def main(args: argparse.Namespace) -> None:
# Compute max_tokens dynamically based on target image size
target_height = prompt_dict.get("height", 1024)
target_width = prompt_dict.get("width", 1024)
- calculated_max_tokens = compute_max_tokens(target_height, target_width)
+ is_i2i = source_image is not None
+ calculated_max_tokens = compute_max_tokens(target_height, target_width, is_i2i=is_i2i)
# Use calculated value unless user explicitly specified a different value
# Default args.max_tokens is 16384 (very large), so prefer calculated value
effective_max_tokens = calculated_max_tokens if args.max_tokens == 16384 else args.max_tokens
if args.verbose:
- print(f"AR max_tokens: {effective_max_tokens} (calculated: {calculated_max_tokens}, arg: {args.max_tokens})")
+ print(
+ f"AR max_tokens: {effective_max_tokens} "
+ f"(calculated: {calculated_max_tokens}, arg: {args.max_tokens}, mode: {'i2i' if is_i2i else 't2i'})"
+ )
# IMPORTANT: GLM-Image AR model requires these exact sampling parameters
# from generation_config.json for proper image token generation.
@@ -303,6 +311,12 @@ def main(args: argparse.Namespace) -> None:
stop_token_ids=[GLM_IMAGE_EOS_TOKEN_ID], # 16385, CRITICAL for stopping
seed=args.seed,
detokenize=False,
+ # Keep target size available in runner/model for deterministic M-RoPE
+ # decode grids in t2i (no mm_features available in this path).
+ extra_args={
+ "target_h": int(target_height),
+ "target_w": int(target_width),
+ },
)
# For diffusion stage, sampling_params contains diffusion-specific parameters
diff --git a/examples/offline_inference/hunyuan_image3/README.md b/examples/offline_inference/hunyuan_image3/README.md
index da28a44d9..3cd8fa01b 100644
--- a/examples/offline_inference/hunyuan_image3/README.md
+++ b/examples/offline_inference/hunyuan_image3/README.md
@@ -1,25 +1,161 @@
-# HunyuanImage-3.0 Image-to-Text Inference
+# HunyuanImage-3.0-Instruct
-This example demonstrates how to run HunyuanImage-3.0 Image-to-Text with the vLLM-Omni.
+## Set up
-## Local CLI Usage
+Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup.
-Download the example image:
+## Run examples
+
+**Note**: These examples work with the default configuration on **8x NVIDIA L40S (48GB)**. For different GPU setups, modify the stage configuration to adjust device allocation and memory utilization.
+
+Get into the hunyuan_image3 folder:
```bash
-wget https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/cherry_blossom.jpg
+cd examples/offline_inference/hunyuan_image3
```
-Run example:
+### Modality Control
+
+HunyuanImage-3.0-Instruct supports multiple modality modes. You can control the mode using the `--modality` argument:
+
+#### Text to Image (text2img)
+
+- **Pipeline**: Text → AR (CoT + latent tokens) → DiT (denoise) → VAE Decode → Image
+- **Stages Used**: Stage 0 (AR) + Stage 1 (DiT)
+- **KV Transfer**: AR sends KV cache to DiT for conditioned generation
+- **Default Config**: `hunyuan_image3_t2i.yaml`
```bash
-python image_to_text.py \
- --image cherry_blossom.jpg \
- --prompt "<|startoftext|>You are an assistant that understands images and outputs text. Describe the content of the picture."
+python end2end.py --model tencent/HunyuanImage-3.0-Instruct \
+ --modality text2img \
+ --prompts "A cute cat sitting on a windowsill watching the sunset"
```
-Key arguments:
+#### Image to Image (img2img)
-- `--model`: Model used. Default is: tencent/HunyuanImage-3.0-Instruct (Optional).
-- `--image`: Path to input image (required).
-- `--prompt`: Text description used to guide image understanding (required).
+- **Pipeline**: Image + Text → AR (CoT + recaption + latent) → DiT → Edited Image
+- **Stages Used**: Stage 0 (AR) + Stage 1 (DiT)
+- **KV Transfer**: AR sends KV cache to DiT
+- **Default Config**: `hunyuan_image3_it2i.yaml`
+
+```bash
+python end2end.py --model tencent/HunyuanImage-3.0-Instruct \
+ --modality img2img \
+ --image-path /path/to/image.png \
+ --prompts "Make the petals neon pink"
+```
+
+#### Image to Text (img2text)
+
+- **Pipeline**: Image + Question → AR → Text description
+- **Stages Used**: Stage 0 (AR) only
+- **Default Config**: `hunyuan_image3_i2t.yaml`
+
+```bash
+python end2end.py --model tencent/HunyuanImage-3.0-Instruct \
+ --modality img2text \
+ --image-path /path/to/image.jpg \
+ --prompts "Describe the content of the picture."
+```
+
+#### Text to Text (text2text)
+
+- **Pipeline**: Text → AR → Text
+- **Stages Used**: Stage 0 (AR) only
+- **Default Config**: `hunyuan_image3_t2t.yaml`
+
+```bash
+python end2end.py --model tencent/HunyuanImage-3.0-Instruct \
+ --modality text2text \
+ --prompts "What is the capital of France?"
+```
+
+### Inference Steps & Guidance
+
+Control generation quality for image modalities:
+
+```bash
+python end2end.py --modality text2img \
+ --steps 50 \
+ --guidance-scale 5.0 \
+ --height 1024 --width 1024 \
+ --prompts "A photo-realistic sunset over the ocean"
+```
+
+### Key Arguments
+
+#### 📌 Command Line Arguments (end2end.py)
+
+| Argument | Type | Default | Description |
+| :--------------------- | :----- | :----------------------------------- | :----------------------------------------------------------- |
+| `--model` | string | `tencent/HunyuanImage-3.0-Instruct` | Model path or name |
+| `--modality` | choice | `text2img` | Modality: `text2img`, `img2img`, `img2text`, `text2text` |
+| `--prompts` | list | `None` | Input text prompts |
+| `--image-path` | string | `None` | Input image path (for `img2img`/`img2text`) |
+| `--output` | string | `.` | Output directory for saved images |
+| `--steps` | int | `50` | Number of inference steps |
+| `--guidance-scale` | float | `5.0` | Classifier-free guidance scale |
+| `--seed` | int | `42` | Random seed |
+| `--height` | int | `1024` | Output image height |
+| `--width` | int | `1024` | Output image width |
+| `--bot-task` | string | auto | Override prompt task (e.g. `it2i_think`, `t2i_recaption`) |
+| `--sys-type` | string | auto | Override system prompt type (e.g. `en_unified`, `en_vanilla`) |
+| `--stage-configs-path` | string | auto | Custom stage config YAML path |
+| `--enforce-eager` | flag | `False` | Disable torch.compile |
+| `--init-timeout` | int | `300` | Initialization timeout (seconds) |
+
+------
+
+#### ⚙️ Stage Configurations
+
+| Config YAML | Modality | Stages | GPUs | Description |
+| :---------------------------------- | :-------- | :----- | :----- | :------------------------------------ |
+| `hunyuan_image3_t2i.yaml` | text2img | 2 | 8 | T2I with AR→DiT, 4 GPU each |
+| `hunyuan_image3_it2i.yaml` | img2img | 2 | 8 | IT2I with AR→DiT, 4 GPU each |
+| `hunyuan_image3_i2t.yaml` | img2text | 1 | 4 | I2T (AR only) |
+| `hunyuan_image3_t2t.yaml` | text2text | 1 | 4 | T2T (AR only) |
+| `hunyuan_image3_t2i_2gpu.yaml` | text2img | 2 | 2 | T2I for 2-GPU setups |
+| `hunyuan_image3_moe.yaml` | text2img | 2 | 8 | T2I with MoE AR→DiT KV reuse |
+| `hunyuan_image3_moe_dit_2gpu_fp8.yaml` | text2img | 2 | 2 | T2I with FP8 quantization |
+
+------
+
+## Using MoE Config
+
+The `hunyuan_image3_moe.yaml` config enables AR→DiT KV cache reuse with 8 GPUs (4 for AR + 4 for DiT).
+
+```bash
+python end2end.py --model tencent/HunyuanImage-3.0-Instruct \
+ --modality text2img \
+ --stage-configs-path hunyuan_image3_moe.yaml \
+ --prompts "A cute cat"
+```
+
+------
+
+## Prompt Format
+
+HunyuanImage-3.0 uses a pretrain template format:
+
+```
+<|startoftext|>{system_prompt}{ }{trigger_tag}{user_prompt}
+```
+
+- ` `: Placeholder for each input image (auto-inserted by `prompt_utils.py`)
+- Trigger tags: `` (CoT), `` (recaptioning)
+- System prompt: Auto-selected based on task
+
+The `prompt_utils.build_prompt()` handles this formatting automatically.
+
+------
+
+## FAQ
+
+- **OOM errors**: Decrease `gpu_memory_utilization` in the YAML stage config, or use a smaller `max_num_batched_tokens`.
+- **Custom image sizes**: Use `--height` and `--width` flags (multiples of 16 recommended).
+
+| Stage | VRAM (approx) |
+| :---------------- | :------------------- |
+| Stage 0 (AR) | ~15 GiB + KV Cache |
+| Stage 1 (DiT) | ~30 GiB |
+| Total (8-GPU) | ~45 GiB + KV Cache |
diff --git a/examples/offline_inference/hunyuan_image3/end2end.py b/examples/offline_inference/hunyuan_image3/end2end.py
new file mode 100644
index 000000000..3c1ae3866
--- /dev/null
+++ b/examples/offline_inference/hunyuan_image3/end2end.py
@@ -0,0 +1,262 @@
+"""
+HunyuanImage-3.0-Instruct unified end-to-end inference script.
+
+Supports all modalities through a single entry point:
+ - text2img: Text → AR → DiT → Image
+ - img2img: Text+Image → AR → DiT → Edited Image (IT2I)
+ - img2text: Image+Text → AR → Text description (I2T)
+ - text2text: Text → AR → Text (comprehension, no image)
+
+Usage:
+ python end2end.py --modality text2img --prompts "A cute cat"
+ python end2end.py --modality img2img --image-path input.png --prompts "Make it snowy"
+ python end2end.py --modality img2text --image-path input.png --prompts "Describe this image"
+"""
+
+import argparse
+import os
+
+from vllm_omni.diffusion.models.hunyuan_image3.system_prompt import (
+ get_system_prompt,
+)
+from vllm_omni.entrypoints.omni import Omni
+from vllm_omni.inputs.data import OmniPromptType
+
+# task → (sys_type, bot_task, trigger_tag)
+_TASK_PRESETS: dict[str, tuple[str, str | None, str | None]] = {
+ "t2t": ("en_unified", None, None),
+ "i2t": ("en_unified", None, None),
+ "it2i_think": ("en_unified", "think", ""),
+ "it2i_recaption": ("en_unified", "recaption", ""),
+ "t2i_think": ("en_unified", "think", ""),
+ "t2i_recaption": ("en_unified", "recaption", ""),
+ "t2i_vanilla": ("en_vanilla", "image", None),
+}
+
+# Modality → prompt_utils task mapping
+_MODALITY_TASK_MAP = {
+ "text2img": "t2i_think",
+ "img2img": "it2i_think",
+ "img2text": "i2t",
+ "text2text": "t2t",
+}
+
+
+def build_prompt(
+ user_prompt: str,
+ task: str = "it2i_think",
+ sys_type: str | None = None,
+ custom_system_prompt: str | None = None,
+) -> str:
+ """Build a HunyuanImage-3.0 prompt using pretrain template format."""
+ if task not in _TASK_PRESETS:
+ raise ValueError(f"Unknown task {task!r}. Choose from: {sorted(_TASK_PRESETS)}")
+
+ preset_sys_type, preset_bot_task, trigger_tag = _TASK_PRESETS[task]
+ effective_sys_type = sys_type or preset_sys_type
+
+ system_prompt = get_system_prompt(effective_sys_type, preset_bot_task, custom_system_prompt)
+ sys_text = system_prompt.strip() if system_prompt else ""
+
+ has_image_input = task.startswith("i2t") or task.startswith("it2i")
+
+ parts = ["<|startoftext|>"]
+ if sys_text:
+ parts.append(sys_text)
+ if has_image_input:
+ parts.append(" ")
+ if trigger_tag:
+ parts.append(trigger_tag)
+ parts.append(user_prompt)
+
+ return "".join(parts)
+
+
+# Modality → default stage config
+_MODALITY_DEFAULT_CONFIG = {
+ "text2img": "hunyuan_image3_t2i.yaml",
+ "img2img": "hunyuan_image3_it2i.yaml",
+ "img2text": "hunyuan_image3_i2t.yaml",
+ "text2text": "hunyuan_image3_t2t.yaml",
+}
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(description="HunyuanImage-3.0-Instruct end-to-end inference.")
+ parser.add_argument(
+ "--model",
+ default="tencent/HunyuanImage-3.0-Instruct",
+ help="Model name or local path.",
+ )
+ parser.add_argument(
+ "--modality",
+ default="text2img",
+ choices=["text2img", "img2img", "img2text", "text2text"],
+ help="Modality mode to control stage execution.",
+ )
+ parser.add_argument("--prompts", nargs="+", default=None, help="Input text prompts.")
+ parser.add_argument(
+ "--image-path",
+ type=str,
+ default=None,
+ help="Path to input image (for img2img/img2text).",
+ )
+ parser.add_argument(
+ "--output",
+ type=str,
+ default=".",
+ help="Output directory to save results.",
+ )
+
+ # Generation parameters
+ 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.")
+
+ # Prompt configuration
+ parser.add_argument(
+ "--bot-task",
+ type=str,
+ default=None,
+ help="Override prompt task (e.g. it2i_think, t2i_recaption). Default: auto from modality.",
+ )
+ parser.add_argument(
+ "--sys-type",
+ type=str,
+ default=None,
+ help="Override system prompt type (e.g. en_unified, en_vanilla).",
+ )
+
+ # Omni init args
+ parser.add_argument("--stage-configs-path", type=str, default=None, help="Custom stage config YAML path.")
+ parser.add_argument("--log-stats", action="store_true", default=False)
+ parser.add_argument("--init-timeout", type=int, default=300, help="Initialization timeout in seconds.")
+ parser.add_argument("--enforce-eager", action="store_true", help="Disable torch.compile.")
+
+ return parser.parse_args()
+
+
+def main():
+ args = parse_args()
+ os.makedirs(args.output, exist_ok=True)
+
+ # Determine task for prompt formatting
+ task = args.bot_task or _MODALITY_TASK_MAP[args.modality]
+
+ # Determine stage config
+ stage_configs_path = args.stage_configs_path or _MODALITY_DEFAULT_CONFIG[args.modality]
+
+ # Build Omni
+ omni_kwargs = {
+ "model": args.model,
+ "stage_configs_path": stage_configs_path,
+ "log_stats": args.log_stats,
+ "init_timeout": args.init_timeout,
+ "enforce_eager": args.enforce_eager,
+ }
+ if args.modality in ("text2img", "img2img"):
+ omni_kwargs["mode"] = "text-to-image"
+
+ omni = Omni(**omni_kwargs)
+
+ # Prepare prompts
+ prompts = args.prompts or ["A cute cat"]
+ if not prompts:
+ print("[Info] No prompts provided, using default.")
+ prompts = ["A cute cat"]
+
+ # Load image if needed
+ input_image = None
+ if args.modality in ("img2img", "img2text"):
+ if not args.image_path or not os.path.exists(args.image_path):
+ raise ValueError(f"--image-path required for {args.modality}, got: {args.image_path}")
+ from PIL import Image
+
+ input_image = Image.open(args.image_path).convert("RGB")
+
+ # Format prompts
+ formatted_prompts: list[OmniPromptType] = []
+ for p in prompts:
+ formatted_text = build_prompt(p, task=task, sys_type=args.sys_type)
+
+ prompt_dict: dict = {"prompt": formatted_text}
+
+ if args.modality == "text2img":
+ prompt_dict["modalities"] = ["image"]
+ elif args.modality == "img2img":
+ prompt_dict["modalities"] = ["image"]
+ prompt_dict["multi_modal_data"] = {"image": input_image}
+ prompt_dict["height"] = input_image.height
+ prompt_dict["width"] = input_image.width
+ elif args.modality == "img2text":
+ prompt_dict["modalities"] = ["text"]
+ prompt_dict["multi_modal_data"] = {"image": input_image}
+ elif args.modality == "text2text":
+ prompt_dict["modalities"] = ["text"]
+
+ formatted_prompts.append(prompt_dict)
+
+ # Build sampling params from defaults
+ params_list = list(omni.default_sampling_params_list)
+
+ # Override diffusion params if applicable
+ from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+
+ for i, sp in enumerate(params_list):
+ if isinstance(sp, OmniDiffusionSamplingParams):
+ sp.num_inference_steps = args.steps
+ sp.guidance_scale = args.guidance_scale
+ if args.seed is not None:
+ sp.seed = args.seed
+ if args.modality in ("text2img",):
+ sp.height = args.height
+ sp.width = args.width
+
+ # Print configuration
+ print(f"\n{'=' * 60}")
+ print("HunyuanImage-3.0 Generation Configuration:")
+ print(f" Model: {args.model}")
+ print(f" Modality: {args.modality}")
+ print(f" Stage config: {stage_configs_path}")
+ print(f" Num stages: {omni.num_stages}")
+ if args.modality in ("text2img", "img2img"):
+ print(f" Inference steps: {args.steps}")
+ print(f" Guidance scale: {args.guidance_scale}")
+ print(f" Seed: {args.seed}")
+ if args.modality == "text2img":
+ print(f" Output size: {args.width}x{args.height}")
+ if args.image_path:
+ print(f" Input image: {args.image_path}")
+ print(f" Prompts: {prompts}")
+ print(f"{'=' * 60}\n")
+
+ # Generate
+ omni_outputs = list(omni.generate(prompts=formatted_prompts, sampling_params_list=params_list))
+
+ # Process outputs
+ img_idx = 0
+ for req_output in omni_outputs:
+ # Text output (AR stage or text-only)
+ ro = getattr(req_output, "request_output", None)
+ if ro and getattr(ro, "outputs", None):
+ txt = "".join(getattr(o, "text", "") or "" for o in ro.outputs)
+ if txt:
+ print(f"[Output] Text:\n{txt}")
+
+ # Image output (DiT stage)
+ images = getattr(req_output, "images", None)
+ if not images and ro and hasattr(ro, "images"):
+ images = ro.images
+
+ if images:
+ for j, img in enumerate(images):
+ save_path = os.path.join(args.output, f"output_{img_idx}_{j}.png")
+ img.save(save_path)
+ print(f"[Output] Saved image to {save_path}")
+ img_idx += 1
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/offline_inference/hunyuan_image3/image_to_text.py b/examples/offline_inference/hunyuan_image3/image_to_text.py
deleted file mode 100644
index d40134ac0..000000000
--- a/examples/offline_inference/hunyuan_image3/image_to_text.py
+++ /dev/null
@@ -1,92 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
-
-import argparse
-import os
-
-from PIL import Image
-
-from vllm_omni.entrypoints.omni import Omni
-
-"""
-The tencent/HunyuanImage-3.0-Instruct base model uses the tencent/Hunyuan-A13B-Instruct backbone. It utilizes two tokenizer delimiter templates:
-
-1) Pretrained template (default for gen_text mode), which concatenates system, image
- tokens, and user question WITHOUT role delimiters:
-"<|startoftext|>{system_prompt}{image_tokens}{user_question}"
-
- Example (before image token expansion):
-"<|startoftext|>You are an assistant that understands images and outputs text. Describe the content of the picture."
-
-2) Instruct template, which uses explicit role prefixes and separators.
-"""
-
-
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="Generate text from image using HunyuanImage-3.0-Instruct.")
- parser.add_argument(
- "--model",
- default="tencent/HunyuanImage-3.0-Instruct",
- help="Model name or local path.",
- )
- parser.add_argument(
- "--image",
- type=str,
- required=True,
- help="Path to input image file (PNG, JPG, etc.).",
- )
- parser.add_argument(
- "--prompt",
- type=str,
- required=True,
- help="Pretrain template prompt: <|startoftext|>{system} {question}",
- )
- parser.add_argument(
- "--enable-diffusion-pipeline-profiler",
- action="store_true",
- help="Enable diffusion pipeline profiler to display stage durations.",
- )
- return parser.parse_args()
-
-
-def load_image(image_path: str) -> Image.Image:
- """Load an image from file path."""
- if not os.path.exists(image_path):
- raise FileNotFoundError(f"Image file not found: {image_path}")
- return Image.open(image_path).convert("RGB")
-
-
-def main(args: argparse.Namespace) -> None:
- omni = Omni(
- model=args.model,
- enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler,
- mode="image-to-text",
- )
-
- prompt = "<|startoftext|>You are an assistant that understands images and outputs text. " + args.prompt
-
- prompt_dict = {
- "prompt": prompt,
- "modalities": ["text"],
- }
-
- # Add image input if provided
- if args.image:
- if not os.path.exists(args.image):
- raise FileNotFoundError(f"Input image not found: {args.image}")
-
- input_image = load_image(args.image)
- prompt_dict["multi_modal_data"] = {"image": input_image}
-
- prompts = [prompt_dict]
- omni_outputs = omni.generate(prompts=prompts)
-
- prompt_text = omni_outputs[0].request_output.prompt
- generated_text = omni_outputs[0].request_output.outputs[0].text
- print(f"Prompt: {prompt_text}")
- print(f"Text: {generated_text}")
-
-
-if __name__ == "__main__":
- args = parse_args()
- main(args)
diff --git a/examples/offline_inference/mimo_audio/README.md b/examples/offline_inference/mimo_audio/README.md
index 596afabee..5615dea51 100644
--- a/examples/offline_inference/mimo_audio/README.md
+++ b/examples/offline_inference/mimo_audio/README.md
@@ -39,7 +39,6 @@ Run a single sample for basic TTS:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type tts_sft
```
@@ -48,7 +47,6 @@ Run batch samples for basic TTS:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type tts_sft \
--num-prompts {batch_size}
@@ -66,7 +64,6 @@ Generate speech from text input:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type tts_sft \
--text "The weather is so nice today."
@@ -78,7 +75,6 @@ Generate speech with explicit voice style instructions:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type tts_sft_with_instruct \
--text "The weather is so nice today." \
@@ -91,7 +87,6 @@ Generate speech using an audio reference for voice cloning:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type tts_sft_with_audio \
--text "The weather is so nice today." \
@@ -104,7 +99,6 @@ Generate speech from text containing natural voice descriptions:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type tts_sft_with_natural_instruction \
--text "In a panting young male voice, he said: I can't run anymore, wait for me!"
@@ -116,7 +110,6 @@ Transcribe audio to text:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type audio_trancribing_sft \
--audio-path "./spoken_dialogue_assistant_turn_1.wav"
@@ -128,7 +121,6 @@ Understand and analyze audio content with text queries:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type audio_understanding_sft \
--text "Summarize the audio." \
@@ -141,7 +133,6 @@ Audio understanding with reasoning chain:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type audio_understanding_sft_with_thinking \
--text "Summarize the audio." \
@@ -154,7 +145,6 @@ Multi-turn dialogue with audio input and output:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type spoken_dialogue_sft_multiturn \
--audio-path "./prompt_speech_zh_m.wav"
@@ -168,7 +158,6 @@ Multi-turn dialogue converting speech to text:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type speech2text_dialogue_sft_multiturn
```
@@ -181,7 +170,6 @@ Multi-turn text-only dialogue:
```bash
python3 -u end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
--model-name XiaomiMiMo/MiMo-Audio-7B-Instruct \
--query-type text_dialogue_sft_multiturn
```
diff --git a/examples/offline_inference/mimo_audio/end2end.py b/examples/offline_inference/mimo_audio/end2end.py
index ae044d2e8..2c67d66fa 100644
--- a/examples/offline_inference/mimo_audio/end2end.py
+++ b/examples/offline_inference/mimo_audio/end2end.py
@@ -182,7 +182,7 @@ def main(args):
omni = Omni(
model=model_name,
- stage_configs_path=args.stage_configs_path,
+ deploy_config=args.deploy_config,
log_stats=args.enable_stats,
log_file=("omni_pipeline.log" if args.enable_stats else None),
init_sleep_seconds=args.init_sleep_seconds,
@@ -428,10 +428,11 @@ def parse_args():
help="Sampling rate for audio.",
)
parser.add_argument(
- "--stage-configs-path",
+ "--deploy-config",
type=str,
- default="../../../model_executor/stage_configs/mimo_audio.yaml",
- help="Path to a stage configs file.",
+ default=None,
+ help="Override the deploy config path. If unset, auto-loads "
+ "vllm_omni/deploy/mimo_audio.yaml based on the HF model_type.",
)
return parser.parse_args()
diff --git a/examples/offline_inference/ming_flash_omni/README.md b/examples/offline_inference/ming_flash_omni/README.md
new file mode 100644
index 000000000..7414163fc
--- /dev/null
+++ b/examples/offline_inference/ming_flash_omni/README.md
@@ -0,0 +1,76 @@
+# Ming-flash-omni 2.0
+
+[Ming-flash-omni-2.0](https://github.com/inclusionAI/Ming) is an omni-modal model supporting text, image, video, and audio understanding, with outputs in text, image, and audio. For now, Ming-flash-omni-2.0 in vLLM-Omni is supported with thinker stage (multi-modal understanding).
+
+## Setup
+
+Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup.
+
+## Run examples
+
+### Text-only
+```bash
+python examples/offline_inference/ming_flash_omni/end2end.py --query-type text
+```
+
+#### Reasoning (Thinking Mode)
+
+Reasoning (Thinking) mode is enabled via applying "detailed thinking on" when building the system prompt template (in `apply_chat_template`).
+
+In the end2end example, a default problem for thinking mode is provided, as referred to the example usage of Ming's cookbook;
+To utilize it, you have to download the example figure from https://github.com/inclusionAI/Ming/blob/3954fcb880ff5e61ff128bcf7f1ec344d46a6fe3/figures/cases/3_0.png
+
+```bash
+python examples/offline_inference/ming_flash_omni/end2end.py -q reasoning --image-path ./3_0.png
+```
+
+### Image understanding
+```bash
+python examples/offline_inference/ming_flash_omni/end2end.py --query-type use_image
+
+# With a local image
+python examples/offline_inference/ming_flash_omni/end2end.py --query-type use_image --image-path /path/to/image.jpg
+```
+
+### Audio understanding
+```bash
+python examples/offline_inference/ming_flash_omni/end2end.py --query-type use_audio
+
+# With a local audio file
+python examples/offline_inference/ming_flash_omni/end2end.py --query-type use_audio --audio-path /path/to/audio.wav
+```
+
+### Video understanding
+```bash
+python examples/offline_inference/ming_flash_omni/end2end.py --query-type use_video
+
+# With a local video and custom frame count
+python examples/offline_inference/ming_flash_omni/end2end.py --query-type use_video --video-path /path/to/video.mp4 --num-frames 16
+```
+
+### Mixed modalities (image + audio)
+```bash
+python examples/offline_inference/ming_flash_omni/end2end.py --query-type use_mixed_modalities \
+ --image-path /path/to/image.jpg \
+ --audio-path /path/to/audio.wav
+```
+
+If media file paths are not provided, the script uses built-in default assets.
+
+### Modality control
+To control output modalities (e.g. text-only output):
+```bash
+python examples/offline_inference/ming_flash_omni/end2end.py --query-type use_audio --modalities text
+```
+
+*For now, only text output is supported*
+
+### Custom stage config
+```bash
+python examples/offline_inference/ming_flash_omni/end2end.py --query-type use_image \
+ --stage-configs-path /path/to/your_config.yaml
+```
+
+## Online serving
+
+For online serving via the OpenAI-compatible API, see [examples/online_serving/ming_flash_omni/README.md](../../online_serving/ming_flash_omni/README.md).
diff --git a/examples/offline_inference/ming_flash_omni/end2end.py b/examples/offline_inference/ming_flash_omni/end2end.py
new file mode 100644
index 000000000..8f8730131
--- /dev/null
+++ b/examples/offline_inference/ming_flash_omni/end2end.py
@@ -0,0 +1,485 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+# Partial example cases are referred from
+# https://github.com/inclusionAI/Ming/blob/3954fcb880ff5e61ff128bcf7f1ec344d46a6fe3/cookbook.ipynb
+import os
+import time
+from typing import NamedTuple
+
+import numpy as np
+import vllm
+from PIL import Image
+from transformers import AutoProcessor
+from vllm import SamplingParams
+from vllm.assets.audio import AudioAsset
+from vllm.assets.image import ImageAsset
+from vllm.assets.video import VideoAsset, video_to_ndarrays
+from vllm.multimodal.image import convert_image_mode
+from vllm.multimodal.media.audio import load_audio
+from vllm.utils.argparse_utils import FlexibleArgumentParser
+
+import vllm_omni
+from vllm_omni.entrypoints.omni import Omni
+
+# Imports the processor also registers itself
+from vllm_omni.transformers_utils.processors.ming import MingFlashOmniProcessor # noqa: F401
+
+SEED = 42
+MODEL_NAME = "Jonathan1909/Ming-flash-omni-2.0"
+
+
+class QueryResult(NamedTuple):
+ inputs: dict
+ limit_mm_per_prompt: dict[str, int]
+
+
+def get_text_query(processor: MingFlashOmniProcessor, question: str | None = None) -> QueryResult:
+ if question is None:
+ question = "请详细介绍鹦鹉的生活习性。"
+ conversation = [{"role": "HUMAN", "content": question}]
+ prompt = processor.apply_chat_template(conversation, tokenize=False)
+ return QueryResult(
+ inputs={"prompt": prompt},
+ limit_mm_per_prompt={},
+ )
+
+
+def get_image_query(
+ processor: MingFlashOmniProcessor,
+ question: str | None = None,
+ image_path: str | None = None,
+) -> QueryResult:
+ if question is None:
+ question = "Describe this image in detail."
+
+ if image_path:
+ if not os.path.exists(image_path):
+ raise FileNotFoundError(f"Image file not found: {image_path}")
+ image_data = convert_image_mode(Image.open(image_path), "RGB")
+ else:
+ image_data = convert_image_mode(ImageAsset("cherry_blossom").pil_image, "RGB")
+
+ conversation = [
+ {
+ "role": "HUMAN",
+ "content": [
+ {"type": "image", "image": image_data},
+ {"type": "text", "text": question},
+ ],
+ }
+ ]
+ prompt = processor.apply_chat_template(conversation, tokenize=False)
+
+ return QueryResult(
+ inputs={
+ "prompt": prompt,
+ "multi_modal_data": {"image": image_data},
+ },
+ limit_mm_per_prompt={"image": 1},
+ )
+
+
+def get_audio_query(
+ processor: MingFlashOmniProcessor,
+ question: str | None = None,
+ audio_path: str | None = None,
+ sampling_rate: int = 16000,
+) -> QueryResult:
+ if question is None:
+ question = "Please recognize the language of this speech and transcribe it. Format: oral."
+
+ if audio_path:
+ if not os.path.exists(audio_path):
+ raise FileNotFoundError(f"Audio file not found: {audio_path}")
+ audio_signal, sr = load_audio(audio_path, sr=sampling_rate)
+ audio_data = (audio_signal.astype(np.float32), sr)
+ else:
+ audio_data = AudioAsset("mary_had_lamb").audio_and_sample_rate
+
+ # Use a string for "audio" so the processor counts it as 1 audio input
+ conversation = [
+ {
+ "role": "HUMAN",
+ "content": [
+ {"type": "audio", "audio": "input"},
+ {"type": "text", "text": question},
+ ],
+ }
+ ]
+ prompt = processor.apply_chat_template(conversation, tokenize=False)
+
+ return QueryResult(
+ inputs={
+ "prompt": prompt,
+ "multi_modal_data": {"audio": audio_data},
+ },
+ limit_mm_per_prompt={"audio": 1},
+ )
+
+
+def get_video_query(
+ processor: MingFlashOmniProcessor,
+ question: str | None = None,
+ video_path: str | None = None,
+ num_frames: int = 16,
+) -> QueryResult:
+ if question is None:
+ question = "Describe what is happening in this video."
+
+ if video_path:
+ if not os.path.exists(video_path):
+ raise FileNotFoundError(f"Video file not found: {video_path}")
+ video_frames = video_to_ndarrays(video_path, num_frames=num_frames)
+ else:
+ video_frames = VideoAsset(name="baby_reading", num_frames=num_frames).np_ndarrays
+
+ conversation = [
+ {
+ "role": "HUMAN",
+ "content": [
+ {"type": "video"},
+ {"type": "text", "text": question},
+ ],
+ }
+ ]
+ prompt = processor.apply_chat_template(conversation, tokenize=False)
+
+ return QueryResult(
+ inputs={
+ "prompt": prompt,
+ "multi_modal_data": {"video": video_frames},
+ },
+ limit_mm_per_prompt={"video": 1},
+ )
+
+
+def get_mixed_modalities_query(
+ processor: MingFlashOmniProcessor,
+ image_path: str | None = None,
+ audio_path: str | None = None,
+ sampling_rate: int = 16000,
+) -> QueryResult:
+ """Mixed image + audio understanding."""
+ question = "Describe the image, and recognize the language of this speech and transcribe it. Format: oral"
+
+ if image_path:
+ if not os.path.exists(image_path):
+ raise FileNotFoundError(f"Image file not found: {image_path}")
+ image_data = convert_image_mode(Image.open(image_path), "RGB")
+ else:
+ image_data = convert_image_mode(ImageAsset("cherry_blossom").pil_image, "RGB")
+
+ if audio_path:
+ if not os.path.exists(audio_path):
+ raise FileNotFoundError(f"Audio file not found: {audio_path}")
+ sig, sr = load_audio(audio_path, sr=sampling_rate)
+ audio_data = (sig.astype(np.float32), sr)
+ else:
+ audio_data = AudioAsset("mary_had_lamb").audio_and_sample_rate
+
+ conversation = [
+ {
+ "role": "HUMAN",
+ "content": [
+ {"type": "image", "image": image_data},
+ {"type": "audio", "audio": "input"},
+ {"type": "text", "text": question},
+ ],
+ }
+ ]
+ prompt = processor.apply_chat_template(conversation, tokenize=False)
+
+ return QueryResult(
+ inputs={
+ "prompt": prompt,
+ "multi_modal_data": {"image": image_data, "audio": audio_data},
+ },
+ limit_mm_per_prompt={"image": 1, "audio": 1},
+ )
+
+
+def get_reasoning_query(
+ processor: MingFlashOmniProcessor,
+ question: str | None = None,
+ image_path: str | None = None,
+) -> QueryResult:
+ if question is None:
+ # NOTE: To use the following default question, input with example figure provided by Ming
+ # https://github.com/inclusionAI/Ming/blob/3954fcb880ff5e61ff128bcf7f1ec344d46a6fe3/figures/cases/3_0.png
+ # E.g.,
+ # python examples/offline_inference/ming_flash_omni/end2end.py -q reasoning --image-path ./3_0.png
+ # Otherwise, the problem solving might be false.
+ question = (
+ "Based on the following rules:\n•\tYou control the smiley face character\n"
+ "•\tYou can move up, down, left, and right, and only a single square at a time\n"
+ "•\tWalls are dark grey and cannot be moved into\n•\tThe brown square is a box\n•"
+ "\tThe box can be pushed by moving into it (i.e., if you are in the square "
+ "adjacent to the box to the left, and move onto the square with the box, "
+ "the box will move one square to the right).\n"
+ "•\tThe box cannot be pushed into walls\n"
+ "•\tThe blue door at the bottom is locked and cannot be passed through, "
+ "unless the box is placed on the blue square\n"
+ "•\tThe square beneath the blue door is the exit\n"
+ "•\tMoving from one square to another\n\n"
+ "Let's assume a coordinate system where the smiley face is "
+ "on the top left at (1,1) and the square below it is (1,2). "
+ "The smiley face performs the following moves: {down, right, right, right}, "
+ "such that the smiley face is at square (4,2) and the box is in square (5,2). "
+ "What are the next sequence of moves that must be done to move the box down to (5,3)? "
+ "Give your answer as a comma separated list."
+ )
+
+ if image_path:
+ if not os.path.exists(image_path):
+ raise FileNotFoundError(f"Image file not found: {image_path}")
+ image_data = convert_image_mode(Image.open(image_path), "RGB")
+ conversation = [
+ {
+ "role": "HUMAN",
+ "content": [
+ {"type": "image", "image": image_data},
+ {"type": "text", "text": question},
+ ],
+ }
+ ]
+ prompt = processor.apply_chat_template(conversation, tokenize=False, use_cot_system_prompt=True)
+ return QueryResult(
+ inputs={
+ "prompt": prompt,
+ "multi_modal_data": {"image": image_data},
+ },
+ limit_mm_per_prompt={"image": 1},
+ )
+
+ conversation = [{"role": "HUMAN", "content": question}]
+ prompt = processor.apply_chat_template(conversation, tokenize=False, use_cot_system_prompt=True)
+ return QueryResult(
+ inputs={"prompt": prompt},
+ limit_mm_per_prompt={},
+ )
+
+
+query_map = {
+ "text": get_text_query,
+ "use_audio": get_audio_query,
+ "use_image": get_image_query,
+ "use_video": get_video_query,
+ "use_mixed_modalities": get_mixed_modalities_query,
+ "reasoning": get_reasoning_query,
+}
+
+
+def main(args):
+ print(
+ "=" * 20,
+ "\n",
+ f"vllm version: {vllm.__version__}\n",
+ f"vllm-omni version: {vllm_omni.__version__}\n",
+ "=" * 20,
+ sep="",
+ )
+
+ processor = AutoProcessor.from_pretrained(MODEL_NAME, trust_remote_code=True)
+ assert isinstance(processor, MingFlashOmniProcessor), f"Wrong processor type being used: {type(processor)}"
+
+ query_func = query_map[args.query_type]
+ if args.query_type == "use_image":
+ query_result = query_func(processor, image_path=args.image_path)
+ elif args.query_type == "use_audio":
+ query_result = query_func(processor, audio_path=args.audio_path, sampling_rate=args.sampling_rate)
+ elif args.query_type == "use_video":
+ query_result = query_func(processor, video_path=args.video_path, num_frames=args.num_frames)
+ elif args.query_type == "use_mixed_modalities":
+ query_result = query_func(
+ processor,
+ image_path=args.image_path,
+ audio_path=args.audio_path,
+ sampling_rate=args.sampling_rate,
+ )
+ elif args.query_type == "reasoning":
+ query_result = query_func(processor, image_path=args.image_path)
+ else:
+ query_result = query_func(processor)
+
+ # Initialize Omni (with thinker-only stage config)
+ omni = Omni(
+ model=MODEL_NAME,
+ stage_configs_path=args.stage_configs_path,
+ log_stats=args.log_stats,
+ init_timeout=args.init_timeout,
+ stage_init_timeout=args.stage_init_timeout,
+ )
+
+ # Thinker sampling params
+ thinker_sampling_params = SamplingParams(
+ temperature=0.4,
+ top_p=0.9,
+ max_tokens=args.max_tokens,
+ repetition_penalty=1.05,
+ seed=SEED,
+ detokenize=True,
+ )
+ sampling_params_list = [thinker_sampling_params]
+
+ prompts = [query_result.inputs for _ in range(args.num_prompts)]
+
+ if args.modalities is not None:
+ output_modalities = args.modalities.split(",")
+ for prompt in prompts:
+ prompt["modalities"] = output_modalities
+
+ total_requests = len(prompts)
+ processed_count = 0
+ print(f"Query type: {args.query_type}")
+ print(f"Number of prompts: {total_requests}")
+
+ output_dir = args.output_dir
+ os.makedirs(output_dir, exist_ok=True)
+
+ profiler_enabled = args.enable_profiler
+ if profiler_enabled:
+ omni.start_profile(stages=args.profiler_stages)
+
+ for stage_outputs in omni.generate(prompts, sampling_params_list):
+ output = stage_outputs.request_output
+ if stage_outputs.final_output_type == "text":
+ request_id = output.request_id
+ text_output = output.outputs[0].text
+ lines = []
+ lines.append("Prompt:\n")
+ lines.append(str(output.prompt) + "\n")
+ lines.append("Text Output:\n")
+ lines.append(str(text_output).strip() + "\n")
+ print(*lines, sep="")
+
+ # Save to file
+ out_txt = os.path.join(output_dir, f"{request_id}.txt")
+ try:
+ with open(out_txt, "w", encoding="utf-8") as f:
+ f.writelines(lines)
+ print(f"Request ID: {request_id}, text saved to {out_txt}")
+ except Exception as e:
+ print(f"Failed to write output file {out_txt}: {e}")
+
+ elif stage_outputs.final_output_type == "audio":
+ raise NotImplementedError("Add audio example after talker supported.")
+
+ processed_count += 1
+ if profiler_enabled and processed_count >= total_requests:
+ print(f"[Info] Processed {processed_count}/{total_requests}. Stopping profiler inside active loop...")
+ # Stop the profiler while workers are still alive
+ omni.stop_profile(stages=args.profiler_stages)
+
+ print("[Info] Waiting 30s for workers to write trace files to disk...")
+ time.sleep(30)
+ print("[Info] Trace export wait time finished.")
+
+ omni.close()
+
+
+def parse_args():
+ parser = FlexibleArgumentParser(description="Ming-flash-omni 2.0 offline inference example")
+ parser.add_argument(
+ "--query-type",
+ "-q",
+ type=str,
+ default="text",
+ choices=query_map.keys(),
+ help="Query type.",
+ )
+ parser.add_argument(
+ "--stage-configs-path",
+ type=str,
+ default=None,
+ help="Path to a stage configs YAML file.",
+ )
+ parser.add_argument(
+ "--log-stats",
+ action="store_true",
+ default=False,
+ help="Enable detailed statistics logging.",
+ )
+ parser.add_argument("--init-timeout", type=int, default=2000, help="Timeout for initializing in seconds.")
+ parser.add_argument(
+ "--stage-init-timeout",
+ type=int,
+ default=2000,
+ help="Timeout for initializing a single stage in seconds.",
+ )
+ parser.add_argument(
+ "--enable-profiler",
+ action="store_true",
+ default=False,
+ help="Enables profiling when set.",
+ )
+ parser.add_argument(
+ "--profiler-stages",
+ type=int,
+ nargs="*",
+ default=[0],
+ help="List of stage IDs to profile. If not set, profiles all stages.",
+ )
+ parser.add_argument(
+ "--image-path",
+ "-i",
+ type=str,
+ default=None,
+ help="Path to local image file. Uses default asset if not provided.",
+ )
+ parser.add_argument(
+ "--audio-path",
+ "-a",
+ type=str,
+ default=None,
+ help="Path to local audio file. Uses default asset if not provided.",
+ )
+ parser.add_argument(
+ "--video-path",
+ "-v",
+ type=str,
+ default=None,
+ help="Path to local video file. Uses default asset if not provided.",
+ )
+ parser.add_argument(
+ "--num-frames",
+ type=int,
+ default=16,
+ help="Number of frames to extract from video.",
+ )
+ parser.add_argument(
+ "--sampling-rate",
+ type=int,
+ default=16000,
+ help="Sampling rate for audio loading.",
+ )
+ parser.add_argument(
+ "--max-tokens",
+ type=int,
+ default=16384,
+ help="Maximum tokens to generate.",
+ )
+ parser.add_argument(
+ "--num-prompts",
+ type=int,
+ default=1,
+ help="Number of prompts to generate.",
+ )
+ parser.add_argument(
+ "--modalities",
+ type=str,
+ default=None,
+ help="Output modalities (comma-separated).",
+ )
+ parser.add_argument(
+ "--output-dir",
+ type=str,
+ default="output_ming",
+ help="Output directory for results.",
+ )
+
+ return parser.parse_args()
+
+
+if __name__ == "__main__":
+ args = parse_args()
+ main(args)
diff --git a/examples/offline_inference/qwen2_5_omni/end2end.py b/examples/offline_inference/qwen2_5_omni/end2end.py
index d8f1898ec..dfe124700 100644
--- a/examples/offline_inference/qwen2_5_omni/end2end.py
+++ b/examples/offline_inference/qwen2_5_omni/end2end.py
@@ -320,14 +320,7 @@ def main(args):
query_result = query_func(audio_path=audio_path, sampling_rate=sampling_rate)
else:
query_result = query_func()
- omni = Omni(
- model=model_name,
- log_stats=args.log_stats,
- stage_init_timeout=args.stage_init_timeout,
- batch_timeout=args.batch_timeout,
- init_timeout=args.init_timeout,
- shm_threshold_bytes=args.shm_threshold_bytes,
- )
+ omni = Omni.from_cli_args(args, model=model_name)
thinker_sampling_params = SamplingParams(
temperature=0.0, # Deterministic - no randomness
top_p=1.0, # Disable nucleus sampling
diff --git a/examples/offline_inference/qwen3_omni/README.md b/examples/offline_inference/qwen3_omni/README.md
index d69ad6abf..0710faa13 100644
--- a/examples/offline_inference/qwen3_omni/README.md
+++ b/examples/offline_inference/qwen3_omni/README.md
@@ -70,8 +70,8 @@ For true stage-level concurrency -- where downstream stages (Talker, Code2Wav)
start **before** the upstream stage (Thinker) finishes -- use the async_chunk
example. This requires:
-1. A stage config YAML with ``async_chunk: true`` (e.g.
- ``qwen3_omni_moe_async_chunk.yaml``).
+1. A deploy config YAML with ``async_chunk: true`` (e.g.
+ ``qwen3_omni_moe.yaml``).
2. Hardware that matches the config (e.g. 2x H100 for the default 3-stage
config).
@@ -101,7 +101,7 @@ python end2end_async_chunk.py --query-type text --modalities text
```bash
python end2end_async_chunk.py \
--query-type use_audio \
- --stage-configs-path /path/to/your_async_chunk.yaml
+ --deploy-config /path/to/your_deploy_config.yaml
```
> **Note**: The synchronous ``end2end.py`` (using ``Omni``) is still the
diff --git a/examples/offline_inference/qwen3_omni/end2end.py b/examples/offline_inference/qwen3_omni/end2end.py
index 056f820ff..f028c32aa 100644
--- a/examples/offline_inference/qwen3_omni/end2end.py
+++ b/examples/offline_inference/qwen3_omni/end2end.py
@@ -294,14 +294,7 @@ def main(args):
else:
query_result = query_func()
- omni = Omni(
- model=model_name,
- dtype=args.dtype,
- stage_configs_path=args.stage_configs_path,
- log_stats=args.log_stats,
- stage_init_timeout=args.stage_init_timeout,
- init_timeout=args.init_timeout,
- )
+ omni = Omni.from_cli_args(args, model=model_name)
thinker_sampling_params = SamplingParams(
temperature=0.9,
diff --git a/examples/offline_inference/qwen3_omni/end2end_async_chunk.py b/examples/offline_inference/qwen3_omni/end2end_async_chunk.py
index 074426313..f38922e94 100644
--- a/examples/offline_inference/qwen3_omni/end2end_async_chunk.py
+++ b/examples/offline_inference/qwen3_omni/end2end_async_chunk.py
@@ -14,7 +14,7 @@ actively processing while stage-0 is still generating.
Usage
-----
python end2end_async_chunk.py --query-type use_audio \
- --stage-configs-path
+ --deploy-config
See ``--help`` for all options.
"""
@@ -179,20 +179,26 @@ def clone_prompt_for_request(template: dict) -> dict:
return cloned
-def _default_async_chunk_stage_configs_path() -> str | None:
- """Best-effort default stage config for running Qwen3-Omni with async_chunk.
+def _default_deploy_config_path() -> str | None:
+ """Best-effort default deploy config for running Qwen3-Omni with async_chunk.
- When this example is executed from within the repository, we resolve the
- default YAML path relative to this file. When installed elsewhere, the
- file may not exist and callers should pass --stage-configs-path explicitly.
+ The default ``vllm_omni/deploy/qwen3_omni_moe.yaml`` ships with
+ ``async_chunk: true`` at the top level, so loading it is enough to
+ enable async-chunk semantics. To disable it, copy the YAML and set
+ ``async_chunk: false`` (or pass ``--deploy-config`` to a YAML that
+ overrides the flag).
+
+ When this example is executed from within the repository, we resolve
+ the default YAML path relative to this file. When installed elsewhere,
+ the file may not exist and callers should pass ``--deploy-config``
+ explicitly.
"""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
candidate = os.path.join(
repo_root,
"vllm_omni",
- "model_executor",
- "stage_configs",
- "qwen3_omni_moe_async_chunk.yaml",
+ "deploy",
+ "qwen3_omni_moe.yaml",
)
return candidate if os.path.exists(candidate) else None
@@ -374,15 +380,16 @@ async def run_all(args):
prompt["modalities"] = output_modalities
# Create AsyncOmni
- print(f"[Info] Creating AsyncOmni with stage_configs_path={args.stage_configs_path}")
+ print(f"[Info] Creating AsyncOmni with deploy_config={args.deploy_config}")
async_omni = None
try:
- async_omni = AsyncOmni(
- model=args.model,
- stage_configs_path=args.stage_configs_path,
- log_stats=args.log_stats,
- stage_init_timeout=args.stage_init_timeout,
- )
+ # ``from_cli_args`` expands vars(args) into kwargs and auto-captures
+ # ``_cli_explicit_keys`` from ``sys.argv[1:]`` so argparse defaults
+ # do not silently override deploy YAML values. Mirrors the
+ # ``EngineArgs.from_cli_args`` pattern used throughout vllm /
+ # vllm-omni. ``deploy_config=None`` (the default) falls through to
+ # the bundled ``vllm_omni/deploy/qwen3_omni_moe.yaml``.
+ async_omni = AsyncOmni.from_cli_args(args)
# Use default sampling params from stage config (they are pre-configured
# in the YAML for each stage).
@@ -470,11 +477,11 @@ def parse_args():
help="Query type.",
)
parser.add_argument(
- "--stage-configs-path",
+ "--deploy-config",
type=str,
- default=_default_async_chunk_stage_configs_path(),
+ default=_default_deploy_config_path(),
help=(
- "Path to an async_chunk stage config YAML. "
+ "Path to a deploy config YAML. "
"If not set, uses the model's default config "
"(make sure it has async_chunk: true)."
),
diff --git a/examples/offline_inference/qwen3_omni/run_multiple_prompts_async_chunk.sh b/examples/offline_inference/qwen3_omni/run_multiple_prompts_async_chunk.sh
index 809054867..2f2be2091 100755
--- a/examples/offline_inference/qwen3_omni/run_multiple_prompts_async_chunk.sh
+++ b/examples/offline_inference/qwen3_omni/run_multiple_prompts_async_chunk.sh
@@ -17,7 +17,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
python "${SCRIPT_DIR}/end2end_async_chunk.py" \
--query-type text \
--txt-prompts "${SCRIPT_DIR}/text_prompts_10.txt" \
- --stage-configs-path "${REPO_ROOT}/vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml" \
+ --deploy-config "${REPO_ROOT}/vllm_omni/deploy/qwen3_omni_moe.yaml" \
--output-dir output_audio_async_chunk \
--max-in-flight 2 \
"$@"
diff --git a/examples/offline_inference/qwen3_omni/run_single_prompt_async_chunk.sh b/examples/offline_inference/qwen3_omni/run_single_prompt_async_chunk.sh
index 918c7ee4f..9ef69293c 100755
--- a/examples/offline_inference/qwen3_omni/run_single_prompt_async_chunk.sh
+++ b/examples/offline_inference/qwen3_omni/run_single_prompt_async_chunk.sh
@@ -6,13 +6,13 @@
# achieving true stage-level concurrency via chunk-level streaming.
#
# Prerequisites:
-# - An async_chunk stage config YAML (e.g. qwen3_omni_moe_async_chunk.yaml)
+# - A deploy config YAML (e.g. qwen3_omni_moe.yaml)
# - Hardware matching the config (e.g. 2x H100 for the default 3-stage config)
#
# Usage:
# bash run_single_prompt_async_chunk.sh
# bash run_single_prompt_async_chunk.sh --query-type text --modalities text
-# bash run_single_prompt_async_chunk.sh --stage-configs-path /path/to/custom.yaml
+# bash run_single_prompt_async_chunk.sh --deploy-config /path/to/custom.yaml
set -euo pipefail
@@ -21,6 +21,6 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
python "${SCRIPT_DIR}/end2end_async_chunk.py" \
--query-type use_audio \
- --stage-configs-path "${REPO_ROOT}/vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml" \
+ --deploy-config "${REPO_ROOT}/vllm_omni/deploy/qwen3_omni_moe.yaml" \
--output-dir output_audio_async_chunk \
"$@"
diff --git a/examples/offline_inference/qwen3_tts/README.md b/examples/offline_inference/qwen3_tts/README.md
index bf59dc9ba..2971ad716 100644
--- a/examples/offline_inference/qwen3_tts/README.md
+++ b/examples/offline_inference/qwen3_tts/README.md
@@ -15,11 +15,11 @@ Please refer to the [stage configuration documentation](https://docs.vllm.ai/pro
### ROCm Dependencies
-You will need to install these two dependencies `onnxruntime-rocm` and `sox`.
+You will need to install the dependency `onnxruntime-rocm`.
```
pip uninstall onnxruntime # should be removed before we can install onnxruntime-rocm
-pip install onnxruntime-rocm sox
+pip install onnxruntime-rocm
```
## Quick Start
@@ -104,13 +104,13 @@ completes. This demonstrates that audio data is available progressively rather t
## Batched Decoding
-The Code2Wav stage (stage 1) supports batched decoding, where multiple requests are decoded in a single forward pass through the SpeechTokenizer. To use it, provide a stage config with `max_num_seqs > 1` and pass multiple prompts via `--txt-prompts` with a matching `--batch-size`.
+The Code2Wav stage (stage 1) supports batched decoding, where multiple requests are decoded in a single forward pass through the SpeechTokenizer. To use it, set `max_num_seqs > 1` on both stages via `--stage-overrides` and pass multiple prompts via `--txt-prompts` with a matching `--batch-size`.
```
python end2end.py --query-type CustomVoice \
--txt-prompts benchmark_prompts.txt \
--batch-size 4 \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml
+ --stage-overrides '{"0":{"max_num_seqs":4,"gpu_memory_utilization":0.2},"1":{"max_num_seqs":4,"gpu_memory_utilization":0.2}}'
```
**Important:** `--batch-size` must match a CUDA graph capture size (1, 2, 4, 8, 16...) because the Talker's code predictor KV cache is sized to `max_num_seqs`, and CUDA graphs pad the batch to the next capture size. Both stages need `max_num_seqs >= batch_size` in the stage config for batching to take effect. If only stage 1 has a higher `max_num_seqs`, it won't help — stage 1 can only batch chunks from requests that are in-flight simultaneously, which requires stage 0 to also process multiple requests concurrently.
diff --git a/examples/offline_inference/qwen3_tts/end2end.py b/examples/offline_inference/qwen3_tts/end2end.py
index 901418c39..77da356b4 100644
--- a/examples/offline_inference/qwen3_tts/end2end.py
+++ b/examples/offline_inference/qwen3_tts/end2end.py
@@ -366,12 +366,7 @@ def main(args):
output_dir = args.output_dir
os.makedirs(output_dir, exist_ok=True)
- omni = Omni(
- model=model_name,
- stage_configs_path=args.stage_configs_path,
- log_stats=args.log_stats,
- stage_init_timeout=args.stage_init_timeout,
- )
+ omni = Omni.from_cli_args(args, model=model_name)
batch_size = args.batch_size
for batch_start in range(0, len(inputs), batch_size):
@@ -387,12 +382,7 @@ async def main_streaming(args):
output_dir = args.output_dir
os.makedirs(output_dir, exist_ok=True)
- omni = AsyncOmni(
- model=model_name,
- stage_configs_path=args.stage_configs_path,
- log_stats=args.log_stats,
- stage_init_timeout=args.stage_init_timeout,
- )
+ omni = AsyncOmni.from_cli_args(args, model=model_name)
for i, prompt in enumerate(inputs):
request_id = str(i)
diff --git a/examples/offline_inference/text_to_audio/README.md b/examples/offline_inference/text_to_audio/README.md
index 50bab3e2f..10a8ae37e 100644
--- a/examples/offline_inference/text_to_audio/README.md
+++ b/examples/offline_inference/text_to_audio/README.md
@@ -27,6 +27,22 @@ python text_to_audio.py \
--output stable_audio_output.wav
```
+To reduce per-GPU memory for multi-GPU inference, launch with HSDP:
+
+```bash
+python text_to_audio.py \
+ --model stabilityai/stable-audio-open-1.0 \
+ --prompt "The sound of a hammer hitting a wooden surface" \
+ --negative-prompt "Low quality" \
+ --seed 42 \
+ --guidance-scale 7.0 \
+ --audio-length 10.0 \
+ --num-inference-steps 100 \
+ --use-hsdp \
+ --hsdp-shard-size 2 \
+ --output stable_audio_output.wav
+```
+
Key arguments:
- `--prompt`: text description (string).
@@ -35,5 +51,8 @@ Key arguments:
- `--guidance-scale`: classifier-free guidance scale.
- `--audio-length`: audio duration in seconds.
- `--num-inference-steps`: diffusion sampling steps.(more steps = higher quality, slower).
+- `--use-hsdp`: enable HSDP weight sharding for the Stable Audio DiT.
+- `--hsdp-shard-size`: number of GPUs used for HSDP sharding.
+- `--hsdp-replicate-size`: number of HSDP replica groups.
- `--cache-backend`: cache acceleration backend. Stable Audio currently supports `tea_cache`.
- `--output`: path to save the generated WAV file.
diff --git a/examples/offline_inference/text_to_audio/text_to_audio.py b/examples/offline_inference/text_to_audio/text_to_audio.py
index 3adb3ad53..53f3daf8d 100644
--- a/examples/offline_inference/text_to_audio/text_to_audio.py
+++ b/examples/offline_inference/text_to_audio/text_to_audio.py
@@ -21,6 +21,7 @@ from pathlib import Path
import numpy as np
import torch
+from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
@@ -113,6 +114,23 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Enable diffusion pipeline profiler to display stage durations.",
)
+ parser.add_argument(
+ "--use-hsdp",
+ action="store_true",
+ help="Enable HSDP for Stable Audio DiT weight sharding.",
+ )
+ parser.add_argument(
+ "--hsdp-shard-size",
+ type=int,
+ default=1,
+ help="Number of GPUs to shard Stable Audio DiT weights across when HSDP is enabled.",
+ )
+ parser.add_argument(
+ "--hsdp-replicate-size",
+ type=int,
+ default=1,
+ help="Number of HSDP replica groups. Default 1 means pure sharding.",
+ )
return parser.parse_args()
@@ -158,12 +176,23 @@ def main():
print(f" Inference steps: {args.num_inference_steps}")
print(f" Guidance scale: {args.guidance_scale}")
print(f" Cache backend: {args.cache_backend if args.cache_backend else 'None (no acceleration)'}")
+ if args.use_hsdp:
+ print(f" HSDP: enabled (shard_size={args.hsdp_shard_size}, replicate_size={args.hsdp_replicate_size})")
+ else:
+ print(" HSDP: disabled")
print(f" Seed: {args.seed}")
print(f"{'=' * 60}\n")
+ parallel_config = DiffusionParallelConfig(
+ use_hsdp=args.use_hsdp,
+ hsdp_shard_size=args.hsdp_shard_size,
+ hsdp_replicate_size=args.hsdp_replicate_size,
+ )
+
# Initialize Omni with Stable Audio model
omni = Omni(
model=args.model,
+ parallel_config=parallel_config,
cache_backend=args.cache_backend,
cache_config=cache_config,
enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler,
diff --git a/examples/offline_inference/text_to_image/README.md b/examples/offline_inference/text_to_image/README.md
index cc295e827..c71773972 100644
--- a/examples/offline_inference/text_to_image/README.md
+++ b/examples/offline_inference/text_to_image/README.md
@@ -29,7 +29,8 @@ This folder provides several entrypoints for experimenting with text-to-image di
| `AIDC-AI/Ovis-Image-7B` | 1024 x 1024 | 71.8 | 17.1 |
| `OmniGen2/OmniGen2` | 1024 x 1024 | 20.1 | 14.7 |
| `stabilityai/stable-diffusion-3.5-medium` | 1024 x 1024 | 20.1 | 15.6 |
-| `black-forest-labs/FLUX.1-dev` | 1024 x 1024 | 77.6 | 31.4 |
+| `black-forest-labs/FLUX.1-dev` | 1024 x 1024 | 33.9 | 31.4 |
+| `black-forest-labs/FLUX.1-schnell` | 1024 x 1024 | 33.9 | 31.4 |
| `black-forest-labs/FLUX.2-klein-4B` | 1024 x 1024 | 72.7 | 14.9 |
| `black-forest-labs/FLUX.2-klein-9B` | 1024 x 1024 | 37.1 | 32.3 |
| `black-forest-labs/FLUX.2-dev` | 1024 x 1024 | 65.7 | >80 (CPU offload required) |
diff --git a/examples/offline_inference/voxcpm/README.md b/examples/offline_inference/voxcpm/README.md
new file mode 100644
index 000000000..1eaea9b0d
--- /dev/null
+++ b/examples/offline_inference/voxcpm/README.md
@@ -0,0 +1,123 @@
+# VoxCPM Offline Example
+
+This directory contains the minimal offline VoxCPM example for vLLM Omni.
+
+`end2end.py` is intentionally small and only covers:
+
+- single text-to-speech
+- single voice cloning with `ref_audio` + `ref_text`
+- non-streaming with `vllm_omni/model_executor/stage_configs/voxcpm.yaml`
+- streaming with `vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml`
+
+Advanced workflows were moved out of the getting-started example:
+
+- `benchmarks/voxcpm/vllm_omni/bench_tts_offline.py`: warmup, batch prompts, profiler, offline TTFP / RTF
+- `benchmarks/voxcpm/vllm_omni/run_offline_matrix.py`: fixed offline smoke matrix
+- `benchmarks/voxcpm/`: benchmark scripts and benchmark docs
+
+## Prerequisites
+
+Install VoxCPM in one of these ways:
+
+```bash
+pip install voxcpm
+```
+
+or point vLLM Omni to the local VoxCPM source tree:
+
+```bash
+export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/VoxCPM/src
+```
+
+The example writes WAV files with `soundfile`:
+
+```bash
+pip install soundfile
+```
+
+## Model Path
+
+Pass the native VoxCPM model directory directly:
+
+```bash
+export VOXCPM_MODEL=/path/to/voxcpm-model
+```
+
+If the native VoxCPM `config.json` does not contain HuggingFace metadata such as
+`model_type`, prepare a persistent HF-compatible config directory and point the
+stage configs to it with `VLLM_OMNI_VOXCPM_HF_CONFIG_PATH`:
+
+```bash
+export VLLM_OMNI_VOXCPM_HF_CONFIG_PATH=/tmp/voxcpm_hf_config
+mkdir -p "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"
+cp "$VOXCPM_MODEL/config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/config.json"
+cp "$VOXCPM_MODEL/generation_config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/generation_config.json" 2>/dev/null || true
+python3 -c 'import json, os; p=os.path.join(os.environ["VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"], "config.json"); cfg=json.load(open(p, "r", encoding="utf-8")); cfg["model_type"]="voxcpm"; cfg.setdefault("architectures", ["VoxCPMForConditionalGeneration"]); json.dump(cfg, open(p, "w", encoding="utf-8"), indent=2, ensure_ascii=False)'
+```
+
+If the model directory itself already has `model_type`, this extra directory is
+not required.
+
+## Quick Start
+
+Single text-to-speech, non-streaming:
+
+```bash
+python examples/offline_inference/voxcpm/end2end.py \
+ --model "$VOXCPM_MODEL" \
+ --text "This is a split-stage VoxCPM synthesis example running on vLLM Omni."
+```
+
+Single voice cloning, non-streaming:
+
+```bash
+python examples/offline_inference/voxcpm/end2end.py \
+ --model "$VOXCPM_MODEL" \
+ --text "This sentence is synthesized with a cloned voice." \
+ --ref-audio /path/to/reference.wav \
+ --ref-text "The exact transcript spoken in reference.wav."
+```
+
+Streaming:
+
+```bash
+python examples/offline_inference/voxcpm/end2end.py \
+ --model "$VOXCPM_MODEL" \
+ --stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \
+ --text "This is a split-stage VoxCPM streaming example running on vLLM Omni."
+```
+
+By default, `end2end.py` writes to `output_audio/` for non-streaming and
+`output_audio_streaming/` for streaming.
+
+## Advanced Workflows
+
+Use `benchmarks/voxcpm/vllm_omni/bench_tts_offline.py` when you need:
+
+- warmup runs
+- prompt files
+- batch JSONL inputs
+- profiler injection
+- offline TTFP / RTF emission
+
+Use `benchmarks/voxcpm/vllm_omni/run_offline_matrix.py` when you need the fixed offline smoke matrix that previously lived in `test.py`.
+
+Full matrix benchmark example:
+
+```bash
+python benchmarks/voxcpm/vllm_omni/run_offline_matrix.py \
+ --model "$VOXCPM_MODEL" \
+ --ref-audio /path/to/reference.wav \
+ --ref-text "The exact transcript spoken in reference.wav."
+```
+
+For online serving examples, see [examples/online_serving/voxcpm](../../online_serving/voxcpm/README.md).
+
+For benchmark reporting, see [benchmarks/voxcpm](../../../benchmarks/voxcpm/README.md).
+
+## Notes
+
+- `voxcpm.yaml` is the default non-streaming stage config.
+- `voxcpm_async_chunk.yaml` is the streaming stage config.
+- Streaming is currently single-request oriented; the fixed smoke matrix now lives in `benchmarks/voxcpm/vllm_omni/run_offline_matrix.py`.
+- `ref_text` must be the real transcript of the reference audio. Mismatched text usually causes obvious quality degradation.
diff --git a/examples/offline_inference/voxcpm/end2end.py b/examples/offline_inference/voxcpm/end2end.py
new file mode 100644
index 000000000..980410fea
--- /dev/null
+++ b/examples/offline_inference/voxcpm/end2end.py
@@ -0,0 +1,206 @@
+"""Minimal offline VoxCPM example for vLLM Omni."""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from pathlib import Path
+from typing import Any
+
+import soundfile as sf
+import torch
+from vllm.utils.argparse_utils import FlexibleArgumentParser
+
+from vllm_omni import AsyncOmni, Omni
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+DEFAULT_SYNC_STAGE_CONFIG = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm.yaml"
+
+
+def _build_prompt(args) -> dict[str, Any]:
+ additional_information: dict[str, list[Any]] = {
+ "text": [args.text],
+ "cfg_value": [args.cfg_value],
+ "inference_timesteps": [args.inference_timesteps],
+ "min_len": [args.min_len],
+ "max_new_tokens": [args.max_new_tokens],
+ }
+ if args.streaming_prefix_len is not None:
+ additional_information["streaming_prefix_len"] = [args.streaming_prefix_len]
+ if args.ref_audio is not None:
+ additional_information["ref_audio"] = [args.ref_audio]
+ if args.ref_text is not None:
+ additional_information["ref_text"] = [args.ref_text]
+ return {
+ "prompt_token_ids": [1],
+ "additional_information": additional_information,
+ }
+
+
+def _extract_audio_tensor(mm: dict[str, Any]) -> torch.Tensor:
+ audio = mm.get("audio", mm.get("model_outputs"))
+ if audio is None:
+ raise ValueError("No audio output found in multimodal output.")
+ if isinstance(audio, list):
+ parts = [torch.as_tensor(item).float().cpu().reshape(-1) for item in audio]
+ audio = torch.cat(parts, dim=-1) if parts else torch.zeros(0)
+ if not isinstance(audio, torch.Tensor):
+ audio = torch.as_tensor(audio)
+ return audio.float().cpu().reshape(-1)
+
+
+def _extract_sample_rate(mm: dict[str, Any]) -> int:
+ sr_raw = mm.get("sr", 24000)
+ if isinstance(sr_raw, list) and sr_raw:
+ sr_raw = sr_raw[-1]
+ if hasattr(sr_raw, "item"):
+ return int(sr_raw.item())
+ return int(sr_raw)
+
+
+def _is_streaming_stage_config(stage_config_path: str) -> bool:
+ return "async_chunk" in Path(stage_config_path).stem
+
+
+def _save_audio(audio: torch.Tensor, sample_rate: int, output_dir: Path, request_id: str) -> Path:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ output_path = output_dir / f"output_{request_id}.wav"
+ sf.write(
+ output_path,
+ audio.float().cpu().clamp(-1.0, 1.0).numpy(),
+ sample_rate,
+ format="WAV",
+ subtype="PCM_16",
+ )
+ return output_path
+
+
+async def _run_streaming(args) -> Path:
+ prompt = _build_prompt(args)
+ output_dir = Path(args.output_dir) if args.output_dir is not None else Path("output_audio_streaming")
+ request_id = "streaming_example"
+ sample_rate = 24000
+ buffered_samples = 0
+ chunks: list[torch.Tensor] = []
+ started = time.perf_counter()
+ omni = AsyncOmni(
+ model=args.model,
+ stage_configs_path=args.stage_configs_path,
+ log_stats=args.log_stats,
+ stage_init_timeout=args.stage_init_timeout,
+ )
+ try:
+ async for stage_output in omni.generate(prompt, request_id=request_id):
+ mm = getattr(stage_output, "multimodal_output", None)
+ if not isinstance(mm, dict):
+ request_output = getattr(stage_output, "request_output", None)
+ if request_output is None:
+ continue
+ mm = getattr(request_output, "multimodal_output", None)
+ if not isinstance(mm, dict) and getattr(request_output, "outputs", None):
+ mm = getattr(request_output.outputs[0], "multimodal_output", None)
+ if not isinstance(mm, dict):
+ continue
+ audio = _extract_audio_tensor(mm)
+ if audio.numel() == 0:
+ continue
+ sample_rate = _extract_sample_rate(mm)
+ if audio.numel() > buffered_samples:
+ delta = audio[buffered_samples:]
+ buffered_samples = int(audio.numel())
+ else:
+ delta = audio
+ buffered_samples += int(delta.numel())
+ if delta.numel() > 0:
+ chunks.append(delta)
+ if not chunks:
+ raise RuntimeError("No streaming audio chunks received from VoxCPM.")
+ output_audio = torch.cat(chunks, dim=0)
+ output_path = _save_audio(output_audio, sample_rate, output_dir, request_id)
+ print(f"Saved streaming audio to: {output_path} ({time.perf_counter() - started:.2f}s)")
+ return output_path
+ finally:
+ omni.shutdown()
+
+
+def _run_sync(args) -> Path:
+ prompt = _build_prompt(args)
+ output_dir = Path(args.output_dir) if args.output_dir is not None else Path("output_audio")
+ request_id = "sync_example"
+ started = time.perf_counter()
+ last_mm: dict[str, Any] | None = None
+ omni = Omni(
+ model=args.model,
+ stage_configs_path=args.stage_configs_path,
+ log_stats=args.log_stats,
+ stage_init_timeout=args.stage_init_timeout,
+ )
+ for stage_outputs in omni.generate(prompt):
+ request_output = getattr(stage_outputs, "request_output", None)
+ if request_output is None:
+ continue
+ outputs = getattr(request_output, "outputs", None)
+ if outputs:
+ for output in outputs:
+ mm = getattr(output, "multimodal_output", None)
+ if isinstance(mm, dict):
+ last_mm = mm
+ mm = getattr(request_output, "multimodal_output", None)
+ if isinstance(mm, dict):
+ last_mm = mm
+ if last_mm is None:
+ raise RuntimeError("No audio output received from VoxCPM.")
+ output_path = _save_audio(
+ _extract_audio_tensor(last_mm),
+ _extract_sample_rate(last_mm),
+ output_dir,
+ request_id,
+ )
+ print(f"Saved audio to: {output_path} ({time.perf_counter() - started:.2f}s)")
+ return output_path
+
+
+def parse_args():
+ parser = FlexibleArgumentParser(description="Minimal offline VoxCPM example for vLLM Omni.")
+ parser.add_argument("--model", type=str, required=True, help="Local VoxCPM model directory.")
+ parser.add_argument(
+ "--stage-configs-path",
+ type=str,
+ default=str(DEFAULT_SYNC_STAGE_CONFIG),
+ help=("Stage config path. Use voxcpm.yaml for non-streaming or voxcpm_async_chunk.yaml for streaming."),
+ )
+ parser.add_argument("--text", type=str, required=True, help="Input text for synthesis.")
+ parser.add_argument("--ref-audio", type=str, default=None, help="Reference audio path for voice cloning.")
+ parser.add_argument("--ref-text", type=str, default=None, help="Transcript of the reference audio.")
+ parser.add_argument("--output-dir", type=str, default=None, help="Output directory for generated wav files.")
+ parser.add_argument("--cfg-value", type=float, default=2.0, help="Guidance value passed to VoxCPM.")
+ parser.add_argument("--inference-timesteps", type=int, default=10, help="Number of diffusion timesteps.")
+ parser.add_argument("--min-len", type=int, default=2, help="Minimum latent length.")
+ parser.add_argument("--max-new-tokens", type=int, default=4096, help="Maximum latent length.")
+ parser.add_argument(
+ "--streaming-prefix-len",
+ type=int,
+ default=3,
+ help="Streaming prefix length used by voxcpm_async_chunk.yaml.",
+ )
+ parser.add_argument("--stage-init-timeout", type=int, default=600, help="Stage initialization timeout in seconds.")
+ parser.add_argument("--log-stats", action="store_true", help="Enable vLLM Omni stats logging.")
+ args = parser.parse_args()
+ if (args.ref_audio is None) != (args.ref_text is None):
+ raise ValueError("Voice cloning requires --ref-audio and --ref-text together.")
+ return args
+
+
+def main(args) -> None:
+ route = "streaming" if _is_streaming_stage_config(args.stage_configs_path) else "sync"
+ print(f"Model: {args.model}")
+ print(f"Stage config: {args.stage_configs_path}")
+ print(f"Route: {route}")
+ if route == "streaming":
+ asyncio.run(_run_streaming(args))
+ else:
+ _run_sync(args)
+
+
+if __name__ == "__main__":
+ main(parse_args())
diff --git a/examples/offline_inference/voxcpm2/end2end.py b/examples/offline_inference/voxcpm2/end2end.py
index ce404bf96..6b6bf78dd 100644
--- a/examples/offline_inference/voxcpm2/end2end.py
+++ b/examples/offline_inference/voxcpm2/end2end.py
@@ -65,6 +65,12 @@ def parse_args():
default=None,
help="Text matching --prompt-audio for continuation mode.",
)
+ parser.add_argument(
+ "--ref-text",
+ type=str,
+ default=None,
+ help="Optional transcript of --reference-audio (enables ref_continuation mode).",
+ )
return parser.parse_args()
@@ -74,16 +80,20 @@ def extract_audio(multimodal_output: dict) -> torch.Tensor:
The output processor concatenates per-step delta tensors under
``model_outputs``. Falls back to ``audio`` for backwards compat.
"""
- audio = multimodal_output.get("model_outputs") or multimodal_output.get("audio")
+ audio = multimodal_output.get("model_outputs")
+ if audio is None:
+ audio = multimodal_output.get("audio")
if audio is None:
raise ValueError(f"No audio key in multimodal_output: {list(multimodal_output.keys())}")
if isinstance(audio, list):
- # Take the last valid tensor (most complete audio)
+ # Defensive: usually the output processor consolidates into a single
+ # tensor at request completion, but concatenate here too in case the
+ # caller consumes intermediate (pre-consolidation) outputs.
valid = [torch.as_tensor(a).float().cpu().reshape(-1) for a in audio if a is not None]
if not valid:
raise ValueError("Audio list is empty or all elements are None.")
- return valid[-1]
+ return torch.cat(valid, dim=0) if len(valid) > 1 else valid[0]
return torch.as_tensor(audio).float().cpu().reshape(-1)
@@ -99,24 +109,40 @@ def main():
stage_configs_path=args.stage_configs_path,
)
- additional: dict = {}
- if args.reference_audio:
- additional["reference_audio"] = args.reference_audio
- if args.prompt_audio and args.prompt_text:
- additional["prompt_audio"] = args.prompt_audio
- additional["prompt_text"] = args.prompt_text
+ from transformers import AutoTokenizer
- prompt: dict = {"prompt": args.text}
- if additional:
- prompt["additional_information"] = additional
+ from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import (
+ build_cjk_split_map,
+ build_voxcpm2_prompt,
+ )
+
+ tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
+ split_map = build_cjk_split_map(tokenizer)
+ hf_config = engine.engine.stage_vllm_configs[0].model_config.hf_config
+
+ ref_audio_arg = args.reference_audio or args.prompt_audio
+ ref_text_arg = args.ref_text or args.prompt_text
+ ref_wav, ref_sr = (None, None)
+ if ref_audio_arg:
+ ref_wav_arr, ref_sr = sf.read(ref_audio_arg)
+ ref_wav = ref_wav_arr.mean(axis=-1).tolist() if ref_wav_arr.ndim > 1 else ref_wav_arr.tolist()
+
+ prompt = build_voxcpm2_prompt(
+ hf_config=hf_config,
+ tokenizer=tokenizer,
+ split_map=split_map,
+ text=args.text,
+ ref_audio=ref_wav,
+ ref_sr=ref_sr,
+ ref_text=ref_text_arg,
+ )
print(f"Model : {args.model}")
print(f"Text : {args.text}")
- if args.reference_audio:
- print(f"Ref audio : {args.reference_audio}")
- if args.prompt_audio:
- print(f"Prompt audio: {args.prompt_audio}")
- print(f"Prompt text : {args.prompt_text}")
+ if ref_audio_arg:
+ print(f"Ref audio : {ref_audio_arg}")
+ if ref_text_arg:
+ print(f"Ref text : {ref_text_arg}")
print(f"Output dir : {output_dir}")
t_start = time.perf_counter()
diff --git a/examples/offline_inference/voxtral_tts/README.md b/examples/offline_inference/voxtral_tts/README.md
index 5f3d5413b..a55ce8830 100644
--- a/examples/offline_inference/voxtral_tts/README.md
+++ b/examples/offline_inference/voxtral_tts/README.md
@@ -10,28 +10,24 @@ When `mistral_common` has `SpeechRequest` support, prompt token IDs are built vi
```bash
# Basic single-prompt with cheerful_female voice preset
python3 examples/offline_inference/voxtral_tts/end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxtral_tts.yaml \
--write-audio --voice cheerful_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# 32 replicate prompts with cheerful_female voice preset
python3 examples/offline_inference/voxtral_tts/end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxtral_tts.yaml \
--num-prompts 32 --write-audio --voice cheerful_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# Streaming with neutral_female voice preset
python3 examples/offline_inference/voxtral_tts/end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxtral_tts.yaml \
--streaming --write-audio --voice neutral_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# 32 prompts, 8 concurrent requests per wave, streaming with neutral_female voice
python3 examples/offline_inference/voxtral_tts/end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxtral_tts.yaml \
--num-prompts 32 --concurrency 8 --streaming --write-audio --voice neutral_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
@@ -39,7 +35,6 @@ python3 examples/offline_inference/voxtral_tts/end2end.py \
# Short debug prompt with reference audio
# Note: Reference audio capability is not yet released.
python3 examples/offline_inference/voxtral_tts/end2end.py \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxtral_tts.yaml \
--write-audio \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "This is a test message." \
@@ -54,7 +49,7 @@ python3 examples/offline_inference/voxtral_tts/end2end.py \
| `--text TEXT` | Text to synthesize (default: `"This is a test message."`) |
| `--audio-path PATH` | Path to reference audio file for voice cloning |
| `--output-dir DIR` | Directory to write output WAV files (default: `output_audio`) |
-| `--stage-configs-path PATH` | Path to stage configs YAML (currently it must be set for VoxtralTTS) |
+| `--deploy-config PATH` | Override the deploy config path. If unset, auto-loads `vllm_omni/deploy/voxtral_tts.yaml` from the HF `model_type`. |
| `--num-prompts N` | Number of replicate prompts to run for measuring performance (default: 1) |
| `--streaming` | Use streaming generation via `AsyncOmni` (default: blocking `Omni`) |
| `--concurrency N` | Max concurrent requests per wave (must be used with `--streaming`, must evenly divide `--num-prompts`) |
diff --git a/examples/offline_inference/voxtral_tts/end2end.py b/examples/offline_inference/voxtral_tts/end2end.py
index 075024645..cf28f917e 100644
--- a/examples/offline_inference/voxtral_tts/end2end.py
+++ b/examples/offline_inference/voxtral_tts/end2end.py
@@ -39,7 +39,7 @@ logger = logging.getLogger(__name__)
async def run_streaming(inputs, sampling_params_list, model_name, args, output_dir):
async_omni = AsyncOmni(
model=model_name,
- stage_configs_path=args.stage_configs_path,
+ deploy_config=args.deploy_config,
log_stats=args.log_stats,
)
@@ -192,7 +192,7 @@ def run_non_streaming(inputs, sampling_params_list, model_name, args, output_dir
llm = Omni(
model=model_name,
log_stats=args.log_stats,
- stage_configs_path=args.stage_configs_path,
+ deploy_config=args.deploy_config,
)
if args.profiling_mode:
@@ -253,10 +253,11 @@ def parse_args() -> Namespace:
help="Directory to write output wav files.",
)
parser.add_argument(
- "--stage-configs-path",
+ "--deploy-config",
type=str,
default=None,
- help="Path to stage configs YAML. Auto-resolved from model if not set.",
+ help="Override the deploy config path. If unset, auto-loads "
+ "vllm_omni/deploy/voxtral_tts.yaml based on the HF model_type.",
)
parser.add_argument(
"--num-prompts", type=int, default=1, help="Number of replicate prompts to run for measuring performance"
diff --git a/examples/offline_inference/x_to_video_audio/x_to_video_audio.md b/examples/offline_inference/x_to_video_audio/x_to_video_audio.md
index 4b5188f41..13f2cfe7c 100644
--- a/examples/offline_inference/x_to_video_audio/x_to_video_audio.md
+++ b/examples/offline_inference/x_to_video_audio/x_to_video_audio.md
@@ -30,9 +30,9 @@ dreamid_omni/
```
### Run the Inference
-```
+```python
python x_to_video_audio.py \
- --model /xx/dreamid_omni \
+ --model /path/to/dreamid_omni \
--prompt "Two people walking together and singing happily" \
--image-path ./example0.png ./example1.png \
--audio-path ./example0.wav ./example1.wav \
@@ -42,11 +42,33 @@ python x_to_video_audio.py \
--num-inference-steps 45 \
--height 704 \
--width 1280 \
- --output dreamid_omni.mp4
+ --output out_dreamid_omni_twoip.mp4
```
In the current test scenario (2 images + 2 audio inputs), the VRAM requirement is 72GB, regardless of whether cfg-parallel is enabled or disabled.
The VRAM usage can be reduced by enabling CPU offload via --enable-cpu-offload.
+
+You could take reference images/audios from the test cases in the official repo: https://github.com/Guoxu1233/DreamID-Omni
+
+For example, single IP ref resources can be found under https://github.com/Guoxu1233/DreamID-Omni/tree/main/test_case/oneip, you could download them correspondingly to your local and use them for testing.
+
+```python
+# Example usage for oneip, ref media from the official repo DreamID-Omni
+python x_to_video_audio.py \
+ --model /path/to/dreamid_omni \
+ --prompt ": In the frame, a woman with black long hair is identified as .\n**Overall Environment/Scene**: A lively open-kitchen café at night; stove flames flare, steam rises, and warm pendant lights swing slightly as staff move behind her. The shot is an upper-body close-up.\n**Main Characters/Subjects Appearance**: is a young woman with thick dark wavy hair and a side part. She wears a fitted black top under a light apron, a thin gold chain necklace, and small stud earrings.\n**Main Characters/Subjects Actions**: tastes the sauce with a spoon, then turns her face toward the camera while still holding the spoon, her expression shifting from focused to conflicted.\n maintains eye contact, swallows as if choosing her words, and says, I keep telling myself I’m fine,but some nights it feels like I’m just performing calm." \
+ --image-path 9.png \
+ --audio-path 9.wav \
+ --video-negative-prompt "jitter, bad hands, blur, distortion" \
+ --audio-negative-prompt "robotic, muffled, echo, distorted" \
+ --cfg-parallel-size 2 \
+ --num-inference-steps 45 \
+ --height 704 \
+ --width 1280 \
+ --output out_dreamid_omni_oneip.mp4
+```
+
+
Key arguments:
- `--prompt`: text description (string).
- `--model`: path to the model local directory.
diff --git a/examples/offline_inference/x_to_video_audio/x_to_video_audio.py b/examples/offline_inference/x_to_video_audio/x_to_video_audio.py
index 49a0f496f..497284ceb 100644
--- a/examples/offline_inference/x_to_video_audio/x_to_video_audio.py
+++ b/examples/offline_inference/x_to_video_audio/x_to_video_audio.py
@@ -5,10 +5,12 @@ import argparse
import re
import time
+import numpy as np
from PIL import Image
from vllm.multimodal.media.audio import load_audio
from vllm_omni.diffusion.data import DiffusionParallelConfig
+from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -56,6 +58,11 @@ def parse_args() -> argparse.Namespace:
default=False,
help="Enable CPU offloading for diffusion models.",
)
+ parser.add_argument(
+ "--enable-layerwise-offload",
+ action="store_true",
+ help="Enable layerwise (blockwise) offloading on DiT modules.",
+ )
return parser.parse_args()
@@ -124,6 +131,7 @@ def main() -> None:
parallel_config=parallel_config,
model_type=args.model_type,
enable_cpu_offload=args.enable_cpu_offload,
+ enable_layerwise_offload=args.enable_layerwise_offload,
)
start = time.perf_counter()
outputs = omni.generate(prompt, sampling_params)
@@ -131,15 +139,35 @@ def main() -> None:
if not outputs:
raise RuntimeError("No output returned from DreamID-Omni.")
- output = outputs[0].request_output
- generated_video = output.images[0][0]
- generated_audio = output.images[0][1]
- try:
- from dreamid_omni.utils.io_utils import save_video
- except Exception as e:
- raise RuntimeError(f"Failed to extract video and audio from DreamID-Omni output. Error: {e}")
+ result = outputs[0]
+ if not result.images:
+ raise RuntimeError("No video frames found in DreamID-Omni output.")
+ generated_video = result.images[0]
+ mm = result.multimodal_output or {}
+ generated_audio = mm.get("audio")
+ fps = int(mm.get("fps", 24))
+ sample_rate = int(mm.get("audio_sample_rate", 16000))
+
+ # DreamID-Omni returns video as (C, F, H, W) float32 in [-1, 1].
+ # mux_video_audio_bytes expects (F, H, W, C) uint8.
+ if not isinstance(generated_video, np.ndarray) or generated_video.ndim != 4:
+ raise RuntimeError(f"Unexpected video shape: {getattr(generated_video, 'shape', None)}")
+ frames = generated_video.transpose(1, 2, 3, 0)
+ frames = (np.clip((frames + 1.0) / 2.0, 0.0, 1.0) * 255.0).round().astype(np.uint8)
+
+ audio_np = None
+ if generated_audio is not None:
+ audio_np = np.squeeze(np.asarray(generated_audio)).astype(np.float32)
+
output_path = args.output
- save_video(output_path, generated_video, generated_audio, fps=24, sample_rate=16000)
+ video_bytes = mux_video_audio_bytes(
+ frames,
+ audio_np,
+ fps=float(fps),
+ audio_sample_rate=sample_rate,
+ )
+ with open(output_path, "wb") as f:
+ f.write(video_bytes)
print(f"Saved generated video to {output_path}")
print(f"Total time: {elapsed:.2f}s")
diff --git a/examples/online_serving/fish_speech/README.md b/examples/online_serving/fish_speech/README.md
index ae968d3ba..9b4e3cc40 100644
--- a/examples/online_serving/fish_speech/README.md
+++ b/examples/online_serving/fish_speech/README.md
@@ -29,15 +29,12 @@ Features:
## Launch the Server
```bash
-vllm-omni serve fishaudio/s2-pro \
- --stage-configs-path vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml \
- --omni \
- --port 8091 \
- --trust-remote-code \
- --enforce-eager \
- --gpu-memory-utilization 0.9
+vllm serve fishaudio/s2-pro --omni --port 8091
```
+The deploy config is auto-loaded from `vllm_omni/deploy/fish_qwen3_omni.yaml`
+(the HF `model_type` on the fishaudio checkpoints is `fish_qwen3_omni`).
+
Or use the convenience script:
```bash
diff --git a/examples/online_serving/fish_speech/run_gradio_demo.sh b/examples/online_serving/fish_speech/run_gradio_demo.sh
index 98a696644..a0370b9cc 100755
--- a/examples/online_serving/fish_speech/run_gradio_demo.sh
+++ b/examples/online_serving/fish_speech/run_gradio_demo.sh
@@ -11,18 +11,13 @@ MODEL="${MODEL:-fishaudio/s2-pro}"
PORT="${PORT:-8091}"
GRADIO_PORT="${GRADIO_PORT:-7860}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
echo "Starting Fish Speech S2 Pro server (port $PORT)..."
FLASHINFER_DISABLE_VERSION_CHECK=1 \
-vllm-omni serve "$MODEL" \
- --stage-configs-path "$REPO_ROOT/vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml" \
+vllm serve "$MODEL" \
+ --omni \
--host 0.0.0.0 \
- --port "$PORT" \
- --gpu-memory-utilization 0.9 \
- --trust-remote-code \
- --enforce-eager \
- --omni &
+ --port "$PORT" &
SERVER_PID=$!
cleanup() {
diff --git a/examples/online_serving/fish_speech/run_server.sh b/examples/online_serving/fish_speech/run_server.sh
index 59c09c7fe..a865daf93 100755
--- a/examples/online_serving/fish_speech/run_server.sh
+++ b/examples/online_serving/fish_speech/run_server.sh
@@ -13,11 +13,7 @@ PORT="${PORT:-8091}"
echo "Starting Fish Speech S2 Pro server with model: $MODEL"
FLASHINFER_DISABLE_VERSION_CHECK=1 \
-vllm-omni serve "$MODEL" \
- --stage-configs-path vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml \
+vllm serve "$MODEL" \
+ --omni \
--host 0.0.0.0 \
- --port "$PORT" \
- --gpu-memory-utilization 0.9 \
- --trust-remote-code \
- --enforce-eager \
- --omni
+ --port "$PORT"
diff --git a/examples/online_serving/mimo_audio/README.md b/examples/online_serving/mimo_audio/README.md
index 9f70d59cb..9c1be7f21 100644
--- a/examples/online_serving/mimo_audio/README.md
+++ b/examples/online_serving/mimo_audio/README.md
@@ -13,10 +13,10 @@ Please refer to [README.md](../../../README.md)
```bash
export MIMO_AUDIO_TOKENIZER_PATH="XiaomiMiMo/MiMo-Audio-Tokenizer"
-vllm-omni serve XiaomiMiMo/MiMo-Audio-7B-Instruct --omni \
---served-model-name "MiMo-Audio-7B-Instruct" \
---port 18091 --stage-configs-path ./vllm_omni/model_executor/stage_configs/mimo_audio.yaml \
---chat-template ./examples/online_serving/mimo_audio/chat_template.jinja
+vllm serve XiaomiMiMo/MiMo-Audio-7B-Instruct --omni \
+ --served-model-name "MiMo-Audio-7B-Instruct" \
+ --port 18091 \
+ --chat-template ./examples/online_serving/mimo_audio/chat_template.jinja
```
> ⚠️ **Important**
> **MiMo-Audio is not compatible with the default chat template.**
diff --git a/examples/online_serving/ming_flash_omni/README.md b/examples/online_serving/ming_flash_omni/README.md
new file mode 100644
index 000000000..502232725
--- /dev/null
+++ b/examples/online_serving/ming_flash_omni/README.md
@@ -0,0 +1,204 @@
+# Ming-flash-omni 2.0
+
+## Installation
+
+Please refer to [README.md](../../../README.md)
+
+## Run examples (Ming-flash-omni 2.0)
+
+### Launch the Server
+
+```bash
+vllm serve Jonathan1909/Ming-flash-omni-2.0 --omni --port 8091
+```
+
+If you have custom stage configs file, launch the server with command below
+```bash
+vllm serve Jonathan1909/Ming-flash-omni-2.0 --omni --port 8091 --stage-configs-path /path/to/stage_configs_file
+```
+
+### Send Multi-modal Request
+
+#### Send request via python
+
+```bash
+python examples/online_serving/openai_chat_completion_client_for_multimodal_generation.py --model Jonathan1909/Ming-flash-omni-2.0 --query-type use_mixed_modalities --port 8091 --host "localhost" --modalities text
+```
+
+The Python client supports the following command-line arguments:
+
+- `--query-type` (or `-q`): Query type. Options: `text`, `use_audio`, `use_image`, `use_video`, `use_mixed_modalities`
+- `--video-path` (or `-v`): Path to local video file or URL. If not provided and query-type uses video, uses default video URL. Supports local file paths (automatically encoded to base64) or HTTP/HTTPS URLs. Example: `--video-path /path/to/video.mp4` or `--video-path https://example.com/video.mp4`
+- `--image-path` (or `-i`): Path to local image file or URL. If not provided and query-type uses image, uses default image URL. Supports local file paths (automatically encoded to base64) or HTTP/HTTPS URLs and common image formats: JPEG, PNG, GIF, WebP. Example: `--image-path /path/to/image.jpg` or `--image-path https://example.com/image.png`
+- `--audio-path` (or `-a`): Path to local audio file or URL. If not provided and query-type uses audio, uses default audio URL. Supports local file paths (automatically encoded to base64) or HTTP/HTTPS URLs and common audio formats: MP3, WAV, OGG, FLAC, M4A. Example: `--audio-path /path/to/audio.wav` or `--audio-path https://example.com/audio.mp3`
+- `--prompt` (or `-p`): Custom text prompt/question. If not provided, uses default prompt for the selected query type. Example: `--prompt "What are the main activities shown in this video?"`
+- `--modalities`: Output modalities. For now, only `text` is supported. Example: `--modalities text`
+
+
+#### Send request via curl
+
+```bash
+bash run_curl_multimodal_generation.sh text
+bash run_curl_multimodal_generation.sh use_image
+bash run_curl_multimodal_generation.sh use_audio
+bash run_curl_multimodal_generation.sh use_video
+bash run_curl_multimodal_generation.sh use_mixed_modalities
+```
+
+## Modality control
+
+Ming-flash-omni 2.0 currently supports text output only (thinker stage).
+
+| Modalities | Output |
+|------------|--------|
+| `["text"]` | Text only |
+| Not specified | Text only (default) |
+
+### Using curl
+
+```bash
+curl http://localhost:8091/v1/chat/completions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "Jonathan1909/Ming-flash-omni-2.0",
+ "messages": [
+ {"role": "system", "content": [{"type": "text", "text": "你是一个友好的AI助手。\n\ndetailed thinking off"}]},
+ {"role": "user", "content": "请详细介绍鹦鹉的生活习性。"}
+ ],
+ "modalities": ["text"]
+ }'
+```
+
+### Using OpenAI Python SDK
+
+```python
+from openai import OpenAI
+
+client = OpenAI(base_url="http://localhost:8091/v1", api_key="EMPTY")
+
+response = client.chat.completions.create(
+ model="Jonathan1909/Ming-flash-omni-2.0",
+ messages=[
+ {"role": "system", "content": [{"type": "text", "text": "你是一个友好的AI助手。\n\ndetailed thinking off"}]},
+ {"role": "user", "content": "请详细介绍鹦鹉的生活习性。"},
+ ],
+ modalities=["text"],
+)
+print(response.choices[0].message.content)
+```
+
+### Multi-modal input with OpenAI Python SDK
+
+```python
+from openai import OpenAI
+
+client = OpenAI(base_url="http://localhost:8091/v1", api_key="EMPTY")
+
+response = client.chat.completions.create(
+ model="Jonathan1909/Ming-flash-omni-2.0",
+ messages=[
+ {"role": "system", "content": [{"type": "text", "text": "你是一个友好的AI助手。\n\ndetailed thinking off"}]},
+ {
+ "role": "user",
+ "content": [
+ {"type": "image_url", "image_url": {"url": "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/cherry_blossom.jpg"}},
+ {"type": "text", "text": "Describe this image in detail."},
+ ],
+ },
+ ],
+ modalities=["text"],
+)
+print(response.choices[0].message.content)
+```
+
+## Streaming Output
+
+To enable streaming output:
+
+```bash
+python examples/online_serving/openai_chat_completion_client_for_multimodal_generation.py \
+ --query-type use_image \
+ --model Jonathan1909/Ming-flash-omni-2.0 \
+ --modalities text \
+ --stream
+```
+
+Or with the OpenAI Python SDK:
+
+```python
+from openai import OpenAI
+
+client = OpenAI(base_url="http://localhost:8091/v1", api_key="EMPTY")
+
+response = client.chat.completions.create(
+ model="Jonathan1909/Ming-flash-omni-2.0",
+ messages=[
+ {"role": "system", "content": [{"type": "text", "text": "你是一个友好的AI助手。\n\ndetailed thinking off"}]},
+ {"role": "user", "content": "请详细介绍鹦鹉的生活习性。"},
+ ],
+ modalities=["text"],
+ stream=True,
+)
+for chunk in response:
+ for choice in chunk.choices:
+ if hasattr(choice, "delta") and choice.delta.content:
+ print(choice.delta.content, end="", flush=True)
+print()
+```
+
+Or using curl:
+
+```bash
+curl http://localhost:8091/v1/chat/completions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "Jonathan1909/Ming-flash-omni-2.0",
+ "messages": [
+ {"role": "system", "content": [{"type": "text", "text": "你是一个友好的AI助手。\n\ndetailed thinking off"}]},
+ {"role": "user", "content": "请详细介绍鹦鹉的生活习性。"}
+ ],
+ "modalities": ["text"],
+ "stream": true,
+ }'
+```
+
+
+## Reasoning (Thinking Mode)
+
+To enable reasoning/thinking mode, change `detailed thinking off` to `detailed thinking on` in the system prompt:
+
+### Using curl
+
+```bash
+curl http://localhost:8091/v1/chat/completions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "Jonathan1909/Ming-flash-omni-2.0",
+ "messages": [
+ {"role": "system", "content": [{"type": "text", "text": "你是一个友好的AI助手。\n\ndetailed thinking on"}]},
+ {"role": "user", "content": [
+ {"type": "image_url", "image_url": {"url": "https://example.com/math_problem.png"}},
+ {"type": "text", "text": "Solve this math problem step by step."}
+ ]}
+ ],
+ "modalities": ["text"]
+ }'
+```
+
+### Using OpenAI Python SDK
+
+```python
+from openai import OpenAI
+
+client = OpenAI(base_url="http://localhost:8091/v1", api_key="EMPTY")
+
+response = client.chat.completions.create(
+ model="Jonathan1909/Ming-flash-omni-2.0",
+ messages=[
+ {"role": "system", "content": [{"type": "text", "text": "你是一个友好的AI助手。\n\ndetailed thinking on"}]},
+ {"role": "user", "content": "If a train travels 120 km in 2 hours, what is its average speed?"},
+ ],
+ modalities=["text"],
+)
+print(response.choices[0].message.content)
+```
diff --git a/examples/online_serving/ming_flash_omni/run_curl_multimodal_generation.sh b/examples/online_serving/ming_flash_omni/run_curl_multimodal_generation.sh
new file mode 100755
index 000000000..768a424e4
--- /dev/null
+++ b/examples/online_serving/ming_flash_omni/run_curl_multimodal_generation.sh
@@ -0,0 +1,145 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Server port
+PORT="${PORT:-8091}"
+# Default query type
+QUERY_TYPE="${1:-text}"
+
+# Validate query type
+if [[ ! "$QUERY_TYPE" =~ ^(text|use_audio|use_image|use_video|use_mixed_modalities)$ ]]; then
+ echo "Error: Invalid query type '$QUERY_TYPE'"
+ echo "Usage: $0 [text|use_audio|use_image|use_video|use_mixed_modalities]"
+ echo " text: Text-only query"
+ echo " use_audio: Audio + Text query"
+ echo " use_image: Image + Text query"
+ echo " use_video: Video + Text query"
+ echo " use_mixed_modalities: Audio + Image + Video + Text query"
+ exit 1
+fi
+
+thinker_sampling_params='{
+ "temperature": 0.4,
+ "top_p": 0.9,
+ "top_k": -1,
+ "max_tokens": 16384,
+ "seed": 42,
+ "detokenize": true,
+ "repetition_penalty": 1.05
+}'
+# Above is optional, it has a default setting in stage_configs of the corresponding model.
+
+# Define URLs for assets
+MARY_HAD_LAMB_AUDIO_URL="https://vllm-public-assets.s3.us-west-2.amazonaws.com/multimodal_asset/mary_had_lamb.ogg"
+CHERRY_BLOSSOM_IMAGE_URL="https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/cherry_blossom.jpg"
+SAMPLE_VIDEO_URL="https://huggingface.co/datasets/raushan-testing-hf/videos-test/resolve/main/sample_demo_1.mp4"
+
+# Build user content based on query type
+case "$QUERY_TYPE" in
+ text)
+ user_content='[
+ {
+ "type": "text",
+ "text": "请详细介绍鹦鹉的生活习性。"
+ }
+ ]'
+ ;;
+ use_image)
+ user_content='[
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": "'"$CHERRY_BLOSSOM_IMAGE_URL"'"
+ }
+ },
+ {
+ "type": "text",
+ "text": "Describe this image in detail."
+ }
+ ]'
+ ;;
+ use_audio)
+ user_content='[
+ {
+ "type": "audio_url",
+ "audio_url": {
+ "url": "'"$MARY_HAD_LAMB_AUDIO_URL"'"
+ }
+ },
+ {
+ "type": "text",
+ "text": "Please recognize the language of this speech and transcribe it. Format: oral."
+ }
+ ]'
+ ;;
+ use_video)
+ user_content='[
+ {
+ "type": "video_url",
+ "video_url": {
+ "url": "'"$SAMPLE_VIDEO_URL"'"
+ }
+ },
+ {
+ "type": "text",
+ "text": "Describe what is happening in this video."
+ }
+ ]'
+ ;;
+ use_mixed_modalities)
+ user_content='[
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": "'"$CHERRY_BLOSSOM_IMAGE_URL"'"
+ }
+ },
+ {
+ "type": "audio_url",
+ "audio_url": {
+ "url": "'"$MARY_HAD_LAMB_AUDIO_URL"'"
+ }
+ },
+ {
+ "type": "text",
+ "text": "Describe the image, and recognize the language of this speech and transcribe it. Format: oral"
+ }
+ ]'
+ ;;
+esac
+
+echo "Running query type: $QUERY_TYPE"
+echo ""
+
+request_body=$(cat < **Note on `--no-async-chunk`**: Flips the deploy yaml's `async_chunk:`
+> bool. Pipelines that implement alternate processor functions for
+> chunked vs end-to-end modes (e.g. qwen3_tts code2wav) dispatch
+> automatically based on that bool — no extra flag or variant yaml is
+> needed.
+
+> ⚠️ **For multi-stage models that share GPUs (qwen3_omni_moe by default
+> shares cuda:1 between stages 1 and 2), avoid using global memory flags.**
+> A global `--gpu-memory-utilization 0.85` would apply to every stage and
+> oversubscribe the shared device. Use per-stage overrides instead — see
+> below.
+
+#### 2. Per-stage overrides via `--stage-overrides` (recommended for memory)
+
+```bash
+# Lower stage 1's memory budget; leave others at the YAML default
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
+ --stage-overrides '{
+ "1": {"gpu_memory_utilization": 0.5},
+ "2": {"max_num_batched_tokens": 65536}
+ }'
+```
+
+Per-stage values are always treated as explicit and beat YAML defaults for
+the named stage. Other stages keep their YAML values.
+
+#### 3. Custom deploy YAML
+
+When per-stage overrides get long, write a small overlay YAML that inherits
+from the bundled default:
+
+```yaml
+# my_qwen3_omni_overrides.yaml
+base_config: /path/to/vllm_omni/deploy/qwen3_omni_moe.yaml
+
+stages:
+ - stage_id: 0
+ max_num_batched_tokens: 65536
+ enforce_eager: true
+ - stage_id: 1
+ gpu_memory_utilization: 0.5
+ - stage_id: 2
+ max_model_len: 8192
+```
+
+Then start the server with `--deploy-config my_qwen3_omni_overrides.yaml`.
+The `base_config:` line tells the loader to inherit everything else (stages,
+connectors, edges, platforms section) from the bundled production YAML, so
+you only need to spell out the deltas.
+
+#### 4. Multi-node deployment (cross-host transfer connector)
+
+The bundled `qwen3_omni_moe.yaml` uses `SharedMemoryConnector` between stages,
+which only works when all stages run on the same physical host. For
+**cross-node** deployments, write a small overlay YAML that swaps in a
+network-capable connector (e.g. `MooncakeStoreConnector`) and re-points each
+stage's connector wiring at it. The connector spec carries your own server
+addresses — there is no checked-in default because every cluster is
+different.
+
+```yaml
+# my_qwen3_omni_multinode.yaml
+base_config: /path/to/vllm_omni/deploy/qwen3_omni_moe.yaml
+
+connectors:
+ mooncake_connector:
+ name: MooncakeStoreConnector
+ extra:
+ host: "127.0.0.1"
+ metadata_server: "http://YOUR_METADATA_HOST:8080/metadata"
+ master: "YOUR_MASTER_HOST:50051"
+ segment: 512000000 # 512 MB transfer segment
+ localbuf: 64000000 # 64 MB local buffer
+ proto: "tcp"
+
+stages:
+ - stage_id: 0
+ output_connectors:
+ to_stage_1: mooncake_connector
+ - stage_id: 1
+ input_connectors:
+ from_stage_0: mooncake_connector
+ output_connectors:
+ to_stage_2: mooncake_connector
+ - stage_id: 2
+ input_connectors:
+ from_stage_1: mooncake_connector
+```
+
+Then launch with `--deploy-config my_qwen3_omni_multinode.yaml`. Same
+pattern works for Qwen2.5-Omni — replace `base_config:` with the path to
+`vllm_omni/deploy/qwen2_5_omni.yaml`.
+
+> ⚠️ Replace `YOUR_METADATA_HOST` / `YOUR_MASTER_HOST` with the actual
+> mooncake server addresses for your cluster. The `base_config:` overlay
+> inherits all stage budgets, devices, and edges from the bundled prod
+> YAML — you only need to spell out the connector swap.
+
### Send Multi-modal Request
Get into the example folder
@@ -38,36 +180,43 @@ python examples/online_serving/openai_chat_completion_client_for_multimodal_gene
#### Realtime WebSocket client (`openai_realtime_client.py`)
-[`openai_realtime_client.py`](./openai_realtime_client.py) connects to **`ws://:/v1/realtime`**, uploads a local audio file as **PCM16 mono @ 16 kHz** chunks (OpenAI-style `input_audio_buffer.append` / `commit`), and prints **streaming transcription** (`transcription.delta` / `transcription.done`).
+[`openai_realtime_client.py`](./openai_realtime_client.py) connects to **`ws://:/v1/realtime`**, streams a local WAV as **PCM16 mono @ 16 kHz** in fixed-size chunks (OpenAI-style `input_audio_buffer.append` / `commit`), and receives **`response.audio.delta`** (incremental PCM for the reply) plus **`transcription.*`** events. By default it concatenates audio deltas and writes **`--output-wav`** (model output is typically **24 kHz**). Optional **`--delta-dump-dir`** saves each delta as `delta_000001.wav`, … for debugging.
+
+Streaming input works well for translation-style use cases; if the Thinker runs while input is still incomplete, consider limiting **`max_tokens`** in your session / server defaults to avoid over-generation.
**Dependencies:**
```bash
-pip install websockets numpy
+pip install websockets
```
**From this directory** (`examples/online_serving/qwen3_omni`):
```bash
python openai_realtime_client.py \
- --host localhost \
- --port 8091 \
+ --url ws://localhost:8091/v1/realtime \
--model Qwen/Qwen3-Omni-30B-A3B-Instruct \
- --audio_path /path/to/your.wav
+ --input-wav /path/to/input_16k_mono.wav \
+ --output-wav realtime_output.wav \
+ --delta-dump-dir ./rt_delta_wavs
```
-If `--audio_path` is omitted, the script uses a bundled default clip (`mary_had_lamb` via vLLM assets).
-
**Arguments:**
| Flag | Default | Description |
|------|---------|-------------|
-| `--host` | `localhost` | API server host |
-| `--port` | `8000` | API server port (match your `vllm serve` port, e.g. `8091`) |
-| `--model` | `Qwen/Qwen3-Omni-30B-A3B-Instruct` | Must match the served model (also sent in `session.update`) |
-| `--audio_path` | *(optional)* | Path to input audio; resampled to 16 kHz mono inside the client |
+| `--url` | `ws://localhost:8091/v1/realtime` | Full WebSocket URL including path |
+| `--model` | `Qwen/Qwen3-Omni-30B-A3B-Instruct` | Must match the served model (sent in `session.update`) |
+| `--input-wav` | *(required)* | Input WAV: mono, 16-bit PCM, **16 kHz** |
+| `--output-wav` | `realtime_output.wav` | Output path for concatenated reply audio |
+| `--output-text` | *(optional)* | If set, write final transcription text to this path |
+| `--chunk-ms` | `200` | Size of each uploaded audio chunk (milliseconds of audio) |
+| `--send-delay-ms` | `0` | Delay between chunk sends (simulate realtime upload) |
+| `--delta-dump-dir` | *(optional)* | Directory to write per-`response.audio.delta` WAV files |
+| `--num-requests` | `1` | Number of sequential sessions (see `--concurrency`) |
+| `--concurrency` | `1` | Max concurrent WebSocket sessions when `--num-requests` > 1 |
-Ensure the vLLM-Omni server is running with realtime support for this endpoint, for example:
+Ensure the server is running **without** `async_chunk` if you use `/v1/realtime`, for example:
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
@@ -276,7 +425,7 @@ The script supports the following arguments:
- `--model`: Model name/path (default: Qwen/Qwen3-Omni-30B-A3B-Instruct)
- `--server-port`: Port for vLLM server (default: 8091)
- `--gradio-port`: Port for Gradio demo (default: 7861)
-- `--stage-configs-path`: Path to custom stage configs YAML file (optional)
+- `--deploy-config`: Path to custom deploy config YAML file (optional)
- `--server-host`: Host for vLLM server (default: 0.0.0.0)
- `--gradio-ip`: IP for Gradio demo (default: 127.0.0.1)
- `--share`: Share Gradio demo publicly (creates a public link)
@@ -291,7 +440,7 @@ vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
If you have custom stage configs file:
```bash
-vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --stage-configs-path /path/to/stage_configs_file
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --deploy-config /path/to/deploy_config_file
```
**Step 2: Run the Gradio demo**
diff --git a/examples/online_serving/qwen3_omni/openai_realtime_client.py b/examples/online_serving/qwen3_omni/openai_realtime_client.py
index 660e4ac33..79e30a3f5 100644
--- a/examples/online_serving/qwen3_omni/openai_realtime_client.py
+++ b/examples/online_serving/qwen3_omni/openai_realtime_client.py
@@ -1,81 +1,118 @@
+"""Realtime client for vLLM-Omni /v1/realtime (audio + text events).
+
+This client:
+1) Reads a local WAV file (must be mono, 16-bit PCM, 16kHz),
+2) Streams PCM16 chunks to /v1/realtime with OpenAI-style events,
+3) Receives response.audio.* and transcription.* events,
+4) Saves synthesized audio to an output WAV file and optional text file.
+
+By default each ``response.audio.delta`` is treated as an **incremental PCM**
+chunk and all chunks are concatenated into the final ``--output-wav``.
+
+Optional debugging: pass ``--delta-dump-dir DIR`` to write every
+``response.audio.delta`` payload as ``delta_000001.wav``, ``delta_000002.wav``, …
+
+Usage:
+ python openai_realtime_client.py \
+ --url ws://localhost:8091/v1/realtime \
+ --model Qwen/Qwen3-Omni-30B-A3B-Instruct \
+ --input-wav input_16k_mono.wav \
+ --output-wav realtime_output.wav \
+ --delta-dump-dir ./rt_delta_wavs
+
+Dependencies:
+ pip install websockets
"""
-This script demonstrates how to use the vLLM-Omni Realtime WebSocket API to perform
-audio transcription by uploading an audio file.
-Before running this script, you must start the vLLM-Omni server with a realtime-capable
-model, for example:
-
- vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni
-
-Requirements:
-- vllm with audio support
-- websockets
-- soundfile
-- numpy
-
-The script:
-1. Connects to the Realtime WebSocket endpoint
-2. Converts an audio file to PCM16 @ 16kHz
-3. Sends audio chunks to the server
-4. Receives and prints transcription as it streams
-"""
+from __future__ import annotations
import argparse
import asyncio
import base64
import json
+import wave
+from pathlib import Path
-import numpy as np
-import websockets
-from vllm.assets.audio import AudioAsset
-from vllm.multimodal.media.audio import load_audio
+try:
+ import websockets
+except ImportError:
+ print("Please install websockets: pip install websockets")
+ raise SystemExit(1)
-def audio_to_pcm16_base64(audio_path: str) -> str:
- """
- Load an audio file and convert it to base64-encoded PCM16 @ 16kHz.
- """
- # Load audio and resample to 16kHz mono
- audio, _ = load_audio(audio_path, sr=16000, mono=True)
- # Convert to PCM16
- pcm16 = (audio * 32767).astype(np.int16)
- # Encode as base64
- return base64.b64encode(pcm16.tobytes()).decode("utf-8")
+def _read_wav_pcm16(path: Path) -> bytes:
+ with wave.open(str(path), "rb") as wf:
+ nchannels = wf.getnchannels()
+ sampwidth = wf.getsampwidth()
+ framerate = wf.getframerate()
+ comptype = wf.getcomptype()
+ nframes = wf.getnframes()
+
+ if nchannels != 1:
+ raise ValueError(f"Input WAV must be mono (got {nchannels} channels).")
+ if sampwidth != 2:
+ raise ValueError(f"Input WAV must be 16-bit PCM (got sample width={sampwidth}).")
+ if framerate != 16000:
+ raise ValueError(f"Input WAV must be 16kHz (got {framerate} Hz).")
+ if comptype != "NONE":
+ raise ValueError(f"Input WAV must be uncompressed PCM (got comptype={comptype}).")
+ if nframes <= 0:
+ raise ValueError("Input WAV has no audio frames.")
+
+ return wf.readframes(nframes)
-async def realtime_transcribe(audio_path: str, host: str, port: int, model: str):
- """
- Connect to the Realtime API and transcribe an audio file.
- """
- uri = f"ws://{host}:{port}/v1/realtime"
+def _write_wav_pcm16(path: Path, pcm16_bytes: bytes, sample_rate_hz: int) -> None:
+ with wave.open(str(path), "wb") as wf:
+ wf.setnchannels(1)
+ wf.setsampwidth(2)
+ wf.setframerate(sample_rate_hz)
+ wf.writeframes(pcm16_bytes)
- async with websockets.connect(uri) as ws:
- # Wait for session.created
- response = json.loads(await ws.recv())
- if response["type"] == "session.created":
- print(f"Session created: {response['id']}")
- else:
- print(f"Unexpected response: {response}")
- return
- # Validate model
- await ws.send(json.dumps({"type": "session.update", "model": model}))
+async def run_client(
+ url: str,
+ model: str,
+ input_wav: Path,
+ output_wav: Path,
+ output_text: Path | None,
+ chunk_ms: int,
+ send_delay_ms: int,
+ delta_dump_dir: Path | None,
+ request_idx: int = 1,
+ total_requests: int = 1,
+) -> None:
+ log_prefix = f"[req {request_idx:02d}/{total_requests:02d}] " if total_requests > 1 else ""
+ pcm16 = _read_wav_pcm16(input_wav)
+ bytes_per_ms = 16000 * 2 // 1000 # mono PCM16 at 16kHz
+ chunk_bytes = max(bytes_per_ms * chunk_ms, 2)
- # Signal ready to start
- await ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
+ incremental_pcm_parts: list[bytes] = []
+ output_sample_rate = 24000
+ delta_index = 0
+ text_chunks: list[str] = []
+ final_text: str = ""
- # Convert audio file to base64 PCM16
- print(f"Loading audio from: {audio_path}")
- audio_base64 = audio_to_pcm16_base64(audio_path)
+ if delta_dump_dir is not None:
+ delta_dump_dir.mkdir(parents=True, exist_ok=True)
- # Send audio in chunks (4KB of raw audio = ~8KB base64)
- chunk_size = 4096
- audio_bytes = base64.b64decode(audio_base64)
- total_chunks = (len(audio_bytes) + chunk_size - 1) // chunk_size
+ async with websockets.connect(url, max_size=64 * 1024 * 1024) as ws:
+ # 1) Validate model.
+ await ws.send(
+ json.dumps(
+ {
+ "type": "session.update",
+ "model": model,
+ }
+ )
+ )
- print(f"Sending {total_chunks} audio chunks...")
- for i in range(0, len(audio_bytes), chunk_size):
- chunk = audio_bytes[i : i + chunk_size]
+ # 2) Start generation once (non-final commit).
+ await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": False}))
+
+ # 3) Stream audio chunks.
+ for i in range(0, len(pcm16), chunk_bytes):
+ chunk = pcm16[i : i + chunk_bytes]
await ws.send(
json.dumps(
{
@@ -84,63 +121,212 @@ async def realtime_transcribe(audio_path: str, host: str, port: int, model: str)
}
)
)
+ if send_delay_ms > 0:
+ await asyncio.sleep(send_delay_ms / 1000.0)
- # Signal all audio is sent
+ # 4) Final commit closes input stream.
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
- print("Audio sent. Waiting for transcription...\n")
- # Receive transcription
- print("Transcription: ", end="", flush=True)
+ # 5) Receive server events until audio done.
while True:
- response = json.loads(await ws.recv())
- if response["type"] == "transcription.delta":
- print(response["delta"], end="", flush=True)
- elif response["type"] == "transcription.done":
- print(f"\n\nFinal transcription: {response['text']}")
- if response.get("usage"):
- print(f"Usage: {response['usage']}")
- break
- elif response["type"] == "error":
- print(f"\nError: {response['error']}")
+ message = await ws.recv()
+ if isinstance(message, bytes):
+ # We only expect JSON text frames.
+ continue
+
+ event = json.loads(message)
+ event_type = event.get("type")
+
+ if event_type == "session.created":
+ continue
+
+ if event_type == "response.audio.delta":
+ sr = event.get("sample_rate_hz")
+ if isinstance(sr, int) and sr > 0:
+ output_sample_rate = sr
+ audio_b64 = event.get("audio", "")
+ if audio_b64:
+ pcm_delta = base64.b64decode(audio_b64)
+ incremental_pcm_parts.append(pcm_delta)
+ if delta_dump_dir is not None and pcm_delta:
+ delta_index += 1
+ dump_path = delta_dump_dir / f"delta_{delta_index:06d}.wav"
+ _write_wav_pcm16(dump_path, pcm_delta, output_sample_rate)
+ print(
+ f"{log_prefix}delta dump #{delta_index}: {dump_path} "
+ f"(pcm bytes={len(pcm_delta)}, sr={output_sample_rate})"
+ )
+ continue
+
+ if event_type == "transcription.delta":
+ delta = event.get("delta", "")
+ if delta:
+ text_chunks.append(delta)
+ print(delta, end="", flush=True)
+ continue
+
+ if event_type == "transcription.done":
+ final_text = event.get("text", "") or "".join(text_chunks)
+ usage = event.get("usage")
+ final_text_with_tag = f"Final transcription: {final_text}"
+ if text_chunks:
+ print()
+ print(f"{log_prefix}{final_text_with_tag}")
+ if usage:
+ print(f"{log_prefix}text usage: {usage}")
+ continue
+
+ if event_type == "response.audio.done":
break
+ if event_type == "error":
+ raise RuntimeError(f"Server error: {event}")
-def main(args):
- if args.audio_path:
- audio_path = args.audio_path
+ all_pcm16 = b"".join(incremental_pcm_parts)
+ if not all_pcm16:
+ raise RuntimeError("No audio received from server.")
+
+ output_wav.parent.mkdir(parents=True, exist_ok=True)
+ _write_wav_pcm16(output_wav, all_pcm16, output_sample_rate)
+ print(f"{log_prefix}Saved realtime audio to: {output_wav} (incremental chunks joined)")
+
+ if output_text is not None:
+ text_to_save = final_text if final_text else "".join(text_chunks)
+ output_text.parent.mkdir(parents=True, exist_ok=True)
+ output_text.write_text(text_to_save, encoding="utf-8")
+ print(f"{log_prefix}Saved realtime text to: {output_text}")
+
+
+def _indexed_output_path(path: Path | None, index: int, total: int) -> Path | None:
+ if path is None or total <= 1:
+ return path
+ return path.with_name(f"{path.stem}_{index:02d}{path.suffix}")
+
+
+async def run_clients_concurrent(
+ *,
+ url: str,
+ model: str,
+ input_wav: Path,
+ output_wav: Path,
+ output_text: Path | None,
+ chunk_ms: int,
+ send_delay_ms: int,
+ delta_dump_dir: Path | None,
+ num_requests: int,
+ concurrency: int,
+) -> None:
+ sem = asyncio.Semaphore(concurrency)
+
+ async def _run_one(index: int) -> tuple[int, bool, str | None]:
+ per_output_wav = _indexed_output_path(output_wav, index, num_requests)
+ per_output_text = _indexed_output_path(output_text, index, num_requests)
+ per_delta_dir = None
+ if delta_dump_dir is not None:
+ per_delta_dir = delta_dump_dir / f"req_{index:02d}"
+ async with sem:
+ try:
+ await run_client(
+ url=url,
+ model=model,
+ input_wav=input_wav,
+ output_wav=per_output_wav,
+ output_text=per_output_text,
+ chunk_ms=chunk_ms,
+ send_delay_ms=send_delay_ms,
+ delta_dump_dir=per_delta_dir,
+ request_idx=index,
+ total_requests=num_requests,
+ )
+ return index, True, None
+ except Exception as exc:
+ return index, False, str(exc)
+
+ tasks = [asyncio.create_task(_run_one(i), name=f"rt-client-{i}") for i in range(1, num_requests + 1)]
+ results = await asyncio.gather(*tasks)
+
+ failed = [(idx, err) for idx, ok, err in results if not ok]
+ succeeded = num_requests - len(failed)
+ print(f"[summary] succeeded={succeeded}, failed={len(failed)}, total={num_requests}")
+ if failed:
+ for idx, err in failed:
+ print(f"[summary] req {idx:02d} failed: {err}")
+ raise RuntimeError(f"{len(failed)} concurrent request(s) failed")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Realtime audio/text client for vLLM-Omni")
+ parser.add_argument("--url", default="ws://localhost:8091/v1/realtime", help="WebSocket URL")
+ parser.add_argument(
+ "--model",
+ default="Qwen/Qwen3-Omni-30B-A3B-Instruct",
+ help="Model name for session.update",
+ )
+ parser.add_argument("--input-wav", required=True, type=Path, help="Input WAV (mono, PCM16, 16kHz)")
+ parser.add_argument("--output-wav", default=Path("realtime_output.wav"), type=Path, help="Output WAV path")
+ parser.add_argument(
+ "--output-text",
+ default=None,
+ type=Path,
+ help="Optional output text path for final transcription",
+ )
+ parser.add_argument("--chunk-ms", type=int, default=200, help="Input chunk size in milliseconds")
+ parser.add_argument(
+ "--send-delay-ms",
+ type=int,
+ default=0,
+ help="Delay between chunk sends; set >0 to simulate realtime upload",
+ )
+ parser.add_argument(
+ "--delta-dump-dir",
+ type=Path,
+ default=None,
+ help="If set, each response.audio.delta is saved as delta_NNNNNN.wav under this directory",
+ )
+ parser.add_argument("--num-requests", type=int, default=1, help="Total number of requests to send")
+ parser.add_argument(
+ "--concurrency",
+ type=int,
+ default=1,
+ help="Maximum number of concurrent websocket requests",
+ )
+ args = parser.parse_args()
+
+ if args.num_requests <= 0:
+ raise ValueError("--num-requests must be >= 1")
+ if args.concurrency <= 0:
+ raise ValueError("--concurrency must be >= 1")
+ concurrency = min(args.concurrency, args.num_requests)
+
+ if args.num_requests == 1:
+ asyncio.run(
+ run_client(
+ url=args.url,
+ model=args.model,
+ input_wav=args.input_wav,
+ output_wav=args.output_wav,
+ output_text=args.output_text,
+ chunk_ms=args.chunk_ms,
+ send_delay_ms=args.send_delay_ms,
+ delta_dump_dir=args.delta_dump_dir,
+ )
+ )
else:
- # Use default audio asset
- audio_path = str(AudioAsset("mary_had_lamb").get_local_path())
- print(f"No audio path provided, using default: {audio_path}")
-
- asyncio.run(realtime_transcribe(audio_path, args.host, args.port, args.model))
+ asyncio.run(
+ run_clients_concurrent(
+ url=args.url,
+ model=args.model,
+ input_wav=args.input_wav,
+ output_wav=args.output_wav,
+ output_text=args.output_text,
+ chunk_ms=args.chunk_ms,
+ send_delay_ms=args.send_delay_ms,
+ delta_dump_dir=args.delta_dump_dir,
+ num_requests=args.num_requests,
+ concurrency=concurrency,
+ )
+ )
if __name__ == "__main__":
- parser = argparse.ArgumentParser(description="Realtime WebSocket Transcription Client")
- parser.add_argument(
- "--model",
- type=str,
- default="Qwen/Qwen3-Omni-30B-A3B-Instruct",
- help="Model that is served and should be pinged.",
- )
- parser.add_argument(
- "--audio_path",
- type=str,
- default=None,
- help="Path to the audio file to transcribe.",
- )
- parser.add_argument(
- "--host",
- type=str,
- default="localhost",
- help="vLLM-Omni server host (default: localhost)",
- )
- parser.add_argument(
- "--port",
- type=int,
- default=8000,
- help="vLLM-Omni server port (default: 8000)",
- )
- args = parser.parse_args()
- main(args)
+ main()
diff --git a/examples/online_serving/qwen3_tts/README.md b/examples/online_serving/qwen3_tts/README.md
index b48db9cf4..350fcb71c 100644
--- a/examples/online_serving/qwen3_tts/README.md
+++ b/examples/online_serving/qwen3_tts/README.md
@@ -43,7 +43,7 @@ Then open http://localhost:7860 in your browser.
### Launch the Server
-The default stage config is located at `vllm_omni/model_executor/stage_configs/qwen3_tts.yaml`. For other platforms (e.g., NPU), refer to `vllm_omni/platforms/npu/stage_configs/qwen3_tts.yaml`.
+The default deploy config is located at `vllm_omni/deploy/qwen3_tts.yaml` and is loaded automatically by the model registry — no `--deploy-config` flag needed for default use. Platform-specific deltas (NPU, ROCm, XPU) are merged in automatically from the `platforms:` block of the same YAML based on the detected runtime.
```bash
# CustomVoice model (predefined speakers)
@@ -70,6 +70,22 @@ vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
--port 8091
```
+#### Sync vs async-chunk mode
+
+Qwen3-TTS supports both **chunked streaming** (default, lower latency) and
+**synchronous end-to-end** modes from the same deploy YAML. The bundled
+`qwen3_tts.yaml` ships with `async_chunk: true`; flip with `--no-async-chunk`
+and the pipeline automatically dispatches to the end-to-end codec processor:
+
+```bash
+vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --omni --port 8091 \
+ --no-async-chunk
+```
+
+No variant YAML or extra flag is needed — the `StagePipelineConfig` on each
+stage declares both processor functions and the runtime picks based on the
+`async_chunk:` bool.
+
Alternatively, use the convenience script:
```bash
./run_server.sh # Default: CustomVoice model
diff --git a/examples/online_serving/qwen3_tts/batch_speech_client.py b/examples/online_serving/qwen3_tts/batch_speech_client.py
index 7d48e650f..47fdc3691 100644
--- a/examples/online_serving/qwen3_tts/batch_speech_client.py
+++ b/examples/online_serving/qwen3_tts/batch_speech_client.py
@@ -5,11 +5,13 @@ A particularly useful scenario is voice cloning: set ref_audio once at the
batch level and generate many utterances in the cloned voice without repeating
the reference for each item.
-Start the server (with batch-optimized config for best throughput):
+Start the server (with batch-optimized stage settings for best throughput):
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml \
- --trust-remote-code
+ --omni \
+ --trust-remote-code \
+ --stage-overrides '{"0":{"max_num_seqs":4,"gpu_memory_utilization":0.2},
+ "1":{"max_num_seqs":4,"gpu_memory_utilization":0.2}}'
Examples:
# Batch with a predefined voice
diff --git a/examples/online_serving/qwen3_tts/run_gradio_demo.sh b/examples/online_serving/qwen3_tts/run_gradio_demo.sh
index bcc0ddb7c..d79be3c2a 100644
--- a/examples/online_serving/qwen3_tts/run_gradio_demo.sh
+++ b/examples/online_serving/qwen3_tts/run_gradio_demo.sh
@@ -127,7 +127,7 @@ echo "Starting vLLM server..."
LOG_FILE="/tmp/vllm_tts_server_${SERVER_PORT}.log"
vllm-omni serve "$MODEL" \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts.yaml \
+ --deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--host "$SERVER_HOST" \
--port "$SERVER_PORT" \
--gpu-memory-utilization 0.9 \
diff --git a/examples/online_serving/qwen3_tts/run_server.sh b/examples/online_serving/qwen3_tts/run_server.sh
index 6f4aa83a0..78dd2c305 100755
--- a/examples/online_serving/qwen3_tts/run_server.sh
+++ b/examples/online_serving/qwen3_tts/run_server.sh
@@ -31,7 +31,7 @@ esac
echo "Starting Qwen3-TTS server with model: $MODEL"
vllm-omni serve "$MODEL" \
- --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts.yaml \
+ --deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--host 0.0.0.0 \
--port 8091 \
--gpu-memory-utilization 0.9 \
diff --git a/examples/online_serving/qwen3_tts/speaker_embedding_interpolation.py b/examples/online_serving/qwen3_tts/speaker_embedding_interpolation.py
index 38a2bdea9..7790fa512 100644
--- a/examples/online_serving/qwen3_tts/speaker_embedding_interpolation.py
+++ b/examples/online_serving/qwen3_tts/speaker_embedding_interpolation.py
@@ -5,7 +5,7 @@ speaker encoder from a Qwen3-TTS checkpoint, then interpolates between them
using SLERP and sends the result to the /v1/audio/speech API.
Requirements:
- pip install torch resampy soundfile numpy httpx
+ pip install torch soundfile numpy httpx
Examples:
# Extract and save an embedding
@@ -143,11 +143,12 @@ def _load_speaker_encoder_weights(encoder: torch.nn.Module, model_path: str) ->
def compute_mel_spectrogram(audio: np.ndarray, sr: int = 24000) -> torch.Tensor:
"""Compute 128-bin mel spectrogram matching Qwen3-TTS's extraction pipeline."""
- from vllm.multimodal.audio import resample_audio_resampy
+ from vllm.multimodal.audio import AudioResampler
# Resample to 24kHz if needed
if sr != 24000:
- audio = resample_audio_resampy(audio.astype(np.float32), orig_sr=sr, target_sr=24000)
+ resampler = AudioResampler(target_sr=24000)
+ audio = resampler.resample(audio.astype(np.float32), orig_sr=sr)
y = torch.from_numpy(audio).unsqueeze(0).float()
diff --git a/examples/online_serving/voxcpm/README.md b/examples/online_serving/voxcpm/README.md
new file mode 100644
index 000000000..78e1bf4aa
--- /dev/null
+++ b/examples/online_serving/voxcpm/README.md
@@ -0,0 +1,166 @@
+# VoxCPM
+
+## Prerequisites
+
+Install VoxCPM in one of these ways:
+
+```bash
+pip install voxcpm
+```
+
+or point vLLM-Omni to a local VoxCPM source tree:
+
+```bash
+export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/VoxCPM/src
+```
+
+If the native VoxCPM `config.json` lacks HF metadata such as `model_type`,
+prepare a persistent HF-compatible config directory and export:
+
+```bash
+export VLLM_OMNI_VOXCPM_HF_CONFIG_PATH=/tmp/voxcpm_hf_config
+mkdir -p "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"
+cp "$VOXCPM_MODEL/config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/config.json"
+cp "$VOXCPM_MODEL/generation_config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/generation_config.json" 2>/dev/null || true
+python3 -c 'import json, os; p=os.path.join(os.environ["VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"], "config.json"); cfg=json.load(open(p, "r", encoding="utf-8")); cfg["model_type"]="voxcpm"; cfg.setdefault("architectures", ["VoxCPMForConditionalGeneration"]); json.dump(cfg, open(p, "w", encoding="utf-8"), indent=2, ensure_ascii=False)'
+```
+
+The VoxCPM stage configs read `VLLM_OMNI_VOXCPM_HF_CONFIG_PATH` directly. The `python3 -c` form above avoids heredoc/indentation issues in interactive shells.
+
+## Launch the Server
+
+Use the async-chunk stage config by default:
+
+```bash
+export VOXCPM_MODEL=/path/to/voxcpm-model
+cd examples/online_serving/voxcpm
+./run_server.sh
+```
+
+Use the non-streaming stage config:
+
+```bash
+./run_server.sh sync
+```
+
+You can also launch the server directly:
+
+```bash
+vllm serve "$VOXCPM_MODEL" \
+ --stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \
+ --trust-remote-code \
+ --enforce-eager \
+ --omni \
+ --port 8091
+```
+
+## Send Requests
+
+### Basic text-to-speech
+
+```bash
+python openai_speech_client.py \
+ --model "$VOXCPM_MODEL" \
+ --text "This is a VoxCPM online text-to-speech example."
+```
+
+### Voice cloning
+
+```bash
+python openai_speech_client.py \
+ --model "$VOXCPM_MODEL" \
+ --text "This sentence is synthesized with a cloned voice." \
+ --ref-audio /path/to/reference.wav \
+ --ref-text "The exact transcript spoken in reference.wav."
+```
+
+`ref_text` must be the real transcript of the reference audio. Placeholder text or mismatched text will usually degrade quality badly.
+
+### Streaming PCM output
+
+```bash
+python openai_speech_client.py \
+ --model "$VOXCPM_MODEL" \
+ --text "This is a streaming VoxCPM request." \
+ --stream \
+ --output voxcpm_stream.pcm
+```
+
+### Using curl
+
+```bash
+curl -X POST http://localhost:8091/v1/audio/speech \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "OpenBMB/VoxCPM1.5",
+ "input": "Hello from VoxCPM online serving.",
+ "response_format": "wav"
+ }' --output output.wav
+```
+
+Voice cloning:
+
+```bash
+curl -X POST http://localhost:8091/v1/audio/speech \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "OpenBMB/VoxCPM1.5",
+ "input": "This sentence uses a cloned voice.",
+ "ref_audio": "https://example.com/reference.wav",
+ "ref_text": "The exact transcript spoken in the reference audio.",
+ "response_format": "wav"
+ }' --output cloned.wav
+```
+
+Streaming PCM:
+
+```bash
+curl -X POST http://localhost:8091/v1/audio/speech \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "OpenBMB/VoxCPM1.5",
+ "input": "This is a streaming VoxCPM request.",
+ "stream": true,
+ "response_format": "pcm"
+ }' --output output.pcm
+```
+
+## Supported Request Shape
+
+VoxCPM online serving currently supports:
+
+- plain text-to-speech
+- voice cloning with `ref_audio` + `ref_text`
+- `stream=true` with `response_format=pcm` or `wav`
+
+VoxCPM online serving does not use these generic TTS fields:
+
+- `voice`
+- `instructions`
+- `language`
+- `speaker_embedding`
+- `x_vector_only_mode`
+
+## Streaming vs Non-Streaming
+
+- `voxcpm_async_chunk.yaml` enables async-chunk streaming and is best for single-request streaming latency.
+- `voxcpm.yaml` performs one-shot latent generation then VAE decode.
+
+Like native VoxCPM, the async streaming path should be treated as single-request. If you need stable throughput benchmarking, prefer `voxcpm.yaml`.
+
+Do not use `voxcpm_async_chunk.yaml` for concurrent online streaming or `/v1/audio/speech/batch`. For multiple requests, prefer `voxcpm.yaml`.
+
+## Benchmark
+
+The serving benchmark reports TTFP and RTF:
+
+```bash
+python benchmarks/voxcpm/vllm_omni/bench_tts_serve.py \
+ --host 127.0.0.1 \
+ --port 8091 \
+ --num-prompts 10 \
+ --max-concurrency 1 \
+ --result-dir /tmp/voxcpm_bench
+```
+
+For the async-chunk server, keep `--max-concurrency 1`.
diff --git a/examples/online_serving/voxcpm/openai_speech_client.py b/examples/online_serving/voxcpm/openai_speech_client.py
new file mode 100644
index 000000000..c400114e8
--- /dev/null
+++ b/examples/online_serving/voxcpm/openai_speech_client.py
@@ -0,0 +1,155 @@
+"""OpenAI-compatible client for VoxCPM via /v1/audio/speech.
+
+Examples:
+ # Basic text-to-speech
+ python openai_speech_client.py --text "Hello from VoxCPM"
+
+ # Voice cloning
+ python openai_speech_client.py \
+ --text "This sentence uses the cloned voice." \
+ --ref-audio /path/to/reference.wav \
+ --ref-text "The exact transcript spoken in the reference audio."
+
+ # Streaming PCM output
+ python openai_speech_client.py \
+ --text "This is a streaming VoxCPM request." \
+ --stream \
+ --output output.pcm
+"""
+
+import argparse
+import base64
+import os
+
+import httpx
+
+DEFAULT_API_BASE = "http://localhost:8091"
+DEFAULT_API_KEY = "EMPTY"
+DEFAULT_MODEL = "OpenBMB/VoxCPM1.5"
+
+
+def encode_audio_to_base64(audio_path: str) -> str:
+ """Encode a local audio file to 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_map = {
+ "wav": "audio/wav",
+ "mp3": "audio/mpeg",
+ "flac": "audio/flac",
+ "ogg": "audio/ogg",
+ }
+ mime_type = mime_map.get(ext, "audio/wav")
+
+ with open(audio_path, "rb") as f:
+ audio_b64 = base64.b64encode(f.read()).decode("utf-8")
+ return f"data:{mime_type};base64,{audio_b64}"
+
+
+def build_payload(args) -> dict[str, object]:
+ payload: dict[str, object] = {
+ "model": args.model,
+ "input": args.text,
+ "response_format": "pcm" if args.stream else args.response_format,
+ }
+
+ if args.ref_audio:
+ if args.ref_audio.startswith(("http://", "https://", "data:")):
+ payload["ref_audio"] = args.ref_audio
+ else:
+ payload["ref_audio"] = encode_audio_to_base64(args.ref_audio)
+ if args.ref_text:
+ payload["ref_text"] = args.ref_text
+ if args.max_new_tokens is not None:
+ payload["max_new_tokens"] = args.max_new_tokens
+ if args.stream:
+ payload["stream"] = True
+
+ return payload
+
+
+def run_tts(args) -> None:
+ payload = build_payload(args)
+ api_url = f"{args.api_base}/v1/audio/speech"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {args.api_key}",
+ }
+
+ print(f"Model: {args.model}")
+ print(f"Text: {args.text}")
+ if args.ref_audio:
+ print("Mode: voice cloning")
+ print(f"Reference audio: {args.ref_audio}")
+ else:
+ print("Mode: text-to-speech")
+
+ if args.stream:
+ output_path = args.output or "voxcpm_output.pcm"
+ with httpx.Client(timeout=300.0) as client:
+ with client.stream("POST", api_url, json=payload, headers=headers) as response:
+ if response.status_code != 200:
+ print(f"Error: {response.status_code}")
+ print(response.read().decode("utf-8", errors="ignore"))
+ return
+
+ total_bytes = 0
+ with open(output_path, "wb") as f:
+ for chunk in response.iter_bytes():
+ if not chunk:
+ continue
+ f.write(chunk)
+ total_bytes += len(chunk)
+ print(f"Streamed {total_bytes} bytes to: {output_path}")
+ return
+
+ with httpx.Client(timeout=300.0) as client:
+ response = client.post(api_url, json=payload, headers=headers)
+
+ if response.status_code != 200:
+ print(f"Error: {response.status_code}")
+ print(response.text)
+ return
+
+ try:
+ text = response.content.decode("utf-8")
+ if text.startswith('{"error"'):
+ print(f"Error: {text}")
+ return
+ except UnicodeDecodeError:
+ pass
+
+ output_path = args.output or "voxcpm_output.wav"
+ with open(output_path, "wb") as f:
+ f.write(response.content)
+ print(f"Audio saved to: {output_path}")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="VoxCPM OpenAI-compatible speech client")
+ parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="API base URL")
+ parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="API key")
+ parser.add_argument("--model", "-m", default=DEFAULT_MODEL, help="Model name or path")
+ parser.add_argument("--text", required=True, help="Text to synthesize")
+ parser.add_argument("--ref-audio", default=None, help="Reference audio path, URL, or data URL")
+ parser.add_argument(
+ "--ref-text",
+ default=None,
+ help="The exact transcript spoken in the reference audio",
+ )
+ parser.add_argument("--stream", action="store_true", help="Enable streaming PCM output")
+ parser.add_argument(
+ "--response-format",
+ default="wav",
+ choices=["wav", "pcm", "flac", "mp3", "aac", "opus"],
+ help="Audio format for non-streaming mode (default: wav)",
+ )
+ parser.add_argument("--max-new-tokens", type=int, default=None, help="Maximum tokens to generate")
+ parser.add_argument("--output", "-o", default=None, help="Output file path")
+ args = parser.parse_args()
+ run_tts(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/online_serving/voxcpm/run_server.sh b/examples/online_serving/voxcpm/run_server.sh
new file mode 100755
index 000000000..ab4b6fe85
--- /dev/null
+++ b/examples/online_serving/voxcpm/run_server.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+# Launch vLLM-Omni server for VoxCPM online speech serving.
+#
+# Usage:
+# ./run_server.sh # default: async_chunk stage config
+# ./run_server.sh async # async_chunk stage config
+# ./run_server.sh sync # no-async-chunk stage config
+# VOXCPM_MODEL=/path/to/model ./run_server.sh
+
+set -e
+
+MODE="${1:-async}"
+MODEL="${VOXCPM_MODEL:-OpenBMB/VoxCPM1.5}"
+
+case "$MODE" in
+ async)
+ STAGE_CONFIG="vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml"
+ ;;
+ sync)
+ STAGE_CONFIG="vllm_omni/model_executor/stage_configs/voxcpm.yaml"
+ ;;
+ *)
+ echo "Unknown mode: $MODE"
+ echo "Supported: async, sync"
+ exit 1
+ ;;
+esac
+
+echo "Starting VoxCPM server with model: $MODEL"
+echo "Stage config: $STAGE_CONFIG"
+
+vllm serve "$MODEL" \
+ --stage-configs-path "$STAGE_CONFIG" \
+ --host 0.0.0.0 \
+ --port 8091 \
+ --trust-remote-code \
+ --enforce-eager \
+ --omni
diff --git a/examples/online_serving/voxcpm2/README.md b/examples/online_serving/voxcpm2/README.md
index 8735180f0..9ca2ae708 100644
--- a/examples/online_serving/voxcpm2/README.md
+++ b/examples/online_serving/voxcpm2/README.md
@@ -5,12 +5,13 @@ Serve VoxCPM2 TTS via the OpenAI-compatible `/v1/audio/speech` endpoint.
## Start the Server
```bash
-python -m vllm_omni.entrypoints.openai.api_server \
- --model openbmb/VoxCPM2 \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm2.yaml \
- --host 0.0.0.0 --port 8000
+vllm serve openbmb/VoxCPM2 --omni --host 0.0.0.0 --port 8000
```
+The deploy config is auto-loaded from `vllm_omni/deploy/voxcpm2.yaml`. Pass
+`--deploy-config ` to override, or `--stage-N- ` (e.g.
+`--stage-0-max-num-seqs 8`) for per-stage runtime overrides.
+
## Zero-shot Synthesis
```bash
diff --git a/examples/online_serving/voxcpm2/gradio_demo.py b/examples/online_serving/voxcpm2/gradio_demo.py
new file mode 100644
index 000000000..c6706198a
--- /dev/null
+++ b/examples/online_serving/voxcpm2/gradio_demo.py
@@ -0,0 +1,599 @@
+"""Gradio demo for VoxCPM2 TTS with gapless streaming audio playback.
+
+Uses a custom AudioWorklet-based player for gap-free streaming
+(adapted from the Qwen3-TTS demo). Audio is streamed from the vLLM
+server through a same-origin proxy and played via the Web Audio API's
+AudioWorklet, which maintains a FIFO buffer queue and plays samples at
+the audio clock rate.
+
+Usage:
+ # Start the vLLM server first:
+ vllm serve openbmb/VoxCPM2 --omni --host 0.0.0.0 --port 8000
+
+ # Then launch the demo:
+ python gradio_demo.py --api-base http://localhost:8000
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import io
+import json
+import logging
+
+import gradio as gr
+import httpx
+import numpy as np
+import soundfile as sf
+from fastapi import FastAPI, Request
+from fastapi.responses import Response, StreamingResponse
+
+logger = logging.getLogger(__name__)
+
+SAMPLE_RATE = 48000
+
+# ── AudioWorklet processor (loaded in browser via Blob URL) ──────────
+WORKLET_JS = r"""
+class TTSPlaybackProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.queue = [];
+ this.buf = null;
+ this.pos = 0;
+ this.playing = false;
+ this.played = 0;
+ this.port.onmessage = (e) => {
+ if (e.data && e.data.type === 'clear') {
+ this.queue = []; this.buf = null; this.pos = 0; this.played = 0;
+ if (this.playing) { this.playing = false; this.port.postMessage({type:'stopped'}); }
+ return;
+ }
+ this.queue.push(e.data);
+ };
+ }
+ process(inputs, outputs) {
+ const out = outputs[0][0];
+ for (let i = 0; i < out.length; i++) {
+ if (!this.buf || this.pos >= this.buf.length) {
+ if (this.queue.length > 0) {
+ this.buf = this.queue.shift(); this.pos = 0;
+ } else {
+ for (let j = i; j < out.length; j++) out[j] = 0;
+ if (this.playing) { this.playing = false; this.port.postMessage({type:'stopped', played:this.played}); }
+ return true;
+ }
+ }
+ out[i] = this.buf[this.pos++] / 32768;
+ this.played++;
+ }
+ if (!this.playing) { this.playing = true; this.port.postMessage({type:'started'}); }
+ return true;
+ }
+}
+registerProcessor('tts-playback-processor', TTSPlaybackProcessor);
+"""
+
+PLAYER_HTML = """
+
+"""
+
+
+def _build_player_js() -> str:
+ return f"""
+
+"""
+
+
+def _encode_audio(audio_data: tuple) -> str:
+ sr, audio_np = audio_data
+ if audio_np.dtype in (np.float32, np.float64):
+ audio_np = np.clip(audio_np, -1.0, 1.0)
+ audio_np = (audio_np * 32767).astype(np.int16)
+ elif audio_np.dtype != np.int16:
+ audio_np = audio_np.astype(np.int16)
+ buf = io.BytesIO()
+ sf.write(buf, audio_np, sr, format="WAV")
+ return f"data:audio/wav;base64,{base64.b64encode(buf.getvalue()).decode()}"
+
+
+def create_app(api_base: str):
+ app = FastAPI()
+ _pending: dict[str, dict] = {}
+
+ @app.post("/proxy/v1/audio/speech")
+ async def proxy_speech(request: Request):
+ body = await request.json()
+ req_id = body.get("_req_id")
+ if req_id and req_id in _pending:
+ body = _pending.pop(req_id)
+ logger.info("Proxy: %s", {k: (f"<{len(str(v))} chars>" if k == "ref_audio" else v) for k, v in body.items()})
+ try:
+ client = httpx.AsyncClient(timeout=300)
+ resp = await client.send(
+ client.build_request(
+ "POST",
+ f"{api_base}/v1/audio/speech",
+ json=body,
+ headers={"Authorization": "Bearer EMPTY", "Content-Type": "application/json"},
+ ),
+ stream=True,
+ )
+ except Exception as exc:
+ logger.exception("Proxy connection error")
+ await client.aclose()
+ return Response(content=str(exc), status_code=502)
+ if resp.status_code != 200:
+ content = await resp.aread()
+ await resp.aclose()
+ await client.aclose()
+ return Response(content=content, status_code=resp.status_code)
+
+ async def relay():
+ try:
+ async for chunk in resp.aiter_bytes():
+ yield chunk
+ finally:
+ await resp.aclose()
+ await client.aclose()
+
+ return StreamingResponse(relay(), media_type="application/octet-stream")
+
+ css = """
+ #generate-btn button { width: 100%; }
+ #streaming-player { border: 1px solid var(--border-color-primary) !important; border-radius: var(--block-radius) !important; padding: var(--block-padding) !important; }
+ """
+ theme = gr.themes.Default(
+ primary_hue=gr.themes.Color(
+ c50="#f0f5ff",
+ c100="#dce6f9",
+ c200="#b8cef3",
+ c300="#8eb2eb",
+ c400="#6496e0",
+ c500="#4A90D9",
+ c600="#3a7bc8",
+ c700="#2d66b0",
+ c800="#1f4f8f",
+ c900="#163a6e",
+ c950="#0e2650",
+ ),
+ )
+
+ with gr.Blocks(title="VoxCPM2 TTS Demo") as demo:
+ gr.HTML(f"""
+
+
+
+
VoxCPM2 Streaming Demo
+
+ Served by vLLM-Omni
+ · {api_base}
+ · 48 kHz
+
+
+
+ """)
+
+ gr.Markdown(
+ "**Three modes:** "
+ "**Voice Design** (control instruction only) · "
+ "**Controllable Cloning** (ref audio + optional style control) · "
+ "**Ultimate Cloning** (ref audio + transcript for audio continuation)"
+ )
+
+ with gr.Row():
+ with gr.Column(scale=3):
+ text_input = gr.Textbox(
+ label="Target Text",
+ placeholder="Enter text to synthesize...",
+ lines=4,
+ )
+ control_instruction = gr.Textbox(
+ label="Control Instruction (optional)",
+ placeholder="e.g. A warm young woman / Excited and fast-paced",
+ lines=2,
+ info="Describe voice style, emotion, pace. Works for both Voice Design and Controllable Cloning.",
+ )
+
+ with gr.Accordion("Voice Cloning", open=False):
+ ref_audio = gr.Audio(
+ label="Reference Audio (upload for cloning)",
+ type="numpy",
+ sources=["upload", "microphone"],
+ )
+ ref_audio_url = gr.Textbox(
+ label="or Reference Audio URL",
+ placeholder="https://example.com/reference.wav",
+ )
+ ultimate_clone = gr.Checkbox(
+ label="Ultimate Cloning Mode",
+ value=False,
+ info="Provide transcript of ref audio for audio continuation (disables control instruction)",
+ )
+ prompt_text = gr.Textbox(
+ label="Reference Audio Transcript",
+ placeholder="Transcript of your reference audio (for ultimate cloning)",
+ lines=2,
+ visible=False,
+ )
+
+ with gr.Row():
+ stream_checkbox = gr.Checkbox(
+ label="Stream (gapless)",
+ value=True,
+ info="AudioWorklet streaming",
+ )
+ with gr.Row():
+ generate_btn = gr.Button(
+ "Generate Speech",
+ variant="primary",
+ size="lg",
+ elem_id="generate-btn",
+ scale=3,
+ )
+ reset_btn = gr.Button("Reset", variant="secondary", size="lg", scale=1)
+
+ with gr.Column(scale=2):
+ player_html = gr.HTML(
+ value=PLAYER_HTML,
+ visible=True,
+ label="streaming player",
+ elem_id="streaming-player",
+ )
+ audio_output = gr.Audio(
+ label="generated audio",
+ interactive=False,
+ autoplay=True,
+ visible=False,
+ )
+ gr.Examples(
+ examples=[
+ ["Hello, this is a VoxCPM2 demo running on vLLM-Omni.", ""],
+ [
+ "I have a dream that my four little children will one day live in a nation "
+ "where they will not be judged by the color of their skin but by the content "
+ "of their character.",
+ "",
+ ],
+ [
+ "I never asked you to stay. It's not like I care or anything. "
+ "But why does it still hurt so much now that you're gone?",
+ "A young girl with a soft, sweet voice. Speaks slowly with a melancholic tone.",
+ ],
+ ],
+ inputs=[text_input, control_instruction],
+ label="examples",
+ )
+ gr.HTML("""
+
+ """)
+
+ hidden_payload = gr.Textbox(visible=False, elem_id="tts-payload")
+
+ def on_ultimate_toggle(checked):
+ return (
+ gr.update(visible=checked), # prompt_text
+ gr.update(interactive=not checked), # control_instruction
+ )
+
+ ultimate_clone.change(
+ fn=on_ultimate_toggle,
+ inputs=[ultimate_clone],
+ outputs=[prompt_text, control_instruction],
+ )
+
+ def on_stream_change(stream: bool):
+ if stream:
+ return gr.update(visible=True), gr.update(visible=False)
+ return gr.update(visible=False), gr.update(visible=True)
+
+ stream_checkbox.change(
+ fn=on_stream_change,
+ inputs=[stream_checkbox],
+ outputs=[player_html, audio_output],
+ )
+
+ def on_reset():
+ return "", "", None, "", False, "", PLAYER_HTML
+
+ reset_btn.click(
+ fn=on_reset,
+ outputs=[
+ text_input,
+ control_instruction,
+ audio_output,
+ hidden_payload,
+ ultimate_clone,
+ prompt_text,
+ player_html,
+ ],
+ js="() => { if (window.ttsStop) window.ttsStop(); }",
+ )
+
+ def on_generate(stream_enabled, text, ctrl_instr, ref_a, ref_url, ult_clone, p_text):
+ import time as _time
+
+ if not text or not text.strip():
+ raise gr.Error("Please enter text to synthesize.")
+
+ # VoxCPM2 uses "(instruction)text" format for control
+ ctrl = ctrl_instr.strip() if ctrl_instr and not ult_clone else ""
+ final_text = f"({ctrl}){text.strip()}" if ctrl else text.strip()
+
+ payload: dict = {
+ "input": final_text,
+ "voice": "default",
+ "response_format": "pcm" if stream_enabled else "wav",
+ "stream": stream_enabled,
+ }
+
+ # Reference audio for cloning
+ ref_url_s = ref_url.strip() if ref_url else ""
+ if ref_url_s:
+ payload["ref_audio"] = ref_url_s
+ elif ref_a is not None:
+ payload["ref_audio"] = _encode_audio(ref_a)
+
+ # Ultimate cloning: prompt_audio + prompt_text for continuation
+ if ult_clone and p_text and p_text.strip():
+ if ref_url_s:
+ payload["prompt_audio"] = ref_url_s
+ elif ref_a is not None:
+ payload["prompt_audio"] = payload.get("ref_audio", "")
+ payload["prompt_text"] = p_text.strip()
+
+ if stream_enabled:
+ if ref_a is not None and not ref_url_s:
+ req_id = f"req-{int(_time.time() * 1000)}"
+ _pending[req_id] = payload
+ browser_payload = {"_req_id": req_id, "_nonce": int(_time.time() * 1000)}
+ return json.dumps(browser_payload), gr.update()
+ payload["_nonce"] = int(_time.time() * 1000)
+ return json.dumps(payload), gr.update()
+ else:
+ try:
+ with httpx.Client(timeout=300.0) as client:
+ resp = client.post(
+ f"{api_base}/v1/audio/speech",
+ json=payload,
+ headers={"Content-Type": "application/json", "Authorization": "Bearer EMPTY"},
+ )
+ except httpx.ConnectError:
+ raise gr.Error(f"Cannot connect to server at {api_base}.")
+ if resp.status_code != 200:
+ raise gr.Error(f"Server error ({resp.status_code}): {resp.text[:200]}")
+ audio_np, sr = sf.read(io.BytesIO(resp.content))
+ if audio_np.ndim > 1:
+ audio_np = audio_np[:, 0]
+ return "", (sr, audio_np.astype(np.float32))
+
+ generate_btn.click(
+ fn=on_generate,
+ inputs=[
+ stream_checkbox,
+ text_input,
+ control_instruction,
+ ref_audio,
+ ref_audio_url,
+ ultimate_clone,
+ prompt_text,
+ ],
+ outputs=[hidden_payload, audio_output],
+ ).then(
+ fn=lambda p: p,
+ inputs=[hidden_payload],
+ outputs=[hidden_payload],
+ js="(p) => { if (p && p.trim()) { const d = JSON.parse(p); delete d._nonce; window.ttsGenerate(d); } return p; }",
+ )
+
+ demo.queue()
+
+ return gr.mount_gradio_app(app, demo, path="/", css=css, theme=theme, head=_build_player_js())
+
+
+def main():
+ parser = argparse.ArgumentParser(description="VoxCPM2 streaming Gradio demo")
+ parser.add_argument("--api-base", default="http://localhost:8000", help="vLLM API server URL")
+ parser.add_argument("--host", default="0.0.0.0", help="Gradio server host")
+ parser.add_argument("--port", type=int, default=7860, help="Gradio server port")
+ args = parser.parse_args()
+
+ logging.basicConfig(level=logging.INFO)
+ print(f"Connecting to vLLM server at: {args.api_base}")
+
+ import uvicorn
+
+ uvicorn.run(create_app(args.api_base), host=args.host, port=args.port)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/online_serving/voxcpm2/openai_speech_client.py b/examples/online_serving/voxcpm2/openai_speech_client.py
index a117d24fd..127b8cebb 100644
--- a/examples/online_serving/voxcpm2/openai_speech_client.py
+++ b/examples/online_serving/voxcpm2/openai_speech_client.py
@@ -13,10 +13,7 @@ Examples:
--ref-audio "https://example.com/reference.wav"
Server setup:
- python -m vllm_omni.entrypoints.openai.api_server \
- --model openbmb/VoxCPM2 \
- --stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm2.yaml \
- --host 0.0.0.0 --port 8000
+ vllm serve openbmb/VoxCPM2 --omni --host 0.0.0.0 --port 8000
"""
from __future__ import annotations
diff --git a/mkdocs.yml b/mkdocs.yml
index 6461c65f2..1e184439b 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -97,6 +97,7 @@ plugins:
exclude:
- "re:vllm_omni\\._.*" # Internal modules
- "vllm_omni.diffusion.models.qwen_image" # avoid importing vllm in mkdocs building
+ - "vllm_omni.diffusion.models.dreamid_omni.wan2_2" # docstring signature warnings break strict docs
- "vllm_omni.diffusion.quantization" # avoid importing vllm in mkdocs building
- "vllm_omni.quantization" # avoid importing vllm in mkdocs building
- "vllm_omni.entrypoints.async_diffusion" # avoid importing vllm in mkdocs building
diff --git a/pyproject.toml b/pyproject.toml
index 57a4b474f..b6f4092fd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -55,12 +55,24 @@ dev = [
"pyttsx3>=2.99",
"opencc>=1.2.0",
"mistune>=3.2.0", # for example tests
+ "torchmetrics>=1.4.0", # for accuracy similarity metrics
]
demo = [
"gradio>=6.7.0",
]
+# Seed-TTS serve benchmark WER (BytedanceSpeech/seed-tts-eval run_wer.py protocol).
+seed-tts-eval = [
+ "jiwer>=3.0.0",
+ "zhon>=2.0.0",
+ "zhconv>=1.4.2",
+ "scipy>=1.10.0",
+ "soundfile>=0.12.0",
+ "transformers>=4.36.0",
+ "funasr>=1.0.0",
+]
+
docs = [
"mkdocs>=1.5.0",
"mkdocs-api-autonav",
@@ -115,12 +127,13 @@ exclude = [
[tool.ruff.lint]
select = [
- "E", # pycodestyle errors
- "W", # pycodestyle warnings
- "F", # pyflakes
- "I", # isort (handled separately, but included for compatibility)
- "N", # pep8-naming
- "UP", # pyupgrade
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # pyflakes
+ "I", # isort (handled separately, but included for compatibility)
+ "N", # pep8-naming
+ "UP", # pyupgrade
+ "TID251", # flake8-tidy-imports.banned-api
]
ignore = [
"E203", # whitespace before ':' (conflicts with black)
@@ -135,6 +148,9 @@ ignore = [
"examples/**" = ["E501"] # Allow long lines in examples
"tests/**" = ["E501"] # Allow long lines in tests
+[tool.ruff.lint.flake8-tidy-imports.banned-api]
+"librosa".msg = "The librosa module is banned, use vllm.multimodal helpers instead"
+
[tool.mypy]
python_version = "3.12, 3.13"
warn_return_any = true
@@ -225,3 +241,4 @@ ue = "ue"
semantics = "semantics"
fullset = "fullset"
Vai = "Vai"
+CANN = "CANN"
diff --git a/recipes/Qwen/Qwen3-Omni.md b/recipes/Qwen/Qwen3-Omni.md
new file mode 100644
index 000000000..081e1453d
--- /dev/null
+++ b/recipes/Qwen/Qwen3-Omni.md
@@ -0,0 +1,90 @@
+# Qwen3-Omni for multimodal chat on 1x A100 80GB
+
+## Summary
+
+- Vendor: Qwen
+- Model: `Qwen/Qwen3-Omni-30B-A3B-Instruct`
+- Task: Multimodal chat with text, image, audio, or video input
+- 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
+`Qwen/Qwen3-Omni-30B-A3B-Instruct` with vLLM-Omni on a single 80 GB A100 and
+validate the deployment with the existing multimodal client examples in this
+repository.
+
+## References
+
+- Upstream or canonical docs:
+ [`docs/user_guide/examples/online_serving/qwen3_omni.md`](../../docs/user_guide/examples/online_serving/qwen3_omni.md)
+- Related example under `examples/`:
+ [`examples/online_serving/qwen3_omni/README.md`](../../examples/online_serving/qwen3_omni/README.md)
+- Related issue or discussion:
+ [RFC: add recipes folder](https://github.com/vllm-project/vllm-omni/issues/2645)
+
+## Hardware Support
+
+This recipe currently documents one tested-style reference configuration for
+CUDA GPU serving. Add more sections for other hardware as community validation
+lands.
+
+## GPU
+
+### 1x A100 80GB
+
+#### Environment
+
+- OS: Linux
+- Python: 3.10+
+- Driver / runtime: NVIDIA CUDA environment with an A100 80 GB GPU
+- vLLM version: Match the repository requirements for your checkout
+- vLLM-Omni version or commit: Use the commit you are deploying from
+
+#### Command
+
+Start the server from the repository root:
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
+```
+
+To enable async chunking, use the bundled stage config:
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct \
+ --omni \
+ --port 8091 \
+ --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml
+```
+
+#### Verification
+
+Run one of the existing example clients after the server is ready:
+
+```bash
+python examples/online_serving/openai_chat_completion_client_for_multimodal_generation.py \
+ --model Qwen/Qwen3-Omni-30B-A3B-Instruct \
+ --query-type use_image \
+ --port 8091 \
+ --host localhost
+```
+
+For a quick API smoke test, request text-only output:
+
+```bash
+curl http://localhost:8091/v1/chat/completions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
+ "messages": [{"role": "user", "content": "Describe vLLM in brief."}],
+ "modalities": ["text"]
+ }'
+```
+
+#### Notes
+
+- Memory usage: Size depends on runtime options and output modalities; leave headroom for multimodal workloads.
+- Key flags: `--omni` is required; `--stage-configs-path` is optional for custom or async-chunk stage configs.
+- Known limitations: This starter recipe is intentionally narrow and focuses on the single-GPU online-serving path already documented in the repo examples.
diff --git a/recipes/README.md b/recipes/README.md
new file mode 100644
index 000000000..01ecc41f1
--- /dev/null
+++ b/recipes/README.md
@@ -0,0 +1,37 @@
+# Community Recipes
+
+This directory contains community-maintained recipes for answering a
+practical user question:
+
+> How do I run model X on hardware Y for task Z?
+
+Add recipes for this repository under this in-repo `recipes/` directory. To
+keep naming and layout consistent, organize recipes by model vendor in a way
+that is aligned with
+[`vllm-project/recipes`](https://github.com/vllm-project/recipes), but treat
+that external repository as a reference for structure rather than the place to
+add files for this repo. Use one Markdown file per model family by default.
+
+Example layout:
+
+```text
+recipes/
+ Qwen/
+ Qwen3-Omni.md
+ Qwen3-TTS.md
+ Tencent-Hunyuan/
+ HunyuanVideo.md
+```
+
+## Available Recipes
+
+- [`Qwen/Qwen3-Omni.md`](./Qwen/Qwen3-Omni.md): online serving recipe for
+ multimodal chat on `1x A100 80GB`
+- [`Wan-AI/Wan2.2-I2V.md`](./Wan-AI/Wan2.2-I2V.md): image-to-video serving
+ recipe for Wan2.2 14B on `8x Ascend NPU (A2/A3)`
+
+Within a single recipe file, include different hardware support sections such
+as `GPU`, `ROCm`, and `NPU`, and add concrete tested configurations like
+`1x A100 80GB` or `2x L40S` inside those sections when applicable.
+
+See [TEMPLATE.md](./TEMPLATE.md) for the recommended format.
diff --git a/recipes/TEMPLATE.md b/recipes/TEMPLATE.md
new file mode 100644
index 000000000..9bf8cb9c7
--- /dev/null
+++ b/recipes/TEMPLATE.md
@@ -0,0 +1,82 @@
+# Recipe Title
+
+> Example: Qwen3-Omni for speech chat on 1x A100 80GB
+
+## Summary
+
+- Vendor:
+- Model:
+- Task:
+- Mode:
+- Maintainer:
+
+## When to use this recipe
+
+Briefly describe the concrete scenario this recipe covers.
+
+## References
+
+- Upstream or canonical docs:
+- Related example under `examples/`:
+- Related issue or discussion:
+
+## Hardware Support
+
+Add one section per platform, such as `GPU`, `ROCm`, or `NPU`. Under each
+platform section, document one or more tested hardware configurations.
+
+## GPU
+
+### 1x A100 80GB
+
+#### Environment
+
+- OS:
+- Python:
+- Driver / runtime:
+- vLLM version:
+- vLLM-Omni version or commit:
+
+#### Command
+
+```bash
+# Add the exact command(s) here
+```
+
+#### Verification
+
+```bash
+# Add a quick validation command or expected output here
+```
+
+#### Notes
+
+- Memory usage:
+- Key flags:
+- Known limitations:
+
+### 2x L40S
+
+Repeat the same structure for other hardware setups as needed.
+
+## ROCm
+
+### Example hardware configuration
+
+Repeat the same nested structure for ROCm setups as needed:
+
+- `#### Environment`
+- `#### Command`
+- `#### Verification`
+- `#### Notes`
+
+## NPU
+
+### Example hardware configuration
+
+Repeat the same nested structure for NPU setups as needed:
+
+- `#### Environment`
+- `#### Command`
+- `#### Verification`
+- `#### Notes`
diff --git a/recipes/Wan-AI/Wan2.2-I2V.md b/recipes/Wan-AI/Wan2.2-I2V.md
new file mode 100644
index 000000000..99ceac3ce
--- /dev/null
+++ b/recipes/Wan-AI/Wan2.2-I2V.md
@@ -0,0 +1,136 @@
+# Wan2.2 Image To Video
+
+## Summary
+
+- Vendor: Wan-AI
+- Model: `Wan-AI/Wan2.2-I2V-A14B-Diffusers`
+- Task: Image-to-video generation
+- Mode: Online serving with the OpenAI-compatible API
+- Maintainer: Community
+
+## When to use this recipe
+
+Use this recipe when you want to deploy the Wan2.2 14B image-to-video model
+with vLLM-Omni using multi-card parallelism. Two configurations are provided:
+
+1. **Distilled model (no negative-prompt / CFG computation)** — higher
+ throughput, recommended when using a distilled checkpoint that does not
+ require classifier-free guidance.
+2. **Official open-source model (with CFG)** — uses `--cfg 2` to run negative
+ and positive samples in parallel for the original released weights.
+
+## References
+
+- Upstream model card:
+
+## Hardware Support
+
+## NPU
+
+### 8x Ascend A2 / A3
+
+#### Environment
+
+- OS: Linux
+- Python: 3.10+
+- Driver / runtime: Ascend NPU driver with CANN toolkit
+- Recommended operator library: **mindie-sd** (Ascend high-performance fused
+ operators — enables `adalayernorm` and other fused kernels automatically upon
+ installation)
+- vLLM version: Match the repository requirements for your checkout
+- vLLM-Omni version or commit: Use the commit you are deploying from
+
+#### Prerequisites
+
+Install the **mindie-sd** operator library to enable Ascend-optimized fused
+operators (`adalayernorm`, etc.):
+
+```bash
+git clone https://gitcode.com/Ascend/MindIE-SD.git && cd MindIE-SD
+
+# Comment out the tik_ops build step (not needed for this use case)
+sed -i 's|^\(\s*\)source ${current_script_dir}/build_tik_ops.sh|\1# source ${current_script_dir}/build_tik_ops.sh|' build/build_ops.sh
+
+python setup.py bdist_wheel
+cd dist
+pip install mindiesd-*.whl
+```
+
+After installation, enable the Laser Attention kernel for significant
+long-sequence speedups (up to ~40% at 720p in tested workloads):
+
+```bash
+export MINDIE_SD_FA_TYPE=ascend_laser_attention
+```
+
+When using HSDP with FSDP2, set the following environment variable to work
+around a PyTorch NPU multi-stream memory reuse issue
+([pytorch/pytorch#147168](https://github.com/pytorch/pytorch/issues/147168)).
+This issue has been fixed on CUDA but still applies to NPU:
+
+```bash
+export MULTI_STREAM_MEMORY_REUSE=2
+```
+
+#### Command
+
+**Distilled model (no CFG, recommended for distilled checkpoints):**
+
+```bash
+export MINDIE_SD_FA_TYPE=ascend_laser_attention
+export MULTI_STREAM_MEMORY_REUSE=2
+
+vllm serve \
+ --omni Wan-AI/Wan2.2-I2V-A14B-Diffusers \
+ --use-hsdp \
+ --usp 8 \
+ --vae-patch-parallel-size 8 \
+ --vae-use-tiling
+```
+
+**Official open-source model (with CFG):**
+
+```bash
+export MINDIE_SD_FA_TYPE=ascend_laser_attention
+export MULTI_STREAM_MEMORY_REUSE=2
+
+vllm serve \
+ --omni Wan-AI/Wan2.2-I2V-A14B-Diffusers \
+ --use-hsdp \
+ --usp 4 \
+ --cfg 2 \
+ --vae-patch-parallel-size 8 \
+ --vae-use-tiling
+```
+
+> **Why the difference?** With `--cfg 2`, two copies of the input (positive and
+> negative prompts) are processed in parallel, effectively doubling the compute
+> for the DiT backbone. USP is therefore halved from 8 to 4 so that the total
+> parallelism across the 8 cards remains balanced (`usp * cfg = 8`).
+
+#### Verification
+
+After the server is ready, see
+[`examples/online_serving/image_to_video/README.md`](../../examples/online_serving/image_to_video/README.md)
+for complete client examples and request formats.
+
+#### Notes
+
+- **Key flags:**
+ - `--omni` — enables vLLM-Omni diffusion serving.
+ - `--use-hsdp` — enables Hybrid Sharded Data Parallelism for the DiT model
+ weights.
+ - `--usp ` — Unified Sequence Parallelism degree.
+ - `--cfg ` — Classifier-Free Guidance parallelism; set to 2 for models
+ that require negative-prompt computation, omit for distilled models.
+ - `--vae-patch-parallel-size 8` — parallelizes VAE decoding across all 8
+ cards.
+ - `--vae-use-tiling` — enables tiled VAE decoding to reduce peak memory.
+- **Performance tips:**
+ - Installing mindie-sd and enabling Laser Attention
+ (`MINDIE_SD_FA_TYPE=ascend_laser_attention`) provides up to ~40%
+ performance improvement at 720p resolution due to long-sequence attention
+ optimization.
+- **Known limitations:**
+ - `MULTI_STREAM_MEMORY_REUSE=2` is required on NPU when using HSDP/FSDP2
+ due to a multi-stream memory reuse bug. This is not needed on CUDA.
diff --git a/requirements/common.txt b/requirements/common.txt
index 1fff58444..63e16d580 100644
--- a/requirements/common.txt
+++ b/requirements/common.txt
@@ -1,7 +1,6 @@
# Common dependencies for all platforms
av>=14.0.0
omegaconf>=2.3.0
-resampy>=0.4.3
diffusers>=0.36.0
accelerate==1.12.0
soundfile>=0.13.1
@@ -10,7 +9,6 @@ tqdm>=4.66.0
torchsde>=0.2.6
openai-whisper>=20250625
imageio[ffmpeg]>=2.37.2
-sox>=1.5.0
x-transformers>=2.12.2
einops>=0.8.1
prettytable>=3.8.0
diff --git a/tests/config/__init__.py b/tests/config/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/config/test_pipeline_registry.py b/tests/config/test_pipeline_registry.py
new file mode 100644
index 000000000..3483d530c
--- /dev/null
+++ b/tests/config/test_pipeline_registry.py
@@ -0,0 +1,111 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Tests for the central pipeline registry (2.5/N)."""
+
+from __future__ import annotations
+
+import pytest
+
+from vllm_omni.config.pipeline_registry import (
+ _DIFFUSION_PIPELINES,
+ _OMNI_PIPELINES,
+ _VLLM_OMNI_PIPELINES,
+)
+from vllm_omni.config.stage_config import (
+ _PIPELINE_REGISTRY,
+ PipelineConfig,
+ StageExecutionType,
+ StagePipelineConfig,
+ register_pipeline,
+)
+
+
+class TestCentralRegistryDeclarations:
+ """Every in-tree pipeline must be declared exactly once in the central registry."""
+
+ def test_union_contains_all_omni(self):
+ for key in _OMNI_PIPELINES:
+ assert key in _VLLM_OMNI_PIPELINES
+
+ def test_union_contains_all_diffusion(self):
+ for key in _DIFFUSION_PIPELINES:
+ assert key in _VLLM_OMNI_PIPELINES
+
+ def test_no_duplicate_model_type_between_omni_and_diffusion(self):
+ overlap = set(_OMNI_PIPELINES) & set(_DIFFUSION_PIPELINES)
+ assert not overlap, f"Duplicate model_types across omni/diffusion: {overlap}"
+
+ def test_expected_omni_pipelines_present(self):
+ # Guard against accidental removal during future refactors.
+ assert "qwen2_5_omni" in _OMNI_PIPELINES
+ assert "qwen2_5_omni_thinker_only" in _OMNI_PIPELINES
+ assert "qwen3_omni_moe" in _OMNI_PIPELINES
+ assert "qwen3_tts" in _OMNI_PIPELINES
+
+
+class TestLazyLoading:
+ """Pipelines are imported only on first access."""
+
+ def test_contains_without_import(self):
+ # ``in`` hits the lazy map, not the loaded cache.
+ assert "qwen3_omni_moe" in _PIPELINE_REGISTRY
+
+ def test_getitem_loads_correct_pipeline(self):
+ pipeline = _PIPELINE_REGISTRY["qwen3_omni_moe"]
+ assert pipeline.model_type == "qwen3_omni_moe"
+ assert pipeline.model_arch == "Qwen3OmniMoeForConditionalGeneration"
+
+ def test_unknown_model_type_returns_none_via_get(self):
+ assert _PIPELINE_REGISTRY.get("not_a_real_pipeline") is None
+
+ def test_unknown_model_type_raises_keyerror_via_getitem(self):
+ with pytest.raises(KeyError):
+ _PIPELINE_REGISTRY["not_a_real_pipeline"]
+
+ def test_iteration_yields_registered_pipelines(self):
+ keys = set(_PIPELINE_REGISTRY)
+ assert "qwen2_5_omni" in keys
+ assert "qwen3_omni_moe" in keys
+
+
+class TestDynamicRegistration:
+ """``register_pipeline()`` still works for plugins and tests."""
+
+ def test_register_adds_to_registry(self):
+ custom = PipelineConfig(
+ model_type="_test_dynamic_registration",
+ model_arch="DynamicTestModel",
+ stages=(
+ StagePipelineConfig(
+ stage_id=0,
+ model_stage="test",
+ execution_type=StageExecutionType.LLM_AR,
+ input_sources=(),
+ final_output=True,
+ ),
+ ),
+ )
+ register_pipeline(custom)
+ try:
+ assert "_test_dynamic_registration" in _PIPELINE_REGISTRY
+ assert _PIPELINE_REGISTRY["_test_dynamic_registration"] is custom
+ finally:
+ # Don't leak the test registration into other tests.
+ if "_test_dynamic_registration" in _PIPELINE_REGISTRY:
+ del _PIPELINE_REGISTRY["_test_dynamic_registration"]
+
+ def test_dynamic_registration_overrides_lazy_entry(self):
+ # Build a substitute for qwen3_omni_moe that we can distinguish.
+ original = _PIPELINE_REGISTRY["qwen3_omni_moe"]
+ override = PipelineConfig(
+ model_type="qwen3_omni_moe",
+ model_arch="OverriddenArch",
+ stages=original.stages,
+ )
+ register_pipeline(override)
+ try:
+ assert _PIPELINE_REGISTRY["qwen3_omni_moe"].model_arch == "OverriddenArch"
+ finally:
+ # Remove the dynamic override so later tests see the original.
+ if "qwen3_omni_moe" in _PIPELINE_REGISTRY._loaded:
+ del _PIPELINE_REGISTRY["qwen3_omni_moe"]
diff --git a/tests/conftest.py b/tests/conftest.py
index adb87cbd7..77075f952 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3423 +1,62 @@
-import atexit
-import base64
-import datetime
-import io
-import json
-import math
-import os
-import random
-import re
-import tempfile
-
-import requests
-
-os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
-# Set CPU device for CI environments without GPU
-if "VLLM_TARGET_DEVICE" not in os.environ:
- os.environ["VLLM_TARGET_DEVICE"] = "cpu"
-
-import concurrent.futures
-import contextlib
-import gc
-import multiprocessing
-import socket
-import subprocess
-import sys
-import threading
-import time
-import uuid
-from collections.abc import Generator
-from dataclasses import dataclass
-from io import BytesIO
-from pathlib import Path
-from typing import Any, NamedTuple
-
-import cv2
-import numpy as np
-import psutil
-import pytest
-import soundfile as sf
-import torch
-import yaml
-from openai import OpenAI, omit
-from PIL import Image
-from transformers import pipeline
-from vllm import TextPrompt
-from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
-from vllm.logger import init_logger
-from vllm.utils.network_utils import get_open_port
-
-from vllm_omni.entrypoints.omni import Omni
-from vllm_omni.inputs.data import OmniSamplingParams
-from vllm_omni.outputs import OmniRequestOutput
-from vllm_omni.platforms import current_omni_platform
-
-logger = init_logger(__name__)
-
-
-PromptAudioInput = list[tuple[Any, int]] | tuple[Any, int] | None
-PromptImageInput = list[Any] | Any | None
-PromptVideoInput = list[Any] | Any | None
-
-_GENDER_PIPELINE = None
-# transformers.Pipeline is not thread-safe; concurrent e2e requests must serialize inference.
-_GENDER_PIPELINE_LOCK = threading.Lock()
-
-# int16 mono PCM from /v1/audio/speech when response_format=pcm (Qwen3-TTS code2wav output rate).
-_PCM_SPEECH_SAMPLE_RATE_HZ = 24_000
-
-
-class OmniServerParams(NamedTuple):
- model: str
- port: int | None = None
- stage_config_path: str | None = None
- server_args: list[str] | None = None
- env_dict: dict[str, str] | None = None
- use_omni: bool = True
- use_stage_cli: bool = False
- init_timeout: int | None = None
- stage_init_timeout: int | None = None # None defers to the server's own default (300 s)
-
-
-def assert_image_diffusion_response(
- response,
- request_config: dict[str, Any],
- run_level: str = None,
-) -> None:
- """
- Validate image diffusion response.
-
- Expected request_config schema:
- {
- "request_type": "image",
- "extra_body": {
- "num_outputs_per_prompt": 1,
- "width": ...,
- "height": ...,
- ...
- }
- }
- """
- assert response.images is not None, "Image response is None"
- assert len(response.images) > 0, "No images in response"
-
- extra_body = request_config.get("extra_body") or {}
-
- num_outputs_per_prompt = extra_body.get("num_outputs_per_prompt")
- if num_outputs_per_prompt is not None:
- assert len(response.images) == num_outputs_per_prompt, (
- f"Expected {num_outputs_per_prompt} images, got {len(response.images)}"
- )
-
- if run_level == "advanced_model":
- width = extra_body.get("width")
- height = extra_body.get("height")
-
- if width is not None or height is not None:
- for img in response.images:
- assert_image_valid(img, width=width, height=height)
-
-
-def assert_video_diffusion_response(
- response,
- request_config: dict[str, Any],
- run_level: str = None,
-) -> None:
- """
- Validate video diffusion response.
-
- Expected request_config schema:
- {
- "request_type": "video",
- "form_data": {
- "prompt": "...",
- "num_frames": ...,
- "width": ...,
- "height": ...,
- "fps": ...,
- ...
- }
- }
- """
- form_data = request_config.get("form_data", {})
-
- assert response.videos is not None, "Video response is None"
- assert len(response.videos) > 0, "No videos in response"
-
- expected_frames = _maybe_int(form_data.get("num_frames"))
- expected_width = _maybe_int(form_data.get("width"))
- expected_height = _maybe_int(form_data.get("height"))
- expected_fps = _maybe_int(form_data.get("fps"))
-
- for vid_bytes in response.videos:
- assert_video_valid(
- vid_bytes,
- num_frames=expected_frames,
- width=expected_width,
- height=expected_height,
- fps=expected_fps,
- )
-
-
-def assert_audio_diffusion_response(
- response,
- request_config: dict[str, Any],
- run_level: str = None,
-) -> None:
- """
- Validate audio diffusion response.
- """
- raise NotImplementedError("Audio validation is not implemented yet")
-
-
-def _maybe_int(value: Any) -> int | None:
- if value is None:
- return None
- return int(value)
-
-
-def assert_image_valid(image: Path | Image.Image, *, width: int | None = None, height: int | None = None):
- """Assert the file is a loadable image with optional exact dimensions."""
- if isinstance(image, Path):
- assert image.exists(), f"Image not found: {image}"
- image = Image.open(image)
- image.load()
- assert image.width > 0 and image.height > 0
- if width is not None:
- assert image.width == width, f"Expected width={width}, got {image.width}"
- if height is not None:
- assert image.height == height, f"Expected height={height}, got {image.height}"
- return image
-
-
-def assert_video_valid(
- video: Path | bytes | BytesIO,
- *,
- num_frames: int | None = None,
- width: int | None = None,
- height: int | None = None,
- fps: float | None = None,
-) -> dict[str, int | float]:
- """Assert the MP4 has the expected resolution and exact frame count."""
- temp_path = None
- cap = None
- try:
- # Normalize input to file path
- if isinstance(video, Path):
- if not video.exists():
- raise AssertionError(f"Video file not found: {video}")
- video_path = str(video)
- else:
- # Create temp file for bytes/BytesIO
- suffix = ".mp4"
- with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, mode="wb") as tmp:
- if isinstance(video, bytes):
- tmp.write(video)
- elif isinstance(video, BytesIO):
- tmp.write(video.getvalue())
- else:
- raise TypeError(f"Unsupported video type: {type(video)}")
- temp_path = Path(tmp.name)
- video_path = str(temp_path)
-
- # Open video capture
- cap = cv2.VideoCapture(video_path)
- if not cap.isOpened():
- raise AssertionError(f"Failed to open video: {video_path}")
-
- # Extract properties
- actual_num_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
- actual_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
- actual_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
- actual_fps = cap.get(cv2.CAP_PROP_FPS)
-
- actual_num_frames = 0
- while True:
- ok, _frame = cap.read()
- if not ok:
- break
- actual_num_frames += 1
-
- # Basic validity checks
- if actual_num_frames <= 0:
- raise AssertionError(f"Invalid frame count: {actual_num_frames} (must be > 0)")
- if actual_width <= 0 or actual_height <= 0:
- raise AssertionError(f"Invalid dimensions: {actual_width}x{actual_height} (must be > 0)")
- if actual_fps <= 0:
- raise AssertionError(f"Invalid FPS: {actual_fps} (must be > 0)")
-
- # Validate against expectations
- if num_frames is not None:
- expected_num_frames = (num_frames // 4) * 4 + 1
- assert actual_num_frames == expected_num_frames, (
- f"Frame count mismatch: expected {num_frames}, got {actual_num_frames}"
- )
- if width is not None:
- assert actual_width == width, f"Width mismatch: expected {width}px, got {actual_width}px"
- if height is not None:
- assert actual_height == height, f"Height mismatch: expected {height}px, got {actual_height}px"
- if fps is not None:
- # Use tolerance for float comparison (codec rounding)
- assert abs(actual_fps - fps) < 0.5, f"FPS mismatch: expected {fps}, got {actual_fps:.2f}"
-
- return {"num_frames": actual_num_frames, "width": actual_width, "height": actual_height, "fps": actual_fps}
-
- except Exception as e:
- print(f"ERROR: {type(e).__name__}: {e}", flush=True)
- raise
-
- finally:
- # Cleanup resources
- if cap is not None:
- cap.release()
- if temp_path and temp_path.exists():
- try:
- temp_path.unlink()
- except OSError:
- pass
-
-
-def assert_audio_valid(
- audio_or_path: Path | np.ndarray,
- *,
- sample_rate: int,
- channels: int,
- duration_s: float,
-) -> None:
- """Assert WAV file or (batch, channels, samples) ndarray matches expected audio format."""
- expected_samples = int(duration_s * sample_rate)
- if isinstance(audio_or_path, np.ndarray):
- audio = audio_or_path
- assert audio.ndim == 3, f"Expected audio ndim=3 (batch, channels, samples), got shape {audio.shape}"
- assert audio.shape[0] == 1, f"Expected batch size 1, got {audio.shape[0]}"
- assert audio.shape[1] == channels, f"Expected {channels} channels, got {audio.shape[1]}"
- assert audio.shape[2] == expected_samples, (
- f"Expected {expected_samples} samples ({duration_s}s @ {sample_rate} Hz), got {audio.shape[2]}"
- )
- return
-
- path = audio_or_path
- assert path.exists(), f"Audio not found: {path}"
- info = sf.info(str(path))
- assert info.samplerate == sample_rate, f"Expected sample_rate={sample_rate}, got {info.samplerate}"
- assert info.channels == channels, f"Expected {channels} channel(s), got {info.channels}"
- assert info.frames == expected_samples, (
- f"Expected {expected_samples} frames ({duration_s}s @ {sample_rate} Hz), got {info.frames}"
- )
-
-
-def decode_b64_image(b64: str):
- img = Image.open(BytesIO(base64.b64decode(b64)))
- img.load()
- return img
-
-
-@pytest.fixture(scope="session")
-def model_prefix() -> str:
- """Optional model-path prefix from MODEL_PREFIX env var.
- Useful if models are downloaded to non-default local directories.
- """
- prefix = os.environ.get("MODEL_PREFIX", "")
- return f"{prefix.rstrip('/')}/" if prefix else ""
-
-
-@pytest.fixture(autouse=True)
-def default_vllm_config():
- """Set a default VllmConfig for all tests.
-
- This fixture is auto-used for all tests to ensure that any test
- that directly instantiates vLLM CustomOps (e.g., RMSNorm, LayerNorm)
- or model components has the required VllmConfig context.
-
- This fixture is required for vLLM 0.14.0+ where CustomOp initialization
- requires a VllmConfig context set via set_current_vllm_config().
- """
- from vllm.config import DeviceConfig, VllmConfig, set_current_vllm_config
-
- # Use CPU device if no GPU is available (e.g., in CI environments)
- has_gpu = torch.cuda.is_available() and torch.cuda.device_count() > 0
- device = "cuda" if has_gpu else "cpu"
- device_config = DeviceConfig(device=device)
-
- with set_current_vllm_config(VllmConfig(device_config=device_config)):
- yield
-
-
-@pytest.fixture(autouse=True)
-def clean_gpu_memory_between_tests():
- print("\n=== PRE-TEST GPU CLEANUP ===")
- _run_pre_test_cleanup()
- yield
- _run_post_test_cleanup()
-
-
-@pytest.fixture(autouse=True)
-def log_test_name_before_test(request):
- print(f"--- Running test: {request.node.name}")
- yield
-
-
-def _run_pre_test_cleanup(enable_force=False):
- if os.getenv("VLLM_TEST_CLEAN_GPU_MEMORY", "0") != "1" and not enable_force:
- print("\nPre-test GPU cleanup skipped(Default off is typical when one worker/instance runs many tests.)\n")
- return
-
- print("\nPre-test GPU status:")
-
- num_gpus = torch.cuda.device_count()
- if num_gpus > 0:
- try:
- from tests.utils import wait_for_gpu_memory_to_clear
-
- wait_for_gpu_memory_to_clear(
- devices=list(range(num_gpus)),
- threshold_ratio=0.05,
- )
- except Exception as e:
- print(f"Pre-test cleanup note: {e}")
-
-
-def _run_post_test_cleanup(enable_force=False):
- if os.getenv("VLLM_TEST_CLEAN_GPU_MEMORY", "0") != "1" and not enable_force:
- print("GPU cleanup disabled")
- return
-
- if torch.cuda.is_available():
- gc.collect()
- torch.cuda.empty_cache()
-
- print("Post-test GPU status:")
- _print_gpu_processes()
-
-
-def _print_gpu_processes():
- """Print GPU information including nvidia-smi and system processes"""
-
- print("\n" + "=" * 80)
- print("NVIDIA GPU Information (nvidia-smi)")
- print("=" * 80)
-
- try:
- nvidia_result = subprocess.run(
- ["nvidia-smi"],
- capture_output=True,
- text=True,
- timeout=5,
- )
-
- if nvidia_result.returncode == 0:
- lines = nvidia_result.stdout.strip().split("\n")
- for line in lines[:20]:
- print(line)
-
- if len(lines) > 20:
- print(f"... (showing first 20 of {len(lines)} lines)")
- else:
- print("nvidia-smi command failed")
-
- except (subprocess.TimeoutExpired, FileNotFoundError):
- print("nvidia-smi not available or timed out")
- except Exception as e:
- print(f"Error running nvidia-smi: {e}")
-
- print("\n" + "=" * 80)
- print("Detailed GPU Processes (nvidia-smi pmon)")
- print("=" * 80)
-
- try:
- pmon_result = subprocess.run(
- ["nvidia-smi", "pmon", "-c", "1"],
- capture_output=True,
- text=True,
- timeout=3,
- )
-
- if pmon_result.returncode == 0 and pmon_result.stdout.strip():
- print(pmon_result.stdout)
- else:
- print("No active GPU processes found via nvidia-smi pmon")
-
- except Exception:
- print("nvidia-smi pmon not available")
-
- print("\n" + "=" * 80)
- print("System Processes with GPU keywords")
- print("=" * 80)
-
-
-def dummy_messages_from_mix_data(
- system_prompt: dict[str, Any] = None,
- video_data_url: Any = None,
- audio_data_url: Any = None,
- image_data_url: Any = None,
- content_text: str = None,
-):
- """Create messages with video、image、audio data URL for OpenAI API."""
-
- if content_text is not None:
- content = [{"type": "text", "text": content_text}]
- else:
- content = []
-
- media_items = []
- if isinstance(video_data_url, list):
- for video_url in video_data_url:
- media_items.append((video_url, "video"))
- else:
- media_items.append((video_data_url, "video"))
-
- if isinstance(image_data_url, list):
- for url in image_data_url:
- media_items.append((url, "image"))
- else:
- media_items.append((image_data_url, "image"))
-
- if isinstance(audio_data_url, list):
- for url in audio_data_url:
- media_items.append((url, "audio"))
- else:
- media_items.append((audio_data_url, "audio"))
-
- content.extend(
- {"type": f"{media_type}_url", f"{media_type}_url": {"url": url}}
- for url, media_type in media_items
- if url is not None
- )
- messages = [{"role": "user", "content": content}]
- if system_prompt is not None:
- messages = [system_prompt] + messages
- return messages
-
-
-def generate_synthetic_audio(
- duration: int, # seconds
- num_channels: int, # 1:Mono,2:Stereo 5:5.1 surround sound
- sample_rate: int = 48000, # Default use 48000Hz.
- save_to_file: bool = False,
-) -> dict[str, Any]:
- """
- Generate TTS speech with pyttsx3 and return base64 string.
- """
-
- import pyttsx3
- import soundfile as sf
-
- def _pick_voice(engine: pyttsx3.Engine) -> str | None:
- voices = engine.getProperty("voices")
- if not voices:
- return None
-
- preferred_tokens = (
- "natural",
- "jenny",
- "sonia",
- "susan",
- "zira",
- "aria",
- "hazel",
- "samantha",
- "ava",
- "allison",
- "female",
- "woman",
- "english-us",
- "en-us",
- "english",
- )
- discouraged_tokens = (
- "espeak",
- "robot",
- "mbrola",
- "microsoft david",
- "male",
- "man",
- )
-
- best_voice = voices[0]
- best_score = float("-inf")
- for voice in voices:
- voice_text = f"{getattr(voice, 'id', '')} {getattr(voice, 'name', '')}".lower()
- voice_languages = " ".join(
- lang.decode(errors="ignore") if isinstance(lang, bytes) else str(lang)
- for lang in getattr(voice, "languages", [])
- ).lower()
- combined_text = f"{voice_text} {voice_languages}"
- score = 0
- for idx, token in enumerate(preferred_tokens):
- if token in combined_text:
- score += 20 - idx
- for token in discouraged_tokens:
- if token in combined_text:
- score -= 10
- if "english" in combined_text or "en_" in combined_text or "en-" in combined_text:
- score += 4
- if "en-us" in combined_text or "english-us" in combined_text:
- score += 4
- if score > best_score:
- best_score = score
- best_voice = voice
-
- return best_voice.id
-
- def _resample_audio(audio: np.ndarray, src_sr: int, dst_sr: int) -> np.ndarray:
- if src_sr == dst_sr or len(audio) == 0:
- return audio.astype(np.float32)
-
- src_len = audio.shape[0]
- dst_len = max(1, int(round(src_len * float(dst_sr) / float(src_sr))))
- src_idx = np.arange(src_len, dtype=np.float32)
- dst_idx = np.linspace(0, src_len - 1, dst_len, dtype=np.float32)
-
- resampled_channels: list[np.ndarray] = []
- for ch in range(audio.shape[1]):
- resampled_channels.append(np.interp(dst_idx, src_idx, audio[:, ch]).astype(np.float32))
- return np.stack(resampled_channels, axis=1)
-
- def _match_channels(audio: np.ndarray, target_channels: int) -> np.ndarray:
- current_channels = audio.shape[1]
- if current_channels == target_channels:
- return audio.astype(np.float32)
- if target_channels == 1:
- return np.mean(audio, axis=1, keepdims=True, dtype=np.float32)
- if current_channels == 1:
- return np.repeat(audio, target_channels, axis=1).astype(np.float32)
-
- collapsed = np.mean(audio, axis=1, keepdims=True, dtype=np.float32)
- return np.repeat(collapsed, target_channels, axis=1).astype(np.float32)
-
- def _trim_silence(audio: np.ndarray, threshold: float = 0.01) -> np.ndarray:
- if len(audio) == 0:
- return audio
- energy = np.max(np.abs(audio), axis=1)
- voiced = np.where(energy > threshold)[0]
- if len(voiced) == 0:
- return audio
- start = max(0, int(voiced[0]) - int(sample_rate * 0.02))
- end = min(len(audio), int(voiced[-1]) + int(sample_rate * 0.04) + 1)
- return audio[start:end]
-
- def _enhance_speech(audio: np.ndarray) -> np.ndarray:
- if len(audio) == 0:
- return audio.astype(np.float32)
- enhanced = audio.astype(np.float32).copy()
- enhanced -= np.mean(enhanced, axis=0, keepdims=True, dtype=np.float32)
- if len(enhanced) > 1:
- preemphasis = enhanced.copy()
- preemphasis[1:] = enhanced[1:] - 0.94 * enhanced[:-1]
- enhanced = 0.7 * enhanced + 0.3 * preemphasis
- # Mild dynamic-range compression for ASR/TTS robustness.
- enhanced = np.sign(enhanced) * np.sqrt(np.abs(enhanced))
- # Light fade to avoid clicks after trimming/repeating.
- fade = min(len(enhanced) // 4, max(1, int(sample_rate * 0.01)))
- if fade > 1:
- ramp_in = np.linspace(0.0, 1.0, fade, dtype=np.float32)
- ramp_out = np.linspace(1.0, 0.0, fade, dtype=np.float32)
- enhanced[:fade] *= ramp_in[:, None]
- enhanced[-fade:] *= ramp_out[:, None]
- peak = float(np.max(np.abs(enhanced)))
- if peak > 1e-8:
- enhanced = enhanced / peak * 0.95
- return enhanced.astype(np.float32)
-
- phrase_text = "test"
- num_samples = int(sample_rate * max(1, duration))
- audio_data = np.zeros((num_samples, num_channels), dtype=np.float32)
-
- engine = pyttsx3.init()
- engine.setProperty("rate", 112)
- engine.setProperty("volume", 1.0)
- selected_voice = _pick_voice(engine)
- if selected_voice is not None:
- engine.setProperty("voice", selected_voice)
-
- temp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
- temp_wav.close()
-
- try:
- engine.save_to_file(phrase_text, temp_wav.name)
- engine.runAndWait()
- engine.stop()
-
- ready = False
- for _ in range(50):
- if os.path.exists(temp_wav.name) and os.path.getsize(temp_wav.name) > 44:
- ready = True
- break
- time.sleep(0.1)
-
- if not ready:
- raise RuntimeError("pyttsx3 did not produce a WAV file in time.")
-
- tts_audio, tts_sr = sf.read(temp_wav.name, dtype="float32", always_2d=True)
- finally:
- if os.path.exists(temp_wav.name):
- os.unlink(temp_wav.name)
-
- if len(tts_audio) == 0:
- raise RuntimeError("pyttsx3 produced an empty WAV file.")
-
- tts_audio = _resample_audio(tts_audio, tts_sr, sample_rate)
- tts_audio = _match_channels(tts_audio, num_channels)
- tts_audio = _trim_silence(tts_audio, threshold=0.012)
- tts_audio = _enhance_speech(tts_audio)
-
- lead_silence = min(int(sample_rate * 0.02), num_samples // 8)
- pause_samples = int(sample_rate * 0.18)
- start = lead_silence
- phrase_len = tts_audio.shape[0]
-
- while start < num_samples:
- take = min(phrase_len, num_samples - start)
- audio_data[start : start + take] = tts_audio[:take]
- start += phrase_len + pause_samples
-
- max_amp = float(np.max(np.abs(audio_data)))
- if max_amp > 0:
- audio_data = audio_data / max_amp * 0.95
-
- audio_bytes: bytes | None = None
- output_path: str | None = None
- result: dict[str, Any] = {
- "np_array": audio_data.copy(),
- }
-
- if save_to_file:
- timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
- output_path = f"audio_{num_channels}ch_{timestamp}.wav"
-
- try:
- sf.write(output_path, audio_data, sample_rate, format="WAV", subtype="PCM_16")
- print(f"Audio saved: {output_path}")
-
- with open(output_path, "rb") as f:
- audio_bytes = f.read()
- except Exception as e:
- print(f"Save failed: {e}")
- save_to_file = False
-
- # If not saving or save failed, create in memory
- if not save_to_file or audio_bytes is None:
- buffer = io.BytesIO()
- sf.write(buffer, audio_data, sample_rate, format="WAV", subtype="PCM_16")
- buffer.seek(0)
- audio_bytes = buffer.read()
-
- # Return result
- base64_audio = base64.b64encode(audio_bytes).decode("utf-8")
- result["base64"] = base64_audio
- # Always include file_path to avoid KeyError in callers.
- result["file_path"] = output_path if save_to_file and output_path else None
-
- return result
-
-
-def _mux_mp4_bytes_with_synthetic_audio(
- video_mp4_bytes: bytes,
- *,
- num_frames: int,
- fps: float = 30.0,
- sample_rate: int = 48000,
-) -> bytes:
- """
- Mux a video-only MP4 with mono TTS audio from :func:`generate_synthetic_audio` (AAC).
-
- Audio length is at least the video duration in whole seconds (rounded up); ffmpeg
- ``-shortest`` trims to the video when the WAV is longer.
-
- Uses ffmpeg from ``imageio_ffmpeg`` when available, else ``ffmpeg`` on PATH.
- If TTS or mux fails, returns ``video_mp4_bytes`` unchanged.
-
- Mux subprocess does **not** use ``capture_output=True``: ffmpeg can block writing
- to a full stderr pipe while :func:`subprocess.run` waits for exit (classic deadlock).
- """
- duration_sec = num_frames / fps if fps > 0 else 0.0
- # generate_synthetic_audio(duration=int) uses at least 1s of buffer internally
- duration_int = max(1, int(math.ceil(duration_sec)))
-
- try:
- audio_result = generate_synthetic_audio(
- duration=duration_int,
- num_channels=1,
- sample_rate=sample_rate,
- save_to_file=False,
- )
- audio_pcm = audio_result["np_array"]
- except Exception as e:
- logger.warning("Synthetic video: generate_synthetic_audio failed (%s); using video-only MP4.", e)
- return video_mp4_bytes
-
- try:
- import imageio_ffmpeg
-
- ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
- except Exception:
- ffmpeg_exe = "ffmpeg"
-
- import tempfile
-
- try:
- with tempfile.TemporaryDirectory(prefix="syn_vid_mux_") as tmp:
- vid_path = os.path.join(tmp, "video.mp4")
- wav_path = os.path.join(tmp, "audio.wav")
- out_path = os.path.join(tmp, "out.mp4")
- with open(vid_path, "wb") as f:
- f.write(video_mp4_bytes)
- sf.write(wav_path, audio_pcm, sample_rate, format="WAV", subtype="PCM_16")
- cmd = [
- ffmpeg_exe,
- "-y",
- "-nostdin",
- "-hide_banner",
- "-loglevel",
- "error",
- "-i",
- vid_path,
- "-i",
- wav_path,
- "-c:v",
- "copy",
- "-c:a",
- "aac",
- "-b:a",
- "128k",
- "-shortest",
- "-movflags",
- "+faststart",
- out_path,
- ]
- subprocess.run(
- cmd,
- check=True,
- stdin=subprocess.DEVNULL,
- timeout=300,
- )
- with open(out_path, "rb") as f:
- return f.read()
- except (
- FileNotFoundError,
- subprocess.CalledProcessError,
- subprocess.TimeoutExpired,
- OSError,
- ) as e:
- logger.warning("Synthetic video: audio mux failed (%s); using video-only MP4.", e)
- return video_mp4_bytes
-
-
-def generate_synthetic_video(
- width: int,
- height: int,
- num_frames: int,
- save_to_file: bool = False,
- *,
- embed_audio: bool = False,
-) -> dict[str, Any]:
- """Generate synthetic video with bouncing balls and base64 MP4.
-
- When ``embed_audio`` is True, muxes mono AAC from :func:`generate_synthetic_audio`
- (TTS + ffmpeg) into the MP4; otherwise returns video-only MP4 (faster when tests do
- not need an audio track).
- """
-
- import cv2
- import imageio
-
- # Create random balls
- num_balls = random.randint(3, 8)
- balls = []
-
- for _ in range(num_balls):
- radius = min(width, height) // 8
- if radius < 1:
- raise ValueError(f"Video dimensions ({width}x{height}) are too small for synthetic video generation")
- x = random.randint(radius, width - radius)
- y = random.randint(radius, height - radius)
-
- speed = random.uniform(3.0, 8.0)
- angle = random.uniform(0, 2 * math.pi)
- vx = speed * math.cos(angle)
- vy = speed * math.sin(angle)
-
- # OpenCV uses BGR format, but imageio expects RGB
- # We'll create in BGR first, then convert to RGB later
- color_bgr = (random.randint(50, 255), random.randint(50, 255), random.randint(50, 255))
-
- balls.append({"x": x, "y": y, "vx": vx, "vy": vy, "radius": radius, "color_bgr": color_bgr})
-
- # Generate video frames
- video_frames = []
-
- for frame_idx in range(num_frames):
- # Create black background (BGR format)
- frame_bgr = np.zeros((height, width, 3), dtype=np.uint8)
-
- for ball in balls:
- # Update position
- ball["x"] += ball["vx"]
- ball["y"] += ball["vy"]
-
- # Boundary collision detection
- if ball["x"] - ball["radius"] <= 0 or ball["x"] + ball["radius"] >= width:
- ball["vx"] = -ball["vx"]
- ball["x"] = max(ball["radius"], min(width - ball["radius"], ball["x"]))
-
- if ball["y"] - ball["radius"] <= 0 or ball["y"] + ball["radius"] >= height:
- ball["vy"] = -ball["vy"]
- ball["y"] = max(ball["radius"], min(height - ball["radius"], ball["y"]))
-
- # Use cv2 to draw circle
- x, y = int(ball["x"]), int(ball["y"])
- radius = ball["radius"]
-
- # Draw solid circle (main circle)
- cv2.circle(frame_bgr, (x, y), radius, ball["color_bgr"], -1)
-
- # Add simple 3D effect: draw a brighter center
- if radius > 3: # Only add highlight when radius is large enough
- highlight_radius = max(1, radius // 2)
- highlight_x = max(highlight_radius, min(x - radius // 4, width - highlight_radius))
- highlight_y = max(highlight_radius, min(y - radius // 4, height - highlight_radius))
-
- # Create highlight color (brighter)
- highlight_color = tuple(min(c + 40, 255) for c in ball["color_bgr"])
- cv2.circle(frame_bgr, (highlight_x, highlight_y), highlight_radius, highlight_color, -1)
-
- # Convert BGR to RGB for imageio
- frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
- video_frames.append(frame_rgb)
-
- video_array = np.array(video_frames)
- result = {
- "np_array": video_array,
- }
- saved_file_path = None
-
- fps = 30
- buffer = io.BytesIO()
- writer_kwargs = {
- "format": "mp4",
- "fps": fps,
- "codec": "libx264",
- "quality": 7,
- "pixelformat": "yuv420p",
- "macro_block_size": 16,
- "ffmpeg_params": [
- "-preset",
- "medium",
- "-crf",
- "23",
- "-movflags",
- "+faststart",
- "-pix_fmt",
- "yuv420p",
- "-vf",
- f"scale={width}:{height}",
- ],
- }
-
- try:
- with imageio.get_writer(buffer, **writer_kwargs) as writer:
- for frame in video_frames:
- writer.append_data(frame)
- buffer.seek(0)
- video_only_bytes = buffer.read()
- except Exception as e:
- print(f"Warning: Failed to encode synthetic video: {e}")
- raise
-
- if embed_audio:
- video_bytes = _mux_mp4_bytes_with_synthetic_audio(video_only_bytes, num_frames=num_frames, fps=float(fps))
- else:
- video_bytes = video_only_bytes
-
- if save_to_file:
- timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
- output_path = f"video_{width}x{height}_{timestamp}.mp4"
- try:
- with open(output_path, "wb") as f:
- f.write(video_bytes)
- saved_file_path = output_path
- print(f"Video saved to: {saved_file_path}")
- except Exception as e:
- print(f"Warning: Failed to save video to file {output_path}: {e}")
-
- base64_video = base64.b64encode(video_bytes).decode("utf-8")
-
- result["base64"] = base64_video
- if save_to_file and saved_file_path:
- result["file_path"] = saved_file_path
-
- return result
-
-
-def generate_synthetic_image(width: int, height: int, save_to_file: bool = False) -> dict[str, Any]:
- """Generate synthetic image with randomly colored squares and return base64 string."""
- from PIL import Image, ImageDraw
-
- # Create white background
- image = Image.new("RGB", (width, height), (255, 255, 255))
- draw = ImageDraw.Draw(image)
-
- # Generate random number of squares
- num_squares = random.randint(3, 8)
-
- for _ in range(num_squares):
- # Random square size
- square_size = random.randint(min(width, height) // 8, min(width, height) // 4)
-
- # Random position
- x = random.randint(0, width - square_size - 1)
- y = random.randint(0, height - square_size - 1)
-
- # Random color
- color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
-
- # Random border width
- border_width = random.randint(1, 5)
-
- # Draw square
- draw.rectangle([x, y, x + square_size, y + square_size], fill=color, outline=(0, 0, 0), width=border_width)
-
- image_array = np.array(image)
- result = {"np_array": image_array.copy()}
-
- # Handle file saving
- image_bytes = None
- saved_file_path = None
-
- if save_to_file:
- timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
- output_path = f"image_{width}x{height}_{timestamp}.jpg"
-
- try:
- # Save image to file
- image.save(output_path, format="JPEG", quality=85, optimize=True)
- saved_file_path = output_path
- print(f"Image saved to: {saved_file_path}")
-
- # Read file for base64 encoding
- with open(output_path, "rb") as f:
- image_bytes = f.read()
-
- except Exception as e:
- print(f"Warning: Failed to save image to file {output_path}: {e}")
- save_to_file = False
-
- # If not saving or save failed, create in memory
- if not save_to_file or image_bytes is None:
- buffer = io.BytesIO()
- image.save(buffer, format="JPEG", quality=85, optimize=True)
- buffer.seek(0)
- image_bytes = buffer.read()
-
- # Generate base64
- base64_image = base64.b64encode(image_bytes).decode("utf-8")
-
- # Return result
- result["base64"] = base64_image
- if save_to_file and saved_file_path:
- result["file_path"] = saved_file_path
-
- return result
-
-
-def preprocess_text(text):
- import opencc
-
- word_to_num = {
- "zero": "0",
- "one": "1",
- "two": "2",
- "three": "3",
- "four": "4",
- "five": "5",
- "six": "6",
- "seven": "7",
- "eight": "8",
- "nine": "9",
- "ten": "10",
- }
-
- for word, num in word_to_num.items():
- pattern = r"\b" + re.escape(word) + r"\b"
- text = re.sub(pattern, num, text, flags=re.IGNORECASE)
-
- text = re.sub(r"[^\w\s]", "", text)
- text = re.sub(r"\s+", " ", text)
- cc = opencc.OpenCC("t2s")
- text = cc.convert(text)
-
- # Special handling for spaces between Chinese characters:
- # - Keep single spaces between English words/numbers
- # - Remove spaces only when surrounded by Chinese characters on both sides to prevent incorrect word segmentation
- text = re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])", "", text)
-
- return text.lower().strip()
-
-
-def cosine_similarity_text(text1, text2, n: int = 3):
- from collections import Counter
-
- if not text1 or not text2:
- return 0.0
-
- text1 = preprocess_text(text1)
- text2 = preprocess_text(text2)
- print(f"cosine similarity text1 is: {text1}, text2 is: {text2}")
-
- ngrams1 = [text1[i : i + n] for i in range(len(text1) - n + 1)]
- ngrams2 = [text2[i : i + n] for i in range(len(text2) - n + 1)]
-
- counter1 = Counter(ngrams1)
- counter2 = Counter(ngrams2)
-
- all_ngrams = set(counter1.keys()) | set(counter2.keys())
- vec1 = [counter1.get(ng, 0) for ng in all_ngrams]
- vec2 = [counter2.get(ng, 0) for ng in all_ngrams]
-
- dot_product = sum(a * b for a, b in zip(vec1, vec2))
- norm1 = sum(a * a for a in vec1) ** 0.5
- norm2 = sum(b * b for b in vec2) ** 0.5
-
- if norm1 == 0 or norm2 == 0:
- return 0.0
- return dot_product / (norm1 * norm2)
-
-
-def convert_audio_to_text(audio_data):
- """
- Convert base64 encoded audio data to text using speech recognition.
- """
- audio_data = base64.b64decode(audio_data)
- output_path = f"./test_{uuid.uuid4().hex}.wav"
- with open(output_path, "wb") as audio_file:
- audio_file.write(audio_data)
-
- print(f"audio data is saved: {output_path}")
- text = convert_audio_file_to_text(output_path=output_path)
- return text
-
-
-def _merge_base64_audio_to_segment(base64_list: list[str]):
- """Merge a list of base64-encoded audio chunks into one pydub AudioSegment."""
- from pydub import AudioSegment
-
- merged = None
- for b64 in base64_list:
- raw = base64.b64decode(b64.split(",", 1)[-1])
- seg = AudioSegment.from_file(io.BytesIO(raw))
- merged = seg if merged is None else merged + seg
- return merged
-
-
-@contextlib.contextmanager
-def _serialize_whisper_small_model_download():
- """Serialize Whisper ``small`` cache writes across processes (Linux; ``fcntl``)."""
- import fcntl
-
- lock_path = Path.home() / ".cache" / "whisper" / ".small_model_download.lock"
- lock_path.parent.mkdir(parents=True, exist_ok=True)
- f = open(lock_path, "a+b")
- try:
- fcntl.flock(f.fileno(), fcntl.LOCK_EX)
- yield
- finally:
- fcntl.flock(f.fileno(), fcntl.LOCK_UN)
- f.close()
-
-
-def _whisper_transcribe_in_current_process(output_path: str) -> str:
- import whisper
-
- # Multi-GPU: use last visible device to avoid colliding with default device 0; single device uses 0.
- device_index = None
- if current_omni_platform.is_available():
- n = current_omni_platform.get_device_count()
- if n == 1:
- device_index = 0
- elif n > 1:
- device_index = n - 1
-
- if device_index is not None:
- torch_device = current_omni_platform.get_torch_device(device_index)
- current_omni_platform.set_device(torch_device)
- device = str(torch_device)
- use_accelerator = True
- else:
- use_accelerator = False
- device = "cpu"
- with _serialize_whisper_small_model_download():
- model = whisper.load_model("small", device=device)
- try:
- text = model.transcribe(
- output_path,
- temperature=0.0,
- word_timestamps=True,
- condition_on_previous_text=False,
- )["text"]
- finally:
- del model
- gc.collect()
- if use_accelerator:
- current_omni_platform.synchronize()
- current_omni_platform.empty_cache()
-
- return text or ""
-
-
-def convert_audio_file_to_text(output_path: str) -> str:
- """Convert an audio file to text in an isolated subprocess (spawn)."""
- ctx = multiprocessing.get_context("spawn")
- with concurrent.futures.ProcessPoolExecutor(max_workers=1, mp_context=ctx) as executor:
- future = executor.submit(_whisper_transcribe_in_current_process, output_path)
- return future.result()
-
-
-def convert_audio_bytes_to_text(raw_bytes: bytes) -> str:
- """
- Write container audio bytes (WAV, etc.) to a temp WAV file suitable for Whisper/ffmpeg.
- Normalizes with soundfile to PCM_16 WAV when possible to avoid codec issues.
- """
- output_path = f"./test_{uuid.uuid4().hex}.wav"
- data, samplerate = sf.read(io.BytesIO(raw_bytes))
- sf.write(output_path, data, samplerate, format="WAV", subtype="PCM_16")
- text = convert_audio_file_to_text(output_path)
- return text
-
-
-def modify_stage_config(
- yaml_path: str,
- updates: dict[str, Any] = None,
- deletes: dict[str, Any] = None,
-) -> str:
- """
- Modify configurations in a YAML file, supporting both top-level and stage-specific modifications,
- including addition, modification, and deletion of configurations.
-
- Args:
- yaml_path: Path to the YAML configuration file.
- updates: Dictionary containing both top-level and stage-specific modifications to add or update.
- Format: {
- 'async_chunk': True,
- 'stage_args': {
- 0: {'engine_args.max_model_len': 5800},
- 1: {'engine_args.max_num_seqs': 2}
- }
- }
- deletes: Dictionary containing configurations to delete.
- Format: {
- 'old_config': None, # Delete entire key
- 'stage_args': {
- 0: ['engine_args.old_param'],
- 1: ['runtime.unused_setting']
- }
- }
-
- Returns:
- str: Path to the newly created modified YAML file with timestamp suffix.
- """
- path = Path(yaml_path)
- if not path.exists():
- raise FileNotFoundError(f"yaml does not exist: {path}")
-
- try:
- with open(yaml_path, encoding="utf-8") as f:
- config = yaml.safe_load(f) or {}
- except Exception as e:
- raise ValueError(f"Cannot parse YAML file: {e}")
-
- # Helper function to apply update
- def apply_update(config_dict: dict, key_path: str, value: Any) -> None:
- """Apply update to dictionary using dot-separated path."""
- # Handle direct list assignment (e.g., engine_input_source: [1, 2])
- if "." not in key_path:
- # Simple key, set directly
- config_dict[key_path] = value
- return
-
- current = config_dict
- keys = key_path.split(".")
-
- for i in range(len(keys) - 1):
- key = keys[i]
-
- # Handle list indices
- if key.isdigit() and isinstance(current, list):
- index = int(key)
- if index < 0:
- raise ValueError(f"Negative list index not allowed: {index}")
- if index >= len(current):
- # Expand list if needed
- while len(current) <= index:
- # If we need to go deeper (more keys after this), create a dict
- # Otherwise, create None placeholder
- current.append({} if i < len(keys) - 2 else None)
- current = current[index]
- elif isinstance(current, dict):
- # Handle dictionary keys
- if key not in current:
- # If there are more keys after this, create appropriate structure
- if i < len(keys) - 1:
- # Check if next key is a digit (list index) or string (dict key)
- if keys[i + 1].isdigit():
- current[key] = []
- else:
- current[key] = {}
- else:
- # This is the last key, create based on value type
- current[key] = [] if isinstance(value, list) else {}
- elif not isinstance(current[key], (dict, list)) and i < len(keys) - 1:
- # If current value is not dict/list but we need to go deeper, replace it
- if keys[i + 1].isdigit():
- current[key] = []
- else:
- current[key] = {}
- current = current[key]
- else:
- # Current is not a dict or list, cannot traverse further
- raise TypeError(
- f"Cannot access {'.'.join(keys[: i + 1])} as a dict/list. It's a {type(current).__name__}"
- )
-
- # Set the final value
- last_key = keys[-1]
- if isinstance(current, list) and last_key.isdigit():
- # Setting a value in a list by index
- index = int(last_key)
- if index < 0:
- raise ValueError(f"Negative list index not allowed: {index}")
- if index >= len(current):
- # Expand list if needed
- while len(current) <= index:
- current.append(None)
- current[index] = value
- elif isinstance(current, dict):
- # Special case: if the value is a list and we're setting a top-level key
- # Example: updating engine_input_source with [1, 2]
- current[last_key] = value
- else:
- # Current is not a dict, cannot set key
- raise TypeError(f"Cannot set value at {key_path}. Current type is {type(current).__name__}, expected dict.")
-
- # Helper function to delete by path
- def delete_by_path(config_dict: dict, path: str) -> None:
- """Delete configuration by dot-separated path."""
- if not path:
- return
-
- current = config_dict
- keys = path.split(".")
-
- # Traverse to the parent
- for i in range(len(keys) - 1):
- key = keys[i]
-
- # Handle list indices
- if key.isdigit() and isinstance(current, list):
- index = int(key)
- if index < 0 or index >= len(current):
- raise KeyError(f"List index {index} out of bounds")
- current = current[index]
- elif isinstance(current, dict):
- if key not in current:
- raise KeyError(f"Path {'.'.join(keys[: i + 1])} does not exist")
- current = current[key]
- else:
- raise TypeError(
- f"Cannot access {'.'.join(keys[: i + 1])} as a dict/list. It's a {type(current).__name__}"
- )
-
- # Delete the item
- last_key = keys[-1]
-
- if isinstance(current, list) and last_key.isdigit():
- index = int(last_key)
- if index < 0 or index >= len(current):
- raise KeyError(f"List index {index} out of bounds")
- del current[index]
- elif isinstance(current, dict) and last_key in current:
- del current[last_key]
- else:
- print(f"Path {path} does not exist")
-
- # Apply deletions first
- if deletes:
- for key, value in deletes.items():
- if key == "stage_args":
- if value and isinstance(value, dict):
- stage_args = config.get("stage_args", [])
- if not stage_args:
- raise ValueError("stage_args does not exist in config")
-
- for stage_id, delete_paths in value.items():
- if not delete_paths:
- continue
-
- # Find stage by ID
- target_stage = None
- for stage in stage_args:
- if stage.get("stage_id") == int(stage_id):
- target_stage = stage
- break
-
- if target_stage is None:
- continue
-
- # Delete specified paths in this stage
- # Avoid shadowing the original YAML Path used for the output filename below.
- for delete_path in delete_paths:
- if delete_path: # Skip empty paths
- delete_by_path(target_stage, delete_path)
- elif "." in key:
- # Delete using dot-separated path
- delete_by_path(config, key)
- elif value is None and key in config:
- # Delete entire key
- del config[key]
-
- # Apply updates
- if updates:
- for key, value in updates.items():
- if key == "stage_args":
- if value and isinstance(value, dict):
- stage_args = config.get("stage_args", [])
- if not stage_args:
- raise ValueError("stage_args does not exist in config")
-
- for stage_id, stage_updates in value.items():
- # Find stage by ID
- target_stage = None
- for stage in stage_args:
- if stage.get("stage_id") == int(stage_id):
- target_stage = stage
- break
-
- if target_stage is None:
- available_ids = [s.get("stage_id") for s in stage_args if "stage_id" in s]
- raise KeyError(f"Stage ID {stage_id} not found, available: {available_ids}")
-
- # Apply updates to this stage
- for update_path, val in stage_updates.items():
- # Check if this is a simple key (not dot-separated)
- # Example: 'engine_input_source' vs 'engine_args.max_model_len'
- if "." not in update_path:
- # Direct key assignment (e.g., updating a list value)
- target_stage[update_path] = val
- else:
- # Dot-separated path (e.g., nested dict access)
- apply_update(target_stage, update_path, val)
- elif "." in key:
- # Apply using dot-separated path
- apply_update(config, key, value)
- else:
- # Direct top-level key
- config[key] = value
-
- # Unique suffix: multiple modify_stage_config calls in one process often run
- # within the same second (e.g. test_qwen3_omni_expansion imports both
- # get_chunk_config and get_batch_token_config). int(time.time()) would collide
- # and the later write would overwrite the earlier YAML on disk.
- # Keep generated configs outside the repo and delete them when pytest exits.
- output_fd, output_path = tempfile.mkstemp(prefix=f"{path.stem}_", suffix=".yaml")
- atexit.register(Path(output_path).unlink, missing_ok=True)
-
- with os.fdopen(output_fd, "w", encoding="utf-8") as f:
- yaml.dump(config, f, default_flow_style=None, sort_keys=False, allow_unicode=True, indent=2)
-
- return str(output_path)
-
-
-class OmniServer:
- """Omniserver for vLLM-Omni tests."""
-
- def __init__(
- self,
- model: str,
- serve_args: list[str],
- *,
- port: int | None = None,
- env_dict: dict[str, str] | None = None,
- use_omni: bool = True,
- ) -> None:
- _run_pre_test_cleanup(enable_force=True)
- _run_post_test_cleanup(enable_force=True)
- cleanup_dist_env_and_memory()
- self.model = model
- self.serve_args = serve_args
- self.env_dict = env_dict
- self.use_omni = use_omni
- self.proc: subprocess.Popen | None = None
- self.host = "127.0.0.1"
- if port is None:
- self.port = get_open_port()
- else:
- self.port = port
-
- def _start_server(self) -> None:
- """Start the vLLM-Omni server subprocess."""
- env = os.environ.copy()
- env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
- if self.env_dict is not None:
- env.update(self.env_dict)
-
- cmd = [
- sys.executable,
- "-m",
- "vllm_omni.entrypoints.cli.main",
- "serve",
- self.model,
- "--host",
- self.host,
- "--port",
- str(self.port),
- ]
- if self.use_omni:
- cmd.append("--omni")
- cmd += self.serve_args
-
- print(f"Launching OmniServer with: {' '.join(cmd)}")
- self.proc = subprocess.Popen(
- cmd,
- env=env,
- cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), # Set working directory to vllm-omni root
- )
-
- # Wait for server to be ready
- max_wait = 1200 # 20 minutes
- start_time = time.time()
- while time.time() - start_time < max_wait:
- # Check for process status
- ret = self.proc.poll()
- if ret is not None:
- raise RuntimeError(f"Server processes exited with code {ret} before becoming ready.")
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
- sock.settimeout(1)
- result = sock.connect_ex((self.host, self.port))
- if result == 0:
- print(f"Server ready on {self.host}:{self.port}")
- return
- time.sleep(2)
-
- raise RuntimeError(f"Server failed to start within {max_wait} seconds")
-
- def _kill_process_tree(self, pid):
- """kill process and its children with verification"""
- try:
- parent = psutil.Process(pid)
- children = parent.children(recursive=True)
-
- # Get all PIDs first
- all_pids = [pid] + [child.pid for child in children]
-
- # Terminate children
- for child in children:
- try:
- child.terminate()
- except psutil.NoSuchProcess:
- pass
-
- # Wait for children
- gone, still_alive = psutil.wait_procs(children, timeout=10)
-
- # Kill remaining children
- for child in still_alive:
- try:
- child.kill()
- except psutil.NoSuchProcess:
- pass
-
- # Terminate parent
- try:
- parent.terminate()
- parent.wait(timeout=10)
- except (psutil.NoSuchProcess, psutil.TimeoutExpired):
- try:
- parent.kill()
- except psutil.NoSuchProcess:
- pass
-
- # VERIFICATION: Check if all processes are gone
- time.sleep(1) # Give system time
- alive_processes = []
- for check_pid in all_pids:
- if psutil.pid_exists(check_pid):
- alive_processes.append(check_pid)
-
- if alive_processes:
- print(f"Warning: Processes still alive: {alive_processes}")
- # Optional: Try system kill
- import subprocess
-
- for alive_pid in alive_processes:
- try:
- subprocess.run(["kill", "-9", str(alive_pid)], timeout=2)
- except Exception as e:
- print(f"Cleanup failed: {e}")
-
- except psutil.NoSuchProcess:
- pass
-
- def __enter__(self):
- self._start_server()
- return self
-
- def __exit__(self, exc_type, exc_val, exc_tb):
- if self.proc:
- self._kill_process_tree(self.proc.pid)
- _run_pre_test_cleanup(enable_force=True)
- _run_post_test_cleanup(enable_force=True)
- cleanup_dist_env_and_memory()
-
-
-class OmniServerStageCli(OmniServer):
- """Omni server harness that exercises the stage CLI flow."""
-
- def __init__(
- self,
- model: str,
- stage_config_path: str,
- serve_args: list[str] | None = None,
- *,
- stage_ids: list[int] | None = None,
- port: int | None = None,
- env_dict: dict[str, str] | None = None,
- ) -> None:
- super().__init__(model, serve_args or [], port=port, env_dict=env_dict, use_omni=True)
- self.stage_config_path = stage_config_path
- self.master_port = get_open_port()
- self.visible_device_list = self._load_visible_device_list(env_dict)
- self.stage_runtime_devices = self._load_stage_runtime_devices(stage_config_path)
- self.stage_ids = stage_ids or self._load_stage_ids(stage_config_path)
- if 0 not in self.stage_ids:
- raise ValueError(f"Stage CLI test requires stage_id=0 in config: {stage_config_path}")
- self.stage_procs: dict[int, subprocess.Popen] = {}
- self.proc = None
-
- @staticmethod
- def _load_stage_ids(stage_config_path: str) -> list[int]:
- with open(stage_config_path, encoding="utf-8") as f:
- cfg = yaml.safe_load(f) or {}
-
- stage_ids = [stage["stage_id"] for stage in cfg.get("stage_args", []) if "stage_id" in stage]
- if not stage_ids:
- raise ValueError(f"No stage IDs found in config: {stage_config_path}")
- return stage_ids
-
- @staticmethod
- def _load_stage_runtime_devices(stage_config_path: str) -> dict[int, str]:
- with open(stage_config_path, encoding="utf-8") as f:
- cfg = yaml.safe_load(f) or {}
-
- runtime_devices: dict[int, str] = {}
- for stage in cfg.get("stage_args", []):
- stage_id = stage.get("stage_id")
- devices = stage.get("runtime", {}).get("devices")
- if stage_id is not None and devices:
- runtime_devices[int(stage_id)] = str(devices)
- return runtime_devices
-
- @classmethod
- def _parse_device_list(cls, devices: str | int) -> list[str]:
- if isinstance(devices, int):
- if devices < 0:
- raise ValueError("Device IDs must be non-negative integers")
- return [str(devices)]
- return [token.strip() for token in str(devices).split(",") if token.strip()]
-
- @classmethod
- def _load_visible_device_list(cls, env_dict: dict[str, str] | None) -> list[str] | None:
- env = os.environ.copy()
- if env_dict is not None:
- env.update(env_dict)
-
- env_var = getattr(current_omni_platform, "device_control_env_var", None)
- if env_var and env_var in env:
- return [token.strip() for token in env[env_var].split(",") if token.strip()]
- return None
-
- @classmethod
- def _map_stage_devices(cls, stage_id: int, visible_device_list: list[str] | None, devices: str) -> str:
- device_list = cls._parse_device_list(devices)
-
- if visible_device_list is None:
- return ",".join(device_list)
-
- if not all(device.isdigit() for device in device_list):
- raise ValueError("Logical devices must be non-negative integers")
-
- logical_ids = [int(device) for device in device_list]
- if logical_ids and max(logical_ids) >= len(visible_device_list):
- raise ValueError(
- f"Stage {stage_id} has logical IDs {device_list}, one or more of which exceed the number of visible devices"
- )
-
- return ",".join(visible_device_list[idx] for idx in logical_ids)
-
- def _set_stage_device_env(self, stage_id: int, env: dict[str, str], devices: str) -> None:
- mapped_devices = self._map_stage_devices(stage_id, self.visible_device_list, devices)
- env_var = getattr(current_omni_platform, "device_control_env_var", None)
- if env_var:
- env[env_var] = mapped_devices
-
- def _build_stage_cmd(self, stage_id: int, *, headless: bool) -> list[str]:
- cmd = [
- sys.executable,
- "-m",
- "vllm_omni.entrypoints.cli.main",
- "serve",
- self.model,
- "--omni",
- "--stage-configs-path",
- self.stage_config_path,
- "--stage-id",
- str(stage_id),
- "--omni-master-address",
- self.host,
- "--omni-master-port",
- str(self.master_port),
- ]
-
- if headless:
- cmd.append("--headless")
- else:
- cmd += ["--host", self.host, "--port", str(self.port)]
-
- cmd += self.serve_args
- return cmd
-
- def _launch_stage(self, stage_id: int, *, headless: bool) -> None:
- env = os.environ.copy()
- env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
- 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)
-
- cmd = self._build_stage_cmd(stage_id, headless=headless)
- print(f"Launching OmniServerStageCli stage {stage_id}: {' '.join(cmd)}")
- proc = subprocess.Popen(
- cmd,
- env=env,
- cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
- )
- self.stage_procs[stage_id] = proc
- if stage_id == 0:
- self.proc = proc
-
- def _ensure_stage_processes_alive(self) -> None:
- for stage_id, proc in self.stage_procs.items():
- ret = proc.poll()
- if ret is not None:
- raise RuntimeError(f"Stage {stage_id} exited with code {ret} before API server became ready.")
-
- def _start_server(self) -> None:
- ordered_stage_ids = [0, *[stage_id for stage_id in self.stage_ids if stage_id != 0]]
-
- self._launch_stage(0, headless=False)
- time.sleep(2)
- self._ensure_stage_processes_alive()
-
- for stage_id in ordered_stage_ids[1:]:
- self._launch_stage(stage_id, headless=True)
-
- max_wait = 1200
- start_time = time.time()
- while time.time() - start_time < max_wait:
- self._ensure_stage_processes_alive()
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
- sock.settimeout(1)
- result = sock.connect_ex((self.host, self.port))
- if result == 0:
- print(f"OmniServerStageCli ready on {self.host}:{self.port}")
- return
- time.sleep(2)
-
- raise RuntimeError(f"OmniServerStageCli failed to start within {max_wait} seconds")
-
- def __exit__(self, exc_type, exc_val, exc_tb):
- for stage_id in sorted(self.stage_procs, reverse=True):
- proc = self.stage_procs[stage_id]
- if proc.poll() is None:
- self._kill_process_tree(proc.pid)
- _run_pre_test_cleanup(enable_force=True)
- _run_post_test_cleanup(enable_force=True)
- cleanup_dist_env_and_memory()
-
-
-def pytest_addoption(parser):
- parser.addoption(
- "--run-level",
- action="store",
- default="core_model",
- choices=["core_model", "advanced_model"],
- help="Test level to run: L2, L3",
- )
-
-
-@pytest.fixture(scope="session")
-def run_level(request) -> str:
- """A command-line argument that specifies the level of tests to run in this session.
- See https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/CI_5levels/"""
- return request.config.getoption("--run-level")
-
-
-_omni_server_lock = threading.Lock()
-
-
-@pytest.fixture(scope="module")
-def omni_server(request: pytest.FixtureRequest, run_level: str, model_prefix: str) -> Generator[OmniServer, Any, None]:
- """Start vLLM-Omni through the standard or stage-CLI launcher.
-
- The fixture stays module-scoped because multi-stage initialization is costly.
- The ``use_stage_cli`` flag on ``OmniServerParams`` routes the setup through the
- stage-CLI harness while still reusing the same fixture grouping semantics.
- """
- with _omni_server_lock:
- params: OmniServerParams = request.param
- model = model_prefix + params.model
- port = params.port
- stage_config_path = params.stage_config_path
- if run_level == "advanced_model" and stage_config_path is not None:
- with open(stage_config_path, encoding="utf-8") as f:
- cfg = yaml.safe_load(f) or {}
- stage_ids = [stage["stage_id"] for stage in cfg.get("stage_args", []) if "stage_id" in stage]
- stage_config_path = modify_stage_config(
- stage_config_path,
- deletes={"stage_args": {stage_id: ["engine_args.load_format"] for stage_id in stage_ids}},
- )
-
- server_args = params.server_args or []
- if params.use_omni and params.stage_init_timeout is not None:
- server_args = [*server_args, "--stage-init-timeout", str(params.stage_init_timeout)]
- else:
- server_args = [*server_args, "--stage-init-timeout", "600"]
- if params.init_timeout is not None:
- server_args = [*server_args, "--init-timeout", str(params.init_timeout)]
- else:
- server_args = [*server_args, "--init-timeout", "900"]
- if params.use_stage_cli:
- if not params.use_omni:
- raise ValueError("omni_server with use_stage_cli=True requires use_omni=True")
- if stage_config_path is None:
- raise ValueError("omni_server with use_stage_cli=True requires a stage_config_path")
-
- with OmniServerStageCli(
- model,
- stage_config_path,
- server_args,
- port=port,
- env_dict=params.env_dict,
- ) as server:
- print("OmniServer started successfully")
- yield server
- print("OmniServer stopping...")
- else:
- if stage_config_path is not None:
- server_args += ["--stage-configs-path", stage_config_path]
-
- with (
- OmniServer(
- model,
- server_args,
- port=port,
- env_dict=params.env_dict,
- use_omni=params.use_omni,
- )
- if port
- else OmniServer(
- model,
- server_args,
- env_dict=params.env_dict,
- use_omni=params.use_omni,
- )
- ) as server:
- print("OmniServer started successfully")
- yield server
- print("OmniServer stopping...")
-
- print("OmniServer stopped")
-
-
-@dataclass
-class OmniResponse:
- text_content: str | None = None
- audio_data: list[str] | None = None
- audio_content: str | None = None
- audio_format: str | None = None
- audio_bytes: bytes | None = None
- similarity: float | None = None
- e2e_latency: float | None = None
- success: bool = False
- error_message: str | None = None
-
-
-@dataclass
-class DiffusionResponse:
- text_content: str | None = None
- images: list[Image.Image] | None = None
- audios: list[Any] | None = None
- videos: list[Any] | None = None
- e2e_latency: float | None = None
- success: bool = False
- error_message: str | None = None
-
-
-def _load_gender_pipeline():
- """
- Lazy-load a cached audio-classification pipeline for gender.
-
- We prefer the pipeline wrapper because it encapsulates processor/model loading
- and avoids direct AutoProcessor.from_pretrained call sites in this file.
- """
- global _GENDER_PIPELINE
- if _GENDER_PIPELINE is not None:
- return _GENDER_PIPELINE
-
- model_name = "7wolf/wav2vec2-base-gender-classification"
- try:
- # device=-1 forces CPU for pipeline.
- _GENDER_PIPELINE = pipeline(
- task="audio-classification",
- model=model_name,
- device=-1,
- )
- return _GENDER_PIPELINE
- except Exception as exc: # pragma: no cover - best-effort fallback
- print(f"Warning: failed to create gender pipeline '{model_name}': {exc}")
- _GENDER_PIPELINE = None
- return None
-
-
-def _median_pitch_hz_from_autocorr(mono: np.ndarray, sr: int) -> float | None:
- """
- Rough median F0 (Hz) over short-time frames. Used to debias wav2vec2 gender head on TTS,
- which often labels lower-pitched synthetic speech as female under load or on clean signals.
- Returns None if the clip is too short or mostly unvoiced.
- """
- x = np.asarray(mono, dtype=np.float64)
- x = x - np.mean(x)
- if x.size < int(0.15 * sr):
- return None
- frame_len = int(0.04 * sr)
- hop = max(frame_len // 2, 1)
- f0_min_hz, f0_max_hz = 70.0, 400.0
- lag_min = max(1, int(sr / f0_max_hz))
- lag_max = min(frame_len - 2, int(sr / f0_min_hz))
- if lag_max <= lag_min:
- return None
- win = np.hamming(frame_len)
- pitches: list[float] = []
- for start in range(0, int(x.shape[0]) - frame_len, hop):
- frame = x[start : start + frame_len] * win
- frame = frame - np.mean(frame)
- if float(np.sqrt(np.mean(frame**2))) < 1e-4:
- continue
- ac = np.correlate(frame, frame, mode="full")[frame_len - 1 :]
- ac = ac / (float(ac[0]) + 1e-12)
- region = ac[lag_min : lag_max + 1]
- peak_rel = int(np.argmax(region))
- peak_lag = peak_rel + lag_min
- if peak_lag <= 0:
- continue
- f0 = float(sr) / float(peak_lag)
- if f0_min_hz <= f0 <= f0_max_hz:
- pitches.append(f0)
- if len(pitches) < 4:
- return None
- return float(np.median(np.asarray(pitches, dtype=np.float64)))
-
-
-def _estimate_voice_gender_from_audio(audio_bytes: bytes) -> str:
- """
- Estimate voice gender from audio using a small pre-trained classification model.
-
- Uses a cached `audio-classification` pipeline to classify the clip.
- Returns 'male' / 'female' when the model confidence is >= 0.9 and the label
- maps to one of these; otherwise returns 'unknown'. If the model is unavailable
- or inference fails, returns 'unknown' to keep tests stable.
-
- Under concurrent tests, a global lock serializes pipeline calls (the HF pipeline is not
- thread-safe). A coarse F0 median can correct systematic "male -> female" errors on TTS audio.
- """
- data, sr = sf.read(io.BytesIO(audio_bytes), dtype="float32", always_2d=True)
- if data.size == 0:
- raise ValueError("Empty audio")
- mono = np.mean(data, axis=1)
-
- try:
- target_sr = 16000
- if int(sr) != target_sr and mono.size > 1:
- src_len = int(mono.shape[0])
- dst_len = max(1, int(round(src_len * float(target_sr) / float(sr))))
- src_idx = np.arange(src_len, dtype=np.float32)
- dst_idx = np.linspace(0, src_len - 1, dst_len, dtype=np.float32)
- mono = np.interp(dst_idx, src_idx, mono.astype(np.float32, copy=False)).astype(np.float32)
- sr = target_sr
-
- median_f0 = _median_pitch_hz_from_autocorr(mono, sr)
-
- clf = _load_gender_pipeline()
- if clf is None:
- print("gender model not available, returning 'unknown'")
- return "unknown"
-
- # transformers pipeline returns a list of {label, score} (highest score first).
- with _GENDER_PIPELINE_LOCK:
- outputs = clf(mono, sampling_rate=sr)
- if not outputs:
- return "unknown"
-
- top = outputs[0]
- label = str(top.get("label", "")).lower()
- conf = float(top.get("score", 0.0))
-
- if conf < 0.5:
- gender = "unknown"
- # Some models use non-English labels (e.g., Russian). Normalize to 'male'/'female'.
- elif ("female" in label) or ("жен" in label):
- gender = "female"
- elif ("male" in label) or ("муж" in label):
- gender = "male"
- else:
- gender = "unknown"
-
- # Debias: wav2vec2 gender heads often call TTS / band-limited male speech "female".
- # Low median F0 (~speech male range) + female label -> trust pitch when score is not overwhelming.
- if gender == "female" and median_f0 is not None and median_f0 < 165.0 and conf < 0.88:
- print(f"gender pitch assist: reclassifying female->male (median_f0={median_f0:.1f} Hz, conf={conf:.3f})")
- gender = "male"
- elif gender == "male" and median_f0 is not None and median_f0 > 230.0 and conf < 0.88:
- print(f"gender pitch assist: reclassifying male->female (median_f0={median_f0:.1f} Hz, conf={conf:.3f})")
- gender = "female"
-
- print(
- f"gender classifier: label={label}, conf={conf:.3f}, gender={gender}"
- + (f", median_f0={median_f0:.1f}Hz" if median_f0 is not None else "")
- )
- return gender
- except Exception as exc: # pragma: no cover - best-effort fallback
- print(f"Warning: gender classification failed, returning 'unknown': {exc}")
- return "unknown"
-
-
-_PRESET_VOICE_GENDER_MAP: dict[str, str] = {
- "serena": "female",
- "uncle_fu": "male",
- "chelsie": "female",
- "clone": "female",
- "ethan": "male",
+"""
+Root pytest entrypoint for the vLLM-Omni test suite.
+
+- `tests/conftest.py` stays thin: plugin registration + compatibility re-exports.
+- Importable utilities live under `tests/helpers/`.
+- Fixtures live under `tests/helpers/fixtures/` and are loaded via `pytest_plugins`.
+"""
+
+from __future__ import annotations
+
+pytest_plugins = (
+ "tests.helpers.fixtures.env",
+ "tests.helpers.fixtures.log",
+ "tests.helpers.fixtures.run_args",
+ "tests.helpers.fixtures.runtime",
+)
+
+
+def pytest_terminal_summary(terminalreporter, exitstatus, config):
+ # Marker for Buildkite log folding before pytest summary lines.
+ terminalreporter.write_line("--- Running Summary")
+
+
+# Backward-compatible lazy re-exports.
+# (Many tests still import from `tests.conftest`; migrate these imports to `tests.helpers.*` over time.)
+# Keep these lazy so conftest import does not trigger heavy helper dependencies.
+_ASSERTION_EXPORT_NAMES = (
+ "assert_audio_speech_response",
+ "assert_diffusion_response",
+ "assert_image_diffusion_response",
+ "assert_image_valid",
+ "assert_omni_response",
+ "assert_video_diffusion_response",
+ "assert_video_valid",
+)
+_MEDIA_EXPORT_NAMES = (
+ "convert_audio_bytes_to_text",
+ "convert_audio_file_to_text",
+ "cosine_similarity_text",
+ "decode_b64_image",
+ "generate_synthetic_audio",
+ "generate_synthetic_image",
+ "generate_synthetic_video",
+)
+_STAGE_CONFIG_EXPORT_NAMES = ("modify_stage_config",)
+_RUNTIME_EXPORT_NAMES = (
+ "DiffusionResponse",
+ "OmniResponse",
+ "OmniRunner",
+ "OmniRunnerHandler",
+ "OmniServer",
+ "OmniServerParams",
+ "OmniServerStageCli",
+ "OpenAIClientHandler",
+ "dummy_messages_from_mix_data",
+)
+_LAZY_EXPORT_MODULES = {
+ **{name: "tests.helpers.assertions" for name in _ASSERTION_EXPORT_NAMES},
+ **{name: "tests.helpers.media" for name in _MEDIA_EXPORT_NAMES},
+ **{name: "tests.helpers.stage_config" for name in _STAGE_CONFIG_EXPORT_NAMES},
+ **{name: "tests.helpers.runtime" for name in _RUNTIME_EXPORT_NAMES},
}
-
-
-def _assert_preset_voice_gender_from_audio(
- audio_bytes: bytes | None,
- voice_name: str | None,
-) -> None:
- """If ``voice_name`` matches a known preset, assert classifier gender matches (skip when unknown)."""
- if not voice_name or not audio_bytes:
- return
- key = str(voice_name).lower()
- expected_gender = _PRESET_VOICE_GENDER_MAP.get(key)
- if expected_gender is None:
- return
- estimated_gender = _estimate_voice_gender_from_audio(audio_bytes)
- print(f"Preset voice gender check: preset={key!r}, estimated={estimated_gender!r}, expected={expected_gender!r}")
- if estimated_gender != "unknown":
- assert estimated_gender == expected_gender, (
- f"{voice_name!r} is expected {expected_gender}, but estimated gender is {estimated_gender!r}"
- )
-
-
-# Threshold aligned with _compute_pcm_hnr_db docstring (clean clone vs distorted).
-_MIN_PCM_SPEECH_HNR_DB = 1.0
-
-
-def _compute_pcm_hnr_db(pcm_samples: np.ndarray, sr: int = _PCM_SPEECH_SAMPLE_RATE_HZ) -> float:
- """Compute mean Harmonic-to-Noise Ratio (dB) for speech quality.
-
- Clean cloned speech has HNR > 1.2 dB; distorted speech (e.g. lost
- ref_code decoder context) drops below 1.0 dB.
- """
- frame_len = int(0.03 * sr) # 30ms frames
- hop = frame_len // 2
- hnr_values: list[float] = []
-
- for start in range(0, len(pcm_samples) - frame_len, hop):
- frame = pcm_samples[start : start + frame_len].astype(np.float32, copy=False)
- frame = frame - np.mean(frame)
- if np.max(np.abs(frame)) < 0.01:
- continue
- ac = np.correlate(frame, frame, mode="full")[len(frame) - 1 :]
- ac = ac / (ac[0] + 1e-10)
- min_lag = int(sr / 400)
- max_lag = min(int(sr / 80), len(ac))
- if min_lag >= max_lag:
- continue
- peak = float(np.max(ac[min_lag:max_lag]))
- if 0 < peak < 1:
- hnr_values.append(10 * np.log10(peak / (1 - peak + 1e-10)))
-
- return float(np.mean(hnr_values)) if hnr_values else 0.0
-
-
-def _assert_pcm_int16_speech_hnr(audio_bytes: bytes) -> None:
- """Validate harmonic-to-noise ratio on raw int16 PCM from /v1/audio/speech."""
- assert audio_bytes is not None and len(audio_bytes) >= 2, "missing PCM bytes"
- assert len(audio_bytes) % 2 == 0, "PCM byte length must be aligned to int16"
- pcm_samples = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
- hnr = _compute_pcm_hnr_db(pcm_samples)
- print(f"PCM speech HNR: {hnr:.2f} dB (threshold: {_MIN_PCM_SPEECH_HNR_DB} dB)")
- assert hnr >= _MIN_PCM_SPEECH_HNR_DB, (
- f"Audio distortion detected: HNR={hnr:.2f} dB < {_MIN_PCM_SPEECH_HNR_DB} dB. "
- "Voice clone decoder may be losing ref_code speaker context on later chunks."
- )
-
-
-def assert_omni_response(response: OmniResponse, request_config: dict[str, Any], run_level):
- """
- Validate response results.
-
- Args:
- response: OmniResponse object
-
- Raises:
- AssertionError: When the response does not meet validation criteria
- """
- assert response.success, "The request failed."
- e2e_latency = response.e2e_latency
- if e2e_latency is not None:
- print(f"the e2e latency is: {e2e_latency}")
-
- modalities = request_config.get("modalities", ["text", "audio"])
-
- if run_level == "advanced_model":
- if "audio" in modalities:
- assert response.audio_content is not None, "No audio output is generated"
- print(f"audio content is: {response.audio_content}")
- speaker = request_config.get("speaker")
- if speaker:
- _assert_preset_voice_gender_from_audio(
- response.audio_bytes,
- speaker,
- )
-
- if "text" in modalities:
- assert response.text_content is not None, "No text output is generated"
- print(f"text content is: {response.text_content}")
-
- # Verify image description
- word_types = ["text", "image", "audio", "video"]
- keywords_dict = request_config.get("key_words", {})
- for word_type in word_types:
- keywords = keywords_dict.get(word_type)
- if "text" in modalities:
- if keywords:
- text_lower = response.text_content.lower()
- assert any(str(kw).lower() in text_lower for kw in keywords), (
- "The output does not contain any of the keywords."
- )
- else:
- if keywords:
- audio_lower = response.audio_content.lower()
- assert any(str(kw).lower() in audio_lower for kw in keywords), (
- "The output does not contain any of the keywords."
- )
-
- # Verify similarity (Whisper transcript vs streamed/detokenized text)
- if "text" in modalities and "audio" in modalities:
- assert response.similarity is not None and response.similarity > 0.9, (
- "The audio content is not same as the text"
- )
- print(f"similarity is: {response.similarity}")
-
-
-def assert_audio_speech_response(
- response: OmniResponse,
- request_config: dict[str, Any],
- run_level: str,
-) -> None:
- """
- Validate /v1/audio/speech response: success, optional format check, transcription similarity
- and gender (non-PCM only for advanced_model), and int16 PCM HNR when response_format is pcm.
- """
- assert response.success, "The request failed."
-
- req_fmt = request_config.get("response_format")
-
- if req_fmt == "pcm" and response.audio_bytes:
- _assert_pcm_int16_speech_hnr(response.audio_bytes)
- if response.audio_format:
- assert "pcm" in response.audio_format.lower(), (
- f"Expected audio/pcm content-type, got {response.audio_format!r}"
- )
-
- elif req_fmt == "wav" and response.audio_format:
- assert req_fmt in response.audio_format, (
- f"The response audio format {response.audio_format} don't match the request audio format {req_fmt}"
- )
-
- e2e_latency = response.e2e_latency
- if e2e_latency is not None:
- print(f"the avg e2e latency is: {e2e_latency}")
-
- if run_level == "advanced_model" and req_fmt != "pcm":
- # Text–audio semantic similarity check (skipped for raw PCM: no Whisper transcript).
- expected_text = request_config.get("input")
- if expected_text:
- transcript = (response.audio_content or "").strip()
- print(f"audio content is: {transcript}")
- print(f"input text is: {expected_text}")
- similarity = cosine_similarity_text(transcript.lower(), expected_text.lower())
- print(f"Cosine similarity: {similarity:.3f}")
- assert similarity > 0.9, (
- f"Transcript doesn't match input: similarity={similarity:.2f}, transcript='{transcript}'"
- )
-
- # Voice gender consistency check (preset names in ``_PRESET_VOICE_GENDER_MAP``).
- # When the estimator returns 'unknown', we treat it as inconclusive and do NOT fail the test.
- _assert_preset_voice_gender_from_audio(
- response.audio_bytes,
- request_config.get("voice"),
- )
-
-
-def assert_diffusion_response(response: DiffusionResponse, request_config: dict[str, Any], run_level: str = None):
- """
- Validate diffusion response results.
-
- Dispatcher that routes validation to modality-specific assert functions.
-
- Args:
- response: DiffusionResponse object.
- request_config: Request configuration dictionary.
- run_level: Test run level (e.g. "core_model", "advanced_model")
-
- Raises:
- AssertionError: When the response does not meet validation criteria
- KeyError: When the request_config does not contain necessary parameters for validation
- """
- assert response.success, "The request failed."
-
- e2e_latency = response.e2e_latency
- if e2e_latency is not None:
- print(f"the avg e2e is: {e2e_latency}")
-
- has_any_content = any(content is not None for content in (response.images, response.videos, response.audios))
- assert has_any_content, "Response contains no images, videos, or audios"
-
- if response.images is not None:
- assert_image_diffusion_response(
- response=response,
- request_config=request_config,
- run_level=run_level,
- )
-
- if response.videos is not None:
- assert_video_diffusion_response(
- response=response,
- request_config=request_config,
- run_level=run_level,
- )
-
- if response.audios is not None:
- assert_audio_diffusion_response(
- response=response,
- request_config=request_config,
- run_level=run_level,
- )
-
-
-class OpenAIClientHandler:
- """
- OpenAI client handler class, encapsulating both streaming and non-streaming response processing logic.
-
- This class integrates OpenAI API request sending, response handling, and validation functionality,
- supporting both single request and concurrent request modes.
- """
-
- def __init__(
- self, host: str = "127.0.0.1", port: int = get_open_port(), api_key: str = "EMPTY", run_level: str = None
- ):
- """
- Initialize the OpenAI client.
-
- Args:
- host: vLLM-Omni server host address
- port: vLLM-Omni server port
- api_key: API key (defaults to "EMPTY")
- """
- self.base_url = f"http://{host}:{port}"
- self.client = OpenAI(base_url=f"http://{host}:{port}/v1", api_key=api_key)
- self.run_level = run_level
-
- def _process_stream_omni_response(self, chat_completion) -> OmniResponse:
- """
- Process streaming responses.
-
- Args:
- chat_completion: OpenAI streaming response object
- request_config: Request configuration dictionary
-
- Returns:
- OmniResponse: Processed response object
- """
- result = OmniResponse()
- start_time = time.perf_counter()
-
- try:
- text_content = ""
- audio_data = []
-
- for chunk in chat_completion:
- for choice in chunk.choices:
- # Get content data
- if hasattr(choice, "delta"):
- content = getattr(choice.delta, "content", None)
- else:
- content = None
-
- # Get modality type
- modality = getattr(chunk, "modality", None)
-
- # Process content based on modality type
- if modality == "audio" and content:
- audio_data.append(content)
- elif modality == "text" and content:
- text_content += content if content else ""
-
- # Calculate end-to-end latency
- result.e2e_latency = time.perf_counter() - start_time
-
- # Process audio and text content
- audio_content = None
- similarity = None
-
- if audio_data or text_content:
- if audio_data:
- merged_seg = _merge_base64_audio_to_segment(audio_data)
- wav_buf = BytesIO()
- merged_seg.export(wav_buf, format="wav")
- result.audio_bytes = wav_buf.getvalue()
- audio_content = convert_audio_bytes_to_text(result.audio_bytes)
- if audio_content and text_content:
- similarity = cosine_similarity_text(audio_content.lower(), text_content.lower())
-
- # Populate result object
- result.text_content = text_content
- result.audio_data = audio_data
- result.audio_content = audio_content
- result.similarity = similarity
- result.success = True
-
- except Exception as e:
- result.error_message = f"Stream processing error: {str(e)}"
- print(f"Error: {result.error_message}")
-
- return result
-
- def _process_non_stream_omni_response(self, chat_completion) -> OmniResponse:
- """
- Process non-streaming responses.
-
- Args:
- chat_completion: OpenAI non-streaming response object
- request_config: Request configuration dictionary
-
- Returns:
- OmniResponse: Processed response object
- """
- result = OmniResponse()
- start_time = time.perf_counter()
-
- try:
- audio_data = None
- text_content = None
-
- # Iterate through all choices
- for choice in chat_completion.choices:
- # Process audio data
- if hasattr(choice.message, "audio") and choice.message.audio is not None:
- audio_message = choice.message
- audio_data = audio_message.audio.data
-
- # Process text content
- if hasattr(choice.message, "content") and choice.message.content is not None:
- text_content = choice.message.content
-
- # Calculate end-to-end latency
- result.e2e_latency = time.perf_counter() - start_time
-
- # Process audio and text content
- audio_content = None
- similarity = None
-
- if audio_data or text_content:
- if audio_data:
- result.audio_bytes = base64.b64decode(audio_data)
- audio_content = convert_audio_bytes_to_text(result.audio_bytes)
- if audio_content and text_content:
- similarity = cosine_similarity_text(audio_content.lower(), text_content.lower())
-
- # Populate result object
- result.text_content = text_content
- result.audio_content = audio_content
- result.similarity = similarity
- result.success = True
-
- except Exception as e:
- result.error_message = f"Non-stream processing error: {str(e)}"
- print(f"Error: {result.error_message}")
-
- return result
-
- def _process_diffusion_response(self, chat_completion) -> DiffusionResponse:
- """
- Process diffusion responses (image generation/editing).
-
- Args:
- chat_completion: OpenAI response object
-
- Returns:
- DiffusionResponse: Processed response object
- """
- result = DiffusionResponse()
- start_time = time.perf_counter()
-
- try:
- images = []
- # [TODO] reading video and audio output from API response for later validation
-
- for choice in chat_completion.choices:
- if hasattr(choice.message, "content") and choice.message.content is not None:
- content = choice.message.content
- if isinstance(content, list):
- for item in content:
- if isinstance(item, dict):
- image_url = item.get("image_url", {}).get("url")
- else:
- image_url_obj = getattr(item, "image_url", None)
- image_url = hasattr(image_url_obj, "url", None) if image_url_obj else None
- if image_url and image_url.startswith("data:image"):
- b64_data = image_url.split(",", 1)[1]
- img = decode_b64_image(b64_data)
- images.append(img)
-
- result.e2e_latency = time.perf_counter() - start_time
- result.images = images if images else None
- result.success = True
-
- except Exception as e:
- result.error_message = f"Diffusion response processing error: {str(e)}"
- print(f"Error: {result.error_message}")
-
- return result
-
- def _process_stream_audio_speech_response(self, response, *, response_format: str | None = None) -> OmniResponse:
- """
- Process streaming /v1/audio/speech responses into an OmniResponse.
-
- This mirrors _process_stream_omni_response but operates on low-level
- audio bytes and produces an OmniResponse with audio_content filled
- from Whisper transcription.
- """
- result = OmniResponse()
- start_time = time.perf_counter()
-
- try:
- # Aggregate all audio bytes from the streaming response.
- data = bytearray()
-
- # Preferred OpenAI helper.
- if hasattr(response, "iter_bytes") and callable(getattr(response, "iter_bytes")):
- for chunk in response.iter_bytes():
- if chunk:
- data.extend(chunk)
- else:
- # Generic iterable-of-bytes fallback (e.g., generator or list of chunks).
- try:
- iterator = iter(response)
- except TypeError:
- iterator = None
-
- if iterator is not None:
- for chunk in iterator:
- if not chunk:
- continue
- if isinstance(chunk, (bytes, bytearray)):
- data.extend(chunk)
- elif hasattr(chunk, "data"):
- data.extend(chunk.data) # type: ignore[arg-type]
- elif hasattr(chunk, "content"):
- data.extend(chunk.content) # type: ignore[arg-type]
- else:
- raise TypeError(f"Unsupported stream chunk type: {type(chunk)}")
- else:
- raise TypeError(f"Unsupported audio speech streaming response type: {type(response)}")
-
- raw_bytes = bytes(data)
- if response_format == "pcm":
- transcript = None
- else:
- transcript = convert_audio_bytes_to_text(raw_bytes)
-
- # Populate OmniResponse.
- result.audio_bytes = raw_bytes
- result.audio_content = transcript
- result.e2e_latency = time.perf_counter() - start_time
- result.success = True
- result.audio_format = getattr(response, "response", None)
- if result.audio_format is not None:
- result.audio_format = result.audio_format.headers.get("content-type", "")
-
- except Exception as e:
- result.error_message = f"Audio speech stream processing error: {str(e)}"
- print(f"Error: {result.error_message}")
-
- return result
-
- def _process_non_stream_audio_speech_response(
- self, response, *, response_format: str | None = None
- ) -> OmniResponse:
- """
- Process non-streaming /v1/audio/speech responses into an OmniResponse.
-
- This mirrors _process_non_stream_omni_response but for the binary
- audio payload returned by audio.speech.create.
- """
- result = OmniResponse()
- start_time = time.perf_counter()
-
- try:
- # OpenAI non-streaming audio.speech.create returns HttpxBinaryResponseContent (.read() or .content)
- if hasattr(response, "read") and callable(getattr(response, "read")):
- raw_bytes = response.read()
- elif hasattr(response, "content"):
- raw_bytes = response.content # type: ignore[assignment]
- else:
- raise TypeError(f"Unsupported audio speech response type: {type(response)}")
-
- if response_format == "pcm":
- transcript = None
- else:
- transcript = convert_audio_bytes_to_text(raw_bytes)
-
- result.audio_bytes = raw_bytes
- result.audio_content = transcript
- result.e2e_latency = time.perf_counter() - start_time
- result.success = True
- result.audio_format = getattr(response, "response", None)
- if result.audio_format is not None:
- result.audio_format = result.audio_format.headers.get("content-type", "")
-
- except Exception as e:
- result.error_message = f"Audio speech non-stream processing error: {str(e)}"
- print(f"Error: {result.error_message}")
-
- return result
-
- def send_omni_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[OmniResponse]:
- """
- Send OpenAI requests.
-
- Args:
- request_config: Request configuration dictionary containing parameters like model, messages, stream.
- Optional ``use_audio_in_video`` (bool): when true, sets
- ``extra_body["mm_processor_kwargs"] = {"use_audio_in_video": True}`` for Qwen-Omni video+audio
- extraction.
- Optional top-level ``speaker`` (str): Qwen3-Omni preset TTS speaker name; sent as
- ``extra_body["speaker"]`` to ``chat.completions.create``.
- request_num: Number of requests, defaults to 1 (single request)
-
- Returns:
- List[OmniResponse]: List of response objects
- """
-
- responses = []
- stream = request_config.get("stream", False)
- modalities = request_config.get("modalities", ["text", "audio"])
-
- extra_body: dict[str, Any] = {}
- if "speaker" in request_config:
- extra_body["speaker"] = request_config["speaker"]
- if request_config.get("use_audio_in_video"):
- mm = dict(extra_body.get("mm_processor_kwargs") or {})
- mm["use_audio_in_video"] = True
- extra_body["mm_processor_kwargs"] = mm
- extra_body_arg: dict[str, Any] | None = extra_body if extra_body else None
-
- create_kwargs: dict[str, Any] = {
- "model": request_config.get("model"),
- "messages": request_config.get("messages"),
- "stream": stream,
- "modalities": modalities,
- }
- if extra_body_arg is not None:
- create_kwargs["extra_body"] = extra_body_arg
-
- if request_num == 1:
- # Send single request
- chat_completion = self.client.chat.completions.create(**create_kwargs)
-
- if stream:
- response = self._process_stream_omni_response(chat_completion)
- else:
- response = self._process_non_stream_omni_response(chat_completion)
-
- assert_omni_response(response, request_config, run_level=self.run_level)
- responses.append(response)
-
- else:
- # Send concurrent requests: run create + process in worker so e2e_latency includes full round-trip.
- def _one_omni_request():
- start = time.perf_counter()
- worker_kwargs: dict[str, Any] = {
- "model": request_config.get("model"),
- "messages": request_config.get("messages"),
- "modalities": modalities,
- "stream": stream,
- }
- if extra_body_arg is not None:
- worker_kwargs["extra_body"] = extra_body_arg
- chat_completion = self.client.chat.completions.create(**worker_kwargs)
- if stream:
- response = self._process_stream_omni_response(chat_completion)
- else:
- response = self._process_non_stream_omni_response(chat_completion)
- response.e2e_latency = time.perf_counter() - start
- return response
-
- with concurrent.futures.ThreadPoolExecutor(max_workers=request_num) as executor:
- futures = [executor.submit(_one_omni_request) for _ in range(request_num)]
- for future in concurrent.futures.as_completed(futures):
- response = future.result()
- assert_omni_response(response, request_config, run_level=self.run_level)
- responses.append(response)
-
- return responses
-
- def send_audio_speech_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[OmniResponse]:
- """
- Call the /v1/audio/speech endpoint using the same configuration-dict
- style as send_omni_request, but via the OpenAI Python client's
- audio.speech APIs.
-
- Expected keys in request_config:
- - model: model name/path (required)
- - input: text to synthesize (required)
- - response_format: audio format such as "wav" or "pcm" (optional)
- - task_type, ref_text, ref_audio: TTS-specific extras (optional, passed via extra_body)
- - timeout: request timeout in seconds (float, optional, default 120.0)
- - stream: whether to use streaming API (bool, optional, default False)
- """
- timeout = float(request_config.get("timeout", 120.0))
-
- model = request_config["model"]
- text_input = request_config["input"]
- stream = bool(request_config.get("stream", False))
- voice = request_config.get("voice", None)
-
- # Standard OpenAI param: use omit when not provided to keep default behavior.
- response_format = request_config.get("response_format", omit)
-
- # 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"):
- if key in request_config:
- extra_body[key] = request_config[key]
-
- responses: list[OmniResponse] = []
-
- speech_fmt: str | None = None if response_format is omit else str(response_format).lower()
-
- if request_num == 1:
- if stream:
- # Use streaming response helper.
- with self.client.audio.speech.with_streaming_response.create(
- model=model,
- input=text_input,
- response_format=response_format,
- extra_body=extra_body or None,
- timeout=timeout,
- voice=voice,
- ) as resp:
- omni_resp = self._process_stream_audio_speech_response(resp, response_format=speech_fmt)
- else:
- # Non-streaming response.
- resp = self.client.audio.speech.create(
- model=model,
- input=text_input,
- response_format=response_format,
- extra_body=extra_body or None,
- timeout=timeout,
- voice=voice,
- )
- omni_resp = self._process_non_stream_audio_speech_response(resp, response_format=speech_fmt)
-
- assert_audio_speech_response(omni_resp, request_config, run_level=self.run_level)
- responses.append(omni_resp)
- return responses
- else:
- # request_num > 1: concurrent requests (use same params as single-request path)
-
- if stream:
-
- def _stream_task():
- with self.client.audio.speech.with_streaming_response.create(
- model=model,
- input=text_input,
- response_format=response_format,
- extra_body=extra_body or None,
- timeout=timeout,
- voice=voice,
- ) as resp:
- return self._process_stream_audio_speech_response(resp, response_format=speech_fmt)
-
- with concurrent.futures.ThreadPoolExecutor(max_workers=request_num) as executor:
- futures = [executor.submit(_stream_task) for _ in range(request_num)]
- for future in concurrent.futures.as_completed(futures):
- omni_resp = future.result()
- assert_audio_speech_response(omni_resp, request_config, run_level=self.run_level)
- responses.append(omni_resp)
- else:
- with concurrent.futures.ThreadPoolExecutor(max_workers=request_num) as executor:
- futures = []
- for _ in range(request_num):
- future = executor.submit(
- self.client.audio.speech.create,
- model=model,
- input=text_input,
- response_format=response_format,
- extra_body=extra_body or None,
- timeout=timeout,
- voice=voice,
- )
- futures.append(future)
-
- for future in concurrent.futures.as_completed(futures):
- resp = future.result()
- omni_resp = self._process_non_stream_audio_speech_response(resp, response_format=speech_fmt)
- assert_audio_speech_response(omni_resp, request_config, run_level=self.run_level)
- responses.append(omni_resp)
-
- return responses
-
- def send_diffusion_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[OmniResponse]:
- """
- Send OpenAI requests for diffusion models.
-
- Args:
- request_config: Request configuration dictionary containing parameters like model, messages
- request_num: Number of requests to send concurrently, defaults to 1 (single request)
- Returns:
- List[OmniResponse]: List of response objects
- """
- responses = []
- stream = request_config.get("stream", False)
- modalities = request_config.get("modalities", omit) # Most diffusion models don't require modalities param
- extra_body = request_config.get("extra_body", None)
-
- if stream:
- raise NotImplementedError("Streaming is not currently implemented for diffusion model e2e test")
-
- if request_num == 1:
- # Send single request
- chat_completion = self.client.chat.completions.create(
- model=request_config.get("model"),
- messages=request_config.get("messages"),
- extra_body=extra_body,
- modalities=modalities,
- )
-
- response = self._process_diffusion_response(chat_completion)
- assert_diffusion_response(response, request_config, run_level=self.run_level)
- responses.append(response)
-
- else:
- # Send concurrent requests
- with concurrent.futures.ThreadPoolExecutor(max_workers=request_num) as executor:
- futures = []
-
- # Submit all request tasks
- for _ in range(request_num):
- future = executor.submit(
- self.client.chat.completions.create,
- model=request_config.get("model"),
- messages=request_config.get("messages"),
- modalities=modalities,
- extra_body=extra_body,
- )
- futures.append(future)
-
- # Process completed tasks
- for future in concurrent.futures.as_completed(futures):
- chat_completion = future.result()
- response = self._process_diffusion_response(chat_completion)
- assert_diffusion_response(response, request_config, run_level=self.run_level)
- responses.append(response)
-
- return responses
-
- def send_video_diffusion_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[OmniResponse]:
- """
- Send native /v1/videos requests.
- """
- if request_num != 1:
- raise NotImplementedError("Concurrent video diffusion requests are not currently implemented")
-
- if request_config.get("stream", False):
- raise NotImplementedError("Streaming is not currently implemented for video diffusion e2e test")
-
- form_data = request_config.get("form_data")
- if not isinstance(form_data, dict):
- raise ValueError("Video request_config must contain 'form_data'")
-
- if not form_data.get("prompt"):
- raise ValueError("Video request_config['form_data'] must contain 'prompt'")
-
- normalized_form_data = {key: str(value) for key, value in form_data.items() if value is not None}
-
- files: dict[str, tuple[str, BytesIO, str]] = {}
- image_reference = request_config.get("image_reference")
- if image_reference:
- if image_reference.startswith("data:image"):
- header, encoded = image_reference.split(",", 1)
- content_type = header.split(";")[0].removeprefix("data:")
- extension = content_type.split("/")[-1]
- file_data = base64.b64decode(encoded)
-
- files["input_reference"] = (
- f"reference.{extension}",
- BytesIO(file_data),
- content_type,
- )
- else:
- normalized_form_data["image_reference"] = json.dumps({"image_url": image_reference})
-
- result = DiffusionResponse()
- start_time = time.perf_counter()
-
- try:
- create_url = self._build_url("/v1/videos")
- response = requests.post(
- create_url,
- data=normalized_form_data,
- files=files,
- headers={"Accept": "application/json"},
- timeout=60,
- )
- response.raise_for_status()
-
- job_data = response.json()
- video_id = job_data["id"]
-
- self._wait_until_video_completed(video_id)
-
- video_content = self._download_video_content(video_id)
-
- result.success = True
- result.videos = [video_content]
- result.e2e_latency = time.perf_counter() - start_time
-
- assert_diffusion_response(result, request_config, run_level=self.run_level)
-
- except Exception as e:
- result.success = False
- result.error_message = f"Diffusion response processing error: {e}"
- assert False, result.error_message
-
- return [result]
-
- def _wait_until_video_completed(
- self,
- video_id: str,
- poll_interval_seconds: int = 2,
- timeout_seconds: int = 300,
- ) -> None:
- status_url = self._build_url(f"/v1/videos/{video_id}")
- deadline = time.monotonic() + timeout_seconds
-
- while time.monotonic() < deadline:
- status_resp = requests.get(
- status_url,
- headers={"Accept": "application/json"},
- timeout=30,
- )
- status_resp.raise_for_status()
-
- status_data = status_resp.json()
- current_status = status_data["status"]
-
- if current_status == "completed":
- return
-
- if current_status == "failed":
- error_msg = status_data.get("last_error", "Unknown error")
- raise RuntimeError(f"Job failed: {error_msg}")
-
- time.sleep(poll_interval_seconds)
-
- raise TimeoutError(f"Video job {video_id} did not complete within {timeout_seconds}s")
-
- def _download_video_content(self, video_id: str) -> bytes:
- download_url = self._build_url(f"/v1/videos/{video_id}/content")
- video_resp = requests.get(download_url, stream=True, timeout=60)
- video_resp.raise_for_status()
-
- video_bytes = BytesIO()
- for chunk in video_resp.iter_content(chunk_size=8192):
- if chunk:
- video_bytes.write(chunk)
-
- return video_bytes.getvalue()
-
- def _build_url(self, path: str) -> str:
- return f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
-
-
-@pytest.fixture
-def openai_client(request: pytest.FixtureRequest, run_level: str):
- """Create OpenAIClientHandler fixture to facilitate communication with OmniServer
- with encapsulated request sending, concurrent requests, response handling, and validation."""
- server = request.getfixturevalue("omni_server")
- return OpenAIClientHandler(host=server.host, port=server.port, api_key="EMPTY", run_level=run_level)
-
-
-class OmniRunner:
- """
- Offline test runner for Omni models.
- """
-
- def __init__(
- self,
- model_name: str,
- seed: int = 42,
- stage_init_timeout: int = 600,
- batch_timeout: int = 10,
- init_timeout: int = 900,
- shm_threshold_bytes: int = 65536,
- log_stats: bool = False,
- stage_configs_path: str | None = None,
- **kwargs,
- ) -> None:
- """
- Initialize an OmniRunner for testing.
-
- Args:
- model_name: The model name or path
- seed: Random seed for reproducibility
- stage_init_timeout: Timeout for initializing a single stage in seconds
- batch_timeout: Timeout for batching in seconds
- init_timeout: Timeout for initializing stages in seconds
- shm_threshold_bytes: Threshold for using shared memory
- log_stats: Enable detailed statistics logging
- stage_configs_path: Optional path to YAML stage config file
- **kwargs: Additional arguments passed to Omni
- """
- cleanup_dist_env_and_memory()
- _run_pre_test_cleanup(enable_force=True)
- _run_post_test_cleanup(enable_force=True)
- self.model_name = model_name
- self.seed = seed
-
- self.omni = Omni(
- model=model_name,
- log_stats=log_stats,
- stage_init_timeout=stage_init_timeout,
- batch_timeout=batch_timeout,
- init_timeout=init_timeout,
- shm_threshold_bytes=shm_threshold_bytes,
- stage_configs_path=stage_configs_path,
- **kwargs,
- )
-
- def _estimate_prompt_len(
- self,
- additional_information: dict[str, Any],
- model_name: str,
- _cache: dict[str, Any] = {},
- ) -> int:
- """Estimate prompt_token_ids placeholder length for the Talker stage.
-
- The AR Talker replaces all input embeddings via ``preprocess``, so the
- placeholder values are irrelevant but the **length** must match the
- embeddings that ``preprocess`` will produce.
- """
- try:
- from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import Qwen3TTSConfig
- from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
- Qwen3TTSTalkerForConditionalGeneration,
- )
-
- if model_name not in _cache:
- from transformers import AutoTokenizer
-
- tok = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True, padding_side="left")
- cfg = Qwen3TTSConfig.from_pretrained(model_name, trust_remote_code=True)
- _cache[model_name] = (tok, getattr(cfg, "talker_config", None))
-
- tok, tcfg = _cache[model_name]
- task_type = (additional_information.get("task_type") or ["CustomVoice"])[0]
- return Qwen3TTSTalkerForConditionalGeneration.estimate_prompt_len_from_additional_information(
- additional_information=additional_information,
- task_type=task_type,
- tokenize_prompt=lambda t: tok(t, padding=False)["input_ids"],
- codec_language_id=getattr(tcfg, "codec_language_id", None),
- spk_is_dialect=getattr(tcfg, "spk_is_dialect", None),
- )
- except Exception as exc:
- logger.warning("Failed to estimate prompt length, using fallback 2048: %s", exc)
- return 2048
-
- def get_default_sampling_params_list(self) -> list[OmniSamplingParams]:
- """
- Get a list of default sampling parameters for all stages.
-
- Returns:
- List of SamplingParams with default decoding for each stage
- """
- if not hasattr(self.omni, "default_sampling_params_list"):
- raise AttributeError("Omni.default_sampling_params_list is not available")
- return list(self.omni.default_sampling_params_list)
-
- def get_omni_inputs(
- self,
- prompts: list[str] | str,
- system_prompt: str | None = None,
- audios: PromptAudioInput = None,
- images: PromptImageInput = None,
- videos: PromptVideoInput = None,
- mm_processor_kwargs: dict[str, Any] | None = None,
- modalities: list[str] | None = None,
- ) -> list[TextPrompt]:
- """
- Construct Omni input format from prompts and multimodal data.
-
- Args:
- prompts: Text prompt(s) - either a single string or list of strings
- system_prompt: Optional system prompt (defaults to Qwen system prompt)
- audios: Audio input(s) - tuple of (audio_array, sample_rate) or list of tuples
- images: Image input(s) - PIL Image or list of PIL Images
- videos: Video input(s) - numpy array or list of numpy arrays
- mm_processor_kwargs: Optional processor kwargs (e.g., use_audio_in_video)
-
- Returns:
- List of prompt dictionaries suitable for Omni.generate()
- """
- if system_prompt is None:
- system_prompt = (
- "You are Qwen, a virtual human developed by the Qwen Team, Alibaba "
- "Group, capable of perceiving auditory and visual inputs, as well as "
- "generating text and speech."
- )
-
- video_padding_token = "<|VIDEO|>"
- image_padding_token = "<|IMAGE|>"
- audio_padding_token = "<|AUDIO|>"
-
- if "Qwen3-Omni-30B-A3B-Instruct" in self.model_name:
- video_padding_token = "<|video_pad|>"
- image_padding_token = "<|image_pad|>"
- audio_padding_token = "<|audio_pad|>"
-
- if isinstance(prompts, str):
- prompts = [prompts]
-
- # Qwen-TTS: follow examples/offline_inference/qwen3_tts/end2end.py style.
- # Stage 0 expects token placeholders + additional_information (text/speaker/task_type/...),
- # and Talker replaces embeddings in preprocess based on additional_information only.
- is_tts_model = "Qwen3-TTS" in self.model_name or "qwen3_tts" in self.model_name.lower()
- if is_tts_model and modalities == ["audio"]:
- tts_kw = mm_processor_kwargs or {}
- task_type = tts_kw.get("task_type", "CustomVoice")
- speaker = tts_kw.get("speaker", "Vivian")
- language = tts_kw.get("language", "Auto")
- max_new_tokens = int(tts_kw.get("max_new_tokens", 2048))
- ref_audio = tts_kw.get("ref_audio", None)
- ref_text = tts_kw.get("ref_text", None)
-
- omni_inputs: list[TextPrompt] = []
- for prompt_text in prompts:
- text_str = str(prompt_text).strip() or " "
- additional_information: dict[str, Any] = {
- "task_type": [task_type],
- "text": [text_str],
- "language": [language],
- "speaker": [speaker],
- "max_new_tokens": [max_new_tokens],
- }
- if ref_audio is not None:
- additional_information["ref_audio"] = [ref_audio]
- if ref_text is not None:
- additional_information["ref_text"] = [ref_text]
- # Use official helper to get correct placeholder length
- plen = self._estimate_prompt_len(additional_information, self.model_name)
- input_dict: TextPrompt = {
- "prompt_token_ids": [0] * plen,
- "additional_information": additional_information,
- }
- omni_inputs.append(input_dict)
- return omni_inputs
-
- def _normalize_mm_input(mm_input, num_prompts):
- if mm_input is None:
- return [None] * num_prompts
- if isinstance(mm_input, list):
- if len(mm_input) != num_prompts:
- raise ValueError(
- f"Multimodal input list length ({len(mm_input)}) must match prompts length ({num_prompts})"
- )
- return mm_input
- return [mm_input] * num_prompts
-
- num_prompts = len(prompts)
- audios_list = _normalize_mm_input(audios, num_prompts)
- images_list = _normalize_mm_input(images, num_prompts)
- videos_list = _normalize_mm_input(videos, num_prompts)
-
- omni_inputs = []
- for i, prompt_text in enumerate(prompts):
- user_content = ""
- multi_modal_data = {}
-
- audio = audios_list[i]
- if audio is not None:
- if isinstance(audio, list):
- for _ in audio:
- user_content += f"<|audio_bos|>{audio_padding_token}<|audio_eos|>"
- multi_modal_data["audio"] = audio
- else:
- user_content += f"<|audio_bos|>{audio_padding_token}<|audio_eos|>"
- multi_modal_data["audio"] = audio
-
- image = images_list[i]
- if image is not None:
- if isinstance(image, list):
- for _ in image:
- user_content += f"<|vision_bos|>{image_padding_token}<|vision_eos|>"
- multi_modal_data["image"] = image
- else:
- user_content += f"<|vision_bos|>{image_padding_token}<|vision_eos|>"
- multi_modal_data["image"] = image
-
- video = videos_list[i]
- if video is not None:
- if isinstance(video, list):
- for _ in video:
- user_content += f"<|vision_bos|>{video_padding_token}<|vision_eos|>"
- multi_modal_data["video"] = video
- else:
- user_content += f"<|vision_bos|>{video_padding_token}<|vision_eos|>"
- multi_modal_data["video"] = video
-
- user_content += prompt_text
-
- full_prompt = (
- f"<|im_start|>system\n{system_prompt}<|im_end|>\n"
- f"<|im_start|>user\n{user_content}<|im_end|>\n"
- f"<|im_start|>assistant\n"
- )
-
- input_dict: TextPrompt = {"prompt": full_prompt}
- if multi_modal_data:
- input_dict["multi_modal_data"] = multi_modal_data
- if modalities:
- input_dict["modalities"] = modalities
- if mm_processor_kwargs:
- input_dict["mm_processor_kwargs"] = mm_processor_kwargs
-
- omni_inputs.append(input_dict)
-
- return omni_inputs
-
- def generate(
- self,
- prompts: list[TextPrompt],
- sampling_params_list: list[OmniSamplingParams] | None = None,
- ) -> list[OmniRequestOutput]:
- """
- Generate outputs for the given prompts.
-
- Args:
- prompts: List of prompt dictionaries with 'prompt' and optionally
- 'multi_modal_data' keys
- sampling_params_list: List of sampling parameters for each stage.
- If None, uses default parameters.
-
- Returns:
- List of OmniRequestOutput objects from stages with final_output=True
- """
- if sampling_params_list is None:
- sampling_params_list = self.get_default_sampling_params_list()
-
- return self.omni.generate(prompts, sampling_params_list)
-
- def generate_multimodal(
- self,
- prompts: list[str] | str,
- sampling_params_list: list[OmniSamplingParams] | None = None,
- system_prompt: str | None = None,
- audios: PromptAudioInput = None,
- images: PromptImageInput = None,
- videos: PromptVideoInput = None,
- mm_processor_kwargs: dict[str, Any] | None = None,
- modalities: list[str] | None = None,
- ) -> list[OmniRequestOutput]:
- """
- Convenience method to generate with multimodal inputs.
-
- Args:
- prompts: Text prompt(s)
- sampling_params_list: List of sampling parameters for each stage
- system_prompt: Optional system prompt
- audios: Audio input(s)
- images: Image input(s)
- videos: Video input(s)
- mm_processor_kwargs: Optional processor kwargs
-
- Returns:
- List of OmniRequestOutput objects from stages with final_output=True
- """
- omni_inputs = self.get_omni_inputs(
- prompts=prompts,
- system_prompt=system_prompt,
- audios=audios,
- images=images,
- videos=videos,
- mm_processor_kwargs=mm_processor_kwargs,
- modalities=modalities,
- )
- return self.generate(omni_inputs, sampling_params_list)
-
- def start_profile(
- self,
- profile_prefix: str | None = None,
- stages: list[int] | None = None,
- ) -> list[Any]:
- """Start profiling specified stages.
-
- Args:
- profile_prefix: Optional prefix for the trace file names.
- stages: List of stage IDs to profile. If None, profiles all stages.
-
- Returns:
- List of results from each stage.
- """
- return self.omni.start_profile(profile_prefix=profile_prefix, stages=stages)
-
- def stop_profile(self, stages: list[int] | None = None) -> list[Any]:
- """Stop profiling specified stages.
-
- Args:
- stages: List of stage IDs to profile. If None, stops all stages.
-
- Returns:
- List of results from each stage.
- """
- return self.omni.stop_profile(stages=stages)
-
- def _cleanup_process(self):
- try:
- keywords = ["enginecore"]
- matched = []
-
- for proc in psutil.process_iter(["pid", "name", "cmdline", "username"]):
- try:
- cmdline = " ".join(proc.cmdline()).lower() if proc.cmdline() else ""
- name = proc.name().lower()
-
- is_process = any(keyword in cmdline for keyword in keywords) or any(
- keyword in name for keyword in keywords
- )
-
- if is_process:
- print(f"Found vllm process: PID={proc.pid}, cmd={cmdline[:100]}")
- matched.append(proc)
- except (psutil.NoSuchProcess, psutil.AccessDenied):
- pass
-
- for proc in matched:
- try:
- proc.terminate()
- except (psutil.NoSuchProcess, psutil.AccessDenied):
- pass
-
- _, still_alive = psutil.wait_procs(matched, timeout=5)
- for proc in still_alive:
- try:
- proc.kill()
- except (psutil.NoSuchProcess, psutil.AccessDenied):
- pass
-
- if still_alive:
- _, stubborn = psutil.wait_procs(still_alive, timeout=3)
- if stubborn:
- print(f"Warning: failed to kill residual vllm pids: {[p.pid for p in stubborn]}")
- else:
- print(f"Force-killed residual vllm pids: {[p.pid for p in still_alive]}")
- elif matched:
- print(f"Terminated vllm pids: {[p.pid for p in matched]}")
-
- except Exception as e:
- print(f"Error in psutil vllm cleanup: {e}")
-
- def __enter__(self):
- """Context manager entry."""
- return self
-
- def __exit__(self, exc_type, exc_val, exc_tb):
- """Context manager exit - cleanup resources."""
- if hasattr(self.omni, "close"):
- self.omni.close()
- self._cleanup_process()
- _run_pre_test_cleanup(enable_force=True)
- _run_post_test_cleanup(enable_force=True)
- cleanup_dist_env_and_memory()
-
-
-@pytest.fixture(scope="module")
-def omni_runner(request, model_prefix):
- with _omni_server_lock:
- model, stage_config_path = request.param
- model = model_prefix + model
- with OmniRunner(model, seed=42, stage_configs_path=stage_config_path) as runner:
- print("OmniRunner started successfully")
- yield runner
- print("OmniRunner stopping...")
-
- print("OmniRunner stopped")
-
-
-class OmniRunnerHandler:
- def __init__(self, omni_runner):
- self.runner = omni_runner
-
- def _process_output(self, outputs: list[Any]) -> OmniResponse:
- result = OmniResponse()
- try:
- text_content = None
- audio_content = None
- for stage_output in outputs:
- if getattr(stage_output, "final_output_type", None) == "text":
- text_content = stage_output.request_output.outputs[0].text
- if getattr(stage_output, "final_output_type", None) == "audio":
- audio_content = stage_output.request_output.outputs[0].multimodal_output["audio"]
-
- result.audio_content = audio_content
- result.text_content = text_content
- result.success = True
-
- except Exception as e:
- result.error_message = f"Output processing error: {str(e)}"
- result.success = False
- print(f"Error: {result.error_message}")
-
- return result
-
- def send_request(self, request_config: dict[str, Any] | None = None) -> OmniResponse:
- if request_config is None:
- request_config = {}
- prompts = request_config.get("prompts")
- videos = request_config.get("videos")
- images = request_config.get("images")
- audios = request_config.get("audios")
- modalities = request_config.get("modalities", ["text", "audio"])
- outputs = self.runner.generate_multimodal(
- prompts=prompts, videos=videos, images=images, audios=audios, modalities=modalities
- )
- response = self._process_output(outputs)
- assert_omni_response(response, request_config, run_level="core_model")
- return response
-
- def send_audio_speech_request(
- self,
- request_config: dict[str, Any],
- ) -> OmniResponse:
- """
- Offline TTS: text -> audio via generate_multimodal, then validate with assert_audio_speech_response.
-
- request_config must contain:
- - 'input' or 'prompts': text to synthesize.
- Optional keys:
- - 'voice' -> speaker (CustomVoice)
- - 'task_type' -> task_type in additional_information (default: "CustomVoice")
- - 'language' -> language in additional_information (default: "Auto")
- - 'max_new_tokens' -> max_new_tokens in additional_information (default: 2048)
- - 'response_format' -> desired audio format (used only for assertion)
- """
- input_text = request_config.get("input") or request_config.get("prompts")
- if input_text is None:
- raise ValueError("request_config must contain 'input' or 'prompts' for TTS")
- if isinstance(input_text, list):
- input_text = input_text[0] if input_text else ""
-
- # Build TTS-specific kwargs passed through to get_omni_inputs for Qwen3-TTS,
- # matching examples/offline_inference/qwen3_tts/end2end.py.
- mm_processor_kwargs: dict[str, Any] = {}
- if "voice" in request_config:
- mm_processor_kwargs["speaker"] = request_config["voice"]
- if "task_type" in request_config:
- mm_processor_kwargs["task_type"] = request_config["task_type"]
- if "ref_audio" in request_config:
- mm_processor_kwargs["ref_audio"] = request_config["ref_audio"]
- if "ref_text" in request_config:
- mm_processor_kwargs["ref_text"] = request_config["ref_text"]
- if "language" in request_config:
- mm_processor_kwargs["language"] = request_config["language"]
- if "max_new_tokens" in request_config:
- mm_processor_kwargs["max_new_tokens"] = request_config["max_new_tokens"]
-
- outputs = self.runner.generate_multimodal(
- prompts=input_text,
- modalities=["audio"],
- mm_processor_kwargs=mm_processor_kwargs or None,
- )
- mm_out: dict[str, Any] | None = None
- for stage_out in outputs:
- if getattr(stage_out, "final_output_type", None) == "audio":
- mm_out = stage_out.request_output.outputs[0].multimodal_output
- break
- if mm_out is None:
- result = OmniResponse(success=False, error_message="No audio output from pipeline")
- assert result.success, result.error_message
- return result
-
- audio_data = mm_out.get("audio")
- if audio_data is None:
- result = OmniResponse(success=False, error_message="No audio tensor in multimodal output")
- assert result.success, result.error_message
- return result
-
- sr_raw = mm_out.get("sr")
- sr_val = sr_raw[-1] if isinstance(sr_raw, list) and sr_raw else sr_raw
- sr = int(sr_val.item() if hasattr(sr_val, "item") else sr_val)
- wav_tensor = torch.cat(audio_data, dim=-1) if isinstance(audio_data, list) else audio_data
- wav_buf = io.BytesIO()
- sf.write(
- wav_buf,
- wav_tensor.float().cpu().numpy().reshape(-1),
- samplerate=sr,
- format="WAV",
- subtype="PCM_16",
- )
- result = OmniResponse(success=True, audio_bytes=wav_buf.getvalue(), audio_format="audio/wav")
- assert_audio_speech_response(result, request_config, run_level="core_model")
- return result
-
- def start_profile(
- self,
- profile_prefix: str | None = None,
- stages: list[int] | None = None,
- ) -> list[Any]:
- """Start profiling specified stages."""
- return self.runner.start_profile(profile_prefix=profile_prefix, stages=stages)
-
- def stop_profile(self, stages: list[int] | None = None) -> list[Any]:
- """Stop profiling specified stages."""
- return self.runner.stop_profile(stages=stages)
-
-
-@pytest.fixture
-def omni_runner_handler(omni_runner):
- return OmniRunnerHandler(omni_runner)
diff --git a/tests/core/sched/test_omni_scheduler_mixin.py b/tests/core/sched/test_omni_scheduler_mixin.py
new file mode 100644
index 000000000..e04a9c39f
--- /dev/null
+++ b/tests/core/sched/test_omni_scheduler_mixin.py
@@ -0,0 +1,129 @@
+"""Unit tests for OmniSchedulerMixin streaming session replacement.
+
+These tests pin the behavior of `_replace_session_with_streaming_update` against
+current vLLM `Request` / `StreamingUpdate` (and Omni patches). When upgrading
+vLLM, failures here should highlight incompatible changes to request state or
+update payloads early.
+"""
+
+from __future__ import annotations
+
+from dataclasses import replace
+
+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.engine import EngineCoreEventType
+from vllm.v1.request import Request, RequestStatus, StreamingUpdate
+from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
+
+# isort: on
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+class _SchedulerStub(OmniSchedulerMixin):
+ """Minimal scheduler surface required by OmniSchedulerMixin."""
+
+ def __init__(self, *, log_stats: bool = False) -> None:
+ self.num_waiting_for_streaming_input = 0
+ self.log_stats = log_stats
+
+
+def _make_request(**kwargs) -> Request:
+ sp = SamplingParams(max_tokens=8)
+ defaults = dict(
+ request_id="req-mixin-test",
+ prompt_token_ids=[1, 2, 3],
+ sampling_params=sp,
+ pooling_params=None,
+ arrival_time=100.0,
+ block_hasher=None,
+ )
+ defaults.update(kwargs)
+ return Request(**defaults)
+
+
+def _make_update(**kwargs) -> StreamingUpdate:
+ sp_new = SamplingParams(max_tokens=16)
+ defaults = dict(
+ mm_features=None,
+ prompt_token_ids=[10, 20],
+ max_tokens=32,
+ arrival_time=200.0,
+ sampling_params=sp_new,
+ )
+ defaults.update(kwargs)
+ return StreamingUpdate(**defaults)
+
+
+class TestReplaceSessionWithStreamingUpdate:
+ def test_resets_tokens_and_prompt_from_update(self) -> None:
+ sched = _SchedulerStub()
+ session = _make_request()
+ session.append_output_token_ids([7, 8])
+ session.num_computed_tokens = 99
+ session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
+
+ update = _make_update(prompt_token_ids=[40, 41, 42])
+ sched.num_waiting_for_streaming_input = 3
+ sched._replace_session_with_streaming_update(session, update)
+
+ assert session._output_token_ids == []
+ assert list(session._all_token_ids) == [40, 41, 42]
+ assert session.prompt_token_ids == [40, 41, 42]
+ assert session.num_computed_tokens == 0
+ assert session.num_prompt_tokens == 3
+ assert session.arrival_time == 200.0
+ assert session.sampling_params is update.sampling_params
+ assert session.status == RequestStatus.WAITING
+ assert sched.num_waiting_for_streaming_input == 2
+
+ def test_none_prompt_token_ids_becomes_empty(self) -> None:
+ sched = _SchedulerStub()
+ session = _make_request()
+ session.status = RequestStatus.RUNNING
+ update = _make_update(prompt_token_ids=None)
+ sched._replace_session_with_streaming_update(session, update)
+
+ assert session.prompt_token_ids == ()
+ assert list(session._all_token_ids) == []
+ assert session.num_prompt_tokens == 0
+ assert sched.num_waiting_for_streaming_input == 0
+
+ def test_additional_information_cleared_when_update_omits_it(self) -> None:
+ sched = _SchedulerStub()
+ session = _make_request()
+ if not hasattr(session, "additional_information"):
+ pytest.skip("Request has no additional_information (Omni patch inactive?)")
+ session.additional_information = {"keep": True}
+ session.status = RequestStatus.RUNNING
+
+ base = _make_update()
+ if not hasattr(base, "additional_information"):
+ pytest.skip("StreamingUpdate has no additional_information (Omni patch inactive?)")
+ update = replace(base, additional_information=None)
+
+ sched._replace_session_with_streaming_update(session, update)
+ assert session.additional_information is None
+
+ def test_does_not_decrement_waiting_when_not_streaming_status(self) -> None:
+ sched = _SchedulerStub()
+ session = _make_request()
+ session.status = RequestStatus.RUNNING
+ sched.num_waiting_for_streaming_input = 5
+ sched._replace_session_with_streaming_update(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())
+
+ assert session.events
+ assert session.events[-1].type == EngineCoreEventType.QUEUED
diff --git a/tests/core/test_prefix_cache.py b/tests/core/test_prefix_cache.py
new file mode 100644
index 000000000..b5d0e96d3
--- /dev/null
+++ b/tests/core/test_prefix_cache.py
@@ -0,0 +1,349 @@
+import pytest
+import torch
+
+from vllm_omni.core.prefix_cache import OmniTensorPrefixCache
+
+DEFAULT_SEQ_LEN = 15
+NUM_BLOCKS = 10
+BLOCK_SIZE = 4
+HIDDEN_SIZE = 2
+DTYPE = torch.float32
+OTHER_DTYPE = torch.float16
+DEFAULT_SHAPE = torch.Size([NUM_BLOCKS, BLOCK_SIZE, HIDDEN_SIZE])
+
+
+class MockInputBatch:
+ def __init__(self, num_computed_tokens_cpu):
+ self.req_ids = ["req1", "req2"]
+ self.req_id_to_index = {req_id: i for i, req_id in enumerate(self.req_ids)}
+ self.num_computed_tokens_cpu = num_computed_tokens_cpu
+
+ # Block table is only mocked for validation of length;
+ # we don't actually need to add valid values here since
+ # we patch the table when testing.
+ class _DummyBlockTable:
+ pass
+
+ self.block_table = _DummyBlockTable()
+ self.block_table.block_tables = [None]
+
+
+def get_omni_pcache_with_mm_tensors(feat_dims, seq_len) -> OmniTensorPrefixCache:
+ """Build an OmniTensorPrefixCache and init mm tensors."""
+ cache = get_omni_pcache()
+ mm_outputs = get_multimodal_outputs(feat_dims, seq_len)
+ cache.maybe_init_missing_mm_cache_keys(mm_outputs, seq_len)
+ return cache
+
+
+def get_omni_pcache() -> OmniTensorPrefixCache:
+ """Build an OmniTensorPrefixCache, but don't init mm tensors."""
+ cache = OmniTensorPrefixCache(
+ num_blocks=NUM_BLOCKS,
+ block_size=BLOCK_SIZE,
+ hidden_size=HIDDEN_SIZE,
+ hs_dtype=DTYPE,
+ )
+ return cache
+
+
+def get_multimodal_outputs(feat_dims: dict[str, int], seq_len: int) -> dict[str, torch.Tensor]:
+ fake_mm_inputs = {}
+ for mm_key, feat_dim in feat_dims.items():
+ fake_mm_inputs[mm_key] = torch.rand((seq_len, feat_dim), dtype=DTYPE)
+ return fake_mm_inputs
+
+
+### Tests for initialization
+def test_initialization_simple():
+ """Check default initialization only creates the hidden states."""
+ cache = get_omni_pcache()
+ assert isinstance(cache.hidden_states_cache, torch.Tensor)
+ assert cache.hidden_states_cache.shape == DEFAULT_SHAPE
+ assert len(cache.mm_outputs_cache) == 0
+ assert len(cache.mm_cache_keys) == 0
+
+
+def test_initialization_with_multimodal():
+ """Check initialization + registration of multimodal outputs."""
+ cache = get_omni_pcache()
+ feat_dims = {"foo": 100, "bar": 50, "baz": 10}
+ mm_outputs = get_multimodal_outputs(
+ feat_dims,
+ seq_len=DEFAULT_SEQ_LEN,
+ )
+ # Cast one of the keys to a different dtype; the dtype of the tensor
+ # that is used to initialize the cache dictates the cache dtype.
+ mm_outputs["foo"] = mm_outputs["foo"].to(OTHER_DTYPE)
+
+ cache.maybe_init_missing_mm_cache_keys(mm_outputs, DEFAULT_SEQ_LEN)
+ assert len(cache.mm_cache_keys) == 3
+ assert set(cache.mm_cache_keys) == set(feat_dims.keys())
+ for mm_key in cache.mm_cache_keys:
+ cache_tensor = cache.mm_outputs_cache[mm_key]
+ assert isinstance(cache_tensor, torch.Tensor)
+ assert cache_tensor.shape[-1] == feat_dims[mm_key]
+ assert mm_outputs[mm_key].dtype == cache_tensor.dtype
+
+
+def test_init_missing_mm_cache_keys_is_idempotent():
+ """Ensure that the cache doesn't reinitialize old keys."""
+ cache = get_omni_pcache()
+ mm_key = "foo"
+ feat_dims = {mm_key: 100}
+ mm_outputs = get_multimodal_outputs(
+ feat_dims,
+ seq_len=DEFAULT_SEQ_LEN,
+ )
+ cache.maybe_init_missing_mm_cache_keys(mm_outputs, DEFAULT_SEQ_LEN)
+ assert len(cache.mm_cache_keys) == 1
+ assert mm_key in cache.mm_cache_keys
+
+ # Cache is initialized to 0 - fill it with 1s
+ cache.mm_outputs_cache[mm_key].fill_(1)
+
+ # Ensure that running another initialization
+ # doesn't zero out our cache values
+ cache.maybe_init_missing_mm_cache_keys(mm_outputs, DEFAULT_SEQ_LEN)
+ assert len(cache.mm_cache_keys) == 1
+ assert mm_key in cache.mm_cache_keys
+ assert torch.all(cache.mm_outputs_cache[mm_key] == 1)
+
+
+### Tests for Update
+def test_update_no_multimodal():
+ """Test that slot mappings act as row indices hidden states."""
+ cache = get_omni_pcache()
+
+ num_tokens_unpadded = 8
+ slot_offset = 8
+ slot_mapping = torch.arange(slot_offset, slot_offset + num_tokens_unpadded)
+ new_hidden_states = torch.rand((num_tokens_unpadded, HIDDEN_SIZE), dtype=DTYPE)
+
+ cache.update_omni_tensor_prefix_cache(
+ hidden_states=new_hidden_states,
+ multimodal_outputs=None,
+ num_tokens_unpadded=num_tokens_unpadded,
+ slot_mapping=slot_mapping,
+ )
+
+ # Ensure that if we reshape our 3D cache back to 2D, we can use the
+ # indices in our slot mappings to access the hidden states as expected
+ hs_rows = cache.hidden_states_cache.view(NUM_BLOCKS * BLOCK_SIZE, HIDDEN_SIZE)
+ for slot_idx, new_states in zip(slot_mapping, new_hidden_states):
+ slot_states = hs_rows[slot_idx]
+ assert torch.all(slot_states == new_states)
+
+
+@pytest.mark.parametrize(
+ "feat_dims",
+ [
+ {"foo": 100, "bar": 100},
+ {"foo": 100, "bar": 50, "baz": 10},
+ ],
+)
+def test_update_with_multimodal_outputs(feat_dims):
+ """Test that slot mappings are correct for multimodal tensors."""
+ cache = get_omni_pcache_with_mm_tensors(feat_dims, seq_len=DEFAULT_SEQ_LEN)
+
+ num_tokens_unpadded = 8
+ slot_offset = 8
+ slot_mapping = torch.arange(slot_offset, slot_offset + num_tokens_unpadded)
+ feature_dims = {key: val.shape[-1] for key, val in cache.mm_outputs_cache.items()}
+ mm_outputs = {key: torch.rand((num_tokens_unpadded, feature_dims[key]), dtype=DTYPE) for key in cache.mm_cache_keys}
+ cache.update_omni_tensor_prefix_cache(
+ hidden_states=None,
+ multimodal_outputs=mm_outputs,
+ num_tokens_unpadded=num_tokens_unpadded,
+ slot_mapping=slot_mapping,
+ )
+
+ for mm_key in feat_dims.keys():
+ assert mm_key in cache.mm_outputs_cache
+ key_feat_dim = feature_dims[mm_key]
+ mm_state_rows = cache.mm_outputs_cache[mm_key].view(NUM_BLOCKS * BLOCK_SIZE, key_feat_dim)
+
+ # Similar to hidden states, but for each key in the dict;
+ # Different tensors may have different feature dims
+ new_mm_outputs = mm_outputs[mm_key]
+ for slot_idx, new_output in zip(slot_mapping, new_mm_outputs):
+ slot_states = mm_state_rows[slot_idx]
+ assert torch.all(slot_states == new_output)
+
+
+### Tests for Merging
+def fake_get_cached_block_ids(self, req_idx, *args, **kwargs):
+ """Fake block table lookup.
+
+ Assumption:
+ req_idx 0 is a cache hit with slots 8, 9, ..., 15
+ req_idx 1 is a cache miss
+ """
+ assert req_idx < 2
+ if req_idx == 0:
+ # With the slot offset we provided (8), the corresponding
+ # blocks IDs are 2 & 3 because the block size is 4.
+ return torch.tensor([2, 3], dtype=torch.long)
+ return torch.tensor([], dtype=torch.long)
+
+
+@pytest.mark.parametrize("num_tokens_padded", [None, 16])
+def test_get_merged_hidden_states(num_tokens_padded, mocker):
+ """Ensure that hidden states are merged correctly."""
+ cache = get_omni_pcache()
+
+ orig_num_tokens_unpadded = 8
+ slot_offset = 8 # We'll put our states in slots 8, 9, 10, ..., 15
+ orig_slot_mapping = torch.arange(slot_offset, slot_offset + orig_num_tokens_unpadded)
+ orig_hidden_states = torch.rand((orig_num_tokens_unpadded, HIDDEN_SIZE), dtype=DTYPE)
+
+ cache.update_omni_tensor_prefix_cache(
+ hidden_states=orig_hidden_states,
+ multimodal_outputs=None,
+ num_tokens_unpadded=orig_num_tokens_unpadded,
+ slot_mapping=orig_slot_mapping,
+ num_tokens_padded=num_tokens_padded,
+ )
+
+ # Say that we have two requests, but only one of them is a cache hit
+ num_new_toks_req1 = 3
+ num_new_toks_req2 = 2
+ cache.add_prefix_cached_new_req_id("req1")
+
+ num_scheduled_tokens = {
+ "req1": num_new_toks_req1,
+ "req2": num_new_toks_req2,
+ }
+ new_hidden_states = torch.rand(
+ (num_new_toks_req1 + num_new_toks_req2, HIDDEN_SIZE),
+ dtype=DTYPE,
+ )
+ req1_new_states = new_hidden_states[:num_new_toks_req1]
+ req2_new_states = new_hidden_states[-num_new_toks_req2:]
+
+ input_batch = MockInputBatch(num_computed_tokens_cpu=torch.Tensor([orig_num_tokens_unpadded, 0]))
+
+ mocker.patch(
+ "vllm_omni.core.prefix_cache.OmniTensorPrefixCache._get_cached_block_ids",
+ new=fake_get_cached_block_ids,
+ )
+ merged_states = cache.get_merged_hidden_states(
+ query_start_loc=[0, num_new_toks_req1],
+ input_batch=input_batch,
+ hidden_states=new_hidden_states,
+ num_scheduled_tokens=num_scheduled_tokens,
+ )
+
+ assert "req1" in merged_states and "req2" in merged_states
+ req1_merged_states = merged_states["req1"]
+ req2_merged_states = merged_states["req2"]
+
+ # First, check the cache hit case
+ assert req1_merged_states.shape == torch.Size([orig_num_tokens_unpadded + num_new_toks_req1, HIDDEN_SIZE])
+ # Ensure that the req1 merged states are the cached states + the new req1 states
+ assert torch.all(req1_merged_states[:orig_num_tokens_unpadded] == orig_hidden_states)
+ assert torch.all(req1_merged_states[-num_new_toks_req1:] == req1_new_states)
+
+ # Next, ensure that the cache miss case only has the new states
+ assert req2_merged_states.shape == torch.Size([num_new_toks_req2, HIDDEN_SIZE])
+ assert torch.all(req2_merged_states == req2_new_states)
+
+
+@pytest.mark.parametrize("num_tokens_padded", [None, 16])
+@pytest.mark.parametrize(
+ "feat_dims",
+ [
+ {"foo": 100, "bar": 100},
+ {"foo": 100, "bar": 50, "baz": 10},
+ ],
+)
+def test_get_merged_multimodal_outputs(feat_dims, num_tokens_padded, mocker):
+ cache = get_omni_pcache_with_mm_tensors(feat_dims, seq_len=DEFAULT_SEQ_LEN)
+
+ orig_num_tokens_unpadded = 8
+ slot_offset = 8 # We'll put our states in slots 8, 9, 10, ..., 15
+ orig_slot_mapping = torch.arange(slot_offset, slot_offset + orig_num_tokens_unpadded)
+ feature_dims = {key: val.shape[-1] for key, val in cache.mm_outputs_cache.items()}
+ orig_mm_outputs = {
+ key: torch.rand((orig_num_tokens_unpadded, feature_dims[key]), dtype=DTYPE) for key in cache.mm_cache_keys
+ }
+
+ cache.update_omni_tensor_prefix_cache(
+ hidden_states=None,
+ multimodal_outputs=orig_mm_outputs,
+ num_tokens_unpadded=orig_num_tokens_unpadded,
+ slot_mapping=orig_slot_mapping,
+ num_tokens_padded=num_tokens_padded,
+ )
+
+ # Similar to hs test- say that we have two requests, but only one of them is a cache hit
+ num_new_toks_req1 = 3
+ num_new_toks_req2 = 2
+ cache.add_prefix_cached_new_req_id("req1")
+
+ num_scheduled_tokens = {
+ "req1": num_new_toks_req1,
+ "req2": num_new_toks_req2,
+ }
+
+ new_mm_outputs = {}
+ for mm_key in cache.mm_cache_keys:
+ new_mm_outputs[mm_key] = torch.rand(
+ (num_new_toks_req1 + num_new_toks_req2, feature_dims[mm_key]),
+ dtype=DTYPE,
+ )
+ # We also want to make sure passthrough data (outside of our keys) isn't dropped
+ new_mm_outputs["passthrough_data"] = "Something else"
+ # Lists are a special case because we can't split them yet if we want to match
+ # the nonprefix cache behavior, because this runs before post process.
+ new_mm_outputs["passthrough_list"] = ["should", "not", "split"]
+
+ input_batch = MockInputBatch(num_computed_tokens_cpu=torch.Tensor([orig_num_tokens_unpadded, 0]))
+
+ mocker.patch(
+ "vllm_omni.core.prefix_cache.OmniTensorPrefixCache._get_cached_block_ids",
+ new=fake_get_cached_block_ids,
+ )
+ merged_mm_outputs = cache.get_merged_multimodal_states(
+ query_start_loc=[0, num_new_toks_req1],
+ input_batch=input_batch,
+ multimodal_outputs=new_mm_outputs,
+ num_scheduled_tokens=num_scheduled_tokens,
+ )
+
+ # Ensure the passthrough data wasn't dropped
+ assert "passthrough_data" in merged_mm_outputs
+ assert "passthrough_list" in merged_mm_outputs
+
+ for mm_key, mm_output in merged_mm_outputs.items():
+ # Ensure passthrough data is just forwarded normally and not duplicated
+ assert isinstance(mm_output, dict)
+ assert "req1" in mm_output and "req2" in mm_output
+ if mm_key == "passthrough_data":
+ assert mm_key not in cache.mm_cache_keys
+ assert new_mm_outputs[mm_key] == mm_output["req1"]
+ assert new_mm_outputs[mm_key] == mm_output["req2"]
+ elif mm_key == "passthrough_list":
+ assert mm_key not in cache.mm_cache_keys
+ assert new_mm_outputs[mm_key] == mm_output["req1"]
+ assert new_mm_outputs[mm_key] == mm_output["req2"]
+ else:
+ assert mm_key in cache.mm_cache_keys
+ curr_feat_dim = feature_dims[mm_key]
+ # Ensure that req1 (cache hit) merged the mm data
+ req1_merged_mm_outputs = mm_output["req1"]
+ req1_new_mm_outputs = new_mm_outputs[mm_key][:num_new_toks_req1]
+
+ assert req1_merged_mm_outputs.shape == torch.Size(
+ [orig_num_tokens_unpadded + num_new_toks_req1, curr_feat_dim]
+ )
+ # Ensure that the req1 merged mm data are the cached data + the new data
+ assert torch.all(req1_merged_mm_outputs[:orig_num_tokens_unpadded] == orig_mm_outputs[mm_key])
+ assert torch.all(req1_merged_mm_outputs[-num_new_toks_req1:] == req1_new_mm_outputs)
+
+ # Ensure that req2 (cache miss) only has the new mm data
+ req2_merged_mm_outputs = mm_output["req2"]
+ req2_new_mm_outputs = new_mm_outputs[mm_key][-num_new_toks_req2:]
+
+ assert req2_merged_mm_outputs.shape == torch.Size([num_new_toks_req2, curr_feat_dim])
+ assert torch.all(req2_merged_mm_outputs == req2_new_mm_outputs)
diff --git a/tests/dfx/conftest.py b/tests/dfx/conftest.py
index e54141b34..c3f6d0a15 100644
--- a/tests/dfx/conftest.py
+++ b/tests/dfx/conftest.py
@@ -2,7 +2,9 @@ import json
from pathlib import Path
from typing import Any
-from tests.conftest import modify_stage_config
+import pytest
+
+from tests.helpers.stage_config import modify_stage_config
def load_configs(config_path: str) -> list[dict[str, Any]]:
@@ -38,22 +40,32 @@ def modify_stage(default_path, updates, deletes):
def create_unique_server_params(
configs: list[dict[str, Any]],
stage_configs_dir: Path,
-) -> list[tuple[str, str, str]]:
+) -> list[tuple[str, str, str | None, str | None, tuple[str, ...]]]:
unique_params = []
seen = set()
for config in configs:
test_name = config["test_name"]
- model = config["server_params"]["model"]
- stage_config_name = config["server_params"].get("stage_config_name")
+ server_params = config["server_params"]
+ model = server_params["model"]
+ stage_config_name = server_params.get("stage_config_name")
if stage_config_name:
stage_config_path = str(stage_configs_dir / stage_config_name)
- delete = config["server_params"].get("delete", None)
- update = config["server_params"].get("update", None)
+ delete = server_params.get("delete", None)
+ update = server_params.get("update", None)
stage_config_path = modify_stage(stage_config_path, update, delete)
else:
stage_config_path = None
- server_param = (test_name, model, stage_config_path)
+ stage_overrides = server_params.get("stage_overrides")
+ stage_overrides_json = json.dumps(stage_overrides) if stage_overrides else None
+
+ # ``extra_cli_args`` passes raw CLI flags straight through to
+ # ``vllm_omni.entrypoints.cli.main serve`` — used for flags that
+ # don't map to stage-level overrides, e.g. ``--async-chunk`` /
+ # ``--no-async-chunk`` toggling the deploy-level async_chunk bool.
+ extra_cli_args = tuple(server_params.get("extra_cli_args") or ())
+
+ server_param = (test_name, model, stage_config_path, stage_overrides_json, extra_cli_args)
if server_param not in seen:
seen.add(server_param)
unique_params.append(server_param)
@@ -95,3 +107,13 @@ def create_benchmark_indices(
indices.append((test_name, idx))
return indices
+
+
+def pytest_addoption(parser: pytest.Parser) -> None:
+ """Register shared CLI options for DFX benchmark suites."""
+ parser.addoption(
+ "--test-config-file",
+ action="store",
+ default=None,
+ help=("Path to benchmark config JSON. Example: --test-config-file tests/dfx/perf/tests/test_tts.json"),
+ )
diff --git a/tests/dfx/perf/scripts/diffusion_result_template.json b/tests/dfx/perf/scripts/diffusion_result_template.json
new file mode 100644
index 000000000..86bdf1bc7
--- /dev/null
+++ b/tests/dfx/perf/scripts/diffusion_result_template.json
@@ -0,0 +1,86 @@
+[
+ {
+ "test_name": null,
+ "backend": null,
+ "timestamp": null,
+ "server_params": {
+ "model": null,
+ "serve_args": {
+ "enable-diffusion-pipeline-profiler": false
+ }
+ },
+ "benchmark_params": {
+ "name": null,
+ "dataset": null,
+ "task": null,
+ "width": 0,
+ "height": 0,
+ "num-inference-steps": 0,
+ "num-prompts": 0,
+ "max-concurrency": 0,
+ "num-input-images": 0,
+ "enable-negative-prompt": false,
+ "baseline": {
+ "throughput_qps": 0,
+ "latency_mean": 0,
+ "peak_memory_mb_max": 0,
+ "peak_memory_mb_mean": 0
+ }
+ },
+ "result": {
+ "duration": 0,
+ "completed_requests": 0,
+ "failed_requests": 0,
+ "throughput_qps": 0,
+ "latency_mean": 0,
+ "latency_median": 0,
+ "latency_p99": 0,
+ "latency_p95": 0,
+ "latency_p50": 0,
+ "peak_memory_mb_max": 0,
+ "peak_memory_mb_mean": 0,
+ "peak_memory_mb_median": 0,
+ "stage_durations_mean": {},
+ "stage_durations_p50": {},
+ "stage_durations_p99": {},
+ "backend": null,
+ "model": null,
+ "dataset": null,
+ "task": null
+ },
+ "log_file": null,
+ "Model": null,
+ "Framework": null,
+ "Hardware": null,
+ "Deployment": null,
+ "Task": null,
+ "Dataset": null,
+ "resolution": null,
+ "Parallelism": null,
+ "max_concurrency": 0,
+ "Cache": null,
+ "Quantization": null,
+ "offload": null,
+ "compile": null,
+ "Attn_backend": null,
+ "num_inference_steps": 0,
+ "completed": 0,
+ "failed": 0,
+ "throughput_qps": 0,
+ "latency_mean": 0,
+ "latency_median": 0,
+ "latency_p99": 0,
+ "latency_p95": 0,
+ "latency_p50": 0,
+ "peak_memory_mb_max": 0,
+ "peak_memory_mb_mean": 0,
+ "peak_memory_mb_median": 0,
+ "stage_durations_mean": {},
+ "stage_durations_p50": {},
+ "stage_durations_p99": {},
+ "commit_sha": null,
+ "build_id": null,
+ "build_url": null,
+ "source_file": null
+ }
+]
diff --git a/tests/dfx/perf/scripts/result_omni_template.json b/tests/dfx/perf/scripts/result_omni_template.json
new file mode 100644
index 000000000..1d6132140
--- /dev/null
+++ b/tests/dfx/perf/scripts/result_omni_template.json
@@ -0,0 +1,55 @@
+{
+ "date": null,
+ "endpoint_type": null,
+ "backend": null,
+ "label": null,
+ "model_id": null,
+ "tokenizer_id": null,
+ "num_prompts": 0,
+ "request_rate": null,
+ "burstiness": 0,
+ "max_concurrency": 0,
+ "duration": 0,
+ "completed": 0,
+ "failed": 0,
+ "total_input_tokens": 0,
+ "total_output_tokens": 0,
+ "request_throughput": 0,
+ "request_goodput": null,
+ "output_throughput": 0,
+ "total_token_throughput": 0,
+ "total_audio_duration_s": 0,
+ "total_audio_frames": 0,
+ "audio_throughput": 0,
+ "max_output_tokens_per_s": 0,
+ "max_concurrent_requests": 0,
+ "rtfx": 0,
+ "mean_ttft_ms": 0,
+ "median_ttft_ms": 0,
+ "p99_ttft_ms": 0,
+ "mean_tpot_ms": 0,
+ "median_tpot_ms": 0,
+ "p99_tpot_ms": 0,
+ "mean_itl_ms": 0,
+ "median_itl_ms": 0,
+ "p99_itl_ms": 0,
+ "mean_e2el_ms": 0,
+ "median_e2el_ms": 0,
+ "p99_e2el_ms": 0,
+ "mean_audio_rtf": 0,
+ "median_audio_rtf": 0,
+ "p99_audio_rtf": 0,
+ "mean_audio_ttfp_ms": 0,
+ "median_audio_ttfp_ms": 0,
+ "p99_audio_ttfp_ms": 0,
+ "mean_audio_duration_s": 0,
+ "median_audio_duration_s": 0,
+ "p99_audio_duration_s": 0,
+ "baseline": {
+ "mean_ttft_ms": 0,
+ "mean_audio_ttfp_ms": 0,
+ "mean_audio_rtf": 0
+ },
+ "random_input_len": 0,
+ "random_output_len": 0
+}
diff --git a/tests/dfx/perf/scripts/run_benchmark.py b/tests/dfx/perf/scripts/run_benchmark.py
index b64cc0d95..13011b4bd 100644
--- a/tests/dfx/perf/scripts/run_benchmark.py
+++ b/tests/dfx/perf/scripts/run_benchmark.py
@@ -8,7 +8,6 @@ from typing import Any
import pytest
-from tests.conftest import OmniServer
from tests.dfx.conftest import (
create_benchmark_indices,
create_test_parameter_mapping,
@@ -16,18 +15,41 @@ from tests.dfx.conftest import (
get_benchmark_params_for_server,
load_configs,
)
+from tests.helpers.runtime import OmniServer
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
-CONFIG_FILE_PATH = str(Path(__file__).parent.parent / "tests" / "test.json")
+def _get_config_file_from_argv() -> str | None:
+ """Read ``--test-config-file`` from ``sys.argv`` at import time so parametrization can use it."""
+ import sys
+
+ for i, arg in enumerate(sys.argv):
+ if arg == "--test-config-file" and i + 1 < len(sys.argv):
+ return sys.argv[i + 1]
+ if arg.startswith("--test-config-file="):
+ return arg.split("=", 1)[1]
+ return None
+
+
+_PERF_TESTS_DIR = Path(__file__).resolve().parent.parent / "tests"
+_DEFAULT_CONFIG_FILE = str(_PERF_TESTS_DIR / "test_qwen_omni.json")
+
+CONFIG_FILE_PATH = _get_config_file_from_argv()
+if CONFIG_FILE_PATH is None:
+ print(
+ "No --test-config-file in argv, using default: tests/dfx/perf/tests/test_qwen_omni.json "
+ "(override with e.g. --test-config-file tests/dfx/perf/tests/test_tts.json)"
+ )
+ CONFIG_FILE_PATH = _DEFAULT_CONFIG_FILE
+
BENCHMARK_CONFIGS = load_configs(CONFIG_FILE_PATH)
-STAGE_INIT_TIMEOUT = 600
+OMNI_RESULT_TEMPLATE_PATH = Path(__file__).parent / "result_omni_template.json"
-STAGE_CONFIGS_DIR = Path(__file__).parent.parent / "stage_configs"
-test_params = create_unique_server_params(BENCHMARK_CONFIGS, STAGE_CONFIGS_DIR)
+DEPLOY_CONFIGS_DIR = Path(__file__).parent.parent / "deploy"
+test_params = create_unique_server_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR)
server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS)
_omni_server_lock = threading.Lock()
@@ -40,13 +62,19 @@ def omni_server(request):
Multi-stage initialization can take 10-20+ minutes.
"""
with _omni_server_lock:
- test_name, model, stage_config_path = request.param
+ test_name, model, stage_config_path, stage_overrides, extra_cli_args = request.param
print(f"Starting OmniServer with test: {test_name}, model: {model}")
- server_args = ["--stage-init-timeout", str(STAGE_INIT_TIMEOUT), "--init-timeout", "900"]
+ server_args = ["--stage-init-timeout", "600", "--init-timeout", "900"]
+ # --deploy-config and --stage-overrides compose at the CLI (see vllm_omni/entrypoints/utils.py):
+ # deploy-config sets the base; stage-overrides are applied on top. Both can be set.
if stage_config_path:
- server_args = ["--stage-configs-path", stage_config_path] + server_args
+ server_args = ["--deploy-config", stage_config_path] + server_args
+ if stage_overrides:
+ server_args = ["--stage-overrides", stage_overrides] + server_args
+ if extra_cli_args:
+ server_args = list(extra_cli_args) + server_args
with OmniServer(model, server_args) as server:
server.test_name = test_name
print("OmniServer started successfully")
@@ -56,24 +84,47 @@ def omni_server(request):
print("OmniServer stopped")
+def _safe_filename_token(value: Any | None, *, default: str = "na") -> str:
+ """Make a single path segment safe for result filenames on common filesystems."""
+ if value is None:
+ return default
+ s = str(value).strip()
+ for bad in ("/", "\\", ":", "*", "?", '"', "<", ">", "|"):
+ s = s.replace(bad, "_")
+ return s if s else default
+
+
def run_benchmark(
args: list,
test_name: str,
flow,
dataset_name: str,
num_prompt,
+ *,
+ baseline_config: dict[str, Any] | None = None,
+ sweep_index: int | None = None,
+ request_rate: Any | None = None,
+ max_concurrency: Any | None = None,
+ random_input_len: Any | None = None,
+ random_output_len: Any | None = None,
) -> Any:
- """Run a single benchmark iteration and return the parsed result JSON."""
+ """Run a single benchmark iteration and return the parsed result JSON.
+
+ After ``vllm bench`` writes the JSON, ``result["baseline"]`` holds the same
+ per-metric resolved thresholds as ``assert_result`` (via ``_baseline_thresholds_for_step``).
+ When ``random_input_len`` / ``random_output_len`` are set, they are also written into the result JSON;
+ omitted keys when not configured.
+ """
current_dt = datetime.now().strftime("%Y%m%d-%H%M%S")
- result_filename = f"result_{test_name}_{dataset_name}_{flow}_{num_prompt}_{current_dt}.json"
+ ri = _safe_filename_token(random_input_len)
+ ro = _safe_filename_token(random_output_len)
+ result_filename = f"result_{test_name}_{dataset_name}_{flow}_{num_prompt}_in{ri}_out{ro}_{current_dt}.json"
if "--result-filename" in args:
print(f"The result file will be overwritten by {result_filename}")
command = (
["vllm", "bench", "serve", "--omni"]
+ args
+ [
- "--num-warmups",
- "2",
"--save-result",
"--result-dir",
os.environ.get("BENCHMARK_DIR", "tests"),
@@ -97,8 +148,34 @@ def run_benchmark(
else:
result_dir = "./"
- with open(os.path.join(result_dir, result_filename), encoding="utf-8") as f:
- result = json.load(f)
+ result_path = os.path.join(result_dir, result_filename)
+ if not os.path.exists(result_path):
+ with open(OMNI_RESULT_TEMPLATE_PATH, encoding="utf-8") as f:
+ template_result: dict[str, Any] = json.load(f)
+ Path(result_path).parent.mkdir(parents=True, exist_ok=True)
+ with open(result_path, "w", encoding="utf-8") as f:
+ json.dump(template_result, f, ensure_ascii=False, indent=2)
+ print(f"Benchmark result file not generated, fallback to template: {result_path}")
+ result = template_result
+ else:
+ with open(result_path, encoding="utf-8") as f:
+ result = json.load(f)
+
+ if baseline_config:
+ result["baseline"] = _baseline_thresholds_for_step(
+ baseline_config,
+ sweep_index=sweep_index,
+ request_rate=request_rate,
+ max_concurrency=max_concurrency,
+ )
+ else:
+ result["baseline"] = {}
+ if random_input_len is not None:
+ result["random_input_len"] = random_input_len
+ if random_output_len is not None:
+ result["random_output_len"] = random_output_len
+ with open(result_path, "w", encoding="utf-8") as f:
+ json.dump(result, f, ensure_ascii=False, indent=2)
return result
@@ -168,6 +245,25 @@ def _resolve_baseline_value(
return baseline_raw
+def _baseline_thresholds_for_step(
+ baseline_data: dict[str, Any],
+ *,
+ sweep_index: int | None = None,
+ max_concurrency: Any = None,
+ request_rate: Any = None,
+) -> dict[str, Any]:
+ """Resolve ``test.json`` ``baseline`` block to one threshold per metric (same as ``assert_result``)."""
+ return {
+ metric_name: _resolve_baseline_value(
+ baseline_raw,
+ sweep_index=sweep_index,
+ max_concurrency=max_concurrency,
+ request_rate=request_rate,
+ )
+ for metric_name, baseline_raw in baseline_data.items()
+ }
+
+
def assert_result(
result,
params,
@@ -258,6 +354,12 @@ def test_performance_benchmark(omni_server, benchmark_params):
flow=qps,
dataset_name=dataset_name,
num_prompt=num_prompt,
+ baseline_config=params.get("baseline"),
+ sweep_index=i,
+ request_rate=qps,
+ max_concurrency=None,
+ random_input_len=params.get("random_input_len"),
+ random_output_len=params.get("random_output_len"),
)
assert_result(
result,
@@ -276,6 +378,12 @@ def test_performance_benchmark(omni_server, benchmark_params):
flow=concurrency,
dataset_name=dataset_name,
num_prompt=num_prompt,
+ baseline_config=params.get("baseline"),
+ sweep_index=i,
+ request_rate=None,
+ max_concurrency=concurrency,
+ random_input_len=params.get("random_input_len"),
+ random_output_len=params.get("random_output_len"),
)
assert_result(
result,
diff --git a/tests/dfx/perf/scripts/run_diffusion_benchmark.py b/tests/dfx/perf/scripts/run_diffusion_benchmark.py
index 123f21405..23efa8bb0 100644
--- a/tests/dfx/perf/scripts/run_diffusion_benchmark.py
+++ b/tests/dfx/perf/scripts/run_diffusion_benchmark.py
@@ -5,8 +5,8 @@ Supports vLLM-Omni server backend:
- vllm-omni (default): starts DiffusionServer via vllm_omni.entrypoints.cli.main,
benchmarks with diffusion_benchmark_serving.py --backend vllm-omni
-A config JSON file is REQUIRED via --config-file:
- pytest run_diffusion_benchmark.py --config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
+A config JSON file is REQUIRED via --test-config-file:
+ pytest run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
JSON config entries use a "server_type" field, and this runner executes
the vllm-omni path.
@@ -52,19 +52,20 @@ BENCHMARK_SCRIPT = str(
_SESSION_TIMESTAMP = datetime.now().strftime("%Y%m%d-%H%M%S")
_RESULT_LOCK = threading.Lock()
_BRANCHPOINT_COMMIT_SHA: str | None = None
+DIFFUSION_RESULT_TEMPLATE_PATH = Path(__file__).parent / "diffusion_result_template.json"
def _get_config_file_from_argv() -> str | None:
- """Read --config-file from sys.argv at import time so pytest parametrize can use it.
+ """Read --test-config-file from sys.argv at import time so pytest parametrize can use it.
pytest_addoption (below) registers the same flag so pytest does not reject it.
- Supports both ``--config-file path`` and ``--config-file=path`` forms.
+ Supports both ``--test-config-file path`` and ``--test-config-file=path`` forms.
Returns None if the flag is not present; callers must handle the missing case.
"""
for i, arg in enumerate(sys.argv):
- if arg == "--config-file" and i + 1 < len(sys.argv):
+ if arg == "--test-config-file" and i + 1 < len(sys.argv):
return sys.argv[i + 1]
- if arg.startswith("--config-file="):
+ if arg.startswith("--test-config-file="):
return arg.split("=", 1)[1]
return None
@@ -133,19 +134,6 @@ def _append_to_aggregated_file(record: dict[str, Any]) -> None:
json.dump(records, f, indent=2, ensure_ascii=False)
-# Register --config-file with pytest so it does not reject the argument.
-def pytest_addoption(parser: pytest.Parser) -> None:
- parser.addoption(
- "--config-file",
- action="store",
- default=None,
- help=(
- "Path to the benchmark config JSON file (required). "
- "Example: --config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json"
- ),
- )
-
-
_server_lock = threading.Lock()
# ---------------------------------------------------------------------------
@@ -576,10 +564,17 @@ def run_benchmark(
if process.returncode != 0:
tmp_result_file.unlink(missing_ok=True)
- raise RuntimeError(f"Benchmark script exited with code {process.returncode}")
+ print(f"ERROR:Benchmark script exited with code {process.returncode}")
if not tmp_result_file.exists():
- raise FileNotFoundError(f"Benchmark result file not found: {tmp_result_file}")
+ with open(DIFFUSION_RESULT_TEMPLATE_PATH, encoding="utf-8") as f:
+ template_payload = json.load(f)
+ # Template schema is fixed and owned by this repo:
+ # ``diffusion_result_template.json`` is a one-item list and metrics live at [0]["result"].
+ template_metrics: dict[str, Any] = template_payload[0]["result"]
+ with open(tmp_result_file, "w", encoding="utf-8") as f:
+ json.dump(template_metrics, f, ensure_ascii=False, indent=2)
+ print(f"Benchmark result file not generated, fallback to template: {tmp_result_file}")
try:
with open(tmp_result_file, encoding="utf-8") as f:
diff --git a/tests/dfx/perf/stage_configs/qwen3_omni.yaml b/tests/dfx/perf/stage_configs/qwen3_omni.yaml
deleted file mode 100644
index 2add22b87..000000000
--- a/tests/dfx/perf/stage_configs/qwen3_omni.yaml
+++ /dev/null
@@ -1,101 +0,0 @@
-# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
-# Stage 0: Thinker (multimodal understanding + text generation)
-# Stage 1: Talker (text embeddings → 8-layer RVQ codec codes)
-# Stage 2: Code2Wav (8-layer RVQ codes → audio waveform)
-
-# The following config has been verified on 2x H100-80G GPUs.
-async_chunk: false
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "0"
- engine_args:
- model_stage: thinker
- max_num_seqs: 64
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.9
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output hidden states for talker
- distributed_executor_backend: "mp"
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- hf_config_name: thinker_config
- tensor_parallel_size: 1
- final_output: true
- final_output_type: text
- is_comprehension: true
- default_sampling_params:
- temperature: 0.4
- top_p: 0.9
- top_k: 1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
-
- - stage_id: 1
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 64
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.6
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output codec codes for code2wav
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- distributed_executor_backend: "mp"
- hf_config_name: talker_config
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
- # final_output: true
- # final_output_type: text
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: False
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 2
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "1"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 64
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: false
- enable_prefix_caching: false
- engine_output_type: audio # Final output: audio waveform
- gpu_memory_utilization: 0.1
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 100000
- hf_config_name: thinker_config
- engine_input_source: [1]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
diff --git a/tests/dfx/perf/stage_configs/qwen3_tts.yaml b/tests/dfx/perf/stage_configs/qwen3_tts.yaml
deleted file mode 100644
index 97b309056..000000000
--- a/tests/dfx/perf/stage_configs/qwen3_tts.yaml
+++ /dev/null
@@ -1,96 +0,0 @@
-# Stage config for running Qwen3-TTS with 2-stage architecture
-# Stage 0: Talker (text -> 8-layer RVQ codec codes)
-# Stage 1: Code2Wav (codec codes -> audio waveform)
-#
-# The following config has been verified on 1x H100-80G GPU.
-async_chunk: true
-stage_args:
- - stage_id: 0
- stage_type: llm
- is_comprehension: true
- runtime:
- devices: "0"
- engine_args:
- max_num_seqs: 4
- model_stage: qwen3_tts
- model_arch: Qwen3TTSTalkerForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- enforce_eager: false
- trust_remote_code: true
- async_scheduling: false
- enable_prefix_caching: false
- engine_output_type: latent
- gpu_memory_utilization: 0.3
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 512
- max_model_len: 4096
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
- output_connectors:
- to_stage_1: connector_of_shared_memory
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: false
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 1
- stage_type: llm
- runtime:
- devices: "0"
- engine_args:
- max_num_seqs: 4
- model_stage: code2wav
- model_arch: Qwen3TTSCode2Wav
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: false
- enable_prefix_caching: false
- engine_output_type: audio
- gpu_memory_utilization: 0.2
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 8192
- max_model_len: 32768
- engine_input_source: [0]
- final_output: true
- final_output_type: audio
- input_connectors:
- from_stage_0: connector_of_shared_memory
- tts_args:
- max_instructions_length: 500
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: true
- repetition_penalty: 1.0
-
-runtime:
- enabled: true
- defaults:
- window_size: -1
- max_inflight: 4
-
- connectors:
- connector_of_shared_memory:
- name: SharedMemoryConnector
- extra:
- shm_threshold_bytes: 65536
- codec_streaming: true
- connector_get_sleep_s: 0.01
- connector_get_max_wait_first_chunk: 3000
- connector_get_max_wait: 300
- codec_chunk_frames: 25
- codec_left_context_frames: 72
-
- edges:
- - from: 0
- to: 1
- window_size: -1
diff --git a/tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json b/tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json
new file mode 100644
index 000000000..3cf13509c
--- /dev/null
+++ b/tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json
@@ -0,0 +1,49 @@
+[
+ {
+ "test_name": "test_qwen_image_layered_single_device",
+ "description": "Single-device baseline",
+ "server_type": "vllm-omni",
+ "server_params": {
+ "model": "Qwen/Qwen-Image-Layered",
+ "serve_args": {
+ "enable-diffusion-pipeline-profiler": true
+ }
+ },
+ "benchmark_params": [
+ {
+ "name": "640x640_steps20_i2i",
+ "dataset": "random",
+ "task": "i2i",
+ "width": 640,
+ "height": 640,
+ "num-inference-steps": 20,
+ "num-prompts": 10,
+ "max-concurrency": 1,
+ "enable-negative-prompt": true,
+ "baseline": {
+ "throughput_qps": 0.02,
+ "latency_mean": 40.0,
+ "peak_memory_mb_max": 70000,
+ "peak_memory_mb_mean": 70000
+ }
+ },
+ {
+ "name": "1024x1024_steps35_i2i",
+ "dataset": "random",
+ "task": "i2i",
+ "width": 1024,
+ "height": 1024,
+ "num-inference-steps": 35,
+ "num-prompts": 10,
+ "max-concurrency": 1,
+ "enable-negative-prompt": true,
+ "baseline": {
+ "throughput_qps": 0.005,
+ "latency_mean": 80.0,
+ "peak_memory_mb_max": 70000,
+ "peak_memory_mb_mean": 70000
+ }
+ }
+ ]
+ }
+]
diff --git a/tests/dfx/perf/tests/test_qwen_image_vllm_omni.json b/tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
index 1f3a2bbf7..cdd0cac2c 100644
--- a/tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
+++ b/tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
@@ -44,6 +44,52 @@
}
]
},
+ {
+ "test_name": "test_qwen_image_single_device_step_execution",
+ "description": "Single-device baseline (no parallelism) with step execution",
+ "server_type": "vllm-omni",
+ "server_params": {
+ "model": "Qwen/Qwen-Image",
+ "serve_args": {
+ "enable-diffusion-pipeline-profiler": true,
+ "step-execution": true
+ }
+ },
+ "benchmark_params": [
+ {
+ "name": "512x512_steps20",
+ "dataset": "random",
+ "task": "t2i",
+ "width": 512,
+ "height": 512,
+ "num-inference-steps": 20,
+ "num-prompts": 10,
+ "max-concurrency": 1,
+ "enable-negative-prompt": true,
+ "baseline": {
+ "throughput_qps": 0.30,
+ "latency_mean": 3.50,
+ "peak_memory_mb_mean": 67000
+ }
+ },
+ {
+ "name": "1536x1536_steps35",
+ "dataset": "random",
+ "task": "t2i",
+ "width": 1536,
+ "height": 1536,
+ "num-inference-steps": 35,
+ "num-prompts": 10,
+ "max-concurrency": 1,
+ "enable-negative-prompt": true,
+ "baseline": {
+ "throughput_qps": 0.037,
+ "latency_mean": 27.0,
+ "peak_memory_mb_mean": 74000
+ }
+ }
+ ]
+ },
{
"test_name": "test_qwen_image_ulysses2_cfg2_vae_patch4",
"description": "Ulysses SP=2 + CFG-parallel=2 + VAE Patch Parallel=4",
@@ -59,22 +105,6 @@
}
},
"benchmark_params": [
- {
- "name": "512x512_steps20",
- "dataset": "random",
- "task": "t2i",
- "width": 512,
- "height": 512,
- "num-inference-steps": 20,
- "num-prompts": 10,
- "max-concurrency": 1,
- "enable-negative-prompt": true,
- "baseline": {
- "throughput_qps": 0.1,
- "latency_mean": 2.7,
- "peak_memory_mb_mean": 61000
- }
- },
{
"name": "1536x1536_steps35",
"dataset": "random",
diff --git a/tests/dfx/perf/tests/test.json b/tests/dfx/perf/tests/test_qwen_omni.json
similarity index 86%
rename from tests/dfx/perf/tests/test.json
rename to tests/dfx/perf/tests/test_qwen_omni.json
index 159e27a06..ca3eb5557 100644
--- a/tests/dfx/perf/tests/test.json
+++ b/tests/dfx/perf/tests/test_qwen_omni.json
@@ -3,7 +3,7 @@
"test_name": "test_qwen3_omni",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
- "stage_config_name": "qwen3_omni.yaml"
+ "extra_cli_args": ["--no-async-chunk"]
},
"benchmark_params": [
{
@@ -109,25 +109,7 @@
"test_name": "test_qwen3_omni_chunk",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
- "stage_config_name": "qwen3_omni.yaml",
- "update": {
- "async_chunk": true,
- "stage_args": {
- "0": {
- "engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk"
- },
- "1": {
- "engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_async_chunk"
- }
- }
- },
- "delete": {
- "stage_args": {
- "2": [
- "custom_process_input_func"
- ]
- }
- }
+ "extra_cli_args": ["--async-chunk"]
},
"benchmark_params": [
{
@@ -329,37 +311,5 @@
}
}
]
- },
- {
- "test_name": "test_qwen3_tts",
- "server_params": {
- "model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
- },
- "benchmark_params": [
- {
- "dataset_name": "random",
- "backend": "openai-audio-speech",
- "endpoint": "/v1/audio/speech",
- "num_prompts": [
- 10,
- 40
- ],
- "max_concurrency": [
- 1,
- 4
- ],
- "random_input_len": 100,
- "random_output_len": 100,
- "extra_body": {
- "voice": "Vivian",
- "language": "English"
- },
- "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
- "baseline": {
- "mean_audio_ttfp_ms": [6000, 6000],
- "mean_audio_rtf": [0.3, 0.3]
- }
- }
- ]
}
]
diff --git a/tests/dfx/perf/tests/test_tts.json b/tests/dfx/perf/tests/test_tts.json
new file mode 100644
index 000000000..3583b45b4
--- /dev/null
+++ b/tests/dfx/perf/tests/test_tts.json
@@ -0,0 +1,34 @@
+[
+ {
+ "test_name": "test_qwen3_tts",
+ "server_params": {
+ "model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
+ },
+ "benchmark_params": [
+ {
+ "dataset_name": "random",
+ "backend": "openai-audio-speech",
+ "endpoint": "/v1/audio/speech",
+ "num_prompts": [
+ 10,
+ 40
+ ],
+ "max_concurrency": [
+ 1,
+ 4
+ ],
+ "random_input_len": 100,
+ "random_output_len": 100,
+ "extra_body": {
+ "voice": "Vivian",
+ "language": "English"
+ },
+ "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
+ "baseline": {
+ "mean_audio_ttfp_ms": [6000, 6000],
+ "mean_audio_rtf": [0.3, 0.3]
+ }
+ }
+ ]
+ }
+]
diff --git a/tests/dfx/stability/conftest.py b/tests/dfx/stability/conftest.py
index 3a0aee760..e36c88b9a 100644
--- a/tests/dfx/stability/conftest.py
+++ b/tests/dfx/stability/conftest.py
@@ -1,125 +1,17 @@
-"""
-Stability-specific conftest: when pytest is executed under this directory,
-resource monitoring is started before each test and finalized after each test,
-so each stability test case gets its own HTML report (one report per case).
-No need to wrap pytest with `bash resource_monitor.sh run -- pytest ...`.
-"""
+"""Stability pytest hooks and fixtures."""
-import os
import subprocess
import sys
import threading
-import time
-from pathlib import Path
import pytest
-STABILITY_DIR = Path(__file__).resolve().parent
-RESOURCE_MONITOR_SCRIPT = STABILITY_DIR / "scripts" / "resource_monitor.sh"
-REPO_ROOT = STABILITY_DIR.parent.parent.parent
-
-
-def _start_resource_monitor():
- """Start `resource_monitor.sh start` in the background and return `Popen` or `None`."""
- if not RESOURCE_MONITOR_SCRIPT.is_file():
- return None
- try:
- proc = subprocess.Popen(
- ["bash", str(RESOURCE_MONITOR_SCRIPT), "start", "--backend", "gpu"],
- cwd=str(REPO_ROOT),
- stdout=subprocess.DEVNULL,
- stderr=subprocess.PIPE,
- start_new_session=True,
- )
- try:
- proc.wait(timeout=2)
- if proc.returncode != 0:
- stderr = proc.stderr.read().decode("utf-8", errors="ignore") if proc.stderr else ""
- if stderr.strip():
- sys.stderr.write(f"[Stability] Resource monitor failed to start: {stderr.strip()}\n")
- return None
- except subprocess.TimeoutExpired:
- pass
- return proc
- except (FileNotFoundError, OSError):
- return None
-
-
-def _get_monitor_data_root() -> Path:
- data_root = os.environ.get("RESOURCE_MONITOR_DATA_ROOT") or os.environ.get("GPU_MONITOR_DATA_ROOT")
- if data_root:
- return Path(data_root)
- return STABILITY_DIR / "gpu_monitor_data"
-
-
-def _wait_for_run_dir(timeout_sec: int = 10) -> Path | None:
- data_root = _get_monitor_data_root()
- run_id_file = data_root / "current_run_id"
- deadline = time.time() + timeout_sec
- while time.time() < deadline:
- if run_id_file.is_file():
- run_id = run_id_file.read_text(encoding="utf-8").strip()
- if run_id:
- run_dir = data_root / run_id
- if run_dir.is_dir():
- return run_dir
- time.sleep(0.5)
- return None
-
-
-def _report_latest_gpu_samples(stop_event: threading.Event) -> None:
- """Periodically print the latest sampled GPU line."""
- log_interval = int(
- os.environ.get("RESOURCE_MONITOR_LOG_INTERVAL") or os.environ.get("GPU_MONITOR_LOG_INTERVAL") or "15"
- )
- log_interval = max(log_interval, 1)
- last_line = ""
-
- time.sleep(min(log_interval, 5))
- while not stop_event.wait(log_interval):
- run_dir = _wait_for_run_dir(timeout_sec=1)
- if run_dir is None:
- continue
- csv_file = run_dir / "gpu_metrics.csv"
- if not csv_file.is_file():
- continue
- try:
- lines = csv_file.read_text(encoding="utf-8").splitlines()
- except OSError:
- continue
- if len(lines) <= 1:
- continue
- latest = lines[-1].strip()
- if latest and latest != last_line:
- last_line = latest
- sys.stderr.write(f"[GPU] {latest}\n")
-
-
-def _finalize_resource_monitor() -> str | None:
- """
- Run `resource_monitor.sh finalize` for the current run and generate the report.
- Returns the bundle dir path (for this test case's report) if successful, else None.
- """
- if not RESOURCE_MONITOR_SCRIPT.is_file():
- return None
- try:
- result = subprocess.run(
- ["bash", str(RESOURCE_MONITOR_SCRIPT), "finalize", "--backend", "gpu"],
- cwd=str(REPO_ROOT),
- capture_output=True,
- text=True,
- timeout=60,
- check=False,
- )
- if result.returncode != 0:
- return None
- for line in (result.stdout or "").splitlines():
- if line.startswith("GPU_MONITOR_BUNDLE_DIR=") or line.startswith("RESOURCE_MONITOR_BUNDLE_DIR="):
- _, _, value = line.partition("=")
- return value.strip() if value else None
- return None
- except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
- return None
+from tests.dfx.stability.helpers import (
+ finalize_resource_monitor,
+ report_latest_gpu_samples,
+ start_resource_monitor,
+ wait_for_run_dir,
+)
@pytest.fixture(autouse=True)
@@ -128,19 +20,19 @@ def stability_resource_monitor_per_test(request: pytest.FixtureRequest):
For each test under this directory: start GPU monitor before the test,
then finalize after the test so this case gets its own report.html.
"""
- proc = _start_resource_monitor()
+ proc = start_resource_monitor()
stop_event = threading.Event()
reporter: threading.Thread | None = None
if proc is not None:
reporter = threading.Thread(
- target=_report_latest_gpu_samples,
+ target=report_latest_gpu_samples,
args=(stop_event,),
name="stability-resource-monitor-reporter",
daemon=True,
)
reporter.start()
- run_dir = _wait_for_run_dir(timeout_sec=5)
+ run_dir = wait_for_run_dir(timeout_sec=5)
node_name = request.node.name
if run_dir is not None:
sys.stderr.write(f"[Stability] Resource monitor started for test: {node_name} | run dir: {run_dir}\n")
@@ -161,7 +53,7 @@ def stability_resource_monitor_per_test(request: pytest.FixtureRequest):
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
- bundle_dir = _finalize_resource_monitor()
+ bundle_dir = finalize_resource_monitor()
node_name = request.node.name
if bundle_dir:
sys.stderr.write(f"[Stability] Report for test «{node_name}»: {bundle_dir}/report.html\n")
diff --git a/tests/dfx/stability/helpers.py b/tests/dfx/stability/helpers.py
new file mode 100644
index 000000000..3956bd213
--- /dev/null
+++ b/tests/dfx/stability/helpers.py
@@ -0,0 +1,117 @@
+"""Stability resource monitor helpers."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+
+STABILITY_DIR = Path(__file__).resolve().parent
+RESOURCE_MONITOR_SCRIPT = STABILITY_DIR / "scripts" / "resource_monitor.sh"
+REPO_ROOT = STABILITY_DIR.parent.parent.parent
+
+
+def start_resource_monitor():
+ """Start `resource_monitor.sh start` in the background and return `Popen` or `None`."""
+ if not RESOURCE_MONITOR_SCRIPT.is_file():
+ return None
+ try:
+ proc = subprocess.Popen(
+ ["bash", str(RESOURCE_MONITOR_SCRIPT), "start", "--backend", "gpu"],
+ cwd=str(REPO_ROOT),
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ start_new_session=True,
+ )
+ try:
+ proc.wait(timeout=2)
+ if proc.returncode != 0:
+ stderr = proc.stderr.read().decode("utf-8", errors="ignore") if proc.stderr else ""
+ if stderr.strip():
+ sys.stderr.write(f"[Stability] Resource monitor failed to start: {stderr.strip()}\n")
+ return None
+ except subprocess.TimeoutExpired:
+ pass
+ return proc
+ except (FileNotFoundError, OSError):
+ return None
+
+
+def get_monitor_data_root() -> Path:
+ data_root = os.environ.get("RESOURCE_MONITOR_DATA_ROOT") or os.environ.get("GPU_MONITOR_DATA_ROOT")
+ if data_root:
+ return Path(data_root)
+ return STABILITY_DIR / "gpu_monitor_data"
+
+
+def wait_for_run_dir(timeout_sec: int = 10) -> Path | None:
+ data_root = get_monitor_data_root()
+ run_id_file = data_root / "current_run_id"
+ deadline = time.time() + timeout_sec
+ while time.time() < deadline:
+ if run_id_file.is_file():
+ run_id = run_id_file.read_text(encoding="utf-8").strip()
+ if run_id:
+ run_dir = data_root / run_id
+ if run_dir.is_dir():
+ return run_dir
+ time.sleep(0.5)
+ return None
+
+
+def report_latest_gpu_samples(stop_event: threading.Event) -> None:
+ """Periodically print the latest sampled GPU line."""
+ log_interval = int(
+ os.environ.get("RESOURCE_MONITOR_LOG_INTERVAL") or os.environ.get("GPU_MONITOR_LOG_INTERVAL") or "15"
+ )
+ log_interval = max(log_interval, 1)
+ last_line = ""
+
+ time.sleep(min(log_interval, 5))
+ while not stop_event.wait(log_interval):
+ run_dir = wait_for_run_dir(timeout_sec=1)
+ if run_dir is None:
+ continue
+ csv_file = run_dir / "gpu_metrics.csv"
+ if not csv_file.is_file():
+ continue
+ try:
+ lines = csv_file.read_text(encoding="utf-8").splitlines()
+ except OSError:
+ continue
+ if len(lines) <= 1:
+ continue
+ latest = lines[-1].strip()
+ if latest and latest != last_line:
+ last_line = latest
+ sys.stderr.write(f"[GPU] {latest}\n")
+
+
+def finalize_resource_monitor() -> str | None:
+ """
+ Run `resource_monitor.sh finalize` for the current run and generate the report.
+ Returns the bundle dir path (for this test case's report) if successful, else None.
+ """
+ if not RESOURCE_MONITOR_SCRIPT.is_file():
+ return None
+ try:
+ result = subprocess.run(
+ ["bash", str(RESOURCE_MONITOR_SCRIPT), "finalize", "--backend", "gpu"],
+ cwd=str(REPO_ROOT),
+ capture_output=True,
+ text=True,
+ timeout=60,
+ check=False,
+ )
+ if result.returncode != 0:
+ return None
+ for line in (result.stdout or "").splitlines():
+ if line.startswith("GPU_MONITOR_BUNDLE_DIR=") or line.startswith("RESOURCE_MONITOR_BUNDLE_DIR="):
+ _, _, value = line.partition("=")
+ return value.strip() if value else None
+ return None
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
+ return None
diff --git a/tests/dfx/stability/scripts/test_benchmark_stability.py b/tests/dfx/stability/scripts/test_benchmark_stability.py
index e8568652d..620241762 100644
--- a/tests/dfx/stability/scripts/test_benchmark_stability.py
+++ b/tests/dfx/stability/scripts/test_benchmark_stability.py
@@ -24,7 +24,6 @@ from typing import Any
import pytest
-from tests.conftest import OmniServer
from tests.dfx.conftest import (
create_benchmark_indices,
create_test_parameter_mapping,
@@ -33,9 +32,10 @@ from tests.dfx.conftest import (
load_configs,
)
from tests.dfx.perf.scripts.run_benchmark import run_benchmark
+from tests.helpers.runtime import OmniServer
STABILITY_DIR = Path(__file__).resolve().parent.parent
-STAGE_CONFIGS_DIR = STABILITY_DIR / "stage_configs"
+DEPLOY_CONFIGS_DIR = STABILITY_DIR / "deploy"
CONFIG_FILE_PATH = str(STABILITY_DIR / "tests" / "test.json")
DEFAULT_NUM_PROMPTS_PER_BATCH = 20
@@ -45,7 +45,7 @@ try:
except FileNotFoundError:
BENCHMARK_CONFIGS = []
-test_params = create_unique_server_params(BENCHMARK_CONFIGS, STAGE_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
+test_params = create_unique_server_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {}
_omni_server_lock = threading.Lock()
@@ -112,6 +112,8 @@ def _run_one_benchmark_batch(
flow=flow,
dataset_name=dataset_name,
num_prompt=num_prompts,
+ random_input_len=params.get("random_input_len"),
+ random_output_len=params.get("random_output_len"),
)
return result
except (FileNotFoundError, OSError) as e:
@@ -217,11 +219,20 @@ def omni_server(request):
Multi-stage initialization can take 10-20+ minutes.
"""
with _omni_server_lock:
- test_name, model, stage_config_path = request.param
+ test_name, model, stage_config_path, stage_overrides, extra_cli_args = request.param
print(f"Starting OmniServer with test: {test_name}, model: {model}")
- with OmniServer(model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "120"]) as server:
+ server_args = ["--stage-init-timeout", "120"]
+ # --deploy-config and --stage-overrides compose at the CLI (see vllm_omni/entrypoints/utils.py):
+ # deploy-config sets the base; stage-overrides are applied on top. Both can be set.
+ if stage_config_path:
+ server_args = ["--deploy-config", stage_config_path] + server_args
+ if stage_overrides:
+ server_args = ["--stage-overrides", stage_overrides] + server_args
+ if extra_cli_args:
+ server_args = list(extra_cli_args) + server_args
+ with OmniServer(model, server_args) as server:
server.test_name = test_name
print("OmniServer started successfully")
yield server
diff --git a/tests/dfx/stability/stage_configs/qwen3_omni.yaml b/tests/dfx/stability/stage_configs/qwen3_omni.yaml
deleted file mode 100644
index 802f8dd24..000000000
--- a/tests/dfx/stability/stage_configs/qwen3_omni.yaml
+++ /dev/null
@@ -1,101 +0,0 @@
-# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
-# Stage 0: Thinker (multimodal understanding + text generation)
-# Stage 1: Talker (text embeddings → 8-layer RVQ codec codes)
-# Stage 2: Code2Wav (8-layer RVQ codes → audio waveform)
-
-# The following config has been verified on 2x H100-80G GPUs.
-async_chunk: false
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type to launch OmniLLM
- runtime:
- devices: "0"
- max_batch_size: 64
- engine_args:
- model_stage: thinker
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.9
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output hidden states for talker
- distributed_executor_backend: "mp"
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- hf_config_name: thinker_config
- tensor_parallel_size: 1
- final_output: true
- final_output_type: text
- is_comprehension: true
- default_sampling_params:
- temperature: 0.4
- top_p: 0.9
- top_k: 1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
-
- - stage_id: 1
- stage_type: llm # Use llm stage type to launch OmniLLM
- runtime:
- devices: "1"
- max_batch_size: 64
- engine_args:
- model_stage: talker
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.6
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output codec codes for code2wav
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- distributed_executor_backend: "mp"
- hf_config_name: talker_config
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
- # final_output: true
- # final_output_type: text
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: False
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 2
- stage_type: llm # Use llm stage type to launch OmniLLM
- runtime:
- devices: "1"
- max_batch_size: 64
- engine_args:
- model_stage: code2wav
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: false
- enable_prefix_caching: false
- engine_output_type: audio # Final output: audio waveform
- gpu_memory_utilization: 0.1
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 1000000
- hf_config_name: thinker_config
- engine_input_source: [1]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
diff --git a/tests/dfx/stability/tests/test.json b/tests/dfx/stability/tests/test.json
index 95993c9c5..255cd5b10 100644
--- a/tests/dfx/stability/tests/test.json
+++ b/tests/dfx/stability/tests/test.json
@@ -3,7 +3,11 @@
"test_name": "test_qwen3_omni_stability",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
- "stage_config_name": "qwen3_omni.yaml"
+ "stage_overrides": {
+ "2": {
+ "max_num_batched_tokens": 1000000
+ }
+ }
},
"benchmark_params": [
{
@@ -36,25 +40,12 @@
"test_name": "test_qwen3_omni_stability_async_chunk",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
- "stage_config_name": "qwen3_omni.yaml",
- "update": {
- "async_chunk": true,
- "stage_args": {
- "0": {
- "engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk"
- },
- "1": {
- "engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_async_chunk"
- }
+ "stage_overrides": {
+ "2": {
+ "max_num_batched_tokens": 1000000
}
},
- "delete": {
- "stage_args": {
- "2": [
- "custom_process_input_func"
- ]
- }
- }
+ "extra_cli_args": ["--async-chunk"]
},
"benchmark_params": [
{
diff --git a/tests/diffusion/cache/test_cache_dit.py b/tests/diffusion/cache/test_cache_dit.py
new file mode 100644
index 000000000..0b7ef7235
--- /dev/null
+++ b/tests/diffusion/cache/test_cache_dit.py
@@ -0,0 +1,40 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+"""
+Model specific tests for CacheDiT enablement.
+"""
+
+from unittest.mock import Mock, patch
+
+import pytest
+
+import vllm_omni.diffusion.cache.cache_dit_backend as cd_backend
+from vllm_omni.diffusion.data import DiffusionCacheConfig
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+SEPARATE_CFG_ENABLERS = [
+ cd_backend.enable_cache_for_ltx2,
+ cd_backend.enable_cache_for_wan22,
+ cd_backend.enable_cache_for_longcat_image,
+]
+
+SAMPLE_CACHE_CONFIG = DiffusionCacheConfig()
+
+
+@pytest.mark.parametrize("enabler", SEPARATE_CFG_ENABLERS)
+@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
+@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
+def test_separate_cfg(mock_cache_dit, mock_block_adapter, enabler):
+ """Ensure that custom enablers for models with separate CFG pass
+ the param through to cache_dit correctly.
+
+ Regression test for: https://github.com/vllm-project/vllm-omni/pull/2860
+ """
+ mock_pipeline = Mock()
+ enabler(mock_pipeline, SAMPLE_CACHE_CONFIG)
+
+ mock_cache_dit.enable_cache.assert_called_once()
+ adapter_kwargs = mock_block_adapter.call_args.kwargs
+ assert adapter_kwargs["has_separate_cfg"] is True
diff --git a/tests/diffusion/cache/test_teacache_extractors.py b/tests/diffusion/cache/test_teacache_extractors.py
index c22a60e22..4bb958a36 100644
--- a/tests/diffusion/cache/test_teacache_extractors.py
+++ b/tests/diffusion/cache/test_teacache_extractors.py
@@ -21,7 +21,7 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
import torch
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.cache.teacache.extractors import extract_flux2_context, extract_flux2_klein_context
from vllm_omni.diffusion.models.flux2_klein.flux2_klein_transformer import (
Flux2Transformer2DModel,
diff --git a/tests/diffusion/distributed/test_ulysses_uaa_perf.py b/tests/diffusion/distributed/test_ulysses_uaa_perf.py
index 04bbf5ee8..2a16a9ae5 100644
--- a/tests/diffusion/distributed/test_ulysses_uaa_perf.py
+++ b/tests/diffusion/distributed/test_ulysses_uaa_perf.py
@@ -17,7 +17,7 @@ import pytest
import torch
import torch.distributed as dist
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.attention.parallel.ulysses import (
_all_gather_int,
_ulysses_all_to_all_any_o,
diff --git a/tests/diffusion/layers/test_norm.py b/tests/diffusion/layers/test_norm.py
new file mode 100644
index 000000000..e42041528
--- /dev/null
+++ b/tests/diffusion/layers/test_norm.py
@@ -0,0 +1,453 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Unit tests for LayerNorm and RMSNorm custom ops in diffusion layers."""
+
+import pytest
+import torch
+
+pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
+
+
+# ── Import tests ──
+
+
+def test_layernorm_import():
+ """Verify LayerNorm can be imported from the norm module."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm # noqa: F401
+
+
+def test_rmsnorm_import():
+ """Verify RMSNorm can be imported from the norm module."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm # noqa: F401
+
+
+# ── LayerNorm tests ──
+
+
+def test_layernorm_forward_shape():
+ """LayerNorm produces correct output shapes."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 64
+ batch = 2
+ seq_len = 4
+ norm = LayerNorm(dim)
+
+ x = torch.randn(batch, seq_len, dim)
+ out = norm(x)
+
+ assert out.shape == (batch, seq_len, dim)
+
+
+def test_layernorm_forward_shape_2d():
+ """LayerNorm works with 2D input tensors."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 64
+ batch = 2
+ norm = LayerNorm(dim)
+
+ x = torch.randn(batch, dim)
+ out = norm(x)
+
+ assert out.shape == (batch, dim)
+
+
+def test_layernorm_preserves_dtype_fp32():
+ """LayerNorm preserves float32 dtype."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 64
+ norm = LayerNorm(dim)
+
+ x = torch.randn(2, 4, dim, dtype=torch.float32)
+ out = norm(x)
+
+ assert out.dtype == torch.float32
+
+
+def test_layernorm_preserves_dtype_fp16():
+ """LayerNorm preserves float16 dtype."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 64
+ norm = LayerNorm(dim)
+
+ x = torch.randn(2, 4, dim, dtype=torch.float16)
+ out = norm(x)
+
+ assert out.dtype == torch.float16
+
+
+def test_layernorm_preserves_dtype_bf16():
+ """LayerNorm preserves bfloat16 dtype."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 64
+ norm = LayerNorm(dim)
+
+ x = torch.randn(2, 4, dim, dtype=torch.bfloat16)
+ out = norm(x)
+
+ assert out.dtype == torch.bfloat16
+
+
+def test_layernorm_without_elementwise_affine():
+ """LayerNorm works without elementwise_affine (no learned parameters)."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 64
+ norm = LayerNorm(dim, elementwise_affine=False)
+
+ assert norm.weight is None
+ assert norm.bias is None
+
+ x = torch.randn(2, 4, dim)
+ out = norm(x)
+
+ assert out.shape == (2, 4, dim)
+
+
+def test_layernorm_custom_eps():
+ """LayerNorm accepts custom epsilon value."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 64
+ eps = 1e-5
+ norm = LayerNorm(dim, eps=eps)
+
+ assert norm.eps == eps
+
+
+def test_layernorm_has_learnable_parameters():
+ """LayerNorm has learnable weight and bias by default."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 64
+ norm = LayerNorm(dim)
+
+ assert norm.weight is not None
+ assert norm.bias is not None
+ assert norm.weight.shape == (dim,)
+ assert norm.bias.shape == (dim,)
+
+
+def test_layernorm_matches_fp32_reference():
+ """Verify LayerNorm produces identical output to FP32 nn.LayerNorm."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 64
+ eps = 1e-6
+ torch.manual_seed(42)
+
+ ours = LayerNorm(dim, eps=eps)
+ ref = torch.nn.LayerNorm(dim, eps=eps)
+
+ # Copy weights
+ ref.weight.data.copy_(ours.weight.data)
+ ref.bias.data.copy_(ours.bias.data)
+
+ x = torch.randn(2, 4, dim)
+
+ out_ours = ours(x)
+ out_ref = ref(x.float()).to(x.dtype)
+
+ torch.testing.assert_close(out_ours, out_ref, atol=1e-5, rtol=1e-5)
+
+
+def test_layernorm_matches_diffusers_fp32layernorm():
+ """Verify LayerNorm produces identical output to diffusers FP32LayerNorm."""
+ from diffusers.models.normalization import FP32LayerNorm
+
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 64
+ eps = 1e-6
+ torch.manual_seed(42)
+
+ ours = LayerNorm(dim, eps=eps)
+ ref = FP32LayerNorm(dim, eps=eps)
+
+ # Copy weights
+ ref.weight.data.copy_(ours.weight.data)
+ ref.bias.data.copy_(ours.bias.data)
+
+ # Test with fp16 input to verify FP32 computation
+ x = torch.randn(2, 4, dim, dtype=torch.float16)
+
+ out_ours = ours(x)
+ out_ref = ref(x)
+
+ torch.testing.assert_close(out_ours, out_ref, atol=1e-3, rtol=1e-3)
+
+
+# ── RMSNorm tests ──
+
+
+def test_rmsnorm_forward_shape():
+ """RMSNorm produces correct output shapes."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ hidden_size = 64
+ batch = 2
+ seq_len = 4
+ norm = RMSNorm(hidden_size)
+
+ x = torch.randn(batch, seq_len, hidden_size)
+ out = norm(x)
+
+ assert out.shape == (batch, seq_len, hidden_size)
+
+
+def test_rmsnorm_forward_shape_2d():
+ """RMSNorm works with 2D input tensors."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ hidden_size = 64
+ batch = 2
+ norm = RMSNorm(hidden_size)
+
+ x = torch.randn(batch, hidden_size)
+ out = norm(x)
+
+ assert out.shape == (batch, hidden_size)
+
+
+def test_rmsnorm_preserves_dtype_fp32():
+ """RMSNorm preserves float32 dtype."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ hidden_size = 64
+ norm = RMSNorm(hidden_size)
+
+ x = torch.randn(2, 4, hidden_size, dtype=torch.float32)
+ out = norm(x)
+
+ assert out.dtype == torch.float32
+
+
+def test_rmsnorm_preserves_dtype_fp16():
+ """RMSNorm preserves float16 dtype."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ hidden_size = 64
+ norm = RMSNorm(hidden_size)
+
+ x = torch.randn(2, 4, hidden_size, dtype=torch.float16)
+ out = norm(x)
+
+ assert out.dtype == torch.float16
+
+
+def test_rmsnorm_preserves_dtype_bf16():
+ """RMSNorm preserves bfloat16 dtype."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ hidden_size = 64
+ norm = RMSNorm(hidden_size)
+
+ x = torch.randn(2, 4, hidden_size, dtype=torch.bfloat16)
+ out = norm(x)
+
+ assert out.dtype == torch.bfloat16
+
+
+def test_rmsnorm_custom_eps():
+ """RMSNorm accepts custom epsilon value."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ hidden_size = 64
+ eps = 1e-5
+ norm = RMSNorm(hidden_size, eps=eps)
+
+ assert norm.variance_epsilon == eps
+
+
+def test_rmsnorm_has_weight_parameter():
+ """RMSNorm has learnable weight parameter initialized to ones."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ hidden_size = 64
+ norm = RMSNorm(hidden_size)
+
+ assert norm.weight is not None
+ assert norm.weight.shape == (hidden_size,)
+ torch.testing.assert_close(norm.weight, torch.ones(hidden_size))
+
+
+def test_rmsnorm_numerical_correctness():
+ """Verify RMSNorm produces numerically correct output."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ hidden_size = 64
+ eps = 1e-6
+ torch.manual_seed(42)
+
+ norm = RMSNorm(hidden_size, eps=eps)
+ x = torch.randn(2, 4, hidden_size)
+
+ # Compute expected output manually
+ x_fp32 = x.to(torch.float32)
+ variance = x_fp32.pow(2).mean(-1, keepdim=True)
+ expected = x_fp32 * torch.rsqrt(variance + eps)
+ expected = norm.weight.to(torch.float32) * expected
+ expected = expected.to(x.dtype)
+
+ out = norm(x)
+
+ torch.testing.assert_close(out, expected, atol=1e-5, rtol=1e-5)
+
+
+def test_rmsnorm_matches_reference_implementation():
+ """Verify RMSNorm matches a reference implementation."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ def reference_rmsnorm(x, weight, eps):
+ """Reference RMSNorm implementation."""
+ input_dtype = x.dtype
+ x = x.to(torch.float32)
+ variance = x.pow(2).mean(-1, keepdim=True)
+ out = x * torch.rsqrt(variance + eps)
+ out = weight.to(torch.float32) * out
+ return out.to(input_dtype)
+
+ hidden_size = 128
+ eps = 1e-6
+ torch.manual_seed(123)
+
+ norm = RMSNorm(hidden_size, eps=eps)
+
+ # Test with various dtypes
+ for dtype in [torch.float32, torch.float16, torch.bfloat16]:
+ x = torch.randn(4, 8, hidden_size, dtype=dtype)
+ expected = reference_rmsnorm(x, norm.weight, eps)
+ out = norm(x)
+ torch.testing.assert_close(out, expected, atol=1e-3, rtol=1e-3)
+
+
+# ── CustomOp dispatch tests ──
+
+
+def test_layernorm_inherits_from_customop():
+ """LayerNorm inherits from CustomOp for platform dispatch."""
+ from vllm_omni.diffusion.layers.custom_op import CustomOp
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ norm = LayerNorm(64)
+ assert isinstance(norm, CustomOp)
+
+
+def test_rmsnorm_inherits_from_customop():
+ """RMSNorm inherits from CustomOp for platform dispatch."""
+ from vllm_omni.diffusion.layers.custom_op import CustomOp
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ norm = RMSNorm(64)
+ assert isinstance(norm, CustomOp)
+
+
+def test_layernorm_has_platform_methods():
+ """LayerNorm has forward methods for each platform."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ norm = LayerNorm(64)
+
+ assert hasattr(norm, "forward_cuda")
+ assert hasattr(norm, "forward_hip")
+ assert hasattr(norm, "forward_xpu")
+ assert hasattr(norm, "forward_npu")
+ assert hasattr(norm, "forward_native")
+
+
+def test_rmsnorm_has_platform_methods():
+ """RMSNorm has forward methods for each platform."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ norm = RMSNorm(64)
+
+ assert hasattr(norm, "forward_cuda")
+ assert hasattr(norm, "forward_hip")
+ assert hasattr(norm, "forward_xpu")
+ assert hasattr(norm, "forward_npu")
+ assert hasattr(norm, "forward_native")
+
+
+def test_layernorm_forward_native_directly():
+ """LayerNorm.forward_native can be called directly."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 64
+ norm = LayerNorm(dim)
+ x = torch.randn(2, 4, dim)
+
+ out = norm.forward_native(x)
+
+ assert out.shape == (2, 4, dim)
+
+
+def test_rmsnorm_forward_native_directly():
+ """RMSNorm.forward_native can be called directly."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ hidden_size = 64
+ norm = RMSNorm(hidden_size)
+ x = torch.randn(2, 4, hidden_size)
+
+ out = norm.forward_native(x)
+
+ assert out.shape == (2, 4, hidden_size)
+
+
+# ── Edge case tests ──
+
+
+def test_layernorm_with_large_dim():
+ """LayerNorm works with large hidden dimensions."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 4096
+ norm = LayerNorm(dim)
+ x = torch.randn(1, 16, dim)
+
+ out = norm(x)
+
+ assert out.shape == (1, 16, dim)
+
+
+def test_rmsnorm_with_large_dim():
+ """RMSNorm works with large hidden dimensions."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ hidden_size = 4096
+ norm = RMSNorm(hidden_size)
+ x = torch.randn(1, 16, hidden_size)
+
+ out = norm(x)
+
+ assert out.shape == (1, 16, hidden_size)
+
+
+def test_layernorm_with_single_element_batch():
+ """LayerNorm works with batch size of 1."""
+ from vllm_omni.diffusion.layers.norm import LayerNorm
+
+ dim = 64
+ norm = LayerNorm(dim)
+ x = torch.randn(1, 1, dim)
+
+ out = norm(x)
+
+ assert out.shape == (1, 1, dim)
+
+
+def test_rmsnorm_with_single_element_batch():
+ """RMSNorm works with batch size of 1."""
+ from vllm_omni.diffusion.layers.norm import RMSNorm
+
+ hidden_size = 64
+ norm = RMSNorm(hidden_size)
+ x = torch.randn(1, 1, hidden_size)
+
+ out = norm(x)
+
+ assert out.shape == (1, 1, hidden_size)
diff --git a/tests/diffusion/layers/test_rotary_emb_equivalence.py b/tests/diffusion/layers/test_rotary_emb_equivalence.py
new file mode 100644
index 000000000..2fbb7a31f
--- /dev/null
+++ b/tests/diffusion/layers/test_rotary_emb_equivalence.py
@@ -0,0 +1,112 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""
+Numerical equivalence tests for rotary embedding implementations (#2436).
+
+Verifies that the optimized stack+flatten RoPE produces bit-identical results
+to the original strided-slice implementation across various tensor shapes and
+dtypes, ensuring the refactor is safe.
+"""
+
+from __future__ import annotations
+
+import pytest
+import torch
+
+
+def _apply_rotary_emb_helios_original(
+ hidden_states: torch.Tensor,
+ freqs_cis: torch.Tensor,
+) -> torch.Tensor:
+ """Original Helios RoPE using strided slice assignment (pre-#2436)."""
+ x_1, x_2 = hidden_states.unflatten(-1, (-1, 2)).unbind(-1)
+ cos, sin = freqs_cis.unsqueeze(-2).chunk(2, dim=-1)
+ out = torch.empty_like(hidden_states)
+ out[..., 0::2] = x_1 * cos[..., 0::2] - x_2 * sin[..., 1::2]
+ out[..., 1::2] = x_1 * sin[..., 1::2] + x_2 * cos[..., 0::2]
+ return out.type_as(hidden_states)
+
+
+def _apply_rotary_emb_helios_optimized(
+ hidden_states: torch.Tensor,
+ freqs_cis: torch.Tensor,
+) -> torch.Tensor:
+ """Optimized Helios RoPE using stack+flatten (post-#2436)."""
+ x_1, x_2 = hidden_states.unflatten(-1, (-1, 2)).unbind(-1)
+ cos, sin = freqs_cis.unsqueeze(-2).chunk(2, dim=-1)
+ rotated = torch.stack(
+ (
+ x_1 * cos[..., 0::2] - x_2 * sin[..., 1::2],
+ x_1 * sin[..., 1::2] + x_2 * cos[..., 0::2],
+ ),
+ dim=-1,
+ )
+ return rotated.flatten(-2, -1).type_as(hidden_states)
+
+
+def _make_inputs(
+ batch: int,
+ seq_len: int,
+ num_heads: int,
+ head_dim: int,
+ dtype: torch.dtype = torch.float32,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """Generate random hidden_states and freqs_cis for testing."""
+ torch.manual_seed(42)
+ hidden_states = torch.randn(batch, seq_len, num_heads, head_dim, dtype=dtype)
+ # freqs_cis: [B, seq, head_dim*2] — cos and sin concatenated along last dim
+ freqs_cis = torch.randn(batch, seq_len, head_dim * 2, dtype=dtype)
+ return hidden_states, freqs_cis
+
+
+class TestHeliosRoPEEquivalence:
+ """Verify optimized Helios RoPE is numerically identical to original."""
+
+ @pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
+ def test_equivalence_across_dtypes(self, dtype: torch.dtype) -> None:
+ """Optimized output must be bit-identical to original across dtypes."""
+ hidden, freqs = _make_inputs(2, 16, 8, 64, dtype=dtype)
+ original = _apply_rotary_emb_helios_original(hidden, freqs)
+ optimized = _apply_rotary_emb_helios_optimized(hidden, freqs)
+ torch.testing.assert_close(optimized, original, atol=0, rtol=0)
+
+ @pytest.mark.parametrize(
+ "batch,seq_len,num_heads,head_dim",
+ [
+ (1, 8, 1, 32), # minimal: single batch, single head
+ (2, 16, 8, 64), # typical transformer config
+ (1, 8192, 4, 64), # video-scale patch tokens (720p DiT)
+ (4, 32, 16, 128), # large head_dim
+ ],
+ )
+ def test_equivalence_across_shapes(self, batch: int, seq_len: int, num_heads: int, head_dim: int) -> None:
+ """Equivalence must hold across different tensor shapes."""
+ hidden, freqs = _make_inputs(batch, seq_len, num_heads, head_dim)
+ original = _apply_rotary_emb_helios_original(hidden, freqs)
+ optimized = _apply_rotary_emb_helios_optimized(hidden, freqs)
+ torch.testing.assert_close(optimized, original, atol=0, rtol=0)
+
+ def test_output_contiguous(self) -> None:
+ """Optimized output should be contiguous in memory."""
+ hidden, freqs = _make_inputs(2, 16, 8, 64)
+ optimized = _apply_rotary_emb_helios_optimized(hidden, freqs)
+ assert optimized.is_contiguous()
+
+ def test_output_shape_preserved(self) -> None:
+ """Output shape must match input shape."""
+ hidden, freqs = _make_inputs(2, 16, 8, 64)
+ optimized = _apply_rotary_emb_helios_optimized(hidden, freqs)
+ assert optimized.shape == hidden.shape
+
+ def test_output_dtype_preserved(self) -> None:
+ """Output dtype must match input dtype."""
+ hidden, freqs = _make_inputs(2, 16, 8, 64, dtype=torch.float16)
+ optimized = _apply_rotary_emb_helios_optimized(hidden, freqs)
+ assert optimized.dtype == hidden.dtype
+
+ def test_odd_head_dim_raises(self) -> None:
+ """Odd head_dim should fail at unflatten (not a valid RoPE config)."""
+ hidden = torch.randn(1, 4, 2, 63)
+ freqs = torch.randn(1, 4, 126)
+ with pytest.raises(RuntimeError):
+ _apply_rotary_emb_helios_optimized(hidden, freqs)
diff --git a/tests/diffusion/lora/conftest.py b/tests/diffusion/lora/helpers.py
similarity index 100%
rename from tests/diffusion/lora/conftest.py
rename to tests/diffusion/lora/helpers.py
diff --git a/tests/diffusion/lora/test_lora_manager.py b/tests/diffusion/lora/test_lora_manager.py
index 83ac7a114..785f5d842 100644
--- a/tests/diffusion/lora/test_lora_manager.py
+++ b/tests/diffusion/lora/test_lora_manager.py
@@ -8,7 +8,7 @@ import torch
from vllm.lora.lora_weights import LoRALayerWeights
from vllm.lora.utils import get_supported_lora_modules
-from tests.diffusion.lora.conftest import (
+from tests.diffusion.lora.helpers import (
DummyBaseLayerWithLoRA,
FakeLinearBase,
fake_replace_submodule,
diff --git a/tests/diffusion/models/bagel/test_bagel_lora.py b/tests/diffusion/models/bagel/test_bagel_lora.py
index 8cb3446ed..c285758fe 100644
--- a/tests/diffusion/models/bagel/test_bagel_lora.py
+++ b/tests/diffusion/models/bagel/test_bagel_lora.py
@@ -11,7 +11,7 @@ import pytest
import torch
from safetensors.torch import save_file
-from tests.diffusion.lora.conftest import (
+from tests.diffusion.lora.helpers import (
DummyBaseLayerWithLoRA,
FakeLinearBase,
fake_replace_submodule,
diff --git a/tests/diffusion/models/dmd2/__init__.py b/tests/diffusion/models/dmd2/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/diffusion/models/dmd2/test_dmd2_request_sanitization.py b/tests/diffusion/models/dmd2/test_dmd2_request_sanitization.py
new file mode 100644
index 000000000..e270390bd
--- /dev/null
+++ b/tests/diffusion/models/dmd2/test_dmd2_request_sanitization.py
@@ -0,0 +1,180 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+from unittest.mock import MagicMock, patch
+
+import pytest
+import torch
+
+from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline, LTX2T2VDMD2Pipeline
+from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_image2video import LTX2I2VDMD2Pipeline, LTX2ImageToVideoPipeline
+from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline, WanT2VDMD2Pipeline
+from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v import Wan22I2VPipeline, WanI2VDMD2Pipeline
+from vllm_omni.diffusion.request import OmniDiffusionRequest, OmniDiffusionSamplingParams
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+# DMD2 subclass → immediate base pipeline whose __init__ loads model weights (mocked in tests).
+_DMD2_BASE = {
+ WanT2VDMD2Pipeline: Wan22Pipeline,
+ WanI2VDMD2Pipeline: Wan22I2VPipeline,
+ LTX2T2VDMD2Pipeline: LTX2Pipeline,
+ LTX2I2VDMD2Pipeline: LTX2ImageToVideoPipeline,
+}
+
+
+def _make_pipeline(cls):
+ """Run the DMD2 __init__ with the base pipeline mocked out (no model weights loaded)."""
+
+ base = _DMD2_BASE[cls]
+ od_config = MagicMock()
+ od_config.model = "/nonexistent"
+
+ def _mock_base_init(self, *a, **kw):
+ self.od_config = od_config
+
+ with patch.object(base, "__init__", _mock_base_init):
+ pipeline = object.__new__(cls)
+ torch.nn.Module.__init__(pipeline)
+ cls.__init__(pipeline, od_config=od_config)
+ return pipeline
+
+
+def _make_request(prompts=None, **sp_kwargs) -> OmniDiffusionRequest:
+ sp = OmniDiffusionSamplingParams(**sp_kwargs)
+ return OmniDiffusionRequest(
+ prompts=prompts or [{"prompt": "a cat dancing"}],
+ sampling_params=sp,
+ )
+
+
+@pytest.fixture(
+ params=list(_DMD2_BASE.keys()),
+ ids=["wan_t2v", "wan_i2v", "ltx2_t2v", "ltx2_i2v"],
+)
+def pipeline(request):
+ return _make_pipeline(request.param)
+
+
+# ---------------------------------------------------------------------------
+# num_inference_steps
+# ---------------------------------------------------------------------------
+
+
+def test_num_inference_steps_forced_to_dmd2_value(pipeline):
+ req = _make_request(num_inference_steps=40)
+ pipeline._sanitize_dmd2_request(req)
+ assert req.sampling_params.num_inference_steps == pipeline.num_inference_steps
+
+
+def test_num_inference_steps_already_correct(pipeline):
+ req = _make_request(num_inference_steps=pipeline.num_inference_steps)
+ pipeline._sanitize_dmd2_request(req)
+ assert req.sampling_params.num_inference_steps == pipeline.num_inference_steps
+
+
+# ---------------------------------------------------------------------------
+# guidance_scale
+# ---------------------------------------------------------------------------
+
+
+def test_guidance_scale_forced_to_one(pipeline):
+ req = _make_request(guidance_scale=5.0, guidance_scale_provided=True)
+ pipeline._sanitize_dmd2_request(req)
+ assert req.sampling_params.guidance_scale == pipeline.dmd2_guidance_scale
+ assert req.sampling_params.guidance_scale_provided is False
+
+
+def test_guidance_scale_already_correct(pipeline):
+ req = _make_request(guidance_scale=pipeline.dmd2_guidance_scale, guidance_scale_provided=False)
+ pipeline._sanitize_dmd2_request(req)
+ assert req.sampling_params.guidance_scale == pipeline.dmd2_guidance_scale
+
+
+def test_guidance_scale_provided_flag_cleared(pipeline):
+ """guidance_scale_provided=True must be cleared even if scale is already dmd2_guidance_scale."""
+ req = _make_request(guidance_scale=pipeline.dmd2_guidance_scale, guidance_scale_provided=True)
+ pipeline._sanitize_dmd2_request(req)
+ assert req.sampling_params.guidance_scale_provided is False
+
+
+def test_guidance_scale_2_cleared(pipeline):
+ req = _make_request(guidance_scale_2=3.0)
+ pipeline._sanitize_dmd2_request(req)
+ assert req.sampling_params.guidance_scale_2 is None
+
+
+def test_guidance_scale_2_unset_unchanged(pipeline):
+ req = _make_request()
+ pipeline._sanitize_dmd2_request(req)
+ assert req.sampling_params.guidance_scale_2 is None
+
+
+def test_true_cfg_scale_cleared(pipeline):
+ req = _make_request(true_cfg_scale=2.0)
+ pipeline._sanitize_dmd2_request(req)
+ assert req.sampling_params.true_cfg_scale is None
+
+
+def test_do_classifier_free_guidance_forced_false(pipeline):
+ req = _make_request(do_classifier_free_guidance=True)
+ pipeline._sanitize_dmd2_request(req)
+ assert req.sampling_params.do_classifier_free_guidance is False
+
+
+def test_is_cfg_negative_forced_false(pipeline):
+ req = _make_request(is_cfg_negative=True)
+ pipeline._sanitize_dmd2_request(req)
+ assert req.sampling_params.is_cfg_negative is False
+
+
+def test_negative_prompt_stripped_from_prompt_dict(pipeline):
+ req = _make_request(prompts=[{"prompt": "a cat", "negative_prompt": "blurry"}])
+ pipeline._sanitize_dmd2_request(req)
+ assert "negative_prompt" not in req.prompts[0]
+ assert req.prompts[0]["prompt"] == "a cat"
+
+
+def test_no_negative_prompt_unchanged(pipeline):
+ req = _make_request(prompts=[{"prompt": "a cat"}])
+ pipeline._sanitize_dmd2_request(req)
+ assert req.prompts[0] == {"prompt": "a cat"}
+
+
+def test_string_prompt_not_mutated(pipeline):
+ """String prompts (not dicts) must pass through unchanged."""
+ req = _make_request(prompts=["a cat dancing"])
+ pipeline._sanitize_dmd2_request(req)
+ assert req.prompts == ["a cat dancing"]
+
+
+def test_multiple_prompts_all_sanitized(pipeline):
+ req = _make_request(
+ prompts=[
+ {"prompt": "a cat", "negative_prompt": "blurry"},
+ {"prompt": "a dog", "negative_prompt": "ugly"},
+ ]
+ )
+ pipeline._sanitize_dmd2_request(req)
+ for p in req.prompts:
+ assert "negative_prompt" not in p
+
+
+# ---------------------------------------------------------------------------
+# Clean request — nothing changes
+# ---------------------------------------------------------------------------
+
+
+def test_clean_request_no_changes(pipeline):
+ req = _make_request(
+ guidance_scale=pipeline.dmd2_guidance_scale,
+ guidance_scale_provided=False,
+ do_classifier_free_guidance=False,
+ is_cfg_negative=False,
+ )
+ pipeline._sanitize_dmd2_request(req)
+ assert req.sampling_params.guidance_scale == pipeline.dmd2_guidance_scale
+ assert req.sampling_params.guidance_scale_provided is False
+ assert req.sampling_params.guidance_scale_2 is None
+ assert req.sampling_params.true_cfg_scale is None
+ assert req.sampling_params.do_classifier_free_guidance is False
+ assert req.sampling_params.is_cfg_negative is False
diff --git a/tests/diffusion/models/dmd2/test_dmd2_scheduler.py b/tests/diffusion/models/dmd2/test_dmd2_scheduler.py
new file mode 100644
index 000000000..32d00dbf1
--- /dev/null
+++ b/tests/diffusion/models/dmd2/test_dmd2_scheduler.py
@@ -0,0 +1,93 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+from unittest.mock import MagicMock, patch
+
+import pytest
+import torch
+
+from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline, LTX2T2VDMD2Pipeline
+from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_image2video import LTX2I2VDMD2Pipeline, LTX2ImageToVideoPipeline
+from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline, WanT2VDMD2Pipeline
+from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v import Wan22I2VPipeline, WanI2VDMD2Pipeline
+from vllm_omni.diffusion.request import OmniDiffusionRequest, OmniDiffusionSamplingParams
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+_DMD2_TIMESTEPS = [999, 937, 833, 624]
+
+# DMD2 subclass → immediate base pipeline whose __init__ loads model weights (mocked in tests).
+_DMD2_BASE = {
+ WanT2VDMD2Pipeline: Wan22Pipeline,
+ WanI2VDMD2Pipeline: Wan22I2VPipeline,
+ LTX2T2VDMD2Pipeline: LTX2Pipeline,
+ LTX2I2VDMD2Pipeline: LTX2ImageToVideoPipeline,
+}
+
+
+def _make_pipeline(cls):
+ """Run the DMD2 __init__ (including __init_dmd2__) with the base pipeline mocked."""
+
+ base = _DMD2_BASE[cls]
+ od_config = MagicMock()
+ od_config.model = "/nonexistent"
+
+ def _mock_base_init(self, *a, **kw):
+ self.od_config = od_config # __init_dmd2__ needs this
+
+ with patch.object(base, "__init__", _mock_base_init):
+ pipeline = object.__new__(cls)
+ torch.nn.Module.__init__(pipeline)
+ cls.__init__(pipeline, od_config=od_config)
+ return pipeline
+
+
+def _make_request(**sp_kwargs) -> OmniDiffusionRequest:
+ sp = OmniDiffusionSamplingParams(**sp_kwargs)
+ return OmniDiffusionRequest(prompts=[{"prompt": "a cat"}], sampling_params=sp)
+
+
+@pytest.fixture(
+ params=list(_DMD2_BASE.keys()),
+ ids=["wan_t2v", "wan_i2v", "ltx2_t2v", "ltx2_i2v"],
+)
+def pipeline(request):
+ return _make_pipeline(request.param)
+
+
+# ---------------------------------------------------------------------------
+# forward() timestep injection
+# ---------------------------------------------------------------------------
+
+
+def _fake_parent_forward(self, req, *args, num_inference_steps=40, **kwargs):
+ """Stub that calls set_timesteps as the real parent does."""
+ self.scheduler.set_timesteps(num_inference_steps, device="cpu")
+ return MagicMock()
+
+
+def test_forward_timesteps_match_dmd2_schedule(pipeline):
+ """After forward() runs, scheduler.timesteps must equal the DMD2 training schedule."""
+ parent = _DMD2_BASE[type(pipeline)]
+
+ # Baseline: calling set_timesteps(40) without the DMD2 override gives a different schedule
+ pipeline.scheduler.set_timesteps(40, device="cpu")
+ default_timesteps = pipeline.scheduler.timesteps.long().tolist()
+ assert default_timesteps == _DMD2_TIMESTEPS, (
+ "DMD2EulerScheduler should always return DMD2 timesteps regardless of num_steps"
+ )
+
+ with patch.object(parent, "forward", _fake_parent_forward):
+ pipeline.forward(_make_request())
+
+ assert pipeline.scheduler.timesteps.long().tolist() == _DMD2_TIMESTEPS
+
+
+def test_forward_timesteps_idempotent_across_calls(pipeline):
+ """Successive forward() calls must not cause scheduler state to drift."""
+ parent = _DMD2_BASE[type(pipeline)]
+
+ with patch.object(parent, "forward", _fake_parent_forward):
+ pipeline.forward(_make_request())
+ pipeline.forward(_make_request())
+
+ assert pipeline.scheduler.timesteps.long().tolist() == _DMD2_TIMESTEPS
diff --git a/tests/diffusion/models/flux2/test_flux2_transformer_tp.py b/tests/diffusion/models/flux2/test_flux2_transformer_tp.py
index 54dda1dd0..c613bb0b4 100644
--- a/tests/diffusion/models/flux2/test_flux2_transformer_tp.py
+++ b/tests/diffusion/models/flux2/test_flux2_transformer_tp.py
@@ -2,7 +2,7 @@ import pytest
import torch
from pytest_mock import MockerFixture
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.models.flux2.flux2_transformer import (
Flux2PosEmbed,
Flux2Transformer2DModel,
diff --git a/tests/diffusion/models/glm_image/test_glm_image_sp.py b/tests/diffusion/models/glm_image/test_glm_image_sp.py
new file mode 100644
index 000000000..40d1c8730
--- /dev/null
+++ b/tests/diffusion/models/glm_image/test_glm_image_sp.py
@@ -0,0 +1,133 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Tests for GLM-Image Sequence Parallelism support."""
+
+import pytest
+
+from vllm_omni.diffusion.data import DiffusionParallelConfig
+
+
+@pytest.fixture(scope="function", autouse=True)
+def setup_sp_groups(mocker):
+ """Set up SP and TP groups for each test function."""
+ mock_get_sp_group = mocker.patch("vllm_omni.diffusion.distributed.parallel_state.get_sp_group")
+ mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_world_size", return_value=1)
+ mock_get_tp_group = mocker.patch("vllm.distributed.parallel_state.get_tp_group")
+
+ mock_sp_group = mocker.MagicMock()
+ mock_sp_group.world_size = 4
+ mock_get_sp_group.return_value = mock_sp_group
+
+ mock_tp_group = mocker.MagicMock()
+ mock_tp_group.world_size = 1
+ mock_get_tp_group.return_value = mock_tp_group
+ yield
+
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def test_glm_image_sp_plan_defined():
+ """Test that _sp_plan is properly defined on GlmImageTransformer2DModel."""
+ from vllm_omni.diffusion.models.glm_image.glm_image_transformer import (
+ GlmImageTransformer2DModel,
+ )
+
+ assert hasattr(GlmImageTransformer2DModel, "_sp_plan")
+ plan = GlmImageTransformer2DModel._sp_plan
+ assert plan is not None
+
+ # Verify plan structure
+ assert "prepare" in plan
+ assert "proj_out" in plan
+
+
+def test_glm_image_sp_plan_valid():
+ """Validate _sp_plan structure."""
+ from vllm_omni.diffusion.distributed.sp_plan import validate_sp_plan
+ from vllm_omni.diffusion.models.glm_image.glm_image_transformer import (
+ GlmImageTransformer2DModel,
+ )
+
+ plan = GlmImageTransformer2DModel._sp_plan
+ validate_sp_plan(plan)
+
+
+def test_glm_image_prepare_module_exists():
+ """Test that GlmImagePrepare module exists."""
+ from vllm_omni.diffusion.models.glm_image.glm_image_transformer import (
+ GlmImagePrepare,
+ )
+
+ assert GlmImagePrepare is not None
+
+
+def test_glm_image_attention_accepts_parallel_config():
+ """Test that GlmImageAttention accepts parallel_config parameter."""
+ from vllm_omni.diffusion.models.glm_image.glm_image_transformer import (
+ GlmImageAttention,
+ )
+
+ parallel_config = DiffusionParallelConfig(
+ ulysses_degree=2,
+ ring_degree=2,
+ tensor_parallel_size=1,
+ sequence_parallel_size=4,
+ )
+
+ attn = GlmImageAttention(
+ dim=2560,
+ num_heads=64,
+ head_dim=40,
+ parallel_config=parallel_config,
+ )
+
+ assert attn.parallel_config is not None
+ assert attn.parallel_config.sequence_parallel_size == 4
+
+
+def test_glm_image_transformer_block_accepts_parallel_config():
+ """Test that GlmImageTransformerBlock accepts parallel_config parameter."""
+ from vllm_omni.diffusion.models.glm_image.glm_image_transformer import (
+ GlmImageTransformerBlock,
+ )
+
+ parallel_config = DiffusionParallelConfig(
+ ulysses_degree=2,
+ ring_degree=2,
+ tensor_parallel_size=1,
+ sequence_parallel_size=4,
+ )
+
+ block = GlmImageTransformerBlock(
+ dim=2560,
+ num_attention_heads=64,
+ attention_head_dim=40,
+ time_embed_dim=512,
+ parallel_config=parallel_config,
+ )
+
+ assert block.attn1.parallel_config is not None
+ assert block.attn1.parallel_config.sequence_parallel_size == 4
+
+
+def test_glm_image_has_sp_support():
+ """Test that GLM-Image has SP support implemented."""
+ from vllm_omni.diffusion.models.glm_image.glm_image_transformer import (
+ GlmImageTransformer2DModel,
+ )
+
+ # Check that the model has parallel_config support
+ assert hasattr(GlmImageTransformer2DModel, "__init__")
+
+ # Verify the model can be instantiated with SP config
+
+ # This test just verifies the structure exists
+ # Actual SP testing requires multi-GPU setup
+
+
+@pytest.mark.cuda
+@pytest.mark.sp
+def test_glm_image_sp_inference():
+ """Test SP inference (requires multi-GPU setup)."""
+ pytest.skip("Requires multi-GPU SP setup")
diff --git a/tests/diffusion/models/hunyuan_image_3/test_hunyuan_fused_moe.py b/tests/diffusion/models/hunyuan_image3/test_hunyuan_fused_moe.py
similarity index 85%
rename from tests/diffusion/models/hunyuan_image_3/test_hunyuan_fused_moe.py
rename to tests/diffusion/models/hunyuan_image3/test_hunyuan_fused_moe.py
index 2cda9116c..626f78eed 100644
--- a/tests/diffusion/models/hunyuan_image_3/test_hunyuan_fused_moe.py
+++ b/tests/diffusion/models/hunyuan_image3/test_hunyuan_fused_moe.py
@@ -12,7 +12,7 @@ class TestSetForwardContextNumTokens:
def test_sets_num_tokens_when_context_available(self, mocker):
"""num_tokens should be set on ForwardContext when available."""
- import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe
+ import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
mock_ctx = mocker.MagicMock()
del mock_ctx.in_profile_run # simulate missing attr
@@ -26,7 +26,7 @@ class TestSetForwardContextNumTokens:
def test_sets_in_profile_run_only_if_missing(self, mocker):
"""in_profile_run should not be overwritten if already set."""
- import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe
+ import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
mock_ctx = mocker.MagicMock()
mock_ctx.in_profile_run = True # already set
@@ -40,7 +40,7 @@ class TestSetForwardContextNumTokens:
def test_noop_when_context_unavailable(self, mocker):
"""Should do nothing when ForwardContext is not available."""
- import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe
+ import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
mocker.patch.object(hunyuan_moe._vllm_fc, "is_forward_context_available", return_value=False)
mock_get = mocker.patch.object(hunyuan_moe._vllm_fc, "get_forward_context")
@@ -55,11 +55,11 @@ class TestHunyuanFusedMoEPlatformDispatch:
def test_default_platform_uses_default_impl_qualname(self, mocker):
"""HunyuanFusedMoE should resolve the impl class from the platform hook."""
- import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe
+ import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
mock_platform = mocker.MagicMock()
mock_platform.get_diffusion_model_impl_qualname.return_value = (
- "vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe.HunyuanFusedMoEDefault"
+ "vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe.HunyuanFusedMoEDefault"
)
mocker.patch.object(
@@ -71,7 +71,7 @@ class TestHunyuanFusedMoEPlatformDispatch:
mock_impl = mocker.MagicMock()
mock_resolve.return_value = mock_impl
- from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import (
+ from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import (
HunyuanFusedMoE,
)
@@ -80,7 +80,7 @@ class TestHunyuanFusedMoEPlatformDispatch:
mock_platform.prepare_diffusion_op_runtime.assert_called_once_with("hunyuan_fused_moe")
mock_platform.get_diffusion_model_impl_qualname.assert_called_once_with("hunyuan_fused_moe")
mock_resolve.assert_called_once_with(
- "vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe.HunyuanFusedMoEDefault"
+ "vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe.HunyuanFusedMoEDefault"
)
mock_impl.assert_called_once_with(prefix="")
@@ -90,7 +90,7 @@ class TestHunyuanFusedMoEFactory:
def test_new_delegates_to_impl_class(self, mocker):
"""HunyuanFusedMoE(prefix=..., **kwargs) should instantiate and return impl instance."""
- import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe
+ import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
class MockImpl:
def __init__(self, *, prefix: str = "", **kwargs):
@@ -104,7 +104,7 @@ class TestHunyuanFusedMoEFactory:
mock_impl_class = mocker.MagicMock(return_value=MockImpl(prefix="test", a=1))
mocker.patch.object(hunyuan_moe, "resolve_obj_by_qualname", return_value=mock_impl_class)
- from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import (
+ from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import (
HunyuanFusedMoE,
)
@@ -119,7 +119,7 @@ class TestHunyuanFusedMoEFactory:
def test_make_expert_params_mapping_delegates_to_impl(self, mocker):
"""make_expert_params_mapping should delegate to impl class method."""
- import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe
+ import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
expected_mapping = [("a", "b", 0, "c")]
mock_platform = mocker.MagicMock()
@@ -130,7 +130,7 @@ class TestHunyuanFusedMoEFactory:
mock_impl_class.make_expert_params_mapping = mocker.MagicMock(return_value=expected_mapping)
mocker.patch.object(hunyuan_moe, "resolve_obj_by_qualname", return_value=mock_impl_class)
- from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import (
+ from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import (
HunyuanFusedMoE,
)
diff --git a/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_sampler.py b/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_sampler.py
new file mode 100644
index 000000000..51f6a85f5
--- /dev/null
+++ b/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_sampler.py
@@ -0,0 +1,190 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Unit tests for HunyuanImage3 AR sampler logic (stage transitions,
+ratio restriction, comprehension blocking)."""
+
+import pytest
+import torch
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+# Fake token IDs for testing (avoid importing the real model).
+END_OF_THINK = 100
+RECAPTION = 101
+END_OF_RECAPTION = 102
+ANSWER = 103
+BOI = 104
+SIZE_TOKEN = 105
+EOS = 106
+RATIO_START = 200
+RATIO_END = 210
+RATIO_OTHER_START = 220
+RATIO_OTHER_END = 223
+
+
+class FakeSamplerModel:
+ """Minimal stub that replicates the sampler-relevant attributes of
+ HunyuanImage3ForConditionalGeneration without loading real weights."""
+
+ def __init__(self, *, is_comprehension: bool = False):
+ self._is_comprehension = is_comprehension
+ self._eos_token_id = EOS
+ self._end_of_think_id = END_OF_THINK
+ self._recaption_id = RECAPTION
+ self._end_of_recaption_id = END_OF_RECAPTION
+ self._answer_id = ANSWER
+ self._mrope_boi_token_id = BOI
+ self._size_token_id = SIZE_TOKEN
+ self._start_ratio_id = RATIO_START
+ self._end_ratio_id = RATIO_END
+ self._ratio_other_slices = [(RATIO_OTHER_START, RATIO_OTHER_END + 1)]
+ self._all_ratio_ids = set(range(RATIO_START, RATIO_END + 1))
+ self._all_ratio_ids.update(range(RATIO_OTHER_START, RATIO_OTHER_END + 1))
+
+ self._stage_transitions: dict[int, list[int]] = {}
+ if not is_comprehension:
+ self._stage_transitions[END_OF_THINK] = [RECAPTION]
+ self._stage_transitions[END_OF_RECAPTION] = [ANSWER, BOI, SIZE_TOKEN]
+
+ self._blocked_token_ids: set[int] = set()
+ if is_comprehension:
+ self._blocked_token_ids.update([BOI, SIZE_TOKEN])
+ self._blocked_token_ids.update(self._all_ratio_ids)
+
+ # Bind the real methods from the model class.
+ from vllm_omni.model_executor.models.hunyuan_image3.hunyuan_image3 import (
+ HunyuanImage3ForConditionalGeneration as _Real,
+ )
+
+ _get_forced_token = _Real._get_forced_token
+ _apply_ratio_restriction = _Real._apply_ratio_restriction
+
+
+class TestGetForcedToken:
+ """Tests for the stateless _get_forced_token method."""
+
+ def setup_method(self):
+ self.model = FakeSamplerModel(is_comprehension=False)
+
+ def test_no_trigger_returns_none(self):
+ assert self.model._get_forced_token([1, 2, 3]) is None
+
+ def test_empty_history_returns_none(self):
+ assert self.model._get_forced_token([]) is None
+
+ def test_end_of_think_forces_recaption(self):
+ assert self.model._get_forced_token([END_OF_THINK]) == RECAPTION
+
+ def test_end_of_think_completed(self):
+ assert self.model._get_forced_token([END_OF_THINK, RECAPTION]) is None
+
+ def test_end_of_recaption_forces_answer(self):
+ tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION]
+ assert self.model._get_forced_token(tokens) == ANSWER
+
+ def test_end_of_recaption_forces_boi_after_answer(self):
+ tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION, ANSWER]
+ assert self.model._get_forced_token(tokens) == BOI
+
+ def test_end_of_recaption_forces_size_after_boi(self):
+ tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION, ANSWER, BOI]
+ assert self.model._get_forced_token(tokens) == SIZE_TOKEN
+
+ def test_full_sequence_complete(self):
+ tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION, ANSWER, BOI, SIZE_TOKEN]
+ assert self.model._get_forced_token(tokens) is None
+
+ def test_diverged_history_returns_none(self):
+ tokens = [END_OF_RECAPTION, 999] # 999 != ANSWER
+ assert self.model._get_forced_token(tokens) is None
+
+ def test_later_trigger_takes_precedence(self):
+ tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION]
+ assert self.model._get_forced_token(tokens) == ANSWER
+
+ def test_trigger_with_extra_tokens_before(self):
+ tokens = [1, 2, 3, END_OF_THINK]
+ assert self.model._get_forced_token(tokens) == RECAPTION
+
+
+class TestComprehensionBlocking:
+ """Tests for comprehension mode token blocking."""
+
+ def test_blocked_tokens_masked(self):
+ model = FakeSamplerModel(is_comprehension=True)
+ vocab_size = 300
+ logits = torch.zeros(1, vocab_size)
+ logits[0, BOI] = 5.0
+ logits[0, SIZE_TOKEN] = 3.0
+ logits[0, RATIO_START] = 2.0
+ min_score = torch.finfo(logits.dtype).min
+
+ for tid in model._blocked_token_ids:
+ if tid < vocab_size:
+ logits[0, tid] = min_score
+
+ assert logits[0, BOI].item() == min_score
+ assert logits[0, SIZE_TOKEN].item() == min_score
+ assert logits[0, RATIO_START].item() == min_score
+
+ def test_non_blocked_tokens_preserved(self):
+ model = FakeSamplerModel(is_comprehension=True)
+ vocab_size = 300
+ logits = torch.zeros(1, vocab_size)
+ logits[0, 50] = 7.0
+ min_score = torch.finfo(logits.dtype).min
+
+ for tid in model._blocked_token_ids:
+ if tid < vocab_size:
+ logits[0, tid] = min_score
+
+ assert logits[0, 50].item() == 7.0
+
+
+class TestRatioRestriction:
+ """Tests for _apply_ratio_restriction (greedy: only argmax ratio survives)."""
+
+ def test_greedy_selects_single_ratio_token(self):
+ model = FakeSamplerModel(is_comprehension=False)
+ vocab_size = 300
+ logits = torch.zeros(1, vocab_size)
+ logits[0, RATIO_START + 3] = 10.0
+ logits[0, RATIO_START + 1] = 5.0
+ logits[0, 50] = 20.0 # non-ratio, should be masked
+ min_score = torch.finfo(logits.dtype).min
+
+ model._apply_ratio_restriction(logits, 0, min_score)
+
+ assert logits[0, RATIO_START + 3].item() == 0
+ assert logits[0, RATIO_START + 1].item() == min_score
+ assert logits[0, 50].item() == min_score
+
+ def test_extra_ratio_slices_considered(self):
+ model = FakeSamplerModel(is_comprehension=False)
+ vocab_size = 300
+ logits = torch.zeros(1, vocab_size)
+ logits[0, RATIO_OTHER_START] = 15.0
+ logits[0, RATIO_START] = 5.0
+ min_score = torch.finfo(logits.dtype).min
+
+ model._apply_ratio_restriction(logits, 0, min_score)
+
+ assert logits[0, RATIO_OTHER_START].item() == 0
+ assert logits[0, RATIO_START].item() == min_score
+
+
+class TestForceEosAfterRatio:
+ """Tests that a ratio token as last_token forces EOS."""
+
+ def test_ratio_token_forces_eos(self):
+ model = FakeSamplerModel(is_comprehension=False)
+ vocab_size = 300
+ logits = torch.randn(1, vocab_size)
+ min_score = torch.finfo(logits.dtype).min
+
+ logits[0].fill_(min_score)
+ logits[0, model._eos_token_id] = 0
+
+ assert logits[0, EOS].item() == 0
+ non_eos_max = logits[0, :EOS].max().item()
+ assert non_eos_max == min_score
diff --git a/tests/diffusion/models/ltx2/test_ltx2_hsdp.py b/tests/diffusion/models/ltx2/test_ltx2_hsdp.py
new file mode 100644
index 000000000..4dd07e1bf
--- /dev/null
+++ b/tests/diffusion/models/ltx2/test_ltx2_hsdp.py
@@ -0,0 +1,25 @@
+import pytest
+import torch.nn as nn
+
+from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def test_ltx2_exposes_hsdp_shard_conditions_for_transformer_blocks():
+ model = object.__new__(LTX2VideoTransformer3DModel)
+ nn.Module.__init__(model)
+ model.transformer_blocks = nn.ModuleList([nn.Linear(4, 4) for _ in range(2)])
+ model.norm_out = nn.LayerNorm(4)
+
+ conditions = getattr(model, "_hsdp_shard_conditions", None)
+
+ assert conditions is not None
+ assert len(conditions) == 1
+
+ matched = []
+ for name, module in model.named_modules():
+ if any(cond(name, module) for cond in conditions):
+ matched.append(name)
+
+ assert matched == ["transformer_blocks.0", "transformer_blocks.1"]
diff --git a/tests/diffusion/models/qwen_image/test_qwen_image_edit_plus.py b/tests/diffusion/models/qwen_image/test_qwen_image_edit_plus.py
new file mode 100644
index 000000000..873b52bf7
--- /dev/null
+++ b/tests/diffusion/models/qwen_image/test_qwen_image_edit_plus.py
@@ -0,0 +1,38 @@
+# SPDX-License-Identifier: Apache-2.0
+
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+from PIL import Image
+
+from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit_plus import (
+ get_qwen_image_edit_plus_pre_process_func,
+)
+
+pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
+
+
+def test_qwen_image_edit_plus_rejects_too_many_input_images(tmp_path: Path):
+ vae_dir = tmp_path / "vae"
+ vae_dir.mkdir()
+ # Keep the mock config intentionally minimal: this test only needs the
+ # fields touched during pre-process initialization.
+ (vae_dir / "config.json").write_text(json.dumps({"z_dim": 16}))
+
+ pre_process = get_qwen_image_edit_plus_pre_process_func(SimpleNamespace(model=str(tmp_path)))
+ image = Image.fromarray(np.zeros((32, 32, 3), dtype=np.uint8))
+ request = SimpleNamespace(
+ prompts=[
+ {
+ "prompt": "combine",
+ "multi_modal_data": {"image": [image, image, image, image, image]},
+ }
+ ],
+ sampling_params=SimpleNamespace(height=None, width=None),
+ )
+
+ with pytest.raises(ValueError, match=r"At most 4 images are supported by this model"):
+ pre_process(request)
diff --git a/tests/diffusion/models/qwen_image/test_qwen_image_max_sequence_length.py b/tests/diffusion/models/qwen_image/test_qwen_image_max_sequence_length.py
new file mode 100644
index 000000000..f5676a005
--- /dev/null
+++ b/tests/diffusion/models/qwen_image/test_qwen_image_max_sequence_length.py
@@ -0,0 +1,260 @@
+import inspect
+from types import SimpleNamespace
+
+import pytest
+import torch
+from torch import nn
+
+from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image import (
+ QwenImagePipeline,
+)
+from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit import (
+ QwenImageEditPipeline,
+)
+from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit_plus import (
+ QwenImageEditPlusPipeline,
+)
+from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_layered import (
+ QwenImageLayeredPipeline,
+)
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+class _RejectingTextEncoder:
+ dtype = torch.float32
+
+ def __call__(self, *args, **kwargs):
+ raise AssertionError("text encoder should not run for prompts that exceed max_sequence_length")
+
+
+class _FakeModelInputs:
+ def __init__(self, total_sequence_length: int):
+ attention_mask = torch.ones((1, total_sequence_length), dtype=torch.long)
+ self.input_ids = attention_mask.clone()
+ self.attention_mask = attention_mask
+ self.pixel_values = None
+ self.image_grid_thw = None
+
+ def to(self, device):
+ return self
+
+
+class _FakeTokenizer:
+ def __init__(self, total_sequence_length: int | list[int]):
+ if isinstance(total_sequence_length, list):
+ self.total_sequence_lengths = list(total_sequence_length)
+ else:
+ self.total_sequence_lengths = [total_sequence_length]
+
+ def __call__(self, *args, **kwargs):
+ if len(self.total_sequence_lengths) > 1:
+ total_sequence_length = self.total_sequence_lengths.pop(0)
+ else:
+ total_sequence_length = self.total_sequence_lengths[0]
+ return _FakeModelInputs(total_sequence_length)
+
+
+class _FakeProcessor(_FakeTokenizer):
+ pass
+
+
+class _FakeScheduler:
+ def __init__(self):
+ self.begin_index = None
+
+ def set_begin_index(self, begin_index: int):
+ self.begin_index = begin_index
+
+
+PIPELINE_CASES = [
+ pytest.param(QwenImagePipeline, 34, "tokenizer", id="qwen-image"),
+ pytest.param(QwenImageLayeredPipeline, 34, "tokenizer", id="qwen-image-layered"),
+ pytest.param(QwenImageEditPipeline, 64, "processor", id="qwen-image-edit"),
+ pytest.param(QwenImageEditPlusPipeline, 64, "processor", id="qwen-image-edit-plus"),
+]
+
+
+def _make_pipeline(
+ pipeline_class: type,
+ *,
+ total_sequence_length: int,
+ drop_idx: int,
+ input_kind: str,
+):
+ pipeline = object.__new__(pipeline_class)
+ nn.Module.__init__(pipeline)
+ pipeline.device = torch.device("cpu")
+ pipeline.text_encoder = _RejectingTextEncoder()
+ pipeline.tokenizer_max_length = 1024
+ pipeline.prompt_template_encode = "{}"
+ pipeline.prompt_template_encode_start_idx = drop_idx
+ pipeline.tokenizer = _FakeTokenizer([total_sequence_length, 0])
+ if input_kind == "processor":
+ pipeline.processor = _FakeProcessor(total_sequence_length)
+ return pipeline
+
+
+@pytest.mark.parametrize(("pipeline_class", "drop_idx", "input_kind"), PIPELINE_CASES)
+def test_encode_prompt_rejects_prompt_longer_than_default_max_sequence_length(
+ pipeline_class: type,
+ drop_idx: int,
+ input_kind: str,
+):
+ pipeline = _make_pipeline(
+ pipeline_class,
+ total_sequence_length=1025,
+ drop_idx=drop_idx,
+ input_kind=input_kind,
+ )
+
+ with pytest.raises(ValueError, match=r"got 1025 tokens, but `max_sequence_length` is 1024"):
+ pipeline.encode_prompt(prompt="prompt")
+
+
+@pytest.mark.parametrize(("pipeline_class", "drop_idx", "input_kind"), PIPELINE_CASES)
+def test_encode_prompt_rejects_prompt_longer_than_explicit_max_sequence_length(
+ pipeline_class: type,
+ drop_idx: int,
+ input_kind: str,
+):
+ pipeline = _make_pipeline(
+ pipeline_class,
+ total_sequence_length=17,
+ drop_idx=drop_idx,
+ input_kind=input_kind,
+ )
+
+ with pytest.raises(ValueError, match=r"got 17 tokens, but `max_sequence_length` is 16"):
+ pipeline.encode_prompt(prompt="prompt", max_sequence_length=16)
+
+
+def test_prepare_encode_defaults_to_tokenizer_max_length():
+ pipeline = object.__new__(QwenImagePipeline)
+ nn.Module.__init__(pipeline)
+ pipeline.tokenizer_max_length = 1024
+ pipeline.vae_scale_factor = 8
+ pipeline.default_sample_size = 128
+ pipeline.scheduler = _FakeScheduler()
+ pipeline._extract_prompts = lambda prompts: (["prompt"], None)
+
+ captured = {}
+
+ def _fake_prepare_generation_context(**kwargs):
+ captured["max_sequence_length"] = kwargs["max_sequence_length"]
+ embeds = torch.ones((1, 1, 1))
+ mask = torch.ones((1, 1), dtype=torch.long)
+ return {
+ "prompt_embeds": embeds,
+ "prompt_embeds_mask": mask,
+ "negative_prompt_embeds": None,
+ "negative_prompt_embeds_mask": None,
+ "latents": embeds,
+ "timesteps": torch.tensor([1]),
+ "do_true_cfg": False,
+ "guidance": None,
+ "img_shapes": [[(1, 1, 1)]],
+ "txt_seq_lens": [1],
+ "negative_txt_seq_lens": None,
+ }
+
+ pipeline._prepare_generation_context = _fake_prepare_generation_context
+ state = SimpleNamespace(
+ prompts=["prompt"],
+ sampling=SimpleNamespace(
+ height=None,
+ width=None,
+ num_inference_steps=None,
+ sigmas=None,
+ guidance_scale_provided=False,
+ num_outputs_per_prompt=0,
+ generator=None,
+ true_cfg_scale=None,
+ max_sequence_length=None,
+ ),
+ )
+
+ pipeline.prepare_encode(state)
+
+ assert captured["max_sequence_length"] == 1024
+
+
+@pytest.mark.parametrize(
+ ("pipeline_class", "drop_idx"),
+ [
+ pytest.param(QwenImageEditPipeline, 64, id="qwen-image-edit"),
+ pytest.param(QwenImageEditPlusPipeline, 64, id="qwen-image-edit-plus"),
+ ],
+)
+def test_edit_pipelines_validate_text_prompt_length_before_image_token_expansion(
+ pipeline_class: type,
+ drop_idx: int,
+):
+ pipeline = object.__new__(pipeline_class)
+ nn.Module.__init__(pipeline)
+ pipeline.device = torch.device("cpu")
+ pipeline.text_encoder = _RejectingTextEncoder()
+ pipeline.tokenizer_max_length = 1024
+ pipeline.prompt_template_encode = "{}"
+ pipeline.prompt_template_encode_start_idx = drop_idx
+ pipeline.tokenizer = _FakeTokenizer([8, 0])
+ pipeline.processor = _FakeProcessor(drop_idx + 1500)
+
+ with pytest.raises(AssertionError, match="text encoder should not run"):
+ pipeline.encode_prompt(prompt="short prompt")
+
+
+@pytest.mark.parametrize(
+ "pipeline_class",
+ [
+ pytest.param(QwenImagePipeline, id="qwen-image"),
+ pytest.param(QwenImageLayeredPipeline, id="qwen-image-layered"),
+ ],
+)
+def test_qwen_generation_validator_excludes_template_suffix_from_budget(pipeline_class: type):
+ pipeline = object.__new__(pipeline_class)
+ nn.Module.__init__(pipeline)
+ pipeline.device = torch.device("cpu")
+ pipeline.text_encoder = _RejectingTextEncoder()
+ pipeline.tokenizer_max_length = 1024
+ pipeline.prompt_template_encode = "{}"
+ pipeline.prompt_template_encode_start_idx = 34
+ pipeline.tokenizer = _FakeTokenizer([1029, 5])
+
+ with pytest.raises(AssertionError, match="text encoder should not run"):
+ pipeline.encode_prompt(prompt="boundary prompt")
+
+
+@pytest.mark.parametrize(
+ "pipeline_class",
+ [
+ pytest.param(QwenImageEditPipeline, id="qwen-image-edit"),
+ pytest.param(QwenImageEditPlusPipeline, id="qwen-image-edit-plus"),
+ ],
+)
+def test_qwen_edit_validator_excludes_image_placeholders_from_budget(pipeline_class: type):
+ pipeline = object.__new__(pipeline_class)
+ nn.Module.__init__(pipeline)
+ pipeline.device = torch.device("cpu")
+ pipeline.text_encoder = _RejectingTextEncoder()
+ pipeline.tokenizer_max_length = 1024
+ pipeline.prompt_template_encode = "{}"
+ pipeline.prompt_template_encode_start_idx = 64
+ pipeline.tokenizer = _FakeTokenizer([30, 20])
+ pipeline.processor = _FakeProcessor(1500)
+
+ with pytest.raises(AssertionError, match="text encoder should not run"):
+ pipeline.encode_prompt(prompt="short prompt")
+
+
+@pytest.mark.parametrize(
+ "pipeline_class",
+ [
+ QwenImagePipeline,
+ QwenImageLayeredPipeline,
+ QwenImageEditPipeline,
+ QwenImageEditPlusPipeline,
+ ],
+)
+def test_forward_max_sequence_length_default_is_1024(pipeline_class: type):
+ assert inspect.signature(pipeline_class.forward).parameters["max_sequence_length"].default == 1024
diff --git a/tests/diffusion/models/stable_audio/test_stable_audio_hsdp.py b/tests/diffusion/models/stable_audio/test_stable_audio_hsdp.py
new file mode 100644
index 000000000..923b9a863
--- /dev/null
+++ b/tests/diffusion/models/stable_audio/test_stable_audio_hsdp.py
@@ -0,0 +1,25 @@
+import pytest
+import torch.nn as nn
+
+from vllm_omni.diffusion.models.stable_audio.stable_audio_transformer import StableAudioDiTModel
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def test_stable_audio_exposes_hsdp_shard_conditions_for_transformer_blocks():
+ model = object.__new__(StableAudioDiTModel)
+ nn.Module.__init__(model)
+ model.transformer_blocks = nn.ModuleList([nn.Linear(4, 4) for _ in range(2)])
+ model.proj_out = nn.Linear(4, 4)
+
+ conditions = getattr(model, "_hsdp_shard_conditions", None)
+
+ assert conditions is not None
+ assert len(conditions) == 1
+
+ matched = []
+ for name, module in model.named_modules():
+ if any(cond(name, module) for cond in conditions):
+ matched.append(name)
+
+ assert matched == ["transformer_blocks.0", "transformer_blocks.1"]
diff --git a/tests/diffusion/models/wan2_2/__init__.py b/tests/diffusion/models/wan2_2/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/diffusion/models/wan2_2/conftest.py b/tests/diffusion/models/wan2_2/conftest.py
new file mode 100644
index 000000000..f836fa545
--- /dev/null
+++ b/tests/diffusion/models/wan2_2/conftest.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+from types import SimpleNamespace
+
+import torch
+from torch import nn
+
+
+class StubTransformer(nn.Module):
+ def __init__(self, *, name: str = "transformer", in_channels: int = 4, out_channels: int = 4) -> None:
+ super().__init__()
+ self.name = name
+ self.config = SimpleNamespace(
+ patch_size=(1, 2, 2),
+ in_channels=in_channels,
+ out_channels=out_channels,
+ image_dim=None,
+ )
+
+ @property
+ def dtype(self) -> torch.dtype:
+ return torch.float32
+
+ def forward(self, **kwargs):
+ hidden_states = kwargs["hidden_states"]
+ return (torch.zeros_like(hidden_states[:, : self.config.out_channels]),)
+
+
+class StubScheduler:
+ def __init__(self, timesteps: list[int]) -> None:
+ self.timesteps = torch.tensor(timesteps, dtype=torch.int64)
+ self.config = SimpleNamespace(num_train_timesteps=1000)
+ self.set_timesteps_calls: list[tuple[int, torch.device]] = []
+
+ def set_timesteps(self, num_steps: int, device: torch.device) -> None:
+ self.set_timesteps_calls.append((num_steps, device))
+
+
+class StubVAE:
+ dtype = torch.float32
+
+ def __init__(self, z_dim: int = 4) -> None:
+ self.config = SimpleNamespace(
+ z_dim=z_dim,
+ scale_factor_temporal=4,
+ scale_factor_spatial=8,
+ latents_mean=[0.0] * z_dim,
+ latents_std=[1.0] * z_dim,
+ )
+
+ def encode(self, video: torch.Tensor):
+ latent_frames = (video.shape[2] + self.config.scale_factor_temporal - 1) // self.config.scale_factor_temporal
+ latent_height = video.shape[-2] // self.config.scale_factor_spatial
+ latent_width = video.shape[-1] // self.config.scale_factor_spatial
+ latents = torch.ones(
+ video.shape[0],
+ self.config.z_dim,
+ latent_frames,
+ latent_height,
+ latent_width,
+ dtype=video.dtype,
+ device=video.device,
+ )
+ return SimpleNamespace(latents=latents)
+
+ def decode(self, latents: torch.Tensor, return_dict: bool = False):
+ del return_dict
+ return (latents,)
+
+
+@contextmanager
+def noop_progress_bar(*args, **kwargs):
+ del args, kwargs
+
+ class Bar:
+ def update(self) -> None:
+ return None
+
+ yield Bar()
diff --git a/tests/diffusion/models/wan2_2/test_wan22_i2v_pipeline.py b/tests/diffusion/models/wan2_2/test_wan22_i2v_pipeline.py
new file mode 100644
index 000000000..576678e2c
--- /dev/null
+++ b/tests/diffusion/models/wan2_2/test_wan22_i2v_pipeline.py
@@ -0,0 +1,125 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+from types import SimpleNamespace
+
+import pytest
+import torch
+from PIL import Image
+from torch import nn
+
+from tests.diffusion.models.wan2_2.conftest import StubTransformer, StubVAE, noop_progress_bar
+from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v import (
+ Wan22I2VPipeline,
+ get_wan22_i2v_pre_process_func,
+)
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
+
+
+def _make_i2v_pipeline(*, expand_timesteps: bool) -> Wan22I2VPipeline:
+ pipeline = object.__new__(Wan22I2VPipeline)
+ nn.Module.__init__(pipeline)
+ pipeline.device = torch.device("cpu")
+ pipeline.transformer = StubTransformer(name="high", in_channels=8, out_channels=4)
+ pipeline.transformer_2 = StubTransformer(name="low", in_channels=8, out_channels=4)
+ pipeline.vae = StubVAE(z_dim=4)
+ pipeline.vae_scale_factor_temporal = 4
+ pipeline.vae_scale_factor_spatial = 8
+ pipeline.expand_timesteps = expand_timesteps
+ pipeline.progress_bar = noop_progress_bar
+ return pipeline
+
+
+def test_i2v_preprocess_requires_image_and_resizes_to_480p_aspect() -> None:
+ preprocess = get_wan22_i2v_pre_process_func(SimpleNamespace())
+ request = SimpleNamespace(
+ prompts=[{"prompt": "p", "multi_modal_data": {"image": Image.new("RGB", (320, 160), "red")}}],
+ sampling_params=SimpleNamespace(height=None, width=None),
+ )
+
+ result = preprocess(request)
+ prompt = result.prompts[0]
+
+ assert result.sampling_params.height == 432
+ assert result.sampling_params.width == 880
+ assert prompt["multi_modal_data"]["image"].size == (880, 432)
+
+ missing_image = SimpleNamespace(
+ prompts=[{"prompt": "p", "multi_modal_data": {}}],
+ sampling_params=SimpleNamespace(height=None, width=None),
+ )
+ with pytest.raises(ValueError, match="No image is provided"):
+ preprocess(missing_image)
+
+
+def test_i2v_diffuse_selects_stage_guidance_and_expands_timesteps() -> None:
+ pipeline = _make_i2v_pipeline(expand_timesteps=True)
+ latents = torch.zeros(1, 4, 2, 4, 4)
+ condition = torch.ones_like(latents)
+ first_frame_mask = torch.ones(1, 1, 2, 4, 4)
+ first_frame_mask[:, :, 0] = 0
+ timesteps = torch.tensor([900, 100])
+
+ calls = []
+
+ def fake_predict_noise_maybe_with_cfg(**kwargs):
+ positive = kwargs["positive_kwargs"]
+ calls.append(
+ {
+ "model": positive["current_model"].name,
+ "scale": kwargs["true_cfg_scale"],
+ "timestep_shape": tuple(positive["timestep"].shape),
+ "timestep_values": positive["timestep"].clone(),
+ "hidden_states": positive["hidden_states"].clone(),
+ }
+ )
+ return torch.ones_like(latents)
+
+ pipeline.predict_noise_maybe_with_cfg = fake_predict_noise_maybe_with_cfg # type: ignore[method-assign]
+ pipeline.scheduler_step_maybe_with_cfg = lambda noise, t, current, cfg: current + noise # type: ignore[method-assign]
+
+ result = pipeline.diffuse(
+ latents=latents,
+ timesteps=timesteps,
+ prompt_embeds=torch.zeros(1, 2, 3),
+ negative_prompt_embeds=None,
+ image_embeds=None,
+ guidance_low=1.0,
+ guidance_high=2.0,
+ boundary_timestep=500.0,
+ dtype=torch.float32,
+ attention_kwargs={},
+ condition=condition,
+ first_frame_mask=first_frame_mask,
+ )
+
+ assert [call["model"] for call in calls] == ["high", "low"]
+ assert [call["scale"] for call in calls] == [1.0, 2.0]
+ assert calls[0]["timestep_shape"] == (1, 8)
+ timestep_dtype = calls[0]["timestep_values"].dtype
+ torch.testing.assert_close(calls[0]["timestep_values"][0, :4], torch.zeros(4, dtype=timestep_dtype))
+ torch.testing.assert_close(calls[0]["timestep_values"][0, 4:], torch.full((4,), 900, dtype=timestep_dtype))
+ torch.testing.assert_close(calls[0]["hidden_states"][:, :, 0], torch.ones(1, 4, 4, 4))
+ torch.testing.assert_close(result, torch.full_like(latents, 2.0))
+
+
+def test_i2v_prepare_latents_builds_expand_condition_and_first_frame_mask() -> None:
+ pipeline = _make_i2v_pipeline(expand_timesteps=True)
+ latents, condition, first_frame_mask = pipeline.prepare_latents(
+ image=torch.zeros(1, 3, 16, 16),
+ batch_size=1,
+ num_channels_latents=4,
+ height=16,
+ width=16,
+ num_frames=5,
+ dtype=torch.float32,
+ device=torch.device("cpu"),
+ generator=torch.Generator(device="cpu").manual_seed(0),
+ )
+
+ assert latents.shape == (1, 4, 2, 2, 2)
+ assert condition.shape == (1, 4, 1, 2, 2)
+ assert first_frame_mask.shape == (1, 1, 2, 2, 2)
+ assert first_frame_mask[:, :, 0].sum() == 0
+ assert first_frame_mask[:, :, 1].sum() == 4
diff --git a/tests/diffusion/models/wan2_2/test_wan22_pipeline_diffuse.py b/tests/diffusion/models/wan2_2/test_wan22_pipeline_diffuse.py
new file mode 100644
index 000000000..54bb672ef
--- /dev/null
+++ b/tests/diffusion/models/wan2_2/test_wan22_pipeline_diffuse.py
@@ -0,0 +1,155 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+from contextlib import contextmanager
+from types import SimpleNamespace
+
+import pytest
+import torch
+from torch import nn
+
+from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
+
+
+class _StubTransformer(nn.Module):
+ @property
+ def dtype(self) -> torch.dtype:
+ return torch.float32
+
+
+class _StubScheduler:
+ def __init__(self, timesteps: list[int]) -> None:
+ self.timesteps = torch.tensor(timesteps, dtype=torch.int64)
+ self.config = SimpleNamespace(num_train_timesteps=1000)
+ self.set_timesteps_calls: list[tuple[int, torch.device]] = []
+
+ def set_timesteps(self, num_steps: int, device: torch.device) -> None:
+ self.set_timesteps_calls.append((num_steps, device))
+
+
+@contextmanager
+def _noop_progress_bar(*args, **kwargs):
+ del args, kwargs
+
+ class _Bar:
+ def update(self) -> None:
+ return None
+
+ yield _Bar()
+
+
+def _make_pipeline() -> Wan22Pipeline:
+ pipeline = object.__new__(Wan22Pipeline)
+ nn.Module.__init__(pipeline)
+ pipeline.device = torch.device("cpu")
+ pipeline.transformer = _StubTransformer()
+ pipeline.transformer_2 = None
+ pipeline.transformer_config = SimpleNamespace(patch_size=(1, 2, 2), in_channels=4, out_channels=4)
+ pipeline.scheduler = _StubScheduler([9, 5])
+ pipeline.od_config = SimpleNamespace(flow_shift=5.0)
+ pipeline._sample_solver = "unipc"
+ pipeline._flow_shift = 5.0
+ pipeline.vae_scale_factor_temporal = 4
+ pipeline.vae_scale_factor_spatial = 8
+ pipeline.boundary_ratio = 0.875
+ pipeline.expand_timesteps = False
+ pipeline._guidance_scale = None
+ pipeline._guidance_scale_2 = None
+ pipeline._num_timesteps = None
+ pipeline._current_timestep = None
+ pipeline.check_inputs = lambda **kwargs: None
+ pipeline.prepare_latents = lambda **kwargs: torch.zeros((1, 4, 1, 8, 8), dtype=torch.float32)
+ pipeline.progress_bar = _noop_progress_bar
+ return pipeline
+
+
+def test_forward_delegates_denoising_to_diffuse(monkeypatch) -> None:
+ pipeline = _make_pipeline()
+
+ prompt_embeds = torch.randn(1, 8)
+ captured: dict[str, object] = {}
+
+ def _fake_diffuse(**kwargs):
+ captured.update(kwargs)
+ return kwargs["latents"] + 1
+
+ pipeline.diffuse = _fake_diffuse # type: ignore[method-assign]
+
+ req = SimpleNamespace(
+ prompts=["prompt"],
+ sampling_params=SimpleNamespace(
+ height=None,
+ width=None,
+ num_frames=1,
+ num_inference_steps=2,
+ guidance_scale_provided=False,
+ guidance_scale=None,
+ guidance_scale_2=None,
+ boundary_ratio=None,
+ generator=None,
+ seed=None,
+ num_outputs_per_prompt=1,
+ max_sequence_length=32,
+ latents=None,
+ extra_args={},
+ ),
+ )
+
+ output = pipeline.forward(req, prompt_embeds=prompt_embeds, output_type="latent", guidance_scale=1.0)
+
+ assert torch.equal(output.output, torch.ones((1, 4, 1, 8, 8)))
+ assert torch.equal(captured["timesteps"], pipeline.scheduler.timesteps)
+ assert captured["guidance_low"] == 1.0
+ assert captured["guidance_high"] == 1.0
+ assert captured["boundary_timestep"] == pytest.approx(875.0)
+ assert captured["latent_condition"] is None
+ assert captured["first_frame_mask"] is None
+ assert pipeline.scheduler.set_timesteps_calls == [(2, torch.device("cpu"))]
+
+
+def test_diffuse_runs_prediction_and_scheduler_for_each_timestep() -> None:
+ pipeline = _make_pipeline()
+ latents = torch.zeros((1, 1, 1, 2, 2), dtype=torch.float32)
+ timesteps = torch.tensor([7, 3], dtype=torch.int64)
+ prompt_embeds = torch.randn(1, 8)
+
+ predict_calls: list[dict[str, object]] = []
+ scheduler_calls: list[tuple[float, int, float, bool]] = []
+
+ def _fake_predict_noise_maybe_with_cfg(**kwargs):
+ predict_calls.append(kwargs)
+ timestep = kwargs["positive_kwargs"]["timestep"]
+ assert isinstance(timestep, torch.Tensor)
+ return torch.full_like(latents, float(timestep[0].item()))
+
+ def _fake_scheduler_step_maybe_with_cfg(noise_pred, t, current_latents, do_true_cfg):
+ scheduler_calls.append(
+ (float(noise_pred[0, 0, 0, 0, 0]), int(t.item()), float(current_latents.sum()), do_true_cfg)
+ )
+ return current_latents + noise_pred
+
+ pipeline.predict_noise_maybe_with_cfg = _fake_predict_noise_maybe_with_cfg # type: ignore[method-assign]
+ pipeline.scheduler_step_maybe_with_cfg = _fake_scheduler_step_maybe_with_cfg # type: ignore[method-assign]
+
+ result = pipeline.diffuse(
+ latents=latents,
+ timesteps=timesteps,
+ prompt_embeds=prompt_embeds,
+ negative_prompt_embeds=None,
+ guidance_low=1.0,
+ guidance_high=2.0,
+ boundary_timestep=5.0,
+ dtype=torch.float32,
+ attention_kwargs={},
+ )
+
+ assert len(predict_calls) == 2
+ assert predict_calls[0]["true_cfg_scale"] == 1.0
+ assert predict_calls[1]["true_cfg_scale"] == 2.0
+ assert scheduler_calls == [
+ (7.0, 7, 0.0, False),
+ (3.0, 3, 28.0, False),
+ ]
+ assert torch.equal(result, torch.full_like(latents, 10.0))
diff --git a/tests/diffusion/models/wan2_2/test_wan22_pipeline_helpers.py b/tests/diffusion/models/wan2_2/test_wan22_pipeline_helpers.py
new file mode 100644
index 000000000..314717869
--- /dev/null
+++ b/tests/diffusion/models/wan2_2/test_wan22_pipeline_helpers.py
@@ -0,0 +1,81 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+import json
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+import vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 as wan22_module
+from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
+ create_transformer_from_config,
+ load_transformer_config,
+ retrieve_latents,
+)
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
+
+
+class _LatentDist:
+ def sample(self, generator):
+ assert isinstance(generator, torch.Generator)
+ return torch.tensor([1.0])
+
+ def mode(self):
+ return torch.tensor([2.0])
+
+
+def test_retrieve_latents_supports_sample_mode_argmax_and_direct_latents() -> None:
+ generator = torch.Generator(device="cpu")
+
+ assert retrieve_latents(SimpleNamespace(latent_dist=_LatentDist()), generator).item() == 1.0
+ assert retrieve_latents(SimpleNamespace(latent_dist=_LatentDist()), sample_mode="argmax").item() == 2.0
+ torch.testing.assert_close(retrieve_latents(SimpleNamespace(latents=torch.tensor([3.0]))), torch.tensor([3.0]))
+
+
+def test_retrieve_latents_rejects_unknown_encoder_output() -> None:
+ with pytest.raises(AttributeError, match="Could not access latents"):
+ retrieve_latents(SimpleNamespace())
+
+
+def test_load_transformer_config_reads_local_subfolder_config(tmp_path) -> None:
+ config_dir = tmp_path / "transformer_2"
+ config_dir.mkdir(parents=True)
+ (config_dir / "config.json").write_text(json.dumps({"patch_size": [1, 2, 2], "num_layers": 2}))
+
+ assert load_transformer_config(str(tmp_path), "transformer_2") == {"patch_size": [1, 2, 2], "num_layers": 2}
+ assert load_transformer_config(str(tmp_path), "missing") == {}
+
+
+def test_create_transformer_from_config_maps_supported_keys(monkeypatch) -> None:
+ captured = {}
+
+ class FakeTransformer:
+ def __init__(self, **kwargs) -> None:
+ captured.update(kwargs)
+
+ monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
+
+ transformer = create_transformer_from_config(
+ {
+ "patch_size": [1, 2, 2],
+ "num_attention_heads": 8,
+ "attention_head_dim": 128,
+ "in_channels": 16,
+ "out_channels": 16,
+ "text_dim": 4096,
+ "vace_layers": [0],
+ "ignored": "value",
+ }
+ )
+
+ assert isinstance(transformer, FakeTransformer)
+ assert captured == {
+ "patch_size": (1, 2, 2),
+ "num_attention_heads": 8,
+ "attention_head_dim": 128,
+ "in_channels": 16,
+ "out_channels": 16,
+ "text_dim": 4096,
+ }
diff --git a/tests/diffusion/models/wan2_2/test_wan22_ti2v_pipeline.py b/tests/diffusion/models/wan2_2/test_wan22_ti2v_pipeline.py
new file mode 100644
index 000000000..e611c37b6
--- /dev/null
+++ b/tests/diffusion/models/wan2_2/test_wan22_ti2v_pipeline.py
@@ -0,0 +1,97 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+from types import SimpleNamespace
+
+import pytest
+import torch
+from PIL import Image
+from torch import nn
+
+from tests.diffusion.models.wan2_2.conftest import StubTransformer, StubVAE, noop_progress_bar
+from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_ti2v import (
+ Wan22TI2VPipeline,
+ get_wan22_ti2v_pre_process_func,
+)
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
+
+
+def _make_ti2v_pipeline() -> Wan22TI2VPipeline:
+ pipeline = object.__new__(Wan22TI2VPipeline)
+ nn.Module.__init__(pipeline)
+ pipeline.device = torch.device("cpu")
+ pipeline.transformer = StubTransformer(in_channels=4, out_channels=4)
+ pipeline.vae = StubVAE(z_dim=4)
+ pipeline.vae_scale_factor_temporal = 4
+ pipeline.vae_scale_factor_spatial = 8
+ pipeline.progress_bar = noop_progress_bar
+ return pipeline
+
+
+def test_ti2v_preprocess_uses_720p_area_for_image_condition() -> None:
+ preprocess = get_wan22_ti2v_pre_process_func(SimpleNamespace())
+ request = SimpleNamespace(
+ prompts=[{"prompt": "p", "multi_modal_data": {"image": Image.new("RGB", (320, 160), "blue")}}],
+ sampling_params=SimpleNamespace(height=None, width=None),
+ )
+
+ result = preprocess(request)
+
+ assert result.sampling_params.height == 672
+ assert result.sampling_params.width == 1344
+ assert result.prompts[0]["multi_modal_data"]["image"].size == (1344, 672)
+
+
+def test_ti2v_diffuse_without_image_condition_expands_patch_timesteps() -> None:
+ pipeline = _make_ti2v_pipeline()
+ latents = torch.zeros(1, 4, 2, 4, 4)
+ calls = []
+
+ def fake_predict_noise_maybe_with_cfg(**kwargs):
+ calls.append(kwargs)
+ return torch.ones_like(latents)
+
+ pipeline.predict_noise_maybe_with_cfg = fake_predict_noise_maybe_with_cfg # type: ignore[method-assign]
+ pipeline.scheduler_step_maybe_with_cfg = lambda noise, t, current, cfg: current + noise # type: ignore[method-assign]
+
+ result = pipeline.diffuse(
+ latents=latents,
+ timesteps=torch.tensor([7]),
+ prompt_embeds=torch.zeros(1, 2, 3),
+ negative_prompt_embeds=torch.zeros(1, 2, 3),
+ guidance_scale=3.0,
+ dtype=torch.float32,
+ attention_kwargs={"a": "b"},
+ num_latent_frames=2,
+ latent_height=4,
+ latent_width=4,
+ )
+
+ positive = calls[0]["positive_kwargs"]
+ assert calls[0]["do_true_cfg"] is True
+ assert positive["timestep"].shape == (1, 8)
+ torch.testing.assert_close(positive["timestep"], torch.full((1, 8), 7, dtype=positive["timestep"].dtype))
+ torch.testing.assert_close(positive["hidden_states"], latents)
+ torch.testing.assert_close(result, torch.ones_like(latents))
+
+
+def test_ti2v_prepare_i2v_latents_encodes_condition_and_masks_first_frame() -> None:
+ pipeline = _make_ti2v_pipeline()
+ latents, latent_condition, first_frame_mask = pipeline.prepare_i2v_latents(
+ image=torch.zeros(1, 3, 16, 16),
+ batch_size=1,
+ num_channels_latents=4,
+ height=16,
+ width=16,
+ num_frames=5,
+ dtype=torch.float32,
+ device=torch.device("cpu"),
+ generator=None,
+ latents=torch.zeros(1, 4, 2, 2, 2),
+ )
+
+ torch.testing.assert_close(latents, torch.zeros(1, 4, 2, 2, 2))
+ assert latent_condition.shape == (1, 4, 1, 2, 2)
+ assert first_frame_mask[:, :, 0].sum() == 0
+ assert first_frame_mask[:, :, 1].sum() == 4
diff --git a/tests/diffusion/models/wan2_2/test_wan22_vace_pipeline.py b/tests/diffusion/models/wan2_2/test_wan22_vace_pipeline.py
new file mode 100644
index 000000000..9fa9b67c4
--- /dev/null
+++ b/tests/diffusion/models/wan2_2/test_wan22_vace_pipeline.py
@@ -0,0 +1,137 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+from types import SimpleNamespace
+
+import pytest
+import torch
+from PIL import Image
+from torch import nn
+
+from tests.diffusion.models.wan2_2.conftest import StubTransformer, StubVAE, noop_progress_bar
+from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_vace import (
+ Wan22VACEPipeline,
+ create_vace_transformer_from_config,
+ get_wan22_vace_pre_process_func,
+)
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
+
+
+def _make_vace_pipeline() -> Wan22VACEPipeline:
+ pipeline = object.__new__(Wan22VACEPipeline)
+ nn.Module.__init__(pipeline)
+ pipeline.device = torch.device("cpu")
+ pipeline.transformer = StubTransformer(in_channels=4, out_channels=4)
+ pipeline.transformer_config = pipeline.transformer.config
+ pipeline.vae = StubVAE(z_dim=4)
+ pipeline.vae_scale_factor_temporal = 4
+ pipeline.vae_scale_factor_spatial = 8
+ pipeline.progress_bar = noop_progress_bar
+ return pipeline
+
+
+def test_vace_preprocess_collects_reference_video_and_mask_inputs() -> None:
+ preprocess = get_wan22_vace_pre_process_func(SimpleNamespace())
+ ref = Image.new("RGB", (320, 160), "green")
+ frame = Image.new("RGB", (64, 64), "black")
+ mask = Image.new("L", (64, 64), 255)
+ request = SimpleNamespace(
+ prompts=[
+ {
+ "prompt": "p",
+ "multi_modal_data": {
+ "image": ref,
+ "video": [frame],
+ "mask": mask,
+ },
+ }
+ ],
+ sampling_params=SimpleNamespace(height=None, width=None),
+ )
+
+ result = preprocess(request)
+ additional_info = result.prompts[0]["additional_information"]
+
+ assert result.sampling_params.height == 432
+ assert result.sampling_params.width == 880
+ assert additional_info["reference_images"] == [ref]
+ assert additional_info["source_video"] == [frame]
+ assert additional_info["mask"] == [mask]
+
+
+def test_create_vace_transformer_from_config_maps_vace_specific_keys(monkeypatch) -> None:
+ captured = {}
+
+ class FakeVACETransformer:
+ def __init__(self, **kwargs) -> None:
+ captured.update(kwargs)
+
+ monkeypatch.setattr(
+ "vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_vace.WanVACETransformer3DModel",
+ FakeVACETransformer,
+ )
+
+ transformer = create_vace_transformer_from_config(
+ {
+ "patch_size": [1, 2, 2],
+ "in_channels": 96,
+ "out_channels": 16,
+ "vace_layers": [0, 1, 2],
+ "vace_in_channels": 132,
+ "unknown": "ignored",
+ }
+ )
+
+ assert isinstance(transformer, FakeVACETransformer)
+ assert captured == {
+ "patch_size": (1, 2, 2),
+ "in_channels": 96,
+ "out_channels": 16,
+ "vace_layers": [0, 1, 2],
+ "vace_in_channels": 132,
+ }
+
+
+def test_vace_prepare_masks_encodes_spatial_stride_and_reference_padding() -> None:
+ pipeline = _make_vace_pipeline()
+ mask = torch.ones(1, 3, 5, 16, 16)
+ reference_images = [[torch.zeros(3, 16, 16), torch.zeros(3, 16, 16)]]
+
+ encoded = pipeline.prepare_masks(mask, reference_images)
+
+ assert encoded.shape == (1, 64, 4, 2, 2)
+ torch.testing.assert_close(encoded[:, :, :2], torch.zeros(1, 64, 2, 2, 2))
+ torch.testing.assert_close(encoded[:, :, 2:], torch.ones(1, 64, 2, 2, 2))
+
+
+def test_vace_diffuse_passes_context_and_scale_to_cfg_branches() -> None:
+ pipeline = _make_vace_pipeline()
+ latents = torch.zeros(1, 4, 1, 2, 2)
+ vace_context = torch.ones(1, 12, 1, 2, 2)
+ calls = []
+
+ def fake_predict_noise_maybe_with_cfg(**kwargs):
+ calls.append(kwargs)
+ return torch.ones_like(latents)
+
+ pipeline.predict_noise_maybe_with_cfg = fake_predict_noise_maybe_with_cfg # type: ignore[method-assign]
+ pipeline.scheduler_step_maybe_with_cfg = lambda noise, t, current, cfg: current + noise # type: ignore[method-assign]
+
+ result = pipeline.diffuse(
+ latents=latents,
+ timesteps=torch.tensor([5]),
+ prompt_embeds=torch.zeros(1, 2, 3),
+ negative_prompt_embeds=torch.zeros(1, 2, 3),
+ guidance_scale=4.0,
+ dtype=torch.float32,
+ attention_kwargs={},
+ vace_context=vace_context,
+ vace_context_scale=0.75,
+ )
+
+ assert calls[0]["do_true_cfg"] is True
+ assert calls[0]["true_cfg_scale"] == 4.0
+ assert calls[0]["positive_kwargs"]["vace_context"] is vace_context
+ assert calls[0]["negative_kwargs"]["vace_context_scale"] == 0.75
+ torch.testing.assert_close(result, torch.ones_like(latents))
diff --git a/tests/diffusion/quantization/test_quantization_quality.py b/tests/diffusion/quantization/test_quantization_quality.py
index 3d8f18736..ba6a150c4 100644
--- a/tests/diffusion/quantization/test_quantization_quality.py
+++ b/tests/diffusion/quantization/test_quantization_quality.py
@@ -32,7 +32,7 @@ import numpy as np
import pytest
import torch
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
# ---------------------------------------------------------------------------
# Configuration — add new quantization methods / models here
diff --git a/tests/diffusion/test_diffusion_model_runner.py b/tests/diffusion/test_diffusion_model_runner.py
index 8768986f0..b63f6d888 100644
--- a/tests/diffusion/test_diffusion_model_runner.py
+++ b/tests/diffusion/test_diffusion_model_runner.py
@@ -8,7 +8,7 @@ import pytest
import torch
import vllm_omni.diffusion.worker.diffusion_model_runner as model_runner_module
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
pytestmark = [pytest.mark.diffusion]
diff --git a/tests/diffusion/test_diffusion_step_pipeline.py b/tests/diffusion/test_diffusion_step_pipeline.py
index 42687d4a1..06f8cd14d 100644
--- a/tests/diffusion/test_diffusion_step_pipeline.py
+++ b/tests/diffusion/test_diffusion_step_pipeline.py
@@ -13,7 +13,7 @@ import torch
from pytest_mock import MockerFixture
import vllm_omni.diffusion.worker.diffusion_model_runner as model_runner_module
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
diff --git a/tests/diffusion/test_diffusion_worker.py b/tests/diffusion/test_diffusion_worker.py
index e2bd7ef8a..fc08c5f7f 100644
--- a/tests/diffusion/test_diffusion_worker.py
+++ b/tests/diffusion/test_diffusion_worker.py
@@ -16,7 +16,7 @@ from pytest_mock import MockerFixture
from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker
-pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
+pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.gpu]
@pytest.fixture
@@ -81,17 +81,31 @@ class TestDiffusionWorkerLoadWeights:
class TestDiffusionWorkerSleep:
"""Test DiffusionWorker.sleep method."""
+ @pytest.fixture(autouse=True)
+ def setup_allocator(self, mocker: MockerFixture):
+ """
+ Unified interception of Allocators, and provision of default security values.
+ """
+ self.mock_allocator_class = mocker.patch("vllm.device_allocator.cumem.CuMemAllocator")
+ self.mock_allocator = mocker.Mock()
+ self.mock_allocator_class.get_instance.return_value = self.mock_allocator
+ self.mock_allocator.get_current_usage.return_value = 4 * 1024**3
+ self.mock_allocator.sleep = mocker.Mock()
+
def test_sleep_level_1(self, mocker: MockerFixture, mock_gpu_worker):
"""Test sleep mode level 1 (offload weights only)."""
mock_allocator_class = mocker.patch("vllm.device_allocator.cumem.CuMemAllocator")
- mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
+ mock_platform = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
+ mock_platform.get_free_memory.side_effect = [10 * 1024**3, 12 * 1024**3]
+ mock_platform.get_device_total_memory.return_value = 80 * 1024**3
mock_get_process_memory = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.get_process_gpu_memory")
# Setup process-scoped memory mocks
# Before sleep: 3GB used
# After sleep: 1GB used (freed 2GB)
+ initial_usage = 3 * 1024**3
mock_get_process_memory.side_effect = [
- 3 * 1024**3,
+ initial_usage,
1 * 1024**3,
]
@@ -99,25 +113,29 @@ class TestDiffusionWorkerSleep:
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.sleep = mocker.Mock()
+ mock_allocator.get_current_usage.return_value = initial_usage
# Call sleep with level 1
result = mock_gpu_worker.sleep(level=1)
# Verify sleep was called with correct tags
mock_allocator.sleep.assert_called_once_with(offload_tags=("weights",))
- assert result is True
+ assert bool(result) is True
# Verify buffers were NOT saved (level 1 doesn't save buffers)
assert len(mock_gpu_worker._sleep_saved_buffers) == 0
def test_sleep_level_2(self, mocker: MockerFixture, mock_gpu_worker):
"""Test sleep mode level 2 (offload all, save buffers)."""
mock_allocator_class = mocker.patch("vllm.device_allocator.cumem.CuMemAllocator")
- mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
+ mock_platform = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
+ mock_platform.get_free_memory.side_effect = [5 * 1024**3, 10 * 1024**3]
+ mock_platform.get_device_total_memory.return_value = 80 * 1024**3
mock_get_process_memory = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.get_process_gpu_memory")
# Setup process-scoped memory mocks
+ initial_usage = 5 * 1024**3
mock_get_process_memory.side_effect = [
- 5 * 1024**3, # Before sleep
+ initial_usage, # Before sleep
1 * 1024**3, # After sleep (freed 4GB)
]
@@ -125,6 +143,7 @@ class TestDiffusionWorkerSleep:
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.sleep = mocker.Mock()
+ mock_allocator.get_current_usage.return_value = initial_usage
# Mock pipeline buffers
mock_buffer1 = torch.randn(10, 10)
@@ -141,7 +160,7 @@ class TestDiffusionWorkerSleep:
# Verify sleep was called with empty tags (offload all)
mock_allocator.sleep.assert_called_once_with(offload_tags=tuple())
- assert result is True
+ assert bool(result) is True
# Verify buffers were saved
assert len(mock_gpu_worker._sleep_saved_buffers) == 2
@@ -151,22 +170,26 @@ class TestDiffusionWorkerSleep:
def test_sleep_memory_freed_validation(self, mocker: MockerFixture, mock_gpu_worker):
"""Test that sleep validates memory was actually freed."""
mock_allocator_class = mocker.patch("vllm.device_allocator.cumem.CuMemAllocator")
- mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
+ mock_platform = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
+ mock_platform.get_free_memory.return_value = 10 * 1024**3
+ mock_platform.get_device_total_memory.return_value = 80 * 1024**3
mock_get_process_memory = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.get_process_gpu_memory")
# Simulate process memory increase (should trigger assertion error)
+ initial_usage = 1 * 1024**3
mock_get_process_memory.side_effect = [
- 1 * 1024**3, # Before sleep: 1GB used
+ initial_usage, # Before sleep: 1GB used
3 * 1024**3, # After sleep: 3GB used (negative freed)
]
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.sleep = mocker.Mock()
+ mock_allocator.get_current_usage.return_value = initial_usage
# This should raise an assertion error
- with pytest.raises(AssertionError, match="Memory usage increased after sleeping"):
- mock_gpu_worker.sleep(level=1)
+ result = mock_gpu_worker.sleep(level=1)
+ assert result == initial_usage
def test_sleep_falls_back_to_device_memory_when_nvml_unavailable(self, mocker: MockerFixture, mock_gpu_worker):
"""Test sleep uses device-scoped fallback when NVML is unavailable."""
@@ -184,11 +207,12 @@ class TestDiffusionWorkerSleep:
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.sleep = mocker.Mock()
+ mock_allocator.get_current_usage.return_value = 2 * 1024**3
result = mock_gpu_worker.sleep(level=1)
mock_allocator.sleep.assert_called_once_with(offload_tags=("weights",))
- assert result is True
+ assert bool(result) is True
class TestDiffusionWorkerWakeUp:
@@ -202,6 +226,7 @@ class TestDiffusionWorkerWakeUp:
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.wake_up = mocker.Mock()
+ mock_allocator.get_current_usage.return_value = 10 * 1024**3
# Ensure no saved buffers
mock_gpu_worker._sleep_saved_buffers = {}
@@ -211,7 +236,7 @@ class TestDiffusionWorkerWakeUp:
# Verify allocator.wake_up was called
mock_allocator.wake_up.assert_called_once_with(["weights"])
- assert result is True
+ assert bool(result) is True
def test_wake_up_with_buffers(self, mocker: MockerFixture, mock_gpu_worker):
"""Test wake_up with saved buffers (level 2 sleep)."""
@@ -221,6 +246,7 @@ class TestDiffusionWorkerWakeUp:
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.wake_up = mocker.Mock()
+ mock_allocator.get_current_usage.return_value = 10 * 1024**3
# Create saved buffers
saved_buffer1 = torch.randn(10, 10)
@@ -255,7 +281,7 @@ class TestDiffusionWorkerWakeUp:
# Verify saved buffers were cleared
assert len(mock_gpu_worker._sleep_saved_buffers) == 0
- assert result is True
+ assert bool(result) is True
def test_wake_up_partial_buffer_restore(self, mocker: MockerFixture, mock_gpu_worker):
"""Test wake_up only restores buffers that were saved."""
@@ -265,6 +291,7 @@ class TestDiffusionWorkerWakeUp:
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.wake_up = mocker.Mock()
+ mock_allocator.get_current_usage.return_value = 10 * 1024**3
# Only save buffer1, not buffer2
saved_buffer1 = torch.randn(10, 10)
@@ -293,4 +320,4 @@ class TestDiffusionWorkerWakeUp:
# buffer2 should NOT be restored since it wasn't saved
mock_buffer2.data.copy_.assert_not_called()
- assert result is True
+ assert bool(result) is True
diff --git a/tests/diffusion/test_inline_stage_diffusion_client.py b/tests/diffusion/test_inline_stage_diffusion_client.py
new file mode 100644
index 000000000..385f39b12
--- /dev/null
+++ b/tests/diffusion/test_inline_stage_diffusion_client.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from vllm_omni.diffusion.data import OmniDiffusionConfig
+from vllm_omni.diffusion.inline_stage_diffusion_client import InlineStageDiffusionClient
+from vllm_omni.engine.stage_init_utils import StageMetadata
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+from vllm_omni.outputs import OmniRequestOutput
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+@pytest.fixture
+def mock_engine():
+ with patch("vllm_omni.diffusion.inline_stage_diffusion_client.DiffusionEngine") as mock:
+ engine_instance = MagicMock()
+ mock.make_engine.return_value = engine_instance
+ yield engine_instance
+
+
+@pytest.fixture
+def client(mock_engine):
+ metadata = StageMetadata(
+ stage_id=0,
+ stage_type="diffusion",
+ engine_output_type="image",
+ is_comprehension=False,
+ requires_multimodal_data=False,
+ engine_input_source="prompt",
+ final_output=True,
+ final_output_type="image",
+ default_sampling_params={},
+ custom_process_input_func=None,
+ model_stage=None,
+ runtime_cfg=None,
+ )
+ with patch.object(InlineStageDiffusionClient, "_enrich_config"):
+ od_config = MagicMock(spec=OmniDiffusionConfig)
+ c = InlineStageDiffusionClient(model="test_model", od_config=od_config, metadata=metadata, batch_size=1)
+ yield c
+ c.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_inline_dispatch_request_success(client, mock_engine):
+ # Setup mock engine step to return a successful result
+ mock_result = OmniRequestOutput.from_diffusion(request_id="req-1", images=[MagicMock()])
+ mock_engine.step.return_value = [mock_result]
+
+ sampling_params = OmniDiffusionSamplingParams()
+ await client.add_request_async("req-1", "A test prompt", sampling_params)
+
+ # Wait for the task to be processed
+ for _ in range(10):
+ output = client.get_diffusion_output_nowait()
+ if output is not None:
+ break
+ await asyncio.sleep(0.01)
+
+ assert output is not None
+ assert output.request_id == "req-1"
+ mock_engine.step.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_inline_dispatch_request_error(client, mock_engine):
+ # Setup mock engine step to raise an exception
+ mock_engine.step.side_effect = RuntimeError("Engine failure")
+
+ sampling_params = OmniDiffusionSamplingParams()
+ await client.add_request_async("req-err", "A test prompt", sampling_params)
+
+ for _ in range(10):
+ output = client.get_diffusion_output_nowait()
+ if output is not None:
+ break
+ await asyncio.sleep(0.01)
+
+ assert output is not None
+ assert output.request_id == "req-err"
+ assert output.error == "Engine failure"
+ assert not output.images
+
+
+def test_inline_shutdown(client, mock_engine):
+ assert not client._shutting_down
+
+ # Shutting down should cleanly cancel anything queued and close engine
+ client.shutdown()
+
+ assert client._shutting_down
+ mock_engine.close.assert_called_once()
diff --git a/tests/diffusion/test_multiproc_engine_concurrency.py b/tests/diffusion/test_multiproc_engine_concurrency.py
index 4bc3e05fe..9ec06e810 100644
--- a/tests/diffusion/test_multiproc_engine_concurrency.py
+++ b/tests/diffusion/test_multiproc_engine_concurrency.py
@@ -1,17 +1,25 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+import asyncio
+import multiprocessing as mp
import queue
import threading
+import time
from types import SimpleNamespace
+from unittest.mock import MagicMock, Mock
import pytest
import torch
+import zmq
+from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.executor.multiproc_executor import MultiprocDiffusionExecutor
from vllm_omni.diffusion.sched import RequestScheduler
+from vllm_omni.diffusion.stage_diffusion_proc import StageDiffusionProc
+from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.diffusion, pytest.mark.core_model, pytest.mark.cpu]
@@ -51,6 +59,8 @@ def _make_executor(num_gpus: int = 1):
executor._result_mq = mock_rmq
executor._closed = False
executor._processes = []
+ executor.is_failed = False
+ executor._failure_callbacks = []
return executor, req_q, res_q
@@ -334,8 +344,9 @@ class TestSerialEngineOperations:
class TestCollectiveRpcTimeoutWhileLockHeld:
"""``collective_rpc(timeout=...)`` must honour its timeout even when
- another thread holds ``engine._rpc_lock`` indefinitely (e.g. a stalled
- ``add_req`` waiting on an unresponsive worker).
+ another thread holds ``engine._rpc_lock`` indefinitely (e.g. request
+ execution stalled on ``add_req_and_wait_for_response`` → ``execute_fn``
+ → ``collective_rpc`` while blocked on an unresponsive worker).
"""
def test_rpc_times_out_when_lock_held_directly(self):
@@ -359,10 +370,10 @@ class TestCollectiveRpcTimeoutWhileLockHeld:
with pytest.raises(TimeoutError):
engine.collective_rpc("health", timeout=0.5)
- def test_rpc_times_out_when_add_req_stalled_on_worker(self):
+ def test_rpc_times_out_when_request_execution_stalled_on_worker(self):
"""Real-world scenario the bot flagged:
- ``add_req`` holds ``_rpc_lock`` while blocked on
+ The scheduler/execute path holds ``_rpc_lock`` while blocked on
``executor._result_mq.dequeue()`` because the worker never replies.
A concurrent ``collective_rpc(timeout=...)`` must still time out
instead of hanging forever waiting for the lock.
@@ -428,3 +439,353 @@ class TestCollectiveRpcTimeoutWhileLockHeld:
t.join(5)
assert result.error == "ok"
+
+
+# ───────── error handling: EngineDeadError propagation through layers ─────
+
+
+class TestMultiprocExecutorRaisesEngineDeadError:
+ """``collective_rpc`` raises ``EngineDeadError`` when the engine is failed."""
+
+ def test_collective_rpc_raises_when_is_failed(self):
+ executor = object.__new__(MultiprocDiffusionExecutor)
+ executor._closed = False
+ executor._broadcast_mq = MagicMock()
+ executor._result_mq = MagicMock()
+ executor._result_mq.dequeue = MagicMock(side_effect=TimeoutError)
+ executor.is_failed = True
+
+ with pytest.raises(EngineDeadError):
+ executor.collective_rpc(
+ "generate",
+ args=(MagicMock(),),
+ unique_reply_rank=0,
+ exec_all_ranks=True,
+ )
+
+ def test_collective_rpc_raises_mid_dequeue_when_is_failed(self):
+ """Worker dies while we are polling the dequeue loop."""
+ executor, _, res_q = _make_executor()
+
+ call_count = 0
+ orig_dequeue = executor._result_mq.dequeue
+
+ def _dying_dequeue(timeout=None):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ executor.is_failed = True
+ raise TimeoutError
+ return orig_dequeue(timeout=timeout)
+
+ executor._result_mq.dequeue = _dying_dequeue
+
+ with pytest.raises(EngineDeadError):
+ executor.collective_rpc(
+ "generate",
+ args=(MagicMock(),),
+ unique_reply_rank=0,
+ exec_all_ranks=True,
+ )
+
+
+class TestDiffusionEngineDeadErrorPassthrough:
+ """``DiffusionEngine.add_req_and_wait_for_response`` re-raises
+ ``EngineDeadError`` from executor and wraps other errors."""
+
+ def test_engine_dead_error_propagates(self):
+ engine, executor, _, _ = _make_engine()
+ engine.execute_fn = Mock(side_effect=EngineDeadError())
+
+ with pytest.raises(EngineDeadError):
+ engine.add_req_and_wait_for_response(_mock_request("dead"))
+
+ def test_runtime_error_wrapped_in_output(self):
+ engine, executor, _, _ = _make_engine()
+ engine.execute_fn = Mock(side_effect=RuntimeError("gpu fault"))
+
+ out = engine.add_req_and_wait_for_response(_mock_request("fault"))
+ assert isinstance(out, DiffusionOutput)
+ assert "gpu fault" in out.error
+
+
+class TestStageDiffusionClientErrorPropagation:
+ """Error surface behaviour of ``StageDiffusionClient``.
+
+ Uses ``object.__new__`` to construct a client without spawning a real
+ subprocess, then manually sets the fields needed for each test.
+ """
+
+ def _make_client(self, *, engine_dead=False, proc_alive=True):
+ from vllm_omni.diffusion.stage_diffusion_client import StageDiffusionClient
+
+ client = object.__new__(StageDiffusionClient)
+ client.stage_id = 0
+ client.final_output = True
+ client.final_output_type = "image"
+ client.default_sampling_params = None
+ client.custom_process_input_func = None
+ client.engine_input_source = None
+
+ client._output_queue = asyncio.Queue()
+ client._rpc_results = {}
+ client._pending_rpcs = set()
+ client._tasks = {}
+ client._shutting_down = False
+ client._engine_dead = engine_dead
+ client._owns_process = True
+ client._proc = MagicMock(
+ is_alive=MagicMock(return_value=proc_alive),
+ exitcode=1,
+ )
+ client._request_socket = MagicMock()
+ client._response_socket = MagicMock()
+ client._encoder = MagicMock()
+ client._decoder = MagicMock()
+
+ return client
+
+ @pytest.mark.asyncio
+ async def test_add_request_raises_when_dead(self):
+ client = self._make_client(engine_dead=True)
+
+ with pytest.raises(EngineDeadError):
+ await client.add_request_async("req-3", "test prompt", None)
+
+ def test_check_health_raises_when_dead(self):
+ client = self._make_client(engine_dead=True)
+
+ with pytest.raises(EngineDeadError):
+ client.check_health()
+
+ def test_check_health_ok_when_alive(self):
+ client = self._make_client()
+ client.check_health()
+
+ def test_get_output_raises_engine_dead_when_dead(self):
+ """When ``_engine_dead`` is True and the output queue is empty,
+ ``get_diffusion_output_nowait`` must raise ``EngineDeadError``."""
+ client = self._make_client(engine_dead=True)
+ # Simulate _drain_responses as a no-op (no ZMQ socket)
+ client._response_socket.recv.side_effect = zmq.Again
+
+ with pytest.raises(EngineDeadError):
+ client.get_diffusion_output_nowait()
+
+ def test_get_output_returns_none_when_alive_and_empty(self):
+ """When the engine is alive and the queue is empty, return None."""
+ client = self._make_client()
+ client._response_socket.recv.side_effect = zmq.Again
+
+ assert client.get_diffusion_output_nowait() is None
+
+ def test_check_health_raises_when_proc_dead(self):
+ """``check_health`` detects a dead subprocess via ``_proc.is_alive()``
+ and raises ``EngineDeadError``, setting ``_engine_dead`` as a
+ side effect."""
+ client = self._make_client(proc_alive=False)
+
+ with pytest.raises(EngineDeadError, match="not alive"):
+ client.check_health()
+
+ assert client._engine_dead is True
+
+ def test_get_output_raises_when_proc_dead(self):
+ """When the subprocess has died (non-signal exit) and the output
+ queue is empty, ``get_diffusion_output_nowait`` must raise
+ ``EngineDeadError`` with the exit code."""
+ client = self._make_client(proc_alive=False)
+ client._response_socket.recv.side_effect = zmq.Again
+
+ with pytest.raises(EngineDeadError, match="exit code"):
+ client.get_diffusion_output_nowait()
+
+ assert client._engine_dead is True
+
+ def test_get_output_returns_none_on_signal_death(self):
+ """When the subprocess was killed by a signal (exit code > 128),
+ ``get_diffusion_output_nowait`` returns ``None`` and sets
+ ``_shutting_down`` instead of raising."""
+ client = self._make_client(proc_alive=False)
+ client._proc.exitcode = 137 # SIGKILL (128 + 9)
+ client._response_socket.recv.side_effect = zmq.Again
+
+ result = client.get_diffusion_output_nowait()
+
+ assert result is None
+ assert client._shutting_down is True
+ assert client._engine_dead is True
+
+
+# ───────── monitor thread & death sentinel integration tests ─────────
+
+
+def _poll_flag(get_flag, *, timeout=5.0, interval=0.05) -> bool:
+ """Poll until ``get_flag()`` returns True or *timeout* elapses."""
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if get_flag():
+ return True
+ time.sleep(interval)
+ return False
+
+
+def _make_short_lived_process() -> mp.Process:
+ """Spawn a real subprocess that exits immediately.
+
+ The process must be started with ``"fork"`` (or the platform default)
+ so that it can use a plain ``lambda`` as its target — ``"spawn"`` would
+ fail to pickle it.
+ """
+ ctx = mp.get_context("fork")
+ p = ctx.Process(target=lambda: None, name="ShortLivedWorker-0")
+ p.start()
+ return p
+
+
+class TestMultiprocExecutorWorkerMonitor:
+ """Integration tests for ``start_worker_monitor``.
+
+ Uses real short-lived subprocesses so that OS-level sentinel fd
+ readiness is exercised end-to-end.
+ """
+
+ def test_worker_monitor_sets_is_failed_and_calls_callbacks_on_death(self):
+ """When a worker process dies, the monitor thread must:
+ 1. Set ``is_failed = True``
+ 2. Call ``shutdown()`` (which sets ``_closed = True``)
+ 3. Invoke all registered failure callbacks
+ """
+ executor = object.__new__(MultiprocDiffusionExecutor)
+ executor._closed = False
+ executor.is_failed = False
+ executor._failure_callbacks = []
+ executor._broadcast_mq = None
+ executor._result_mq = None
+ executor.resources = None
+ # Use a no-op so shutdown() doesn't crash on None resources.
+ executor._finalizer = lambda: None
+
+ proc = _make_short_lived_process()
+ executor._processes = [proc]
+
+ callback_called = threading.Event()
+ executor.register_failure_callback(callback_called.set)
+
+ executor.start_worker_monitor()
+
+ # Wait for the process to exit and the monitor to react.
+ proc.join(5)
+ assert _poll_flag(lambda: executor.is_failed), "is_failed was not set"
+ assert executor._closed, "shutdown() was not called"
+ assert callback_called.wait(timeout=2), "failure callback was not invoked"
+
+ def test_worker_monitor_noop_when_already_closed(self):
+ """If ``_closed`` is already True when the process dies (orderly
+ shutdown), the monitor must *not* set ``is_failed``."""
+ executor = object.__new__(MultiprocDiffusionExecutor)
+ executor._closed = True # already shut down
+ executor.is_failed = False
+ executor._failure_callbacks = []
+ executor._broadcast_mq = None
+ executor._result_mq = None
+ executor.resources = None
+ executor._finalizer = lambda: None
+
+ proc = _make_short_lived_process()
+ executor._processes = [proc]
+
+ executor.start_worker_monitor()
+ proc.join(5)
+
+ # Give the monitor thread a chance to run (it should early-return).
+ time.sleep(0.3)
+ assert not executor.is_failed, "is_failed should remain False on orderly shutdown"
+
+
+class TestStageDiffusionClientProcMonitor:
+ """Integration test for ``StageDiffusionClient._start_proc_monitor``.
+
+ Uses a real short-lived subprocess to verify the sentinel-based
+ detection pipeline.
+ """
+
+ def test_proc_monitor_sets_engine_dead_on_process_death(self):
+ """When the subprocess dies, the monitor thread must set
+ ``_engine_dead = True``."""
+ from vllm_omni.diffusion.stage_diffusion_client import StageDiffusionClient
+
+ client = object.__new__(StageDiffusionClient)
+ client.stage_id = 0
+ client._shutting_down = False
+ client._engine_dead = False
+
+ proc = _make_short_lived_process()
+ client._proc = proc
+
+ client._start_proc_monitor()
+ proc.join(5)
+
+ assert _poll_flag(lambda: client._engine_dead), "_engine_dead was not set"
+
+
+class TestDrainResponsesDeathSentinel:
+ """Tests for death sentinel and error routing in
+ ``StageDiffusionClient._drain_responses()``.
+ """
+
+ def _make_client(self):
+ from vllm_omni.diffusion.stage_diffusion_client import StageDiffusionClient
+
+ client = object.__new__(StageDiffusionClient)
+ client.stage_id = 0
+ client._engine_dead = False
+ client._shutting_down = False
+ client._output_queue = asyncio.Queue()
+ client._rpc_results = {}
+ client._pending_rpcs = set()
+ client._response_socket = MagicMock()
+ client._decoder = MagicMock()
+ return client
+
+ def test_drain_responses_sets_engine_dead_on_death_sentinel(self):
+ """When ``_drain_responses`` receives the ``DIFFUSION_PROC_DEAD``
+ sentinel, it must set ``_engine_dead = True`` and stop draining
+ (decoder is never called)."""
+ client = self._make_client()
+
+ # First recv returns the death sentinel, second would be a normal
+ # message but should never be reached.
+ client._response_socket.recv.side_effect = [
+ StageDiffusionProc.DIFFUSION_PROC_DEAD,
+ b"should-not-be-reached",
+ ]
+
+ client._drain_responses()
+
+ assert client._engine_dead is True
+ client._decoder.decode.assert_not_called()
+
+ def test_drain_responses_routes_error_as_omni_request_output(self):
+ """When ``_drain_responses`` receives a ``{"type": "error"}`` message
+ with a ``request_id``, it must place an ``OmniRequestOutput`` with
+ the error on ``_output_queue``."""
+ client = self._make_client()
+
+ error_msg = {
+ "type": "error",
+ "request_id": "req-fail",
+ "error": "gpu fault",
+ }
+ # First recv returns the encoded error, second raises zmq.Again.
+ client._response_socket.recv.side_effect = [b"encoded-error", zmq.Again]
+ client._decoder.decode.return_value = error_msg
+
+ client._drain_responses()
+
+ assert not client._output_queue.empty()
+ output = client._output_queue.get_nowait()
+ assert isinstance(output, OmniRequestOutput)
+ assert output.request_id == "req-fail"
+ assert output.error == "gpu fault"
+ assert output.finished is True
diff --git a/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py b/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py
index 7a3caba11..256e3e0a3 100644
--- a/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py
+++ b/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py
@@ -4,10 +4,12 @@
import threading
from collections import deque
from types import SimpleNamespace
+from unittest.mock import patch
import pytest
import torch
from pytest_mock import MockerFixture
+from vllm.v1.core.sched.scheduler import Scheduler as VLLMScheduler
from vllm.v1.request import RequestStatus
from vllm_omni.distributed.omni_connectors.transfer_adapter.base import OmniTransferAdapterBase
@@ -335,6 +337,27 @@ def test_cleanup_after_poll_flow(build_adapter):
assert "ext-flow" not in adapter.request_payload
+def test_finish_requests_restores_status(build_adapter):
+ """Abort path must pop ``requests_origin_status`` and restore pre-wait status.
+
+ While ``process_pending_chunks`` holds a request off the scheduler queues, the
+ adapter records the prior status (WAITING or RUNNING). ``finish_requests`` must
+ put that status back on the live ``Request`` so base ``Scheduler.finish_requests``
+ can finish bookkeeping without inconsistent state / crashes.
+ """
+ adapter, _ = build_adapter(stage_id=1)
+ req_id = "req-abort-during-chunk"
+ prior = RequestStatus.RUNNING
+ request = _req(req_id, RequestStatus.WAITING_FOR_CHUNK)
+ adapter.requests_origin_status[req_id] = prior
+ requests_map = {req_id: request}
+
+ adapter.finish_requests([req_id], RequestStatus.FINISHED_ABORTED, requests_map)
+
+ assert request.status == prior
+ assert req_id not in adapter.requests_origin_status
+
+
# ---------------------------------------------------------------
# Scheduler trigger tests
# ---------------------------------------------------------------
@@ -508,3 +531,31 @@ def test_ar_scheduler_defers_cleanup_and_queues_save_on_finished(mocker: MockerF
assert len(cleanup_calls) == 0
assert len(save_calls) == 1
+
+
+def test_omni_ar_scheduler_finish_requests(mocker: MockerFixture):
+ """``OmniARScheduler.finish_requests`` must run chunk adapter hook before vLLM base."""
+ from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
+
+ order: list[str] = []
+
+ adapter = mocker.MagicMock()
+
+ def _adapter_finish(request_ids, finished_status, requests):
+ order.append("adapter")
+ return []
+
+ adapter.finish_requests.side_effect = _adapter_finish
+
+ def _super_finish(_self, request_ids, finished_status):
+ order.append("super")
+ return []
+
+ sched = OmniARScheduler.__new__(OmniARScheduler)
+ sched.chunk_transfer_adapter = adapter
+ sched.requests = {}
+
+ with patch.object(VLLMScheduler, "finish_requests", _super_finish):
+ OmniARScheduler.finish_requests(sched, ["r1"], RequestStatus.FINISHED_ABORTED)
+
+ assert order == ["adapter", "super"]
diff --git a/tests/distributed/omni_connectors/test_tp_rank_aware.py b/tests/distributed/omni_connectors/test_tp_rank_aware.py
new file mode 100644
index 000000000..d4793479a
--- /dev/null
+++ b/tests/distributed/omni_connectors/test_tp_rank_aware.py
@@ -0,0 +1,716 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Unit tests for rank-aware KV transfer (TP > 1) and heterogeneous TP support.
+
+Covers:
+- _build_rank_aware_send_keys / _build_rank_aware_recv_keys
+- _get_kv_source_ranks / _get_kv_target_ranks / get_kv_connector_key
+- update_sender_info storing base host/port
+- receive path constructing per-rank metadata for connector.get()
+- Mooncake connector _query_metadata_at and partial-metadata get() path
+"""
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+import torch
+
+from vllm_omni.distributed.omni_connectors.kv_transfer_manager import (
+ KVCacheTransferData,
+ OmniKVCacheConfig,
+ OmniKVTransferManager,
+)
+from vllm_omni.distributed.omni_connectors.utils.initialization import (
+ KV_RANK_PORT_STRIDE,
+)
+from vllm_omni.distributed.omni_connectors.utils.kv_utils import (
+ KVTPTopology,
+ build_rank_aware_recv_keys,
+ build_rank_aware_send_keys,
+ get_kv_connector_key,
+ get_kv_source_ranks,
+ get_kv_target_ranks,
+ merge_received_rank_shards,
+ slice_received_rank_shard,
+)
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def _make_manager(
+ from_tp: int = 1,
+ to_tp: int = 1,
+ local_rank: int = 0,
+ from_stage: str = "stage0",
+ to_stage: str = "stage1",
+ stage_id: str = "stage1",
+ need_recv: bool = True,
+ need_send: bool = False,
+ recv_timeout: float = 0.3,
+) -> OmniKVTransferManager:
+ """Build a manager with TP params injected, bypassing torch.distributed."""
+ config = OmniKVCacheConfig(
+ connector_config={"type": "mock"},
+ from_stage=from_stage,
+ to_stage=to_stage,
+ stage_id=stage_id,
+ need_recv_cache=need_recv,
+ need_send_cache=need_send,
+ recv_timeout=recv_timeout,
+ from_tp=from_tp,
+ to_tp=to_tp,
+ )
+ with (
+ patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_local_tp_rank", return_value=local_rank),
+ patch(
+ "vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_tp_world_size",
+ return_value=max(from_tp, to_tp),
+ ),
+ ):
+ mgr = OmniKVTransferManager(config)
+ return mgr
+
+
+def _make_payload(head_values: list[float], request_id: str = "req-1") -> dict:
+ head_tensor = torch.tensor(head_values, dtype=torch.float32).view(1, len(head_values), 1).repeat(2, 1, 1)
+ return {
+ "request_id": request_id,
+ "layer_blocks": {
+ "key_cache": [head_tensor.clone()],
+ "value_cache": [(head_tensor + 100).clone()],
+ },
+ "block_ids": [0],
+ "metadata": {"seq_len": 2},
+ }
+
+
+def _make_transfer_data(head_values: list[float], request_id: str = "req-1") -> KVCacheTransferData:
+ payload = _make_payload(head_values, request_id=request_id)
+ return KVCacheTransferData(
+ request_id=request_id,
+ layer_blocks=payload["layer_blocks"],
+ block_ids=payload["block_ids"],
+ metadata=payload["metadata"],
+ )
+
+
+# ── Key format helper ────────────────────────────────────────────────
+
+
+class TestConnectorKeyFormat:
+ def test_key_format_matches_pr2677(self):
+ key = get_kv_connector_key("req-1", "stage0", 0, 1, 2)
+ assert key == "req-1_stage0_0_1_2"
+
+ def test_key_fields_are_positional(self):
+ key = get_kv_connector_key("r", "s", 5, 3, 7)
+ parts = key.split("_")
+ assert parts == ["r", "s", "5", "3", "7"]
+
+
+# ── Source / target rank mapping ─────────────────────────────────────
+
+
+class TestRankMapping:
+ """Verify get_kv_target_ranks and get_kv_source_ranks for various TP configs."""
+
+ def test_homogeneous_tp2_rank0(self):
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=0)
+ assert get_kv_target_ranks(topo) == [0]
+ assert get_kv_source_ranks(topo) == [0]
+
+ def test_homogeneous_tp2_rank1(self):
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=1)
+ assert get_kv_target_ranks(topo) == [1]
+ assert get_kv_source_ranks(topo) == [1]
+
+ def test_homogeneous_tp4_rank3(self):
+ topo = KVTPTopology(source_tp_size=4, target_tp_size=4, local_rank=3)
+ assert get_kv_target_ranks(topo) == [3]
+ assert get_kv_source_ranks(topo) == [3]
+
+ def test_sender_gt_receiver_tp4_to_tp2_rank0(self):
+ """Receiver rank 0 should receive from sender rank 0 and 1."""
+ topo = KVTPTopology(source_tp_size=4, target_tp_size=2, local_rank=0)
+ assert get_kv_source_ranks(topo) == [0, 1]
+
+ def test_sender_gt_receiver_tp4_to_tp2_rank1(self):
+ """Receiver rank 1 should receive from sender rank 2 and 3."""
+ topo = KVTPTopology(source_tp_size=4, target_tp_size=2, local_rank=1)
+ assert get_kv_source_ranks(topo) == [2, 3]
+
+ def test_sender_lt_receiver_tp2_to_tp4_rank0(self):
+ """Sender rank 0 should send to receiver ranks 0 and 1."""
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=0)
+ assert get_kv_target_ranks(topo) == [0, 1]
+
+ def test_sender_lt_receiver_tp2_to_tp4_rank1(self):
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=1)
+ assert get_kv_target_ranks(topo) == [2, 3]
+
+ def test_receiver_lt_sender_source_ranks(self):
+ """Receiver rank 0 with tp2_to_tp4 should source from rank 0 only."""
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=0)
+ assert get_kv_source_ranks(topo) == [0]
+
+ def test_invalid_topology_raises(self):
+ topo = KVTPTopology(source_tp_size=3, target_tp_size=2, local_rank=0)
+ with pytest.raises(ValueError, match="divisible"):
+ get_kv_source_ranks(topo)
+
+
+# ── _build_rank_aware_recv_keys ──────────────────────────────────────
+
+
+class TestBuildRankAwareRecvKeys:
+ """Verify build_rank_aware_recv_keys returns (key, from_rank) tuples."""
+
+ def test_tp1_returns_legacy_key_with_none_rank(self):
+ topo = KVTPTopology(source_tp_size=1, target_tp_size=1, local_rank=0)
+ pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
+ assert len(pairs) == 1
+ key, rank = pairs[0]
+ assert key == "omni_stage0_to_stage1_kv_cache_req-1"
+ assert rank is None
+
+ def test_homogeneous_tp2_rank0(self):
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=0)
+ pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
+ assert len(pairs) == 1
+ key, rank = pairs[0]
+ assert key == "req-1_stage0_0_0_0"
+ assert rank == 0
+
+ def test_homogeneous_tp2_rank1(self):
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=1)
+ pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
+ assert len(pairs) == 1
+ key, rank = pairs[0]
+ assert key == "req-1_stage0_0_1_1"
+ assert rank == 1
+
+ def test_heterogeneous_tp4_to_tp2_rank0_gets_two_keys(self):
+ """Receiver rank 0 with source_tp=4, target_tp=2 should get 2 keys."""
+ topo = KVTPTopology(source_tp_size=4, target_tp_size=2, local_rank=0)
+ pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
+ assert len(pairs) == 2
+
+ keys = [k for k, _ in pairs]
+ ranks = [r for _, r in pairs]
+ assert keys == ["req-1_stage0_0_0_0", "req-1_stage0_0_1_0"]
+ assert ranks == [0, 1]
+
+ def test_heterogeneous_tp4_to_tp2_rank1_gets_two_keys(self):
+ topo = KVTPTopology(source_tp_size=4, target_tp_size=2, local_rank=1)
+ pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
+ assert len(pairs) == 2
+
+ ranks = [r for _, r in pairs]
+ assert ranks == [2, 3]
+
+ def test_heterogeneous_tp2_to_tp4_rank2_gets_one_key(self):
+ """Receiver rank 2 with source_tp=2, target_tp=4 should get 1 key from sender rank 1."""
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=2)
+ pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
+ assert len(pairs) == 1
+ key, rank = pairs[0]
+ assert rank == 1
+ assert key == "req-1_stage0_0_1_2"
+
+
+# ── _build_rank_aware_send_keys ──────────────────────────────────────
+
+
+class TestBuildRankAwareSendKeys:
+ def test_tp1_returns_legacy_key(self):
+ topo = KVTPTopology(source_tp_size=1, target_tp_size=1, local_rank=0)
+ keys = build_rank_aware_send_keys("req-1", "stage0", "stage1", topo)
+ assert keys == ["omni_stage0_to_stage1_kv_cache_req-1"]
+
+ def test_homogeneous_tp2_rank0(self):
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=0)
+ keys = build_rank_aware_send_keys("req-1", "stage0", "stage1", topo)
+ assert keys == ["req-1_stage0_0_0_0"]
+
+ def test_sender_lt_receiver_tp2_to_tp4_rank0_sends_two_keys(self):
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=0)
+ keys = build_rank_aware_send_keys("req-1", "stage0", "stage1", topo)
+ assert len(keys) == 2
+ assert keys == ["req-1_stage0_0_0_0", "req-1_stage0_0_0_1"]
+
+
+# ── update_sender_info stores base host/port ─────────────────────────
+
+
+class TestUpdateSenderInfoBase:
+ def test_stores_base_host_and_port(self):
+ mgr = _make_manager(from_tp=2, to_tp=2, local_rank=0)
+ mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
+
+ assert mgr._sender_base_host == "10.0.0.1"
+ assert mgr._sender_base_zmq_port == 50151
+
+ def test_rank1_adjusts_default_port_but_preserves_base(self):
+ mgr = _make_manager(from_tp=2, to_tp=2, local_rank=1)
+ mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
+
+ assert mgr._sender_base_host == "10.0.0.1"
+ assert mgr._sender_base_zmq_port == 50151
+ expected_adjusted = 50151 + 1 * KV_RANK_PORT_STRIDE
+ assert mgr.config.connector_config["sender_zmq_port"] == expected_adjusted
+
+ def test_nested_sender_info_resolves_correctly(self):
+ """Nested sender_info keyed by integer stage id should resolve
+ using recv_stages (engine_input_source → recv_from)."""
+ config = OmniKVCacheConfig(
+ connector_config={"type": "mock"},
+ stage_id=2,
+ engine_input_source=[1],
+ need_recv_cache=True,
+ from_tp=2,
+ to_tp=2,
+ )
+ with (
+ patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_local_tp_rank", return_value=0),
+ patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_tp_world_size", return_value=2),
+ ):
+ mgr = OmniKVTransferManager(config)
+ mgr.update_sender_info(
+ {
+ 0: {"host": "10.0.0.1", "zmq_port": 50151},
+ 1: {"host": "10.0.0.2", "zmq_port": 50152},
+ }
+ )
+ assert mgr._sender_base_host == "10.0.0.2"
+ assert mgr._sender_base_zmq_port == 50152
+
+
+# ── receive path constructs per-rank metadata ────────────────────────
+
+
+class TestReceiveConstructsMetadata:
+ """Verify that receive_kv_cache_for_request passes metadata with
+ correct (host, port) to connector.get() for heterogeneous TP."""
+
+ def test_tp1_no_metadata_passed(self):
+ """TP=1: connector.get() should be called WITHOUT metadata."""
+ mgr = _make_manager(from_tp=1, to_tp=1, local_rank=0, recv_timeout=0.05)
+ mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
+
+ calls = []
+
+ class _Connector:
+ def get(self, from_stage, to_stage, get_key, metadata=None):
+ calls.append({"key": get_key, "metadata": metadata})
+ return None
+
+ mgr._connector = _Connector()
+ mgr.receive_kv_cache_for_request("req-1")
+
+ assert len(calls) > 0
+ assert calls[0]["metadata"] is None
+
+ def test_homogeneous_tp2_rank0_passes_metadata(self):
+ """TP=2 rank 0: metadata should point to sender rank 0's port."""
+ mgr = _make_manager(from_tp=2, to_tp=2, local_rank=0, recv_timeout=0.05)
+ mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
+
+ calls = []
+
+ class _Connector:
+ def get(self, from_stage, to_stage, get_key, metadata=None):
+ calls.append({"key": get_key, "metadata": metadata})
+ return None
+
+ mgr._connector = _Connector()
+ mgr.receive_kv_cache_for_request("req-1")
+
+ assert len(calls) > 0
+ meta = calls[0]["metadata"]
+ assert meta is not None
+ assert meta["source_host"] == "10.0.0.1"
+ assert meta["source_port"] == 50151 + 0 * KV_RANK_PORT_STRIDE
+
+ def test_homogeneous_tp2_rank1_passes_metadata_with_offset(self):
+ mgr = _make_manager(from_tp=2, to_tp=2, local_rank=1, recv_timeout=0.05)
+ mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
+
+ calls = []
+
+ class _Connector:
+ def get(self, from_stage, to_stage, get_key, metadata=None):
+ calls.append({"key": get_key, "metadata": metadata})
+ return None
+
+ mgr._connector = _Connector()
+ mgr.receive_kv_cache_for_request("req-1")
+
+ meta = calls[0]["metadata"]
+ assert meta["source_port"] == 50151 + 1 * KV_RANK_PORT_STRIDE
+
+ def test_heterogeneous_tp4_to_tp2_rank0_multiple_metadata(self):
+ """Receiver rank 0 with source_tp=4, target_tp=2 should call get() with
+ two different metadata entries for sender ranks 0 and 1."""
+ mgr = _make_manager(from_tp=4, to_tp=2, local_rank=0, recv_timeout=0.05)
+ mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
+
+ calls = []
+
+ class _Connector:
+ def get(self, from_stage, to_stage, get_key, metadata=None):
+ calls.append({"key": get_key, "metadata": metadata})
+ return None
+
+ mgr._connector = _Connector()
+ mgr.receive_kv_cache_for_request("req-1")
+
+ seen_ports = set()
+ for c in calls:
+ if c["metadata"]:
+ seen_ports.add(c["metadata"]["source_port"])
+ expected_ports = {
+ 50151 + 0 * KV_RANK_PORT_STRIDE,
+ 50151 + 1 * KV_RANK_PORT_STRIDE,
+ }
+ assert expected_ports.issubset(seen_ports)
+
+
+# ── Mooncake connector _query_metadata_at ────────────────────────────
+
+
+class TestMooncakeQueryMetadataAt:
+ """Test the connector's _query_metadata_at method and partial-metadata
+ path in get() without requiring real RDMA/Mooncake."""
+
+ def test_query_metadata_at_returns_full_metadata(self):
+ """Mock the ZMQ interaction to verify _query_metadata_at returns
+ complete metadata including data_size."""
+
+ try:
+ from vllm_omni.distributed.omni_connectors.connectors.mooncake_transfer_engine_connector import (
+ MooncakeTransferEngineConnector,
+ QueryResponse,
+ )
+ except ImportError:
+ pytest.skip("Mooncake not available")
+
+ import msgspec
+
+ connector = MagicMock(spec=MooncakeTransferEngineConnector)
+ connector._get_req_socket = MagicMock()
+
+ mock_socket = MagicMock()
+ resp = QueryResponse(request_id="test_key@s0_s1", data_size=4096, is_fast_path=True)
+ mock_socket.recv.return_value = msgspec.msgpack.encode(resp)
+ connector._get_req_socket.return_value = mock_socket
+
+ result = MooncakeTransferEngineConnector._query_metadata_at(
+ connector,
+ "test_key@s0_s1",
+ "10.0.0.1",
+ 50151,
+ )
+
+ assert result is not None
+ assert result["source_host"] == "10.0.0.1"
+ assert result["source_port"] == 50151
+ assert result["data_size"] == 4096
+ assert result["is_fast_path"] is True
+
+ def test_query_metadata_at_returns_none_on_not_found(self):
+ try:
+ from vllm_omni.distributed.omni_connectors.connectors.mooncake_transfer_engine_connector import (
+ INFO_NOT_FOUND,
+ MooncakeTransferEngineConnector,
+ )
+ except ImportError:
+ pytest.skip("Mooncake not available")
+
+ connector = MagicMock(spec=MooncakeTransferEngineConnector)
+ mock_socket = MagicMock()
+ mock_socket.recv.return_value = INFO_NOT_FOUND
+ connector._get_req_socket.return_value = mock_socket
+
+ result = MooncakeTransferEngineConnector._query_metadata_at(
+ connector,
+ "test_key@s0_s1",
+ "10.0.0.1",
+ 50151,
+ )
+ assert result is None
+
+
+# ── Merge / slice hooks ──────────────────────────────────────────────
+
+
+class TestMergeSliceHooks:
+ def test_single_shard_passes_through(self):
+ payload = {"layer_blocks": {"key_cache": [1]}}
+ assert merge_received_rank_shards([payload]) == payload
+
+ def test_default_merger_concats_head_dim(self):
+ p0 = _make_payload([0.0])
+ p1 = _make_payload([1.0])
+ result = merge_received_rank_shards([p0, p1])
+ key_cache = result["layer_blocks"]["key_cache"][0]
+ value_cache = result["layer_blocks"]["value_cache"][0]
+ assert key_cache.shape == (2, 2, 1)
+ assert value_cache.shape == (2, 2, 1)
+ assert torch.equal(key_cache[:, :, 0], torch.tensor([[0.0, 1.0], [0.0, 1.0]]))
+ assert torch.equal(value_cache[:, :, 0], torch.tensor([[100.0, 101.0], [100.0, 101.0]]))
+
+ def test_custom_merger_hook_called(self):
+ merged = {"merged": True}
+ assert merge_received_rank_shards([{}, {}], merger=lambda payloads: merged) == merged
+
+ def test_slicer_hook_called(self):
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=0)
+ sliced = {"sliced": True}
+ assert slice_received_rank_shard({"full": True}, topo, slicer=lambda payload: sliced) == sliced
+
+ def test_default_slicer_extracts_rank_local_heads(self):
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=1)
+ payload = _make_payload([0.0, 1.0])
+ result = slice_received_rank_shard(payload, topo)
+ key_cache = result["layer_blocks"]["key_cache"][0]
+ value_cache = result["layer_blocks"]["value_cache"][0]
+ assert key_cache.shape == (2, 1, 1)
+ assert value_cache.shape == (2, 1, 1)
+ assert torch.equal(key_cache[:, :, 0], torch.tensor([[1.0], [1.0]]))
+ assert torch.equal(value_cache[:, :, 0], torch.tensor([[101.0], [101.0]]))
+
+ def test_presliced_payload_is_not_sliced_twice(self):
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=1)
+ payload = _make_payload([1.0])
+ payload["metadata"]["tp_head_slice"] = {"applied": True, "target_rank": 1}
+ result = slice_received_rank_shard(payload, topo)
+ assert result is payload
+
+ def test_round_trip_merge_from_tp4_to_tp2(self):
+ topo = KVTPTopology(source_tp_size=4, target_tp_size=2, local_rank=1)
+ source_ranks = get_kv_source_ranks(topo)
+ payloads = [_make_payload([float(rank)]) for rank in source_ranks]
+ result = merge_received_rank_shards(payloads)
+ key_cache = result["layer_blocks"]["key_cache"][0]
+ assert torch.equal(key_cache[:, :, 0], torch.tensor([[2.0, 3.0], [2.0, 3.0]]))
+
+ def test_round_trip_slice_from_tp2_to_tp4(self):
+ topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=3)
+ payload = _make_payload([2.0, 3.0])
+ result = slice_received_rank_shard(payload, topo)
+ key_cache = result["layer_blocks"]["key_cache"][0]
+ assert torch.equal(key_cache[:, :, 0], torch.tensor([[3.0], [3.0]]))
+
+
+class TestSenderSideSlicing:
+ def test_transfer_slices_before_sending_to_multiple_targets(self):
+ mgr = _make_manager(
+ from_tp=2,
+ to_tp=4,
+ local_rank=0,
+ need_send=True,
+ need_recv=False,
+ )
+ sent_payloads = []
+
+ class _Connector:
+ supports_raw_data = False
+
+ def put(self, from_stage, to_stage, put_key, data):
+ sent_payloads.append((put_key, KVCacheTransferData.from_bytes(data)))
+ return True, len(data), {}
+
+ mgr._connector = _Connector()
+ mgr._transfer_kv_cache(_make_transfer_data([0.0, 1.0]), "req-1")
+
+ assert [key for key, _ in sent_payloads] == ["req-1_stage0_0_0_0", "req-1_stage0_0_0_1"]
+ assert sent_payloads[0][1]["layer_blocks"]["key_cache"][0].shape == (2, 1, 1)
+ assert sent_payloads[1][1]["layer_blocks"]["key_cache"][0].shape == (2, 1, 1)
+ assert torch.equal(
+ sent_payloads[0][1]["layer_blocks"]["key_cache"][0][:, :, 0],
+ torch.tensor([[0.0], [0.0]]),
+ )
+ assert torch.equal(
+ sent_payloads[1][1]["layer_blocks"]["key_cache"][0][:, :, 0],
+ torch.tensor([[1.0], [1.0]]),
+ )
+ assert sent_payloads[0][1]["metadata"]["tp_head_slice"]["target_rank"] == 0
+ assert sent_payloads[1][1]["metadata"]["tp_head_slice"]["target_rank"] == 1
+
+
+class _MockBroadcastGroup:
+ def __init__(self, world_size: int, rank_in_group: int, broadcast_value=None, recv_value=None):
+ self.world_size = world_size
+ self.rank_in_group = rank_in_group
+ self.broadcast_value = broadcast_value
+ self.recv_value = recv_value
+ self.broadcast_calls = []
+ self.send_calls = []
+ self.recv_calls = []
+ self.shm_broadcaster = None
+
+ def broadcast_object(self, obj=None, src: int = 0):
+ self.broadcast_calls.append((obj, src))
+ return self.broadcast_value if self.broadcast_value is not None else obj
+
+ def send_object(self, obj, dst: int):
+ self.send_calls.append((dst, obj))
+
+ def recv_object(self, src: int):
+ self.recv_calls.append(src)
+ return self.recv_value
+
+
+class TestDistributedReceive:
+ def test_tp_cfg_leader_receives_then_sends_branch_local_payloads(self):
+ mgr = _make_manager(from_tp=2, to_tp=4, local_rank=0)
+ req = SimpleNamespace(request_id="req-1", sampling_params=SimpleNamespace())
+ world_group = _MockBroadcastGroup(world_size=4, rank_in_group=2)
+ cfg_group = _MockBroadcastGroup(world_size=3, rank_in_group=0)
+
+ def _receive(req_obj, cfg_func, target_device):
+ req_obj.past_key_values = SimpleNamespace(key_cache=[torch.tensor([1.0])])
+ req_obj.kv_metadata = {"source": "leader"}
+ req_obj.sampling_params.past_key_values = req_obj.past_key_values
+ req_obj.sampling_params.kv_metadata = req_obj.kv_metadata
+ req_obj.sampling_params.cfg_text_past_key_values = SimpleNamespace(key_cache=[torch.tensor([2.0])])
+ req_obj.sampling_params.cfg_text_kv_metadata = {"source": "cfg_text"}
+ req_obj.sampling_params.cfg_img_past_key_values = SimpleNamespace(key_cache=[torch.tensor([3.0])])
+ req_obj.sampling_params.cfg_img_kv_metadata = {"source": "cfg_img"}
+ return True
+
+ mgr.receive_multi_kv_cache = MagicMock(side_effect=_receive)
+ with (
+ patch("vllm_omni.diffusion.distributed.parallel_state.get_world_group", return_value=world_group),
+ patch(
+ "vllm_omni.diffusion.distributed.parallel_state.get_classifier_free_guidance_world_size",
+ return_value=3,
+ ),
+ patch(
+ "vllm_omni.diffusion.distributed.parallel_state.get_classifier_free_guidance_rank",
+ return_value=0,
+ ),
+ patch("vllm_omni.diffusion.distributed.parallel_state.get_cfg_group", return_value=cfg_group),
+ ):
+ assert mgr.receive_multi_kv_cache_distributed(req) is True
+
+ mgr.receive_multi_kv_cache.assert_called_once()
+ assert mgr.receive_multi_kv_cache.call_args.args[2] == torch.device("cpu")
+ assert req.kv_metadata == {"source": "leader"}
+ assert cfg_group.broadcast_calls == []
+ assert [dst for dst, _ in cfg_group.send_calls] == [1, 2]
+ rank1_payload = cfg_group.send_calls[0][1]
+ rank2_payload = cfg_group.send_calls[1][1]
+ assert torch.equal(rank1_payload["past_key_values"].key_cache[0], torch.tensor([1.0]))
+ assert torch.equal(rank2_payload["past_key_values"].key_cache[0], torch.tensor([1.0]))
+ assert rank1_payload["sp.cfg_active_branch"] == "cfg_text"
+ assert rank2_payload["sp.cfg_active_branch"] == "cfg_img"
+ assert rank1_payload["sp.cfg_branch_roles"] == ["cfg_text", "cfg_img"]
+ assert rank2_payload["sp.cfg_branch_roles"] == ["cfg_text", "cfg_img"]
+ assert "sp.cfg_branch_past_key_values" in rank1_payload
+ assert "sp.cfg_branch_past_key_values" in rank2_payload
+ assert list(rank1_payload["sp.cfg_branch_past_key_values"].keys()) == ["cfg_text"]
+ assert list(rank2_payload["sp.cfg_branch_past_key_values"].keys()) == ["cfg_img"]
+ assert "sp.cfg_text_past_key_values" in rank1_payload
+ assert "sp.cfg_img_past_key_values" not in rank1_payload
+ assert "sp.cfg_img_past_key_values" in rank2_payload
+ assert "sp.cfg_text_past_key_values" not in rank2_payload
+
+ def test_tp_cfg_follower_receives_local_payload_without_receiving(self):
+ mgr = _make_manager(from_tp=2, to_tp=4, local_rank=1)
+ req = SimpleNamespace(request_id="req-1", sampling_params=SimpleNamespace())
+ world_group = _MockBroadcastGroup(world_size=4, rank_in_group=3)
+ cfg_payload = {
+ "past_key_values": SimpleNamespace(key_cache=[torch.tensor([1.0])]),
+ "kv_metadata": {"source": "main"},
+ "sp.past_key_values": SimpleNamespace(key_cache=[torch.tensor([1.0])]),
+ "sp.kv_metadata": {"source": "main"},
+ "sp.cfg_active_branch": "cfg_text",
+ "sp.cfg_branch_roles": ["cfg_text", "cfg_img"],
+ "sp.cfg_branch_past_key_values": {
+ "cfg_text": SimpleNamespace(key_cache=[torch.tensor([2.0])]),
+ },
+ "sp.cfg_branch_kv_metadata": {"cfg_text": {"source": "cfg-text"}},
+ "sp.cfg_text_past_key_values": SimpleNamespace(key_cache=[torch.tensor([2.0])]),
+ }
+ cfg_group = _MockBroadcastGroup(world_size=2, rank_in_group=1, recv_value=cfg_payload)
+
+ mgr.receive_multi_kv_cache = MagicMock(return_value=True)
+ with (
+ patch("vllm_omni.diffusion.distributed.parallel_state.get_world_group", return_value=world_group),
+ patch(
+ "vllm_omni.diffusion.distributed.parallel_state.get_classifier_free_guidance_world_size",
+ return_value=2,
+ ),
+ patch(
+ "vllm_omni.diffusion.distributed.parallel_state.get_classifier_free_guidance_rank",
+ return_value=1,
+ ),
+ patch("vllm_omni.diffusion.distributed.parallel_state.get_cfg_group", return_value=cfg_group),
+ ):
+ assert mgr.receive_multi_kv_cache_distributed(req) is True
+
+ mgr.receive_multi_kv_cache.assert_not_called()
+ assert req.kv_metadata == {"source": "main"}
+ assert torch.equal(req.past_key_values.key_cache[0], torch.tensor([1.0]))
+ assert torch.equal(req.sampling_params.past_key_values.key_cache[0], torch.tensor([1.0]))
+ assert req.sampling_params.cfg_active_branch == "cfg_text"
+ assert req.sampling_params.cfg_branch_roles == ["cfg_text", "cfg_img"]
+ assert torch.equal(
+ req.sampling_params.cfg_branch_past_key_values["cfg_text"].key_cache[0],
+ torch.tensor([2.0]),
+ )
+ assert req.sampling_params.cfg_branch_kv_metadata == {"cfg_text": {"source": "cfg-text"}}
+ assert torch.equal(req.sampling_params.cfg_text_past_key_values.key_cache[0], torch.tensor([2.0]))
+ assert cfg_group.broadcast_calls == []
+ assert cfg_group.recv_calls == [0]
+
+ def test_tp_without_cfg_keeps_independent_receive_path(self):
+ mgr = _make_manager(from_tp=2, to_tp=2, local_rank=1)
+ req = SimpleNamespace(request_id="req-1", sampling_params=SimpleNamespace())
+ world_group = _MockBroadcastGroup(world_size=2, rank_in_group=1)
+ mgr.receive_multi_kv_cache = MagicMock(return_value=True)
+
+ with patch("vllm_omni.diffusion.distributed.parallel_state.get_world_group", return_value=world_group):
+ assert mgr.receive_multi_kv_cache_distributed(req, target_device=torch.device("cpu")) is True
+
+ mgr.receive_multi_kv_cache.assert_called_once_with(req, None, torch.device("cpu"))
+
+
+# ── TP auto-detect ───────────────────────────────────────────────────
+
+
+class TestAutoDetectTP:
+ def test_auto_detect_when_config_defaults(self):
+ """When config from_tp/to_tp == 1 (default), manager should auto-detect."""
+ config = OmniKVCacheConfig(
+ connector_config={"type": "mock"},
+ from_stage="s0",
+ stage_id="s1",
+ need_recv_cache=True,
+ )
+ with (
+ patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_local_tp_rank", return_value=0),
+ patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_tp_world_size", return_value=4),
+ ):
+ mgr = OmniKVTransferManager(config)
+ assert mgr._tp_topo.source_tp_size == 4
+ assert mgr._tp_topo.target_tp_size == 4
+
+ def test_explicit_tp_overrides_auto_detect(self):
+ config = OmniKVCacheConfig(
+ connector_config={"type": "mock"},
+ from_stage="s0",
+ stage_id="s1",
+ need_recv_cache=True,
+ from_tp=2,
+ to_tp=4,
+ )
+ with (
+ patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_local_tp_rank", return_value=0),
+ patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_tp_world_size", return_value=8),
+ ):
+ mgr = OmniKVTransferManager(config)
+ assert mgr._tp_topo.source_tp_size == 2
+ assert mgr._tp_topo.target_tp_size == 4
diff --git a/tests/distributed/omni_coordinator/test_load_balancer.py b/tests/distributed/omni_coordinator/test_load_balancer.py
index b2d1f3ee8..8350b33d3 100644
--- a/tests/distributed/omni_coordinator/test_load_balancer.py
+++ b/tests/distributed/omni_coordinator/test_load_balancer.py
@@ -7,7 +7,9 @@ import pytest
from vllm_omni.distributed.omni_coordinator import (
InstanceInfo,
+ LeastQueueLengthBalancer,
RandomBalancer,
+ RoundRobinBalancer,
StageStatus,
)
@@ -60,3 +62,173 @@ def test_load_balancer_select_returns_valid_index():
assert isinstance(index, int)
assert 0 <= index < len(instances)
+
+
+def test_round_robin_balancer_cycles_instances():
+ now = time()
+ instances = [
+ InstanceInfo(
+ input_addr="tcp://host:10001",
+ output_addr="tcp://host:10001-out",
+ stage_id=0,
+ status=StageStatus.UP,
+ queue_length=2,
+ last_heartbeat=now,
+ registered_at=now,
+ ),
+ InstanceInfo(
+ input_addr="tcp://host:10002",
+ output_addr="tcp://host:10002-out",
+ stage_id=0,
+ status=StageStatus.UP,
+ queue_length=1,
+ last_heartbeat=now,
+ registered_at=now,
+ ),
+ InstanceInfo(
+ input_addr="tcp://host:10003",
+ output_addr="tcp://host:10003-out",
+ stage_id=1,
+ status=StageStatus.UP,
+ queue_length=0,
+ last_heartbeat=now,
+ registered_at=now,
+ ),
+ ]
+
+ balancer = RoundRobinBalancer()
+ results = [balancer.select({}, instances) for _ in range(5)]
+
+ # Default start_index=0 => 0,1,2,0,1
+ assert results == [0, 1, 2, 0, 1]
+
+
+def test_round_robin_balancer_empty_instances_raises():
+ with pytest.raises(ValueError, match="instances must not be empty"):
+ RoundRobinBalancer().select({}, [])
+
+
+def test_round_robin_balancer_after_large_index_and_shorter_list():
+ """Large start_index % len(instances) then counter wraps with shorter list."""
+ now = time()
+ two = [
+ InstanceInfo(
+ input_addr="tcp://host:10001",
+ output_addr="tcp://host:10001-out",
+ stage_id=0,
+ status=StageStatus.UP,
+ queue_length=0,
+ last_heartbeat=now,
+ registered_at=now,
+ ),
+ InstanceInfo(
+ input_addr="tcp://host:10002",
+ output_addr="tcp://host:10002-out",
+ stage_id=0,
+ status=StageStatus.UP,
+ queue_length=0,
+ last_heartbeat=now,
+ registered_at=now,
+ ),
+ ]
+ balancer = RoundRobinBalancer(start_index=7)
+ assert balancer.select({}, two) == 1 # 7 % 2
+ assert balancer.select({}, two) == 0 # next index wrapped to 0
+
+
+def test_least_queue_length_balancer_picks_min_queue():
+ now = time()
+ instances = [
+ InstanceInfo(
+ input_addr="tcp://host:10001",
+ output_addr="tcp://host:10001-out",
+ stage_id=0,
+ status=StageStatus.UP,
+ queue_length=2,
+ last_heartbeat=now,
+ registered_at=now,
+ ),
+ InstanceInfo(
+ input_addr="tcp://host:10002",
+ output_addr="tcp://host:10002-out",
+ stage_id=0,
+ status=StageStatus.UP,
+ queue_length=0,
+ last_heartbeat=now,
+ registered_at=now,
+ ),
+ InstanceInfo(
+ input_addr="tcp://host:10003",
+ output_addr="tcp://host:10003-out",
+ stage_id=1,
+ status=StageStatus.UP,
+ queue_length=5,
+ last_heartbeat=now,
+ registered_at=now,
+ ),
+ ]
+
+ balancer = LeastQueueLengthBalancer()
+ index = balancer.select({}, instances)
+ assert index == 1
+
+
+def test_least_queue_length_balancer_empty_instances_raises():
+ with pytest.raises(ValueError, match="instances must not be empty"):
+ LeastQueueLengthBalancer().select({}, [])
+
+
+def test_least_queue_length_balancer_equal_queues_uses_choice(mocker):
+ now = time()
+ instances = [
+ InstanceInfo(
+ input_addr="tcp://host:10001",
+ output_addr="tcp://host:10001-out",
+ stage_id=0,
+ status=StageStatus.UP,
+ queue_length=3,
+ last_heartbeat=now,
+ registered_at=now,
+ ),
+ InstanceInfo(
+ input_addr="tcp://host:10002",
+ output_addr="tcp://host:10002-out",
+ stage_id=0,
+ status=StageStatus.UP,
+ queue_length=3,
+ last_heartbeat=now,
+ registered_at=now,
+ ),
+ InstanceInfo(
+ input_addr="tcp://host:10003",
+ output_addr="tcp://host:10003-out",
+ stage_id=1,
+ status=StageStatus.UP,
+ queue_length=3,
+ last_heartbeat=now,
+ registered_at=now,
+ ),
+ ]
+ balancer = LeastQueueLengthBalancer()
+ mocker.patch(
+ "vllm_omni.distributed.omni_coordinator.load_balancer.random.choice",
+ return_value=2,
+ )
+ assert balancer.select({}, instances) == 2
+
+
+def test_least_queue_length_balancer_negative_queue_raises():
+ now = time()
+ instances = [
+ InstanceInfo(
+ input_addr="tcp://host:10001",
+ output_addr="tcp://host:10001-out",
+ stage_id=0,
+ status=StageStatus.UP,
+ queue_length=-1,
+ last_heartbeat=now,
+ registered_at=now,
+ ),
+ ]
+ with pytest.raises(ValueError, match="queue_length must be non-negative"):
+ LeastQueueLengthBalancer().select({}, instances)
diff --git a/tests/e2e/accuracy/conftest.py b/tests/e2e/accuracy/conftest.py
index 062750b3c..709fdf345 100644
--- a/tests/e2e/accuracy/conftest.py
+++ b/tests/e2e/accuracy/conftest.py
@@ -1,16 +1,18 @@
from __future__ import annotations
import os
-import shutil
import subprocess
from contextlib import contextmanager
from dataclasses import dataclass
+from io import BytesIO
from pathlib import Path
import pytest
+import requests
import torch
+from PIL import Image
-from tests.conftest import OmniServer, OmniServerParams
+from tests.helpers.runtime import OmniServer, OmniServerParams
def pytest_addoption(parser):
@@ -183,16 +185,26 @@ def accuracy_artifact_root() -> Path:
return root
-def reset_artifact_dir(path: Path) -> Path:
- if path.exists():
- shutil.rmtree(path)
- path.mkdir(parents=True, exist_ok=True)
- return path
+@pytest.fixture(scope="session")
+def qwen_bear_image(accuracy_artifact_root: Path) -> Image.Image:
+ """Download the Qwen bear image from the URL and save it to the accuracy artifact root."""
+ QWEN_BEAR_IMAGE_URL = "https://vllm-public-assets.s3.us-west-2.amazonaws.com/omni-assets/qwen-bear.png"
+ response = requests.get(QWEN_BEAR_IMAGE_URL, timeout=60)
+ response.raise_for_status()
+ image = Image.open(BytesIO(response.content)).convert("RGB")
+ image.save(accuracy_artifact_root / "qwen_bear.png")
+ return image
-def infer_model_label(model: str) -> str:
- label = Path(model.rstrip("/\\")).name or "model"
- return "".join(char if char.isalnum() or char in {"-", "_"} else "_" for char in label)
+@pytest.fixture(scope="session")
+def rabbit_image(accuracy_artifact_root: Path) -> Image.Image:
+ """Download the rabbit image from the URL and save it to the accuracy artifact root."""
+ RABBIT_IMAGE_URL = "https://vllm-public-assets.s3.us-west-2.amazonaws.com/omni-assets/rabbit.png"
+ response = requests.get(RABBIT_IMAGE_URL, timeout=60)
+ response.raise_for_status()
+ image = Image.open(BytesIO(response.content)).convert("RGB")
+ image.save(accuracy_artifact_root / "rabbit.png")
+ return image
def _build_accuracy_server_config(
diff --git a/tests/e2e/accuracy/helpers.py b/tests/e2e/accuracy/helpers.py
new file mode 100644
index 000000000..382d3ea9b
--- /dev/null
+++ b/tests/e2e/accuracy/helpers.py
@@ -0,0 +1,115 @@
+from pathlib import Path
+
+import numpy as np
+import pytest
+import torch
+from PIL import Image
+from torchmetrics.image import PeakSignalNoiseRatio, StructuralSimilarityIndexMeasure
+
+
+def reset_artifact_dir(path: Path) -> Path:
+ import shutil
+
+ if path.exists():
+ shutil.rmtree(path)
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def infer_model_label(model: str) -> str:
+ label = Path(model.rstrip("/\\")).name or "model"
+ return "".join(char if char.isalnum() or char in {"-", "_"} else "_" for char in label)
+
+
+def model_output_dir(parent_dir: Path, model: str) -> Path:
+ safe_model_name = model.split("/")[-1].replace(".", "_")
+ path = parent_dir / safe_model_name
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def assert_similarity(
+ *,
+ model_name: str,
+ vllm_image: Image.Image,
+ diffusers_image: Image.Image,
+ ssim_threshold: float,
+ psnr_threshold: float,
+ width: int | None = None,
+ height: int | None = None,
+ compare_mode: str = "RGB",
+) -> None:
+ requested_size = (width, height) if width is not None and height is not None else None
+ if requested_size is not None and diffusers_image.size != requested_size:
+ pytest.skip(
+ "Skipping as diffusers baseline output is corrupt and not comparable: "
+ f"dimensions do not match requested size; requested={requested_size}, got={diffusers_image.size}."
+ )
+
+ assert vllm_image.size == diffusers_image.size, (
+ f"Online and diffusers output sizes mismatch: online={vllm_image.size}, diffusers={diffusers_image.size}"
+ )
+
+ ssim_score, psnr_score = compute_image_ssim_psnr(
+ prediction=vllm_image,
+ reference=diffusers_image,
+ compare_mode=compare_mode,
+ )
+ print(f"{model_name} similarity metrics:")
+ print(f" SSIM: value={ssim_score:.6f}, threshold>={ssim_threshold:.6f}, range=[-1, 1], higher_is_better=True")
+ print(
+ f" PSNR: value={psnr_score:.6f} dB, threshold>={psnr_threshold:.6f} dB, range=[0, +inf), higher_is_better=True"
+ )
+
+ assert ssim_score >= ssim_threshold, (
+ f"SSIM below threshold for {model_name}: got {ssim_score:.6f}, expected >= {ssim_threshold:.6f}."
+ )
+ assert psnr_score >= psnr_threshold, (
+ f"PSNR below threshold for {model_name}: got {psnr_score:.6f}, expected >= {psnr_threshold:.6f}."
+ )
+
+
+def assert_image_sequence_similarity(
+ *,
+ model_name: str,
+ vllm_images: list[Image.Image],
+ diffusers_images: list[Image.Image],
+ ssim_threshold: float,
+ psnr_threshold: float,
+ compare_mode: str = "RGB",
+) -> None:
+ assert len(vllm_images) == len(diffusers_images), (
+ f"Output image count mismatch for {model_name}: online={len(vllm_images)}, diffusers={len(diffusers_images)}"
+ )
+ for index, (vllm_image, diffusers_image) in enumerate(zip(vllm_images, diffusers_images, strict=True), start=1):
+ assert_similarity(
+ model_name=f"{model_name}[layer={index}]",
+ vllm_image=vllm_image,
+ diffusers_image=diffusers_image,
+ ssim_threshold=ssim_threshold,
+ psnr_threshold=psnr_threshold,
+ compare_mode=compare_mode,
+ )
+
+
+def compute_image_ssim_psnr(
+ *,
+ prediction: Image.Image,
+ reference: Image.Image,
+ compare_mode: str = "RGB",
+) -> tuple[float, float]:
+ pred_tensor = _pil_to_batched_tensor(prediction, compare_mode=compare_mode)
+ ref_tensor = _pil_to_batched_tensor(reference, compare_mode=compare_mode)
+
+ ssim_metric = StructuralSimilarityIndexMeasure(data_range=1.0)
+ psnr_metric = PeakSignalNoiseRatio(data_range=1.0)
+
+ ssim_value = float(ssim_metric(pred_tensor, ref_tensor).item())
+ psnr_value = float(psnr_metric(pred_tensor, ref_tensor).item())
+ return ssim_value, psnr_value
+
+
+def _pil_to_batched_tensor(image: Image.Image, *, compare_mode: str) -> torch.Tensor:
+ array = np.asarray(image.convert(compare_mode), dtype=np.float32) / 255.0
+ tensor = torch.from_numpy(array).permute(2, 0, 1).unsqueeze(0)
+ return tensor
diff --git a/tests/e2e/accuracy/test_gebench_h100_smoke.py b/tests/e2e/accuracy/test_gebench_h100_smoke.py
index b4b831871..748919269 100644
--- a/tests/e2e/accuracy/test_gebench_h100_smoke.py
+++ b/tests/e2e/accuracy/test_gebench_h100_smoke.py
@@ -6,8 +6,8 @@ from pathlib import Path
import pytest
from benchmarks.accuracy.text_to_image.gbench import main as gbench_main
-from tests.e2e.accuracy.conftest import infer_model_label, reset_artifact_dir
-from tests.utils import hardware_test
+from tests.e2e.accuracy.helpers import infer_model_label, reset_artifact_dir
+from tests.helpers.mark import hardware_test
@pytest.mark.advanced_model
diff --git a/tests/e2e/accuracy/test_gedit_bench_h100_smoke.py b/tests/e2e/accuracy/test_gedit_bench_h100_smoke.py
index 960ea5796..6227d6368 100644
--- a/tests/e2e/accuracy/test_gedit_bench_h100_smoke.py
+++ b/tests/e2e/accuracy/test_gedit_bench_h100_smoke.py
@@ -7,8 +7,8 @@ import pytest
from benchmarks.accuracy.image_to_image.gedit_bench import GROUPS
from benchmarks.accuracy.image_to_image.gedit_bench import main as gedit_main
-from tests.e2e.accuracy.conftest import infer_model_label, reset_artifact_dir
-from tests.utils import hardware_test
+from tests.e2e.accuracy.helpers import infer_model_label, reset_artifact_dir
+from tests.helpers.mark import hardware_test
@pytest.mark.advanced_model
diff --git a/tests/e2e/accuracy/test_qwen_image.py b/tests/e2e/accuracy/test_qwen_image.py
new file mode 100644
index 000000000..8922d9d10
--- /dev/null
+++ b/tests/e2e/accuracy/test_qwen_image.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+import base64
+import gc
+import io
+import os
+from pathlib import Path
+
+import pytest
+import requests
+import torch
+from diffusers.pipelines.pipeline_utils import DiffusionPipeline
+from PIL import Image
+
+from tests.e2e.accuracy.helpers import assert_similarity, model_output_dir
+from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServer
+
+MODEL_ID = "Qwen/Qwen-Image"
+MODEL_ENV_VAR = "QWEN_IMAGE_MODEL"
+PROMPT = "A photo of a cat sitting on a laptop keyboard, digital art style."
+NEGATIVE_PROMPT = "blurry, low quality"
+WIDTH = 512
+HEIGHT = 512
+NUM_INFERENCE_STEPS = 20
+TRUE_CFG_SCALE = 4.0
+SEED = 42
+SSIM_THRESHOLD = 0.97
+PSNR_THRESHOLD = 30.0
+
+
+def _model_name() -> str:
+ return os.environ.get(MODEL_ENV_VAR, MODEL_ID)
+
+
+def _local_files_only(model: str) -> bool:
+ return Path(model).exists()
+
+
+def _run_vllm_omni_qwen_image(*, model: str, output_path: Path) -> Image.Image:
+ server_args = ["--num-gpus", "1", "--stage-init-timeout", "300", "--init-timeout", "900"]
+ with OmniServer(model, server_args, use_omni=True) as omni_server:
+ response = requests.post(
+ f"http://{omni_server.host}:{omni_server.port}/v1/images/generations",
+ json={
+ "model": omni_server.model,
+ "prompt": PROMPT,
+ "size": f"{WIDTH}x{HEIGHT}",
+ "n": 1,
+ "response_format": "b64_json",
+ "negative_prompt": NEGATIVE_PROMPT,
+ "num_inference_steps": NUM_INFERENCE_STEPS,
+ "true_cfg_scale": TRUE_CFG_SCALE,
+ "seed": SEED,
+ },
+ timeout=600,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ assert len(payload["data"]) == 1
+ image_bytes = base64.b64decode(payload["data"][0]["b64_json"])
+ image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
+ image.load()
+ image.save(output_path)
+ return image
+
+
+def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> Image.Image:
+ run_pre_test_cleanup(enable_force=True)
+ pipe: DiffusionPipeline | None = None
+ try:
+ pipe = DiffusionPipeline.from_pretrained(
+ model,
+ torch_dtype=torch.bfloat16,
+ trust_remote_code=True,
+ local_files_only=_local_files_only(model),
+ ).to("cuda")
+ generator = torch.Generator(device="cuda").manual_seed(SEED)
+ result = pipe( # pyright: ignore[reportCallIssue]
+ prompt=PROMPT,
+ negative_prompt=NEGATIVE_PROMPT,
+ width=WIDTH,
+ height=HEIGHT,
+ num_inference_steps=NUM_INFERENCE_STEPS,
+ true_cfg_scale=TRUE_CFG_SCALE,
+ generator=generator,
+ )
+ output_image = result.images[0].convert("RGB")
+ output_image.save(output_path)
+ return output_image
+ finally:
+ if pipe is not None and hasattr(pipe, "maybe_free_model_hooks"):
+ pipe.maybe_free_model_hooks()
+ del pipe
+ gc.collect()
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ run_post_test_cleanup(enable_force=True)
+
+
+@pytest.mark.advanced_model
+@pytest.mark.benchmark
+@pytest.mark.diffusion
+@hardware_test(res={"cuda": "H100"}, num_cards=1)
+def test_qwen_image_matches_diffusers(accuracy_artifact_root: Path) -> None:
+ model = _model_name()
+ output_dir = model_output_dir(accuracy_artifact_root, MODEL_ID)
+
+ vllm_output = _run_vllm_omni_qwen_image(model=model, output_path=output_dir / "vllm_omni.png")
+ diffusers_output = _run_diffusers_qwen_image(model=model, output_path=output_dir / "diffusers.png")
+
+ assert_similarity(
+ model_name=MODEL_ID,
+ vllm_image=vllm_output,
+ diffusers_image=diffusers_output,
+ width=WIDTH,
+ height=HEIGHT,
+ ssim_threshold=SSIM_THRESHOLD,
+ psnr_threshold=PSNR_THRESHOLD,
+ )
diff --git a/tests/e2e/accuracy/test_qwen_image_edit.py b/tests/e2e/accuracy/test_qwen_image_edit.py
new file mode 100644
index 000000000..e17aca6e9
--- /dev/null
+++ b/tests/e2e/accuracy/test_qwen_image_edit.py
@@ -0,0 +1,229 @@
+from __future__ import annotations
+
+import gc
+from pathlib import Path
+
+import pytest
+import requests
+import torch
+from diffusers import QwenImageEditPipeline, QwenImageEditPlusPipeline
+from PIL import Image
+
+from benchmarks.accuracy.common import decode_base64_image, pil_to_png_bytes
+from tests.e2e.accuracy.helpers import assert_similarity, model_output_dir
+from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServer
+
+SINGLE_MODEL = "Qwen/Qwen-Image-Edit"
+MULTIPLE_MODEL = "Qwen/Qwen-Image-Edit-2509"
+WIDTH = 512
+HEIGHT = 512
+NUM_INFERENCE_STEPS = 20
+TRUE_CFG_SCALE = 4.0
+SEED = 42
+SSIM_THRESHOLD = 0.94
+PSNR_THRESHOLD = 28.0
+
+PROMPT_SINGLE_IMAGE = "The input is a 2D cartoon bear mascot. Restyle it into a painterly oil artwork with warm colors while preserving the main structure."
+PROMPT_MULTIPLE_IMAGE = "Put the cartoon bear mascot and the furry rabbit into one coherent scene with a painterly oil artwork style and consistent lighting."
+NEGATIVE_PROMPT = "low quality, blurry, artifacts, distortion"
+SERVER_ARGS = ["--num-gpus", "1", "--stage-init-timeout", "300", "--init-timeout", "900"]
+
+
+def _run_vllm_omni_image_edit(
+ *,
+ omni_server: OmniServer,
+ prompt: str,
+ input_images: list[Image.Image],
+ output_path: Path,
+) -> Image.Image:
+ response = requests.post(
+ f"http://{omni_server.host}:{omni_server.port}/v1/images/edits",
+ data={
+ "model": omni_server.model,
+ "prompt": prompt,
+ "size": f"{WIDTH}x{HEIGHT}",
+ "n": 1,
+ "response_format": "b64_json",
+ "negative_prompt": NEGATIVE_PROMPT,
+ "num_inference_steps": NUM_INFERENCE_STEPS,
+ "true_cfg_scale": TRUE_CFG_SCALE,
+ "seed": SEED,
+ },
+ files=[
+ ("image", (f"image_{index}.png", pil_to_png_bytes(image), "image/png"))
+ for index, image in enumerate(input_images)
+ ],
+ timeout=600,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ assert len(payload["data"]) == 1
+ image = decode_base64_image(payload["data"][0]["b64_json"])
+ image.load()
+ image.save(output_path)
+ return image
+
+
+def _run_diffusers_image_edit(
+ *,
+ model: str,
+ pipeline_class: type[QwenImageEditPipeline] | type[QwenImageEditPlusPipeline],
+ prompt: str,
+ input_images: list[Image.Image],
+ output_path: Path,
+) -> Image.Image:
+ run_pre_test_cleanup(enable_force=True)
+ pipe: QwenImageEditPipeline | QwenImageEditPlusPipeline | None = None
+ device = torch.device("cuda:0")
+ torch.cuda.set_device(device)
+ try:
+ images = input_images[0] if len(input_images) == 1 else input_images
+ pipe = pipeline_class.from_pretrained(
+ model,
+ torch_dtype=torch.bfloat16,
+ trust_remote_code=True,
+ ).to(device)
+ pipe.set_progress_bar_config(disable=False)
+ generator = torch.Generator(device=device).manual_seed(SEED)
+ result = pipe( # pyright: ignore[reportCallIssue]
+ prompt=prompt,
+ image=images,
+ negative_prompt=NEGATIVE_PROMPT,
+ num_inference_steps=NUM_INFERENCE_STEPS,
+ true_cfg_scale=TRUE_CFG_SCALE,
+ width=WIDTH,
+ height=HEIGHT,
+ generator=generator,
+ )
+ output_image = result.images[0].convert("RGB") # pyright: ignore[reportAttributeAccessIssue]
+ output_image.save(output_path)
+ return output_image
+ finally:
+ if pipe is not None and hasattr(pipe, "maybe_free_model_hooks"):
+ pipe.maybe_free_model_hooks()
+ del pipe
+ gc.collect()
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ run_post_test_cleanup(enable_force=True)
+
+
+def _vllm_omni_output_single_image(
+ accuracy_artifact_root: Path,
+ qwen_bear_image: Image.Image,
+) -> Image.Image:
+ output_dir = model_output_dir(accuracy_artifact_root, SINGLE_MODEL)
+ output_path = output_dir / "vllm_omni_single.png"
+ with OmniServer(model=SINGLE_MODEL, serve_args=SERVER_ARGS) as server:
+ output = _run_vllm_omni_image_edit(
+ omni_server=server,
+ prompt=PROMPT_SINGLE_IMAGE,
+ input_images=[qwen_bear_image],
+ output_path=output_path,
+ )
+ return output
+
+
+def _diffusers_output_single_image(accuracy_artifact_root: Path, qwen_bear_image: Image.Image) -> Image.Image:
+ output_dir = model_output_dir(accuracy_artifact_root, SINGLE_MODEL)
+ output_path = output_dir / "diffusers_single.png"
+ return _run_diffusers_image_edit(
+ model=SINGLE_MODEL,
+ pipeline_class=QwenImageEditPipeline,
+ prompt=PROMPT_SINGLE_IMAGE,
+ input_images=[qwen_bear_image],
+ output_path=output_path,
+ )
+
+
+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.advanced_model
+@pytest.mark.benchmark
+@pytest.mark.diffusion
+@hardware_test(res={"cuda": "H100"}, num_cards=1)
+def test_qwen_image_edit_single_matches_diffusers(
+ accuracy_artifact_root: Path,
+ qwen_bear_image: Image.Image,
+) -> None:
+ vllm_image = _vllm_omni_output_single_image(
+ accuracy_artifact_root=accuracy_artifact_root,
+ qwen_bear_image=qwen_bear_image,
+ )
+ diffusers_image = _diffusers_output_single_image(
+ accuracy_artifact_root=accuracy_artifact_root,
+ qwen_bear_image=qwen_bear_image,
+ )
+ assert_similarity(
+ model_name=SINGLE_MODEL,
+ vllm_image=vllm_image,
+ diffusers_image=diffusers_image,
+ width=WIDTH,
+ height=HEIGHT,
+ ssim_threshold=SSIM_THRESHOLD,
+ psnr_threshold=PSNR_THRESHOLD,
+ )
+
+
+@pytest.mark.advanced_model
+@pytest.mark.benchmark
+@pytest.mark.diffusion
+@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,
+ )
diff --git a/tests/e2e/accuracy/test_qwen_image_layered.py b/tests/e2e/accuracy/test_qwen_image_layered.py
new file mode 100644
index 000000000..0ab9cb323
--- /dev/null
+++ b/tests/e2e/accuracy/test_qwen_image_layered.py
@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+import base64
+import gc
+import io
+import os
+from pathlib import Path
+
+import pytest
+import requests
+import torch
+from diffusers.pipelines.pipeline_utils import DiffusionPipeline
+from PIL import Image
+
+from tests.e2e.accuracy.helpers import assert_image_sequence_similarity, model_output_dir
+from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServer
+
+MODEL_ID = "Qwen/Qwen-Image-Layered"
+MODEL_ENV_VAR = "QWEN_IMAGE_LAYERED_MODEL"
+PROMPT = "decompose into layers"
+NEGATIVE_PROMPT = " "
+NUM_INFERENCE_STEPS = 20
+TRUE_CFG_SCALE = 4.0
+SEED = 777
+LAYERS = 3
+RESOLUTION = 640
+SSIM_THRESHOLD = 0.97
+PSNR_THRESHOLD = 30.0
+
+
+def _model_name() -> str:
+ return os.environ.get(MODEL_ENV_VAR, MODEL_ID)
+
+
+def _local_files_only(model: str) -> bool:
+ return Path(model).exists()
+
+
+def _normalize_layered_images(images: object) -> list[Image.Image]:
+ if not isinstance(images, list) or not images:
+ raise AssertionError(f"Unexpected layered output container: {type(images).__name__}")
+
+ first_item = images[0]
+ if isinstance(first_item, Image.Image):
+ return [image.convert("RGBA") for image in images if isinstance(image, Image.Image)]
+ if isinstance(first_item, (list, tuple)):
+ return [image.convert("RGBA") for image in first_item if isinstance(image, Image.Image)]
+ raise AssertionError(f"Unexpected layered image element type: {type(first_item).__name__}")
+
+
+def _run_vllm_omni_qwen_image_layered(*, model: str, input_image: Image.Image, output_dir: Path) -> list[Image.Image]:
+ input_image.save(output_dir / "input.png")
+ server_args = ["--num-gpus", "1", "--stage-init-timeout", "300", "--init-timeout", "900"]
+ with OmniServer(model, server_args, use_omni=True) as omni_server:
+ buffer = io.BytesIO()
+ input_image.save(buffer, format="PNG")
+ buffer.seek(0)
+ response = requests.post(
+ f"http://{omni_server.host}:{omni_server.port}/v1/images/edits",
+ data={
+ "model": omni_server.model,
+ "prompt": PROMPT,
+ "size": "auto",
+ "n": 1,
+ "response_format": "b64_json",
+ "negative_prompt": NEGATIVE_PROMPT,
+ "num_inference_steps": NUM_INFERENCE_STEPS,
+ "true_cfg_scale": TRUE_CFG_SCALE,
+ "seed": SEED,
+ "layers": LAYERS,
+ "resolution": RESOLUTION,
+ },
+ files=[("image", ("input.png", buffer, "image/png"))],
+ timeout=600,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ assert len(payload["data"]) == LAYERS
+ output_images = []
+ for item in payload["data"]:
+ image_bytes = base64.b64decode(item["b64_json"])
+ image = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
+ image.load()
+ output_images.append(image)
+ for index, image in enumerate(output_images, start=1):
+ image.save(output_dir / f"vllm_omni_layer_{index}.png")
+ return output_images
+
+
+def _run_diffusers_qwen_image_layered(*, model: str, input_image: Image.Image, output_dir: Path) -> list[Image.Image]:
+ run_pre_test_cleanup(enable_force=True)
+ pipe: DiffusionPipeline | None = None
+ try:
+ pipe = DiffusionPipeline.from_pretrained(
+ model,
+ torch_dtype=torch.bfloat16,
+ trust_remote_code=True,
+ local_files_only=_local_files_only(model),
+ ).to("cuda")
+ generator = torch.Generator(device="cuda").manual_seed(SEED)
+ result = pipe( # pyright: ignore[reportCallIssue]
+ image=input_image,
+ prompt=PROMPT,
+ negative_prompt=NEGATIVE_PROMPT,
+ num_inference_steps=NUM_INFERENCE_STEPS,
+ true_cfg_scale=TRUE_CFG_SCALE,
+ generator=generator,
+ num_images_per_prompt=1,
+ layers=LAYERS,
+ resolution=RESOLUTION,
+ )
+ output_images = _normalize_layered_images(result.images)
+ assert len(output_images) == LAYERS, f"Expected {LAYERS} diffusers layers, got {len(output_images)}"
+ for index, image in enumerate(output_images, start=1):
+ image.save(output_dir / f"diffusers_layer_{index}.png")
+ return output_images
+ finally:
+ if pipe is not None and hasattr(pipe, "maybe_free_model_hooks"):
+ pipe.maybe_free_model_hooks()
+ del pipe
+ gc.collect()
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ run_post_test_cleanup(enable_force=True)
+
+
+@pytest.mark.advanced_model
+@pytest.mark.benchmark
+@pytest.mark.diffusion
+@hardware_test(res={"cuda": "H100"}, num_cards=1)
+def test_qwen_image_layered_matches_diffusers(accuracy_artifact_root: Path, qwen_bear_image: Image.Image) -> None:
+ model = _model_name()
+ output_dir = model_output_dir(accuracy_artifact_root, MODEL_ID)
+ input_image = qwen_bear_image.convert("RGBA")
+
+ vllm_outputs = _run_vllm_omni_qwen_image_layered(model=model, input_image=input_image, output_dir=output_dir)
+ diffusers_outputs = _run_diffusers_qwen_image_layered(model=model, input_image=input_image, output_dir=output_dir)
+
+ assert_image_sequence_similarity(
+ model_name=MODEL_ID,
+ vllm_images=vllm_outputs,
+ diffusers_images=diffusers_outputs,
+ ssim_threshold=SSIM_THRESHOLD,
+ psnr_threshold=PSNR_THRESHOLD,
+ compare_mode="RGBA",
+ )
diff --git a/tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py b/tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py
index 3cdda1f9f..3aa5da85c 100644
--- a/tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py
+++ b/tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py
@@ -22,7 +22,6 @@ import torch
from diffusers import UniPCMultistepScheduler
from PIL import Image
-from tests.conftest import OmniServerParams
from tests.e2e.accuracy.wan22_i2v.run_wan22_i2v_diffusers_cp import (
_configure_scheduler,
_ensure_wan_ftfy_fallback,
@@ -48,7 +47,8 @@ from tests.e2e.accuracy.wan22_i2v.wan22_i2v_video_similarity_common import (
SSIM_THRESHOLD,
WIDTH,
)
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServerParams
def test_parse_video_metadata_extracts_dimensions_and_fps() -> None:
diff --git a/tests/e2e/offline_inference/custom_pipeline/test_async_omni_collective_rpc.py b/tests/e2e/offline_inference/custom_pipeline/test_async_omni_collective_rpc.py
index 57743d62b..bd3f2e099 100644
--- a/tests/e2e/offline_inference/custom_pipeline/test_async_omni_collective_rpc.py
+++ b/tests/e2e/offline_inference/custom_pipeline/test_async_omni_collective_rpc.py
@@ -26,7 +26,7 @@ from contextlib import ExitStack
import pytest
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
diff --git a/tests/e2e/offline_inference/custom_pipeline/test_async_omni_qwen_image_generate.py b/tests/e2e/offline_inference/custom_pipeline/test_async_omni_qwen_image_generate.py
index 03bd12efa..0681687fe 100644
--- a/tests/e2e/offline_inference/custom_pipeline/test_async_omni_qwen_image_generate.py
+++ b/tests/e2e/offline_inference/custom_pipeline/test_async_omni_qwen_image_generate.py
@@ -19,7 +19,7 @@ import numpy as np
import pytest
from transformers import AutoTokenizer
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
diff --git a/tests/e2e/offline_inference/custom_pipeline/test_worker_extension.py b/tests/e2e/offline_inference/custom_pipeline/test_worker_extension.py
index ffbe703ca..653b35d7e 100644
--- a/tests/e2e/offline_inference/custom_pipeline/test_worker_extension.py
+++ b/tests/e2e/offline_inference/custom_pipeline/test_worker_extension.py
@@ -10,7 +10,7 @@ import pytest
from tests.e2e.offline_inference.custom_pipeline.worker_extension import (
vLLMOmniColocateWorkerExtensionForTest,
)
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.worker.diffusion_worker import CustomPipelineWorkerExtension
from vllm_omni.entrypoints.async_omni import AsyncOmni
diff --git a/tests/e2e/offline_inference/stage_configs/bagel_mooncake_ci.yaml b/tests/e2e/offline_inference/stage_configs/bagel_mooncake_ci.yaml
index 1f0d06cb8..b7768c071 100644
--- a/tests/e2e/offline_inference/stage_configs/bagel_mooncake_ci.yaml
+++ b/tests/e2e/offline_inference/stage_configs/bagel_mooncake_ci.yaml
@@ -64,9 +64,6 @@ stage_args:
# Top-level runtime config with Mooncake connector
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
connectors:
mooncake_connector:
name: MooncakeConnector
@@ -80,4 +77,3 @@ runtime:
edges:
- from: 0
to: 1
- window_size: -1
diff --git a/tests/e2e/offline_inference/stage_configs/bagel_sharedmemory_ci.yaml b/tests/e2e/offline_inference/stage_configs/bagel_sharedmemory_ci.yaml
index 36b1d2bbe..504f3c98e 100644
--- a/tests/e2e/offline_inference/stage_configs/bagel_sharedmemory_ci.yaml
+++ b/tests/e2e/offline_inference/stage_configs/bagel_sharedmemory_ci.yaml
@@ -62,10 +62,6 @@ stage_args:
# Runtime edges
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
-
# Distributed connectors configuration (optional)
# More connectors will be supported in the future.
connectors:
@@ -78,4 +74,3 @@ runtime:
edges:
- from: 0
to: 1
- window_size: -1
diff --git a/tests/e2e/offline_inference/stage_configs/npu/qwen2_5_omni_ci.yaml b/tests/e2e/offline_inference/stage_configs/npu/qwen2_5_omni_ci.yaml
deleted file mode 100644
index f93a6c714..000000000
--- a/tests/e2e/offline_inference/stage_configs/npu/qwen2_5_omni_ci.yaml
+++ /dev/null
@@ -1,103 +0,0 @@
-# stage config for running qwen2.5-omni for multi-stage omni runtime.
-
-# This config is optimized for CI e2e tests.
-stage_args:
- - stage_id: 0
- runtime:
- process: true # Run this stage in a separate process
- devices: "0"
- engine_args:
- model_stage: thinker
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- max_model_len: 896
- max_num_batched_tokens: 896
- max_num_seqs: 1
- gpu_memory_utilization: 0.8
- skip_mm_profiling: true
- enforce_eager: true # Now we only support eager mode
- trust_remote_code: true
- engine_output_type: latent
- enable_prefix_caching: false
- mm_processor_cache_gb: 0
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 128
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
- - stage_id: 1
- runtime:
- process: true
- devices: "1"
- engine_args:
- model_stage: talker
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- max_model_len: 896
- max_num_batched_tokens: 896
- max_num_seqs: 1
- gpu_memory_utilization: 0.8
- skip_mm_profiling: true
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: latent
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen2_5_omni.thinker2talker
- default_sampling_params:
- temperature: 0.9
- top_p: 0.8
- top_k: 40
- max_tokens: 128
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
- stop_token_ids: [8294]
- - stage_id: 2
- runtime:
- process: true
- devices: "0" # Example: use a different GPU than the previous stage; use "0" if single GPU
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- gpu_memory_utilization: 0.15
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio
- engine_input_source: [1]
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 128
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
-# Top-level runtime config (concise): default windows and stage edges
-runtime:
- enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
- edges:
- - from: 0 # thinker → talker: trigger only after receiving full input (-1)
- to: 1
- window_size: -1
- - from: 1 # talker → code2wav: trigger only after receiving full input (-1)
- to: 2
- window_size: -1
diff --git a/tests/e2e/offline_inference/test_bagel_img2img.py b/tests/e2e/offline_inference/test_bagel_img2img.py
index 63d2a37da..66aec80c7 100644
--- a/tests/e2e/offline_inference/test_bagel_img2img.py
+++ b/tests/e2e/offline_inference/test_bagel_img2img.py
@@ -22,9 +22,10 @@ import pytest
from PIL import Image
from vllm.assets.image import ImageAsset
-from tests.conftest import OmniRunner, modify_stage_config
-from tests.utils import hardware_test
-from vllm_omni import Omni
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
+from tests.helpers.stage_config import modify_stage_config
+from vllm_omni.entrypoints.omni import Omni
from vllm_omni.platforms import current_omni_platform
# Reference pixel data extracted from the known-good output image
@@ -32,30 +33,30 @@ from vllm_omni.platforms import current_omni_platform
# prompt='Change the grass color to red',
# input image: 2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg
REFERENCE_PIXELS = [
- {"position": (100, 100), "rgb": (157, 172, 217)},
- {"position": (400, 50), "rgb": (105, 144, 218)},
- {"position": (700, 100), "rgb": (118, 159, 233)},
- {"position": (150, 400), "rgb": (195, 34, 60)},
- {"position": (512, 336), "rgb": (222, 214, 193)},
- {"position": (700, 400), "rgb": (197, 15, 43)},
- {"position": (100, 600), "rgb": (105, 13, 18)},
- {"position": (400, 600), "rgb": (169, 33, 44)},
- {"position": (700, 600), "rgb": (101, 86, 93)},
- {"position": (256, 256), "rgb": (181, 202, 222)},
+ {"position": (100, 100), "rgb": (156, 172, 217)},
+ {"position": (400, 50), "rgb": (105, 144, 217)},
+ {"position": (700, 100), "rgb": (118, 159, 232)},
+ {"position": (150, 400), "rgb": (180, 22, 52)},
+ {"position": (512, 336), "rgb": (221, 211, 194)},
+ {"position": (700, 400), "rgb": (192, 10, 46)},
+ {"position": (100, 600), "rgb": (102, 12, 22)},
+ {"position": (400, 600), "rgb": (161, 28, 47)},
+ {"position": (700, 600), "rgb": (100, 87, 94)},
+ {"position": (256, 256), "rgb": (181, 201, 221)},
]
if current_omni_platform.is_rocm():
REFERENCE_PIXELS = [
- {"position": (100, 100), "rgb": (156, 172, 215)},
- {"position": (400, 50), "rgb": (106, 144, 216)},
- {"position": (700, 100), "rgb": (118, 158, 231)},
- {"position": (150, 400), "rgb": (183, 23, 48)},
- {"position": (512, 336), "rgb": (218, 215, 191)},
- {"position": (700, 400), "rgb": (194, 14, 42)},
- {"position": (100, 600), "rgb": (105, 10, 16)},
- {"position": (400, 600), "rgb": (167, 33, 46)},
- {"position": (700, 600), "rgb": (102, 86, 92)},
- {"position": (256, 256), "rgb": (181, 201, 220)},
+ {"position": (100, 100), "rgb": (156, 172, 217)},
+ {"position": (400, 50), "rgb": (105, 144, 217)},
+ {"position": (700, 100), "rgb": (118, 159, 232)},
+ {"position": (150, 400), "rgb": (180, 22, 52)},
+ {"position": (512, 336), "rgb": (221, 211, 194)},
+ {"position": (700, 400), "rgb": (192, 10, 46)},
+ {"position": (100, 600), "rgb": (102, 12, 22)},
+ {"position": (400, 600), "rgb": (161, 28, 47)},
+ {"position": (700, 600), "rgb": (100, 87, 94)},
+ {"position": (256, 256), "rgb": (181, 201, 221)},
]
PIXEL_TOLERANCE = 10
diff --git a/tests/e2e/offline_inference/test_bagel_lora.py b/tests/e2e/offline_inference/test_bagel_lora.py
index 501d23eaa..75f41f9be 100644
--- a/tests/e2e/offline_inference/test_bagel_lora.py
+++ b/tests/e2e/offline_inference/test_bagel_lora.py
@@ -31,9 +31,10 @@ import torch
from PIL import Image
from safetensors.torch import save_file
-from tests.conftest import OmniRunner, modify_stage_config
-from tests.utils import hardware_test
-from vllm_omni import Omni
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
+from tests.helpers.stage_config import modify_stage_config
+from vllm_omni.entrypoints.omni import Omni
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
diff --git a/tests/e2e/offline_inference/test_bagel_text2img.py b/tests/e2e/offline_inference/test_bagel_text2img.py
index e45d64f2a..0819f103a 100644
--- a/tests/e2e/offline_inference/test_bagel_text2img.py
+++ b/tests/e2e/offline_inference/test_bagel_text2img.py
@@ -27,9 +27,10 @@ from typing import Any
import pytest
from PIL import Image
-from tests.conftest import OmniRunner, modify_stage_config
-from tests.utils import hardware_test
-from vllm_omni import Omni
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
+from tests.helpers.stage_config import modify_stage_config
+from vllm_omni.entrypoints.omni import Omni
from vllm_omni.platforms import current_omni_platform
# Reference pixel data extracted from the known-good output image
@@ -37,30 +38,30 @@ from vllm_omni.platforms import current_omni_platform
# "Generated with seed=52, num_inference_steps=15,
# prompt='A futuristic city skyline at twilight, cyberpunk style'"
REFERENCE_PIXELS = [
- {"position": (100, 100), "rgb": (121, 118, 100)},
- {"position": (400, 50), "rgb": (163, 162, 143)},
- {"position": (700, 100), "rgb": (170, 156, 127)},
- {"position": (150, 400), "rgb": (129, 127, 112)},
- {"position": (512, 512), "rgb": (135, 61, 59)},
- {"position": (700, 400), "rgb": (205, 107, 43)},
- {"position": (100, 700), "rgb": (197, 177, 157)},
- {"position": (400, 700), "rgb": (139, 107, 86)},
- {"position": (700, 700), "rgb": (247, 205, 146)},
- {"position": (256, 256), "rgb": (171, 160, 153)},
+ {"position": (100, 100), "rgb": (115, 113, 94)},
+ {"position": (400, 50), "rgb": (159, 160, 144)},
+ {"position": (700, 100), "rgb": (164, 151, 123)},
+ {"position": (150, 400), "rgb": (120, 121, 107)},
+ {"position": (512, 512), "rgb": (165, 133, 127)},
+ {"position": (700, 400), "rgb": (217, 130, 66)},
+ {"position": (100, 700), "rgb": (191, 168, 152)},
+ {"position": (400, 700), "rgb": (130, 96, 77)},
+ {"position": (700, 700), "rgb": (247, 203, 140)},
+ {"position": (256, 256), "rgb": (167, 156, 150)},
]
if current_omni_platform.is_rocm():
REFERENCE_PIXELS = [
- {"position": (100, 100), "rgb": (123, 119, 100)},
- {"position": (400, 50), "rgb": (162, 161, 142)},
- {"position": (700, 100), "rgb": (171, 156, 127)},
- {"position": (150, 400), "rgb": (131, 128, 112)},
- {"position": (512, 512), "rgb": (134, 61, 59)},
- {"position": (700, 400), "rgb": (204, 107, 43)},
- {"position": (100, 700), "rgb": (201, 180, 165)},
- {"position": (400, 700), "rgb": (140, 108, 87)},
- {"position": (700, 700), "rgb": (247, 205, 145)},
- {"position": (256, 256), "rgb": (171, 160, 153)},
+ {"position": (100, 100), "rgb": (115, 113, 94)},
+ {"position": (400, 50), "rgb": (159, 160, 144)},
+ {"position": (700, 100), "rgb": (164, 151, 123)},
+ {"position": (150, 400), "rgb": (120, 121, 107)},
+ {"position": (512, 512), "rgb": (165, 133, 127)},
+ {"position": (700, 400), "rgb": (217, 130, 66)},
+ {"position": (100, 700), "rgb": (191, 168, 152)},
+ {"position": (400, 700), "rgb": (130, 96, 77)},
+ {"position": (700, 700), "rgb": (247, 203, 140)},
+ {"position": (256, 256), "rgb": (167, 156, 150)},
]
# Maximum allowed difference per color channel
diff --git a/tests/e2e/offline_inference/test_bagel_understanding.py b/tests/e2e/offline_inference/test_bagel_understanding.py
index bbee32980..c3ed97b42 100644
--- a/tests/e2e/offline_inference/test_bagel_understanding.py
+++ b/tests/e2e/offline_inference/test_bagel_understanding.py
@@ -26,8 +26,9 @@ from pathlib import Path
import pytest
from vllm.assets.image import ImageAsset
-from tests.conftest import OmniRunner, modify_stage_config
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
+from tests.helpers.stage_config import modify_stage_config
MODEL_NAME = "ByteDance-Seed/BAGEL-7B-MoT"
STAGE_CONFIG = str(Path(__file__).parent / "stage_configs" / "bagel_sharedmemory_ci.yaml")
diff --git a/tests/e2e/offline_inference/test_cache_dit.py b/tests/e2e/offline_inference/test_cache_dit.py
index fc08da7be..1577dd9f6 100644
--- a/tests/e2e/offline_inference/test_cache_dit.py
+++ b/tests/e2e/offline_inference/test_cache_dit.py
@@ -11,8 +11,8 @@ It uses minimal settings to keep test time short for CI.
import pytest
import torch
-from tests.conftest import OmniRunner
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
diff --git a/tests/e2e/offline_inference/test_cosyvoice3.py b/tests/e2e/offline_inference/test_cosyvoice3.py
index 8c88d972d..7206f1e7b 100644
--- a/tests/e2e/offline_inference/test_cosyvoice3.py
+++ b/tests/e2e/offline_inference/test_cosyvoice3.py
@@ -15,19 +15,18 @@ from __future__ import annotations
import functools
import io
import os
-import tempfile
from pathlib import Path
from urllib.request import urlopen
import numpy as np
import pytest
import soundfile as sf
-import yaml
from huggingface_hub import snapshot_download
from vllm.sampling_params import SamplingParams
-from tests.conftest import OmniRunner
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
+from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.model_executor.models.cosyvoice3.config import CosyVoice3Config
from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer
@@ -44,13 +43,9 @@ REFERENCE_STAGE0_TEMPERATURE = 1.0
REFERENCE_STAGE0_REPETITION_PENALTY = 2.0
-def _stage_config(name: str) -> str:
- return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
-
-
-STAGE_CONFIGS = [
- _stage_config("cosyvoice3.yaml"),
- _stage_config("cosyvoice3_async_chunk.yaml"),
+ASYNC_CHUNK_MODES = [
+ pytest.param(False, id="sync"),
+ pytest.param(True, id="async_chunk"),
]
@@ -138,19 +133,6 @@ def _get_stage_engine_outputs(omni_runner: OmniRunner, stage_id: int):
raise AttributeError("Unable to locate stage outputs on Omni runner")
-def _patched_stage_config(base_stage_config: str, model_dir: Path, tmp_dir: Path) -> str:
- cfg = yaml.safe_load(Path(base_stage_config).read_text(encoding="utf-8"))
- tokenizer_path = str(model_dir / "CosyVoice-BlankEN")
- for stage in cfg.get("stage_args", []):
- engine_args = stage.setdefault("engine_args", {})
- engine_args["tokenizer"] = tokenizer_path
- engine_args["enforce_eager"] = True
- engine_args["hf_overrides"] = {"architectures": ["CosyVoice3Model"]}
- out_path = tmp_dir / Path(base_stage_config).name
- out_path.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
- return str(out_path)
-
-
def _build_reference_inputs(prompt_audio: tuple[np.ndarray, int]) -> list[dict[str, object]]:
return [
{
@@ -165,54 +147,54 @@ def _build_reference_inputs(prompt_audio: tuple[np.ndarray, int]) -> list[dict[s
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
-@pytest.mark.parametrize("base_stage_config", STAGE_CONFIGS)
-def test_cosyvoice3_offline_reference_zero_shot(base_stage_config: str) -> None:
+@pytest.mark.parametrize("async_chunk", ASYNC_CHUNK_MODES)
+def test_cosyvoice3_offline_reference_zero_shot(async_chunk: bool) -> None:
"""CosyVoice3 zero-shot reference inference should stop cleanly and produce sane audio."""
prompt_audio, prompt_sr = _load_reference_prompt_wav()
model_dir = _resolve_model_dir()
expected_stop_token = int(CosyVoice3Config().llm["eos_token_id"])
- with tempfile.TemporaryDirectory(prefix="cv3-e2e-") as tmp:
- stage_config = _patched_stage_config(base_stage_config, model_dir, Path(tmp))
- with OmniRunner(
- str(model_dir), seed=42, stage_configs_path=stage_config, stage_init_timeout=300
- ) as omni_runner:
- sampling_params_list = omni_runner.get_default_sampling_params_list()
- sampling_params_list[0] = _reference_zero_shot_stage0_sampling(text=REFERENCE_SYNTH_TEXT)
+ with OmniRunner(
+ str(model_dir),
+ seed=42,
+ stage_configs_path=get_deploy_config_path("cosyvoice3.yaml"),
+ async_chunk=async_chunk,
+ stage_init_timeout=300,
+ ) as omni_runner:
+ sampling_params_list = omni_runner.get_default_sampling_params_list()
+ sampling_params_list[0] = _reference_zero_shot_stage0_sampling(text=REFERENCE_SYNTH_TEXT)
- outputs = omni_runner.omni.generate(
- _build_reference_inputs((prompt_audio, prompt_sr)), sampling_params_list
+ outputs = omni_runner.omni.generate(_build_reference_inputs((prompt_audio, prompt_sr)), sampling_params_list)
+
+ assert outputs, "No outputs returned"
+ audio_mm = outputs[0].multimodal_output
+ assert "audio" in audio_mm, "No audio output found"
+
+ audio = _concat_audio(audio_mm["audio"])
+ assert audio.size > 0, "Generated audio is empty"
+
+ sr_val = audio_mm.get("sr", 24000)
+ if isinstance(sr_val, list) and sr_val:
+ sr_val = sr_val[-1]
+ if hasattr(sr_val, "item"):
+ sr_val = sr_val.item()
+ sr = int(sr_val)
+ assert sr == 24000, f"Unexpected sample_rate={sr}"
+
+ duration_s = audio.size / sr
+ assert 2.8 <= duration_s <= 8.8, f"Unexpected duration={duration_s:.3f}s (samples={audio.size}, sr={sr})"
+
+ stage0_outputs = _get_stage_engine_outputs(omni_runner, 0)
+ if stage0_outputs:
+ completion = stage0_outputs[0].outputs[0]
+ finish_reason = getattr(completion, "finish_reason", None)
+ stop_reason = getattr(completion, "stop_reason", None)
+ num_tokens = len(getattr(completion, "token_ids", []) or [])
+
+ assert finish_reason == "stop", f"Stage-0 finish_reason={finish_reason}, expected 'stop'"
+ assert int(stop_reason) == expected_stop_token, (
+ f"Stage-0 stop_reason={stop_reason}, expected {expected_stop_token}"
)
-
- assert outputs, "No outputs returned"
- audio_mm = outputs[0].multimodal_output
- assert "audio" in audio_mm, "No audio output found"
-
- audio = _concat_audio(audio_mm["audio"])
- assert audio.size > 0, "Generated audio is empty"
-
- sr_val = audio_mm.get("sr", 24000)
- if isinstance(sr_val, list) and sr_val:
- sr_val = sr_val[-1]
- if hasattr(sr_val, "item"):
- sr_val = sr_val.item()
- sr = int(sr_val)
- assert sr == 24000, f"Unexpected sample_rate={sr}"
-
- duration_s = audio.size / sr
- assert 2.8 <= duration_s <= 8.8, f"Unexpected duration={duration_s:.3f}s (samples={audio.size}, sr={sr})"
-
- stage0_outputs = _get_stage_engine_outputs(omni_runner, 0)
- if stage0_outputs:
- completion = stage0_outputs[0].outputs[0]
- finish_reason = getattr(completion, "finish_reason", None)
- stop_reason = getattr(completion, "stop_reason", None)
- num_tokens = len(getattr(completion, "token_ids", []) or [])
-
- assert finish_reason == "stop", f"Stage-0 finish_reason={finish_reason}, expected 'stop'"
- assert int(stop_reason) == expected_stop_token, (
- f"Stage-0 stop_reason={stop_reason}, expected {expected_stop_token}"
- )
- assert 80 <= num_tokens <= 220, f"Stage-0 num_tokens={num_tokens}, expected sane stop-bound range"
- else:
- assert "async_chunk" in Path(base_stage_config).name, "Stage-0 produced no engine outputs"
+ assert 80 <= num_tokens <= 220, f"Stage-0 num_tokens={num_tokens}, expected sane stop-bound range"
+ else:
+ assert async_chunk, "Stage-0 produced no engine outputs"
diff --git a/tests/e2e/offline_inference/test_diffusion_cpu_offload.py b/tests/e2e/offline_inference/test_diffusion_cpu_offload.py
index 257755ef8..d7fd6f72f 100644
--- a/tests/e2e/offline_inference/test_diffusion_cpu_offload.py
+++ b/tests/e2e/offline_inference/test_diffusion_cpu_offload.py
@@ -4,8 +4,9 @@ import pytest
import torch
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
-from tests.conftest import OmniRunner
-from tests.utils import DeviceMemoryMonitor, hardware_test
+from tests.helpers.env import DeviceMemoryMonitor
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
diff --git a/tests/e2e/offline_inference/test_diffusion_layerwise_offload.py b/tests/e2e/offline_inference/test_diffusion_layerwise_offload.py
index bdfd594c7..4f19c1004 100644
--- a/tests/e2e/offline_inference/test_diffusion_layerwise_offload.py
+++ b/tests/e2e/offline_inference/test_diffusion_layerwise_offload.py
@@ -2,8 +2,8 @@ import pytest
import torch
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
-from tests.conftest import OmniRunner
-from tests.utils import DeviceMemoryMonitor
+from tests.helpers.env import DeviceMemoryMonitor
+from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
diff --git a/tests/e2e/offline_inference/test_diffusion_lora.py b/tests/e2e/offline_inference/test_diffusion_lora.py
index 7edd03f20..027dadb3f 100644
--- a/tests/e2e/offline_inference/test_diffusion_lora.py
+++ b/tests/e2e/offline_inference/test_diffusion_lora.py
@@ -7,7 +7,7 @@ import pytest
import torch
from safetensors.torch import save_file
-from tests.conftest import OmniRunner
+from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
diff --git a/tests/e2e/offline_inference/test_dynin_omni.py b/tests/e2e/offline_inference/test_dynin_omni.py
index 5388ac674..f891fc4f1 100644
--- a/tests/e2e/offline_inference/test_dynin_omni.py
+++ b/tests/e2e/offline_inference/test_dynin_omni.py
@@ -18,7 +18,7 @@ import pytest
import torch
from transformers import AutoTokenizer
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
diff --git a/tests/e2e/offline_inference/test_expert_parallel.py b/tests/e2e/offline_inference/test_expert_parallel.py
index 29d84d7a3..f11646b30 100644
--- a/tests/e2e/offline_inference/test_expert_parallel.py
+++ b/tests/e2e/offline_inference/test_expert_parallel.py
@@ -18,8 +18,8 @@ import torch
import torch.distributed as dist
from PIL import Image
-from tests.conftest import OmniRunner
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
diff --git a/tests/e2e/offline_inference/test_flux.py b/tests/e2e/offline_inference/test_flux.py
new file mode 100644
index 000000000..02c6787be
--- /dev/null
+++ b/tests/e2e/offline_inference/test_flux.py
@@ -0,0 +1,37 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+"""Tests for Flux1 Schnell."""
+
+import pytest
+from PIL import Image
+
+from vllm_omni.entrypoints.omni import Omni
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+
+MODEL = "black-forest-labs/FLUX.1-schnell"
+
+
+@pytest.mark.core_model
+@pytest.mark.diffusion
+def test_flux_schnell_text_to_image():
+ """Test FLUX.1-schnell text-to-image generation."""
+ omni = Omni(model=MODEL)
+
+ omni_outputs = list(
+ omni.generate(
+ prompts=["A photo of a cat sitting on a laptop"],
+ sampling_params_list=OmniDiffusionSamplingParams(
+ height=512,
+ width=512,
+ num_inference_steps=2,
+ seed=42,
+ ),
+ )
+ )
+
+ assert len(omni_outputs) > 0
+ images = omni_outputs[0].images
+ assert len(images) == 1
+ assert isinstance(images[0], Image.Image)
+ assert images[0].size == (512, 512)
diff --git a/tests/e2e/offline_inference/test_flux2_klein_inpaint.py b/tests/e2e/offline_inference/test_flux2_klein_inpaint.py
new file mode 100644
index 000000000..ac7169361
--- /dev/null
+++ b/tests/e2e/offline_inference/test_flux2_klein_inpaint.py
@@ -0,0 +1,216 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+"""
+End-to-end test for Flux2 Klein inpainting.
+
+"""
+
+# ruff: noqa: E402
+
+import os
+import sys
+from pathlib import Path
+
+import pytest
+import torch
+from PIL import Image, ImageDraw
+
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+from vllm_omni.outputs import OmniRequestOutput
+from vllm_omni.platforms import current_omni_platform
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+if str(REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPO_ROOT))
+
+from vllm_omni import Omni
+
+os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "1"
+
+MODEL = "black-forest-labs/FLUX.2-klein-4B"
+
+_HEIGHT = 512
+_WIDTH = 512
+_NUM_INFERENCE_STEPS = 4
+
+
+def _create_test_image(width: int = _WIDTH, height: int = _HEIGHT, color: tuple = (128, 128, 128)) -> Image.Image:
+ return Image.new("RGB", (width, height), color)
+
+
+def _create_test_mask(width: int = _WIDTH, height: int = _HEIGHT) -> Image.Image:
+ mask = Image.new("L", (width, height), 0)
+ draw = ImageDraw.Draw(mask)
+ draw.rectangle([width // 4, height // 4, width * 3 // 4, height * 3 // 4], fill=255)
+ return mask
+
+
+def _create_test_inputs(color: tuple = (100, 150, 200)):
+ return _create_test_image(_WIDTH, _HEIGHT, color), _create_test_mask(_WIDTH, _HEIGHT)
+
+
+def _extract_images_from_output(outputs: list) -> list[Image.Image]:
+ images = []
+ for req_output in outputs:
+ if hasattr(req_output, "images") and req_output.images:
+ images.extend(req_output.images)
+ elif hasattr(req_output, "request_output") and req_output.request_output:
+ stage_out = req_output.request_output
+ if isinstance(stage_out, OmniRequestOutput) and hasattr(stage_out, "images"):
+ images.extend(stage_out.images)
+ elif isinstance(stage_out, list):
+ for s in stage_out:
+ if hasattr(s, "images") and s.images:
+ images.extend(s.images)
+ return images
+
+
+@pytest.mark.core_model
+@pytest.mark.diffusion
+def test_flux2_klein_inpaint_basic():
+ m = None
+ try:
+ m = Omni(model=MODEL)
+ input_image, mask_image = _create_test_inputs()
+
+ outputs = m.generate(
+ prompts=[
+ {
+ "prompt": "Fill in the masked area with a beautiful garden",
+ "multi_modal_data": {"image": input_image, "mask_image": mask_image},
+ }
+ ],
+ sampling_params_list=OmniDiffusionSamplingParams(
+ height=_HEIGHT,
+ width=_WIDTH,
+ num_inference_steps=_NUM_INFERENCE_STEPS,
+ guidance_scale=0.0,
+ generator=torch.Generator(current_omni_platform.device_type).manual_seed(42),
+ num_outputs_per_prompt=1,
+ ),
+ )
+
+ images = _extract_images_from_output(list(outputs))
+ assert len(images) == 1
+ assert images[0].size == (_WIDTH, _HEIGHT)
+ finally:
+ if m is not None and hasattr(m, "close"):
+ m.close()
+
+
+@pytest.mark.diffusion
+def test_flux2_klein_inpaint_deterministic():
+ m = None
+ try:
+ m = Omni(model=MODEL)
+ input_image, mask_image = _create_test_inputs()
+ seed = 12345
+
+ gen1 = torch.Generator(current_omni_platform.device_type).manual_seed(seed)
+ gen2 = torch.Generator(current_omni_platform.device_type).manual_seed(seed)
+
+ outputs1 = m.generate(
+ prompts=[
+ {
+ "prompt": "A red flower in a field",
+ "multi_modal_data": {"image": input_image, "mask_image": mask_image},
+ }
+ ],
+ sampling_params_list=OmniDiffusionSamplingParams(
+ height=_HEIGHT,
+ width=_WIDTH,
+ num_inference_steps=_NUM_INFERENCE_STEPS,
+ guidance_scale=0.0,
+ generator=gen1,
+ num_outputs_per_prompt=1,
+ ),
+ )
+
+ outputs2 = m.generate(
+ prompts=[
+ {
+ "prompt": "A red flower in a field",
+ "multi_modal_data": {"image": input_image, "mask_image": mask_image},
+ }
+ ],
+ sampling_params_list=OmniDiffusionSamplingParams(
+ height=_HEIGHT,
+ width=_WIDTH,
+ num_inference_steps=_NUM_INFERENCE_STEPS,
+ guidance_scale=0.0,
+ generator=gen2,
+ num_outputs_per_prompt=1,
+ ),
+ )
+
+ images1 = _extract_images_from_output(list(outputs1))
+ images2 = _extract_images_from_output(list(outputs2))
+
+ assert len(images1) == 1
+ assert len(images2) == 1
+
+ assert list(images1[0].getdata()) == list(images2[0].getdata()), (
+ "Same input with same seed should produce identical output. "
+ "This is critical for offline/online consistency."
+ )
+ finally:
+ if m is not None and hasattr(m, "close"):
+ m.close()
+
+
+@pytest.mark.diffusion
+def test_flux2_klein_inpaint_different_seeds_different_output():
+ m = None
+ try:
+ m = Omni(model=MODEL)
+ input_image, mask_image = _create_test_inputs()
+
+ gen1 = torch.Generator(current_omni_platform.device_type).manual_seed(42)
+ gen2 = torch.Generator(current_omni_platform.device_type).manual_seed(99999)
+
+ outputs1 = m.generate(
+ prompts=[
+ {
+ "prompt": "A beautiful landscape",
+ "multi_modal_data": {"image": input_image, "mask_image": mask_image},
+ }
+ ],
+ sampling_params_list=OmniDiffusionSamplingParams(
+ height=_HEIGHT,
+ width=_WIDTH,
+ num_inference_steps=_NUM_INFERENCE_STEPS,
+ guidance_scale=0.0,
+ generator=gen1,
+ num_outputs_per_prompt=1,
+ ),
+ )
+
+ outputs2 = m.generate(
+ prompts=[
+ {
+ "prompt": "A beautiful landscape",
+ "multi_modal_data": {"image": input_image, "mask_image": mask_image},
+ }
+ ],
+ sampling_params_list=OmniDiffusionSamplingParams(
+ height=_HEIGHT,
+ width=_WIDTH,
+ num_inference_steps=_NUM_INFERENCE_STEPS,
+ guidance_scale=0.0,
+ generator=gen2,
+ num_outputs_per_prompt=1,
+ ),
+ )
+
+ images1 = _extract_images_from_output(list(outputs1))
+ images2 = _extract_images_from_output(list(outputs2))
+
+ assert len(images1) == 1
+ assert len(images2) == 1
+
+ different_pixel_count = sum(1 for p1, p2 in zip(images1[0].getdata(), images2[0].getdata()) if p1 != p2)
+ assert different_pixel_count > 0, "Different seeds should produce different outputs"
+ finally:
+ if m is not None and hasattr(m, "close"):
+ m.close()
diff --git a/tests/e2e/offline_inference/test_flux_autoround_w4a16.py b/tests/e2e/offline_inference/test_flux_autoround_w4a16.py
index cbcd1009d..ef5d6f9e0 100644
--- a/tests/e2e/offline_inference/test_flux_autoround_w4a16.py
+++ b/tests/e2e/offline_inference/test_flux_autoround_w4a16.py
@@ -14,8 +14,9 @@ import pytest
import torch
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
-from tests.conftest import OmniRunner
-from tests.utils import DeviceMemoryMonitor, hardware_test
+from tests.helpers.env import DeviceMemoryMonitor
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
diff --git a/tests/e2e/offline_inference/test_flux_kontext.py b/tests/e2e/offline_inference/test_flux_kontext.py
index cd711d6b8..057319c85 100644
--- a/tests/e2e/offline_inference/test_flux_kontext.py
+++ b/tests/e2e/offline_inference/test_flux_kontext.py
@@ -13,7 +13,7 @@ import pytest
from PIL import Image
from vllm.assets.image import ImageAsset
-from tests.conftest import OmniRunner
+from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
diff --git a/tests/e2e/offline_inference/test_hunyuanimage3_text2img.py b/tests/e2e/offline_inference/test_hunyuanimage3_text2img.py
index 79bb64dca..bd0d132d0 100644
--- a/tests/e2e/offline_inference/test_hunyuanimage3_text2img.py
+++ b/tests/e2e/offline_inference/test_hunyuanimage3_text2img.py
@@ -8,7 +8,7 @@ import torch.nn.functional as F
from PIL import Image
from transformers import CLIPModel, CLIPProcessor
-from tests.conftest import OmniRunner
+from tests.helpers.runtime import OmniRunner
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
@@ -17,7 +17,7 @@ PROMPT = "A brown and white dog is running on the grass"
MODEL_NAME = "tencent/HunyuanImage-3.0"
LOCAL_CLIP_PATH = "openai/clip-vit-base-patch32"
REPO_ROOT = Path(__file__).resolve().parents[3]
-STAGE_CONFIG_PATH = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "hunyuan_image_3_moe.yaml"
+STAGE_CONFIG_PATH = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "hunyuan_image3_t2i.yaml"
pytestmark = [pytest.mark.advanced_model, pytest.mark.diffusion]
diff --git a/tests/e2e/offline_inference/test_ltx2_cfg_parallel_parity.py b/tests/e2e/offline_inference/test_ltx2_cfg_parallel_parity.py
index 659040929..07aa5a647 100644
--- a/tests/e2e/offline_inference/test_ltx2_cfg_parallel_parity.py
+++ b/tests/e2e/offline_inference/test_ltx2_cfg_parallel_parity.py
@@ -11,7 +11,7 @@ import numpy as np
import pytest
from PIL import Image
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
REPO_ROOT = Path(__file__).resolve().parents[3]
T2V_EXAMPLE = REPO_ROOT / "examples" / "offline_inference" / "text_to_video" / "text_to_video.py"
diff --git a/tests/e2e/offline_inference/test_magi_human.py b/tests/e2e/offline_inference/test_magi_human.py
index abb7f9c16..6d4614172 100644
--- a/tests/e2e/offline_inference/test_magi_human.py
+++ b/tests/e2e/offline_inference/test_magi_human.py
@@ -8,8 +8,8 @@ import av
import numpy as np
import pytest
-from tests.conftest import OmniRunner
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
diff --git a/tests/e2e/offline_inference/test_mammoth_moda2.py b/tests/e2e/offline_inference/test_mammoth_moda2.py
index ff744c86e..c3d95844c 100644
--- a/tests/e2e/offline_inference/test_mammoth_moda2.py
+++ b/tests/e2e/offline_inference/test_mammoth_moda2.py
@@ -23,8 +23,10 @@ import pytest
import torch
from vllm.sampling_params import SamplingParams
-from tests.conftest import OmniRunner
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
+
+os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "1"
# ---------------------------------------------------------------------------
# Constants
diff --git a/tests/e2e/offline_inference/test_ming_flash_omni.py b/tests/e2e/offline_inference/test_ming_flash_omni.py
new file mode 100644
index 000000000..c591e910a
--- /dev/null
+++ b/tests/e2e/offline_inference/test_ming_flash_omni.py
@@ -0,0 +1,142 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+import os
+
+os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
+os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
+
+from pathlib import Path
+
+import pytest
+
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import (
+ generate_synthetic_audio,
+ generate_synthetic_image,
+ generate_synthetic_video,
+)
+from tests.helpers.stage_config import modify_stage_config
+
+models = ["Jonathan1909/Ming-flash-omni-2.0"]
+
+# Ming-specific
+SYSTEM_PROMPT = "你是一个友好的AI助手。\n\ndetailed thinking off"
+EOS_TOKEN = "<|role_end|>"
+IMAGE_TOKEN = ""
+VIDEO_TOKEN = ""
+AUDIO_TOKEN = ""
+
+
+def build_prompt(user_text: str) -> str:
+ """Build a Ming chat prompt."""
+ return (
+ f"SYSTEM {SYSTEM_PROMPT}{EOS_TOKEN}HUMAN {user_text}{EOS_TOKEN}ASSISTANT "
+ )
+
+
+def get_eager_config():
+ path = modify_stage_config(
+ str(Path(__file__).parent.parent / "stage_configs" / "bailingmm_moe_v2_lite_ci.yaml"),
+ updates={
+ "stage_args": {
+ 0: {
+ "engine_args.enforce_eager": "true",
+ },
+ },
+ },
+ )
+ return path
+
+
+stage_configs = [get_eager_config()]
+test_params = [(model, stage_config) for model in models for stage_config in stage_configs]
+
+
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "H100"}, num_cards=4)
+@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
+def test_text_to_text(omni_runner, omni_runner_handler) -> None:
+ """
+ Test text-only input processing and text output generation.
+ Input Modal: text
+ Output Modal: text
+ """
+ prompt = build_prompt("请详细介绍鹦鹉的生活习性。")
+ request_config = {"prompts": prompt, "modalities": ["text"]}
+
+ omni_runner_handler.send_request(request_config)
+
+
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "H100"}, num_cards=4)
+@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
+def test_image_to_text(omni_runner, omni_runner_handler) -> None:
+ """
+ Test image understanding with text output.
+ Input Modal: image + text
+ Output Modal: text
+ """
+ image = generate_synthetic_image(224, 224)["np_array"]
+ prompt = build_prompt(f"{IMAGE_TOKEN}Describe this image briefly.")
+ request_config = {"prompts": prompt, "images": image, "modalities": ["text"]}
+
+ omni_runner_handler.send_request(request_config)
+
+
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "H100"}, num_cards=4)
+@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
+def test_audio_to_text(omni_runner, omni_runner_handler) -> None:
+ """
+ Test audio understanding with text output.
+ Input Modal: audio + text
+ Output Modal: text
+ """
+ audio = generate_synthetic_audio(2, 1, 16000)["np_array"]
+ if len(audio.shape) == 2:
+ audio = audio.squeeze()
+ prompt = build_prompt(f"{AUDIO_TOKEN}Please recognize the language of this speech and transcribe it. Format: oral.")
+ request_config = {"prompts": prompt, "audios": audio, "modalities": ["text"]}
+
+ omni_runner_handler.send_request(request_config)
+
+
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "H100"}, num_cards=4)
+@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
+def test_video_to_text(omni_runner, omni_runner_handler) -> None:
+ """
+ Test video understanding with text output.
+ Input Modal: video + text
+ Output Modal: text
+ """
+ video = generate_synthetic_video(224, 224, 30)["np_array"]
+ prompt = build_prompt(f"{VIDEO_TOKEN}Describe what is happening in this video.")
+ request_config = {"prompts": prompt, "videos": video, "modalities": ["text"]}
+
+ omni_runner_handler.send_request(request_config)
+
+
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "H100"}, num_cards=4)
+@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
+def test_mixed_to_text(omni_runner, omni_runner_handler) -> None:
+ """
+ Test mixed modality input (image + audio) with text output.
+ Input Modal: image + audio + text
+ Output Modal: text
+ """
+ image = generate_synthetic_image(224, 224)["np_array"]
+ audio = generate_synthetic_audio(2, 1, 16000)["np_array"]
+ if len(audio.shape) == 2:
+ audio = audio.squeeze()
+ prompt = build_prompt(f"{IMAGE_TOKEN}{AUDIO_TOKEN}Describe the image and transcribe the audio.")
+ request_config = {"prompts": prompt, "images": image, "audios": audio, "modalities": ["text"]}
+
+ omni_runner_handler.send_request(request_config)
diff --git a/tests/e2e/offline_inference/test_omni_sleep_mode.py b/tests/e2e/offline_inference/test_omni_sleep_mode.py
new file mode 100644
index 000000000..2ad0b53b0
--- /dev/null
+++ b/tests/e2e/offline_inference/test_omni_sleep_mode.py
@@ -0,0 +1,159 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+"""
+End-to-end tests for Omni Sleep Mode across various model architectures.
+"""
+
+import pytest
+import torch
+from vllm import SamplingParams
+
+from tests.helpers.mark import hardware_test
+from vllm_omni.entrypoints.async_omni import AsyncOmni
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+
+MODEL = "ByteDance-Seed/BAGEL-7B-MoT"
+MODEL_DIFF = "riverclouds/qwen_image_random"
+
+
+def get_ack_info(ack, key, default=None):
+ if hasattr(ack, key):
+ return getattr(ack, key)
+ if isinstance(ack, dict):
+ return ack.get(key, default)
+ return default
+
+
+def get_dynamic_devices(stage_idx, num_stages, tp_size):
+ total_gpus = torch.cuda.device_count()
+ gpus_per_stage = tp_size
+ start_idx = stage_idx * gpus_per_stage
+ if start_idx + gpus_per_stage > total_gpus:
+ start_idx = start_idx % total_gpus
+ device_ids = [str(start_idx + i) for i in range(gpus_per_stage)]
+ return ",".join(device_ids)
+
+
+# Test 1: Diffusion Model (2-Stage BAGEL)
+@pytest.mark.advanced_model
+@pytest.mark.omni
+@pytest.mark.parametrize("tp_size", [1, 2])
+@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
+@pytest.mark.asyncio
+async def test_diffusion_model_sleep_tp(tp_size):
+ num_gpus = torch.cuda.device_count()
+ if num_gpus < tp_size:
+ pytest.skip(f"Skipping TP={tp_size}")
+
+ engine_args = {
+ "model": MODEL,
+ "enable_sleep_mode": True,
+ "tensor_parallel_size": tp_size,
+ "enforce_eager": True,
+ "trust_remote_code": True,
+ "dtype": "bfloat16",
+ "gpu_memory_utilization": 0.5,
+ }
+
+ engine = AsyncOmni(**engine_args, stage_init_timeout=1200)
+ try:
+ # BAGEL requires 2 params
+ diff_sp = OmniDiffusionSamplingParams(num_inference_steps=2, height=256, width=256)
+ llm_sp = SamplingParams()
+
+ # Warmup
+ async for _ in engine.generate("test", sampling_params=[llm_sp, diff_sp]):
+ pass
+
+ # Sleep all
+ acks = await engine.sleep(level=2)
+ statuses = [get_ack_info(ack, "status") for ack in acks]
+ assert all(s == "SUCCESS" for s in statuses), f"Sleep failed. Statuses: {statuses}"
+
+ # Wakeup & Verify
+ await engine.wake_up()
+ async for _ in engine.generate("verify", sampling_params=[llm_sp, diff_sp]):
+ pass
+
+ print(f"Diffusion TP={tp_size} Lifecycle OK")
+ finally:
+ engine.shutdown()
+
+
+# Test 2: Multi-stage Manual Config
+@pytest.mark.advanced_model
+@pytest.mark.omni
+@pytest.mark.parametrize("tp_size", [1, 2])
+@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
+@pytest.mark.asyncio
+async def test_multistage_sleep_h100(tp_size):
+ num_gpus = torch.cuda.device_count()
+ if num_gpus < tp_size * 2:
+ pytest.skip("Not enough GPUs")
+
+ stages = []
+ for i in range(2):
+ devs = get_dynamic_devices(i, 2, tp_size)
+ stages.append(
+ {
+ "stage_id": i,
+ "stage_type": "llm" if i == 0 else "diffusion",
+ "runtime": {"process": True, "devices": devs},
+ "engine_args": {
+ "model": MODEL,
+ "model_stage": "thinker" if i == 0 else "base",
+ "tensor_parallel_size": tp_size,
+ "gpu_memory_utilization": 0.4,
+ "dtype": "bfloat16",
+ "enable_sleep_mode": True,
+ "trust_remote_code": True,
+ },
+ }
+ )
+
+ connectors = [{"src_stage_id": 0, "dst_stage_id": 1, "connector_type": "queue"}]
+
+ engine = AsyncOmni(
+ model=MODEL, stages=stages, connectors=connectors, enable_sleep_mode=True, stage_init_timeout=1200
+ )
+ try:
+ sp = OmniDiffusionSamplingParams(num_inference_steps=2)
+ async for _ in engine.generate("warmup", sampling_params=[SamplingParams(), sp]):
+ pass
+
+ acks = await engine.sleep(stage_ids=[0, 1], level=2)
+ assert len(acks) == 2 * tp_size
+
+ await engine.wake_up(stage_ids=[0, 1])
+ async for _ in engine.generate("verify", sampling_params=[SamplingParams(), sp]):
+ pass
+ finally:
+ engine.shutdown()
+
+
+# Test 3: Pure Diffusion Single-Stage
+@pytest.mark.advanced_model
+@pytest.mark.omni
+@pytest.mark.parametrize("tp_size", [1, 2])
+@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
+@pytest.mark.asyncio
+async def test_pure_diffusion_scenario(tp_size):
+ engine_args = {
+ "model": MODEL_DIFF,
+ "enable_sleep_mode": True,
+ "tensor_parallel_size": tp_size,
+ "enforce_eager": True,
+ "dtype": "bfloat16",
+ "gpu_memory_utilization": 0.5,
+ }
+
+ engine = AsyncOmni(**engine_args, stage_init_timeout=1200)
+ try:
+ await engine.sleep(level=1)
+ await engine.wake_up()
+ async for _ in engine.generate("test", sampling_params=[SamplingParams()]):
+ pass
+ print("Pure Diffusion OK")
+ finally:
+ engine.shutdown()
diff --git a/tests/e2e/offline_inference/test_omnivoice.py b/tests/e2e/offline_inference/test_omnivoice.py
index bb4c8a5dd..30a3427be 100644
--- a/tests/e2e/offline_inference/test_omnivoice.py
+++ b/tests/e2e/offline_inference/test_omnivoice.py
@@ -16,8 +16,8 @@ from pathlib import Path
import numpy as np
import pytest
-from tests.conftest import OmniRunner
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
MODEL = "k2-fsa/OmniVoice"
diff --git a/tests/e2e/offline_inference/test_ovis_image.py b/tests/e2e/offline_inference/test_ovis_image.py
index 41e21bca3..70fab4fe1 100644
--- a/tests/e2e/offline_inference/test_ovis_image.py
+++ b/tests/e2e/offline_inference/test_ovis_image.py
@@ -16,7 +16,7 @@ import pytest
import torch
from pytest_mock import MockerFixture
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.data import OmniDiffusionConfig, TransformerConfig
# Mock the OvisImageTransformer2DModel to avoid complex init if needed,
diff --git a/tests/e2e/offline_inference/test_quantization_fp8.py b/tests/e2e/offline_inference/test_quantization_fp8.py
index 291779fd9..92cf351e3 100644
--- a/tests/e2e/offline_inference/test_quantization_fp8.py
+++ b/tests/e2e/offline_inference/test_quantization_fp8.py
@@ -36,8 +36,8 @@ from typing import Any
import pytest
import torch
-from tests.conftest import OmniRunner
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
diff --git a/tests/e2e/offline_inference/test_qwen2_5_omni.py b/tests/e2e/offline_inference/test_qwen2_5_omni.py
index 4c4315aab..8ea41b007 100644
--- a/tests/e2e/offline_inference/test_qwen2_5_omni.py
+++ b/tests/e2e/offline_inference/test_qwen2_5_omni.py
@@ -2,46 +2,39 @@
E2E tests for Qwen2.5-Omni model with mixed modality inputs, audio and text output.
"""
-from pathlib import Path
-
import pytest
-from tests.conftest import (
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import (
generate_synthetic_audio,
generate_synthetic_image,
generate_synthetic_video,
- modify_stage_config,
)
-from tests.utils import hardware_test
+from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.platforms import current_omni_platform
models = ["Qwen/Qwen2.5-Omni-7B"]
+# Single CI deploy YAML; rocm/xpu deltas are picked automatically via the
+# platforms: section. NPU still uses the legacy per-platform YAML until it
+# also migrates to the new schema.
+_CI_DEPLOY = get_deploy_config_path("ci/qwen2_5_omni.yaml")
+
def get_cuda_graph_config():
- path = modify_stage_config(
- str(Path(__file__).parent.parent / "stage_configs" / "qwen2_5_omni_ci.yaml"),
+ return modify_stage_config(
+ _CI_DEPLOY,
updates={
- "stage_args": {
- 0: {
- "engine_args.enforce_eager": "true",
- },
- 1: {"engine_args.enforce_eager": "true"},
+ "stages": {
+ 0: {"enforce_eager": True},
+ 1: {"enforce_eager": True},
},
},
)
- return path
-# CI stage config optimized for 24GB GPU (L4/RTX3090) or NPU
-if current_omni_platform.is_npu():
- stage_config = str(Path(__file__).parent / "stage_configs" / "npu" / "qwen2_5_omni_ci.yaml")
-elif current_omni_platform.is_rocm():
- # ROCm stage config optimized for MI325 GPU
- stage_config = str(Path(__file__).parent.parent / "stage_configs" / "rocm" / "qwen2_5_omni_ci.yaml")
-elif current_omni_platform.is_xpu():
- # Intel XPU stage config optimized for B60 GPU
- stage_config = str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen2_5_omni_ci.yaml")
+if current_omni_platform.is_rocm() or current_omni_platform.is_xpu() or current_omni_platform.is_npu():
+ stage_config = _CI_DEPLOY
else:
stage_config = get_cuda_graph_config()
diff --git a/tests/e2e/offline_inference/test_qwen3_omni.py b/tests/e2e/offline_inference/test_qwen3_omni.py
index cc0af437e..c4d257b51 100644
--- a/tests/e2e/offline_inference/test_qwen3_omni.py
+++ b/tests/e2e/offline_inference/test_qwen3_omni.py
@@ -7,41 +7,35 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
-from pathlib import Path
-
import pytest
-from tests.conftest import (
- generate_synthetic_video,
- modify_stage_config,
-)
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import generate_synthetic_video
+from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.platforms import current_omni_platform
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
+# Single CI deploy YAML; rocm/xpu deltas are picked automatically via the
+# platforms: section. Only CUDA needs an extra enforce_eager tweak.
+_CI_DEPLOY = get_deploy_config_path("ci/qwen3_omni_moe.yaml")
+
+
def get_cuda_graph_config():
- path = modify_stage_config(
- str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml"),
+ return modify_stage_config(
+ _CI_DEPLOY,
updates={
- "stage_args": {
- 0: {
- "engine_args.enforce_eager": "true",
- },
- 1: {"engine_args.enforce_eager": "true"},
+ "stages": {
+ 0: {"enforce_eager": True},
+ 1: {"enforce_eager": True},
},
},
)
- return path
-# CI stage config for 2xH100-80G GPUs or AMD GPU MI325
-if current_omni_platform.is_rocm():
- # ROCm stage config optimized for MI325 GPU
- stage_configs = [str(Path(__file__).parent.parent / "stage_configs" / "rocm" / "qwen3_omni_ci.yaml")]
-elif current_omni_platform.is_xpu():
- stage_configs = [str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen3_omni_ci.yaml")]
+if current_omni_platform.is_rocm() or current_omni_platform.is_xpu():
+ stage_configs = [_CI_DEPLOY]
else:
stage_configs = [get_cuda_graph_config()]
diff --git a/tests/e2e/offline_inference/test_qwen3_tts_base.py b/tests/e2e/offline_inference/test_qwen3_tts_base.py
index be7bd50a3..af2b5195b 100644
--- a/tests/e2e/offline_inference/test_qwen3_tts_base.py
+++ b/tests/e2e/offline_inference/test_qwen3_tts_base.py
@@ -13,12 +13,10 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
-from pathlib import Path
-
import pytest
-from tests.conftest import modify_stage_config
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
REF_AUDIO_URL = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone_2.wav"
@@ -26,23 +24,31 @@ REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know wh
def get_cuda_graph_config():
- path = modify_stage_config(
- get_stage_config(),
+ """Build a temp deploy yaml mirroring the deleted qwen3_tts_no_async_chunk.yaml.
+
+ Composes the synchronous (no-async-chunk) variant on top of the bundled
+ qwen3_tts.yaml prod default, with cudagraphs disabled. Replaces the deleted
+ standalone variant yaml; same effective config, no checked-in file needed.
+ """
+ return modify_stage_config(
+ get_deploy_config_path("qwen3_tts.yaml"),
updates={
- "stage_args": {
+ "async_chunk": False,
+ "stages": {
0: {
- "engine_args.enforce_eager": "true",
+ "max_num_seqs": 1,
+ "gpu_memory_utilization": 0.2,
+ "enforce_eager": True,
+ "async_scheduling": False,
+ },
+ 1: {
+ "gpu_memory_utilization": 0.2,
+ "enforce_eager": True,
+ "async_scheduling": False,
},
- 1: {"engine_args.enforce_eager": "true"},
},
},
)
- return path
-
-
-def get_stage_config(name: str = "qwen3_tts_no_async_chunk.yaml"):
- """Get the no_async_chunk stage config path (async_chunk disable, cuda_graph disabled)."""
- return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
# Same structure as test_qwen3_omni: models, stage_configs, test_params
diff --git a/tests/e2e/offline_inference/test_qwen3_tts_customvoice.py b/tests/e2e/offline_inference/test_qwen3_tts_customvoice.py
index 67d72df90..3214541af 100644
--- a/tests/e2e/offline_inference/test_qwen3_tts_customvoice.py
+++ b/tests/e2e/offline_inference/test_qwen3_tts_customvoice.py
@@ -13,34 +13,40 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
-from pathlib import Path
-
import pytest
-from tests.conftest import modify_stage_config
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
def get_cuda_graph_config():
- path = modify_stage_config(
- get_stage_config(),
+ """Build a temp deploy yaml mirroring the deleted qwen3_tts_no_async_chunk.yaml.
+
+ Composes the synchronous (no-async-chunk) variant on top of the bundled
+ qwen3_tts.yaml prod default, with cudagraphs disabled. Replaces the deleted
+ standalone variant yaml; same effective config, no checked-in file needed.
+ """
+ return modify_stage_config(
+ get_deploy_config_path("qwen3_tts.yaml"),
updates={
- "stage_args": {
+ "async_chunk": False,
+ "stages": {
0: {
- "engine_args.enforce_eager": "true",
+ "max_num_seqs": 1,
+ "gpu_memory_utilization": 0.2,
+ "enforce_eager": True,
+ "async_scheduling": False,
+ },
+ 1: {
+ "gpu_memory_utilization": 0.2,
+ "enforce_eager": True,
+ "async_scheduling": False,
},
- 1: {"engine_args.enforce_eager": "true"},
},
},
)
- return path
-
-
-def get_stage_config(name: str = "qwen3_tts_no_async_chunk.yaml"):
- """Get the no_async_chunk stage config path (async_chunk disable, cuda_graph disabled)."""
- return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
# Same structure as test_qwen3_omni: models, stage_configs, test_params
diff --git a/tests/e2e/offline_inference/test_qwen_image_diffusion_batching.py b/tests/e2e/offline_inference/test_qwen_image_diffusion_batching.py
index f0b0b55c9..2ce113d5b 100644
--- a/tests/e2e/offline_inference/test_qwen_image_diffusion_batching.py
+++ b/tests/e2e/offline_inference/test_qwen_image_diffusion_batching.py
@@ -36,8 +36,8 @@ from pathlib import Path
import pytest
import torch
-from tests.conftest import OmniRunner
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
diff --git a/tests/e2e/offline_inference/test_sequence_parallel.py b/tests/e2e/offline_inference/test_sequence_parallel.py
index d3abccd78..9f76b3b75 100644
--- a/tests/e2e/offline_inference/test_sequence_parallel.py
+++ b/tests/e2e/offline_inference/test_sequence_parallel.py
@@ -20,8 +20,8 @@ import torch
import torch.distributed as dist
from PIL import Image
-from tests.conftest import OmniRunner
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
diff --git a/tests/e2e/offline_inference/test_stable_audio_expansion.py b/tests/e2e/offline_inference/test_stable_audio_expansion.py
index 54c1799e1..2fc07eb24 100644
--- a/tests/e2e/offline_inference/test_stable_audio_expansion.py
+++ b/tests/e2e/offline_inference/test_stable_audio_expansion.py
@@ -15,8 +15,8 @@ import numpy as np
import pytest
import torch
-from tests.conftest import assert_audio_valid
-from tests.utils import hardware_test
+from tests.helpers.assertions import assert_audio_valid
+from tests.helpers.mark import hardware_test
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
diff --git a/tests/e2e/offline_inference/test_t2i_model.py b/tests/e2e/offline_inference/test_t2i_model.py
index fc54f9a7f..702f902cd 100644
--- a/tests/e2e/offline_inference/test_t2i_model.py
+++ b/tests/e2e/offline_inference/test_t2i_model.py
@@ -1,7 +1,7 @@
import pytest
import torch
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
diff --git a/tests/e2e/offline_inference/test_t2v_model.py b/tests/e2e/offline_inference/test_t2v_model.py
index 6fe623cfc..cedc9e59b 100644
--- a/tests/e2e/offline_inference/test_t2v_model.py
+++ b/tests/e2e/offline_inference/test_t2v_model.py
@@ -3,7 +3,7 @@ import os
import pytest
import torch
-from tests.conftest import OmniRunner
+from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
diff --git a/tests/e2e/offline_inference/test_teacache.py b/tests/e2e/offline_inference/test_teacache.py
index 7cd1c5a47..8152792fc 100644
--- a/tests/e2e/offline_inference/test_teacache.py
+++ b/tests/e2e/offline_inference/test_teacache.py
@@ -11,8 +11,8 @@ It uses minimal settings to keep test time short for CI.
import pytest
import torch
-from tests.conftest import OmniRunner
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
diff --git a/tests/e2e/offline_inference/test_vae_decode_parallelism.py b/tests/e2e/offline_inference/test_vae_decode_parallelism.py
index 0fce28d66..32902c318 100644
--- a/tests/e2e/offline_inference/test_vae_decode_parallelism.py
+++ b/tests/e2e/offline_inference/test_vae_decode_parallelism.py
@@ -18,7 +18,7 @@ if str(REPO_ROOT) not in sys.path:
import time
-from tests.conftest import OmniRunner
+from tests.helpers.runtime import OmniRunner
from vllm_omni.platforms import current_omni_platform
# os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "1"
diff --git a/tests/e2e/offline_inference/test_voxcpm.py b/tests/e2e/offline_inference/test_voxcpm.py
new file mode 100644
index 000000000..bda087612
--- /dev/null
+++ b/tests/e2e/offline_inference/test_voxcpm.py
@@ -0,0 +1,156 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+"""E2E test for VoxCPM offline inference."""
+
+import json
+import os
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import pytest
+import torch
+
+import tests.helpers.runtime as omni_runtime
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
+from vllm_omni.model_executor.models.voxcpm.voxcpm_runtime_utils import (
+ prepare_voxcpm_hf_config_dir,
+ resolve_voxcpm_model_dir,
+)
+
+VOXCPM_MODEL = os.environ.get("VOXCPM_MODEL", "OpenBMB/VoxCPM1.5")
+STAGE_CONFIG = str(
+ Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm.yaml"
+)
+SAMPLE_RATE = 24000
+
+
+@pytest.fixture(autouse=True)
+def _patch_npu_cleanup_for_voxcpm(monkeypatch: pytest.MonkeyPatch):
+ """Limit the NPU cleanup workaround to this VoxCPM test module only."""
+ original_cleanup = omni_runtime.cleanup_dist_env_and_memory
+
+ def _safe_cleanup() -> None:
+ try:
+ original_cleanup()
+ except RuntimeError as exc:
+ if "Allocator for npu is not a DeviceAllocator" in str(exc):
+ return
+ raise
+
+ monkeypatch.setattr(omni_runtime, "cleanup_dist_env_and_memory", _safe_cleanup)
+
+
+def _build_prompt(text: str) -> dict[str, Any]:
+ return {
+ "prompt_token_ids": [1],
+ "additional_information": {
+ "text": [text],
+ "cfg_value": [2.0],
+ "inference_timesteps": [10],
+ "min_len": [2],
+ "max_new_tokens": [1024],
+ },
+ }
+
+
+def _extract_audio_tensor(multimodal_output: dict[str, Any]) -> torch.Tensor:
+ audio = multimodal_output.get("audio", multimodal_output.get("model_outputs"))
+ assert audio is not None, f"No audio output found, keys={list(multimodal_output.keys())}"
+
+ if isinstance(audio, list):
+ parts: list[torch.Tensor] = []
+ for item in audio:
+ if item is None:
+ continue
+ tensor = torch.as_tensor(item)
+ if tensor.numel() == 0:
+ continue
+ parts.append(tensor.float().cpu().reshape(-1))
+ return torch.cat(parts, dim=-1) if parts else torch.zeros((0,), dtype=torch.float32)
+
+ return torch.as_tensor(audio).float().cpu().reshape(-1)
+
+
+def _extract_final_multimodal_output(outputs) -> dict[str, Any]:
+ for item in reversed(outputs):
+ request_output = getattr(item, "request_output", None)
+ if request_output is not None:
+ multimodal_output = getattr(request_output, "multimodal_output", None)
+ if isinstance(multimodal_output, dict):
+ return multimodal_output
+ completions = getattr(request_output, "outputs", None) or []
+ for completion in completions:
+ multimodal_output = getattr(completion, "multimodal_output", None)
+ if isinstance(multimodal_output, dict):
+ return multimodal_output
+
+ multimodal_output = getattr(item, "multimodal_output", None)
+ if isinstance(multimodal_output, dict):
+ return multimodal_output
+
+ raise AssertionError("No multimodal audio output found in VoxCPM generate results")
+
+
+@pytest.fixture
+def voxcpm_model_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> str:
+ model_dir = resolve_voxcpm_model_dir(VOXCPM_MODEL)
+
+ hf_config_env = os.environ.get("VLLM_OMNI_VOXCPM_HF_CONFIG_PATH")
+ if hf_config_env:
+ hf_config_dir = Path(hf_config_env).expanduser()
+ else:
+ hf_config_dir = tmp_path / "voxcpm_hf_config"
+
+ if not (hf_config_dir / "config.json").exists():
+ prepare_voxcpm_hf_config_dir(model_dir, hf_config_dir)
+
+ monkeypatch.setenv("VLLM_OMNI_VOXCPM_HF_CONFIG_PATH", str(hf_config_dir))
+ return str(model_dir)
+
+
+def test_prepare_voxcpm_hf_config_dir(tmp_path: Path):
+ model_dir = tmp_path / "model"
+ model_dir.mkdir()
+ (model_dir / "config.json").write_text(json.dumps({"hidden_size": 1024}), encoding="utf-8")
+ (model_dir / "generation_config.json").write_text(json.dumps({"do_sample": False}), encoding="utf-8")
+
+ hf_config_dir = prepare_voxcpm_hf_config_dir(model_dir, tmp_path / "voxcpm_hf_config")
+
+ prepared_config = json.loads((hf_config_dir / "config.json").read_text(encoding="utf-8"))
+ assert prepared_config["model_type"] == "voxcpm"
+ assert prepared_config["architectures"] == ["VoxCPMForConditionalGeneration"]
+ assert (hf_config_dir / "generation_config.json").exists()
+
+
+def test_resolve_voxcpm_model_dir_local_path(tmp_path: Path):
+ model_dir = tmp_path / "OpenBMB" / "VoxCPM1.5"
+ model_dir.mkdir(parents=True)
+
+ assert resolve_voxcpm_model_dir(str(model_dir)) == model_dir
+
+
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "L4"}, num_cards=1)
+def test_voxcpm_zero_shot_001(voxcpm_model_path: str):
+ with OmniRunner(voxcpm_model_path, stage_configs_path=STAGE_CONFIG) as runner:
+ outputs = list(runner.omni.generate(_build_prompt("Hello, this is a VoxCPM offline inference test.")))
+
+ assert outputs, "No outputs returned"
+
+ multimodal_output = _extract_final_multimodal_output(outputs)
+ audio = _extract_audio_tensor(multimodal_output)
+ assert audio.numel() > SAMPLE_RATE // 2, f"Audio too short: {audio.numel()} samples"
+
+ duration_s = audio.shape[0] / SAMPLE_RATE
+ assert 0.5 < duration_s < 30.0, f"Audio duration out of range: {duration_s:.2f}s"
+
+ peak = float(torch.max(torch.abs(audio)).item()) if audio.numel() > 0 else 0.0
+ assert peak > 0.01, "Generated audio appears to be silence"
+
+ audio_np = audio.numpy()
+ rms = float(np.sqrt(np.mean(np.square(audio_np)))) if audio_np.size else 0.0
+ assert rms > 1e-4, "Generated audio RMS too low"
diff --git a/tests/e2e/offline_inference/test_voxcpm2.py b/tests/e2e/offline_inference/test_voxcpm2.py
index 4e4f635d5..913a6969d 100644
--- a/tests/e2e/offline_inference/test_voxcpm2.py
+++ b/tests/e2e/offline_inference/test_voxcpm2.py
@@ -5,27 +5,19 @@ import os
import pytest
import torch
-from tests.conftest import OmniRunner
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
+from tests.helpers.stage_config import get_deploy_config_path
VOXCPM2_MODEL = "openbmb/VoxCPM2"
-STAGE_CONFIG = os.path.join(
- os.path.dirname(__file__),
- "..",
- "..",
- "..",
- "vllm_omni",
- "model_executor",
- "stage_configs",
- "voxcpm2.yaml",
-)
+DEPLOY_CONFIG = get_deploy_config_path("voxcpm2.yaml")
SAMPLE_RATE = 48000
@pytest.fixture(scope="module")
def voxcpm2_engine():
"""Create VoxCPM2 engine for testing."""
- with OmniRunner(VOXCPM2_MODEL, stage_configs_path=STAGE_CONFIG) as runner:
+ with OmniRunner(VOXCPM2_MODEL, stage_configs_path=DEPLOY_CONFIG) as runner:
yield runner.omni
@@ -33,14 +25,16 @@ def _extract_audio(multimodal_output: dict) -> torch.Tensor:
"""Extract the final complete audio tensor from multimodal output."""
assert isinstance(multimodal_output, dict), f"Expected dict, got {type(multimodal_output)}"
- # Output processor accumulates per-step full audio under "audio".
- audio = multimodal_output.get("audio") or multimodal_output.get("model_outputs")
+ # Output processor accumulates per-step audio chunks under "audio".
+ audio = multimodal_output.get("audio")
+ if audio is None:
+ audio = multimodal_output.get("model_outputs")
assert audio is not None, f"No audio key, got {list(multimodal_output.keys())}"
if isinstance(audio, list):
- valid = [x for x in audio if isinstance(x, torch.Tensor) and x.numel() > 100]
+ valid = [torch.as_tensor(x).float().cpu().reshape(-1) for x in audio if x is not None]
assert valid, "No valid audio tensors in output list"
- audio = valid[-1]
+ audio = torch.cat(valid, dim=0) if len(valid) > 1 else valid[0]
assert isinstance(audio, torch.Tensor), f"Expected Tensor, got {type(audio)}"
return audio
@@ -98,3 +92,31 @@ def test_voxcpm2_voice_clone_002(voxcpm2_engine):
audio = _extract_audio(outputs[0].outputs[0].multimodal_output)
duration_s = audio.shape[0] / SAMPLE_RATE
assert 0.5 < duration_s < 30.0, f"Audio duration out of range: {duration_s:.2f}s"
+
+
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "L4"}, num_cards=1)
+def test_voxcpm2_prefill_decode_mixed_batch_003(voxcpm2_engine):
+ """Regression: prefill+decode mixed batch must not crash (PR #2903)."""
+ long_prompt = (
+ "This is a deliberately long prompt that will stay in the decode "
+ "phase for many steps so that subsequent shorter prompts keep "
+ "entering prefill alongside it, reproducing the prefill plus "
+ "decode mixed batch scheduling pattern."
+ )
+ short_prompts = [
+ "Hello one.",
+ "Hello two.",
+ "Hello three.",
+ "Hello four.",
+ ]
+ requests = [{"prompt": long_prompt}] + [{"prompt": p} for p in short_prompts]
+
+ outputs = voxcpm2_engine.generate(requests)
+ assert len(outputs) == len(requests)
+
+ for i, out in enumerate(outputs):
+ audio = _extract_audio(out.outputs[0].multimodal_output)
+ duration_s = audio.shape[0] / SAMPLE_RATE
+ assert 0.1 < duration_s < 30.0, f"Request {i} audio duration out of range: {duration_s:.2f}s"
diff --git a/tests/e2e/offline_inference/test_voxtral_tts.py b/tests/e2e/offline_inference/test_voxtral_tts.py
index 4f440f243..5386e2db4 100644
--- a/tests/e2e/offline_inference/test_voxtral_tts.py
+++ b/tests/e2e/offline_inference/test_voxtral_tts.py
@@ -29,14 +29,13 @@ from mistral_common.protocol.speech.request import SpeechRequest
from mistral_common.tokens.tokenizers.mistral import MistralTokenizer
from vllm import SamplingParams
-from tests.conftest import OmniRunner, modify_stage_config
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
+from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.entrypoints.async_omni import AsyncOmni
MODEL = "mistralai/Voxtral-4B-TTS-2603"
-STAGE_CONFIG = str(
- Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / "voxtral_tts.yaml"
-)
+STAGE_CONFIG = get_deploy_config_path("voxtral_tts.yaml")
SAMPLE_RATE = 24000
# Minimum expected audio samples for a short sentence (~0.04s of 24kHz audio)
MIN_AUDIO_SAMPLES = 1000
@@ -65,9 +64,9 @@ def _resolve_stage_config(run_level: str) -> str:
return modify_stage_config(
STAGE_CONFIG,
deletes={
- "stage_args": {
- 0: ["engine_args.load_format"],
- 1: ["engine_args.load_format"],
+ "stages": {
+ 0: ["load_format"],
+ 1: ["load_format"],
}
},
)
diff --git a/tests/e2e/offline_inference/test_zimage_parallelism.py b/tests/e2e/offline_inference/test_zimage_parallelism.py
index 27edc48f2..ab330ee9a 100644
--- a/tests/e2e/offline_inference/test_zimage_parallelism.py
+++ b/tests/e2e/offline_inference/test_zimage_parallelism.py
@@ -20,8 +20,9 @@ import pytest
import torch
from PIL import Image
-from tests.conftest import OmniRunner
-from tests.utils import DeviceMemoryMonitor, hardware_test
+from tests.helpers.env import DeviceMemoryMonitor
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -213,7 +214,10 @@ def test_zimage_tensor_parallel_tp2(tmp_path: Path):
)
-@pytest.mark.integration
+@pytest.mark.advanced_model
+@pytest.mark.diffusion
+@pytest.mark.parallel
+@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
def test_zimage_vae_patch_parallel_tp2(tmp_path: Path):
if current_omni_platform.is_npu():
pytest.skip("Z-Image VAE patch parallel e2e test is only supported on CUDA and ROCm for now.")
diff --git a/tests/e2e/online_serving/test_bagel_expansion.py b/tests/e2e/online_serving/test_bagel_expansion.py
index e2d75e0d1..aa289c6d9 100644
--- a/tests/e2e/online_serving/test_bagel_expansion.py
+++ b/tests/e2e/online_serving/test_bagel_expansion.py
@@ -16,13 +16,8 @@ assert_diffusion_response validates successful generation and the expected
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
- dummy_messages_from_mix_data,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
PROMPT = "A futuristic city skyline at twilight, cyberpunk style, ultra-detailed, high resolution."
NEGATIVE_PROMPT = "low quality, blurry, distorted, deformed, watermark"
@@ -88,7 +83,7 @@ def _get_diffusion_feature_cases(model: str):
],
),
id="parallel_tp_2",
- marks=PARALLEL_FEATURE_MARKS,
+ marks=[*PARALLEL_FEATURE_MARKS, pytest.mark.skip(reason="issue: #2862")],
),
# Ulysses-SP degree=2 (2 GPUs)
pytest.param(
@@ -138,7 +133,7 @@ def test_bagel(
- Ulysses-SP (degree=2)
- Ring-Attention (degree=2)
- Validation is delegated to assert_diffusion_response in tests.conftest,
+ Validation is delegated to assert_diffusion_response in tests/helpers/assertions.py,
which checks output dimensions and basic correctness.
"""
diff --git a/tests/e2e/online_serving/test_bagel_online.py b/tests/e2e/online_serving/test_bagel_online.py
index a3f999f13..745e9a116 100644
--- a/tests/e2e/online_serving/test_bagel_online.py
+++ b/tests/e2e/online_serving/test_bagel_online.py
@@ -28,8 +28,8 @@ from pathlib import Path
import pytest
from vllm.assets.image import ImageAsset
-from tests.conftest import OmniServerParams
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServerParams
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
diff --git a/tests/e2e/online_serving/test_cosyvoice3_tts.py b/tests/e2e/online_serving/test_cosyvoice3_tts.py
index 276b1782f..76e5e4f49 100644
--- a/tests/e2e/online_serving/test_cosyvoice3_tts.py
+++ b/tests/e2e/online_serving/test_cosyvoice3_tts.py
@@ -12,12 +12,11 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
-from pathlib import Path
-
import pytest
-from tests.conftest import OmniServerParams
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServerParams
+from tests.helpers.stage_config import get_deploy_config_path
MODEL = "FunAudioLLM/Fun-CosyVoice3-0.5B-2512"
@@ -27,8 +26,8 @@ REF_TEXT = "希望你以后能够做的比我还好呦。"
def get_stage_config(name: str = "cosyvoice3.yaml"):
- """Get the stage config path from vllm_omni model_executor stage_configs."""
- return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
+ """Get the deploy config path for CosyVoice3."""
+ return get_deploy_config_path(name)
def get_prompt(prompt_type="zh"):
@@ -44,7 +43,7 @@ tts_server_params = [
OmniServerParams(
model=MODEL,
stage_config_path=get_stage_config(),
- server_args=["--trust-remote-code", "--disable-log-stats"],
+ server_args=["--trust-remote-code", "--disable-log-stats", "--no-async-chunk"],
),
id="cosyvoice3",
)
@@ -54,7 +53,7 @@ tts_async_chunk_server_params = [
pytest.param(
OmniServerParams(
model=MODEL,
- stage_config_path=get_stage_config("cosyvoice3_async_chunk.yaml"),
+ stage_config_path=get_stage_config(),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="cosyvoice3_async_chunk",
@@ -93,7 +92,7 @@ def test_voice_clone_zh_001(omni_server, openai_client) -> None:
def test_voice_clone_zh_002(omni_server, openai_client) -> None:
"""
Test voice cloning TTS with Chinese text via async_chunk streaming.
- Deploy Setting: cosyvoice3_async_chunk.yaml
+ Deploy Setting: cosyvoice3.yaml with default ``async_chunk: true``
Input Modal: text + ref_audio + ref_text
Output Modal: audio (streamed)
Input Setting: stream=True
diff --git a/tests/e2e/online_serving/test_dynin_omni_expansion.py b/tests/e2e/online_serving/test_dynin_omni_expansion.py
index 710c480f0..b63fcbd75 100644
--- a/tests/e2e/online_serving/test_dynin_omni_expansion.py
+++ b/tests/e2e/online_serving/test_dynin_omni_expansion.py
@@ -15,9 +15,9 @@ import pytest
import soundfile as sf
from vllm.assets.image import ImageAsset
-from tests import conftest as tests_conftest
-from tests.conftest import OmniServerParams
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import convert_audio_bytes_to_text
+from tests.helpers.runtime import OmniServerParams
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
@@ -87,7 +87,7 @@ def _convert_audio_bytes_to_text_without_ffmpeg(raw_bytes: bytes) -> str:
@pytest.fixture
def dynin_t2s_openai_client(openai_client, monkeypatch):
monkeypatch.setattr(
- tests_conftest,
+ convert_audio_bytes_to_text,
"convert_audio_bytes_to_text",
_convert_audio_bytes_to_text_without_ffmpeg,
)
diff --git a/tests/e2e/online_serving/test_flux2_expansion.py b/tests/e2e/online_serving/test_flux2_expansion.py
index 336bd83a1..ce06ad564 100644
--- a/tests/e2e/online_serving/test_flux2_expansion.py
+++ b/tests/e2e/online_serving/test_flux2_expansion.py
@@ -11,12 +11,8 @@ Coverage:
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
FOUR_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "L4"}, num_cards=4)
POSITIVE_PROMPT = "A cat sitting on a windowsill"
diff --git a/tests/e2e/online_serving/test_flux2_klein_inpaint_expansion.py b/tests/e2e/online_serving/test_flux2_klein_inpaint_expansion.py
new file mode 100644
index 000000000..0c45bb33f
--- /dev/null
+++ b/tests/e2e/online_serving/test_flux2_klein_inpaint_expansion.py
@@ -0,0 +1,160 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+"""
+End-to-end tests for Flux2 Klein inpainting in online serving mode.
+
+Uses /v1/images/edits endpoint which is the correct API for image inpainting.
+"""
+
+import base64
+from io import BytesIO
+
+import httpx
+import pytest
+from PIL import Image, ImageDraw
+
+from tests.helpers.runtime import OmniServer, OmniServerParams
+
+MODEL = "black-forest-labs/FLUX.2-klein-4B"
+
+_HEIGHT = 512
+_WIDTH = 512
+_NUM_INFERENCE_STEPS = 4
+
+
+def _get_diffusion_feature_cases(model: str):
+ return [
+ pytest.param(
+ OmniServerParams(
+ model=model,
+ server_args=["--tensor-parallel-size", "2"],
+ ),
+ id="tp2_basic",
+ ),
+ ]
+
+
+def _image_to_base64_jpeg(image: Image.Image) -> str:
+ buffer = BytesIO()
+ image.save(buffer, format="JPEG")
+ buffer.seek(0)
+ return base64.b64encode(buffer.read()).decode("utf-8")
+
+
+def _create_test_mask_base64(width: int = _WIDTH, height: int = _HEIGHT) -> str:
+ mask = Image.new("L", (width, height), 0)
+ draw = ImageDraw.Draw(mask)
+ draw.rectangle([width // 4, height // 4, width * 3 // 4, height * 3 // 4], fill=255)
+ return _image_to_base64_jpeg(mask)
+
+
+def _compare_images(img1: Image.Image, img2: Image.Image) -> bool:
+ return list(img1.getdata()) == list(img2.getdata())
+
+
+def _send_edit_request(host: str, port: int, model: str, image_b64: str, mask_b64: str, prompt: str, **kwargs):
+ url = f"http://{host}:{port}/v1/images/edits"
+ files = {
+ "image": ("image.jpg", base64.b64decode(image_b64), "image/jpeg"),
+ "mask_image": ("mask.jpg", base64.b64decode(mask_b64), "image/jpeg"),
+ }
+ data = {"prompt": prompt, "model": model, **kwargs}
+ with httpx.Client(timeout=60.0) as client:
+ response = client.post(url, files=files, data=data)
+ response.raise_for_status()
+ return response.json()
+
+
+@pytest.mark.advanced_model
+@pytest.mark.diffusion
+@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True)
+def test_flux2_klein_inpaint_basic(omni_server: OmniServer):
+ input_image_b64 = _image_to_base64_jpeg(Image.new("RGB", (_WIDTH, _HEIGHT), (128, 128, 128)))
+ mask_b64 = _create_test_mask_base64()
+
+ result = _send_edit_request(
+ host=omni_server.host,
+ port=omni_server.port,
+ model=MODEL,
+ image_b64=input_image_b64,
+ mask_b64=mask_b64,
+ prompt="Fill in the masked area with a beautiful garden",
+ guidance_scale=1.0,
+ num_inference_steps=_NUM_INFERENCE_STEPS,
+ n=1,
+ seed=42,
+ )
+
+ assert "data" in result and len(result["data"]) == 1
+ img_data = result["data"][0].get("b64_json") or result["data"][0].get("url", "").split(",")[-1]
+ img = Image.open(BytesIO(base64.b64decode(img_data)))
+ assert img.size == (_WIDTH, _HEIGHT)
+
+
+@pytest.mark.advanced_model
+@pytest.mark.diffusion
+@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True)
+def test_flux2_klein_inpaint_deterministic(omni_server: OmniServer):
+ input_image_b64 = _image_to_base64_jpeg(Image.new("RGB", (_WIDTH, _HEIGHT), (128, 128, 128)))
+ mask_b64 = _create_test_mask_base64()
+ prompt = "A red flower in a field"
+
+ result1 = _send_edit_request(
+ host=omni_server.host,
+ port=omni_server.port,
+ model=MODEL,
+ image_b64=input_image_b64,
+ mask_b64=mask_b64,
+ prompt=prompt,
+ guidance_scale=1.0,
+ num_inference_steps=_NUM_INFERENCE_STEPS,
+ n=1,
+ seed=12345,
+ )
+
+ result2 = _send_edit_request(
+ host=omni_server.host,
+ port=omni_server.port,
+ model=MODEL,
+ image_b64=input_image_b64,
+ mask_b64=mask_b64,
+ prompt=prompt,
+ guidance_scale=1.0,
+ num_inference_steps=_NUM_INFERENCE_STEPS,
+ n=1,
+ seed=12345,
+ )
+
+ img1_data = result1["data"][0].get("b64_json") or result1["data"][0].get("url", "").split(",")[-1]
+ img2_data = result2["data"][0].get("b64_json") or result2["data"][0].get("url", "").split(",")[-1]
+
+ img1 = Image.open(BytesIO(base64.b64decode(img1_data)))
+ img2 = Image.open(BytesIO(base64.b64decode(img2_data)))
+
+ assert _compare_images(img1, img2), (
+ "Same input with same seed should produce identical output. This is critical for offline/online consistency."
+ )
+
+
+@pytest.mark.advanced_model
+@pytest.mark.diffusion
+@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True)
+def test_flux2_klein_inpaint_multiple_outputs(omni_server: OmniServer):
+ input_image_b64 = _image_to_base64_jpeg(Image.new("RGB", (_WIDTH, _HEIGHT), (128, 128, 128)))
+ mask_b64 = _create_test_mask_base64()
+
+ result = _send_edit_request(
+ host=omni_server.host,
+ port=omni_server.port,
+ model=MODEL,
+ image_b64=input_image_b64,
+ mask_b64=mask_b64,
+ prompt="A beautiful landscape",
+ guidance_scale=1.0,
+ num_inference_steps=_NUM_INFERENCE_STEPS,
+ n=2,
+ seed=42,
+ )
+
+ assert "data" in result and len(result["data"]) == 2
diff --git a/tests/e2e/online_serving/test_flux_2_dev_expansion.py b/tests/e2e/online_serving/test_flux_2_dev_expansion.py
index f7477ed80..addf6f002 100644
--- a/tests/e2e/online_serving/test_flux_2_dev_expansion.py
+++ b/tests/e2e/online_serving/test_flux_2_dev_expansion.py
@@ -14,13 +14,8 @@ image resolution.
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
- dummy_messages_from_mix_data,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
MODEL = "black-forest-labs/FLUX.2-dev"
PROMPT = "A cinematic mountain landscape at sunrise, dramatic clouds, ultra-detailed, realistic photography."
diff --git a/tests/e2e/online_serving/test_flux_kontext_expansion.py b/tests/e2e/online_serving/test_flux_kontext_expansion.py
index c13e1e818..574bb3db4 100644
--- a/tests/e2e/online_serving/test_flux_kontext_expansion.py
+++ b/tests/e2e/online_serving/test_flux_kontext_expansion.py
@@ -5,13 +5,8 @@ and are supported by the FluxKontext model.
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
- dummy_messages_from_mix_data,
- generate_synthetic_image,
-)
+from tests.helpers.media import generate_synthetic_image
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
EDIT_PROMPT = "Transform this modern, geometrist image into a Vincent van Gogh style impressionist painting."
NEGATIVE_PROMPT = "blurry, low quality, modern, geometrist"
diff --git a/tests/e2e/online_serving/test_hunyuan_video_15_expansion.py b/tests/e2e/online_serving/test_hunyuan_video_15_expansion.py
index de950edb9..7af4c4344 100644
--- a/tests/e2e/online_serving/test_hunyuan_video_15_expansion.py
+++ b/tests/e2e/online_serving/test_hunyuan_video_15_expansion.py
@@ -11,12 +11,8 @@ Coverage (H100, since model cannot fit L4):
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
PROMPT = "A cat walking across a sunlit garden, cinematic lighting, slow motion."
NEGATIVE_PROMPT = "low quality, blurry, distorted"
diff --git a/tests/e2e/online_serving/test_image_gen_edit.py b/tests/e2e/online_serving/test_image_gen_edit.py
index 7db740f20..56747abd1 100644
--- a/tests/e2e/online_serving/test_image_gen_edit.py
+++ b/tests/e2e/online_serving/test_image_gen_edit.py
@@ -22,7 +22,7 @@ from PIL import Image
from vllm.assets.image import ImageAsset
from vllm.utils.network_utils import get_open_port
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
# Increase timeout for downloading assets from S3 (default 5s is too short for CI)
diff --git a/tests/e2e/online_serving/test_images_generations_lora.py b/tests/e2e/online_serving/test_images_generations_lora.py
index fb1e3ea1e..931a57287 100644
--- a/tests/e2e/online_serving/test_images_generations_lora.py
+++ b/tests/e2e/online_serving/test_images_generations_lora.py
@@ -22,8 +22,8 @@ import torch
from PIL import Image
from safetensors.torch import save_file
-from tests.conftest import OmniServer
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServer
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
diff --git a/tests/e2e/online_serving/test_longcat_image_edit_expansion.py b/tests/e2e/online_serving/test_longcat_image_edit_expansion.py
index 8a2cfbcc1..9a96280f2 100644
--- a/tests/e2e/online_serving/test_longcat_image_edit_expansion.py
+++ b/tests/e2e/online_serving/test_longcat_image_edit_expansion.py
@@ -13,14 +13,9 @@ This validates:
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
- dummy_messages_from_mix_data,
- generate_synthetic_image,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.media import generate_synthetic_image
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
EDIT_PROMPT = "Transform this modern image into a cinematic animation style with vibrant colors and soft lighting."
NEGATIVE_PROMPT = "blurry, low quality, distorted, oversaturated"
diff --git a/tests/e2e/online_serving/test_longcat_image_expansion.py b/tests/e2e/online_serving/test_longcat_image_expansion.py
index 161e7cd2e..7d12db6a2 100644
--- a/tests/e2e/online_serving/test_longcat_image_expansion.py
+++ b/tests/e2e/online_serving/test_longcat_image_expansion.py
@@ -13,13 +13,8 @@ This validates:
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
- dummy_messages_from_mix_data,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
TEXT_TO_IMAGE_PROMPT = (
"A cinematic illustration of a cat typing on a silver laptop, soft window light, highly detailed."
diff --git a/tests/e2e/online_serving/test_mimo_audio.py b/tests/e2e/online_serving/test_mimo_audio.py
index 43eeb7733..df00c6416 100644
--- a/tests/e2e/online_serving/test_mimo_audio.py
+++ b/tests/e2e/online_serving/test_mimo_audio.py
@@ -9,13 +9,10 @@ from pathlib import Path
import pytest
-from tests.conftest import (
- OmniServerParams,
- dummy_messages_from_mix_data,
- generate_synthetic_audio,
- modify_stage_config,
-)
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import generate_synthetic_audio
+from tests.helpers.runtime import OmniServerParams, dummy_messages_from_mix_data
+from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.model_executor.model_loader.weight_utils import (
download_weights_from_hf_specific,
)
@@ -29,26 +26,6 @@ os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
models = ["XiaomiMiMo/MiMo-Audio-7B-Instruct"]
-def get_chunk_config():
- path = modify_stage_config(
- str(Path(__file__).parent.parent / "stage_configs" / "mimo_audio_ci.yaml"),
- updates={
- "async_chunk": True,
- "stage_args": {
- 0: {
- "engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.mimo_audio.llm2code2wav_async_chunk"
- },
- 1: {
- "engine_args.max_model_len": 8192,
- "engine_args.max_num_batched_tokens": 8192,
- },
- },
- },
- deletes={"stage_args": {1: ["custom_process_input_func"]}},
- )
- return path
-
-
def download_tokenizer():
tokenizer_path = os.environ.get("MIMO_AUDIO_TOKENIZER_PATH", MIMO_AUDIO_TOKENIZER_REPO)
if os.path.exists(tokenizer_path):
@@ -62,19 +39,21 @@ def download_tokenizer():
return local_path
-# CI stage config for H100 / MI325
# Guard module-level setup so test collection doesn't fail in environments
# where the model cache is read-only or models aren't available.
try:
- stage_configs = [get_chunk_config()]
+ stage_configs = [get_deploy_config_path("mimo_audio.yaml")]
tokenizer_path = download_tokenizer()
os.environ["MIMO_AUDIO_TOKENIZER_PATH"] = tokenizer_path
+ # --load-format dummy applies to every stage pipeline-wide, avoiding a
+ # per-stage yaml rewrite (the old approach wrote a tempfile + atexit-unlink
+ # which raced with CI's process lifecycle).
test_params = [
OmniServerParams(
model=model,
stage_config_path=stage_config,
- server_args=["--chat-template", CHAT_TEMPLATE_PATH],
+ server_args=["--chat-template", CHAT_TEMPLATE_PATH, "--load-format", "dummy"],
)
for model in models
for stage_config in stage_configs
diff --git a/tests/e2e/online_serving/test_ming_flash_omni.py b/tests/e2e/online_serving/test_ming_flash_omni.py
new file mode 100644
index 000000000..8161c4389
--- /dev/null
+++ b/tests/e2e/online_serving/test_ming_flash_omni.py
@@ -0,0 +1,246 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""
+E2E online serving tests for Ming-flash-omni-2.0 model (Thinker stage).
+Tests multimodal understanding via OpenAI-compatible API.
+"""
+
+import os
+from pathlib import Path
+
+import pytest
+
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import (
+ generate_synthetic_audio,
+ generate_synthetic_image,
+ generate_synthetic_video,
+)
+from tests.helpers.runtime import OmniServerParams, dummy_messages_from_mix_data
+from tests.helpers.stage_config import modify_stage_config
+
+os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
+os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
+
+models = ["Jonathan1909/Ming-flash-omni-2.0"]
+
+
+def get_eager_config():
+ path = modify_stage_config(
+ str(Path(__file__).parent.parent / "stage_configs" / "bailingmm_moe_v2_lite_ci.yaml"),
+ updates={
+ "stage_args": {
+ 0: {
+ "engine_args.enforce_eager": "true",
+ },
+ },
+ },
+ )
+ return path
+
+
+stage_configs = [get_eager_config()]
+
+# Create parameter combinations for model and stage config
+test_params = [
+ OmniServerParams(model=model, stage_config_path=stage_config) for model in models for stage_config in stage_configs
+]
+
+
+def get_system_prompt():
+ return {
+ "role": "system",
+ "content": [
+ {
+ "type": "text",
+ "text": "你是一个友好的AI助手。\n\ndetailed thinking off",
+ }
+ ],
+ }
+
+
+def get_prompt(prompt_type="text_only"):
+ prompts = {
+ "text_only": "What is the capital of China? Answer in 20 words.",
+ "text_image": "What is in this image?",
+ "text_audio": "What is in this audio?",
+ "text_video": "What is in this video?",
+ "mix": "What is recited in the audio? What is in this image? What is in this video?",
+ }
+ return prompts.get(prompt_type, prompts["text_only"])
+
+
+def get_max_batch_size(size_type="few"):
+ batch_sizes = {"few": 5, "medium": 100, "large": 256}
+ return batch_sizes.get(size_type, 5)
+
+
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "H100"}, num_cards=4)
+@pytest.mark.parametrize("omni_server", test_params, indirect=True)
+def test_text_to_text_001(omni_server, openai_client) -> None:
+ """
+ Input Modal: text
+ Output Modal: text
+ Input Setting: stream=False
+ Datasets: single request
+ """
+ messages = dummy_messages_from_mix_data(
+ system_prompt=get_system_prompt(),
+ content_text=get_prompt("text_only"),
+ )
+
+ request_config = {
+ "model": omni_server.model,
+ "messages": messages,
+ "stream": False,
+ "modalities": ["text"],
+ "key_words": {"text": ["beijing"]},
+ }
+
+ openai_client.send_omni_request(request_config)
+
+
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "H100"}, num_cards=4)
+@pytest.mark.parametrize("omni_server", test_params, indirect=True)
+def test_text_to_text_stream_001(omni_server, openai_client) -> None:
+ """
+ Input Modal: text
+ Output Modal: text
+ Input Setting: stream=True
+ Datasets: few requests
+ """
+ messages = dummy_messages_from_mix_data(
+ system_prompt=get_system_prompt(),
+ content_text=get_prompt("text_only"),
+ )
+
+ request_config = {
+ "model": omni_server.model,
+ "messages": messages,
+ "stream": True,
+ "modalities": ["text"],
+ "key_words": {"text": ["beijing"]},
+ }
+
+ openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
+
+
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "H100"}, num_cards=4)
+@pytest.mark.parametrize("omni_server", test_params, indirect=True)
+def test_image_to_text_001(omni_server, openai_client) -> None:
+ """
+ Input Modal: image + text
+ Output Modal: text
+ Input Setting: stream=True
+ Datasets: single request
+ """
+ image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}"
+ messages = dummy_messages_from_mix_data(
+ system_prompt=get_system_prompt(),
+ image_data_url=image_data_url,
+ content_text=get_prompt("text_image"),
+ )
+
+ request_config = {
+ "model": omni_server.model,
+ "messages": messages,
+ "stream": True,
+ "modalities": ["text"],
+ }
+
+ openai_client.send_omni_request(request_config)
+
+
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "H100"}, num_cards=4)
+@pytest.mark.parametrize("omni_server", test_params, indirect=True)
+def test_audio_to_text_001(omni_server, openai_client) -> None:
+ """
+ Input Modal: audio + text
+ Output Modal: text
+ Input Setting: stream=True
+ Datasets: single request
+ """
+ audio_data_url = f"data:audio/wav;base64,{generate_synthetic_audio(2, 1)['base64']}"
+ messages = dummy_messages_from_mix_data(
+ system_prompt=get_system_prompt(),
+ audio_data_url=audio_data_url,
+ content_text=get_prompt("text_audio"),
+ )
+
+ request_config = {
+ "model": omni_server.model,
+ "messages": messages,
+ "stream": True,
+ "modalities": ["text"],
+ }
+
+ openai_client.send_omni_request(request_config)
+
+
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "H100"}, num_cards=4)
+@pytest.mark.parametrize("omni_server", test_params, indirect=True)
+def test_video_to_text_001(omni_server, openai_client) -> None:
+ """
+ Input Modal: video + text
+ Output Modal: text
+ Input Setting: stream=False
+ Datasets: single request
+ """
+ video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300)['base64']}"
+ messages = dummy_messages_from_mix_data(
+ system_prompt=get_system_prompt(),
+ video_data_url=video_data_url,
+ content_text=get_prompt("text_video"),
+ )
+
+ request_config = {
+ "model": omni_server.model,
+ "messages": messages,
+ "stream": False,
+ "modalities": ["text"],
+ }
+
+ openai_client.send_omni_request(request_config)
+
+
+@pytest.mark.advanced_model
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "H100"}, num_cards=4)
+@pytest.mark.parametrize("omni_server", test_params, indirect=True)
+def test_mix_to_text_001(omni_server, openai_client) -> None:
+ """
+ Input Modal: text + audio + image + video
+ Output Modal: text
+ Input Setting: stream=True
+ Datasets: single request
+ """
+ video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300)['base64']}"
+ image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}"
+ audio_data_url = f"data:audio/wav;base64,{generate_synthetic_audio(2, 1)['base64']}"
+ messages = dummy_messages_from_mix_data(
+ system_prompt=get_system_prompt(),
+ video_data_url=video_data_url,
+ image_data_url=image_data_url,
+ audio_data_url=audio_data_url,
+ content_text=get_prompt("mix"),
+ )
+
+ request_config = {
+ "model": omni_server.model,
+ "messages": messages,
+ "stream": True,
+ "modalities": ["text"],
+ }
+
+ openai_client.send_omni_request(request_config)
diff --git a/tests/e2e/online_serving/test_nextstep_expansion.py b/tests/e2e/online_serving/test_nextstep_expansion.py
new file mode 100644
index 000000000..004a0967b
--- /dev/null
+++ b/tests/e2e/online_serving/test_nextstep_expansion.py
@@ -0,0 +1,71 @@
+"""
+Online serving E2E for NextStep-1.1 text-to-image (tensor parallel).
+"""
+
+import os
+
+import pytest
+
+from tests.helpers.mark import hardware_marks
+from tests.helpers.runtime import (
+ OmniServer,
+ OmniServerParams,
+ OpenAIClientHandler,
+ dummy_messages_from_mix_data,
+)
+
+# L4: 4 GPUs + TP=4; XPU B60: 2 cards (use num_cards={"cuda": 4, "xpu": 4} if needed)
+FOUR_CARD_MARKS = hardware_marks(
+ res={"cuda": "L4", "xpu": "B60"},
+ num_cards={"cuda": 2, "xpu": 2},
+)
+
+POSITIVE_PROMPT = "A small red barn in a snowy field, simple illustration."
+NEGATIVE_PROMPT = "blurry, low quality"
+
+_DEFAULT_MODEL = "stepfun-ai/NextStep-1.1"
+
+
+def _get_diffusion_feature_cases(model: str):
+ """Single online config: TP=4, explicit pipeline class."""
+ return [
+ pytest.param(
+ OmniServerParams(
+ model=model,
+ server_args=[
+ "--tensor-parallel-size",
+ "2",
+ "--model-class-name",
+ "NextStep11Pipeline",
+ ],
+ ),
+ id="nextstep_tp4_pipeline",
+ marks=FOUR_CARD_MARKS,
+ ),
+ ]
+
+
+@pytest.mark.advanced_model
+@pytest.mark.diffusion
+@pytest.mark.parametrize(
+ "omni_server",
+ _get_diffusion_feature_cases(model=os.environ.get("VLLM_TEST_NEXTSTEP_MODEL", _DEFAULT_MODEL)),
+ indirect=True,
+)
+def test_nextstep_11(omni_server: OmniServer, openai_client: OpenAIClientHandler):
+ messages = dummy_messages_from_mix_data(content_text=POSITIVE_PROMPT)
+ request_config = {
+ "model": omni_server.model,
+ "messages": messages,
+ "extra_body": {
+ "height": 512,
+ "width": 512,
+ "num_inference_steps": 2,
+ "guidance_scale": 5.0,
+ "guidance_scale_2": 1.0,
+ "negative_prompt": NEGATIVE_PROMPT,
+ "seed": 42,
+ },
+ }
+
+ openai_client.send_diffusion_request(request_config)
diff --git a/tests/e2e/online_serving/test_omnivoice.py b/tests/e2e/online_serving/test_omnivoice.py
index 4a0069f40..892896e05 100644
--- a/tests/e2e/online_serving/test_omnivoice.py
+++ b/tests/e2e/online_serving/test_omnivoice.py
@@ -17,8 +17,9 @@ from pathlib import Path
import httpx
import pytest
-from tests.conftest import OmniServerParams, generate_synthetic_audio
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import generate_synthetic_audio
+from tests.helpers.runtime import OmniServerParams
try:
from transformers import HiggsAudioV2TokenizerModel # noqa: F401
diff --git a/tests/e2e/online_serving/test_qwen2_5_omni.py b/tests/e2e/online_serving/test_qwen2_5_omni.py
index e2913ce02..8a1a8eb99 100644
--- a/tests/e2e/online_serving/test_qwen2_5_omni.py
+++ b/tests/e2e/online_serving/test_qwen2_5_omni.py
@@ -3,20 +3,13 @@ E2E Online tests for Qwen2.5-Omni model with video input and audio output.
"""
import os
-from pathlib import Path
import pytest
-from tests.conftest import (
- OmniServerParams,
- dummy_messages_from_mix_data,
- generate_synthetic_audio,
- generate_synthetic_image,
- generate_synthetic_video,
- modify_stage_config,
-)
-from tests.utils import hardware_test
-from vllm_omni.platforms import current_omni_platform
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video
+from tests.helpers.runtime import OmniServerParams, dummy_messages_from_mix_data
+from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
@@ -24,20 +17,9 @@ os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
models = ["Qwen/Qwen2.5-Omni-7B"]
-
-def get_config():
- path = modify_stage_config(
- str(Path(__file__).parent.parent / "stage_configs" / "qwen2_5_omni_ci.yaml"),
- )
- return path
-
-
-# CI stage config for 2xH100-80G GPUs or AMD GPU MI325
-if current_omni_platform.is_rocm():
- # ROCm stage config optimized for MI325 GPU
- stage_configs = [str(Path(__file__).parent.parent / "stage_configs" / "rocm" / "qwen2_5_omni_ci.yaml")]
-else:
- stage_configs = [get_config()]
+# Single CI deploy YAML; rocm/xpu deltas are picked automatically via the
+# platforms: section in vllm_omni/deploy/ci/qwen2_5_omni.yaml.
+stage_configs = [modify_stage_config(get_deploy_config_path("ci/qwen2_5_omni.yaml"))]
# Create parameter combinations for model and stage config
test_params = [
diff --git a/tests/e2e/online_serving/test_qwen3_omni.py b/tests/e2e/online_serving/test_qwen3_omni.py
index f4aabb8b9..7d1a181d2 100644
--- a/tests/e2e/online_serving/test_qwen3_omni.py
+++ b/tests/e2e/online_serving/test_qwen3_omni.py
@@ -3,19 +3,13 @@ E2E Online tests for Qwen3-Omni model with video input and audio output.
"""
import os
-from pathlib import Path
import pytest
-from tests.conftest import (
- OmniServerParams,
- dummy_messages_from_mix_data,
- generate_synthetic_audio,
- generate_synthetic_image,
- generate_synthetic_video,
- modify_stage_config,
-)
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video
+from tests.helpers.runtime import OmniServerParams, dummy_messages_from_mix_data
+from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.platforms import current_omni_platform
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
@@ -24,35 +18,64 @@ os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
+# Set VLLM_TEST_PD_MODE=1 to test PD disaggregation (follow-up — deploy overlay not yet migrated).
+_USE_PD = os.environ.get("VLLM_TEST_PD_MODE", "0") == "1"
-def get_chunk_config():
+_CI_DEPLOY = get_deploy_config_path("ci/qwen3_omni_moe.yaml")
+
+
+def get_chunk_config(config_path: str | None = None):
+ """Load the qwen3_omni CI deploy yaml with async_chunk modifications for streaming mode."""
+ if config_path is None:
+ config_path = _CI_DEPLOY
+ # TODO: remove this workaround once legacy `stage_args` path is deleted.
+ # The pipeline (qwen3_omni/pipeline.py) already wires
+ # thinker2talker_async_chunk / talker2code2wav_async_chunk on stage 0/1,
+ # so only async_chunk needs flipping. Writing nested `engine_args:` into
+ # the new-schema overlay trips _parse_stage_deploy's legacy branch and
+ # drops flat fields (load_format, max_num_seqs, ...).
+ return modify_stage_config(config_path, updates={"async_chunk": True})
+
+
+def get_prefix_caching_config(config_path: str):
+ """Create a stage config with prefix caching enabled on the thinker (stage 0)."""
path = modify_stage_config(
- str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml"),
+ config_path,
updates={
- "async_chunk": True,
"stage_args": {
- 0: {
- "engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk"
- },
- 1: {
- "engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_async_chunk"
- },
+ 0: {"engine_args.enable_prefix_caching": True},
},
},
- deletes={"stage_args": {2: ["custom_process_input_func"]}},
)
return path
+# Platform-specific overrides live inside the new deploy yaml's ``platforms:``
+# section, so a single ``_CI_DEPLOY`` path serves CUDA, ROCm, and XPU.
+# TODO: re-add VLLM_TEST_PD_MODE branch once the PD-disaggregation deploy
+# overlay has been migrated to the new schema (previously used the deleted
+# ``qwen3_omni_moe_pd_ci.yaml`` stage-configs file).
if current_omni_platform.is_xpu():
- stage_configs = [str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen3_omni_ci.yaml")]
-else: # MI325 GPU should share the same config as H100
+ stage_configs = [_CI_DEPLOY]
+else: # CUDA + ROCm MI325 share the same deploy config
stage_configs = [get_chunk_config()]
+prefix_caching_stage_configs = [get_prefix_caching_config(_CI_DEPLOY)]
# Create parameter combinations for model and stage config
test_params = [
OmniServerParams(model=model, stage_config_path=stage_config) for model in models for stage_config in stage_configs
]
+# For prefix caching, we need to enable prompt token details so that we
+# can determine if any tokens were cached.
+prefix_test_params = [
+ OmniServerParams(
+ model=model,
+ stage_config_path=stage_config,
+ server_args=["--enable-prompt-tokens-details"], # Enable prompt tokens details to get cached_tokens
+ )
+ for model in models
+ for stage_config in prefix_caching_stage_configs
+]
def get_system_prompt():
@@ -75,6 +98,7 @@ def get_prompt(prompt_type="text_only"):
prompts = {
"text_only": "What is the capital of China? Answer in 20 words.",
"mix": "What is recited in the audio? What is in this image? Describe the video briefly.",
+ "text_image": "What color are the squares in this image?",
}
return prompts.get(prompt_type, prompts["text_only"])
@@ -87,7 +111,8 @@ def get_max_batch_size(size_type="few"):
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
-@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
+@pytest.mark.skipif(_USE_PD, reason="Temporarily skip PD mode in this test module.")
+@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=3 if _USE_PD else 2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
"""
@@ -126,7 +151,8 @@ def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
-@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
+@pytest.mark.skipif(_USE_PD, reason="Temporarily skip PD mode in this test module.")
+@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=3 if _USE_PD else 2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_text_001(omni_server, openai_client) -> None:
"""
@@ -147,3 +173,42 @@ def test_text_to_text_001(omni_server, openai_client) -> None:
}
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
+
+
+@pytest.mark.advanced_model
+@pytest.mark.core_model
+@pytest.mark.omni
+@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
+@pytest.mark.parametrize("omni_server", prefix_test_params, indirect=True)
+@pytest.mark.skip(reason="issue: #2833")
+def test_thinker_prefix_caching(omni_server, openai_client) -> None:
+ """
+ Test thinker prefix caching by sending identical requests with an image (i.e.,
+ a large shared prefix) and verifying that the second request uses cached tokens
+ & produces the same output.
+ """
+ image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}"
+ messages = dummy_messages_from_mix_data(
+ system_prompt=get_system_prompt(),
+ image_data_url=image_data_url,
+ content_text=get_prompt("text_image"),
+ )
+
+ request_config = {
+ "model": omni_server.model,
+ "messages": messages,
+ "stream": False,
+ "modalities": ["text"],
+ }
+
+ response_1 = openai_client.send_omni_request(request_config, request_num=1)[0]
+ response_2 = openai_client.send_omni_request(request_config, request_num=1)[0]
+
+ assert response_1.success
+ assert response_2.success
+ assert response_2.cached_tokens is not None
+ # We should cache the vast majority of the prompt (image + up to last full block),
+ # and set seed in the CI config, so the second request should give an identical
+ # response for the generated input image, even if we use dummy weights
+ assert response_2.cached_tokens > 0
+ assert response_1.text_content == response_2.text_content
diff --git a/tests/e2e/online_serving/test_qwen3_omni_expansion.py b/tests/e2e/online_serving/test_qwen3_omni_expansion.py
index 306543908..6be2ebb2a 100644
--- a/tests/e2e/online_serving/test_qwen3_omni_expansion.py
+++ b/tests/e2e/online_serving/test_qwen3_omni_expansion.py
@@ -6,22 +6,14 @@ E2E Online tests for Qwen3-Omni model.
import os
-from vllm_omni.platforms import current_omni_platform
-
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
-from pathlib import Path
import pytest
-from tests.conftest import (
- OmniServerParams,
- dummy_messages_from_mix_data,
- generate_synthetic_audio,
- generate_synthetic_image,
- generate_synthetic_video,
- modify_stage_config,
-)
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video
+from tests.helpers.runtime import OmniServerParams, dummy_messages_from_mix_data
+from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
model = "Qwen/Qwen3-Omni-30B-A3B-Instruct"
@@ -40,47 +32,57 @@ LARGE_IMAGE_HEIGHT = 1080
LONG_AUDIO_DURATION_SEC = 120
-def get_chunk_config(default_path):
- path = modify_stage_config(
- default_path,
- updates={
- "async_chunk": True,
- "stage_args": {
- 0: {
- "engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk",
- "default_sampling_params.max_tokens": 2048,
- },
- 1: {
- "engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_async_chunk"
- },
- },
- },
- deletes={"stage_args": {2: ["custom_process_input_func"]}},
- )
- return path
-
-
def get_batch_token_config(default_path):
- path = modify_stage_config(
+ """Override stage 1's max_num_batched_tokens to exercise small-batch paths.
+
+ Uses the new flat-stage schema (``stages..``); the legacy
+ ``stage_args..engine_args.`` path no longer applies because
+ the deploy YAML doesn't nest engine fields under ``engine_args:``.
+ """
+ return modify_stage_config(
default_path,
updates={
- "stage_args": {1: {"engine_args.max_num_batched_tokens": 64}},
+ "stages": {1: {"max_num_batched_tokens": 64}},
},
)
- return path
-# CI stage config for 2*H100-80G GPUs
-default_path = str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml")
+def get_async_chunk_config(default_path):
+ """Flip async_chunk on and bump stage 0 thinker output to 2048 tokens.
-if current_omni_platform.is_xpu():
- default_path = str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen3_omni_ci.yaml")
+ Pipeline registry (qwen3_omni/pipeline.py) already wires
+ thinker2talker_async_chunk / talker2code2wav_async_chunk on stages 0/1,
+ so no per-stage processor override is needed. Using only flat-schema
+ writes so _parse_stage_deploy stays in its flat branch (nested
+ ``engine_args:`` would drop other overlay fields).
+ """
+ return modify_stage_config(
+ default_path,
+ updates={
+ "stages": {0: {"default_sampling_params.max_tokens": 2048}},
+ },
+ )
+
+
+# CI deploy YAML (single file; xpu deltas applied via ``platforms:`` section).
+# The overlay explicitly sets ``async_chunk: False``, so ``default`` tests the
+# sync path and ``async_chunk`` tests the streaming path with a longer thinker
+# output — two distinct scenarios, kept as separate parametrizations.
+default_path = get_deploy_config_path("ci/qwen3_omni_moe.yaml")
-# Create parameter combinations for model and stage config
test_params = [
- pytest.param(OmniServerParams(model=model, stage_config_path=default_path, use_stage_cli=True), id="default"),
pytest.param(
- OmniServerParams(model=model, stage_config_path=get_chunk_config(default_path), use_stage_cli=True),
+ OmniServerParams(
+ model=model, stage_config_path=default_path, use_stage_cli=True, server_args=["--no-async-chunk"]
+ ),
+ id="default",
+ ),
+ pytest.param(
+ OmniServerParams(
+ model=model,
+ stage_config_path=get_async_chunk_config(default_path),
+ use_stage_cli=True,
+ ),
id="async_chunk",
),
]
@@ -371,6 +373,7 @@ def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
+@pytest.mark.skip(reason="issue: #2827")
def test_audio_in_video_001(omni_server, openai_client) -> None:
"""
Input Modal: text + video (synthetic MP4 with embedded audio; ``use_audio_in_video`` uses audio from the video).
@@ -447,7 +450,7 @@ def test_one_word_prompt_001(omni_server, openai_client) -> None:
"key_words": {"text": ["london"]},
}
- # Retry only when assert_omni_response fails on text/audio cosine similarity (see tests/conftest.py).
+ # Retry only when assert_omni_response fails on text/audio cosine similarity (see tests/helpers/assertions.py).
_similarity_assert_msg = "The audio content is not same as the text"
_max_retries = 3
for attempt in range(_max_retries):
@@ -491,6 +494,7 @@ def test_speaker_001(omni_server, openai_client) -> None:
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
+@pytest.mark.skip(reason="Known issue: occasional inaccuracy in voice recognition.")
def test_speaker_002(omni_server, openai_client) -> None:
"""
Input Modal: text only (one-word answer constraint).
@@ -511,7 +515,7 @@ def test_speaker_002(omni_server, openai_client) -> None:
"key_words": {"text": ["beijing"]},
}
- # Retry only when assert_omni_response fails on preset voice gender (see tests/conftest.py).
+ # Retry only when assert_omni_response fails on preset voice gender (see tests/helpers/assertions.py).
_gender_assert_substr = "estimated gender"
_max_retries = 3
for attempt in range(_max_retries):
diff --git a/tests/e2e/online_serving/test_qwen3_omni_realtime_websocket.py b/tests/e2e/online_serving/test_qwen3_omni_realtime_websocket.py
new file mode 100644
index 000000000..f3b261081
--- /dev/null
+++ b/tests/e2e/online_serving/test_qwen3_omni_realtime_websocket.py
@@ -0,0 +1,206 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""
+E2E online tests for Qwen3-Omni /v1/realtime WebSocket (streaming PCM in, audio out).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import io
+import json
+import os
+import wave
+
+import pytest
+import websockets
+
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import (
+ convert_audio_bytes_to_text,
+ cosine_similarity_text,
+ generate_synthetic_audio,
+)
+from tests.helpers.runtime import OmniServerParams
+from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
+
+os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
+
+MODEL = "Qwen/Qwen3-Omni-30B-A3B-Instruct"
+
+# The new-schema CI overlay bakes in async_chunk: False and covers CUDA/ROCm/XPU
+# via its ``platforms:`` section, so one path serves all three.
+default_stage_config = get_deploy_config_path("ci/qwen3_omni_moe.yaml")
+
+
+def _realtime_stage_config_path() -> str:
+ """CI omni layout without async_chunk; stage 0 thinker max_tokens=10."""
+ return modify_stage_config(
+ default_stage_config,
+ updates={"stages": {0: {"default_sampling_params.max_tokens": 10}}},
+ )
+
+
+realtime_server_params = [
+ pytest.param(
+ OmniServerParams(
+ model=MODEL,
+ stage_config_path=_realtime_stage_config_path(),
+ use_stage_cli=True,
+ ),
+ id="thinker_max_tokens_10",
+ ),
+]
+
+
+def _pcm16_mono_16k_from_wav_bytes(wav_bytes: bytes) -> bytes:
+ with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
+ if wf.getnchannels() != 1:
+ raise ValueError(f"Expected mono WAV, got {wf.getnchannels()} channels")
+ if wf.getsampwidth() != 2:
+ raise ValueError(f"Expected 16-bit PCM, sampwidth={wf.getsampwidth()}")
+ if wf.getframerate() != 16000:
+ raise ValueError(f"Expected 16 kHz input for /v1/realtime, got {wf.getframerate()} Hz")
+ if wf.getcomptype() != "NONE":
+ raise ValueError(f"Expected uncompressed PCM, comptype={wf.getcomptype()!r}")
+ return wf.readframes(wf.getnframes())
+
+
+def _wav_bytes_from_pcm16(pcm: bytes, sample_rate_hz: int) -> bytes:
+ buf = io.BytesIO()
+ with wave.open(buf, "wb") as wf:
+ wf.setnchannels(1)
+ wf.setsampwidth(2)
+ wf.setframerate(sample_rate_hz)
+ wf.writeframes(pcm)
+ return buf.getvalue()
+
+
+async def _run_realtime_audio_roundtrip(
+ host: str,
+ port: int,
+ model: str,
+ pcm16: bytes,
+ *,
+ chunk_ms: int = 100,
+) -> dict:
+ uri = f"ws://{host}:{port}/v1/realtime"
+ incremental: list[bytes] = []
+ output_sr = 24000
+ text_chunks: list[str] = []
+ final_text = ""
+ delta_events = 0
+
+ bytes_per_ms = 16000 * 2 // 1000
+ chunk_bytes = max(bytes_per_ms * chunk_ms, 2)
+
+ async with websockets.connect(uri, max_size=64 * 1024 * 1024) as ws:
+ await ws.send(json.dumps({"type": "session.update", "model": model}))
+ await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": False}))
+
+ for i in range(0, len(pcm16), chunk_bytes):
+ chunk = pcm16[i : i + chunk_bytes]
+ await ws.send(
+ json.dumps(
+ {
+ "type": "input_audio_buffer.append",
+ "audio": base64.b64encode(chunk).decode("utf-8"),
+ }
+ )
+ )
+
+ await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
+
+ while True:
+ message = await asyncio.wait_for(ws.recv(), timeout=600)
+ if isinstance(message, bytes):
+ continue
+
+ event = json.loads(message)
+ event_type = event.get("type")
+
+ if event_type == "session.created":
+ continue
+
+ if event_type == "response.audio.delta":
+ delta_events += 1
+ sr = event.get("sample_rate_hz")
+ if isinstance(sr, int) and sr > 0:
+ output_sr = sr
+ audio_b64 = event.get("audio", "")
+ if audio_b64:
+ incremental.append(base64.b64decode(audio_b64))
+ continue
+
+ if event_type == "transcription.delta":
+ d = event.get("delta", "")
+ if d:
+ text_chunks.append(d)
+ continue
+
+ if event_type == "transcription.done":
+ final_text = event.get("text", "") or "".join(text_chunks)
+ continue
+
+ if event_type == "response.audio.done":
+ break
+
+ if event_type == "error":
+ raise AssertionError(f"WebSocket error: {event}")
+
+ raise AssertionError(f"Unexpected WebSocket event: {event}")
+
+ out_pcm = b"".join(incremental)
+ return {
+ "output_pcm": out_pcm,
+ "output_sample_rate": output_sr,
+ "transcription_text": final_text if final_text else "".join(text_chunks),
+ "delta_events": delta_events,
+ }
+
+
+class TestQwen3OmniRealtimeWebSocket:
+ @pytest.mark.advanced_model
+ @pytest.mark.omni
+ @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
+ @pytest.mark.parametrize("omni_server", realtime_server_params, indirect=True)
+ def test_streaming_audio_input_pcm_output(self, omni_server) -> None:
+ """
+ Short streamed 16 kHz mono PCM16 input; expect streamed PCM16 audio deltas and
+ transcription. Verify Whisper(output audio) aligns with model text (same idea
+ as multimodal omni e2e).
+ """
+ syn = generate_synthetic_audio(10, 1, sample_rate=16000)
+ wav_bytes = base64.b64decode(syn["base64"])
+ pcm16 = _pcm16_mono_16k_from_wav_bytes(wav_bytes)
+
+ result = asyncio.run(
+ _run_realtime_audio_roundtrip(
+ omni_server.host,
+ omni_server.port,
+ omni_server.model,
+ pcm16,
+ chunk_ms=100,
+ )
+ )
+
+ out_pcm = result["output_pcm"]
+ assert result["delta_events"] >= 1
+ assert out_pcm, "No output PCM from response.audio.delta"
+ assert len(out_pcm) % 2 == 0
+ assert len(out_pcm) >= 4096, "Output audio unexpectedly small"
+ assert result["output_sample_rate"] > 0
+
+ final_text = (result["transcription_text"] or "").strip()
+ assert final_text, "Expected non-empty transcription (model text stream)"
+
+ wav_out = _wav_bytes_from_pcm16(out_pcm, result["output_sample_rate"])
+ whisper_text = convert_audio_bytes_to_text(wav_out).strip()
+ assert whisper_text, "Whisper returned empty string for synthesized output audio"
+
+ sim = cosine_similarity_text(whisper_text.lower(), final_text.lower())
+ assert sim > 0.9, (
+ f"Output audio transcript should match model text (sim={sim:.3f}): "
+ f"whisper={whisper_text!r}, model_text={final_text!r}"
+ )
diff --git a/tests/e2e/online_serving/test_qwen3_tts_base.py b/tests/e2e/online_serving/test_qwen3_tts_base.py
index 002f9d997..fd7bc43b5 100644
--- a/tests/e2e/online_serving/test_qwen3_tts_base.py
+++ b/tests/e2e/online_serving/test_qwen3_tts_base.py
@@ -12,12 +12,11 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
-from pathlib import Path
-
import pytest
-from tests.conftest import OmniServerParams
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServerParams
+from tests.helpers.stage_config import get_deploy_config_path
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
@@ -25,11 +24,6 @@ REF_AUDIO_URL = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/
REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
-def get_stage_config(name: str = "qwen3_tts.yaml"):
- """Get the stage config path from vllm_omni model_executor stage_configs."""
- return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
-
-
def get_prompt(prompt_type="text"):
"""Text prompt for text-to-audio tests (same as test_qwen3_omni - beijing test case)."""
prompts = {
@@ -48,7 +42,7 @@ tts_server_params = [
pytest.param(
OmniServerParams(
model=MODEL,
- stage_config_path=get_stage_config("qwen3_tts.yaml"),
+ stage_config_path=get_deploy_config_path("qwen3_tts.yaml"),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="async_chunk",
diff --git a/tests/e2e/online_serving/test_qwen3_tts_base_expansion.py b/tests/e2e/online_serving/test_qwen3_tts_base_expansion.py
index 3c33485e4..527eeaef4 100644
--- a/tests/e2e/online_serving/test_qwen3_tts_base_expansion.py
+++ b/tests/e2e/online_serving/test_qwen3_tts_base_expansion.py
@@ -12,12 +12,11 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
-from pathlib import Path
-
import pytest
-from tests.conftest import OmniServerParams
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServerParams
+from tests.helpers.stage_config import get_deploy_config_path
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
@@ -25,11 +24,6 @@ REF_AUDIO_URL = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/
REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
-def get_stage_config(name: str = "qwen3_tts.yaml"):
- """Get the stage config path from vllm_omni model_executor stage_configs."""
- return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
-
-
def get_prompt(prompt_type="text"):
"""Text prompt for text-to-audio tests (same as test_qwen3_omni - beijing test case)."""
prompts = {
@@ -48,16 +42,19 @@ tts_server_params = [
pytest.param(
OmniServerParams(
model=MODEL,
- stage_config_path=get_stage_config("qwen3_tts.yaml"),
+ stage_config_path=get_deploy_config_path("qwen3_tts.yaml"),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="async_chunk",
),
+ # Synchronous (no async-chunk) variant — ``--no-async-chunk`` alone
+ # flips the deploy yaml's bool and the pipeline dispatches to the
+ # end-to-end codec processor. No variant yaml / pipeline needed.
pytest.param(
OmniServerParams(
model=MODEL,
- stage_config_path=get_stage_config("qwen3_tts_no_async_chunk.yaml"),
- server_args=["--trust-remote-code", "--disable-log-stats"],
+ stage_config_path=get_deploy_config_path("qwen3_tts.yaml"),
+ server_args=["--trust-remote-code", "--disable-log-stats", "--no-async-chunk"],
),
id="no_async_chunk",
),
diff --git a/tests/e2e/online_serving/test_qwen3_tts_batch.py b/tests/e2e/online_serving/test_qwen3_tts_batch.py
index 1a453afb7..3ca068819 100644
--- a/tests/e2e/online_serving/test_qwen3_tts_batch.py
+++ b/tests/e2e/online_serving/test_qwen3_tts_batch.py
@@ -22,19 +22,18 @@ import httpx
import pytest
import yaml
-from tests.conftest import (
- OmniServer,
- convert_audio_file_to_text,
- cosine_similarity_text,
-)
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import convert_audio_file_to_text, cosine_similarity_text
+from tests.helpers.runtime import OmniServer
+from tests.helpers.stage_config import get_deploy_config_path
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice"
STAGE_INIT_TIMEOUT_S = 120
-def get_stage_config(name: str = "qwen3_tts.yaml"):
- return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
+def get_stage_config(name: str = "qwen3_tts.yaml") -> str:
+ """Resolve a deploy config path under vllm_omni/deploy/."""
+ return get_deploy_config_path(name)
@pytest.fixture(scope="module")
diff --git a/tests/e2e/online_serving/test_qwen3_tts_customvoice.py b/tests/e2e/online_serving/test_qwen3_tts_customvoice.py
index fb60df725..2577361a0 100644
--- a/tests/e2e/online_serving/test_qwen3_tts_customvoice.py
+++ b/tests/e2e/online_serving/test_qwen3_tts_customvoice.py
@@ -12,21 +12,15 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
-from pathlib import Path
-
import pytest
-from tests.conftest import OmniServerParams
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServerParams
+from tests.helpers.stage_config import get_deploy_config_path
MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
-def get_stage_config(name: str = "qwen3_tts.yaml"):
- """Get the stage config path from vllm_omni model_executor stage_configs."""
- return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
-
-
def get_prompt(prompt_type="text"):
"""Text prompt for text-to-audio tests (same as test_qwen3_omni - beijing test case)."""
prompts = {
@@ -45,7 +39,7 @@ tts_server_params = [
pytest.param(
OmniServerParams(
model=MODEL,
- stage_config_path=get_stage_config("qwen3_tts.yaml"),
+ stage_config_path=get_deploy_config_path("qwen3_tts.yaml"),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="async_chunk",
diff --git a/tests/e2e/online_serving/test_qwen3_tts_customvoice_expansion.py b/tests/e2e/online_serving/test_qwen3_tts_customvoice_expansion.py
index 03a985896..b32303842 100644
--- a/tests/e2e/online_serving/test_qwen3_tts_customvoice_expansion.py
+++ b/tests/e2e/online_serving/test_qwen3_tts_customvoice_expansion.py
@@ -12,21 +12,15 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
-from pathlib import Path
-
import pytest
-from tests.conftest import OmniServerParams
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServerParams
+from tests.helpers.stage_config import get_deploy_config_path
MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
-def get_stage_config(name: str = "qwen3_tts.yaml"):
- """Get the stage config path from vllm_omni model_executor stage_configs."""
- return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
-
-
def get_prompt(prompt_type="english"):
"""Text prompt for text-to-audio tests (same as test_qwen3_omni - beijing test case)."""
prompts = {
@@ -46,16 +40,19 @@ tts_server_params = [
pytest.param(
OmniServerParams(
model=MODEL,
- stage_config_path=get_stage_config("qwen3_tts.yaml"),
+ stage_config_path=get_deploy_config_path("qwen3_tts.yaml"),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="async_chunk",
),
+ # Synchronous (no async-chunk) variant — ``--no-async-chunk`` alone
+ # flips the deploy yaml's bool and the pipeline dispatches to the
+ # end-to-end codec processor. No variant yaml / pipeline needed.
pytest.param(
OmniServerParams(
model=MODEL,
- stage_config_path=get_stage_config("qwen3_tts_no_async_chunk.yaml"),
- server_args=["--trust-remote-code", "--disable-log-stats"],
+ stage_config_path=get_deploy_config_path("qwen3_tts.yaml"),
+ server_args=["--trust-remote-code", "--disable-log-stats", "--no-async-chunk"],
),
id="no_async_chunk",
),
diff --git a/tests/e2e/online_serving/test_qwen3_tts_speaker_embedding.py b/tests/e2e/online_serving/test_qwen3_tts_speaker_embedding.py
index 8c1c86081..b91548c5a 100644
--- a/tests/e2e/online_serving/test_qwen3_tts_speaker_embedding.py
+++ b/tests/e2e/online_serving/test_qwen3_tts_speaker_embedding.py
@@ -13,13 +13,13 @@ os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
import struct
-from pathlib import Path
import httpx
import pytest
-from tests.conftest import OmniServer
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServer
+from tests.helpers.stage_config import get_deploy_config_path
MODEL_BASE = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
MODEL_BASE_1_7B = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
@@ -37,10 +37,8 @@ MIN_AUDIO_BYTES = 2000
MAX_NEW_TOKENS = 256
-def get_stage_config():
- return str(
- Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / "qwen3_tts.yaml"
- )
+def get_stage_config() -> str:
+ return get_deploy_config_path("qwen3_tts.yaml")
def _server_args():
diff --git a/tests/e2e/online_serving/test_qwen3_tts_websocket.py b/tests/e2e/online_serving/test_qwen3_tts_websocket.py
index 849d1c115..5ac021cf8 100644
--- a/tests/e2e/online_serving/test_qwen3_tts_websocket.py
+++ b/tests/e2e/online_serving/test_qwen3_tts_websocket.py
@@ -7,13 +7,13 @@ E2E online tests for Qwen3-TTS WebSocket streaming speech.
import asyncio
import json
import os
-from pathlib import Path
import pytest
import websockets
-from tests.conftest import OmniServer
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServer
+from tests.helpers.stage_config import get_deploy_config_path
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
@@ -23,9 +23,7 @@ STAGE_INIT_TIMEOUT_S = 120
def get_stage_config() -> str:
- return str(
- Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / "qwen3_tts.yaml"
- )
+ return get_deploy_config_path("qwen3_tts.yaml")
@pytest.fixture(scope="module")
diff --git a/tests/e2e/online_serving/test_qwen_image_edit_expansion.py b/tests/e2e/online_serving/test_qwen_image_edit_expansion.py
index 14e4c915b..c2461977d 100644
--- a/tests/e2e/online_serving/test_qwen_image_edit_expansion.py
+++ b/tests/e2e/online_serving/test_qwen_image_edit_expansion.py
@@ -7,14 +7,9 @@ and are supported by the following models:
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
- dummy_messages_from_mix_data,
- generate_synthetic_image,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.media import generate_synthetic_image
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
EDIT_PROMPT = "Transform this modern, geometrist image into a Vincent van Gogh style impressionist painting."
MULTI_EDIT_PROMPT = (
diff --git a/tests/e2e/online_serving/test_qwen_image_expansion.py b/tests/e2e/online_serving/test_qwen_image_expansion.py
index 88e56cc3e..b6f91c13d 100644
--- a/tests/e2e/online_serving/test_qwen_image_expansion.py
+++ b/tests/e2e/online_serving/test_qwen_image_expansion.py
@@ -12,13 +12,8 @@ See docs/user_guide/diffusion_acceleration.md.
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
- dummy_messages_from_mix_data,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
T2I_PROMPT = "A photo of a cat sitting on a laptop keyboard, digital art style."
NEGATIVE_PROMPT = "blurry, low quality"
diff --git a/tests/e2e/online_serving/test_qwen_image_layered_expansion.py b/tests/e2e/online_serving/test_qwen_image_layered_expansion.py
index fc73801c0..b958cfc05 100644
--- a/tests/e2e/online_serving/test_qwen_image_layered_expansion.py
+++ b/tests/e2e/online_serving/test_qwen_image_layered_expansion.py
@@ -14,15 +14,9 @@ Total distinct features covered: cache_dit, Ulysses-SP, CFG-Parallel, layerwise-
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
- decode_b64_image,
- dummy_messages_from_mix_data,
- generate_synthetic_image,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.media import decode_b64_image, generate_synthetic_image
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
MODEL = "Qwen/Qwen-Image-Layered"
EDIT_PROMPT = "Decompose this image into layers."
diff --git a/tests/e2e/online_serving/test_sd3_expansion.py b/tests/e2e/online_serving/test_sd3_expansion.py
index 3ed5cc5f3..37e590ba3 100644
--- a/tests/e2e/online_serving/test_sd3_expansion.py
+++ b/tests/e2e/online_serving/test_sd3_expansion.py
@@ -4,12 +4,8 @@ Tests for Stable Diffusion 3.5 medium model.
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
FOUR_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "L4"}, num_cards=4)
POSITIVE_PROMPT = "A serene mountain landscape at sunset"
diff --git a/tests/e2e/online_serving/test_video_generation_api.py b/tests/e2e/online_serving/test_video_generation_api.py
index 0711a1048..6a8fe4587 100644
--- a/tests/e2e/online_serving/test_video_generation_api.py
+++ b/tests/e2e/online_serving/test_video_generation_api.py
@@ -16,8 +16,8 @@ from typing import Any
import pytest
import requests
-from tests.conftest import OmniServer
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServer
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
diff --git a/tests/e2e/online_serving/test_voxtral_tts.py b/tests/e2e/online_serving/test_voxtral_tts.py
index f795288f3..649f822ba 100644
--- a/tests/e2e/online_serving/test_voxtral_tts.py
+++ b/tests/e2e/online_serving/test_voxtral_tts.py
@@ -12,19 +12,16 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
-from pathlib import Path
-
import httpx
import pytest
-from tests.conftest import OmniServerParams
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.runtime import OmniServerParams
+from tests.helpers.stage_config import get_deploy_config_path
MODEL = "mistralai/Voxtral-4B-TTS-2603"
-STAGE_CONFIG = str(
- Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / "voxtral_tts.yaml"
-)
+STAGE_CONFIG = get_deploy_config_path("voxtral_tts.yaml")
EXTRA_ARGS = ["--trust-remote-code", "--enforce-eager", "--disable-log-stats"]
TEST_PARAMS = [OmniServerParams(model=MODEL, stage_config_path=STAGE_CONFIG, server_args=EXTRA_ARGS)]
diff --git a/tests/e2e/online_serving/test_wan22_expansion.py b/tests/e2e/online_serving/test_wan22_expansion.py
index e5e2d748d..7e5bc9121 100644
--- a/tests/e2e/online_serving/test_wan22_expansion.py
+++ b/tests/e2e/online_serving/test_wan22_expansion.py
@@ -19,13 +19,9 @@ assert_diffusion_response validates successful generation
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
- generate_synthetic_image,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.media import generate_synthetic_image
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
PROMPT = "Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage."
NEGATIVE_PROMPT = "low quality, blurry, distorted face, extra limbs, bad anatomy, watermark, logo, text, ugly, deformed, mutated, jpeg artifacts"
diff --git a/tests/e2e/online_serving/test_wan_2_1_vace_expansion.py b/tests/e2e/online_serving/test_wan_2_1_vace_expansion.py
index 0de70afe8..1f7a8c072 100644
--- a/tests/e2e/online_serving/test_wan_2_1_vace_expansion.py
+++ b/tests/e2e/online_serving/test_wan_2_1_vace_expansion.py
@@ -23,12 +23,8 @@ Coverage:
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
MODEL = "Wan-AI/Wan2.1-VACE-1.3B-diffusers"
PROMPT = "A cat walking slowly across a sunlit garden path"
diff --git a/tests/e2e/online_serving/test_zimage_expansion.py b/tests/e2e/online_serving/test_zimage_expansion.py
index 9f90ec855..679233c82 100644
--- a/tests/e2e/online_serving/test_zimage_expansion.py
+++ b/tests/e2e/online_serving/test_zimage_expansion.py
@@ -13,12 +13,8 @@ exercise Z-Image's supported feature combinations:
import pytest
-from tests.conftest import (
- OmniServer,
- OmniServerParams,
- OpenAIClientHandler,
-)
-from tests.utils import hardware_marks
+from tests.helpers.mark import hardware_marks
+from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
MODEL = "Tongyi-MAI/Z-Image-Turbo"
PROMPT = "A high-detail studio photo of an orange tabby cat sitting on a laptop keyboard."
diff --git a/tests/e2e/stage_configs/bailingmm_moe_v2_lite_ci.yaml b/tests/e2e/stage_configs/bailingmm_moe_v2_lite_ci.yaml
new file mode 100644
index 000000000..fb0c72cc5
--- /dev/null
+++ b/tests/e2e/stage_configs/bailingmm_moe_v2_lite_ci.yaml
@@ -0,0 +1,35 @@
+# Thinker stage only
+stage_args:
+ - stage_id: 0
+ stage_type: llm
+ runtime:
+ devices: "0,1,2,3"
+ max_batch_size: 1
+ engine_args:
+ model_stage: thinker
+ model_arch: MingFlashOmniForConditionalGeneration
+ worker_type: ar
+ scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
+ gpu_memory_utilization: 0.9
+ enforce_eager: false
+ trust_remote_code: true
+ engine_output_type: latent
+ distributed_executor_backend: "mp"
+ enable_prefix_caching: false
+ max_num_batched_tokens: 32768
+ max_model_len: 32768
+ tensor_parallel_size: 4
+ hf_config_name: llm_config
+ load_format: dummy
+ mm_processor_cache_gb: 0
+ final_output: true
+ final_output_type: text
+ is_comprehension: true
+ default_sampling_params:
+ temperature: 0.4
+ top_p: 0.9
+ max_tokens: 100
+ repetition_penalty: 1.05
+ seed: 42
+ detokenize: true
+ ignore_eos: false
diff --git a/tests/e2e/stage_configs/dynin_omni_ci.yaml b/tests/e2e/stage_configs/dynin_omni_ci.yaml
index 024000751..525b7d888 100644
--- a/tests/e2e/stage_configs/dynin_omni_ci.yaml
+++ b/tests/e2e/stage_configs/dynin_omni_ci.yaml
@@ -72,13 +72,8 @@ stage_args:
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
edges:
- from: 0
to: 1
- window_size: -1
- from: 1
to: 2
- window_size: -1
diff --git a/tests/e2e/stage_configs/mimo_audio_ci.yaml b/tests/e2e/stage_configs/mimo_audio_ci.yaml
deleted file mode 100644
index 7127a71b4..000000000
--- a/tests/e2e/stage_configs/mimo_audio_ci.yaml
+++ /dev/null
@@ -1,71 +0,0 @@
-# CI stage config for running MiMo-Audio in multi-stage omni runtime.
-# Based on mimo_audio.yaml with load_format: dummy for CI testing.
-
-async_chunk: false
-stage_args:
- - stage_id: 0
- stage_type: llm
- runtime:
- process: true
- devices: "0"
- engine_args:
- dtype: bfloat16
- max_num_seqs: 1
- model_stage: fused_thinker_talker
- model_arch: MiMoAudioForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- tensor_parallel_size: 1
- gpu_memory_utilization: 0.5
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: latent
- max_model_len: 8192
- max_num_batched_tokens: 8192
- load_format: dummy
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.6
- top_p: 0.95
- top_k: 50
- max_tokens: 18192
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
- - stage_id: 1
- stage_type: llm
- runtime:
- process: true
- devices: "0"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: MiMoAudioForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- tensor_parallel_size: 1
- gpu_memory_utilization: 0.2
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio
- max_model_len: 18192
- max_num_batched_tokens: 18192
- async_scheduling: false
- load_format: dummy
- engine_input_source: [ 0 ]
- is_comprehension: false
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.mimo_audio.llm2code2wav
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 18192
- seed: 42
- detokenize: false
diff --git a/tests/e2e/stage_configs/qwen2_5_omni_ci.yaml b/tests/e2e/stage_configs/qwen2_5_omni_ci.yaml
deleted file mode 100644
index a7c637d48..000000000
--- a/tests/e2e/stage_configs/qwen2_5_omni_ci.yaml
+++ /dev/null
@@ -1,109 +0,0 @@
-# stage config for running qwen2.5-omni for multi-stage omni runtime.
-
-# The following config has been verified on 2x 24GB GPU (L4/RTX3090/RTX4090).
-# This config is optimized for CI e2e tests.
-stage_args:
- - stage_id: 0
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
- engine_args:
- model_stage: thinker
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- max_model_len: 16384
- max_num_batched_tokens: 16384
- max_num_seqs: 1
- gpu_memory_utilization: 0.9
- skip_mm_profiling: true
- enforce_eager: true # Now we only support eager mode
- trust_remote_code: true
- engine_output_type: latent
- enable_prefix_caching: false
- mm_processor_cache_gb: 0
- load_format: dummy
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 128
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
- - stage_id: 1
- runtime:
- process: true
- devices: "1"
- engine_args:
- model_stage: talker
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- max_model_len: 16384
- max_num_batched_tokens: 16384
- max_num_seqs: 1
- gpu_memory_utilization: 0.4
- skip_mm_profiling: true
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: latent
- load_format: dummy
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen2_5_omni.thinker2talker
- default_sampling_params:
- temperature: 0.9
- top_p: 0.8
- top_k: 40
- max_tokens: 4096
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
- stop_token_ids: [8294]
- - stage_id: 2
- runtime:
- process: true
- devices: "2" # Example: use a different GPU than the previous stage; use "0" if single GPU
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- gpu_memory_utilization: 0.5 #increase the gpu memory utilization to enable the test on H800
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio
- max_num_batched_tokens: 8192
- max_model_len: 8192
- load_format: dummy
- engine_input_source: [1]
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 8192
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
-# Top-level runtime config (concise): default windows and stage edges
-runtime:
- enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
- edges:
- - from: 0 # thinker → talker: trigger only after receiving full input (-1)
- to: 1
- window_size: -1
- - from: 1 # talker → code2wav: trigger only after receiving full input (-1)
- to: 2
- window_size: -1
diff --git a/tests/e2e/stage_configs/qwen2_5_omni_thinker_ci.yaml b/tests/e2e/stage_configs/qwen2_5_omni_thinker_ci.yaml
deleted file mode 100644
index 940138284..000000000
--- a/tests/e2e/stage_configs/qwen2_5_omni_thinker_ci.yaml
+++ /dev/null
@@ -1,31 +0,0 @@
-stage_args:
- - stage_id: 0
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
- engine_args:
- model_stage: thinker
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- max_model_len: 16384
- max_num_batched_tokens: 16384
- max_num_seqs: 1
- gpu_memory_utilization: 0.9
- skip_mm_profiling: true
- enforce_eager: true # Now we only support eager mode
- trust_remote_code: true
- engine_output_type: latent
- enable_prefix_caching: false
- mm_processor_cache_gb: 0
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 128
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
diff --git a/tests/e2e/stage_configs/qwen3_omni_ci.yaml b/tests/e2e/stage_configs/qwen3_omni_ci.yaml
deleted file mode 100644
index 08dd49de9..000000000
--- a/tests/e2e/stage_configs/qwen3_omni_ci.yaml
+++ /dev/null
@@ -1,102 +0,0 @@
-# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
-# Stage 0: Thinker (multimodal understanding + text generation)
-# Stage 1: Talker (text embeddings → 16-layer RVQ codec codes)
-# Stage 2: Code2Wav (8-layer RVQ codes → audio waveform)
-
-# The following config has been verified on 2x H100-80G GPUs.
-stage_args:
-- stage_id: 0
- runtime:
- devices: "0"
- engine_args:
- model_stage: thinker
- max_num_seqs: 5
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.9
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output hidden states for talker
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 32768
- max_model_len: 32768
- enable_prefix_caching: false
- mm_processor_cache_gb: 0
- hf_config_name: thinker_config
- tensor_parallel_size: 1
- load_format: dummy
- final_output: true
- final_output_type: text
- is_comprehension: true
- default_sampling_params:
- temperature: 0.4
- top_p: 0.9
- top_k: 1
- max_tokens: 150
- seed: 42
- ignore_eos: False
- detokenize: True
- repetition_penalty: 1.05
-
-- stage_id: 1
- runtime:
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 5
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.5
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output codec codes for code2wav
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- max_model_len: 32768
- distributed_executor_backend: "mp"
- hf_config_name: talker_config
- load_format: dummy
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 1000
- seed: 42
- detokenize: False
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
-- stage_id: 2
- runtime:
- devices: "1"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 5
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio # Final output: audio waveform
- gpu_memory_utilization: 0.1
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 100000
- hf_config_name: thinker_config
- async_scheduling: false
- load_format: dummy
- engine_input_source: [1]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2000
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
diff --git a/tests/e2e/stage_configs/rocm/qwen2_5_omni_ci.yaml b/tests/e2e/stage_configs/rocm/qwen2_5_omni_ci.yaml
deleted file mode 100644
index 0c756ce56..000000000
--- a/tests/e2e/stage_configs/rocm/qwen2_5_omni_ci.yaml
+++ /dev/null
@@ -1,106 +0,0 @@
-# stage config for running qwen2.5-omni for multi-stage omni runtime.
-
-# The following config has been verified on 2x 24GB GPU (L4/RTX3090/RTX4090).
-# This config is optimized for CI e2e tests.
-stage_args:
- - stage_id: 0
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
- engine_args:
- model_stage: thinker
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- max_model_len: 16384
- max_num_batched_tokens: 16384
- max_num_seqs: 1
- gpu_memory_utilization: 0.8
- skip_mm_profiling: true
- enforce_eager: true # Now we only support eager mode
- trust_remote_code: true
- engine_output_type: latent
- enable_prefix_caching: false
- mm_processor_cache_gb: 0
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 128
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
- - stage_id: 1
- runtime:
- process: true
- devices: "1"
- engine_args:
- model_stage: talker
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- max_model_len: 16384
- max_num_batched_tokens: 16384
- max_num_seqs: 1
- gpu_memory_utilization: 0.8
- skip_mm_profiling: true
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: latent
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen2_5_omni.thinker2talker
- default_sampling_params:
- temperature: 0.9
- top_p: 0.8
- top_k: 40
- max_tokens: 4096
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
- stop_token_ids: [8294]
- - stage_id: 2
- runtime:
- process: true
- devices: "0" # Example: use a different GPU than the previous stage; use "0" if single GPU
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- gpu_memory_utilization: 0.15
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio
- max_num_batched_tokens: 4096
- max_model_len: 4096
- engine_input_source: [1]
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 4096
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
-# Top-level runtime config (concise): default windows and stage edges
-runtime:
- enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
- edges:
- - from: 0 # thinker → talker: trigger only after receiving full input (-1)
- to: 1
- window_size: -1
- - from: 1 # talker → code2wav: trigger only after receiving full input (-1)
- to: 2
- window_size: -1
diff --git a/tests/e2e/stage_configs/rocm/qwen3_omni_ci.yaml b/tests/e2e/stage_configs/rocm/qwen3_omni_ci.yaml
deleted file mode 100644
index ac2b1fbd7..000000000
--- a/tests/e2e/stage_configs/rocm/qwen3_omni_ci.yaml
+++ /dev/null
@@ -1,100 +0,0 @@
-# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
-# Stage 0: Thinker (multimodal understanding + text generation)
-# Stage 1: Talker (text embeddings → 16-layer RVQ codec codes)
-# Stage 2: Code2Wav (8-layer RVQ codes → audio waveform)
-
-# The following config has been verified on 2x H100-80G GPUs.
-stage_args:
- - stage_id: 0
- runtime:
- devices: "0"
- engine_args:
- model_stage: thinker
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.9
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output hidden states for talker
- distributed_executor_backend: "mp"
- enable_prefix_caching: false
- mm_processor_cache_gb: 0
- hf_config_name: thinker_config
- tensor_parallel_size: 1
- load_format: dummy
- final_output: true
- final_output_type: text
- is_comprehension: true
- default_sampling_params:
- temperature: 0.4
- top_p: 0.9
- top_k: 1
- max_tokens: 100
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
-
- - stage_id: 1
- runtime:
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.6
- enforce_eager: true
- trust_remote_code: true
- engine_output_type: latent # Output codec codes for code2wav
- # tensor_parallel_size: 2
- enable_prefix_caching: false
- distributed_executor_backend: "mp"
- hf_config_name: talker_config
- load_format: dummy
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
- # final_output: true
- # final_output_type: text
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 100
- seed: 42
- detokenize: False
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 2
- runtime:
- devices: "1"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio # Final output: audio waveform
- gpu_memory_utilization: 0.1
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 1000000
- hf_config_name: thinker_config
- load_format: dummy
- async_scheduling: false
- engine_input_source: [1]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 200
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
diff --git a/tests/e2e/stage_configs/xpu/qwen2_5_omni_ci.yaml b/tests/e2e/stage_configs/xpu/qwen2_5_omni_ci.yaml
deleted file mode 100644
index 14ef3c343..000000000
--- a/tests/e2e/stage_configs/xpu/qwen2_5_omni_ci.yaml
+++ /dev/null
@@ -1,108 +0,0 @@
-# stage config for running qwen2.5-omni for multi-stage omni runtime.
-
-# The following config is verified with 2 * Intel Arc Pro B60 XPU.
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage
- engine_args:
- model_stage: thinker
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- max_model_len: 16384
- max_num_batched_tokens: 16384
- max_num_seqs: 1
- gpu_memory_utilization: 0.9 # thinker weight is around 16.74GB for Qwen2.5-Omni-7B
- skip_mm_profiling: true
- enforce_eager: true
- trust_remote_code: true
- engine_output_type: latent
- enable_prefix_caching: false
- mm_processor_cache_gb: 0
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 128
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
- - stage_id: 1
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true
- devices: "1"
- engine_args:
- model_stage: talker
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- max_model_len: 16384
- max_num_batched_tokens: 16384
- max_num_seqs: 1
- gpu_memory_utilization: 0.5 # talker weight is 6.03GB for Qwen2.5-Omni-7B
- skip_mm_profiling: true
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: latent
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen2_5_omni.thinker2talker
- default_sampling_params:
- temperature: 0.9
- top_p: 0.8
- top_k: 40
- max_tokens: 4096
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
- stop_token_ids: [8294]
-
- - stage_id: 2
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true
- devices: "2"
- engine_args:
- max_num_seqs: 1
- model_stage: code2wav
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- gpu_memory_utilization: 0.3 # code2wav weight is around 1.46GB for Qwen2.5-Omni-7B
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio
- engine_input_source: [1]
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
-# Top-level runtime config (concise): default windows and stage edges
-runtime:
- enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
-
- edges:
- - from: 0 # thinker → talker: trigger only after receiving full input (-1)
- to: 1
- window_size: -1
- - from: 1 # talker → code2wav: trigger only after receiving full input (-1)
- to: 2
- window_size: -1
diff --git a/tests/e2e/stage_configs/xpu/qwen3_omni_ci.yaml b/tests/e2e/stage_configs/xpu/qwen3_omni_ci.yaml
deleted file mode 100644
index c4586e066..000000000
--- a/tests/e2e/stage_configs/xpu/qwen3_omni_ci.yaml
+++ /dev/null
@@ -1,109 +0,0 @@
-# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
-# Stage 0: Thinker (multimodal understanding + text generation)
-# Stage 1: Talker (text embeddings → 8-layer RVQ codec codes)
-# Stage 2: Code2Wav (8-layer RVQ codes → audio waveform)
-
-# The following config is verified with 8 * Intel Arc Pro B60 XPU.
-stage_args:
-- stage_id: 0
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "0,1,2,3"
- engine_args:
- max_num_seqs: 1
- model_stage: thinker
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.85 # thinker weight is around 61.08GB for Qwen3-Omni-30B-A3B-Instruct
- skip_mm_profiling: true
- enforce_eager: true
- trust_remote_code: true
- engine_output_type: latent # Output hidden states for talker
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 4096
- max_model_len: 4096
- enable_prefix_caching: false
- hf_config_name: thinker_config
- tensor_parallel_size: 4
- max_cudagraph_capture_size: 0
- final_output: true
- final_output_type: text
- is_comprehension: true
- default_sampling_params:
- temperature: 0.4
- top_p: 0.9
- top_k: 1
- max_tokens: 100
- seed: 42
- ignore_eos: False
- detokenize: True
- repetition_penalty: 1.05
-
-- stage_id: 1
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "4"
- engine_args:
- max_num_seqs: 1
- model_stage: talker
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.6 # talker weight is around 8.5GB for Qwen3-Omni-30B-A3B-Instruct
- skip_mm_profiling: true
- enforce_eager: true
- trust_remote_code: true
- engine_output_type: latent # Output codec codes for code2wav
- enable_prefix_caching: false
- max_num_batched_tokens: 4096
- max_model_len: 4096
- distributed_executor_backend: "mp"
- hf_config_name: talker_config
- max_cudagraph_capture_size: 0
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
- # final_output: true
- # final_output_type: text
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: False
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
-- stage_id: 2
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "5"
- engine_args:
- max_num_seqs: 1
- model_stage: code2wav
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio # Final output: audio waveform
- gpu_memory_utilization: 0.3 # code2wav weight is around 0.4GB for Qwen3-Omni-30B-A3B-Instruct
- skip_mm_profiling: true
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 100000
- hf_config_name: thinker_config
- async_scheduling: false
- max_cudagraph_capture_size: 0
- engine_input_source: [1]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2000
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
diff --git a/tests/engine/test_arg_utils.py b/tests/engine/test_arg_utils.py
index 35d55f1cc..4d69f24c5 100644
--- a/tests/engine/test_arg_utils.py
+++ b/tests/engine/test_arg_utils.py
@@ -7,6 +7,7 @@ explicitly patch values that differ from vLLM.
import argparse
import inspect
from types import SimpleNamespace
+from unittest.mock import Mock
import pytest
from pydantic import ValidationError
@@ -38,21 +39,28 @@ def test_default_stage_id_is_concrete_int():
assert cfg.stage_id == 0
-def test_multimodal_kwarg_overrides():
+def test_multimodal_kwarg_overrides(mocker):
"""Ensure that overrides in the multimodal config are preserved."""
- # Get a different value than the default for a multimodal field
sig = inspect.signature(OmniEngineArgs)
default_mm_cache = sig.parameters["mm_processor_cache_gb"].default
override_val = default_mm_cache + 1
- # NOTE: This needs to be a model that resolves to supports_multimodal=True
- # in vLLM, otherwise we won't have an MM config
+ fake_model_config = SimpleNamespace(
+ multimodal_config=SimpleNamespace(mm_processor_cache_gb=override_val),
+ )
+
+ def _fake_parent_create_model_config(self):
+ assert self.mm_processor_cache_gb == override_val
+ return fake_model_config
+
+ mocker.patch.object(EngineArgs, "create_model_config", _fake_parent_create_model_config)
+ mocker.patch.object(OmniModelConfig, "from_vllm_model_config", side_effect=lambda model_config, **_: model_config)
+
cfg = OmniEngineArgs(
model="Qwen/Qwen2-VL-2B-Instruct",
mm_processor_cache_gb=override_val,
).create_model_config()
- # Ensure that the override was applied correctly
assert cfg.multimodal_config is not None
assert cfg.multimodal_config.mm_processor_cache_gb == override_val
@@ -166,6 +174,24 @@ def test_stage_configs_path_field():
assert args.stage_configs_path == "/some/path.yaml"
+def test_voxcpm_model_arch_injects_model_type_override(mocker):
+ """Ensure VoxCPM model_arch injects hf_overrides for config resolution."""
+ mocker.patch.object(OmniEngineArgs, "_ensure_omni_models_registered", return_value=True)
+ mocker.patch.object(OmniEngineArgs, "_patch_empty_hf_config")
+ mocker.patch.object(EngineArgs, "create_model_config", return_value=Mock())
+ mocker.patch.object(OmniModelConfig, "from_vllm_model_config", return_value=Mock())
+
+ args = OmniEngineArgs(
+ model="OpenBMB/VoxCPM1.5",
+ model_arch="VoxCPMForConditionalGeneration",
+ )
+ args.create_model_config()
+
+ assert args.hf_overrides["architectures"] == ["VoxCPMForConditionalGeneration"]
+ assert args.hf_overrides["model_type"] == "voxcpm"
+ args._patch_empty_hf_config.assert_called_once_with("voxcpm")
+
+
def test_strip_single_engine_args():
"""_strip_single_engine_args should remove EngineArgs fields but keep omni fields."""
kwargs = {
diff --git a/tests/engine/test_async_omni_engine_abort.py b/tests/engine/test_async_omni_engine_abort.py
index 34fdf45ea..eda7a7a78 100644
--- a/tests/engine/test_async_omni_engine_abort.py
+++ b/tests/engine/test_async_omni_engine_abort.py
@@ -2,20 +2,21 @@ import asyncio
import os
import sys
from contextlib import ExitStack
-from pathlib import Path
import pytest
from vllm import SamplingParams
from vllm.inputs import PromptType
-from tests.utils import hardware_test
+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
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
SEED = 42
-stage_config = str(Path(__file__).parent.parent / "e2e" / "stage_configs" / "qwen2_5_omni_thinker_ci.yaml")
+# Single-stage thinker-only deploy, materialized from tests.helpers.stage_config._CI_OVERLAYS.
+stage_config = get_deploy_config_path("ci/qwen2_5_omni_thinker_only.yaml")
model = "Qwen/Qwen2.5-Omni-7B"
@@ -60,7 +61,6 @@ async def generate(
@pytest.mark.core_model
@pytest.mark.omni
-@pytest.mark.real_hf_config
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
@pytest.mark.asyncio
async def test_abort():
diff --git a/tests/engine/test_async_omni_engine_outputs.py b/tests/engine/test_async_omni_engine_outputs.py
index ef3cfab3b..47b3d5e9f 100644
--- a/tests/engine/test_async_omni_engine_outputs.py
+++ b/tests/engine/test_async_omni_engine_outputs.py
@@ -63,3 +63,39 @@ async def test_try_get_output_async_raises_after_orchestrator_dies(mocker: Mocke
with pytest.raises(RuntimeError, match="Orchestrator died unexpectedly"):
await engine.try_get_output_async()
+
+
+def test_fatal_error_message_surfaces_through_try_get_output(mocker: MockerFixture):
+ """When the orchestrator thread crashes, it enqueues a fatal error message.
+
+ ``try_get_output`` must return this message so the caller
+ (``OmniBase._handle_output_message``) can detect the fatal flag.
+ """
+ fatal_msg = {"type": "error", "error": "Orchestrator thread crashed", "fatal": True}
+
+ mock_queue = mocker.MagicMock()
+ mock_queue.sync_q.get.return_value = fatal_msg
+
+ engine = _make_engine(mock_queue, mocker, thread_alive=False)
+
+ msg = engine.try_get_output()
+ assert msg is not None
+ assert msg["type"] == "error"
+ assert msg["fatal"] is True
+ assert "crashed" in msg["error"]
+
+
+@pytest.mark.asyncio
+async def test_fatal_error_message_surfaces_through_try_get_output_async(mocker: MockerFixture):
+ """Async variant of the fatal error message test."""
+ fatal_msg = {"type": "error", "error": "Orchestrator thread crashed", "fatal": True}
+
+ mock_queue = mocker.MagicMock()
+ mock_queue.sync_q.get_nowait.return_value = fatal_msg
+
+ engine = _make_engine(mock_queue, mocker, thread_alive=False)
+
+ msg = await engine.try_get_output_async()
+ assert msg is not None
+ assert msg["type"] == "error"
+ assert msg["fatal"] is True
diff --git a/tests/engine/test_async_omni_engine_stage_init.py b/tests/engine/test_async_omni_engine_stage_init.py
index 7b995fe70..5c2a9edb7 100644
--- a/tests/engine/test_async_omni_engine_stage_init.py
+++ b/tests/engine/test_async_omni_engine_stage_init.py
@@ -100,10 +100,11 @@ def test_initialize_stages_passes_stage_init_timeout_to_diffusion_handshake(monk
engine.log_stats = False
engine.model = "dummy-model"
engine.config_path = "dummy-config"
- engine.num_stages = 1
+ engine.num_stages = 2
engine.async_chunk = False
engine.diffusion_batch_size = 1
engine.single_stage_mode = False
+ engine._omni_master_server = None
engine.stage_configs = [types.SimpleNamespace(stage_id=0, stage_type="diffusion", engine_args={})]
metadata = types.SimpleNamespace(
@@ -182,6 +183,7 @@ def test_launch_llm_stage_passes_stage_init_timeout_to_complete_stage_handshake(
engine.model = "dummy-model"
engine.single_stage_mode = False
engine._omni_master_server = None
+ engine.stage_configs = []
metadata = types.SimpleNamespace(stage_id=0, runtime_cfg={"devices": "0"})
fake_vllm_config = types.SimpleNamespace()
@@ -237,6 +239,7 @@ def test_launch_llm_stage_releases_launch_lock_before_complete_stage_handshake(m
engine.model = "dummy-model"
engine.single_stage_mode = False
engine._omni_master_server = None
+ engine.stage_configs = []
fake_vllm_config = types.SimpleNamespace()
fake_addresses = types.SimpleNamespace()
@@ -377,3 +380,70 @@ def test_attach_llm_stage_uses_omni_input_preprocessor(monkeypatch):
assert input_processor is not None
assert isinstance(input_processor.input_preprocessor, DummyOmniInputPreprocessor)
assert input_processor.input_preprocessor.renderer is input_processor.renderer
+
+
+def test_inject_kv_stage_info_infers_sender_tp_topology():
+ from vllm_omni.engine.stage_init_utils import inject_kv_stage_info
+
+ stage0 = types.SimpleNamespace(
+ stage_id=0,
+ engine_args={
+ "tensor_parallel_size": 4,
+ "omni_kv_config": {
+ "need_send_cache": True,
+ "omni_from_stage": "0",
+ "omni_to_stage": "1",
+ },
+ },
+ engine_input_source=[],
+ )
+ stage1 = types.SimpleNamespace(
+ stage_id=1,
+ engine_args={
+ "parallel_config": {
+ "tensor_parallel_size": 2,
+ "cfg_parallel_size": 1,
+ },
+ "omni_kv_config": {"need_recv_cache": True},
+ },
+ engine_input_source=[0],
+ )
+
+ inject_kv_stage_info(stage0, 0, [stage0, stage1])
+
+ assert stage0.engine_args["omni_kv_config"]["stage_id"] == 0
+ assert stage0.engine_args["omni_kv_config"]["rank_mapping"] == {"from_tp": 4, "to_tp": 2}
+
+
+def test_inject_kv_stage_info_infers_receiver_tp_topology():
+ from vllm_omni.engine.stage_init_utils import inject_kv_stage_info
+
+ stage0 = types.SimpleNamespace(
+ stage_id=0,
+ engine_args={
+ "tensor_parallel_size": 4,
+ "omni_kv_config": {"need_send_cache": True},
+ },
+ engine_input_source=[],
+ )
+ stage1 = types.SimpleNamespace(
+ stage_id=1,
+ engine_args={
+ "parallel_config": {
+ "tensor_parallel_size": 2,
+ "cfg_parallel_size": 1,
+ },
+ "omni_kv_config": {
+ "need_recv_cache": True,
+ "omni_from_stage": "0",
+ "omni_to_stage": "1",
+ },
+ },
+ engine_input_source=[0],
+ )
+
+ inject_kv_stage_info(stage1, 1, [stage0, stage1])
+
+ assert stage1.engine_args["omni_kv_config"]["stage_id"] == 1
+ assert stage1.engine_args["omni_kv_config"]["engine_input_source"] == [0]
+ assert stage1.engine_args["omni_kv_config"]["rank_mapping"] == {"from_tp": 4, "to_tp": 2}
diff --git a/tests/engine/test_cfg_companion_tracker.py b/tests/engine/test_cfg_companion_tracker.py
new file mode 100644
index 000000000..f856a38c3
--- /dev/null
+++ b/tests/engine/test_cfg_companion_tracker.py
@@ -0,0 +1,82 @@
+import pytest
+
+from vllm_omni.engine.cfg_companion_tracker import CfgCompanionTracker
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def test_register_companion_and_cleanup():
+ tracker = CfgCompanionTracker()
+
+ tracker.register_companion("req1", "cfg_text", "req1__cfg_text")
+ tracker.register_companion("req1", "cfg_img", "req1__cfg_img")
+
+ assert tracker.is_companion("req1__cfg_text")
+ assert tracker.get_companion_request_ids("req1") == {
+ "cfg_text": "req1__cfg_text",
+ "cfg_img": "req1__cfg_img",
+ }
+
+ removed = tracker.cleanup_parent("req1")
+
+ assert sorted(removed) == ["req1__cfg_img", "req1__cfg_text"]
+ assert not tracker.is_companion("req1__cfg_text")
+ assert tracker.get_companion_request_ids("req1") == {}
+
+
+def test_attach_cfg_request_ids_clones_diffusion_params():
+ tracker = CfgCompanionTracker()
+ tracker.register_companion("req1", "cfg_text", "req1__cfg_text")
+
+ params = OmniDiffusionSamplingParams()
+ updated = tracker.attach_cfg_request_ids("req1", params)
+
+ assert updated is not params
+ assert params.cfg_kv_request_ids is None
+ assert updated.cfg_kv_request_ids == {"cfg_text": "req1__cfg_text"}
+
+
+def test_abort_parent_expands_to_companions_and_cleans_up_deferred_parent():
+ tracker = CfgCompanionTracker()
+ tracker.register_companion("req1", "cfg_text", "req1__cfg_text")
+ tracker.defer_parent("req1", {"out": 1}, stage_id=0)
+
+ aborted = tracker.abort_parents(["req1"])
+
+ assert sorted(aborted) == ["req1", "req1__cfg_text"]
+ assert not tracker.is_companion("req1__cfg_text")
+ assert tracker.pop_pending_parent("req1") is None
+
+
+def test_abort_companion_does_not_expand_to_parent():
+ tracker = CfgCompanionTracker()
+ tracker.register_companion("req1", "cfg_text", "req1__cfg_text")
+
+ aborted = tracker.abort_parents(["req1__cfg_text"])
+
+ assert aborted == ["req1__cfg_text"]
+
+
+def test_companion_completion_flushes_deferred_parent():
+ tracker = CfgCompanionTracker()
+ tracker.register_companion("req1", "cfg_text", "req1__cfg_text")
+ tracker.defer_parent("req1", {"out": 123}, stage_id=0)
+
+ assert not tracker.all_companions_done("req1")
+ assert tracker.on_companion_completed("req1__cfg_text") == "req1"
+ assert tracker.all_companions_done("req1")
+
+ popped = tracker.pop_pending_parent("req1")
+ assert popped is not None
+ assert popped["engine_outputs"] == {"out": 123}
+ assert popped["stage_id"] == 0
+
+
+def test_companion_completion_without_registered_parent_asserts():
+ tracker = CfgCompanionTracker()
+ tracker._companion_ids.add("req1__cfg_text")
+ tracker._companion_to_parent["req1__cfg_text"] = "req1"
+
+ with pytest.raises(AssertionError, match="completed before parent req1 was registered"):
+ tracker.on_companion_completed("req1__cfg_text")
diff --git a/tests/engine/test_orchestrator.py b/tests/engine/test_orchestrator.py
index 7bf2eccf7..0b549f58e 100644
--- a/tests/engine/test_orchestrator.py
+++ b/tests/engine/test_orchestrator.py
@@ -70,7 +70,7 @@ class FakeStageClient:
def set_engine_outputs(self, outputs) -> None:
return None
- def process_engine_inputs(self, stage_list, prompt=None):
+ def process_engine_inputs(self, stage_list, prompt=None, streaming_context=None):
return list(self.next_inputs)
async def abort_requests_async(self, request_ids: list[str]) -> None:
diff --git a/tests/engine/test_orchestrator_error_handling.py b/tests/engine/test_orchestrator_error_handling.py
new file mode 100644
index 000000000..6131c5e9b
--- /dev/null
+++ b/tests/engine/test_orchestrator_error_handling.py
@@ -0,0 +1,160 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Tests for error propagation paths within the Orchestrator.
+
+Covers:
+- EngineDeadError from an LLM stage poll → fatal error broadcast + shutdown
+- Diffusion stage error output (OmniRequestOutput.from_error) → routed correctly
+"""
+
+from __future__ import annotations
+
+import asyncio
+import queue
+import time
+from types import SimpleNamespace
+
+import pytest
+from vllm.v1.engine.exceptions import EngineDeadError
+
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+from vllm_omni.outputs import OmniRequestOutput
+
+from .test_orchestrator import (
+ FakeStageClient,
+ OrchestratorFixture,
+ _build_harness,
+ _enqueue_add_request,
+ _wait_for,
+)
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def _sampling_params(max_tokens: int = 4):
+ from vllm.sampling_params import SamplingParams
+
+ return SamplingParams(max_tokens=max_tokens)
+
+
+async def _get_any_output_message(fixture: OrchestratorFixture, *, timeout: float = 2.0) -> dict:
+ """Like _get_output_message but returns any message type (including errors)."""
+ deadline = time.monotonic() + timeout
+ while True:
+ if time.monotonic() >= deadline:
+ raise AssertionError("Timed out waiting for orchestrator output")
+ try:
+ return fixture.output_sync_q.get_nowait()
+ except queue.Empty:
+ await asyncio.sleep(0.01)
+
+
+@pytest.fixture
+def orchestrator_factory():
+ fixtures: list[OrchestratorFixture] = []
+
+ def _factory(*args, **kwargs) -> OrchestratorFixture:
+ fixture = _build_harness(*args, **kwargs)
+ fixtures.append(fixture)
+ return fixture
+
+ yield _factory
+
+ for fixture in fixtures:
+ if fixture.thread.is_alive():
+ fixture.request_sync_q.put_nowait({"type": "shutdown"})
+ fixture.thread.join(timeout=5)
+ for q in fixture.queues:
+ q.close()
+
+
+# ───────── EngineDeadError from LLM stage poll ─────────
+
+
+class FakeDeadLLMStageClient(FakeStageClient):
+ """LLM stage client that raises EngineDeadError on get_output_async."""
+
+ async def get_output_async(self):
+ raise EngineDeadError("Stage-0 engine core is dead")
+
+
+@pytest.mark.asyncio
+async def test_engine_dead_error_broadcasts_fatal_and_shuts_down(orchestrator_factory) -> None:
+ """When a stage raises EngineDeadError during poll, the orchestrator must:
+ 1. Enqueue a fatal error message for each affected request
+ 2. Shut itself down (thread exits)
+ """
+ stage0 = FakeDeadLLMStageClient(stage_type="llm", final_output=True)
+ orchestrator_fixture = orchestrator_factory([stage0])
+ request = SimpleNamespace(request_id="req-dead", prompt_token_ids=[1, 2])
+
+ try:
+ await _enqueue_add_request(
+ orchestrator_fixture,
+ request_id="req-dead",
+ prompt=request,
+ original_prompt={"prompt": "hello"},
+ sampling_params_list=[_sampling_params()],
+ final_stage_id=0,
+ )
+
+ # Collect the fatal error message.
+ msg = await _get_any_output_message(orchestrator_fixture)
+
+ assert msg["type"] == "error"
+ assert msg["fatal"] is True
+ assert msg["request_id"] == "req-dead"
+ assert "Stage-0 engine core is dead" in msg["error"]
+
+ # The orchestrator thread should exit after the fatal error.
+ orchestrator_fixture.thread.join(timeout=5)
+ assert not orchestrator_fixture.thread.is_alive()
+
+ # Request state should be cleaned up.
+ assert "req-dead" not in orchestrator_fixture.orchestrator.request_states
+ finally:
+ if orchestrator_fixture.thread.is_alive():
+ orchestrator_fixture.request_sync_q.put_nowait({"type": "shutdown"})
+ orchestrator_fixture.thread.join(timeout=5)
+
+
+# ───────── Diffusion stage error output routing ─────────
+
+
+@pytest.mark.asyncio
+async def test_diffusion_error_output_routed_as_finished(orchestrator_factory) -> None:
+ """When a diffusion stage returns an OmniRequestOutput with a non-None
+ error, the orchestrator must route it as a finished output message and
+ clean up the request state.
+ """
+ stage0 = FakeStageClient(stage_type="diffusion", final_output=True, final_output_type="image")
+ orchestrator_fixture = orchestrator_factory([stage0])
+ params = OmniDiffusionSamplingParams()
+
+ try:
+ await _enqueue_add_request(
+ orchestrator_fixture,
+ request_id="req-err",
+ prompt={"prompt": "draw a cat"},
+ original_prompt={"prompt": "draw a cat"},
+ sampling_params_list=[params],
+ final_stage_id=0,
+ )
+
+ await _wait_for(lambda: len(stage0.add_request_calls) == 1)
+
+ # Push an error output from the diffusion stage.
+ stage0.push_diffusion_output(OmniRequestOutput.from_error("req-err", "gpu fault"))
+
+ msg = await _get_any_output_message(orchestrator_fixture)
+
+ assert msg["type"] == "output"
+ assert msg["request_id"] == "req-err"
+ assert msg["finished"] is True
+ assert msg["engine_outputs"].error == "gpu fault"
+
+ # Request state should be cleaned up.
+ await _wait_for(lambda: "req-err" not in orchestrator_fixture.orchestrator.request_states)
+ finally:
+ orchestrator_fixture.request_sync_q.put_nowait({"type": "shutdown"})
+ orchestrator_fixture.thread.join(timeout=5)
diff --git a/tests/engine/test_orchestrator_kv_sender_info.py b/tests/engine/test_orchestrator_kv_sender_info.py
index 94da4ce71..7e3fe0906 100644
--- a/tests/engine/test_orchestrator_kv_sender_info.py
+++ b/tests/engine/test_orchestrator_kv_sender_info.py
@@ -4,6 +4,7 @@ from types import SimpleNamespace
import pytest
from vllm import SamplingParams
+from vllm_omni.engine.cfg_companion_tracker import CfgCompanionTracker
from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState
from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClient
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -130,7 +131,7 @@ def test_forward_to_diffusion_attaches_kv_sender_info():
orchestrator.num_stages = 2
orchestrator.stage_clients = [sender_stage, diffusion_stage]
- orchestrator._companion_map = {}
+ orchestrator._cfg_tracker = CfgCompanionTracker()
orchestrator.stage_vllm_configs = [None, None]
orchestrator.output_processors = [None, None]
@@ -161,7 +162,7 @@ def test_forward_to_diffusion_uses_engine_input_source_for_kv_sender_info():
orchestrator.num_stages = 3
orchestrator.stage_clients = [source_stage, previous_stage, diffusion_stage]
- orchestrator._companion_map = {}
+ orchestrator._cfg_tracker = CfgCompanionTracker()
orchestrator.stage_vllm_configs = [None, None, None]
orchestrator.output_processors = [None, None, None]
diff --git a/tests/engine/test_single_stage_mode.py b/tests/engine/test_single_stage_mode.py
index 608e92ac4..28ccccaa2 100644
--- a/tests/engine/test_single_stage_mode.py
+++ b/tests/engine/test_single_stage_mode.py
@@ -1555,6 +1555,7 @@ class TestLaunchLlmStageSingleStageMode:
engine.single_stage_mode = True
engine._single_stage_id_filter = 0
engine._llm_stage_launch_lock = threading.Lock()
+ engine.stage_configs = []
mock_oms = mocker.Mock(spec=OmniMasterServer)
mock_oms.address = "127.0.0.1"
mock_oms.port = 25000
@@ -1629,6 +1630,7 @@ class TestLaunchLlmStageSingleStageMode:
engine.single_stage_mode = False
engine._omni_master_server = None
engine._llm_stage_launch_lock = threading.Lock()
+ engine.stage_configs = []
fake_vllm_config = mocker.Mock()
fake_executor_cls = mocker.Mock()
diff --git a/tests/engine/test_stage_engine_core_client.py b/tests/engine/test_stage_engine_core_client.py
new file mode 100644
index 000000000..dde0927af
--- /dev/null
+++ b/tests/engine/test_stage_engine_core_client.py
@@ -0,0 +1,46 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Tests for StageEngineCoreClient.check_health().
+
+Uses object.__new__ to construct a minimal client — check_health only
+touches self.resources, self.stage_id, and self._proc.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+from vllm.v1.engine.exceptions import EngineDeadError
+
+from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClient
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def _make_client(*, engine_dead=False, proc_alive=True):
+ client = object.__new__(StageEngineCoreClient)
+ client.stage_id = 0
+ client.resources = SimpleNamespace(engine_dead=engine_dead)
+ client._proc = MagicMock(is_alive=MagicMock(return_value=proc_alive), exitcode=1)
+ return client
+
+
+def test_check_health_passes_when_alive():
+ client = _make_client(engine_dead=False, proc_alive=True)
+ client.check_health() # no exception
+
+
+def test_check_health_raises_when_resources_engine_dead():
+ client = _make_client(engine_dead=True, proc_alive=True)
+ with pytest.raises(EngineDeadError, match="engine core is dead"):
+ client.check_health()
+
+
+def test_check_health_raises_when_proc_not_alive():
+ client = _make_client(engine_dead=False, proc_alive=False)
+ with pytest.raises(EngineDeadError, match="not alive"):
+ client.check_health()
+ # Verify it set resources.engine_dead as a side effect
+ assert client.resources.engine_dead is True
diff --git a/tests/entrypoints/openai_api/test_image_server.py b/tests/entrypoints/openai_api/test_image_server.py
index c91c5a5c7..4cf46f18c 100644
--- a/tests/entrypoints/openai_api/test_image_server.py
+++ b/tests/entrypoints/openai_api/test_image_server.py
@@ -106,10 +106,13 @@ def test_encode_image_base64():
class MockGenerationResult:
- """Mock result object from AsyncOmni.generate()"""
+ """Mock result object compatible with current diffusion output shape."""
def __init__(self, images):
self.images = images
+ self.request_output = SimpleNamespace(images=images)
+ self.stage_durations = {}
+ self.peak_memory_mb = 0.0
class FakeAsyncOmni:
@@ -117,20 +120,26 @@ class FakeAsyncOmni:
def __init__(self, images=None):
self.stage_configs = [
- SimpleNamespace(stage_type="llm"),
- SimpleNamespace(stage_type="diffusion"),
+ SimpleNamespace(stage_type="llm", is_comprehension=True),
+ SimpleNamespace(stage_type="diffusion", is_comprehension=False),
]
self.default_sampling_params_list = [SamplingParams(temperature=0.1), OmniDiffusionSamplingParams()]
self.captured_sampling_params_list = None
self.captured_prompt = None
self._images = images or [Image.new("RGB", (64, 64), color="green")]
- async def generate(self, prompt, request_id, sampling_params_list):
- self.captured_sampling_params_list = sampling_params_list
+ async def generate(self, prompt, request_id, sampling_params=None, sampling_params_list=None):
+ if sampling_params_list is not None:
+ self.captured_sampling_params_list = sampling_params_list
+ else:
+ self.captured_sampling_params_list = [sampling_params]
self.captured_prompt = prompt
images = [img.copy() for img in self._images]
yield MockGenerationResult(images)
+ def __class_getitem__(cls, item):
+ return cls
+
@pytest.fixture
def mock_async_diffusion(mocker: MockerFixture):
@@ -177,7 +186,7 @@ def test_client(mock_async_diffusion):
[BaseModelPath(name="Qwen/Qwen-Image", model_path="Qwen/Qwen-Image")]
)
app.state.args = Namespace(
- default_sampling_params='{"0": {"num_inference_steps":4, "guidance_scale":7.5}}',
+ default_sampling_params='{"0": {"num_inference_steps":4, "guidance_scale":7.5, "generator_device":"cpu"}}',
max_generated_image_size=1024 * 1792,
)
@@ -189,18 +198,60 @@ def async_omni_test_client():
"""Create test client with mocked AsyncOmni engine."""
from fastapi import FastAPI
+ from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.openai.api_server import router
+ from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
+
+ class FakeAsyncOmniClass(AsyncOmni):
+ def __init__(self):
+ stage_configs = [
+ SimpleNamespace(stage_type="llm", is_comprehension=True),
+ SimpleNamespace(stage_type="diffusion", is_comprehension=False),
+ ]
+ default_sampling_params_list = [
+ SamplingParams(temperature=0.1),
+ OmniDiffusionSamplingParams(),
+ ]
+ self.engine = SimpleNamespace(
+ stage_configs=stage_configs,
+ default_sampling_params_list=default_sampling_params_list,
+ )
+ self.default_sampling_params_list = default_sampling_params_list
+ self.captured_sampling_params_list = None
+ self.captured_prompt = None
+ self._images = [Image.new("RGB", (64, 64), color="green")]
+ self.od_config = SimpleNamespace(supports_multimodal_inputs=True)
+
+ async def generate(self, prompt, request_id, sampling_params=None, sampling_params_list=None):
+ if sampling_params_list is not None:
+ self.captured_sampling_params_list = sampling_params_list
+ else:
+ self.captured_sampling_params_list = [sampling_params]
+ self.captured_prompt = prompt
+ images = [img.copy() for img in self._images]
+ yield MockGenerationResult(images)
+
+ def __class_getitem__(cls, item):
+ return cls
+
+ def get_diffusion_od_config(self):
+ return self.od_config
app = FastAPI()
app.include_router(router)
- app.state.engine_client = FakeAsyncOmni()
+ engine = FakeAsyncOmniClass()
+ chat_handler = object.__new__(OmniOpenAIServingChat)
+ chat_handler.engine_client = engine
+ chat_handler._diffusion_engine = None
+ app.state.openai_serving_chat = chat_handler
+ app.state.engine_client = engine
app.state.stage_configs = [
SimpleNamespace(stage_type="llm"),
SimpleNamespace(stage_type="diffusion"),
]
app.state.args = Namespace(
- default_sampling_params='{"1": {"num_inference_steps":4, "guidance_scale":7.5}}',
+ default_sampling_params='{"1": {"num_inference_steps":4, "guidance_scale":7.5, "generator_device":"cpu"}}',
max_generated_image_size=1048576, # 1024*1024 to support resolution tests
)
return TestClient(app)
@@ -211,18 +262,60 @@ def async_omni_rgba_test_client():
"""Create test client with mocked AsyncOmni engine returning RGBA output."""
from fastapi import FastAPI
+ from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.openai.api_server import router
+ from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
+
+ class FakeAsyncOmniClass(AsyncOmni):
+ def __init__(self):
+ stage_configs = [
+ SimpleNamespace(stage_type="llm", is_comprehension=True),
+ SimpleNamespace(stage_type="diffusion", is_comprehension=False),
+ ]
+ default_sampling_params_list = [
+ SamplingParams(temperature=0.1),
+ OmniDiffusionSamplingParams(),
+ ]
+ self.engine = SimpleNamespace(
+ stage_configs=stage_configs,
+ default_sampling_params_list=default_sampling_params_list,
+ )
+ self.default_sampling_params_list = default_sampling_params_list
+ self.captured_sampling_params_list = None
+ self.captured_prompt = None
+ self._images = [Image.new("RGBA", (64, 64), color=(0, 255, 0, 128))]
+ self.od_config = SimpleNamespace(supports_multimodal_inputs=True)
+
+ async def generate(self, prompt, request_id, sampling_params=None, sampling_params_list=None):
+ if sampling_params_list is not None:
+ self.captured_sampling_params_list = sampling_params_list
+ else:
+ self.captured_sampling_params_list = [sampling_params]
+ self.captured_prompt = prompt
+ images = [img.copy() for img in self._images]
+ yield MockGenerationResult(images)
+
+ def __class_getitem__(cls, item):
+ return cls
+
+ def get_diffusion_od_config(self):
+ return self.od_config
app = FastAPI()
app.include_router(router)
- app.state.engine_client = FakeAsyncOmni(images=[Image.new("RGBA", (64, 64), color=(0, 255, 0, 128))])
+ engine = FakeAsyncOmniClass()
+ chat_handler = object.__new__(OmniOpenAIServingChat)
+ chat_handler.engine_client = engine
+ chat_handler._diffusion_engine = None
+ app.state.openai_serving_chat = chat_handler
+ app.state.engine_client = engine
app.state.stage_configs = [
SimpleNamespace(stage_type="llm"),
SimpleNamespace(stage_type="diffusion"),
]
app.state.args = Namespace(
- default_sampling_params='{"1": {"num_inference_steps":4, "guidance_scale":7.5}}',
+ default_sampling_params='{"1": {"num_inference_steps":4, "guidance_scale":7.5, "generator_device":"cpu"}}',
max_generated_image_size=1048576,
)
return TestClient(app)
@@ -233,18 +326,57 @@ def async_omni_stage_configs_only_client():
"""Create test client with refactored AsyncOmni compatibility surface only."""
from fastapi import FastAPI
+ from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.openai.api_server import router
+ from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
+
+ class FakeAsyncOmniClass(AsyncOmni):
+ def __init__(self):
+ stage_configs = [
+ SimpleNamespace(stage_type="llm", is_comprehension=True),
+ SimpleNamespace(stage_type="diffusion", is_comprehension=False),
+ ]
+ default_sampling_params_list = [
+ SamplingParams(temperature=0.1),
+ OmniDiffusionSamplingParams(),
+ ]
+ self.engine = SimpleNamespace(
+ stage_configs=stage_configs,
+ default_sampling_params_list=default_sampling_params_list,
+ )
+ self.default_sampling_params_list = default_sampling_params_list
+ self.captured_sampling_params_list = None
+ self.captured_prompt = None
+ self._images = [Image.new("RGB", (64, 64), color="green")]
+ self.od_config = SimpleNamespace(supports_multimodal_inputs=True)
+
+ async def generate(self, prompt, request_id, sampling_params=None, sampling_params_list=None):
+ if sampling_params_list is not None:
+ self.captured_sampling_params_list = sampling_params_list
+ else:
+ self.captured_sampling_params_list = [sampling_params]
+ self.captured_prompt = prompt
+ images = [img.copy() for img in self._images]
+ yield MockGenerationResult(images)
+
+ def __class_getitem__(cls, item):
+ return cls
+
+ def get_diffusion_od_config(self):
+ return self.od_config
app = FastAPI()
app.include_router(router)
- engine = FakeAsyncOmni()
+ engine = FakeAsyncOmniClass()
assert not hasattr(engine, "stage_list")
app.state.engine_client = engine
- # Intentionally do not populate app.state.stage_configs. Refactored
- # AsyncOmni exposes stage_configs on the engine instance.
+ chat_handler = object.__new__(OmniOpenAIServingChat)
+ chat_handler.engine_client = engine
+ chat_handler._diffusion_engine = None
+ app.state.openai_serving_chat = chat_handler
app.state.args = Namespace(
- default_sampling_params='{"1": {"num_inference_steps":4, "guidance_scale":7.5}}',
+ default_sampling_params='{"1": {"num_inference_steps":4, "guidance_scale":7.5, "generator_device":"cpu"}}',
max_generated_image_size=1024 * 1792,
)
return TestClient(app)
@@ -275,6 +407,29 @@ def test_health_endpoint_no_engine():
assert data["status"] == "unhealthy"
+def test_health_endpoint_dead_engine():
+ """Health returns 503 when the engine raises EngineDeadError."""
+ from unittest.mock import AsyncMock
+
+ from fastapi import FastAPI
+ from vllm.v1.engine.exceptions import EngineDeadError
+
+ from vllm_omni.entrypoints.openai.api_server import router
+
+ app = FastAPI()
+ app.include_router(router)
+
+ dead_engine = AsyncMock()
+ dead_engine.check_health = AsyncMock(side_effect=EngineDeadError())
+ app.state.engine_client = dead_engine
+
+ client = TestClient(app)
+ response = client.get("/health")
+ assert response.status_code == 503
+ data = response.json()
+ assert data["status"] == "unhealthy"
+
+
def test_models_endpoint(test_client):
"""Test /v1/models endpoint for diffusion mode"""
response = test_client.get("/v1/models")
@@ -306,6 +461,9 @@ def test_models_endpoint_no_engine():
def test_generate_single_image(test_client):
"""Test generating a single image"""
+ # Single-stage path should not require openai_serving_chat.
+ assert not hasattr(test_client.app.state, "openai_serving_chat")
+
response = test_client.post(
"/v1/images/generations",
json={
@@ -374,6 +532,43 @@ def test_generate_images_async_omni_stage_configs_only(async_omni_stage_configs_
assert captured[1].seed == 11
+def test_multistage_images_async_omni_construction(async_omni_test_client):
+ """Regression: multistage image generation builds the expected chat-style payload."""
+ response = async_omni_test_client.post(
+ "/v1/images/generations",
+ json={
+ "prompt": "a cat",
+ "n": 2,
+ "size": "128x256",
+ "seed": 7,
+ "num_inference_steps": 12,
+ "guidance_scale": 6.5,
+ },
+ )
+ assert response.status_code == 200
+
+ engine = async_omni_test_client.app.state.engine_client
+ captured_prompt = engine.captured_prompt
+ assert captured_prompt["prompt"] == "a cat"
+ assert captured_prompt["modalities"] == ["image"]
+ assert captured_prompt["mm_processor_kwargs"] == {
+ "target_h": 256,
+ "target_w": 128,
+ }
+
+ captured = engine.captured_sampling_params_list
+ assert captured is not None
+ assert len(captured) == 2
+ assert captured[0].temperature == 0.1
+ assert captured[0].seed == 7
+ assert captured[1].num_outputs_per_prompt == 2
+ assert captured[1].width == 128
+ assert captured[1].height == 256
+ assert captured[1].seed == 7
+ assert captured[1].num_inference_steps == 12
+ assert captured[1].guidance_scale == 6.5
+
+
def test_image_edits_async_omni_stage_configs_only(async_omni_stage_configs_only_client):
"""Regression: image edits accepts refactored AsyncOmni without stage_list."""
img_bytes = make_test_image_bytes((16, 16))
@@ -679,6 +874,19 @@ def test_model_field_omitted_works(test_client):
assert response.status_code == 200
+def test_generate_images_rejects_model_mismatch(test_client):
+ response = test_client.post(
+ "/v1/images/generations",
+ json={
+ "prompt": "test",
+ "model": "Qwen/Qwen-Image-2512",
+ "size": "1024x1024",
+ },
+ )
+ assert response.status_code == 400
+ assert "model mismatch" in response.json()["detail"].lower()
+
+
def make_test_image_bytes(size=(64, 64)) -> bytes:
img = Image.new(
"RGB",
@@ -782,6 +990,98 @@ def test_image_edit_rejects_multiple_images_when_model_does_not_support_them(asy
assert engine.captured_prompt is None
+def test_image_edit_rejects_model_mismatch(test_client):
+ img_bytes = make_test_image_bytes((16, 16))
+ response = test_client.post(
+ "/v1/images/edits",
+ files=[("image", img_bytes)],
+ data={
+ "prompt": "edit me",
+ "model": "Qwen/Qwen-Image-Edit",
+ },
+ )
+ assert response.status_code == 400
+ assert "model mismatch" in response.json()["detail"].lower()
+
+
+def test_image_edit_rejects_too_many_images_for_qwen_image_edit_2511(async_omni_test_client):
+ engine = async_omni_test_client.app.state.engine_client
+ engine.get_diffusion_od_config = lambda: SimpleNamespace(
+ supports_multimodal_inputs=True,
+ max_multimodal_image_inputs=4,
+ )
+
+ response = async_omni_test_client.post(
+ "/v1/images/edits",
+ files=[
+ ("image", make_test_image_bytes((16, 16))),
+ ("image", make_test_image_bytes((16, 16))),
+ ("image", make_test_image_bytes((16, 16))),
+ ("image", make_test_image_bytes((16, 16))),
+ ("image", make_test_image_bytes((16, 16))),
+ ],
+ data={"prompt": "hello world."},
+ )
+
+ assert response.status_code == 400
+ assert response.json()["detail"] == "Received 5 input images. At most 4 images are supported by this model."
+ assert engine.captured_prompt is None
+
+
+def test_image_edit_rejects_too_many_images_for_qwen_image_edit_2511_before_loading(
+ async_omni_test_client, monkeypatch: pytest.MonkeyPatch
+):
+ import vllm_omni.entrypoints.openai.api_server as api_server_module
+
+ engine = async_omni_test_client.app.state.engine_client
+ engine.get_diffusion_od_config = lambda: SimpleNamespace(
+ supports_multimodal_inputs=True,
+ max_multimodal_image_inputs=4,
+ )
+
+ def _fail_load(*args, **kwargs):
+ raise AssertionError("_load_input_images should not run for over-limit requests")
+
+ monkeypatch.setattr(api_server_module, "_load_input_images", _fail_load)
+
+ response = async_omni_test_client.post(
+ "/v1/images/edits",
+ files=[
+ ("image", make_test_image_bytes((16, 16))),
+ ("image", make_test_image_bytes((16, 16))),
+ ("image", make_test_image_bytes((16, 16))),
+ ("image", make_test_image_bytes((16, 16))),
+ ("image", make_test_image_bytes((16, 16))),
+ ],
+ data={"prompt": "hello world."},
+ )
+
+ assert response.status_code == 400
+ assert response.json()["detail"] == "Received 5 input images. At most 4 images are supported by this model."
+ assert engine.captured_prompt is None
+
+
+def test_image_edit_ignores_mock_like_multimodal_limit(async_omni_test_client):
+ engine = async_omni_test_client.app.state.engine_client
+ engine.get_diffusion_od_config = lambda: SimpleNamespace(
+ supports_multimodal_inputs=SimpleNamespace(),
+ max_multimodal_image_inputs=SimpleNamespace(),
+ )
+
+ response = async_omni_test_client.post(
+ "/v1/images/edits",
+ files=[("image", make_test_image_bytes((16, 16)))],
+ data={"prompt": "hello world."},
+ )
+
+ assert response.status_code == 200
+ captured_prompt = engine.captured_prompt
+ assert captured_prompt is not None
+ processed_images = captured_prompt["multi_modal_data"]["image"]
+ assert len(processed_images) == 1
+ assert processed_images[0].size == (16, 16)
+
+
def test_image_edit_parameter_pass(async_omni_test_client):
img_bytes_1 = make_test_image_bytes((16, 16))
@@ -960,6 +1260,7 @@ def test_image_edit_parameter_default(async_omni_test_client):
assert captured_sampling_params.num_outputs_per_prompt == 1
assert captured_sampling_params.num_inference_steps == 4
assert captured_sampling_params.guidance_scale == 7.5
+ assert captured_sampling_params.generator_device == "cpu"
# Test that a size exceeding max_generated_image_size returns 400
response = async_omni_test_client.post(
@@ -993,6 +1294,7 @@ def test_image_edit_parameter_default_single_stage(test_client):
assert captured_sampling_params.num_outputs_per_prompt == 1
assert captured_sampling_params.num_inference_steps == 4
assert captured_sampling_params.guidance_scale == 7.5
+ assert captured_sampling_params.generator_device == "cpu"
# Size exceeding max_generated_image_size (1024*1792) returns 400
response = test_client.post(
@@ -1165,3 +1467,91 @@ def test_image_edit_with_seed_zero_single_stage(test_client):
f"Expected seed=0, but got seed={captured_sampling_params.seed}. "
"This indicates the bug where seed=0 is treated as falsy."
)
+
+
+def test_normalize_image():
+ """Test _normalize_image with various input types"""
+ import numpy as np
+
+ from vllm_omni.entrypoints.openai.api_server import _normalize_image
+
+ # Test PIL Image input
+ img = Image.new("RGB", (64, 64), color="red")
+ result = _normalize_image(img)
+ assert isinstance(result, Image.Image)
+ assert result.size == (64, 64)
+
+ # Test uint8 numpy array
+ arr = np.random.randint(0, 255, (64, 64, 3), dtype=np.uint8)
+ result = _normalize_image(arr)
+ assert isinstance(result, Image.Image)
+ assert result.size == (64, 64)
+
+ # Test float [0, 1] numpy array
+ arr = np.random.rand(64, 64, 3).astype(np.float32)
+ result = _normalize_image(arr)
+ assert isinstance(result, Image.Image)
+ assert result.size == (64, 64)
+
+ # Test float [-1, 1] numpy array
+ arr = np.random.rand(64, 64, 3).astype(np.float32) * 2 - 1
+ result = _normalize_image(arr)
+ assert isinstance(result, Image.Image)
+ assert result.size == (64, 64)
+
+ # Test batch dimensions (1, 1, H, W, C)
+ arr = np.random.randint(0, 255, (1, 1, 64, 64, 3), dtype=np.uint8)
+ result = _normalize_image(arr)
+ assert isinstance(result, Image.Image)
+ assert result.size == (64, 64)
+
+
+def test_extract_images_from_result():
+ """Test _extract_images_from_result with various result formats"""
+ import numpy as np
+
+ from vllm_omni.entrypoints.openai.api_server import _extract_images_from_result
+
+ # Test empty result
+ class EmptyResult:
+ pass
+
+ result = EmptyResult()
+ images = _extract_images_from_result(result)
+ assert images == []
+
+ # Test nested batch: [np.array(shape=(3, 64, 64, 3))]
+ batch = np.random.randint(0, 255, (3, 1, 64, 64, 3), dtype=np.uint8)
+
+ class BatchResult:
+ def __init__(self):
+ self.images = [batch]
+
+ result = BatchResult()
+ images = _extract_images_from_result(result)
+ assert len(images) == 3
+ assert all(isinstance(img, Image.Image) for img in images)
+ assert all(img.size == (64, 64) for img in images)
+
+ # Test dict path: result.request_output["images"]
+ class DictRequestOutput:
+ def __init__(self):
+ self.request_output = {"images": [np.random.randint(0, 255, (64, 64, 3), dtype=np.uint8)]}
+
+ result = DictRequestOutput()
+ images = _extract_images_from_result(result)
+ assert len(images) == 1
+ assert isinstance(images[0], Image.Image)
+
+ # Test attribute path: result.request_output.images
+ class AttrRequestOutput:
+ def __init__(self):
+ self.request_output = type(
+ "obj", (), {"images": [np.random.randint(0, 255, (32, 32, 3), dtype=np.uint8)]}
+ )()
+
+ result = AttrRequestOutput()
+ images = _extract_images_from_result(result)
+ assert len(images) == 1
+ assert isinstance(images[0], Image.Image)
+ assert images[0].size == (32, 32)
diff --git a/tests/entrypoints/openai_api/test_serving_chat_multistage_generation.py b/tests/entrypoints/openai_api/test_serving_chat_multistage_generation.py
new file mode 100644
index 000000000..a9b9f53ba
--- /dev/null
+++ b/tests/entrypoints/openai_api/test_serving_chat_multistage_generation.py
@@ -0,0 +1,82 @@
+# SPDX-License-Identifier: Apache-2.0
+"""Regression tests for multistage diffusion generation input construction."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+from PIL import Image
+from vllm.sampling_params import SamplingParams
+
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+@pytest.fixture
+def serving_chat():
+ from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
+
+ return object.__new__(OmniOpenAIServingChat)
+
+
+def test_build_multistage_generation_inputs_applies_stage_specific_overrides(serving_chat):
+ 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),
+ SimpleNamespace(stage_type="diffusion", is_comprehension=False),
+ ],
+ default_sampling_params_list=[
+ SamplingParams(temperature=0.2, seed=11),
+ OmniDiffusionSamplingParams(),
+ OmniDiffusionSamplingParams(),
+ ],
+ )
+ reference_image = Image.new("RGB", (24, 24), color="green")
+ extra_body = {
+ "negative_prompt": "blurry",
+ "num_inference_steps": 28,
+ "guidance_scale": 7.5,
+ "true_cfg_scale": 5.0,
+ "guidance_scale_2": 1.25,
+ "layers": 6,
+ "resolution": 1024,
+ "lora": {"name": "adapter-a", "path": "/tmp/adapter-a", "scale": 0.6},
+ }
+ gen_params = OmniDiffusionSamplingParams(height=768, width=1024, seed=0, num_outputs_per_prompt=2)
+
+ engine_prompt, sampling_params_list = OmniOpenAIServingChat._build_multistage_generation_inputs(
+ serving_chat,
+ engine=engine,
+ prompt="draw a robot",
+ extra_body=extra_body,
+ reference_images=[reference_image],
+ gen_params=gen_params,
+ )
+
+ assert engine_prompt["prompt"] == "draw a robot"
+ assert engine_prompt["modalities"] == ["img2img"]
+ assert engine_prompt["negative_prompt"] == "blurry"
+ assert engine_prompt["mm_processor_kwargs"] == {"target_h": 768, "target_w": 1024}
+ assert engine_prompt["multi_modal_data"]["img2img"].size == (24, 24)
+
+ assert len(sampling_params_list) == 3
+ assert sampling_params_list[0].temperature == 0.2
+ assert sampling_params_list[0].seed == 0
+ assert sampling_params_list[1].height == 768
+ assert sampling_params_list[1].width == 1024
+ assert sampling_params_list[1].seed == 0
+ assert sampling_params_list[1].num_inference_steps == 28
+ assert sampling_params_list[1].guidance_scale == 7.5
+ assert sampling_params_list[1].num_outputs_per_prompt == 2
+ assert sampling_params_list[1].true_cfg_scale == 5.0
+ assert sampling_params_list[1].lora_request.name == "adapter-a"
+ assert sampling_params_list[2].height == 768
+ assert sampling_params_list[2].width == 1024
+ assert sampling_params_list[2].num_inference_steps == 28
+ assert engine.default_sampling_params_list[1].height is None
+ assert engine.default_sampling_params_list[2].resolution == 640
diff --git a/tests/entrypoints/openai_api/test_serving_chat_sampling_params.py b/tests/entrypoints/openai_api/test_serving_chat_sampling_params.py
index fa4c1e195..0a9d45a8c 100644
--- a/tests/entrypoints/openai_api/test_serving_chat_sampling_params.py
+++ b/tests/entrypoints/openai_api/test_serving_chat_sampling_params.py
@@ -100,6 +100,10 @@ def mock_request(mocker: MockerFixture):
request.stop_token_ids = None
request.frequency_penalty = None
request.presence_penalty = None
+ # Must be real Python objects (not MagicMock) so the code's explicit-field
+ # and extra_body checks work correctly.
+ request.model_fields_set = set()
+ request.extra_body = {}
return request
@@ -150,6 +154,7 @@ def test_preserves_yaml_defaults_when_no_request_params(serving_chat, mock_reque
def test_request_temperature_overrides_yaml_default(serving_chat, mock_request):
"""Test that request temperature overrides YAML default."""
mock_request.temperature = 0.8
+ mock_request.model_fields_set = {"temperature"}
result = serving_chat._build_sampling_params_list_from_request(mock_request)
@@ -162,6 +167,7 @@ def test_request_temperature_overrides_yaml_default(serving_chat, mock_request):
def test_request_top_p_overrides_yaml_default(serving_chat, mock_request):
"""Test that request top_p overrides YAML default."""
mock_request.top_p = 0.95
+ mock_request.model_fields_set = {"top_p"}
result = serving_chat._build_sampling_params_list_from_request(mock_request)
@@ -173,6 +179,7 @@ def test_request_top_p_overrides_yaml_default(serving_chat, mock_request):
def test_request_max_tokens_overrides_yaml_default(serving_chat, mock_request):
"""Test that request max_tokens overrides YAML default."""
mock_request.max_tokens = 100
+ mock_request.model_fields_set = {"max_tokens"}
result = serving_chat._build_sampling_params_list_from_request(mock_request)
@@ -189,6 +196,7 @@ def test_max_tokens_uses_yaml_default_when_not_specified(serving_chat, mock_requ
def test_request_seed_overrides_yaml_default(serving_chat, mock_request):
"""Test that request seed overrides YAML default."""
mock_request.seed = 123
+ mock_request.model_fields_set = {"seed"}
result = serving_chat._build_sampling_params_list_from_request(mock_request)
@@ -200,6 +208,7 @@ def test_request_seed_overrides_yaml_default(serving_chat, mock_request):
def test_request_frequency_penalty_overrides(serving_chat, mock_request):
"""Test that request frequency_penalty is applied."""
mock_request.frequency_penalty = 0.5
+ mock_request.model_fields_set = {"frequency_penalty"}
result = serving_chat._build_sampling_params_list_from_request(mock_request)
@@ -209,6 +218,7 @@ def test_request_frequency_penalty_overrides(serving_chat, mock_request):
def test_request_presence_penalty_overrides(serving_chat, mock_request):
"""Test that request presence_penalty is applied."""
mock_request.presence_penalty = 0.3
+ mock_request.model_fields_set = {"presence_penalty"}
result = serving_chat._build_sampling_params_list_from_request(mock_request)
@@ -235,6 +245,7 @@ def test_multiple_params_override_together(serving_chat, mock_request):
mock_request.temperature = 0.7
mock_request.top_p = 0.85
mock_request.seed = 999
+ mock_request.model_fields_set = {"max_tokens", "temperature", "top_p", "seed"}
result = serving_chat._build_sampling_params_list_from_request(mock_request)
@@ -275,6 +286,7 @@ def test_apply_request_overrides_applies_values(serving_chat, mock_request, defa
"""Test that _apply_request_overrides applies non-None request values."""
mock_request.temperature = 0.8
mock_request.seed = 123
+ mock_request.model_fields_set = {"temperature", "seed"}
result = serving_chat._apply_request_overrides(default_comprehension_params, mock_request)
@@ -284,6 +296,197 @@ def test_apply_request_overrides_applies_values(serving_chat, mock_request, defa
assert result.top_k == 1 # YAML custom param preserved
+# =============================================================================
+# Tests for empty-list handling in _apply_request_overrides
+# =============================================================================
+
+
+def test_apply_overrides_empty_stop_list_preserves_default(serving_chat, mocker):
+ """Test that request.stop=[] does NOT override YAML default stop words."""
+ default_params = SamplingParams(temperature=0.5, stop=["<|im_end|>"])
+ request = mocker.MagicMock()
+ request.temperature = None
+ request.top_p = None
+ request.top_k = None
+ request.max_tokens = None
+ request.min_tokens = None
+ request.seed = None
+ request.ignore_eos = None
+ request.stop = [] # empty list — should be treated as "not set"
+ request.stop_token_ids = None
+ request.frequency_penalty = None
+ request.presence_penalty = None
+ request.model_fields_set = {"stop"}
+ request.extra_body = {}
+
+ result = serving_chat._apply_request_overrides(default_params, request)
+
+ assert result.stop == ["<|im_end|>"] # YAML default preserved
+
+
+def test_apply_overrides_nonempty_stop_list_overrides_default(serving_chat, mocker):
+ """Test that request.stop=["\\n"] overrides YAML default stop words."""
+ default_params = SamplingParams(temperature=0.5, stop=["<|im_end|>"])
+ request = mocker.MagicMock()
+ request.temperature = None
+ request.top_p = None
+ request.top_k = None
+ request.max_tokens = None
+ request.min_tokens = None
+ request.seed = None
+ request.ignore_eos = None
+ request.stop = ["\n"] # non-empty list — should override
+ request.stop_token_ids = None
+ request.frequency_penalty = None
+ request.presence_penalty = None
+ request.model_fields_set = {"stop"}
+ request.extra_body = {}
+
+ result = serving_chat._apply_request_overrides(default_params, request)
+
+ assert result.stop == ["\n"] # Overridden by request
+
+
+def test_apply_overrides_empty_stop_token_ids_preserves_default(serving_chat, mocker):
+ """Test that request.stop_token_ids=[] does NOT override YAML default."""
+ default_params = SamplingParams(temperature=0.5, stop_token_ids=[2, 3])
+ request = mocker.MagicMock()
+ request.temperature = None
+ request.top_p = None
+ request.top_k = None
+ request.max_tokens = None
+ request.min_tokens = None
+ request.seed = None
+ request.ignore_eos = None
+ request.stop = None
+ request.stop_token_ids = [] # empty list — should be treated as "not set"
+ request.frequency_penalty = None
+ request.presence_penalty = None
+
+ result = serving_chat._apply_request_overrides(default_params, request)
+
+ assert result.stop_token_ids == [2, 3] # YAML default preserved
+
+
+def test_apply_overrides_nonempty_stop_token_ids_overrides_default(serving_chat, mocker):
+ """Test that request.stop_token_ids=[100] overrides YAML default."""
+ default_params = SamplingParams(temperature=0.5, stop_token_ids=[2, 3])
+ request = mocker.MagicMock()
+ request.temperature = None
+ request.top_p = None
+ request.top_k = None
+ request.max_tokens = None
+ request.min_tokens = None
+ request.seed = None
+ request.ignore_eos = None
+ request.stop = None
+ request.stop_token_ids = [100] # non-empty list — should override
+ request.frequency_penalty = None
+ request.presence_penalty = None
+ request.model_fields_set = {"stop_token_ids"}
+ request.extra_body = {}
+
+ result = serving_chat._apply_request_overrides(default_params, request)
+
+ assert result.stop_token_ids == [100] # Overridden by request
+
+
+def test_apply_overrides_mixed_empty_and_nonempty_lists(serving_chat, mocker):
+ """Test mixing empty and non-empty list fields with scalar fields."""
+ default_params = SamplingParams(
+ temperature=0.4,
+ stop=["<|end|>"],
+ stop_token_ids=[2],
+ )
+ request = mocker.MagicMock()
+ request.temperature = 0.9
+ request.top_p = None
+ request.top_k = None
+ request.max_tokens = None
+ request.min_tokens = None
+ request.seed = None
+ request.ignore_eos = None
+ request.stop = [] # empty — should NOT override
+ request.stop_token_ids = [100, 200] # non-empty — SHOULD override
+ request.frequency_penalty = None
+ request.presence_penalty = None
+ request.model_fields_set = {"temperature", "stop", "stop_token_ids"}
+ request.extra_body = {}
+
+ result = serving_chat._apply_request_overrides(default_params, request)
+
+ assert result.temperature == 0.9 # Scalar override works
+ assert result.stop == ["<|end|>"] # Empty list did NOT override
+ assert result.stop_token_ids == [100, 200] # Non-empty list DID override
+
+
+def test_apply_overrides_none_scalar_still_preserves_default(serving_chat, mocker):
+ """Regression: ensure None scalar values still don't override defaults."""
+ default_params = SamplingParams(temperature=0.5, max_tokens=100, seed=42)
+ request = mocker.MagicMock()
+ request.temperature = None
+ request.top_p = None
+ request.top_k = None
+ request.max_tokens = None
+ request.min_tokens = None
+ request.seed = None
+ request.ignore_eos = None
+ request.stop = None
+ request.stop_token_ids = None
+ request.frequency_penalty = None
+ request.presence_penalty = None
+ request.model_fields_set = set()
+ request.extra_body = {}
+
+ result = serving_chat._apply_request_overrides(default_params, request)
+
+ assert result.temperature == 0.5
+ assert result.max_tokens == 100
+ assert result.seed == 42
+
+
+def test_apply_overrides_both_lists_empty_preserves_defaults(serving_chat, mocker):
+ """Test that both stop=[] and stop_token_ids=[] preserve YAML defaults."""
+ default_params = SamplingParams(
+ temperature=0.5,
+ stop=["<|end|>", "\\n"],
+ stop_token_ids=[2, 32000],
+ )
+ request = mocker.MagicMock()
+ request.temperature = None
+ request.top_p = None
+ request.top_k = None
+ request.max_tokens = None
+ request.min_tokens = None
+ request.seed = None
+ request.ignore_eos = None
+ request.stop = []
+ request.stop_token_ids = []
+ request.frequency_penalty = None
+ request.presence_penalty = None
+ request.model_fields_set = {"stop", "stop_token_ids"}
+ request.extra_body = {}
+
+ result = serving_chat._apply_request_overrides(default_params, request)
+
+ assert result.stop == ["<|end|>", "\\n"]
+ assert result.stop_token_ids == [2, 32000]
+
+
+def test_build_sampling_params_list_empty_stop_preserves_yaml(serving_chat, mock_request):
+ """Test that empty stop list in request preserves YAML defaults via
+ _build_sampling_params_list_from_request."""
+ mock_request.stop = []
+ mock_request.stop_token_ids = []
+
+ result = serving_chat._build_sampling_params_list_from_request(mock_request)
+
+ comprehension_params = result[0]
+ # Empty lists should NOT override — YAML defaults are preserved
+ assert comprehension_params.stop == []
+ assert comprehension_params.stop_token_ids == []
+
+
# =============================================================================
# Tests for _get_comprehension_stage_index
# =============================================================================
@@ -332,3 +535,165 @@ def test_get_comprehension_stage_index_raises_when_not_found(mocker: MockerFixtu
with pytest.raises(ValueError, match="No comprehension stage"):
instance._get_comprehension_stage_index()
+
+
+# =============================================================================
+# Tests for _resolve_height_width_from_extra_body
+# =============================================================================
+
+
+class TestResolveHeightWidth:
+ def test_explicit_height_width(self):
+ from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
+
+ h, w = OmniOpenAIServingChat._resolve_height_width_from_extra_body({"height": 512, "width": 768})
+ assert h == 512
+ assert w == 768
+
+ def test_size_string(self):
+ from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
+
+ h, w = OmniOpenAIServingChat._resolve_height_width_from_extra_body({"size": "768x512"})
+ assert w == 768
+ assert h == 512
+
+ def test_size_string_uppercase(self):
+ from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
+
+ h, w = OmniOpenAIServingChat._resolve_height_width_from_extra_body({"size": "768X512"})
+ assert w == 768
+ assert h == 512
+
+ def test_size_fallback_when_height_missing(self):
+ from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
+
+ h, w = OmniOpenAIServingChat._resolve_height_width_from_extra_body({"size": "512x512", "width": 1024})
+ # height is None -> size fallback fires and sets BOTH width and height
+ assert h == 512
+ assert w == 512
+
+ def test_empty_extra_body(self):
+ from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
+
+ h, w = OmniOpenAIServingChat._resolve_height_width_from_extra_body({})
+ assert h is None
+ assert w is None
+
+ def test_invalid_size_format_ignored(self):
+ from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
+
+ h, w = OmniOpenAIServingChat._resolve_height_width_from_extra_body({"size": "invalid"})
+ assert h is None
+ assert w is None
+
+
+# =============================================================================
+# Tests for _apply_request_overrides with GLM-Image (max_tokens computation)
+# =============================================================================
+
+
+class TestApplyRequestOverridesGLMImage:
+ """Test dynamic max_tokens computation for GLM-Image AR stage."""
+
+ @pytest.fixture
+ def glm_serving_chat(self, mock_engine_client, mocker: MockerFixture):
+ from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
+
+ instance = object.__new__(OmniOpenAIServingChat)
+ instance.engine_client = mock_engine_client
+ # Mock the image extraction to return no reference images (t2i by default)
+ instance._extract_diffusion_prompt_and_images_from_messages = mocker.MagicMock(return_value=("a cat", []))
+ return instance
+
+ @pytest.fixture
+ def glm_request(self, mocker: MockerFixture):
+ req = mocker.MagicMock()
+ req.temperature = None
+ req.top_p = None
+ req.top_k = None
+ req.max_tokens = None
+ req.min_tokens = None
+ req.seed = None
+ req.ignore_eos = None
+ req.stop = None
+ req.stop_token_ids = None
+ req.frequency_penalty = None
+ req.presence_penalty = None
+ req.extra_body = {"height": 1024, "width": 1024}
+ req.model_fields_set = set()
+ return req
+
+ def test_t2i_computes_max_tokens(self, glm_serving_chat, glm_request, default_comprehension_params):
+ """t2i mode: max_tokens computed from height/width, no reference images."""
+ result = glm_serving_chat._apply_request_overrides(default_comprehension_params, glm_request)
+ # t2i 1024x1024 = 256 + 1024 + 1 = 1281
+ assert result.max_tokens == 1281
+ assert result.extra_args["target_h"] == 1024
+ assert result.extra_args["target_w"] == 1024
+
+ def test_i2i_computes_fewer_tokens(
+ self, glm_serving_chat, glm_request, default_comprehension_params, mocker: MockerFixture
+ ):
+ """i2i mode: max_tokens should be smaller than t2i for same dimensions."""
+ # Make it detect reference images
+ glm_serving_chat._extract_diffusion_prompt_and_images_from_messages = mocker.MagicMock(
+ return_value=("edit this", ["fake_image"])
+ )
+
+ result = glm_serving_chat._apply_request_overrides(default_comprehension_params, glm_request)
+ # i2i 1024x1024 = 1024 + 1 = 1025
+ assert result.max_tokens == 1025
+
+ def test_dynamic_max_tokens_overrides_user_value(self, glm_serving_chat, glm_request, default_comprehension_params):
+ """When height/width are provided, dynamic computation overrides user max_tokens."""
+ glm_request.max_tokens = 500
+ glm_request.model_fields_set = {"max_tokens"}
+
+ result = glm_serving_chat._apply_request_overrides(default_comprehension_params, glm_request)
+ # Dynamic computation from height/width always wins when present
+ assert result.max_tokens == 1281
+
+ def test_no_height_width_preserves_default(
+ self, glm_serving_chat, mocker: MockerFixture, default_comprehension_params
+ ):
+ """When no height/width in extra_body, keep YAML default max_tokens."""
+ req = mocker.MagicMock()
+ req.temperature = None
+ req.top_p = None
+ req.top_k = None
+ req.max_tokens = None
+ req.min_tokens = None
+ req.seed = None
+ req.ignore_eos = None
+ req.stop = None
+ req.stop_token_ids = None
+ req.frequency_penalty = None
+ req.presence_penalty = None
+ req.extra_body = {}
+ req.model_fields_set = set()
+
+ result = glm_serving_chat._apply_request_overrides(default_comprehension_params, req)
+ assert result.max_tokens == 2048 # YAML default
+
+ def test_size_string_parsed_for_glm_image(
+ self, glm_serving_chat, mocker: MockerFixture, default_comprehension_params
+ ):
+ """'size' in extra_body is parsed as fallback for height/width."""
+ req = mocker.MagicMock()
+ req.temperature = None
+ req.top_p = None
+ req.top_k = None
+ req.max_tokens = None
+ req.min_tokens = None
+ req.seed = None
+ req.ignore_eos = None
+ req.stop = None
+ req.stop_token_ids = None
+ req.frequency_penalty = None
+ req.presence_penalty = None
+ req.extra_body = {"size": "512x512"}
+ req.model_fields_set = set()
+
+ result = glm_serving_chat._apply_request_overrides(default_comprehension_params, req)
+ # 512x512 t2i = 256 + 256 + 1 = 513
+ assert result.max_tokens == 513
diff --git a/tests/entrypoints/openai_api/test_serving_speech_voxcpm.py b/tests/entrypoints/openai_api/test_serving_speech_voxcpm.py
new file mode 100644
index 000000000..48660b6d1
--- /dev/null
+++ b/tests/entrypoints/openai_api/test_serving_speech_voxcpm.py
@@ -0,0 +1,143 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+"""UTs for VoxCPM OpenAI speech serving behavior."""
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from pytest_mock import MockerFixture
+
+from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
+from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+@pytest.fixture
+def voxcpm_server(mocker: MockerFixture):
+ mocker.patch.object(OmniOpenAIServingSpeech, "_load_supported_speakers", return_value=set())
+ mocker.patch.object(OmniOpenAIServingSpeech, "_load_codec_frame_rate", return_value=None)
+
+ mock_engine_client = mocker.MagicMock()
+ mock_engine_client.errored = False
+ mock_engine_client.model_config = mocker.MagicMock(model="OpenBMB/VoxCPM1.5")
+ mock_engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)]
+ mock_engine_client.tts_batch_max_items = 32
+ mock_engine_client.generate = mocker.MagicMock(return_value="generator")
+ mock_engine_client.stage_configs = [
+ SimpleNamespace(
+ engine_args=SimpleNamespace(
+ model_stage="latent_generator",
+ model_arch="VoxCPMForConditionalGeneration",
+ ),
+ tts_args={},
+ ),
+ SimpleNamespace(
+ engine_args=SimpleNamespace(model_stage="vae"),
+ tts_args={},
+ ),
+ ]
+
+ mock_models = mocker.MagicMock()
+ mock_models.is_base_model.return_value = True
+
+ return OmniOpenAIServingSpeech(
+ engine_client=mock_engine_client,
+ models=mock_models,
+ request_logger=mocker.MagicMock(),
+ )
+
+
+class TestVoxCPMServing:
+ def test_voxcpm_model_type_detection(self, voxcpm_server):
+ assert voxcpm_server._tts_model_type == "voxcpm"
+ assert voxcpm_server._is_tts is True
+ assert voxcpm_server.supported_speakers == set()
+
+ @pytest.mark.parametrize(
+ ("request_kwargs", "expected_substring"),
+ [
+ ({"voice": "alice"}, "voice"),
+ ({"instructions": "whisper"}, "instructions"),
+ ({"language": "en"}, "language"),
+ ({"task_type": "CustomVoice"}, "plain tts"),
+ ({"x_vector_only_mode": True}, "x_vector_only_mode"),
+ ({"speaker_embedding": [0.1, 0.2]}, "speaker_embedding"),
+ ({"initial_codec_chunk_frames": 4}, "initial_codec_chunk_frames"),
+ ({"ref_text": "reference"}, "ref_audio"),
+ ],
+ )
+ def test_validate_voxcpm_rejects_unsupported_fields(self, voxcpm_server, request_kwargs, expected_substring):
+ request = OpenAICreateSpeechRequest(input="hello voxcpm", **request_kwargs)
+ error = voxcpm_server._validate_voxcpm_request(request)
+ assert error is not None
+ assert expected_substring in error.lower()
+
+ def test_validate_voxcpm_accepts_plain_tts_request(self, voxcpm_server):
+ request = OpenAICreateSpeechRequest(input="hello voxcpm", max_new_tokens=256)
+ assert voxcpm_server._validate_voxcpm_request(request) is None
+
+ def test_validate_voxcpm_accepts_voice_clone_request(self, voxcpm_server):
+ request = OpenAICreateSpeechRequest(
+ input="clone this voice",
+ ref_audio="data:audio/wav;base64,QUJD",
+ ref_text="reference transcript",
+ max_new_tokens=256,
+ )
+ assert voxcpm_server._validate_voxcpm_request(request) is None
+
+ def test_prepare_speech_generation_voxcpm_text_only(self, voxcpm_server):
+ request = OpenAICreateSpeechRequest(input="hello voxcpm", max_new_tokens=321)
+
+ request_id, generator, tts_params = asyncio.run(voxcpm_server._prepare_speech_generation(request))
+
+ assert request_id.startswith("speech-")
+ assert generator == "generator"
+ assert tts_params == {
+ "text": ["hello voxcpm"],
+ "cfg_value": [2.0],
+ "inference_timesteps": [10],
+ "min_len": [2],
+ "max_new_tokens": [321],
+ }
+
+ voxcpm_server.engine_client.generate.assert_called_once()
+ call = voxcpm_server.engine_client.generate.call_args
+ assert call.kwargs["prompt"] == {
+ "prompt_token_ids": [1],
+ "additional_information": tts_params,
+ }
+ assert call.kwargs["output_modalities"] == ["audio"]
+
+ def test_prepare_speech_generation_voxcpm_voice_clone_resolves_ref_audio(self, voxcpm_server):
+ voxcpm_server._resolve_ref_audio = AsyncMock(return_value=([0.1, -0.1, 0.2], 16000))
+ request = OpenAICreateSpeechRequest(
+ input="clone this voice",
+ ref_audio="data:audio/wav;base64,QUJD",
+ ref_text="reference transcript",
+ max_new_tokens=512,
+ )
+
+ request_id, generator, tts_params = asyncio.run(voxcpm_server._prepare_speech_generation(request))
+
+ assert request_id.startswith("speech-")
+ assert generator == "generator"
+ assert tts_params == {
+ "text": ["clone this voice"],
+ "cfg_value": [2.0],
+ "inference_timesteps": [10],
+ "min_len": [2],
+ "max_new_tokens": [512],
+ "ref_text": ["reference transcript"],
+ "ref_audio": [[[0.1, -0.1, 0.2], 16000]],
+ }
+
+ voxcpm_server._resolve_ref_audio.assert_awaited_once_with("data:audio/wav;base64,QUJD")
+ call = voxcpm_server.engine_client.generate.call_args
+ assert call.kwargs["prompt"] == {
+ "prompt_token_ids": [1],
+ "additional_information": tts_params,
+ }
diff --git a/tests/entrypoints/openai_api/test_text_splitter.py b/tests/entrypoints/openai_api/test_text_splitter.py
index a1886662a..b9022e015 100644
--- a/tests/entrypoints/openai_api/test_text_splitter.py
+++ b/tests/entrypoints/openai_api/test_text_splitter.py
@@ -4,7 +4,7 @@ import pytest
from vllm_omni.entrypoints.openai.text_splitter import SentenceSplitter
-pytestmark = [pytest.mark.openai, pytest.mark.speech, pytest.mark.core_model, pytest.mark.cpu]
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class TestSentenceSplitterEnglish:
diff --git a/tests/entrypoints/openai_api/test_video_api_utils.py b/tests/entrypoints/openai_api/test_video_api_utils.py
new file mode 100644
index 000000000..9e732403f
--- /dev/null
+++ b/tests/entrypoints/openai_api/test_video_api_utils.py
@@ -0,0 +1,93 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Unit tests for OpenAI-compatible video API encoding helpers."""
+
+import numpy as np
+import pytest
+import torch
+
+from vllm_omni.diffusion.postprocess import rife_interpolator
+from vllm_omni.entrypoints.openai import video_api_utils
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def _install_fake_video_mux(monkeypatch, mux_calls):
+ def _fake_mux_video_audio_bytes(frames, audio, fps, audio_sample_rate, video_codec_options=None):
+ mux_calls.append(
+ {
+ "frames": frames,
+ "audio": audio,
+ "fps": fps,
+ "audio_sample_rate": audio_sample_rate,
+ "video_codec_options": video_codec_options,
+ }
+ )
+ return b"fake-video"
+
+ monkeypatch.setattr(
+ "vllm_omni.diffusion.utils.media_utils.mux_video_audio_bytes",
+ _fake_mux_video_audio_bytes,
+ )
+
+
+def test_encode_video_bytes_exports_frames_without_interpolation(monkeypatch):
+ mux_calls = []
+ _install_fake_video_mux(monkeypatch, mux_calls)
+
+ frames = [np.full((2, 2, 3), fill_value=i / 5, dtype=np.float32) for i in range(5)]
+ video_bytes = video_api_utils._encode_video_bytes(
+ frames,
+ fps=8,
+ )
+
+ assert video_bytes == b"fake-video"
+ assert mux_calls[0]["frames"].shape == (5, 2, 2, 3)
+ assert mux_calls[0]["frames"].dtype == np.uint8
+ assert mux_calls[0]["fps"] == 8.0
+ assert mux_calls[0]["audio"] is None
+
+
+def test_rife_model_inference_runs_on_dummy_tensors():
+ model = rife_interpolator.Model().eval()
+ img0 = torch.rand(1, 3, 32, 32)
+ img1 = torch.rand(1, 3, 32, 32)
+
+ output = model.inference(img0, img1, scale=1.0)
+
+ assert output.shape == (1, 3, 32, 32)
+ assert torch.isfinite(output).all()
+
+
+def test_frame_interpolator_runs_actual_torch_tensor_path(monkeypatch):
+ model = rife_interpolator.Model().eval()
+ interpolator = rife_interpolator.FrameInterpolator()
+ monkeypatch.setattr(interpolator, "_ensure_model_loaded", lambda preferred_device=None: model)
+
+ video = torch.zeros(1, 3, 2, 32, 32)
+ output_video, multiplier = interpolator.interpolate_tensor(video, exp=1, scale=1.0)
+
+ assert multiplier == 2
+ assert output_video.shape == (1, 3, 3, 32, 32)
+ assert torch.isfinite(output_video).all()
+
+
+def test_frame_interpolator_uses_platform_device_when_tensor_is_cpu(monkeypatch):
+ chosen_devices = []
+ model = rife_interpolator.Model().eval()
+
+ def _fake_ensure_model_loaded(*, preferred_device=None):
+ chosen_devices.append(preferred_device)
+ return model
+
+ interpolator = rife_interpolator.FrameInterpolator()
+ monkeypatch.setattr(interpolator, "_ensure_model_loaded", _fake_ensure_model_loaded)
+ monkeypatch.setattr(model.flownet, "to", lambda device: model.flownet)
+ monkeypatch.setattr(rife_interpolator, "_select_torch_device", lambda: torch.device("cuda"))
+
+ video = torch.zeros(1, 3, 2, 32, 32)
+ output_video, multiplier = interpolator.interpolate_tensor(video, exp=1, scale=1.0)
+
+ assert chosen_devices == [torch.device("cuda")]
+ assert multiplier == 2
+ assert output_video.shape == (1, 3, 3, 32, 32)
diff --git a/tests/entrypoints/openai_api/test_video_server.py b/tests/entrypoints/openai_api/test_video_server.py
index 82c34f87e..1cc561665 100644
--- a/tests/entrypoints/openai_api/test_video_server.py
+++ b/tests/entrypoints/openai_api/test_video_server.py
@@ -34,15 +34,28 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class MockVideoResult:
- def __init__(self, videos, audios=None, sample_rate=None, stage_durations=None, peak_memory_mb=0.0):
+ def __init__(
+ self,
+ videos,
+ audios=None,
+ sample_rate=None,
+ custom_output=None,
+ stage_durations=None,
+ peak_memory_mb=0.0,
+ ):
self.multimodal_output = {"video": videos}
if audios is not None:
self.multimodal_output["audio"] = audios
if sample_rate is not None:
self.multimodal_output["audio_sample_rate"] = sample_rate
+ self._custom_output = custom_output or {}
self.stage_durations = stage_durations or {}
self.peak_memory_mb = peak_memory_mb
+ @property
+ def custom_output(self):
+ return self._custom_output
+
class FakeAsyncOmni:
def __init__(self):
@@ -120,7 +133,6 @@ def _wait_for_status(client: TestClient, video_id: str, status: str, timeout_s:
last_payload = None
while time.time() < deadline:
response = client.get(f"/v1/videos/{video_id}")
- assert response.status_code == 200
last_payload = response.json()
if last_payload["status"] == status:
return last_payload
@@ -400,6 +412,67 @@ def test_sampling_params_pass_through(test_client, mocker: MockerFixture):
assert captured.extra_args["flow_shift"] == 0.25
+def test_frame_interpolation_params_pass_to_diffusion_sampling_params(test_client, mocker: MockerFixture):
+ """Frame interpolation parameters should be forwarded to diffusion worker sampling params."""
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
+ return_value=b"fake-video",
+ )
+ response = test_client.post(
+ "/v1/videos",
+ data={
+ "prompt": "smooth motion",
+ "fps": "8",
+ "enable_frame_interpolation": "true",
+ "frame_interpolation_exp": "2",
+ "frame_interpolation_scale": "0.5",
+ "frame_interpolation_model_path": "local-rife",
+ },
+ )
+
+ assert response.status_code == 200
+ video_id = response.json()["id"]
+ _wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
+
+ engine = test_client.app.state.openai_serving_video._engine_client
+ captured = engine.captured_sampling_params_list[0]
+ assert captured.enable_frame_interpolation is True
+ assert captured.frame_interpolation_exp == 2
+ assert captured.frame_interpolation_scale == 0.5
+ assert captured.frame_interpolation_model_path == "local-rife"
+
+
+def test_worker_fps_multiplier_is_applied_to_async_encoding(test_client, mocker: MockerFixture):
+ fps_values = []
+ engine = test_client.app.state.openai_serving_video._engine_client
+
+ async def _generate(prompt, request_id, sampling_params_list):
+ engine.captured_prompt = prompt
+ engine.captured_sampling_params_list = sampling_params_list
+ import numpy as np
+
+ yield MockVideoResult([np.zeros((1, 64, 64, 3), dtype=np.uint8)], custom_output={"video_fps_multiplier": 2})
+
+ engine.generate = _generate
+
+ def _fake_encode(video, fps, **kwargs):
+ del video, kwargs
+ fps_values.append(fps)
+ return b"fake-video"
+
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
+ side_effect=_fake_encode,
+ )
+
+ response = test_client.post("/v1/videos", data={"prompt": "fps multiplier", "fps": "8"})
+
+ assert response.status_code == 200
+ video_id = response.json()["id"]
+ _wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
+ assert fps_values == [16]
+
+
def test_audio_sample_rate_comes_from_model_config(test_client, mocker: MockerFixture):
audio_sample_rates = []
@@ -490,6 +563,18 @@ def test_missing_prompt_returns_422(test_client):
assert response.status_code == 422
+def test_video_generation_rejects_model_mismatch(test_client):
+ response = test_client.post(
+ "/v1/videos",
+ data={
+ "prompt": "bad model",
+ "model": "Wan-AI/Wan2.1-T2V-14B-Diffusers",
+ },
+ )
+ assert response.status_code == 400
+ assert "model mismatch" in response.json()["detail"].lower()
+
+
def test_invalid_size_parse_returns_422(test_client):
response = test_client.post(
"/v1/videos",
@@ -558,7 +643,7 @@ def test_invalid_lora_returns_400(test_client):
assert response.status_code == 200
video_id = response.json()["id"]
failed = _wait_for_status(test_client, video_id, VideoGenerationStatus.FAILED.value)
- assert failed["error"]["code"] == "HTTPException"
+ assert failed["error"]["code"] == 400
assert "lora object" in failed["error"]["message"].lower()
@@ -595,6 +680,10 @@ def test_video_request_validation():
with pytest.raises(ValueError):
VideoGenerationRequest(prompt="test", image_reference={"file_id": "file-1", "image_url": "https://example.com"})
+ with pytest.raises(ValueError):
+ VideoGenerationRequest(prompt="test", frame_interpolation_exp=0)
+ with pytest.raises(ValueError):
+ VideoGenerationRequest(prompt="test", frame_interpolation_scale=0)
def test_list_videos_supports_order_after_and_limit(test_client, mocker: MockerFixture):
@@ -1032,3 +1121,57 @@ def test_sync_sampling_params_pass_through(test_client, mocker: MockerFixture):
assert captured.num_inference_steps == 30
assert captured.guidance_scale == 6.5
assert captured.seed == 42
+
+
+def test_sync_frame_interpolation_params_pass_to_sampling_params(test_client, mocker: MockerFixture):
+ """Frame interpolation parameters should be forwarded on the sync path."""
+ encode_mock = _mock_encode_video_bytes(mocker)
+ response = test_client.post(
+ "/v1/videos/sync",
+ data={
+ "prompt": "smooth sync",
+ "fps": "8",
+ "enable_frame_interpolation": "true",
+ "frame_interpolation_exp": "2",
+ "frame_interpolation_scale": "0.5",
+ "frame_interpolation_model_path": "local-rife",
+ },
+ )
+
+ assert response.status_code == 200
+ engine = test_client.app.state.openai_serving_video._engine_client
+ captured = engine.captured_sampling_params_list[0]
+ assert captured.enable_frame_interpolation is True
+ assert captured.frame_interpolation_exp == 2
+ assert captured.frame_interpolation_scale == 0.5
+ assert captured.frame_interpolation_model_path == "local-rife"
+ _, kwargs = encode_mock.call_args
+ assert kwargs["fps"] == 8
+
+
+def test_worker_fps_multiplier_is_applied_to_sync_encoding(test_client, mocker: MockerFixture):
+ engine = test_client.app.state.openai_serving_video._engine_client
+ fps_values = []
+
+ async def _generate(prompt, request_id, sampling_params_list):
+ engine.captured_prompt = prompt
+ engine.captured_sampling_params_list = sampling_params_list
+ yield MockVideoResult([object()], custom_output={"video_fps_multiplier": 2})
+
+ engine.generate = _generate
+
+ def _fake_encode(video, fps, **kwargs):
+ del video, kwargs
+ fps_values.append(fps)
+ return b"fps-multiplied"
+
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
+ side_effect=_fake_encode,
+ )
+
+ response = test_client.post("/v1/videos/sync", data={"prompt": "fps multiplier", "fps": "8"})
+
+ assert response.status_code == 200
+ assert response.content == b"fps-multiplied"
+ assert fps_values == [16]
diff --git a/tests/entrypoints/test_async_omni_diffusion_config.py b/tests/entrypoints/test_async_omni_diffusion_config.py
index a55eaf05b..7ed812826 100644
--- a/tests/entrypoints/test_async_omni_diffusion_config.py
+++ b/tests/entrypoints/test_async_omni_diffusion_config.py
@@ -69,6 +69,20 @@ def test_default_stage_config_propagates_ulysses_mode():
assert parallel_config.ulysses_mode == "advanced_uaa"
+def test_default_stage_config_includes_default_sampling_params():
+ """Ensure default sampling params survive the default diffusion-stage builder."""
+ stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(
+ {
+ "default_sampling_params": '{"0": {"generator_device":"cpu", "guidance_scale":7.5}}',
+ }
+ )[0]
+
+ assert stage_cfg["default_sampling_params"] == {
+ "generator_device": "cpu",
+ "guidance_scale": 7.5,
+ }
+
+
def test_serve_cli_accepts_ulysses_mode():
"""Ensure diffusion serve CLI exposes ulysses_mode and wires it to parallel_config."""
parser = FlexibleArgumentParser()
diff --git a/tests/entrypoints/test_cfg_companion_tracker.py b/tests/entrypoints/test_cfg_companion_tracker.py
deleted file mode 100644
index 941ead41f..000000000
--- a/tests/entrypoints/test_cfg_companion_tracker.py
+++ /dev/null
@@ -1,114 +0,0 @@
-import time
-from types import SimpleNamespace
-
-import pytest
-
-from vllm_omni.entrypoints.cfg_companion_tracker import CfgCompanionTracker
-
-pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
-
-
-def dummy_expand_func(prompt, sp0):
- if prompt == "expand_me":
- return [SimpleNamespace(prompt={"prompt": "neg"}, role="cfg_text", request_id_suffix="__cfg_text")]
- return []
-
-
-@pytest.fixture
-def tracker():
- sp0 = SimpleNamespace()
- return CfgCompanionTracker(prompt_expand_func=dummy_expand_func, stage0_sampling_params=sp0, timeout_s=0.1)
-
-
-def test_companion_tracker_initialization(tracker):
- assert not tracker.is_active
- assert tracker.num_companions == 0
-
-
-def test_expand_prompts_registers_companions(tracker):
- request_id_to_prompt = {"req1": "expand_me", "req2": "do_not_expand"}
-
- pairs = tracker.expand_prompts(request_id_to_prompt)
-
- assert len(pairs) == 1
- companion_id, prompt = pairs[0]
- assert companion_id == "req1__cfg_text"
- assert prompt == {"prompt": "neg"}
-
- assert tracker.is_active
- assert tracker.num_companions == 1
- assert tracker.is_companion("req1__cfg_text")
- assert not tracker.is_companion("req2__cfg_text")
- assert tracker.has_companions("req1")
- assert not tracker.has_companions("req2")
-
- comp_map = tracker.get_companion_request_ids("req1")
- assert comp_map == {"cfg_text": "req1__cfg_text"}
-
-
-def test_companion_lifecycle_success(tracker):
- request_id_to_prompt = {"req1": "expand_me"}
- tracker.expand_prompts(request_id_to_prompt)
-
- # Defer parent
- engine_outputs = {"out": 123}
- tracker.defer_parent("req1", engine_outputs, stage_id=0)
-
- # Initially not done
- assert not tracker.all_companions_done("req1")
-
- # Companion completes
- parent_id = tracker.on_companion_completed("req1__cfg_text")
-
- # Parent should be returned since all companions are done and it is pending
- assert parent_id == "req1"
- assert tracker.all_companions_done("req1")
-
- # Pop pending parent
- popped = tracker.pop_pending_parent("req1")
- assert popped is not None
- assert popped["engine_outputs"] == engine_outputs
- assert popped["stage_id"] == 0
-
-
-def test_companion_lifecycle_failure(tracker):
- request_id_to_prompt = {"req1": "expand_me"}
- tracker.expand_prompts(request_id_to_prompt)
-
- tracker.defer_parent("req1", {"out": 123}, stage_id=0)
-
- # Companion fails
- parent_id, aborted = tracker.on_companion_error("req1__cfg_text")
-
- assert parent_id == "req1"
- assert aborted is True
- assert tracker.is_parent_failed("req1")
-
- # Parent should be removed from pending list
- assert tracker.pop_pending_parent("req1") is None
-
- # Consume failure
- tracker.consume_parent_failure("req1")
- assert not tracker.is_parent_failed("req1")
-
-
-def test_companion_lifecycle_timeout(tracker):
- request_id_to_prompt = {"req1": "expand_me"}
- tracker.expand_prompts(request_id_to_prompt)
-
- tracker.defer_parent("req1", {"out": 123}, stage_id=0)
-
- # Initially no timeouts
- timeouts = tracker.check_timeouts()
- assert len(timeouts) == 0
-
- # Wait for timeout
- time.sleep(0.15)
-
- # Check timeouts again
- timeouts = tracker.check_timeouts()
- assert len(timeouts) == 1
- assert timeouts[0] == "req1"
-
- # Should be removed from pending
- assert tracker.pop_pending_parent("req1") is None
diff --git a/tests/entrypoints/test_omni_entrypoints.py b/tests/entrypoints/test_omni_entrypoints.py
index 3cffcd37d..289cf2673 100644
--- a/tests/entrypoints/test_omni_entrypoints.py
+++ b/tests/entrypoints/test_omni_entrypoints.py
@@ -4,11 +4,13 @@ import queue
from collections.abc import Callable
from types import SimpleNamespace
from typing import Any
+from unittest.mock import MagicMock
import pytest
from vllm.entrypoints.openai.models.protocol import BaseModelPath
from vllm.entrypoints.openai.models.serving import OpenAIServingModels
from vllm.sampling_params import RequestOutputKind, SamplingParams
+from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.omni import Omni
@@ -163,6 +165,15 @@ def _patch_engine(monkeypatch: pytest.MonkeyPatch, engine: FakeAsyncOmniEngine)
monkeypatch.setattr("vllm_omni.entrypoints.omni_base.omni_snapshot_download", lambda model: model)
+def _make_base():
+ from vllm_omni.entrypoints.omni_base import OmniBase
+
+ obj = object.__new__(OmniBase)
+ obj.engine = MagicMock()
+ obj.request_states = {}
+ return obj
+
+
def _stage_spec(
stage_id: int,
*,
@@ -687,3 +698,255 @@ def test_omni_forces_final_only_on_llm_stages(monkeypatch: pytest.MonkeyPatch):
assert submitted_params[1].output_kind == RequestOutputKind.FINAL_ONLY
assert submitted_params[2].output_kind == original_diffusion_output_kind
assert len(outputs) == 2
+
+
+def test_fatal_error_raises_engine_dead():
+ base = _make_base()
+ msg = {"type": "error", "error": "orchestrator crashed", "fatal": True}
+
+ with pytest.raises(EngineDeadError, match="orchestrator crashed"):
+ base._handle_output_message(msg)
+
+
+def test_non_fatal_error_raises_runtime():
+ base = _make_base()
+ msg = {"type": "error", "error": "something wrong"}
+
+ with pytest.raises(RuntimeError, match="something wrong"):
+ base._handle_output_message(msg)
+
+
+def test_async_omni_errored_property_alive():
+ omni = object.__new__(AsyncOmni)
+ omni.engine = SimpleNamespace(
+ is_alive=lambda: True,
+ stage_clients=[SimpleNamespace(is_comprehension=False)],
+ )
+
+ assert omni.errored is False
+
+
+def test_async_omni_errored_property_dead_engine():
+ omni = object.__new__(AsyncOmni)
+ omni.engine = SimpleNamespace(
+ is_alive=lambda: False,
+ stage_clients=[SimpleNamespace(is_comprehension=False)],
+ )
+
+ assert omni.errored is True
+
+
+def test_async_omni_errored_property_dead_stage():
+ omni = object.__new__(AsyncOmni)
+ dead_stage = SimpleNamespace(is_comprehension=False, _engine_dead=True)
+ omni.engine = SimpleNamespace(
+ is_alive=lambda: True,
+ stage_clients=[dead_stage],
+ )
+
+ assert omni.errored is True
+
+
+def _enqueue_stage_error(
+ engine: FakeAsyncOmniEngine,
+ msg,
+ *,
+ error_text: str,
+ kill_engine: bool = False,
+):
+ """Enqueue a stage error output, optionally killing the engine."""
+ if kill_engine:
+ engine._alive = False
+ engine.output_q.put_nowait(
+ {
+ "type": "output",
+ "request_id": msg["request_id"],
+ "stage_id": 0,
+ "engine_outputs": SimpleNamespace(
+ payload="",
+ finished=True,
+ images=[],
+ stage_durations={},
+ error=error_text,
+ ),
+ "finished": False,
+ }
+ )
+
+
+@pytest.mark.asyncio
+async def test_async_omni_propagates_engine_dead_error(monkeypatch: pytest.MonkeyPatch):
+ """When the engine is dead and an error output arrives, ``generate()``
+ must raise ``EngineDeadError`` (not plain ``RuntimeError``)."""
+
+ engine = FakeAsyncOmniEngine(
+ stage_metadata=THREE_STAGE_META,
+ on_add_request=lambda eng, msg: _enqueue_stage_error(eng, msg, error_text="worker OOM", kill_engine=True),
+ )
+ _patch_engine(monkeypatch, engine)
+
+ app = AsyncOmni("dummy-model")
+ try:
+ with pytest.raises(EngineDeadError, match="worker OOM"):
+ async for _ in app.generate(prompt="hello", request_id="req-dead"):
+ pass
+ finally:
+ app.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_async_omni_propagates_engine_generate_error(monkeypatch: pytest.MonkeyPatch):
+ """When the engine is alive but a stage error occurs, ``generate()``
+ must raise ``EngineGenerateError`` (recoverable, not ``EngineDeadError``)."""
+
+ engine = FakeAsyncOmniEngine(
+ stage_metadata=THREE_STAGE_META,
+ on_add_request=lambda eng, msg: _enqueue_stage_error(eng, msg, error_text="diffusion step failed"),
+ )
+ _patch_engine(monkeypatch, engine)
+
+ app = AsyncOmni("dummy-model")
+ try:
+ with pytest.raises(EngineGenerateError):
+ async for _ in app.generate(prompt="hello", request_id="req-recover"):
+ pass
+ finally:
+ app.shutdown()
+
+
+# ───────── OmniBase.check_health() aggregation ─────────
+
+
+def test_check_health_passes_when_all_healthy():
+ base = _make_base()
+ healthy_stage = MagicMock()
+ healthy_stage.check_health = MagicMock()
+ base.engine.is_alive.return_value = True
+ base.engine.stage_clients = [healthy_stage]
+ base.check_health() # should not raise
+
+
+def test_check_health_raises_when_stage_dead():
+ base = _make_base()
+ dead_stage = MagicMock()
+ dead_stage.check_health = MagicMock(side_effect=EngineDeadError("Stage-1 dead"))
+ base.engine.is_alive.return_value = True
+ base.engine.stage_clients = [dead_stage]
+ with pytest.raises(EngineDeadError, match="Stage-1 dead"):
+ base.check_health()
+
+
+def test_check_health_raises_when_orchestrator_dead():
+ base = _make_base()
+ base.engine.is_alive.return_value = False
+ base.engine.stage_clients = []
+ with pytest.raises(EngineDeadError, match="not alive"):
+ base.check_health()
+
+
+# ───────── OmniBase.errored property ─────────
+
+
+def test_omni_base_errored_false_when_alive():
+ base = _make_base()
+ base.engine.is_alive.return_value = True
+ base.engine.stage_clients = [SimpleNamespace()]
+ assert base.errored is False
+
+
+def test_omni_base_errored_true_when_orchestrator_dead():
+ base = _make_base()
+ base.engine.is_alive.return_value = False
+ base.engine.stage_clients = []
+ assert base.errored is True
+
+
+def test_omni_base_errored_true_when_stage_engine_dead():
+ base = _make_base()
+ base.engine.is_alive.return_value = True
+ dead_stage = SimpleNamespace(_engine_dead=True)
+ base.engine.stage_clients = [dead_stage]
+ assert base.errored is True
+
+
+def test_omni_base_errored_true_when_stage_resources_engine_dead():
+ base = _make_base()
+ base.engine.is_alive.return_value = True
+ dead_stage = SimpleNamespace(resources=SimpleNamespace(engine_dead=True))
+ base.engine.stage_clients = [dead_stage]
+ assert base.errored is True
+
+
+# ───────── Omni (sync) EngineDeadError / EngineGenerateError ─────────
+
+
+def test_omni_propagates_engine_dead_error(monkeypatch: pytest.MonkeyPatch):
+ """When the engine is dead and a stage error output arrives,
+ ``Omni.generate()`` must raise ``EngineDeadError``."""
+ engine = FakeAsyncOmniEngine(
+ stage_metadata=THREE_STAGE_META,
+ on_add_request=lambda eng, msg: _enqueue_stage_error(eng, msg, error_text="worker OOM", kill_engine=True),
+ )
+ _patch_engine(monkeypatch, engine)
+
+ app = Omni("dummy-model")
+ try:
+ with pytest.raises(EngineDeadError, match="worker OOM"):
+ list(app.generate(["hello"], py_generator=False, use_tqdm=False))
+ finally:
+ app.shutdown()
+
+
+def test_omni_propagates_engine_generate_error(monkeypatch: pytest.MonkeyPatch):
+ """When the engine is alive but a stage error occurs,
+ ``Omni.generate()`` must raise ``EngineGenerateError`` (recoverable)."""
+ engine = FakeAsyncOmniEngine(
+ stage_metadata=THREE_STAGE_META,
+ on_add_request=lambda eng, msg: _enqueue_stage_error(eng, msg, error_text="diffusion step failed"),
+ )
+ _patch_engine(monkeypatch, engine)
+
+ app = Omni("dummy-model")
+ try:
+ with pytest.raises(EngineGenerateError):
+ list(app.generate(["hello"], py_generator=False, use_tqdm=False))
+ finally:
+ app.shutdown()
+
+
+def test_omni_errored_property_alive(monkeypatch: pytest.MonkeyPatch):
+ """Omni.errored (inherited from OmniBase) returns False when healthy."""
+ engine = FakeAsyncOmniEngine(stage_metadata=THREE_STAGE_META)
+ _patch_engine(monkeypatch, engine)
+
+ app = Omni("dummy-model")
+ try:
+ assert app.errored is False
+ finally:
+ app.shutdown()
+
+
+def test_omni_errored_property_dead_engine(monkeypatch: pytest.MonkeyPatch):
+ """Omni.errored returns True when the orchestrator is dead."""
+ engine = FakeAsyncOmniEngine(stage_metadata=THREE_STAGE_META)
+ _patch_engine(monkeypatch, engine)
+
+ app = Omni("dummy-model")
+ try:
+ engine._alive = False
+ assert app.errored is True
+ finally:
+ app.shutdown()
+
+
+def test_omni_errored_property_dead_stage(monkeypatch: pytest.MonkeyPatch):
+ """Omni.errored returns True when a stage client is marked dead."""
+ engine = FakeAsyncOmniEngine(stage_metadata=THREE_STAGE_META)
+ _patch_engine(monkeypatch, engine)
+
+ app = Omni("dummy-model")
+ try:
+ engine.stage_clients[0]._engine_dead = True
+ assert app.errored is True
+ finally:
+ app.shutdown()
diff --git a/tests/entrypoints/test_omni_sleep_mode.py b/tests/entrypoints/test_omni_sleep_mode.py
new file mode 100644
index 000000000..aa7be1ba0
--- /dev/null
+++ b/tests/entrypoints/test_omni_sleep_mode.py
@@ -0,0 +1,336 @@
+import asyncio
+import logging
+import os
+
+import pytest
+import torch
+from vllm import SamplingParams
+
+from tests.helpers.mark import hardware_test
+from vllm_omni.entrypoints.async_omni import AsyncOmni
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+from vllm_omni.platforms import current_omni_platform
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger("OmniTest")
+pytestmark = [pytest.mark.advanced_model]
+
+
+def clean_gpu_envs():
+ """clean up GPU environment variables to ensure tests run on all available devices."""
+ device_visibility_vars = [
+ "CUDA_VISIBLE_DEVICES", # NVIDIA
+ "HIP_VISIBLE_DEVICES", # AMD ROCm
+ "ZE_AFFINITY_MASK", # Intel XPU
+ "ONEAPI_DEVICE_SELECTOR", # Intel OneAPI
+ "ASCEND_RT_VISIBLE_DEVICES", # Huawei NPU (CAN)
+ ]
+ for key in device_visibility_vars:
+ os.environ.pop(key, None)
+
+
+def get_vram_info(device_id: int) -> dict:
+ """Obtain a snapshot of the specified GPU's memory (GiB)."""
+ try:
+ if current_omni_platform.is_rocm():
+ num_gpus = torch.cuda.device_count()
+ safe_id = device_id if device_id < num_gpus else 0
+ torch.cuda.synchronize(safe_id)
+ return {
+ "reserved": torch.cuda.memory_reserved(safe_id) / 1024**3,
+ "allocated": torch.cuda.memory_allocated(safe_id) / 1024**3,
+ }
+ else:
+ with torch.cuda.device(device_id):
+ torch.cuda.synchronize()
+ return {
+ "reserved": torch.cuda.memory_reserved() / 1024**3,
+ "allocated": torch.cuda.memory_allocated() / 1024**3,
+ }
+ except Exception as e:
+ logger.warning(f"memory skip ({device_id}): {e}")
+ return {"reserved": 0.0, "allocated": 0.0}
+
+
+def get_ack_info(ack, key, default=None):
+ """
+ Since ACKs in a distributed environment can be either objects or dictionaries,
+ this tool ensures compatibility.
+ """
+ if hasattr(ack, key):
+ return getattr(ack, key)
+ if isinstance(ack, dict):
+ return ack.get(key, default)
+ return default
+
+
+@pytest.fixture(scope="function")
+async def llm_engine():
+ if current_omni_platform.is_rocm():
+ clean_gpu_envs()
+ model_name = "ByteDance-Seed/BAGEL-7B-MoT"
+ common_args = {
+ "worker_type": "ar",
+ "enable_sleep_mode": True,
+ "dtype": "bfloat16",
+ "trust_remote_code": True,
+ "max_model_len": 2048,
+ "max_num_batched_tokens": 8192,
+ "enforce_eager": True,
+ }
+ stages = [
+ {
+ "stage_id": 0,
+ "stage_type": "llm",
+ "runtime": {"process": True, "devices": "0", "max_batch_size": 1},
+ "engine_args": {**common_args, "model_stage": "thinker", "gpu_memory_utilization": 0.1},
+ },
+ {
+ "stage_id": 1,
+ "stage_type": "llm",
+ "engine_input_source": [0],
+ "runtime": {"process": True, "devices": "1", "max_batch_size": 1, "connector_type": "queue"},
+ "engine_args": {**common_args, "model_stage": "talker", "gpu_memory_utilization": 0.1},
+ },
+ ]
+ connectors = [{"src_stage_id": 0, "dst_stage_id": 1, "connector_type": "queue"}]
+ engine = AsyncOmni(model=model_name, stages=stages, connectors=connectors, init_timeout=600, enable_sleep_mode=True)
+ yield engine
+ engine.shutdown()
+
+
+@pytest.fixture(scope="function")
+async def diffusion_engine():
+ if current_omni_platform.is_rocm():
+ clean_gpu_envs()
+ model_name = "ByteDance-Seed/BAGEL-7B-MoT"
+ stages = [
+ {
+ "stage_id": 0,
+ "stage_type": "diffusion",
+ "runtime": {"process": True, "devices": "0,1", "max_batch_size": 1},
+ "engine_args": {
+ "model_stage": "base",
+ "gpu_memory_utilization": 0.1,
+ "model_class_name": "BagelPipeline",
+ "enable_sleep_mode": True,
+ "enforce_eager": True,
+ "max_num_batched_tokens": 8192,
+ "parallel_config": {
+ "tensor_parallel_size": 2,
+ },
+ },
+ "final_output": True,
+ "final_output_type": "image",
+ }
+ ]
+ engine = AsyncOmni(model=model_name, stages=stages, init_timeout=600, enable_sleep_mode=True)
+ yield engine
+ engine.shutdown()
+
+
+class TestOmniSleepMode:
+ @pytest.mark.asyncio
+ @hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
+ async def test_llm_sleep_ack(self, llm_engine: AsyncOmni):
+ """LLM Thinker (GPU0) Signal and Physical Recycling Audit"""
+ try:
+ acks = await llm_engine.sleep(stage_ids=[0], level=2)
+ # Verification signal successful
+ assert all(get_ack_info(ack, "status") == "SUCCESS" for ack in acks)
+ # Verify physical recycling volume
+ total_freed_bytes = sum(get_ack_info(ack, "freed_bytes", 0) for ack in acks)
+ freed_gib = total_freed_bytes / 1024**3
+ logger.info(f"Thinker VRAM physically reclaimed: {freed_gib:.2f} GiB")
+ assert freed_gib > 5.0
+ finally:
+ llm_engine.shutdown()
+
+ @pytest.mark.asyncio
+ @hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=2)
+ async def test_diffusion_sleep_handshake(self, diffusion_engine: AsyncOmni):
+ """Diffusion Worker stage signal loop"""
+ try:
+ logger.info("Starting Diffusion Worker Handshake Test")
+ acks = await diffusion_engine.sleep(stage_ids=[0], level=2)
+
+ def _get_status(ack):
+ return ack.status if hasattr(ack, "status") else ack.get("status")
+
+ assert len(acks) >= 1, "Expected at least 1 ACK from Diffusion Workers"
+ assert all(_get_status(ack) == "SUCCESS" for ack in acks)
+ logger.info(f"Success: Received {len(acks)} Diffusion Worker ACKs")
+ logger.info("Testing auto-wakeup before test end...")
+ await diffusion_engine.wake_up(stage_ids=[0])
+ logger.info("Test logic finished, triggering manual shutdown...")
+ finally:
+ diffusion_engine.shutdown()
+ logger.info("Manual shutdown executed. Test should exit now.")
+
+ @pytest.mark.asyncio
+ @hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=2)
+ async def test_cross_device_cleanup(self, diffusion_engine: AsyncOmni):
+ """Physical recycling audit: leveraging deterministic data returned by Workers"""
+ try:
+ acks = await diffusion_engine.sleep(stage_ids=[0], level=1)
+ # Sum up the release amounts reported by all Workers.
+ total_freed_bytes = sum(get_ack_info(ack, "freed_bytes", 0) for ack in acks)
+ freed_gb = total_freed_bytes / 1024**3
+ logger.info("Physical reclamation summary from workers:")
+ logger.info(f"- Total Workers: {len(acks)}")
+ logger.info(f"- Total Freed: {freed_gb:.2f} GiB")
+ assert freed_gb > 14.0
+ logger.info("SUCCESS: 100% weights offloaded.")
+ finally:
+ diffusion_engine.shutdown()
+
+ @pytest.mark.asyncio
+ @hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=2)
+ async def test_diffusion_integrity_bit_level(self, diffusion_engine: AsyncOmni):
+ """Bit-level consistency after Diffusion wake-up (prevent image corruption)"""
+ try:
+ prompt = "A huge swimming pool, with many people swimming."
+ sp = OmniDiffusionSamplingParams(num_inference_steps=4, height=512, width=512, seed=42)
+ llm_sp = SamplingParams()
+
+ # Baseline Generation
+ logger.info("Running Baseline Generation...")
+ base_output = None
+ async for output in diffusion_engine.generate(prompt, request_id="base", sampling_params_list=[llm_sp, sp]):
+ base_output = output
+ assert base_output is not None and len(base_output.images) > 0
+ logger.info("Baseline Generation successful.")
+ # Sleep Level 2
+ logger.info("Entering Deep Sleep (VRAM Scavenging)...")
+ await diffusion_engine.sleep(stage_ids=[0], level=2)
+ # Wake-up
+ logger.info("Waking up (Reloading Weights)...")
+ await diffusion_engine.wake_up(stage_ids=[0])
+
+ await asyncio.sleep(2.0)
+ import gc
+
+ gc.collect()
+
+ logger.info("Running Post-Wakeup Generation...")
+ post_output = None
+ async for output in diffusion_engine.generate(prompt, request_id="post", sampling_params_list=[llm_sp, sp]):
+ post_output = output
+ # Assert result consistency
+ assert post_output is not None
+ assert len(base_output.images) == len(post_output.images)
+ assert post_output.images[0] is not None
+ logger.info("SUCCESS: Diffusion integrity verified after Sleep/Wake cycle.")
+ except Exception as e:
+ logger.error(f"Integrity test failed: {e}")
+ raise e
+ finally:
+ logger.info("Triggering mandatory cleanup...")
+ diffusion_engine.shutdown()
+ logger.info("Cleanup complete, test exiting.")
+
+ @pytest.mark.asyncio
+ @hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=2)
+ async def test_coordinated_cross_device(self, llm_engine: AsyncOmni, diffusion_engine: AsyncOmni):
+ """Heterogeneous Coordinated Cleanup Test (Talker and Diffusion on GPU 1)"""
+ device_id = 1
+ try:
+ logger.info(f"Waking up both engines on GPU {device_id}...")
+ await llm_engine.wake_up(stage_ids=[1])
+ await diffusion_engine.wake_up(stage_ids=[0])
+
+ get_vram_info(device_id)
+ torch.cuda.empty_cache()
+ await asyncio.sleep(2)
+
+ initial_vram = get_vram_info(device_id)["reserved"]
+ logger.info(f"GPU {device_id} Peak Pressure: {initial_vram:.2f} GiB")
+
+ # coordinated sleep
+ logger.info("Issuing concurrent SLEEP commands...")
+ await llm_engine.sleep(stage_ids=[1], level=2)
+ await asyncio.sleep(1.0)
+ await diffusion_engine.sleep(stage_ids=[0], level=2)
+
+ await asyncio.sleep(3.0)
+ torch.cuda.empty_cache()
+
+ final_vram = get_vram_info(device_id)["reserved"]
+ logger.info(f"GPU {device_id} Final VRAM after coordinated sleep: {final_vram:.2f} GiB")
+
+ assert initial_vram - final_vram > 15.0 or final_vram < 8.0
+ logger.info(f"SUCCESS: Heterogeneous VRAM drop verified on GPU {device_id}.")
+ except Exception as e:
+ logger.error(f"Coordinated test failed: {e}")
+ raise e
+ finally:
+ logger.info("Triggering mandatory cleanup for both engines...")
+ llm_engine.shutdown()
+ diffusion_engine.shutdown()
+ logger.info("All engines scavenged. Ready for next test.")
+
+ @pytest.mark.asyncio
+ @hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=2)
+ async def test_diffusion_vram_lifecycle_audit(self, diffusion_engine: AsyncOmni):
+ """Diffusion memory loop: Active -> Deep Sleep -> Active -> inference sanity check"""
+ device_id = 1
+ try:
+ get_vram_info(device_id)
+ torch.cuda.empty_cache()
+ vram_initial = get_vram_info(device_id)["reserved"]
+ logger.info(f"Diffusion Initial VRAM: {vram_initial:.2f} GiB")
+
+ # Sleep
+ logger.info("Triggering Level 2 Deep Sleep (Full Weight Offloading)...")
+ acks = await diffusion_engine.sleep(stage_ids=[0], level=2)
+
+ reported_freed_bytes = sum(getattr(ack, "freed_bytes", 0) for ack in acks)
+ reported_freed_gib = reported_freed_bytes / 1024**3
+ logger.info(f"Worker internally reported freed: {reported_freed_gib:.2f} GiB")
+
+ await asyncio.sleep(2)
+ get_vram_info(device_id)
+ torch.cuda.empty_cache()
+
+ vram_sleeping = get_vram_info(device_id)["reserved"]
+ logger.info(f"External VRAM measurement during Sleep: {vram_sleeping:.2f} GiB")
+
+ assert reported_freed_gib > 14.0 or vram_sleeping < 5.0, (
+ f"Reclamation failed. Reported: {reported_freed_gib:.2f}G, Measured: {vram_sleeping:.2f}G"
+ )
+
+ # wake-up
+ logger.info("Triggering Wake-up (Reloading weights to GPU)...")
+ await diffusion_engine.wake_up(stage_ids=[0])
+
+ await asyncio.sleep(2)
+ get_vram_info(device_id)
+ torch.cuda.empty_cache()
+ vram_restored = get_vram_info(device_id)["reserved"]
+ logger.info(f"VRAM after Wake-up: {vram_restored:.2f} GiB")
+
+ assert abs(vram_restored - vram_initial) < 3.0, "VRAM failed to restore to initial levels"
+
+ # inference sanity check
+ logger.info("Running post-lifecycle inference smoke test...")
+ prompt = "A futuristic lab with glowing lights, high quality."
+ sp = OmniDiffusionSamplingParams(num_inference_steps=2, height=512, width=512, seed=42)
+ llm_sp = SamplingParams()
+
+ base_img_found = False
+ async for output in diffusion_engine.generate(
+ prompt, request_id="lifecycle-check", sampling_params_list=[llm_sp, sp]
+ ):
+ if output.images and output.images[0] is not None:
+ base_img_found = True
+
+ assert base_img_found, "Inference failed after Wake-up cycle!"
+ logger.info("SUCCESS: Full Diffusion Lifecycle (Active -> Sleep -> Active -> Generate) audited.")
+
+ except Exception as e:
+ logger.error(f"Lifecycle audit failed: {e}")
+ raise e
+ finally:
+ logger.info("Cleaning up engine and scavenging processes...")
+ diffusion_engine.shutdown()
+ await asyncio.sleep(1)
diff --git a/tests/entrypoints/test_pd_disaggregation.py b/tests/entrypoints/test_pd_disaggregation.py
new file mode 100644
index 000000000..5ffabfbf2
--- /dev/null
+++ b/tests/entrypoints/test_pd_disaggregation.py
@@ -0,0 +1,1222 @@
+"""Unit tests for PD (Prefill-Decode) disaggregation in the Omni orchestrator.
+
+Tests the PD detection, validation, config parsing, sampling param
+preparation, and routing logic added by the PD disaggregation feature
+(issue #1188). All tests run without GPU.
+
+NOTE (v1908 adaptation): Tests that relied on the old OmniStage / stage_list
+architecture (removed in PR #1908) are marked xfail with
+``reason="Requires migration to v1908 Orchestrator architecture"``.
+The remaining tests exercise PDDisaggregationMixin directly and work
+without spinning up a real engine.
+"""
+
+import uuid
+import warnings
+from queue import Empty, Queue
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+from vllm import SamplingParams
+
+from vllm_omni.entrypoints.pd_utils import PDDisaggregationMixin
+
+pytestmark = pytest.mark.skip(reason="Temporarily skip PD entrypoint tests while PD config is being removed.")
+
+# Suppress noisy DeprecationWarnings from optional Swig bindings imported by vLLM dependencies.
+warnings.filterwarnings(
+ "ignore",
+ message=r"builtin type SwigPy.*has no __module__ attribute",
+ category=DeprecationWarning,
+)
+
+
+def _ns(**kwargs):
+ """Create a lightweight attribute object for tests."""
+ return SimpleNamespace(**kwargs)
+
+
+# ---------------------------------------------------------------------------
+# Fake helpers (same pattern as test_omni_llm.py)
+# ---------------------------------------------------------------------------
+
+
+class _FakeEngineArgs(dict):
+ """Fake engine args that supports both attribute and dict access."""
+
+ def __init__(self, args_dict: dict[str, Any]):
+ super().__init__(args_dict)
+ if "model_stage" not in self:
+ self["model_stage"] = None
+ if "engine_output_type" not in self:
+ self["engine_output_type"] = None
+ for key, value in self.items():
+ setattr(self, key, value)
+
+
+class _FakeStageConfig:
+ def __init__(self, config_dict: dict[str, Any]):
+ engine_args_dict = config_dict.get("engine_args", {})
+ self.engine_args = _FakeEngineArgs(engine_args_dict)
+ self.final_output = config_dict.get("final_output", False)
+ self.final_output_type = config_dict.get("final_output_type", None)
+ self.stage_id = config_dict.get("stage_id", 0)
+ self.is_prefill_only = config_dict.get("is_prefill_only", False)
+ self.is_decode_only = config_dict.get("is_decode_only", False)
+ self.engine_input_source = config_dict.get("engine_input_source", [])
+ self.is_comprehension = config_dict.get("is_comprehension", False)
+ self._config_dict = config_dict
+
+
+class _FakeQueue:
+ def __init__(self, maxsize=0):
+ self._queue = Queue(maxsize=maxsize)
+
+ def put(self, item):
+ self._queue.put(item)
+
+ def put_nowait(self, item):
+ self._queue.put_nowait(item)
+
+ def get(self):
+ return self._queue.get()
+
+ def get_nowait(self):
+ return self._queue.get_nowait()
+
+ def empty(self):
+ return self._queue.empty()
+
+
+class _FakeStage:
+ """Lightweight stage stub with PD disaggregation flag support."""
+
+ def __init__(self, config, stage_init_timeout: int = 300):
+ if isinstance(config, dict):
+ config = _FakeStageConfig(config)
+ self.config = config
+ self.stage_config = config
+ self.engine = None
+ self.engine_outputs = None
+ self.stage_id = getattr(config, "stage_id", 0)
+ self.engine_args = config.engine_args
+ self.model_stage = getattr(config.engine_args, "model_stage", None)
+ self.stage_type = "llm"
+ self.default_sampling_params = SamplingParams(temperature=1.0)
+ self.final_output = config.final_output if hasattr(config, "final_output") else False
+ self.final_output_type = getattr(config, "final_output_type", None)
+ self.is_prefill_only = getattr(config, "is_prefill_only", False)
+ self.is_decode_only = getattr(config, "is_decode_only", False)
+ self.engine_input_source = getattr(config, "engine_input_source", [])
+ self.is_comprehension = getattr(config, "is_comprehension", False)
+ processed_input = getattr(config, "_config_dict", {}).get("processed_input", ["processed"])
+ self._processed_input = processed_input
+ self._in_q = None
+ self._out_q = None
+ self._proc = None
+ self._stage_init_timeout = max(0, int(stage_init_timeout))
+
+ def attach_queues(self, in_q, out_q):
+ self._in_q = in_q
+ self._out_q = out_q
+
+ def init_stage_worker(
+ self, model: str, *, is_async=False, shm_threshold_bytes=65536, ctx=None, batch_timeout=10, **kwargs
+ ):
+ self._proc = _ns(
+ start=lambda: None,
+ join=lambda timeout=None: None,
+ is_alive=lambda: False,
+ terminate=lambda: None,
+ )
+ if self._out_q is not None:
+ try:
+ self._out_q.put_nowait({"type": "stage_ready", "stage_id": self.stage_id})
+ except Exception:
+ pass
+
+ def stop_stage_worker(self):
+ if self._in_q is not None:
+ try:
+ self._in_q.put_nowait({"type": "shutdown"})
+ except Exception:
+ pass
+
+ def submit(self, payload: dict[str, Any]):
+ if self._in_q is not None:
+ self._in_q.put(payload)
+
+ def try_collect(self) -> Any:
+ if self._out_q is None:
+ return None
+ try:
+ return self._out_q.get_nowait()
+ except Empty:
+ return None
+
+ def set_engine_outputs(self, outputs):
+ self.engine_outputs = outputs
+
+ def process_engine_inputs(self, stage_list, prompts):
+ return self._processed_input
+
+
+# ---------------------------------------------------------------------------
+# Shared mock setup helpers
+# ---------------------------------------------------------------------------
+
+
+def _setup_engine_mocks(monkeypatch):
+ fake_engine = _ns()
+ fake_engine.tokenizer = _ns()
+ fake_engine.log_stats = False
+ fake_engine.vllm_config = _ns()
+ fake_engine.vllm_config.model_config = _ns()
+ fake_engine.vllm_config.model_config.io_processor_plugin = None
+ fake_engine.get_supported_tasks = lambda: []
+ fake_engine.model_config = _ns()
+ fake_engine.model_config.io_processor_plugin = None
+ fake_registry = _ns()
+ fake_registry.resolve_model_cls = lambda *args, **kwargs: (_ns(), "test_arch")
+ fake_engine.model_config.registry = fake_registry
+ fake_engine.vllm_config.model_config.registry = fake_registry
+
+ monkeypatch.setattr(
+ "vllm.v1.engine.llm_engine.LLMEngine.from_engine_args",
+ lambda **kw: fake_engine,
+ raising=False,
+ )
+
+ class FakeModelClass:
+ pass
+
+ monkeypatch.setattr(
+ "vllm.model_executor.model_loader.utils.get_model_architecture",
+ lambda model_config: (FakeModelClass, "test_arch"),
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "vllm.model_executor.model_loader.utils._get_model_architecture",
+ lambda model_config: (FakeModelClass, "test_arch"),
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "vllm.model_executor.models.adapters.try_create_mm_pooling_model_cls",
+ lambda model_cls: model_cls,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "vllm.multimodal.cache._enable_processor_cache",
+ lambda model_config, mm_registry: False,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "vllm.plugins.io_processors.get_io_processor",
+ lambda vllm_config, io_processor_plugin: None,
+ raising=False,
+ )
+
+
+def _setup_multiprocessing_mocks(monkeypatch):
+ import multiprocessing as mp
+
+ fake_process_instance = _ns(
+ start=lambda: None,
+ join=lambda timeout=None: None,
+ is_alive=lambda: False,
+ terminate=lambda: None,
+ )
+
+ def fake_process_class(*args, **kwargs):
+ return fake_process_instance
+
+ fake_ctx = _ns()
+ fake_ctx.Queue = lambda maxsize=0: _FakeQueue(maxsize=maxsize)
+ fake_ctx.Process = fake_process_class
+
+ monkeypatch.setattr(mp, "get_context", lambda method: fake_ctx, raising=False)
+ monkeypatch.setattr(mp, "Process", fake_process_class, raising=False)
+
+
+def _setup_ipc_mocks(monkeypatch):
+ # These IPC helpers existed in the old architecture; no-op in new arch.
+ pass
+
+
+def _setup_log_mocks(monkeypatch):
+ class _FakeOrchestratorAggregator:
+ def __init__(self, num_stages, enable_stats, wall_start_ts, final_stage_id_for_e2e=None):
+ self.num_stages = num_stages
+ self.enable_stats = enable_stats
+ self.stage_first_ts = [None] * num_stages
+ self.stage_last_ts = [None] * num_stages
+ self.stage_total_tokens = [0] * num_stages
+ self.accumulated_gen_time_ms = {}
+ self.e2e_done = set()
+ self.e2e_count = 0
+ self.e2e_total_ms = 0.0
+
+ def on_stage_metrics(self, stage_id, req_id, metrics, final_output_type=None):
+ pass
+
+ def on_finalize_request(self, stage_id, req_id, start_ts):
+ self.e2e_done.add(req_id)
+
+ def on_forward(self, from_stage, to_stage, req_id, size_bytes, tx_ms, use_shm):
+ pass
+
+ def accumulate_diffusion_metrics(self, stage_type, req_id, engine_outputs):
+ pass
+
+ def record_audio_generated_frames(self, output, stage_id, req_id):
+ pass
+
+ def stage_postprocess_timer(self, stage_id, req_id):
+ from contextlib import contextmanager
+
+ @contextmanager
+ def _noop():
+ yield
+
+ return _noop()
+
+ def build_and_log_summary(self):
+ return "Fake summary"
+
+ monkeypatch.setattr(
+ "vllm_omni.entrypoints.omni.OrchestratorAggregator",
+ _FakeOrchestratorAggregator,
+ raising=False,
+ )
+
+
+def _clear_modules():
+ import sys
+
+ for module_name in [
+ "vllm_omni.entrypoints.utils",
+ "vllm_omni.entrypoints.omni",
+ ]:
+ if module_name in sys.modules:
+ del sys.modules[module_name]
+
+
+@pytest.fixture(autouse=True)
+def mock_get_config(monkeypatch):
+ """Auto-mock get_config and related model loading functions."""
+ import sys
+
+ fake_tokenizer = _ns()
+ fake_tokenizer.encode = lambda *args, **kwargs: [1, 2, 3]
+ fake_tokenizer.decode = lambda *args, **kwargs: "test"
+
+ def _mock_init_tokenizer_from_configs(model_config=None, **kwargs):
+ return fake_tokenizer
+
+ monkeypatch.setattr(
+ "vllm.transformers_utils.tokenizer.init_tokenizer_from_configs",
+ _mock_init_tokenizer_from_configs,
+ raising=False,
+ )
+ tokenizer_module_path = "vllm.transformers_utils.tokenizer"
+ if tokenizer_module_path in sys.modules:
+ setattr(sys.modules[tokenizer_module_path], "init_tokenizer_from_configs", _mock_init_tokenizer_from_configs)
+
+ def _mock_length_from_prompt_token_ids_or_embeds(prompt_token_ids=None, prompt_embeds=None):
+ if prompt_token_ids is not None:
+ if isinstance(prompt_token_ids, list):
+ return len(prompt_token_ids)
+ return 10
+
+ monkeypatch.setattr(
+ "vllm.utils.length_from_prompt_token_ids_or_embeds", _mock_length_from_prompt_token_ids_or_embeds, raising=False
+ )
+ monkeypatch.setattr(
+ "vllm_omni.engine.input_processor.length_from_prompt_token_ids_or_embeds",
+ _mock_length_from_prompt_token_ids_or_embeds,
+ raising=False,
+ )
+
+ processor_module_path = "vllm_omni.engine.input_processor"
+ if processor_module_path in sys.modules:
+ setattr(
+ sys.modules[processor_module_path],
+ "length_from_prompt_token_ids_or_embeds",
+ _mock_length_from_prompt_token_ids_or_embeds,
+ )
+
+ monkeypatch.setattr(
+ "vllm_omni.entrypoints.async_omni.init_tokenizer_from_configs", _mock_init_tokenizer_from_configs, raising=False
+ )
+ async_omni_path = "vllm_omni.entrypoints.async_omni"
+ if async_omni_path in sys.modules:
+ setattr(sys.modules[async_omni_path], "init_tokenizer_from_configs", _mock_init_tokenizer_from_configs)
+
+ fake_hf_config = _ns()
+ fake_hf_config.model_type = "qwen2_5_omni"
+
+ monkeypatch.setattr(
+ "vllm.transformers_utils.config.get_config", lambda model, **kwargs: fake_hf_config, raising=False
+ )
+ monkeypatch.setattr("vllm_omni.entrypoints.utils.get_config", lambda model, **kwargs: fake_hf_config, raising=False)
+
+ def _mock_cached_file(path_or_repo_id, *args, **kwargs):
+ import os
+ import tempfile
+
+ fake_config_file = os.path.join(tempfile.gettempdir(), "fake_config.json")
+ if not os.path.exists(fake_config_file):
+ with open(fake_config_file, "w") as f:
+ f.write('{"model_type": "qwen2_5_omni"}')
+ return fake_config_file
+
+ monkeypatch.setattr("transformers.utils.hub.cached_file", _mock_cached_file, raising=False)
+ monkeypatch.setattr(
+ "transformers.utils.hub.cached_files",
+ lambda path_or_repo_id, filenames, **kwargs: (
+ [_mock_cached_file(path_or_repo_id, filenames[0])] if filenames else None
+ ),
+ raising=False,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Helper to build an Omni instance with PD stage configs
+# ---------------------------------------------------------------------------
+
+
+def _make_pd_omni(monkeypatch, stage_configs, *, extra_setup=None):
+ """Create a lightweight PDDisaggregationMixin instance for unit tests.
+
+ Bypasses the full OmniBase / AsyncOmniEngine init chain so tests run
+ without GPU. Returns an object that has all PDDisaggregationMixin
+ methods and state (``_pd_separation_pair``, ``_pd_kv_params_by_req``,
+ etc.) initialised from *stage_configs*.
+
+ Tests that need the full ``Omni.generate()`` loop (old stage_list / queue
+ infrastructure) are marked ``xfail`` and not covered here.
+ """
+ configs = [_FakeStageConfig(c) for c in stage_configs]
+
+ class _LightweightOmni(PDDisaggregationMixin):
+ """Minimal shim: exposes stage_configs so PDDisaggregationMixin works."""
+
+ def __init__(self):
+ self._name = "Omni"
+ self._stage_configs = configs
+ self._init_pd_state()
+
+ @property
+ def stage_configs(self):
+ return self._stage_configs
+
+ if extra_setup:
+ import vllm_omni.entrypoints.omni as omni_module
+
+ extra_setup(monkeypatch, omni_module)
+
+ return _LightweightOmni()
+
+
+# ---------------------------------------------------------------------------
+# Stage config templates
+# ---------------------------------------------------------------------------
+
+
+def _prefill_stage_cfg(stage_id=0, **overrides):
+ cfg = {
+ "stage_id": stage_id,
+ "engine_args": {
+ "model_stage": "thinker",
+ "kv_transfer_config": {
+ "kv_connector": "MooncakeConnector",
+ "kv_role": "kv_producer",
+ "kv_rank": 0,
+ "kv_parallel_size": 2,
+ "kv_connector_extra_config": {"mooncake_bootstrap_port": 25201},
+ },
+ },
+ "is_prefill_only": True,
+ "final_output": False,
+ "is_comprehension": True,
+ }
+ cfg.update(overrides)
+ return cfg
+
+
+def _decode_stage_cfg(stage_id=1, engine_input_source=None, **overrides):
+ cfg = {
+ "stage_id": stage_id,
+ "engine_args": {
+ "model_stage": "thinker",
+ "kv_transfer_config": {
+ "kv_connector": "MooncakeConnector",
+ "kv_role": "kv_consumer",
+ "kv_rank": 1,
+ "kv_parallel_size": 2,
+ "kv_connector_extra_config": {"mooncake_bootstrap_port": 25202},
+ },
+ },
+ "is_decode_only": True,
+ "engine_input_source": engine_input_source if engine_input_source is not None else [0],
+ "final_output": True,
+ "final_output_type": "text",
+ "is_comprehension": True,
+ }
+ cfg.update(overrides)
+ return cfg
+
+
+def _talker_stage_cfg(stage_id=2, engine_input_source=None, **overrides):
+ cfg = {
+ "stage_id": stage_id,
+ "engine_args": {"model_stage": "talker"},
+ "engine_input_source": engine_input_source if engine_input_source is not None else [1],
+ "final_output": False,
+ }
+ cfg.update(overrides)
+ return cfg
+
+
+def _code2wav_stage_cfg(stage_id=3, engine_input_source=None, **overrides):
+ cfg = {
+ "stage_id": stage_id,
+ "engine_args": {"model_stage": "code2wav"},
+ "engine_input_source": engine_input_source if engine_input_source is not None else [2],
+ "final_output": True,
+ "final_output_type": "audio",
+ }
+ cfg.update(overrides)
+ return cfg
+
+
+# ===================================================================
+# Tests: PD pair detection
+# ===================================================================
+
+
+class TestDetectPDSeparation:
+ """Tests for Omni._detect_pd_separation()."""
+
+ def test_detects_pd_pair(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(stage_id=0),
+ _decode_stage_cfg(stage_id=1, engine_input_source=[0]),
+ ],
+ )
+ assert omni._pd_separation_pair == (0, 1)
+
+ def test_no_pd_pair_without_flags(self, monkeypatch):
+ """Normal (non-PD) pipeline has no PD pair."""
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ {
+ "stage_id": 0,
+ "engine_args": {"model_stage": "thinker"},
+ "final_output": True,
+ "final_output_type": "text",
+ },
+ {
+ "stage_id": 1,
+ "engine_args": {"model_stage": "talker"},
+ "engine_input_source": [0],
+ "final_output": True,
+ "final_output_type": "audio",
+ },
+ ],
+ )
+ assert omni._pd_separation_pair is None
+
+ def test_detects_pd_pair_in_4_stage_pipeline(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(stage_id=0),
+ _decode_stage_cfg(stage_id=1, engine_input_source=[0]),
+ _talker_stage_cfg(stage_id=2, engine_input_source=[1]),
+ _code2wav_stage_cfg(stage_id=3, engine_input_source=[2]),
+ ],
+ )
+ assert omni._pd_separation_pair == (0, 1)
+
+ def test_pd_pair_uses_stage_id_for_input_source(self, monkeypatch):
+ """engine_input_source references stage_id, not list index."""
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(stage_id=10),
+ _decode_stage_cfg(stage_id=20, engine_input_source=[10]),
+ ],
+ )
+ assert omni._pd_separation_pair == (0, 1)
+
+
+# ===================================================================
+# Tests: PD config validation
+# ===================================================================
+
+
+class TestValidatePDConfig:
+ """Tests for Omni._validate_pd_separation_config()."""
+
+ def test_valid_config_passes(self, monkeypatch):
+ """Valid PD config should not raise."""
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ # If we got here without error, validation passed
+ assert omni._pd_separation_pair == (0, 1)
+
+ def test_mismatched_connector_raises(self, monkeypatch):
+ """Different kv_connector types should raise ValueError."""
+ decode_cfg = _decode_stage_cfg(engine_input_source=[0])
+ decode_cfg["engine_args"]["kv_transfer_config"]["kv_connector"] = "NixlConnector"
+
+ with pytest.raises(ValueError, match="connector mismatch"):
+ _make_pd_omni(monkeypatch, [_prefill_stage_cfg(), decode_cfg])
+
+ def test_wrong_prefill_role_raises(self, monkeypatch):
+ """Prefill with kv_consumer role should raise."""
+ prefill_cfg = _prefill_stage_cfg()
+ prefill_cfg["engine_args"]["kv_transfer_config"]["kv_role"] = "kv_consumer"
+
+ with pytest.raises(ValueError, match="kv_role must be"):
+ _make_pd_omni(monkeypatch, [prefill_cfg, _decode_stage_cfg(engine_input_source=[0])])
+
+ def test_wrong_decode_role_raises(self, monkeypatch):
+ """Decode with kv_producer role should raise."""
+ decode_cfg = _decode_stage_cfg(engine_input_source=[0])
+ decode_cfg["engine_args"]["kv_transfer_config"]["kv_role"] = "kv_producer"
+
+ with pytest.raises(ValueError, match="kv_role must be"):
+ _make_pd_omni(monkeypatch, [_prefill_stage_cfg(), decode_cfg])
+
+ def test_missing_kv_transfer_config_raises(self, monkeypatch):
+ """Missing kv_transfer_config should raise."""
+ prefill_cfg = _prefill_stage_cfg()
+ del prefill_cfg["engine_args"]["kv_transfer_config"]
+
+ with pytest.raises(ValueError, match="kv_transfer_config"):
+ _make_pd_omni(monkeypatch, [prefill_cfg, _decode_stage_cfg(engine_input_source=[0])])
+
+ def test_mismatched_buffer_device_raises(self, monkeypatch):
+ """Mismatched kv_buffer_device should raise."""
+ prefill_cfg = _prefill_stage_cfg()
+ prefill_cfg["engine_args"]["kv_transfer_config"]["kv_buffer_device"] = "cuda"
+ decode_cfg = _decode_stage_cfg(engine_input_source=[0])
+ decode_cfg["engine_args"]["kv_transfer_config"]["kv_buffer_device"] = "cpu"
+
+ with pytest.raises(ValueError, match="kv_buffer_device mismatch"):
+ _make_pd_omni(monkeypatch, [prefill_cfg, decode_cfg])
+
+
+# ===================================================================
+# Tests: Connector info extraction
+# ===================================================================
+
+
+class TestGetPDConnectorInfo:
+ """Tests for Omni._get_pd_connector_info()."""
+
+ def test_extracts_bootstrap_addr_for_mooncake(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ info = omni._pd_connector_info
+ assert "prefill_bootstrap_addr" in info
+ assert info["prefill_bootstrap_addr"] == "127.0.0.1:25201"
+
+ def test_none_for_non_pd_pipeline(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ {"stage_id": 0, "engine_args": {}, "final_output": True, "final_output_type": "text"},
+ ],
+ )
+ assert omni._pd_connector_info is None
+
+
+# ===================================================================
+# Tests: Prefill sampling params preparation
+# ===================================================================
+
+
+class TestPreparePrefillSamplingParams:
+ """Tests for Omni._prepare_prefill_sampling_params()."""
+
+ def test_sets_max_tokens_to_1(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ sp = SamplingParams(max_tokens=2048)
+ result = omni._prepare_prefill_sampling_params("req-1", sp)
+
+ assert result.max_tokens == 1
+ assert result is not sp # should be cloned
+
+ def test_injects_kv_transfer_params(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ sp = SamplingParams(max_tokens=2048)
+ result = omni._prepare_prefill_sampling_params("req-1", sp)
+
+ kv_params = result.extra_args["kv_transfer_params"]
+ assert kv_params["do_remote_decode"] is True
+ assert kv_params["do_remote_prefill"] is False
+ assert kv_params["transfer_id"] == "xfer-req-1"
+
+ def test_preserves_existing_extra_args(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ sp = SamplingParams(max_tokens=2048, extra_args={"custom_key": "value"})
+ result = omni._prepare_prefill_sampling_params("req-1", sp)
+
+ assert result.extra_args["custom_key"] == "value"
+ assert "kv_transfer_params" in result.extra_args
+
+ def test_does_not_mutate_original(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ sp = SamplingParams(max_tokens=2048)
+ _ = omni._prepare_prefill_sampling_params("req-1", sp)
+
+ assert sp.max_tokens == 2048
+ assert sp.extra_args is None
+
+
+# ===================================================================
+# Tests: Sampling params auto-duplication for PD split
+# ===================================================================
+
+
+@pytest.mark.xfail(reason="Requires migration to v1908 Orchestrator architecture (no stage_list / OmniStage)")
+class TestSamplingParamsAutoDuplication:
+ """When user provides N-1 sampling params (for logical stages), the
+ orchestrator should auto-duplicate the thinker params for the decode stage.
+ """
+
+ def test_auto_duplicates_for_4_stage_pipeline(self, monkeypatch):
+ """User provides 3 params for 4 physical stages -> auto-insert decode params."""
+ test_uuid = uuid.UUID("00000000-0000-0000-0000-000000000001")
+
+ def _extra_setup(mp, omni_module):
+ mp.setattr(uuid, "uuid4", lambda: test_uuid)
+ mp.setattr(omni_module, "uuid", uuid)
+
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(stage_id=0),
+ _decode_stage_cfg(stage_id=1, engine_input_source=[0]),
+ _talker_stage_cfg(stage_id=2, engine_input_source=[1]),
+ _code2wav_stage_cfg(stage_id=3, engine_input_source=[2]),
+ ],
+ extra_setup=_extra_setup,
+ )
+
+ assert omni._pd_separation_pair == (0, 1)
+ assert len(omni.stage_list) == 4
+
+ # Simulate outputs for all stages
+ expected_rid = f"0_{test_uuid}"
+ for i in range(4):
+ omni.stage_list[i]._out_q.put_nowait(
+ {
+ "request_id": expected_rid,
+ "engine_outputs": [_ns(request_id=expected_rid, outputs=[_ns(token_ids=[1, 2])])],
+ "metrics": {"num_tokens_out": 1, "stage_gen_time_ms": 10.0},
+ }
+ )
+
+ # Provide 3 params (one less than 4 stages) - should auto-duplicate
+ sp_thinker = SamplingParams(temperature=0.4, max_tokens=2048)
+ sp_talker = SamplingParams(temperature=0.9, max_tokens=4096)
+ sp_code2wav = SamplingParams(temperature=0.0, max_tokens=65536)
+
+ # This should NOT raise ValueError about param count mismatch
+ outputs = omni.generate(
+ prompts=["hello"],
+ sampling_params_list=[sp_thinker, sp_talker, sp_code2wav],
+ )
+ assert isinstance(outputs, list)
+
+
+# ===================================================================
+# Tests: KV transfer params normalization
+# ===================================================================
+
+
+class TestNormalizeKVTransferParams:
+ def test_dict_passthrough(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ d = {"transfer_id": "test", "do_remote_decode": True}
+ assert omni._normalize_kv_transfer_params(d) is d
+
+ def test_none_returns_none(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ assert omni._normalize_kv_transfer_params(None) is None
+
+ def test_dataclass_to_dict(self, monkeypatch):
+ from dataclasses import dataclass
+
+ @dataclass
+ class FakeKVParams:
+ transfer_id: str = "test"
+ do_remote_decode: bool = True
+
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ result = omni._normalize_kv_transfer_params(FakeKVParams())
+ assert isinstance(result, dict)
+ assert result["transfer_id"] == "test"
+
+
+# ===================================================================
+# Tests: _kv_cfg_to_dict
+# ===================================================================
+
+
+class TestKvCfgToDict:
+ def test_dict_passthrough(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ d = {"kv_connector": "MooncakeConnector"}
+ assert omni._kv_cfg_to_dict(d) is d
+
+ def test_none_returns_empty(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ assert omni._kv_cfg_to_dict(None) == {}
+
+ def test_dataclass_converted(self, monkeypatch):
+ from dataclasses import dataclass
+
+ @dataclass
+ class FakeCfg:
+ kv_connector: str = "TestConnector"
+ kv_role: str = "kv_producer"
+
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ result = omni._kv_cfg_to_dict(FakeCfg())
+ assert result["kv_connector"] == "TestConnector"
+ assert result["kv_role"] == "kv_producer"
+
+
+# ===================================================================
+# Tests: PD routing in scheduling loop
+# ===================================================================
+
+
+@pytest.mark.xfail(reason="Requires migration to v1908 Orchestrator architecture (no stage_list / OmniStage)")
+class TestPDRouting:
+ """Test that the scheduling loop correctly routes requests from
+ prefill to decode stage with proper kv_transfer_params.
+ """
+
+ def test_prefill_stage_receives_max_tokens_1(self, monkeypatch):
+ """Stage 0 (prefill) should receive max_tokens=1."""
+ test_uuid = uuid.UUID("00000000-0000-0000-0000-000000000002")
+
+ def _extra_setup(mp, omni_module):
+ mp.setattr(uuid, "uuid4", lambda: test_uuid)
+ mp.setattr(omni_module, "uuid", uuid)
+
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(stage_id=0),
+ _decode_stage_cfg(stage_id=1, engine_input_source=[0]),
+ ],
+ extra_setup=_extra_setup,
+ )
+
+ expected_rid = f"0_{test_uuid}"
+
+ # Put stage outputs in both queues
+ omni.stage_list[0]._out_q.put_nowait(
+ {
+ "request_id": expected_rid,
+ "engine_outputs": [_ns(request_id=expected_rid, outputs=[_ns(token_ids=[1])])],
+ "metrics": {"num_tokens_out": 1, "stage_gen_time_ms": 10.0},
+ }
+ )
+ omni.stage_list[1]._out_q.put_nowait(
+ {
+ "request_id": expected_rid,
+ "engine_outputs": [_ns(request_id=expected_rid, outputs=[_ns(token_ids=[1, 2, 3])])],
+ "metrics": {"num_tokens_out": 3, "stage_gen_time_ms": 50.0},
+ }
+ )
+
+ sp_list = [SamplingParams(max_tokens=2048), SamplingParams(max_tokens=2048)]
+ omni.generate(prompts=["hello"], sampling_params_list=sp_list)
+
+ # Check what was submitted to stage 0's input queue
+ # (skip the stage_ready message first)
+ task = omni.stage_list[0]._in_q.get_nowait()
+ assert task["sampling_params"].max_tokens == 1
+ kv_params = task["sampling_params"].extra_args["kv_transfer_params"]
+ assert kv_params["do_remote_decode"] is True
+ assert kv_params["do_remote_prefill"] is False
+ assert kv_params["transfer_id"] == f"xfer-{expected_rid}"
+
+ def test_decode_stage_receives_original_prompt(self, monkeypatch):
+ """Decode stage should get the original prompt (not processed outputs)."""
+ test_uuid = uuid.UUID("00000000-0000-0000-0000-000000000003")
+
+ def _extra_setup(mp, omni_module):
+ mp.setattr(uuid, "uuid4", lambda: test_uuid)
+ mp.setattr(omni_module, "uuid", uuid)
+
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(stage_id=0),
+ _decode_stage_cfg(stage_id=1, engine_input_source=[0]),
+ ],
+ extra_setup=_extra_setup,
+ )
+
+ expected_rid = f"0_{test_uuid}"
+ original_prompt = "test prompt for PD"
+
+ omni.stage_list[0]._out_q.put_nowait(
+ {
+ "request_id": expected_rid,
+ "engine_outputs": [_ns(request_id=expected_rid, outputs=[_ns(token_ids=[1])])],
+ "metrics": {"num_tokens_out": 1, "stage_gen_time_ms": 10.0},
+ }
+ )
+ omni.stage_list[1]._out_q.put_nowait(
+ {
+ "request_id": expected_rid,
+ "engine_outputs": [_ns(request_id=expected_rid, outputs=[_ns(token_ids=[1, 2, 3])])],
+ "metrics": {"num_tokens_out": 3, "stage_gen_time_ms": 50.0},
+ }
+ )
+
+ sp_list = [SamplingParams(max_tokens=2048), SamplingParams(max_tokens=2048)]
+ omni.generate(prompts=[original_prompt], sampling_params_list=sp_list)
+
+ # Check what was forwarded to stage 1 (decode)
+ # The connector sends tasks to stage 1's input queue
+ task = omni.stage_list[1]._in_q.get_nowait()
+ # The engine_inputs should contain the original prompt
+ engine_inputs = task.get("engine_inputs")
+ # For PD routing, the original prompt is wrapped in a list
+ if isinstance(engine_inputs, list):
+ assert original_prompt in engine_inputs
+ else:
+ assert engine_inputs == original_prompt
+
+ def test_decode_kv_params_have_correct_flags(self, monkeypatch):
+ """Decode stage kv_transfer_params should have correct role flags."""
+ test_uuid = uuid.UUID("00000000-0000-0000-0000-000000000004")
+
+ def _extra_setup(mp, omni_module):
+ mp.setattr(uuid, "uuid4", lambda: test_uuid)
+ mp.setattr(omni_module, "uuid", uuid)
+
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(stage_id=0),
+ _decode_stage_cfg(stage_id=1, engine_input_source=[0]),
+ ],
+ extra_setup=_extra_setup,
+ )
+
+ expected_rid = f"0_{test_uuid}"
+
+ omni.stage_list[0]._out_q.put_nowait(
+ {
+ "request_id": expected_rid,
+ "engine_outputs": [_ns(request_id=expected_rid, outputs=[_ns(token_ids=[1])])],
+ "metrics": {"num_tokens_out": 1, "stage_gen_time_ms": 10.0},
+ }
+ )
+ omni.stage_list[1]._out_q.put_nowait(
+ {
+ "request_id": expected_rid,
+ "engine_outputs": [_ns(request_id=expected_rid, outputs=[_ns(token_ids=[1, 2, 3])])],
+ "metrics": {"num_tokens_out": 3, "stage_gen_time_ms": 50.0},
+ }
+ )
+
+ sp_list = [SamplingParams(max_tokens=2048), SamplingParams(max_tokens=2048)]
+ omni.generate(prompts=["hello"], sampling_params_list=sp_list)
+
+ # Check decode task's kv_transfer_params
+ task = omni.stage_list[1]._in_q.get_nowait()
+ kv_params = task["sampling_params"].extra_args["kv_transfer_params"]
+ assert kv_params["do_remote_prefill"] is True
+ assert kv_params["do_remote_decode"] is False
+ assert kv_params["transfer_id"] == f"xfer-{expected_rid}"
+ assert kv_params["remote_bootstrap_addr"] == "127.0.0.1:25201"
+
+
+# ===================================================================
+# Tests: KV params cleanup
+# ===================================================================
+
+
+class TestKVParamsCleanup:
+ def test_drop_cleans_up(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ omni._pd_kv_params_by_req["req-1"] = {"transfer_id": "xfer-1"}
+ omni._drop_pd_kv_params("req-1")
+ assert "req-1" not in omni._pd_kv_params_by_req
+
+ def test_drop_nonexistent_is_noop(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ omni._drop_pd_kv_params("nonexistent") # should not raise
+
+ def test_pop_returns_stored_params(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ stored = {"transfer_id": "xfer-1", "extra_field": "value"}
+ omni._pd_kv_params_by_req["req-1"] = stored
+
+ result = omni._pop_pd_kv_params("req-1")
+ assert result == stored
+ assert "req-1" not in omni._pd_kv_params_by_req
+
+ def test_pop_uses_fallback_when_no_stored(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ fallback = {"transfer_id": "xfer-fallback"}
+ result = omni._pop_pd_kv_params("req-1", fallback=fallback)
+ assert result == fallback
+
+
+# ===================================================================
+# Tests: Config YAML loads without error
+# ===================================================================
+
+
+class TestPDYAMLConfig:
+ def test_pd_yaml_loads(self):
+ """The PD separation YAML config should load without errors."""
+ import os
+
+ yaml_path = os.path.join(
+ os.path.dirname(__file__),
+ "../../vllm_omni/model_executor/stage_configs/qwen3_omni_moe_pd_separation.yaml",
+ )
+ yaml_path = os.path.abspath(yaml_path)
+ if not os.path.exists(yaml_path):
+ pytest.skip("PD separation YAML not found")
+
+ from omegaconf import OmegaConf
+
+ cfg = OmegaConf.load(yaml_path)
+ stages = cfg.stage_args
+ assert len(stages) == 4
+
+ # Prefill stage
+ assert stages[0].is_prefill_only is True
+ assert stages[0].final_output is False
+ assert stages[0].is_comprehension is True
+
+ # Decode stage
+ assert stages[1].is_decode_only is True
+ assert stages[1].final_output is True
+ assert stages[1].final_output_type == "text"
+ assert stages[1].is_comprehension is True
+ assert 0 in stages[1].engine_input_source
+
+ # KV transfer configs
+ assert stages[0].engine_args.kv_transfer_config.kv_role == "kv_producer"
+ assert stages[1].engine_args.kv_transfer_config.kv_role == "kv_consumer"
+ assert stages[0].engine_args.kv_transfer_config.kv_connector == "MooncakeConnector"
+ assert stages[1].engine_args.kv_transfer_config.kv_connector == "MooncakeConnector"
+
+
+class TestPrefillStopNeutralization:
+ """Tests that _prepare_prefill_sampling_params neutralizes stop
+ conditions to ensure finish_reason='length'.
+ """
+
+ def test_clears_stop_strings(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ sp = SamplingParams(max_tokens=2048, stop=[" ", "STOP"])
+ result = omni._prepare_prefill_sampling_params("req-1", sp)
+ assert result.stop == []
+
+ def test_clears_stop_token_ids(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ sp = SamplingParams(max_tokens=2048, stop_token_ids=[151643, 151644])
+ result = omni._prepare_prefill_sampling_params("req-1", sp)
+ assert result.stop_token_ids == []
+
+ def test_clears_include_stop_str_in_output(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ sp = SamplingParams(max_tokens=2048, include_stop_str_in_output=True)
+ result = omni._prepare_prefill_sampling_params("req-1", sp)
+ assert result.include_stop_str_in_output is False
+
+ def test_original_sp_unchanged(self, monkeypatch):
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ sp = SamplingParams(max_tokens=2048, stop=[" "], stop_token_ids=[151643])
+ _ = omni._prepare_prefill_sampling_params("req-1", sp)
+ assert sp.stop == [""]
+ assert sp.stop_token_ids == [151643]
+
+
+# ===================================================================
+# Tests: Failure mode & memory leak prevention
+# ===================================================================
+# NOTE: Full generate()-level failure mode tests are removed for now.
+# The _run_generation error handler (line 1344-1350 in omni.py) calls
+# _drop_pd_kv_params but does not increment completed_requests, causing
+# the while-loop to hang. These tests need to be revisited once the
+# production error-handling path is fixed to properly terminate on
+# stage errors.
+
+
+# ===================================================================
+# Tests: TP size validation
+# ===================================================================
+
+
+class TestTPSizeValidation:
+ """Tests that _validate_pd_separation_config checks tensor_parallel_size."""
+
+ def test_matching_tp_passes(self, monkeypatch):
+ """Same TP size should not raise."""
+ prefill_cfg = _prefill_stage_cfg()
+ prefill_cfg["engine_args"]["tensor_parallel_size"] = 2
+ decode_cfg = _decode_stage_cfg(engine_input_source=[0])
+ decode_cfg["engine_args"]["tensor_parallel_size"] = 2
+ omni = _make_pd_omni(monkeypatch, [prefill_cfg, decode_cfg])
+ assert omni._pd_separation_pair == (0, 1)
+
+ def test_mismatched_tp_raises(self, monkeypatch):
+ """Different TP sizes should raise ValueError."""
+ prefill_cfg = _prefill_stage_cfg()
+ prefill_cfg["engine_args"]["tensor_parallel_size"] = 2
+ decode_cfg = _decode_stage_cfg(engine_input_source=[0])
+ decode_cfg["engine_args"]["tensor_parallel_size"] = 4
+ with pytest.raises(ValueError, match="tensor_parallel_size"):
+ _make_pd_omni(monkeypatch, [prefill_cfg, decode_cfg])
+
+ def test_default_tp_no_error(self, monkeypatch):
+ """Stages without explicit TP (defaults to 1) should pass."""
+ omni = _make_pd_omni(
+ monkeypatch,
+ [
+ _prefill_stage_cfg(),
+ _decode_stage_cfg(engine_input_source=[0]),
+ ],
+ )
+ assert omni._pd_separation_pair == (0, 1)
diff --git a/tests/entrypoints/test_realtime_connection_helpers.py b/tests/entrypoints/test_realtime_connection_helpers.py
new file mode 100644
index 000000000..e795aa92d
--- /dev/null
+++ b/tests/entrypoints/test_realtime_connection_helpers.py
@@ -0,0 +1,86 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Unit tests for realtime streaming helpers (PR #2581 /v1/realtime path)."""
+
+from __future__ import annotations
+
+import base64
+
+import numpy as np
+import pytest
+import torch
+from vllm.sampling_params import RequestOutputKind, SamplingParams
+
+from vllm_omni.entrypoints.async_omni import AsyncOmni
+from vllm_omni.entrypoints.openai.realtime_connection import RealtimeConnection
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+@pytest.fixture
+def realtime_conn() -> RealtimeConnection:
+ return RealtimeConnection.__new__(RealtimeConnection)
+
+
+class TestRealtimeConnectionTensorAndPcm:
+ def test_tensor_to_numpy_none(self) -> None:
+ assert RealtimeConnection._tensor_to_numpy(None) is None
+
+ def test_tensor_to_numpy_1d_numpy(self) -> None:
+ arr = np.array([1.0, 2.0], dtype=np.float64)
+ out = RealtimeConnection._tensor_to_numpy(arr)
+ assert out is not None
+ assert out.dtype == np.float32
+ assert out.shape == (2,)
+
+ def test_tensor_to_numpy_2d_numpy_flattened(self) -> None:
+ arr = np.array([[0.5], [-0.5]], dtype=np.float32)
+ out = RealtimeConnection._tensor_to_numpy(arr)
+ assert out is not None
+ assert out.shape == (2,)
+
+ def test_tensor_to_numpy_torch(self) -> None:
+ t = torch.tensor([[0.25, -0.25]], dtype=torch.float32)
+ out = RealtimeConnection._tensor_to_numpy(t)
+ assert out is not None
+ assert out.shape == (2,)
+ np.testing.assert_allclose(out, [0.25, -0.25], rtol=1e-5)
+
+ def test_pcm16_b64_roundtrip(self) -> None:
+ audio = np.array([0.0, 1.0, -1.0], dtype=np.float32)
+ b64 = RealtimeConnection._pcm16_b64(audio)
+ raw = base64.b64decode(b64)
+ assert len(raw) == 6
+ pcm = np.frombuffer(raw, dtype=np.int16)
+ assert pcm[0] == 0
+ assert pcm[1] == 32767
+ assert pcm[2] == -32767
+
+
+class TestAsyncOmniStreamingParamsValidation:
+ def test_accepts_streaming_friendly_params(self) -> None:
+ p = SamplingParams(
+ n=1,
+ stop=[],
+ output_kind=RequestOutputKind.DELTA,
+ )
+ AsyncOmni._validate_streaming_input_sampling_params(p)
+
+ def test_rejects_non_sampling_params(self) -> None:
+ with pytest.raises(ValueError, match="Input streaming"):
+ AsyncOmni._validate_streaming_input_sampling_params(object()) # type: ignore[arg-type]
+
+ def test_rejects_n_greater_than_one(self) -> None:
+ p = SamplingParams(n=2, stop=[], output_kind=RequestOutputKind.DELTA)
+ with pytest.raises(ValueError, match="Input streaming"):
+ AsyncOmni._validate_streaming_input_sampling_params(p)
+
+ def test_rejects_final_only(self) -> None:
+ p = SamplingParams(n=1, stop=[], output_kind=RequestOutputKind.FINAL_ONLY)
+ with pytest.raises(ValueError, match="Input streaming"):
+ AsyncOmni._validate_streaming_input_sampling_params(p)
+
+ def test_rejects_stop_strings(self) -> None:
+ p = SamplingParams(n=1, stop=["\n"], output_kind=RequestOutputKind.DELTA)
+ with pytest.raises(ValueError, match="Input streaming"):
+ AsyncOmni._validate_streaming_input_sampling_params(p)
diff --git a/tests/entrypoints/test_serve.py b/tests/entrypoints/test_serve.py
index afa7fa82e..e60afc9cd 100644
--- a/tests/entrypoints/test_serve.py
+++ b/tests/entrypoints/test_serve.py
@@ -7,11 +7,33 @@ import argparse
import pytest
from pytest_mock import MockerFixture
-from vllm_omni.entrypoints.cli.serve import run_headless
+from vllm_omni.entrypoints.cli.serve import OmniServeCommand, run_headless
+from vllm_omni.entrypoints.utils import detect_explicit_cli_keys
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+def test_serve_parser_accepts_no_async_chunk_and_marks_it_explicit() -> None:
+ """``--no-async-chunk`` should parse to ``async_chunk=False`` and mark the
+ shared deploy-level dest as explicitly provided by the user."""
+ try:
+ from vllm.utils.argparse_utils import FlexibleArgumentParser
+ except Exception as exc:
+ pytest.skip(f"Cannot build parser in this environment: {exc}")
+
+ root = FlexibleArgumentParser()
+ subparsers = root.add_subparsers(dest="subcommand")
+ cmd = OmniServeCommand()
+ serve_parser = cmd.subparser_init(subparsers)
+
+ argv = ["serve", "fake-model", "--omni", "--no-async-chunk"]
+ args = root.parse_args(argv)
+
+ assert args.async_chunk is False
+ explicit = detect_explicit_cli_keys(argv, serve_parser)
+ assert "async_chunk" in explicit
+
+
def _make_headless_args() -> argparse.Namespace:
return argparse.Namespace(
model="fake-model",
diff --git a/tests/entrypoints/test_utils.py b/tests/entrypoints/test_utils.py
index 94e254c25..248629d51 100644
--- a/tests/entrypoints/test_utils.py
+++ b/tests/entrypoints/test_utils.py
@@ -310,6 +310,39 @@ class TestResolveModelConfigPath:
assert result is not None
assert "glm_image.yaml" in result
+ def test_voxcpm_transformers_format_resolution(self, mocker: MockerFixture):
+ """Test VoxCPM transformers config resolves to the voxcpm stage config."""
+ mocker.patch(
+ "vllm_omni.entrypoints.utils.get_config",
+ side_effect=ValueError("missing transformers config"),
+ )
+ mocker.patch(
+ "vllm_omni.entrypoints.utils.file_or_path_exists",
+ side_effect=lambda _model, filename, revision=None: filename == "config.json",
+ )
+ mocker.patch(
+ "vllm_omni.entrypoints.utils.get_hf_file_to_dict",
+ return_value={"model_type": "voxcpm"},
+ )
+ mocker.patch(
+ "vllm_omni.entrypoints.utils.current_omni_platform.get_default_stage_config_path",
+ return_value="vllm_omni/model_executor/stage_configs",
+ )
+
+ original_exists = os.path.exists
+
+ def mock_exists(path):
+ if "voxcpm.yaml" in str(path):
+ return True
+ return original_exists(path)
+
+ mocker.patch("os.path.exists", side_effect=mock_exists)
+
+ result = resolve_model_config_path("OpenBMB/VoxCPM1.5")
+
+ assert result is not None
+ assert "voxcpm.yaml" in result
+
class TestLoadAndResolveStageConfigs:
def test_load_and_resolve_with_kwargs(self):
diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py
index 137d15f16..867731b21 100644
--- a/tests/examples/conftest.py
+++ b/tests/examples/conftest.py
@@ -1,353 +1,3 @@
-"""
-Shared fixtures, helpers, and path constants for tests/examples/.
-"""
+"""Pytest fixtures for tests/examples."""
-import json
-import os
-import re
-import shlex
-import subprocess
-import sys
-import tempfile
-from collections import defaultdict
-from collections.abc import Callable
-from pathlib import Path
-from typing import Any, NamedTuple, cast
-
-import pytest
-import torch
-from safetensors.torch import save_file
-
-# ---------------------------------------------------------------------------
-# Path constants and fixtures
-# ---------------------------------------------------------------------------
-
-REPO_ROOT = Path(__file__).resolve().parents[2]
-EXAMPLES = REPO_ROOT / "examples"
-
-# Use Python tempfile instead of pytest's tmp_path_factory because
-# OUTPUT_DIR is needed in test collection time, but tmp_path_factory is only available in test running time.
-# It is needed during test collection because extract_readme_snippets replaces LoRA path with a generated one under OUTPUT_DIR,
-# and extract_readme_snippets is called at collection time to generate separate test cases for each README code block.
-OUTPUT_DIR = (
- REPO_ROOT / prefix
- if (prefix := os.environ.get("OUTPUT_DIR"))
- else Path(tempfile.mkdtemp(prefix="vllm_omni_test_examples_"))
-)
-
-
-# ---------------------------------------------------------------------------
-# Code snippet extraction and asset file helpers
-# ---------------------------------------------------------------------------
-
-# parameters: language, code, h2_title
-ReadmeSnippetExtractionSkipPredicate = Callable[[str, str, str], tuple[bool, str]]
-
-
-class ReadmeSnippet(NamedTuple):
- language: str
- code: str
- h2_title: str
- index_in_section: int
- output_file_path: Path | None = None
- skip: tuple[bool, str] = (False, "")
-
- @property
- def test_id(self) -> str:
- return f"{ReadmeSnippet._slug(self.h2_title)}_{self.index_in_section:03d}"
-
- @staticmethod
- def extract_readme_snippets(
- readme_path: Path,
- skipif: ReadmeSnippetExtractionSkipPredicate | None = None,
- ) -> list["ReadmeSnippet"]:
- import mistune
-
- markdown = mistune.create_markdown(renderer="ast")
- tokens = markdown(readme_path.read_text(encoding="utf-8"))
- tokens = cast(list[dict[str, Any]], tokens) # mistune's AST renderer always produces a list, not a str
-
- h2_title = ""
- section_counts: defaultdict[str, int] = defaultdict(int)
- snippets: list[ReadmeSnippet] = []
-
- for token in tokens:
- token_type = token.get("type")
-
- if token_type == "heading":
- level = (token.get("attrs") or {}).get("level")
- title = ReadmeSnippet._heading_text(token)
- if level == 2:
- h2_title = title
- continue
-
- if token_type != "block_code":
- continue
-
- try:
- info = token.get("attrs").get("info") # type: ignore[reportOptionalMemberAccess]
- language = info.strip().split()[0].lower() # type: ignore[reportOptionalMemberAccess]
-
- # Common shell aliases to "bash" in several markdown renderers.
- if language in {"shell", "sh", "ksh", "zsh"}:
- language = "bash"
-
- if language not in {"bash", "python"}:
- continue
- except AttributeError:
- # The fence is missing explicit language info; skip it.
- continue
-
- key = h2_title
- section_counts[key] += 1
- code = token.get("raw", "")
- output_file_path = None
- if language == "bash":
- argv = ReadmeSnippet._normalize_bash_command(code, Path(readme_path.parent))
- code = shlex.join(argv)
- output_file_path = ReadmeSnippet._output_file_path_from_argv(argv)
- if skipif is not None:
- skip_config = skipif(language, code, h2_title)
- else:
- skip_config = (False, "")
- snippet = ReadmeSnippet(
- language=language,
- code=code,
- h2_title=h2_title,
- index_in_section=section_counts[key],
- output_file_path=output_file_path,
- skip=skip_config,
- )
- snippets.append(snippet)
-
- return snippets
-
- @staticmethod
- def _normalize_bash_command(command: str, readme_dir: Path) -> list[str]:
- line_joined_command = re.sub(r"\\\s*\n", " ", command).strip()
- argv = shlex.split(line_joined_command, comments=True)
- assert argv, "README bash fence produced an empty command"
-
- # Normalize python directory and example script location
- if argv[0] in {"python", "python3"}:
- argv[0] = sys.executable
- if len(argv) > 1 and argv[1].endswith(".py"):
- script_arg = argv[1]
- script_path = Path(script_arg)
- if script_path.is_absolute():
- resolved_script = script_path
- else:
- # Take the file name only, and append script_dir to its front
- resolved_script = readme_dir / script_path.name
- assert resolved_script.exists(), (
- f"README bash snippet references a script that does not exist: {script_arg} (resolved to {resolved_script})"
- )
- argv[1] = str(resolved_script)
-
- # Normalize LoRA adapter path and ensure README LoRA assets exist.
- try:
- lora_arg_idx = argv.index("--lora-path") # Raise ValueError if not found
- assert len(argv) > lora_arg_idx + 1, "README bash snippet uses --lora-path without a following value"
-
- lora_dir = OUTPUT_DIR / "lora"
- adapter_model = lora_dir / "adapter_model.safetensors"
- adapter_config = lora_dir / "adapter_config.json"
- if not adapter_model.exists() or not adapter_config.exists():
- write_zimage_lora(lora_dir, v_scale=8.0)
-
- argv[lora_arg_idx + 1] = str(lora_dir)
- except ValueError:
- pass
-
- return argv
-
- @staticmethod
- def _output_file_path_from_argv(argv: list[str]) -> Path | None:
- if "--output" not in argv:
- return None
- output_param_idx = argv.index("--output")
- assert len(argv) > output_param_idx + 1, "README bash snippet uses --output without a following value"
- output_arg = argv[output_param_idx + 1]
- return Path(output_arg)
-
- @staticmethod
- def _slug(text: str) -> str:
- return "".join(ch.lower() if ch.isalnum() else "_" for ch in text).strip("_")
-
- @staticmethod
- def _heading_text(token: dict) -> str:
- return "".join(child.get("raw", "") for child in token.get("children", [])).strip()
-
-
-# [TODO] Duplicate `_write_zimage_lora` in tests/e2e/online_serving/test_images_generations_lora.py. Combine these helpers and tests/e2e/offline_inference/test_diffusion_lora.py to test/utils later
-def write_zimage_lora(adapter_dir: Path, *, q_scale: float = 0.0, k_scale: float = 0.0, v_scale: float = 0.0):
- adapter_dir.mkdir(parents=True, exist_ok=True)
-
- # Z-Image transformer uses dim=3840 by default.
- dim = 3840
- module_name = "transformer.layers.0.attention.to_qkv"
- rank = 1
-
- lora_a = torch.zeros((rank, dim), dtype=torch.float32)
- lora_a[0, 0] = 1.0
-
- # QKVParallelLinear packs (Q, K, V) => out dim is 3 * dim (tp=1).
- lora_b = torch.zeros((3 * dim, rank), dtype=torch.float32)
- if q_scale:
- lora_b[:dim, 0] = q_scale
- if k_scale:
- lora_b[dim : 2 * dim, 0] = k_scale
- if v_scale:
- lora_b[2 * dim :, 0] = v_scale
-
- save_file(
- {
- f"base_model.model.{module_name}.lora_A.weight": lora_a,
- f"base_model.model.{module_name}.lora_B.weight": lora_b,
- },
- str(adapter_dir / "adapter_model.safetensors"),
- )
- (adapter_dir / "adapter_config.json").write_text(
- json.dumps(
- {
- "r": rank,
- "lora_alpha": rank,
- "target_modules": [module_name],
- }
- ),
- encoding="utf-8",
- )
-
-
-# ---------------------------------------------------------------------------
-# Code runner and subprocess helpers
-# ---------------------------------------------------------------------------
-
-
-class ExampleRunResult(NamedTuple):
- run_dir: Path
- assets: list[Path]
-
-
-class ExampleRunner:
- """Run extracted README snippets and return generated assets.
-
- The output materials are organized in a three-level directory structure:
- - Set at init: `self.output_root` for all tests (from env OUTPUT_DIR)
- - Set at `self.run(...)`: `output_subfolder` for a specific example page (e.g., `example_offline_t2i`)
- - Generated by `extract_readme_snippets`: `snippet.test_id` for a specific code block (matching H2 titles, e.g., `basic_usage_001`)
- """
-
- IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
-
- def __init__(self, output_root: Path) -> None:
- self.output_root = output_root
-
- def run(
- self, snippet: ReadmeSnippet, *, output_subfolder: Path = Path("."), env: dict[str, str] | None = None
- ) -> ExampleRunResult:
- run_dir = self.output_root / output_subfolder / snippet.test_id
- run_dir.mkdir(parents=True, exist_ok=True)
-
- if snippet.language == "python":
- assets = self._run_python_snippet(snippet, run_dir, env)
- return ExampleRunResult(run_dir=run_dir, assets=assets)
-
- if snippet.language == "bash":
- asset = self._run_bash_snippet(snippet, run_dir, env)
- return ExampleRunResult(run_dir=run_dir, assets=[asset])
-
- raise AssertionError(f"Unsupported snippet language: {snippet.language}")
-
- def _run_python_snippet(
- self, snippet: ReadmeSnippet, run_dir: Path, env: dict[str, str] | None = None
- ) -> list[Path]:
- # Saving the script to a temporary file and `run_cmd` it.
- # Not using `exec(snippet.code)` because the output is lost.
- script_path = run_dir / "snippet.py"
- script_path.write_text(snippet.code, encoding="utf-8")
-
- before = self._collect_images(run_dir)
- run_cmd([sys.executable, str(script_path)], cwd=run_dir, env=env)
- after = self._collect_images(run_dir)
-
- assets = sorted(after - before)
- return assets
-
- def _run_bash_snippet(self, snippet: ReadmeSnippet, run_dir: Path, env: dict[str, str] | None = None) -> Path:
- run_cmd(snippet.code, shell=True, cwd=run_dir, env=env)
-
- assert snippet.output_file_path is not None, (
- f"README bash snippet is missing --output argument: {snippet.test_id}. "
- "The test script cannot guess the output file path."
- )
-
- # If the code snippet declares a relative path for the output file, append this path to the parent output collection directory.
- # If the code snippet declares an absolute path (not likely but just in case), the return value resolution removes `run_dir`, also correctly pointing to this file.
- return run_dir / snippet.output_file_path
-
- def _collect_images(self, root: Path) -> set[Path]:
- return {path for path in root.rglob("*") if path.suffix.lower() in self.IMAGE_SUFFIXES}
-
-
-@pytest.fixture
-def example_runner() -> ExampleRunner:
- return ExampleRunner(output_root=OUTPUT_DIR)
-
-
-def run_cmd(
- command: list[str] | str,
- *,
- shell: bool = False,
- env: dict[str, str] | None = None,
- cwd: Path | str | None = None,
-) -> str:
- """Run a command as a subprocess; assert zero exit code and return stdout.
-
- Output is fully captured and returned as a string so callers can parse it
- (e.g. with :func:`extract_content_after_keyword`).
- Use this for scripts whose printed output is part of the test assertion.
- """
- if env is not None:
- env = {**os.environ.copy(), **env}
- result = subprocess.run(command, capture_output=True, text=True, shell=shell, env=env, cwd=cwd)
-
- if result.returncode != 0:
- print(f"STDERR: {result.stderr}")
- raise subprocess.CalledProcessError(result.returncode, command)
-
- all_output = result.stdout
- print(f"All output:\n{all_output}")
- return all_output
-
-
-# ---------------------------------------------------------------------------
-# Output validation helpers
-# ---------------------------------------------------------------------------
-
-
-def extract_content_after_keyword(keywords: str, text: str) -> str:
- """Return the text that follows *keywords* in *text* (regex match).
-
- Raises ``AssertionError`` if the keyword is not found, so test failures
- produce a clear message pointing at the missing keyword.
- """
- matches = re.findall(rf"{keywords}\s*(.+)", text, re.DOTALL)
-
- if not matches:
- raise AssertionError(f"Keywords {keywords} not found in provided text output")
- return matches[0]
-
-
-def strip_trailing_audio_saved_line(text: str) -> str:
- """Drop trailing ``Audio saved to ...`` lines from captured client stdout.
-
- ``openai_chat_completion_client_for_multimodal_generation.py`` may print
- ``Chat completion output from text:`` for one choice and ``Audio saved to``
- for another; :func:`extract_content_after_keyword` uses ``re.DOTALL`` and
- would otherwise keep the audio progress line inside the *text* segment.
- """
- lines = text.splitlines()
- while lines and lines[-1].strip().startswith("Audio saved to"):
- lines.pop()
- return "\n".join(lines).strip()
+from tests.examples.helpers import example_runner # noqa: F401
diff --git a/tests/examples/helpers.py b/tests/examples/helpers.py
new file mode 100644
index 000000000..137d15f16
--- /dev/null
+++ b/tests/examples/helpers.py
@@ -0,0 +1,353 @@
+"""
+Shared fixtures, helpers, and path constants for tests/examples/.
+"""
+
+import json
+import os
+import re
+import shlex
+import subprocess
+import sys
+import tempfile
+from collections import defaultdict
+from collections.abc import Callable
+from pathlib import Path
+from typing import Any, NamedTuple, cast
+
+import pytest
+import torch
+from safetensors.torch import save_file
+
+# ---------------------------------------------------------------------------
+# Path constants and fixtures
+# ---------------------------------------------------------------------------
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+EXAMPLES = REPO_ROOT / "examples"
+
+# Use Python tempfile instead of pytest's tmp_path_factory because
+# OUTPUT_DIR is needed in test collection time, but tmp_path_factory is only available in test running time.
+# It is needed during test collection because extract_readme_snippets replaces LoRA path with a generated one under OUTPUT_DIR,
+# and extract_readme_snippets is called at collection time to generate separate test cases for each README code block.
+OUTPUT_DIR = (
+ REPO_ROOT / prefix
+ if (prefix := os.environ.get("OUTPUT_DIR"))
+ else Path(tempfile.mkdtemp(prefix="vllm_omni_test_examples_"))
+)
+
+
+# ---------------------------------------------------------------------------
+# Code snippet extraction and asset file helpers
+# ---------------------------------------------------------------------------
+
+# parameters: language, code, h2_title
+ReadmeSnippetExtractionSkipPredicate = Callable[[str, str, str], tuple[bool, str]]
+
+
+class ReadmeSnippet(NamedTuple):
+ language: str
+ code: str
+ h2_title: str
+ index_in_section: int
+ output_file_path: Path | None = None
+ skip: tuple[bool, str] = (False, "")
+
+ @property
+ def test_id(self) -> str:
+ return f"{ReadmeSnippet._slug(self.h2_title)}_{self.index_in_section:03d}"
+
+ @staticmethod
+ def extract_readme_snippets(
+ readme_path: Path,
+ skipif: ReadmeSnippetExtractionSkipPredicate | None = None,
+ ) -> list["ReadmeSnippet"]:
+ import mistune
+
+ markdown = mistune.create_markdown(renderer="ast")
+ tokens = markdown(readme_path.read_text(encoding="utf-8"))
+ tokens = cast(list[dict[str, Any]], tokens) # mistune's AST renderer always produces a list, not a str
+
+ h2_title = ""
+ section_counts: defaultdict[str, int] = defaultdict(int)
+ snippets: list[ReadmeSnippet] = []
+
+ for token in tokens:
+ token_type = token.get("type")
+
+ if token_type == "heading":
+ level = (token.get("attrs") or {}).get("level")
+ title = ReadmeSnippet._heading_text(token)
+ if level == 2:
+ h2_title = title
+ continue
+
+ if token_type != "block_code":
+ continue
+
+ try:
+ info = token.get("attrs").get("info") # type: ignore[reportOptionalMemberAccess]
+ language = info.strip().split()[0].lower() # type: ignore[reportOptionalMemberAccess]
+
+ # Common shell aliases to "bash" in several markdown renderers.
+ if language in {"shell", "sh", "ksh", "zsh"}:
+ language = "bash"
+
+ if language not in {"bash", "python"}:
+ continue
+ except AttributeError:
+ # The fence is missing explicit language info; skip it.
+ continue
+
+ key = h2_title
+ section_counts[key] += 1
+ code = token.get("raw", "")
+ output_file_path = None
+ if language == "bash":
+ argv = ReadmeSnippet._normalize_bash_command(code, Path(readme_path.parent))
+ code = shlex.join(argv)
+ output_file_path = ReadmeSnippet._output_file_path_from_argv(argv)
+ if skipif is not None:
+ skip_config = skipif(language, code, h2_title)
+ else:
+ skip_config = (False, "")
+ snippet = ReadmeSnippet(
+ language=language,
+ code=code,
+ h2_title=h2_title,
+ index_in_section=section_counts[key],
+ output_file_path=output_file_path,
+ skip=skip_config,
+ )
+ snippets.append(snippet)
+
+ return snippets
+
+ @staticmethod
+ def _normalize_bash_command(command: str, readme_dir: Path) -> list[str]:
+ line_joined_command = re.sub(r"\\\s*\n", " ", command).strip()
+ argv = shlex.split(line_joined_command, comments=True)
+ assert argv, "README bash fence produced an empty command"
+
+ # Normalize python directory and example script location
+ if argv[0] in {"python", "python3"}:
+ argv[0] = sys.executable
+ if len(argv) > 1 and argv[1].endswith(".py"):
+ script_arg = argv[1]
+ script_path = Path(script_arg)
+ if script_path.is_absolute():
+ resolved_script = script_path
+ else:
+ # Take the file name only, and append script_dir to its front
+ resolved_script = readme_dir / script_path.name
+ assert resolved_script.exists(), (
+ f"README bash snippet references a script that does not exist: {script_arg} (resolved to {resolved_script})"
+ )
+ argv[1] = str(resolved_script)
+
+ # Normalize LoRA adapter path and ensure README LoRA assets exist.
+ try:
+ lora_arg_idx = argv.index("--lora-path") # Raise ValueError if not found
+ assert len(argv) > lora_arg_idx + 1, "README bash snippet uses --lora-path without a following value"
+
+ lora_dir = OUTPUT_DIR / "lora"
+ adapter_model = lora_dir / "adapter_model.safetensors"
+ adapter_config = lora_dir / "adapter_config.json"
+ if not adapter_model.exists() or not adapter_config.exists():
+ write_zimage_lora(lora_dir, v_scale=8.0)
+
+ argv[lora_arg_idx + 1] = str(lora_dir)
+ except ValueError:
+ pass
+
+ return argv
+
+ @staticmethod
+ def _output_file_path_from_argv(argv: list[str]) -> Path | None:
+ if "--output" not in argv:
+ return None
+ output_param_idx = argv.index("--output")
+ assert len(argv) > output_param_idx + 1, "README bash snippet uses --output without a following value"
+ output_arg = argv[output_param_idx + 1]
+ return Path(output_arg)
+
+ @staticmethod
+ def _slug(text: str) -> str:
+ return "".join(ch.lower() if ch.isalnum() else "_" for ch in text).strip("_")
+
+ @staticmethod
+ def _heading_text(token: dict) -> str:
+ return "".join(child.get("raw", "") for child in token.get("children", [])).strip()
+
+
+# [TODO] Duplicate `_write_zimage_lora` in tests/e2e/online_serving/test_images_generations_lora.py. Combine these helpers and tests/e2e/offline_inference/test_diffusion_lora.py to test/utils later
+def write_zimage_lora(adapter_dir: Path, *, q_scale: float = 0.0, k_scale: float = 0.0, v_scale: float = 0.0):
+ adapter_dir.mkdir(parents=True, exist_ok=True)
+
+ # Z-Image transformer uses dim=3840 by default.
+ dim = 3840
+ module_name = "transformer.layers.0.attention.to_qkv"
+ rank = 1
+
+ lora_a = torch.zeros((rank, dim), dtype=torch.float32)
+ lora_a[0, 0] = 1.0
+
+ # QKVParallelLinear packs (Q, K, V) => out dim is 3 * dim (tp=1).
+ lora_b = torch.zeros((3 * dim, rank), dtype=torch.float32)
+ if q_scale:
+ lora_b[:dim, 0] = q_scale
+ if k_scale:
+ lora_b[dim : 2 * dim, 0] = k_scale
+ if v_scale:
+ lora_b[2 * dim :, 0] = v_scale
+
+ save_file(
+ {
+ f"base_model.model.{module_name}.lora_A.weight": lora_a,
+ f"base_model.model.{module_name}.lora_B.weight": lora_b,
+ },
+ str(adapter_dir / "adapter_model.safetensors"),
+ )
+ (adapter_dir / "adapter_config.json").write_text(
+ json.dumps(
+ {
+ "r": rank,
+ "lora_alpha": rank,
+ "target_modules": [module_name],
+ }
+ ),
+ encoding="utf-8",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Code runner and subprocess helpers
+# ---------------------------------------------------------------------------
+
+
+class ExampleRunResult(NamedTuple):
+ run_dir: Path
+ assets: list[Path]
+
+
+class ExampleRunner:
+ """Run extracted README snippets and return generated assets.
+
+ The output materials are organized in a three-level directory structure:
+ - Set at init: `self.output_root` for all tests (from env OUTPUT_DIR)
+ - Set at `self.run(...)`: `output_subfolder` for a specific example page (e.g., `example_offline_t2i`)
+ - Generated by `extract_readme_snippets`: `snippet.test_id` for a specific code block (matching H2 titles, e.g., `basic_usage_001`)
+ """
+
+ IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
+
+ def __init__(self, output_root: Path) -> None:
+ self.output_root = output_root
+
+ def run(
+ self, snippet: ReadmeSnippet, *, output_subfolder: Path = Path("."), env: dict[str, str] | None = None
+ ) -> ExampleRunResult:
+ run_dir = self.output_root / output_subfolder / snippet.test_id
+ run_dir.mkdir(parents=True, exist_ok=True)
+
+ if snippet.language == "python":
+ assets = self._run_python_snippet(snippet, run_dir, env)
+ return ExampleRunResult(run_dir=run_dir, assets=assets)
+
+ if snippet.language == "bash":
+ asset = self._run_bash_snippet(snippet, run_dir, env)
+ return ExampleRunResult(run_dir=run_dir, assets=[asset])
+
+ raise AssertionError(f"Unsupported snippet language: {snippet.language}")
+
+ def _run_python_snippet(
+ self, snippet: ReadmeSnippet, run_dir: Path, env: dict[str, str] | None = None
+ ) -> list[Path]:
+ # Saving the script to a temporary file and `run_cmd` it.
+ # Not using `exec(snippet.code)` because the output is lost.
+ script_path = run_dir / "snippet.py"
+ script_path.write_text(snippet.code, encoding="utf-8")
+
+ before = self._collect_images(run_dir)
+ run_cmd([sys.executable, str(script_path)], cwd=run_dir, env=env)
+ after = self._collect_images(run_dir)
+
+ assets = sorted(after - before)
+ return assets
+
+ def _run_bash_snippet(self, snippet: ReadmeSnippet, run_dir: Path, env: dict[str, str] | None = None) -> Path:
+ run_cmd(snippet.code, shell=True, cwd=run_dir, env=env)
+
+ assert snippet.output_file_path is not None, (
+ f"README bash snippet is missing --output argument: {snippet.test_id}. "
+ "The test script cannot guess the output file path."
+ )
+
+ # If the code snippet declares a relative path for the output file, append this path to the parent output collection directory.
+ # If the code snippet declares an absolute path (not likely but just in case), the return value resolution removes `run_dir`, also correctly pointing to this file.
+ return run_dir / snippet.output_file_path
+
+ def _collect_images(self, root: Path) -> set[Path]:
+ return {path for path in root.rglob("*") if path.suffix.lower() in self.IMAGE_SUFFIXES}
+
+
+@pytest.fixture
+def example_runner() -> ExampleRunner:
+ return ExampleRunner(output_root=OUTPUT_DIR)
+
+
+def run_cmd(
+ command: list[str] | str,
+ *,
+ shell: bool = False,
+ env: dict[str, str] | None = None,
+ cwd: Path | str | None = None,
+) -> str:
+ """Run a command as a subprocess; assert zero exit code and return stdout.
+
+ Output is fully captured and returned as a string so callers can parse it
+ (e.g. with :func:`extract_content_after_keyword`).
+ Use this for scripts whose printed output is part of the test assertion.
+ """
+ if env is not None:
+ env = {**os.environ.copy(), **env}
+ result = subprocess.run(command, capture_output=True, text=True, shell=shell, env=env, cwd=cwd)
+
+ if result.returncode != 0:
+ print(f"STDERR: {result.stderr}")
+ raise subprocess.CalledProcessError(result.returncode, command)
+
+ all_output = result.stdout
+ print(f"All output:\n{all_output}")
+ return all_output
+
+
+# ---------------------------------------------------------------------------
+# Output validation helpers
+# ---------------------------------------------------------------------------
+
+
+def extract_content_after_keyword(keywords: str, text: str) -> str:
+ """Return the text that follows *keywords* in *text* (regex match).
+
+ Raises ``AssertionError`` if the keyword is not found, so test failures
+ produce a clear message pointing at the missing keyword.
+ """
+ matches = re.findall(rf"{keywords}\s*(.+)", text, re.DOTALL)
+
+ if not matches:
+ raise AssertionError(f"Keywords {keywords} not found in provided text output")
+ return matches[0]
+
+
+def strip_trailing_audio_saved_line(text: str) -> str:
+ """Drop trailing ``Audio saved to ...`` lines from captured client stdout.
+
+ ``openai_chat_completion_client_for_multimodal_generation.py`` may print
+ ``Chat completion output from text:`` for one choice and ``Audio saved to``
+ for another; :func:`extract_content_after_keyword` uses ``re.DOTALL`` and
+ would otherwise keep the audio progress line inside the *text* segment.
+ """
+ lines = text.splitlines()
+ while lines and lines[-1].strip().startswith("Audio saved to"):
+ lines.pop()
+ return "\n".join(lines).strip()
diff --git a/tests/examples/offline_inference/test_text_to_image.py b/tests/examples/offline_inference/test_text_to_image.py
index a08d16f16..f24506587 100644
--- a/tests/examples/offline_inference/test_text_to_image.py
+++ b/tests/examples/offline_inference/test_text_to_image.py
@@ -7,9 +7,9 @@ from pathlib import Path
import pytest
-from tests.conftest import assert_image_valid
-from tests.examples.conftest import EXAMPLES, ExampleRunner, ReadmeSnippet
-from tests.utils import hardware_marks
+from tests.examples.helpers import EXAMPLES, ExampleRunner, ReadmeSnippet
+from tests.helpers.assertions import assert_image_valid
+from tests.helpers.mark import hardware_marks
pytestmark = [pytest.mark.advanced_model, pytest.mark.example, *hardware_marks(res={"cuda": "H100"})]
diff --git a/tests/examples/online_serving/test_qwen2_5_omni.py b/tests/examples/online_serving/test_qwen2_5_omni.py
index a78ccf592..b8acfef84 100644
--- a/tests/examples/online_serving/test_qwen2_5_omni.py
+++ b/tests/examples/online_serving/test_qwen2_5_omni.py
@@ -5,33 +5,29 @@ See examples/online_serving/qwen2_5_omni/README.md
import os
-from vllm_omni.platforms import current_omni_platform
-
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
from pathlib import Path
import pytest
-from tests.conftest import OmniServerParams, convert_audio_file_to_text, cosine_similarity_text
-from tests.examples.conftest import (
+from tests.examples.helpers import (
extract_content_after_keyword,
run_cmd,
strip_trailing_audio_saved_line,
)
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import convert_audio_file_to_text, cosine_similarity_text
+from tests.helpers.runtime import OmniServerParams
+from tests.helpers.stage_config import get_deploy_config_path
pytestmark = [pytest.mark.advanced_model, pytest.mark.example]
models = ["Qwen/Qwen2.5-Omni-7B"]
-
-stage_configs = [str(Path(__file__).parent.parent.parent / "e2e" / "stage_configs" / "qwen2_5_omni_ci.yaml")]
-
-if current_omni_platform.is_xpu():
- stage_configs = [
- str(Path(__file__).parent.parent.parent / "e2e" / "stage_configs" / "xpu" / "qwen2_5_omni_ci.yaml")
- ]
+# Single CI deploy YAML; rocm/xpu deltas are picked automatically via the
+# platforms: section in vllm_omni/deploy/ci/qwen2_5_omni.yaml.
+stage_configs = [get_deploy_config_path("ci/qwen2_5_omni.yaml")]
example_dir = str(Path(__file__).parent.parent.parent.parent / "examples" / "online_serving")
# Create parameter combinations for model and stage config
diff --git a/tests/examples/online_serving/test_qwen3_omni.py b/tests/examples/online_serving/test_qwen3_omni.py
index 65f99d7bf..e2c3b3384 100644
--- a/tests/examples/online_serving/test_qwen3_omni.py
+++ b/tests/examples/online_serving/test_qwen3_omni.py
@@ -5,31 +5,28 @@ See examples/online_serving/qwen3_omni/README.md
import os
-from vllm_omni.platforms import current_omni_platform
-
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
from pathlib import Path
import pytest
-from tests.conftest import OmniServerParams, convert_audio_file_to_text, cosine_similarity_text
-from tests.examples.conftest import (
+from tests.examples.helpers import (
extract_content_after_keyword,
run_cmd,
strip_trailing_audio_saved_line,
)
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
+from tests.helpers.media import convert_audio_file_to_text, cosine_similarity_text
+from tests.helpers.runtime import OmniServerParams
+from tests.helpers.stage_config import get_deploy_config_path
pytestmark = [pytest.mark.advanced_model, pytest.mark.example]
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
-stage_configs = [str(Path(__file__).parent.parent.parent / "e2e" / "stage_configs" / "qwen3_omni_ci.yaml")]
-
-if current_omni_platform.is_xpu():
- stage_configs = [str(Path(__file__).parent.parent.parent / "e2e" / "stage_configs" / "xpu" / "qwen3_omni_ci.yaml")]
+stage_configs = [get_deploy_config_path("ci/qwen3_omni_moe.yaml")]
example_dir = str(Path(__file__).parent.parent.parent.parent / "examples" / "online_serving")
diff --git a/tests/examples/online_serving/test_text_to_image.py b/tests/examples/online_serving/test_text_to_image.py
index 51b7ff61b..6f89cb549 100644
--- a/tests/examples/online_serving/test_text_to_image.py
+++ b/tests/examples/online_serving/test_text_to_image.py
@@ -13,9 +13,10 @@ from pathlib import Path
import pytest
-from tests.conftest import OmniServer, OmniServerParams, assert_image_valid
-from tests.examples.conftest import EXAMPLES, OUTPUT_DIR, run_cmd, write_zimage_lora
-from tests.utils import hardware_marks
+from tests.examples.helpers import EXAMPLES, OUTPUT_DIR, run_cmd, write_zimage_lora
+from tests.helpers.assertions import assert_image_valid
+from tests.helpers.mark import hardware_marks
+from tests.helpers.runtime import OmniServer, OmniServerParams
pytestmark = [pytest.mark.advanced_model, pytest.mark.example, *hardware_marks(res={"cuda": "H100"})]
diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py
new file mode 100644
index 000000000..a3348b07f
--- /dev/null
+++ b/tests/helpers/__init__.py
@@ -0,0 +1,8 @@
+"""Shared, importable test helper utilities.
+
+Submodules (``assertions``, ``env``, ``media``, ``runtime``, …) are imported
+explicitly by callers. Avoid star-importing everything here: that ran before
+refactor only inside the old monolithic ``conftest``; a greedy ``__init__``
+changes import order and can affect in-process Omni (``OmniRunner`` / offline
+e2e) vs subprocess-based ``OmniServer`` tests.
+"""
diff --git a/tests/helpers/assertions.py b/tests/helpers/assertions.py
new file mode 100644
index 000000000..533806477
--- /dev/null
+++ b/tests/helpers/assertions.py
@@ -0,0 +1,506 @@
+"""Assertion and response validation helpers for tests."""
+
+import io
+import tempfile
+import threading
+from io import BytesIO
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import soundfile as sf
+from PIL import Image
+
+from tests.helpers.media import cosine_similarity_text
+
+_GENDER_PIPELINE = None
+_GENDER_PIPELINE_LOCK = threading.Lock()
+_PCM_SPEECH_SAMPLE_RATE_HZ = 24_000
+_MIN_PCM_SPEECH_HNR_DB = 1.0
+_PRESET_VOICE_GENDER_MAP: dict[str, str] = {
+ "serena": "female",
+ "uncle_fu": "male",
+ "chelsie": "female",
+ "clone": "female",
+ "ethan": "male",
+}
+
+
+def assert_image_diffusion_response(
+ response,
+ request_config: dict[str, Any],
+ run_level: str = None,
+) -> None:
+ """
+ Validate image diffusion response.
+
+ Expected request_config schema:
+ {
+ "request_type": "image",
+ "extra_body": {
+ "num_outputs_per_prompt": 1,
+ "width": ...,
+ "height": ...,
+ ...
+ }
+ }
+ """
+ assert response.images is not None, "Image response is None"
+ assert len(response.images) > 0, "No images in response"
+
+ extra_body = request_config.get("extra_body") or {}
+
+ num_outputs_per_prompt = extra_body.get("num_outputs_per_prompt")
+ if num_outputs_per_prompt is not None:
+ assert len(response.images) == num_outputs_per_prompt, (
+ f"Expected {num_outputs_per_prompt} images, got {len(response.images)}"
+ )
+
+ if run_level == "advanced_model":
+ width = extra_body.get("width")
+ height = extra_body.get("height")
+
+ if width is not None or height is not None:
+ for img in response.images:
+ assert_image_valid(img, width=width, height=height)
+
+
+def assert_video_diffusion_response(
+ response,
+ request_config: dict[str, Any],
+ run_level: str = None,
+) -> None:
+ """
+ Validate video diffusion response.
+
+ Expected request_config schema:
+ {
+ "request_type": "video",
+ "form_data": {
+ "prompt": "...",
+ "num_frames": ...,
+ "width": ...,
+ "height": ...,
+ "fps": ...,
+ ...
+ }
+ }
+ """
+ form_data = request_config.get("form_data", {})
+
+ assert response.videos is not None, "Video response is None"
+ assert len(response.videos) > 0, "No videos in response"
+
+ expected_frames = _maybe_int(form_data.get("num_frames"))
+ expected_width = _maybe_int(form_data.get("width"))
+ expected_height = _maybe_int(form_data.get("height"))
+ expected_fps = _maybe_int(form_data.get("fps"))
+
+ for vid_bytes in response.videos:
+ assert_video_valid(
+ vid_bytes,
+ num_frames=expected_frames,
+ width=expected_width,
+ height=expected_height,
+ fps=expected_fps,
+ )
+
+
+def assert_audio_diffusion_response(
+ response,
+ request_config: dict[str, Any],
+ run_level: str = None,
+) -> None:
+ """
+ Validate audio diffusion response.
+ """
+ raise NotImplementedError("Audio validation is not implemented yet")
+
+
+def _maybe_int(value: Any) -> int | None:
+ if value is None:
+ return None
+ return int(value)
+
+
+def assert_image_valid(image: Path | Image.Image, *, width: int | None = None, height: int | None = None):
+ """Assert the file is a loadable image with optional exact dimensions."""
+ if isinstance(image, Path):
+ assert image.exists(), f"Image not found: {image}"
+ image = Image.open(image)
+ image.load()
+ assert image.width > 0 and image.height > 0
+ if width is not None:
+ assert image.width == width, f"Expected width={width}, got {image.width}"
+ if height is not None:
+ assert image.height == height, f"Expected height={height}, got {image.height}"
+ return image
+
+
+def assert_video_valid(
+ video: Path | bytes | BytesIO,
+ *,
+ num_frames: int | None = None,
+ width: int | None = None,
+ height: int | None = None,
+ fps: float | None = None,
+) -> dict[str, int | float]:
+ """Assert the MP4 has the expected resolution and frame count.
+
+ For several diffusion backends, encoded MP4 frame count follows a codec-aligned
+ convention (e.g. request `num_frames=8` can produce 9 encoded frames). Keep
+ this compatibility behavior to avoid false negatives in online-serving tests.
+ """
+ temp_path = None
+ cap = None
+ try:
+ import cv2
+
+ if isinstance(video, Path):
+ if not video.exists():
+ raise AssertionError(f"Video file not found: {video}")
+ video_path = str(video)
+ else:
+ suffix = ".mp4"
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, mode="wb") as tmp:
+ if isinstance(video, bytes):
+ tmp.write(video)
+ elif isinstance(video, BytesIO):
+ tmp.write(video.getvalue())
+ else:
+ raise TypeError(f"Unsupported video type: {type(video)}")
+ temp_path = Path(tmp.name)
+ video_path = str(temp_path)
+
+ cap = cv2.VideoCapture(video_path)
+ if not cap.isOpened():
+ raise AssertionError("Failed to open video capture")
+
+ actual_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
+ actual_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
+ actual_fps = float(cap.get(cv2.CAP_PROP_FPS))
+ actual_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
+
+ if width is not None:
+ assert actual_width == width, f"Expected width={width}, got {actual_width}"
+ if height is not None:
+ assert actual_height == height, f"Expected height={height}, got {actual_height}"
+ if fps is not None and actual_fps:
+ assert abs(actual_fps - float(fps)) < 1.0, f"Expected fps~={fps}, got {actual_fps}"
+ if num_frames is not None:
+ expected_frames = (int(num_frames) // 4) * 4 + 1
+ assert actual_frames == expected_frames, f"Expected frames={expected_frames}, got {actual_frames}"
+
+ return {
+ "width": actual_width,
+ "height": actual_height,
+ "fps": actual_fps,
+ "num_frames": actual_frames,
+ }
+ except Exception as e:
+ print(f"ERROR: {type(e).__name__}: {e}", flush=True)
+ raise
+ finally:
+ if cap is not None:
+ cap.release()
+ if temp_path and temp_path.exists():
+ try:
+ temp_path.unlink()
+ except OSError:
+ pass
+
+
+def assert_audio_valid(
+ audio_or_path: Path | np.ndarray,
+ *,
+ sample_rate: int,
+ channels: int,
+ duration_s: float,
+) -> None:
+ """Assert WAV file or (batch, channels, samples) ndarray matches expected audio format."""
+ expected_samples = int(duration_s * sample_rate)
+ if isinstance(audio_or_path, np.ndarray):
+ audio = audio_or_path
+ assert audio.ndim == 3, f"Expected audio ndim=3 (batch, channels, samples), got shape {audio.shape}"
+ assert audio.shape[0] == 1, f"Expected batch size 1, got {audio.shape[0]}"
+ assert audio.shape[1] == channels, f"Expected {channels} channels, got {audio.shape[1]}"
+ assert audio.shape[2] == expected_samples, (
+ f"Expected {expected_samples} samples ({duration_s}s @ {sample_rate} Hz), got {audio.shape[2]}"
+ )
+ return
+
+ path = audio_or_path
+ assert path.exists(), f"Audio not found: {path}"
+ info = sf.info(str(path))
+ assert info.samplerate == sample_rate, f"Expected sample_rate={sample_rate}, got {info.samplerate}"
+ assert info.channels == channels, f"Expected {channels} channel(s), got {info.channels}"
+ assert info.frames == expected_samples, (
+ f"Expected {expected_samples} frames ({duration_s}s @ {sample_rate} Hz), got {info.frames}"
+ )
+
+
+def _load_gender_pipeline():
+ global _GENDER_PIPELINE
+ if _GENDER_PIPELINE is not None:
+ return _GENDER_PIPELINE
+ model_name = "7wolf/wav2vec2-base-gender-classification"
+ try:
+ from transformers import pipeline
+
+ _GENDER_PIPELINE = pipeline(task="audio-classification", model=model_name, device=-1)
+ return _GENDER_PIPELINE
+ except Exception as exc: # pragma: no cover
+ print(f"Warning: failed to create gender pipeline '{model_name}': {exc}")
+ _GENDER_PIPELINE = None
+ return None
+
+
+def _median_pitch_hz_from_autocorr(mono: np.ndarray, sr: int) -> float | None:
+ x = np.asarray(mono, dtype=np.float64)
+ x = x - np.mean(x)
+ if x.size < int(0.15 * sr):
+ return None
+ frame_len = int(0.04 * sr)
+ hop = max(frame_len // 2, 1)
+ f0_min_hz, f0_max_hz = 70.0, 400.0
+ lag_min = max(1, int(sr / f0_max_hz))
+ lag_max = min(frame_len - 2, int(sr / f0_min_hz))
+ if lag_max <= lag_min:
+ return None
+ win = np.hamming(frame_len)
+ pitches: list[float] = []
+ for start in range(0, int(x.shape[0]) - frame_len, hop):
+ frame = x[start : start + frame_len] * win
+ frame = frame - np.mean(frame)
+ if float(np.sqrt(np.mean(frame**2))) < 1e-4:
+ continue
+ ac = np.correlate(frame, frame, mode="full")[frame_len - 1 :]
+ ac = ac / (float(ac[0]) + 1e-12)
+ region = ac[lag_min : lag_max + 1]
+ peak_rel = int(np.argmax(region))
+ peak_lag = peak_rel + lag_min
+ if peak_lag <= 0:
+ continue
+ f0 = float(sr) / float(peak_lag)
+ if f0_min_hz <= f0 <= f0_max_hz:
+ pitches.append(f0)
+ if len(pitches) < 4:
+ return None
+ return float(np.median(np.asarray(pitches, dtype=np.float64)))
+
+
+def _estimate_voice_gender_from_audio(audio_bytes: bytes) -> str:
+ data, sr = sf.read(io.BytesIO(audio_bytes), dtype="float32", always_2d=True)
+ if data.size == 0:
+ raise ValueError("Empty audio")
+ mono = np.mean(data, axis=1)
+ try:
+ target_sr = 16000
+ if int(sr) != target_sr and mono.size > 1:
+ src_len = int(mono.shape[0])
+ dst_len = max(1, int(round(src_len * float(target_sr) / float(sr))))
+ src_idx = np.arange(src_len, dtype=np.float32)
+ dst_idx = np.linspace(0, src_len - 1, dst_len, dtype=np.float32)
+ mono = np.interp(dst_idx, src_idx, mono.astype(np.float32, copy=False)).astype(np.float32)
+ sr = target_sr
+
+ median_f0 = _median_pitch_hz_from_autocorr(mono, sr)
+ clf = _load_gender_pipeline()
+ if clf is None:
+ print("gender model not available, returning 'unknown'")
+ return "unknown"
+ with _GENDER_PIPELINE_LOCK:
+ outputs = clf(mono, sampling_rate=sr)
+ if not outputs:
+ return "unknown"
+ top = outputs[0]
+ label = str(top.get("label", "")).lower()
+ conf = float(top.get("score", 0.0))
+ if conf < 0.5:
+ gender = "unknown"
+ elif ("female" in label) or ("жен" in label):
+ gender = "female"
+ elif ("male" in label) or ("муж" in label):
+ gender = "male"
+ else:
+ gender = "unknown"
+
+ if gender == "female" and median_f0 is not None and median_f0 < 165.0 and conf < 0.88:
+ print(f"gender pitch assist: reclassifying female->male (median_f0={median_f0:.1f} Hz, conf={conf:.3f})")
+ gender = "male"
+ elif gender == "male" and median_f0 is not None and median_f0 > 230.0 and conf < 0.88:
+ print(f"gender pitch assist: reclassifying male->female (median_f0={median_f0:.1f} Hz, conf={conf:.3f})")
+ gender = "female"
+ print(
+ f"gender classifier: label={label}, conf={conf:.3f}, gender={gender}"
+ + (f", median_f0={median_f0:.1f}Hz" if median_f0 is not None else "")
+ )
+ return gender
+ except Exception as exc: # pragma: no cover
+ print(f"Warning: gender classification failed, returning 'unknown': {exc}")
+ return "unknown"
+
+
+def _assert_preset_voice_gender_from_audio(audio_bytes: bytes | None, voice_name: str | None) -> None:
+ """If ``voice_name`` matches a known preset, assert classifier gender matches (skip when unknown)."""
+ if not voice_name or not audio_bytes:
+ return
+ key = str(voice_name).lower()
+ expected_gender = _PRESET_VOICE_GENDER_MAP.get(key)
+ if expected_gender is None:
+ return
+ estimated_gender = _estimate_voice_gender_from_audio(audio_bytes)
+ print(f"Preset voice gender check: preset={key!r}, estimated={estimated_gender!r}, expected={expected_gender!r}")
+ if estimated_gender != "unknown":
+ assert estimated_gender == expected_gender, (
+ f"{voice_name!r} is expected {expected_gender}, but estimated gender is {estimated_gender!r}"
+ )
+
+
+def _compute_pcm_hnr_db(pcm_samples: np.ndarray, sr: int = _PCM_SPEECH_SAMPLE_RATE_HZ) -> float:
+ frame_len = int(0.03 * sr)
+ hop = frame_len // 2
+ hnr_values: list[float] = []
+ for start in range(0, len(pcm_samples) - frame_len, hop):
+ frame = pcm_samples[start : start + frame_len].astype(np.float32, copy=False)
+ frame = frame - np.mean(frame)
+ if np.max(np.abs(frame)) < 0.01:
+ continue
+ ac = np.correlate(frame, frame, mode="full")[len(frame) - 1 :]
+ ac = ac / (ac[0] + 1e-10)
+ min_lag = int(sr / 400)
+ max_lag = min(int(sr / 80), len(ac))
+ if min_lag >= max_lag:
+ continue
+ peak = float(np.max(ac[min_lag:max_lag]))
+ if 0 < peak < 1:
+ hnr_values.append(10 * np.log10(peak / (1 - peak + 1e-10)))
+ return float(np.mean(hnr_values)) if hnr_values else 0.0
+
+
+def _assert_pcm_int16_speech_hnr(audio_bytes: bytes) -> None:
+ assert audio_bytes is not None and len(audio_bytes) >= 2, "missing PCM bytes"
+ assert len(audio_bytes) % 2 == 0, "PCM byte length must be aligned to int16"
+ pcm_samples = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
+ hnr = _compute_pcm_hnr_db(pcm_samples)
+ print(f"PCM speech HNR: {hnr:.2f} dB (threshold: {_MIN_PCM_SPEECH_HNR_DB} dB)")
+ assert hnr >= _MIN_PCM_SPEECH_HNR_DB, (
+ f"Audio distortion detected: HNR={hnr:.2f} dB < {_MIN_PCM_SPEECH_HNR_DB} dB. "
+ "Voice clone decoder may be losing ref_code speaker context on later chunks."
+ )
+
+
+def assert_omni_response(response: Any, request_config: dict[str, Any], run_level):
+ """
+ Validate response results.
+
+ Args:
+ response: OmniResponse object
+
+ Raises:
+ AssertionError: When the response does not meet validation criteria
+ """
+ assert response.success, "The request failed."
+ e2e_latency = response.e2e_latency
+ if e2e_latency is not None:
+ print(f"the e2e latency is: {e2e_latency}")
+
+ modalities = request_config.get("modalities", ["text", "audio"])
+
+ if run_level == "advanced_model":
+ if "audio" in modalities:
+ assert response.audio_content is not None, "No audio output is generated"
+ print(f"audio content is: {response.audio_content}")
+ speaker = request_config.get("speaker")
+ if speaker:
+ _assert_preset_voice_gender_from_audio(
+ response.audio_bytes,
+ speaker,
+ )
+
+ if "text" in modalities:
+ assert response.text_content is not None, "No text output is generated"
+ print(f"text content is: {response.text_content}")
+
+ # Verify image description
+ word_types = ["text", "image", "audio", "video"]
+ keywords_dict = request_config.get("key_words", {})
+ for word_type in word_types:
+ keywords = keywords_dict.get(word_type)
+ if "text" in modalities:
+ if keywords:
+ text_lower = response.text_content.lower()
+ assert any(str(kw).lower() in text_lower for kw in keywords), (
+ "The output does not contain any of the keywords."
+ )
+ else:
+ if keywords:
+ audio_lower = response.audio_content.lower()
+ assert any(str(kw).lower() in audio_lower for kw in keywords), (
+ "The output does not contain any of the keywords."
+ )
+
+ # Verify similarity (Whisper transcript vs streamed/detokenized text)
+ if "text" in modalities and "audio" in modalities:
+ assert response.similarity is not None and response.similarity > 0.9, (
+ "The audio content is not same as the text"
+ )
+ print(f"similarity is: {response.similarity}")
+
+
+def assert_audio_speech_response(response: Any, request_config: dict[str, Any], run_level: str) -> None:
+ assert response.success, "The request failed."
+ e2e_latency = getattr(response, "e2e_latency", None)
+ if e2e_latency is not None:
+ print(f"the avg e2e latency is: {e2e_latency}")
+
+ req_fmt = request_config.get("response_format")
+ if req_fmt == "pcm" and response.audio_bytes:
+ _assert_pcm_int16_speech_hnr(response.audio_bytes)
+ if response.audio_format:
+ assert "pcm" in response.audio_format.lower(), (
+ f"Expected audio/pcm content-type, got {response.audio_format!r}"
+ )
+ elif req_fmt == "wav" and response.audio_format:
+ assert req_fmt in response.audio_format
+
+ if run_level == "advanced_model" and req_fmt != "pcm":
+ expected_text = request_config.get("input")
+ if expected_text:
+ transcript = (response.audio_content or "").strip()
+ print(f"audio content is: {transcript}")
+ print(f"input text is: {expected_text}")
+ similarity = cosine_similarity_text(transcript.lower(), expected_text.lower())
+ print(f"Cosine similarity: {similarity:.3f}")
+ assert similarity > 0.9, (
+ f"Transcript doesn't match input: similarity={similarity:.2f}, transcript='{transcript}'"
+ )
+ _assert_preset_voice_gender_from_audio(response.audio_bytes, request_config.get("voice"))
+
+
+def assert_diffusion_response(response: Any, request_config: dict[str, Any], run_level: str = None):
+ assert response.success, "The request failed."
+ e2e_latency = getattr(response, "e2e_latency", None)
+ if e2e_latency is not None:
+ print(f"the avg e2e is: {e2e_latency}")
+ has_any_content = any(content is not None for content in (response.images, response.videos, response.audios))
+ assert has_any_content, "Response contains no images, videos, or audios"
+ if response.images is not None:
+ assert_image_diffusion_response(response=response, request_config=request_config, run_level=run_level)
+ if response.videos is not None:
+ assert_video_diffusion_response(response=response, request_config=request_config, run_level=run_level)
+ if response.audios is not None:
+ assert_audio_diffusion_response(response=response, request_config=request_config, run_level=run_level)
+
+
+__all__ = [
+ "assert_audio_diffusion_response",
+ "assert_audio_speech_response",
+ "assert_diffusion_response",
+ "assert_image_diffusion_response",
+ "assert_image_valid",
+ "assert_omni_response",
+ "assert_video_diffusion_response",
+ "assert_video_valid",
+ "assert_audio_valid",
+]
diff --git a/tests/helpers/env.py b/tests/helpers/env.py
new file mode 100644
index 000000000..22ec9a786
--- /dev/null
+++ b/tests/helpers/env.py
@@ -0,0 +1,280 @@
+"""Test environment / lifecycle helpers (GPU cleanup hooks and memory monitoring for tests).
+
+``vllm.platforms`` / ``vllm_omni.platforms`` are imported only inside functions that need them
+so importing this module at pytest plugin load does not run before session autouse fixtures
+"""
+
+from __future__ import annotations
+
+import gc
+import os
+import subprocess
+import threading
+import time
+from contextlib import contextmanager
+
+import torch
+
+
+def run_forced_gpu_cleanup_round() -> None:
+ run_pre_test_cleanup(enable_force=True)
+ run_post_test_cleanup(enable_force=True)
+
+
+def get_physical_device_indices(devices):
+ visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES")
+ if visible_devices is None:
+ return devices
+ visible_indices = [int(x) for x in visible_devices.split(",")]
+ index_mapping = {i: physical for i, physical in enumerate(visible_indices)}
+ return [index_mapping[i] for i in devices if i in index_mapping]
+
+
+def wait_for_gpu_memory_to_clear(
+ *,
+ devices: list[int],
+ threshold_bytes: int | None = None,
+ threshold_ratio: float | None = None,
+ timeout_s: float = 120,
+) -> None:
+ from vllm.platforms import current_platform
+
+ assert threshold_bytes is not None or threshold_ratio is not None
+ devices = get_physical_device_indices(devices)
+ start_time = time.time()
+
+ device_list = ", ".join(str(d) for d in devices)
+ if threshold_bytes is not None:
+ threshold_str = f"{threshold_bytes / 2**30:.2f} GiB"
+ condition_str = f"Memory usage ≤ {threshold_str}"
+ else:
+ threshold_percent = threshold_ratio * 100
+ threshold_str = f"{threshold_percent:.1f}%"
+ condition_str = f"Memory usage ratio ≤ {threshold_str}"
+
+ print(f"[GPU Memory Monitor] Waiting for GPU {device_list} to free memory, Condition: {condition_str}")
+
+ if threshold_bytes is not None:
+
+ def is_free(used, total):
+ return used <= threshold_bytes / 2**30
+ else:
+
+ def is_free(used, total):
+ return used / total <= threshold_ratio
+
+ @contextmanager
+ def nvml_scope():
+ if current_platform.is_rocm():
+ from amdsmi import amdsmi_init, amdsmi_shut_down
+
+ amdsmi_init()
+ try:
+ yield
+ finally:
+ amdsmi_shut_down()
+ elif current_platform.is_cuda():
+ from vllm.third_party.pynvml import nvmlInit, nvmlShutdown
+
+ nvmlInit()
+ try:
+ yield
+ finally:
+ nvmlShutdown()
+ else:
+ yield
+
+ is_rocm = current_platform.is_rocm()
+
+ with nvml_scope():
+ if is_rocm:
+ from amdsmi import amdsmi_get_gpu_vram_usage, amdsmi_get_processor_handles
+ elif current_platform.is_cuda():
+ from vllm.third_party.pynvml import nvmlDeviceGetHandleByIndex, nvmlDeviceGetMemoryInfo
+
+ while True:
+ output: dict[int, str] = {}
+ output_raw: dict[int, tuple[float, float]] = {}
+ for device in devices:
+ if is_rocm:
+ dev_handle = amdsmi_get_processor_handles()[device]
+ mem_info = amdsmi_get_gpu_vram_usage(dev_handle)
+ gb_used = mem_info["vram_used"] / 2**10
+ gb_total = mem_info["vram_total"] / 2**10
+ else:
+ dev_handle = nvmlDeviceGetHandleByIndex(device)
+ mem_info = nvmlDeviceGetMemoryInfo(dev_handle)
+ gb_used = mem_info.used / 2**30
+ gb_total = mem_info.total / 2**30
+ output_raw[device] = (gb_used, gb_total)
+ usage_percent = (gb_used / gb_total) * 100 if gb_total > 0 else 0
+ output[device] = f"{gb_used:.1f}GiB/{gb_total:.1f}GiB ({usage_percent:.1f}%)"
+
+ print("[GPU Memory Status] Current usage:")
+ for device_id, mem_info in output.items():
+ print(f" GPU {device_id}: {mem_info}")
+
+ dur_s = time.time() - start_time
+ elapsed_minutes = dur_s / 60
+ if all(is_free(used, total) for used, total in output_raw.values()):
+ print(f"[GPU Memory Freed] Devices {device_list} meet memory condition")
+ print(f" Condition: {condition_str}")
+ print(f" Wait time: {dur_s:.1f} seconds ({elapsed_minutes:.1f} minutes)")
+ break
+
+ if dur_s >= timeout_s:
+ raise ValueError(
+ f"[GPU Memory Timeout] Devices {device_list} still don't meet memory condition after {dur_s:.1f} seconds\n"
+ f"Condition: {condition_str}\n"
+ f"Current status:\n" + "\n".join(f" GPU {device}: {output[device]}" for device in devices)
+ )
+
+ gc.collect()
+ torch.cuda.empty_cache()
+ time.sleep(5)
+
+
+def _print_gpu_processes() -> None:
+ """Print GPU information including nvidia-smi and system processes."""
+
+ print("\n" + "=" * 80)
+ print("NVIDIA GPU Information (nvidia-smi)")
+ print("=" * 80)
+
+ try:
+ nvidia_result = subprocess.run(
+ ["nvidia-smi"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+
+ if nvidia_result.returncode == 0:
+ lines = nvidia_result.stdout.strip().split("\n")
+ for line in lines[:20]:
+ print(line)
+
+ if len(lines) > 20:
+ print(f"... (showing first 20 of {len(lines)} lines)")
+ else:
+ print("nvidia-smi command failed")
+
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ print("nvidia-smi not available or timed out")
+ except Exception as e:
+ print(f"Error running nvidia-smi: {e}")
+
+ print("\n" + "=" * 80)
+ print("Detailed GPU Processes (nvidia-smi pmon)")
+ print("=" * 80)
+
+ try:
+ pmon_result = subprocess.run(
+ ["nvidia-smi", "pmon", "-c", "1"],
+ capture_output=True,
+ text=True,
+ timeout=3,
+ )
+
+ if pmon_result.returncode == 0 and pmon_result.stdout.strip():
+ print(pmon_result.stdout)
+ else:
+ print("No active GPU processes found via nvidia-smi pmon")
+
+ except Exception:
+ print("nvidia-smi pmon not available")
+
+ print("\n" + "=" * 80)
+ print("System Processes with GPU keywords")
+ print("=" * 80)
+
+
+_SKIPPED_GPU_CLEANUP_MSG = (
+ "\nSkipping GPU memory cleanup check (typically: instance already up; no check needed between tests)\n"
+)
+
+
+def run_pre_test_cleanup(enable_force: bool = False) -> None:
+ if os.getenv("VLLM_TEST_CLEAN_GPU_MEMORY", "0") != "1" and not enable_force:
+ print(_SKIPPED_GPU_CLEANUP_MSG)
+ return
+
+ print("Pre-test GPU status:")
+
+ num_gpus = torch.cuda.device_count()
+ if num_gpus > 0:
+ try:
+ wait_for_gpu_memory_to_clear(
+ devices=list(range(num_gpus)),
+ threshold_ratio=0.05,
+ )
+ except Exception as e:
+ print(f"Pre-test cleanup note: {e}")
+
+
+def run_post_test_cleanup(enable_force: bool = False) -> None:
+ if os.getenv("VLLM_TEST_CLEAN_GPU_MEMORY", "0") != "1" and not enable_force:
+ print(_SKIPPED_GPU_CLEANUP_MSG)
+ return
+
+ if torch.cuda.is_available():
+ gc.collect()
+ torch.cuda.empty_cache()
+
+ print("Post-test GPU status:")
+ _print_gpu_processes()
+
+
+class DeviceMemoryMonitor:
+ """Poll global device memory usage."""
+
+ def __init__(self, device_index: int, interval: float = 0.05):
+ self.device_index = device_index
+ self.interval = interval
+ self._peak_used_mb = 0.0
+ self._stop_event = threading.Event()
+ self._thread: threading.Thread | None = None
+
+ def start(self) -> None:
+ from vllm_omni.platforms import current_omni_platform
+
+ def monitor_loop() -> None:
+ while not self._stop_event.is_set():
+ try:
+ with current_omni_platform.device(self.device_index):
+ free_bytes, total_bytes = current_omni_platform.mem_get_info()
+ used_mb = (total_bytes - free_bytes) / (1024**2)
+ self._peak_used_mb = max(self._peak_used_mb, used_mb)
+ except Exception:
+ pass
+ time.sleep(self.interval)
+
+ self._thread = threading.Thread(target=monitor_loop, daemon=False)
+ self._thread.start()
+
+ def stop(self) -> None:
+ if self._thread is None:
+ return
+ self._stop_event.set()
+ self._thread.join(timeout=2.0)
+
+ @property
+ def peak_used_mb(self) -> float:
+ from vllm_omni.platforms import current_omni_platform
+
+ fallback_alloc = current_omni_platform.max_memory_allocated(device=self.device_index) / (1024**2)
+ fallback_reserved = current_omni_platform.max_memory_reserved(device=self.device_index) / (1024**2)
+ return max(self._peak_used_mb, fallback_alloc, fallback_reserved)
+
+ def __del__(self):
+ self.stop()
+
+
+__all__ = [
+ "DeviceMemoryMonitor",
+ "get_physical_device_indices",
+ "run_post_test_cleanup",
+ "run_pre_test_cleanup",
+ "run_forced_gpu_cleanup_round",
+ "wait_for_gpu_memory_to_clear",
+]
diff --git a/tests/helpers/fixtures/__init__.py b/tests/helpers/fixtures/__init__.py
new file mode 100644
index 000000000..8bd090b78
--- /dev/null
+++ b/tests/helpers/fixtures/__init__.py
@@ -0,0 +1 @@
+"""Pytest fixture modules under tests.helpers."""
diff --git a/tests/helpers/fixtures/env.py b/tests/helpers/fixtures/env.py
new file mode 100644
index 000000000..939bad02c
--- /dev/null
+++ b/tests/helpers/fixtures/env.py
@@ -0,0 +1,59 @@
+import os
+
+import pytest
+import torch
+
+
+@pytest.fixture(scope="session", autouse=True)
+def default_env():
+ # Keep behavior but avoid import-time side effects (RFC #2299).
+ keys = ("VLLM_WORKER_MULTIPROC_METHOD", "VLLM_TARGET_DEVICE")
+ previous = {key: os.environ.get(key) for key in keys}
+ os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = previous["VLLM_WORKER_MULTIPROC_METHOD"] or "spawn"
+ os.environ["VLLM_TARGET_DEVICE"] = previous["VLLM_TARGET_DEVICE"] or (
+ "cuda" if torch.cuda.is_available() and torch.cuda.device_count() > 0 else "cpu"
+ )
+ yield
+ for key, value in previous.items():
+ if value is None:
+ os.environ.pop(key, None)
+ else:
+ os.environ[key] = value
+
+
+@pytest.fixture(scope="session")
+def model_prefix() -> str:
+ prefix = os.environ.get("MODEL_PREFIX", "")
+ return f"{prefix.rstrip('/')}/" if prefix else ""
+
+
+@pytest.fixture(autouse=True)
+def clean_gpu_memory_between_tests():
+ # Import here so ``tests.helpers.env`` (and vLLM platform modules) load only
+ # after session autouse fixtures like ``default_env`` have run (RFC #2299).
+ from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
+
+ print("\n=== PRE-TEST GPU CLEANUP ===")
+ run_pre_test_cleanup()
+ yield
+ run_post_test_cleanup()
+
+
+@pytest.fixture(scope="session", autouse=True)
+def default_vllm_config():
+ """Set a default VllmConfig for the whole test session.
+
+ Session scope ensures module-scoped fixtures (e.g. ``omni_runner``) and
+ deferred imports of ``tests.helpers.runtime`` both see the same context.
+ Function-scoped autouse ran too late for ``OmniRunner`` setup and could
+ desynchronize vLLM init vs request preprocessing (e.g. renderer state).
+ """
+ from vllm.config import DeviceConfig, VllmConfig, set_current_vllm_config
+
+ # Use CPU device if no GPU is available (e.g., in CI environments)
+ has_gpu = torch.cuda.is_available() and torch.cuda.device_count() > 0
+ device = "cuda" if has_gpu else "cpu"
+ device_config = DeviceConfig(device=device)
+
+ with set_current_vllm_config(VllmConfig(device_config=device_config)):
+ yield
diff --git a/tests/helpers/fixtures/log.py b/tests/helpers/fixtures/log.py
new file mode 100644
index 000000000..798fa4ae6
--- /dev/null
+++ b/tests/helpers/fixtures/log.py
@@ -0,0 +1,7 @@
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def log_test_name_before_test(request: pytest.FixtureRequest):
+ print(f"--- Running test: {request.node.name}")
+ yield
diff --git a/tests/helpers/fixtures/run_args.py b/tests/helpers/fixtures/run_args.py
new file mode 100644
index 000000000..b18a64b98
--- /dev/null
+++ b/tests/helpers/fixtures/run_args.py
@@ -0,0 +1,17 @@
+import pytest
+
+
+def pytest_addoption(parser):
+ parser.addoption(
+ "--run-level",
+ action="store",
+ default="core_model",
+ choices=["core_model", "advanced_model"],
+ help="Test level to run: L2, L3",
+ )
+
+
+@pytest.fixture(scope="session")
+def run_level(request) -> str:
+ """Session test level from ``--run-level`` (see CI five-level docs)."""
+ return request.config.getoption("--run-level")
diff --git a/tests/helpers/fixtures/runtime.py b/tests/helpers/fixtures/runtime.py
new file mode 100644
index 000000000..77a468f2f
--- /dev/null
+++ b/tests/helpers/fixtures/runtime.py
@@ -0,0 +1,137 @@
+"""Runtime fixtures (OmniRunner / OmniServer). Imports are deferred to fixture time.
+
+Loading ``tests.helpers.runtime`` at plugin import time (before session fixtures)
+pulls in vLLM/vllm_omni too early and breaks initialization order vs the legacy
+monolithic conftest. Defer imports until fixtures run so ``default_env`` /
+``default_vllm_config`` run first.
+"""
+
+from __future__ import annotations
+
+import threading
+from collections.abc import Generator
+from typing import Any
+
+import pytest
+import yaml
+
+from tests.helpers.runtime import OmniServer
+from tests.helpers.stage_config import modify_stage_config
+
+omni_fixture_lock = threading.Lock()
+
+
+@pytest.fixture(scope="module")
+def omni_server(request: pytest.FixtureRequest, run_level: str, model_prefix: str) -> Generator[OmniServer, Any, None]:
+ """Start vLLM-Omni through the standard or stage-CLI launcher.
+
+ The fixture stays module-scoped because multi-stage initialization is costly.
+ The ``use_stage_cli`` flag on ``OmniServerParams`` routes the setup through the
+ stage-CLI harness while still reusing the same fixture grouping semantics.
+ """
+ with omni_fixture_lock:
+ from tests.helpers.runtime import OmniServer, OmniServerParams, OmniServerStageCli
+
+ params: OmniServerParams = request.param
+ model = model_prefix + params.model
+ port = params.port
+ stage_config_path = params.stage_config_path
+ if run_level == "advanced_model" and stage_config_path is not None:
+ with open(stage_config_path, encoding="utf-8") as f:
+ cfg = yaml.safe_load(f) or {}
+ # Strip ``load_format: dummy`` (CI overlay default) so advanced_model
+ # tests use real weights. New schema (``stages:``) writes the field
+ # flat at stage level; legacy schema (``stage_args:``) nests it as
+ # ``engine_args.load_format``. Handle both.
+ new_schema_stages = cfg.get("stages")
+ stage_key = "stages" if new_schema_stages is not None else "stage_args"
+ delete_path = "load_format" if new_schema_stages is not None else "engine_args.load_format"
+ stage_entries = cfg.get(stage_key, [])
+ stage_ids = [stage["stage_id"] for stage in stage_entries if "stage_id" in stage]
+ stage_config_path = modify_stage_config(
+ stage_config_path,
+ deletes={stage_key: {stage_id: [delete_path] for stage_id in stage_ids}},
+ )
+
+ server_args = params.server_args or []
+ if params.use_omni and params.stage_init_timeout is not None:
+ server_args = [*server_args, "--stage-init-timeout", str(params.stage_init_timeout)]
+ else:
+ server_args = [*server_args, "--stage-init-timeout", "600"]
+ if params.init_timeout is not None:
+ server_args = [*server_args, "--init-timeout", str(params.init_timeout)]
+ else:
+ server_args = [*server_args, "--init-timeout", "900"]
+ if params.use_stage_cli:
+ if not params.use_omni:
+ raise ValueError("omni_server with use_stage_cli=True requires use_omni=True")
+ if stage_config_path is None:
+ raise ValueError("omni_server with use_stage_cli=True requires a stage_config_path")
+ server_args += ["--stage-configs-path", stage_config_path]
+
+ with OmniServerStageCli(
+ model,
+ stage_config_path,
+ server_args,
+ port=port,
+ env_dict=params.env_dict,
+ ) as server:
+ print("OmniServer started successfully")
+ yield server
+ print("OmniServer stopping...")
+ else:
+ if stage_config_path is not None:
+ server_args += ["--stage-configs-path", stage_config_path]
+
+ with (
+ OmniServer(
+ model,
+ server_args,
+ port=port,
+ env_dict=params.env_dict,
+ use_omni=params.use_omni,
+ )
+ if port
+ else OmniServer(
+ model,
+ server_args,
+ env_dict=params.env_dict,
+ use_omni=params.use_omni,
+ )
+ ) as server:
+ print("OmniServer started successfully")
+ yield server
+ print("OmniServer stopping...")
+
+ print("OmniServer stopped")
+
+
+@pytest.fixture
+def openai_client(request: pytest.FixtureRequest, run_level: str):
+ """Resolve ``omni_server`` lazily so parametrized server fixtures work like upstream."""
+ from tests.helpers.runtime import OpenAIClientHandler
+
+ server = request.getfixturevalue("omni_server")
+ return OpenAIClientHandler(host=server.host, port=server.port, api_key="EMPTY", run_level=run_level)
+
+
+@pytest.fixture(scope="module")
+def omni_runner(request: pytest.FixtureRequest, model_prefix: str):
+ from tests.helpers.runtime import OmniRunner
+
+ with omni_fixture_lock:
+ model, stage_config_path = request.param
+ model = model_prefix + model
+ with OmniRunner(model, seed=42, stage_configs_path=stage_config_path) as runner:
+ print("OmniRunner started successfully")
+ yield runner
+ print("OmniRunner stopping...")
+
+ print("OmniRunner stopped")
+
+
+@pytest.fixture
+def omni_runner_handler(omni_runner: Any):
+ from tests.helpers.runtime import OmniRunnerHandler
+
+ return OmniRunnerHandler(omni_runner)
diff --git a/tests/helpers/mark.py b/tests/helpers/mark.py
new file mode 100644
index 000000000..ed45dd7e9
--- /dev/null
+++ b/tests/helpers/mark.py
@@ -0,0 +1,135 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Pytest marks and decorators for hardware / resource selection (CUDA, ROCm, …)."""
+
+import pytest
+from vllm.utils.torch_utils import cuda_device_count_stateless
+
+# Re-exported from tests.helpers.env (GPU wait + DeviceMemoryMonitor).
+
+
+def cuda_marks(*, res: str, num_cards: int):
+ test_platform_detail = pytest.mark.cuda
+ if res == "L4":
+ test_resource = pytest.mark.L4
+ elif res == "H100":
+ test_resource = pytest.mark.H100
+ else:
+ raise ValueError(f"Invalid CUDA resource type: {res}. Supported: L4, H100")
+ marks = [test_resource, test_platform_detail]
+ if num_cards == 1:
+ return marks
+ test_distributed = pytest.mark.distributed_cuda(num_cards=num_cards)
+ test_skipif = pytest.mark.skipif_cuda(
+ cuda_device_count_stateless() < num_cards,
+ reason=f"Need at least {num_cards} CUDA GPUs to run the test.",
+ )
+ return marks + [test_distributed, test_skipif]
+
+
+def rocm_marks(*, res: str, num_cards: int):
+ test_platform_detail = pytest.mark.rocm
+ if res == "MI325":
+ test_resource = pytest.mark.MI325
+ else:
+ raise ValueError(f"Invalid ROCm resource type: {res}. Supported: MI325")
+ marks = [test_resource, test_platform_detail]
+ if num_cards == 1:
+ return marks
+ test_distributed = pytest.mark.distributed_rocm(num_cards=num_cards)
+ return marks + [test_distributed]
+
+
+def xpu_marks(*, res: str, num_cards: int):
+ test_platform_detail = pytest.mark.xpu
+ if res == "B60":
+ test_resource = pytest.mark.B60
+ else:
+ raise ValueError(f"Invalid XPU resource type: {res}. Supported: B60")
+ marks = [test_resource, test_platform_detail]
+ if num_cards == 1:
+ return marks
+ test_distributed = pytest.mark.distributed_rocm(num_cards=num_cards)
+ return marks + [test_distributed]
+
+
+def musa_marks(*, res: str, num_cards: int):
+ test_platform_detail = pytest.mark.musa
+ if res == "S5000":
+ test_resource = pytest.mark.S5000
+ else:
+ raise ValueError(f"Invalid MUSA resource type: {res}. Supported: S5000")
+ marks = [test_resource, test_platform_detail]
+ if num_cards == 1:
+ return marks
+ test_distributed = pytest.mark.distributed_musa(num_cards=num_cards)
+ return marks + [test_distributed]
+
+
+def gpu_marks(*, res: str, num_cards: int):
+ test_platform = pytest.mark.gpu
+ if res in ("L4", "H100"):
+ return [test_platform] + cuda_marks(res=res, num_cards=num_cards)
+ if res == "MI325":
+ return [test_platform] + rocm_marks(res=res, num_cards=num_cards)
+ if res == "B60":
+ return [test_platform] + xpu_marks(res=res, num_cards=num_cards)
+ if res == "S5000":
+ return [test_platform] + musa_marks(res=res, num_cards=num_cards)
+ raise ValueError(f"Invalid resource type: {res}. Supported: L4, H100, MI325, B60, S5000")
+
+
+def npu_marks(*, res: str, num_cards: int):
+ test_platform = pytest.mark.npu
+ if res == "A2":
+ test_resource = pytest.mark.A2
+ elif res == "A3":
+ test_resource = pytest.mark.A3
+ else:
+ test_resource = None
+ if num_cards == 1:
+ return [mark for mark in [test_platform, test_resource] if mark is not None]
+ test_distributed = pytest.mark.distributed_npu(num_cards=num_cards)
+ return [mark for mark in [test_platform, test_resource, test_distributed] if mark is not None]
+
+
+def hardware_marks(*, res: dict[str, str], num_cards: int | dict[str, int] = 1):
+ for platform, _ in res.items():
+ if platform not in ("cuda", "rocm", "xpu", "npu", "musa"):
+ raise ValueError(f"Unsupported platform: {platform}")
+ if isinstance(num_cards, int):
+ num_cards_dict = {platform: num_cards for platform in res.keys()}
+ else:
+ num_cards_dict = num_cards
+ for platform in num_cards_dict.keys():
+ if platform not in res:
+ raise ValueError(f"Platform '{platform}' in num_cards but not in res.")
+ for platform in res.keys():
+ if platform not in num_cards_dict:
+ num_cards_dict[platform] = 1
+
+ all_marks: list[pytest.MarkDecorator] = []
+ for platform, resource in res.items():
+ cards = num_cards_dict[platform]
+ if platform in ("cuda", "rocm", "xpu"):
+ marks = gpu_marks(res=resource, num_cards=cards)
+ elif platform == "musa":
+ marks = musa_marks(res=resource, num_cards=cards)
+ elif platform == "npu":
+ marks = npu_marks(res=resource, num_cards=cards)
+ else:
+ raise ValueError(f"Unsupported platform: {platform}")
+ all_marks.extend(marks)
+ return all_marks
+
+
+def hardware_test(*, res: dict[str, str], num_cards: int | dict[str, int] = 1):
+ all_marks = hardware_marks(res=res, num_cards=num_cards)
+
+ def wrapper(f):
+ func = f
+ for mark in reversed(all_marks):
+ func = mark(func)
+ return func
+
+ return wrapper
diff --git a/tests/helpers/media.py b/tests/helpers/media.py
new file mode 100644
index 000000000..4463acbbb
--- /dev/null
+++ b/tests/helpers/media.py
@@ -0,0 +1,648 @@
+"""Synthetic media generation and media/text utilities for tests."""
+
+import base64
+import concurrent.futures
+import gc
+import io
+import logging
+import math
+import multiprocessing
+import os
+import random
+import re
+import subprocess
+import tempfile
+import time
+import uuid
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import soundfile as sf
+from PIL import Image
+
+logger = logging.getLogger(__name__)
+
+
+def _resolve_synthetic_media_cache_dir(cache_dir: Path | str | None) -> Path:
+ if cache_dir is not None:
+ return Path(cache_dir).expanduser().resolve()
+ return Path(tempfile.gettempdir()) / "vllm_omni_test_synthetic_media"
+
+
+def _np_array_from_mp4_bytes(video_bytes: bytes) -> np.ndarray:
+ """Decode MP4 bytes to a (T, H, W, 3) uint8 RGB stack (matches in-memory synthetic frames)."""
+ import cv2
+
+ with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
+ tmp.write(video_bytes)
+ path = tmp.name
+ cap = None
+ try:
+ cap = cv2.VideoCapture(path)
+ if not cap.isOpened():
+ raise RuntimeError("Failed to open cached synthetic video for decode")
+ frames: list[np.ndarray] = []
+ while True:
+ ok, frame_bgr = cap.read()
+ if not ok:
+ break
+ frames.append(cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB))
+ if not frames:
+ raise RuntimeError("Cached synthetic video has no decodable frames")
+ return np.stack(frames, axis=0)
+ finally:
+ if cap is not None:
+ cap.release()
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+
+
+def generate_synthetic_audio(
+ duration: int,
+ num_channels: int,
+ sample_rate: int = 48000,
+ *,
+ force_regenerate: bool = False,
+ cache_dir: Path | str | None = None,
+) -> dict[str, Any]:
+ """
+ Generate TTS speech with pyttsx3 and return base64 string.
+
+ Caches the WAV under ``cache_dir`` when given, else under the default temp
+ subdirectory. Reuses the file when the same
+ ``duration`` / ``num_channels`` / ``sample_rate`` are requested unless
+ ``force_regenerate`` is true.
+ """
+ root = _resolve_synthetic_media_cache_dir(cache_dir)
+ root.mkdir(parents=True, exist_ok=True)
+ cache_path = root / f"synth_audio_d{duration}_ch{num_channels}_sr{sample_rate}.wav"
+
+ if not force_regenerate and cache_path.is_file():
+ data, _sr = sf.read(str(cache_path), dtype="float32", always_2d=True)
+ audio_bytes = cache_path.read_bytes()
+ return {
+ "np_array": np.asarray(data, dtype=np.float32),
+ "base64": base64.b64encode(audio_bytes).decode("utf-8"),
+ "file_path": str(cache_path.resolve()),
+ }
+
+ import pyttsx3
+
+ def _pick_voice(engine: pyttsx3.Engine) -> str | None:
+ voices = engine.getProperty("voices")
+ if not voices:
+ return None
+
+ preferred_tokens = (
+ "natural",
+ "jenny",
+ "sonia",
+ "susan",
+ "zira",
+ "aria",
+ "hazel",
+ "samantha",
+ "ava",
+ "allison",
+ "female",
+ "woman",
+ "english-us",
+ "en-us",
+ "english",
+ )
+ discouraged_tokens = (
+ "espeak",
+ "robot",
+ "mbrola",
+ "microsoft david",
+ "male",
+ "man",
+ )
+
+ best_voice = voices[0]
+ best_score = float("-inf")
+ for voice in voices:
+ voice_text = f"{getattr(voice, 'id', '')} {getattr(voice, 'name', '')}".lower()
+ voice_languages = " ".join(
+ lang.decode(errors="ignore") if isinstance(lang, bytes) else str(lang)
+ for lang in getattr(voice, "languages", [])
+ ).lower()
+ combined_text = f"{voice_text} {voice_languages}"
+ score = 0
+ for idx, token in enumerate(preferred_tokens):
+ if token in combined_text:
+ score += 20 - idx
+ for token in discouraged_tokens:
+ if token in combined_text:
+ score -= 10
+ if "english" in combined_text or "en_" in combined_text or "en-" in combined_text:
+ score += 4
+ if "en-us" in combined_text or "english-us" in combined_text:
+ score += 4
+ if score > best_score:
+ best_score = score
+ best_voice = voice
+
+ return best_voice.id
+
+ def _resample_audio(audio: np.ndarray, src_sr: int, dst_sr: int) -> np.ndarray:
+ if src_sr == dst_sr or len(audio) == 0:
+ return audio.astype(np.float32)
+ src_len = audio.shape[0]
+ dst_len = max(1, int(round(src_len * float(dst_sr) / float(src_sr))))
+ src_idx = np.arange(src_len, dtype=np.float32)
+ dst_idx = np.linspace(0, src_len - 1, dst_len, dtype=np.float32)
+ resampled_channels: list[np.ndarray] = []
+ for ch in range(audio.shape[1]):
+ resampled_channels.append(np.interp(dst_idx, src_idx, audio[:, ch]).astype(np.float32))
+ return np.stack(resampled_channels, axis=1)
+
+ def _match_channels(audio: np.ndarray, target_channels: int) -> np.ndarray:
+ current_channels = audio.shape[1]
+ if current_channels == target_channels:
+ return audio.astype(np.float32)
+ if target_channels == 1:
+ return np.mean(audio, axis=1, keepdims=True, dtype=np.float32)
+ if current_channels == 1:
+ return np.repeat(audio, target_channels, axis=1).astype(np.float32)
+ collapsed = np.mean(audio, axis=1, keepdims=True, dtype=np.float32)
+ return np.repeat(collapsed, target_channels, axis=1).astype(np.float32)
+
+ def _trim_silence(audio: np.ndarray, threshold: float = 0.01) -> np.ndarray:
+ if len(audio) == 0:
+ return audio
+ energy = np.max(np.abs(audio), axis=1)
+ voiced = np.where(energy > threshold)[0]
+ if len(voiced) == 0:
+ return audio
+ start = max(0, int(voiced[0]) - int(sample_rate * 0.02))
+ end = min(len(audio), int(voiced[-1]) + int(sample_rate * 0.04) + 1)
+ return audio[start:end]
+
+ def _enhance_speech(audio: np.ndarray) -> np.ndarray:
+ if len(audio) == 0:
+ return audio.astype(np.float32)
+ enhanced = audio.astype(np.float32).copy()
+ enhanced -= np.mean(enhanced, axis=0, keepdims=True, dtype=np.float32)
+ if len(enhanced) > 1:
+ preemphasis = enhanced.copy()
+ preemphasis[1:] = enhanced[1:] - 0.94 * enhanced[:-1]
+ enhanced = 0.7 * enhanced + 0.3 * preemphasis
+ enhanced = np.sign(enhanced) * np.sqrt(np.abs(enhanced))
+ fade = min(len(enhanced) // 4, max(1, int(sample_rate * 0.01)))
+ if fade > 1:
+ ramp_in = np.linspace(0.0, 1.0, fade, dtype=np.float32)
+ ramp_out = np.linspace(1.0, 0.0, fade, dtype=np.float32)
+ enhanced[:fade] *= ramp_in[:, None]
+ enhanced[-fade:] *= ramp_out[:, None]
+ peak = float(np.max(np.abs(enhanced)))
+ if peak > 1e-8:
+ enhanced = enhanced / peak * 0.95
+ return enhanced.astype(np.float32)
+
+ phrase_text = "test"
+ num_samples = int(sample_rate * max(1, duration))
+ audio_data = np.zeros((num_samples, num_channels), dtype=np.float32)
+
+ engine = pyttsx3.init()
+ engine.setProperty("rate", 112)
+ engine.setProperty("volume", 1.0)
+ selected_voice = _pick_voice(engine)
+ if selected_voice is not None:
+ engine.setProperty("voice", selected_voice)
+
+ temp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
+ temp_wav.close()
+ try:
+ engine.save_to_file(phrase_text, temp_wav.name)
+ engine.runAndWait()
+ engine.stop()
+
+ ready = False
+ for _ in range(50):
+ if os.path.exists(temp_wav.name) and os.path.getsize(temp_wav.name) > 44:
+ ready = True
+ break
+ time.sleep(0.1)
+ if not ready:
+ raise RuntimeError("pyttsx3 did not produce a WAV file in time.")
+
+ tts_audio, tts_sr = sf.read(temp_wav.name, dtype="float32", always_2d=True)
+ finally:
+ if os.path.exists(temp_wav.name):
+ os.unlink(temp_wav.name)
+
+ if len(tts_audio) == 0:
+ raise RuntimeError("pyttsx3 produced an empty WAV file.")
+
+ tts_audio = _resample_audio(tts_audio, tts_sr, sample_rate)
+ tts_audio = _match_channels(tts_audio, num_channels)
+ tts_audio = _trim_silence(tts_audio, threshold=0.012)
+ tts_audio = _enhance_speech(tts_audio)
+
+ lead_silence = min(int(sample_rate * 0.02), num_samples // 8)
+ pause_samples = int(sample_rate * 0.18)
+ start = lead_silence
+ phrase_len = tts_audio.shape[0]
+ while start < num_samples:
+ take = min(phrase_len, num_samples - start)
+ audio_data[start : start + take] = tts_audio[:take]
+ start += phrase_len + pause_samples
+
+ max_amp = float(np.max(np.abs(audio_data)))
+ if max_amp > 0:
+ audio_data = audio_data / max_amp * 0.95
+
+ sf.write(str(cache_path), audio_data, sample_rate, format="WAV", subtype="PCM_16")
+ audio_bytes = cache_path.read_bytes()
+
+ return {
+ "np_array": audio_data.copy(),
+ "base64": base64.b64encode(audio_bytes).decode("utf-8"),
+ "file_path": str(cache_path.resolve()),
+ }
+
+
+def _mux_mp4_bytes_with_synthetic_audio(
+ video_mp4_bytes: bytes,
+ *,
+ num_frames: int,
+ fps: float = 30.0,
+ sample_rate: int = 48000,
+) -> bytes:
+ duration_sec = num_frames / fps if fps > 0 else 0.0
+ duration_int = max(1, int(math.ceil(duration_sec)))
+
+ try:
+ audio_result = generate_synthetic_audio(
+ duration=duration_int,
+ num_channels=1,
+ sample_rate=sample_rate,
+ )
+ audio_pcm = audio_result["np_array"]
+ except Exception as e:
+ logger.warning("Synthetic video: generate_synthetic_audio failed (%s); using video-only MP4.", e)
+ return video_mp4_bytes
+
+ try:
+ import imageio_ffmpeg
+
+ ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
+ except Exception:
+ ffmpeg_exe = "ffmpeg"
+
+ try:
+ with tempfile.TemporaryDirectory(prefix="syn_vid_mux_") as tmp:
+ vid_path = os.path.join(tmp, "video.mp4")
+ wav_path = os.path.join(tmp, "audio.wav")
+ out_path = os.path.join(tmp, "out.mp4")
+ with open(vid_path, "wb") as f:
+ f.write(video_mp4_bytes)
+ sf.write(wav_path, audio_pcm, sample_rate, format="WAV", subtype="PCM_16")
+ cmd = [
+ ffmpeg_exe,
+ "-y",
+ "-nostdin",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ vid_path,
+ "-i",
+ wav_path,
+ "-c:v",
+ "copy",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "128k",
+ "-shortest",
+ "-movflags",
+ "+faststart",
+ out_path,
+ ]
+ subprocess.run(cmd, check=True, stdin=subprocess.DEVNULL, timeout=300)
+ with open(out_path, "rb") as f:
+ return f.read()
+ except (
+ FileNotFoundError,
+ subprocess.CalledProcessError,
+ subprocess.TimeoutExpired,
+ OSError,
+ ) as e:
+ logger.warning("Synthetic video: audio mux failed (%s); using video-only MP4.", e)
+ return video_mp4_bytes
+
+
+def generate_synthetic_video(
+ width: int,
+ height: int,
+ num_frames: int,
+ *,
+ embed_audio: bool = False,
+ force_regenerate: bool = False,
+ cache_dir: Path | str | None = None,
+) -> dict[str, Any]:
+ """
+ Generate synthetic MP4 (optional AAC audio). Caches final bytes by
+ ``width`` / ``height`` / ``num_frames`` / ``embed_audio`` unless
+ ``force_regenerate`` is true. Cache root: ``cache_dir`` if given, else the
+ default temp subdirectory.
+ """
+ root = _resolve_synthetic_media_cache_dir(cache_dir)
+ root.mkdir(parents=True, exist_ok=True)
+ cache_path = root / f"synth_video_w{width}_h{height}_nf{num_frames}_ea{int(embed_audio)}.mp4"
+
+ if not force_regenerate and cache_path.is_file():
+ video_bytes = cache_path.read_bytes()
+ return {
+ "np_array": _np_array_from_mp4_bytes(video_bytes),
+ "base64": base64.b64encode(video_bytes).decode("utf-8"),
+ "file_path": str(cache_path.resolve()),
+ }
+
+ import cv2
+ import imageio
+
+ num_balls = random.randint(3, 8)
+ balls = []
+ for _ in range(num_balls):
+ radius = min(width, height) // 8
+ if radius < 1:
+ raise ValueError(f"Video dimensions ({width}x{height}) too small")
+ x = random.randint(radius, width - radius)
+ y = random.randint(radius, height - radius)
+ speed = random.uniform(3.0, 8.0)
+ angle = random.uniform(0, 2 * math.pi)
+ vx = speed * math.cos(angle)
+ vy = speed * math.sin(angle)
+ color_bgr = (random.randint(50, 255), random.randint(50, 255), random.randint(50, 255))
+ balls.append({"x": x, "y": y, "vx": vx, "vy": vy, "radius": radius, "color_bgr": color_bgr})
+
+ video_frames = []
+ for _ in range(num_frames):
+ frame_bgr = np.zeros((height, width, 3), dtype=np.uint8)
+ for ball in balls:
+ ball["x"] += ball["vx"]
+ ball["y"] += ball["vy"]
+ if ball["x"] - ball["radius"] <= 0 or ball["x"] + ball["radius"] >= width:
+ ball["vx"] = -ball["vx"]
+ ball["x"] = max(ball["radius"], min(width - ball["radius"], ball["x"]))
+ if ball["y"] - ball["radius"] <= 0 or ball["y"] + ball["radius"] >= height:
+ ball["vy"] = -ball["vy"]
+ ball["y"] = max(ball["radius"], min(height - ball["radius"], ball["y"]))
+ x, y = int(ball["x"]), int(ball["y"])
+ radius = int(ball["radius"])
+ cv2.circle(frame_bgr, (x, y), radius, ball["color_bgr"], -1)
+ frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
+ video_frames.append(frame_rgb)
+
+ fps = 30
+ buffer = io.BytesIO()
+ writer_kwargs = {
+ "format": "mp4",
+ "fps": fps,
+ "codec": "libx264",
+ "quality": 7,
+ "pixelformat": "yuv420p",
+ "macro_block_size": 16,
+ "ffmpeg_params": ["-preset", "medium", "-crf", "23", "-movflags", "+faststart", "-pix_fmt", "yuv420p"],
+ }
+ try:
+ with imageio.get_writer(buffer, **writer_kwargs) as writer:
+ for frame in video_frames:
+ writer.append_data(frame)
+ buffer.seek(0)
+ video_only_bytes = buffer.read()
+ except Exception as e:
+ print(f"Warning: Failed to encode synthetic video: {e}")
+ raise
+ video_bytes = (
+ _mux_mp4_bytes_with_synthetic_audio(video_only_bytes, num_frames=num_frames, fps=float(fps))
+ if embed_audio
+ else video_only_bytes
+ )
+
+ cache_path.write_bytes(video_bytes)
+
+ return {
+ "np_array": np.array(video_frames),
+ "base64": base64.b64encode(video_bytes).decode("utf-8"),
+ "file_path": str(cache_path.resolve()),
+ }
+
+
+def generate_synthetic_image(
+ width: int,
+ height: int,
+ *,
+ force_regenerate: bool = False,
+ cache_dir: Path | str | None = None,
+) -> dict[str, Any]:
+ """
+ Random colored squares on white background. Caches JPEG by ``width`` /
+ ``height`` unless ``force_regenerate`` is true. Cache root: ``cache_dir``
+ if given, else the default temp subdirectory.
+ """
+ root = _resolve_synthetic_media_cache_dir(cache_dir)
+ root.mkdir(parents=True, exist_ok=True)
+ cache_path = root / f"synth_image_w{width}_h{height}.jpg"
+
+ if not force_regenerate and cache_path.is_file():
+ from PIL import Image as PILImage
+
+ image = PILImage.open(cache_path)
+ image.load()
+ image_bytes = cache_path.read_bytes()
+ return {
+ "np_array": np.array(image).copy(),
+ "base64": base64.b64encode(image_bytes).decode("utf-8"),
+ "file_path": str(cache_path.resolve()),
+ }
+
+ from PIL import ImageDraw
+
+ image = Image.new("RGB", (width, height), (255, 255, 255))
+ draw = ImageDraw.Draw(image)
+ num_squares = random.randint(3, 8)
+ for _ in range(num_squares):
+ square_size = random.randint(max(1, min(width, height) // 8), max(2, min(width, height) // 4))
+ x = random.randint(0, max(0, width - square_size - 1))
+ y = random.randint(0, max(0, height - square_size - 1))
+ color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
+ border_width = random.randint(1, 5)
+ draw.rectangle([x, y, x + square_size, y + square_size], fill=color, outline=(0, 0, 0), width=border_width)
+
+ image.save(str(cache_path), format="JPEG", quality=85, optimize=True)
+ image_bytes = cache_path.read_bytes()
+
+ return {
+ "np_array": np.array(image).copy(),
+ "base64": base64.b64encode(image_bytes).decode("utf-8"),
+ "file_path": str(cache_path.resolve()),
+ }
+
+
+def decode_b64_image(b64: str):
+ img = Image.open(io.BytesIO(base64.b64decode(b64)))
+ img.load()
+ return img
+
+
+def preprocess_text(text):
+ import opencc
+
+ word_to_num = {
+ "zero": "0",
+ "one": "1",
+ "two": "2",
+ "three": "3",
+ "four": "4",
+ "five": "5",
+ "six": "6",
+ "seven": "7",
+ "eight": "8",
+ "nine": "9",
+ "ten": "10",
+ }
+ for word, num in word_to_num.items():
+ pattern = r"\b" + re.escape(word) + r"\b"
+ text = re.sub(pattern, num, text, flags=re.IGNORECASE)
+
+ text = re.sub(r"[^\w\s]", "", text)
+ text = re.sub(r"\s+", " ", text)
+ cc = opencc.OpenCC("t2s")
+ text = cc.convert(text)
+ text = re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])", "", text)
+ return text.lower().strip()
+
+
+def cosine_similarity_text(text1, text2, n: int = 3):
+ from collections import Counter
+
+ if not text1 or not text2:
+ return 0.0
+
+ text1 = preprocess_text(text1)
+ text2 = preprocess_text(text2)
+ print(f"cosine similarity text1 is: {text1}, text2 is: {text2}")
+
+ ngrams1 = [text1[i : i + n] for i in range(len(text1) - n + 1)]
+ ngrams2 = [text2[i : i + n] for i in range(len(text2) - n + 1)]
+ counter1 = Counter(ngrams1)
+ counter2 = Counter(ngrams2)
+
+ all_ngrams = set(counter1.keys()) | set(counter2.keys())
+ vec1 = [counter1.get(ng, 0) for ng in all_ngrams]
+ vec2 = [counter2.get(ng, 0) for ng in all_ngrams]
+ dot_product = sum(a * b for a, b in zip(vec1, vec2))
+ norm1 = sum(a * a for a in vec1) ** 0.5
+ norm2 = sum(b * b for b in vec2) ** 0.5
+ if norm1 == 0 or norm2 == 0:
+ return 0.0
+ cosine = dot_product / (norm1 * norm2)
+ # Down-weight when lengths differ: repeated/hallucinated transcripts stay
+ # high in bag-of-ngrams cosine (e.g. ABCABCABC vs ABC) but should score low.
+ len1, len2 = len(text1), len(text2)
+ length_harmony = (2.0 * min(len1, len2)) / (len1 + len2)
+ return cosine * length_harmony
+
+
+def _merge_base64_audio_to_segment(base64_list: list[str]):
+ from pydub import AudioSegment
+
+ merged = None
+ for b64 in base64_list:
+ raw = base64.b64decode(b64.split(",", 1)[-1])
+ seg = AudioSegment.from_file(io.BytesIO(raw))
+ merged = seg if merged is None else merged + seg
+ return merged
+
+
+@contextmanager
+def _serialize_whisper_small_model_download():
+ """Serialize Whisper ``small`` cache writes across processes (Linux/Unix)."""
+ import fcntl
+
+ lock_path = Path.home() / ".cache" / "whisper" / ".small_model_download.lock"
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
+ f = open(lock_path, "a+b")
+ try:
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
+ yield
+ finally:
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
+ f.close()
+
+
+def _whisper_transcribe_in_current_process(output_path: str) -> str:
+ import whisper
+
+ device_index = None
+ from vllm_omni.platforms import current_omni_platform
+
+ if current_omni_platform.is_available():
+ n = current_omni_platform.get_device_count()
+ if n == 1:
+ device_index = 0
+ elif n > 1:
+ device_index = n - 1
+
+ if device_index is not None:
+ torch_device = current_omni_platform.get_torch_device(device_index)
+ current_omni_platform.set_device(torch_device)
+ device = str(torch_device)
+ use_accelerator = True
+ else:
+ use_accelerator = False
+ device = "cpu"
+
+ with _serialize_whisper_small_model_download():
+ model = whisper.load_model("small", device=device)
+ try:
+ text = model.transcribe(
+ output_path,
+ temperature=0.0,
+ word_timestamps=True,
+ condition_on_previous_text=False,
+ )["text"]
+ finally:
+ del model
+ gc.collect()
+ if use_accelerator:
+ current_omni_platform.synchronize()
+ current_omni_platform.empty_cache()
+ return text or ""
+
+
+def convert_audio_file_to_text(output_path: str) -> str:
+ """Convert an audio file to text in an isolated subprocess."""
+ ctx = multiprocessing.get_context("spawn")
+ with concurrent.futures.ProcessPoolExecutor(max_workers=1, mp_context=ctx) as executor:
+ future = executor.submit(_whisper_transcribe_in_current_process, output_path)
+ return future.result()
+
+
+def convert_audio_bytes_to_text(raw_bytes: bytes) -> str:
+ output_path = f"./test_{uuid.uuid4().hex}.wav"
+ data, samplerate = sf.read(io.BytesIO(raw_bytes))
+ sf.write(output_path, data, samplerate, format="WAV", subtype="PCM_16")
+ print(f"audio data is saved: {output_path}")
+ return convert_audio_file_to_text(output_path)
+
+
+__all__ = [
+ "_merge_base64_audio_to_segment",
+ "convert_audio_bytes_to_text",
+ "convert_audio_file_to_text",
+ "cosine_similarity_text",
+ "decode_b64_image",
+ "generate_synthetic_audio",
+ "generate_synthetic_image",
+ "generate_synthetic_video",
+ "preprocess_text",
+]
diff --git a/tests/e2e/offline_inference/utils.py b/tests/helpers/process.py
similarity index 58%
rename from tests/e2e/offline_inference/utils.py
rename to tests/helpers/process.py
index 3113599a3..094de9652 100644
--- a/tests/e2e/offline_inference/utils.py
+++ b/tests/helpers/process.py
@@ -1,7 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
-import contextlib
import functools
import os
import signal
@@ -10,73 +9,48 @@ import sys
import tempfile
from collections.abc import Callable
from contextlib import ExitStack, suppress
-from pathlib import Path
from typing import Any, Literal
import cloudpickle
from typing_extensions import ParamSpec
from vllm.platforms import current_platform
-VLLM_PATH = Path(__file__).parent.parent.parent
-"""Path to root of the vLLM repository."""
-
-
_P = ParamSpec("_P")
def fork_new_process_for_each_test(func: Callable[_P, None]) -> Callable[_P, None]:
- """Decorator to fork a new process for each test function.
- See https://github.com/vllm-project/vllm/issues/7053 for more details.
- """
+ """Decorator to fork a new process for each test function."""
@functools.wraps(func)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
- # Make the process the leader of its own process group
- # to avoid sending SIGTERM to the parent process
os.setpgrp()
from _pytest.outcomes import Skipped
- # Create a unique temporary file to store exception info from child
- # process. Use test function name and process ID to avoid collisions.
with (
tempfile.NamedTemporaryFile(
- delete=False,
- mode="w+b",
- prefix=f"vllm_test_{func.__name__}_{os.getpid()}_",
- suffix=".exc",
+ delete=False, mode="w+b", prefix=f"vllm_test_{func.__name__}_{os.getpid()}_", suffix=".exc"
) as exc_file,
ExitStack() as delete_after,
):
exc_file_path = exc_file.name
delete_after.callback(os.remove, exc_file_path)
-
pid = os.fork()
- print(f"Fork a new process to run a test {pid}")
if pid == 0:
- # Parent process responsible for deleting, don't delete
- # in child.
delete_after.pop_all()
try:
func(*args, **kwargs)
except Skipped as e:
- # convert Skipped to exit code 0
print(str(e))
os._exit(0)
except Exception as e:
import traceback
tb_string = traceback.format_exc()
-
- # Try to serialize the exception object first
exc_to_serialize: dict[str, Any]
try:
- # First, try to pickle the actual exception with
- # its traceback.
exc_to_serialize = {"pickled_exception": e}
- # Test if it can be pickled
cloudpickle.dumps(exc_to_serialize)
except (Exception, KeyboardInterrupt):
- # Fall back to string-based approach.
exc_to_serialize = {
"exception_type": type(e).__name__,
"exception_msg": str(e),
@@ -86,7 +60,6 @@ def fork_new_process_for_each_test(func: Callable[_P, None]) -> Callable[_P, Non
with open(exc_file_path, "wb") as f:
cloudpickle.dump(exc_to_serialize, f)
except Exception:
- # Fallback: just print the traceback.
print(tb_string)
os._exit(1)
else:
@@ -94,40 +67,24 @@ def fork_new_process_for_each_test(func: Callable[_P, None]) -> Callable[_P, Non
else:
pgid = os.getpgid(pid)
_pid, _exitcode = os.waitpid(pid, 0)
- # ignore SIGTERM signal itself
old_signal_handler = signal.signal(signal.SIGTERM, signal.SIG_IGN)
- # kill all child processes
os.killpg(pgid, signal.SIGTERM)
- # restore the signal handler
signal.signal(signal.SIGTERM, old_signal_handler)
if _exitcode != 0:
- # Try to read the exception from the child process
exc_info = {}
if os.path.exists(exc_file_path):
- with (
- contextlib.suppress(Exception),
- open(exc_file_path, "rb") as f,
- ):
+ with suppress(Exception), open(exc_file_path, "rb") as f:
exc_info = cloudpickle.load(f)
-
- original_exception = exc_info.get("pickled_exception")
- if original_exception is not None and isinstance(original_exception, Exception):
- # Re-raise the actual exception object if it was
- # successfully pickled.
+ if (original_exception := exc_info.get("pickled_exception")) is not None:
+ assert isinstance(original_exception, Exception)
raise original_exception
-
if (original_tb := exc_info.get("traceback")) is not None:
- # Use string-based traceback for fallback case
raise AssertionError(
- f"Test {func.__name__} failed when called with"
- f" args {args} and kwargs {kwargs}"
+ f"Test {func.__name__} failed when called with args {args} and kwargs {kwargs}"
f" (exit code: {_exitcode}):\n{original_tb}"
) from None
-
- # Fallback to the original generic error
raise AssertionError(
- f"function {func.__name__} failed when called with"
- f" args {args} and kwargs {kwargs}"
+ f"function {func.__name__} failed when called with args {args} and kwargs {kwargs}"
f" (exit code: {_exitcode})"
) from None
@@ -139,9 +96,7 @@ def spawn_new_process_for_each_test(f: Callable[_P, None]) -> Callable[_P, None]
@functools.wraps(f)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
- # Check if we're already in a subprocess
if os.environ.get("RUNNING_IN_SUBPROCESS") == "1":
- # If we are, just run the function directly
return f(*args, **kwargs)
import torch.multiprocessing as mp
@@ -149,33 +104,18 @@ def spawn_new_process_for_each_test(f: Callable[_P, None]) -> Callable[_P, None]
with suppress(RuntimeError):
mp.set_start_method("spawn")
- # Get the module
module_name = f.__module__
-
- # Create a process with environment variable set
env = os.environ.copy()
env["RUNNING_IN_SUBPROCESS"] = "1"
with tempfile.TemporaryDirectory() as tempdir:
output_filepath = os.path.join(tempdir, "new_process.tmp")
-
- # `cloudpickle` allows pickling complex functions directly
input_bytes = cloudpickle.dumps((f, output_filepath))
-
- repo_root = str(VLLM_PATH.resolve())
-
- env = dict(env or os.environ)
- env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "")
-
cmd = [sys.executable, "-m", f"{module_name}"]
-
returned = subprocess.run(cmd, input=input_bytes, capture_output=True, env=env)
-
- # check if the subprocess is successful
try:
returned.check_returncode()
except Exception as e:
- # wrap raised exception to provide more information
raise RuntimeError(f"Error raised in subprocess:\n{returned.stderr.decode()}") from e
return wrapper
@@ -184,27 +124,11 @@ def spawn_new_process_for_each_test(f: Callable[_P, None]) -> Callable[_P, None]
def create_new_process_for_each_test(
method: Literal["spawn", "fork"] | None = None,
) -> Callable[[Callable[_P, None]], Callable[_P, None]]:
- """Creates a decorator that runs each test function in a new process.
-
- Args:
- method: The process creation method. Can be either "spawn" or "fork".
- If not specified, it defaults to "spawn" on ROCm and XPU
- platforms and "fork" otherwise.
-
- Returns:
- A decorator to run test functions in separate processes.
- """
+ """Creates a decorator that runs each test function in a new process."""
if method is None:
- # TODO: Find out why spawn is not working correctly on ROCm
- # The test content will not run and tests passed immediately.
- # For now, using `fork` for ROCm as it can run with `fork`
- # and tests are running correctly.
use_spawn = current_platform.is_xpu()
method = "spawn" if use_spawn else "fork"
-
assert method in ["spawn", "fork"], "Method must be either 'spawn' or 'fork'"
-
if method == "fork":
return fork_new_process_for_each_test
-
return spawn_new_process_for_each_test
diff --git a/tests/helpers/runtime.py b/tests/helpers/runtime.py
new file mode 100644
index 000000000..9520aff0c
--- /dev/null
+++ b/tests/helpers/runtime.py
@@ -0,0 +1,1413 @@
+"""Server/client/runner runtime primitives for tests."""
+
+import base64
+import concurrent.futures
+import io
+import json
+import os
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+from dataclasses import dataclass
+from io import BytesIO
+from pathlib import Path
+from typing import Any, NamedTuple
+
+import psutil
+import requests
+import soundfile as sf
+import torch
+import yaml
+from openai import OpenAI, omit
+from PIL import Image
+from vllm import TextPrompt
+from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
+from vllm.logger import init_logger
+
+from tests.helpers.assertions import (
+ assert_audio_speech_response,
+ assert_diffusion_response,
+ assert_omni_response,
+)
+from tests.helpers.env import run_forced_gpu_cleanup_round
+from tests.helpers.media import (
+ _merge_base64_audio_to_segment,
+ convert_audio_bytes_to_text,
+ cosine_similarity_text,
+ decode_b64_image,
+)
+from vllm_omni.config.stage_config import resolve_deploy_yaml
+from vllm_omni.platforms import current_omni_platform
+
+logger = init_logger(__name__)
+
+PromptAudioInput = list[tuple[Any, int]] | tuple[Any, int] | None
+PromptImageInput = list[Any] | Any | None
+PromptVideoInput = list[Any] | Any | None
+
+try:
+ from vllm.distributed.parallel_state import cleanup_dist_env_and_memory # type: ignore
+except Exception: # pragma: no cover
+
+ def cleanup_dist_env_and_memory() -> None:
+ return None
+
+
+def get_open_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return int(s.getsockname()[1])
+
+
+def dummy_messages_from_mix_data(
+ system_prompt: dict[str, Any] = None,
+ video_data_url: Any = None,
+ audio_data_url: Any = None,
+ image_data_url: Any = None,
+ content_text: str = None,
+):
+ """Create messages with video、image、audio data URL for OpenAI API."""
+ if content_text is not None:
+ content = [{"type": "text", "text": content_text}]
+ else:
+ content = []
+
+ media_items = []
+ if isinstance(video_data_url, list):
+ for video_url in video_data_url:
+ media_items.append((video_url, "video"))
+ else:
+ media_items.append((video_data_url, "video"))
+
+ if isinstance(image_data_url, list):
+ for url in image_data_url:
+ media_items.append((url, "image"))
+ else:
+ media_items.append((image_data_url, "image"))
+
+ if isinstance(audio_data_url, list):
+ for url in audio_data_url:
+ media_items.append((url, "audio"))
+ else:
+ media_items.append((audio_data_url, "audio"))
+
+ content.extend(
+ {"type": f"{media_type}_url", f"{media_type}_url": {"url": url}}
+ for url, media_type in media_items
+ if url is not None
+ )
+ messages = [{"role": "user", "content": content}]
+ if system_prompt is not None:
+ messages = [system_prompt] + messages
+ return messages
+
+
+def _omni_subprocess_cwd() -> str:
+ """Repo root for ``python -m vllm_omni...`` (legacy conftest lived under ``tests/``; helpers under ``tests/helpers/``)."""
+ return os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
+
+
+class OmniServerParams(NamedTuple):
+ model: str
+ port: int | None = None
+ stage_config_path: str | None = None
+ server_args: list[str] | None = None
+ env_dict: dict[str, str] | None = None
+ use_omni: bool = True
+ use_stage_cli: bool = False
+ init_timeout: int | None = None
+ stage_init_timeout: int | None = None # None: fixture supplies default (600 s)
+
+
+class OmniServer:
+ """Omniserver for vLLM-Omni tests."""
+
+ def __init__(
+ self,
+ model: str,
+ serve_args: list[str],
+ *,
+ port: int | None = None,
+ env_dict: dict[str, str] | None = None,
+ use_omni: bool = True,
+ ) -> None:
+ run_forced_gpu_cleanup_round()
+ cleanup_dist_env_and_memory()
+ self.model = model
+ self.serve_args = serve_args
+ self.env_dict = env_dict
+ self.use_omni = use_omni
+ self.proc: subprocess.Popen | None = None
+ self.host = "127.0.0.1"
+ self.port = get_open_port() if port is None else port
+
+ def _start_server(self) -> None:
+ env = os.environ.copy()
+ env.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
+ if self.env_dict is not None:
+ env.update(self.env_dict)
+
+ cmd = [
+ sys.executable,
+ "-m",
+ "vllm_omni.entrypoints.cli.main",
+ "serve",
+ self.model,
+ "--host",
+ self.host,
+ "--port",
+ str(self.port),
+ ]
+ if self.use_omni:
+ cmd.append("--omni")
+ cmd += self.serve_args
+
+ print(f"Launching OmniServer with: {' '.join(cmd)}")
+ self.proc = subprocess.Popen(
+ cmd,
+ env=env,
+ cwd=_omni_subprocess_cwd(),
+ )
+
+ max_wait = 1200
+ start_time = time.time()
+ while time.time() - start_time < max_wait:
+ ret = self.proc.poll()
+ if ret is not None:
+ raise RuntimeError(f"Server processes exited with code {ret} before becoming ready.")
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.settimeout(1)
+ if sock.connect_ex((self.host, self.port)) == 0:
+ print(f"Server ready on {self.host}:{self.port}")
+ return
+ time.sleep(2)
+ raise RuntimeError(f"Server failed to start within {max_wait} seconds")
+
+ def _kill_process_tree(self, pid):
+ 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
+
+ try:
+ parent.terminate()
+ parent.wait(timeout=10)
+ except (psutil.NoSuchProcess, psutil.TimeoutExpired):
+ try:
+ parent.kill()
+ except psutil.NoSuchProcess:
+ pass
+
+ time.sleep(1)
+ alive_processes = []
+ for check_pid in all_pids:
+ if psutil.pid_exists(check_pid):
+ alive_processes.append(check_pid)
+
+ if alive_processes:
+ print(f"Warning: Processes still alive: {alive_processes}")
+ for alive_pid in alive_processes:
+ try:
+ subprocess.run(["kill", "-9", str(alive_pid)], timeout=2)
+ except Exception as e:
+ print(f"Cleanup failed: {e}")
+
+ except psutil.NoSuchProcess:
+ pass
+
+ def __enter__(self):
+ self._start_server()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ if self.proc:
+ self._kill_process_tree(self.proc.pid)
+ run_forced_gpu_cleanup_round()
+ cleanup_dist_env_and_memory()
+
+
+class OmniServerStageCli(OmniServer):
+ """Omni server harness that exercises the stage CLI flow."""
+
+ def __init__(
+ self,
+ model: str,
+ stage_config_path: str,
+ serve_args: list[str] | None = None,
+ *,
+ stage_ids: list[int] | None = None,
+ port: int | None = None,
+ env_dict: dict[str, str] | None = None,
+ ) -> None:
+ super().__init__(model, serve_args or [], port=port, env_dict=env_dict, use_omni=True)
+ self.stage_config_path = stage_config_path
+ self.master_port = get_open_port()
+ self.visible_device_list = self._load_visible_device_list(env_dict)
+ resolved_cfg = resolve_deploy_yaml(stage_config_path)
+ # Dump the resolved deploy config so CI logs show each stage's
+ # gpu_memory_utilization / max_model_len / max_num_seqs after
+ # base_config inheritance and overlay merge — essential when
+ # diagnosing OOMs that depend on the merged values.
+ print(
+ f"[OmniServerStageCli] Resolved deploy config from {stage_config_path}:\n"
+ f"{yaml.safe_dump(resolved_cfg, sort_keys=False, default_flow_style=False)}",
+ flush=True,
+ )
+ self.stage_runtime_devices = self._load_stage_runtime_devices(resolved_cfg)
+ self.stage_ids = stage_ids or self._load_stage_ids(resolved_cfg)
+ if 0 not in self.stage_ids:
+ raise ValueError(f"Stage CLI test requires stage_id=0 in config: {stage_config_path}")
+ self.stage_procs: dict[int, subprocess.Popen] = {}
+ self.proc = None
+
+ @staticmethod
+ def _stage_entries(cfg: dict) -> list[dict]:
+ """Return the list of stage entries from either legacy (``stage_args``)
+ or new-schema (``stages``) deploy YAMLs."""
+ return cfg.get("stage_args") or cfg.get("stages") or []
+
+ @staticmethod
+ def _load_stage_ids(resolved_config: dict) -> list[int]:
+ stage_ids = [
+ stage["stage_id"] for stage in OmniServerStageCli._stage_entries(resolved_config) if "stage_id" in stage
+ ]
+ if not stage_ids:
+ raise ValueError("No stage IDs found in resolved config")
+ return stage_ids
+
+ @staticmethod
+ def _load_stage_runtime_devices(resolved_config: dict) -> dict[int, str]:
+ runtime_devices: dict[int, str] = {}
+ for stage in OmniServerStageCli._stage_entries(resolved_config):
+ stage_id = stage.get("stage_id")
+ # New schema: stage.devices is flat at stage level.
+ # Legacy schema: stage.runtime.devices is nested.
+ devices = stage.get("devices") or stage.get("runtime", {}).get("devices")
+ if stage_id is not None and devices:
+ runtime_devices[int(stage_id)] = str(devices)
+ return runtime_devices
+
+ @classmethod
+ def _parse_device_list(cls, devices: str | int) -> list[str]:
+ if isinstance(devices, int):
+ if devices < 0:
+ raise ValueError("Device IDs must be non-negative integers")
+ return [str(devices)]
+ return [token.strip() for token in str(devices).split(",") if token.strip()]
+
+ @classmethod
+ def _load_visible_device_list(cls, env_dict: dict[str, str] | None) -> list[str] | None:
+ env = os.environ.copy()
+ if env_dict is not None:
+ env.update(env_dict)
+
+ env_var = getattr(current_omni_platform, "device_control_env_var", None)
+ if env_var and env_var in env:
+ return [token.strip() for token in env[env_var].split(",") if token.strip()]
+ return None
+
+ @classmethod
+ def _map_stage_devices(cls, stage_id: int, visible_device_list: list[str] | None, devices: str) -> str:
+ device_list = cls._parse_device_list(devices)
+
+ if visible_device_list is None:
+ return ",".join(device_list)
+
+ if not all(device.isdigit() for device in device_list):
+ raise ValueError("Logical devices must be non-negative integers")
+
+ logical_ids = [int(device) for device in device_list]
+ if logical_ids and max(logical_ids) >= len(visible_device_list):
+ raise ValueError(
+ f"Stage {stage_id} has logical IDs {device_list}, one or more of which exceed the number of visible devices"
+ )
+
+ return ",".join(visible_device_list[idx] for idx in logical_ids)
+
+ def _set_stage_device_env(self, stage_id: int, env: dict[str, str], devices: str) -> None:
+ mapped_devices = self._map_stage_devices(stage_id, self.visible_device_list, devices)
+ env_var = getattr(current_omni_platform, "device_control_env_var", None)
+ if env_var:
+ env[env_var] = mapped_devices
+
+ def _build_stage_cmd(self, stage_id: int, *, headless: bool) -> list[str]:
+ cmd = [
+ sys.executable,
+ "-m",
+ "vllm_omni.entrypoints.cli.main",
+ "serve",
+ self.model,
+ "--omni",
+ "--stage-configs-path",
+ self.stage_config_path,
+ "--stage-id",
+ str(stage_id),
+ "--omni-master-address",
+ self.host,
+ "--omni-master-port",
+ str(self.master_port),
+ ]
+
+ if headless:
+ cmd.append("--headless")
+ else:
+ cmd += ["--host", self.host, "--port", str(self.port)]
+
+ cmd += self.serve_args
+ return cmd
+
+ def _launch_stage(self, stage_id: int, *, headless: bool) -> None:
+ env = os.environ.copy()
+ env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
+ 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)
+
+ cmd = self._build_stage_cmd(stage_id, headless=headless)
+ print(f"Launching OmniServerStageCli stage {stage_id}: {' '.join(cmd)}")
+ # Capture each subprocess's stdout+stderr to a per-stage log file so
+ # debugging "Stage N exited before API server ready" doesn't rely on
+ # guessing; the file is surfaced in the RuntimeError message.
+ log_path = Path(tempfile.gettempdir()) / f"omni_stage_{stage_id}_{self.master_port}.log"
+ self._stage_log_paths = getattr(self, "_stage_log_paths", {})
+ self._stage_log_paths[stage_id] = log_path
+ log_fh = open(log_path, "w", buffering=1) # noqa: SIM115 - closed in __exit__
+ self._stage_log_files = getattr(self, "_stage_log_files", {})
+ self._stage_log_files[stage_id] = log_fh
+ proc = subprocess.Popen(
+ cmd,
+ env=env,
+ cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ stdout=log_fh,
+ stderr=subprocess.STDOUT,
+ )
+ self.stage_procs[stage_id] = proc
+ if stage_id == 0:
+ self.proc = proc
+
+ def _ensure_stage_processes_alive(self) -> None:
+ for stage_id, proc in self.stage_procs.items():
+ ret = proc.poll()
+ if ret is not None:
+ log_path = getattr(self, "_stage_log_paths", {}).get(stage_id)
+ tail = ""
+ if log_path and log_path.exists():
+ try:
+ with open(log_path, encoding="utf-8", errors="replace") as f:
+ lines = f.readlines()
+ tail = "\n=== Last 60 lines of stage {} log ({}) ===\n{}".format(
+ stage_id, log_path, "".join(lines[-60:]) or ""
+ )
+ except Exception as exc: # pragma: no cover - diagnostic only
+ tail = f"\n"
+ raise RuntimeError(f"Stage {stage_id} exited with code {ret} before API server became ready.{tail}")
+
+ def _start_server(self) -> None:
+ ordered_stage_ids = [0, *[stage_id for stage_id in self.stage_ids if stage_id != 0]]
+
+ self._launch_stage(0, headless=False)
+ time.sleep(2)
+ self._ensure_stage_processes_alive()
+
+ for stage_id in ordered_stage_ids[1:]:
+ self._launch_stage(stage_id, headless=True)
+
+ max_wait = 1200
+ start_time = time.time()
+ while time.time() - start_time < max_wait:
+ self._ensure_stage_processes_alive()
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.settimeout(1)
+ result = sock.connect_ex((self.host, self.port))
+ if result == 0:
+ print(f"OmniServerStageCli ready on {self.host}:{self.port}")
+ return
+ time.sleep(2)
+
+ raise RuntimeError(f"OmniServerStageCli failed to start within {max_wait} seconds")
+
+ def _dump_stage_logs_for_debug(self, head_lines: int = 300, tail_lines: int = 500) -> None:
+ """Tail each stage's subprocess log back to stdout on teardown.
+
+ Stage subprocesses redirect stdout/stderr to ``/tmp/omni_stage_*.log``
+ so we don't spam the main CI stream while tests run; but that also
+ hides engine init (KV cache size, Available KV cache memory, vLLM
+ engine config) when things go wrong. Dump them here so buildkite
+ captures them post-run. Head covers engine init; tail covers
+ whatever state the stage was in when it was torn down.
+ """
+ log_paths = getattr(self, "_stage_log_paths", {}) or {}
+ for stage_id in sorted(log_paths):
+ log_path = log_paths[stage_id]
+ if not log_path or not log_path.exists():
+ continue
+ try:
+ with open(log_path, encoding="utf-8", errors="replace") as f:
+ lines = f.readlines()
+ except Exception as exc: # pragma: no cover - diagnostic only
+ print(f"[OmniServerStageCli] stage {stage_id} log read failed: {exc}", flush=True)
+ continue
+ total = len(lines)
+ if total <= head_lines + tail_lines:
+ head_chunk = lines
+ tail_chunk = []
+ elided = 0
+ else:
+ head_chunk = lines[:head_lines]
+ tail_chunk = lines[-tail_lines:]
+ elided = total - head_lines - tail_lines
+ print(f"\n=== stage {stage_id} log HEAD ({log_path}) ===", flush=True)
+ print("".join(head_chunk).rstrip("\n"), flush=True)
+ if tail_chunk:
+ print(f"\n... [{elided} lines elided] ...", flush=True)
+ print(f"\n=== stage {stage_id} log TAIL ({log_path}) ===", flush=True)
+ print("".join(tail_chunk).rstrip("\n"), flush=True)
+ print(f"=== end stage {stage_id} log ===\n", flush=True)
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self._dump_stage_logs_for_debug()
+ for stage_id in sorted(self.stage_procs, reverse=True):
+ proc = self.stage_procs[stage_id]
+ if proc.poll() is None:
+ self._kill_process_tree(proc.pid)
+ run_forced_gpu_cleanup_round()
+ cleanup_dist_env_and_memory()
+
+
+@dataclass
+class OmniResponse:
+ text_content: str | None = None
+ audio_data: list[str] | None = None
+ audio_content: str | None = None
+ audio_format: str | None = None
+ audio_bytes: bytes | None = None
+ similarity: float | None = None
+ e2e_latency: float | None = None
+ success: bool = False
+ error_message: str | None = None
+ cached_tokens: int | None = None
+
+
+@dataclass
+class DiffusionResponse:
+ text_content: str | None = None
+ images: list[Image.Image] | None = None
+ audios: list[Any] | None = None
+ videos: list[Any] | None = None
+ e2e_latency: float | None = None
+ success: bool = False
+ error_message: str | None = None
+
+
+class OpenAIClientHandler:
+ def __init__(self, host: str = "127.0.0.1", port: int = None, api_key: str = "EMPTY", run_level: str = None):
+ if port is None:
+ port = get_open_port()
+ self.base_url = f"http://{host}:{port}"
+ self.client = OpenAI(base_url=f"http://{host}:{port}/v1", api_key=api_key)
+ self.run_level = run_level
+
+ def _process_stream_omni_response(self, chat_completion) -> OmniResponse:
+ result = OmniResponse()
+ start_time = time.perf_counter()
+ try:
+ text_content = ""
+ audio_data = []
+ for chunk in chat_completion:
+ for choice in chunk.choices:
+ content = getattr(getattr(choice, "delta", None), "content", None)
+ modality = getattr(chunk, "modality", None)
+ if modality == "audio" and content:
+ audio_data.append(content)
+ elif modality == "text" and content:
+ text_content += content
+ result.e2e_latency = time.perf_counter() - start_time
+ audio_content = None
+ similarity = None
+ if audio_data:
+ merged_seg = _merge_base64_audio_to_segment(audio_data)
+ wav_buf = BytesIO()
+ merged_seg.export(wav_buf, format="wav")
+ result.audio_bytes = wav_buf.getvalue()
+ audio_content = convert_audio_bytes_to_text(result.audio_bytes)
+ if audio_content and text_content:
+ similarity = cosine_similarity_text(audio_content.lower(), text_content.lower())
+ result.text_content = text_content
+ result.audio_data = audio_data
+ result.audio_content = audio_content
+ result.similarity = similarity
+ result.success = True
+ except Exception as e:
+ result.error_message = f"Stream processing error: {str(e)}"
+ print(f"Error: {result.error_message}")
+ return result
+
+ def _process_non_stream_omni_response(self, chat_completion) -> OmniResponse:
+ result = OmniResponse()
+ start_time = time.perf_counter()
+ try:
+ audio_data = None
+ text_content = None
+ for choice in chat_completion.choices:
+ if hasattr(choice.message, "audio") and choice.message.audio is not None:
+ audio_data = choice.message.audio.data
+ if hasattr(choice.message, "content") and choice.message.content is not None:
+ text_content = choice.message.content
+ # Extract cached_tokens for prefix caching tests
+ usage = getattr(chat_completion, "usage", None)
+ if usage and (details := getattr(usage, "prompt_tokens_details", None)):
+ result.cached_tokens = details.cached_tokens
+ result.e2e_latency = time.perf_counter() - start_time
+ audio_content = None
+ similarity = None
+ if audio_data:
+ result.audio_bytes = base64.b64decode(audio_data)
+ audio_content = convert_audio_bytes_to_text(result.audio_bytes)
+ if audio_content and text_content:
+ similarity = cosine_similarity_text(audio_content.lower(), text_content.lower())
+ result.text_content = text_content
+ result.audio_content = audio_content
+ result.similarity = similarity
+ result.success = True
+ except Exception as e:
+ result.error_message = f"Non-stream processing error: {str(e)}"
+ print(f"Error: {result.error_message}")
+ return result
+
+ def _process_diffusion_response(self, chat_completion) -> DiffusionResponse:
+ result = DiffusionResponse()
+ start_time = time.perf_counter()
+ try:
+ images = []
+ for choice in chat_completion.choices:
+ content = getattr(choice.message, "content", None)
+ if isinstance(content, list):
+ for item in content:
+ image_url = None
+ if isinstance(item, dict):
+ image_url = item.get("image_url", {}).get("url")
+ else:
+ image_url_obj = getattr(item, "image_url", None)
+ image_url = getattr(image_url_obj, "url", None) if image_url_obj else None
+ if image_url and image_url.startswith("data:image"):
+ b64_data = image_url.split(",", 1)[1]
+ images.append(decode_b64_image(b64_data))
+ result.e2e_latency = time.perf_counter() - start_time
+ result.images = images if images else None
+ result.success = True
+ except Exception as e:
+ result.error_message = f"Diffusion response processing error: {str(e)}"
+ print(f"Error: {result.error_message}")
+ return result
+
+ def send_omni_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[OmniResponse]:
+ responses: list[OmniResponse] = []
+ stream = request_config.get("stream", False)
+ modalities = request_config.get("modalities", ["text", "audio"])
+ extra_body: dict[str, Any] = {}
+ if "speaker" in request_config:
+ extra_body["speaker"] = request_config["speaker"]
+ if request_config.get("use_audio_in_video"):
+ mm = dict(extra_body.get("mm_processor_kwargs") or {})
+ mm["use_audio_in_video"] = True
+ extra_body["mm_processor_kwargs"] = mm
+ create_kwargs: dict[str, Any] = {
+ "model": request_config.get("model"),
+ "messages": request_config.get("messages"),
+ "stream": stream,
+ "modalities": modalities,
+ }
+ if extra_body:
+ create_kwargs["extra_body"] = extra_body
+
+ if request_num == 1:
+ chat_completion = self.client.chat.completions.create(**create_kwargs)
+ resp = (
+ self._process_stream_omni_response(chat_completion)
+ if stream
+ else self._process_non_stream_omni_response(chat_completion)
+ )
+ assert_omni_response(resp, request_config, run_level=self.run_level)
+ responses.append(resp)
+ return responses
+
+ def _one():
+ chat_completion = self.client.chat.completions.create(**create_kwargs)
+ return (
+ self._process_stream_omni_response(chat_completion)
+ if stream
+ else self._process_non_stream_omni_response(chat_completion)
+ )
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=request_num) as executor:
+ futures = [executor.submit(_one) for _ in range(request_num)]
+ for future in concurrent.futures.as_completed(futures):
+ resp = future.result()
+ assert_omni_response(resp, request_config, run_level=self.run_level)
+ responses.append(resp)
+ return responses
+
+ def _process_stream_audio_speech_response(self, response, *, response_format: str | None = None) -> OmniResponse:
+ """
+ Process streaming /v1/audio/speech responses into an OmniResponse.
+
+ This mirrors _process_stream_omni_response but operates on low-level
+ audio bytes and produces an OmniResponse with audio_content filled
+ from Whisper transcription.
+ """
+ result = OmniResponse()
+ start_time = time.perf_counter()
+
+ try:
+ # Aggregate all audio bytes from the streaming response.
+ data = bytearray()
+
+ # Preferred OpenAI helper.
+ if hasattr(response, "iter_bytes") and callable(getattr(response, "iter_bytes")):
+ for chunk in response.iter_bytes():
+ if chunk:
+ data.extend(chunk)
+ else:
+ # Generic iterable-of-bytes fallback (e.g., generator or list of chunks).
+ try:
+ iterator = iter(response)
+ except TypeError:
+ iterator = None
+
+ if iterator is not None:
+ for chunk in iterator:
+ if not chunk:
+ continue
+ if isinstance(chunk, (bytes, bytearray)):
+ data.extend(chunk)
+ elif hasattr(chunk, "data"):
+ data.extend(chunk.data) # type: ignore[arg-type]
+ elif hasattr(chunk, "content"):
+ data.extend(chunk.content) # type: ignore[arg-type]
+ else:
+ raise TypeError(f"Unsupported stream chunk type: {type(chunk)}")
+ else:
+ raise TypeError(f"Unsupported audio speech streaming response type: {type(response)}")
+
+ raw_bytes = bytes(data)
+ if response_format == "pcm":
+ transcript = None
+ else:
+ transcript = convert_audio_bytes_to_text(raw_bytes)
+
+ # Populate OmniResponse.
+ result.audio_bytes = raw_bytes
+ result.audio_content = transcript
+ result.e2e_latency = time.perf_counter() - start_time
+ result.success = True
+ result.audio_format = getattr(response, "response", None)
+ if result.audio_format is not None:
+ result.audio_format = result.audio_format.headers.get("content-type", "")
+
+ except Exception as e:
+ result.error_message = f"Audio speech stream processing error: {str(e)}"
+ print(f"Error: {result.error_message}")
+
+ return result
+
+ def _process_non_stream_audio_speech_response(
+ self, response, *, response_format: str | None = None
+ ) -> OmniResponse:
+ """
+ Process non-streaming /v1/audio/speech responses into an OmniResponse.
+
+ This mirrors _process_non_stream_omni_response but for the binary
+ audio payload returned by audio.speech.create.
+ """
+ result = OmniResponse()
+ start_time = time.perf_counter()
+
+ try:
+ # OpenAI non-streaming audio.speech.create returns HttpxBinaryResponseContent (.read() or .content)
+ if hasattr(response, "read") and callable(getattr(response, "read")):
+ raw_bytes = response.read()
+ elif hasattr(response, "content"):
+ raw_bytes = response.content # type: ignore[assignment]
+ else:
+ raise TypeError(f"Unsupported audio speech response type: {type(response)}")
+
+ if response_format == "pcm":
+ transcript = None
+ else:
+ transcript = convert_audio_bytes_to_text(raw_bytes)
+
+ result.audio_bytes = raw_bytes
+ result.audio_content = transcript
+ result.e2e_latency = time.perf_counter() - start_time
+ result.success = True
+ result.audio_format = getattr(response, "response", None)
+ if result.audio_format is not None:
+ result.audio_format = result.audio_format.headers.get("content-type", "")
+
+ except Exception as e:
+ result.error_message = f"Audio speech non-stream processing error: {str(e)}"
+ print(f"Error: {result.error_message}")
+
+ return result
+
+ def send_audio_speech_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[OmniResponse]:
+ """
+ Call the /v1/audio/speech endpoint using the same configuration-dict
+ style as send_omni_request, but via the OpenAI Python client's
+ audio.speech APIs.
+
+ Expected keys in request_config:
+ - model: model name/path (required)
+ - input: text to synthesize (required)
+ - response_format: audio format such as "wav" or "pcm" (optional)
+ - task_type, ref_text, ref_audio: TTS-specific extras (optional, passed via extra_body)
+ - timeout: request timeout in seconds (float, optional, default 120.0)
+ - stream: whether to use streaming API (bool, optional, default False)
+ """
+ timeout = float(request_config.get("timeout", 120.0))
+
+ model = request_config["model"]
+ text_input = request_config["input"]
+ stream = bool(request_config.get("stream", False))
+ voice = request_config.get("voice", None)
+
+ # Standard OpenAI param: use omit when not provided to keep default behavior.
+ response_format = request_config.get("response_format", omit)
+
+ # 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"):
+ if key in request_config:
+ extra_body[key] = request_config[key]
+
+ responses: list[OmniResponse] = []
+
+ speech_fmt: str | None = None if response_format is omit else str(response_format).lower()
+
+ if request_num == 1:
+ if stream:
+ # Use streaming response helper.
+ with self.client.audio.speech.with_streaming_response.create(
+ model=model,
+ input=text_input,
+ response_format=response_format,
+ extra_body=extra_body or None,
+ timeout=timeout,
+ voice=voice,
+ ) as resp:
+ omni_resp = self._process_stream_audio_speech_response(resp, response_format=speech_fmt)
+ else:
+ # Non-streaming response.
+ resp = self.client.audio.speech.create(
+ model=model,
+ input=text_input,
+ response_format=response_format,
+ extra_body=extra_body or None,
+ timeout=timeout,
+ voice=voice,
+ )
+ omni_resp = self._process_non_stream_audio_speech_response(resp, response_format=speech_fmt)
+
+ assert_audio_speech_response(omni_resp, request_config, run_level=self.run_level)
+ responses.append(omni_resp)
+ return responses
+ else:
+ # request_num > 1: concurrent requests (use same params as single-request path)
+
+ if stream:
+
+ def _stream_task():
+ with self.client.audio.speech.with_streaming_response.create(
+ model=model,
+ input=text_input,
+ response_format=response_format,
+ extra_body=extra_body or None,
+ timeout=timeout,
+ voice=voice,
+ ) as resp:
+ return self._process_stream_audio_speech_response(resp, response_format=speech_fmt)
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=request_num) as executor:
+ futures = [executor.submit(_stream_task) for _ in range(request_num)]
+ for future in concurrent.futures.as_completed(futures):
+ omni_resp = future.result()
+ assert_audio_speech_response(omni_resp, request_config, run_level=self.run_level)
+ responses.append(omni_resp)
+ else:
+ with concurrent.futures.ThreadPoolExecutor(max_workers=request_num) as executor:
+ futures = []
+ for _ in range(request_num):
+ future = executor.submit(
+ self.client.audio.speech.create,
+ model=model,
+ input=text_input,
+ response_format=response_format,
+ extra_body=extra_body or None,
+ timeout=timeout,
+ voice=voice,
+ )
+ futures.append(future)
+
+ for future in concurrent.futures.as_completed(futures):
+ resp = future.result()
+ omni_resp = self._process_non_stream_audio_speech_response(resp, response_format=speech_fmt)
+ assert_audio_speech_response(omni_resp, request_config, run_level=self.run_level)
+ responses.append(omni_resp)
+
+ return responses
+
+ def send_diffusion_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[DiffusionResponse]:
+ """
+ Send OpenAI requests for diffusion models.
+ Args:
+ request_config: Request configuration dictionary containing parameters like model, messages
+ request_num: Number of requests to send concurrently, defaults to 1 (single request)
+ Returns:
+ list[DiffusionResponse]: List of DiffusionResponse objects containing the response data
+ """
+ responses: list[DiffusionResponse] = []
+ stream = request_config.get("stream", False)
+ modalities = request_config.get("modalities", omit) # Most diffusion models don't require modalities param
+ extra_body = request_config.get("extra_body", None)
+ if stream:
+ raise NotImplementedError("Streaming is not currently implemented for diffusion model e2e test")
+ if request_num == 1:
+ # Send single request
+ chat_completion = self.client.chat.completions.create(
+ model=request_config.get("model"),
+ messages=request_config.get("messages"),
+ extra_body=extra_body,
+ modalities=modalities,
+ )
+ response = self._process_diffusion_response(chat_completion)
+ assert_diffusion_response(response, request_config, run_level=self.run_level)
+ responses.append(response)
+ else:
+ # Send concurrent requests
+ with concurrent.futures.ThreadPoolExecutor(max_workers=request_num) as executor:
+ futures = []
+ # Submit all request tasks
+ for _ in range(request_num):
+ future = executor.submit(
+ self.client.chat.completions.create,
+ model=request_config.get("model"),
+ messages=request_config.get("messages"),
+ modalities=modalities,
+ extra_body=extra_body,
+ )
+ futures.append(future)
+ # Process completed tasks
+ for future in concurrent.futures.as_completed(futures):
+ chat_completion = future.result()
+ response = self._process_diffusion_response(chat_completion)
+ assert_diffusion_response(response, request_config, run_level=self.run_level)
+ responses.append(response)
+ return responses
+
+ def send_video_diffusion_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[OmniResponse]:
+ """
+ Send native /v1/videos requests.
+ """
+ if request_num != 1:
+ raise NotImplementedError("Concurrent video diffusion requests are not currently implemented")
+ form_data = request_config.get("form_data")
+ if not isinstance(form_data, dict):
+ raise ValueError("Video request_config must contain 'form_data'")
+ normalized_form_data = {key: str(value) for key, value in form_data.items() if value is not None}
+ files: dict[str, tuple[str, BytesIO, str]] = {}
+ image_reference = request_config.get("image_reference")
+ if image_reference:
+ if image_reference.startswith("data:image"):
+ header, encoded = image_reference.split(",", 1)
+ content_type = header.split(";")[0].removeprefix("data:")
+ extension = content_type.split("/")[-1]
+ file_data = base64.b64decode(encoded)
+ files["input_reference"] = (f"reference.{extension}", BytesIO(file_data), content_type)
+ else:
+ normalized_form_data["image_reference"] = json.dumps({"image_url": image_reference})
+
+ result = DiffusionResponse()
+ start_time = time.perf_counter()
+ create_url = self._build_url("/v1/videos")
+ response = requests.post(
+ create_url,
+ data=normalized_form_data,
+ files=files,
+ headers={"Accept": "application/json"},
+ timeout=60,
+ )
+ response.raise_for_status()
+ job_data = response.json()
+ video_id = job_data["id"]
+ self._wait_until_video_completed(video_id)
+ video_content = self._download_video_content(video_id)
+ result.success = True
+ result.videos = [video_content]
+ result.e2e_latency = time.perf_counter() - start_time
+ assert_diffusion_response(result, request_config, run_level=self.run_level)
+ return [result]
+
+ def _wait_until_video_completed(
+ self, video_id: str, poll_interval_seconds: int = 2, timeout_seconds: int = 300
+ ) -> None:
+ status_url = self._build_url(f"/v1/videos/{video_id}")
+ deadline = time.monotonic() + timeout_seconds
+ while time.monotonic() < deadline:
+ status_resp = requests.get(status_url, headers={"Accept": "application/json"}, timeout=30)
+ status_resp.raise_for_status()
+ status_data = status_resp.json()
+ current_status = status_data["status"]
+ if current_status == "completed":
+ return
+ if current_status == "failed":
+ error_msg = status_data.get("last_error", "Unknown error")
+ raise RuntimeError(f"Job failed: {error_msg}")
+ time.sleep(poll_interval_seconds)
+ raise TimeoutError(f"Video job {video_id} did not complete within {timeout_seconds}s")
+
+ def _download_video_content(self, video_id: str) -> bytes:
+ download_url = self._build_url(f"/v1/videos/{video_id}/content")
+ video_resp = requests.get(download_url, stream=True, timeout=60)
+ video_resp.raise_for_status()
+ video_bytes = BytesIO()
+ for chunk in video_resp.iter_content(chunk_size=8192):
+ if chunk:
+ video_bytes.write(chunk)
+ return video_bytes.getvalue()
+
+ def _build_url(self, path: str) -> str:
+ return f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
+
+
+class OmniRunner:
+ def __init__(
+ self,
+ model_name: str,
+ seed: int = 42,
+ stage_init_timeout: int = 600,
+ batch_timeout: int = 10,
+ init_timeout: int = 900,
+ shm_threshold_bytes: int = 65536,
+ log_stats: bool = False,
+ stage_configs_path: str | None = None,
+ **kwargs,
+ ) -> None:
+ cleanup_dist_env_and_memory()
+ run_forced_gpu_cleanup_round()
+ self.model_name = model_name
+ self.seed = seed
+ self._prompt_len_estimate_cache: dict[str, Any] = {}
+ from vllm_omni.entrypoints.omni import Omni
+
+ self.omni = Omni(
+ model=model_name,
+ log_stats=log_stats,
+ stage_init_timeout=stage_init_timeout,
+ batch_timeout=batch_timeout,
+ init_timeout=init_timeout,
+ shm_threshold_bytes=shm_threshold_bytes,
+ stage_configs_path=stage_configs_path,
+ **kwargs,
+ )
+
+ def get_default_sampling_params_list(self) -> list[Any]:
+ if not hasattr(self.omni, "default_sampling_params_list"):
+ raise AttributeError("Omni.default_sampling_params_list is not available")
+ return list(self.omni.default_sampling_params_list)
+
+ def _estimate_prompt_len(
+ self,
+ additional_information: dict[str, Any],
+ model_name: str,
+ ) -> int:
+ """Estimate prompt_token_ids placeholder length for the Talker stage.
+
+ The AR Talker replaces all input embeddings via ``preprocess``, so the
+ placeholder values are irrelevant but the **length** must match the
+ embeddings that ``preprocess`` will produce.
+ """
+ _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,
+ )
+
+ if model_name not in _cache:
+ from transformers import AutoTokenizer
+
+ tok = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True, padding_side="left")
+ cfg = Qwen3TTSConfig.from_pretrained(model_name, trust_remote_code=True)
+ _cache[model_name] = (tok, getattr(cfg, "talker_config", None))
+
+ tok, tcfg = _cache[model_name]
+ task_type = (additional_information.get("task_type") or ["CustomVoice"])[0]
+ return Qwen3TTSTalkerForConditionalGeneration.estimate_prompt_len_from_additional_information(
+ additional_information=additional_information,
+ task_type=task_type,
+ tokenize_prompt=lambda t: tok(t, padding=False)["input_ids"],
+ codec_language_id=getattr(tcfg, "codec_language_id", None),
+ spk_is_dialect=getattr(tcfg, "spk_is_dialect", None),
+ )
+ except Exception as exc:
+ logger.warning("Failed to estimate prompt length, using fallback 2048: %s", exc)
+ return 2048
+
+ def get_omni_inputs(
+ self,
+ prompts: list[str] | str,
+ system_prompt: str | None = None,
+ audios: PromptAudioInput = None,
+ images: PromptImageInput = None,
+ videos: PromptVideoInput = None,
+ mm_processor_kwargs: dict[str, Any] | None = None,
+ modalities: list[str] | None = None,
+ ) -> list[TextPrompt]:
+ if system_prompt is None:
+ system_prompt = (
+ "You are Qwen, a virtual human developed by the Qwen Team, Alibaba "
+ "Group, capable of perceiving auditory and visual inputs, as well as "
+ "generating text and speech."
+ )
+ video_padding_token = "<|VIDEO|>"
+ image_padding_token = "<|IMAGE|>"
+ audio_padding_token = "<|AUDIO|>"
+ if "Qwen3-Omni-30B-A3B-Instruct" in self.model_name:
+ video_padding_token = "<|video_pad|>"
+ image_padding_token = "<|image_pad|>"
+ audio_padding_token = "<|audio_pad|>"
+ elif "Ming-flash-omni" in self.model_name:
+ video_padding_token = ""
+ image_padding_token = ""
+ audio_padding_token = ""
+ if isinstance(prompts, str):
+ prompts = [prompts]
+
+ # Qwen-TTS: follow examples/offline_inference/qwen3_tts/end2end.py style.
+ # Stage 0 expects token placeholders + additional_information (text/speaker/task_type/...),
+ # and Talker replaces embeddings in preprocess based on additional_information only.
+ is_tts_model = "Qwen3-TTS" in self.model_name or "qwen3_tts" in self.model_name.lower()
+ if is_tts_model and modalities == ["audio"]:
+ tts_kw = mm_processor_kwargs or {}
+ task_type = tts_kw.get("task_type", "CustomVoice")
+ speaker = tts_kw.get("speaker", "Vivian")
+ language = tts_kw.get("language", "Auto")
+ max_new_tokens = int(tts_kw.get("max_new_tokens", 2048))
+ ref_audio = tts_kw.get("ref_audio", None)
+ ref_text = tts_kw.get("ref_text", None)
+
+ omni_inputs: list[TextPrompt] = []
+ for prompt_text in prompts:
+ text_str = str(prompt_text).strip() or " "
+ additional_information: dict[str, Any] = {
+ "task_type": [task_type],
+ "text": [text_str],
+ "language": [language],
+ "speaker": [speaker],
+ "max_new_tokens": [max_new_tokens],
+ }
+ if ref_audio is not None:
+ additional_information["ref_audio"] = [ref_audio]
+ if ref_text is not None:
+ additional_information["ref_text"] = [ref_text]
+ plen = self._estimate_prompt_len(additional_information, self.model_name)
+ input_dict: TextPrompt = {
+ "prompt_token_ids": [0] * plen,
+ "additional_information": additional_information,
+ }
+ omni_inputs.append(input_dict)
+ return omni_inputs
+
+ def _normalize(mm_input, num_prompts):
+ if mm_input is None:
+ return [None] * num_prompts
+ if isinstance(mm_input, list):
+ if len(mm_input) != num_prompts:
+ raise ValueError("Multimodal input list length must match prompts length")
+ return mm_input
+ return [mm_input] * num_prompts
+
+ num_prompts = len(prompts)
+ audios_list = _normalize(audios, num_prompts)
+ images_list = _normalize(images, num_prompts)
+ videos_list = _normalize(videos, num_prompts)
+
+ omni_inputs = []
+ for i, prompt_text in enumerate(prompts):
+ user_content = ""
+ multi_modal_data = {}
+ audio = audios_list[i]
+ if audio is not None:
+ if isinstance(audio, list):
+ for _ in audio:
+ user_content += f"<|audio_bos|>{audio_padding_token}<|audio_eos|>"
+ multi_modal_data["audio"] = audio
+ else:
+ user_content += f"<|audio_bos|>{audio_padding_token}<|audio_eos|>"
+ multi_modal_data["audio"] = audio
+ image = images_list[i]
+ if image is not None:
+ if isinstance(image, list):
+ for _ in image:
+ user_content += f"<|vision_bos|>{image_padding_token}<|vision_eos|>"
+ multi_modal_data["image"] = image
+ else:
+ user_content += f"<|vision_bos|>{image_padding_token}<|vision_eos|>"
+ multi_modal_data["image"] = image
+ video = videos_list[i]
+ if video is not None:
+ if isinstance(video, list):
+ for _ in video:
+ user_content += f"<|vision_bos|>{video_padding_token}<|vision_eos|>"
+ multi_modal_data["video"] = video
+ else:
+ user_content += f"<|vision_bos|>{video_padding_token}<|vision_eos|>"
+ multi_modal_data["video"] = video
+ user_content += prompt_text
+
+ full_prompt = (
+ f"<|im_start|>system\n{system_prompt}<|im_end|>\n"
+ f"<|im_start|>user\n{user_content}<|im_end|>\n"
+ f"<|im_start|>assistant\n"
+ )
+ input_dict: dict[str, Any] = {"prompt": full_prompt}
+ if multi_modal_data:
+ input_dict["multi_modal_data"] = multi_modal_data
+ if modalities:
+ input_dict["modalities"] = modalities
+ if mm_processor_kwargs:
+ input_dict["mm_processor_kwargs"] = mm_processor_kwargs
+ omni_inputs.append(input_dict)
+ return omni_inputs
+
+ def generate(
+ self,
+ prompts: list[Any],
+ sampling_params_list: list[Any] | None = None,
+ ) -> list[Any]:
+ if sampling_params_list is None:
+ sampling_params_list = self.get_default_sampling_params_list()
+ return self.omni.generate(prompts, sampling_params_list)
+
+ def generate_multimodal(
+ self,
+ prompts: list[str] | str,
+ sampling_params_list: list[Any] | None = None,
+ system_prompt: str | None = None,
+ audios: PromptAudioInput = None,
+ images: PromptImageInput = None,
+ videos: PromptVideoInput = None,
+ mm_processor_kwargs: dict[str, Any] | None = None,
+ modalities: list[str] | None = None,
+ ) -> list[Any]:
+ omni_inputs = self.get_omni_inputs(
+ prompts=prompts,
+ system_prompt=system_prompt,
+ audios=audios,
+ images=images,
+ videos=videos,
+ mm_processor_kwargs=mm_processor_kwargs,
+ modalities=modalities,
+ )
+ return self.generate(omni_inputs, sampling_params_list)
+
+ def start_profile(self, profile_prefix: str | None = None, stages: list[int] | None = None) -> list[Any]:
+ return self.omni.start_profile(profile_prefix=profile_prefix, stages=stages)
+
+ def stop_profile(self, stages: list[int] | None = None) -> list[Any]:
+ return self.omni.stop_profile(stages=stages)
+
+ def _cleanup_process(self):
+ try:
+ keywords = ["enginecore"]
+ matched = []
+ for proc in psutil.process_iter(["pid", "name", "cmdline", "username"]):
+ try:
+ cmdline = " ".join(proc.cmdline()).lower() if proc.cmdline() else ""
+ name = proc.name().lower()
+ if any(k in cmdline for k in keywords) or any(k in name for k in keywords):
+ print(f"Found vllm process: PID={proc.pid}, cmd={cmdline[:100]}")
+ matched.append(proc)
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ pass
+ for proc in matched:
+ try:
+ proc.terminate()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ pass
+ _, still_alive = psutil.wait_procs(matched, timeout=5)
+ for proc in still_alive:
+ try:
+ proc.kill()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ pass
+ if still_alive:
+ _, stubborn = psutil.wait_procs(still_alive, timeout=3)
+ if stubborn:
+ print(f"Warning: failed to kill residual vllm pids: {[p.pid for p in stubborn]}")
+ else:
+ print(f"Force-killed residual vllm pids: {[p.pid for p in still_alive]}")
+ elif matched:
+ print(f"Terminated vllm pids: {[p.pid for p in matched]}")
+ except Exception as e:
+ print(f"Error in psutil vllm cleanup: {e}")
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ if hasattr(self.omni, "close"):
+ self.omni.close()
+ self._cleanup_process()
+ run_forced_gpu_cleanup_round()
+ cleanup_dist_env_and_memory()
+
+
+class OmniRunnerHandler:
+ def __init__(self, omni_runner):
+ self.runner = omni_runner
+
+ def _process_output(self, outputs: list[Any]) -> OmniResponse:
+ result = OmniResponse()
+ try:
+ text_content = None
+ audio_content = None
+ for stage_output in outputs:
+ if getattr(stage_output, "final_output_type", None) == "text":
+ text_content = stage_output.request_output.outputs[0].text
+ if getattr(stage_output, "final_output_type", None) == "audio":
+ audio_content = stage_output.request_output.outputs[0].multimodal_output["audio"]
+ result.audio_content = audio_content
+ result.text_content = text_content
+ result.success = True
+ except Exception as e:
+ result.error_message = f"Output processing error: {str(e)}"
+ result.success = False
+ print(f"Error: {result.error_message}")
+ return result
+
+ def send_request(self, request_config: dict[str, Any] | None = None) -> OmniResponse:
+ if request_config is None:
+ request_config = {}
+ prompts = request_config.get("prompts")
+ videos = request_config.get("videos")
+ images = request_config.get("images")
+ audios = request_config.get("audios")
+ modalities = request_config.get("modalities", ["text", "audio"])
+ outputs = self.runner.generate_multimodal(
+ prompts=prompts, videos=videos, images=images, audios=audios, modalities=modalities
+ )
+ response = self._process_output(outputs)
+ assert_omni_response(response, request_config, run_level="core_model")
+ return response
+
+ def send_audio_speech_request(self, request_config: dict[str, Any]) -> OmniResponse:
+ """
+ Offline TTS: text -> audio via generate_multimodal, then validate with assert_audio_speech_response.
+
+ request_config must contain:
+ - 'input' or 'prompts': text to synthesize.
+ Optional keys:
+ - 'voice' -> speaker (CustomVoice)
+ - 'task_type' -> task_type in additional_information (default: "CustomVoice")
+ - 'language' -> language in additional_information (default: "Auto")
+ - 'max_new_tokens' -> max_new_tokens in additional_information (default: 2048)
+ - 'response_format' -> desired audio format (used only for assertion)
+ """
+ input_text = request_config.get("input") or request_config.get("prompts")
+ if input_text is None:
+ raise ValueError("request_config must contain 'input' or 'prompts' for TTS")
+ if isinstance(input_text, list):
+ input_text = input_text[0] if input_text else ""
+
+ mm_processor_kwargs: dict[str, Any] = {}
+ if "voice" in request_config:
+ mm_processor_kwargs["speaker"] = request_config["voice"]
+ if "task_type" in request_config:
+ mm_processor_kwargs["task_type"] = request_config["task_type"]
+ if "ref_audio" in request_config:
+ mm_processor_kwargs["ref_audio"] = request_config["ref_audio"]
+ if "ref_text" in request_config:
+ mm_processor_kwargs["ref_text"] = request_config["ref_text"]
+ if "language" in request_config:
+ mm_processor_kwargs["language"] = request_config["language"]
+ if "max_new_tokens" in request_config:
+ mm_processor_kwargs["max_new_tokens"] = request_config["max_new_tokens"]
+
+ outputs = self.runner.generate_multimodal(
+ prompts=input_text,
+ modalities=["audio"],
+ mm_processor_kwargs=mm_processor_kwargs or None,
+ )
+ mm_out: dict[str, Any] | None = None
+ for stage_out in outputs:
+ if getattr(stage_out, "final_output_type", None) == "audio":
+ mm_out = stage_out.request_output.outputs[0].multimodal_output
+ break
+ if mm_out is None:
+ result = OmniResponse(success=False, error_message="No audio output from pipeline")
+ assert result.success, result.error_message
+ return result
+
+ audio_data = mm_out.get("audio")
+ if audio_data is None:
+ result = OmniResponse(success=False, error_message="No audio tensor in multimodal output")
+ assert result.success, result.error_message
+ return result
+
+ sr_raw = mm_out.get("sr")
+ sr_val = sr_raw[-1] if isinstance(sr_raw, list) and sr_raw else sr_raw
+ sr = int(sr_val.item() if hasattr(sr_val, "item") else sr_val)
+ wav_tensor = torch.cat(audio_data, dim=-1) if isinstance(audio_data, list) else audio_data
+ wav_buf = io.BytesIO()
+ sf.write(
+ wav_buf,
+ wav_tensor.float().cpu().numpy().reshape(-1),
+ samplerate=sr,
+ format="WAV",
+ subtype="PCM_16",
+ )
+ result = OmniResponse(success=True, audio_bytes=wav_buf.getvalue(), audio_format="audio/wav")
+ assert_audio_speech_response(result, request_config, run_level="core_model")
+ return result
+
+ def start_profile(self, profile_prefix: str | None = None, stages: list[int] | None = None) -> list[Any]:
+ return self.runner.start_profile(profile_prefix=profile_prefix, stages=stages)
+
+ def stop_profile(self, stages: list[int] | None = None) -> list[Any]:
+ return self.runner.stop_profile(stages=stages)
+
+
+__all__ = [
+ "DiffusionResponse",
+ "OmniResponse",
+ "OmniRunner",
+ "OmniRunnerHandler",
+ "OmniServer",
+ "OmniServerParams",
+ "OmniServerStageCli",
+ "OpenAIClientHandler",
+ "get_open_port",
+ "run_forced_gpu_cleanup_round",
+ "dummy_messages_from_mix_data",
+]
diff --git a/tests/helpers/stage_config.py b/tests/helpers/stage_config.py
new file mode 100644
index 000000000..29a80372e
--- /dev/null
+++ b/tests/helpers/stage_config.py
@@ -0,0 +1,475 @@
+"""Config/message construction helpers used by tests."""
+
+import atexit
+import os
+import tempfile
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+
+def modify_stage_config(
+ yaml_path: str,
+ updates: dict[str, Any] = None,
+ deletes: dict[str, Any] = None,
+) -> str:
+ """
+ Modify configurations in a YAML file, supporting both top-level and stage-specific modifications,
+ including addition, modification, and deletion of configurations.
+
+ Args:
+ yaml_path: Path to the YAML configuration file.
+ updates: Dictionary containing both top-level and stage-specific modifications to add or update.
+ Format: {
+ 'async_chunk': True,
+ 'stage_args': {
+ 0: {'engine_args.max_model_len': 5800},
+ 1: {'engine_args.max_num_seqs': 2}
+ }
+ }
+ deletes: Dictionary containing configurations to delete.
+ Format: {
+ 'old_config': None, # Delete entire key
+ 'stage_args': {
+ 0: ['engine_args.old_param'],
+ 1: ['runtime.unused_setting']
+ }
+ }
+
+ Returns:
+ str: Path to the newly created modified YAML file with timestamp suffix.
+ """
+ path = Path(yaml_path)
+ if not path.exists():
+ raise FileNotFoundError(f"yaml does not exist: {path}")
+
+ try:
+ with open(yaml_path, encoding="utf-8") as f:
+ config = yaml.safe_load(f) or {}
+ except Exception as e:
+ raise ValueError(f"Cannot parse YAML file: {e}")
+
+ # Helper function to apply update
+ def apply_update(config_dict: dict, key_path: str, value: Any) -> None:
+ """Apply update to dictionary using dot-separated path."""
+ # Handle direct list assignment (e.g., engine_input_source: [1, 2])
+ if "." not in key_path:
+ # Simple key, set directly
+ config_dict[key_path] = value
+ return
+
+ current = config_dict
+ keys = key_path.split(".")
+
+ for i in range(len(keys) - 1):
+ key = keys[i]
+
+ # Handle list indices
+ if key.isdigit() and isinstance(current, list):
+ index = int(key)
+ if index < 0:
+ raise ValueError(f"Negative list index not allowed: {index}")
+ if index >= len(current):
+ # Expand list if needed
+ while len(current) <= index:
+ # If we need to go deeper (more keys after this), create a dict
+ # Otherwise, create None placeholder
+ current.append({} if i < len(keys) - 2 else None)
+ current = current[index]
+ elif isinstance(current, dict):
+ # Handle dictionary keys
+ if key not in current:
+ # If there are more keys after this, create appropriate structure
+ if i < len(keys) - 1:
+ # Check if next key is a digit (list index) or string (dict key)
+ if keys[i + 1].isdigit():
+ current[key] = []
+ else:
+ current[key] = {}
+ else:
+ # This is the last key, create based on value type
+ current[key] = [] if isinstance(value, list) else {}
+ elif not isinstance(current[key], (dict, list)) and i < len(keys) - 1:
+ # If current value is not dict/list but we need to go deeper, replace it
+ if keys[i + 1].isdigit():
+ current[key] = []
+ else:
+ current[key] = {}
+ current = current[key]
+ else:
+ # Current is not a dict or list, cannot traverse further
+ raise TypeError(
+ f"Cannot access {'.'.join(keys[: i + 1])} as a dict/list. It's a {type(current).__name__}"
+ )
+
+ # Set the final value
+ last_key = keys[-1]
+ if isinstance(current, list) and last_key.isdigit():
+ # Setting a value in a list by index
+ index = int(last_key)
+ if index < 0:
+ raise ValueError(f"Negative list index not allowed: {index}")
+ if index >= len(current):
+ # Expand list if needed
+ while len(current) <= index:
+ current.append(None)
+ current[index] = value
+ elif isinstance(current, dict):
+ # Special case: if the value is a list and we're setting a top-level key
+ # Example: updating engine_input_source with [1, 2]
+ current[last_key] = value
+ else:
+ # Current is not a dict, cannot set key
+ raise TypeError(f"Cannot set value at {key_path}. Current type is {type(current).__name__}, expected dict.")
+
+ # Helper function to delete by path
+ def delete_by_path(config_dict: dict, path: str) -> None:
+ """Delete configuration by dot-separated path."""
+ if not path:
+ return
+
+ current = config_dict
+ keys = path.split(".")
+
+ # Traverse to the parent
+ for i in range(len(keys) - 1):
+ key = keys[i]
+
+ # Handle list indices
+ if key.isdigit() and isinstance(current, list):
+ index = int(key)
+ if index < 0 or index >= len(current):
+ raise KeyError(f"List index {index} out of bounds")
+ current = current[index]
+ elif isinstance(current, dict):
+ if key not in current:
+ raise KeyError(f"Path {'.'.join(keys[: i + 1])} does not exist")
+ current = current[key]
+ else:
+ raise TypeError(
+ f"Cannot access {'.'.join(keys[: i + 1])} as a dict/list. It's a {type(current).__name__}"
+ )
+
+ # Delete the item
+ last_key = keys[-1]
+
+ if isinstance(current, list) and last_key.isdigit():
+ index = int(last_key)
+ if index < 0 or index >= len(current):
+ raise KeyError(f"List index {index} out of bounds")
+ del current[index]
+ elif isinstance(current, dict) and last_key in current:
+ del current[last_key]
+ else:
+ print(f"Path {path} does not exist")
+
+ _stage_key = "stages" if "stages" in config else "stage_args"
+
+ # Apply deletions first
+ if deletes:
+ for key, value in deletes.items():
+ if key in ("stage_args", "stages"):
+ if value and isinstance(value, dict):
+ stage_args = config.get(_stage_key, [])
+ if not stage_args:
+ raise ValueError("stage_args does not exist in config")
+
+ for stage_id, delete_paths in value.items():
+ if not delete_paths:
+ continue
+
+ # Find stage by ID
+ target_stage = None
+ for stage in stage_args:
+ if stage.get("stage_id") == int(stage_id):
+ target_stage = stage
+ break
+
+ if target_stage is None:
+ continue
+
+ # Delete specified paths in this stage
+ # Avoid shadowing the original YAML Path used for the output filename below.
+ for delete_path in delete_paths:
+ if delete_path: # Skip empty paths
+ delete_by_path(target_stage, delete_path)
+ elif "." in key:
+ # Delete using dot-separated path
+ delete_by_path(config, key)
+ elif value is None and key in config:
+ # Delete entire key
+ del config[key]
+
+ # Apply updates
+ if updates:
+ for key, value in updates.items():
+ if key in ("stage_args", "stages"):
+ if value and isinstance(value, dict):
+ stage_args = config.get(_stage_key, [])
+ if not stage_args:
+ raise ValueError("stage_args does not exist in config")
+
+ for stage_id, stage_updates in value.items():
+ # Find stage by ID
+ target_stage = None
+ for stage in stage_args:
+ if stage.get("stage_id") == int(stage_id):
+ target_stage = stage
+ break
+
+ if target_stage is None:
+ available_ids = [s.get("stage_id") for s in stage_args if "stage_id" in s]
+ raise KeyError(f"Stage ID {stage_id} not found, available: {available_ids}")
+
+ # Apply updates to this stage
+ for update_path, val in stage_updates.items():
+ # Check if this is a simple key (not dot-separated)
+ # Example: 'engine_input_source' vs 'engine_args.max_model_len'
+ if "." not in update_path:
+ # Direct key assignment (e.g., updating a list value)
+ target_stage[update_path] = val
+ else:
+ # Dot-separated path (e.g., nested dict access)
+ apply_update(target_stage, update_path, val)
+ elif "." in key:
+ # Apply using dot-separated path
+ apply_update(config, key, value)
+ else:
+ # Direct top-level key
+ config[key] = value
+
+ # Unique suffix: multiple modify_stage_config calls in one process often run
+ # within the same second (e.g. test_qwen3_omni_expansion imports both
+ # get_chunk_config and get_batch_token_config). int(time.time()) would collide
+ # and the later write would overwrite the earlier YAML on disk.
+ # Keep generated configs outside the repo and delete them when pytest exits.
+ output_fd, output_path = tempfile.mkstemp(prefix=f"{path.stem}_", suffix=".yaml")
+ atexit.register(Path(output_path).unlink, missing_ok=True)
+
+ with os.fdopen(output_fd, "w", encoding="utf-8") as f:
+ yaml.dump(config, f, default_flow_style=None, sort_keys=False, allow_unicode=True, indent=2)
+
+ return str(output_path)
+
+
+# ``stage_config.py`` lives under ``tests/helpers/``; repo root is three parents up.
+_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
+_DEPLOY_DIR = _REPO_ROOT / "vllm_omni" / "deploy"
+_CI_GENERATED_DIR = _REPO_ROOT / "tests" / ".ci_generated"
+
+
+# CI overlays as Python dicts (LSP-friendly). Materialized on demand to
+# tests/.ci_generated/.yaml via get_deploy_config_path("ci/.yaml").
+_CI_OVERLAYS: dict[str, dict[str, Any]] = {
+ "qwen2_5_omni": {
+ "base_config": "qwen2_5_omni.yaml",
+ "async_chunk": False,
+ "stages": [
+ {
+ "stage_id": 0,
+ "max_model_len": 16384,
+ "max_num_batched_tokens": 16384,
+ "max_num_seqs": 1,
+ "gpu_memory_utilization": 0.9,
+ "skip_mm_profiling": True,
+ "load_format": "dummy",
+ "default_sampling_params": {"max_tokens": 128},
+ },
+ {
+ "stage_id": 1,
+ "max_model_len": 16384,
+ "max_num_batched_tokens": 16384,
+ "max_num_seqs": 1,
+ "gpu_memory_utilization": 0.4,
+ "skip_mm_profiling": True,
+ "load_format": "dummy",
+ "default_sampling_params": {"max_tokens": 4096},
+ },
+ {
+ "stage_id": 2,
+ "max_num_seqs": 1,
+ "gpu_memory_utilization": 0.5,
+ "max_num_batched_tokens": 8192,
+ "max_model_len": 8192,
+ "load_format": "dummy",
+ "devices": "2",
+ "default_sampling_params": {"max_tokens": 8192},
+ },
+ ],
+ "platforms": {
+ "rocm": {
+ "stages": [
+ {"stage_id": 0, "gpu_memory_utilization": 0.9},
+ {"stage_id": 1, "gpu_memory_utilization": 0.4},
+ {"stage_id": 2, "gpu_memory_utilization": 0.5, "devices": "2"},
+ ],
+ },
+ "xpu": {
+ "stages": [
+ {
+ "stage_id": 0,
+ "gpu_memory_utilization": 0.9,
+ "max_num_batched_tokens": 16384,
+ "max_model_len": 16384,
+ },
+ {"stage_id": 1, "gpu_memory_utilization": 0.5},
+ {
+ "stage_id": 2,
+ "gpu_memory_utilization": 0.3,
+ "max_num_batched_tokens": 4096,
+ "max_model_len": 4096,
+ "devices": "2",
+ },
+ ],
+ },
+ },
+ },
+ "qwen3_omni_moe": {
+ "base_config": "qwen3_omni_moe.yaml",
+ "async_chunk": False,
+ "stages": [
+ {
+ "stage_id": 0,
+ "max_num_seqs": 5,
+ "max_model_len": 32768,
+ "mm_processor_cache_gb": 0,
+ "load_format": "dummy",
+ "default_sampling_params": {"max_tokens": 150, "ignore_eos": False},
+ },
+ {
+ "stage_id": 1,
+ "gpu_memory_utilization": 0.5,
+ "max_num_seqs": 5,
+ "max_model_len": 32768,
+ "load_format": "dummy",
+ "default_sampling_params": {"max_tokens": 1000},
+ },
+ {
+ "stage_id": 2,
+ "max_num_seqs": 5,
+ "max_num_batched_tokens": 100000,
+ "load_format": "dummy",
+ "default_sampling_params": {"max_tokens": 2000},
+ },
+ ],
+ "platforms": {
+ "rocm": {
+ "stages": [
+ {"stage_id": 0, "max_num_seqs": 1, "default_sampling_params": {"max_tokens": 100}},
+ {
+ "stage_id": 1,
+ "max_num_seqs": 1,
+ "enforce_eager": True,
+ "default_sampling_params": {"max_tokens": 100},
+ },
+ {
+ "stage_id": 2,
+ "max_num_seqs": 1,
+ "max_num_batched_tokens": 1000000,
+ "default_sampling_params": {"max_tokens": 200},
+ },
+ ],
+ },
+ "xpu": {
+ "stages": [
+ {
+ "stage_id": 0,
+ "gpu_memory_utilization": 0.85,
+ "max_num_seqs": 1,
+ "tensor_parallel_size": 4,
+ "enforce_eager": True,
+ "max_num_batched_tokens": 4096,
+ "max_model_len": 4096,
+ "max_cudagraph_capture_size": 0,
+ "skip_mm_profiling": True,
+ "devices": "0,1,2,3",
+ "default_sampling_params": {"max_tokens": 100, "ignore_eos": False},
+ },
+ {
+ "stage_id": 1,
+ "gpu_memory_utilization": 0.6,
+ "max_num_seqs": 1,
+ "enforce_eager": True,
+ "max_num_batched_tokens": 4096,
+ "max_model_len": 4096,
+ "max_cudagraph_capture_size": 0,
+ "skip_mm_profiling": True,
+ "devices": "4",
+ },
+ {
+ "stage_id": 2,
+ "gpu_memory_utilization": 0.3,
+ "max_num_seqs": 1,
+ "max_num_batched_tokens": 100000,
+ "max_cudagraph_capture_size": 0,
+ "skip_mm_profiling": True,
+ "devices": "5",
+ "default_sampling_params": {"max_tokens": 2000},
+ },
+ ],
+ },
+ },
+ },
+ # Single-stage thinker-only topology for the abort test.
+ "qwen2_5_omni_thinker_only": {
+ "async_chunk": False,
+ "pipeline": "qwen2_5_omni_thinker_only",
+ "stages": [
+ {
+ "stage_id": 0,
+ "max_num_seqs": 1,
+ "gpu_memory_utilization": 0.9,
+ "enforce_eager": True,
+ "max_num_batched_tokens": 16384,
+ "max_model_len": 16384,
+ "skip_mm_profiling": True,
+ "mm_processor_cache_gb": 0,
+ "load_format": "dummy",
+ "devices": "0",
+ "default_sampling_params": {
+ "temperature": 0.0,
+ "top_p": 1.0,
+ "top_k": -1,
+ "max_tokens": 128,
+ "seed": 42,
+ "repetition_penalty": 1.1,
+ },
+ },
+ ],
+ },
+}
+
+
+def _materialize_ci_overlay(model_type: str) -> Path:
+ import yaml
+
+ if model_type not in _CI_OVERLAYS:
+ raise KeyError(f"No CI overlay registered for {model_type!r}. Available: {sorted(_CI_OVERLAYS)}")
+
+ _CI_GENERATED_DIR.mkdir(parents=True, exist_ok=True)
+ out = _CI_GENERATED_DIR / f"{model_type}.yaml"
+
+ overlay = {**_CI_OVERLAYS[model_type]}
+ base = overlay.get("base_config")
+ if base:
+ overlay["base_config"] = str(_DEPLOY_DIR / base)
+
+ with open(out, "w", encoding="utf-8") as f:
+ yaml.safe_dump(overlay, f, sort_keys=False)
+ return out
+
+
+def get_deploy_config_path(rel_path: str) -> str:
+ """Resolve a deploy yaml; ``ci/.yaml`` materializes from ``_CI_OVERLAYS``."""
+ if rel_path.startswith("ci/") and rel_path.endswith(".yaml"):
+ model_type = rel_path[len("ci/") : -len(".yaml")]
+ if model_type in _CI_OVERLAYS:
+ return str(_materialize_ci_overlay(model_type))
+ return str(_DEPLOY_DIR / rel_path)
+
+
+__all__ = [
+ "modify_stage_config",
+ "get_deploy_config_path",
+]
diff --git a/tests/model_executor/models/cosyvoice3/test_cosyvoice3_components.py b/tests/model_executor/models/cosyvoice3/test_cosyvoice3_components.py
index ec24f6949..0e071f724 100644
--- a/tests/model_executor/models/cosyvoice3/test_cosyvoice3_components.py
+++ b/tests/model_executor/models/cosyvoice3/test_cosyvoice3_components.py
@@ -8,7 +8,7 @@ import pytest
import torch
import torch.nn as nn
-from tests.utils import hardware_test
+from tests.helpers.mark import hardware_test
class TestPreLookaheadLayer:
diff --git a/tests/model_executor/models/glm_image/test_glm_image_ar.py b/tests/model_executor/models/glm_image/test_glm_image_ar.py
new file mode 100644
index 000000000..32a016b2a
--- /dev/null
+++ b/tests/model_executor/models/glm_image/test_glm_image_ar.py
@@ -0,0 +1,352 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Unit tests for GLM-Image AR model: DataParser, processor, and M-RoPE."""
+
+import importlib.util
+import os
+import sys
+import types
+from unittest.mock import MagicMock, patch
+
+import pytest
+import torch
+
+# ---------------------------------------------------------------------------
+# Load target classes via importlib to avoid requiring transformers.models.glm_image
+# (which may not exist in CI). This follows the same pattern as
+# tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py.
+# ---------------------------------------------------------------------------
+
+_BASE = os.path.join(
+ os.path.dirname(__file__),
+ os.pardir,
+ os.pardir,
+ os.pardir,
+ os.pardir,
+ "vllm_omni",
+ "model_executor",
+ "models",
+ "glm_image",
+)
+
+
+def _load_module(name: str, filename: str):
+ path = os.path.abspath(os.path.join(_BASE, filename))
+ spec = importlib.util.spec_from_file_location(name, path)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+def _build_mock_modules() -> dict[str, object]:
+ """Build the dict of modules to inject into sys.modules."""
+ # Stub transformers.models.glm_image submodules
+ glm_image_mod = types.ModuleType("transformers.models.glm_image")
+ glm_config_mod = types.ModuleType("transformers.models.glm_image.configuration_glm_image")
+ glm_config_mod.GlmImageConfig = type("GlmImageConfig", (), {})
+ glm_config_mod.GlmImageTextConfig = type("GlmImageTextConfig", (), {})
+ glm_config_mod.GlmImageVisionConfig = type("GlmImageVisionConfig", (), {})
+ glm_config_mod.GlmImageVQVAEConfig = type("GlmImageVQVAEConfig", (), {})
+ glm_proc_mod = types.ModuleType("transformers.models.glm_image.processing_glm_image")
+ glm_proc_mod.GlmImageProcessor = type("GlmImageProcessor", (), {})
+
+ # vllm_omni submodules needed by the import chain
+ vllm_omni_mod = MagicMock()
+ vllm_omni_models = types.ModuleType("vllm_omni.model_executor.models")
+ vllm_omni_glm_image_pkg = types.ModuleType("vllm_omni.model_executor.models.glm_image")
+ vllm_omni_glm_image_pkg.__path__ = [os.path.abspath(_BASE)]
+ vllm_omni_output = MagicMock()
+
+ return {
+ "transformers.models.glm_image": glm_image_mod,
+ "transformers.models.glm_image.configuration_glm_image": glm_config_mod,
+ "transformers.models.glm_image.processing_glm_image": glm_proc_mod,
+ "vllm_omni": vllm_omni_mod,
+ "vllm_omni.model_executor": types.ModuleType("vllm_omni.model_executor"),
+ "vllm_omni.model_executor.models": vllm_omni_models,
+ "vllm_omni.model_executor.models.glm_image": vllm_omni_glm_image_pkg,
+ "vllm_omni.model_executor.models.output_templates": vllm_omni_output,
+ }
+
+
+def _load_target_classes():
+ """Load the glm_image_ar module with mocked dependencies."""
+ mocks = _build_mock_modules()
+ with patch.dict(sys.modules, mocks):
+ mod = _load_module(
+ "vllm_omni.model_executor.models.glm_image.glm_image_ar",
+ "glm_image_ar.py",
+ )
+ sys.modules["vllm_omni.model_executor.models.glm_image.glm_image_ar"] = mod
+ return mod
+
+
+_ar_mod = _load_target_classes()
+
+GlmImageDataParser = _ar_mod.GlmImageDataParser
+GlmImageMultiModalProcessor = _ar_mod.GlmImageMultiModalProcessor
+GlmImageForConditionalGeneration = _ar_mod.GlmImageForConditionalGeneration
+GlmImageRotaryEmbedding = _ar_mod.GlmImageRotaryEmbedding
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+# =============================================================================
+# Helper: Minimal config for testing
+# =============================================================================
+
+
+def _make_hf_config(**overrides):
+ """Create a minimal GlmImageConfig-like object for testing."""
+ defaults = {
+ "image_token_id": 167855,
+ "image_start_token_id": 16384,
+ "image_end_token_id": 16385,
+ "grid_bos_token_id": None,
+ "grid_eos_token_id": None,
+ }
+ defaults.update(overrides)
+ from types import SimpleNamespace
+
+ return SimpleNamespace(**defaults)
+
+
+# =============================================================================
+# Tests for GlmImageDataParser
+# =============================================================================
+
+
+class TestGlmImageDataParser:
+ """Test that img2img key is normalized to image in the data parser."""
+
+ def test_img2img_normalized_to_image(self):
+ parser = GlmImageDataParser.__new__(GlmImageDataParser)
+ parser._expected_hidden_size = 4096
+ # The _get_subparsers should include img2img
+ subparsers = parser._get_subparsers()
+ assert "img2img" in subparsers
+ assert subparsers["img2img"] == parser._parse_image_data
+
+ def test_parse_mm_data_normalizes_img2img(self):
+ parser = GlmImageDataParser.__new__(GlmImageDataParser)
+ parser._expected_hidden_size = 4096
+ # Create a mock for the parent parse_mm_data
+ original_parse = type(parser).parse_mm_data
+
+ calls = []
+
+ def mock_parse(mm_data, **kwargs):
+ calls.append(mm_data)
+ return MagicMock()
+
+ # Monkey-patch temporarily
+ type(parser).parse_mm_data = mock_parse
+ try:
+ parser.parse_mm_data({"img2img": "fake_image"})
+ except Exception:
+ pass # parse might fail on mock, we just check the normalization
+ finally:
+ type(parser).parse_mm_data = original_parse
+
+ # Verify that "img2img" was normalized to "image"
+ if calls:
+ assert "image" in calls[0]
+ assert "img2img" not in calls[0]
+
+
+# =============================================================================
+# Tests for _build_generation_grids
+# =============================================================================
+
+
+class TestBuildGenerationGrids:
+ """Test M-RoPE grid construction for t2i mode."""
+
+ @pytest.fixture
+ def processor(self):
+ """Create a minimal processor instance with mocked info."""
+ proc = object.__new__(GlmImageMultiModalProcessor)
+ proc.info = MagicMock()
+ return proc
+
+ def test_1024x1024(self, processor):
+ kwargs = {"target_h": 1024, "target_w": 1024}
+ grids = processor._build_generation_grids(kwargs)
+ # token_h = 32, token_w = 32
+ # ratio = 1.0, small_h = 16, small_w = 16
+ assert grids.shape == (2, 3)
+ assert grids[0].tolist() == [1, 32, 32] # large
+ assert grids[1].tolist() == [1, 16, 16] # small
+
+ def test_512x512(self, processor):
+ kwargs = {"target_h": 512, "target_w": 512}
+ grids = processor._build_generation_grids(kwargs)
+ assert grids.shape == (2, 3)
+ assert grids[0].tolist() == [1, 16, 16]
+ # small: ratio=1.0, small_h=int(sqrt(1)*16)=16, small_w=16
+ assert grids[1].tolist() == [1, 16, 16]
+
+ def test_non_square(self, processor):
+ kwargs = {"target_h": 1024, "target_w": 512}
+ grids = processor._build_generation_grids(kwargs)
+ # token_h = 32, token_w = 16, ratio = 2.0
+ # small_h = int(sqrt(2)*16) = 22, small_w = int(sqrt(0.5)*16) = 11
+ assert grids[0].tolist() == [1, 32, 16]
+ assert grids[1].tolist() == [1, 22, 11]
+
+ def test_defaults_to_1024_when_no_target(self, processor):
+ kwargs = {}
+ grids = processor._build_generation_grids(kwargs)
+ assert grids[0].tolist() == [1, 32, 32]
+
+ def test_height_width_fallback(self, processor):
+ kwargs = {"height": 512, "width": 512}
+ grids = processor._build_generation_grids(kwargs)
+ assert grids[0].tolist() == [1, 16, 16]
+
+ def test_aligned_to_factor(self, processor):
+ # 1000 not aligned to 32, should be rounded down to 992
+ kwargs = {"target_h": 1000, "target_w": 1000}
+ grids = processor._build_generation_grids(kwargs)
+ # 1000 // 32 = 31
+ assert grids[0].tolist() == [1, 31, 31]
+
+
+# =============================================================================
+# Tests for get_mrope_input_positions
+# =============================================================================
+
+
+class TestGetMropeInputPositions:
+ """Test M-RoPE position ID computation."""
+
+ @pytest.fixture
+ def model(self):
+ """Create a minimal model instance for M-RoPE testing."""
+ model = object.__new__(GlmImageForConditionalGeneration)
+ model.config = _make_hf_config()
+ return model
+
+ def test_pure_text(self, model):
+ """Pure text tokens: all 3 dimensions get same sequential positions."""
+ input_tokens = [100, 101, 102, 103]
+ positions, delta = model.get_mrope_input_positions(input_tokens)
+ assert positions.shape == (3, 4)
+ # All three dims should be [0, 1, 2, 3]
+ for dim in range(3):
+ assert positions[dim].tolist() == [0, 1, 2, 3]
+ assert delta == 0 # max(3) + 1 - seq_len(4) = 0
+
+ def test_t2i_with_target_size(self, model):
+ """t2i with explicit target_h/target_w: grids built from them."""
+ input_tokens = [100, 101, 102, 16384] # text +
+ kwargs = {"target_h": 256, "target_w": 256}
+
+ positions, delta = model.get_mrope_input_positions(input_tokens, **kwargs)
+ # 256/32=8 -> grids = [[1,8,8], [1,16,16]] (small uses factor//2=16 base)
+ # Decode order (reversed): grid[-1]=[1,16,16]=256, grid[-2]=[1,8,8]=64, EOS=1
+ total_decode = 256 + 64 + 1 # 321
+ assert positions.shape == (3, 4 + total_decode)
+ # delta = max_position + 1 - seq_len
+ # Positions advance by max(h,w) per grid: max(16,16)=16, max(8,8)=8
+ # max_pos = seq_len(4) + 16 + 8 = 28, then EOS at 28
+ # delta = 28 + 1 - 4 = 25
+ assert delta == 25
+
+ def test_t2i_1024_default_grids(self, model):
+ """t2i with default 1024x1024 grids when no explicit target size."""
+ # Prompt ending with image_start_token_id but no image_end_token_id
+ input_tokens = [100, 101, 16384]
+ # No target_h/target_w, no mrope_image_grid_thw
+ # Falls back to token parsing then to default [[1,32,32], [1,16,16]]
+ positions, delta = model.get_mrope_input_positions(input_tokens)
+ assert positions.shape[0] == 3
+
+ def test_i2i_with_mrope_grid(self, model):
+ """i2i: mrope_image_grid_thw contains source + target grids."""
+ # Source image tokens: [16384, 167855*4, 16385] + text + 16384(bos)
+ source_grid = [1, 2, 2] # 2x2 = 4 image tokens
+ target_grid = [1, 32, 32] # 32x32 = 1024 tokens
+ mrope_grid = torch.tensor([source_grid, target_grid], dtype=torch.long)
+
+ # input_tokens: text + + 4*image_token + +
+ input_tokens = [100, 101, 16384] + [167855] * 4 + [16385, 16384]
+
+ positions, delta = model.get_mrope_input_positions(input_tokens, mrope_image_grid_thw=mrope_grid)
+
+ # 1 source image (num_complete_images=1), 1 target grid (num_decode_grids=1)
+ # Prefill covers all input tokens
+ # Decode covers: 32*32 + 1(EOS) = 1025 tokens
+ assert positions.shape[0] == 3
+
+ def test_position_delta_non_negative(self, model):
+ """mrope_position_delta should be non-negative for valid inputs."""
+ input_tokens = [100, 16384]
+ kwargs = {"target_h": 64, "target_w": 64}
+ positions, delta = model.get_mrope_input_positions(input_tokens, **kwargs)
+ assert delta >= 0
+
+
+# =============================================================================
+# Tests for GlmImageRotaryEmbedding._apply_mrope
+# =============================================================================
+
+
+class TestGlmImageRotaryEmbedding:
+ """Test M-RoPE section interleaving in the rotary embedding."""
+
+ @pytest.fixture
+ def rotary_emb(self):
+ # mrope_section=[8,12,12] sums to 32, so rotary_dim//2 must be >= 32
+ # -> head_dim=64 gives rotary_dim=64, rotary_dim//2=32
+ return GlmImageRotaryEmbedding(head_dim=64, mrope_section=[8, 12, 12])
+
+ def test_apply_mrope_shape(self, rotary_emb):
+ """Output shape matches [num_tokens, rotary_dim // 2]."""
+ freqs = torch.randn(3, 5, 32) # 3 dims, 5 tokens, rotary_dim//2=32
+ result = rotary_emb._apply_mrope(freqs)
+ assert result.shape == (5, 32)
+
+ def test_apply_mrope_interleaving(self, rotary_emb):
+ """Verify that M-RoPE correctly interleaves T/H/W sections."""
+ # mrope_section = [8, 12, 12] splits dim 32 into 3 chunks: [8, 12, 12]
+ # chunk 0 (size 8): dim 0 % 3 = 0 (temporal)
+ # chunk 1 (size 12): dim 1 % 3 = 1 (height)
+ # chunk 2 (size 12): dim 2 % 3 = 2 (width)
+ freqs = torch.ones(3, 1, 32)
+ freqs[0, :, :] = 1.0 # temporal
+ freqs[1, :, :] = 2.0 # height
+ freqs[2, :, :] = 3.0 # width
+
+ result = rotary_emb._apply_mrope(freqs)
+ assert result.shape == (1, 32)
+ assert (result[0, :8] == 1.0).all() # chunk 0: temporal
+ assert (result[0, 8:20] == 2.0).all() # chunk 1: height
+ assert (result[0, 20:32] == 3.0).all() # chunk 2: width
+
+ def test_forward_1d_positions(self, rotary_emb):
+ """Forward with 1D positions (text-only) produces correct shapes."""
+ positions = torch.arange(10) # [10]
+ q = torch.randn(10, 64)
+ k = torch.randn(10, 64)
+ q_out, k_out = rotary_emb(positions, q, k)
+ assert q_out.shape == (10, 64)
+ assert k_out.shape == (10, 64)
+
+ def test_forward_3d_positions(self, rotary_emb):
+ """Forward with 3D M-RoPE positions produces correct shapes."""
+ positions = torch.arange(30).reshape(3, 10) # [3, 10]
+ q = torch.randn(10, 64)
+ k = torch.randn(10, 64)
+ q_out, k_out = rotary_emb(positions, q, k)
+ assert q_out.shape == (10, 64)
+ assert k_out.shape == (10, 64)
+
+ def test_forward_preserves_dtype(self, rotary_emb):
+ """Output dtype matches input dtype."""
+ positions = torch.arange(5)
+ q = torch.randn(5, 64, dtype=torch.float32)
+ k = torch.randn(5, 64, dtype=torch.float32)
+ q_out, k_out = rotary_emb(positions, q, k)
+ assert q_out.dtype == torch.float32
+ assert k_out.dtype == torch.float32
diff --git a/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py b/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py
index b0ce10a8d..8798cb3ca 100644
--- a/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py
+++ b/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py
@@ -21,7 +21,7 @@ import torch
from pytest_mock import MockerFixture
# Direct file import to avoid vllm_omni.__init__ patch dependencies.
-_BASE = os.path.join(
+_MODELS = os.path.join(
os.path.dirname(__file__),
os.pardir,
os.pardir,
@@ -30,14 +30,16 @@ _BASE = os.path.join(
"vllm_omni",
"model_executor",
"models",
- "qwen3_tts",
)
+_BASE = os.path.join(_MODELS, "qwen3_tts")
+_COMMON = os.path.join(_MODELS, "common")
def _load_module(name: str, filename: str):
path = os.path.abspath(os.path.join(_BASE, filename))
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
+ sys.modules[name] = mod # register before exec (needed for dataclasses etc.)
spec.loader.exec_module(mod)
return mod
@@ -59,8 +61,17 @@ def _build_mock_modules(mocker: MockerFixture) -> dict[str, object]:
weight_utils_mock = mocker.MagicMock()
weight_utils_mock.default_weight_loader = lambda p, w: None
- pkg = types.ModuleType("vllm_omni.model_executor.models.qwen3_tts")
- pkg.__path__ = [os.path.abspath(_BASE)]
+ tts_pkg = types.ModuleType("vllm_omni.model_executor.models.qwen3_tts")
+ tts_pkg.__path__ = [os.path.abspath(_BASE)]
+
+ common_pkg = types.ModuleType("vllm_omni.model_executor.models.common")
+ common_pkg.__path__ = [os.path.abspath(_COMMON)]
+
+ models_pkg = types.ModuleType("vllm_omni.model_executor.models")
+ models_pkg.__path__ = [os.path.abspath(_MODELS)]
+
+ vllm_parallel_mock = mocker.MagicMock()
+ vllm_parallel_mock.VocabParallelEmbedding = torch.nn.Embedding
return {
"vllm_omni": mocker.MagicMock(),
@@ -69,9 +80,11 @@ def _build_mock_modules(mocker: MockerFixture) -> dict[str, object]:
"vllm.config": mocker.MagicMock(),
"vllm.config.vllm": vllm_config_mod,
"vllm.model_executor.model_loader.weight_utils": weight_utils_mock,
+ "vllm.model_executor.layers.vocab_parallel_embedding": vllm_parallel_mock,
"vllm_omni.model_executor": types.ModuleType("vllm_omni.model_executor"),
- "vllm_omni.model_executor.models": types.ModuleType("vllm_omni.model_executor.models"),
- "vllm_omni.model_executor.models.qwen3_tts": pkg,
+ "vllm_omni.model_executor.models": models_pkg,
+ "vllm_omni.model_executor.models.common": common_pkg,
+ "vllm_omni.model_executor.models.qwen3_tts": tts_pkg,
}
@@ -88,6 +101,15 @@ def _load_target_classes(mocker: MockerFixture):
)
sys.modules["vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts"] = config_mod
+ # Load the shared common module (thin wrappers import from it)
+ common_cp_path = os.path.abspath(os.path.join(_COMMON, "qwen3_code_predictor.py"))
+ common_spec = importlib.util.spec_from_file_location(
+ "vllm_omni.model_executor.models.common.qwen3_code_predictor", common_cp_path
+ )
+ common_cp_mod = importlib.util.module_from_spec(common_spec)
+ sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"] = common_cp_mod
+ common_spec.loader.exec_module(common_cp_mod)
+
cp_mod = _load_module(
"vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code_predictor_vllm",
"qwen3_tts_code_predictor_vllm.py",
@@ -104,6 +126,7 @@ def loaded_target_classes(mocker: MockerFixture):
config_mod.Qwen3TTSTalkerConfig,
cp_mod.Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM,
cp_mod.Qwen3TTSTalkerCodePredictorModelVLLM,
+ cp_mod.CodePredictorWrapperConfig,
)
@@ -114,6 +137,7 @@ def _make_tiny_config(loaded_target_classes) -> tuple:
qwen3_tts_talker_config,
_,
_,
+ _,
) = loaded_target_classes
cp_config = qwen3_tts_talker_code_predictor_config(
vocab_size=64,
@@ -145,7 +169,7 @@ class TestCodePredictorDtypeAlignment:
def test_ensure_buffers_uses_given_dtype(self, mocker: MockerFixture, loaded_target_classes) -> None:
"""_ensure_buffers should create proj_buf with the given dtype."""
- _, _, code_predictor_wrapper, _ = loaded_target_classes
+ _, _, code_predictor_wrapper, _, _ = loaded_target_classes
cp_config, talker_config = _make_tiny_config(loaded_target_classes)
vllm_config = _make_vllm_config(mocker)
@@ -156,17 +180,17 @@ class TestCodePredictorDtypeAlignment:
)
# Create buffer in float16
- predictor._ensure_buffers(torch.device("cpu"), torch.float16)
+ predictor._ensure_buffers(torch.device("cpu"), torch.float16, 4)
assert predictor._proj_buf is not None
assert predictor._proj_buf.dtype == torch.float16
# Re-create buffer in float32 (different dtype triggers re-allocation)
- predictor._ensure_buffers(torch.device("cpu"), torch.float32)
+ predictor._ensure_buffers(torch.device("cpu"), torch.float32, 4)
assert predictor._proj_buf.dtype == torch.float32
def test_warmup_aligns_buffer_to_model_params(self, mocker: MockerFixture, loaded_target_classes) -> None:
"""_warmup_buckets should align proj_buf dtype to model parameters."""
- _, _, code_predictor_wrapper, _ = loaded_target_classes
+ _, _, code_predictor_wrapper, _, _ = loaded_target_classes
cp_config, talker_config = _make_tiny_config(loaded_target_classes)
vllm_config = _make_vllm_config(mocker, max_num_seqs=2)
@@ -180,7 +204,7 @@ class TestCodePredictorDtypeAlignment:
predictor = predictor.to(torch.float16)
# Pre-create proj_buf with WRONG dtype (float32) — simulating the bug
- predictor._ensure_buffers(torch.device("cpu"), torch.float32)
+ predictor._ensure_buffers(torch.device("cpu"), torch.float32, 2)
assert predictor._proj_buf.dtype == torch.float32
# Simulate _setup_compile having cached model dtype and compiled forward
@@ -194,7 +218,7 @@ class TestCodePredictorDtypeAlignment:
def test_setup_compile_caches_model_dtype(self, mocker: MockerFixture, loaded_target_classes) -> None:
"""_setup_compile should cache model parameter dtype."""
- _, _, code_predictor_wrapper, _ = loaded_target_classes
+ _, _, code_predictor_wrapper, _, _ = loaded_target_classes
cp_config, talker_config = _make_tiny_config(loaded_target_classes)
vllm_config = _make_vllm_config(mocker, max_num_seqs=2)
@@ -211,7 +235,7 @@ class TestCodePredictorDtypeAlignment:
def test_forward_with_mismatched_input_dtype(self, mocker: MockerFixture, loaded_target_classes) -> None:
"""forward() should not crash when inputs are float32 but model is float16."""
- _, _, code_predictor_wrapper, _ = loaded_target_classes
+ _, _, code_predictor_wrapper, _, _ = loaded_target_classes
cp_config, talker_config = _make_tiny_config(loaded_target_classes)
vllm_config = _make_vllm_config(mocker, max_num_seqs=2)
@@ -250,9 +274,9 @@ class TestCodePredictorModelDtype:
def test_model_forward_float16(self, loaded_target_classes) -> None:
"""Inner model forward should work in float16."""
- _, _, _, code_predictor_model = loaded_target_classes
+ _, _, _, code_predictor_model, _ = loaded_target_classes
cp_config, _ = _make_tiny_config(loaded_target_classes)
- model = code_predictor_model(cp_config, talker_hidden_size=32).to(torch.float16)
+ model = code_predictor_model(cp_config, embedding_dim=32).to(torch.float16)
bsz, seq_len = 1, 4
inputs = torch.randn(bsz, seq_len, 32, dtype=torch.float16)
@@ -264,9 +288,9 @@ class TestCodePredictorModelDtype:
def test_model_forward_float32(self, loaded_target_classes) -> None:
"""Inner model forward should work in float32."""
- _, _, _, code_predictor_model = loaded_target_classes
+ _, _, _, code_predictor_model, _ = loaded_target_classes
cp_config, _ = _make_tiny_config(loaded_target_classes)
- model = code_predictor_model(cp_config, talker_hidden_size=32).to(torch.float32)
+ model = code_predictor_model(cp_config, embedding_dim=32).to(torch.float32)
bsz, seq_len = 1, 4
inputs = torch.randn(bsz, seq_len, 32, dtype=torch.float32)
@@ -275,3 +299,37 @@ class TestCodePredictorModelDtype:
output = model(inputs, pos_ids)
assert output.dtype == torch.float32
assert output.shape == (bsz, seq_len, 32)
+
+
+class TestCodePredictorWrapperConfig:
+ """Test wrapper configuration for different models."""
+
+ def test_omni_config(self, loaded_target_classes) -> None:
+ """Qwen3-Omni uses correct wrapper config."""
+ _, _, _, _, code_predictor_wrapper_config = loaded_target_classes
+ config = code_predictor_wrapper_config(
+ use_cuda_graphs=False,
+ use_parallel_embedding=True,
+ use_projection=False,
+ return_proj_buf=True,
+ sampling_mode="stored",
+ )
+ assert config.use_cuda_graphs is False
+ assert config.use_parallel_embedding is True
+ assert config.return_proj_buf is True
+ assert config.sampling_mode == "stored"
+
+ def test_tts_config(self, loaded_target_classes) -> None:
+ """Qwen3-TTS uses correct wrapper config."""
+ _, _, _, _, code_predictor_wrapper_config = loaded_target_classes
+ config = code_predictor_wrapper_config(
+ use_cuda_graphs=True,
+ use_parallel_embedding=False,
+ use_projection=True,
+ return_proj_buf=False,
+ sampling_mode="per_call",
+ )
+ assert config.use_cuda_graphs is True
+ assert config.use_parallel_embedding is False
+ assert config.return_proj_buf is False
+ assert config.sampling_mode == "per_call"
diff --git a/tests/model_executor/models/voxcpm2/__init__.py b/tests/model_executor/models/voxcpm2/__init__.py
new file mode 100644
index 000000000..208f01a7c
--- /dev/null
+++ b/tests/model_executor/models/voxcpm2/__init__.py
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
diff --git a/tests/model_executor/models/voxcpm2/test_talker_state_eviction.py b/tests/model_executor/models/voxcpm2/test_talker_state_eviction.py
new file mode 100644
index 000000000..929e8a36a
--- /dev/null
+++ b/tests/model_executor/models/voxcpm2/test_talker_state_eviction.py
@@ -0,0 +1,120 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Regression tests for VoxCPM2 talker per-request state lifecycle."""
+
+from __future__ import annotations
+
+import pytest
+
+torch = pytest.importorskip("torch")
+
+from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import ( # noqa: E402
+ VoxCPM2TalkerForConditionalGeneration,
+ _RequestState,
+)
+
+
+def _make_bare_talker() -> VoxCPM2TalkerForConditionalGeneration:
+ talker = VoxCPM2TalkerForConditionalGeneration.__new__(VoxCPM2TalkerForConditionalGeneration)
+ talker._active_states = {}
+ talker._current_request_id = None
+ talker._pending_requests = []
+ talker._results_queue = []
+ talker._audio_queue = []
+ talker._deferred_cleanup_ids = set()
+ talker._max_batch_size = 4
+ talker._active_state_warn_threshold = 512
+ talker._active_state_warned = False
+ return talker
+
+
+def _seed_cached_decode(talker, req_id: str) -> _RequestState:
+ state = _RequestState(request_id=req_id)
+ state.prefill_completed = True
+ state.decode_step_count = 5
+ talker._active_states[req_id] = state
+ return state
+
+
+class TestStateEvictionContract:
+ def test_pending_requests_is_not_used_for_eviction(self) -> None:
+ talker = _make_bare_talker()
+
+ cached_ids = [f"req-{i}" for i in range(4)]
+ for rid in cached_ids:
+ _seed_cached_decode(talker, rid)
+
+ walked_so_far = ["req-new", cached_ids[0], cached_ids[1]]
+ talker._pending_requests = [(rid, False, None, 0) for rid in walked_so_far]
+
+ for rid in cached_ids:
+ assert rid in talker._active_states
+ assert talker._active_states[rid].prefill_completed is True
+
+ def test_on_requests_finished_defers_cleanup(self) -> None:
+ talker = _make_bare_talker()
+ _seed_cached_decode(talker, "req-A")
+ _seed_cached_decode(talker, "req-B")
+
+ talker.on_requests_finished({"req-A"})
+
+ assert "req-A" in talker._active_states
+ assert "req-A" in talker._deferred_cleanup_ids
+
+ def test_flush_deferred_cleanup_removes_only_finished(self) -> None:
+ talker = _make_bare_talker()
+ _seed_cached_decode(talker, "req-A")
+ _seed_cached_decode(talker, "req-B")
+ talker.on_requests_finished(["req-A"])
+
+ talker._flush_deferred_cleanup()
+
+ assert "req-A" not in talker._active_states
+ assert "req-B" in talker._active_states
+ assert talker._deferred_cleanup_ids == set()
+
+ def test_current_request_id_cleared_when_matching(self) -> None:
+ talker = _make_bare_talker()
+ _seed_cached_decode(talker, "req-A")
+ talker._current_request_id = "req-A"
+
+ talker.on_requests_finished({"req-A"})
+ talker._flush_deferred_cleanup()
+
+ assert talker._current_request_id is None
+
+ def test_current_request_id_preserved_when_not_finished(self) -> None:
+ talker = _make_bare_talker()
+ _seed_cached_decode(talker, "req-A")
+ _seed_cached_decode(talker, "req-B")
+ talker._current_request_id = "req-B"
+
+ talker.on_requests_finished({"req-A"})
+ talker._flush_deferred_cleanup()
+
+ assert talker._current_request_id == "req-B"
+
+
+class TestLeakWarnGuard:
+ def test_warn_fires_once_over_threshold(self, monkeypatch) -> None:
+ from vllm_omni.model_executor.models.voxcpm2 import voxcpm2_talker as tk
+
+ calls: list[str] = []
+
+ def _capture(msg, *args, **kwargs):
+ calls.append(msg % args if args else msg)
+
+ monkeypatch.setattr(tk.logger, "warning", _capture)
+
+ talker = _make_bare_talker()
+ talker._active_state_warn_threshold = 3
+
+ for i in range(4):
+ talker._active_states[f"seed-{i}"] = _RequestState(request_id=f"seed-{i}")
+
+ talker._get_or_create_state("new-1")
+ talker._get_or_create_state("new-2")
+
+ leak_warnings = [m for m in calls if "cleanup path leak" in m]
+ assert len(leak_warnings) == 1
+ assert talker._active_state_warned is True
diff --git a/tests/model_executor/stage_input_processors/test_glm_image.py b/tests/model_executor/stage_input_processors/test_glm_image.py
new file mode 100644
index 000000000..028d3e7cd
--- /dev/null
+++ b/tests/model_executor/stage_input_processors/test_glm_image.py
@@ -0,0 +1,403 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Unit tests for GLM-Image stage input processor."""
+
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+from vllm_omni.model_executor.stage_input_processors.glm_image import (
+ _first_source_image,
+ _has_source_image,
+ _parse_generated_tokens,
+ _upsample_token_ids,
+ ar2diffusion,
+ compute_max_tokens,
+)
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+# =============================================================================
+# Helpers
+# =============================================================================
+
+
+def _source_output(token_ids: list[int], mm_output: dict | None = None):
+ """Create a minimal AR output mock."""
+ return SimpleNamespace(
+ outputs=[SimpleNamespace(token_ids=token_ids)],
+ multimodal_output=mm_output,
+ )
+
+
+def _stage_with_outputs(outputs):
+ """Create a stage list entry with engine_outputs."""
+ return SimpleNamespace(engine_outputs=outputs)
+
+
+# =============================================================================
+# Tests for _has_source_image
+# =============================================================================
+
+
+class TestHasSourceImage:
+ def test_none_input(self):
+ assert _has_source_image(None) is False
+
+ def test_non_dict_input(self):
+ assert _has_source_image("not_a_dict") is False
+
+ def test_empty_dict(self):
+ assert _has_source_image({}) is False
+
+ def test_image_key_present(self):
+ from PIL import Image
+
+ img = Image.new("RGB", (64, 64))
+ assert _has_source_image({"image": img}) is True
+
+ def test_image_key_none(self):
+ assert _has_source_image({"image": None}) is False
+
+ def test_img2img_key_present(self):
+ from PIL import Image
+
+ img = Image.new("RGB", (64, 64))
+ assert _has_source_image({"img2img": img}) is True
+
+ def test_images_key_list(self):
+ from PIL import Image
+
+ imgs = [Image.new("RGB", (64, 64))]
+ assert _has_source_image({"images": imgs}) is True
+
+ def test_images_key_empty_list(self):
+ assert _has_source_image({"images": []}) is False
+
+ def test_images_key_single(self):
+ from PIL import Image
+
+ img = Image.new("RGB", (64, 64))
+ assert _has_source_image({"images": img}) is True
+
+
+# =============================================================================
+# Tests for _first_source_image
+# =============================================================================
+
+
+class TestFirstSourceImage:
+ def test_none_input(self):
+ assert _first_source_image(None) is None
+
+ def test_non_dict_input(self):
+ assert _first_source_image("not_a_dict") is None
+
+ def test_image_key_single(self):
+ from PIL import Image
+
+ img = Image.new("RGB", (64, 64))
+ assert _first_source_image({"image": img}) is img
+
+ def test_image_key_list(self):
+ from PIL import Image
+
+ img = Image.new("RGB", (64, 64))
+ assert _first_source_image({"image": [img]}) is img
+
+ def test_image_key_empty_list(self):
+ assert _first_source_image({"image": []}) is None
+
+ def test_img2img_key_single(self):
+ from PIL import Image
+
+ img = Image.new("RGB", (64, 64))
+ assert _first_source_image({"img2img": img}) is img
+
+ def test_images_key_list(self):
+ from PIL import Image
+
+ imgs = [Image.new("RGB", (64, 64))]
+ assert _first_source_image({"images": imgs}) is imgs[0]
+
+ def test_images_key_empty_list(self):
+ assert _first_source_image({"images": []}) is None
+
+ def test_images_key_single_not_list(self):
+ from PIL import Image
+
+ img = Image.new("RGB", (64, 64))
+ assert _first_source_image({"images": img}) is img
+
+
+# =============================================================================
+# Tests for compute_max_tokens
+# =============================================================================
+
+
+class TestComputeMaxTokens:
+ def test_t2i_1024x1024(self):
+ # t2i: small_tokens + large_tokens + 1 (EOS)
+ # token_h = 1024/32 = 32, token_w = 1024/32 = 32
+ # large = 32*32 = 1024
+ # ratio = 1.0, small_h = sqrt(1)*16 = 16, small_w = sqrt(1)*16 = 16, small = 256
+ # total = 256 + 1024 + 1 = 1281
+ result = compute_max_tokens(1024, 1024, is_i2i=False)
+ assert result == 1281
+
+ def test_i2i_1024x1024(self):
+ # i2i: large_tokens + 1 (EOS)
+ # large = 32*32 = 1024, total = 1025
+ result = compute_max_tokens(1024, 1024, is_i2i=True)
+ assert result == 1025
+
+ def test_t2i_512x512(self):
+ # token_h = 16, token_w = 16, large = 256
+ # ratio = 1.0, small_h = 16, small_w = 16, small = 256
+ # total = 256 + 256 + 1 = 513
+ result = compute_max_tokens(512, 512, is_i2i=False)
+ assert result == 513
+
+ def test_i2i_512x512(self):
+ # large = 256, total = 257
+ result = compute_max_tokens(512, 512, is_i2i=True)
+ assert result == 257
+
+ def test_non_square_t2i(self):
+ # 1024x512: token_h=32, token_w=16, large=512
+ # ratio = 32/16 = 2.0
+ # small_h = max(1, int(sqrt(2)*16)) = 22, small_w = max(1, int(sqrt(0.5)*16)) = 11
+ # small = 22*11 = 242
+ # total = 242 + 512 + 1 = 755
+ result = compute_max_tokens(1024, 512, is_i2i=False)
+ assert result == 242 + 512 + 1
+
+ def test_custom_factor(self):
+ # factor=16, 512x512: token_h=32, token_w=32, large=1024
+ # ratio=1.0, small_h=8, small_w=8, small=64
+ # total = 64 + 1024 + 1 = 1089
+ result = compute_max_tokens(512, 512, factor=16, is_i2i=False)
+ assert result == 1089
+
+ def test_i2i_smaller_than_t2i(self):
+ t2i = compute_max_tokens(1024, 1024, is_i2i=False)
+ i2i = compute_max_tokens(1024, 1024, is_i2i=True)
+ assert i2i < t2i
+
+
+# =============================================================================
+# Tests for _upsample_token_ids
+# =============================================================================
+
+
+class TestUpsampleTokenIds:
+ def test_2x2_to_4x4(self):
+ tokens = torch.tensor([1, 2, 3, 4])
+ result = _upsample_token_ids(tokens, 2, 2)
+ assert result.shape == (16,) # 4 * 4 = 16 (2x each dim)
+
+ def test_1x1_to_2x2(self):
+ tokens = torch.tensor([7])
+ result = _upsample_token_ids(tokens, 1, 1)
+ assert result.shape == (4,) # 2 * 2
+ assert (result == 7).all()
+
+ def test_4x4_to_8x8(self):
+ tokens = torch.arange(16, dtype=torch.long)
+ result = _upsample_token_ids(tokens, 4, 4)
+ assert result.shape == (64,)
+
+ def test_preserves_dtype(self):
+ tokens = torch.tensor([1, 2, 3, 4], dtype=torch.long)
+ result = _upsample_token_ids(tokens, 2, 2)
+ assert result.dtype == torch.long
+
+
+# =============================================================================
+# Tests for _parse_generated_tokens
+# =============================================================================
+
+
+class TestParseGeneratedTokens:
+ def test_t2i_standard(self):
+ # 1024x1024, t2i: small(256) + large(1024) + EOS
+ # Generate 256 + 1024 + 1 = 1281 tokens, last is EOS (16385)
+ large_tokens = list(range(1024))
+ small_tokens = list(range(1000, 1256))
+ eos = [16385]
+ token_ids = small_tokens + large_tokens + eos
+
+ prior, h, w = _parse_generated_tokens(token_ids, 1024, 1024, is_i2i=False)
+ assert h == 1024
+ assert w == 1024
+ # Prior tokens should be upsampled: 1024 tokens -> 4*1024 = 4096
+ assert prior.shape[0] == 1024 * 4
+
+ def test_i2i_standard(self):
+ # 1024x1024, i2i: large(1024) + EOS
+ large_tokens = list(range(1024))
+ eos = [16385]
+ token_ids = large_tokens + eos
+
+ prior, h, w = _parse_generated_tokens(token_ids, 1024, 1024, is_i2i=True)
+ assert h == 1024
+ assert w == 1024
+ assert prior.shape[0] == 1024 * 4
+
+ def test_i2i_without_eos(self):
+ # i2i without EOS marker
+ large_tokens = list(range(1024))
+ prior, h, w = _parse_generated_tokens(large_tokens, 1024, 1024, is_i2i=True)
+ assert h == 1024
+ assert w == 1024
+
+ def test_i2i_too_few_tokens_raises(self):
+ with pytest.raises(ValueError, match="i2i token parse failed"):
+ _parse_generated_tokens([1, 2, 3], 1024, 1024, is_i2i=True)
+
+ def test_t2i_too_few_tokens_raises(self):
+ # Only large tokens, no small preview
+ large_tokens = list(range(1024))
+ with pytest.raises(ValueError, match="t2i token parse failed"):
+ _parse_generated_tokens(large_tokens, 1024, 1024, is_i2i=False)
+
+ def test_i2i_t2i_style_layout_fallback(self):
+ # i2i but got t2i-style (small + large) tokens
+ small_tokens = list(range(256))
+ large_tokens = list(range(1024))
+ token_ids = small_tokens + large_tokens
+
+ prior, h, w = _parse_generated_tokens(token_ids, 1024, 1024, is_i2i=True)
+ # Should extract the large portion
+ assert h == 1024
+ assert w == 1024
+
+
+# =============================================================================
+# Tests for ar2diffusion
+# =============================================================================
+
+
+class TestAr2Diffusion:
+ def test_basic_t2i(self):
+ """Test basic text-to-image pipeline: AR -> Diffusion."""
+ # 1024x1024 t2i: small(256) + large(1024) + EOS
+ token_ids = list(range(256)) + list(range(1024)) + [16385]
+ stage_list = [_stage_with_outputs([_source_output(token_ids)])]
+
+ prompt = {"prompt": "a cat", "mm_processor_kwargs": {"target_h": 1024, "target_w": 1024}}
+
+ result = ar2diffusion(stage_list, [0], prompt=[prompt])
+ assert len(result) == 1
+ assert result[0]["prompt"] == "a cat"
+ assert result[0]["height"] == 1024
+ assert result[0]["width"] == 1024
+ assert "prior_token_ids" in result[0]["extra"]
+
+ def test_i2i_with_mm_output(self):
+ """Test image-to-image with prior_token_image_ids from AR model."""
+ token_ids = list(range(1024)) + [16385]
+ mm_output = {"prior_token_image_ids": torch.tensor([1, 2, 3])}
+ stage_list = [_stage_with_outputs([_source_output(token_ids, mm_output)])]
+
+ from PIL import Image
+
+ img = Image.new("RGB", (64, 64))
+ prompt = {
+ "prompt": "edit this",
+ "mm_processor_kwargs": {"target_h": 1024, "target_w": 1024},
+ "multi_modal_data": {"image": img},
+ }
+
+ result = ar2diffusion(stage_list, [0], prompt=[prompt])
+ assert len(result) == 1
+ assert result[0]["extra"]["prior_token_image_ids"] is not None
+
+ def test_i2i_detected_via_modalities(self):
+ """Test i2i mode detected via modalities field."""
+ token_ids = list(range(1024)) + [16385]
+ stage_list = [_stage_with_outputs([_source_output(token_ids)])]
+
+ prompt = {
+ "prompt": "edit this",
+ "mm_processor_kwargs": {"target_h": 1024, "target_w": 1024},
+ "modalities": ["img2img"],
+ }
+
+ result = ar2diffusion(stage_list, [0], prompt=[prompt])
+ assert len(result) == 1
+
+ def test_empty_engine_input_source_raises(self):
+ with pytest.raises(ValueError, match="engine_input_source cannot be empty"):
+ ar2diffusion([], [], prompt={})
+
+ def test_invalid_stage_id_raises(self):
+ with pytest.raises(IndexError, match="Invalid stage_id"):
+ ar2diffusion([_stage_with_outputs(None)], [5], prompt={})
+
+ def test_no_outputs_raises(self):
+ with pytest.raises(RuntimeError, match="has no outputs yet"):
+ ar2diffusion([SimpleNamespace(engine_outputs=None)], [0], prompt={})
+
+ def test_default_dimensions(self):
+ """When no height/width in prompt, defaults to 1024x1024."""
+ token_ids = list(range(256)) + list(range(1024)) + [16385]
+ stage_list = [_stage_with_outputs([_source_output(token_ids)])]
+
+ prompt = {"prompt": "test"}
+ result = ar2diffusion(stage_list, [0], prompt=[prompt])
+ assert result[0]["height"] == 1024
+ assert result[0]["width"] == 1024
+
+ def test_requires_multimodal_data_with_pil_image(self):
+ """Test that pil_image is included when requires_multimodal_data=True."""
+ token_ids = list(range(256)) + list(range(1024)) + [16385]
+ stage_list = [_stage_with_outputs([_source_output(token_ids)])]
+
+ from PIL import Image
+
+ img = Image.new("RGB", (64, 64))
+ prompt = {
+ "prompt": "test",
+ "multi_modal_data": {"image": img},
+ }
+
+ result = ar2diffusion(stage_list, [0], prompt=[prompt], requires_multimodal_data=True)
+ assert result[0]["pil_image"] is img
+
+ def test_extra_params_passed_through(self):
+ """Test that seed, num_inference_steps, guidance_scale, negative_prompt are passed."""
+ token_ids = list(range(256)) + list(range(1024)) + [16385]
+ stage_list = [_stage_with_outputs([_source_output(token_ids)])]
+
+ prompt = {
+ "prompt": "test",
+ "seed": 42,
+ "num_inference_steps": 50,
+ "guidance_scale": 7.5,
+ "negative_prompt": "blurry",
+ }
+
+ result = ar2diffusion(stage_list, [0], prompt=[prompt])
+ assert result[0]["seed"] == 42
+ assert result[0]["num_inference_steps"] == 50
+ assert result[0]["guidance_scale"] == 7.5
+ assert result[0]["negative_prompt"] == "blurry"
+
+ def test_batch_requests(self):
+ """Test processing multiple requests in a batch."""
+ tokens1 = list(range(256)) + list(range(1024)) + [16385]
+ tokens2 = list(range(256)) + list(range(1024)) + [16385]
+ stage_list = [_stage_with_outputs([_source_output(tokens1), _source_output(tokens2)])]
+
+ prompts = [
+ {"prompt": "first", "mm_processor_kwargs": {"target_h": 1024, "target_w": 1024}},
+ {"prompt": "second", "mm_processor_kwargs": {"target_h": 512, "target_w": 512}},
+ ]
+
+ result = ar2diffusion(stage_list, [0], prompt=prompts)
+ assert len(result) == 2
+ assert result[0]["prompt"] == "first"
+ assert result[1]["prompt"] == "second"
diff --git a/tests/model_executor/stage_input_processors/test_mimo_audio_llm2code2wav.py b/tests/model_executor/stage_input_processors/test_mimo_audio_llm2code2wav.py
new file mode 100644
index 000000000..1c14ab0cf
--- /dev/null
+++ b/tests/model_executor/stage_input_processors/test_mimo_audio_llm2code2wav.py
@@ -0,0 +1,72 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+
+import logging
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+from vllm_omni.model_executor.stage_input_processors import mimo_audio as sip
+from vllm_omni.model_executor.stage_input_processors.mimo_audio import (
+ MAX_CODE2WAV_TOKENS,
+ llm2code2wav,
+)
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def _make_stage_list(codec_codes: torch.Tensor, request_id: str = "req-0"):
+ """Build a minimal stage_list[0] with a single talker output carrying codec_codes."""
+ output = SimpleNamespace(multimodal_output={"code_predictor_codes": codec_codes})
+ talker_output = SimpleNamespace(outputs=[output], request_id=request_id)
+ stage0 = SimpleNamespace(engine_outputs=[talker_output])
+ return [stage0]
+
+
+def test_llm2code2wav_truncates_when_flat_exceeds_max(caplog):
+ """Flat codec sequences longer than MAX_CODE2WAV_TOKENS must be truncated, not passed through."""
+ # prepend_and_flatten_colmajor produces 36 ids per (8, 4) codec frame:
+ # pad adds one row -> (9, 4) per frame, permuted and flattened.
+ # Pick enough frames to comfortably exceed the cap.
+ frames = (MAX_CODE2WAV_TOKENS // 36) + 100
+ codec_codes = torch.ones(frames, 1, 8, 4, dtype=torch.long)
+
+ stage_list = _make_stage_list(codec_codes, request_id="req-long")
+
+ # Attach caplog's handler directly to the module logger so the warning is
+ # captured regardless of propagation (vllm's logger configuration can
+ # interact badly with caplog.at_level's default root-handler path).
+ target_logger = logging.getLogger("vllm_omni.model_executor.stage_input_processors.mimo_audio")
+ target_logger.addHandler(caplog.handler)
+ prev_level = target_logger.level
+ target_logger.setLevel(logging.WARNING)
+ try:
+ prompts = llm2code2wav(stage_list, engine_input_source=[0])
+ finally:
+ target_logger.removeHandler(caplog.handler)
+ target_logger.setLevel(prev_level)
+
+ assert len(prompts) == 1
+ assert len(prompts[0]["prompt_token_ids"]) == MAX_CODE2WAV_TOKENS
+ assert any("truncating" in rec.getMessage() for rec in caplog.records), (
+ f"Expected a 'truncating' warning; captured records: {[r.getMessage() for r in caplog.records]}"
+ )
+
+
+def test_llm2code2wav_short_sequence_unchanged():
+ """Short codec sequences are returned without truncation."""
+ codec_codes = torch.ones(4, 1, 8, 4, dtype=torch.long)
+ stage_list = _make_stage_list(codec_codes, request_id="req-short")
+
+ prompts = llm2code2wav(stage_list, engine_input_source=[0])
+
+ assert len(prompts) == 1
+ # 4 frames + 1 pad row, flattened col-major → well below the cap
+ assert 0 < len(prompts[0]["prompt_token_ids"]) <= MAX_CODE2WAV_TOKENS
+
+
+def test_llm2code2wav_truncation_boundary_constant_matches_yaml():
+ """MAX_CODE2WAV_TOKENS must match the stage-1 max_model_len in mimo_audio.yaml and end2end.py."""
+ assert sip.MAX_CODE2WAV_TOKENS == 18192
diff --git a/tests/model_executor/stage_input_processors/test_qwen3_omni_streaming_helpers.py b/tests/model_executor/stage_input_processors/test_qwen3_omni_streaming_helpers.py
new file mode 100644
index 000000000..18972c91d
--- /dev/null
+++ b/tests/model_executor/stage_input_processors/test_qwen3_omni_streaming_helpers.py
@@ -0,0 +1,81 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Unit tests for Qwen3-Omni streaming thinker→talker / talker→codec helpers (PR #2581)."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+import vllm_omni.model_executor.stage_input_processors.qwen3_omni as q3
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+@pytest.fixture(autouse=True)
+def _streaming_context() -> SimpleNamespace:
+ return SimpleNamespace(bridge_states={})
+
+
+def test_get_streaming_talker_tokens_first_segment(_streaming_context: SimpleNamespace) -> None:
+ inc_p, inc_o, merged, thinker_in = q3._get_streaming_talker_tokens(
+ "r1",
+ [1, 2],
+ [10, 11],
+ streaming_context=_streaming_context,
+ )
+ 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(
+ "r2",
+ [1, 2, 3, 4],
+ [10, 11, 12, 13],
+ streaming_context=_streaming_context,
+ )
+ 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(
+ "r3",
+ [1, 2, 3, 4, 5, 6],
+ [10],
+ new_prompt_len_snapshot=2,
+ streaming_context=_streaming_context,
+ )
+ 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:
+ q3._get_streaming_talker_tokens("r4", [1], [2], streaming_context=_streaming_context, clear_state=True)
+ state = q3._get_qwen3_streaming_state("r4", _streaming_context).thinker2talker
+ assert state.last_prompt_len == 0
+ assert state.last_output_len == 0
+ assert state.merged_sequences == []
+
+
+def test_get_streaming_codec_delta_len_increments_and_finishes(_streaming_context: SimpleNamespace) -> None:
+ d1 = q3._get_streaming_codec_delta_len(5, "c1", SimpleNamespace(finished=False), _streaming_context)
+ assert d1 == 5
+ d2 = q3._get_streaming_codec_delta_len(8, "c1", SimpleNamespace(finished=False), _streaming_context)
+ assert d2 == 2
+ # After d2, stored cursor is cur_seq_len + 1 == 9; next delta uses new cur_seq_len - 9.
+ d3 = q3._get_streaming_codec_delta_len(10, "c1", SimpleNamespace(finished=True), _streaming_context)
+ assert d3 == 1
+ state = q3._get_qwen3_streaming_state("c1", _streaming_context)
+ assert state.talker2code2wav_last_seq_len == 0
diff --git a/tests/model_executor/stage_input_processors/test_voxcpm_async_chunk.py b/tests/model_executor/stage_input_processors/test_voxcpm_async_chunk.py
new file mode 100644
index 000000000..7d6fc6e74
--- /dev/null
+++ b/tests/model_executor/stage_input_processors/test_voxcpm_async_chunk.py
@@ -0,0 +1,87 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+"""UTs for VoxCPM async-chunk stage input processing."""
+
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+from vllm_omni.model_executor.stage_input_processors.voxcpm import (
+ _VOXCPM_LATENT_MAGIC,
+ _coerce_finished_flag,
+ latent2vae_async_chunk,
+)
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def _request(*, finished):
+ return SimpleNamespace(is_finished=lambda: finished)
+
+
+def _decode_serialized_latent(codes: list[int]) -> torch.Tensor:
+ assert codes[0] == _VOXCPM_LATENT_MAGIC
+ latent_dim = codes[1]
+ time_dim = codes[2]
+ payload = torch.tensor(codes[3:], dtype=torch.int32).to(torch.uint16)
+ return payload.view(torch.bfloat16).to(torch.float32).reshape(1, latent_dim, time_dim)
+
+
+@pytest.mark.parametrize(
+ ("value", "expected"),
+ [
+ (None, False),
+ (False, False),
+ (True, True),
+ (torch.tensor(False), False),
+ (torch.tensor(True), True),
+ ([torch.tensor(True)], True),
+ (([True],), True),
+ ([], False),
+ ],
+)
+def test_coerce_finished_flag(value, expected):
+ assert _coerce_finished_flag(value) is expected
+
+
+def test_latent2vae_async_chunk_serializes_latent_payload():
+ latent = torch.arange(6, dtype=torch.float32).reshape(2, 3)
+
+ payload = latent2vae_async_chunk(
+ transfer_manager=None,
+ pooling_output={"latent_audio_feat": latent},
+ request=_request(finished=False),
+ is_finished=torch.tensor(False),
+ )
+
+ assert payload is not None
+ assert torch.equal(payload["finished"], torch.tensor(False, dtype=torch.bool))
+ recovered = _decode_serialized_latent(payload["code_predictor_codes"])
+ torch.testing.assert_close(recovered, latent.to(torch.bfloat16).to(torch.float32).unsqueeze(0))
+
+
+def test_latent2vae_async_chunk_returns_terminal_marker_without_latent():
+ payload = latent2vae_async_chunk(
+ transfer_manager=None,
+ pooling_output=None,
+ request=_request(finished=[torch.tensor(True)]),
+ is_finished=False,
+ )
+
+ assert payload == {
+ "code_predictor_codes": [],
+ "finished": torch.tensor(True, dtype=torch.bool),
+ }
+
+
+def test_latent2vae_async_chunk_returns_none_for_nonterminal_empty_chunk():
+ payload = latent2vae_async_chunk(
+ transfer_manager=None,
+ pooling_output={"latent_audio_feat": torch.zeros((0,), dtype=torch.float32)},
+ request=_request(finished=False),
+ is_finished=False,
+ )
+
+ assert payload is None
diff --git a/tests/profile/test_omni_torch_profiler.py b/tests/profile/test_omni_torch_profiler.py
new file mode 100644
index 000000000..3920078af
--- /dev/null
+++ b/tests/profile/test_omni_torch_profiler.py
@@ -0,0 +1,582 @@
+# tests/test_omni_torch_profiler.py
+from __future__ import annotations
+
+import gzip
+from dataclasses import dataclass
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from openpyxl import load_workbook
+
+import vllm_omni.profiler.omni_torch_profiler as profiler_mod
+from vllm_omni.profiler.omni_torch_profiler import OmniTorchProfilerWrapper
+
+
+@pytest.fixture(autouse=True)
+def patch_worker_profiler_init(monkeypatch):
+ def fake_init(self, profiler_config):
+ self.profiler_config = profiler_config
+
+ monkeypatch.setattr(
+ profiler_mod.WorkerProfiler,
+ "__init__",
+ fake_init,
+ )
+
+
+@dataclass
+class DummyProfilerConfig:
+ torch_profiler_dir: str
+ torch_profiler_use_gzip: bool = False
+ torch_profiler_record_shapes: bool = True
+ torch_profiler_with_memory: bool = True
+ torch_profiler_with_stack: bool = True
+ torch_profiler_with_flops: bool = False
+ torch_profiler_dump_cuda_time_total: bool = False
+
+
+class FakeEvent:
+ def __init__(
+ self,
+ *,
+ name: str = "aten::mm",
+ count: int = 1,
+ input_shapes=None,
+ stack=None,
+ self_cpu_time_total: float = 10.0,
+ cpu_time_total: float = 12.0,
+ self_cuda_time_total: float = 20.0,
+ cuda_time_total: float = 25.0,
+ self_xpu_time_total: float = 0.0,
+ xpu_time_total: float = 0.0,
+ self_cpu_memory_usage: int = 128,
+ cpu_memory_usage: int = 256,
+ self_cuda_memory_usage: int = 1024,
+ cuda_memory_usage: int = 2048,
+ self_xpu_memory_usage: int = 0,
+ xpu_memory_usage: int = 0,
+ device_type: str = "CUDA",
+ node_id: int = 0,
+ overload_name: str = "",
+ is_async: bool = False,
+ is_legacy: bool = False,
+ ):
+ self.key = name
+ self.name = name
+ self.count = count
+ self.input_shapes = input_shapes if input_shapes is not None else [[2, 2], [2, 2]]
+ self.stack = stack if stack is not None else ["frame_a", "frame_b"]
+ self.self_cpu_time_total = self_cpu_time_total
+ self.cpu_time_total = cpu_time_total
+ self.self_cuda_time_total = self_cuda_time_total
+ self.cuda_time_total = cuda_time_total
+ self.self_xpu_time_total = self_xpu_time_total
+ self.xpu_time_total = xpu_time_total
+ self.self_cpu_memory_usage = self_cpu_memory_usage
+ self.cpu_memory_usage = cpu_memory_usage
+ self.self_cuda_memory_usage = self_cuda_memory_usage
+ self.cuda_memory_usage = cuda_memory_usage
+ self.self_xpu_memory_usage = self_xpu_memory_usage
+ self.xpu_memory_usage = xpu_memory_usage
+ self.device_type = device_type
+ self.node_id = node_id
+ self.overload_name = overload_name
+ self.is_async = is_async
+ self.is_legacy = is_legacy
+
+
+class FakeEventList(list):
+ def table(self, sort_by=None, row_limit=-1):
+ return f"fake_table(sort_by={sort_by}, row_limit={row_limit}, len={len(self)})"
+
+
+class FakeTorchProfiler:
+ def __init__(self, on_trace_ready=None):
+ self.started = False
+ self.stopped = False
+ self.on_trace_ready = on_trace_ready
+ self.exported_traces = []
+ self.exported_stacks = []
+
+ def start(self):
+ self.started = True
+
+ def stop(self):
+ self.stopped = True
+ if self.on_trace_ready is not None:
+ self.on_trace_ready(self)
+
+ def export_chrome_trace(self, path):
+ Path(path).write_text('{"traceEvents": []}')
+ self.exported_traces.append(path)
+
+ def export_stacks(self, path, metric):
+ Path(path).write_text(f"metric={metric}\nstack_line_1\nstack_line_2\n")
+ self.exported_stacks.append((path, metric))
+
+ def key_averages(self, group_by_input_shape=False, group_by_stack_n=0):
+ if group_by_input_shape:
+ return FakeEventList(
+ [
+ FakeEvent(
+ name="aten::bmm",
+ input_shapes=[[4, 8, 16], [4, 16, 32]],
+ )
+ ]
+ )
+ if group_by_stack_n:
+ return FakeEventList(
+ [
+ FakeEvent(
+ name="aten::all_reduce",
+ stack=["python_a", "python_b", "python_c"],
+ )
+ ]
+ )
+ return FakeEventList(
+ [
+ FakeEvent(name="aten::mm"),
+ FakeEvent(name="nccl:all_reduce"),
+ ]
+ )
+
+
+@pytest.fixture
+def fake_config(tmp_path):
+ return DummyProfilerConfig(torch_profiler_dir=str(tmp_path))
+
+
+@pytest.fixture
+def fake_profiler_factory(monkeypatch):
+ created = {}
+
+ def fake_profile(*args, **kwargs):
+ profiler = FakeTorchProfiler(on_trace_ready=kwargs.get("on_trace_ready"))
+ created["profiler"] = profiler
+ created["args"] = args
+ created["kwargs"] = kwargs
+ return profiler
+
+ monkeypatch.setattr(profiler_mod.torch.profiler, "profile", fake_profile)
+ return created
+
+
+@pytest.fixture
+def wrapper(fake_config, fake_profiler_factory):
+ return OmniTorchProfilerWrapper(
+ profiler_config=fake_config,
+ worker_name="worker0",
+ local_rank=0,
+ activities=["CPU", "CUDA"],
+ )
+
+
+def test_set_trace_filename_creates_timestamped_session_dir(wrapper, monkeypatch, tmp_path):
+ class FixedDatetime:
+ @classmethod
+ def now(cls):
+ class _Now:
+ def strftime(self, fmt):
+ return "20260403-034200"
+
+ return _Now()
+
+ monkeypatch.setattr(profiler_mod, "datetime", FixedDatetime)
+
+ wrapper.set_trace_filename("stage_0_llm_1234567890")
+
+ session_dir = Path(wrapper._session_dir)
+ assert session_dir.exists()
+ assert session_dir.parent == tmp_path
+ assert session_dir.name == "20260403-034200_stage_0_llm_1234567890"
+
+
+def test_set_trace_filename_with_full_path_creates_timestamped_leaf(wrapper, monkeypatch, tmp_path):
+ class FixedDatetime:
+ @classmethod
+ def now(cls):
+ class _Now:
+ def strftime(self, fmt):
+ return "20260403-111111"
+
+ return _Now()
+
+ monkeypatch.setattr(profiler_mod, "datetime", FixedDatetime)
+
+ target = tmp_path / "nested" / "stage_x"
+ wrapper.set_trace_filename(str(target))
+
+ session_dir = Path(wrapper._session_dir)
+ assert session_dir.exists()
+ assert session_dir.parent == target.parent
+ assert session_dir.name == "20260403-111111_stage_x"
+
+
+def test_on_trace_ready_exports_trace_json(wrapper):
+ wrapper.set_trace_filename("case_trace")
+
+ wrapper._on_trace_ready(wrapper.profiler)
+
+ trace_path = Path(wrapper._trace_path)
+ assert trace_path.exists()
+ assert trace_path.name == "trace_rank0.json"
+ assert trace_path.read_text() == '{"traceEvents": []}'
+
+
+def test_on_trace_ready_exports_gzip_trace(fake_config, fake_profiler_factory, monkeypatch):
+ fake_config.torch_profiler_use_gzip = True
+
+ wrapper = OmniTorchProfilerWrapper(
+ profiler_config=fake_config,
+ worker_name="worker0",
+ local_rank=0,
+ activities=["CPU", "CUDA"],
+ )
+ wrapper.set_trace_filename("case_gzip")
+
+ def fake_popen(cmd):
+ assert cmd[:2] == ["gzip", "-f"]
+ src = Path(cmd[2])
+ gz_path = src.with_suffix(src.suffix + ".gz")
+ gz_path.write_bytes(gzip.compress(src.read_bytes()))
+ src.unlink()
+
+ class DummyProc:
+ pass
+
+ return DummyProc()
+
+ monkeypatch.setattr(profiler_mod.subprocess, "Popen", fake_popen)
+
+ wrapper._on_trace_ready(wrapper.profiler)
+
+ assert wrapper._trace_path.endswith(".json.gz")
+ gz_path = Path(wrapper._trace_path)
+ assert gz_path.exists()
+ assert gzip.decompress(gz_path.read_bytes()) == b'{"traceEvents": []}'
+
+
+def test_start_enables_memory_history(wrapper, monkeypatch):
+ calls = []
+
+ monkeypatch.setattr(profiler_mod.torch.cuda, "is_available", lambda: True)
+
+ def fake_record_memory_history(*args, **kwargs):
+ calls.append((args, kwargs))
+
+ monkeypatch.setattr(
+ profiler_mod.torch.cuda.memory,
+ "_record_memory_history",
+ fake_record_memory_history,
+ )
+
+ wrapper.set_trace_filename("case_memory_start")
+ wrapper._start()
+
+ assert wrapper.profiler.started is True
+ assert wrapper._memory_history_enabled is True
+ assert len(calls) == 1
+ assert calls[0][1]["enabled"] == "all"
+ assert calls[0][1]["context"] == "all"
+ assert calls[0][1]["stacks"] == "python"
+ assert calls[0][1]["max_entries"] == 100000
+ assert calls[0][1]["clear_history"] is True
+
+
+def test_start_skips_memory_history_when_memory_disabled(fake_config, fake_profiler_factory, monkeypatch):
+ fake_config.torch_profiler_with_memory = False
+
+ wrapper = OmniTorchProfilerWrapper(
+ profiler_config=fake_config,
+ worker_name="worker0",
+ local_rank=0,
+ activities=["CPU", "CUDA"],
+ )
+
+ called = {"n": 0}
+
+ monkeypatch.setattr(profiler_mod.torch.cuda, "is_available", lambda: True)
+
+ def fake_record_memory_history(*args, **kwargs):
+ called["n"] += 1
+
+ monkeypatch.setattr(
+ profiler_mod.torch.cuda.memory,
+ "_record_memory_history",
+ fake_record_memory_history,
+ )
+
+ wrapper.set_trace_filename("case_skip_memory")
+ wrapper._start()
+
+ assert called["n"] == 0
+ assert wrapper._memory_history_enabled is False
+
+
+def test_try_dump_memory_snapshot_writes_pickle(wrapper, monkeypatch):
+ wrapper.set_trace_filename("case_snapshot")
+ wrapper._memory_history_enabled = True
+ wrapper._memory_history_backend = "CUDA"
+ wrapper._memory_history_module = profiler_mod.torch.cuda.memory
+
+ disable_calls = []
+
+ def fake_record_memory_history(*args, **kwargs):
+ disable_calls.append((args, kwargs))
+
+ def fake_dump_snapshot(path):
+ Path(path).write_bytes(b"fake pickle bytes")
+
+ monkeypatch.setattr(
+ profiler_mod.torch.cuda.memory,
+ "_record_memory_history",
+ fake_record_memory_history,
+ )
+ monkeypatch.setattr(
+ profiler_mod.torch.cuda.memory,
+ "_dump_snapshot",
+ fake_dump_snapshot,
+ )
+
+ wrapper._try_dump_memory_snapshot()
+
+ snapshot = Path(wrapper._artifact_paths["memory_snapshot"])
+ assert snapshot.exists()
+ assert snapshot.name == "memory_snapshot_rank0.pickle"
+ assert snapshot.read_bytes() == b"fake pickle bytes"
+ assert wrapper._memory_history_enabled is False
+
+ assert disable_calls[-1][1]["enabled"] is None
+
+
+def test_stop_always_dumps_memory_snapshot_on_success_path(wrapper, monkeypatch):
+ wrapper.set_trace_filename("case_stop")
+
+ record_calls = []
+ dump_calls = []
+
+ monkeypatch.setattr(profiler_mod.torch.cuda, "is_available", lambda: True)
+
+ def fake_record_memory_history(*args, **kwargs):
+ record_calls.append((args, kwargs))
+
+ def fake_dump_snapshot(path):
+ dump_calls.append(path)
+ Path(path).write_bytes(b"snapshot-bytes")
+
+ monkeypatch.setattr(
+ profiler_mod.torch.cuda.memory,
+ "_record_memory_history",
+ fake_record_memory_history,
+ )
+ monkeypatch.setattr(
+ profiler_mod.torch.cuda.memory,
+ "_dump_snapshot",
+ fake_dump_snapshot,
+ )
+
+ wrapper._start()
+ wrapper._stop()
+
+ session_dir = Path(wrapper._session_dir)
+
+ assert wrapper.profiler.started is True
+ assert wrapper.profiler.stopped is True
+ assert (session_dir / "memory_snapshot_rank0.pickle").exists()
+ assert len(dump_calls) == 1
+ assert record_calls[0][1]["enabled"] == "all"
+ assert record_calls[-1][1]["enabled"] is None
+
+
+def test_on_stop_hook_generates_stack_and_excel_artifacts(wrapper):
+ wrapper.set_trace_filename("case_artifacts")
+ wrapper._on_stop_hook()
+
+ session_dir = Path(wrapper._session_dir)
+
+ assert not (session_dir / "ops_summary_rank0.txt").exists()
+ assert not (session_dir / "ops_by_shape_rank0.txt").exists()
+ assert not (session_dir / "ops_by_stack_rank0.txt").exists()
+ assert (session_dir / "stacks_cpu_rank0.txt").exists()
+ assert (session_dir / "stacks_cuda_rank0.txt").exists()
+ assert (session_dir / "ops_rank0.xlsx").exists()
+
+
+def test_excel_contains_expected_sheets(wrapper):
+ wrapper.set_trace_filename("case_excel")
+ wrapper._on_stop_hook()
+
+ xlsx_path = Path(wrapper._session_dir) / "ops_rank0.xlsx"
+ wb = load_workbook(xlsx_path)
+
+ assert "summary" in wb.sheetnames
+ assert "by_shape" in wb.sheetnames
+ assert "by_stack" in wb.sheetnames
+
+
+def test_excel_summary_has_expected_columns(wrapper):
+ wrapper.set_trace_filename("case_excel_columns")
+ wrapper._on_stop_hook()
+
+ xlsx_path = Path(wrapper._session_dir) / "ops_rank0.xlsx"
+ wb = load_workbook(xlsx_path)
+ ws = wb["summary"]
+
+ headers = [cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))]
+ assert "name" in headers
+ assert "count" in headers
+ assert "self_cpu_time_total_us" in headers
+ assert "self_cuda_time_total_us" in headers
+ assert "self_cpu_memory_usage_bytes" in headers
+ assert "self_cuda_memory_usage_bytes" in headers
+ assert "input_shapes" in headers
+ assert "stack" in headers
+
+
+def test_get_results_returns_all_artifact_paths(wrapper, monkeypatch):
+ wrapper.set_trace_filename("case_results")
+
+ monkeypatch.setattr(profiler_mod.torch.cuda, "is_available", lambda: True)
+ monkeypatch.setattr(
+ profiler_mod.torch.cuda.memory,
+ "_record_memory_history",
+ lambda *args, **kwargs: None,
+ )
+ monkeypatch.setattr(
+ profiler_mod.torch.cuda.memory,
+ "_dump_snapshot",
+ lambda path: Path(path).write_bytes(b"snapshot"),
+ )
+
+ wrapper._start()
+ wrapper._stop()
+
+ results = wrapper.get_results()
+
+ assert "trace" in results
+ assert "table" in results
+ assert "session_dir" in results
+ assert "ops" in results
+ assert "memory_snapshot" in results
+ assert Path(results["session_dir"]).exists()
+ assert Path(results["ops"]).exists()
+ assert Path(results["table"]).exists()
+ assert Path(results["table"]).name == "ops_rank0.xlsx"
+ assert Path(results["memory_snapshot"]).exists()
+
+
+def test_start_uses_xpu_memory_history_when_available(wrapper, monkeypatch):
+ calls = []
+
+ def fake_record_memory_history(*args, **kwargs):
+ calls.append((args, kwargs))
+
+ fake_memory_module = SimpleNamespace(
+ _record_memory_history=fake_record_memory_history,
+ )
+ monkeypatch.setattr(
+ wrapper,
+ "_resolve_memory_history_backend",
+ lambda: ("XPU", fake_memory_module),
+ )
+
+ wrapper.set_trace_filename("case_xpu_memory_start")
+ wrapper._start()
+
+ assert wrapper._memory_history_enabled is True
+ assert wrapper._memory_history_backend == "XPU"
+ assert wrapper._memory_history_module is fake_memory_module
+ assert calls[0][1]["enabled"] == "all"
+
+
+def test_start_uses_npu_memory_history_when_available(wrapper, monkeypatch):
+ calls = []
+
+ def fake_record_memory_history(*args, **kwargs):
+ calls.append((args, kwargs))
+
+ fake_memory_module = SimpleNamespace(
+ _record_memory_history=fake_record_memory_history,
+ )
+ monkeypatch.setattr(
+ wrapper,
+ "_resolve_memory_history_backend",
+ lambda: ("NPU", fake_memory_module),
+ )
+
+ wrapper.set_trace_filename("case_npu_memory_start")
+ wrapper._start()
+
+ assert wrapper._memory_history_enabled is True
+ assert wrapper._memory_history_backend == "NPU"
+ assert wrapper._memory_history_module is fake_memory_module
+ assert calls[0][1]["enabled"] == "all"
+
+
+def test_start_skips_memory_history_when_backend_api_missing(wrapper, monkeypatch):
+ fake_memory_module = SimpleNamespace()
+ monkeypatch.setattr(
+ wrapper,
+ "_resolve_memory_history_backend",
+ lambda: ("XPU", fake_memory_module),
+ )
+
+ wrapper.set_trace_filename("case_missing_memory_api")
+ wrapper._start()
+
+ assert wrapper._memory_history_enabled is False
+ assert wrapper._memory_history_backend is None
+ assert wrapper._memory_history_module is None
+
+
+def test_try_dump_memory_snapshot_uses_resolved_backend_module(wrapper):
+ wrapper.set_trace_filename("case_xpu_snapshot")
+ wrapper._memory_history_enabled = True
+ wrapper._memory_history_backend = "XPU"
+
+ calls = []
+
+ def fake_record_memory_history(*args, **kwargs):
+ calls.append((args, kwargs))
+
+ def fake_dump_snapshot(path):
+ Path(path).write_bytes(b"xpu snapshot bytes")
+
+ wrapper._memory_history_module = SimpleNamespace(
+ _record_memory_history=fake_record_memory_history,
+ _dump_snapshot=fake_dump_snapshot,
+ )
+
+ wrapper._try_dump_memory_snapshot()
+
+ snapshot = Path(wrapper._artifact_paths["memory_snapshot"])
+ assert snapshot.exists()
+ assert snapshot.read_bytes() == b"xpu snapshot bytes"
+ assert calls[-1][1]["enabled"] is None
+ assert wrapper._memory_history_enabled is False
+ assert wrapper._memory_history_backend is None
+ assert wrapper._memory_history_module is None
+
+
+def test_event_list_to_rows_contains_expected_fields(wrapper):
+ rows = wrapper._event_list_to_rows(
+ [
+ FakeEvent(
+ name="aten::linear",
+ input_shapes=[[8, 16], [16, 32]],
+ stack=["f1", "f2"],
+ )
+ ]
+ )
+
+ assert len(rows) == 1
+ row = rows[0]
+ assert row["name"] == "aten::linear"
+ assert row["count"] == 1
+ assert row["self_cpu_time_total_us"] == 10.0
+ assert row["self_cuda_time_total_us"] == 20.0
+ assert row["self_cpu_memory_usage_bytes"] == 128
+ assert row["self_cuda_memory_usage_bytes"] == 1024
+ assert "[[8, 16], [16, 32]]" == row["input_shapes"]
+ assert row["stack"] == "f1\nf2"
diff --git a/tests/test_arg_utils.py b/tests/test_arg_utils.py
new file mode 100644
index 000000000..dab5ed687
--- /dev/null
+++ b/tests/test_arg_utils.py
@@ -0,0 +1,353 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Tests for vllm_omni.engine.arg_utils — invariants that must
+hold for the orchestrator/engine/server CLI flag partition."""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, fields
+
+import pytest
+
+from vllm_omni.engine.arg_utils import (
+ SHARED_FIELDS,
+ derive_server_dests_from_vllm_parser,
+ internal_blacklist_keys,
+ orchestrator_args_from_argparse,
+ orchestrator_field_names,
+ split_kwargs,
+)
+
+# ---------------------------------------------------------------------------
+# Fake engine class for unit testing — avoids pulling in the full vllm
+# EngineArgs and its heavy __post_init__ at test time.
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class _FakeEngineArgs:
+ """Stand-in for OmniEngineArgs with a representative subset of fields."""
+
+ model: str = ""
+ stage_id: int = 0
+ max_num_seqs: int = 64
+ gpu_memory_utilization: float = 0.9
+ async_chunk: bool = False # also in OrchestratorArgs → shared
+ log_stats: bool = False # also in OrchestratorArgs → shared
+ stage_configs_path: str | None = None
+
+
+# ============================================================================
+# Invariant 1 — OrchestratorArgs and engine must not ambiguously overlap.
+# ============================================================================
+
+
+def test_no_ambiguous_overlap_with_fake_engine():
+ """OrchestratorArgs ∩ engine fields must be ⊆ SHARED_FIELDS."""
+ orch = orchestrator_field_names()
+ engine = {f.name for f in fields(_FakeEngineArgs)}
+ overlap = orch & engine
+ unexpected = overlap - SHARED_FIELDS
+ assert not unexpected, (
+ f"Fields declared in both OrchestratorArgs and the engine class "
+ f"but not in SHARED_FIELDS: {sorted(unexpected)}. These cause "
+ f"double-routing — either remove the duplicate declaration or add "
+ f"to SHARED_FIELDS if sharing is intentional."
+ )
+
+
+def test_no_ambiguous_overlap_with_real_engine():
+ """Same check, but against the real OmniEngineArgs."""
+ try:
+ from vllm_omni.engine.arg_utils import OmniEngineArgs
+ except Exception as exc:
+ pytest.skip(f"OmniEngineArgs not importable: {exc}")
+
+ orch = orchestrator_field_names()
+ engine = {f.name for f in fields(OmniEngineArgs)}
+ overlap = orch & engine
+ unexpected = overlap - SHARED_FIELDS
+ assert not unexpected, (
+ f"Real OmniEngineArgs has ambiguous overlap with OrchestratorArgs: "
+ f"{sorted(unexpected)}. Update SHARED_FIELDS or remove duplication."
+ )
+
+
+# ============================================================================
+# Invariant 2 — split_kwargs partitions correctly.
+# ============================================================================
+
+
+def test_split_orchestrator_only():
+ """Pure orchestrator fields go to OrchestratorArgs, not engine_kwargs."""
+ raw = {"stage_init_timeout": 500, "worker_backend": "ray"}
+ orch, engine = split_kwargs(raw, engine_cls=_FakeEngineArgs)
+ assert orch.stage_init_timeout == 500
+ assert orch.worker_backend == "ray"
+ assert "stage_init_timeout" not in engine
+ assert "worker_backend" not in engine
+
+
+def test_split_engine_only():
+ """Pure engine fields go to engine_kwargs, not OrchestratorArgs."""
+ raw = {"max_num_seqs": 128, "gpu_memory_utilization": 0.85}
+ orch, engine = split_kwargs(raw, engine_cls=_FakeEngineArgs)
+ assert engine["max_num_seqs"] == 128
+ assert engine["gpu_memory_utilization"] == 0.85
+ # These fields don't exist on OrchestratorArgs at all.
+
+
+def test_split_shared_fields_go_to_both():
+ """Fields in SHARED_FIELDS are copied to both buckets."""
+ raw = {"model": "Qwen/Qwen2.5-Omni-7B", "log_stats": True}
+ orch, engine = split_kwargs(raw, engine_cls=_FakeEngineArgs)
+ assert orch.log_stats is True
+ assert engine["model"] == "Qwen/Qwen2.5-Omni-7B"
+ assert engine["log_stats"] is True
+
+
+def test_split_drops_unclassified():
+ """Unclassified fields (uvicorn/server) are dropped silently."""
+ raw = {
+ "max_num_seqs": 64, # engine
+ "host": "0.0.0.0", # unclassified (server)
+ "port": 8091, # unclassified (server)
+ "ssl_keyfile": "key.pem", # unclassified (server)
+ }
+ orch, engine = split_kwargs(raw, engine_cls=_FakeEngineArgs)
+ assert engine == {"max_num_seqs": 64}
+ assert "host" not in engine
+ assert "port" not in engine
+ assert "ssl_keyfile" not in engine
+
+
+def test_split_mixed_real_world():
+ """End-to-end: raw CLI kwargs with all three classes present."""
+ raw = {
+ # orchestrator
+ "stage_init_timeout": 400,
+ "deploy_config": "/tmp/deploy.yaml",
+ "worker_backend": "multi_process",
+ "async_chunk": True,
+ # engine
+ "max_num_seqs": 32,
+ "gpu_memory_utilization": 0.8,
+ # shared
+ "model": "Qwen/Qwen3-Omni",
+ "log_stats": False,
+ # server / unclassified
+ "host": "0.0.0.0",
+ "port": 8091,
+ "api_key": "secret",
+ # None values
+ "ray_address": None,
+ }
+ orch, engine = split_kwargs(raw, engine_cls=_FakeEngineArgs)
+
+ # Orchestrator side
+ assert orch.stage_init_timeout == 400
+ assert orch.deploy_config == "/tmp/deploy.yaml"
+ assert orch.worker_backend == "multi_process"
+ assert orch.async_chunk is True
+ assert orch.log_stats is False # shared, read from raw
+ assert orch.ray_address is None # default preserved
+
+ # Engine side
+ assert engine["max_num_seqs"] == 32
+ assert engine["gpu_memory_utilization"] == 0.8
+ assert engine["model"] == "Qwen/Qwen3-Omni"
+ assert engine["log_stats"] is False
+ assert "host" not in engine
+ assert "port" not in engine
+ assert "api_key" not in engine
+ # 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
+
+
+# ============================================================================
+# Invariant 3 — user-typed unclassifiable flags warn (don't fail silently).
+# ============================================================================
+
+
+def test_user_typed_unclassified_warns(caplog):
+ """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"):
+ 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]}"
+ )
+
+
+def test_unclassified_without_user_typed_silent(caplog):
+ """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"):
+ 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)
+
+
+# ============================================================================
+# Invariant 4 — CLI flag classification completeness.
+# Catches new flags added without updating OrchestratorArgs or OmniEngineArgs.
+# ============================================================================
+
+
+def test_all_omni_cli_flags_classified():
+ """Every vllm-omni-added CLI flag must be classifiable.
+
+ Runs ``OmniServeCommand.subparser_init`` and checks that every new
+ argument (compared to vllm's base parser) is either:
+ - a field on OrchestratorArgs, OR
+ - a field on OmniEngineArgs, OR
+ - in SHARED_FIELDS
+ """
+ try:
+ from vllm.utils.argparse_utils import FlexibleArgumentParser
+
+ from vllm_omni.engine.arg_utils import OmniEngineArgs
+ from vllm_omni.entrypoints.cli.serve import OmniServeCommand
+ except Exception as exc:
+ pytest.skip(f"Cannot build parser in this environment: {exc}")
+
+ # Build the serve parser
+ root = FlexibleArgumentParser()
+ subparsers = root.add_subparsers()
+ cmd = OmniServeCommand()
+ try:
+ parser = cmd.subparser_init(subparsers)
+ except Exception as exc:
+ pytest.skip(f"subparser_init failed (dev env issue): {exc}")
+
+ all_dests = {a.dest for a in parser._actions if a.dest and a.dest not in {"help", "model_tag"}}
+
+ orch = orchestrator_field_names()
+ engine = {f.name for f in fields(OmniEngineArgs)}
+ server_derived = derive_server_dests_from_vllm_parser()
+
+ unclassified = all_dests - orch - engine - SHARED_FIELDS - server_derived
+ # Some argparse-internal dests (suppressed, private) may not match —
+ # filter those out.
+ unclassified = {d for d in unclassified if not d.startswith("_")}
+
+ assert not unclassified, (
+ f"These CLI flags are not classified as "
+ f"orchestrator/engine/shared/server: {sorted(unclassified)}. "
+ f"Add them to OrchestratorArgs (if consumed by orchestrator), "
+ f"OmniEngineArgs (if consumed by per-stage engine), or the known-server "
+ f"allowlist (if they're vllm frontend flags). "
+ f"If intentional (e.g. a new CLI-only flag that doesn't map to either "
+ f"dataclass), add it to a KNOWN_UNROUTED allowlist."
+ )
+
+
+# ============================================================================
+# argparse interop (Phase 3).
+# ============================================================================
+
+
+def test_orchestrator_args_from_argparse():
+ """Can build OrchestratorArgs from an argparse.Namespace."""
+ import argparse
+
+ ns = argparse.Namespace(
+ stage_init_timeout=500,
+ deploy_config="/tmp/x.yaml",
+ max_num_seqs=64, # engine field — ignored
+ host="0.0.0.0", # server field — ignored
+ )
+ orch = orchestrator_args_from_argparse(ns)
+ assert orch.stage_init_timeout == 500
+ assert orch.deploy_config == "/tmp/x.yaml"
+ assert orch.worker_backend == "multi_process" # default
+
+
+def test_derive_server_dests_returns_frozenset():
+ """Server-dest derivation returns a frozenset (possibly empty)."""
+ result = derive_server_dests_from_vllm_parser()
+ assert isinstance(result, frozenset)
+
+
+# ============================================================================
+# internal_blacklist_keys — single source of truth for per-stage forwarding.
+# ============================================================================
+
+
+def test_internal_blacklist_keys_derived_from_orchestrator():
+ """Blacklist is exactly OrchestratorArgs fields minus SHARED_FIELDS.
+
+ This function replaces the old hardcoded INTERNAL_STAGE_OVERRIDE_KEYS
+ frozenset. Asserts the contract so future changes to OrchestratorArgs
+ automatically propagate to the blacklist.
+ """
+ blacklist = internal_blacklist_keys()
+ assert blacklist == orchestrator_field_names() - SHARED_FIELDS
+ # Spot-check expected entries
+ assert "stage_init_timeout" in blacklist
+ assert "deploy_config" in blacklist
+ assert "async_chunk" in blacklist
+ # Shared fields must NOT appear — they flow to both orchestrator and engine
+ assert "model" not in blacklist
+ assert "log_stats" not in blacklist
+
+
+# ============================================================================
+# Boundary value analysis — edge cases around split_kwargs.
+# ============================================================================
+
+
+def test_split_empty_kwargs():
+ """Empty kwargs yields default OrchestratorArgs and empty engine dict."""
+ orch, engine = split_kwargs({}, engine_cls=_FakeEngineArgs)
+ assert orch.stage_init_timeout == 300 # dataclass default
+ assert orch.worker_backend == "multi_process" # dataclass default
+ assert engine == {}
+
+
+def test_split_all_none_values_preserved_on_orchestrator():
+ """None values for orchestrator fields are kept (represents 'not set')."""
+ raw = {"ray_address": None, "deploy_config": None, "max_num_seqs": None}
+ orch, engine = split_kwargs(raw, engine_cls=_FakeEngineArgs)
+ assert orch.ray_address is None
+ assert orch.deploy_config is None
+ # Engine-side None still passes through; caller decides semantics downstream.
+ assert engine.get("max_num_seqs") is None
+
+
+def test_split_user_typed_with_empty_kwargs_no_warn(caplog):
+ """user_typed non-empty but kwargs empty — no warnings emitted."""
+ with caplog.at_level(logging.WARNING, logger="vllm_omni.engine.arg_utils"):
+ split_kwargs({}, engine_cls=_FakeEngineArgs, user_typed={"nothing"})
+ assert not caplog.records
+
+
+def test_ambiguous_field_strict_raises():
+ """strict=True raises ValueError on overlap outside SHARED_FIELDS."""
+
+ # deploy_config is on OrchestratorArgs; declaring it on the engine class
+ # too (without adding to SHARED_FIELDS) creates an ambiguous route.
+ @dataclass
+ class _AmbiguousEngine:
+ deploy_config: str | None = None
+
+ with pytest.raises(ValueError, match="both OrchestratorArgs and"):
+ split_kwargs({"deploy_config": "x"}, engine_cls=_AmbiguousEngine, strict=True)
+
+
+def test_ambiguous_field_non_strict_routes_to_orchestrator(caplog):
+ """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"):
+ 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)
diff --git a/tests/test_config_factory.py b/tests/test_config_factory.py
index e284de48d..6cf8dcd40 100644
--- a/tests/test_config_factory.py
+++ b/tests/test_config_factory.py
@@ -4,12 +4,26 @@
Unit tests for StageConfigFactory and related classes.
"""
+from dataclasses import dataclass
+from pathlib import Path
+
+import pytest
+
from vllm_omni.config.stage_config import (
+ _EXECUTION_TYPE_TO_SCHEDULER,
+ _PIPELINE_REGISTRY,
ModelPipeline,
+ PipelineConfig,
StageConfig,
StageConfigFactory,
+ StageExecutionType,
+ StagePipelineConfig,
StageType,
+ build_stage_runtime_overrides,
+ register_pipeline,
+ strip_parent_engine_args,
)
+from vllm_omni.engine.arg_utils import SHARED_FIELDS, internal_blacklist_keys
class TestStageType:
@@ -241,8 +255,9 @@ class TestStageConfigFactory:
def test_default_diffusion_with_parallel_config(self):
"""Test diffusion config calculates devices from parallel_config."""
+ @dataclass
class MockParallelConfig:
- world_size = 4
+ world_size: int = 4
kwargs = {
"parallel_config": MockParallelConfig(),
@@ -270,7 +285,7 @@ class TestStageConfigFactory:
stage = StageConfig(stage_id=0, model_stage="thinker", input_sources=[])
cli_overrides = {
"gpu_memory_utilization": 0.9, # Well-known param
- "custom_engine_flag": True, # Not in _INTERNAL_KEYS, so forwarded
+ "custom_engine_flag": True, # Not orchestrator-owned, so forwarded
}
overrides = StageConfigFactory._merge_cli_overrides(stage, cli_overrides)
@@ -311,6 +326,56 @@ class TestStageConfigFactory:
assert "batch_timeout" not in overrides
+class TestStageResolutionHelpers:
+ """Tests for shared stage override / filtering helpers."""
+
+ def test_build_stage_runtime_overrides_ignores_other_stage_and_internal_keys(self):
+ # Pass the same filter set the function uses by default
+ # (orchestrator-only fields plus SHARED_FIELDS so ``model`` is
+ # treated as not-per-stage-overridable).
+ overrides = build_stage_runtime_overrides(
+ 0,
+ {
+ "gpu_memory_utilization": 0.5,
+ "stage_0_gpu_memory_utilization": 0.9,
+ "stage_1_gpu_memory_utilization": 0.1,
+ "stage_0_model": "should_be_ignored",
+ "parallel_config": {"world_size": 2},
+ },
+ internal_keys=internal_blacklist_keys() | SHARED_FIELDS,
+ )
+
+ assert overrides["gpu_memory_utilization"] == 0.9
+ assert "model" not in overrides
+ assert "parallel_config" not in overrides
+
+ def test_strip_parent_engine_args_reports_only_surprising_parent_overrides(self):
+ from dataclasses import fields as dc_fields
+
+ from vllm.engine.arg_utils import EngineArgs
+
+ parent_fields = {f.name: f for f in dc_fields(EngineArgs)}
+ filtered, overridden = strip_parent_engine_args(
+ {
+ "model": "some/model",
+ "stage_configs_path": "/tmp/stages.yaml",
+ "tensor_parallel_size": 4,
+ "worker_extension_cls": "some.Extension",
+ "custom_pipeline_args": {"pipeline_class": "demo.Pipeline"},
+ },
+ parent_fields=parent_fields,
+ keep_keys={"worker_extension_cls"},
+ strip_keys={"stage_configs_path"},
+ no_warn_keys={"model"},
+ )
+
+ assert filtered == {
+ "worker_extension_cls": "some.Extension",
+ "custom_pipeline_args": {"pipeline_class": "demo.Pipeline"},
+ }
+ assert overridden == ["tensor_parallel_size"]
+
+
class TestPipelineYamlParsing:
"""Tests for pipeline YAML file parsing (@ZJY0516)."""
@@ -609,16 +674,647 @@ stages:
assert pipeline.async_chunk is False
-class TestArchitectureFallback:
- """Tests for architecture-based model detection fallback."""
+class TestPipelineDiscovery:
+ """Tests for the central pipeline registry (``pipeline_registry._VLLM_OMNI_PIPELINES``)."""
- def test_architecture_models_mapping_exists(self):
- """Test that _ARCHITECTURE_MODELS contains expected entries."""
- assert "MiMoAudioForConditionalGeneration" in StageConfigFactory._ARCHITECTURE_MODELS
- assert StageConfigFactory._ARCHITECTURE_MODELS["MiMoAudioForConditionalGeneration"] == "mimo_audio"
- assert "HunyuanImage3ForCausalMM" in StageConfigFactory._ARCHITECTURE_MODELS
- assert StageConfigFactory._ARCHITECTURE_MODELS["HunyuanImage3ForCausalMM"] == "hunyuan_image3"
+ def test_registry_has_known_models(self):
+ """Built-in pipelines are lazy-loaded from the central declaration
+ on first access; no eager import or discovery walk needed."""
+ # ``in`` triggers the lazy-map lookup without forcing a load.
+ assert "qwen2_5_omni" in _PIPELINE_REGISTRY
+ assert "qwen3_omni_moe" in _PIPELINE_REGISTRY
+ assert "qwen3_tts" in _PIPELINE_REGISTRY
- def test_mimo_audio_in_pipeline_models(self):
- """Test that mimo_audio is registered in PIPELINE_MODELS."""
- assert "mimo_audio" in StageConfigFactory.PIPELINE_MODELS
+ def test_registry_loads_pipeline_on_getitem(self):
+ """Looking up a registered model_type returns the matching PipelineConfig."""
+ pipeline = _PIPELINE_REGISTRY["qwen3_omni_moe"]
+ assert pipeline.model_type == "qwen3_omni_moe"
+ assert len(pipeline.stages) == 3 # thinker + talker + code2wav
+
+ def test_registry_returns_none_for_unknown(self):
+ """Unknown model_types aren't found; ``get()`` returns None."""
+ assert "definitely_not_a_real_model" not in _PIPELINE_REGISTRY
+ assert _PIPELINE_REGISTRY.get("definitely_not_a_real_model") is None
+
+ def test_pipeline_config_supports_hf_architectures(self):
+ """PipelineConfig accepts hf_architectures for HF-arch fallback
+ (replaces the old _ARCHITECTURE_MODELS dict)."""
+ p = PipelineConfig(
+ model_type="custom_collide",
+ hf_architectures=("SomeCollidingArch",),
+ )
+ assert p.hf_architectures == ("SomeCollidingArch",)
+
+
+class TestStagePipelineConfig:
+ def test_frozen(self):
+ s = StagePipelineConfig(stage_id=0, model_stage="a")
+ with pytest.raises(AttributeError):
+ s.model_stage = "changed"
+
+ def test_defaults(self):
+ s = StagePipelineConfig(stage_id=0, model_stage="a")
+ assert s.execution_type == StageExecutionType.LLM_AR
+ assert s.input_sources == ()
+ assert s.final_output is False
+ assert s.sampling_constraints == {}
+ assert s.engine_output_type is None
+
+
+class TestPipelineConfigNew:
+ def test_frozen(self):
+ p = PipelineConfig(model_type="t", model_arch="A")
+ with pytest.raises(AttributeError):
+ p.model_type = "changed"
+
+ def test_validate_valid(self):
+ p = PipelineConfig(
+ model_type="t",
+ model_arch="A",
+ stages=(
+ StagePipelineConfig(stage_id=0, model_stage="a"),
+ StagePipelineConfig(stage_id=1, model_stage="b", input_sources=(0,)),
+ ),
+ )
+ assert p.validate() == []
+
+ def test_validate_no_stages(self):
+ p = PipelineConfig(model_type="t", model_arch="A")
+ assert any("no stages" in e.lower() for e in p.validate())
+
+ def test_get_scheduler_cls(self):
+ p = PipelineConfig(
+ model_type="t",
+ model_arch="A",
+ stages=(
+ StagePipelineConfig(stage_id=0, model_stage="a", execution_type=StageExecutionType.LLM_AR),
+ StagePipelineConfig(
+ stage_id=1, model_stage="b", execution_type=StageExecutionType.LLM_GENERATION, input_sources=(0,)
+ ),
+ ),
+ )
+ assert "OmniARScheduler" in p.get_scheduler_cls(0)
+ assert "OmniGenerationScheduler" in p.get_scheduler_cls(1)
+
+
+class TestExecutionTypeToScheduler:
+ def test_all_types_mapped(self):
+ for et in StageExecutionType:
+ assert et in _EXECUTION_TYPE_TO_SCHEDULER
+
+
+class TestPipelineRegistry:
+ def test_register_and_lookup(self):
+ p = PipelineConfig(
+ model_type="__test_only__",
+ model_arch="A",
+ stages=(StagePipelineConfig(stage_id=0, model_stage="a"),),
+ )
+ register_pipeline(p)
+ assert _PIPELINE_REGISTRY["__test_only__"] is p
+ del _PIPELINE_REGISTRY["__test_only__"]
+
+
+class TestDeployConfigLoading:
+ def test_load_deploy_config(self):
+ from pathlib import Path
+
+ from vllm_omni.config.stage_config import load_deploy_config
+
+ deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
+ if not deploy_path.exists():
+ pytest.skip("Deploy config not found")
+
+ deploy = load_deploy_config(deploy_path)
+ assert len(deploy.stages) == 3
+ assert deploy.async_chunk is True
+ assert deploy.connectors is not None
+ assert deploy.platforms is not None
+
+ def test_merge_pipeline_deploy(self):
+ from pathlib import Path
+
+ import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
+ from vllm_omni.config.stage_config import load_deploy_config, merge_pipeline_deploy
+
+ pipeline = _PIPELINE_REGISTRY["qwen3_omni_moe"]
+ deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
+ if not deploy_path.exists():
+ pytest.skip("Deploy config not found")
+
+ deploy = load_deploy_config(deploy_path)
+ stages = merge_pipeline_deploy(pipeline, deploy)
+
+ assert len(stages) == 3
+ s0 = stages[0]
+ assert s0.model_stage == "thinker"
+ assert s0.yaml_engine_args["model_arch"] == "Qwen3OmniMoeForConditionalGeneration"
+ assert s0.yaml_engine_args["engine_output_type"] == "latent"
+ assert s0.yaml_extras["default_sampling_params"]["detokenize"] is True
+
+
+class TestQwen3OmniPipeline:
+ def test_registered(self):
+ import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
+
+ p = _PIPELINE_REGISTRY.get("qwen3_omni_moe")
+ assert p is not None
+ assert p.model_arch == "Qwen3OmniMoeForConditionalGeneration"
+ assert len(p.stages) == 3
+ assert p.validate() == []
+
+ def test_thinker(self):
+ import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
+
+ s = _PIPELINE_REGISTRY["qwen3_omni_moe"].get_stage(0)
+ assert s.model_stage == "thinker"
+ assert s.execution_type == StageExecutionType.LLM_AR
+ assert s.owns_tokenizer is True
+ assert s.engine_output_type == "latent"
+ assert s.sampling_constraints["detokenize"] is True
+
+ def test_talker(self):
+ import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
+
+ s = _PIPELINE_REGISTRY["qwen3_omni_moe"].get_stage(1)
+ assert s.input_sources == (0,)
+ assert s.sampling_constraints["stop_token_ids"] == [2150]
+ assert s.custom_process_input_func is not None
+ assert s.custom_process_next_stage_input_func is not None
+
+ def test_code2wav(self):
+ import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
+
+ s = _PIPELINE_REGISTRY["qwen3_omni_moe"].get_stage(2)
+ assert s.execution_type == StageExecutionType.LLM_GENERATION
+ assert s.final_output_type == "audio"
+ assert s.custom_process_input_func is not None
+
+
+class TestQwen2_5OmniPipeline:
+ def test_registered(self):
+ import vllm_omni.model_executor.models.qwen2_5_omni.pipeline # noqa: F401
+
+ p = _PIPELINE_REGISTRY.get("qwen2_5_omni")
+ assert p is not None
+ assert p.model_arch == "Qwen2_5OmniForConditionalGeneration"
+ assert len(p.stages) == 3
+ assert p.validate() == []
+
+ def test_thinker(self):
+ import vllm_omni.model_executor.models.qwen2_5_omni.pipeline # noqa: F401
+
+ s = _PIPELINE_REGISTRY["qwen2_5_omni"].get_stage(0)
+ assert s.model_stage == "thinker"
+ assert s.execution_type == StageExecutionType.LLM_AR
+ assert s.owns_tokenizer is True
+ assert s.engine_output_type == "latent"
+ assert s.requires_multimodal_data is True
+
+ def test_talker(self):
+ import vllm_omni.model_executor.models.qwen2_5_omni.pipeline # noqa: F401
+
+ s = _PIPELINE_REGISTRY["qwen2_5_omni"].get_stage(1)
+ assert s.input_sources == (0,)
+ assert s.sampling_constraints["stop_token_ids"] == [8294]
+ assert s.custom_process_input_func is not None
+
+ def test_code2wav(self):
+ import vllm_omni.model_executor.models.qwen2_5_omni.pipeline # noqa: F401
+
+ s = _PIPELINE_REGISTRY["qwen2_5_omni"].get_stage(2)
+ assert s.execution_type == StageExecutionType.LLM_GENERATION
+ assert s.final_output_type == "audio"
+ assert s.engine_output_type == "audio"
+
+
+class TestQwen3TTSPipeline:
+ def test_registered(self):
+ import vllm_omni.model_executor.models.qwen3_tts.pipeline # noqa: F401
+
+ p = _PIPELINE_REGISTRY.get("qwen3_tts")
+ assert p is not None
+ assert p.model_arch == "Qwen3TTSTalkerForConditionalGeneration"
+ assert len(p.stages) == 2
+ assert p.validate() == []
+
+ def test_talker_stage(self):
+ import vllm_omni.model_executor.models.qwen3_tts.pipeline # noqa: F401
+
+ s = _PIPELINE_REGISTRY["qwen3_tts"].get_stage(0)
+ assert s.model_stage == "qwen3_tts"
+ assert s.execution_type == StageExecutionType.LLM_AR
+ assert s.owns_tokenizer is True
+ assert s.engine_output_type == "latent"
+ assert s.sampling_constraints["stop_token_ids"] == [2150]
+ # Stage 0 inherits the pipeline-level model_arch
+ assert s.model_arch is None
+
+ def test_code2wav_stage_has_per_stage_model_arch(self):
+ import vllm_omni.model_executor.models.qwen3_tts.pipeline # noqa: F401
+
+ s = _PIPELINE_REGISTRY["qwen3_tts"].get_stage(1)
+ assert s.execution_type == StageExecutionType.LLM_GENERATION
+ assert s.final_output_type == "audio"
+ assert s.engine_output_type == "audio"
+ # Per-stage model_arch override (different from pipeline-level talker)
+ assert s.model_arch == "Qwen3TTSCode2Wav"
+ # tts_args is passed through via extras
+ assert s.extras["tts_args"]["max_instructions_length"] == 500
+
+ def test_per_stage_model_arch_flows_through_merge(self, tmp_path):
+ """Verify the new ps.model_arch override survives merge_pipeline_deploy."""
+ 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 = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_tts.yaml"
+ if not deploy_path.exists():
+ pytest.skip("qwen3_tts deploy yaml not found")
+
+ deploy = load_deploy_config(deploy_path)
+ pipeline = _PIPELINE_REGISTRY["qwen3_tts"]
+ stages = merge_pipeline_deploy(pipeline, deploy)
+
+ # Stage 0 inherits pipeline-level model_arch
+ assert stages[0].yaml_engine_args["model_arch"] == "Qwen3TTSTalkerForConditionalGeneration"
+ # Stage 1 uses its per-stage override
+ assert stages[1].yaml_engine_args["model_arch"] == "Qwen3TTSCode2Wav"
+
+ def test_subtalker_sampling_params_deep_merge_preserves_base_keys(self):
+ """Verify subtalker sampling params participate in stage deep-merge."""
+ from vllm_omni.config.stage_config import _deep_merge_stage
+
+ base = {
+ "stage_id": 0,
+ "subtalker_sampling_params": {
+ "do_sample": True,
+ "temperature": 0.9,
+ "top_k": 50,
+ "top_p": 1.0,
+ },
+ }
+ overlay = {
+ "stage_id": 0,
+ "subtalker_sampling_params": {
+ "temperature": 0.7,
+ "top_k": 32,
+ },
+ }
+
+ merged = _deep_merge_stage(base, overlay)
+
+ assert merged["subtalker_sampling_params"] == {
+ "do_sample": True,
+ "temperature": 0.7,
+ "top_k": 32,
+ "top_p": 1.0,
+ }
+
+
+class TestBaseConfigInheritance:
+ """Test deploy YAML base_config inheritance."""
+
+ def test_ci_inherits_from_main(self):
+ from tests.helpers.stage_config import get_deploy_config_path
+ from vllm_omni.config.stage_config import load_deploy_config
+
+ ci_path = Path(get_deploy_config_path("ci/qwen3_omni_moe.yaml"))
+ if not ci_path.exists():
+ pytest.skip("CI deploy config not found")
+
+ deploy = load_deploy_config(ci_path)
+ assert len(deploy.stages) == 3
+ # CI overrides
+ assert deploy.stages[0].engine_extras.get("load_format") == "dummy"
+ assert deploy.stages[0].max_num_seqs == 5
+ # Inherited from base
+ assert deploy.stages[0].gpu_memory_utilization == 0.9
+ assert deploy.connectors is not None
+ assert "connector_of_shared_memory" in deploy.connectors
+ # CI overlay explicitly sets async_chunk: False (see
+ # tests.helpers.stage_config._CI_OVERLAYS and PR #2383 discussion). Overlay
+ # bool overrides base even when the base yaml has async_chunk: true.
+ assert deploy.async_chunk is False
+
+ def test_ci_sampling_merge(self):
+ from tests.helpers.stage_config import get_deploy_config_path
+ from vllm_omni.config.stage_config import load_deploy_config
+
+ ci_path = Path(get_deploy_config_path("ci/qwen3_omni_moe.yaml"))
+ if not ci_path.exists():
+ pytest.skip("CI deploy config not found")
+
+ deploy = load_deploy_config(ci_path)
+ s0 = deploy.stages[0].default_sampling_params
+ # CI overrides max_tokens
+ assert s0["max_tokens"] == 150
+ # Inherited from base
+ assert s0["temperature"] == 0.4
+ assert s0["seed"] == 42
+
+ def test_pure_inheritance_overlay(self, tmp_path):
+ """An overlay with only ``base_config`` inherits everything."""
+ from vllm_omni.config.stage_config import load_deploy_config
+
+ base = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
+ if not base.exists():
+ pytest.skip("Base deploy config not found")
+
+ overlay = tmp_path / "overlay.yaml"
+ overlay.write_text(f"base_config: {base}\n")
+
+ deploy = load_deploy_config(overlay)
+ assert len(deploy.stages) == 3
+ assert deploy.stages[0].gpu_memory_utilization == 0.9
+
+ def test_single_field_overlay(self, tmp_path):
+ """An overlay overriding one stage field merges with the base."""
+ from vllm_omni.config.stage_config import load_deploy_config
+
+ base = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
+ if not base.exists():
+ pytest.skip("Base deploy config not found")
+
+ overlay = tmp_path / "overlay.yaml"
+ overlay.write_text(f"base_config: {base}\nstages:\n - stage_id: 2\n max_num_batched_tokens: 1000000\n")
+
+ deploy = load_deploy_config(overlay)
+ assert deploy.stages[2].max_num_batched_tokens == 1000000
+ # Rest inherited
+ assert deploy.stages[0].gpu_memory_utilization == 0.9
+
+
+class TestPlatformOverrides:
+ """Test platform-specific deploy config overrides."""
+
+ def test_npu_overrides(self):
+ from pathlib import Path
+
+ from vllm_omni.config.stage_config import _apply_platform_overrides, load_deploy_config
+
+ deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
+ if not deploy_path.exists():
+ pytest.skip("Deploy config not found")
+
+ deploy = load_deploy_config(deploy_path)
+ deploy = _apply_platform_overrides(deploy, platform="npu")
+
+ assert deploy.stages[0].gpu_memory_utilization == 0.6
+ assert deploy.stages[0].tensor_parallel_size == 2
+ assert deploy.stages[0].devices == "0,1"
+ # Stage 2 unaffected fields stay at base
+ assert deploy.stages[2].enforce_eager is True
+
+ def test_xpu_overrides(self):
+ from pathlib import Path
+
+ from vllm_omni.config.stage_config import _apply_platform_overrides, load_deploy_config
+
+ deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
+ if not deploy_path.exists():
+ pytest.skip("Deploy config not found")
+
+ deploy = load_deploy_config(deploy_path)
+ deploy = _apply_platform_overrides(deploy, platform="xpu")
+
+ assert deploy.stages[0].tensor_parallel_size == 4
+ assert deploy.stages[0].devices == "0,1,2,3"
+ assert deploy.stages[0].engine_extras.get("max_cudagraph_capture_size") == 0
+
+ def test_unknown_platform_noop(self):
+ from pathlib import Path
+
+ from vllm_omni.config.stage_config import _apply_platform_overrides, load_deploy_config
+
+ deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
+ if not deploy_path.exists():
+ pytest.skip("Deploy config not found")
+
+ deploy = load_deploy_config(deploy_path)
+ original_mem = deploy.stages[0].gpu_memory_utilization
+ deploy = _apply_platform_overrides(deploy, platform="unknown_hw")
+ assert deploy.stages[0].gpu_memory_utilization == original_mem
+
+ def test_platforms_deep_merge_inheritance(self, tmp_path):
+ """Overlay's platforms: block layers onto base's, per-stage."""
+ from vllm_omni.config.stage_config import _apply_platform_overrides, load_deploy_config
+
+ base = tmp_path / "base.yaml"
+ base.write_text(
+ "stages:\n"
+ " - stage_id: 0\n"
+ " gpu_memory_utilization: 0.9\n"
+ "platforms:\n"
+ " rocm:\n"
+ " stages:\n"
+ " - stage_id: 0\n"
+ " enforce_eager: true\n"
+ )
+ overlay = tmp_path / "overlay.yaml"
+ overlay.write_text(
+ f"base_config: {base.name}\n"
+ "platforms:\n"
+ " rocm:\n"
+ " stages:\n"
+ " - stage_id: 0\n"
+ " max_num_seqs: 1\n"
+ )
+
+ deploy = load_deploy_config(overlay)
+ deploy = _apply_platform_overrides(deploy, platform="rocm")
+ # Both base's enforce_eager and overlay's max_num_seqs should apply.
+ assert deploy.stages[0].enforce_eager is True
+ assert deploy.stages[0].max_num_seqs == 1
+ # Inherited stage default not touched by overlay platforms section.
+ assert deploy.stages[0].gpu_memory_utilization == 0.9
+
+
+class TestCLIOverrideFlow:
+ """Test --stage-overrides JSON merge into StageConfig."""
+
+ def test_stage_overrides_merge(self):
+ from pathlib import Path
+
+ import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
+ from vllm_omni.config.stage_config import load_deploy_config, merge_pipeline_deploy
+
+ pipeline = _PIPELINE_REGISTRY["qwen3_omni_moe"]
+ deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
+ if not deploy_path.exists():
+ pytest.skip("Deploy config not found")
+
+ deploy = load_deploy_config(deploy_path)
+ stages = merge_pipeline_deploy(pipeline, deploy)
+
+ # Simulate --stage-overrides '{"0": {"gpu_memory_utilization": 0.5}}'
+ overrides = {"stage_0_gpu_memory_utilization": 0.5}
+ stages[0].runtime_overrides = StageConfigFactory._merge_cli_overrides(stages[0], overrides)
+ assert stages[0].runtime_overrides["gpu_memory_utilization"] == 0.5
+
+ def test_global_override_applies_to_all(self):
+ from pathlib import Path
+
+ import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
+ from vllm_omni.config.stage_config import load_deploy_config, merge_pipeline_deploy
+
+ pipeline = _PIPELINE_REGISTRY["qwen3_omni_moe"]
+ deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
+ if not deploy_path.exists():
+ pytest.skip("Deploy config not found")
+
+ deploy = load_deploy_config(deploy_path)
+ stages = merge_pipeline_deploy(pipeline, deploy)
+
+ overrides = {"enforce_eager": True}
+ for s in stages:
+ s.runtime_overrides = StageConfigFactory._merge_cli_overrides(s, overrides)
+ assert s.runtime_overrides["enforce_eager"] is True
+
+
+class TestCLIExplicitPrecedence:
+ """Verify YAML > argparse defaults; explicit CLI args > YAML."""
+
+ def _stages(self, cli_overrides, cli_explicit_keys):
+ import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
+
+ return StageConfigFactory._create_from_registry(
+ "qwen3_omni_moe",
+ cli_overrides=cli_overrides,
+ cli_explicit_keys=cli_explicit_keys,
+ )
+
+ def test_explicit_cli_overrides_yaml(self):
+ """User-typed --max-num-seqs wins over the deploy YAML value."""
+ stages = self._stages(
+ cli_overrides={"max_num_seqs": 999},
+ cli_explicit_keys={"max_num_seqs"},
+ )
+ # Stage 2 yaml has max_num_seqs=1; explicit CLI must beat it.
+ assert stages[2].runtime_overrides.get("max_num_seqs") == 999
+
+ def test_default_cli_does_not_override_yaml(self):
+ """Argparse defaults must NOT clobber values that are present in YAML."""
+ stages = self._stages(
+ cli_overrides={"max_num_seqs": 256},
+ cli_explicit_keys=set(), # user typed nothing
+ )
+ # Stage 2's YAML value (1) should win because the user didn't type --max-num-seqs.
+ assert stages[2].runtime_overrides.get("max_num_seqs") != 256
+
+ def test_default_cli_fills_missing_yaml_field(self):
+ """Argparse defaults still fill fields the YAML doesn't set."""
+ stages = self._stages(
+ cli_overrides={"some_unrelated_knob": "fallback"},
+ cli_explicit_keys=set(),
+ )
+ # Field absent from YAML → CLI default flows through as a fallback.
+ assert stages[0].runtime_overrides.get("some_unrelated_knob") == "fallback"
+
+ def test_per_stage_overrides_always_explicit(self):
+ """``stage__*`` keys are always treated as explicit."""
+ stages = self._stages(
+ cli_overrides={"stage_0_gpu_memory_utilization": 0.42},
+ cli_explicit_keys=set(), # not in the explicit set, but per-stage
+ )
+ assert stages[0].runtime_overrides.get("gpu_memory_utilization") == 0.42
+
+ def test_none_explicit_set_treats_all_as_explicit(self):
+ """Programmatic Omni() callers (cli_explicit_keys=None) keep current behavior."""
+ stages = self._stages(
+ cli_overrides={"max_num_seqs": 999},
+ cli_explicit_keys=None,
+ )
+ assert stages[2].runtime_overrides.get("max_num_seqs") == 999
+
+ def test_explicit_async_chunk_false_overrides_yaml(self):
+ """``--no-async-chunk`` flips the deploy-level async_chunk to False even
+ when the YAML sets it to True. Verifies that the per-stage
+ ``async_chunk: True`` injection in ``merge_pipeline_deploy`` is skipped
+ and that ``async_chunk`` does not leak through ``_merge_cli_overrides``.
+ """
+ stages = self._stages(
+ cli_overrides={"async_chunk": False},
+ cli_explicit_keys={"async_chunk"},
+ )
+ # qwen3_omni_moe.yaml has `async_chunk: true`, so by default every
+ # stage's engine_args would carry it. With the explicit override, it
+ # must NOT show up.
+ for stage in stages:
+ assert stage.yaml_engine_args.get("async_chunk") is not True
+ assert stage.runtime_overrides.get("async_chunk") is None
+
+ def test_default_async_chunk_leaves_yaml_alone(self):
+ """An unset ``--async-chunk`` (default None) must leave the YAML's True
+ in force on every stage."""
+ stages = self._stages(
+ cli_overrides={"async_chunk": None},
+ cli_explicit_keys=set(),
+ )
+ # qwen3_omni_moe.yaml: `async_chunk: true` → injected on every stage.
+ for stage in stages:
+ assert stage.yaml_engine_args.get("async_chunk") is True
+
+ def test_explicit_enable_prefix_caching_overrides_yaml(self):
+ """``--enable-prefix-caching`` (global) flips every stage's
+ ``enable_prefix_caching`` to True regardless of the YAML default."""
+ stages = self._stages(
+ cli_overrides={"enable_prefix_caching": True},
+ cli_explicit_keys={"enable_prefix_caching"},
+ )
+ for stage in stages:
+ assert stage.runtime_overrides.get("enable_prefix_caching") is True
+
+ def test_async_chunk_dispatches_processors(self):
+ """A single ``qwen3_tts`` pipeline picks per-chunk vs end-to-end
+ processors based on ``deploy.async_chunk``, without needing a
+ separate variant pipeline registration."""
+ import vllm_omni.model_executor.models.qwen3_tts.pipeline # noqa: F401
+ from vllm_omni.config.stage_config import (
+ _PIPELINE_REGISTRY,
+ DeployConfig,
+ merge_pipeline_deploy,
+ )
+
+ pipeline = _PIPELINE_REGISTRY["qwen3_tts"]
+
+ # async_chunk=True → stage 0's per-chunk processor wires up, stage 1
+ # has no sync input processor.
+ async_stages = merge_pipeline_deploy(pipeline, DeployConfig(async_chunk=True))
+ assert (
+ async_stages[0]
+ .yaml_engine_args.get("custom_process_next_stage_input_func", "")
+ .endswith("talker2code2wav_async_chunk")
+ )
+ assert async_stages[1].custom_process_input_func is None
+
+ # async_chunk=False → stage 0 has no streaming processor, stage 1's
+ # batch-end processor wires up.
+ sync_stages = merge_pipeline_deploy(pipeline, DeployConfig(async_chunk=False))
+ assert "custom_process_next_stage_input_func" not in sync_stages[0].yaml_engine_args
+ assert sync_stages[1].custom_process_input_func is not None
+ assert sync_stages[1].custom_process_input_func.endswith("talker2code2wav")
+
+
+class TestSamplingConstraintsPrecedence:
+ """Test that pipeline sampling_constraints override deploy defaults."""
+
+ def test_constraints_win(self):
+ from pathlib import Path
+
+ import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
+ from vllm_omni.config.stage_config import load_deploy_config, merge_pipeline_deploy
+
+ pipeline = _PIPELINE_REGISTRY["qwen3_omni_moe"]
+ deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
+ if not deploy_path.exists():
+ pytest.skip("Deploy config not found")
+
+ deploy = load_deploy_config(deploy_path)
+ stages = merge_pipeline_deploy(pipeline, deploy)
+
+ # Pipeline says detokenize=True for thinker, deploy can't override
+ assert stages[0].yaml_extras["default_sampling_params"]["detokenize"] is True
+ # Pipeline says stop_token_ids=[2150] for talker
+ assert stages[1].yaml_extras["default_sampling_params"]["stop_token_ids"] == [2150]
+ # Deploy temperature still flows through
+ assert stages[0].yaml_extras["default_sampling_params"]["temperature"] == 0.4
diff --git a/tests/test_diffusion_config_propagation.py b/tests/test_diffusion_config_propagation.py
index 7d6d9c43f..eeb3505ef 100644
--- a/tests/test_diffusion_config_propagation.py
+++ b/tests/test_diffusion_config_propagation.py
@@ -15,6 +15,7 @@ from vllm_omni.diffusion.data import (
DiffusionParallelConfig,
OmniDiffusionConfig,
)
+from vllm_omni.diffusion.model_metadata import QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -109,3 +110,12 @@ class TestCreateDefaultDiffusion:
ea = stages[0]["engine_args"]
assert ea["enforce_eager"] is True
assert ea["lora_path"] == "/tmp/lora"
+
+
+def test_qwen_image_edit_plus_sets_generic_multimodal_limit():
+ od_config = OmniDiffusionConfig(model="Qwen/Qwen-Image-Edit-2511", model_class_name="QwenImageEditPlusPipeline")
+
+ od_config.update_multimodal_support()
+
+ assert od_config.supports_multimodal_inputs is True
+ assert od_config.max_multimodal_image_inputs == QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES
diff --git a/tests/test_generate_nightly_perf_excel.py b/tests/test_generate_nightly_perf_excel.py
new file mode 100644
index 000000000..9b05d6de0
--- /dev/null
+++ b/tests/test_generate_nightly_perf_excel.py
@@ -0,0 +1,71 @@
+import importlib.util
+import json
+import sys
+from pathlib import Path
+
+import pytest
+from openpyxl import load_workbook
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def _load_excel_module():
+ repo_root = Path(__file__).resolve().parents[1]
+ module_path = repo_root / "tools" / "nightly" / "generate_nightly_perf_excel.py"
+ spec = importlib.util.spec_from_file_location("generate_nightly_perf_excel", module_path)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def _cell_value_by_header(ws, header_name: str, row_idx: int = 2):
+ headers = [c.value for c in ws[1]]
+ col_idx = headers.index(header_name) + 1
+ return ws.cell(row=row_idx, column=col_idx).value
+
+
+def test_generate_excel_report_with_perf_templates(tmp_path: Path):
+ module = _load_excel_module()
+ repo_root = Path(__file__).resolve().parents[1]
+ perf_scripts_dir = repo_root / "tests" / "dfx" / "perf" / "scripts"
+
+ omni_template_path = perf_scripts_dir / "result_omni_template.json"
+ diffusion_template_path = perf_scripts_dir / "diffusion_result_template.json"
+
+ omni_record = json.loads(omni_template_path.read_text(encoding="utf-8"))
+ diffusion_records = json.loads(diffusion_template_path.read_text(encoding="utf-8"))
+
+ input_dir = tmp_path / "input"
+ diffusion_input_dir = tmp_path / "diffusion_input"
+ input_dir.mkdir()
+ diffusion_input_dir.mkdir()
+
+ # Keep file names compatible with parser conventions in generate_nightly_perf_excel.py
+ omni_result_file = input_dir / "result_test_perf_random_1_4_in2500_out900_20260415-185642.json"
+ diffusion_result_file = diffusion_input_dir / "diffusion_result_qwen_image_edit_20260415-193200.json"
+ omni_result_file.write_text(json.dumps(omni_record, ensure_ascii=False, indent=2), encoding="utf-8")
+ diffusion_result_file.write_text(json.dumps(diffusion_records, ensure_ascii=False, indent=2), encoding="utf-8")
+
+ output_file = tmp_path / "nightly_perf.xlsx"
+ module.generate_excel_report(
+ input_dir=str(input_dir),
+ diffusion_input_dir=str(diffusion_input_dir),
+ output_file=str(output_file),
+ commit_sha="test_commit_sha",
+ build_id="test_build_id",
+ build_url="https://example.com/build/123",
+ )
+
+ assert output_file.exists()
+
+ wb = load_workbook(output_file)
+ assert set(wb.sheetnames) >= {"omni_summary", "diffusion_summary", "omni_raw", "diffusion_raw"}
+
+ ws_omni_raw = wb["omni_raw"]
+ baseline_cell = _cell_value_by_header(ws_omni_raw, "baseline")
+ assert baseline_cell == json.dumps(omni_record["baseline"], ensure_ascii=False, sort_keys=True)
+
+ ws_omni_summary = wb["omni_summary"]
+ assert _cell_value_by_header(ws_omni_summary, "commit_sha") == "test_commit_sha"
+ assert _cell_value_by_header(ws_omni_summary, "build_id") == "test_build_id"
diff --git a/tests/test_generate_nightly_perf_html.py b/tests/test_generate_nightly_perf_html.py
new file mode 100644
index 000000000..4e77eb3ad
--- /dev/null
+++ b/tests/test_generate_nightly_perf_html.py
@@ -0,0 +1,54 @@
+import importlib.util
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def _load_html_module():
+ repo_root = Path(__file__).resolve().parents[1]
+ module_path = repo_root / "tools" / "nightly" / "generate_nightly_perf_html.py"
+ spec = importlib.util.spec_from_file_location("generate_nightly_perf_html", module_path)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_generate_html_report_with_perf_templates(tmp_path: Path):
+ module = _load_html_module()
+ repo_root = Path(__file__).resolve().parents[1]
+ perf_scripts_dir = repo_root / "tests" / "dfx" / "perf" / "scripts"
+
+ omni_template_path = perf_scripts_dir / "result_omni_template.json"
+ diffusion_template_path = perf_scripts_dir / "diffusion_result_template.json"
+
+ omni_record = json.loads(omni_template_path.read_text(encoding="utf-8"))
+ diffusion_records = json.loads(diffusion_template_path.read_text(encoding="utf-8"))
+
+ input_dir = tmp_path / "input"
+ diffusion_input_dir = tmp_path / "diffusion_input"
+ input_dir.mkdir()
+ diffusion_input_dir.mkdir()
+
+ omni_result_file = input_dir / "result_test_perf_random_1_4_in2500_out900_20260415-185642.json"
+ diffusion_result_file = diffusion_input_dir / "diffusion_result_qwen_image_edit_20260415-193200.json"
+ omni_result_file.write_text(json.dumps(omni_record, ensure_ascii=False, indent=2), encoding="utf-8")
+ diffusion_result_file.write_text(json.dumps(diffusion_records, ensure_ascii=False, indent=2), encoding="utf-8")
+
+ output_file = tmp_path / "nightly_perf_v2.html"
+ module.generate_html_report(
+ input_dir=str(input_dir),
+ diffusion_input_dir=str(diffusion_input_dir),
+ output_file=str(output_file),
+ )
+
+ assert output_file.exists()
+ html = output_file.read_text(encoding="utf-8")
+ assert "Nightly Performance Report" in html
+ assert "Omni records 1 " in html
+ assert f"Diffusion records {len(diffusion_records)} " in html
+ assert "const DIFF_DATA =" in html
diff --git a/tests/test_version.py b/tests/test_version.py
new file mode 100644
index 000000000..07e622a7d
--- /dev/null
+++ b/tests/test_version.py
@@ -0,0 +1,58 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Unit tests for version compatibility warnings."""
+
+import warnings
+from unittest import mock
+
+import pytest
+
+from vllm_omni.version import warn_if_misaligned_vllm_version
+
+
+@mock.patch("vllm_omni.version.__version_tuple__", (0, 19, 0))
+@mock.patch("vllm_omni.version.__version__", "0.19.0")
+@mock.patch("vllm.__version_tuple__", (0, 18, 0))
+@mock.patch("vllm.__version__", "0.18.0")
+def test_version_mismatch_warning():
+ """Ensure that we warn when vLLM and vLLM-Omni major/minor versions differ."""
+ with pytest.warns(RuntimeWarning, match="mismatched major/minor versions"):
+ warn_if_misaligned_vllm_version()
+
+
+@pytest.mark.parametrize(
+ "vllm_ver,vllm_tuple,omni_ver,omni_tuple",
+ [
+ ("0.19.0", (0, 19, 0), "0.19.5", (0, 19, 5)), # Patch differs
+ ("0.18.0", (0, 18, 0), "dev", (0, 0, "dev")), # Omni dev
+ ("dev", (0, 0, "dev"), "0.19.0", (0, 19, 0)), # vLLM dev
+ # Ensure local identifies don't matter for the warning
+ ("0.19.0+foo", (0, 19, 0, "foo"), "0.19.5", (0, 19, 0)),
+ ("0.19.0", (0, 19, 0), "0.19.5+bar", (0, 19, 0, "bar")),
+ ("0.19.0+foo", (0, 19, 0, "foo"), "0.19.5+bar", (0, 19, 0, "bar")),
+ ],
+)
+def test_no_warning_cases(vllm_ver, vllm_tuple, omni_ver, omni_tuple):
+ """Ensure we don't warn when minor versions match or either is a dev build."""
+ with (
+ mock.patch.multiple("vllm", __version__=vllm_ver, __version_tuple__=vllm_tuple),
+ mock.patch.multiple("vllm_omni.version", __version__=omni_ver, __version_tuple__=omni_tuple),
+ ):
+ with warnings.catch_warnings():
+ warnings.simplefilter("error")
+ warn_if_misaligned_vllm_version()
+
+
+@mock.patch("vllm_omni.version.__version_tuple__", (0, 19, 0))
+@mock.patch("vllm_omni.version.__version__", "0.19.0rc2.dev21")
+@mock.patch("vllm.__version_tuple__", (0, 18, 0))
+@mock.patch("vllm.__version__", "0.18.0")
+def test_warning_contains_version_strings():
+ """Ensure that the warning contains the full version strings."""
+ with pytest.warns(RuntimeWarning) as record:
+ warn_if_misaligned_vllm_version()
+
+ assert len(record) == 1
+ msg = str(record[0].message)
+ assert "0.19.0rc2.dev21" in msg
+ assert "0.18.0" in msg
diff --git a/tests/utils.py b/tests/utils.py
deleted file mode 100644
index 84edbbf3d..000000000
--- a/tests/utils.py
+++ /dev/null
@@ -1,621 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
-# Some functions are copied from vllm/tests/utils.py
-import functools
-import os
-import signal
-import subprocess
-import sys
-import tempfile
-import threading
-import time
-from collections.abc import Callable
-from contextlib import ExitStack, contextmanager, suppress
-from typing import Any, Literal
-
-import cloudpickle
-import pytest
-import torch
-from typing_extensions import ParamSpec
-from vllm.platforms import current_platform
-from vllm.utils.torch_utils import cuda_device_count_stateless
-
-from vllm_omni.platforms import current_omni_platform
-
-_P = ParamSpec("_P")
-
-if current_platform.is_rocm():
- from amdsmi import (
- amdsmi_get_gpu_vram_usage,
- amdsmi_get_processor_handles,
- amdsmi_init,
- amdsmi_shut_down,
- )
-
- @contextmanager
- def _nvml():
- try:
- amdsmi_init()
- yield
- finally:
- amdsmi_shut_down()
-elif current_platform.is_cuda():
- from vllm.third_party.pynvml import (
- nvmlDeviceGetHandleByIndex,
- nvmlDeviceGetMemoryInfo,
- nvmlInit,
- nvmlShutdown,
- )
-
- @contextmanager
- def _nvml():
- try:
- nvmlInit()
- yield
- finally:
- nvmlShutdown()
-else:
-
- @contextmanager
- def _nvml():
- yield
-
-
-def get_physical_device_indices(devices):
- visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES")
- if visible_devices is None:
- return devices
-
- visible_indices = [int(x) for x in visible_devices.split(",")]
- index_mapping = {i: physical for i, physical in enumerate(visible_indices)}
- return [index_mapping[i] for i in devices if i in index_mapping]
-
-
-@_nvml()
-def wait_for_gpu_memory_to_clear(
- *,
- devices: list[int],
- threshold_bytes: int | None = None,
- threshold_ratio: float | None = None,
- timeout_s: float = 120,
-) -> None:
- import gc
-
- assert threshold_bytes is not None or threshold_ratio is not None
- # Use nvml instead of pytorch to reduce measurement error from torch cuda
- # context.
- devices = get_physical_device_indices(devices)
- start_time = time.time()
-
- # Print waiting start information
- device_list = ", ".join(str(d) for d in devices)
- if threshold_bytes is not None:
- threshold_str = f"{threshold_bytes / 2**30:.2f} GiB"
- condition_str = f"Memory usage ≤ {threshold_str}"
- else:
- threshold_percent = threshold_ratio * 100
- threshold_str = f"{threshold_percent:.1f}%"
- condition_str = f"Memory usage ratio ≤ {threshold_str}"
-
- print(f"[GPU Memory Monitor] Waiting for GPU {device_list} to free memory, Condition: {condition_str}")
-
- # Define the is_free function based on threshold type
- if threshold_bytes is not None:
-
- def is_free(used, total):
- return used <= threshold_bytes / 2**30
- else:
-
- def is_free(used, total):
- return used / total <= threshold_ratio
-
- while True:
- output: dict[int, str] = {}
- output_raw: dict[int, tuple[float, float]] = {}
- for device in devices:
- if current_platform.is_rocm():
- dev_handle = amdsmi_get_processor_handles()[device]
- mem_info = amdsmi_get_gpu_vram_usage(dev_handle)
- gb_used = mem_info["vram_used"] / 2**10
- gb_total = mem_info["vram_total"] / 2**10
- else:
- dev_handle = nvmlDeviceGetHandleByIndex(device)
- mem_info = nvmlDeviceGetMemoryInfo(dev_handle)
- gb_used = mem_info.used / 2**30
- gb_total = mem_info.total / 2**30
- output_raw[device] = (gb_used, gb_total)
- # Format to more readable form
- usage_percent = (gb_used / gb_total) * 100 if gb_total > 0 else 0
- output[device] = f"{gb_used:.1f}GiB/{gb_total:.1f}GiB ({usage_percent:.1f}%)"
-
- # Optimized GPU memory status print
- print("[GPU Memory Status] Current usage:")
- for device_id, mem_info in output.items():
- print(f" GPU {device_id}: {mem_info}")
-
- # Calculate waiting duration
- dur_s = time.time() - start_time
- elapsed_minutes = dur_s / 60
-
- # Check if all devices meet the condition
- if all(is_free(used, total) for used, total in output_raw.values()):
- # Optimized completion message
- print(f"[GPU Memory Freed] Devices {device_list} meet memory condition")
- print(f" Condition: {condition_str}")
- print(f" Wait time: {dur_s:.1f} seconds ({elapsed_minutes:.1f} minutes)")
- print(" Final status:")
- for device_id, mem_info in output.items():
- print(f" GPU {device_id}: {mem_info}")
- break
-
- # Check timeout
- if dur_s >= timeout_s:
- raise ValueError(
- f"[GPU Memory Timeout] Devices {device_list} still don't meet memory condition after {dur_s:.1f} seconds\n"
- f"Condition: {condition_str}\n"
- f"Current status:\n" + "\n".join(f" GPU {device}: {output[device]}" for device in devices)
- )
-
- # Add waiting hint (optional)
- if dur_s > 10 and int(dur_s) % 10 == 0: # Show hint every 10 seconds
- print(f"Waiting... Already waited {dur_s:.1f} seconds ({elapsed_minutes:.1f} minutes)")
-
- gc.collect()
- torch.cuda.empty_cache()
-
- time.sleep(5)
-
-
-def fork_new_process_for_each_test(func: Callable[_P, None]) -> Callable[_P, None]:
- """Decorator to fork a new process for each test function.
- See https://github.com/vllm-project/vllm/issues/7053 for more details.
- """
-
- @functools.wraps(func)
- def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
- # Make the process the leader of its own process group
- # to avoid sending SIGTERM to the parent process
- os.setpgrp()
- from _pytest.outcomes import Skipped
-
- # Create a unique temporary file to store exception info from child
- # process. Use test function name and process ID to avoid collisions.
- with (
- tempfile.NamedTemporaryFile(
- delete=False, mode="w+b", prefix=f"vllm_test_{func.__name__}_{os.getpid()}_", suffix=".exc"
- ) as exc_file,
- ExitStack() as delete_after,
- ):
- exc_file_path = exc_file.name
- delete_after.callback(os.remove, exc_file_path)
-
- pid = os.fork()
- print(f"Fork a new process to run a test {pid}")
- if pid == 0:
- # Parent process responsible for deleting, don't delete
- # in child.
- delete_after.pop_all()
- try:
- func(*args, **kwargs)
- except Skipped as e:
- # convert Skipped to exit code 0
- print(str(e))
- os._exit(0)
- except Exception as e:
- import traceback
-
- tb_string = traceback.format_exc()
-
- # Try to serialize the exception object first
- exc_to_serialize: dict[str, Any]
- try:
- # First, try to pickle the actual exception with
- # its traceback.
- exc_to_serialize = {"pickled_exception": e}
- # Test if it can be pickled
- cloudpickle.dumps(exc_to_serialize)
- except (Exception, KeyboardInterrupt):
- # Fall back to string-based approach.
- exc_to_serialize = {
- "exception_type": type(e).__name__,
- "exception_msg": str(e),
- "traceback": tb_string,
- }
- try:
- with open(exc_file_path, "wb") as f:
- cloudpickle.dump(exc_to_serialize, f)
- except Exception:
- # Fallback: just print the traceback.
- print(tb_string)
- os._exit(1)
- else:
- os._exit(0)
- else:
- pgid = os.getpgid(pid)
- _pid, _exitcode = os.waitpid(pid, 0)
- # ignore SIGTERM signal itself
- old_signal_handler = signal.signal(signal.SIGTERM, signal.SIG_IGN)
- # kill all child processes
- os.killpg(pgid, signal.SIGTERM)
- # restore the signal handler
- signal.signal(signal.SIGTERM, old_signal_handler)
- if _exitcode != 0:
- # Try to read the exception from the child process
- exc_info = {}
- if os.path.exists(exc_file_path):
- with suppress(Exception), open(exc_file_path, "rb") as f:
- exc_info = cloudpickle.load(f)
-
- if (original_exception := exc_info.get("pickled_exception")) is not None:
- # Re-raise the actual exception object if it was
- # successfully pickled.
- assert isinstance(original_exception, Exception)
- raise original_exception
-
- if (original_tb := exc_info.get("traceback")) is not None:
- # Use string-based traceback for fallback case
- raise AssertionError(
- f"Test {func.__name__} failed when called with"
- f" args {args} and kwargs {kwargs}"
- f" (exit code: {_exitcode}):\n{original_tb}"
- ) from None
-
- # Fallback to the original generic error
- raise AssertionError(
- f"function {func.__name__} failed when called with"
- f" args {args} and kwargs {kwargs}"
- f" (exit code: {_exitcode})"
- ) from None
-
- return wrapper
-
-
-def spawn_new_process_for_each_test(f: Callable[_P, None]) -> Callable[_P, None]:
- """Decorator to spawn a new process for each test function."""
-
- @functools.wraps(f)
- def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
- # Check if we're already in a subprocess
- if os.environ.get("RUNNING_IN_SUBPROCESS") == "1":
- # If we are, just run the function directly
- return f(*args, **kwargs)
-
- import torch.multiprocessing as mp
-
- with suppress(RuntimeError):
- mp.set_start_method("spawn")
-
- # Get the module
- module_name = f.__module__
-
- # Create a process with environment variable set
- env = os.environ.copy()
- env["RUNNING_IN_SUBPROCESS"] = "1"
-
- with tempfile.TemporaryDirectory() as tempdir:
- output_filepath = os.path.join(tempdir, "new_process.tmp")
-
- # `cloudpickle` allows pickling complex functions directly
- input_bytes = cloudpickle.dumps((f, output_filepath))
-
- cmd = [sys.executable, "-m", f"{module_name}"]
-
- returned = subprocess.run(cmd, input=input_bytes, capture_output=True, env=env)
-
- # check if the subprocess is successful
- try:
- returned.check_returncode()
- except Exception as e:
- # wrap raised exception to provide more information
- raise RuntimeError(f"Error raised in subprocess:\n{returned.stderr.decode()}") from e
-
- return wrapper
-
-
-def create_new_process_for_each_test(
- method: Literal["spawn", "fork"] | None = None,
-) -> Callable[[Callable[_P, None]], Callable[_P, None]]:
- """Creates a decorator that runs each test function in a new process.
-
- Args:
- method: The process creation method. Can be either "spawn" or "fork".
- If not specified, it defaults to "spawn" on ROCm and XPU
- platforms and "fork" otherwise.
-
- Returns:
- A decorator to run test functions in separate processes.
- """
- if method is None:
- # TODO: Spawn is not working correctly on ROCm
- # The test content will not run and tests passed immediately.
- # For now, using `fork` for ROCm as it can run with `fork`
- # and tests are running correctly.
- use_spawn = current_platform.is_xpu()
- method = "spawn" if use_spawn else "fork"
-
- assert method in ["spawn", "fork"], "Method must be either 'spawn' or 'fork'"
-
- if method == "fork":
- return fork_new_process_for_each_test
-
- return spawn_new_process_for_each_test
-
-
-def cuda_marks(*, res: str, num_cards: int):
- """
- Get a collection of pytest marks to apply for `@cuda_test`.
-
- Args:
- res: Resource type, e.g., "L4" or "H100".
- num_cards: Number of GPU cards required.
-
- Returns:
- List of pytest marks to apply.
- """
- test_platform_detail = pytest.mark.cuda
-
- if res == "L4":
- test_resource = pytest.mark.L4
- elif res == "H100":
- test_resource = pytest.mark.H100
- else:
- raise ValueError(f"Invalid CUDA resource type: {res}. Supported: L4, H100")
-
- marks = [test_resource, test_platform_detail]
-
- if num_cards == 1:
- return marks
- else:
- test_distributed = pytest.mark.distributed_cuda(num_cards=num_cards)
- test_skipif = pytest.mark.skipif_cuda(
- cuda_device_count_stateless() < num_cards,
- reason=f"Need at least {num_cards} CUDA GPUs to run the test.",
- )
- return marks + [test_distributed, test_skipif]
-
-
-def rocm_marks(*, res: str, num_cards: int):
- """
- Get a collection of pytest marks to apply for `@rocm_test`.
-
- Args:
- res: Resource type, e.g., "MI325".
- num_cards: Number of GPU cards required.
-
- Returns:
- List of pytest marks to apply.
- """
- test_platform_detail = pytest.mark.rocm
-
- if res == "MI325":
- test_resource = pytest.mark.MI325
- else:
- raise ValueError(f"Invalid ROCm resource type: {res}. Supported: MI325")
-
- marks = [test_resource, test_platform_detail]
-
- if num_cards == 1:
- return marks
- else:
- test_distributed = pytest.mark.distributed_rocm(num_cards=num_cards)
- # TODO: add ROCm support for `skipif_rocm` marker
- return marks + [test_distributed]
-
-
-def xpu_marks(*, res: str, num_cards: int):
- """
- Get a collection of pytest marks to apply for `@xpu_test`.
-
- Args:
- res: Resource type, e.g., "B60".
- num_cards: Number of GPU cards required.
-
- Returns:
- List of pytest marks to apply.
- """
- test_platform_detail = pytest.mark.xpu
-
- if res == "B60":
- test_resource = pytest.mark.B60
- else:
- raise ValueError(f"Invalid XPU resource type: {res}. Supported: B60")
-
- marks = [test_resource, test_platform_detail]
-
- if num_cards == 1:
- return marks
- else:
- test_distributed = pytest.mark.distributed_rocm(num_cards=num_cards)
- # TODO: add XPU support for `skipif_xpu` marker
- return marks + [test_distributed]
-
-
-def musa_marks(*, res: str, num_cards: int):
- """
- Get a collection of pytest marks to apply for `@musa_test`.
-
- Args:
- res: Resource type, e.g., "S5000".
- num_cards: Number of GPU cards required.
-
- Returns:
- List of pytest marks to apply.
- """
- test_platform_detail = pytest.mark.musa
-
- if res == "S5000":
- test_resource = pytest.mark.S5000
- else:
- raise ValueError(f"Invalid MUSA resource type: {res}. Supported: S5000")
-
- marks = [test_resource, test_platform_detail]
-
- if num_cards == 1:
- return marks
- else:
- test_distributed = pytest.mark.distributed_musa(num_cards=num_cards)
- # TODO: add MUSA support for `skipif_musa` marker
- return marks + [test_distributed]
-
-
-def gpu_marks(*, res: str, num_cards: int):
- """
- Get a collection of pytest marks to apply for `@gpu_test`.
- Platform is automatically determined based on resource type.
-
- Args:
- res: Resource type, e.g., "L4", "H100" for CUDA, or "MI325" for ROCm, or "B60" for XPU, or "S5000" for MUSA.
- num_cards: Number of GPU cards required.
-
- Returns:
- List of pytest marks to apply.
- """
- test_platform = pytest.mark.gpu
- if res in ("L4", "H100"):
- return [test_platform] + cuda_marks(res=res, num_cards=num_cards)
- if res == "MI325":
- return [test_platform] + rocm_marks(res=res, num_cards=num_cards)
- if res == "B60":
- return [test_platform] + xpu_marks(res=res, num_cards=num_cards)
- if res == "S5000":
- return [test_platform] + musa_marks(res=res, num_cards=num_cards)
- raise ValueError(f"Invalid resource type: {res}. Supported: L4, H100, MI325, B60, S5000")
-
-
-def npu_marks(*, res: str, num_cards: int):
- """Get a collection of pytest marks to apply for `@npu_test`."""
- test_platform = pytest.mark.npu
- if res == "A2":
- test_resource = pytest.mark.A2
- elif res == "A3":
- test_resource = pytest.mark.A3
- else:
- # TODO: Currently we don't have various NPU card types defined
- # Use None to skip resource-specific marking for unknown types
- test_resource = None
-
- if num_cards == 1:
- return [mark for mark in [test_platform, test_resource] if mark is not None]
- else:
- # Multiple cards scenario needs distributed_npu mark
- test_distributed = pytest.mark.distributed_npu(num_cards=num_cards)
- # TODO: add NPU support for `skipif_npu` marker
- return [mark for mark in [test_platform, test_resource, test_distributed] if mark is not None]
-
-
-def hardware_marks(*, res: dict[str, str], num_cards: int | dict[str, int] = 1):
- """
- Get a collection of pytest marks to apply for `@hardware_test`,
- including CUDA, ROCm, XPU, NPU, and MUSA,
- based on the specified platforms and resources.
- """
- # Validate platforms
- # Don't validate platform details in this decorator
- for platform, _ in res.items():
- if platform not in ("cuda", "rocm", "xpu", "npu", "musa"):
- raise ValueError(f"Unsupported platform: {platform}")
-
- # Normalize num_cards
- if isinstance(num_cards, int):
- num_cards_dict = {platform: num_cards for platform in res.keys()}
- else:
- num_cards_dict = num_cards
- for platform in num_cards_dict.keys():
- if platform not in res:
- raise ValueError(
- f"Platform '{platform}' in num_cards but not in res. Available platforms: {list(res.keys())}"
- )
- for platform in res.keys():
- if platform not in num_cards_dict:
- num_cards_dict[platform] = 1
-
- # Collect marks from all platforms
- all_marks: list[pytest.MarkDecorator] = []
- for platform, resource in res.items():
- cards = num_cards_dict[platform]
- if platform == "cuda" or platform == "rocm" or platform == "xpu":
- marks = gpu_marks(res=resource, num_cards=cards)
- elif platform == "musa":
- marks = musa_marks(res=resource, num_cards=cards)
- elif platform == "npu":
- marks = npu_marks(res=resource, num_cards=cards)
- else:
- raise ValueError(f"Unsupported platform: {platform}")
- all_marks.extend(marks)
- return all_marks
-
-
-def hardware_test(*, res: dict[str, str], num_cards: int | dict[str, int] = 1):
- """
- Decorate a test for multiple hardware platforms with a single call.
- Automatically wraps the test with @create_new_process_for_each_test() for distributed tests.
-
- Args:
- res: Mapping from platform to resource type. Supported platforms/resources:
- - cuda: L4, H100
- - rocm: MI325
- - xpu: B60
- - npu: A2, A3
- - musa: S5000
- num_cards: Number of cards required. Can be:
- - int: same card count for all platforms (default: 1)
- - dict: per-platform card count, e.g., {"cuda": 2, "rocm": 2}
-
- Example:
- @hardware_test(
- res={"cuda": "L4", "rocm": "MI325", "npu": "A2", "musa": "S5000"},
- num_cards={"cuda": 2, "rocm": 2, "npu": 2, "musa": 2},
- )
- def test_multi_platform():
- ...
- """
- all_marks = hardware_marks(res=res, num_cards=num_cards)
-
- def wrapper(f: Callable[_P, None]) -> Callable[_P, None]:
- func = f
- for mark in reversed(all_marks):
- func = mark(func)
- return func
-
- return wrapper
-
-
-class DeviceMemoryMonitor:
- """Poll global device memory usage."""
-
- def __init__(self, device_index: int, interval: float = 0.05):
- self.device_index = device_index
- self.interval = interval
- self._peak_used_mb = 0.0
- self._stop_event = threading.Event()
- self._thread: threading.Thread | None = None
-
- def start(self) -> None:
- def monitor_loop() -> None:
- while not self._stop_event.is_set():
- try:
- with current_omni_platform.device(self.device_index):
- free_bytes, total_bytes = current_omni_platform.mem_get_info()
- used_mb = (total_bytes - free_bytes) / (1024**2)
- self._peak_used_mb = max(self._peak_used_mb, used_mb)
- except Exception:
- pass
- time.sleep(self.interval)
-
- self._thread = threading.Thread(target=monitor_loop, daemon=False)
- self._thread.start()
-
- def stop(self) -> None:
- if self._thread is None:
- return
- self._stop_event.set()
- self._thread.join(timeout=2.0)
-
- @property
- def peak_used_mb(self) -> float:
- fallback_alloc = current_omni_platform.max_memory_allocated(device=self.device_index) / (1024**2)
- fallback_reserved = current_omni_platform.max_memory_reserved(device=self.device_index) / (1024**2)
- return max(self._peak_used_mb, fallback_alloc, fallback_reserved)
-
- def __del__(self):
- self.stop()
diff --git a/tests/utils/test_audio.py b/tests/utils/test_audio.py
index cfbd2501b..0e483e646 100644
--- a/tests/utils/test_audio.py
+++ b/tests/utils/test_audio.py
@@ -1,12 +1,13 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
-"""Unit tests for vllm_omni.utils.audio.mel_filter_bank."""
+"""Unit tests for vllm_omni.utils.audio."""
+import numpy as np
import pytest
import torch
-from vllm_omni.utils.audio import mel_filter_bank
+from vllm_omni.utils.audio import mel_filter_bank, peak_normalize
# Parameter combinations used across the codebase.
_PARAM_SETS = [
@@ -59,3 +60,20 @@ class TestMelFilterBank:
last_nonzero_low = (fb_low.sum(dim=0) > 0).nonzero()[-1].item()
last_nonzero_high = (fb_high.sum(dim=0) > 0).nonzero()[-1].item()
assert last_nonzero_high > last_nonzero_low
+
+
+class TestPeakNormalize:
+ def test_silence_unchanged(self):
+ """All-zero input should remain all-zero."""
+ audio = np.zeros(1600, dtype=np.float32)
+ result = peak_normalize(audio, db_level=-6.0)
+ np.testing.assert_array_equal(result, audio)
+
+ def test_peak_reaches_target(self):
+ """After normalization, peak amplitude should be at target dB."""
+ rng = np.random.default_rng(7)
+ audio = rng.uniform(-0.4, 0.4, size=16000).astype(np.float32)
+
+ result = peak_normalize(audio, db_level=-6.0)
+ peak_db = 20 * np.log10(np.abs(result).max())
+ np.testing.assert_allclose(peak_db, -6.0, atol=1e-4)
diff --git a/tests/worker/test_omni_gpu_model_runner.py b/tests/worker/test_omni_gpu_model_runner.py
index b2d619315..a74c9ffc2 100644
--- a/tests/worker/test_omni_gpu_model_runner.py
+++ b/tests/worker/test_omni_gpu_model_runner.py
@@ -41,7 +41,17 @@ class MiMoAudioForConditionalGeneration(torch.nn.Module):
class DummyTalkerMTP(torch.nn.Module):
"""A fake talker_mtp module for deterministic CPU testing."""
- def forward(self, req_input_ids, req_embeds, last_talker_hidden, text_step):
+ def forward(
+ self,
+ req_input_ids,
+ req_embeds,
+ last_talker_hidden,
+ text_step,
+ do_sample=None,
+ temperature=None,
+ top_k=None,
+ top_p=None,
+ ):
# Deterministic behavior:
# - output embeds = input embeds + 1
# - output codes = [[0], [1], ...]
@@ -51,6 +61,36 @@ class DummyTalkerMTP(torch.nn.Module):
return new_embeds, codes
+class CaptureTalkerMTP(torch.nn.Module):
+ """A fake talker_mtp module that records sampling kwargs."""
+
+ def __init__(self):
+ super().__init__()
+ self.calls = []
+
+ def forward(
+ self,
+ req_input_ids,
+ req_embeds,
+ last_talker_hidden,
+ text_step,
+ do_sample=None,
+ temperature=None,
+ top_k=None,
+ top_p=None,
+ ):
+ self.calls.append(
+ {
+ "do_sample": do_sample,
+ "temperature": temperature,
+ "top_k": top_k,
+ "top_p": top_p,
+ }
+ )
+ codes = torch.zeros((req_embeds.shape[0], 1), dtype=torch.int64)
+ return req_embeds, codes
+
+
@contextmanager
def _noop_forward_context(*args, **kwargs):
"""A no-op context manager to replace vLLM forward context in CPU tests."""
@@ -80,7 +120,7 @@ def _make_runner(req_ids=("r1", "r2"), hidden_size=4):
runner.talker_mtp = DummyTalkerMTP()
runner.model = SimpleNamespace(talker_mtp_output_key="code_predictor_codes")
- runner.vllm_config = object()
+ runner.vllm_config = SimpleNamespace(model_config=SimpleNamespace())
# Provide a minimal implementation that returns the expected 4-tuple.
def _determine_batch_execution_and_padding(**kwargs):
@@ -168,6 +208,43 @@ def test_talker_mtp_forward_cpu_empty_batch_noop(monkeypatch):
assert torch.allclose(inputs_embeds, before)
+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)
+
+ runner = _make_runner(req_ids=("r1",), hidden_size=4)
+ runner.talker_mtp = CaptureTalkerMTP()
+ runner.vllm_config = SimpleNamespace(
+ model_config=SimpleNamespace(
+ subtalker_sampling_params={
+ "do_sample": False,
+ "temperature": 0.2,
+ "top_k": 9,
+ "top_p": 0.55,
+ }
+ )
+ )
+
+ def fake_determine(self, num_tokens, num_reqs, num_scheduled_tokens_np, max_num_scheduled_tokens, use_cascade_attn):
+ batch_desc = SimpleNamespace(num_tokens=int(num_tokens))
+ return (False, batch_desc, None, None, None)
+
+ monkeypatch.setattr(runner, "_determine_batch_execution_and_padding", fake_determine.__get__(runner, type(runner)))
+
+ inputs_embeds = torch.zeros((2, 4), dtype=torch.float32)
+ OmniGPUModelRunner._talker_mtp_forward(runner, ["r1"], inputs_embeds)
+
+ assert runner.talker_mtp.calls == [
+ {
+ "do_sample": False,
+ "temperature": 0.2,
+ "top_k": 9,
+ "top_p": 0.55,
+ }
+ ]
+
+
def test_update_intermediate_buffer_writes_to_buffer_and_setattr(monkeypatch):
"""Validate that _update_intermediate_buffer writes to model_intermediate_buffer
(forward path) and mirrors to additional_information_cpu setattr (backward compat)."""
diff --git a/tools/nightly/generate_nightly_perf_excel.py b/tools/nightly/generate_nightly_perf_excel.py
index 5f9eb428b..6ba1d1eef 100644
--- a/tools/nightly/generate_nightly_perf_excel.py
+++ b/tools/nightly/generate_nightly_perf_excel.py
@@ -319,10 +319,10 @@ def _load_json_file(path: str) -> dict[str, Any] | list[Any] | None:
def _parse_from_filename(filename: str) -> dict[str, Any]:
- """Parse test-related metadata from a result JSON filename.
+ """Parse test-related metadata from a ``result_test_*.json`` filename.
- Expected pattern (after prefix/suffix stripped):
- ____
+ Matches ``tests/dfx/perf/scripts/run_benchmark.py`` naming, including optional
+ ``_in{X}_out{Y}_`` before the timestamp (``na`` when unset).
"""
name, ext = os.path.splitext(filename)
if ext != ".json" or not name.startswith(_RESULT_JSON_PREFIX):
@@ -331,22 +331,42 @@ def _parse_from_filename(filename: str) -> dict[str, Any]:
core = name[len(_RESULT_JSON_PREFIX) :]
parts = core.split("_")
if len(parts) < 5:
- LOGGER.warning("filename '%s' does not match expected pattern, skip parsing test metadata", filename)
+ LOGGER.warning(
+ "filename '%s' does not match expected pattern (need >= 5 segments), skip parsing",
+ filename,
+ )
return {}
- timestamp = parts[-1]
- num_prompts_str = parts[-2]
- max_concurrency_str = parts[-3]
- dataset_name = parts[-4]
- test_name = "_".join(parts[:-4]) if parts[:-4] else ""
+ idx = len(parts) - 1
+ timestamp = parts[idx]
+ idx -= 1
parsed: dict[str, Any] = {}
-
if len(timestamp) >= 15:
parsed["date"] = timestamp
- if dataset_name in DATASET_NAME_ALLOWED:
- parsed["dataset_name"] = dataset_name
+ if idx >= 0 and parts[idx].startswith("out"):
+ parsed["random_output_len"] = parts[idx][3:]
+ idx -= 1
+ if idx >= 0 and parts[idx].startswith("in"):
+ parsed["random_input_len"] = parts[idx][2:]
+ idx -= 1
+
+ if idx < 3:
+ LOGGER.warning(
+ "filename '%s' has too few segments after timestamp / optional in-out (idx=%s)",
+ filename,
+ idx,
+ )
+ return parsed
+
+ num_prompts_str = parts[idx]
+ idx -= 1
+ flow_str = parts[idx]
+ idx -= 1
+ dataset_name = parts[idx]
+ idx -= 1
+ test_name = "_".join(parts[: idx + 1]) if idx >= 0 else ""
try:
parsed["num_prompts"] = int(num_prompts_str)
@@ -354,13 +374,16 @@ def _parse_from_filename(filename: str) -> dict[str, Any]:
pass
try:
- parsed["max_concurrency"] = int(max_concurrency_str)
+ parsed["max_concurrency"] = int(flow_str)
except (TypeError, ValueError):
pass
if test_name:
parsed["test_name"] = test_name
+ if dataset_name in DATASET_NAME_ALLOWED:
+ parsed["dataset_name"] = dataset_name
+
return parsed
@@ -642,6 +665,21 @@ def _to_float_if_numeric(value: Any) -> Any:
return value
+def _to_excel_compatible(value: Any) -> Any:
+ """Convert non-scalar objects to JSON string for openpyxl cell values."""
+ if isinstance(value, (dict, list, tuple)):
+ try:
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
+ except (TypeError, ValueError):
+ return str(value)
+ if isinstance(value, set):
+ try:
+ return json.dumps(sorted(value), ensure_ascii=False)
+ except (TypeError, ValueError):
+ return str(value)
+ return value
+
+
def _write_sheet(
ws,
columns: Sequence[str],
@@ -657,6 +695,7 @@ def _write_sheet(
v = record.get(col)
if col in numeric_set:
v = _to_float_if_numeric(v)
+ v = _to_excel_compatible(v)
row_values.append(v)
ws.append(row_values)
diff --git a/tools/nightly/generate_nightly_perf_html.py b/tools/nightly/generate_nightly_perf_html.py
index 05dc48d71..a50a46255 100644
--- a/tools/nightly/generate_nightly_perf_html.py
+++ b/tools/nightly/generate_nightly_perf_html.py
@@ -17,7 +17,7 @@ from typing import Any
LOGGER = logging.getLogger(__name__)
_RESULT_JSON_PREFIX = "result_test_"
-_DIFFUSION_JSON_PREFIX = "diffusion_perf_"
+_DIFFUSION_JSON_PREFIXES = ("diffusion_perf_", "diffusion_result_")
DEFAULT_INPUT_DIR = os.getenv("DEFAULT_INPUT_DIR") or "tests"
DEFAULT_OUTPUT_DIR = os.getenv("DEFAULT_OUTPUT_DIR") or "tests"
DEFAULT_DIFFUSION_INPUT_DIR = os.getenv("DIFFUSION_BENCHMARK_DIR")
@@ -51,7 +51,7 @@ def _default_diffusion_input_dir(input_dir: str) -> str:
return DEFAULT_DIFFUSION_INPUT_DIR if DEFAULT_DIFFUSION_INPUT_DIR else input_dir
-def _load_json_file(path: str) -> dict[str, Any] | None:
+def _load_json_file(path: str) -> dict[str, Any] | list[Any] | None:
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
@@ -59,14 +59,15 @@ def _load_json_file(path: str) -> dict[str, Any] | None:
LOGGER.warning("failed to load json '%s': %s", path, exc)
return None
- if not isinstance(data, dict):
- LOGGER.warning("json root in '%s' is not an object, skip", path)
+ if not isinstance(data, (dict, list)):
+ LOGGER.warning("json root in '%s' is not an object or list, skip", path)
return None
return data
def _parse_from_filename(filename: str) -> dict[str, Any]:
+ """Parse ``result_test_*.json`` filenames; same rules as ``generate_nightly_perf_excel``."""
name, ext = os.path.splitext(filename)
if ext != ".json" or not name.startswith(_RESULT_JSON_PREFIX):
return {}
@@ -75,32 +76,58 @@ def _parse_from_filename(filename: str) -> dict[str, Any]:
parts = core.split("_")
if len(parts) < 5:
LOGGER.warning(
- "filename '%s' does not match expected pattern, skip parsing test metadata",
+ "filename '%s' does not match expected pattern (need >= 5 segments), skip parsing",
filename,
)
return {}
- timestamp = parts[-1]
- num_prompts_str = parts[-2]
- max_concurrency_str = parts[-3]
- dataset_name = parts[-4]
- test_name = "_".join(parts[:-4]) if parts[:-4] else ""
+ idx = len(parts) - 1
+ timestamp = parts[idx]
+ idx -= 1
parsed: dict[str, Any] = {}
if len(timestamp) >= 15:
parsed["date"] = timestamp
- if dataset_name in ("random", "random-mm"):
- parsed["dataset_name"] = dataset_name
+
+ if idx >= 0 and parts[idx].startswith("out"):
+ parsed["random_output_len"] = parts[idx][3:]
+ idx -= 1
+ if idx >= 0 and parts[idx].startswith("in"):
+ parsed["random_input_len"] = parts[idx][2:]
+ idx -= 1
+
+ if idx < 3:
+ LOGGER.warning(
+ "filename '%s' has too few segments after timestamp / optional in-out (idx=%s)",
+ filename,
+ idx,
+ )
+ return parsed
+
+ num_prompts_str = parts[idx]
+ idx -= 1
+ flow_str = parts[idx]
+ idx -= 1
+ dataset_name = parts[idx]
+ idx -= 1
+ test_name = "_".join(parts[: idx + 1]) if idx >= 0 else ""
+
try:
parsed["num_prompts"] = int(num_prompts_str)
except (TypeError, ValueError):
pass
+
try:
- parsed["max_concurrency"] = int(max_concurrency_str)
+ parsed["max_concurrency"] = int(flow_str)
except (TypeError, ValueError):
pass
+
if test_name:
parsed["test_name"] = test_name
+
+ if dataset_name in ("random", "random-mm"):
+ parsed["dataset_name"] = dataset_name
+
return parsed
@@ -143,9 +170,10 @@ def _iter_omni_json_records(input_dir: str) -> Iterable[dict[str, Any]]:
def _parse_diffusion_from_filename(filename: str) -> dict[str, Any]:
name, ext = os.path.splitext(filename)
- if ext != ".json" or not name.startswith(_DIFFUSION_JSON_PREFIX):
+ if ext != ".json" or not any(name.startswith(prefix) for prefix in _DIFFUSION_JSON_PREFIXES):
return {}
- core = name[len(_DIFFUSION_JSON_PREFIX) :]
+ matched_prefix = next(prefix for prefix in _DIFFUSION_JSON_PREFIXES if name.startswith(prefix))
+ core = name[len(matched_prefix) :]
parts = core.split("_")
if len(parts) < 2:
return {}
@@ -168,7 +196,7 @@ def _iter_diffusion_json_records(input_dir: str) -> Iterable[dict[str, Any]]:
return
for entry in sorted(os.listdir(input_dir)):
- if not entry.endswith(".json") or not entry.startswith(_DIFFUSION_JSON_PREFIX):
+ if not entry.endswith(".json") or not any(entry.startswith(prefix) for prefix in _DIFFUSION_JSON_PREFIXES):
continue
full_path = os.path.join(input_dir, entry)
if not os.path.isfile(full_path):
@@ -177,17 +205,32 @@ def _iter_diffusion_json_records(input_dir: str) -> Iterable[dict[str, Any]]:
if data is None:
continue
- record: dict[str, Any] = dict(data)
filename_meta = _parse_diffusion_from_filename(os.path.basename(full_path))
- if "date" not in record or not record.get("date"):
- record["date"] = filename_meta.get("date") or datetime.now(
- timezone.utc,
- ).strftime("%Y%m%d-%H%M%S")
- if "test_name" not in record or not record.get("test_name"):
- if "test_name" in filename_meta:
- record["test_name"] = filename_meta["test_name"]
- record["source_file"] = os.path.basename(full_path)
- yield record
+ if isinstance(data, dict):
+ records = [data]
+ elif isinstance(data, list):
+ records = [r for r in data if isinstance(r, dict)]
+ else:
+ records = []
+
+ if not records:
+ LOGGER.warning("diffusion json '%s' has no valid records, skip", full_path)
+ continue
+
+ for record in records:
+ flat: dict[str, Any] = dict(record)
+ result = flat.pop("result", None)
+ if isinstance(result, dict):
+ flat.update(result)
+ if "date" not in flat or not flat.get("date"):
+ flat["date"] = filename_meta.get("date") or datetime.now(
+ timezone.utc,
+ ).strftime("%Y%m%d-%H%M%S")
+ if "test_name" not in flat or not flat.get("test_name"):
+ if "test_name" in filename_meta:
+ flat["test_name"] = filename_meta["test_name"]
+ flat["source_file"] = os.path.basename(full_path)
+ yield flat
def _collect_omni_records(input_dir: str) -> list[dict[str, Any]]:
diff --git a/tools/pre_commit/check_pickle_imports.py b/tools/pre_commit/check_pickle_imports.py
index 1c08a1543..819a7c8c3 100644
--- a/tools/pre_commit/check_pickle_imports.py
+++ b/tools/pre_commit/check_pickle_imports.py
@@ -16,8 +16,7 @@ import regex as re
# alternatives like msgpack or pydantic that are already in use in vLLM. Only
# add to this list if absolutely necessary and after careful security review.
ALLOWED_FILES = {
- "tests/e2e/offline_inference/utils.py",
- "tests/utils.py",
+ "tests/helpers/process.py",
"vllm_omni/diffusion/distributed/group_coordinator.py",
"tests/diffusion/attention/test_attention_sp.py",
}
diff --git a/vllm_omni/__init__.py b/vllm_omni/__init__.py
index cec8b0af7..65ad79c72 100644
--- a/vllm_omni/__init__.py
+++ b/vllm_omni/__init__.py
@@ -12,6 +12,12 @@ Architecture:
processing
"""
+# We import version early, because it will warn if vLLM / vLLM Omni
+# are not using the same major + minor version (if vLLM is installed).
+# We should do this before applying patch, because vLLM imports might
+# throw in patch if the versions differ.
+from .version import __version__, __version_tuple__ # isort:skip # noqa: F401
+
try:
from . import patch # noqa: F401
except ModuleNotFoundError as exc: # pragma: no cover - optional dependency
@@ -25,8 +31,6 @@ from vllm_omni.transformers_utils import configs as _configs # noqa: F401, E402
from .config import OmniModelConfig
-from .version import __version__, __version_tuple__ # isort:skip
-
def __getattr__(name: str):
# Lazy import for AsyncOmni and Omni to avoid pulling in heavy
diff --git a/vllm_omni/benchmarks/data_modules/daily_omni_dataset.py b/vllm_omni/benchmarks/data_modules/daily_omni_dataset.py
new file mode 100644
index 000000000..01b86d0fd
--- /dev/null
+++ b/vllm_omni/benchmarks/data_modules/daily_omni_dataset.py
@@ -0,0 +1,887 @@
+"""Daily-Omni Dataset loader for benchmark.
+
+Daily-Omni is an audio-visual reasoning benchmark with 684 videos
+and 1,197 multiple-choice QA pairs across 6 major task types.
+
+Dataset source: https://huggingface.co/datasets/liarliar/Daily-Omni
+
+Supports loading QA metadata from:
+- Local JSON file (``qa_json_path``): recommended for offline/air-gapped environments
+- HuggingFace datasets (``dataset_path``): legacy online mode
+
+The videos must be separately downloaded and extracted from Videos.tar.
+
+Why ``BenchmarkDataset`` instead of ``HuggingFaceDataset``?
+ vLLM's ``HuggingFaceDataset`` is a thin wrapper whose ``__init__`` always ends by calling
+ ``load_data()`` → ``datasets.load_dataset(...)`` with a required Hub id and split. That
+ contract fits "Hub-only" benches, but Daily-Omni also needs **offline QA metadata** from a
+ local ``qa.json`` without touching the network. Subclassing ``HuggingFaceDataset`` would
+ mean fighting the parent constructor (fake ``dataset_path``, reordering ``load_data``, or
+ duplicating half the parent) and would still imply ``datasets`` is always relevant.
+
+ This class therefore inherits only ``BenchmarkDataset`` (minimal: ``dataset_path``,
+ ``random_seed``, ``self.data``) and implements **two explicit loaders**:
+ ``_load_from_local_json`` (default path for air-gapped runs) and ``_load_from_huggingface``
+ (optional legacy path for users who prefer ``datasets`` + Hub cache). The latter is **not**
+ inheritance; it is the same Hub rows as before, factored into a helper so one class can
+ serve both deployment modes without mandatory ``datasets`` when using ``qa_json_path``.
+
+Usage:
+ from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniDataset
+
+ # Local JSON mode (recommended)
+ dataset = DailyOmniDataset(
+ qa_json_path="/path/to/qa.json",
+ video_dir="/path/to/Videos",
+ random_seed=42,
+ )
+
+ # HuggingFace mode (legacy, requires network)
+ dataset = DailyOmniDataset(
+ dataset_path="liarliar/Daily-Omni",
+ dataset_split="train",
+ random_seed=42,
+ )
+ requests = dataset.sample(
+ tokenizer=tokenizer,
+ num_requests=100,
+ output_len=256,
+ )
+"""
+
+import base64
+import json
+import logging
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Literal
+
+try:
+ from vllm.benchmarks.datasets import BenchmarkDataset, SampleRequest
+except ImportError:
+ # Fallback: if BenchmarkDataset not available, use base class from same module
+ from vllm.benchmarks.datasets import HuggingFaceDataset as BenchmarkDataset
+ from vllm.benchmarks.datasets import SampleRequest
+from vllm.tokenizers import TokenizerLike
+from vllm.tokenizers.hf import get_cached_tokenizer
+
+try:
+ from datasets import load_dataset
+except ImportError:
+ load_dataset = None
+
+logger = logging.getLogger(__name__)
+
+
+class _ListDatasetIterator:
+ """Simple iterator wrapper around a list to mimic HuggingFace streaming dataset behavior."""
+
+ def __init__(self, data: list[dict[str, Any]]) -> None:
+ self._data = data
+ self._index = 0
+
+ def __iter__(self):
+ self._index = 0
+ return self
+
+ def __next__(self) -> dict[str, Any]:
+ if self._index >= len(self._data):
+ raise StopIteration
+ item = self._data[self._index]
+ self._index += 1
+ return item
+
+ def __len__(self) -> int:
+ return len(self._data)
+
+ def __getitem__(self, idx: int | slice) -> dict[str, Any] | list[dict[str, Any]]:
+ return self._data[idx]
+
+
+# Aligns with Lliar-liar/Daily-Omni CLI ``--input_mode`` (test_model/*/testmodel.py).
+DailyOmniInputMode = Literal["all", "visual", "audio"]
+
+# ``build_conversation()`` in Daily-Omni ``test_model/Qwen2.5-Omni/testmodel.py`` (verbatim).
+DAILY_OMNI_SYSTEM_TEXT = (
+ "You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, "
+ "capable of perceiving auditory and visual inputs, as well as generating text and speech."
+)
+
+
+@dataclass
+class DailyOmniSampleRequest(SampleRequest):
+ """``SampleRequest`` with Daily-Omni gold labels for post-run accuracy scoring."""
+
+ daily_omni_gold_answer: str = ""
+ daily_omni_video_id: str = ""
+ daily_omni_task_type: str = ""
+ #: Official qa.json ``video_duration`` (e.g. ``30s``, ``60s``) for leaderboard-style breakdown.
+ daily_omni_video_duration: str = ""
+ #: Official ``video_category`` (YouTube-style category string) for per-category accuracy.
+ daily_omni_video_category: str = ""
+ #: Extra JSON fields merged into chat-completions ``extra_body`` (e.g. ``mm_processor_kwargs``).
+ omni_extra_body: dict[str, Any] | None = None
+ #: Full OpenAI ``messages`` (system + user) mirroring upstream Daily-Omni conversation.
+ omni_chat_messages: list[dict[str, Any]] | None = None
+ #: Used only when ``omni_chat_messages`` is None (non-Daily-Omni-style requests).
+ omni_chat_mm_position: Literal["first", "last"] = "last"
+
+
+class DailyOmniDataset(BenchmarkDataset):
+ """Daily-Omni audio-visual QA dataset for benchmarking.
+
+ Inherits ``BenchmarkDataset`` only (not ``HuggingFaceDataset``): see module docstring for why
+ Hub loading lives in ``_load_from_huggingface`` instead of subclassing the HF base class.
+
+ The dataset includes:
+ - 684 videos from daily life scenarios (available in Videos.tar)
+ - 1,197 multiple-choice QA pairs in qa.json
+ - 6 major task categories
+
+ QA metadata can be loaded from:
+ - Local JSON file (``qa_json_path``): recommended for offline/air-gapped environments
+ - HuggingFace datasets (``dataset_path``): legacy online mode
+
+ The videos must be separately downloaded and extracted from Videos.tar.
+
+ Args:
+ qa_json_path: Path to local qa.json file (offline mode, preferred). When provided,
+ ``dataset_path`` and ``dataset_split`` are ignored.
+ dataset_path: HuggingFace dataset path (e.g., "liarliar/Daily-Omni"). Used only if
+ ``qa_json_path`` is not provided (legacy online mode).
+ dataset_split: Dataset split to use (default: "train"). Used only in online mode.
+ random_seed: Random seed for shuffling
+ video_dir: Directory containing extracted video files (default: None)
+ input_mode: Which modalities to send, matching upstream Daily-Omni ``--input_mode``:
+ ``all`` — video + WAV (default; official audio-visual protocol);
+ ``visual`` — video only;
+ ``audio`` — extracted WAV only (requires ``{video_id}/{video_id}_audio.wav`` under ``video_dir``).
+ max_duration_seconds: Reserved for future ffprobe-based filtering; currently **not applied**
+ when building requests (metadata ``video_duration`` is still passed through for eval).
+ dataset_subset: Optional HuggingFace subset name (``load_dataset(..., name=...)``); used by bench
+ ``--hf-subset`` / patch.
+ no_stream: If True, load the Hub split non-streaming (matches bench ``--no-stream``).
+ inline_local_video: If True, embed local MP4 as ``data:video/mp4;base64,...`` in requests so
+ the API server does not need ``--allowed-local-media-path`` (large JSON; use for small runs).
+ When ``input_mode`` is ``audio`` or ``all``, local WAV is embedded the same way
+ (``data:audio/wav;base64,...``).
+ trust_remote_code: Whether to trust remote code when loading HuggingFace dataset
+ (online mode only).
+ """
+
+ SUPPORTED_DATASET_PATHS: set[str] = {
+ "liarliar/Daily-Omni",
+ }
+ #: Default Hub id for synthetic video URLs when ``qa_json_path`` is used (``dataset_path`` None).
+ DEFAULT_HF_DATASET_ID = "liarliar/Daily-Omni"
+ IS_MULTIMODAL = True
+ DEFAULT_OUTPUT_LEN = 256
+
+ def __init__(
+ self,
+ qa_json_path: str | None = None,
+ dataset_path: str | None = None,
+ dataset_split: str = "train",
+ random_seed: int = 0,
+ video_dir: str | None = None,
+ input_mode: DailyOmniInputMode = "all",
+ inline_local_video: bool = False,
+ trust_remote_code: bool = False,
+ max_duration_seconds: float | None = None,
+ dataset_subset: str | None = None,
+ no_stream: bool = False,
+ **kwargs,
+ ) -> None:
+ if input_mode not in ("all", "visual", "audio"):
+ raise ValueError(f"input_mode must be 'all', 'visual', or 'audio', got {input_mode!r}")
+
+ # Validate arguments: need either local JSON or HF path
+ if qa_json_path is None and dataset_path is None:
+ raise ValueError(
+ "Either 'qa_json_path' (local JSON) or 'dataset_path' (HuggingFace) must be provided. "
+ "For offline/air-gapped environments, download qa.json and use qa_json_path."
+ )
+
+ # Store configuration
+ self.qa_json_path = Path(qa_json_path) if qa_json_path else None
+ self.dataset_path = dataset_path
+ self.dataset_split = dataset_split
+ self.dataset_subset = dataset_subset
+ #: Match vLLM ``HuggingFaceDataset`` / bench CLI ``--no-stream``.
+ self._hf_streaming = not no_stream
+ self.video_dir = Path(video_dir) if video_dir else None
+ self.inline_local_video = inline_local_video
+ self.input_mode: DailyOmniInputMode = input_mode
+ self.max_duration_seconds = max_duration_seconds
+ self.trust_remote_code = trust_remote_code
+
+ #: In-process cache of ffprobe durations only (no disk persistence).
+ self._video_durations: dict[str, float] = {}
+
+ # Initialize parent BenchmarkDataset
+ super().__init__(
+ dataset_path=dataset_path if qa_json_path is None else None,
+ random_seed=random_seed,
+ **kwargs,
+ )
+
+ # Load data based on mode
+ self.load_data()
+
+ # Verify dataset info
+ logger.info(
+ "Loaded Daily-Omni dataset: mode=%s, source=%s, random_seed=%d, input_mode=%s, max_duration=%s",
+ "local_json" if self.qa_json_path else "huggingface",
+ str(self.qa_json_path) if self.qa_json_path else f"{dataset_path}/{dataset_split}",
+ random_seed,
+ input_mode,
+ f"{max_duration_seconds}s" if max_duration_seconds else "unlimited",
+ )
+
+ def load_data(self) -> None:
+ """Populate ``self.data`` from either local JSON or the Hub.
+
+ See module docstring: we do not subclass ``HuggingFaceDataset`` because Daily-Omni needs
+ a first-class offline path; Hub loading is an optional branch implemented below.
+ """
+ if self.qa_json_path is not None:
+ self._load_from_local_json()
+ else:
+ self._load_from_huggingface()
+
+ def _load_from_local_json(self) -> None:
+ """Load QA data from local JSON file."""
+ if not self.qa_json_path.exists():
+ raise FileNotFoundError(f"QA JSON file not found: {self.qa_json_path}")
+
+ with open(self.qa_json_path, encoding="utf-8") as f:
+ data = json.load(f)
+
+ # Support both list format and dict with "train"/"test" splits
+ if isinstance(data, dict):
+ # Try to get the requested split, fallback to first available
+ split_data = data.get(self.dataset_split)
+ if split_data is None:
+ available = list(data.keys())
+ if available:
+ logger.warning(
+ "Split '%s' not found in %s, using '%s' instead",
+ self.dataset_split,
+ self.qa_json_path,
+ available[0],
+ )
+ split_data = data[available[0]]
+ else:
+ split_data = []
+ data = split_data
+
+ if not isinstance(data, list):
+ raise ValueError(f"Expected list of QA items in JSON, got {type(data).__name__}")
+
+ # Shuffle if requested
+ if not getattr(self, "disable_shuffle", False) and self.random_seed is not None:
+ import random
+
+ rng = random.Random(self.random_seed)
+ shuffled = data[:]
+ rng.shuffle(shuffled)
+ data = shuffled
+
+ # Create an iterator-like wrapper for compatibility
+ self.data = _ListDatasetIterator(data)
+
+ def _load_from_huggingface(self) -> None:
+ """Load QA rows via ``datasets.load_dataset`` (legacy / convenience path).
+
+ Kept for backward compatibility: callers can still pass ``dataset_path=liarliar/Daily-Omni``
+ and get the same parquet-backed rows as the Hub dataset card, with streaming (or
+ non-streaming if ``no_stream=True``) and shuffle.
+
+ This is intentionally **not** implemented by subclassing ``HuggingFaceDataset``: that base
+ always runs Hub ``load_dataset`` from its constructor and expects a Hub id as the primary
+ API; Daily-Omni instead chooses the source in ``load_data()`` (JSON vs Hub) while sharing
+ one ``sample()`` / request-building implementation for both.
+ """
+ if load_dataset is None:
+ raise ImportError(
+ "datasets library is required for HuggingFace mode. "
+ "Install with: pip install datasets, or use local JSON mode instead."
+ )
+
+ ds = load_dataset(
+ self.dataset_path,
+ name=self.dataset_subset,
+ split=self.dataset_split,
+ streaming=self._hf_streaming,
+ trust_remote_code=self.trust_remote_code,
+ )
+ if not getattr(self, "disable_shuffle", False):
+ ds = ds.shuffle(seed=self.random_seed)
+ self.data = ds
+
+ def get_task_statistics(self) -> dict[str, int]:
+ """Get distribution of task types in the dataset.
+
+ Returns:
+ Dict mapping task type to count
+ """
+ stats: dict[str, int] = {}
+ for item in self.data:
+ row = self._coerce_row(item)
+ fields = self._normalize_qa_fields(row)
+ task_type = fields["task_type"] or "unknown"
+ stats[task_type] = stats.get(task_type, 0) + 1
+ return stats
+
+ @staticmethod
+ def _coerce_row(item: Any) -> dict[str, Any]:
+ """Turn a dataset row into a plain dict (Arrow / Mapping)."""
+ if isinstance(item, dict):
+ return item
+ if hasattr(item, "as_py"):
+ return dict(item.as_py()) # pyarrow Row
+ try:
+ return dict(item)
+ except (TypeError, ValueError):
+ return {k: item[k] for k in item} # type: ignore[misc]
+
+ @staticmethod
+ def _normalize_qa_fields(row: dict[str, Any]) -> dict[str, Any]:
+ """Map official Daily-Omni qa.json / Hub schema to internal fields.
+
+ Official fields (see liarliar/Daily-Omni ``qa.json``): ``Question``, ``Choice`` (list),
+ ``Answer``, ``video_id``, ``Type``, ``video_duration`` (``30s`` / ``60s``), ``video_category``,
+ plus other category columns. Legacy aliases (lowercase / older loaders) are still accepted.
+ """
+ out: dict[str, Any] = {}
+
+ out["question"] = str(row.get("Question") or row.get("question") or "").strip()
+ vid = row.get("video_id") if row.get("video_id") is not None else row.get("video")
+ out["video_id"] = str(vid).strip() if vid is not None else ""
+ out["task_type"] = str(row.get("Type") or row.get("task_type") or row.get("type") or "").strip()
+ vc = row.get("video_category") if row.get("video_category") is not None else row.get("videoCategory")
+ out["video_category"] = str(vc).strip() if vc is not None else ""
+ vd = row.get("video_duration") if row.get("video_duration") is not None else row.get("videoDuration")
+ out["video_duration"] = str(vd).strip() if vd is not None else ""
+ out["answer"] = str(row.get("Answer") or row.get("answer") or "").strip()
+ vu = row.get("video_url") if row.get("video_url") is not None else row.get("Video_URL")
+ out["video_url"] = str(vu).strip() if vu is not None and str(vu).strip() else None
+
+ choice = row.get("Choice")
+ if choice is None:
+ choice = row.get("options") or row.get("choice")
+ out["choice"] = choice
+
+ return out
+
+ def sample(
+ self,
+ tokenizer: TokenizerLike,
+ num_requests: int,
+ output_len: int | None = None,
+ request_id_prefix: str = "",
+ no_oversample: bool = False,
+ **kwargs,
+ ) -> list[SampleRequest]:
+ """Sample requests from Daily-Omni dataset.
+
+ Args:
+ tokenizer: Tokenizer for computing prompt length
+ num_requests: Number of requests to sample
+ output_len: Target output length in tokens (default: 256)
+ request_id_prefix: Prefix for request IDs
+ no_oversample: If True, do not oversample if fewer examples available
+ **kwargs: Additional arguments (ignored)
+
+ Returns:
+ List of SampleRequest objects with video URLs and prompts
+ """
+ if output_len is None:
+ output_len = self.DEFAULT_OUTPUT_LEN
+
+ sampled_requests: list[SampleRequest] = []
+ ind = 0
+ cached_tokenizer = get_cached_tokenizer(tokenizer)
+
+ # Iterate over shuffled dataset
+ for item in self.data:
+ if len(sampled_requests) >= num_requests:
+ break
+
+ request = self._create_sample_request(
+ self._coerce_row(item), cached_tokenizer, output_len, request_id_prefix, ind
+ )
+ if request:
+ sampled_requests.append(request)
+ ind += 1
+
+ logger.info("Created %d sample requests from Daily-Omni dataset", len(sampled_requests))
+
+ # Handle oversampling if needed
+ self.maybe_oversample_requests(sampled_requests, num_requests, request_id_prefix, no_oversample)
+
+ return sampled_requests
+
+ def _create_sample_request(
+ self,
+ qa_item: dict[str, Any],
+ tokenizer: TokenizerLike,
+ output_len: int,
+ request_id_prefix: str,
+ index: int,
+ ) -> SampleRequest | None:
+ """Create a SampleRequest from a QA item.
+
+ Args:
+ qa_item: QA pair from the dataset
+ tokenizer: Tokenizer
+ output_len: Target output length
+ request_id_prefix: Prefix for request ID
+ index: Request index
+
+ Returns:
+ SampleRequest or None if invalid
+ """
+ fields = self._normalize_qa_fields(qa_item)
+ video_id = fields["video_id"]
+ question = fields["question"]
+ choice = fields["choice"]
+ task_type = fields["task_type"]
+ video_url = fields["video_url"]
+ video_duration = fields.get("video_duration") or ""
+ video_category = fields.get("video_category") or ""
+
+ if not video_id and not video_url:
+ logger.warning("Skipping item: no video_id / video_url")
+ return None
+
+ if not question:
+ logger.warning("Skipping item: no question found")
+ return None
+
+ # Official layout after extracting Videos.tar (see Lliar-liar/Daily-Omni test_model):
+ # {video_base_dir}/{video_id}/{video_id}_video.mp4
+ mm_payload, omni_extra, mm_pos = self._compose_daily_omni_multimodal(video_id, video_url)
+ if not mm_payload:
+ return None
+
+ messages = self._build_daily_omni_openai_messages(mm_payload, question, choice)
+ user_text = self._official_daily_omni_user_prompt(question, choice)
+ # Text-only length estimate (same as before: no MM token count in bench).
+ prompt_len = len(tokenizer.encode(f"{DAILY_OMNI_SYSTEM_TEXT}\n{user_text}"))
+
+ return DailyOmniSampleRequest(
+ prompt=user_text,
+ prompt_len=prompt_len,
+ expected_output_len=output_len,
+ multi_modal_data=None,
+ request_id=f"{request_id_prefix}{index}",
+ daily_omni_gold_answer=fields["answer"],
+ daily_omni_video_id=video_id,
+ daily_omni_task_type=task_type,
+ daily_omni_video_duration=video_duration,
+ daily_omni_video_category=video_category,
+ omni_extra_body=omni_extra,
+ omni_chat_messages=messages,
+ omni_chat_mm_position=mm_pos,
+ )
+
+ @staticmethod
+ def _official_video_relpath(video_id: str) -> str:
+ """Relative path inside extracted ``Videos/`` per upstream Daily-Omni scripts."""
+ return f"{video_id}/{video_id}_video.mp4"
+
+ @staticmethod
+ def _official_audio_relpath(video_id: str) -> str:
+ """Relative path for extracted WAV per upstream ``get_audio_path``."""
+ return f"{video_id}/{video_id}_audio.wav"
+
+ def _resolve_local_video_path(self, video_id: str) -> Path | None:
+ """Pick an existing file under ``video_dir`` (official layout + flat fallback)."""
+ if not self.video_dir or not video_id:
+ return None
+
+ candidates = [
+ self.video_dir / self._official_video_relpath(video_id),
+ self.video_dir / f"{video_id}.mp4", # flat layout (custom mirrors / outdated docs)
+ ]
+ seen: set[Path] = set()
+ for p in candidates:
+ rp = p.resolve()
+ if rp in seen:
+ continue
+ seen.add(rp)
+ if p.exists():
+ return p
+ return None
+
+ def _resolve_local_audio_path(self, video_id: str) -> Path | None:
+ """Pick an existing WAV under ``video_dir`` (official layout + flat fallback)."""
+ if not self.video_dir or not video_id:
+ return None
+ candidates = [
+ self.video_dir / self._official_audio_relpath(video_id),
+ self.video_dir / f"{video_id}.wav",
+ ]
+ seen: set[Path] = set()
+ for p in candidates:
+ rp = p.resolve()
+ if rp in seen:
+ continue
+ seen.add(rp)
+ if p.exists():
+ return p
+ return None
+
+ def _local_file_to_video_url_payload(self, video_path: Path) -> dict[str, Any]:
+ """Build OpenAI-style video_url part for a resolved local file.
+
+ vLLM rejects ``file://`` unless the server was started with
+ ``--allowed-local-media-path`` set to a directory that **contains** the file
+ (typically the extracted ``Videos`` root). Use ``inline_local_video=True`` to
+ send base64 data URLs instead (no server path allowlist; larger requests).
+ """
+ path = video_path.expanduser().resolve()
+ if self.inline_local_video:
+ raw = path.read_bytes()
+ b64 = base64.b64encode(raw).decode("ascii")
+ return {
+ "type": "video_url",
+ "video_url": {"url": f"data:video/mp4;base64,{b64}"},
+ }
+ return {
+ "type": "video_url",
+ "video_url": {"url": path.as_uri()},
+ }
+
+ def _local_file_to_audio_url_payload(self, audio_path: Path) -> dict[str, Any]:
+ """Build OpenAI-style ``audio_url`` part for a resolved local WAV file."""
+ path = audio_path.expanduser().resolve()
+ if self.inline_local_video:
+ raw = path.read_bytes()
+ b64 = base64.b64encode(raw).decode("ascii")
+ return {
+ "type": "audio_url",
+ "audio_url": {"url": f"data:audio/wav;base64,{b64}"},
+ }
+ return {
+ "type": "audio_url",
+ "audio_url": {"url": path.as_uri()},
+ }
+
+ def _get_video_content(
+ self,
+ video_id: str,
+ video_url: str | None,
+ ) -> dict[str, Any] | None:
+ """Resolve video for OpenAI-style ``video_url`` content.
+
+ Upstream uses ``get_video_path(video_id, base) -> base/video_id/video_id_video.mp4``.
+ The Hub repo only publishes ``Videos.tar``; use ``--daily-omni-video-dir`` pointing
+ at the extracted ``Videos`` folder (parent of per-``video_id`` subdirs).
+
+ For ``file://`` URLs, start ``vllm serve`` with e.g.
+ ``--allowed-local-media-path /same/path/as/daily-omni-video-dir``.
+ """
+ if video_url:
+ url = video_url
+ if not url.startswith(("http://", "https://", "file://")):
+ url = f"https://{url.lstrip('/')}"
+ return {"type": "video_url", "video_url": {"url": url}}
+
+ if self.video_dir and video_id:
+ video_path = self._resolve_local_video_path(video_id)
+ if video_path is not None:
+ return self._local_file_to_video_url_payload(video_path)
+ logger.warning(
+ "Video not found under video_dir=%s for video_id=%r (expected %s or %s)",
+ self.video_dir,
+ video_id,
+ self._official_video_relpath(video_id),
+ f"{video_id}.mp4",
+ )
+
+ if video_id:
+ repo = self.dataset_path or self.DEFAULT_HF_DATASET_ID
+ rel = self._official_video_relpath(video_id)
+ hf_video_url = f"https://huggingface.co/datasets/{repo}/resolve/main/Videos/{rel}"
+ logger.debug(
+ "Using HF video URL (likely 404 — Hub ships Videos.tar only): %s",
+ hf_video_url,
+ )
+ return {"type": "video_url", "video_url": {"url": hf_video_url}}
+
+ logger.error("Could not determine video source for video_id=%r", video_id)
+ return None
+
+ def _get_audio_content(self, video_id: str) -> dict[str, Any] | None:
+ """Resolve extracted WAV for OpenAI-style ``audio_url`` (local files only)."""
+ if not self.video_dir or not video_id:
+ logger.warning(
+ "Daily-Omni input_mode %r requires --daily-omni-video-dir with %s",
+ self.input_mode,
+ self._official_audio_relpath(video_id),
+ )
+ return None
+ audio_path = self._resolve_local_audio_path(video_id)
+ if audio_path is not None:
+ return self._local_file_to_audio_url_payload(audio_path)
+ logger.warning(
+ "Audio not found under video_dir=%s for video_id=%r (expected %s or %s)",
+ self.video_dir,
+ video_id,
+ self._official_audio_relpath(video_id),
+ f"{video_id}.wav",
+ )
+ return None
+
+ def _compose_daily_omni_multimodal(
+ self,
+ video_id: str,
+ video_url: str | None,
+ ) -> tuple[dict[str, Any] | list[dict[str, Any]] | None, dict[str, Any] | None, Literal["first", "last"]]:
+ """Build ``multi_modal_data`` and request extras for the active ``input_mode``.
+
+ Mirrors upstream Daily-Omni: separate video + WAV with ``use_audio_in_video=False``.
+ """
+ extra: dict[str, Any] = {"mm_processor_kwargs": {"use_audio_in_video": False}}
+ mode = self.input_mode
+
+ if mode == "visual":
+ v = self._get_video_content(video_id, video_url)
+ return v, extra, "last"
+
+ if mode == "audio":
+ a = self._get_audio_content(video_id)
+ return a, extra, "first"
+
+ v = self._get_video_content(video_id, video_url)
+ a = self._get_audio_content(video_id)
+ if not v or not a:
+ return None, None, "first"
+ return [v, a], extra, "first"
+
+ @staticmethod
+ def _media_desc_for_official_prompt(mode: DailyOmniInputMode) -> str:
+ """``media_desc`` in upstream ``build_conversation``."""
+ if mode == "audio":
+ return "given audio"
+ if mode == "all":
+ return "given video and audio together"
+ return "given video"
+
+ @staticmethod
+ def _choices_repr_for_official_prompt(choice: Any) -> str:
+ """Format ``Choice`` from qa.json for the model (one option per line when possible).
+
+ Using ``str(list)`` embeds Python list brackets and quotes, which is poor for MCQ
+ reading; lists/tuples are joined with newlines instead. Other shapes fall back to
+ ``str(choice)`` for parity with exotic upstream payloads.
+ """
+ if choice is None:
+ return ""
+ if isinstance(choice, (list, tuple)):
+ lines = [str(x).strip() for x in choice if str(x).strip()]
+ return "\n".join(lines)
+ if isinstance(choice, dict):
+ return "\n".join(f"{k}. {v}" for k, v in choice.items())
+ return str(choice)
+
+ def _official_daily_omni_user_prompt(self, question: str, choice: Any) -> str:
+ """User text block from Daily-Omni ``build_conversation`` (after media parts)."""
+ task_prompt = self._media_desc_for_official_prompt(self.input_mode)
+ choices = self._choices_repr_for_official_prompt(choice)
+ # Single f-string with explicit newlines avoids accidental implicit concatenation
+ # gluing sentences (e.g. ``...media_desc.Select...``) when editing.
+ return (
+ "Your task is to accurately answer multiple-choice questions "
+ f"based on the {task_prompt}.\n"
+ "Select the single most accurate answer from the given choices.\n"
+ f"Question: {question}\n"
+ f"Choices: {choices}\n"
+ "Your answer should be a capital letter representing your choice: "
+ "A, B, C, or D. Don't generate any other text.\n"
+ )
+
+ def _build_daily_omni_openai_messages(
+ self,
+ mm_payload: dict[str, Any] | list[dict[str, Any]],
+ question: str,
+ choice: Any,
+ ) -> list[dict[str, Any]]:
+ """Map upstream conversation to OpenAI Chat Completions ``messages`` (video_url / audio_url parts)."""
+ user_text = self._official_daily_omni_user_prompt(question, choice)
+ mm_list: list[dict[str, Any]] = mm_payload if isinstance(mm_payload, list) else [mm_payload]
+ user_content: list[dict[str, Any]] = [*mm_list, {"type": "text", "text": user_text}]
+ return [
+ {"role": "system", "content": [{"type": "text", "text": DAILY_OMNI_SYSTEM_TEXT}]},
+ {"role": "user", "content": user_content},
+ ]
+
+ def sample_by_task_type(
+ self,
+ tokenizer: TokenizerLike,
+ task_type: str,
+ num_samples: int,
+ output_len: int | None = None,
+ request_id_prefix: str = "",
+ **kwargs,
+ ) -> list[SampleRequest]:
+ """Sample requests filtered by task type.
+
+ Args:
+ tokenizer: Tokenizer
+ task_type: Task type to filter by
+ num_samples: Number of samples
+ output_len: Target output length
+ request_id_prefix: Prefix for request IDs
+ **kwargs: Additional sampling arguments
+
+ Returns:
+ List of SampleRequest objects matching the task type
+ """
+ if output_len is None:
+ output_len = self.DEFAULT_OUTPUT_LEN
+
+ filtered = [
+ item for item in self.data if self._normalize_qa_fields(self._coerce_row(item))["task_type"] == task_type
+ ]
+
+ available = len(filtered)
+ if available < num_samples:
+ logger.warning(
+ "Only %d samples available for task type '%s', requested %d",
+ available,
+ task_type,
+ num_samples,
+ )
+ num_samples = available
+
+ sampled_requests: list[SampleRequest] = []
+ cached_tokenizer = get_cached_tokenizer(tokenizer)
+
+ for i, item in enumerate(filtered[:num_samples]):
+ request = self._create_sample_request(item, cached_tokenizer, output_len, request_id_prefix, i)
+ if request:
+ sampled_requests.append(request)
+
+ return sampled_requests
+
+ def __repr__(self) -> str:
+ return (
+ f"DailyOmniDataset("
+ f"dataset_path={self.dataset_path!r}, "
+ f"dataset_split={self.dataset_split!r}, "
+ f"video_dir={self.video_dir!r}, "
+ f"input_mode={self.input_mode!r}, "
+ f"inline_local_video={self.inline_local_video!r}, "
+ f"max_duration_seconds={self.max_duration_seconds}, "
+ f"random_seed={self.random_seed}"
+ f")"
+ )
+
+
+def load_daily_omni_dataset(
+ qa_json_path: str | None = None,
+ dataset_path: str | None = None,
+ dataset_split: str = "train",
+ random_seed: int = 0,
+ video_dir: str | None = None,
+ input_mode: DailyOmniInputMode = "all",
+ max_duration_seconds: float | None = None,
+ dataset_subset: str | None = None,
+ no_stream: bool = False,
+ **kwargs,
+) -> DailyOmniDataset:
+ """Convenience function to load Daily-Omni dataset.
+
+ Args:
+ qa_json_path: Path to local qa.json file (recommended for offline/air-gapped environments).
+ When provided, ``dataset_path`` is ignored.
+ dataset_path: HuggingFace dataset path (default: liarliar/Daily-Omni). Used only if
+ ``qa_json_path`` is not provided (legacy online mode).
+ dataset_split: Dataset split to use (default: "train")
+ random_seed: Random seed for shuffling
+ video_dir: Directory containing extracted ``Videos/`` tree (MP4 and, for ``all``/``audio``, WAV)
+ input_mode: ``visual`` | ``audio`` | ``all`` (same semantics as upstream Daily-Omni)
+ max_duration_seconds: Maximum video duration in seconds (e.g., 30 for 30s subset, 60 for 60s subset);
+ uses ffprobe on local files under ``video_dir`` (in-memory cache only for this process).
+ **kwargs: Additional arguments passed to DailyOmniDataset
+
+ Returns:
+ DailyOmniDataset instance
+
+ Example:
+ >>> from vllm_omni.benchmarks.data_modules.daily_omni_dataset import load_daily_omni_dataset
+
+ # Local JSON mode (recommended for offline)
+ >>> dataset = load_daily_omni_dataset(
+ ... qa_json_path="/path/to/qa.json",
+ ... video_dir="/path/to/Daily-Omni/Videos",
+ ... random_seed=42,
+ ... max_duration_seconds=30,
+ ... )
+
+ # HuggingFace mode (legacy online)
+ >>> dataset = load_daily_omni_dataset(
+ ... dataset_path="liarliar/Daily-Omni",
+ ... video_dir="/path/to/Daily-Omni/Videos",
+ ... random_seed=42,
+ ... )
+ >>> requests = dataset.sample(tokenizer, num_requests=100)
+ """
+ return DailyOmniDataset(
+ qa_json_path=qa_json_path,
+ dataset_path=dataset_path,
+ dataset_split=dataset_split,
+ random_seed=random_seed,
+ video_dir=video_dir,
+ input_mode=input_mode,
+ max_duration_seconds=max_duration_seconds,
+ dataset_subset=dataset_subset,
+ no_stream=no_stream,
+ **kwargs,
+ )
+
+
+def get_daily_omni_statistics(
+ qa_json_path: str | None = None,
+ dataset_path: str | None = DailyOmniDataset.DEFAULT_HF_DATASET_ID,
+ dataset_split: str = "train",
+) -> dict[str, Any]:
+ """Get statistics about the Daily-Omni dataset.
+
+ Args:
+ qa_json_path: Path to local qa.json file (recommended for offline/air-gapped environments).
+ When provided, ``dataset_path`` is ignored.
+ dataset_path: HuggingFace dataset path. Defaults to ``DailyOmniDataset.DEFAULT_HF_DATASET_ID``
+ when ``qa_json_path`` is omitted. Pass ``None`` only together with ``qa_json_path``.
+ dataset_split: Dataset split to use (default: "train")
+
+ Returns:
+ Statistics dict with task type distribution and other info
+
+ Example:
+ >>> from vllm_omni.benchmarks.data_modules.daily_omni_dataset import get_daily_omni_statistics
+
+ # Local JSON mode
+ >>> stats = get_daily_omni_statistics(qa_json_path="/path/to/qa.json")
+
+ # HuggingFace mode
+ >>> stats = get_daily_omni_statistics(dataset_path="liarliar/Daily-Omni")
+ >>> print(f"Total QA pairs: {stats['total_qa_pairs']}")
+ >>> print(f"Task distribution: {stats['task_distribution']}")
+ """
+ dataset = DailyOmniDataset(
+ qa_json_path=qa_json_path,
+ dataset_path=dataset_path,
+ dataset_split=dataset_split,
+ )
+ task_stats = dataset.get_task_statistics()
+
+ source = str(qa_json_path) if qa_json_path else f"{dataset_path}/{dataset_split}"
+ return {
+ "source": source,
+ "total_qa_pairs": len(list(dataset.data)),
+ "task_distribution": task_stats,
+ }
diff --git a/vllm_omni/benchmarks/data_modules/daily_omni_eval.py b/vllm_omni/benchmarks/data_modules/daily_omni_eval.py
new file mode 100644
index 000000000..ecc9edc84
--- /dev/null
+++ b/vllm_omni/benchmarks/data_modules/daily_omni_eval.py
@@ -0,0 +1,406 @@
+"""Daily-Omni multiple-choice accuracy scoring for vLLM-Omni bench serve.
+
+Compares model ``generated_text`` to dataset ``Answer`` (A/B/C/D).
+
+**Alignment with open-source** (`Lliar-liar/Daily-Omni` ``test_model/.../testmodel.py``):
+
+- Answer extraction defaults to the same rules as ``extract_choice_letter`` (strip after an
+ ``assistant`` marker, then leading ``A``–``D``, else first ``\\b[A-D]\\b``). Set env
+ ``DAILY_OMNI_EXTRACT_MODE=relaxed`` to use the older vLLM-Omni heuristics (last ``answer:``,
+ tail scan, etc.).
+- Overall accuracy comparable to the official script uses **successful HTTP responses only** as
+ the denominator (their ``valid_questions = total - failed`` excludes inference / I/O skips).
+ We also report ``daily_omni_accuracy_incl_http_fail`` where each failed request counts as a
+ wrong answer in the denominator (stricter throughput-bench view).
+- **By video length:** mirrors upstream ``--- Accuracy by Video Duration ---`` for ``30s`` /
+ ``60s`` (``qa.json`` ``video_duration``): ``daily_omni_per_duration*`` metrics and a printed block.
+- **By video category:** mirrors ``--- Accuracy by Video Category ---`` using ``video_category``
+ from ``qa.json`` (``daily_omni_per_category*``; empty category is bucketed as ``unknown``).
+- **Correctness:** uses the same ``evaluate_answer`` rule as upstream (truthy extracted letter vs
+ raw ``Answer`` string, both ``strip().upper()``). Rows with empty ``Answer`` are skipped
+ (``no_gold``), matching missing-field skips in the official loop.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+from typing import Any
+
+from vllm.benchmarks.lib.endpoint_request_func import RequestFuncOutput
+
+from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniSampleRequest
+
+_VALID = frozenset("ABCD")
+
+# Official ``testmodel.py`` buckets (``qa.json`` ``video_duration``).
+DAILY_OMNI_DURATION_KEYS: tuple[str, ...] = ("30s", "60s")
+
+
+def extract_choice_letter_official(text: str | None) -> str | None:
+ """Port of Daily-Omni ``extract_choice_letter`` (first A–D, assistant-tail semantics)."""
+ if not text:
+ return None
+ raw = str(text).strip()
+ if not raw:
+ return None
+ match = re.search(r"assistant\s*([\s\S]*)$", raw, flags=re.IGNORECASE)
+ candidate = match.group(1).strip() if match else raw
+ direct = re.match(r"(?i)^\s*([A-D])(?:[\s\.\)::]|$)", candidate)
+ if direct:
+ return direct.group(1).upper()
+ fallback = re.search(r"\b([A-D])\b", candidate.upper())
+ if fallback:
+ return fallback.group(1)
+ return None
+
+
+def evaluate_answer_official(model_answer: str | None, correct_answer: str) -> bool:
+ """Port of Daily-Omni ``evaluate_answer`` (strict string match after strip/upper)."""
+ if not model_answer:
+ return False
+ return model_answer.strip().upper() == (correct_answer or "").strip().upper()
+
+
+def normalize_gold_answer(gold: str) -> str | None:
+ """Best-effort single letter from ``Answer`` (for ``gold_normalized`` in saved items only)."""
+ g = (gold or "").strip().upper()
+ if len(g) == 1 and g in _VALID:
+ return g
+ m = re.search(r"([ABCD])\b", g)
+ if m:
+ return m.group(1).upper()
+ return None
+
+
+def _extract_predicted_choice_relaxed(text: str) -> str | None:
+ """Legacy vLLM-Omni heuristics (last ``answer:`` patterns, tail scan)."""
+ if not text or not str(text).strip():
+ return None
+ t = str(text).strip()
+
+ strong_patterns = [
+ r"(?i)\*\*answer\*\*\s*[::]?\s*\(?([ABCD])\)?",
+ r"(?i)\banswer\s*[::]?\s*\(?([ABCD])\)?",
+ r"(?i)\bfinal\s+answer\s*[::]?\s*\(?([ABCD])\)?",
+ r"(?i)\bcorrect\s+(?:answer|option)\s*[::]?\s*\(?([ABCD])\)?",
+ r"(?i)\bthe\s+(?:correct\s+)?option\s+(?:is|would\s+be)\s*\(?([ABCD])\)?",
+ r"(?i)\bI\s+(?:would\s+)?(?:choose|select|pick)\s*\(?([ABCD])\)?",
+ ]
+ last_letter: str | None = None
+ for pat in strong_patterns:
+ for m in re.finditer(pat, t):
+ last_letter = m.group(1).upper()
+ if last_letter:
+ return last_letter
+
+ # Weaker phrases: first match can be spurious; still prefer last occurrence.
+ weak_patterns = [
+ r"(?i)\boption\s*[::]?\s*\(?([ABCD])\)?",
+ r"(?i)\bchoice\s*[::]?\s*\(?([ABCD])\)?",
+ ]
+ for pat in weak_patterns:
+ for m in re.finditer(pat, t):
+ last_letter = m.group(1).upper()
+ if last_letter:
+ return last_letter
+
+ paren = list(re.finditer(r"\(([ABCD])\)", t))
+ if paren:
+ return paren[-1].group(1).upper()
+
+ # First line sometimes is just "B" or "B." — allow if whole output is short
+ one_line = t.split("\n", 1)[0].strip()
+ if len(t) < 120 and len(one_line) <= 6:
+ m0 = re.match(r"^([ABCD])\s*[.:\)]?\s*$", one_line, re.I)
+ if m0:
+ return m0.group(1).upper()
+
+ # Tail-only: avoids matching echoed "A. ..." option blocks at the start
+ tail_len = min(500, len(t))
+ tail = t[-tail_len:]
+ # ``\b`` after the letter avoids "Because"/"Definitely" false positives
+ m = re.search(r"(?:^|[^\w])([ABCD])\b", tail, re.I)
+ if m:
+ return m.group(1).upper()
+
+ return None
+
+
+def extract_predicted_choice(text: str | None) -> str | None:
+ """Parse model output to A–D (official Daily-Omni rules by default)."""
+ if not text or not str(text).strip():
+ return None
+ mode = os.environ.get("DAILY_OMNI_EXTRACT_MODE", "official").strip().lower()
+ if mode in ("relaxed", "heuristic", "legacy"):
+ return _extract_predicted_choice_relaxed(str(text))
+ return extract_choice_letter_official(text)
+
+
+def compute_daily_omni_accuracy_metrics(
+ input_requests: list[Any],
+ outputs: list[RequestFuncOutput],
+ *,
+ include_per_item: bool = False,
+) -> dict[str, Any] | None:
+ """If all requests are :class:`DailyOmniSampleRequest`, compute accuracy stats.
+
+ Rows with empty ``Answer`` (after strip) are skipped as ``no_gold``, like upstream missing
+ ``correct_answer``.
+
+ **Denominators:** The open-source script excludes items that hit inference / I/O failures
+ from ``valid_questions``; we mirror that with ``daily_omni_accuracy`` (= correct /
+ successful responses). Failed HTTP requests are also tracked and used in
+ ``daily_omni_accuracy_incl_http_fail`` (each failure counts as incorrect in the
+ denominator).
+ """
+ if not input_requests or len(input_requests) != len(outputs):
+ return None
+ if not all(isinstance(r, DailyOmniSampleRequest) for r in input_requests):
+ return None
+
+ # total / correct: all rows with gold (incl. HTTP fail in total)
+ # total_ok / correct_ok: successful HTTP only (GitHub-style per-type denominator)
+ per_task: dict[str, dict[str, int]] = {}
+ per_category: dict[str, dict[str, int]] = {}
+ per_duration: dict[str, dict[str, int]] = {
+ k: {"correct": 0, "total": 0, "correct_ok": 0, "total_ok": 0} for k in DAILY_OMNI_DURATION_KEYS
+ }
+ items: list[dict[str, Any]] = []
+ correct = 0
+ evaluated = 0
+ no_gold = 0
+ request_failed = 0
+ parse_failed = 0 # success but could not extract A–D
+
+ for req, out in zip(input_requests, outputs, strict=True):
+ assert isinstance(req, DailyOmniSampleRequest)
+ gold_raw = (req.daily_omni_gold_answer or "").strip()
+ gold_norm = normalize_gold_answer(req.daily_omni_gold_answer)
+ tt = (req.daily_omni_task_type or "unknown").strip() or "unknown"
+ dur_key = (req.daily_omni_video_duration or "").strip()
+ dur_active = dur_key in per_duration
+ cat_key = (req.daily_omni_video_category or "").strip() or "unknown"
+ if tt not in per_task:
+ per_task[tt] = {"correct": 0, "total": 0, "correct_ok": 0, "total_ok": 0}
+ if cat_key not in per_category:
+ per_category[cat_key] = {"correct": 0, "total": 0, "correct_ok": 0, "total_ok": 0}
+
+ if not gold_raw:
+ no_gold += 1
+ items.append(
+ {
+ "request_id": req.request_id,
+ "skipped": True,
+ "reason": "no_gold",
+ "task_type": tt,
+ "video_id": req.daily_omni_video_id,
+ "video_duration": dur_key or None,
+ "video_category": cat_key if cat_key != "unknown" else None,
+ }
+ )
+ continue
+
+ if not out.success:
+ request_failed += 1
+ evaluated += 1
+ per_task[tt]["total"] += 1
+ per_category[cat_key]["total"] += 1
+ if dur_active:
+ per_duration[dur_key]["total"] += 1
+ # GitHub: failed inference not in valid_questions — do not increment total_ok
+ items.append(
+ {
+ "request_id": req.request_id,
+ "gold": gold_raw,
+ "gold_normalized": gold_norm,
+ "predicted": None,
+ "correct": False,
+ "task_type": tt,
+ "video_id": req.daily_omni_video_id,
+ "video_duration": dur_key or None,
+ "video_category": cat_key if cat_key != "unknown" else None,
+ "error": (out.error or "")[:500],
+ }
+ )
+ continue
+
+ pred = extract_predicted_choice(out.generated_text)
+ evaluated += 1
+ per_task[tt]["total"] += 1
+ per_task[tt]["total_ok"] += 1
+ per_category[cat_key]["total"] += 1
+ per_category[cat_key]["total_ok"] += 1
+ if dur_active:
+ per_duration[dur_key]["total"] += 1
+ per_duration[dur_key]["total_ok"] += 1
+ if pred is None:
+ parse_failed += 1
+ is_correct = evaluate_answer_official(pred, req.daily_omni_gold_answer)
+ if is_correct:
+ correct += 1
+ per_task[tt]["correct"] += 1
+ per_task[tt]["correct_ok"] += 1
+ per_category[cat_key]["correct"] += 1
+ per_category[cat_key]["correct_ok"] += 1
+ if dur_active:
+ per_duration[dur_key]["correct"] += 1
+ per_duration[dur_key]["correct_ok"] += 1
+
+ items.append(
+ {
+ "request_id": req.request_id,
+ "gold": gold_raw,
+ "gold_normalized": gold_norm,
+ "predicted": pred,
+ "correct": is_correct,
+ "parse_failed": pred is None,
+ "task_type": tt,
+ "video_id": req.daily_omni_video_id,
+ "video_duration": dur_key or None,
+ "video_category": cat_key if cat_key != "unknown" else None,
+ }
+ )
+
+ evaluated_ok = evaluated - request_failed
+ accuracy_github = (correct / evaluated_ok) if evaluated_ok else None
+ accuracy_incl_fail = (correct / evaluated) if evaluated else None
+
+ per_task_accuracy: dict[str, float | None] = {}
+ per_task_accuracy_github: dict[str, float | None] = {}
+ for name, st in per_task.items():
+ tot = st["total"]
+ per_task_accuracy[name] = (st["correct"] / tot) if tot else None
+ tok = st["total_ok"]
+ per_task_accuracy_github[name] = (st["correct_ok"] / tok) if tok else None
+
+ per_category_accuracy: dict[str, float | None] = {}
+ per_category_accuracy_github: dict[str, float | None] = {}
+ for name, st in per_category.items():
+ tot = st["total"]
+ per_category_accuracy[name] = (st["correct"] / tot) if tot else None
+ tok = st["total_ok"]
+ per_category_accuracy_github[name] = (st["correct_ok"] / tok) if tok else None
+
+ per_duration_accuracy: dict[str, float | None] = {}
+ per_duration_accuracy_github: dict[str, float | None] = {}
+ for name, st in per_duration.items():
+ tot = st["total"]
+ per_duration_accuracy[name] = (st["correct"] / tot) if tot else None
+ tok = st["total_ok"]
+ per_duration_accuracy_github[name] = (st["correct_ok"] / tok) if tok else None
+
+ out: dict[str, Any] = {
+ # Comparable to GitHub testmodel.py: correct / successful inferences
+ "daily_omni_accuracy": accuracy_github,
+ "daily_omni_accuracy_incl_http_fail": accuracy_incl_fail,
+ "daily_omni_correct": correct,
+ "daily_omni_evaluated": evaluated,
+ "daily_omni_evaluated_ok": evaluated_ok,
+ "daily_omni_no_gold": no_gold,
+ "daily_omni_request_failed": request_failed,
+ "daily_omni_parse_failed": parse_failed,
+ "daily_omni_per_task": {k: dict(v) for k, v in per_task.items()},
+ "daily_omni_per_task_accuracy": per_task_accuracy,
+ "daily_omni_per_task_accuracy_github_style": per_task_accuracy_github,
+ "daily_omni_per_category": {k: dict(v) for k, v in per_category.items()},
+ "daily_omni_per_category_accuracy": per_category_accuracy,
+ "daily_omni_per_category_accuracy_github_style": per_category_accuracy_github,
+ "daily_omni_per_duration": {k: dict(v) for k, v in per_duration.items()},
+ "daily_omni_per_duration_accuracy": per_duration_accuracy,
+ "daily_omni_per_duration_accuracy_github_style": per_duration_accuracy_github,
+ }
+ if include_per_item:
+ out["daily_omni_eval_items"] = items
+ return out
+
+
+def print_daily_omni_accuracy_summary(metrics: dict[str, Any]) -> None:
+ """Pretty-print accuracy block (stdout)."""
+ acc = metrics.get("daily_omni_accuracy")
+ acc_fail = metrics.get("daily_omni_accuracy_incl_http_fail")
+ if acc is None and acc_fail is None and metrics.get("daily_omni_evaluated", 0) == 0:
+ return
+ print("{s:{c}^{n}}".format(s=" Daily-Omni accuracy (MCQ) ", n=50, c="="))
+ ok = int(metrics.get("daily_omni_evaluated_ok", 0) or 0)
+ cor = int(metrics.get("daily_omni_correct", 0) or 0)
+ if ok > 0 and acc is not None:
+ print(f"Overall Accuracy: {cor}/{ok} = {acc:.2%}")
+ elif int(metrics.get("daily_omni_evaluated", 0) or 0) > 0:
+ print("Overall Accuracy: 0/0 = N/A (no successful HTTP responses)")
+ print(
+ "{:<40} {:<10}".format(
+ "Submitted (gold present):",
+ metrics.get("daily_omni_evaluated", 0),
+ )
+ )
+ print(
+ "{:<40} {:<10}".format(
+ "Successful HTTP (GitHub denom.):",
+ metrics.get("daily_omni_evaluated_ok", 0),
+ )
+ )
+ print("{:<40} {:<10}".format("Correct:", metrics.get("daily_omni_correct", 0)))
+ if acc is not None:
+ print("{:<40} {:<10.4f}".format("Accuracy (ratio, same as above):", acc))
+ if acc_fail is not None and metrics.get("daily_omni_request_failed", 0):
+ print(
+ "{:<40} {:<10.4f}".format(
+ "Accuracy (incl. HTTP as wrong):",
+ acc_fail,
+ )
+ )
+ print("{:<40} {:<10}".format("Skipped (no gold):", metrics.get("daily_omni_no_gold", 0)))
+ print(
+ "{:<40} {:<10}".format(
+ "HTTP failed (excl. from GitHub acc.):",
+ metrics.get("daily_omni_request_failed", 0),
+ )
+ )
+ print(
+ "{:<40} {:<10}".format(
+ "Parsed OK but no A–D found:",
+ metrics.get("daily_omni_parse_failed", 0),
+ )
+ )
+ pt = metrics.get("daily_omni_per_task") or {}
+ pta = metrics.get("daily_omni_per_task_accuracy_github_style") or {}
+ if pta:
+ print("\n--- Accuracy by QA Type ---")
+ for name in sorted(pta.keys()):
+ a = pta[name]
+ st = pt.get(name) or {}
+ tok = int(st.get("total_ok", 0) or 0)
+ cok = int(st.get("correct_ok", 0) or 0)
+ if tok and a is not None:
+ print(f"{name}: {cok}/{tok} = {a:.2%}")
+ else:
+ print(f"{name}: 0/0 = N/A")
+
+ pc = metrics.get("daily_omni_per_category") or {}
+ ptc = metrics.get("daily_omni_per_category_accuracy_github_style") or {}
+ if ptc:
+ print("\n--- Accuracy by Video Category ---")
+ for name in sorted(ptc.keys()):
+ a = ptc[name]
+ st = pc.get(name) or {}
+ tok = int(st.get("total_ok", 0) or 0)
+ cok = int(st.get("correct_ok", 0) or 0)
+ if tok and a is not None:
+ print(f"{name}: {cok}/{tok} = {a:.2%}")
+ else:
+ print(f"{name}: 0/0 = N/A")
+
+ pdf = metrics.get("daily_omni_per_duration_accuracy_github_style") or {}
+ if pdf:
+ print("\n--- Accuracy by Video Duration ---")
+ for name in DAILY_OMNI_DURATION_KEYS:
+ a = pdf.get(name)
+ st = (metrics.get("daily_omni_per_duration") or {}).get(name) or {}
+ tok = int(st.get("total_ok", 0) or 0)
+ cor = int(st.get("correct_ok", 0) or 0)
+ if tok and a is not None:
+ print(f"{name} Duration: {cor}/{tok} = {a:.2%}")
+ else:
+ print(f"{name} Duration: 0/0 = N/A")
+ print("=" * 50)
diff --git a/vllm_omni/benchmarks/data_modules/daily_omni_text_audio.py b/vllm_omni/benchmarks/data_modules/daily_omni_text_audio.py
new file mode 100644
index 000000000..69fbe026b
--- /dev/null
+++ b/vllm_omni/benchmarks/data_modules/daily_omni_text_audio.py
@@ -0,0 +1,255 @@
+"""Daily-Omni: optional consistency check between text stream and generated speech.
+
+The benchmark MCQ accuracy uses ``generated_text`` only. When the omni server also
+streams ``modality=audio`` (TTS), this module can transcribe the concatenated WAV
+with Whisper and compare the inferred option letter to the one parsed from text.
+
+Requires ``openai-whisper`` (``pip install openai-whisper``). Enable via env
+``DAILY_OMNI_TEXT_AUDIO_CONSISTENCY=1`` or CLI ``--daily-omni-text-audio-consistency``.
+
+Whisper model name defaults to ``tiny`` (override with ``DAILY_OMNI_WHISPER_MODEL``).
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+import threading
+from typing import Any
+
+from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniSampleRequest
+from vllm_omni.benchmarks.data_modules.daily_omni_eval import extract_predicted_choice
+
+logger = logging.getLogger(__name__)
+
+_whisper_model = None
+_whisper_model_name: str | None = None
+_whisper_lock = threading.Lock()
+
+
+def env_text_audio_check_enabled() -> bool:
+ return os.environ.get("DAILY_OMNI_TEXT_AUDIO_CONSISTENCY", "").lower() in (
+ "1",
+ "true",
+ "yes",
+ )
+
+
+def extract_choice_from_asr_transcript(transcript: str) -> str | None:
+ """Parse A–D from ASR text; extends :func:`extract_predicted_choice` with spoken Chinese phrases."""
+ c = extract_predicted_choice(transcript)
+ if c:
+ return c
+ t = transcript or ""
+ for pat in (
+ r"(?i)选项\s*([ABCD])\b",
+ r"(?i)选\s*([ABCD])\b",
+ r"(?i)答案\s*是\s*([ABCD])\b",
+ r"(?i)答案\s*([ABCD])\b",
+ ):
+ m = re.search(pat, t)
+ if m:
+ return m.group(1).upper()
+ return None
+
+
+def _get_whisper_model(model_name: str):
+ global _whisper_model, _whisper_model_name
+ with _whisper_lock:
+ if _whisper_model is None or _whisper_model_name != model_name:
+ import whisper
+
+ logger.warning(
+ "Loading Whisper model %r for Daily-Omni text/audio consistency (one-time)...",
+ model_name,
+ )
+ _whisper_model = whisper.load_model(model_name)
+ _whisper_model_name = model_name
+ return _whisper_model
+
+
+def transcribe_wav_bytes(
+ wav_bytes: bytes,
+ *,
+ language: str | None = None,
+ model_name: str | None = None,
+) -> tuple[str | None, str | None]:
+ """Transcribe WAV bytes. Returns ``(transcript, error)`` — one of them is set.
+
+ Args:
+ wav_bytes: RIFF WAV file bytes.
+ language: Optional Whisper language code (e.g. ``en``, ``zh``); improves accuracy/latency.
+ model_name: Override model id; else ``DAILY_OMNI_WHISPER_MODEL`` or ``tiny``.
+ """
+ if not wav_bytes:
+ return None, "empty_wav"
+ if model_name is None or not str(model_name).strip():
+ model_name = os.environ.get("DAILY_OMNI_WHISPER_MODEL") or "tiny"
+ model_name = str(model_name).strip() or "tiny"
+ path: str | None = None
+ try:
+ import tempfile
+
+ model = _get_whisper_model(model_name)
+ fd, path = tempfile.mkstemp(suffix=".wav")
+ with os.fdopen(fd, "wb") as fp:
+ fp.write(wav_bytes)
+ kwargs: dict = {}
+ if language:
+ kwargs["language"] = language
+ result = model.transcribe(path, **kwargs)
+ text = (result.get("text") or "").strip()
+ return (text if text else None), None
+ except ImportError:
+ return None, "openai-whisper is not installed (pip install openai-whisper)"
+ except Exception as e:
+ return None, str(e)[:500]
+ finally:
+ if path:
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+
+
+def compute_daily_omni_text_audio_consistency_metrics(
+ input_requests: list[Any],
+ outputs: list[Any],
+ *,
+ include_per_item: bool = False,
+) -> dict[str, Any] | None:
+ """Compare option letter from ``generated_text`` vs Whisper transcript of output audio.
+
+ Only considers requests where ``outputs[i]`` has ``generated_audio_wav_bytes`` set
+ (populated by the omni benchmark when TA check is enabled).
+ """
+ if not input_requests or len(input_requests) != len(outputs):
+ return None
+ if not all(isinstance(r, DailyOmniSampleRequest) for r in input_requests):
+ return None
+
+ ta_no_wav = 0
+ ta_asr_failed = 0
+ ta_text_unparsed = 0
+ ta_audio_unparsed = 0
+ ta_consistent = 0
+ ta_mismatch = 0
+ ta_both_parsed = 0
+ items: list[dict[str, Any]] = []
+
+ for req, out in zip(input_requests, outputs, strict=True):
+ assert isinstance(req, DailyOmniSampleRequest)
+ rid = req.request_id
+ if not getattr(out, "success", False):
+ if include_per_item:
+ items.append(
+ {
+ "request_id": rid,
+ "skipped": True,
+ "reason": "request_not_success",
+ }
+ )
+ continue
+
+ wav = getattr(out, "generated_audio_wav_bytes", None)
+ if not wav:
+ ta_no_wav += 1
+ if include_per_item:
+ items.append(
+ {
+ "request_id": rid,
+ "skipped": False,
+ "reason": "no_output_audio",
+ "text_choice": extract_predicted_choice(getattr(out, "generated_text", "") or ""),
+ }
+ )
+ continue
+
+ transcript, asr_err = transcribe_wav_bytes(wav)
+ if asr_err:
+ ta_asr_failed += 1
+ if include_per_item:
+ items.append(
+ {
+ "request_id": rid,
+ "asr_error": asr_err,
+ "text_choice": extract_predicted_choice(getattr(out, "generated_text", "") or ""),
+ }
+ )
+ continue
+
+ text_choice = extract_predicted_choice(getattr(out, "generated_text", "") or "")
+ audio_choice = extract_choice_from_asr_transcript(transcript or "")
+
+ if text_choice is None:
+ ta_text_unparsed += 1
+ if audio_choice is None:
+ ta_audio_unparsed += 1
+
+ if text_choice is not None and audio_choice is not None:
+ ta_both_parsed += 1
+ if text_choice == audio_choice:
+ ta_consistent += 1
+ else:
+ ta_mismatch += 1
+
+ if include_per_item:
+ consistent: bool | None
+ if text_choice is None or audio_choice is None:
+ consistent = None
+ else:
+ consistent = text_choice == audio_choice
+ items.append(
+ {
+ "request_id": rid,
+ "text_choice": text_choice,
+ "audio_choice": audio_choice,
+ "asr_transcript": (transcript or "")[:500],
+ "text_audio_consistent": consistent,
+ }
+ )
+
+ comparable = ta_consistent + ta_mismatch
+ rate = (ta_consistent / comparable) if comparable else None
+
+ out: dict[str, Any] = {
+ "daily_omni_ta_enabled": True,
+ "daily_omni_ta_no_output_audio": ta_no_wav,
+ "daily_omni_ta_asr_failed": ta_asr_failed,
+ "daily_omni_ta_text_unparsed": ta_text_unparsed,
+ "daily_omni_ta_audio_unparsed": ta_audio_unparsed,
+ "daily_omni_ta_both_parsed": ta_both_parsed,
+ "daily_omni_ta_consistent": ta_consistent,
+ "daily_omni_ta_mismatch": ta_mismatch,
+ "daily_omni_ta_consistency_rate": rate,
+ }
+ if include_per_item:
+ out["daily_omni_ta_items"] = items
+ return out
+
+
+def print_daily_omni_text_audio_summary(metrics: dict[str, Any]) -> None:
+ if not metrics.get("daily_omni_ta_enabled"):
+ return
+ print("{s:{c}^{n}}".format(s=" Daily-Omni text vs audio (ASR) ", n=50, c="="))
+ print("{:<40} {:<10}".format("No output audio captured:", metrics.get("daily_omni_ta_no_output_audio", 0)))
+ print("{:<40} {:<10}".format("ASR failed:", metrics.get("daily_omni_ta_asr_failed", 0)))
+ print("{:<40} {:<10}".format("Both text+audio letter parsed:", metrics.get("daily_omni_ta_both_parsed", 0)))
+ print("{:<40} {:<10}".format("Consistent (same letter):", metrics.get("daily_omni_ta_consistent", 0)))
+ print("{:<40} {:<10}".format("Mismatch:", metrics.get("daily_omni_ta_mismatch", 0)))
+ r = metrics.get("daily_omni_ta_consistency_rate")
+ if r is not None:
+ print("{:<40} {:<10.4f}".format("Consistency rate (of both parsed):", r))
+ print(
+ "{:<40} {:<10}".format(
+ "Text unparsed (among w/ audio):",
+ metrics.get("daily_omni_ta_text_unparsed", 0),
+ )
+ )
+ print(
+ "{:<40} {:<10}".format(
+ "Audio unparsed (among w/ audio):",
+ metrics.get("daily_omni_ta_audio_unparsed", 0),
+ )
+ )
diff --git a/vllm_omni/benchmarks/data_modules/seed_tts_dataset.py b/vllm_omni/benchmarks/data_modules/seed_tts_dataset.py
new file mode 100644
index 000000000..ca6de4cb2
--- /dev/null
+++ b/vllm_omni/benchmarks/data_modules/seed_tts_dataset.py
@@ -0,0 +1,272 @@
+"""Seed-TTS zero-shot evaluation-style prompts for ``vllm bench serve``.
+
+Loads rows from the `meta.lst` format used in `BytedanceSpeech/seed-tts-eval`_ (or any
+HuggingFace dataset repo with the same layout)::
+
+ utt_id|prompt_transcript|prompt_wav_relative_path|text_to_synthesize
+
+Each benchmark request supplies target text plus ``ref_text`` / ``ref_audio`` (Qwen3-TTS ``Base`` /
+voice clone), merged into the JSON body. By default ``ref_audio`` is an inline ``data:`` URL so
+the server does not need ``--allowed-local-media-path``. Use ``--seed-tts-file-ref-audio`` for
+``file://`` (smaller bodies; requires that flag). Use ``--backend openai-audio-speech``
+(``/v1/audio/speech``) or ``--backend openai-chat-omni`` (``/v1/chat/completions`` with the same
+fields on the body plus a Qwen3-Omni-style ``system`` message and the target text as ``user`` content).
+
+.. _BytedanceSpeech/seed-tts-eval: https://github.com/BytedanceSpeech/seed-tts-eval
+"""
+
+from __future__ import annotations
+
+import base64
+import logging
+import random
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from vllm.benchmarks.datasets import BenchmarkDataset, SampleRequest
+from vllm.tokenizers import TokenizerLike
+from vllm.tokenizers.hf import get_cached_tokenizer
+
+logger = logging.getLogger(__name__)
+
+# Matches Qwen3-Omni serving examples (``openai_chat_completion_client_for_multimodal_generation`` /
+# ``qwen3_omni/gradio_demo``) plus explicit TTS / voice-clone instructions for chat completions.
+SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT = (
+ "You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, "
+ "capable of perceiving auditory and visual inputs, as well as generating text and speech.\n"
+ "For this request you act as a text-to-speech engine with zero-shot voice cloning: "
+ "the API provides reference audio and its transcript (ref_audio, ref_text) and task_type Base. "
+ "The user message is the exact text you must speak. "
+ "Synthesize natural speech in the same language as that user text, "
+ "matching the timbre, prosody, and speaking style of the reference audio while reading the new content clearly."
+)
+
+
+@dataclass
+class SeedTTSSampleRequest(SampleRequest):
+ """``SampleRequest`` with per-row fields merged into ``/v1/audio/speech`` JSON."""
+
+ #: Shallow-merged into ``RequestFuncInput.extra_body`` (ref_audio, ref_text, task_type, …).
+ seed_tts_speech_extra: dict[str, Any] | None = None
+ seed_tts_utterance_id: str = ""
+ seed_tts_locale: str = ""
+ #: For ``openai-chat-omni``: becomes the chat ``system`` message (Qwen3-Omni + TTS behavior).
+ seed_tts_system_prompt: str = ""
+ #: Local path to reference prompt WAV (for SIM vs. synthesized PCM in ``seed_tts_eval``).
+ seed_tts_ref_wav_path: str = ""
+
+
+@dataclass
+class _SeedTTSRow:
+ utterance_id: str
+ ref_text: str
+ prompt_wav_rel: str
+ target_text: str
+
+
+def _parse_meta_line(line: str) -> _SeedTTSRow | None:
+ line = line.strip()
+ if not line or line.startswith("#"):
+ return None
+ parts = line.split("|")
+ if len(parts) < 4:
+ logger.warning("Skipping malformed meta.lst line (need 4 '|'-fields): %r", line[:120])
+ return None
+ utt_id, ref_text, wav_rel, target = parts[0], parts[1], parts[2], parts[3]
+ if not target.strip():
+ return None
+ return _SeedTTSRow(
+ utterance_id=utt_id.strip(),
+ ref_text=ref_text.strip(),
+ prompt_wav_rel=wav_rel.strip(),
+ target_text=target.strip(),
+ )
+
+
+def _load_meta_rows(meta_file: Path) -> list[_SeedTTSRow]:
+ text = meta_file.read_text(encoding="utf-8")
+ rows: list[_SeedTTSRow] = []
+ for line in text.splitlines():
+ r = _parse_meta_line(line)
+ if r is not None:
+ rows.append(r)
+ return rows
+
+
+def resolve_seed_tts_root(dataset_path: str | None, *, explicit_root: str | None) -> Path:
+ """Return directory containing ``{locale}/meta.lst`` and ``{locale}/prompt-wavs/``."""
+ if explicit_root:
+ root = Path(explicit_root).expanduser().resolve()
+ if not root.is_dir():
+ raise FileNotFoundError(f"--seed-tts-root is not a directory: {root}")
+ return root
+
+ if not dataset_path:
+ raise ValueError("Seed-TTS requires --dataset-path (HF repo id or local root) or --seed-tts-root.")
+
+ p = Path(dataset_path).expanduser()
+ if p.exists() and p.is_dir():
+ return p.resolve()
+
+ repo_id = dataset_path.strip()
+ try:
+ from huggingface_hub import snapshot_download
+ except ImportError as e:
+ raise ImportError(
+ "Install huggingface_hub to download Seed-TTS from the Hub, or clone the dataset "
+ "locally and pass --dataset-path / --seed-tts-root to that directory."
+ ) from e
+ cache = snapshot_download(repo_id=repo_id, repo_type="dataset")
+ return Path(cache).resolve()
+
+
+def _ref_audio_payload(wav_path: Path, *, inline: bool) -> str:
+ if inline:
+ raw = wav_path.read_bytes()
+ b64 = base64.b64encode(raw).decode("ascii")
+ return f"data:audio/wav;base64,{b64}"
+ return wav_path.expanduser().resolve().as_uri()
+
+
+class SeedTTSDataset(BenchmarkDataset):
+ """Seed-TTS-style zero-shot TTS rows for throughput/latency benchmarking.
+
+ Args:
+ dataset_path: HuggingFace dataset repo id (``org/dataset``) or local directory with
+ ``en/meta.lst`` (and ``zh/meta.lst`` if using zh).
+ locale: ``en`` or ``zh`` — which subfolder under the root to read.
+ inline_ref_audio: If True (default), embed prompt WAV as ``data:audio/wav;base64,...``
+ so Qwen3-TTS / ``/v1/audio/speech`` works without server
+ ``--allowed-local-media-path``. If False, use ``file://`` (smaller
+ requests; server must set ``--allowed-local-media-path`` to the dataset root).
+ seed_tts_root: Optional override for the root directory (same layout as HF dataset).
+ system_prompt: Optional override for the chat system message when using
+ ``--backend openai-chat-omni``; defaults to :data:`SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT`.
+ """
+
+ IS_MULTIMODAL = False
+ DEFAULT_OUTPUT_LEN = 2048
+
+ def __init__(
+ self,
+ dataset_path: str,
+ random_seed: int = 0,
+ locale: str = "en",
+ inline_ref_audio: bool = True,
+ seed_tts_root: str | None = None,
+ system_prompt: str | None = None,
+ disable_shuffle: bool = False,
+ **kwargs: Any,
+ ) -> None:
+ if locale not in ("en", "zh"):
+ raise ValueError("locale must be 'en' or 'zh'")
+ self.locale = locale
+ self.inline_ref_audio = inline_ref_audio
+ self._explicit_root = seed_tts_root
+ sp = (system_prompt or "").strip()
+ self._system_prompt = sp if sp else SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT
+ super().__init__(
+ dataset_path=dataset_path,
+ random_seed=random_seed,
+ disable_shuffle=disable_shuffle,
+ **kwargs,
+ )
+ self._root = resolve_seed_tts_root(self.dataset_path, explicit_root=self._explicit_root)
+ self._rows: list[_SeedTTSRow] = []
+ self.load_data()
+
+ def load_data(self) -> None:
+ meta = self._root / self.locale / "meta.lst"
+ if not meta.is_file():
+ raise FileNotFoundError(
+ f"Seed-TTS meta not found: {meta}. "
+ f"Expected layout from seed-tts-eval (e.g. {self._root}/{self.locale}/meta.lst)."
+ )
+ self._rows = _load_meta_rows(meta)
+ if not self._rows:
+ raise ValueError(f"No valid rows in {meta}")
+ if not self.disable_shuffle:
+ rng = random.Random(self.random_seed)
+ rng.shuffle(self._rows)
+ self.data = self._rows
+ logger.info(
+ "Loaded Seed-TTS: root=%s locale=%s rows=%d inline_ref_audio=%s",
+ self._root,
+ self.locale,
+ len(self._rows),
+ self.inline_ref_audio,
+ )
+
+ def sample(
+ self,
+ tokenizer: TokenizerLike,
+ num_requests: int,
+ output_len: int | None = None,
+ request_id_prefix: str = "",
+ no_oversample: bool = False,
+ **kwargs: Any,
+ ) -> list[SampleRequest]:
+ if output_len is None:
+ output_len = self.DEFAULT_OUTPUT_LEN
+
+ tok = get_cached_tokenizer(tokenizer)
+ out: list[SampleRequest] = []
+ for i, row in enumerate(self._rows):
+ if len(out) >= num_requests:
+ break
+ wav_path = (self._root / self.locale / row.prompt_wav_rel).resolve()
+ if not wav_path.is_file():
+ logger.warning("Missing prompt wav for %s: %s", row.utterance_id, wav_path)
+ continue
+
+ target = row.target_text
+ prompt_len = len(tok.encode(f"{self._system_prompt}\n{target}"))
+ lang = "English" if self.locale == "en" else "Chinese"
+ ref_uri = _ref_audio_payload(wav_path, inline=self.inline_ref_audio)
+ speech_extra: dict[str, Any] = {
+ "ref_audio": ref_uri,
+ "ref_text": row.ref_text,
+ "task_type": "Base",
+ "language": lang,
+ "max_new_tokens": output_len,
+ }
+
+ out.append(
+ SeedTTSSampleRequest(
+ prompt=target,
+ prompt_len=prompt_len,
+ expected_output_len=output_len,
+ multi_modal_data=None,
+ request_id=f"{request_id_prefix}{i}",
+ seed_tts_speech_extra=speech_extra,
+ seed_tts_utterance_id=row.utterance_id,
+ seed_tts_locale=self.locale,
+ seed_tts_system_prompt=self._system_prompt,
+ seed_tts_ref_wav_path=str(wav_path),
+ )
+ )
+
+ logger.info("Seed-TTS: built %d requests (asked %d)", len(out), num_requests)
+ self.maybe_oversample_requests(out, num_requests, request_id_prefix, no_oversample)
+ return out
+
+
+def load_seed_tts_dataset(
+ dataset_path: str,
+ random_seed: int = 0,
+ locale: str = "en",
+ inline_ref_audio: bool = True,
+ seed_tts_root: str | None = None,
+ system_prompt: str | None = None,
+ **kwargs: Any,
+) -> SeedTTSDataset:
+ return SeedTTSDataset(
+ dataset_path=dataset_path,
+ random_seed=random_seed,
+ locale=locale,
+ inline_ref_audio=inline_ref_audio,
+ seed_tts_root=seed_tts_root,
+ system_prompt=system_prompt,
+ **kwargs,
+ )
diff --git a/vllm_omni/benchmarks/data_modules/seed_tts_eval.py b/vllm_omni/benchmarks/data_modules/seed_tts_eval.py
new file mode 100644
index 000000000..d5f1b6470
--- /dev/null
+++ b/vllm_omni/benchmarks/data_modules/seed_tts_eval.py
@@ -0,0 +1,729 @@
+"""Seed-TTS WER aligned with Bytedance ``seed-tts-eval`` / ``run_wer.py``.
+
+Matches the published protocol (see Hugging Face dataset card and
+https://github.com/BytedanceSpeech/seed-tts-eval):
+
+- **EN**: ``openai/whisper-large-v3`` via ``transformers``, audio resampled to **16 kHz**
+ (same as ``run_wer.py``).
+- **ZH**: ``funasr`` **paraformer-zh**, hypothesis converted with **zhconv** to zh-cn.
+- **WER**: ``jiwer`` after punctuation stripping (``zhon.hanzi.punctuation`` + ``string.punctuation``,
+ preserving ``'``) and EN lowercasing / ZH per-character spacing. Supports jiwer 3.x
+ (``compute_measures``) and 4.x (``process_words``).
+
+- **SIM** (speaker similarity proxy): cosine similarity of L2-normalized mean-pooled **WavLM**
+ embeddings (reference prompt WAV vs. synthesized PCM), 16 kHz. Official ``cal_sim.sh`` uses
+ UniSpeech ``verification_pair_list_v2.py`` with a **fine-tuned** WavLM SV checkpoint — set
+ ``SEED_TTS_WAVLM_MODEL`` to another HF id if you need closer parity. Disable with
+ ``SEED_TTS_SIM_EVAL=0``. Optional: ``SEED_TTS_SIM_DEVICE`` (e.g. ``cpu``) to avoid GPU
+ issues when Whisper already uses CUDA; ``SEED_TTS_WAVLM_MIN_SAMPLES`` pads very short
+ waveforms so the WavLM CNN front-end does not fail.
+
+- **UTMOS** (predicted MOS from TorchScript): default ``balacoon/utmos`` → ``utmos.jit``
+ (Sarulab-style demo export). Uses ``torch`` + ``huggingface_hub`` only. Aggregate metrics
+ are over **all requests with captured PCM** (independent of ASR/WER). Non-finite scores are
+ dropped and counted as failures. Override repo/file via ``SEED_TTS_UTMOS_HF_REPO`` /
+ ``SEED_TTS_UTMOS_JIT_FILE``. **Device**: defaults to **CPU** when ``SEED_TTS_UTMOS_DEVICE``
+ is unset; set ``SEED_TTS_UTMOS_DEVICE=cuda:0`` (or ``cuda:1`` etc.) to run on GPU. The JIT
+ model is loaded directly onto the target device via ``map_location`` to avoid cross-device
+ issues (some PyTorch builds/Windows have problems moving TorchScript modules after load).
+ Forward uses **float32** waveform in ``[-1, 1]`` (same as the WER resampled array) so
+ tensor dtypes match JIT weights; using int16 triggers
+ ``RuntimeError: input type and weight type should be same`` on common exports. Disable
+ with ``SEED_TTS_UTMOS_EVAL=0``.
+
+Enable with ``SEED_TTS_WER_EVAL=1`` or ``--seed-tts-wer-eval``. Install optional deps::
+
+ pip install 'vllm-omni[seed-tts-eval]'
+
+Env: ``SEED_TTS_EVAL_DEVICE`` (e.g. ``cuda:0``, ``cpu``); ``SEED_TTS_HF_WHISPER_MODEL``
+defaults to ``openai/whisper-large-v3`` (override for debugging only).
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+import math
+import os
+import statistics
+import string
+import tempfile
+import threading
+import wave
+from typing import Any
+
+import numpy as np
+from vllm.benchmarks.datasets import SampleRequest
+
+from vllm_omni.benchmarks.data_modules.seed_tts_dataset import SeedTTSSampleRequest
+
+logger = logging.getLogger(__name__)
+
+# Mirrors seed-tts-eval/run_wer.py
+OFFICIAL_WHISPER_HF_ID = "openai/whisper-large-v3"
+PARAFORMER_MODEL_ID = "paraformer-zh"
+
+_lock = threading.Lock()
+_device: str | None = None
+_en_processor = None
+_en_model = None
+_zh_paraformer = None
+_wavlm_model = None
+_wavlm_processor = None
+_wavlm_device: str | None = None
+_utmos_jit_model = None
+_utmos_jit_device: str | None = None
+_utmos_jit_load_failed = False
+_utmos_forward_warned = False
+
+
+def pcm_s16le_mono_to_wav_bytes(pcm: bytes, *, sample_rate: int = 24000) -> bytes:
+ buf = io.BytesIO()
+ with wave.open(buf, "wb") as wf:
+ wf.setnchannels(1)
+ wf.setsampwidth(2)
+ wf.setframerate(sample_rate)
+ wf.writeframes(pcm)
+ return buf.getvalue()
+
+
+def _get_eval_device() -> str:
+ explicit = os.environ.get("SEED_TTS_EVAL_DEVICE", "").strip()
+ if explicit:
+ return explicit
+ try:
+ import torch
+
+ return "cuda:0" if torch.cuda.is_available() else "cpu"
+ except ImportError:
+ return "cpu"
+
+
+def _punctuation_all() -> str:
+ from zhon.hanzi import punctuation
+
+ return punctuation + string.punctuation
+
+
+def _jiwer_wer(reference: str, hypothesis: str) -> float:
+ """Word-level WER; strings are normalized like ``run_wer.process_one``.
+
+ jiwer 4.x removed ``compute_measures`` (``ImportError``); fall back to ``process_words``.
+ """
+ try:
+ from jiwer import compute_measures
+
+ return float(compute_measures(reference, hypothesis)["wer"])
+ except ImportError:
+ import jiwer
+
+ out = jiwer.process_words(reference, hypothesis)
+ return float(out.wer)
+
+
+def process_one_official(hypo: str, truth: str, lang: str) -> tuple[float, str, str]:
+ """Same normalization + ``jiwer`` call as ``run_wer.process_one`` (hypo=ASR, truth=reference)."""
+ raw_truth = truth
+ raw_hypo = hypo
+ truth_n = truth
+ hypo_n = hypo
+ for x in _punctuation_all():
+ if x == "'":
+ continue
+ truth_n = truth_n.replace(x, "")
+ hypo_n = hypo_n.replace(x, "")
+ truth_n = truth_n.replace(" ", " ")
+ hypo_n = hypo_n.replace(" ", " ")
+ if lang == "zh":
+ truth_n = " ".join([x for x in truth_n])
+ hypo_n = " ".join([x for x in hypo_n])
+ elif lang == "en":
+ truth_n = truth_n.lower()
+ hypo_n = hypo_n.lower()
+ else:
+ raise ValueError(f"unsupported lang {lang!r}")
+ wer = _jiwer_wer(truth_n, hypo_n)
+ return wer, raw_truth, raw_hypo
+
+
+def _pcm_s16le_to_f32_16k(pcm: bytes, pcm_sample_rate: int = 24000) -> np.ndarray:
+ import scipy.signal
+
+ if not pcm:
+ return np.zeros(0, dtype=np.float32)
+ raw = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
+ target_len = int(len(raw) * 16000 / pcm_sample_rate)
+ if target_len <= 0:
+ return np.zeros(0, dtype=np.float32)
+ return scipy.signal.resample(raw, target_len).astype(np.float32)
+
+
+def _eval_submetric_enabled(env_name: str, *, default: bool = True) -> bool:
+ raw = os.environ.get(env_name, "").strip().lower()
+ if raw in ("0", "false", "no", "off"):
+ return False
+ if raw in ("1", "true", "yes", "on"):
+ return True
+ return default
+
+
+def _audio_path_to_f32_16k(path: str) -> np.ndarray:
+ import scipy.signal
+ import soundfile as sf
+
+ data, sr = sf.read(path, dtype="float32", always_2d=True)
+ mono = np.mean(data, axis=1).astype(np.float32)
+ if int(sr) == 16000:
+ return mono
+ target_len = max(1, int(len(mono) * 16000 / int(sr)))
+ return scipy.signal.resample(mono, target_len).astype(np.float32)
+
+
+def _ensure_wavlm_sim() -> None:
+ global _wavlm_model, _wavlm_processor, _wavlm_device
+ with _lock:
+ if _wavlm_model is not None:
+ return
+ from transformers import AutoFeatureExtractor, AutoModel
+
+ mid = os.environ.get("SEED_TTS_WAVLM_MODEL", "microsoft/wavlm-base-plus").strip() or "microsoft/wavlm-base-plus"
+ _wavlm_device = os.environ.get("SEED_TTS_SIM_DEVICE", "").strip() or _get_eval_device()
+ logger.warning(
+ "Loading WavLM %r on %s for Seed-TTS SIM (embedding cosine; not identical to "
+ "seed-tts-eval UniSpeech SV checkpoint).",
+ mid,
+ _wavlm_device,
+ )
+ _wavlm_processor = AutoFeatureExtractor.from_pretrained(mid)
+ _wavlm_model = AutoModel.from_pretrained(mid).to(_wavlm_device)
+ _wavlm_model.eval()
+
+
+def _wavlm_prepare_waveform(wav: np.ndarray) -> np.ndarray:
+ """Trim, pad to a minimum length WavLM/Wav2Vec2 CNN stack accepts, float32 mono."""
+ max_sec = float(os.environ.get("SEED_TTS_WAVLM_MAX_SECONDS", "30"))
+ cap = int(max_sec * 16000)
+ w = np.asarray(wav, dtype=np.float32).reshape(-1)
+ if len(w) == 0:
+ return w
+ if len(w) > cap:
+ w = w[:cap].copy()
+ # Very short clips make the strided conv front-end fail (shape / empty time dim).
+ min_samples = int(os.environ.get("SEED_TTS_WAVLM_MIN_SAMPLES", "4000"))
+ if len(w) < min_samples:
+ w = np.pad(w, (0, min_samples - len(w)), mode="constant")
+ return w
+
+
+def _wavlm_mean_embedding_f32_16k(wav: np.ndarray) -> np.ndarray | None:
+ import torch
+
+ _ensure_wavlm_sim()
+ w = _wavlm_prepare_waveform(wav)
+ if len(w) == 0:
+ return None
+ assert _wavlm_processor is not None and _wavlm_model is not None and _wavlm_device is not None
+ # Single utterance: avoid padding=True (adds zeros that distort mean pooling). Still pass
+ # attention_mask when the extractor provides it (sample-level; do not mix with hidden length).
+ try:
+ inputs = _wavlm_processor(
+ w,
+ sampling_rate=16000,
+ return_tensors="pt",
+ padding=False,
+ return_attention_mask=True,
+ )
+ except TypeError:
+ inputs = _wavlm_processor(
+ w,
+ sampling_rate=16000,
+ return_tensors="pt",
+ padding=False,
+ )
+ iv = inputs["input_values"].to(_wavlm_device)
+ am = inputs.get("attention_mask")
+ if am is not None:
+ am = am.to(_wavlm_device)
+ with torch.inference_mode():
+ out = _wavlm_model(iv, attention_mask=am)
+ h = out.last_hidden_state
+ v = h.mean(dim=1).squeeze(0).float().cpu().numpy()
+ n = float(np.linalg.norm(v))
+ if not np.isfinite(n) or n < 1e-8:
+ return None
+ return (v / n).astype(np.float32)
+
+
+def _cosine_similarity_unit_vectors(a: np.ndarray, b: np.ndarray) -> float:
+ return float(np.dot(a, b))
+
+
+def _ensure_utmos_jit_model() -> Any | None:
+ """Load UTMOS as TorchScript (``balacoon/utmos`` style): no ``import utmos`` / fairseq."""
+ global _utmos_jit_model, _utmos_jit_device, _utmos_jit_load_failed
+ with _lock:
+ if _utmos_jit_load_failed:
+ return None
+ if _utmos_jit_model is not None:
+ return _utmos_jit_model
+ try:
+ import torch
+ from huggingface_hub import hf_hub_download
+
+ repo = os.environ.get("SEED_TTS_UTMOS_HF_REPO", "balacoon/utmos").strip() or "balacoon/utmos"
+ fname = os.environ.get("SEED_TTS_UTMOS_JIT_FILE", "utmos.jit").strip() or "utmos.jit"
+ logger.warning(
+ "Loading UTMOS TorchScript from Hugging Face %r file %r (one-time download/cache)...",
+ repo,
+ fname,
+ )
+ path = hf_hub_download(repo_id=repo, filename=fname, repo_type="model")
+
+ # TODO The model weights in UTMOS must be loaded in cuda:0; otherwise, the model execution will fail.
+ want = "cuda:0"
+ if want.startswith("cuda") and torch.cuda.is_available():
+ idx = want.split(":")[-1] if ":" in want else "0"
+ target_dev = f"cuda:{idx}"
+ else:
+ target_dev = "cpu"
+
+ try:
+ m = torch.jit.load(path, map_location=target_dev)
+ m.eval()
+ _utmos_jit_device = target_dev
+ except Exception as load_e:
+ if target_dev.startswith("cuda"):
+ logger.warning(
+ "UTMOS JIT load on %s failed (%s), retrying on CPU...",
+ target_dev,
+ load_e,
+ )
+ m = torch.jit.load(path, map_location="cpu")
+ m.eval()
+ _utmos_jit_device = "cpu"
+ else:
+ raise
+ _utmos_jit_model = m
+ except Exception as e:
+ logger.warning(
+ "UTMOS JIT unavailable (install torch + huggingface_hub; check HF access): %s",
+ e,
+ )
+ _utmos_jit_load_failed = True
+ return None
+ return _utmos_jit_model
+
+
+def _utmos_predict_f32_16k(wav_f32: np.ndarray) -> float | None:
+ """MOS from JIT model; input is float32 mono @ 16 kHz in ``[-1, 1]`` (WER pipeline).
+
+ ``balacoon/utmos`` demos sometimes use int16 numpy, but the exported ``.jit`` weights are
+ float32; passing int16 tensors causes: "RuntimeError: ... input type and weight type
+ should be same".
+ """
+ import torch
+
+ if len(wav_f32) == 0:
+ return None
+ model = _ensure_utmos_jit_model()
+ if model is None:
+ return None
+ # Infer model's device from its first parameter/buffer to guarantee input sits with weights.
+ try:
+ model_dev = next(model.parameters()).device
+ except StopIteration:
+ try:
+ model_dev = next(model.buffers()).device
+ except StopIteration:
+ model_dev = torch.device("cpu")
+ w = np.ascontiguousarray(wav_f32, dtype=np.float32)
+ x = torch.from_numpy(w).unsqueeze(0).to(device=model_dev, dtype=torch.float32)
+ with torch.no_grad():
+ out = model(x)
+ val = float(out.reshape(-1)[0].item())
+ if not math.isfinite(val):
+ return None
+ return val
+
+
+def _ensure_en_asr() -> None:
+ global _en_processor, _en_model, _device
+ with _lock:
+ if _en_processor is not None:
+ return
+ from transformers import WhisperForConditionalGeneration, WhisperProcessor
+
+ _device = _get_eval_device()
+ mid = os.environ.get("SEED_TTS_HF_WHISPER_MODEL", OFFICIAL_WHISPER_HF_ID).strip() or OFFICIAL_WHISPER_HF_ID
+ logger.warning(
+ "Loading Seed-TTS eval Whisper HF model %r on %s (one-time, seed-tts-eval protocol)...",
+ mid,
+ _device,
+ )
+ _en_processor = WhisperProcessor.from_pretrained(mid)
+ _en_model = WhisperForConditionalGeneration.from_pretrained(mid).to(_device)
+ _en_model.eval()
+
+
+def _ensure_zh_asr() -> None:
+ global _zh_paraformer, _device
+ with _lock:
+ if _zh_paraformer is not None:
+ return
+ from funasr import AutoModel
+
+ _device = _get_eval_device()
+ logger.warning(
+ "Loading Seed-TTS eval Paraformer %r on %s (one-time, seed-tts-eval protocol)...",
+ PARAFORMER_MODEL_ID,
+ _device,
+ )
+ try:
+ _zh_paraformer = AutoModel(model=PARAFORMER_MODEL_ID, device=_device)
+ except TypeError:
+ _zh_paraformer = AutoModel(model=PARAFORMER_MODEL_ID)
+
+
+def _transcribe_en_f32_16k(wav_f32: np.ndarray) -> str:
+ import torch
+
+ _ensure_en_asr()
+ if len(wav_f32) == 0:
+ return ""
+ with _lock:
+ assert _en_processor is not None and _en_model is not None and _device is not None
+ inputs = _en_processor(wav_f32, sampling_rate=16000, return_tensors="pt")
+ input_features = inputs.input_features.to(_device)
+ with torch.no_grad():
+ try:
+ forced = _en_processor.get_decoder_prompt_ids(language="english", task="transcribe")
+ predicted_ids = _en_model.generate(input_features, forced_decoder_ids=forced)
+ except Exception:
+ predicted_ids = _en_model.generate(
+ input_features,
+ language="english",
+ task="transcribe",
+ )
+ text = _en_processor.batch_decode(predicted_ids, skip_special_tokens=True)[0]
+ return (text or "").strip()
+
+
+def _transcribe_zh_wav_path(wav_path: str) -> str:
+ import zhconv
+
+ _ensure_zh_asr()
+ with _lock:
+ assert _zh_paraformer is not None
+ res = _zh_paraformer.generate(input=wav_path, batch_size_s=300)
+ transcription = res[0]["text"] if res else ""
+ return zhconv.convert(transcription, "zh-cn").strip()
+
+
+def _missing_deps_message(lang: str) -> str | None:
+ try:
+ import jiwer # noqa: F401
+ from zhon.hanzi import punctuation # noqa: F401
+ except ImportError as e:
+ return f"Seed-TTS WER eval needs jiwer and zhon ({e!s}). Install: pip install 'vllm-omni[seed-tts-eval]'"
+ try:
+ import scipy.signal # noqa: F401
+ import soundfile # noqa: F401
+ except ImportError as e:
+ return f"Seed-TTS WER eval needs scipy and soundfile ({e!s})."
+ if lang == "en":
+ try:
+ import torch # noqa: F401
+ from transformers import WhisperForConditionalGeneration # noqa: F401
+ except ImportError as e:
+ return f"English WER needs torch and transformers ({e!s}). Install: pip install 'vllm-omni[seed-tts-eval]'"
+ else:
+ try:
+ import zhconv # noqa: F401
+ from funasr import AutoModel # noqa: F401
+ except ImportError as e:
+ return f"Chinese WER needs funasr and zhconv ({e!s}). Install: pip install 'vllm-omni[seed-tts-eval]'"
+ return None
+
+
+def compute_seed_tts_wer_metrics(
+ input_requests: list[SampleRequest],
+ outputs: list[Any],
+ *,
+ include_per_item: bool = False,
+) -> dict[str, Any] | None:
+ """If all requests are :class:`SeedTTSSampleRequest`, run seed-tts-eval-style WER."""
+ global _utmos_forward_warned
+ if not input_requests or len(input_requests) != len(outputs):
+ return None
+ if not all(isinstance(r, SeedTTSSampleRequest) for r in input_requests):
+ return None
+
+ first = input_requests[0]
+ assert isinstance(first, SeedTTSSampleRequest)
+ lang = "zh" if (first.seed_tts_locale or "en").lower().startswith("zh") else "en"
+
+ setup_err = _missing_deps_message(lang)
+ if setup_err:
+ logger.error("%s", setup_err)
+ return {
+ "seed_tts_eval_setup_error": setup_err,
+ "seed_tts_eval_protocol": "seed-tts-eval",
+ "seed_tts_content_evaluated": 0,
+ "seed_tts_content_error_mean": None,
+ "seed_tts_content_error_median": None,
+ "seed_tts_request_failed": 0,
+ "seed_tts_no_pcm": 0,
+ "seed_tts_asr_failed": 0,
+ "seed_tts_content_metric": "wer",
+ }
+
+ import soundfile as sf
+
+ errs: list[float] = []
+ items: list[dict[str, Any]] = []
+ asr_failed = 0
+ no_pcm = 0
+ request_failed = 0
+ sim_values: list[float] = []
+ utmos_values: list[float] = []
+ sim_failed = 0
+ sim_skipped_no_ref = 0
+ utmos_failed = 0
+ utmos_on = _eval_submetric_enabled("SEED_TTS_UTMOS_EVAL", default=True)
+
+ for req, out in zip(input_requests, outputs, strict=True):
+ assert isinstance(req, SeedTTSSampleRequest)
+ ref = req.prompt
+ locale = req.seed_tts_locale or "en"
+ row_lang = "zh" if locale.lower().startswith("zh") else "en"
+ utmos_v: float | None = None
+
+ if not out.success:
+ request_failed += 1
+ if include_per_item:
+ items.append(
+ {
+ "utterance_id": req.seed_tts_utterance_id,
+ "locale": locale,
+ "error": "request_failed",
+ "detail": (out.error or "")[:500],
+ }
+ )
+ continue
+
+ pcm = getattr(out, "tts_output_pcm_bytes", None)
+ if not pcm:
+ no_pcm += 1
+ if include_per_item:
+ items.append(
+ {
+ "utterance_id": req.seed_tts_utterance_id,
+ "locale": locale,
+ "error": "no_pcm",
+ }
+ )
+ continue
+
+ wav_16k = _pcm_s16le_to_f32_16k(pcm)
+ if len(wav_16k) == 0:
+ asr_failed += 1
+ if include_per_item:
+ items.append(
+ {
+ "utterance_id": req.seed_tts_utterance_id,
+ "locale": locale,
+ "error": "empty_audio",
+ }
+ )
+ continue
+
+ # UTMOS scores synthesized audio only; do not gate on ASR/WER (those can fail independently).
+ if utmos_on:
+ try:
+ utmos_v = _utmos_predict_f32_16k(wav_16k)
+ if utmos_v is not None:
+ utmos_values.append(utmos_v)
+ elif not _utmos_jit_load_failed:
+ utmos_failed += 1
+ except Exception:
+ if not _utmos_forward_warned:
+ _utmos_forward_warned = True
+ logger.warning(
+ "UTMOS JIT forward failed (first utterance=%s; set logging DEBUG for "
+ "full trace). Check sample rate (16 kHz), input shape, or "
+ "SEED_TTS_UTMOS_DEVICE.",
+ req.seed_tts_utterance_id,
+ exc_info=True,
+ )
+ else:
+ logger.debug(
+ "UTMOS forward failed for %s",
+ req.seed_tts_utterance_id,
+ exc_info=True,
+ )
+ utmos_failed += 1
+
+ try:
+ if row_lang == "en":
+ hyp = _transcribe_en_f32_16k(wav_16k)
+ else:
+ fd, tmp_wav = tempfile.mkstemp(suffix=".wav")
+ os.close(fd)
+ try:
+ sf.write(tmp_wav, wav_16k, 16000, subtype="PCM_16")
+ hyp = _transcribe_zh_wav_path(tmp_wav)
+ finally:
+ try:
+ os.unlink(tmp_wav)
+ except OSError:
+ pass
+ except Exception as e:
+ logger.exception("Seed-TTS ASR failed for %s", req.seed_tts_utterance_id)
+ asr_failed += 1
+ if include_per_item:
+ items.append(
+ {
+ "utterance_id": req.seed_tts_utterance_id,
+ "locale": locale,
+ "error": "asr_exception",
+ "detail": str(e)[:500],
+ }
+ )
+ continue
+
+ if not hyp:
+ asr_failed += 1
+ if include_per_item:
+ items.append(
+ {
+ "utterance_id": req.seed_tts_utterance_id,
+ "locale": locale,
+ "error": "empty_asr",
+ }
+ )
+ continue
+
+ try:
+ wer, raw_truth, raw_hypo = process_one_official(hyp, ref, row_lang)
+ except Exception as e:
+ logger.warning("jiwer/normalize failed for %s: %s", req.seed_tts_utterance_id, e)
+ asr_failed += 1
+ if include_per_item:
+ items.append(
+ {
+ "utterance_id": req.seed_tts_utterance_id,
+ "locale": locale,
+ "error": "wer_compute_failed",
+ "detail": str(e)[:500],
+ }
+ )
+ continue
+
+ errs.append(wer)
+ sim_v: float | None = None
+
+ if _eval_submetric_enabled("SEED_TTS_SIM_EVAL", default=True):
+ ref_path = getattr(req, "seed_tts_ref_wav_path", "") or ""
+ if ref_path and os.path.isfile(ref_path):
+ try:
+ ref_wav = _audio_path_to_f32_16k(ref_path)
+ e_ref = _wavlm_mean_embedding_f32_16k(ref_wav)
+ e_hyp = _wavlm_mean_embedding_f32_16k(wav_16k)
+ if e_ref is not None and e_hyp is not None:
+ sim_v = _cosine_similarity_unit_vectors(e_ref, e_hyp)
+ sim_values.append(sim_v)
+ except Exception as e:
+ logger.warning(
+ "SIM embedding failed for utterance=%s: %s: %s",
+ req.seed_tts_utterance_id,
+ type(e).__name__,
+ e,
+ )
+ sim_failed += 1
+ else:
+ sim_skipped_no_ref += 1
+
+ if include_per_item:
+ row: dict[str, Any] = {
+ "utterance_id": req.seed_tts_utterance_id,
+ "locale": locale,
+ "wer": wer,
+ "reference_raw": raw_truth,
+ "asr_raw": raw_hypo,
+ }
+ if sim_v is not None:
+ row["sim"] = sim_v
+ if utmos_v is not None:
+ row["utmos"] = utmos_v
+ items.append(row)
+
+ result: dict[str, Any] = {
+ "seed_tts_eval_protocol": "seed-tts-eval",
+ "seed_tts_content_evaluated": len(errs),
+ "seed_tts_content_error_mean": statistics.fmean(errs) if errs else None,
+ "seed_tts_content_error_median": statistics.median(errs) if errs else None,
+ "seed_tts_request_failed": request_failed,
+ "seed_tts_no_pcm": no_pcm,
+ "seed_tts_asr_failed": asr_failed,
+ "seed_tts_content_metric": "wer",
+ "seed_tts_sim_evaluated": len(sim_values),
+ "seed_tts_sim_mean": statistics.fmean(sim_values) if sim_values else None,
+ "seed_tts_sim_median": statistics.median(sim_values) if sim_values else None,
+ "seed_tts_sim_failed": sim_failed,
+ "seed_tts_sim_skipped_no_ref": sim_skipped_no_ref,
+ "seed_tts_utmos_evaluated": len(utmos_values),
+ "seed_tts_utmos_mean": statistics.fmean(utmos_values) if utmos_values else None,
+ "seed_tts_utmos_median": statistics.median(utmos_values) if utmos_values else None,
+ "seed_tts_utmos_failed": utmos_failed,
+ }
+ if include_per_item:
+ result["seed_tts_wer_eval_items"] = items
+ return result
+
+
+def print_seed_tts_wer_summary(metrics: dict[str, Any]) -> None:
+ setup = metrics.get("seed_tts_eval_setup_error")
+ if setup:
+ print("{s:{c}^{n}}".format(s=" Seed-TTS eval (seed-tts-eval protocol) ", n=50, c="="))
+ print(setup)
+ return
+
+ ev = int(metrics.get("seed_tts_content_evaluated", 0) or 0)
+ rf = int(metrics.get("seed_tts_request_failed", 0) or 0)
+ npc = int(metrics.get("seed_tts_no_pcm", 0) or 0)
+ af = int(metrics.get("seed_tts_asr_failed", 0) or 0)
+ sim_ev = int(metrics.get("seed_tts_sim_evaluated", 0) or 0)
+ ut_ev = int(metrics.get("seed_tts_utmos_evaluated", 0) or 0)
+ if ev == 0 and rf == 0 and npc == 0 and af == 0 and sim_ev == 0 and ut_ev == 0:
+ return
+ print("{s:{c}^{n}}".format(s=" Seed-TTS eval (seed-tts-eval protocol) ", n=50, c="="))
+ print("{:<40} {:<10}".format("Evaluated (WER, lower is better):", ev))
+ mean = metrics.get("seed_tts_content_error_mean")
+ if mean is not None:
+ print("{:<40} {:<10.4f}".format("Mean WER:", float(mean)))
+ med = metrics.get("seed_tts_content_error_median")
+ if med is not None:
+ print("{:<40} {:<10.4f}".format("Median WER:", float(med)))
+ print("{:<40} {:<10}".format("Request failed:", metrics.get("seed_tts_request_failed", 0)))
+ print("{:<40} {:<10}".format("No PCM captured:", metrics.get("seed_tts_no_pcm", 0)))
+ print("{:<40} {:<10}".format("ASR / WER failed:", metrics.get("seed_tts_asr_failed", 0)))
+ if sim_ev or metrics.get("seed_tts_sim_skipped_no_ref") or metrics.get("seed_tts_sim_failed"):
+ print("{:<40} {:<10}".format("SIM evaluated (higher ~ closer):", sim_ev))
+ sm = metrics.get("seed_tts_sim_mean")
+ if sm is not None:
+ print("{:<40} {:<10.4f}".format("Mean SIM:", float(sm)))
+ s_med = metrics.get("seed_tts_sim_median")
+ if s_med is not None:
+ print("{:<40} {:<10.4f}".format("Median SIM:", float(s_med)))
+ print("{:<40} {:<10}".format("SIM skipped (no ref path):", metrics.get("seed_tts_sim_skipped_no_ref", 0)))
+ print("{:<40} {:<10}".format("SIM embedding errors:", metrics.get("seed_tts_sim_failed", 0)))
+ if ut_ev or metrics.get("seed_tts_utmos_failed"):
+ print("{:<40} {:<10}".format("UTMOS evaluated (JIT MOS, higher better):", ut_ev))
+ um = metrics.get("seed_tts_utmos_mean")
+ if um is not None:
+ print("{:<40} {:<10.4f}".format("Mean UTMOS:", float(um)))
+ u_med = metrics.get("seed_tts_utmos_median")
+ if u_med is not None:
+ print("{:<40} {:<10.4f}".format("Median UTMOS:", float(u_med)))
+ print("{:<40} {:<10}".format("UTMOS errors:", metrics.get("seed_tts_utmos_failed", 0)))
+ print("=" * 50)
diff --git a/vllm_omni/benchmarks/patch/__init__.py b/vllm_omni/benchmarks/patch/__init__.py
index e69de29bb..ca6b41ba8 100644
--- a/vllm_omni/benchmarks/patch/__init__.py
+++ b/vllm_omni/benchmarks/patch/__init__.py
@@ -0,0 +1,3 @@
+"""Omni benchmark monkey-patches (side effects in ``patch.patch``)."""
+
+from . import patch as _patch_module # noqa: F401
diff --git a/vllm_omni/benchmarks/patch/patch.py b/vllm_omni/benchmarks/patch/patch.py
index 17d7498ba..41aed0942 100644
--- a/vllm_omni/benchmarks/patch/patch.py
+++ b/vllm_omni/benchmarks/patch/patch.py
@@ -6,6 +6,7 @@ import json
import os
import random
import ssl
+import sys
import time
import traceback
from collections.abc import Iterable
@@ -33,15 +34,245 @@ from vllm.logger import init_logger
from vllm.tokenizers import TokenizerLike
logger = init_logger(__name__)
+
+from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniDataset, DailyOmniSampleRequest
from vllm_omni.benchmarks.data_modules.random_multi_modal_dataset import OmniRandomMultiModalDataset
+from vllm_omni.benchmarks.data_modules.seed_tts_dataset import (
+ SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT,
+ SeedTTSDataset,
+ SeedTTSSampleRequest,
+)
get_samples_old = datasets.get_samples
+_DEFAULT_DAILY_OMNI_REPO = "liarliar/Daily-Omni"
+
+
+def _seed_tts_capture_pcm_for_wer() -> bool:
+ return os.environ.get("SEED_TTS_WER_EVAL", "").lower() in (
+ "1",
+ "true",
+ "yes",
+ )
+
+
+def _merge_extra_body_mm_kwargs(base: dict | None, overlay: dict | None) -> dict | None:
+ """Shallow-merge ``extra_body`` dicts; deep-merge ``mm_processor_kwargs`` if both set."""
+ if not base and not overlay:
+ return None
+ out = dict(base or {})
+ if not overlay:
+ return out
+ for k, v in overlay.items():
+ if k == "mm_processor_kwargs" and isinstance(v, dict):
+ prev = out.get("mm_processor_kwargs")
+ merged_kw = {**(prev if isinstance(prev, dict) else {}), **v}
+ out["mm_processor_kwargs"] = merged_kw
+ else:
+ out[k] = v
+ return out
+
+
+def _attach_daily_omni_to_request_func_input(sample: SampleRequest, rfi: RequestFuncInput) -> None:
+ """Apply per-request OpenAI fields (``mm_processor_kwargs``, messages) for Daily-Omni."""
+ if not isinstance(sample, DailyOmniSampleRequest):
+ return
+ rfi.extra_body = _merge_extra_body_mm_kwargs(rfi.extra_body, sample.omni_extra_body)
+ if sample.omni_chat_messages is not None:
+ setattr(rfi, "omni_chat_messages", sample.omni_chat_messages)
+ else:
+ setattr(rfi, "mm_position", sample.omni_chat_mm_position)
+
+
+def _attach_seed_tts_to_request_func_input(sample: SampleRequest, rfi: RequestFuncInput) -> None:
+ """Merge Seed-TTS per-row TTS fields (ref_audio, ref_text, task_type, …) into ``extra_body``.
+
+ Used by both ``/v1/audio/speech`` and ``/v1/chat/completions`` (flattened into JSON body).
+ For ``openai-chat-omni``, also sets ``omni_chat_messages`` (system + user) so Qwen3-Omni
+ follows the same role layout as official TTS / multimodal demos. ``/v1/audio/speech`` ignores
+ ``messages`` and only uses ``input`` + body fields.
+ Flags ``openai-chat-omni`` to request audio output and optionally export PCM for WER.
+ """
+ if not isinstance(sample, SeedTTSSampleRequest):
+ return
+ ex = sample.seed_tts_speech_extra
+ if not ex:
+ return
+ base = dict(rfi.extra_body) if rfi.extra_body else {}
+ base.update(ex)
+ rfi.extra_body = base
+ # Used by request funcs to force streaming TTS behavior and to export PCM when WER is on.
+ setattr(rfi, "seed_tts_row", True)
+ sys_prompt = (sample.seed_tts_system_prompt or "").strip() or SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT
+ setattr(
+ rfi,
+ "omni_chat_messages",
+ [
+ {"role": "system", "content": [{"type": "text", "text": sys_prompt}]},
+ {"role": "user", "content": [{"type": "text", "text": sample.prompt}]},
+ ],
+ )
+
+
+def _daily_omni_repo_from_args(args) -> str | None:
+ """Resolve HuggingFace repo id for Daily-Omni from CLI args.
+
+ vLLM allows ``--dataset-path`` to be a local path while the real HF id is
+ passed via ``--hf-name``. Upstream ``get_samples`` for ``hf`` only matches
+ a fixed elif-chain and never discovers Omni's loader, so we must detect
+ Daily-Omni here using either field.
+ """
+ dp = getattr(args, "dataset_path", None)
+ hn = getattr(args, "hf_name", None)
+ if dp in DailyOmniDataset.SUPPORTED_DATASET_PATHS:
+ return dp
+ if hn in DailyOmniDataset.SUPPORTED_DATASET_PATHS:
+ return hn
+ return None
+
def get_samples(args, tokenizer):
- if args.backend not in ["openai-chat-omni", "openai-audio-speech"]:
+ # Daily-Omni: explicit dataset name, or hf + matching path/hf-name
+ is_daily_omni = args.dataset_name == "daily-omni" or (
+ args.dataset_name == "hf" and _daily_omni_repo_from_args(args) is not None
+ )
+ is_seed_tts = args.dataset_name == "seed-tts"
+
+ # Check if we need to handle omni-related backends/datasets
+ is_omni_backend = args.backend in ["openai-chat-omni", "openai-audio-speech", "daily-omni"]
+ is_omni_dataset = is_daily_omni or is_seed_tts or args.dataset_name == "random-mm"
+
+ if not is_omni_backend and not is_omni_dataset:
+ # Not an omni-related request, delegate to original implementation
return get_samples_old(args, tokenizer)
- elif args.dataset_name == "random-mm":
+
+ # Handle Daily-Omni dataset
+ if is_daily_omni:
+ # Support:
+ # --dataset-name daily-omni [--dataset-path liarliar/Daily-Omni]
+ # --dataset-name daily-omni --daily-omni-qa-json /path/to/qa.json (offline QA)
+ # --dataset-name hf --dataset-path liarliar/Daily-Omni
+ # --dataset-name hf --hf-name liarliar/Daily-Omni (dataset-path may be local)
+
+ # Validate backend supports multimodal (video)
+ if args.backend not in ["openai-chat-omni", "daily-omni"]:
+ raise ValueError(
+ f"Daily-Omni dataset requires a multimodal backend that supports video. "
+ f"Got backend='{args.backend}'. Please use '--backend openai-chat-omni'"
+ )
+
+ # Determine video directory if specified (for local video files)
+ video_dir = getattr(args, "daily_omni_video_dir", None)
+
+ # Get HF split (default to "train"; unused when loading from local qa.json)
+ dataset_split = getattr(args, "hf_split", None) or "train"
+
+ qa_json = getattr(args, "daily_omni_qa_json", None)
+ if isinstance(qa_json, str):
+ qa_json = qa_json.strip() or None
+
+ if qa_json is not None:
+ logger.info(
+ "Loading Daily-Omni dataset: qa_json=%s, video_dir=%s (Hub not used for QA)",
+ qa_json,
+ video_dir,
+ )
+ dataset = DailyOmniDataset(
+ qa_json_path=qa_json,
+ dataset_path=None,
+ dataset_split=dataset_split,
+ random_seed=args.seed,
+ video_dir=video_dir,
+ input_mode=getattr(args, "daily_omni_input_mode", "all"),
+ inline_local_video=getattr(args, "daily_omni_inline_local_video", False),
+ trust_remote_code=getattr(args, "trust_remote_code", False),
+ disable_shuffle=getattr(args, "disable_shuffle", False),
+ )
+ else:
+ repo_id = _daily_omni_repo_from_args(args)
+ if args.dataset_name == "daily-omni":
+ if repo_id is None:
+ repo_id = _DEFAULT_DAILY_OMNI_REPO
+ elif repo_id is None:
+ raise ValueError(
+ "Daily-Omni with --dataset-name hf requires "
+ f"--dataset-path {_DEFAULT_DAILY_OMNI_REPO} or "
+ f"--hf-name {_DEFAULT_DAILY_OMNI_REPO}."
+ )
+
+ logger.info(
+ "Loading Daily-Omni dataset: hf_repo=%s, split=%s, video_dir=%s",
+ repo_id,
+ dataset_split,
+ video_dir,
+ )
+
+ dataset = DailyOmniDataset(
+ dataset_path=repo_id,
+ dataset_split=dataset_split,
+ dataset_subset=getattr(args, "hf_subset", None),
+ random_seed=args.seed,
+ video_dir=video_dir,
+ input_mode=getattr(args, "daily_omni_input_mode", "all"),
+ inline_local_video=getattr(args, "daily_omni_inline_local_video", False),
+ trust_remote_code=getattr(args, "trust_remote_code", False),
+ no_stream=getattr(args, "no_stream", False),
+ disable_shuffle=getattr(args, "disable_shuffle", False),
+ )
+
+ out_len = getattr(args, "output_len", None)
+ if out_len is None:
+ out_len = getattr(args, "hf_output_len", None)
+ if out_len is None:
+ out_len = DailyOmniDataset.DEFAULT_OUTPUT_LEN
+
+ input_requests = dataset.sample(
+ tokenizer=tokenizer,
+ num_requests=args.num_prompts,
+ output_len=out_len,
+ request_id_prefix=args.request_id_prefix,
+ no_oversample=args.no_oversample,
+ )
+ return input_requests
+
+ if is_seed_tts:
+ if args.backend not in ("openai-audio-speech", "openai-chat-omni"):
+ raise ValueError(
+ "Seed-TTS requires --backend openai-audio-speech (POST /v1/audio/speech) or "
+ "--backend openai-chat-omni (POST /v1/chat/completions with ref_audio/ref_text). "
+ f"Got backend={args.backend!r}."
+ )
+ repo_id = getattr(args, "dataset_path", None) or getattr(args, "hf_name", None)
+ if not repo_id:
+ raise ValueError(
+ "Seed-TTS requires --dataset-path (HF dataset repo id or local directory) or "
+ "--hf-name for the Hub dataset id."
+ )
+
+ dataset = SeedTTSDataset(
+ dataset_path=repo_id,
+ random_seed=args.seed,
+ locale=getattr(args, "seed_tts_locale", "en"),
+ inline_ref_audio=not getattr(args, "seed_tts_file_ref_audio", False),
+ seed_tts_root=getattr(args, "seed_tts_root", None),
+ system_prompt=getattr(args, "seed_tts_system_prompt", None),
+ disable_shuffle=getattr(args, "disable_shuffle", False),
+ )
+ out_len = getattr(args, "output_len", None)
+ if out_len is None:
+ out_len = getattr(args, "hf_output_len", None)
+ if out_len is None:
+ out_len = SeedTTSDataset.DEFAULT_OUTPUT_LEN
+ return dataset.sample(
+ tokenizer=tokenizer,
+ num_requests=args.num_prompts,
+ output_len=out_len,
+ request_id_prefix=args.request_id_prefix,
+ no_oversample=args.no_oversample,
+ )
+
+ # Handle random-mm dataset (Omni's synthetic multimodal dataset)
+ if args.dataset_name == "random-mm":
dataset = OmniRandomMultiModalDataset(random_seed=args.seed, dataset_path=args.dataset_path)
input_requests = dataset.sample(
tokenizer=tokenizer,
@@ -64,6 +295,10 @@ def get_samples(args, tokenizer):
datasets.get_samples = get_samples
+_serve_mod = sys.modules.get("vllm.benchmarks.serve")
+if _serve_mod is not None:
+ _serve_mod.get_samples = get_samples
+
@dataclass
class MixRequestFuncOutput(RequestFuncOutput):
@@ -72,6 +307,9 @@ class MixRequestFuncOutput(RequestFuncOutput):
audio_frames: int = 0
audio_rtf: float = 0.0
text_latency: float = 0.0
+ #: Raw PCM s16le mono at 24 kHz for Seed-TTS WER: from ``/v1/audio/speech`` stream or
+ #: resampled export after ``openai-chat-omni`` audio deltas.
+ tts_output_pcm_bytes: bytes | None = None
async def async_request_openai_chat_omni_completions(
@@ -83,13 +321,17 @@ async def async_request_openai_chat_omni_completions(
api_url = request_func_input.api_url
_validate_api_url(api_url, "OpenAI Chat Completions API", "chat/completions")
- content = _get_chat_content(request_func_input, mm_position=mm_position)
+ omni_messages = getattr(request_func_input, "omni_chat_messages", None)
+ if omni_messages is not None:
+ messages_payload = omni_messages
+ else:
+ effective_mm_position = getattr(request_func_input, "mm_position", mm_position)
+ content = _get_chat_content(request_func_input, mm_position=effective_mm_position)
+ messages_payload = [{"role": "user", "content": content}]
payload = {
"model": request_func_input.model_name if request_func_input.model_name else request_func_input.model,
- "messages": [
- {"role": "user", "content": content},
- ],
+ "messages": messages_payload,
"temperature": 0.0,
"max_tokens": request_func_input.output_len,
"stream": True,
@@ -98,6 +340,10 @@ async def async_request_openai_chat_omni_completions(
},
}
_update_payload_common(payload, request_func_input)
+ # Seed-TTS via chat: voice-clone fields live on the body; ensure audio is streamed.
+ if getattr(request_func_input, "seed_tts_row", False):
+ if payload.get("modalities") is None:
+ payload["modalities"] = ["text", "audio"]
response_format = payload.get("response_format", "wav")
if response_format == "pcm":
@@ -167,7 +413,10 @@ async def async_request_openai_chat_omni_completions(
data = json.loads(chunk)
if choices := data.get("choices"):
modality = data.get("modality")
- content = choices[0]["delta"].get("content")
+ delta = choices[0].get("delta") or {}
+ content = delta.get("content")
+ if not content and isinstance(delta.get("audio"), dict):
+ content = delta["audio"].get("data")
if modality == "text":
# First token
if ttft == 0.0:
@@ -182,7 +431,7 @@ async def async_request_openai_chat_omni_completions(
if output.audio_ttfp == 0.0:
output.audio_ttfp = timestamp - st
audio_generate_time = timestamp - st
- if content != "":
+ if content:
audio_bytes = base64.b64decode(content)
seg = AudioSegment.from_file(io.BytesIO(audio_bytes))
if seg is not None:
@@ -214,6 +463,12 @@ async def async_request_openai_chat_omni_completions(
else:
output.audio_rtf = 0
logger.warning("Audio duration is zero")
+ if _seed_tts_capture_pcm_for_wer() and getattr(request_func_input, "seed_tts_row", False):
+ try:
+ seg = generated_audio.set_frame_rate(24000).set_channels(1).set_sample_width(2)
+ output.tts_output_pcm_bytes = bytes(seg.raw_data)
+ except Exception as ex:
+ logger.warning("seed_tts WER PCM export failed: %s", ex)
output.success = True
else:
output.error = response.reason or ""
@@ -268,6 +523,10 @@ async def async_request_openai_audio_speech(
"response_format": "pcm",
}
_update_payload_common(payload, request_func_input)
+ # Seed-TTS + WER: ``--extra-body`` may set stream=false / other formats; speech must stream PCM.
+ if getattr(request_func_input, "seed_tts_row", False) and _seed_tts_capture_pcm_for_wer():
+ payload["stream"] = True
+ payload["response_format"] = "pcm"
headers = {
"Content-Type": "application/json",
@@ -286,6 +545,8 @@ async def async_request_openai_audio_speech(
st = time.perf_counter()
output.start_time = st
total_pcm_bytes = 0
+ capture_wer_pcm = _seed_tts_capture_pcm_for_wer() and getattr(request_func_input, "seed_tts_row", False)
+ pcm_capture = bytearray() if capture_wer_pcm else None
try:
async with session.post(url=api_url, json=payload, headers=headers) as response:
if response.status == 200:
@@ -297,6 +558,8 @@ async def async_request_openai_audio_speech(
output.audio_ttfp = timestamp - st
output.ttft = output.audio_ttfp
total_pcm_bytes += len(chunk)
+ if pcm_capture is not None:
+ pcm_capture.extend(chunk)
end_time = time.perf_counter()
output.latency = end_time - st
@@ -309,6 +572,16 @@ async def async_request_openai_audio_speech(
else:
output.audio_rtf = 0
logger.warning("Audio duration is zero")
+ if pcm_capture is not None and pcm_capture:
+ output.tts_output_pcm_bytes = bytes(pcm_capture)
+ elif capture_wer_pcm:
+ ct = response.headers.get("Content-Type", "")
+ logger.warning(
+ "Seed-TTS WER: HTTP 200 but no PCM bytes (Content-Type=%r, url=%s). "
+ "Check stream=true and response_format=pcm on the server.",
+ ct,
+ api_url,
+ )
output.success = True
else:
output.error = response.reason or ""
@@ -331,6 +604,12 @@ ASYNC_REQUEST_FUNCS["openai-audio-speech"] = async_request_openai_audio_speech
if "openai-audio-speech" not in OPENAI_COMPATIBLE_BACKENDS:
OPENAI_COMPATIBLE_BACKENDS.append("openai-audio-speech")
+# Daily-Omni backend for audio-visual reasoning benchmark
+# Reuses openai-chat-omni completions for video+text understanding
+ASYNC_REQUEST_FUNCS["daily-omni"] = async_request_openai_chat_omni_completions
+if "daily-omni" not in OPENAI_COMPATIBLE_BACKENDS:
+ OPENAI_COMPATIBLE_BACKENDS.append("daily-omni")
+
# ruff: noqa: E402
# Prevent import order from causing patch failures
from vllm.benchmarks import serve
@@ -422,6 +701,8 @@ async def benchmark(
extra_headers=extra_headers,
extra_body=extra_body,
)
+ _attach_daily_omni_to_request_func_input(input_requests[0], test_input)
+ _attach_seed_tts_to_request_func_input(input_requests[0], test_input)
if ready_check_timeout_sec > 0:
test_output = await wait_for_endpoint(
@@ -484,6 +765,8 @@ async def benchmark(
extra_headers=extra_headers,
extra_body=extra_body,
)
+ _attach_daily_omni_to_request_func_input(input_requests[0], profile_input)
+ _attach_seed_tts_to_request_func_input(input_requests[0], profile_input)
profile_output = await request_func(request_func_input=profile_input, session=session)
if profile_output.success:
print("Profiler started")
@@ -564,6 +847,8 @@ async def benchmark(
extra_body=extra_body,
request_id=request_id,
)
+ _attach_daily_omni_to_request_func_input(request, request_func_input)
+ _attach_seed_tts_to_request_func_input(request, request_func_input)
tasks.append(
asyncio.create_task(limited_request_func(request_func_input=request_func_input, session=session, pbar=pbar))
)
@@ -631,6 +916,37 @@ async def benchmark(
"errors": [output.error for output in outputs],
}
+ from vllm_omni.benchmarks.data_modules.daily_omni_eval import (
+ compute_daily_omni_accuracy_metrics,
+ print_daily_omni_accuracy_summary,
+ )
+
+ _save_items = os.environ.get("DAILY_OMNI_SAVE_EVAL_ITEMS", "").lower() in (
+ "1",
+ "true",
+ "yes",
+ )
+ _daily_acc = compute_daily_omni_accuracy_metrics(input_requests, outputs, include_per_item=_save_items)
+ if _daily_acc is not None:
+ result.update(_daily_acc)
+ print_daily_omni_accuracy_summary(_daily_acc)
+
+ if _seed_tts_capture_pcm_for_wer():
+ from vllm_omni.benchmarks.data_modules.seed_tts_eval import (
+ compute_seed_tts_wer_metrics,
+ print_seed_tts_wer_summary,
+ )
+
+ _save_wer = os.environ.get("SEED_TTS_WER_SAVE_ITEMS", "").lower() in (
+ "1",
+ "true",
+ "yes",
+ )
+ _wer_m = compute_seed_tts_wer_metrics(input_requests, outputs, include_per_item=_save_wer)
+ if _wer_m is not None:
+ result.update(_wer_m)
+ print_seed_tts_wer_summary(_wer_m)
+
if rps_change_events:
result["rps_change_events"] = rps_change_events
diff --git a/vllm_omni/benchmarks/serve.py b/vllm_omni/benchmarks/serve.py
index fe9460369..d3f3510c5 100644
--- a/vllm_omni/benchmarks/serve.py
+++ b/vllm_omni/benchmarks/serve.py
@@ -1,9 +1,21 @@
import argparse
import asyncio
+import os
from typing import Any
from vllm.benchmarks.serve import main_async
+# Import patch to register daily-omni dataset and omni backends
+# This monkey-patches vllm.benchmarks.datasets.get_samples before it's used
+# Must be imported before any vllm.benchmarks module usage
+import vllm_omni.benchmarks.patch.patch # noqa: F401
+
def main(args: argparse.Namespace) -> dict[str, Any]:
+ if getattr(args, "seed_tts_wer_eval", False):
+ os.environ["SEED_TTS_WER_EVAL"] = "1"
+ if getattr(args, "seed_tts_wer_save_items", False):
+ os.environ["SEED_TTS_WER_SAVE_ITEMS"] = "1"
+ if getattr(args, "daily_omni_save_eval_items", False):
+ os.environ["DAILY_OMNI_SAVE_EVAL_ITEMS"] = "1"
return asyncio.run(main_async(args))
diff --git a/vllm_omni/config/__init__.py b/vllm_omni/config/__init__.py
index 2aa236e69..f02c07588 100644
--- a/vllm_omni/config/__init__.py
+++ b/vllm_omni/config/__init__.py
@@ -5,10 +5,18 @@ Configuration module for vLLM-Omni.
from vllm_omni.config.lora import LoRAConfig
from vllm_omni.config.model import OmniModelConfig
from vllm_omni.config.stage_config import (
+ DeployConfig,
ModelPipeline,
+ PipelineConfig,
StageConfig,
StageConfigFactory,
+ StageDeployConfig,
+ StageExecutionType,
+ StagePipelineConfig,
StageType,
+ load_deploy_config,
+ merge_pipeline_deploy,
+ register_pipeline,
)
from vllm_omni.config.yaml_util import (
create_config,
@@ -24,6 +32,14 @@ __all__ = [
"StageConfigFactory",
"ModelPipeline",
"StageType",
+ "StageExecutionType",
+ "StagePipelineConfig",
+ "PipelineConfig",
+ "StageDeployConfig",
+ "DeployConfig",
+ "load_deploy_config",
+ "merge_pipeline_deploy",
+ "register_pipeline",
"create_config",
"load_yaml_config",
"merge_configs",
diff --git a/vllm_omni/config/model.py b/vllm_omni/config/model.py
index 588efabfc..96a34a8d7 100644
--- a/vllm_omni/config/model.py
+++ b/vllm_omni/config/model.py
@@ -109,9 +109,11 @@ class OmniModelConfig(ModelConfig):
"extra": {},
}
)
+ subtalker_sampling_params: dict[str, Any] | None = None
omni_kv_config: dict | None = None
codec_frame_rate_hz: float | None = None
task_type: str | None = None
+ enable_sleep_mode: bool = False
@property
def registry(self):
diff --git a/vllm_omni/config/pipeline_registry.py b/vllm_omni/config/pipeline_registry.py
new file mode 100644
index 000000000..69a004015
--- /dev/null
+++ b/vllm_omni/config/pipeline_registry.py
@@ -0,0 +1,75 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Central declarative registry of all vllm-omni pipelines.
+
+Mirrors the pattern in ``vllm/model_executor/models/registry.py``: each entry
+is ``model_type -> (module_path, variable_name)``, and the module is imported
+lazily on first lookup (see ``_LazyPipelineRegistry`` in
+``vllm_omni/config/stage_config.py``). Keeping every pipeline declared in one
+file makes it easy to spot a missing registration, which was the original
+motivation in https://github.com/vllm-project/vllm-omni/issues/2887 (item 4).
+
+Per-model ``pipeline.py`` modules still define the ``PipelineConfig`` instance;
+they just no longer need to self-register via ``register_pipeline(...)``.
+
+Adding a new pipeline:
+ 1. Define the ``PipelineConfig`` instance as a module-level variable in
+ ``vllm_omni/.../pipeline.py``.
+ 2. Add one line to ``_OMNI_PIPELINES`` or ``_DIFFUSION_PIPELINES`` below.
+
+``register_pipeline(config)`` in ``stage_config`` is still supported for
+out-of-tree plugins and tests that create pipelines at runtime; those override
+the entries declared here.
+"""
+
+from __future__ import annotations
+
+# --- Multi-stage omni pipelines (LLM-centric; audio / video I/O) ---
+_OMNI_PIPELINES: dict[str, tuple[str, str]] = {
+ # model_type -> (module_path, variable_name)
+ "qwen2_5_omni": (
+ "vllm_omni.model_executor.models.qwen2_5_omni.pipeline",
+ "QWEN2_5_OMNI_PIPELINE",
+ ),
+ "qwen2_5_omni_thinker_only": (
+ "vllm_omni.model_executor.models.qwen2_5_omni.pipeline",
+ "QWEN2_5_OMNI_THINKER_ONLY_PIPELINE",
+ ),
+ "qwen3_omni_moe": (
+ "vllm_omni.model_executor.models.qwen3_omni.pipeline",
+ "QWEN3_OMNI_PIPELINE",
+ ),
+ "qwen3_tts": (
+ "vllm_omni.model_executor.models.qwen3_tts.pipeline",
+ "QWEN3_TTS_PIPELINE",
+ ),
+ "voxcpm2": (
+ "vllm_omni.model_executor.models.voxcpm2.pipeline",
+ "VOXCPM2_PIPELINE",
+ ),
+ "cosyvoice3": (
+ "vllm_omni.model_executor.models.cosyvoice3.pipeline",
+ "COSYVOICE3_PIPELINE",
+ ),
+ "mimo_audio": (
+ "vllm_omni.model_executor.models.mimo_audio.pipeline",
+ "MIMO_AUDIO_PIPELINE",
+ ),
+ "voxtral_tts": (
+ "vllm_omni.model_executor.models.voxtral_tts.pipeline",
+ "VOXTRAL_TTS_PIPELINE",
+ ),
+ "fish_qwen3_omni": (
+ "vllm_omni.model_executor.models.fish_speech.pipeline",
+ "FISH_SPEECH_PIPELINE",
+ ),
+}
+
+# --- Single-stage diffusion pipelines (populated in PR 3/N) ---
+_DIFFUSION_PIPELINES: dict[str, tuple[str, str]] = {}
+
+# Union view used by ``_LazyPipelineRegistry``; don't mutate at runtime.
+_VLLM_OMNI_PIPELINES: dict[str, tuple[str, str]] = {
+ **_OMNI_PIPELINES,
+ **_DIFFUSION_PIPELINES,
+}
diff --git a/vllm_omni/config/stage_config.py b/vllm_omni/config/stage_config.py
index a4e186c3b..9839a5389 100644
--- a/vllm_omni/config/stage_config.py
+++ b/vllm_omni/config/stage_config.py
@@ -1,18 +1,13 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
-"""
-Stage Configuration System for vLLM-Omni.
-
-Pipeline structure (stages, types, data-flow) is defined in per-model YAML
-files and is set by model developers at integration time.
-Runtime parameters (gpu_memory_utilization, tp_size, etc.) come from CLI.
-"""
+"""Stage configuration system for vLLM-Omni."""
from __future__ import annotations
+import dataclasses
import re
import warnings
-from dataclasses import asdict, dataclass, field
+from dataclasses import asdict, dataclass, field, fields
from enum import Enum
from pathlib import Path
from typing import Any
@@ -20,76 +15,819 @@ from typing import Any
from vllm.logger import init_logger
from vllm_omni.config.yaml_util import create_config, load_yaml_config, to_dict
+from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
+from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
-# Pipeline YAMLs live alongside model code in model_executor/models//
_MODELS_DIR = Path(__file__).resolve().parent.parent / "model_executor" / "models"
def get_pipeline_path(model_dir: str, filename: str) -> Path:
- """Return the full path to a pipeline YAML file.
-
- Args:
- model_dir: Model subdirectory name (e.g., "qwen3_omni").
- filename: Name of the YAML file (e.g., "pipeline.yaml").
-
- Returns:
- Absolute path to the file.
- """
return _MODELS_DIR / model_dir / filename
logger = init_logger(__name__)
+_STAGE_OVERRIDE_PATTERN = re.compile(r"^stage_(\d+)_(.+)$")
+
+
+def build_stage_runtime_overrides(
+ stage_id: int,
+ cli_overrides: dict[str, Any],
+ *,
+ internal_keys: set[str] | frozenset[str] | None = None,
+) -> dict[str, Any]:
+ """Build per-stage runtime overrides from global and ``stage__*`` kwargs.
+
+ ``internal_keys`` defaults to the union of
+ ``arg_utils.internal_blacklist_keys()`` and ``arg_utils.SHARED_FIELDS``
+ so that neither orchestrator-only fields nor shared-pipeline fields
+ (``model`` / ``stage_configs_path`` / ``log_stats`` / ``stage_id``) leak
+ into a stage's per-stage runtime overrides — the orchestrator sets those
+ uniformly for every stage, they are not per-stage knobs. Callers can
+ pass an explicit set for tests or specialized flows.
+ """
+ if internal_keys is None:
+ from vllm_omni.engine.arg_utils import SHARED_FIELDS, internal_blacklist_keys
+
+ internal_keys = internal_blacklist_keys() | SHARED_FIELDS
+
+ result: dict[str, Any] = {}
+
+ for key, value in cli_overrides.items():
+ if value is None or key in internal_keys:
+ continue
+
+ match = _STAGE_OVERRIDE_PATTERN.match(key)
+ if match is not None:
+ override_stage_id = int(match.group(1))
+ param_name = match.group(2)
+ if override_stage_id == stage_id and param_name not in internal_keys:
+ result[param_name] = value
+ continue
+
+ result[key] = value
+
+ return result
+
+
+def strip_parent_engine_args(
+ kwargs: dict[str, Any],
+ *,
+ parent_fields: dict[str, dataclasses.Field],
+ keep_keys: set[str] | frozenset[str] = frozenset(),
+ strip_keys: set[str] | frozenset[str] = frozenset(),
+ no_warn_keys: set[str] | frozenset[str] = frozenset(),
+) -> tuple[dict[str, Any], list[str]]:
+ """Strip parent ``EngineArgs`` fields before merging into stage YAML."""
+ overridden: list[str] = []
+ result: dict[str, Any] = {}
+
+ for key, value in kwargs.items():
+ if key in strip_keys:
+ continue
+
+ if key not in parent_fields or key in keep_keys:
+ result[key] = value
+ continue
+
+ field_def = parent_fields[key]
+ if field_def.default is not dataclasses.MISSING:
+ default = field_def.default
+ elif field_def.default_factory is not dataclasses.MISSING:
+ default = field_def.default_factory()
+ else:
+ default = dataclasses.MISSING
+
+ if default is dataclasses.MISSING or value is None:
+ continue
+
+ if dataclasses.is_dataclass(default) and not isinstance(default, type):
+ default = asdict(default)
+
+ if value != default and key not in no_warn_keys:
+ overridden.append(key)
+
+ return result, sorted(overridden)
+
+
class StageType(str, Enum):
"""Type of processing stage in the Omni pipeline."""
+ # TODO(@lishunyang12): remove once all models migrate to StageExecutionType
LLM = "llm"
DIFFUSION = "diffusion"
-@dataclass
-class StageConfig:
- """Per-stage configuration from pipeline YAML.
+class StageExecutionType(str, Enum):
+ """Merged StageType + WorkerType — 3 combinations today."""
- Topology fields (stage_id, input_sources, etc.) define the DAG.
- Engine and runtime defaults come from the YAML; CLI overrides take
- precedence via ``runtime_overrides``.
- """
+ LLM_AR = "llm_ar"
+ LLM_GENERATION = "llm_generation"
+ DIFFUSION = "diffusion"
+
+
+# Mapping class refs (not dotted-path strings) so module/class renames fail
+# at import time instead of lazily at scheduler resolution. YAML overrides
+# and downstream serialization still use the dotted-path string form; the
+# conversion happens at the map lookup site via _scheduler_path().
+_EXECUTION_TYPE_TO_SCHEDULER: dict[StageExecutionType, type | None] = {
+ StageExecutionType.LLM_AR: OmniARScheduler,
+ StageExecutionType.LLM_GENERATION: OmniGenerationScheduler,
+ StageExecutionType.DIFFUSION: None,
+}
+
+
+def _scheduler_path(cls: type | None) -> str | None:
+ """Return the dotted import path for a scheduler class (``None`` passes through)."""
+ if cls is None:
+ return None
+ return f"{cls.__module__}.{cls.__qualname__}"
+
+
+@dataclass(frozen=True)
+class StagePipelineConfig:
+ """Fixed topology for one stage (frozen, not user-configurable)."""
- # Identity
stage_id: int
model_stage: str
+ execution_type: StageExecutionType = StageExecutionType.LLM_AR
+ input_sources: tuple[int, ...] = ()
+ final_output: bool = False
+ final_output_type: str | None = None
+ owns_tokenizer: bool = False
+ requires_multimodal_data: bool = False
+ hf_config_name: str | None = None
+ engine_output_type: str | None = None
+ model_arch: str | None = None
+ sampling_constraints: dict[str, Any] = field(default_factory=dict)
+ custom_process_input_func: str | None = None
+ custom_process_next_stage_input_func: str | None = None
+ # Alternates picked by ``merge_pipeline_deploy`` based on ``deploy.async_chunk``.
+ async_chunk_process_next_stage_input_func: str | None = None
+ sync_process_input_func: str | None = None
+ prompt_expand_func: str | None = None
+ cfg_kv_collect_func: str | None = None
+ omni_kv_config: dict[str, Any] | None = None
+ extras: dict[str, Any] = field(default_factory=dict)
- # Stage type
+
+@dataclass(frozen=True)
+class PipelineConfig:
+ """Complete pipeline topology for a model (frozen)."""
+
+ model_type: str
+ model_arch: str = ""
+ stages: tuple[StagePipelineConfig, ...] = ()
+ # HF architecture aliases: used by StageConfigFactory when the model's
+ # HF config reports a generic model_type that collides with a different
+ # model (e.g. MiMo Audio reports model_type="qwen2"). The factory
+ # matches ``hf_config.architectures[*]`` against this tuple to route
+ # to the correct pipeline. Leave empty for models with unique model_type.
+ hf_architectures: tuple[str, ...] = ()
+
+ def get_stage(self, stage_id: int) -> StagePipelineConfig | None:
+ """Look up a stage by its ID."""
+ for stage in self.stages:
+ if stage.stage_id == stage_id:
+ return stage
+ return None
+
+ def get_scheduler_cls(self, stage_id: int) -> str | None:
+ """Return the inferred scheduler class path for a stage.
+
+ Returns ``None`` for DIFFUSION stages (no vLLM scheduler). Raises
+ ``ValueError`` if ``stage_id`` doesn't exist in this pipeline, and
+ ``KeyError`` if ``execution_type`` isn't in the scheduler map.
+ """
+ stage = self.get_stage(stage_id)
+ if stage is None:
+ raise ValueError(f"Pipeline {self.model_type!r} has no stage with id {stage_id}")
+ return _scheduler_path(_EXECUTION_TYPE_TO_SCHEDULER[stage.execution_type])
+
+ def validate(self) -> list[str]:
+ """Return list of topology errors (empty if valid)."""
+ errors: list[str] = []
+ if not self.stages:
+ errors.append("Pipeline has no stages defined")
+ return errors
+ stage_ids = [s.stage_id for s in self.stages]
+ if len(stage_ids) != len(set(stage_ids)):
+ errors.append("Duplicate stage IDs found")
+ stage_id_set = set(stage_ids)
+ for stage in self.stages:
+ for src in stage.input_sources:
+ if src not in stage_id_set:
+ errors.append(f"Stage {stage.stage_id} references non-existent input source {src}")
+ if src == stage.stage_id:
+ errors.append(f"Stage {stage.stage_id} references itself")
+ if not any(not s.input_sources for s in self.stages):
+ errors.append("No entry point (stage with empty input_sources)")
+ return errors
+
+
+class _LazyPipelineRegistry:
+ """Dict-like registry that lazy-loads pipelines from the central declaration.
+
+ In-tree pipelines are declared once in
+ ``vllm_omni/config/pipeline_registry.py`` as
+ ``model_type -> (module_path, variable_name)`` entries; the module is
+ imported only when the pipeline is first looked up. This mirrors the
+ pattern in ``vllm/model_executor/models/registry.py`` and addresses
+ https://github.com/vllm-project/vllm-omni/issues/2887 (item 4): having
+ every registration in one file makes a missing entry easy to spot.
+
+ Out-of-tree / dynamic registrations via ``register_pipeline()`` are stored
+ directly in ``_loaded`` and take precedence over the lazy-map entry with
+ the same ``model_type``.
+
+ The class exposes the subset of ``dict`` operations the rest of this
+ module relies on (``__contains__``, ``__getitem__``, ``__setitem__``,
+ ``get``, ``keys``, ``values``, ``items``, ``__iter__``), so existing call
+ sites don't need to change.
+ """
+
+ def __init__(self) -> None:
+ self._loaded: dict[str, PipelineConfig] = {}
+ # Populated lazily to avoid a circular import at module init time.
+ self._lazy_map: dict[str, tuple[str, str]] | None = None
+
+ def _get_lazy_map(self) -> dict[str, tuple[str, str]]:
+ if self._lazy_map is None:
+ from vllm_omni.config.pipeline_registry import _VLLM_OMNI_PIPELINES
+
+ self._lazy_map = _VLLM_OMNI_PIPELINES
+ return self._lazy_map
+
+ def _load_lazy(self, model_type: str) -> PipelineConfig | None:
+ entry = self._get_lazy_map().get(model_type)
+ if entry is None:
+ return None
+ module_path, var_name = entry
+ import importlib
+
+ try:
+ module = importlib.import_module(module_path)
+ except ImportError as exc:
+ logger.error(
+ "Failed to import pipeline module %r for %r: %s",
+ module_path,
+ model_type,
+ exc,
+ )
+ return None
+ pipeline = getattr(module, var_name, None)
+ if pipeline is None:
+ logger.error(
+ "Pipeline variable %r not found in module %r (registered for %r)",
+ var_name,
+ module_path,
+ model_type,
+ )
+ return None
+ errors = pipeline.validate()
+ if errors:
+ logger.warning("Pipeline %s has issues: %s", pipeline.model_type, errors)
+ self._loaded[model_type] = pipeline
+ return pipeline
+
+ def __contains__(self, model_type: str) -> bool:
+ if model_type in self._loaded:
+ return True
+ return model_type in self._get_lazy_map()
+
+ def __getitem__(self, model_type: str) -> PipelineConfig:
+ if model_type in self._loaded:
+ return self._loaded[model_type]
+ pipeline = self._load_lazy(model_type)
+ if pipeline is None:
+ raise KeyError(model_type)
+ return pipeline
+
+ def get(self, model_type: str, default: PipelineConfig | None = None) -> PipelineConfig | None:
+ if model_type in self._loaded:
+ return self._loaded[model_type]
+ pipeline = self._load_lazy(model_type)
+ return pipeline if pipeline is not None else default
+
+ def __setitem__(self, model_type: str, pipeline: PipelineConfig) -> None:
+ self._loaded[model_type] = pipeline
+
+ def __delitem__(self, model_type: str) -> None:
+ """Remove a dynamically-registered pipeline.
+
+ Only the dynamic-cache side of the registry can be mutated; the
+ central declarative registry is immutable at runtime. Calling ``del``
+ on a model_type that only exists in the central registry raises
+ ``KeyError``.
+ """
+ if model_type in self._loaded:
+ del self._loaded[model_type]
+ return
+ if model_type in self._get_lazy_map():
+ raise KeyError(
+ f"{model_type!r} is declared in the central pipeline_registry and "
+ "cannot be removed at runtime. Edit "
+ "vllm_omni/config/pipeline_registry.py to delete a built-in entry."
+ )
+ raise KeyError(model_type)
+
+ def keys(self) -> set[str]:
+ return set(self._get_lazy_map().keys()) | set(self._loaded.keys())
+
+ def values(self):
+ # Iterating values forces load of every lazy pipeline.
+ for key in self.keys():
+ yield self[key]
+
+ def items(self):
+ for key in self.keys():
+ yield key, self[key]
+
+ def __iter__(self):
+ return iter(self.keys())
+
+
+_PIPELINE_REGISTRY = _LazyPipelineRegistry()
+
+
+def register_pipeline(pipeline: PipelineConfig) -> None:
+ """Register a pipeline config dynamically.
+
+ In-tree pipelines are declared in ``pipeline_registry._VLLM_OMNI_PIPELINES``
+ and loaded lazily; calling ``register_pipeline`` is only needed for
+ out-of-tree plugins or tests that build a ``PipelineConfig`` at runtime.
+ A dynamic registration overrides the central-registry entry with the same
+ ``model_type``.
+ """
+ errors = pipeline.validate()
+ if errors:
+ logger.warning("Pipeline %s has issues: %s", pipeline.model_type, errors)
+ _PIPELINE_REGISTRY[pipeline.model_type] = pipeline
+
+
+_DEPLOY_DIR = Path(__file__).resolve().parent.parent / "deploy"
+
+
+@dataclass
+class StageDeployConfig:
+ """Per-stage deployment knobs.
+
+ Only fields whose value legitimately varies across stages of the same
+ pipeline live here (e.g. ``max_num_seqs`` on thinker vs talker,
+ ``devices`` for GPU placement). Pipeline-wide settings
+ (``trust_remote_code``, ``distributed_executor_backend``, ``dtype``,
+ ``quantization``, prefix/chunked prefill, DP/PP sizes) are declared at
+ the top level of ``DeployConfig`` and propagated to every stage.
+ """
+
+ stage_id: int
+ max_num_seqs: int = 64
+ gpu_memory_utilization: float = 0.9
+ tensor_parallel_size: int = 1
+ enforce_eager: bool = False
+ max_num_batched_tokens: int = 32768
+ max_model_len: int | None = None
+ async_scheduling: bool | None = None
+ devices: str = "0"
+ output_connectors: dict[str, str] | None = None
+ input_connectors: dict[str, str] | None = None
+ default_sampling_params: dict[str, Any] | None = None
+ subtalker_sampling_params: dict[str, Any] | None = None
+ engine_extras: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class DeployConfig:
+ """Loaded from deploy/.yaml — the only config file users edit.
+
+ Top-level fields (``trust_remote_code``, ``distributed_executor_backend``,
+ ``dtype``, ``quantization``, ``enable_prefix_caching``,
+ ``enable_chunked_prefill``, ``data_parallel_size``,
+ ``pipeline_parallel_size``) are pipeline-wide: they apply uniformly to
+ every stage. Fields that legitimately vary per stage live in the
+ individual ``StageDeployConfig`` entries under ``stages:``.
+ """
+
+ async_chunk: bool = True
+ connectors: dict[str, Any] | None = None
+ edges: list[dict[str, Any]] | None = None
+ stages: list[StageDeployConfig] = field(default_factory=list)
+ platforms: dict[str, Any] | None = None
+ # Overrides the auto-detected pipeline registry key for structural variants.
+ pipeline: str | None = None
+
+ # === Pipeline-wide engine settings (applied uniformly to every stage) ===
+ trust_remote_code: bool = True
+ distributed_executor_backend: str = "mp"
+ dtype: str | None = None
+ quantization: str | None = None
+ enable_prefix_caching: bool = False
+ enable_chunked_prefill: bool | None = None
+ data_parallel_size: int = 1
+ pipeline_parallel_size: int = 1
+
+
+_STAGE_NON_ENGINE_KEYS = frozenset(
+ {
+ "stage_id",
+ "devices",
+ "output_connectors",
+ "input_connectors",
+ "default_sampling_params",
+ "engine_extras",
+ }
+)
+
+# Fields on StageDeployConfig that are populated from engine_args dict
+_STAGE_DEPLOY_FIELDS = {f.name: f for f in fields(StageDeployConfig) if f.name not in _STAGE_NON_ENGINE_KEYS}
+
+
+def _parse_stage_deploy(stage_data: dict[str, Any]) -> StageDeployConfig:
+ """Parse a single stage entry from deploy YAML into StageDeployConfig."""
+ if "engine_args" in stage_data:
+ engine_args = dict(stage_data["engine_args"])
+ devices = stage_data.get("runtime", {}).get("devices", stage_data.get("devices", "0"))
+ else:
+ engine_args = {k: v for k, v in stage_data.items() if k not in _STAGE_NON_ENGINE_KEYS and k != "stage_id"}
+ devices = stage_data.get("devices", "0")
+
+ kwargs: dict[str, Any] = {"stage_id": stage_data["stage_id"], "devices": devices}
+ for name, f in _STAGE_DEPLOY_FIELDS.items():
+ if name in engine_args:
+ kwargs[name] = engine_args.pop(name)
+
+ kwargs["output_connectors"] = stage_data.get("output_connectors")
+ kwargs["input_connectors"] = stage_data.get("input_connectors")
+ kwargs["default_sampling_params"] = stage_data.get("default_sampling_params")
+ kwargs["engine_extras"] = engine_args
+ return StageDeployConfig(**kwargs)
+
+
+_DEEP_MERGE_KEYS = frozenset({"default_sampling_params", "subtalker_sampling_params", "engine_extras", "engine_args"})
+
+
+def _deep_merge_stage(base: dict, overlay: dict) -> dict:
+ """Deep-merge ``_DEEP_MERGE_KEYS`` so thin overlays don't drop base keys."""
+ merged = dict(base)
+ for k, v in overlay.items():
+ if k in _DEEP_MERGE_KEYS:
+ base_val = merged.get(k)
+ if isinstance(v, dict) and isinstance(base_val, dict):
+ merged[k] = {**base_val, **v}
+ continue
+ # Deep-merge key but at least one side isn't a dict: surface the
+ # silent clobber so mismatched YAML types don't get past review.
+ if base_val is not None:
+ logger.warning(
+ "Deep-merge key %r has non-dict value (base=%s, overlay=%s); "
+ "overlay will fully replace base instead of merging.",
+ k,
+ type(base_val).__name__,
+ type(v).__name__,
+ )
+ merged[k] = v
+ return merged
+
+
+def _merge_stage_lists(
+ base_stages: list[dict[str, Any]] | None,
+ overlay_stages: list[dict[str, Any]] | None,
+) -> list[dict[str, Any]]:
+ """Merge two ``stages:`` lists by ``stage_id`` (overlay wins per field)."""
+ by_id: dict[int, dict[str, Any]] = {s["stage_id"]: s for s in (base_stages or [])}
+ for overlay_stage in overlay_stages or []:
+ sid = overlay_stage["stage_id"]
+ if sid in by_id:
+ by_id[sid] = _deep_merge_stage(by_id[sid], overlay_stage)
+ else:
+ by_id[sid] = overlay_stage
+ return list(by_id.values())
+
+
+def _merge_platforms(
+ base: dict[str, Any] | None,
+ overlay: dict[str, Any] | None,
+) -> dict[str, Any] | None:
+ """Deep-merge two ``platforms:`` blocks per-platform, per-stage_id."""
+ if not base and not overlay:
+ return None
+ base = base or {}
+ overlay = overlay or {}
+ merged: dict[str, Any] = {}
+ for plat in set(base) | set(overlay):
+ bp = base.get(plat) or {}
+ op = overlay.get(plat) or {}
+ merged_plat = {**bp, **{k: v for k, v in op.items() if k != "stages"}}
+ merged_plat["stages"] = _merge_stage_lists(bp.get("stages"), op.get("stages"))
+ merged[plat] = merged_plat
+ return merged
+
+
+def resolve_deploy_yaml(path: str | Path) -> dict[str, Any]:
+ """Load a deploy YAML with optional ``base_config`` inheritance."""
+ raw_dict = to_dict(load_yaml_config(path))
+
+ base_path = raw_dict.pop("base_config", None)
+ if base_path is None:
+ return raw_dict
+
+ # Resolve relative to the overlay file's directory
+ base_path = Path(path).parent / base_path
+ base_dict = resolve_deploy_yaml(base_path)
+
+ # Merge top-level scalars: overlay wins. ``stages:`` and ``platforms:``
+ # are deep-merged below so an overlay can layer on top of the base.
+ merged = {
+ **base_dict,
+ **{k: v for k, v in raw_dict.items() if k not in ("stages", "platforms")},
+ }
+ merged["stages"] = _merge_stage_lists(base_dict.get("stages"), raw_dict.get("stages"))
+ merged_platforms = _merge_platforms(base_dict.get("platforms"), raw_dict.get("platforms"))
+ if merged_platforms is not None:
+ merged["platforms"] = merged_platforms
+
+ return merged
+
+
+def load_deploy_config(path: str | Path) -> DeployConfig:
+ """Load a deploy YAML (with optional base_config inheritance)."""
+ raw_dict = resolve_deploy_yaml(path)
+
+ stages = [_parse_stage_deploy(s) for s in raw_dict.get("stages", [])]
+
+ kwargs: dict[str, Any] = {
+ "async_chunk": raw_dict.get("async_chunk", True),
+ "connectors": raw_dict.get("connectors", None),
+ "edges": raw_dict.get("edges", None),
+ "stages": stages,
+ "platforms": raw_dict.get("platforms", None),
+ "pipeline": raw_dict.get("pipeline", None),
+ }
+ # Pipeline-wide engine settings: only set if explicitly present in YAML
+ # so the DeployConfig dataclass defaults take effect otherwise.
+ for name in (
+ "trust_remote_code",
+ "distributed_executor_backend",
+ "dtype",
+ "quantization",
+ "enable_prefix_caching",
+ "enable_chunked_prefill",
+ "data_parallel_size",
+ "pipeline_parallel_size",
+ ):
+ if name in raw_dict:
+ kwargs[name] = raw_dict[name]
+ return DeployConfig(**kwargs)
+
+
+def _detect_platform() -> str | None:
+ """Return "npu", "rocm", "xpu", or None (CUDA default)."""
+ try:
+ from vllm.platforms import current_platform
+
+ name = current_platform.device_name.lower()
+ if "npu" in name:
+ return "npu"
+ if "rocm" in name or "amd" in name:
+ return "rocm"
+ if "xpu" in name:
+ return "xpu"
+ except Exception as e:
+ logger.debug("Platform auto-detect failed, falling back to CUDA: %s", e)
+ return None
+
+
+def _extract_platform_overrides(ps: dict[str, Any]) -> tuple[dict[str, Any], str | None]:
+ """Return ``(overrides, devices)`` from a platform stage entry.
+
+ Handles both the nested layout (``engine_args:`` / ``runtime.devices``) and
+ the flat layout. ``devices`` is ``None`` when no override is set.
+ """
+ if "engine_args" in ps:
+ return dict(ps["engine_args"]), ps.get("runtime", {}).get("devices")
+ overrides = {k: v for k, v in ps.items() if k not in ("stage_id", "devices")}
+ return overrides, ps.get("devices")
+
+
+def _apply_platform_overrides(
+ deploy: DeployConfig,
+ platform: str | None = None,
+) -> DeployConfig:
+ """Merge platform-specific stage overrides into deploy config."""
+ if platform is None:
+ platform = _detect_platform()
+ if platform is None or deploy.platforms is None:
+ return deploy
+ platform_section = deploy.platforms.get(platform)
+ if platform_section is None:
+ return deploy
+
+ platform_stages = platform_section.get("stages", [])
+ base_by_id = {s.stage_id: s for s in deploy.stages}
+
+ for ps in platform_stages:
+ base = base_by_id.get(ps["stage_id"])
+ if base is None:
+ continue
+ overrides, devices = _extract_platform_overrides(ps)
+ if devices is not None:
+ base.devices = devices
+ for key, val in overrides.items():
+ if hasattr(base, key):
+ setattr(base, key, val)
+ else:
+ base.engine_extras[key] = val
+
+ return deploy
+
+
+_EXECUTION_TYPE_TO_STAGE_WORKER: dict[StageExecutionType, tuple[StageType, str | None]] = {
+ StageExecutionType.LLM_AR: (StageType.LLM, "ar"),
+ StageExecutionType.LLM_GENERATION: (StageType.LLM, "generation"),
+ StageExecutionType.DIFFUSION: (StageType.DIFFUSION, None),
+}
+
+
+def _resolve_execution_mode(
+ execution_type: StageExecutionType,
+) -> tuple[StageType, str | None]:
+ """Map ``execution_type`` → ``(stage_type, worker_type)`` legacy tuple."""
+ return _EXECUTION_TYPE_TO_STAGE_WORKER.get(execution_type, (StageType.LLM, None))
+
+
+def _select_processor_funcs(
+ ps: StagePipelineConfig,
+ async_chunk: bool,
+) -> tuple[str | None, str | None]:
+ """Pick ``(input_proc, next_stage_proc)`` based on the async_chunk mode."""
+ next_stage_proc = ps.custom_process_next_stage_input_func
+ input_proc = ps.custom_process_input_func
+ if async_chunk and ps.async_chunk_process_next_stage_input_func:
+ next_stage_proc = ps.async_chunk_process_next_stage_input_func
+ elif not async_chunk and ps.sync_process_input_func:
+ input_proc = ps.sync_process_input_func
+ return input_proc, next_stage_proc
+
+
+# Pipeline-wide DeployConfig fields that are propagated to every stage's
+# engine args during merge. These live at top level of the deploy YAML.
+_PIPELINE_WIDE_ENGINE_FIELDS: tuple[str, ...] = (
+ "trust_remote_code",
+ "distributed_executor_backend",
+ "dtype",
+ "quantization",
+ "enable_prefix_caching",
+ "enable_chunked_prefill",
+ "data_parallel_size",
+ "pipeline_parallel_size",
+)
+
+
+def _build_engine_args(
+ ps: StagePipelineConfig,
+ ds: StageDeployConfig | None,
+ pipeline: PipelineConfig,
+ deploy: DeployConfig,
+ next_stage_proc: str | None,
+) -> dict[str, Any]:
+ """Assemble the flat ``yaml_engine_args`` dict for one stage.
+
+ Pipeline-wide DeployConfig fields are applied uniformly to every stage;
+ per-stage StageDeployConfig overrides take precedence when present (e.g.
+ ``engine_extras`` can still carry a stage-specific ``dtype``).
+ """
+ engine_args: dict[str, Any] = {"model_arch": ps.model_arch or pipeline.model_arch}
+ if ps.engine_output_type:
+ engine_args["engine_output_type"] = ps.engine_output_type
+ if next_stage_proc:
+ engine_args["custom_process_next_stage_input_func"] = next_stage_proc
+
+ # Pipeline-wide top-level DeployConfig settings, applied to every stage.
+ for name in _PIPELINE_WIDE_ENGINE_FIELDS:
+ value = getattr(deploy, name)
+ if value is not None:
+ engine_args[name] = value
+
+ # Per-stage StageDeployConfig values override pipeline-wide settings.
+ if ds is not None:
+ for k, v in asdict(ds).items():
+ if k in _STAGE_NON_ENGINE_KEYS or v is None:
+ continue
+ engine_args[k] = v
+ engine_args.update(ds.engine_extras)
+ if deploy.async_chunk:
+ engine_args["async_chunk"] = True
+ return engine_args
+
+
+def _build_extras(
+ ps: StagePipelineConfig,
+ ds: StageDeployConfig | None,
+) -> dict[str, Any]:
+ """Assemble ``yaml_extras`` (sampling + connectors + pipeline extras)."""
+ extras: dict[str, Any] = {}
+ sampling: dict[str, Any] = {}
+ if ds is not None and ds.default_sampling_params:
+ sampling.update(ds.default_sampling_params)
+ sampling.update(ps.sampling_constraints)
+ if sampling:
+ extras["default_sampling_params"] = sampling
+ if ds is not None and ds.output_connectors:
+ extras["output_connectors"] = dict(ds.output_connectors)
+ if ds is not None and ds.input_connectors:
+ extras["input_connectors"] = dict(ds.input_connectors)
+ if ps.extras:
+ extras.update(ps.extras)
+ return extras
+
+
+def merge_pipeline_deploy(
+ pipeline: PipelineConfig,
+ deploy: DeployConfig,
+ cli_overrides: dict[str, Any] | None = None,
+) -> list[StageConfig]:
+ """Merge pipeline + deploy + platform overrides → list[StageConfig]."""
+ if cli_overrides is None:
+ cli_overrides = {}
+
+ deploy = _apply_platform_overrides(deploy)
+ deploy_by_id = {s.stage_id: s for s in deploy.stages}
+
+ # A pipeline supports async_chunk if any stage has either an explicit
+ # async-chunk-only processor slot OR a custom next-stage processor (some
+ # pipelines like qwen3_omni wire async-chunk processing directly through
+ # ``custom_process_next_stage_input_func``). Only raise when neither is
+ # present — that's the "user enabled async_chunk but pipeline has no
+ # inter-stage processing at all" case.
+ if deploy.async_chunk and not any(
+ ps.async_chunk_process_next_stage_input_func or ps.custom_process_next_stage_input_func
+ for ps in pipeline.stages
+ ):
+ raise ValueError(
+ f"Pipeline {pipeline.model_type!r} has async_chunk=True in deploy but no stage "
+ "declares a next-stage input processor "
+ "(``async_chunk_process_next_stage_input_func`` or ``custom_process_next_stage_input_func``). "
+ "Either set async_chunk=False or implement an async-chunk processor on the pipeline."
+ )
+
+ result: list[StageConfig] = []
+ for ps in pipeline.stages:
+ ds = deploy_by_id.get(ps.stage_id)
+ stage_type, worker_type = _resolve_execution_mode(ps.execution_type)
+ input_proc, next_stage_proc = _select_processor_funcs(ps, deploy.async_chunk)
+ engine_args = _build_engine_args(ps, ds, pipeline, deploy, next_stage_proc)
+ extras = _build_extras(ps, ds)
+ runtime: dict[str, Any] = {"process": True}
+ if ds is not None:
+ runtime["devices"] = ds.devices
+
+ result.append(
+ StageConfig(
+ stage_id=ps.stage_id,
+ model_stage=ps.model_stage,
+ stage_type=stage_type,
+ input_sources=list(ps.input_sources),
+ custom_process_input_func=input_proc,
+ final_output=ps.final_output,
+ final_output_type=ps.final_output_type,
+ worker_type=worker_type,
+ scheduler_cls=_scheduler_path(_EXECUTION_TYPE_TO_SCHEDULER.get(ps.execution_type)),
+ hf_config_name=ps.hf_config_name,
+ is_comprehension=ps.owns_tokenizer,
+ yaml_engine_args=engine_args,
+ yaml_runtime=runtime,
+ yaml_extras=extras,
+ )
+ )
+ return result
+
+
+@dataclass
+class StageConfig:
+ """Per-stage config (legacy path). Used by both new and legacy loaders.
+
+ TODO(@lishunyang12): replace with ResolvedStageConfig once all models are migrated.
+ """
+
+ stage_id: int
+ model_stage: str
stage_type: StageType = StageType.LLM
-
input_sources: list[int] = field(default_factory=list)
custom_process_input_func: str | None = None
final_output: bool = False
- final_output_type: str | None = None # "text", "audio", "image"
- worker_type: str | None = None # "ar" or "generation"
+ final_output_type: str | None = None
+ worker_type: str | None = None
scheduler_cls: str | None = None
hf_config_name: str | None = None
is_comprehension: bool = False
-
- # Per-stage engine args from pipeline YAML (defaults)
yaml_engine_args: dict[str, Any] = field(default_factory=dict)
- # Per-stage runtime config from pipeline YAML (devices, etc.)
yaml_runtime: dict[str, Any] = field(default_factory=dict)
- # Pass-through fields from pipeline YAML (default_sampling_params,
- # output_connectors, input_connectors, tts_args, etc.)
yaml_extras: dict[str, Any] = field(default_factory=dict)
-
- # Runtime overrides (populated from CLI, not from pipeline YAML)
runtime_overrides: dict[str, Any] = field(default_factory=dict)
def to_omegaconf(self) -> Any:
- """Convert to OmegaConf for backward compatibility with OmniStage.
-
- Returns:
- OmegaConf DictConfig with stage configuration in legacy format.
- """
+ """TODO(@lishunyang12): remove once engine consumes ResolvedStageConfig directly."""
# Start with YAML engine_args defaults
engine_args: dict[str, Any] = dict(self.yaml_engine_args)
@@ -152,9 +890,9 @@ class StageConfig:
@dataclass
class ModelPipeline:
- """Complete pipeline definition for a multi-stage model.
+ """Complete pipeline definition for a multi-stage model (legacy).
- Defined by model developers, bundled with the model, not user-editable.
+ TODO(@lishunyang12): remove once all models migrate to PipelineConfig.
"""
model_type: str
@@ -225,49 +963,55 @@ class StageConfigFactory:
"""Factory that loads pipeline YAML and merges CLI overrides.
Handles both single-stage and multi-stage models.
+
+ Pipelines are declared in ``vllm_omni/config/pipeline_registry.py`` and
+ loaded lazily via ``_PIPELINE_REGISTRY``; no hardcoded model-type →
+ directory mapping is maintained here. Models with generic HF
+ ``model_type`` collisions (e.g. MiMo Audio reports ``qwen2``) should
+ declare ``hf_architectures=(...)`` on their ``PipelineConfig`` so the
+ factory can disambiguate via ``hf_config.architectures``.
"""
- # Mapping of model types to directories under model_executor/models/.
- PIPELINE_MODELS: dict[str, str] = {
- "qwen3_omni_moe": "qwen3_omni",
- "qwen2_5_omni": "qwen2_5_omni",
- "bagel": "bagel",
- "qwen3_tts": "qwen3_tts",
- "voxtral_tts": "voxtral_tts",
- "mimo_audio": "mimo_audio",
- "glm-image": "glm_image",
- "cosyvoice3": "cosyvoice3",
- "mammothmoda2": "mammoth_moda2",
- }
-
- # Fallback: map HF architecture class names to pipeline dirs.
- # Used when model_type collides with another model (e.g. MiMo Audio
- # reports model_type="qwen2" which matches plain Qwen2, not our pipeline).
- _ARCHITECTURE_MODELS: dict[str, str] = {
- "MiMoAudioForConditionalGeneration": "mimo_audio",
- "HunyuanImage3ForCausalMM": "hunyuan_image3",
- }
-
@classmethod
def create_from_model(
cls,
model: str,
cli_overrides: dict[str, Any] | None = None,
+ deploy_config_path: str | None = None,
+ cli_explicit_keys: set[str] | None = None,
) -> list[StageConfig] | None:
- """Load pipeline YAML, merge with CLI overrides.
+ """Load pipeline + deploy config, merge with CLI overrides.
- Args:
- model: Model name or path.
- cli_overrides: CLI overrides from VllmConfig/OmniDiffusionConfig.
+ Checks _PIPELINE_REGISTRY first (new path), falls back to legacy YAML.
- Returns:
- List of StageConfig objects with CLI overrides applied,
- or None if no pipeline definition was found for this model.
+ ``cli_explicit_keys`` is the set of CLI keys the user actually typed
+ (captured at the parser layer in ``vllm serve``). When ``None`` —
+ which is the case for programmatic ``Omni()`` callers — every kwarg
+ in ``cli_overrides`` is treated as explicit.
"""
if cli_overrides is None:
cli_overrides = {}
trust_remote_code = cli_overrides.get("trust_remote_code", True)
+
+ # --- New path: check pipeline registry by model_type first ---
+ model_type, hf_config = cls._auto_detect_model_type(model, trust_remote_code=trust_remote_code)
+ if model_type and model_type in _PIPELINE_REGISTRY:
+ return cls._create_from_registry(model_type, cli_overrides, deploy_config_path, cli_explicit_keys)
+
+ # --- HF architecture fallback: some models report a generic
+ # model_type that collides with another model. Match by the
+ # hf_architectures declared on each registered PipelineConfig.
+ if hf_config is not None:
+ hf_archs = set(getattr(hf_config, "architectures", []) or [])
+ if hf_archs:
+ for registered in _PIPELINE_REGISTRY.values():
+ if hf_archs.intersection(registered.hf_architectures):
+ return cls._create_from_registry(
+ registered.model_type, cli_overrides, deploy_config_path, cli_explicit_keys
+ )
+
+ # --- Legacy path: load from pipeline YAML ---
pipeline = cls._load_pipeline(model, trust_remote_code=trust_remote_code)
if pipeline is None:
@@ -295,6 +1039,78 @@ class StageConfigFactory:
return result
+ @classmethod
+ def _create_from_registry(
+ cls,
+ model_type: str,
+ cli_overrides: dict[str, Any],
+ deploy_config_path: str | None = None,
+ cli_explicit_keys: set[str] | None = None,
+ ) -> list[StageConfig]:
+ """Create StageConfigs from pipeline registry + deploy YAML.
+
+ Precedence (high → low):
+ explicit CLI args > deploy YAML > parser default CLI values
+
+ ``cli_explicit_keys`` carries the set of long-option attribute names
+ the user actually typed (captured in ``OmniServeCommand.cmd``). Any
+ kwarg whose key is not in that set is treated as a parser default
+ and is only used to fill fields YAML doesn't already cover. When the
+ set is ``None`` (programmatic ``Omni()`` callers, which have no
+ argparse layer), every kwarg is treated as explicit.
+ """
+ # Resolve deploy config path
+ if deploy_config_path is None:
+ deploy_path = _DEPLOY_DIR / f"{model_type}.yaml"
+ else:
+ deploy_path = Path(deploy_config_path)
+
+ if not deploy_path.exists():
+ logger.warning(
+ "Deploy config not found: %s — using pipeline defaults only",
+ deploy_path,
+ )
+ deploy_cfg = DeployConfig()
+ else:
+ deploy_cfg = load_deploy_config(deploy_path)
+
+ cli_async_chunk = cli_overrides.get("async_chunk")
+ if cli_async_chunk is not None and (cli_explicit_keys is None or "async_chunk" in cli_explicit_keys):
+ deploy_cfg.async_chunk = bool(cli_async_chunk)
+
+ pipeline_key = deploy_cfg.pipeline or model_type
+ if pipeline_key not in _PIPELINE_REGISTRY:
+ raise KeyError(
+ f"Pipeline {pipeline_key!r} not in registry "
+ f"(resolved from {deploy_path.name!r}). Available: "
+ f"{sorted(_PIPELINE_REGISTRY.keys())}"
+ )
+ pipeline_cfg = _PIPELINE_REGISTRY[pipeline_key]
+
+ stages = merge_pipeline_deploy(pipeline_cfg, deploy_cfg, cli_overrides)
+
+ # Precedence: explicit CLI > yaml > parser-default CLI.
+ # Per-stage (``stage_N_*``) keys are always treated as explicit.
+ explicit_overrides: dict[str, Any] = {}
+ default_overrides: dict[str, Any] = {}
+ for key, value in cli_overrides.items():
+ if value is None:
+ continue
+ is_per_stage = bool(re.match(r"stage_\d+_", key))
+ is_explicit = cli_explicit_keys is None or key in cli_explicit_keys or is_per_stage
+ if is_explicit:
+ explicit_overrides[key] = value
+ else:
+ default_overrides[key] = value
+
+ for stage in stages:
+ yaml_keys = set(stage.yaml_engine_args)
+ fallback = {k: v for k, v in default_overrides.items() if k not in yaml_keys}
+ merged = {**fallback, **explicit_overrides}
+ stage.runtime_overrides = cls._merge_cli_overrides(stage, merged)
+
+ return stages
+
@classmethod
def create_default_diffusion(cls, kwargs: dict[str, Any]) -> list[dict[str, Any]]:
"""Single-stage diffusion - no YAML needed.
@@ -322,9 +1138,16 @@ class StageConfigFactory:
continue
engine_args[key] = value
- # Serialize parallel_config as dict for OmegaConf compatibility
+ # Serialize parallel_config as dict for OmegaConf. Test helpers
+ # sometimes pass SimpleNamespace rather than a dataclass instance.
if "parallel_config" in kwargs:
- engine_args["parallel_config"] = asdict(kwargs["parallel_config"])
+ parallel_config = kwargs["parallel_config"]
+ if dataclasses.is_dataclass(parallel_config) and not isinstance(parallel_config, type):
+ engine_args["parallel_config"] = asdict(parallel_config)
+ elif hasattr(parallel_config, "__dict__"):
+ engine_args["parallel_config"] = dict(vars(parallel_config))
+ else:
+ engine_args["parallel_config"] = parallel_config
engine_args.setdefault("cache_backend", "none")
engine_args["model_stage"] = "diffusion"
@@ -351,40 +1174,49 @@ class StageConfigFactory:
@classmethod
def _load_pipeline(cls, model: str, trust_remote_code: bool = True) -> ModelPipeline | None:
- """Load pipeline YAML for the model.
+ """Load a legacy ``pipeline.yaml`` for the model.
- Args:
- model: Model name or path.
- trust_remote_code: Whether to trust remote code for HF config loading.
+ Searches ``model_executor/models//pipeline.yaml`` by trying
+ (a) the raw ``model_type`` as the directory name, then
+ (b) ``model_type`` with hyphens replaced by underscores,
+ and finally (c) scanning every ``pipeline.yaml`` for one that
+ declares a matching ``model_type`` or ``hf_architectures``.
- Returns:
- ModelPipeline if found, None otherwise.
+ Returns None if no pipeline.yaml is found — caller handles the
+ ``resolve_model_config_path`` fallback via stage_configs/ YAMLs.
"""
model_type, hf_config = cls._auto_detect_model_type(model, trust_remote_code=trust_remote_code)
if model_type is None:
return None
- pipeline_dir = cls.PIPELINE_MODELS.get(model_type)
+ # Direct lookups by convention
+ candidates = [model_type, model_type.replace("-", "_")]
+ for dir_name in candidates:
+ pipeline_path = get_pipeline_path(dir_name, "pipeline.yaml")
+ if pipeline_path.exists():
+ return cls._parse_pipeline_yaml(pipeline_path, model_type)
- # Fallback: check HF architectures when model_type doesn't match
- if pipeline_dir is None and hf_config is not None:
- for arch in getattr(hf_config, "architectures", []) or []:
- pipeline_dir = cls._ARCHITECTURE_MODELS.get(arch)
- if pipeline_dir is not None:
- model_type = pipeline_dir
- break
+ # Scan fallback: read every pipeline.yaml and match on declared fields
+ hf_archs = set(getattr(hf_config, "architectures", []) or []) if hf_config else set()
+ if _MODELS_DIR.exists():
+ for subdir in sorted(_MODELS_DIR.iterdir()):
+ if not subdir.is_dir():
+ continue
+ pipeline_path = subdir / "pipeline.yaml"
+ if not pipeline_path.exists():
+ continue
+ try:
+ cfg = load_yaml_config(pipeline_path)
+ except Exception as exc:
+ logger.debug("Skip %s: %s", pipeline_path, exc)
+ continue
+ declared_type = getattr(cfg, "model_type", None)
+ declared_archs = set(getattr(cfg, "hf_architectures", None) or [])
+ if declared_type == model_type or (hf_archs and hf_archs.intersection(declared_archs)):
+ return cls._parse_pipeline_yaml(pipeline_path, declared_type or model_type)
- if pipeline_dir is None:
- logger.debug(f"No pipeline mapping for model_type: {model_type}")
- return None
-
- pipeline_path = get_pipeline_path(pipeline_dir, "pipeline.yaml")
-
- if not pipeline_path.exists():
- logger.debug(f"Pipeline file not found: {pipeline_path}")
- return None
-
- return cls._parse_pipeline_yaml(pipeline_path, model_type)
+ logger.debug("No pipeline.yaml found for model_type %s (archs=%s)", model_type, sorted(hf_archs))
+ return None
# Keys consumed as explicit StageConfig fields — everything else is
# passed through via yaml_extras.
@@ -535,29 +1367,33 @@ class StageConfigFactory:
from vllm.transformers_utils.config import get_hf_file_to_dict
config_dict = get_hf_file_to_dict("config.json", model, revision=None)
- if config_dict and "model_type" in config_dict:
- return config_dict["model_type"], None
+ if config_dict:
+ if "model_type" in config_dict:
+ return config_dict["model_type"], None
+ # VoxCPM2-style configs use singular ``architecture`` rather
+ # than HF's standard ``model_type`` / ``architectures``. Accept
+ # it as a fallback so the pipeline registry can still match.
+ if "architecture" in config_dict and isinstance(config_dict["architecture"], str):
+ return config_dict["architecture"], None
except Exception as e:
logger.debug(f"Failed to auto-detect model type for {model}: {e}")
- return None, None
+ # Final fallback: some models (e.g. CosyVoice3) ship an empty
+ # config.json and rely on naming conventions. Match the model path
+ # basename against registered pipeline keys — longest match wins
+ # so "cosyvoice3" (length 10) beats "cosyvoice" (length 9).
+ model_lower = model.lower().replace("-", "").replace("_", "")
+ best: str | None = None
+ best_len = 0
+ for registered_key in _PIPELINE_REGISTRY.keys():
+ candidate = registered_key.lower().replace("-", "").replace("_", "")
+ if candidate and candidate in model_lower and len(candidate) > best_len:
+ best = registered_key
+ best_len = len(candidate)
+ if best is not None:
+ return best, None
- # Keys that should never be forwarded as engine overrides (internal /
- # orchestrator-only knobs, complex objects, etc.).
- _INTERNAL_KEYS: set[str] = {
- "model",
- "stage_configs_path",
- "stage_id",
- "stage_init_timeout",
- "init_timeout",
- "shm_threshold_bytes",
- "worker_backend",
- "ray_address",
- "batch_timeout",
- "log_stats",
- "tokenizer",
- "parallel_config",
- }
+ return None, None
@classmethod
def _merge_cli_overrides(
@@ -565,43 +1401,11 @@ class StageConfigFactory:
stage: StageConfig,
cli_overrides: dict[str, Any],
) -> dict[str, Any]:
- """Merge CLI overrides into stage runtime config.
+ """Merge global and per-stage (``stage_N_*``) CLI overrides.
- All CLI arguments registered by engine config classes (e.g.
- EngineArgs / OmniDiffusionConfig) are accepted as overrides
- unless they appear in ``_INTERNAL_KEYS``.
-
- Handles:
- - Global overrides (apply to all stages)
- - Per-stage overrides (--stage-N-* format, take precedence)
-
- Args:
- stage: The stage to merge overrides into.
- cli_overrides: CLI arguments from VllmConfig/OmniDiffusionConfig.
-
- Returns:
- Dict of runtime overrides for this stage.
+ Orchestrator-owned keys are filtered by ``build_stage_runtime_overrides``
+ using ``OrchestratorArgs`` as the single source of truth; unknown
+ server/uvicorn keys are dropped downstream by
+ ``filter_dataclass_kwargs(OmniEngineArgs, ...)``.
"""
- result: dict[str, Any] = {}
-
- # Apply global overrides – any key not in the internal blocklist
- # is forwarded so that engine-registered params work out of the box.
- for key, value in cli_overrides.items():
- if key in cls._INTERNAL_KEYS:
- continue
- if re.match(r"stage_\d+_", key):
- # Per-stage keys handled below
- continue
- if value is not None:
- result[key] = value
-
- # Apply per-stage overrides (--stage-N-* format, take precedence)
- stage_prefix = f"stage_{stage.stage_id}_"
- for key, value in cli_overrides.items():
- if key.startswith(stage_prefix) and value is not None:
- param_name = key[len(stage_prefix) :]
- if param_name in cls._INTERNAL_KEYS:
- continue
- result[param_name] = value
-
- return result
+ return build_stage_runtime_overrides(stage.stage_id, cli_overrides)
diff --git a/vllm_omni/core/prefix_cache.py b/vllm_omni/core/prefix_cache.py
new file mode 100644
index 000000000..69e7346c4
--- /dev/null
+++ b/vllm_omni/core/prefix_cache.py
@@ -0,0 +1,264 @@
+"""
+Utilities for Prefix Caching in Omni models.
+"""
+
+import torch
+from vllm.logger import init_logger
+from vllm.v1.worker.gpu_input_batch import InputBatch
+
+from vllm_omni.utils.mm_outputs import build_mm_cpu, to_payload_element
+
+logger = init_logger(__name__)
+
+
+class OmniTensorPrefixCache:
+ """Prefix cache for hidden states (model outputs) and model specific
+ multimodal outputs.
+
+ This class implements prefix caching in a non-invasive way on top of
+ vLLM by leveraging the same slot mappings that the vLLM scheduler uses
+ for the KV Cache.
+
+ Conceptually, this means we are mapping vLLM's cache mapping:
+ (num_blocks, block_size)
+
+ to 3D tensors of shape:
+ (num_blocks, block_size, feature_size)
+
+ Note that feature_size may vary across multimodal_outputs.
+ """
+
+ def __init__(
+ self,
+ num_blocks: int,
+ block_size: int,
+ hidden_size: int,
+ hs_dtype: torch.dtype,
+ ):
+ self.num_blocks = num_blocks
+ self.block_size = block_size
+ self.default_hidden_size = hidden_size
+
+ # Initialize the hidden states cache immediately
+ self.hidden_states_cache = self._get_cache_tensor(dtype=hs_dtype)
+
+ # Defer initialization of the mm_outputs_cache until we
+ # actually see mm output tensors dependent on num tokens.
+ self.mm_outputs_cache = {}
+ self.mm_cache_keys = set()
+ self._new_req_cache_hit_ids: set[str] = set()
+
+ def maybe_init_missing_mm_cache_keys(self, multimodal_outputs: dict, seq_len: int):
+ """Given multimodal outputs from executing the model, dynamically
+ determine which multimodal outputs are tensors depending on sequence
+ length and should be cached, and initialize the cache tensors
+ accordingly.
+
+ NOTE: This is done to avoid the need for explicit specification of
+ cache keys for every model/stage and aligns with the current way
+ that we slice the multimodal outputs based on the first dimension.
+
+ This will usually be called by the first forward pass, i.e.,
+ determined by the warmup.
+ """
+ for key, val in multimodal_outputs.items():
+ if isinstance(val, torch.Tensor) and val.shape[0] == seq_len and key not in self.mm_cache_keys:
+ feat_dim = val.shape[-1]
+ self.mm_outputs_cache[key] = self._get_cache_tensor(
+ dtype=val.dtype,
+ hidden_size=feat_dim,
+ )
+ self.mm_cache_keys.add(key)
+ new_tensor_shape = self.mm_outputs_cache[key].shape
+ logger.info("Initializing multimodal output cache of size %s for key: %s", list(new_tensor_shape), key)
+
+ def _get_cache_tensor(self, dtype: torch.dtype, hidden_size: int | None = None) -> torch.Tensor:
+ """Allocate a CPU cache tensor for a specific key."""
+ actual_hidden_size = hidden_size if hidden_size is not None else self.default_hidden_size
+ return torch.zeros(
+ (self.num_blocks, self.block_size, actual_hidden_size),
+ dtype=dtype,
+ device="cpu",
+ )
+
+ def add_prefix_cached_new_req_id(self, req_id: str):
+ """Adds a new request ID to the set of prefix cache hits on the batch."""
+ self._new_req_cache_hit_ids.add(req_id)
+
+ def reset_prefix_cached_new_req_ids(self):
+ """Clears the cache hit IDs to prepare for a new engine step."""
+ self._new_req_cache_hit_ids.clear()
+
+ @staticmethod
+ def _coerce_to_cpu_tensor(maybe_gpu_tensor: torch.Tensor) -> torch.Tensor:
+ """Convert GPU tensors -> contiguous CPU tensors if needed."""
+ return maybe_gpu_tensor.detach().cpu().contiguous()
+
+ def update_omni_tensor_prefix_cache(
+ self,
+ hidden_states: torch.Tensor | None,
+ multimodal_outputs: dict[str, torch.Tensor] | None,
+ num_tokens_unpadded: int,
+ slot_mapping: torch.Tensor,
+ num_tokens_padded: int | None = None,
+ ):
+ """Updates the hidden cache state for the provided hidden states and multimodal outputs.
+
+ Args:
+ hidden_states: Hidden states tensor to cache (if any)
+ multimodal_outputs: Multimodal dict whose tensors may be cached
+ num_tokens_unpadded: Number of tokens without padding
+ slot_mapping: Slot mapping for the input sequence
+ num_tokens_padded: Total number of tokens including padding
+ """
+ unpadded_slot_mapping = slot_mapping[:num_tokens_unpadded]
+ if num_tokens_padded is None:
+ num_tokens_padded = num_tokens_unpadded
+
+ if hidden_states is not None:
+ # Slice to unpadded portion before caching
+ hidden_states = hidden_states[:num_tokens_unpadded]
+ # Ensure that hidden states are on the CPU
+ hidden_states = OmniTensorPrefixCache._coerce_to_cpu_tensor(hidden_states)
+ # View the cache as 2D so that we can treat our slots as row indices
+ flat_cache = self.hidden_states_cache.view(-1, self.hidden_states_cache.shape[-1])
+ flat_cache[unpadded_slot_mapping] = hidden_states
+ logger.debug("Writing to hidden states for %s tokens", num_tokens_unpadded)
+
+ # Do the same for the stage's cached multimodal outputs
+ if multimodal_outputs is not None:
+ # If we haven't initialized the keys already, do it now
+ # We check against the padded token count since we haven't sliced yet
+ self.maybe_init_missing_mm_cache_keys(
+ multimodal_outputs,
+ seq_len=num_tokens_padded,
+ )
+
+ for mm_out_key, mm_cache in self.mm_outputs_cache.items():
+ if mm_out_key in multimodal_outputs:
+ # Slice to unpadded portion before caching
+ mm_state = multimodal_outputs[mm_out_key][:num_tokens_unpadded]
+ mm_state = OmniTensorPrefixCache._coerce_to_cpu_tensor(mm_state)
+ flat_cache = mm_cache.view(-1, mm_cache.shape[-1])
+ flat_cache[unpadded_slot_mapping] = mm_state
+ logger.debug("Writing to mm output cache for %s tokens", num_tokens_unpadded)
+
+ def _coerce_to_payload_dict(
+ self,
+ element: object,
+ query_start_loc: torch.Tensor,
+ input_batch: InputBatch,
+ num_scheduled_tokens: dict[str, int],
+ ) -> dict[str, object]:
+ """Build the multimodal passthrough data per request for
+ the object under consideration. This is identical to the case
+ for no prefix cache when we tensor does have a first dimension
+ matching the seq len.
+ """
+ elem_dict = {}
+ for req_id in input_batch.req_ids:
+ req_idx = input_batch.req_id_to_index[req_id]
+ start = query_start_loc[req_idx]
+ end = start + num_scheduled_tokens[req_id]
+ elem_dict[req_id] = to_payload_element(
+ element, req_idx, start=start, end=end, pass_lists_through=True, seq_len=None
+ )
+ return elem_dict
+
+ def get_merged_multimodal_states(
+ self,
+ query_start_loc: torch.Tensor,
+ input_batch: InputBatch,
+ multimodal_outputs: dict,
+ num_scheduled_tokens: dict[str, int],
+ ):
+ """Get the merged multimodal states if hidden state prefix caching is enabled."""
+ combined_multimodal_outputs = {}
+ # First get the prefix cached tensors that are present in the mm data
+ for mm_key in self.mm_cache_keys:
+ if mm_key in multimodal_outputs:
+ combined_multimodal_outputs[mm_key] = self._get_merged_tensors(
+ query_start_loc=query_start_loc,
+ input_batch=input_batch,
+ cache=self.mm_outputs_cache[mm_key],
+ hidden_states=multimodal_outputs[mm_key],
+ num_scheduled_tokens=num_scheduled_tokens,
+ )
+
+ # Then, get everything else (passthrough data); first, convert to CPU
+ # tensors similarly to the non prefix cached path, and then populate
+ # the subdicts mapping request IDs -> payload objects
+ passthrough_keys = set(multimodal_outputs.keys()) - self.mm_cache_keys
+ passthrough_mm_data = {k: v for k, v in multimodal_outputs.items() if k in passthrough_keys}
+ mm_cpu = build_mm_cpu(multimodal_outputs=passthrough_mm_data)
+
+ for mm_key, mm_val in mm_cpu.items():
+ combined_multimodal_outputs[mm_key] = self._coerce_to_payload_dict(
+ element=mm_val,
+ query_start_loc=query_start_loc,
+ input_batch=input_batch,
+ num_scheduled_tokens=num_scheduled_tokens,
+ )
+ return combined_multimodal_outputs
+
+ def get_merged_hidden_states(self, *args, **kwargs) -> dict[str, torch.Tensor]:
+ """Get the merged hidden states."""
+ return self._get_merged_tensors(
+ *args,
+ **kwargs,
+ cache=self.hidden_states_cache,
+ )
+
+ def _get_merged_tensors(
+ self,
+ query_start_loc: torch.Tensor,
+ input_batch: InputBatch,
+ cache: torch.Tensor,
+ hidden_states: torch.Tensor,
+ num_scheduled_tokens: dict[str, int],
+ ) -> dict[str, torch.Tensor]:
+ """When hidden state caching is enabled, takes the input hidden_states,
+ which only correspond to the scheduled tokens, and returns a mapping
+ from request IDs to their full hidden states. This is accomplished by
+ looking up the block IDs & scheduled token counts to split the
+ hidden_states.
+ """
+ # We do not support hybrid caches at the moment.
+ if len(input_batch.block_table.block_tables) > 1:
+ logger.warning_once(
+ "Omni prefix caching is enabled, but the batch block table appears to"
+ " have multiple kv groups; only the first group will be used!"
+ )
+
+ combined_hidden_states = {}
+ hidden_states = OmniTensorPrefixCache._coerce_to_cpu_tensor(hidden_states)
+ for req_id in input_batch.req_ids:
+ req_idx = input_batch.req_id_to_index[req_id]
+
+ if req_id in self._new_req_cache_hit_ids:
+ block_ids = self._get_cached_block_ids(req_idx, input_batch)
+ cached_hs = cache[block_ids].reshape(-1, cache.shape[-1])
+
+ # Slice the hidden states corresponding to this request;
+ # we do this by using the query start
+ start = query_start_loc[req_idx]
+ new_hs = hidden_states[start : start + num_scheduled_tokens[req_id]]
+ combined_hidden_states[req_id] = torch.cat([cached_hs, new_hs], dim=0)
+ else:
+ # cache miss for this request, pass through normally
+ start = query_start_loc[req_idx]
+ new_hs = hidden_states[start : start + num_scheduled_tokens[req_id]]
+ combined_hidden_states[req_id] = new_hs
+
+ return combined_hidden_states
+
+ def _get_cached_block_ids(self, req_idx: int, input_batch: InputBatch) -> torch.Tensor:
+ """Given an input batch and request index in the batch (not ID), get the
+ block IDs corresponding to the cache hit.
+ """
+ num_computed = input_batch.num_computed_tokens_cpu[req_idx]
+ # NOTE: vLLM only caches full blocks
+ num_cached_blocks = num_computed // self.block_size
+ # Get the block IDs attached to this cache hit and reindex into
+ # the flattened cached hidden states (i.e., 1 row per token).
+ return input_batch.block_table[0].block_table.cpu[req_idx, :num_cached_blocks]
diff --git a/vllm_omni/core/sched/omni_ar_scheduler.py b/vllm_omni/core/sched/omni_ar_scheduler.py
index 0ee8cd16a..a5579dd46 100644
--- a/vllm_omni/core/sched/omni_ar_scheduler.py
+++ b/vllm_omni/core/sched/omni_ar_scheduler.py
@@ -15,9 +15,10 @@ from vllm.v1.core.sched.utils import remove_all
from vllm.v1.engine import EngineCoreOutput, EngineCoreOutputs
from vllm.v1.metrics.perf import PerfStats
from vllm.v1.outputs import ModelRunnerOutput
-from vllm.v1.request import Request, RequestStatus
+from vllm.v1.request import Request, RequestStatus, StreamingUpdate
from vllm.v1.spec_decode.metrics import SpecDecodingStats
+from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
from vllm_omni.core.sched.output import OmniSchedulerOutput
from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import (
OmniChunkTransferAdapter,
@@ -38,7 +39,7 @@ class KVCacheTransferData:
return asdict(self)
-class OmniARScheduler(VLLMScheduler):
+class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
"""
OmniARScheduler: Scheduler for vLLM-Omni multimodal processing.
@@ -76,6 +77,8 @@ class OmniARScheduler(VLLMScheduler):
self.chunk_transfer_adapter = None
if getattr(model_config, "async_chunk", False):
self.chunk_transfer_adapter = OmniChunkTransferAdapter(self.vllm_config)
+ # Snapshot prompt length for each streaming input update
+ self._new_prompt_len_snapshot: dict[str, int] = {}
def _get_kv_transfer_criteria(self) -> dict | None:
# Note: vllm_config is available in Scheduler after super().__init__
@@ -338,6 +341,7 @@ class OmniARScheduler(VLLMScheduler):
)
stopped = False
+ is_segment_finished = False
new_logprobs = None
new_token_ids = generated_token_ids
pooler_output = pooler_outputs[req_index] if pooler_outputs else None
@@ -366,6 +370,7 @@ class OmniARScheduler(VLLMScheduler):
# Capture finish_reason BEFORE _handle_stopped_request, which may
# reset the status to WAITING for streaming requests that continue.
finish_reason = request.get_finished_reason()
+ is_segment_finished = request.is_finished() and request.resumable
finished = self._handle_stopped_request(request)
if finished:
kv_transfer_params = self._free_request(request)
@@ -418,6 +423,8 @@ class OmniARScheduler(VLLMScheduler):
num_external_computed_tokens=request.num_external_computed_tokens,
routed_experts=routed_experts,
num_nans_in_logits=request.num_nans_in_logits,
+ is_segment_finished=is_segment_finished,
+ new_prompt_len_snapshot=self._new_prompt_len_snapshot.get(req_id, None),
)
)
if self.chunk_transfer_adapter is not None:
@@ -539,6 +546,39 @@ class OmniARScheduler(VLLMScheduler):
return engine_core_outputs
+ def finish_requests(self, request_ids: Any, finished_status: RequestStatus) -> list[tuple[str, int]]:
+ """Handles the finish signal from outside the scheduler.
+
+ For example, the API server can abort a request when the client
+ disconnects.
+
+ If request_ids is None, all requests will be finished.
+
+ Returns:
+ Tuple of (req_id, client_index) for requests that were aborted. Will not
+ include any that were already finished.
+ """
+
+ if self.chunk_transfer_adapter:
+ self.chunk_transfer_adapter.finish_requests(request_ids, finished_status, self.requests)
+
+ return super().finish_requests(request_ids, finished_status)
+
+ def _update_request_as_session(self, session: Request, update: StreamingUpdate) -> None:
+ """
+ Override: Only extend prompt at stage 0, and replace
+ the existing session with the next streaming update at other stages.
+
+ Discards the last sampled output token from the prior input chunk at stage 0.
+ """
+ req_id = session.request_id
+ self._new_prompt_len_snapshot[req_id] = len(update.prompt_token_ids)
+ if self.vllm_config.model_config.stage_id != 0:
+ self._replace_session_with_streaming_update(session, update)
+
+ else:
+ super()._update_request_as_session(session, update)
+
def _free_request(self, request: Request, delay_free_blocks: bool = False) -> dict[str, Any] | None:
# TODO(wzliu)! for offline mode, we should not end process until all data is transferred
"""Mark a request as finished and free its resources."""
@@ -552,6 +592,7 @@ class OmniARScheduler(VLLMScheduler):
self.encoder_cache_manager.free(request)
request_id = request.request_id
self.finished_req_ids.add(request_id)
+ self._new_prompt_len_snapshot.pop(request_id, None)
if self.finished_req_ids_dict is not None:
self.finished_req_ids_dict[request.client_index].add(request_id)
diff --git a/vllm_omni/core/sched/omni_generation_scheduler.py b/vllm_omni/core/sched/omni_generation_scheduler.py
index 1c4356d4f..81f0b7fc2 100644
--- a/vllm_omni/core/sched/omni_generation_scheduler.py
+++ b/vllm_omni/core/sched/omni_generation_scheduler.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import time
from collections import defaultdict
@@ -11,11 +13,16 @@ from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.core.sched.request_queue import create_request_queue
from vllm.v1.core.sched.scheduler import Scheduler as VLLMScheduler
from vllm.v1.core.sched.utils import remove_all
-from vllm.v1.engine import EngineCoreEventType, EngineCoreOutput, EngineCoreOutputs
+from vllm.v1.engine import (
+ EngineCoreEventType,
+ EngineCoreOutput,
+ EngineCoreOutputs,
+)
from vllm.v1.metrics.perf import PerfStats
-from vllm.v1.request import Request, RequestStatus
+from vllm.v1.request import Request, RequestStatus, StreamingUpdate
from vllm.v1.spec_decode.metrics import SpecDecodingStats
+from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
from vllm_omni.core.sched.output import OmniCachedRequestData, OmniNewRequestData
from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import (
OmniChunkTransferAdapter,
@@ -25,7 +32,7 @@ from vllm_omni.outputs import OmniModelRunnerOutput
logger = init_logger(__name__)
-class OmniGenerationScheduler(VLLMScheduler):
+class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
model_config = self.vllm_config.model_config
@@ -324,6 +331,24 @@ class OmniGenerationScheduler(VLLMScheduler):
return scheduler_output
+ def finish_requests(self, request_ids, finished_status: RequestStatus) -> list[tuple[str, int]]:
+ """Handles the finish signal from outside the scheduler.
+
+ For example, the API server can abort a request when the client
+ disconnects.
+
+ If request_ids is None, all requests will be finished.
+
+ Returns:
+ Tuple of (req_id, client_index) for requests that were aborted. Will not
+ include any that were already finished.
+ """
+
+ if self.chunk_transfer_adapter:
+ self.chunk_transfer_adapter.finish_requests(request_ids, finished_status, self.requests)
+
+ return super().finish_requests(request_ids, finished_status)
+
"""
Scheduler for the diffusion model.
This scheduler is modified to stop the request immediately for the diffusion model.
@@ -581,3 +606,11 @@ class OmniGenerationScheduler(VLLMScheduler):
eco.scheduler_stats = stats
return engine_core_outputs
+
+ def _update_request_as_session(self, session: Request, update: StreamingUpdate) -> None:
+ """
+ Override: Just replace the existing session with the next streaming update.
+
+ Do not expend prompt id using update.
+ """
+ self._replace_session_with_streaming_update(session, update)
diff --git a/vllm_omni/core/sched/omni_scheduler_mixin.py b/vllm_omni/core/sched/omni_scheduler_mixin.py
new file mode 100644
index 000000000..36080e63a
--- /dev/null
+++ b/vllm_omni/core/sched/omni_scheduler_mixin.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+from vllm.v1.engine import EngineCoreEventType
+from vllm.v1.request import Request, RequestStatus, StreamingUpdate
+
+
+class OmniSchedulerMixin:
+ """Shared scheduler helpers for omni-specific request handling."""
+
+ def _replace_session_with_streaming_update(
+ self,
+ session: Request,
+ update: StreamingUpdate,
+ ) -> None:
+ """For streaming input: Replace an existing streaming session payload with the latest update."""
+ session._output_token_ids.clear()
+ session._all_token_ids.clear()
+ new_prompt = update.prompt_token_ids or ()
+ session._all_token_ids.extend(new_prompt)
+ session.num_computed_tokens = 0
+ session.prompt_token_ids = update.prompt_token_ids or ()
+ session.additional_information = update.additional_information or None
+ # Update block hashes for the new tokens.
+ session.update_block_hashes()
+ session.num_prompt_tokens = len(session.prompt_token_ids)
+ session.arrival_time = update.arrival_time
+ session.sampling_params = update.sampling_params
+ if session.status == RequestStatus.WAITING_FOR_STREAMING_REQ:
+ self.num_waiting_for_streaming_input -= 1
+ session.status = RequestStatus.WAITING
+
+ if self.log_stats:
+ session.record_event(EngineCoreEventType.QUEUED)
diff --git a/vllm_omni/deploy/cosyvoice3.yaml b/vllm_omni/deploy/cosyvoice3.yaml
new file mode 100644
index 000000000..53e3eb3f3
--- /dev/null
+++ b/vllm_omni/deploy/cosyvoice3.yaml
@@ -0,0 +1,58 @@
+# CosyVoice3 deploy: 2-stage talker → flow-matching code2wav.
+#
+# Default mode is async-chunk streaming through SharedMemoryConnector. Pass
+# ``--no-async-chunk`` to the serve CLI for the legacy sync path (stage 1
+# builds flow input from the full speech-token sequence via ``text2flow``).
+# The shared-memory connector definition and input/output_connectors
+# references stay in the yaml unconditionally; the runtime only activates
+# them when ``async_chunk: true``.
+#
+# enforce_eager=true everywhere:
+# * Stage 0 (talker) — cudagraph not verified on this checkpoint.
+# * Stage 1 (code2wav) — CUDA graphs don't work with dynamic conv shapes.
+async_chunk: true
+dtype: float32
+
+connectors:
+ connector_of_shared_memory:
+ name: SharedMemoryConnector
+ extra:
+ codec_streaming: true
+ connector_get_sleep_s: 0.01
+ connector_get_max_wait_first_chunk: 3000
+ connector_get_max_wait: 300
+ codec_chunk_frames: 25
+ codec_left_context_frames: 25
+ codec_vocab_size: 6561
+
+stages:
+ - stage_id: 0
+ max_num_seqs: 1
+ gpu_memory_utilization: 0.4
+ enforce_eager: true
+ devices: "0"
+ output_connectors:
+ to_stage_1: connector_of_shared_memory
+ default_sampling_params:
+ max_tokens: 2048
+ top_k: 25
+ top_p: 0.8
+ # near-identity repetition penalty forces vLLM to track
+ # output_token_ids for RAS (stop-token logit logsumexp).
+ repetition_penalty: 1.0001
+ disable_hybrid_kv_cache_manager: true
+ mm_processor_cache_gb: 0
+ skip_mm_profiling: true
+
+ - stage_id: 1
+ max_num_seqs: 1
+ gpu_memory_utilization: 0.2
+ enforce_eager: true
+ max_model_len: 32768
+ devices: "0"
+ input_connectors:
+ from_stage_0: connector_of_shared_memory
+ default_sampling_params:
+ max_tokens: 2048
+ disable_hybrid_kv_cache_manager: true
+ skip_mm_profiling: true
diff --git a/vllm_omni/deploy/fish_qwen3_omni.yaml b/vllm_omni/deploy/fish_qwen3_omni.yaml
new file mode 100644
index 000000000..a5bee925b
--- /dev/null
+++ b/vllm_omni/deploy/fish_qwen3_omni.yaml
@@ -0,0 +1,61 @@
+# Fish Speech S2 Pro deploy: async-chunk streaming slow_ar → dac_decoder.
+# Verified on 1× H20.
+# Registry key and filename use the HF ``model_type=fish_qwen3_omni`` for
+# auto-detection; human-readable "fish_speech" stays as the source directory.
+async_chunk: true
+enable_chunked_prefill: false
+
+connectors:
+ connector_of_shared_memory:
+ name: SharedMemoryConnector
+ extra:
+ shm_threshold_bytes: 65536
+ codec_streaming: true
+ connector_get_sleep_s: 0.01
+ connector_get_max_wait_first_chunk: 3000
+ connector_get_max_wait: 300
+ # ~21 Hz codec; 25 frames ≈ 1.16s of audio.
+ codec_chunk_frames: 25
+ codec_left_context_frames: 25
+ initial_codec_chunk_frames: 4
+
+stages:
+ - stage_id: 0
+ max_num_seqs: 4
+ gpu_memory_utilization: 0.6
+ enforce_eager: false
+ async_scheduling: false
+ # vLLM >=0.19 requires max_num_batched_tokens >= max_model_len when
+ # enable_chunked_prefill=false. Bumped from legacy 3072 to match
+ # max_model_len; chunked prefill stays off because the SlowAR decode
+ # loop isn't chunked-prefill-safe.
+ max_num_batched_tokens: 16384
+ max_model_len: 16384
+ devices: "0"
+ output_connectors:
+ to_stage_1: connector_of_shared_memory
+ default_sampling_params:
+ temperature: 0.8
+ top_k: 30
+ top_p: 0.9
+ max_tokens: 2048
+ seed: 42
+ repetition_penalty: 1.0
+
+ - stage_id: 1
+ max_num_seqs: 1
+ gpu_memory_utilization: 0.1
+ enforce_eager: true
+ async_scheduling: false
+ max_num_batched_tokens: 16384
+ max_model_len: 16384
+ devices: "0"
+ input_connectors:
+ from_stage_0: connector_of_shared_memory
+ default_sampling_params:
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
+ max_tokens: 65536
+ seed: 42
+ repetition_penalty: 1.0
diff --git a/vllm_omni/deploy/mimo_audio.yaml b/vllm_omni/deploy/mimo_audio.yaml
new file mode 100644
index 000000000..f5e704f9b
--- /dev/null
+++ b/vllm_omni/deploy/mimo_audio.yaml
@@ -0,0 +1,56 @@
+# MiMo Audio deploy: 2-stage thinker+talker → code2wav.
+#
+# Default mode is async-chunk streaming on a single GPU (both stages on
+# device 0) through SharedMemoryConnector. For the legacy 2-GPU sync
+# pipeline (stage 1 on a second card), pass ``--no-async-chunk
+# --stage-1-devices 1 --stage-1-max-model-len 18192
+# --stage-1-max-num-batched-tokens 18192`` to the serve CLI.
+async_chunk: true
+dtype: bfloat16
+
+connectors:
+ connector_of_shared_memory:
+ name: SharedMemoryConnector
+ extra:
+ shm_threshold_bytes: 365536
+ codec_streaming: true
+ connector_get_sleep_s: 0.001
+ connector_get_max_wait_first_chunk: 3000
+ connector_get_max_wait: 300
+ codec_chunk_frames: 3
+ codec_left_context_frames: 3
+
+stages:
+ - stage_id: 0
+ max_num_seqs: 1
+ gpu_memory_utilization: 0.3
+ enforce_eager: true
+ max_num_batched_tokens: 8192
+ max_model_len: 8192
+ devices: "0"
+ output_connectors:
+ to_stage_1: connector_of_shared_memory
+ default_sampling_params:
+ temperature: 0.6
+ top_p: 0.95
+ top_k: 50
+ max_tokens: 18192
+ seed: 42
+ repetition_penalty: 1.1
+
+ - stage_id: 1
+ max_num_seqs: 1
+ gpu_memory_utilization: 0.2
+ enforce_eager: true
+ async_scheduling: false
+ max_num_batched_tokens: 8192
+ max_model_len: 8192
+ devices: "0"
+ input_connectors:
+ from_stage_0: connector_of_shared_memory
+ default_sampling_params:
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
+ max_tokens: 18192
+ seed: 42
diff --git a/vllm_omni/deploy/qwen2_5_omni.yaml b/vllm_omni/deploy/qwen2_5_omni.yaml
new file mode 100644
index 000000000..41aef0df6
--- /dev/null
+++ b/vllm_omni/deploy/qwen2_5_omni.yaml
@@ -0,0 +1,92 @@
+# Qwen2.5-Omni deploy: CUDA defaults + platform overrides, verified on 2x H100.
+# Stage 2 disables flashinfer autotune because its DiT block never invokes
+# flashinfer; the autotune dummy run OOMs the shared cuda:0 device otherwise.
+#
+# Fields omitted from a stage fall back to StageDeployConfig dataclass
+# defaults (see vllm_omni/config/stage_config.py). For instance, every
+# stage here uses vLLM's default max_num_batched_tokens=32768 because
+# chat-sized prefill comfortably fits; only models with codec prefill
+# (Qwen3-Omni, Qwen3-TTS) need to bump it above 32k.
+#
+# enforce_eager policy across the three deploy YAMLs:
+# * code2wav / generation stages: always true (cudagraph incompatible with
+# the custom generation loop — set explicitly everywhere).
+# * AR stages (thinker, talker): model-dependent. Qwen2.5-Omni runs eager
+# on CUDA (thinker uses custom ops that don't trace cleanly); NPU / XPU
+# platform overrides flip back to false where cudagraph is verified.
+# Qwen3-Omni / Qwen3-TTS AR stages use the default (false = cudagraph on).
+async_chunk: false
+
+stages:
+ - stage_id: 0
+ max_num_seqs: 1
+ gpu_memory_utilization: 0.8
+ enforce_eager: true
+ mm_processor_cache_gb: 0
+ devices: "0"
+ default_sampling_params:
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
+ max_tokens: 2048
+ seed: 42
+ repetition_penalty: 1.1
+
+ - stage_id: 1
+ max_num_seqs: 1
+ gpu_memory_utilization: 0.8
+ enforce_eager: true
+ devices: "1"
+ default_sampling_params:
+ temperature: 0.9
+ top_p: 0.8
+ top_k: 40
+ max_tokens: 2048
+ seed: 42
+ repetition_penalty: 1.05
+
+ - stage_id: 2
+ max_num_seqs: 1
+ gpu_memory_utilization: 0.15
+ enforce_eager: true
+ enable_flashinfer_autotune: false
+ async_scheduling: false
+ devices: "0"
+ default_sampling_params:
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
+ max_tokens: 2048
+ seed: 42
+ repetition_penalty: 1.1
+
+platforms:
+ npu:
+ stages:
+ # NPU has cudagraph support for the thinker, unlike GPU which still
+ # only runs eager.
+ - stage_id: 0
+ enforce_eager: false
+ - stage_id: 2
+ # 3-NPU layout: stage 2 lives on its own card.
+ devices: "2"
+
+ rocm:
+ stages:
+ - stage_id: 2
+ # 3-GPU MI325 layout: stage 2 on a separate card.
+ devices: "2"
+
+ xpu:
+ stages:
+ # Verified on 2x Intel Arc Pro B60. Both AR stages use cudagraphs.
+ - stage_id: 0
+ gpu_memory_utilization: 0.9
+ enforce_eager: false
+ - stage_id: 1
+ gpu_memory_utilization: 0.5
+ enforce_eager: false
+ - stage_id: 2
+ gpu_memory_utilization: 0.3
+ # Stage 2 colocates with stage 1's device on XPU.
+ devices: "1"
diff --git a/vllm_omni/deploy/qwen3_omni_moe.yaml b/vllm_omni/deploy/qwen3_omni_moe.yaml
new file mode 100644
index 000000000..fb8b61621
--- /dev/null
+++ b/vllm_omni/deploy/qwen3_omni_moe.yaml
@@ -0,0 +1,98 @@
+# Qwen3-Omni-MoE production deploy, verified on 2x H100 (stage 0 on cuda:0,
+# stages 1+2 on cuda:1).
+#
+# Fields omitted from a stage fall back to StageDeployConfig defaults (see
+# vllm_omni/config/stage_config.py). Notable implicit defaults for this
+# model:
+# * Stages 0/1 (thinker, talker) do not set max_num_batched_tokens —
+# chat-sized prefill fits in the 32768 default.
+# * Stages 0/1 do not set enforce_eager — cudagraph runs by default
+# (false). Stage 2 (code2wav) sets true because its generation loop
+# is cudagraph-incompatible.
+# * Platform sections flip enforce_eager per-stage where platform
+# cudagraph support differs.
+async_chunk: true
+
+connectors:
+ connector_of_shared_memory:
+ name: SharedMemoryConnector
+ extra:
+ codec_chunk_frames: 25
+ codec_left_context_frames: 25
+
+stages:
+ - stage_id: 0
+ gpu_memory_utilization: 0.9
+ devices: "0"
+ default_sampling_params:
+ temperature: 0.4
+ top_p: 0.9
+ top_k: 1
+ max_tokens: 2048
+ seed: 42
+ repetition_penalty: 1.05
+
+ - stage_id: 1
+ gpu_memory_utilization: 0.6
+ devices: "1"
+ input_connectors:
+ from_stage_0: connector_of_shared_memory
+ default_sampling_params:
+ temperature: 0.9
+ top_k: 50
+ max_tokens: 4096
+ seed: 42
+ repetition_penalty: 1.05
+
+ - stage_id: 2
+ gpu_memory_utilization: 0.1
+ max_num_seqs: 1
+ enforce_eager: true
+ async_scheduling: false
+ max_num_batched_tokens: 51200
+ devices: "1"
+ input_connectors:
+ from_stage_1: connector_of_shared_memory
+ default_sampling_params:
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
+ max_tokens: 65536
+ seed: 42
+ repetition_penalty: 1.1
+
+platforms:
+ npu:
+ stages:
+ - stage_id: 0
+ gpu_memory_utilization: 0.6
+ tensor_parallel_size: 2
+ devices: "0,1"
+ - stage_id: 1
+ gpu_memory_utilization: 0.6
+ enforce_eager: true
+ devices: "2"
+ - stage_id: 2
+ gpu_memory_utilization: 0.3
+ devices: "2"
+
+ rocm:
+ stages:
+ - stage_id: 0
+ enforce_eager: true
+
+ xpu:
+ stages:
+ - stage_id: 0
+ tensor_parallel_size: 4
+ enforce_eager: true
+ max_cudagraph_capture_size: 0
+ devices: "0,1,2,3"
+ - stage_id: 1
+ enforce_eager: true
+ max_cudagraph_capture_size: 0
+ devices: "4"
+ - stage_id: 2
+ gpu_memory_utilization: 0.3
+ max_cudagraph_capture_size: 0
+ devices: "4"
diff --git a/vllm_omni/deploy/qwen3_tts.yaml b/vllm_omni/deploy/qwen3_tts.yaml
new file mode 100644
index 000000000..5948007a9
--- /dev/null
+++ b/vllm_omni/deploy/qwen3_tts.yaml
@@ -0,0 +1,86 @@
+# Qwen3-TTS deploy: talker → code2wav via shared-memory chunk streaming.
+# Verified on 1x H100.
+#
+# Fields omitted from a stage fall back to StageDeployConfig defaults (see
+# vllm_omni/config/stage_config.py). Notable choices for this model:
+# * Stage 0 (talker) sets max_num_batched_tokens=512 for async-chunk
+# latency tuning (not correctness) — small per-step batches keep
+# first-chunk latency low.
+# * Stage 1 (code2wav) sets max_num_batched_tokens=65536 for correctness:
+# codec prefill length (Q * num_frames) exceeds the 32k default.
+# * Stage 0 does not set enforce_eager — talker runs cudagraph by default.
+# Stage 1 sets true because its codec generation loop is not
+# cudagraph-compatible. NPU platform flips stage 0 to true where
+# cudagraph is not yet verified.
+async_chunk: true
+
+connectors:
+ connector_of_shared_memory:
+ name: SharedMemoryConnector
+ extra:
+ shm_threshold_bytes: 65536
+ codec_streaming: true
+ connector_get_sleep_s: 0.01
+ connector_get_max_wait_first_chunk: 3000
+ connector_get_max_wait: 300
+ # Must match the decoder sliding attention window.
+ codec_chunk_frames: 25
+ codec_left_context_frames: 72
+
+stages:
+ - stage_id: 0
+ max_num_seqs: 10
+ gpu_memory_utilization: 0.3
+ async_scheduling: true
+ max_num_batched_tokens: 512
+ max_model_len: 4096
+ devices: "0"
+ output_connectors:
+ to_stage_1: connector_of_shared_memory
+ default_sampling_params:
+ temperature: 0.9
+ top_k: 50
+ max_tokens: 4096
+ seed: 42
+ repetition_penalty: 1.05
+ subtalker_sampling_params:
+ do_sample: true
+ temperature: 0.9
+ top_k: 50
+ top_p: 1.0
+
+ - stage_id: 1
+ max_num_seqs: 1
+ gpu_memory_utilization: 0.3
+ enforce_eager: true
+ async_scheduling: true
+ # Must be divisible by num_code_groups and cover (left_context + chunk).
+ # Prefill length is Q * num_frames (e.g. 16 * 2148 = 34368); keep
+ # headroom past 32k.
+ max_num_batched_tokens: 65536
+ # async_chunk appends windows per step; max_model_len must cover the
+ # accumulated flat codec stream.
+ max_model_len: 65536
+ devices: "0"
+ input_connectors:
+ from_stage_0: connector_of_shared_memory
+ default_sampling_params:
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
+ max_tokens: 65536
+ seed: 42
+ repetition_penalty: 1.0
+
+platforms:
+ npu:
+ stages:
+ # NPU does not yet support async-scheduling for TTS, and the
+ # talker fits at max_num_seqs=1 only.
+ - stage_id: 0
+ max_num_seqs: 1
+ enforce_eager: true
+ async_scheduling: false
+ - stage_id: 1
+ gpu_memory_utilization: 0.2
+ async_scheduling: false
diff --git a/vllm_omni/deploy/voxcpm2.yaml b/vllm_omni/deploy/voxcpm2.yaml
new file mode 100644
index 000000000..b49906710
--- /dev/null
+++ b/vllm_omni/deploy/voxcpm2.yaml
@@ -0,0 +1,29 @@
+# VoxCPM2 deploy: single-stage AR pipeline with per-request state batching.
+# Verified on 1x H20 141GB.
+#
+# Fields omitted from a stage fall back to StageDeployConfig defaults (see
+# vllm_omni/config/stage_config.py). Notable choices:
+# * enforce_eager=true because the talker's KV-cache save/restore loop is
+# not cudagraph-compatible (captured separately via voxcpm2_talker's own
+# _CapturedGraph path).
+# * max_num_seqs=4 matches the legacy max_batch_size=4 contract: concurrent
+# requests are supported via per-request StaticKVCache save/restore.
+async_chunk: false
+dtype: bfloat16
+
+stages:
+ - stage_id: 0
+ max_num_seqs: 4
+ gpu_memory_utilization: 0.9
+ enforce_eager: true
+ async_scheduling: true
+ max_num_batched_tokens: 4096
+ max_model_len: 4096
+ devices: "0"
+ default_sampling_params:
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
+ max_tokens: 4096
+ seed: 42
+ repetition_penalty: 1.0
diff --git a/vllm_omni/deploy/voxtral_tts.yaml b/vllm_omni/deploy/voxtral_tts.yaml
new file mode 100644
index 000000000..b0899a399
--- /dev/null
+++ b/vllm_omni/deploy/voxtral_tts.yaml
@@ -0,0 +1,65 @@
+# Voxtral TTS deploy: async-chunk streaming generator → audio tokenizer.
+# Verified on 1× H20.
+#
+# Mistral tokenizer/config/load flags are declared per-stage here because
+# DeployConfig does not yet expose them as pipeline-wide fields. They must
+# match on every stage so the engine args stay consistent across workers.
+async_chunk: true
+
+connectors:
+ connector_of_shared_memory:
+ name: SharedMemoryConnector
+ extra:
+ shm_threshold_bytes: 65536
+ codec_streaming: true
+ connector_get_sleep_s: 0.01
+ connector_get_max_wait_first_chunk: 3000
+ connector_get_max_wait: 300
+ codec_chunk_frames: 25
+ codec_chunk_frames_at_begin: 5
+ codec_left_context_frames: 25
+
+stages:
+ - stage_id: 0
+ max_num_seqs: 32
+ gpu_memory_utilization: 0.8
+ enforce_eager: false
+ async_scheduling: true
+ max_model_len: 4096
+ devices: "0"
+ output_connectors:
+ to_stage_1: connector_of_shared_memory
+ default_sampling_params:
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
+ max_tokens: 2048
+ seed: 42
+ repetition_penalty: 1.1
+ tokenizer_mode: mistral
+ config_format: mistral
+ load_format: mistral
+ skip_mm_profiling: true
+ enable_chunked_prefill: false
+
+ - stage_id: 1
+ max_num_seqs: 32
+ gpu_memory_utilization: 0.1
+ enforce_eager: true
+ async_scheduling: false
+ max_num_batched_tokens: 65536
+ max_model_len: 65536
+ devices: "0"
+ input_connectors:
+ from_stage_0: connector_of_shared_memory
+ default_sampling_params:
+ temperature: 0.9
+ top_p: 0.8
+ top_k: 40
+ max_tokens: 2048
+ seed: 42
+ repetition_penalty: 1.05
+ tokenizer_mode: mistral
+ config_format: mistral
+ load_format: mistral
+ skip_mm_profiling: true
diff --git a/vllm_omni/diffusion/attention/parallel/ulysses.py b/vllm_omni/diffusion/attention/parallel/ulysses.py
index 5d860b335..326b5d456 100644
--- a/vllm_omni/diffusion/attention/parallel/ulysses.py
+++ b/vllm_omni/diffusion/attention/parallel/ulysses.py
@@ -414,10 +414,6 @@ class UlyssesParallelAttention:
def post_attention(self, attn_output: torch.Tensor, ctx: ParallelAttentionContext | None) -> torch.Tensor:
assert isinstance(ctx, _UlyssesCtx), f"Unexpected ctx type: {type(ctx)!r}"
- # If we have joint tensors (Text), they were Head-Sliced.
- # The main sequence (Image) was Sequence-Sliced.
- # attn_output contains [Joint_Sliced | Image_Sliced] (if strategy='front').
-
if ctx.joint_len > 0:
joint_len = ctx.joint_len
diff --git a/vllm_omni/diffusion/cache/cache_dit_backend.py b/vllm_omni/diffusion/cache/cache_dit_backend.py
index e9f79da4f..d5397dd16 100644
--- a/vllm_omni/diffusion/cache/cache_dit_backend.py
+++ b/vllm_omni/diffusion/cache/cache_dit_backend.py
@@ -281,6 +281,7 @@ def enable_cache_for_longcat_image(pipeline: Any, cache_config: Any) -> Callable
],
forward_pattern=[ForwardPattern.Pattern_1, ForwardPattern.Pattern_1],
params_modifiers=[modifier],
+ has_separate_cfg=True,
)
),
cache_config=db_cache_config,
@@ -632,6 +633,7 @@ def enable_cache_for_ltx2(pipeline: Any, cache_config: Any) -> Callable[[int], N
forward_pattern=ForwardPattern.Pattern_0,
# Treat audio_hidden_states as encoder_hidden_states in Pattern_0
check_forward_pattern=False,
+ has_separate_cfg=True,
),
cache_config=db_cache_config,
calibrator_config=calibrator_config,
@@ -1168,36 +1170,6 @@ def enable_cache_for_bagel(pipeline: Any, cache_config: Any) -> Callable[[int],
return refresh_cache_context
-def enable_cache_for_glm_image(pipeline: Any, cache_config: Any) -> Callable[[int], None]:
- """Enable cache-dit for GLM-Image pipeline.
-
- GLM-Image processes prompt and image by calling the transformer before the
- denoising loop. When an input image is provided (editing mode), the cache must
- be force-refreshed after the preprocessing step so stale hidden states are
- discarded. Set force_refresh_step_hint = 1 for editing, None for text-to-image.
- """
- db_cache_config = _build_db_cache_config(cache_config)
-
- calibrator_config = None
- if cache_config.enable_taylorseer:
- calibrator_config = TaylorSeerCalibratorConfig(taylorseer_order=cache_config.taylorseer_order)
- logger.info(f"TaylorSeer enabled with order={cache_config.taylorseer_order}")
-
- logger.info(
- f"Enabling cache-dit on GLM-Image transformer: "
- f"Fn={db_cache_config.Fn_compute_blocks}, "
- f"Bn={db_cache_config.Bn_compute_blocks}, "
- f"W={db_cache_config.max_warmup_steps}, "
- f"force_refresh_step_hint={db_cache_config.force_refresh_step_hint}, "
- )
-
- cache_dit.enable_cache(
- pipeline.transformer,
- cache_config=db_cache_config,
- calibrator_config=calibrator_config,
- )
-
-
def enable_cache_for_flux2(pipeline: Any, cache_config: Any) -> Callable[[int], None]:
"""Enable cache-dit for Flux.2-dev pipeline.
@@ -1272,6 +1244,82 @@ def enable_cache_for_flux2(pipeline: Any, cache_config: Any) -> Callable[[int],
return refresh_cache_context
+def enable_cache_for_glm_image(pipeline: Any, cache_config: Any) -> Callable[[int], None]:
+ """Enable cache-dit for GlmImage pipeline.
+
+ Args:
+ pipeline: The GlmImage pipeline instance.
+ cache_config: DiffusionCacheConfig instance with cache configuration.
+ Returns:
+ A refresh function that can be called with a new ``num_inference_steps``
+ to update the cache context for the pipeline.
+ """
+ # Build DBCacheConfig for transformer
+ db_cache_config = _build_db_cache_config(cache_config)
+
+ calibrator = None
+ if cache_config.enable_taylorseer:
+ taylorseer_order = cache_config.taylorseer_order
+ calibrator = TaylorSeerCalibratorConfig(taylorseer_order=taylorseer_order)
+ logger.info(f"TaylorSeer enabled with order={taylorseer_order}")
+
+ # Build ParamsModifier for transformer
+ modifier = ParamsModifier(
+ cache_config=db_cache_config,
+ calibrator_config=calibrator,
+ )
+
+ logger.info(
+ f"Enabling cache-dit on GlmImage transformer with BlockAdapter: "
+ f"Fn={db_cache_config.Fn_compute_blocks}, "
+ f"Bn={db_cache_config.Bn_compute_blocks}, "
+ f"W={db_cache_config.max_warmup_steps}, "
+ )
+
+ # Enable cache-dit using BlockAdapter for transformer
+ # Note: We don't use patch_functor here because it's designed for diffusers' GlmImage,
+ # and our vllm-omni implementation has a different forward signature.
+ # We use ForwardPattern.Pattern_0 because our block returns (hidden_states, encoder_hidden_states)
+ cache_dit.enable_cache(
+ (
+ BlockAdapter(
+ transformer=pipeline.transformer,
+ blocks=pipeline.transformer.transformer_blocks,
+ forward_pattern=ForwardPattern.Pattern_0,
+ params_modifiers=[modifier],
+ patch_functor=None,
+ has_separate_cfg=True,
+ )
+ ),
+ cache_config=db_cache_config,
+ )
+
+ def refresh_cache_context(pipeline: Any, num_inference_steps: int, verbose: bool = True) -> None:
+ """Refresh cache context for the transformer with new num_inference_steps.
+
+ Args:
+ pipeline: The GlmImage pipeline instance.
+ num_inference_steps: New number of inference steps.
+ """
+ if cache_config.scm_steps_mask_policy is None:
+ cache_dit.refresh_context(pipeline.transformer, num_inference_steps=num_inference_steps, verbose=verbose)
+ else:
+ cache_dit.refresh_context(
+ pipeline.transformer,
+ cache_config=DBCacheConfig().reset(
+ num_inference_steps=num_inference_steps,
+ steps_computation_mask=cache_dit.steps_mask(
+ mask_policy=cache_config.scm_steps_mask_policy,
+ total_steps=num_inference_steps,
+ ),
+ steps_computation_policy=cache_config.scm_steps_policy,
+ ),
+ verbose=verbose,
+ )
+
+ return refresh_cache_context
+
+
# Register custom cache-dit enablers after function definitions
CUSTOM_DIT_ENABLERS.update(
{
diff --git a/vllm_omni/diffusion/cache/teacache/coefficient_estimator.py b/vllm_omni/diffusion/cache/teacache/coefficient_estimator.py
index baec21c27..38c805c28 100644
--- a/vllm_omni/diffusion/cache/teacache/coefficient_estimator.py
+++ b/vllm_omni/diffusion/cache/teacache/coefficient_estimator.py
@@ -1,20 +1,18 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+import os
from typing import Any
import numpy as np
import torch
from vllm.config import LoadConfig
-from vllm.utils.torch_utils import set_default_torch_dtype
+from vllm.transformers_utils.config import get_hf_file_to_dict
from vllm_omni.diffusion.cache.teacache.extractors import get_extractor
-from vllm_omni.diffusion.data import OmniDiffusionConfig
+from vllm_omni.diffusion.data import OmniDiffusionConfig, TransformerConfig
from vllm_omni.diffusion.hooks import HookRegistry, ModelHook
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
-from vllm_omni.diffusion.models.bagel.pipeline_bagel import BagelPipeline
-from vllm_omni.diffusion.models.flux2.pipeline_flux2 import Flux2Pipeline
-from vllm_omni.diffusion.models.stable_audio.pipeline_stable_audio import StableAudioPipeline
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -36,6 +34,7 @@ class DataCollectionHook(ModelHook):
def new_forward(self, module: torch.nn.Module, *args: Any, **kwargs: Any) -> Any:
ctx = self.extractor_fn(module, *args, **kwargs)
+ # NOTE: We upcast to float32 to also handle bfloat16.
modulated_input_cpu = ctx.modulated_input.detach().float().cpu().numpy()
outputs = ctx.run_transformer_blocks()
@@ -54,19 +53,53 @@ class DataCollectionHook(ModelHook):
return list(self.current_trajectory)
-class BagelAdapter:
- """Adapter for Bagel model."""
+class DefaultAdapter:
+ """Default adapter for standard diffusers pipelines."""
+
+ model_class_name = None
+ uses_tf_config = True
+
+ @classmethod
+ def load_pipeline(cls, model_path: str, device: str, dtype: torch.dtype) -> Any:
+ if cls.model_class_name is None:
+ raise ValueError("Adapter doesn't have a set class name.")
+
+ od_config = OmniDiffusionConfig.from_kwargs(
+ model_class_name=cls.model_class_name,
+ model=model_path,
+ dtype=dtype,
+ )
+
+ if cls.uses_tf_config:
+ # TODO (Alex): Refactor to handle tf_model_config in OmniDiffusionConfig
+ # instead of OmniDiffusion and remove the manual population here
+ tf_config_dict = get_hf_file_to_dict(
+ os.path.join("transformer", "config.json"),
+ od_config.model,
+ )
+ od_config.tf_model_config = TransformerConfig.from_dict(tf_config_dict)
+
+ loader = DiffusersPipelineLoader(LoadConfig(), od_config=od_config)
+ # load_model will handle dtypes / device placement, put in .eval() mode
+ return loader.load_model(od_config=od_config, load_device=device)
@staticmethod
- def load_pipeline(model_path: str, device: str = "cuda", dtype: torch.dtype = torch.bfloat16) -> BagelPipeline:
- od_config = OmniDiffusionConfig.from_kwargs(model=model_path, dtype=dtype)
- od_config.model_class_name = "BagelPipeline"
+ def get_transformer(pipeline: Any) -> tuple[Any, str]:
+ return pipeline.transformer, pipeline.transformer.__class__.__name__
- pipeline = BagelPipeline(od_config=od_config)
- loader = DiffusersPipelineLoader(LoadConfig())
- loader.load_weights(pipeline)
- pipeline.to(device)
- return pipeline
+ @staticmethod
+ def install_hook(transformer: Any, hook: DataCollectionHook) -> None:
+ registry = HookRegistry.get_or_create(transformer)
+ registry.register_hook(hook._HOOK_NAME, hook)
+
+
+class BagelAdapter(DefaultAdapter):
+ """Adapter for Bagel model."""
+
+ model_class_name = "BagelPipeline"
+ # Skip the hack for loading the tf model config,
+ # because bagel doesn't use it.
+ uses_tf_config = False
@staticmethod
def get_transformer(pipeline: Any) -> tuple[Any, str]:
@@ -78,78 +111,32 @@ class BagelAdapter:
registry.register_hook(hook._HOOK_NAME, hook)
-class StableAudioAdapter:
- """Adapter for Stable Audio Open 1.0 coefficient estimation."""
-
- @staticmethod
- def load_pipeline(model_path: str, device: str = "cuda", dtype: torch.dtype = torch.float16) -> Any:
- od_config = OmniDiffusionConfig.from_kwargs(model=model_path, dtype=dtype)
-
- # Strictly necessary because we bypass loader.load_model()
- with set_default_torch_dtype(dtype):
- pipeline = StableAudioPipeline(od_config=od_config)
-
- loader = DiffusersPipelineLoader(LoadConfig())
- loader.load_weights(pipeline)
- pipeline.to(device)
- return pipeline
-
- @staticmethod
- def get_transformer(pipeline: Any) -> tuple[Any, str]:
- return pipeline.transformer, "StableAudioDiTModel"
-
- @staticmethod
- def install_hook(transformer: Any, hook: DataCollectionHook) -> None:
- registry = HookRegistry.get_or_create(transformer)
- registry.register_hook(hook._HOOK_NAME, hook)
-
-
-class Flux2Adapter:
+class Flux2Adapter(DefaultAdapter):
"""Adapter for Flux2 model coefficient estimation."""
- @staticmethod
- def load_pipeline(model_path: str, device: str = "cuda", dtype: torch.dtype = torch.bfloat16) -> Flux2Pipeline:
- """Load Flux2 pipeline for coefficient estimation."""
- od_config = OmniDiffusionConfig.from_kwargs(model=model_path, dtype=dtype)
- od_config.model_class_name = "Flux2Pipeline"
-
- pipeline = Flux2Pipeline(od_config=od_config)
- loader = DiffusersPipelineLoader(LoadConfig())
- loader.load_weights(pipeline)
- pipeline.to(device)
- return pipeline
-
- @staticmethod
- def get_transformer(pipeline: Any) -> tuple[Any, str]:
- return pipeline.transformer, pipeline.transformer.__class__.__name__
-
- @staticmethod
- def install_hook(transformer: Any, hook: DataCollectionHook) -> None:
- registry = HookRegistry.get_or_create(transformer)
- registry.register_hook(hook._HOOK_NAME, hook)
+ model_class_name = "Flux2Pipeline"
-class DefaultAdapter:
- """Default adapter for standard diffusers pipelines."""
+class LongCatAdapter(DefaultAdapter):
+ """Adapter for LongCat Image - NOTE: currently this model needs the vLLM
+ context to be correctly configured to actually run the estimation, since it
+ uses vLLM norm layers etc.
+ """
- @staticmethod
- def load_pipeline(model_path: str, device: str, dtype: torch.dtype) -> Any:
- raise NotImplementedError("DefaultAdapter.load_pipeline not implemented")
+ model_class_name = "LongCatImagePipeline"
- @staticmethod
- def get_transformer(pipeline: Any) -> tuple[Any, str]:
- return pipeline.transformer, pipeline.transformer.__class__.__name__
- @staticmethod
- def install_hook(transformer: Any, hook: DataCollectionHook) -> None:
- registry = HookRegistry.get_or_create(transformer)
- registry.register_hook(hook._HOOK_NAME, hook)
+class StableAudioAdapter(DefaultAdapter):
+ """Adapter for Stable Audio Open 1.0 coefficient estimation."""
+
+ model_class_name = "StableAudioPipeline"
_MODEL_ADAPTERS: dict[str, type] = {
"Bagel": BagelAdapter,
"StableAudio": StableAudioAdapter,
"Flux2": Flux2Adapter,
+ "LongCat": LongCatAdapter,
}
_EPSILON = 1e-6
@@ -196,7 +183,6 @@ class TeaCacheCoefficientEstimator:
device: str = "cuda",
dtype: torch.dtype = torch.bfloat16,
):
- # Add validation here ⬇️
if model_type not in _MODEL_ADAPTERS:
available_types = list(_MODEL_ADAPTERS.keys())
raise ValueError(
@@ -205,7 +191,7 @@ class TeaCacheCoefficientEstimator:
f"To add support for a new model, add an entry to _MODEL_ADAPTERS."
)
- adapter = _MODEL_ADAPTERS.get(model_type, DefaultAdapter)
+ adapter = _MODEL_ADAPTERS[model_type]
self.pipeline = adapter.load_pipeline(model_path, device, dtype)
self.transformer, self.transformer_type = adapter.get_transformer(self.pipeline)
self.hook = DataCollectionHook(self.transformer_type)
diff --git a/vllm_omni/diffusion/cache/teacache/config.py b/vllm_omni/diffusion/cache/teacache/config.py
index ecf3bfc1d..7efdd418e 100644
--- a/vllm_omni/diffusion/cache/teacache/config.py
+++ b/vllm_omni/diffusion/cache/teacache/config.py
@@ -73,6 +73,8 @@ _MODEL_COEFFICIENTS = {
3.20000000e00,
-2.00000000e-02,
],
+ # LongCat Image transformer coefficients
+ "LongCatImageTransformer2DModel": [652.5980, -424.1615, 84.5526, -4.5923, 0.1694],
}
diff --git a/vllm_omni/diffusion/cache/teacache/extractors.py b/vllm_omni/diffusion/cache/teacache/extractors.py
index 3d247e318..d0da0d9df 100644
--- a/vllm_omni/diffusion/cache/teacache/extractors.py
+++ b/vllm_omni/diffusion/cache/teacache/extractors.py
@@ -19,10 +19,13 @@ from typing import Any
import torch
import torch.nn as nn
+from vllm.logger import init_logger
from vllm_omni.diffusion.forward_context import get_forward_context
from vllm_omni.platforms import current_omni_platform
+logger = init_logger(__name__)
+
@dataclass
class CacheContext:
@@ -222,7 +225,8 @@ def extract_qwen_context(
block = module.transformer_blocks[0]
img_mod_params = block.img_mod(temb)
img_mod1, _ = img_mod_params.chunk(2, dim=-1)
- img_modulated, _ = block.img_norm1(hidden_states, img_mod1)
+ img_scale1, img_shift1, _ = block._modulate(img_mod1)
+ img_modulated = block.img_norm1(hidden_states, img_scale1, img_shift1)
# ============================================================================
# DEFINE TRANSFORMER EXECUTION (Qwen-specific)
@@ -722,6 +726,105 @@ def extract_flux2_klein_context(
)
+def extract_longcat_context(
+ module: nn.Module, # LongCatImageTransformer2DModel
+ hidden_states,
+ timestep,
+ guidance,
+ encoder_hidden_states,
+ txt_ids,
+ img_ids,
+ **kwargs,
+) -> CacheContext:
+ """Extract the cache context for LongCat Image.
+
+ Similar to other extractors, this is currently the only code needed
+ for TeaCache support for LongCat image, and encapsulates preprocessing,
+ modulated input extraction, transformer execution, and postprocessing
+ logic.
+
+ Args & kawrgs are identical to the inputs to LongCat Image's forward.
+
+ Returns:
+ CacheContext with all information needed for generic caching
+ """
+ # TODO (Alex) - Refactor TeaCache extractors to more tightly integrate with .forward
+ from diffusers.models.modeling_outputs import Transformer2DModelOutput
+
+ # 1. Model specific preprocessing
+ fwd_context = get_forward_context()
+ sp_size = module.parallel_config.sequence_parallel_size
+ if sp_size is not None and sp_size > 1:
+ # NOTE: For now, we set this to False on the forward context
+ # to be consistent with LongCat Image's current behavior when
+ # TeaCache is enabled. We do not need to reset it in post process
+ # since we should never split text embed in sp for this model.
+ fwd_context.split_text_embed_in_sp = False
+
+ hidden_states = module.x_embedder(hidden_states)
+
+ timestep = timestep.to(hidden_states.dtype) * 1000
+
+ temb = module.time_embed(timestep, hidden_states.dtype)
+ encoder_hidden_states = module.context_embedder(encoder_hidden_states)
+
+ # Compute RoPE embeddings via rope_preparer module
+ # _sp_plan will automatically shard img_cos/img_sin (outputs 2, 3)
+ # txt_cos/txt_sin (outputs 0, 1) remain replicated for dual-stream attention
+ txt_cos, txt_sin, img_cos, img_sin = module.rope_preparer(txt_ids, img_ids)
+
+ # Reconstruct image_rotary_emb with chunked values
+ # Final shape: (txt_seq_len + img_seq_len // SP, head_dim)
+ image_rotary_emb = (
+ torch.cat([txt_cos, img_cos], dim=0),
+ torch.cat([txt_sin, img_sin], dim=0),
+ )
+
+ # 2. Extract the modulated output from the first mm-DiT block
+ first_block = module.transformer_blocks[0]
+ img_modulated = first_block.norm1(hidden_states, emb=temb)[0]
+
+ # 3. Define the transformer execution
+ def run_transformer_blocks():
+ """Execute all Longcat transformer blocks."""
+ h = hidden_states
+ e = encoder_hidden_states
+ for block in module.transformer_blocks:
+ e, h = block(
+ hidden_states=h,
+ encoder_hidden_states=e,
+ temb=temb,
+ image_rotary_emb=image_rotary_emb,
+ )
+
+ for block in module.single_transformer_blocks:
+ e, h = block(
+ hidden_states=h,
+ encoder_hidden_states=e,
+ temb=temb,
+ image_rotary_emb=image_rotary_emb,
+ )
+ # Hook expects hidden states to be first
+ return (h, e)
+
+ # 4. Postprocessing
+ def postprocess(h):
+ """Apply Longcat-specific output postprocessing."""
+ h = module.norm_out(h, temb)
+ output = module.proj_out(h)
+ return Transformer2DModelOutput(sample=output)
+
+ # 5. Return the CacheContext
+ return CacheContext(
+ modulated_input=img_modulated,
+ hidden_states=hidden_states,
+ encoder_hidden_states=encoder_hidden_states,
+ temb=temb,
+ run_transformer_blocks=run_transformer_blocks,
+ postprocess=postprocess,
+ )
+
+
def extract_stable_audio_context(
module: nn.Module,
hidden_states: torch.Tensor,
@@ -979,6 +1082,7 @@ EXTRACTOR_REGISTRY: dict[str, Callable] = {
"Flux2Klein": extract_flux2_klein_context,
"StableAudioDiTModel": extract_stable_audio_context,
"Flux2Transformer2DModel": extract_flux2_context,
+ "LongCatImageTransformer2DModel": extract_longcat_context,
# Future models:
# "FluxTransformer2DModel": extract_flux_context,
# "CogVideoXTransformer3DModel": extract_cogvideox_context,
diff --git a/vllm_omni/diffusion/data.py b/vllm_omni/diffusion/data.py
index 56a891aa5..012c0130c 100644
--- a/vllm_omni/diffusion/data.py
+++ b/vllm_omni/diffusion/data.py
@@ -18,6 +18,7 @@ from vllm.model_executor.layers.quantization.base_config import (
QuantizationConfig,
)
+from vllm_omni.diffusion.model_metadata import get_diffusion_model_metadata
from vllm_omni.diffusion.utils.network_utils import is_port_available
from vllm_omni.quantization import build_quant_config
@@ -352,6 +353,8 @@ class DiffusionCacheConfig:
@dataclass
class OmniDiffusionConfig:
# Model and path configuration (for convenience)
+ stage_id: int = 0
+
model: str | None = None
model_class_name: str | None = None
@@ -481,8 +484,10 @@ class OmniDiffusionConfig:
# Scheduler flow_shift for Wan2.2 (12.0 for 480p, 5.0 for 720p)
flow_shift: float | None = None
- # support multi images input
+ # Support multi-image inputs and expose any model-specific request limit
+ # through a generic config field so serving code stays model-agnostic.
supports_multimodal_inputs: bool = False
+ max_multimodal_image_inputs: int | None = None
log_level: str = "info"
@@ -505,6 +510,8 @@ class OmniDiffusionConfig:
# Step mode settings
step_execution: bool = False
+ # sleep mode
+ enable_sleep_mode: bool = False
# Maximum number of sequences to generate in a batch
max_num_seqs: int = 1
@@ -664,7 +671,54 @@ class OmniDiffusionConfig:
)
def update_multimodal_support(self) -> None:
- self.supports_multimodal_inputs = self.model_class_name in {"QwenImageEditPlusPipeline"}
+ # Resolve serving-visible multimodal behavior from shared metadata
+ # instead of importing concrete pipeline modules into the config layer.
+ metadata = get_diffusion_model_metadata(self.model_class_name)
+ self.supports_multimodal_inputs = metadata.supports_multimodal_inputs
+ self.max_multimodal_image_inputs = metadata.max_multimodal_image_inputs
+
+ def enrich_config(self) -> None:
+ """Load model metadata from HuggingFace and populate config fields.
+
+ Diffusers-style models expose ``model_index.json`` with ``_class_name``.
+ Non-diffusers models (e.g. Bagel, NextStep) only have ``config.json``,
+ so we fall back to reading that and mapping model_type manually.
+ """
+ from vllm.transformers_utils.config import get_hf_file_to_dict
+
+ try:
+ config_dict = get_hf_file_to_dict("model_index.json", self.model)
+ if config_dict is not None:
+ if self.model_class_name is None:
+ self.model_class_name = config_dict.get("_class_name", None)
+ self.update_multimodal_support()
+
+ tf_config_dict = get_hf_file_to_dict("transformer/config.json", self.model)
+ self.tf_model_config = TransformerConfig.from_dict(tf_config_dict)
+ else:
+ raise FileNotFoundError("model_index.json not found")
+ except (AttributeError, OSError, ValueError, FileNotFoundError):
+ cfg = get_hf_file_to_dict("config.json", self.model)
+ if cfg is None:
+ raise ValueError(f"Could not find config.json or model_index.json for model {self.model}")
+
+ self.tf_model_config = TransformerConfig.from_dict(cfg)
+ model_type = cfg.get("model_type")
+ architectures = cfg.get("architectures") or []
+
+ if model_type == "bagel" or "BagelForConditionalGeneration" in architectures:
+ self.model_class_name = "BagelPipeline"
+ self.tf_model_config = TransformerConfig()
+ self.update_multimodal_support()
+ elif model_type == "nextstep":
+ if self.model_class_name is None:
+ self.model_class_name = "NextStep11Pipeline"
+ self.tf_model_config = TransformerConfig()
+ self.update_multimodal_support()
+ elif architectures and len(architectures) == 1:
+ self.model_class_name = architectures[0]
+ else:
+ raise
@classmethod
def from_kwargs(cls, **kwargs: Any) -> "OmniDiffusionConfig":
@@ -747,5 +801,43 @@ class AttentionBackendEnum(enum.Enum):
return self.name.lower()
+@dataclass
+class OmniACK:
+ """
+ Handshake payload from Workers to Orchestrator.
+ """
+
+ task_id: str
+ status: str
+ stage_id: int | None = None
+ rank: int | None = None
+ freed_bytes: int = 0
+ metadata: dict[str, Any] = field(default_factory=dict)
+ """
+ Additional telemetry such as:
+ - max_contiguous_block: for fragmentation analysis.
+ - cuda_graph_recalled: boolean if graphs were successfully destroyed/rebuilt.
+ - latency_ms: time taken for the D2H/H2D transfer.
+ """
+ error_msg: str | None = None
+
+
+@dataclass
+class OmniSleepTask:
+ """Structured sleep instruction."""
+
+ task_id: str
+ level: int = 2
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class OmniWakeTask:
+ """Structured wake-up instruction."""
+
+ task_id: str
+ tags: list[str] | None = None
+
+
# Special message broadcast via scheduler queues to signal worker shutdown.
SHUTDOWN_MESSAGE = {"type": "shutdown"}
diff --git a/vllm_omni/diffusion/diffusion_engine.py b/vllm_omni/diffusion/diffusion_engine.py
index 52a8f3854..abaf59895 100644
--- a/vllm_omni/diffusion/diffusion_engine.py
+++ b/vllm_omni/diffusion/diffusion_engine.py
@@ -3,6 +3,7 @@
from __future__ import annotations
+import inspect
import queue
import threading
import time
@@ -13,6 +14,7 @@ import numpy as np
import PIL.Image
import torch
from vllm.logger import init_logger
+from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.diffusion.data import (
DiffusionOutput,
@@ -78,6 +80,12 @@ class DiffusionEngine:
self.post_process_func = get_diffusion_post_process_func(od_config)
self.pre_process_func = get_diffusion_pre_process_func(od_config)
+ # Cache whether the model-specific postprocess accepts request-level
+ # sampling params so step() can support both legacy and extended hooks.
+ self._post_process_accepts_sampling_params = bool(
+ self.post_process_func is not None
+ and "sampling_params" in inspect.signature(self.post_process_func).parameters
+ )
executor_class = DiffusionExecutor.get_class(od_config)
self.executor = executor_class(od_config)
@@ -115,7 +123,7 @@ class DiffusionEngine:
if output.aborted:
raise DiffusionRequestAbortedError(output.abort_message or "Diffusion request aborted.")
if output.error:
- raise RuntimeError(f"{output.error}")
+ raise RuntimeError(output.error)
logger.info("Generation completed successfully.")
if output.output is None:
@@ -143,12 +151,22 @@ class DiffusionEngine:
output_data = output_data.cpu()
postprocess_start_time = time.perf_counter()
- outputs = self.post_process_func(output_data) if self.post_process_func is not None else output_data
+ if self.post_process_func is not None:
+ # Some video pipelines need request-level controls during
+ # postprocess (for example worker-side frame interpolation).
+ if self._post_process_accepts_sampling_params:
+ outputs = self.post_process_func(output_data, sampling_params=request.sampling_params)
+ else:
+ outputs = self.post_process_func(output_data)
+ else:
+ outputs = output_data
audio_payload = None
+ custom_output = output.custom_output or {}
model_audio_sample_rate = None
model_fps = None
if isinstance(outputs, dict):
audio_payload = outputs.get("audio")
+ custom_output.update(outputs.get("custom_output") or {})
model_audio_sample_rate = outputs.get("audio_sample_rate")
model_fps = outputs.get("fps")
outputs = outputs.get("video", outputs)
@@ -225,7 +243,7 @@ class DiffusionEngine:
trajectory_timesteps=output.trajectory_timesteps,
trajectory_log_probs=output.trajectory_log_probs,
trajectory_decoded=output.trajectory_decoded,
- custom_output=output.custom_output or {},
+ custom_output=custom_output,
multimodal_output=mm_output,
stage_durations=output.stage_durations,
peak_memory_mb=output.peak_memory_mb,
@@ -295,7 +313,7 @@ class DiffusionEngine:
trajectory_timesteps=output.trajectory_timesteps,
trajectory_log_probs=output.trajectory_log_probs,
trajectory_decoded=output.trajectory_decoded,
- custom_output=output.custom_output or {},
+ custom_output=custom_output,
multimodal_output=mm_output,
stage_durations=output.stage_durations,
peak_memory_mb=output.peak_memory_mb,
@@ -341,6 +359,8 @@ class DiffusionEngine:
sched_req_id = sched_output.scheduled_req_ids[0]
try:
runner_output = self.execute_fn(sched_output)
+ except EngineDeadError:
+ raise
except Exception as exc:
logger.error("Execution failed for diffusion request %s", sched_req_id, exc_info=True)
runner_output = RunnerOutput(
diff --git a/vllm_omni/diffusion/distributed/autoencoders/distributed_vae_executor.py b/vllm_omni/diffusion/distributed/autoencoders/distributed_vae_executor.py
index ad60d164a..209f65625 100644
--- a/vllm_omni/diffusion/distributed/autoencoders/distributed_vae_executor.py
+++ b/vllm_omni/diffusion/distributed/autoencoders/distributed_vae_executor.py
@@ -54,9 +54,9 @@ class DistributedVaeExecutor:
self.parallel_size = parallel_size
def gather_tensors(self, tensor: torch.Tensor):
- gather_list = [torch.empty_like(tensor) for _ in range(self.world_size)] if self.rank == 0 else None
- dist.gather(tensor, gather_list=gather_list, dst=0, group=self.group)
- return gather_list
+ gather_list = [torch.empty_like(tensor) for _ in range(self.world_size)]
+ dist.all_gather(gather_list, tensor, group=self.group)
+ return gather_list if self.rank == 0 else None
def broadcast_tensor(self, tensor: torch.Tensor):
dist.broadcast(tensor, src=0, group=self.group)
diff --git a/vllm_omni/diffusion/distributed/group_coordinator.py b/vllm_omni/diffusion/distributed/group_coordinator.py
index 8ab38f2a6..5294e6c9e 100644
--- a/vllm_omni/diffusion/distributed/group_coordinator.py
+++ b/vllm_omni/diffusion/distributed/group_coordinator.py
@@ -104,6 +104,7 @@ class GroupCoordinator:
self.local_rank = local_rank
self.device_group = None
self.cpu_group = None
+ self.shm_broadcaster = None
for ranks in group_ranks:
device_group = torch.distributed.new_group(ranks, backend=torch_distributed_backend)
@@ -316,7 +317,7 @@ class GroupCoordinator:
assert dst < self.world_size, f"Invalid dst rank ({dst})"
- assert dst != self.rank, "Invalid destination rank. Destination rank is the same as the current rank."
+ assert dst != self.rank_in_group, "Invalid destination rank. Destination rank is the same as the current rank."
# Serialize object to tensor and get the size as well
object_tensor = torch.frombuffer(pickle.dumps(obj), dtype=torch.uint8)
@@ -338,7 +339,7 @@ class GroupCoordinator:
assert src < self.world_size, f"Invalid src rank ({src})"
- assert src != self.rank, "Invalid source rank. Source rank is the same as the current rank."
+ assert src != self.rank_in_group, "Invalid source rank. Source rank is the same as the current rank."
size_tensor = torch.empty(1, dtype=torch.long, device="cpu")
diff --git a/vllm_omni/diffusion/executor/multiproc_executor.py b/vllm_omni/diffusion/executor/multiproc_executor.py
index e55a464fb..dcb35cfde 100644
--- a/vllm_omni/diffusion/executor/multiproc_executor.py
+++ b/vllm_omni/diffusion/executor/multiproc_executor.py
@@ -1,14 +1,18 @@
from __future__ import annotations
import multiprocessing as mp
+import multiprocessing.connection
+import threading
import time
import weakref
+from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import zmq
from vllm.distributed.device_communicators.shm_broadcast import MessageQueue
from vllm.logger import init_logger
+from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.diffusion.data import SHUTDOWN_MESSAGE, DiffusionOutput
from vllm_omni.diffusion.executor.abstract import DiffusionExecutor
@@ -22,6 +26,8 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
+_DEQUEUE_TIMEOUT_S = 5.0
+
@dataclass
class BackgroundResources:
@@ -36,10 +42,14 @@ class BackgroundResources:
def __call__(self):
"""Clean up background resources."""
+ if hasattr(self, "wake_events") and self.wake_events:
+ for ev in self.wake_events:
+ ev.set()
+
if self.broadcast_mq is not None:
try:
for _ in range(self.num_workers):
- self.broadcast_mq.enqueue(SHUTDOWN_MESSAGE)
+ self.broadcast_mq.enqueue(SHUTDOWN_MESSAGE, timeout=1.0)
self.broadcast_mq = None
self.result_mq = None
@@ -63,13 +73,17 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
def _init_executor(self) -> None:
self._processes: list[mp.Process] = []
self._closed = False
+ self.is_failed = False
+ self._failure_callbacks: list[Callable[[], None]] = []
num_workers = self.od_config.num_gpus
+ self.wake_events = [mp.Event() for _ in range(num_workers)]
+
self._broadcast_mq = self._init_broadcast_queue(num_workers)
broadcast_handle = self._broadcast_mq.export_handle()
# Launch workers
- processes, result_handle = self._launch_workers(broadcast_handle)
+ processes, result_handle = self._launch_workers(broadcast_handle, self.wake_events)
self._result_mq = self._init_result_queue(result_handle)
self._processes = processes
@@ -81,6 +95,8 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
)
self._finalizer = weakref.finalize(self, self.resources)
+ self.start_worker_monitor()
+
def _init_broadcast_queue(self, num_workers: int) -> MessageQueue:
return MessageQueue(
n_reader=num_workers,
@@ -100,7 +116,24 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
if self._result_mq is None:
raise RuntimeError("Result queue not initialized")
- def _launch_workers(self, broadcast_handle):
+ def _dequeue_one_with_failure_polling(self, deadline: float | None, method: str) -> Any:
+ """Block until one result message, polling ``is_failed`` between chunk timeouts."""
+ while True:
+ if deadline is None:
+ chunk_timeout = _DEQUEUE_TIMEOUT_S
+ else:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError(f"RPC call to {method} timed out.")
+ chunk_timeout = min(_DEQUEUE_TIMEOUT_S, remaining)
+ try:
+ return self._result_mq.dequeue(timeout=chunk_timeout)
+ except (TimeoutError, zmq.error.Again):
+ if self.is_failed:
+ raise EngineDeadError()
+ continue
+
+ def _launch_workers(self, broadcast_handle, wake_events):
od_config = self.od_config
logger.info("Starting server...")
@@ -126,6 +159,7 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
od_config,
writer,
broadcast_handle,
+ wake_events[i],
worker_extension_cls,
custom_pipeline_args,
),
@@ -164,6 +198,49 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
return processes, result_handle
+ def start_worker_monitor(self) -> None:
+ # Monitors worker process liveness. If any die unexpectedly,
+ # logs an error, shuts down the executor and invokes the failure
+ # callback to inform the engine.
+ sentinels = [p.sentinel for p in self._processes]
+ if not sentinels:
+ return
+
+ def _monitor() -> None:
+ try:
+ finished = multiprocessing.connection.wait(sentinels)
+ except OSError:
+ return
+
+ if self._closed:
+ return
+
+ dead = [p.name for p in self._processes if p.sentinel in finished]
+ if dead:
+ logger.error(
+ "Diffusion worker(s) died unexpectedly: %s",
+ dead,
+ )
+ self.is_failed = True
+
+ self.shutdown()
+
+ for cb in self._failure_callbacks:
+ try:
+ cb()
+ except Exception:
+ logger.exception("failure_callback raised")
+
+ t = threading.Thread(target=_monitor, daemon=True, name="diffusion-worker-monitor")
+ t.start()
+
+ def register_failure_callback(
+ self,
+ callback: Callable[[], None],
+ ) -> None:
+ """Register a callback invoked when a worker process dies."""
+ self._failure_callbacks.append(callback)
+
def add_req(self, request: OmniDiffusionRequest) -> DiffusionOutput:
self._ensure_open()
rpc_request = {
@@ -286,27 +363,21 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
responses = []
for _ in range(num_responses):
- dequeue_timeout = None if deadline is None else max(0, deadline - time.monotonic())
+ response = self._dequeue_one_with_failure_polling(deadline, method)
+
try:
- response = self._result_mq.dequeue(timeout=dequeue_timeout)
+ unpack_diffusion_output_shm(response)
+ except Exception as e:
+ logger.warning("SHM unpack failed (data may already be inline): %s", e)
- try:
- unpack_diffusion_output_shm(response)
- except Exception as e:
- logger.warning("SHM unpack failed (data may already be inline): %s", e)
+ # Check if response indicates an error
+ if isinstance(response, dict) and response.get("status") == "error":
+ raise RuntimeError(
+ f"Worker failed with error '{response.get('error')}', "
+ "please check the stack trace above for the root cause"
+ )
- # Check if response indicates an error
- if isinstance(response, dict) and response.get("status") == "error":
- raise RuntimeError(
- f"Worker failed with error '{response.get('error')}', "
- "please check the stack trace above for the root cause"
- )
-
- responses.append(response)
- except zmq.error.Again as e:
- raise TimeoutError(f"RPC call to {method} timed out.") from e
- except TimeoutError as e:
- raise TimeoutError(f"RPC call to {method} timed out.") from e
+ responses.append(response)
return responses[0] if unique_reply_rank is not None else responses
except Exception as e:
@@ -314,10 +385,13 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
raise
def check_health(self) -> None:
- # Simple check if processes are alive
+ self._ensure_open()
+ if self.is_failed:
+ raise EngineDeadError()
for p in self._processes:
if not p.is_alive():
- raise RuntimeError(f"Worker process {p.name} is dead")
+ self.is_failed = True
+ raise EngineDeadError(f"Worker process {p.name} is dead")
def shutdown(self) -> None:
self._closed = True
diff --git a/vllm_omni/diffusion/inline_stage_diffusion_client.py b/vllm_omni/diffusion/inline_stage_diffusion_client.py
new file mode 100644
index 000000000..a33a3e956
--- /dev/null
+++ b/vllm_omni/diffusion/inline_stage_diffusion_client.py
@@ -0,0 +1,348 @@
+"""Inline Stage Diffusion Client for vLLM-Omni multi-stage runtime.
+
+Runs DiffusionEngine in a ThreadPoolExecutor inside the Orchestrator process
+instead of spawning a separate StageDiffusionProc subprocess, eliminating ZMQ
+IPC overhead. Used when there is only a single diffusion stage.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from concurrent.futures import ThreadPoolExecutor
+from typing import TYPE_CHECKING, Any
+
+import torch
+from PIL import Image
+from vllm.logger import init_logger
+
+from vllm_omni.diffusion.data import DiffusionRequestAbortedError
+from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
+from vllm_omni.diffusion.request import OmniDiffusionRequest
+from vllm_omni.engine.stage_init_utils import StageMetadata
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+from vllm_omni.outputs import OmniRequestOutput
+
+if TYPE_CHECKING:
+ from vllm_omni.diffusion.data import OmniDiffusionConfig
+ from vllm_omni.inputs.data import OmniPromptType
+
+logger = init_logger(__name__)
+
+
+class InlineStageDiffusionClient:
+ """Runs DiffusionEngine in a thread executor inside the Orchestrator."""
+
+ stage_type: str = "diffusion"
+
+ def __init__(
+ self,
+ model: str,
+ od_config: OmniDiffusionConfig,
+ metadata: StageMetadata,
+ batch_size: int = 1,
+ ) -> None:
+ self.model = model
+ self.od_config = od_config
+ self.stage_id = metadata.stage_id
+ self.final_output = metadata.final_output
+ self.final_output_type = metadata.final_output_type
+ self.default_sampling_params = metadata.default_sampling_params
+ self.custom_process_input_func = metadata.custom_process_input_func
+ self.engine_input_source = metadata.engine_input_source
+ self.batch_size = batch_size
+
+ self._enrich_config()
+ self._engine = DiffusionEngine.make_engine(self.od_config)
+ self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="inline-diffusion")
+
+ self._output_queue: asyncio.Queue[OmniRequestOutput] = asyncio.Queue()
+ self._tasks: dict[str, asyncio.Task] = {}
+ self._shutting_down = False
+
+ logger.info(
+ "[InlineStageDiffusionClient] Stage-%s initialized inline (batch_size=%d)",
+ self.stage_id,
+ self.batch_size,
+ )
+
+ def _enrich_config(self) -> None:
+ """Load model metadata from HuggingFace and populate od_config fields."""
+ self.od_config.enrich_config()
+
+ # ------------------------------------------------------------------
+ # Request processing
+ # ------------------------------------------------------------------
+
+ async def add_request_async(
+ self,
+ request_id: str,
+ prompt: OmniPromptType,
+ sampling_params: OmniDiffusionSamplingParams,
+ kv_sender_info: dict[int, dict[str, Any]] | None = None,
+ ) -> None:
+ task = asyncio.create_task(
+ self._dispatch_request(
+ request_id,
+ prompt,
+ sampling_params,
+ kv_sender_info,
+ )
+ )
+ self._tasks[request_id] = task
+
+ async def _dispatch_request(
+ self,
+ request_id: str,
+ prompt: Any,
+ sampling_params: OmniDiffusionSamplingParams,
+ kv_sender_info: dict[str, Any] | None = None,
+ ) -> None:
+ try:
+ request = OmniDiffusionRequest(
+ prompts=[prompt],
+ sampling_params=sampling_params,
+ request_ids=[request_id],
+ request_id=request_id,
+ kv_sender_info=kv_sender_info,
+ )
+
+ loop = asyncio.get_running_loop()
+ results = await loop.run_in_executor(self._executor, self._engine.step, request)
+ result = results[0]
+ if not result.request_id:
+ result.request_id = request_id
+
+ self._output_queue.put_nowait(result)
+ except DiffusionRequestAbortedError as e:
+ logger.info("request_id: %s aborted: %s", request_id, str(e))
+ except Exception as e:
+ logger.exception("Diffusion request %s failed: %s", request_id, e)
+ error_output = OmniRequestOutput.from_diffusion(
+ request_id=request_id,
+ images=[],
+ )
+ error_output.error = str(e)
+ self._output_queue.put_nowait(error_output)
+ finally:
+ self._tasks.pop(request_id, None)
+
+ async def add_batch_request_async(
+ self,
+ request_id: str,
+ prompts: list[OmniPromptType],
+ sampling_params: OmniDiffusionSamplingParams,
+ kv_sender_info: dict[int, dict[str, Any]] | None = None,
+ ) -> None:
+ task = asyncio.create_task(
+ self._dispatch_batch(
+ request_id,
+ prompts,
+ sampling_params,
+ kv_sender_info,
+ )
+ )
+ self._tasks[request_id] = task
+
+ async def _dispatch_batch(
+ self,
+ request_id: str,
+ prompts: list[Any],
+ sampling_params: OmniDiffusionSamplingParams,
+ kv_sender_info: dict[str, Any] | None = None,
+ ) -> None:
+ try:
+ request = OmniDiffusionRequest(
+ prompts=prompts,
+ sampling_params=sampling_params,
+ request_ids=[f"{request_id}-{i}" for i in range(len(prompts))],
+ request_id=request_id,
+ kv_sender_info=kv_sender_info,
+ )
+
+ loop = asyncio.get_running_loop()
+ results = await loop.run_in_executor(self._executor, self._engine.step, request)
+
+ all_images: list = []
+ merged_mm: dict[str, Any] = {}
+ merged_metrics: dict[str, Any] = {}
+ merged_durations: dict[str, float] = {}
+ merged_custom: dict[str, Any] = {}
+ peak_mem = 0.0
+ latents = None
+ trajectory_latents: list[torch.Tensor] | None = None
+ trajectory_timesteps: list[torch.Tensor] | None = None
+ trajectory_log_probs: torch.Tensor | None = None
+ trajectory_decoded: list[Image.Image] | None = None
+ final_output_type = "image"
+
+ for r in results:
+ all_images.extend(r.images)
+ merged_mm.update(r._multimodal_output)
+ merged_metrics.update(r.metrics)
+ merged_durations.update(r.stage_durations)
+ merged_custom.update(r._custom_output)
+ peak_mem = max(peak_mem, r.peak_memory_mb)
+ if latents is None and r.latents is not None:
+ latents = r.latents
+ if trajectory_latents is None:
+ trajectory_latents = r.trajectory_latents
+ if trajectory_timesteps is None:
+ trajectory_timesteps = r.trajectory_timesteps
+ if trajectory_log_probs is None:
+ trajectory_log_probs = r.trajectory_log_probs
+ if trajectory_decoded is None:
+ trajectory_decoded = r.trajectory_decoded
+ if r.final_output_type != "image":
+ final_output_type = r.final_output_type
+
+ result = OmniRequestOutput.from_diffusion(
+ request_id=request_id,
+ images=all_images,
+ prompt=prompts[0] if len(prompts) == 1 else None,
+ metrics=merged_metrics,
+ latents=latents,
+ trajectory_latents=trajectory_latents,
+ trajectory_timesteps=trajectory_timesteps,
+ trajectory_log_probs=trajectory_log_probs,
+ trajectory_decoded=trajectory_decoded,
+ custom_output=merged_custom or None,
+ multimodal_output=merged_mm or None,
+ final_output_type=final_output_type,
+ stage_durations=merged_durations,
+ peak_memory_mb=peak_mem,
+ )
+
+ self._output_queue.put_nowait(result)
+ except DiffusionRequestAbortedError as e:
+ logger.info("request_id: %s aborted: %s", request_id, str(e))
+ except Exception as e:
+ logger.exception("Batch diffusion request %s failed: %s", request_id, e)
+ error_output = OmniRequestOutput.from_diffusion(
+ request_id=request_id,
+ images=[],
+ )
+ error_output.error = str(e)
+ self._output_queue.put_nowait(error_output)
+ finally:
+ self._tasks.pop(request_id, None)
+
+ def get_diffusion_output_nowait(self) -> OmniRequestOutput | None:
+ try:
+ return self._output_queue.get_nowait()
+ except asyncio.QueueEmpty:
+ return None
+
+ async def abort_requests_async(self, request_ids: list[str]) -> None:
+ for rid in request_ids:
+ task = self._tasks.pop(rid, None)
+ if task:
+ task.cancel()
+ self._engine.abort(rid)
+
+ async def collective_rpc_async(
+ self,
+ method: str,
+ timeout: float | None = None,
+ args: tuple[Any, ...] = (),
+ kwargs: dict[str, Any] | None = None,
+ ) -> Any:
+ loop = asyncio.get_running_loop()
+
+ if method == "profile":
+ is_start = args[0] if args else True
+ profile_prefix = args[1] if len(args) > 1 else None
+ if is_start and profile_prefix is None:
+ profile_prefix = f"stage_{self.stage_id}_diffusion_{int(time.time())}"
+ return await loop.run_in_executor(
+ self._executor,
+ self._engine.profile,
+ is_start,
+ profile_prefix,
+ )
+
+ kwargs = kwargs or {}
+
+ # LoRA methods
+ if method == "add_lora":
+ lora_request = args[0] if args else kwargs.get("lora_request")
+ results = await loop.run_in_executor(
+ self._executor,
+ self._engine.collective_rpc,
+ "add_lora",
+ timeout,
+ (),
+ {"lora_request": lora_request},
+ None,
+ )
+ return all(results) if isinstance(results, list) else results
+
+ if method == "remove_lora":
+ results = await loop.run_in_executor(
+ self._executor,
+ self._engine.collective_rpc,
+ "remove_lora",
+ timeout,
+ args,
+ kwargs,
+ None,
+ )
+ return all(results) if isinstance(results, list) else results
+
+ if method == "list_loras":
+ results = await loop.run_in_executor(
+ self._executor,
+ self._engine.collective_rpc,
+ "list_loras",
+ timeout,
+ (),
+ {},
+ None,
+ )
+ if not isinstance(results, list):
+ return results or []
+ merged: set[int] = set()
+ for part in results:
+ merged.update(part or [])
+ return sorted(merged)
+
+ if method == "pin_lora":
+ lora_id = args[0] if args else kwargs.get("adapter_id")
+ results = await loop.run_in_executor(
+ self._executor,
+ self._engine.collective_rpc,
+ "pin_lora",
+ timeout,
+ (),
+ {"adapter_id": lora_id},
+ None,
+ )
+ return all(results) if isinstance(results, list) else results
+
+ return await loop.run_in_executor(
+ self._executor,
+ self._engine.collective_rpc,
+ method,
+ timeout,
+ args,
+ kwargs,
+ None,
+ )
+
+ def shutdown(self) -> None:
+ self._shutting_down = True
+
+ # Cancel all pending tasks
+ for task in self._tasks.values():
+ task.cancel()
+
+ try:
+ # Cancel queued futures and wait for the running one to complete deterministically
+ self._executor.shutdown(wait=True, cancel_futures=True)
+ except Exception:
+ pass
+
+ try:
+ self._engine.close()
+ except Exception:
+ pass
diff --git a/vllm_omni/diffusion/layers/adalayernorm.py b/vllm_omni/diffusion/layers/adalayernorm.py
index 35f63e2fc..d147bdcfe 100644
--- a/vllm_omni/diffusion/layers/adalayernorm.py
+++ b/vllm_omni/diffusion/layers/adalayernorm.py
@@ -7,6 +7,7 @@ from vllm.logger import init_logger
from vllm.model_executor.layers.linear import ReplicatedLinear
from vllm_omni.diffusion.layers.custom_op import CustomOp
+from vllm_omni.diffusion.layers.norm import LayerNorm
if TYPE_CHECKING:
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
@@ -27,107 +28,63 @@ class AdaLayerNorm(CustomOp):
self.eps = eps
self.elementwise_affine = elementwise_affine
self.hidden_size = hidden_size
- self.layernorm = nn.LayerNorm(self.hidden_size, elementwise_affine=self.elementwise_affine, eps=self.eps)
-
- def preprocess(
- self,
- mod_params: torch.Tensor,
- index: torch.Tensor = None,
- ) -> torch.Tensor:
- # shift: b d, scale: b d, gate: b d
- shift, scale, gate = mod_params.chunk(3, dim=-1)
-
- if index is not None:
- # Assuming mod_params batch dim is 2*actual_batch (chunked into 2 parts)
- # So shift, scale, gate have shape [2*actual_batch, d]
- actual_batch = shift.size(0) // 2
- shift_0, shift_1 = shift[:actual_batch], shift[actual_batch:] # each: [actual_batch, d]
- scale_0, scale_1 = scale[:actual_batch], scale[actual_batch:]
- gate_0, gate_1 = gate[:actual_batch], gate[actual_batch:]
-
- # index: [b, l] where b is actual batch size
- # Expand to [b, l, 1] to match feature dimension
- index_expanded = index.unsqueeze(-1) # [b, l, 1]
-
- # Expand chunks to [b, 1, d] then broadcast to [b, l, d]
- shift_0_exp = shift_0.unsqueeze(1) # [b, 1, d]
- shift_1_exp = shift_1.unsqueeze(1) # [b, 1, d]
- scale_0_exp = scale_0.unsqueeze(1)
- scale_1_exp = scale_1.unsqueeze(1)
- gate_0_exp = gate_0.unsqueeze(1)
- gate_1_exp = gate_1.unsqueeze(1)
-
- # Use torch.where to select based on index
- shift_result = torch.where(index_expanded == 0, shift_0_exp, shift_1_exp)
- scale_result = torch.where(index_expanded == 0, scale_0_exp, scale_1_exp)
- gate_result = torch.where(index_expanded == 0, gate_0_exp, gate_1_exp)
- else:
- shift_result = shift.unsqueeze(1)
- scale_result = scale.unsqueeze(1)
- gate_result = gate.unsqueeze(1)
-
- return shift_result, scale_result, gate_result
+ self.layernorm = LayerNorm(self.hidden_size, elementwise_affine=self.elementwise_affine, eps=self.eps)
def forward_cuda(
self,
x: torch.Tensor,
- mod_params: torch.Tensor,
- index: torch.Tensor = None,
+ scale: torch.Tensor,
+ shift: torch.Tensor,
) -> torch.Tensor:
- return self.forward_native(x, mod_params, index)
+ return self.forward_native(x, scale, shift)
def forward_hip(
self,
x: torch.Tensor,
- mod_params: torch.Tensor,
- index: torch.Tensor = None,
+ scale: torch.Tensor,
+ shift: torch.Tensor,
) -> torch.Tensor:
- return self.forward_native(x, mod_params, index)
+ return self.forward_native(x, scale, shift)
def forward_npu(
self,
x: torch.Tensor,
- mod_params: torch.Tensor,
- index: torch.Tensor = None,
+ scale: torch.Tensor,
+ shift: torch.Tensor,
) -> torch.Tensor:
- shift_result, scale_result, gate_result = self.preprocess(mod_params, index)
-
if _HAS_MINDIESD:
try:
from mindiesd import layernorm_scale_shift
- output = layernorm_scale_shift(self.layernorm, x, scale_result, shift_result, fused=True)
+ output = layernorm_scale_shift(self.layernorm, x, scale, shift, fused=True)
- return output, gate_result
+ return output
except ImportError as e:
logger.warning_once(f"mindiesd import failed, falling back to torch_npu: {e}")
import torch_npu
output = (
- torch_npu.npu_layer_norm_eval(x, normalized_shape=[self.hidden_size], eps=self.eps) * (1 + scale_result)
- + shift_result
+ torch_npu.npu_layer_norm_eval(x, normalized_shape=[self.hidden_size], eps=self.eps) * (1 + scale) + shift
)
- return output, gate_result
+ return output
def forward_xpu(
self,
x: torch.Tensor,
- mod_params: torch.Tensor,
- index: torch.Tensor = None,
+ scale: torch.Tensor,
+ shift: torch.Tensor,
) -> torch.Tensor:
- return self.forward_native(x, mod_params, index)
+ return self.forward_native(x, scale, shift)
def forward_native(
self,
x: torch.Tensor,
- mod_params: torch.Tensor,
- index: torch.Tensor = None,
+ scale: torch.Tensor,
+ shift: torch.Tensor,
) -> torch.Tensor:
- shift_result, scale_result, gate_result = self.preprocess(mod_params, index)
-
- return self.layernorm(x) * (1 + scale_result) + shift_result, gate_result
+ return self.layernorm(x) * (1 + scale) + shift
class AdaLayerNormZero(nn.Module):
diff --git a/vllm_omni/diffusion/layers/norm.py b/vllm_omni/diffusion/layers/norm.py
new file mode 100644
index 000000000..6096ad7c3
--- /dev/null
+++ b/vllm_omni/diffusion/layers/norm.py
@@ -0,0 +1,110 @@
+from importlib.util import find_spec
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+from vllm.logger import init_logger
+
+from vllm_omni.diffusion.layers.custom_op import CustomOp
+
+logger = init_logger(__name__)
+
+_HAS_MINDIESD = find_spec("mindiesd") is not None
+
+
+class LayerNorm(nn.LayerNorm, CustomOp):
+ """
+ LayerNorm implementation that inherits from both ``nn.LayerNorm`` and ``CustomOp``.
+ NPU:
+ Uses ``mindiesd.fast_layernorm(self, x)`` when MindIE-SD is installed.
+ CUDA / HIP / XPU / native:
+ Falls back to FP32 nn.LayerNorm implementation.
+ """
+
+ def __init__(self, dim: int, eps: float = 1e-6, elementwise_affine: bool = True):
+ super().__init__(normalized_shape=dim, eps=eps, elementwise_affine=elementwise_affine)
+ # CustomOp.__init__ cannot be called here because it would re-run
+ # nn.Module initialization and clear LayerNorm parameters.
+ self._forward_method = CustomOp.dispatch_forward(self)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ return self._forward_method(x)
+
+ def forward_cuda(self, x: torch.Tensor) -> torch.Tensor:
+ return self.forward_native(x)
+
+ def forward_hip(self, x: torch.Tensor) -> torch.Tensor:
+ return self.forward_native(x)
+
+ def forward_xpu(self, x: torch.Tensor) -> torch.Tensor:
+ return self.forward_native(x)
+
+ def forward_npu(self, x: torch.Tensor) -> torch.Tensor:
+ if _HAS_MINDIESD:
+ try:
+ from mindiesd import fast_layernorm
+
+ return fast_layernorm(self, x)
+ except ImportError as e:
+ logger.warning_once(
+ "mindiesd.fast_layernorm import failed, falling back to FP32 layer_norm: %s",
+ e,
+ )
+
+ return self.forward_native(x)
+
+ def forward_native(self, x: torch.Tensor) -> torch.Tensor:
+ origin_dtype = x.dtype
+ return F.layer_norm(
+ x.float(),
+ self.normalized_shape,
+ self.weight.float() if self.weight is not None else None,
+ self.bias.float() if self.bias is not None else None,
+ self.eps,
+ ).to(origin_dtype)
+
+
+class RMSNorm(CustomOp):
+ def __init__(self, hidden_size: int, eps: float = 1e-6) -> None:
+ super().__init__()
+ self.weight = nn.Parameter(torch.ones(hidden_size))
+ self.variance_epsilon = eps
+
+ def forward_cuda(
+ self,
+ x: torch.Tensor,
+ ) -> torch.Tensor:
+ return self.forward_native(x)
+
+ def forward_hip(
+ self,
+ x: torch.Tensor,
+ ) -> torch.Tensor:
+ return self.forward_native(x)
+
+ def forward_npu(
+ self,
+ x: torch.Tensor,
+ ) -> torch.Tensor:
+ import torch_npu
+
+ output = torch_npu.npu_rms_norm(x, gamma=self.weight, epsilon=self.variance_epsilon)[0]
+
+ return output
+
+ def forward_xpu(
+ self,
+ x: torch.Tensor,
+ ) -> torch.Tensor:
+ return self.forward_native(x)
+
+ def forward_native(
+ self,
+ x: torch.Tensor,
+ ) -> torch.Tensor:
+ input_dtype = x.dtype
+ x = x.to(torch.float32)
+ variance = x.pow(2).mean(-1, keepdim=True)
+ out = x * torch.rsqrt(variance + self.variance_epsilon)
+ out = self.weight.to(torch.float32) * out
+ return out.to(input_dtype)
diff --git a/vllm_omni/diffusion/model_metadata.py b/vllm_omni/diffusion/model_metadata.py
new file mode 100644
index 000000000..ec133e738
--- /dev/null
+++ b/vllm_omni/diffusion/model_metadata.py
@@ -0,0 +1,31 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class DiffusionModelMetadata:
+ # Keep serving-facing capability metadata in a lightweight shared module so
+ # config/model plumbing can read it without importing concrete pipelines.
+ supports_multimodal_inputs: bool = False
+ max_multimodal_image_inputs: int | None = None
+
+
+QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES = 4
+
+
+_DIFFUSION_MODEL_METADATA: dict[str, DiffusionModelMetadata] = {
+ "QwenImageEditPlusPipeline": DiffusionModelMetadata(
+ supports_multimodal_inputs=True,
+ max_multimodal_image_inputs=QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES,
+ ),
+}
+
+
+def get_diffusion_model_metadata(model_class_name: str | None) -> DiffusionModelMetadata:
+ # Unknown models fall back to "no special multimodal capabilities" so new
+ # pipelines do not accidentally inherit limits meant for other models.
+ if model_class_name is None:
+ return DiffusionModelMetadata()
+ return _DIFFUSION_MODEL_METADATA.get(model_class_name, DiffusionModelMetadata())
diff --git a/vllm_omni/diffusion/models/bagel/pipeline_bagel.py b/vllm_omni/diffusion/models/bagel/pipeline_bagel.py
index 72e53e7f4..90baf5f67 100644
--- a/vllm_omni/diffusion/models/bagel/pipeline_bagel.py
+++ b/vllm_omni/diffusion/models/bagel/pipeline_bagel.py
@@ -365,35 +365,65 @@ class BagelPipeline(nn.Module, DiffusionPipelineProfilerMixin):
if req.sampling_params.kv_metadata and "image_shape" in req.sampling_params.kv_metadata:
image_shape = tuple(req.sampling_params.kv_metadata["image_shape"])
- cfg_text_kv = getattr(req.sampling_params, "cfg_text_past_key_values", None)
+ branch_kvs = getattr(req.sampling_params, "cfg_branch_past_key_values", None) or {}
+ branch_metadata = getattr(req.sampling_params, "cfg_branch_kv_metadata", None) or {}
+ active_branch = getattr(req.sampling_params, "cfg_active_branch", None)
+ branch_roles = getattr(req.sampling_params, "cfg_branch_roles", None) or list(branch_kvs.keys())
+
+ cfg_text_kv = getattr(req.sampling_params, "cfg_text_past_key_values", None) or branch_kvs.get("cfg_text")
+ cfg_text_metadata = getattr(req.sampling_params, "cfg_text_kv_metadata", None) or branch_metadata.get(
+ "cfg_text"
+ )
+ cfg_img_kv = getattr(req.sampling_params, "cfg_img_past_key_values", None) or branch_kvs.get("cfg_img")
+ cfg_img_metadata = getattr(req.sampling_params, "cfg_img_kv_metadata", None) or branch_metadata.get(
+ "cfg_img"
+ )
+
+ cfg_parallel_contract = (
+ active_branch is not None or bool(branch_roles) or cfg_text_kv is not None or cfg_img_kv is not None
+ )
+ if cfg_parallel_contract:
+ logger.info(
+ "CFG enabled with injected branch KV context roles=%s active=%s",
+ branch_roles,
+ active_branch,
+ )
+
if cfg_text_kv is not None:
- logger.info("CFG enabled with multi-KV: using injected cfg_text KV Cache")
cfg_text_seq_len = cfg_text_kv.key_cache[0].shape[0]
cfg_text_context["past_key_values"] = cfg_text_kv
cfg_text_context["kv_lens"] = [cfg_text_seq_len]
- cfg_text_metadata = getattr(req.sampling_params, "cfg_text_kv_metadata", None)
if cfg_text_metadata and "ropes" in cfg_text_metadata:
cfg_text_context["ropes"] = cfg_text_metadata["ropes"]
else:
cfg_text_context["ropes"] = [cfg_text_seq_len]
+ else:
+ # No cfg_text companion received. For text2img this is the
+ # expected path: original BAGEL uses an empty KV cache (0
+ # tokens) as the text-unconditional branch. Keep the default
+ # empty NaiveCache in cfg_text_context and preserve the
+ # original cfg_text_scale so CFG still applies.
+ pass
- cfg_img_kv = getattr(req.sampling_params, "cfg_img_past_key_values", None) or injected_kv
+ if cfg_img_kv is None:
+ # text2img multi-stage: cfg_img reuses gen KV (positive prompt,
+ # no image), mirroring forward_cache_update_text on cfg_img_context
+ # in the single-stage path.
+ cfg_img_seq_len = injected_kv.key_cache[0].shape[0]
+ cfg_img_context["past_key_values"] = injected_kv
+ cfg_img_context["kv_lens"] = [cfg_img_seq_len]
+ if req.sampling_params.kv_metadata and "ropes" in req.sampling_params.kv_metadata:
+ cfg_img_context["ropes"] = req.sampling_params.kv_metadata["ropes"]
+ else:
+ cfg_img_context["ropes"] = [cfg_img_seq_len]
+ else:
cfg_img_seq_len = cfg_img_kv.key_cache[0].shape[0]
cfg_img_context["past_key_values"] = cfg_img_kv
cfg_img_context["kv_lens"] = [cfg_img_seq_len]
- cfg_img_metadata = getattr(req.sampling_params, "cfg_img_kv_metadata", None)
if cfg_img_metadata and "ropes" in cfg_img_metadata:
cfg_img_context["ropes"] = cfg_img_metadata["ropes"]
else:
cfg_img_context["ropes"] = [cfg_img_seq_len]
- else:
- logger.warning("CFG is disabled: only single KV cache available")
- gen_params = BagelGenParams(
- num_timesteps=gen_params.num_timesteps,
- timestep_shift=gen_params.timestep_shift,
- cfg_text_scale=1.0,
- cfg_img_scale=1.0,
- )
else:
image_input = (
diff --git a/vllm_omni/diffusion/models/dmd2/__init__.py b/vllm_omni/diffusion/models/dmd2/__init__.py
new file mode 100644
index 000000000..d0c8219d4
--- /dev/null
+++ b/vllm_omni/diffusion/models/dmd2/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+from vllm_omni.diffusion.models.dmd2.mixin import DMD2PipelineMixin
+
+__all__ = [
+ "DMD2PipelineMixin",
+]
diff --git a/vllm_omni/diffusion/models/dmd2/mixin.py b/vllm_omni/diffusion/models/dmd2/mixin.py
new file mode 100644
index 000000000..60c4b95ba
--- /dev/null
+++ b/vllm_omni/diffusion/models/dmd2/mixin.py
@@ -0,0 +1,88 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+from __future__ import annotations
+
+import logging
+import os
+
+from vllm_omni.diffusion.data import DiffusionOutput
+from vllm_omni.diffusion.models.schedulers import DMD2EulerScheduler
+from vllm_omni.diffusion.models.utils import _load_json
+from vllm_omni.diffusion.request import OmniDiffusionRequest
+
+logger = logging.getLogger(__name__)
+
+
+class DMD2PipelineMixin:
+ """Mixin for FastGen DMD2-distilled models. Must appear before the base pipeline in MRO."""
+
+ def __init_dmd2__(self) -> None:
+ """Call after super().__init__() to apply DMD2 scheduler and read model_index."""
+ local_files_only = os.path.exists(self.od_config.model)
+ try:
+ model_index = _load_json(self.od_config.model, "model_index.json", local_files_only)
+ except Exception:
+ model_index = {}
+
+ dmd2_timesteps = model_index.get("dmd2_denoising_timesteps", [999, 937, 833, 624])
+ self.num_inference_steps = model_index.get("dmd2_num_inference_steps", 4)
+ shift = model_index.get("dmd2_scheduler_shift", 1.0)
+ self.dmd2_guidance_scale = model_index.get("dmd2_guidance_scale", 1.0)
+
+ self.scheduler = DMD2EulerScheduler(
+ num_train_timesteps=1000,
+ shift=shift,
+ dmd2_timesteps=dmd2_timesteps,
+ )
+
+ def _sanitize_dmd2_request(self, req: OmniDiffusionRequest) -> None:
+ """Sanitize CFG-related fields in-place. Mutates req.sampling_params and req.prompts."""
+ sp = req.sampling_params
+
+ if sp.num_inference_steps and sp.num_inference_steps != self.num_inference_steps:
+ logger.warning(
+ "DMD2: ignoring num_inference_steps=%d, forcing %d.",
+ sp.num_inference_steps,
+ self.num_inference_steps,
+ )
+ sp.num_inference_steps = self.num_inference_steps
+
+ if sp.guidance_scale_provided and sp.guidance_scale != self.dmd2_guidance_scale:
+ logger.warning(
+ "DMD2: ignoring guidance_scale=%.2f, forcing %.2f.",
+ sp.guidance_scale,
+ self.dmd2_guidance_scale,
+ )
+ sp.guidance_scale = self.dmd2_guidance_scale
+ sp.guidance_scale_provided = False
+
+ if sp.guidance_scale_2 is not None:
+ logger.warning("DMD2: ignoring guidance_scale_2.")
+ sp.guidance_scale_2 = None
+
+ if sp.true_cfg_scale is not None:
+ logger.warning("DMD2: ignoring true_cfg_scale.")
+ sp.true_cfg_scale = None
+
+ sp.do_classifier_free_guidance = False
+ sp.is_cfg_negative = False
+
+ fixed = []
+ for p in req.prompts:
+ if isinstance(p, dict) and "negative_prompt" in p:
+ logger.warning("DMD2: ignoring negative_prompt.")
+ p = {k: v for k, v in p.items() if k != "negative_prompt"}
+ fixed.append(p)
+ req.prompts = fixed
+
+ def forward(self, req: OmniDiffusionRequest, **kwargs) -> DiffusionOutput:
+ self._sanitize_dmd2_request(req)
+ kwargs.pop("guidance_scale", None)
+ kwargs.pop("num_inference_steps", None)
+ return super().forward(
+ req,
+ guidance_scale=self.dmd2_guidance_scale,
+ num_inference_steps=self.num_inference_steps,
+ **kwargs,
+ )
diff --git a/vllm_omni/diffusion/models/dreamid_omni/fusion.py b/vllm_omni/diffusion/models/dreamid_omni/fusion.py
index a534f5a76..abca4c947 100644
--- a/vllm_omni/diffusion/models/dreamid_omni/fusion.py
+++ b/vllm_omni/diffusion/models/dreamid_omni/fusion.py
@@ -1,3 +1,5 @@
+import re
+
import torch
import torch.nn as nn
from vllm.logger import init_logger
@@ -15,78 +17,26 @@ from vllm_omni.diffusion.models.dreamid_omni.wan2_2 import WanModel, rope_apply
logger = init_logger(__name__)
-class FusionModel(nn.Module):
- def __init__(self, video_config=None, audio_config=None):
- super().__init__()
- has_video = True
- has_audio = True
- if video_config is not None:
- self.video_model = WanModel(**video_config)
- else:
- has_video = False
- self.video_model = None
- logger.warning("No video model is provided!")
+class FusedBlock(nn.Module):
+ """Wrapper pairing a video block and audio block for layerwise offloading.
- if audio_config is not None:
- self.audio_model = WanModel(**audio_config)
- else:
- has_audio = False
- self.audio_model = None
- logger.warning("No audio model is provided!")
+ Registers both blocks as submodules so their parameters are visible to the offload hooks.
+ """
- if has_video and has_audio:
- assert len(self.video_model.blocks) == len(self.audio_model.blocks)
- self.num_blocks = len(self.video_model.blocks)
-
- self.inject_cross_attention_kv_projections()
- self.device = get_local_device()
-
- self.num_heads = self.video_model.num_heads
- self.head_dim = self.video_model.dim // self.video_model.num_heads
- self.attn = Attention(
- num_heads=self.num_heads,
- head_size=self.head_dim,
- num_kv_heads=self.num_heads,
- softmax_scale=1.0 / (self.head_dim**0.5),
- causal=False,
- )
-
- def inject_cross_attention_kv_projections(self):
- for vid_block in self.video_model.blocks:
- vid_block.cross_attn.k_fusion = nn.Linear(vid_block.dim, vid_block.dim)
- vid_block.cross_attn.v_fusion = nn.Linear(vid_block.dim, vid_block.dim)
- vid_block.cross_attn.pre_attn_norm_fusion = WanLayerNorm(vid_block.dim, elementwise_affine=True)
- vid_block.cross_attn.norm_k_fusion = (
- WanRMSNorm(vid_block.dim, eps=1e-6) if vid_block.qk_norm else nn.Identity()
- )
-
- for audio_block in self.audio_model.blocks:
- audio_block.cross_attn.k_fusion = nn.Linear(audio_block.dim, audio_block.dim)
- audio_block.cross_attn.v_fusion = nn.Linear(audio_block.dim, audio_block.dim)
- audio_block.cross_attn.pre_attn_norm_fusion = WanLayerNorm(audio_block.dim, elementwise_affine=True)
- audio_block.cross_attn.norm_k_fusion = (
- WanRMSNorm(audio_block.dim, eps=1e-6) if audio_block.qk_norm else nn.Identity()
- )
-
- def merge_kwargs(self, vid_kwargs, audio_kwargs):
- """
- keys in each kwarg:
- e
- seq_lens
- grid_sizes
- freqs
- context
- context_lens
- """
- merged_kwargs = {}
- for key in vid_kwargs:
- merged_kwargs[f"vid_{key}"] = vid_kwargs[key]
- for key in audio_kwargs:
- merged_kwargs[f"audio_{key}"] = audio_kwargs[key]
- return merged_kwargs
-
- def single_fusion_cross_attention_forward(
+ def __init__(
self,
+ vid_block: nn.Module,
+ audio_block: nn.Module,
+ device: torch.device,
+ ):
+ super().__init__()
+ self.vid_block = vid_block
+ self.audio_block = audio_block
+ self.device = device
+
+ def _cross_attention_forward(
+ self,
+ attn: Attention,
cross_attn_block,
src_seq,
src_grid_sizes,
@@ -104,21 +54,17 @@ class FusionModel(nn.Module):
):
b, n, d = src_seq.size(0), cross_attn_block.num_heads, cross_attn_block.head_dim
if hasattr(cross_attn_block, "k_img"):
- ## means is i2v block
q, k, v, k_img, v_img = cross_attn_block.qkv_fn(src_seq, context)
else:
- ## means is t2v block
q, k, v = cross_attn_block.qkv_fn(src_seq, context)
k_img = v_img = None
- x = self.attn(q, k, v)
+ x = attn(q, k, v)
if k_img is not None:
- img_x = self.attn(q, k_img, v_img)
+ img_x = attn(q, k_img, v_img)
x = x + img_x
- # is_vid = src_grid_sizes.shape[1] > 1
- # compute target attention
target_seq = cross_attn_block.pre_attn_norm_fusion(target_seq)
k_target = cross_attn_block.norm_k_fusion(cross_attn_block.k_fusion(target_seq)).view(b, -1, n, d)
v_target = cross_attn_block.v_fusion(target_seq).view(b, -1, n, d)
@@ -132,17 +78,16 @@ class FusionModel(nn.Module):
freqs_scaling=target_freqs_scaling,
)
- target_x = self.attn(q, k_target, v_target)
+ target_x = attn(q, k_target, v_target)
x = x + target_x
-
- x = x.flatten(2) # [B, L/P, C]
-
+ x = x.flatten(2)
x = cross_attn_block.o(x)
return x
- def single_fusion_cross_attention_ffn_forward(
+ def _cross_attention_ffn_forward(
self,
+ attn: Attention,
attn_block,
src_seq,
src_grid_sizes,
@@ -159,7 +104,8 @@ class FusionModel(nn.Module):
target_ref_lengths=None,
target_freqs_scaling=None,
):
- src_seq = src_seq + self.single_fusion_cross_attention_forward(
+ src_seq = src_seq + self._cross_attention_forward(
+ attn,
attn_block.cross_attn,
attn_block.norm3(src_seq),
src_grid_sizes=src_grid_sizes,
@@ -180,12 +126,11 @@ class FusionModel(nn.Module):
src_seq = src_seq + y * src_e[5].squeeze(2)
return src_seq
- def single_fusion_block_forward(
+ def forward(
self,
- vid_block,
- audio_block,
vid,
audio,
+ attn: Attention,
vid_e,
vid_seq_lens,
vid_grid_sizes,
@@ -203,6 +148,9 @@ class FusionModel(nn.Module):
audio_ref_lengths,
audio_freqs_scaling,
):
+ vid_block = self.vid_block
+ audio_block = self.audio_block
+
## audio modulation
assert audio_e.dtype == torch.bfloat16
assert len(audio_e.shape) == 4 and audio_e.size(2) == 6 and audio_e.shape[1] == audio.shape[1], (
@@ -246,7 +194,8 @@ class FusionModel(nn.Module):
og_audio = audio
# audio cross-attention
- audio = self.single_fusion_cross_attention_ffn_forward(
+ audio = self._cross_attention_ffn_forward(
+ attn,
audio_block,
audio,
audio_grid_sizes,
@@ -267,7 +216,8 @@ class FusionModel(nn.Module):
assert not torch.equal(og_audio, audio), "Audio should be changed after cross-attention!"
# video cross-attention
- vid = self.single_fusion_cross_attention_ffn_forward(
+ vid = self._cross_attention_ffn_forward(
+ attn,
vid_block,
vid,
vid_grid_sizes,
@@ -287,6 +237,128 @@ class FusionModel(nn.Module):
return vid, audio
+
+class FusionModel(nn.Module):
+ _layerwise_offload_blocks_attrs = ["fused_blocks"]
+
+ def __init__(self, video_config=None, audio_config=None):
+ super().__init__()
+ has_video = True
+ has_audio = True
+ self.device = get_local_device()
+ if video_config is not None:
+ self.video_model = WanModel(**video_config)
+ else:
+ has_video = False
+ self.video_model = None
+ logger.warning("No video model is provided!")
+
+ if audio_config is not None:
+ self.audio_model = WanModel(**audio_config)
+ else:
+ has_audio = False
+ self.audio_model = None
+ logger.warning("No audio model is provided!")
+
+ if has_video and has_audio:
+ assert len(self.video_model.blocks) == len(self.audio_model.blocks)
+ self.num_blocks = len(self.video_model.blocks)
+
+ self.inject_cross_attention_kv_projections()
+
+ self.num_heads = self.video_model.num_heads
+ self.head_dim = self.video_model.dim // self.video_model.num_heads
+ # Make a single shared instance to pass in at forward time
+ self.attn = Attention(
+ num_heads=self.num_heads,
+ head_size=self.head_dim,
+ num_kv_heads=self.num_heads,
+ softmax_scale=1.0 / (self.head_dim**0.5),
+ causal=False,
+ )
+
+ if has_video and has_audio:
+ self.fused_blocks = nn.ModuleList(
+ [
+ FusedBlock(
+ self.video_model.blocks[i],
+ self.audio_model.blocks[i],
+ self.device,
+ )
+ for i in range(self.num_blocks)
+ ]
+ )
+
+ def load_state_dict(self, state_dict, strict=True, assign=False):
+ """Remap checkpoints where blocks are stored under
+ `video_model.blocks.N.*` / `audio_model.blocks.N.*` to the current
+ `fused_blocks.N.vid_block.*` / `fused_blocks.N.audio_block.*`.
+ """
+ needs_remap = any(re.match(r"^(video_model|audio_model)\.blocks\.\d+\.", k) for k in state_dict)
+ if needs_remap:
+ remapped = {}
+ for k, v in state_dict.items():
+ new_k = re.sub(r"^video_model\.blocks\.(\d+)\.", r"fused_blocks.\1.vid_block.", k)
+ new_k = re.sub(r"^audio_model\.blocks\.(\d+)\.", r"fused_blocks.\1.audio_block.", new_k)
+ remapped[new_k] = v
+ state_dict = remapped
+
+ self._detach_blocks_from_backbones()
+
+ return super().load_state_dict(state_dict, strict=strict, assign=assign)
+
+ def inject_cross_attention_kv_projections(self):
+ for vid_block in self.video_model.blocks:
+ vid_block.cross_attn.k_fusion = nn.Linear(vid_block.dim, vid_block.dim)
+ vid_block.cross_attn.v_fusion = nn.Linear(vid_block.dim, vid_block.dim)
+ vid_block.cross_attn.pre_attn_norm_fusion = WanLayerNorm(vid_block.dim, elementwise_affine=True)
+ vid_block.cross_attn.norm_k_fusion = (
+ WanRMSNorm(vid_block.dim, eps=1e-6) if vid_block.qk_norm else nn.Identity()
+ )
+
+ for audio_block in self.audio_model.blocks:
+ audio_block.cross_attn.k_fusion = nn.Linear(audio_block.dim, audio_block.dim)
+ audio_block.cross_attn.v_fusion = nn.Linear(audio_block.dim, audio_block.dim)
+ audio_block.cross_attn.pre_attn_norm_fusion = WanLayerNorm(audio_block.dim, elementwise_affine=True)
+ audio_block.cross_attn.norm_k_fusion = (
+ WanRMSNorm(audio_block.dim, eps=1e-6) if audio_block.qk_norm else nn.Identity()
+ )
+
+ def _detach_blocks_from_backbones(self) -> None:
+ """Keep offloadable blocks owned only by a single place.
+
+ NOTE: This is a special workaround to support layerwise offloading.
+ The model registers the same Wan blocks under both the video/audio
+ backbones and `fused_blocks` which is a wrapper for unified blocks
+ walking through. However, layerwise offloading will only consider
+ `fused_blocks` as offloadable components and will materialize all
+ other modules onto device, including the same blocks owned by both
+ `fused_blocks` and `video_model` and `audio_model`.
+ """
+ video_blocks = list(self.video_model.blocks)
+ audio_blocks = list(self.audio_model.blocks)
+ self.video_model._modules.pop("blocks", None)
+ self.audio_model._modules.pop("blocks", None)
+ self.video_model.blocks = tuple(video_blocks)
+ self.audio_model.blocks = tuple(audio_blocks)
+
+ def merge_kwargs(self, vid_kwargs, audio_kwargs):
+ """
+ keys in each kwarg:
+ e
+ seq_lens
+ grid_sizes
+ freqs
+ context
+ context_lens
+ """
+ merged_kwargs = {}
+ for key in vid_kwargs:
+ merged_kwargs[f"vid_{key}"] = vid_kwargs[key]
+ for key in audio_kwargs:
+ merged_kwargs[f"audio_{key}"] = audio_kwargs[key]
+ return merged_kwargs
+
def forward(
self,
vid,
@@ -316,17 +388,8 @@ class FusionModel(nn.Module):
kwargs = self.merge_kwargs(vid_kwargs, audio_kwargs)
- for i in range(self.num_blocks):
- """
- 1 fusion block refers to 1 audio block with 1 video block.
- """
-
- vid_block = self.video_model.blocks[i]
- audio_block = self.audio_model.blocks[i]
-
- vid, audio = self.single_fusion_block_forward(
- vid_block=vid_block, audio_block=audio_block, vid=vid, audio=audio, **kwargs
- )
+ for fused_block in self.fused_blocks:
+ vid, audio = fused_block(vid, audio, self.attn, **kwargs)
vid = self.video_model.post_transformer_block_out(vid, vid_kwargs["grid_sizes"], vid_e)
audio = self.audio_model.post_transformer_block_out(audio, audio_kwargs["grid_sizes"], audio_e)
diff --git a/vllm_omni/diffusion/models/dreamid_omni/pipeline_dreamid_omni.py b/vllm_omni/diffusion/models/dreamid_omni/pipeline_dreamid_omni.py
index 974cc582f..cc932f8c1 100644
--- a/vllm_omni/diffusion/models/dreamid_omni/pipeline_dreamid_omni.py
+++ b/vllm_omni/diffusion/models/dreamid_omni/pipeline_dreamid_omni.py
@@ -4,6 +4,7 @@
import logging
import math
import os
+from collections.abc import Iterable
import torch
import torch.distributed
@@ -16,6 +17,7 @@ from tqdm import tqdm
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.utils import get_local_device
+from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.models.interface import SupportAudioInput, SupportImageInput
from vllm_omni.diffusion.request import OmniDiffusionRequest
@@ -27,7 +29,6 @@ try:
init_mmaudio_vae,
init_text_model,
init_wan_vae_2_2,
- load_fusion_checkpoint,
)
from dreamid_omni.utils.rearrange import Rearrange
from dreamid_omni.utils.resize import NaResize
@@ -38,6 +39,21 @@ from vllm_omni.diffusion.models.dreamid_omni.fusion import FusionModel
logger = logging.getLogger(__name__)
+def get_dreamid_omni_post_process_func(*args, **kwargs):
+ def post_process(output):
+ if isinstance(output, tuple) and len(output) == 2:
+ video, audio = output
+ return {
+ "video": video,
+ "audio": audio,
+ "audio_sample_rate": 16000,
+ "fps": 24,
+ }
+ return output
+
+ return post_process
+
+
AUDIO_CONFIG = {
"patch_size": [1],
"model_type": "t2a",
@@ -107,16 +123,24 @@ class DreamIDOmniPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Suppor
self.text_model = init_text_model(model, rank=self.device)
self.text_encoder = self.text_model.model
- # Fusion model
- ## load audio/video model config
- Fusion_model = FusionModel(VIDEO_CONFIG, AUDIO_CONFIG)
-
- checkpoint_path = self.od_config.tf_model_config.get("fusion", None)
- assert checkpoint_path is not None, "fusion checkpoint path is None"
- load_fusion_checkpoint(Fusion_model, checkpoint_path=os.path.join(model, checkpoint_path))
- self.model = Fusion_model
+ # Fusion model — weights are loaded later via load_weights()
+ self.model = FusionModel(VIDEO_CONFIG, AUDIO_CONFIG)
self.transformer = self.model
+ fusion_path = self.od_config.tf_model_config.get("fusion", None)
+ assert fusion_path is not None, "fusion checkpoint path is None in transformer config"
+ fusion_subfolder = os.path.dirname(fusion_path) or None
+ fusion_filename = os.path.basename(fusion_path)
+ self.weights_sources = [
+ DiffusersPipelineLoader.ComponentSource(
+ model_or_path=model,
+ subfolder=fusion_subfolder,
+ revision=None,
+ prefix="model.",
+ allow_patterns_overrides=[fusion_filename],
+ )
+ ]
+
# Fixed attributes, non-configurable
self.audio_latent_channel = AUDIO_CONFIG.get("in_dim")
self.video_latent_channel = VIDEO_CONFIG.get("in_dim")
@@ -211,8 +235,11 @@ class DreamIDOmniPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Suppor
return ref_vae_latents, ref_audio_lengths
- def load_weights(self, weights):
- pass
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ prefix = "model."
+ state_dict = {name[len(prefix) :]: tensor for name, tensor in weights if name.startswith(prefix)}
+ self.model.load_state_dict(state_dict, strict=True)
+ return {prefix + k for k in state_dict}
def get_scheduler_time_steps(self, sampling_steps, solver_name="unipc", device=0, shift=5.0):
torch.manual_seed(4)
diff --git a/vllm_omni/diffusion/models/flux/flux_transformer.py b/vllm_omni/diffusion/models/flux/flux_transformer.py
index 680b8bfbb..dd88ee76c 100644
--- a/vllm_omni/diffusion/models/flux/flux_transformer.py
+++ b/vllm_omni/diffusion/models/flux/flux_transformer.py
@@ -381,7 +381,9 @@ class FluxSingleTransformerBlock(nn.Module):
super().__init__()
self.mlp_hidden_dim = int(dim * mlp_ratio)
- self.norm = AdaLayerNormZeroSingle(dim, quant_config=quant_config, prefix=f"{prefix}.norm")
+ # Modulation linear kept full precision; shift/scale/gate outputs
+ # are multiplied into the residual stream every block (see #2728).
+ self.norm = AdaLayerNormZeroSingle(dim, quant_config=None, prefix=f"{prefix}.norm")
self.proj_mlp = ReplicatedLinear(
dim,
self.mlp_hidden_dim,
@@ -524,10 +526,10 @@ class FluxTransformer2DModel(nn.Module):
def __init__(
self,
- od_config: OmniDiffusionConfig = None,
+ od_config: OmniDiffusionConfig | None = None,
patch_size: int = 1,
in_channels: int = 64,
- out_channels: int = None,
+ out_channels: int | None = None,
num_layers: int = 19,
num_single_layers: int = 38,
attention_head_dim: int = 128,
@@ -563,13 +565,16 @@ class FluxTransformer2DModel(nn.Module):
self.context_embedder = nn.Linear(joint_attention_dim, self.inner_dim)
self.x_embedder = nn.Linear(in_channels, self.inner_dim)
+ # Dual-stream blocks kept full precision — FP8 on their joint
+ # attention path causes noise on FLUX (#2728). Single-stream
+ # blocks (38 vs 19) still get FP8 for memory savings.
self.transformer_blocks = nn.ModuleList(
[
FluxTransformerBlock(
dim=self.inner_dim,
num_attention_heads=num_attention_heads,
attention_head_dim=attention_head_dim,
- quant_config=quant_config,
+ quant_config=None,
prefix=f"transformer_blocks.{i}",
)
for i in range(num_layers)
@@ -589,12 +594,13 @@ class FluxTransformer2DModel(nn.Module):
]
)
+ # Final modulation feeds proj_out; keep full precision (see #2728).
self.norm_out = AdaLayerNormContinuous(
self.inner_dim,
self.inner_dim,
elementwise_affine=False,
eps=1e-6,
- quant_config=quant_config,
+ quant_config=None,
prefix="norm_out",
)
self.proj_out = nn.Linear(self.inner_dim, patch_size * patch_size * self.out_channels, bias=True)
diff --git a/vllm_omni/diffusion/models/flux/pipeline_flux.py b/vllm_omni/diffusion/models/flux/pipeline_flux.py
index 6f43e8dbb..70d572d9a 100644
--- a/vllm_omni/diffusion/models/flux/pipeline_flux.py
+++ b/vllm_omni/diffusion/models/flux/pipeline_flux.py
@@ -30,6 +30,7 @@ from vllm_omni.diffusion.models.flux.flux_pipeline_mixin import FluxPipelineMixi
from vllm_omni.diffusion.models.t5_encoder import T5EncoderModel
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
+from vllm_omni.diffusion.utils.tf_utils import get_transformer_config_kwargs
from vllm_omni.model_executor.model_loader.weight_utils import download_weights_from_hf_specific
logger = logging.getLogger(__name__)
@@ -106,7 +107,11 @@ class FluxPipeline(nn.Module, FluxPipelineMixin, CFGParallelMixin, DiffusionPipe
self.vae = AutoencoderKL.from_pretrained(model, subfolder="vae", local_files_only=local_files_only).to(
self.device
)
- self.transformer = FluxTransformer2DModel(od_config=od_config, quant_config=od_config.quantization_config)
+
+ transformer_kwargs = get_transformer_config_kwargs(od_config.tf_model_config, FluxTransformer2DModel)
+ self.transformer = FluxTransformer2DModel(
+ **transformer_kwargs, od_config=od_config, quant_config=od_config.quantization_config
+ )
self.tokenizer = CLIPTokenizer.from_pretrained(model, subfolder="tokenizer", local_files_only=local_files_only)
self.tokenizer_2 = T5TokenizerFast.from_pretrained(
diff --git a/vllm_omni/diffusion/models/flux2_klein/pipeline_flux2_klein.py b/vllm_omni/diffusion/models/flux2_klein/pipeline_flux2_klein.py
index 437dd58d0..021d28c2a 100644
--- a/vllm_omni/diffusion/models/flux2_klein/pipeline_flux2_klein.py
+++ b/vllm_omni/diffusion/models/flux2_klein/pipeline_flux2_klein.py
@@ -234,7 +234,15 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
self.transformer = Flux2Transformer2DModel(quant_config=od_config.quantization_config, **transformer_kwargs)
self.vae_scale_factor = 2 ** (len(self.vae.config.block_out_channels) - 1) if getattr(self, "vae", None) else 8
+ self.latent_channels = self.vae.config.latent_channels if hasattr(self.vae, "config") else 16
self.image_processor = Flux2ImageProcessor(vae_scale_factor=self.vae_scale_factor * 2)
+ self.mask_processor = VaeImageProcessor(
+ vae_scale_factor=self.vae_scale_factor * 2,
+ vae_latent_channels=self.latent_channels,
+ do_normalize=False,
+ do_binarize=True,
+ do_convert_grayscale=True,
+ )
self.tokenizer_max_length = 512
self.default_sample_size = 128
@@ -247,6 +255,14 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
enable_diffusion_pipeline_profiler=self.od_config.enable_diffusion_pipeline_profiler
)
+ def get_timesteps(self, num_inference_steps, strength, device):
+ init_timestep = min(num_inference_steps * strength, num_inference_steps)
+ t_start = int(max(num_inference_steps - init_timestep, 0))
+ timesteps = self.scheduler.timesteps[t_start * self.scheduler.order :]
+ if hasattr(self.scheduler, "set_begin_index"):
+ self.scheduler.set_begin_index(t_start * self.scheduler.order)
+ return timesteps, num_inference_steps - t_start
+
@staticmethod
def _get_qwen3_prompt_embeds(
text_encoder: Qwen3ForCausalLM,
@@ -582,6 +598,54 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
return image_latents, image_latent_ids
+ def prepare_mask_latents(
+ self,
+ mask,
+ masked_image,
+ batch_size,
+ num_channels_latents,
+ num_images_per_prompt,
+ height,
+ width,
+ dtype,
+ device,
+ generator,
+ ):
+ height = 2 * (int(height) // (self.vae_scale_factor * 2))
+ width = 2 * (int(width) // (self.vae_scale_factor * 2))
+ mask = torch.nn.functional.interpolate(mask, size=(height, width))
+ mask = mask.to(device=device, dtype=dtype)
+
+ batch_size = batch_size * num_images_per_prompt
+
+ if masked_image is not None:
+ masked_image = masked_image.to(device=device, dtype=dtype)
+ if masked_image.shape[1] != num_channels_latents:
+ masked_image_latents = self._encode_vae_image(image=masked_image, generator=generator)
+ else:
+ masked_image_latents = masked_image
+ else:
+ masked_image_latents = None
+
+ if mask.shape[0] < batch_size:
+ if not batch_size % mask.shape[0] == 0:
+ raise ValueError("The passed mask and the required batch size don't match.")
+ mask = mask.repeat(batch_size // mask.shape[0], 1, 1, 1)
+ if masked_image_latents is not None and masked_image_latents.shape[0] < batch_size:
+ if not batch_size % masked_image_latents.shape[0] == 0:
+ raise ValueError("The passed mask and the required batch size don't match.")
+ masked_image_latents = masked_image_latents.repeat(batch_size // masked_image_latents.shape[0], 1, 1, 1)
+
+ if masked_image_latents is not None:
+ masked_image_latents = masked_image_latents.to(device=device, dtype=dtype)
+ masked_image_latents = self._pack_latents(masked_image_latents)
+
+ mask = mask.repeat(1, self.latent_channels, 1, 1)
+ mask = self._patchify_latents(mask)
+ mask = self._pack_latents(mask)
+
+ return mask, masked_image_latents
+
def check_inputs(
self,
prompt,
@@ -590,7 +654,15 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
prompt_embeds=None,
callback_on_step_end_tensor_inputs=None,
guidance_scale=None,
+ strength=None,
+ num_inference_steps=None,
):
+ if strength is not None:
+ if strength < 0 or strength > 1:
+ raise ValueError(f"strength must be between 0 and 1, got {strength}")
+ if num_inference_steps is not None and num_inference_steps <= 0:
+ raise ValueError(f"num_inference_steps must be positive, got {num_inference_steps}")
+
if (
height is not None
and height % (self.vae_scale_factor * 2) != 0
@@ -622,7 +694,7 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
elif prompt is not None and (not isinstance(prompt, str) and not isinstance(prompt, list)):
raise ValueError(f"`prompt` has to be of type `str` or `list` but is {type(prompt)}")
- if guidance_scale > 1.0 and self.is_distilled:
+ if guidance_scale is not None and guidance_scale > 1.0 and self.is_distilled:
logger.warning(f"Guidance scale {guidance_scale} is ignored for step-wise distilled models.")
@property
@@ -653,11 +725,14 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
self,
req: OmniDiffusionRequest,
image: PIL.Image.Image | list[PIL.Image.Image] | None = None,
+ reference_image: PIL.Image.Image | list[PIL.Image.Image] | None = None,
+ mask_image: PIL.Image.Image | list[PIL.Image.Image] | None = None,
prompt: str | list[str] | None = None,
height: int | None = None,
width: int | None = None,
num_inference_steps: int = 50,
sigmas: list[float] | None = None,
+ strength: float = 1.0,
guidance_scale: float | None = 4.0,
num_images_per_prompt: int = 1,
generator: torch.Generator | list[torch.Generator] | None = None,
@@ -671,6 +746,7 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
callback_on_step_end_tensor_inputs: list[str] = ["latents"],
max_sequence_length: int = 512,
text_encoder_out_layers: tuple[int, ...] = (9, 18, 27),
+ padding_mask_crop: int | None = None,
) -> DiffusionOutput:
r"""
Function invoked when calling the pipeline for generation.
@@ -804,6 +880,8 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
prompt_embeds=prompt_embeds,
callback_on_step_end_tensor_inputs=callback_on_step_end_tensor_inputs,
guidance_scale=guidance_scale,
+ strength=strength,
+ num_inference_steps=num_inference_steps,
)
self._guidance_scale = guidance_scale
@@ -848,6 +926,9 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
if image is not None and not isinstance(image, list):
image = [image]
+ multiple_of = self.vae_scale_factor * 2
+ crops_coords = None
+ resize_mode = "crop"
condition_images = None
if image is not None:
for img in image:
@@ -860,10 +941,14 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
img = self.image_processor._resize_to_target_area(img, 1024 * 1024)
image_width, image_height = img.size
- multiple_of = self.vae_scale_factor * 2
image_width = (image_width // multiple_of) * multiple_of
image_height = (image_height // multiple_of) * multiple_of
- img = self.image_processor.preprocess(img, height=image_height, width=image_width, resize_mode="crop")
+ if padding_mask_crop is not None:
+ crops_coords = self.mask_processor.get_crop_region(mask_image, width, height, pad=padding_mask_crop)
+ resize_mode = "fill"
+ img = self.image_processor.preprocess(
+ img, height=image_height, width=image_width, crops_coords=crops_coords, resize_mode=resize_mode
+ )
condition_images.append(img)
height = height or image_height
width = width or image_width
@@ -871,6 +956,16 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
height = height or self.default_sample_size * self.vae_scale_factor
width = width or self.default_sample_size * self.vae_scale_factor
+ # Get mask_image and reference_image
+ multi_modal_data = req.prompts[0].get("multi_modal_data", {}) if req.prompts else {}
+ mask_image = multi_modal_data.get("mask_image")
+ reference_image = multi_modal_data.get("reference_image")
+
+ if mask_image is not None and (image is None or (isinstance(image, list) and len(image) == 0)):
+ raise ValueError("image must be provided when using mask_image for inpainting")
+
+ init_image = condition_images[0] if condition_images else None
+
# 5. prepare latent variables
num_channels_latents = self.transformer.config.in_channels // 4
latents, latent_ids = self.prepare_latents(
@@ -884,6 +979,8 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
latents=latents,
)
+ original_latent_ids = latent_ids
+
image_latents = None
image_latent_ids = None
if condition_images is not None:
@@ -895,6 +992,71 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
dtype=self.vae.dtype,
)
+ # Preprocess reference_image
+ if reference_image is not None and not (
+ isinstance(reference_image, torch.Tensor) and reference_image.size(1) == self.latent_channels
+ ):
+ if (
+ isinstance(reference_image, list)
+ and isinstance(reference_image[0], torch.Tensor)
+ and reference_image[0].ndim == 4
+ ):
+ reference_image = torch.cat(reference_image, dim=0)
+ img_reference = reference_image[0] if isinstance(reference_image, list) else reference_image
+ reference_image_height, reference_image_width = self.image_processor.get_default_height_width(img_reference)
+
+ reference_image_width = reference_image_width // multiple_of * multiple_of
+ reference_image_height = reference_image_height // multiple_of * multiple_of
+ reference_image = self.image_processor.resize(
+ reference_image, reference_image_height, reference_image_width
+ )
+ reference_image = self.image_processor.preprocess(
+ reference_image,
+ reference_image_height,
+ reference_image_width,
+ crops_coords=crops_coords,
+ resize_mode=resize_mode,
+ )
+ else:
+ pass
+
+ reference_image_latents = None
+ reference_image_latent_ids = None
+ if reference_image is not None:
+ reference_image_latents, reference_image_latent_ids = self.prepare_image_latents(
+ images=[reference_image],
+ batch_size=batch_size * num_images_per_prompt,
+ generator=generator,
+ device=device,
+ dtype=self.vae.dtype,
+ )
+
+ if reference_image_latent_ids is not None:
+ latent_ids = torch.cat([latent_ids, reference_image_latent_ids], dim=1)
+ elif image_latent_ids is not None:
+ latent_ids = torch.cat([latent_ids, image_latent_ids], dim=1)
+
+ mask = None
+ if mask_image is not None:
+ mask_condition = self.mask_processor.preprocess(
+ mask_image, height=height, width=width, resize_mode=resize_mode, crops_coords=crops_coords
+ )
+ if init_image is not None:
+ masked_image = init_image * (mask_condition < 0.5).to(init_image.dtype)
+ else:
+ masked_image = None
+ mask, _ = self.prepare_mask_latents(
+ mask_condition,
+ masked_image,
+ batch_size,
+ self.latent_channels,
+ num_images_per_prompt,
+ height,
+ width,
+ prompt_embeds.dtype,
+ device,
+ generator,
+ )
# 6. Prepare timesteps
sigmas = np.linspace(1.0, 1 / num_inference_steps, num_inference_steps) if sigmas is None else sigmas
if hasattr(self.scheduler.config, "use_flow_sigmas") and self.scheduler.config.use_flow_sigmas:
@@ -908,6 +1070,13 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
sigmas=sigmas,
mu=mu,
)
+ if reference_image is not None or mask_image is not None:
+ timesteps, num_inference_steps = self.get_timesteps(num_inference_steps, strength, device)
+ if num_inference_steps < 1:
+ raise ValueError(
+ f"After adjusting the num_inference_steps by strength parameter: {strength}, the number of pipeline"
+ f"steps is {num_inference_steps} which is < 1 and not appropriate for this pipeline."
+ )
self._num_timesteps = len(timesteps)
# 7. Denoising loop
@@ -924,7 +1093,10 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
latent_model_input = latents.to(self.transformer.dtype)
latent_image_ids = latent_ids
- if image_latents is not None:
+ if reference_image_latents is not None:
+ latent_model_input = torch.cat([latents, reference_image_latents], dim=1)
+ latent_image_ids = latent_ids
+ elif image_latents is not None:
latent_model_input = torch.cat([latents, image_latents], dim=1).to(self.transformer.dtype)
latent_image_ids = torch.cat([latent_ids, image_latent_ids], dim=1)
@@ -953,7 +1125,9 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
negative_kwargs = None
# For editing pipelines, we need to slice the output to remove condition latents
- output_slice = latents.size(1) if image_latents is not None else None
+ output_slice = (
+ latents.size(1) if (image_latents is not None or reference_image_latents is not None) else None
+ )
noise_pred = self.predict_noise_maybe_with_cfg(
do_true_cfg=self.do_classifier_free_guidance,
@@ -964,9 +1138,22 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
output_slice=output_slice,
)
+ latents_dtype = latents.dtype
# Compute the previous noisy sample x_t -> x_t-1 with automatic CFG sync
latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, self.do_classifier_free_guidance)
+ if mask is not None and image_latents is not None:
+ init_latents_proper = image_latents
+ if i < len(timesteps) - 1:
+ noise_timestep = timesteps[i + 1]
+ init_latents_proper = self.scheduler.scale_noise(
+ init_latents_proper, torch.tensor([noise_timestep], device=device), latents
+ )
+ latents = (1 - mask) * init_latents_proper + mask * latents
+
+ if latents.dtype != latents_dtype and torch.backends.mps.is_available():
+ latents = latents.to(latents_dtype)
+
if callback_on_step_end is not None:
callback_kwargs = {}
for k in callback_on_step_end_tensor_inputs:
@@ -978,7 +1165,7 @@ class Flux2KleinPipeline(nn.Module, CFGParallelMixin, SupportImageInput, Diffusi
self._current_timestep = None
- latents = self._unpack_latents_with_ids(latents, latent_ids)
+ latents = self._unpack_latents_with_ids(latents, original_latent_ids)
latents_bn_mean = self.vae.bn.running_mean.view(1, -1, 1, 1).to(latents.device, latents.dtype)
latents_bn_std = torch.sqrt(self.vae.bn.running_var.view(1, -1, 1, 1) + self.vae.config.batch_norm_eps).to(
diff --git a/vllm_omni/diffusion/models/glm_image/glm_image_transformer.py b/vllm_omni/diffusion/models/glm_image/glm_image_transformer.py
index 490e0198b..7ff42a5f0 100644
--- a/vllm_omni/diffusion/models/glm_image/glm_image_transformer.py
+++ b/vllm_omni/diffusion/models/glm_image/glm_image_transformer.py
@@ -19,10 +19,16 @@ from vllm.model_executor.layers.linear import (
)
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
+from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.cache.base import CachedTransformer
-from vllm_omni.diffusion.data import OmniDiffusionConfig
+from vllm_omni.diffusion.data import DiffusionParallelConfig, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.hsdp_utils import is_transformer_block_module
+from vllm_omni.diffusion.distributed.sp_plan import (
+ SequenceParallelInput,
+ SequenceParallelOutput,
+)
+from vllm_omni.diffusion.forward_context import get_forward_context
logger = init_logger(__name__)
@@ -108,8 +114,8 @@ class GlmImageImageProjector(nn.Module):
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
batch_size, channel, height, width = hidden_states.shape
- post_patch_height = height // self.patch_size
- post_patch_width = width // self.patch_size
+ post_patch_height = torch.tensor(height // self.patch_size, device=hidden_states.device, dtype=torch.int64)
+ post_patch_width = torch.tensor(width // self.patch_size, device=hidden_states.device, dtype=torch.int64)
# Reshape: [B, C, H, W] -> [B, H', W', C*p*p] -> [B, H'*W', C*p*p]
hidden_states = hidden_states.reshape(
@@ -159,6 +165,65 @@ class GlmImageRotaryPosEmbed(nn.Module):
return (freqs.cos(), freqs.sin())
+class GlmImagePrepare(nn.Module):
+ """Prepare module for GLM-Image that handles patch embedding and RoPE computation.
+
+ This module encapsulates the input processing pipeline to create a module boundary
+ where _sp_plan can shard outputs via split_output=True.
+
+ Similar to Qwen-Image's ImageRopePrepare, this ensures hidden_states and RoPE
+ embeddings are sharded together to maintain dimension alignment.
+ """
+
+ def __init__(
+ self,
+ image_projector: nn.Module,
+ rope: GlmImageRotaryPosEmbed,
+ patch_size: int,
+ ):
+ super().__init__()
+ self.image_projector = image_projector
+ self.rope = rope
+ self.patch_size = patch_size
+
+ def forward(
+ self,
+ hidden_states: torch.Tensor,
+ prior_hidden_states: torch.Tensor | None = None,
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
+ """Process hidden_states and compute RoPE embeddings.
+
+ Args:
+ hidden_states: Input latent tensor [B, C, H, W]
+ prior_hidden_states: Optional prior embedding to add
+
+ Returns:
+ hidden_states: Patched hidden states [B, seq_len, D]
+ rope_cos: RoPE cos embeddings [seq_len, dim]
+ rope_sin: RoPE sin embeddings [seq_len, dim]
+ post_patch_height: Scalar tensor for height after patching
+ post_patch_width: Scalar tensor for width after patching
+ """
+ batch_size, num_channels, height, width = hidden_states.shape
+
+ post_patch_height = torch.tensor(height // self.patch_size, device=hidden_states.device, dtype=torch.int64)
+ post_patch_width = torch.tensor(width // self.patch_size, device=hidden_states.device, dtype=torch.int64)
+
+ # Compute RoPE (uses original 4D hidden_states shape)
+ image_rotary_emb = self.rope(hidden_states)
+ rope_cos = image_rotary_emb[0].to(hidden_states.device)
+ rope_sin = image_rotary_emb[1].to(hidden_states.device)
+
+ # Patch embedding: [B, C, H, W] -> [B, seq_len, D]
+ hidden_states = self.image_projector(hidden_states)
+
+ # Add prior embedding if provided
+ if prior_hidden_states is not None:
+ hidden_states = hidden_states + prior_hidden_states
+
+ return hidden_states, rope_cos, rope_sin, post_patch_height, post_patch_width
+
+
class GlmImageAdaLayerNormZero(nn.Module):
"""Adaptive LayerNorm with zero initialization for both image and text streams."""
@@ -397,6 +462,7 @@ class GlmImageAttention(nn.Module):
dim: int,
num_heads: int,
head_dim: int,
+ parallel_config: DiffusionParallelConfig | None = None,
out_bias: bool = True,
eps: float = 1e-5,
):
@@ -404,6 +470,7 @@ class GlmImageAttention(nn.Module):
self.dim = dim
self.total_num_heads = num_heads
self.head_dim = head_dim
+ self.parallel_config = parallel_config
# QKV projection (fused for efficiency)
self.to_qkv = QKVParallelLinear(
@@ -450,16 +517,19 @@ class GlmImageAttention(nn.Module):
attention_mask: torch.Tensor | None = None,
kv_cache: GlmImageLayerKVCache | None = None,
kv_cache_mode: KVCacheMode | None = None,
+ hidden_states_mask: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""
Forward pass for joint attention.
Args:
- hidden_states: Image hidden states [B, img_seq_len, D]
- encoder_hidden_states: Text hidden states [B, text_seq_len, D]
- image_rotary_emb: Tuple of (cos, sin) for RoPE
+ hidden_states: Image hidden states [B, img_seq_len, D] (sharded in SP mode)
+ encoder_hidden_states: Text hidden states [B, text_seq_len, D] (full in SP mode)
+ image_rotary_emb: Tuple of (cos, sin) for RoPE (sharded in SP mode)
+ attention_mask: Optional attention mask
kv_cache: Optional layer KV cache for image editing
kv_cache_mode: Cache mode (WRITE, READ, SKIP)
+ hidden_states_mask: Mask for SP padding (True=valid, False=padding)
Returns:
Tuple of (image_hidden_states, text_hidden_states)
@@ -467,6 +537,13 @@ class GlmImageAttention(nn.Module):
dtype = encoder_hidden_states.dtype
batch_size, text_seq_length, _ = encoder_hidden_states.shape
+ # Check if SP is enabled
+ sp_size = self.parallel_config.sequence_parallel_size if self.parallel_config else None
+ use_sp = sp_size is not None and sp_size > 1
+ if use_sp:
+ forward_ctx = get_forward_context()
+ use_sp = not forward_ctx.split_text_embed_in_sp
+
# Concatenate text and image: [text, image]
hidden_states_combined = torch.cat([encoder_hidden_states, hidden_states], dim=1)
@@ -485,41 +562,88 @@ class GlmImageAttention(nn.Module):
query = self.norm_q(query).to(dtype=dtype)
key = self.norm_k(key).to(dtype=dtype)
- # Apply RoPE only to image tokens (not text tokens)
- if image_rotary_emb is not None:
- # Only apply RoPE to image part (after text_seq_length)
- query_img = query[:, text_seq_length:, :, :]
- key_img = key[:, text_seq_length:, :, :]
- from diffusers.models.embeddings import apply_rotary_emb
+ if use_sp:
+ # SP mode: use joint attention mechanism
+ # Split Q/K/V into text and image parts
+ text_query = query[:, :text_seq_length, :, :]
+ text_key = key[:, :text_seq_length, :, :]
+ text_value = value[:, :text_seq_length, :, :]
+ img_query = query[:, text_seq_length:, :, :]
+ img_key = key[:, text_seq_length:, :, :]
+ img_value = value[:, text_seq_length:, :, :]
- query_img = apply_rotary_emb(query_img, image_rotary_emb, sequence_dim=1, use_real_unbind_dim=-2)
- key_img = apply_rotary_emb(key_img, image_rotary_emb, sequence_dim=1, use_real_unbind_dim=-2)
- query = torch.cat([query[:, :text_seq_length, :, :], query_img], dim=1)
- key = torch.cat([key[:, :text_seq_length, :, :], key_img], dim=1)
+ # Apply RoPE only to image part
+ if image_rotary_emb is not None:
+ from diffusers.models.embeddings import apply_rotary_emb
- # Handle KV cache for image editing
- if kv_cache is not None and kv_cache_mode is not None:
- if kv_cache_mode == KVCacheMode.WRITE:
- kv_cache.store(key, value)
- elif kv_cache_mode == KVCacheMode.READ:
- k_cached, v_cached = kv_cache.get()
- if k_cached is not None:
- key = torch.cat([k_cached, key], dim=1)
- value = torch.cat([v_cached, value], dim=1)
- # KVCacheMode.SKIP: do nothing
+ img_query = apply_rotary_emb(img_query, image_rotary_emb, sequence_dim=1, use_real_unbind_dim=-2)
+ img_key = apply_rotary_emb(img_key, image_rotary_emb, sequence_dim=1, use_real_unbind_dim=-2)
- # Attention computation
- hidden_states_out = self.attn(query, key, value)
- hidden_states_out = hidden_states_out.flatten(2, 3)
- hidden_states_out = hidden_states_out.to(dtype)
+ # Create attention metadata for joint attention
+ attn_metadata = AttentionMetadata(
+ joint_query=text_query,
+ joint_key=text_key,
+ joint_value=text_value,
+ joint_strategy="front",
+ )
- # Output projection
- for module in self.to_out:
- hidden_states_out = module(hidden_states_out)
+ # Add padding mask for SP if available
+ if hidden_states_mask is not None:
+ attn_metadata.attn_mask = hidden_states_mask
- # Split back to text and image
- encoder_hidden_states_out = hidden_states_out[:, :text_seq_length, :]
- hidden_states_out = hidden_states_out[:, text_seq_length:, :]
+ # Attention computation with joint text/image
+ # Note: Ulysses post_attention returns [text, image] concatenated
+ joint_hidden_states_out = self.attn(img_query, img_key, img_value, attn_metadata)
+
+ # Project combined [text, image] outputs, then split.
+ # This keeps SP numerically aligned with the non-SP path.
+ joint_hidden_states_out = joint_hidden_states_out.flatten(2, 3).to(dtype)
+ for module in self.to_out:
+ joint_hidden_states_out = module(joint_hidden_states_out)
+
+ encoder_hidden_states_out = joint_hidden_states_out[:, :text_seq_length, :]
+ hidden_states_out = joint_hidden_states_out[:, text_seq_length:, :]
+ else:
+ # Non-SP mode: original logic
+ # Apply RoPE only to image tokens (not text tokens)
+ if image_rotary_emb is not None:
+ query_img = query[:, text_seq_length:, :, :]
+ key_img = key[:, text_seq_length:, :, :]
+ from diffusers.models.embeddings import apply_rotary_emb
+
+ query_img = apply_rotary_emb(query_img, image_rotary_emb, sequence_dim=1, use_real_unbind_dim=-2)
+ key_img = apply_rotary_emb(key_img, image_rotary_emb, sequence_dim=1, use_real_unbind_dim=-2)
+ query = torch.cat([query[:, :text_seq_length, :, :], query_img], dim=1)
+ key = torch.cat([key[:, :text_seq_length, :, :], key_img], dim=1)
+
+ # Handle KV cache for image editing
+ if kv_cache is not None and kv_cache_mode is not None:
+ if kv_cache_mode == KVCacheMode.WRITE:
+ kv_cache.store(key, value)
+ elif kv_cache_mode == KVCacheMode.READ:
+ k_cached, v_cached = kv_cache.get()
+ if k_cached is not None:
+ key = torch.cat([k_cached, key], dim=1)
+ value = torch.cat([v_cached, value], dim=1)
+
+ # Attention computation
+ attn_metadata = None
+ if attention_mask is not None:
+ if attention_mask.dim() == 3:
+ attention_mask = attention_mask.unsqueeze(1)
+ attn_metadata = AttentionMetadata(attn_mask=attention_mask)
+
+ hidden_states_out = self.attn(query, key, value, attn_metadata)
+ hidden_states_out = hidden_states_out.flatten(2, 3)
+ hidden_states_out = hidden_states_out.to(dtype)
+
+ # Output projection
+ for module in self.to_out:
+ hidden_states_out = module(hidden_states_out)
+
+ # Split back to text and image
+ encoder_hidden_states_out = hidden_states_out[:, :text_seq_length, :]
+ hidden_states_out = hidden_states_out[:, text_seq_length:, :]
return hidden_states_out, encoder_hidden_states_out
@@ -628,6 +752,7 @@ class GlmImageTransformerBlock(nn.Module):
attention_head_dim: int = 40,
time_embed_dim: int = 512,
ffn_hidden_dim: int | None = None,
+ parallel_config: DiffusionParallelConfig | None = None,
) -> None:
super().__init__()
@@ -637,6 +762,7 @@ class GlmImageTransformerBlock(nn.Module):
dim=dim,
num_heads=num_attention_heads,
head_dim=attention_head_dim,
+ parallel_config=parallel_config,
)
# 2. Feedforward
@@ -654,6 +780,7 @@ class GlmImageTransformerBlock(nn.Module):
attention_kwargs: dict[str, Any] | None = None,
kv_cache: GlmImageLayerKVCache | None = None,
kv_cache_mode: KVCacheMode | None = None,
+ hidden_states_mask: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""
Forward pass for transformer block.
@@ -667,6 +794,7 @@ class GlmImageTransformerBlock(nn.Module):
attention_kwargs: Additional attention arguments
kv_cache: Layer-specific KV cache for image editing
kv_cache_mode: Cache mode (WRITE, READ, SKIP)
+ hidden_states_mask: Mask for SP padding (True=valid, False=padding)
Returns:
Tuple of (image_hidden_states, text_hidden_states)
@@ -693,6 +821,7 @@ class GlmImageTransformerBlock(nn.Module):
attention_mask=attention_mask,
kv_cache=kv_cache,
kv_cache_mode=kv_cache_mode,
+ hidden_states_mask=hidden_states_mask,
)
hidden_states = hidden_states + attn_hidden_states * gate_msa.unsqueeze(1)
encoder_hidden_states = encoder_hidden_states + attn_encoder_hidden_states * c_gate_msa.unsqueeze(1)
@@ -724,6 +853,26 @@ class GlmImageTransformer2DModel(CachedTransformer):
"""
_repeated_blocks = ["GlmImageTransformerBlock"]
+ # SP plan using GlmImagePrepare module for sharding hidden_states and RoPE together.
+ # Similar to Qwen-Image's ImageRopePrepare, this creates a module boundary where
+ # _sp_plan can shard outputs via split_output=True.
+ #
+ # Key insight: hidden_states and RoPE embeddings MUST be sharded together
+ # to maintain dimension alignment for RoPE computation in attention layers.
+ _sp_plan = {
+ # Shard GlmImagePrepare outputs (hidden_states and RoPE must be sharded together)
+ "prepare": {
+ # hidden_states: [B, seq_len, D] - shard along sequence dimension
+ 0: SequenceParallelInput(split_dim=1, expected_dims=3, split_output=True, auto_pad=True),
+ # RoPE cos: [seq_len, dim] - shard along sequence dimension
+ 1: SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True, auto_pad=True),
+ # RoPE sin: [seq_len, dim] - shard along sequence dimension
+ 2: SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True, auto_pad=True),
+ # post_patch_height and post_patch_width are scalars, not sharded
+ },
+ # Gather output at proj_out
+ "proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
+ }
_hsdp_shard_conditions = [is_transformer_block_module]
@@ -790,6 +939,9 @@ class GlmImageTransformer2DModel(CachedTransformer):
dim=inner_dim, dim_out=inner_dim, inner_dim=inner_dim, activation_fn="linear-silu"
)
+ # Prepare module for SP (encapsulates patch embedding and RoPE for _sp_plan)
+ self.prepare = GlmImagePrepare(self.image_projector, self.rope, patch_size)
+
self.time_condition_embed = GlmImageCombinedTimestepSizeEmbeddings(
embedding_dim=time_embed_dim,
condition_dim=condition_dim,
@@ -806,6 +958,7 @@ class GlmImageTransformer2DModel(CachedTransformer):
attention_head_dim,
time_embed_dim,
ffn_hidden_dim=ffn_hidden_dim,
+ parallel_config=self.parallel_config,
)
for _ in range(num_layers)
]
@@ -859,33 +1012,51 @@ class GlmImageTransformer2DModel(CachedTransformer):
# Get KV cache mode
kv_cache_mode = kv_cache.mode if kv_cache is not None else None
- # 1. RoPE
- if image_rotary_emb is None:
- image_rotary_emb = self.rope(hidden_states)
- # Move to correct device
- image_rotary_emb = (
- image_rotary_emb[0].to(hidden_states.device),
- image_rotary_emb[1].to(hidden_states.device),
- )
+ # Set SP context if enabled
+ sp_size = self.parallel_config.sequence_parallel_size
+ if sp_size is not None and sp_size > 1:
+ get_forward_context().split_text_embed_in_sp = False
- # 2. Patch & Timestep embeddings
- p = self.patch_size
- post_patch_height = height // p
- post_patch_width = width // p
-
- hidden_states = self.image_projector(hidden_states)
+ # Text embedding projection
encoder_hidden_states = self.glyph_projector(encoder_hidden_states)
# Prior embedding with dropout
prior_embedding = self.prior_token_embedding(prior_token_id)
prior_embedding[prior_token_drop] *= 0.0
prior_hidden_states = self.prior_projector(prior_embedding)
- hidden_states = hidden_states + prior_hidden_states
+
+ # 1. Prepare hidden_states and RoPE via GlmImagePrepare module
+ # _sp_plan will shard hidden_states and RoPE together via split_output=True
+ hidden_states, rope_cos, rope_sin, post_patch_height_t, post_patch_width_t = self.prepare(
+ hidden_states, prior_hidden_states
+ )
+ image_rotary_emb = (rope_cos, rope_sin)
+ post_patch_height = int(post_patch_height_t.item())
+ post_patch_width = int(post_patch_width_t.item())
# Timestep conditioning
temb = self.time_condition_embed(timestep, target_size, crop_coords, hidden_states.dtype)
- # 3. Transformer blocks
+ # Create padding mask for SP if needed (after _sp_plan hooks have run)
+ hidden_states_mask = None
+ if sp_size is not None and sp_size > 1:
+ from vllm_omni.diffusion.forward_context import is_forward_context_available
+
+ if is_forward_context_available():
+ ctx = get_forward_context()
+ if ctx.sp_original_seq_len is not None and ctx.sp_padding_size > 0:
+ img_padded_seq_len = ctx.sp_original_seq_len + ctx.sp_padding_size
+ hidden_states_mask = torch.ones(
+ batch_size,
+ img_padded_seq_len,
+ dtype=torch.bool,
+ device=hidden_states.device,
+ )
+ hidden_states_mask[:, ctx.sp_original_seq_len :] = False
+ if hidden_states_mask.all():
+ hidden_states_mask = None
+
+ # 2. Transformer blocks
for layer_idx, block in enumerate(self.transformer_blocks):
# Get layer-specific KV cache if available
layer_kv_cache = kv_cache[layer_idx] if kv_cache is not None else None
@@ -899,13 +1070,16 @@ class GlmImageTransformer2DModel(CachedTransformer):
attention_kwargs,
kv_cache=layer_kv_cache,
kv_cache_mode=kv_cache_mode,
+ hidden_states_mask=hidden_states_mask,
)
- # 4. Output norm & projection
+ # 3. Output norm & projection
+ # _sp_plan will gather hidden_states via proj_out hook
hidden_states = self.norm_out(hidden_states, temb)
hidden_states = self.proj_out(hidden_states)
- # 5. Unpatchify: [B, H'*W', C*p*p] -> [B, C, H, W]
+ # 4. Unpatchify: [B, H'*W', C*p*p] -> [B, C, H, W]
+ p = self.patch_size
hidden_states = hidden_states.reshape(batch_size, post_patch_height, post_patch_width, -1, p, p)
output = hidden_states.permute(0, 3, 1, 4, 2, 5).flatten(4, 5).flatten(2, 3)
diff --git a/vllm_omni/diffusion/models/glm_image/pipeline_glm_image.py b/vllm_omni/diffusion/models/glm_image/pipeline_glm_image.py
index 375f7e7b8..038636499 100644
--- a/vllm_omni/diffusion/models/glm_image/pipeline_glm_image.py
+++ b/vllm_omni/diffusion/models/glm_image/pipeline_glm_image.py
@@ -712,6 +712,14 @@ class GlmImagePipeline(nn.Module, DiffusionPipelineProfilerMixin):
if img is not None:
preprocessed_images = [img]
+ # Priority: prompt dict (from ar2diffusion) > sampling_params
+ # ar2diffusion returns adjusted height/width that matches prior_token_ids
+ if not isinstance(first_prompt, str):
+ ar_height = first_prompt.get("height")
+ ar_width = first_prompt.get("width")
+ else:
+ ar_height = ar_width = None
+
img_height = req.sampling_params.height
img_width = req.sampling_params.width
@@ -719,12 +727,19 @@ class GlmImagePipeline(nn.Module, DiffusionPipelineProfilerMixin):
# Treat that as t2i warmup to avoid requiring i2i-only KV-cache inputs.
is_image_edit = (preprocessed_images is not None) and (not is_dummy_warmup)
- # Use image dimensions as default if available
- height = req.sampling_params.height or img_height or self.default_sample_size * self.vae_scale_factor
- width = req.sampling_params.width or img_width or self.default_sample_size * self.vae_scale_factor
+ # Use prompt dict dimensions (from ar2diffusion) as priority, then sampling_params
+ height = (
+ ar_height or req.sampling_params.height or img_height or self.default_sample_size * self.vae_scale_factor
+ )
+ width = ar_width or req.sampling_params.width or img_width or self.default_sample_size * self.vae_scale_factor
num_inference_steps = req.sampling_params.num_inference_steps or 50
guidance_scale = req.sampling_params.guidance_scale or 1.5
+ # Ensure dimensions are multiples of vae_scale_factor * patch_size
+ multiple_of = self.vae_scale_factor * self._patch_size
+ height = height // multiple_of * multiple_of
+ width = width // multiple_of * multiple_of
+
self.check_inputs(prompt=prompt, height=height, width=width, prompt_embeds=prompt_embeds)
batch_size = 1
@@ -753,6 +768,20 @@ class GlmImagePipeline(nn.Module, DiffusionPipelineProfilerMixin):
prior_token_id = prior_token_id.to(device=self.device, dtype=torch.long)
if prior_token_id.dim() == 1:
prior_token_id = prior_token_id.unsqueeze(0)
+
+ # Validate that prior_token_id seq_len matches dimensions
+ prior_seq_len = prior_token_id.shape[1]
+ expected_seq_len = (height // self.vae_scale_factor // self._patch_size) * (
+ width // self.vae_scale_factor // self._patch_size
+ )
+ if prior_seq_len != expected_seq_len:
+ raise ValueError(
+ f"prior_token_ids seq_len ({prior_seq_len}) doesn't match dimensions "
+ f"({height}x{width}, expected seq_len={expected_seq_len}). "
+ f"This indicates a mismatch between AR output and Diffusion input. "
+ f"Please ensure ar2diffusion returns correct height/width."
+ )
+
prior_token_image_ids = None
if external_prior_image_ids is not None:
if isinstance(external_prior_image_ids, torch.Tensor):
diff --git a/vllm_omni/diffusion/models/helios/helios_transformer.py b/vllm_omni/diffusion/models/helios/helios_transformer.py
index b3d2621ad..5e7934c3b 100644
--- a/vllm_omni/diffusion/models/helios/helios_transformer.py
+++ b/vllm_omni/diffusion/models/helios/helios_transformer.py
@@ -62,10 +62,16 @@ def apply_rotary_emb_helios(
"""
x_1, x_2 = hidden_states.unflatten(-1, (-1, 2)).unbind(-1)
cos, sin = freqs_cis.unsqueeze(-2).chunk(2, dim=-1)
- out = torch.empty_like(hidden_states)
- out[..., 0::2] = x_1 * cos[..., 0::2] - x_2 * sin[..., 1::2]
- out[..., 1::2] = x_1 * sin[..., 1::2] + x_2 * cos[..., 0::2]
- return out.type_as(hidden_states)
+ # Use stack+flatten instead of strided slice assignment for contiguous
+ # memory layout and better performance on GPU/NPU (#2436, cf. PR #2393).
+ rotated = torch.stack(
+ (
+ x_1 * cos[..., 0::2] - x_2 * sin[..., 1::2],
+ x_1 * sin[..., 1::2] + x_2 * cos[..., 0::2],
+ ),
+ dim=-1,
+ )
+ return rotated.flatten(-2, -1).type_as(hidden_states)
class DistributedRMSNorm(nn.Module):
diff --git a/vllm_omni/diffusion/models/hunyuan_image_3/__init__.py b/vllm_omni/diffusion/models/hunyuan_image3/__init__.py
similarity index 58%
rename from vllm_omni/diffusion/models/hunyuan_image_3/__init__.py
rename to vllm_omni/diffusion/models/hunyuan_image3/__init__.py
index cbc6a8ad1..6612bd855 100644
--- a/vllm_omni/diffusion/models/hunyuan_image_3/__init__.py
+++ b/vllm_omni/diffusion/models/hunyuan_image3/__init__.py
@@ -2,12 +2,12 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Hunyuan Image 3 diffusion model components."""
-from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import HunyuanFusedMoE
-from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_image_3_transformer import (
+from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import HunyuanFusedMoE
+from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer import (
HunyuanImage3Model,
HunyuanImage3Text2ImagePipeline,
)
-from vllm_omni.diffusion.models.hunyuan_image_3.pipeline_hunyuan_image_3 import (
+from vllm_omni.diffusion.models.hunyuan_image3.pipeline_hunyuan_image3 import (
HunyuanImage3Pipeline,
)
diff --git a/vllm_omni/diffusion/models/hunyuan_image_3/autoencoder.py b/vllm_omni/diffusion/models/hunyuan_image3/autoencoder.py
similarity index 100%
rename from vllm_omni/diffusion/models/hunyuan_image_3/autoencoder.py
rename to vllm_omni/diffusion/models/hunyuan_image3/autoencoder.py
diff --git a/vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_fused_moe.py b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_fused_moe.py
similarity index 100%
rename from vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_fused_moe.py
rename to vllm_omni/diffusion/models/hunyuan_image3/hunyuan_fused_moe.py
diff --git a/vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_image_3_tokenizer.py b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_tokenizer.py
similarity index 99%
rename from vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_image_3_tokenizer.py
rename to vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_tokenizer.py
index ce563f711..4a29e9df9 100644
--- a/vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_image_3_tokenizer.py
+++ b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_tokenizer.py
@@ -13,7 +13,7 @@ from diffusers.utils.outputs import BaseOutput
from transformers import AutoTokenizer
from vllm.logger import init_logger
-from .hunyuan_image_3_transformer import ImageInfo, JointImageInfo, default
+from .hunyuan_image3_transformer import ImageInfo, JointImageInfo, default
logger = init_logger(__name__)
diff --git a/vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_image_3_transformer.py b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py
similarity index 99%
rename from vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_image_3_transformer.py
rename to vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py
index bc81ca9c3..fbdacddaf 100644
--- a/vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_image_3_transformer.py
+++ b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py
@@ -74,7 +74,7 @@ from vllm_omni.diffusion.distributed.sp_plan import (
)
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.layers.rope import RotaryEmbedding
-from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import HunyuanFusedMoE
+from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import HunyuanFusedMoE
logger = logging.getLogger(__name__)
@@ -1684,7 +1684,8 @@ class HunYuanAttention(nn.Module):
else:
attn_output = self.attn(q, k, v)
# For o_proj
- attn_output = attn_output.view(q.shape[0], -1)
+ # image_attn may return a non-contiguous tensor; reshape is safe here.
+ attn_output = attn_output.reshape(q.shape[0], -1)
output, _ = self.o_proj(attn_output)
output = output.reshape(bsz, q_len, -1)
return output, None, past_key_value
diff --git a/vllm_omni/diffusion/models/hunyuan_image_3/pipeline_hunyuan_image_3.py b/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py
similarity index 99%
rename from vllm_omni/diffusion/models/hunyuan_image_3/pipeline_hunyuan_image_3.py
rename to vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py
index 7e9e2d278..3de0ab310 100644
--- a/vllm_omni/diffusion/models/hunyuan_image_3/pipeline_hunyuan_image_3.py
+++ b/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py
@@ -6,7 +6,6 @@ import os
from collections.abc import Iterable
from typing import Any
-import numpy as np
import torch
import torch.nn as nn
from diffusers.schedulers.scheduling_flow_match_euler_discrete import FlowMatchEulerDiscreteScheduler
@@ -25,8 +24,8 @@ from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPi
from vllm_omni.diffusion.request import OmniDiffusionRequest
from .autoencoder import AutoencoderKLConv3D
-from .hunyuan_image_3_tokenizer import TokenizerWrapper
-from .hunyuan_image_3_transformer import (
+from .hunyuan_image3_tokenizer import TokenizerWrapper
+from .hunyuan_image3_transformer import (
CausalMMOutputWithPast,
HunyuanImage3ImageProcessor,
HunyuanImage3Model,
@@ -544,7 +543,7 @@ class HunyuanImage3Pipeline(HunyuanImage3PreTrainedModel, GenerationMixin, Diffu
generator = [torch.Generator(self.device).manual_seed(seed) for seed in seeds]
# 3. apply chat template
- cfg_factor = {"gen_text": 1, "gen_image": 2}
+ cfg_factor = {"gen_text": 1, "gen_image": 1 + int(guidance_scale > 1.0)}
bot_task = kwargs.pop("bot_task", "auto")
# If `drop_think` enabled, always drop parts in the context.
drop_think = kwargs.get("drop_think", self.generation_config.drop_think)
@@ -1009,8 +1008,7 @@ class HunyuanImage3Pipeline(HunyuanImage3PreTrainedModel, GenerationMixin, Diffu
if req.sampling_params.guidance_scale_provided:
guidance_scale = req.sampling_params.guidance_scale
if guidance_scale <= 1.0:
- logger.warning("HunyuanImage3.0 does not support guidance_scale <= 1.0, will set it to 1.0 + epsilon.")
- guidance_scale = 1.0 + np.finfo(float).eps
+ logger.info("HunyuanImage3.0 runs without classifier-free guidance when guidance_scale <= 1.0.")
image_size = (height, width)
model_inputs = self.prepare_model_inputs(
prompt=prompt,
diff --git a/vllm_omni/diffusion/models/hunyuan_image_3/system_prompt.py b/vllm_omni/diffusion/models/hunyuan_image3/system_prompt.py
similarity index 100%
rename from vllm_omni/diffusion/models/hunyuan_image_3/system_prompt.py
rename to vllm_omni/diffusion/models/hunyuan_image3/system_prompt.py
diff --git a/vllm_omni/diffusion/models/ltx2/__init__.py b/vllm_omni/diffusion/models/ltx2/__init__.py
index 9f9d70f01..2a78b61ba 100644
--- a/vllm_omni/diffusion/models/ltx2/__init__.py
+++ b/vllm_omni/diffusion/models/ltx2/__init__.py
@@ -4,12 +4,14 @@
from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import (
LTX2Pipeline,
+ LTX2T2VDMD2Pipeline,
LTX2TwoStagesPipeline,
create_transformer_from_config,
get_ltx2_post_process_func,
load_transformer_config,
)
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_image2video import (
+ LTX2I2VDMD2Pipeline,
LTX2ImageToVideoPipeline,
LTX2ImageToVideoTwoStagesPipeline,
)
@@ -17,7 +19,9 @@ from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_latent_upsample import LTX2La
__all__ = [
"LTX2Pipeline",
+ "LTX2T2VDMD2Pipeline",
"LTX2ImageToVideoPipeline",
+ "LTX2I2VDMD2Pipeline",
"LTX2LatentUpsamplePipeline",
"LTX2TwoStagesPipeline",
"LTX2ImageToVideoTwoStagesPipeline",
diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_transformer.py b/vllm_omni/diffusion/models/ltx2/ltx2_transformer.py
index a1bf7f780..0ae71aa2d 100644
--- a/vllm_omni/diffusion/models/ltx2/ltx2_transformer.py
+++ b/vllm_omni/diffusion/models/ltx2/ltx2_transformer.py
@@ -41,6 +41,7 @@ from vllm.model_executor.model_loader.weight_utils import default_weight_loader
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
from vllm_omni.diffusion.attention.layer import Attention
+from vllm_omni.diffusion.distributed.hsdp_utils import is_transformer_block_module
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelInput, SequenceParallelOutput
from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_context_available
@@ -1264,6 +1265,8 @@ class LTX2VideoTransformer3DModel(nn.Module):
_supports_gradient_checkpointing = True
_skip_layerwise_casting_patterns = ["norm"]
_repeated_blocks = ["LTX2VideoTransformerBlock"]
+ _layerwise_offload_blocks_attrs = ["transformer_blocks"]
+ _hsdp_shard_conditions = [is_transformer_block_module]
_sp_plan: dict[str, Any] | None = None
@staticmethod
diff --git a/vllm_omni/diffusion/models/ltx2/pipeline_ltx2.py b/vllm_omni/diffusion/models/ltx2/pipeline_ltx2.py
index c60b192f0..f06ffab16 100644
--- a/vllm_omni/diffusion/models/ltx2/pipeline_ltx2.py
+++ b/vllm_omni/diffusion/models/ltx2/pipeline_ltx2.py
@@ -33,6 +33,7 @@ from vllm_omni.diffusion.distributed.parallel_state import (
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.lora.manager import DiffusionLoRAManager
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
+from vllm_omni.diffusion.models.dmd2 import DMD2PipelineMixin
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.lora.request import LoRARequest
@@ -1304,3 +1305,11 @@ class LTX2TwoStagesPipeline(nn.Module):
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
loader = AutoWeightsLoader(self)
return loader.load_weights(weights)
+
+
+class LTX2T2VDMD2Pipeline(DMD2PipelineMixin, LTX2Pipeline):
+ """LTX-2 T2V pipeline for FastGen DMD2-distilled models."""
+
+ def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
+ super().__init__(od_config=od_config, prefix=prefix)
+ self.__init_dmd2__()
diff --git a/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_image2video.py b/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_image2video.py
index 65e7454b7..50a71a54b 100644
--- a/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_image2video.py
+++ b/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_image2video.py
@@ -25,6 +25,7 @@ from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.lora.manager import DiffusionLoRAManager
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
+from vllm_omni.diffusion.models.dmd2 import DMD2PipelineMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.lora.request import LoRARequest
@@ -889,3 +890,11 @@ class LTX2ImageToVideoTwoStagesPipeline(nn.Module):
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
loader = AutoWeightsLoader(self)
return loader.load_weights(weights)
+
+
+class LTX2I2VDMD2Pipeline(DMD2PipelineMixin, LTX2ImageToVideoPipeline):
+ """LTX-2 I2V pipeline for FastGen DMD2-distilled models."""
+
+ def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
+ super().__init__(od_config=od_config, prefix=prefix)
+ self.__init_dmd2__()
diff --git a/vllm_omni/diffusion/models/magi_human/pipeline_magi_human.py b/vllm_omni/diffusion/models/magi_human/pipeline_magi_human.py
index 881c72edc..c1abdf91f 100644
--- a/vllm_omni/diffusion/models/magi_human/pipeline_magi_human.py
+++ b/vllm_omni/diffusion/models/magi_human/pipeline_magi_human.py
@@ -48,6 +48,7 @@ from vllm_omni.diffusion.model_loader.diffusers_loader import (
)
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
from vllm_omni.diffusion.models.t5_encoder.t5_gemma_encoder import T5GemmaEncoderModelTP
+from vllm_omni.diffusion.models.utils import _load_json
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import (
DiffusionPipelineProfilerMixin,
)
@@ -1640,20 +1641,6 @@ def get_magi_human_post_process_func(*args, **kwargs):
# ===========================================================================
-def _load_json(model_path: str, filename: str, local_files_only: bool = True) -> dict:
- """Load a JSON config file from a local path or HuggingFace Hub repo."""
- if local_files_only:
- path = os.path.join(model_path, *filename.split("/"))
- with open(path) as f:
- return json.load(f)
- else:
- from huggingface_hub import hf_hub_download
-
- cached = hf_hub_download(repo_id=model_path, filename=filename)
- with open(cached) as f:
- return json.load(f)
-
-
def _resolve_subdir(
model_path: str,
subfolder: str,
diff --git a/vllm_omni/diffusion/models/omnigen2/omnigen2_transformer.py b/vllm_omni/diffusion/models/omnigen2/omnigen2_transformer.py
index 9ff681a3c..3f03563a1 100644
--- a/vllm_omni/diffusion/models/omnigen2/omnigen2_transformer.py
+++ b/vllm_omni/diffusion/models/omnigen2/omnigen2_transformer.py
@@ -5,6 +5,8 @@ from types import SimpleNamespace
import torch
import torch.nn as nn
+import torch.nn.functional as F
+import vllm._custom_ops as ops
from diffusers.models.activations import get_activation
from diffusers.models.embeddings import Timesteps, get_1d_rotary_pos_embed
from diffusers.models.modeling_outputs import Transformer2DModelOutput
@@ -16,6 +18,7 @@ from vllm.model_executor.layers.linear import (
QKVParallelLinear,
RowParallelLinear,
)
+from vllm.model_executor.layers.quantization import QuantizationConfig
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
from vllm_omni.diffusion.attention.layer import Attention
@@ -24,6 +27,105 @@ from vllm_omni.platforms import current_omni_platform
logger = logging.getLogger(__name__)
+def _patch_cutlass_padded_fp8():
+ """Monkey-patch vllm._custom_ops.cutlass_scaled_mm to pad tensors whose
+ dimensions are not multiples of 16, so the CUTLASS FP8 kernel is used.
+
+ OmniGen2 has hidden_size=2520 (2520 % 16 == 8). Without this patch,
+ vLLM's cutlass_scaled_mm falls back to a Triton scaled_mm kernel for
+ every FP8 linear layer (QKV, attn output, gate_up_proj, down_proj),
+ which is dramatically slower than the native CUTLASS FP8 tensor-core
+ path on H100/H200 GPUs.
+
+ Weight tensors (b) are constant across forward passes, so padded
+ versions are computed once and cached by data_ptr to avoid repeated
+ allocation and column-major conversion overhead.
+ """
+ _orig_cutlass_scaled_mm = ops.cutlass_scaled_mm
+ # Cache: data_ptr → (padded_b, padded_bias, padded_scale_b, pad_k, pad_n, orig_n)
+ _weight_cache: dict[int, tuple] = {}
+
+ def _padded_cutlass_scaled_mm(
+ a: torch.Tensor,
+ b: torch.Tensor,
+ scale_a: torch.Tensor,
+ scale_b: torch.Tensor,
+ out_dtype: torch.dtype,
+ bias: torch.Tensor | None = None,
+ ) -> torch.Tensor:
+ if b.shape[0] % 16 == 0 and b.shape[1] % 16 == 0:
+ return _orig_cutlass_scaled_mm(a, b, scale_a, scale_b, out_dtype, bias)
+
+ # Reshape to 2D (mirrors the original function)
+ target_shape = (*a.shape[:-1], b.shape[1])
+ a = a.view(-1, a.shape[-1])
+ orig_n = b.shape[1]
+
+ # Cache the padded weight — it's a model parameter that never changes.
+ key = b.data_ptr()
+ if key not in _weight_cache:
+ pad_k = (16 - b.shape[0] % 16) % 16
+ pad_n = (16 - orig_n % 16) % 16
+ b_pad = b
+ if pad_k > 0:
+ b_pad = F.pad(b_pad, (0, 0, 0, pad_k))
+ if pad_n > 0:
+ b_pad = F.pad(b_pad, (0, pad_n))
+ # CUTLASS requires b column-major (stride(0)==1).
+ b_pad = b_pad.t().contiguous().t()
+
+ bias_pad = None
+ if bias is not None and pad_n > 0:
+ bias_pad = F.pad(bias, (0, pad_n))
+
+ scale_b_pad = scale_b
+ if scale_b.numel() > 1 and pad_n > 0:
+ scale_b_pad = F.pad(
+ scale_b.view(-1, scale_b.shape[-1]),
+ (0, pad_n),
+ value=1.0,
+ )
+
+ _weight_cache[key] = (
+ b_pad,
+ bias_pad,
+ scale_b_pad,
+ pad_k,
+ pad_n,
+ orig_n,
+ )
+
+ b_pad, bias_pad, scale_b_pad, pad_k, pad_n, orig_n = _weight_cache[key]
+
+ # Pad activations on K dimension (cheap — activations are small).
+ if pad_k > 0:
+ a = F.pad(a, (0, pad_k)).contiguous()
+
+ out = torch.empty((a.shape[0], b_pad.shape[1]), dtype=out_dtype, device=a.device)
+ torch.ops._C.cutlass_scaled_mm(
+ out,
+ a,
+ b_pad,
+ scale_a,
+ scale_b_pad,
+ bias_pad if bias is not None else None,
+ )
+
+ if pad_n > 0:
+ out = out[:, :orig_n]
+
+ return out.view(*target_shape)
+
+ ops.cutlass_scaled_mm = _padded_cutlass_scaled_mm
+ logger.info(
+ "Patched vllm._custom_ops.cutlass_scaled_mm with CUTLASS-padded FP8 "
+ "variant (avoids slow Triton fallback for non-%%16 dimensions)"
+ )
+
+
+_patch_cutlass_padded_fp8()
+
+
class OmniGen2Attention(nn.Module):
def __init__(
self,
@@ -31,6 +133,8 @@ class OmniGen2Attention(nn.Module):
num_heads: int,
num_kv_heads: int,
eps: float = 1e-5,
+ quant_config: QuantizationConfig | None = None,
+ prefix: str = "",
):
super().__init__()
self.dim = dim
@@ -46,12 +150,26 @@ class OmniGen2Attention(nn.Module):
total_num_kv_heads=num_kv_heads,
disable_tp=True,
bias=False,
+ quant_config=quant_config,
+ prefix=f"{prefix}.to_qkv",
)
self.norm_q = RMSNorm(self.head_dim, eps=eps)
self.norm_k = RMSNorm(self.head_dim, eps=eps)
- self.to_out = nn.ModuleList([nn.Linear(dim, dim, bias=False)])
+ self.to_out = nn.ModuleList(
+ [
+ RowParallelLinear(
+ dim,
+ dim,
+ bias=False,
+ input_is_parallel=False,
+ quant_config=quant_config,
+ return_bias=False,
+ prefix=f"{prefix}.to_out.0",
+ )
+ ]
+ )
self.attn = Attention(
num_heads=num_heads,
head_size=self.head_dim,
@@ -78,6 +196,9 @@ class OmniGen2Attention(nn.Module):
"""
batch_size = hidden_states.shape[0]
+ # Contiguous layout for FP8 quantized linear GEMMs (matches FLUX DiT).
+ hidden_states = hidden_states.contiguous()
+
# Get Query-Key-Value Pair
qkv, _ = self.to_qkv(hidden_states)
@@ -121,7 +242,7 @@ class OmniGen2Attention(nn.Module):
hidden_states = hidden_states.reshape(batch_size, -1, self.num_heads * self.head_dim)
hidden_states = hidden_states.to(dtype)
- hidden_states = self.to_out[0](hidden_states)
+ hidden_states = self.to_out[0](hidden_states.contiguous())
return hidden_states
@@ -233,6 +354,7 @@ class LuminaRMSNormZero(nn.Module):
embedding_dim: int,
norm_eps: float,
norm_elementwise_affine: bool,
+ **kwargs,
):
super().__init__()
self.silu = nn.SiLU()
@@ -325,6 +447,8 @@ class LuminaFeedForward(nn.Module):
inner_dim: int,
multiple_of: int | None = 256,
ffn_dim_multiplier: float | None = None,
+ quant_config: QuantizationConfig | None = None,
+ prefix: str = "",
):
super().__init__()
@@ -338,6 +462,8 @@ class LuminaFeedForward(nn.Module):
[inner_dim, inner_dim],
bias=False,
return_bias=False,
+ quant_config=quant_config,
+ prefix=f"{prefix}.gate_up_proj",
)
self.act_fn = get_act_and_mul_fn("silu")
self.down_proj = RowParallelLinear(
@@ -346,6 +472,8 @@ class LuminaFeedForward(nn.Module):
bias=False,
input_is_parallel=True,
return_bias=False,
+ quant_config=quant_config,
+ prefix=f"{prefix}.down_proj",
)
def forward(self, x):
@@ -591,6 +719,8 @@ class OmniGen2TransformerBlock(nn.Module):
ffn_dim_multiplier: float,
norm_eps: float,
modulation: bool = True,
+ quant_config: QuantizationConfig | None = None,
+ prefix: str = "",
) -> None:
"""Initialize the transformer block."""
super().__init__()
@@ -602,6 +732,8 @@ class OmniGen2TransformerBlock(nn.Module):
num_heads=num_attention_heads,
num_kv_heads=num_kv_heads,
eps=1e-5,
+ quant_config=quant_config,
+ prefix=f"{prefix}.attn",
)
# Initialize feed-forward network
@@ -610,11 +742,19 @@ class OmniGen2TransformerBlock(nn.Module):
inner_dim=4 * dim,
multiple_of=multiple_of,
ffn_dim_multiplier=ffn_dim_multiplier,
+ quant_config=quant_config,
+ prefix=f"{prefix}.feed_forward",
)
# Initialize normalization layers
if modulation:
- self.norm1 = LuminaRMSNormZero(embedding_dim=dim, norm_eps=norm_eps, norm_elementwise_affine=True)
+ self.norm1 = LuminaRMSNormZero(
+ embedding_dim=dim,
+ norm_eps=norm_eps,
+ norm_elementwise_affine=True,
+ quant_config=quant_config,
+ prefix=f"{prefix}.norm1",
+ )
else:
self.norm1 = RMSNorm(dim, eps=norm_eps)
@@ -713,6 +853,7 @@ class OmniGen2Transformer2DModel(nn.Module):
axes_lens: tuple[int, int, int] = (1024, 1664, 1664),
text_feat_dim: int = 2048,
timestep_scale: float = 1000.0,
+ quant_config: QuantizationConfig | None = None,
) -> None:
"""Initialize the OmniGen2 transformer model."""
super().__init__()
@@ -770,8 +911,10 @@ class OmniGen2Transformer2DModel(nn.Module):
ffn_dim_multiplier,
norm_eps,
modulation=True,
+ quant_config=quant_config,
+ prefix=f"noise_refiner.{i}",
)
- for _ in range(num_refiner_layers)
+ for i in range(num_refiner_layers)
]
)
@@ -785,8 +928,10 @@ class OmniGen2Transformer2DModel(nn.Module):
ffn_dim_multiplier,
norm_eps,
modulation=True,
+ quant_config=quant_config,
+ prefix=f"ref_image_refiner.{i}",
)
- for _ in range(num_refiner_layers)
+ for i in range(num_refiner_layers)
]
)
@@ -800,8 +945,10 @@ class OmniGen2Transformer2DModel(nn.Module):
ffn_dim_multiplier,
norm_eps,
modulation=False,
+ quant_config=quant_config,
+ prefix=f"context_refiner.{i}",
)
- for _ in range(num_refiner_layers)
+ for i in range(num_refiner_layers)
]
)
@@ -816,8 +963,10 @@ class OmniGen2Transformer2DModel(nn.Module):
ffn_dim_multiplier,
norm_eps,
modulation=True,
+ quant_config=quant_config,
+ prefix=f"layers.{i}",
)
- for _ in range(num_layers)
+ for i in range(num_layers)
]
)
@@ -847,11 +996,25 @@ class OmniGen2Transformer2DModel(nn.Module):
temb,
):
batch_size = len(hidden_states)
+ has_ref_tokens = any(ref_img_len > 0 for ref_lens in l_effective_ref_img_len for ref_img_len in ref_lens)
max_combined_img_len = max(
[img_len + sum(ref_img_len) for img_len, ref_img_len in zip(l_effective_img_len, l_effective_ref_img_len)]
)
hidden_states = self.x_embedder(hidden_states)
+ if not has_ref_tokens:
+ # FP8 kernels do not support zero-token GEMM on ref_image_patch_embedder; skip that path only.
+ # Still run noise_refiner and return the same combined layout as the no-ref case below
+ # (batch, max_combined_img_len, hidden) — not raw noise tokens alone.
+ for layer in self.noise_refiner:
+ hidden_states = layer(hidden_states, padded_img_mask, noise_rotary_emb, temb)
+ combined_img_hidden_states = hidden_states.new_zeros(
+ batch_size, max_combined_img_len, self.config.hidden_size
+ )
+ for i, img_len in enumerate(l_effective_img_len):
+ combined_img_hidden_states[i, :img_len] = hidden_states[i, :img_len]
+ return combined_img_hidden_states
+
ref_image_hidden_states = self.ref_image_patch_embedder(ref_image_hidden_states)
for i in range(batch_size):
diff --git a/vllm_omni/diffusion/models/omnigen2/pipeline_omnigen2.py b/vllm_omni/diffusion/models/omnigen2/pipeline_omnigen2.py
index e8e307b87..04720c932 100644
--- a/vllm_omni/diffusion/models/omnigen2/pipeline_omnigen2.py
+++ b/vllm_omni/diffusion/models/omnigen2/pipeline_omnigen2.py
@@ -676,7 +676,10 @@ class OmniGen2Pipeline(CFGParallelMixin, nn.Module):
)
transformer_kwargs = get_transformer_config_kwargs(od_config.tf_model_config, OmniGen2Transformer2DModel)
- self.transformer = OmniGen2Transformer2DModel(**transformer_kwargs)
+ self.transformer = OmniGen2Transformer2DModel(
+ **transformer_kwargs,
+ quant_config=od_config.quantization_config,
+ )
self.mllm = Qwen2_5_VLForConditionalGeneration.from_pretrained(
model, subfolder="mllm", local_files_only=local_files_only
).to(self.device)
@@ -1253,8 +1256,6 @@ class OmniGen2Pipeline(CFGParallelMixin, nn.Module):
# broadcast to batch dimension in a way that's compatible with ONNX/Core ML
timestep = t.expand(latents.shape[0]).to(latents.dtype)
- batch_size, num_channels_latents, height, width = latents.shape
-
optional_kwargs = {}
if "ref_image_hidden_states" in set(inspect.signature(self.transformer.forward).parameters.keys()):
optional_kwargs["ref_image_hidden_states"] = ref_image_hidden_states
diff --git a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py
index 9f75c8453..9ef0cacd5 100644
--- a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py
+++ b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py
@@ -34,6 +34,9 @@ from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import (
)
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
+from vllm_omni.diffusion.utils.prompt_utils import (
+ validate_prompt_sequence_lengths,
+)
from vllm_omni.diffusion.utils.size_utils import (
normalize_min_aligned_size,
)
@@ -363,8 +366,10 @@ class QwenImagePipeline(nn.Module, QwenImageCFGParallelMixin, DiffusionPipelineP
"that was used to generate `negative_prompt_embeds`."
)
- if max_sequence_length is not None and max_sequence_length > 1024:
- raise ValueError(f"`max_sequence_length` cannot be greater than 1024 but is {max_sequence_length}")
+ if max_sequence_length is not None and max_sequence_length > self.tokenizer_max_length:
+ raise ValueError(
+ f"`max_sequence_length` cannot be greater than {self.tokenizer_max_length} but is {max_sequence_length}"
+ )
def _extract_masked_hidden(self, hidden_states: torch.Tensor, mask: torch.Tensor):
bool_mask = mask.bool()
@@ -378,6 +383,8 @@ class QwenImagePipeline(nn.Module, QwenImageCFGParallelMixin, DiffusionPipelineP
self,
prompt: str | list[str] = None,
dtype: torch.dtype | None = None,
+ max_sequence_length: int | None = None,
+ prompt_name: str = "prompt",
):
dtype = dtype or self.text_encoder.dtype
@@ -388,12 +395,27 @@ class QwenImagePipeline(nn.Module, QwenImageCFGParallelMixin, DiffusionPipelineP
txt = [template.format(e) for e in prompt]
txt_tokens = self.tokenizer(
txt,
- max_length=self.tokenizer_max_length + drop_idx,
padding=True,
- truncation=True,
+ truncation=False,
return_tensors="pt",
).to(self.device)
- # print(f"attention mask: {txt_tokens.attention_mask}")
+ # Validate only the user prompt contribution. The Qwen template also
+ # adds a fixed suffix after the user text, so subtracting only
+ # prompt_template_encode_start_idx would overcount near-limit prompts.
+ template_tokens = self.tokenizer(
+ [template.format("")],
+ padding=True,
+ truncation=False,
+ return_tensors="pt",
+ ).to(self.device)
+ validate_prompt_sequence_lengths(
+ txt_tokens.attention_mask,
+ max_sequence_length=max_sequence_length or self.tokenizer_max_length,
+ supported_max_sequence_length=self.tokenizer_max_length,
+ prompt_name=prompt_name,
+ baseline_attention_mask=template_tokens.attention_mask,
+ error_context="after applying the Qwen prompt template",
+ )
encoder_hidden_states = self.text_encoder(
input_ids=txt_tokens.input_ids,
attention_mask=txt_tokens.attention_mask,
@@ -422,6 +444,7 @@ class QwenImagePipeline(nn.Module, QwenImageCFGParallelMixin, DiffusionPipelineP
prompt_embeds: torch.Tensor | None = None,
prompt_embeds_mask: torch.Tensor | None = None,
max_sequence_length: int = 1024,
+ prompt_name: str = "prompt",
):
r"""
@@ -439,7 +462,11 @@ class QwenImagePipeline(nn.Module, QwenImageCFGParallelMixin, DiffusionPipelineP
batch_size = len(prompt) if prompt_embeds is None else prompt_embeds.shape[0]
if prompt_embeds is None:
- prompt_embeds, prompt_embeds_mask = self._get_qwen_prompt_embeds(prompt)
+ prompt_embeds, prompt_embeds_mask = self._get_qwen_prompt_embeds(
+ prompt,
+ max_sequence_length=max_sequence_length,
+ prompt_name=prompt_name,
+ )
prompt_embeds = prompt_embeds[:, :max_sequence_length]
prompt_embeds_mask = prompt_embeds_mask[:, :max_sequence_length]
@@ -632,6 +659,7 @@ class QwenImagePipeline(nn.Module, QwenImageCFGParallelMixin, DiffusionPipelineP
prompt_embeds_mask=negative_prompt_embeds_mask,
num_images_per_prompt=num_images_per_prompt,
max_sequence_length=max_sequence_length,
+ prompt_name="negative_prompt",
)
else:
negative_prompt_embeds = None
@@ -703,7 +731,7 @@ class QwenImagePipeline(nn.Module, QwenImageCFGParallelMixin, DiffusionPipelineP
num_images_per_prompt=sampling.num_outputs_per_prompt if sampling.num_outputs_per_prompt > 0 else 1,
generator=sampling.generator,
true_cfg_scale=sampling.true_cfg_scale or 4.0,
- max_sequence_length=sampling.max_sequence_length or 512,
+ max_sequence_length=sampling.max_sequence_length or self.tokenizer_max_length,
attention_kwargs=kwargs.get("attention_kwargs"),
)
@@ -934,7 +962,7 @@ class QwenImagePipeline(nn.Module, QwenImageCFGParallelMixin, DiffusionPipelineP
output_type: str | None = "pil",
attention_kwargs: dict[str, Any] | None = None,
callback_on_step_end_tensor_inputs: list[str] = ["latents"],
- max_sequence_length: int = 512,
+ max_sequence_length: int = 1024,
) -> DiffusionOutput:
extracted_prompt, negative_prompt = self._extract_prompts(req.prompts)
prompt = extracted_prompt or prompt
diff --git a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py
index dd77d71b1..cef7fe473 100644
--- a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py
+++ b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py
@@ -37,6 +37,9 @@ from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import (
)
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
+from vllm_omni.diffusion.utils.prompt_utils import (
+ validate_prompt_sequence_lengths,
+)
from vllm_omni.diffusion.utils.size_utils import (
normalize_min_aligned_size,
)
@@ -323,8 +326,10 @@ class QwenImageEditPipeline(nn.Module, SupportImageInput, QwenImageCFGParallelMi
"that was used to generate `negative_prompt_embeds`."
)
- if max_sequence_length is not None and max_sequence_length > 1024:
- raise ValueError(f"`max_sequence_length` cannot be greater than 1024 but is {max_sequence_length}")
+ if max_sequence_length is not None and max_sequence_length > self.tokenizer_max_length:
+ raise ValueError(
+ f"`max_sequence_length` cannot be greater than {self.tokenizer_max_length} but is {max_sequence_length}"
+ )
def _extract_masked_hidden(self, hidden_states: torch.Tensor, mask: torch.Tensor):
bool_mask = mask.bool()
@@ -384,6 +389,8 @@ class QwenImageEditPipeline(nn.Module, SupportImageInput, QwenImageCFGParallelMi
prompt: str | list[str] = None,
image: PIL.Image.Image | torch.Tensor | None = None,
dtype: torch.dtype | None = None,
+ max_sequence_length: int | None = None,
+ prompt_name: str = "prompt",
):
"""Get prompt embeddings with image support for editing."""
dtype = dtype or self.text_encoder.dtype
@@ -393,6 +400,33 @@ class QwenImageEditPipeline(nn.Module, SupportImageInput, QwenImageCFGParallelMi
template = self.prompt_template_encode
drop_idx = self.prompt_template_encode_start_idx
txt = [template.format(e) for e in prompt]
+ txt_tokens = self.tokenizer(
+ txt,
+ padding=True,
+ truncation=False,
+ return_tensors="pt",
+ ).to(self.device)
+ # The edit template contains fixed multimodal scaffolding around the
+ # instruction. Validate against the empty-template baseline so image
+ # placeholder text does not consume the user's text budget.
+ template_tokens = self.tokenizer(
+ [template.format("")],
+ padding=True,
+ truncation=False,
+ return_tensors="pt",
+ ).to(self.device)
+ # Qwen-Image-Edit expands image placeholders into many vision tokens
+ # inside the processor. `max_sequence_length` is meant to constrain the
+ # prompt text length, so validate on the text template before image
+ # token expansion.
+ validate_prompt_sequence_lengths(
+ txt_tokens.attention_mask,
+ max_sequence_length=max_sequence_length or self.tokenizer_max_length,
+ supported_max_sequence_length=self.tokenizer_max_length,
+ prompt_name=prompt_name,
+ baseline_attention_mask=template_tokens.attention_mask,
+ error_context="after applying the Qwen prompt template",
+ )
# Use processor to handle both text and image inputs
model_inputs = self.processor(
@@ -434,6 +468,7 @@ class QwenImageEditPipeline(nn.Module, SupportImageInput, QwenImageCFGParallelMi
prompt_embeds: torch.Tensor | None = None,
prompt_embeds_mask: torch.Tensor | None = None,
max_sequence_length: int = 1024,
+ prompt_name: str = "prompt",
):
r"""
@@ -453,7 +488,12 @@ class QwenImageEditPipeline(nn.Module, SupportImageInput, QwenImageCFGParallelMi
batch_size = len(prompt) if prompt_embeds is None else prompt_embeds.shape[0]
if prompt_embeds is None:
- prompt_embeds, prompt_embeds_mask = self._get_qwen_prompt_embeds(prompt, image)
+ prompt_embeds, prompt_embeds_mask = self._get_qwen_prompt_embeds(
+ prompt,
+ image,
+ max_sequence_length=max_sequence_length,
+ prompt_name=prompt_name,
+ )
_, seq_len, _ = prompt_embeds.shape
prompt_embeds = prompt_embeds.repeat(1, num_images_per_prompt, 1)
@@ -624,7 +664,7 @@ class QwenImageEditPipeline(nn.Module, SupportImageInput, QwenImageCFGParallelMi
output_type: str | None = "pil",
attention_kwargs: dict[str, Any] | None = None,
callback_on_step_end_tensor_inputs: list[str] = ["latents"],
- max_sequence_length: int = 512,
+ max_sequence_length: int = 1024,
) -> DiffusionOutput:
"""Forward pass for image editing."""
# TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "")
@@ -739,6 +779,7 @@ class QwenImageEditPipeline(nn.Module, SupportImageInput, QwenImageCFGParallelMi
prompt_embeds_mask=negative_prompt_embeds_mask,
num_images_per_prompt=num_images_per_prompt,
max_sequence_length=max_sequence_length,
+ prompt_name="negative_prompt",
)
num_channels_latents = self.transformer.in_channels // 4
diff --git a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit_plus.py b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit_plus.py
index 6f6c9d2ba..2e25d0fe6 100644
--- a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit_plus.py
+++ b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit_plus.py
@@ -25,6 +25,7 @@ from vllm.model_executor.models.utils import AutoWeightsLoader
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
+from vllm_omni.diffusion.model_metadata import QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES
from vllm_omni.diffusion.models.interface import SupportImageInput
from vllm_omni.diffusion.models.qwen_image.cfg_parallel import (
QwenImageCFGParallelMixin,
@@ -40,6 +41,9 @@ from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import (
)
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
+from vllm_omni.diffusion.utils.prompt_utils import (
+ validate_prompt_sequence_lengths,
+)
from vllm_omni.diffusion.utils.size_utils import (
normalize_min_aligned_size,
)
@@ -53,6 +57,12 @@ logger = logging.getLogger(__name__)
CONDITION_IMAGE_SIZE = 384 * 384
VAE_IMAGE_SIZE = 1024 * 1024
+# Keep this in sync with the practical conditioning-token budget for
+# Qwen-Image-Edit-2511. Empirically, 4 images stays within the supported range
+# while 5 images overflows the prompt/conditioning path and fails downstream.
+# Re-export the shared metadata value locally so this pipeline keeps a nearby,
+# descriptive constant for validation and tests without becoming the source of truth.
+MAX_QWEN_IMAGE_EDIT_PLUS_INPUT_IMAGES = QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES
def get_qwen_image_edit_plus_pre_process_func(
@@ -90,6 +100,11 @@ def get_qwen_image_edit_plus_pre_process_func(
if not isinstance(raw_image, list):
raw_image = [raw_image]
+ if len(raw_image) > MAX_QWEN_IMAGE_EDIT_PLUS_INPUT_IMAGES:
+ raise ValueError(
+ f"Received {len(raw_image)} input images. "
+ f"At most {MAX_QWEN_IMAGE_EDIT_PLUS_INPUT_IMAGES} images are supported by this model."
+ )
image = [
PIL.Image.open(im) if isinstance(im, str) else cast(PIL.Image.Image | np.ndarray | torch.Tensor, im)
for im in raw_image
@@ -283,8 +298,10 @@ class QwenImageEditPlusPipeline(
"that was used to generate `negative_prompt_embeds`."
)
- if max_sequence_length is not None and max_sequence_length > 1024:
- raise ValueError(f"`max_sequence_length` cannot be greater than 1024 but is {max_sequence_length}")
+ if max_sequence_length is not None and max_sequence_length > self.tokenizer_max_length:
+ raise ValueError(
+ f"`max_sequence_length` cannot be greater than {self.tokenizer_max_length} but is {max_sequence_length}"
+ )
def _extract_masked_hidden(self, hidden_states: torch.Tensor, mask: torch.Tensor):
bool_mask = mask.bool()
@@ -299,6 +316,8 @@ class QwenImageEditPlusPipeline(
prompt: str | list[str],
image: list[torch.Tensor] | torch.Tensor | None = None,
dtype: torch.dtype | None = None,
+ max_sequence_length: int | None = None,
+ prompt_name: str = "prompt",
):
"""Get prompt embeddings with support for multiple images."""
dtype = dtype or self.text_encoder.dtype
@@ -319,6 +338,32 @@ class QwenImageEditPlusPipeline(
template = self.prompt_template_encode
drop_idx = self.prompt_template_encode_start_idx
txt = [template.format(base_img_prompt + e) for e in prompt]
+ txt_tokens = self.tokenizer(
+ txt,
+ padding=True,
+ truncation=False,
+ return_tensors="pt",
+ ).to(self.device)
+ # Multi-image edit prepends "Picture N" placeholders before the user
+ # instruction. Subtract the placeholder-aware baseline so attached
+ # images do not reduce the remaining prompt budget.
+ template_tokens = self.tokenizer(
+ [template.format(base_img_prompt)],
+ padding=True,
+ truncation=False,
+ return_tensors="pt",
+ ).to(self.device)
+ # The processor expands image placeholders into many vision tokens.
+ # `max_sequence_length` should guard the prompt text length before that
+ # multimodal expansion happens.
+ validate_prompt_sequence_lengths(
+ txt_tokens.attention_mask,
+ max_sequence_length=max_sequence_length or self.tokenizer_max_length,
+ supported_max_sequence_length=self.tokenizer_max_length,
+ prompt_name=prompt_name,
+ baseline_attention_mask=template_tokens.attention_mask,
+ error_context="after applying the Qwen prompt template",
+ )
# Use processor to handle both text and image inputs
model_inputs = self.processor(
@@ -360,6 +405,7 @@ class QwenImageEditPlusPipeline(
prompt_embeds: torch.Tensor | None = None,
prompt_embeds_mask: torch.Tensor | None = None,
max_sequence_length: int = 1024,
+ prompt_name: str = "prompt",
):
r"""
@@ -379,7 +425,12 @@ class QwenImageEditPlusPipeline(
batch_size = len(prompt) if prompt_embeds is None else prompt_embeds.shape[0]
if prompt_embeds is None:
- prompt_embeds, prompt_embeds_mask = self._get_qwen_prompt_embeds(prompt, image)
+ prompt_embeds, prompt_embeds_mask = self._get_qwen_prompt_embeds(
+ prompt,
+ image,
+ max_sequence_length=max_sequence_length,
+ prompt_name=prompt_name,
+ )
_, seq_len, _ = prompt_embeds.shape
prompt_embeds = prompt_embeds.repeat(1, num_images_per_prompt, 1)
@@ -557,7 +608,7 @@ class QwenImageEditPlusPipeline(
output_type: str | None = "pil",
attention_kwargs: dict[str, Any] | None = None,
callback_on_step_end_tensor_inputs: list[str] = ["latents"],
- max_sequence_length: int = 512,
+ max_sequence_length: int = 1024,
) -> DiffusionOutput:
"""Forward pass for image editing with support for multiple images."""
# TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "")
@@ -692,6 +743,7 @@ class QwenImageEditPlusPipeline(
prompt_embeds_mask=negative_prompt_embeds_mask,
num_images_per_prompt=num_images_per_prompt,
max_sequence_length=max_sequence_length,
+ prompt_name="negative_prompt",
)
num_channels_latents = self.transformer.in_channels // 4
diff --git a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_layered.py b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_layered.py
index 38866d89c..905ef5b42 100644
--- a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_layered.py
+++ b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_layered.py
@@ -36,6 +36,9 @@ from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import (
)
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
+from vllm_omni.diffusion.utils.prompt_utils import (
+ validate_prompt_sequence_lengths,
+)
from vllm_omni.diffusion.utils.size_utils import (
normalize_min_aligned_size,
)
@@ -340,8 +343,10 @@ the image\n<|vision_start|><|image_pad|><|vision_end|><|im_end|>\n<|im_start|>as
"generate `negative_prompt_embeds`."
)
- if max_sequence_length is not None and max_sequence_length > 1024:
- raise ValueError(f"`max_sequence_length` cannot be greater than 1024 but is {max_sequence_length}")
+ if max_sequence_length is not None and max_sequence_length > self.tokenizer_max_length:
+ raise ValueError(
+ f"`max_sequence_length` cannot be greater than {self.tokenizer_max_length} but is {max_sequence_length}"
+ )
def _extract_masked_hidden(self, hidden_states: torch.Tensor, mask: torch.Tensor):
bool_mask = mask.bool()
@@ -356,6 +361,8 @@ the image\n<|vision_start|><|image_pad|><|vision_end|><|im_end|>\n<|im_start|>as
prompt: str | list[str] | None = None,
device: torch.device | None = None,
dtype: torch.dtype | None = None,
+ max_sequence_length: int | None = None,
+ prompt_name: str = "prompt",
):
device = device or self.device
dtype = dtype or self.text_encoder.dtype
@@ -368,8 +375,26 @@ the image\n<|vision_start|><|image_pad|><|vision_end|><|im_end|>\n<|im_start|>as
txt_tokens = self.tokenizer(
txt,
padding=True,
+ truncation=False,
return_tensors="pt",
).to(device)
+ # The layered template also appends fixed non-user tokens after the
+ # editable text, so use the empty-template tokenized baseline instead of
+ # counting everything after prompt_template_encode_start_idx.
+ template_tokens = self.tokenizer(
+ [template.format("")],
+ padding=True,
+ truncation=False,
+ return_tensors="pt",
+ ).to(device)
+ validate_prompt_sequence_lengths(
+ txt_tokens.attention_mask,
+ max_sequence_length=max_sequence_length or self.tokenizer_max_length,
+ supported_max_sequence_length=self.tokenizer_max_length,
+ prompt_name=prompt_name,
+ baseline_attention_mask=template_tokens.attention_mask,
+ error_context="after applying the Qwen prompt template",
+ )
encoder_hidden_states = self.text_encoder(
input_ids=txt_tokens.input_ids,
attention_mask=txt_tokens.attention_mask,
@@ -399,6 +424,7 @@ the image\n<|vision_start|><|image_pad|><|vision_end|><|im_end|>\n<|im_start|>as
prompt_embeds: torch.Tensor | None = None,
prompt_embeds_mask: torch.Tensor | None = None,
max_sequence_length: int = 1024,
+ prompt_name: str = "prompt",
):
r"""
@@ -419,7 +445,11 @@ the image\n<|vision_start|><|image_pad|><|vision_end|><|im_end|>\n<|im_start|>as
batch_size = len(prompt) if prompt_embeds is None else prompt_embeds.shape[0]
if prompt_embeds is None:
- prompt_embeds, prompt_embeds_mask = self._get_qwen_prompt_embeds(prompt)
+ prompt_embeds, prompt_embeds_mask = self._get_qwen_prompt_embeds(
+ prompt,
+ max_sequence_length=max_sequence_length,
+ prompt_name=prompt_name,
+ )
prompt_embeds = prompt_embeds[:, :max_sequence_length]
prompt_embeds_mask = prompt_embeds_mask[:, :max_sequence_length]
@@ -603,7 +633,7 @@ the image\n<|vision_start|><|image_pad|><|vision_end|><|im_end|>\n<|im_start|>as
negative_prompt_embeds_mask: torch.Tensor | None = None,
output_type: str | None = "pil",
attention_kwargs: dict[str, Any] | None = None,
- max_sequence_length: int = 512,
+ max_sequence_length: int = 1024,
resolution: int = 640,
cfg_normalize: bool = False,
use_en_prompt: bool = False,
@@ -736,6 +766,7 @@ the image\n<|vision_start|><|image_pad|><|vision_end|><|im_end|>\n<|im_start|>as
device=self.device,
num_images_per_prompt=num_images_per_prompt,
max_sequence_length=max_sequence_length,
+ prompt_name="negative_prompt",
)
# 4. Prepare latent variables
diff --git a/vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py b/vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py
index b34f19e95..88a66d7f6 100644
--- a/vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py
+++ b/vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py
@@ -169,12 +169,15 @@ class QwenTimestepProjEmbeddings(nn.Module):
self.time_proj = Timesteps(num_channels=256, flip_sin_to_cos=True, downscale_freq_shift=0, scale=1000)
self.timestep_embedder = TimestepEmbedding(in_channels=256, time_embed_dim=embedding_dim)
+ # Time embedding MLP is kept full precision (quant_config=None) —
+ # small layers that feed per-block modulation; precision-sensitive
+ # (see #2728).
self.timestep_embedder.linear_1 = ReplicatedLinear(
256,
embedding_dim,
bias=True,
return_bias=False,
- quant_config=quant_config,
+ quant_config=None,
prefix="timestep_embedder.linear_1",
)
self.timestep_embedder.linear_2 = ReplicatedLinear(
@@ -182,7 +185,7 @@ class QwenTimestepProjEmbeddings(nn.Module):
embedding_dim,
bias=True,
return_bias=False,
- quant_config=quant_config,
+ quant_config=None,
prefix="timestep_embedder.linear_2",
)
self.use_additional_t_cond = use_additional_t_cond
@@ -701,7 +704,10 @@ class QwenImageTransformerBlock(nn.Module):
self.num_attention_heads = num_attention_heads
self.attention_head_dim = attention_head_dim
- # Image processing modules
+ # Image processing modules.
+ # Modulation linear is kept full precision (quant_config=None) — it
+ # produces shift/scale/gate values that are precision-sensitive
+ # (see #2728).
self.img_mod = nn.Sequential(
nn.SiLU(),
ReplicatedLinear(
@@ -709,7 +715,7 @@ class QwenImageTransformerBlock(nn.Module):
6 * dim,
bias=True,
return_bias=False,
- quant_config=quant_config,
+ quant_config=None,
prefix="img_mod.1",
),
)
@@ -725,7 +731,7 @@ class QwenImageTransformerBlock(nn.Module):
self.img_norm2 = AdaLayerNorm(dim, elementwise_affine=False, eps=eps)
self.img_mlp = FeedForward(dim=dim, dim_out=dim, quant_config=quant_config, prefix="img_mlp")
- # Text processing modules
+ # Text processing modules.
self.txt_mod = nn.Sequential(
nn.SiLU(),
ReplicatedLinear(
@@ -733,7 +739,7 @@ class QwenImageTransformerBlock(nn.Module):
6 * dim,
bias=True,
return_bias=False,
- quant_config=quant_config,
+ quant_config=None,
prefix="txt_mod.1",
),
)
@@ -744,9 +750,9 @@ class QwenImageTransformerBlock(nn.Module):
self.zero_cond_t = zero_cond_t
- def _modulate(self, x, mod_params, index=None):
+ def _modulate(self, mod_params, index=None):
"""Apply modulation to input tensor"""
- # x: b l d, shift: b d, scale: b d, gate: b d
+ # shift: b d, scale: b d, gate: b d
shift, scale, gate = mod_params.chunk(3, dim=-1)
if index is not None:
@@ -778,7 +784,7 @@ class QwenImageTransformerBlock(nn.Module):
scale_result = scale.unsqueeze(1)
gate_result = gate.unsqueeze(1)
- return x * (1 + scale_result) + shift_result, gate_result
+ return scale_result, shift_result, gate_result
def forward(
self,
@@ -804,10 +810,12 @@ class QwenImageTransformerBlock(nn.Module):
txt_mod1, txt_mod2 = txt_mod_params.chunk(2, dim=-1) # Each [B, 3*dim]
# Process image stream - norm1 + modulation
- img_modulated, img_gate1 = self.img_norm1(hidden_states, img_mod1, modulate_index)
+ img_scale1, img_shift1, img_gate1 = self._modulate(img_mod1, modulate_index)
+ img_modulated = self.img_norm1(hidden_states, img_scale1, img_shift1)
# Process text stream - norm1 + modulation
- txt_modulated, txt_gate1 = self.txt_norm1(encoder_hidden_states, txt_mod1)
+ txt_scale1, txt_shift1, txt_gate1 = self._modulate(txt_mod1)
+ txt_modulated = self.txt_norm1(encoder_hidden_states, txt_scale1, txt_shift1)
# Use QwenAttnProcessor2_0 for joint attention computation
# This directly implements the DoubleStreamLayerMegatron logic:
@@ -832,13 +840,16 @@ class QwenImageTransformerBlock(nn.Module):
encoder_hidden_states = encoder_hidden_states + txt_gate1 * txt_attn_output
# Process image stream - norm2 + MLP
- img_modulated2, img_gate2 = self.img_norm2(hidden_states, img_mod2, modulate_index)
+ img_scale2, img_shift2, img_gate2 = self._modulate(img_mod2, modulate_index)
+ img_modulated2 = self.img_norm2(hidden_states, img_scale2, img_shift2)
img_mlp_output = self.img_mlp(img_modulated2)
hidden_states = hidden_states + img_gate2 * img_mlp_output
# Process text stream - norm2 + MLP
- txt_modulated2, txt_gate2 = self.txt_norm2(encoder_hidden_states, txt_mod2)
+ txt_scale2, txt_shift2, txt_gate2 = self._modulate(txt_mod2)
+ txt_modulated2 = self.txt_norm2(encoder_hidden_states, txt_scale2, txt_shift2)
+
txt_mlp_output = self.txt_mlp(txt_modulated2)
encoder_hidden_states = encoder_hidden_states + txt_gate2 * txt_mlp_output
@@ -958,12 +969,14 @@ class QwenImageTransformer2DModel(CachedTransformer):
self.txt_norm = RMSNorm(joint_attention_dim, eps=1e-6)
+ # Entry projections (image/text) are kept full precision —
+ # small sensitive layers at the network boundary (see #2728).
self.img_in = ReplicatedLinear(
in_channels,
self.inner_dim,
bias=True,
return_bias=False,
- quant_config=quant_config,
+ quant_config=None,
prefix="img_in",
)
self.txt_in = ReplicatedLinear(
@@ -971,7 +984,7 @@ class QwenImageTransformer2DModel(CachedTransformer):
self.inner_dim,
bias=True,
return_bias=False,
- quant_config=quant_config,
+ quant_config=None,
prefix="txt_in",
)
@@ -988,13 +1001,16 @@ class QwenImageTransformer2DModel(CachedTransformer):
]
)
+ # Final modulation and output projection are kept full precision —
+ # they produce the output latent and are precision-sensitive
+ # (see #2728).
self.norm_out = AdaLayerNormContinuous(self.inner_dim, self.inner_dim, elementwise_affine=False, eps=1e-6)
self.norm_out.linear = ReplicatedLinear(
self.inner_dim,
2 * self.inner_dim,
bias=True,
return_bias=False,
- quant_config=quant_config,
+ quant_config=None,
prefix="norm_out.linear",
)
self.proj_out = ReplicatedLinear(
@@ -1002,7 +1018,7 @@ class QwenImageTransformer2DModel(CachedTransformer):
patch_size * patch_size * self.out_channels,
bias=True,
return_bias=False,
- quant_config=quant_config,
+ quant_config=None,
prefix="proj_out",
)
diff --git a/vllm_omni/diffusion/models/schedulers/__init__.py b/vllm_omni/diffusion/models/schedulers/__init__.py
index 6f8df78eb..e683ed272 100644
--- a/vllm_omni/diffusion/models/schedulers/__init__.py
+++ b/vllm_omni/diffusion/models/schedulers/__init__.py
@@ -1,10 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+from vllm_omni.diffusion.models.schedulers.scheduling_dmd2_euler import DMD2EulerScheduler
from vllm_omni.diffusion.models.schedulers.scheduling_flow_unipc_multistep import (
FlowUniPCMultistepScheduler,
)
__all__ = [
+ "DMD2EulerScheduler",
"FlowUniPCMultistepScheduler",
]
diff --git a/vllm_omni/diffusion/models/schedulers/scheduling_dmd2_euler.py b/vllm_omni/diffusion/models/schedulers/scheduling_dmd2_euler.py
new file mode 100644
index 000000000..01447a41d
--- /dev/null
+++ b/vllm_omni/diffusion/models/schedulers/scheduling_dmd2_euler.py
@@ -0,0 +1,23 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+from __future__ import annotations
+
+import torch
+from diffusers import FlowMatchEulerDiscreteScheduler
+
+
+class DMD2EulerScheduler(FlowMatchEulerDiscreteScheduler):
+ """Euler scheduler that always uses the fixed DMD2 training timestep schedule."""
+
+ def __init__(self, *args, dmd2_timesteps: list[int], **kwargs):
+ super().__init__(*args, **kwargs)
+ self._dmd2_timesteps = dmd2_timesteps
+
+ def set_timesteps(
+ self,
+ num_inference_steps: int | None = None,
+ device: str | torch.device | None = None,
+ **kwargs,
+ ) -> None:
+ super().set_timesteps(timesteps=self._dmd2_timesteps, device=device)
diff --git a/vllm_omni/diffusion/models/stable_audio/stable_audio_transformer.py b/vllm_omni/diffusion/models/stable_audio/stable_audio_transformer.py
index 4a4892673..a3d4dc517 100644
--- a/vllm_omni/diffusion/models/stable_audio/stable_audio_transformer.py
+++ b/vllm_omni/diffusion/models/stable_audio/stable_audio_transformer.py
@@ -17,6 +17,7 @@ from vllm.model_executor.model_loader.weight_utils import default_weight_loader
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.data import OmniDiffusionConfig
+from vllm_omni.diffusion.distributed.hsdp_utils import is_transformer_block_module
logger = init_logger(__name__)
@@ -376,6 +377,7 @@ class StableAudioDiTModel(nn.Module):
"""
_repeated_blocks = ["StableAudioDiTBlock"]
+ _hsdp_shard_conditions = [is_transformer_block_module]
def __init__(
self,
diff --git a/vllm_omni/diffusion/models/utils.py b/vllm_omni/diffusion/models/utils.py
new file mode 100644
index 000000000..ba0d8dda2
--- /dev/null
+++ b/vllm_omni/diffusion/models/utils.py
@@ -0,0 +1,21 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+from __future__ import annotations
+
+import json
+import os
+
+
+def _load_json(model_path: str, filename: str, local_files_only: bool = True) -> dict:
+ """Load a JSON config file from a local path or HuggingFace Hub repo."""
+ if local_files_only:
+ path = os.path.join(model_path, *filename.split("/"))
+ with open(path) as f:
+ return json.load(f)
+ else:
+ from huggingface_hub import hf_hub_download
+
+ cached = hf_hub_download(repo_id=model_path, filename=filename)
+ with open(cached) as f:
+ return json.load(f)
diff --git a/vllm_omni/diffusion/models/wan2_2/__init__.py b/vllm_omni/diffusion/models/wan2_2/__init__.py
index d418001d9..97808df29 100644
--- a/vllm_omni/diffusion/models/wan2_2/__init__.py
+++ b/vllm_omni/diffusion/models/wan2_2/__init__.py
@@ -3,6 +3,7 @@
from .pipeline_wan2_2 import (
Wan22Pipeline,
+ WanT2VDMD2Pipeline,
create_transformer_from_config,
get_wan22_post_process_func,
get_wan22_pre_process_func,
@@ -11,6 +12,7 @@ from .pipeline_wan2_2 import (
)
from .pipeline_wan2_2_i2v import (
Wan22I2VPipeline,
+ WanI2VDMD2Pipeline,
get_wan22_i2v_post_process_func,
get_wan22_i2v_pre_process_func,
)
@@ -28,6 +30,7 @@ from .wan2_2_transformer import WanTransformer3DModel
from .wan2_2_vace_transformer import VaceWanTransformerBlock, WanVACETransformer3DModel
__all__ = [
+ "WanT2VDMD2Pipeline",
"Wan22Pipeline",
"get_wan22_post_process_func",
"get_wan22_pre_process_func",
@@ -35,6 +38,7 @@ __all__ = [
"load_transformer_config",
"create_transformer_from_config",
"Wan22I2VPipeline",
+ "WanI2VDMD2Pipeline",
"get_wan22_i2v_post_process_func",
"get_wan22_i2v_pre_process_func",
"Wan22TI2VPipeline",
diff --git a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py
index 84d89619e..188eb70b2 100644
--- a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py
+++ b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py
@@ -22,10 +22,12 @@ from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import Dist
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
+from vllm_omni.diffusion.models.dmd2 import DMD2PipelineMixin
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin, _is_rank_zero
from vllm_omni.diffusion.models.schedulers import FlowUniPCMultistepScheduler
from vllm_omni.diffusion.models.wan2_2.scheduling_wan_euler import WanEulerScheduler
from vllm_omni.diffusion.models.wan2_2.wan2_2_transformer import WanTransformer3DModel
+from vllm_omni.diffusion.postprocess import interpolate_video_tensor
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniTextPrompt
@@ -162,10 +164,23 @@ def get_wan22_post_process_func(
def post_process_func(
video: torch.Tensor,
output_type: str = "np",
+ sampling_params=None,
):
if output_type == "latent":
return video
- return video_processor.postprocess_video(video, output_type=output_type)
+ custom_output = {}
+ if sampling_params is not None and getattr(sampling_params, "enable_frame_interpolation", False):
+ video, multiplier = interpolate_video_tensor(
+ video,
+ exp=sampling_params.frame_interpolation_exp,
+ scale=sampling_params.frame_interpolation_scale,
+ model_path=sampling_params.frame_interpolation_model_path,
+ )
+ custom_output["video_fps_multiplier"] = multiplier
+ return {
+ "video": video_processor.postprocess_video(video, output_type=output_type),
+ "custom_output": custom_output,
+ }
return post_process_func
@@ -175,9 +190,6 @@ def get_wan22_pre_process_func(
):
"""Pre-process function for Wan2.2: optionally load and resize input image for I2V mode."""
import numpy as np
- from diffusers.video_processor import VideoProcessor
-
- video_processor = VideoProcessor(vae_scale_factor=8)
def pre_process_func(request: OmniDiffusionRequest) -> OmniDiffusionRequest:
for i, prompt in enumerate(request.prompts):
@@ -221,10 +233,6 @@ def get_wan22_pre_process_func(
)
prompt["multi_modal_data"]["image"] = image # type: ignore # key existence already checked above
- # Preprocess for VAE
- prompt["additional_information"]["preprocessed_image"] = video_processor.preprocess(
- image, height=request.sampling_params.height, width=request.sampling_params.width
- )
request.prompts[i] = prompt
return request
@@ -373,6 +381,102 @@ class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, DiffusionPipe
def current_timestep(self):
return self._current_timestep
+ def diffuse(
+ self,
+ latents: torch.Tensor,
+ timesteps: torch.Tensor,
+ prompt_embeds: torch.Tensor,
+ negative_prompt_embeds: torch.Tensor | None,
+ guidance_low: float,
+ guidance_high: float,
+ boundary_timestep: float | None,
+ dtype: torch.dtype,
+ attention_kwargs: dict[str, Any],
+ latent_condition: torch.Tensor | None = None,
+ first_frame_mask: torch.Tensor | None = None,
+ ) -> torch.Tensor:
+ with self.progress_bar(total=len(timesteps)) as pbar:
+ for t in timesteps:
+ self._current_timestep = t
+
+ # Select model based on timestep and boundary_ratio
+ # High noise stage (t >= boundary_timestep): use transformer
+ # Low noise stage (t < boundary_timestep): use transformer_2
+ if boundary_timestep is not None and t < boundary_timestep:
+ # Low noise stage - always use guidance_high for this stage
+ current_guidance_scale = guidance_high
+ if self.transformer_2 is not None:
+ current_model = self.transformer_2
+ elif self.transformer is not None:
+ # Fallback to transformer if transformer_2 not loaded
+ current_model = self.transformer
+ else:
+ raise RuntimeError("No transformer available for low-noise stage")
+ else:
+ # High noise stage - always use guidance_low for this stage
+ current_guidance_scale = guidance_low
+ if self.transformer is not None:
+ current_model = self.transformer
+ elif self.transformer_2 is not None:
+ # Fallback to transformer_2 if transformer not loaded
+ current_model = self.transformer_2
+ else:
+ raise RuntimeError("No transformer available for high-noise stage")
+
+ if self.expand_timesteps and latent_condition is not None:
+ # I2V mode: blend condition with latents using mask
+ latent_model_input = (1 - first_frame_mask) * latent_condition + first_frame_mask * latents
+ latent_model_input = latent_model_input.to(dtype)
+
+ # Expand timesteps per patch - use floor division to match patch embedding
+ patch_size = self.transformer_config.patch_size
+ patch_height = latents.shape[3] // patch_size[1]
+ patch_width = latents.shape[4] // patch_size[2]
+
+ # Create mask at patch resolution (same as hidden states sequence length)
+ patch_mask = first_frame_mask[:, :, :, :: patch_size[1], :: patch_size[2]]
+ patch_mask = patch_mask[:, :, :, :patch_height, :patch_width] # Ensure correct dimensions
+ temp_ts = (patch_mask[0][0] * t).flatten()
+ timestep = temp_ts.unsqueeze(0).expand(latents.shape[0], -1)
+ else:
+ # T2V mode: standard forward
+ latent_model_input = latents.to(dtype)
+ timestep = t.expand(latents.shape[0])
+
+ do_true_cfg = current_guidance_scale > 1.0 and negative_prompt_embeds is not None
+ positive_kwargs = {
+ "hidden_states": latent_model_input,
+ "timestep": timestep,
+ "encoder_hidden_states": prompt_embeds,
+ "attention_kwargs": attention_kwargs,
+ "return_dict": False,
+ "current_model": current_model,
+ }
+ if do_true_cfg:
+ negative_kwargs = {
+ "hidden_states": latent_model_input,
+ "timestep": timestep,
+ "encoder_hidden_states": negative_prompt_embeds,
+ "attention_kwargs": attention_kwargs,
+ "return_dict": False,
+ "current_model": current_model,
+ }
+ else:
+ negative_kwargs = None
+
+ noise_pred = self.predict_noise_maybe_with_cfg(
+ do_true_cfg=do_true_cfg,
+ true_cfg_scale=current_guidance_scale,
+ positive_kwargs=positive_kwargs,
+ negative_kwargs=negative_kwargs,
+ cfg_normalize=False,
+ )
+
+ latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg)
+ pbar.update()
+
+ return latents
+
def forward(
self,
req: OmniDiffusionRequest,
@@ -615,90 +719,19 @@ class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, DiffusionPipe
if DEBUG_PERF:
_t_denoise_start = time.perf_counter()
- with self.progress_bar(total=len(timesteps)) as pbar:
- for t in timesteps:
- self._current_timestep = t
-
- # Select model based on timestep and boundary_ratio
- # High noise stage (t >= boundary_timestep): use transformer
- # Low noise stage (t < boundary_timestep): use transformer_2
- if boundary_timestep is not None and t < boundary_timestep:
- # Low noise stage - always use guidance_high for this stage
- current_guidance_scale = guidance_high
- if self.transformer_2 is not None:
- current_model = self.transformer_2
- elif self.transformer is not None:
- # Fallback to transformer if transformer_2 not loaded
- current_model = self.transformer
- else:
- raise RuntimeError("No transformer available for low-noise stage")
- else:
- # High noise stage - always use guidance_low for this stage
- current_guidance_scale = guidance_low
- if self.transformer is not None:
- current_model = self.transformer
- elif self.transformer_2 is not None:
- # Fallback to transformer_2 if transformer not loaded
- current_model = self.transformer_2
- else:
- raise RuntimeError("No transformer available for high-noise stage")
-
- if self.expand_timesteps and latent_condition is not None:
- # I2V mode: blend condition with latents using mask
- latent_model_input = (1 - first_frame_mask) * latent_condition + first_frame_mask * latents
- latent_model_input = latent_model_input.to(dtype)
-
- # Expand timesteps per patch - use floor division to match patch embedding
- patch_size = self.transformer_config.patch_size
- num_latent_frames = latents.shape[2]
- patch_height = latents.shape[3] // patch_size[1]
- patch_width = latents.shape[4] // patch_size[2]
-
- # Create mask at patch resolution (same as hidden states sequence length)
- patch_mask = first_frame_mask[:, :, :, :: patch_size[1], :: patch_size[2]]
- patch_mask = patch_mask[:, :, :, :patch_height, :patch_width] # Ensure correct dimensions
- temp_ts = (patch_mask[0][0] * t).flatten()
- timestep = temp_ts.unsqueeze(0).expand(latents.shape[0], -1)
- else:
- # T2V mode: standard forward
- latent_model_input = latents.to(dtype)
- timestep = t.expand(latents.shape[0])
-
- do_true_cfg = current_guidance_scale > 1.0 and negative_prompt_embeds is not None
- # Prepare kwargs for positive and negative predictions
- positive_kwargs = {
- "hidden_states": latent_model_input,
- "timestep": timestep,
- "encoder_hidden_states": prompt_embeds,
- "attention_kwargs": attention_kwargs,
- "return_dict": False,
- "current_model": current_model,
- }
- if do_true_cfg:
- negative_kwargs = {
- "hidden_states": latent_model_input,
- "timestep": timestep,
- "encoder_hidden_states": negative_prompt_embeds,
- "attention_kwargs": attention_kwargs,
- "return_dict": False,
- "current_model": current_model,
- }
- else:
- negative_kwargs = None
-
- # Predict noise with automatic CFG parallel handling
- noise_pred = self.predict_noise_maybe_with_cfg(
- do_true_cfg=do_true_cfg,
- true_cfg_scale=current_guidance_scale,
- positive_kwargs=positive_kwargs,
- negative_kwargs=negative_kwargs,
- cfg_normalize=False,
- )
-
- # Compute the previous noisy sample x_t -> x_t-1 with automatic CFG sync
- latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg)
-
- pbar.update()
+ latents = self.diffuse(
+ latents=latents,
+ timesteps=timesteps,
+ prompt_embeds=prompt_embeds,
+ negative_prompt_embeds=negative_prompt_embeds,
+ guidance_low=guidance_low,
+ guidance_high=guidance_high,
+ boundary_timestep=boundary_timestep,
+ dtype=dtype,
+ attention_kwargs=attention_kwargs,
+ latent_condition=latent_condition,
+ first_frame_mask=first_frame_mask,
+ )
# Wan2.2 is prone to out of memory errors when predicting large videos
# so we empty the cache here to avoid OOM before vae decoding.
@@ -916,3 +949,16 @@ class Wan22Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, DiffusionPipe
if boundary_ratio is None and guidance_scale_2 is not None:
raise ValueError("`guidance_scale_2` is only supported when `boundary_ratio` is set.")
+
+
+# ---------------------------------------------------------------------------
+# DMD2-distilled variant
+# ---------------------------------------------------------------------------
+
+
+class WanT2VDMD2Pipeline(DMD2PipelineMixin, Wan22Pipeline):
+ """Wan 2.x T2V pipeline for FastGen DMD2-distilled models."""
+
+ def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
+ super().__init__(od_config=od_config, prefix=prefix)
+ self.__init_dmd2__()
diff --git a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py
index 46484cd78..373350c70 100644
--- a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py
+++ b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py
@@ -12,6 +12,7 @@ from typing import Any, cast
import numpy as np
import PIL.Image
import torch
+import torchvision.transforms.functional as TF
from diffusers.utils.torch_utils import randn_tensor
from torch import nn
from transformers import AutoTokenizer, CLIPImageProcessor, CLIPVisionModel, UMT5EncoderModel
@@ -22,8 +23,10 @@ from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import Dist
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
+from vllm_omni.diffusion.models.dmd2 import DMD2PipelineMixin
from vllm_omni.diffusion.models.interface import SupportImageInput
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin, _is_rank_zero
+from vllm_omni.diffusion.models.utils import _load_json
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
build_wan_scheduler,
create_transformer_from_config,
@@ -32,6 +35,7 @@ from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
resolve_wan_sample_solver,
retrieve_latents,
)
+from vllm_omni.diffusion.postprocess import interpolate_video_tensor
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniTextPrompt
@@ -41,29 +45,6 @@ logger = logging.getLogger(__name__)
DEBUG_PERF = False
-def _load_model_index(model: str, local_files_only: bool) -> dict:
- """Load model_index.json from local path or HF Hub."""
- if local_files_only:
- model_index_path = os.path.join(model, "model_index.json")
- if os.path.exists(model_index_path):
- import json
-
- with open(model_index_path) as f:
- return json.load(f)
- else:
- try:
- import json
-
- from huggingface_hub import hf_hub_download
-
- model_index_path = hf_hub_download(model, "model_index.json")
- with open(model_index_path) as f:
- return json.load(f)
- except Exception:
- pass
- return {}
-
-
def get_wan22_i2v_post_process_func(
od_config: OmniDiffusionConfig,
):
@@ -74,10 +55,23 @@ def get_wan22_i2v_post_process_func(
def post_process_func(
video: torch.Tensor,
output_type: str = "np",
+ sampling_params=None,
):
if output_type == "latent":
return video
- return video_processor.postprocess_video(video, output_type=output_type)
+ custom_output = {}
+ if sampling_params is not None and getattr(sampling_params, "enable_frame_interpolation", False):
+ video, multiplier = interpolate_video_tensor(
+ video,
+ exp=sampling_params.frame_interpolation_exp,
+ scale=sampling_params.frame_interpolation_scale,
+ model_path=sampling_params.frame_interpolation_model_path,
+ )
+ custom_output["video_fps_multiplier"] = multiplier
+ return {
+ "video": video_processor.postprocess_video(video, output_type=output_type),
+ "custom_output": custom_output,
+ }
return post_process_func
@@ -86,9 +80,6 @@ def get_wan22_i2v_pre_process_func(
od_config: OmniDiffusionConfig,
):
"""Pre-process function for I2V: load and resize input image."""
- from diffusers.video_processor import VideoProcessor
-
- video_processor = VideoProcessor(vae_scale_factor=8)
def pre_process_func(request: OmniDiffusionRequest) -> OmniDiffusionRequest:
for i, prompt in enumerate(request.prompts):
@@ -134,10 +125,6 @@ def get_wan22_i2v_pre_process_func(
)
prompt["multi_modal_data"]["image"] = image # type: ignore # key existence already checked above
- # Preprocess for VAE
- prompt["additional_information"]["preprocessed_image"] = video_processor.preprocess(
- image, height=request.sampling_params.height, width=request.sampling_params.width
- )
request.prompts[i] = prompt
return request
@@ -181,7 +168,10 @@ class Wan22I2VPipeline(
]
# Load model_index.json to detect available components
- model_index = _load_model_index(model, local_files_only)
+ try:
+ model_index = _load_json(model, "model_index.json", local_files_only)
+ except Exception:
+ model_index = {}
# Check if this is a two-stage model (MoE with transformer_2)
self.has_transformer_2 = "transformer_2" in model_index
@@ -270,6 +260,82 @@ class Wan22I2VPipeline(
def current_timestep(self):
return self._current_timestep
+ def diffuse(
+ self,
+ latents: torch.Tensor,
+ timesteps: torch.Tensor,
+ prompt_embeds: torch.Tensor,
+ negative_prompt_embeds: torch.Tensor | None,
+ image_embeds: torch.Tensor | None,
+ guidance_low: float,
+ guidance_high: float,
+ boundary_timestep: float | None,
+ dtype: torch.dtype,
+ attention_kwargs: dict[str, Any],
+ condition: torch.Tensor,
+ first_frame_mask: torch.Tensor,
+ ) -> torch.Tensor:
+ with self.progress_bar(total=len(timesteps)) as pbar:
+ for t in timesteps:
+ self._current_timestep = t
+
+ # Select model and guidance scale based on timestep
+ current_model = self.transformer
+ current_guidance_scale = guidance_low
+ if boundary_timestep is not None and t < boundary_timestep and self.transformer_2 is not None:
+ current_model = self.transformer_2
+ current_guidance_scale = guidance_high
+
+ # Prepare latent input
+ if self.expand_timesteps:
+ # TI2V-5B style: blend condition with latents using mask
+ latent_model_input = (1 - first_frame_mask) * condition + first_frame_mask * latents
+ latent_model_input = latent_model_input.to(dtype)
+
+ # Expand timesteps for each patch
+ temp_ts = (first_frame_mask[0][0][:, ::2, ::2] * t).flatten()
+ timestep = temp_ts.unsqueeze(0).expand(latents.shape[0], -1)
+ else:
+ # Wan2.1 style: concatenate condition with latents
+ latent_model_input = torch.cat([latents, condition], dim=1).to(dtype)
+ timestep = t.expand(latents.shape[0])
+
+ do_true_cfg = current_guidance_scale > 1.0 and negative_prompt_embeds is not None
+ positive_kwargs = {
+ "hidden_states": latent_model_input,
+ "timestep": timestep,
+ "encoder_hidden_states": prompt_embeds,
+ "encoder_hidden_states_image": image_embeds,
+ "attention_kwargs": attention_kwargs,
+ "return_dict": False,
+ "current_model": current_model,
+ }
+ if do_true_cfg:
+ negative_kwargs = {
+ "hidden_states": latent_model_input,
+ "timestep": timestep,
+ "encoder_hidden_states": negative_prompt_embeds,
+ "encoder_hidden_states_image": image_embeds,
+ "attention_kwargs": attention_kwargs,
+ "return_dict": False,
+ "current_model": current_model,
+ }
+ else:
+ negative_kwargs = None
+
+ noise_pred = self.predict_noise_maybe_with_cfg(
+ do_true_cfg=do_true_cfg,
+ true_cfg_scale=current_guidance_scale,
+ positive_kwargs=positive_kwargs,
+ negative_kwargs=negative_kwargs,
+ cfg_normalize=False,
+ )
+
+ latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg)
+ pbar.update()
+
+ return latents
+
def encode_image(
self,
image: PIL.Image.Image | list[PIL.Image.Image],
@@ -464,6 +530,7 @@ class Wan22I2VPipeline(
video_processor = VideoProcessor(vae_scale_factor=self.vae_scale_factor_spatial)
if isinstance(image, PIL.Image.Image):
+ image = TF.to_tensor(image).to(device)
image_tensor = video_processor.preprocess(image, height=height, width=width)
else:
image_tensor = image
@@ -472,6 +539,7 @@ class Wan22I2VPipeline(
# Handle last_image if provided
if last_image is not None:
if isinstance(last_image, PIL.Image.Image):
+ image = TF.to_tensor(last_image).to(device)
last_image_tensor = video_processor.preprocess(last_image, height=height, width=width)
else:
last_image_tensor = last_image
@@ -502,68 +570,20 @@ class Wan22I2VPipeline(
if DEBUG_PERF:
_t_denoise_start = time.perf_counter()
- with self.progress_bar(total=len(timesteps)) as pbar:
- for t in timesteps:
- self._current_timestep = t
-
- # Select model and guidance scale based on timestep
- current_model = self.transformer
- current_guidance_scale = guidance_low
- if boundary_timestep is not None and t < boundary_timestep and self.transformer_2 is not None:
- current_model = self.transformer_2
- current_guidance_scale = guidance_high
-
- # Prepare latent input
- if self.expand_timesteps:
- # TI2V-5B style: blend condition with latents using mask
- latent_model_input = (1 - first_frame_mask) * condition + first_frame_mask * latents
- latent_model_input = latent_model_input.to(dtype)
-
- # Expand timesteps for each patch
- temp_ts = (first_frame_mask[0][0][:, ::2, ::2] * t).flatten()
- timestep = temp_ts.unsqueeze(0).expand(latents.shape[0], -1)
- else:
- # Wan2.1 style: concatenate condition with latents
- latent_model_input = torch.cat([latents, condition], dim=1).to(dtype)
- timestep = t.expand(latents.shape[0])
-
- do_true_cfg = current_guidance_scale > 1.0 and negative_prompt_embeds is not None
- # Prepare kwargs for positive and negative predictions
- positive_kwargs = {
- "hidden_states": latent_model_input,
- "timestep": timestep,
- "encoder_hidden_states": prompt_embeds,
- "encoder_hidden_states_image": image_embeds,
- "attention_kwargs": attention_kwargs,
- "return_dict": False,
- "current_model": current_model,
- }
- if do_true_cfg:
- negative_kwargs = {
- "hidden_states": latent_model_input,
- "timestep": timestep,
- "encoder_hidden_states": negative_prompt_embeds,
- "encoder_hidden_states_image": image_embeds,
- "attention_kwargs": attention_kwargs,
- "return_dict": False,
- "current_model": current_model,
- }
- else:
- negative_kwargs = None
-
- # Predict noise with automatic CFG parallel handling
- noise_pred = self.predict_noise_maybe_with_cfg(
- do_true_cfg=do_true_cfg,
- true_cfg_scale=current_guidance_scale,
- positive_kwargs=positive_kwargs,
- negative_kwargs=negative_kwargs,
- cfg_normalize=False,
- )
-
- # Compute the previous noisy sample x_t -> x_t-1 with automatic CFG sync
- latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg)
-
- pbar.update()
+ latents = self.diffuse(
+ latents=latents,
+ timesteps=timesteps,
+ prompt_embeds=prompt_embeds,
+ negative_prompt_embeds=negative_prompt_embeds,
+ image_embeds=image_embeds,
+ guidance_low=guidance_low,
+ guidance_high=guidance_high,
+ boundary_timestep=boundary_timestep,
+ dtype=dtype,
+ attention_kwargs=attention_kwargs,
+ condition=condition,
+ first_frame_mask=first_frame_mask,
+ )
# Wan2.2 is prone to out of memory errors when predicting large videos
# so we empty the cache here to avoid OOM before vae decoding.
@@ -794,12 +814,14 @@ class Wan22I2VPipeline(
return latents, latent_condition, first_frame_mask
# Wan2.1 style: create mask and concatenate with condition
- mask_lat_size = torch.ones(batch_size, 1, num_frames, latent_height, latent_width)
+ mask_lat_size = torch.ones(
+ batch_size, 1, num_frames, latent_height, latent_width, device=latent_condition.device
+ )
if last_image is None:
- mask_lat_size[:, :, list(range(1, num_frames))] = 0
+ mask_lat_size[:, :, 1:] = 0
else:
- mask_lat_size[:, :, list(range(1, num_frames - 1))] = 0
+ mask_lat_size[:, :, 1 : num_frames - 1] = 0
first_frame_mask = mask_lat_size[:, :, 0:1]
first_frame_mask = torch.repeat_interleave(first_frame_mask, dim=2, repeats=self.vae_scale_factor_temporal)
@@ -856,3 +878,16 @@ class Wan22I2VPipeline(
"""Load weights using AutoWeightsLoader for vLLM integration."""
loader = AutoWeightsLoader(self)
return loader.load_weights(weights)
+
+
+# ---------------------------------------------------------------------------
+# DMD2-distilled variant
+# ---------------------------------------------------------------------------
+
+
+class WanI2VDMD2Pipeline(DMD2PipelineMixin, Wan22I2VPipeline):
+ """Wan 2.x I2V pipeline for FastGen DMD2-distilled models."""
+
+ def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
+ super().__init__(od_config=od_config, prefix=prefix)
+ self.__init_dmd2__()
diff --git a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_ti2v.py b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_ti2v.py
index 939fe294a..04d5c4c95 100644
--- a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_ti2v.py
+++ b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_ti2v.py
@@ -44,6 +44,7 @@ from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
resolve_wan_sample_solver,
retrieve_latents,
)
+from vllm_omni.diffusion.postprocess import interpolate_video_tensor
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniTextPrompt
from vllm_omni.platforms import current_omni_platform
@@ -61,10 +62,23 @@ def get_wan22_ti2v_post_process_func(
def post_process_func(
video: torch.Tensor,
output_type: str = "np",
+ sampling_params=None,
):
if output_type == "latent":
return video
- return video_processor.postprocess_video(video, output_type=output_type)
+ custom_output = {}
+ if sampling_params is not None and getattr(sampling_params, "enable_frame_interpolation", False):
+ video, multiplier = interpolate_video_tensor(
+ video,
+ exp=sampling_params.frame_interpolation_exp,
+ scale=sampling_params.frame_interpolation_scale,
+ model_path=sampling_params.frame_interpolation_model_path,
+ )
+ custom_output["video_fps_multiplier"] = multiplier
+ return {
+ "video": video_processor.postprocess_video(video, output_type=output_type),
+ "custom_output": custom_output,
+ }
return post_process_func
@@ -73,9 +87,6 @@ def get_wan22_ti2v_pre_process_func(
od_config: OmniDiffusionConfig,
):
"""Pre-process function for TI2V: optionally load and resize input image."""
- from diffusers.video_processor import VideoProcessor
-
- video_processor = VideoProcessor(vae_scale_factor=8)
def pre_process_func(request: OmniDiffusionRequest) -> OmniDiffusionRequest:
for i, prompt in enumerate(request.prompts):
@@ -121,10 +132,6 @@ def get_wan22_ti2v_pre_process_func(
)
prompt["multi_modal_data"]["image"] = image # type: ignore # key existence already checked above
- # Preprocess for VAE
- prompt["additional_information"]["preprocessed_image"] = video_processor.preprocess(
- image, height=request.sampling_params.height, width=request.sampling_params.width
- )
request.prompts[i] = prompt
return request
@@ -216,6 +223,77 @@ class Wan22TI2VPipeline(nn.Module, SupportImageInput, CFGParallelMixin, Progress
def current_timestep(self):
return self._current_timestep
+ def diffuse(
+ self,
+ latents: torch.Tensor,
+ timesteps: torch.Tensor,
+ prompt_embeds: torch.Tensor,
+ negative_prompt_embeds: torch.Tensor | None,
+ guidance_scale: float,
+ dtype: torch.dtype,
+ attention_kwargs: dict[str, Any],
+ num_latent_frames: int,
+ latent_height: int,
+ latent_width: int,
+ latent_condition: torch.Tensor | None = None,
+ first_frame_mask: torch.Tensor | None = None,
+ ) -> torch.Tensor:
+ with self.progress_bar(total=len(timesteps)) as pbar:
+ for t in timesteps:
+ self._current_timestep = t
+
+ # Prepare latent input
+ if latent_condition is not None:
+ # I2V mode: blend condition with latents using mask
+ latent_model_input = (1 - first_frame_mask) * latent_condition + first_frame_mask * latents
+ latent_model_input = latent_model_input.to(dtype)
+
+ # Expand timesteps for each patch (TI2V style)
+ temp_ts = (first_frame_mask[0][0][:, ::2, ::2] * t).flatten()
+ timestep = temp_ts.unsqueeze(0).expand(latents.shape[0], -1)
+ else:
+ # T2V mode: use latents directly
+ latent_model_input = latents.to(dtype)
+
+ # Expand timesteps for TI2V model architecture
+ mask = torch.ones(1, 1, num_latent_frames, latent_height, latent_width, device=latents.device)
+ temp_ts = (mask[0][0][:, ::2, ::2] * t).flatten()
+ timestep = temp_ts.unsqueeze(0).expand(latents.shape[0], -1)
+
+ do_true_cfg = guidance_scale > 1.0 and negative_prompt_embeds is not None
+ positive_kwargs = {
+ "hidden_states": latent_model_input,
+ "timestep": timestep,
+ "encoder_hidden_states": prompt_embeds,
+ "attention_kwargs": attention_kwargs,
+ "return_dict": False,
+ "current_model": self.transformer,
+ }
+ if do_true_cfg:
+ negative_kwargs = {
+ "hidden_states": latent_model_input,
+ "timestep": timestep,
+ "encoder_hidden_states": negative_prompt_embeds,
+ "attention_kwargs": attention_kwargs,
+ "return_dict": False,
+ "current_model": self.transformer,
+ }
+ else:
+ negative_kwargs = None
+
+ noise_pred = self.predict_noise_maybe_with_cfg(
+ do_true_cfg=do_true_cfg,
+ true_cfg_scale=guidance_scale,
+ positive_kwargs=positive_kwargs,
+ negative_kwargs=negative_kwargs,
+ cfg_normalize=False,
+ )
+
+ latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg)
+ pbar.update()
+
+ return latents
+
def forward(
self,
req: OmniDiffusionRequest,
@@ -385,64 +463,20 @@ class Wan22TI2VPipeline(nn.Module, SupportImageInput, CFGParallelMixin, Progress
if attention_kwargs is None:
attention_kwargs = {}
- # Denoising loop
- with self.progress_bar(total=len(timesteps)) as pbar:
- for t in timesteps:
- self._current_timestep = t
-
- # Prepare latent input
- if latent_condition is not None:
- # I2V mode: blend condition with latents using mask
- latent_model_input = (1 - first_frame_mask) * latent_condition + first_frame_mask * latents
- latent_model_input = latent_model_input.to(dtype)
-
- # Expand timesteps for each patch (TI2V style)
- temp_ts = (first_frame_mask[0][0][:, ::2, ::2] * t).flatten()
- timestep = temp_ts.unsqueeze(0).expand(latents.shape[0], -1)
- else:
- # T2V mode: use latents directly
- latent_model_input = latents.to(dtype)
-
- # Expand timesteps for TI2V model architecture
- mask = torch.ones(1, 1, num_latent_frames, latent_height, latent_width, device=device)
- temp_ts = (mask[0][0][:, ::2, ::2] * t).flatten()
- timestep = temp_ts.unsqueeze(0).expand(latents.shape[0], -1)
-
- do_true_cfg = guidance_scale > 1.0 and negative_prompt_embeds is not None
- # Prepare kwargs for positive and negative predictions
- positive_kwargs = {
- "hidden_states": latent_model_input,
- "timestep": timestep,
- "encoder_hidden_states": prompt_embeds,
- "attention_kwargs": attention_kwargs,
- "return_dict": False,
- "current_model": self.transformer,
- }
- if do_true_cfg:
- negative_kwargs = {
- "hidden_states": latent_model_input,
- "timestep": timestep,
- "encoder_hidden_states": negative_prompt_embeds,
- "attention_kwargs": attention_kwargs,
- "return_dict": False,
- "current_model": self.transformer,
- }
- else:
- negative_kwargs = None
-
- # Predict noise with automatic CFG parallel handling
- noise_pred = self.predict_noise_maybe_with_cfg(
- do_true_cfg=do_true_cfg,
- true_cfg_scale=guidance_scale,
- positive_kwargs=positive_kwargs,
- negative_kwargs=negative_kwargs,
- cfg_normalize=False,
- )
-
- # Compute the previous noisy sample x_t -> x_t-1 with automatic CFG sync
- latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg)
-
- pbar.update()
+ latents = self.diffuse(
+ latents=latents,
+ timesteps=timesteps,
+ prompt_embeds=prompt_embeds,
+ negative_prompt_embeds=negative_prompt_embeds,
+ guidance_scale=guidance_scale,
+ dtype=dtype,
+ attention_kwargs=attention_kwargs,
+ num_latent_frames=num_latent_frames,
+ latent_height=latent_height,
+ latent_width=latent_width,
+ latent_condition=latent_condition,
+ first_frame_mask=first_frame_mask,
+ )
# Wan2.2 is prone to out of memory errors when predicting large videos
# so we empty the cache here to avoid OOM before vae decoding.
diff --git a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_vace.py b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_vace.py
index ea5233631..0458f8859 100644
--- a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_vace.py
+++ b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_vace.py
@@ -176,6 +176,62 @@ class Wan22VACEPipeline(Wan22Pipeline, SupportImageInput):
"""Build VACE transformer directly from config dict."""
return create_vace_transformer_from_config(config)
+ def diffuse(
+ self,
+ latents: torch.Tensor,
+ timesteps: torch.Tensor,
+ prompt_embeds: torch.Tensor,
+ negative_prompt_embeds: torch.Tensor | None,
+ guidance_scale: float,
+ dtype: torch.dtype,
+ attention_kwargs: dict[str, object],
+ vace_context: torch.Tensor | None,
+ vace_context_scale: float,
+ ) -> torch.Tensor:
+ with self.progress_bar(total=len(timesteps)) as pbar:
+ for t in timesteps:
+ self._current_timestep = t
+ latent_model_input = latents.to(dtype)
+ timestep = t.expand(latents.shape[0])
+
+ do_true_cfg = guidance_scale > 1.0 and negative_prompt_embeds is not None
+
+ positive_kwargs = {
+ "hidden_states": latent_model_input,
+ "timestep": timestep,
+ "encoder_hidden_states": prompt_embeds,
+ "attention_kwargs": attention_kwargs,
+ "vace_context": vace_context,
+ "vace_context_scale": vace_context_scale,
+ "return_dict": False,
+ }
+ negative_kwargs = (
+ {
+ "hidden_states": latent_model_input,
+ "timestep": timestep,
+ "encoder_hidden_states": negative_prompt_embeds,
+ "attention_kwargs": attention_kwargs,
+ "vace_context": vace_context,
+ "vace_context_scale": vace_context_scale,
+ "return_dict": False,
+ }
+ if do_true_cfg
+ else None
+ )
+
+ noise_pred = self.predict_noise_maybe_with_cfg(
+ do_true_cfg=do_true_cfg,
+ true_cfg_scale=guidance_scale,
+ positive_kwargs=positive_kwargs,
+ negative_kwargs=negative_kwargs,
+ cfg_normalize=False,
+ )
+
+ latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg)
+ pbar.update()
+
+ return latents
+
def check_inputs(
self,
prompt,
@@ -569,48 +625,17 @@ class Wan22VACEPipeline(Wan22Pipeline, SupportImageInput):
timesteps = self.scheduler.timesteps
self._num_timesteps = len(timesteps)
- # Denoising loop
- with self.progress_bar(total=len(timesteps)) as pbar:
- for t in timesteps:
- self._current_timestep = t
- latent_model_input = latents.to(dtype)
- timestep = t.expand(latents.shape[0])
-
- do_true_cfg = guidance_scale > 1.0 and negative_prompt_embeds is not None
-
- positive_kwargs = {
- "hidden_states": latent_model_input,
- "timestep": timestep,
- "encoder_hidden_states": prompt_embeds,
- "attention_kwargs": attention_kwargs,
- "vace_context": vace_context,
- "vace_context_scale": vace_context_scale,
- "return_dict": False,
- }
- negative_kwargs = (
- {
- "hidden_states": latent_model_input,
- "timestep": timestep,
- "encoder_hidden_states": negative_prompt_embeds,
- "attention_kwargs": attention_kwargs,
- "vace_context": vace_context,
- "vace_context_scale": vace_context_scale,
- "return_dict": False,
- }
- if do_true_cfg
- else None
- )
-
- noise_pred = self.predict_noise_maybe_with_cfg(
- do_true_cfg=do_true_cfg,
- true_cfg_scale=guidance_scale,
- positive_kwargs=positive_kwargs,
- negative_kwargs=negative_kwargs,
- cfg_normalize=False,
- )
-
- latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg)
- pbar.update()
+ latents = self.diffuse(
+ latents=latents,
+ timesteps=timesteps,
+ prompt_embeds=prompt_embeds,
+ negative_prompt_embeds=negative_prompt_embeds,
+ guidance_scale=guidance_scale,
+ dtype=dtype,
+ attention_kwargs=attention_kwargs,
+ vace_context=vace_context,
+ vace_context_scale=vace_context_scale,
+ )
self._current_timestep = None
diff --git a/vllm_omni/diffusion/models/wan2_2/wan2_2_transformer.py b/vllm_omni/diffusion/models/wan2_2/wan2_2_transformer.py
index 3b43f3eaf..d4d81b78e 100644
--- a/vllm_omni/diffusion/models/wan2_2/wan2_2_transformer.py
+++ b/vllm_omni/diffusion/models/wan2_2/wan2_2_transformer.py
@@ -11,7 +11,6 @@ import torch.nn.functional as F
from diffusers.models.attention import FeedForward
from diffusers.models.embeddings import PixArtAlphaTextProjection, TimestepEmbedding, Timesteps
from diffusers.models.modeling_outputs import Transformer2DModelOutput
-from diffusers.models.normalization import FP32LayerNorm
from vllm.distributed import (
get_tensor_model_parallel_rank,
get_tensor_model_parallel_world_size,
@@ -29,6 +28,8 @@ from vllm_omni.diffusion.distributed.sp_plan import (
SequenceParallelOutput,
)
from vllm_omni.diffusion.forward_context import get_forward_context
+from vllm_omni.diffusion.layers.adalayernorm import AdaLayerNorm
+from vllm_omni.diffusion.layers.norm import LayerNorm, RMSNorm
from vllm_omni.platforms import current_omni_platform
logger = init_logger(__name__)
@@ -235,9 +236,9 @@ class WanImageEmbedding(nn.Module):
def __init__(self, in_features: int, out_features: int, pos_embed_seq_len: int | None = None):
super().__init__()
- self.norm1 = FP32LayerNorm(in_features)
+ self.norm1 = LayerNorm(in_features)
self.ff = FeedForward(in_features, out_features, mult=1, activation_fn="gelu")
- self.norm2 = FP32LayerNorm(out_features)
+ self.norm2 = LayerNorm(out_features)
if pos_embed_seq_len is not None:
self.pos_embed = nn.Parameter(torch.zeros(1, pos_embed_seq_len, in_features))
else:
@@ -377,8 +378,12 @@ class WanSelfAttention(nn.Module):
self.tp_inner_dim = self.num_heads * head_dim
# QK normalization using vLLM's RMSNorm
- self.norm_q = DistributedRMSNorm(self.tp_inner_dim, eps=eps)
- self.norm_k = DistributedRMSNorm(self.tp_inner_dim, eps=eps)
+ if get_tensor_model_parallel_world_size() > 1:
+ self.norm_q = DistributedRMSNorm(self.tp_inner_dim, eps=eps)
+ self.norm_k = DistributedRMSNorm(self.tp_inner_dim, eps=eps)
+ else:
+ self.norm_q = RMSNorm(self.tp_inner_dim, eps=eps)
+ self.norm_k = RMSNorm(self.tp_inner_dim, eps=eps)
self.to_out = RowParallelLinear(
self.inner_dim,
@@ -497,8 +502,12 @@ class WanCrossAttention(nn.Module):
self.tp_inner_dim = self.num_heads * head_dim
# QK normalization
- self.norm_q = DistributedRMSNorm(self.tp_inner_dim, eps=eps)
- self.norm_k = DistributedRMSNorm(self.tp_inner_dim, eps=eps)
+ if get_tensor_model_parallel_world_size() > 1:
+ self.norm_q = DistributedRMSNorm(self.tp_inner_dim, eps=eps)
+ self.norm_k = DistributedRMSNorm(self.tp_inner_dim, eps=eps)
+ else:
+ self.norm_q = RMSNorm(self.tp_inner_dim, eps=eps)
+ self.norm_k = RMSNorm(self.tp_inner_dim, eps=eps)
# Optional added KV projections for I2V (image embeddings)
self.added_kv_proj_dim = added_kv_proj_dim
@@ -517,7 +526,10 @@ class WanCrossAttention(nn.Module):
gather_output=False,
return_bias=False,
)
- self.norm_added_k = DistributedRMSNorm(self.tp_inner_dim, eps=eps)
+ if get_tensor_model_parallel_world_size() > 1:
+ self.norm_added_k = DistributedRMSNorm(self.tp_inner_dim, eps=eps)
+ else:
+ self.norm_added_k = RMSNorm(self.tp_inner_dim, eps=eps)
else:
self.add_k_proj = None
self.add_v_proj = None
@@ -620,7 +632,7 @@ class WanTransformerBlock(nn.Module):
head_dim = dim // num_heads
# 1. Self-attention
- self.norm1 = FP32LayerNorm(dim, eps, elementwise_affine=False)
+ self.norm1 = AdaLayerNorm(dim, elementwise_affine=False, eps=eps)
self.attn1 = WanSelfAttention(
dim=dim,
num_heads=num_heads,
@@ -636,11 +648,11 @@ class WanTransformerBlock(nn.Module):
eps=eps,
added_kv_proj_dim=added_kv_proj_dim,
)
- self.norm2 = FP32LayerNorm(dim, eps, elementwise_affine=True) if cross_attn_norm else nn.Identity()
+ self.norm2 = LayerNorm(dim, eps, elementwise_affine=True) if cross_attn_norm else nn.Identity()
# 3. Feed-forward
self.ffn = WanFeedForward(dim=dim, inner_dim=ffn_dim, dim_out=dim)
- self.norm3 = FP32LayerNorm(dim, eps, elementwise_affine=False)
+ self.norm3 = AdaLayerNorm(dim, elementwise_affine=False, eps=eps)
# Scale-shift table for modulation
self.scale_shift_table = nn.Parameter(torch.randn(1, 6, dim) / dim**0.5)
@@ -656,7 +668,7 @@ class WanTransformerBlock(nn.Module):
if temb.ndim == 4:
# temb: batch_size, seq_len, 6, inner_dim (wan2.2 ti2v)
shift_msa, scale_msa, gate_msa, c_shift_msa, c_scale_msa, c_gate_msa = (
- self.scale_shift_table.unsqueeze(0) + temb.float()
+ self.scale_shift_table.unsqueeze(0) + temb
).chunk(6, dim=2)
shift_msa = shift_msa.squeeze(2)
scale_msa = scale_msa.squeeze(2)
@@ -667,25 +679,23 @@ class WanTransformerBlock(nn.Module):
else:
# temb: batch_size, 6, inner_dim (wan2.1/wan2.2 14B)
shift_msa, scale_msa, gate_msa, c_shift_msa, c_scale_msa, c_gate_msa = (
- self.scale_shift_table + temb.float()
+ self.scale_shift_table + temb
).chunk(6, dim=1)
# 1. Self-attention
- norm_hidden_states = (self.norm1(hidden_states.float()) * (1 + scale_msa) + shift_msa).type_as(hidden_states)
+ norm_hidden_states = self.norm1(hidden_states, scale_msa, shift_msa).type_as(hidden_states)
attn_output = self.attn1(norm_hidden_states, rotary_emb, hidden_states_mask)
- hidden_states = (hidden_states.float() + attn_output * gate_msa).type_as(hidden_states)
+ hidden_states = (hidden_states + attn_output * gate_msa).type_as(hidden_states)
# 2. Cross-attention
- norm_hidden_states = self.norm2(hidden_states.float()).type_as(hidden_states)
+ norm_hidden_states = self.norm2(hidden_states).type_as(hidden_states)
attn_output = self.attn2(norm_hidden_states, encoder_hidden_states)
hidden_states = hidden_states + attn_output
# 3. Feed-forward
- norm_hidden_states = (self.norm3(hidden_states.float()) * (1 + c_scale_msa) + c_shift_msa).type_as(
- hidden_states
- )
+ norm_hidden_states = self.norm3(hidden_states, c_scale_msa, c_shift_msa).type_as(hidden_states)
ff_output = self.ffn(norm_hidden_states)
- hidden_states = (hidden_states.float() + ff_output.float() * c_gate_msa).type_as(hidden_states)
+ hidden_states = (hidden_states + ff_output * c_gate_msa).type_as(hidden_states)
return hidden_states
@@ -854,7 +864,7 @@ class WanTransformer3DModel(nn.Module):
)
# 4. Output norm & projection
- self.norm_out = FP32LayerNorm(inner_dim, eps, elementwise_affine=False)
+ self.norm_out = AdaLayerNorm(inner_dim, elementwise_affine=False, eps=eps)
self.proj_out = nn.Linear(inner_dim, out_channels * math.prod(patch_size))
# SP helper modules
@@ -942,7 +952,7 @@ class WanTransformer3DModel(nn.Module):
shift = shift.unsqueeze(1)
scale = scale.unsqueeze(1)
- hidden_states = (self.norm_out(hidden_states.float()) * (1 + scale) + shift).type_as(hidden_states)
+ hidden_states = self.norm_out(hidden_states, scale, shift).type_as(hidden_states)
hidden_states = self.proj_out(hidden_states)
hidden_states = hidden_states.reshape(
diff --git a/vllm_omni/diffusion/models/wan2_2/wan2_2_vace_transformer.py b/vllm_omni/diffusion/models/wan2_2/wan2_2_vace_transformer.py
index 4f4217dab..c48938e1b 100644
--- a/vllm_omni/diffusion/models/wan2_2/wan2_2_vace_transformer.py
+++ b/vllm_omni/diffusion/models/wan2_2/wan2_2_vace_transformer.py
@@ -239,7 +239,7 @@ class WanVACETransformer3DModel(WanTransformer3DModel):
shift = shift.unsqueeze(1)
scale = scale.unsqueeze(1)
- hidden_states = (self.norm_out(hidden_states.float()) * (1 + scale) + shift).type_as(hidden_states)
+ hidden_states = self.norm_out(hidden_states, scale, shift).type_as(hidden_states)
hidden_states = self.proj_out(hidden_states)
hidden_states = hidden_states.reshape(
diff --git a/vllm_omni/diffusion/models/z_image/z_image_transformer.py b/vllm_omni/diffusion/models/z_image/z_image_transformer.py
index 3ffad221b..c36ea7466 100644
--- a/vllm_omni/diffusion/models/z_image/z_image_transformer.py
+++ b/vllm_omni/diffusion/models/z_image/z_image_transformer.py
@@ -214,12 +214,14 @@ class TimestepEmbedder(nn.Module):
super().__init__()
if mid_size is None:
mid_size = out_size
+ # Time embedding MLP is kept full precision (quant_config=None) —
+ # small layers that feed adaLN; precision-sensitive (see #2728).
self.mlp = nn.Sequential(
ReplicatedLinear(
frequency_embedding_size,
mid_size,
bias=True,
- quant_config=quant_config,
+ quant_config=None,
return_bias=False,
),
nn.SiLU(),
@@ -227,7 +229,7 @@ class TimestepEmbedder(nn.Module):
mid_size,
out_size,
bias=True,
- quant_config=quant_config,
+ quant_config=None,
return_bias=False,
),
)
@@ -426,9 +428,16 @@ class ZImageTransformerBlock(nn.Module):
self.modulation = modulation
if modulation:
+ # Modulation linear is kept at full precision (quant_config=None)
+ # — it produces scale/gate values that are precision-sensitive
+ # (see #2728, mirrors OmniGen2 fix).
self.adaLN_modulation = nn.Sequential(
ReplicatedLinear(
- min(dim, ADALN_EMBED_DIM), 4 * dim, bias=True, return_bias=False, quant_config=quant_config
+ min(dim, ADALN_EMBED_DIM),
+ 4 * dim,
+ bias=True,
+ quant_config=None,
+ return_bias=False,
),
)
@@ -485,14 +494,24 @@ class FinalLayer(nn.Module):
def __init__(self, hidden_size, out_channels, quant_config: "QuantizationConfig | None" = None):
super().__init__()
self.norm_final = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
+ # Final output projection and its modulation are precision-sensitive
+ # (produce the output latent); keep at full precision (see #2728).
self.linear = ReplicatedLinear(
- hidden_size, out_channels, bias=True, quant_config=quant_config, return_bias=False
+ hidden_size,
+ out_channels,
+ bias=True,
+ quant_config=None,
+ return_bias=False,
)
self.adaLN_modulation = nn.Sequential(
nn.SiLU(),
ReplicatedLinear(
- min(hidden_size, ADALN_EMBED_DIM), hidden_size, bias=True, quant_config=quant_config, return_bias=False
+ min(hidden_size, ADALN_EMBED_DIM),
+ hidden_size,
+ bias=True,
+ quant_config=None,
+ return_bias=False,
),
)
@@ -673,11 +692,13 @@ class ZImageTransformer2DModel(CachedTransformer):
all_x_embedder = {}
all_final_layer = {}
for patch_idx, (patch_size, f_patch_size) in enumerate(zip(all_patch_size, all_f_patch_size)):
+ # x_embedder (patch embed) is a small precision-sensitive entry
+ # layer; keep full precision (see #2728).
x_embedder = ReplicatedLinear(
f_patch_size * patch_size * patch_size * in_channels,
dim,
bias=True,
- quant_config=quant_config,
+ quant_config=None,
return_bias=False,
)
all_x_embedder[f"{patch_size}-{f_patch_size}"] = x_embedder
@@ -720,9 +741,17 @@ class ZImageTransformer2DModel(CachedTransformer):
]
)
self.t_embedder = TimestepEmbedder(min(dim, ADALN_EMBED_DIM), mid_size=1024, quant_config=quant_config)
+ # Caption embedder maps text features -> hidden; keep full precision
+ # (see #2728).
self.cap_embedder = nn.Sequential(
RMSNorm(cap_feat_dim, eps=norm_eps),
- ReplicatedLinear(cap_feat_dim, dim, bias=True, return_bias=False, quant_config=quant_config),
+ ReplicatedLinear(
+ cap_feat_dim,
+ dim,
+ bias=True,
+ quant_config=None,
+ return_bias=False,
+ ),
)
self.x_pad_token = nn.Parameter(torch.empty((1, dim)))
diff --git a/vllm_omni/diffusion/offloader/layerwise_backend.py b/vllm_omni/diffusion/offloader/layerwise_backend.py
index 7876b0094..d1216e2f2 100644
--- a/vllm_omni/diffusion/offloader/layerwise_backend.py
+++ b/vllm_omni/diffusion/offloader/layerwise_backend.py
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+from __future__ import annotations
from itertools import chain
from typing import Any
diff --git a/vllm_omni/diffusion/postprocess/__init__.py b/vllm_omni/diffusion/postprocess/__init__.py
new file mode 100644
index 000000000..e6fe5b2d2
--- /dev/null
+++ b/vllm_omni/diffusion/postprocess/__init__.py
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Diffusion post-processing helpers."""
+
+from vllm_omni.diffusion.postprocess.rife_interpolator import (
+ FrameInterpolator,
+ interpolate_video_tensor,
+)
+
+__all__ = ["FrameInterpolator", "interpolate_video_tensor"]
diff --git a/vllm_omni/diffusion/postprocess/rife_interpolator.py b/vllm_omni/diffusion/postprocess/rife_interpolator.py
new file mode 100644
index 000000000..89297d0a4
--- /dev/null
+++ b/vllm_omni/diffusion/postprocess/rife_interpolator.py
@@ -0,0 +1,443 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""
+RIFE 4.22.lite frame interpolation for vLLM-Omni video generation.
+
+RIFE model code is vendored and adapted from:
+ - https://github.com/hzwer/ECCV2022-RIFE (MIT License)
+ - https://github.com/hzwer/Practical-RIFE (MIT License)
+ Copyright (c) 2021 Zhewei Huang
+
+The FrameInterpolator wrapper and vLLM-Omni integration code are original work.
+"""
+
+from __future__ import annotations
+
+import os
+import threading
+from typing import Any
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+from vllm.logger import init_logger
+
+logger = init_logger(__name__)
+
+_DEFAULT_RIFE_HF_REPO = "elfgum/RIFE-4.22.lite"
+_MODEL_CACHE: dict[tuple[str, str], Model] = {}
+_MODEL_CACHE_LOCK = threading.Lock()
+
+
+def warp(ten_input: torch.Tensor, ten_flow: torch.Tensor) -> torch.Tensor:
+ """Warp input tensor by optical flow using grid_sample."""
+ ten_horizontal = (
+ torch.linspace(-1.0, 1.0, ten_flow.shape[3], device=ten_flow.device)
+ .view(1, 1, 1, ten_flow.shape[3])
+ .expand(ten_flow.shape[0], -1, ten_flow.shape[2], -1)
+ )
+ ten_vertical = (
+ torch.linspace(-1.0, 1.0, ten_flow.shape[2], device=ten_flow.device)
+ .view(1, 1, ten_flow.shape[2], 1)
+ .expand(ten_flow.shape[0], -1, -1, ten_flow.shape[3])
+ )
+ ten_grid = torch.cat([ten_horizontal, ten_vertical], dim=1)
+
+ ten_flow = torch.cat(
+ [
+ ten_flow[:, 0:1, :, :] / ((ten_input.shape[3] - 1.0) / 2.0),
+ ten_flow[:, 1:2, :, :] / ((ten_input.shape[2] - 1.0) / 2.0),
+ ],
+ dim=1,
+ )
+ grid = (ten_grid + ten_flow).permute(0, 2, 3, 1)
+ return F.grid_sample(
+ input=ten_input,
+ grid=grid,
+ mode="bilinear",
+ padding_mode="border",
+ align_corners=True,
+ )
+
+
+def _conv(
+ in_planes: int,
+ out_planes: int,
+ kernel_size: int = 3,
+ stride: int = 1,
+ padding: int = 1,
+ dilation: int = 1,
+) -> nn.Sequential:
+ return nn.Sequential(
+ nn.Conv2d(
+ in_planes,
+ out_planes,
+ kernel_size=kernel_size,
+ stride=stride,
+ padding=padding,
+ dilation=dilation,
+ bias=True,
+ ),
+ nn.LeakyReLU(0.2, True),
+ )
+
+
+class ResConv(nn.Module):
+ """Residual convolution block with learnable beta scaling."""
+
+ def __init__(self, c: int, dilation: int = 1):
+ super().__init__()
+ self.conv = nn.Conv2d(c, c, 3, 1, dilation, dilation=dilation, groups=1)
+ self.beta = nn.Parameter(torch.ones((1, c, 1, 1)), requires_grad=True)
+ self.relu = nn.LeakyReLU(0.2, True)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ return self.relu(self.conv(x) * self.beta + x)
+
+
+class IFBlock(nn.Module):
+ """Single-scale optical flow, mask, and feature block."""
+
+ def __init__(self, in_planes: int, c: int = 64):
+ super().__init__()
+ self.conv0 = nn.Sequential(
+ _conv(in_planes, c // 2, 3, 2, 1),
+ _conv(c // 2, c, 3, 2, 1),
+ )
+ self.convblock = nn.Sequential(
+ ResConv(c),
+ ResConv(c),
+ ResConv(c),
+ ResConv(c),
+ ResConv(c),
+ ResConv(c),
+ ResConv(c),
+ ResConv(c),
+ )
+ self.lastconv = nn.Sequential(
+ nn.ConvTranspose2d(c, 4 * 13, 4, 2, 1),
+ nn.PixelShuffle(2),
+ )
+
+ def forward(
+ self,
+ x: torch.Tensor,
+ flow: torch.Tensor | None = None,
+ scale: float = 1.0,
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
+ x = F.interpolate(x, scale_factor=1.0 / scale, mode="bilinear", align_corners=False)
+ if flow is not None:
+ flow = (
+ F.interpolate(
+ flow,
+ scale_factor=1.0 / scale,
+ mode="bilinear",
+ align_corners=False,
+ )
+ * 1.0
+ / scale
+ )
+ x = torch.cat((x, flow), 1)
+ feat = self.conv0(x)
+ feat = self.convblock(feat)
+ tmp = self.lastconv(feat)
+ tmp = F.interpolate(tmp, scale_factor=scale, mode="bilinear", align_corners=False)
+ flow = tmp[:, :4] * scale
+ mask = tmp[:, 4:5]
+ feat = tmp[:, 5:]
+ return flow, mask, feat
+
+
+class Head(nn.Module):
+ """Feature encoder producing four-channel features at full resolution."""
+
+ def __init__(self):
+ super().__init__()
+ self.cnn0 = nn.Conv2d(3, 16, 3, 2, 1)
+ self.cnn1 = nn.Conv2d(16, 16, 3, 1, 1)
+ self.cnn2 = nn.Conv2d(16, 16, 3, 1, 1)
+ self.cnn3 = nn.ConvTranspose2d(16, 4, 4, 2, 1)
+ self.relu = nn.LeakyReLU(0.2, True)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ x0 = self.cnn0(x)
+ x = self.relu(x0)
+ x1 = self.cnn1(x)
+ x = self.relu(x1)
+ x2 = self.cnn2(x)
+ x = self.relu(x2)
+ x3 = self.cnn3(x)
+ return x3
+
+
+class IFNet(nn.Module):
+ """Four-scale IFNet optical flow network."""
+
+ def __init__(self):
+ super().__init__()
+ self.block0 = IFBlock(7 + 8, c=192)
+ self.block1 = IFBlock(8 + 4 + 8 + 8, c=128)
+ self.block2 = IFBlock(8 + 4 + 8 + 8, c=64)
+ self.block3 = IFBlock(8 + 4 + 8 + 8, c=32)
+ self.encode = Head()
+
+ def forward(
+ self,
+ x: torch.Tensor,
+ timestep: float = 0.5,
+ scale_list: list[float] | None = None,
+ ) -> tuple[list[torch.Tensor], torch.Tensor, list[tuple[torch.Tensor, torch.Tensor] | torch.Tensor]]:
+ if scale_list is None:
+ scale_list = [8, 4, 2, 1]
+
+ channel = x.shape[1] // 2
+ img0 = x[:, :channel]
+ img1 = x[:, channel:]
+
+ if not torch.is_tensor(timestep):
+ timestep = (x[:, :1].clone() * 0 + 1) * timestep
+ else:
+ timestep = timestep.repeat(1, 1, img0.shape[2], img0.shape[3])
+
+ f0 = self.encode(img0[:, :3])
+ f1 = self.encode(img1[:, :3])
+
+ flow_list: list[torch.Tensor] = []
+ merged: list[tuple[torch.Tensor, torch.Tensor] | torch.Tensor] = []
+ mask_list: list[torch.Tensor] = []
+ warped_img0 = img0
+ warped_img1 = img1
+ flow = None
+ mask = None
+
+ for i, block in enumerate([self.block0, self.block1, self.block2, self.block3]):
+ if flow is None:
+ flow, mask, feat = block(
+ torch.cat((img0[:, :3], img1[:, :3], f0, f1, timestep), 1),
+ None,
+ scale=scale_list[i],
+ )
+ else:
+ wf0 = warp(f0, flow[:, :2])
+ wf1 = warp(f1, flow[:, 2:4])
+ fd, m0, feat = block(
+ torch.cat(
+ (
+ warped_img0[:, :3],
+ warped_img1[:, :3],
+ wf0,
+ wf1,
+ timestep,
+ mask,
+ feat,
+ ),
+ 1,
+ ),
+ flow,
+ scale=scale_list[i],
+ )
+ mask = m0
+ flow = flow + fd
+
+ mask_list.append(mask)
+ flow_list.append(flow)
+ warped_img0 = warp(img0, flow[:, :2])
+ warped_img1 = warp(img1, flow[:, 2:4])
+ merged.append((warped_img0, warped_img1))
+
+ mask = torch.sigmoid(mask)
+ merged[3] = warped_img0 * mask + warped_img1 * (1 - mask)
+ return flow_list, mask_list[3], merged
+
+
+class Model:
+ """Wraps IFNet and exposes RIFE-compatible load/inference helpers."""
+
+ def __init__(self):
+ self.flownet = IFNet()
+
+ def eval(self) -> Model:
+ self.flownet.eval()
+ return self
+
+ def device(self) -> torch.device:
+ return next(self.flownet.parameters()).device
+
+ def load_model(self, path: str) -> None:
+ flownet_path = os.path.join(path, "flownet.pkl")
+ if not os.path.isfile(flownet_path):
+ raise FileNotFoundError(
+ f"RIFE weight file not found: {flownet_path}. Expected layout: /flownet.pkl"
+ )
+
+ state = torch.load(flownet_path, map_location="cpu", weights_only=False)
+ state = {k.removeprefix("module."): v for k, v in state.items()}
+ self.flownet.load_state_dict(state, strict=False)
+ logger.info("Loaded RIFE weights from %s", flownet_path)
+
+ def inference(
+ self,
+ img0: torch.Tensor,
+ img1: torch.Tensor,
+ scale: float = 1.0,
+ timestep: float = 0.5,
+ ) -> torch.Tensor:
+ _n, _c, h, w = img0.shape
+ ph = ((h - 1) // 32 + 1) * 32
+ pw = ((w - 1) // 32 + 1) * 32
+ pad = (0, pw - w, 0, ph - h)
+ img0 = F.pad(img0, pad)
+ img1 = F.pad(img1, pad)
+
+ imgs = torch.cat((img0, img1), 1)
+ scale_list = [8 / scale, 4 / scale, 2 / scale, 1 / scale]
+ with torch.no_grad():
+ _flow_list, _mask, merged = self.flownet(
+ imgs,
+ timestep=timestep,
+ scale_list=scale_list,
+ )
+ return merged[3][:, :, :h, :w]
+
+
+def _resolve_rife_model_path(model_path: str | None) -> str:
+ model_path = model_path or _DEFAULT_RIFE_HF_REPO
+ if os.path.isdir(model_path):
+ return model_path
+ from vllm_omni.model_executor.model_loader.weight_utils import (
+ download_weights_from_hf_specific,
+ )
+
+ return download_weights_from_hf_specific(
+ model_path,
+ cache_dir=None,
+ allow_patterns=["flownet.pkl"],
+ require_all=True,
+ )
+
+
+def _select_torch_device() -> torch.device:
+ try:
+ from vllm_omni.platforms import current_omni_platform
+
+ return current_omni_platform.get_torch_device()
+ except Exception as exc:
+ logger.warning("Failed to resolve current vLLM-Omni torch device: %s", exc)
+
+ if torch.cuda.is_available():
+ return torch.device("cuda")
+ return torch.device("cpu")
+
+
+def _normalize_video_tensor_layout(video: torch.Tensor) -> tuple[torch.Tensor, Any]:
+ if video.ndim == 5:
+ if video.shape[1] in (3, 4):
+ return video, lambda out: out
+ if video.shape[2] in (3, 4):
+ return video.permute(0, 2, 1, 3, 4), lambda out: out.permute(0, 2, 1, 3, 4)
+ elif video.ndim == 4:
+ if video.shape[0] in (3, 4):
+ return video.unsqueeze(0), lambda out: out.squeeze(0)
+ if video.shape[1] in (3, 4):
+ return video.permute(1, 0, 2, 3).unsqueeze(0), lambda out: out.squeeze(0).permute(1, 0, 2, 3)
+ raise ValueError(f"Unsupported video tensor shape for interpolation: {tuple(video.shape)}")
+
+
+def _normalize_video_tensor_range(video: torch.Tensor) -> tuple[torch.Tensor, Any]:
+ original_dtype = video.dtype
+ video = video.detach()
+ if video.is_floating_point():
+ video = video.to(torch.float32)
+ if torch.amin(video) < 0.0 or torch.amax(video) > 1.0:
+ return video.clamp(-1.0, 1.0) * 0.5 + 0.5, lambda out: (out * 2.0 - 1.0).to(original_dtype)
+ return video.clamp(0.0, 1.0), lambda out: out.to(original_dtype)
+ return video.to(torch.float32) / 255.0, lambda out: (out * 255.0).round().clamp(0, 255).to(original_dtype)
+
+
+class FrameInterpolator:
+ """Lazy-loaded RIFE 4.22.lite frame interpolator."""
+
+ def __init__(self, model_path: str | None = None):
+ self._model_path = model_path
+ self._resolved_path: str | None = None
+
+ def _ensure_model_loaded(self, preferred_device: torch.device | None = None) -> Model:
+ resolved_path = _resolve_rife_model_path(self._model_path)
+ self._resolved_path = resolved_path
+ device = preferred_device or _select_torch_device()
+ cache_key = (resolved_path, str(device))
+
+ with _MODEL_CACHE_LOCK:
+ if cache_key in _MODEL_CACHE:
+ return _MODEL_CACHE[cache_key]
+
+ model = Model()
+ model.load_model(resolved_path)
+ model.eval()
+ model.flownet = model.flownet.to(device)
+ _MODEL_CACHE[cache_key] = model
+ logger.info("RIFE model loaded on device: %s", device)
+ return model
+
+ def _make_inference(
+ self,
+ model: Model,
+ img0: torch.Tensor,
+ img1: torch.Tensor,
+ n: int,
+ scale: float,
+ ) -> list[torch.Tensor]:
+ if n == 1:
+ return [model.inference(img0, img1, scale=scale)]
+ mid = model.inference(img0, img1, scale=scale)
+ return (
+ self._make_inference(model, img0, mid, n // 2, scale)
+ + [mid]
+ + self._make_inference(model, mid, img1, n // 2, scale)
+ )
+
+ def interpolate_tensor(
+ self,
+ video: torch.Tensor,
+ exp: int = 1,
+ scale: float = 1.0,
+ ) -> tuple[torch.Tensor, int]:
+ if exp < 1:
+ raise ValueError(f"frame interpolation exp must be >= 1, got {exp}")
+ if scale <= 0:
+ raise ValueError(f"frame interpolation scale must be > 0, got {scale}")
+
+ video, restore_layout = _normalize_video_tensor_layout(video)
+ if video.shape[2] < 2:
+ return restore_layout(video), 1
+
+ video, restore_range = _normalize_video_tensor_range(video)
+ # A CPU tensor may be transport/offload state rather than an execution
+ # choice, so only trust it when it is already on an accelerator.
+ preferred_device = video.device
+ if preferred_device.type == "cpu":
+ preferred_device = _select_torch_device()
+ model = self._ensure_model_loaded(preferred_device=preferred_device)
+ video = video.to(model.device())
+ intermediates_per_pair = 2**exp // 2
+
+ result_frames: list[torch.Tensor] = []
+ for idx in range(video.shape[2] - 1):
+ img0 = video[:, :, idx, :, :]
+ img1 = video[:, :, idx + 1, :, :]
+ result_frames.append(img0)
+ result_frames.extend(self._make_inference(model, img0, img1, intermediates_per_pair, scale))
+ result_frames.append(video[:, :, -1, :, :])
+ result = torch.stack(result_frames, dim=2)
+ return restore_layout(restore_range(result)), 2**exp
+
+
+def interpolate_video_tensor(
+ video: torch.Tensor,
+ exp: int = 1,
+ scale: float = 1.0,
+ model_path: str | None = None,
+) -> tuple[torch.Tensor, int]:
+ """Interpolate a video tensor and return the FPS multiplier."""
+ interpolator = FrameInterpolator(model_path=model_path)
+ return interpolator.interpolate_tensor(video, exp=exp, scale=scale)
diff --git a/vllm_omni/diffusion/registry.py b/vllm_omni/diffusion/registry.py
index 97bc7fa29..4001109cc 100644
--- a/vllm_omni/diffusion/registry.py
+++ b/vllm_omni/diffusion/registry.py
@@ -83,6 +83,16 @@ _DIFFUSION_MODELS = {
"pipeline_ltx2_image2video",
"LTX2ImageToVideoTwoStagesPipeline",
),
+ "LTX2T2VDMD2Pipeline": (
+ "ltx2",
+ "pipeline_ltx2",
+ "LTX2T2VDMD2Pipeline",
+ ),
+ "LTX2I2VDMD2Pipeline": (
+ "ltx2",
+ "pipeline_ltx2_image2video",
+ "LTX2I2VDMD2Pipeline",
+ ),
"StableAudioPipeline": (
"stable_audio",
"pipeline_stable_audio",
@@ -93,6 +103,16 @@ _DIFFUSION_MODELS = {
"pipeline_wan2_2_i2v",
"Wan22I2VPipeline",
),
+ "WanT2VDMD2Pipeline": (
+ "wan2_2",
+ "pipeline_wan2_2",
+ "WanT2VDMD2Pipeline",
+ ),
+ "WanI2VDMD2Pipeline": (
+ "wan2_2",
+ "pipeline_wan2_2_i2v",
+ "WanI2VDMD2Pipeline",
+ ),
"LongCatImagePipeline": (
"longcat_image",
"pipeline_longcat_image",
@@ -119,8 +139,8 @@ _DIFFUSION_MODELS = {
"FluxKontextPipeline",
),
"HunyuanImage3ForCausalMM": (
- "hunyuan_image_3",
- "pipeline_hunyuan_image_3",
+ "hunyuan_image3",
+ "pipeline_hunyuan_image3",
"HunyuanImage3Pipeline",
),
"Flux2KleinPipeline": (
@@ -357,8 +377,12 @@ _DIFFUSION_POST_PROCESS_FUNCS = {
"LTX2TwoStagesPipeline": "get_ltx2_post_process_func",
"LTX2ImageToVideoPipeline": "get_ltx2_post_process_func",
"LTX2ImageToVideoTwoStagesPipeline": "get_ltx2_post_process_func",
+ "LTX2T2VDMD2Pipeline": "get_ltx2_post_process_func",
+ "LTX2I2VDMD2Pipeline": "get_ltx2_post_process_func",
"StableAudioPipeline": "get_stable_audio_post_process_func",
"WanImageToVideoPipeline": "get_wan22_i2v_post_process_func",
+ "WanT2VDMD2Pipeline": "get_wan22_post_process_func",
+ "WanI2VDMD2Pipeline": "get_wan22_i2v_post_process_func",
"LongCatImagePipeline": "get_longcat_image_post_process_func",
"BagelPipeline": "get_bagel_post_process_func",
"LongCatImageEditPipeline": "get_longcat_image_post_process_func",
@@ -375,6 +399,7 @@ _DIFFUSION_POST_PROCESS_FUNCS = {
"HunyuanVideo15ImageToVideoPipeline": "get_hunyuan_video_15_i2v_post_process_func",
"MagiHumanPipeline": "get_magi_human_post_process_func",
"OmniVoicePipeline": "get_omnivoice_post_process_func",
+ "DreamIDOmniPipeline": "get_dreamid_omni_post_process_func",
}
_DIFFUSION_PRE_PROCESS_FUNCS = {
@@ -389,6 +414,8 @@ _DIFFUSION_PRE_PROCESS_FUNCS = {
"WanPipeline": "get_wan22_pre_process_func",
"WanVACEPipeline": "get_wan22_vace_pre_process_func",
"WanImageToVideoPipeline": "get_wan22_i2v_pre_process_func",
+ "WanT2VDMD2Pipeline": "get_wan22_pre_process_func",
+ "WanI2VDMD2Pipeline": "get_wan22_i2v_pre_process_func",
"OmniGen2Pipeline": "get_omnigen2_pre_process_func",
"HeliosPipeline": "get_helios_pre_process_func",
"HeliosPyramidPipeline": "get_helios_pre_process_func",
diff --git a/vllm_omni/diffusion/stage_diffusion_client.py b/vllm_omni/diffusion/stage_diffusion_client.py
index 7e740dc89..ff2d9f1e8 100644
--- a/vllm_omni/diffusion/stage_diffusion_client.py
+++ b/vllm_omni/diffusion/stage_diffusion_client.py
@@ -8,15 +8,20 @@ from any stage client.
from __future__ import annotations
import asyncio
+import multiprocessing.connection
import time
import uuid
+import weakref
from dataclasses import fields, is_dataclass
+from threading import Thread
from typing import TYPE_CHECKING, Any
import zmq
from vllm.logger import init_logger
+from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.diffusion.stage_diffusion_proc import (
+ StageDiffusionProc,
complete_diffusion_handshake,
spawn_diffusion_proc,
)
@@ -34,6 +39,24 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
+def create_diffusion_client(
+ model: str,
+ od_config: OmniDiffusionConfig,
+ metadata: StageMetadata,
+ stage_init_timeout: int,
+ batch_size: int = 1,
+ use_inline: bool = False,
+) -> Any:
+ """Factory to create either an inline or out-of-process diffusion client."""
+ if use_inline:
+ from vllm_omni.diffusion.inline_stage_diffusion_client import InlineStageDiffusionClient
+
+ return InlineStageDiffusionClient(model, od_config, metadata, batch_size=batch_size)
+ return StageDiffusionClient(
+ model, od_config, metadata, stage_init_timeout=stage_init_timeout, batch_size=batch_size
+ )
+
+
class StageDiffusionClient:
"""Communicates with StageDiffusionProc via ZMQ for use inside the Orchestrator.
@@ -111,6 +134,12 @@ class StageDiffusionClient:
self._pending_rpcs: set[str] = set()
self._tasks: dict[str, asyncio.Task] = {}
self._shutting_down = False
+ self._engine_dead: bool = False
+
+ # Background thread to detect silent process death (SIGKILL, segfault)
+ # where the subprocess cannot send the ZMQ death sentinel.
+ # Mirrors MPClient.start_engine_core_monitor() in vLLM.
+ self._start_proc_monitor()
logger.info(
"[StageDiffusionClient] Stage-%s initialized (owns_process=%s, batch_size=%d)",
@@ -119,6 +148,37 @@ class StageDiffusionClient:
batch_size,
)
+ # ------------------------------------------------------------------
+ # Process monitor (mirrors vLLM's MPClient.start_engine_core_monitor)
+ # ------------------------------------------------------------------
+
+ def _start_proc_monitor(self) -> None:
+ """Start a daemon thread that watches the subprocess sentinel.
+
+ When the subprocess dies without sending the ZMQ death sentinel
+ (e.g. SIGKILL, segfault), this thread sets ``_engine_dead`` so
+ subsequent calls raise ``EngineDeadError``.
+ """
+ proc = self._proc
+ self_ref = weakref.ref(self)
+
+ def _monitor() -> None:
+ try:
+ multiprocessing.connection.wait([proc.sentinel])
+ except Exception:
+ return
+ client = self_ref()
+ if client is None or client._shutting_down or client._engine_dead:
+ return
+ client._engine_dead = True
+ logger.error(
+ "[StageDiffusionClient] Stage-%s StageDiffusionProc died unexpectedly (exit code %s).",
+ client.stage_id,
+ proc.exitcode,
+ )
+
+ Thread(target=_monitor, daemon=True, name="DiffusionProcMonitor").start()
+
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@@ -131,6 +191,15 @@ class StageDiffusionClient:
except zmq.Again:
break
+ # Check for the death sentinel (raw bytes, not msgpack-encoded).
+ if raw == StageDiffusionProc.DIFFUSION_PROC_DEAD:
+ self._engine_dead = True
+ logger.error(
+ "[StageDiffusionClient] Stage-%s received DIFFUSION_PROC_DEAD sentinel from subprocess.",
+ self.stage_id,
+ )
+ break
+
msg = self._decoder.decode(raw)
msg_type = msg.get("type")
@@ -154,6 +223,10 @@ class StageDiffusionClient:
"error": True,
"reason": error_msg,
}
+ # Route request errors as error outputs so the Orchestrator
+ # sees the request complete (instead of hanging forever).
+ if req_id is not None:
+ self._output_queue.put_nowait(OmniRequestOutput.from_error(req_id, error_msg))
# Fields that are subprocess-local and cannot be serialized across
# process boundaries. They are recreated in the subprocess with
@@ -217,6 +290,8 @@ class StageDiffusionClient:
sampling_params: OmniDiffusionSamplingParams,
kv_sender_info: dict[int, dict[str, Any]] | None = None,
) -> None:
+ if self._engine_dead:
+ raise EngineDeadError()
self._request_socket.send(
self._encoder.encode(
{
@@ -244,6 +319,8 @@ class StageDiffusionClient:
and the combined result is placed on the output queue with a single
*request_id*.
"""
+ if self._engine_dead:
+ raise EngineDeadError()
task = asyncio.create_task(
self._run_batch(
request_id,
@@ -281,6 +358,7 @@ class StageDiffusionClient:
request_id,
e,
)
+ await self._output_queue.put(OmniRequestOutput.from_error(request_id, str(e)))
finally:
self._tasks.pop(request_id, None)
@@ -289,7 +367,10 @@ class StageDiffusionClient:
try:
return self._output_queue.get_nowait()
except asyncio.QueueEmpty:
+ if self._engine_dead:
+ raise EngineDeadError()
if not self._shutting_down and self._owns_process and self._proc is not None and not self._proc.is_alive():
+ self._engine_dead = True
exitcode = self._proc.exitcode
# One final drain – the last ZMQ frame may have arrived
# between the first drain and the is_alive() check.
@@ -303,7 +384,7 @@ class StageDiffusionClient:
logger.warning("StageDiffusionProc was killed by signal %d; treating as external shutdown.", sig)
self._shutting_down = True
return None
- raise RuntimeError(f"StageDiffusionProc died unexpectedly (exit code {exitcode})")
+ raise EngineDeadError(f"StageDiffusionProc died unexpectedly (exit code {exitcode})")
return None
async def abort_requests_async(self, request_ids: list[str]) -> None:
@@ -324,6 +405,9 @@ class StageDiffusionClient:
kwargs: dict[str, Any] | None = None,
) -> Any:
"""Forward control RPCs to the diffusion subprocess."""
+ if self._engine_dead:
+ raise EngineDeadError()
+
# Inject a default profile_prefix that includes stage_id when profiling.
if method == "profile":
args_list = list(args)
@@ -361,8 +445,9 @@ class StageDiffusionClient:
self._drain_responses()
if rpc_id in self._rpc_results:
return self._rpc_results.pop(rpc_id)
- if self._owns_process and self._proc is not None and not self._proc.is_alive():
- raise RuntimeError(
+ if self._engine_dead or (self._owns_process and self._proc is not None and not self._proc.is_alive()):
+ self._engine_dead = True
+ raise EngineDeadError(
f"StageDiffusionProc died while waiting for "
f"collective_rpc '{method}' (exit code {self._proc.exitcode})"
)
@@ -372,6 +457,19 @@ class StageDiffusionClient:
finally:
self._pending_rpcs.discard(rpc_id)
+ def check_health(self) -> None:
+ """Raise ``EngineDeadError`` if the diffusion engine is dead.
+
+ Mirrors the ``check_health`` protocol on vLLM's ``EngineClient``.
+ """
+ if self._engine_dead:
+ raise EngineDeadError(f"Stage-{self.stage_id} diffusion subprocess is dead")
+ if self._proc is not None and not self._proc.is_alive():
+ self._engine_dead = True
+ raise EngineDeadError(
+ f"Stage-{self.stage_id} diffusion subprocess is not alive (exit code: {self._proc.exitcode})."
+ )
+
def shutdown(self) -> None:
self._shutting_down = True
try:
diff --git a/vllm_omni/diffusion/stage_diffusion_proc.py b/vllm_omni/diffusion/stage_diffusion_proc.py
index cef697630..d36c5c644 100644
--- a/vllm_omni/diffusion/stage_diffusion_proc.py
+++ b/vllm_omni/diffusion/stage_diffusion_proc.py
@@ -19,12 +19,11 @@ import zmq
import zmq.asyncio
from PIL import Image
from vllm.logger import init_logger
-from vllm.transformers_utils.config import get_hf_file_to_dict
from vllm.utils.network_utils import get_open_zmq_ipc_path, zmq_socket_ctx
from vllm.utils.system_utils import get_mp_context
from vllm.v1.utils import shutdown
-from vllm_omni.diffusion.data import DiffusionRequestAbortedError, TransformerConfig
+from vllm_omni.diffusion.data import DiffusionRequestAbortedError
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.distributed.omni_connectors.utils.serialization import (
@@ -47,6 +46,8 @@ class StageDiffusionProc:
and ZMQ-based communication with StageDiffusionClient.
"""
+ DIFFUSION_PROC_DEAD = b"DIFFUSION_PROC_DEAD"
+
def __init__(self, model: str, od_config: OmniDiffusionConfig) -> None:
self._model = model
self._od_config = od_config
@@ -66,47 +67,8 @@ class StageDiffusionProc:
logger.info("StageDiffusionProc initialized with model: %s", self._model)
def _enrich_config(self) -> None:
- """Load model metadata from HuggingFace and populate od_config fields.
-
- Diffusers-style models expose ``model_index.json`` with ``_class_name``.
- Non-diffusers models (e.g. Bagel, NextStep) only have ``config.json``,
- so we fall back to reading that and mapping model_type manually.
- """
- od_config = self._od_config
-
- try:
- config_dict = get_hf_file_to_dict("model_index.json", od_config.model)
- if config_dict is not None:
- if od_config.model_class_name is None:
- od_config.model_class_name = config_dict.get("_class_name", None)
- od_config.update_multimodal_support()
-
- tf_config_dict = get_hf_file_to_dict("transformer/config.json", od_config.model)
- od_config.tf_model_config = TransformerConfig.from_dict(tf_config_dict)
- else:
- raise FileNotFoundError("model_index.json not found")
- except (AttributeError, OSError, ValueError, FileNotFoundError):
- cfg = get_hf_file_to_dict("config.json", od_config.model)
- if cfg is None:
- raise ValueError(f"Could not find config.json or model_index.json for model {od_config.model}")
-
- od_config.tf_model_config = TransformerConfig.from_dict(cfg)
- model_type = cfg.get("model_type")
- architectures = cfg.get("architectures") or []
-
- if model_type == "bagel" or "BagelForConditionalGeneration" in architectures:
- od_config.model_class_name = "BagelPipeline"
- od_config.tf_model_config = TransformerConfig()
- od_config.update_multimodal_support()
- elif model_type == "nextstep":
- if od_config.model_class_name is None:
- od_config.model_class_name = "NextStep11Pipeline"
- od_config.tf_model_config = TransformerConfig()
- od_config.update_multimodal_support()
- elif architectures and len(architectures) == 1:
- od_config.model_class_name = architectures[0]
- else:
- raise
+ """Load model metadata from HuggingFace and populate od_config fields."""
+ self._od_config.enrich_config()
# ------------------------------------------------------------------
# Request processing
@@ -490,6 +452,16 @@ class StageDiffusionProc:
elif msg_type == "shutdown":
break
+ except Exception:
+ # Send the death sentinel so the client can detect the
+ # fatal failure promptly (mirrors EngineCoreProc._send_engine_dead).
+ try:
+ response_socket.setsockopt(zmq.LINGER, 4000)
+ await response_socket.send(StageDiffusionProc.DIFFUSION_PROC_DEAD)
+ except Exception:
+ logger.warning("Failed to send DIFFUSION_PROC_DEAD sentinel to client.")
+ raise
+
finally:
for task in tasks.values():
task.cancel()
diff --git a/vllm_omni/diffusion/utils/prompt_utils.py b/vllm_omni/diffusion/utils/prompt_utils.py
new file mode 100644
index 000000000..fc1769f4d
--- /dev/null
+++ b/vllm_omni/diffusion/utils/prompt_utils.py
@@ -0,0 +1,38 @@
+import torch
+
+
+def validate_prompt_sequence_lengths(
+ attention_mask: torch.Tensor,
+ *,
+ max_sequence_length: int,
+ supported_max_sequence_length: int,
+ prompt_name: str = "prompt",
+ length_offset: int = 0,
+ baseline_attention_mask: torch.Tensor | None = None,
+ error_context: str,
+) -> None:
+ sequence_lengths = attention_mask.sum(dim=1)
+ if baseline_attention_mask is not None:
+ # Some callers need to validate only the user-controlled portion of a
+ # templated prompt. In those cases we subtract the fully-tokenized
+ # template baseline instead of only removing a fixed prefix length,
+ # because the template may also contribute a suffix or image markers.
+ baseline_lengths = baseline_attention_mask.sum(dim=1)
+ if baseline_lengths.shape[0] == 1 and sequence_lengths.shape[0] > 1:
+ baseline_lengths = baseline_lengths.expand(sequence_lengths.shape[0])
+ sequence_lengths = sequence_lengths - baseline_lengths
+ if length_offset:
+ sequence_lengths = sequence_lengths - length_offset
+ sequence_lengths = torch.clamp(sequence_lengths, min=0)
+ too_long = torch.nonzero(sequence_lengths > max_sequence_length, as_tuple=False)
+ if too_long.numel() == 0:
+ return
+
+ batch_idx = int(too_long[0].item())
+ actual_length = int(sequence_lengths[batch_idx].item())
+ prompt_ref = f"`{prompt_name}` at batch index {batch_idx}" if attention_mask.shape[0] > 1 else f"`{prompt_name}`"
+ raise ValueError(
+ f"{prompt_ref} is too long {error_context}: got {actual_length} tokens, but "
+ f"`max_sequence_length` is {max_sequence_length}. Shorten the prompt or increase "
+ f"`max_sequence_length` up to {supported_max_sequence_length}."
+ )
diff --git a/vllm_omni/diffusion/worker/diffusion_worker.py b/vllm_omni/diffusion/worker/diffusion_worker.py
index 160309e0d..621f7eebd 100644
--- a/vllm_omni/diffusion/worker/diffusion_worker.py
+++ b/vllm_omni/diffusion/worker/diffusion_worker.py
@@ -27,7 +27,10 @@ from vllm.v1.worker.workspace import init_workspace_manager
from vllm_omni.diffusion.data import (
DiffusionOutput,
+ OmniACK,
OmniDiffusionConfig,
+ OmniSleepTask,
+ OmniWakeTask,
)
from vllm_omni.diffusion.distributed.parallel_state import (
destroy_distributed_env,
@@ -77,6 +80,7 @@ class DiffusionWorker:
self.model_runner: DiffusionModelRunner | None = None
self._sleep_saved_buffers: dict[str, torch.Tensor] = {}
self.lora_manager: DiffusionLoRAManager | None = None
+ self.stage_id = getattr(od_config, "stage_id", 0)
self.init_device()
# Create model runner
self.model_runner = DiffusionModelRunner(
@@ -159,8 +163,10 @@ class DiffusionWorker:
def _get_profiler(self) -> WorkerProfiler | None:
return getattr(self, "profiler", None)
- def load_model(self, load_format: str = "default", custom_pipeline_name: str | None = None) -> None:
+ def load_model(self, load_format: str = "default", custom_pipeline_name: str | None = None, **kwargs) -> None:
"""Load the diffusion model using DiffusionModelRunner."""
+ load_format = kwargs.get("load_format", load_format)
+ custom_pipeline_name = kwargs.get("custom_pipeline_name", custom_pipeline_name)
with (
set_forward_context(vllm_config=self.vllm_config, omni_diffusion_config=self.od_config),
set_current_vllm_config(self.vllm_config),
@@ -170,6 +176,8 @@ class DiffusionWorker:
load_format=load_format,
custom_pipeline_name=custom_pipeline_name,
)
+ current_omni_platform.synchronize()
+ gc.collect()
process_memory = get_process_gpu_memory(self.local_rank)
if process_memory is not None:
logger.info(
@@ -284,77 +292,176 @@ class DiffusionWorker:
"""
from vllm.device_allocator.cumem import CuMemAllocator
- process_memory_before_sleep = get_process_gpu_memory(self.local_rank)
- free_bytes_before_sleep = None
- if process_memory_before_sleep is None:
- free_bytes_before_sleep = current_omni_platform.get_free_memory()
+ allocator = CuMemAllocator.get_instance()
+
+ usage_before = allocator.get_current_usage()
- # Save the buffers before level 2 sleep
if level == 2 and self.model_runner is not None:
+ if hasattr(self.model_runner, "graph_runners"):
+ self.model_runner.graph_runners.clear()
+ logger.info(f"[Worker {self.rank}] CUDA Graphs cleared.")
model = self.model_runner.pipeline
self._sleep_saved_buffers = {name: buffer.cpu().clone() for name, buffer in model.named_buffers()}
- allocator = CuMemAllocator.get_instance()
- allocator.sleep(offload_tags=("weights",) if level == 1 else tuple())
- process_memory_after_sleep = get_process_gpu_memory(self.local_rank)
- if process_memory_before_sleep is not None and process_memory_after_sleep is not None:
- freed_bytes = process_memory_before_sleep - process_memory_after_sleep
- used_bytes = process_memory_after_sleep
- accounting_scope = "process-scoped"
+ free_mem_before = current_omni_platform.get_free_memory()
+
+ # Level 1: Offload weights; Level 2: Total Discard
+ offload_tags = ("weights",) if level == 1 else tuple()
+ allocator.sleep(offload_tags=offload_tags)
+
+ current_omni_platform.empty_cache()
+ current_omni_platform.synchronize()
+
+ free_mem_after = current_omni_platform.get_free_memory()
+ try:
+ total_mem = current_omni_platform.get_device_total_memory()
+ except (NotImplementedError, AttributeError):
+ total_mem = torch.cuda.get_device_properties(self.device).total_memory
+
+ phys_freed_bytes = max(0, free_mem_after - free_mem_before)
+ phys_used_bytes = total_mem - free_mem_after
+
+ if usage_before > 0:
+ logger.info(
+ f"[Diffusion Worker {self.rank}] Sleep Level {level}: "
+ f"physically freed {phys_freed_bytes / GiB_bytes:.2f} GiB, "
+ f"{phys_used_bytes / GiB_bytes:.2f} GiB is still in use."
+ )
else:
- free_bytes_after_sleep = current_omni_platform.get_free_memory()
- assert free_bytes_before_sleep is not None
- device_id = self.device.index if self.device.index is not None else 0
- total = current_omni_platform.get_device_total_memory(device_id)
- freed_bytes = free_bytes_after_sleep - free_bytes_before_sleep
- used_bytes = total - free_bytes_after_sleep
- accounting_scope = "device-scoped fallback"
- assert freed_bytes >= 0, "Memory usage increased after sleeping."
- logger.info(
- "Sleep mode (%s) freed %.2f GiB memory, %.2f GiB memory is still in use.",
- accounting_scope,
- freed_bytes / GiB_bytes,
- used_bytes / GiB_bytes,
- )
- return True
+ logger.info(f"[Worker {self.rank}] Sleep Level {level} completed (GPU was already empty).")
+ logger.info(f"[Worker {self.rank}] Memory usage before sleep: {usage_before / GiB_bytes:.2f} GiB.")
+ return usage_before
def wake_up(self, tags: list[str] | None = None) -> bool:
"""
- Wake up the worker from sleep mode. See the sleep function
- method for more details.
+ Wake up the worker from sleep mode.
+
+ Re-activates the memory allocator for the specified tags and restores
+ model buffers from CPU back to GPU if they were saved during Level 2 sleep.
Args:
- tags: An optional list of tags to reallocate the worker memory
- for specific memory allocations. Values must be in
- `("weights")`. If None, all memory is reallocated.
- wake_up should be called with all tags (or None) before the
- worker is used again.
+ tags: List of memory pool tags to re-activate (e.g., ["weights"]
+ to match Level 1 sleep). If None, all pools are re-activated.
"""
from vllm.device_allocator.cumem import CuMemAllocator
allocator = CuMemAllocator.get_instance()
allocator.wake_up(tags)
-
- # Restore the buffers after level 2 sleep
+ current_omni_platform.synchronize()
if len(self._sleep_saved_buffers) and self.model_runner is not None:
model = self.model_runner.pipeline
for name, buffer in model.named_buffers():
if name in self._sleep_saved_buffers:
buffer.data.copy_(self._sleep_saved_buffers[name].data)
self._sleep_saved_buffers = {}
+ logger.info(f"[Worker {self.rank}] Buffers restored from CPU.")
+ logger.info(f"[Worker {self.rank}] Wake-up complete.")
return True
+ def handle_sleep_task(self, task: OmniSleepTask) -> OmniACK:
+ from vllm_omni.platforms import current_omni_platform
+
+ try:
+ if isinstance(task, dict):
+ task = OmniSleepTask(**task)
+ logger.info(f"[Worker {self.rank}] Handshake Received: Task {task.task_id}")
+
+ current_omni_platform.synchronize()
+ usage_before = current_omni_platform.get_current_memory_usage(self.device)
+ self.sleep(level=task.level)
+ current_omni_platform.synchronize()
+ usage_after = current_omni_platform.get_current_memory_usage(self.device)
+ real_freed = max(0, usage_before - usage_after)
+ logger.info(f"[Worker {self.rank}] Preparing ACK: freed_bytes={real_freed / GiB_bytes:.2f} GiB.")
+
+ # Ensure all ranks have completed sleep before measuring memory and sending ACK
+ if torch.distributed.is_initialized():
+ t_freed = torch.tensor([float(real_freed)], device=self.device)
+ torch.distributed.all_reduce(t_freed)
+ real_freed = int(t_freed.item())
+
+ if self.rank != 0:
+ return None
+
+ ack = OmniACK(
+ task_id=task.task_id,
+ status="SUCCESS",
+ stage_id=self.stage_id,
+ rank=self.rank,
+ freed_bytes=real_freed,
+ # return RL need metadata
+ metadata={
+ "source": f"Platform_{current_omni_platform.get_device_name()}",
+ "total_freed_gib": f"{real_freed / GiB_bytes:.2f}",
+ "rank_residual_gib": f"{usage_after / GiB_bytes:.2f}",
+ },
+ )
+ logger.info(f"[Worker {self.rank}] ACK emitted. Freed {real_freed / GiB_bytes:.2f} GiB.")
+ return ack
+ except Exception as e:
+ logger.error(f"Sleep failed: {e}", exc_info=True)
+ if torch.distributed.is_initialized():
+ try:
+ torch.distributed.barrier()
+ except Exception:
+ pass
+ return OmniACK(task_id=task.task_id, status="ERROR", error_msg=str(e))
+
+ def handle_wake_task(self, task: OmniWakeTask) -> OmniACK:
+ from vllm_omni.platforms import current_omni_platform
+
+ try:
+ if isinstance(task, dict):
+ task = OmniWakeTask(**task)
+ logger.info(f"[Worker {self.rank}] Responding to Wake-up Task: {task.task_id}")
+ self.wake_up(tags=task.tags)
+
+ logger.info(f"[Worker {self.rank}] wake_up logic finished, entering barrier...")
+ if torch.distributed.is_initialized():
+ torch.distributed.barrier()
+
+ current_omni_platform.synchronize()
+ usage_now = current_omni_platform.get_current_memory_usage(self.device)
+ current_used_gib = usage_now / (1024**3)
+
+ if self.rank != 0:
+ return None
+ logger.info(f"[Worker {self.rank}] PASSED barrier, about to return to loop.")
+
+ return OmniACK(
+ task_id=task.task_id,
+ status="SUCCESS",
+ stage_id=self.stage_id,
+ rank=self.rank,
+ metadata={
+ "state": "WARM",
+ "source": f"Platform_{current_omni_platform.get_device_name()}",
+ "current_vram_gib": f"{current_used_gib:.2f}",
+ },
+ )
+ except Exception as e:
+ logger.error(f"Wake-up failed on Rank {self.rank}: {e}", exc_info=True)
+ if torch.distributed.is_initialized():
+ try:
+ torch.distributed.barrier()
+ except Exception:
+ pass
+ return OmniACK(task_id=task.task_id, status="ERROR", error_msg=str(e))
+
def _maybe_get_memory_pool_context(self, tag: str) -> AbstractContextManager:
"""Get memory pool context for sleep mode support."""
- if self.od_config.enable_sleep_mode:
+ is_sleep_enabled = getattr(self.od_config, "enable_sleep_mode", False)
+ if is_sleep_enabled:
+ current_omni_platform.synchronize()
+ gc.collect()
from vllm.device_allocator.cumem import CuMemAllocator
allocator = CuMemAllocator.get_instance()
if tag == "weights":
assert allocator.get_current_usage() == 0, "Sleep mode can only be used for one instance per process."
+ logger.info(f"[Worker {self.rank}] Activating Diffusion CuMem pool for tag: {tag}")
return allocator.use_memory_pool(tag=tag)
- else:
- return nullcontext()
+ return nullcontext()
def shutdown(self) -> None:
"""Shutdown the worker and cleanup distributed environment."""
@@ -395,11 +502,13 @@ class WorkerProc:
od_config: OmniDiffusionConfig,
gpu_id: int,
broadcast_handle,
+ wake_event: mp.Event,
worker_extension_cls: str | None = None,
custom_pipeline_args: dict[str, Any] | None = None,
):
self.od_config = od_config
self.gpu_id = gpu_id
+ self.wake_event = wake_event
# Inter-process Communication
self.context = zmq.Context(io_threads=2)
@@ -414,7 +523,13 @@ class WorkerProc:
if gpu_id == 0:
self.result_mq = MessageQueue(n_reader=1, n_local_reader=1, local_reader_ranks=[0])
self.result_mq_handle = self.result_mq.export_handle()
+ WorkerProc._shared_result_handle = self.result_mq_handle
logger.info(f"Worker {gpu_id} created result MessageQueue")
+ else:
+ handle = getattr(WorkerProc, "_shared_result_handle", None)
+ if handle:
+ self.result_mq = MessageQueue.create_from_handle(handle, gpu_id)
+ logger.info(f"Worker {gpu_id} attached to shared result MessageQueue")
assert od_config.master_port is not None
@@ -438,13 +553,17 @@ class WorkerProc:
)
return wrapper
- def return_result(self, output: object):
+ def return_result(self, output: Any):
"""Reply to client, only on rank 0."""
if self.result_mq is not None:
+ if isinstance(output, OmniACK):
+ self.result_mq.enqueue(output)
+ return
try:
pack_diffusion_output_shm(output)
except Exception as e:
- logger.warning("SHM pack failed, falling back to raw enqueue: %s", e)
+ if hasattr(output, "output"):
+ logger.warning("SHM pack failed for model output: %s", e)
self.result_mq.enqueue(output)
def recv_message(self):
@@ -480,20 +599,31 @@ class WorkerProc:
while self._running:
msg = None
try:
- msg = self.recv_message()
- except Exception as e:
- logger.error(
- f"Error receiving message in worker loop: {e}",
- exc_info=True,
- )
+ msg = self.mq.dequeue(timeout=1.0)
+ except Exception:
+ if self.wake_event and self.wake_event.is_set():
+ self.wake_event.clear()
+ logger.info(f"Worker {self.gpu_id} caught OOB POKE, forcing wake-up sequence.")
+ msg = {"type": "wake_up", "task_id": "recovery-task", "tags": None}
+ else:
+ continue
+ if msg is None:
continue
if msg is None or len(msg) == 0:
logger.warning("Worker %s: Received empty payload, ignoring", self.gpu_id)
continue
+ if isinstance(msg, dict) and msg.get("type") == "sleep":
+ task = OmniSleepTask(level=msg.get("level", 2), task_id=msg.get("task_id", "local"))
+ ack = self.worker.handle_sleep_task(task)
+ self.return_result(ack)
+ elif isinstance(msg, dict) and msg.get("type") == "wake_up":
+ task = OmniWakeTask(tags=msg.get("tags"), task_id=msg.get("task_id", "local"))
+ ack = self.worker.handle_wake_task(task)
+ self.return_result(ack)
# Route message based on type
- if isinstance(msg, dict) and msg.get("type") == "rpc":
+ elif isinstance(msg, dict) and msg.get("type") == "rpc":
try:
result, should_reply = self.execute_rpc(msg)
if should_reply:
@@ -538,6 +668,7 @@ class WorkerProc:
od_config: OmniDiffusionConfig,
pipe_writer: mp.connection.Connection,
broadcast_handle,
+ wake_event: mp.Event,
worker_extension_cls: str | None = None,
custom_pipeline_args: dict[str, Any] | None = None,
) -> None:
@@ -549,6 +680,7 @@ class WorkerProc:
od_config,
gpu_id=rank,
broadcast_handle=broadcast_handle,
+ wake_event=wake_event,
worker_extension_cls=worker_extension_cls,
custom_pipeline_args=custom_pipeline_args,
)
@@ -574,6 +706,7 @@ class WorkerWrapperBase:
gpu_id: int,
od_config: OmniDiffusionConfig,
base_worker_class: type = DiffusionWorker,
+ wake_event: mp.Event = None,
worker_extension_cls: str | None = None,
custom_pipeline_args: dict[str, Any] | None = None,
):
@@ -734,6 +867,12 @@ class WorkerWrapperBase:
"""
return self.worker.wake_up(tags)
+ def handle_sleep_task(self, task):
+ return self.worker.handle_sleep_task(task)
+
+ def handle_wake_task(self, task):
+ return self.worker.handle_wake_task(task)
+
def shutdown(self) -> None:
"""Shutdown the worker and cleanup resources."""
return self.worker.shutdown()
diff --git a/vllm_omni/distributed/omni_connectors/kv_transfer_manager.py b/vllm_omni/distributed/omni_connectors/kv_transfer_manager.py
index 1958c9d40..ad008c397 100644
--- a/vllm_omni/distributed/omni_connectors/kv_transfer_manager.py
+++ b/vllm_omni/distributed/omni_connectors/kv_transfer_manager.py
@@ -14,8 +14,20 @@ from vllm.logger import init_logger
from .factory import OmniConnectorFactory
from .utils.config import ConnectorSpec
-from .utils.initialization import KV_TRANSFER_PORT_OFFSET
-from .utils.kv_utils import normalize_layer_kv
+from .utils.initialization import KV_RANK_PORT_STRIDE
+from .utils.kv_utils import (
+ KVTPTopology,
+ build_rank_aware_recv_keys,
+ build_rank_aware_send_keys,
+ get_kv_target_ranks,
+ get_local_tp_rank,
+ get_tp_world_size,
+ kv_zmq_port,
+ merge_received_rank_shards,
+ normalize_layer_kv,
+ slice_layer_blocks,
+ slice_received_rank_shard,
+)
logger = init_logger(__name__)
@@ -57,6 +69,8 @@ class OmniKVCacheConfig:
need_recv_cache: bool = False
need_send_cache: bool = False
recv_timeout: float = 30.0
+ from_tp: int = 1
+ to_tp: int = 1
@dataclass
@@ -72,82 +86,44 @@ class KVCacheTransferData:
"""Convert to dictionary for serialization."""
return asdict(self)
- def to_bytes(self) -> bytes:
- """Convert to compact binary format for fast transfer."""
+ def _build_tensors_desc(self, *, cpu: bool) -> tuple[list[dict[str, Any]], list, int, torch.device | None]:
+ """Iterate layer blocks and build tensor descriptors + data chunks.
+
+ Returns ``(tensors_desc, chunks, total_bytes, device)``.
+ *chunks* contains ``bytes`` when *cpu* is True, flat uint8 GPU tensors otherwise.
+ """
tensors_desc: list[dict[str, Any]] = []
- tensor_bufs: list[bytes] = []
- data_offset = 0
-
- for cache_name in ("key_cache", "value_cache"):
- cache_list = self.layer_blocks.get(cache_name, [])
- for layer_idx, tensor in enumerate(cache_list):
- if tensor is None:
- tensors_desc.append({"n": f"{cache_name}_{layer_idx}", "x": True})
- continue
-
- t = tensor.detach().cpu().contiguous()
- dtype_str = str(t.dtype).removeprefix("torch.")
- raw = t.view(torch.uint8).numpy().tobytes()
- tensors_desc.append(
- {
- "n": f"{cache_name}_{layer_idx}",
- "i": layer_idx,
- "d": dtype_str,
- "s": list(t.shape),
- "o": data_offset,
- "b": len(raw),
- }
- )
- tensor_bufs.append(raw)
- data_offset += len(raw)
-
- header = json.dumps(
- {
- "rid": self.request_id,
- "bids": self.block_ids,
- "meta": self.metadata,
- "td": tensors_desc,
- "nl": len(self.layer_blocks.get("key_cache", [])),
- },
- separators=(",", ":"),
- ).encode("utf-8")
- return b"".join([struct.pack(">I", len(header)), header] + tensor_bufs)
-
- def to_gpu_tensor(self) -> torch.Tensor:
- """Convert to a packed GPU tensor for raw-data connectors."""
- tensors_desc: list[dict[str, Any]] = []
- gpu_tensors: list[torch.Tensor] = []
+ chunks: list = []
data_offset = 0
device = None
for cache_name in ("key_cache", "value_cache"):
- cache_list = self.layer_blocks.get(cache_name, [])
- for layer_idx, tensor in enumerate(cache_list):
+ for layer_idx, tensor in enumerate(self.layer_blocks.get(cache_name, [])):
if tensor is None:
tensors_desc.append({"n": f"{cache_name}_{layer_idx}", "x": True})
continue
-
t = tensor.detach().contiguous()
- if device is None and t.is_cuda:
+ if cpu:
+ t = t.cpu()
+ elif device is None and t.is_cuda:
device = t.device
- dtype_str = str(t.dtype).removeprefix("torch.")
nbytes = t.numel() * t.element_size()
tensors_desc.append(
{
"n": f"{cache_name}_{layer_idx}",
"i": layer_idx,
- "d": dtype_str,
+ "d": str(t.dtype).removeprefix("torch."),
"s": list(t.shape),
"o": data_offset,
"b": nbytes,
}
)
- gpu_tensors.append(t.view(torch.uint8).flatten())
+ chunks.append(t.view(torch.uint8).numpy().tobytes() if cpu else t.view(torch.uint8).flatten())
data_offset += nbytes
- if device is None:
- raise RuntimeError("No CUDA tensors found, use to_bytes() instead")
+ return tensors_desc, chunks, data_offset, device
+ def _build_header_bytes(self, tensors_desc: list[dict[str, Any]]) -> bytes:
header = json.dumps(
{
"rid": self.request_id,
@@ -158,19 +134,26 @@ class KVCacheTransferData:
},
separators=(",", ":"),
).encode("utf-8")
+ return struct.pack(">I", len(header)) + header
- header_prefix = struct.pack(">I", len(header)) + header
- total_size = len(header_prefix) + data_offset
- output = torch.empty(total_size, dtype=torch.uint8, device=device)
- header_tensor = torch.frombuffer(bytearray(header_prefix), dtype=torch.uint8)
- output[: len(header_prefix)].copy_(header_tensor)
+ def to_bytes(self) -> bytes:
+ """Convert to compact binary format for fast transfer."""
+ tensors_desc, chunks, _, _ = self._build_tensors_desc(cpu=True)
+ return b"".join([self._build_header_bytes(tensors_desc)] + chunks)
+ def to_gpu_tensor(self) -> torch.Tensor:
+ """Convert to a packed GPU tensor for raw-data connectors."""
+ tensors_desc, chunks, data_offset, device = self._build_tensors_desc(cpu=False)
+ if device is None:
+ raise RuntimeError("No CUDA tensors found, use to_bytes() instead")
+ header_prefix = self._build_header_bytes(tensors_desc)
+ output = torch.empty(len(header_prefix) + data_offset, dtype=torch.uint8, device=device)
+ output[: len(header_prefix)].copy_(torch.frombuffer(bytearray(header_prefix), dtype=torch.uint8))
pos = len(header_prefix)
- for t_flat in gpu_tensors:
+ for t_flat in chunks:
n = t_flat.numel()
output[pos : pos + n].copy_(t_flat)
pos += n
-
return output
@staticmethod
@@ -237,11 +220,8 @@ class KVCacheTransferData:
return layer_idx
@staticmethod
- def from_bytes(raw: "bytes | bytearray | memoryview") -> dict[str, Any]:
- """Reconstruct KV cache data from the packed bytes format."""
- raw_mv = memoryview(raw) if not isinstance(raw, memoryview) else raw
- header, tensor_data_mv = KVCacheTransferData._load_header_from_memoryview(raw_mv)
-
+ def _populate_caches(header: dict[str, Any], get_tensor: callable) -> dict[str, Any]:
+ """Shared deserialization loop for both CPU and GPU paths."""
num_layers = header["nl"]
key_cache: list[torch.Tensor | None] = [None] * num_layers
value_cache: list[torch.Tensor | None] = [None] * num_layers
@@ -249,20 +229,9 @@ class KVCacheTransferData:
for info in header["td"]:
if info.get("x"):
continue
-
name: str = info["n"]
torch_dtype = KVCacheTransferData._resolve_torch_dtype(info["d"])
- offset, nbytes = KVCacheTransferData._validate_tensor_span(name, info, len(tensor_data_mv))
- t = (
- torch.frombuffer(
- tensor_data_mv,
- dtype=torch.uint8,
- offset=offset,
- count=nbytes,
- )
- .view(torch_dtype)
- .reshape(info["s"])
- )
+ t = get_tensor(info).view(torch_dtype).reshape(info["s"])
layer_idx = KVCacheTransferData._resolve_layer_idx(info, num_layers)
if name.startswith("key_cache_"):
key_cache[layer_idx] = t
@@ -276,37 +245,30 @@ class KVCacheTransferData:
"metadata": header["meta"],
}
+ @staticmethod
+ def from_bytes(raw: "bytes | bytearray | memoryview") -> dict[str, Any]:
+ """Reconstruct KV cache data from the packed bytes format."""
+ raw_mv = memoryview(raw) if not isinstance(raw, memoryview) else raw
+ header, tensor_data_mv = KVCacheTransferData._load_header_from_memoryview(raw_mv)
+ data_len = len(tensor_data_mv)
+
+ def _get(info: dict) -> torch.Tensor:
+ offset, nbytes = KVCacheTransferData._validate_tensor_span(info["n"], info, data_len)
+ return torch.frombuffer(tensor_data_mv, dtype=torch.uint8, offset=offset, count=nbytes)
+
+ return KVCacheTransferData._populate_caches(header, _get)
+
@staticmethod
def from_bytes_gpu(gpu_tensor: torch.Tensor) -> dict[str, Any]:
"""Reconstruct KV cache data from a packed GPU tensor."""
header, data_start = KVCacheTransferData._load_header_from_tensor(gpu_tensor)
+ data_len = int(gpu_tensor.numel()) - data_start
- num_layers = header["nl"]
- key_cache: list[torch.Tensor | None] = [None] * num_layers
- value_cache: list[torch.Tensor | None] = [None] * num_layers
- tensor_data_bytes = int(gpu_tensor.numel()) - data_start
+ def _get(info: dict) -> torch.Tensor:
+ offset, nbytes = KVCacheTransferData._validate_tensor_span(info["n"], info, data_len)
+ return gpu_tensor[data_start + offset : data_start + offset + nbytes].clone()
- for info in header["td"]:
- if info.get("x"):
- continue
-
- name: str = info["n"]
- torch_dtype = KVCacheTransferData._resolve_torch_dtype(info["d"])
- offset, nbytes = KVCacheTransferData._validate_tensor_span(name, info, tensor_data_bytes)
- t = gpu_tensor[data_start + offset : data_start + offset + nbytes].clone()
- t = t.view(torch_dtype).reshape(info["s"])
- layer_idx = KVCacheTransferData._resolve_layer_idx(info, num_layers)
- if name.startswith("key_cache_"):
- key_cache[layer_idx] = t
- elif name.startswith("value_cache_"):
- value_cache[layer_idx] = t
-
- return {
- "request_id": header["rid"],
- "layer_blocks": {"key_cache": key_cache, "value_cache": value_cache},
- "block_ids": header["bids"],
- "metadata": header["meta"],
- }
+ return KVCacheTransferData._populate_caches(header, _get)
class OmniKVTransferManager:
@@ -341,6 +303,30 @@ class OmniKVTransferManager:
else (None, None)
)
+ local_rank = get_local_tp_rank()
+
+ if config.from_tp <= 1 and config.to_tp <= 1:
+ detected_tp = get_tp_world_size()
+ from_tp = detected_tp
+ to_tp = detected_tp
+ else:
+ from_tp = config.from_tp
+ to_tp = config.to_tp
+
+ self._tp_topo = KVTPTopology(source_tp_size=from_tp, target_tp_size=to_tp, local_rank=local_rank)
+
+ # Injectable hooks (compatible with PR #2677 OmniConnectorModelRunnerMixin).
+ self.kv_send_key_builder: Callable | None = None
+ self.kv_recv_key_builder: Callable | None = None
+ self.kv_payload_merger: Callable | None = None
+ self.kv_payload_slicer: Callable | None = None
+
+ # Base sender endpoint (rank-0 host/port) stored during
+ # update_sender_info(). Used by the receive path to construct
+ # per-rank metadata for heterogeneous TP without querying a registry.
+ self._sender_base_host: str | None = None
+ self._sender_base_zmq_port: int | None = None
+
if config.need_send_cache and config.connector_config:
try:
_ = self.connector
@@ -348,11 +334,20 @@ class OmniKVTransferManager:
except Exception as e:
logger.warning("Failed to eagerly initialize sender connector: %s", e)
+ # ------------------------------------------------------------------ #
+ # Factory helpers
+ # ------------------------------------------------------------------ #
+
@classmethod
def _create(cls, cfg: dict | None) -> "OmniKVTransferManager":
"""Create manager from raw config dict."""
if not cfg or not isinstance(cfg, dict):
return cls(OmniKVCacheConfig())
+
+ rank_mapping = cfg.get("rank_mapping", {})
+ if not isinstance(rank_mapping, dict):
+ rank_mapping = {}
+
return cls(
OmniKVCacheConfig(
connector_config=cfg.get("connector_config"),
@@ -363,18 +358,17 @@ class OmniKVTransferManager:
need_recv_cache=cfg.get("need_recv_cache", False),
need_send_cache=cfg.get("need_send_cache", False),
recv_timeout=cfg.get("recv_timeout", 30.0),
+ from_tp=int(rank_mapping.get("from_tp", 1)),
+ to_tp=int(rank_mapping.get("to_tp", 1)),
)
)
@classmethod
- def from_model_config(cls, config: Any) -> "OmniKVTransferManager":
- """Create from model config (for AR model runner)."""
+ def from_od_config(cls, config: Any) -> "OmniKVTransferManager":
+ """Create from model or OmniDiffusion config."""
return cls._create(getattr(config, "omni_kv_config", None))
- @classmethod
- def from_od_config(cls, config: Any) -> "OmniKVTransferManager":
- """Create from OmniDiffusion config (for diffusion runner)."""
- return cls._create(getattr(config, "omni_kv_config", None))
+ from_model_config = from_od_config
@classmethod
def from_vllm_config(cls, vllm_config: Any, model_config: Any) -> "OmniKVTransferManager":
@@ -417,45 +411,33 @@ class OmniKVTransferManager:
)
c_extra["to_stage"] = str(self.config.to_stage) if self.config.to_stage is not None else "1"
+ try:
+ stage_int = int(self.config.from_stage) if self.config.from_stage is not None else 0
+ except (TypeError, ValueError):
+ stage_int = 0
+ zmq_port = kv_zmq_port(base_port, stage_int, self._tp_topo.local_rank)
+
if self.config.need_send_cache:
c_extra["role"] = "sender"
- from_stage = self.config.from_stage
- if from_stage is not None:
- try:
- c_extra["zmq_port"] = base_port + KV_TRANSFER_PORT_OFFSET + int(from_stage)
- except (TypeError, ValueError):
- c_extra["zmq_port"] = base_port + KV_TRANSFER_PORT_OFFSET
+ c_extra["zmq_port"] = zmq_port
elif self.config.need_recv_cache:
c_extra["role"] = "receiver"
- from_stage = self.config.from_stage
- sender_port = base_port + KV_TRANSFER_PORT_OFFSET
- if from_stage is not None:
- try:
- sender_port = base_port + KV_TRANSFER_PORT_OFFSET + int(from_stage)
- except (TypeError, ValueError):
- pass
c_extra.setdefault("sender_host", c_extra.get("host", "127.0.0.1"))
- c_extra.setdefault("sender_zmq_port", sender_port)
+ c_extra.setdefault("sender_zmq_port", zmq_port)
logger.info(
- "Initializing OmniConnector (purpose=kv_transfer) with config: %s, role: %s",
- cfg,
+ "Initializing OmniConnector type=%s role=%s",
+ c_type,
c_extra.get("role", "N/A"),
)
self._connector = OmniConnectorFactory.create_connector(ConnectorSpec(name=c_type, extra=c_extra))
- except Exception as e:
- logger.error(f"Failed to initialize OmniConnector: {e}")
- import traceback
-
- traceback.print_exc()
- # Cache failure sentinel to avoid repeated initialization attempts in hot paths.
+ except Exception:
+ logger.exception("Failed to initialize OmniConnector")
self._connector = False
return self._connector if self._connector else None
- def get_connector(self):
- """Get connector (compatibility wrapper for existing code)."""
- return self.connector
+ get_connector = property(lambda self: self.connector)
def _resolve_sender_info(
self, sender_info: dict[str, Any], sender_stage_id: str | int | None = None
@@ -513,8 +495,187 @@ class OmniKVTransferManager:
cache_list[idx] = tensor.clone()
return data
+ def _slice_transfer_data_for_target(self, kv_data: KVCacheTransferData, target_rank: int) -> KVCacheTransferData:
+ """Pre-slice sender payload for one target rank when sender TP < receiver TP."""
+ topo = self._tp_topo
+ ratio = topo.target_tp_size // topo.source_tp_size
+ offset_in_sender = target_rank % ratio
+ metadata = dict(kv_data.metadata) if isinstance(kv_data.metadata, dict) else {}
+ metadata["tp_head_slice"] = {
+ "applied": True,
+ "side": "sender",
+ "target_rank": target_rank,
+ "source_rank": topo.local_rank,
+ "from_tp": topo.source_tp_size,
+ "to_tp": topo.target_tp_size,
+ "offset_in_shard": offset_in_sender,
+ "num_slices": ratio,
+ }
+ return KVCacheTransferData(
+ request_id=kv_data.request_id,
+ layer_blocks=slice_layer_blocks(kv_data.layer_blocks, offset_in_sender, ratio),
+ block_ids=list(kv_data.block_ids),
+ metadata=metadata,
+ )
+
+ def _serialize_transfer_payload(self, kv_data: KVCacheTransferData) -> torch.Tensor | bytes | dict[str, Any]:
+ """Serialize KV transfer data using the connector's fastest supported path."""
+ if getattr(self.connector, "supports_raw_data", False):
+ try:
+ return kv_data.to_gpu_tensor()
+ except Exception:
+ pass
+ try:
+ return kv_data.to_bytes()
+ except Exception:
+ return kv_data.to_dict()
+
+ @staticmethod
+ def _collect_request_kv_payload(req: Any) -> dict[str, object]:
+ """Collect request-side KV objects for object broadcast."""
+ kv_payload: dict[str, object] = {}
+ for attr in ("past_key_values", "kv_metadata"):
+ val = getattr(req, attr, None)
+ if val is not None:
+ kv_payload[attr] = val
+
+ if hasattr(req, "sampling_params") and req.sampling_params is not None:
+ for key in list(vars(req.sampling_params).keys()):
+ if key in ("past_key_values", "kv_metadata") or (
+ key.startswith("cfg_")
+ and (
+ key.endswith("_past_key_values")
+ or key.endswith("_kv_metadata")
+ or key
+ in (
+ "cfg_kv_request_ids",
+ "cfg_active_branch",
+ "cfg_branch_roles",
+ "cfg_branch_past_key_values",
+ "cfg_branch_kv_metadata",
+ )
+ )
+ ):
+ val = getattr(req.sampling_params, key, None)
+ if val is not None:
+ kv_payload[f"sp.{key}"] = val
+
+ return kv_payload
+
+ @staticmethod
+ def _apply_request_kv_payload(
+ req: Any,
+ kv_payload: dict[str, object],
+ target_device: torch.device | None = None,
+ ) -> None:
+ """Apply a broadcast KV payload back onto a request object."""
+ for attr in ("past_key_values", "kv_metadata"):
+ val = kv_payload.get(attr)
+ if val is not None:
+ if target_device is not None:
+ val = _move_to_device(val, target_device)
+ setattr(req, attr, val)
+
+ if hasattr(req, "sampling_params") and req.sampling_params is not None:
+ for key, val in kv_payload.items():
+ if key.startswith("sp."):
+ if target_device is not None:
+ val = _move_to_device(val, target_device)
+ setattr(req.sampling_params, key[3:], val)
+
+ @staticmethod
+ def _discover_cfg_branch_roles(req: Any) -> list[str]:
+ """Discover CFG branch roles in a stable order."""
+ sampling_params = getattr(req, "sampling_params", None)
+ if sampling_params is None:
+ return []
+
+ roles: list[str] = []
+ branch_map = getattr(sampling_params, "cfg_branch_past_key_values", None) or {}
+ for preferred_role in ("cfg_text", "cfg_img"):
+ if (
+ preferred_role in branch_map
+ or getattr(sampling_params, f"{preferred_role}_past_key_values", None) is not None
+ ):
+ roles.append(preferred_role)
+
+ for role in branch_map.keys():
+ if role not in roles and branch_map.get(role) is not None:
+ roles.append(role)
+
+ for key in vars(sampling_params).keys():
+ if not (key.startswith("cfg_") and key.endswith("_past_key_values")):
+ continue
+ role = key.removesuffix("_past_key_values")
+ if role in ("cfg_branch",) or role in roles:
+ continue
+ if getattr(sampling_params, key, None) is not None:
+ roles.append(role)
+
+ return roles
+
+ @classmethod
+ def _build_cfg_rank_local_payloads(cls, req: Any, cfg_size: int) -> list[dict[str, object] | None]:
+ """Build per-cfg-rank payloads so each rank receives only its branch KV."""
+ full_payload = cls._collect_request_kv_payload(req)
+ payloads: list[dict[str, object] | None] = []
+
+ main_payload = {
+ key: value
+ for key, value in full_payload.items()
+ if key in ("past_key_values", "kv_metadata", "sp.past_key_values", "sp.kv_metadata")
+ }
+ branch_roles = cls._discover_cfg_branch_roles(req)
+ if branch_roles:
+ main_payload["sp.cfg_branch_roles"] = list(branch_roles)
+ main_payload["sp.cfg_active_branch"] = None
+ payloads.append(main_payload or None)
+
+ sampling_params = getattr(req, "sampling_params", None)
+ branch_map = getattr(sampling_params, "cfg_branch_past_key_values", None) or {}
+ branch_metadata_map = getattr(sampling_params, "cfg_branch_kv_metadata", None) or {}
+
+ for role in branch_roles:
+ if sampling_params is None:
+ payloads.append(None)
+ continue
+
+ branch_kv = branch_map.get(role)
+ if branch_kv is None:
+ branch_kv = getattr(sampling_params, f"{role}_past_key_values", None)
+ branch_metadata = branch_metadata_map.get(role)
+ if branch_metadata is None:
+ branch_metadata = getattr(sampling_params, f"{role}_kv_metadata", None)
+ if branch_kv is None:
+ payloads.append(None)
+ continue
+
+ local_payload = dict(main_payload)
+ local_payload["sp.cfg_active_branch"] = role
+ local_payload["sp.cfg_branch_roles"] = list(branch_roles)
+ local_payload["sp.cfg_branch_past_key_values"] = {role: branch_kv}
+ local_payload[f"sp.{role}_past_key_values"] = branch_kv
+ if branch_metadata is not None:
+ local_payload["sp.cfg_branch_kv_metadata"] = {role: branch_metadata}
+ local_payload[f"sp.{role}_kv_metadata"] = branch_metadata
+
+ payloads.append(local_payload)
+
+ while len(payloads) < cfg_size:
+ payloads.append(None)
+
+ return payloads[:cfg_size]
+
def update_sender_info(self, sender_info: dict[str, Any], sender_stage_id: str | int | None = None) -> None:
- """Update receiver-side sender info before loading remote KV cache."""
+ """Update receiver-side sender info before loading remote KV cache.
+
+ The orchestrator always reports rank-0's ZMQ port. When TP > 1 the
+ receiver must offset the port so that each TP rank connects to the
+ corresponding sender rank's port.
+
+ The base host/port are also stored so that the receive path can
+ construct per-rank metadata for heterogeneous TP scenarios.
+ """
if not self.config.need_recv_cache:
return
@@ -523,18 +684,39 @@ class OmniKVTransferManager:
logger.warning("Invalid sender_info format: %s", sender_info)
return
+ sender_host = actual_info.get("host")
+ base_zmq_port = actual_info.get("zmq_port")
+
+ # Store base sender info for per-rank metadata construction.
+ self._sender_base_host = sender_host
+ if base_zmq_port is not None:
+ self._sender_base_zmq_port = int(base_zmq_port)
+
+ # --- Default sender: offset to match this receiver's corresponding sender rank ---
+ zmq_port = base_zmq_port
+ if zmq_port is not None and self._tp_topo.local_rank > 0:
+ zmq_port = int(zmq_port) + self._tp_topo.local_rank * KV_RANK_PORT_STRIDE
+
if self.config.connector_config:
- self.config.connector_config["sender_host"] = actual_info.get("host")
- self.config.connector_config["sender_zmq_port"] = actual_info.get("zmq_port")
+ self.config.connector_config["sender_host"] = sender_host
+ self.config.connector_config["sender_zmq_port"] = zmq_port
if self._connector and hasattr(self._connector, "update_sender_info"):
try:
- self._connector.update_sender_info(actual_info.get("host"), actual_info.get("zmq_port"))
+ self._connector.update_sender_info(sender_host, zmq_port)
except Exception:
if hasattr(self._connector, "sender_host"):
- self._connector.sender_host = actual_info.get("host")
+ self._connector.sender_host = sender_host
if hasattr(self._connector, "sender_zmq_port"):
- self._connector.sender_zmq_port = actual_info.get("zmq_port")
+ self._connector.sender_zmq_port = zmq_port
+
+ logger.info(
+ "Sender info updated: host=%s, base_port=%s, adjusted_port=%s (local_rank=%s)",
+ sender_host,
+ base_zmq_port,
+ zmq_port,
+ self._tp_topo.local_rank,
+ )
def handle_finished_requests_kv_transfer(
self,
@@ -692,35 +874,54 @@ class OmniKVTransferManager:
kv_data.request_id = transfer_req_id
serialization_start = time.perf_counter()
- transfer_data: torch.Tensor | bytes | dict[str, Any]
- supports_raw = getattr(self.connector, "supports_raw_data", False)
+ topo = self._tp_topo
+ send_keys = build_rank_aware_send_keys(
+ transfer_req_id, from_stage, to_stage, topo, hook=self.kv_send_key_builder
+ )
+ sender_slice_active = (
+ topo.source_tp_size < topo.target_tp_size and len(send_keys) > 1 and not callable(self.kv_send_key_builder)
+ )
+ per_key_payloads: list[tuple[str, torch.Tensor | bytes | dict[str, Any]]] = []
- try:
- if supports_raw:
- transfer_data = kv_data.to_gpu_tensor()
+ if sender_slice_active:
+ target_ranks = get_kv_target_ranks(topo)
+ if len(target_ranks) != len(send_keys):
+ logger.warning(
+ "Skip sender-side KV slicing because target rank count does not match send key count: "
+ "target_ranks=%s send_keys=%s",
+ len(target_ranks),
+ len(send_keys),
+ )
+ sender_slice_active = False
else:
- raise RuntimeError("Connector does not support raw tensor")
- except Exception:
- try:
- transfer_data = kv_data.to_bytes()
- except Exception:
- data_dict = kv_data.to_dict()
- data_dict["request_id"] = transfer_req_id
- transfer_data = data_dict
+ for put_key, target_rank in zip(send_keys, target_ranks, strict=False):
+ sliced_kv_data = self._slice_transfer_data_for_target(kv_data, target_rank)
+ per_key_payloads.append((put_key, self._serialize_transfer_payload(sliced_kv_data)))
+
+ if not per_key_payloads:
+ transfer_data = self._serialize_transfer_payload(kv_data)
+ per_key_payloads = [(put_key, transfer_data) for put_key in send_keys]
serialization_ms = (time.perf_counter() - serialization_start) * 1000
logger.info("KV cache serialized for %s in %.1f ms", transfer_req_id, serialization_ms)
transfer_start = time.perf_counter()
- success, size, _ = self._transfer_with_retry(from_stage, to_stage, f"kv_cache_{transfer_req_id}", transfer_data)
+ total_size = 0
+ all_succeeded = True
+ for put_key, transfer_data in per_key_payloads:
+ success, size, _ = self._transfer_with_retry(from_stage, to_stage, put_key, transfer_data)
+ total_size += size
+ all_succeeded = all_succeeded and success
+
elapsed = time.perf_counter() - transfer_start
- if success:
- mbps = (size / 1024 / 1024) / elapsed if elapsed > 0 else 0
+ if all_succeeded:
+ mbps = (total_size / 1024 / 1024) / elapsed if elapsed > 0 else 0
logger.info(
- "KV transfer OK: %s, %s bytes, %.3fs, %.1f MB/s",
+ "KV transfer OK: %s, %s bytes across %s key(s), %.3fs, %.1f MB/s",
transfer_req_id,
- size,
+ total_size,
+ len(send_keys),
elapsed,
mbps,
)
@@ -731,7 +932,7 @@ class OmniKVTransferManager:
self,
from_stage: str,
to_stage: str,
- request_id: str,
+ put_key: str,
data: "dict[str, Any] | bytes | torch.Tensor",
max_retries: int = 3,
) -> tuple[bool, int, dict[str, Any] | None]:
@@ -740,7 +941,7 @@ class OmniKVTransferManager:
Args:
from_stage: Source stage identifier
to_stage: Target stage identifier
- request_id: Request identifier for the key
+ put_key: Pre-built connector key (rank-aware when TP > 1)
data: Data to transfer
max_retries: Maximum number of retry attempts
@@ -749,14 +950,12 @@ class OmniKVTransferManager:
"""
for attempt in range(max_retries):
try:
- # Build the full key for connector
- full_request_id = f"omni_{from_stage}_to_{to_stage}_{request_id}"
success, size, metadata = self.connector.put(
- from_stage=from_stage, to_stage=to_stage, put_key=full_request_id, data=data
+ from_stage=from_stage, to_stage=to_stage, put_key=put_key, data=data
)
if success:
return success, size, metadata
- logger.warning(f"Transfer attempt {attempt + 1} failed for {request_id}")
+ logger.warning(f"Transfer attempt {attempt + 1} failed for {put_key}")
except Exception as e:
logger.warning(f"Transfer attempt {attempt + 1} exception: {e}")
@@ -801,22 +1000,46 @@ class OmniKVTransferManager:
poll_interval = 0.01
max_poll_interval = 0.5
- logger.info(f"Wait for KV cache for request {request_id} from stage {from_stage} to {to_stage}...")
+ topo = self._tp_topo
+ recv_key_pairs = build_rank_aware_recv_keys(
+ request_id, from_stage, to_stage, topo, hook=self.kv_recv_key_builder
+ )
+ pending_pairs = list(recv_key_pairs)
+ received_payloads: dict[str, tuple[dict[str, Any], int]] = {}
+
+ logger.info(
+ "Wait for KV cache for request %s from stage %s to %s via %s key(s)...",
+ request_id,
+ from_stage,
+ to_stage,
+ len(recv_key_pairs),
+ )
try:
while True:
- # Build the full key for connector
- full_request_id = f"omni_{from_stage}_to_{to_stage}_kv_cache_{request_id}"
link_start = time.perf_counter()
- result = self.connector.get(
- from_stage=from_stage,
- to_stage=to_stage,
- get_key=full_request_id,
- )
- if result:
+ for get_key, from_rank in list(pending_pairs):
+ # Construct per-rank metadata so the connector queries
+ # the correct sender endpoint (heterogeneous TP path).
+ # When from_rank is None (TP<=1), metadata stays None
+ # and the connector falls back to its default sender.
+ rank_metadata: dict[str, Any] | None = None
+ if from_rank is not None and self._sender_base_host and self._sender_base_zmq_port is not None:
+ rank_metadata = {
+ "source_host": self._sender_base_host,
+ "source_port": self._sender_base_zmq_port + from_rank * KV_RANK_PORT_STRIDE,
+ }
+
+ result = self.connector.get(
+ from_stage=from_stage,
+ to_stage=to_stage,
+ get_key=get_key,
+ metadata=rank_metadata,
+ )
+ if not result:
+ continue
+
raw_data, size = result
- elapsed = time.time() - start_time
- link_ms = (time.perf_counter() - link_start) * 1000
managed_buffer = None
if hasattr(raw_data, "tensor") and hasattr(raw_data, "release"):
@@ -844,6 +1067,21 @@ class OmniKVTransferManager:
else:
data = raw_data
+ received_payloads[get_key] = (data, size)
+ pending_pairs.remove((get_key, from_rank))
+
+ if not pending_pairs and received_payloads:
+ elapsed = time.time() - start_time
+ link_ms = (time.perf_counter() - link_start) * 1000
+ ordered_payloads = [received_payloads[key][0] for key, _ in recv_key_pairs]
+ total_size = sum(received_payloads[key][1] for key, _ in recv_key_pairs)
+
+ if len(ordered_payloads) == 1:
+ data = ordered_payloads[0]
+ else:
+ data = merge_received_rank_shards(ordered_payloads, merger=self.kv_payload_merger)
+ data = slice_received_rank_shard(data, topo, slicer=self.kv_payload_slicer)
+
try:
if isinstance(data, dict) and "layer_blocks" in data:
layer_blocks = data["layer_blocks"]
@@ -856,18 +1094,18 @@ class OmniKVTransferManager:
continue
if target_device is not None and tensor.device != target_device:
cache_list[i] = tensor.to(target_device).contiguous()
- finally:
- if managed_buffer is not None:
- managed_buffer.release()
+ except Exception:
+ logger.exception("Failed to move KV cache tensors to target device")
logger.info(
- "Successfully received KV cache for %s, %s bytes, wait=%.3fs, link=%.1fms",
+ "Successfully received KV cache for %s, %s bytes across %s key(s), wait=%.3fs, link=%.1fms",
request_id,
- size,
+ total_size,
+ len(recv_key_pairs),
elapsed,
link_ms,
)
- return data, size
+ return data, total_size
if time.time() - start_time > timeout:
logger.error(f"Timeout waiting for KV cache for request {request_id} after {timeout}s")
@@ -876,11 +1114,8 @@ class OmniKVTransferManager:
time.sleep(poll_interval)
poll_interval = min(poll_interval * 2, max_poll_interval)
- except Exception as e:
- logger.error(f"Error receiving KV cache for {request_id}: {e}")
- import traceback
-
- traceback.print_exc()
+ except Exception:
+ logger.exception("Error receiving KV cache for %s", request_id)
return None, 0
def apply_kv_cache_to_request(self, req: Any, data: dict[str, Any]) -> None:
@@ -994,73 +1229,79 @@ class OmniKVTransferManager:
cfg_kv_collect_func: Callable | None = None,
target_device: torch.device | None = None,
) -> bool:
- """Broadcast-aware wrapper around :meth:`receive_multi_kv_cache`.
+ """Distributed wrapper around :meth:`receive_multi_kv_cache`.
- SharedMemory connector is single-reader: once rank 0 consumes the
- segment it is deleted. For multi-GPU stages (e.g. sequence-parallel)
- only rank 0 receives; the result is then broadcast to every other
- rank via the world process-group.
-
- For single-worker stages this is equivalent to calling
- :meth:`receive_multi_kv_cache` directly.
+ TP-aware path selection:
+ - world size 1: direct receive
+ - TP active, cfg size 1: each rank independently receives
+ - TP active, cfg size > 1: cfg-rank 0 receives, then broadcasts to
+ peers that share the same TP rank
+ - TP inactive: legacy rank-0 receive then world broadcast
"""
- from vllm_omni.diffusion.distributed.parallel_state import get_world_group
+ from vllm_omni.diffusion.distributed.parallel_state import (
+ get_cfg_group,
+ get_classifier_free_guidance_rank,
+ get_classifier_free_guidance_world_size,
+ get_world_group,
+ )
world = get_world_group()
if world.world_size <= 1:
return self.receive_multi_kv_cache(req, cfg_kv_collect_func, target_device)
- # --- rank 0: receive to CPU (needed for pickle-based broadcast) ---
+ topo = self._tp_topo
+ tp_active = topo.source_tp_size > 1 or topo.target_tp_size > 1
+ cfg_size = 1
+ cfg_rank = 0
+ cfg_group = None
+ try:
+ cfg_size = get_classifier_free_guidance_world_size()
+ cfg_rank = get_classifier_free_guidance_rank()
+ cfg_group = get_cfg_group()
+ except Exception:
+ cfg_size = 1
+ cfg_rank = 0
+ cfg_group = None
+
+ if tp_active and cfg_size <= 1:
+ logger.info(
+ "Rank-aware KV receive: rank %s independently receiving (from_tp=%s, to_tp=%s)",
+ topo.local_rank,
+ topo.source_tp_size,
+ topo.target_tp_size,
+ )
+ return self.receive_multi_kv_cache(req, cfg_kv_collect_func, target_device)
+
+ if tp_active and cfg_size > 1 and cfg_group is not None:
+ kv_payload: dict[str, object] | None = None
+ if cfg_rank == 0:
+ received = self.receive_multi_kv_cache(req, cfg_kv_collect_func, torch.device("cpu"))
+ rank_payloads = self._build_cfg_rank_local_payloads(req, cfg_size) if received else [None] * cfg_size
+ kv_payload = rank_payloads[0]
+ for dst_rank in range(1, cfg_size):
+ cfg_group.send_object(rank_payloads[dst_rank], dst_rank)
+ else:
+ kv_payload = cfg_group.recv_object(0)
+
+ if not kv_payload:
+ return False
+
+ self._apply_request_kv_payload(req, kv_payload, target_device)
+ return True
+
+ kv_payload: dict[str, object] | None = None
if world.rank_in_group == 0:
- self.receive_multi_kv_cache(req, cfg_kv_collect_func, torch.device("cpu"))
+ received = self.receive_multi_kv_cache(req, cfg_kv_collect_func, torch.device("cpu"))
+ if received:
+ kv_payload = self._collect_request_kv_payload(req)
- kv_payload: dict[str, object] = {}
- for attr in ("past_key_values", "kv_metadata"):
- val = getattr(req, attr, None)
- if val is not None:
- kv_payload[attr] = val
+ kv_payload = world.broadcast_object(kv_payload, src=0)
- if hasattr(req, "sampling_params") and req.sampling_params is not None:
- for key in list(vars(req.sampling_params).keys()):
- if (key.startswith("cfg_") and key.endswith("_past_key_values")) or key in (
- "past_key_values",
- "kv_metadata",
- ):
- val = getattr(req.sampling_params, key, None)
- if val is not None:
- kv_payload[f"sp.{key}"] = val
-
- payload_list = [kv_payload]
- # Use broadcast_object_list (pickle-based) instead of broadcast_tensor_dict
- # because the KV cache is a heterogeneous nested structure (NaiveCache objects
- # with metadata + tensors), not a flat tensor dict. This runs once before
- # the denoising loop so the serialization cost is negligible.
- torch.distributed.broadcast_object_list(payload_list, src=world.ranks[0], group=world.cpu_group)
- kv_payload = payload_list[0]
- else:
- payload_list: list[dict[str, object] | None] = [None]
- torch.distributed.broadcast_object_list(payload_list, src=world.ranks[0], group=world.cpu_group)
- kv_payload = payload_list[0]
-
- # --- apply on ALL ranks (rank 0 also needs CPU→GPU move) ---
if not kv_payload:
return False
- for attr in ("past_key_values", "kv_metadata"):
- val = kv_payload.get(attr)
- if val is not None:
- if target_device is not None:
- val = _move_to_device(val, target_device)
- setattr(req, attr, val)
-
- if hasattr(req, "sampling_params") and req.sampling_params is not None:
- for key, val in kv_payload.items():
- if key.startswith("sp."):
- if target_device is not None:
- val = _move_to_device(val, target_device)
- setattr(req.sampling_params, key[3:], val)
-
+ self._apply_request_kv_payload(req, kv_payload, target_device)
return True
diff --git a/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py b/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py
index 393d0e801..1d5fc398a 100644
--- a/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py
+++ b/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py
@@ -58,6 +58,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self.waiting_for_chunk_waiting_requests: deque[Any] = deque()
self.waiting_for_chunk_running_requests: deque[Any] = deque()
self.requests_with_ready_chunks = set()
+ self.requests_origin_status = {}
@classmethod
def create_connector(cls, model_config: Any):
@@ -279,6 +280,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self.get_req_chunk.pop(request_id, None)
self.requests_with_ready_chunks.discard(request_id)
self.request_ids_mapping.pop(request_id, None)
+ self.requests_origin_status.pop(request_id, None)
self._cancelled_load_reqs.add(request_id)
self._finished_load_reqs.discard(request_id)
@@ -408,6 +410,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self.requests_with_ready_chunks.add(request.request_id)
continue
queue.remove(request)
+ self.requests_origin_status[request.request_id] = target_status
waiting_for_chunk_list.append(request)
def _clear_chunk_ready(self, scheduler_output: Any) -> None:
@@ -420,3 +423,23 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
for req_id in scheduler_output.scheduled_cached_reqs.req_ids:
if req_id in self.requests_with_ready_chunks:
self.requests_with_ready_chunks.remove(req_id)
+
+ def finish_requests(
+ self, request_ids: Any, finished_status: RequestStatus, requests: dict[str, Request] | None = None
+ ) -> list[tuple[str, int]]:
+ assert RequestStatus.is_finished(finished_status)
+ if isinstance(request_ids, str):
+ request_ids = (request_ids,)
+ elif request_ids is not None:
+ request_ids = set(request_ids)
+ else:
+ request_ids = requests.keys()
+
+ # First pass: collect requests to remove from queues
+ for req_id in request_ids:
+ request = requests.get(req_id) if requests else None
+ if request is None or request.is_finished():
+ # Invalid request ID.
+ continue
+ if req_id in self.requests_origin_status:
+ request.status = self.requests_origin_status.pop(req_id)
diff --git a/vllm_omni/distributed/omni_connectors/utils/initialization.py b/vllm_omni/distributed/omni_connectors/utils/initialization.py
index 0497bbb3a..f012af3c9 100644
--- a/vllm_omni/distributed/omni_connectors/utils/initialization.py
+++ b/vllm_omni/distributed/omni_connectors/utils/initialization.py
@@ -206,6 +206,19 @@ def load_omni_transfer_config(
if config_dict is None:
return None
+ # Normalize new-schema (top-level ``connectors`` + ``stages``) into the
+ # legacy ``runtime.connectors`` + ``stage_args`` shape the parser reads.
+ if "stages" in config_dict and "stage_args" not in config_dict:
+ normalized: dict[str, Any] = dict(config_dict)
+ runtime = dict(normalized.get("runtime") or {})
+ if "connectors" in normalized and "connectors" not in runtime:
+ runtime["connectors"] = normalized["connectors"]
+ if "edges" in normalized and "edges" not in runtime:
+ runtime["edges"] = normalized["edges"]
+ normalized["runtime"] = runtime
+ normalized["stage_args"] = normalized["stages"]
+ config_dict = normalized
+
# Parse connectors
connectors = {}
runtime_config = config_dict.get("runtime", {})
diff --git a/vllm_omni/distributed/omni_connectors/utils/kv_utils.py b/vllm_omni/distributed/omni_connectors/utils/kv_utils.py
index 2cb48a8b3..12b9b3d4f 100644
--- a/vllm_omni/distributed/omni_connectors/utils/kv_utils.py
+++ b/vllm_omni/distributed/omni_connectors/utils/kv_utils.py
@@ -1,15 +1,380 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
-"""Utility helpers for KV cache manipulation."""
+"""Utility helpers for KV cache manipulation, TP routing, and merge/slice."""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
import torch
+from vllm.distributed.parallel_state import (
+ get_tensor_model_parallel_rank,
+ get_tensor_model_parallel_world_size,
+)
from vllm.logger import init_logger
+from .initialization import KV_RANK_PORT_STRIDE, KV_TRANSFER_PORT_OFFSET
+
logger = init_logger(__name__)
LayerKV = torch.Tensor | tuple[torch.Tensor, torch.Tensor]
+# ------------------------------------------------------------------ #
+# TP Topology
+# ------------------------------------------------------------------ #
+
+
+@dataclass(frozen=True)
+class KVTPTopology:
+ """Immutable descriptor for a KV-transfer parallel mapping.
+
+ Captures sender/receiver parallel sizes and the local rank within
+ that parallel dimension. Works for any divisible parallel dimension
+ (TP, SP, Ring Attention).
+ """
+
+ source_tp_size: int
+ target_tp_size: int
+ local_rank: int
+
+ def __post_init__(self) -> None:
+ if self.source_tp_size <= 0 or self.target_tp_size <= 0:
+ raise ValueError(
+ f"Parallel sizes must be positive: "
+ f"source_tp_size={self.source_tp_size}, target_tp_size={self.target_tp_size}"
+ )
+ if self.local_rank < 0:
+ raise ValueError(f"local_rank must be non-negative, got {self.local_rank}")
+
+ @property
+ def is_heterogeneous(self) -> bool:
+ return self.source_tp_size != self.target_tp_size
+
+ @property
+ def ratio(self) -> int:
+ """Larger parallel size divided by smaller. Always >= 1."""
+ return max(self.source_tp_size, self.target_tp_size) // min(self.source_tp_size, self.target_tp_size)
+
+
+# ------------------------------------------------------------------ #
+# Runtime TP detection
+# ------------------------------------------------------------------ #
+
+
+def get_local_tp_rank() -> int:
+ """Return the TP-local rank of this worker process.
+
+ Uses ``get_tensor_model_parallel_rank()`` which returns the rank
+ within the TP group only, not the stage-global rank.
+ """
+ try:
+ return get_tensor_model_parallel_rank()
+ except Exception:
+ logger.debug("TP parallel state not initialized, falling back to LOCAL_RANK env", exc_info=True)
+ try:
+ return int(os.environ.get("LOCAL_RANK", "0"))
+ except (ValueError, TypeError):
+ return 0
+
+
+def get_tp_world_size() -> int:
+ """Return the TP world size (tensor-parallel dimension only).
+
+ Uses ``get_tensor_model_parallel_world_size()`` so that
+ cfg_parallel, SP, PP etc. are not included in the count.
+ """
+ try:
+ return get_tensor_model_parallel_world_size()
+ except Exception:
+ logger.debug("TP parallel state not initialized, defaulting world_size=1", exc_info=True)
+ return 1
+
+
+# ------------------------------------------------------------------ #
+# ZMQ port computation
+# ------------------------------------------------------------------ #
+
+
+def kv_zmq_port(base_port: int, from_stage: int, local_rank: int = 0) -> int:
+ """Compute the ZMQ port for a KV-transfer connector.
+
+ Each TP rank gets its own port so that TP > 1 deployments do not
+ cause ``EADDRINUSE`` when multiple sender workers bind on the same
+ host. The formula is backward-compatible: rank 0 produces the same
+ port as the previous ``base + OFFSET + stage`` formula.
+ """
+ return base_port + KV_TRANSFER_PORT_OFFSET + local_rank * KV_RANK_PORT_STRIDE + from_stage
+
+
+# ------------------------------------------------------------------ #
+# TP topology validation and rank routing
+# ------------------------------------------------------------------ #
+
+
+def validate_kv_tp_topology(topo: KVTPTopology) -> None:
+ """Reject heterogeneous TP mappings that cannot be routed losslessly."""
+ larger = max(topo.source_tp_size, topo.target_tp_size)
+ smaller = min(topo.source_tp_size, topo.target_tp_size)
+ if larger % smaller != 0:
+ raise ValueError(
+ f"KV TP mapping must be divisible: "
+ f"source_tp_size={topo.source_tp_size}, "
+ f"target_tp_size={topo.target_tp_size}"
+ )
+
+
+def get_kv_target_ranks(topo: KVTPTopology) -> list[int]:
+ """Which remote ranks this local rank sends KV shards to (send side)."""
+ validate_kv_tp_topology(topo)
+ if topo.source_tp_size == topo.target_tp_size:
+ return [topo.local_rank]
+ if topo.source_tp_size > topo.target_tp_size:
+ return [topo.local_rank // (topo.source_tp_size // topo.target_tp_size)]
+ ratio = topo.target_tp_size // topo.source_tp_size
+ return [topo.local_rank * ratio + i for i in range(ratio)]
+
+
+def get_kv_source_ranks(topo: KVTPTopology) -> list[int]:
+ """Which remote ranks this local rank receives KV shards from (recv side)."""
+ validate_kv_tp_topology(topo)
+ if topo.source_tp_size == topo.target_tp_size:
+ return [topo.local_rank]
+ if topo.source_tp_size > topo.target_tp_size:
+ ratio = topo.source_tp_size // topo.target_tp_size
+ return [topo.local_rank * ratio + i for i in range(ratio)]
+ return [topo.local_rank // (topo.target_tp_size // topo.source_tp_size)]
+
+
+# ------------------------------------------------------------------ #
+# Rank-aware connector key building
+# ------------------------------------------------------------------ #
+
+
+def get_kv_connector_key(
+ req_id: str,
+ from_stage: int | str,
+ chunk_id: int,
+ from_rank: int,
+ to_rank: int,
+) -> str:
+ """Build connector key that includes rank info for KV transfers.
+
+ Format matches PR #2677: ``{req_id}_{from_stage}_{chunk_id}_{from_rank}_{to_rank}``
+ """
+ return f"{req_id}_{from_stage}_{chunk_id}_{from_rank}_{to_rank}"
+
+
+def build_rank_aware_send_keys(
+ request_id: str,
+ from_stage: str,
+ to_stage: str,
+ topo: KVTPTopology,
+ hook: Callable | None = None,
+) -> list[str]:
+ """Build send-side connector keys, checking injectable hook first."""
+ if callable(hook):
+ keys = list(hook(request_id, from_stage, to_stage))
+ if keys:
+ return keys
+ if topo.source_tp_size <= 1 and topo.target_tp_size <= 1:
+ return [f"omni_{from_stage}_to_{to_stage}_kv_cache_{request_id}"]
+ target_ranks = get_kv_target_ranks(topo)
+ return [get_kv_connector_key(request_id, from_stage, 0, topo.local_rank, r) for r in target_ranks]
+
+
+def build_rank_aware_recv_keys(
+ request_id: str,
+ from_stage: str,
+ to_stage: str,
+ topo: KVTPTopology,
+ hook: Callable | None = None,
+) -> list[tuple[str, int | None]]:
+ """Build recv-side connector keys with sender rank info.
+
+ Returns a list of ``(key, from_rank)`` tuples. ``from_rank`` is
+ ``None`` when TP <= 1 (single sender, no per-rank routing needed).
+ For TP > 1, ``from_rank`` identifies which sender rank owns the
+ key so that the connector can route metadata queries to the
+ correct endpoint.
+ """
+ if callable(hook):
+ raw = list(hook(request_id, from_stage, to_stage))
+ if raw:
+ if isinstance(raw[0], tuple):
+ return raw
+ # Hook returned plain strings (e.g. OmniConnectorModelRunnerMixin.
+ # get_rank_aware_kv_keys). Reconstruct from_rank from topology so
+ # Mooncake connector can route metadata queries to the correct
+ # sender endpoint in heterogeneous TP.
+ # TODO: have the mixin return (key, from_rank) tuples directly
+ # to avoid this indirect reconstruction.
+ source_ranks = get_kv_source_ranks(topo)
+ if len(raw) == len(source_ranks):
+ return list(zip(raw, source_ranks))
+ return [(k, None) for k in raw]
+ if topo.source_tp_size <= 1 and topo.target_tp_size <= 1:
+ return [(f"omni_{from_stage}_to_{to_stage}_kv_cache_{request_id}", None)]
+ source_ranks = get_kv_source_ranks(topo)
+ return [(get_kv_connector_key(request_id, from_stage, 0, r, topo.local_rank), r) for r in source_ranks]
+
+
+# ------------------------------------------------------------------ #
+# KV tensor head slicing (heterogeneous TP)
+# ------------------------------------------------------------------ #
+
+
+def slice_kv_tensor_heads(
+ tensor: torch.Tensor | None,
+ offset_in_shard: int,
+ num_slices: int,
+) -> torch.Tensor | None:
+ """Slice one KV tensor along its head dimension (dim 1)."""
+ if tensor is None:
+ return None
+ if not isinstance(tensor, torch.Tensor):
+ return tensor
+ if tensor.dim() < 2:
+ raise ValueError(f"Expected KV tensor with a head dimension, got shape={tuple(tensor.shape)}")
+ if num_slices <= 0:
+ raise ValueError(f"num_slices must be > 0, got {num_slices}")
+ if not (0 <= offset_in_shard < num_slices):
+ raise ValueError(f"offset_in_shard must be in [0, {num_slices}), got {offset_in_shard}")
+
+ heads_in_shard = tensor.shape[1]
+ if heads_in_shard % num_slices != 0:
+ raise ValueError(
+ "KV head count must be divisible for heterogeneous TP slicing: "
+ f"heads_in_shard={heads_in_shard}, num_slices={num_slices}"
+ )
+
+ heads_per_slice = heads_in_shard // num_slices
+ start = offset_in_shard * heads_per_slice
+ end = start + heads_per_slice
+ return tensor[:, start:end, ...].contiguous()
+
+
+def slice_layer_blocks(
+ layer_blocks: dict[str, Any],
+ offset_in_shard: int,
+ num_slices: int,
+) -> dict[str, list[torch.Tensor | None]]:
+ """Slice all KV layers for one logical receiver rank."""
+ sliced_blocks: dict[str, list[torch.Tensor | None]] = {}
+ for cache_name in ("key_cache", "value_cache"):
+ cache_list = layer_blocks.get(cache_name, [])
+ sliced_blocks[cache_name] = [
+ slice_kv_tensor_heads(tensor, offset_in_shard, num_slices) for tensor in cache_list
+ ]
+ return sliced_blocks
+
+
+# ------------------------------------------------------------------ #
+# Multi-rank merge and receiver-side slice
+# ------------------------------------------------------------------ #
+
+
+def merge_received_rank_shards(
+ payloads: list[dict[str, Any]],
+ merger: Callable | None = None,
+) -> dict[str, Any] | None:
+ """Merge multiple source-rank KV shards for one target rank.
+
+ When *merger* is provided (injectable hook), it is called directly.
+ Otherwise the default merges along the head dimension (dim 1).
+ """
+ if callable(merger):
+ return merger(payloads)
+ if not payloads:
+ return None
+ if len(payloads) == 1:
+ return payloads[0]
+
+ base_payload = payloads[0]
+ if not isinstance(base_payload, dict) or "layer_blocks" not in base_payload:
+ return base_payload
+
+ merged: dict[str, Any] = {
+ "request_id": base_payload.get("request_id"),
+ "block_ids": list(base_payload.get("block_ids", [])),
+ "metadata": dict(base_payload.get("metadata", {})),
+ }
+ merged_layer_blocks: dict[str, list[torch.Tensor | None]] = {}
+
+ for cache_name in ("key_cache", "value_cache"):
+ cache_lists = [payload.get("layer_blocks", {}).get(cache_name, []) for payload in payloads]
+ num_layers = max((len(cache_list) for cache_list in cache_lists), default=0)
+ merged_cache: list[torch.Tensor | None] = []
+
+ for layer_idx in range(num_layers):
+ layer_tensors = [
+ cache_list[layer_idx]
+ for cache_list in cache_lists
+ if layer_idx < len(cache_list) and cache_list[layer_idx] is not None
+ ]
+ if not layer_tensors:
+ merged_cache.append(None)
+ elif len(layer_tensors) == 1 or not isinstance(layer_tensors[0], torch.Tensor):
+ merged_cache.append(layer_tensors[0])
+ else:
+ merged_cache.append(torch.cat(layer_tensors, dim=1).contiguous())
+
+ merged_layer_blocks[cache_name] = merged_cache
+
+ merged["layer_blocks"] = merged_layer_blocks
+ return merged
+
+
+def slice_received_rank_shard(
+ payload: dict[str, Any] | None,
+ topo: KVTPTopology,
+ slicer: Callable | None = None,
+) -> dict[str, Any] | None:
+ """Optionally slice a received payload to extract this rank's portion.
+
+ Used when ``to_tp > from_tp``: the sender sent full heads and each
+ receiver rank slices out its own subset.
+ """
+ if callable(slicer):
+ return slicer(payload)
+ if not payload or topo.target_tp_size <= topo.source_tp_size or "layer_blocks" not in payload:
+ return payload
+
+ metadata = payload.get("metadata", {})
+ slice_metadata = metadata.get("tp_head_slice") if isinstance(metadata, dict) else None
+ if isinstance(slice_metadata, dict) and slice_metadata.get("applied"):
+ tagged_rank = slice_metadata.get("target_rank")
+ if tagged_rank is not None and tagged_rank != topo.local_rank:
+ logger.warning(
+ "Received pre-sliced KV payload for unexpected target rank: expected=%s got=%s",
+ topo.local_rank,
+ tagged_rank,
+ )
+ return payload
+
+ ratio = topo.target_tp_size // topo.source_tp_size
+ offset_in_sender = topo.local_rank % ratio
+ updated_metadata = dict(metadata) if isinstance(metadata, dict) else {}
+ updated_metadata["tp_head_slice"] = {
+ "applied": True,
+ "side": "receiver",
+ "target_rank": topo.local_rank,
+ "from_tp": topo.source_tp_size,
+ "to_tp": topo.target_tp_size,
+ "offset_in_shard": offset_in_sender,
+ "num_slices": ratio,
+ }
+ return {
+ "request_id": payload.get("request_id"),
+ "layer_blocks": slice_layer_blocks(payload["layer_blocks"], offset_in_sender, ratio),
+ "block_ids": list(payload.get("block_ids", [])),
+ "metadata": updated_metadata,
+ }
+
+
def normalize_layer_kv(
layer_kv: LayerKV,
*,
diff --git a/vllm_omni/distributed/omni_coordinator/__init__.py b/vllm_omni/distributed/omni_coordinator/__init__.py
index cbef920d4..6894e3113 100644
--- a/vllm_omni/distributed/omni_coordinator/__init__.py
+++ b/vllm_omni/distributed/omni_coordinator/__init__.py
@@ -2,9 +2,11 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from .load_balancer import (
+ LeastQueueLengthBalancer,
LoadBalancer,
LoadBalancingPolicy,
RandomBalancer,
+ RoundRobinBalancer,
Task,
)
from .messages import InstanceEvent, InstanceInfo, InstanceList, StageStatus
@@ -24,4 +26,6 @@ __all__ = [
"LoadBalancer",
"LoadBalancingPolicy",
"RandomBalancer",
+ "RoundRobinBalancer",
+ "LeastQueueLengthBalancer",
]
diff --git a/vllm_omni/distributed/omni_coordinator/load_balancer.py b/vllm_omni/distributed/omni_coordinator/load_balancer.py
index 15a079b0a..41b03be16 100644
--- a/vllm_omni/distributed/omni_coordinator/load_balancer.py
+++ b/vllm_omni/distributed/omni_coordinator/load_balancer.py
@@ -4,6 +4,7 @@
from __future__ import annotations
import random
+import threading
from abc import ABC, abstractmethod
from enum import Enum
from typing import Any, TypedDict
@@ -26,11 +27,16 @@ class Task(TypedDict, total=False):
class LoadBalancingPolicy(str, Enum):
"""Enumeration for load balancing policies.
- Only ``RANDOM`` is implemented. Additional policies (e.g. round-robin,
- least-connections) can be added in the future.
+ These policies are used by :class:`LoadBalancer` implementations to route
+ tasks to a subset of available instances.
+
+ TODO(NumberWan): Map enum values to balancer classes when OmniCoordinator
+ integration lands. Tracked in https://github.com/vllm-project/vllm-omni/pull/2448
"""
RANDOM = "random"
+ ROUND_ROBIN = "round-robin"
+ LEAST_QUEUE_LENGTH = "least-queue-length"
class LoadBalancer(ABC):
@@ -61,10 +67,10 @@ class LoadBalancer(ABC):
class RandomBalancer(LoadBalancer):
"""Load balancer that selects an instance uniformly at random.
- This is the initial and only policy supported. It intentionally ignores
- the task payload and chooses a random index from the provided instance
- list. More sophisticated policies (e.g. round-robin, least-connections)
- can be implemented as additional subclasses of :class:`LoadBalancer`.
+ It intentionally ignores the task payload and chooses a random index from
+ the provided instance list. More sophisticated policies (e.g. round-robin,
+ least-queue-length) can be implemented as additional subclasses of
+ :class:`LoadBalancer`.
"""
def select(self, task: Task, instances: list[InstanceInfo]) -> int: # noqa: ARG002
@@ -74,9 +80,69 @@ class RandomBalancer(LoadBalancer):
return random.randrange(len(instances))
+class RoundRobinBalancer(LoadBalancer):
+ """Load balancer that selects instances in a round-robin fashion.
+
+ This implementation keeps a running index modulo ``len(instances)``. It
+ therefore depends on the **order and stable meaning** of the ``instances``
+ list between calls. If the list length or ordering changes, the sequence
+ of picks may skip or repeat entries relative to a fixed set of backends.
+
+ When instance membership changes dynamically, callers should reset routing
+ state—for example by constructing a new ``RoundRobinBalancer`` or resetting
+ ``_next_index``—similar to rebuilding ``itertools.cycle`` after mutating
+ the instance list (as in vLLM's disaggregated proxy examples).
+
+ Concurrency: ``select`` is synchronous and is expected to run on the
+ coordinator asyncio event loop thread without ``await`` inside this
+ method, so a single invocation is not interleaved with another on that
+ thread. A :class:`threading.Lock` still serializes updates to
+ ``_next_index`` for callers that might invoke ``select`` from multiple
+ threads or alongside threaded infrastructure (e.g. ZMQ receive threads).
+ """
+
+ def __init__(self, start_index: int = 0) -> None:
+ self._next_index = start_index
+ self._lock = threading.Lock()
+
+ def select(self, task: Task, instances: list[InstanceInfo]) -> int: # noqa: ARG002
+ if not instances:
+ raise ValueError("instances must not be empty")
+
+ n = len(instances)
+ with self._lock:
+ idx = self._next_index % n
+ self._next_index = (self._next_index + 1) % n
+ return idx
+
+
+class LeastQueueLengthBalancer(LoadBalancer):
+ """Select the instance with the smallest ``queue_length``.
+
+ If multiple instances share the same minimum queue length, one of them is
+ chosen uniformly at random.
+
+ Raises:
+ ValueError: If any instance has a negative ``queue_length``.
+ """
+
+ def select(self, task: Task, instances: list[InstanceInfo]) -> int: # noqa: ARG002
+ if not instances:
+ raise ValueError("instances must not be empty")
+
+ queue_lengths = [inst.queue_length for inst in instances]
+ if any(q < 0 for q in queue_lengths):
+ raise ValueError("queue_length must be non-negative for all instances")
+ min_q = min(queue_lengths)
+ candidates = [i for i, q in enumerate(queue_lengths) if q == min_q]
+ return random.choice(candidates)
+
+
__all__ = [
"Task",
"LoadBalancingPolicy",
"LoadBalancer",
"RandomBalancer",
+ "RoundRobinBalancer",
+ "LeastQueueLengthBalancer",
]
diff --git a/vllm_omni/engine/__init__.py b/vllm_omni/engine/__init__.py
index c8a96e6d2..6c92d7952 100644
--- a/vllm_omni/engine/__init__.py
+++ b/vllm_omni/engine/__init__.py
@@ -79,6 +79,10 @@ class OmniEngineCoreRequest(EngineCoreRequest):
class OmniEngineCoreOutput(EngineCoreOutput):
pooling_output: dict[str, torch.Tensor] | None = None
+ # Finished flag for streaming input segment
+ is_segment_finished: bool | None = False
+ # Streaming update prompt length
+ new_prompt_len_snapshot: int | None = None
class OmniEngineCoreOutputs(EngineCoreOutputs):
diff --git a/vllm_omni/engine/arg_utils.py b/vllm_omni/engine/arg_utils.py
index d61102c7e..c93bd32c2 100644
--- a/vllm_omni/engine/arg_utils.py
+++ b/vllm_omni/engine/arg_utils.py
@@ -3,14 +3,15 @@ import dataclasses
import json
import os
import tempfile
-from dataclasses import dataclass, field
+from dataclasses import dataclass, field, fields
from typing import Any
-from vllm.engine.arg_utils import EngineArgs
+from vllm.engine.arg_utils import AsyncEngineArgs, EngineArgs
from vllm.logger import init_logger
from vllm_omni.config import OmniModelConfig
from vllm_omni.engine.output_modality import OutputModality
+from vllm_omni.platforms import current_omni_platform
from vllm_omni.plugins import load_omni_general_plugins
logger = init_logger(__name__)
@@ -21,6 +22,7 @@ _ARCH_TO_MODEL_TYPE: dict[str, str] = {
"CosyVoice3Model": "cosyvoice3",
"OmniVoiceModel": "omnivoice",
"VoxCPM2TalkerForConditionalGeneration": "voxcpm2",
+ "VoxCPMForConditionalGeneration": "voxcpm",
}
# Maps model architecture names to tokenizer subfolder paths within HF repos.
@@ -41,6 +43,7 @@ def _register_omni_hf_configs() -> None:
from vllm_omni.model_executor.models.voxtral_tts.configuration_voxtral_tts import (
VoxtralTTSConfig,
)
+ from vllm_omni.transformers_utils.configs.voxcpm import VoxCPMConfig
from vllm_omni.transformers_utils.configs.voxcpm2 import VoxCPM2Config
except Exception as exc: # pragma: no cover - best-effort optional registration
logger.warning("Skipping omni HF config registration due to import error: %s", exc)
@@ -59,6 +62,7 @@ def _register_omni_hf_configs() -> None:
("cosyvoice3", CosyVoice3Config),
("omnivoice", OmniVoiceConfig),
("voxtral_tts", VoxtralTTSConfig),
+ ("voxcpm", VoxCPMConfig),
("voxcpm2", VoxCPM2Config),
]:
try:
@@ -136,11 +140,30 @@ class OmniEngineArgs(EngineArgs):
hf_config_name: str | None = None
custom_process_next_stage_input_func: str | None = None
stage_connector_spec: dict[str, Any] = field(default_factory=dict)
+ subtalker_sampling_params: dict[str, Any] | None = None
async_chunk: bool = False
omni_kv_config: dict | None = None
quantization_config: Any | None = None
worker_type: str | None = None
task_type: str | None = None
+ worker_cls: str = None
+ enable_sleep_mode: bool = False
+ omni: bool = False
+
+ @classmethod
+ def _add_omni_specific_args(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
+ try:
+ parser.add_argument("--omni", action="store_true", default=False, help="Enable Omni engine features.")
+ except argparse.ArgumentError:
+ pass
+ try:
+ parser.add_argument(
+ "--enable-sleep-mode", action="store_true", default=False, help="Enable GPU memory pool for sleep mode."
+ )
+ except argparse.ArgumentError:
+ pass
+ return parser
+
omni_master_address: str | None = None
omni_master_port: int | None = None
stage_configs_path: str | None = None
@@ -149,6 +172,11 @@ class OmniEngineArgs(EngineArgs):
custom_pipeline_args: dict[str, Any] | None = None
def __post_init__(self) -> None:
+ if self.worker_cls is None:
+ if self.worker_type == "ar":
+ self.worker_cls = current_omni_platform.get_omni_ar_worker_cls()
+ elif self.worker_type == "generation":
+ self.worker_cls = current_omni_platform.get_omni_generation_worker_cls()
load_omni_general_plugins()
super().__post_init__()
@@ -288,12 +316,273 @@ class OmniEngineArgs(EngineArgs):
hf_config_name=self.hf_config_name,
custom_process_next_stage_input_func=self.custom_process_next_stage_input_func,
stage_connector_config=stage_connector_config,
+ subtalker_sampling_params=self.subtalker_sampling_params,
omni_kv_config=self.omni_kv_config,
task_type=self.task_type,
)
return omni_config
+
+@dataclass
+class OmniAsyncEngineArgs(AsyncEngineArgs, OmniEngineArgs):
+ @classmethod
+ def add_cli_args(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
+ parser = AsyncEngineArgs.add_cli_args(parser)
+ parser = OmniEngineArgs._add_omni_specific_args(parser)
+ return parser
+
@property
def output_modality(self) -> OutputModality:
"""Parse engine_output_type into a type-safe OutputModality flag."""
return OutputModality.from_string(self.engine_output_type)
+
+
+# ============================================================================
+# CLI argument routing
+# ============================================================================
+#
+# vLLM-Omni's CLI flags live in three buckets:
+#
+# ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
+# │ OrchestratorArgs │ │ OmniEngineArgs │ │ (upstream vllm) │
+# │ │ │ │ │ server/api │
+# │ stage_timeout │ │ max_num_seqs │ │ host, port │
+# │ worker_backend │ │ gpu_mem_util │ │ ssl_keyfile │
+# │ deploy_config │ │ dtype, quant │ │ api_key │
+# │ ... │ │ ... │ │ ... │
+# └──────────────────┘ └──────────────────┘ └──────────────────┘
+# │ │ │
+# ▼ ▼ ▼
+# orchestrator each stage uvicorn /
+# consumes engine FastAPI
+#
+# Fields in ``SHARED_FIELDS`` (e.g. ``model``, ``log_stats``) flow to BOTH
+# orchestrator and engine by design.
+#
+# Invariants enforced by ``tests/test_arg_utils.py``:
+#
+# 1. ``OrchestratorArgs`` ∩ ``OmniEngineArgs`` ⊆ ``SHARED_FIELDS``
+# 2. Every CLI flag is classifiable into one of the three buckets
+# 3. User-typed flags that match none of the above are logged as dropped
+#
+# Adding a new orchestrator-only flag → add a field to ``OrchestratorArgs``.
+# Everything else is automatic.
+
+
+@dataclass(frozen=True)
+class OrchestratorArgs:
+ """CLI flags consumed by the orchestrator.
+
+ Contract: every field here is either
+ (a) orchestrator-only (never needed by a stage engine), OR
+ (b) orchestrator-read-then-redistributed (e.g. ``async_chunk`` is read
+ from CLI, written to ``DeployConfig``, then propagated to every
+ stage via ``merge_pipeline_deploy`` — not via direct kwargs
+ forwarding).
+
+ Fields that BOTH orchestrator and engine genuinely need (e.g. ``model``,
+ ``log_stats``) should be listed in ``SHARED_FIELDS`` below; ``split_kwargs``
+ will copy them to both buckets.
+ """
+
+ # === Lifecycle ===
+ stage_init_timeout: int = 300
+ init_timeout: int = 600
+
+ # === Cross-stage Communication ===
+ shm_threshold_bytes: int = 65536
+ batch_timeout: int = 10
+
+ # === Cluster / Backend ===
+ worker_backend: str = "multi_process"
+ ray_address: str | None = None
+
+ # === Config Files ===
+ stage_configs_path: str | None = None
+ deploy_config: str | None = None
+ stage_overrides: str | None = None # raw JSON string; parsed downstream
+
+ # === Mode Switches (orchestrator reads, DeployConfig redistributes) ===
+ async_chunk: bool | None = None
+
+ # === Observability ===
+ log_stats: bool = False
+
+ # === Headless Mode (also forwarded to engine — see SHARED_FIELDS) ===
+ stage_id: int | None = None
+
+ # === Pre-built Objects ===
+ parallel_config: Any = None
+
+ # === Multi-stage guards ===
+ # --tokenizer is captured here so it does not propagate to every stage
+ # uniformly (different stages often need different tokenizers, e.g.
+ # qwen3_omni thinker vs talker). Users wanting a per-stage tokenizer
+ # should set it in the deploy YAML.
+ tokenizer: str | None = None
+
+
+# Fields that live in BOTH OrchestratorArgs and OmniEngineArgs by design.
+# Changes to this set are a review red flag — revisit the contract.
+SHARED_FIELDS: frozenset[str] = frozenset(
+ {
+ "model", # orch: detect model_type; engine: load weights
+ "stage_id", # orch: route (headless); engine: identity
+ "log_stats", # both want the flag
+ "stage_configs_path", # orch: load legacy YAML; engine: may reference for validation
+ }
+)
+
+
+def orchestrator_field_names() -> frozenset[str]:
+ """Return the names of every field on OrchestratorArgs."""
+ return frozenset(f.name for f in fields(OrchestratorArgs))
+
+
+def internal_blacklist_keys() -> frozenset[str]:
+ """Return the set of CLI keys that must never be forwarded as per-stage
+ engine overrides.
+
+ Derived from ``OrchestratorArgs`` fields minus ``SHARED_FIELDS``, so
+ adding a new orchestrator-owned flag is a one-line change to the
+ dataclass — this function updates automatically.
+ """
+ return orchestrator_field_names() - SHARED_FIELDS
+
+
+def split_kwargs(
+ kwargs: dict[str, Any],
+ *,
+ engine_cls: type | None = None,
+ user_typed: set[str] | None = None,
+ strict: bool = False,
+) -> tuple[OrchestratorArgs, dict[str, Any]]:
+ """Partition CLI kwargs into (orchestrator, engine) buckets.
+
+ Args:
+ kwargs: Raw dict, typically ``vars(args)``.
+ engine_cls: Engine dataclass used to whitelist-filter the engine
+ bucket. Defaults to ``OmniEngineArgs``. Pass a custom class
+ for testing.
+ user_typed: Keys the user actually typed on the command line. Used
+ to warn when a user-typed flag is unclassifiable.
+ strict: If True, raise ``ValueError`` on ambiguous (double-classified
+ but not in ``SHARED_FIELDS``) fields. Default False to keep the
+ rollout non-breaking; flip to True in tests and CI.
+
+ Returns:
+ ``(orchestrator_args, engine_kwargs)``. ``engine_kwargs`` has already
+ been whitelist-filtered against ``engine_cls`` — safe to pass directly
+ to ``engine_cls(**engine_kwargs)``.
+ """
+ if engine_cls is None:
+ engine_cls = OmniEngineArgs
+
+ orch_fields = orchestrator_field_names()
+ engine_fields = {f.name for f in fields(engine_cls)}
+
+ orch_kwargs: dict[str, Any] = {}
+ engine_candidate: dict[str, Any] = {}
+ shared_values: dict[str, Any] = {}
+ unclassified: dict[str, Any] = {}
+
+ for key, value in kwargs.items():
+ in_orch = key in orch_fields
+ in_engine = key in engine_fields
+ is_shared = key in SHARED_FIELDS
+
+ if is_shared:
+ shared_values[key] = value
+ elif in_orch and in_engine:
+ # Declared in both but not marked shared → ambiguous.
+ msg = (
+ f"Field {key!r} is defined on both OrchestratorArgs and "
+ f"{engine_cls.__name__} but is not in SHARED_FIELDS. "
+ f"This causes double-routing. Either remove the duplicate or "
+ f"add {key!r} to SHARED_FIELDS if the sharing is intentional."
+ )
+ if strict:
+ raise ValueError(msg)
+ logger.error(msg)
+ # Default: treat as orchestrator-only to preserve existing behavior.
+ orch_kwargs[key] = value
+ elif in_orch:
+ orch_kwargs[key] = value
+ elif in_engine:
+ engine_candidate[key] = value
+ else:
+ unclassified[key] = value
+
+ # Warn on user-typed but unclassifiable flags so we don't silently drop
+ # something the user cared about (fixes the class of bug that spawned #873).
+ if unclassified and user_typed:
+ user_typed_unknown = sorted(k for k in unclassified if k in user_typed)
+ if user_typed_unknown:
+ logger.warning(
+ "CLI flags not consumed by vllm-omni and dropped before "
+ "per-stage engine construction: %s. If these are vllm "
+ "frontend/uvicorn flags (host, port, ssl_*, api_key, …) this "
+ "is expected; otherwise check your spelling.",
+ user_typed_unknown,
+ )
+
+ # Engine bucket: shared + engine-only. We do NOT pass through unclassified
+ # fields — that's exactly the server/uvicorn noise we want to shed.
+ engine_kwargs = {**shared_values, **engine_candidate}
+
+ # Construct the orchestrator dataclass. Shared fields that OrchestratorArgs
+ # also declares get copied into its constructor.
+ orch_init: dict[str, Any] = dict(orch_kwargs)
+ for key, value in shared_values.items():
+ if key in orch_fields:
+ orch_init[key] = value
+ orch_args = OrchestratorArgs(**orch_init)
+
+ return orch_args, engine_kwargs
+
+
+def derive_server_dests_from_vllm_parser() -> frozenset[str]:
+ """Derive the set of argparse dests that belong to vllm's frontend/server.
+
+ Returns every dest registered by ``make_arg_parser`` that is NOT a field
+ of ``OmniEngineArgs`` and NOT a field of ``OrchestratorArgs``. Useful for
+ CI tests to assert all CLI flags are classifiable without maintaining
+ a hardcoded server list.
+
+ Returns empty frozenset if vllm's parser cannot be built (e.g. in a
+ minimal test environment).
+ """
+ try:
+ from vllm.entrypoints.openai.cli_args import make_arg_parser
+ from vllm.utils.argparse_utils import FlexibleArgumentParser
+ except ImportError:
+ logger.debug("Cannot import vllm parser — server-dest derivation skipped")
+ return frozenset()
+
+ try:
+ parser = make_arg_parser(FlexibleArgumentParser())
+ all_dests = {a.dest for a in parser._actions if a.dest and a.dest != "help"}
+ except Exception as exc:
+ logger.debug("Failed to build vllm parser: %s", exc)
+ return frozenset()
+
+ engine_fields = {f.name for f in fields(OmniEngineArgs)}
+ orch_fields = orchestrator_field_names()
+
+ return frozenset(all_dests - engine_fields - orch_fields - SHARED_FIELDS)
+
+
+def orchestrator_args_from_argparse(args: Any) -> OrchestratorArgs:
+ """Build an ``OrchestratorArgs`` from an ``argparse.Namespace``.
+
+ Only copies attributes that exist on the namespace — missing fields fall
+ back to the dataclass default. Useful when the full parser is already
+ built and ``vars(args)`` would include noise.
+ """
+ kwargs: dict[str, Any] = {}
+ for f in fields(OrchestratorArgs):
+ if hasattr(args, f.name):
+ value = getattr(args, f.name)
+ if value is not None or f.default is None:
+ kwargs[f.name] = value
+ return OrchestratorArgs(**kwargs)
diff --git a/vllm_omni/engine/async_omni_engine.py b/vllm_omni/engine/async_omni_engine.py
index 9609cf6e2..a26102d05 100644
--- a/vllm_omni/engine/async_omni_engine.py
+++ b/vllm_omni/engine/async_omni_engine.py
@@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Any
import janus
import torch
from omegaconf import OmegaConf
+from vllm import envs as vllm_envs
from vllm.engine.arg_utils import EngineArgs
from vllm.inputs import PromptType
from vllm.logger import init_logger
@@ -32,6 +33,7 @@ from vllm.tokenizers import cached_tokenizer_from_config
from vllm.v1.engine import EngineCoreRequest
from vllm.v1.engine.input_processor import InputProcessor
+from vllm_omni.config.stage_config import strip_parent_engine_args
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.diffusion.stage_diffusion_client import StageDiffusionClient
from vllm_omni.diffusion.stage_diffusion_proc import (
@@ -61,6 +63,7 @@ from vllm_omni.engine.stage_engine_startup import (
)
from vllm_omni.engine.stage_init_utils import (
StartedLlmStage,
+ _inject_inferred_kv_tp_topology,
acquire_device_locks,
build_diffusion_config,
build_engine_args_dict,
@@ -78,7 +81,11 @@ from vllm_omni.engine.stage_init_utils import (
setup_stage_devices,
terminate_alive_proc,
)
-from vllm_omni.entrypoints.utils import load_and_resolve_stage_configs
+from vllm_omni.entrypoints.pd_utils import PDDisaggregationMixin
+from vllm_omni.entrypoints.utils import (
+ inject_omni_kv_config,
+ load_and_resolve_stage_configs,
+)
from vllm_omni.inputs.preprocess import OmniInputPreprocessor
from vllm_omni.platforms import current_omni_platform
@@ -87,6 +94,29 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
+_STARTUP_POLL_INTERVAL_S = 1.0
+
+
+# ============================================================================
+# Parent-EngineArgs field-routing contracts (consumed by
+# AsyncOmniEngine._strip_parent_engine_args when ``stage_configs_path`` is set).
+# ============================================================================
+
+# Fields that must survive the "equal to default → strip" filter because
+# diffusion stages need them even when equal to vllm's default value
+# (e.g. colocate worker setup relies on worker_extension_cls being forwarded).
+_PARENT_ARGS_KEEP: frozenset[str] = frozenset({"worker_extension_cls"})
+
+# Omni orchestrator-level fields consumed by ``_resolve_stage_configs`` that
+# must never leak into per-stage EngineArgs (``stage_configs_path`` would
+# trigger the ``create_model_config`` guard).
+_PARENT_ARGS_STRIP: frozenset[str] = frozenset({"stage_configs_path"})
+
+# Fields always populated by callers (via ``from_cli_args`` / ``asdict``) so
+# their presence as an override is never a surprise — suppress the
+# "override ignored" warning for these.
+_PARENT_ARGS_NO_WARN: frozenset[str] = frozenset({"model"})
+
def _patch_generation_config_if_needed(model_config: Any) -> None:
"""Ensure try_get_generation_config won't crash for models whose HF
@@ -313,22 +343,7 @@ class AsyncOmniEngine:
name="orchestrator",
)
self.orchestrator_thread.start()
-
- # Wait for stage/runtime initialization result from orchestrator thread.
- try:
- startup_future.result(timeout=startup_timeout)
- except concurrent.futures.TimeoutError as e:
- try:
- self.shutdown()
- except Exception:
- logger.exception("[AsyncOmniEngine] Failed to cleanup after orchestrator startup timeout")
- raise TimeoutError(f"Orchestrator did not become ready within {startup_timeout}s") from e
- except Exception:
- try:
- self.shutdown()
- except Exception:
- logger.exception("[AsyncOmniEngine] Failed to cleanup after orchestrator startup failure")
- raise
+ self._wait_for_orchestrator_init(startup_future, startup_timeout)
# Stage runtime fields are assigned directly on self by the bootstrap thread.
self._weak_finalizer = weakref.finalize(
@@ -378,6 +393,12 @@ class AsyncOmniEngine:
omni_kv["omni_to_stage"] = omni_to
omni_kv.setdefault("stage_id", metadata.stage_id)
engine_args_dict["omni_kv_config"] = omni_kv
+ if self.stage_configs:
+ _inject_inferred_kv_tp_topology(
+ engine_args_dict.get("omni_kv_config"),
+ metadata.stage_id,
+ self.stage_configs,
+ )
vllm_config, executor_class = build_vllm_config(
stage_cfg,
self.model,
@@ -747,10 +768,8 @@ class AsyncOmniEngine:
setup_stage_devices(configured_stage_id, metadata.runtime_cfg)
omni_conn_cfg, omni_from, omni_to = omni_kv_connector
if omni_conn_cfg:
- from vllm_omni.entrypoints.utils import inject_omni_kv_config
-
inject_omni_kv_config(stage_cfg, omni_conn_cfg, omni_from, omni_to)
- inject_kv_stage_info(stage_cfg, configured_stage_id)
+ inject_kv_stage_info(stage_cfg, configured_stage_id, self.stage_configs)
if self.single_stage_mode:
assert self._omni_master_server is not None
stage_clients[stage_idx] = self._launch_diffusion_stage(
@@ -759,12 +778,15 @@ class AsyncOmniEngine:
self._omni_master_server,
)
else:
+ use_inline = True if self.num_stages == 1 else False
stage_clients[stage_idx] = initialize_diffusion_stage(
+ configured_stage_id,
self.model,
stage_cfg,
metadata,
stage_init_timeout=stage_init_timeout,
batch_size=self.diffusion_batch_size,
+ use_inline=use_inline,
)
logger.info(
"[AsyncOmniEngine] Stage %s initialized (diffusion, batch_size=%d)",
@@ -892,6 +914,7 @@ class AsyncOmniEngine:
self._initialize_janus_queues()
self._initialize_stages(stage_init_timeout)
+ pd_config = self._detect_pd_config()
orchestrator = Orchestrator(
request_async_queue=self.request_queue.async_q,
output_async_queue=self.output_queue.async_q,
@@ -900,6 +923,7 @@ class AsyncOmniEngine:
stage_clients=self.stage_clients,
output_processors=self.output_processors,
stage_vllm_configs=self.stage_vllm_configs,
+ pd_config=pd_config,
)
if not startup_future.done():
startup_future.set_result(asyncio.get_running_loop())
@@ -909,13 +933,17 @@ class AsyncOmniEngine:
loop.run_until_complete(_run_orchestrator())
except Exception as e:
if not startup_future.done():
- startup_future.set_exception(RuntimeError(f"Orchestrator initialization failed: {e}"))
+ wrapped = RuntimeError(f"Orchestrator initialization failed: {e}")
+ wrapped.__cause__ = e
+ startup_future.set_exception(wrapped)
logger.exception("[AsyncOmniEngine] Orchestrator thread crashed")
+ error_text = str(e) or "Orchestrator thread crashed"
try:
+ error_msg = {"type": "error", "error": error_text, "fatal": True}
if self.output_queue is not None:
- self.output_queue.sync_q.put_nowait({"type": "error", "error": "Orchestrator thread crashed"})
+ self.output_queue.sync_q.put_nowait(error_msg)
if self.rpc_output_queue is not None:
- self.rpc_output_queue.sync_q.put_nowait({"type": "error", "error": "Orchestrator thread crashed"})
+ self.rpc_output_queue.sync_q.put_nowait(error_msg)
except Exception:
pass
raise
@@ -935,6 +963,31 @@ class AsyncOmniEngine:
asyncio.set_event_loop(None)
loop.close()
+ def _wait_for_orchestrator_init(self, startup_future: concurrent.futures.Future, startup_timeout: int) -> None:
+ """
+ Wait for orchestrator startup future to return ready. Raises exception on any failures to the init process.
+ """
+ deadline = time.monotonic() + startup_timeout
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ self._try_shutdown("[AsyncOmniEngine] Failed to cleanup after orchestrator startup timeout")
+ raise TimeoutError(f"Orchestrator did not become ready within {startup_timeout}s")
+ try:
+ startup_future.result(
+ timeout=min(remaining, _STARTUP_POLL_INTERVAL_S),
+ )
+ break
+ except concurrent.futures.TimeoutError:
+ if not self.orchestrator_thread.is_alive():
+ self._try_shutdown("[AsyncOmniEngine] Failed to cleanup after orchestrator startup failure")
+ if startup_future.done():
+ startup_future.result() # re-raises the real exception
+ raise RuntimeError("Orchestrator thread died during startup")
+ except Exception:
+ self._try_shutdown("[AsyncOmniEngine] Failed to cleanup after orchestrator startup failure")
+ raise
+
# ---- request helpers ----
def _build_add_request_message(
@@ -1062,7 +1115,6 @@ class AsyncOmniEngine:
params=companion_params,
supported_tasks=self.supported_tasks,
)
- request = _upgrade_to_omni_request(request, companion_prompt)
request.external_req_id = cid
self.output_processors[0].add_request(
@@ -1122,12 +1174,64 @@ class AsyncOmniEngine:
cache_config = AsyncOmniEngine._get_default_cache_config(cache_backend)
return cache_config
+ def _detect_pd_config(self) -> dict[str, Any] | None:
+ """Detect PD (Prefill-Decode) disaggregation config from stage_configs.
+ Returns a dict with 'pd_pair' and 'bootstrap_addr', or None.
+ """
+ pd_pair = PDDisaggregationMixin.detect_pd_separation_from_stage_configs(self.stage_configs)
+ if pd_pair is None:
+ return None
+ prefill_idx, decode_idx = pd_pair
+
+ # Extract bootstrap address from prefill stage engine_args
+ bootstrap_addr: str | None = None
+ try:
+ prefill_cfg = self.stage_configs[prefill_idx]
+ ea = getattr(prefill_cfg, "engine_args", None)
+ kv_cfg = getattr(ea, "kv_transfer_config", None) if ea is not None else None
+ if kv_cfg is not None:
+ port = vllm_envs.VLLM_MOONCAKE_BOOTSTRAP_PORT
+ kv_ip = getattr(kv_cfg, "kv_ip", None) or "127.0.0.1"
+ bootstrap_addr = f"http://{kv_ip}:{port}"
+ except Exception as exc:
+ logger.warning("[AsyncOmniEngine] Could not extract PD bootstrap address: %s", exc)
+
+ logger.info(
+ "[AsyncOmniEngine] PD disaggregation detected: prefill=stage-%d, decode=stage-%d, bootstrap=%s",
+ prefill_idx,
+ decode_idx,
+ bootstrap_addr,
+ )
+ prefill_engine_id: str | None = None
+ try:
+ prefill_client = self.stage_clients[prefill_idx]
+ kv_cfg = getattr(getattr(prefill_client, "vllm_config", None), "kv_transfer_config", None)
+ prefill_engine_id = getattr(kv_cfg, "engine_id", None)
+ except Exception as exc:
+ logger.warning("[AsyncOmniEngine] Could not extract prefill engine_id: %s", exc)
+
+ return {
+ "pd_pair": (prefill_idx, decode_idx),
+ "bootstrap_addr": bootstrap_addr,
+ "prefill_engine_id": prefill_engine_id,
+ }
+
@staticmethod
def _create_default_diffusion_stage_cfg(kwargs: dict[str, Any]) -> list:
"""Create a default single-stage diffusion config from kwargs."""
# We temporally create a default config for diffusion stage.
# In the future, we should merge the default config with the user-provided config.
normalized_kwargs = dict(kwargs)
+ default_sampling_params = normalized_kwargs.get("default_sampling_params")
+ if isinstance(default_sampling_params, str):
+ try:
+ default_sampling_params = json.loads(default_sampling_params)
+ except json.JSONDecodeError:
+ logger.warning("Invalid default_sampling_params JSON, ignoring stage defaults.")
+ default_sampling_params = None
+ if not isinstance(default_sampling_params, dict):
+ default_sampling_params = None
+ stage_default_sampling_params = default_sampling_params.get("0", {}) if default_sampling_params else {}
# TODO: hack, convert dtype to string to avoid non-premitive omegaconf create error.
if "dtype" in normalized_kwargs and not isinstance(normalized_kwargs["dtype"], str):
@@ -1224,6 +1328,7 @@ class AsyncOmniEngine:
"devices": devices,
},
"engine_args": stage_engine_args,
+ "default_sampling_params": stage_default_sampling_params,
"final_output": True,
"final_output_type": "image",
}
@@ -1244,45 +1349,17 @@ class AsyncOmniEngine:
Logs a warning for any parent field whose value differs from the
dataclass default, so users know their explicit overrides are ignored.
+ See the module-level ``_PARENT_ARGS_*`` constants for the routing
+ contracts this method enforces.
"""
- # worker_extension_cls is a parent field but must pass through to
- # diffusion stages for colocate worker setup.
- _keep = {"worker_extension_cls"}
- # Orchestrator-level OmniEngineArgs fields that are consumed by
- # _resolve_stage_configs and must not leak into per-stage configs
- # (stage_configs_path would trigger the create_model_config guard).
- _strip_omni = {"stage_configs_path"}
- # Fields that are always set by callers (via from_cli_args / asdict)
- # and would always appear as overridden — suppress from the warning
- # so it only surfaces genuinely surprising overrides.
- _no_warn = {"model"}
-
parent_fields: dict[str, dataclasses.Field] = {f.name: f for f in dataclasses.fields(EngineArgs)}
- overridden: list[str] = []
- result: dict[str, Any] = {}
- for k, v in kwargs.items():
- if k in _strip_omni:
- continue
- if k not in parent_fields or k in _keep:
- result[k] = v
- continue
- # Detect explicitly-set values that differ from the default.
- # Values may have been through asdict() which converts dataclass
- # defaults to dicts, so normalise before comparing.
- field = parent_fields[k]
- if field.default is not dataclasses.MISSING:
- default = field.default
- elif field.default_factory is not dataclasses.MISSING:
- default = field.default_factory()
- else:
- default = dataclasses.MISSING
- if default is dataclasses.MISSING or v is None:
- continue
- # Normalise dataclass defaults to dicts for comparison
- if dataclasses.is_dataclass(default) and not isinstance(default, type):
- default = dataclasses.asdict(default)
- if v != default and k not in _no_warn:
- overridden.append(k)
+ result, overridden = strip_parent_engine_args(
+ kwargs,
+ parent_fields=parent_fields,
+ keep_keys=_PARENT_ARGS_KEEP,
+ strip_keys=_PARENT_ARGS_STRIP,
+ no_warn_keys=_PARENT_ARGS_NO_WARN,
+ )
if overridden:
logger.warning(
@@ -1297,6 +1374,12 @@ class AsyncOmniEngine:
"""Resolve stage configs and inject defaults shared by orchestrator/headless."""
stage_configs_path = kwargs.get("stage_configs_path", None)
+ deploy_config_path = kwargs.pop("deploy_config", None)
+ stage_overrides_json = kwargs.pop("stage_overrides", None)
+ # Set of CLI keys the user actually typed; ``None`` means we have no
+ # parser-level info (e.g. programmatic Omni() call) and the lower
+ # layers should treat all kwargs as explicit.
+ cli_explicit_keys = kwargs.pop("_cli_explicit_keys", None)
explicit_stage_configs = kwargs.pop("stage_configs", None)
if explicit_stage_configs is not None:
logger.warning(
@@ -1309,18 +1392,38 @@ class AsyncOmniEngine:
else:
base_kwargs = kwargs
- # Use the legacy config loading path (load_and_resolve_stage_configs).
- # StageConfigFactory wiring will be done in config refactor [2/N].
+ # Parse --stage-overrides JSON string if provided
+ stage_overrides = None
+ if stage_overrides_json:
+ if isinstance(stage_overrides_json, str):
+ try:
+ stage_overrides = json.loads(stage_overrides_json)
+ except json.JSONDecodeError as exc:
+ raise ValueError(
+ f"--stage-overrides is not valid JSON: {exc}. Got: {stage_overrides_json!r}"
+ ) from exc
+ else:
+ stage_overrides = stage_overrides_json
+
config_path, stage_configs = load_and_resolve_stage_configs(
model,
stage_configs_path,
base_kwargs,
default_stage_cfg_factory=lambda: self._create_default_diffusion_stage_cfg(kwargs),
+ deploy_config_path=deploy_config_path,
+ stage_overrides=stage_overrides,
+ cli_explicit_keys=cli_explicit_keys,
)
# Inject diffusion LoRA-related knobs from kwargs if not present in the stage config.
for cfg in stage_configs:
try:
+ if not hasattr(cfg, "engine_args") or cfg.engine_args is None:
+ cfg.engine_args = OmegaConf.create({})
+ global_sleep_mode = kwargs.get("enable_sleep_mode")
+ if global_sleep_mode is not None:
+ if not hasattr(cfg.engine_args, "enable_sleep_mode") or cfg.engine_args.enable_sleep_mode is None:
+ cfg.engine_args.enable_sleep_mode = global_sleep_mode
if getattr(cfg, "stage_type", None) != "diffusion":
continue
if not hasattr(cfg, "engine_args") or cfg.engine_args is None:
@@ -1642,3 +1745,9 @@ class AsyncOmniEngine:
except Exception:
logger.exception("[AsyncOmniEngine] Failed to stop OmniMasterServer during shutdown")
self._omni_master_server = None
+
+ def _try_shutdown(self, *args, **kwargs) -> None:
+ try:
+ self.shutdown()
+ except Exception:
+ logger.exception(*args, **kwargs)
diff --git a/vllm_omni/engine/cfg_companion_tracker.py b/vllm_omni/engine/cfg_companion_tracker.py
new file mode 100644
index 000000000..b9dfae833
--- /dev/null
+++ b/vllm_omni/engine/cfg_companion_tracker.py
@@ -0,0 +1,125 @@
+"""CFG companion request tracker for the Omni orchestrator.
+
+Encapsulates all bookkeeping for Classifier-Free Guidance companion
+requests (parent/companion ID mapping, completion tracking,
+deferred forwarding, and cleanup).
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+
+logger = logging.getLogger(__name__)
+
+
+class CfgCompanionTracker:
+ """Manages CFG companion request lifecycle in the orchestrator scheduling loop."""
+
+ def __init__(self) -> None:
+ self._companion_map: dict[str, dict[str, str]] = {} # parent -> {role: companion_id}
+ self._companion_ids: set[str] = set()
+ self._companion_to_parent: dict[str, str] = {} # companion -> parent
+ self._done: dict[str, set[str]] = {} # parent -> completed companion ids
+ self._pending_parents: dict[str, dict[str, Any]] = {} # parent -> deferred result
+
+ def is_companion(self, req_id: str) -> bool:
+ return req_id in self._companion_ids
+
+ def has_companions(self, parent_id: str) -> bool:
+ return parent_id in self._companion_map
+
+ def all_companions_done(self, parent_id: str) -> bool:
+ role_map = self._companion_map.get(parent_id, {})
+ done_set = self._done.get(parent_id, set())
+ return all(cid in done_set for cid in role_map.values())
+
+ def get_companion_request_ids(self, parent_id: str) -> dict[str, str]:
+ """Return ``{role: companion_request_id}`` for a parent."""
+ return self._companion_map.get(parent_id, {})
+
+ def register_parent(self, parent_id: str) -> None:
+ self._companion_map.setdefault(parent_id, {})
+ self._done.setdefault(parent_id, set())
+
+ def register_companion(self, parent_id: str, role: str, companion_id: str) -> None:
+ self.register_parent(parent_id)
+ self._companion_map[parent_id][role] = companion_id
+ self._companion_ids.add(companion_id)
+ self._companion_to_parent[companion_id] = parent_id
+
+ def attach_cfg_request_ids(self, parent_id: str, sampling_params: Any) -> Any:
+ cfg_ids = self.get_companion_request_ids(parent_id)
+ if not cfg_ids:
+ return sampling_params
+
+ if isinstance(sampling_params, OmniDiffusionSamplingParams):
+ sampling_params = sampling_params.clone()
+ sampling_params.cfg_kv_request_ids = cfg_ids
+ logger.info(
+ "Attaching cfg_kv_request_ids=%s to request %s",
+ cfg_ids,
+ parent_id,
+ )
+ return sampling_params
+
+ def on_companion_completed(self, companion_id: str) -> str | None:
+ """Mark done. Returns parent_id only if parent is pending and all companions finished."""
+ parent_id = self._companion_to_parent.get(companion_id)
+ if parent_id is None:
+ return None
+ done_set = self._done.get(parent_id)
+ assert done_set is not None, f"Companion {companion_id} completed before parent {parent_id} was registered"
+ if companion_id in done_set:
+ return None
+ done_set.add(companion_id)
+ logger.debug("CFG companion %s completed (parent=%s)", companion_id, parent_id)
+ if parent_id in self._pending_parents and self.all_companions_done(parent_id):
+ return parent_id
+ return None
+
+ def defer_parent(self, parent_id: str, engine_outputs: Any, stage_id: int) -> None:
+ """Hold parent result while waiting for companions to finish."""
+ # TODO: Add timeout/error recovery when the orchestrator grows a
+ # companion-failure path. Today deferred parents are released only when
+ # companions finish or the external layer aborts the request.
+ self._pending_parents[parent_id] = {
+ "engine_outputs": engine_outputs,
+ "stage_id": stage_id,
+ }
+ logger.debug("Parent %s deferred, waiting for CFG companions", parent_id)
+
+ def pop_pending_parent(self, parent_id: str) -> dict[str, Any] | None:
+ return self._pending_parents.pop(parent_id, None)
+
+ def cleanup_parent(self, parent_id: str) -> list[str]:
+ companion_ids = list(self._companion_map.pop(parent_id, {}).values())
+ for companion_id in companion_ids:
+ self._companion_ids.discard(companion_id)
+ self._companion_to_parent.pop(companion_id, None)
+ self._done.pop(parent_id, None)
+ self._pending_parents.pop(parent_id, None)
+ return companion_ids
+
+ def abort_parents(self, request_ids: list[str]) -> list[str]:
+ all_request_ids = list(request_ids)
+ seen = set(all_request_ids)
+ parents_to_cleanup: set[str] = set()
+
+ for req_id in request_ids:
+ # The orchestrator calls this with parent request IDs. If a raw
+ # companion ID is passed here, keep it as a direct abort target and
+ # avoid tearing down parent tracking state implicitly.
+ if req_id not in self._companion_ids:
+ parents_to_cleanup.add(req_id)
+
+ for parent_id in parents_to_cleanup:
+ companion_ids = self.cleanup_parent(parent_id)
+ for companion_id in companion_ids:
+ if companion_id not in seen:
+ seen.add(companion_id)
+ all_request_ids.append(companion_id)
+
+ return all_request_ids
diff --git a/vllm_omni/engine/orchestrator.py b/vllm_omni/engine/orchestrator.py
index 386b545eb..ca60c7531 100644
--- a/vllm_omni/engine/orchestrator.py
+++ b/vllm_omni/engine/orchestrator.py
@@ -22,11 +22,13 @@ from vllm.outputs import RequestOutput
from vllm.pooling_params import PoolingParams
from vllm.sampling_params import SamplingParams
from vllm.v1.engine import EngineCoreOutputs
+from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.distributed.omni_connectors.adapter import compute_talker_prompt_ids_length
from vllm_omni.engine import (
OmniEngineCoreRequest,
)
+from vllm_omni.engine.cfg_companion_tracker import CfgCompanionTracker
from vllm_omni.engine.serialization import serialize_additional_information
from vllm_omni.metrics.stats import StageRequestStats as StageRequestMetrics
from vllm_omni.metrics.stats import StageStats
@@ -41,6 +43,8 @@ def build_engine_core_request_from_tokens(
params: SamplingParams | PoolingParams,
arrival_time: float | None = None,
model_config: ModelConfig | None = None,
+ resumable: bool = False,
+ mm_features: list | None = None,
) -> OmniEngineCoreRequest:
"""Build an OmniEngineCoreRequest directly from an OmniTokensPrompt.
@@ -75,7 +79,7 @@ def build_engine_core_request_from_tokens(
return OmniEngineCoreRequest(
request_id=request_id,
prompt_token_ids=prompt_token_ids,
- mm_features=None,
+ mm_features=mm_features,
sampling_params=sampling_params,
pooling_params=pooling_params,
arrival_time=arrival_time,
@@ -83,6 +87,7 @@ def build_engine_core_request_from_tokens(
cache_salt=None,
data_parallel_rank=None,
prompt_embeds=prompt_embeds,
+ resumable=resumable,
additional_information=additional_info_payload,
)
@@ -103,6 +108,22 @@ class OrchestratorRequestState:
# Metrics: timestamp when request was submitted to each stage
stage_submit_ts: dict[int, float] = field(default_factory=dict)
+ mm_processor_kwargs: dict | None = None
+ mm_features: list | None = None
+
+ streaming: StreamingInputState = field(default_factory=lambda: StreamingInputState())
+
+
+@dataclass
+class StreamingInputState:
+ # Flag of streaming input request
+ enabled: bool = False
+ # Flag of segment of streaming input finished
+ segment_finished: bool = False
+ # Streaming update prompt length
+ new_prompt_len_snapshot: int | None = None
+ # Model/bridge-specific runtime states (e.g., thinker->talker)
+ bridge_states: dict[str, Any] = field(default_factory=dict)
class Orchestrator:
@@ -122,6 +143,7 @@ class Orchestrator:
stage_vllm_configs: list[Any],
*,
async_chunk: bool = False,
+ pd_config: dict[str, Any] | None = None,
) -> None:
self.request_async_queue = request_async_queue
self.output_async_queue = output_async_queue
@@ -134,15 +156,21 @@ class Orchestrator:
self.output_processors: list[Any] = output_processors
self.stage_vllm_configs: list[Any] = stage_vllm_configs
+ # PD disaggregation state
+ self._pd_pair: tuple[int, int] | None = None
+ self._pd_bootstrap_addr: str | None = None
+ self._pd_prefill_engine_id: str | None = None
+ self._pd_kv_params: dict[str, Any] = {}
+ if pd_config is not None:
+ self._pd_pair = pd_config.get("pd_pair")
+ self._pd_bootstrap_addr = pd_config.get("bootstrap_addr")
+ self._pd_prefill_engine_id = pd_config.get("prefill_engine_id")
+
# Per-request state
self.request_states: dict[str, OrchestratorRequestState] = {}
# CFG companion tracking
- self._companion_map: dict[str, dict[str, str]] = {}
- self._companion_to_parent: dict[str, str] = {}
- self._companion_ids: set[str] = set()
- self._companion_done: dict[str, set[str]] = {}
- self._deferred_parents: dict[str, dict[str, Any]] = {}
+ self._cfg_tracker = CfgCompanionTracker()
# Per-stage metrics accumulators.
self._batch_seq: list[int] = [0] * self.num_stages
@@ -152,6 +180,7 @@ class Orchestrator:
# Shutdown coordination
self._shutdown_event = asyncio.Event()
self._stages_shutdown = False
+ self._fatal_error: str | None = None
async def run(self) -> None:
"""Main entry point for the Orchestrator event loop."""
@@ -181,6 +210,12 @@ class Orchestrator:
except Exception:
pass
+ # If a fatal error caused the shutdown, drain any pending
+ # add_request messages that were never processed and broadcast
+ # fatal error responses so callers are not left hanging.
+ if self._fatal_error is not None:
+ await self._drain_pending_requests_on_fatal()
+
self._shutdown_stages()
# Cancel any remaining tasks spawned by wait_for / gather so
@@ -244,8 +279,40 @@ class Orchestrator:
output = stage_client.get_diffusion_output_nowait()
if output is not None:
idle = False
+
+ if getattr(output, "error", None) is not None:
+ await self.output_async_queue.put(
+ {
+ "type": "output",
+ "request_id": output.request_id,
+ "stage_id": stage_id,
+ "engine_outputs": output,
+ "metrics": None,
+ "finished": True,
+ }
+ )
+ self.request_states.pop(output.request_id, None)
+ continue
+
req_state = self.request_states.get(output.request_id)
if req_state is not None:
+ if getattr(output, "error", None) is not None:
+ parent_id = self._companion_to_parent.get(output.request_id, output.request_id)
+ await self.output_async_queue.put(
+ {
+ "type": "error",
+ "request_id": parent_id,
+ "stage_id": stage_id,
+ "error": output.error,
+ }
+ )
+ role_map = self._companion_map.get(parent_id, {})
+ for cid in role_map.values():
+ self.request_states.pop(cid, None)
+ self._cleanup_companion_state(parent_id)
+ self.request_states.pop(parent_id, None)
+ continue
+
stage_metrics = self._build_stage_metrics(stage_id, output.request_id, [output], req_state)
await self._route_output(stage_id, output, req_state, stage_metrics)
continue
@@ -257,6 +324,26 @@ class Orchestrator:
continue
except asyncio.CancelledError:
raise
+ except EngineDeadError as e:
+ logger.error(
+ "[Orchestrator] Stage-%s is dead: %s",
+ stage_id,
+ e,
+ )
+ self._fatal_error = str(e)
+ for req_id, req_state in list(self.request_states.items()):
+ if stage_id in req_state.stage_submit_ts:
+ await self.output_async_queue.put(
+ {
+ "type": "error",
+ "error": str(e),
+ "fatal": True,
+ "request_id": req_id,
+ }
+ )
+ self.request_states.pop(req_id, None)
+ self._shutdown_event.set()
+ raise
except Exception:
if self._shutdown_event.is_set():
return
@@ -317,7 +404,7 @@ class Orchestrator:
# CFG companion handling: companions don't produce user-visible output
# and don't forward to the next stage directly.
- if finished and req_id in self._companion_ids:
+ if finished and self._cfg_tracker.is_companion(req_id):
await self._handle_cfg_companion_ready(req_id)
self.request_states.pop(req_id, None)
return
@@ -345,63 +432,80 @@ class Orchestrator:
}
)
+ # PD disaggregation: extract KV transfer params from prefill stage output
+ if self._pd_pair is not None and finished and stage_id == self._pd_pair[0]:
+ kv_params = getattr(output, "kv_transfer_params", None)
+ if kv_params is not None:
+ self._pd_kv_params[req_id] = kv_params if isinstance(kv_params, dict) else dict(kv_params)
+ logger.debug(
+ "[Orchestrator][PD] stored kv_transfer_params for req=%s (keys=%s)",
+ req_id,
+ list(self._pd_kv_params[req_id].keys()),
+ )
+ else:
+ logger.warning(
+ "[Orchestrator][PD] prefill stage output for req=%s has no kv_transfer_params; "
+ "KV transfer may fail. Ensure apply_mooncake_connector_patch() was called.",
+ req_id,
+ )
+
if (
- finished
+ (finished or (req_state.streaming.enabled and req_state.streaming.segment_finished))
and stage_id < req_state.final_stage_id
and not self.async_chunk
- and not self._next_stage_already_submitted(stage_id, req_state)
+ and (not self._next_stage_already_submitted(stage_id, req_state) or req_state.streaming.enabled)
):
- if req_id in self._companion_map and not self._all_companions_done(req_id):
- self._deferred_parents[req_id] = {
- "stage_id": stage_id,
- "output": output,
- }
+ if (
+ finished
+ and self._cfg_tracker.has_companions(req_id)
+ and not self._cfg_tracker.all_companions_done(req_id)
+ ):
+ self._cfg_tracker.defer_parent(req_id, output, stage_id)
else:
- await self._forward_to_next_stage(req_id, stage_id, output, req_state)
+ await self._forward_to_next_stage(
+ req_id,
+ stage_id,
+ output,
+ req_state,
+ is_streaming_session=req_state.streaming.enabled,
+ is_final_update=False,
+ )
+ if req_state.streaming.enabled and finished:
+ # For streaming sessions, send the terminal (resumable=False) update only on a finish
+ await self._forward_to_next_stage(
+ req_id,
+ stage_id,
+ output,
+ req_state,
+ is_streaming_session=True,
+ is_final_update=True,
+ )
if finished and stage_id == req_state.final_stage_id:
- self._cleanup_companion_state(req_id)
+ # PD: clean up any lingering KV params for this request
+ self._pd_kv_params.pop(req_id, None)
+ self._cfg_tracker.cleanup_parent(req_id)
self.request_states.pop(req_id, None)
- def _cleanup_companion_state(self, parent_id: str) -> None:
- """Remove all companion tracking state for a completed parent."""
- role_map = self._companion_map.pop(parent_id, {})
- for cid in role_map.values():
- self._companion_ids.discard(cid)
- self._companion_to_parent.pop(cid, None)
- self._companion_done.pop(parent_id, None)
- self._deferred_parents.pop(parent_id, None)
-
- def _all_companions_done(self, parent_id: str) -> bool:
- """Check whether all CFG companions for a parent request have finished."""
- role_map = self._companion_map.get(parent_id, {})
- if not role_map:
- return True
- done_set = self._companion_done.get(parent_id, set())
- return all(cid in done_set for cid in role_map.values())
-
def _next_stage_already_submitted(self, stage_id: int, req_state: OrchestratorRequestState) -> bool:
return (stage_id + 1) in req_state.stage_submit_ts
async def _handle_cfg_companion_ready(self, req_id: str) -> None:
"""Mark a CFG companion as done; if all companions are done, flush deferred parent."""
- parent_id = self._companion_to_parent.get(req_id)
+ parent_id = self._cfg_tracker.on_companion_completed(req_id)
if parent_id is None:
return
- done_set = self._companion_done.setdefault(parent_id, set())
- if req_id in done_set:
+ deferred = self._cfg_tracker.pop_pending_parent(parent_id)
+ if deferred is None:
return
- done_set.add(req_id)
- if parent_id in self._deferred_parents and self._all_companions_done(parent_id):
- deferred = self._deferred_parents.pop(parent_id)
- parent_state = self.request_states.get(parent_id)
- if parent_state is not None and not self._next_stage_already_submitted(deferred["stage_id"], parent_state):
- await self._forward_to_next_stage(
- parent_id,
- deferred["stage_id"],
- deferred["output"],
- parent_state,
- )
+ parent_state = self.request_states.get(parent_id)
+ if parent_state is not None and not self._next_stage_already_submitted(deferred["stage_id"], parent_state):
+ await self._forward_to_next_stage(
+ parent_id,
+ deferred["stage_id"],
+ deferred["engine_outputs"],
+ parent_state,
+ )
async def _handle_kv_ready_raw_outputs(self, stage_id: int, raw_outputs: EngineCoreOutputs) -> None:
"""Forward split requests once stage-0 KV is ready, not only when decode fully finishes."""
@@ -415,21 +519,63 @@ class Orchestrator:
req_state = self.request_states.get(req_id)
if req_state is None:
continue
- if req_id in self._companion_ids:
+ if self._cfg_tracker.is_companion(req_id):
await self._handle_cfg_companion_ready(req_id)
continue
if stage_id >= req_state.final_stage_id:
continue
if self._next_stage_already_submitted(stage_id, req_state):
continue
- if req_id in self._companion_map and not self._all_companions_done(req_id):
- self._deferred_parents[req_id] = {
- "stage_id": stage_id,
- "output": raw_output,
- }
+ if self._cfg_tracker.has_companions(req_id) and not self._cfg_tracker.all_companions_done(req_id):
+ self._cfg_tracker.defer_parent(req_id, raw_output, stage_id)
else:
await self._forward_to_next_stage(req_id, stage_id, raw_output, req_state)
+ def _build_pd_decode_params(self, req_id: str, sp: Any) -> Any:
+ """Build decode-side sampling params with KV transfer params for PD routing.
+
+ Clones the sampling params and injects kv_transfer_params that tell the
+ decode engine where to pull the KV cache from (prefill engine's bootstrap addr).
+ """
+ sp = sp.clone()
+ if sp.extra_args is None:
+ sp.extra_args = {}
+
+ # Get KV params captured from the prefill output (must include remote_request_id).
+ kv_prefill_params = self._pd_kv_params.pop(req_id, None)
+ if not kv_prefill_params or "remote_request_id" not in kv_prefill_params:
+ raise RuntimeError(
+ f"[Orchestrator][PD] Missing prefill kv_transfer_params.remote_request_id for req={req_id}"
+ )
+
+ decode_kv_params: dict[str, Any] = {
+ "transfer_id": f"xfer-{req_id}",
+ }
+
+ if self._pd_bootstrap_addr:
+ decode_kv_params["remote_bootstrap_addr"] = self._pd_bootstrap_addr
+
+ if self._pd_prefill_engine_id:
+ decode_kv_params["remote_engine_id"] = self._pd_prefill_engine_id
+
+ # Overlay params from prefill side (includes remote_request_id set by monkey patch).
+ decode_kv_params.update(kv_prefill_params)
+
+ # Ensure these flags are set correctly after any overlay.
+ decode_kv_params["do_remote_prefill"] = True
+ decode_kv_params["do_remote_decode"] = False
+ if not decode_kv_params.get("transfer_id"):
+ decode_kv_params["transfer_id"] = f"xfer-{req_id}"
+
+ sp.extra_args["kv_transfer_params"] = decode_kv_params
+
+ logger.debug(
+ "[Orchestrator][PD] decode kv_transfer_params for req=%s: %s",
+ req_id,
+ decode_kv_params,
+ )
+ return sp
+
def _build_stage_metrics(
self,
stage_id: int,
@@ -507,6 +653,9 @@ class Orchestrator:
stage_id: int,
output: Any,
req_state: OrchestratorRequestState,
+ *,
+ is_streaming_session: bool = False,
+ is_final_update: bool = False,
) -> None:
"""Forward output from current stage to the next stage.
@@ -516,35 +665,32 @@ class Orchestrator:
next_stage_id = stage_id + 1
next_client = self.stage_clients[next_stage_id]
params = req_state.sampling_params_list[next_stage_id]
+ next_stage_resumable = is_streaming_session and not is_final_update
if next_client.stage_type == "diffusion":
self.stage_clients[stage_id].set_engine_outputs([output])
if next_client.custom_process_input_func is not None:
+ _t_ar2d = _time.perf_counter()
diffusion_prompt = next_client.custom_process_input_func(
self.stage_clients,
next_client.engine_input_source,
req_state.prompt,
False,
)
+ _dt_ar2d = (_time.perf_counter() - _t_ar2d) * 1000
+ logger.info(
+ "[Orchestrator] ar2diffusion req=%s wall_time=%.3fms stage=%d->%d",
+ req_id,
+ _dt_ar2d,
+ stage_id,
+ next_stage_id,
+ )
if isinstance(diffusion_prompt, list):
diffusion_prompt = diffusion_prompt[0]
else:
diffusion_prompt = req_state.prompt
- # Attach CFG companion KV request IDs so the diffusion model
- # runner can fetch companion KV caches alongside the primary one.
- cfg_ids = self._companion_map.get(req_id)
- if cfg_ids:
- from vllm_omni.inputs.data import OmniDiffusionSamplingParams
-
- if isinstance(params, OmniDiffusionSamplingParams):
- params = copy.deepcopy(params)
- params.cfg_kv_request_ids = cfg_ids
- logger.info(
- "[Orchestrator] Attaching cfg_kv_request_ids=%s to req %s",
- cfg_ids,
- req_id,
- )
+ params = self._cfg_tracker.attach_cfg_request_ids(req_id, params)
source_stage_ids = list(getattr(next_client, "engine_input_source", None) or [stage_id])
kv_sender_info = self._build_kv_sender_info(sender_stage_ids=source_stage_ids)
@@ -565,6 +711,52 @@ class Orchestrator:
req_state.stage_submit_ts[next_stage_id] = _time.time()
return
+ # PD disaggregation: prefill → decode routing uses original prompt + KV transfer params
+ if self._pd_pair is not None and (stage_id, next_stage_id) == self._pd_pair:
+ # Save prefill stage outputs so thinker2talker can merge embeddings later
+ self.stage_clients[stage_id].set_engine_outputs([output])
+
+ params = self._build_pd_decode_params(req_id, params)
+
+ # Use the original user prompt for the decode stage (not processed embeddings)
+ original_prompt = req_state.prompt
+ raw_decode_inputs = [original_prompt] if not isinstance(original_prompt, list) else original_prompt
+
+ decode_inputs: list[dict[str, Any]] = []
+ for decode_input in raw_decode_inputs:
+ if isinstance(decode_input, dict):
+ decode_inputs.append(decode_input)
+ continue
+ prompt_token_ids = getattr(decode_input, "prompt_token_ids", None)
+ if prompt_token_ids is None:
+ raise TypeError(
+ "[Orchestrator][PD] decode input must be dict or have prompt_token_ids, "
+ f"got {type(decode_input).__name__} for req={req_id}"
+ )
+ decode_inputs.append({"prompt_token_ids": list(prompt_token_ids)})
+
+ for decode_input in decode_inputs:
+ request = build_engine_core_request_from_tokens(
+ request_id=req_id,
+ prompt=decode_input,
+ params=params,
+ model_config=self.stage_vllm_configs[next_stage_id].model_config,
+ mm_features=req_state.mm_features, # Pass mm_features for M-RoPE
+ )
+ request.external_req_id = request.request_id
+
+ self.output_processors[next_stage_id].add_request(
+ request=request,
+ prompt=None,
+ parent_req=None,
+ request_index=0,
+ queue=None,
+ )
+ await next_client.add_request_async(request)
+
+ req_state.stage_submit_ts[next_stage_id] = _time.time()
+ return
+
self.stage_clients[stage_id].set_engine_outputs([output])
# Process inputs for next stage
@@ -572,6 +764,7 @@ class Orchestrator:
next_inputs = next_client.process_engine_inputs(
stage_list=self.stage_clients,
prompt=req_state.prompt,
+ streaming_context=(req_state.streaming if req_state.streaming.enabled else None),
)
except Exception:
logger.exception(
@@ -583,11 +776,17 @@ class Orchestrator:
# Build and submit requests for each input
for next_input in next_inputs:
+ # Only AR thinker stages consume encoder mm_features; downstream
+ # (talker/code2wav/…) must not see them (avoids encoder-cache misses).
+ _ms = getattr(next_client, "model_stage", None)
+ _mm_features = req_state.mm_features if _ms == "thinker" else None
request = build_engine_core_request_from_tokens(
request_id=req_id,
prompt=next_input,
params=params,
model_config=self.stage_vllm_configs[next_stage_id].model_config,
+ mm_features=_mm_features,
+ resumable=next_stage_resumable,
)
# TODO: Here we directly use the req id to assign.
@@ -629,6 +828,13 @@ class Orchestrator:
raw_outputs.timestamp,
None,
)
+ for eco in raw_outputs.outputs:
+ if not hasattr(eco, "request_id"):
+ continue
+ req_state = self.request_states.get(eco.request_id)
+ if req_state:
+ req_state.streaming.segment_finished = eco.is_segment_finished
+ req_state.streaming.new_prompt_len_snapshot = eco.new_prompt_len_snapshot
if processed.reqs_to_abort:
await self.stage_clients[stage_id].abort_requests_async(processed.reqs_to_abort)
@@ -664,19 +870,22 @@ class Orchestrator:
# Track request state - use original_prompt so downstream stages
# (e.g. thinker2talker) can access the raw dict with multi_modal_data.
+ request = prompt
+ is_streaming = bool(getattr(request, "resumable", False))
req_state = OrchestratorRequestState(
request_id=request_id,
prompt=original_prompt,
sampling_params_list=sampling_params_list,
final_stage_id=final_stage_id,
+ mm_features=getattr(prompt, "mm_features", None), # Save mm_features for PD
)
+ req_state.streaming.enabled = is_streaming
req_state.stage_submit_ts[stage_id] = _time.time()
self.request_states[request_id] = req_state
# Stage-0 prompt is already a fully-formed OmniEngineCoreRequest
# (pre-processed by AsyncOmniEngine.add_request, output processor
# already registered there) - submit directly.
- request = prompt
stage_client = self.stage_clients[stage_id]
if stage_client.stage_type == "diffusion":
if isinstance(prompt, list):
@@ -712,6 +921,7 @@ class Orchestrator:
if "sampling_params_list" in msg and msg["sampling_params_list"]:
req_state.sampling_params_list = msg["sampling_params_list"]
+ req_state.streaming.enabled = True
req_state.stage_submit_ts[stage_id] = _time.time()
stage_client = self.stage_clients[stage_id]
@@ -800,13 +1010,7 @@ class Orchestrator:
companion_prompt = msg["prompt"]
sampling_params_list = msg["sampling_params_list"]
- # Register companion mapping
- if parent_id not in self._companion_map:
- self._companion_map[parent_id] = {}
- self._companion_map[parent_id][role] = companion_id
- self._companion_ids.add(companion_id)
- self._companion_to_parent[companion_id] = parent_id
- self._companion_done.setdefault(parent_id, set())
+ self._cfg_tracker.register_companion(parent_id, role, companion_id)
companion_state = OrchestratorRequestState(
request_id=companion_id,
@@ -831,22 +1035,10 @@ class Orchestrator:
async def _handle_abort(self, msg: dict[str, Any]) -> None:
"""Handle an abort message from the main thread."""
request_ids = msg["request_ids"]
- # Also abort any CFG companions for aborted parents
- companion_ids_to_abort: list[str] = []
- for req_id in request_ids:
- role_map = self._companion_map.pop(req_id, {})
- for cid in role_map.values():
- companion_ids_to_abort.append(cid)
- self._companion_ids.discard(cid)
- self._companion_to_parent.pop(cid, None)
- self.request_states.pop(cid, None)
- self._companion_done.pop(req_id, None)
- self._deferred_parents.pop(req_id, None)
-
- all_ids_to_abort = list(request_ids) + companion_ids_to_abort
+ all_ids_to_abort = self._cfg_tracker.abort_parents(request_ids)
for stage_id in range(self.num_stages):
await self.stage_clients[stage_id].abort_requests_async(all_ids_to_abort)
- for req_id in request_ids:
+ for req_id in all_ids_to_abort:
self.request_states.pop(req_id, None)
logger.info("[Orchestrator] Aborted request(s) %s", request_ids)
@@ -915,6 +1107,53 @@ class Orchestrator:
}
)
+ async def _drain_pending_requests_on_fatal(self) -> None:
+ """Drain the request queue and broadcast fatal errors for any
+ pending add_request messages that were never processed.
+
+ Called from the ``run()`` finally block when a fatal error
+ (e.g. ``EngineDeadError``) caused the orchestrator to shut down
+ before the request handler could process all queued messages.
+ Also broadcasts for any already-tracked requests still in
+ ``request_states`` that were not yet notified.
+ """
+ assert self._fatal_error is not None
+
+ notified: set[str] = set()
+
+ # 1) Drain pending messages from the request queue.
+ while True:
+ try:
+ msg = self.request_async_queue.get_nowait()
+ except Exception:
+ break
+ if msg.get("type") == "add_request":
+ req_id = msg["request_id"]
+ await self.output_async_queue.put(
+ {
+ "type": "error",
+ "error": self._fatal_error,
+ "fatal": True,
+ "request_id": req_id,
+ }
+ )
+ notified.add(req_id)
+
+ # 2) Broadcast for any tracked requests not already notified
+ # (e.g. request was registered but the EngineDeadError handler
+ # missed it because it wasn't submitted to the dead stage yet).
+ for req_id in list(self.request_states):
+ if req_id not in notified:
+ await self.output_async_queue.put(
+ {
+ "type": "error",
+ "error": self._fatal_error,
+ "fatal": True,
+ "request_id": req_id,
+ }
+ )
+ self.request_states.pop(req_id, None)
+
def _shutdown_stages(self) -> None:
"""Shutdown all stage clients."""
if self._stages_shutdown:
diff --git a/vllm_omni/engine/output_processor.py b/vllm_omni/engine/output_processor.py
index 43d02e85b..a5ed29ece 100644
--- a/vllm_omni/engine/output_processor.py
+++ b/vllm_omni/engine/output_processor.py
@@ -232,10 +232,9 @@ class OmniRequestState(RequestState):
# Reuse base text/logprobs logic, then annotate with pooling_result.
base_output = super()._new_completion_output(token_ids, finish_reason, stop_reason, routed_experts)
try:
+ if not hasattr(base_output, "multimodal_output"):
+ setattr(base_output, "multimodal_output", {})
if self.mm_accumulated is not None:
- # Attach accumulated multimodal dict on the completion output
- if not hasattr(base_output, "multimodal_output"):
- setattr(base_output, "multimodal_output", {})
mm_out = getattr(base_output, "multimodal_output")
if isinstance(mm_out, dict):
for k, v in self.mm_accumulated.items():
diff --git a/vllm_omni/engine/stage_engine_core_client.py b/vllm_omni/engine/stage_engine_core_client.py
index 52e674f47..f6683c1e0 100644
--- a/vllm_omni/engine/stage_engine_core_client.py
+++ b/vllm_omni/engine/stage_engine_core_client.py
@@ -6,15 +6,21 @@ Directly inherits from vLLM's AsyncMPClient to reuse EngineCore architecture.
from __future__ import annotations
+import multiprocessing.connection
import socket
+import threading
+import weakref
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from vllm.logger import init_logger
from vllm.v1.engine import EngineCoreRequest
from vllm.v1.engine.core_client import AsyncMPClient, DPLBAsyncMPClient
+from vllm.v1.engine.exceptions import EngineDeadError
-from vllm_omni.distributed.omni_connectors.utils.initialization import KV_TRANSFER_PORT_OFFSET
+from vllm_omni.distributed.omni_connectors.utils.initialization import (
+ KV_TRANSFER_PORT_OFFSET,
+)
from vllm_omni.engine.stage_init_utils import StageMetadata
if TYPE_CHECKING:
@@ -156,13 +162,64 @@ class StageEngineCoreClientBase:
shutdown_error,
)
raise
+
self._initialize_kv_sender_endpoint()
+
+ if self._proc is not None:
+ self._start_proc_monitor()
+
logger.info(
"[%s] Stage-%s EngineCore running",
client_name,
self.stage_id,
)
+ def _start_proc_monitor(self) -> None:
+ """Start a daemon thread that watches the subprocess sentinel.
+
+ When the subprocess dies without sending the ZMQ ``ENGINE_CORE_DEAD``
+ sentinel (e.g. SIGKILL, segfault, OOM-killer), this thread sets
+ ``resources.engine_dead`` so subsequent calls raise
+ ``EngineDeadError``.
+ """
+ proc = self._proc
+ resources_ref = weakref.ref(self.resources)
+ stage_id = self.stage_id
+
+ def _monitor() -> None:
+ try:
+ multiprocessing.connection.wait([proc.sentinel])
+ except Exception:
+ return
+ resources = resources_ref()
+ if resources is None or resources.engine_dead:
+ return
+ resources.engine_dead = True
+ logger.error(
+ "[StageEngineCoreClient] Stage-%s subprocess died unexpectedly (exit code %s).",
+ stage_id,
+ proc.exitcode,
+ )
+
+ t = threading.Thread(
+ target=_monitor,
+ daemon=True,
+ name=f"StageCoreProcMonitor-{stage_id}",
+ )
+ t.start()
+
+ def check_health(self) -> None:
+ """Raise ``EngineDeadError`` if the stage subprocess is dead.
+
+ Called by ``OmniBase.check_health()`` and transitively by the
+ ``/health`` HTTP endpoint.
+ """
+ if self.resources.engine_dead:
+ raise EngineDeadError(f"Stage-{self.stage_id} engine core is dead")
+ if self._proc is not None and not self._proc.is_alive():
+ self.resources.engine_dead = True
+ raise EngineDeadError(f"Stage-{self.stage_id} subprocess is not alive (exit code {self._proc.exitcode})")
+
# ==================== Overrides ====================
async def add_request_async(self, request: EngineCoreRequest) -> None:
@@ -246,6 +303,8 @@ class StageEngineCoreClientBase:
from_stage = omni_kv_config.get("omni_from_stage", from_stage)
try:
+ # Orchestrator always reports rank-0's port; receiver
+ # workers add their own local_rank * KV_RANK_PORT_STRIDE.
sender_port = int(base_port) + KV_TRANSFER_PORT_OFFSET + int(from_stage)
except (TypeError, ValueError):
logger.warning(
@@ -284,6 +343,7 @@ class StageEngineCoreClientBase:
self._kv_sender_host = self._resolve_contact_host()
if self._kv_sender_host is None:
return None
+ # rank-0 base port; receiver workers adjust per KV_RANK_PORT_STRIDE.
return {
"host": self._kv_sender_host,
"zmq_port": base_port + kv_transfer_port_offset + int(self.stage_id),
@@ -297,11 +357,21 @@ class StageEngineCoreClientBase:
self,
stage_list: list[Any],
prompt: OmniTokensPrompt | list[OmniTokensPrompt] | None = None,
+ streaming_context: Any | None = None,
) -> list[OmniTokensPrompt]:
"""Process inputs from upstream stages."""
from vllm_omni.inputs.data import OmniTokensPrompt
if self.custom_process_input_func is not None:
+ # Keep legacy arg call for non-streaming processors.
+ if bool(getattr(streaming_context, "enabled", False)):
+ return self.custom_process_input_func(
+ stage_list,
+ self.engine_input_source,
+ prompt,
+ self.requires_multimodal_data,
+ streaming_context,
+ )
return self.custom_process_input_func(
stage_list,
self.engine_input_source,
diff --git a/vllm_omni/engine/stage_init_utils.py b/vllm_omni/engine/stage_init_utils.py
index 158b4c547..cd6cd6a69 100644
--- a/vllm_omni/engine/stage_init_utils.py
+++ b/vllm_omni/engine/stage_init_utils.py
@@ -13,7 +13,7 @@ import importlib
import multiprocessing as mp
import os
import time
-from collections.abc import Callable
+from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Any, Literal
@@ -23,6 +23,7 @@ from vllm.usage.usage_lib import UsageContext
from vllm.v1.engine.input_processor import InputProcessor
from vllm.v1.executor import Executor
+from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.engine.arg_utils import OmniEngineArgs
from vllm_omni.entrypoints.stage_utils import _to_dict, set_stage_devices
from vllm_omni.entrypoints.utils import filter_dataclass_kwargs, resolve_model_config_path
@@ -101,8 +102,110 @@ def resolve_worker_cls(engine_args: dict[str, Any]) -> None:
raise ValueError(f"Unknown worker_type: {worker_type}")
-def inject_kv_stage_info(stage_cfg: Any, stage_id: int) -> None:
- """Inject stage metadata into omni_kv_config when present."""
+def _get_attr_or_item(obj: Any, key: str, default: Any = None) -> Any:
+ """Read *key* from *obj* regardless of whether it's a dict or object."""
+ if hasattr(obj, "get"):
+ return obj.get(key, default)
+ return getattr(obj, key, default)
+
+
+def _tp_size_for_stage(stage_configs: Sequence[Any], stage_id: Any) -> int | None:
+ """Resolve tensor_parallel_size for *stage_id* from the loaded stage configs."""
+ id_strs = {str(stage_id)}
+ try:
+ id_strs.add(str(int(stage_id)))
+ except (TypeError, ValueError):
+ pass
+
+ for stage_cfg in stage_configs:
+ if str(getattr(stage_cfg, "stage_id", None)) not in id_strs:
+ continue
+ engine_args = getattr(stage_cfg, "engine_args", None)
+ if engine_args is None:
+ return 1
+ parallel_config = _get_attr_or_item(engine_args, "parallel_config")
+ if parallel_config is not None:
+ tp = _get_attr_or_item(parallel_config, "tensor_parallel_size", 1)
+ else:
+ tp = _get_attr_or_item(engine_args, "tensor_parallel_size", 1)
+ try:
+ return max(1, int(tp))
+ except (TypeError, ValueError):
+ return 1
+ return None
+
+
+def _inject_inferred_kv_tp_topology(
+ omni_kv: Any,
+ stage_id: int,
+ stage_configs: Sequence[Any],
+ engine_input_source: Sequence[int] | None = None,
+) -> None:
+ """Infer adjacent-stage TP topology and inject it into omni_kv_config.
+
+ This keeps heterogeneous TP working without requiring user-authored
+ rank_mapping blocks in config files.
+ """
+ if omni_kv is None:
+ return
+
+ if hasattr(omni_kv, "get"):
+ need_send = bool(omni_kv.get("need_send_cache", False))
+ need_recv = bool(omni_kv.get("need_recv_cache", False))
+ omni_from_stage = omni_kv.get("omni_from_stage")
+ omni_to_stage = omni_kv.get("omni_to_stage")
+ rank_mapping = omni_kv.get("rank_mapping")
+ else:
+ need_send = bool(getattr(omni_kv, "need_send_cache", False))
+ need_recv = bool(getattr(omni_kv, "need_recv_cache", False))
+ omni_from_stage = getattr(omni_kv, "omni_from_stage", None)
+ omni_to_stage = getattr(omni_kv, "omni_to_stage", None)
+ rank_mapping = getattr(omni_kv, "rank_mapping", None)
+
+ if not need_send and not need_recv:
+ return
+
+ current_tp = _tp_size_for_stage(stage_configs, stage_id)
+ if current_tp is None:
+ return
+
+ peer_stage_id = None
+ from_tp = None
+ to_tp = None
+ if str(omni_from_stage) == str(stage_id):
+ peer_stage_id = omni_to_stage
+ from_tp = current_tp
+ to_tp = _tp_size_for_stage(stage_configs, peer_stage_id)
+ elif str(omni_to_stage) == str(stage_id):
+ peer_stage_id = omni_from_stage
+ from_tp = _tp_size_for_stage(stage_configs, peer_stage_id)
+ to_tp = current_tp
+ elif need_recv and engine_input_source:
+ peer_stage_id = engine_input_source[0]
+ from_tp = _tp_size_for_stage(stage_configs, peer_stage_id)
+ to_tp = current_tp
+
+ if from_tp is None or to_tp is None:
+ return
+
+ if not isinstance(rank_mapping, dict):
+ rank_mapping = {}
+ rank_mapping.setdefault("from_tp", int(from_tp))
+ rank_mapping.setdefault("to_tp", int(to_tp))
+
+ if hasattr(omni_kv, "__setitem__"):
+ omni_kv["rank_mapping"] = rank_mapping
+ else:
+ setattr(omni_kv, "rank_mapping", rank_mapping)
+
+
+def inject_kv_stage_info(stage_cfg: Any, stage_id: int, stage_configs: Sequence[Any] | None = None) -> None:
+ """Inject stage_id, engine_input_source, and inferred TP topology into omni_kv_config.
+
+ When *stage_configs* is provided, also infers from_tp/to_tp for
+ heterogeneous TP topologies so the KV transfer manager can compute
+ rank mappings automatically.
+ """
try:
engine_args = stage_cfg.engine_args
if hasattr(engine_args, "get"):
@@ -125,6 +228,14 @@ def inject_kv_stage_info(stage_cfg: Any, stage_id: int) -> None:
omni_kv.setdefault("engine_input_source", list(engine_input_source))
elif hasattr(omni_kv, "__setitem__") and "engine_input_source" not in omni_kv:
omni_kv["engine_input_source"] = list(engine_input_source)
+
+ if stage_configs:
+ _inject_inferred_kv_tp_topology(
+ omni_kv,
+ stage_id=stage_id,
+ stage_configs=stage_configs,
+ engine_input_source=engine_input_source,
+ )
except Exception as e:
logger.debug("Failed to inject stage info into omni_kv_config: %s", e)
@@ -192,8 +303,9 @@ def extract_stage_metadata(stage_config: Any) -> StageMetadata:
default_sampling_params: OmniSamplingParams = SPClass(**default_sp)
custom_process_input_func: Callable | None = None
- if hasattr(stage_config, "custom_process_input_func"):
- mod_path, fn_name = stage_config.custom_process_input_func.rsplit(".", 1)
+ _cpif_path = getattr(stage_config, "custom_process_input_func", None)
+ if _cpif_path:
+ mod_path, fn_name = _cpif_path.rsplit(".", 1)
custom_process_input_func = getattr(importlib.import_module(mod_path), fn_name)
prompt_expand_func: Callable | None = None
@@ -323,6 +435,20 @@ def build_vllm_config(
filtered_engine_args_dict = filter_dataclass_kwargs(OmniEngineArgs, engine_args_dict)
omni_engine_args = OmniEngineArgs(**filtered_engine_args_dict)
+
+ # Multi-stage pipelines (qwen3_tts code2wav, etc.) set max_model_len
+ # larger than HF max_position_embeddings by design. vLLM's validator
+ # rejects that without the env flag.
+ if filtered_engine_args_dict.get("max_model_len") is not None and not os.environ.get(
+ "VLLM_ALLOW_LONG_MAX_MODEL_LEN"
+ ):
+ os.environ["VLLM_ALLOW_LONG_MAX_MODEL_LEN"] = "1"
+ logger.debug(
+ "Auto-set VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 for stage %s (max_model_len=%s).",
+ stage_config.stage_id,
+ filtered_engine_args_dict["max_model_len"],
+ )
+
vllm_config = omni_engine_args.create_engine_config(
usage_context=UsageContext.LLM_CLASS,
headless=headless,
@@ -525,11 +651,13 @@ def build_diffusion_config(
def initialize_diffusion_stage(
+ stage_id: int,
model: str,
stage_cfg: Any,
metadata: StageMetadata,
stage_init_timeout: int,
batch_size: int = 1,
+ use_inline: bool = False,
) -> Any:
"""Build a diffusion stage client.
@@ -541,13 +669,21 @@ def initialize_diffusion_stage(
batch_size: Maximum number of requests to batch together in the
diffusion engine. Passed through to ``StageDiffusionClient``
and ultimately to ``AsyncOmni``.
+ use_inline: If True, uses the inline diffusion client instead of subprocess.
"""
- from vllm_omni.diffusion.stage_diffusion_client import StageDiffusionClient
+ from vllm_omni.diffusion.stage_diffusion_client import create_diffusion_client
- od_config = build_diffusion_config(model, stage_cfg, metadata)
- return StageDiffusionClient(
- model, od_config, metadata, stage_init_timeout=stage_init_timeout, batch_size=batch_size
+ engine_args = _to_dict(stage_cfg.engine_args)
+ engine_args.pop("stage_id", None)
+ od_config = OmniDiffusionConfig.from_kwargs(
+ stage_id=stage_id,
+ model=model,
+ **engine_args,
)
+ if metadata.cfg_kv_collect_func is not None:
+ od_config.cfg_kv_collect_func = metadata.cfg_kv_collect_func
+ od_config = build_diffusion_config(model, stage_cfg, metadata)
+ return create_diffusion_client(model, od_config, metadata, stage_init_timeout, batch_size, use_inline)
def _shutdown_or_close_resource(resource: Any, resource_name: str, stage_id: int) -> None:
diff --git a/vllm_omni/entrypoints/async_omni.py b/vllm_omni/entrypoints/async_omni.py
index 129ef3c99..67425ea2e 100644
--- a/vllm_omni/entrypoints/async_omni.py
+++ b/vllm_omni/entrypoints/async_omni.py
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import time
+import uuid
from collections.abc import AsyncGenerator, Iterable, Mapping, Sequence
from typing import TYPE_CHECKING, Any
@@ -24,10 +25,12 @@ from vllm.sampling_params import RequestOutputKind, SamplingParams
from vllm.tasks import SupportedTask
from vllm.v1.engine.exceptions import EngineDeadError
+from vllm_omni.diffusion.data import OmniACK, OmniSleepTask, OmniWakeTask
from vllm_omni.entrypoints.client_request_state import ClientRequestState
from vllm_omni.entrypoints.omni_base import OmniBase
from vllm_omni.metrics.stats import OrchestratorAggregator as OrchestratorMetrics
from vllm_omni.outputs import OmniRequestOutput
+from vllm_omni.platforms import current_omni_platform
if TYPE_CHECKING:
from vllm.inputs.preprocess import InputPreprocessor
@@ -40,6 +43,63 @@ logger = init_logger(__name__)
_FINAL_OUTPUT_IDLE_SLEEP_S = 0.001
+class AsyncEventResolver:
+ """
+ A generic signal aggregator designed for synchronized handshakes in
+ distributed or multi-stage environments. Supports waiting for a specified
+ number (expected_count) of worker signals in both inline and multiprocess modes.
+ """
+
+ def __init__(self, orchestrator=None):
+ self._pending_tasks: dict[str, dict] = {}
+ self.orchestrator = orchestrator
+ self._lock = asyncio.Lock()
+
+ def watch_task(self, task_id: str, expected_count: int = 1) -> asyncio.Future:
+ loop = asyncio.get_running_loop()
+ fut = loop.create_future()
+ self._pending_tasks[task_id] = {
+ "future": fut,
+ "expected_count": expected_count,
+ "received": [],
+ "start_time": time.time(),
+ }
+ return fut
+
+ async def resolve(self, ack: OmniACK):
+ tid = getattr(ack, "task_id", None)
+
+ if tid is None and isinstance(ack, dict):
+ tid = ack.get("task_id")
+
+ async with self._lock:
+ task_info = self._pending_tasks.get(tid)
+ if task_info is None:
+ logger.warning(f"Received stray ACK for task_id {tid}. Task might have timed out.")
+ return
+
+ task_info["received"].append(ack)
+ current_count = len(task_info["received"])
+ expected = task_info["expected_count"]
+
+ orchestrator = self.orchestrator
+ if orchestrator and hasattr(orchestrator, "metrics") and orchestrator.metrics:
+ freed = getattr(ack, "freed_bytes", 0)
+ if freed == 0 and isinstance(ack, dict):
+ freed = ack.get("freed_bytes", 0)
+ orchestrator.metrics.record_vram_reclaimed(freed)
+
+ logger.info(f"[Resolver] Task {tid} progress: {current_count}/{expected} ACKs received.")
+
+ if current_count >= expected:
+ self._pending_tasks.pop(tid)
+ fut = task_info["future"]
+ if not fut.done():
+ elapsed = time.time() - task_info["start_time"]
+ logger.info(f"[Resolver] Task {tid} completed successfully in {elapsed:.2f}s.")
+ fut.set_result(task_info["received"])
+
+
class AsyncOmni(EngineClient, OmniBase):
"""Asynchronous unified entry point for multi-stage pipelines using AsyncOmniEngine.
@@ -76,9 +136,8 @@ class AsyncOmni(EngineClient, OmniBase):
self._paused: bool = False
self._is_sleeping: bool = False
self.final_output_task: asyncio.Task | None = None
-
+ self.event_resolver = AsyncEventResolver(orchestrator=self)
self.config_path = self.engine.config_path
- self.stage_configs = self.engine.stage_configs
self.tts_max_instructions_length = kwargs.get("tts_max_instructions_length", None)
self.input_processor = self.engine.input_processor
@@ -209,6 +268,13 @@ class AsyncOmni(EngineClient, OmniBase):
# Start final output dispatcher on the first call to generate()
self._final_output_handler()
+ # Expand sampling params for PD disaggregation (user may provide N-1 params)
+ if (
+ sampling_params_list is not None
+ and isinstance(sampling_params_list, Sequence)
+ and not isinstance(sampling_params_list, (str, bytes))
+ ):
+ sampling_params_list = self._maybe_expand_sampling_params(list(sampling_params_list))
sampling_params_list = self.resolve_sampling_params_list(sampling_params_list)
# Track per-request metrics
@@ -228,20 +294,27 @@ class AsyncOmni(EngineClient, OmniBase):
req_state.metrics = metrics
self.request_states[request_id] = req_state
+ # PD disaggregation: modify prefill-stage sampling params per request
+ req_sp_list = list(sampling_params_list)
+ pd_pair = self._get_pd_separation_pair()
+ if pd_pair is not None:
+ p_id = pd_pair[0]
+ req_sp_list[p_id] = self._prepare_prefill_sampling_params(request_id, req_sp_list[p_id])
+
# Add request(s) to stage 0. For streaming inputs, submit
# chunks incrementally through streaming_update.
if isinstance(prompt, AsyncGenerator):
input_stream_task = await self._add_streaming_input_request(
request_id=request_id,
input_stream=prompt,
- sampling_params_list=sampling_params_list,
+ sampling_params_list=req_sp_list,
final_stage_id=final_stage_id_for_e2e,
)
else:
await self.engine.add_request_async(
request_id=request_id,
prompt=prompt,
- sampling_params_list=sampling_params_list,
+ sampling_params_list=req_sp_list,
final_stage_id=final_stage_id_for_e2e,
)
submit_ts = time.time()
@@ -296,7 +369,6 @@ class AsyncOmni(EngineClient, OmniBase):
if not stage0_params.skip_clone:
stage0_params = stage0_params.clone()
stage0_params.skip_clone = True
- stage0_params.output_kind = RequestOutputKind.DELTA
has_submitted_first_chunk = False
@@ -421,6 +493,9 @@ class AsyncOmni(EngineClient, OmniBase):
stage_id = result.get("stage_id", 0)
+ if result.get("type") == "error" and result.get("fatal"):
+ raise EngineDeadError(result.get("error", ""))
+
# Check for errors
if "error" in result:
logger.error(
@@ -431,6 +506,8 @@ class AsyncOmni(EngineClient, OmniBase):
)
raise RuntimeError(result)
+ self._check_engine_output_error(result, request_id, stage_id)
+
# Process the result (constructs OmniRequestOutput)
output_to_yield = self._process_single_result(
result,
@@ -476,6 +553,22 @@ class AsyncOmni(EngineClient, OmniBase):
await asyncio.sleep(_FINAL_OUTPUT_IDLE_SLEEP_S)
continue
+ if isinstance(msg, dict) and msg.get("type") == "ack":
+ ack_data = msg.get("ack")
+ tid = getattr(ack_data, "task_id", "unknown")
+ logger.info(f"[{self._name}] Intercepted wrapped ACK for task {tid}")
+ await self.event_resolver.resolve(ack_data)
+ continue
+ if isinstance(msg, OmniACK):
+ logger.info(f"[{self._name}] Intercepted raw ACK object: {msg.task_id}")
+ await self.event_resolver.resolve(msg)
+ continue
+ if hasattr(msg, "task_id"):
+ tid = getattr(msg, "task_id")
+ logger.info(f"[{self._name}] Intercepted task-ID object: {tid}")
+ await self.event_resolver.resolve(msg)
+ continue
+
should_continue, _, stage_id, req_state = self._handle_output_message(msg)
if should_continue:
continue
@@ -487,6 +580,16 @@ class AsyncOmni(EngineClient, OmniBase):
except asyncio.CancelledError:
raise
+ except EngineDeadError as e:
+ logger.error("[AsyncOmni] Engine dead: %s", e)
+ for req_state in list(self.request_states.values()):
+ error_msg = {
+ "type": "error",
+ "error": str(e),
+ "fatal": True,
+ "request_id": req_state.request_id,
+ }
+ await req_state.queue.put(error_msg)
except Exception as e:
logger.exception("[AsyncOmni] final_output_loop failed.")
for req_state in list(self.request_states.values()):
@@ -642,21 +745,68 @@ class AsyncOmni(EngineClient, OmniBase):
logger.warning("[AsyncOmni] reset_prefix_cache not yet supported with Orchestrator process")
return True
- async def sleep(self, level: int = 1, mode: PauseMode = "abort") -> None:
- """Sleep all stages.
+ async def sleep(
+ self, stage_ids: list[int] | None = None, level: int = 2, mode: PauseMode = "abort"
+ ) -> list[OmniACK]:
+ self._final_output_handler()
+ if stage_ids is None:
+ stage_ids = list(range(len(self.engine.stage_clients)))
+ total_workers = 0
+ for sid in stage_ids:
+ client = self.engine.stage_clients[sid]
+ # During the Diffusion phase, regardless of the TP amount,
+ # currently only a summary ACK is reported at Rank 0.
+ if getattr(client, "stage_type", "") == "diffusion":
+ total_workers += 1
+ else:
+ config = self.engine.stage_vllm_configs[sid]
+ actual_tp = config.parallel_config.tensor_parallel_size if config else 1
+ total_workers += actual_tp
- Best-effort: unsupported stages will emit a TODO result.
- """
+ task_id = str(uuid.uuid4())
+ self.event_resolver.watch_task(task_id, expected_count=total_workers)
+ logger.info(f"[{self._name}] Sleep initiated (Task: {task_id}). Awaiting {total_workers} ACKs...")
+ task = OmniSleepTask(level=level, task_id=task_id)
+ rpc_results = await self.collective_rpc(method="handle_sleep_task", args=(task,), stage_ids=stage_ids)
+ final_acks = []
+ for stage_res in rpc_results:
+ worker_acks = stage_res if isinstance(stage_res, list) else [stage_res]
+ for ack in worker_acks:
+ if ack is not None:
+ await self.event_resolver.resolve(ack)
+ final_acks.append(ack)
self._is_sleeping = True
- await self.collective_rpc(method="sleep", args=(level,))
+ return final_acks
- async def wake_up(self, tags: list[str] | None = None) -> None:
- """Wake up all stages.
-
- Best-effort: unsupported stages will emit a TODO result.
- """
+ async def wake_up(self, stage_ids: list[int] | None = None, tags: list[str] | None = None) -> list[OmniACK]:
+ self._final_output_handler()
+ if stage_ids is None:
+ stage_ids = list(range(len(self.engine.stage_clients)))
+ total_workers = 0
+ for sid in stage_ids:
+ client = self.engine.stage_clients[sid]
+ if getattr(client, "stage_type", "") == "diffusion":
+ total_workers += 1
+ else:
+ config = self.engine.stage_vllm_configs[sid]
+ total_workers += config.parallel_config.tensor_parallel_size if config else 1
+ task_id = str(uuid.uuid4())
+ self.event_resolver.watch_task(task_id, expected_count=total_workers)
+ logger.info(f"[{self._name}] Wake-up initiated (Task: {task_id}). Awaiting {total_workers} ACKs...")
+ task = OmniWakeTask(tags=tags, task_id=task_id)
+ rpc_results = await self.collective_rpc(method="handle_wake_task", args=(task,), stage_ids=stage_ids)
+ final_acks = []
+ for stage_res in rpc_results:
+ worker_acks = stage_res if isinstance(stage_res, list) else [stage_res]
+ for ack in worker_acks:
+ if ack is not None:
+ await self.event_resolver.resolve(ack)
+ final_acks.append(ack)
+ current_omni_platform.synchronize()
+ await asyncio.sleep(0.1)
self._is_sleeping = False
- await self.collective_rpc(method="wake_up", args=(tags,))
+ logger.info(f"[{self._name}] All {len(final_acks)}/{total_workers} workers reported WARM for task {task_id}.")
+ return final_acks
async def is_sleeping(self) -> bool:
"""Return whether all stages are sleeping.
@@ -706,12 +856,25 @@ class AsyncOmni(EngineClient, OmniBase):
@property
def is_running(self) -> bool:
"""Check if the engine is running."""
- return self.final_output_task is not None and not self.final_output_task.done()
+ orchestrator_alive = self.engine.is_alive()
+ task_alive = self.final_output_task is not None and not self.final_output_task.done()
+ return orchestrator_alive and task_alive
@property
def errored(self) -> bool:
- """Whether orchestrator thread has stopped unexpectedly."""
- return not self.engine.is_alive()
+ """Whether the engine is in a non-recoverable error state.
+
+ Delegates to ``OmniBase.errored`` which checks the orchestrator
+ thread and all stage clients. Redeclared here to satisfy the
+ ``EngineClient`` abstract-property requirement (Python's ABC
+ mechanism does not resolve abstract methods from sibling MRO
+ entries).
+ """
+ return OmniBase.errored.fget(self) # type: ignore[union-attr]
+
+ @property
+ def _name(self) -> str:
+ return "AsyncOrchestrator"
@property
def is_stopped(self) -> bool:
diff --git a/vllm_omni/entrypoints/cfg_companion_tracker.py b/vllm_omni/entrypoints/cfg_companion_tracker.py
deleted file mode 100644
index 9c2e835f0..000000000
--- a/vllm_omni/entrypoints/cfg_companion_tracker.py
+++ /dev/null
@@ -1,233 +0,0 @@
-"""CFG companion request tracker for the Omni orchestrator.
-
-Encapsulates all bookkeeping for Classifier-Free Guidance companion
-requests (prompt expansion, parent/companion ID mapping, completion
-tracking, deferred forwarding, failure propagation, and timeouts)
-so that ``Omni._run_generation`` stays clean.
-"""
-
-from __future__ import annotations
-
-import copy
-import logging
-import os
-import time
-from collections.abc import Callable, Sequence
-from typing import Any
-
-from vllm_omni.distributed.omni_connectors.adapter import try_send_via_connector
-from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniSamplingParams
-
-logger = logging.getLogger(__name__)
-
-
-class CfgCompanionTracker:
- """Manages CFG companion request lifecycle in the orchestrator scheduling loop."""
-
- def __init__(
- self,
- prompt_expand_func: Callable[..., Any] | None,
- stage0_sampling_params: Any,
- timeout_s: float | None = None,
- ) -> None:
- self._expand_func = prompt_expand_func
- self._sp0 = stage0_sampling_params
- self._timeout_s = (
- timeout_s if timeout_s is not None else float(os.environ.get("VLLM_CFG_PENDING_TIMEOUT_S", "120"))
- )
-
- self._companion_map: dict[str, dict[str, str]] = {} # parent -> {role: companion_id}
- self._companion_ids: set[str] = set()
- self._companion_to_parent: dict[str, str] = {} # companion -> parent
- self._done: dict[str, set[str]] = {} # parent -> completed companion ids
- self._pending_parents: dict[str, dict[str, Any]] = {} # parent -> deferred result
- self._failed_parents: set[str] = set()
-
- @property
- def is_active(self) -> bool:
- return bool(self._companion_ids)
-
- @property
- def num_companions(self) -> int:
- return len(self._companion_ids)
-
- @property
- def stage0_sampling_params(self) -> Any:
- return self._sp0
-
- def expand_prompts(
- self,
- request_id_to_prompt: dict[str, Any],
- ) -> list[tuple[str, Any]]:
- """Expand user prompts into ``(companion_id, prompt)`` pairs via model-specific func."""
- if not self._expand_func:
- return []
-
- pairs: list[tuple[str, Any]] = []
- for rid, prompt in request_id_to_prompt.items():
- expanded = self._expand_func(prompt, self._sp0)
- if not expanded:
- continue
- role_map: dict[str, str] = {}
- for ep in expanded:
- cid = f"{rid}{ep.request_id_suffix}"
- role_map[ep.role] = cid
- self._companion_ids.add(cid)
- self._companion_to_parent[cid] = rid
- pairs.append((cid, ep.prompt))
- self._companion_map[rid] = role_map
- self._done[rid] = set()
-
- logger.debug(
- "CFG expansion: %d parent(s) -> %d companion(s)",
- len(self._companion_map),
- len(self._companion_ids),
- )
- return pairs
-
- def is_companion(self, req_id: str) -> bool:
- return req_id in self._companion_ids
-
- def has_companions(self, parent_id: str) -> bool:
- return parent_id in self._companion_map
-
- def all_companions_done(self, parent_id: str) -> bool:
- role_map = self._companion_map.get(parent_id, {})
- done_set = self._done.get(parent_id, set())
- return all(cid in done_set for cid in role_map.values())
-
- def get_companion_request_ids(self, parent_id: str) -> dict[str, str]:
- """Return ``{role: companion_request_id}`` for a parent."""
- return self._companion_map.get(parent_id, {})
-
- def is_parent_failed(self, parent_id: str) -> bool:
- return parent_id in self._failed_parents
-
- # -- Lifecycle events --
-
- def on_companion_error(self, companion_id: str) -> tuple[str | None, bool]:
- """Record failure. Returns ``(parent_id, parent_was_aborted)``."""
- parent_id = self._companion_to_parent.get(companion_id)
- if parent_id is None:
- return None, False
- self._failed_parents.add(parent_id)
- logger.error("CFG companion %s failed; marking parent %s as failed", companion_id, parent_id)
- aborted = parent_id in self._pending_parents
- if aborted:
- self._pending_parents.pop(parent_id, None)
- return parent_id, aborted
-
- def on_companion_completed(self, companion_id: str) -> str | None:
- """Mark done. Returns parent_id only if parent is pending and all companions finished."""
- parent_id = self._companion_to_parent.get(companion_id)
- if parent_id is None:
- return None
- self._done[parent_id].add(companion_id)
- logger.debug("CFG companion %s completed (parent=%s)", companion_id, parent_id)
- if parent_id in self._pending_parents and self.all_companions_done(parent_id):
- return parent_id
- return None
-
- def consume_parent_failure(self, parent_id: str) -> None:
- self._failed_parents.discard(parent_id)
-
- # -- Deferred parent management --
-
- def defer_parent(self, parent_id: str, engine_outputs: Any, stage_id: int) -> None:
- """Hold parent result while waiting for companions to finish."""
- self._pending_parents[parent_id] = {
- "engine_outputs": engine_outputs,
- "stage_id": stage_id,
- "pending_since": time.time(),
- }
- logger.debug("Parent %s deferred, waiting for CFG companions", parent_id)
-
- def pop_pending_parent(self, parent_id: str) -> dict[str, Any] | None:
- return self._pending_parents.pop(parent_id, None)
-
- def check_timeouts(self) -> list[str]:
- """Return and remove parent IDs that exceeded the pending timeout."""
- if not self._pending_parents:
- return []
- now = time.time()
- timed_out: list[str] = []
- for pid in list(self._pending_parents):
- pending_since = self._pending_parents[pid].get("pending_since", now)
- if now - pending_since > self._timeout_s:
- self._pending_parents.pop(pid)
- self._failed_parents.discard(pid)
- timed_out.append(pid)
- logger.error("Parent %s timed out waiting for CFG companions (>%.0fs)", pid, self._timeout_s)
- return timed_out
-
- # -- Forward parent with CFG KV --
-
- def forward_parent_with_cfg(
- self,
- req_id: str,
- parent_result: dict[str, Any],
- stage_list: Sequence[Any],
- connectors: dict[tuple[str, str], Any],
- sampling_params_list: Sequence[OmniSamplingParams],
- request_id_to_prompt: dict[str, Any],
- final_stage_id_to_prompt: dict[str, int],
- metrics: Any,
- remaining_by_stage: list[int],
- ) -> bool:
- """Forward a parent request to the next stage with CFG KV request IDs attached."""
- stage_id = parent_result["stage_id"]
- next_stage_id = stage_id + 1
- if next_stage_id > final_stage_id_to_prompt.get(req_id, 0):
- return True
-
- next_stage = stage_list[next_stage_id]
- try:
- with metrics.stage_postprocess_timer(stage_id, req_id):
- next_inputs = next_stage.process_engine_inputs(
- stage_list,
- [request_id_to_prompt[req_id]],
- source_outputs_override=parent_result["engine_outputs"],
- )
- except Exception as e:
- logger.exception(
- "Process engine inputs error for req %s at stage %d: %s",
- req_id,
- next_stage_id,
- e,
- )
- return False
-
- sp_next = copy.deepcopy(sampling_params_list[next_stage_id])
- if isinstance(sp_next, OmniDiffusionSamplingParams):
- sp_next.cfg_kv_request_ids = self.get_companion_request_ids(req_id)
- logger.info(
- "Attaching cfg_kv_request_ids=%s to request %s",
- sp_next.cfg_kv_request_ids,
- req_id,
- )
-
- connector_key = (str(stage_id), str(next_stage_id))
- connector = connectors.get(connector_key)
- sent_via_connector = False
- if connector:
- sent_via_connector = try_send_via_connector(
- connector=connector,
- stage_id=stage_id,
- next_stage_id=next_stage_id,
- req_id=req_id,
- next_inputs=next_inputs,
- sampling_params=sp_next,
- original_prompt=request_id_to_prompt[req_id],
- next_stage_queue_submit_fn=stage_list[next_stage_id].submit,
- metrics=metrics,
- )
-
- if not sent_via_connector:
- raise RuntimeError(
- f"Failed to send CFG request {req_id} to stage-{next_stage_id} via connector. "
- "Configure a connector for this edge or inspect connector logs for details."
- )
-
- logger.debug("Forwarded CFG-enabled request %s to stage-%d", req_id, next_stage_id)
- remaining_by_stage[next_stage_id] += 1
- return True
diff --git a/vllm_omni/entrypoints/cli/benchmark/serve.py b/vllm_omni/entrypoints/cli/benchmark/serve.py
index 906e8851a..d281432e5 100644
--- a/vllm_omni/entrypoints/cli/benchmark/serve.py
+++ b/vllm_omni/entrypoints/cli/benchmark/serve.py
@@ -1,4 +1,5 @@
import argparse
+import os
from vllm.benchmarks.serve import add_cli_args
@@ -6,15 +7,149 @@ from vllm_omni.benchmarks.serve import main
from vllm_omni.entrypoints.cli.benchmark.base import OmniBenchmarkSubcommandBase
+def add_daily_omni_cli_args(parser: argparse.ArgumentParser) -> None:
+ """Add CLI arguments specific to Daily-Omni dataset.
+
+ This function should be called by the CLI entrypoint to add additional
+ arguments for daily-omni benchmark support.
+
+ Args:
+ parser: The ArgumentParser instance to extend
+ """
+ # Daily-Omni specific arguments
+ daily_omni_group = parser.add_argument_group("Daily-Omni Dataset Options")
+
+ daily_omni_group.add_argument(
+ "--daily-omni-qa-json",
+ type=str,
+ default=None,
+ help="Path to local upstream qa.json. When set, QA rows are read from this file and "
+ "the HuggingFace dataset is not loaded (no network). Use with --daily-omni-video-dir "
+ "for fully offline runs. --dataset-path / Hub split flags are then ignored for QA loading.",
+ )
+ daily_omni_group.add_argument(
+ "--daily-omni-video-dir",
+ type=str,
+ default=None,
+ help="Root directory of extracted Daily-Omni videos (contents of Videos.tar: "
+ "each video_id in its own subdir with {video_id}_video.mp4). "
+ "When using file URLs, you MUST start the vLLM server with "
+ "--allowed-local-media-path set to this same directory (or a parent), "
+ "otherwise requests fail with 'Cannot load local files without "
+ "--allowed-local-media-path'.",
+ )
+ daily_omni_group.add_argument(
+ "--daily-omni-inline-local-video",
+ action="store_true",
+ default=False,
+ help="For local videos only: embed MP4 as base64 data URLs in benchmark "
+ "requests so the server does not need --allowed-local-media-path. "
+ "Increases request size and client memory; use for small --num-prompts. "
+ "When using --daily-omni-input-mode audio or all, local WAV files are "
+ "embedded the same way.",
+ )
+ daily_omni_group.add_argument(
+ "--daily-omni-input-mode",
+ type=str,
+ choices=["all", "visual", "audio"],
+ default="all",
+ help="Daily-Omni input protocol (mirrors upstream Lliar-liar/Daily-Omni "
+ "--input_mode). 'visual': video only (default). 'audio': WAV only, "
+ "requires {video_id}/{video_id}_audio.wav under --daily-omni-video-dir. "
+ "'all': video + WAV together. Sets mm_processor_kwargs.use_audio_in_video=false "
+ "and matches official separate video/audio streams.",
+ )
+ daily_omni_group.add_argument(
+ "--daily-omni-save-eval-items",
+ action="store_true",
+ default=False,
+ help="Include per-request Daily-Omni accuracy rows (gold/predicted/correct) "
+ "in the saved JSON under key daily_omni_eval_items. "
+ "Alternatively set env DAILY_OMNI_SAVE_EVAL_ITEMS=1.",
+ )
+
+ # Note: --dataset-name daily-omni via get_samples patch; use either Hub (--dataset-path
+ # liarliar/Daily-Omni) or local --daily-omni-qa-json (offline).
+
+
+def add_seed_tts_cli_args(parser: argparse.ArgumentParser) -> None:
+ """CLI for Seed-TTS zero-shot TTS benchmark (``--dataset-name seed-tts``)."""
+ g = parser.add_argument_group("Seed-TTS Dataset Options")
+ g.add_argument(
+ "--seed-tts-locale",
+ type=str,
+ choices=["en", "zh"],
+ default="en",
+ help="Which Seed-TTS split to load: en/meta.lst or zh/meta.lst under the dataset root.",
+ )
+ g.add_argument(
+ "--seed-tts-root",
+ type=str,
+ default=None,
+ help="Override root directory that contains en/ and zh/ (meta.lst + prompt-wavs). "
+ "If set, --dataset-path can still name the HF repo for logging; this path is used for files.",
+ )
+ g.add_argument(
+ "--seed-tts-file-ref-audio",
+ action="store_true",
+ default=False,
+ help="Send ref_audio as file:// URIs (smaller HTTP bodies). Requires the API server "
+ "to be started with --allowed-local-media-path covering the Seed-TTS dataset root. "
+ "Default is inline data:audio/wav;base64 so Qwen3-TTS works without that flag.",
+ )
+ g.add_argument(
+ "--seed-tts-inline-ref-audio",
+ action="store_true",
+ default=False,
+ help=argparse.SUPPRESS,
+ )
+ g.add_argument(
+ "--seed-tts-system-prompt",
+ type=str,
+ default=None,
+ help="Override chat system message for --backend openai-chat-omni (Qwen3-Omni TTS). "
+ "Default follows official Qwen3-Omni identity + zero-shot voice-clone instructions.",
+ )
+ g.add_argument(
+ "--seed-tts-wer-eval",
+ action="store_true",
+ default=False,
+ help="Keep synthesized audio as 24 kHz mono PCM for WER (works with "
+ "--backend openai-audio-speech or openai-chat-omni). Scoring follows "
+ "BytedanceSpeech/seed-tts-eval (Whisper-large-v3 / Paraformer-zh + jiwer). "
+ "Sets SEED_TTS_WER_EVAL=1. Install: pip install 'vllm-omni[seed-tts-eval]'. "
+ "Optional: SEED_TTS_EVAL_DEVICE, SEED_TTS_HF_WHISPER_MODEL.",
+ )
+ g.add_argument(
+ "--seed-tts-wer-save-items",
+ action="store_true",
+ default=False,
+ help="Include per-utterance ASR rows in the saved JSON under key seed_tts_wer_eval_items. "
+ "Or set SEED_TTS_WER_SAVE_ITEMS=1.",
+ )
+
+
class OmniBenchmarkServingSubcommand(OmniBenchmarkSubcommandBase):
"""The `serve` subcommand for vllm bench."""
name = "serve"
- help = "Benchmark the online serving throughput."
+ help = "Benchmark the online serving throughput. Supports Daily-Omni and Seed-TTS datasets."
@classmethod
def add_cli_args(cls, parser: argparse.ArgumentParser) -> None:
add_cli_args(parser)
+
+ # Add Daily-Omni specific arguments
+ add_daily_omni_cli_args(parser)
+ add_seed_tts_cli_args(parser)
+
+ for action in parser._actions:
+ if action.dest == "dataset_name" and action.choices is not None:
+ extra = [c for c in ("daily-omni", "seed-tts") if c not in action.choices]
+ if extra:
+ action.choices = list(action.choices) + extra
+
+ # Update help messages for omni-specific features
for action in parser._actions:
if action.dest == "percentile_metrics":
action.help = (
@@ -48,4 +183,10 @@ class OmniBenchmarkServingSubcommand(OmniBenchmarkSubcommandBase):
@staticmethod
def cmd(args: argparse.Namespace) -> None:
+ if getattr(args, "daily_omni_save_eval_items", False):
+ os.environ["DAILY_OMNI_SAVE_EVAL_ITEMS"] = "1"
+ if getattr(args, "seed_tts_wer_eval", False):
+ os.environ["SEED_TTS_WER_EVAL"] = "1"
+ if getattr(args, "seed_tts_wer_save_items", False):
+ os.environ["SEED_TTS_WER_SAVE_ITEMS"] = "1"
main(args)
diff --git a/vllm_omni/entrypoints/cli/serve.py b/vllm_omni/entrypoints/cli/serve.py
index 6e9adc246..e355ee679 100644
--- a/vllm_omni/entrypoints/cli/serve.py
+++ b/vllm_omni/entrypoints/cli/serve.py
@@ -9,6 +9,7 @@ import argparse
import json
import os
import signal
+import sys
from types import FrameType
from typing import Any
@@ -21,6 +22,7 @@ from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni.entrypoints.cli.logo import log_logo
from vllm_omni.entrypoints.openai.api_server import omni_run_server
+from vllm_omni.entrypoints.utils import detect_explicit_cli_keys
logger = init_logger(__name__)
@@ -79,6 +81,9 @@ class OmniServeCommand(CLISubcommand):
"""The `serve` subcommand for the vLLM CLI."""
name = "serve"
+ # Parser stashed at subparser_init so ``cmd`` can resolve each user-typed
+ # flag to its real ``dest`` via the parser's action table.
+ _parser: FlexibleArgumentParser | None = None
@staticmethod
def cmd(args: argparse.Namespace) -> None:
@@ -90,6 +95,10 @@ class OmniServeCommand(CLISubcommand):
if hasattr(args, "model_tag") and args.model_tag is not None:
args.model = args.model_tag
+ # Stash the set of long-option keys the user actually typed so the
+ # stage-config factory can give YAML precedence over argparse defaults.
+ args._cli_explicit_keys = detect_explicit_cli_keys(sys.argv[1:], OmniServeCommand._parser)
+
if args.headless:
run_headless(args)
else:
@@ -130,6 +139,17 @@ class OmniServeCommand(CLISubcommand):
action="store_true",
help="Enable vLLM-Omni mode for multi-modal and diffusion models",
)
+
+ try:
+ omni_config_group.add_argument(
+ "--enable-sleep-mode",
+ action="store_true",
+ default=False,
+ help="Enable GPU memory pool for sleep mode.",
+ )
+ except argparse.ArgumentError:
+ pass
+
omni_config_group.add_argument(
"--task-type",
type=str,
@@ -138,11 +158,33 @@ class OmniServeCommand(CLISubcommand):
help="Default task type for TTS models (CustomVoice, VoiceDesign, or Base). "
"If not specified, will be inferred from model path.",
)
+ # TODO(@lishunyang12): deprecate once all models migrate to --deploy-config
omni_config_group.add_argument(
"--stage-configs-path",
type=str,
default=None,
- help="Path to the stage configs file. If not specified, the stage configs will be loaded from the model.",
+ help="[Deprecated — will be removed in a future release] Path to a legacy "
+ "stage configs YAML (stage_args format). Prefer --deploy-config for new-format deploy YAMLs.",
+ )
+ omni_config_group.add_argument(
+ "--deploy-config",
+ type=str,
+ default=None,
+ help="Path to a deploy config YAML (new format with stages/engine_args). "
+ "Mutually exclusive with --stage-configs-path.",
+ )
+ omni_config_group.add_argument(
+ "--stage-overrides",
+ type=str,
+ default=None,
+ help="Per-stage JSON overrides. Example: "
+ '\'{"0": {"gpu_memory_utilization": 0.8}, "2": {"enforce_eager": true}}\'',
+ )
+ omni_config_group.add_argument(
+ "--async-chunk",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Override the deploy YAML's ``async_chunk:`` bool. Unset leaves the YAML value in force.",
)
omni_config_group.add_argument(
"--stage-id",
@@ -406,6 +448,9 @@ class OmniServeCommand(CLISubcommand):
action="store_true",
help="Enable diffusion pipeline profiler to display stage durations.",
)
+ # Stash via type(self) so the docs hook (which execs this function in a
+ # sandboxed globals dict via ``DummySelf``) doesn't fail on a NameError.
+ type(self)._parser = serve_parser
return serve_parser
@@ -461,10 +506,15 @@ def run_headless(args: argparse.Namespace) -> None:
raise ValueError("headless mode requires worker_backend=multi_process")
args_dict = vars(args).copy()
+ # Preserve the explicit-keys set captured at parse time so per-stage yaml
+ # values (e.g. stage 1's ``gpu_memory_utilization: 0.5``) are not
+ # overwritten by argparse defaults for flags the user didn't type.
+ cli_explicit_keys = args_dict.pop("_cli_explicit_keys", None)
config_path, stage_configs = load_and_resolve_stage_configs(
model,
args_dict.get("stage_configs_path"),
args_dict,
+ cli_explicit_keys=cli_explicit_keys,
)
# Locate the stage config that matches stage_id.
diff --git a/vllm_omni/entrypoints/omni.py b/vllm_omni/entrypoints/omni.py
index a3bfe98ce..223c208af 100644
--- a/vllm_omni/entrypoints/omni.py
+++ b/vllm_omni/entrypoints/omni.py
@@ -66,6 +66,13 @@ class Omni(OmniBase):
py_generator: bool = False,
use_tqdm: bool | Callable[..., tqdm] = True,
) -> Generator[OmniRequestOutput, None, None] | list[OmniRequestOutput]:
+ # Expand sampling params for PD disaggregation (user may provide N-1 params)
+ if (
+ sampling_params_list is not None
+ and isinstance(sampling_params_list, Sequence)
+ and not isinstance(sampling_params_list, (str, bytes))
+ ):
+ sampling_params_list = self._maybe_expand_sampling_params(list(sampling_params_list))
sampling_params_list = self.resolve_sampling_params_list(sampling_params_list)
try:
if py_generator:
@@ -125,10 +132,17 @@ class Omni(OmniBase):
req_state.metrics = metrics
self.request_states[req_id] = req_state
+ # PD disaggregation: modify stage-0 (prefill) sampling params per request
+ req_sp_list = list(sampling_params_list)
+ pd_pair = self._get_pd_separation_pair()
+ if pd_pair is not None:
+ p_id = pd_pair[0]
+ req_sp_list[p_id] = self._prepare_prefill_sampling_params(req_id, req_sp_list[p_id])
+
self.engine.add_request(
request_id=req_id,
prompt=prompt,
- sampling_params_list=sampling_params_list,
+ sampling_params_list=req_sp_list,
final_stage_id=final_stage_id,
)
submit_ts = time.time()
@@ -152,6 +166,8 @@ class Omni(OmniBase):
logger.warning("[Omni] Received output for unknown/finished request_id=%s", req_id)
continue
+ self._check_engine_output_error(msg, req_id, stage_id)
+
if req_state.metrics is None:
continue
output_to_yield = self._process_single_result(
diff --git a/vllm_omni/entrypoints/omni_base.py b/vllm_omni/entrypoints/omni_base.py
index 1a7ffc4a5..3180c9c80 100644
--- a/vllm_omni/entrypoints/omni_base.py
+++ b/vllm_omni/entrypoints/omni_base.py
@@ -1,6 +1,8 @@
from __future__ import annotations
+import argparse
import os
+import sys
import time
import types
import weakref
@@ -10,11 +12,12 @@ from typing import TYPE_CHECKING, Any, Literal
import huggingface_hub
from vllm.logger import init_logger
-from vllm.v1.engine.exceptions import EngineDeadError
+from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.entrypoints.client_request_state import ClientRequestState
-from vllm_omni.entrypoints.utils import get_final_stage_id_for_e2e
+from vllm_omni.entrypoints.pd_utils import PDDisaggregationMixin
+from vllm_omni.entrypoints.utils import detect_explicit_cli_keys, get_final_stage_id_for_e2e
from vllm_omni.metrics.stats import OrchestratorAggregator as OrchestratorMetrics
from vllm_omni.model_executor.model_loader.weight_utils import download_weights_from_hf_specific
from vllm_omni.outputs import OmniRequestOutput
@@ -65,9 +68,51 @@ def omni_snapshot_download(model_id: str) -> str:
OutputMessageHandleResult = tuple[Literal[True], None, None, None] | tuple[Literal[False], str, int, ClientRequestState]
-class OmniBase:
+class OmniBase(PDDisaggregationMixin):
"""Shared runtime foundation for AsyncOmni and Omni."""
+ @classmethod
+ def from_cli_args(
+ cls,
+ args: argparse.Namespace,
+ *,
+ parser: argparse.ArgumentParser | None = None,
+ **overrides: Any,
+ ) -> OmniBase:
+ """Construct an ``Omni`` / ``AsyncOmni`` from an ``argparse.Namespace``.
+
+ Mirrors the ``EngineArgs.from_cli_args`` pattern used upstream and in
+ ``OmniEngineArgs.from_cli_args``. This is the recommended entry point
+ for any argparse-based caller (offline scripts, tests, CI): it
+ expands ``vars(args)`` into kwargs and automatically captures which
+ flags the user typed on the command line so that argparse defaults
+ do not silently override deploy YAML values.
+
+ Passing ``parser`` is strongly recommended: without it, flag-to-dest
+ resolution falls back to a name-based heuristic that misidentifies
+ flags with ``dest=`` overrides, alias flags, and ``--disable-X`` /
+ ``store_false`` pairs. See :func:`detect_explicit_cli_keys`.
+
+ Args:
+ args: Parsed argparse namespace from ``parser.parse_args()``.
+ parser: The argparse parser used to produce ``args``. When
+ provided, each user-typed flag is resolved to its real
+ ``dest`` via the parser's action table.
+ **overrides: Extra keyword arguments that take precedence over
+ attributes on ``args``.
+
+ Example::
+
+ parser = FlexibleArgumentParser()
+ parser.add_argument("--model", required=True)
+ args = parser.parse_args()
+ omni = Omni.from_cli_args(args, parser=parser) # preferred
+ omni = Omni.from_cli_args(args, parser=parser, model="other")
+ """
+ kwargs: dict[str, Any] = {**vars(args), **overrides}
+ kwargs["_cli_explicit_keys"] = detect_explicit_cli_keys(sys.argv[1:], parser)
+ return cls(**kwargs)
+
def __init__(
self,
model: str,
@@ -77,16 +122,24 @@ class OmniBase:
stage_init_timeout = kwargs.pop("stage_init_timeout", 300)
init_timeout = kwargs.pop("init_timeout", 600)
log_stats = kwargs.pop("log_stats", False)
- async_chunk = kwargs.pop("async_chunk", False)
+ # NOTE: read-only lookup — must NOT pop. Popping here drops the key
+ # before it reaches ``StageConfigFactory._create_from_registry``, so
+ # ``--no-async-chunk`` (``async_chunk=False``) silently fails to
+ # override the deploy YAML's ``async_chunk: true`` default.
+ async_chunk = kwargs.get("async_chunk")
output_modalities = kwargs.pop("output_modalities", None)
diffusion_batch_size: int = kwargs.pop("diffusion_batch_size", 1)
if "log_requests" in kwargs:
raise TypeError("`log_requests` has been removed in Omni/AsyncOmni. Use `log_stats`.")
model = omni_snapshot_download(model)
+ self.__dict__["_name"] = self.__class__.__name__
self.model = model
self.log_stats = log_stats
- self.async_chunk = async_chunk
+ # Provisional value (mirrors the CLI/caller kwarg); the engine resolves
+ # pipeline + deploy YAML + CLI precedence below and the final value is
+ # re-assigned from ``self.engine.async_chunk`` after init.
+ self.async_chunk = bool(async_chunk) if async_chunk is not None else False
self.output_modalities = output_modalities or []
self.tts_batch_max_items: int = kwargs.pop("tts_batch_max_items", 32)
@@ -104,7 +157,11 @@ class OmniBase:
self._weak_finalizer = weakref.finalize(self, _weak_shutdown_engine, self.engine)
et = time.time()
logger.info("[%s] AsyncOmniEngine initialized in %.2f seconds", self.__class__.__name__, et - st)
- self.async_chunk = bool(self.async_chunk or getattr(self.engine, "async_chunk", False))
+ # Authoritative: ``AsyncOmniEngine`` resolves (pipeline + deploy YAML +
+ # CLI overrides) through ``StageConfigFactory`` and stores the final
+ # value on ``engine.async_chunk``; mirror it here so ``--no-async-chunk``
+ # (explicit ``False``) is not fallen-back-through by ``or``.
+ self.async_chunk = bool(getattr(self.engine, "async_chunk", False))
self.request_states: dict[str, ClientRequestState] = {}
@@ -125,17 +182,49 @@ class OmniBase:
model,
)
+ # PD disaggregation state (detects if a prefill/decode stage pair is configured)
+ self._init_pd_state()
+
@property
def num_stages(self) -> int:
return self.engine.num_stages
+ @property
+ def stage_configs(self) -> list:
+ """Expose engine stage configs for PD disaggregation detection and validation."""
+ return self.engine.stage_configs
+
@property
def is_running(self) -> bool:
return self.engine.is_alive()
+ @property
+ def errored(self) -> bool:
+ """Whether the engine is in a non-recoverable error state.
+
+ True when the orchestrator thread is dead **or** any stage client
+ has been marked dead (e.g. diffusion worker OOM / process death).
+
+ Checks both ``_engine_dead`` (StageDiffusionClient) and
+ ``resources.engine_dead`` (StageEngineCoreClient / AsyncMPClient)
+ since the two client types store the flag differently.
+ """
+ if not self.engine.is_alive():
+ return True
+ for stage_client in self.engine.stage_clients:
+ if getattr(stage_client, "_engine_dead", False):
+ return True
+ resources = getattr(stage_client, "resources", None)
+ if resources is not None and getattr(resources, "engine_dead", False):
+ return True
+ return False
+
def check_health(self) -> None:
if not self.engine.is_alive():
raise EngineDeadError("Orchestrator process is not alive")
+ for stage_client in self.engine.stage_clients:
+ if hasattr(stage_client, "check_health"):
+ stage_client.check_health()
def resolve_sampling_params_list(
self,
@@ -206,7 +295,10 @@ class OmniBase:
return True, None, None, None
if msg_type == "error":
- raise RuntimeError(msg.get("error", "Orchestrator returned an error message"))
+ error_text = msg.get("error", "Orchestrator returned an error message")
+ if msg.get("fatal"):
+ raise EngineDeadError(error_text)
+ raise RuntimeError(error_text)
if msg_type != "output":
logger.warning("[%s] got unexpected msg type: %s", self.__class__.__name__, msg_type)
@@ -235,6 +327,34 @@ class OmniBase:
return False, req_id, stage_id, req_state
+ def _check_engine_output_error(
+ self,
+ result: dict[str, Any],
+ request_id: str,
+ stage_id: int,
+ ) -> None:
+ """Raise if ``engine_outputs`` carries an error field.
+
+ Raises :class:`EngineDeadError` when ``self.errored`` indicates the
+ engine is unrecoverable, otherwise raises :class:`EngineGenerateError`
+ (recoverable, single-request failure).
+ """
+ engine_outputs = result.get("engine_outputs")
+ error_text = getattr(engine_outputs, "error", None)
+ if error_text is None:
+ return
+ logger.error(
+ "[%s] Stage error for req=%s stage-%s: %s",
+ self.__class__.__name__,
+ request_id,
+ stage_id,
+ error_text,
+ )
+ # NOTE: O(n_stages) check for every error.
+ if self.errored:
+ raise EngineDeadError(error_text)
+ raise EngineGenerateError(error_text)
+
def _process_single_result(
self,
result: dict[str, Any],
diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py
index 6a65f4433..41ccbcf1d 100644
--- a/vllm_omni/entrypoints/openai/api_server.py
+++ b/vllm_omni/entrypoints/openai/api_server.py
@@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
import base64
+import dataclasses
import io
import json
import multiprocessing
@@ -15,9 +16,11 @@ from argparse import Namespace
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from http import HTTPStatus
+from numbers import Integral
from typing import Annotated, Any, Literal, cast
import httpx
+import numpy as np
import vllm.envs as envs
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, WebSocket
from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
@@ -29,7 +32,7 @@ from vllm import SamplingParams
from vllm.engine.protocol import EngineClient
from vllm.entrypoints.anthropic.serving import AnthropicServingMessages
from vllm.entrypoints.chat_utils import load_chat_template
-from vllm.entrypoints.launcher import serve_http
+from vllm.entrypoints.launcher import serve_http, terminate_if_errored
from vllm.entrypoints.logger import RequestLogger
from vllm.entrypoints.mcp.tool_server import DemoToolServer, MCPToolServer, ToolServer
from vllm.entrypoints.openai.api_server import build_app as build_openai_app
@@ -48,11 +51,11 @@ from vllm.entrypoints.openai.engine.protocol import (
ModelCard,
ModelList,
ModelPermission,
+ RequestResponseMetadata,
)
from vllm.entrypoints.openai.models.protocol import BaseModelPath
from vllm.entrypoints.openai.models.serving import OpenAIServingModels
from vllm.entrypoints.openai.orca_metrics import metrics_header
-from vllm.entrypoints.openai.realtime.connection import RealtimeConnection
from vllm.entrypoints.openai.realtime.serving import OpenAIServingRealtime
from vllm.entrypoints.openai.responses.serving import OpenAIServingResponses
from vllm.entrypoints.openai.server_utils import get_uvicorn_log_config
@@ -73,6 +76,7 @@ from vllm.entrypoints.serve.instrumentator.basic import base
from vllm.entrypoints.serve.render.serving import OpenAIServingRender
from vllm.entrypoints.serve.tokenize.serving import OpenAIServingTokenization
from vllm.entrypoints.utils import (
+ create_error_response,
load_aware_call,
process_lora_modules,
with_cancellation,
@@ -82,6 +86,7 @@ from vllm.tasks import POOLING_TASKS
from vllm.tool_parsers import ToolParserManager
from vllm.utils import random_uuid
from vllm.utils.system_utils import decorate_logs
+from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError
@@ -107,6 +112,7 @@ from vllm_omni.entrypoints.openai.protocol.videos import (
VideoListResponse,
VideoResponse,
)
+from vllm_omni.entrypoints.openai.realtime_connection import RealtimeConnection
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.entrypoints.openai.serving_speech_stream import OmniStreamingSpeechHandler
@@ -120,6 +126,7 @@ from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniSamplingParam
logger = init_logger(__name__)
router = APIRouter()
+MAX_UINT32_SEED = 2**32 - 1
profiler_router = APIRouter()
@@ -196,6 +203,50 @@ def _remove_route_from_app(app, path: str, methods: set[str] | None = None):
app.routes.remove(route)
+def _register_omni_exception_handlers(app) -> None:
+ """Override upstream vLLM exception handlers with Omni-aware versions.
+
+ The upstream ``engine_error_handler`` is designed for ``AsyncLLM`` (single
+ EngineCore process). Omni uses a multi-stage orchestrator with different
+ health semantics, so we register our own handlers that:
+
+ - Log multi-stage diagnostic info (orchestrator liveness, per-stage health)
+ when an ``EngineDeadError`` is caught.
+ - Call ``terminate_if_errored``
+ - Return an OpenAI-compatible error JSON response.
+ """
+
+ async def omni_engine_error_handler(
+ req: Request,
+ exc: EngineDeadError | EngineGenerateError,
+ ):
+ request_id = req.state.request_metadata.request_id if hasattr(req.state, "request_metadata") else None
+
+ if req.app.state.args.log_error_stack:
+ logger.exception("Engine Exception caught. Request id: %s", request_id)
+
+ engine = req.app.state.engine_client
+ if isinstance(exc, EngineDeadError):
+ # Log Omni-specific diagnostic information for dead engines.
+ orchestrator_alive = engine.engine.is_alive() if hasattr(engine, "engine") else "N/A"
+ logger.error(
+ "EngineDeadError: orchestrator_alive=%s, errored=%s, request_id=%s",
+ orchestrator_alive,
+ engine.errored,
+ request_id,
+ )
+
+ terminate_if_errored(
+ server=req.app.state.server,
+ engine=engine,
+ )
+ err = create_error_response(exc)
+ return JSONResponse(err.model_dump(), status_code=err.error.code)
+
+ app.exception_handler(EngineGenerateError)(omni_engine_error_handler)
+ app.exception_handler(EngineDeadError)(omni_engine_error_handler)
+
+
class _DiffusionServingModels:
"""Minimal OpenAIServingModels implementation for diffusion-only servers.
@@ -305,6 +356,10 @@ async def omni_run_server_worker(listen_address, sock, args, client_config=None,
_remove_route_from_app(app, "/v1/models", {"GET"}) # Remove upstream /v1/models to use omni's handler
app.include_router(router)
+ # OMNI: Override upstream exception handlers with Omni-aware versions
+ # that understand the multi-stage orchestrator lifecycle.
+ _register_omni_exception_handlers(app)
+
await omni_init_app_state(engine_client, app.state, args)
# Conditionally register profiler endpoints based on stage YAML configs
@@ -835,6 +890,7 @@ async def omni_init_app_state(
state.enable_server_load_tracking = args.enable_server_load_tracking
state.server_load_metrics = 0
+ state.sleeping_stages = set()
def Omnivideo(request: Request) -> OmniOpenAIServingVideo | None:
@@ -874,6 +930,8 @@ async def create_chat_completion(request: ChatCompletionRequest, raw_request: Re
return base_server.create_error_response(message="The model does not support Chat Completions API")
try:
generator = await handler.create_chat_completion(request, raw_request)
+ except (EngineGenerateError, EngineDeadError):
+ raise # Propagate to the global Omni exception handler
except Exception as e:
logger.exception("Chat completion failed: %s", e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=str(e)) from e
@@ -969,6 +1027,8 @@ async def create_speech(request: OpenAICreateSpeechRequest, raw_request: Request
status_code=result.error.code if result.error else 400,
)
return result
+ except (EngineGenerateError, EngineDeadError):
+ raise # Propagate to the global Omni exception handler
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=str(e)) from e
@@ -1003,6 +1063,8 @@ async def create_speech_batch(request: BatchSpeechRequest, raw_request: Request)
status_code=result.error.code if result.error else 400,
)
return JSONResponse(content=result.model_dump())
+ except (EngineGenerateError, EngineDeadError):
+ raise # Propagate to the global Omni exception handler
except ValueError as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST.value, detail=str(e)) from e
except Exception as e:
@@ -1202,6 +1264,22 @@ async def streaming_speech(websocket: WebSocket):
@router.websocket("/v1/realtime")
async def realtime_websocket(websocket: WebSocket):
"""WebSocket endpoint for OpenAI-style realtime interactions."""
+ engine_client = getattr(websocket.app.state, "engine_client", None)
+ if engine_client is not None and getattr(engine_client, "async_chunk", False):
+ await websocket.accept()
+ await websocket.send_json(
+ {
+ "type": "error",
+ "error": (
+ "The /v1/realtime API is not supported when async_chunk is enabled on the server. "
+ "Use a stage configuration with async_chunk disabled and restart the server before using "
+ "this endpoint."
+ ),
+ "code": "unsupported",
+ }
+ )
+ await websocket.close()
+ return
serving = getattr(websocket.app.state, "openai_serving_realtime", None)
if serving is None:
await websocket.accept()
@@ -1224,31 +1302,26 @@ _remove_route_from_router(router, "/health")
async def health(raw_request: Request) -> JSONResponse:
"""Health check endpoint that works for both LLM and diffusion modes.
- Returns 200 OK if the server is healthy.
- For LLM mode: delegates to engine_client health check
- For diffusion mode: checks if diffusion_engine is running
+ Returns 200 OK if the server is healthy, 503 if the engine is dead.
+ Mirrors vLLM upstream's /health which catches EngineDeadError -> 503.
"""
- # Check if we're in diffusion mode
- diffusion_engine = getattr(raw_request.app.state, "diffusion_engine", None)
- if diffusion_engine is not None:
- # Diffusion mode health check
- if hasattr(diffusion_engine, "is_running") and diffusion_engine.is_running:
- return JSONResponse(content={"status": "healthy"})
+ engine_client = getattr(raw_request.app.state, "engine_client", None) or getattr(
+ raw_request.app.state, "diffusion_engine", None
+ )
+ if engine_client is None:
return JSONResponse(
- content={"status": "unhealthy", "reason": "Diffusion engine is not running"},
+ content={"status": "unhealthy", "reason": "No engine initialized"},
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
)
- # LLM mode - delegate to engine_client
- engine_client = getattr(raw_request.app.state, "engine_client", None)
- if engine_client is not None:
+ try:
await engine_client.check_health()
return JSONResponse(content={"status": "healthy"})
-
- return JSONResponse(
- content={"status": "unhealthy", "reason": "No engine initialized"},
- status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
- )
+ except EngineDeadError:
+ return JSONResponse(
+ content={"status": "unhealthy"},
+ status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
+ )
# Remove existing models endpoint if present (from vllm imports)
@@ -1303,14 +1376,64 @@ async def generate_images(request: ImageGenerationRequest, raw_request: Request)
# Get engine client (AsyncOmni) from app state
engine_client, model_name, stage_configs = _get_engine_and_model(raw_request)
- # Validate model field (warn if mismatch, don't error)
if request.model is not None and request.model != model_name:
- logger.warning(
- f"Model mismatch: request specifies '{request.model}' but "
- f"server is running '{model_name}'. Using server model."
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST.value,
+ detail=(f"Model mismatch: request specifies '{request.model}' but server is running '{model_name}'."),
)
try:
+ # Unify request construction for any multi-stage pipeline to avoid
+ # divergence between /v1/images and /v1/chat/completions.
+ if len(stage_configs) > 1:
+ chat_handler = getattr(raw_request.app.state, "openai_serving_chat", None)
+ if chat_handler is None:
+ logger.warning("openai_serving_chat is not initialized for multi-stage /v1/images/generations")
+ raise HTTPException(
+ status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
+ detail="openai_serving_chat is not initialized for multi-stage image generation.",
+ )
+
+ effective_seed = request.seed if request.seed is not None else random.randint(0, MAX_UINT32_SEED)
+ extra_body: dict[str, Any] = {
+ "seed": effective_seed,
+ "num_outputs_per_prompt": request.n,
+ }
+ if request.size is not None:
+ parse_size(request.size)
+ width, height = parse_size(request.size)
+ app_state_args = getattr(raw_request.app.state, "args", None)
+ _check_max_generated_image_size(app_state_args, width, height)
+ extra_body["size"] = request.size
+ if request.negative_prompt is not None:
+ extra_body["negative_prompt"] = request.negative_prompt
+ if request.num_inference_steps is not None:
+ extra_body["num_inference_steps"] = request.num_inference_steps
+ if request.guidance_scale is not None:
+ extra_body["guidance_scale"] = request.guidance_scale
+ if request.true_cfg_scale is not None:
+ extra_body["true_cfg_scale"] = request.true_cfg_scale
+ if request.generator_device is not None:
+ extra_body["generator_device"] = request.generator_device
+ if request.lora is not None:
+ # Keep /images validation semantics: invalid LoRA should fail with 400.
+ _parse_lora_request(request.lora)
+ extra_body["lora"] = request.lora
+
+ generation_result = await chat_handler.generate_diffusion_images(
+ prompt=request.prompt,
+ extra_body=extra_body,
+ request_id=f"img_gen-{random_uuid()}",
+ )
+ if isinstance(generation_result, ErrorResponse):
+ return JSONResponse(
+ status_code=generation_result.error.code if generation_result.error else 400,
+ content=generation_result.model_dump(),
+ )
+ flat_images, _, _ = generation_result
+ image_data = [ImageData(b64_json=encode_image_base64(img), revised_prompt=None) for img in flat_images]
+ return ImageGenerationResponse(created=int(time.time()), data=image_data)
+
# Build params - pass through user values directly
prompt: OmniTextPrompt = {"prompt": request.prompt}
if request.negative_prompt is not None:
@@ -1336,6 +1459,16 @@ async def generate_images(request: ImageGenerationRequest, raw_request: Request)
else:
size_str = "model default"
+ # Keep AR stage target grid in sync with requested output size.
+ # GLM-Image consumes target_h/target_w via mm_processor_kwargs.
+ if width is not None and height is not None:
+ prompt["mm_processor_kwargs"] = {
+ "target_h": height,
+ "target_w": width,
+ }
+ # Backward-compatible fallback for processors reading top-level fields.
+ prompt["height"] = height
+ prompt["width"] = width
app_state_args = getattr(raw_request.app.state, "args", None)
_check_max_generated_image_size(app_state_args, width, height)
@@ -1351,12 +1484,13 @@ async def generate_images(request: ImageGenerationRequest, raw_request: Request)
# This fixes issues where using the default global generator
# might produce blurry images in some environments.
_update_if_not_none(
- gen_params, "seed", request.seed if request.seed is not None else random.randint(0, 2**32 - 1)
+ gen_params, "seed", request.seed if request.seed is not None else random.randint(0, MAX_UINT32_SEED)
)
_update_if_not_none(gen_params, "generator_device", request.generator_device)
_update_if_not_none(gen_params, "layers", request.layers)
request_id = f"img_gen-{random_uuid()}"
+ raw_request.state.request_metadata = RequestResponseMetadata(request_id=request_id)
logger.info(f"Generating {request.n} image(s) {size_str}")
@@ -1388,8 +1522,9 @@ async def generate_images(request: ImageGenerationRequest, raw_request: Request)
data=image_data,
)
+ except (EngineGenerateError, EngineDeadError):
+ raise # Propagate to the global Omni exception handler
except HTTPException:
- # Re-raise HTTPExceptions as-is
raise
except ValueError as e:
logger.error(f"Validation error: {e}")
@@ -1425,6 +1560,9 @@ async def edit_images(
background: str | None = Form("auto"),
output_compression: Annotated[int, Form(ge=0, le=100)] = 100,
user: str | None = Form(None), # unused now
+ # vllm-omni extensions for image editing
+ mask_image: str | UploadFile | None = None,
+ reference_image: str | UploadFile | None = None,
# vllm-omni extensions for diffusion control
negative_prompt: str | None = Form(None),
num_inference_steps: int | None = Form(None),
@@ -1445,8 +1583,9 @@ async def edit_images(
# 1. get engine and model
engine_client, model_name, stage_configs = _get_engine_and_model(raw_request)
if model is not None and model != model_name:
- logger.warning(
- f"Model mismatch: request specifies '{model}' but server is running '{model_name}'. Using server model."
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST.value,
+ detail=(f"Model mismatch: request specifies '{model}' but server is running '{model_name}'."),
)
# 2. get output format & compression
output_format = _choose_output_format(output_format, background)
@@ -1469,15 +1608,35 @@ async def edit_images(
input_images_list.extend(urls)
if not input_images_list:
raise HTTPException(status_code=422, detail="Field 'image' or 'url' is required")
- pil_images = await _load_input_images(input_images_list)
- if len(pil_images) > 1 and not _supports_multimodal_image_inputs(raw_request, engine_client):
+ # Reject oversized multi-image edit requests before fetching or decoding
+ # any inputs. This keeps over-limit URL requests from burning network,
+ # CPU, and memory on work that will be rejected anyway.
+ max_input_images = _get_max_edit_input_images(raw_request, engine_client)
+ if max_input_images is not None and len(input_images_list) > max_input_images:
+ detail = (
+ "Received multiple input images. Only a single image is supported by this model."
+ if max_input_images == 1
+ else (
+ f"Received {len(input_images_list)} input images. "
+ f"At most {max_input_images} images are supported by this model."
+ )
+ )
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
- detail="Received multiple input images. Only a single image is supported by this model.",
+ detail=detail,
)
+ pil_images = await _load_input_images(input_images_list)
prompt["multi_modal_data"] = {}
prompt["multi_modal_data"]["image"] = pil_images
+ if mask_image is not None:
+ loaded = await _load_input_images([mask_image])
+ prompt["multi_modal_data"]["mask_image"] = loaded[0]
+
+ if reference_image is not None:
+ loaded = await _load_input_images([reference_image])
+ prompt["multi_modal_data"]["reference_image"] = loaded[0]
+
# 3 Build sample params
gen_params = OmniDiffusionSamplingParams()
# 3.0 Init with system default values
@@ -1537,6 +1696,18 @@ async def edit_images(
_check_max_generated_image_size(app_state_args, width, height, resolution)
size_str = f"{width}x{height}" if width is not None and height is not None else "auto"
+
+ # Keep AR stage target grid in sync with requested output size.
+ # GLM-Image consumes target_h/target_w via mm_processor_kwargs.
+ if width is not None and height is not None:
+ prompt["mm_processor_kwargs"] = {
+ "target_h": height,
+ "target_w": width,
+ }
+ # Backward-compatible fallback for processors reading top-level fields.
+ prompt["height"] = height
+ prompt["width"] = width
+
_update_if_not_none(gen_params, "width", width)
_update_if_not_none(gen_params, "height", height)
@@ -1549,13 +1720,14 @@ async def edit_images(
# a proper generator is initialized in the backend.
# This fixes issues where using the default global generator
# might produce blurry images in some environments.
- _update_if_not_none(gen_params, "seed", seed if seed is not None else random.randint(0, 2**32 - 1))
+ _update_if_not_none(gen_params, "seed", seed if seed is not None else random.randint(0, MAX_UINT32_SEED))
_update_if_not_none(gen_params, "generator_device", generator_device)
_update_if_not_none(gen_params, "layers", layers)
_update_if_not_none(gen_params, "resolution", resolution)
# 4. Generate images using AsyncOmni (multi-stage mode)
request_id = f"img_edit-{random_uuid()}"
+ raw_request.state.request_metadata = RequestResponseMetadata(request_id=request_id)
logger.info(f"Generating {n} image(s) {size_str}")
result = await _generate_with_async_omni(
engine_client=engine_client,
@@ -1587,8 +1759,9 @@ async def edit_images(
size=size_str,
)
+ except (EngineGenerateError, EngineDeadError):
+ raise # Propagate to the global Omni exception handler
except HTTPException:
- # Re-raise HTTPExceptions as-is
raise
except ValueError as e:
logger.error(f"Validation error: {e}")
@@ -1639,18 +1812,39 @@ def _get_engine_and_model(raw_request: Request):
return engine_client, model_name, normalized_stage_configs
-def _supports_multimodal_image_inputs(raw_request: Request, engine_client: Any) -> bool:
+def _get_diffusion_od_config(raw_request: Request, engine_client: Any) -> Any:
diffusion_engine = getattr(raw_request.app.state, "diffusion_engine", None) or engine_client
get_diffusion_od_config = getattr(diffusion_engine, "get_diffusion_od_config", None)
- od_config = (
+ return (
get_diffusion_od_config() if callable(get_diffusion_od_config) else getattr(diffusion_engine, "od_config", None)
)
+
+def _get_max_edit_input_images(raw_request: Request, engine_client: Any) -> int | None:
+ od_config = _get_diffusion_od_config(raw_request, engine_client)
if od_config is None:
# Preserve the existing compatibility behavior when the diffusion
# config is not exposed on the serving surface.
- return True
- return bool(getattr(od_config, "supports_multimodal_inputs", False))
+ return None
+
+ supports_multimodal_inputs = getattr(od_config, "supports_multimodal_inputs", None)
+ if not isinstance(supports_multimodal_inputs, bool):
+ # Older serving surfaces and mocked engines may expose a placeholder
+ # object instead of a real diffusion config. Treat that as "unknown"
+ # so existing single-image flows keep working.
+ return None
+
+ if not supports_multimodal_inputs:
+ return 1
+
+ max_input_images = getattr(od_config, "max_multimodal_image_inputs", None)
+ if max_input_images is None:
+ return None
+ if isinstance(max_input_images, bool) or not isinstance(max_input_images, Integral):
+ return None
+ if max_input_images < 1:
+ return None
+ return int(max_input_images)
def _get_lora_from_json_str(lora_body):
@@ -1767,6 +1961,34 @@ def _update_if_not_none(object: Any, key: str, val: Any) -> None:
setattr(object, key, val)
+def _normalize_image(image: Any) -> Any:
+ """Normalize a single image output to a PIL-compatible format."""
+ if isinstance(image, Image.Image):
+ return image
+ if not isinstance(image, np.ndarray):
+ raise ValueError(f"Unsupported image type: {type(image)}")
+ if not np.issubdtype(image.dtype, np.integer) and not np.issubdtype(image.dtype, np.floating):
+ raise ValueError(f"Unsupported dtype: {image.dtype}")
+ if isinstance(image, np.ndarray):
+ while image.ndim > 3:
+ image = image[0]
+ if image.min() < 0:
+ if image.min() < -1.01 or image.max() > 1.01:
+ logger.warning(
+ f"Image float range [{image.min():.2f}, {image.max():.2f}] outside expected [-1, 1]. "
+ f"Clipping to [-1, 1] before normalization."
+ )
+ image = np.clip(image, -1.0, 1.0) * 0.5 + 0.5
+ elif image.max() > 1.01:
+ logger.warning(
+ f"Image float range [{image.min():.2f}, {image.max():.2f}] outside expected [0, 1]. "
+ f"Clipping to [0, 1] before normalization."
+ )
+ image = (np.clip(image, 0.0, 1.0) * 255).astype(np.uint8)
+ image = Image.fromarray(image)
+ return image
+
+
def _extract_images_from_result(result: Any) -> list[Any]:
images = []
if hasattr(result, "images") and result.images:
@@ -1777,6 +1999,10 @@ def _extract_images_from_result(result: Any) -> list[Any]:
images = request_output["images"]
elif hasattr(request_output, "images") and request_output.images:
images = request_output.images
+ # Handle when generate more than one image
+ if images and isinstance(images[0], np.ndarray) and images[0].shape[0] > 1 and images[0].ndim == 5:
+ # Unwrap batch: (N, T, H, W, C) -> [img1, img2, ...]
+ images = list(images[0])
# Flatten nested lists (e.g., from layered models like Qwen-Image-Layered).
# Note: This only flattens one level deep. Deeper nesting is not supported.
flattened = []
@@ -1785,7 +2011,7 @@ def _extract_images_from_result(result: Any) -> list[Any]:
flattened.extend(img)
else:
flattened.append(img)
- return flattened
+ return [_normalize_image(img) for img in flattened]
async def _load_input_images(
@@ -1955,6 +2181,48 @@ def video_response_from_request(model_name: str, req: VideoGenerationRequest) ->
return resp
+def _status_code_for_video_failure(error: VideoError | None) -> int:
+ if error is None:
+ return HTTPStatus.INTERNAL_SERVER_ERROR.value
+
+ if isinstance(error.code, int):
+ if 400 <= error.code < 600:
+ return error.code
+ return HTTPStatus.INTERNAL_SERVER_ERROR.value
+
+ if error.code == "HTTPException":
+ status_text, _, _ = error.message.partition(":")
+ try:
+ status_code = int(status_text)
+ except ValueError:
+ return HTTPStatus.INTERNAL_SERVER_ERROR.value
+ if 400 <= status_code < 600:
+ return status_code
+ return HTTPStatus.INTERNAL_SERVER_ERROR.value
+
+ if error.code == "EngineDeadError":
+ return HTTPStatus.INTERNAL_SERVER_ERROR.value
+ if error.code == "EngineGenerateError":
+ return HTTPStatus.INTERNAL_SERVER_ERROR.value
+
+ return HTTPStatus.INTERNAL_SERVER_ERROR.value
+
+
+def _video_error_from_exception(exc: Exception) -> VideoError:
+ if isinstance(exc, HTTPException):
+ message = str(exc.detail) if exc.detail else str(exc)
+ return VideoError(code=exc.status_code, message=message)
+
+ if isinstance(exc, (EngineGenerateError, EngineDeadError)):
+ err = create_error_response(exc)
+ return VideoError(code=err.error.code, message=err.error.message)
+
+ return VideoError(
+ code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
+ message=str(exc),
+ )
+
+
def _cleanup_video(video_id: str, output_path: str | None):
try:
if output_path is not None:
@@ -1968,6 +2236,7 @@ async def _run_video_generation_job(
request: VideoGenerationRequest,
video_id: str,
reference_image: ReferenceImage | None = None,
+ app_state: Any | None = None,
) -> None:
job = await VIDEO_STORE.get(video_id)
if job is None:
@@ -1998,17 +2267,36 @@ async def _run_video_generation_job(
"peak_memory_mb": peak_memory_mb,
},
)
- except Exception as exc:
- logger.exception("Video generation failed for id=%s", video_id)
+ except (EngineGenerateError, EngineDeadError) as exc:
+ logger.exception("Video generation failed (engine error) for id=%s", video_id)
_cleanup_video(video_id, output_path)
- # TODO: It would be better to have a finite collection of errors to return rather than the exception name
await VIDEO_STORE.update_fields(
video_id,
{
"status": VideoGenerationStatus.FAILED,
"completed_at": int(time.time()),
- "error": VideoError(code=type(exc).__name__, message=str(exc)),
+ "error": _video_error_from_exception(exc),
+ "inference_time_s": time.perf_counter() - started_at,
+ },
+ )
+ # Background tasks can't propagate exceptions to FastAPI handlers.
+ # Actively signal shutdown when the engine is dead.
+ if app_state is not None and isinstance(exc, EngineDeadError):
+ terminate_if_errored(
+ server=app_state.server,
+ engine=app_state.engine_client,
+ )
+ except Exception as exc:
+ logger.exception("Video generation failed for id=%s", video_id)
+
+ _cleanup_video(video_id, output_path)
+ await VIDEO_STORE.update_fields(
+ video_id,
+ {
+ "status": VideoGenerationStatus.FAILED,
+ "completed_at": int(time.time()),
+ "error": _video_error_from_exception(exc),
"inference_time_s": time.perf_counter() - started_at,
},
)
@@ -2042,6 +2330,10 @@ async def _parse_video_form(
true_cfg_scale: float | None = Form(default=None),
seed: int | None = Form(default=None),
negative_prompt: str | None = Form(default=None),
+ enable_frame_interpolation: bool = Form(default=False),
+ frame_interpolation_exp: int = Form(default=1, ge=1),
+ frame_interpolation_scale: float = Form(default=1.0, gt=0.0),
+ frame_interpolation_model_path: str | None = Form(default=None),
lora: str | None = Form(default=None),
extra_params: str | None = Form(default=None),
) -> tuple[VideoGenerationRequest, "OmniOpenAIServingVideo", str, ReferenceImage | None]:
@@ -2078,6 +2370,10 @@ async def _parse_video_form(
"true_cfg_scale": true_cfg_scale,
"seed": seed,
"negative_prompt": negative_prompt,
+ "enable_frame_interpolation": enable_frame_interpolation,
+ "frame_interpolation_exp": frame_interpolation_exp,
+ "frame_interpolation_scale": frame_interpolation_scale,
+ "frame_interpolation_model_path": frame_interpolation_model_path,
"lora": _parse_form_json(lora, expected_type=dict),
"extra_params": _parse_form_json(extra_params, expected_type=dict),
}
@@ -2095,10 +2391,12 @@ async def _parse_video_form(
app_model_name, app_stage_configs = _resolve_video_runtime_context(raw_request)
effective_model_name = handler.model_name or app_model_name or request.model or "unknown"
if request.model is not None and effective_model_name is not None and request.model != effective_model_name:
- logger.warning(
- "Model mismatch: request specifies '%s' but server is running '%s'. Using server model.",
- request.model,
- effective_model_name,
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST.value,
+ detail=(
+ f"Model mismatch: request specifies '{request.model}' but server is running "
+ f"'{effective_model_name}'."
+ ),
)
handler.set_stage_configs_if_missing(app_stage_configs)
except HTTPException:
@@ -2129,6 +2427,7 @@ async def _parse_video_form(
},
)
async def create_video(
+ raw_request: Request,
ctx: tuple[VideoGenerationRequest, OmniOpenAIServingVideo, str, ReferenceImage | None] = Depends(_parse_video_form),
) -> VideoResponse:
"""Create an asynchronous video generation job.
@@ -2139,7 +2438,9 @@ async def create_video(
request, handler, effective_model_name, reference_image = ctx
ref = video_response_from_request(effective_model_name, request)
await VIDEO_STORE.upsert(ref.id, ref)
- task = asyncio.create_task(_run_video_generation_job(handler, request, ref.id, reference_image))
+ task = asyncio.create_task(
+ _run_video_generation_job(handler, request, ref.id, reference_image, app_state=raw_request.app.state)
+ )
await VIDEO_TASKS.upsert(ref.id, task)
return ref
@@ -2154,6 +2455,7 @@ async def create_video(
},
)
async def create_video_sync(
+ raw_request: Request,
ctx: tuple[VideoGenerationRequest, OmniOpenAIServingVideo, str, ReferenceImage | None] = Depends(_parse_video_form),
) -> Response:
"""Synchronous video generation endpoint.
@@ -2167,6 +2469,7 @@ async def create_video_sync(
"""
request, handler, effective_model_name, reference_image = ctx
request_id = f"video_sync-{random_uuid()}"
+ raw_request.state.request_metadata = RequestResponseMetadata(request_id=request_id)
started_at = time.perf_counter()
try:
video_bytes, stage_durations, peak_memory_mb = await asyncio.wait_for(
@@ -2178,6 +2481,8 @@ async def create_video_sync(
status_code=HTTPStatus.GATEWAY_TIMEOUT.value,
detail=f"Video generation timed out after {VIDEO_SYNC_TIMEOUT_S}s.",
)
+ except (EngineGenerateError, EngineDeadError):
+ raise # Propagate to the global Omni exception handler
except HTTPException:
raise
except Exception as exc:
@@ -2238,8 +2543,8 @@ async def list_videos(
return VideoListResponse(data=jobs, has_more=has_more, first_id=first_id, last_id=last_id)
-@router.get("/v1/videos/{video_id}")
-async def retrieve_video(video_id: str) -> VideoResponse:
+@router.get("/v1/videos/{video_id}", response_model=None)
+async def retrieve_video(video_id: str) -> VideoResponse | JSONResponse:
"""Retrieve metadata for a previously created video job.
Args:
@@ -2254,6 +2559,15 @@ async def retrieve_video(video_id: str) -> VideoResponse:
job = await VIDEO_STORE.get(video_id)
if job is None:
raise HTTPException(status_code=404, detail="Video not found")
+ if job.status == VideoGenerationStatus.FAILED:
+ status_code = _status_code_for_video_failure(job.error)
+ content = job.model_dump(mode="json")
+ if content.get("error") is not None:
+ content["error"]["code"] = status_code
+ return JSONResponse(
+ content=content,
+ status_code=status_code,
+ )
return job
@@ -2390,3 +2704,64 @@ async def stop_profile(raw_request: Request, request: ProfileRequest | None = No
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Failed to stop profiler: {str(e)}"
)
+
+
+class OmniSleepRequest(BaseModel):
+ stage_ids: list[int]
+ level: int = 2
+
+
+class OmniWakeupRequest(BaseModel):
+ stage_ids: list[int]
+
+
+@router.post("/v1/omni/sleep")
+async def omni_sleep(request: OmniSleepRequest, raw_request: Request):
+ engine_client = raw_request.app.state.engine_client
+ sleeping_set = raw_request.app.state.sleeping_stages
+ if not hasattr(engine_client, "sleep"):
+ raise HTTPException(status_code=501, detail="Engine does not support sleep")
+ acks = await engine_client.sleep(stage_ids=request.stage_ids, level=request.level)
+ for sid in request.stage_ids:
+ sleeping_set.add(sid)
+ return {"status": "SUCCESS", "acks": [dataclasses.asdict(a) if dataclasses.is_dataclass(a) else a for a in acks]}
+
+
+@router.post("/v1/omni/wakeup")
+async def omni_wakeup(request: OmniWakeupRequest, raw_request: Request):
+ engine_client = raw_request.app.state.engine_client
+ sleeping_set = raw_request.app.state.sleeping_stages
+ if not any(sid in sleeping_set for sid in request.stage_ids):
+ return {"status": "SKIPPED", "reason": "Target stages are not sleeping."}
+ if not hasattr(engine_client, "wake_up"):
+ raise HTTPException(status_code=501, detail="Engine does not support wake_up")
+ acks = await engine_client.wake_up(stage_ids=request.stage_ids)
+ for sid in request.stage_ids:
+ if sid in sleeping_set:
+ sleeping_set.remove(sid)
+ return {"status": "SUCCESS", "acks": [dataclasses.asdict(a) if dataclasses.is_dataclass(a) else a for a in acks]}
+
+
+if __name__ == "__main__":
+ import argparse
+ import asyncio
+
+ from vllm.entrypoints.openai.cli_args import make_arg_parser
+
+ parser = argparse.ArgumentParser(description="vLLM-Omni OpenAI-Compatible REST API server")
+ parser = make_arg_parser(parser)
+ registered_flags = set()
+ for action in parser._actions:
+ registered_flags.update(action.option_strings)
+ if "--omni" not in registered_flags:
+ parser.add_argument("--omni", action="store_true", default=False, help="Enable vLLM-Omni mode.")
+ if "--enable-sleep-mode" not in registered_flags:
+ parser.add_argument(
+ "--enable-sleep-mode", action="store_true", default=False, help="Enable GPU memory pool for sleep mode."
+ )
+ args = parser.parse_args()
+ if not hasattr(args, "model_tag"):
+ setattr(args, "model_tag", args.model)
+ if hasattr(args, "model_tag") and args.model_tag is None:
+ args.model_tag = args.model
+ asyncio.run(omni_run_server(args))
diff --git a/vllm_omni/entrypoints/openai/protocol/videos.py b/vllm_omni/entrypoints/openai/protocol/videos.py
index de5362dd9..d46c8d43d 100644
--- a/vllm_omni/entrypoints/openai/protocol/videos.py
+++ b/vllm_omni/entrypoints/openai/protocol/videos.py
@@ -150,6 +150,29 @@ class VideoGenerationRequest(BaseModel):
)
seed: int | None = Field(default=None, description="Random seed for reproducibility")
+ # vllm-omni extensions for post-generation frame interpolation.
+ enable_frame_interpolation: bool = Field(
+ default=False,
+ description="Enable post-generation RIFE frame interpolation before MP4 encoding.",
+ )
+ frame_interpolation_exp: int = Field(
+ default=1,
+ ge=1,
+ description="Interpolation exponent: 1=2x temporal resolution, 2=4x, etc.",
+ )
+ frame_interpolation_scale: float = Field(
+ default=1.0,
+ gt=0.0,
+ description="RIFE inference scale. Use 0.5 for high-resolution inputs to save memory.",
+ )
+ frame_interpolation_model_path: str | None = Field(
+ default=None,
+ description=(
+ "Local directory or Hugging Face repo ID containing RIFE flownet.pkl weights. "
+ "Defaults to elfgum/RIFE-4.22.lite."
+ ),
+ )
+
# vllm-omni extension for per-request LoRA.
lora: dict[str, Any] | None = Field(
default=None,
@@ -212,7 +235,7 @@ class VideoGenerationResponse(BaseModel):
class VideoError(BaseModel):
- code: str = Field(..., description="A machine-readable error code that was returned.")
+ code: int | str = Field(..., description="A machine-readable error code that was returned.")
message: str = Field(..., description="A human-readable description of the error that was returned.")
diff --git a/vllm_omni/entrypoints/openai/realtime_connection.py b/vllm_omni/entrypoints/openai/realtime_connection.py
new file mode 100644
index 000000000..1d5470f56
--- /dev/null
+++ b/vllm_omni/entrypoints/openai/realtime_connection.py
@@ -0,0 +1,193 @@
+from __future__ import annotations
+
+import asyncio
+import base64
+import json
+from collections.abc import AsyncGenerator
+from uuid import uuid4
+
+import numpy as np
+from vllm.entrypoints.openai.engine.protocol import UsageInfo
+from vllm.entrypoints.openai.realtime.connection import RealtimeConnection as VllmRealtimeConnection
+from vllm.entrypoints.openai.realtime.protocol import TranscriptionDelta, TranscriptionDone
+from vllm.logger import init_logger
+
+logger = init_logger(__name__)
+
+
+class RealtimeConnection(VllmRealtimeConnection):
+ """Omni realtime connection with audio-only server events.
+
+ Reuses upstream vLLM websocket/session lifecycle and only customizes
+ generation output handling to emit audio deltas.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # Last audio buffer seen for this realtime generation (cumulative or concatenation
+ # of increments); used to turn server cumulative PCM into true deltas.
+ self._realtime_audio_ref: np.ndarray | None = None
+
+ async def start_generation(self):
+ await super().start_generation()
+
+ @staticmethod
+ def _tensor_to_numpy(value) -> np.ndarray | None:
+ if value is None:
+ return None
+ if isinstance(value, np.ndarray):
+ arr = value
+ elif hasattr(value, "detach"):
+ arr = value.detach().float().cpu().numpy()
+ else:
+ try:
+ arr = np.asarray(value)
+ except Exception:
+ return None
+ if arr.ndim > 1:
+ arr = arr.reshape(-1)
+ return arr.astype(np.float32, copy=False)
+
+ @staticmethod
+ def _numpy_audio_prefix_match(prev: np.ndarray, curr: np.ndarray) -> bool:
+ n = prev.shape[0]
+ if n == 0:
+ return True
+ if curr.shape[0] < n:
+ return False
+ return bool(np.allclose(curr[:n], prev, rtol=1e-3, atol=2e-4))
+
+ def _raw_waveform_to_deltas(self, arr: np.ndarray) -> list[np.ndarray]:
+ """Convert one streaming PCM f32 chunk into incremental piece(s) for the client.
+
+ Some engine paths emit a growing cumulative waveform each step; others emit
+ true per-step deltas. We support both without duplicating audio on the client.
+ """
+ if arr.size == 0:
+ return []
+ ref = self._realtime_audio_ref
+ if ref is None:
+ self._realtime_audio_ref = arr.copy()
+ return [arr]
+ if self._numpy_audio_prefix_match(ref, arr):
+ delta = arr[ref.shape[0] :]
+ self._realtime_audio_ref = arr.copy()
+ return [delta] if delta.size > 0 else []
+ # True per-step delta (not a prefix extension of what we have seen).
+ self._realtime_audio_ref = np.concatenate([ref, arr])
+ return [arr]
+
+ def _extract_audio_chunks(self, output) -> tuple[list[np.ndarray], int]:
+ mm = getattr(output, "multimodal_output", None)
+ if not isinstance(mm, dict):
+ return [], 24000
+
+ sr = mm.get("sr") or mm.get("sample_rate") or mm.get("audio_sample_rate") or 24000
+ key = "audio" if "audio" in mm else ("model_outputs" if "model_outputs" in mm else None)
+ if key is None:
+ return [], int(sr)
+
+ raw_audio = mm.get(key)
+ chunks: list[np.ndarray] = []
+ if isinstance(raw_audio, (list, tuple)):
+ if len(raw_audio) > 0:
+ arr = self._tensor_to_numpy(raw_audio[-1])
+ if arr is not None and arr.size > 0:
+ chunks.extend(self._raw_waveform_to_deltas(arr))
+ else:
+ arr = self._tensor_to_numpy(raw_audio)
+ if arr is not None and arr.size > 0:
+ chunks.extend(self._raw_waveform_to_deltas(arr))
+ return chunks, int(sr)
+
+ @staticmethod
+ def _pcm16_b64(audio_f32: np.ndarray) -> str:
+ clipped = np.clip(audio_f32, -1.0, 1.0)
+ pcm16 = (clipped * 32767.0).astype(np.int16)
+ return base64.b64encode(pcm16.tobytes()).decode("utf-8")
+
+ async def _run_generation(
+ self,
+ streaming_input_gen: AsyncGenerator,
+ input_stream: asyncio.Queue[list[int]],
+ ):
+ request_id = f"rt-{self.connection_id}-{uuid4()}"
+ sent_audio = False
+ audio_done_sent = False
+ full_text = ""
+ sent_text_len = 0
+ prompt_token_ids_len = 0
+ completion_tokens_len = 0
+ self._realtime_audio_ref = None
+
+ try:
+ result_gen = self.serving.engine_client.generate(
+ prompt=streaming_input_gen,
+ request_id=request_id,
+ )
+
+ async for output in result_gen:
+ if output.outputs and len(output.outputs) > 0:
+ output0 = output.outputs[0]
+ token_ids = list(output0.token_ids)
+ if token_ids:
+ input_stream.put_nowait(token_ids)
+ # token_ids are cumulative per request
+ completion_tokens_len = len(token_ids)
+ if not prompt_token_ids_len and output.prompt_token_ids:
+ prompt_token_ids_len = len(output.prompt_token_ids)
+ cumulative_text = output0.text or ""
+ if cumulative_text:
+ if len(cumulative_text) >= sent_text_len:
+ delta_text = cumulative_text[sent_text_len:]
+ else:
+ delta_text = cumulative_text
+ sent_text_len = len(cumulative_text)
+ full_text = cumulative_text
+ else:
+ delta_text = ""
+
+ if delta_text:
+ await self.send(TranscriptionDelta(delta=delta_text))
+
+ audio_chunks, sample_rate = self._extract_audio_chunks(output)
+
+ for chunk in audio_chunks:
+ sent_audio = True
+ await self.send_json(
+ {
+ "type": "response.audio.delta",
+ "audio": self._pcm16_b64(chunk),
+ "format": "pcm16",
+ "sample_rate_hz": sample_rate,
+ }
+ )
+
+ if not self._is_connected:
+ break
+
+ usage = UsageInfo(
+ prompt_tokens=prompt_token_ids_len,
+ completion_tokens=completion_tokens_len,
+ total_tokens=prompt_token_ids_len + completion_tokens_len,
+ )
+ await self.send(TranscriptionDone(text=full_text, usage=usage))
+
+ if sent_audio:
+ await self.send_json({"type": "response.audio.done", "has_audio": True})
+ audio_done_sent = True
+ except Exception as e:
+ logger.exception("Error in generation: %s", e)
+ await self.send_error(str(e), "processing_error")
+ finally:
+ # Always send terminal event so clients don't hang forever.
+ if self._is_connected and not audio_done_sent:
+ try:
+ await self.send_json({"type": "response.audio.done", "has_audio": sent_audio})
+ except Exception:
+ logger.exception("Failed to send response.audio.done")
+ while not self.audio_queue.empty():
+ self.audio_queue.get_nowait()
+
+ async def send_json(self, payload: dict):
+ await self.websocket.send_text(json.dumps(payload))
diff --git a/vllm_omni/entrypoints/openai/serving_chat.py b/vllm_omni/entrypoints/openai/serving_chat.py
index 39fcbc9a0..06b739e3b 100644
--- a/vllm_omni/entrypoints/openai/serving_chat.py
+++ b/vllm_omni/entrypoints/openai/serving_chat.py
@@ -32,6 +32,7 @@ from vllm.entrypoints.chat_utils import (
get_history_tool_calls_cnt,
make_tool_call_id,
)
+from vllm.entrypoints.launcher import terminate_if_errored
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionNamedToolChoiceParam,
ChatCompletionRequest,
@@ -80,12 +81,14 @@ from vllm.tokenizers.mistral import (
from vllm.tool_parsers import ToolParser
from vllm.tool_parsers.mistral_tool_parser import MistralToolCall
from vllm.utils.collection_utils import as_list
+from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin
from vllm_omni.entrypoints.openai.image_api_utils import validate_layered_layers
from vllm_omni.entrypoints.openai.protocol import OmniChatCompletionStreamResponse
from vllm_omni.entrypoints.openai.protocol.audio import AudioResponse, CreateAudio
from vllm_omni.entrypoints.openai.utils import (
+ get_stage_type,
get_supported_speakers_from_hf_config,
parse_lora_request,
validate_requested_speaker,
@@ -294,6 +297,8 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
)
num_inference_steps = None
+ cfg_text_scale = None
+ cfg_img_scale = None
# Omni multistage image generation: Stage-0 (AR) should receive a clean
# text prompt (and optional conditioning image/size) so the model's own
# processor can construct the correct inputs.
@@ -301,20 +306,9 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
# effectively unconditioned and produce nonsense images.
if request.modalities and ("image" in request.modalities):
try:
- messages_as_dicts: list[dict[str, Any]] = []
- for msg in request.messages:
- if hasattr(msg, "model_dump"):
- messages_as_dicts.append(msg.model_dump())
- elif isinstance(msg, dict):
- messages_as_dicts.append(msg)
- else:
- messages_as_dicts.append(
- {
- "role": getattr(msg, "role", "user"),
- "content": getattr(msg, "content", ""),
- }
- )
- extracted_prompt, reference_images = self._extract_diffusion_prompt_and_images(messages_as_dicts)
+ extracted_prompt, reference_images = self._extract_diffusion_prompt_and_images_from_messages(
+ request.messages
+ )
if not extracted_prompt:
return self.create_error_response("No text prompt found in messages")
@@ -325,39 +319,33 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
extra_body = getattr(request, "extra_body", None)
if not extra_body:
extra_body = request.model_extra or {}
- height = extra_body.get("height")
- width = extra_body.get("width")
+
+ height, width = self._resolve_height_width_from_extra_body(extra_body)
+
num_inference_steps = extra_body.get("num_inference_steps")
if num_inference_steps is not None:
try:
num_inference_steps = int(num_inference_steps)
except Exception:
num_inference_steps = None
- if "size" in extra_body:
- try:
- size_str = extra_body["size"]
- if isinstance(size_str, str) and "x" in size_str.lower():
- w, h = size_str.lower().split("x")
- width, height = int(w), int(h)
- except Exception:
- pass
+
negative_prompt = extra_body.get("negative_prompt")
+ cfg_text_scale = extra_body.get("cfg_text_scale")
+ cfg_img_scale = extra_body.get("cfg_img_scale")
engine_prompt_image: dict[str, Any] | None = None
- is_img2img = False
if reference_images:
# Best-effort decode first reference image for i2i.
try:
img_bytes = base64.b64decode(reference_images[0])
img = Image.open(BytesIO(img_bytes))
engine_prompt_image = {"img2img": img}
- is_img2img = True
except Exception:
engine_prompt_image = None
# Override the prompts produced by chat-template preprocessing.
tprompt: OmniTextPrompt = {"prompt": extracted_prompt}
- if is_img2img:
+ if engine_prompt_image:
tprompt["modalities"] = ["img2img"]
else:
tprompt["modalities"] = ["image"]
@@ -373,6 +361,13 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
tprompt["mm_processor_kwargs"] = mm_processor_kwargs
if engine_prompt_image is not None:
tprompt["multi_modal_data"] = engine_prompt_image
+ # Provide multi_modal_uuids so that newer vLLM versions
+ # can validate multi_modal_data / multi_modal_uuids
+ # consistency. After the multimodal processor consumes
+ # the image data, the uuids remain as a stable reference.
+ tprompt["multi_modal_uuids"] = {
+ k: [f"{request_id}-{k}-{i}"] for i, k in enumerate(engine_prompt_image)
+ }
engine_prompts = [tprompt]
# Store height/width for applying to diffusion stage sampling params later
@@ -397,14 +392,18 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
sampling_params_list = self._build_sampling_params_list_from_request(request)
# Apply user-specified overrides to diffusion stage(s) for image generation
- if _image_gen_height is not None or _image_gen_width is not None or num_inference_steps is not None:
- for idx, sp in enumerate(sampling_params_list):
- if hasattr(sp, "height") and _image_gen_height is not None:
- sp.height = _image_gen_height
- if hasattr(sp, "width") and _image_gen_width is not None:
- sp.width = _image_gen_width
- if hasattr(sp, "num_inference_steps") and num_inference_steps is not None:
- sp.num_inference_steps = num_inference_steps
+ for idx, sp in enumerate(sampling_params_list):
+ if hasattr(sp, "height") and _image_gen_height is not None:
+ sp.height = _image_gen_height
+ if hasattr(sp, "width") and _image_gen_width is not None:
+ sp.width = _image_gen_width
+ if hasattr(sp, "num_inference_steps") and num_inference_steps is not None:
+ sp.num_inference_steps = num_inference_steps
+ if hasattr(sp, "extra_args") and sp.extra_args is not None:
+ if cfg_text_scale is not None:
+ sp.extra_args["cfg_text_scale"] = cfg_text_scale
+ if cfg_img_scale is not None:
+ sp.extra_args["cfg_img_scale"] = cfg_img_scale
self._log_inputs(
request_id,
@@ -438,6 +437,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
tokenizer,
request_metadata,
reasoning_parser,
+ raw_request=raw_request,
)
try:
@@ -535,20 +535,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
# containing image tokens.
req_modalities = getattr(request, "modalities", [])
if req_modalities and ("image" in req_modalities):
- messages_as_dicts: list[dict[str, Any]] = []
- for msg in messages:
- if hasattr(msg, "model_dump"):
- messages_as_dicts.append(msg.model_dump())
- elif isinstance(msg, dict):
- messages_as_dicts.append(msg)
- else:
- messages_as_dicts.append(
- {
- "role": getattr(msg, "role", "user"),
- "content": getattr(msg, "content", ""),
- }
- )
- extracted_prompt, _ = self._extract_diffusion_prompt_and_images(messages_as_dicts)
+ extracted_prompt, _ = self._extract_diffusion_prompt_and_images_from_messages(messages)
if extracted_prompt:
engine_prompt["prompt"] = extracted_prompt
@@ -708,6 +695,9 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
Starts with YAML defaults and only overrides fields that the user
explicitly provided (non-None values) in the request.
+ For GLM-Image AR stage, if max_tokens is not in YAML and user provides
+ height/width in extra_body, computes max_tokens dynamically.
+
Args:
default_params: Default SamplingParams from stage config YAML.
request: The chat completion request containing user-provided values.
@@ -717,13 +707,64 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
"""
params = default_params.clone()
+ # Only apply fields explicitly provided by user, not protocol defaults.
+ # Pydantic v2 uses `model_fields_set`; keep v1 fallback for compatibility.
+ explicit_fields = getattr(request, "model_fields_set", None)
+ if explicit_fields is None:
+ explicit_fields = getattr(request, "__fields_set__", set())
+
for field_name in self._OPENAI_SAMPLING_FIELDS:
+ if field_name not in explicit_fields:
+ continue
+
value = getattr(request, field_name, None)
- if value is not None:
+ if (value is not None and not isinstance(value, list)) or (isinstance(value, list) and len(value) > 0):
setattr(params, field_name, value)
+ # For GLM-Image: compute max_tokens from height/width with mode-aware
+ # budgeting (t2i vs i2i).
+ extra_body = getattr(request, "extra_body", {}) or {}
+ height, width = self._resolve_height_width_from_extra_body(extra_body)
+
+ # Best-effort mode detection from user messages.
+ # i2i requests include at least one reference image in message content.
+ _, reference_images = self._extract_diffusion_prompt_and_images_from_messages(request.messages)
+ ref_image_count = len(reference_images)
+ is_img2img = ref_image_count > 0
+
+ if height is not None and width is not None:
+ try:
+ from vllm_omni.model_executor.stage_input_processors.glm_image import compute_max_tokens
+
+ max_tokens = getattr(explicit_fields, "max_tokens", None)
+ if max_tokens is None:
+ max_tokens = compute_max_tokens(int(height), int(width), is_i2i=is_img2img)
+ params.max_tokens = max_tokens
+ # Keep target size in stage-0 sampling params so runner/model can
+ # build deterministic M-RoPE grids for t2i (no MM features).
+ extra_args = dict(getattr(params, "extra_args", {}) or {})
+ extra_args["target_h"] = int(height)
+ extra_args["target_w"] = int(width)
+ params.extra_args = extra_args
+ except (ImportError, ValueError, TypeError) as e:
+ logger.warning(f"Failed to compute max_tokens: {e}, using default if available")
+ else:
+ logger.info(
+ "[SamplingParams] Skip dynamic max_tokens (height=%s, width=%s, mode=%s, ref_images=%s)",
+ height,
+ width,
+ "i2i" if is_img2img else "t2i",
+ ref_image_count,
+ )
+
return params
+ @staticmethod
+ def _set_if_supported(obj: Any, **kwargs: Any) -> None:
+ for key, value in kwargs.items():
+ if value is not None and hasattr(obj, key):
+ setattr(obj, key, value)
+
def _build_sampling_params_list_from_request(
self,
request: ChatCompletionRequest,
@@ -787,6 +828,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
tokenizer: AnyTokenizer,
request_metadata: RequestResponseMetadata,
reasoning_parser: ReasoningParser | None = None,
+ raw_request: Request | None = None,
):
created_time = int(time.time())
chunk_object_type: Final = "chat.completion.chunk"
@@ -1499,6 +1541,21 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
delta=False,
)
+ except EngineDeadError as e:
+ logger.error(
+ "EngineDeadError during streaming for request %s: %s",
+ request_id,
+ e,
+ )
+ data = self.create_streaming_error_response(e)
+ yield f"data: {data}\n\n"
+ # Actively signal shutdown instead of waiting for the watchdog
+ # (5s polling interval).
+ if raw_request is not None:
+ terminate_if_errored(
+ server=raw_request.app.state.server,
+ engine=self.engine_client,
+ )
except Exception as e:
logger.exception("Error in chat completion stream generator.")
data = self.create_streaming_error_response(e)
@@ -1570,6 +1627,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
role,
reasoning_parser,
)
+ final_res = omni_outputs.request_output
elif omni_outputs.final_output_type == "audio":
choices_data = self._create_audio_choice(omni_outputs, role, request, stream=False)
elif omni_outputs.final_output_type == "image":
@@ -2048,6 +2106,254 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
return choices
# ==================== Diffusion Mode Methods ====================
+ def _build_multistage_generation_inputs(
+ self,
+ *,
+ engine: AsyncOmni,
+ prompt: str,
+ extra_body: dict[str, Any],
+ reference_images: list[Image.Image],
+ gen_params: OmniDiffusionSamplingParams,
+ ) -> tuple[OmniTextPrompt, list[Any]]:
+ """Build the shared multistage generation prompt and stage params."""
+ stage_configs = getattr(engine, "stage_configs", None) or []
+ default_params_list = list(getattr(engine, "default_sampling_params_list", []) or [])
+
+ height = gen_params.height
+ width = gen_params.width
+ seed = gen_params.seed
+ generator_device = gen_params.generator_device
+ num_outputs_per_prompt = gen_params.num_outputs_per_prompt
+ num_inference_steps = extra_body.get("num_inference_steps")
+ guidance_scale = extra_body.get("guidance_scale")
+ true_cfg_scale = extra_body.get("true_cfg_scale") or extra_body.get("cfg_scale")
+ negative_prompt = extra_body.get("negative_prompt")
+ num_frames = extra_body.get("num_frames")
+ guidance_scale_2 = extra_body.get("guidance_scale_2")
+ lora_body = extra_body.get("lora")
+ layers = extra_body.get("layers")
+ resolution = extra_body.get("resolution")
+
+ engine_prompt_data: dict[str, Any] | None = None
+ modalities = ["image"]
+ if reference_images:
+ if len(reference_images) == 1:
+ engine_prompt_data = {"img2img": reference_images[0]}
+ modalities = ["img2img"]
+ else:
+ engine_prompt_data = {"image": reference_images}
+
+ engine_prompt: OmniTextPrompt = {"prompt": prompt}
+ engine_prompt["modalities"] = modalities
+ if negative_prompt is not None:
+ engine_prompt["negative_prompt"] = negative_prompt
+
+ mm_processor_kwargs: dict[str, Any] = {}
+ if height is not None:
+ mm_processor_kwargs["target_h"] = height
+ if width is not None:
+ mm_processor_kwargs["target_w"] = width
+ if mm_processor_kwargs:
+ engine_prompt["mm_processor_kwargs"] = mm_processor_kwargs
+ if engine_prompt_data is not None:
+ engine_prompt["multi_modal_data"] = engine_prompt_data
+
+ comprehension_idx = None
+ for idx, stage in enumerate(stage_configs):
+ if getattr(stage, "is_comprehension", False):
+ comprehension_idx = idx
+ break
+
+ sampling_params_list: list[Any] = []
+ for idx, stage_cfg in enumerate(stage_configs):
+ stage_type = get_stage_type(stage_cfg)
+ if idx < len(default_params_list):
+ default_stage_params = default_params_list[idx]
+ if hasattr(default_stage_params, "clone"):
+ try:
+ default_stage_params = default_stage_params.clone()
+ except Exception:
+ pass
+ elif stage_type == "diffusion":
+ default_stage_params = gen_params.clone()
+ else:
+ default_stage_params = SamplingParams()
+
+ if (
+ comprehension_idx is not None
+ and idx == comprehension_idx
+ and seed is not None
+ and hasattr(default_stage_params, "seed")
+ ):
+ default_stage_params.seed = seed
+
+ if stage_type == "diffusion":
+ self._set_if_supported(
+ default_stage_params,
+ height=height,
+ width=width,
+ seed=seed,
+ generator_device=generator_device,
+ num_outputs_per_prompt=num_outputs_per_prompt,
+ num_inference_steps=num_inference_steps,
+ guidance_scale=guidance_scale,
+ true_cfg_scale=true_cfg_scale,
+ num_frames=num_frames,
+ guidance_scale_2=guidance_scale_2,
+ layers=layers,
+ resolution=resolution,
+ )
+ if lora_body and isinstance(lora_body, dict):
+ try:
+ lora_req, lora_scale = parse_lora_request(lora_body)
+ if lora_req is not None:
+ default_stage_params.lora_request = lora_req
+ if lora_scale is not None:
+ default_stage_params.lora_scale = lora_scale
+ except Exception as e: # pragma: no cover - safeguard
+ logger.warning("Failed to parse LoRA request: %s", e)
+
+ sampling_params_list.append(default_stage_params)
+
+ return engine_prompt, sampling_params_list
+
+ async def generate_diffusion_images(
+ self,
+ *,
+ prompt: str,
+ extra_body: dict[str, Any] | None = None,
+ reference_images: list[str] | None = None,
+ request_id: str | None = None,
+ ) -> tuple[list[Image.Image], dict[str, Any], float] | ErrorResponse:
+ """Generate diffusion images and return raw images plus generation stats."""
+ if request_id is None:
+ request_id = f"chatcmpl-{uuid.uuid4().hex[:16]}"
+ if extra_body is None:
+ extra_body = {}
+ if reference_images is None:
+ reference_images = []
+
+ engine = self._diffusion_engine if self._diffusion_engine is not None else self.engine_client
+
+ height = extra_body.get("height")
+ width = extra_body.get("width")
+ if "size" in extra_body:
+ try:
+ size_str = extra_body["size"]
+ if isinstance(size_str, str) and "x" in size_str.lower():
+ w, h = size_str.lower().split("x")
+ width, height = int(w), int(h)
+ except ValueError:
+ logger.warning("Invalid size format: %s", extra_body.get("size"))
+
+ seed = extra_body.get("seed")
+ generator_device = extra_body.get("generator_device")
+ negative_prompt = extra_body.get("negative_prompt")
+ num_outputs_per_prompt = extra_body.get("num_outputs_per_prompt", 1)
+ lora_body = extra_body.get("lora")
+
+ pil_images: list[Image.Image] = []
+ for img_b64 in reference_images:
+ try:
+ img_bytes = base64.b64decode(img_b64)
+ pil_images.append(Image.open(BytesIO(img_bytes)))
+ except Exception as e:
+ logger.warning("Failed to decode reference image: %s", e)
+
+ gen_params = OmniDiffusionSamplingParams(
+ height=height,
+ width=width,
+ num_outputs_per_prompt=num_outputs_per_prompt,
+ seed=seed,
+ )
+ self._set_if_supported(
+ gen_params,
+ generator_device=generator_device,
+ num_inference_steps=extra_body.get("num_inference_steps"),
+ guidance_scale=extra_body.get("guidance_scale"),
+ true_cfg_scale=extra_body.get("true_cfg_scale") or extra_body.get("cfg_scale"),
+ num_frames=extra_body.get("num_frames"),
+ guidance_scale_2=extra_body.get("guidance_scale_2"),
+ layers=extra_body.get("layers"),
+ resolution=extra_body.get("resolution"),
+ )
+
+ if lora_body and isinstance(lora_body, dict):
+ try:
+ lora_req, lora_scale = parse_lora_request(lora_body)
+ if lora_req is not None:
+ gen_params.lora_request = lora_req
+ if lora_scale is not None:
+ gen_params.lora_scale = lora_scale
+ except Exception as e: # pragma: no cover - safeguard
+ logger.warning("Failed to parse LoRA request: %s", e)
+
+ gen_prompt: OmniTextPrompt = {
+ "prompt": prompt,
+ "negative_prompt": negative_prompt,
+ }
+ if pil_images:
+ if len(pil_images) == 1:
+ gen_prompt["multi_modal_data"] = {"image": pil_images[0]}
+ else:
+ od_config = getattr(engine, "od_config", None)
+ supports_multimodal_inputs = getattr(od_config, "supports_multimodal_inputs", False)
+ if od_config is None:
+ supports_multimodal_inputs = True
+ if supports_multimodal_inputs:
+ gen_prompt["multi_modal_data"] = {"image": pil_images}
+ else:
+ return self._create_error_response(
+ "Multiple input images are not supported by the current diffusion model. "
+ "For multi-image editing, start the server with Qwen-Image-Edit-2509 "
+ "and send multiple images in the user message content.",
+ status_code=400,
+ )
+
+ if isinstance(engine, AsyncOmni):
+ diffusion_engine = cast(AsyncOmni, engine)
+ stage_configs = getattr(diffusion_engine, "stage_configs", None) or []
+ if len(stage_configs) > 1:
+ engine_prompt, sampling_params_list = self._build_multistage_generation_inputs(
+ engine=diffusion_engine,
+ prompt=prompt,
+ extra_body=extra_body,
+ reference_images=pil_images,
+ gen_params=gen_params,
+ )
+ else:
+ engine_prompt = gen_prompt
+ sampling_params_list = [gen_params]
+
+ result = None
+ async for output in diffusion_engine.generate(
+ prompt=engine_prompt,
+ sampling_params_list=sampling_params_list,
+ request_id=request_id,
+ ):
+ result = output
+ if result is None:
+ return self._create_error_response("No output generated from AsyncOmni", status_code=500)
+ else:
+ result = await engine.generate(
+ prompt=gen_prompt,
+ sampling_params=gen_params,
+ request_id=request_id,
+ )
+
+ images = getattr(result.request_output, "images", [])
+ stage_durations = result.stage_durations
+ peak_memory_mb = result.peak_memory_mb
+
+ flat_images: list[Image.Image] = []
+ for item in images:
+ if isinstance(item, list):
+ flat_images.extend(item)
+ else:
+ flat_images.append(item)
+
+ return flat_images, stage_durations, peak_memory_mb
+
async def _create_diffusion_chat_completion(
self,
request: ChatCompletionRequest,
@@ -2108,6 +2414,8 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
num_inference_steps = extra_body.get("num_inference_steps")
guidance_scale = extra_body.get("guidance_scale")
true_cfg_scale = extra_body.get("true_cfg_scale") or extra_body.get("cfg_scale")
+ cfg_text_scale = extra_body.get("cfg_text_scale")
+ cfg_img_scale = extra_body.get("cfg_img_scale")
seed = extra_body.get("seed")
negative_prompt = extra_body.get("negative_prompt")
num_outputs_per_prompt = extra_body.get("num_outputs_per_prompt", 1)
@@ -2162,6 +2470,10 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
gen_params.guidance_scale = guidance_scale
if true_cfg_scale is not None:
gen_params.true_cfg_scale = true_cfg_scale
+ if cfg_text_scale is not None:
+ gen_params.extra_args["cfg_text_scale"] = cfg_text_scale
+ if cfg_img_scale is not None:
+ gen_params.extra_args["cfg_img_scale"] = cfg_img_scale
if num_frames is not None:
gen_params.num_frames = num_frames
if guidance_scale_2 is not None:
@@ -2182,7 +2494,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
except Exception as e: # pragma: no cover - safeguard
logger.warning("Failed to parse LoRA request: %s", e)
- # Add reference image if provided
+ # Add reference image if provided (from messages content)
if pil_images:
if len(pil_images) == 1:
gen_prompt["multi_modal_data"] = {}
@@ -2206,10 +2518,30 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
# Generate image
diffusion_engine = cast(AsyncOmni, self._diffusion_engine)
+ stage_configs = list(getattr(diffusion_engine, "stage_configs", []) or [])
+ default_params_list = list(getattr(diffusion_engine, "default_sampling_params_list", []) or [])
+
+ sampling_params_list: list[Any] = []
+ for idx, stage_cfg in enumerate(stage_configs):
+ if get_stage_type(stage_cfg) == "diffusion":
+ sampling_params_list.append(gen_params)
+ continue
+
+ default_stage_params = default_params_list[idx] if idx < len(default_params_list) else SamplingParams()
+ if hasattr(default_stage_params, "clone"):
+ try:
+ default_stage_params = default_stage_params.clone()
+ except Exception as e:
+ logger.warning("Failed to clone default params for stage %d: %s", idx, e)
+ sampling_params_list.append(default_stage_params)
+
+ if not sampling_params_list:
+ sampling_params_list = [gen_params]
+
result = None
async for output in diffusion_engine.generate(
prompt=gen_prompt,
- sampling_params_list=[gen_params], # Pass as single-stage params
+ sampling_params_list=sampling_params_list,
request_id=request_id,
):
result = output
@@ -2353,6 +2685,48 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
prompt = " ".join(prompt_parts).strip()
return prompt, images
+ def _extract_diffusion_prompt_and_images_from_messages(
+ self,
+ messages: list[Any],
+ ) -> tuple[str, list[str]]:
+ """Normalize mixed message types and extract prompt + reference images once."""
+ return self._extract_diffusion_prompt_and_images(self._messages_to_dicts(messages))
+
+ @staticmethod
+ def _messages_to_dicts(messages: list[Any]) -> list[dict[str, Any]]:
+ """Normalize request messages to plain dicts."""
+ out: list[dict[str, Any]] = []
+ for msg in messages:
+ if hasattr(msg, "model_dump"):
+ out.append(msg.model_dump())
+ elif isinstance(msg, dict):
+ out.append(msg)
+ else:
+ out.append(
+ {
+ "role": getattr(msg, "role", "user"),
+ "content": getattr(msg, "content", ""),
+ }
+ )
+ return out
+
+ @staticmethod
+ def _resolve_height_width_from_extra_body(extra_body: dict[str, Any]) -> tuple[Any, Any]:
+ """Extract generation height/width with optional size string fallback."""
+ height = extra_body.get("height")
+ width = extra_body.get("width")
+
+ if "size" in extra_body and (height is None or width is None):
+ try:
+ size_str = extra_body["size"]
+ if isinstance(size_str, str) and "x" in size_str.lower():
+ w, h = size_str.lower().split("x")
+ width, height = int(w), int(h)
+ except Exception:
+ pass
+
+ return height, width
+
def _create_error_response(
self,
message: str,
diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py
index d4d57ae0f..de0f409ae 100644
--- a/vllm_omni/entrypoints/openai/serving_speech.py
+++ b/vllm_omni/entrypoints/openai/serving_speech.py
@@ -17,12 +17,17 @@ import torch
from fastapi import Request, UploadFile
from fastapi.responses import Response, StreamingResponse
from transformers.utils.hub import cached_file
-from vllm.entrypoints.openai.engine.protocol import ErrorResponse
+from vllm.entrypoints.launcher import terminate_if_errored
+from vllm.entrypoints.openai.engine.protocol import (
+ ErrorResponse,
+ RequestResponseMetadata,
+)
from vllm.entrypoints.openai.engine.serving import OpenAIServing
from vllm.logger import init_logger
from vllm.multimodal.media import MediaConnector
from vllm.utils import random_uuid
from vllm.utils.async_utils import make_async
+from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError
from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin
from vllm_omni.entrypoints.openai.protocol.audio import (
@@ -49,6 +54,7 @@ _QWEN3_TTS_MODEL_STAGES = {"qwen3_tts"}
_FISH_TTS_MODEL_STAGES = {"fish_speech_slow_ar"}
_COSYVOICE3_TTS_MODEL_STAGES = {"cosyvoice3_talker"}
_OMNIVOICE_TTS_MODEL_STAGES = {"omnivoice_generator"}
+_VOXCPM_TTS_MODEL_STAGES = {"latent_generator", "vae"}
_VOXCPM2_TTS_MODEL_STAGES = {"latent_generator"}
_MOSS_TTS_MODEL_STAGES = {"moss_tts_nano"}
_TTS_MODEL_STAGES: set[str] = (
@@ -57,6 +63,7 @@ _TTS_MODEL_STAGES: set[str] = (
| _FISH_TTS_MODEL_STAGES
| _COSYVOICE3_TTS_MODEL_STAGES
| _OMNIVOICE_TTS_MODEL_STAGES
+ | _VOXCPM_TTS_MODEL_STAGES
| _VOXCPM2_TTS_MODEL_STAGES
| _MOSS_TTS_MODEL_STAGES
)
@@ -216,6 +223,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
"Re-upload voices after each restart if needed."
)
self._tts_tokenizer = None
+ self._voxcpm2_tokenizer = None
+ self._voxcpm2_split_map: dict[int, list[int]] = {}
logger.info(f"Loaded {len(self.supported_speakers)} supported speakers: {sorted(self.supported_speakers)}")
@@ -284,6 +293,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if self._tts_stage is None:
return None
model_stage = getattr(self._tts_stage.engine_args, "model_stage", None)
+ model_arch = getattr(self._tts_stage.engine_args, "model_arch", None)
+ if model_arch == "VoxCPM2TalkerForConditionalGeneration":
+ return "voxcpm2"
+ if model_arch == "VoxCPMForConditionalGeneration":
+ return "voxcpm"
if model_stage in _QWEN3_TTS_MODEL_STAGES:
return "qwen3_tts"
if model_stage in _VOXTRAL_TTS_MODEL_STAGES:
@@ -298,6 +312,12 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
return "voxcpm2"
if model_stage in _MOSS_TTS_MODEL_STAGES:
return "moss_tts_nano"
+ if model_stage in (_VOXCPM_TTS_MODEL_STAGES | _VOXCPM2_TTS_MODEL_STAGES):
+ has_vae_stage = any(
+ getattr(getattr(stage, "engine_args", None), "model_stage", None) == "vae"
+ for stage in self.engine_client.stage_configs
+ )
+ return "voxcpm" if has_vae_stage or model_stage == "vae" else "voxcpm2"
return None
def _compute_max_instructions_length(self) -> int:
@@ -322,6 +342,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
def _load_supported_speakers(self) -> set[str]:
"""Load supported speakers (case-insensitive) from the model configuration."""
try:
+ if self._tts_model_type == "voxcpm":
+ return set()
if self._tts_model_type == "voxtral_tts":
config = self.engine_client.model_config.hf_config.audio_config
else:
@@ -381,6 +403,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
def _estimate_prompt_len(self, tts_params: dict[str, Any]) -> int:
"""Estimate prompt length so the placeholder matches model-side embeddings."""
try:
+ if self._tts_model_type == "voxcpm":
+ return 1
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
Qwen3TTSTalkerForConditionalGeneration,
)
@@ -444,6 +468,25 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
logger.warning("Failed to estimate Fish Speech prompt length, using fallback 2048: %s", e)
return 2048
+ async def _build_voxcpm2_prompt(self, request: OpenAICreateSpeechRequest) -> dict[str, Any]:
+ """Build prefill prompt for VoxCPM2 TTS (`prompt_token_ids` padded to full prefill length)."""
+ from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import build_voxcpm2_prompt
+
+ self._voxcpm2_encode("") # lazy-init tokenizer + split_map
+ ref_audio = None
+ ref_sr = None
+ if request.ref_audio is not None:
+ ref_audio, ref_sr = await self._resolve_ref_audio(request.ref_audio)
+ return build_voxcpm2_prompt(
+ hf_config=self.engine_client.model_config.hf_config,
+ tokenizer=self._voxcpm2_tokenizer,
+ split_map=self._voxcpm2_split_map,
+ text=request.input,
+ ref_audio=ref_audio,
+ ref_sr=ref_sr,
+ ref_text=request.ref_text,
+ )
+
def _get_uploaded_audio_data(self, voice_name: str) -> str | None:
"""Get base64 encoded audio data for uploaded voice."""
voice_name_lower = voice_name.lower()
@@ -795,12 +838,33 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
return self._validate_fish_tts_request(request)
if self._tts_model_type == "cosyvoice3":
return self._validate_cosyvoice3_request(request)
+ if self._tts_model_type == "voxcpm":
+ return self._validate_voxcpm_request(request)
if self._tts_model_type == "voxcpm2":
return None # VoxCPM2 accepts any text input
if self._tts_model_type == "moss_tts_nano":
return self._validate_moss_tts_request(request)
return self._validate_qwen_tts_request(request)
+ def _voxcpm2_encode(self, text: str) -> list[int]:
+ """Tokenize text for VoxCPM2, splitting multichar Chinese tokens."""
+ from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import (
+ build_cjk_split_map,
+ split_multichar_chinese,
+ )
+
+ if self._voxcpm2_tokenizer is None:
+ from transformers import AutoTokenizer
+
+ model_name = self.engine_client.model_config.model
+ tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
+ self._voxcpm2_split_map = build_cjk_split_map(tokenizer)
+ self._voxcpm2_tokenizer = tokenizer
+ logger.info("VoxCPM2 serving: built multichar split map (%d entries)", len(self._voxcpm2_split_map))
+
+ ids = self._voxcpm2_tokenizer.encode(text, add_special_tokens=True)
+ return split_multichar_chinese(ids, self._voxcpm2_split_map)
+
def _validate_ref_audio_format(self, ref_audio: str) -> str | None:
"""Validate ref_audio is a supported URI format. Returns error or None."""
if not (
@@ -838,6 +902,43 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
return None
+ def _validate_voxcpm_request(self, request: OpenAICreateSpeechRequest) -> str | None:
+ """Validate VoxCPM request parameters. Returns error message or None."""
+ if not request.input or not request.input.strip():
+ return "Input text cannot be empty"
+
+ if request.voice is not None:
+ return "'voice' is not supported for VoxCPM"
+ if request.instructions is not None:
+ return "'instructions' is not supported for VoxCPM"
+ if request.language is not None:
+ return "'language' is not supported for VoxCPM"
+ if request.task_type not in (None, "Base"):
+ return "VoxCPM only supports plain TTS or voice cloning with ref_audio/ref_text"
+ if request.x_vector_only_mode is not None:
+ return "'x_vector_only_mode' is not supported for VoxCPM"
+ if request.speaker_embedding is not None:
+ return "'speaker_embedding' is not supported for VoxCPM"
+ if request.initial_codec_chunk_frames is not None:
+ return "'initial_codec_chunk_frames' is not supported for VoxCPM"
+
+ if request.ref_audio is not None:
+ fmt_err = self._validate_ref_audio_format(request.ref_audio)
+ if fmt_err:
+ return fmt_err
+ if not request.ref_text or not request.ref_text.strip():
+ return "Voice cloning requires 'ref_text' (transcript of the reference audio)"
+ elif request.ref_text is not None:
+ return "'ref_text' requires 'ref_audio' for VoxCPM voice cloning"
+
+ if request.max_new_tokens is not None:
+ if request.max_new_tokens < _TTS_MAX_NEW_TOKENS_MIN:
+ return f"max_new_tokens must be at least {_TTS_MAX_NEW_TOKENS_MIN}"
+ if request.max_new_tokens > _TTS_MAX_NEW_TOKENS_MAX:
+ return f"max_new_tokens cannot exceed {_TTS_MAX_NEW_TOKENS_MAX}"
+
+ return None
+
def _validate_qwen_tts_request(self, request: OpenAICreateSpeechRequest) -> str | None:
"""Validate Qwen TTS request parameters. Returns error message or None."""
# Infer Base task when ref_audio or ref_text is provided without explicit task_type.
@@ -1087,7 +1188,13 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
)
return wav_np.tolist(), sr
- async def _generate_audio_chunks(self, generator, request_id: str, response_format: str = "pcm"):
+ async def _generate_audio_chunks(
+ self,
+ generator,
+ request_id: str,
+ response_format: str = "pcm",
+ raw_request: Request | None = None,
+ ):
"""Generate audio chunks for streaming response.
Handles two audio output modes from the engine:
@@ -1162,6 +1269,19 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
except asyncio.CancelledError:
logger.info("Streaming request %s cancelled by client", request_id)
raise
+ except EngineDeadError as e:
+ logger.error(
+ "EngineDeadError during streaming speech for %s: %s",
+ request_id,
+ e,
+ )
+ # Actively signal shutdown rather than relying on the watchdog.
+ if raw_request is not None:
+ terminate_if_errored(
+ server=raw_request.app.state.server,
+ engine=self.engine_client,
+ )
+ raise
except Exception as e:
logger.exception("Streaming speech generation failed for %s: %s", request_id, e)
raise
@@ -1199,6 +1319,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
Processes each parameter if present, skips if not.
Values are wrapped in lists as required by the model.
"""
+ if self._tts_model_type == "voxcpm":
+ params: dict[str, Any] = {
+ "text": [request.input],
+ "cfg_value": [2.0],
+ "inference_timesteps": [10],
+ "min_len": [2],
+ "max_new_tokens": [request.max_new_tokens or 4096],
+ }
+ if request.ref_text is not None:
+ params["ref_text"] = [request.ref_text]
+ return params
+
params: dict[str, Any] = {}
# Text content (always required)
@@ -1424,6 +1556,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
async def _prepare_speech_generation(
self,
request: OpenAICreateSpeechRequest,
+ request_id: str | None = None,
) -> tuple[str, Any, dict[str, Any]]:
if self.engine_client.errored:
raise self.engine_client.dead_error
@@ -1467,14 +1600,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if request.instructions:
prompt["instruct"] = request.instructions
elif self._tts_model_type == "voxcpm2":
+ prompt = await self._build_voxcpm2_prompt(request)
tts_params = {}
- additional: dict[str, Any] = {}
- if request.ref_audio is not None:
- wav_list, sr = await self._resolve_ref_audio(request.ref_audio)
- additional["reference_audio"] = [[wav_list, sr]]
- prompt = {"prompt": request.input}
- if additional:
- prompt["additional_information"] = additional
elif self._is_tts:
validation_error = self._validate_tts_request(request)
if validation_error:
@@ -1525,13 +1652,15 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
tts_params = {}
prompt = {"prompt": request.input}
- request_id = f"speech-{random_uuid()}"
+ request_id = request_id or f"speech-{random_uuid()}"
if self._is_fish_speech:
model_type = "fish_speech"
elif self._tts_model_type == "voxtral_tts":
model_type = "voxtral_tts"
elif self._tts_model_type == "cosyvoice3":
model_type = "cosyvoice3"
+ elif self._tts_model_type == "voxcpm":
+ model_type = "voxcpm"
elif self._tts_model_type == "voxcpm2":
model_type = "voxcpm2"
elif self._is_tts:
@@ -1605,8 +1734,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
self,
request: OpenAICreateSpeechRequest,
base64_encode: bool = False,
+ request_id: str | None = None,
) -> tuple[bytes | str, str]:
- request_id, generator, _ = await self._prepare_speech_generation(request)
+ request_id, generator, _ = await self._prepare_speech_generation(request, request_id=request_id)
final_output: OmniRequestOutput | None = None
async for res in generator:
@@ -1730,6 +1860,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
except asyncio.CancelledError:
return self._diffusion_error_response("Client disconnected")
+ except (EngineGenerateError, EngineDeadError):
+ raise # Propagate to the global Omni exception handler
except ValueError as e:
return self._diffusion_error_response(str(e))
except Exception as e:
@@ -1775,6 +1907,12 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
logger.error("Error with model %s", error_check_ret)
return error_check_ret
+ request_id = f"speech-{random_uuid()}"
+ if raw_request:
+ raw_request.state.request_metadata = RequestResponseMetadata(
+ request_id=request_id,
+ )
+
try:
if request.stream:
# Determine response format and media type for streaming
@@ -1795,17 +1933,24 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
)
media_type = "audio/wav" if response_format == "wav" else "audio/pcm"
- request_id, generator, _ = await self._prepare_speech_generation(request)
+ _, generator, _ = await self._prepare_speech_generation(request, request_id=request_id)
return StreamingResponse(
- self._generate_audio_chunks(generator, request_id, response_format),
+ self._generate_audio_chunks(
+ generator,
+ request_id,
+ response_format,
+ raw_request=raw_request,
+ ),
media_type=media_type,
)
- audio_bytes, media_type = await self._generate_audio_bytes(request)
+ audio_bytes, media_type = await self._generate_audio_bytes(request, request_id=request_id)
return Response(content=audio_bytes, media_type=media_type)
except asyncio.CancelledError:
return self.create_error_response("Client disconnected")
+ except (EngineGenerateError, EngineDeadError):
+ raise # Propagate to the global Omni exception handler
except ValueError as e:
return self.create_error_response(e)
except Exception as e:
diff --git a/vllm_omni/entrypoints/openai/serving_video.py b/vllm_omni/entrypoints/openai/serving_video.py
index 0001fa65f..741295c7c 100644
--- a/vllm_omni/entrypoints/openai/serving_video.py
+++ b/vllm_omni/entrypoints/openai/serving_video.py
@@ -113,6 +113,10 @@ class OmniOpenAIServingVideo:
if vp.fps is not None:
gen_params.fps = vp.fps
gen_params.frame_rate = float(vp.fps)
+ gen_params.enable_frame_interpolation = request.enable_frame_interpolation
+ gen_params.frame_interpolation_exp = request.frame_interpolation_exp
+ gen_params.frame_interpolation_scale = request.frame_interpolation_scale
+ gen_params.frame_interpolation_model_path = request.frame_interpolation_model_path
if request.num_inference_steps is not None:
gen_params.num_inference_steps = request.num_inference_steps
@@ -160,7 +164,7 @@ class OmniOpenAIServingVideo:
videos = self._extract_video_outputs(result)
audios = self._extract_audio_outputs(result, expected_count=len(videos))
audio_sample_rate = self._resolve_audio_sample_rate(result)
- output_fps = vp.fps or self._resolve_fps(result) or 24
+ output_fps = (vp.fps or self._resolve_fps(result) or 24) * self._resolve_video_fps_multiplier(result)
return VideoGenerationArtifacts(
videos=videos,
audios=audios,
@@ -243,6 +247,22 @@ class OmniOpenAIServingVideo:
logger.info("Video response encoding (MP4 bytes): %.2f ms", _t_encode_ms)
return video_bytes, artifacts.stage_durations, artifacts.peak_memory_mb
+ @staticmethod
+ def _resolve_video_fps_multiplier(result: Any) -> int:
+ custom_output = getattr(result, "custom_output", None)
+ if isinstance(custom_output, dict):
+ multiplier = custom_output.get("video_fps_multiplier")
+ if multiplier is not None:
+ return int(multiplier)
+ request_output = getattr(result, "request_output", None)
+ if request_output is not None:
+ custom_output = getattr(request_output, "custom_output", None)
+ if isinstance(custom_output, dict):
+ multiplier = custom_output.get("video_fps_multiplier")
+ if multiplier is not None:
+ return int(multiplier)
+ return 1
+
@staticmethod
def _apply_lora(lora_body: Any, gen_params: OmniDiffusionSamplingParams) -> None:
try:
diff --git a/vllm_omni/entrypoints/openai/video_api_utils.py b/vllm_omni/entrypoints/openai/video_api_utils.py
index 193546979..3fb991225 100644
--- a/vllm_omni/entrypoints/openai/video_api_utils.py
+++ b/vllm_omni/entrypoints/openai/video_api_utils.py
@@ -227,6 +227,9 @@ def _encode_video_bytes(
frames_np *= 255.0
frames_u8 = np.round(frames_np).astype(np.uint8)
+ # Ensure contiguous memory layout for faster PyAV muxing
+ frames_u8 = np.ascontiguousarray(frames_u8)
+
audio_np = _coerce_audio_to_numpy(audio) if audio is not None else None
return mux_video_audio_bytes(
diff --git a/vllm_omni/entrypoints/pd_utils.py b/vllm_omni/entrypoints/pd_utils.py
index 0e3d65f55..413d5d6b4 100644
--- a/vllm_omni/entrypoints/pd_utils.py
+++ b/vllm_omni/entrypoints/pd_utils.py
@@ -23,9 +23,19 @@ logger = logging.getLogger(__name__)
class PDDisaggregationMixin:
"""Mixin supplying PD disaggregation helpers to OmniBase."""
+ def _get_pd_separation_pair(self) -> tuple[int, int] | None:
+ """PD prefill/decode indices when ``_init_pd_state`` ran; else ``None``.
+
+ Partial test doubles may skip ``OmniBase.__init__``; treat missing state as
+ no PD disaggregation instead of raising ``AttributeError``.
+ """
+ return getattr(self, "_pd_separation_pair", None)
+
def _init_pd_state(self) -> None:
"""Initialise PD disaggregation state."""
- self._pd_separation_pair: tuple[int, int] | None = self._detect_pd_separation()
+ self._pd_separation_pair: tuple[int, int] | None = self.detect_pd_separation_from_stage_configs(
+ self.stage_configs
+ )
self._pd_connector_info: dict[str, Any] | None = None
self._pd_kv_params_by_req: dict[str, dict[str, Any]] = {}
self._pd_kv_params_lock = threading.Lock()
@@ -40,11 +50,19 @@ class PDDisaggregationMixin:
d_id,
)
- def _detect_pd_separation(self) -> tuple[int, int] | None:
- """Scan stage_list for a prefill/decode pair. Returns (p_id, d_id) or None."""
+ @staticmethod
+ def detect_pd_separation_from_stage_configs(stage_configs: list[Any]) -> tuple[int, int] | None:
+ """Scan stage configs for a prefill/decode pair.
+
+ Returns:
+ (prefill_idx, decode_idx) if one pair exists, None if not found.
+
+ Raises:
+ ValueError: if multiple candidate PD pairs are found.
+ """
prefill_by_id: dict[int, int] = {}
decode_indices: list[int] = []
- for i, stage in enumerate(self.stage_list):
+ for i, stage in enumerate(stage_configs):
if getattr(stage, "is_prefill_only", False):
prefill_by_id[i] = i
sid = getattr(stage, "stage_id", i)
@@ -55,7 +73,7 @@ class PDDisaggregationMixin:
pd_pairs: list[tuple[int, int]] = []
for j in decode_indices:
- source_ids = getattr(self.stage_list[j], "engine_input_source", [])
+ source_ids = getattr(stage_configs[j], "engine_input_source", [])
for src in source_ids:
if src in prefill_by_id:
pd_pairs.append((prefill_by_id[src], j))
@@ -107,10 +125,11 @@ class PDDisaggregationMixin:
def _validate_pd_separation_config(self) -> None:
"""Validate PD stage configurations are consistent."""
- assert self._pd_separation_pair is not None
- p_id, d_id = self._pd_separation_pair
- p_stage = self.stage_list[p_id]
- d_stage = self.stage_list[d_id]
+ pair = self._get_pd_separation_pair()
+ assert pair is not None
+ p_id, d_id = pair
+ p_stage = self.stage_configs[p_id]
+ d_stage = self.stage_configs[d_id]
def _get_kv_cfg(stage: "OmniStage") -> dict[str, Any]:
ea = stage.engine_args
@@ -158,11 +177,12 @@ class PDDisaggregationMixin:
def _get_pd_connector_info(self) -> dict[str, Any] | None:
"""Extract prefill engine KV connector info."""
- if self._pd_separation_pair is None:
+ pair = self._get_pd_separation_pair()
+ if pair is None:
return None
- p_id, _ = self._pd_separation_pair
- p_stage = self.stage_list[p_id]
+ p_id, _ = pair
+ p_stage = self.stage_configs[p_id]
ea = p_stage.engine_args
kv_cfg = getattr(ea, "kv_transfer_config", None)
@@ -241,18 +261,17 @@ class PDDisaggregationMixin:
def _is_pd_routing(self, stage_id: int, next_stage_id: int) -> bool:
"""True when edge stage_id → next_stage_id is the prefill→decode boundary."""
- return self._pd_separation_pair is not None and self._pd_separation_pair == (
- stage_id,
- next_stage_id,
- )
+ pair = self._get_pd_separation_pair()
+ return pair is not None and pair == (stage_id, next_stage_id)
def _maybe_expand_sampling_params(self, sampling_params_list: list) -> list:
"""Auto-duplicate thinker SP for decode stage when user provides N-1 params."""
- if self._pd_separation_pair is None:
+ pair = self._get_pd_separation_pair()
+ if pair is None:
return sampling_params_list
- if len(sampling_params_list) != len(self.stage_list) - 1:
+ if len(sampling_params_list) != len(self.stage_configs) - 1:
return sampling_params_list
- p_id, d_id = self._pd_separation_pair
+ p_id, d_id = pair
sp_list = list(sampling_params_list)
sp_list.insert(d_id, sp_list[p_id])
return sp_list
diff --git a/vllm_omni/entrypoints/utils.py b/vllm_omni/entrypoints/utils.py
index 84391c2ea..04c0ffe47 100644
--- a/vllm_omni/entrypoints/utils.py
+++ b/vllm_omni/entrypoints/utils.py
@@ -1,3 +1,4 @@
+import argparse
import os
import types
from collections import Counter
@@ -5,10 +6,12 @@ from dataclasses import fields, is_dataclass
from pathlib import Path
from typing import Any, get_args, get_origin
+import yaml
from vllm.logger import init_logger
from vllm.transformers_utils.config import get_config, get_hf_file_to_dict
from vllm.transformers_utils.repo_utils import file_or_path_exists
+from vllm_omni.config.stage_config import StageConfigFactory
from vllm_omni.config.yaml_util import create_config, load_yaml_config, merge_configs
from vllm_omni.entrypoints.stage_utils import _to_dict
from vllm_omni.platforms import current_omni_platform
@@ -23,6 +26,65 @@ _DIFFUSERS_CLASS_TO_CONFIG: dict[str, str] = {
}
+def detect_explicit_cli_keys(
+ argv: list[str],
+ parser: argparse.ArgumentParser | None = None,
+) -> set[str]:
+ """Walk ``argv`` and return the set of ``dest`` attribute names the user
+ explicitly provided (e.g. ``--max-num-seqs 64`` → ``max_num_seqs``).
+
+ Used to distinguish user-typed CLI args from argparse default values so
+ deploy YAMLs are not silently overridden by parser defaults. Shared
+ across online (``vllm serve``) and offline (scripts, examples, tests,
+ CI) entry points — offline callers that parse CLI args via argparse
+ should invoke this on ``sys.argv[1:]`` and pass the result through to
+ ``AsyncOmni`` / ``Omni`` via the ``_cli_explicit_keys`` kwarg.
+
+ When ``parser`` is provided, each token is looked up in the parser's
+ action table to find its real ``dest``. This correctly handles flags
+ with ``dest=`` overrides, alias flags (e.g. ``--usp`` /
+ ``--ulysses-degree`` both mapping to ``ulysses_degree``), and
+ ``--disable-foo`` / ``store_false`` patterns that map to a differently
+ named dest. Callers with access to an ``argparse.ArgumentParser`` should
+ always pass it.
+
+ When ``parser`` is ``None``, a name-based heuristic is used as a
+ fallback (hyphens → underscores, plus a ``no_`` prefix strip for
+ ``argparse.BooleanOptionalAction``). This is correct for simple flags
+ but silently misidentifies ``--disable-X``-style flags and explicit
+ ``dest=`` overrides, so prefer the parser-aware form.
+ """
+ if parser is not None:
+ dest_map: dict[str, str] = {}
+ for action in parser._actions:
+ for opt in action.option_strings:
+ dest_map[opt] = action.dest
+ explicit: set[str] = set()
+ for tok in argv:
+ if not tok.startswith("--"):
+ continue
+ flag = tok.split("=", 1)[0]
+ dest = dest_map.get(flag)
+ if dest is not None:
+ explicit.add(dest)
+ return explicit
+
+ # Fallback: name-based heuristic (legacy path for callers without a parser).
+ explicit = set()
+ for tok in argv:
+ if not tok.startswith("--"):
+ continue
+ name = tok[2:].split("=", 1)[0]
+ if not name:
+ continue
+ attr = name.replace("-", "_")
+ explicit.add(attr)
+ # BooleanOptionalAction: --no-foo records as dest `foo`, not `no_foo`.
+ if attr.startswith("no_"):
+ explicit.add(attr[3:])
+ return explicit
+
+
def inject_omni_kv_config(stage: Any, omni_conn_cfg: dict[str, Any], omni_from: str, omni_to: str) -> None:
"""Inject connector configuration into stage engine arguments."""
# Prepare omni_kv_config dict
@@ -185,22 +247,25 @@ def _convert_dataclasses_to_dict(obj: Any) -> Any:
def _try_resolve_omni_model_type(model: str) -> str | None:
"""Try to resolve model_type for omni models with empty config.json.
- Checks if any registered omni stage config file name matches a substring
- in the model name (e.g. 'cosyvoice3' in 'FunAudioLLM/Fun-CosyVoice3-0.5B-2512').
- When multiple configs match, the longest stem wins to avoid ambiguity
- (e.g. 'bagel_single_stage' over 'bagel').
+ Searches both the legacy ``stage_configs/*.yaml`` directory and the
+ migrated ``deploy/*.yaml`` directory for a stem that substring-matches
+ the model path (e.g. ``cosyvoice3`` in
+ ``FunAudioLLM/Fun-CosyVoice3-0.5B-2512``). The longest match wins so
+ ``cosyvoice3`` beats ``cosyvoice`` and ``bagel_single_stage`` beats
+ ``bagel``.
"""
- stage_configs_dir = PROJECT_ROOT / "vllm_omni" / "model_executor" / "stage_configs"
- if not stage_configs_dir.exists():
- return None
model_lower = model.lower().replace("-", "").replace("_", "")
best_match: str | None = None
best_len = 0
- for config_file in sorted(stage_configs_dir.glob("*.yaml")):
- candidate = config_file.stem.replace("-", "").replace("_", "")
- if candidate in model_lower and len(candidate) > best_len:
- best_match = config_file.stem
- best_len = len(candidate)
+ for subdir in ("model_executor/stage_configs", "deploy"):
+ config_dir = PROJECT_ROOT / "vllm_omni" / subdir
+ if not config_dir.exists():
+ continue
+ for config_file in sorted(config_dir.glob("*.yaml")):
+ candidate = config_file.stem.replace("-", "").replace("_", "")
+ if candidate and candidate in model_lower and len(candidate) > best_len:
+ best_match = config_file.stem
+ best_len = len(candidate)
return best_match
@@ -273,29 +338,59 @@ def resolve_model_config_path(model: str) -> str:
return str(stage_config_path)
-def load_stage_configs_from_model(model: str, base_engine_args: dict | None = None) -> list:
+def load_stage_configs_from_model(
+ model: str,
+ base_engine_args: dict | None = None,
+ deploy_config_path: str | None = None,
+ stage_overrides: dict[str, dict[str, Any]] | None = None,
+ cli_explicit_keys: set[str] | None = None,
+) -> list:
"""Load stage configurations from model's default config file.
- .. deprecated::
- This is the legacy OmegaConf-based loading path. New code should use
- ``StageConfigFactory.create_from_model()`` instead. This function will
- be removed once all callers are migrated (see PR series [2/N]).
+ For models registered in the pipeline registry (new path), uses
+ ``StageConfigFactory.create_from_model()`` which merges
+ PipelineConfig + DeployConfig + CLI overrides.
- Loads stage configurations based on the model type and device type.
- First tries to load a device-specific YAML file from stage_configs/{device_type}/
- directory. If not found, falls back to the default config file.
+ For other models (legacy path), loads stage configs from YAML.
Args:
model: Model name or path (used to determine model_type)
+ base_engine_args: Base engine args to merge as CLI overrides.
+ deploy_config_path: Optional explicit deploy config path.
+ stage_overrides: Per-stage overrides from --stage-overrides.
+ cli_explicit_keys: Set of CLI keys the user actually typed. When
+ provided, only these keys override deploy YAML; argparse defaults
+ stay subordinate to YAML. ``None`` means treat every kwarg as
+ explicit (programmatic ``Omni()`` calls).
Returns:
List of stage configuration dictionaries
-
- Raises:
- FileNotFoundError: If no stage config file exists for the model type
"""
if base_engine_args is None:
base_engine_args = {}
+
+ cli_overrides = _convert_dataclasses_to_dict(dict(base_engine_args))
+ # Per-stage JSON overrides are always explicit (the user typed --stage-overrides).
+ explicit = set(cli_explicit_keys) if cli_explicit_keys is not None else None
+ if stage_overrides:
+ for stage_id_str, overrides in stage_overrides.items():
+ for key, val in overrides.items():
+ stage_key = f"stage_{stage_id_str}_{key}"
+ cli_overrides[stage_key] = val
+ if explicit is not None:
+ explicit.add(stage_key)
+
+ stages = StageConfigFactory.create_from_model(
+ model,
+ cli_overrides=cli_overrides,
+ deploy_config_path=deploy_config_path,
+ cli_explicit_keys=explicit,
+ )
+ if stages is not None:
+ # Convert StageConfig objects to OmegaConf for backward compat
+ return [stage.to_omegaconf() for stage in stages]
+
+ # Legacy fallback: load from YAML
stage_config_path = resolve_model_config_path(model)
if stage_config_path is None:
return []
@@ -312,10 +407,9 @@ def load_stage_configs_from_yaml(
base_engine_args: dict | None = None,
prefer_stage_engine_args: bool = True,
) -> list:
- """Load stage configurations from a YAML file.
+ """Load stage configurations from a YAML file (legacy OmegaConf path).
- .. deprecated::
- Legacy OmegaConf-based loader. Will be removed in PR series [2/N].
+ TODO(@lishunyang12): remove once all models use PipelineConfig + DeployConfig.
Args:
config_path: Path to the YAML configuration file
@@ -449,22 +543,75 @@ def load_and_resolve_stage_configs(
stage_configs_path: str | None,
kwargs: dict | None,
default_stage_cfg_factory: Any = None,
+ deploy_config_path: str | None = None,
+ stage_overrides: dict[str, dict[str, Any]] | None = None,
+ cli_explicit_keys: set[str] | None = None,
) -> tuple[str, list]:
"""Load stage configurations from model or YAML file with fallback to defaults.
Args:
model: Model name or path
- stage_configs_path: Optional path to YAML file containing stage configurations
+ stage_configs_path: Optional path to legacy YAML (stage_args format)
kwargs: Engine arguments to merge with stage configs
default_stage_cfg_factory: Optional callable that takes no args and returns
default stage config list when no configs are found
+ deploy_config_path: Optional path to deploy YAML (new format).
+ Mutually exclusive with ``stage_configs_path``.
+ stage_overrides: Per-stage overrides from ``--stage-overrides`` JSON.
+ Keys are stage_id strings, values are dicts of overrides.
Returns:
Tuple of (config_path, stage_configs)
"""
- if stage_configs_path is None:
+ if stage_configs_path is not None and deploy_config_path is not None:
+ raise ValueError(
+ "--stage-configs-path and --deploy-config are mutually exclusive: "
+ "they use different path resolution rules and loading paths. "
+ "Use --deploy-config for new-format YAMLs (preferred); "
+ "--stage-configs-path is kept only for the legacy `stage_args` format "
+ "and will be removed in a future release."
+ )
+ if stage_configs_path is not None and deploy_config_path is None:
+ if not os.path.exists(stage_configs_path):
+ raise FileNotFoundError(
+ f"--stage-configs-path {stage_configs_path!r} does not exist. "
+ "Legacy `stage_configs/` yamls were replaced by `vllm_omni/deploy/.yaml`; "
+ "use --deploy-config. See docs/configuration/stage_configs.md."
+ )
+ with open(stage_configs_path, encoding="utf-8") as f:
+ _peek = yaml.safe_load(f) or {}
+ if "stages" in _peek and "stage_args" not in _peek:
+ deploy_config_path = stage_configs_path
+ stage_configs_path = None
+ else:
+ logger.warning(
+ "--stage-configs-path is deprecated; migrate %r and use --deploy-config.",
+ stage_configs_path,
+ )
+
+ if deploy_config_path is not None:
+ config_path = deploy_config_path
+ stage_configs = load_stage_configs_from_model(
+ model,
+ base_engine_args=kwargs,
+ deploy_config_path=deploy_config_path,
+ stage_overrides=stage_overrides,
+ cli_explicit_keys=cli_explicit_keys,
+ )
+ if not stage_configs:
+ if default_stage_cfg_factory is not None:
+ default_stage_cfg = default_stage_cfg_factory()
+ stage_configs = create_config(default_stage_cfg)
+ else:
+ stage_configs = []
+ elif stage_configs_path is None:
config_path = resolve_model_config_path(model)
- stage_configs = load_stage_configs_from_model(model, base_engine_args=kwargs)
+ stage_configs = load_stage_configs_from_model(
+ model,
+ base_engine_args=kwargs,
+ stage_overrides=stage_overrides,
+ cli_explicit_keys=cli_explicit_keys,
+ )
if not stage_configs:
if default_stage_cfg_factory is not None:
default_stage_cfg = default_stage_cfg_factory()
diff --git a/vllm_omni/inputs/data.py b/vllm_omni/inputs/data.py
index 9cb6c4433..e4c33a58c 100644
--- a/vllm_omni/inputs/data.py
+++ b/vllm_omni/inputs/data.py
@@ -227,6 +227,10 @@ class OmniDiffusionSamplingParams:
frame_rate: float | None = None # Floating-point rate used by the diffusion model when it differs from `fps`.
height_not_provided: bool = False
width_not_provided: bool = False
+ enable_frame_interpolation: bool = False
+ frame_interpolation_exp: int = 1
+ frame_interpolation_scale: float = 1.0
+ frame_interpolation_model_path: str | None = None
# Timesteps
timesteps: torch.Tensor | None = None
@@ -263,6 +267,10 @@ class OmniDiffusionSamplingParams:
cfg_text_kv_metadata: dict[str, Any] | None = None
cfg_img_kv_metadata: dict[str, Any] | None = None
cfg_kv_request_ids: dict[str, str] | None = None
+ cfg_active_branch: str | None = None
+ cfg_branch_roles: list[str] | None = None
+ cfg_branch_past_key_values: dict[str, Any] | None = None
+ cfg_branch_kv_metadata: dict[str, dict[str, Any]] | None = None
# Component modules
modules: dict[str, Any] = field(default_factory=dict)
diff --git a/vllm_omni/inputs/preprocess.py b/vllm_omni/inputs/preprocess.py
index c6dffd054..cca6ce568 100644
--- a/vllm_omni/inputs/preprocess.py
+++ b/vllm_omni/inputs/preprocess.py
@@ -29,6 +29,8 @@ class OmniInputPreprocessor(InputPreprocessor):
self,
parsed_content: OmniTextPrompt,
tokenization_kwargs: dict[str, Any] | None = None,
+ *,
+ mm_uuids: Any | None = None,
) -> OmniTokenInputs | MultiModalInput:
"""Process text prompts with support for mm_processor_kwargs.
@@ -38,6 +40,10 @@ class OmniInputPreprocessor(InputPreprocessor):
"""
prompt_text = parsed_content["prompt"]
mm_processor_kwargs = parsed_content.get("mm_processor_kwargs") or {}
+ # When the deprecated raw-prompt path is used, process_inputs does
+ # not pass mm_uuids to preprocess(). Fall back to reading it from
+ # the prompt dict so the Renderer's _validate_mm_uuids can see it.
+ effective_mm_uuids = mm_uuids or parsed_content.get("multi_modal_uuids")
inputs: OmniTokenInputs | MultiModalInput
if multi_modal_data := parsed_content.get("multi_modal_data"):
@@ -46,6 +52,7 @@ class OmniInputPreprocessor(InputPreprocessor):
multi_modal_data,
mm_processor_kwargs,
tokenization_kwargs=tokenization_kwargs,
+ mm_uuids=effective_mm_uuids,
)
prompt_embeds = parsed_content.get("prompt_embeds")
if prompt_embeds is not None:
@@ -59,6 +66,7 @@ class OmniInputPreprocessor(InputPreprocessor):
{},
mm_processor_kwargs,
tokenization_kwargs=tokenization_kwargs,
+ mm_uuids=effective_mm_uuids,
)
else:
prompt_token_ids = self._tokenize_prompt(
@@ -142,6 +150,8 @@ class OmniInputPreprocessor(InputPreprocessor):
self,
prompt: SingletonDictPrompt,
tokenization_kwargs: dict[str, Any] | None = None,
+ *,
+ mm_uuids: Any | None = None,
) -> SingletonInput:
"""
Extract the singleton inputs from a prompt.
@@ -166,6 +176,7 @@ class OmniInputPreprocessor(InputPreprocessor):
return self._process_text(
prompt, # type: ignore[arg-type]
tokenization_kwargs=tokenization_kwargs,
+ mm_uuids=mm_uuids,
)
assert_never(prompt) # type: ignore[arg-type]
diff --git a/vllm_omni/model_executor/models/common/__init__.py b/vllm_omni/model_executor/models/common/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/vllm_omni/model_executor/models/common/qwen3_code_predictor.py b/vllm_omni/model_executor/models/common/qwen3_code_predictor.py
new file mode 100644
index 000000000..3a904442f
--- /dev/null
+++ b/vllm_omni/model_executor/models/common/qwen3_code_predictor.py
@@ -0,0 +1,654 @@
+"""Qwen3 Code Predictor -- optimized re-prefill, no KV cache.
+
+Shared by Qwen3-Omni and Qwen3-TTS talker models.
+
+* SDPA attention (F.scaled_dot_product_attention) with native GQA support
+* HF-compatible numerics (float32 RMSNorm, float32 RoPE, separate linear layers)
+* Per-call embedding buffer to avoid cross-request aliasing
+* Pre-allocated position_ids (read-only, safe to persist)
+* torch.compile (epilogue_fusion=False) on inner transformer by default
+* Optional manual CUDA graph capture per batch-size bucket
+* Inline sampling (top-k + top-p) -- no custom op overhead
+"""
+
+from __future__ import annotations
+
+import dataclasses
+from collections.abc import Iterable
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+from vllm.config import VllmConfig
+from vllm.logger import init_logger
+from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding
+from vllm.model_executor.model_loader.weight_utils import default_weight_loader
+
+from vllm_omni.platforms import current_omni_platform
+
+logger = init_logger(__name__)
+
+
+# ===================================================================
+# HF-numerics-compatible layers for code predictor
+# ===================================================================
+#
+# These use plain PyTorch ops (nn.Linear, manual RMSNorm in float32,
+# rotate_half RoPE) to produce outputs numerically identical to the
+# HuggingFace reference. vLLM's fused kernels (RMSNorm, QKVParallel,
+# get_rope) introduce small precision differences that compound across
+# the autoregressive steps of the code predictor, causing severe
+# audio quality degradation.
+#
+# See: https://github.com/vllm-project/vllm-omni/issues/2274
+
+
+class _RMSNorm(nn.Module):
+ """RMSNorm matching HuggingFace's implementation exactly.
+
+ Computes variance in float32 to avoid bfloat16 precision loss.
+ """
+
+ def __init__(self, hidden_size: int, eps: float = 1e-6) -> None:
+ super().__init__()
+ self.weight = nn.Parameter(torch.ones(hidden_size))
+ self.variance_epsilon = eps
+
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ input_dtype = hidden_states.dtype
+ hidden_states = hidden_states.to(torch.float32)
+ variance = hidden_states.pow(2).mean(-1, keepdim=True)
+ hidden_states = hidden_states * torch.rsqrt(variance + self.variance_epsilon)
+ return self.weight * hidden_states.to(input_dtype)
+
+
+def _rotate_half(x: torch.Tensor) -> torch.Tensor:
+ """Rotates half the hidden dims of the input."""
+ x1 = x[..., : x.shape[-1] // 2]
+ x2 = x[..., x.shape[-1] // 2 :]
+ return torch.cat((-x2, x1), dim=-1)
+
+
+class _RotaryEmbedding(nn.Module):
+ """RoPE matching HuggingFace's implementation exactly.
+
+ Forces float32 computation for cos/sin, matching HF's torch.autocast(enabled=False).
+ """
+
+ def __init__(self, config) -> None:
+ super().__init__()
+ head_dim = getattr(
+ config,
+ "head_dim",
+ config.hidden_size // config.num_attention_heads,
+ )
+ rope_theta = getattr(config, "rope_theta", 10000.0)
+ inv_freq = 1.0 / (rope_theta ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim))
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
+
+ def forward(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
+ # position_ids: [batch, seq_len]
+ inv_freq_expanded = self.inv_freq[None, :, None].float().expand(position_ids.shape[0], -1, 1)
+ position_ids_expanded = position_ids[:, None, :].float()
+
+ # Force float32 (matching HF)
+ device_type = x.device.type if isinstance(x.device.type, str) and x.device.type != "mps" else "cpu"
+ with torch.autocast(device_type=device_type, enabled=False):
+ freqs = (inv_freq_expanded.float() @ position_ids_expanded.float()).transpose(1, 2)
+ emb = torch.cat((freqs, freqs), dim=-1)
+ cos = emb.cos()
+ sin = emb.sin()
+
+ return cos.to(dtype=x.dtype), sin.to(dtype=x.dtype)
+
+
+# ===================================================================
+# Attention
+# ===================================================================
+
+
+class CodePredictorAttention(nn.Module):
+ """Multi-head self-attention for code predictor.
+
+ Uses ``F.scaled_dot_product_attention`` with HF-compatible RoPE and RMSNorm.
+ No KV cache -- the code predictor always re-prefills the full (short)
+ sequence each AR step.
+
+ Input : [B, seq_len, hidden_size]
+ Output: [B, seq_len, hidden_size]
+ """
+
+ def __init__(self, config, *, prefix: str = "") -> None:
+ super().__init__()
+ self.num_heads = config.num_attention_heads
+ self.num_kv_heads = config.num_key_value_heads
+ self.head_dim = getattr(
+ config,
+ "head_dim",
+ config.hidden_size // config.num_attention_heads,
+ )
+ self.hidden_size = config.hidden_size
+ self.scaling = self.head_dim**-0.5
+ self._use_gqa = self.num_kv_heads != self.num_heads
+
+ # Separate q/k/v projections matching HF (no fused packing)
+ bias = getattr(config, "attention_bias", False)
+ self.q_proj = nn.Linear(self.hidden_size, self.num_heads * self.head_dim, bias=bias)
+ self.k_proj = nn.Linear(self.hidden_size, self.num_kv_heads * self.head_dim, bias=bias)
+ self.v_proj = nn.Linear(self.hidden_size, self.num_kv_heads * self.head_dim, bias=bias)
+ self.o_proj = nn.Linear(self.num_heads * self.head_dim, self.hidden_size, bias=False)
+ self.q_norm = _RMSNorm(self.head_dim, eps=config.rms_norm_eps)
+ self.k_norm = _RMSNorm(self.head_dim, eps=config.rms_norm_eps)
+
+ def forward(
+ self,
+ hidden_states: torch.Tensor,
+ position_embeddings: tuple[torch.Tensor, torch.Tensor],
+ ) -> torch.Tensor:
+ bsz, seq_len, _ = hidden_states.shape
+ hidden_shape_q = (bsz, seq_len, self.num_heads, self.head_dim)
+ hidden_shape_kv = (bsz, seq_len, self.num_kv_heads, self.head_dim)
+
+ q = self.q_norm(self.q_proj(hidden_states).view(hidden_shape_q)).transpose(1, 2)
+ k = self.k_norm(self.k_proj(hidden_states).view(hidden_shape_kv)).transpose(1, 2)
+ v = self.v_proj(hidden_states).view(hidden_shape_kv).transpose(1, 2)
+
+ cos, sin = position_embeddings
+ # cos/sin are [batch, seq_len, head_dim], need unsqueeze at dim=1 for heads
+ cos = cos.unsqueeze(1) # [batch, 1, seq_len, head_dim]
+ sin = sin.unsqueeze(1)
+ q = (q * cos) + (_rotate_half(q) * sin)
+ k = (k * cos) + (_rotate_half(k) * sin)
+
+ attn_out = F.scaled_dot_product_attention(
+ q,
+ k,
+ v,
+ scale=self.scaling,
+ is_causal=True,
+ enable_gqa=self._use_gqa,
+ )
+
+ attn_out = attn_out.transpose(1, 2).reshape(bsz, seq_len, -1)
+ return self.o_proj(attn_out)
+
+
+# ===================================================================
+# MLP
+# ===================================================================
+
+
+class CodePredictorMLP(nn.Module):
+ """SiLU-gated MLP for code predictor, matching HF's implementation."""
+
+ def __init__(self, config, *, prefix: str = "") -> None:
+ super().__init__()
+ self.gate_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
+ self.up_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
+ self.down_proj = nn.Linear(config.intermediate_size, config.hidden_size, bias=False)
+
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ return self.down_proj(F.silu(self.gate_proj(hidden_states)) * self.up_proj(hidden_states))
+
+
+# ===================================================================
+# Decoder Layer
+# ===================================================================
+
+
+class CodePredictorDecoderLayer(nn.Module):
+ """Transformer decoder layer (SDPA, no KV cache)."""
+
+ def __init__(self, config, *, prefix: str = "") -> None:
+ super().__init__()
+ self.self_attn = CodePredictorAttention(config, prefix=f"{prefix}.self_attn")
+ self.mlp = CodePredictorMLP(config, prefix=f"{prefix}.mlp")
+ self.input_layernorm = _RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
+ self.post_attention_layernorm = _RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
+
+ def forward(
+ self,
+ hidden_states: torch.Tensor,
+ position_embeddings: tuple[torch.Tensor, torch.Tensor],
+ ) -> torch.Tensor:
+ residual = hidden_states
+ hidden_states = self.input_layernorm(hidden_states)
+ hidden_states = self.self_attn(hidden_states, position_embeddings)
+ hidden_states = residual + hidden_states
+
+ residual = hidden_states
+ hidden_states = self.post_attention_layernorm(hidden_states)
+ hidden_states = self.mlp(hidden_states)
+ hidden_states = residual + hidden_states
+ return hidden_states
+
+
+# ===================================================================
+# Base Transformer Model (re-prefill, no KV cache)
+# ===================================================================
+
+
+class CodePredictorBaseModel(nn.Module):
+ """Inner transformer for code predictor.
+
+ Signature: ``forward(inputs_embeds, position_ids) -> hidden_states``
+ """
+
+ def __init__(
+ self,
+ config,
+ *,
+ embedding_dim: int | None = None,
+ use_parallel_embedding: bool = False,
+ prefix: str = "",
+ ) -> None:
+ super().__init__()
+ self.config = config
+
+ emb_dim = int(embedding_dim) if embedding_dim is not None else int(config.hidden_size)
+ if use_parallel_embedding:
+ self.codec_embedding = nn.ModuleList(
+ [VocabParallelEmbedding(config.vocab_size, emb_dim) for _ in range(config.num_code_groups - 1)]
+ )
+ else:
+ self.codec_embedding = nn.ModuleList(
+ [nn.Embedding(config.vocab_size, emb_dim) for _ in range(config.num_code_groups - 1)]
+ )
+
+ self.layers = nn.ModuleList(
+ [
+ CodePredictorDecoderLayer(config, prefix=f"{prefix}.layers.{idx}")
+ for idx in range(config.num_hidden_layers)
+ ]
+ )
+ self.norm = _RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
+ self.rotary_emb = _RotaryEmbedding(config)
+
+ def get_input_embeddings(self) -> nn.ModuleList:
+ return self.codec_embedding
+
+ def forward(
+ self,
+ inputs_embeds: torch.Tensor,
+ position_ids: torch.Tensor,
+ ) -> torch.Tensor:
+ hidden_states = inputs_embeds
+ position_embeddings = self.rotary_emb(hidden_states, position_ids)
+ for layer in self.layers:
+ hidden_states = layer(hidden_states, position_embeddings)
+ hidden_states = self.norm(hidden_states)
+ return hidden_states
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ params_dict = dict(self.named_parameters(remove_duplicate=False))
+ loaded_params: set[str] = set()
+ for name, loaded_weight in weights:
+ if "rotary_emb.inv_freq" in name:
+ continue
+ param = params_dict.get(name)
+ if param is None:
+ continue
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
+ weight_loader(param, loaded_weight)
+ loaded_params.add(name)
+ return loaded_params
+
+
+# ===================================================================
+# Wrapper Configuration
+# ===================================================================
+
+
+@dataclasses.dataclass
+class CodePredictorWrapperConfig:
+ """Controls behavioral differences between model-specific code predictors."""
+
+ use_cuda_graphs: bool = False
+ use_parallel_embedding: bool = False
+ use_projection: bool = False
+ return_proj_buf: bool = False
+ sampling_mode: str = "stored"
+
+
+# ===================================================================
+# Code Predictor Wrapper (optimized re-prefill, persistent buffers)
+# ===================================================================
+
+
+class CodePredictorWrapper(nn.Module):
+ """Optimized code predictor -- re-prefill approach, no KV cache.
+
+ Each AR step forwards the full growing sequence (len 2 -> num_code_groups+1)
+ through the transformer. The extra O(T^2) FLOPs are negligible for
+ short sequences, and this avoids all KV-cache management overhead.
+
+ Optimizations:
+ 1. Per-call embedding buffer -- avoids cross-request aliasing.
+ 2. Pre-allocated position_ids -- no torch.arange per step.
+ 3. Cached module references -- bypass ModuleList indexing.
+ 4. torch.compile on inner transformer.
+ 5. Inline sampling (top-k + top-p) -- no custom op overhead.
+ 6. Optional manual CUDA graph capture per batch-size bucket.
+ """
+
+ def __init__(
+ self,
+ *,
+ vllm_config: VllmConfig,
+ cp_config,
+ wrapper_config: CodePredictorWrapperConfig,
+ talker_hidden_size: int | None = None,
+ prefix: str = "",
+ ) -> None:
+ super().__init__()
+ self._vllm_config = vllm_config
+ self.config = cp_config
+ self._wrapper_config = wrapper_config
+ self.prefix = prefix
+
+ self._num_groups = int(cp_config.num_code_groups)
+ self._cp_hidden = int(cp_config.hidden_size)
+
+ # For Omni backward compat (accessed by the talker)
+ self.num_code_groups = self._num_groups
+
+ # Determine embedding dimension
+ _talker_hidden = int(talker_hidden_size) if talker_hidden_size is not None else self._cp_hidden
+
+ self.model = CodePredictorBaseModel(
+ cp_config,
+ embedding_dim=_talker_hidden,
+ use_parallel_embedding=wrapper_config.use_parallel_embedding,
+ prefix=f"{prefix}.model" if prefix else "model",
+ )
+
+ self.lm_head = nn.ModuleList(
+ [nn.Linear(cp_config.hidden_size, cp_config.vocab_size, bias=False) for _ in range(self._num_groups - 1)]
+ )
+
+ # Projection: Identity when hidden sizes match or not needed
+ if wrapper_config.use_projection and _talker_hidden != self._cp_hidden:
+ self.small_to_mtp_projection = nn.Linear(_talker_hidden, self._cp_hidden, bias=True)
+ else:
+ self.small_to_mtp_projection = nn.Identity()
+
+ # Sampling defaults for "stored" mode
+ self._top_k: int = 50
+ self._top_p: float = 0.8
+
+ # Lazily initialised state
+ self._proj_buf: torch.Tensor | None = None
+ self._model_dtype: torch.dtype | None = None
+ self._compiled_model_fwd = None
+ self._bucket_sizes: list[int] = []
+ self._bucket_pos_ids: dict[int, torch.Tensor] = {}
+ self._lm_heads_list: list[nn.Module] | None = None
+ self._codec_embeds_list: list[nn.Module] | None = None
+ self._cuda_graphs: dict[int, tuple[torch.cuda.CUDAGraph, torch.Tensor]] = {}
+
+ def get_input_embeddings(self) -> nn.ModuleList:
+ return self.model.get_input_embeddings()
+
+ def set_sampling_params(self, top_k: int = 50, top_p: float = 0.8) -> None:
+ """Configure sampling parameters to maintain consistency with previous implementation."""
+ self._top_k = top_k
+ self._top_p = top_p
+ logger.debug("Sampling parameters updated: top_k=%d, top_p=%.2f", top_k, top_p)
+
+ # ------------------------------------------------------------------
+ # Lazy-init helpers
+ # ------------------------------------------------------------------
+
+ def _ensure_buffers(self, device: torch.device, dtype: torch.dtype, bsz: int) -> None:
+ """Ensure the projection buffer can hold at least *bsz* rows."""
+ max_seq = self._num_groups + 1
+ if (
+ self._proj_buf is not None
+ and self._proj_buf.device == device
+ and self._proj_buf.dtype == dtype
+ and self._proj_buf.shape[0] >= bsz
+ ):
+ return
+ self._proj_buf = torch.zeros(bsz, max_seq, self._cp_hidden, dtype=dtype, device=device)
+
+ def _setup_compile(self) -> None:
+ """Lazily set up torch.compile with optional CUDA graph capture."""
+ if self._compiled_model_fwd is not None:
+ return
+
+ # Cache model parameter dtype so forward() doesn't need to query it
+ # on every call. Also ensures warmup buffers match model precision
+ # even when upstream modules produce a different dtype (#2385).
+ self._model_dtype = next(self.model.parameters()).dtype
+ self._lm_heads_list = list(self.lm_head)
+ self._codec_embeds_list = list(self.model.codec_embedding)
+
+ if not current_omni_platform.supports_torch_inductor():
+ logger.warning_once("code_predictor: torch.compile disabled")
+ self._compiled_model_fwd = self.model.forward
+ return
+
+ # torch.compile fuses RMSNorm/RoPE in ways that lose float32
+ # precision, compounding across AR steps. Use epilogue_fusion=False
+ # to disable the problematic fusions while still getting kernel
+ # fusion benefits for the linear layers and SDPA.
+ self._compiled_model_fwd = torch.compile(
+ self.model.forward,
+ dynamic=False,
+ options={"epilogue_fusion": False},
+ )
+ self._warmup_buckets()
+
+ if self._wrapper_config.use_cuda_graphs:
+ self._capture_cuda_graphs()
+ logger.info("code_predictor: torch.compile (no epilogue fusion) + CUDA graphs")
+ else:
+ logger.info("code_predictor: torch.compile (dynamic=False, no epilogue fusion)")
+
+ def _padded_bsz(self, bsz: int) -> int:
+ """Round batch size up to nearest power-of-2 bucket."""
+ for bucket in self._bucket_sizes:
+ if bsz <= bucket:
+ return bucket
+ return bsz
+
+ def _warmup_buckets(self) -> None:
+ """Warmup power-of-2 batch-size buckets to front-load Inductor compilation."""
+ max_bsz = self._vllm_config.scheduler_config.max_num_seqs
+ bucket_sizes = [1 << i for i in range(max_bsz.bit_length()) if (1 << i) <= max_bsz]
+ if max_bsz not in bucket_sizes:
+ bucket_sizes.append(max_bsz)
+ self._bucket_sizes = sorted(bucket_sizes)
+
+ max_seq = self._num_groups + 1
+ device = next(self.model.parameters()).device
+
+ # Ensure proj_buf matches model parameter dtype to avoid dtype
+ # mismatch during warmup compilation (see #2385).
+ self._ensure_buffers(device, self._model_dtype, max(self._bucket_sizes))
+ proj_buf = self._proj_buf
+
+ for bsz in self._bucket_sizes:
+ pos_ids = torch.arange(max_seq, device=device, dtype=torch.long).unsqueeze(0).expand(bsz, -1).contiguous()
+ self._bucket_pos_ids[bsz] = pos_ids
+ for _ in range(3):
+ self._compiled_model_fwd(proj_buf[:bsz, :max_seq, :], pos_ids)
+ logger.info("code_predictor: warmup done for buckets %s", self._bucket_sizes)
+
+ def _capture_cuda_graphs(self) -> None:
+ """Capture a CUDA graph per bucket using vLLM's global graph pool."""
+ from vllm.platforms import current_platform
+
+ pool = current_platform.get_global_graph_pool()
+ max_seq = self._num_groups + 1
+ proj_buf = self._proj_buf
+
+ for bsz in self._bucket_sizes:
+ static_input = proj_buf[:bsz, :max_seq, :]
+ pos_ids = self._bucket_pos_ids[bsz]
+
+ g = torch.cuda.CUDAGraph()
+ with torch.cuda.graph(g, pool=pool):
+ static_output = self._compiled_model_fwd(static_input, pos_ids)
+
+ self._cuda_graphs[bsz] = (g, static_output)
+
+ logger.info("code_predictor: captured CUDA graphs for buckets %s", self._bucket_sizes)
+
+ # ------------------------------------------------------------------
+ # Forward -- re-prefill + inline sampling
+ # ------------------------------------------------------------------
+
+ @torch.inference_mode()
+ def forward(
+ self,
+ layer0_code: torch.Tensor,
+ layer0_embed: torch.Tensor,
+ last_talker_hidden: torch.Tensor,
+ do_sample: bool = True,
+ temperature: float = 0.9,
+ top_k: int = 50,
+ top_p: float = 1.0,
+ ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
+ """Predict residual codebooks 1..G-1 autoregressively via re-prefill."""
+ bsz = int(layer0_code.shape[0])
+ num_groups = self._num_groups
+ device = layer0_code.device
+
+ # _setup_compile caches _model_dtype on first call; use it for buffers
+ # so they always match model weight precision (#2385).
+ self._setup_compile()
+ dtype = self._model_dtype
+
+ padded_bsz = self._padded_bsz(bsz)
+ self._ensure_buffers(device, dtype, padded_bsz)
+
+ proj_buf = self._proj_buf
+ max_seq = num_groups + 1
+ projection = self.small_to_mtp_projection
+ model_fwd = self._compiled_model_fwd
+ lm_heads = self._lm_heads_list
+ codec_embeds = self._codec_embeds_list
+
+ # Zero the padded region of the buffer
+ proj_buf[:padded_bsz].zero_()
+
+ # Fill buffer positions 0 (talker hidden) & 1 (layer0 embed)
+ proj_buf[:bsz, 0, :] = projection(last_talker_hidden.reshape(bsz, 1, -1).to(dtype)).reshape(bsz, -1)
+ proj_buf[:bsz, 1, :] = projection(layer0_embed.reshape(bsz, 1, -1).to(dtype)).reshape(bsz, -1)
+
+ # Get pre-computed pos_ids for this bucket
+ full_pos_ids = self._bucket_pos_ids.get(padded_bsz)
+ if full_pos_ids is None:
+ full_pos_ids = (
+ torch.arange(max_seq, device=device, dtype=torch.long).unsqueeze(0).expand(padded_bsz, -1).contiguous()
+ )
+
+ # Use captured CUDA graph if available, otherwise call compiled fn.
+ cuda_graph_entry = self._cuda_graphs.get(padded_bsz)
+
+ # Prepare sampling parameters
+ stored_mode = self._wrapper_config.sampling_mode == "stored"
+ if stored_mode:
+ s_top_k = self._top_k
+ s_top_p = self._top_p
+ else:
+ use_sampling = do_sample and temperature > 0
+ inv_temperature = 1.0 / max(temperature, 1e-6) if use_sampling else 0.0
+ if use_sampling and top_p != 1.0:
+ raise NotImplementedError(
+ "top_p sampling is not implemented for the vLLM-native code predictor; please set top_p=1.0."
+ )
+
+ # Output codes -- shape depends on return mode
+ if self._wrapper_config.return_proj_buf:
+ all_codes = torch.empty(bsz, num_groups, 1, dtype=torch.int64, device=device)
+ all_codes[:, 0] = layer0_code.reshape(bsz, -1)[:, :1]
+ else:
+ all_codes = torch.empty(bsz, num_groups, dtype=torch.long, device=device)
+ all_codes[:, 0] = layer0_code.reshape(bsz)
+
+ # Autoregressive loop: predict layers 1..G-1
+ for step in range(1, num_groups):
+ # Run transformer (CUDA graph replay or compiled forward)
+ if cuda_graph_entry is not None:
+ cuda_graph_entry[0].replay()
+ hidden_out = cuda_graph_entry[1]
+ else:
+ hidden_out = model_fwd(proj_buf[:padded_bsz, :max_seq, :], full_pos_ids)
+
+ logits = lm_heads[step - 1](hidden_out[:bsz, step, :])
+
+ # Sample next code
+ if stored_mode:
+ # "stored" mode: top-k -> top-p -> softmax -> multinomial
+ if s_top_k > 0:
+ topk_vals, _ = logits.topk(s_top_k, dim=-1)
+ logits = logits.masked_fill(logits < topk_vals[:, -1:], float("-inf"))
+ if s_top_p < 1.0:
+ sorted_logits, sorted_idx = logits.sort(dim=-1, descending=True)
+ sorted_probs = F.softmax(sorted_logits, dim=-1)
+ cumulative_probs = sorted_probs.cumsum(dim=-1)
+ remove_mask = (cumulative_probs - sorted_probs) >= s_top_p
+ sorted_logits[remove_mask] = float("-inf")
+ logits = sorted_logits.scatter(1, sorted_idx, sorted_logits)
+ probs = F.softmax(logits, dim=-1)
+ code = torch.multinomial(probs, num_samples=1)
+ else:
+ # "per_call" mode: temperature-scaled + top-k
+ if use_sampling:
+ scaled = logits * inv_temperature
+ if top_k > 0:
+ topk_vals, _ = scaled.topk(top_k, dim=-1)
+ scaled = scaled.masked_fill(scaled < topk_vals[:, -1:], float("-inf"))
+ probs = F.softmax(scaled, dim=-1)
+ code = torch.multinomial(probs, num_samples=1)
+ else:
+ code = logits.argmax(dim=-1, keepdim=True)
+
+ # Store code
+ if self._wrapper_config.return_proj_buf:
+ all_codes[:, step] = code
+ else:
+ all_codes[:, step] = code.reshape(bsz)
+
+ # Embed predicted code -> project -> next buffer position
+ if step < num_groups - 1 or self._wrapper_config.return_proj_buf:
+ new_embed = codec_embeds[step - 1](code)
+ proj_buf[:bsz, step + 1, :] = projection(new_embed.reshape(bsz, 1, -1)).reshape(bsz, -1)
+
+ if self._wrapper_config.return_proj_buf:
+ return all_codes, proj_buf[:bsz].clone()
+ return all_codes
+
+ # ------------------------------------------------------------------
+ # Weight loading
+ # ------------------------------------------------------------------
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ """Load weights directly (no fused projection remapping needed)."""
+ loaded: set[str] = set()
+ model_weights: list[tuple[str, torch.Tensor]] = []
+ other_weights: list[tuple[str, torch.Tensor]] = []
+
+ for name, w in weights:
+ if "rotary_emb.inv_freq" in name:
+ continue
+ if name.startswith("model."):
+ model_weights.append((name[len("model.") :], w))
+ else:
+ other_weights.append((name, w))
+
+ loaded_model = self.model.load_weights(model_weights)
+ loaded |= {f"model.{n}" for n in loaded_model}
+
+ params = dict(self.named_parameters(remove_duplicate=False))
+ for name, w in other_weights:
+ param = params.get(name)
+ if param is None:
+ continue
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
+ weight_loader(param, w)
+ loaded.add(name)
+
+ return loaded
diff --git a/vllm_omni/model_executor/models/cosyvoice3/pipeline.py b/vllm_omni/model_executor/models/cosyvoice3/pipeline.py
new file mode 100644
index 000000000..4480a0dd8
--- /dev/null
+++ b/vllm_omni/model_executor/models/cosyvoice3/pipeline.py
@@ -0,0 +1,50 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""CosyVoice3 pipeline topology (frozen).
+
+Stage 0: Talker — text prompt → speech tokens (LLM autoregressive).
+Stage 1: Code2Wav — flow-matching decoder → acoustic features → waveform.
+ * ``sync_process_input_func`` runs when ``deploy.async_chunk=false``:
+ stage 1 builds full-sequence flow input via ``text2flow``.
+ * ``async_chunk_process_next_stage_input_func`` runs when
+ ``deploy.async_chunk=true``: stage 0 streams codec chunks to stage 1
+ through the shared-memory connector.
+"""
+
+from vllm_omni.config.stage_config import (
+ PipelineConfig,
+ StageExecutionType,
+ StagePipelineConfig,
+)
+
+_PROC = "vllm_omni.model_executor.stage_input_processors.cosyvoice3"
+
+COSYVOICE3_PIPELINE = PipelineConfig(
+ model_type="cosyvoice3",
+ model_arch="CosyVoice3Model",
+ stages=(
+ StagePipelineConfig(
+ stage_id=0,
+ model_stage="cosyvoice3_talker",
+ execution_type=StageExecutionType.LLM_AR,
+ input_sources=(),
+ owns_tokenizer=True,
+ engine_output_type="latent",
+ async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2code2wav_async_chunk"),
+ sampling_constraints={
+ # merged speech stop token (logsumexp of all 200 stop logits)
+ "stop_token_ids": [6562],
+ },
+ ),
+ StagePipelineConfig(
+ stage_id=1,
+ model_stage="cosyvoice3_code2wav",
+ execution_type=StageExecutionType.LLM_GENERATION,
+ input_sources=(0,),
+ final_output=True,
+ final_output_type="audio",
+ engine_output_type="latent",
+ sync_process_input_func=f"{_PROC}.text2flow",
+ ),
+ ),
+)
diff --git a/vllm_omni/model_executor/models/fish_speech/fish_speech_slow_ar.py b/vllm_omni/model_executor/models/fish_speech/fish_speech_slow_ar.py
index 62776cbb3..45f7afc69 100644
--- a/vllm_omni/model_executor/models/fish_speech/fish_speech_slow_ar.py
+++ b/vllm_omni/model_executor/models/fish_speech/fish_speech_slow_ar.py
@@ -649,6 +649,7 @@ class FishSpeechSlowARForConditionalGeneration(nn.Module):
input_embeds: torch.Tensor,
last_talker_hidden: torch.Tensor,
text_step: torch.Tensor,
+ **kwargs: Any,
) -> tuple[torch.Tensor, torch.Tensor]:
"""GPU fast-path: run Fast AR to predict residual codebook codes.
diff --git a/vllm_omni/model_executor/models/fish_speech/pipeline.py b/vllm_omni/model_executor/models/fish_speech/pipeline.py
new file mode 100644
index 000000000..baee1b5e7
--- /dev/null
+++ b/vllm_omni/model_executor/models/fish_speech/pipeline.py
@@ -0,0 +1,51 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Fish Speech S2 Pro pipeline topology (frozen).
+
+Stage 0: slow_ar — text → RVQ codec tokens (LLM autoregressive).
+Stage 1: dac_decoder — RVQ tokens → audio waveform (LLM_GENERATION).
+
+The HF config top-level reports ``model_type = "fish_qwen3_omni"`` (the
+OmniConfig that bundles slow-AR and fast-AR sub-configs), which is why the
+registry key follows the HF name rather than the human-readable "fish_speech".
+"""
+
+from vllm_omni.config.stage_config import (
+ PipelineConfig,
+ StageExecutionType,
+ StagePipelineConfig,
+)
+
+_PROC = "vllm_omni.model_executor.stage_input_processors.fish_speech"
+
+FISH_SPEECH_PIPELINE = PipelineConfig(
+ model_type="fish_qwen3_omni",
+ model_arch="FishSpeechSlowARForConditionalGeneration",
+ stages=(
+ StagePipelineConfig(
+ stage_id=0,
+ model_stage="fish_speech_slow_ar",
+ execution_type=StageExecutionType.LLM_AR,
+ input_sources=(),
+ owns_tokenizer=True,
+ engine_output_type="latent",
+ async_chunk_process_next_stage_input_func=(f"{_PROC}.slow_ar_to_dac_decoder_async_chunk"),
+ sampling_constraints={
+ "detokenize": False,
+ # <|im_end|> — stop when the model emits end-of-turn.
+ "stop_token_ids": [151645],
+ },
+ ),
+ StagePipelineConfig(
+ stage_id=1,
+ model_stage="dac_decoder",
+ model_arch="FishSpeechDACDecoder",
+ execution_type=StageExecutionType.LLM_GENERATION,
+ input_sources=(0,),
+ final_output=True,
+ final_output_type="audio",
+ engine_output_type="audio",
+ sampling_constraints={"detokenize": True},
+ ),
+ ),
+)
diff --git a/vllm_omni/model_executor/models/fish_speech/prompt_utils.py b/vllm_omni/model_executor/models/fish_speech/prompt_utils.py
index 923e97b63..8b8d8559e 100644
--- a/vllm_omni/model_executor/models/fish_speech/prompt_utils.py
+++ b/vllm_omni/model_executor/models/fish_speech/prompt_utils.py
@@ -38,10 +38,7 @@ def _encode_plain_text(tokenizer: Any, text: str) -> list[int]:
def _encode_control_token(tokenizer: Any, token: str) -> list[int]:
- vocab = tokenizer.get_vocab() if hasattr(tokenizer, "get_vocab") else {}
- token_id = vocab.get(token)
- if token_id is None:
- token_id = tokenizer.convert_tokens_to_ids(token)
+ token_id = tokenizer.convert_tokens_to_ids(token)
if token_id is None or token_id == getattr(tokenizer, "unk_token_id", None):
raise ValueError(f"Fish Speech tokenizer is missing required control token: {token}")
return [int(token_id)]
diff --git a/vllm_omni/model_executor/models/glm_image/glm_image_ar.py b/vllm_omni/model_executor/models/glm_image/glm_image_ar.py
index 31eed9b2c..bf21c01a6 100644
--- a/vllm_omni/model_executor/models/glm_image/glm_image_ar.py
+++ b/vllm_omni/model_executor/models/glm_image/glm_image_ar.py
@@ -21,6 +21,7 @@
# limitations under the License.
"""Inference-only GLM-Image model compatible with HuggingFace weights."""
+import math
import os
from collections.abc import Iterable, Mapping, Sequence
from typing import Annotated, Literal
@@ -127,6 +128,14 @@ class GlmImageDataParser(MultiModalDataParser):
parsers["img2img"] = self._parse_image_data
return parsers
+ def parse_mm_data(self, mm_data, **kwargs):
+ # Normalize "img2img" to "image" so the rest of the pipeline
+ # (mm_hashes, _merge_mm_kwargs) uses a single modality key.
+ normalized = {}
+ for k, v in mm_data.items():
+ normalized["image" if k == "img2img" else k] = v
+ return super().parse_mm_data(normalized, **kwargs)
+
class GlmImageProcessingInfo(BaseProcessingInfo):
"""
@@ -346,6 +355,10 @@ class GlmImageMultiModalProcessor(BaseMultiModalProcessor[GlmImageProcessingInfo
target_h = mm_kwargs.get("target_h", 1024) if mm_kwargs else 1024
target_w = mm_kwargs.get("target_w", 1024) if mm_kwargs else 1024
+ logger.debug(
+ f"_call_hf_processor: target dimensions for generation: {target_h}x{target_w}, mm_kwargs={mm_kwargs}"
+ )
+
if not mm_data or not mm_data.get("images"):
# Text-to-image mode
if processor is not None:
@@ -566,6 +579,58 @@ class GlmImageMultiModalProcessor(BaseMultiModalProcessor[GlmImageProcessingInfo
tensor_type="pt",
)
+ def _apply_hf_processor_text_only(
+ self, prompt_text: str, hf_processor_mm_kwargs: Mapping[str, object], tokenization_kwargs: Mapping[str, object]
+ ) -> list[int]:
+ prompt_ids, _, _ = super()._apply_hf_processor_text_mm(
+ prompt_text=prompt_text,
+ mm_items=MultiModalDataItems({}),
+ hf_processor_mm_kwargs=hf_processor_mm_kwargs,
+ tokenization_kwargs=tokenization_kwargs,
+ )
+ return prompt_ids
+
+ def _build_generation_grids(self, hf_processor_mm_kwargs: Mapping[str, object]) -> torch.Tensor:
+ """Build generation grids for M-RoPE decode positions.
+
+ For GLM-Image generation, decode order is:
+ 1) small preview grid
+ 2) large target grid
+ 3) EOS
+
+ We store grids as [large, small] to match HF processor behavior, and
+ decode logic consumes them in reverse order.
+ """
+
+ target_h = (
+ hf_processor_mm_kwargs.get("target_h") if isinstance(hf_processor_mm_kwargs.get("target_h"), int) else None
+ )
+ target_w = (
+ hf_processor_mm_kwargs.get("target_w") if isinstance(hf_processor_mm_kwargs.get("target_w"), int) else None
+ )
+ if target_h is None or target_w is None:
+ target_h = (
+ hf_processor_mm_kwargs.get("height") if isinstance(hf_processor_mm_kwargs.get("height"), int) else 1024
+ )
+ target_w = (
+ hf_processor_mm_kwargs.get("width") if isinstance(hf_processor_mm_kwargs.get("width"), int) else 1024
+ )
+
+ factor = 32
+ target_h = (target_h // factor) * factor
+ target_w = (target_w // factor) * factor
+ token_h = target_h // factor
+ token_w = target_w // factor
+
+ ratio = token_h / token_w if token_w > 0 else 1.0
+ small_token_h = max(1, int(math.sqrt(ratio) * (factor // 2)))
+ small_token_w = max(1, int(math.sqrt(1 / ratio) * (factor // 2)))
+
+ return torch.tensor(
+ [[1, token_h, token_w], [1, small_token_h, small_token_w]],
+ dtype=torch.long,
+ )
+
def _apply_hf_processor_main(
self,
prompt: str | list[int],
@@ -594,126 +659,145 @@ class GlmImageMultiModalProcessor(BaseMultiModalProcessor[GlmImageProcessingInfo
logger.debug(f"_apply_hf_processor_main: mm_counts={mm_counts}, num_images={num_images}")
- if num_images == 0 or enable_hf_prompt_update:
+ if num_images == 0 and isinstance(prompt, str):
# t2i mode or normal flow - use parent implementation
- return super()._apply_hf_processor_main(
- prompt=prompt,
+ prompt_ids = self._apply_hf_processor_text_only(
+ prompt_text=prompt,
+ hf_processor_mm_kwargs=hf_processor_mm_kwargs,
+ tokenization_kwargs=tokenization_kwargs,
+ )
+ mm_processed_data = self._apply_hf_processor_mm_only(
mm_items=mm_items,
hf_processor_mm_kwargs=hf_processor_mm_kwargs,
tokenization_kwargs=tokenization_kwargs,
- enable_hf_prompt_update=enable_hf_prompt_update,
)
- # i2i mode with enable_hf_prompt_update=False (cache miss scenario)
- # We need to build prompt_ids with image placeholders
- logger.debug(f"_apply_hf_processor_main: i2i mode with enable_hf_prompt_update=False, num_images={num_images}")
+ # t2i has no source images, so mm features cannot provide image_grid_thw.
+ # Provide explicit generation grids for M-RoPE to avoid fallback token parsing
+ # (which can degrade high-resolution spatial positions, e.g. 1920x1920).
+ try:
+ mrope_grid_thw = self._build_generation_grids(hf_processor_mm_kwargs)
+ mm_processed_data["mrope_image_grid_thw"] = mrope_grid_thw
+ logger.info(
+ "_apply_hf_processor_main t2i: mrope_image_grid_thw=%s",
+ mrope_grid_thw.tolist(),
+ )
+ except Exception as e:
+ logger.warning("_apply_hf_processor_main t2i: failed to set mrope_image_grid_thw: %s", e)
- # Get mm data from our overridden _apply_hf_processor_mm_only
- mm_processed_data = self._apply_hf_processor_mm_only(
- mm_items=mm_items,
- hf_processor_mm_kwargs=hf_processor_mm_kwargs,
- tokenization_kwargs=tokenization_kwargs,
+ return prompt_ids, mm_processed_data, False
+
+ # i2i mode: use unified HF processor path only.
+ # This avoids drift between duplicated manual/HF i2i implementations.
+ logger.debug(
+ "_apply_hf_processor_main: i2i mode (enable_hf_prompt_update=%s), num_images=%s",
+ enable_hf_prompt_update,
+ num_images,
)
- # In this path we do NOT call HF apply_chat_template, so we must still
- # provide full grids (source + target) for M-RoPE to compute decode positions.
- # Keep `image_grid_thw` source-only for MM batching/validation.
- try:
- source_grid_thw = mm_processed_data.get("image_grid_thw")
- if source_grid_thw is not None and isinstance(source_grid_thw, torch.Tensor):
- # Compute target grid following HF GlmImageProcessor: factor=32.
- # Prefer explicit target_h/target_w if present, otherwise fall back.
- target_h = (
- hf_processor_mm_kwargs.get("target_h")
- if isinstance(hf_processor_mm_kwargs.get("target_h"), int)
- else None
- )
- target_w = (
- hf_processor_mm_kwargs.get("target_w")
- if isinstance(hf_processor_mm_kwargs.get("target_w"), int)
- else None
- )
- if target_h is None or target_w is None:
- # Some callers pass generation size as height/width.
- target_h = (
- hf_processor_mm_kwargs.get("height")
- if isinstance(hf_processor_mm_kwargs.get("height"), int)
- else 1024
- )
- target_w = (
- hf_processor_mm_kwargs.get("width")
- if isinstance(hf_processor_mm_kwargs.get("width"), int)
- else 1024
- )
+ if not isinstance(prompt, str):
+ # Online OpenAI chat preprocessing can arrive here with tokenized
+ # prompts (list[int]) before serving_chat replaces engine prompt
+ # with the clean text prompt. Do not fail the whole request.
+ logger.warning(
+ "_apply_hf_processor_main i2i: got tokenized prompt type=%s; "
+ "using compatibility path for preprocessing",
+ type(prompt).__name__,
+ )
- factor = 32
- target_h = (target_h // factor) * factor
- target_w = (target_w // factor) * factor
- token_h = target_h // factor
- token_w = target_w // factor
- target_grid = torch.tensor([[1, token_h, token_w]], dtype=source_grid_thw.dtype)
+ prompt_ids = list(prompt)
+ mm_processed_data = self._apply_hf_processor_mm_only(
+ mm_items=mm_items,
+ hf_processor_mm_kwargs=hf_processor_mm_kwargs,
+ tokenization_kwargs=tokenization_kwargs,
+ )
- mm_processed_data["mrope_image_grid_thw"] = torch.cat([source_grid_thw, target_grid], dim=0)
- except Exception:
- # Best-effort only; M-RoPE has additional fallbacks.
- pass
-
- # Build prompt_ids with image placeholders
- # _apply_prompt_updates will replace each [image_token_id] with expanded tokens
- tokenizer = self.info.get_tokenizer()
- image_token_id = tokenizer.convert_tokens_to_ids("<|image|>")
-
- if isinstance(prompt, str):
- # Match HF GlmImageProcessor behavior: append target grid tokens + BOS.
- # This helps M-RoPE/grid parsing and keeps i2i vs t2i behavior aligned.
+ # Preserve full grids for M-RoPE decode (source + target), while
+ # keeping image_grid_thw source-only for MM batching.
try:
- grid_bos = getattr(tokenizer, "grid_bos_token", "")
- grid_eos = getattr(tokenizer, "grid_eos_token", "")
- bos = getattr(tokenizer, "bos_token", "")
-
- # Use the same target sizes we used for mrope grids when available.
- target_h = (
- hf_processor_mm_kwargs.get("target_h")
- if isinstance(hf_processor_mm_kwargs.get("target_h"), int)
- else None
- )
- target_w = (
- hf_processor_mm_kwargs.get("target_w")
- if isinstance(hf_processor_mm_kwargs.get("target_w"), int)
- else None
- )
- if target_h is None or target_w is None:
+ source_grid_thw = mm_processed_data.get("image_grid_thw")
+ if source_grid_thw is not None and isinstance(source_grid_thw, torch.Tensor):
target_h = (
- hf_processor_mm_kwargs.get("height")
- if isinstance(hf_processor_mm_kwargs.get("height"), int)
- else 1024
+ hf_processor_mm_kwargs.get("target_h")
+ if isinstance(hf_processor_mm_kwargs.get("target_h"), int)
+ else None
)
target_w = (
- hf_processor_mm_kwargs.get("width")
- if isinstance(hf_processor_mm_kwargs.get("width"), int)
- else 1024
+ hf_processor_mm_kwargs.get("target_w")
+ if isinstance(hf_processor_mm_kwargs.get("target_w"), int)
+ else None
)
+ if target_h is None or target_w is None:
+ target_h = (
+ hf_processor_mm_kwargs.get("height")
+ if isinstance(hf_processor_mm_kwargs.get("height"), int)
+ else 1024
+ )
+ target_w = (
+ hf_processor_mm_kwargs.get("width")
+ if isinstance(hf_processor_mm_kwargs.get("width"), int)
+ else 1024
+ )
- factor = 32
- target_h = (target_h // factor) * factor
- target_w = (target_w // factor) * factor
- token_h = target_h // factor
- token_w = target_w // factor
-
- expanded_prompt = f"{prompt}{grid_bos}{token_h} {token_w}{grid_eos}{bos}"
- text_ids = tokenizer.encode(expanded_prompt, add_special_tokens=False)
+ factor = 32
+ token_h = max(1, target_h // factor)
+ token_w = max(1, target_w // factor)
+ target_grid = torch.tensor([[1, token_h, token_w]], dtype=source_grid_thw.dtype)
+ mm_processed_data["mrope_image_grid_thw"] = torch.cat([source_grid_thw, target_grid], dim=0)
except Exception:
- text_ids = tokenizer.encode(prompt, add_special_tokens=False)
+ pass
+
+ # Prompt updates will expand image placeholders in this compatibility path.
+ return prompt_ids, mm_processed_data, False
+
+ images = mm_items.get_items("image", ImageProcessorItems)
+ image_list = [images.get(i) for i in range(images.get_count())]
+ if not image_list:
+ raise ValueError("GLM-Image i2i requires at least one source image in mm_items")
+
+ hf_inputs = self._call_hf_processor(
+ prompt=prompt,
+ mm_data={"images": image_list},
+ mm_kwargs=hf_processor_mm_kwargs,
+ tok_kwargs=tokenization_kwargs,
+ )
+
+ input_ids = hf_inputs.get("input_ids")
+ if input_ids is None:
+ raise ValueError("HF i2i processor returned no input_ids")
+
+ if isinstance(input_ids, torch.Tensor):
+ prompt_ids = input_ids[0].tolist() if input_ids.dim() > 1 else input_ids.tolist()
else:
- text_ids = list(prompt)
+ prompt_ids = (
+ input_ids[0]
+ if isinstance(input_ids, list) and input_ids and isinstance(input_ids[0], list)
+ else list(input_ids)
+ )
- # Prepend image placeholders - one per image
- prompt_ids = [image_token_id] * num_images + text_ids
+ mm_processed_data = BatchFeature(dict(), tensor_type="pt")
+ for key in ("pixel_values", "image_grid_thw", "mrope_image_grid_thw"):
+ value = hf_inputs.get(key)
+ if value is not None:
+ mm_processed_data[key] = value
- logger.debug(f"_apply_hf_processor_main: built prompt_ids with {num_images} image placeholders")
+ image_grid_thw = mm_processed_data.get("image_grid_thw")
+ mrope_grid_thw = mm_processed_data.get("mrope_image_grid_thw")
+ hf_config = self.info.get_hf_config()
+ image_token_id = getattr(hf_config, "image_token_id", 167855)
+ image_token_count = prompt_ids.count(image_token_id)
+ logger.info(
+ "_apply_hf_processor_main i2i(HF): num_images=%s, prompt_len=%s, image_token_count=%s, "
+ "source_grid_shape=%s, mrope_grid_shape=%s",
+ num_images,
+ len(prompt_ids),
+ image_token_count,
+ tuple(image_grid_thw.shape) if image_grid_thw is not None else None,
+ tuple(mrope_grid_thw.shape) if mrope_grid_thw is not None else None,
+ )
- # Return is_update_applied=False so _apply_prompt_updates will expand the placeholders
- return prompt_ids, mm_processed_data, False
+ # HF processor already expanded image placeholders in input_ids.
+ return prompt_ids, mm_processed_data, True
def _get_mm_fields_config(
self,
@@ -2667,9 +2751,23 @@ class GlmImageForConditionalGeneration(nn.Module, SupportsMultiModal, SupportsPP
# Input format: "textH Wh w" where =image_start_token_id=16384
# For 1024x1024: H=32, W=32 (large), h=16, w=16 (small preview)
if not image_grid_thw:
+ # Preferred path for t2i: use explicit target size propagated from
+ # serving/request sampling params. This avoids fragile grid parsing
+ # from token IDs and matches HF processor grid construction.
+ target_h = kwargs.get("target_h")
+ target_w = kwargs.get("target_w")
+ if isinstance(target_h, int) and isinstance(target_w, int) and target_h > 0 and target_w > 0:
+ factor = 32
+ token_h = target_h // factor
+ token_w = target_w // factor
+ ratio = token_h / token_w if token_w > 0 else 1.0
+ small_h = max(1, int(math.sqrt(ratio) * (factor // 2)))
+ small_w = max(1, int(math.sqrt(1 / ratio) * (factor // 2)))
+ image_grid_thw = [[1, token_h, token_w], [1, small_h, small_w]]
+
# Try to parse from kwargs (passed from processor)
hf_config_arg = kwargs.get("hf_config")
- if hf_config_arg is not None and hasattr(hf_config_arg, "image_grid_thw"):
+ if (not image_grid_thw) and hf_config_arg is not None and hasattr(hf_config_arg, "image_grid_thw"):
image_grid_thw = hf_config_arg.image_grid_thw
# If still empty, try to infer from input tokens
@@ -2723,19 +2821,29 @@ class GlmImageForConditionalGeneration(nn.Module, SupportsMultiModal, SupportsPP
prompt_ends_with_start = len(input_tokens) > 0 and input_tokens[-1] == image_start_token_id
if prompt_ends_with_start and len(image_grid_thw) == num_source_images and num_source_images > 0:
# i2i mode: source grids exist but no target grids
- # Parse target grids from prompt tokens or use defaults
- parsed_grids = self._parse_grid_from_tokens(input_tokens, hf_config)
- if parsed_grids:
- # parsed_grids contains all grids mentioned in prompt
- # For i2i, add only the generation target grids
- if len(parsed_grids) > num_source_images:
- image_grid_thw = list(image_grid_thw) + parsed_grids[num_source_images:]
- else:
- # Fallback: add default 1024x1024 generation grids (1 target for i2i)
- image_grid_thw = list(image_grid_thw) + [[1, 32, 32]]
+ # Prefer explicit target size propagated from request sampling params.
+ # This avoids fragile grid parsing from token IDs for non-1024 i2i.
+ target_h = kwargs.get("target_h")
+ target_w = kwargs.get("target_w")
+ if isinstance(target_h, int) and isinstance(target_w, int) and target_h > 0 and target_w > 0:
+ factor = 32
+ token_h = target_h // factor
+ token_w = target_w // factor
+ image_grid_thw = list(image_grid_thw) + [[1, token_h, token_w]]
else:
- # Fallback to default 1024x1024 grids for generation
- image_grid_thw = list(image_grid_thw) + [[1, 32, 32]]
+ # Parse target grids from prompt tokens or use defaults
+ parsed_grids = self._parse_grid_from_tokens(input_tokens, hf_config)
+ if parsed_grids:
+ # parsed_grids contains all grids mentioned in prompt
+ # For i2i, add only the generation target grids
+ if len(parsed_grids) > num_source_images:
+ image_grid_thw = list(image_grid_thw) + parsed_grids[num_source_images:]
+ else:
+ # Fallback: add default 1024x1024 generation grid (1 target for i2i)
+ image_grid_thw = list(image_grid_thw) + [[1, 32, 32]]
+ else:
+ # Fallback to default 1024x1024 grid for generation
+ image_grid_thw = list(image_grid_thw) + [[1, 32, 32]]
llm_pos_ids_list: list[torch.Tensor] = []
diff --git a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py
index 5c280ddcf..6304eeab2 100644
--- a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py
+++ b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py
@@ -77,7 +77,9 @@ from vllm.multimodal.processing import (
from vllm.sequence import IntermediateTensors
from vllm.transformers_utils.tokenizer import get_tokenizer
from vllm.utils.tensor_schema import TensorSchema
+from vllm.v1.outputs import SamplerOutput
from vllm.v1.sample.metadata import SamplingMetadata
+from vllm.v1.sample.sampler import Sampler
from vllm_omni.model_executor.models.hunyuan_image3.autoencoder_kl_3d import AutoencoderKLConv3D
from vllm_omni.model_executor.models.hunyuan_image3.siglip2 import LightProjector, Siglip2VisionTransformer
@@ -175,8 +177,11 @@ class HunyuanModel(HunYuanModel):
return True
return False
+ skipped_unexpected: set[str] = set()
+
for name, loaded_weight in weights:
if contains_unexpected_keyword(name, unexpected_keywords):
+ skipped_unexpected.add(name)
continue
if "rotary_emb.inv_freq" in name:
@@ -362,6 +367,17 @@ class HunyuanModel(HunYuanModel):
weight_loader = getattr(param, "weight_loader", default_weight_loader)
weight_loader(param, loaded_weight)
loaded_params.add(name)
+
+ if skipped_unexpected:
+ logger.warning_once(
+ "Skipped %d weights matching unexpected_keywords "
+ "(e.g. vae, vision_model, patch_embed, timestep_emb). "
+ "If upstream renamed components, these may be silently "
+ "lost. Skipped names: %s",
+ len(skipped_unexpected),
+ sorted(skipped_unexpected)[:10],
+ )
+
return loaded_params
@@ -1149,6 +1165,8 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
HunyuanImage3Inputs: TypeAlias = HunyuanImage3PixelInputs
+ prefer_model_sampler = True
+
packed_modules_mapping = {
"qkv_proj": [
"q_proj",
@@ -1199,6 +1217,10 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
else:
self.lm_head = PPMissingLayer()
+ # --- AR-stage components ---
+ # These are needed for image encoding in the AR stage.
+ # If a future text-only stage is added, gate on vllm_config.model_config.model_stage.
+
# vae
self.vae = AutoencoderKLConv3D.from_config(config.vae)
self.patch_embed = UNetDown(
@@ -1226,6 +1248,63 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
self._mrope_joint_img_sep_token_id = tokenizer.convert_tokens_to_ids("")
self._mrope_max_num_patches = config.vit_processor.get("max_num_patches", 729)
+ # Special token IDs for logits processors (stage transitions).
+ # These mirror the official tokenization_hunyuan_image_3.py setup.
+ self._end_of_think_id = tokenizer.convert_tokens_to_ids(" ")
+ self._recaption_id = tokenizer.convert_tokens_to_ids("")
+ self._end_of_recaption_id = tokenizer.convert_tokens_to_ids(" ")
+ self._answer_id = tokenizer.convert_tokens_to_ids("")
+ self._end_of_answer_id = tokenizer.convert_tokens_to_ids(" ")
+ image_base_size = getattr(config, "image_base_size", 1024)
+ self._size_token_id = tokenizer.convert_tokens_to_ids(f"")
+ self._start_ratio_id = tokenizer.convert_tokens_to_ids("")
+ self._end_ratio_id = tokenizer.convert_tokens_to_ids("")
+ ratio_33 = tokenizer.convert_tokens_to_ids("")
+ ratio_36 = tokenizer.convert_tokens_to_ids("")
+ self._ratio_other_slices = [(ratio_33, ratio_36 + 1)]
+ # Build the full set of ratio token IDs for use as stop tokens.
+ self._all_ratio_ids = set(range(self._start_ratio_id, self._end_ratio_id + 1))
+ for s, e in self._ratio_other_slices:
+ self._all_ratio_ids.update(range(s, e))
+
+ # Determine mode: comprehension (I2T/T2T) vs generation (IT2I/T2I).
+ engine_output_type = getattr(vllm_config.model_config, "engine_output_type", None)
+ self._is_comprehension = engine_output_type in (None, "text")
+
+ # For comprehension mode, block image generation tokens but allow
+ # text structure tokens (, , etc.) so the model can
+ # follow its natural generation pattern. Stop tokens in YAML will
+ # terminate at or EOS.
+ self._blocked_token_ids: set[int] = set()
+ if self._is_comprehension:
+ self._blocked_token_ids.update(
+ [
+ self._mrope_boi_token_id, #
+ self._mrope_eoi_token_id, #
+ self._size_token_id, #
+ ]
+ )
+ self._blocked_token_ids.update(self._all_ratio_ids)
+
+ # For generation mode, build stage transition map.
+ # Official logic: → [],
+ # → [, , ]
+ # After , restrict vocab to ratio tokens only.
+ # Stage-transition forced sequences, keyed by trigger token.
+ self._stage_transitions: dict[int, list[int]] = {}
+ if not self._is_comprehension:
+ self._stage_transitions[self._end_of_think_id] = [
+ self._recaption_id,
+ ]
+ self._stage_transitions[self._end_of_recaption_id] = [
+ self._answer_id,
+ self._mrope_boi_token_id,
+ self._size_token_id,
+ ]
+
+ self._sampler: Sampler | None = None
+ self._eos_token_id: int = tokenizer.eos_token_id
+
self._replace_rotary_embeddings()
def _replace_rotary_embeddings(self):
@@ -1257,6 +1336,12 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
head_dim,
rope_theta,
)
+ if replaced == 0:
+ raise RuntimeError(
+ "HunyuanImage3: _replace_rotary_embeddings replaced 0 layers. "
+ "The custom interleaved 2D mRoPE is not active — model outputs "
+ "will be incorrect. Check that model.layers[*].self_attn.rotary_emb exists."
+ )
def _parse_and_validate_image_input(
self,
@@ -1274,6 +1359,10 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
if vit_pixel_values is None or vae_pixel_values is None:
return None
+ # Handle empty batch (e.g., during profiling with 0 images / T2T mode)
+ if vit_pixel_values.numel() == 0 or vae_pixel_values.numel() == 0:
+ return None
+
return HunyuanImage3PixelInputs(
type="pixel_values",
pixel_values={
@@ -1472,6 +1561,112 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
logits = self.logits_processor(self.lm_head, hidden_states)
return logits
+ # ------------------------------------------------------------------
+ # Custom sampler — applies HunyuanImage3-specific logits processors
+ # before the standard sampling step.
+ #
+ # Comprehension (I2T / T2T):
+ # Block generation-specific special tokens so sampling can't
+ # accidentally produce , , ratio tokens, etc.
+ #
+ # Generation (IT2I / T2I think):
+ # 1. _StageTransitionLogitsProcessor — force token sequences at
+ # transition boundaries ( → , etc.)
+ # 2. _ConditionalSliceVocabLogitsProcessor — after ,
+ # restrict vocab to ratio tokens only (greedy).
+ # ------------------------------------------------------------------
+
+ def sample(
+ self,
+ logits: torch.Tensor,
+ sampling_metadata: SamplingMetadata,
+ ) -> SamplerOutput | None:
+ if logits is None or logits.numel() == 0:
+ return None
+
+ if self._sampler is None:
+ self._sampler = Sampler()
+
+ min_score = torch.finfo(logits.dtype).min
+
+ assert logits.shape[0] == 1, f"HunyuanImage3 sampler requires max_num_seqs=1, got batch size {logits.shape[0]}"
+
+ for req_idx in range(logits.shape[0]):
+ decoded_tokens: list[int] = (
+ sampling_metadata.output_token_ids[req_idx] if req_idx < len(sampling_metadata.output_token_ids) else []
+ )
+ last_token = decoded_tokens[-1] if decoded_tokens else -1
+
+ if self._is_comprehension:
+ for tid in self._blocked_token_ids:
+ logits[req_idx, tid] = min_score
+ else:
+ forced = self._get_forced_token(decoded_tokens)
+ if forced is not None:
+ logits[req_idx].fill_(min_score)
+ logits[req_idx, forced] = 0
+ elif last_token == self._size_token_id:
+ self._apply_ratio_restriction(logits, req_idx, min_score)
+ elif last_token in self._all_ratio_ids:
+ logits[req_idx].fill_(min_score)
+ logits[req_idx, self._eos_token_id] = 0
+
+ return self._sampler(logits=logits, sampling_metadata=sampling_metadata)
+
+ def _get_forced_token(self, decoded_tokens: list[int]) -> int | None:
+ """Derive the next forced token from output history (stateless).
+
+ Scans decoded_tokens backwards for the most recent trigger token,
+ then prefix-matches the forced sequence against what followed.
+ Returns the next token to force, or None if the sequence is complete
+ or history has diverged from the expected forced sequence.
+ """
+ for i in range(len(decoded_tokens) - 1, -1, -1):
+ trigger = decoded_tokens[i]
+ if trigger not in self._stage_transitions:
+ continue
+
+ forced_seq = self._stage_transitions[trigger]
+ emitted = decoded_tokens[i + 1 :]
+
+ matched = 0
+ for expected, actual in zip(forced_seq, emitted):
+ if actual != expected:
+ # History diverged from the expected forced sequence.
+ # Stop applying transition forcing for safety.
+ return None
+ matched += 1
+
+ if matched < len(forced_seq):
+ return forced_seq[matched]
+ return None
+
+ return None
+
+ def _apply_ratio_restriction(
+ self,
+ logits: torch.Tensor,
+ req_idx: int,
+ min_score: float,
+ ) -> None:
+ """Port of official _ConditionalSliceVocabLogitsProcessor.__call__.
+
+ After the size token, only allow ratio tokens and pick greedily.
+ """
+ original = logits[req_idx].clone()
+ logits[req_idx].fill_(min_score)
+ # Allow primary ratio range.
+ logits[req_idx, self._start_ratio_id : self._end_ratio_id + 1] = original[
+ self._start_ratio_id : self._end_ratio_id + 1
+ ]
+ # Allow extra ratio slices.
+ for s, e in self._ratio_other_slices:
+ logits[req_idx, s:e] = original[s:e]
+ # Force greedy: keep only the argmax.
+ max_id = logits[req_idx].argmax().item()
+ logits[req_idx].fill_(min_score)
+ logits[req_idx, max_id] = 0
+
def make_empty_intermediate_tensors(
self, batch_size: int, dtype: torch.dtype, device: torch.device
) -> IntermediateTensors:
diff --git a/vllm_omni/model_executor/models/mimo_audio/pipeline.py b/vllm_omni/model_executor/models/mimo_audio/pipeline.py
new file mode 100644
index 000000000..33e78a172
--- /dev/null
+++ b/vllm_omni/model_executor/models/mimo_audio/pipeline.py
@@ -0,0 +1,54 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""MiMo Audio pipeline topology (frozen).
+
+Stage 0: fused thinker+talker — multimodal understanding + text + RVQ codes.
+Stage 1: Code2Wav — RVQ codes → waveform.
+
+MiMoAudioConfig inherits from Qwen2Config, so the HF ``model_type`` field
+reports ``qwen2`` — the registry's model_type-based auto-detect can't
+disambiguate. ``hf_architectures`` lets ``StageConfigFactory`` fall back to
+matching ``hf_config.architectures`` instead.
+"""
+
+from vllm_omni.config.stage_config import (
+ PipelineConfig,
+ StageExecutionType,
+ StagePipelineConfig,
+)
+
+_PROC = "vllm_omni.model_executor.stage_input_processors.mimo_audio"
+
+MIMO_AUDIO_PIPELINE = PipelineConfig(
+ model_type="mimo_audio",
+ # HF ``architectures: ["MiMoAudioModel"]`` is also the registry key in
+ # ``model_executor/models/registry.py``; it resolves to the internal
+ # class ``MiMoAudioForConditionalGeneration`` in ``mimo_audio.py``.
+ model_arch="MiMoAudioModel",
+ hf_architectures=("MiMoAudioModel",),
+ stages=(
+ StagePipelineConfig(
+ stage_id=0,
+ model_stage="fused_thinker_talker",
+ execution_type=StageExecutionType.LLM_AR,
+ input_sources=(),
+ final_output=True,
+ final_output_type="text",
+ owns_tokenizer=True,
+ engine_output_type="latent",
+ async_chunk_process_next_stage_input_func=(f"{_PROC}.llm2code2wav_async_chunk"),
+ sampling_constraints={"detokenize": True},
+ ),
+ StagePipelineConfig(
+ stage_id=1,
+ model_stage="code2wav",
+ execution_type=StageExecutionType.LLM_GENERATION,
+ input_sources=(0,),
+ final_output=True,
+ final_output_type="audio",
+ engine_output_type="audio",
+ sync_process_input_func=f"{_PROC}.llm2code2wav",
+ sampling_constraints={"detokenize": False},
+ ),
+ ),
+)
diff --git a/vllm_omni/model_executor/models/ming_flash_omni/__init__.py b/vllm_omni/model_executor/models/ming_flash_omni/__init__.py
new file mode 100644
index 000000000..d7fa44fd7
--- /dev/null
+++ b/vllm_omni/model_executor/models/ming_flash_omni/__init__.py
@@ -0,0 +1,18 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 The vLLM-Omni team.
+
+from .ming_flash_omni import MingFlashOmniForConditionalGeneration
+from .ming_flash_omni_thinker import (
+ MingFlashOmniThinkerDummyInputsBuilder,
+ MingFlashOmniThinkerForConditionalGeneration,
+ MingFlashOmniThinkerMultiModalProcessor,
+ MingFlashOmniThinkerProcessingInfo,
+)
+
+__all__ = [
+ "MingFlashOmniForConditionalGeneration",
+ "MingFlashOmniThinkerForConditionalGeneration",
+ "MingFlashOmniThinkerProcessingInfo",
+ "MingFlashOmniThinkerMultiModalProcessor",
+ "MingFlashOmniThinkerDummyInputsBuilder",
+]
diff --git a/vllm_omni/model_executor/models/ming_flash_omni/audio_encoder.py b/vllm_omni/model_executor/models/ming_flash_omni/audio_encoder.py
new file mode 100644
index 000000000..6ca199011
--- /dev/null
+++ b/vllm_omni/model_executor/models/ming_flash_omni/audio_encoder.py
@@ -0,0 +1,246 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 The vLLM-Omni team.
+# Copyright 2024 ANT Group and the HuggingFace Inc. team.
+# Copyright (c) 2022 OpenAI
+# Adapted from Ming repository modeling_whisper_encoder.py
+# https://github.com/inclusionAI/Ming
+
+import operator
+from collections.abc import Iterable
+from itertools import accumulate
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+from vllm.logger import init_logger
+from vllm.model_executor.model_loader.weight_utils import default_weight_loader
+
+from vllm_omni.diffusion.attention.backends.utils.fa import HAS_FLASH_ATTN, flash_attn_varlen_func
+from vllm_omni.model_executor.models.whisper_utils import Conv1d, Linear, sinusoids
+
+logger = init_logger(__name__)
+
+
+class MultiHeadAttention(nn.Module):
+ """Multi-head attention with packed sequence support.
+ Adapted from Qwen3-TTS WhisperEncoder.
+ """
+
+ def __init__(self, n_state: int, n_head: int, use_flash_attn: bool = True):
+ super().__init__()
+ self.n_head = n_head
+ self.query = Linear(n_state, n_state)
+ self.key = Linear(n_state, n_state, bias=False)
+ self.value = Linear(n_state, n_state)
+ self.out = Linear(n_state, n_state)
+
+ if use_flash_attn and not HAS_FLASH_ATTN:
+ logger.warning("flash-attn is not available. Fallback to manual PyTorch version")
+ self.use_flash_attn = use_flash_attn and HAS_FLASH_ATTN
+
+ def forward(self, x: torch.Tensor, cu_seqlens: torch.Tensor) -> torch.Tensor:
+ """Forward pass with packed sequence support.
+
+ Args:
+ x: [total_tokens, n_state] packed sequence
+ cu_seqlens: [num_seqs + 1] cumulative sequence lengths, e.g. [0, len1, len1+len2, ...]
+
+ Returns:
+ [total_tokens, n_state] attention output
+ """
+ q = self.query(x)
+ k = self.key(x)
+ v = self.value(x)
+
+ n_ctx, n_state = q.shape
+ head_dim = n_state // self.n_head
+
+ q = q.view(n_ctx, self.n_head, head_dim)
+ k = k.view(n_ctx, self.n_head, head_dim)
+ v = v.view(n_ctx, self.n_head, head_dim)
+
+ # Try flash attention varlen
+ if self.use_flash_attn and cu_seqlens is not None and q.dtype in [torch.float16, torch.bfloat16]:
+ max_seqlen = (cu_seqlens[1:] - cu_seqlens[:-1]).max().item()
+ attn_output = flash_attn_varlen_func(q, k, v, cu_seqlens, cu_seqlens, max_seqlen, max_seqlen)
+ else:
+ attn_output = self._manual_attention(q, k, v, cu_seqlens)
+
+ # Reshape back: [T, H, D] -> [T, H*D]
+ attn_output = attn_output.contiguous().view(n_ctx, n_state)
+ return self.out(attn_output)
+
+ def _manual_attention(
+ self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, cu_seqlens: torch.Tensor
+ ) -> torch.Tensor:
+ """Manual attention for variable-length sequences (fallback)."""
+ _, n_head, head_dim = q.shape
+ scale = head_dim**-0.5
+
+ # Unpack sequences and pad to max length
+ seqlens = (cu_seqlens[1:] - cu_seqlens[:-1]).tolist()
+ batch_size = len(seqlens)
+ max_seqlen = max(seqlens)
+
+ # Create padded tensors
+ q_padded = torch.zeros(batch_size, max_seqlen, n_head, head_dim, dtype=q.dtype, device=q.device)
+ k_padded = torch.zeros_like(q_padded)
+ v_padded = torch.zeros_like(q_padded)
+
+ # Fill with actual sequences
+ for i in range(batch_size):
+ start_idx = cu_seqlens[i]
+ end_idx = cu_seqlens[i + 1]
+ seq_len = seqlens[i]
+ q_padded[i, :seq_len] = q[start_idx:end_idx]
+ k_padded[i, :seq_len] = k[start_idx:end_idx]
+ v_padded[i, :seq_len] = v[start_idx:end_idx]
+
+ # Transpose for attention: [B, H, T, D]
+ q_padded = q_padded.transpose(1, 2)
+ k_padded = k_padded.transpose(1, 2)
+ v_padded = v_padded.transpose(1, 2)
+
+ # Create attention mask for variable lengths: 0 for valid positions, -inf for padding
+ padding_mask = (
+ torch.arange(max_seqlen, device=q.device)[None, :] >= torch.tensor(seqlens, device=q.device)[:, None]
+ )
+ attn_mask = torch.zeros(batch_size, 1, 1, max_seqlen, dtype=q.dtype, device=q.device)
+ attn_mask = attn_mask.masked_fill(padding_mask.unsqueeze(1).unsqueeze(2), -torch.finfo(q.dtype).max)
+
+ # Compute attention
+ attn_scores = torch.matmul(q_padded, k_padded.transpose(-2, -1)) * scale
+ attn_scores = attn_scores + attn_mask
+ attn_weights = F.softmax(attn_scores, dim=-1)
+ context = torch.matmul(attn_weights, v_padded)
+
+ # Transpose back: [B, H, T, D] -> [B, T, H, D]
+ context = context.transpose(1, 2).contiguous()
+ output_packed = torch.cat([context[i, : seqlens[i]] for i in range(batch_size)], dim=0)
+
+ return output_packed
+
+
+class ResidualAttentionBlock(nn.Module):
+ """Whisper-style residual attention block with packed sequence support.
+
+ Adapted from
+ https://github.com/openai/whisper/blob/v20250625/whisper/model.py
+ vllm_omni/model_executor/models/qwen3_tts/tokenizer_25hz/vq/whisper_encoder.py
+ """
+
+ def __init__(self, n_state: int, n_head: int, use_flash_attn: bool = True):
+ super().__init__()
+ self.attn = MultiHeadAttention(n_state, n_head, use_flash_attn=use_flash_attn)
+ self.attn_ln = nn.LayerNorm(n_state)
+
+ n_mlp = n_state * 4
+ self.mlp = nn.Sequential(
+ Linear(n_state, n_mlp),
+ nn.GELU(),
+ Linear(n_mlp, n_state),
+ )
+ self.mlp_ln = nn.LayerNorm(n_state)
+
+ def forward(self, x: torch.Tensor, cu_seqlens: torch.Tensor) -> torch.Tensor:
+ x = x + self.attn(self.attn_ln(x), cu_seqlens=cu_seqlens)
+ x = x + self.mlp(self.mlp_ln(x))
+ return x
+
+
+class WhisperAudioEncoder(nn.Module):
+ """Whisper audio encoder for Ming with packed sequence support.
+
+ Adapted from
+ https://github.com/openai/whisper/blob/v20250625/whisper/model.py
+ vllm_omni/model_executor/models/qwen3_tts/tokenizer_25hz/vq/whisper_encoder.py
+ """
+
+ def __init__(
+ self,
+ n_mels: int = 128,
+ n_ctx: int = 15000,
+ n_state: int = 1280,
+ n_head: int = 20,
+ n_layer: int = 32,
+ use_flash_attn: bool = True,
+ ):
+ super().__init__()
+ self.conv1 = Conv1d(n_mels, n_state, kernel_size=3, padding=1)
+ self.conv2 = Conv1d(n_state, n_state, kernel_size=3, stride=2, padding=1)
+ # self.positional_embedding = nn.Parameter(torch.empty(n_ctx, n_state))
+ self.register_buffer("positional_embedding", sinusoids(n_ctx, n_state))
+ self.blocks = nn.ModuleList(
+ [ResidualAttentionBlock(n_state, n_head, use_flash_attn=use_flash_attn) for _ in range(n_layer)]
+ )
+ self.ln_post = nn.LayerNorm(n_state)
+ self.audio_emb_dim = n_state
+
+ self.n_layer = n_layer
+ self.n_mels = n_mels
+ self.use_flash_attn = use_flash_attn
+
+ def forward(
+ self,
+ x_list: list[torch.Tensor],
+ audio_lens: list[int],
+ ) -> torch.Tensor:
+ """Forward pass with packed sequence format for variable-length inputs.
+
+ Args:
+ x_list: List of [n_mels, T_i] mel spectrogram features for each audio
+ audio_lens: List of original audio lengths in frames
+
+ Returns:
+ [total_T', n_state] packed encoded audio features, where
+ total_T' is the sum of all encoded sequence lengths
+ """
+ # Cast inputs to model dtype
+ target_dtype = self.conv1.weight.dtype
+ x_list = [x.to(target_dtype) for x in x_list]
+
+ encoded_list = []
+ encoded_lens = []
+ for mel_spec in x_list:
+ # mel_spec: [n_mels, T] - process through conv layers
+ x = mel_spec.unsqueeze(0) # [1, n_mels, T]
+ x = F.gelu(self.conv1(x))
+ x = F.gelu(self.conv2(x))
+ x = x.squeeze(0).transpose(0, 1) # [T', n_state]
+
+ # Add positional embedding
+ seq_len = x.shape[0]
+ positional_embedding = self.positional_embedding[:seq_len, :]
+ x = (x + positional_embedding).to(x.dtype)
+
+ encoded_list.append(x)
+ encoded_lens.append(seq_len)
+
+ x_packed = torch.cat(encoded_list, dim=0) # [total_T', n_state]
+
+ cu_seqlens = list(accumulate(encoded_lens, func=operator.add, initial=0))
+ cu_seqlens = torch.tensor(cu_seqlens, device=x_packed.device, dtype=torch.int32)
+
+ for block in self.blocks:
+ x_packed = block(x_packed, cu_seqlens=cu_seqlens)
+
+ x_packed = self.ln_post(x_packed)
+ return x_packed
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ params_dict: dict[str, torch.Tensor] = {
+ **dict(self.named_parameters(remove_duplicate=False)),
+ **dict(self.named_buffers()),
+ }
+ loaded_params: set[str] = set()
+
+ for name, loaded_weight in weights:
+ if name not in params_dict:
+ logger.warning("Skipping unknown audio encoder weight: %s", name)
+ continue
+ param = params_dict[name]
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
+ weight_loader(param, loaded_weight)
+ loaded_params.add(name)
+
+ return loaded_params
diff --git a/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni.py b/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni.py
new file mode 100644
index 000000000..87728890b
--- /dev/null
+++ b/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni.py
@@ -0,0 +1,223 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 The vLLM-Omni team.
+# Copyright 2024 ANT Group and the HuggingFace Inc. team. All rights reserved.
+# Adapted from Ming repository modeling_bailingmm2.py
+# https://github.com/inclusionAI/Ming
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Ming-flash-omni-2.0 unified model (thinker + imagegen + talker)."""
+
+from collections.abc import Iterable
+
+import torch
+import torch.nn as nn
+from vllm.config import VllmConfig
+from vllm.logger import init_logger
+from vllm.model_executor.models.interfaces import (
+ SupportsMRoPE,
+ SupportsMultiModal,
+ SupportsPP,
+)
+from vllm.model_executor.models.module_mapping import MultiModelKeys
+from vllm.model_executor.models.utils import (
+ init_vllm_registered_model,
+ maybe_prefix,
+)
+from vllm.multimodal import MULTIMODAL_REGISTRY
+from vllm.sequence import IntermediateTensors
+
+from vllm_omni.model_executor.custom_process_mixin import CustomProcessMixin
+from vllm_omni.model_executor.models.output_templates import OmniOutput
+from vllm_omni.model_executor.models.utils import add_prefix_to_loaded_weights
+from vllm_omni.transformers_utils.configs.ming_flash_omni import BailingMM2Config, MingFlashOmniConfig
+
+from .ming_flash_omni_thinker import (
+ MingFlashOmniThinkerDummyInputsBuilder,
+ MingFlashOmniThinkerMultiModalProcessor,
+ MingFlashOmniThinkerProcessingInfo,
+)
+
+logger = init_logger(__name__)
+
+
+@MULTIMODAL_REGISTRY.register_processor(
+ MingFlashOmniThinkerMultiModalProcessor,
+ info=MingFlashOmniThinkerProcessingInfo,
+ dummy_inputs=MingFlashOmniThinkerDummyInputsBuilder,
+)
+class MingFlashOmniForConditionalGeneration(
+ nn.Module,
+ SupportsMultiModal,
+ SupportsPP,
+ SupportsMRoPE,
+ CustomProcessMixin,
+):
+ """Unified Ming-flash-omni-2.0 model combining thinker, imagegen, and talker."""
+
+ supports_multimodal = True
+ requires_raw_input_tokens: bool = True
+
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
+ super().__init__()
+ self.have_multimodal_outputs = True
+ self.has_preprocess = False
+ self.has_postprocess = False
+
+ config = vllm_config.model_config.hf_config
+
+ self.vllm_config = vllm_config
+ self.config = config
+
+ if isinstance(config, MingFlashOmniConfig):
+ thinker_config = config.thinker_config
+ else:
+ thinker_config = config
+
+ self.thinker_config: BailingMM2Config = thinker_config
+ self.model_stage = vllm_config.model_config.model_stage
+
+ if self.model_stage == "thinker":
+ thinker_vllm_config = vllm_config.with_hf_config(
+ thinker_config, architectures=["MingFlashOmniThinkerForConditionalGeneration"]
+ )
+ self.thinker = init_vllm_registered_model(
+ vllm_config=thinker_vllm_config,
+ prefix=maybe_prefix(prefix, "thinker"),
+ architectures=["MingFlashOmniThinkerForConditionalGeneration"],
+ )
+ self.model = self.thinker
+ self.imagegen = None
+ self.talker = None
+
+ elif self.model_stage == "imagegen":
+ # TODO: Implement image generator stage
+ raise NotImplementedError(
+ "Image generation stage is not yet implemented. Please use model_stage='thinker' for now."
+ )
+
+ elif self.model_stage == "talker":
+ # TODO: Implement talker (TTS) stage
+ raise NotImplementedError(
+ "Talker (TTS) stage is not yet implemented. Please use model_stage='thinker' for now."
+ )
+
+ else:
+ raise ValueError(
+ f"Invalid model_stage: {self.model_stage}. Must be one of: 'thinker', 'imagegen', 'talker'"
+ )
+
+ # Set up intermediate tensors
+ self.make_empty_intermediate_tensors = (
+ self.thinker.make_empty_intermediate_tensors if self.model_stage == "thinker" else lambda: None
+ )
+
+ def forward(
+ self,
+ input_ids: torch.Tensor,
+ positions: torch.Tensor,
+ intermediate_tensors: IntermediateTensors | None = None,
+ inputs_embeds: torch.Tensor | None = None,
+ **kwargs,
+ ) -> OmniOutput:
+ return self.model.forward(
+ input_ids=input_ids,
+ positions=positions,
+ intermediate_tensors=intermediate_tensors,
+ inputs_embeds=inputs_embeds,
+ **kwargs,
+ )
+
+ def compute_logits(
+ self,
+ hidden_states: torch.Tensor,
+ sampling_metadata=None,
+ ) -> torch.Tensor | None:
+ if hasattr(self.model, "compute_logits"):
+ return self.model.compute_logits(hidden_states, sampling_metadata)
+ return None
+
+ def sample(
+ self,
+ logits: torch.Tensor,
+ sampling_metadata,
+ ):
+ if hasattr(self.model, "sample"):
+ return self.model.sample(logits, sampling_metadata)
+ raise NotImplementedError("sample method not available on current stage")
+
+ def get_mrope_input_positions(self, *args, **kwargs):
+ if hasattr(self.model, "get_mrope_input_positions"):
+ return self.model.get_mrope_input_positions(*args, **kwargs)
+ raise NotImplementedError("get_mrope_input_positions not available on current stage")
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ loaded_weights = set()
+ thinker_weights = []
+ imagegen_weights = []
+ talker_weights = []
+
+ for name, value in weights:
+ if name.startswith("thinker."):
+ thinker_weights.append((name, value))
+ elif name.startswith("imagegen."):
+ imagegen_weights.append((name, value))
+ elif name.startswith("talker."):
+ talker_weights.append((name, value))
+ else:
+ # Weights without prefix go to thinker by default
+ thinker_weights.append((name, value))
+
+ if self.model_stage == "thinker" and thinker_weights:
+ # Remove "thinker." prefix before loading
+ thinker_weights_stripped = [
+ (name.replace("thinker.", "", 1) if name.startswith("thinker.") else name, value)
+ for name, value in thinker_weights
+ ]
+ thinker_loaded = self.thinker.load_weights(thinker_weights_stripped)
+ thinker_loaded = add_prefix_to_loaded_weights(thinker_loaded, "thinker")
+ loaded_weights.update(thinker_loaded)
+
+ # TODO: Load imagegen weights when implemented
+ # TODO: Load talker weights when implemented
+
+ return loaded_weights
+
+ def get_mm_mapping(self) -> MultiModelKeys:
+ return MultiModelKeys.from_string_field(
+ language_model="thinker.language_model",
+ connector=["thinker.linear_proj.", "thinker.linear_proj_audio."],
+ tower_model=["thinker.vision.", "thinker.audio."],
+ )
+
+ @property
+ def sampler(self):
+ if hasattr(self.model, "sampler"):
+ return self.model.sampler
+ return None
+
+ def embed_input_ids(
+ self,
+ input_ids: torch.Tensor,
+ multimodal_embeddings=None,
+ *,
+ is_multimodal=None,
+ ) -> torch.Tensor:
+ return self.model.embed_input_ids(
+ input_ids,
+ multimodal_embeddings,
+ is_multimodal=is_multimodal,
+ )
+
+ def embed_multimodal(self, **kwargs):
+ return self.model.embed_multimodal(**kwargs)
diff --git a/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni_thinker.py b/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni_thinker.py
new file mode 100644
index 000000000..bde7477b9
--- /dev/null
+++ b/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni_thinker.py
@@ -0,0 +1,893 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 The vLLM-Omni team.
+# Copyright 2024 ANT Group and the HuggingFace Inc. team.
+# Adapted from Ming repository modeling_bailingmm2.py and processing_bailingmm2.py
+# https://github.com/inclusionAI/Ming
+
+"""Ming-flash-omni-2.0 Thinker stage implementation (multimodal understanding)."""
+
+from collections.abc import Iterable, Iterator, Mapping, Sequence
+from typing import Annotated, Any
+
+import numpy as np
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+from transformers.feature_extraction_utils import BatchFeature
+from vllm.config import VllmConfig
+from vllm.config.multimodal import BaseDummyOptions
+from vllm.inputs import MultiModalDataDict
+from vllm.logger import init_logger
+from vllm.model_executor.models.interfaces import (
+ MultiModalEmbeddings,
+ SupportsMRoPE,
+ SupportsMultiModal,
+ SupportsPP,
+)
+from vllm.model_executor.models.qwen2_5_vl import (
+ Qwen2_5_VLImageInputs,
+ Qwen2_5_VLImagePixelInputs,
+ Qwen2_5_VLVideoInputs,
+ Qwen2_5_VLVideoPixelInputs,
+)
+from vllm.model_executor.models.qwen2_vl import (
+ Qwen2VLProcessingInfo,
+)
+from vllm.model_executor.models.utils import (
+ AutoWeightsLoader,
+ WeightsMapper,
+ _merge_multimodal_embeddings,
+ maybe_prefix,
+)
+from vllm.multimodal import MULTIMODAL_REGISTRY
+from vllm.multimodal.inputs import (
+ MultiModalFeatureSpec,
+ MultiModalFieldConfig,
+ MultiModalKwargsItems,
+)
+from vllm.multimodal.parse import (
+ AudioProcessorItems,
+ ImageProcessorItems,
+ MultiModalDataItems,
+ MultiModalDataParser,
+ VideoProcessorItems,
+)
+from vllm.multimodal.processing import (
+ BaseDummyInputsBuilder,
+ BaseMultiModalProcessor,
+ PromptReplacement,
+ PromptUpdate,
+ PromptUpdateDetails,
+)
+from vllm.sequence import IntermediateTensors
+from vllm.utils.tensor_schema import TensorSchema, TensorShape
+
+from vllm_omni.model_executor.custom_process_mixin import CustomProcessMixin
+from vllm_omni.model_executor.models.output_templates import OmniOutput
+from vllm_omni.transformers_utils.configs.ming_flash_omni import BailingMM2Config
+from vllm_omni.transformers_utils.processors.ming import (
+ PLACEHOLDER_AUDIO_TOKEN_IN_TEXT,
+ PLACEHOLDER_IMAGE_TOKEN_IN_TEXT,
+ PLACEHOLDER_VIDEO_TOKEN_IN_TEXT,
+ MingFlashOmniProcessor,
+ MingWhisperFeatureExtractor,
+)
+
+from .audio_encoder import WhisperAudioEncoder
+from .modeling_bailing_moe_v2 import BailingMoeV2ForCausalLM
+from .projectors import AudioProjector, VisionProjector
+from .vision_encoder import MingVisionEncoder
+
+logger = init_logger(__name__)
+
+
+class MingAudioInput(TensorSchema):
+ """
+ Dimensions:
+ - b: Batch size
+ - l: Total audio frames (clips concatenated along the time axis)
+ - nm: Number of mel bins
+ - N: Max number of audio clips per batch item
+ """
+
+ audio_feats: Annotated[
+ torch.Tensor,
+ TensorShape("b", "l", "nm"),
+ ]
+
+ audio_feats_lengths: Annotated[
+ torch.Tensor,
+ TensorShape("b", "N"),
+ ]
+
+
+class MingFlashOmniThinkerProcessingInfo(Qwen2VLProcessingInfo):
+ def get_hf_config(self) -> BailingMM2Config:
+ return self.ctx.get_hf_config(BailingMM2Config)
+
+ def get_hf_processor(self, **kwargs: object):
+ return self.ctx.get_hf_processor(MingFlashOmniProcessor, **kwargs)
+
+ def get_target_channels(self) -> int:
+ # See `_normalize_audio_tensor` in vllm_omni/transformers_utils/processors/ming.py
+ return 1
+
+ def get_supported_mm_limits(self) -> Mapping[str, int | None]:
+ return {"image": None, "video": None, "audio": None}
+
+ def get_mm_max_tokens_per_item(
+ self,
+ seq_len: int,
+ mm_counts: Mapping[str, int],
+ ) -> Mapping[str, int]:
+ mm_counts = mm_counts or {}
+ requested_modalities = {m for m, c in mm_counts.items() if c > 0}
+ mm_max_tokens: dict[str, int] = {}
+
+ if requested_modalities & {"image", "video"}:
+ vl_tokens = super().get_mm_max_tokens_per_item(
+ seq_len=seq_len,
+ mm_counts=mm_counts,
+ )
+ mm_max_tokens.update({m: vl_tokens[m] for m in ["image", "video"] if m in requested_modalities})
+
+ if "audio" in requested_modalities:
+ # TODO: consider computing from audio config
+ mm_max_tokens["audio"] = 3000
+
+ return mm_max_tokens
+
+ def get_feature_extractor(self, **kwargs: object) -> MingWhisperFeatureExtractor:
+ hf_processor = self.get_hf_processor(**kwargs)
+ feature_extractor = hf_processor.audio_processor
+ assert isinstance(feature_extractor, MingWhisperFeatureExtractor)
+ return feature_extractor
+
+ def get_data_parser(self):
+ feature_extractor = self.get_feature_extractor()
+ return MultiModalDataParser(
+ target_sr=feature_extractor.sampling_rate,
+ target_channels=self.get_target_channels(),
+ expected_hidden_size=self._get_expected_hidden_size(),
+ )
+
+
+class MingFlashOmniThinkerDummyInputsBuilder(BaseDummyInputsBuilder[MingFlashOmniThinkerProcessingInfo]):
+ def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
+ num_images = mm_counts.get("image", 0)
+ num_videos = mm_counts.get("video", 0)
+ num_audios = mm_counts.get("audio", 0)
+
+ hf_processor = self.info.get_hf_processor()
+
+ audio_token: str = hf_processor.audio_token
+ image_token: str = hf_processor.image_token
+ video_token: str = hf_processor.video_token
+
+ return image_token * num_images + video_token * num_videos + audio_token * num_audios
+
+ def get_dummy_mm_data(
+ self,
+ seq_len: int,
+ mm_counts: Mapping[str, int],
+ mm_options: Mapping[str, BaseDummyOptions] | None = None,
+ ) -> MultiModalDataDict:
+ num_images = mm_counts.get("image", 0)
+ num_videos = mm_counts.get("video", 0)
+ num_audios = mm_counts.get("audio", 0)
+
+ # Default dimensions for dummy data
+ image_width, image_height = 448, 448
+ video_width, video_height = 448, 448
+ num_frames = 8
+ audio_duration = 3.0 # seconds
+ sample_rate = 16000
+
+ audio_length = int(audio_duration * sample_rate)
+
+ mm_data: MultiModalDataDict = {
+ "image": self._get_dummy_images(
+ width=image_width,
+ height=image_height,
+ num_images=num_images,
+ ),
+ "video": self._get_dummy_videos(
+ width=video_width,
+ height=video_height,
+ num_frames=num_frames,
+ num_videos=num_videos,
+ ),
+ "audio": [(np.random.randn(audio_length).astype(np.float32), sample_rate) for _ in range(num_audios)],
+ }
+
+ return mm_data
+
+
+class MingFlashOmniThinkerMultiModalProcessor(BaseMultiModalProcessor[MingFlashOmniThinkerProcessingInfo]):
+ """Multimodal processor for Ming-flash-omni Thinker stage.
+
+ Handles preprocessing of 1) image, 2) video, and 3) audio inputs,
+ and expands placeholder tokens to the correct number of patch tokens.
+ """
+
+ def _get_prompt_updates(
+ self,
+ mm_items: MultiModalDataItems,
+ hf_processor_mm_kwargs: Mapping[str, Any],
+ out_mm_kwargs: MultiModalKwargsItems,
+ ) -> Sequence[PromptUpdate]:
+ tokenizer = self.info.get_tokenizer()
+ # might want to add a fallback to resolve token ids
+ # vocab = tokenizer.get_vocab()
+ thinker_config = self.info.get_hf_config()
+
+ # patch/delimiter token IDs (used in replacement sequences)
+ image_start_token_id = thinker_config.llm_config.image_start_token
+ image_patch_token_id = thinker_config.llm_config.image_patch_token
+ image_end_token_id = thinker_config.llm_config.image_end_token
+
+ video_start_token_id = thinker_config.llm_config.video_start_token
+ frame_patch_token_id = thinker_config.llm_config.video_patch_token
+ video_end_token_id = thinker_config.llm_config.video_end_token
+
+ audio_start_token_id = thinker_config.llm_config.audio_start_token
+ audio_patch_token_id = thinker_config.llm_config.audio_patch_token
+ audio_end_token_id = thinker_config.llm_config.audio_end_token
+
+ vision_config = thinker_config.vision_config
+ spatial_merge_size = vision_config.spatial_merge_size if vision_config else 2
+
+ newline_token_ids: list[int] = tokenizer.encode("\n", add_special_tokens=False)
+
+ out_mm_data = out_mm_kwargs.get_data()
+
+ def get_replacement_image(item_idx: int) -> PromptUpdateDetails:
+ """Generate token sequence for an image."""
+ grid_thw = out_mm_data.get("image_grid_thw")
+ if grid_thw is None:
+ raise ValueError(
+ "image_grid_thw missing from processor output; "
+ "cannot determine image patch count for prompt replacement."
+ )
+ if isinstance(grid_thw, torch.Tensor):
+ thw = grid_thw[item_idx]
+ num_patches = int(thw.prod().item()) // (spatial_merge_size**2)
+ else:
+ thw = grid_thw[item_idx]
+ num_patches = (thw[0] * thw[1] * thw[2]) // (spatial_merge_size**2)
+
+ # Build token sequence: *N \n
+ # the newline token is added in purpose from original model processing
+ tokens: list[int] = []
+ tokens.append(image_start_token_id)
+ tokens.extend([image_patch_token_id] * num_patches)
+ tokens.append(image_end_token_id)
+ # Refer to Ming's BailingMM2Processor._expand_image_tokens
+ # https://github.com/inclusionAI/Ming/blob/3954fcb880ff5e61ff128bcf7f1ec344d46a6fe3/processing_bailingmm2.py
+ tokens.extend(newline_token_ids)
+
+ # Only tokens receive multimodal embeddings
+ return PromptUpdateDetails.select_token_id(tokens, image_patch_token_id)
+
+ def get_replacement_video(item_idx: int) -> PromptUpdateDetails:
+ """Generate token sequence for a video."""
+ grid_thw = out_mm_data.get("video_grid_thw", None)
+ if grid_thw is None:
+ raise ValueError(
+ "video_grid_thw missing from processor output; "
+ "cannot determine video patch count for prompt replacement."
+ )
+ if isinstance(grid_thw, torch.Tensor):
+ thw = grid_thw[item_idx]
+ num_patches = int(thw.prod().item()) // (spatial_merge_size**2)
+ else:
+ thw = grid_thw[item_idx]
+ num_patches = (thw[0] * thw[1] * thw[2]) // (spatial_merge_size**2)
+
+ # Build token sequence: *N \n
+ # the newline token is added in purpose from original model processing
+ tokens: list[int] = []
+ tokens.append(video_start_token_id)
+ tokens.extend([frame_patch_token_id] * num_patches)
+ tokens.append(video_end_token_id)
+ tokens.extend(newline_token_ids)
+
+ # Only tokens receive multimodal embeddings
+ return PromptUpdateDetails.select_token_id(tokens, frame_patch_token_id)
+
+ def get_replacement_audio(item_idx: int) -> PromptUpdateDetails:
+ """Generate token sequence for an audio."""
+ encoder_feats_lengths = out_mm_data.get("encoder_feats_lengths", None)
+ if encoder_feats_lengths is None:
+ raise ValueError(
+ "encoder_feats_lengths missing from processor output; "
+ "cannot determine audio patch count for prompt replacement."
+ )
+ if isinstance(encoder_feats_lengths, torch.Tensor):
+ num_patches = int(encoder_feats_lengths[item_idx].item())
+ else:
+ num_patches = encoder_feats_lengths[item_idx]
+
+ # Build token sequence: *N
+ tokens: list[int] = []
+ tokens.append(audio_start_token_id)
+ tokens.extend([audio_patch_token_id] * num_patches)
+ tokens.append(audio_end_token_id)
+
+ # Only tokens receive multimodal embeddings
+ return PromptUpdateDetails.select_token_id(tokens, audio_patch_token_id)
+
+ # Build prompt updates and process replacement
+ updates: list[PromptUpdate] = []
+
+ if "image" in mm_items and mm_items.get_items("image", ImageProcessorItems):
+ updates.append(
+ PromptReplacement(
+ modality="image",
+ target=PLACEHOLDER_IMAGE_TOKEN_IN_TEXT,
+ replacement=get_replacement_image,
+ )
+ )
+ if "video" in mm_items and mm_items.get_items("video", VideoProcessorItems):
+ updates.append(
+ PromptReplacement(
+ modality="video",
+ target=PLACEHOLDER_VIDEO_TOKEN_IN_TEXT,
+ replacement=get_replacement_video,
+ )
+ )
+ if "audio" in mm_items and mm_items.get_items("audio", AudioProcessorItems):
+ updates.append(
+ PromptReplacement(
+ modality="audio",
+ target=PLACEHOLDER_AUDIO_TOKEN_IN_TEXT,
+ replacement=get_replacement_audio,
+ )
+ )
+ return updates
+
+ def _get_mm_fields_config(
+ self,
+ hf_inputs: BatchFeature,
+ hf_processor_mm_kwargs: Mapping[str, object],
+ ) -> Mapping[str, MultiModalFieldConfig]:
+ config: dict[str, MultiModalFieldConfig] = {}
+
+ # Image fields, pixel_values is flat (concatenated patches from all images)
+ image_grid_thw = hf_inputs.get("image_grid_thw", torch.empty((0, 3)))
+ if "pixel_values" in hf_inputs:
+ image_sizes = image_grid_thw.prod(-1)
+ config["pixel_values"] = MultiModalFieldConfig.flat_from_sizes(
+ "image",
+ image_sizes,
+ )
+ if "image_grid_thw" in hf_inputs:
+ config["image_grid_thw"] = MultiModalFieldConfig.batched("image")
+
+ # Video fields, same flat layout as images
+ video_grid_thw = hf_inputs.get("video_grid_thw", torch.empty((0, 3)))
+ if "pixel_values_videos" in hf_inputs:
+ video_sizes = video_grid_thw.prod(-1)
+ config["pixel_values_videos"] = MultiModalFieldConfig.flat_from_sizes(
+ "video",
+ video_sizes,
+ )
+ if "video_grid_thw" in hf_inputs:
+ config["video_grid_thw"] = MultiModalFieldConfig.batched("video")
+
+ # Audio fields
+ if "audio_feats" in hf_inputs:
+ config["audio_feats"] = MultiModalFieldConfig.batched("audio")
+ if "audio_feats_lengths" in hf_inputs:
+ config["audio_feats_lengths"] = MultiModalFieldConfig.batched("audio")
+ if "encoder_feats_lengths" in hf_inputs:
+ config["encoder_feats_lengths"] = MultiModalFieldConfig.batched("audio")
+ if "placeholder_audio_loc_lens" in hf_inputs:
+ config["placeholder_audio_loc_lens"] = MultiModalFieldConfig.batched("audio")
+
+ return config
+
+ def _hf_processor_applies_updates(
+ self,
+ prompt_text: str,
+ mm_items: MultiModalDataItems,
+ hf_processor_mm_kwargs: Mapping[str, object],
+ tokenization_kwargs: Mapping[str, object],
+ ) -> bool:
+ return False
+
+ def _call_hf_processor(
+ self,
+ prompt: str,
+ mm_data: Mapping[str, object],
+ mm_kwargs: Mapping[str, object],
+ tok_kwargs: Mapping[str, object],
+ ) -> BatchFeature:
+ """Call sub-processors for multimodal inputs and tokenize.
+
+ We call the image/audio sub-processors directly (instead of going
+ through `MingFlashOmniProcessor.__call__`) so that the high-level
+ placeholder tokens remain **unexpanded** in the tokenized output.
+ """
+ hf_processor = self.info.get_hf_processor()
+ tokenizer = self.info.get_tokenizer()
+
+ data: dict[str, object] = {}
+
+ images = mm_data.get("images", None)
+ if images is not None:
+ image_outputs = hf_processor.image_processor(
+ images=images,
+ videos=None,
+ return_tensors="pt",
+ )
+ data.update(image_outputs)
+
+ videos = mm_data.get("videos", None)
+ if videos is not None:
+ video_outputs = hf_processor.image_processor(
+ images=None,
+ videos=videos,
+ return_tensors="pt",
+ )
+ # Rename keys to distinguish from images
+ if "pixel_values" in video_outputs:
+ video_outputs["pixel_values_videos"] = video_outputs.pop("pixel_values")
+ if "image_grid_thw" in video_outputs:
+ video_outputs["video_grid_thw"] = video_outputs.pop("image_grid_thw")
+ data.update(video_outputs)
+
+ audios = mm_data.get("audios", None)
+ if audios is not None:
+ # vLLM's AudioProcessorItems provides raw numpy arrays (already resampled).
+ # MingWhisperAudioProcessor expects (waveform, sr) tuples,
+ # so wrap them with the target sample rate.
+ target_sr = hf_processor.audio_processor.sampling_rate
+ audio_tuples = [(a, target_sr) if not isinstance(a, tuple) else a for a in audios]
+
+ audio_outputs = hf_processor.audio_processor(
+ audio_tuples,
+ return_tensors="pt",
+ )
+ data.update(audio_outputs)
+
+ # Tokenize text with placeholders still intact
+ text_outputs = tokenizer(prompt, return_tensors="pt", **tok_kwargs)
+ data.update(text_outputs)
+
+ return BatchFeature(data=data)
+
+
+@MULTIMODAL_REGISTRY.register_processor(
+ MingFlashOmniThinkerMultiModalProcessor,
+ info=MingFlashOmniThinkerProcessingInfo,
+ dummy_inputs=MingFlashOmniThinkerDummyInputsBuilder,
+)
+class MingFlashOmniThinkerForConditionalGeneration(
+ nn.Module,
+ SupportsMultiModal,
+ SupportsPP,
+ SupportsMRoPE,
+ CustomProcessMixin,
+):
+ """Ming Thinker stage: multimodal understanding
+ (text + image + video + audio) -> text generation.
+ """
+
+ hf_to_vllm_mapper = WeightsMapper(
+ orig_to_new_prefix={"model.": "language_model."},
+ )
+
+ @classmethod
+ def get_placeholder_str(cls, modality: str, i: int) -> str | None:
+ # vllm_omni/transformers_utils/processors/ming.py
+ if modality.startswith("image"):
+ return ""
+ elif modality.startswith("video"):
+ return ""
+ elif modality.startswith("audio"):
+ return ""
+
+ raise ValueError("Only image, video, or audio modality is supported")
+
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
+ super().__init__()
+
+ config = vllm_config.model_config.hf_config
+
+ thinker_config: BailingMM2Config = config
+ if (
+ thinker_config.llm_config is None
+ or thinker_config.vision_config is None
+ or thinker_config.audio_config is None
+ ):
+ raise ValueError(
+ "MingFlashOmniThinker requires `llm_config`, `vision_config`, and `audio_config` in `thinker_config`."
+ )
+
+ llm_config = thinker_config.llm_config
+
+ self.config = llm_config
+ self.thinker_config = thinker_config
+ self.have_multimodal_outputs = True
+
+ # Initialize LLM as a component
+ with self._mark_language_model(vllm_config):
+ llm_vllm_config = vllm_config.with_hf_config(llm_config)
+ self.language_model = BailingMoeV2ForCausalLM(
+ vllm_config=llm_vllm_config, prefix=maybe_prefix(prefix, "llm")
+ )
+
+ # Ming thinker is inherently multimodal; initialize both towers eagerly.
+ with self._mark_tower_model(vllm_config, {"image", "video"}):
+ self.vision = MingVisionEncoder(
+ vision_config=thinker_config.vision_config,
+ quant_config=vllm_config.quant_config,
+ prefix=maybe_prefix(prefix, "vision"),
+ )
+ self.linear_proj = VisionProjector(
+ vision_dim=self.vision.image_emb_dim,
+ llm_dim=llm_config.hidden_size,
+ mlp_depth=getattr(thinker_config, "mlp_depth", 2),
+ )
+ logger.info("Initialized MingVisionEncoder and VisionProjector")
+
+ audio_cfg = thinker_config.audio_config
+ whisper_cfg = getattr(audio_cfg, "whisper_encoder_config", {}) or {}
+ with self._mark_tower_model(vllm_config, "audio"):
+ self.audio = WhisperAudioEncoder(
+ **whisper_cfg,
+ use_flash_attn=True,
+ )
+ self.linear_proj_audio = AudioProjector(
+ audio_dim=self.audio.audio_emb_dim,
+ llm_dim=llm_config.hidden_size,
+ ds_kernel_size=getattr(audio_cfg, "ds_kernel_size", 3),
+ ds_stride=getattr(audio_cfg, "ds_stride", 2),
+ mlp_depth=getattr(thinker_config, "mlp_depth", 1),
+ )
+ logger.info("Initialized WhisperAudioEncoder and AudioProjector")
+
+ # Expose interfaces
+ self.make_empty_intermediate_tensors = self.language_model.make_empty_intermediate_tensors
+
+ logger.info("MingFlashOmniThinker initialized with vision and audio towers")
+
+ def extract_image_feature(self, pixel_values: torch.Tensor, grid_thw: torch.Tensor) -> torch.Tensor:
+ """Extract and project image features.
+
+ Args:
+ pixel_values: Flattened pixel values from vision processor.
+ grid_thw: [num_images, 3] tensor of (t, h, w) grid dimensions.
+
+ Returns:
+ [seq_len, hidden_size] L2-normalized image embeddings.
+ """
+ if self.vision is None:
+ raise ValueError("Vision encoder not initialized")
+
+ with torch.amp.autocast(pixel_values.device.type, dtype=torch.bfloat16):
+ image_embeds = self.vision(pixel_values, grid_thw=grid_thw)
+
+ if self.vision.use_deepstack:
+ image_embeds = image_embeds[:, : self.vision.image_emb_dim]
+
+ image_embeds = self.linear_proj(image_embeds)
+ image_embeds = F.normalize(image_embeds, dim=-1)
+ return image_embeds
+
+ def extract_audio_feature(
+ self, audio_feats: torch.Tensor, audio_feats_lengths: torch.Tensor
+ ) -> tuple[torch.Tensor, ...]:
+ """Extract and project audio features.
+
+ Args:
+ audio_feats: [B, L_total, n_mels] wrapped mel features — multiple audio
+ clips per batch item are concatenated along the time dimension
+ (time-first, as produced by MingWhisperFeatureExtractor).
+ audio_feats_lengths: [B, N] lengths of each audio clip per batch item.
+ N is the max number of clips per item; zero-padded entries are skipped.
+
+ Returns:
+ Tuple of per-clip [T'_i, hidden_size] projected audio embeddings.
+ """
+ if self.audio is None:
+ raise ValueError("Audio encoder not initialized")
+
+ # Unwrap packed [B, L_total, n_mels] into a list of [n_mels, T'_i] tensors,
+ # one per audio clip, as expected by WhisperAudioEncoder.
+ x_list: list[torch.Tensor] = []
+ audio_lens: list[int] = []
+ for i in range(audio_feats_lengths.shape[0]):
+ feat_index = 0
+ for j in range(audio_feats_lengths.shape[1]):
+ feat_len = int(audio_feats_lengths[i, j].item())
+ if feat_len == 0:
+ break
+ mel_seg = audio_feats[i, feat_index : feat_index + feat_len].transpose(0, 1)
+ x_list.append(mel_seg)
+ audio_lens.append(feat_len)
+ feat_index += feat_len
+
+ audio_packed = self.audio(x_list, audio_lens)
+
+ # Compute per-clip lengths after Whisper Conv1d (kernel=3, stride=2, pad=1)
+ encoded_lens = [(audio_len - 3 + 2) // 2 + 1 for audio_len in audio_lens]
+
+ # Project packed
+ proj_packed, proj_lens = self.linear_proj_audio.forward_packed(audio_packed, encoded_lens)
+
+ normalize = getattr(self.thinker_config.audio_config, "norm_query_embeds", False)
+ if normalize:
+ proj_packed = F.normalize(proj_packed, dim=-1)
+
+ proj_packed = proj_packed.to(audio_feats.dtype)
+
+ # Split into per-clip tensors
+ return proj_packed.split(proj_lens)
+
+ def _parse_and_validate_multimodal_inputs(self, **kwargs: object) -> dict:
+ """Parse and validate multimodal kwargs into per-modality dicts."""
+ mm_input_by_modality: dict[str, Qwen2_5_VLImageInputs | Qwen2_5_VLVideoInputs | MingAudioInput] = {}
+
+ for key in kwargs:
+ if key == "pixel_values" and "image" not in mm_input_by_modality:
+ pixel_values = kwargs.get("pixel_values")
+ image_grid_thw = kwargs.get("image_grid_thw")
+ if pixel_values is not None and image_grid_thw is not None:
+ mm_input_by_modality["image"] = Qwen2_5_VLImagePixelInputs(
+ type="pixel_values",
+ pixel_values=pixel_values, # type: ignore[arg-type]
+ image_grid_thw=image_grid_thw, # type: ignore[arg-type]
+ )
+ elif key == "pixel_values_videos" and "video" not in mm_input_by_modality:
+ pixel_values_videos = kwargs.get("pixel_values_videos")
+ video_grid_thw = kwargs.get("video_grid_thw")
+ second_per_grid_ts = kwargs.get("second_per_grid_ts")
+ if pixel_values_videos is not None and video_grid_thw is not None:
+ mm_input_by_modality["video"] = Qwen2_5_VLVideoPixelInputs(
+ type="pixel_values_videos",
+ pixel_values_videos=pixel_values_videos, # type: ignore[arg-type]
+ video_grid_thw=video_grid_thw, # type: ignore[arg-type]
+ second_per_grid_ts=second_per_grid_ts, # type: ignore[arg-type]
+ )
+ elif key == "audio_feats" and "audio" not in mm_input_by_modality:
+ audio_feats = kwargs.get("audio_feats")
+ audio_feats_lengths = kwargs.get("audio_feats_lengths")
+ if audio_feats is not None and audio_feats_lengths is not None:
+ mm_input_by_modality["audio"] = MingAudioInput(
+ audio_feats=audio_feats, # type: ignore[arg-type]
+ audio_feats_lengths=audio_feats_lengths, # type: ignore[arg-type]
+ )
+
+ return mm_input_by_modality
+
+ def _process_image_input(self, image_input: Qwen2_5_VLImageInputs) -> list[torch.Tensor]:
+ # Splits the flat [total_tokens, D] output of extract_image_feature
+ # into one tensor per image.
+ pixel_values = image_input["pixel_values"]
+ image_grid_thw = image_input["image_grid_thw"]
+ image_embeds = self.extract_image_feature(pixel_values, image_grid_thw)
+ merge_unit = self.thinker_config.vision_config.spatial_merge_size**2
+ sizes = (image_grid_thw.prod(dim=-1) // merge_unit).tolist()
+ return list(image_embeds.split([int(s) for s in sizes], dim=0))
+
+ def _process_video_input(self, video_input: Qwen2_5_VLVideoInputs) -> list[torch.Tensor]:
+ pixel_values_videos = video_input["pixel_values_videos"]
+ video_grid_thw = video_input["video_grid_thw"]
+ video_embeds = self.extract_image_feature(pixel_values_videos, video_grid_thw)
+ merge_unit = self.thinker_config.vision_config.spatial_merge_size**2
+ sizes = (video_grid_thw.prod(dim=-1) // merge_unit).tolist()
+ return list(video_embeds.split([int(s) for s in sizes], dim=0))
+
+ def _process_audio_input(self, audio_input: MingAudioInput) -> list[torch.Tensor]:
+ return list(self.extract_audio_feature(audio_input["audio_feats"], audio_input["audio_feats_lengths"]))
+
+ def _compute_modality_masks(self, input_ids: torch.Tensor) -> tuple[torch.Tensor | None, torch.Tensor | None]:
+ """Compute vision and audio MoE-routing masks from input_ids.
+
+ Returns:
+ Tuple of (vision_mask, audio_mask), each shape [seq_len] bool.
+ """
+ llm_config = self.config
+
+ # vision mask
+ vision_mask = torch.zeros_like(input_ids, dtype=torch.bool)
+ image_token = llm_config.image_patch_token
+ video_token = llm_config.video_patch_token
+ vision_mask = vision_mask | (input_ids == image_token)
+ vision_mask = vision_mask | (input_ids == video_token)
+
+ # audio mask
+ audio_token = llm_config.audio_patch_token
+ audio_mask = input_ids == audio_token
+
+ return vision_mask, audio_mask
+
+ def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings:
+ mm_input_by_modality = self._parse_and_validate_multimodal_inputs(**kwargs)
+ if not mm_input_by_modality:
+ return []
+
+ # preserve the order of modalities
+ multimodal_embeddings: tuple[torch.Tensor, ...] = ()
+
+ for modality, mm_input in mm_input_by_modality.items():
+ if modality == "image":
+ multimodal_embeddings += tuple(self._process_image_input(mm_input)) # type: ignore[arg-type]
+ elif modality == "video":
+ multimodal_embeddings += tuple(self._process_video_input(mm_input)) # type: ignore[arg-type]
+ elif modality == "audio":
+ multimodal_embeddings += tuple(self._process_audio_input(mm_input)) # type: ignore[arg-type]
+
+ return multimodal_embeddings
+
+ def embed_input_ids(
+ self,
+ input_ids: torch.Tensor,
+ multimodal_embeddings: MultiModalEmbeddings | None = None,
+ *,
+ is_multimodal: torch.Tensor | None = None,
+ handle_oov_mm_token: bool = False,
+ ) -> torch.Tensor:
+ inputs_embeds = self.language_model.model.word_embeddings(input_ids)
+
+ if multimodal_embeddings is None or len(multimodal_embeddings) == 0:
+ return inputs_embeds
+
+ assert is_multimodal is not None, "`is_multimodal` mask required when `multimodal_embeddings` provided"
+ return _merge_multimodal_embeddings(
+ inputs_embeds=inputs_embeds,
+ multimodal_embeddings=multimodal_embeddings,
+ is_multimodal=is_multimodal,
+ )
+
+ def forward(
+ self,
+ input_ids: torch.Tensor,
+ positions: torch.Tensor,
+ intermediate_tensors: IntermediateTensors | None = None,
+ inputs_embeds: torch.Tensor | None = None,
+ **kwargs,
+ ) -> OmniOutput:
+ # Compute MoE modality masks on every device
+ image_mask, audio_mask = self._compute_modality_masks(input_ids)
+ hidden_states = self.language_model.forward(
+ input_ids=input_ids,
+ positions=positions,
+ intermediate_tensors=intermediate_tensors,
+ inputs_embeds=inputs_embeds,
+ image_mask=image_mask,
+ audio_mask=audio_mask,
+ )
+
+ # Capture embeddings for downstream stages
+ multimodal_outputs = {
+ "final_hidden_states": hidden_states,
+ }
+
+ return OmniOutput(
+ text_hidden_states=hidden_states,
+ multimodal_outputs=multimodal_outputs,
+ )
+
+ def compute_logits(self, hidden_states: torch.Tensor, sampling_metadata) -> torch.Tensor | None:
+ return self.language_model.compute_logits(hidden_states, sampling_metadata)
+
+ def sample(self, logits: torch.Tensor, sampling_metadata):
+ return self.language_model.sample(logits, sampling_metadata)
+
+ @property
+ def sampler(self):
+ return self.language_model.sampler
+
+ def iter_mm_features(
+ self,
+ mm_features: list[MultiModalFeatureSpec],
+ ) -> Iterator[tuple[int, str, dict[str, Any]]]:
+ """Iterate over image/video features sorted by token position.
+
+ Yields: (offset, modality, feature_data) where feature_data contains:
+ - image: {"grid_t", "grid_h", "grid_w", "second_per_grid_t"}
+ - video: {"grid_t", "grid_h", "grid_w", "second_per_grid_t"}
+
+ Audio features are not yielded: Ming assigns them sequential
+ text positions (same T/H/W value) rather than 3D grid positions.
+ """
+ spatial_merge_size = self.config.spatial_merge_size
+
+ for mm_feature in sorted(mm_features, key=lambda f: f.mm_position.offset):
+ if mm_feature.data is None:
+ continue
+
+ offset = mm_feature.mm_position.offset
+ modality = mm_feature.modality
+
+ if modality == "image":
+ t, h, w = mm_feature.data["image_grid_thw"].data.tolist()
+ yield (
+ offset,
+ "image",
+ {
+ "grid_t": int(t),
+ "grid_h": int(h) // spatial_merge_size,
+ "grid_w": int(w) // spatial_merge_size,
+ "second_per_grid_t": 0.0,
+ },
+ )
+ elif modality == "video":
+ t, h, w = mm_feature.data["video_grid_thw"].data.tolist()
+ second_per_grid_t = 1.0
+ spgt_field = mm_feature.data.get("second_per_grid_ts")
+ if spgt_field is not None:
+ second_per_grid_t = float(spgt_field.data.item())
+ yield (
+ offset,
+ "video",
+ {
+ "grid_t": int(t),
+ "grid_h": int(h) // spatial_merge_size,
+ "grid_w": int(w) // spatial_merge_size,
+ "second_per_grid_t": second_per_grid_t,
+ },
+ )
+
+ def get_mrope_input_positions(
+ self,
+ input_tokens: list[int],
+ mm_features: list[MultiModalFeatureSpec] | None = None,
+ **kwargs: object,
+ ) -> tuple[torch.Tensor, int]:
+ """Compute M-RoPE input positions using mm_features directly."""
+ llm_config = self.config
+ tokens_per_second: int = getattr(llm_config, "tokens_per_second", 2)
+ seq_len = len(input_tokens)
+
+ llm_pos_ids_list: list[np.ndarray] = []
+ st = 0 # index of next unprocessed token
+
+ for patch_offset, _modality, data in self.iter_mm_features(mm_features or []):
+ text_len = patch_offset - st
+ st_idx = int(llm_pos_ids_list[-1].max()) + 1 if llm_pos_ids_list else 0
+ if text_len > 0:
+ llm_pos_ids_list.append(np.broadcast_to(np.arange(text_len), (3, text_len)) + st_idx)
+ st_idx += text_len
+
+ # 3-D grid positions for patch tokens
+ grid_t: int = data["grid_t"]
+ grid_h: int = data["grid_h"]
+ grid_w: int = data["grid_w"]
+ second_per_grid_t: float = data["second_per_grid_t"]
+
+ t_raw = np.arange(grid_t)
+ if second_per_grid_t > 0:
+ t_index = (t_raw * second_per_grid_t * tokens_per_second).astype(np.int64)
+ else:
+ t_index = t_raw.astype(np.int64)
+ t_index = np.repeat(t_index, grid_h * grid_w)
+
+ h_index = np.tile(np.arange(grid_h).repeat(grid_w), grid_t)
+ w_index = np.tile(np.arange(grid_w), grid_t * grid_h)
+
+ llm_pos_ids_list.append(np.stack([t_index, h_index, w_index]) + st_idx)
+
+ num_patches = grid_t * grid_h * grid_w
+ st = patch_offset + num_patches
+
+ if st < seq_len:
+ st_idx = int(llm_pos_ids_list[-1].max()) + 1 if llm_pos_ids_list else 0
+ tail_len = seq_len - st
+ llm_pos_ids_list.append(np.broadcast_to(np.arange(tail_len), (3, tail_len)) + st_idx)
+
+ if llm_pos_ids_list:
+ position_ids = torch.from_numpy(np.concatenate(llm_pos_ids_list, axis=1).astype(np.int64)) # (3, seq_len)
+ else:
+ # text-only, simple sequential positions
+ position_ids = torch.arange(seq_len, dtype=torch.long).unsqueeze(0).expand(3, -1)
+
+ mrope_position_delta = int(position_ids.max().item()) + 1 - seq_len
+ return position_ids, mrope_position_delta
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ loader = AutoWeightsLoader(self)
+ return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
diff --git a/vllm_omni/model_executor/models/ming_flash_omni/modeling_bailing_moe_v2.py b/vllm_omni/model_executor/models/ming_flash_omni/modeling_bailing_moe_v2.py
new file mode 100644
index 000000000..1ff362c5b
--- /dev/null
+++ b/vllm_omni/model_executor/models/ming_flash_omni/modeling_bailing_moe_v2.py
@@ -0,0 +1,896 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 The vLLM-Omni team.
+# Copyright 2023 Antgroup and The HuggingFace Inc. team. All rights reserved.
+# Adapted from Ming
+# https://github.com/inclusionAI/Ming/blob/2a0c02ae3130190160c215f89fce7de3005db483/modeling_bailing_moe_v2.py
+#
+# This code is based on EleutherAI's GPT-NeoX library and the GPT-NeoX
+# and OPT implementations in this library. It has been modified from its
+# original forms to accommodate minor architectural differences compared
+# to GPT-NeoX and OPT used by the Meta AI team that trained the model.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from collections.abc import Iterable
+
+import torch
+from torch import nn
+from vllm.compilation.decorators import support_torch_compile
+from vllm.config import VllmConfig
+from vllm.config.cache import CacheConfig
+from vllm.distributed import get_pp_group, get_tensor_model_parallel_world_size
+from vllm.logger import init_logger
+from vllm.model_executor.layers.activation import SiluAndMul
+from vllm.model_executor.layers.attention import Attention
+from vllm.model_executor.layers.fused_moe import FusedMoE, SharedFusedMoE
+from vllm.model_executor.layers.layernorm import RMSNorm
+from vllm.model_executor.layers.linear import (
+ MergedColumnParallelLinear,
+ QKVParallelLinear,
+ ReplicatedLinear,
+ RowParallelLinear,
+)
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
+from vllm.model_executor.layers.quantization import QuantizationConfig
+from vllm.model_executor.layers.rotary_embedding import get_rope
+from vllm.model_executor.layers.rotary_embedding.mrope import MRotaryEmbedding
+from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead, VocabParallelEmbedding
+from vllm.model_executor.model_loader.weight_utils import default_weight_loader
+from vllm.model_executor.models.utils import (
+ PPMissingLayer,
+ WeightsMapper,
+ make_empty_intermediate_tensors_factory,
+ make_layers,
+ maybe_prefix,
+)
+from vllm.sequence import IntermediateTensors
+from vllm.v1.outputs import SamplerOutput
+from vllm.v1.sample.sampler import Sampler
+
+from vllm_omni.model_executor.custom_process_mixin import CustomProcessMixin
+from vllm_omni.transformers_utils.configs.ming_flash_omni import BailingMoeV2Config
+
+logger = init_logger(__name__)
+
+
+class MingVideoRopeMRotaryEmbedding(MRotaryEmbedding):
+ """MRotaryEmbedding with Ming's video_rope cos/sin interleaving.
+
+ Unlike standard mrope which maps contiguous frequency sections to T/H/W,
+ video_rope alternates H/W frequencies element-wise in the spatial section
+ and places temporal frequencies at the end:
+ Standard mrope: [T T T ... H H H ... W W W ...]
+ Video rope: [H W H W ... H W ... T T T ...]
+
+ Refer to Ming's BailingMoeV2RotaryEmbedding3D
+ https://github.com/inclusionAI/Ming/blob/2a0c02ae3130190160c215f89fce7de3005db483/modeling_bailing_moe_v2.py#L174
+ """
+
+ def _remap_video_rope(
+ self,
+ cos: torch.Tensor,
+ sin: torch.Tensor,
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ """Remap 3D cos/sin to video_rope interleaved layout.
+
+ Args:
+ cos, sin: [3, num_tokens, rotary_dim // 2]
+ Returns:
+ cos, sin: [num_tokens, rotary_dim // 2]
+
+ Refer to Ming's apply_3d_rotary_pos_emb
+ https://github.com/inclusionAI/Ming/blob/2a0c02ae3130190160c215f89fce7de3005db483/modeling_bailing_moe_v2.py#L226
+ """
+ assert self.mrope_section is not None
+ hw_size = self.mrope_section[1] + self.mrope_section[2]
+
+ result_cos = torch.empty_like(cos[0])
+ result_sin = torch.empty_like(sin[0])
+
+ # Spatial frequencies: even indices from H (dim 1), odd from W (dim 2)
+ result_cos[:, 0:hw_size:2] = cos[1, :, 0:hw_size:2]
+ result_cos[:, 1:hw_size:2] = cos[2, :, 1:hw_size:2]
+ result_sin[:, 0:hw_size:2] = sin[1, :, 0:hw_size:2]
+ result_sin[:, 1:hw_size:2] = sin[2, :, 1:hw_size:2]
+
+ # Temporal frequencies at the end
+ result_cos[:, hw_size:] = cos[0, :, hw_size:]
+ result_sin[:, hw_size:] = sin[0, :, hw_size:]
+
+ return result_cos, result_sin
+
+ def forward_native(
+ self,
+ positions: torch.Tensor,
+ query: torch.Tensor,
+ key: torch.Tensor | None = None,
+ offsets: torch.Tensor | None = None,
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
+ assert positions.ndim == 1 or positions.ndim == 2
+ assert key is not None
+
+ cos_sin_cache = self._match_cos_sin_cache_dtype(query)
+ num_tokens = positions.shape[-1]
+ cos_sin = cos_sin_cache[positions]
+ cos, sin = cos_sin.chunk(2, dim=-1)
+
+ if positions.ndim == 2:
+ cos, sin = self._remap_video_rope(cos, sin)
+
+ query_shape = query.shape
+ query = query.view(num_tokens, -1, self.head_size)
+ query_rot = query[..., : self.rotary_dim]
+ query_pass = query[..., self.rotary_dim :]
+ query_rot = self.apply_rotary_emb.forward_native(query_rot, cos, sin)
+ query = torch.cat((query_rot, query_pass), dim=-1).reshape(query_shape)
+
+ key_shape = key.shape
+ key = key.view(num_tokens, -1, self.head_size)
+ key_rot = key[..., : self.rotary_dim]
+ key_pass = key[..., self.rotary_dim :]
+ key_rot = self.apply_rotary_emb.forward_native(key_rot, cos, sin)
+ key = torch.cat((key_rot, key_pass), dim=-1).reshape(key_shape)
+
+ return query, key
+
+ def forward_cuda(
+ self,
+ positions: torch.Tensor,
+ query: torch.Tensor,
+ key: torch.Tensor | None = None,
+ offsets: torch.Tensor | None = None,
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
+ # No custom Triton kernel for video_rope; fall back to native for 3D
+ # TODO: Consider custom optimization
+ if positions.ndim == 2:
+ return self.forward_native(positions, query, key, offsets)
+ return super().forward_cuda(positions, query, key, offsets)
+
+ def forward_cpu(
+ self,
+ positions: torch.Tensor,
+ query: torch.Tensor,
+ key: torch.Tensor | None = None,
+ offsets: torch.Tensor | None = None,
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
+ return self.forward_native(positions, query, key, offsets)
+
+
+class BailingMoeV2MLP(nn.Module):
+ def __init__(
+ self,
+ config: BailingMoeV2Config,
+ intermediate_size: int,
+ hidden_act: str = "silu",
+ quant_config: QuantizationConfig | None = None,
+ reduce_results: bool = True,
+ prefix: str = "",
+ ):
+ super().__init__()
+ self.config = config
+ self.hidden_size = config.hidden_size
+ self.intermediate_size = intermediate_size
+
+ self.gate_up_proj = MergedColumnParallelLinear(
+ self.hidden_size,
+ [self.intermediate_size] * 2,
+ bias=False,
+ quant_config=quant_config,
+ prefix=f"{prefix}.gate_up_proj",
+ )
+ self.down_proj = RowParallelLinear(
+ self.intermediate_size,
+ self.hidden_size,
+ bias=False,
+ quant_config=quant_config,
+ reduce_results=reduce_results,
+ prefix=f"{prefix}.down_proj",
+ )
+
+ if hidden_act != "silu":
+ raise ValueError(f"Unsupported activation: {hidden_act}")
+ self.act_fn = SiluAndMul()
+
+ def forward(self, x):
+ gate_up, _ = self.gate_up_proj(x)
+ x = self.act_fn(gate_up)
+ x, _ = self.down_proj(x)
+ return x
+
+
+class BailingMoeV2Gate(nn.Module):
+ """MoE routing gate with grouped expert selection."""
+
+ def __init__(
+ self,
+ config: BailingMoeV2Config,
+ quant_config: QuantizationConfig | None = None,
+ prefix: str = "",
+ ):
+ super().__init__()
+ self.config = config
+ self.top_k = config.num_experts_per_tok
+ self.num_experts = config.num_experts
+
+ self.n_group = config.n_group
+ self.topk_group = config.topk_group
+
+ self.gating_dim = config.hidden_size
+
+ self.gate = ReplicatedLinear(
+ self.gating_dim,
+ self.num_experts,
+ bias=False,
+ quant_config=quant_config,
+ prefix=f"{prefix}.gate",
+ )
+
+ self.routed_scaling_factor = config.routed_scaling_factor
+
+ self.expert_bias = nn.Parameter(torch.zeros(self.num_experts), requires_grad=False)
+
+ def group_limited_topk(self, scores: torch.Tensor):
+ """Group-limited top-k selection for expert routing."""
+ num_tokens, _ = scores.size()
+ # Organize experts into groups
+ group_scores = scores.view(num_tokens, self.n_group, -1).topk(2, dim=-1)[0].sum(dim=-1)
+ group_idx = torch.topk(group_scores, k=self.topk_group, dim=-1, sorted=False)[1]
+ group_mask = torch.zeros_like(group_scores)
+ group_mask.scatter_(1, group_idx, 1)
+
+ # Mask experts based on selected groups
+ score_mask = (
+ group_mask.unsqueeze(-1)
+ .expand(num_tokens, self.n_group, self.num_experts // self.n_group)
+ .reshape(num_tokens, -1)
+ )
+
+ masked_scores = scores.masked_fill(~score_mask.bool(), float("-inf"))
+ probs, top_indices = torch.topk(masked_scores, k=self.top_k, dim=-1, sorted=False)
+
+ return probs, top_indices
+
+ def forward(self, hidden_states):
+ # compute gating score
+ hidden_states = hidden_states.view(-1, hidden_states.shape[-1])
+ logits, _ = self.gate(hidden_states)
+
+ logits = logits.float()
+ scores = torch.sigmoid(logits)
+
+ scores_for_routing = scores + self.expert_bias
+ _, topk_idx = self.group_limited_topk(scores_for_routing)
+
+ scores = torch.gather(scores, dim=1, index=topk_idx).type_as(logits)
+
+ topk_weight = scores / (scores.sum(dim=-1, keepdim=True) + 1e-20) if self.top_k > 1 else scores
+ topk_weight = topk_weight * self.routed_scaling_factor
+
+ return topk_idx, topk_weight, logits
+
+
+def _unpack_multi_routing(
+ hidden_states: torch.Tensor,
+ gating_output: torch.Tensor,
+ topk: int,
+ renormalize: bool,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """Stateless routing function that unpacks pre-computed routing results.
+
+ Used as `custom_routing_function` for `FusedMoE`. The caller is expected
+ to pack (topk_weight, topk_idx) into `gating_output` before
+ calling FusedMoE.forward(), and this function unpacks them.
+
+ Args:
+ gating_output: [num_tokens, top_k * 2]
+ - [:, :top_k], topk_weight (float)
+ - [:, top_k:], topk_idx (float, cast back to int)
+ """
+ topk_weight = gating_output[:, :topk].contiguous()
+ topk_idx = gating_output[:, topk:]
+ return topk_weight.to(torch.float32), topk_idx.to(torch.int32)
+
+
+class BailingMoeV2SparseMoeBlock(nn.Module):
+ """Sparse MoE block with MultiRouter support for multimodal routing.
+
+ Keep the custom multi-router gating logic external.
+ """
+
+ def __init__(
+ self,
+ config: BailingMoeV2Config,
+ quant_config: QuantizationConfig | None = None,
+ prefix: str = "",
+ ):
+ super().__init__()
+ self.config = config
+ self.tp_size = get_tensor_model_parallel_world_size()
+ self.num_experts_per_tok = config.num_experts_per_tok
+
+ if isinstance(self.config.num_shared_experts, int) and self.config.num_shared_experts > 0:
+ self.shared_experts = BailingMoeV2MLP(
+ config=self.config,
+ intermediate_size=self.config.moe_intermediate_size * self.config.num_shared_experts,
+ quant_config=quant_config,
+ reduce_results=False,
+ prefix=f"{prefix}.shared_experts",
+ )
+ else:
+ self.shared_experts = None
+
+ self.experts = SharedFusedMoE(
+ shared_experts=self.shared_experts,
+ num_experts=config.num_experts,
+ top_k=config.num_experts_per_tok,
+ hidden_size=config.hidden_size,
+ intermediate_size=config.moe_intermediate_size,
+ custom_routing_function=_unpack_multi_routing,
+ renormalize=False, # we handle normalization in the gate
+ reduce_results=True,
+ quant_config=quant_config,
+ prefix=f"{prefix}.experts",
+ )
+
+ self.experts.expert_mapping = FusedMoE.make_expert_params_mapping(
+ self.experts,
+ ckpt_gate_proj_name="gate_proj",
+ ckpt_down_proj_name="down_proj",
+ ckpt_up_proj_name="up_proj",
+ num_experts=config.num_experts,
+ )
+
+ self.router_type = self.config.router_type
+ if self.router_type == "topN":
+ self.gate = BailingMoeV2Gate(self.config, quant_config, prefix=f"{prefix}.gate")
+ elif self.router_type == "MultiRouter":
+ self.gate = BailingMoeV2Gate(self.config, quant_config, prefix=f"{prefix}.gate")
+ self.image_gate = BailingMoeV2Gate(self.config, quant_config, prefix=f"{prefix}.image_gate")
+ self.audio_gate = BailingMoeV2Gate(self.config, quant_config, prefix=f"{prefix}.audio_gate")
+ else:
+ raise ValueError(f"Unsupported router_type: {self.router_type}")
+
+ @staticmethod
+ def _normalize_mask(
+ mask: torch.Tensor,
+ bsz: int,
+ seq_len: int,
+ name: str,
+ ) -> torch.Tensor:
+ """Validate and reshape a modality mask to [bsz*seq_len, 1] bool."""
+ N = bsz * seq_len
+ if mask.ndim == 1:
+ # vLLM path: flat tokens [N]
+ assert mask.shape[0] == N, f"{name} length {mask.shape[0]} != N={N}"
+ elif mask.ndim == 2:
+ assert mask.shape == (bsz, seq_len), f"{name} shape {mask.shape} != ({bsz}, {seq_len})"
+ elif mask.ndim == 3:
+ assert mask.shape == (bsz, seq_len, 1), f"{name} shape {mask.shape} != ({bsz}, {seq_len}, 1)"
+ else:
+ raise ValueError(f"Unsupported {name} shape: {mask.shape}")
+
+ return mask.reshape(N, 1).bool()
+
+ def forward(self, hidden_states, image_mask: torch.Tensor, audio_mask: torch.Tensor):
+ # TODO(yuanheng-zhao): revise the shapes in the flow
+ assert 2 <= hidden_states.dim() <= 3, f"{self.__class__.__name__} only supports 2D or 3D inputs"
+ input_is_2d = hidden_states.ndim == 2
+ if input_is_2d:
+ hidden_states = hidden_states.unsqueeze(0)
+
+ bsz, seq_len, h = hidden_states.shape
+
+ if self.router_type == "MultiRouter":
+ image_mask = self._normalize_mask(image_mask, bsz, seq_len, "image_mask").to(hidden_states.device)
+ audio_mask = self._normalize_mask(audio_mask, bsz, seq_len, "audio_mask").to(hidden_states.device)
+
+ # if image_mask is not None and audio_mask is not None:
+ # assert torch.logical_and(image_mask, audio_mask).sum() == 0
+
+ image_topk_idx, image_topk_weight, _ = self.image_gate(hidden_states)
+ audio_topk_idx, audio_topk_weight, _ = self.audio_gate(hidden_states)
+ topk_idx, topk_weight, _ = self.gate(hidden_states)
+
+ topk_idx = torch.where(image_mask, image_topk_idx, topk_idx)
+ topk_weight = torch.where(image_mask, image_topk_weight, topk_weight)
+ topk_idx = torch.where(audio_mask, audio_topk_idx, topk_idx)
+ topk_weight = torch.where(audio_mask, audio_topk_weight, topk_weight)
+ else:
+ topk_idx, topk_weight, _ = self.gate(hidden_states)
+
+ # Pack pre-computed routing into a single tensor
+ packed_routing = torch.cat(
+ [
+ topk_weight.to(hidden_states.dtype),
+ topk_idx.to(hidden_states.dtype),
+ ],
+ dim=-1,
+ )
+
+ # SharedFusedMoE expects 2D hidden_states
+ hidden_states_2d = hidden_states.view(-1, h)
+ result = self.experts(hidden_states_2d, packed_routing)
+
+ if self.shared_experts is not None:
+ shared_output, fused_out = result
+ else:
+ shared_output, fused_out = None, result
+
+ final_hidden_states = fused_out + shared_output if shared_output is not None else fused_out
+
+ final_hidden_states = final_hidden_states.view(bsz, seq_len, h)
+
+ return final_hidden_states.squeeze(0) if input_is_2d else final_hidden_states
+
+
+class BailingMoeV2Attention(nn.Module):
+ """Multi-headed attention using vLLM's Attention layer with 3D RoPE support."""
+
+ def __init__(
+ self,
+ config: BailingMoeV2Config,
+ layer_idx: int,
+ cache_config: CacheConfig | None = None,
+ quant_config: QuantizationConfig | None = None,
+ prefix: str = "",
+ ):
+ super().__init__()
+ self.config = config
+ self.layer_idx = layer_idx
+
+ self.hidden_size = config.hidden_size
+ self.num_heads = config.num_attention_heads
+ self.num_kv_heads = config.num_key_value_heads
+ self.head_dim = config.head_dim
+
+ tp_size = get_tensor_model_parallel_world_size()
+ assert self.num_heads % tp_size == 0
+ self.num_heads = self.num_heads // tp_size
+ self.num_kv_heads = max(1, self.num_kv_heads // tp_size)
+
+ self.q_size = self.num_heads * self.head_dim
+ self.kv_size = self.num_kv_heads * self.head_dim
+ self.scaling = self.head_dim**-0.5
+
+ partial_rotary_factor = config.partial_rotary_factor
+ self.rope_dim = int(self.head_dim * partial_rotary_factor)
+
+ total_num_heads = config.num_attention_heads
+ total_num_kv_heads = config.num_key_value_heads
+ self.qkv_proj = QKVParallelLinear(
+ self.hidden_size,
+ self.head_dim,
+ total_num_heads,
+ total_num_kv_heads,
+ bias=config.use_qkv_bias,
+ quant_config=quant_config,
+ prefix=f"{prefix}.qkv_proj",
+ )
+
+ self.dense = RowParallelLinear(
+ total_num_heads * self.head_dim,
+ self.hidden_size,
+ bias=config.use_bias,
+ quant_config=quant_config,
+ prefix=f"{prefix}.dense",
+ )
+
+ # apply vLLM RMSNorm here rather than BailingMoeV2RMSNorm, diff might exist
+ self.q_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
+ self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
+
+ # 3D Rotary embeddings for multimodal
+ if config.rope_scaling is None:
+ raise ValueError("rope_scaling must not be None")
+
+ rope_type = config.rope_scaling.get("rope_type", config.rope_scaling.get("type"))
+ mrope_section = config.rope_scaling.get("mrope_section", [8, 12, 12])
+
+ if rope_type == "video_rope":
+ # Ming-specific video_rope with custom H/W interleaving
+ self.rotary_emb = MingVideoRopeMRotaryEmbedding(
+ head_size=self.head_dim,
+ rotary_dim=self.rope_dim,
+ max_position_embeddings=config.max_position_embeddings,
+ base=config.rope_theta,
+ is_neox_style=True,
+ dtype=torch.get_default_dtype(),
+ mrope_section=mrope_section,
+ )
+ else:
+ # Standard m_rope (rope_type "3D", "default", or None)
+ rope_scaling = dict(config.rope_scaling)
+ rope_scaling["rope_type"] = "default" # normalize for get_rope dispatch
+ rope_scaling["mrope_section"] = mrope_section
+ self.rotary_emb = get_rope(
+ head_size=self.head_dim,
+ max_position=config.max_position_embeddings,
+ is_neox_style=True,
+ rope_parameters={
+ "rope_theta": config.rope_theta,
+ "partial_rotary_factor": config.partial_rotary_factor,
+ **rope_scaling,
+ },
+ )
+
+ self.attn = Attention(
+ self.num_heads,
+ self.head_dim,
+ self.scaling,
+ num_kv_heads=self.num_kv_heads,
+ cache_config=cache_config,
+ quant_config=quant_config,
+ prefix=f"{prefix}.attn",
+ )
+
+ def forward(
+ self,
+ positions: torch.Tensor,
+ hidden_states: torch.Tensor,
+ ) -> torch.Tensor:
+ """Forward pass for attention with 3D RoPE.
+
+ Args:
+ positions: Position IDs, shape (3, num_tokens) for 3D rope
+ or (num_tokens,) for text-only
+ hidden_states: Input hidden states, shape (num_tokens, hidden_size)
+
+ Returns:
+ Attention output tensor, shape (num_tokens, hidden_size)
+ """
+ qkv, _ = self.qkv_proj(hidden_states)
+ q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
+
+ num_tokens = q.shape[0]
+ q = self.q_norm(q.view(num_tokens, self.num_heads, self.head_dim)).view(num_tokens, self.q_size)
+ k = self.k_norm(k.view(num_tokens, self.num_kv_heads, self.head_dim)).view(num_tokens, self.kv_size)
+
+ q, k = self.rotary_emb(positions, q, k)
+
+ attn_output = self.attn(q, k, v)
+
+ output, _ = self.dense(attn_output)
+ return output
+
+
+class BailingMoeV2DecoderLayer(nn.Module):
+ """Decoder layer with attention and MoE MLP."""
+
+ def __init__(
+ self,
+ config: BailingMoeV2Config,
+ layer_idx: int,
+ cache_config: CacheConfig | None = None,
+ quant_config: QuantizationConfig | None = None,
+ prefix: str = "",
+ ):
+ super().__init__()
+ self.hidden_size = config.hidden_size
+ self.layer_idx = layer_idx
+
+ self.attention = BailingMoeV2Attention(
+ config=config,
+ layer_idx=layer_idx,
+ cache_config=cache_config,
+ quant_config=quant_config,
+ prefix=f"{prefix}.attention",
+ )
+
+ # MLP or MoE based on layer index
+ if config.num_experts is not None and layer_idx >= config.first_k_dense_replace:
+ self.mlp = BailingMoeV2SparseMoeBlock(
+ config=config,
+ quant_config=quant_config,
+ prefix=f"{prefix}.mlp",
+ )
+ self.is_moe = True
+ else:
+ self.mlp = BailingMoeV2MLP(
+ config=config,
+ intermediate_size=config.intermediate_size,
+ quant_config=quant_config,
+ prefix=f"{prefix}.mlp",
+ )
+ self.is_moe = False
+
+ # apply vLLM RMSNorm to replace BailingMoeV2RMSNorm, diff might exist
+ self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
+ self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
+
+ def forward(
+ self,
+ positions: torch.Tensor,
+ hidden_states: torch.Tensor,
+ residual: torch.Tensor | None,
+ image_mask: torch.Tensor | None = None,
+ audio_mask: torch.Tensor | None = None,
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ """Forward pass for decoder layer.
+
+ Args:
+ positions: Position IDs
+ hidden_states: Input hidden states
+ residual: Residual connection from previous layer
+ image_mask: Mask for image tokens (for MultiRouter MoE)
+ audio_mask: Mask for audio tokens (for MultiRouter MoE)
+
+ Returns:
+ Tuple of (hidden_states, residual)
+ """
+ if residual is None:
+ residual = hidden_states
+ hidden_states = self.input_layernorm(hidden_states)
+ else:
+ hidden_states, residual = self.input_layernorm(hidden_states, residual)
+
+ hidden_states = self.attention(
+ positions=positions,
+ hidden_states=hidden_states,
+ )
+
+ hidden_states, residual = self.post_attention_layernorm(hidden_states, residual)
+
+ if self.is_moe:
+ hidden_states = self.mlp(hidden_states, image_mask, audio_mask)
+ else:
+ # Dense MLP only takes hidden_states (no routing masks)
+ hidden_states = self.mlp(hidden_states)
+
+ return hidden_states, residual
+
+
+@support_torch_compile(
+ dynamic_arg_dims={
+ "input_ids": 0,
+ "positions": -1,
+ "intermediate_tensors": 0,
+ "inputs_embeds": 0,
+ "image_mask": 0,
+ "audio_mask": 0,
+ }
+)
+class BailingMoeV2Model(nn.Module):
+ """BailingMoeV2 Model adapted from:
+
+ Ming repo BailingMoeV2Model
+ https://github.com/inclusionAI/Ming/blob/2a0c02ae3130190160c215f89fce7de3005db483/modeling_bailing_moe_v2.py
+ vLLM repo BailingMoeModel
+ https://github.com/vllm-project/vllm/blob/7291d1b288558d48508e1a17c37b0aa170332264/vllm/model_executor/models/bailing_moe.py
+ """
+
+ def __init__(
+ self,
+ *,
+ vllm_config: VllmConfig,
+ prefix: str = "",
+ ):
+ super().__init__()
+
+ # BailingMoeV2Config
+ config = vllm_config.model_config.hf_text_config
+
+ cache_config = vllm_config.cache_config
+ quant_config = vllm_config.quant_config
+
+ self.config = config
+ self.quant_config = quant_config
+ self.padding_idx = config.pad_token_id
+ self.vocab_size = config.vocab_size
+ self.tie_word_embeddings = getattr(config, "tie_word_embeddings", False)
+
+ if get_pp_group().is_first_rank or (self.tie_word_embeddings and get_pp_group().is_last_rank):
+ self.word_embeddings = VocabParallelEmbedding(
+ config.vocab_size,
+ config.hidden_size,
+ quant_config=quant_config,
+ prefix=f"{prefix}.word_embeddings",
+ )
+ else:
+ self.word_embeddings = PPMissingLayer()
+
+ # Decoder layers with later pipeline parallelism support
+ self.start_layer, self.end_layer, self.layers = make_layers(
+ config.num_hidden_layers,
+ lambda prefix: BailingMoeV2DecoderLayer(
+ config=config,
+ layer_idx=int(prefix.split(".")[-1]),
+ cache_config=cache_config,
+ quant_config=quant_config,
+ prefix=prefix,
+ ),
+ prefix=f"{prefix}.layers",
+ )
+
+ if get_pp_group().is_last_rank:
+ # apply vLLM RMSNorm to replace BailingMoeV2RMSNorm, diff might exist
+ self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
+ else:
+ self.norm = PPMissingLayer()
+
+ self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
+ ["hidden_states", "residual"], config.hidden_size
+ )
+
+ def get_input_embeddings(self):
+ return self.word_embeddings
+
+ def forward(
+ self,
+ input_ids: torch.Tensor,
+ positions: torch.Tensor,
+ intermediate_tensors: IntermediateTensors | None = None,
+ inputs_embeds: torch.Tensor | None = None,
+ image_mask: torch.Tensor | None = None,
+ audio_mask: torch.Tensor | None = None,
+ ) -> torch.Tensor | IntermediateTensors:
+ if get_pp_group().is_first_rank:
+ if inputs_embeds is not None:
+ hidden_states = inputs_embeds
+ else:
+ hidden_states = self.word_embeddings(input_ids)
+ residual = None
+ else:
+ assert intermediate_tensors is not None
+ hidden_states = intermediate_tensors["hidden_states"]
+ residual = intermediate_tensors["residual"]
+
+ for layer in self.layers[self.start_layer : self.end_layer]:
+ hidden_states, residual = layer(
+ positions,
+ hidden_states,
+ residual,
+ image_mask=image_mask,
+ audio_mask=audio_mask,
+ )
+
+ if not get_pp_group().is_last_rank:
+ return IntermediateTensors({"hidden_states": hidden_states, "residual": residual})
+
+ hidden_states, _ = self.norm(hidden_states, residual)
+ return hidden_states
+
+
+class BailingMoeV2ForCausalLM(nn.Module, CustomProcessMixin):
+ """BailingMoeV2 model for causal language modeling, adapted for vLLM.
+
+ Inherits from CustomProcessMixin to support custom preprocessing and postprocessing
+ for integration with omni model pipelines.
+ """
+
+ packed_modules_mapping = {
+ "qkv_proj": ["q_proj", "k_proj", "v_proj"],
+ "gate_up_proj": ["gate_proj", "up_proj"],
+ }
+
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
+ super().__init__()
+
+ # BailingMoeV2Config
+ config = vllm_config.model_config.hf_text_config
+ quant_config = vllm_config.quant_config
+
+ self.config = config
+ self.quant_config = quant_config
+
+ self.model = BailingMoeV2Model(
+ vllm_config=vllm_config,
+ prefix=maybe_prefix(prefix, "model"),
+ )
+
+ self.tie_word_embeddings = getattr(config, "tie_word_embeddings", False)
+ if get_pp_group().is_last_rank:
+ self.lm_head = ParallelLMHead(
+ config.vocab_size,
+ config.hidden_size,
+ quant_config=quant_config,
+ prefix=maybe_prefix(prefix, "lm_head"),
+ )
+ if self.tie_word_embeddings:
+ self.lm_head.weight = self.model.word_embeddings.weight
+ else:
+ self.lm_head = PPMissingLayer()
+
+ self.logits_processor = LogitsProcessor(config.vocab_size)
+ self.sampler = Sampler()
+ self.make_empty_intermediate_tensors = self.model.make_empty_intermediate_tensors
+
+ def forward(
+ self,
+ input_ids: torch.Tensor,
+ positions: torch.Tensor,
+ intermediate_tensors: IntermediateTensors | None = None,
+ inputs_embeds: torch.Tensor | None = None,
+ image_mask: torch.Tensor | None = None,
+ audio_mask: torch.Tensor | None = None,
+ ):
+ hidden_states = self.model(
+ input_ids=input_ids,
+ positions=positions,
+ intermediate_tensors=intermediate_tensors,
+ inputs_embeds=inputs_embeds,
+ image_mask=image_mask,
+ audio_mask=audio_mask,
+ )
+ return hidden_states
+
+ def compute_logits(
+ self,
+ hidden_states: torch.Tensor,
+ sampling_metadata,
+ ) -> torch.Tensor | None:
+ logits = self.logits_processor(self.lm_head, hidden_states, sampling_metadata)
+ return logits
+
+ def sample(
+ self,
+ logits: torch.Tensor,
+ sampling_metadata,
+ ) -> SamplerOutput | None:
+ next_tokens = self.sampler(logits, sampling_metadata)
+ return next_tokens
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ stacked_params_mapping = [
+ # (param_name, weight_name, shard_id)
+ # BailingMoE stores fused QKV in checkpoint as query_key_value
+ ("qkv_proj", "query_key_value", None),
+ # Dense MLP and shared_experts gate/up are stored separately
+ ("gate_up_proj", "gate_proj", 0),
+ ("gate_up_proj", "up_proj", 1),
+ ]
+
+ # Gate router linear layers: checkpoint `{r}.weight` -> model `{r}.gate.weight`
+ gate_name_mapper = WeightsMapper(
+ orig_to_new_substr={f".{r}.weight": f".{r}.gate.weight" for r in ("gate", "image_gate", "audio_gate")}
+ )
+
+ # FusedMoE expert params mapping is identical across all MoE layers
+ expert_params_mapping: list[tuple[str, str, int, str]] = []
+ for layer in self.model.layers:
+ if hasattr(layer, "mlp") and hasattr(layer.mlp, "experts"):
+ expert_params_mapping = layer.mlp.experts.expert_mapping
+ break
+
+ params_dict = dict(self.named_parameters(remove_duplicate=False))
+ loaded_params: set[str] = set()
+
+ for name, loaded_weight in gate_name_mapper.apply(weights):
+ for param_name, weight_name, shard_id in stacked_params_mapping:
+ if weight_name not in name or "mlp.experts" in name:
+ continue
+ name = name.replace(weight_name, param_name)
+ param = params_dict.get(name)
+ if param is not None:
+ weight_loader = param.weight_loader
+ weight_loader(param, loaded_weight, shard_id)
+ loaded_params.add(name)
+ break
+ else:
+ for param_name, weight_name, expert_id, shard_id in expert_params_mapping:
+ if weight_name not in name:
+ continue
+ name = name.replace(weight_name, param_name)
+ param = params_dict.get(name)
+ if param is not None:
+ weight_loader = param.weight_loader
+ weight_loader(param, loaded_weight, name, shard_id=shard_id, expert_id=expert_id)
+ loaded_params.add(name)
+ break
+ else:
+ param = params_dict.get(name)
+ if param is not None:
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
+ weight_loader(param, loaded_weight)
+ loaded_params.add(name)
+
+ return loaded_params
diff --git a/vllm_omni/model_executor/models/ming_flash_omni/projectors.py b/vllm_omni/model_executor/models/ming_flash_omni/projectors.py
new file mode 100644
index 000000000..42e53d1c6
--- /dev/null
+++ b/vllm_omni/model_executor/models/ming_flash_omni/projectors.py
@@ -0,0 +1,184 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 The vLLM-Omni team.
+# Copyright (c) Ant Group. All rights reserved.
+# Adapted from Ming repository modeling_bailingmm2.py
+# https://github.com/inclusionAI/Ming
+
+from collections.abc import Iterable
+
+import torch
+import torch.nn as nn
+from vllm.logger import init_logger
+from vllm.model_executor.model_loader.weight_utils import default_weight_loader
+
+logger = init_logger(__name__)
+
+
+class Transpose(nn.Module):
+ """Used in nn.Sequential pipelines."""
+
+ def __init__(self, dim0: int, dim1: int):
+ super().__init__()
+ self.dim0 = dim0
+ self.dim1 = dim1
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ return x.transpose(self.dim0, self.dim1)
+
+
+class VisionProjector(nn.Module):
+ """MLP projector from vision encoder output to LLM hidden space.
+
+ Args:
+ vision_dim: Vision encoder output dimension (out_hidden_size).
+ llm_dim: LLM hidden dimension.
+ mlp_depth: Number of linear layers (>= 1).
+ """
+
+ def __init__(self, vision_dim: int, llm_dim: int, mlp_depth: int = 1):
+ super().__init__()
+ layers: list[nn.Module] = [nn.Linear(vision_dim, llm_dim)]
+ for _ in range(1, mlp_depth):
+ layers.append(nn.GELU())
+ layers.append(nn.Linear(llm_dim, llm_dim))
+ self.proj = nn.Sequential(*layers)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Project vision features.
+
+ Args:
+ x: [seq_len, vision_dim] or [B, seq_len, vision_dim]
+
+ Returns:
+ Projected features with last dim = llm_dim.
+ """
+ return self.proj(x)
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ params_dict = dict(self.named_parameters())
+ loaded_params: set[str] = set()
+ for name, loaded_weight in weights:
+ if not name.startswith("proj."):
+ name = f"proj.{name}"
+ if name not in params_dict:
+ logger.warning("Skipping unknown vision projector weight: %s", name)
+ continue
+ param = params_dict[name]
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
+ weight_loader(param, loaded_weight)
+ loaded_params.add(name)
+ return loaded_params
+
+
+class AudioProjector(nn.Module):
+ """Projector for audio features.
+
+ Args:
+ audio_dim: Audio encoder output dimension (n_state).
+ llm_dim: LLM hidden dimension.
+ ds_kernel_size: Conv1d kernel size for downsampling.
+ ds_stride: Conv1d stride for downsampling.
+ mlp_depth: Total number of projection layers (>= 1).
+ """
+
+ def __init__(
+ self,
+ audio_dim: int,
+ llm_dim: int,
+ ds_kernel_size: int = 3,
+ ds_stride: int = 2,
+ mlp_depth: int = 1,
+ ):
+ super().__init__()
+ self.ds_kernel_size = ds_kernel_size
+ self.ds_stride = ds_stride
+
+ layers: list[nn.Module] = [
+ nn.Conv1d(
+ audio_dim,
+ llm_dim,
+ kernel_size=ds_kernel_size,
+ stride=ds_stride,
+ padding=ds_kernel_size // 2,
+ ),
+ Transpose(-1, -2), # [B, llm_dim, T'] -> [B, T', llm_dim]
+ ]
+ for _ in range(1, mlp_depth):
+ layers.append(nn.GELU())
+ layers.append(nn.Linear(llm_dim, llm_dim))
+ self.proj = nn.Sequential(*layers)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Project audio features with temporal downsampling.
+
+ Args:
+ x: [B, T, audio_dim] audio encoder output (channel-last).
+
+ Returns:
+ [B, T', llm_dim] projected features (channel-last),
+ where T' = (T - ds_kernel_size + 2*(ds_kernel_size//2)) // ds_stride + 1.
+ """
+ # Conv1d expects [B, C, T], so transpose input
+ x = x.transpose(-1, -2) # [B, audio_dim, T]
+ return self.proj(x)
+
+ def forward_packed(
+ self,
+ packed: torch.Tensor,
+ encoded_lens: list[int],
+ ) -> tuple[torch.Tensor, list[int]]:
+ """Project packed audio features from the Whisper encoder.
+
+ Args:
+ packed: [total_T', audio_dim] packed encoder output.
+ encoded_lens: Length of each clip after Whisper encoding.
+
+ Returns:
+ Tuple of:
+ - [total_T'', llm_dim] packed projected features.
+ - List of projected lengths per clip.
+ """
+ conv1d = self.proj[0]
+ mlp = self.proj[2:]
+
+ # Split packed tensor per clip for Conv1d
+ segments = packed.split(encoded_lens)
+ conv_segments = []
+ proj_lens: list[int] = []
+ for seg in segments:
+ out = conv1d(seg.transpose(0, 1).unsqueeze(0)) # [1, llm_dim, T'_i]
+ out = out.squeeze(0).transpose(0, 1) # [T'_i, llm_dim]
+ conv_segments.append(out)
+ proj_lens.append(out.shape[0])
+
+ packed_proj = torch.cat(conv_segments, dim=0) # [total_T'', llm_dim]
+ packed_proj = mlp(packed_proj)
+ return packed_proj, proj_lens
+
+ def compute_output_length(self, input_length: torch.Tensor) -> torch.Tensor:
+ """Compute output sequence length after Conv1d downsampling.
+
+ Args:
+ input_length: Original mel spectrogram lengths.
+
+ Returns:
+ Output lengths after both convolutions.
+ """
+ length = (input_length - 3 + 2 * 1) // 2 + 1
+ length = (length - self.ds_kernel_size + 2 * (self.ds_kernel_size // 2)) // self.ds_stride + 1
+ return length
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ params_dict = dict(self.named_parameters())
+ loaded_params: set[str] = set()
+ for name, loaded_weight in weights:
+ if not name.startswith("proj."):
+ name = f"proj.{name}"
+ if name not in params_dict:
+ logger.warning("Skipping unknown audio projector weight: %s", name)
+ continue
+ param = params_dict[name]
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
+ weight_loader(param, loaded_weight)
+ loaded_params.add(name)
+ return loaded_params
diff --git a/vllm_omni/model_executor/models/ming_flash_omni/vision_encoder.py b/vllm_omni/model_executor/models/ming_flash_omni/vision_encoder.py
new file mode 100644
index 000000000..7976d76ce
--- /dev/null
+++ b/vllm_omni/model_executor/models/ming_flash_omni/vision_encoder.py
@@ -0,0 +1,125 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 The vLLM-Omni team.
+# Copyright 2025 The Qwen Team and The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# Adapted from Ming repository qwen3_moe_vit.py
+# https://github.com/inclusionAI/Ming
+
+from collections.abc import Iterable
+
+import torch
+import torch.nn as nn
+from vllm.logger import init_logger
+from vllm.model_executor.layers.quantization import QuantizationConfig
+from vllm.model_executor.models.qwen3_omni_moe_thinker import (
+ Qwen3Omni_VisionTransformer,
+)
+from vllm.model_executor.models.utils import WeightsMapper
+
+logger = init_logger(__name__)
+
+
+def _adapt_vision_config(vision_config):
+ # Adapt Ming's Qwen3VLMoeVisionConfig to be compatible with vLLM's
+ # Qwen3Omni_VisionTransformer expectations.
+ if not hasattr(vision_config, "image_size") or vision_config.image_size is None:
+ if hasattr(vision_config, "num_position_embeddings") and vision_config.num_position_embeddings:
+ import math
+
+ num_grid = int(math.sqrt(vision_config.num_position_embeddings))
+ vision_config.image_size = num_grid * vision_config.patch_size
+ else:
+ vision_config.image_size = vision_config.patch_size * 14 # fallback
+
+ if not hasattr(vision_config, "apply_vit_abs_pos_embed"):
+ vision_config.apply_vit_abs_pos_embed = True
+
+ return vision_config
+
+
+class MingVisionEncoder(nn.Module):
+ """**Wrapper** around vLLM's Qwen3Omni_VisionTransformer for Ming."""
+
+ hf_to_vllm_mapper = WeightsMapper(
+ orig_to_new_substr={
+ "deepstack_merger_list.": "merger_list.",
+ "merger.norm.": "merger.ln_q.",
+ "merger.linear_fc1.": "merger.mlp.0.",
+ "merger.linear_fc2.": "merger.mlp.2.",
+ }
+ )
+
+ def __init__(
+ self,
+ vision_config,
+ quant_config: QuantizationConfig | None = None,
+ prefix: str = "",
+ ) -> None:
+ super().__init__()
+ adapted_config = _adapt_vision_config(vision_config)
+ norm_eps = 1e-6
+ self.encoder = Qwen3Omni_VisionTransformer(
+ vision_config=adapted_config,
+ norm_eps=norm_eps,
+ quant_config=quant_config,
+ prefix=f"{prefix}.encoder",
+ )
+ self.image_emb_dim = vision_config.out_hidden_size
+ self.use_deepstack = (
+ hasattr(vision_config, "deepstack_visual_indexes") and vision_config.deepstack_visual_indexes is not None
+ )
+
+ @property
+ def dtype(self) -> torch.dtype:
+ return self.encoder.dtype
+
+ @property
+ def device(self) -> torch.device:
+ return self.encoder.device
+
+ def forward(
+ self,
+ pixel_values: torch.Tensor,
+ grid_thw: torch.Tensor,
+ ) -> torch.Tensor:
+ """forward method of the vision encoder.
+
+ Args:
+ pixel_values: Flattened pixel values.
+ grid_thw: [num_images, 3] tensor of (t, h, w) grid sizes.
+
+ Returns:
+ If deepstack is enabled, returns concatenated multi-scale features
+ along the feature dim: [seq_len, hidden_size * (1 + num_deepstack)].
+ Otherwise returns [seq_len, hidden_size].
+ """
+ return self.encoder(pixel_values, grid_thw=grid_thw)
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ import re
+
+ def _remap_merger_list_inner(name: str) -> str:
+ name = re.sub(r"(merger_list\.\d+)\.norm\.", r"\1.ln_q.", name)
+ name = re.sub(r"(merger_list\.\d+)\.linear_fc1\.", r"\1.mlp.0.", name)
+ name = re.sub(r"(merger_list\.\d+)\.linear_fc2\.", r"\1.mlp.2.", name)
+
+ return name
+
+ remapped_weights = self.hf_to_vllm_mapper.apply(weights)
+ remapped_weights = ((_remap_merger_list_inner(name), tensor) for name, tensor in remapped_weights)
+ loaded_params = self.encoder.load_weights(remapped_weights)
+
+ loaded_params = {f"encoder.{loaded_param}" for loaded_param in loaded_params}
+
+ return loaded_params
diff --git a/vllm_omni/model_executor/models/qwen2_5_omni/pipeline.py b/vllm_omni/model_executor/models/qwen2_5_omni/pipeline.py
new file mode 100644
index 000000000..b44d08eb3
--- /dev/null
+++ b/vllm_omni/model_executor/models/qwen2_5_omni/pipeline.py
@@ -0,0 +1,78 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Qwen2.5-Omni pipeline topology (frozen).
+
+Stage 0: Thinker — multimodal understanding + text generation
+Stage 1: Talker — text embeddings → speech tokens
+Stage 2: Code2Wav — speech tokens → audio waveform
+"""
+
+from vllm_omni.config.stage_config import (
+ PipelineConfig,
+ StageExecutionType,
+ StagePipelineConfig,
+)
+
+_PROC = "vllm_omni.model_executor.stage_input_processors.qwen2_5_omni"
+
+QWEN2_5_OMNI_PIPELINE = PipelineConfig(
+ model_type="qwen2_5_omni",
+ model_arch="Qwen2_5OmniForConditionalGeneration",
+ stages=(
+ StagePipelineConfig(
+ stage_id=0,
+ model_stage="thinker",
+ execution_type=StageExecutionType.LLM_AR,
+ input_sources=(),
+ final_output=True,
+ final_output_type="text",
+ owns_tokenizer=True,
+ requires_multimodal_data=True,
+ engine_output_type="latent",
+ sampling_constraints={"detokenize": True},
+ ),
+ StagePipelineConfig(
+ stage_id=1,
+ model_stage="talker",
+ execution_type=StageExecutionType.LLM_AR,
+ input_sources=(0,),
+ engine_output_type="latent",
+ custom_process_input_func=f"{_PROC}.thinker2talker",
+ sampling_constraints={
+ "detokenize": True,
+ "stop_token_ids": [8294],
+ },
+ ),
+ StagePipelineConfig(
+ stage_id=2,
+ model_stage="code2wav",
+ execution_type=StageExecutionType.LLM_GENERATION,
+ input_sources=(1,),
+ final_output=True,
+ final_output_type="audio",
+ engine_output_type="audio",
+ sampling_constraints={"detokenize": True},
+ ),
+ ),
+)
+
+
+# Single-stage thinker-only variant for the abort test.
+QWEN2_5_OMNI_THINKER_ONLY_PIPELINE = PipelineConfig(
+ model_type="qwen2_5_omni_thinker_only",
+ model_arch="Qwen2_5OmniForConditionalGeneration",
+ stages=(
+ StagePipelineConfig(
+ stage_id=0,
+ model_stage="thinker",
+ execution_type=StageExecutionType.LLM_AR,
+ input_sources=(),
+ final_output=True,
+ final_output_type="text",
+ owns_tokenizer=True,
+ requires_multimodal_data=True,
+ engine_output_type="latent",
+ sampling_constraints={"detokenize": True},
+ ),
+ ),
+)
diff --git a/vllm_omni/model_executor/models/qwen3_omni/pipeline.py b/vllm_omni/model_executor/models/qwen3_omni/pipeline.py
new file mode 100644
index 000000000..1c69ec795
--- /dev/null
+++ b/vllm_omni/model_executor/models/qwen3_omni/pipeline.py
@@ -0,0 +1,63 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Qwen3-Omni-MoE pipeline topology (frozen).
+
+Stage 0: Thinker — multimodal understanding + text generation
+Stage 1: Talker — text embeddings → RVQ codec codes
+Stage 2: Code2Wav — RVQ codes → audio waveform
+"""
+
+from vllm_omni.config.stage_config import (
+ PipelineConfig,
+ StageExecutionType,
+ StagePipelineConfig,
+)
+
+_PROC = "vllm_omni.model_executor.stage_input_processors.qwen3_omni"
+
+QWEN3_OMNI_PIPELINE = PipelineConfig(
+ model_type="qwen3_omni_moe",
+ model_arch="Qwen3OmniMoeForConditionalGeneration",
+ stages=(
+ StagePipelineConfig(
+ stage_id=0,
+ model_stage="thinker",
+ execution_type=StageExecutionType.LLM_AR,
+ input_sources=(),
+ final_output=True,
+ final_output_type="text",
+ owns_tokenizer=True,
+ requires_multimodal_data=True,
+ hf_config_name="thinker_config",
+ engine_output_type="latent",
+ custom_process_next_stage_input_func=(f"{_PROC}.thinker2talker_async_chunk"),
+ sampling_constraints={"detokenize": True},
+ ),
+ StagePipelineConfig(
+ stage_id=1,
+ model_stage="talker",
+ execution_type=StageExecutionType.LLM_AR,
+ input_sources=(0,),
+ hf_config_name="talker_config",
+ engine_output_type="latent",
+ custom_process_input_func=f"{_PROC}.thinker2talker",
+ custom_process_next_stage_input_func=(f"{_PROC}.talker2code2wav_async_chunk"),
+ sampling_constraints={
+ "detokenize": False,
+ "stop_token_ids": [2150],
+ },
+ ),
+ StagePipelineConfig(
+ stage_id=2,
+ model_stage="code2wav",
+ execution_type=StageExecutionType.LLM_GENERATION,
+ input_sources=(1,),
+ final_output=True,
+ final_output_type="audio",
+ hf_config_name="thinker_config",
+ engine_output_type="audio",
+ custom_process_input_func=f"{_PROC}.talker2code2wav",
+ sampling_constraints={"detokenize": True},
+ ),
+ ),
+)
diff --git a/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni.py b/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni.py
index 7df694797..ce3360375 100644
--- a/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni.py
+++ b/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni.py
@@ -180,6 +180,11 @@ class Qwen3OmniMoeForConditionalGeneration(
"trailing_text_hidden",
"tts_pad_embed_projected",
}
+ # Keys that need to be accumulated across streaming inputs
+ self.streaming_accumulated_keys: set[str] = {
+ "thinker_prefill_embeddings",
+ "thinker_hidden_states",
+ }
elif self.model_stage == "code2wav":
self.enable_update_additional_information = True
@@ -684,6 +689,7 @@ class Qwen3OmniMoeForConditionalGeneration(
input_embeds: torch.Tensor,
last_talker_hidden: torch.Tensor,
text_step: torch.Tensor,
+ **kwargs: Any,
):
# TODO(Peiqi): not support intermediate_tensors now
input_ids = safe_tensor_reshape(input_ids, (input_ids.shape[0], -1))
@@ -891,10 +897,11 @@ class Qwen3OmniMoeForConditionalGeneration(
Returns:
(input_ids, input_embeds) for talker
"""
+ target_len = thinker_result_ids.shape[-1]
im_start_indexes = torch.cat(
(
torch.nonzero(input_ids[0] == self.config.im_start_token_id).squeeze(),
- torch.tensor([thinker_result_ids.shape[-1]], device=input_ids.device, dtype=input_ids.dtype),
+ torch.tensor([target_len], device=input_ids.device, dtype=input_ids.dtype),
),
dim=-1,
) # Shape [n_starts + 1]; Take batch 0 since batched inference is not supported here.
@@ -1029,8 +1036,35 @@ class Qwen3OmniMoeForConditionalGeneration(
return last_talker_hidden, text_step, update_dict
def _get_talker_user_parts(self, im_start_index, segment_end_index, multimodal_mask, thinker_hidden, thinker_embed):
+ clamped = min(
+ segment_end_index,
+ multimodal_mask.shape[0],
+ thinker_hidden.shape[0],
+ thinker_embed.shape[0],
+ )
+ if clamped < segment_end_index:
+ logger.warning(
+ "_get_talker_user_parts: segment_end_index %d clamped to %d "
+ "(embed=%d, hidden=%d, mask=%d). "
+ "This usually means _merge_pd_embeddings failed to merge "
+ "prefill embeddings – check PD prefill_mm keys.",
+ segment_end_index,
+ clamped,
+ thinker_embed.shape[0],
+ thinker_hidden.shape[0],
+ multimodal_mask.shape[0],
+ )
+ segment_end_index = clamped
+ seg_len = segment_end_index - im_start_index
+ if seg_len <= 0:
+ return torch.empty(
+ (0, self.config.talker_config.text_config.hidden_size),
+ device=thinker_hidden.device,
+ dtype=torch.bfloat16,
+ )
+
user_talker_part = torch.empty(
- (segment_end_index - im_start_index, self.config.talker_config.text_config.hidden_size),
+ (seg_len, self.config.talker_config.text_config.hidden_size),
device=thinker_hidden.device,
dtype=torch.bfloat16,
)
diff --git a/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_code_predictor_mtp.py b/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_code_predictor_mtp.py
index 2ceaafdb6..819e22e18 100644
--- a/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_code_predictor_mtp.py
+++ b/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_code_predictor_mtp.py
@@ -1,510 +1,28 @@
-"""Qwen3-Omni Code Predictor -- optimized re-prefill, no KV cache.
+"""Qwen3-Omni Code Predictor -- thin wrapper over CodePredictorWrapper."""
-* SDPA attention (F.scaled_dot_product_attention) with native GQA support
-* HF-compatible numerics (float32 RMSNorm, float32 RoPE, separate linear layers)
-* Per-call embedding buffer to avoid cross-request aliasing
-* Pre-allocated position_ids (read-only, safe to persist)
-* torch.compile (epilogue_fusion=False) on inner transformer by default
-* Inline sampling (top-k + top-p) -- no custom op overhead
-"""
-
-import torch
-import torch.nn as nn
-import torch.nn.functional as F
from vllm.config import VllmConfig
-from vllm.logger import init_logger
-from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding
-from vllm.model_executor.model_loader.weight_utils import default_weight_loader
-from vllm_omni.platforms import current_omni_platform
-
-logger = init_logger(__name__)
+from vllm_omni.model_executor.models.common.qwen3_code_predictor import (
+ CodePredictorWrapper,
+ CodePredictorWrapperConfig,
+)
-# ===================================================================
-# HF-numerics-compatible layers for code predictor
-# ===================================================================
-#
-# These use plain PyTorch ops (nn.Linear, manual RMSNorm in float32,
-# rotate_half RoPE) to produce outputs numerically identical to the
-# HuggingFace reference. vLLM's fused kernels (RMSNorm, QKVParallel,
-# get_rope) introduce small precision differences that compound across
-# the autoregressive steps of the code predictor, causing severe
-# audio quality degradation.
-#
-# See: https://github.com/vllm-project/vllm-omni/issues/2274
+class Qwen3OmniMoeTalkerCodePredictor(CodePredictorWrapper):
+ """Qwen3-Omni code predictor (no CUDA graphs, VocabParallelEmbedding)."""
-
-class _RMSNorm(nn.Module):
- """RMSNorm matching HuggingFace's implementation exactly.
-
- Computes variance in float32 to avoid bfloat16 precision loss.
- """
-
- def __init__(self, hidden_size: int, eps: float = 1e-6) -> None:
- super().__init__()
- self.weight = nn.Parameter(torch.ones(hidden_size))
- self.variance_epsilon = eps
-
- def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
- input_dtype = hidden_states.dtype
- hidden_states = hidden_states.to(torch.float32)
- variance = hidden_states.pow(2).mean(-1, keepdim=True)
- hidden_states = hidden_states * torch.rsqrt(variance + self.variance_epsilon)
- return self.weight * hidden_states.to(input_dtype)
-
-
-def _rotate_half(x: torch.Tensor) -> torch.Tensor:
- """Rotates half the hidden dims of the input."""
- x1 = x[..., : x.shape[-1] // 2]
- x2 = x[..., x.shape[-1] // 2 :]
- return torch.cat((-x2, x1), dim=-1)
-
-
-class _RotaryEmbedding(nn.Module):
- """RoPE matching HuggingFace's implementation exactly.
-
- Forces float32 computation for cos/sin, matching HF's torch.autocast(enabled=False).
- """
-
- def __init__(self, config) -> None:
- super().__init__()
- head_dim = getattr(
- config,
- "head_dim",
- config.hidden_size // config.num_attention_heads,
- )
- rope_theta = getattr(config, "rope_theta", 10000.0)
- inv_freq = 1.0 / (rope_theta ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim))
- self.register_buffer("inv_freq", inv_freq, persistent=False)
-
- def forward(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
- # position_ids: [batch, seq_len]
- inv_freq_expanded = self.inv_freq[None, :, None].float().expand(position_ids.shape[0], -1, 1)
- position_ids_expanded = position_ids[:, None, :].float()
-
- # Force float32 (matching HF)
- device_type = x.device.type if isinstance(x.device.type, str) and x.device.type != "mps" else "cpu"
- with torch.autocast(device_type=device_type, enabled=False):
- freqs = (inv_freq_expanded.float() @ position_ids_expanded.float()).transpose(1, 2)
- emb = torch.cat((freqs, freqs), dim=-1)
- cos = emb.cos()
- sin = emb.sin()
-
- return cos.to(dtype=x.dtype), sin.to(dtype=x.dtype)
-
-
-class Qwen3OmniCodePredictorAttention(nn.Module):
- """Multi-head self-attention for code predictor.
-
- Uses ``F.scaled_dot_product_attention`` with HF-compatible RoPE and RMSNorm.
- No KV cache -- the code predictor always re-prefills the full (short)
- sequence each AR step.
-
- Input : [B, seq_len, hidden_size]
- Output: [B, seq_len, hidden_size]
- """
-
- def __init__(
- self,
- config,
- prefix: str = "",
- ):
- super().__init__()
- cp_cfg = config.code_predictor_config
- self.num_heads = cp_cfg.num_attention_heads
- self.num_kv_heads = cp_cfg.num_key_value_heads
- self.head_dim = getattr(
- cp_cfg,
- "head_dim",
- cp_cfg.hidden_size // cp_cfg.num_attention_heads,
- )
- self.hidden_size = cp_cfg.hidden_size
- self.scaling = self.head_dim**-0.5
- self._use_gqa = self.num_kv_heads != self.num_heads
-
- # Separate q/k/v projections matching HF (no fused packing)
- self.q_proj = nn.Linear(
- self.hidden_size,
- self.num_heads * self.head_dim,
- bias=False,
- )
- self.k_proj = nn.Linear(
- self.hidden_size,
- self.num_kv_heads * self.head_dim,
- bias=False,
- )
- self.v_proj = nn.Linear(
- self.hidden_size,
- self.num_kv_heads * self.head_dim,
- bias=False,
- )
- self.o_proj = nn.Linear(
- self.num_heads * self.head_dim,
- self.hidden_size,
- bias=False,
- )
- self.q_norm = _RMSNorm(self.head_dim, eps=cp_cfg.rms_norm_eps)
- self.k_norm = _RMSNorm(self.head_dim, eps=cp_cfg.rms_norm_eps)
-
- def forward(
- self,
- hidden_states: torch.Tensor,
- position_embeddings: tuple[torch.Tensor, torch.Tensor],
- ) -> torch.Tensor:
- bsz, seq_len, _ = hidden_states.shape
- hidden_shape_q = (bsz, seq_len, self.num_heads, self.head_dim)
- hidden_shape_kv = (bsz, seq_len, self.num_kv_heads, self.head_dim)
-
- q = self.q_norm(self.q_proj(hidden_states).view(hidden_shape_q)).transpose(1, 2)
- k = self.k_norm(self.k_proj(hidden_states).view(hidden_shape_kv)).transpose(1, 2)
- v = self.v_proj(hidden_states).view(hidden_shape_kv).transpose(1, 2)
-
- cos, sin = position_embeddings
- # cos/sin are [batch, seq_len, head_dim], need unsqueeze at dim=1 for heads
- cos = cos.unsqueeze(1) # [batch, 1, seq_len, head_dim]
- sin = sin.unsqueeze(1)
- q = (q * cos) + (_rotate_half(q) * sin)
- k = (k * cos) + (_rotate_half(k) * sin)
-
- attn_out = F.scaled_dot_product_attention(
- q,
- k,
- v,
- scale=self.scaling,
- is_causal=True,
- enable_gqa=self._use_gqa,
- )
-
- attn_out = attn_out.transpose(1, 2).reshape(bsz, seq_len, -1)
- output = self.o_proj(attn_out)
- return output
-
-
-# ===================================================================
-# MLP
-# ===================================================================
-
-
-class Qwen3OmniCodePredictorMLP(nn.Module):
- """SiLU-gated MLP for code predictor, matching HF's implementation."""
-
- def __init__(
- self,
- config,
- prefix: str = "",
- ):
- super().__init__()
- hidden_size = config.code_predictor_config.hidden_size
- intermediate_size = config.code_predictor_config.intermediate_size
-
- self.gate_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
- self.up_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
- self.down_proj = nn.Linear(intermediate_size, hidden_size, bias=False)
-
- def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
- return self.down_proj(F.silu(self.gate_proj(hidden_states)) * self.up_proj(hidden_states))
-
-
-# ===================================================================
-# Decoder Layer
-# ===================================================================
-
-
-class Qwen3OmniCodePredictorDecoderLayer(nn.Module):
- """Transformer decoder layer (SDPA, no KV cache)."""
-
- def __init__(
- self,
- config,
- prefix: str = "",
- ) -> None:
- super().__init__()
- self.self_attn = Qwen3OmniCodePredictorAttention(
- config,
- prefix=f"{prefix}.self_attn",
- )
- self.mlp = Qwen3OmniCodePredictorMLP(
- config,
- prefix=f"{prefix}.mlp",
- )
- cp_cfg = config.code_predictor_config
- self.input_layernorm = _RMSNorm(cp_cfg.hidden_size, eps=cp_cfg.rms_norm_eps)
- self.post_attention_layernorm = _RMSNorm(cp_cfg.hidden_size, eps=cp_cfg.rms_norm_eps)
-
- def forward(
- self,
- hidden_states: torch.Tensor,
- position_embeddings: tuple[torch.Tensor, torch.Tensor],
- ) -> torch.Tensor:
- residual = hidden_states
- hidden_states = self.input_layernorm(hidden_states)
- hidden_states = self.self_attn(hidden_states, position_embeddings)
- hidden_states = residual + hidden_states
-
- residual = hidden_states
- hidden_states = self.post_attention_layernorm(hidden_states)
- hidden_states = self.mlp(hidden_states)
- hidden_states = residual + hidden_states
- return hidden_states
-
-
-# ===================================================================
-# Base Transformer Model (re-prefill, no KV cache)
-# ===================================================================
-
-
-class Qwen3OmniCodePredictorBaseModel(nn.Module):
- """Inner transformer for code predictor.
-
- Signature: ``forward(inputs_embeds, position_ids) -> hidden_states``
- -- plain Tensor in, plain Tensor out (no namedtuple).
- """
-
- def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
- super().__init__()
- config = vllm_config.model_config.hf_config.code_predictor_config
- self.config = config
-
- self.codec_embedding = nn.ModuleList(
- [VocabParallelEmbedding(config.vocab_size, config.hidden_size) for _ in range(config.num_code_groups - 1)]
- )
-
- self.layers = nn.ModuleList(
- [
- Qwen3OmniCodePredictorDecoderLayer(
- vllm_config.model_config.hf_config,
- prefix=f"{prefix}.layers.{idx}",
- )
- for idx in range(config.num_hidden_layers)
- ]
- )
- self.norm = _RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
- self.rotary_emb = _RotaryEmbedding(config)
-
- def forward(
- self,
- inputs_embeds: torch.Tensor,
- position_ids: torch.Tensor,
- ) -> torch.Tensor:
- hidden_states = inputs_embeds
- position_embeddings = self.rotary_emb(hidden_states, position_ids)
- for layer in self.layers:
- hidden_states = layer(hidden_states, position_embeddings)
- hidden_states = self.norm(hidden_states)
- return hidden_states
-
-
-# ===================================================================
-# Code Predictor Wrapper (optimized re-prefill, persistent buffers)
-# ===================================================================
-
-
-class Qwen3OmniMoeTalkerCodePredictor(nn.Module):
- """Optimized code predictor -- re-prefill approach, no KV cache.
-
- Each AR step forwards the full growing sequence (len 2 -> num_code_groups+1)
- through the transformer. The extra O(T^2) FLOPs are negligible for
- short sequences, and this avoids all KV-cache management overhead.
-
- Optimizations:
- 1. Per-call embedding buffer -- avoids cross-request aliasing.
- 2. Pre-allocated position_ids -- no torch.arange per step.
- 3. Cached module references -- bypass ModuleList indexing.
- 4. torch.compile on inner transformer.
- 5. Inline sampling (top-k + top-p) -- no custom op overhead.
- """
-
- def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
- super().__init__()
-
- config = vllm_config.model_config.hf_config
- self.config = config
- self.quant_config = vllm_config.quant_config
- self.prefix = prefix
-
- self.num_code_groups = config.code_predictor_config.num_code_groups
- self._hidden_size = config.code_predictor_config.hidden_size
-
- self.model = Qwen3OmniCodePredictorBaseModel(
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
+ cp_config = vllm_config.model_config.hf_config.code_predictor_config
+ super().__init__(
vllm_config=vllm_config,
+ cp_config=cp_config,
+ wrapper_config=CodePredictorWrapperConfig(
+ use_cuda_graphs=False,
+ use_parallel_embedding=True,
+ use_projection=False,
+ return_proj_buf=True,
+ sampling_mode="stored",
+ ),
+ talker_hidden_size=cp_config.hidden_size,
prefix=prefix,
)
-
- # One lm_head per residual layer (layers 1 .. G-1)
- self.lm_head = nn.ModuleList(
- [
- nn.Linear(
- config.code_predictor_config.hidden_size,
- config.code_predictor_config.vocab_size,
- bias=False,
- )
- for _ in range(self.num_code_groups - 1)
- ]
- )
-
- self.set_sampling_params()
-
- # Lazily initialised position ids (read-only, safe to persist)
- self._pos_ids: torch.Tensor | None = None
-
- # Cached plain-list refs (set once)
- self._lm_heads: list | None = None
- self._codec_embeds: list | None = None
-
- # Model forward (optionally compiled)
- self._model_fwd: object | None = None
-
- def set_sampling_params(self, top_k: int = 50, top_p: float = 0.8):
- """Configure sampling parameters to maintain consistency with previous implementation."""
- self._top_k = top_k
- self._top_p = top_p
- logger.debug(f"Sampling parameters updated: top_k={top_k}, top_p={top_p}s")
-
- # ------------------------------------------------------------------
- # Lazy-init helpers
- # ------------------------------------------------------------------
-
- def _ensure_pos_ids(self, device: torch.device) -> None:
- if self._pos_ids is not None and self._pos_ids.device == device:
- return
- max_seq = self.num_code_groups + 1
- # [1, max_seq] for HF-style RoPE (will be expanded to [bsz, seq_len] at use)
- self._pos_ids = torch.arange(max_seq, dtype=torch.long, device=device).unsqueeze(0)
-
- def _ensure_cached_refs(self) -> None:
- if self._lm_heads is not None:
- return
- self._lm_heads = list(self.lm_head)
- self._codec_embeds = list(self.model.codec_embedding)
-
- def _ensure_model_fwd(self) -> None:
- if self._model_fwd is not None:
- return
- if current_omni_platform.supports_torch_inductor():
- # torch.compile fuses RMSNorm/RoPE in ways that lose float32
- # precision, compounding across AR steps. Use epilogue_fusion=False
- # to disable the problematic fusions while still getting kernel
- # fusion benefits for the linear layers and SDPA.
- self._model_fwd = torch.compile(
- self.model.forward,
- dynamic=True,
- options={
- "epilogue_fusion": False,
- },
- )
- logger.info("code_predictor: torch.compile enabled (no epilogue fusion)")
- else:
- self._model_fwd = self.model.forward
- logger.info("code_predictor: using eager mode (no torch.compile)")
-
- # ------------------------------------------------------------------
- # Forward -- re-prefill + inline sampling
- # ------------------------------------------------------------------
-
- @torch.inference_mode()
- def forward(
- self,
- layer0_code: torch.Tensor,
- layer0_embed: torch.Tensor,
- last_talker_hidden: torch.Tensor,
- ) -> tuple[torch.Tensor, torch.Tensor]:
- """Predict residual codebooks 1..G-1 autoregressively via re-prefill.
-
- Args:
- layer0_code: [bsz, 1] int64
- layer0_embed: [bsz, 1, hidden_size]
- last_talker_hidden: [bsz, 1, hidden_size]
-
- Returns:
- all_codes: [bsz, num_code_groups, 1]
- proj_buf: [bsz, num_code_groups + 1, hidden_size]
- pos 0 = last_talker_hidden (NOT a codec embed)
- pos 1 = layer0_embed
- pos 2.. = `codec_embedding[i](predicted_code_i)`
- """
- bsz = int(layer0_code.shape[0])
- device = layer0_code.device
- dtype = last_talker_hidden.dtype
- num_groups = self.num_code_groups
-
- # Lazy init (read-only caches only)
- self._ensure_pos_ids(device)
- self._ensure_model_fwd()
- self._ensure_cached_refs()
-
- # Allocate proj_buf locally each call to avoid cross-call aliasing
- max_seq = num_groups + 1
- proj_buf = torch.zeros(bsz, max_seq, self._hidden_size, dtype=dtype, device=device)
- pos_ids = self._pos_ids
- model_fwd = self._model_fwd
- lm_heads = self._lm_heads
- codec_embeds = self._codec_embeds
-
- # Output codes
- all_codes = torch.empty(bsz, num_groups, 1, dtype=torch.int64, device=device)
- all_codes[:, 0] = layer0_code
-
- # Fill buffer positions 0 & 1
- proj_buf[:bsz, 0:1, :] = last_talker_hidden
- proj_buf[:bsz, 1:2, :] = layer0_embed
-
- # Autoregressive loop: predict layers 1..G-1
- for step in range(1, num_groups):
- seq_len = step + 1
- projected = proj_buf[:bsz, :seq_len, :]
- # position_ids: [batch, seq_len] for HF-style RoPE
- step_pos_ids = pos_ids[:, :seq_len].expand(bsz, -1)
-
- hidden_out = model_fwd(projected, step_pos_ids)
-
- # Inline sampling: top-k -> top-p -> softmax -> multinomial
- logits = lm_heads[step - 1](hidden_out[:, -1, :]) # [bsz, vocab]
- if self._top_k > 0:
- topk_vals, _ = logits.topk(self._top_k, dim=-1)
- logits = logits.masked_fill(logits < topk_vals[:, -1:], float("-inf"))
- if self._top_p < 1.0:
- sorted_logits, sorted_idx = logits.sort(dim=-1, descending=True)
- cumulative_probs = F.softmax(sorted_logits, dim=-1).cumsum(dim=-1)
- # Remove tokens with cumulative probability above top_p
- remove_mask = cumulative_probs - F.softmax(sorted_logits, dim=-1) >= self._top_p
- sorted_logits[remove_mask] = float("-inf")
- logits = sorted_logits.scatter(1, sorted_idx, sorted_logits)
- probs = F.softmax(logits, dim=-1)
- code = torch.multinomial(probs, num_samples=1) # [bsz, 1]
-
- all_codes[:, step] = code
-
- # Embed predicted code -> next buffer position
- new_embed = codec_embeds[step - 1](code) # [batch, 1, hidden_size]
- proj_buf[:bsz, step + 1 : step + 2, :] = new_embed
-
- return all_codes, proj_buf[:bsz]
-
- # ------------------------------------------------------------------
- # Weight loading
- # ------------------------------------------------------------------
-
- def load_weights(self, weights: list[tuple[str, torch.Tensor]]) -> set[str]:
- """Load weights directly (no fused projection remapping needed).
-
- Since we use separate nn.Linear for q/k/v/o and gate/up/down,
- weight names match the HF checkpoint directly.
- """
- params_dict = dict(self.named_parameters())
- loaded_params: set[str] = set()
-
- for name, loaded_weight in weights:
- # Skip rotary embeddings
- if "rotary_emb.inv_freq" in name:
- continue
-
- param = params_dict.get(name)
- if param is None:
- continue
-
- weight_loader = getattr(param, "weight_loader", default_weight_loader)
- weight_loader(param, loaded_weight)
- loaded_params.add(name)
-
- return loaded_params
diff --git a/vllm_omni/model_executor/models/qwen3_tts/pipeline.py b/vllm_omni/model_executor/models/qwen3_tts/pipeline.py
new file mode 100644
index 000000000..5051715ce
--- /dev/null
+++ b/vllm_omni/model_executor/models/qwen3_tts/pipeline.py
@@ -0,0 +1,48 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Qwen3-TTS pipeline: Talker (text → RVQ codec) → Code2Wav (codec → audio).
+
+Chunked vs end-to-end mode is dispatched from ``deploy.async_chunk``.
+"""
+
+from vllm_omni.config.stage_config import (
+ PipelineConfig,
+ StageExecutionType,
+ StagePipelineConfig,
+)
+
+_PROC = "vllm_omni.model_executor.stage_input_processors.qwen3_tts"
+
+QWEN3_TTS_PIPELINE = PipelineConfig(
+ model_type="qwen3_tts",
+ # Pipeline-level default; the code2wav stage overrides per-stage below.
+ model_arch="Qwen3TTSTalkerForConditionalGeneration",
+ stages=(
+ StagePipelineConfig(
+ stage_id=0,
+ model_stage="qwen3_tts",
+ execution_type=StageExecutionType.LLM_AR,
+ input_sources=(),
+ owns_tokenizer=True,
+ engine_output_type="latent",
+ async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2code2wav_async_chunk"),
+ sampling_constraints={
+ "detokenize": False,
+ "stop_token_ids": [2150],
+ },
+ ),
+ StagePipelineConfig(
+ stage_id=1,
+ model_stage="code2wav",
+ execution_type=StageExecutionType.LLM_GENERATION,
+ input_sources=(0,),
+ final_output=True,
+ final_output_type="audio",
+ engine_output_type="audio",
+ model_arch="Qwen3TTSCode2Wav",
+ sync_process_input_func=f"{_PROC}.talker2code2wav",
+ sampling_constraints={"detokenize": True},
+ extras={"tts_args": {"max_instructions_length": 500}},
+ ),
+ ),
+)
diff --git a/vllm_omni/model_executor/models/qwen3_tts/pipeline.yaml b/vllm_omni/model_executor/models/qwen3_tts/pipeline.yaml
deleted file mode 100644
index fd8ea3a3f..000000000
--- a/vllm_omni/model_executor/models/qwen3_tts/pipeline.yaml
+++ /dev/null
@@ -1,93 +0,0 @@
-model_type: qwen3_tts
-async_chunk: true
-
-stages:
- - stage_id: 0
- model_stage: qwen3_tts
- stage_type: llm
- is_comprehension: true
- input_sources: []
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- runtime:
- devices: "0"
- engine_args:
- max_num_seqs: 10
- model_arch: Qwen3TTSTalkerForConditionalGeneration
- hf_overrides:
- architectures: [Qwen3TTSTalkerForConditionalGeneration]
- enforce_eager: false
- trust_remote_code: true
- async_scheduling: true
- enable_prefix_caching: false
- engine_output_type: latent
- gpu_memory_utilization: 0.08
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 512
- max_model_len: 4096
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
- output_connectors:
- to_stage_1: connector_of_shared_memory
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: false
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 1
- model_stage: code2wav
- stage_type: llm
- input_sources: [0]
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- final_output: true
- final_output_type: audio
- runtime:
- devices: "0"
- engine_args:
- max_num_seqs: 1
- model_arch: Qwen3TTSCode2Wav
- hf_overrides:
- architectures: [Qwen3TTSCode2Wav]
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: true
- enable_prefix_caching: false
- engine_output_type: audio
- gpu_memory_utilization: 0.08
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 65536
- max_model_len: 65536
- input_connectors:
- from_stage_0: connector_of_shared_memory
- tts_args:
- max_instructions_length: 500
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: true
- repetition_penalty: 1.0
-
-connectors:
- connector_of_shared_memory:
- name: SharedMemoryConnector
- extra:
- shm_threshold_bytes: 65536
- codec_streaming: true
- connector_get_sleep_s: 0.01
- connector_get_max_wait_first_chunk: 3000
- connector_get_max_wait: 300
- codec_chunk_frames: 25
- # Match the decoder sliding attention window to avoid chunk-boundary noise.
- codec_left_context_frames: 72
-
-edges:
- - from: 0
- to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_code_predictor_vllm.py b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_code_predictor_vllm.py
index 1e84eaeba..8d2f0686a 100644
--- a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_code_predictor_vllm.py
+++ b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_code_predictor_vllm.py
@@ -1,318 +1,27 @@
+"""Qwen3-TTS Code Predictor -- thin wrapper over CodePredictorWrapper."""
+
from __future__ import annotations
from collections.abc import Iterable
import torch
-import torch.nn as nn
-import torch.nn.functional as F
from vllm.config import VllmConfig
from vllm.config.vllm import set_current_vllm_config
-from vllm.logger import init_logger
-from vllm.model_executor.model_loader.weight_utils import (
- default_weight_loader,
-)
-from vllm_omni.platforms import current_omni_platform
+from vllm_omni.model_executor.models.common.qwen3_code_predictor import (
+ CodePredictorBaseModel,
+ CodePredictorWrapper,
+ CodePredictorWrapperConfig,
+)
from .configuration_qwen3_tts import Qwen3TTSTalkerCodePredictorConfig, Qwen3TTSTalkerConfig
-logger = init_logger(__name__)
+# Backward-compat alias used by tests
+Qwen3TTSTalkerCodePredictorModelVLLM = CodePredictorBaseModel
-# ===================================================================
-# HF-numerics-compatible layers for code predictor
-# ===================================================================
-#
-# These use plain PyTorch ops (nn.Linear, manual RMSNorm in float32,
-# rotate_half RoPE) to produce outputs numerically identical to the
-# HuggingFace reference. vLLM's fused kernels (RMSNorm, QKVParallel,
-# get_rope) introduce small precision differences that compound across
-# the 15 autoregressive steps of the code predictor, causing severe
-# audio quality degradation (UTMOS ~4.26 → ~2.66).
-#
-# See: https://github.com/vllm-project/vllm-omni/issues/2274
-
-
-class _RMSNorm(nn.Module):
- """RMSNorm matching HuggingFace's Qwen3TTSRMSNorm exactly.
-
- Computes variance in float32 to avoid bfloat16 precision loss.
- """
-
- def __init__(self, hidden_size: int, eps: float = 1e-6) -> None:
- super().__init__()
- self.weight = nn.Parameter(torch.ones(hidden_size))
- self.variance_epsilon = eps
-
- def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
- input_dtype = hidden_states.dtype
- hidden_states = hidden_states.to(torch.float32)
- variance = hidden_states.pow(2).mean(-1, keepdim=True)
- hidden_states = hidden_states * torch.rsqrt(variance + self.variance_epsilon)
- return self.weight * hidden_states.to(input_dtype)
-
-
-def _rotate_half(x: torch.Tensor) -> torch.Tensor:
- """Rotates half the hidden dims of the input."""
- x1 = x[..., : x.shape[-1] // 2]
- x2 = x[..., x.shape[-1] // 2 :]
- return torch.cat((-x2, x1), dim=-1)
-
-
-class _RotaryEmbedding(nn.Module):
- """RoPE matching HuggingFace's Qwen3TTSRotaryEmbedding exactly.
-
- Forces float32 computation for cos/sin, matching HF's torch.autocast(enabled=False).
- """
-
- def __init__(self, config: Qwen3TTSTalkerCodePredictorConfig) -> None:
- super().__init__()
- head_dim = getattr(
- config,
- "head_dim",
- config.hidden_size // config.num_attention_heads,
- )
- # Standard default RoPE
- rope_theta = getattr(config, "rope_theta", 10000.0)
- inv_freq = 1.0 / (rope_theta ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim))
- self.register_buffer("inv_freq", inv_freq, persistent=False)
-
- def forward(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
- # position_ids: [batch, seq_len]
- inv_freq_expanded = self.inv_freq[None, :, None].float().expand(position_ids.shape[0], -1, 1)
- position_ids_expanded = position_ids[:, None, :].float()
-
- # Force float32 (matching HF)
- device_type = x.device.type if isinstance(x.device.type, str) and x.device.type != "mps" else "cpu"
- with torch.autocast(device_type=device_type, enabled=False):
- freqs = (inv_freq_expanded.float() @ position_ids_expanded.float()).transpose(1, 2)
- emb = torch.cat((freqs, freqs), dim=-1)
- cos = emb.cos()
- sin = emb.sin()
-
- return cos.to(dtype=x.dtype), sin.to(dtype=x.dtype)
-
-
-class _CodePredictorAttention(nn.Module):
- """Standalone multi-head attention for code predictor.
-
- Uses F.scaled_dot_product_attention with HF-compatible RoPE and RMSNorm.
- Input: [B, seq_len, hidden_size], output: [B, seq_len, hidden_size].
- """
-
- def __init__(
- self,
- config: Qwen3TTSTalkerCodePredictorConfig,
- *,
- prefix: str = "",
- ) -> None:
- super().__init__()
- self.hidden_size = config.hidden_size
- self.num_heads = config.num_attention_heads
- self.num_kv_heads = config.num_key_value_heads
- self.head_dim = getattr(
- config,
- "head_dim",
- config.hidden_size // config.num_attention_heads,
- )
- self.scaling = self.head_dim**-0.5
- self._use_gqa = self.num_kv_heads != self.num_heads
-
- # Separate q/k/v projections matching HF (no fused packing)
- self.q_proj = nn.Linear(
- self.hidden_size,
- self.num_heads * self.head_dim,
- bias=getattr(config, "attention_bias", False),
- )
- self.k_proj = nn.Linear(
- self.hidden_size,
- self.num_kv_heads * self.head_dim,
- bias=getattr(config, "attention_bias", False),
- )
- self.v_proj = nn.Linear(
- self.hidden_size,
- self.num_kv_heads * self.head_dim,
- bias=getattr(config, "attention_bias", False),
- )
- self.o_proj = nn.Linear(
- self.num_heads * self.head_dim,
- self.hidden_size,
- bias=False,
- )
- self.q_norm = _RMSNorm(self.head_dim, eps=config.rms_norm_eps)
- self.k_norm = _RMSNorm(self.head_dim, eps=config.rms_norm_eps)
-
- def forward(
- self,
- hidden_states: torch.Tensor,
- position_embeddings: tuple[torch.Tensor, torch.Tensor],
- ) -> torch.Tensor:
- bsz, seq_len, _ = hidden_states.shape
- hidden_shape_q = (bsz, seq_len, self.num_heads, self.head_dim)
- hidden_shape_kv = (bsz, seq_len, self.num_kv_heads, self.head_dim)
-
- q = self.q_norm(self.q_proj(hidden_states).view(hidden_shape_q)).transpose(1, 2)
- k = self.k_norm(self.k_proj(hidden_states).view(hidden_shape_kv)).transpose(1, 2)
- v = self.v_proj(hidden_states).view(hidden_shape_kv).transpose(1, 2)
-
- cos, sin = position_embeddings
- # cos/sin are [batch, seq_len, head_dim], need unsqueeze at dim=1 for heads
- cos = cos.unsqueeze(1) # [batch, 1, seq_len, head_dim]
- sin = sin.unsqueeze(1)
- q = (q * cos) + (_rotate_half(q) * sin)
- k = (k * cos) + (_rotate_half(k) * sin)
-
- attn_out = F.scaled_dot_product_attention(
- q,
- k,
- v,
- scale=self.scaling,
- is_causal=True,
- enable_gqa=self._use_gqa,
- )
-
- attn_out = attn_out.transpose(1, 2).reshape(bsz, seq_len, -1)
- output = self.o_proj(attn_out)
- return output
-
-
-class _CodePredictorMLP(nn.Module):
- """SiLU-gated MLP for code predictor, matching HF's Qwen3TTSTalkerTextMLP."""
-
- def __init__(
- self,
- config: Qwen3TTSTalkerCodePredictorConfig,
- *,
- prefix: str = "",
- ) -> None:
- super().__init__()
- self.gate_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
- self.up_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
- self.down_proj = nn.Linear(config.intermediate_size, config.hidden_size, bias=False)
-
- def forward(self, x: torch.Tensor) -> torch.Tensor:
- return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
-
-
-class _CodePredictorDecoderLayer(nn.Module):
- """Transformer decoder layer for code predictor (SDPA, no KV cache)."""
-
- def __init__(
- self,
- config: Qwen3TTSTalkerCodePredictorConfig,
- *,
- prefix: str = "",
- ) -> None:
- super().__init__()
- self.self_attn = _CodePredictorAttention(config, prefix=f"{prefix}.self_attn")
- self.mlp = _CodePredictorMLP(config, prefix=f"{prefix}.mlp")
- self.input_layernorm = _RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
- self.post_attention_layernorm = _RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
-
- def forward(
- self,
- hidden_states: torch.Tensor,
- position_embeddings: tuple[torch.Tensor, torch.Tensor],
- ) -> torch.Tensor:
- residual = hidden_states
- hidden_states = self.input_layernorm(hidden_states)
- hidden_states = self.self_attn(hidden_states, position_embeddings)
- hidden_states = residual + hidden_states
-
- residual = hidden_states
- hidden_states = self.post_attention_layernorm(hidden_states)
- hidden_states = self.mlp(hidden_states)
- hidden_states = residual + hidden_states
- return hidden_states
-
-
-# ===================================================================
-# Code Predictor Transformer Model
-# ===================================================================
-
-
-class Qwen3TTSTalkerCodePredictorModelVLLM(nn.Module):
- """Transformer model for the code predictor (re-prefill, no KV cache)."""
-
- def __init__(
- self,
- config: Qwen3TTSTalkerCodePredictorConfig,
- *,
- talker_hidden_size: int | None = None,
- prefix: str = "",
- ) -> None:
- super().__init__()
- self.config = config
-
- self.layers = nn.ModuleList(
- [_CodePredictorDecoderLayer(config, prefix=f"{prefix}.layers.{i}") for i in range(config.num_hidden_layers)]
- )
- self.norm = _RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
- self.rotary_emb = _RotaryEmbedding(config)
-
- # Codec embeddings: one per residual group. Stored in talker hidden dim
- # (some checkpoints use talker_hidden_size != code_predictor hidden_size).
- emb_dim = int(talker_hidden_size) if talker_hidden_size is not None else int(config.hidden_size)
- self.codec_embedding = nn.ModuleList(
- [nn.Embedding(config.vocab_size, emb_dim) for _ in range(config.num_code_groups - 1)]
- )
-
- def get_input_embeddings(self) -> nn.ModuleList:
- return self.codec_embedding
-
- def forward(
- self,
- inputs_embeds: torch.Tensor,
- position_ids: torch.Tensor,
- ) -> torch.Tensor:
- hidden_states = inputs_embeds
- position_embeddings = self.rotary_emb(hidden_states, position_ids)
- for layer in self.layers:
- hidden_states = layer(hidden_states, position_embeddings)
- hidden_states = self.norm(hidden_states)
- return hidden_states
-
- def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
- params_dict = dict(self.named_parameters(remove_duplicate=False))
- loaded_params: set[str] = set()
- for name, loaded_weight in weights:
- if "rotary_emb.inv_freq" in name:
- continue
- param = params_dict.get(name)
- if param is None:
- continue
- weight_loader = getattr(param, "weight_loader", default_weight_loader)
- weight_loader(param, loaded_weight)
- loaded_params.add(name)
- return loaded_params
-
-
-# ===================================================================
-# Code Predictor Wrapper (optimized re-prefill + torch.compile)
-# ===================================================================
-
-
-class Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM(nn.Module):
- """vLLM-native code_predictor for the AR talker (residual codebooks).
-
- Re-prefill approach: each AR step forwards the full growing sequence
- through the 5-layer transformer. No KV cache needed. This trades
- ~O(T^2) extra attention FLOPs (negligible for T=16, 5 layers) for
- zero KV cache management overhead and a simpler execution model.
-
- Uses HF-compatible layers (plain nn.Linear, float32 RMSNorm, rotate_half
- RoPE) to ensure numerical fidelity with the reference implementation.
- Precision matters here because small errors compound across 15 AR steps.
-
- Optimizations preserved:
- 1. torch.compile on model forward -- fuses small kernel launches.
- 2. Pre-allocated embedding buffer [B, max_seq, H] -- no torch.cat per step.
- 3. Projection caching -- each token projected once and cached.
- 4. Pre-allocated position_ids -- no torch.arange per step.
- 5. Inline sampling -- no custom op / forward_context overhead.
- 6. Cached module references -- bypass nn.Module.__call__ overhead.
- 7. CUDA graphs per batch-size bucket.
- """
+class Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM(CodePredictorWrapper):
+ """Qwen3-TTS code predictor (CUDA graphs, per-call sampling, projection)."""
def __init__(
self,
@@ -322,250 +31,24 @@ class Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM(nn.Module):
talker_config: Qwen3TTSTalkerConfig,
prefix: str = "code_predictor",
) -> None:
- super().__init__()
- self._vllm_config = vllm_config
- self.config = config
- self.talker_config = talker_config
-
- self.model = Qwen3TTSTalkerCodePredictorModelVLLM(
- config,
+ super().__init__(
+ vllm_config=vllm_config,
+ cp_config=config,
+ wrapper_config=CodePredictorWrapperConfig(
+ use_cuda_graphs=True,
+ use_parallel_embedding=False,
+ use_projection=(config.hidden_size != talker_config.hidden_size),
+ return_proj_buf=False,
+ sampling_mode="per_call",
+ ),
talker_hidden_size=int(talker_config.hidden_size),
- prefix=f"{prefix}.model",
+ prefix=prefix,
)
-
- self.lm_head = nn.ModuleList(
- [nn.Linear(config.hidden_size, config.vocab_size, bias=False) for _ in range(config.num_code_groups - 1)]
- )
-
- if config.hidden_size != talker_config.hidden_size:
- self.small_to_mtp_projection = nn.Linear(talker_config.hidden_size, config.hidden_size, bias=True)
- else:
- self.small_to_mtp_projection = nn.Identity()
-
- self._num_groups = int(config.num_code_groups)
- self._talker_hidden = int(talker_config.hidden_size)
- self._cp_hidden = int(config.hidden_size)
-
- # Pre-allocated buffers (lazily initialized on first forward).
- self._proj_buf: torch.Tensor | None = None
- self._model_dtype: torch.dtype | None = None
-
- # torch.compile + warmup state (lazily initialized in _setup_compile).
- self._compiled_model_fwd = None
- self._bucket_sizes: list[int] = []
- self._bucket_pos_ids: dict[int, torch.Tensor] = {}
- self._lm_heads_list: list[nn.Module] | None = None
- self._codec_embeds_list: list[nn.Module] | None = None
- self._cuda_graphs: dict[int, tuple[torch.cuda.CUDAGraph, torch.Tensor]] = {}
-
- def get_input_embeddings(self) -> nn.ModuleList:
- return self.model.get_input_embeddings()
+ # Store talker_config for backward compat (accessed by some callers)
+ self.talker_config = talker_config
+ self._vllm_config = vllm_config
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ """Load weights with vllm config context (required for VocabParallelEmbedding)."""
with set_current_vllm_config(self._vllm_config):
- loaded: set[str] = set()
- model_weights: list[tuple[str, torch.Tensor]] = []
- other_weights: list[tuple[str, torch.Tensor]] = []
- for name, w in weights:
- if name.startswith("model."):
- model_weights.append((name[len("model.") :], w))
- else:
- other_weights.append((name, w))
-
- loaded_model = self.model.load_weights(model_weights)
- loaded |= {f"model.{n}" for n in loaded_model}
-
- params = dict(self.named_parameters(remove_duplicate=False))
- for name, w in other_weights:
- if name not in params:
- continue
- default_weight_loader(params[name], w)
- loaded.add(name)
-
- return loaded
-
- # ------------------------------------------------------------------
- # Pre-allocated buffer management
- # ------------------------------------------------------------------
-
- def _ensure_buffers(self, device: torch.device, dtype: torch.dtype) -> None:
- max_seq = self._num_groups + 1
- if self._proj_buf is not None and self._proj_buf.device == device and self._proj_buf.dtype == dtype:
- return
- max_bsz = self._vllm_config.scheduler_config.max_num_seqs
- self._proj_buf = torch.zeros(
- max_bsz,
- max_seq,
- self._cp_hidden,
- dtype=dtype,
- device=device,
- )
-
- def _setup_compile(self) -> None:
- """Lazily set up torch.compile with manual CUDA graph capture."""
- if self._compiled_model_fwd is not None:
- return
- # Cache model parameter dtype so forward() doesn't need to query it
- # on every call. Also ensures warmup buffers match model precision
- # even when upstream modules produce a different dtype (#2385).
- self._model_dtype = next(self.model.parameters()).dtype
- self._lm_heads_list = list(self.lm_head)
- self._codec_embeds_list = list(self.model.codec_embedding)
- if not current_omni_platform.supports_torch_inductor():
- logger.warning_once("code_predictor: torch.compile disabled")
- self._compiled_model_fwd = self.model.forward
- return
-
- # torch.compile fuses RMSNorm/RoPE in ways that lose float32
- # precision, compounding across 15 AR steps. Use torch.compile
- # with options that disable the problematic fusions while still
- # getting kernel fusion benefits for the linear layers and SDPA.
- self._compiled_model_fwd = torch.compile(
- self.model.forward,
- dynamic=False,
- options={
- "epilogue_fusion": False,
- },
- )
- self._warmup_buckets()
- self._capture_cuda_graphs()
- logger.info("code_predictor: torch.compile (no epilogue fusion) + CUDA graphs")
-
- def _padded_bsz(self, bsz: int) -> int:
- for bucket in self._bucket_sizes:
- if bsz <= bucket:
- return bucket
- return bsz
-
- def _warmup_buckets(self) -> None:
- """Warmup power-of-2 batch-size buckets to front-load Inductor compilation."""
- max_bsz = self._vllm_config.scheduler_config.max_num_seqs
- bucket_sizes = [1 << i for i in range(max_bsz.bit_length()) if (1 << i) <= max_bsz]
- if max_bsz not in bucket_sizes:
- bucket_sizes.append(max_bsz)
- self._bucket_sizes = sorted(bucket_sizes)
-
- max_seq = self._num_groups + 1
- device = next(self.model.parameters()).device
-
- # Ensure proj_buf matches model parameter dtype to avoid dtype
- # mismatch during warmup compilation (see #2385).
- self._ensure_buffers(device, self._model_dtype)
- proj_buf = self._proj_buf
- for bsz in self._bucket_sizes:
- # position_ids: [batch, seq_len] for HF-style RoPE
- pos_ids = torch.arange(max_seq, device=device, dtype=torch.long).unsqueeze(0).expand(bsz, -1)
- self._bucket_pos_ids[bsz] = pos_ids
- for _ in range(3):
- self._compiled_model_fwd(proj_buf[:bsz, :max_seq, :], pos_ids)
- logger.info("code_predictor: warmup done for buckets %s", self._bucket_sizes)
-
- def _capture_cuda_graphs(self) -> None:
- """Capture a CUDA graph per bucket using vLLM's global graph pool."""
- from vllm.platforms import current_platform
-
- pool = current_platform.get_global_graph_pool()
-
- max_seq = self._num_groups + 1
- proj_buf = self._proj_buf
-
- for bsz in self._bucket_sizes:
- static_input = proj_buf[:bsz, :max_seq, :]
- pos_ids = self._bucket_pos_ids[bsz]
-
- g = torch.cuda.CUDAGraph()
- with torch.cuda.graph(g, pool=pool):
- static_output = self._compiled_model_fwd(static_input, pos_ids)
-
- self._cuda_graphs[bsz] = (g, static_output)
-
- logger.info("code_predictor: captured CUDA graphs for buckets %s", self._bucket_sizes)
-
- # ------------------------------------------------------------------
- # Optimized forward: re-prefill + torch.compile + projection cache
- # ------------------------------------------------------------------
-
- @torch.inference_mode()
- def forward(
- self,
- layer0_code: torch.Tensor,
- layer0_embed: torch.Tensor,
- last_talker_hidden: torch.Tensor,
- do_sample: bool = True,
- temperature: float = 0.9,
- top_k: int = 50,
- top_p: float = 1.0,
- ) -> torch.Tensor:
- """Predict residual codebooks 1..Q-1 autoregressively via re-prefill.
-
- torch.compile fuses the ~60 small kernel launches per step into fewer
- fused kernels, reducing kernel launch overhead by ~75%.
-
- Projection caching: each token is projected once via small_to_mtp_projection
- and cached in _proj_buf, avoiding redundant re-projection of past tokens.
- """
- bsz = int(layer0_code.shape[0])
- num_groups = self._num_groups
- device = layer0_code.device
-
- all_codes = torch.empty(bsz, num_groups, dtype=torch.long, device=device)
- all_codes[:, 0] = layer0_code.reshape(bsz)
-
- # _setup_compile caches _model_dtype on first call; use it for buffers
- # so they always match model weight precision (#2385).
- self._setup_compile()
- dtype = self._model_dtype
- self._ensure_buffers(device, dtype)
-
- proj_buf = self._proj_buf
- max_seq = self._num_groups + 1
-
- projection = self.small_to_mtp_projection
- model_fwd = self._compiled_model_fwd
- lm_heads = self._lm_heads_list
- codec_embeds = self._codec_embeds_list
-
- use_sampling = do_sample and temperature > 0
- inv_temperature = 1.0 / max(temperature, 1e-6) if use_sampling else 0.0
- if use_sampling and top_p != 1.0:
- raise NotImplementedError(
- "top_p sampling is not implemented for the vLLM-native code predictor; please set top_p=1.0."
- )
-
- padded_bsz = self._padded_bsz(bsz)
- proj_buf[:padded_bsz].zero_()
-
- proj_buf[:bsz, 0, :] = projection(last_talker_hidden.reshape(bsz, 1, -1).to(dtype)).reshape(bsz, -1)
- proj_buf[:bsz, 1, :] = projection(layer0_embed.reshape(bsz, 1, -1).to(dtype)).reshape(bsz, -1)
- full_pos_ids = self._bucket_pos_ids.get(padded_bsz)
- if full_pos_ids is None:
- full_pos_ids = torch.arange(max_seq, device=device, dtype=torch.long).unsqueeze(0).expand(padded_bsz, -1)
-
- # Use captured CUDA graph if available, otherwise call compiled fn.
- cuda_graph_entry = self._cuda_graphs.get(padded_bsz)
-
- for step in range(1, num_groups):
- if cuda_graph_entry is not None:
- cuda_graph_entry[0].replay()
- hidden_out = cuda_graph_entry[1]
- else:
- hidden_out = model_fwd(proj_buf[:padded_bsz, :max_seq, :], full_pos_ids)
- logits = lm_heads[step - 1](hidden_out[:bsz, step, :])
-
- if use_sampling:
- scaled = logits * inv_temperature
- if top_k > 0:
- topk_vals, _ = scaled.topk(top_k, dim=-1)
- scaled = scaled.masked_fill(scaled < topk_vals[:, -1:], float("-inf"))
- probs = F.softmax(scaled, dim=-1)
- next_ids = torch.multinomial(probs, num_samples=1)
- else:
- next_ids = logits.argmax(dim=-1, keepdim=True)
-
- all_codes[:, step] = next_ids.reshape(bsz)
-
- if step < num_groups - 1:
- new_embed = codec_embeds[step - 1](next_ids)
- proj_buf[:bsz, step + 1, :] = projection(new_embed.reshape(bsz, 1, -1)).reshape(bsz, -1)
-
- return all_codes
+ return super().load_weights(weights)
diff --git a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py
index 6b7b688f1..31fd80622 100644
--- a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py
+++ b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py
@@ -23,6 +23,7 @@ from vllm.model_executor.layers.logits_processor import LogitsProcessor
from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead
from vllm.model_executor.models.qwen3 import Qwen3Model
from vllm.model_executor.models.utils import AutoWeightsLoader, PPMissingLayer, WeightsMapper, maybe_prefix
+from vllm.multimodal.audio import AudioResampler
from vllm.sequence import IntermediateTensors
from vllm_omni.model_executor.models.output_templates import OmniOutput
@@ -414,6 +415,10 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
# In-memory LRU cache for voice extraction artifacts (Base voice clone).
self._voice_cache = VoiceEmbeddingCache()
+ raw_subtalker_sampling = getattr(vllm_config.model_config, "subtalker_sampling_params", None)
+ self._subtalker_sampling_params: dict[str, Any] = (
+ dict(raw_subtalker_sampling) if isinstance(raw_subtalker_sampling, Mapping) else {}
+ )
# -------------------- vLLM required hooks --------------------
@@ -1094,9 +1099,8 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
# Resample to 24kHz for speaker encoder.
target_sr = int(getattr(self.config.speaker_encoder_config, "sample_rate", 24000))
if sr != target_sr:
- from vllm.multimodal.audio import resample_audio_resampy
-
- wav = resample_audio_resampy(wav.astype(np.float32), orig_sr=int(sr), target_sr=target_sr)
+ resampler = AudioResampler(target_sr=target_sr)
+ wav = resampler.resample(wav.astype(np.float32), orig_sr=int(sr))
sr = target_sr
# Follow official implementation: mel_spectrogram expects 24kHz.
@@ -1638,6 +1642,10 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
input_embeds: torch.Tensor,
last_talker_hidden: torch.Tensor,
text_step: torch.Tensor,
+ do_sample: bool | None = None,
+ temperature: float | None = None,
+ top_k: int | None = None,
+ top_p: float | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""GPU fast-path used by OmniGPUModelRunner to predict residual codebooks (1..Q-1).
Returns (inputs_embeds, audio_codes) for the current step."""
@@ -1656,15 +1664,24 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
audio_codes = input_ids.reshape(bsz, 1)
return (last_id_hidden + text_step).reshape(bsz, -1), audio_codes
- # Predict residual codes (1..Q-1) with HF reference sampling params.
+ subtalker_params = self._subtalker_sampling_params
+ if do_sample is None:
+ do_sample = bool(subtalker_params.get("do_sample", True))
+ if temperature is None:
+ temperature = float(subtalker_params.get("temperature", 0.9))
+ if top_k is None:
+ top_k = int(subtalker_params.get("top_k", 50))
+ if top_p is None:
+ top_p = float(subtalker_params.get("top_p", 1.0))
+
audio_codes = self.code_predictor(
layer0_code=input_ids.reshape(bsz, 1),
layer0_embed=last_id_hidden,
last_talker_hidden=past_hidden,
- do_sample=True,
- temperature=0.9,
- top_k=50,
- top_p=1.0,
+ do_sample=do_sample,
+ temperature=temperature,
+ top_k=top_k,
+ top_p=top_p,
) # [B, Q]
# Map invalid layer-0 ids (e.g. EOS) to PAD=0 so SpeechTokenizer sees only real codes.
diff --git a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_tokenizer.py b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_tokenizer.py
index 3db5cfd1b..14bfbc5ee 100644
--- a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_tokenizer.py
+++ b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_tokenizer.py
@@ -22,7 +22,7 @@ import soundfile as sf
import torch
from torch.nn.utils.rnn import pad_sequence
from transformers import AutoConfig, AutoFeatureExtractor, AutoModel
-from vllm.multimodal.audio import resample_audio_resampy
+from vllm.multimodal.audio import AudioResampler
from vllm.multimodal.media.audio import load_audio as _load_audio_file
from .tokenizer_12hz.configuration_qwen3_tts_tokenizer_v2 import Qwen3TTSTokenizerV2Config
@@ -161,7 +161,8 @@ class Qwen3TTSTokenizer:
audio = np.mean(audio, axis=-1)
if sr != target_sr:
- audio = resample_audio_resampy(audio, orig_sr=sr, target_sr=target_sr)
+ resampler = AudioResampler(target_sr=target_sr)
+ audio = resampler.resample(audio, orig_sr=sr)
return audio.astype(np.float32)
@@ -209,7 +210,8 @@ class Qwen3TTSTokenizer:
if a.ndim > 1:
a = np.mean(a, axis=-1)
if int(sr) != target_sr:
- a = resample_audio_resampy(a.astype(np.float32), orig_sr=int(sr), target_sr=target_sr)
+ resampler = AudioResampler(target_sr=target_sr)
+ a = resampler.resample(a.astype(np.float32), orig_sr=int(sr))
out.append(a.astype(np.float32))
return out
diff --git a/vllm_omni/model_executor/models/qwen3_tts/tokenizer_25hz/vq/speech_vq.py b/vllm_omni/model_executor/models/qwen3_tts/tokenizer_25hz/vq/speech_vq.py
index 9bb2f78c5..f7e664c74 100644
--- a/vllm_omni/model_executor/models/qwen3_tts/tokenizer_25hz/vq/speech_vq.py
+++ b/vllm_omni/model_executor/models/qwen3_tts/tokenizer_25hz/vq/speech_vq.py
@@ -17,17 +17,17 @@ import operator
from itertools import accumulate
import onnxruntime
-import sox
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchaudio.compliance.kaldi as kaldi
from torch import Tensor
-from vllm_omni.utils.audio import mel_filter_bank
+from vllm_omni.model_executor.models.whisper_utils import Conv1d, ConvTranspose1d
+from vllm_omni.utils.audio import mel_filter_bank, peak_normalize
from .core_vq import DistributedGroupResidualVectorQuantization
-from .whisper_encoder import Conv1d, ConvTranspose1d, WhisperEncoder
+from .whisper_encoder import WhisperEncoder
def dynamic_range_compression_torch(x, c=1, clip_val=1e-5):
@@ -153,9 +153,6 @@ class XVectorExtractor(nn.Module):
audio_codec_with_xvector, sess_options=option, providers=providers
)
- self.tfm = sox.Transformer()
- self.tfm.norm(db_level=-6)
-
self.mel_ext = MelSpectrogramFeatures(
filter_length=1024,
hop_length=160,
@@ -183,8 +180,7 @@ class XVectorExtractor(nn.Module):
return norm_embedding.numpy(), ref_mel.permute(0, 2, 1).squeeze(0).numpy()
def sox_norm(self, audio):
- wav_norm = self.tfm.build_array(input_array=audio, sample_rate_in=16000)
- return wav_norm
+ return peak_normalize(audio, db_level=-6)
class WhisperEncoderVQ(WhisperEncoder):
diff --git a/vllm_omni/model_executor/models/qwen3_tts/tokenizer_25hz/vq/whisper_encoder.py b/vllm_omni/model_executor/models/qwen3_tts/tokenizer_25hz/vq/whisper_encoder.py
index 8464f53c9..7756720b2 100644
--- a/vllm_omni/model_executor/models/qwen3_tts/tokenizer_25hz/vq/whisper_encoder.py
+++ b/vllm_omni/model_executor/models/qwen3_tts/tokenizer_25hz/vq/whisper_encoder.py
@@ -23,6 +23,7 @@ import torch.nn.functional as F
from torch import Tensor, nn
from vllm_omni.diffusion.attention.backends.utils.fa import HAS_FLASH_ATTN, flash_attn_varlen_func
+from vllm_omni.model_executor.models.whisper_utils import Conv1d, Linear, sinusoids
from vllm_omni.utils.audio import mel_filter_bank
N_FFT = 400
@@ -102,30 +103,6 @@ def get_mel_audio(audio, padding=False, audio_vq_ds_rate=1, n_mels=128):
return mel
-def sinusoids(length, channels, max_timescale=10000):
- """Returns sinusoids for positional embedding"""
- assert channels % 2 == 0
- log_timescale_increment = np.log(max_timescale) / (channels // 2 - 1)
- inv_timescales = torch.exp(-log_timescale_increment * torch.arange(channels // 2))
- scaled_time = torch.arange(length)[:, np.newaxis] * inv_timescales[np.newaxis, :]
- return torch.cat([torch.sin(scaled_time), torch.cos(scaled_time)], dim=1)
-
-
-class Conv1d(nn.Conv1d):
- def _conv_forward(self, x: Tensor, weight: Tensor, bias: Tensor | None) -> Tensor:
- return super()._conv_forward(x, weight.to(x.dtype), None if bias is None else bias.to(x.dtype))
-
-
-class ConvTranspose1d(nn.ConvTranspose1d):
- def _conv_forward(self, x: Tensor, weight: Tensor, bias: Tensor | None) -> Tensor:
- return super()._conv_forward(x, weight.to(x.dtype), None if bias is None else bias.to(x.dtype))
-
-
-class Linear(nn.Linear):
- def forward(self, x: Tensor) -> Tensor:
- return F.linear(x, self.weight.to(x.dtype), None if self.bias is None else self.bias.to(x.dtype))
-
-
class MultiHeadAttention(nn.Module):
def __init__(self, n_state: int, n_head: int, use_flash_attention: bool = True):
super().__init__()
diff --git a/vllm_omni/model_executor/models/registry.py b/vllm_omni/model_executor/models/registry.py
index 2411a388c..13b002c15 100644
--- a/vllm_omni/model_executor/models/registry.py
+++ b/vllm_omni/model_executor/models/registry.py
@@ -145,6 +145,12 @@ _OMNI_MODELS = {
"fish_speech_dac_decoder",
"FishSpeechDACDecoder",
),
+ ## VoxCPM
+ "VoxCPMForConditionalGeneration": (
+ "voxcpm",
+ "voxcpm",
+ "VoxCPMForConditionalGeneration",
+ ),
## VoxCPM2
"VoxCPM2TalkerForConditionalGeneration": (
"voxcpm2",
@@ -174,6 +180,23 @@ _OMNI_MODELS = {
"dynin_omni",
"DyninOmniForConditionalGeneration",
),
+ ## Ming-flash-omni-2.0
+ "MingFlashOmniForConditionalGeneration": (
+ "ming_flash_omni",
+ "ming_flash_omni",
+ "MingFlashOmniForConditionalGeneration",
+ ),
+ "MingFlashOmniThinkerForConditionalGeneration": (
+ "ming_flash_omni",
+ "ming_flash_omni_thinker",
+ "MingFlashOmniThinkerForConditionalGeneration",
+ ),
+ # Alias: HF repo currently ships this architecture name in config.json
+ "BailingMM2NativeForConditionalGeneration": (
+ "ming_flash_omni",
+ "ming_flash_omni",
+ "MingFlashOmniForConditionalGeneration",
+ ),
}
diff --git a/vllm_omni/model_executor/models/voxcpm/__init__.py b/vllm_omni/model_executor/models/voxcpm/__init__.py
new file mode 100644
index 000000000..3b064c0f6
--- /dev/null
+++ b/vllm_omni/model_executor/models/voxcpm/__init__.py
@@ -0,0 +1,7 @@
+from .configuration_voxcpm import VoxCPMConfig
+from .voxcpm import VoxCPMForConditionalGeneration
+
+__all__ = [
+ "VoxCPMConfig",
+ "VoxCPMForConditionalGeneration",
+]
diff --git a/vllm_omni/model_executor/models/voxcpm/configuration_voxcpm.py b/vllm_omni/model_executor/models/voxcpm/configuration_voxcpm.py
new file mode 100644
index 000000000..ce1d809bd
--- /dev/null
+++ b/vllm_omni/model_executor/models/voxcpm/configuration_voxcpm.py
@@ -0,0 +1,3 @@
+from vllm_omni.transformers_utils.configs.voxcpm import VoxCPMConfig
+
+__all__ = ["VoxCPMConfig"]
diff --git a/vllm_omni/model_executor/models/voxcpm/voxcpm.py b/vllm_omni/model_executor/models/voxcpm/voxcpm.py
new file mode 100644
index 000000000..6fa36fc42
--- /dev/null
+++ b/vllm_omni/model_executor/models/voxcpm/voxcpm.py
@@ -0,0 +1,886 @@
+from __future__ import annotations
+
+import json
+import os
+import sys
+import tempfile
+import warnings
+import wave
+from collections.abc import Callable, Generator, Iterable
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import torch
+import torch.nn as nn
+from einops import rearrange
+from tqdm import tqdm
+from vllm.config import VllmConfig
+from vllm.logger import init_logger
+from vllm.sequence import IntermediateTensors
+
+from vllm_omni.model_executor.models.output_templates import OmniOutput
+
+from .voxcpm_loader import (
+ _build_prompt_cache_with_soundfile,
+ _device_to_string,
+ _force_cuda_available_for_npu,
+ _import_voxcpm_audio_vae_classes,
+ _import_voxcpm_base_model_class,
+ _is_torchcodec_load_error,
+ _normalize_dtype_name,
+ _prepare_runtime_model_dir,
+ _resolve_runtime_device,
+)
+from .voxcpm_runtime_utils import resolve_voxcpm_model_dir
+from .voxcpm_stage_wrappers import _DirectVoxCPMAudioVAE, _DirectVoxCPMLatentGenerator
+
+logger = init_logger(__name__)
+_VOXCPM_LATENT_MAGIC = 131071
+
+
+def _make_voxcpm_model_for_omni(base: type[Any]) -> type[Any]:
+ """Subclass upstream VoxCPMModel: local ``_inference`` + ``latents_only`` prompt-cache generation."""
+
+ from voxcpm.model.utils import get_dtype
+
+ class VoxCPMModelForOmni(base):
+ @torch.inference_mode()
+ def build_prompt_cache(self, *args: Any, **kwargs: Any):
+ try:
+ return super().build_prompt_cache(*args, **kwargs)
+ except (ImportError, ModuleNotFoundError, RuntimeError) as exc:
+ if not _is_torchcodec_load_error(exc):
+ raise
+ return _build_prompt_cache_with_soundfile(self, *args, **kwargs)
+
+ @torch.inference_mode()
+ def _inference(
+ self,
+ text: torch.Tensor,
+ text_mask: torch.Tensor,
+ feat: torch.Tensor,
+ feat_mask: torch.Tensor,
+ min_len: int = 2,
+ max_len: int = 2000,
+ inference_timesteps: int = 10,
+ cfg_value: float = 2.0,
+ streaming: bool = False,
+ streaming_prefix_len: int = 3,
+ ) -> Generator[tuple[torch.Tensor, torch.Tensor | list[torch.Tensor]], None, None]:
+ B, _, _, _ = feat.shape
+
+ feat_embed = self.feat_encoder(feat)
+ feat_embed = self.enc_to_lm_proj(feat_embed)
+
+ scale_emb = self.config.lm_config.scale_emb if self.config.lm_config.use_mup else 1.0
+ text_embed = self.base_lm.embed_tokens(text) * scale_emb
+ combined_embed = text_mask.unsqueeze(-1) * text_embed + feat_mask.unsqueeze(-1) * feat_embed
+
+ prefix_feat_cond = feat[:, -1, ...]
+ pred_feat_seq: list[torch.Tensor] = []
+
+ audio_patch_count = int(feat_mask.sum().item())
+ if audio_patch_count > 0:
+ context_len = min(streaming_prefix_len - 1, audio_patch_count)
+ prompt_context_patches = list(feat[:, -context_len:, :, :].split(1, dim=1))
+ pred_feat_seq = prompt_context_patches + pred_feat_seq
+
+ enc_outputs, kv_cache_tuple = self.base_lm(
+ inputs_embeds=combined_embed,
+ is_causal=True,
+ )
+ self.base_lm.kv_cache.fill_caches(kv_cache_tuple)
+
+ enc_outputs = self.fsq_layer(enc_outputs) * feat_mask.unsqueeze(-1) + enc_outputs * text_mask.unsqueeze(-1)
+ lm_hidden = enc_outputs[:, -1, :]
+
+ residual_enc_outputs, residual_kv_cache_tuple = self.residual_lm(
+ inputs_embeds=enc_outputs + feat_mask.unsqueeze(-1) * feat_embed,
+ is_causal=True,
+ )
+ self.residual_lm.kv_cache.fill_caches(residual_kv_cache_tuple)
+ residual_hidden = residual_enc_outputs[:, -1, :]
+
+ for step_idx in tqdm(range(max_len)):
+ dit_hidden = self.lm_to_dit_proj(lm_hidden) + self.res_to_dit_proj(residual_hidden)
+ pred_feat = self.feat_decoder(
+ mu=dit_hidden,
+ patch_size=self.patch_size,
+ cond=prefix_feat_cond.transpose(1, 2).contiguous(),
+ n_timesteps=inference_timesteps,
+ cfg_value=cfg_value,
+ ).transpose(1, 2)
+
+ curr_embed = self.enc_to_lm_proj(self.feat_encoder(pred_feat.unsqueeze(1)))
+ pred_feat_seq.append(pred_feat.unsqueeze(1))
+ prefix_feat_cond = pred_feat
+
+ if streaming:
+ pred_feat_chunk = torch.cat(pred_feat_seq[-streaming_prefix_len:], dim=1)
+ feat_pred = rearrange(pred_feat_chunk, "b t p d -> b d (t p)", b=B, p=self.patch_size)
+ yield feat_pred, pred_feat_seq
+
+ stop_flag = self.stop_head(self.stop_actn(self.stop_proj(lm_hidden))).argmax(dim=-1)[0].cpu().item()
+ if step_idx > min_len and stop_flag == 1:
+ break
+
+ lm_hidden = self.base_lm.forward_step(
+ curr_embed[:, 0, :],
+ torch.tensor([self.base_lm.kv_cache.step()], device=curr_embed.device),
+ ).clone()
+ lm_hidden = self.fsq_layer(lm_hidden)
+ residual_hidden = self.residual_lm.forward_step(
+ lm_hidden + curr_embed[:, 0, :],
+ torch.tensor([self.residual_lm.kv_cache.step()], device=curr_embed.device),
+ ).clone()
+
+ if not streaming:
+ pred_feat_seq_cat = torch.cat(pred_feat_seq, dim=1)
+ feat_pred = rearrange(pred_feat_seq_cat, "b t p d -> b d (t p)", b=B, p=self.patch_size)
+ yield feat_pred, pred_feat_seq_cat.squeeze(0).cpu()
+
+ @torch.inference_mode()
+ def generate_latents_with_prompt_cache(
+ self,
+ target_text: str,
+ prompt_cache: dict,
+ min_len: int = 2,
+ max_len: int = 2000,
+ inference_timesteps: int = 10,
+ cfg_value: float = 2.0,
+ retry_badcase: bool = False,
+ retry_badcase_max_times: int = 3,
+ retry_badcase_ratio_threshold: float = 6.0,
+ streaming_prefix_len: int = 3,
+ ) -> tuple[None, torch.Tensor, torch.Tensor]:
+ return next(
+ self._generate_with_prompt_cache(
+ target_text=target_text,
+ prompt_cache=prompt_cache,
+ min_len=min_len,
+ max_len=max_len,
+ inference_timesteps=inference_timesteps,
+ cfg_value=cfg_value,
+ retry_badcase=retry_badcase,
+ retry_badcase_max_times=retry_badcase_max_times,
+ retry_badcase_ratio_threshold=retry_badcase_ratio_threshold,
+ streaming=False,
+ streaming_prefix_len=streaming_prefix_len,
+ latents_only=True,
+ )
+ )
+
+ @torch.inference_mode()
+ def generate_latents_with_prompt_cache_streaming(
+ self,
+ target_text: str,
+ prompt_cache: dict,
+ min_len: int = 2,
+ max_len: int = 2000,
+ inference_timesteps: int = 10,
+ cfg_value: float = 2.0,
+ retry_badcase: bool = False,
+ retry_badcase_max_times: int = 3,
+ retry_badcase_ratio_threshold: float = 6.0,
+ streaming_prefix_len: int = 3,
+ ) -> Generator[tuple[None, torch.Tensor, torch.Tensor], None, None]:
+ return self._generate_with_prompt_cache(
+ target_text=target_text,
+ prompt_cache=prompt_cache,
+ min_len=min_len,
+ max_len=max_len,
+ inference_timesteps=inference_timesteps,
+ cfg_value=cfg_value,
+ retry_badcase=retry_badcase,
+ retry_badcase_max_times=retry_badcase_max_times,
+ retry_badcase_ratio_threshold=retry_badcase_ratio_threshold,
+ streaming=True,
+ streaming_prefix_len=streaming_prefix_len,
+ latents_only=True,
+ )
+
+ @torch.inference_mode()
+ def _generate_with_prompt_cache(
+ self,
+ target_text: str,
+ prompt_cache: dict,
+ min_len: int = 2,
+ max_len: int = 2000,
+ inference_timesteps: int = 10,
+ cfg_value: float = 2.0,
+ retry_badcase: bool = False,
+ retry_badcase_max_times: int = 3,
+ retry_badcase_ratio_threshold: float = 6.0,
+ streaming: bool = False,
+ streaming_prefix_len: int = 3,
+ latents_only: bool = False,
+ ) -> Generator[tuple[torch.Tensor | None, torch.Tensor, torch.Tensor | list[torch.Tensor]], None, None]:
+ if retry_badcase and streaming:
+ warnings.warn("Retry on bad cases is not supported in streaming mode, setting retry_badcase=False.")
+ retry_badcase = False
+ if prompt_cache is None:
+ prompt_audio_feat = torch.empty((0, self.patch_size, self.audio_vae.latent_dim), dtype=torch.float32)
+ text = target_text
+ else:
+ prompt_audio_feat = prompt_cache["audio_feat"]
+ prompt_text = prompt_cache["prompt_text"]
+ text = prompt_text + target_text
+
+ text_token = torch.LongTensor(self.text_tokenizer(text))
+ text_token = torch.cat(
+ [
+ text_token,
+ torch.tensor([self.audio_start_token], dtype=torch.int32, device=text_token.device),
+ ],
+ dim=-1,
+ )
+ target_text_token = torch.LongTensor(self.text_tokenizer(target_text))
+
+ audio_length = prompt_audio_feat.size(0)
+ text_length = text_token.shape[0]
+ text_pad_token = torch.zeros(audio_length, dtype=torch.int32, device=text_token.device)
+ audio_pad_feat = torch.zeros(
+ (text_token.shape[0], self.patch_size, self.audio_vae.latent_dim),
+ dtype=torch.float32,
+ device=text_token.device,
+ )
+ text_token = torch.cat([text_token, text_pad_token])
+ audio_feat = torch.cat([audio_pad_feat, prompt_audio_feat], dim=0)
+ text_mask = (
+ torch.cat([torch.ones(text_length), torch.zeros(audio_length)]).type(torch.int32).to(text_token.device)
+ )
+ audio_mask = (
+ torch.cat([torch.zeros(text_length), torch.ones(audio_length)]).type(torch.int32).to(text_token.device)
+ )
+
+ text_token = text_token.unsqueeze(0).to(self.device)
+ text_mask = text_mask.unsqueeze(0).to(self.device)
+ audio_feat = audio_feat.unsqueeze(0).to(self.device).to(get_dtype(self.config.dtype))
+ audio_mask = audio_mask.unsqueeze(0).to(self.device)
+
+ target_text_length = len(self.text_tokenizer(target_text))
+ retry_badcase_times = 0
+ while retry_badcase_times < retry_badcase_max_times:
+ inference_result = self._inference(
+ text_token,
+ text_mask,
+ audio_feat,
+ audio_mask,
+ min_len=min_len,
+ max_len=min(int(target_text_length * retry_badcase_ratio_threshold + 10), max_len),
+ inference_timesteps=inference_timesteps,
+ cfg_value=cfg_value,
+ streaming=streaming,
+ streaming_prefix_len=streaming_prefix_len,
+ )
+ if streaming:
+ patch_len = self.patch_size * self.chunk_size
+ for latent_pred, pred_audio_feat in inference_result:
+ if latents_only:
+ decode_audio = None
+ yield (decode_audio, target_text_token, latent_pred)
+ else:
+ decode_audio = self.audio_vae.decode(latent_pred.to(torch.float32))
+ decode_audio = decode_audio[..., -patch_len:].squeeze(1).cpu()
+ yield (decode_audio, target_text_token, pred_audio_feat)
+ break
+
+ latent_pred, pred_audio_feat = next(inference_result)
+ if retry_badcase and pred_audio_feat.shape[0] >= target_text_length * retry_badcase_ratio_threshold:
+ ratio = pred_audio_feat.shape[0] / target_text_length
+ print(f" Badcase detected, audio_text_ratio={ratio}, retrying...", file=sys.stderr)
+ retry_badcase_times += 1
+ continue
+ break
+
+ if not streaming:
+ if latents_only:
+ decode_audio = None
+ else:
+ decode_audio = self.audio_vae.decode(latent_pred.to(torch.float32))
+ patch_len = self.patch_size * self.chunk_size
+ if audio_mask.sum().item() > 0:
+ decode_audio = decode_audio[..., patch_len * (streaming_prefix_len - 1) :].squeeze(1).cpu()
+ else:
+ decode_audio = decode_audio[..., :].squeeze(1).cpu()
+ yield (decode_audio, target_text_token, pred_audio_feat)
+
+ VoxCPMModelForOmni.__name__ = "VoxCPMModelForOmni"
+ VoxCPMModelForOmni.__qualname__ = "VoxCPMModelForOmni"
+ return VoxCPMModelForOmni
+
+
+def _import_voxcpm_model_class() -> type[Any]:
+ base = _import_voxcpm_base_model_class()
+ return _make_voxcpm_model_for_omni(base)
+
+
+def _load_native_voxcpm_model(
+ model_path: str,
+ *,
+ device: torch.device,
+ dtype: str | None,
+):
+ VoxCPMModel = _import_voxcpm_model_class()
+ model_dir = resolve_voxcpm_model_dir(model_path)
+ runtime_model_path = _prepare_runtime_model_dir(model_dir, target_device=device, target_dtype=dtype)
+
+ if device.type == "npu" and hasattr(torch, "npu"):
+ torch.npu.set_device(device)
+
+ with _force_cuda_available_for_npu(device):
+ return VoxCPMModel.from_local(
+ runtime_model_path,
+ optimize=device.type == "cuda",
+ )
+
+
+def _load_native_voxcpm_latent_generator(
+ model_path: str,
+ *,
+ device: torch.device,
+ dtype: str | None,
+) -> _DirectVoxCPMLatentGenerator:
+ return _DirectVoxCPMLatentGenerator(_load_native_voxcpm_model(model_path, device=device, dtype=dtype))
+
+
+def _load_native_voxcpm_audio_vae(
+ model_path: str,
+ *,
+ device: torch.device,
+) -> _DirectVoxCPMAudioVAE:
+ AudioVAE, AudioVAEConfig = _import_voxcpm_audio_vae_classes()
+ model_dir = resolve_voxcpm_model_dir(model_path)
+ runtime_model_path = _prepare_runtime_model_dir(model_dir, target_device=device, target_dtype="float32")
+ config_dict = json.loads((Path(runtime_model_path) / "config.json").read_text())
+ audio_vae_config = config_dict.get("audio_vae_config")
+ audio_vae = AudioVAE(config=AudioVAEConfig(**audio_vae_config)) if audio_vae_config is not None else AudioVAE()
+
+ state_dict = torch.load(
+ Path(runtime_model_path) / "audiovae.pth",
+ map_location="cpu",
+ weights_only=True,
+ )["state_dict"]
+ audio_vae.load_state_dict(state_dict, strict=True)
+ audio_vae = audio_vae.to(device=device, dtype=torch.float32).eval()
+ if device.type == "npu" and hasattr(torch, "npu"):
+ torch.npu.set_device(device)
+ patch_size = int(config_dict.get("patch_size", 2))
+ return _DirectVoxCPMAudioVAE(audio_vae, patch_size=patch_size)
+
+
+class VoxCPMForConditionalGeneration(nn.Module):
+ input_modalities = "audio"
+ _LATENT_STAGES = {"latent_generator", "latent", "ar_dit"}
+ _VAE_STAGES = {"vae", "audio_vae"}
+
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
+ super().__init__()
+ del prefix
+ self.vllm_config = vllm_config
+ self.model_path = vllm_config.model_config.model
+ self.model_stage = getattr(vllm_config.model_config, "model_stage", "latent_generator")
+ self.have_multimodal_outputs = True
+ self.has_preprocess = False
+ self.has_postprocess = False
+ self.enable_update_additional_information = True
+ self.requires_raw_input_tokens = True
+ self.inject_omni_request_id_into_runtime_info = True
+ self._pipeline = None
+ self._latent_stream_gens: dict[str, Any] = {}
+ self._latent_stream_terminal_pending: dict[str, int] = {}
+ self._latent_stream_completed: set[str] = set()
+ self._next_local_stream_key = 0
+ self._ar_emit_stop_token = True
+
+ def _runner_hidden_device_dtype(self) -> tuple[torch.device, torch.dtype]:
+ device = _resolve_runtime_device(self.vllm_config)
+ model_config = getattr(self.vllm_config, "model_config", None)
+ dtype = getattr(model_config, "dtype", torch.float32) if model_config is not None else torch.float32
+ return device, dtype
+
+ def _ensure_model_loaded(self):
+ if self._pipeline is not None:
+ return
+
+ target_device = _resolve_runtime_device(self.vllm_config)
+ model_dtype = getattr(self.vllm_config.model_config, "dtype", None)
+ normalized_dtype = _normalize_dtype_name(model_dtype)
+ if self.model_stage in self._LATENT_STAGES:
+ self._pipeline = _load_native_voxcpm_latent_generator(
+ self.model_path,
+ device=target_device,
+ dtype=normalized_dtype,
+ )
+ elif self.model_stage in self._VAE_STAGES:
+ self._pipeline = _load_native_voxcpm_audio_vae(
+ self.model_path,
+ device=target_device,
+ )
+ else:
+ raise ValueError(
+ f"Unsupported VoxCPM model_stage: {self.model_stage}. "
+ "pure_voxcpm only supports split-stage latent_generator/vae inference."
+ )
+
+ logger.info("Loaded VoxCPM stage '%s' on %s", self.model_stage, _device_to_string(target_device))
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ del weights
+ self._ensure_model_loaded()
+ return set()
+
+ @staticmethod
+ def _extract_val(info: dict[str, Any], key: str, default: Any) -> Any:
+ value = info.get(key, default)
+ if isinstance(value, list):
+ return value[0] if value else default
+ return value
+
+ def _resolve_stream_request_key(self, info: dict[str, Any]) -> str:
+ request_key = info.get("__voxcpm_stream_key")
+ if request_key is not None:
+ return str(request_key)
+
+ request_key = info.get("_omni_req_id")
+ if request_key is not None:
+ request_key = str(request_key)
+ info["__voxcpm_stream_key"] = request_key
+ return request_key
+
+ request_key = f"voxcpm-local-{self._next_local_stream_key}"
+ self._next_local_stream_key += 1
+ info["__voxcpm_stream_key"] = request_key
+ return str(request_key)
+
+ def _recover_latent_from_input_ids(self, input_ids: torch.Tensor | None) -> torch.Tensor | None:
+ if input_ids is None or input_ids.numel() == 0:
+ return None
+ flat_ids = input_ids.detach().reshape(-1).to("cpu")
+ if flat_ids.numel() < 4 or int(flat_ids[0].item()) != _VOXCPM_LATENT_MAGIC:
+ return None
+ latent_dim = int(flat_ids[1].item())
+ time_dim = int(flat_ids[2].item())
+ payload = flat_ids[3:]
+ expected = latent_dim * time_dim
+ if latent_dim <= 0 or time_dim <= 0:
+ raise ValueError(f"Invalid VoxCPM latent header: latent_dim={latent_dim}, time_dim={time_dim}")
+ if int(payload.numel()) != expected:
+ raise ValueError(
+ "Invalid VoxCPM latent payload size: "
+ f"expected={expected}, actual={int(payload.numel())}, "
+ f"latent_dim={latent_dim}, time_dim={time_dim}"
+ )
+ packed = payload.to(dtype=torch.int32).to(torch.uint16)
+ return packed.view(torch.bfloat16).to(torch.float32).reshape(1, latent_dim, time_dim)
+
+ def _maybe_recover_vae_infos(
+ self,
+ infos: list[dict[str, Any]],
+ input_ids: torch.Tensor | None,
+ *,
+ async_chunk: bool,
+ ) -> list[dict[str, Any]]:
+ if not async_chunk:
+ return infos
+ if any(self._extract_val(info, "latent_audio_feat", None) is not None for info in infos):
+ return infos
+ recovered = self._recover_latent_from_input_ids(input_ids)
+ if recovered is None:
+ return infos
+ return [{"latent_audio_feat": recovered}]
+
+ @staticmethod
+ def _normalize_audio_samples(samples: Any) -> np.ndarray:
+ if isinstance(samples, torch.Tensor):
+ return samples.detach().cpu().float().reshape(-1).numpy()
+ return np.asarray(samples, dtype=np.float32).reshape(-1)
+
+ @classmethod
+ def _normalize_ref_audio(cls, ref_audio: Any) -> tuple[np.ndarray, int]:
+ if isinstance(ref_audio, str):
+ raise TypeError("String ref_audio should be handled as a path before waveform normalization.")
+
+ if isinstance(ref_audio, dict):
+ sample_rate = ref_audio.get("sample_rate") or ref_audio.get("sampling_rate") or ref_audio.get("sr")
+ samples = None
+ for key in ("audio", "wav", "samples", "array", "waveform"):
+ if key in ref_audio and ref_audio[key] is not None:
+ samples = ref_audio[key]
+ break
+ if sample_rate is None or samples is None:
+ raise ValueError("ref_audio dict must contain waveform data and sample rate.")
+ return cls._normalize_audio_samples(samples), int(sample_rate)
+
+ if isinstance(ref_audio, (list, tuple)):
+ if len(ref_audio) == 1:
+ return cls._normalize_ref_audio(ref_audio[0])
+ if len(ref_audio) == 2 and np.isscalar(ref_audio[1]):
+ return cls._normalize_audio_samples(ref_audio[0]), int(ref_audio[1])
+
+ raise TypeError(f"Unsupported ref_audio format: {type(ref_audio)!r}")
+
+ @staticmethod
+ def _write_temp_prompt_wav(waveform: np.ndarray, sample_rate: int) -> str:
+ prompt_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
+ prompt_file.close()
+
+ wav = np.asarray(waveform, dtype=np.float32).reshape(-1)
+ wav = np.clip(wav, -1.0, 1.0)
+ pcm16 = (wav * 32767.0).astype(np.int16)
+ with wave.open(prompt_file.name, "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(int(sample_rate))
+ wav_file.writeframes(pcm16.tobytes())
+
+ return prompt_file.name
+
+ @classmethod
+ def _resolve_prompt_inputs(cls, info: dict[str, Any]) -> tuple[str | None, str | None, str | None]:
+ prompt_text = cls._extract_val(info, "prompt_text", None)
+ prompt_wav_path = cls._extract_val(info, "prompt_wav_path", None)
+ if prompt_wav_path:
+ if prompt_text is None:
+ prompt_text = cls._extract_val(info, "ref_text", None)
+ return prompt_wav_path, prompt_text, None
+
+ ref_audio = cls._extract_val(info, "ref_audio", None)
+ ref_text = cls._extract_val(info, "ref_text", None)
+ if ref_audio is None or ref_text is None:
+ return None, None, None
+ if isinstance(ref_audio, str):
+ return ref_audio, ref_text, None
+
+ waveform, sample_rate = cls._normalize_ref_audio(ref_audio)
+ temp_prompt_wav = cls._write_temp_prompt_wav(waveform, sample_rate)
+ return temp_prompt_wav, ref_text, temp_prompt_wav
+
+ def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
+ if input_ids.numel() == 0:
+ return torch.empty((0, 1), device=input_ids.device, dtype=torch.float32)
+ return torch.zeros((input_ids.shape[0], 1), device=input_ids.device, dtype=torch.float32)
+
+ def _get_vocab_size(self) -> int:
+ model_config = getattr(self.vllm_config, "model_config", None)
+ if model_config is not None:
+ getter = getattr(model_config, "get_vocab_size", None)
+ if callable(getter):
+ try:
+ return int(getter())
+ except Exception:
+ pass
+ hf_config = getattr(model_config, "hf_text_config", None)
+ if hf_config is not None and hasattr(hf_config, "vocab_size"):
+ return int(hf_config.vocab_size)
+ return 32000
+
+ def _make_empty_output(
+ self,
+ *,
+ output_key: str,
+ payload_factory: Callable[[], torch.Tensor],
+ infos: list[dict[str, Any]],
+ sample_rate: int,
+ out_device: torch.device,
+ out_dtype: torch.dtype,
+ hidden_rows: int | None = None,
+ ) -> OmniOutput:
+ if hidden_rows is None:
+ hidden_rows = len(infos)
+ return OmniOutput(
+ text_hidden_states=torch.zeros((hidden_rows, 1), device=out_device, dtype=out_dtype),
+ multimodal_outputs={
+ output_key: [payload_factory() for _ in infos],
+ "sr": [torch.tensor(sample_rate, dtype=torch.int32) for _ in infos],
+ },
+ )
+
+ def _finalize_stage_output(
+ self,
+ *,
+ output_key: str,
+ outputs: list[torch.Tensor],
+ sample_rates: list[torch.Tensor],
+ out_device: torch.device,
+ out_dtype: torch.dtype,
+ hidden_rows: int | None = None,
+ ) -> OmniOutput:
+ multimodal_outputs: dict[str, Any] = {output_key: outputs, "sr": sample_rates}
+ if hidden_rows is not None:
+ text_hidden_states = torch.zeros((hidden_rows, 1), device=out_device, dtype=out_dtype)
+ elif outputs:
+ outputs_tensor = torch.stack(outputs)
+ text_hidden_states = (
+ outputs_tensor.unsqueeze(-1)
+ if outputs_tensor.ndim == 1
+ else outputs_tensor.reshape(-1, outputs_tensor.shape[-1])
+ )
+ else:
+ text_hidden_states = torch.zeros((0, 1), device=out_device, dtype=out_dtype)
+ text_hidden_states = text_hidden_states.to(device=out_device, dtype=out_dtype)
+ return OmniOutput(
+ text_hidden_states=text_hidden_states,
+ multimodal_outputs=multimodal_outputs,
+ )
+
+ def _forward_vae_stage(
+ self,
+ infos: list[dict[str, Any]],
+ *,
+ sample_rate: int,
+ async_chunk: bool,
+ out_device: torch.device,
+ out_dtype: torch.dtype,
+ ) -> OmniOutput:
+ if all(self._extract_val(info, "latent_audio_feat", None) is None for info in infos):
+ self._ar_emit_stop_token = True
+ return self._make_empty_output(
+ output_key="model_outputs",
+ payload_factory=lambda: torch.zeros((0,), dtype=torch.float32),
+ infos=infos,
+ sample_rate=sample_rate,
+ out_device=out_device,
+ out_dtype=out_dtype,
+ )
+
+ outputs: list[torch.Tensor] = []
+ sample_rates: list[torch.Tensor] = []
+ for info in infos:
+ latent_audio_feat = self._extract_val(info, "latent_audio_feat", None)
+ audio_tensor = self._pipeline.decode(latent_audio_feat, trim_streaming_patch=async_chunk)
+ outputs.append(audio_tensor.float().cpu())
+ sample_rates.append(torch.tensor(sample_rate, dtype=torch.int32))
+
+ self._ar_emit_stop_token = True
+ return self._finalize_stage_output(
+ output_key="model_outputs",
+ outputs=outputs,
+ sample_rates=sample_rates,
+ out_device=out_device,
+ out_dtype=out_dtype,
+ )
+
+ def _forward_latent_stage(
+ self,
+ infos: list[dict[str, Any]],
+ *,
+ sample_rate: int,
+ async_chunk: bool,
+ out_device: torch.device,
+ out_dtype: torch.dtype,
+ hidden_rows: int,
+ ) -> OmniOutput:
+ texts = [self._extract_val(info, "text", "") for info in infos]
+ if all(not text for text in texts):
+ self._ar_emit_stop_token = True
+ return self._make_empty_output(
+ output_key="latent_audio_feat",
+ payload_factory=lambda: torch.zeros((0,), dtype=torch.float32),
+ infos=infos,
+ sample_rate=sample_rate,
+ out_device=out_device,
+ out_dtype=out_dtype,
+ hidden_rows=hidden_rows,
+ )
+
+ outputs: list[torch.Tensor] = []
+ sample_rates: list[torch.Tensor] = []
+ last_chunk_flags: list[bool] | None = [] if async_chunk else None
+ payload_finished_flags: list[bool] | None = [] if async_chunk else None
+ for info in infos:
+ text = self._extract_val(info, "text", "")
+ cfg_value = float(self._extract_val(info, "cfg_value", 2.0))
+ inference_timesteps = int(self._extract_val(info, "inference_timesteps", 10))
+ min_len = int(self._extract_val(info, "min_len", 2))
+ max_len = int(self._extract_val(info, "max_len", self._extract_val(info, "max_new_tokens", 4096)))
+ retry_badcase = bool(self._extract_val(info, "retry_badcase", True))
+ retry_badcase_max_times = int(self._extract_val(info, "retry_badcase_max_times", 3))
+ retry_badcase_ratio_threshold = float(self._extract_val(info, "retry_badcase_ratio_threshold", 6.0))
+ streaming_prefix_len = int(self._extract_val(info, "streaming_prefix_len", 3))
+
+ request_key = self._resolve_stream_request_key(info)
+ created_temp: str | None = None
+
+ if async_chunk:
+ terminal_pending = self._latent_stream_terminal_pending.get(request_key, 0)
+ if terminal_pending > 0:
+ outputs.append(torch.zeros((0,), dtype=torch.float32))
+ assert last_chunk_flags is not None
+ last_chunk_flags.append(True)
+ assert payload_finished_flags is not None
+ payload_finished_flags.append(terminal_pending == 1)
+ if terminal_pending == 1:
+ self._latent_stream_terminal_pending.pop(request_key, None)
+ else:
+ self._latent_stream_terminal_pending[request_key] = terminal_pending - 1
+ sample_rates.append(torch.tensor(sample_rate, dtype=torch.int32))
+ continue
+
+ if request_key in self._latent_stream_completed:
+ outputs.append(torch.zeros((0,), dtype=torch.float32))
+ assert last_chunk_flags is not None
+ last_chunk_flags.append(True)
+ assert payload_finished_flags is not None
+ payload_finished_flags.append(False)
+ sample_rates.append(torch.tensor(sample_rate, dtype=torch.int32))
+ continue
+
+ if request_key not in self._latent_stream_gens:
+ prompt_wav_path, prompt_text, temp_prompt_wav = self._resolve_prompt_inputs(info)
+ created_temp = temp_prompt_wav
+ self._latent_stream_gens[request_key] = self._pipeline.iter_latent_chunks_streaming(
+ text=text,
+ prompt_wav_path=prompt_wav_path,
+ prompt_text=prompt_text,
+ cfg_value=cfg_value,
+ inference_timesteps=inference_timesteps,
+ min_len=min_len,
+ max_len=max_len,
+ streaming_prefix_len=streaming_prefix_len,
+ retry_badcase=False,
+ retry_badcase_max_times=retry_badcase_max_times,
+ retry_badcase_ratio_threshold=retry_badcase_ratio_threshold,
+ )
+ generator = self._latent_stream_gens[request_key]
+ try:
+ chunk_latent, is_last = next(generator)
+ except StopIteration:
+ self._latent_stream_gens.pop(request_key, None)
+ self._latent_stream_terminal_pending[request_key] = 1
+ self._latent_stream_completed.add(request_key)
+ outputs.append(torch.zeros((0,), dtype=torch.float32))
+ assert last_chunk_flags is not None
+ last_chunk_flags.append(True)
+ assert payload_finished_flags is not None
+ payload_finished_flags.append(True)
+ else:
+ if is_last:
+ self._latent_stream_gens.pop(request_key, None)
+ self._latent_stream_terminal_pending[request_key] = 1
+ self._latent_stream_completed.add(request_key)
+ outputs.append(chunk_latent.detach().float().cpu())
+ assert last_chunk_flags is not None
+ last_chunk_flags.append(bool(is_last))
+ assert payload_finished_flags is not None
+ payload_finished_flags.append(False)
+ finally:
+ if created_temp is not None and os.path.exists(created_temp):
+ os.unlink(created_temp)
+ sample_rates.append(torch.tensor(sample_rate, dtype=torch.int32))
+ continue
+
+ prompt_wav_path, prompt_text, temp_prompt_wav = self._resolve_prompt_inputs(info)
+ try:
+ latent_audio_feat = self._pipeline.generate_latents(
+ text=text,
+ prompt_wav_path=prompt_wav_path,
+ prompt_text=prompt_text,
+ cfg_value=cfg_value,
+ inference_timesteps=inference_timesteps,
+ min_len=min_len,
+ max_len=max_len,
+ retry_badcase=retry_badcase,
+ retry_badcase_max_times=retry_badcase_max_times,
+ retry_badcase_ratio_threshold=retry_badcase_ratio_threshold,
+ )
+ outputs.append(latent_audio_feat.float().cpu())
+ finally:
+ if temp_prompt_wav is not None and os.path.exists(temp_prompt_wav):
+ os.unlink(temp_prompt_wav)
+
+ sample_rates.append(torch.tensor(sample_rate, dtype=torch.int32))
+
+ self._ar_emit_stop_token = all(last_chunk_flags) if async_chunk and last_chunk_flags else True
+ output = self._finalize_stage_output(
+ output_key="latent_audio_feat",
+ outputs=outputs,
+ sample_rates=sample_rates,
+ out_device=out_device,
+ out_dtype=out_dtype,
+ hidden_rows=hidden_rows,
+ )
+ if async_chunk and payload_finished_flags is not None:
+ output.multimodal_outputs["finished"] = [
+ torch.tensor(flag, dtype=torch.bool) for flag in payload_finished_flags
+ ]
+ return output
+
+ def compute_logits(self, hidden_states: torch.Tensor | OmniOutput, sampling_metadata: Any = None) -> torch.Tensor:
+ del sampling_metadata
+ if isinstance(hidden_states, OmniOutput):
+ hidden_states = hidden_states.text_hidden_states
+ if hidden_states is None:
+ device, dtype = self._runner_hidden_device_dtype()
+ hidden_states = torch.zeros((0, 1), device=device, dtype=dtype)
+ if hidden_states.ndim == 1:
+ hidden_states = hidden_states.unsqueeze(-1)
+ elif hidden_states.ndim > 2:
+ hidden_states = hidden_states.reshape(-1, hidden_states.shape[-1])
+
+ vocab_size = self._get_vocab_size()
+ num_rows = int(hidden_states.shape[0])
+ logits = torch.zeros((num_rows, vocab_size), dtype=torch.float32, device=hidden_states.device)
+ eos_id = 2 if vocab_size > 2 else 0
+ safe_id = 1 if vocab_size > 1 and 1 != eos_id else 0
+ emit_stop = getattr(self, "_ar_emit_stop_token", True)
+ if num_rows > 0:
+ if emit_stop:
+ logits[:, eos_id] = 1.0e6
+ else:
+ logits[:, eos_id] = -1.0e9
+ logits[:, safe_id] = 1.0e6
+ return logits
+
+ @torch.no_grad()
+ def forward(
+ self,
+ input_ids: torch.Tensor | None = None,
+ positions: torch.Tensor | None = None,
+ intermediate_tensors: Any = None,
+ inputs_embeds: torch.Tensor | None = None,
+ runtime_additional_information: list[dict[str, Any]] | None = None,
+ model_intermediate_buffer: list[dict[str, Any]] | None = None,
+ **kwargs: Any,
+ ) -> OmniOutput:
+ del positions, intermediate_tensors, inputs_embeds, kwargs
+ self._ensure_model_loaded()
+ out_device, out_dtype = self._runner_hidden_device_dtype()
+ if input_ids is not None and input_ids.device.type == out_device.type:
+ out_device = input_ids.device
+
+ infos = model_intermediate_buffer or runtime_additional_information or [{}]
+ hidden_rows = len(infos)
+ if input_ids is not None and len(input_ids.shape) > 0:
+ hidden_rows = max(hidden_rows, int(input_ids.shape[0]))
+ sample_rate = int(getattr(self._pipeline, "sample_rate", 24000))
+ async_chunk = bool(getattr(self.vllm_config.model_config, "async_chunk", False))
+ if self.model_stage in self._VAE_STAGES:
+ infos = self._maybe_recover_vae_infos(infos, input_ids, async_chunk=async_chunk)
+ return self._forward_vae_stage(
+ infos,
+ sample_rate=sample_rate,
+ async_chunk=async_chunk,
+ out_device=out_device,
+ out_dtype=out_dtype,
+ )
+ if self.model_stage in self._LATENT_STAGES:
+ return self._forward_latent_stage(
+ infos,
+ sample_rate=sample_rate,
+ async_chunk=async_chunk,
+ out_device=out_device,
+ out_dtype=out_dtype,
+ hidden_rows=hidden_rows,
+ )
+ raise ValueError(f"Unsupported VoxCPM model_stage at runtime: {self.model_stage}")
+
+ def make_empty_intermediate_tensors(
+ self, batch_size: int, dtype: torch.dtype, device: torch.device
+ ) -> IntermediateTensors:
+ del batch_size, dtype, device
+ return {}
+
+
+__all__ = ["VoxCPMForConditionalGeneration"]
diff --git a/vllm_omni/model_executor/models/voxcpm/voxcpm_loader.py b/vllm_omni/model_executor/models/voxcpm/voxcpm_loader.py
new file mode 100644
index 000000000..dac7117ca
--- /dev/null
+++ b/vllm_omni/model_executor/models/voxcpm/voxcpm_loader.py
@@ -0,0 +1,247 @@
+from __future__ import annotations
+
+import importlib
+import json
+import os
+import shutil
+import sys
+import tempfile
+from contextlib import contextmanager
+from hashlib import sha256
+from pathlib import Path
+from typing import Any
+from unittest.mock import patch
+
+import numpy as np
+import torch
+from vllm.config import VllmConfig
+from vllm.logger import init_logger
+
+logger = init_logger(__name__)
+
+
+def _iter_voxcpm_src_candidates() -> list[Path]:
+ candidates: list[Path] = []
+ env_path = os.environ.get("VLLM_OMNI_VOXCPM_CODE_PATH")
+ if env_path:
+ candidates.append(Path(env_path).expanduser())
+
+ repo_root = Path(__file__).resolve().parents[4]
+ candidates.append(repo_root.parent / "VoxCPM" / "src")
+
+ unique_candidates: list[Path] = []
+ seen: set[str] = set()
+ for candidate in candidates:
+ candidate_key = str(candidate)
+ if candidate_key in seen:
+ continue
+ seen.add(candidate_key)
+ unique_candidates.append(candidate)
+ return unique_candidates
+
+
+def _prepend_voxcpm_src(candidate: Path) -> None:
+ candidate_str = str(candidate)
+ if candidate_str not in sys.path:
+ sys.path.insert(0, candidate_str)
+
+
+def _import_voxcpm_attrs(module_name: str, *attr_names: str) -> tuple[Any, ...]:
+ last_exc: ImportError | None = None
+ for candidate in _iter_voxcpm_src_candidates():
+ if not candidate.exists():
+ continue
+ _prepend_voxcpm_src(candidate)
+ try:
+ module = importlib.import_module(module_name)
+ return tuple(getattr(module, attr_name) for attr_name in attr_names)
+ except ImportError as exc:
+ last_exc = exc
+
+ try:
+ module = importlib.import_module(module_name)
+ return tuple(getattr(module, attr_name) for attr_name in attr_names)
+ except ImportError as exc:
+ last_exc = exc
+
+ raise ImportError(f"Failed to import {module_name}.") from last_exc
+
+
+def _import_voxcpm_base_model_class():
+ """Import upstream ``VoxCPMModel`` from ``VoxCPM/src/voxcpm`` (env, sibling tree, or pip)."""
+ try:
+ (VoxCPMModel,) = _import_voxcpm_attrs("voxcpm.model.voxcpm", "VoxCPMModel")
+ return VoxCPMModel
+ except ImportError as exc:
+ raise ImportError(
+ "Failed to import VoxCPMModel. Install the `voxcpm` package or set "
+ "`VLLM_OMNI_VOXCPM_CODE_PATH` to the VoxCPM repository `src` directory "
+ "(the parent of the `voxcpm` package that contains `model/` and `modules/`)."
+ ) from exc
+
+
+def _import_voxcpm_audio_vae_classes():
+ try:
+ return _import_voxcpm_attrs("voxcpm.modules.audiovae", "AudioVAE", "AudioVAEConfig")
+ except ImportError as exc:
+ raise ImportError(
+ "Failed to import VoxCPM AudioVAE. Install the `voxcpm` package or set "
+ "`VLLM_OMNI_VOXCPM_CODE_PATH` to the VoxCPM repository `src` directory."
+ ) from exc
+
+
+def _device_to_string(device: torch.device) -> str:
+ if device.index is None:
+ return device.type
+ return f"{device.type}:{device.index}"
+
+
+def _normalize_dtype_name(dtype: Any) -> str | None:
+ if dtype is None:
+ return None
+ if isinstance(dtype, torch.dtype):
+ mapping = {
+ torch.bfloat16: "bfloat16",
+ torch.float16: "float16",
+ torch.float32: "float32",
+ }
+ return mapping.get(dtype, str(dtype).removeprefix("torch."))
+ dtype_str = str(dtype)
+ return dtype_str.removeprefix("torch.")
+
+
+def _resolve_runtime_device(vllm_config: VllmConfig) -> torch.device:
+ try:
+ from vllm_omni.platforms import current_omni_platform
+
+ return current_omni_platform.get_torch_device()
+ except Exception:
+ pass
+
+ device = getattr(getattr(vllm_config, "device_config", None), "device", None)
+ if isinstance(device, torch.device):
+ return device
+ if device:
+ return torch.device(device)
+ return torch.device("cpu")
+
+
+def _prepare_runtime_model_dir(
+ model_path: str | Path,
+ *,
+ target_device: torch.device,
+ target_dtype: str | None,
+) -> str:
+ source_dir = Path(model_path)
+ config_path = source_dir / "config.json"
+ if not config_path.exists():
+ return str(source_dir)
+
+ config_text = config_path.read_text()
+ config_dict = json.loads(config_text)
+ desired_device = target_device.type
+ desired_dtype = target_dtype or config_dict.get("dtype")
+
+ if config_dict.get("device") == desired_device and config_dict.get("dtype") == desired_dtype:
+ return str(source_dir)
+
+ digest = sha256(f"{source_dir.resolve()}:{config_text}:{desired_device}:{desired_dtype}".encode()).hexdigest()[:16]
+ runtime_dir = Path(tempfile.gettempdir()) / "vllm_omni_voxcpm_runtime" / digest
+ runtime_dir.mkdir(parents=True, exist_ok=True)
+
+ for entry in source_dir.iterdir():
+ target = runtime_dir / entry.name
+ if entry.name == "config.json" or target.exists():
+ continue
+ try:
+ target.symlink_to(entry, target_is_directory=entry.is_dir())
+ except OSError as exc:
+ logger.warning(
+ "Falling back to copying VoxCPM runtime artifact %s into %s because symlink creation failed: %s",
+ entry,
+ runtime_dir,
+ exc,
+ )
+ if entry.is_dir():
+ shutil.copytree(entry, target, dirs_exist_ok=True)
+ else:
+ shutil.copy2(entry, target)
+
+ patched_config = dict(config_dict)
+ patched_config["device"] = desired_device
+ if desired_dtype is not None:
+ patched_config["dtype"] = desired_dtype
+ (runtime_dir / "config.json").write_text(json.dumps(patched_config, indent=2, sort_keys=True))
+ return str(runtime_dir)
+
+
+@contextmanager
+def _force_cuda_available_for_npu(device: torch.device):
+ if device.type != "npu":
+ yield
+ return
+
+ with patch("torch.cuda.is_available", return_value=True):
+ yield
+
+
+def _is_torchcodec_load_error(exc: BaseException) -> bool:
+ message = str(exc).lower()
+ return "torchcodec" in message or "load_with_torchcodec" in message
+
+
+def _load_audio_with_soundfile(
+ prompt_wav_path: str,
+ *,
+ sample_rate: int,
+) -> torch.Tensor:
+ try:
+ import soundfile as sf
+ except ImportError:
+ raise
+
+ audio_np, source_sr = sf.read(prompt_wav_path, dtype="float32", always_2d=True)
+ audio = torch.from_numpy(np.ascontiguousarray(audio_np.T))
+
+ if audio.size(0) > 1:
+ audio = audio.mean(dim=0, keepdim=True)
+
+ if int(source_sr) != int(sample_rate):
+ try:
+ import torchaudio
+ except ImportError as exc:
+ raise ImportError("torchaudio is required for resampling prompt audio.") from exc
+ audio = torchaudio.functional.resample(audio, int(source_sr), int(sample_rate))
+
+ return audio
+
+
+def _build_prompt_cache_with_soundfile(model: Any, *args: Any, **kwargs: Any) -> dict[str, Any]:
+ if args:
+ prompt_text = args[0]
+ prompt_wav_path = args[1] if len(args) > 1 else kwargs.get("prompt_wav_path")
+ else:
+ prompt_text = kwargs.get("prompt_text")
+ prompt_wav_path = kwargs.get("prompt_wav_path")
+
+ if not prompt_text or not prompt_wav_path:
+ raise ValueError("prompt_text and prompt_wav_path are required")
+
+ audio = _load_audio_with_soundfile(prompt_wav_path, sample_rate=int(model.sample_rate))
+
+ patch_len = model.patch_size * model.chunk_size
+ if audio.size(1) % patch_len != 0:
+ padding_size = patch_len - audio.size(1) % patch_len
+ audio = torch.nn.functional.pad(audio, (padding_size, 0))
+
+ audio_feat = model.audio_vae.encode(audio.to(model.device), model.sample_rate).cpu()
+ audio_feat = audio_feat.view(
+ model.audio_vae.latent_dim,
+ -1,
+ model.patch_size,
+ ).permute(1, 2, 0)
+
+ return {
+ "prompt_text": prompt_text,
+ "audio_feat": audio_feat,
+ }
diff --git a/vllm_omni/model_executor/models/voxcpm/voxcpm_runtime_utils.py b/vllm_omni/model_executor/models/voxcpm/voxcpm_runtime_utils.py
new file mode 100644
index 000000000..36b4282c2
--- /dev/null
+++ b/vllm_omni/model_executor/models/voxcpm/voxcpm_runtime_utils.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+import json
+import shutil
+from pathlib import Path
+
+
+def resolve_voxcpm_model_dir(model: str) -> Path:
+ model_path = Path(model).expanduser()
+ if model_path.exists():
+ return model_path
+
+ from huggingface_hub import snapshot_download
+
+ return Path(snapshot_download(repo_id=model))
+
+
+def prepare_voxcpm_hf_config_dir(model_dir: str | Path, hf_config_dir: str | Path) -> Path:
+ model_dir = Path(model_dir).expanduser()
+ hf_config_dir = Path(hf_config_dir).expanduser()
+ hf_config_dir.mkdir(parents=True, exist_ok=True)
+
+ source_config_path = model_dir / "config.json"
+ if not source_config_path.exists():
+ raise FileNotFoundError(f"VoxCPM config.json not found under {model_dir}")
+
+ config_path = hf_config_dir / "config.json"
+ shutil.copy2(source_config_path, config_path)
+
+ source_generation_config_path = model_dir / "generation_config.json"
+ if source_generation_config_path.exists():
+ shutil.copy2(source_generation_config_path, hf_config_dir / "generation_config.json")
+
+ config_dict = json.loads(config_path.read_text(encoding="utf-8"))
+ config_dict["model_type"] = "voxcpm"
+ config_dict.setdefault("architectures", ["VoxCPMForConditionalGeneration"])
+ config_path.write_text(json.dumps(config_dict, indent=2, ensure_ascii=False), encoding="utf-8")
+ return hf_config_dir
+
+
+__all__ = [
+ "prepare_voxcpm_hf_config_dir",
+ "resolve_voxcpm_model_dir",
+]
diff --git a/vllm_omni/model_executor/models/voxcpm/voxcpm_stage_wrappers.py b/vllm_omni/model_executor/models/voxcpm/voxcpm_stage_wrappers.py
new file mode 100644
index 000000000..f4446c796
--- /dev/null
+++ b/vllm_omni/model_executor/models/voxcpm/voxcpm_stage_wrappers.py
@@ -0,0 +1,185 @@
+from __future__ import annotations
+
+import os
+from collections.abc import Generator
+from typing import Any
+
+import torch
+import torch.nn as nn
+from einops import rearrange
+
+
+class _DirectVoxCPMLatentGenerator:
+ def __init__(self, tts_model: Any):
+ self.tts_model = tts_model
+ self.sample_rate = int(getattr(tts_model, "sample_rate", 24000))
+
+ def generate_latents(
+ self,
+ *,
+ text: str,
+ prompt_wav_path: str | None = None,
+ prompt_text: str | None = None,
+ cfg_value: float = 2.0,
+ inference_timesteps: int = 10,
+ min_len: int = 2,
+ max_len: int = 4096,
+ retry_badcase: bool = True,
+ retry_badcase_max_times: int = 3,
+ retry_badcase_ratio_threshold: float = 6.0,
+ ) -> torch.Tensor:
+ if not isinstance(text, str) or not text.strip():
+ raise ValueError("target text must be a non-empty string")
+ if (prompt_wav_path is None) != (prompt_text is None):
+ raise ValueError("prompt_wav_path and prompt_text must both be provided or both be None")
+ if prompt_wav_path is not None and not os.path.exists(prompt_wav_path):
+ raise FileNotFoundError(f"prompt_wav_path does not exist: {prompt_wav_path}")
+
+ prompt_cache = None
+ if prompt_wav_path is not None and prompt_text is not None:
+ prompt_cache = self.tts_model.build_prompt_cache(
+ prompt_text=prompt_text,
+ prompt_wav_path=prompt_wav_path,
+ )
+
+ gen_kw = dict(
+ target_text=" ".join(text.split()),
+ prompt_cache=prompt_cache,
+ min_len=min_len,
+ max_len=max_len,
+ inference_timesteps=inference_timesteps,
+ cfg_value=cfg_value,
+ retry_badcase=retry_badcase,
+ retry_badcase_max_times=retry_badcase_max_times,
+ retry_badcase_ratio_threshold=retry_badcase_ratio_threshold,
+ )
+ latent_entry = getattr(self.tts_model, "generate_latents_with_prompt_cache", None)
+ if latent_entry is not None:
+ _, _, pred_audio_feat = latent_entry(**gen_kw)
+ else:
+ try:
+ _, _, pred_audio_feat = self.tts_model.generate_with_prompt_cache(
+ **gen_kw,
+ latents_only=True,
+ )
+ except TypeError:
+ _, _, pred_audio_feat = self.tts_model.generate_with_prompt_cache(**gen_kw)
+ return pred_audio_feat.detach().cpu().to(torch.float32)
+
+ def iter_latent_chunks_streaming(
+ self,
+ *,
+ text: str,
+ prompt_wav_path: str | None = None,
+ prompt_text: str | None = None,
+ cfg_value: float = 2.0,
+ inference_timesteps: int = 10,
+ min_len: int = 2,
+ max_len: int = 4096,
+ streaming_prefix_len: int = 3,
+ retry_badcase: bool = False,
+ retry_badcase_max_times: int = 3,
+ retry_badcase_ratio_threshold: float = 6.0,
+ ) -> Generator[tuple[torch.Tensor, bool], None, None]:
+ """Yield ``(latent_window, is_last_chunk)`` for Omni async_chunk latent to VAE."""
+ if not isinstance(text, str) or not text.strip():
+ raise ValueError("target text must be a non-empty string")
+ if (prompt_wav_path is None) != (prompt_text is None):
+ raise ValueError("prompt_wav_path and prompt_text must both be provided or both be None")
+ if prompt_wav_path is not None and not os.path.exists(prompt_wav_path):
+ raise FileNotFoundError(f"prompt_wav_path does not exist: {prompt_wav_path}")
+
+ prompt_cache = None
+ if prompt_wav_path is not None and prompt_text is not None:
+ prompt_cache = self.tts_model.build_prompt_cache(
+ prompt_text=prompt_text,
+ prompt_wav_path=prompt_wav_path,
+ )
+
+ gen_kw = dict(
+ target_text=" ".join(text.split()),
+ prompt_cache=prompt_cache,
+ min_len=min_len,
+ max_len=max_len,
+ inference_timesteps=inference_timesteps,
+ cfg_value=cfg_value,
+ retry_badcase=retry_badcase,
+ retry_badcase_max_times=retry_badcase_max_times,
+ retry_badcase_ratio_threshold=retry_badcase_ratio_threshold,
+ streaming_prefix_len=streaming_prefix_len,
+ )
+ stream_entry = getattr(self.tts_model, "generate_latents_with_prompt_cache_streaming", None)
+ if stream_entry is not None:
+ gen = stream_entry(**gen_kw)
+ else:
+ fallback_stream_entry = getattr(self.tts_model, "generate_with_prompt_cache_streaming", None)
+ if fallback_stream_entry is not None:
+ gen = fallback_stream_entry(**gen_kw, latents_only=True)
+ else:
+ gen = self.tts_model._generate_with_prompt_cache(streaming=True, latents_only=True, **gen_kw)
+
+ iterator = iter(gen)
+ previous = next(iterator, None)
+ while previous is not None:
+ current = next(iterator, None)
+ _, _target_tok, chunk_latent = previous
+ if not isinstance(chunk_latent, torch.Tensor):
+ chunk_latent = torch.as_tensor(chunk_latent)
+ yield chunk_latent, current is None
+ previous = current
+
+
+class _DirectVoxCPMAudioVAE:
+ def __init__(self, audio_vae: nn.Module, *, patch_size: int = 2):
+ self.audio_vae = audio_vae
+ self.sample_rate = int(getattr(audio_vae, "sample_rate", 24000))
+ self.latent_dim = int(getattr(audio_vae, "latent_dim", 64))
+ self.patch_size = int(patch_size)
+ self._chunk_size = int(getattr(audio_vae, "chunk_size", 1))
+ self._stream_audio_patch_samples = max(1, self.patch_size * self._chunk_size)
+
+ def _prepare_latents_for_decode(self, latent_audio_feat: Any) -> torch.Tensor:
+ latents = latent_audio_feat
+ if not isinstance(latents, torch.Tensor):
+ latents = torch.tensor(latents, dtype=torch.float32)
+ latents = latents.detach().to(torch.float32)
+
+ if latents.ndim == 3:
+ if latents.shape[-1] == self.latent_dim:
+ latents = rearrange(latents, "t p d -> 1 d (t p)")
+ elif latents.shape[1] == self.latent_dim:
+ latents = latents.contiguous()
+ else:
+ raise ValueError(f"Unsupported latent_audio_feat shape: {tuple(latents.shape)}")
+ elif latents.ndim == 2:
+ if latents.shape[0] == self.latent_dim:
+ latents = latents.unsqueeze(0)
+ elif latents.shape[1] == self.latent_dim:
+ latents = rearrange(latents, "t d -> 1 d t")
+ else:
+ raise ValueError(f"Unsupported latent_audio_feat shape: {tuple(latents.shape)}")
+ else:
+ raise ValueError(f"Unsupported latent_audio_feat ndim: {latents.ndim}")
+
+ return latents
+
+ @torch.no_grad()
+ def decode(self, latent_audio_feat: Any, *, trim_streaming_patch: bool = False) -> torch.Tensor:
+ latents = self._prepare_latents_for_decode(latent_audio_feat)
+ device = next(self.audio_vae.parameters()).device
+ raw = self.audio_vae.decode(latents.to(device=device, dtype=torch.float32))
+ if isinstance(raw, dict):
+ audio = raw.get("audio")
+ if audio is None:
+ audio = next(v for v in raw.values() if isinstance(v, torch.Tensor))
+ else:
+ audio = raw
+ if audio.dim() == 3:
+ stream = audio.squeeze(1)
+ elif audio.dim() == 2:
+ stream = audio
+ else:
+ stream = audio.reshape(audio.shape[0], -1)
+ if trim_streaming_patch:
+ stream = stream[..., -self._stream_audio_patch_samples :]
+ return stream.reshape(-1).detach().cpu().to(torch.float32)
diff --git a/vllm_omni/model_executor/models/voxcpm2/minicpm4_paged.py b/vllm_omni/model_executor/models/voxcpm2/minicpm4_paged.py
index 7ea5bc229..b87ec5aaf 100644
--- a/vllm_omni/model_executor/models/voxcpm2/minicpm4_paged.py
+++ b/vllm_omni/model_executor/models/voxcpm2/minicpm4_paged.py
@@ -307,32 +307,39 @@ class MiniCPM4PagedForVoxCPM2(nn.Module):
hidden_states = self.norm(hidden_states)
return hidden_states
+ def precompute_fused_qkv(self) -> None:
+ """Materialize fused QKV weights before CUDA Graph capture."""
+ for layer in self.layers:
+ attn = layer.self_attn
+ if attn._fused_qkv_weight is None:
+ attn._fused_qkv_weight = torch.cat(
+ [attn.q_proj.weight, attn.k_proj.weight, attn.v_proj.weight],
+ dim=0,
+ ).detach()
+
def compile_selective(self) -> list[str]:
- """Compile MLP + o_proj; keep RMSNorm/RoPE eager for precision."""
- compiled: list[str] = []
- for i, layer in enumerate(self.layers):
- if i in self._compiled_layers:
- continue
- try:
- layer.mlp = torch.compile(
- layer.mlp,
- mode="default",
- fullgraph=True,
- )
- layer.self_attn.o_proj = torch.compile(
- layer.self_attn.o_proj,
- mode="default",
- fullgraph=True,
- )
- layer.self_attn._fused_qkv_weight = None
- self._compiled_layers.add(i)
- if i == 0:
- compiled.append(f"layers.*.mlp (×{len(self.layers)})")
- compiled.append(f"layers.*.self_attn.o_proj (×{len(self.layers)})")
- except Exception as e:
- logger.warning("compile_selective: layer %d failed: %s", i, e)
- break
- return compiled
+ """Compile the full model forward as one graph.
+
+ Earlier versions compiled ``layer.mlp`` + ``layer.self_attn.o_proj``
+ (PR #2690) and then the whole ``layer`` (perf/voxcpm2-streaming-vae).
+ Both still paid one Dynamo dispatch per layer per decode step.
+ V3 profiling showed 1,332 per-layer dispatches (~28 layers × ~47
+ decode steps) costing ~726 ms of CPU self-time for a long prompt.
+
+ Compiling ``forward`` at the model level lets Dynamo unroll the
+ 28-layer Python loop inside the graph. Graph breaks at
+ PagedAttention produce sub-graphs but Dynamo memoises the whole
+ trace once, so the per-step dispatch drops from 28 to just a few.
+ """
+ if self._compiled_layers:
+ return []
+ # Null the fused-qkv caches so the compile sees the real weight layout.
+ for layer in self.layers:
+ layer.self_attn._fused_qkv_weight = None
+ self.forward = torch.compile(self.forward, mode="default", fullgraph=False)
+ # Mark every layer as compiled so idempotent callers don't double-wrap.
+ self._compiled_layers.update(range(len(self.layers)))
+ return ["forward (whole model)"]
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""Load weights from native checkpoint (base_lm. prefix pre-stripped)."""
@@ -414,23 +421,25 @@ class MiniCPM4PagedResidualLM(nn.Module):
hidden_states = self.norm(hidden_states)
return hidden_states
+ def precompute_fused_qkv(self) -> None:
+ """Materialize fused QKV weights before CUDA Graph capture."""
+ for layer in self.layers:
+ attn = layer.self_attn
+ if attn._fused_qkv_weight is None:
+ attn._fused_qkv_weight = torch.cat(
+ [attn.q_proj.weight, attn.k_proj.weight, attn.v_proj.weight],
+ dim=0,
+ ).detach()
+
def compile_selective(self) -> list[str]:
- """Compile MLP + o_proj (same as base_lm)."""
- compiled: list[str] = []
- for i, layer in enumerate(self.layers):
- if i in self._compiled_layers:
- continue
- try:
- layer.mlp = torch.compile(layer.mlp, mode="default", fullgraph=True)
- layer.self_attn.o_proj = torch.compile(layer.self_attn.o_proj, mode="default", fullgraph=True)
- layer.self_attn._fused_qkv_weight = None
- self._compiled_layers.add(i)
- if i == 0:
- compiled.append(f"layers.*.mlp (×{len(self.layers)})")
- compiled.append(f"layers.*.self_attn.o_proj (×{len(self.layers)})")
- except Exception as e:
- logger.warning("compile_selective: residual layer %d failed: %s", i, e)
- return compiled
+ """Compile the full residual model forward as one graph (same strategy as base_lm)."""
+ if self._compiled_layers:
+ return []
+ for layer in self.layers:
+ layer.self_attn._fused_qkv_weight = None
+ self.forward = torch.compile(self.forward, mode="default", fullgraph=False)
+ self._compiled_layers.update(range(len(self.layers)))
+ return ["forward (whole residual)"]
def load_weights_from_native(self, native_residual_lm: nn.Module) -> int:
"""Load weights from native residual_lm. Returns param count."""
diff --git a/vllm_omni/model_executor/models/voxcpm2/pipeline.py b/vllm_omni/model_executor/models/voxcpm2/pipeline.py
new file mode 100644
index 000000000..347fce177
--- /dev/null
+++ b/vllm_omni/model_executor/models/voxcpm2/pipeline.py
@@ -0,0 +1,35 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""VoxCPM2 pipeline topology (frozen).
+
+Single-stage AR TTS: text → speech waveform in one pass.
+Uses the native MiniCPM4 base_lm with a per-request StaticKVCache that the
+talker restores into the paged attention layer at step boundaries.
+"""
+
+from vllm_omni.config.stage_config import (
+ PipelineConfig,
+ StageExecutionType,
+ StagePipelineConfig,
+)
+
+VOXCPM2_PIPELINE = PipelineConfig(
+ model_type="voxcpm2",
+ model_arch="VoxCPM2TalkerForConditionalGeneration",
+ stages=(
+ StagePipelineConfig(
+ stage_id=0,
+ model_stage="latent_generator",
+ execution_type=StageExecutionType.LLM_AR,
+ input_sources=(),
+ final_output=True,
+ final_output_type="audio",
+ owns_tokenizer=True,
+ engine_output_type="audio",
+ sampling_constraints={
+ "detokenize": False,
+ "stop_token_ids": [1],
+ },
+ ),
+ ),
+)
diff --git a/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py b/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py
index 0898ca59a..0a9246251 100644
--- a/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py
+++ b/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py
@@ -10,23 +10,26 @@ Architecture:
from __future__ import annotations
+import copy
import dataclasses
+import logging
+import math
import os
import time
from collections.abc import Iterable
from typing import Any
-import librosa
import torch
import torch.nn as nn
-from einops import rearrange
from vllm.config import VllmConfig
+from vllm.forward_context import get_forward_context, override_forward_context
from vllm.logger import init_logger
from vllm.model_executor.models.utils import (
AutoWeightsLoader,
WeightsMapper,
maybe_prefix,
)
+from vllm.multimodal.audio import AudioResampler
from vllm.sequence import IntermediateTensors
from vllm_omni.model_executor.models.output_templates import OmniOutput
@@ -38,6 +41,88 @@ logger = init_logger(__name__)
_ENABLE_PROFILING = os.environ.get("VOXCPM2_PROFILE", "0") == "1"
+# Lower bound for the _active_states leak-warn threshold. The effective
+# threshold is max(_ACTIVE_STATE_LEAK_WARN_MIN, 4 * max_batch_size) so small
+# deployments still get a usable floor instead of a tiny noisy one.
+_ACTIVE_STATE_LEAK_WARN_MIN = 512
+
+
+def is_cjk_char(c: str) -> bool:
+ """Check if a character is a CJK ideograph."""
+ cp = ord(c)
+ return (
+ 0x4E00 <= cp <= 0x9FFF # CJK Unified Ideographs
+ or 0x3400 <= cp <= 0x4DBF # Extension A
+ or 0xF900 <= cp <= 0xFAFF # Compatibility Ideographs
+ or 0x20000 <= cp <= 0x2A6DF # Extension B
+ or 0x2A700 <= cp <= 0x2B73F # Extension C
+ or 0x2B740 <= cp <= 0x2B81F # Extension D
+ or 0x2F800 <= cp <= 0x2FA1F # Compatibility Supplement
+ )
+
+
+def build_cjk_split_map(tokenizer: Any) -> dict[int, list[int]]:
+ """Build {multichar_cjk_token_id: [single_char_ids]} from tokenizer vocab."""
+ vocab = tokenizer.get_vocab()
+ split_map: dict[int, list[int]] = {}
+ for token, token_id in vocab.items():
+ clean = token.replace("\u2581", "")
+ if len(clean) >= 2 and all(is_cjk_char(c) for c in clean):
+ char_ids = tokenizer.convert_tokens_to_ids(list(clean))
+ if all(cid != tokenizer.unk_token_id for cid in char_ids):
+ split_map[token_id] = char_ids
+ return split_map
+
+
+def split_multichar_chinese(token_ids: list[int], split_map: dict[int, list[int]]) -> list[int]:
+ """Replace multichar Chinese token IDs with single-char IDs (idempotent)."""
+ result: list[int] = []
+ for tid in token_ids:
+ expansion = split_map.get(tid)
+ if expansion is not None:
+ result.extend(expansion)
+ else:
+ result.append(tid)
+ return result
+
+
+def build_voxcpm2_prompt(
+ hf_config: Any,
+ tokenizer: Any,
+ split_map: dict[int, list[int]],
+ text: str,
+ ref_audio: Any | None = None,
+ ref_sr: int | None = None,
+ ref_text: str | None = None,
+) -> dict[str, Any]:
+ """Build a VoxCPM2 prefill prompt whose ``prompt_token_ids`` length matches
+ the talker-side prefill length.
+
+ Used by both online serving (``serving_speech._build_voxcpm2_prompt``) and
+ the offline example, so the talker-side length assertion never fires.
+ """
+ ids = split_multichar_chinese(tokenizer.encode(text, add_special_tokens=True), split_map)
+ bos = tokenizer.bos_token_id
+ if ids and ids[0] == bos:
+ ids = ids[1:]
+ prefill_len = len(ids) + 1 # + audio_start
+ additional: dict[str, Any] = {"text_token_ids": [ids]}
+ if ref_audio is not None:
+ vae = hf_config.audio_vae_config
+ patch_samples = hf_config.patch_size * math.prod(vae["encoder_rates"])
+ ref_len = math.ceil(math.ceil(len(ref_audio) * vae["sample_rate"] / ref_sr) / patch_samples)
+ if ref_text is not None:
+ additional["prompt_audio"] = [[ref_audio, ref_sr]]
+ additional["prompt_text"] = [ref_text]
+ ref_ids = split_multichar_chinese(tokenizer.encode(ref_text, add_special_tokens=True), split_map)
+ if ref_ids and ref_ids[0] == bos:
+ ref_ids = ref_ids[1:]
+ prefill_len += ref_len + len(ref_ids)
+ else:
+ additional["reference_audio"] = [[ref_audio, ref_sr]]
+ prefill_len += ref_len + 2 # ref_start / ref_end
+ return {"prompt_token_ids": [1] * prefill_len, "additional_information": additional}
+
def _encode_raw_audio(
tts: nn.Module,
@@ -60,7 +145,8 @@ def _encode_raw_audio(
encode_sr = tts._encode_sample_rate
if sr != encode_sr:
audio_np = audio.squeeze(0).numpy()
- audio_np = librosa.resample(audio_np, orig_sr=sr, target_sr=encode_sr)
+ resampler = AudioResampler(target_sr=encode_sr)
+ audio_np = resampler.resample(audio_np, orig_sr=sr)
audio = torch.from_numpy(audio_np).unsqueeze(0)
patch_len = tts.patch_size * tts.chunk_size
@@ -86,7 +172,11 @@ class _RequestState:
curr_prefix_feat_cond: torch.Tensor | None = None
last_audio_patch_gpu: torch.Tensor | None = None
precomputed_stop_logits: torch.Tensor | None = None
- accumulated_patches: list[torch.Tensor] = dataclasses.field(default_factory=list)
+ # Rolling tail of previously-decoded latents used as VAE receptive-field context.
+ # Shape (n_pad_frames, feat_dim) on GPU. None before first decode.
+ decode_pad: torch.Tensor | None = None
+ # Audio chunks already emitted (CPU float32), concatenated for cumulative output.
+ audio_chunks: list[torch.Tensor] = dataclasses.field(default_factory=list)
decode_step_count: int = 0
request_start_time: float = 0.0
prefill_completed: bool = False
@@ -97,6 +187,14 @@ class _RequestState:
last_decoded_audio: torch.Tensor | None = None
+@dataclasses.dataclass
+class _CapturedGraph:
+ graph: torch.cuda.CUDAGraph
+ input_embeds: torch.Tensor
+ positions: torch.Tensor
+ output: torch.Tensor
+
+
# ===================================================================
# Profiling timer
# ===================================================================
@@ -229,11 +327,11 @@ def _optimized_solve_euler(
buffers.x_in[b : 2 * b].copy_(x)
buffers.mu_in[:b].copy_(mu)
buffers.mu_in[b : 2 * b].zero_()
- buffers.t_in[:b].fill_(t.item())
- buffers.t_in[b : 2 * b].fill_(t.item())
+ # Broadcast the 0-dim GPU scalar directly instead of
+ # ``.fill_(t.item())`` — ``.item()`` forces a GPU->CPU sync.
+ buffers.t_in[: 2 * b].copy_(t)
if mean_mode:
- buffers.dt_in[:b].fill_(dt.item())
- buffers.dt_in[b : 2 * b].fill_(dt.item())
+ buffers.dt_in[: 2 * b].copy_(dt)
else:
buffers.dt_in.zero_()
buffers.cond_in[:b].copy_(cond[:b])
@@ -263,9 +361,10 @@ def _optimized_solve_euler(
else:
buffers.x_in[:b].copy_(x)
buffers.mu_in[:b].copy_(mu)
- buffers.t_in[:b].fill_(t.item())
+ # Broadcast the 0-dim GPU scalar; ``.fill_(t.item())`` would sync.
+ buffers.t_in[:b].copy_(t)
if mean_mode:
- buffers.dt_in[:b].fill_(dt.item())
+ buffers.dt_in[:b].copy_(dt)
else:
buffers.dt_in[:b].zero_()
buffers.cond_in[:b].copy_(cond[:b])
@@ -320,7 +419,10 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
self._inference_timesteps = 10
self._cfg_value = 2.0
self._cfg_cutoff_ratio = 1.0
- self._vae_decode_interval = 5
+ # Number of trailing latent frames to keep as VAE receptive-field context
+ # for sliding-window streaming decode. 12 matches the nanovllm reference
+ # implementation and covers the longest VAE decoder receptive field.
+ self._n_decode_pad_frames = 12
self._enable_torch_compile = True
self._compile_vae = True
self._max_decode_steps = 2000
@@ -328,6 +430,15 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
self._perf = _PerfTimer(enabled=_ENABLE_PROFILING)
self._cfm_buffers: _CFMBufferManager | None = None
+ self._enable_cuda_graph = True
+ self._scaffold_graphs: dict[int, _CapturedGraph] = {}
+ self._residual_graphs: dict[int, _CapturedGraph] = {}
+ self._max_cached_graphs = self._max_batch_size
+ self._cuda_graph_pool: tuple | None = None
+ self._cuda_graph_warmup_steps = 0
+ self._cuda_graph_warmup_threshold = 3
+
+ self._multichar_zh_split: dict[int, list[int]] | None = None
self._active_states: dict[str, _RequestState] = {}
self._current_request_id: str | None = None
@@ -335,6 +446,9 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
self._results_queue: list[tuple[str, torch.Tensor | None]] = []
self._audio_queue: list[tuple[str, Any]] = []
self._deferred_cleanup_ids: set[str] = set()
+ self._active_state_warn_threshold = max(_ACTIVE_STATE_LEAK_WARN_MIN, 4 * self._max_batch_size)
+ # one-shot by design: fires at most once per process to avoid log spam.
+ self._active_state_warned = False
@property
def tts(self) -> nn.Module:
@@ -344,9 +458,20 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
# -------------------- request state management --------------------
def _get_or_create_state(self, request_id: str) -> _RequestState:
- if request_id not in self._active_states:
- self._active_states[request_id] = _RequestState(request_id=request_id)
- return self._active_states[request_id]
+ state = self._active_states.get(request_id)
+ if state is None:
+ state = _RequestState(request_id=request_id)
+ self._active_states[request_id] = state
+ if len(self._active_states) > self._active_state_warn_threshold and not self._active_state_warned:
+ logger.warning(
+ "VoxCPM2: _active_states size=%d exceeds threshold %d "
+ "(max_batch_size=%d); possible cleanup path leak",
+ len(self._active_states),
+ self._active_state_warn_threshold,
+ self._max_batch_size,
+ )
+ self._active_state_warned = True
+ return state
def _switch_to_request(self, request_id: str) -> _RequestState:
if request_id != self._current_request_id:
@@ -475,19 +600,24 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
except Exception as e:
logger.warning("torch.compile AudioVAE failed: %s", e)
- if not getattr(self.model, "_selective_compiled", False):
- try:
- targets.extend(f"scaffold.{t}" for t in self.model.compile_selective())
- self.model._selective_compiled = True
- except Exception as e:
- logger.warning("scaffold compile failed: %s", e)
+ if not self._enable_cuda_graph:
+ if not getattr(self.model, "_selective_compiled", False):
+ try:
+ targets.extend(f"scaffold.{t}" for t in self.model.compile_selective())
+ self.model._selective_compiled = True
+ except Exception as e:
+ logger.warning("scaffold compile failed: %s", e)
- if not getattr(self.residual_model, "_selective_compiled", False):
- try:
- targets.extend(f"residual.{t}" for t in self.residual_model.compile_selective())
- self.residual_model._selective_compiled = True
- except Exception as e:
- logger.warning("residual compile failed: %s", e)
+ if not getattr(self.residual_model, "_selective_compiled", False):
+ try:
+ targets.extend(f"residual.{t}" for t in self.residual_model.compile_selective())
+ self.residual_model._selective_compiled = True
+ except Exception as e:
+ logger.warning("residual compile failed: %s", e)
+ else:
+ self.model.precompute_fused_qkv()
+ self.residual_model.precompute_fused_qkv()
+ targets.append("scaffold+residual (CUDA Graph, skipping compile)")
if not getattr(self, "_projections_compiled", False):
try:
@@ -510,6 +640,90 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
tts = self.tts
return tts.stop_head(tts.stop_actn(tts.stop_proj(lm_h)))
+ def _get_cuda_graph_pool(self) -> tuple:
+ if self._cuda_graph_pool is None:
+ self._cuda_graph_pool = torch.cuda.graph_pool_handle()
+ return self._cuda_graph_pool
+
+ @staticmethod
+ def _nullify_volatile_metadata(ctx: Any) -> Any:
+ """Set ``scheduler_metadata`` to None on all attention layers.
+
+ This is the only tensor FA3 reallocates each step (variable shape).
+ All other metadata tensors are persistent model-runner buffers.
+ Setting it to None makes FA3 use default scheduling (~0.1ms cost).
+ """
+ if not isinstance(ctx.attn_metadata, dict):
+ return ctx
+
+ ctx = copy.copy(ctx)
+ new_meta: dict[str, Any] = {}
+ for layer_name, meta in ctx.attn_metadata.items():
+ if getattr(meta, "scheduler_metadata", None) is not None:
+ meta = copy.copy(meta)
+ meta.scheduler_metadata = None
+ new_meta[layer_name] = meta
+ ctx.attn_metadata = new_meta
+ return ctx
+
+ def _capture_graph(
+ self,
+ model: nn.Module,
+ batch_size: int,
+ label: str,
+ is_residual: bool = False,
+ ) -> _CapturedGraph:
+ """Capture a CUDA Graph for *model* at *batch_size*."""
+ hidden_size = self.config.hidden_size
+ dtype = self._side_dtype
+ dev = torch.device(self._device)
+ pool = self._get_cuda_graph_pool()
+
+ model.precompute_fused_qkv()
+
+ g = _CapturedGraph(
+ graph=torch.cuda.CUDAGraph(),
+ input_embeds=torch.zeros(batch_size, hidden_size, device=dev, dtype=dtype),
+ positions=torch.zeros(batch_size, device=dev, dtype=torch.long),
+ output=torch.zeros(batch_size, hidden_size, device=dev, dtype=dtype),
+ )
+
+ if is_residual:
+ call_kwargs = dict(positions=g.positions, inputs_embeds=g.input_embeds)
+ else:
+ call_kwargs = dict(input_ids=None, positions=g.positions, inputs_embeds=g.input_embeds)
+
+ ctx = get_forward_context()
+ patched_ctx = self._nullify_volatile_metadata(ctx)
+
+ with override_forward_context(patched_ctx):
+ for _ in range(3):
+ _ = model(**call_kwargs)
+
+ with torch.cuda.graph(g.graph, pool=pool):
+ g.output = model(**call_kwargs)
+
+ logger.info("CUDA Graph captured for %s (batch_size=%d)", label, batch_size)
+ return g
+
+ def _replay_graph(
+ self,
+ g: _CapturedGraph,
+ inputs_embeds: torch.Tensor,
+ positions: torch.Tensor,
+ batch_size: int,
+ ) -> torch.Tensor:
+ """Copy fresh inputs into static buffers, then replay.
+
+ No metadata copy needed: persistent buffers (seq_lens, slot_mapping,
+ etc.) are updated in-place by the model runner. scheduler_metadata
+ was nullified at capture time so no kernel references it.
+ """
+ g.input_embeds[:batch_size].copy_(inputs_embeds[:batch_size])
+ g.positions[:batch_size].copy_(positions[:batch_size])
+ g.graph.replay()
+ return g.output[:batch_size].clone()
+
# -------------------- vllm hooks --------------------
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
@@ -526,12 +740,35 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
self._perf.start("forward_total")
dev = input_ids.device
- model_output = self.model(input_ids, positions, intermediate_tensors, inputs_embeds)
- if isinstance(model_output, IntermediateTensors):
- return model_output
- scaffold_hidden = model_output
- if isinstance(scaffold_hidden, tuple):
- scaffold_hidden = scaffold_hidden[0]
+ num_reqs = len(self._pending_requests)
+ num_decode = sum(1 for _, is_p, _, n in self._pending_requests if not is_p and n == 1)
+ is_all_decode = num_decode == num_reqs and num_reqs > 0
+
+ tts_compiled = getattr(self.tts.feat_decoder.estimator, "_compiled", False) if self._tts is not None else False
+ graph_ready = tts_compiled and self._cuda_graph_warmup_steps >= self._cuda_graph_warmup_threshold
+ if num_decode > 0:
+ self._cuda_graph_warmup_steps += 1
+
+ can_use_graph = (
+ self._enable_cuda_graph and graph_ready and intermediate_tensors is None and inputs_embeds is not None
+ )
+
+ if can_use_graph and is_all_decode and num_reqs <= self._max_cached_graphs:
+ self._perf.start("scaffold_fwd")
+ if num_reqs not in self._scaffold_graphs:
+ self._scaffold_graphs[num_reqs] = self._capture_graph(self.model, num_reqs, "scaffold")
+ scaffold_hidden = self._replay_graph(self._scaffold_graphs[num_reqs], inputs_embeds, positions, num_reqs)
+ self._perf.stop("scaffold_fwd")
+
+ else:
+ self._perf.start("scaffold_fwd")
+ model_output = self.model(input_ids, positions, intermediate_tensors, inputs_embeds)
+ self._perf.stop("scaffold_fwd")
+ if isinstance(model_output, IntermediateTensors):
+ return model_output
+ scaffold_hidden = model_output
+ if isinstance(scaffold_hidden, tuple):
+ scaffold_hidden = scaffold_hidden[0]
# Phase 1: per-request FSQ + residual input
token_offset = 0
@@ -563,7 +800,28 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
if residual_inputs:
batch_in = torch.cat(residual_inputs, dim=0)
batch_pos = torch.cat(residual_positions, dim=0)
- batch_out = self.residual_model(batch_pos, batch_in)
+
+ residual_batch_size = batch_in.shape[0]
+ use_residual_graph = (
+ self._enable_cuda_graph
+ and is_all_decode
+ and graph_ready
+ and residual_batch_size == num_reqs # 1 token per request
+ and residual_batch_size <= self._max_cached_graphs
+ )
+
+ self._perf.start("residual_fwd")
+ if use_residual_graph:
+ if residual_batch_size not in self._residual_graphs:
+ self._residual_graphs[residual_batch_size] = self._capture_graph(
+ self.residual_model, residual_batch_size, "residual", is_residual=True
+ )
+ batch_out = self._replay_graph(
+ self._residual_graphs[residual_batch_size], batch_in, batch_pos, residual_batch_size
+ )
+ else:
+ batch_out = self.residual_model(batch_pos, batch_in)
+ self._perf.stop("residual_fwd")
# Phase 3: per-request LocDiT + update
offset = 0
@@ -594,19 +852,12 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
tts_len = text_mask.shape[1]
scaffold_len = base_lm_out.shape[0]
-
- if scaffold_len < tts_len:
- # Voice clone / continuation: scaffold only processed vllm tokens.
- # Pad to match TTS sequence length (extra positions are masked out).
- pad = torch.zeros(
- tts_len - scaffold_len,
- base_lm_out.shape[-1],
- device=base_lm_out.device,
- dtype=base_lm_out.dtype,
- )
- enc_out = torch.cat([base_lm_out, pad], dim=0).unsqueeze(0)
- else:
- enc_out = base_lm_out.unsqueeze(0)
+ assert scaffold_len == tts_len, (
+ f"voxcpm2 prefill length mismatch: scaffold_len={scaffold_len} tts_len={tts_len}; "
+ "caller must pad prompt_token_ids to the full prefill length "
+ "(see serving_speech._build_voxcpm2_prompt or the offline example)."
+ )
+ enc_out = base_lm_out.unsqueeze(0)
prefix_feat_cond = (
feat[:, -1, ...]
@@ -686,7 +937,9 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
state.request_start_time = time.perf_counter()
state.prefill_completed = True
- logger.info("PREFILL[%s]: patch norm=%.4f", state.request_id, pred_feat.norm().item())
+ if logger.isEnabledFor(logging.DEBUG):
+ # Only compute the norm (which forces a GPU->CPU sync) if we will log it.
+ logger.debug("PREFILL[%s]: patch norm=%.4f", state.request_id, pred_feat.norm().item())
self._perf.reset()
def _finish_decode(self, state: _RequestState, meta: dict, res_out: torch.Tensor, dev: Any):
@@ -720,26 +973,54 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
# -------------------- audio collection --------------------
def _collect_audio(self, state: _RequestState) -> torch.Tensor | None:
+ """Per-step sliding-window VAE decode (nanovllm pattern).
+
+ Each decode step feeds ``[decode_pad, new_patch]`` through the VAE
+ and slices out only the audio region corresponding to the new patch.
+ The pad buffer (last ``_n_decode_pad_frames`` latent frames) provides
+ the receptive-field context needed by the VAE's transposed convolutions,
+ eliminating boundary artifacts between chunks.
+
+ Returns the delta audio chunk (not cumulative) so the output processor
+ can stream each chunk to the client independently.
+ """
patch = state.last_audio_patch_gpu
- if patch is not None:
- state.last_audio_patch_gpu = None
- state.accumulated_patches.append(patch.reshape(1, -1).float())
-
- if not state.accumulated_patches:
+ if patch is None:
return None
+ state.last_audio_patch_gpu = None
- n = len(state.accumulated_patches)
- if n <= 1 or n % self._vae_decode_interval == 0 or state.is_stopping:
- self._perf.start("vae_decode")
- all_p = torch.cat(state.accumulated_patches, dim=0)
- state.accumulated_patches = [all_p]
- feat = rearrange(all_p.reshape(1, -1, self._feat_dim), "b t d -> b d t")
- with torch.no_grad():
- audio = self.tts.audio_vae.decode(feat.to(self._device)).reshape(-1).cpu().float()
- self._perf.stop("vae_decode")
- state.last_decoded_audio = audio
- return audio
- return state.last_decoded_audio
+ # patch shape: (patch_size, feat_dim) or (1, patch_size, feat_dim)
+ new_latent = patch.reshape(-1, self._feat_dim).to(torch.float32)
+ n_new = new_latent.shape[0] # = patch_size (typically 4)
+
+ self._perf.start("vae_decode")
+
+ # Build VAE input: [pad_frames | new_latent]
+ if state.decode_pad is not None:
+ vae_input = torch.cat([state.decode_pad, new_latent], dim=0)
+ pad_frames = state.decode_pad.shape[0]
+ else:
+ vae_input = new_latent
+ pad_frames = 0
+
+ # VAE decode: (1, feat_dim, T_frames) -> (1, 1, T_samples)
+ feat = vae_input.unsqueeze(0).transpose(1, 2).contiguous()
+ with torch.no_grad():
+ audio = self.tts.audio_vae.decode(feat.to(self._device)).reshape(-1)
+
+ # Slice out only the new audio (after the pad region).
+ # Each latent frame maps to decoder_chunk_size audio samples.
+ dcs = int(getattr(self.tts.audio_vae, "decode_chunk_size", audio.numel() // vae_input.shape[0]))
+ new_audio = audio[pad_frames * dcs : (pad_frames + n_new) * dcs].detach().cpu().float()
+
+ # Roll the pad buffer: keep last N latent frames as context for next step.
+ all_latents = vae_input # [pad + new]
+ state.decode_pad = all_latents[-self._n_decode_pad_frames :].detach()
+
+ state.audio_chunks.append(new_audio)
+ state.last_decoded_audio = new_audio
+ self._perf.stop("vae_decode")
+ return new_audio
# -------------------- compute_logits --------------------
@@ -797,6 +1078,17 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
return OmniOutput(text_hidden_states=model_outputs, multimodal_outputs=mm)
+ # -------------------- Chinese token splitting --------------------
+
+ def _get_multichar_zh_split(self) -> dict[int, list[int]]:
+ """Lazy-build {multichar_chinese_token_id: [char_id, ...]} map."""
+ if self._multichar_zh_split is not None:
+ return self._multichar_zh_split
+ base_tokenizer = self.tts.text_tokenizer.tokenizer
+ self._multichar_zh_split = build_cjk_split_map(base_tokenizer)
+ logger.info("VoxCPM2: built multichar Chinese split map (%d entries)", len(self._multichar_zh_split))
+ return self._multichar_zh_split
+
# -------------------- preprocess / postprocess --------------------
def preprocess(
@@ -815,22 +1107,29 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
is_prefill = span_len > 1
if is_prefill:
- # Evict stale states
- pending_ids = {rid for rid, *_ in self._pending_requests}
- pending_ids.add(req_id)
- if self._current_request_id:
- pending_ids.add(self._current_request_id)
- for rid in [r for r, s in self._active_states.items() if r not in pending_ids and s.prefill_completed]:
- self._cleanup_request(rid)
-
- # VoxCPM2Tokenizer does char-level Chinese splitting, so use input_ids directly
- token_ids = input_ids.tolist()
+ # Do not evict state here: _pending_requests is a per-step prefix,
+ # not the full batch. Cleanup is driven by on_requests_finished ->
+ # _flush_deferred_cleanup (fed by vLLM scheduler._free_request via
+ # gpu_ar_model_runner.py).
+ real = info_dict.get("text_token_ids")
+ token_ids = input_ids.tolist() if real is None else real[0]
+ # Fail-fast: unsplit multichar Chinese IDs in input_ids means the
+ # serving layer didn't pre-split. Silent fixup here would cause
+ # input_ids/embeds length mismatch (scheduler slot count is fixed).
+ split_map = self._get_multichar_zh_split()
+ if split_map and any(tid in split_map for tid in token_ids):
+ raise ValueError(
+ "VoxCPM2 preprocess received unsplit multichar Chinese "
+ "token IDs. The serving layer must send prompt_token_ids "
+ "with single-char CJK IDs (see _voxcpm2_encode)."
+ )
if token_ids and token_ids[0] == self.config.bos_token_id:
token_ids = token_ids[1:]
state = self._get_or_create_state(req_id)
state.prefill_text = ""
- state.accumulated_patches = []
+ state.decode_pad = None
+ state.audio_chunks = []
state.prefill_completed = False
state.decode_step_count = 0
state.precomputed_stop_logits = None
diff --git a/vllm_omni/model_executor/models/voxtral_tts/__init__.py b/vllm_omni/model_executor/models/voxtral_tts/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/vllm_omni/model_executor/models/voxtral_tts/pipeline.py b/vllm_omni/model_executor/models/voxtral_tts/pipeline.py
new file mode 100644
index 000000000..3bc2743c6
--- /dev/null
+++ b/vllm_omni/model_executor/models/voxtral_tts/pipeline.py
@@ -0,0 +1,45 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Voxtral TTS pipeline topology (frozen).
+
+Stage 0: audio_generation — text → acoustic latents (LLM_AR, tokenizer owner).
+Stage 1: audio_tokenizer — acoustic latents → waveform (LLM_GENERATION).
+"""
+
+from vllm_omni.config.stage_config import (
+ PipelineConfig,
+ StageExecutionType,
+ StagePipelineConfig,
+)
+
+_PROC = "vllm_omni.model_executor.stage_input_processors.voxtral_tts"
+
+VOXTRAL_TTS_PIPELINE = PipelineConfig(
+ model_type="voxtral_tts",
+ model_arch="VoxtralTTSForConditionalGeneration",
+ stages=(
+ StagePipelineConfig(
+ stage_id=0,
+ model_stage="audio_generation",
+ execution_type=StageExecutionType.LLM_AR,
+ input_sources=(),
+ final_output=False,
+ final_output_type="text",
+ owns_tokenizer=True,
+ engine_output_type="latent",
+ async_chunk_process_next_stage_input_func=(f"{_PROC}.generator2tokenizer_async_chunk"),
+ sampling_constraints={"detokenize": True},
+ ),
+ StagePipelineConfig(
+ stage_id=1,
+ model_stage="audio_tokenizer",
+ execution_type=StageExecutionType.LLM_GENERATION,
+ input_sources=(0,),
+ final_output=True,
+ final_output_type="audio",
+ engine_output_type="audio",
+ sampling_constraints={"detokenize": True},
+ extras={"tts_args": {"max_instructions_length": 500}},
+ ),
+ ),
+)
diff --git a/vllm_omni/model_executor/models/whisper_utils.py b/vllm_omni/model_executor/models/whisper_utils.py
new file mode 100644
index 000000000..5aa2fc8a3
--- /dev/null
+++ b/vllm_omni/model_executor/models/whisper_utils.py
@@ -0,0 +1,39 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 The vLLM-Omni team.
+# Copyright (c) 2022 OpenAI
+#
+# Shared Whisper encoder primitives used by multiple model implementations.
+# Originally from the OpenAI Whisper codebase.
+
+import numpy as np
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+
+def sinusoids(length, channels, max_timescale=10000):
+ """Returns sinusoids for positional embedding."""
+ assert channels % 2 == 0
+ log_timescale_increment = np.log(max_timescale) / (channels // 2 - 1)
+ inv_timescales = torch.exp(-log_timescale_increment * torch.arange(channels // 2))
+ scaled_time = torch.arange(length)[:, np.newaxis] * inv_timescales[np.newaxis, :]
+ return torch.cat([torch.sin(scaled_time), torch.cos(scaled_time)], dim=1)
+
+
+class Conv1d(nn.Conv1d):
+ """Conv1d with automatic dtype casting for mixed precision inference."""
+
+ def _conv_forward(self, x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor | None) -> torch.Tensor:
+ return super()._conv_forward(x, weight.to(x.dtype), None if bias is None else bias.to(x.dtype))
+
+
+class ConvTranspose1d(nn.ConvTranspose1d):
+ def _conv_forward(self, x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor | None) -> torch.Tensor:
+ return super()._conv_forward(x, weight.to(x.dtype), None if bias is None else bias.to(x.dtype))
+
+
+class Linear(nn.Linear):
+ """Linear layer with automatic dtype casting for mixed precision inference."""
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ return F.linear(x, self.weight.to(x.dtype), None if self.bias is None else self.bias.to(x.dtype))
diff --git a/vllm_omni/model_executor/stage_configs/bagel.yaml b/vllm_omni/model_executor/stage_configs/bagel.yaml
index dfe9da1c2..75f7c8a06 100644
--- a/vllm_omni/model_executor/stage_configs/bagel.yaml
+++ b/vllm_omni/model_executor/stage_configs/bagel.yaml
@@ -71,10 +71,6 @@ stage_args:
# Runtime edges
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
-
# Distributed connectors configuration (optional)
# More connectors will be supported in the future.
connectors:
@@ -104,4 +100,3 @@ runtime:
edges:
- from: 0
to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/bagel_multiconnector.yaml b/vllm_omni/model_executor/stage_configs/bagel_multiconnector.yaml
index af038f59f..7a0d851f0 100644
--- a/vllm_omni/model_executor/stage_configs/bagel_multiconnector.yaml
+++ b/vllm_omni/model_executor/stage_configs/bagel_multiconnector.yaml
@@ -64,10 +64,6 @@ stage_args:
# Runtime edges
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
-
# Distributed connectors configuration (optional)
# More connectors will be supported in the future.
connectors:
@@ -104,4 +100,3 @@ runtime:
edges:
- from: 0
to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/bagel_single_stage.yaml b/vllm_omni/model_executor/stage_configs/bagel_single_stage.yaml
index bb24763f9..b2d4b07b1 100644
--- a/vllm_omni/model_executor/stage_configs/bagel_single_stage.yaml
+++ b/vllm_omni/model_executor/stage_configs/bagel_single_stage.yaml
@@ -22,6 +22,3 @@ stage_args:
# Runtime edges
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
diff --git a/vllm_omni/model_executor/stage_configs/bagel_think.yaml b/vllm_omni/model_executor/stage_configs/bagel_think.yaml
index 0d2098a20..2575e6736 100644
--- a/vllm_omni/model_executor/stage_configs/bagel_think.yaml
+++ b/vllm_omni/model_executor/stage_configs/bagel_think.yaml
@@ -65,9 +65,6 @@ stage_args:
# Runtime edges
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
connectors:
shared_memory_connector:
@@ -78,4 +75,3 @@ runtime:
edges:
- from: 0
to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/bagel_usp2.yaml b/vllm_omni/model_executor/stage_configs/bagel_usp2.yaml
index 33002b9aa..4599f8b05 100644
--- a/vllm_omni/model_executor/stage_configs/bagel_usp2.yaml
+++ b/vllm_omni/model_executor/stage_configs/bagel_usp2.yaml
@@ -62,9 +62,6 @@ stage_args:
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
connectors:
shared_memory_connector:
name: SharedMemoryConnector
@@ -73,4 +70,3 @@ runtime:
edges:
- from: 0
to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/bailingmm_moe_v2_lite.yaml b/vllm_omni/model_executor/stage_configs/bailingmm_moe_v2_lite.yaml
new file mode 100644
index 000000000..b7d0aeeb7
--- /dev/null
+++ b/vllm_omni/model_executor/stage_configs/bailingmm_moe_v2_lite.yaml
@@ -0,0 +1,46 @@
+# Stage config for Ming-flash-omni-2.0
+# Stage 0: Thinker (Multimodal understanding + text generation)
+# Stage 1a: Image Generator (Text embeddings -> PIL image)
+# Stage 1b: Talker (Text embeddings -> audio waveform)
+
+async_chunk: false
+stage_args:
+ - stage_id: 0
+ stage_type: llm
+ runtime:
+ devices: "0,1,2,3"
+ max_batch_size: 1
+ engine_args:
+ model_stage: thinker
+ model_arch: MingFlashOmniForConditionalGeneration
+ # tokenizer_subdir: talker/llm
+ worker_type: ar
+ scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
+ gpu_memory_utilization: 0.9
+ enforce_eager: false
+ trust_remote_code: true
+ engine_output_type: latent
+ distributed_executor_backend: "mp"
+ enable_prefix_caching: false
+ max_num_batched_tokens: 32768
+ tensor_parallel_size: 4 # Use 4 GPUs for MoE model
+ # pipeline_parallel_size: 4
+ hf_config_name: llm_config
+ compilation_config:
+ pass_config:
+ # there's a version mismatch regarding vllm and flashinfer
+ # disable fuse allreduce for now
+ fuse_allreduce_rms: false
+ final_output: true # Can output text directly
+ final_output_type: text
+ is_comprehension: true
+ default_sampling_params:
+ temperature: 0.4
+ top_p: 0.9
+ max_tokens: 2048
+ repetition_penalty: 1.05
+ seed: 42
+ detokenize: true
+
+ # Future Stage 1a: Image Generator (Optional - not yet implemented)
+ # Future Stage 1b: Talker/TTS (Optional - not yet implemented)
diff --git a/vllm_omni/model_executor/stage_configs/cosyvoice3.yaml b/vllm_omni/model_executor/stage_configs/cosyvoice3.yaml
deleted file mode 100644
index 8e0582723..000000000
--- a/vllm_omni/model_executor/stage_configs/cosyvoice3.yaml
+++ /dev/null
@@ -1,53 +0,0 @@
-# Stage config for running CosyVoice3 with 2 stage architecture
-# Stage 0: Talker (text prompt → speech tokens)
-# Stage 1: Code2Wav (flow matching → acoustic features → waveform)
-# Right now I have coded up stage 2 in stage 1 only; can be split in future
-
-stage_args:
- - stage_id: 0
- is_comprehension: true
- runtime:
- devices: 0
- engine_args:
- model_stage: cosyvoice3_talker
- worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- model_arch: CosyVoice3Model
- trust_remote_code: true
- gpu_memory_utilization: 0.2
- engine_output_type: latent # Output speech tokens for chunk aware flow matching
- disable_hybrid_kv_cache_manager: true
- enable_prefix_caching: false
- enforce_eager: true
- mm_processor_cache_gb: 0
- skip_mm_profiling: true
- dtype: "float32"
- default_sampling_params:
- max_tokens: 2048
- stop_token_ids: [6562] # merged speech stop token (logsumexp of all 200 stop logits)
- top_k: 25
- top_p: 0.8
- repetition_penalty: 1.0001 # near-identity; forces vLLM to track output_token_ids for RAS
-
- - stage_id: 1
- runtime:
- devices: 0
- engine_args:
- model_stage: cosyvoice3_code2wav
- model_arch: CosyVoice3Model
- trust_remote_code: true
- worker_cls: vllm_omni.worker.gpu_generation_worker.GPUGenerationWorker
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- engine_output_type: latent
- gpu_memory_utilization: 0.1
- enforce_eager: true # CUDA graphs don't work with dynamic conv shapes in code2wav
- disable_hybrid_kv_cache_manager: true
- enable_prefix_caching: false
- skip_mm_profiling: true
- dtype: "float32"
- default_sampling_params:
- max_tokens: 2048
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.cosyvoice3.text2flow
- final_output: true
- final_output_type: audio
diff --git a/vllm_omni/model_executor/stage_configs/cosyvoice3_async_chunk.yaml b/vllm_omni/model_executor/stage_configs/cosyvoice3_async_chunk.yaml
deleted file mode 100644
index ca7e9850a..000000000
--- a/vllm_omni/model_executor/stage_configs/cosyvoice3_async_chunk.yaml
+++ /dev/null
@@ -1,85 +0,0 @@
-# Stage config for running CosyVoice3 with async_chunk architecture
-# Stage 0: Talker (text prompt -> speech tokens streamed by chunks)
-# Stage 1: Code2Wav (flow matching -> acoustic features -> waveform)
-async_chunk: true
-
-stage_args:
- - stage_id: 0
- is_comprehension: true
- runtime:
- devices: 0
- max_batch_size: 1
- engine_args:
- model_stage: cosyvoice3_talker
- model_arch: CosyVoice3Model
- worker_type: ar
- worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.cosyvoice3.talker2code2wav_async_chunk
- trust_remote_code: true
- gpu_memory_utilization: 0.4
- engine_output_type: latent
- disable_hybrid_kv_cache_manager: true
- enable_prefix_caching: false
- enforce_eager: true
- mm_processor_cache_gb: 0
- skip_mm_profiling: true
- dtype: "float32"
- default_sampling_params:
- max_tokens: 2048
- stop_token_ids: [6562] # merged speech stop token (logsumexp of all 200 stop logits)
- top_k: 25
- top_p: 0.8
- repetition_penalty: 1.0001 # near-identity; forces vLLM to track output_token_ids for RAS
- output_connectors:
- to_stage_1: connector_of_shared_memory
-
- - stage_id: 1
- runtime:
- devices: 0
- max_batch_size: 1
- engine_args:
- model_stage: cosyvoice3_code2wav
- model_arch: CosyVoice3Model
- worker_type: generation
- worker_cls: vllm_omni.worker.gpu_generation_worker.GPUGenerationWorker
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- trust_remote_code: true
- engine_output_type: latent
- gpu_memory_utilization: 0.2
- enforce_eager: true
- disable_hybrid_kv_cache_manager: true
- enable_prefix_caching: false
- skip_mm_profiling: true
- max_model_len: 32768
- dtype: "float32"
- default_sampling_params:
- max_tokens: 2048
- engine_input_source: [0]
- final_output: true
- final_output_type: audio
- input_connectors:
- from_stage_0: connector_of_shared_memory
-
-runtime:
- enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
-
- connectors:
- connector_of_shared_memory:
- name: SharedMemoryConnector
- extra:
- codec_streaming: true
- connector_get_sleep_s: 0.01
- connector_get_max_wait_first_chunk: 3000
- connector_get_max_wait: 300
- codec_chunk_frames: 25
- codec_left_context_frames: 25
- codec_vocab_size: 6561
-
- edges:
- - from: 0
- to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/dynin_omni.yaml b/vllm_omni/model_executor/stage_configs/dynin_omni.yaml
index 0724146aa..131a0d1cd 100644
--- a/vllm_omni/model_executor/stage_configs/dynin_omni.yaml
+++ b/vllm_omni/model_executor/stage_configs/dynin_omni.yaml
@@ -67,14 +67,9 @@ stage_args:
# Top-level runtime config (concise): default windows and stage edges
runtime:
enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
edges:
- from: 0
to: 1
- window_size: -1
- from: 1
to: 2
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/dynin_omni_multiconnector.yaml b/vllm_omni/model_executor/stage_configs/dynin_omni_multiconnector.yaml
index 7259daa9e..4a54f8188 100644
--- a/vllm_omni/model_executor/stage_configs/dynin_omni_multiconnector.yaml
+++ b/vllm_omni/model_executor/stage_configs/dynin_omni_multiconnector.yaml
@@ -71,9 +71,6 @@ stage_args:
# Top-level runtime config (concise): default windows and stage edges
runtime:
enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
####
# same as Qwen2.5_omni version
# Distributed connectors configuration (optional)
@@ -108,7 +105,5 @@ runtime:
edges:
- from: 0
to: 1
- window_size: -1
- from: 1
to: 2
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml b/vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml
deleted file mode 100644
index 0b0b27859..000000000
--- a/vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml
+++ /dev/null
@@ -1,96 +0,0 @@
-async_chunk: true
-stage_args:
- - stage_id: 0
- stage_type: llm
- is_comprehension: true
- runtime:
- devices: "0"
- max_batch_size: 16
- engine_args:
- max_num_seqs: 4
- model_stage: fish_speech_slow_ar
- model_arch: FishSpeechSlowARForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- enforce_eager: false
- trust_remote_code: true
- async_scheduling: false
- enable_prefix_caching: false
- engine_output_type: latent
- gpu_memory_utilization: 0.6
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 3072
- max_model_len: 16384
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.fish_speech.slow_ar_to_dac_decoder_async_chunk
- output_connectors:
- to_stage_1: connector_of_shared_memory
- default_sampling_params:
- temperature: 0.8
- top_k: 30
- top_p: 0.9
- max_tokens: 2048
- seed: 42
- detokenize: false
- repetition_penalty: 1.0
- # <|im_end|> token -- stop when model emits end-of-turn.
- stop_token_ids: [151645]
-
- - stage_id: 1
- stage_type: llm
- runtime:
- devices: "0"
- max_batch_size: 16
- engine_args:
- max_num_seqs: 1
- model_stage: dac_decoder
- model_arch: FishSpeechDACDecoder
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: false
- enable_prefix_caching: false
- engine_output_type: audio
- gpu_memory_utilization: 0.1
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 8192
- max_model_len: 16384
- engine_input_source: [0]
- final_output: true
- final_output_type: audio
- input_connectors:
- from_stage_0: connector_of_shared_memory
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: true
- repetition_penalty: 1.0
-
-runtime:
- enabled: true
- defaults:
- window_size: -1
- max_inflight: 16
-
- connectors:
- connector_of_shared_memory:
- name: SharedMemoryConnector
- extra:
- shm_threshold_bytes: 65536
- codec_streaming: true
- connector_get_sleep_s: 0.01
- connector_get_max_wait_first_chunk: 3000
- connector_get_max_wait: 300
- # Chunk sizes for streaming -- ~21 Hz codec.
- # 25 frames ≈ 1.16s of audio at 21.5 Hz.
- codec_chunk_frames: 25
- codec_left_context_frames: 25
- initial_codec_chunk_frames: 4
-
- edges:
- - from: 0
- to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/glm_image.yaml b/vllm_omni/model_executor/stage_configs/glm_image.yaml
index 3cc23e1e2..f3ed6c721 100644
--- a/vllm_omni/model_executor/stage_configs/glm_image.yaml
+++ b/vllm_omni/model_executor/stage_configs/glm_image.yaml
@@ -33,7 +33,6 @@ stage_args:
temperature: 0.9 # From model's generation_config.json
top_p: 0.75 # From model's generation_config.json
top_k: 16512 # vision_vocab_size from generation_config.json
- max_tokens: 1281 # For 1024x1024: small(16x16=256) + large(32x32=1024) + EOS(1)
stop_token_ids: [16385] # eos_token_id from generation_config.json
seed: 42
detokenize: false
@@ -70,11 +69,6 @@ stage_args:
# Top-level runtime config
runtime:
enabled: true
- defaults:
- window_size: -1 # Trigger downstream only after full upstream completion
- max_inflight: 1 # Process serially within each stage
-
edges:
- from: 0 # AR → Diffusion: trigger after AR completes
to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/glm_image_muilticonnector.yaml b/vllm_omni/model_executor/stage_configs/glm_image_muilticonnector.yaml
index 719c73a9f..2a85a6dad 100644
--- a/vllm_omni/model_executor/stage_configs/glm_image_muilticonnector.yaml
+++ b/vllm_omni/model_executor/stage_configs/glm_image_muilticonnector.yaml
@@ -35,7 +35,6 @@ stage_args:
temperature: 0.9 # From model's generation_config.json
top_p: 0.75 # From model's generation_config.json
top_k: 16512 # vision_vocab_size from generation_config.json
- max_tokens: 1281 # For 1024x1024: small(16x16=256) + large(32x32=1024) + EOS(1)
stop_token_ids: [16385] # eos_token_id from generation_config.json
seed: 42
detokenize: false
@@ -70,14 +69,9 @@ stage_args:
# Top-level runtime config with MultiConnector support
runtime:
enabled: true
- defaults:
- window_size: -1 # Trigger downstream only after full upstream completion
- max_inflight: 1 # Process serially within each stage
-
edges:
- from: 0 # AR → Diffusion
to: 1
- window_size: -1
# OmniConnector configuration for efficient inter-stage tensor transfer
connectors:
diff --git a/vllm_omni/model_executor/stage_configs/hunyuan_image3_i2t.yaml b/vllm_omni/model_executor/stage_configs/hunyuan_image3_i2t.yaml
new file mode 100644
index 000000000..b68b184ec
--- /dev/null
+++ b/vllm_omni/model_executor/stage_configs/hunyuan_image3_i2t.yaml
@@ -0,0 +1,41 @@
+# Stage config for HunyuanImage-3.0 Image-to-Text (I2T / image understanding).
+# Single LLM stage: AR model reads image + text prompt, generates text output.
+
+stage_args:
+ - stage_id: 0
+ stage_type: llm
+ runtime:
+ process: true
+ devices: "0,1,2,3"
+ max_batch_size: 1
+ requires_multimodal_data: true
+ engine_args:
+ model_stage: AR
+ max_num_seqs: 1
+ model_arch: HunyuanImage3ForCausalMM
+ worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
+ scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
+ gpu_memory_utilization: 0.95
+ enforce_eager: true
+ trust_remote_code: true
+ enable_prefix_caching: false
+ max_num_batched_tokens: 32768
+ tensor_parallel_size: 4
+ pipeline_parallel_size: 1
+ hf_overrides:
+ rope_parameters:
+ mrope_section: [0, 32, 32]
+ rope_type: default
+ is_comprehension: true
+ final_output: true
+ final_output_type: text
+ default_sampling_params:
+ temperature: 0.0
+ top_p: 0.95
+ top_k: 1024
+ max_tokens: 2048
+ stop_token_ids: [127957, 128026] # <|endoftext|>,
+ detokenize: True
+
+runtime:
+ enabled: true
diff --git a/vllm_omni/model_executor/stage_configs/hunyuan_image3_it2i.yaml b/vllm_omni/model_executor/stage_configs/hunyuan_image3_it2i.yaml
new file mode 100644
index 000000000..413e0f09c
--- /dev/null
+++ b/vllm_omni/model_executor/stage_configs/hunyuan_image3_it2i.yaml
@@ -0,0 +1,74 @@
+# Stage config for HunyuanImage-3.0 Image+Text-to-Image (image editing).
+# Stage 0: AR (HunyuanImage3ForConditionalGeneration) — reads (image, text), emits latent tokens
+# Stage 1: Diffusion (HunyuanImage3Pipeline / DiT + VAE) — denoise + decode latents → image
+
+stage_args:
+ # Stage 0: AR Model
+ - stage_id: 0
+ stage_type: llm
+ runtime:
+ process: true
+ devices: "0,1,2,3"
+ max_batch_size: 1
+ requires_multimodal_data: true # AR needs the original image
+ engine_args:
+ model_stage: AR
+ model_arch: HunyuanImage3ForCausalMM
+ worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
+ scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
+ gpu_memory_utilization: 0.95
+ enforce_eager: true
+ trust_remote_code: true
+ engine_output_type: latent # AR outputs latent for DiT
+ enable_prefix_caching: false
+ max_num_batched_tokens: 32768
+ tensor_parallel_size: 4
+ pipeline_parallel_size: 1
+ hf_overrides:
+ rope_parameters:
+ mrope_section: [0, 32, 32]
+ rope_type: default
+ is_comprehension: false # Generation task, not comprehension
+ final_output: false # AR is not the final output
+ default_sampling_params:
+ temperature: 0.6
+ top_p: 0.95
+ top_k: 1024
+ max_tokens: 4096
+ stop_token_ids: [127957] # <|endoftext|>
+ detokenize: false
+
+ # Stage 1: Diffusion (DiT + VAE)
+ # Receives latents from AR stage, performs denoising + VAE decode
+ - stage_id: 1
+ stage_type: diffusion
+ runtime:
+ process: true
+ devices: "4,5,6,7"
+ max_batch_size: 1
+ requires_multimodal_data: true # May need condition images
+ engine_args:
+ model_stage: dit
+ model_arch: HunyuanImage3ForCausalMM
+ enforce_eager: true
+ trust_remote_code: true
+ distributed_executor_backend: "mp"
+ parallel_config:
+ tensor_parallel_size: 4
+ enable_expert_parallel: true
+ omni_kv_config:
+ need_recv_cache: true
+ engine_input_source: [0] # Input from AR stage
+ custom_process_input_func: vllm_omni.model_executor.stage_input_processors.hunyuan_image3.ar2diffusion
+ final_output: true
+ final_output_type: image
+ default_sampling_params:
+ num_inference_steps: 50
+ guidance_scale: 2.5
+
+# Top-level runtime config
+runtime:
+ enabled: true
+ edges:
+ - from: 0 # AR → Diffusion
+ to: 1
diff --git a/vllm_omni/model_executor/stage_configs/hunyuan_image_3_moe.yaml b/vllm_omni/model_executor/stage_configs/hunyuan_image3_moe.yaml
similarity index 59%
rename from vllm_omni/model_executor/stage_configs/hunyuan_image_3_moe.yaml
rename to vllm_omni/model_executor/stage_configs/hunyuan_image3_moe.yaml
index 808b4619f..f0797c632 100644
--- a/vllm_omni/model_executor/stage_configs/hunyuan_image_3_moe.yaml
+++ b/vllm_omni/model_executor/stage_configs/hunyuan_image3_moe.yaml
@@ -1,36 +1,43 @@
-# Stage config for running Hunyuan-Image3.0 for multi-stage omni runtime.
+# Stage config for running Hunyuan-Image3.0 with AR→DiT KV reuse.
# Stage 0: AR Model (vLLM implementation)
+# Stage 1: DiT Model (diffusion)
+#
+# text-to-image flow: AR (stage 0) → KV transfer → DiT (stage 1)
+# image-to-text flow: AR (stage 0) only
+#
+# Compared to hunyuan_image3_t2i.yaml, this config:
+# 1. Enables both stages [0, 1] for text-to-image (AR prefill + DiT denoising)
+# 2. Adds omni_kv_config to send/receive KV cache between stages
-# The following config has been verified on 8x L40S-48G GPU.
-modes:
- - mode: text-to-image
- stages: [1]
- - mode: image-to-text
- stages: [0]
+# The following config has been verified on 8x L40S-48G GPU (4 for AR + 4 for DiT).
stage_args:
- stage_id: 0
stage_type: llm # Use llm stage type for AR stages
runtime:
process: true # Run this stage in a separate process
- devices: "0,1,2,3,4,5,6,7" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
+ devices: "0,1,2,3" # AR stage uses GPU 0-3
engine_args:
model_stage: AR
max_num_seqs: 1
model_arch: HunyuanImage3ForCausalMM
worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.3
+ gpu_memory_utilization: 0.9
enforce_eager: true # Now we only support eager mode
trust_remote_code: true
engine_output_type: latent
enable_prefix_caching: false
max_num_batched_tokens: 32768
- tensor_parallel_size: 8
+ tensor_parallel_size: 4
pipeline_parallel_size: 1
hf_overrides:
rope_parameters:
mrope_section: [0, 32, 32]
rope_type: default
+ omni_kv_config:
+ need_send_cache: true
+ kv_transfer_criteria:
+ type: prefill_finished # Send KV cache after AR prefill completes
is_comprehension: true
final_output: true
final_output_type: text
@@ -46,7 +53,7 @@ stage_args:
stage_type: diffusion
runtime:
process: true
- devices: "0,1,2,3,4,5,6,7"
+ devices: "4,5,6,7" # DiT stage uses GPU 4-7
max_batch_size: 1
engine_args:
model_stage: diffusion
@@ -57,10 +64,12 @@ stage_args:
cache_backend: null
cache_config: null
enable_cache_dit_summary: false
+ omni_kv_config:
+ need_recv_cache: true # Receive AR KV cache from stage 0
parallel_config:
pipeline_parallel_size: 1
data_parallel_size: 1
- tensor_parallel_size: 8
+ tensor_parallel_size: 4
enable_expert_parallel: false
sequence_parallel_size: 1
ulysses_degree: 1
@@ -70,12 +79,18 @@ stage_args:
use_hsdp: false
hsdp_shard_size: -1
hsdp_replicate_size: 1
+ engine_input_source: [0] # Receive input (including KV) from stage 0
final_output: true
final_output_type: image
-# Top-level runtime config (concise): default windows and stage edges
+# Top-level runtime config: windows, edges, and connectors
runtime:
enabled: true
defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
+ window_size: -1 # Trigger downstream only after full upstream completion
+ max_inflight: 1 # Process serially within each stage
+
+ edges:
+ - from: 0
+ to: 1
+ window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/hunyuan_image3_moe_dit_2gpu_fp8.yaml b/vllm_omni/model_executor/stage_configs/hunyuan_image3_moe_dit_2gpu_fp8.yaml
index aeef27a97..586b601bc 100644
--- a/vllm_omni/model_executor/stage_configs/hunyuan_image3_moe_dit_2gpu_fp8.yaml
+++ b/vllm_omni/model_executor/stage_configs/hunyuan_image3_moe_dit_2gpu_fp8.yaml
@@ -30,6 +30,3 @@ stage_args:
# Runtime edges
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
diff --git a/vllm_omni/model_executor/stage_configs/hunyuan_image3_moe_dit.yaml b/vllm_omni/model_executor/stage_configs/hunyuan_image3_t2i.yaml
similarity index 93%
rename from vllm_omni/model_executor/stage_configs/hunyuan_image3_moe_dit.yaml
rename to vllm_omni/model_executor/stage_configs/hunyuan_image3_t2i.yaml
index a60fe9a5b..1d8c7f481 100644
--- a/vllm_omni/model_executor/stage_configs/hunyuan_image3_moe_dit.yaml
+++ b/vllm_omni/model_executor/stage_configs/hunyuan_image3_t2i.yaml
@@ -29,6 +29,3 @@ stage_args:
# Runtime edges
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
diff --git a/vllm_omni/model_executor/stage_configs/hunyuan_image_3_moe_2gpu.yaml b/vllm_omni/model_executor/stage_configs/hunyuan_image3_t2i_2gpu.yaml
similarity index 95%
rename from vllm_omni/model_executor/stage_configs/hunyuan_image_3_moe_2gpu.yaml
rename to vllm_omni/model_executor/stage_configs/hunyuan_image3_t2i_2gpu.yaml
index e029c3836..41ed74ba6 100644
--- a/vllm_omni/model_executor/stage_configs/hunyuan_image_3_moe_2gpu.yaml
+++ b/vllm_omni/model_executor/stage_configs/hunyuan_image3_t2i_2gpu.yaml
@@ -39,6 +39,3 @@ stage_args:
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
diff --git a/vllm_omni/model_executor/stage_configs/hunyuan_image3_t2t.yaml b/vllm_omni/model_executor/stage_configs/hunyuan_image3_t2t.yaml
new file mode 100644
index 000000000..a0a1a0dc1
--- /dev/null
+++ b/vllm_omni/model_executor/stage_configs/hunyuan_image3_t2t.yaml
@@ -0,0 +1,42 @@
+# Stage config for HunyuanImage-3.0 Text-to-Text (T2T / pure text generation).
+# Single LLM stage: AR model reads text prompt only, generates text output.
+# Sampling params aligned with official generation_config.json.
+
+stage_args:
+ - stage_id: 0
+ stage_type: llm
+ runtime:
+ process: true
+ devices: "0,1,2,3"
+ max_batch_size: 1
+ requires_multimodal_data: false
+ engine_args:
+ model_stage: AR
+ max_num_seqs: 1
+ model_arch: HunyuanImage3ForCausalMM
+ worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
+ scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
+ gpu_memory_utilization: 0.95
+ enforce_eager: true
+ trust_remote_code: true
+ enable_prefix_caching: false
+ max_num_batched_tokens: 32768
+ tensor_parallel_size: 4
+ pipeline_parallel_size: 1
+ hf_overrides:
+ rope_parameters:
+ mrope_section: [0, 32, 32]
+ rope_type: default
+ is_comprehension: true
+ final_output: true
+ final_output_type: text
+ default_sampling_params:
+ temperature: 0.0
+ top_p: 0.95
+ top_k: 1024
+ max_tokens: 2048
+ stop_token_ids: [127957, 128026] # <|endoftext|>,
+ detokenize: True
+
+runtime:
+ enabled: true
diff --git a/vllm_omni/model_executor/stage_configs/mimo_audio.yaml b/vllm_omni/model_executor/stage_configs/mimo_audio.yaml
deleted file mode 100644
index 123e5f0cf..000000000
--- a/vllm_omni/model_executor/stage_configs/mimo_audio.yaml
+++ /dev/null
@@ -1,69 +0,0 @@
-# stage config for running mimo-audio for multi-stage omni runtime.
-
-# The following config has been verified on 1x H20-96G GPU.
-async_chunk: false
-stage_args:
- - stage_id: 0
- stage_type: llm
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
- engine_args:
- dtype: bfloat16
- max_num_seqs: 1
- model_stage: fused_thinker_talker
- model_arch: MiMoAudioForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- tensor_parallel_size: 1 # Change to desired TP size for multi-GPU inference (e.g., 4 for 4 GPUs)
- gpu_memory_utilization: 0.3
- enforce_eager: true # need to discuss
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: latent # change the param name,such as pooling_output
- max_model_len: 8192
- max_num_batched_tokens: 8192
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.6
- top_p: 0.95
- top_k: 50
- max_tokens: 18192
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
- - stage_id: 1
- stage_type: llm
- runtime:
- process: true # Run this stage in a separate process
- devices: "1" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: MiMoAudioForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- tensor_parallel_size: 1 # Change to desired TP size for multi-GPU inference (e.g., 4 for 4 GPUs)
- gpu_memory_utilization: 0.2
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio
- max_model_len: 18192
- max_num_batched_tokens: 18192
- async_scheduling: false
- engine_input_source: [ 0 ]
- is_comprehension: false
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.mimo_audio.llm2code2wav
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 18192
- seed: 42
- detokenize: false
diff --git a/vllm_omni/model_executor/stage_configs/mimo_audio_async_chunk.yaml b/vllm_omni/model_executor/stage_configs/mimo_audio_async_chunk.yaml
deleted file mode 100644
index b3c6bbbaf..000000000
--- a/vllm_omni/model_executor/stage_configs/mimo_audio_async_chunk.yaml
+++ /dev/null
@@ -1,96 +0,0 @@
-# stage config for running mimo-audio for multi-stage omni runtime.
-
-# The following config has been verified on 1x H20-96G GPU.
-async_chunk: true
-stage_args:
- - stage_id: 0
- stage_type: llm
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
- engine_args:
- dtype: bfloat16
- max_num_seqs: 1
- model_stage: fused_thinker_talker
- model_arch: MiMoAudioForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- tensor_parallel_size: 1 # Change to desired TP size for multi-GPU inference (e.g., 4 for 4 GPUs)
- gpu_memory_utilization: 0.3
- enforce_eager: true # need to discuss
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: latent # change the param name,such as pooling_output
- max_model_len: 8192
- max_num_batched_tokens: 8192
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.mimo_audio.llm2code2wav_async_chunk
- output_connectors:
- to_stage_1: connector_of_shared_memory
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.6
- top_p: 0.95
- top_k: 50
- max_tokens: 18192
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
- - stage_id: 1
- stage_type: llm
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: MiMoAudioForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- tensor_parallel_size: 1 # Change to desired TP size for multi-GPU inference (e.g., 4 for 4 GPUs)
- gpu_memory_utilization: 0.2
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: false
- enable_prefix_caching: false
- engine_output_type: audio
- max_model_len: 8192
- max_num_batched_tokens: 8192
- engine_input_source: [ 0 ]
- input_connectors:
- from_stage_0: connector_of_shared_memory
- is_comprehension: false
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 18192
- seed: 42
- detokenize: false
-
-runtime:
- enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
-
- connectors:
- connector_of_shared_memory:
- name: SharedMemoryConnector
- extra:
- shm_threshold_bytes: 365536
- codec_streaming: true
- connector_get_sleep_s: 0.001
- connector_get_max_wait_first_chunk: 3000
- connector_get_max_wait: 300
- codec_chunk_frames: 3
- codec_left_context_frames: 3
-
- edges:
- - from: 0
- to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/qwen2_5_omni.yaml b/vllm_omni/model_executor/stage_configs/qwen2_5_omni.yaml
deleted file mode 100644
index 0a307b447..000000000
--- a/vllm_omni/model_executor/stage_configs/qwen2_5_omni.yaml
+++ /dev/null
@@ -1,107 +0,0 @@
-# stage config for running qwen2.5-omni for multi-stage omni runtime.
-
-# The following config has been verified on 2x H100-80G GPU.
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
- engine_args:
- model_stage: thinker
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.8
- enforce_eager: true # Now we only support eager mode
- trust_remote_code: true
- engine_output_type: latent
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- mm_processor_cache_gb: 0
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
- - stage_id: 1
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.8
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- engine_output_type: latent
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen2_5_omni.thinker2talker
- default_sampling_params:
- temperature: 0.9
- top_p: 0.8
- top_k: 40
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
- stop_token_ids: [8294]
-
- - stage_id: 2
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true
- devices: "0" # Example: use a different GPU than the previous stage; use "0" if single GPU
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- gpu_memory_utilization: 0.15
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- async_scheduling: false
- engine_output_type: audio
- engine_input_source: [1]
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
-# Top-level runtime config (concise): default windows and stage edges
-runtime:
- enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
-
- edges:
- - from: 0 # thinker → talker: trigger only after receiving full input (-1)
- to: 1
- window_size: -1
- - from: 1 # talker → code2wav: trigger only after receiving full input (-1)
- to: 2
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/qwen2_5_omni_multiconnector.yaml b/vllm_omni/model_executor/stage_configs/qwen2_5_omni_multiconnector.yaml
deleted file mode 100644
index 6e4f871e3..000000000
--- a/vllm_omni/model_executor/stage_configs/qwen2_5_omni_multiconnector.yaml
+++ /dev/null
@@ -1,141 +0,0 @@
-# stage config for running qwen2.5-omni for multi-stage omni runtime.
-
-# The following config has been verified on 1x H100-80G GPU.
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
- engine_args:
- model_stage: thinker
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.8
- enforce_eager: true # Now we only support eager mode
- trust_remote_code: true
- engine_output_type: latent
- enable_prefix_caching: false
- mm_processor_cache_gb: 0
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
- # Distributed connector configuration (optional)
- output_connectors:
- to_stage_1: mooncake_connector
- - stage_id: 1
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.8
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: latent
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen2_5_omni.thinker2talker
- default_sampling_params:
- temperature: 0.9
- top_p: 0.8
- top_k: 40
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
- stop_token_ids: [8294]
- # Distributed connector configuration (optional)
- input_connectors:
- from_stage_0: mooncake_connector
- output_connectors:
- to_stage_2: mooncake_connector
- - stage_id: 2
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true
- devices: "2" # Example: use a different GPU than the previous stage; use "0" if single GPU
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- gpu_memory_utilization: 0.3
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- engine_output_type: audio
- engine_input_source: [1]
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
- # Distributed connector configuration (optional)
- input_connectors:
- from_stage_1: mooncake_connector
-
-# Top-level runtime config (concise): default windows and stage edges
-runtime:
- enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
-
- # Distributed connectors configuration (optional)
- # More connectors will be supported in the future.
- connectors:
- # Mooncake connector for cross-node/intra-node communication
- mooncake_connector:
- name: MooncakeStoreConnector
- extra:
- host: "127.0.0.1"
- metadata_server: "http://10.90.67.86:8080/metadata"
- master: "10.90.67.86:50051"
- segment: 512000000 # 512MB
- localbuf: 64000000 # 64MB
- proto: "tcp"
-
- # Yuanrong connector for cross-node/intra-node communication
- yuanrong_connector:
- name: YuanrongConnector
- extra:
- host: "127.0.0.1"
- port: "35000"
-
- # SharedMemory connector for intra-node communication
- # Alternative SHM connector with different threshold
- shared_memory_connector:
- name: SharedMemoryConnector
- extra:
- shm_threshold_bytes: 65536 # 64KB threshold
-
- edges:
- - from: 0 # thinker → talker: trigger only after receiving full input (-1)
- to: 1
- window_size: -1
- - from: 1 # talker → code2wav: trigger only after receiving full input (-1)
- to: 2
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/qwen3_omni_moe.yaml b/vllm_omni/model_executor/stage_configs/qwen3_omni_moe.yaml
deleted file mode 100644
index 0ce4f0c94..000000000
--- a/vllm_omni/model_executor/stage_configs/qwen3_omni_moe.yaml
+++ /dev/null
@@ -1,101 +0,0 @@
-# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
-# Stage 0: Thinker (multimodal understanding + text generation)
-# Stage 1: Talker (text embeddings → 8-layer RVQ codec codes)
-# Stage 2: Code2Wav (8-layer RVQ codes → audio waveform)
-
-# The following config has been verified on 2x H100-80G GPUs.
-async_chunk: false
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "0"
- engine_args:
- model_stage: thinker
- max_num_seqs: 64
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.9
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output hidden states for talker
- distributed_executor_backend: "mp"
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- hf_config_name: thinker_config
- tensor_parallel_size: 1
- final_output: true
- final_output_type: text
- is_comprehension: true
- default_sampling_params:
- temperature: 0.4
- top_p: 0.9
- top_k: 1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
-
- - stage_id: 1
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 64
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.6
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output codec codes for code2wav
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- distributed_executor_backend: "mp"
- hf_config_name: talker_config
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
- # final_output: true
- # final_output_type: text
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: False
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 2
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "1"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 32
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: false
- enable_prefix_caching: false
- engine_output_type: audio # Final output: audio waveform
- gpu_memory_utilization: 0.1
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 1000000
- hf_config_name: thinker_config
- engine_input_source: [1]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
diff --git a/vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml b/vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml
deleted file mode 100644
index 38626fc08..000000000
--- a/vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml
+++ /dev/null
@@ -1,117 +0,0 @@
-# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
-# Stage 0: Thinker (multimodal understanding + text generation)
-# Stage 1: Talker (text embeddings → 16-layer RVQ codec codes)
-# Stage 2: Code2Wav (16-layer RVQ codes → audio waveform)
-
-# The following config has been verified on 2x H100-80G GPUs.
-async_chunk: true
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "0"
- engine_args:
- model_stage: thinker
- max_num_seqs: 64
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.9
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output hidden states for talker
- distributed_executor_backend: "mp"
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- hf_config_name: thinker_config
- tensor_parallel_size: 1
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk
- final_output: true
- final_output_type: text
- is_comprehension: true
- # Use named connector to apply runtime.connectors.extra.
- output_connectors:
- to_stage_1: connector_of_shared_memory
- default_sampling_params:
- temperature: 0.4
- top_p: 0.9
- top_k: 1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
-
- - stage_id: 1
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 64
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.6
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output codec codes for code2wav
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- distributed_executor_backend: "mp"
- hf_config_name: talker_config
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_async_chunk
- engine_input_source: [0]
- # final_output: true
- # final_output_type: text
- # Distributed connector configuration
- input_connectors:
- from_stage_0: connector_of_shared_memory
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: False
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 2
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "1"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 64
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: false
- enable_prefix_caching: false
- engine_output_type: audio # Final output: audio waveform
- gpu_memory_utilization: 0.1
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 51200 # [TODO] if max_num_batch_tokens < max_num_seqs * 800, there will be precision problem.
- hf_config_name: thinker_config
- engine_input_source: [1]
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
-runtime:
-
- connectors:
- connector_of_shared_memory:
- name: SharedMemoryConnector
- extra:
- # Align with Omni: small chunks with sufficient context overlap.
- codec_chunk_frames: 25 # code2wav decode chunk size
- codec_left_context_frames: 25 # code2wav left context size
diff --git a/vllm_omni/model_executor/stage_configs/qwen3_omni_moe_multiconnector.yaml b/vllm_omni/model_executor/stage_configs/qwen3_omni_moe_multiconnector.yaml
deleted file mode 100644
index 6c2d2a766..000000000
--- a/vllm_omni/model_executor/stage_configs/qwen3_omni_moe_multiconnector.yaml
+++ /dev/null
@@ -1,143 +0,0 @@
-# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
-# Stage 0: Thinker (multimodal understanding + text generation)
-# Stage 1: Talker (text embeddings -> 8-layer RVQ codec codes)
-# Stage 2: Code2Wav (8-layer RVQ codes -> audio waveform)
-
-# The following config has been verified on 2x H100-80G GPUs.
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "0"
- engine_args:
- model_stage: thinker
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.9
- enforce_eager: true
- trust_remote_code: true
- engine_output_type: latent # Output hidden states for talker
- distributed_executor_backend: "mp"
- enable_prefix_caching: false
- hf_config_name: thinker_config
- tensor_parallel_size: 1
- final_output: true
- final_output_type: text
- is_comprehension: true
- default_sampling_params:
- temperature: 0.4
- top_p: 0.9
- top_k: 1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
- # Distributed connector configuration
- output_connectors:
- to_stage_1: mooncake_connector
-
- - stage_id: 1
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.6
- enforce_eager: true
- trust_remote_code: true
- engine_output_type: latent # Output codec codes for code2wav
- # tensor_parallel_size: 2
- enable_prefix_caching: false
- distributed_executor_backend: "mp"
- hf_config_name: talker_config
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
- # final_output: true
- # final_output_type: text
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: False
- repetition_penalty: 1.05
- stop_token_ids: [2150]
- # Distributed connector configuration
- input_connectors:
- from_stage_0: mooncake_connector
- output_connectors:
- to_stage_2: mooncake_connector
-
- - stage_id: 2
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "1"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 64
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio # Final output: audio waveform
- gpu_memory_utilization: 0.1
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 1000000
- hf_config_name: thinker_config
- engine_input_source: [1]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
- # Distributed connector configuration
- input_connectors:
- from_stage_1: mooncake_connector
-
-# Top-level runtime config: default windows and stage edges
-runtime:
- enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
-
- # Distributed connectors configuration
- connectors:
- # Mooncake connector for cross-node/intra-node communication
- mooncake_connector:
- name: MooncakeStoreConnector
- extra:
- host: "127.0.0.1"
- metadata_server: "http://10.90.67.86:8080/metadata"
- master: "10.90.67.86:50051"
- segment: 512000000 # 512MB
- localbuf: 64000000 # 64MB
- proto: "tcp"
-
- # SharedMemory connector for intra-node communication
- shared_memory_connector:
- name: SharedMemoryConnector
- extra:
- shm_threshold_bytes: 65536 # 64KB threshold
-
- edges:
- - from: 0
- to: 1
- window_size: -1
- - from: 1
- to: 2
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml b/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml
deleted file mode 100644
index a0d38eb4b..000000000
--- a/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml
+++ /dev/null
@@ -1,99 +0,0 @@
-async_chunk: true
-stage_args:
- - stage_id: 0
- stage_type: llm
- is_comprehension: true
- runtime:
- devices: "0"
- engine_args:
- model_stage: qwen3_tts
- max_num_seqs: 10
- model_arch: Qwen3TTSTalkerForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- enforce_eager: false
- trust_remote_code: true
- async_scheduling: true
- enable_prefix_caching: false
- engine_output_type: latent
- gpu_memory_utilization: 0.3
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 512
- max_model_len: 4096
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
- # Use named connector to apply runtime.connectors.extra.
- output_connectors:
- to_stage_1: connector_of_shared_memory
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: false
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 1
- stage_type: llm
- runtime:
- devices: "0"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen3TTSCode2Wav
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: true
- enable_prefix_caching: false
- engine_output_type: audio
- gpu_memory_utilization: 0.3
- distributed_executor_backend: "mp"
- # Must be divisible by num_code_groups and cover (left_context + chunk).
- # Prefill length is Q * num_frames (e.g. 16 * 2148 = 34368); keep headroom past 32k.
- max_num_batched_tokens: 65536
- # async_chunk appends windows per step; max_model_len must cover accumulated flat codec stream.
- max_model_len: 65536
- engine_input_source: [0]
- final_output: true
- final_output_type: audio
- # Distributed connector configuration
- input_connectors:
- from_stage_0: connector_of_shared_memory
- tts_args:
- max_instructions_length: 500
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: true
- repetition_penalty: 1.0
-
-runtime:
- enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
-
- connectors:
- connector_of_shared_memory:
- name: SharedMemoryConnector
- extra:
- shm_threshold_bytes: 65536
- # Frame-aligned codec streaming transport.
- codec_streaming: true
- # Connector polling / timeout (unit: loop count, sleep interval in seconds).
- connector_get_sleep_s: 0.01
- connector_get_max_wait_first_chunk: 3000
- connector_get_max_wait: 300
- # Match the decoder sliding attention window to avoid chunk-boundary noise.
- codec_chunk_frames: 25
- codec_left_context_frames: 72
-
- edges:
- - from: 0
- to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml b/vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml
deleted file mode 100644
index 75b2bab3a..000000000
--- a/vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml
+++ /dev/null
@@ -1,100 +0,0 @@
-# Same as qwen3_tts.yaml with batched talker and code2wav.
-# Stage 0: max_num_seqs 4, stage 1: max_num_seqs 4.
-# max_num_seqs must be a power of two to align with CUDA graph capture sizes
-# (stage 0) and must match --batch-size in end2end.py / benchmark scripts.
-async_chunk: true
-stage_args:
- - stage_id: 0
- stage_type: llm
- is_comprehension: true
- runtime:
- devices: "0"
- engine_args:
- model_stage: qwen3_tts
- max_num_seqs: 4
- model_arch: Qwen3TTSTalkerForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- enforce_eager: false
- trust_remote_code: true
- async_scheduling: true
- enable_prefix_caching: false
- engine_output_type: latent
- gpu_memory_utilization: 0.3
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 512
- max_model_len: 4096
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
- # Use named connector to apply runtime.connectors.extra.
- output_connectors:
- to_stage_1: connector_of_shared_memory
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: false
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 1
- stage_type: llm
- runtime:
- devices: "0"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 4
- model_arch: Qwen3TTSCode2Wav
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: true
- enable_prefix_caching: false
- engine_output_type: audio
- gpu_memory_utilization: 0.2
- distributed_executor_backend: "mp"
- # Must be divisible by num_code_groups and cover (left_context + chunk).
- max_num_batched_tokens: 65536
- # Flat codec prompt can exceed 32k tokens (Q * frames); align with max_tokens below.
- max_model_len: 65536
- engine_input_source: [0]
- final_output: true
- final_output_type: audio
- # Distributed connector configuration
- input_connectors:
- from_stage_0: connector_of_shared_memory
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: true
- repetition_penalty: 1.0
-
-runtime:
- enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
-
- connectors:
- connector_of_shared_memory:
- name: SharedMemoryConnector
- extra:
- shm_threshold_bytes: 65536
- # Frame-aligned codec streaming transport.
- codec_streaming: true
- # Connector polling / timeout (unit: loop count, sleep interval in seconds).
- connector_get_sleep_s: 0.01
- connector_get_max_wait_first_chunk: 3000
- connector_get_max_wait: 300
- # Match the decoder sliding attention window to avoid chunk-boundary noise.
- codec_chunk_frames: 25
- codec_left_context_frames: 72
-
- edges:
- - from: 0
- to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/qwen3_tts_uniproc.yaml b/vllm_omni/model_executor/stage_configs/qwen3_tts_uniproc.yaml
index d2e920806..4ca8d11ad 100644
--- a/vllm_omni/model_executor/stage_configs/qwen3_tts_uniproc.yaml
+++ b/vllm_omni/model_executor/stage_configs/qwen3_tts_uniproc.yaml
@@ -72,9 +72,6 @@ stage_args:
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
connectors:
connector_of_shared_memory:
@@ -94,4 +91,3 @@ runtime:
edges:
- from: 0
to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/voxcpm.yaml b/vllm_omni/model_executor/stage_configs/voxcpm.yaml
new file mode 100644
index 000000000..a5f324f66
--- /dev/null
+++ b/vllm_omni/model_executor/stage_configs/voxcpm.yaml
@@ -0,0 +1,69 @@
+# VoxCPM two-stage (latent → VAE) without async_chunk: one-shot latent then decode.
+stage_args:
+ - stage_id: 0
+ stage_type: llm
+ is_comprehension: true
+ runtime:
+ devices: "0"
+ max_batch_size: 1
+ engine_args:
+ dtype: bfloat16
+ model_stage: latent_generator
+ model_arch: VoxCPMForConditionalGeneration
+ # Optional persistent HF-compatible config dir for native VoxCPM models.
+ hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
+ worker_type: ar
+ scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
+ enforce_eager: true
+ trust_remote_code: true
+ async_scheduling: false
+ enable_prefix_caching: false
+ engine_output_type: latent
+ gpu_memory_utilization: 0.7
+ distributed_executor_backend: "mp"
+ max_num_batched_tokens: 4096
+ max_model_len: 4096
+ default_sampling_params:
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
+ max_tokens: 4096
+ stop_token_ids: [2]
+ seed: 42
+ detokenize: false
+ repetition_penalty: 1.0
+ final_output: false
+
+ - stage_id: 1
+ stage_type: llm
+ runtime:
+ devices: "0"
+ max_batch_size: 1
+ engine_args:
+ dtype: float32
+ model_stage: vae
+ model_arch: VoxCPMForConditionalGeneration
+ hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
+ worker_type: generation
+ scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
+ enforce_eager: true
+ trust_remote_code: true
+ async_scheduling: false
+ enable_prefix_caching: false
+ engine_output_type: audio
+ gpu_memory_utilization: 0.15
+ distributed_executor_backend: "mp"
+ max_num_batched_tokens: 8192
+ max_model_len: 4096
+ engine_input_source: [0]
+ custom_process_input_func: vllm_omni.model_executor.stage_input_processors.voxcpm.latent2vae
+ final_output: true
+ final_output_type: audio
+ default_sampling_params:
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
+ max_tokens: 1
+ seed: 42
+ detokenize: true
+ repetition_penalty: 1.0
diff --git a/vllm_omni/model_executor/stage_configs/voxcpm2.yaml b/vllm_omni/model_executor/stage_configs/voxcpm2.yaml
deleted file mode 100644
index 7cc93d6b2..000000000
--- a/vllm_omni/model_executor/stage_configs/voxcpm2.yaml
+++ /dev/null
@@ -1,36 +0,0 @@
-# VoxCPM2 AR pipeline with per-request state batching.
-# Uses native MiniCPM4 base_lm + per-request StaticKVCache.
-# max_batch_size > 1 supported via KV cache save/restore.
-stage_args:
- - stage_id: 0
- stage_type: llm
- is_comprehension: true
- runtime:
- devices: "0"
- max_batch_size: 4
- engine_args:
- dtype: bfloat16
- model_stage: latent_generator
- model_arch: VoxCPM2TalkerForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: true
- enable_prefix_caching: false
- engine_output_type: audio
- gpu_memory_utilization: 0.9
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 4096
- max_model_len: 4096
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 4096
- seed: 42
- detokenize: false
- repetition_penalty: 1.0
- stop_token_ids: [1]
- final_output: true
- final_output_type: audio
diff --git a/vllm_omni/platforms/npu/stage_configs/qwen3_tts.yaml b/vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml
similarity index 60%
rename from vllm_omni/platforms/npu/stage_configs/qwen3_tts.yaml
rename to vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml
index cd82d91b7..c6fd177a3 100644
--- a/vllm_omni/platforms/npu/stage_configs/qwen3_tts.yaml
+++ b/vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml
@@ -1,3 +1,5 @@
+# VoxCPM two-stage streaming (align with qwen3_tts.yaml async_chunk pattern).
+# Stage0 (latent_generator) emits latent in time chunks; Stage1 (VAE) decodes as chunks arrive.
async_chunk: true
stage_args:
- stage_id: 0
@@ -5,42 +7,48 @@ stage_args:
is_comprehension: true
runtime:
devices: "0"
+ max_batch_size: 1
engine_args:
- model_stage: qwen3_tts
- max_num_seqs: 1
- model_arch: Qwen3TTSTalkerForConditionalGeneration
+ dtype: bfloat16
+ model_stage: latent_generator
+ model_arch: VoxCPMForConditionalGeneration
+ # Optional persistent HF-compatible config dir for native VoxCPM models.
+ hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
enforce_eager: true
trust_remote_code: true
- async_scheduling: false
+ async_scheduling: true
enable_prefix_caching: false
engine_output_type: latent
- gpu_memory_utilization: 0.3
+ gpu_memory_utilization: 0.7
distributed_executor_backend: "mp"
- max_num_batched_tokens: 512
+ max_num_batched_tokens: 4096
max_model_len: 4096
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
- # Use named connector to apply runtime.connectors.extra.
- output_connectors:
- to_stage_1: connector_of_shared_memory
+ custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.voxcpm.latent2vae_async_chunk
default_sampling_params:
- temperature: 0.9
- top_k: 50
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
max_tokens: 4096
+ stop_token_ids: [2]
seed: 42
detokenize: false
- repetition_penalty: 1.05
- stop_token_ids: [2150]
+ repetition_penalty: 1.0
+ final_output: false
+ output_connectors:
+ to_stage_1: voxcpm_shm
- stage_id: 1
stage_type: llm
runtime:
devices: "0"
+ max_batch_size: 1
engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen3TTSCode2Wav
+ dtype: float32
+ model_stage: vae
+ model_arch: VoxCPMForConditionalGeneration
+ hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
enforce_eager: true
@@ -48,35 +56,30 @@ stage_args:
async_scheduling: false
enable_prefix_caching: false
engine_output_type: audio
- gpu_memory_utilization: 0.2
+ gpu_memory_utilization: 0.15
distributed_executor_backend: "mp"
- max_num_batched_tokens: 65536
- max_model_len: 65536
+ max_num_batched_tokens: 8192
+ max_model_len: 4096
engine_input_source: [0]
final_output: true
final_output_type: audio
- # Distributed connector configuration
input_connectors:
- from_stage_0: connector_of_shared_memory
- tts_args:
- max_instructions_length: 500
+ from_stage_0: voxcpm_shm
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
- max_tokens: 65536
+ max_tokens: 128
seed: 42
detokenize: true
repetition_penalty: 1.0
+
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
connectors:
- connector_of_shared_memory:
+ voxcpm_shm:
name: SharedMemoryConnector
extra:
shm_threshold_bytes: 65536
@@ -87,10 +90,9 @@ runtime:
connector_get_max_wait_first_chunk: 3000
connector_get_max_wait: 300
# Align with Omni: small chunks with sufficient context overlap.
- codec_chunk_frames: 25
- codec_left_context_frames: 72
+ codec_chunk_frames: 1
+ codec_left_context_frames: 1
edges:
- from: 0
to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_configs/voxtral_tts.yaml b/vllm_omni/model_executor/stage_configs/voxtral_tts.yaml
deleted file mode 100644
index 31cccb9cc..000000000
--- a/vllm_omni/model_executor/stage_configs/voxtral_tts.yaml
+++ /dev/null
@@ -1,105 +0,0 @@
-async_chunk: true
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type to launch OmniLLM
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
- engine_args:
- max_num_seqs: 32
- model_stage: audio_generation
- model_arch: VoxtralTTSForConditionalGeneration
- worker_type: ar
- worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.8
- enforce_eager: false
- trust_remote_code: true
- async_scheduling: true
- engine_output_type: latent
- enable_prefix_caching: false
- tokenizer_mode: mistral
- config_format: mistral
- load_format: mistral
- skip_mm_profiling: true
- enable_chunked_prefill: false
- max_model_len: 4096
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.voxtral_tts.generator2tokenizer_async_chunk
- output_connectors:
- to_stage_1: connector_of_shared_memory
- is_comprehension: true
- final_output: false
- final_output_type: text
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
- - stage_id: 1
- stage_type: llm # Use llm stage type to launch OmniLLM
- runtime:
- process: true
- devices: "0"
- engine_args:
- max_num_seqs: 32
- model_stage: audio_tokenizer
- model_arch: VoxtralTTSForConditionalGeneration
- worker_type: generation
- worker_cls: vllm_omni.worker.gpu_generation_worker.GPUGenerationWorker
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- async_scheduling: false
- gpu_memory_utilization: 0.1
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- skip_mm_profiling: true
- engine_output_type: audio
- tokenizer_mode: mistral
- config_format: mistral
- load_format: mistral
- max_num_batched_tokens: 65536
- max_model_len: 65536
- engine_input_source: [0]
- is_comprehension: false
- final_output: true
- final_output_type: audio
- input_connectors:
- from_stage_0: connector_of_shared_memory
- tts_args:
- max_instructions_length: 500
- default_sampling_params:
- temperature: 0.9
- top_p: 0.8
- top_k: 40
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
-
-# Top-level runtime config (concise): default windows and stage edges
-runtime:
- enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
-
- connectors:
- connector_of_shared_memory:
- name: SharedMemoryConnector
- extra:
- shm_threshold_bytes: 65536
- codec_streaming: true
- connector_get_sleep_s: 0.01
- connector_get_max_wait_first_chunk: 3000
- connector_get_max_wait: 300
- codec_chunk_frames: 25
- codec_chunk_frames_at_begin: 5
- codec_left_context_frames: 25
-
- edges:
- - from: 0 # language_model → acoustic_transformer: trigger only after receiving full input (-1)
- to: 1
- window_size: -1
diff --git a/vllm_omni/model_executor/stage_input_processors/bagel.py b/vllm_omni/model_executor/stage_input_processors/bagel.py
index bfcff0ea0..52cc14d3a 100644
--- a/vllm_omni/model_executor/stage_input_processors/bagel.py
+++ b/vllm_omni/model_executor/stage_input_processors/bagel.py
@@ -82,6 +82,8 @@ def expand_cfg_prompts(
neg_prompt = _get_negative_prompt(prompt, sampling_params)
if "image" in modalities:
+ if not neg_prompt:
+ return []
neg_prompt_dict = {
"prompt": neg_prompt,
"modalities": prompt.get("modalities", []),
@@ -166,6 +168,8 @@ def expand_cfg_prompts_think(
companion_params = {"max_tokens": 1}
if "image" in modalities:
+ if not neg_prompt:
+ return []
neg_prompt_dict = {
"prompt": neg_prompt,
"modalities": prompt.get("modalities", []),
@@ -287,9 +291,10 @@ def _get_negative_prompt(
) -> str:
"""Resolve the negative prompt for CFG from prompt or sampling params.
- An empty string is treated the same as absent (falls through to
- the Bagel default token pair), because an empty negative prompt is
- not meaningful for CFG guidance.
+ Returns the negative prompt string when one is supplied, otherwise an
+ empty string. Callers decide how to treat the empty case: text2img
+ skips the cfg_text companion entirely, while img2img substitutes it
+ into the cfg_text prompt template.
"""
neg = prompt.get("negative_prompt")
if neg:
@@ -300,4 +305,4 @@ def _get_negative_prompt(
if neg:
return neg
- return "<|im_start|><|im_end|>"
+ return ""
diff --git a/vllm_omni/model_executor/stage_input_processors/glm_image.py b/vllm_omni/model_executor/stage_input_processors/glm_image.py
index 3063620bf..53e99610f 100644
--- a/vllm_omni/model_executor/stage_input_processors/glm_image.py
+++ b/vllm_omni/model_executor/stage_input_processors/glm_image.py
@@ -2,6 +2,8 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Stage input processor for GLM-Image: AR → Diffusion transition."""
+import math
+import time
from typing import Any
import torch
@@ -13,6 +15,86 @@ from vllm_omni.inputs.data import OmniTokensPrompt
logger = init_logger(__name__)
+def _has_source_image(mm_data: Any) -> bool:
+ """Return whether prompt multi_modal_data contains a source image.
+
+ Normalizes legacy/new keys used across omni pipelines:
+ - `image`: single PIL image or list
+ - `img2img`: legacy single-image key
+ - `images`: list or single image
+ """
+ if not isinstance(mm_data, dict):
+ return False
+ if mm_data.get("image") is not None:
+ return True
+ if mm_data.get("img2img") is not None:
+ return True
+ images = mm_data.get("images")
+ return bool(images)
+
+
+def _first_source_image(mm_data: Any) -> Any:
+ """Get first source image from normalized multimodal keys."""
+ if not isinstance(mm_data, dict):
+ return None
+
+ image = mm_data.get("image")
+ if image is not None:
+ if isinstance(image, list):
+ return image[0] if image else None
+ return image
+
+ image = mm_data.get("img2img")
+ if image is not None:
+ if isinstance(image, list):
+ return image[0] if image else None
+ return image
+
+ images = mm_data.get("images")
+ if isinstance(images, list):
+ return images[0] if images else None
+ return images
+
+
+def compute_max_tokens(height: int, width: int, factor: int = 32, is_i2i: bool = False) -> int:
+ """
+ Compute max_new_tokens for GLM-Image AR generation.
+
+ GLM-Image generation differs by mode:
+
+ - text-to-image (t2i): small preview + large target + EOS
+ - image-to-image (i2i): large target + EOS
+
+ Args:
+ height: Target image height in pixels
+ width: Target image width in pixels
+ factor: Downsampling factor (32 for GLM-Image AR output)
+ is_i2i: Whether the request is image-to-image mode
+
+ Returns:
+ Total number of tokens to generate for the specified mode
+ """
+ # Large image tokens (target resolution)
+ token_h = height // factor
+ token_w = width // factor
+ large_tokens = token_h * token_w
+
+ # Small preview tokens (half resolution in each dimension)
+ import math
+
+ ratio = token_h / token_w if token_w > 0 else 1.0
+ small_token_h = max(1, int(math.sqrt(ratio) * (factor // 2)))
+ small_token_w = max(1, int(math.sqrt(1 / ratio) * (factor // 2)))
+ small_tokens = small_token_h * small_token_w
+
+ # Mode-dependent totals:
+ # - t2i: small + large + EOS
+ # - i2i: large + EOS
+ if is_i2i:
+ return large_tokens + 1
+ return small_tokens + large_tokens + 1
+
+
def _upsample_token_ids(token_ids: torch.Tensor, token_h: int, token_w: int) -> torch.Tensor:
"""Upsample token IDs by 2x using nearest neighbor interpolation.
@@ -56,39 +138,49 @@ def _parse_generated_tokens(
large_image_tokens = token_h * token_w
# Calculate small preview image dimensions (used in text-to-image)
- small_token_h = token_h // 2
- small_token_w = token_w // 2
+ ratio = token_h / token_w if token_w > 0 else 1.0
+ small_token_h = max(1, int(math.sqrt(ratio) * (factor // 2)))
+ small_token_w = max(1, int(math.sqrt(1 / ratio) * (factor // 2)))
small_image_tokens = small_token_h * small_token_w
token_tensor = torch.tensor(token_ids, dtype=torch.long)
# Remove EOS token (16385) from the end if present
eos_token_id = 16385
- if len(token_ids) > 0 and token_ids[-1] == eos_token_id:
+ has_terminal_eos = len(token_ids) > 0 and token_ids[-1] == eos_token_id
+ if has_terminal_eos:
token_tensor = token_tensor[:-1]
actual_tokens = len(token_tensor)
- logger.debug(
- f"[_parse_generated_tokens] height={height}, width={width}, "
- f"token_h={token_h}, token_w={token_w}, "
- f"large_image_tokens={large_image_tokens}, small_image_tokens={small_image_tokens}, "
- f"actual_tokens={actual_tokens}"
- )
-
if is_i2i:
- # Image-to-image mode: check if AR generated small+large tokens (like t2i) or just large tokens
- # Some AR models output small+large even in i2i mode
if actual_tokens >= small_image_tokens + large_image_tokens:
- # AR generated full t2i-style output, extract large tokens after small
large_start = small_image_tokens
large_end = large_start + large_image_tokens
prior_token_ids_d32 = token_tensor[large_start:large_end]
actual_h, actual_w = token_h, token_w
- else:
- # AR generated only large tokens (pure i2i output)
+ logger.warning(
+ "[_parse_generated_tokens] i2i detected t2i-style token layout; "
+ "using small-offset extraction: large_start=%s large_end=%s",
+ large_start,
+ large_end,
+ )
+ elif actual_tokens >= large_image_tokens:
prior_token_ids_d32 = token_tensor[:large_image_tokens]
actual_h, actual_w = token_h, token_w
+ logger.info(
+ "[_parse_generated_tokens] i2i using offset-0 extraction: large_tokens=%s",
+ large_image_tokens,
+ )
+ else:
+ logger.warning(
+ "[_parse_generated_tokens] i2i token parse failed: actual_tokens=%s < expected_large_tokens=%s",
+ actual_tokens,
+ large_image_tokens,
+ )
+ raise ValueError(
+ f"i2i token parse failed: actual_tokens={actual_tokens} < expected_large_tokens={large_image_tokens}"
+ )
elif actual_tokens >= small_image_tokens + large_image_tokens:
# Text-to-image: extract large image tokens after small image tokens
large_start = small_image_tokens
@@ -96,43 +188,22 @@ def _parse_generated_tokens(
prior_token_ids_d32 = token_tensor[large_start:large_end]
actual_h, actual_w = token_h, token_w
elif actual_tokens >= large_image_tokens:
- # Image-to-image: large image tokens are at the beginning
- prior_token_ids_d32 = token_tensor[:large_image_tokens]
- actual_h, actual_w = token_h, token_w
+ logger.warning(
+ "[_parse_generated_tokens] t2i token parse failed: got only large tokens without small preview "
+ "(actual_tokens=%s, expected_small_plus_large=%s)",
+ actual_tokens,
+ small_image_tokens + large_image_tokens,
+ )
+ raise ValueError("t2i token parse failed: missing small-preview tokens; refusing low-quality fallback")
else:
- # Insufficient tokens - try to infer the actual grid size
- import math
-
- for scale in [1, 2, 4]:
- test_h = token_h // scale
- test_w = token_w // scale
- test_small_h = test_h // 2
- test_small_w = test_w // 2
- test_large = test_h * test_w
- test_small = test_small_h * test_small_w
-
- if actual_tokens >= test_small + test_large:
- prior_token_ids_d32 = token_tensor[test_small : test_small + test_large]
- actual_h, actual_w = test_h, test_w
- height = test_h * factor
- width = test_w * factor
- logger.warning(f"Adjusted grid to {test_h}x{test_w}, output will be {height}x{width}")
- break
- elif actual_tokens >= test_large:
- prior_token_ids_d32 = token_tensor[:test_large]
- actual_h, actual_w = test_h, test_w
- height = test_h * factor
- width = test_w * factor
- logger.warning(f"Adjusted grid to {test_h}x{test_w}, output will be {height}x{width}")
- break
- else:
- sqrt_tokens = int(math.sqrt(actual_tokens))
- actual_h = actual_w = sqrt_tokens
- usable_tokens = sqrt_tokens * sqrt_tokens
- prior_token_ids_d32 = token_tensor[:usable_tokens]
- height = sqrt_tokens * factor
- width = sqrt_tokens * factor
- logger.error(f"Grid pattern mismatch. Using {sqrt_tokens}x{sqrt_tokens}, output: {height}x{width}")
+ logger.warning(
+ "[_parse_generated_tokens] token parse failed: insufficient tokens "
+ "(actual_tokens=%s, expected=%s, mode=%s)",
+ actual_tokens,
+ large_image_tokens if is_i2i else (small_image_tokens + large_image_tokens),
+ "i2i" if is_i2i else "t2i",
+ )
+ raise ValueError(f"token parse failed: actual_tokens={actual_tokens}, mode={'i2i' if is_i2i else 't2i'}")
# Upsample from 32x to 16x
prior_token_ids = _upsample_token_ids(prior_token_ids_d32, actual_h, actual_w)
@@ -147,6 +218,8 @@ def ar2diffusion(
requires_multimodal_data: bool = False,
) -> list[dict[str, Any]]:
"""Process AR stage outputs to create Diffusion stage inputs."""
+ _t_total = time.perf_counter()
+
if not engine_input_source:
raise ValueError("engine_input_source cannot be empty")
@@ -165,6 +238,7 @@ def ar2diffusion(
prompt = [prompt] if prompt is not None else [{}]
for i, ar_output in enumerate(ar_outputs):
+ _t_req = time.perf_counter()
output = ar_output.outputs[0]
generated_token_ids = output.token_ids
@@ -179,23 +253,76 @@ def ar2diffusion(
else:
original_prompt = {}
- height = original_prompt.get("height", 1024)
- width = original_prompt.get("width", 1024)
+ mm_processor_kwargs = original_prompt.get("mm_processor_kwargs")
+
+ def _coerce_dim(v: Any, default: int) -> int:
+ try:
+ iv = int(v)
+ return iv if iv > 0 else default
+ except (TypeError, ValueError):
+ return default
+
+ # Prefer GLM-Image target size from mm_processor_kwargs (set by serving layer),
+ # then fall back to top-level fields for backward compatibility.
+ height = _coerce_dim(
+ mm_processor_kwargs.get("target_h") if isinstance(mm_processor_kwargs, dict) else None,
+ _coerce_dim(original_prompt.get("height"), 1024),
+ )
+ width = _coerce_dim(
+ mm_processor_kwargs.get("target_w") if isinstance(mm_processor_kwargs, dict) else None,
+ _coerce_dim(original_prompt.get("width"), 1024),
+ )
text_prompt = original_prompt.get("prompt", "")
- # Detect i2i mode first by checking if multimodal_output contains prior_token_image_ids
+ # Detect i2i mode.
+ # Prefer normalized prompt multi_modal_data source-image presence, with
+ # multimodal output as secondary signal.
+ _t_mode = time.perf_counter()
is_i2i = False
+
+ prompt_modalities = original_prompt.get("modalities")
+ if isinstance(prompt_modalities, list) and "img2img" in prompt_modalities:
+ is_i2i = True
+
+ prompt_mm_data = original_prompt.get("multi_modal_data")
+ if _has_source_image(prompt_mm_data):
+ is_i2i = True
+
if hasattr(ar_output, "multimodal_output") and ar_output.multimodal_output:
mm_output = ar_output.multimodal_output
- if isinstance(mm_output, dict) and mm_output.get("prior_token_image_ids") is not None:
- is_i2i = True
+ if isinstance(mm_output, dict):
+ if mm_output.get("prior_token_image_ids") is not None:
+ is_i2i = True
+ _dt_mode = (time.perf_counter() - _t_mode) * 1000
# Parse and upsample prior tokens
- prior_token_ids, pixel_h, pixel_w = _parse_generated_tokens(generated_token_ids, height, width, is_i2i=is_i2i)
+ _t_parse = time.perf_counter()
+ try:
+ prior_token_ids, pixel_h, pixel_w = _parse_generated_tokens(
+ generated_token_ids,
+ height,
+ width,
+ is_i2i=is_i2i,
+ )
+ except ValueError as e:
+ logger.warning(
+ "[ar2diffusion] Request %s: skip due to token parse failure: %s "
+ "(target=%sx%s, mode=%s, raw_tokens=%s, tail=%s)",
+ i,
+ e,
+ height,
+ width,
+ "i2i" if is_i2i else "t2i",
+ len(generated_token_ids),
+ generated_token_ids[-8:] if len(generated_token_ids) >= 8 else generated_token_ids,
+ )
+ continue
+ _dt_parse = (time.perf_counter() - _t_parse) * 1000
# Get prior_token_image_ids from AR model output (for i2i mode)
# This contains VQ-VAE tokens from input image, used for KV cache conditioning
# NOTE: multimodal_output is attached to ar_output (RequestOutput), NOT output (CompletionOutput)
+ _t_prior_img = time.perf_counter()
prior_token_image_ids = None
# Check ar_output (RequestOutput) for multimodal_output - this is the correct location
@@ -234,6 +361,7 @@ def ar2diffusion(
prior_token_image_ids = [raw_prior_image_ids]
elif isinstance(raw_prior_image_ids, list):
prior_token_image_ids = raw_prior_image_ids
+ _dt_prior_img = (time.perf_counter() - _t_prior_img) * 1000
diffusion_input = {
"prompt": text_prompt,
@@ -248,18 +376,38 @@ def ar2diffusion(
if requires_multimodal_data:
mm_data = original_prompt.get("multi_modal_data")
if mm_data:
- pil_image = mm_data.get("image")
- if pil_image is None:
- # Try "images" (plural) as fallback
- images = mm_data.get("images")
- if images:
- pil_image = images[0] if isinstance(images, list) else images
+ pil_image = _first_source_image(mm_data)
diffusion_input["pil_image"] = pil_image
for key in ["seed", "num_inference_steps", "guidance_scale", "negative_prompt"]:
if key in original_prompt:
diffusion_input[key] = original_prompt[key]
+ _dt_req = (time.perf_counter() - _t_req) * 1000
+ logger.info(
+ "[ar2diffusion] req=%d mode=%s target=%dx%d "
+ "raw_tokens=%d prior_tokens=%d prior_image_ids=%s "
+ "timing: mode_detect=%.3fms parse+upsample=%.3fms "
+ "prior_image_ids_extract=%.3fms req_total=%.3fms",
+ i,
+ "i2i" if is_i2i else "t2i",
+ pixel_h,
+ pixel_w,
+ len(generated_token_ids),
+ len(prior_token_ids),
+ "yes" if prior_token_image_ids is not None else "no",
+ _dt_mode,
+ _dt_parse,
+ _dt_prior_img,
+ _dt_req,
+ )
diffusion_inputs.append(diffusion_input)
+ _dt_total = (time.perf_counter() - _t_total) * 1000
+ logger.info(
+ "[ar2diffusion] batch done: %d reqs, total=%.3fms",
+ len(diffusion_inputs),
+ _dt_total,
+ )
+
return diffusion_inputs
diff --git a/vllm_omni/model_executor/stage_input_processors/hunyuan_image3.py b/vllm_omni/model_executor/stage_input_processors/hunyuan_image3.py
new file mode 100644
index 000000000..89a7a28f6
--- /dev/null
+++ b/vllm_omni/model_executor/stage_input_processors/hunyuan_image3.py
@@ -0,0 +1,123 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Stage input processor for HunyuanImage3: AR → Diffusion transition.
+
+In IT2I (image editing) mode:
+ - Stage 0 (AR) receives (image + edit instruction), generates CoT/latent tokens
+ - Stage 1 (DiT) receives the AR output + original image, denoises → edited image
+
+The ar2diffusion function bridges these two stages, following the same
+signature pattern as glm_image.ar2diffusion.
+"""
+
+from typing import Any
+
+import torch
+from vllm.inputs import TextPrompt
+from vllm.logger import init_logger
+
+from vllm_omni.inputs.data import OmniTokensPrompt
+
+logger = init_logger(__name__)
+
+
+def ar2diffusion(
+ stage_list: list[Any],
+ engine_input_source: list[int],
+ prompt: OmniTokensPrompt | TextPrompt | list | None = None,
+ requires_multimodal_data: bool = False,
+) -> list[dict[str, Any]]:
+ """Process AR stage outputs to create Diffusion stage inputs.
+
+ Args:
+ stage_list: List of stage clients (set by orchestrator).
+ engine_input_source: List of source stage IDs (from YAML).
+ prompt: Original user prompt (may contain multimodal data).
+ requires_multimodal_data: Whether to forward multimodal data.
+
+ Returns:
+ List of dicts, each consumable by the HunyuanImage3 diffusion pipeline.
+ """
+ if not engine_input_source:
+ raise ValueError("engine_input_source cannot be empty")
+
+ source_stage_id = engine_input_source[0]
+ if source_stage_id >= len(stage_list):
+ raise IndexError(f"Invalid source stage_id: {source_stage_id}")
+
+ if stage_list[source_stage_id].engine_outputs is None:
+ raise RuntimeError(f"Stage {source_stage_id} has no outputs yet")
+
+ ar_outputs = stage_list[source_stage_id].engine_outputs
+ diffusion_inputs = []
+
+ # Normalize prompt to list
+ if not isinstance(prompt, list):
+ prompt = [prompt] if prompt is not None else [{}]
+
+ for i, ar_output in enumerate(ar_outputs):
+ output = ar_output.outputs[0]
+ generated_token_ids = output.token_ids
+ generated_text = getattr(output, "text", "") or ""
+
+ # Get original prompt info
+ original_prompt = prompt[i] if i < len(prompt) else {}
+ if isinstance(original_prompt, dict):
+ pass
+ elif hasattr(original_prompt, "_asdict"):
+ original_prompt = original_prompt._asdict()
+ elif hasattr(original_prompt, "__dict__"):
+ original_prompt = vars(original_prompt)
+ else:
+ original_prompt = {}
+
+ height = original_prompt.get("height", 1024)
+ width = original_prompt.get("width", 1024)
+ text_prompt = original_prompt.get("prompt", "")
+
+ logger.info(
+ "[ar2diffusion] Request %d: AR generated %d tokens, text length=%d, target size=%dx%d",
+ i,
+ len(generated_token_ids),
+ len(generated_text),
+ height,
+ width,
+ )
+
+ token_tensor = torch.tensor(generated_token_ids, dtype=torch.long)
+
+ diffusion_input: dict[str, Any] = {
+ "prompt": text_prompt,
+ "height": height,
+ "width": width,
+ "extra": {
+ "ar_token_ids": token_tensor,
+ "ar_generated_text": generated_text,
+ },
+ }
+
+ # Forward multimodal data (original image for IT2I conditioning)
+ mm_data = original_prompt.get("multi_modal_data")
+ if mm_data:
+ pil_image = mm_data.get("image")
+ if pil_image is None:
+ images = mm_data.get("images")
+ if images:
+ pil_image = images[0] if isinstance(images, list) else images
+ if pil_image is not None:
+ diffusion_input["pil_image"] = pil_image
+
+ # Forward multimodal output from AR (if any)
+ if hasattr(ar_output, "multimodal_output") and ar_output.multimodal_output:
+ mm_output = ar_output.multimodal_output
+ if isinstance(mm_output, dict):
+ diffusion_input["extra"]["ar_multimodal_output"] = mm_output
+
+ # Forward sampling params
+ for key in ["seed", "num_inference_steps", "guidance_scale", "negative_prompt"]:
+ if key in original_prompt:
+ diffusion_input[key] = original_prompt[key]
+
+ diffusion_inputs.append(diffusion_input)
+
+ return diffusion_inputs
diff --git a/vllm_omni/model_executor/stage_input_processors/mimo_audio.py b/vllm_omni/model_executor/stage_input_processors/mimo_audio.py
index 71573c0e2..39e17b2fe 100644
--- a/vllm_omni/model_executor/stage_input_processors/mimo_audio.py
+++ b/vllm_omni/model_executor/stage_input_processors/mimo_audio.py
@@ -9,6 +9,14 @@ from vllm_omni.model_executor.models.mimo_audio.config_mimo_audio import TALKER_
logger = init_logger(__name__)
+# Maximum tokens supported by the code2wav stage. The flattened talker codec
+# sequence fed to stage-1 must not exceed this, otherwise gpu_input_batch
+# add_request will fail with a broadcast error when copying prompt_token_ids
+# into token_ids_cpu. Keep in sync with the stage-1 ``max_model_len`` in
+# ``vllm_omni/model_executor/stage_configs/mimo_audio.yaml`` and the offline
+# example ``examples/offline_inference/mimo_audio/end2end.py``.
+MAX_CODE2WAV_TOKENS = 18192
+
def prepend_and_flatten_colmajor(x: torch.Tensor, pad_vec: torch.Tensor) -> torch.Tensor:
"""
@@ -235,6 +243,20 @@ def llm2code2wav(
code_final = prepend_and_flatten_colmajor(codec_codes, pad_vec)
code_final = code_final.tolist()
+ # Guard against flattened sequences longer than code2wav's max_model_len.
+ # Without this, add_request raises ``could not broadcast input array
+ # from shape (N,) into shape (max_model_len,)`` and kills the engine
+ # core (see issue #2683). Mirrors the offline end2end.py safeguard.
+ if len(code_final) > MAX_CODE2WAV_TOKENS:
+ request_id = getattr(talker_output, "request_id", f"unknown_{i}")
+ logger.warning(
+ "Request %s: code_final len=%d > MAX_CODE2WAV_TOKENS=%d, truncating.",
+ request_id,
+ len(code_final),
+ MAX_CODE2WAV_TOKENS,
+ )
+ code_final = code_final[:MAX_CODE2WAV_TOKENS]
+
code2wav_inputs.append(
OmniTokensPrompt(
prompt_token_ids=code_final,
diff --git a/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py b/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py
index f4828fdda..699e4b194 100644
--- a/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py
+++ b/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py
@@ -3,6 +3,8 @@
# Copyright 2025 The Qwen team.
"""Stage input processor for Qwen3 Omni MoE: Thinker → Talker transition."""
+import logging
+from dataclasses import dataclass, field
from typing import Any
import torch
@@ -18,6 +20,12 @@ from vllm_omni.model_executor.stage_input_processors.tts_utils import (
extract_speaker_from_request,
)
+logger = logging.getLogger(__name__)
+
+# Pooling output layer keys: "0" = word embedding, "24" = accept_hidden_layer
+_EMBED_LAYER_KEY = "0"
+_HIDDEN_LAYER_KEY = "24"
+
def _compute_talker_prompt_ids_length(info, device: torch.device | str = "cuda") -> int:
im_start_token_id = 151644
@@ -84,6 +92,191 @@ def _validate_stage_inputs(stage_list, engine_input_source):
return stage.engine_outputs
+# =========================
+# PD disaggregation helpers
+# =========================
+
+
+def _get_prefill_stage(stage_list: list[Any], source_stage_id: int) -> Any | None:
+ if source_stage_id <= 0:
+ return None
+ source_stage = stage_list[source_stage_id]
+ if not getattr(source_stage, "is_decode_only", False):
+ return None
+ prev_stage = stage_list[source_stage_id - 1]
+ if getattr(prev_stage, "is_prefill_only", False) and prev_stage.engine_outputs is not None:
+ return prev_stage
+ return None
+
+
+def _merge_pd_embeddings(
+ decode_emb: torch.Tensor,
+ decode_hid: torch.Tensor,
+ prefill_mm: dict[str, Any],
+ device: torch.device,
+ expected_total: int | None = None,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """Merge prefill prompt embeddings with decode generated embeddings.
+
+ In PD mode the prefill engine processes the prompt and the decode engine
+ generates tokens starting from position 1. This function concatenates
+ them, removing the overlapping token(s):
+
+ merged = prefill[:P] + decode[overlap:]
+
+ where overlap = P + D - expected_total.
+ """
+ try:
+ p_emb = prefill_mm[_EMBED_LAYER_KEY].detach().to(device=device, dtype=torch.float)
+ p_hid = prefill_mm[_HIDDEN_LAYER_KEY].detach().to(device=device, dtype=torch.float)
+ except (KeyError, AttributeError, TypeError) as exc:
+ available_keys = list(prefill_mm.keys()) if isinstance(prefill_mm, dict) else type(prefill_mm).__name__
+ logger.error(
+ "_merge_pd_embeddings: failed to extract prefill embeddings (%s). "
+ "Expected keys %r and %r, got: %s. "
+ "Falling back to decode-only embeddings – talker user-segment will be degraded.",
+ exc,
+ _EMBED_LAYER_KEY,
+ _HIDDEN_LAYER_KEY,
+ available_keys,
+ )
+ return decode_emb, decode_hid
+
+ if p_emb.shape[0] == 0 or decode_emb.shape[0] == 0:
+ return decode_emb, decode_hid
+
+ raw_total = p_emb.shape[0] + decode_emb.shape[0]
+ overlap = max(0, raw_total - expected_total) if expected_total is not None else 0
+
+ merged_emb = torch.cat([p_emb, decode_emb[overlap:]], dim=0)
+ merged_hid = torch.cat([p_hid, decode_hid[overlap:]], dim=0)
+ return merged_emb, merged_hid
+
+
+def _get_prefill_multimodal_output(prefill_stage: Any, output_index: int) -> dict[str, Any] | None:
+ """Return multimodal_output dict from the PD prefill stage for a given batch index."""
+ try:
+ prefill_eos = prefill_stage.engine_outputs
+ prefill_eo = prefill_eos[min(output_index, len(prefill_eos) - 1)]
+ return prefill_eo.outputs[0].multimodal_output
+ except Exception:
+ return None
+
+
+def _resolve_tts_token_embedding(
+ key: str,
+ *,
+ thinker_mm: dict[str, Any],
+ prefill_mm: dict[str, Any] | None,
+ device: torch.device,
+) -> torch.Tensor | None:
+ """Return TTS BOS/EOS/PAD embedding tensors for the talker projection path.
+
+ Values are taken from the current thinker (decode) ``multimodal_output``; in
+ PD mode, missing keys may be filled from the paired prefill stage output.
+ """
+ val = thinker_mm.get(key)
+ if val is None and prefill_mm is not None:
+ val = prefill_mm.get(key)
+ return val.detach().to(device=device, dtype=torch.float) if val is not None else None
+
+
+# =========================
+# Streaming input helpers
+# =========================
+
+
+@dataclass
+class _Thinker2TalkerStreamingState:
+ last_prompt_len: int = 0
+ last_output_len: int = 0
+ merged_sequences: list[int] = field(default_factory=list)
+
+
+@dataclass
+class _Qwen3OmniStreamingState:
+ thinker2talker: _Thinker2TalkerStreamingState = field(default_factory=_Thinker2TalkerStreamingState)
+ talker2code2wav_last_seq_len: int = 0
+
+
+def _get_qwen3_streaming_state(
+ request_id: str,
+ streaming_context: Any | None,
+) -> _Qwen3OmniStreamingState:
+ bridge_states = getattr(streaming_context, "bridge_states", None)
+ per_model_state = bridge_states.setdefault("qwen3_omni", {})
+ state = per_model_state.get(request_id)
+ if state is None:
+ state = _Qwen3OmniStreamingState()
+ per_model_state[request_id] = state
+ return state
+
+
+def _get_streaming_talker_tokens(
+ request_id: str,
+ prompt_token_ids: list[int],
+ output_token_ids: list[int],
+ new_prompt_len_snapshot: int | None = None,
+ streaming_context: Any | None = None,
+ *,
+ clear_state: bool = False,
+) -> tuple[list[int], list[int], list[int], list[int]]:
+ """Return streaming token slices and merged token views for thinker->talker.
+ e.g. For the second streaming input request:
+ merged_sequences: [input_prompt 1, output_tokens 1[:-1], input_prompt 2, output_tokens 2]
+ thinker_input_ids: [input_prompt 1, output_tokens 1[:-1], input_prompt 2]
+ Returns:
+ inc_prompt: prompt token delta for this segment.
+ inc_output: output token delta for this segment.
+ merged_sequences: full thinker_sequences to send downstream.
+ thinker_input_ids: full thinker_input_ids paired with merged_sequences.
+ """
+ state = _get_qwen3_streaming_state(request_id, streaming_context).thinker2talker
+ if new_prompt_len_snapshot:
+ prompt_token_ids = prompt_token_ids[:-new_prompt_len_snapshot]
+ cur_prompt_len = len(prompt_token_ids)
+ cur_output_len = len(output_token_ids)
+
+ inc_prompt = prompt_token_ids[state.last_prompt_len :]
+ inc_output = output_token_ids[state.last_output_len :]
+ delta_sequences = inc_prompt + inc_output
+ cached_sequences = state.merged_sequences
+
+ merged_sequences = cached_sequences + delta_sequences
+ thinker_input_ids = cached_sequences + inc_prompt
+
+ # Persist history for next segment. Drop the latest sampled token to keep
+ # thinker_input_ids / thinker_sequences alignment with next-step append.
+ cached_sequences.extend(delta_sequences[:-1])
+
+ state.last_prompt_len = cur_prompt_len
+ state.last_output_len = cur_output_len
+
+ if clear_state:
+ state.last_prompt_len = 0
+ state.last_output_len = 0
+ state.merged_sequences.clear()
+
+ return inc_prompt, inc_output, merged_sequences, thinker_input_ids
+
+
+def _get_streaming_codec_delta_len(
+ cur_seq_len: int,
+ request_id: str,
+ talker_output: Any,
+ streaming_context: Any | None = None,
+) -> int:
+ """Return newly added seq_len for talker->code2wav in streaming mode."""
+ state = _get_qwen3_streaming_state(request_id, streaming_context)
+ prev_seq_len = state.talker2code2wav_last_seq_len
+ seq_len = cur_seq_len - prev_seq_len
+ state.talker2code2wav_last_seq_len = cur_seq_len + 1
+ if bool(getattr(talker_output, "finished", False)):
+ # Final segment: clear history to avoid cross-session carry-over.
+ state.talker2code2wav_last_seq_len = 0
+ return seq_len
+
+
# =========================
# Thinker -> Talker
# =========================
@@ -111,8 +304,8 @@ def thinker2talker_async_chunk(
all_token_ids = _ensure_list(all_token_ids)
prompt_token_ids = _ensure_list(prompt_token_ids)
talker_additional_info = {
- "thinker_prefill_embeddings": pooling_output.get("0").detach().cpu(),
- "thinker_hidden_states": pooling_output.get("24").detach().cpu(),
+ "thinker_prefill_embeddings": pooling_output.get(_EMBED_LAYER_KEY).detach().cpu(),
+ "thinker_hidden_states": pooling_output.get(_HIDDEN_LAYER_KEY).detach().cpu(),
"thinker_sequences": all_token_ids,
"thinker_input_ids": prompt_token_ids,
# Provide thinker-side TTS token embeddings for talker projection
@@ -161,7 +354,7 @@ def thinker2talker_async_chunk(
if output_token_ids:
talker_additional_info["override_keys"] = ["thinker_decode_embeddings", "thinker_output_token_ids"]
- talker_additional_info["thinker_decode_embeddings"] = pooling_output.get("0").detach().cpu()
+ talker_additional_info["thinker_decode_embeddings"] = pooling_output.get(_EMBED_LAYER_KEY).detach().cpu()
talker_additional_info["thinker_output_token_ids"] = output_token_ids
else:
# When prefilling a chunked thinker, thinker_hidden_states needs to be updated.
@@ -176,6 +369,7 @@ def thinker2talker(
engine_input_source: list[int],
prompt: OmniTokensPrompt | TextPrompt | None = None,
requires_multimodal_data: bool = False,
+ streaming_context: Any | None = None,
) -> list[OmniTokensPrompt]:
"""
Process thinker outputs to create talker inputs.
@@ -185,6 +379,9 @@ def thinker2talker(
2. Split hidden states into: prompt embeddings + generated embeddings
3. Package for talker with additional information
+ In PD disaggregation mode, merges prefill-stage prompt embeddings with
+ decode-stage generated embeddings before handing off to the talker.
+
Args:
stage_list: List of stage objects
engine_input_source: Source stage IDs (typically [0] for thinker)
@@ -199,21 +396,66 @@ def thinker2talker(
device = torch.device(current_platform.device_type)
+ # PD disaggregation: look up the preceding prefill stage (if any)
+ source_stage_id = engine_input_source[0]
+ prefill_stage = _get_prefill_stage(stage_list, source_stage_id)
+
# Process each thinker output
for i, thinker_output in enumerate(thinker_outputs):
output = thinker_output.outputs[0]
+ req_id = str(getattr(thinker_output, "request_id", f"idx-{i}"))
+ prompt_token_ids = _ensure_list(thinker_output.prompt_token_ids)
+ output_ids = _ensure_list(output.token_ids)
+ is_streaming_session = bool(getattr(streaming_context, "enabled", False))
+ if is_streaming_session:
+ prompt_token_ids, output_ids, thinker_sequences, thinker_input_ids = _get_streaming_talker_tokens(
+ req_id,
+ prompt_token_ids,
+ output_ids,
+ getattr(streaming_context, "new_prompt_len_snapshot", None),
+ streaming_context,
+ clear_state=bool(getattr(thinker_output, "finished", False)),
+ )
+ else:
+ thinker_sequences = prompt_token_ids + output_ids
+ thinker_input_ids = prompt_token_ids
+ # For streaming input, just send incremental prefill and hidden states tensor to talker
+ # Equally applicable to non-streaming cases.
+ new_seq_length = len(prompt_token_ids + output_ids) - 1
+ thinker_mm = output.multimodal_output
+ # Full thinker embedding sequence for the talker: single thinker engine in the
+ # non-PD path; after optional merge with prefill-side tensors in PD mode.
+ thinker_emb = thinker_mm[_EMBED_LAYER_KEY].detach().to(device=device, dtype=torch.float)[-new_seq_length:]
+ thinker_hid = thinker_mm[_HIDDEN_LAYER_KEY].detach().to(device=device, dtype=torch.float)[-new_seq_length:]
+
+ prefill_mm: dict[str, Any] | None = None
+ if prefill_stage is not None:
+ prefill_mm = _get_prefill_multimodal_output(prefill_stage, i)
+
+ if prefill_mm is not None:
+ expected_total = len(prompt_token_ids) + len(output_ids)
+ try:
+ thinker_emb, thinker_hid = _merge_pd_embeddings(
+ thinker_emb, thinker_hid, prefill_mm, device, expected_total=expected_total
+ )
+ except Exception as exc:
+ logger.warning("[PD] Could not merge prefill embeddings: %s", exc)
info = {
- "thinker_prefill_embeddings": output.multimodal_output["0"].detach().to(device=device, dtype=torch.float),
- "thinker_hidden_states": output.multimodal_output["24"].detach().to(device=device, dtype=torch.float),
- "thinker_sequences": (
- thinker_output.prompt_token_ids + output.token_ids
- ), # the thinker_sequences is the whole ids
- "thinker_input_ids": thinker_output.prompt_token_ids,
+ "thinker_prefill_embeddings": thinker_emb,
+ "thinker_hidden_states": thinker_hid,
+ "thinker_sequences": thinker_sequences, # the thinker_sequences is the whole ids
+ "thinker_input_ids": thinker_input_ids,
# Provide thinker-side TTS token embeddings for talker projection
- "tts_bos_embed": output.multimodal_output["tts_bos_embed"].detach().to(device=device, dtype=torch.float),
- "tts_eos_embed": output.multimodal_output["tts_eos_embed"].detach().to(device=device, dtype=torch.float),
- "tts_pad_embed": output.multimodal_output["tts_pad_embed"].detach().to(device=device, dtype=torch.float),
+ "tts_bos_embed": _resolve_tts_token_embedding(
+ "tts_bos_embed", thinker_mm=thinker_mm, prefill_mm=prefill_mm, device=device
+ ),
+ "tts_eos_embed": _resolve_tts_token_embedding(
+ "tts_eos_embed", thinker_mm=thinker_mm, prefill_mm=prefill_mm, device=device
+ ),
+ "tts_pad_embed": _resolve_tts_token_embedding(
+ "tts_pad_embed", thinker_mm=thinker_mm, prefill_mm=prefill_mm, device=device
+ ),
}
speaker = extract_speaker_from_prompt(prompt, index=i)
if speaker is not None:
@@ -314,6 +556,7 @@ def talker2code2wav(
engine_input_source: list[int],
prompt: OmniTokensPrompt | TextPrompt | None = None,
requires_multimodal_data: bool = False,
+ streaming_context: Any | None = None,
) -> list[OmniTokensPrompt]:
"""
Process talker outputs to create code2wav inputs.
@@ -335,9 +578,14 @@ def talker2code2wav(
talker_outputs = _validate_stage_inputs(stage_list, engine_input_source)
code2wav_inputs: list[OmniTokensPrompt] = []
# Process each talker output
- for talker_output in talker_outputs:
+ for i, talker_output in enumerate(talker_outputs):
output = talker_output.outputs[0]
- seq_len = len(output.token_ids) - 1
+ req_id = str(getattr(talker_output, "request_id", f"idx-{i}"))
+ cur_seq_len = len(output.token_ids) - 1
+ seq_len = cur_seq_len
+ is_streaming_session = bool(getattr(streaming_context, "enabled", False))
+ if is_streaming_session:
+ seq_len = _get_streaming_codec_delta_len(cur_seq_len, req_id, talker_output, streaming_context)
# Extract codec codes from talker output
# Expected shape: [8, seq_len] (8-layer RVQ codes)
codec_codes = (
diff --git a/vllm_omni/model_executor/stage_input_processors/voxcpm.py b/vllm_omni/model_executor/stage_input_processors/voxcpm.py
new file mode 100644
index 000000000..c2fcf521b
--- /dev/null
+++ b/vllm_omni/model_executor/stage_input_processors/voxcpm.py
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+from typing import Any
+
+import torch
+from vllm.inputs import TextPrompt
+
+from vllm_omni.inputs.data import OmniTokensPrompt
+
+_VOXCPM_LATENT_MAGIC = 131071
+
+
+def _serialize_latent_to_codes(latent: Any) -> list[int]:
+ latent_tensor = latent if isinstance(latent, torch.Tensor) else torch.as_tensor(latent)
+ latent_tensor = latent_tensor.detach().cpu().contiguous()
+ if latent_tensor.ndim == 3:
+ if latent_tensor.shape[0] != 1:
+ raise ValueError(f"Expected batch=1 latent tensor, got shape={tuple(latent_tensor.shape)}")
+ latent_tensor = latent_tensor.squeeze(0)
+ if latent_tensor.ndim != 2:
+ raise ValueError(f"Unsupported latent_audio_feat shape for async chunk: {tuple(latent_tensor.shape)}")
+ latent_dim, time_dim = int(latent_tensor.shape[0]), int(latent_tensor.shape[1])
+ packed = latent_tensor.to(torch.bfloat16).contiguous().view(torch.uint16).reshape(-1).to(torch.int32)
+ return [_VOXCPM_LATENT_MAGIC, latent_dim, time_dim, *packed.tolist()]
+
+
+def _coerce_finished_flag(value: Any) -> bool:
+ """Normalize VoxCPM async-chunk finished markers to a Python bool."""
+ if value is None:
+ return False
+ if isinstance(value, torch.Tensor):
+ if value.numel() != 1:
+ raise ValueError(f"finished tensor must be scalar, got shape={tuple(value.shape)}")
+ return bool(value.detach().cpu().item())
+ if isinstance(value, (list, tuple)):
+ if not value:
+ return False
+ if len(value) != 1:
+ raise ValueError(f"finished container must have one element, got len={len(value)}")
+ return _coerce_finished_flag(value[0])
+ return bool(value)
+
+
+def latent2vae(
+ stage_list: list[Any],
+ engine_input_source: list[int],
+ prompt: OmniTokensPrompt | TextPrompt | None = None,
+ requires_multimodal_data: bool = False,
+) -> list[OmniTokensPrompt]:
+ del prompt, requires_multimodal_data
+
+ if not engine_input_source:
+ raise ValueError("engine_input_source cannot be empty")
+
+ source_stage_id = engine_input_source[0]
+ if source_stage_id >= len(stage_list):
+ raise IndexError(f"Invalid stage_id: {source_stage_id}")
+
+ source_outputs = stage_list[source_stage_id].engine_outputs
+ if source_outputs is None:
+ raise RuntimeError(f"Stage {source_stage_id} has no outputs yet")
+
+ vae_inputs: list[OmniTokensPrompt] = []
+ for source_output in source_outputs:
+ output = source_output.outputs[0]
+ multimodal_output = getattr(output, "multimodal_output", None)
+ if not isinstance(multimodal_output, dict) or "latent_audio_feat" not in multimodal_output:
+ raise ValueError(
+ "VoxCPM latent stage output missing 'latent_audio_feat'. "
+ f"request_id={getattr(source_output, 'request_id', None)}"
+ )
+
+ additional_information = {
+ "latent_audio_feat": multimodal_output["latent_audio_feat"],
+ }
+ if "sr" in multimodal_output:
+ additional_information["sample_rate"] = [int(multimodal_output["sr"])]
+
+ vae_inputs.append(
+ OmniTokensPrompt(
+ prompt_token_ids=[0],
+ additional_information=additional_information,
+ multi_modal_data=None,
+ mm_processor_kwargs=None,
+ )
+ )
+
+ return vae_inputs
+
+
+def latent2vae_async_chunk(
+ transfer_manager: Any,
+ pooling_output: dict[str, Any] | None,
+ request: Any,
+ is_finished: bool = False,
+) -> dict[str, Any] | None:
+ """Stage-0 latent → stage-1 VAE under ``async_chunk`` (connector payload)."""
+ # Kept for callback signature compatibility with OmniChunkTransferAdapter.
+ _ = transfer_manager
+ finished_request = _coerce_finished_flag(is_finished)
+ if callable(getattr(request, "is_finished", None)):
+ finished_request = finished_request or _coerce_finished_flag(request.is_finished())
+ if not isinstance(pooling_output, dict):
+ if finished_request:
+ return {
+ "code_predictor_codes": [],
+ "finished": torch.tensor(True, dtype=torch.bool),
+ }
+ return None
+
+ latent = pooling_output.get("latent_audio_feat")
+ if isinstance(latent, torch.Tensor) and latent.numel() == 0:
+ latent = None
+
+ if latent is None:
+ if finished_request:
+ return {
+ "code_predictor_codes": [],
+ "finished": torch.tensor(True, dtype=torch.bool),
+ }
+ return None
+
+ serialized_codes = _serialize_latent_to_codes(latent)
+ out: dict[str, Any] = {
+ "code_predictor_codes": serialized_codes,
+ "finished": torch.tensor(finished_request, dtype=torch.bool),
+ }
+ return out
diff --git a/vllm_omni/outputs.py b/vllm_omni/outputs.py
index 2c2c1d21c..23930f358 100644
--- a/vllm_omni/outputs.py
+++ b/vllm_omni/outputs.py
@@ -100,6 +100,22 @@ class OmniRequestOutput:
# memory usage info
peak_memory_mb: float = 0.0
+ # Error information -- set when the output represents a failed request.
+ error: str | None = None
+
+ @classmethod
+ def from_error(
+ cls,
+ request_id: str,
+ error: str,
+ ) -> "OmniRequestOutput":
+ """Create an error output for a request that failed during generation."""
+ return cls(
+ request_id=request_id,
+ finished=True,
+ error=error,
+ )
+
@classmethod
def from_pipeline(
cls,
diff --git a/vllm_omni/patch.py b/vllm_omni/patch.py
index eafff821a..f6c483a92 100644
--- a/vllm_omni/patch.py
+++ b/vllm_omni/patch.py
@@ -1,6 +1,8 @@
import sys
+from functools import cached_property
from aenum import extend_enum
+from vllm.config import ModelConfig as _OriginalModelConfig
from vllm.inputs import TokensPrompt as _OriginalTokensPrompt
from vllm.model_executor.layers.rotary_embedding import (
MRotaryEmbedding as _OriginalMRotaryEmbedding,
@@ -10,12 +12,63 @@ from vllm.v1.engine import EngineCoreOutputs as _OriginalEngineCoreOutputs
from vllm.v1.engine import EngineCoreRequest as _OriginalEngineCoreRequest
from vllm.v1.request import Request as _OriginalRequest
from vllm.v1.request import RequestStatus
+from vllm.v1.request import StreamingUpdate as _OriginalStreamingUpdate
import vllm_omni.logger # noqa: F401
from vllm_omni.engine import OmniEngineCoreOutput, OmniEngineCoreOutputs, OmniEngineCoreRequest
from vllm_omni.inputs.data import OmniTokensPrompt
from vllm_omni.model_executor.layers.rotary_embedding import OmniMRotaryEmbedding
-from vllm_omni.request import OmniRequest
+from vllm_omni.request import OmniRequest, OmniStreamingUpdate
+
+# =============================================================================
+# Patch ModelConfig.is_mm_prefix_lm to support omni-specific models
+# =============================================================================
+# WHY: HunyuanImage-3.0 requires bidirectional attention for image tokens
+# (cond_token_attn_type: "joint_full" in config.json). vLLM gates this on
+# is_mm_prefix_lm, which checks an internal MM_PREFIX_LM_MODELS list that
+# does not include "hunyuan_image_3_moe" (the upstream HF model_type).
+#
+# WHY NOT model-level: is_mm_prefix_lm is checked in vLLM core (scheduler,
+# attention backend selection) before model code runs — no model-level hook.
+#
+# SCOPE: Only affects model_type in _OMNI_MM_PREFIX_LM_MODELS (currently
+# just "hunyuan_image_3_moe"). All other models fall through to the
+# original vLLM implementation unchanged.
+#
+# FRAGILITY: Relies on is_mm_prefix_lm being a cached_property on
+# ModelConfig. The __dict__ access + __set_name__ dance works around a
+# pydantic dataclass issue in vllm 0.19.0+. If vLLM changes
+# is_mm_prefix_lm to a regular method or removes it, this will break.
+#
+# TODO: Upstream a configurable MM_PREFIX_LM_MODELS or a model_config flag
+# so this patch can be removed.
+_OMNI_MM_PREFIX_LM_MODELS = ("hunyuan_image_3_moe",)
+# Access via __dict__ to avoid triggering cached_property.__get__ which fails
+# with "Cannot use cached_property instance without calling __set_name__" in
+# pydantic dataclasses (vllm 0.19.0+).
+_cp = _OriginalModelConfig.__dict__["is_mm_prefix_lm"]
+_original_is_mm_prefix_lm = _cp.func if hasattr(_cp, "func") else _cp.fget
+
+
+def _patched_is_mm_prefix_lm(self):
+ if _original_is_mm_prefix_lm(self):
+ return True
+ model_type = getattr(self.hf_config, "model_type", "")
+ return model_type in _OMNI_MM_PREFIX_LM_MODELS
+
+
+_patched_cp = cached_property(_patched_is_mm_prefix_lm)
+_patched_cp.__set_name__(_OriginalModelConfig, "is_mm_prefix_lm")
+_OriginalModelConfig.is_mm_prefix_lm = _patched_cp
+
+# Sanity check: verify the patch is active. If vLLM changes the descriptor
+# type or __set_name__ semantics, this will fail loudly at import time
+# rather than silently falling back to unpatched behavior.
+_installed = _OriginalModelConfig.__dict__.get("is_mm_prefix_lm")
+assert _installed is _patched_cp, (
+ "is_mm_prefix_lm patch failed to install — bidirectional attention "
+ "for HunyuanImage3 will not work. Check vLLM ModelConfig changes."
+)
# =============================================================================
# Patch GlmImageTextConfig to expose mrope_section in rope_parameters
@@ -63,5 +116,7 @@ for module_name, module in sys.modules.items():
module.MRotaryEmbedding = OmniMRotaryEmbedding
if hasattr(module, "Request") and module.Request == _OriginalRequest:
module.Request = OmniRequest
+ if hasattr(module, "StreamingUpdate") and module.StreamingUpdate == _OriginalStreamingUpdate:
+ module.StreamingUpdate = OmniStreamingUpdate
if hasattr(module, "EngineCoreRequest") and module.EngineCoreRequest == _OriginalEngineCoreRequest:
module.EngineCoreRequest = OmniEngineCoreRequest
diff --git a/vllm_omni/platforms/interface.py b/vllm_omni/platforms/interface.py
index 8f1e66747..b69731a67 100644
--- a/vllm_omni/platforms/interface.py
+++ b/vllm_omni/platforms/interface.py
@@ -64,7 +64,7 @@ class OmniPlatform(Platform):
@classmethod
def get_diffusion_model_impl_qualname(cls, op_name: str) -> str:
if op_name == "hunyuan_fused_moe":
- return "vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe.HunyuanFusedMoEDefault"
+ return "vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe.HunyuanFusedMoEDefault"
raise NotImplementedError(f"Unsupported diffusion model op: {op_name}")
@classmethod
diff --git a/vllm_omni/platforms/musa/platform.py b/vllm_omni/platforms/musa/platform.py
index fe1ccc6d0..64a70a9be 100644
--- a/vllm_omni/platforms/musa/platform.py
+++ b/vllm_omni/platforms/musa/platform.py
@@ -39,7 +39,7 @@ class MUSAOmniPlatform(OmniPlatform, MUSAPlatformBase):
def get_diffusion_model_impl_qualname(cls, op_name: str) -> str:
# MUSA uses default implementations for diffusion ops
if op_name == "hunyuan_fused_moe":
- return "vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe.HunyuanFusedMoEDefault"
+ return "vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe.HunyuanFusedMoEDefault"
return super().get_diffusion_model_impl_qualname(op_name)
@classmethod
diff --git a/vllm_omni/platforms/npu/stage_configs/hunyuan_image3_moe_dit.yaml b/vllm_omni/platforms/npu/stage_configs/hunyuan_image3_t2i.yaml
similarity index 94%
rename from vllm_omni/platforms/npu/stage_configs/hunyuan_image3_moe_dit.yaml
rename to vllm_omni/platforms/npu/stage_configs/hunyuan_image3_t2i.yaml
index 053e8a8cc..0fd03949d 100644
--- a/vllm_omni/platforms/npu/stage_configs/hunyuan_image3_moe_dit.yaml
+++ b/vllm_omni/platforms/npu/stage_configs/hunyuan_image3_t2i.yaml
@@ -33,6 +33,3 @@ stage_args:
# Runtime defaults
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
diff --git a/vllm_omni/platforms/npu/stage_configs/qwen2_5_omni.yaml b/vllm_omni/platforms/npu/stage_configs/qwen2_5_omni.yaml
deleted file mode 100644
index 8f7af161d..000000000
--- a/vllm_omni/platforms/npu/stage_configs/qwen2_5_omni.yaml
+++ /dev/null
@@ -1,97 +0,0 @@
-# stage config for running qwen2.5-omni for multi-stage omni runtime.
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage
- engine_args:
- model_stage: thinker
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.8
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent
- enable_prefix_caching: false
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
- - stage_id: 1
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.8
- enforce_eager: true # haven't supported talker ACL graph on NPU
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: latent
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen2_5_omni.thinker2talker
- default_sampling_params:
- temperature: 0.9
- top_p: 0.8
- top_k: 40
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
- stop_token_ids: [8294]
- - stage_id: 2
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true
- devices: "2" # Example: use a different NPU than the previous stage; use "0" if single NPU
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- gpu_memory_utilization: 0.15
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio
- engine_input_source: [1]
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
-# Top-level runtime config (concise): default windows and stage edges
-runtime:
- enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
- edges:
- - from: 0 # thinker → talker: trigger only after receiving full input (-1)
- to: 1
- window_size: -1
- - from: 1 # talker → code2wav: trigger only after receiving full input (-1)
- to: 2
- window_size: -1
diff --git a/vllm_omni/platforms/npu/stage_configs/qwen3_omni_moe.yaml b/vllm_omni/platforms/npu/stage_configs/qwen3_omni_moe.yaml
deleted file mode 100644
index 2638c99cd..000000000
--- a/vllm_omni/platforms/npu/stage_configs/qwen3_omni_moe.yaml
+++ /dev/null
@@ -1,99 +0,0 @@
-# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
-# Stage 0: Thinker (multimodal understanding + text generation)
-# Stage 1: Talker (text embeddings → 8-layer RVQ codec codes)
-# Stage 2: Code2Wav (8-layer RVQ codes → audio waveform)
-
-# The following config has been verified on 5x A2/A3-64G NPUs.
-stage_args:
- - stage_id: 0
- runtime:
- devices: "0,1"
- engine_args:
- model_stage: thinker
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.6
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output hidden states for talker
- distributed_executor_backend: "mp"
- enable_prefix_caching: false
- hf_config_name: thinker_config
- tensor_parallel_size: 2
- # profiler_config:
- # profiler: torch
- # torch_profiler_dir: ./perf
- final_output: true
- final_output_type: text
- is_comprehension: true
- default_sampling_params:
- temperature: 0.4
- top_p: 0.9
- top_k: 1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
-
- - stage_id: 1
- runtime:
- devices: "2"
- engine_args:
- model_stage: talker
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.6
- enforce_eager: true # haven't supported talker ACL graph on NPU
- trust_remote_code: true
- engine_output_type: latent # Output codec codes for code2wav
- # tensor_parallel_size: 2
- enable_prefix_caching: false
- distributed_executor_backend: "mp"
- hf_config_name: talker_config
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
- # final_output: true
- # final_output_type: text
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: False
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 2
- runtime:
- devices: "2"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: false
- enable_prefix_caching: false
- engine_output_type: audio # Final output: audio waveform
- gpu_memory_utilization: 0.3
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 1000000
- hf_config_name: thinker_config
- engine_input_source: [1]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
diff --git a/vllm_omni/platforms/npu/stage_configs/qwen3_omni_moe_async_chunk.yaml b/vllm_omni/platforms/npu/stage_configs/qwen3_omni_moe_async_chunk.yaml
deleted file mode 100644
index 9aa20baec..000000000
--- a/vllm_omni/platforms/npu/stage_configs/qwen3_omni_moe_async_chunk.yaml
+++ /dev/null
@@ -1,101 +0,0 @@
-# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
-# Stage 0: Thinker (multimodal understanding + text generation)
-# Stage 1: Talker (text embeddings → 16-layer RVQ codec codes)
-# Stage 2: Code2Wav (16-layer RVQ codes → audio waveform)
-
-# The following config has been verified on 2x H100-80G GPUs.
-async_chunk: true
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "0,1"
- engine_args:
- max_num_seqs: 10
- model_stage: thinker
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.9
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent # Output hidden states for talker
- distributed_executor_backend: "mp"
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- hf_config_name: thinker_config
- tensor_parallel_size: 2
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk
- final_output: true
- final_output_type: text
- is_comprehension: true
- default_sampling_params:
- temperature: 0.4
- top_p: 0.9
- top_k: 1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
-
- - stage_id: 1
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "2"
- engine_args:
- max_num_seqs: 10
- model_stage: talker
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.6
- enforce_eager: true
- trust_remote_code: true
- engine_output_type: latent # Output codec codes for code2wav
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- distributed_executor_backend: "mp"
- hf_config_name: talker_config
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_async_chunk
- engine_input_source: [0]
- # final_output: true
- # final_output_type: text
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: False
- repetition_penalty: 1.0
- stop_token_ids: [2150]
-
- - stage_id: 2
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "2"
- engine_args:
- max_num_seqs: 10
- model_stage: code2wav
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- async_scheduling: false
- enable_prefix_caching: false
- engine_output_type: audio # Final output: audio waveform
- gpu_memory_utilization: 0.3
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 51200 # [TODO] if max_num_batched_tokens < max_num_seqs * 800, there will be precision problem.
- hf_config_name: thinker_config
- engine_input_source: [1]
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
diff --git a/vllm_omni/model_executor/stage_configs/qwen3_tts_no_async_chunk.yaml b/vllm_omni/platforms/npu/stage_configs/voxcpm.yaml
similarity index 59%
rename from vllm_omni/model_executor/stage_configs/qwen3_tts_no_async_chunk.yaml
rename to vllm_omni/platforms/npu/stage_configs/voxcpm.yaml
index 3f412fc4d..dcd1f4051 100644
--- a/vllm_omni/model_executor/stage_configs/qwen3_tts_no_async_chunk.yaml
+++ b/vllm_omni/platforms/npu/stage_configs/voxcpm.yaml
@@ -1,42 +1,47 @@
-async_chunk: false
stage_args:
- stage_id: 0
stage_type: llm
is_comprehension: true
runtime:
devices: "0"
+ max_batch_size: 1
engine_args:
- model_stage: qwen3_tts
- max_num_seqs: 1
- model_arch: Qwen3TTSTalkerForConditionalGeneration
+ dtype: bfloat16
+ model_stage: latent_generator
+ model_arch: VoxCPMForConditionalGeneration
+ # Optional persistent HF-compatible config dir for native VoxCPM models.
+ hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- enforce_eager: false
+ enforce_eager: true
trust_remote_code: true
async_scheduling: false
enable_prefix_caching: false
engine_output_type: latent
- gpu_memory_utilization: 0.3
+ gpu_memory_utilization: 0.75
distributed_executor_backend: "mp"
- max_num_batched_tokens: 512
+ max_num_batched_tokens: 4096
max_model_len: 4096
default_sampling_params:
- temperature: 0.9
- top_k: 50
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
max_tokens: 4096
seed: 42
detokenize: false
- repetition_penalty: 1.05
- stop_token_ids: [2150]
+ repetition_penalty: 1.0
+ final_output: false
- stage_id: 1
stage_type: llm
runtime:
devices: "0"
+ max_batch_size: 1
engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen3TTSCode2Wav
+ dtype: float32
+ model_stage: vae
+ model_arch: VoxCPMForConditionalGeneration
+ hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
enforce_eager: true
@@ -44,21 +49,19 @@ stage_args:
async_scheduling: false
enable_prefix_caching: false
engine_output_type: audio
- gpu_memory_utilization: 0.2
+ gpu_memory_utilization: 0.1
distributed_executor_backend: "mp"
- max_num_batched_tokens: 65536
- max_model_len: 65536
+ max_num_batched_tokens: 8192
+ max_model_len: 4096
engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav
+ custom_process_input_func: vllm_omni.model_executor.stage_input_processors.voxcpm.latent2vae
final_output: true
final_output_type: audio
- tts_args:
- max_instructions_length: 500
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
- max_tokens: 65536
+ max_tokens: 1
seed: 42
detokenize: true
repetition_penalty: 1.0
diff --git a/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs1.yaml b/vllm_omni/platforms/npu/stage_configs/voxcpm_async_chunk.yaml
similarity index 64%
rename from benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs1.yaml
rename to vllm_omni/platforms/npu/stage_configs/voxcpm_async_chunk.yaml
index ca441d286..87843634c 100644
--- a/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs1.yaml
+++ b/vllm_omni/platforms/npu/stage_configs/voxcpm_async_chunk.yaml
@@ -1,5 +1,3 @@
-# Qwen3-TTS batch_size=1 config (streaming with async_chunk)
-# 2-stage pipeline: Talker -> Code2Wav
async_chunk: true
stage_args:
- stage_id: 0
@@ -7,87 +5,85 @@ stage_args:
is_comprehension: true
runtime:
devices: "0"
+ max_batch_size: 1
engine_args:
- max_num_seqs: 1
- model_stage: qwen3_tts
- model_arch: Qwen3TTSTalkerForConditionalGeneration
+ dtype: bfloat16
+ model_stage: latent_generator
+ model_arch: VoxCPMForConditionalGeneration
+ # Optional persistent HF-compatible config dir for native VoxCPM models.
+ hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- enforce_eager: false
+ enforce_eager: true
trust_remote_code: true
- async_scheduling: true
+ async_scheduling: false
enable_prefix_caching: false
engine_output_type: latent
- gpu_memory_utilization: 0.3
+ gpu_memory_utilization: 0.75
distributed_executor_backend: "mp"
- max_num_batched_tokens: 512
+ max_num_batched_tokens: 4096
max_model_len: 4096
- custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
+ custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.voxcpm.latent2vae_async_chunk
output_connectors:
to_stage_1: connector_of_shared_memory
default_sampling_params:
- temperature: 0.9
- top_k: 50
+ temperature: 0.0
+ top_p: 1.0
+ top_k: -1
max_tokens: 4096
seed: 42
detokenize: false
- repetition_penalty: 1.05
- stop_token_ids: [2150]
+ repetition_penalty: 1.0
+ final_output: false
- stage_id: 1
stage_type: llm
runtime:
devices: "0"
+ max_batch_size: 1
engine_args:
- max_num_seqs: 1
- model_stage: code2wav
- model_arch: Qwen3TTSCode2Wav
+ dtype: float32
+ model_stage: vae
+ model_arch: VoxCPMForConditionalGeneration
+ hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
enforce_eager: true
trust_remote_code: true
- async_scheduling: true
+ async_scheduling: false
enable_prefix_caching: false
engine_output_type: audio
- gpu_memory_utilization: 0.3
+ gpu_memory_utilization: 0.1
distributed_executor_backend: "mp"
max_num_batched_tokens: 8192
- max_model_len: 32768
+ max_model_len: 4096
engine_input_source: [0]
- final_output: true
- final_output_type: audio
input_connectors:
from_stage_0: connector_of_shared_memory
- tts_args:
- max_instructions_length: 500
+ final_output: true
+ final_output_type: audio
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
- max_tokens: 65536
+ max_tokens: 1
seed: 42
detokenize: true
repetition_penalty: 1.0
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
shm_threshold_bytes: 65536
- codec_streaming: true
+ codec_streaming: false
connector_get_sleep_s: 0.01
connector_get_max_wait_first_chunk: 3000
connector_get_max_wait: 300
- codec_chunk_frames: 25
- codec_left_context_frames: 25
edges:
- from: 0
to: 1
- window_size: -1
diff --git a/vllm_omni/platforms/npu/worker/npu_model_runner.py b/vllm_omni/platforms/npu/worker/npu_model_runner.py
index 8ef39adfa..310d09311 100644
--- a/vllm_omni/platforms/npu/worker/npu_model_runner.py
+++ b/vllm_omni/platforms/npu/worker/npu_model_runner.py
@@ -417,10 +417,22 @@ class OmniNPUModelRunner(OmniGPUModelRunner, NPUModelRunner):
req_embeds = self.talker_mtp_inputs_embeds.gpu[:num_tokens_padded]
last_talker_hidden = self.last_talker_hidden.gpu[:num_tokens_padded]
text_step = self.text_step.gpu[:num_tokens_padded]
+ subtalker_params = getattr(self.vllm_config.model_config, "subtalker_sampling_params", None)
+ if not isinstance(subtalker_params, dict):
+ subtalker_params = {}
with set_ascend_forward_context(
None, self.vllm_config, aclgraph_runtime_mode=_cudagraph_mode, batch_descriptor=batch_desc
):
- req_embeds, code_predictor_codes = self.talker_mtp(req_input_ids, req_embeds, last_talker_hidden, text_step)
+ req_embeds, code_predictor_codes = self.talker_mtp(
+ req_input_ids,
+ req_embeds,
+ last_talker_hidden,
+ text_step,
+ do_sample=subtalker_params.get("do_sample"),
+ temperature=subtalker_params.get("temperature"),
+ top_k=subtalker_params.get("top_k"),
+ top_p=subtalker_params.get("top_p"),
+ )
# code_predictor_codes stays on GPU here; _update_intermediate_buffer
# keeps it device-resident when the key is in gpu_resident_buffer_keys.
# D2H is deferred to sample_tokens where hidden_states.to("cpu") already
diff --git a/vllm_omni/platforms/rocm/stage_configs/qwen2_5_omni.yaml b/vllm_omni/platforms/rocm/stage_configs/qwen2_5_omni.yaml
deleted file mode 100644
index 35e819354..000000000
--- a/vllm_omni/platforms/rocm/stage_configs/qwen2_5_omni.yaml
+++ /dev/null
@@ -1,102 +0,0 @@
-# stage config for running qwen2.5-omni for multi-stage omni runtime.
-
-# The following config has been verified on 2x H100-80G GPU.
-stage_args:
- - stage_id: 0
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
- engine_args:
- model_stage: thinker
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.8
- enforce_eager: true # Now we only support eager mode
- trust_remote_code: true
- engine_output_type: latent
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
- - stage_id: 1
- runtime:
- process: true
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.8
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- engine_output_type: latent
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen2_5_omni.thinker2talker
- default_sampling_params:
- temperature: 0.9
- top_p: 0.8
- top_k: 40
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
- stop_token_ids: [8294]
-
- - stage_id: 2
- runtime:
- process: true
- devices: "2" # Example: use a different GPU than the previous stage; use "0" if single GPU
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- gpu_memory_utilization: 0.15
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- engine_output_type: audio
- engine_input_source: [1]
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
-# Top-level runtime config (concise): default windows and stage edges
-runtime:
- enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
-
- edges:
- - from: 0 # thinker → talker: trigger only after receiving full input (-1)
- to: 1
- window_size: -1
- - from: 1 # talker → code2wav: trigger only after receiving full input (-1)
- to: 2
- window_size: -1
diff --git a/vllm_omni/platforms/rocm/stage_configs/qwen3_omni_moe.yaml b/vllm_omni/platforms/rocm/stage_configs/qwen3_omni_moe.yaml
deleted file mode 100644
index 0ca150bee..000000000
--- a/vllm_omni/platforms/rocm/stage_configs/qwen3_omni_moe.yaml
+++ /dev/null
@@ -1,97 +0,0 @@
-# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
-# Stage 0: Thinker (multimodal understanding + text generation)
-# Stage 1: Talker (text embeddings → 8-layer RVQ codec codes)
-# Stage 2: Code2Wav (8-layer RVQ codes → audio waveform)
-
-# The following config has been verified on 2x H100-80G GPUs.
-stage_args:
- - stage_id: 0
- runtime:
- devices: "0"
- engine_args:
- model_stage: thinker
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.9
- enforce_eager: true
- trust_remote_code: true
- engine_output_type: latent # Output hidden states for talker
- distributed_executor_backend: "mp"
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- hf_config_name: thinker_config
- tensor_parallel_size: 1
- final_output: true
- final_output_type: text
- is_comprehension: true
- default_sampling_params:
- temperature: 0.4
- top_p: 0.9
- top_k: 1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
-
- - stage_id: 1
- runtime:
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.6
- enforce_eager: true
- trust_remote_code: true
- engine_output_type: latent # Output codec codes for code2wav
- # tensor_parallel_size: 2
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- distributed_executor_backend: "mp"
- hf_config_name: talker_config
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
- # final_output: true
- # final_output_type: text
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: False
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 2
- runtime:
- devices: "1"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio # Final output: audio waveform
- gpu_memory_utilization: 0.1
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 1000000
- hf_config_name: thinker_config
- engine_input_source: [1]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
diff --git a/vllm_omni/platforms/xpu/stage_configs/bagel.yaml b/vllm_omni/platforms/xpu/stage_configs/bagel.yaml
index 0fc8a25ea..7b27f6a44 100644
--- a/vllm_omni/platforms/xpu/stage_configs/bagel.yaml
+++ b/vllm_omni/platforms/xpu/stage_configs/bagel.yaml
@@ -67,10 +67,6 @@ stage_args:
# Runtime edges
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
-
# Distributed connectors configuration (optional)
# More connectors will be supported in the future.
connectors:
@@ -83,4 +79,3 @@ runtime:
edges:
- from: 0
to: 1
- window_size: -1
diff --git a/vllm_omni/platforms/xpu/stage_configs/hunyuan_image_3_moe.yaml b/vllm_omni/platforms/xpu/stage_configs/hunyuan_image3_t2i.yaml
similarity index 93%
rename from vllm_omni/platforms/xpu/stage_configs/hunyuan_image_3_moe.yaml
rename to vllm_omni/platforms/xpu/stage_configs/hunyuan_image3_t2i.yaml
index 8f969ced5..4e0005f82 100644
--- a/vllm_omni/platforms/xpu/stage_configs/hunyuan_image_3_moe.yaml
+++ b/vllm_omni/platforms/xpu/stage_configs/hunyuan_image3_t2i.yaml
@@ -78,6 +78,3 @@ stage_args:
# Top-level runtime config (concise): default windows and stage edges
runtime:
enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
diff --git a/vllm_omni/platforms/xpu/stage_configs/qwen2_5_omni.yaml b/vllm_omni/platforms/xpu/stage_configs/qwen2_5_omni.yaml
deleted file mode 100644
index 7dbedb29a..000000000
--- a/vllm_omni/platforms/xpu/stage_configs/qwen2_5_omni.yaml
+++ /dev/null
@@ -1,101 +0,0 @@
-# stage config for running qwen2.5-omni for multi-stage omni runtime.
-
-# The following config is verified with 2 * Intel Arc Pro B60 XPU.
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true # Run this stage in a separate process
- devices: "0" # Visible devices for this stage
- engine_args:
- model_stage: thinker
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.9 # thinker weight is around 16.74GB for Qwen2.5-Omni-7B
- enforce_eager: false
- trust_remote_code: true
- engine_output_type: latent
- enable_prefix_caching: false
- is_comprehension: true
- final_output: true
- final_output_type: text
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
- - stage_id: 1
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true
- devices: "1"
- engine_args:
- model_stage: talker
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.5 # talker weight is 6.03GB for Qwen2.5-Omni-7B
- enforce_eager: false
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: latent
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen2_5_omni.thinker2talker
- default_sampling_params:
- temperature: 0.9
- top_p: 0.8
- top_k: 40
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
- stop_token_ids: [8294]
-
- - stage_id: 2
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- process: true
- devices: "1"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen2_5OmniForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- gpu_memory_utilization: 0.3 # code2wav weight is around 1.46GB for Qwen2.5-Omni-7B
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio
- engine_input_source: [1]
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
-
-# Top-level runtime config (concise): default windows and stage edges
-runtime:
- enabled: true
- defaults:
- window_size: -1 # Simplified: trigger downstream only after full upstream completion
- max_inflight: 1 # Simplified: process serially within each stage
-
- edges:
- - from: 0 # thinker → talker: trigger only after receiving full input (-1)
- to: 1
- window_size: -1
- - from: 1 # talker → code2wav: trigger only after receiving full input (-1)
- to: 2
- window_size: -1
diff --git a/vllm_omni/platforms/xpu/stage_configs/qwen3_omni_moe.yaml b/vllm_omni/platforms/xpu/stage_configs/qwen3_omni_moe.yaml
deleted file mode 100644
index 49914bebc..000000000
--- a/vllm_omni/platforms/xpu/stage_configs/qwen3_omni_moe.yaml
+++ /dev/null
@@ -1,102 +0,0 @@
-# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
-# Stage 0: Thinker (multimodal understanding + text generation)
-# Stage 1: Talker (text embeddings → 8-layer RVQ codec codes)
-# Stage 2: Code2Wav (8-layer RVQ codes → audio waveform)
-
-# The following config is verified with 8 * Intel Arc Pro B60 XPU.
-stage_args:
- - stage_id: 0
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "0,1,2,3"
- engine_args:
- model_stage: thinker
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.9 # thinker weight is around 61.08GB for Qwen3-Omni-30B-A3B-Instruct
- enforce_eager: true
- trust_remote_code: true
- engine_output_type: latent # Output hidden states for talker
- distributed_executor_backend: "mp"
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- hf_config_name: thinker_config
- tensor_parallel_size: 4
- max_cudagraph_capture_size: 0
- final_output: true
- final_output_type: text
- is_comprehension: true
- default_sampling_params:
- temperature: 0.4
- top_p: 0.9
- top_k: 1
- max_tokens: 2048
- seed: 42
- detokenize: True
- repetition_penalty: 1.05
-
- - stage_id: 1
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "4"
- engine_args:
- model_stage: talker
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: ar
- scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
- gpu_memory_utilization: 0.6 # talker weight is around 8.5GB for Qwen3-Omni-30B-A3B-Instruct
- enforce_eager: true
- trust_remote_code: true
- engine_output_type: latent # Output codec codes for code2wav
- enable_prefix_caching: false
- max_num_batched_tokens: 32768
- distributed_executor_backend: "mp"
- hf_config_name: talker_config
- max_cudagraph_capture_size: 0
- engine_input_source: [0]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
- # final_output: true
- # final_output_type: text
- default_sampling_params:
- temperature: 0.9
- top_k: 50
- max_tokens: 4096
- seed: 42
- detokenize: False
- repetition_penalty: 1.05
- stop_token_ids: [2150]
-
- - stage_id: 2
- stage_type: llm # Use llm stage type for AR stages
- runtime:
- devices: "4"
- engine_args:
- model_stage: code2wav
- max_num_seqs: 1
- model_arch: Qwen3OmniMoeForConditionalGeneration
- worker_type: generation
- scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
- enforce_eager: true
- trust_remote_code: true
- enable_prefix_caching: false
- engine_output_type: audio # Final output: audio waveform
- gpu_memory_utilization: 0.3 # code2wav weight is around 0.4GB for Qwen3-Omni-30B-A3B-Instruct
- distributed_executor_backend: "mp"
- max_num_batched_tokens: 1000000
- hf_config_name: thinker_config
- max_cudagraph_capture_size: 0
- engine_input_source: [1]
- custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
- final_output: true
- final_output_type: audio
- default_sampling_params:
- temperature: 0.0
- top_p: 1.0
- top_k: -1
- max_tokens: 65536
- seed: 42
- detokenize: True
- repetition_penalty: 1.1
diff --git a/vllm_omni/platforms/xpu/stage_configs/voxtral_tts.yaml b/vllm_omni/platforms/xpu/stage_configs/voxtral_tts.yaml
index 10051c1ed..0820ab632 100644
--- a/vllm_omni/platforms/xpu/stage_configs/voxtral_tts.yaml
+++ b/vllm_omni/platforms/xpu/stage_configs/voxtral_tts.yaml
@@ -88,9 +88,6 @@ stage_args:
runtime:
enabled: true
- defaults:
- window_size: -1
- max_inflight: 1
connectors:
connector_of_shared_memory:
@@ -108,4 +105,3 @@ runtime:
edges:
- from: 0
to: 1
- window_size: -1
diff --git a/vllm_omni/profiler/omni_torch_profiler.py b/vllm_omni/profiler/omni_torch_profiler.py
index 2257a2128..023ad5009 100644
--- a/vllm_omni/profiler/omni_torch_profiler.py
+++ b/vllm_omni/profiler/omni_torch_profiler.py
@@ -5,8 +5,8 @@ from __future__ import annotations
import os
import subprocess
-import time
-from typing import Literal
+from datetime import datetime
+from typing import Any, Literal
import torch
from typing_extensions import override
@@ -62,6 +62,13 @@ class OmniTorchProfilerWrapper(WorkerProfiler):
self._trace_path: str | None = None
self._table_path: str | None = None
+ self._activities = activities
+ self._session_dir: str | None = None
+ self._artifact_paths: dict[str, str | None] = {}
+ self._memory_history_enabled = False
+ self._memory_history_backend: str | None = None
+ self._memory_history_module = None
+
if local_rank in (None, 0):
logger.info_once(
"Omni torch profiling enabled. Traces will be saved to: %s",
@@ -72,6 +79,9 @@ class OmniTorchProfilerWrapper(WorkerProfiler):
self.dump_cpu_time_total = "CPU" in activities and len(activities) == 1
self.profiler = self._create_profiler(profiler_config, activities)
+ def _rank(self) -> int:
+ return 0 if self.local_rank is None else self.local_rank
+
def _get_default_activities(self) -> list[TorchProfilerActivity]:
"""Get default activities for this platform.
@@ -106,19 +116,58 @@ class OmniTorchProfilerWrapper(WorkerProfiler):
Can also be a full path (e.g. from diffusion engine).
"""
self._trace_filename = filename
+ self._session_dir = None
+ self._ensure_session_dir()
+
+ def _ensure_session_dir(self) -> str:
+ """Create one timestamped directory for this profiling run."""
+ if self._session_dir is not None:
+ os.makedirs(self._session_dir, exist_ok=True)
+ return self._session_dir
+
+ ts = datetime.now().strftime("%Y%m%d-%H%M%S")
+ base_name = self._trace_filename or self._worker_name
+
+ if os.path.dirname(base_name):
+ parent_dir = os.path.dirname(base_name)
+ leaf_name = os.path.basename(base_name)
+ session_name = f"{ts}_{leaf_name}"
+ self._session_dir = os.path.join(parent_dir, session_name)
+ else:
+ session_name = f"{ts}_{base_name}"
+ self._session_dir = os.path.join(self._trace_dir, session_name)
+
+ os.makedirs(self._session_dir, exist_ok=True)
+ self._artifact_paths["session_dir"] = self._session_dir
+ return self._session_dir
+
+ def _artifact_path(self, stem: str, suffix: str) -> str:
+ """Build artifact path under the session directory."""
+ return os.path.join(
+ self._ensure_session_dir(),
+ f"{stem}_rank{self._rank()}{suffix}",
+ )
+
+ def _write_text_artifact(self, name: str, content: str) -> str:
+ path = self._artifact_path(name, ".txt")
+ with open(path, "w") as f:
+ f.write(content)
+ self._artifact_paths[name] = path
+ return path
+
+ def _has_cuda_like_activity(self) -> bool:
+ return any(a in self._activities for a in ("CUDA", "MUSA"))
+
+ def _get_time_sort_key(self) -> str:
+ if self._has_cuda_like_activity():
+ return "self_cuda_time_total"
+ return "self_cpu_time_total"
def _on_trace_ready(self, prof) -> None:
"""Custom trace handler: export chrome trace with omni naming."""
- rank = self.local_rank
- filename = self._trace_filename or f"{self._worker_name}_{int(time.time())}"
- # If filename already contains a directory, use as-is (e.g. from
- # diffusion engine which builds full path). Otherwise join with trace_dir.
- if os.path.dirname(filename):
- json_file = f"{filename}_rank{rank}.json"
- else:
- json_file = os.path.join(self._trace_dir, f"{filename}_rank{rank}.json")
+ rank = self._rank()
- os.makedirs(os.path.dirname(json_file), exist_ok=True)
+ json_file = self._artifact_path("trace", ".json")
try:
prof.export_chrome_trace(json_file)
@@ -143,18 +192,211 @@ class OmniTorchProfilerWrapper(WorkerProfiler):
else:
self._trace_path = json_file
+ self._artifact_paths["trace"] = self._trace_path
+
except Exception as e:
logger.warning("[Rank %s] Failed to export trace: %s", rank, e)
+ def _try_enable_memory_history(self) -> None:
+ """Enable backend-specific memory history for snapshot analysis."""
+ if not self.profiler_config.torch_profiler_with_memory:
+ return
+
+ backend_name, memory_module = self._resolve_memory_history_backend()
+ if backend_name is None or memory_module is None:
+ return
+
+ record_memory_history = getattr(memory_module, "_record_memory_history", None)
+ if record_memory_history is None:
+ logger.info(
+ "[Rank %s] %s memory history is not supported on this platform",
+ self._rank(),
+ backend_name,
+ )
+ return
+
+ try:
+ record_memory_history(
+ enabled="all",
+ context="all",
+ stacks="python",
+ max_entries=100000,
+ clear_history=True,
+ )
+ self._memory_history_enabled = True
+ self._memory_history_backend = backend_name
+ self._memory_history_module = memory_module
+ logger.info("[Rank %s] %s memory history enabled", self._rank(), backend_name)
+ except Exception as e:
+ logger.warning(
+ "[Rank %s] Failed to enable %s memory history: %s",
+ self._rank(),
+ backend_name,
+ e,
+ )
+
+ def _try_dump_memory_snapshot(self) -> None:
+ """Dump a backend-specific memory snapshot into the session directory."""
+ if not self._memory_history_enabled:
+ return
+
+ try:
+ if self._memory_history_module is None or self._memory_history_backend is None:
+ return
+
+ dump_snapshot = getattr(self._memory_history_module, "_dump_snapshot", None)
+ if dump_snapshot is None:
+ logger.info(
+ "[Rank %s] %s memory snapshot is not supported on this platform",
+ self._rank(),
+ self._memory_history_backend,
+ )
+ return
+
+ snapshot_file = self._artifact_path("memory_snapshot", ".pickle")
+ dump_snapshot(snapshot_file)
+ self._artifact_paths["memory_snapshot"] = snapshot_file
+ logger.info(
+ "[Rank %s] %s memory snapshot dumped to %s",
+ self._rank(),
+ self._memory_history_backend,
+ snapshot_file,
+ )
+ except Exception as e:
+ logger.warning(
+ "[Rank %s] Failed to dump %s memory snapshot: %s",
+ self._rank(),
+ self._memory_history_backend,
+ e,
+ )
+ finally:
+ try:
+ if self._memory_history_module is not None:
+ disable_memory_history = getattr(
+ self._memory_history_module,
+ "_record_memory_history",
+ None,
+ )
+ if disable_memory_history is not None:
+ disable_memory_history(enabled=None)
+ except Exception:
+ pass
+ self._memory_history_enabled = False
+ self._memory_history_backend = None
+ self._memory_history_module = None
+
+ def _resolve_memory_history_backend(self) -> tuple[str | None, Any]:
+ """Resolve the memory backend that supports history/snapshot APIs."""
+ backend_specs = [
+ ("CUDA", self._has_cuda_like_activity(), getattr(torch, "cuda", None)),
+ ("NPU", "NPU" in self._activities, getattr(torch, "npu", None)),
+ ("XPU", "XPU" in self._activities, getattr(torch, "xpu", None)),
+ ("MUSA", "MUSA" in self._activities, getattr(torch, "musa", None)),
+ ]
+
+ for backend_name, enabled, device_module in backend_specs:
+ if not enabled or device_module is None:
+ continue
+
+ is_available = getattr(device_module, "is_available", None)
+ if callable(is_available) and not is_available():
+ continue
+
+ memory_module = getattr(device_module, "memory", None)
+ if memory_module is not None:
+ return backend_name, memory_module
+
+ return None, None
+
+ def _safe_get(self, obj, name: str, default=None):
+ return getattr(obj, name, default)
+
+ def _event_list_to_rows(self, event_list) -> list[dict]:
+ rows = []
+ for evt in event_list:
+ row = {
+ "name": self._safe_get(evt, "key", None) or self._safe_get(evt, "name", None),
+ "count": self._safe_get(evt, "count", None),
+ "device_type": self._safe_get(evt, "device_type", None),
+ "node_id": self._safe_get(evt, "node_id", None),
+ "self_cpu_time_total_us": self._safe_get(evt, "self_cpu_time_total", None),
+ "cpu_time_total_us": self._safe_get(evt, "cpu_time_total", None),
+ "self_cuda_time_total_us": self._safe_get(evt, "self_cuda_time_total", None),
+ "cuda_time_total_us": self._safe_get(evt, "cuda_time_total", None),
+ "self_xpu_time_total_us": self._safe_get(evt, "self_xpu_time_total", None),
+ "xpu_time_total_us": self._safe_get(evt, "xpu_time_total", None),
+ "self_cpu_memory_usage_bytes": self._safe_get(evt, "self_cpu_memory_usage", None),
+ "cpu_memory_usage_bytes": self._safe_get(evt, "cpu_memory_usage", None),
+ "self_cuda_memory_usage_bytes": self._safe_get(evt, "self_cuda_memory_usage", None),
+ "cuda_memory_usage_bytes": self._safe_get(evt, "cuda_memory_usage", None),
+ "self_xpu_memory_usage_bytes": self._safe_get(evt, "self_xpu_memory_usage", None),
+ "xpu_memory_usage_bytes": self._safe_get(evt, "xpu_memory_usage", None),
+ "input_shapes": str(self._safe_get(evt, "input_shapes", None)),
+ "stack": "\n".join(self._safe_get(evt, "stack", []) or []),
+ "overload_name": self._safe_get(evt, "overload_name", None),
+ "is_async": self._safe_get(evt, "is_async", None),
+ "is_legacy": self._safe_get(evt, "is_legacy", None),
+ }
+ rows.append(row)
+ return rows
+
+ def _write_excel_artifact(self, name: str, sheets: dict[str, list[dict]]) -> str:
+ path = self._artifact_path(name, ".xlsx")
+
+ try:
+ import pandas as pd
+ except Exception as e:
+ logger.warning(
+ "[Rank %s] pandas not available, skip Excel export: %s",
+ self._rank(),
+ e,
+ )
+ return path
+
+ with pd.ExcelWriter(path, engine="openpyxl") as writer:
+ for sheet_name, rows in sheets.items():
+ df = pd.DataFrame(rows)
+
+ safe_sheet_name = sheet_name if sheet_name else "Sheet1"
+
+ df.to_excel(
+ writer,
+ sheet_name=safe_sheet_name,
+ index=False,
+ freeze_panes=(1, 0),
+ )
+
+ ws = writer.sheets[safe_sheet_name]
+ ws.auto_filter.ref = ws.dimensions
+
+ for col_cells in ws.columns:
+ max_len = 0
+ col_letter = col_cells[0].column_letter
+ for cell in col_cells[:200]:
+ try:
+ val = "" if cell.value is None else str(cell.value)
+ max_len = max(max_len, len(val))
+ except Exception:
+ pass
+ ws.column_dimensions[col_letter].width = min(max(max_len + 2, 12), 80)
+
+ self._artifact_paths[name] = path
+ return path
+
@override
def _start(self) -> None:
+ self._ensure_session_dir()
+ self._try_enable_memory_history()
self.profiler.start()
@override
def _stop(self) -> None:
"""Stop profiler, export trace via on_trace_ready, and dump table."""
self.profiler.stop()
- self._on_stop_hook()
+ try:
+ self._on_stop_hook()
+ finally:
+ self._try_dump_memory_snapshot()
def _on_stop_hook(self) -> None:
"""Hook called after profiler.stop().
@@ -163,6 +405,68 @@ class OmniTorchProfilerWrapper(WorkerProfiler):
Base implementation handles CUDA time total dump.
"""
rank = self.local_rank
+ sort_key = self._get_time_sort_key()
+
+ excel_sheets: dict[str, list[dict]] = {}
+
+ # 1) Summary op table
+ summary_events = self.profiler.key_averages()
+ excel_sheets["summary"] = self._event_list_to_rows(summary_events)
+
+ # 2) Shape-grouped op table
+ if self.profiler_config.torch_profiler_record_shapes:
+ try:
+ shape_events = self.profiler.key_averages(
+ group_by_input_shape=True,
+ )
+ excel_sheets["by_shape"] = self._event_list_to_rows(shape_events)
+ except Exception as e:
+ logger.warning(
+ "[Rank %s] Failed to export shape-grouped op table: %s",
+ rank,
+ e,
+ )
+
+ # 3) Stack-grouped op table
+ if self.profiler_config.torch_profiler_with_stack:
+ try:
+ stack_events = self.profiler.key_averages(
+ group_by_stack_n=8,
+ )
+ excel_sheets["by_stack"] = self._event_list_to_rows(stack_events)
+ except Exception as e:
+ logger.warning(
+ "[Rank %s] Failed to export stack-grouped op table: %s",
+ rank,
+ e,
+ )
+
+ # 4) Export stack files
+ try:
+ cpu_stack_file = self._artifact_path("stacks_cpu", ".txt")
+ self.profiler.export_stacks(
+ cpu_stack_file,
+ metric="self_cpu_time_total",
+ )
+ self._artifact_paths["stacks_cpu"] = cpu_stack_file
+ except Exception as e:
+ logger.warning("[Rank %s] export_stacks(cpu) failed: %s", rank, e)
+
+ if self._has_cuda_like_activity():
+ try:
+ cuda_stack_file = self._artifact_path("stacks_cuda", ".txt")
+ self.profiler.export_stacks(
+ cuda_stack_file,
+ metric="self_cuda_time_total",
+ )
+ self._artifact_paths["stacks_cuda"] = cuda_stack_file
+ except Exception as e:
+ logger.warning("[Rank %s] export_stacks(cuda) failed: %s", rank, e)
+
+ try:
+ self._table_path = self._write_excel_artifact("ops", excel_sheets)
+ except Exception as e:
+ logger.warning("[Rank %s] Failed to export Excel workbook: %s", rank, e)
if self.profiler_config.torch_profiler_dump_cuda_time_total:
profiler_dir = self.profiler_config.torch_profiler_dir
@@ -190,6 +494,7 @@ class OmniTorchProfilerWrapper(WorkerProfiler):
return {
"trace": self._trace_path,
"table": self._table_path,
+ **self._artifact_paths,
}
diff --git a/vllm_omni/request.py b/vllm_omni/request.py
index 3ec325316..48cbf9b31 100644
--- a/vllm_omni/request.py
+++ b/vllm_omni/request.py
@@ -1,8 +1,11 @@
from collections.abc import Callable
+from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
import torch
+from vllm.multimodal.inputs import MultiModalFeatureSpec
+from vllm.sampling_params import SamplingParams
from vllm.v1.request import Request
if TYPE_CHECKING:
@@ -92,3 +95,34 @@ class OmniRequest(Request):
resumable=request.resumable,
reasoning_ended=request.reasoning_ended,
)
+
+
+@dataclass
+class OmniStreamingUpdate:
+ """
+ Override: add additional information
+ Lightweight data for streaming session continuation.
+
+ Contains only the fields needed to update an existing streaming session
+ with new input data.
+ """
+
+ mm_features: list[MultiModalFeatureSpec] | None
+ prompt_token_ids: list[int] | None
+ max_tokens: int
+ arrival_time: float
+ sampling_params: SamplingParams | None
+ additional_information: AdditionalInformationPayload | None = None
+
+ @classmethod
+ def from_request(cls, request: "Request") -> "OmniStreamingUpdate | None":
+ if not request.resumable:
+ return None
+ return cls(
+ mm_features=request.mm_features,
+ prompt_token_ids=request.prompt_token_ids,
+ max_tokens=request.max_tokens,
+ arrival_time=request.arrival_time,
+ sampling_params=request.sampling_params,
+ additional_information=request.additional_information,
+ )
diff --git a/vllm_omni/transformers_utils/configs/__init__.py b/vllm_omni/transformers_utils/configs/__init__.py
index 5f957c2f6..598ac3a96 100644
--- a/vllm_omni/transformers_utils/configs/__init__.py
+++ b/vllm_omni/transformers_utils/configs/__init__.py
@@ -17,7 +17,13 @@ _CLASS_TO_MODULE: dict[str, str] = {
"FishSpeechConfig": "vllm_omni.transformers_utils.configs.fish_speech",
"FishSpeechSlowARConfig": "vllm_omni.transformers_utils.configs.fish_speech",
"FishSpeechFastARConfig": "vllm_omni.transformers_utils.configs.fish_speech",
+ "VoxCPMConfig": "vllm_omni.transformers_utils.configs.voxcpm",
"VoxCPM2Config": "vllm_omni.transformers_utils.configs.voxcpm2",
+ "BailingMoeV2Config": "vllm_omni.transformers_utils.configs.ming_flash_omni",
+ "BailingMM2Config": "vllm_omni.transformers_utils.configs.ming_flash_omni",
+ "MingFlashOmniConfig": "vllm_omni.transformers_utils.configs.ming_flash_omni",
+ "Qwen3VLMoeVisionConfig": "vllm_omni.transformers_utils.configs.ming_flash_omni",
+ "WhisperEncoderConfig": "vllm_omni.transformers_utils.configs.ming_flash_omni",
}
__all__ = [
@@ -28,7 +34,13 @@ __all__ = [
"FishSpeechConfig",
"FishSpeechSlowARConfig",
"FishSpeechFastARConfig",
+ "VoxCPMConfig",
"VoxCPM2Config",
+ "BailingMoeV2Config",
+ "BailingMM2Config",
+ "MingFlashOmniConfig",
+ "Qwen3VLMoeVisionConfig",
+ "WhisperEncoderConfig",
]
@@ -49,4 +61,6 @@ def __dir__():
# run as soon as `vllm_omni.transformers_utils.configs` is imported.
from vllm_omni.transformers_utils.configs import fish_speech as _fish_speech # noqa: F401, E402
from vllm_omni.transformers_utils.configs import mammoth_moda2 as _mammoth_moda2 # noqa: F401, E402
+from vllm_omni.transformers_utils.configs import ming_flash_omni as _ming_flash_omni # noqa: F401, E402
+from vllm_omni.transformers_utils.configs import voxcpm as _voxcpm # noqa: F401, E402
from vllm_omni.transformers_utils.configs import voxcpm2 as _voxcpm2 # noqa: F401, E402
diff --git a/vllm_omni/transformers_utils/configs/ming_flash_omni.py b/vllm_omni/transformers_utils/configs/ming_flash_omni.py
new file mode 100644
index 000000000..dd13b682d
--- /dev/null
+++ b/vllm_omni/transformers_utils/configs/ming_flash_omni.py
@@ -0,0 +1,302 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 The vLLM-Omni team.
+# Copyright 2024 ANT Group and the HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Configuration for Ming-flash-omni-2.0 model"""
+
+import os
+from typing import Any, ClassVar
+
+from transformers import AutoConfig, AutoTokenizer, PretrainedConfig, PreTrainedTokenizerFast
+from transformers.utils import logging
+
+logger = logging.get_logger(__name__)
+
+
+class BailingMoeV2Config(PretrainedConfig):
+ model_type = "bailing_moe_v2"
+
+ def __init__(
+ self,
+ vocab_size=30592,
+ hidden_size=1024,
+ intermediate_size=None,
+ num_hidden_layers=24,
+ num_attention_heads=16,
+ num_key_value_heads=0,
+ hidden_act="silu",
+ use_qkv_bias=False,
+ use_qk_norm=False,
+ use_bias=True,
+ rms_norm_eps=1e-05,
+ norm_head=False,
+ tie_word_embeddings=False,
+ embedding_dropout=0.0,
+ attention_dropout=0.0,
+ output_dropout=0.0,
+ initializer_range=0.02,
+ max_position_embeddings=16384,
+ rope_theta=10000.0,
+ use_cache=True,
+ use_sliding_window=False,
+ sliding_window=81920,
+ max_window_layers=28,
+ rope_scaling=None,
+ mrope_section=None,
+ pad_token_id=126081,
+ num_experts=16,
+ num_shared_experts=1,
+ num_experts_per_tok=2,
+ n_group=8,
+ topk_group=4,
+ routed_scaling_factor=2.5,
+ moe_intermediate_size=None,
+ first_k_dense_replace=0,
+ head_dim=None,
+ output_router_logits=False,
+ partial_rotary_factor=0.5,
+ router_type="topN",
+ _attn_implementation="flash_attention_2",
+ use_interleaved_frame_timestamp=True,
+ # Multimodal token IDs
+ image_patch_token=157157,
+ video_patch_token=157175,
+ audio_patch_token=157168,
+ image_start_token=157158,
+ video_start_token=157160,
+ audio_start_token=157169,
+ image_end_token=157159,
+ video_end_token=157161,
+ audio_end_token=157170,
+ # Position encoding parameters
+ spatial_merge_size=2,
+ tokens_per_second=2,
+ **kwargs,
+ ):
+ self.num_hidden_layers = num_hidden_layers
+ self.vocab_size = vocab_size
+ self.hidden_size = hidden_size
+ self.intermediate_size = intermediate_size
+ self.num_attention_heads = num_attention_heads
+ self.num_key_value_heads = num_key_value_heads
+ self.hidden_act = hidden_act
+ self.use_qkv_bias = use_qkv_bias
+ self.use_bias = use_bias
+ self.norm_head = norm_head
+ self.rms_norm_eps = rms_norm_eps
+ self.embedding_dropout = embedding_dropout
+ self.attention_dropout = attention_dropout
+ self.output_dropout = output_dropout
+ self.initializer_range = initializer_range
+ self.max_position_embeddings = max_position_embeddings
+ self.rope_theta = rope_theta
+ self.use_cache = use_cache
+ self.use_sliding_window = use_sliding_window
+ self.sliding_window = sliding_window
+ self.max_window_layers = max_window_layers
+ self.head_dim = head_dim or self.hidden_size // self.num_attention_heads
+ self.use_qk_norm = use_qk_norm # arg unused; QK norm is always applied
+
+ # By default, match the value of `mrope_section`
+ # to `apply_3d_rotary_pos_emb` in Ming's repo:
+ # https://github.com/inclusionAI/Ming/blob/3954fcb880ff5e61ff128bcf7f1ec344d46a6fe3/modeling_bailing_moe_v2.py
+ if mrope_section is None:
+ mrope_section = (rope_scaling or {}).get("mrope_section", [8, 12, 12])
+ # Ensure mrope_section is stored inside rope_scaling
+ if rope_scaling is not None and isinstance(rope_scaling, dict):
+ rope_scaling = dict(rope_scaling)
+ rope_scaling.setdefault("mrope_section", mrope_section)
+ self.rope_scaling = rope_scaling
+
+ # NOTE: Expose rope_parameters["mrope_section"]
+ # This refers to the pattern used for GLM-Image in vllm_omni/patch.py
+ rope_type = (rope_scaling or {}).get("type", (rope_scaling or {}).get("rope_type", ""))
+ if rope_type in ("video_rope", "3D", "mrope"):
+ self.rope_parameters = {"mrope_section": mrope_section}
+ else:
+ self.rope_parameters = None
+
+ # MoE configs
+ self.num_experts = num_experts
+ self.num_shared_experts = num_shared_experts
+ self.num_experts_per_tok = num_experts_per_tok
+ self.n_group = n_group
+ self.topk_group = topk_group
+ self.moe_intermediate_size = moe_intermediate_size
+ self.first_k_dense_replace = first_k_dense_replace
+ self.output_router_logits = output_router_logits
+ self.routed_scaling_factor = routed_scaling_factor
+ self.partial_rotary_factor = partial_rotary_factor
+ self.router_type = router_type
+ self.use_interleaved_frame_timestamp = use_interleaved_frame_timestamp
+ self._attn_implementation = _attn_implementation
+
+ # Multimodal token IDs and position encoding
+ self.image_patch_token = image_patch_token
+ self.video_patch_token = video_patch_token
+ self.audio_patch_token = audio_patch_token
+ self.image_start_token = image_start_token
+ self.video_start_token = video_start_token
+ self.audio_start_token = audio_start_token
+ self.image_end_token = image_end_token
+ self.video_end_token = video_end_token
+ self.audio_end_token = audio_end_token
+ self.spatial_merge_size = spatial_merge_size
+ self.tokens_per_second = tokens_per_second
+
+ super().__init__(pad_token_id=pad_token_id, tie_word_embeddings=tie_word_embeddings, **kwargs)
+
+
+class Qwen3VLMoeVisionConfig(PretrainedConfig):
+ """Configuration class for Qwen3 MoE Vision Transformer"""
+
+ model_type = "qwen3_moe_vit"
+
+ def __init__(
+ self,
+ depth=27,
+ hidden_size=1152,
+ hidden_act="gelu_pytorch_tanh",
+ intermediate_size=4304,
+ num_heads=16,
+ in_channels=3,
+ patch_size=16,
+ spatial_merge_size=2,
+ temporal_patch_size=2,
+ out_hidden_size=3584,
+ num_position_embeddings=2304,
+ deepstack_visual_indexes=[8, 16, 24],
+ initializer_range=0.02,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+
+ self.depth = depth
+ self.hidden_size = hidden_size
+ self.hidden_act = hidden_act
+ self.intermediate_size = intermediate_size
+ self.num_heads = num_heads
+ self.in_channels = in_channels
+ self.patch_size = patch_size
+ self.spatial_merge_size = spatial_merge_size
+ self.temporal_patch_size = temporal_patch_size
+ self.out_hidden_size = out_hidden_size
+ self.num_position_embeddings = num_position_embeddings
+ self.initializer_range = initializer_range
+ self.deepstack_visual_indexes = deepstack_visual_indexes
+
+ @classmethod
+ def from_pretrained(cls, pretrained_model_name_or_path: str | os.PathLike, **kwargs) -> "PretrainedConfig":
+ cls._set_token_in_kwargs(kwargs)
+
+ config_dict, kwargs = cls.get_config_dict(pretrained_model_name_or_path, **kwargs)
+
+ if "vision_config" in config_dict:
+ config_dict = config_dict["vision_config"]
+
+ if "model_type" in config_dict and hasattr(cls, "model_type") and config_dict["model_type"] != cls.model_type:
+ logger.warning(
+ f"You are using a model of type {config_dict['model_type']} to instantiate a model of type "
+ f"{cls.model_type}. This is not supported for all configurations of models and can yield errors."
+ )
+
+ return cls.from_dict(config_dict, **kwargs)
+
+
+class WhisperEncoderConfig(PretrainedConfig):
+ """Configuration class for Whisper audio encoder"""
+
+ model_type = "whisper_encoder"
+
+ def __init__(
+ self,
+ whisper_encoder_config: dict[str, Any] | None = None,
+ ds_kernel_size=3,
+ ds_stride=2,
+ norm_query_embeds=True,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.whisper_encoder_config = whisper_encoder_config or {}
+ self.ds_kernel_size = ds_kernel_size
+ self.ds_stride = ds_stride
+ self.norm_query_embeds = norm_query_embeds
+
+
+class BailingMM2Config(PretrainedConfig):
+ model_type = "bailingmm_moe_v2_lite"
+ is_composition = True
+ sub_configs: ClassVar = {"llm_config": AutoConfig}
+
+ def __init__(
+ self,
+ mlp_depth=1,
+ llm_config: BailingMoeV2Config | None = None,
+ vision_config: Qwen3VLMoeVisionConfig | None = None,
+ audio_config: WhisperEncoderConfig | None = None,
+ **kwargs,
+ ):
+ self.audio_config = WhisperEncoderConfig(**audio_config) if isinstance(audio_config, dict) else audio_config
+ self.vision_config = (
+ Qwen3VLMoeVisionConfig(**vision_config) if isinstance(vision_config, dict) else vision_config
+ )
+ self.llm_config = BailingMoeV2Config(**llm_config) if isinstance(llm_config, dict) else llm_config
+ self.mlp_depth = mlp_depth
+ super().__init__(**kwargs)
+
+ def get_text_config(self, decoder: bool = False) -> PretrainedConfig: # noqa: ARG002
+ return self.llm_config
+
+
+class MingFlashOmniConfig(PretrainedConfig):
+ """Configuration class for unified Ming-flash-omni-2.0 model"""
+
+ model_type = "ming_flash_omni"
+ is_composition = True
+ sub_configs: ClassVar = {"thinker_config": BailingMM2Config}
+
+ def __init__(
+ self,
+ thinker_config: BailingMM2Config | None = None,
+ image_gen_config: dict[str, Any] | None = None,
+ talker_config: dict[str, Any] | None = None,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+
+ if isinstance(thinker_config, dict):
+ self.thinker_config = BailingMM2Config(**thinker_config)
+ else:
+ self.thinker_config = thinker_config or BailingMM2Config()
+
+ # Image generation config (for future implementation)
+ self.image_gen_config = image_gen_config
+
+ # Talker config (for future implementation)
+ self.talker_config = talker_config
+
+ def get_text_config(self, decoder: bool = False) -> PretrainedConfig: # noqa: ARG002
+ return self.thinker_config.get_text_config()
+
+
+# Register model_type -> config class for AutoConfig
+AutoConfig.register(BailingMoeV2Config.model_type, BailingMoeV2Config)
+AutoConfig.register(BailingMM2Config.model_type, BailingMM2Config)
+AutoConfig.register(MingFlashOmniConfig.model_type, MingFlashOmniConfig)
+
+# Register tokenizer mapping for composition configs so that
+# AutoTokenizer.from_pretrained can resolve the tokenizer class
+AutoTokenizer.register(BailingMM2Config, fast_tokenizer_class=PreTrainedTokenizerFast)
+AutoTokenizer.register(MingFlashOmniConfig, fast_tokenizer_class=PreTrainedTokenizerFast)
diff --git a/vllm_omni/transformers_utils/configs/voxcpm.py b/vllm_omni/transformers_utils/configs/voxcpm.py
new file mode 100644
index 000000000..026783891
--- /dev/null
+++ b/vllm_omni/transformers_utils/configs/voxcpm.py
@@ -0,0 +1,68 @@
+from transformers import AutoConfig
+from transformers.configuration_utils import PretrainedConfig
+
+
+class VoxCPMConfig(PretrainedConfig):
+ model_type = "voxcpm"
+ keys_to_ignore_at_inference = ["past_key_values"]
+
+ def __init__(
+ self,
+ bos_token_id: int = 1,
+ eos_token_id: int = 2,
+ vocab_size: int = 32000,
+ hidden_size: int = 1024,
+ intermediate_size: int = 4096,
+ max_position_embeddings: int = 4096,
+ num_attention_heads: int = 16,
+ num_hidden_layers: int = 24,
+ num_key_value_heads: int = 16,
+ rms_norm_eps: float = 1e-6,
+ rope_theta: float = 10000.0,
+ rope_scaling: dict | None = None,
+ lm_config: dict | None = None,
+ encoder_config: dict | None = None,
+ dit_config: dict | None = None,
+ audio_vae_config: dict | None = None,
+ patch_size: int = 2,
+ feat_dim: int = 64,
+ residual_lm_num_layers: int = 6,
+ scalar_quantization_latent_dim: int = 256,
+ scalar_quantization_scale: int = 9,
+ max_length: int = 4096,
+ device: str = "cuda",
+ dtype: str = "bfloat16",
+ dit_mean_mode: bool = False,
+ **kwargs,
+ ):
+ super().__init__(bos_token_id=bos_token_id, eos_token_id=eos_token_id, **kwargs)
+ self.vocab_size = vocab_size
+ self.hidden_size = hidden_size
+ self.intermediate_size = intermediate_size
+ self.max_position_embeddings = max_position_embeddings
+ self.num_attention_heads = num_attention_heads
+ self.num_hidden_layers = num_hidden_layers
+ self.num_key_value_heads = num_key_value_heads
+ self.rms_norm_eps = rms_norm_eps
+ self.rope_theta = rope_theta
+ self.rope_scaling = rope_scaling
+
+ self.lm_config = lm_config or {}
+ self.encoder_config = encoder_config or {}
+ self.dit_config = dit_config or {}
+ self.audio_vae_config = audio_vae_config
+
+ self.patch_size = patch_size
+ self.feat_dim = feat_dim
+ self.residual_lm_num_layers = residual_lm_num_layers
+ self.scalar_quantization_latent_dim = scalar_quantization_latent_dim
+ self.scalar_quantization_scale = scalar_quantization_scale
+ self.max_length = max_length
+ self.device = device
+ self.dtype = dtype
+ self.dit_mean_mode = dit_mean_mode
+
+
+AutoConfig.register("voxcpm", VoxCPMConfig)
+
+__all__ = ["VoxCPMConfig"]
diff --git a/vllm_omni/transformers_utils/processors/__init__.py b/vllm_omni/transformers_utils/processors/__init__.py
new file mode 100644
index 000000000..52ca65753
--- /dev/null
+++ b/vllm_omni/transformers_utils/processors/__init__.py
@@ -0,0 +1,12 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 The vLLM-Omni team.
+
+from vllm_omni.transformers_utils.processors.ming import (
+ MingFlashOmniProcessor,
+ MingWhisperFeatureExtractor,
+)
+
+__all__ = [
+ "MingFlashOmniProcessor",
+ "MingWhisperFeatureExtractor",
+]
diff --git a/vllm_omni/transformers_utils/processors/ming.py b/vllm_omni/transformers_utils/processors/ming.py
new file mode 100644
index 000000000..7f414b726
--- /dev/null
+++ b/vllm_omni/transformers_utils/processors/ming.py
@@ -0,0 +1,430 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 The vLLM-Omni team.
+# Copyright 2024 ANT Group and the HuggingFace Inc. team.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import Any
+
+import numpy as np
+import torch
+from transformers import AutoFeatureExtractor, AutoProcessor
+from transformers.feature_extraction_utils import BatchFeature, FeatureExtractionMixin
+from transformers.processing_utils import ProcessorMixin
+from transformers.tokenization_utils_base import PreTokenizedInput, TextInput
+
+DEFAULT_IMAGE_PATCH_TOKEN = ""
+DEFAULT_IM_START_TOKEN = ""
+DEFAULT_IM_END_TOKEN = " "
+DEFAULT_VID_START_TOKEN = ""
+DEFAULT_VID_END_TOKEN = " "
+DEFAULT_FRAME_PATCH_TOKEN = ""
+
+DEFAULT_AUDIO_PATCH_TOKEN = ""
+DEFAULT_AU_START_TOKEN = ""
+DEFAULT_AU_END_TOKEN = " "
+
+# High-level placeholders used in user prompts
+PLACEHOLDER_IMAGE_TOKEN_IN_TEXT = ""
+PLACEHOLDER_VIDEO_TOKEN_IN_TEXT = ""
+PLACEHOLDER_AUDIO_TOKEN_IN_TEXT = ""
+
+# Chat template constants
+USER_PREFIX = "HUMAN "
+ASSISTANT_PREFIX = "ASSISTANT "
+SYSTEM_PROMPT_NOTHINK = "SYSTEM 你是一个友好的AI助手。\n\ndetailed thinking off"
+SYSTEM_PROMPT_THINK = "SYSTEM 你是一个友好的AI助手。\n\ndetailed thinking on"
+
+
+_NORM_FACTOR_FOR_DTYPE = {
+ torch.int8: 2**7,
+ torch.int16: 2**15,
+ torch.int32: 2**31,
+ torch.int64: 2**63,
+ torch.float32: 1,
+ torch.float64: 1,
+}
+
+
+def _normalize_audio_tensor(
+ waveform: torch.Tensor,
+ sample_rate: int,
+ target_sample_rate: int = 16000,
+) -> torch.Tensor:
+ """Normalize waveform to float32, mono, and optionally resample."""
+ norm_factor = _NORM_FACTOR_FOR_DTYPE.get(waveform.dtype, 1)
+ waveform = waveform.to(torch.float32) / norm_factor
+
+ # Remove channel dimension
+ while len(waveform.shape) > 1:
+ waveform = waveform[0]
+
+ # Resample if needed
+ if sample_rate != target_sample_rate:
+ import torchaudio
+
+ resampler = torchaudio.transforms.Resample(orig_freq=sample_rate, new_freq=target_sample_rate)
+ waveform = resampler(waveform.unsqueeze(0)).squeeze(0)
+
+ return waveform
+
+
+class MingWhisperFeatureExtractor(FeatureExtractionMixin):
+ """Whisper log-mel feature extractor for Ming-flash-omni-2.0.
+
+ Produces audio_feats in the time-first packed format.
+
+ Adapted from Ming's WhisperAudioEncoder
+ https://github.com/inclusionAI/Ming/blob/070dc3c13f95d97952ab7d22030df0c9e28a5122/modeling_whisper_encoder.py
+ and HF transformers WhisperFeatureExtractor
+ https://github.com/huggingface/transformers/blob/f842abaca95a7dbf3fc6e16122e7409109bc1431/src/transformers/models/whisper/feature_extraction_whisper.py#L33
+ """
+
+ model_input_names = ["audio_feats", "audio_feats_lengths"]
+
+ def __init__(self, feature_size: int = 128, sampling_rate: int = 16000, **kwargs):
+ # feature_size == n_mels; stored so to_dict() serialises it correctly.
+ self.feature_size = feature_size
+ self.sampling_rate = sampling_rate
+ super().__init__(**kwargs)
+
+ @property
+ def n_mels(self) -> int:
+ return self.feature_size
+
+ def __call__(
+ self,
+ audios: tuple | list,
+ return_tensors: str | None = None,
+ **kwargs,
+ ) -> BatchFeature:
+ """Preprocess audio(s) into Whisper log-mel spectrograms"""
+ import whisper
+
+ if not isinstance(audios, list):
+ audios = [audios]
+
+ audio_feat_list = []
+ for waveform, sr in audios:
+ if isinstance(waveform, np.ndarray):
+ waveform = torch.from_numpy(waveform)
+ waveform = _normalize_audio_tensor(waveform, sr, target_sample_rate=self.sampling_rate)
+ mel = whisper.log_mel_spectrogram(waveform, n_mels=self.n_mels)
+ audio_feat_list.append(mel.transpose(0, 1)) # [T, n_mels]
+
+ audio_feats_lengths = torch.tensor([[feat.shape[0] for feat in audio_feat_list]], dtype=torch.long)
+ # Two stride-2 convolutions in series:
+ # 1. WhisperAudioEncoder conv2: kernel=3, stride=2, padding=1
+ # (conv1 has stride=1 and does not change T)
+ # 2. AudioProjector Conv1d: kernel=3, stride=2, padding=1
+ # Combined: T → ((T-1)//2 + 1 - 1)//2 + 1
+ # See also: AudioProjector.compute_output_length()
+ encoder_feats_lengths = ((audio_feats_lengths - 3 + 2 * 1) // 2 + 1 - 3 + 2 * 1) // 2 + 1
+ audio_feats = torch.cat(audio_feat_list, dim=0).unsqueeze(0) # [1, T_total, n_mels]
+
+ data = {
+ # [1, T_total, n_mels], all audio clips concatenated
+ "audio_feats": audio_feats.numpy(),
+ # [1, n_audios], actual frame count
+ "audio_feats_lengths": audio_feats_lengths.numpy(),
+ # [1, n_audios]
+ "encoder_feats_lengths": encoder_feats_lengths,
+ }
+ return BatchFeature(data=data, tensor_type=return_tensors)
+
+
+class MingFlashOmniProcessor(ProcessorMixin):
+ """Top-level multimodal processor for Ming-flash-omni 2.0.
+
+ Adapted from Ming's BailingMM2Processor
+ https://github.com/inclusionAI/Ming/blob/3954fcb880ff5e61ff128bcf7f1ec344d46a6fe3/processing_bailingmm2.py
+
+ Subprocessors include:
+ - Qwen2VLImageProcessor (image/video)
+ - MingWhisperFeatureExtractor (modified audio processor using Whisper's log-mel spectrogram)
+ """
+
+ attributes = ["image_processor", "audio_processor", "tokenizer"]
+ image_processor_class = "AutoImageProcessor"
+ audio_processor_class = "AutoFeatureExtractor"
+ tokenizer_class = "AutoTokenizer"
+
+ def __init__(
+ self,
+ image_processor=None,
+ audio_processor=None,
+ tokenizer=None,
+ merge_size: int = 2,
+ **kwargs,
+ ):
+ # Enforce that all sub-processors exist
+ # Keep None defaults in the signature for HF ProcessorMixin compatibility
+ if image_processor is None:
+ raise ValueError("MingFlashOmniProcessor requires `image_processor`.")
+ if audio_processor is None:
+ raise ValueError("MingFlashOmniProcessor requires `audio_processor`.")
+ if tokenizer is None:
+ raise ValueError("MingFlashOmniProcessor requires `tokenizer`.")
+
+ self.spatial_merge_size = merge_size
+ self.image_token = PLACEHOLDER_IMAGE_TOKEN_IN_TEXT
+ self.video_token = PLACEHOLDER_VIDEO_TOKEN_IN_TEXT
+ self.audio_token = PLACEHOLDER_AUDIO_TOKEN_IN_TEXT
+ super().__init__(
+ image_processor=image_processor,
+ audio_processor=audio_processor,
+ tokenizer=tokenizer,
+ )
+
+ # Fall back to the tokenizer's own chat_template.
+ if self.chat_template is None:
+ self.chat_template = getattr(tokenizer, "chat_template", None)
+
+ def __call__(
+ self,
+ text: TextInput | PreTokenizedInput | list[TextInput] | list[PreTokenizedInput],
+ images: Any | None = None,
+ videos: Any | None = None,
+ audios: tuple[np.ndarray, int] | list[tuple[np.ndarray, int]] | None = None,
+ **kwargs,
+ ) -> BatchFeature:
+ # This should always be parallel implementations that mirror
+ # `_get_prompt_updates` logic in Ming processor, and vice versa.
+ # Ensure text is a list
+ if isinstance(text, str):
+ text = [text]
+ elif not isinstance(text, list):
+ raise ValueError("text must be a string or list of strings")
+
+ data: dict[str, Any] = {}
+
+ if images is not None:
+ image_outputs = self.image_processor(
+ images=images,
+ videos=None,
+ return_tensors="pt",
+ **kwargs.get("images_kwargs", {}),
+ )
+ data.update(image_outputs)
+ if "image_grid_thw" in image_outputs:
+ text = self._expand_image_tokens(text, image_outputs["image_grid_thw"])
+
+ if videos is not None:
+ video_outputs = self.image_processor(
+ images=None,
+ videos=videos,
+ return_tensors="pt",
+ **kwargs.get("videos_kwargs", {}),
+ )
+ if "pixel_values" in video_outputs:
+ video_outputs["pixel_values_videos"] = video_outputs.pop("pixel_values")
+ if "image_grid_thw" in video_outputs:
+ video_outputs["video_grid_thw"] = video_outputs.pop("image_grid_thw")
+ data.update(video_outputs)
+ if "video_grid_thw" in video_outputs:
+ text = self._expand_video_tokens(text, video_outputs["video_grid_thw"])
+
+ if audios is not None:
+ audio_outputs = self.audio_processor(
+ audios,
+ return_tensors="pt",
+ **kwargs.get("audio_kwargs", {}),
+ )
+ data.update(audio_outputs)
+ if "encoder_feats_lengths" in audio_outputs:
+ text = self._expand_audio_tokens(text, audio_outputs["encoder_feats_lengths"])
+
+ text_outputs = self.tokenizer(
+ text,
+ return_tensors="pt",
+ **kwargs.get("text_kwargs", {}),
+ )
+ data.update(text_outputs)
+ return BatchFeature(data=data)
+
+ def _expand_image_tokens(
+ self,
+ text: list[str],
+ image_grid_thw: torch.Tensor,
+ special_token: str = PLACEHOLDER_IMAGE_TOKEN_IN_TEXT,
+ ) -> list[str]:
+ merge_size = self.spatial_merge_size
+ num_patches_per_image = torch.prod(image_grid_thw, dim=1) // (merge_size**2)
+ prompt_strings = []
+ image_index = 0
+ for sample in text:
+ num_images = sample.count(special_token)
+ if num_images > 0:
+ for i in range(image_index, num_images + image_index):
+ num_patches = int(num_patches_per_image[i].item())
+ img_text = (
+ DEFAULT_IM_START_TOKEN + (DEFAULT_IMAGE_PATCH_TOKEN * num_patches) + DEFAULT_IM_END_TOKEN + "\n"
+ )
+ sample = sample.replace(special_token, img_text, 1)
+ image_index += num_images
+ prompt_strings.append(sample)
+ return prompt_strings
+
+ def _expand_video_tokens(
+ self,
+ text: list[str],
+ video_grid_thw: torch.Tensor,
+ special_token: str = PLACEHOLDER_VIDEO_TOKEN_IN_TEXT,
+ ) -> list[str]:
+ merge_size = self.spatial_merge_size
+ num_patches_per_video = torch.prod(video_grid_thw, dim=1) // (merge_size**2)
+ prompt_strings = []
+ video_index = 0
+ for sample in text:
+ num_videos = sample.count(special_token)
+ if num_videos > 0:
+ for i in range(video_index, num_videos + video_index):
+ num_patches = int(num_patches_per_video[i].item())
+ video_text = (
+ DEFAULT_VID_START_TOKEN
+ + (DEFAULT_FRAME_PATCH_TOKEN * num_patches)
+ + DEFAULT_VID_END_TOKEN
+ + "\n"
+ )
+ sample = sample.replace(special_token, video_text, 1)
+ video_index += num_videos
+ prompt_strings.append(sample)
+ return prompt_strings
+
+ def _expand_audio_tokens(
+ self,
+ text: list[str],
+ encoder_feats_lengths: torch.Tensor,
+ special_token: str = PLACEHOLDER_AUDIO_TOKEN_IN_TEXT,
+ ) -> list[str]:
+ prompt_strings = []
+ for sample, lengths_tensor in zip(text, encoder_feats_lengths):
+ for length in lengths_tensor:
+ num_patches = int(length.item())
+ if num_patches == 0:
+ continue
+ audio_text = DEFAULT_AU_START_TOKEN + (DEFAULT_AUDIO_PATCH_TOKEN * num_patches) + DEFAULT_AU_END_TOKEN
+ if special_token in sample:
+ sample = sample.replace(special_token, audio_text, 1)
+ else:
+ sample = sample + audio_text + "\n"
+ prompt_strings.append(sample)
+ return prompt_strings
+
+ def apply_system_template(
+ self,
+ sys_prompt_exp: str | None = None,
+ use_cot_system_prompt: bool = False,
+ ) -> str:
+ sys_prompt = SYSTEM_PROMPT_THINK if use_cot_system_prompt else SYSTEM_PROMPT_NOTHINK
+ if sys_prompt_exp is not None:
+ sys_prompt = sys_prompt.replace("你是一个友好的AI助手。", sys_prompt_exp)
+ return sys_prompt
+
+ def apply_chat_template(
+ self,
+ conversation: list[dict[str, Any]],
+ sys_prompt_exp: str | None = None,
+ use_cot_system_prompt: bool = False,
+ **kwargs,
+ ) -> str:
+ eos = self.tokenizer.eos_token
+ text = self.apply_system_template(sys_prompt_exp, use_cot_system_prompt) + eos
+
+ for idx, message in enumerate(conversation):
+ assert message["role"] in ["HUMAN", "ASSISTANT"], (
+ f"Invalid role: {message['role']}. Must be 'HUMAN' or 'ASSISTANT'"
+ )
+ if idx == len(conversation) - 1:
+ assert message["role"] == "HUMAN", "Last message must be from HUMAN"
+
+ text += USER_PREFIX if message["role"] == "HUMAN" else ASSISTANT_PREFIX
+
+ content = message["content"]
+ if isinstance(content, str):
+ # text-only
+ text += content
+ elif isinstance(content, list):
+ # structured content with multimodal elements
+ # Count existing placeholders from text items only
+ image_placeholders = 0
+ video_placeholders = 0
+ audio_placeholders = 0
+ for content_item in content:
+ if content_item.get("type", "text") == "text":
+ t = content_item.get("text", "")
+ image_placeholders += t.count(PLACEHOLDER_IMAGE_TOKEN_IN_TEXT)
+ video_placeholders += t.count(PLACEHOLDER_VIDEO_TOKEN_IN_TEXT)
+ audio_placeholders += t.count(PLACEHOLDER_AUDIO_TOKEN_IN_TEXT)
+
+ if video_placeholders > 1:
+ raise ValueError("Video count must be at most 1 per message!")
+
+ # Insert placeholders only for media items not already covered
+ for content_item in content:
+ content_type = content_item.get("type", "text")
+
+ if content_type == "image":
+ image_data = content_item.get("image")
+ if image_data is not None:
+ from PIL import Image as PILImage
+
+ num_images = 1 if isinstance(image_data, (str, PILImage.Image)) else len(image_data)
+ for _ in range(num_images):
+ if image_placeholders > 0:
+ image_placeholders -= 1
+ else:
+ text += PLACEHOLDER_IMAGE_TOKEN_IN_TEXT
+
+ elif content_type == "video":
+ if video_placeholders > 0:
+ video_placeholders -= 1
+ else:
+ text += PLACEHOLDER_VIDEO_TOKEN_IN_TEXT
+ elif content_type == "audio":
+ audio_data = content_item.get("audio")
+ if audio_data is not None:
+ num_audios = 1 if isinstance(audio_data, str) else len(audio_data)
+ for _ in range(num_audios):
+ if audio_placeholders > 0:
+ audio_placeholders -= 1
+ else:
+ text += PLACEHOLDER_AUDIO_TOKEN_IN_TEXT
+
+ elif content_type == "text":
+ text += content_item.get("text", "")
+
+ # Add EOS token after each message except the last one
+ text += eos
+
+ text += ASSISTANT_PREFIX
+ return text
+
+ def batch_decode(self, *args, **kwargs):
+ return self.tokenizer.batch_decode(*args, **kwargs)
+
+ def decode(self, *args, **kwargs):
+ return self.tokenizer.decode(*args, **kwargs)
+
+ @property
+ def model_input_names(self):
+ names = (
+ self.tokenizer.model_input_names
+ + self.image_processor.model_input_names
+ + self.audio_processor.model_input_names
+ )
+ return list(dict.fromkeys(names))
+
+
+AutoFeatureExtractor.register("MingWhisperFeatureExtractor", MingWhisperFeatureExtractor)
+AutoProcessor.register("MingFlashOmniProcessor", MingFlashOmniProcessor)
diff --git a/vllm_omni/utils/audio.py b/vllm_omni/utils/audio.py
index 490737bd5..cc25c1794 100644
--- a/vllm_omni/utils/audio.py
+++ b/vllm_omni/utils/audio.py
@@ -3,6 +3,7 @@
"""Audio utility functions shared across models and entrypoints."""
+import numpy as np
import torch
from torchaudio.functional import melscale_fbanks
@@ -43,3 +44,25 @@ def mel_filter_bank(
mel_scale="slaney",
norm="slaney",
).T
+
+
+def peak_normalize(
+ audio: np.ndarray,
+ db_level: float = -6.0,
+) -> np.ndarray:
+ """Normalize audio so peak amplitude reaches a target dB level.
+
+ Drop-in replacement for ``sox.Transformer().norm(db_level=...)``.
+
+ Args:
+ audio: Input waveform as a 1-D numpy array.
+ db_level: Target peak amplitude in dBFS.
+
+ Returns:
+ Normalized waveform with the same dtype as *audio*.
+ """
+ peak = np.abs(audio).max()
+ if peak == 0:
+ return audio
+ target = 10.0 ** (db_level / 20.0)
+ return audio * (target / peak)
diff --git a/vllm_omni/utils/mm_outputs.py b/vllm_omni/utils/mm_outputs.py
new file mode 100644
index 000000000..66d4e6ffe
--- /dev/null
+++ b/vllm_omni/utils/mm_outputs.py
@@ -0,0 +1,93 @@
+"""Utilities for handling multimodal outputs / building multimodal output
+payloads, most of which are shared by the prefix cache / no prefix cache path.
+"""
+
+import torch
+from vllm.logger import init_logger
+
+logger = init_logger(__name__)
+
+
+def build_mm_cpu(multimodal_outputs: dict) -> dict[str, object]:
+ """Pre-copies multimodal tensor to CPU once (not per-request) to avoid
+ redundant D2H transfers when gpu_resident_buffer_keys keeps them on GPU.
+
+ In the case of prefix caching, the multimodal outputs provided will
+ only contain the passthrough data.
+
+ Args:
+ multimodal_outputs: Multimodal dict mapping strings to objects.
+ """
+ # Pre-copy multimodal tensors to CPU once (not per-request) to avoid
+ # redundant D2H transfers when gpu_resident_buffer_keys keeps them on GPU.
+ mm_cpu: dict[str, object] = {}
+ # Currently there are some cases where this is true at the
+ # moment, which should be fixed.
+ if not isinstance(multimodal_outputs, dict):
+ logger.warning("Multimodal outputs are not a dict and will not be passed")
+
+ if multimodal_outputs:
+ for k, v in multimodal_outputs.items():
+ if isinstance(v, torch.Tensor):
+ mm_cpu[k] = v.detach().to("cpu").contiguous()
+ elif isinstance(v, dict):
+ sub_dict: dict[str, torch.Tensor] = {}
+ for sk, sv in v.items():
+ if isinstance(sv, torch.Tensor):
+ sub_dict[str(sk)] = sv.detach().to("cpu").contiguous()
+ if sub_dict:
+ mm_cpu[k] = sub_dict
+ elif isinstance(v, list) and len(v) > 0:
+ cpu_list = []
+ for elem in v:
+ if isinstance(elem, torch.Tensor):
+ cpu_list.append(elem.detach().to("cpu").contiguous())
+ else:
+ cpu_list.append(elem)
+ mm_cpu[k] = cpu_list
+ elif v is not None:
+ mm_cpu[k] = v
+ return mm_cpu
+
+
+def to_payload_element(
+ element: object, idx: int, start: int, end: int, pass_lists_through: bool = False, seq_len: int | None = None
+):
+ """Build an mm payload element corresponding to one request index
+ from an element containing 0 or more CPU tensors.
+
+ Args:
+ element: The object to be added to the payload.
+ idx: The index of the request.
+ start: The start index corresponding to the request idx.
+ end: The end index corresponding to the request idx.
+ pass_lists_through: bool Whether or not lists should be treated as
+ passthrough data; this should be False in normal cases, but True
+ if we need to avoid splitting nonempty lists prior to calling
+ postprocess, which is the case for prefix cache.
+ seq_len: Optional sequence length (i.e., dim 0 of hidden states).
+ This should be set to None in the prefix caching case, because
+ the condition that would be executed here is the same as the
+ criteria for being added to the multimodal outputs cache.
+ """
+ # Prefix cache won't hit this case because this is the condition
+ # for being a mm_cache_key in the multimodal outputs tensor.
+ if seq_len is not None and isinstance(element, torch.Tensor) and element.shape[0] == seq_len:
+ return element[start:end].contiguous()
+ # Every other case is shared between prefix cache (passthrough data)
+ # and running a model without prefix caching.
+ elif isinstance(element, dict):
+ return {sk: sv[start:end].contiguous() for sk, sv in element.items()}
+ elif isinstance(element, list):
+ # For lists, clone tensors to avoid cross-request aliasing
+ if pass_lists_through:
+ return [elem.clone() if isinstance(elem, torch.Tensor) else elem for elem in element]
+ element = element[idx] if idx < len(element) else element[0]
+ if isinstance(element, torch.Tensor):
+ element = element.clone()
+ return element
+ elif isinstance(element, torch.Tensor):
+ # List-derived tensor payloads are request-invariant; clone to
+ # avoid accidental cross-request aliasing on downstream mutation.
+ return element.clone()
+ return element
diff --git a/vllm_omni/version.py b/vllm_omni/version.py
index e5f0b6b66..296bebc8e 100644
--- a/vllm_omni/version.py
+++ b/vllm_omni/version.py
@@ -5,12 +5,12 @@ The version is automatically generated from git tags via setuptools_scm
and written to _version.py during package build.
"""
+import warnings
+
try:
# Import auto-generated version from _version.py (created by setuptools_scm)
from ._version import __version__, __version_tuple__
except ImportError as e:
- import warnings
-
warnings.warn(
f"Failed to import version from _version.py: {e}\n"
"This typically happens in development mode before building.\n"
@@ -22,4 +22,37 @@ except ImportError as e:
__version__ = "dev"
__version_tuple__ = (0, 0, "dev")
+
+def warn_if_misaligned_vllm_version():
+ """Warn if vLLM and vllm-omni versions don't match (major.minor)."""
+ # Import vllm lazily since import order may be sensitive with current monkeypatching,
+ # but we want to check this before potentially breaking imports run.
+ from vllm import __version__ as vllm_version
+ from vllm import __version_tuple__ as vllm_version_tuple
+
+ omni_ver: tuple[str | int, ...] = __version_tuple__[:2]
+ vllm_ver: tuple[str | int, ...] = vllm_version_tuple[:2]
+ # Skip if either version is dev (0, 0)
+ if omni_ver == (0, 0) or vllm_ver == (0, 0):
+ return
+
+ # Compare major.minor
+ if omni_ver != vllm_ver:
+ warnings.warn(
+ "vLLM and vLLM-Omni appear to have mismatched major/minor versions:\n"
+ f" --> vLLM-Omni version {__version__}\n"
+ f" --> vLLM version {vllm_version}\n"
+ "This will likely cause compatibility issues.",
+ RuntimeWarning,
+ stacklevel=2,
+ )
+
+
__all__ = ["__version__", "__version_tuple__"]
+
+# Run version check automatically when this module is imported
+try:
+ warn_if_misaligned_vllm_version()
+except ModuleNotFoundError:
+ # vLLM not installed (e.g., documentation builds)
+ pass
diff --git a/vllm_omni/worker/base.py b/vllm_omni/worker/base.py
index f7f5dbd1d..8bd9efc89 100644
--- a/vllm_omni/worker/base.py
+++ b/vllm_omni/worker/base.py
@@ -2,15 +2,23 @@
from __future__ import annotations
+import gc
import os
import time
+from contextlib import AbstractContextManager, nullcontext
import torch
from vllm.logger import init_logger
from vllm.utils.mem_utils import format_gib, memory_profiling
from vllm.v1.worker.gpu_worker import Worker as GPUWorker
+from vllm_omni.diffusion.data import (
+ OmniACK,
+ OmniSleepTask,
+ OmniWakeTask,
+)
from vllm_omni.entrypoints.utils import detect_pid_host
+from vllm_omni.platforms import current_omni_platform
from vllm_omni.worker.gpu_memory_utils import (
get_process_gpu_memory,
is_process_scoped_memory_available,
@@ -30,6 +38,13 @@ class OmniGPUWorkerBase(GPUWorker):
for custom trace naming, background gzip, and trace path collection.
"""
+ def load_model(self, *args, **kwargs):
+ with self._maybe_get_memory_pool_context("weights"):
+ res = super().load_model(*args, **kwargs)
+ current_omni_platform.synchronize()
+ gc.collect()
+ return res
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -94,10 +109,8 @@ class OmniGPUWorkerBase(GPUWorker):
"""
if kv_cache_memory_bytes := self.cache_config.kv_cache_memory_bytes:
self.model_runner.profile_run()
- logger.info(
- "Using explicit kv_cache_memory_bytes: %s GiB",
- format_gib(kv_cache_memory_bytes),
- )
+ if current_omni_platform.is_rocm():
+ torch.cuda.synchronize()
return kv_cache_memory_bytes
with memory_profiling(
@@ -154,3 +167,141 @@ class OmniGPUWorkerBase(GPUWorker):
)
return int(self.available_kv_cache_memory_bytes)
+
+ # Provide memory pool context
+ def _maybe_get_memory_pool_context(self, tag: str) -> AbstractContextManager:
+ v1_config_enabled = False
+ if hasattr(self, "vllm_config"):
+ model_cfg = getattr(self.vllm_config, "model_config", None)
+ v1_config_enabled = getattr(model_cfg, "enable_sleep_mode", False)
+
+ is_sleep_enabled = v1_config_enabled or getattr(self.cache_config, "enable_sleep_mode", False)
+ if is_sleep_enabled:
+ current_omni_platform.synchronize()
+ gc.collect()
+ from vllm.device_allocator.cumem import CuMemAllocator
+
+ allocator = CuMemAllocator.get_instance()
+ logger.info(f"[LLM Worker {self.rank}] Sleep Mode ENABLED. Activating CuMem pool for tag: {tag}")
+ return allocator.use_memory_pool(tag=tag)
+ else:
+ logger.warning(f"[LLM Worker {self.rank}] Sleep Mode DISABLED.")
+ return nullcontext()
+
+ def sleep(self, level: int = 1) -> bool:
+ """
+ Put the worker to sleep.
+ Args:
+ level: 1 (Offload weights to CPU), level: 2 (Total Discard).
+ """
+ from vllm.device_allocator.cumem import CuMemAllocator
+
+ mem_before = current_omni_platform.get_current_memory_usage(self.device)
+ offload_tags = ("weights",) if level == 1 else tuple()
+ allocator = CuMemAllocator.get_instance()
+ allocator.sleep(offload_tags=offload_tags)
+ current_omni_platform.empty_cache()
+ current_omni_platform.synchronize()
+ mem_after = current_omni_platform.get_current_memory_usage(self.device)
+ freed = max(0, mem_before - mem_after)
+ remaining_gb = mem_after / 1024**3
+ logger.info(
+ f"[LLM Worker {self.rank}] Level {level} Sleep: Freed "
+ f"{freed / 1024**3:.2f} GiB. {remaining_gb:.2f}GiB memory "
+ "is still in use."
+ )
+ return True
+
+ def wake_up(self, tags: list[str] | None = None) -> bool:
+ "Physical video memory reloading logic"
+ from vllm.device_allocator.cumem import CuMemAllocator
+
+ allocator = CuMemAllocator.get_instance()
+ allocator.wake_up(tags)
+ current_omni_platform.synchronize()
+ logger.info(f"[LLM Worker {self.rank}] Wake-up complete.")
+ return True
+
+ def handle_sleep_task(self, task: OmniSleepTask) -> OmniACK:
+ "Handle deterministic Sleep command from the main process"
+ try:
+ if isinstance(task, dict):
+ task = OmniSleepTask(**task)
+ logger.info(f"[LLM Worker {self.rank}] Handshake Received: Task {task.task_id}, Level {task.level}")
+ if task.level == 2:
+ if hasattr(self.model_runner, "graph_runners"):
+ self.model_runner.graph_runners.clear()
+ logger.info(f"[LLM Worker {self.rank}] LLM CUDA Graphs cleared.")
+ mem_before = current_omni_platform.get_current_memory_usage(self.device)
+ self.sleep(level=task.level)
+ mem_after = current_omni_platform.get_current_memory_usage(self.device)
+ rank_freed = max(0, mem_before - mem_after)
+ if torch.distributed.is_initialized():
+ t_freed = torch.tensor([float(rank_freed)], device=self.device)
+ torch.distributed.all_reduce(t_freed)
+ total_freed = int(t_freed.item())
+ torch.distributed.barrier()
+ else:
+ total_freed = rank_freed
+ if self.rank != 0:
+ return None
+ current_stage_id = getattr(self.vllm_config.model_config, "stage_id", 0)
+ ack = OmniACK(
+ task_id=task.task_id,
+ status="SUCCESS",
+ stage_id=current_stage_id,
+ rank=self.rank,
+ freed_bytes=total_freed,
+ metadata={
+ "source": "omni_platform_audit",
+ "total_freed_gib": f"{total_freed / 1024**3:.2f}",
+ "rank_residual_gib": f"{mem_after / 1024**3:.2f}",
+ },
+ )
+ if hasattr(self, "result_mq") and self.result_mq:
+ self.result_mq.put(ack)
+ logger.info(f"[LLM Worker {self.rank}] ACK emitted for Task {task.task_id}")
+ return ack
+ except Exception as e:
+ logger.error(f"[LLM Worker {self.rank}] Sleep Task Failed: {e}", exc_info=True)
+ if torch.distributed.is_initialized():
+ try:
+ torch.distributed.barrier()
+ except Exception:
+ pass
+ return OmniACK(task_id=task.task_id, status="ERROR", error_msg=str(e))
+
+ def handle_wake_task(self, task: OmniWakeTask) -> OmniACK:
+ "Handle deterministic Wakeup command from the main process"
+ try:
+ if isinstance(task, dict):
+ task = OmniWakeTask(**task)
+ self.wake_up(tags=task.tags)
+ if torch.distributed.is_initialized():
+ torch.distributed.barrier()
+ gc.collect()
+ current_omni_platform.synchronize()
+ usage_now = current_omni_platform.get_current_memory_usage(self.device)
+ if self.rank != 0:
+ return None
+ current_stage_id = getattr(self.vllm_config.model_config, "stage_id", 0)
+ ack = OmniACK(
+ task_id=task.task_id,
+ status="SUCCESS",
+ stage_id=current_stage_id,
+ rank=self.rank,
+ metadata={"state": "WARM", "current_vram_gib": f"{usage_now / 1024**3:.2f}"},
+ )
+ if hasattr(self, "result_mq") and self.result_mq:
+ self.result_mq.put(ack)
+ logger.info(f"[LLM Worker {self.rank}] Wake-up ACK emitted.")
+ return ack
+ except Exception as e:
+ logger.error(f"[LLM Worker {self.rank}] Wake-up Failed: {e}", exc_info=True)
+ if torch.distributed.is_initialized():
+ try:
+ torch.distributed.barrier()
+ except Exception:
+ pass
+ tid = task.task_id if hasattr(task, "task_id") else "unknown"
+ return OmniACK(task_id=tid, status="ERROR", error_msg=str(e))
diff --git a/vllm_omni/worker/gpu_ar_model_runner.py b/vllm_omni/worker/gpu_ar_model_runner.py
index 62a0c8571..f37b2224e 100644
--- a/vllm_omni/worker/gpu_ar_model_runner.py
+++ b/vllm_omni/worker/gpu_ar_model_runner.py
@@ -39,6 +39,7 @@ from vllm.v1.worker.utils import is_residual_scattered_for_sp
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import OmniKVTransferManager
from vllm_omni.outputs import OmniModelRunnerOutput
+from vllm_omni.utils.mm_outputs import build_mm_cpu, to_payload_element
from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner
from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorModelRunnerMixin
@@ -201,6 +202,63 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
finally:
set_cudagraph_capturing_enabled(False)
+ def _maybe_update_prefix_cache(
+ self,
+ hidden_states: torch.Tensor,
+ multimodal_outputs: dict,
+ num_tokens_unpadded: int,
+ num_tokens_padded: int,
+ ):
+ """If prefix caching is enabled and it's the last pipeline parallelism rank,
+ retrieve the hidden states & multimodal outputs from the prefix cache based
+ on our batch slot mappings.
+ """
+ # Cache hidden states if we've enabled hidden state prefix caching
+ # unless this isn't the last pipeline parallelism rank.
+ if self.omni_prefix_cache is not None and get_pp_group().is_last_rank:
+ # If this happens, it generally means the model is not following the correct
+ # interface yet and is therefore currently not compatible with prefix cache.
+ if multimodal_outputs is not None and not isinstance(multimodal_outputs, dict):
+ logger.warning_once(
+ "prefix caching expects mm outputs to be a dict, but got %s",
+ type(multimodal_outputs),
+ )
+
+ self.omni_prefix_cache.update_omni_tensor_prefix_cache(
+ hidden_states=hidden_states,
+ multimodal_outputs=multimodal_outputs,
+ num_tokens_unpadded=num_tokens_unpadded,
+ slot_mapping=self.input_batch.block_table[0].slot_mapping.cpu,
+ num_tokens_padded=num_tokens_padded,
+ )
+
+ def _maybe_get_combined_prefix_cache_tensors(
+ self,
+ hidden_states: torch.Tensor,
+ multimodal_outputs: dict,
+ num_scheduled_tokens: dict[str, int],
+ ) -> tuple[dict[str, torch.Tensor] | None, dict | None]:
+ """If prefix caching is enabled, extract the merged hidden states and multimodal outputs for
+ all requests in the batch (including those that aren't a hit on Prefix cache).
+ """
+ # Prior to applying the post-processing func, extract
+ # the prefix cached hidden states and multimodal states.
+ combined_hidden_states, combined_multimodal_outputs = None, None
+ if self.omni_prefix_cache is not None:
+ combined_hidden_states = self.omni_prefix_cache.get_merged_hidden_states(
+ query_start_loc=self.query_start_loc.cpu,
+ input_batch=self.input_batch,
+ hidden_states=hidden_states,
+ num_scheduled_tokens=num_scheduled_tokens,
+ )
+ combined_multimodal_outputs = self.omni_prefix_cache.get_merged_multimodal_states(
+ query_start_loc=self.query_start_loc.cpu,
+ input_batch=self.input_batch,
+ multimodal_outputs=multimodal_outputs,
+ num_scheduled_tokens=num_scheduled_tokens,
+ )
+ return combined_hidden_states, combined_multimodal_outputs
+
@torch.inference_mode()
def execute_model(
self,
@@ -476,6 +534,15 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
hidden_states, multimodal_outputs = self.extract_multimodal_outputs(model_output)
+ # Cache hidden states & multimodal outputs if we've enabled hidden state
+ # prefix caching unless this isn't the last pipeline parallelism rank.
+ self._maybe_update_prefix_cache(
+ hidden_states=hidden_states,
+ multimodal_outputs=multimodal_outputs,
+ num_tokens_unpadded=num_tokens_unpadded,
+ num_tokens_padded=num_tokens_padded,
+ )
+
if not self.broadcast_pp_output:
# Common case.
if not get_pp_group().is_last_rank:
@@ -589,6 +656,23 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
return super()._sample(logits, spec_decode_metadata)
+ @staticmethod
+ def _resolve_req_hidden_states(
+ hidden_states_cpu: torch.Tensor,
+ combined_hidden_states: dict[str, torch.Tensor] | None,
+ rid: str,
+ start: int,
+ end: int,
+ ):
+ if combined_hidden_states is not None:
+ # We always have all request IDs for prefix cache, even for
+ # partial cache misses, so this should never happen.
+ if rid not in combined_hidden_states:
+ raise RuntimeError("Request IDs in the batch are missing from the merged states!")
+ return combined_hidden_states[rid]
+ # Prefix caching is disabled
+ return hidden_states_cpu[start:end]
+
@torch.inference_mode()
def sample_tokens(
self,
@@ -597,6 +681,13 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
kv_extracted_req_ids = getattr(self, "kv_extracted_req_ids", None)
self.kv_extracted_req_ids = None
+ # Used for prefix cache
+ combined_hidden_states = None
+ combined_multimodal_outputs = None
+ # Used when we don't use prefix cache; prefix cache builds the payloads
+ # internally since it already needs to do this for the cached tensors
+ mm_cpu = {}
+
if self.execute_model_state is None:
kv_connector_output = self.kv_connector_output
self.kv_connector_output = None
@@ -628,6 +719,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
slot_mappings, # OMNI: unpack slot_mappings for drafter
) = self.execute_model_state
self.execute_model_state = None
+ seq_len = hidden_states.shape[0]
# Apply structured output bitmasks if present.
if grammar_output is not None:
@@ -749,37 +841,29 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
dtype=np.int32,
)
- self._process_additional_information_updates(
- hidden_states, multimodal_outputs, num_scheduled_tokens_np, scheduler_output
- )
+ # Prior to applying the post-processing func, extract
+ # the prefix cached hidden states and multimodal states.
+ if self.omni_prefix_cache is not None:
+ (
+ combined_hidden_states,
+ combined_multimodal_outputs,
+ ) = self._maybe_get_combined_prefix_cache_tensors(
+ hidden_states,
+ multimodal_outputs,
+ scheduler_output.num_scheduled_tokens,
+ )
+ # Otherwise we don't have the mm CPU data yet, so we still need to build it
+ if self.omni_prefix_cache is None:
+ mm_cpu = build_mm_cpu(multimodal_outputs)
- # Pre-copy multimodal tensors to CPU once (not per-request) to avoid
- # redundant D2H transfers when gpu_resident_buffer_keys keeps them on GPU.
- mm_cpu: dict[str, object] = {}
- if isinstance(multimodal_outputs, dict) and multimodal_outputs:
- for k, v in multimodal_outputs.items():
- try:
- if isinstance(v, torch.Tensor) and v.shape[0] == hidden_states_cpu.shape[0]:
- mm_cpu[k] = v.detach().to("cpu").contiguous()
- elif isinstance(v, dict):
- sub_dict: dict[str, torch.Tensor] = {}
- for sk, sv in v.items():
- if isinstance(sv, torch.Tensor) and sv.shape[0] == hidden_states_cpu.shape[0]:
- sub_dict[str(sk)] = sv.detach().to("cpu").contiguous()
- if sub_dict:
- mm_cpu[k] = sub_dict
- elif isinstance(v, list):
- if len(v) == 0:
- continue
- cpu_list = []
- for elem in v:
- if isinstance(elem, torch.Tensor):
- cpu_list.append(elem.detach().to("cpu").contiguous())
- else:
- cpu_list.append(elem)
- mm_cpu[k] = cpu_list
- except Exception as e:
- logger.error(f"Error in merge multimodal outputs: {e}")
+ self._process_additional_information_updates(
+ hidden_states,
+ multimodal_outputs,
+ num_scheduled_tokens_np,
+ scheduler_output,
+ combined_hidden_states,
+ combined_multimodal_outputs,
+ )
pooler_output: list[dict[str, object]] = []
for rid in req_ids_output_copy:
@@ -787,29 +871,43 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
start = int(self.query_start_loc.cpu[idx])
sched = int(num_scheduled_tokens_np[idx])
end = start + sched
- hidden_slice = hidden_states_cpu[start:end]
- payload: dict[str, object] = {"hidden": hidden_slice}
- if mm_cpu:
- mm_payload: dict[str, object] = {}
- for k, v in mm_cpu.items():
- if isinstance(v, torch.Tensor) and v.shape[0] == hidden_states_cpu.shape[0]:
- mm_payload[k] = v[start:end].contiguous()
- elif isinstance(v, dict):
- mm_payload[k] = {sk: sv[start:end].contiguous() for sk, sv in v.items()}
- elif isinstance(v, list):
- element = v[idx] if idx < len(v) else v[0]
- if element is not None:
- if isinstance(element, torch.Tensor):
- element = element.clone()
- mm_payload[k] = element
- # Skip None elements: msgspec cannot serialize None
- # in dict[str, torch.Tensor] typed fields.
- elif isinstance(v, torch.Tensor):
- # List-derived tensor payloads are request-invariant; clone to
- # avoid accidental cross-request aliasing on downstream mutation.
- mm_payload[k] = v.clone()
- else:
- mm_payload[k] = v
+ # If prefix cache is enabled, we have already split everything
+ # by request and converted the states to CPU tensors
+ req_hidden_states = self._resolve_req_hidden_states(
+ hidden_states_cpu,
+ combined_hidden_states,
+ rid,
+ start,
+ end,
+ )
+ payload: dict[str, object] = {"hidden": req_hidden_states}
+
+ mm_payload: dict[str, object] = {}
+ if combined_multimodal_outputs or mm_cpu:
+ if combined_multimodal_outputs:
+ # Prefix cache enabled; all items have already been processed
+ # and split apart for each request as needed, and all tensors
+ # have already been detached to the CPU. The only exception is
+ # lists, which we keep as passthrough data for consistent behavior
+ # in postprocess.
+ for mm_key in combined_multimodal_outputs.keys():
+ value = combined_multimodal_outputs[mm_key][rid]
+ if isinstance(value, list):
+ mm_payload[mm_key] = value[idx] if idx < len(value) else value[0]
+ else:
+ mm_payload[mm_key] = value
+
+ else:
+ # Prefix cache disabled; we still need to process the data
+ for mm_key, mm_val in mm_cpu.items():
+ mm_payload[mm_key] = to_payload_element(
+ element=mm_val,
+ idx=idx,
+ start=start,
+ end=end,
+ pass_lists_through=False,
+ seq_len=seq_len,
+ )
payload.update(mm_payload)
pooler_output.append(payload)
with record_function_or_nullcontext("gpu_model_runner: ModelRunnerOutput"):
diff --git a/vllm_omni/worker/gpu_ar_worker.py b/vllm_omni/worker/gpu_ar_worker.py
index 4abe21964..2668bbe8b 100644
--- a/vllm_omni/worker/gpu_ar_worker.py
+++ b/vllm_omni/worker/gpu_ar_worker.py
@@ -12,6 +12,7 @@ from vllm.v1.worker.gpu_worker import init_worker_distributed_environment
from vllm.v1.worker.utils import request_memory
from vllm.v1.worker.workspace import init_workspace_manager
+from vllm_omni.diffusion.data import OmniACK, OmniSleepTask, OmniWakeTask
from vllm_omni.worker.base import OmniGPUWorkerBase
from vllm_omni.worker.gpu_ar_model_runner import GPUARModelRunner
from vllm_omni.worker.mixins import OmniWorkerMixin
@@ -104,3 +105,23 @@ class GPUARWorker(OmniWorkerMixin, OmniGPUWorkerBase):
if self.rank == 0:
# If usage stat is enabled, collect relevant info.
report_usage_stats(self.vllm_config)
+
+ def handle_sleep_task(self, task: OmniSleepTask | dict) -> OmniACK:
+ """
+ Explicitly handle sleep commands.
+ Calls the implementation in the base class OmniGPUWorkerBase.
+ """
+ logger.debug(f"[AR Worker {self.rank}] Resolving handle_sleep_task dispatch")
+ if isinstance(task, dict):
+ task = OmniSleepTask(**task)
+ return super().handle_sleep_task(task)
+
+ def handle_wake_task(self, task: OmniWakeTask | dict) -> OmniACK:
+ """
+ Explicitly handle wake-up commands.
+ Calls the implementation in the base class OmniGPUWorkerBase.
+ """
+ logger.debug(f"[AR Worker {self.rank}] Resolving handle_wake_task dispatch")
+ if isinstance(task, dict):
+ task = OmniWakeTask(**task)
+ return super().handle_wake_task(task)
diff --git a/vllm_omni/worker/gpu_model_runner.py b/vllm_omni/worker/gpu_model_runner.py
index 5ff62c11b..d3ccbaaf3 100644
--- a/vllm_omni/worker/gpu_model_runner.py
+++ b/vllm_omni/worker/gpu_model_runner.py
@@ -20,6 +20,7 @@ from vllm.v1.worker.gpu_input_batch import CachedRequestState
from vllm.v1.worker.gpu_model_runner import GPUModelRunner, IntermediateTensors, PerLayerAttnMetadata
from vllm.v1.worker.ubatch_utils import maybe_create_ubatch_slices
+from vllm_omni.core.prefix_cache import OmniTensorPrefixCache
from vllm_omni.engine.serialization import deserialize_additional_information
from vllm_omni.model_executor.layers.rotary_embedding.mrope import OmniMRotaryEmbedding as MRotaryEmbedding
from vllm_omni.model_executor.models.output_templates import OmniOutput
@@ -43,6 +44,9 @@ class OmniGPUModelRunner(GPUModelRunner):
self.model_intermediate_buffer: dict[str, dict[str, Any]] = {}
self._omni_num_scheduled_tokens_np: np.ndarray | None = None
self._omni_last_model_output: object | None = None
+ # The Omni tensor prefix cache will be allocated
+ # when we initialize the metadata builders if enabled
+ self.omni_prefix_cache = None
def initialize_metadata_builders(self, kv_cache_config, kernel_block_sizes):
"""Override to fix scheduler_metadata buffer size for FA3 + CUDA graph.
@@ -70,6 +74,16 @@ class OmniGPUModelRunner(GPUModelRunner):
device=sm.device,
)
+ # Initialize the wrapper for both multimodal output tensors
+ # and for hidden states to be passed between stages
+ if self.cache_config.enable_prefix_caching:
+ self.omni_prefix_cache = OmniTensorPrefixCache(
+ num_blocks=kv_cache_config.num_blocks,
+ block_size=self.cache_config.block_size,
+ hidden_size=self.model_config.get_hidden_size(),
+ hs_dtype=self.dtype,
+ )
+
@instrument(span_name="Loading (GPU)")
def load_model(self, *args, **kwargs) -> None:
super().load_model(*args, **kwargs)
@@ -138,8 +152,10 @@ class OmniGPUModelRunner(GPUModelRunner):
if supports_mrope(self.get_model()):
# Model implements SupportsMRoPE interface
# Pass all extracted metadata; models use what they need via **kwargs
- req_state.mrope_positions, req_state.mrope_position_delta = self.model.get_mrope_input_positions(
- req_state.prompt_token_ids,
+ sp_extra_args = getattr(req_state.sampling_params, "extra_args", {}) if req_state.sampling_params else {}
+ target_h = sp_extra_args.get("target_h") if isinstance(sp_extra_args, dict) else None
+ target_w = sp_extra_args.get("target_w") if isinstance(sp_extra_args, dict) else None
+ kwargs = dict(
mm_features=req_state.mm_features,
hf_config=self.model_config.hf_config,
image_grid_thw=image_grid_thw,
@@ -148,6 +164,14 @@ class OmniGPUModelRunner(GPUModelRunner):
audio_feature_lengths=audio_feature_lengths,
use_audio_in_video=use_audio_in_video,
)
+ if target_h is not None:
+ kwargs["target_h"] = target_h
+ if target_w is not None:
+ kwargs["target_w"] = target_w
+ req_state.mrope_positions, req_state.mrope_position_delta = self.model.get_mrope_input_positions(
+ req_state.prompt_token_ids,
+ **kwargs,
+ )
else:
req_state.mrope_positions, req_state.mrope_position_delta = MRotaryEmbedding.get_input_positions_tensor(
req_state.prompt_token_ids,
@@ -234,6 +258,10 @@ class OmniGPUModelRunner(GPUModelRunner):
The SamplingMetadata is updated and copied to the GPU if there is a
new/resumed/paused/finished request in the batch.
"""
+ # Used for prefix cache
+ if self.omni_prefix_cache is not None:
+ self.omni_prefix_cache.reset_prefix_cached_new_req_ids()
+
# Remove finished requests from the cached states.
for req_id in scheduler_output.finished_req_ids:
self.requests.pop(req_id, None)
@@ -290,10 +318,18 @@ class OmniGPUModelRunner(GPUModelRunner):
for new_req_data in scheduler_output.scheduled_new_reqs:
req_id = new_req_data.req_id
if req_id in self.requests:
+ self._update_streaming_input_additional_info(new_req_data, req_id)
req_state = self._update_streaming_request(req_id, new_req_data)
reqs_to_add.append(req_state)
continue
+ # Since this is the first time the request has been scheduled,
+ # num_computed_tokens > 0 means that we have a hit in prefix
+ # caching; mark it so that we can manage the hidden states
+ # later on as needed.
+ if self.omni_prefix_cache is not None and new_req_data.num_computed_tokens > 0:
+ self.omni_prefix_cache.add_prefix_cached_new_req_id(req_id)
+
sampling_params = new_req_data.sampling_params
pooling_params = new_req_data.pooling_params
@@ -1010,6 +1046,8 @@ class OmniGPUModelRunner(GPUModelRunner):
multimodal_outputs: object,
num_scheduled_tokens_np: np.ndarray,
scheduler_output: "SchedulerOutput",
+ combined_hidden_states: dict[str, torch.Tensor] | None = None,
+ combined_multimodal_outputs: dict[str, object] | None = None,
) -> None:
"""Process model-provided per-request updates and merge into model_intermediate_buffer."""
try:
@@ -1018,21 +1056,31 @@ class OmniGPUModelRunner(GPUModelRunner):
if hasattr(self.model, "has_postprocess") and self.model.has_postprocess:
for req_index, req_id in enumerate(self.input_batch.req_ids):
req_infos = self.model_intermediate_buffer.get(req_id, {})
- start_offset = int(self.query_start_loc.cpu[req_index])
- sched_tokens = int(num_scheduled_tokens_np[req_index])
- s, e = start_offset, start_offset + sched_tokens
- # only consider to store data into update dict.
- hidden_states_slice = hidden_states[s:e]
+ if combined_hidden_states:
+ # Combined hidden states contains all hidden states for every request
+ hidden_states_slice = combined_hidden_states[req_id]
+ else:
+ start_offset = int(self.query_start_loc.cpu[req_index])
+ sched_tokens = int(num_scheduled_tokens_np[req_index])
+ s, e = start_offset, start_offset + sched_tokens
+ # only consider to store data into update dict.
+ hidden_states_slice = hidden_states[s:e]
+
+ if combined_multimodal_outputs:
+ # NOTE this is a bit ugly, but the mm data is structured as a list of
+ # keys mapping to request IDs, and if enabled, we will always have all
+ # request IDs in every subdict, including for cache misses.
+ mm_out = {k: v[req_id] for k, v in combined_multimodal_outputs.items()}
+ else:
+ mm_out = multimodal_outputs
update_dict = self.model.postprocess(
- hidden_states_slice, multimodal_outputs=multimodal_outputs, **req_infos
+ hidden_states_slice,
+ multimodal_outputs=mm_out,
+ **req_infos,
)
self._update_intermediate_buffer(req_id, update_dict)
except Exception as e:
- logger.error(
- f"Error merging for requests:{self.input_batch.req_ids} "
- f"additional information update: {e}, with the multimodal_outputs "
- f"as {multimodal_outputs}"
- )
+ logger.error(f"Error merging for requests:{self.input_batch.req_ids} additional information update: {e}")
import traceback
traceback.print_exc()
@@ -1307,10 +1355,22 @@ class OmniGPUModelRunner(GPUModelRunner):
req_embeds = self.talker_mtp_inputs_embeds.gpu[:num_tokens_padded]
last_talker_hidden = self.last_talker_hidden.gpu[:num_tokens_padded]
text_step = self.text_step.gpu[:num_tokens_padded]
+ subtalker_params = getattr(self.vllm_config.model_config, "subtalker_sampling_params", None)
+ if not isinstance(subtalker_params, dict):
+ subtalker_params = {}
with set_forward_context(
None, self.vllm_config, cudagraph_runtime_mode=_cudagraph_mode, batch_descriptor=batch_desc
):
- req_embeds, code_predictor_codes = self.talker_mtp(req_input_ids, req_embeds, last_talker_hidden, text_step)
+ req_embeds, code_predictor_codes = self.talker_mtp(
+ req_input_ids,
+ req_embeds,
+ last_talker_hidden,
+ text_step,
+ do_sample=subtalker_params.get("do_sample"),
+ temperature=subtalker_params.get("temperature"),
+ top_k=subtalker_params.get("top_k"),
+ top_p=subtalker_params.get("top_p"),
+ )
# code_predictor_codes stays on GPU here; _update_intermediate_buffer
# keeps it device-resident when the key is in gpu_resident_buffer_keys.
# D2H is deferred to sample_tokens where hidden_states.to("cpu") already
@@ -1377,3 +1437,30 @@ class OmniGPUModelRunner(GPUModelRunner):
def _merge_additional_information_update(self, req_id, upd):
logger.warning_once("_merge_additional_information_update is deprecated, use _update_intermediate_buffer")
return self._update_intermediate_buffer(req_id, upd)
+
+ def _update_streaming_input_additional_info(self, new_req_data, req_id):
+ # For streaming input prefill case only. Update buffer from last segment input
+ cached_additional_info = self.model_intermediate_buffer.get(req_id, {})
+ if cached_additional_info:
+ payload_info = getattr(new_req_data, "additional_information", None)
+ inc_info = deserialize_additional_information(payload_info)
+ if isinstance(inc_info, dict) and inc_info:
+ merged_info = dict(cached_additional_info)
+ for key, value in inc_info.items():
+ accumulated_keys: set[str] = set()
+ if hasattr(self, "model") and hasattr(self.model, "streaming_accumulated_keys"):
+ accumulated_keys = self.model.streaming_accumulated_keys
+ if key in accumulated_keys and isinstance(value, torch.Tensor):
+ inc_tensor = value.detach().to("cpu").contiguous()
+ old_tensor = merged_info.get(key)
+ if old_tensor is None:
+ merged_info[key] = inc_tensor
+ else:
+ merged_info[key] = torch.cat((old_tensor, inc_tensor), dim=0)
+ continue
+
+ # Default for other keys: latest value.
+ merged_info[key] = value
+ merged_info["num_processed_tokens"] = 0
+ self.model_intermediate_buffer[req_id] = merged_info
+ setattr(self.requests[req_id], "additional_information_cpu", merged_info)