Compare commits

...
Author SHA1 Message Date
khluuandClaude Opus 4.6 936dd424d2 Enable twine upload to PyPI in release pipeline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 03:54:51 -07:00
Kevin H. LuuandClaude Opus 4.7 bc26cad19a [CI/Build] Unify release pipeline with NIGHTLY=1 option, add x86_64/aarch64 image builds (#3428)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:40:14 +08:00
9c5e35f485 [Config Refactor] Support Recursive Merging for Engine Args (#3009)
Signed-off-by: Alex Brooks <albrooks@redhat.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: SYLAR <125541396+lishunyang12@users.noreply.github.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-05-17 07:33:03 +08:00
Yuanheng Zhao a64ebf103b [Refactor] Migrate and clean up TTS configs: CosyVoice3, OmniVoice, VoxCPM (#3338)
Signed-off-by: yuanheng <jonathan.zhaoyh@gmail.com>
Signed-off-by: Yuanheng Zhao <jonathan.zhaoyh@gmail.com>
2026-05-17 07:32:34 +08:00
TJian c08959ee04 [ROCm] [CI] [Bugfix] Upgrade vllm version to v0.21.0 and ROCm 7.2.2 (#3659)
Signed-off-by: tjtanaa <tunjian.tan@embeddedllm.com>
2026-05-17 03:15:59 +12:00
rongfu.leng c5ac295e3c [Feat] Add helios support cache dit (#3470)
Signed-off-by: rongfu.leng <lenronfu@gmail.com>
2026-05-16 21:11:34 +08:00
Zeng ChuangandHongsheng Liu ea35a0cc4a [Bugfix]update process name for dit stage (#3602)
Signed-off-by: zengchuang <zengchuang3@huawei.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-05-16 20:51:31 +08:00
wuhangandHongsheng Liu 0f4853ff86 [Bugfix] Support diffusion worker dead detect when use inline engine (#3214)
Signed-off-by: wuhang <wuhang6@huawei.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-05-16 20:50:29 +08:00
WeiQing Chen b5e163cfca [CI][Accuracy] Add Qwen-Image-2512 Qwen-Image-Edit-2511 pixel accuracy tests (#3502)
Signed-off-by: david6666666 <530634352@qq.com>
2026-05-16 20:08:51 +08:00
dengyunyangandHongsheng Liu d647e7e4cf [Hunyuanimage 3.0] hunyuan accuracy test (#3655)
Signed-off-by: dengyunyang <584797741@qq.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-05-16 23:35:05 +12:00
33220b1e39 [BugFix] Finish async_chunk requests without pad-token injection (#3613)
Signed-off-by: Nick Cao <ncao@redhat.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-05-16 23:34:04 +12:00
+20 eb4e60ee64 [Rebase] Rebase to vllm v0.21.0 (#3530)
Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
Signed-off-by: Zhou Taichang <tzhouam@connect.ust.hk>
Signed-off-by: NumberWan <wantszkin2003@gmail.com>
Signed-off-by: Divyansh Singhvi <divyanshsinghvi@gmail.com>
Signed-off-by: dsinghvi <divyanshsinghvi@gmail.com>
Signed-off-by: Dnoob <dxpouo@gmail.com>
Signed-off-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
Signed-off-by: knlnguyen1802 <knlnguyen1802@gmail.com>
Signed-off-by: rein yang <ruiruyang2@gmail.com>
Signed-off-by: Nick Cao <ncao@redhat.com>
Signed-off-by: zhumingjue <zhumingjue@huawei.com>
Signed-off-by: Ricardo Noriega De Soto <rnoriega@redhat.com>
Signed-off-by: lyj-jjj <liuyingjun5@huawei.com>
Signed-off-by: gcanlin <canlinguosdu@gmail.com>
Signed-off-by: wangyu <410167048@qq.com>
Signed-off-by: weizhoublue <weizhoublue@github.com>
Signed-off-by: weizhou.lan@daocloud.io <weizhou.lan@daocloud.io>
Signed-off-by: dengyunyang <584797741@qq.com>
Signed-off-by: 汪志鹏 <wangzhipeng628@gmail.com>
Signed-off-by: David Chen <530634352@qq.com>
Signed-off-by: Jie Liu <33612777+keeper-jie@users.noreply.github.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Signed-off-by: princepride <wangzhipeng628@gmail.com>
Signed-off-by: natureofnature <wzliu@connect.hku.hk>
Signed-off-by: bjf-frz <frz123db@gmail.com>
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
Signed-off-by: KexiongYu <yukexiong1@huawei.com>
Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com>
Signed-off-by: Zeyu Huang | 黃澤宇 <11222265+fhfuih@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: NumberWan <wantszkin2003@gmail.com>
Co-authored-by: dsinghvi <divyanshsinghvi@gmail.com>
Co-authored-by: Dnoob <dxpouo@gmail.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: knlnguyen1802 <knlnguyen1802@gmail.com>
Co-authored-by: Samit <285365963@qq.com>
Co-authored-by: rein yang <73573651+R2-Y@users.noreply.github.com>
Co-authored-by: Nick Cao <ncao@redhat.com>
Co-authored-by: zhumingjue138 <zhumingjue@huawei.com>
Co-authored-by: Ricardo Noriega <rnoriega@redhat.com>
Co-authored-by: lyj-jjj <liuyingjun5@huawei.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: gcanlin <canlinguosdu@gmail.com>
Co-authored-by: wangyu <53896905+yenuo26@users.noreply.github.com>
Co-authored-by: weizhoublue <45163302+weizhoublue@users.noreply.github.com>
Co-authored-by: weizhoublue <weizhoublue@github.com>
Co-authored-by: dengyunyang <584797741@qq.com>
Co-authored-by: 汪志鹏 <wangzhipeng628@gmail.com>
Co-authored-by: WeiQing Chen <40507679+david6666666@users.noreply.github.com>
Co-authored-by: Jie Liu <33612777+keeper-jie@users.noreply.github.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: NATURE <wzliu@connect.hku.hk>
Co-authored-by: bjf-frz <frz123db@gmail.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
Co-authored-by: Y. Fisher <yukexiong1@huawei.com>
Co-authored-by: Zeyu Huang | 黃澤宇 <11222265+fhfuih@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-16 18:18:43 +08:00
Yueqian LinandHongsheng Liu 5e1986206f [CI] Replace c=128 perf cell with c=16; loosen new-cell baselines (#3637)
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-05-15 23:35:44 +08:00
amy-why-3459 d1c65bdffa [BugFix] fix ci (#3650)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-05-15 22:50:03 +08:00
Zeyu Huang | 黃澤宇andCopilot Autofix powered by AI d18168ccbb [bugfix] Fix diffusers backend input bug after #2913 (#3644)
Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com>
Signed-off-by: Zeyu Huang | 黃澤宇 <11222265+fhfuih@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 09:55:09 +00:00
Y. Fisher 779bf3118b [Bugfix] fix compatibility of _hunyuan_image3_unpack_packed_topk between vllm / vllm ascend (#3640)
Signed-off-by: KexiongYu <yukexiong1@huawei.com>
2026-05-15 17:35:22 +08:00
amy-why-3459 e7ee5de09f [BugFix] Fix the issue of thinker requests being preempted, causing shape mismatch. (#3147)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-05-15 17:07:42 +08:00
bjf-frz 440c718d2a [Bugfix]Fix multimodal cache routing for AR replicas (#3605)
Signed-off-by: bjf-frz <frz123db@gmail.com>
2026-05-15 16:42:18 +08:00
NATUREandHongsheng Liu 82a0b3a467 [2/5] [core]refactor communication layer: PR 2 of 5 Qwen3 Omni non async (#2677)
Signed-off-by: natureofnature <wzliu@connect.hku.hk>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-05-15 14:14:08 +08:00
汪志鹏 c7178d89bb [Bugfix] UnspecifiedOmniPlatform.get_device_count returns 0 instead o… (#3636)
Signed-off-by: princepride <wangzhipeng628@gmail.com>
2026-05-15 05:28:40 +00:00
wangyu fdb0efea94 [CI] add cuda marker to Diffusion X2V function pytest (#3625)
Signed-off-by: wangyu <410167048@qq.com>
2026-05-15 11:50:22 +08:00
Jie LiuandYueqian Lin 90f5b3c3a1 Update streaming_speech_client.py to solve Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice voice problem (#3380)
Signed-off-by: Jie Liu <33612777+keeper-jie@users.noreply.github.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
2026-05-14 20:43:05 -07:00
lyj-jjjandCursor bbc00f9f86 [BugFix] fix(omni): isolate diffusion KV-cache dtype from vLLM --kv-cache-dtype #3585 (#3596)
Signed-off-by: lyj-jjj <liuyingjun5@huawei.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 11:22:59 +08:00
WeiQing Chen adb2291c27 Update WeChat group QR code (#3624)
Signed-off-by: David Chen <530634352@qq.com>
2026-05-15 09:38:47 +08:00
Canlin Guo 4f13b871f9 [CI] Add Qwen3-TTS tests for ready tag (#3600)
Signed-off-by: gcanlin <canlinguosdu@gmail.com>
2026-05-14 20:37:51 +08:00
dengyunyangand汪志鹏 94254e015f [BugFix] fix shm connector (#3583)
Signed-off-by: dengyunyang <584797741@qq.com>
Signed-off-by: 汪志鹏 <wangzhipeng628@gmail.com>
Co-authored-by: 汪志鹏 <wangzhipeng628@gmail.com>
2026-05-14 20:07:12 +08:00
bjf-frzandCanlin Guo f7161b07d0 [Bugfix]Allow HunyuanImage3 AR sampler batching (#3590)
Signed-off-by: bjf-frz <frz123db@gmail.com>
Co-authored-by: Canlin Guo <canlinguosdu@gmail.com>
2026-05-14 09:27:59 +00:00
Hongsheng Liuandhsliu_ustc c0b7509f07 update v0.20.0 readme (#3594)
Signed-off-by: hsliu_ustc <hsliu_ustc@noreply.gitcode.com>
Co-authored-by: hsliu_ustc <hsliu_ustc@noreply.gitcode.com>
2026-05-14 17:23:55 +08:00
3f63aaf982 [Feature] HunyuanImage-3.0 IT2I: multi-image input + prompt API cleanup (#3444)
Signed-off-by: TaffyOfficial <2324465096@qq.com>
Signed-off-by: TaffyOfficial <wu15922848573@outlook.com>
Signed-off-by: skf1999 <13234016272@163.com>
Signed-off-by: zuiho <2324465096@qq.com>
Signed-off-by: Claude Code <noreply@anthropic.com>
Signed-off-by: zuiho <wu15922848573@outlook.com>
Signed-off-by: TaffyOfficial <2587297563@qq.com>
Co-authored-by: TaffyOfficial <2324465096@qq.com>
Co-authored-by: TaffyOfficial <wu15922848573@outlook.com>
Co-authored-by: skf1999 <13234016272@163.com>
2026-05-14 16:58:40 +08:00
Yueqian Lin c4f859bf56 [CI] Harden Qwen3-TTS perf nightly: enable Base voice_clone, add c=64/128, 2-GPU split (#3491)
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
2026-05-14 14:26:59 +08:00
138 changed files with 5867 additions and 1710 deletions

No files matched your search

-20
View File
@@ -1,20 +0,0 @@
steps:
- label: "Build and upload wheel"
key: "build-wheel"
agents:
queue: cpu_queue_release
commands:
- "curl -LsSf https://astral.sh/uv/install.sh | sh"
- 'export PATH="$HOME/.local/bin:$PATH"'
- "uv venv --python=3.12 && source .venv/bin/activate"
- "uv pip install --upgrade build"
- "python3 -m build"
- "bash .buildkite/scripts/upload-nightly-wheels.sh"
- label: "Generate and upload wheel indices"
depends_on: "build-wheel"
allow_dependency_failure: true
agents:
queue: small_cpu_queue_release
commands:
- "bash .buildkite/scripts/generate-and-upload-nightly-index.sh"
+13 -9
View File
@@ -78,15 +78,19 @@ steps:
echo "--- Image pushed successfully" echo "--- Image pushed successfully"
echo "$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" echo "$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}"
# - label: "Upload Ready Pipeline" - label: "Upload Ready Pipeline"
# depends_on: image-build depends_on: image-build
# key: upload-ready-pipeline key: upload-ready-pipeline
# if: build.branch != "main" && build.pull_request.labels includes "npu-test" if: >-
# agents: build.branch != "main" && (
# queue: "ascend-a2b3" build.pull_request.labels includes "npu-test" ||
# resource_class: "npu-1" build.pull_request.labels includes "ready"
# command: | )
# buildkite-agent pipeline upload .buildkite/test-npu-ready.yaml agents:
queue: "ascend-a2b3"
resource_class: "npu-1"
command: |
buildkite-agent pipeline upload .buildkite/test-npu-ready.yaml
# L4 Test — main+NIGHTLY=1 (scheduled), or PR with specific label (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" - label: "Upload Nightly Pipeline"
+38
View File
@@ -0,0 +1,38 @@
steps:
- label: ":docker: Build image"
key: image-build
commands:
- "aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/q9t5s3a7"
- "docker build --progress=plain --file docker/Dockerfile.ci -t vllm-omni-ci ."
- "docker tag vllm-omni-ci public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT"
- "docker push public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT"
agents:
queue: "cpu_queue_premerge"
# L2 Test
- label: "Upload Ready Pipeline"
depends_on: image-build
key: upload-ready-pipeline
commands:
- buildkite-agent pipeline upload .buildkite/test-ready.yml
agents:
queue: "cpu_queue_premerge"
# L3 Test
- label: "Upload Merge Pipeline"
depends_on: image-build
key: upload-merge-pipeline
commands:
- buildkite-agent pipeline upload .buildkite/test-merge.yml
agents:
queue: "cpu_queue_premerge"
# L4 Test
- label: "Upload Nightly Pipeline"
depends_on: image-build
key: upload-nightly-pipeline
if: build.env("NIGHTLY") == "1"
commands:
- buildkite-agent pipeline upload .buildkite/test-nightly.yml
agents:
queue: "cpu_queue_premerge"
+103 -31
View File
@@ -1,38 +1,110 @@
steps: steps:
- label: ":docker: Build image" - input: "Provide Release version here"
key: image-build id: input-release-version
commands: fields:
- "aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/q9t5s3a7" - text: "What is the release version?"
- "docker build --progress=plain --file docker/Dockerfile.ci -t vllm-omni-ci ." key: release-version
- "docker tag vllm-omni-ci public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT"
- "docker push public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT"
agents:
queue: "cpu_queue_premerge"
# L2 Test - label: "Build and upload wheel"
- label: "Upload Ready Pipeline" depends_on: ~
depends_on: image-build key: "build-wheel"
key: upload-ready-pipeline
commands:
- buildkite-agent pipeline upload .buildkite/test-ready.yml
agents: agents:
queue: "cpu_queue_premerge" queue: cpu_queue_release
commands:
- "curl -LsSf https://astral.sh/uv/install.sh | sh"
- 'export PATH="$HOME/.local/bin:$PATH"'
- "uv venv --python=3.12 && source .venv/bin/activate"
- "uv pip install --upgrade build"
- "python3 -m build"
- "bash .buildkite/scripts/upload-nightly-wheels.sh"
# L3 Test - label: "Generate and upload wheel indices"
- label: "Upload Merge Pipeline" depends_on: "build-wheel"
depends_on: image-build allow_dependency_failure: true
key: upload-merge-pipeline
commands:
- buildkite-agent pipeline upload .buildkite/test-merge.yml
agents: agents:
queue: "cpu_queue_premerge" queue: small_cpu_queue_release
commands:
- "bash .buildkite/scripts/generate-and-upload-nightly-index.sh"
# L4 Test - block: "Upload release wheels to PyPI"
- label: "Upload Nightly Pipeline" depends_on: "build-wheel"
depends_on: image-build key: block-upload-release-wheels
key: upload-nightly-pipeline
if: build.env("NIGHTLY") == "1" - label: "Upload release wheels to PyPI"
commands: depends_on:
- buildkite-agent pipeline upload .buildkite/test-nightly.yml - block-upload-release-wheels
- input-release-version
key: upload-release-wheels
agents: agents:
queue: "cpu_queue_premerge" queue: small_cpu_queue_release
commands:
- "bash .buildkite/scripts/publish-release-wheels-pypi.sh"
- block: "Unblock to build release Docker images"
depends_on: ~
key: block-build-release-images
- group: "Build release Docker images"
key: "build-release-images"
depends_on: block-build-release-images
allow_dependency_failure: true
steps:
- label: "Build release image - x86_64"
key: build-release-image-x86
depends_on: ~
agents:
queue: cpu_queue_release
commands:
- "aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/q9t5s3a7"
- |
DOCKER_BUILDKIT=1 docker build \
--build-arg max_jobs=16 \
--tag public.ecr.aws/q9t5s3a7/vllm-omni-release-repo:$BUILDKITE_COMMIT-$(uname -m) \
--progress plain \
-f docker/Dockerfile.cuda .
- "docker push public.ecr.aws/q9t5s3a7/vllm-omni-release-repo:$BUILDKITE_COMMIT-$(uname -m)"
# re-tag to default image tag and push, just in case arm64 build fails
- "docker tag public.ecr.aws/q9t5s3a7/vllm-omni-release-repo:$BUILDKITE_COMMIT-$(uname -m) public.ecr.aws/q9t5s3a7/vllm-omni-release-repo:$BUILDKITE_COMMIT"
- "docker push public.ecr.aws/q9t5s3a7/vllm-omni-release-repo:$BUILDKITE_COMMIT"
env:
DOCKER_BUILDKIT: "1"
- label: "Build release image - aarch64"
key: build-release-image-arm64
depends_on: ~
agents:
queue: arm64_cpu_queue_release
commands:
- "aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/q9t5s3a7"
- |
DOCKER_BUILDKIT=1 docker build \
--build-arg max_jobs=16 \
--tag public.ecr.aws/q9t5s3a7/vllm-omni-release-repo:$BUILDKITE_COMMIT-$(uname -m) \
--progress plain \
-f docker/Dockerfile.cuda .
- "docker push public.ecr.aws/q9t5s3a7/vllm-omni-release-repo:$BUILDKITE_COMMIT-$(uname -m)"
env:
DOCKER_BUILDKIT: "1"
- block: "Publish release images to DockerHub"
key: block-publish-release-images
depends_on:
- build-release-image-x86
- build-release-image-arm64
- input-release-version
- label: "Publish release images to DockerHub"
depends_on:
- block-publish-release-images
key: publish-release-images-dockerhub
agents:
queue: small_cpu_queue_release
commands:
- "bash .buildkite/scripts/publish-release-images.sh"
plugins:
- docker-login#v3.0.0:
username: vllmbot
password-env: DOCKERHUB_TOKEN
env:
DOCKER_BUILDKIT: "1"
DOCKERHUB_USERNAME: "vllmbot"
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# SPDX-License-Identifier: Apache-2.0
#
# Publish release Docker images from ECR to DockerHub.
# Pulls per-arch images, tags with `latest` and versioned tags, pushes them,
# then creates and pushes a multi-arch manifest.
set -euo pipefail
RELEASE_VERSION=$(buildkite-agent meta-data get release-version --default "" | sed 's/^v//')
if [ -z "${RELEASE_VERSION}" ]; then
echo "ERROR: release-version metadata not set"
exit 1
fi
COMMIT="$BUILDKITE_COMMIT"
DOCKERHUB_REPO="vllm/vllm-omni"
ECR_REPO="public.ecr.aws/q9t5s3a7/vllm-omni-release-repo"
echo "========================================"
echo "Publishing release images v${RELEASE_VERSION}"
echo " Commit: ${COMMIT}"
echo "========================================"
# Login to ECR to pull staging images
aws ecr-public get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin public.ecr.aws/q9t5s3a7
docker pull "${ECR_REPO}:${COMMIT}-x86_64"
docker pull "${ECR_REPO}:${COMMIT}-aarch64"
docker tag "${ECR_REPO}:${COMMIT}-x86_64" "${DOCKERHUB_REPO}:latest-x86_64"
docker tag "${ECR_REPO}:${COMMIT}-x86_64" "${DOCKERHUB_REPO}:v${RELEASE_VERSION}-x86_64"
docker push "${DOCKERHUB_REPO}:latest-x86_64"
docker push "${DOCKERHUB_REPO}:v${RELEASE_VERSION}-x86_64"
docker tag "${ECR_REPO}:${COMMIT}-aarch64" "${DOCKERHUB_REPO}:latest-aarch64"
docker tag "${ECR_REPO}:${COMMIT}-aarch64" "${DOCKERHUB_REPO}:v${RELEASE_VERSION}-aarch64"
docker push "${DOCKERHUB_REPO}:latest-aarch64"
docker push "${DOCKERHUB_REPO}:v${RELEASE_VERSION}-aarch64"
docker manifest rm "${DOCKERHUB_REPO}:latest" || true
docker manifest rm "${DOCKERHUB_REPO}:v${RELEASE_VERSION}" || true
docker manifest create "${DOCKERHUB_REPO}:latest" "${DOCKERHUB_REPO}:latest-x86_64" "${DOCKERHUB_REPO}:latest-aarch64"
docker manifest create "${DOCKERHUB_REPO}:v${RELEASE_VERSION}" "${DOCKERHUB_REPO}:v${RELEASE_VERSION}-x86_64" "${DOCKERHUB_REPO}:v${RELEASE_VERSION}-aarch64"
docker manifest push "${DOCKERHUB_REPO}:latest"
docker manifest push "${DOCKERHUB_REPO}:v${RELEASE_VERSION}"
echo ""
echo "Successfully published release images for v${RELEASE_VERSION}"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
set -e
BUCKET="vllm-wheels"
SUBPATH="omni/$BUILDKITE_COMMIT"
S3_COMMIT_PREFIX="s3://$BUCKET/$SUBPATH/"
RELEASE_VERSION=$(buildkite-agent meta-data get release-version | sed 's/^v//')
if [[ -z "$RELEASE_VERSION" ]]; then
echo "[FATAL] release-version metadata not set."
exit 1
fi
echo "Release version: $RELEASE_VERSION"
if [[ -z "$PYPI_TOKEN" ]]; then
echo "[FATAL] PYPI_TOKEN is not set."
exit 1
else
export TWINE_USERNAME="__token__"
export TWINE_PASSWORD="$PYPI_TOKEN"
fi
set -x
if ! command -v uv &> /dev/null; then
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
fi
uv venv --python 3.12 /tmp/vllm-omni-release-env
source /tmp/vllm-omni-release-env/bin/activate
uv pip install twine
DIST_DIR=/tmp/vllm-omni-release-dist
mkdir -p "$DIST_DIR"
echo "Downloading wheels from S3:"
aws s3 ls "$S3_COMMIT_PREFIX"
aws s3 cp --recursive --exclude "*" --include "vllm_omni-${RELEASE_VERSION}*.whl" --exclude "*dev*" "$S3_COMMIT_PREFIX" "$DIST_DIR"
ls -la "$DIST_DIR"
PYPI_WHEEL_FILES=$(find "$DIST_DIR" -name "vllm_omni-${RELEASE_VERSION}*.whl" -not -name "*+*")
if [[ -z "$PYPI_WHEEL_FILES" ]]; then
echo "[FATAL] No wheels found for version ${RELEASE_VERSION}"
exit 1
fi
python3 -m twine check $PYPI_WHEEL_FILES
python3 -m twine upload --non-interactive --verbose $PYPI_WHEEL_FILES
echo "Wheels uploaded to PyPI"
+3 -2
View File
@@ -1,5 +1,6 @@
env: env:
VLLM_WORKER_MULTIPROC_METHOD: spawn VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_USE_FLASHINFER_MOE_FP16: 0
HF_HUB_DOWNLOAD_TIMEOUT: 300 HF_HUB_DOWNLOAD_TIMEOUT: 300
HF_HUB_ETAG_TIMEOUT: 60 HF_HUB_ETAG_TIMEOUT: 60
@@ -172,7 +173,7 @@ steps:
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py tests/e2e/offline_inference/test_qwen3_tts_customvoice.py -m 'advanced_model and cuda' --run-level 'advanced_model' pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py tests/e2e/offline_inference/test_qwen3_tts_customvoice.py -m 'advanced_model and cuda' --run-level 'advanced_model'
" "
agents: agents:
queue: "gpu_1_queue" queue: "gpu_4_queue"
plugins: plugins:
- docker#v5.2.0: - docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
@@ -193,7 +194,7 @@ steps:
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m 'advanced_model and cuda' --run-level 'advanced_model' pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m 'advanced_model and cuda' --run-level 'advanced_model'
" "
agents: agents:
queue: "gpu_1_queue" queue: "gpu_4_queue"
plugins: plugins:
- docker#v5.2.0: - docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+3 -83
View File
@@ -1,5 +1,6 @@
env: env:
VLLM_WORKER_MULTIPROC_METHOD: spawn VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_USE_FLASHINFER_MOE_FP16: 0
HF_HUB_DOWNLOAD_TIMEOUT: 300 HF_HUB_DOWNLOAD_TIMEOUT: 300
HF_HUB_ETAG_TIMEOUT: 60 HF_HUB_ETAG_TIMEOUT: 60
@@ -388,7 +389,7 @@ steps:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources: resources:
limits: limits:
nvidia.com/gpu: 1 nvidia.com/gpu: 2
volumeMounts: volumeMounts:
- name: devshm - name: devshm
mountPath: /dev/shm mountPath: /dev/shm
@@ -659,87 +660,6 @@ steps:
path: /mnt/hf-cache path: /mnt/hf-cache
type: DirectoryOrCreate 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 full_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 full_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" - label: ":full_moon: Diffusion X2I(&A&T) · Accuracy Test"
timeout_in_minutes: 180 timeout_in_minutes: 180
commands: commands:
@@ -1039,7 +959,7 @@ steps:
- label: ":full_moon: Diffusion X2V · Function Test" - label: ":full_moon: Diffusion X2V · Function Test"
timeout_in_minutes: 90 timeout_in_minutes: 90
commands: 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 --run-level "full_model" - 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 "full_model and cuda" --run-level "full_model"
agents: agents:
queue: "mithril-h100-pool" queue: "mithril-h100-pool"
plugins: plugins:
+21
View File
@@ -0,0 +1,21 @@
steps:
- group: ":card_index_dividers: TTS Model Test"
key: ready-tts-group
depends_on: upload-ready-pipeline
steps:
- label: ":sound: TTS · Qwen3-TTS CustomVoice Test"
agents:
queue: "ascend-a2b3"
resource_class: "npu-1"
image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}"
plugins:
- kubernetes:
podSpecPatch:
imagePullSecrets:
- name: swr-secret
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
HF_TOKEN: "${HF_TOKEN}"
commands:
- pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m 'core_model' --run-level 'core_model'
+3 -2
View File
@@ -1,5 +1,6 @@
env: env:
VLLM_WORKER_MULTIPROC_METHOD: spawn VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_USE_FLASHINFER_MOE_FP16: 0
HF_HUB_DOWNLOAD_TIMEOUT: 300 HF_HUB_DOWNLOAD_TIMEOUT: 300
HF_HUB_ETAG_TIMEOUT: 60 HF_HUB_ETAG_TIMEOUT: 60
@@ -303,7 +304,7 @@ steps:
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m 'core_model' --run-level 'core_model' pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m 'core_model' --run-level 'core_model'
" "
agents: agents:
queue: "gpu_1_queue" queue: "gpu_4_queue"
plugins: plugins:
- docker#v5.2.0: - docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
@@ -326,7 +327,7 @@ steps:
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m 'core_model' --run-level 'core_model' pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m 'core_model' --run-level 'core_model'
" "
agents: agents:
queue: "gpu_1_queue" queue: "gpu_4_queue"
plugins: plugins:
- docker#v5.2.0: - docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+1
View File
@@ -1,5 +1,6 @@
env: env:
VLLM_WORKER_MULTIPROC_METHOD: spawn VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_USE_FLASHINFER_MOE_FP16: 0
HF_HUB_DOWNLOAD_TIMEOUT: 300 HF_HUB_DOWNLOAD_TIMEOUT: 300
HF_HUB_ETAG_TIMEOUT: 60 HF_HUB_ETAG_TIMEOUT: 60
+2 -2
View File
@@ -9,19 +9,19 @@ Easy, fast, and cheap omni-modality model serving for everyone
</h3> </h3>
<p align="center"> <p align="center">
| <a href="https://vllm-omni.readthedocs.io/en/latest/"><b>Documentation</b></a> | <a href="https://discuss.vllm.ai"><b>User Forum</b></a> | <a href="https://slack.vllm.ai"><b>Developer Slack</b></a> | <a href="docs/assets/WeChat.jpg"><b>WeChat</b></a> | <a href="https://arxiv.org/abs/2602.02204"><b>Paper</b></a> | <a href="https://docs.google.com/presentation/d/1XJWgv79lORl8rbaVvp2d5Sqs6ZEBgAgj/edit?slide=id.p1#slide=id.p1"><b>Slides</b></a> | | <a href="https://vllm-omni.readthedocs.io/en/latest/"><b>Documentation</b></a> | <a href="https://discuss.vllm.ai"><b>User Forum</b></a> | <a href="https://slack.vllm.ai"><b>Developer Slack</b></a> | <a href="docs/assets/WeChat.jpg"><b>WeChat</b></a> | <a href="https://arxiv.org/abs/2602.02204"><b>Paper</b></a> | <a href="https://docs.google.com/presentation/d/111-L8zF7A1j_YI_cR8JsblofdScdRr2f/edit?usp=sharing&ouid=110473603432222024453&rtpof=true&sd=true"><b>Slides</b></a> |
</p> </p>
--- ---
*Latest News* 🔥 *Latest News* 🔥
- [2026/05] We released [0.20.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.20.0) - refreshes the serving/runtime stack for large-scale omni workloads, and improves diffusion model performance, quantization, and hardware readiness across CUDA, ROCm, MUSA, NPU, and XPU backends.
- [2026/03] We released [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0) - strengthens the core runtime through a large entrypoint refactor and scheduler/runtime cleanups, expands unified quantization and diffusion execution, broadens multimodal model coverage, and improves production readiness across audio, omni, image, video, RL, and multi-platform deployments. - [2026/03] We released [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0) - strengthens the core runtime through a large entrypoint refactor and scheduler/runtime cleanups, expands unified quantization and diffusion execution, broadens multimodal model coverage, and improves production readiness across audio, omni, image, video, RL, and multi-platform deployments.
- [2026/03] Check out our first public [project deepdive](https://youtu.be/sgwNfsNnR9I) at the vLLM Hong Kong Meetup! - [2026/03] Check out our first public [project deepdive](https://youtu.be/sgwNfsNnR9I) at the vLLM Hong Kong Meetup!
- [2026/03] **[vllm-omni-skills](https://github.com/hsliuustc0106/vllm-omni-skills)** is a community-driven collection of AI assistant skills that help developers work with vLLM-Omni more effectively. These skills can be used with popular agentic AI coding assistants like **Cursor IDE**, **Claude**, **Codex**, and more. - [2026/03] **[vllm-omni-skills](https://github.com/hsliuustc0106/vllm-omni-skills)** is a community-driven collection of AI assistant skills that help developers work with vLLM-Omni more effectively. These skills can be used with popular agentic AI coding assistants like **Cursor IDE**, **Claude**, **Codex**, and more.
- [2026/02] We released [0.16.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.16.0) - A major alignment + capability release that rebases onto **upstream vLLM v0.16.0** and significantly expands performance, distributed execution, and production readiness across **Qwen3-Omni / Qwen3-TTS**, **Bagel**, **MiMo-Audio**, **GLM-Image** and the **Diffusion (DiT) image/video stack**—while also improving platform coverage (CUDA / ROCm / NPU / XPU), CI quality, and documentation. - [2026/02] We released [0.16.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.16.0) - A major alignment + capability release that rebases onto **upstream vLLM v0.16.0** and significantly expands performance, distributed execution, and production readiness across **Qwen3-Omni / Qwen3-TTS**, **Bagel**, **MiMo-Audio**, **GLM-Image** and the **Diffusion (DiT) image/video stack**—while also improving platform coverage (CUDA / ROCm / NPU / XPU), CI quality, and documentation.
- [2026/02] We released [0.14.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.14.0) - This is the first **stable release** of vLLM-Omni that expands Omni’s diffusion / image-video generation and audio / TTS stack, improves distributed execution and memory efficiency, and broadens platform/backend coverage (GPU/ROCm/NPU/XPU). It also brings meaningful upgrades to serving APIs, profiling & benchmarking, and overall stability. Please check our latest [paper](https://arxiv.org/abs/2602.02204) for architecture design and performance results. - [2026/02] We released [0.14.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.14.0) - This is the first **stable release** of vLLM-Omni that expands Omni’s diffusion / image-video generation and audio / TTS stack, improves distributed execution and memory efficiency, and broadens platform/backend coverage (GPU/ROCm/NPU/XPU). It also brings meaningful upgrades to serving APIs, profiling & benchmarking, and overall stability. Please check our latest [paper](https://arxiv.org/abs/2602.02204) for architecture design and performance results.
- [2026/01] We released [0.12.0rc1](https://github.com/vllm-project/vllm-omni/releases/tag/v0.12.0rc1) - a major RC milestone focused on maturing the diffusion stack, strengthening OpenAI-compatible serving, expanding omni-model coverage, and improving stability across platforms (GPU/NPU/ROCm).
- [2025/11] vLLM community officially released [vllm-project/vllm-omni](https://github.com/vllm-project/vllm-omni) in order to support omni-modality models serving. - [2025/11] vLLM community officially released [vllm-project/vllm-omni](https://github.com/vllm-project/vllm-omni) in order to support omni-modality models serving.
--- ---
@@ -18,3 +18,13 @@ vd017|||The quarterly results exceed expectations across all major metrics.|A sh
vd018|||Chapter one. The morning sun filtered gently through the forest canopy.|A smooth, rich male audiobook narrator voice, expressive and engaging. vd018|||Chapter one. The morning sun filtered gently through the forest canopy.|A smooth, rich male audiobook narrator voice, expressive and engaging.
vd019|||To be or not to be, that is the question.|A theatrical female voice, dramatic and expressive, stage projection quality. vd019|||To be or not to be, that is the question.|A theatrical female voice, dramatic and expressive, stage projection quality.
vd020|||And that is all for tonight. Stay well out there, everyone.|A warm, velvety male late-night radio DJ voice, smooth and intimate. vd020|||And that is all for tonight. Stay well out there, everyone.|A warm, velvety male late-night radio DJ voice, smooth and intimate.
vd021|||Please fasten your seatbelts; we will be experiencing some turbulence shortly.|A calm, professional female flight attendant voice, reassuring and clear.
vd022|||The fossil record suggests these creatures lived more than sixty million years ago.|A measured male documentary narrator voice, scientific and curious.
vd023|||Hey kiddo, did you finish your homework before dinner today?|A gentle, caring father voice, warm and slightly tired after a long day.
vd024|||Live from courtside, the home team has just taken a commanding lead.|A high-energy male sports broadcaster, urgent and engaged.
vd025|||Please remain on the line while I transfer you to the next available agent.|A polite, neutral female call-center voice, professional and patient.
vd026|||I have a dream that one day this nation will rise up and live out the true meaning of its creed.|A powerful, resonant male orator voice, slow and impassioned.
vd027|||Mama always said life was like a box of chocolates; you never know what you're gonna get.|A folksy male voice with a Southern American accent, warm and reflective.
vd028|||Ahoy there, matey! Hoist the colors and prepare to set sail.|A theatrical pirate male voice, gruff and boisterous.
vd029|||Ladies and gentlemen, please welcome to the stage our keynote speaker.|A polished female event MC voice, gracious and articulate.
vd030|||Once you've completed the form, click submit and we'll review it within two business days.|A friendly female product-onboarding voice, encouraging and clear.
@@ -18,3 +18,13 @@ smoke017|||In the distance, the mountains were shrouded in thick morning fog.
smoke018|||Our company reported record revenue for the fourth quarter of the fiscal year. smoke018|||Our company reported record revenue for the fourth quarter of the fiscal year.
smoke019|||She explained the new policy in detail during the staff meeting this morning. smoke019|||She explained the new policy in detail during the staff meeting this morning.
smoke020|||The children laughed and played in the garden until the sun began to set. smoke020|||The children laughed and played in the garden until the sun began to set.
smoke021|||A long line of cars stretched along the coastal highway after the lighthouse opened.
smoke022|||The chef recommended the seared scallops with a lemon butter sauce as the special.
smoke023|||Our quarterly engineering review begins promptly at two in the afternoon on Thursday.
smoke024|||Despite the heavy rain, the marathon runners kept a steady pace through downtown.
smoke025|||She finished the crossword puzzle in under fifteen minutes while sipping her tea.
smoke026|||The new release adds streaming output, faster startup, and better error reporting.
smoke027|||His grandfather taught him how to whittle small wooden birds from cedar offcuts.
smoke028|||Travelers were advised to check the timetable since trains were running on a holiday schedule.
smoke029|||The startup launched a public beta after eighteen months of closed development.
smoke030|||At night the desert sky filled with stars so bright the Milky Way was clearly visible.
+1 -1
View File
@@ -1,5 +1,5 @@
ARG VLLM_BASE_IMAGE=vllm/vllm-openai ARG VLLM_BASE_IMAGE=vllm/vllm-openai
ARG VLLM_BASE_TAG=v0.20.0 ARG VLLM_BASE_TAG=v0.21.0
FROM ${VLLM_BASE_IMAGE}:${VLLM_BASE_TAG} FROM ${VLLM_BASE_IMAGE}:${VLLM_BASE_TAG}
ARG APP_DIR=/workspace/vllm-omni ARG APP_DIR=/workspace/vllm-omni
WORKDIR ${APP_DIR} WORKDIR ${APP_DIR}
+7 -1
View File
@@ -15,7 +15,8 @@ RUN apt-get update && \
libsm6 \ libsm6 \
libxext6 \ libxext6 \
libxrender1 \ libxrender1 \
libxcb1 && \ libxcb1 \
espeak-ng && \
rm -rf /var/cache/apt/* && \ rm -rf /var/cache/apt/* && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
@@ -29,6 +30,11 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
"pytest-cov>=4.0.0" \ "pytest-cov>=4.0.0" \
"pytest-mock>=3.10.0" \ "pytest-mock>=3.10.0" \
"openpyxl>=3.0.0" \ "openpyxl>=3.0.0" \
"psutil>=7.2.0" \
"soundfile>=0.13.1" \
"pyttsx3>=2.99" \
"opencv-python>=4.12.0.88" \
"imageio[ffmpeg]>=0.6.0" \
--index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \ --index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \ --trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60 && \ --default-timeout=60 && \
+7 -1
View File
@@ -15,7 +15,8 @@ RUN apt-get update && \
libsm6 \ libsm6 \
libxext6 \ libxext6 \
libxrender1 \ libxrender1 \
libxcb1 && \ libxcb1 \
espeak-ng && \
rm -rf /var/cache/apt/* && \ rm -rf /var/cache/apt/* && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
@@ -29,6 +30,11 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
"pytest-cov>=4.0.0" \ "pytest-cov>=4.0.0" \
"pytest-mock>=3.10.0" \ "pytest-mock>=3.10.0" \
"openpyxl>=3.0.0" \ "openpyxl>=3.0.0" \
"psutil>=7.2.0" \
"soundfile>=0.13.1" \
"pyttsx3>=2.99" \
"opencv-python>=4.12.0.88" \
"imageio[ffmpeg]>=0.6.0" \
--index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \ --index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \ --trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60 && \ --default-timeout=60 && \
+1 -1
View File
@@ -1,4 +1,4 @@
ARG BASE_IMAGE=vllm/vllm-openai-rocm:v0.20.0 ARG BASE_IMAGE=vllm/vllm-openai-rocm:v0.21.0
FROM ${BASE_IMAGE} AS base FROM ${BASE_IMAGE} AS base
# Declare a variable to know if we want to use the nightly build or the stable build. # Declare a variable to know if we want to use the nightly build or the stable build.
+4 -1
View File
@@ -99,7 +99,6 @@ Configuration classes.
- [vllm_omni.diffusion.cache.teacache.config.TeaCacheConfig][] - [vllm_omni.diffusion.cache.teacache.config.TeaCacheConfig][]
- [vllm_omni.distributed.omni_connectors.utils.config.ConnectorSpec][] - [vllm_omni.distributed.omni_connectors.utils.config.ConnectorSpec][]
- [vllm_omni.distributed.omni_connectors.utils.config.OmniTransferConfig][] - [vllm_omni.distributed.omni_connectors.utils.config.OmniTransferConfig][]
- [vllm_omni.model_executor.models.cosyvoice3.config.CosyVoice3Config][]
- [vllm_omni.model_executor.models.fish_speech.configuration_fish_speech.FishSpeechConfig][] - [vllm_omni.model_executor.models.fish_speech.configuration_fish_speech.FishSpeechConfig][]
- [vllm_omni.model_executor.models.fish_speech.configuration_fish_speech.FishSpeechFastARConfig][] - [vllm_omni.model_executor.models.fish_speech.configuration_fish_speech.FishSpeechFastARConfig][]
- [vllm_omni.model_executor.models.fish_speech.configuration_fish_speech.FishSpeechSlowARConfig][] - [vllm_omni.model_executor.models.fish_speech.configuration_fish_speech.FishSpeechSlowARConfig][]
@@ -116,10 +115,14 @@ Configuration classes.
- [vllm_omni.model_executor.models.qwen3_tts.tokenizer_25hz.configuration_qwen3_tts_tokenizer_v1.Qwen3TTSTokenizerV1DecoderConfig][] - [vllm_omni.model_executor.models.qwen3_tts.tokenizer_25hz.configuration_qwen3_tts_tokenizer_v1.Qwen3TTSTokenizerV1DecoderConfig][]
- [vllm_omni.model_executor.models.qwen3_tts.tokenizer_25hz.configuration_qwen3_tts_tokenizer_v1.Qwen3TTSTokenizerV1DecoderDiTConfig][] - [vllm_omni.model_executor.models.qwen3_tts.tokenizer_25hz.configuration_qwen3_tts_tokenizer_v1.Qwen3TTSTokenizerV1DecoderDiTConfig][]
- [vllm_omni.model_executor.models.qwen3_tts.tokenizer_25hz.configuration_qwen3_tts_tokenizer_v1.Qwen3TTSTokenizerV1EncoderConfig][] - [vllm_omni.model_executor.models.qwen3_tts.tokenizer_25hz.configuration_qwen3_tts_tokenizer_v1.Qwen3TTSTokenizerV1EncoderConfig][]
- [vllm_omni.transformers_utils.configs.cosyvoice3.CosyVoice3Config][]
- [vllm_omni.transformers_utils.configs.mammoth_moda2.Mammothmoda2Config][] - [vllm_omni.transformers_utils.configs.mammoth_moda2.Mammothmoda2Config][]
- [vllm_omni.transformers_utils.configs.mammoth_moda2.Mammothmoda2Qwen2_5_VLConfig][] - [vllm_omni.transformers_utils.configs.mammoth_moda2.Mammothmoda2Qwen2_5_VLConfig][]
- [vllm_omni.transformers_utils.configs.mammoth_moda2.Mammothmoda2Qwen2_5_VLTextConfig][] - [vllm_omni.transformers_utils.configs.mammoth_moda2.Mammothmoda2Qwen2_5_VLTextConfig][]
- [vllm_omni.transformers_utils.configs.mammoth_moda2.Mammothmoda2Qwen2_5_VLVisionConfig][] - [vllm_omni.transformers_utils.configs.mammoth_moda2.Mammothmoda2Qwen2_5_VLVisionConfig][]
- [vllm_omni.transformers_utils.configs.omnivoice.OmniVoiceConfig][]
- [vllm_omni.transformers_utils.configs.voxcpm.VoxCPMConfig][]
- [vllm_omni.transformers_utils.configs.voxcpm2.VoxCPM2Config][]
## Workers ## Workers
Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 261 KiB

+1 -1
View File
@@ -34,7 +34,7 @@ vLLM-Omni is a Python library that supports the following GPU variants. The libr
### Pre-built wheels ### Pre-built wheels
Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, and 0.18.0. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source). Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, 0.18.0, 0.20.0, and 0.21.0. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source).
=== "NVIDIA CUDA" === "NVIDIA CUDA"
@@ -20,7 +20,7 @@ Therefore, it is recommended to install vLLM and vLLM-Omni with a **fresh new**
vLLM-Omni is built based on vLLM. Please install it with command below. vLLM-Omni is built based on vLLM. Please install it with command below.
```bash ```bash
uv pip install vllm --torch-backend=auto uv pip install vllm==0.21.0 --torch-backend=auto
``` ```
#### Installation of vLLM-Omni #### Installation of vLLM-Omni
@@ -39,13 +39,13 @@ uv pip install 'vllm-omni[demo]'
# --8<-- [start:build-wheel-from-source] # --8<-- [start:build-wheel-from-source]
#### Installation of vLLM #### Installation of vLLM
If you do not need to modify source code of vLLM, you can directly install the stable 0.20.0 release version of the library If you do not need to modify source code of vLLM, you can directly install the stable 0.21.0 release version of the library
```bash ```bash
uv pip install vllm==0.20.0 --torch-backend=auto uv pip install vllm==0.21.0 --torch-backend=auto
``` ```
The 0.20.0 release of vLLM ships CUDA 13.0-compatible binaries by default. If you need a different CUDA variant or want to reuse an existing PyTorch installation, build vLLM from source instead. The 0.21.0 release of vLLM ships CUDA 13.0-compatible binaries by default. If you need a different CUDA variant or want to reuse an existing PyTorch installation, build vLLM from source instead.
#### Installation of vLLM-Omni #### Installation of vLLM-Omni
Since vllm-omni is rapidly evolving, it's recommended to install it from source Since vllm-omni is rapidly evolving, it's recommended to install it from source
@@ -66,12 +66,12 @@ If you want to check, modify or debug with source code of vLLM, install the libr
```bash ```bash
git clone https://github.com/vllm-project/vllm.git git clone https://github.com/vllm-project/vllm.git
cd vllm cd vllm
git checkout v0.20.0 git checkout v0.21.0
``` ```
Set up environment variables to get pre-built wheels. If there are internet problems, just download the whl file manually. And set `VLLM_PRECOMPILED_WHEEL_LOCATION` as your local absolute path of whl file. Set up environment variables to get pre-built wheels. If there are internet problems, just download the whl file manually. And set `VLLM_PRECOMPILED_WHEEL_LOCATION` as your local absolute path of whl file.
```bash ```bash
#For CUDA 13.0 (the default for v0.20.0; the wheel filename has no `+cu130` suffix) #For CUDA 13.0 (the default for v0.21.0; the wheel filename has no `+cu130` suffix)
export VLLM_PRECOMPILED_WHEEL_LOCATION=https://github.com/vllm-project/vllm/releases/download/v0.20.0/vllm-0.20.0-cp38-abi3-manylinux_2_35_x86_64.whl export VLLM_PRECOMPILED_WHEEL_LOCATION=https://github.com/vllm-project/vllm/releases/download/v0.21.0/vllm-0.21.0-cp38-abi3-manylinux_2_35_x86_64.whl
``` ```
Install vllm with command below (If you have no existing PyTorch). Install vllm with command below (If you have no existing PyTorch).
```bash ```bash
@@ -102,7 +102,7 @@ docker run --runtime nvidia --gpus 2 \
--env "HF_TOKEN=$HF_TOKEN" \ --env "HF_TOKEN=$HF_TOKEN" \
-p 8091:8091 \ -p 8091:8091 \
--ipc=host \ --ipc=host \
vllm/vllm-omni:v0.18.0 \ vllm/vllm-omni:v0.21.0 \
--model Qwen/Qwen3-Omni-30B-A3B-Instruct --port 8091 --model Qwen/Qwen3-Omni-30B-A3B-Instruct --port 8091
``` ```
@@ -13,7 +13,7 @@ vLLM-Omni current recommends the steps in under setup through Docker Images.
vLLM-Omni is built based on vLLM. Please install it with command below. vLLM-Omni is built based on vLLM. Please install it with command below.
```bash ```bash
uv pip install vllm==0.20.0+rocm721 --extra-index-url https://wheels.vllm.ai/rocm/0.20.0/rocm721 uv pip install vllm==0.21.0+rocm722 --extra-index-url https://wheels.vllm.ai/rocm/0.21.0/rocm722
``` ```
#### Installation of vLLM-Omni #### Installation of vLLM-Omni
@@ -34,13 +34,13 @@ uv pip install onnxruntime-rocm
# --8<-- [start:build-wheel-from-source] # --8<-- [start:build-wheel-from-source]
#### Installation of vLLM #### Installation of vLLM
If you do not need to modify source code of vLLM, you can directly install the stable 0.20.0 release version of the library If you do not need to modify source code of vLLM, you can directly install the stable 0.21.0 release version of the library
```bash ```bash
uv pip install vllm==0.20.0+rocm721 --extra-index-url https://wheels.vllm.ai/rocm/0.20.0/rocm721 uv pip install vllm==0.21.0+rocm722 --extra-index-url https://wheels.vllm.ai/rocm/0.21.0/rocm722
``` ```
The pre-built 0.20.0 vLLM wheel targets ROCm 7.2.1. If you need a different ROCm stack or want to reuse an existing PyTorch installation, build vLLM from source instead. The pre-built 0.21.0 vLLM wheel targets ROCm 7.2.2. If you need a different ROCm stack or want to reuse an existing PyTorch installation, build vLLM from source instead.
#### Installation of vLLM-Omni #### Installation of vLLM-Omni
Since vllm-omni is rapidly evolving, it's recommended to install it from source Since vllm-omni is rapidly evolving, it's recommended to install it from source
@@ -58,7 +58,7 @@ If you want to check, modify or debug with source code of vLLM, install the libr
```bash ```bash
git clone https://github.com/vllm-project/vllm.git git clone https://github.com/vllm-project/vllm.git
cd vllm cd vllm
git checkout v0.20.0 git checkout v0.21.0
python3 -m pip install -r requirements/rocm.txt python3 -m pip install -r requirements/rocm.txt
python3 setup.py develop python3 setup.py develop
``` ```
@@ -130,7 +130,7 @@ docker run --rm \
-v ~/.cache/huggingface:/root/.cache/huggingface \ -v ~/.cache/huggingface:/root/.cache/huggingface \
--env "HF_TOKEN=$HF_TOKEN" \ --env "HF_TOKEN=$HF_TOKEN" \
-p 8091:8091 \ -p 8091:8091 \
vllm/vllm-omni-rocm:v0.20.0 \ vllm/vllm-omni-rocm:v0.21.0 \
--model Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 --model Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
``` ```
@@ -149,7 +149,7 @@ docker run --rm -it \
-v ~/.cache/huggingface:/root/.cache/huggingface \ -v ~/.cache/huggingface:/root/.cache/huggingface \
--env "HF_TOKEN=$HF_TOKEN" \ --env "HF_TOKEN=$HF_TOKEN" \
--entrypoint bash \ --entrypoint bash \
vllm/vllm-omni-rocm:v0.20.0 vllm/vllm-omni-rocm:v0.21.0
``` ```
# --8<-- [end:pre-built-images] # --8<-- [end:pre-built-images]
+3 -3
View File
@@ -19,10 +19,10 @@ uv venv --python 3.12 --seed
source .venv/bin/activate source .venv/bin/activate
# On CUDA # On CUDA
uv pip install vllm==0.20.0 --torch-backend=auto uv pip install vllm==0.21.0 --torch-backend=auto
# On ROCm # On ROCm
uv pip install vllm==0.20.0+rocm721 --extra-index-url https://wheels.vllm.ai/rocm/0.20.0/rocm721 uv pip install vllm==0.21.0+rocm721 --extra-index-url https://wheels.vllm.ai/rocm/0.21.0/rocm721
git clone https://github.com/vllm-project/vllm-omni.git git clone https://github.com/vllm-project/vllm-omni.git
cd vllm-omni cd vllm-omni
@@ -35,7 +35,7 @@ For additional installation methods — please see the [installation guide](inst
!!! note !!! note
It is important to install the same major & minor version of vLLM and vLLM Omni, otherwise things may not work as expected. If the versions are misaligned, you will see a warning when you import vLLM Omni. It is important to install the same major & minor version of vLLM and vLLM Omni, otherwise things may not work as expected. If the versions are misaligned, you will see a warning when you import vLLM Omni.
If you are seeing strange behavior with the `vllm` command not handling the `--omni` flag correctly, you most likely have a version mismatch with vLLM < `0.20.0` and vLLM Omni `0.20.0`, as vLLM Omni no longer hijacks the vLLM entrypoint. Updating vLLM should resolve this issue. If you are seeing strange behavior with the `vllm` command not handling the `--omni` flag correctly, you most likely have a version mismatch with vLLM < `0.21.0` and vLLM Omni `0.21.0`, as vLLM Omni no longer hijacks the vLLM entrypoint. Updating vLLM should resolve this issue.
## Offline Inference ## Offline Inference
+1 -1
View File
@@ -144,7 +144,7 @@ The following tables show which models support each feature:
| **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | | **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | | **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **LTX-2.3** | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | **LTX-2.3** | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Helios** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | | **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ❌ | | **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
@@ -8,15 +8,20 @@ vLLM-Omni supports online FP8 quantization for eligible diffusion Flash
Attention (FA) to reduce FA latency while keeping model weights in their Attention (FA) to reduce FA latency while keeping model weights in their
original dtype. original dtype.
This feature is configured through `kv_cache_dtype`, matching the option name This feature is configured through `diffusion_kv_cache_dtype` on
used by vLLM's language-model KV-cache quantization. In vLLM-Omni diffusion `OmniDiffusionConfig` (CLI: `--diffusion-kv-cache-dtype`). It is intentionally
pipelines, however, it is a runtime FA path: Q/K/V tensors are dynamically **not** the same as vLLM's `--kv-cache-dtype`, which controls autoregressive
quantized before the attention operator. It does not quantize model weights and language-model KV cache storage and defaults to `"auto"`. Diffusion FA
is separate from [FP8 W8A8](fp8.md), [Int8 W8A8](int8.md), or pre-quantized quantization uses the dedicated diffusion flags so omni serve does not inherit
checkpoint formats. that default.
If `kv_cache_dtype` is not set, behavior is unchanged and attention runs in the In vLLM-Omni diffusion pipelines, this is a runtime FA path: Q/K/V tensors are
native dtype. dynamically quantized before the attention operator. It does not quantize model
weights and is separate from [FP8 W8A8](fp8.md), [Int8 W8A8](int8.md), or
pre-quantized checkpoint formats.
If `diffusion_kv_cache_dtype` is not set, behavior is unchanged and attention
runs in the native dtype.
## Hardware Support ## Hardware Support
@@ -30,8 +35,8 @@ native dtype.
Legend: `✅` supported, `❌` unsupported. Legend: `✅` supported, `❌` unsupported.
FP8 FA is currently implemented only for the NPU Flash Attention backend. Other FP8 FA is currently implemented only for the NPU Flash Attention backend. Other
backends do not support `kv_cache_dtype="fp8"` for diffusion attention and fall backends do not support `diffusion_kv_cache_dtype="fp8"` for diffusion attention
back to native dtype execution. and fall back to native dtype execution.
## Model Type Support ## Model Type Support
@@ -40,7 +45,7 @@ back to native dtype execution.
| Model | Scope | Status | Notes | | Model | Scope | Status | Notes |
|-------|-------|--------|-------| |-------|-------|--------|-------|
| Wan2.2 | Eligible DiT full-attention FA on Ascend NPU | Tested | Compare quality and latency against a BF16 baseline before production use | | Wan2.2 | Eligible DiT full-attention FA on Ascend NPU | Tested | Compare quality and latency against a BF16 baseline before production use |
| Other diffusion models | Eligible DiT full-attention FA on Ascend NPU | Not tested | You can try `kv_cache_dtype="fp8"`; tune `kv_cache_skip_steps` and `kv_cache_skip_layers` when higher precision is needed | | Other diffusion models | Eligible DiT full-attention FA on Ascend NPU | Not tested | You can try `diffusion_kv_cache_dtype="fp8"`; tune `diffusion_kv_cache_skip_steps` and `diffusion_kv_cache_skip_layers` when higher precision is needed |
### Multi-Stage Omni/TTS Model (Qwen3-Omni, Qwen3-TTS) ### Multi-Stage Omni/TTS Model (Qwen3-Omni, Qwen3-TTS)
@@ -50,8 +55,8 @@ guide documents support.
### Multi-Stage Diffusion Model (BAGEL, GLM-Image) ### Multi-Stage Diffusion Model (BAGEL, GLM-Image)
Not tested. If the diffusion stage uses the same NPU Flash Attention backend, Not tested. If the diffusion stage uses the same NPU Flash Attention backend,
`kv_cache_dtype` may apply in theory; validate quality and latency for each `diffusion_kv_cache_dtype` may apply in theory; validate quality and latency for
stage and model. each stage and model.
## Configuration ## Configuration
@@ -67,15 +72,15 @@ python examples/offline_inference/image_to_video/image_to_video.py \
--num-inference-steps 4 \ --num-inference-steps 4 \
--ulysses-degree 4 \ --ulysses-degree 4 \
--vae-patch-parallel-size 4 \ --vae-patch-parallel-size 4 \
--kv-cache-dtype fp8 \ --diffusion-kv-cache-dtype fp8 \
--kv-cache-skip-steps "0,1" \ --diffusion-kv-cache-skip-steps "0,1" \
--kv-cache-skip-layers "0-2" --diffusion-kv-cache-skip-layers "0-2"
``` ```
Online serving: Online serving:
```bash ```bash
vllm serve <your-model> --omni --kv-cache-dtype fp8 vllm serve <your-model> --omni --diffusion-kv-cache-dtype fp8
``` ```
Stage config: Stage config:
@@ -86,18 +91,23 @@ stage_args:
stage_type: diffusion stage_type: diffusion
engine_args: engine_args:
model_stage: dit model_stage: dit
kv_cache_dtype: "fp8" diffusion_kv_cache_dtype: "fp8"
kv_cache_skip_steps: "0,1" diffusion_kv_cache_skip_steps: "0,1"
kv_cache_skip_layers: "0-2" diffusion_kv_cache_skip_layers: "0-2"
``` ```
Legacy YAML keys `kv_cache_dtype`, `kv_cache_skip_steps`, and
`kv_cache_skip_layers` are still accepted when constructing
`OmniDiffusionConfig` (for example via `from_kwargs`); prefer the `diffusion_*`
names for new configs.
## Parameters ## Parameters
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
|-----------|------|---------|-------------| |-----------|------|---------|-------------|
| `kv_cache_dtype` | str \| None | `None` | Set to `"fp8"` to enable dynamic FP8 FA on supported attention backends | | `diffusion_kv_cache_dtype` | str \| None | `None` | Set to `"fp8"` to enable dynamic FP8 FA on supported attention backends |
| `kv_cache_skip_steps` | str \| None | `None` | Denoising step selector to keep in native dtype, for example `"0,1,4-6"` | | `diffusion_kv_cache_skip_steps` | str \| None | `None` | Denoising step selector to keep in native dtype, for example `"0,1,4-6"` |
| `kv_cache_skip_layers` | str \| None | `None` | Transformer layer selector to keep in native dtype, for example `"0-2,10"` | | `diffusion_kv_cache_skip_layers` | str \| None | `None` | Transformer layer selector to keep in native dtype, for example `"0-2,10"` |
Selectors use comma-separated integers and inclusive ranges. Listed steps or Selectors use comma-separated integers and inclusive ranges. Listed steps or
layers skip FP8 FA; all other eligible full-attention forwards use the FP8 path. layers skip FP8 FA; all other eligible full-attention forwards use the FP8 path.
@@ -106,9 +116,9 @@ layers skip FP8 FA; all other eligible full-attention forwards use the FP8 path.
1. Compare generated images or videos against a BF16 baseline with the same 1. Compare generated images or videos against a BF16 baseline with the same
seed, prompt, resolution, frame count, and denoising steps. seed, prompt, resolution, frame count, and denoising steps.
2. Use `kv_cache_skip_steps` for denoising steps where quality is more 2. Use `diffusion_kv_cache_skip_steps` for denoising steps where quality is more
sensitive. sensitive.
3. Use `kv_cache_skip_layers` for transformer layers that show visible quality 3. Use `diffusion_kv_cache_skip_layers` for transformer layers that show visible quality
regressions. regressions.
4. Report both latency and quality results when enabling this option for a new 4. Report both latency and quality results when enabling this option for a new
model. For image or video models, include visual comparison and quantitative model. For image or video models, include visual comparison and quantitative
@@ -112,6 +112,7 @@ python end2end.py --modality text2img \
--additional-config '{"torchair_graph_config":{"enabled":true}}' --additional-config '{"torchair_graph_config":{"enabled":true}}'
``` ```
## Key Arguments ## Key Arguments
| Argument | Description | | Argument | Description |
@@ -123,16 +124,15 @@ python end2end.py --modality text2img \
| `--steps` | Number of diffusion inference steps for image generation. | | `--steps` | Number of diffusion inference steps for image generation. |
| `--guidance-scale` | Classifier-free guidance scale for image generation. | | `--guidance-scale` | Classifier-free guidance scale for image generation. |
| `--height`, `--width` | Output image size for `text2img`. | | `--height`, `--width` | Output image size for `text2img`. |
| `--bot-task` | Prompt behavior. `auto` selects the default from `--modality`; `think` adds `<think>`; `recaption` adds `<recaption>`; `vanilla` uses the text-to-image pretrain template. | | `--bot-task` | Override prompt mode. `none`, `think`, `recaption`, `think_recaption`, or `vanilla`. |
| `--sys-type` | Override the system prompt type, for example `en_unified` or `en_vanilla`. | | `--sys-type` | Override the system prompt type, for example `en_unified` or `en_vanilla`. |
| `--vae-use-tiling` | Enable VAE tiling for memory reduction. | | `--vae-use-tiling` | Enable VAE tiling for memory reduction. |
## Notes ## Notes
- `hunyuan_image3_ar.yaml` is a 4-card AR-only text/comprehension deploy. It sets `engine_output_type: text`, `final_output_type: text`, and text sampling defaults. - `hunyuan_image3_ar.yaml` is a 4-card AR-only text/comprehension deploy.
- `hunyuan_image3_dit.yaml` is a single-stage DiT deploy with `stage_id: 0`; it does not require stage 1 or a running AR stage. - `hunyuan_image3_dit.yaml` is a single-stage DiT deploy with `stage_id: 0`.
- The old HunyuanImage3 YAMLs under `model_executor/stage_configs/` and `platforms/*/stage_configs/` have been folded into the deploy YAMLs. - The old HunyuanImage3 YAMLs under `model_executor/stage_configs/` and `platforms/*/stage_configs/` have been folded into the deploy YAMLs.
- This PR does not keep the HunyuanImage3 AR-to-DiT KV reuse wiring. The deploy YAMLs describe the topology and platform settings only.
## Prompt Format ## Prompt Format
@@ -148,22 +148,8 @@ Assistant: {trigger_tag?}
- `<img>`: Placeholder for each input image (single token; expanded by the multimodal pipeline). - `<img>`: Placeholder for each input image (single token; expanded by the multimodal pipeline).
- Trigger tags: `<think>` for CoT and `<recaption>` for recaptioning, placed after `Assistant: `. - Trigger tags: `<think>` for CoT and `<recaption>` for recaptioning, placed after `Assistant: `.
- System prompt: Auto-selected based on task. - System prompt: Auto-selected from `task` and `bot_task`.
- `t2i_vanilla` is the only task that uses the bare pretrain template without chat structure. - `bot_task='vanilla'` with `task='t2i'` uses the bare pretrain template.
- The example composes the internal prompt task from `--modality` and `--bot-task`
before calling `prompt_utils`; for example, `img2text + think` becomes
`i2t_think` for prompt and stop-token lookup.
The shared `vllm_omni.diffusion.models.hunyuan_image3.prompt_utils.build_prompt_tokens()` The shared `vllm_omni.diffusion.models.hunyuan_image3.prompt_utils.build_prompt_tokens()`
helper handles segment-by-segment tokenization and matches HF `apply_chat_template`. helper handles segment-by-segment tokenization and matches HF `apply_chat_template`.
## FAQ
- **OOM errors**: Decrease `gpu_memory_utilization` in the deploy YAML, use a smaller `max_num_batched_tokens`, or enable VAE tiling with `--vae-use-tiling`.
- **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 |
@@ -1,16 +1,5 @@
""" """
HunyuanImage-3.0-Instruct unified end-to-end inference script. 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 argparse
@@ -19,18 +8,25 @@ import os
from pathlib import Path from pathlib import Path
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import ( from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
_TASK_PRESETS, MAX_IMAGES_PER_REQUEST,
build_prompt_tokens, build_prompt_tokens,
resolve_stop_token_ids, resolve_stop_token_ids,
resolve_sys_type,
) )
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniPromptType from vllm_omni.inputs.data import OmniPromptType
# Default deploy configs are absolute so this example works from any cwd.
_REPO_ROOT = Path(__file__).resolve().parents[3] _REPO_ROOT = Path(__file__).resolve().parents[3]
_DEFAULT_DEPLOY_CONFIG = str(_REPO_ROOT / "vllm_omni" / "deploy" / "hunyuan_image3.yaml") _DEFAULT_DEPLOY_CONFIG = str(_REPO_ROOT / "vllm_omni" / "deploy" / "hunyuan_image3.yaml")
_DEFAULT_AR_DEPLOY_CONFIG = str(_REPO_ROOT / "vllm_omni" / "deploy" / "hunyuan_image3_ar.yaml") _DEFAULT_AR_DEPLOY_CONFIG = str(_REPO_ROOT / "vllm_omni" / "deploy" / "hunyuan_image3_ar.yaml")
_MODALITY_TASK_MAP: dict[str, tuple[str, str | None]] = {
"text2img": ("t2i", "think"),
"img2img": ("it2i", "think"),
"img2text": ("i2t", None),
"text2text": ("t2t", None),
}
_MODALITY_DEFAULT_DEPLOY_CONFIG = { _MODALITY_DEFAULT_DEPLOY_CONFIG = {
"text2img": _DEFAULT_DEPLOY_CONFIG, "text2img": _DEFAULT_DEPLOY_CONFIG,
"img2img": _DEFAULT_DEPLOY_CONFIG, "img2img": _DEFAULT_DEPLOY_CONFIG,
@@ -45,73 +41,37 @@ _MODALITY_MODE = {
"text2text": "text-to-text", "text2text": "text-to-text",
} }
_MODALITY_TASK_MAP = {
"text2img": "t2i",
"img2img": "it2i",
"img2text": "i2t",
"text2text": "t2t",
}
def parse_args(): def parse_args():
parser = argparse.ArgumentParser(description="HunyuanImage-3.0-Instruct end-to-end inference.") parser = argparse.ArgumentParser(description="HunyuanImage-3.0-Instruct end-to-end inference.")
parser.add_argument( parser.add_argument("--model", default="tencent/HunyuanImage-3.0-Instruct", help="Model name or local path.")
"--model",
default="tencent/HunyuanImage-3.0-Instruct",
help="Model name or local path.",
)
parser.add_argument( parser.add_argument(
"--modality", "--modality",
default="text2img", default="text2img",
choices=["text2img", "img2img", "img2text", "text2text"], choices=list(_MODALITY_TASK_MAP),
help="Modality mode to control stage execution.",
) )
parser.add_argument("--prompts", nargs="+", default=None, help="Input text prompts.") parser.add_argument("--prompts", nargs="+", default=None, help="Input text prompts.")
parser.add_argument( parser.add_argument(
"--image-path", "--image-path",
type=str, type=str,
default=None, default=None,
help="Path to input image (for img2img/img2text).", help="Input image path(s) for img2img/img2text. Comma-separated for multi-image (up to 3).",
) )
parser.add_argument( parser.add_argument("--output", type=str, default=".", help="Output directory to save results.")
"--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("--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("--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("--seed", type=int, default=42, help="Random seed.")
parser.add_argument("--height", type=int, default=1024, help="Output image height.") parser.add_argument("--height", type=int, default=1024, help="Output image height.")
parser.add_argument("--width", type=int, default=1024, help="Output image width.") parser.add_argument("--width", type=int, default=1024, help="Output image width.")
parser.add_argument( parser.add_argument("--vae-use-tiling", action="store_true", help="Enable VAE tiling.")
"--vae-use-tiling",
action="store_true",
help="Enable VAE tiling for memory optimization.",
)
# Prompt configuration
parser.add_argument( parser.add_argument(
"--bot-task", "--bot-task",
type=str, type=str,
default="auto",
choices=["auto", "think", "recaption", "think_recaption", "vanilla"],
help=(
"Prompt behavior. 'auto' selects the default for the modality; "
"'think' adds <think>; 'recaption' adds <recaption>; "
"'vanilla' uses the t2i pretrain template."
),
)
parser.add_argument(
"--sys-type",
type=str,
default=None, default=None,
help="Override system prompt type (e.g. en_unified, en_vanilla).", choices=["none", "think", "recaption", "think_recaption", "vanilla"],
help="Override prompt mode. Default: auto from --modality.",
) )
parser.add_argument("--sys-type", type=str, default=None, help="Override system prompt type.")
# Omni init args
parser.add_argument("--deploy-config", type=str, default=None, help="Custom deploy YAML path.") parser.add_argument("--deploy-config", type=str, default=None, help="Custom deploy YAML path.")
parser.add_argument("--stage-configs-path", type=str, default=None, help="Custom legacy stage config YAML path.") parser.add_argument("--stage-configs-path", type=str, default=None, help="Custom legacy stage config YAML path.")
parser.add_argument("--log-stats", action="store_true", default=False) parser.add_argument("--log-stats", action="store_true", default=False)
@@ -157,22 +117,13 @@ def main():
os.makedirs(args.output, exist_ok=True) os.makedirs(args.output, exist_ok=True)
additional_config = parse_additional_config(args.additional_config) additional_config = parse_additional_config(args.additional_config)
# Determine task for prompt formatting from modality + bot behavior. task, default_bot_task = _MODALITY_TASK_MAP[args.modality]
task = _MODALITY_TASK_MAP[args.modality] if args.bot_task is None:
assert task is not None bot_task: str | None = default_bot_task
bot_task = args.bot_task elif args.bot_task == "none":
if bot_task != "auto": bot_task = None
task = task + "_" + bot_task else:
if task not in _TASK_PRESETS: bot_task = args.bot_task
valid_bot_tasks = {
"text2img": ["think", "recaption", "vanilla"],
"img2img": ["think", "recaption", "think_recaption"],
"img2text": ["auto"],
"text2text": ["auto"],
}[args.modality]
raise ValueError(
f"--bot-task {bot_task!r} is not supported for {args.modality}. Choose from: {valid_bot_tasks}"
)
if args.deploy_config is not None and args.stage_configs_path is not None: if args.deploy_config is not None and args.stage_configs_path is not None:
raise ValueError("--deploy-config and --stage-configs-path are mutually exclusive.") raise ValueError("--deploy-config and --stage-configs-path are mutually exclusive.")
@@ -182,13 +133,13 @@ def main():
if deploy_config is None and stage_configs_path is None: if deploy_config is None and stage_configs_path is None:
deploy_config = _MODALITY_DEFAULT_DEPLOY_CONFIG[args.modality] deploy_config = _MODALITY_DEFAULT_DEPLOY_CONFIG[args.modality]
# Build Omni
omni_kwargs = { omni_kwargs = {
"model": args.model, "model": args.model,
"vae_use_tiling": args.vae_use_tiling, "vae_use_tiling": args.vae_use_tiling,
"log_stats": args.log_stats, "log_stats": args.log_stats,
"init_timeout": args.init_timeout, "init_timeout": args.init_timeout,
"enforce_eager": args.enforce_eager, "enforce_eager": args.enforce_eager,
"mode": _MODALITY_MODE[args.modality],
} }
if additional_config is not None: if additional_config is not None:
@@ -197,71 +148,67 @@ def main():
omni_kwargs["deploy_config"] = deploy_config omni_kwargs["deploy_config"] = deploy_config
else: else:
omni_kwargs["stage_configs_path"] = stage_configs_path omni_kwargs["stage_configs_path"] = stage_configs_path
omni_kwargs["mode"] = _MODALITY_MODE[args.modality]
omni = Omni(**omni_kwargs) omni = Omni(**omni_kwargs)
# Prepare prompts
prompts = args.prompts or ["A cute cat"] prompts = args.prompts or ["A cute cat"]
if not prompts: input_images: list = []
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 args.modality in ("img2img", "img2text"):
if not args.image_path or not os.path.exists(args.image_path): if not args.image_path:
raise ValueError(f"--image-path required for {args.modality}, got: {args.image_path}") raise ValueError(f"--image-path required for {args.modality}, got: {args.image_path}")
from PIL import Image from PIL import Image
input_image = Image.open(args.image_path).convert("RGB") image_paths = [p.strip() for p in args.image_path.split(",") if p.strip()]
if len(image_paths) > MAX_IMAGES_PER_REQUEST:
raise ValueError(
f"--image-path accepts at most {MAX_IMAGES_PER_REQUEST} images for "
f"HunyuanImage-3.0 IT2I, got {len(image_paths)}: {args.image_path}"
)
for image_path in image_paths:
if not os.path.exists(image_path):
raise ValueError(f"Image path does not exist: {image_path}")
input_images.append(Image.open(image_path).convert("RGB"))
if not input_images:
raise ValueError(f"--image-path produced no usable paths: {args.image_path!r}")
# Load tokenizer for segment-wise prompt tokenization (matches HF
# apply_chat_template byte-for-byte; see build_prompt_tokens docstring).
from transformers import AutoTokenizer from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True) tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
mm_image_payload = (input_images[0] if len(input_images) == 1 else input_images) if input_images else None
# Format prompts
formatted_prompts: list[OmniPromptType] = [] formatted_prompts: list[OmniPromptType] = []
for p in prompts: for prompt in prompts:
result = build_prompt_tokens(p, tokenizer, task=task, sys_type=args.sys_type) build_kwargs: dict = {"task": task, "bot_task": bot_task, "sys_type": args.sys_type}
if input_images:
build_kwargs["num_images"] = len(input_images)
result = build_prompt_tokens(prompt, tokenizer, **build_kwargs)
token_ids = result.token_ids token_ids = result.token_ids
effective_sys_type = result.system_prompt_type effective_sys_type = args.sys_type or resolve_sys_type(bot_task)
# `prompt_token_ids` drives the AR stage (matches HF byte-for-byte).
# `prompt` and `use_system_prompt` are forwarded by ar2diffusion to
# the DiT stage so the diffusion pipeline can rebuild the same
# system prefix when constructing its model inputs.
prompt_dict: dict = { prompt_dict: dict = {
"prompt_token_ids": token_ids, "prompt_token_ids": token_ids,
"prompt": p, "prompt": prompt,
"use_system_prompt": effective_sys_type, "use_system_prompt": effective_sys_type,
} }
if args.modality == "text2img": if args.modality == "text2img":
prompt_dict["modalities"] = ["image"] prompt_dict["modalities"] = ["image"]
elif args.modality == "img2img": elif args.modality == "img2img":
prompt_dict["modalities"] = ["image"] prompt_dict["modalities"] = ["image"]
prompt_dict["multi_modal_data"] = {"image": input_image} prompt_dict["multi_modal_data"] = {"image": mm_image_payload}
prompt_dict["height"] = input_image.height prompt_dict["height"] = input_images[0].height
prompt_dict["width"] = input_image.width prompt_dict["width"] = input_images[0].width
elif args.modality == "img2text": elif args.modality == "img2text":
prompt_dict["modalities"] = ["text"] prompt_dict["modalities"] = ["text"]
prompt_dict["multi_modal_data"] = {"image": input_image} prompt_dict["multi_modal_data"] = {"image": mm_image_payload}
elif args.modality == "text2text": else:
prompt_dict["modalities"] = ["text"] prompt_dict["modalities"] = ["text"]
formatted_prompts.append(prompt_dict) formatted_prompts.append(prompt_dict)
# Build sampling params from defaults
params_list = list(omni.default_sampling_params_list) params_list = list(omni.default_sampling_params_list)
# Override diffusion params if applicable
from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.inputs.data import OmniDiffusionSamplingParams
ar_stop_token_ids = resolve_stop_token_ids(task=task, bot_task=bot_task, tokenizer=tokenizer) ar_stop_token_ids = resolve_stop_token_ids(task=task, bot_task=bot_task, tokenizer=tokenizer)
assert ar_stop_token_ids is not None
for sp in params_list: for sp in params_list:
if isinstance(sp, OmniDiffusionSamplingParams): if isinstance(sp, OmniDiffusionSamplingParams):
sp.num_inference_steps = args.steps sp.num_inference_steps = args.steps
@@ -269,13 +216,12 @@ def main():
sp.guidance_scale_provided = True sp.guidance_scale_provided = True
if args.seed is not None: if args.seed is not None:
sp.seed = args.seed sp.seed = args.seed
if args.modality in ("text2img",): if args.modality == "text2img":
sp.height = args.height sp.height = args.height
sp.width = args.width sp.width = args.width
elif hasattr(sp, "stop_token_ids"): elif hasattr(sp, "stop_token_ids"):
sp.stop_token_ids = ar_stop_token_ids sp.stop_token_ids = ar_stop_token_ids
# Print configuration
print(f"\n{'=' * 60}") print(f"\n{'=' * 60}")
print("HunyuanImage-3.0 Generation Configuration:") print("HunyuanImage-3.0 Generation Configuration:")
print(f" Model: {args.model}") print(f" Model: {args.model}")
@@ -300,13 +246,9 @@ def main():
print(f" Prompts: {prompts}") print(f" Prompts: {prompts}")
print(f"{'=' * 60}\n") print(f"{'=' * 60}\n")
# Generate
omni_outputs = list(omni.generate(prompts=formatted_prompts, sampling_params_list=params_list)) omni_outputs = list(omni.generate(prompts=formatted_prompts, sampling_params_list=params_list))
# Process outputs
img_idx = 0 img_idx = 0
for req_output in omni_outputs: for req_output in omni_outputs:
# Text output (AR stage or text-only)
ro = getattr(req_output, "request_output", None) ro = getattr(req_output, "request_output", None)
txt = "" txt = ""
if ro and getattr(ro, "outputs", None): if ro and getattr(ro, "outputs", None):
@@ -320,11 +262,9 @@ def main():
if txt: if txt:
print(f"[Output] Text:\n{txt}") print(f"[Output] Text:\n{txt}")
# Image output (DiT stage)
images = getattr(req_output, "images", None) images = getattr(req_output, "images", None)
if not images and ro and hasattr(ro, "images"): if not images and ro and hasattr(ro, "images"):
images = ro.images images = ro.images
if images: if images:
for j, img in enumerate(images): for j, img in enumerate(images):
save_path = os.path.join(args.output, f"output_{img_idx}_{j}.png") save_path = os.path.join(args.output, f"output_{img_idx}_{j}.png")
@@ -103,22 +103,22 @@ def parse_args() -> argparse.Namespace:
help="Sampling solver for Wan2.2 pipelines. Use 'euler' for Lightning/Distill setups.", help="Sampling solver for Wan2.2 pipelines. Use 'euler' for Lightning/Distill setups.",
) )
parser.add_argument( parser.add_argument(
"--kv-cache-dtype", "--diffusion-kv-cache-dtype",
type=str, type=str,
default=None, default=None,
help="Config-level KV cache dtype (e.g. float8_e4m3fn).", help="Diffusion attention KV cache dtype (e.g. float8_e4m3fn). Separate from vLLM --kv-cache-dtype.",
) )
parser.add_argument( parser.add_argument(
"--kv-cache-skip-steps", "--diffusion-kv-cache-skip-steps",
type=str, type=str,
default=None, default=None,
help="Config-level KV-cache quantization skip-step selector, e.g. '0-9,20,25-30'.", help="Diffusion KV-cache quantization skip-step selector, e.g. '0-9,20,25-30'.",
) )
parser.add_argument( parser.add_argument(
"--kv-cache-skip-layers", "--diffusion-kv-cache-skip-layers",
type=str, type=str,
default=None, default=None,
help="Config-level KV-cache quantization skip-layer selector, e.g. '0,1,4-8'.", help="Diffusion KV-cache quantization skip-layer selector, e.g. '0,1,4-8'.",
) )
parser.add_argument("--output", type=str, default="i2v_output.mp4", help="Path to save the video (mp4).") parser.add_argument("--output", type=str, default="i2v_output.mp4", help="Path to save the video (mp4).")
parser.add_argument("--fps", type=int, default=None, help="Frames per second for the output video.") parser.add_argument("--fps", type=int, default=None, help="Frames per second for the output video.")
@@ -334,9 +334,9 @@ def main():
vae_use_tiling=args.vae_use_tiling, vae_use_tiling=args.vae_use_tiling,
boundary_ratio=args.boundary_ratio, boundary_ratio=args.boundary_ratio,
flow_shift=args.flow_shift, flow_shift=args.flow_shift,
kv_cache_dtype=args.kv_cache_dtype, diffusion_kv_cache_dtype=args.diffusion_kv_cache_dtype,
kv_cache_skip_steps=args.kv_cache_skip_steps, diffusion_kv_cache_skip_steps=args.diffusion_kv_cache_skip_steps,
kv_cache_skip_layers=args.kv_cache_skip_layers, diffusion_kv_cache_skip_layers=args.diffusion_kv_cache_skip_layers,
enable_cpu_offload=args.enable_cpu_offload, enable_cpu_offload=args.enable_cpu_offload,
parallel_config=parallel_config, parallel_config=parallel_config,
enforce_eager=args.enforce_eager, enforce_eager=args.enforce_eager,
@@ -361,9 +361,9 @@ def main():
print(f" Inference steps: {args.num_inference_steps}") print(f" Inference steps: {args.num_inference_steps}")
print(f" Frames: {args.num_frames}") print(f" Frames: {args.num_frames}")
print(f" Solver: {args.sample_solver}") print(f" Solver: {args.sample_solver}")
print(f" kv_cache_dtype(config): {args.kv_cache_dtype}") print(f" diffusion_kv_cache_dtype(config): {args.diffusion_kv_cache_dtype}")
print(f" kv_cache_skip_steps(config): {args.kv_cache_skip_steps}") print(f" diffusion_kv_cache_skip_steps(config): {args.diffusion_kv_cache_skip_steps}")
print(f" kv_cache_skip_layers(config): {args.kv_cache_skip_layers}") print(f" diffusion_kv_cache_skip_layers(config): {args.diffusion_kv_cache_skip_layers}")
print( print(
f" Parallel configuration: cfg_parallel_size={args.cfg_parallel_size}," f" Parallel configuration: cfg_parallel_size={args.cfg_parallel_size},"
f" tensor_parallel_size={args.tensor_parallel_size}, vae_patch_parallel_size={args.vae_patch_parallel_size}" f" tensor_parallel_size={args.tensor_parallel_size}, vae_patch_parallel_size={args.vae_patch_parallel_size}"
@@ -73,13 +73,15 @@ python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \
``` ```
### Voice cloning ### Voice cloning
Pass a reference audio. Note that CosyVoice3's `--prompt-text` is a system-style prompt for the GPT stage, not a reference transcript: If `--ref-audio` is omitted, the script downloads the upstream
[`zero_shot_prompt.wav`](https://github.com/FunAudioLLM/CosyVoice/blob/main/asset/zero_shot_prompt.wav) from the CosyVoice repo into the current directory.
To use your own clip, pass `--ref-audio /path/to/reference.wav`, and modify `--prompt-text` correspondingly.
```bash ```bash
python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \ python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \
--model pretrained_models/Fun-CosyVoice3-0.5B \ --model pretrained_models/Fun-CosyVoice3-0.5B \
--tokenizer pretrained_models/Fun-CosyVoice3-0.5B/CosyVoice-BlankEN \ --tokenizer pretrained_models/Fun-CosyVoice3-0.5B/CosyVoice-BlankEN \
--ref-audio prompt.wav \ --ref-audio /path/to/reference.wav \
--prompt-text "You are a helpful assistant.<|endofprompt|>Testing my voices. Why should I not?" --prompt-text "You are a helpful assistant.<|endofprompt|>Trascript in your ref audio clip"
``` ```
### Streaming ### Streaming
@@ -317,44 +319,39 @@ pip install voxcpm soundfile
export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/VoxCPM/src export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/VoxCPM/src
``` ```
If the native VoxCPM `config.json` does not contain HF metadata such as `model_type`, prepare a persistent HF-compatible config directory and point the stage configs to it via `VLLM_OMNI_VOXCPM_HF_CONFIG_PATH`:
```bash
export VOXCPM_MODEL=/path/to/voxcpm-model
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)'
```
### Quick start ### Quick start
```bash ```bash
python examples/offline_inference/text_to_speech/voxcpm/end2end.py \ python examples/offline_inference/text_to_speech/voxcpm/end2end.py \
--model "$VOXCPM_MODEL" \ --model openbmb/VoxCPM-0.5B \
--text "This is a split-stage VoxCPM synthesis example running on vLLM Omni." --text "This is a split-stage VoxCPM synthesis example running on vLLM Omni."
``` ```
### Voice cloning ### Voice cloning
```bash ```bash
python examples/offline_inference/text_to_speech/voxcpm/end2end.py \ python examples/offline_inference/text_to_speech/voxcpm/end2end.py \
--model "$VOXCPM_MODEL" \ --model openbmb/VoxCPM-0.5B \
--text "This sentence is synthesized with a cloned voice." \ --text "This sentence is synthesized with a cloned voice." \
--ref-audio /path/to/reference.wav \ --ref-audio /path/to/reference.wav \
--ref-text "The exact transcript spoken in reference.wav." --ref-text "The exact transcript spoken in reference.wav."
``` ```
### Streaming ### Streaming
Pass the async-chunk stage config: Pass `--streaming` together with the legacy async-chunk stage config:
```bash ```bash
python examples/offline_inference/text_to_speech/voxcpm/end2end.py \ python examples/offline_inference/text_to_speech/voxcpm/end2end.py \
--model "$VOXCPM_MODEL" \ --model openbmb/VoxCPM-0.5B \
--streaming \
--stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \ --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." --text "This is a split-stage VoxCPM streaming example running on vLLM Omni."
``` ```
### Persistent HF config (optional)
The engine auto-patches a missing `model_type` in the checkpoint's `config.json` in-memory at startup (see `vllm_omni/engine/arg_utils.py`), so no manual setup is required.
If you'd rather maintain a persistent patched config dir (e.g. to share across runs or to add fields the auto-patcher doesn't set), export `VLLM_OMNI_VOXCPM_HF_CONFIG_PATH` to its location — both the bundled deploy and async-chunk yamls read it via `${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}`.
### Notes ### Notes
- `voxcpm.yaml` is the default non-streaming stage config; `voxcpm_async_chunk.yaml` enables streaming. - Non-streaming auto-loads `vllm_omni/deploy/voxcpm.yaml` from the model directory name / HF `model_type`. Streaming uses the legacy `voxcpm_async_chunk.yaml` passed via `--stage-configs-path`.
- Streaming is currently single-request oriented. - Streaming is currently single-request oriented.
- `--ref-text` must be the real transcript of `--ref-audio`; mismatched text degrades quality. - `--ref-text` must be the real transcript of `--ref-audio`; mismatched text degrades quality.
- For online serving, see the [VoxCPM section in the online hub](../../online_serving/text_to_speech/README.md#voxcpm). For benchmark reporting, see [`benchmarks/voxcpm`](../../../benchmarks/voxcpm/README.md). - For online serving, see the [VoxCPM section in the online hub](../../online_serving/text_to_speech/README.md#voxcpm). For benchmark reporting, see [`benchmarks/voxcpm`](../../../benchmarks/voxcpm/README.md).
@@ -2,18 +2,32 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import argparse import argparse
import os import os
import urllib.request
from pathlib import Path
import numpy as np import numpy as np
import soundfile as sf import soundfile as sf
from vllm import SamplingParams from vllm import SamplingParams
from vllm.assets.audio import AudioAsset
from vllm.multimodal.media.audio import load_audio from vllm.multimodal.media.audio import load_audio
from vllm_omni.engine.arg_utils import nullify_stage_engine_defaults from vllm_omni.engine.arg_utils import nullify_stage_engine_defaults
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
from vllm_omni.model_executor.models.cosyvoice3.config import CosyVoice3Config
from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer
from vllm_omni.model_executor.models.cosyvoice3.utils import extract_text_token from vllm_omni.model_executor.models.cosyvoice3.utils import extract_text_token
from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config
# Upstream zero-shot reference clip
ZERO_SHOT_PROMPT_URL = "https://raw.githubusercontent.com/FunAudioLLM/CosyVoice/main/asset/zero_shot_prompt.wav"
def _default_ref_audio() -> str:
# Download the upstream zero_shot_prompt.wav into the current dir
dest = Path("zero_shot_prompt.wav")
if not dest.exists() or dest.stat().st_size == 0:
print(f"Downloading default reference audio to {dest}")
urllib.request.urlretrieve(ZERO_SHOT_PROMPT_URL, dest)
return str(dest)
def run_e2e(): def run_e2e():
@@ -36,9 +50,15 @@ def run_e2e():
parser.add_argument( parser.add_argument(
"--prompt-text", "--prompt-text",
type=str, type=str,
default="You are a helpful assistant.<|endofprompt|>Testing my voices. Why should I not?", default="You are a helpful assistant.<|endofprompt|>希望你以后,能够做的比我还好呦!",
)
parser.add_argument(
"--ref-audio",
type=str,
default=None,
help="Path to reference audio for voice cloning. "
"If unset, downloads the upstream CosyVoice3 zero-shot prompt audio clip",
) )
parser.add_argument("--ref-audio", type=str, default="prompt.wav")
parser.add_argument( parser.add_argument(
"--tokenizer", "--tokenizer",
type=str, type=str,
@@ -66,24 +86,22 @@ def run_e2e():
sampling_cfg = {"top_p": 0.8, "top_k": 25, "eos_token_id": 6561 + 1} sampling_cfg = {"top_p": 0.8, "top_k": 25, "eos_token_id": 6561 + 1}
print("Model initialized. Preparing inputs...") print("Model initialized. Preparing inputs...")
if args.ref_audio: ref_audio_path = args.ref_audio or _default_ref_audio()
if not os.path.exists(args.ref_audio): if not os.path.exists(ref_audio_path):
raise FileNotFoundError(f"Audio file not found: {args.ref_audio}") raise FileNotFoundError(f"Audio file not found: {ref_audio_path}")
# Load at native sample rate # Load at native sample rate
audio_signal, sr = load_audio(args.ref_audio, sr=None) audio_signal, sr = load_audio(ref_audio_path, sr=None)
# Validate sample rate before processing (similar to original CosyVoice) # Validate sample rate before processing (similar to original CosyVoice)
min_sr = 16000 min_sr = 16000
if sr < min_sr: if sr < min_sr:
raise ValueError( raise ValueError(
f"Audio sample rate {sr} Hz is too low. " f"Audio sample rate {sr} Hz is too low. "
f"Minimum required: {min_sr} Hz. " f"Minimum required: {min_sr} Hz. "
f"Please provide audio with sample rate >= {min_sr} Hz." f"Please provide audio with sample rate >= {min_sr} Hz."
) )
audio_data = (audio_signal.astype(np.float32), sr) audio_data = (audio_signal.astype(np.float32), sr)
else:
audio_data = AudioAsset("mary_had_lamb").audio_and_sample_rate
prompts = { prompts = {
"prompt": args.text, "prompt": args.text,
@@ -11,12 +11,9 @@ import soundfile as sf
import torch import torch
from vllm.utils.argparse_utils import FlexibleArgumentParser from vllm.utils.argparse_utils import FlexibleArgumentParser
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni import AsyncOmni, Omni from vllm_omni import AsyncOmni, Omni
from vllm_omni.engine.arg_utils import nullify_stage_engine_defaults from vllm_omni.engine.arg_utils import nullify_stage_engine_defaults
DEFAULT_SYNC_STAGE_CONFIG = get_deploy_config_path("voxcpm.yaml")
def _build_prompt(args) -> dict[str, Any]: def _build_prompt(args) -> dict[str, Any]:
additional_information: dict[str, list[Any]] = { additional_information: dict[str, list[Any]] = {
@@ -59,10 +56,6 @@ def _extract_sample_rate(mm: dict[str, Any]) -> int:
return int(sr_raw) 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: 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_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"output_{request_id}.wav" output_path = output_dir / f"output_{request_id}.wav"
@@ -87,6 +80,7 @@ async def _run_streaming(args) -> Path:
omni = AsyncOmni( omni = AsyncOmni(
model=args.model, model=args.model,
stage_configs_path=args.stage_configs_path, stage_configs_path=args.stage_configs_path,
deploy_config=args.deploy_config,
log_stats=args.log_stats, log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout, stage_init_timeout=args.stage_init_timeout,
) )
@@ -133,6 +127,7 @@ def _run_sync(args) -> Path:
omni = Omni( omni = Omni(
model=args.model, model=args.model,
stage_configs_path=args.stage_configs_path, stage_configs_path=args.stage_configs_path,
deploy_config=args.deploy_config,
log_stats=args.log_stats, log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout, stage_init_timeout=args.stage_init_timeout,
) )
@@ -164,11 +159,27 @@ def _run_sync(args) -> Path:
def parse_args(): def parse_args():
parser = FlexibleArgumentParser(description="Minimal offline VoxCPM example for vLLM Omni.") 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("--model", type=str, required=True, help="Local VoxCPM model directory.")
parser.add_argument(
"--deploy-config",
type=str,
default=None,
help=("Override the deploy config path."),
)
parser.add_argument( parser.add_argument(
"--stage-configs-path", "--stage-configs-path",
type=str, type=str,
default=DEFAULT_SYNC_STAGE_CONFIG, default=None,
help=("Stage config path. Use voxcpm.yaml for non-streaming or voxcpm_async_chunk.yaml for streaming."), help=(
"Legacy stage_args yaml path. Required for streaming "
"(vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml); "
"leave unset for non-streaming to use the auto-resolved deploy config."
),
)
parser.add_argument(
"--streaming",
action="store_true",
default=False,
help="Stream audio chunks as they arrive via AsyncOmni.",
) )
parser.add_argument("--text", type=str, required=True, help="Input text for synthesis.") 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-audio", type=str, default=None, help="Reference audio path for voice cloning.")
@@ -194,9 +205,10 @@ def parse_args():
def main(args) -> None: def main(args) -> None:
route = "streaming" if _is_streaming_stage_config(args.stage_configs_path) else "sync" route = "streaming" if args.streaming else "sync"
print(f"Model: {args.model}") print(f"Model: {args.model}")
print(f"Stage config: {args.stage_configs_path}") print(f"Deploy config: {args.deploy_config or '<auto from HF model_type>'}")
print(f"Stage configs path: {args.stage_configs_path or '<unused>'}")
print(f"Route: {route}") print(f"Route: {route}")
if route == "streaming": if route == "streaming":
asyncio.run(_run_streaming(args)) asyncio.run(_run_streaming(args))
@@ -7,6 +7,12 @@ Usage:
# Send full text at once # Send full text at once
python streaming_speech_client.py --text "Hello world. How are you? I am fine." python streaming_speech_client.py --text "Hello world. How are you? I am fine."
# Pick a built-in speaker for CustomVoice models
python streaming_speech_client.py \
--text "打开电子枪,关闭扫描,切换到低倍模式。" \
--speaker "Serena" \
--language "Chinese"
# Simulate STT: send text word-by-word with delay # Simulate STT: send text word-by-word with delay
python streaming_speech_client.py \ python streaming_speech_client.py \
--text "Hello world. How are you? I am fine." \ --text "Hello world. How are you? I am fine." \
@@ -211,7 +217,9 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# Build session config (only include non-None values) # Build session config (only include non-None values).
# Server canonical field is `voice`; `speaker` is accepted as an alias
# (see StreamingSpeechSessionConfig.voice in protocol/audio.py).
config = {} config = {}
for key in [ for key in [
"model", "model",
@@ -0,0 +1,94 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from vllm.v1.request import RequestStatus
import vllm_omni.core.sched.omni_ar_scheduler as ar_sched_mod
import vllm_omni.core.sched.omni_generation_scheduler as gen_sched_mod
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class FakeInputCoordinator:
def __init__(self) -> None:
self.freed: list[str] = []
def free_finished_request(self, request_id: str) -> None:
self.freed.append(request_id)
class DummyRequest:
request_id = "req-free"
client_index = 0
additional_information = None
def is_finished(self) -> bool:
return True
@pytest.mark.parametrize(
("scheduler_cls", "scheduler_mod"),
[
(OmniARScheduler, ar_sched_mod),
(OmniGenerationScheduler, gen_sched_mod),
],
)
def test_finish_requests_cleans_input_coordinator_for_finished_ids(
monkeypatch: pytest.MonkeyPatch,
scheduler_cls,
scheduler_mod,
) -> None:
coordinator = FakeInputCoordinator()
scheduler = scheduler_cls.__new__(scheduler_cls)
scheduler.chunk_transfer_adapter = None
scheduler.input_coordinator = coordinator
def fake_finish_requests(self, request_ids, finished_status):
assert request_ids == ["req-a", "req-b"]
assert finished_status == RequestStatus.FINISHED_STOPPED
return [("req-a", 0), ("req-b", 1)]
monkeypatch.setattr(scheduler_mod.VLLMScheduler, "finish_requests", fake_finish_requests)
result = scheduler_cls.finish_requests(
scheduler,
["req-a", "req-b"],
RequestStatus.FINISHED_STOPPED,
)
assert result == [("req-a", 0), ("req-b", 1)]
assert coordinator.freed == ["req-a", "req-b"]
def test_ar_free_request_cleans_input_coordinator_on_normal_free() -> None:
coordinator = FakeInputCoordinator()
scheduler = OmniARScheduler.__new__(OmniARScheduler)
scheduler.input_coordinator = coordinator
scheduler._omits_kv_transfer_cache = {"req-free": True}
scheduler.encoder_cache_manager = SimpleNamespace(free=lambda request: None)
scheduler.finished_req_ids = set()
scheduler.finished_req_ids_dict = None
scheduler._new_prompt_len_snapshot = {"req-free": 3}
scheduler._connector_finished = lambda request: (False, None)
scheduler._should_transfer_kv_for_request = lambda request_id: False
scheduler._free_blocks = lambda request: None
result = OmniARScheduler._free_request(scheduler, DummyRequest())
assert result is None
assert coordinator.freed == ["req-free"]
assert scheduler._omits_kv_transfer_cache == {}
assert scheduler._new_prompt_len_snapshot == {}
def test_generation_free_request_cleans_input_coordinator(monkeypatch: pytest.MonkeyPatch) -> None:
coordinator = FakeInputCoordinator()
scheduler = OmniGenerationScheduler.__new__(OmniGenerationScheduler)
scheduler.input_coordinator = coordinator
def fake_free_request(self, request, delay_free_blocks=False):
assert delay_free_blocks is True
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for OmniSchedulingCoordinator (formerly ChunkSchedulingCoordinator). """Unit tests for OmniSchedulingCoordinator.
These tests use mock request objects and mock queues. They do not require These tests use mock request objects and mock queues. They do not require
GPU, vLLM runtime, or any connector. GPU, vLLM runtime, or any connector.
@@ -11,10 +11,12 @@ from __future__ import annotations
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
import torch
import vllm_omni.core.sched.omni_scheduling_coordinator as coord_mod import vllm_omni.core.sched.omni_scheduling_coordinator as coord_mod
from vllm_omni.core.sched.omni_scheduling_coordinator import ( from vllm_omni.core.sched.omni_scheduling_coordinator import (
ChunkSchedulingCoordinator,
OmniSchedulingCoordinator, OmniSchedulingCoordinator,
uses_qwen3_omni_full_payload_input_coordinator,
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -89,11 +91,55 @@ class MockQueue:
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
class TestFullPayloadCoordinatorSelection(unittest.TestCase):
def test_qwen3_omni_talker_and_code2wav_use_full_payload_input_coordinator(self):
for model_stage in ("talker", "code2wav"):
model_config = SimpleNamespace(
stage_id=1,
async_chunk=False,
model_arch="Qwen3OmniMoeForConditionalGeneration",
model_stage=model_stage,
)
self.assertTrue(uses_qwen3_omni_full_payload_input_coordinator(model_config))
def test_async_chunk_and_non_qwen3_omni_do_not_use_full_payload_input_coordinator(self):
cases = [
SimpleNamespace(
stage_id=1,
async_chunk=True,
model_arch="Qwen3OmniMoeForConditionalGeneration",
model_stage="talker",
),
SimpleNamespace(
stage_id=1,
async_chunk=False,
model_arch="Qwen3TTSForConditionalGeneration",
model_stage="code2wav",
),
SimpleNamespace(
stage_id=1,
async_chunk=False,
model_arch="Qwen2_5OmniForConditionalGeneration",
model_stage="talker",
),
SimpleNamespace(
stage_id=0,
async_chunk=False,
model_arch="Qwen3OmniMoeForConditionalGeneration",
model_stage="thinker",
),
]
for model_config in cases:
self.assertFalse(uses_qwen3_omni_full_payload_input_coordinator(model_config))
class TestChunkCoordinatorStateTransition(unittest.TestCase): class TestChunkCoordinatorStateTransition(unittest.TestCase):
"""Test 5: process_pending_chunks transitions WAITING_FOR_CHUNK → target.""" """Test 5: process_pending_chunks transitions WAITING_FOR_CHUNK → target."""
def test_ready_request_transitions_to_waiting(self): def test_ready_request_transitions_to_waiting(self):
coord = ChunkSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1, async_chunk=True) coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1, async_chunk=True)
req = _make_request("r1", status=RequestStatus.WAITING_FOR_CHUNK) req = _make_request("r1", status=RequestStatus.WAITING_FOR_CHUNK)
waiting = MockQueue([req]) waiting = MockQueue([req])
@@ -110,7 +156,7 @@ class TestChunkCoordinatorStateTransition(unittest.TestCase):
self.assertIn("r1", coord.requests_with_ready_chunks) self.assertIn("r1", coord.requests_with_ready_chunks)
def test_non_ready_stays_waiting_for_chunk(self): def test_non_ready_stays_waiting_for_chunk(self):
coord = ChunkSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1, async_chunk=True) coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1, async_chunk=True)
req = _make_request("r1", status=RequestStatus.WAITING_FOR_CHUNK) req = _make_request("r1", status=RequestStatus.WAITING_FOR_CHUNK)
waiting = MockQueue([req]) waiting = MockQueue([req])
@@ -126,7 +172,7 @@ class TestChunkCoordinatorStateTransition(unittest.TestCase):
self.assertEqual(req.status, RequestStatus.WAITING_FOR_CHUNK) self.assertEqual(req.status, RequestStatus.WAITING_FOR_CHUNK)
def test_stage_0_is_noop(self): def test_stage_0_is_noop(self):
coord = ChunkSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=0) coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=0)
req = _make_request("r1") req = _make_request("r1")
waiting = MockQueue([req]) waiting = MockQueue([req])
running: list = [] running: list = []
@@ -144,7 +190,7 @@ class TestChunkCoordinatorRestoreQueues(unittest.TestCase):
"""Test 6: restore_queues returns waiting-for-chunk requests.""" """Test 6: restore_queues returns waiting-for-chunk requests."""
def test_restore(self): def test_restore(self):
coord = ChunkSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1)
r1 = _make_request("r1") r1 = _make_request("r1")
r2 = _make_request("r2") r2 = _make_request("r2")
@@ -166,7 +212,7 @@ class TestChunkCoordinatorFinishedSignal(unittest.TestCase):
"""Test 8: chunk_finished_req_ids → finished_requests.""" """Test 8: chunk_finished_req_ids → finished_requests."""
def test_finished_signal(self): def test_finished_signal(self):
coord = ChunkSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1, async_chunk=True) coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1, async_chunk=True)
req = _make_request("r1", status=RequestStatus.WAITING_FOR_CHUNK) req = _make_request("r1", status=RequestStatus.WAITING_FOR_CHUNK)
waiting = MockQueue([req]) waiting = MockQueue([req])
@@ -187,7 +233,7 @@ class TestChunkCoordinatorUpdateRequestMetadata(unittest.TestCase):
def test_ar_mode_no_longer_sets_additional_information(self): def test_ar_mode_no_longer_sets_additional_information(self):
"""AR mode only processes scheduling metadata, not full payloads.""" """AR mode only processes scheduling metadata, not full payloads."""
coord = ChunkSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1)
req = _make_request("r1") req = _make_request("r1")
requests = {"r1": req} requests = {"r1": req}
@@ -204,10 +250,14 @@ class TestChunkCoordinatorUpdateRequestMetadata(unittest.TestCase):
self.assertIsNone(getattr(req, "additional_information", None)) self.assertIsNone(getattr(req, "additional_information", None))
def test_generation_mode(self): def test_generation_mode(self):
coord = ChunkSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1)
req = _make_request("r1") req = _make_request("r1")
req.prompt_token_ids = [0, 0, 0] req.prompt_token_ids = [0, 0, 0]
req.num_prompt_tokens = 3
req.num_computed_tokens = 3
req._all_token_ids = [0, 0, 0, 99]
req._output_token_ids = [99]
requests = {"r1": req} requests = {"r1": req}
request_metadata = { request_metadata = {
@@ -220,16 +270,61 @@ class TestChunkCoordinatorUpdateRequestMetadata(unittest.TestCase):
coord.update_request_metadata(requests, request_metadata, model_mode="generation") coord.update_request_metadata(requests, request_metadata, model_mode="generation")
self.assertEqual(req.prompt_token_ids, [10, 20, 30]) self.assertEqual(req.prompt_token_ids, [10, 20, 30])
self.assertEqual(req.num_prompt_tokens, 3)
self.assertEqual(req.num_computed_tokens, 0) self.assertEqual(req.num_computed_tokens, 0)
self.assertEqual(req._all_token_ids, [10, 20, 30])
self.assertEqual(req._output_token_ids, [])
self.assertIsNone(req.additional_information) self.assertIsNone(req.additional_information)
self.assertEqual(req._omni_initial_model_buffer, {"meta": {"left_context_size": 25}}) self.assertEqual(req._omni_initial_model_buffer, {"meta": {"left_context_size": 25}})
def test_generation_mode_flattens_tensor_code_predictor_codes(self):
coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1)
req = _make_request("r1")
req.prompt_token_ids = [9]
req.num_prompt_tokens = 1
req._all_token_ids = [9, 8]
req._output_token_ids = [8]
requests = {"r1": req}
coord.update_request_metadata(
requests,
{"r1": {"code_predictor_codes": torch.tensor([[1, 2, 3]], dtype=torch.long)}},
model_mode="generation",
)
self.assertEqual(req.prompt_token_ids, [1, 2, 3])
self.assertEqual(req.num_prompt_tokens, 3)
self.assertEqual(req._all_token_ids, [1, 2, 3])
self.assertEqual(req._output_token_ids, [])
def test_generation_mode_flattens_nested_code_predictor_codes(self):
coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1)
req = _make_request("r1")
req.prompt_token_ids = [9]
req.num_prompt_tokens = 1
req._all_token_ids = [9, 8]
req._output_token_ids = [8]
requests = {"r1": req}
coord.update_request_metadata(
requests,
{"r1": {"code_predictor_codes": [[1, 2], [3, 4]]}},
model_mode="generation",
)
self.assertEqual(req.prompt_token_ids, [1, 2, 3, 4])
self.assertEqual(req.num_prompt_tokens, 4)
self.assertEqual(req._all_token_ids, [1, 2, 3, 4])
self.assertEqual(req._output_token_ids, [])
class TestChunkCoordinatorPostprocess(unittest.TestCase): class TestChunkCoordinatorPostprocess(unittest.TestCase):
"""Test postprocess_scheduler_output clears ready chunks.""" """Test postprocess_scheduler_output clears ready chunks."""
def test_clear_ready(self): def test_clear_ready(self):
coord = ChunkSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1)
coord.requests_with_ready_chunks = {"r1", "r2"} coord.requests_with_ready_chunks = {"r1", "r2"}
new_req = SimpleNamespace(req_id="r1") new_req = SimpleNamespace(req_id="r1")
@@ -366,6 +461,33 @@ class TestWaitingForInputTransition(unittest.TestCase):
self.assertEqual(len(coord.pending_input_registrations), 1) self.assertEqual(len(coord.pending_input_registrations), 1)
self.assertEqual(coord.pending_input_registrations[0].request_id, "r1") self.assertEqual(coord.pending_input_registrations[0].request_id, "r1")
def test_idle_cycles_retain_received_marker_before_request_appears(self):
coord = OmniSchedulingCoordinator(
scheduler_max_num_seqs=10,
stage_id=1,
async_chunk=False,
)
coord._full_payload_input_received.add("late")
coord.finished_requests.add("late")
waiting = MockQueue()
running: list = []
coord.process_pending_full_payload_inputs(waiting, running, stage_recv_req_ids=set())
self.assertIn("late", coord._full_payload_input_received)
self.assertIn("late", coord.finished_requests)
late_req = _make_request("late", status=RequestStatus.WAITING)
waiting.add_request(late_req)
coord.process_pending_full_payload_inputs(waiting, running, stage_recv_req_ids=set())
self.assertEqual(late_req.status, RequestStatus.WAITING)
self.assertEqual(coord.pending_input_registrations, [])
self.assertIn("late", coord._full_payload_input_received)
self.assertIn("late", coord.finished_requests)
class TestTimeoutDetection(unittest.TestCase): class TestTimeoutDetection(unittest.TestCase):
"""Regression tests for orphaned pending-recv timeout detection. """Regression tests for orphaned pending-recv timeout detection.
@@ -508,14 +630,18 @@ class TestTimeoutDetection(unittest.TestCase):
self.assertEqual(len(coord._waiting_for_input), 0) self.assertEqual(len(coord._waiting_for_input), 0)
def test_free_finished_request_clears_waiting_since(self): def test_free_finished_request_clears_waiting_since(self):
"""free_finished_request clears _waiting_since.""" """free_finished_request clears coordinator lifecycle markers."""
coord = OmniSchedulingCoordinator( coord = OmniSchedulingCoordinator(
scheduler_max_num_seqs=10, scheduler_max_num_seqs=10,
stage_id=1, stage_id=1,
) )
coord._waiting_since["r1"] = 0.0 coord._waiting_since["r1"] = 0.0
coord._full_payload_input_received.add("r1")
coord.finished_requests.add("r1")
coord.free_finished_request("r1") coord.free_finished_request("r1")
self.assertNotIn("r1", coord._waiting_since) self.assertNotIn("r1", coord._waiting_since)
self.assertNotIn("r1", coord._full_payload_input_received)
self.assertNotIn("r1", coord.finished_requests)
def test_timeout_from_running_queue_full_lifecycle(self): def test_timeout_from_running_queue_full_lifecycle(self):
"""End-to-end: request from running → WAITING_FOR_CHUNK → restore → """End-to-end: request from running → WAITING_FOR_CHUNK → restore →
@@ -10,16 +10,16 @@
"dataset_name": "random", "dataset_name": "random",
"backend": "openai-chat-omni", "backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions", "endpoint": "/v1/chat/completions",
"num_prompts": [4, 16, 32, 64], "num_prompts": [4, 16, 32, 64, 128],
"max_concurrency": [1, 4, 8, 16], "max_concurrency": [1, 4, 8, 16, 32],
"random_input_len": 2500, "random_input_len": 2500,
"random_output_len": 900, "random_output_len": 900,
"ignore_eos": true, "ignore_eos": true,
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": { "baseline": {
"mean_ttft_ms": [1000, 3000, 5000, 7000], "mean_ttft_ms": [1000, 3000, 5000, 7000, 9000],
"mean_audio_ttfp_ms": [1000, 3000, 5000, 7000], "mean_audio_ttfp_ms": [1000, 3000, 5000, 7000, 9000],
"mean_audio_rtf": [0.2, 0.35, 0.6, 0.85] "mean_audio_rtf": [0.2, 0.35, 0.6, 0.85, 0.9]
} }
}, },
{ {
+26 -19
View File
@@ -2,16 +2,19 @@
{ {
"test_name": "test_qwen3_tts_base", "test_name": "test_qwen3_tts_base",
"server_params": { "server_params": {
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base" "model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"stage_overrides": {
"1": {"devices": "1"}
}
}, },
"benchmark_params": [ "benchmark_params": [
{ {
"task": "voice_clone", "task": "voice_clone",
"eval_phase": "latency", "eval_phase": "latency",
"enabled": false,
"dataset_name": "seed-tts", "dataset_name": "seed-tts",
"backend": "openai-audio-speech", "backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech", "endpoint": "/v1/audio/speech",
"dataset_path": "linyueqian/seed-tts-eval-subset",
"num_prompts": [20], "num_prompts": [20],
"max_concurrency": [1], "max_concurrency": [1],
"seed_tts_locale": "en", "seed_tts_locale": "en",
@@ -24,18 +27,18 @@
{ {
"task": "voice_clone", "task": "voice_clone",
"eval_phase": "throughput", "eval_phase": "throughput",
"enabled": false,
"dataset_name": "seed-tts", "dataset_name": "seed-tts",
"backend": "openai-audio-speech", "backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech", "endpoint": "/v1/audio/speech",
"num_prompts": [80], "dataset_path": "linyueqian/seed-tts-eval-subset",
"max_concurrency": [8], "num_prompts": [80, 128, 128],
"max_concurrency": [8, 16, 64],
"seed_tts_locale": "en", "seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": { "baseline": {
"median_audio_ttfp_ms": [3500], "median_audio_ttfp_ms": [3500, 30000, 7000],
"median_audio_rtf": [0.75], "median_audio_rtf": [0.75, 10.0, 1.5],
"audio_throughput": [10.0] "audio_throughput": [10.0, 1.0, 40.0]
} }
}, },
{ {
@@ -45,6 +48,7 @@
"dataset_name": "seed-tts", "dataset_name": "seed-tts",
"backend": "openai-audio-speech", "backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech", "endpoint": "/v1/audio/speech",
"dataset_path": "linyueqian/seed-tts-eval-subset",
"num_prompts": [200], "num_prompts": [200],
"max_concurrency": [4], "max_concurrency": [4],
"seed_tts_locale": "en", "seed_tts_locale": "en",
@@ -59,7 +63,10 @@
{ {
"test_name": "test_qwen3_tts_customvoice", "test_name": "test_qwen3_tts_customvoice",
"server_params": { "server_params": {
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" "model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"stage_overrides": {
"1": {"devices": "1"}
}
}, },
"benchmark_params": [ "benchmark_params": [
{ {
@@ -86,15 +93,15 @@
"backend": "openai-audio-speech", "backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech", "endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke", "dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [80], "num_prompts": [80, 128, 128],
"max_concurrency": [8], "max_concurrency": [8, 16, 64],
"seed_tts_locale": "en", "seed_tts_locale": "en",
"extra_body": {"voice": "Vivian", "language": "English", "task_type": "CustomVoice"}, "extra_body": {"voice": "Vivian", "language": "English", "task_type": "CustomVoice"},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": { "baseline": {
"median_audio_ttfp_ms": [1500], "median_audio_ttfp_ms": [1500, 30000, 3000],
"median_audio_rtf": [0.30], "median_audio_rtf": [0.30, 10.0, 0.60],
"audio_throughput": [30.0] "audio_throughput": [30.0, 1.0, 100.0]
} }
}, },
{ {
@@ -157,15 +164,15 @@
"backend": "openai-audio-speech", "backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech", "endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_design", "dataset_path": "benchmarks/build_dataset/seed_tts_design",
"num_prompts": [80], "num_prompts": [80, 128, 128],
"max_concurrency": [8], "max_concurrency": [8, 16, 64],
"seed_tts_locale": "en", "seed_tts_locale": "en",
"extra_body": {"task_type": "VoiceDesign", "language": "English"}, "extra_body": {"task_type": "VoiceDesign", "language": "English"},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": { "baseline": {
"median_audio_ttfp_ms": [1500], "median_audio_ttfp_ms": [1500, 30000, 3500],
"median_audio_rtf": [0.35], "median_audio_rtf": [0.35, 10.0, 0.70],
"audio_throughput": [25.0] "audio_throughput": [25.0, 1.0, 80.0]
} }
}, },
{ {
@@ -394,6 +394,9 @@ class TestAttentionInitUsesCurrentDiffusionConfig:
per_role={"cross": AttentionSpec(backend="TORCH_SDPA", extra={"block_size": 128})}, per_role={"cross": AttentionSpec(backend="TORCH_SDPA", extra={"block_size": 128})},
), ),
parallel_config=SimpleNamespace(ring_degree=2), parallel_config=SimpleNamespace(ring_degree=2),
diffusion_kv_cache_dtype=None,
diffusion_kv_cache_skip_step_indices=None,
diffusion_kv_cache_skip_layer_indices=None,
) )
with set_current_diffusion_config(od_config): with set_current_diffusion_config(od_config):
@@ -0,0 +1,255 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Multi-image input regression for HunyuanImage3 IT2I prompt construction.
The official HunyuanImage-3.0-Instruct supports up to 3 reference images
per IT2I request ("Multi-Image Fusion"; see hunyuan3.0_ins/README.md
section 200-216 + line 500). Each cond image becomes its own user-role
message and `apply_general_template` concatenates successive user
messages back-to-back inside ONE user_prefix/user_suffix wrap (see
hunyuan3.0_ins/tokenization_hunyuan_image_3.py:1399-1400, 1499-1515).
The lightweight `<img>` + `multi_modal_data` builder used by the example
flow must match that contract: N consecutive `<img>` placeholders sit
between `User: ` and the user prompt, with no separator between them.
This file pins:
1. N consecutive `<img>` placeholders for N=1/2/3 across both the
string builder (`build_prompt`) and the token builder
(`build_prompt_tokens`).
2. The N=1 path stays bit-identical to the legacy single-image builder
(regression guard so default callers don't notice).
3. N=2 / N=3 token sequences differ from N=1 by exactly (N-1) extra
`<img>` ids inserted between `User: ` and `user_prompt`.
4. Validation: N<1 and N>3 raise ValueError (hard cap N<=3 mirrors
official upstream).
5. Text-only tasks ignore `num_images` (no validation, no extra ids).
"""
from __future__ import annotations
import os
import pytest
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
MAX_IMAGES_PER_REQUEST,
build_prompt,
build_prompt_tokens,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class FakeTokenizer:
"""Recording fake tokenizer mirroring the one in test_prompt_utils.
Special token ids: `<|startoftext|>`=1, `<img>`=2, `<think>`=3,
`<recaption>`=4. encode() returns one id per character starting at
100, so substring-position assertions are stable.
"""
SPECIAL = {
"<|startoftext|>": 1,
"<img>": 2,
"<think>": 3,
"<recaption>": 4,
}
def __init__(self) -> None:
self.encode_calls: list[str] = []
def convert_tokens_to_ids(self, tok: str) -> int:
return self.SPECIAL.get(tok, 0)
def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
self.encode_calls.append(text)
return list(range(100, 100 + len(text)))
_IMAGE_TASK_COMBOS = (
("i2t", None),
("it2i", "think"),
("it2i", "recaption"),
)
_TEXT_ONLY_TASK_COMBOS = (("t2t", None),)
# -------------------- string builder --------------------
@pytest.mark.parametrize("task,bot_task", _IMAGE_TASK_COMBOS)
@pytest.mark.parametrize("num_images", [1, 2, 3])
def test_build_prompt_emits_N_consecutive_img_placeholders(task: str, bot_task: str | None, num_images: int):
"""N=1/2/3 -> exactly N `<img>` substrings appear consecutively
between `User: ` and the user prompt, with no separator between them."""
s = build_prompt("HELLO", task=task, bot_task=bot_task, num_images=num_images)
assert s.count("<img>") == num_images, (
f"task={task} bot_task={bot_task} num_images={num_images}: expected {num_images} <img> "
f"placeholders, found {s.count('<img>')} -- prompt was: {s!r}"
)
# All `<img>` placeholders must form one contiguous run "<img><img>..."
# immediately after `User: ` and before HELLO.
user_idx = s.index("User: ") + len("User: ")
hello_idx = s.index("HELLO")
between = s[user_idx:hello_idx]
assert between == "<img>" * num_images, (
f"region between `User: ` and prompt must be exactly N <img> placeholders; got {between!r}"
)
def test_build_prompt_default_num_images_matches_legacy():
"""num_images default = 1 must produce a string bit-identical to the
pre-multi-image behavior (single `<img>` placeholder)."""
legacy = build_prompt("HELLO", task="it2i", bot_task="think")
explicit = build_prompt("HELLO", task="it2i", bot_task="think", num_images=1)
assert legacy == explicit, "default num_images=1 must match legacy single-image output"
# -------------------- token builder --------------------
@pytest.mark.parametrize("task,bot_task", _IMAGE_TASK_COMBOS)
def test_build_prompt_tokens_inserts_N_img_ids(task: str, bot_task: str | None):
"""N=1/2/3 -> the resulting id sequence contains exactly N copies of
img_id (=2) sitting consecutively after the `User: ` segment."""
tok = FakeTokenizer()
ids_n1 = build_prompt_tokens("hi", tok, task=task, bot_task=bot_task, num_images=1).token_ids
tok = FakeTokenizer()
ids_n2 = build_prompt_tokens("hi", tok, task=task, bot_task=bot_task, num_images=2).token_ids
tok = FakeTokenizer()
ids_n3 = build_prompt_tokens("hi", tok, task=task, bot_task=bot_task, num_images=3).token_ids
assert ids_n1.count(2) == 1
assert ids_n2.count(2) == 2
assert ids_n3.count(2) == 3
# Each additional image must extend the sequence by exactly one img_id,
# not shift other tokens around.
assert len(ids_n2) == len(ids_n1) + 1
assert len(ids_n3) == len(ids_n1) + 2
# The img_ids must be CONSECUTIVE (no other token between successive
# `<img>` placeholders -- mirrors the official `process_successive_message`
# wrapping where successive user messages share one user_prefix/suffix).
for ids, n in [(ids_n2, 2), (ids_n3, 3)]:
first = ids.index(2)
for k in range(n):
assert ids[first + k] == 2, (
f"img_ids must be consecutive starting at position {first} for n={n}; got {ids[first : first + n]!r}"
)
def test_build_prompt_tokens_default_num_images_matches_legacy():
"""num_images default = 1 must produce the same id sequence as
omitting the parameter (regression guard for existing single-image
callers)."""
tok_a = FakeTokenizer()
legacy = build_prompt_tokens("hi", tok_a, task="it2i", bot_task="think").token_ids
tok_b = FakeTokenizer()
explicit = build_prompt_tokens("hi", tok_b, task="it2i", bot_task="think", num_images=1).token_ids
assert legacy == explicit
# Also: encode() must have been called on the same set of segments,
# so segment boundaries are preserved.
assert tok_a.encode_calls == tok_b.encode_calls
# -------------------- validation --------------------
@pytest.mark.parametrize("task,bot_task", _IMAGE_TASK_COMBOS)
@pytest.mark.parametrize("bad", [0, -1, MAX_IMAGES_PER_REQUEST + 1, 99])
def test_build_prompt_rejects_out_of_range_num_images(task: str, bot_task: str | None, bad: int):
with pytest.raises(ValueError, match="num_images must be in"):
build_prompt("hi", task=task, bot_task=bot_task, num_images=bad)
with pytest.raises(ValueError, match="num_images must be in"):
build_prompt_tokens("hi", FakeTokenizer(), task=task, bot_task=bot_task, num_images=bad)
@pytest.mark.parametrize("task,bot_task", _TEXT_ONLY_TASK_COMBOS)
@pytest.mark.parametrize("num_images", [0, 1, 2, 99])
def test_text_only_tasks_ignore_num_images(task: str, bot_task: str | None, num_images: int):
"""Validation only kicks in for image-input tasks; t2t et al. accept
any num_images and emit zero `<img>` placeholders."""
s = build_prompt("hi", task=task, bot_task=bot_task, num_images=num_images)
assert "<img>" not in s
ids = build_prompt_tokens("hi", FakeTokenizer(), task=task, bot_task=bot_task, num_images=num_images).token_ids
assert 2 not in ids
# -------------------- real HF tokenizer regression --------------------
_HUNYUAN_MODEL_ID = "tencent/HunyuanImage-3.0-Instruct"
def _hf_cached(model_id: str) -> bool:
hf_home = os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface")
snap_dir = os.path.join(hf_home, "hub", f"models--{model_id.replace('/', '--')}", "snapshots")
return os.path.isdir(snap_dir) and any(os.scandir(snap_dir))
@pytest.mark.skipif(not _hf_cached(_HUNYUAN_MODEL_ID), reason=f"{_HUNYUAN_MODEL_ID} tokenizer not in HF cache")
@pytest.mark.parametrize("num_images", [1, 2, 3])
def test_real_tokenizer_emits_n_consecutive_img_ids(num_images: int):
"""Real `AutoTokenizer.from_pretrained(...)` (the production path) must
encode N=1/2/3 prompts to a sequence with exactly N consecutive `<img>`
token-ids in the right place — proves the placeholder layout from
`build_prompt_tokens` survives a real BPE tokenizer, not just FakeTokenizer.
"""
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(_HUNYUAN_MODEL_ID, trust_remote_code=True)
img_id = tok.convert_tokens_to_ids("<img>")
assert img_id is not None and img_id >= 0, f"<img> not in tokenizer vocab; got id={img_id}"
ids = build_prompt_tokens("hi", tok, task="it2i", bot_task="think", num_images=num_images).token_ids
# Exactly N copies of <img> id, all consecutive.
img_positions = [i for i, x in enumerate(ids) if x == img_id]
assert len(img_positions) == num_images, (
f"expected {num_images} <img> ids, got {len(img_positions)} at positions {img_positions}"
)
assert img_positions == list(range(img_positions[0], img_positions[0] + num_images)), (
f"<img> ids must be contiguous; got positions {img_positions}"
)
@pytest.mark.skipif(not _hf_cached(_HUNYUAN_MODEL_ID), reason=f"{_HUNYUAN_MODEL_ID} tokenizer not in HF cache")
def test_real_tokenizer_n_plus_one_extends_by_exactly_one_img_id():
"""Going from N to N+1 images must extend the encoded id sequence by
exactly one extra `<img>` token-id and shift nothing else. Catches
accidental separator tokens between successive `<img>` placeholders
that a FakeTokenizer (deterministic encode) can't surface."""
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(_HUNYUAN_MODEL_ID, trust_remote_code=True)
img_id = tok.convert_tokens_to_ids("<img>")
ids_n1 = build_prompt_tokens("hi", tok, task="it2i", bot_task="think", num_images=1).token_ids
ids_n2 = build_prompt_tokens("hi", tok, task="it2i", bot_task="think", num_images=2).token_ids
ids_n3 = build_prompt_tokens("hi", tok, task="it2i", bot_task="think", num_images=3).token_ids
assert len(ids_n2) == len(ids_n1) + 1, f"N=2 should be N=1 + 1 token; got {len(ids_n2)} vs {len(ids_n1)}"
assert len(ids_n3) == len(ids_n1) + 2, f"N=3 should be N=1 + 2 tokens; got {len(ids_n3)} vs {len(ids_n1)}"
# Insert one img_id at the existing position; everything else unchanged.
p1 = ids_n1.index(img_id)
assert ids_n2[: p1 + 1] == ids_n1[: p1 + 1] + [], "prefix before extra <img> must match N=1"
assert ids_n2[p1] == img_id and ids_n2[p1 + 1] == img_id, "two consecutive <img> ids at the insertion point"
assert ids_n2[p1 + 2 :] == ids_n1[p1 + 1 :], "tail after the extra <img> must match N=1's tail"
# N=3 same pattern, three in a row.
assert ids_n3[p1 : p1 + 3] == [img_id, img_id, img_id]
assert ids_n3[p1 + 3 :] == ids_n1[p1 + 1 :]
@pytest.mark.skipif(not _hf_cached(_HUNYUAN_MODEL_ID), reason=f"{_HUNYUAN_MODEL_ID} tokenizer not in HF cache")
def test_real_tokenizer_default_n1_byte_identical_to_legacy():
"""Default `num_images=1` must produce the exact same id sequence as
omitting the parameter — pins the legacy single-image regression
against the real tokenizer (not just FakeTokenizer)."""
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(_HUNYUAN_MODEL_ID, trust_remote_code=True)
legacy = build_prompt_tokens("hi", tok, task="it2i", bot_task="think").token_ids
explicit = build_prompt_tokens("hi", tok, task="it2i", bot_task="think", num_images=1).token_ids
assert legacy == explicit, "real tokenizer: default num_images=1 must be byte-identical to legacy"
@@ -1,20 +1,5 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for HunyuanImage3 prompt construction (PR #3243).
Two layers:
1. Pure-logic tests with a recording fake tokenizer -- protect the
prompt template structure (BOS, User:/Assistant: framing, trigger
placement, image placeholder position) and protect the segment-
by-segment tokenization contract (each segment must hit
`tokenizer.encode` in isolation).
2. Real-tokenizer regression -- run when the HunyuanImage3-Instruct
tokenizer is in the local HF cache. Asserts the segment-tokenized
output diverges from the naive full-string encode, which is the
bug-tripping fixture for the cross-segment BPE merge fix
(commit 7bd429ed).
"""
from __future__ import annotations from __future__ import annotations
import ast import ast
@@ -24,7 +9,9 @@ import pathlib
import pytest import pytest
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import ( from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
_TASK_PRESETS,
HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS, HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS,
available_bot_tasks,
available_tasks, available_tasks,
build_prompt, build_prompt,
build_prompt_tokens, build_prompt_tokens,
@@ -34,18 +21,7 @@ from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# -------------------- Pure-logic structural tests --------------------
class FakeTokenizer: class FakeTokenizer:
"""Minimal tokenizer stub that records every encode() call.
Returns deterministic ids from convert_tokens_to_ids while
encode() returns one id per character starting at 100. This lets
tests both verify segmentation (by inspecting `encode_calls`) and
locate substrings inside the returned id list.
"""
SPECIAL = { SPECIAL = {
"<|startoftext|>": 1, "<|startoftext|>": 1,
"<img>": 2, "<img>": 2,
@@ -72,85 +48,131 @@ class FakeTokenizer:
def test_available_tasks_covers_all_modalities(): def test_available_tasks_covers_all_modalities():
tasks = set(available_tasks()) assert set(available_tasks()) == {"t2t", "i2t", "it2i", "t2i"}
assert tasks >= {
"t2t",
"i2t", def test_available_bot_tasks_covers_all_modes():
assert set(available_bot_tasks()) == {None, "think", "recaption", "think_recaption", "vanilla"}
def test_legacy_task_presets_still_available():
assert {
"it2i_think", "it2i_think",
"it2i_recaption", "it2i_recaption",
"it2i_think_recaption", "it2i_think_recaption",
"t2i_think", "t2i_think",
"t2i_recaption", "t2i_recaption",
"t2i_vanilla", "t2i_vanilla",
} <= set(_TASK_PRESETS)
def test_legacy_base_task_omitted_bot_task_keeps_plain_mode():
prompt = build_prompt("HELLO", task="i2t")
assert prompt.endswith("Assistant: ")
assert not prompt.endswith("<think>")
result = build_prompt_tokens("hi", FakeTokenizer(), task="i2t")
assert result.system_prompt_type == "en_unified"
assert result.token_ids[-1] not in {
FakeTokenizer.SPECIAL["<think>"],
FakeTokenizer.SPECIAL["<recaption>"],
} }
def test_resolve_stop_token_ids_uses_answer_for_generation_tasks(): def test_legacy_composite_task_with_none_bot_task_keeps_encoded_mode():
prompt = build_prompt("HELLO", task="it2i_think", bot_task=None)
assert prompt.endswith("Assistant: <think>")
result = build_prompt_tokens("hi", FakeTokenizer(), task="it2i_recaption", bot_task=None)
assert result.token_ids[-1] == FakeTokenizer.SPECIAL["<recaption>"]
def test_default_prompt_still_uses_it2i_think_mode():
prompt = build_prompt("HELLO")
assert prompt.endswith("Assistant: <think>")
result = build_prompt_tokens("hi", FakeTokenizer())
assert result.system_prompt_type == "en_unified"
assert result.token_ids[-1] == FakeTokenizer.SPECIAL["<think>"]
def test_resolve_stop_token_ids_image_tasks_stop_on_ratio_range():
"""Image-output tasks stop on any ``<img_ratio_*>`` token.
Mirrors upstream ``modeling_hunyuan_image_3.py::generate_image``
(line 3289-3303): when ``need_ratio`` is true,
``final_stop_tokens = list(range(start_ratio, end_ratio + 1)) +
ratio_token_other_slices``. AR stops AT the ratio token sampled
after ``<img_size_*>``; the bridge then strips the trailing ratio
token before passing the cot to DiT.
"""
tok = FakeTokenizer() tok = FakeTokenizer()
start = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_0>"]
end = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_32>"]
other_start = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_33>"]
other_end = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_36>"]
expected = list(range(start, end + 1)) + list(range(other_start, other_end + 1))
# Image-output: t2i / it2i stop on the full ratio token range.
for bot in ("think", "recaption", "think_recaption", "vanilla"):
assert resolve_stop_token_ids(task="t2i", bot_task=bot, tokenizer=tok) == expected
assert resolve_stop_token_ids(task="it2i", bot_task=bot, tokenizer=tok) == expected
# Text-output: i2t / t2t comprehension stops on <answer> (response sits inside).
answer_id = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<answer>"] answer_id = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<answer>"]
assert resolve_stop_token_ids(task="t2i_think", tokenizer=tok) == [answer_id] assert resolve_stop_token_ids(task="i2t", bot_task=None, tokenizer=tok) == [answer_id]
assert resolve_stop_token_ids(task="t2i_recaption", tokenizer=tok) == [answer_id] assert resolve_stop_token_ids(task="t2t", bot_task=None, tokenizer=tok) == [answer_id]
@pytest.mark.parametrize( @pytest.mark.parametrize(
"task", "task,bot_task",
[ [
"t2t", ("t2t", None),
"i2t", ("i2t", None),
"it2i_think", ("it2i", "think"),
"it2i_recaption", ("it2i", "recaption"),
"it2i_think_recaption", ("it2i", "think_recaption"),
"t2i_think", ("t2i", "think"),
"t2i_recaption", ("t2i", "recaption"),
("t2i", "think_recaption"),
], ],
) )
def test_build_prompt_string_structure_chat_template(task: str): def test_build_prompt_string_structure_chat_template(task: str, bot_task: str | None):
"""Chat-template tasks must produce <|startoftext|>...User: ...Assistant: ... s = build_prompt("HELLO", task=task, bot_task=bot_task)
with image placeholder (when applicable) and trigger tag AFTER `Assistant: `."""
s = build_prompt("HELLO", task=task)
assert s.startswith("<|startoftext|>") assert s.startswith("<|startoftext|>")
assert "User: " in s assert "User: " in s
assert "Assistant: " in s assert "Assistant: " in s
assert s.index("User: ") < s.index("HELLO") < s.index("Assistant: ") assert s.index("User: ") < s.index("HELLO") < s.index("Assistant: ")
if task.startswith(("i2t", "it2i")): if task in ("i2t", "it2i"):
assert s.index("User: ") < s.index("<img>") < s.index("HELLO"), ( assert s.index("User: ") < s.index("<img>") < s.index("HELLO")
"<img> placeholder must sit between `User: ` and the user prompt"
)
else: else:
assert "<img>" not in s assert "<img>" not in s
# Trigger tag must be the FINAL token of the prompt (after `Assistant: `). if bot_task in ("think", "think_recaption"):
# Note: the system prompt itself mentions <think>/<recaption> as mode assert s.endswith("Assistant: <think>")
# documentation, so substring index() catches the wrong occurrence -- use elif bot_task == "recaption":
# endswith() which directly captures "trigger is at the tail" (the Part A assert s.endswith("Assistant: <recaption>")
# fix: trigger goes AFTER `Assistant: `, not before user_prompt). elif bot_task is None:
if task in ("it2i_think", "t2i_think", "it2i_think_recaption"): assert s.endswith("Assistant: ")
assert s.endswith("Assistant: <think>"), (
f"Trigger <think> must be appended right after `Assistant: ` (Part A fix). Got tail: ...{s[-40:]!r}"
)
if task in ("it2i_recaption", "t2i_recaption"):
assert s.endswith("Assistant: <recaption>"), (
f"Trigger <recaption> must be appended right after `Assistant: ` (Part A fix). Got tail: ...{s[-40:]!r}"
)
if task in ("t2t", "i2t"):
assert s.endswith("Assistant: "), "Plain (no-trigger) task must end at `Assistant: ` with no trailing tag."
def test_build_prompt_vanilla_uses_pretrain_template(): def test_build_prompt_vanilla_uses_pretrain_template():
"""t2i_vanilla is the only task that bypasses chat structure -- direct s = build_prompt("HELLO", task="t2i", bot_task="vanilla")
text->image generation driven by the vanilla system prompt."""
s = build_prompt("HELLO", task="t2i_vanilla")
assert s.startswith("<|startoftext|>") assert s.startswith("<|startoftext|>")
assert "User: " not in s assert "User: " not in s
assert "Assistant: " not in s assert "Assistant: " not in s
assert "<think>" not in s
assert "<recaption>" not in s
assert s.endswith("HELLO") assert s.endswith("HELLO")
def test_build_prompt_vanilla_rejects_non_t2i_task():
with pytest.raises(ValueError, match="bot_task='vanilla'"):
build_prompt("x", task="it2i", bot_task="vanilla")
with pytest.raises(ValueError, match="bot_task='vanilla'"):
build_prompt_tokens("x", FakeTokenizer(), task="i2t", bot_task="vanilla")
def test_build_prompt_unknown_task_raises(): def test_build_prompt_unknown_task_raises():
with pytest.raises(ValueError, match="Unknown task"): with pytest.raises(ValueError, match="Unknown task"):
build_prompt("x", task="bogus") build_prompt("x", task="bogus")
@@ -158,127 +180,83 @@ def test_build_prompt_unknown_task_raises():
build_prompt_tokens("x", FakeTokenizer(), task="bogus") build_prompt_tokens("x", FakeTokenizer(), task="bogus")
def test_build_prompt_unknown_bot_task_raises():
with pytest.raises(ValueError, match="Unknown bot_task"):
build_prompt("x", task="t2i", bot_task="bogus")
with pytest.raises(ValueError, match="Unknown bot_task"):
build_prompt_tokens("x", FakeTokenizer(), task="t2i", bot_task="bogus")
def test_build_prompt_tokens_segments_each_boundary(): def test_build_prompt_tokens_segments_each_boundary():
"""Regression for cross-segment BPE merge bug (commit 7bd429ed):
each template segment must hit tokenizer.encode() independently;
user_prompt MUST NOT be concatenated with the following separator
in the same encode() call."""
tok = FakeTokenizer() tok = FakeTokenizer()
build_prompt_tokens("写诗。", tok, task="i2t") build_prompt_tokens("写诗。", tok, task="i2t", bot_task=None)
# Each canonical segment is encoded in its own call.
assert "User: " in tok.encode_calls assert "User: " in tok.encode_calls
assert "写诗。" in tok.encode_calls, ( assert "写诗。" in tok.encode_calls
"user_prompt must be encoded alone -- if it is concatenated with the "
"trailing separator, BPE will merge across the boundary (the PR-#3243 bug)."
)
assert "\n\nAssistant: " in tok.encode_calls assert "\n\nAssistant: " in tok.encode_calls
# No call must contain user_prompt glued to neighboring text.
for call in tok.encode_calls: for call in tok.encode_calls:
if call != "写诗。": if call != "写诗。":
assert "写诗。" not in call, f"user_prompt leaked into a multi-segment encode call: {call!r}" assert "写诗。" not in call
def test_build_prompt_tokens_image_placeholder_present_for_image_tasks(): def test_build_prompt_tokens_image_placeholder_present_for_image_tasks():
tok = FakeTokenizer() tok = FakeTokenizer()
result = build_prompt_tokens("hi", tok, task="i2t") result = build_prompt_tokens("hi", tok, task="i2t", bot_task=None)
ids = result.token_ids ids = result.token_ids
assert ids[0] == FakeTokenizer.SPECIAL["<|startoftext|>"], "BOS (<|startoftext|>) must be the first token" assert ids[0] == FakeTokenizer.SPECIAL["<|startoftext|>"]
assert FakeTokenizer.SPECIAL["<img>"] in ids, "<img> placeholder must be present for i2t/it2i tasks" assert FakeTokenizer.SPECIAL["<img>"] in ids
def test_build_prompt_tokens_no_image_for_text_only_tasks(): def test_build_prompt_tokens_no_image_for_text_only_tasks():
tok = FakeTokenizer() tok = FakeTokenizer()
result = build_prompt_tokens("hi", tok, task="t2t") result = build_prompt_tokens("hi", tok, task="t2t", bot_task=None)
ids = result.token_ids ids = result.token_ids
assert FakeTokenizer.SPECIAL["<img>"] not in ids, "<img> must NOT appear for text-only tasks" assert FakeTokenizer.SPECIAL["<img>"] not in ids
@pytest.mark.parametrize( @pytest.mark.parametrize(
"task,trigger_id", "task,bot_task,trigger_id",
[ [
("it2i_think", FakeTokenizer.SPECIAL["<think>"]), ("it2i", "think", FakeTokenizer.SPECIAL["<think>"]),
("t2i_think", FakeTokenizer.SPECIAL["<think>"]), ("t2i", "think", FakeTokenizer.SPECIAL["<think>"]),
("it2i_recaption", FakeTokenizer.SPECIAL["<recaption>"]), ("t2i", "think_recaption", FakeTokenizer.SPECIAL["<think>"]),
("t2i_recaption", FakeTokenizer.SPECIAL["<recaption>"]), ("it2i", "recaption", FakeTokenizer.SPECIAL["<recaption>"]),
("t2i", "recaption", FakeTokenizer.SPECIAL["<recaption>"]),
("it2i_think", None, FakeTokenizer.SPECIAL["<think>"]),
("it2i_recaption", None, FakeTokenizer.SPECIAL["<recaption>"]),
], ],
) )
def test_build_prompt_tokens_trigger_is_last_token(task: str, trigger_id: int): def test_build_prompt_tokens_trigger_is_last_token(task: str, bot_task: str | None, trigger_id: int):
"""Trigger tag id must be the LAST token (after `Assistant: ` segment)."""
tok = FakeTokenizer() tok = FakeTokenizer()
result = build_prompt_tokens("hi", tok, task=task) result = build_prompt_tokens("hi", tok, task=task, bot_task=bot_task)
ids = result.token_ids assert result.token_ids[-1] == trigger_id
assert ids[-1] == trigger_id
def test_build_prompt_tokens_no_trigger_for_plain_tasks(): def test_build_prompt_tokens_no_trigger_for_plain_tasks():
"""Tasks without trigger_tag (t2t / i2t) must NOT append a trigger id."""
tok = FakeTokenizer() tok = FakeTokenizer()
result = build_prompt_tokens("hi", tok, task="t2t") result = build_prompt_tokens("hi", tok, task="t2t", bot_task=None)
ids = result.token_ids assert result.token_ids[-1] not in {
assert ids[-1] not in {
FakeTokenizer.SPECIAL["<think>"], FakeTokenizer.SPECIAL["<think>"],
FakeTokenizer.SPECIAL["<recaption>"], FakeTokenizer.SPECIAL["<recaption>"],
} }
# -------------------- end2end.py wiring guard --------------------
def _repo_root() -> pathlib.Path: def _repo_root() -> pathlib.Path:
# tests/diffusion/models/hunyuan_image3/test_prompt_utils.py -> repo root
return pathlib.Path(__file__).resolve().parents[4] return pathlib.Path(__file__).resolve().parents[4]
def test_end2end_routes_through_shared_prompt_utils(): def test_end2end_routes_through_shared_prompt_utils():
"""Regression for the *delivery vector* of PR #3243.
Background: the wrong-template bug that PR #3243 fixes was introduced
when end2end.py grew its own hand-rolled prompt builder that diverged
from the canonical instruct chat template. To prevent that exact
failure mode from recurring, end2end.py MUST:
1. Import the prompt builders from the shared prompt_utils module.
2. NOT redefine `build_prompt` or `build_prompt_tokens` locally.
A local redefinition is precisely how a future merge can silently
re-introduce a pretrain-style template (trigger BEFORE user_prompt,
no User:/Assistant: framing, etc.) without touching prompt_utils,
bypassing every other test in this file.
"""
end2end_path = _repo_root() / "examples" / "offline_inference" / "hunyuan_image3" / "end2end.py" end2end_path = _repo_root() / "examples" / "offline_inference" / "hunyuan_image3" / "end2end.py"
assert end2end_path.is_file(), f"end2end.py not found at {end2end_path}"
tree = ast.parse(end2end_path.read_text(encoding="utf-8")) tree = ast.parse(end2end_path.read_text(encoding="utf-8"))
local_func_names = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)} local_func_names = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
forbidden = {"build_prompt", "build_prompt_tokens"} assert not (local_func_names & {"build_prompt", "build_prompt_tokens"})
redefined = local_func_names & forbidden
assert not redefined, (
f"end2end.py defines {sorted(redefined)} locally. This is exactly how "
"the wrong prompt template re-entered the example before PR #3243. "
"Use the shared `vllm_omni.diffusion.models.hunyuan_image3.prompt_utils` "
"helpers instead."
)
imported_from_prompt_utils: set[str] = set() imported_from_prompt_utils: set[str] = set()
for node in ast.walk(tree): for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module and node.module.endswith("hunyuan_image3.prompt_utils"): if isinstance(node, ast.ImportFrom) and node.module and node.module.endswith("hunyuan_image3.prompt_utils"):
imported_from_prompt_utils.update(alias.name for alias in node.names) imported_from_prompt_utils.update(alias.name for alias in node.names)
expected_imports = { expected_imports = {"build_prompt_tokens", "resolve_stop_token_ids", "resolve_sys_type"}
"_TASK_PRESETS", assert expected_imports <= imported_from_prompt_utils
"build_prompt_tokens",
"resolve_stop_token_ids",
}
assert expected_imports <= imported_from_prompt_utils, (
"end2end.py must import the HunyuanImage3 prompt and stop-token helpers from "
"vllm_omni.diffusion.models.hunyuan_image3.prompt_utils -- the shared "
"module is the single source of truth for the AR-prefill template and "
"bot_task-derived AR stop token ids."
)
# -------------------- Real-tokenizer regression --------------------
_HUNYUAN_MODEL_ID = "tencent/HunyuanImage-3.0-Instruct" _HUNYUAN_MODEL_ID = "tencent/HunyuanImage-3.0-Instruct"
@@ -290,41 +268,14 @@ def _hf_cached(model_id: str) -> bool:
return os.path.isdir(snap_dir) and any(os.scandir(snap_dir)) return os.path.isdir(snap_dir) and any(os.scandir(snap_dir))
@pytest.mark.skipif( @pytest.mark.skipif(not _hf_cached(_HUNYUAN_MODEL_ID), reason=f"{_HUNYUAN_MODEL_ID} tokenizer not in HF cache")
not _hf_cached(_HUNYUAN_MODEL_ID),
reason=f"{_HUNYUAN_MODEL_ID} tokenizer not in HF cache",
)
def test_segment_tokenize_diverges_from_full_string_encode(): def test_segment_tokenize_diverges_from_full_string_encode():
"""Regression for PR #3243 segment-tokenization fix.
The naive `tokenizer.encode(build_prompt(...))` lets BPE merge tokens
across segment boundaries (notably `。\\n\\n` -> a single id), which
drifts the AR prefill away from HF's apply_chat_template output. The
segment-by-segment build_prompt_tokens must produce a STRICTLY
DIFFERENT id sequence on a prompt that triggers the merge.
If someone "simplifies" build_prompt_tokens to call encode() on the
full string, this assertion fires.
"""
from transformers import AutoTokenizer from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(_HUNYUAN_MODEL_ID, trust_remote_code=True) tok = AutoTokenizer.from_pretrained(_HUNYUAN_MODEL_ID, trust_remote_code=True)
user_prompt = "写一首关于夜的诗。" user_prompt = "写一首关于夜的诗。"
result = build_prompt_tokens(user_prompt, tok, task="i2t") result = build_prompt_tokens(user_prompt, tok, task="i2t", bot_task=None)
seg_ids = result.token_ids seg_ids = result.token_ids
full_ids = tok.encode(build_prompt(user_prompt, task="i2t"), add_special_tokens=False) full_ids = tok.encode(build_prompt(user_prompt, task="i2t", bot_task=None), add_special_tokens=False)
assert seg_ids != full_ids
assert seg_ids != full_ids, ( assert len(seg_ids) >= len(full_ids)
"build_prompt_tokens output equals naive full-string encode -- "
"the BPE-merge-bypass behavior is no longer exercised. This means "
"the segment-by-segment fix from PR #3243 has been silently undone."
)
# Segmenting prevents merges, so the segment id list should have AT LEAST
# as many tokens as the merged version (a merge consumes 2+ ids -> 1).
assert len(seg_ids) >= len(full_ids), (
f"segment-encoded length ({len(seg_ids)}) shorter than full-string "
f"merged length ({len(full_ids)}) -- impossible if segmentation is "
f"genuinely bypassing merges."
)
@@ -5,6 +5,7 @@ import pytest
import torch import torch
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from tests.helpers import skip_if_gated_repo_inaccessible
from tests.helpers.env import DeviceMemoryMonitor from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.mark import hardware_test from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner from tests.helpers.runtime import OmniRunner
@@ -21,6 +22,11 @@ IMAGE_VIDEO_MODELS = {
MODELS = {**AUDIO_MODEL, **IMAGE_VIDEO_MODELS} MODELS = {**AUDIO_MODEL, **IMAGE_VIDEO_MODELS}
_GATED_MODELS = {"stabilityai/stable-audio-open-1.0"}
# Aliased for backward compatibility (imported by test_diffusion_layerwise_offload.py).
_skip_if_gated_repo_inaccessible = skip_if_gated_repo_inaccessible
AUDIO_MODEL_PARAMS = { AUDIO_MODEL_PARAMS = {
"runner_params": {}, "runner_params": {},
"sampler_params": {}, "sampler_params": {},
@@ -94,10 +100,23 @@ def check_audio_determinism(audio1, audio2, atol=1e-2):
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}) @hardware_test(res={"cuda": "L4", "rocm": "MI325"})
@pytest.mark.parametrize("model_name", list(MODELS.keys())) @pytest.mark.parametrize("model_name", list(MODELS.keys()))
def test_cpu_offload_diffusion_model(model_name: str): def test_cpu_offload_diffusion_model(model_name: str):
if model_name in _GATED_MODELS:
_skip_if_gated_repo_inaccessible(model_name)
try: try:
offload_peak_memory, output_offload = inference(model_name, offload=True) offload_peak_memory, output_offload = inference(model_name, offload=True)
cleanup_dist_env_and_memory() cleanup_dist_env_and_memory()
no_offload_peak_memory, output_no_offload = inference(model_name, offload=False) no_offload_peak_memory, output_no_offload = inference(model_name, offload=False)
except ValueError as exc:
# omni_snapshot_download wraps GatedRepoError in a ValueError.
# If the pre-flight guard above did not catch it (e.g. partial
# HF_TOKEN where config.json is accessible but weight shards are
# blocked), skip instead of failing.
if "Access to model" in str(exc) and "is restricted" in str(exc):
pytest.skip(
f"Skipping: gated HF repo {model_name!r} inaccessible "
f"({exc}). See docs/contributing/ci/hf_credentials.md."
)
pytest.fail(f"Inference failed: {exc}")
except Exception: except Exception:
pytest.fail("Inference failed") pytest.fail("Inference failed")
print(f"Offload peak memory: {offload_peak_memory} MB") print(f"Offload peak memory: {offload_peak_memory} MB")
@@ -3,6 +3,10 @@ import pytest
import torch import torch
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from tests.diffusion.offloader.test_diffusion_cpu_offload import (
_GATED_MODELS,
_skip_if_gated_repo_inaccessible,
)
from tests.helpers.env import DeviceMemoryMonitor from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.runtime import OmniRunner from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -96,6 +100,8 @@ def test_layerwise_offload_diffusion_model(model_name: str):
offloader keeps only a single transformer block on GPU at a time, with offloader keeps only a single transformer block on GPU at a time, with
prefetching for compute-memory overlap. prefetching for compute-memory overlap.
""" """
if model_name in _GATED_MODELS:
_skip_if_gated_repo_inaccessible(model_name)
try: try:
# Run without layerwise offloading (baseline) # Run without layerwise offloading (baseline)
no_offload_peak_memory, output_no_offload = run_inference(model_name, layerwise_offload=False) no_offload_peak_memory, output_no_offload = run_inference(model_name, layerwise_offload=False)
@@ -104,6 +110,17 @@ def test_layerwise_offload_diffusion_model(model_name: str):
# Run with layerwise offloading (1 layer on device) # Run with layerwise offloading (1 layer on device)
layerwise_offload_peak_memory, output_offload = run_inference(model_name, layerwise_offload=True) layerwise_offload_peak_memory, output_offload = run_inference(model_name, layerwise_offload=True)
cleanup_dist_env_and_memory() cleanup_dist_env_and_memory()
except ValueError as exc:
# omni_snapshot_download wraps GatedRepoError in a ValueError.
# If the pre-flight guard above did not catch it (e.g. partial
# HF_TOKEN where config.json is accessible but weight shards are
# blocked), skip instead of failing.
if "Access to model" in str(exc) and "is restricted" in str(exc):
pytest.skip(
f"Skipping: gated HF repo {model_name!r} inaccessible "
f"({exc}). See docs/contributing/ci/hf_credentials.md."
)
pytest.fail(f"Inference failed: {exc}")
except Exception: except Exception:
pytest.fail("Inference failed") pytest.fail("Inference failed")
@@ -5,6 +5,7 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.diffusion.inline_stage_diffusion_client import InlineStageDiffusionClient from vllm_omni.diffusion.inline_stage_diffusion_client import InlineStageDiffusionClient
@@ -19,6 +20,10 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def mock_engine(): def mock_engine():
with patch("vllm_omni.diffusion.inline_stage_diffusion_client.DiffusionEngine") as mock: with patch("vllm_omni.diffusion.inline_stage_diffusion_client.DiffusionEngine") as mock:
engine_instance = MagicMock() engine_instance = MagicMock()
engine_instance.executor = SimpleNamespace(
register_failure_callback=MagicMock(),
check_health=MagicMock(),
)
mock.make_engine.return_value = engine_instance mock.make_engine.return_value = engine_instance
yield engine_instance yield engine_instance
@@ -97,6 +102,41 @@ def test_inline_shutdown(client, mock_engine):
mock_engine.close.assert_called_once() mock_engine.close.assert_called_once()
def test_inline_registers_executor_failure_callback(client, mock_engine):
mock_engine.executor.register_failure_callback.assert_called_once()
def test_inline_executor_failure_marks_engine_dead(client, mock_engine):
callback = mock_engine.executor.register_failure_callback.call_args.args[0]
callback()
assert client._engine_dead is True
with pytest.raises(EngineDeadError, match="inline diffusion engine is dead"):
client.get_diffusion_output_nowait()
def test_inline_returns_queued_output_before_engine_dead(client, mock_engine):
callback = mock_engine.executor.register_failure_callback.call_args.args[0]
output = OmniRequestOutput.from_diffusion(request_id="req-queued", images=[])
client._output_queue.put_nowait(output)
callback()
assert client.get_diffusion_output_nowait() is output
with pytest.raises(EngineDeadError, match="inline diffusion engine is dead"):
client.get_diffusion_output_nowait()
def test_inline_check_health_marks_engine_dead(client, mock_engine):
mock_engine.executor.check_health.side_effect = EngineDeadError()
with pytest.raises(EngineDeadError):
client.check_health()
assert client._engine_dead is True
def test_inline_client_requires_replica_id(mock_engine): def test_inline_client_requires_replica_id(mock_engine):
metadata = SimpleNamespace( metadata = SimpleNamespace(
stage_id=0, stage_id=0,
@@ -199,37 +199,53 @@ def test_send_single_request_cleans_up_after_finished_payload(build_adapter, mon
assert args[1] == "ext-finished" assert args[1] == "ext-finished"
def test_update_request_payload(build_adapter): def test_load_poll_non_ar_merges_into_existing_additional_information(build_adapter):
adapter, _ = build_adapter() adapter, connector = build_adapter(stage_id=2, model_mode="diffusion")
request = _req("req-non-ar", RequestStatus.WAITING, external_req_id="ext-non-ar")
first: OmniPayload = { request.additional_information = {
"hidden_states": {"output": torch.tensor([[1.0]])}, "hidden_states": {"output": torch.tensor([[1.0]])},
"codes": {"audio": [1]}, "ids": {"prompt": [11, 12]},
"meta": {"finished": torch.tensor(False, dtype=torch.bool)}, "meta": {"finished": torch.tensor(False, dtype=torch.bool), "step": 1},
} }
adapter._update_request_payload("ext", first) request.num_computed_tokens = 9
second: OmniPayload = {
"hidden_states": {"output": torch.tensor([[2.0]])},
"codes": {"audio": [2]},
"meta": {"finished": torch.tensor(True, dtype=torch.bool)},
}
merged = adapter._update_request_payload("ext", second)
assert torch.equal(merged["hidden_states"]["output"], torch.tensor([[1.0], [2.0]])) payload: OmniPayload = {
assert merged["codes"]["audio"] == [1, 2] "hidden_states": {"output": torch.tensor([[2.0]])},
assert merged["meta"]["finished"].item() is True "ids": {"all": [21, 22]},
"codes": {"audio": torch.tensor([7, 8], dtype=torch.long)},
"meta": {"finished": torch.tensor(True, dtype=torch.bool), "phase": "decode"},
"kv_metadata": {"foo": "bar"},
}
connector.get.return_value = (payload, 8)
assert adapter._poll_single_request(request) is True
assert request.prompt_token_ids == [7, 8]
assert request.num_computed_tokens == 0
assert torch.equal(
request.additional_information["hidden_states"]["output"],
torch.tensor([[2.0]]),
)
assert request.additional_information["ids"]["prompt"] == [11, 12]
assert request.additional_information["ids"]["all"] == [21, 22]
# non-ar merge path intentionally doesn't overwrite meta.finished.
assert request.additional_information["meta"]["finished"].item() is False
assert request.additional_information["meta"]["phase"] == "decode"
assert request.additional_information["kv_metadata"] == {"foo": "bar"}
assert "req-non-ar" in adapter._finished_load_reqs
assert "req-non-ar" in adapter.finished_requests
def test_load_poll_ar_request_additional_information_concats_tensors(build_adapter): def test_load_poll_ar_request_additional_information_concats_tensors(build_adapter):
adapter, connector = build_adapter(stage_id=2, model_mode="ar") adapter, connector = build_adapter(stage_id=2, model_mode="ar")
request = _req("req-merged", RequestStatus.WAITING, external_req_id="ext-merged") request = _req("req-merged", RequestStatus.WAITING, external_req_id="ext-merged")
request.additional_information = {
adapter.request_ids_mapping["req-merged"] = "ext-merged"
adapter.request_payload["ext-merged"] = {
"hidden_states": {"output": torch.tensor([[1.0]])}, "hidden_states": {"output": torch.tensor([[1.0]])},
"ids": {"prompt": [11, 12]}, "ids": {"prompt": [11, 12]},
"meta": {"finished": torch.tensor(False, dtype=torch.bool)}, "meta": {"finished": torch.tensor(False, dtype=torch.bool)},
} }
adapter.request_ids_mapping["req-merged"] = "ext-merged"
payload: OmniPayload = { payload: OmniPayload = {
"hidden_states": {"output": torch.tensor([[2.0]])}, "hidden_states": {"output": torch.tensor([[2.0]])},
"meta": {"finished": torch.tensor(True, dtype=torch.bool)}, "meta": {"finished": torch.tensor(True, dtype=torch.bool)},
@@ -238,12 +254,8 @@ def test_load_poll_ar_request_additional_information_concats_tensors(build_adapt
adapter._poll_single_request(request) adapter._poll_single_request(request)
assert torch.equal( # AR mode now forwards the latest payload directly.
request.additional_information["hidden_states"]["output"], assert request.additional_information == payload
torch.tensor([[1.0], [2.0]]),
)
# Keys absent from the new chunk are dropped (matches main's behavior).
assert "ids" not in request.additional_information
assert request.additional_information["meta"]["finished"].item() is True assert request.additional_information["meta"]["finished"].item() is True
@@ -528,6 +540,7 @@ def test_generation_scheduler_calls_cleanup_on_finished(monkeypatch, mocker: Moc
kv_connector_output=None, kv_connector_output=None,
cudagraph_stats=None, cudagraph_stats=None,
req_id_to_index={"req-s1": 0}, req_id_to_index={"req-s1": 0},
routed_experts_dict=None,
) )
OmniGenerationScheduler.update_from_output(scheduler, scheduler_output, model_runner_output) OmniGenerationScheduler.update_from_output(scheduler, scheduler_output, model_runner_output)
@@ -613,6 +626,7 @@ def test_ar_scheduler_defers_cleanup_and_queues_save_on_finished(mocker: MockerF
cudagraph_stats=None, cudagraph_stats=None,
req_id_to_index={"req-ar": 0}, req_id_to_index={"req-ar": 0},
kv_extracted_req_ids=None, kv_extracted_req_ids=None,
routed_experts_dict=None,
) )
OmniARScheduler.update_from_output(scheduler, scheduler_output, model_runner_output) OmniARScheduler.update_from_output(scheduler, scheduler_output, model_runner_output)
@@ -686,3 +700,232 @@ def test_wire_round_trip_struct_to_dict_contract():
assert set(decoded.keys()) == set(expected.keys()) assert set(decoded.keys()) == set(expected.keys())
assert set(decoded["meta"].keys()) == set(expected["meta"].keys()) assert set(decoded["meta"].keys()) == set(expected["meta"].keys())
assert set(decoded["codes"].keys()) == set(expected["codes"].keys()) assert set(decoded["codes"].keys()) == set(expected["codes"].keys())
# ---------------------------------------------------------------
# Deferred finish for upstream-completed requests
# ---------------------------------------------------------------
def _build_deferred_finish_scheduler(mocker, *, running, pending_finish_reqs):
"""Build a mock scheduler with requests queued for deferred finish."""
adapter_mock = mocker.MagicMock()
adapter_mock.finished_requests = {r.request_id for r in pending_finish_reqs}
cleanup_calls = []
adapter_mock.cleanup = lambda *a, **kw: cleanup_calls.append((a, kw))
scheduler = mocker.MagicMock()
scheduler.chunk_transfer_adapter = adapter_mock
scheduler.connector = None
scheduler.ec_connector = None
scheduler.perf_metrics = None
scheduler.log_stats = False
scheduler.recompute_kv_load_failures = False
scheduler.structured_output_manager = mocker.MagicMock()
scheduler.structured_output_manager.should_advance.return_value = False
scheduler.finished_req_ids_dict = {}
scheduler.kv_cache_manager.take_events.return_value = None
scheduler.kv_event_publisher = mocker.MagicMock()
scheduler._pending_finish_reqs = list(pending_finish_reqs)
scheduler._handle_stopped_request = mocker.MagicMock(return_value=True)
scheduler._free_request = mocker.MagicMock(return_value=None)
scheduler._get_routed_experts = mocker.MagicMock(return_value=None)
scheduler.running = list(running)
scheduler.waiting = mocker.MagicMock()
scheduler.waiting.remove_requests = mocker.MagicMock()
scheduler.make_stats = mocker.MagicMock(return_value=None)
scheduler.requests = {r.request_id: r for r in running}
scheduler_output = SimpleNamespace(
num_scheduled_tokens={},
scheduled_spec_decode_tokens={},
num_invalid_spec_tokens=0,
)
model_runner_output = SimpleNamespace(
sampled_token_ids=None,
logprobs=None,
prompt_logprobs_dict={},
pooler_output=None,
num_nans_in_logits=None,
kv_connector_output=None,
cudagraph_stats=None,
req_id_to_index={},
)
return scheduler, scheduler_output, model_runner_output, cleanup_calls
def test_deferred_finish_emits_finished_output(mocker: MockerFixture):
"""A request whose upstream completed with no remaining tokens should
emit a FINISHED EngineCoreOutput, free resources, and clean up adapter state."""
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
request = _HashableRequest(
request_id="req-df1",
external_req_id="ext-df1",
status=RequestStatus.RUNNING,
is_finished=lambda: False,
num_computed_tokens=16,
num_prompt_tokens=16,
prompt_token_ids=list(range(1, 17)),
num_output_placeholders=0,
sampling_params=None,
pooling_params=None,
stop_reason=None,
client_index=0,
take_events=lambda: [],
trace_headers=None,
has_encoder_inputs=False,
take_prefill_stats=lambda: None,
num_nans_in_logits=0,
get_finished_reason=lambda: "stop",
)
scheduler, sched_out, model_out, cleanup_calls = _build_deferred_finish_scheduler(
mocker,
running=[request],
pending_finish_reqs=[request],
)
scheduler._free_request.return_value = {"mock": "kv_params"}
result = OmniGenerationScheduler.update_from_output(scheduler, sched_out, model_out)
assert request.status == RequestStatus.FINISHED_STOPPED
scheduler._handle_stopped_request.assert_called_once_with(request)
scheduler._free_request.assert_called_once_with(request)
assert len(cleanup_calls) == 1
assert cleanup_calls[0][0] == ("req-df1", "ext-df1")
eco = result[0]
assert len(eco.outputs) == 1
assert eco.outputs[0].request_id == "req-df1"
assert eco.outputs[0].finish_reason == "stop"
assert eco.outputs[0].kv_transfer_params == {"mock": "kv_params"}
assert scheduler._pending_finish_reqs == []
def test_deferred_finish_empty_prompt(mocker: MockerFixture):
"""A request that never received any tokens (finished immediately upstream)
should still emit a FINISHED output and clean up."""
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
request = _HashableRequest(
request_id="req-df2",
external_req_id="ext-df2",
status=RequestStatus.WAITING,
is_finished=lambda: False,
num_computed_tokens=0,
num_prompt_tokens=0,
prompt_token_ids=[],
num_output_placeholders=0,
sampling_params=None,
pooling_params=None,
stop_reason=None,
client_index=0,
take_events=lambda: [],
trace_headers=None,
has_encoder_inputs=False,
take_prefill_stats=lambda: None,
num_nans_in_logits=0,
get_finished_reason=lambda: "stop",
)
scheduler, sched_out, model_out, cleanup_calls = _build_deferred_finish_scheduler(
mocker,
running=[],
pending_finish_reqs=[request],
)
result = OmniGenerationScheduler.update_from_output(scheduler, sched_out, model_out)
assert request.status == RequestStatus.FINISHED_STOPPED
scheduler._free_request.assert_called_once_with(request)
assert len(cleanup_calls) == 1
eco = result[0]
assert len(eco.outputs) == 1
assert eco.outputs[0].finish_reason == "stop"
assert scheduler._pending_finish_reqs == []
def test_deferred_finish_skips_already_finished(mocker: MockerFixture):
"""A request that was aborted between schedule() and update_from_output()
should be skipped without emitting output or freeing resources twice."""
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
request = _HashableRequest(
request_id="req-df3",
external_req_id="ext-df3",
status=RequestStatus.FINISHED_ABORTED,
is_finished=lambda: True,
num_computed_tokens=0,
num_prompt_tokens=0,
prompt_token_ids=[],
num_output_placeholders=0,
sampling_params=None,
pooling_params=None,
stop_reason=None,
client_index=0,
take_events=lambda: [],
trace_headers=None,
has_encoder_inputs=False,
take_prefill_stats=lambda: None,
num_nans_in_logits=0,
get_finished_reason=lambda: "stop",
)
scheduler, sched_out, model_out, cleanup_calls = _build_deferred_finish_scheduler(
mocker,
running=[],
pending_finish_reqs=[request],
)
result = OmniGenerationScheduler.update_from_output(scheduler, sched_out, model_out)
scheduler._handle_stopped_request.assert_not_called()
scheduler._free_request.assert_not_called()
assert len(cleanup_calls) == 0
assert 0 not in result
assert scheduler._pending_finish_reqs == []
def test_deferred_finish_not_finished_still_emits_output(mocker: MockerFixture):
"""When _handle_stopped_request returns False (resumable request), the
output must still be emitted so the client stream doesn't hang."""
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
request = _HashableRequest(
request_id="req-df4",
external_req_id="ext-df4",
status=RequestStatus.RUNNING,
is_finished=lambda: False,
num_computed_tokens=16,
num_prompt_tokens=16,
prompt_token_ids=list(range(1, 17)),
num_output_placeholders=0,
sampling_params=None,
pooling_params=None,
stop_reason=None,
client_index=0,
take_events=lambda: [],
trace_headers=None,
has_encoder_inputs=False,
take_prefill_stats=lambda: None,
num_nans_in_logits=0,
get_finished_reason=lambda: "stop",
)
scheduler, sched_out, model_out, cleanup_calls = _build_deferred_finish_scheduler(
mocker,
running=[request],
pending_finish_reqs=[request],
)
scheduler._handle_stopped_request.return_value = False
result = OmniGenerationScheduler.update_from_output(scheduler, sched_out, model_out)
scheduler._handle_stopped_request.assert_called_once_with(request)
scheduler._free_request.assert_not_called()
assert len(cleanup_calls) == 0
eco = result[0]
assert len(eco.outputs) == 1
assert eco.outputs[0].request_id == "req-df4"
assert eco.outputs[0].finish_reason == "stop"
assert eco.outputs[0].kv_transfer_params is None
assert scheduler._pending_finish_reqs == []
@@ -25,7 +25,11 @@ from vllm_omni.distributed.omni_connectors.connectors.mooncake_transfer_engine_c
) )
# All tests in this file require Mooncake TransferEngine and an RDMA environment. # All tests in this file require Mooncake TransferEngine and an RDMA environment.
pytestmark = [pytest.mark.parallel, pytest.mark.core_model] pytestmark = [
pytest.mark.parallel,
pytest.mark.core_model,
pytest.mark.skipif(TransferEngine is None, reason="Mooncake TransferEngine not installed"),
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Shared helpers # Shared helpers
@@ -115,39 +115,43 @@ class TestConnectorKeyFormat:
class TestRankMapping: class TestRankMapping:
"""Verify get_kv_target_ranks and get_kv_source_ranks for various TP configs.""" """Verify get_kv_target_ranks and get_kv_source_ranks for various TP configs."""
def test_homogeneous_tp2_rank0(self): @pytest.mark.parametrize(
topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=0) "tp_size,local_rank",
assert get_kv_target_ranks(topo) == [0] [
assert get_kv_source_ranks(topo) == [0] (2, 0),
(2, 1),
(4, 3),
],
)
def test_homogeneous_tp(self, tp_size, local_rank):
"""Homogeneous TP: each rank sends/receives to/from itself."""
topo = KVTPTopology(source_tp_size=tp_size, target_tp_size=tp_size, local_rank=local_rank)
assert get_kv_target_ranks(topo) == [local_rank]
assert get_kv_source_ranks(topo) == [local_rank]
def test_homogeneous_tp2_rank1(self): @pytest.mark.parametrize(
topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=1) "local_rank,expected_source_ranks",
assert get_kv_target_ranks(topo) == [1] [
assert get_kv_source_ranks(topo) == [1] (0, [0, 1]), # rank 0 receives from sender ranks 0 and 1
(1, [2, 3]), # rank 1 receives from sender ranks 2 and 3
],
)
def test_sender_gt_receiver_tp4_to_tp2(self, local_rank, expected_source_ranks):
"""Receiver with source_tp=4, target_tp=2."""
topo = KVTPTopology(source_tp_size=4, target_tp_size=2, local_rank=local_rank)
assert get_kv_source_ranks(topo) == expected_source_ranks
def test_homogeneous_tp4_rank3(self): @pytest.mark.parametrize(
topo = KVTPTopology(source_tp_size=4, target_tp_size=4, local_rank=3) "local_rank,expected_target_ranks",
assert get_kv_target_ranks(topo) == [3] [
assert get_kv_source_ranks(topo) == [3] (0, [0, 1]), # sender rank 0 sends to receiver ranks 0 and 1
(1, [2, 3]), # sender rank 1 sends to receiver ranks 2 and 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) def test_sender_lt_receiver_tp2_to_tp4(self, local_rank, expected_target_ranks):
assert get_kv_source_ranks(topo) == [0, 1] """Sender with source_tp=2, target_tp=4."""
topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=local_rank)
def test_sender_gt_receiver_tp4_to_tp2_rank1(self): assert get_kv_target_ranks(topo) == expected_target_ranks
"""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): def test_receiver_lt_sender_source_ranks(self):
"""Receiver rank 0 with tp2_to_tp4 should source from rank 0 only.""" """Receiver rank 0 with tp2_to_tp4 should source from rank 0 only."""
@@ -174,21 +178,21 @@ class TestBuildRankAwareRecvKeys:
assert key == "omni_stage0_to_stage1_kv_cache_req-1" assert key == "omni_stage0_to_stage1_kv_cache_req-1"
assert rank is None assert rank is None
def test_homogeneous_tp2_rank0(self): @pytest.mark.parametrize(
topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=0) "local_rank,expected_key,expected_rank",
[
(0, "req-1_stage0_0_0_0", 0),
(1, "req-1_stage0_0_1_1", 1),
],
)
def test_homogeneous_tp2(self, local_rank, expected_key, expected_rank):
"""Homogeneous TP=2: each rank gets one key."""
topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=local_rank)
pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo) pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
assert len(pairs) == 1 assert len(pairs) == 1
key, rank = pairs[0] key, rank = pairs[0]
assert key == "req-1_stage0_0_0_0" assert key == expected_key
assert rank == 0 assert rank == expected_rank
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): 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.""" """Receiver rank 0 with source_tp=4, target_tp=2 should get 2 keys."""
@@ -311,9 +315,16 @@ class TestReceiveConstructsMetadata:
assert len(calls) > 0 assert len(calls) > 0
assert calls[0]["metadata"] is None assert calls[0]["metadata"] is None
def test_homogeneous_tp2_rank0_passes_metadata(self): @pytest.mark.parametrize(
"""TP=2 rank 0: metadata should point to sender rank 0's port.""" "local_rank,expected_port_offset",
mgr = _make_manager(from_tp=2, to_tp=2, local_rank=0, recv_timeout=0.05) [
(0, 0),
(1, 1),
],
)
def test_homogeneous_tp2_passes_metadata(self, local_rank, expected_port_offset):
"""TP=2: metadata should point to sender rank's port with correct offset."""
mgr = _make_manager(from_tp=2, to_tp=2, local_rank=local_rank, recv_timeout=0.05)
mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151}) mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
calls = [] calls = []
@@ -330,24 +341,7 @@ class TestReceiveConstructsMetadata:
meta = calls[0]["metadata"] meta = calls[0]["metadata"]
assert meta is not None assert meta is not None
assert meta["source_host"] == "10.0.0.1" assert meta["source_host"] == "10.0.0.1"
assert meta["source_port"] == 50151 + 0 * KV_RANK_PORT_STRIDE assert meta["source_port"] == 50151 + expected_port_offset * 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): def test_heterogeneous_tp4_to_tp2_rank0_multiple_metadata(self):
"""Receiver rank 0 with source_tp=4, target_tp=2 should call get() with """Receiver rank 0 with source_tp=4, target_tp=2 should call get() with
@@ -677,6 +671,230 @@ class TestDistributedReceive:
mgr.receive_multi_kv_cache.assert_called_once_with(req, None, torch.device("cpu")) mgr.receive_multi_kv_cache.assert_called_once_with(req, None, torch.device("cpu"))
# ── SP-only scenarios ────────────────────────────────────────────
@pytest.mark.parametrize(
"sp_rank,world_rank_in_group,is_owner",
[
(0, 0, True), # owner receives and broadcasts
(1, 1, False), # follower receives via broadcast
],
)
def test_sp_only_scenarios(self, sp_rank, world_rank_in_group, is_owner):
"""SP-only (sp_size=2, cfg_size=1): Test owner and follower behavior."""
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=world_rank_in_group)
sp_payload = {
"past_key_values": SimpleNamespace(key_cache=[torch.tensor([1.0])]),
"kv_metadata": {"source": "owner"},
"sp.past_key_values": SimpleNamespace(key_cache=[torch.tensor([1.0])]),
"sp.kv_metadata": {"source": "owner"},
}
sp_group = _MockBroadcastGroup(world_size=2, rank_in_group=sp_rank, broadcast_value=sp_payload)
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": "owner"}
req_obj.sampling_params.past_key_values = req_obj.past_key_values
req_obj.sampling_params.kv_metadata = req_obj.kv_metadata
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=1,
),
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=None),
patch(
"vllm_omni.diffusion.distributed.parallel_state.get_sequence_parallel_world_size",
return_value=2,
),
patch(
"vllm_omni.diffusion.distributed.parallel_state.get_sequence_parallel_rank",
return_value=sp_rank,
),
patch("vllm_omni.diffusion.distributed.parallel_state.get_sp_group", return_value=sp_group),
):
assert mgr.receive_multi_kv_cache_distributed(req) is True
# Verify behavior based on role
if is_owner:
mgr.receive_multi_kv_cache.assert_called_once()
assert mgr.receive_multi_kv_cache.call_args.args[2] == torch.device("cpu")
assert sp_group.broadcast_calls[0][1] == 0
else:
mgr.receive_multi_kv_cache.assert_not_called()
assert len(sp_group.broadcast_calls) == 1
assert torch.equal(req.past_key_values.key_cache[0], torch.tensor([1.0]))
assert req.sampling_params.kv_metadata == {"source": "owner"}
# ── CFG + SP mixed scenarios ─────────────────────────────────────
@pytest.mark.parametrize(
"cfg_rank,sp_rank,world_rank_in_group,role",
[
(0, 0, 0, "owner"), # owner: receives, cfg scatter, sp broadcast
(1, 0, 2, "cfg_follower_sp_leader"), # cfg follower + sp leader: cfg recv, sp broadcast
(0, 1, 1, "sp_follower"), # sp follower: only sp broadcast
],
)
def test_cfg_sp_mixed_scenarios(self, cfg_rank, sp_rank, world_rank_in_group, role):
"""CFG+SP (cfg_size=2, sp_size=2): Test various rank combinations."""
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=8, rank_in_group=world_rank_in_group)
# Setup payloads based on role
if role == "owner":
owner_sp_payload = {
"past_key_values": SimpleNamespace(key_cache=[torch.tensor([1.0])]),
"kv_metadata": {"source": "owner"},
"sp.past_key_values": SimpleNamespace(key_cache=[torch.tensor([1.0])]),
"sp.kv_metadata": {"source": "owner"},
"sp.cfg_branch_roles": ["cfg_text"],
"sp.cfg_active_branch": None,
}
cfg_group = _MockBroadcastGroup(world_size=2, rank_in_group=cfg_rank)
sp_group = _MockBroadcastGroup(world_size=2, rank_in_group=sp_rank, broadcast_value=owner_sp_payload)
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": "owner"}
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"}
return True
mgr.receive_multi_kv_cache = MagicMock(side_effect=_receive)
else:
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" if role == "cfg_follower_sp_leader" else None,
"sp.cfg_branch_roles": ["cfg_text"],
"sp.cfg_branch_past_key_values": {
"cfg_text": SimpleNamespace(key_cache=[torch.tensor([2.0])]),
}
if role == "cfg_follower_sp_leader"
else {},
}
if role == "cfg_follower_sp_leader":
cfg_payload["sp.cfg_text_past_key_values"] = SimpleNamespace(key_cache=[torch.tensor([2.0])])
cfg_group = _MockBroadcastGroup(world_size=2, rank_in_group=cfg_rank, recv_value=cfg_payload)
sp_group = _MockBroadcastGroup(world_size=2, rank_in_group=sp_rank, broadcast_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=cfg_rank,
),
patch("vllm_omni.diffusion.distributed.parallel_state.get_cfg_group", return_value=cfg_group),
patch(
"vllm_omni.diffusion.distributed.parallel_state.get_sequence_parallel_world_size",
return_value=2,
),
patch(
"vllm_omni.diffusion.distributed.parallel_state.get_sequence_parallel_rank",
return_value=sp_rank,
),
patch("vllm_omni.diffusion.distributed.parallel_state.get_sp_group", return_value=sp_group),
):
assert mgr.receive_multi_kv_cache_distributed(req) is True
# Verify behavior based on role
if role == "owner":
mgr.receive_multi_kv_cache.assert_called_once()
assert mgr.receive_multi_kv_cache.call_args.args[2] == torch.device("cpu")
assert [dst for dst, _ in cfg_group.send_calls] == [1]
assert cfg_group.send_calls[0][1]["sp.cfg_active_branch"] == "cfg_text"
assert len(sp_group.broadcast_calls) == 1
assert sp_group.broadcast_calls[0][1] == 0
elif role == "cfg_follower_sp_leader":
mgr.receive_multi_kv_cache.assert_not_called()
assert cfg_group.recv_calls == [0]
assert len(sp_group.broadcast_calls) == 1
assert sp_group.broadcast_calls[0][1] == 0
assert req.sampling_params.cfg_active_branch == "cfg_text"
assert torch.equal(
req.sampling_params.cfg_branch_past_key_values["cfg_text"].key_cache[0],
torch.tensor([2.0]),
)
else: # sp_follower
mgr.receive_multi_kv_cache.assert_not_called()
assert cfg_group.recv_calls == []
assert cfg_group.send_calls == []
assert len(sp_group.broadcast_calls) == 1
assert torch.equal(req.past_key_values.key_cache[0], torch.tensor([1.0]))
assert req.kv_metadata == {"source": "main"}
@pytest.mark.parametrize(
"has_cfg,cfg_size",
[
(False, 1), # SP-only: owner receive failure
(True, 2), # CFG+SP: owner receive failure with deadlock prevention
],
)
def test_owner_receive_failure_scenarios(self, has_cfg, cfg_size):
"""Test receive failure handling in SP-only and CFG+SP scenarios."""
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 if not has_cfg else 8, rank_in_group=0)
cfg_group = _MockBroadcastGroup(world_size=2, rank_in_group=0) if has_cfg else None
sp_group = _MockBroadcastGroup(world_size=2, rank_in_group=0, broadcast_value=None)
mgr.receive_multi_kv_cache = MagicMock(return_value=False)
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=cfg_size,
),
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),
patch(
"vllm_omni.diffusion.distributed.parallel_state.get_sequence_parallel_world_size",
return_value=2,
),
patch(
"vllm_omni.diffusion.distributed.parallel_state.get_sequence_parallel_rank",
return_value=0,
),
patch("vllm_omni.diffusion.distributed.parallel_state.get_sp_group", return_value=sp_group),
):
assert mgr.receive_multi_kv_cache_distributed(req) is False
# Owner attempted to receive but failed
mgr.receive_multi_kv_cache.assert_called_once()
# SP broadcast still called but with None
assert len(sp_group.broadcast_calls) == 1
assert sp_group.broadcast_calls[0][0] is None
# CFG+SP: owner must send None to cfg followers to prevent deadlock
if has_cfg:
assert len(cfg_group.send_calls) == 1
assert cfg_group.send_calls[0] == (1, None)
# ── TP auto-detect ─────────────────────────────────────────────────── # ── TP auto-detect ───────────────────────────────────────────────────
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+6
View File
@@ -185,6 +185,12 @@ def accuracy_artifact_root() -> Path:
return root return root
@pytest.fixture(scope="session")
def accuracy_assets_root() -> Path:
root = Path(__file__).resolve().parent / "assets"
return root
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def qwen_bear_image(accuracy_artifact_root: Path): def qwen_bear_image(accuracy_artifact_root: Path):
"""Download the Qwen bear image from the URL and save it to the accuracy artifact root.""" """Download the Qwen bear image from the URL and save it to the accuracy artifact root."""
+150
View File
@@ -1,9 +1,11 @@
import os import os
from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import numpy as np import numpy as np
import pytest import pytest
import requests
import torch import torch
from PIL import Image from PIL import Image
from torchmetrics.image import PeakSignalNoiseRatio, StructuralSimilarityIndexMeasure from torchmetrics.image import PeakSignalNoiseRatio, StructuralSimilarityIndexMeasure
@@ -125,6 +127,74 @@ def assert_image_sequence_similarity(
) )
def assert_images_pixel_close(
*,
model_name: str,
vllm_image: Image.Image,
diffusers_image: Image.Image,
mean_threshold: float,
p99_threshold: float,
compare_mode: str = "RGB",
) -> None:
"""Assert full-image pixel closeness between online serving and diffusers output.
Match the threshold style used by diffusion parallelism tests: convert both
images to float32 RGB tensors in the [0, 1] range, then compare mean and p99
absolute channel differences.
Accuracy improves as both values move lower:
- ``mean_abs_diff`` tracks global average drift across the whole image.
- ``p99_abs_diff`` tracks tail drift while ignoring the noisiest 1% of
channel samples.
The printed mismatch ratios are diagnostics only. ``pixel_ratio`` counts
pixels where any channel exceeds a tolerance, while ``channel_ratio`` counts
individual channel samples above that tolerance.
"""
assert vllm_image.size == diffusers_image.size, (
f"Online and diffusers output sizes mismatch: online={vllm_image.size}, diffusers={diffusers_image.size}"
)
vllm_array = np.asarray(vllm_image.convert(compare_mode), dtype=np.float32) / 255.0
diffusers_array = np.asarray(diffusers_image.convert(compare_mode), dtype=np.float32) / 255.0
channel_abs_diff = np.abs(vllm_array - diffusers_array)
mean_abs_diff = float(channel_abs_diff.mean())
p99_abs_diff = float(np.quantile(channel_abs_diff, 0.99))
percentiles = {
percentile: float(np.quantile(channel_abs_diff, percentile / 100.0)) for percentile in (50, 90, 95, 99, 99.9)
}
print(f"{model_name} pixel metrics:")
print(
f" mean_abs_diff: value={mean_abs_diff:.6e}, threshold<={mean_threshold:.6e}, "
"range=[0, 1], lower_is_better=True"
)
print(
f" p99_abs_diff: value={p99_abs_diff:.6e}, threshold<={p99_threshold:.6e}, range=[0, 1], lower_is_better=True"
)
print(" abs_diff_percentiles:")
for percentile, value in percentiles.items():
print(f" p{percentile}: value={value:.6e}, range=[0, 1], lower_is_better=True")
print(" mismatch_ratios_by_channel_threshold:")
for tolerance in (0, 1, 2, 4, 8, 16, 32, 64, 128):
normalized_tolerance = tolerance / 255.0
pixel_mismatch = np.any(channel_abs_diff > normalized_tolerance, axis=-1)
pixel_mismatch_ratio = float(np.count_nonzero(pixel_mismatch) / pixel_mismatch.size)
channel_mismatch_ratio = float(
np.count_nonzero(channel_abs_diff > normalized_tolerance) / channel_abs_diff.size
)
print(
f" threshold>{normalized_tolerance:.6e} ({tolerance}/255): pixel_ratio={pixel_mismatch_ratio:.8f}, "
f"channel_ratio={channel_mismatch_ratio:.8f}"
)
assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, (
f"Image diff exceeded threshold for {model_name}: mean_abs_diff={mean_abs_diff:.6e}, "
f"p99_abs_diff={p99_abs_diff:.6e} (thresholds: mean<={mean_threshold:.6e}, "
f"p99<={p99_threshold:.6e})"
)
def compute_image_ssim_psnr( def compute_image_ssim_psnr(
*, *,
prediction: Image.Image, prediction: Image.Image,
@@ -161,6 +231,16 @@ class CLIPScorer:
similarity = (img_emb * txt_emb).sum(dim=-1) similarity = (img_emb * txt_emb).sum(dim=-1)
return float(similarity.item() * 100) return float(similarity.item() * 100)
def image_image_score(self, image1: Image.Image, image2: Image.Image) -> float:
inputs = self._processor(text=[""], images=[image1, image2], return_tensors="pt", padding=True)
with torch.no_grad():
outputs = self._model(**inputs)
img_emb1, img_emb2 = outputs.image_embeds.chunk(2)
img_emb1 = img_emb1 / img_emb1.norm(p=2, dim=-1, keepdim=True)
img_emb2 = img_emb2 / img_emb2.norm(p=2, dim=-1, keepdim=True)
similarity = (img_emb1 * img_emb2).sum(dim=-1)
return float(similarity.item() * 100)
def assert_score(self, *, model_name: str, image: Image.Image, text: str, threshold: float) -> None: def assert_score(self, *, model_name: str, image: Image.Image, text: str, threshold: float) -> None:
value = self.score(image, text) value = self.score(image, text)
print(f"{model_name} CLIP score:") print(f"{model_name} CLIP score:")
@@ -180,3 +260,73 @@ def _pil_to_batched_tensor(image: Image.Image, *, compare_mode: str) -> torch.Te
array = np.asarray(image.convert(compare_mode), dtype=np.float32) / 255.0 array = np.asarray(image.convert(compare_mode), dtype=np.float32) / 255.0
tensor = torch.from_numpy(array).permute(2, 0, 1).unsqueeze(0) tensor = torch.from_numpy(array).permute(2, 0, 1).unsqueeze(0)
return tensor return tensor
class SemanticSimilarityScorer:
"""Semantic similarity scorer for text-text and image-text comparisons"""
def __init__(
self,
text_model_name: str = "BAAI/bge-m3",
):
"""
Initialize semantic similarity scorer
Args:
text_model_name: Model for text-text similarity (BGE-M3)
"""
self._text_model = None
self._text_model_name = text_model_name
def _load_text_model(self):
"""Lazy load text similarity model (BGE-M3)"""
if self._text_model is None:
try:
from FlagEmbedding import BGEM3FlagModel
print(f"Loading text similarity model: {self._text_model_name}")
self._text_model = BGEM3FlagModel(self._text_model_name, use_fp16=True)
except ImportError:
raise ImportError("FlagEmbedding not installed. Install with: pip install FlagEmbedding")
return self._text_model
def text_similarity(self, text1: str, text2: str) -> float:
"""
Compute semantic similarity between two texts using BGE-M3
"""
results = {}
# 1. Text prefix matching (maximum matching character length)
match_count = 0
for char_vllm, char_ref in zip(text1, text2):
if char_vllm == char_ref:
match_count += 1
else:
break
results["text_prefix_match_count"] = match_count
# 2. Full CoT semantic similarity comparison
model = self._load_text_model()
emb1 = model.encode([text1], return_dense=True, return_sparse=False, return_colbert_vecs=False)["dense_vecs"][0]
emb2 = model.encode([text2], return_dense=True, return_sparse=False, return_colbert_vecs=False)["dense_vecs"][0]
similarity = np.dot(emb1, emb2) / (np.linalg.norm(emb1) * np.linalg.norm(emb2))
results["cot_semantic_sim"] = float(similarity)
return results
def release_models(self):
"""Release loaded models to free memory"""
if self._text_model is not None:
del self._text_model
self._text_model = None
def download_images(urls: list[str]) -> list[Image.Image]:
"""Download and cache multiple images"""
images = []
for i, url in enumerate(urls):
print(f"Downloading image {i} from {url}")
response = requests.get(url, timeout=30)
response.raise_for_status()
image = Image.open(BytesIO(response.content)).convert("RGB")
images.append(image)
return images
@@ -284,7 +284,6 @@ def test_diffusers_backend_t2i_matches_diffusers(model_id: str, accuracy_artifac
[ [
pytest.param( pytest.param(
"Wan-AI/Wan2.2-I2V-A14B-Diffusers", "Wan-AI/Wan2.2-I2V-A14B-Diffusers",
marks=pytest.mark.skip(reason="#3488"),
), ),
], ],
) )
+118 -84
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import copy import copy
import gc import gc
import importlib
import os
import tempfile import tempfile
import time import time
from pathlib import Path from pathlib import Path
@@ -14,28 +16,59 @@ import torch
import yaml import yaml
from PIL import Image from PIL import Image
from tests.e2e.accuracy.helpers import CLIPScorer, assert_similarity, model_output_dir from tests.e2e.accuracy.helpers import (
CLIPScorer,
SemanticSimilarityScorer,
compute_image_ssim_psnr,
download_images,
model_output_dir,
)
from tests.helpers.runtime import OmniRunner from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import build_prompt_tokens from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import build_prompt_tokens, resolve_stop_token_ids
os.environ["DIFFUSION_ATTENTION_BACKEND"] = "TORCH_SDPA"
pytestmark = [pytest.mark.full_model, pytest.mark.diffusion] pytestmark = [pytest.mark.full_model, pytest.mark.diffusion]
# ============================================================================
# Configurable Parameters
# ============================================================================
AR_DEVICES = "0,1"
DIT_DEVICES = "2,3"
MODEL_NAME = "tencent/HunyuanImage-3.0-Instruct" MODEL_NAME = "tencent/HunyuanImage-3.0-Instruct"
SEED = 42
NUM_INFERENCE_STEPS = 50 NUM_INFERENCE_STEPS = 50
GUIDANCE_SCALE = 0 GUIDANCE_SCALE = 2.5
HEIGHT, WIDTH = 1024, 1024
PSNR_THRESHOLD = 0.0
SSIM_THRESHOLD = 0.0
CLIP_SCORE_THRESHOLD = 20.0
PROMPT = "A brown and white dog is running on the grass."
# ============================================================================
# Constants
# ============================================================================
MODEL_PATH = os.environ.get("HUNYUAN_MODEL_PATH", MODEL_NAME)
# Test input
PROMPT = "基于图一的logo,参考图二中冰箱贴的材质,制作一个新的冰箱贴"
TEST_IMAGE_URLS = [
"https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_1_0.png",
"https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_1_1.png",
]
SEED = 42
AR_TP_SIZE = len(AR_DEVICES.split(","))
DIT_TP_SIZE = len(DIT_DEVICES.split(","))
# Precision thresholds
THRESHOLDS = {
# AR text comparison
"text_prefix_match": 10, # First 10 characters must match exactly
"cot_semantic_sim": 0.9, # Full CoT semantic similarity
# Image comparison
"clip_score": 85, # CLIP image semantic similarity
"ssim": 0.20, # Structural similarity
"psnr": 11.0, # Peak signal-to-noise ratio (dB)
}
# fmt: off # fmt: off
_BASE_CONFIG = { _BASE_CONFIG = {
"stage_args": [ "stage_args": [
{ {
"stage_id": 0, "stage_type": "llm", "stage_id": 0, "stage_type": "llm",
"runtime": {"process": True, "devices": "0,1", "max_batch_size": 1, "requires_multimodal_data": True}, "runtime": {"process": True, "devices": AR_DEVICES, "max_batch_size": 1, "requires_multimodal_data": True},
"engine_args": { "engine_args": {
"model_stage": "AR", "model_arch": "HunyuanImage3ForCausalMM", "model_stage": "AR", "model_arch": "HunyuanImage3ForCausalMM",
"worker_cls": "vllm_omni.worker.gpu_ar_worker.GPUARWorker", "worker_cls": "vllm_omni.worker.gpu_ar_worker.GPUARWorker",
@@ -50,11 +83,11 @@ _BASE_CONFIG = {
"temperature": 0.0, "top_p": 1, "top_k": -1, "max_tokens": 8192, "temperature": 0.0, "top_p": 1, "top_k": -1, "max_tokens": 8192,
"stop_token_ids": [128025], "detokenize": True, "skip_special_tokens": False, "stop_token_ids": [128025], "detokenize": True, "skip_special_tokens": False,
}, },
"output_connectors": {"to_stage_1": "rdma_connector"}, "output_connectors": {"to_stage_1": "shared_memory_connector"},
}, },
{ {
"stage_id": 1, "stage_type": "diffusion", "stage_id": 1, "stage_type": "diffusion",
"runtime": {"process": True, "devices": "2,3", "max_batch_size": 1, "requires_multimodal_data": True}, "runtime": {"process": True, "devices": DIT_DEVICES, "max_batch_size": 1, "requires_multimodal_data": True},
"engine_args": { "engine_args": {
"model_stage": "dit", "model_arch": "HunyuanImage3ForCausalMM", "model_stage": "dit", "model_arch": "HunyuanImage3ForCausalMM",
"enforce_eager": True, "trust_remote_code": True, "distributed_executor_backend": "mp", "enforce_eager": True, "trust_remote_code": True, "distributed_executor_backend": "mp",
@@ -64,20 +97,32 @@ _BASE_CONFIG = {
"custom_process_input_func": "vllm_omni.model_executor.stage_input_processors.hunyuan_image3.ar2diffusion", "custom_process_input_func": "vllm_omni.model_executor.stage_input_processors.hunyuan_image3.ar2diffusion",
"final_output": True, "final_output_type": "image", "final_output": True, "final_output_type": "image",
"default_sampling_params": {"num_inference_steps": NUM_INFERENCE_STEPS, "guidance_scale": GUIDANCE_SCALE}, "default_sampling_params": {"num_inference_steps": NUM_INFERENCE_STEPS, "guidance_scale": GUIDANCE_SCALE},
"input_connectors": {"from_stage_0": "rdma_connector"}, "input_connectors": {"from_stage_0": "shared_memory_connector"},
}, },
], ],
"runtime": { "runtime": {
"enabled": True, "enabled": True,
"connectors": {"rdma_connector": { "connectors": {"shared_memory_connector": {
"name": "MooncakeTransferEngineConnector", "name": "SharedMemoryConnector",
"extra": {"host": "auto", "zmq_port": 50051, "protocol": "rdma", "device_name": "", "memory_pool_size": 4294967296, "memory_pool_device": "cpu"}, "extra": {"shm_threshold_bytes": 65536}
}}, }},
"edges": [{"from": 0, "to": 1}], "edges": [{"from": 0, "to": 1}],
}, },
} }
# fmt: on # fmt: on
# fmt: off
COT_REF = ("首先,我分析所有输入图像:图像1是一个圆形的logo,设计现代且抽象。它由不同色调的蓝色(深蓝、中蓝、浅蓝)和白色构成,这些色块以流畅的曲线相互交织,形成一个动态的、类似旋涡或波浪的图案。整个logo是扁平化的矢量图形,背景为纯黑色。图像2展示了四个并排摆放的卡通动物造型冰箱贴,"
"它们被放置在灰色的织物背景上。这些冰箱贴的关键特征是其材质:它们具有光滑、高光的珐琅或烤漆质感,边缘有明显的金属包边,整体呈现出一种立体的、有厚度的实体感。用户的指令是“基于图一的logo,参考图二中冰箱贴的材质,制作一个新的冰箱贴”。这个指令要求将一个二维的平面设计(logo)"
"转化为一个具有特定物理属性(材质和立体感)的三维物体。核心任务是保留logo的视觉识别性,同时赋予其冰箱贴的实体质感。为了构建答案图像,我会将图一的圆形logo作为基础形状。然后,我会将图二中冰箱贴的材质特性应用到这个logo上。具体来说,logo中的每一个色块(深蓝、中蓝、浅蓝、白色)"
"都会被渲染成具有高光泽度的珐琅质感,表面会反射出柔和的环境光,形成自然的高光。logo中不同颜色区域之间的分界线,将被处理成纤细的、带有金属光泽的凸起边缘,这既能清晰地勾勒出图案,也符合珐琅工艺品的典型特征。整个冰箱贴会呈现出轻微的厚度和圆润的边缘,使其看起来像一个真实的、可触摸的物体。"
"最后,将这个制作完成的冰箱贴放置在图二所示的灰色织物背景上,并为其添加一个微妙的、柔和的阴影,以增强其立体感和与背景的融合度,最终呈现出一个精致、逼真的产品展示图。</think><recaption>这幅图像以产品摄影的精致风格,呈现了一枚根据`image_1`标志定制的圆形珐琅冰箱贴。最终图像使用`image_2`的分辨率。"
"冰箱贴居中放置在`image_2`的灰色织物背景上,其设计完美复刻了`image_1`中由深蓝、中蓝、浅蓝和白色构成的动态旋涡图案。整个冰箱贴被赋予了`image_2`中冰箱贴特有的高级质感:表面覆盖着一层光滑如镜的珐琅釉面,反射出柔和而清晰的高光;图案的每一个色块边缘都由纤细的抛光金属边框精确勾勒,增强了立体感。"
"柔和的顶光在冰箱贴的弧形边缘上形成平滑的过渡,并在其下方投下淡淡的、轮廓模糊的阴影,使其与织物背景无缝融合,营造出一种真实、静谧的视觉效果。<relation_1>最终图像完整保留了`image_1`中标志的全部设计元素。这包括其完美的圆形轮廓,以及内部由深蓝、中蓝、浅蓝和白色组成的精确旋涡状图案布局、形状和色彩关系。"
"</relation_1><relation_2>最终图像的分辨率、背景和材质均来自`image_2`。背景中灰色织物的纹理和质感被完整保留。冰箱贴的材质被完美重构,精确复刻了`image_2`中冰箱贴所展示的光滑珐琅质感、抛光金属边框的视觉效果,以及整体柔和、均匀的布光环境和由此产生的自然阴影。</relation_2></recaption><answer><boi>"
"<img_size_1024><img_ratio_36><timestep>[<img>]{3600}<eoi></answer>")
# fmt: on
def _make_config(enable_kv_reuse: bool, path: Path) -> None: def _make_config(enable_kv_reuse: bool, path: Path) -> None:
config = copy.deepcopy(_BASE_CONFIG) config = copy.deepcopy(_BASE_CONFIG)
@@ -86,18 +131,25 @@ def _make_config(enable_kv_reuse: bool, path: Path) -> None:
path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
def _run(stage_config_path: str, output_path: Path) -> tuple[Image.Image, str, float]: def _run_offline(stage_configs_path: str, output_path: Path) -> tuple[Image.Image, str, float]:
from transformers import AutoTokenizer from transformers import AutoTokenizer
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType
from vllm_omni.platforms import current_omni_platform from vllm_omni.platforms import current_omni_platform
build_kwargs: dict = {"task": "it2i", "bot_task": "think_recaption", "sys_type": "en_unified", "num_images": 2}
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True) tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
result = build_prompt_tokens(PROMPT, tokenizer, task="it2i_recaption", sys_type="en_unified") result = build_prompt_tokens(
PROMPT,
tokenizer,
**build_kwargs,
)
token_ids = result.token_ids token_ids = result.token_ids
system_prompt_type = result.system_prompt_type system_prompt_type = result.system_prompt_type
with OmniRunner(MODEL_NAME, stage_configs_path=stage_config_path) as runner: ar_stop_token_ids = resolve_stop_token_ids(task="it2i", bot_task="think_recaption", tokenizer=tokenizer)
with OmniRunner(MODEL_NAME, stage_configs_path=stage_configs_path) as runner:
params_list = list(runner.omni.default_sampling_params_list) params_list = list(runner.omni.default_sampling_params_list)
for sp in params_list: for sp in params_list:
if isinstance(sp, OmniDiffusionSamplingParams): if isinstance(sp, OmniDiffusionSamplingParams):
@@ -105,14 +157,17 @@ def _run(stage_config_path: str, output_path: Path) -> tuple[Image.Image, str, f
sp.guidance_scale = GUIDANCE_SCALE sp.guidance_scale = GUIDANCE_SCALE
sp.seed = SEED sp.seed = SEED
sp.generator = torch.Generator(device=current_omni_platform.device_type or "cuda").manual_seed(SEED) sp.generator = torch.Generator(device=current_omni_platform.device_type or "cuda").manual_seed(SEED)
elif hasattr(sp, "stop_token_ids"):
sp.stop_token_ids = ar_stop_token_ids
images = download_images(TEST_IMAGE_URLS)
prompts: list[OmniPromptType] = [ prompts: list[OmniPromptType] = [
{ {
"prompt_token_ids": token_ids, "prompt_token_ids": token_ids,
"prompt": PROMPT, "prompt": PROMPT,
"use_system_prompt": system_prompt_type, "use_system_prompt": system_prompt_type,
"height": HEIGHT, "modalities": ["image"],
"width": WIDTH, "multi_modal_data": {"image": images},
} }
] ]
t0 = time.perf_counter() t0 = time.perf_counter()
@@ -125,9 +180,13 @@ def _run(stage_config_path: str, output_path: Path) -> tuple[Image.Image, str, f
for out in outputs: for out in outputs:
ro = getattr(out, "request_output", None) ro = getattr(out, "request_output", None)
if ro and getattr(ro, "outputs", None): if ro and getattr(ro, "outputs", None):
txt = "".join(getattr(o, "text", "") or "" for o in ro.outputs) cot_text = "".join(getattr(o, "text", "") or "" for o in ro.outputs)
if txt: if not cot_text:
cot_text = txt ar_text = getattr(out, "custom_output", {}).get("ar_generated_text")
if isinstance(ar_text, list):
cot_text = "\n".join(text for text in ar_text if text)
else:
cot_text = ar_text or ""
imgs = getattr(out, "images", None) imgs = getattr(out, "images", None)
if not imgs and ro and hasattr(ro, "images"): if not imgs and ro and hasattr(ro, "images"):
@@ -136,9 +195,11 @@ def _run(stage_config_path: str, output_path: Path) -> tuple[Image.Image, str, f
images = imgs images = imgs
assert images, "Pipeline output had no images" assert images, "Pipeline output had no images"
cot_text = cot_text.lstrip("\n")
image = images[0].convert("RGB") image = images[0].convert("RGB")
image.save(output_path) image.save(output_path / "image_offline.png")
(output_path / "cot_offline.txt").write_text(cot_text, encoding="utf-8")
gc.collect() gc.collect()
if torch.accelerator.is_available(): if torch.accelerator.is_available():
torch.accelerator.empty_cache() torch.accelerator.empty_cache()
@@ -146,71 +207,44 @@ def _run(stage_config_path: str, output_path: Path) -> tuple[Image.Image, str, f
@pytest.mark.skipif(torch.accelerator.device_count() < 4, reason="Needs 4+ GPUs (2 AR + 2 DiT)") @pytest.mark.skipif(torch.accelerator.device_count() < 4, reason="Needs 4+ GPUs (2 AR + 2 DiT)")
def test_text_to_image_alignment(accuracy_artifact_root: Path) -> None: def test_image_to_image_alignment(accuracy_artifact_root: Path, accuracy_assets_root: Path) -> None:
"""KV reuse ON vs OFF: same pipeline, same seed → PSNR >= 40 dB.""" if importlib.util.find_spec("FlagEmbedding") is None:
output_dir = model_output_dir(accuracy_artifact_root, MODEL_NAME + "-kv-reuse") raise ImportError("Missing dependency: FlagEmbedding\nInstall with: pip install FlagEmbedding")
from tabulate import tabulate # lazy import
"""KV reuse ON vs OFF: same pipeline, same seed → PSNR >= 10 dB."""
output_dir = model_output_dir(accuracy_artifact_root, MODEL_NAME + "-offline-kv-reuse")
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir) tmp = Path(tmpdir)
_make_config(False, tmp / "off.yaml")
image_no_reuse_1, cot_no_reuse_1, time_no_reuse_1 = _run(
str(tmp / "off.yaml"), output_dir / "without_kv_reuse_1.png"
)
image_no_reuse_2, cot_no_reuse_2, time_no_reuse_2 = _run(
str(tmp / "off.yaml"), output_dir / "without_kv_reuse_2.png"
)
_make_config(True, tmp / "on.yaml") _make_config(True, tmp / "on.yaml")
image_reuse, cot_reuse, time_reuse = _run(str(tmp / "on.yaml"), output_dir / "with_kv_reuse.png") omni_image, omni_cot, time_reuse = _run_offline(str(tmp / "on.yaml"), output_dir)
print("\n--- End-to-end time ---")
print(f" WITHOUT KV reuse 1: {time_no_reuse_1:.2f}s")
print(f" WITHOUT KV reuse 2: {time_no_reuse_2:.2f}s")
print(f" WITH KV reuse: {time_reuse:.2f}s")
(output_dir / "cot_with_kv_reuse.txt").write_text(cot_reuse, encoding="utf-8")
(output_dir / "cot_without_kv_reuse_1.txt").write_text(cot_no_reuse_1, encoding="utf-8")
(output_dir / "cot_without_kv_reuse_2.txt").write_text(cot_no_reuse_2, encoding="utf-8")
print(f"\n--- CoT WITH KV reuse (len={len(cot_reuse)}) ---\n{cot_reuse}")
print(f"\n--- CoT WITHOUT KV reuse 1 (len={len(cot_no_reuse_1)}) ---\n{cot_no_reuse_1}")
print(f"\n--- CoT WITHOUT KV reuse 2 (len={len(cot_no_reuse_2)}) ---\n{cot_no_reuse_2}")
scorer = SemanticSimilarityScorer()
clip_scorer = CLIPScorer() clip_scorer = CLIPScorer()
clip_scorer.assert_score( cot_results = scorer.text_similarity(omni_cot, COT_REF)
model_name=f"{MODEL_NAME} with-reuse", image_ref = Image.open(str(accuracy_assets_root / "hunyuan_image_ref.png")).convert("RGB")
image=image_reuse, image_clip_score = clip_scorer.image_image_score(omni_image, image_ref)
text=PROMPT, ssim_value, psnr_value = compute_image_ssim_psnr(prediction=omni_image, reference=image_ref, compare_mode="RGB")
threshold=CLIP_SCORE_THRESHOLD,
)
clip_scorer.assert_score(
model_name=f"{MODEL_NAME} without-reuse 1",
image=image_no_reuse_1,
text=PROMPT,
threshold=CLIP_SCORE_THRESHOLD,
)
clip_scorer.assert_score(
model_name=f"{MODEL_NAME} without-reuse 2",
image=image_no_reuse_2,
text=PROMPT,
threshold=CLIP_SCORE_THRESHOLD,
)
assert_similarity( table = [
model_name=f"{MODEL_NAME} non-reuse run1 vs run2", ["COT similarity to reference", f"{cot_results['cot_semantic_sim']:.4f}", 0.9644],
vllm_image=image_no_reuse_1, ["COT prefix match", f"{cot_results['text_prefix_match_count']:.4f}", 29],
diffusers_image=image_no_reuse_2, ["Image-Image similarity", f"{image_clip_score:.4f}", 94.5538],
ssim_threshold=SSIM_THRESHOLD, ["SSIM", f"{ssim_value:.4f}", 0.242],
psnr_threshold=PSNR_THRESHOLD, ["PSNR (dB)", f"{psnr_value:.2f}", 14.1],
width=WIDTH, ]
height=HEIGHT,
)
assert_similarity( print(tabulate(table, headers=["Metric", "Value", "L20x Reference"], tablefmt="grid"))
model_name=f"{MODEL_NAME} KV-reuse vs no-reuse",
vllm_image=image_reuse, assert cot_results["cot_semantic_sim"] >= THRESHOLDS["cot_semantic_sim"], (
diffusers_image=image_no_reuse_1, f"COT semantic similarity {cot_results['cot_semantic_sim']:.4f} is below threshold {THRESHOLDS['cot_semantic_sim']}"
ssim_threshold=SSIM_THRESHOLD,
psnr_threshold=PSNR_THRESHOLD,
width=WIDTH,
height=HEIGHT,
) )
assert cot_results["text_prefix_match_count"] >= THRESHOLDS["text_prefix_match"], (
f"COT prefix match count {cot_results['text_prefix_match_count']} is below threshold {THRESHOLDS['text_prefix_match']}"
)
assert image_clip_score >= THRESHOLDS["clip_score"], (
f"Image-Image similarity{image_clip_score:.4f} is below threshold {THRESHOLDS['clip_score']}"
)
assert ssim_value >= THRESHOLDS["ssim"], f"SSIM {ssim_value:.4f} is below threshold {THRESHOLDS['ssim']}"
assert psnr_value >= THRESHOLDS["psnr"], f"PSNR {psnr_value:.2f} dB is below threshold {THRESHOLDS['psnr']} dB"
+113 -1
View File
@@ -12,7 +12,7 @@ import torch
from diffusers.pipelines.pipeline_utils import DiffusionPipeline from diffusers.pipelines.pipeline_utils import DiffusionPipeline
from PIL import Image from PIL import Image
from tests.e2e.accuracy.helpers import assert_similarity, model_output_dir from tests.e2e.accuracy.helpers import assert_images_pixel_close, assert_similarity, model_output_dir
from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
from tests.helpers.mark import hardware_test from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer from tests.helpers.runtime import OmniServer
@@ -32,11 +32,38 @@ SEED = 42
SSIM_THRESHOLD = 0.97 SSIM_THRESHOLD = 0.97
PSNR_THRESHOLD = 30.0 PSNR_THRESHOLD = 30.0
MODEL_2512_ID = "Qwen/Qwen-Image-2512"
MODEL_2512_ENV_VAR = "QWEN_IMAGE_2512_MODEL"
PROMPT_2512 = (
"A 20-year-old East Asian girl with delicate, charming features and large, bright brown eyes—expressive and "
"lively, with a cheerful or subtly smiling expression. Her naturally wavy long hair is either loose or tied in "
"twin ponytails. She has fair skin and light makeup accentuating her youthful freshness. She wears a modern, "
"cute dress or relaxed outfit in bright, soft colors—lightweight fabric, minimalist cut. She stands indoors at "
"an anime convention, surrounded by banners, posters, or stalls. Lighting is typical indoor illumination—no "
"staged lighting—and the image resembles a casual iPhone snapshot: unpretentious composition, yet brimming "
"with vivid, fresh, youthful charm."
)
NEGATIVE_PROMPT_2512 = (
"低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感。"
"构图混乱。文字模糊,扭曲。"
)
WIDTH_2512 = 1664
HEIGHT_2512 = 928
NUM_INFERENCE_STEPS_2512 = 50
TRUE_CFG_SCALE_2512 = 4.0
SEED_2512 = 42
MEAN_ABS_DIFF_THRESHOLD_2512 = 3e-2
P99_ABS_DIFF_THRESHOLD_2512 = 4e-1
def _model_name() -> str: def _model_name() -> str:
return os.environ.get(MODEL_ENV_VAR, MODEL_ID) return os.environ.get(MODEL_ENV_VAR, MODEL_ID)
def _model_2512_name() -> str:
return os.environ.get(MODEL_2512_ENV_VAR, MODEL_2512_ID)
def _local_files_only(model: str) -> bool: def _local_files_only(model: str) -> bool:
return Path(model).exists() return Path(model).exists()
@@ -102,6 +129,67 @@ def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> Image.Image:
run_post_test_cleanup() run_post_test_cleanup()
def _run_vllm_omni_qwen_image_2512(*, 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_2512,
"size": f"{WIDTH_2512}x{HEIGHT_2512}",
"n": 1,
"response_format": "b64_json",
"negative_prompt": NEGATIVE_PROMPT_2512,
"num_inference_steps": NUM_INFERENCE_STEPS_2512,
"true_cfg_scale": TRUE_CFG_SCALE_2512,
"seed": SEED_2512,
},
timeout=1200,
)
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_2512(*, model: str, output_path: Path) -> Image.Image:
run_pre_test_cleanup()
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_2512)
result = pipe( # pyright: ignore[reportCallIssue]
prompt=PROMPT_2512,
negative_prompt=NEGATIVE_PROMPT_2512,
width=WIDTH_2512,
height=HEIGHT_2512,
num_inference_steps=NUM_INFERENCE_STEPS_2512,
true_cfg_scale=TRUE_CFG_SCALE_2512,
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.accelerator.empty_cache()
run_post_test_cleanup()
@pytest.mark.benchmark @pytest.mark.benchmark
@hardware_test(res={"cuda": "H100"}, num_cards=1) @hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_qwen_image_matches_diffusers(accuracy_artifact_root: Path) -> None: def test_qwen_image_matches_diffusers(accuracy_artifact_root: Path) -> None:
@@ -120,3 +208,27 @@ def test_qwen_image_matches_diffusers(accuracy_artifact_root: Path) -> None:
ssim_threshold=SSIM_THRESHOLD, ssim_threshold=SSIM_THRESHOLD,
psnr_threshold=PSNR_THRESHOLD, psnr_threshold=PSNR_THRESHOLD,
) )
@pytest.mark.benchmark
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_qwen_image_2512_matches_diffusers_pixelwise(accuracy_artifact_root: Path) -> None:
model = _model_2512_name()
output_dir = model_output_dir(accuracy_artifact_root, MODEL_2512_ID)
vllm_output_path = output_dir / "vllm_omni_2512.png"
diffusers_output_path = output_dir / "diffusers_2512.png"
vllm_output = _run_vllm_omni_qwen_image_2512(model=model, output_path=vllm_output_path)
diffusers_output = _run_diffusers_qwen_image_2512(model=model, output_path=diffusers_output_path)
print(f"{MODEL_2512_ID} generated images:")
print(f" vllm_omni: {vllm_output_path}")
print(f" diffusers: {diffusers_output_path}")
assert_images_pixel_close(
model_name=MODEL_2512_ID,
vllm_image=vllm_output,
diffusers_image=diffusers_output,
mean_threshold=MEAN_ABS_DIFF_THRESHOLD_2512,
p99_threshold=P99_ABS_DIFF_THRESHOLD_2512,
)
+157 -5
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import gc import gc
import os
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -10,7 +11,7 @@ from diffusers import QwenImageEditPipeline, QwenImageEditPlusPipeline
from PIL import Image from PIL import Image
from benchmarks.accuracy.common import decode_base64_image, pil_to_png_bytes 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.e2e.accuracy.helpers import assert_images_pixel_close, assert_similarity, model_output_dir
from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
from tests.helpers.mark import hardware_test from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer from tests.helpers.runtime import OmniServer
@@ -28,12 +29,43 @@ SEED = 42
SSIM_THRESHOLD = 0.94 SSIM_THRESHOLD = 0.94
PSNR_THRESHOLD = 28.0 PSNR_THRESHOLD = 28.0
EDIT_2511_MODEL = "Qwen/Qwen-Image-Edit-2511"
EDIT_2511_MODEL_ENV_VAR = "QWEN_IMAGE_EDIT_2511_MODEL"
EDIT_2511_WIDTH = 1190
EDIT_2511_HEIGHT = 2032
EDIT_2511_NUM_INFERENCE_STEPS = 40
EDIT_2511_TRUE_CFG_SCALE = 4.0
EDIT_2511_GUIDANCE_SCALE = 1.0
EDIT_2511_SEED = 42
EDIT_2511_MEAN_ABS_DIFF_THRESHOLD = 3e-2
EDIT_2511_P99_ABS_DIFF_THRESHOLD = 4e-1
EDIT_2511_NEGATIVE_PROMPT = " "
EDIT_2511_PROMPT = (
"将第二张图中人脸/猫狗脸转换为3D卡通形象,质量要求:毛发纹理自然电影级色彩校准,注意保留原图的外貌、"
"肤色、发型特征,仅替换第一张财神爷/猫狗财神的用绿框框住的头部区域,耳朵样式和原图的3D卡通形象一致, "
"(帽子紧贴头顶,未贴合部分可调整帽子摆放角度达到完全贴合), 头部与身体过渡必须自然,无拼接痕迹,"
"无额外元素出现在头部四周,纯白背景,不要出现绿色圆圈"
)
EDIT_2511_ASSET_DIR = Path(__file__).resolve().parent / "assets"
EDIT_2511_IMAGE_PATHS = [
EDIT_2511_ASSET_DIR / "qwen_image_edit_2511_test1.png",
EDIT_2511_ASSET_DIR / "qwen_image_edit_2511_test2.png",
]
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_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." 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" NEGATIVE_PROMPT = "low quality, blurry, artifacts, distortion"
SERVER_ARGS = ["--num-gpus", "1", "--stage-init-timeout", "300", "--init-timeout", "900"] SERVER_ARGS = ["--num-gpus", "1", "--stage-init-timeout", "300", "--init-timeout", "900"]
def _edit_2511_model_name() -> str:
return os.environ.get(EDIT_2511_MODEL_ENV_VAR, EDIT_2511_MODEL)
def _local_files_only(model: str) -> bool:
return Path(model).exists()
def _run_vllm_omni_image_edit( def _run_vllm_omni_image_edit(
*, *,
omni_server: OmniServer, omni_server: OmniServer,
@@ -79,17 +111,15 @@ def _run_diffusers_image_edit(
) -> Image.Image: ) -> Image.Image:
run_pre_test_cleanup() run_pre_test_cleanup()
pipe: QwenImageEditPipeline | QwenImageEditPlusPipeline | None = None pipe: QwenImageEditPipeline | QwenImageEditPlusPipeline | None = None
device = torch.device("cuda:0")
torch.cuda.set_device(device)
try: try:
images = input_images[0] if len(input_images) == 1 else input_images images = input_images[0] if len(input_images) == 1 else input_images
pipe = pipeline_class.from_pretrained( pipe = pipeline_class.from_pretrained(
model, model,
torch_dtype=torch.bfloat16, torch_dtype=torch.bfloat16,
trust_remote_code=True, trust_remote_code=True,
).to(device) ).to("cuda")
pipe.set_progress_bar_config(disable=False) pipe.set_progress_bar_config(disable=False)
generator = torch.Generator(device=device).manual_seed(SEED) generator = torch.Generator(device="cuda").manual_seed(SEED)
result = pipe( # pyright: ignore[reportCallIssue] result = pipe( # pyright: ignore[reportCallIssue]
prompt=prompt, prompt=prompt,
image=images, image=images,
@@ -113,6 +143,88 @@ def _run_diffusers_image_edit(
run_post_test_cleanup() run_post_test_cleanup()
def _load_edit_2511_input_images() -> list[Image.Image]:
return [Image.open(path).convert("RGB") for path in EDIT_2511_IMAGE_PATHS]
def _run_vllm_omni_image_edit_2511(
*,
model: str,
input_images: list[Image.Image],
output_path: Path,
) -> Image.Image:
with OmniServer(model=model, serve_args=SERVER_ARGS) as server:
response = requests.post(
f"http://{server.host}:{server.port}/v1/images/edits",
data={
"model": server.model,
"prompt": EDIT_2511_PROMPT,
"size": f"{EDIT_2511_WIDTH}x{EDIT_2511_HEIGHT}",
"n": 1,
"response_format": "b64_json",
"negative_prompt": EDIT_2511_NEGATIVE_PROMPT,
"num_inference_steps": EDIT_2511_NUM_INFERENCE_STEPS,
"true_cfg_scale": EDIT_2511_TRUE_CFG_SCALE,
"guidance_scale": EDIT_2511_GUIDANCE_SCALE,
"seed": EDIT_2511_SEED,
},
files=[
("image", (f"image_{index}.png", pil_to_png_bytes(image), "image/png"))
for index, image in enumerate(input_images)
],
timeout=1200,
)
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_2511(
*,
model: str,
input_images: list[Image.Image],
output_path: Path,
) -> Image.Image:
run_pre_test_cleanup()
pipe: QwenImageEditPlusPipeline | None = None
try:
pipe = QwenImageEditPlusPipeline.from_pretrained(
model,
torch_dtype=torch.bfloat16,
trust_remote_code=True,
local_files_only=_local_files_only(model),
).to("cuda")
pipe.set_progress_bar_config(disable=False)
generator = torch.Generator(device="cuda").manual_seed(EDIT_2511_SEED)
result = pipe( # pyright: ignore[reportCallIssue]
image=input_images,
prompt=EDIT_2511_PROMPT,
generator=generator,
true_cfg_scale=EDIT_2511_TRUE_CFG_SCALE,
negative_prompt=EDIT_2511_NEGATIVE_PROMPT,
num_inference_steps=EDIT_2511_NUM_INFERENCE_STEPS,
guidance_scale=EDIT_2511_GUIDANCE_SCALE,
num_images_per_prompt=1,
width=EDIT_2511_WIDTH,
height=EDIT_2511_HEIGHT,
)
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.accelerator.empty_cache()
run_post_test_cleanup()
def _vllm_omni_output_single_image( def _vllm_omni_output_single_image(
accuracy_artifact_root: Path, accuracy_artifact_root: Path,
qwen_bear_image: Image.Image, qwen_bear_image: Image.Image,
@@ -226,3 +338,43 @@ def test_qwen_image_edit_multiple_matches_diffusers(
ssim_threshold=SSIM_THRESHOLD, ssim_threshold=SSIM_THRESHOLD,
psnr_threshold=PSNR_THRESHOLD, psnr_threshold=PSNR_THRESHOLD,
) )
@pytest.mark.benchmark
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_qwen_image_edit_2511_matches_diffusers_pixelwise(accuracy_artifact_root: Path) -> None:
model = _edit_2511_model_name()
output_dir = model_output_dir(accuracy_artifact_root, EDIT_2511_MODEL)
input_images = _load_edit_2511_input_images()
input_paths: list[Path] = []
for index, image in enumerate(input_images, start=1):
input_path = output_dir / f"edit_2511_input_{index}.png"
image.save(input_path)
input_paths.append(input_path)
vllm_output_path = output_dir / "vllm_omni_edit_2511.png"
diffusers_output_path = output_dir / "diffusers_edit_2511.png"
vllm_image = _run_vllm_omni_image_edit_2511(
model=model,
input_images=input_images,
output_path=vllm_output_path,
)
diffusers_image = _run_diffusers_image_edit_2511(
model=model,
input_images=input_images,
output_path=diffusers_output_path,
)
print(f"{EDIT_2511_MODEL} generated images:")
for index, input_path in enumerate(input_paths, start=1):
print(f" input_{index}: {input_path}")
print(f" vllm_omni: {vllm_output_path}")
print(f" diffusers: {diffusers_output_path}")
assert_images_pixel_close(
model_name=EDIT_2511_MODEL,
vllm_image=vllm_image,
diffusers_image=diffusers_image,
mean_threshold=EDIT_2511_MEAN_ABS_DIFF_THRESHOLD,
p99_threshold=EDIT_2511_P99_ABS_DIFF_THRESHOLD,
)
@@ -27,9 +27,9 @@ from tests.helpers.mark import hardware_test
from tests.helpers.media import get_asset_path from tests.helpers.media import get_asset_path
from tests.helpers.runtime import OmniRunner from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import get_deploy_config_path 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 from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer
from vllm_omni.outputs import OmniRequestOutput from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config
MODEL = "FunAudioLLM/Fun-CosyVoice3-0.5B-2512" MODEL = "FunAudioLLM/Fun-CosyVoice3-0.5B-2512"
MODEL_DIR_ENV = "VLLM_OMNI_COSYVOICE3_MODEL_DIR" MODEL_DIR_ENV = "VLLM_OMNI_COSYVOICE3_MODEL_DIR"
@@ -106,18 +106,21 @@ def _build_request(
def _collect_audio(omni: Omni, request: dict) -> tuple[torch.Tensor, int]: def _collect_audio(omni: Omni, request: dict) -> tuple[torch.Tensor, int]:
"""Run a single request and return (waveform, sample_rate).""" """Run a single request and return (waveform, sample_rate)."""
# Omni.generate returns list[OmniRequestOutput] by default (py_generator=False). for stage_outputs in omni.generate(request, DEFAULT_SAMPLING):
for omni_out in omni.generate(request, DEFAULT_SAMPLING): req_output = stage_outputs.request_output
mm = omni_out.multimodal_output if req_output is not None:
assert mm is not None, "Expected multimodal_output to be non-None" mm = req_output.outputs[0].multimodal_output
audio = mm.get("audio") assert mm is not None, "Expected multimodal_output to be non-None"
sr = mm.get("sr") audio = mm.get("audio")
assert audio is not None, "Expected 'audio' key in multimodal_output" sr = mm.get("sr")
assert isinstance(audio, torch.Tensor), f"audio should be Tensor, got {type(audio)}" assert audio is not None, "Expected 'audio' key in multimodal_output"
return audio.cpu(), int(sr.item()) if sr is not None else SAMPLE_RATE assert isinstance(audio, torch.Tensor), f"audio should be Tensor, got {type(audio)}"
return audio.cpu(), int(sr.item()) if sr is not None else SAMPLE_RATE
raise AssertionError("No stage outputs received") raise AssertionError("No stage outputs received")
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}) @hardware_test(res={"cuda": "L4"})
def test_moss_tts_nano_english(omni_runner: OmniRunner, ref_audio_path) -> None: def test_moss_tts_nano_english(omni_runner: OmniRunner, ref_audio_path) -> None:
"""English TTS produces non-empty 48 kHz stereo audio.""" """English TTS produces non-empty 48 kHz stereo audio."""
@@ -129,6 +132,8 @@ def test_moss_tts_nano_english(omni_runner: OmniRunner, ref_audio_path) -> None:
assert not torch.all(audio == 0), "Audio should not be all-zeros (silence)" assert not torch.all(audio == 0), "Audio should not be all-zeros (silence)"
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}) @hardware_test(res={"cuda": "L4"})
def test_moss_tts_nano_chinese(omni_runner: OmniRunner, ref_audio_path) -> None: def test_moss_tts_nano_chinese(omni_runner: OmniRunner, ref_audio_path) -> None:
"""Chinese TTS produces non-empty audio.""" """Chinese TTS produces non-empty audio."""
@@ -140,6 +145,8 @@ def test_moss_tts_nano_chinese(omni_runner: OmniRunner, ref_audio_path) -> None:
assert not torch.all(audio == 0) assert not torch.all(audio == 0)
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}) @hardware_test(res={"cuda": "L4"})
def test_moss_tts_nano_deterministic(omni_runner: OmniRunner, ref_audio_path) -> None: def test_moss_tts_nano_deterministic(omni_runner: OmniRunner, ref_audio_path) -> None:
"""Same seed produces identical waveforms.""" """Same seed produces identical waveforms."""
@@ -151,6 +158,8 @@ def test_moss_tts_nano_deterministic(omni_runner: OmniRunner, ref_audio_path) ->
assert torch.allclose(audio1, audio2, atol=1e-4), "Waveforms should match with same seed" assert torch.allclose(audio1, audio2, atol=1e-4), "Waveforms should match with same seed"
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}) @hardware_test(res={"cuda": "L4"})
def test_moss_tts_nano_batch(omni_runner: OmniRunner, ref_audio_path) -> None: def test_moss_tts_nano_batch(omni_runner: OmniRunner, ref_audio_path) -> None:
"""Batch of two requests returns audio for each.""" """Batch of two requests returns audio for each."""
@@ -159,11 +168,13 @@ def test_moss_tts_nano_batch(omni_runner: OmniRunner, ref_audio_path) -> None:
_build_request("Second request.", ref_audio_path), _build_request("Second request.", ref_audio_path),
] ]
results = [] results = []
# Single-stage pipeline: one SamplingParams object is broadcast to all prompts. # Single-stage model (num_stages=1): one sampling param for all requests.
for omni_out in omni_runner.omni.generate(requests, DEFAULT_SAMPLING): for stage_outputs in omni_runner.omni.generate(requests, [DEFAULT_SAMPLING]):
mm = omni_out.multimodal_output req_output = stage_outputs.request_output
assert mm is not None if req_output is not None:
results.append(mm["audio"].cpu()) mm = req_output.outputs[0].multimodal_output
assert mm is not None
results.append(mm["audio"].cpu())
assert len(results) == 2, f"Expected 2 outputs, got {len(results)}" assert len(results) == 2, f"Expected 2 outputs, got {len(results)}"
for i, audio in enumerate(results): for i, audio in enumerate(results):
@@ -15,6 +15,7 @@ import numpy as np
import pytest import pytest
import torch import torch
from tests.helpers import skip_if_gated_repo_inaccessible
from tests.helpers.assertions import assert_audio_valid from tests.helpers.assertions import assert_audio_valid
from tests.helpers.mark import hardware_test from tests.helpers.mark import hardware_test
from vllm_omni import Omni from vllm_omni import Omni
@@ -24,6 +25,8 @@ from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.full_model, pytest.mark.diffusion] pytestmark = [pytest.mark.full_model, pytest.mark.diffusion]
_MODEL_REPO = "stabilityai/stable-audio-open-1.0"
_SAMPLE_RATE = 44100 _SAMPLE_RATE = 44100
_CLIP_DURATION_S = 2.0 _CLIP_DURATION_S = 2.0
@@ -77,6 +80,7 @@ def test_stable_audio_quantization_and_teacache() -> None:
CI should provide ``HF_TOKEN`` if the checkpoint is gated. CI should provide ``HF_TOKEN`` if the checkpoint is gated.
""" """
skip_if_gated_repo_inaccessible(_MODEL_REPO)
# ``model_class_name`` must be passed explicitly: the default-stage-cfg # ``model_class_name`` must be passed explicitly: the default-stage-cfg
# factory in ``async_omni_engine.py`` reads it out of ``kwargs`` when # factory in ``async_omni_engine.py`` reads it out of ``kwargs`` when
# deciding ``final_output_type`` (#2077), and at construction time the # deciding ``final_output_type`` (#2077), and at construction time the
@@ -44,7 +44,7 @@ def get_batch_token_config(default_path):
return modify_stage_config( return modify_stage_config(
default_path, default_path,
updates={ updates={
"stages": {1: {"max_num_batched_tokens": 64}}, "stages": {0: {"max_num_batched_tokens": 64}, 1: {"max_num_batched_tokens": 64}},
}, },
) )
@@ -95,7 +95,12 @@ test_params = [
test_token_params = [ test_token_params = [
pytest.param( pytest.param(
OmniServerParams(model=model, stage_config_path=get_batch_token_config(default_path), use_stage_cli=True), OmniServerParams(
model=model,
stage_config_path=get_batch_token_config(default_path),
use_stage_cli=True,
server_args=["--async-chunk"],
),
id="batch_token_64", id="batch_token_64",
) )
] ]
+86 -2
View File
@@ -5,13 +5,14 @@ from vllm.v1.engine import EngineCoreRequest
from vllm_omni.engine import OmniEngineCoreRequest from vllm_omni.engine import OmniEngineCoreRequest
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine, StageRuntimeInfo from vllm_omni.engine.async_omni_engine import AsyncOmniEngine, StageRuntimeInfo
from vllm_omni.engine.stage_pool import StagePool
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_engine_core_request() -> EngineCoreRequest: def _make_engine_core_request(request_id: str = "req-1") -> EngineCoreRequest:
return EngineCoreRequest( return EngineCoreRequest(
request_id="req-1", request_id=request_id,
prompt_token_ids=[1, 1, 1], prompt_token_ids=[1, 1, 1],
mm_features=None, mm_features=None,
sampling_params=SamplingParams(max_tokens=8), sampling_params=SamplingParams(max_tokens=8),
@@ -88,3 +89,86 @@ def test_build_add_request_message_with_resumable_streaming(mocker: MockerFixtur
assert msg.type == "streaming_update" assert msg.type == "streaming_update"
input_processor.process_inputs.assert_called_once() input_processor.process_inputs.assert_called_once()
assert input_processor.process_inputs.call_args.kwargs["resumable"] is True assert input_processor.process_inputs.call_args.kwargs["resumable"] is True
class _FakeStageClient:
stage_type = "llm"
final_output = False
def test_build_add_request_message_scopes_mm_uuids_to_selected_stage0_replica(mocker: MockerFixture):
engine = object.__new__(AsyncOmniEngine)
params = SamplingParams(max_tokens=8)
engine.model = "test-model"
engine.default_sampling_params_list = [params]
engine.stage_metadata = [StageRuntimeInfo(final_output=False, final_output_type=None, stage_type="llm")]
engine.supported_tasks = ("generate",)
engine.stage_pools = [StagePool(0, [_FakeStageClient(), _FakeStageClient()])]
seen_uuids: list[str] = []
def process_inputs(**kwargs):
prompt = kwargs["prompt"]
seen_uuids.append(prompt["multi_modal_uuids"]["image"][0])
return _make_engine_core_request(kwargs["request_id"])
input_processor = mocker.Mock()
input_processor.process_inputs.side_effect = process_inputs
engine.input_processor = input_processor
for request_id in ("req-1", "req-2"):
engine._build_add_request_message(
request_id=request_id,
prompt={
"prompt": "describe",
"multi_modal_data": {"image": "same-image"},
},
sampling_params_list=[params],
final_stage_id=0,
)
assert seen_uuids[0].startswith("stage0:rep0:")
assert seen_uuids[1].startswith("stage0:rep1:")
assert seen_uuids[0].removeprefix("stage0:rep0:") == seen_uuids[1].removeprefix("stage0:rep1:")
def test_stage_pool_replica_count_falls_back_to_clients():
class PoolWithoutLiveNumReplicas:
clients = [object(), None, object()]
assert AsyncOmniEngine._stage_pool_replica_count(PoolWithoutLiveNumReplicas()) == 2
def test_stage_pool_is_distributed_falls_back_to_hub():
class PoolWithoutIsDistributed:
_hub = object()
assert AsyncOmniEngine._stage_pool_is_distributed(PoolWithoutIsDistributed()) is True
def test_build_add_request_message_releases_preselected_replica_on_preprocess_error(mocker: MockerFixture):
engine = object.__new__(AsyncOmniEngine)
params = SamplingParams(max_tokens=8)
engine.model = "test-model"
engine.default_sampling_params_list = [params]
engine.stage_metadata = [StageRuntimeInfo(final_output=False, final_output_type=None, stage_type="llm")]
engine.supported_tasks = ("generate",)
stage_pool = StagePool(0, [_FakeStageClient(), _FakeStageClient()])
engine.stage_pools = [stage_pool]
input_processor = mocker.Mock()
input_processor.process_inputs.side_effect = RuntimeError("boom")
engine.input_processor = input_processor
with pytest.raises(RuntimeError, match="boom"):
engine._build_add_request_message(
request_id="req-error",
prompt={
"prompt": "describe",
"multi_modal_data": {"image": "same-image"},
},
sampling_params_list=[params],
final_stage_id=0,
)
assert stage_pool.get_bound_replica_id("req-error") is None
+4 -4
View File
@@ -79,7 +79,7 @@ class FakeStageClient:
self._diffusion_outputs = queue.Queue() self._diffusion_outputs = queue.Queue()
# Orchestrator-facing interface. # Orchestrator-facing interface.
async def add_request_async(self, *args, **_kwargs) -> None: async def add_request_async(self, *args, **kwargs) -> None:
self.add_request_calls.append(args) self.add_request_calls.append(args)
async def get_output_async(self): async def get_output_async(self):
@@ -448,7 +448,7 @@ def test_stage_engine_core_client_shutdown_cleans_children_if_base_shutdown_fail
assert recursive assert recursive
return [fake_child] return [fake_child]
def fail_base_shutdown(self): def fail_base_shutdown(self, **kwargs):
raise RuntimeError("base shutdown failed") raise RuntimeError("base shutdown failed")
monkeypatch.setattr(psutil, "Process", FakePsutilProcess) monkeypatch.setattr(psutil, "Process", FakePsutilProcess)
@@ -480,7 +480,7 @@ def test_stage_engine_core_client_shutdown_kills_stubborn_children(monkeypatch):
monkeypatch.setattr(psutil, "Process", FakePsutilProcess) monkeypatch.setattr(psutil, "Process", FakePsutilProcess)
monkeypatch.setattr(psutil, "wait_procs", lambda procs, timeout: ([], list(procs))) monkeypatch.setattr(psutil, "wait_procs", lambda procs, timeout: ([], list(procs)))
monkeypatch.setattr(AsyncMPClient, "shutdown", lambda self: None) monkeypatch.setattr(AsyncMPClient, "shutdown", lambda self, **kwargs: None)
client = object.__new__(StageEngineCoreClient) client = object.__new__(StageEngineCoreClient)
client._proc = fake_proc client._proc = fake_proc
@@ -994,7 +994,7 @@ async def test_handle_streaming_update_passes_prompt_text_to_stage_pool() -> Non
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stage_pool_submit_initial_rolls_back_output_processor_when_client_submit_fails() -> None: async def test_stage_pool_submit_initial_rolls_back_output_processor_when_client_submit_fails() -> None:
class FailingStageClient(FakeStageClient): class FailingStageClient(FakeStageClient):
async def add_request_async(self, *args, **_kwargs) -> None: async def add_request_async(self, *args, **kwargs) -> None:
raise RuntimeError("submit failed") raise RuntimeError("submit failed")
class TrackingOutputProcessor(FakeOutputProcessor): class TrackingOutputProcessor(FakeOutputProcessor):
@@ -1349,8 +1349,16 @@ def test_image_edit_parameter_default(async_omni_test_client):
engine = async_omni_test_client.app.state.engine_client engine = async_omni_test_client.app.state.engine_client
captured_sampling_params = engine.captured_sampling_params_list[-1] captured_sampling_params = engine.captured_sampling_params_list[-1]
assert captured_sampling_params.width == 24 # size="auto" on multi-stage pipelines deliberately leaves the diffusion
assert captured_sampling_params.height == 16 # stages sampling_params width/height unset so AR-driven pipelines (e.g.
# HunyuanImage-3.0) can let ar2diffusion override the final bucket from
# the AR-predicted ratio token; see
# test_image_edits_size_auto_preserves_bridge_size for the contract.
# Single-stage diffusion (test_image_edit_parameter_default_single_stage)
# still pins width/height to the input image size via api_servers
# gen_params, which is unchanged.
assert captured_sampling_params.width is None
assert captured_sampling_params.height is None
assert captured_sampling_params.num_outputs_per_prompt == 1 assert captured_sampling_params.num_outputs_per_prompt == 1
assert captured_sampling_params.num_inference_steps == 4 assert captured_sampling_params.num_inference_steps == 4
assert captured_sampling_params.guidance_scale == 7.5 assert captured_sampling_params.guidance_scale == 7.5
@@ -1649,3 +1657,59 @@ def test_extract_images_from_result():
assert len(images) == 1 assert len(images) == 1
assert isinstance(images[0], Image.Image) assert isinstance(images[0], Image.Image)
assert images[0].size == (32, 32) assert images[0].size == (32, 32)
def test_image_edits_size_auto_preserves_bridge_size(async_omni_stage_configs_only_client):
"""size=auto must NOT pin the diffusion stage sampling_params.height/width.
Regression: prior to the fix, edit_images resolved size=auto to the
first input image dimensions and forwarded them through gen_params +
extra_body to the diffusion stages sampling_params. AR-driven
pipelines (e.g. HunyuanImage-3.0) rely on ar2diffusions
bridge to override the final bucket via the AR-predicted ratio token,
and the DiT pre_process_func only fills sampling_params from the
bridge value when sampling_params.width is None (see
pipeline_hunyuan_image3.py:290). Non-None width from the input image
silently suppressed the AR decision, producing the wrong bucket
(e.g. 1024x1024 square instead of the AR-decided 1280x720 landscape
for multi-image fusion).
Cross-pins the multi-image fix at the API level: 2 reference images
with bot_task=think must produce 2 <img> placeholders in the captured
AR prompt (build_prompt called with num_images=2).
"""
img_a = make_test_image_bytes((32, 32))
img_b = make_test_image_bytes((128, 64))
response = async_omni_stage_configs_only_client.post(
"/v1/images/edits",
files=[("image", img_a), ("image", img_b)],
data={
"prompt": "fuse",
"size": "auto",
"bot_task": "think",
},
)
assert response.status_code == 200, response.text
engine = async_omni_stage_configs_only_client.app.state.engine_client
captured = engine.captured_sampling_params_list
assert captured is not None
assert len(captured) == 2
diffusion_params = captured[1]
assert diffusion_params.height is None, (
f"size=auto leaked into diffusion sampling_params.height={diffusion_params.height}; "
"must stay None so AR-driven pipelines can apply the bridges decision."
)
assert diffusion_params.width is None, (
f"size=auto leaked into diffusion sampling_params.width={diffusion_params.width}; "
"must stay None so AR-driven pipelines can apply the bridges decision."
)
KEY = "prompt"
IMG = "<img>"
captured_prompt = engine.captured_prompt
if isinstance(captured_prompt, dict) and isinstance(captured_prompt.get("prompt"), str):
assert captured_prompt["prompt"].count("<img>") == 2, (
f"N=2 reference images must emit 2 <img> placeholders in AR prompt; got {captured_prompt[KEY].count(IMG)} -- prompt: {captured_prompt[KEY]!r}"
)
@@ -389,7 +389,7 @@ class TestErrorHandling:
async def empty_gen(*args, **kwargs): async def empty_gen(*args, **kwargs):
return return
yield # noqa: unreachable – makes this an async generator yield # unreachable – makes this an async generator
engine.generate = MagicMock(side_effect=empty_gen) engine.generate = MagicMock(side_effect=empty_gen)
server = _make_server(engine_client=engine) server = _make_server(engine_client=engine)
@@ -448,7 +448,7 @@ class TestErrorHandling:
async def gen_value_error(*args, **kwargs): async def gen_value_error(*args, **kwargs):
raise ValueError("bad value") raise ValueError("bad value")
yield # noqa: unreachable yield # unreachable
engine.generate = MagicMock(side_effect=gen_value_error) engine.generate = MagicMock(side_effect=gen_value_error)
server = _make_server(engine_client=engine) server = _make_server(engine_client=engine)
@@ -463,7 +463,7 @@ class TestErrorHandling:
async def gen_runtime_error(*args, **kwargs): async def gen_runtime_error(*args, **kwargs):
raise RuntimeError("something went wrong") raise RuntimeError("something went wrong")
yield # noqa: unreachable yield # unreachable
engine.generate = MagicMock(side_effect=gen_runtime_error) engine.generate = MagicMock(side_effect=gen_runtime_error)
server = _make_server(engine_client=engine) server = _make_server(engine_client=engine)
@@ -91,3 +91,275 @@ def test_build_multistage_generation_inputs_applies_stage_specific_overrides(ser
assert engine.default_sampling_params_list[1].lora_request is None assert engine.default_sampling_params_list[1].lora_request is None
assert engine.default_sampling_params_list[2].resolution == 640 assert engine.default_sampling_params_list[2].resolution == 640
assert engine.default_sampling_params_list[2].lora_request is None assert engine.default_sampling_params_list[2].lora_request is None
def test_build_multistage_generation_inputs_multi_image_emits_n_img_placeholders(serving_chat):
"""N reference images with bot_task set must emit N <img> placeholders.
Regression: prior to the multi-image online fix, build_prompt was
called without num_images, defaulting to 1. A 2-image edit request
would only get a single <img> placeholder in the AR prompt; vLLMs
_process_multimodal then raised
AssertionError(Failed to apply prompt replacement for mm_items[image][1])
when trying to replace the second image (no placeholder left for it).
Pins the contract that build_prompt() is invoked with the actual image
count so multi-image IT2I is wired correctly through the online
/v1/images/edits path.
"""
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
engine = SimpleNamespace(
stage_configs=[
SimpleNamespace(stage_type="llm", is_comprehension=True),
SimpleNamespace(stage_type="diffusion", is_comprehension=False),
],
default_sampling_params_list=[
SamplingParams(temperature=0.0),
OmniDiffusionSamplingParams(),
],
)
IMG = "<img>"
images = [Image.new("RGB", (32, 32), color="red") for _ in range(3)]
for n in (1, 2, 3):
engine_prompt, _ = OmniOpenAIServingChat._build_multistage_generation_inputs(
serving_chat,
engine=engine,
prompt="edit me",
extra_body={"bot_task": "think"},
reference_images=images[:n],
gen_params=OmniDiffusionSamplingParams(),
)
prompt_str = engine_prompt["prompt"]
assert prompt_str.count("<img>") == n, (
f"N={n}: expected {n} <img> placeholders, got {prompt_str.count(IMG)} -- prompt: {prompt_str!r}"
)
def test_build_multistage_generation_inputs_tokenizer_path_emits_prompt_token_ids(serving_chat):
"""When a tokenizer is provided, the helper must emit HF byte-for-byte
prompt_token_ids and forward use_system_prompt to the engine prompt.
Regression: prior to the HF-byte-equivalent fix, online IT2I always
passed the prompt as a single string. The engine then BPE-merged across
chat-template segment boundaries (e.g. user_prompt-ending punctuation
plus the trailing \n\n before \"Assistant: \") producing a token
sequence that differs from HF apply_chat_template / offline
end2end.py. AR generated different cot_text (706 tokens / 1190 chars
vs offline 661 / 1118 for the same inputs) and DiT produced a visually
different image (yin-yang on brushed-metal vs three-blue swirl on
canvas) under the same seed.
Pins:
1. engine_prompt[\"prompt_token_ids\"] is set when tokenizer is passed.
2. engine_prompt[\"prompt\"] stays as the raw user prompt -- the DiT
side rebuilds its own system prefix via use_system_prompt.
3. engine_prompt[\"use_system_prompt\"] == \"en_unified\" so
ar2diffusion forwards the matching system prompt to DiT.
4. N reference images emit N <img> token ids in the AR sequence.
"""
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
# Minimal FakeTokenizer mirroring tests/diffusion/.../test_hunyuan_image3_it2i_multi_image.py
class FakeTokenizer:
SPECIAL = {
"<|startoftext|>": 1,
"<img>": 2,
"<think>": 3,
"<recaption>": 4,
}
def convert_tokens_to_ids(self, tok: str) -> int:
return self.SPECIAL.get(tok, 0)
def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
return list(range(100, 100 + len(text)))
engine = SimpleNamespace(
stage_configs=[
SimpleNamespace(stage_type="llm", is_comprehension=True),
SimpleNamespace(stage_type="diffusion", is_comprehension=False),
],
default_sampling_params_list=[
SamplingParams(temperature=0.0),
OmniDiffusionSamplingParams(),
],
)
PROMPT_KEY = "prompt"
USP_KEY = "use_system_prompt"
images = [Image.new("RGB", (32, 32), color="red") for _ in range(3)]
for n in (1, 2, 3):
tok = FakeTokenizer()
engine_prompt, _ = OmniOpenAIServingChat._build_multistage_generation_inputs(
serving_chat,
engine=engine,
prompt="edit me",
extra_body={"bot_task": "think"},
reference_images=images[:n],
gen_params=OmniDiffusionSamplingParams(),
tokenizer=tok,
)
# (1) prompt_token_ids must be set and non-empty
assert "prompt_token_ids" in engine_prompt, f"N={n}: prompt_token_ids missing"
token_ids = engine_prompt["prompt_token_ids"]
assert isinstance(token_ids, list) and len(token_ids) > 0, f"N={n}: prompt_token_ids empty"
# (2) raw prompt preserved (DiT bridge needs raw user text)
assert engine_prompt["prompt"] == "edit me", (
f"N={n}: prompt must stay raw user text, got {engine_prompt[PROMPT_KEY]!r}"
)
# (3) use_system_prompt forwarded for ar2diffusion bridge
assert engine_prompt.get("use_system_prompt") == "en_unified", (
f"N={n}: use_system_prompt must be en_unified, got {engine_prompt.get(USP_KEY)!r}"
)
# (4) N <img> token ids (id=2 in FakeTokenizer)
img_count = token_ids.count(2)
assert img_count == n, f"N={n}: expected {n} <img> token ids in prompt_token_ids, got {img_count}"
def test_build_multistage_generation_inputs_bot_task_semantic_changes_trigger_and_sys(serving_chat):
"""Passing bot_task=think_recaption (vs default "think") must flip the
resolved sys_type to en_think_recaption (and trigger tag is still
<think>). Pins that the API actually plumbs the bot_task semantic
through to build_prompt rather than ignoring it.
"""
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
engine = SimpleNamespace(
stage_configs=[
SimpleNamespace(stage_type="llm", is_comprehension=True),
SimpleNamespace(stage_type="diffusion", is_comprehension=False),
],
default_sampling_params_list=[
SamplingParams(temperature=0.0),
OmniDiffusionSamplingParams(),
],
)
images = [Image.new("RGB", (32, 32), color="red")]
# Default bot_task (think) -> en_unified system prompt baked into the
# legacy string path. Use legacy build_prompt (tokenizer=None) so the
# rendered prompt is a string we can grep.
think_prompt, _ = OmniOpenAIServingChat._build_multistage_generation_inputs(
serving_chat,
engine=engine,
prompt="edit me",
extra_body={"task": "it2i", "bot_task": "think"},
reference_images=images,
gen_params=OmniDiffusionSamplingParams(),
)
# think_recaption -> en_think_recaption system prompt (different content).
recap_prompt, _ = OmniOpenAIServingChat._build_multistage_generation_inputs(
serving_chat,
engine=engine,
prompt="edit me",
extra_body={"task": "it2i", "bot_task": "think_recaption"},
reference_images=images,
gen_params=OmniDiffusionSamplingParams(),
)
assert think_prompt["prompt"] != recap_prompt["prompt"], (
"bot_task semantic must change the rendered system prompt: "
f"think/think_recaption produced identical strings (len={len(think_prompt['prompt'])})"
)
def test_build_multistage_generation_inputs_sys_type_override(serving_chat):
"""Caller-supplied sys_type must override the bot_task-derived default.
Mirrors offline `--bot-task think_recaption --sys-type en_unified`
where the user wants think_recaptions trigger but the unified system
prompt body.
"""
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
engine = SimpleNamespace(
stage_configs=[
SimpleNamespace(stage_type="llm", is_comprehension=True),
SimpleNamespace(stage_type="diffusion", is_comprehension=False),
],
default_sampling_params_list=[
SamplingParams(temperature=0.0),
OmniDiffusionSamplingParams(),
],
)
images = [Image.new("RGB", (32, 32), color="red")]
# think_recaption defaults sys_type -> en_think_recaption.
default_sys, _ = OmniOpenAIServingChat._build_multistage_generation_inputs(
serving_chat,
engine=engine,
prompt="edit me",
extra_body={"task": "it2i", "bot_task": "think_recaption"},
reference_images=images,
gen_params=OmniDiffusionSamplingParams(),
)
# sys_type=en_unified overrides -> same system body as bot_task=think.
overridden, _ = OmniOpenAIServingChat._build_multistage_generation_inputs(
serving_chat,
engine=engine,
prompt="edit me",
extra_body={"task": "it2i", "bot_task": "think_recaption", "sys_type": "en_unified"},
reference_images=images,
gen_params=OmniDiffusionSamplingParams(),
)
plain_think, _ = OmniOpenAIServingChat._build_multistage_generation_inputs(
serving_chat,
engine=engine,
prompt="edit me",
extra_body={"task": "it2i", "bot_task": "think"},
reference_images=images,
gen_params=OmniDiffusionSamplingParams(),
)
# Override must (a) differ from the no-override default, and (b) equal
# the prompt that bot_task=think produces (both end up with
# en_unified system body + <think> trigger).
assert overridden["prompt"] != default_sys["prompt"], (
"sys_type override must change the rendered prompt body vs the bot_task default"
)
assert overridden["prompt"] == plain_think["prompt"], (
"sys_type=en_unified + bot_task=think_recaption must produce the same prompt as "
"bot_task=think (both = en_unified system body + <think> trigger)"
)
def test_build_multistage_generation_inputs_custom_system_prompt(serving_chat):
"""`extra_body["system_prompt"]` must reach build_prompt as
`custom_system_prompt`, enabling sys_type="custom" callers to inject
a verbatim system body. Without this plumbing the sys_type="custom"
branch in get_system_prompt() returns None and silently drops the
user-supplied content.
"""
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
engine = SimpleNamespace(
stage_configs=[
SimpleNamespace(stage_type="llm", is_comprehension=True),
SimpleNamespace(stage_type="diffusion", is_comprehension=False),
],
default_sampling_params_list=[
SamplingParams(temperature=0.0),
OmniDiffusionSamplingParams(),
],
)
images = [Image.new("RGB", (32, 32), color="red")]
marker = "ZZZ_CUSTOM_SYSTEM_PROMPT_MARKER_ZZZ"
out, _ = OmniOpenAIServingChat._build_multistage_generation_inputs(
serving_chat,
engine=engine,
prompt="edit me",
extra_body={
"task": "it2i",
"bot_task": "think",
"sys_type": "custom",
"system_prompt": marker,
},
reference_images=images,
gen_params=OmniDiffusionSamplingParams(),
)
assert marker in out["prompt"], (
f"custom system_prompt content must reach the rendered prompt; "
f"marker {marker!r} not found in prompt of length {len(out['prompt'])}"
)
@@ -22,6 +22,7 @@ def serving_chat():
instance = object.__new__(OmniOpenAIServingChat) instance = object.__new__(OmniOpenAIServingChat)
instance._supported_speakers = None instance._supported_speakers = None
instance.has_kv_connector = False
return instance return instance
@@ -943,6 +943,20 @@ def test_omni_base_errored_false_when_alive():
assert base.errored is False assert base.errored is False
def test_omni_base_is_running_false_when_stage_engine_dead():
base = _make_base()
base.engine.is_alive.return_value = True
base.engine.stage_clients = [SimpleNamespace(_engine_dead=True)]
assert base.is_running is False
def test_omni_base_is_running_false_when_stage_resources_engine_dead():
base = _make_base()
base.engine.is_alive.return_value = True
base.engine.stage_clients = [SimpleNamespace(resources=SimpleNamespace(engine_dead=True))]
assert base.is_running is False
def test_omni_base_errored_true_when_orchestrator_dead(): def test_omni_base_errored_true_when_orchestrator_dead():
base = _make_base() base = _make_base()
base.engine.is_alive.return_value = False base.engine.is_alive.return_value = False
+29
View File
@@ -6,3 +6,32 @@ refactor only inside the old monolithic ``conftest``; a greedy ``__init__``
changes import order and can affect in-process Omni (``OmniRunner`` / offline changes import order and can affect in-process Omni (``OmniRunner`` / offline
e2e) vs subprocess-based ``OmniServer`` tests. e2e) vs subprocess-based ``OmniServer`` tests.
""" """
import pytest
def skip_if_gated_repo_inaccessible(repo_id: str) -> None:
"""Skip the test if a gated HuggingFace repo is not accessible.
Tries to download the model's config.json via ``hf_hub_download``,
which performs an actual file-access check (unlike ``HfApi().model_info()``
that only checks metadata). If the token has metadata access but not
file-download access, ``hf_hub_download`` will raise ``GatedRepoError``
and we skip cleanly.
"""
try:
from huggingface_hub import hf_hub_download
from huggingface_hub.errors import GatedRepoError, RepositoryNotFoundError
except Exception:
return
try:
hf_hub_download(repo_id=repo_id, filename="config.json")
except GatedRepoError as exc:
pytest.skip(
f"Skipping: gated HF repo {repo_id!r} inaccessible to the current "
f"HF_TOKEN ({exc}). See docs/contributing/ci/hf_credentials.md."
)
except RepositoryNotFoundError as exc:
pytest.skip(f"Skipping: HF repo {repo_id!r} not found ({exc}).")
except Exception:
return
@@ -48,6 +48,7 @@ def _build_mock_modules(mocker: MockerFixture) -> dict[str, object]:
"""Build the dict of modules to inject into sys.modules.""" """Build the dict of modules to inject into sys.modules."""
platforms_mock = mocker.MagicMock() platforms_mock = mocker.MagicMock()
platforms_mock.current_omni_platform.supports_torch_inductor.return_value = False platforms_mock.current_omni_platform.supports_torch_inductor.return_value = False
platforms_mock.current_omni_platform.is_npu.return_value = False
logger_mock = mocker.MagicMock() logger_mock = mocker.MagicMock()
logger_mock.init_logger = lambda name: mocker.MagicMock() logger_mock.init_logger = lambda name: mocker.MagicMock()
@@ -211,6 +212,10 @@ class TestCodePredictorDtypeAlignment:
predictor._model_dtype = torch.float16 predictor._model_dtype = torch.float16
predictor._compiled_model_fwd = predictor.model.forward predictor._compiled_model_fwd = predictor.model.forward
# Ensure NPU path is not taken on non-NPU hardware
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
mocker.patch.object(common_mod.current_omni_platform, "is_npu", return_value=False)
# _warmup_buckets should fix the dtype mismatch # _warmup_buckets should fix the dtype mismatch
predictor._warmup_buckets() predictor._warmup_buckets()
@@ -230,6 +235,9 @@ class TestCodePredictorDtypeAlignment:
predictor = predictor.to(torch.float16) predictor = predictor.to(torch.float16)
assert predictor._model_dtype is None assert predictor._model_dtype is None
# Ensure NPU path is not taken on non-NPU hardware
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
mocker.patch.object(common_mod.current_omni_platform, "is_npu", return_value=False)
predictor._setup_compile() predictor._setup_compile()
assert predictor._model_dtype == torch.float16 assert predictor._model_dtype == torch.float16
@@ -0,0 +1,92 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for HunyuanImage3 stage input processor."""
import builtins
from types import SimpleNamespace
import pytest
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS,
)
from vllm_omni.model_executor.stage_input_processors.hunyuan_image3 import (
_extract_ratio_index,
_truncate_at_cot_end,
ar2diffusion,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _source_output(token_ids: list[int], text: str = ""):
return SimpleNamespace(
outputs=[
SimpleNamespace(
token_ids=token_ids,
cumulative_token_ids=token_ids,
text=text,
)
],
multimodal_output=None,
)
def test_extract_ratio_index_uses_fixed_special_token_ids():
ratio_33 = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_33>"]
ratio_36 = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_36>"]
assert _extract_ratio_index([1, ratio_33, 2]) == 33
assert _extract_ratio_index([1, ratio_33, 2, ratio_36]) == 36
def test_truncate_at_cot_end_strips_tail_after_recaption_marker():
text = _truncate_at_cot_end("body text</recaption><answer><boi><img_size_1024><img_ratio_0>")
assert text == "body text</recaption>"
def test_ar2diffusion_applies_ratio_and_truncates_tail_without_tokenizer(monkeypatch: pytest.MonkeyPatch):
real_import = builtins.__import__
def _block_transformers_import(name, *args, **kwargs):
if name == "transformers" or name.startswith("transformers."):
raise AssertionError("ar2diffusion must not import transformers on the bridge path")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _block_transformers_import)
end_recaption = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["</recaption>"]
answer = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<answer>"]
boi = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<boi>"]
size = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_size_1024>"]
ratio_0 = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_0>"]
token_ids = [100, 101, end_recaption, answer, boi, size, ratio_0]
result = ar2diffusion(
[_source_output(token_ids, text="decoded without special tokens")],
prompt=[{"prompt": "edit", "height": 64, "width": 64}],
)
assert len(result) == 1
assert (result[0]["height"], result[0]["width"]) == (512, 2048)
assert result[0]["extra"]["ar_generated_text"] == "decoded without special tokens"
assert "ar_token_ids" not in result[0]["extra"]
def test_ar2diffusion_forwards_custom_system_prompt_body():
end_think = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["</think>"]
marker = "CUSTOM_SYSTEM_BODY"
result = ar2diffusion(
[_source_output([100, end_think], text="thought</think>")],
prompt=[
{
"prompt": "edit",
"use_system_prompt": "custom",
"system_prompt": marker,
}
],
)
assert result[0]["use_system_prompt"] == "custom"
assert result[0]["system_prompt"] == marker
@@ -7,6 +7,7 @@ from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
import torch
import vllm_omni.model_executor.stage_input_processors.qwen3_omni as q3 import vllm_omni.model_executor.stage_input_processors.qwen3_omni as q3
@@ -79,3 +80,110 @@ def test_get_streaming_codec_delta_len_increments_and_finishes(_streaming_contex
assert d3 == 1 assert d3 == 1
state = q3._get_qwen3_streaming_state("c1", _streaming_context) state = q3._get_qwen3_streaming_state("c1", _streaming_context)
assert state.talker2code2wav_last_seq_len == 0 assert state.talker2code2wav_last_seq_len == 0
def test_talker2code2wav_full_payload_filters_by_output_token_ids() -> None:
request = SimpleNamespace(
request_id="codec",
output_token_ids=[4197, 1, 2, 4198, -1, 2048],
)
rows = torch.tensor(
[
[100, 101, 102],
[10, 11, 12],
[20, 21, 22],
[30, 31, 32],
[40, 41, 42],
[50, 51, 52],
],
dtype=torch.long,
)
payload = q3.talker2code2wav_full_payload(None, {"codes.audio": rows}, request)
assert payload is not None
assert payload["codes"]["audio"] == [10, 20, 11, 21, 12, 22]
assert payload["code_predictor_codes"] == payload["codes"]["audio"]
def test_talker2code2wav_full_payload_drops_count_matched_terminal_row() -> None:
request = SimpleNamespace(
request_id="codec_terminal_row",
output_token_ids=[0, 4198],
)
rows = torch.tensor(
[
[10, 11, 12],
],
dtype=torch.long,
)
payload = q3.talker2code2wav_full_payload(None, {"codes.audio": rows}, request)
assert payload is None
def test_talker2code2wav_full_payload_drops_rows_aligned_to_non_codec_ids() -> None:
request = SimpleNamespace(
request_id="codec_invalid_ids",
output_token_ids=[4197, 0, 4198, 4196, -1, 2048],
)
rows = torch.tensor(
[
[91, 92, 93],
[0, 0, 0],
[81, 82, 83],
[71, 72, 73],
[61, 62, 63],
[51, 52, 53],
],
dtype=torch.long,
)
payload = q3.talker2code2wav_full_payload(None, {"codes.audio": rows}, request)
assert payload is not None
assert payload["codes"]["audio"] == [0, 0, 0]
assert payload["code_predictor_codes"] == payload["codes"]["audio"]
def test_talker2code2wav_full_payload_keeps_all_zero_codec_rows() -> None:
request = SimpleNamespace(
request_id="codec_zero",
output_token_ids=[0, 1],
)
rows = torch.tensor(
[
[0, 0, 0],
[7, 8, 9],
],
dtype=torch.long,
)
payload = q3.talker2code2wav_full_payload(None, {"codes.audio": rows}, request)
assert payload is not None
assert payload["codes"]["audio"] == [0, 7, 0, 8, 0, 9]
assert payload["code_predictor_codes"] == payload["codes"]["audio"]
def test_thinker2talker_full_payload_packs_complete_tensors() -> None:
request = SimpleNamespace(
request_id="thinker",
prompt_token_ids=[151644, 872],
output_token_ids=[3],
all_token_ids=[151644, 872, 3],
)
pooling_output = {
"hidden_states.layer_0": torch.ones(3, 2),
"hidden_states.layer_24": torch.full((3, 2), 2.0),
"embed.tts_bos": torch.zeros(1, 2),
}
payload = q3.thinker2talker_full_payload(None, pooling_output, request)
assert payload is not None
assert payload["ids"]["all"] == [151644, 872, 3]
assert payload["embed"]["prefill"].device.type == "cpu"
assert payload["hidden_states"]["output"].device.type == "cpu"
assert payload["next_stage_prompt_len"] > 0
+231 -158
View File
@@ -4,27 +4,40 @@
Unit tests for StageConfigFactory and related classes. Unit tests for StageConfigFactory and related classes.
""" """
import importlib
import inspect
import warnings import warnings
from dataclasses import dataclass from dataclasses import dataclass, fields
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import pytest import pytest
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.config.stage_config import ( from vllm_omni.config.stage_config import (
_PIPELINE_REGISTRY, _PIPELINE_REGISTRY,
DeployConfig,
ModelPipeline, ModelPipeline,
PipelineConfig, PipelineConfig,
StageConfig, StageConfig,
StageConfigFactory, StageConfigFactory,
StageDeployConfig,
StageExecutionType, StageExecutionType,
StagePipelineConfig, StagePipelineConfig,
StageType, StageType,
_apply_platform_overrides,
_deep_merge_stage,
_resolve_scheduler, _resolve_scheduler,
build_stage_runtime_overrides, build_stage_runtime_overrides,
deploy_override_field_names,
load_deploy_config,
merge_pipeline_deploy,
register_pipeline, register_pipeline,
strip_parent_engine_args, strip_parent_engine_args,
) )
from vllm_omni.engine.arg_utils import SHARED_FIELDS, internal_blacklist_keys from vllm_omni.engine.arg_utils import SHARED_FIELDS, EngineArgs, internal_blacklist_keys
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class TestStageType: class TestStageType:
@@ -99,8 +112,6 @@ class TestStageConfig:
def test_to_omegaconf_with_runtime_overrides(self): def test_to_omegaconf_with_runtime_overrides(self):
"""Test that runtime overrides are applied to OmegaConf output.""" """Test that runtime overrides are applied to OmegaConf output."""
import warnings
config = StageConfig( config = StageConfig(
stage_id=0, stage_id=0,
model_stage="thinker", model_stage="thinker",
@@ -123,8 +134,6 @@ class TestStageConfig:
def test_to_omegaconf_max_batch_size_deprecation(self): def test_to_omegaconf_max_batch_size_deprecation(self):
"""Test that runtime.max_batch_size emits a FutureWarning.""" """Test that runtime.max_batch_size emits a FutureWarning."""
import warnings
config = StageConfig( config = StageConfig(
stage_id=0, stage_id=0,
model_stage="thinker", model_stage="thinker",
@@ -149,7 +158,6 @@ class TestStageConfig:
def test_to_omegaconf_omits_none_deploy_overrides_for_engine_args(self): def test_to_omegaconf_omits_none_deploy_overrides_for_engine_args(self):
"""None deploy overrides must fall through to EngineArgs defaults.""" """None deploy overrides must fall through to EngineArgs defaults."""
from vllm_omni.config.stage_config import deploy_override_field_names
config = StageConfig( config = StageConfig(
stage_id=0, stage_id=0,
@@ -370,11 +378,7 @@ class TestStageResolutionHelpers:
assert "parallel_config" not in overrides assert "parallel_config" not in overrides
def test_strip_parent_engine_args_reports_only_surprising_parent_overrides(self): def test_strip_parent_engine_args_reports_only_surprising_parent_overrides(self):
from dataclasses import fields as dc_fields parent_fields = {f.name: f for f in fields(EngineArgs)}
from vllm.engine.arg_utils import EngineArgs
parent_fields = {f.name: f for f in dc_fields(EngineArgs)}
filtered, overridden = strip_parent_engine_args( filtered, overridden = strip_parent_engine_args(
{ {
"model": "some/model", "model": "some/model",
@@ -396,11 +400,7 @@ class TestStageResolutionHelpers:
assert overridden == ["tensor_parallel_size"] assert overridden == ["tensor_parallel_size"]
def test_strip_parent_engine_args_keeps_allowed_media_access_controls(self): def test_strip_parent_engine_args_keeps_allowed_media_access_controls(self):
from dataclasses import fields as dc_fields parent_fields = {f.name: f for f in fields(EngineArgs)}
from vllm.engine.arg_utils import EngineArgs
parent_fields = {f.name: f for f in dc_fields(EngineArgs)}
filtered, overridden = strip_parent_engine_args( filtered, overridden = strip_parent_engine_args(
{ {
"model": "some/model", "model": "some/model",
@@ -826,8 +826,6 @@ class TestPipelineRegistry:
class TestDeployConfigLoading: class TestDeployConfigLoading:
def test_deploy_override_fields_include_deploy_schema_fields(self): def test_deploy_override_fields_include_deploy_schema_fields(self):
from vllm_omni.config.stage_config import deploy_override_field_names
expected_fields = { expected_fields = {
"async_chunk", "async_chunk",
"async_scheduling", "async_scheduling",
@@ -864,10 +862,6 @@ class TestDeployConfigLoading:
) )
def test_load_qwen3_omni_moe_deploy_config(self): def test_load_qwen3_omni_moe_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" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
deploy = load_deploy_config(deploy_path) deploy = load_deploy_config(deploy_path)
assert len(deploy.stages) == 3 assert len(deploy.stages) == 3
@@ -876,10 +870,6 @@ class TestDeployConfigLoading:
assert deploy.platforms is not None assert deploy.platforms is not None
def test_load_voxtral_tts_deploy_config_schema_fields(self): def test_load_voxtral_tts_deploy_config_schema_fields(self):
from pathlib import Path
from vllm_omni.config.stage_config import load_deploy_config
deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "voxtral_tts.yaml" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "voxtral_tts.yaml"
deploy = load_deploy_config(deploy_path) deploy = load_deploy_config(deploy_path)
assert deploy.stages[0].config_format == "mistral" assert deploy.stages[0].config_format == "mistral"
@@ -890,21 +880,12 @@ class TestDeployConfigLoading:
) )
def test_load_ming_flash_omni_deploy_config_schema_fields(self): def test_load_ming_flash_omni_deploy_config_schema_fields(self):
from pathlib import Path
from vllm_omni.config.stage_config import load_deploy_config
deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "ming_flash_omni.yaml" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "ming_flash_omni.yaml"
deploy = load_deploy_config(deploy_path) deploy = load_deploy_config(deploy_path)
assert deploy.stages[0].compilation_config == {"pass_config": {"fuse_allreduce_rms": False}} assert deploy.stages[0].compilation_config == {"pass_config": {"fuse_allreduce_rms": False}}
assert "compilation_config" not in deploy.stages[0].engine_extras assert "compilation_config" not in deploy.stages[0].engine_extras
def test_merge_pipeline_deploy(self): 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"] pipeline = _PIPELINE_REGISTRY["qwen3_omni_moe"]
deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
if not deploy_path.exists(): if not deploy_path.exists():
@@ -921,11 +902,6 @@ class TestDeployConfigLoading:
assert s0.yaml_extras["default_sampling_params"]["detokenize"] is True assert s0.yaml_extras["default_sampling_params"]["detokenize"] is True
def test_merge_pipeline_deploy_preserves_num_replicas(self, tmp_path): def test_merge_pipeline_deploy_preserves_num_replicas(self, tmp_path):
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"] pipeline = _PIPELINE_REGISTRY["qwen3_omni_moe"]
base = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" base = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
if not base.exists(): if not base.exists():
@@ -942,15 +918,6 @@ class TestDeployConfigLoading:
assert stages[1].yaml_runtime["num_replicas"] == 2 assert stages[1].yaml_runtime["num_replicas"] == 2
def test_merge_pipeline_deploy_preserves_requires_multimodal_data(self): def test_merge_pipeline_deploy_preserves_requires_multimodal_data(self):
from vllm_omni.config.stage_config import (
DeployConfig,
PipelineConfig,
StageDeployConfig,
StageExecutionType,
StagePipelineConfig,
merge_pipeline_deploy,
)
pipeline = PipelineConfig( pipeline = PipelineConfig(
model_type="test_mm", model_type="test_mm",
model_arch="TestModel", model_arch="TestModel",
@@ -969,11 +936,186 @@ class TestDeployConfigLoading:
assert stages[0].yaml_runtime["requires_multimodal_data"] is True assert stages[0].yaml_runtime["requires_multimodal_data"] is True
def test_mixed_schema_preserves_flat_fields(self):
"""Ensure flat fields are not dropped when engine_args are present."""
fake_config = {
"stages": [
{
"stage_id": 0,
"gpu_memory_utilization": 0.7,
"max_num_seqs": 8,
"engine_args": {
"tensor_parallel_size": 4,
"enforce_eager": True,
},
},
]
}
with patch("vllm_omni.config.stage_config.resolve_deploy_yaml", return_value=fake_config):
deploy = load_deploy_config("dummy.yaml")
assert len(deploy.stages) == 1
stage = deploy.stages[0]
# Check that the engine args are set
assert stage.tensor_parallel_size == 4
assert stage.enforce_eager is True
# Check that the other settings are also preserved
assert stage.gpu_memory_utilization == 0.7
assert stage.max_num_seqs == 8
def test_engine_parse_engine_fields(self):
"""Test that we correctly parse & recursively merge stage deploy fields."""
fake_config = {
"stages": [
{
"stage_id": 0,
"compilation_config": {
"encoder_cudagraph_token_budgets": [1024, 2048],
"pass_config": {"fuse_norm_quant": True},
},
"engine_args": {
"compilation_config": {
"cudagraph_mm_encoder": True,
"pass_config": {"fuse_allreduce_rms": False},
},
},
},
]
}
with patch("vllm_omni.config.stage_config.resolve_deploy_yaml", return_value=fake_config):
deploy = load_deploy_config("dummy.yaml")
assert len(deploy.stages) == 1
stage = deploy.stages[0]
assert stage.compilation_config == {
"encoder_cudagraph_token_budgets": [1024, 2048],
"pass_config": {
"fuse_norm_quant": True,
"fuse_allreduce_rms": False,
},
"cudagraph_mm_encoder": True,
}
def test_engine_extras_deep_merges_dicts_simple(self):
"""Ensure dictionary valued keys merge properly for top level dicts."""
fake_config = {
"stages": [
{
"stage_id": 0,
"foo": {"a": 111, "b": 1},
"engine_args": {
"foo": {"b": 2, "c": 3},
},
},
]
}
with patch("vllm_omni.config.stage_config.resolve_deploy_yaml", return_value=fake_config):
deploy = load_deploy_config("dummy.yaml")
assert len(deploy.stages) == 1
stage = deploy.stages[0]
assert "foo" in stage.engine_extras
assert stage.engine_extras["foo"] == {"a": 111, "b": 2, "c": 3}
def test_engine_extras_dict_type_mismatch(self):
"""Ensure that we handle type mismatches with nested dicts correctly."""
fake_config = {
"stages": [
{
"stage_id": 0,
"foo": {"b": {1: 1}},
"engine_args": {
"foo": {"b": 2},
},
},
]
}
with patch("vllm_omni.config.stage_config.resolve_deploy_yaml", return_value=fake_config):
deploy = load_deploy_config("dummy.yaml")
assert len(deploy.stages) == 1
stage = deploy.stages[0]
assert "foo" in stage.engine_extras
assert stage.engine_extras["foo"] == {"b": 2}
def test_mixed_engine_extras_deep_merges_dicts(self):
"""Ensure dictionary valued keys merge properly for nested dicts."""
fake_config = {
"stages": [
{
"stage_id": 0,
"foo": {"a": 111, "b": {"e": 199}},
"engine_args": {
"foo": {"b": {"d": 9}, "c": 3},
},
},
]
}
with patch("vllm_omni.config.stage_config.resolve_deploy_yaml", return_value=fake_config):
deploy = load_deploy_config("dummy.yaml")
assert len(deploy.stages) == 1
stage = deploy.stages[0]
assert "foo" in stage.engine_extras
assert stage.engine_extras["foo"] == {"a": 111, "b": {"d": 9, "e": 199}, "c": 3}
def test_deep_merge_does_not_mutate_inputs(self):
"""Merging engine_args must not mutate the base stage dict."""
fake_config = {
"stages": [
{
"stage_id": 0,
"foo": {"a": 1, "b": {"x": 10, "y": 20}},
"engine_args": {
"foo": {"b": {"y": 99, "z": 30}, "c": 3},
},
},
]
}
original_foo = fake_config["stages"][0]["foo"]
original_b = original_foo["b"]
with patch("vllm_omni.config.stage_config.resolve_deploy_yaml", return_value=fake_config):
deploy = load_deploy_config("dummy.yaml")
# Values should merge into the new dict correctly
assert deploy.stages[0].engine_extras["foo"] == {
"a": 1,
"b": {"x": 10, "y": 99, "z": 30},
"c": 3,
}
# But the original nested dicts foo / b should be unchanged, since
# we recursively shallow copy to avoid mutating in place.
assert original_foo == {"a": 1, "b": {"x": 10, "y": 20}}
assert original_b == {"x": 10, "y": 20}
def test_mixed_schema_engine_args_wins_scalars(self):
"""engine_args takes precedence over flat fields for scalar conflicts."""
fake_config = {
"stages": [
{
"stage_id": 0,
"gpu_memory_utilization": 0.7,
"engine_args": {
"gpu_memory_utilization": 0.5,
},
},
]
}
with patch("vllm_omni.config.stage_config.resolve_deploy_yaml", return_value=fake_config):
deploy = load_deploy_config("dummy.yaml")
assert deploy.stages[0].gpu_memory_utilization == 0.5
class TestQwen3OmniPipeline: class TestQwen3OmniPipeline:
def test_registered(self): def test_registered(self):
import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
p = _PIPELINE_REGISTRY.get("qwen3_omni_moe") p = _PIPELINE_REGISTRY.get("qwen3_omni_moe")
assert p is not None assert p is not None
assert p.model_arch == "Qwen3OmniMoeForConditionalGeneration" assert p.model_arch == "Qwen3OmniMoeForConditionalGeneration"
@@ -981,8 +1123,6 @@ class TestQwen3OmniPipeline:
assert p.validate() == [] assert p.validate() == []
def test_thinker(self): def test_thinker(self):
import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
s = _PIPELINE_REGISTRY["qwen3_omni_moe"].get_stage(0) s = _PIPELINE_REGISTRY["qwen3_omni_moe"].get_stage(0)
assert s.model_stage == "thinker" assert s.model_stage == "thinker"
assert s.execution_type == StageExecutionType.LLM_AR assert s.execution_type == StageExecutionType.LLM_AR
@@ -991,8 +1131,6 @@ class TestQwen3OmniPipeline:
assert s.sampling_constraints["detokenize"] is True assert s.sampling_constraints["detokenize"] is True
def test_talker(self): def test_talker(self):
import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
s = _PIPELINE_REGISTRY["qwen3_omni_moe"].get_stage(1) s = _PIPELINE_REGISTRY["qwen3_omni_moe"].get_stage(1)
assert s.input_sources == (0,) assert s.input_sources == (0,)
assert s.sampling_constraints["stop_token_ids"] == [2150] assert s.sampling_constraints["stop_token_ids"] == [2150]
@@ -1000,8 +1138,6 @@ class TestQwen3OmniPipeline:
assert s.custom_process_next_stage_input_func is not None assert s.custom_process_next_stage_input_func is not None
def test_code2wav(self): def test_code2wav(self):
import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
s = _PIPELINE_REGISTRY["qwen3_omni_moe"].get_stage(2) s = _PIPELINE_REGISTRY["qwen3_omni_moe"].get_stage(2)
assert s.execution_type == StageExecutionType.LLM_GENERATION assert s.execution_type == StageExecutionType.LLM_GENERATION
assert s.final_output_type == "audio" assert s.final_output_type == "audio"
@@ -1010,8 +1146,6 @@ class TestQwen3OmniPipeline:
class TestQwen2_5OmniPipeline: class TestQwen2_5OmniPipeline:
def test_registered(self): def test_registered(self):
import vllm_omni.model_executor.models.qwen2_5_omni.pipeline # noqa: F401
p = _PIPELINE_REGISTRY.get("qwen2_5_omni") p = _PIPELINE_REGISTRY.get("qwen2_5_omni")
assert p is not None assert p is not None
assert p.model_arch == "Qwen2_5OmniForConditionalGeneration" assert p.model_arch == "Qwen2_5OmniForConditionalGeneration"
@@ -1019,8 +1153,6 @@ class TestQwen2_5OmniPipeline:
assert p.validate() == [] assert p.validate() == []
def test_thinker(self): 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) s = _PIPELINE_REGISTRY["qwen2_5_omni"].get_stage(0)
assert s.model_stage == "thinker" assert s.model_stage == "thinker"
assert s.execution_type == StageExecutionType.LLM_AR assert s.execution_type == StageExecutionType.LLM_AR
@@ -1029,16 +1161,12 @@ class TestQwen2_5OmniPipeline:
assert s.requires_multimodal_data is True assert s.requires_multimodal_data is True
def test_talker(self): 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) s = _PIPELINE_REGISTRY["qwen2_5_omni"].get_stage(1)
assert s.input_sources == (0,) assert s.input_sources == (0,)
assert s.sampling_constraints["stop_token_ids"] == [8294] assert s.sampling_constraints["stop_token_ids"] == [8294]
assert s.custom_process_input_func is not None assert s.custom_process_input_func is not None
def test_code2wav(self): 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) s = _PIPELINE_REGISTRY["qwen2_5_omni"].get_stage(2)
assert s.execution_type == StageExecutionType.LLM_GENERATION assert s.execution_type == StageExecutionType.LLM_GENERATION
assert s.final_output_type == "audio" assert s.final_output_type == "audio"
@@ -1047,8 +1175,6 @@ class TestQwen2_5OmniPipeline:
class TestQwen3TTSPipeline: class TestQwen3TTSPipeline:
def test_registered(self): def test_registered(self):
import vllm_omni.model_executor.models.qwen3_tts.pipeline # noqa: F401
p = _PIPELINE_REGISTRY.get("qwen3_tts") p = _PIPELINE_REGISTRY.get("qwen3_tts")
assert p is not None assert p is not None
assert p.model_arch == "Qwen3TTSTalkerForConditionalGeneration" assert p.model_arch == "Qwen3TTSTalkerForConditionalGeneration"
@@ -1056,8 +1182,6 @@ class TestQwen3TTSPipeline:
assert p.validate() == [] assert p.validate() == []
def test_talker_stage(self): def test_talker_stage(self):
import vllm_omni.model_executor.models.qwen3_tts.pipeline # noqa: F401
s = _PIPELINE_REGISTRY["qwen3_tts"].get_stage(0) s = _PIPELINE_REGISTRY["qwen3_tts"].get_stage(0)
assert s.model_stage == "qwen3_tts" assert s.model_stage == "qwen3_tts"
assert s.execution_type == StageExecutionType.LLM_AR assert s.execution_type == StageExecutionType.LLM_AR
@@ -1068,8 +1192,6 @@ class TestQwen3TTSPipeline:
assert s.model_arch is None assert s.model_arch is None
def test_code2wav_stage_has_per_stage_model_arch(self): 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) s = _PIPELINE_REGISTRY["qwen3_tts"].get_stage(1)
assert s.execution_type == StageExecutionType.LLM_GENERATION assert s.execution_type == StageExecutionType.LLM_GENERATION
assert s.final_output_type == "audio" assert s.final_output_type == "audio"
@@ -1081,9 +1203,6 @@ class TestQwen3TTSPipeline:
def test_per_stage_model_arch_flows_through_merge(self, tmp_path): def test_per_stage_model_arch_flows_through_merge(self, tmp_path):
"""Verify the new ps.model_arch override survives merge_pipeline_deploy.""" """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" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_tts.yaml"
if not deploy_path.exists(): if not deploy_path.exists():
pytest.skip("qwen3_tts deploy yaml not found") pytest.skip("qwen3_tts deploy yaml not found")
@@ -1099,8 +1218,6 @@ class TestQwen3TTSPipeline:
def test_subtalker_sampling_params_deep_merge_preserves_base_keys(self): def test_subtalker_sampling_params_deep_merge_preserves_base_keys(self):
"""Verify subtalker sampling params participate in stage deep-merge.""" """Verify subtalker sampling params participate in stage deep-merge."""
from vllm_omni.config.stage_config import _deep_merge_stage
base = { base = {
"stage_id": 0, "stage_id": 0,
"subtalker_sampling_params": { "subtalker_sampling_params": {
@@ -1130,8 +1247,6 @@ class TestQwen3TTSPipeline:
class TestMingFlashOmniPipeline: class TestMingFlashOmniPipeline:
def test_registered(self): def test_registered(self):
import vllm_omni.model_executor.models.ming_flash_omni.pipeline # noqa: F401
p = _PIPELINE_REGISTRY.get("ming_flash_omni") p = _PIPELINE_REGISTRY.get("ming_flash_omni")
assert p is not None assert p is not None
assert p.model_arch == "MingFlashOmniForConditionalGeneration" assert p.model_arch == "MingFlashOmniForConditionalGeneration"
@@ -1139,8 +1254,6 @@ class TestMingFlashOmniPipeline:
assert p.validate() == [] assert p.validate() == []
def test_thinker_stage(self): def test_thinker_stage(self):
import vllm_omni.model_executor.models.ming_flash_omni.pipeline # noqa: F401
s = _PIPELINE_REGISTRY["ming_flash_omni"].get_stage(0) s = _PIPELINE_REGISTRY["ming_flash_omni"].get_stage(0)
assert s.model_stage == "thinker" assert s.model_stage == "thinker"
assert s.execution_type == StageExecutionType.LLM_AR assert s.execution_type == StageExecutionType.LLM_AR
@@ -1151,8 +1264,6 @@ class TestMingFlashOmniPipeline:
assert s.sampling_constraints["detokenize"] is True assert s.sampling_constraints["detokenize"] is True
def test_talker_stage(self): def test_talker_stage(self):
import vllm_omni.model_executor.models.ming_flash_omni.pipeline # noqa: F401
s = _PIPELINE_REGISTRY["ming_flash_omni"].get_stage(1) s = _PIPELINE_REGISTRY["ming_flash_omni"].get_stage(1)
assert s.model_stage == "ming_tts" assert s.model_stage == "ming_tts"
assert s.execution_type == StageExecutionType.LLM_GENERATION assert s.execution_type == StageExecutionType.LLM_GENERATION
@@ -1171,18 +1282,12 @@ class TestMingFlashOmniPipeline:
Lazy string references only fail at first inference otherwise — this Lazy string references only fail at first inference otherwise — this
catches typos in the pipeline declaration at import / registration time. catches typos in the pipeline declaration at import / registration time.
""" """
import importlib
import vllm_omni.model_executor.models.ming_flash_omni.pipeline # noqa: F401
s = _PIPELINE_REGISTRY["ming_flash_omni"].get_stage(1) s = _PIPELINE_REGISTRY["ming_flash_omni"].get_stage(1)
module_path, _, attr = s.custom_process_input_func.rpartition(".") module_path, _, attr = s.custom_process_input_func.rpartition(".")
module = importlib.import_module(module_path) module = importlib.import_module(module_path)
assert callable(getattr(module, attr)) assert callable(getattr(module, attr))
def test_tts_pipeline_registered(self): def test_tts_pipeline_registered(self):
import vllm_omni.model_executor.models.ming_flash_omni.pipeline # noqa: F401
p = _PIPELINE_REGISTRY.get("ming_flash_omni_tts") p = _PIPELINE_REGISTRY.get("ming_flash_omni_tts")
assert p is not None assert p is not None
assert p.model_arch == "MingFlashOmniTalkerForConditionalGeneration" assert p.model_arch == "MingFlashOmniTalkerForConditionalGeneration"
@@ -1190,8 +1295,6 @@ class TestMingFlashOmniPipeline:
assert p.validate() == [] assert p.validate() == []
def test_tts_stage(self): def test_tts_stage(self):
import vllm_omni.model_executor.models.ming_flash_omni.pipeline # noqa: F401
s = _PIPELINE_REGISTRY["ming_flash_omni_tts"].get_stage(0) s = _PIPELINE_REGISTRY["ming_flash_omni_tts"].get_stage(0)
assert s.model_stage == "ming_tts" assert s.model_stage == "ming_tts"
assert s.execution_type == StageExecutionType.LLM_GENERATION assert s.execution_type == StageExecutionType.LLM_GENERATION
@@ -1204,9 +1307,6 @@ class TestMingFlashOmniPipeline:
def test_full_yaml_loads_and_merges(self): def test_full_yaml_loads_and_merges(self):
"""deploy/ming_flash_omni.yaml parses and merges with the registered pipeline.""" """deploy/ming_flash_omni.yaml parses and merges with the registered pipeline."""
import vllm_omni.model_executor.models.ming_flash_omni.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" / "ming_flash_omni.yaml" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "ming_flash_omni.yaml"
if not deploy_path.exists(): if not deploy_path.exists():
pytest.skip("ming_flash_omni deploy yaml not found") pytest.skip("ming_flash_omni deploy yaml not found")
@@ -1226,9 +1326,6 @@ class TestMingFlashOmniPipeline:
def test_tts_yaml_loads_and_merges(self): def test_tts_yaml_loads_and_merges(self):
"""deploy/ming_flash_omni_tts.yaml parses and routes to the TTS-only pipeline.""" """deploy/ming_flash_omni_tts.yaml parses and routes to the TTS-only pipeline."""
import vllm_omni.model_executor.models.ming_flash_omni.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" / "ming_flash_omni_tts.yaml" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "ming_flash_omni_tts.yaml"
if not deploy_path.exists(): if not deploy_path.exists():
pytest.skip("ming_flash_omni_tts deploy yaml not found") pytest.skip("ming_flash_omni_tts deploy yaml not found")
@@ -1243,8 +1340,6 @@ class TestMingFlashOmniPipeline:
assert stages[0].yaml_engine_args["model_arch"] == "MingFlashOmniTalkerForConditionalGeneration" assert stages[0].yaml_engine_args["model_arch"] == "MingFlashOmniTalkerForConditionalGeneration"
def test_thinker_only_pipeline_registered(self): def test_thinker_only_pipeline_registered(self):
import vllm_omni.model_executor.models.ming_flash_omni.pipeline # noqa: F401
p = _PIPELINE_REGISTRY.get("ming_flash_omni_thinker_only") p = _PIPELINE_REGISTRY.get("ming_flash_omni_thinker_only")
assert p is not None assert p is not None
assert p.model_arch == "MingFlashOmniForConditionalGeneration" assert p.model_arch == "MingFlashOmniForConditionalGeneration"
@@ -1252,8 +1347,6 @@ class TestMingFlashOmniPipeline:
assert p.validate() == [] assert p.validate() == []
def test_thinker_only_stage(self): def test_thinker_only_stage(self):
import vllm_omni.model_executor.models.ming_flash_omni.pipeline # noqa: F401
s = _PIPELINE_REGISTRY["ming_flash_omni_thinker_only"].get_stage(0) s = _PIPELINE_REGISTRY["ming_flash_omni_thinker_only"].get_stage(0)
assert s.model_stage == "thinker" assert s.model_stage == "thinker"
assert s.execution_type == StageExecutionType.LLM_AR assert s.execution_type == StageExecutionType.LLM_AR
@@ -1267,9 +1360,6 @@ class TestMingFlashOmniPipeline:
def test_thinker_only_yaml_loads_and_merges(self): def test_thinker_only_yaml_loads_and_merges(self):
"""deploy/ming_flash_omni_thinker_only.yaml parses and routes to the thinker-only pipeline.""" """deploy/ming_flash_omni_thinker_only.yaml parses and routes to the thinker-only pipeline."""
import vllm_omni.model_executor.models.ming_flash_omni.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" / "ming_flash_omni_thinker_only.yaml" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "ming_flash_omni_thinker_only.yaml"
if not deploy_path.exists(): if not deploy_path.exists():
pytest.skip("ming_flash_omni_thinker_only deploy yaml not found") pytest.skip("ming_flash_omni_thinker_only deploy yaml not found")
@@ -1288,9 +1378,6 @@ class TestBaseConfigInheritance:
"""Test deploy YAML base_config inheritance.""" """Test deploy YAML base_config inheritance."""
def test_ci_inherits_from_main(self): 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")) ci_path = Path(get_deploy_config_path("ci/qwen3_omni_moe.yaml"))
if not ci_path.exists(): if not ci_path.exists():
pytest.skip("CI deploy config not found") pytest.skip("CI deploy config not found")
@@ -1298,7 +1385,7 @@ class TestBaseConfigInheritance:
deploy = load_deploy_config(ci_path) deploy = load_deploy_config(ci_path)
assert len(deploy.stages) == 3 assert len(deploy.stages) == 3
# CI overrides # CI overrides
assert deploy.stages[0].load_format == "dummy" assert deploy.stages[0].load_format is None
assert "load_format" not in deploy.stages[0].engine_extras assert "load_format" not in deploy.stages[0].engine_extras
assert deploy.stages[0].max_num_seqs == 5 assert deploy.stages[0].max_num_seqs == 5
# Inherited from base # Inherited from base
@@ -1311,9 +1398,6 @@ class TestBaseConfigInheritance:
assert deploy.async_chunk is False assert deploy.async_chunk is False
def test_ci_sampling_merge(self): 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")) ci_path = Path(get_deploy_config_path("ci/qwen3_omni_moe.yaml"))
if not ci_path.exists(): if not ci_path.exists():
pytest.skip("CI deploy config not found") pytest.skip("CI deploy config not found")
@@ -1328,8 +1412,6 @@ class TestBaseConfigInheritance:
def test_pure_inheritance_overlay(self, tmp_path): def test_pure_inheritance_overlay(self, tmp_path):
"""An overlay with only ``base_config`` inherits everything.""" """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" base = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
if not base.exists(): if not base.exists():
pytest.skip("Base deploy config not found") pytest.skip("Base deploy config not found")
@@ -1343,8 +1425,6 @@ class TestBaseConfigInheritance:
def test_single_field_overlay(self, tmp_path): def test_single_field_overlay(self, tmp_path):
"""An overlay overriding one stage field merges with the base.""" """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" base = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
if not base.exists(): if not base.exists():
pytest.skip("Base deploy config not found") pytest.skip("Base deploy config not found")
@@ -1362,10 +1442,6 @@ class TestPlatformOverrides:
"""Test platform-specific deploy config overrides.""" """Test platform-specific deploy config overrides."""
def test_npu_overrides(self): 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" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
if not deploy_path.exists(): if not deploy_path.exists():
pytest.skip("Deploy config not found") pytest.skip("Deploy config not found")
@@ -1380,10 +1456,6 @@ class TestPlatformOverrides:
assert deploy.stages[2].enforce_eager is True assert deploy.stages[2].enforce_eager is True
def test_xpu_overrides(self): 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" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
if not deploy_path.exists(): if not deploy_path.exists():
pytest.skip("Deploy config not found") pytest.skip("Deploy config not found")
@@ -1396,10 +1468,6 @@ class TestPlatformOverrides:
assert deploy.stages[0].engine_extras.get("max_cudagraph_capture_size") == 0 assert deploy.stages[0].engine_extras.get("max_cudagraph_capture_size") == 0
def test_unknown_platform_noop(self): 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" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
if not deploy_path.exists(): if not deploy_path.exists():
pytest.skip("Deploy config not found") pytest.skip("Deploy config not found")
@@ -1411,8 +1479,6 @@ class TestPlatformOverrides:
def test_platforms_deep_merge_inheritance(self, tmp_path): def test_platforms_deep_merge_inheritance(self, tmp_path):
"""Overlay's platforms: block layers onto base's, per-stage.""" """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 = tmp_path / "base.yaml"
base.write_text( base.write_text(
"stages:\n" "stages:\n"
@@ -1447,11 +1513,6 @@ class TestCLIOverrideFlow:
"""Test --stage-overrides JSON merge into StageConfig.""" """Test --stage-overrides JSON merge into StageConfig."""
def test_stage_overrides_merge(self): 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"] pipeline = _PIPELINE_REGISTRY["qwen3_omni_moe"]
deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
if not deploy_path.exists(): if not deploy_path.exists():
@@ -1466,11 +1527,6 @@ class TestCLIOverrideFlow:
assert stages[0].runtime_overrides["gpu_memory_utilization"] == 0.5 assert stages[0].runtime_overrides["gpu_memory_utilization"] == 0.5
def test_global_override_applies_to_all(self): 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"] pipeline = _PIPELINE_REGISTRY["qwen3_omni_moe"]
deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
if not deploy_path.exists(): if not deploy_path.exists():
@@ -1490,8 +1546,6 @@ class TestSentinelDefaultPrecedence:
to YAML / dataclass defaults (#3035).""" to YAML / dataclass defaults (#3035)."""
def _stages(self, cli_overrides): def _stages(self, cli_overrides):
import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
return StageConfigFactory._create_from_registry( return StageConfigFactory._create_from_registry(
"qwen3_omni_moe", "qwen3_omni_moe",
cli_overrides=cli_overrides, cli_overrides=cli_overrides,
@@ -1504,7 +1558,7 @@ class TestSentinelDefaultPrecedence:
def test_none_value_skipped_yaml_wins(self): def test_none_value_skipped_yaml_wins(self):
stages = self._stages({"max_num_seqs": None}) stages = self._stages({"max_num_seqs": None})
assert stages[2].runtime_overrides.get("max_num_seqs") is None assert stages[2].runtime_overrides.get("max_num_seqs") is None
assert "max_num_seqs" not in stages[2].yaml_engine_args assert stages[2].yaml_engine_args.get("max_num_seqs") == 64
def test_empty_kwargs_yaml_only(self): def test_empty_kwargs_yaml_only(self):
stages = self._stages({}) stages = self._stages({})
@@ -1550,15 +1604,11 @@ class TestSentinelDefaultPrecedence:
assert stage.runtime_overrides == {} assert stage.runtime_overrides == {}
def test_create_from_registry_no_cli_explicit_keys_param(self): def test_create_from_registry_no_cli_explicit_keys_param(self):
import inspect
sig = inspect.signature(StageConfigFactory._create_from_registry) sig = inspect.signature(StageConfigFactory._create_from_registry)
named = [p for p in sig.parameters.values() if p.kind != p.VAR_KEYWORD] named = [p for p in sig.parameters.values() if p.kind != p.VAR_KEYWORD]
assert "cli_explicit_keys" not in {p.name for p in named} assert "cli_explicit_keys" not in {p.name for p in named}
def test_cli_explicit_keys_kwarg_emits_deprecation(self): def test_cli_explicit_keys_kwarg_emits_deprecation(self):
import vllm_omni.model_executor.models.qwen3_omni.pipeline # noqa: F401
with warnings.catch_warnings(record=True) as w: with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always") warnings.simplefilter("always")
StageConfigFactory._create_from_registry( StageConfigFactory._create_from_registry(
@@ -1572,13 +1622,6 @@ class TestSentinelDefaultPrecedence:
"""A single ``qwen3_tts`` pipeline picks per-chunk vs end-to-end """A single ``qwen3_tts`` pipeline picks per-chunk vs end-to-end
processors based on ``deploy.async_chunk``, without needing a processors based on ``deploy.async_chunk``, without needing a
separate variant pipeline registration.""" 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"] pipeline = _PIPELINE_REGISTRY["qwen3_tts"]
# async_chunk=True → stage 0's per-chunk processor wires up, stage 1 # async_chunk=True → stage 0's per-chunk processor wires up, stage 1
@@ -1598,16 +1641,46 @@ class TestSentinelDefaultPrecedence:
assert sync_stages[1].custom_process_input_func is not None assert sync_stages[1].custom_process_input_func is not None
assert sync_stages[1].custom_process_input_func.endswith("talker2code2wav") assert sync_stages[1].custom_process_input_func.endswith("talker2code2wav")
def test_async_chunk_dispatches_qwen3_omni_processors(self):
import runpy
from pathlib import Path
from vllm_omni.config.stage_config import DeployConfig, merge_pipeline_deploy
pipeline_path = (
Path(__file__).parent.parent / "vllm_omni" / "model_executor" / "models" / "qwen3_omni" / "pipeline.py"
)
pipeline = runpy.run_path(str(pipeline_path))["QWEN3_OMNI_PIPELINE"]
async_stages = merge_pipeline_deploy(pipeline, DeployConfig(async_chunk=True))
assert (
async_stages[0]
.yaml_engine_args["custom_process_next_stage_input_func"]
.endswith("thinker2talker_async_chunk")
)
assert (
async_stages[1]
.yaml_engine_args["custom_process_next_stage_input_func"]
.endswith("talker2code2wav_async_chunk")
)
sync_stages = merge_pipeline_deploy(pipeline, DeployConfig(async_chunk=False))
assert (
sync_stages[0]
.yaml_engine_args["custom_process_next_stage_input_func"]
.endswith("thinker2talker_full_payload")
)
assert (
sync_stages[1]
.yaml_engine_args["custom_process_next_stage_input_func"]
.endswith("talker2code2wav_full_payload")
)
class TestSamplingConstraintsPrecedence: class TestSamplingConstraintsPrecedence:
"""Test that pipeline sampling_constraints override deploy defaults.""" """Test that pipeline sampling_constraints override deploy defaults."""
def test_constraints_win(self): 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"] pipeline = _PIPELINE_REGISTRY["qwen3_omni_moe"]
deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
if not deploy_path.exists(): if not deploy_path.exists():
@@ -35,6 +35,7 @@ def _make_runner(multimodal_outputs):
runner.device = torch.device("cpu") runner.device = torch.device("cpu")
runner.supports_mm_inputs = False runner.supports_mm_inputs = False
runner.speculative_config = None runner.speculative_config = None
runner.routed_experts_initialized = False
return runner return runner
+112
View File
@@ -5,6 +5,7 @@ import pytest
import torch import torch
from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner
from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorModelRunnerMixin
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -330,6 +331,117 @@ def test_update_intermediate_buffer_skips_unknown_req_id():
assert "unknown_req" not in runner.model_intermediate_buffer assert "unknown_req" not in runner.model_intermediate_buffer
def _make_full_payload_accumulation_runner(
model_arch="Qwen3OmniMoeForConditionalGeneration",
model_stage="talker",
async_chunk=False,
):
runner = object.__new__(OmniConnectorModelRunnerMixin)
runner.model_config = SimpleNamespace(
model_arch=model_arch,
model_stage=model_stage,
async_chunk=async_chunk,
)
runner._custom_process_func = object()
runner._pending_full_payload_send = {}
runner._stage_id = 1
return runner
def test_accumulate_full_payload_output_preserves_aligned_all_zero_qwen3_omni_codec_rows():
runner = _make_full_payload_accumulation_runner()
request = SimpleNamespace(output_token_ids=[0, 1])
codes = torch.zeros((2, 3), dtype=torch.long)
OmniConnectorModelRunnerMixin.accumulate_full_payload_output(runner, "r1", {"codes.audio": codes}, request)
stored, _ = OmniConnectorModelRunnerMixin._materialize_full_payload_entry(runner._pending_full_payload_send["r1"])
assert torch.equal(stored["codes.audio"], codes)
def test_accumulate_full_payload_output_keeps_misaligned_all_zero_qwen3_omni_codec_rows():
# After removing the sender-side zero filter, the accumulator keeps every
# codec row including misaligned all-zero rows. The downstream consumer
# (_extract_qwen3_full_payload_codec_rows) is the authoritative crop and
# filters by output_token_ids.
runner = _make_full_payload_accumulation_runner()
request = SimpleNamespace(output_token_ids=[0, 1])
codes = torch.zeros((1, 3), dtype=torch.long)
OmniConnectorModelRunnerMixin.accumulate_full_payload_output(runner, "r1", {"codes.audio": codes}, request)
stored, _ = OmniConnectorModelRunnerMixin._materialize_full_payload_entry(runner._pending_full_payload_send["r1"])
assert "codes.audio" in stored
assert torch.equal(stored["codes.audio"], codes)
def test_accumulate_full_payload_output_preserves_incremental_aligned_all_zero_qwen3_omni_codec_rows():
runner = _make_full_payload_accumulation_runner()
request = SimpleNamespace(output_token_ids=[0, 1])
runner._pending_full_payload_send["r1"] = (
{"codes.audio": torch.ones((1, 3), dtype=torch.long)},
request,
)
codes = torch.zeros((1, 3), dtype=torch.long)
OmniConnectorModelRunnerMixin.accumulate_full_payload_output(runner, "r1", {"codes.audio": codes}, request)
stored, _ = OmniConnectorModelRunnerMixin._materialize_full_payload_entry(runner._pending_full_payload_send["r1"])
assert stored["codes.audio"].shape == (2, 3)
assert torch.equal(stored["codes.audio"][1], torch.zeros(3, dtype=torch.long))
def test_accumulate_full_payload_output_keeps_all_zero_qwen3_omni_prefill_placeholder():
# Prefill placeholder rows (output_token_ids empty) are no longer dropped
# at the sender. The consumer-side crop trims them off using
# output_token_ids, so the end-to-end semantics are unchanged.
runner = _make_full_payload_accumulation_runner()
request = SimpleNamespace(output_token_ids=[])
codes = torch.zeros((2, 3), dtype=torch.long)
OmniConnectorModelRunnerMixin.accumulate_full_payload_output(runner, "r1", {"codes.audio": codes}, request)
stored, _ = OmniConnectorModelRunnerMixin._materialize_full_payload_entry(runner._pending_full_payload_send["r1"])
assert "codes.audio" in stored
assert torch.equal(stored["codes.audio"], codes)
def test_full_payload_output_accumulation_hook_matrix():
assert _make_full_payload_accumulation_runner(model_stage="thinker")._should_accumulate_full_payload_output()
assert _make_full_payload_accumulation_runner(model_stage="talker")._should_accumulate_full_payload_output()
assert not _make_full_payload_accumulation_runner(model_stage="code2wav")._should_accumulate_full_payload_output()
assert not _make_full_payload_accumulation_runner(
model_stage="talker", async_chunk=True
)._should_accumulate_full_payload_output()
assert not _make_full_payload_accumulation_runner(
model_arch="Qwen3TTSForConditionalGeneration"
)._should_accumulate_full_payload_output()
assert not _make_full_payload_accumulation_runner(
model_arch="Qwen2_5OmniForConditionalGeneration"
)._should_accumulate_full_payload_output()
def test_sync_local_stage_payloads_retains_payload_until_request_is_active():
runner = object.__new__(OmniGPUModelRunner)
payload = {"codes": {"audio": [1, 2, 3]}}
runner._local_stage_payload_cache = {"late": payload}
runner._full_payload_pending_broadcast_req_ids = set()
runner.requests = {}
runner.model_intermediate_buffer = {}
OmniGPUModelRunner._sync_local_stage_payloads(runner)
assert runner._local_stage_payload_cache == {"late": payload}
assert runner.model_intermediate_buffer == {}
runner.requests = {"late": DummyReqState()}
OmniGPUModelRunner._sync_local_stage_payloads(runner)
assert runner._local_stage_payload_cache == {}
assert runner.model_intermediate_buffer["late"] == payload
assert runner.requests["late"].additional_information_cpu == payload
def test_maybe_attach_mimo_audio_req_infos_enriches_dict(): def test_maybe_attach_mimo_audio_req_infos_enriches_dict():
runner = _make_runner_for_mimo() runner = _make_runner_for_mimo()
req_id = "r_mimo" req_id = "r_mimo"
+44 -27
View File
@@ -473,7 +473,7 @@ class DeployConfig:
pipeline_parallel_size: int | None = None pipeline_parallel_size: int | None = None
_STAGE_NON_ENGINE_KEYS = frozenset( _STAGE_RESERVED_KEYS = frozenset(
{ {
"stage_id", "stage_id",
"devices", "devices",
@@ -482,24 +482,31 @@ _STAGE_NON_ENGINE_KEYS = frozenset(
"input_connectors", "input_connectors",
"default_sampling_params", "default_sampling_params",
"engine_extras", "engine_extras",
"engine_args",
"runtime",
} }
) )
# Fields on StageDeployConfig that are populated from engine_args dict # 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} _STAGE_DEPLOY_FIELDS = {f.name: f for f in fields(StageDeployConfig) if f.name not in _STAGE_RESERVED_KEYS}
def _parse_stage_deploy(stage_data: dict[str, Any]) -> StageDeployConfig: def _parse_stage_deploy(stage_data: dict[str, Any]) -> StageDeployConfig:
"""Parse a single stage entry from deploy YAML into StageDeployConfig.""" """Parse a single stage entry from deploy YAML into StageDeployConfig."""
# Get the non-reserved keys for this stage
flat_args = {k: v for k, v in stage_data.items() if k not in _STAGE_RESERVED_KEYS}
runtime_cfg = dict(stage_data.get("runtime", {}))
devices = runtime_cfg.get("devices", stage_data.get("devices"))
num_replicas = runtime_cfg.get("num_replicas", stage_data.get("num_replicas", 1))
if "engine_args" in stage_data: if "engine_args" in stage_data:
runtime_cfg = dict(stage_data.get("runtime", {})) for k, v in stage_data["engine_args"].items():
engine_args = dict(stage_data["engine_args"]) existing = flat_args.get(k)
devices = stage_data.get("runtime", {}).get("devices", stage_data.get("devices")) # If we have multiple dictionaries, merge recursively.
num_replicas = runtime_cfg.get("num_replicas", stage_data.get("num_replicas", 1)) if isinstance(v, dict) and isinstance(existing, dict):
else: flat_args[k] = _get_recursively_merged_dict(existing, v)
engine_args = {k: v for k, v in stage_data.items() if k not in _STAGE_NON_ENGINE_KEYS and k != "stage_id"} else:
devices = stage_data.get("devices") flat_args[k] = v
num_replicas = stage_data.get("num_replicas", stage_data.get("runtime", {}).get("num_replicas", 1))
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"stage_id": stage_data["stage_id"], "stage_id": stage_data["stage_id"],
@@ -507,13 +514,13 @@ def _parse_stage_deploy(stage_data: dict[str, Any]) -> StageDeployConfig:
"num_replicas": int(num_replicas), "num_replicas": int(num_replicas),
} }
for name, f in _STAGE_DEPLOY_FIELDS.items(): for name, f in _STAGE_DEPLOY_FIELDS.items():
if name in engine_args: if name in flat_args:
kwargs[name] = engine_args.pop(name) kwargs[name] = flat_args.pop(name)
kwargs["output_connectors"] = stage_data.get("output_connectors") kwargs["output_connectors"] = stage_data.get("output_connectors")
kwargs["input_connectors"] = stage_data.get("input_connectors") kwargs["input_connectors"] = stage_data.get("input_connectors")
kwargs["default_sampling_params"] = stage_data.get("default_sampling_params") kwargs["default_sampling_params"] = stage_data.get("default_sampling_params")
kwargs["engine_extras"] = engine_args kwargs["engine_extras"] = flat_args
return StageDeployConfig(**kwargs) return StageDeployConfig(**kwargs)
@@ -522,24 +529,34 @@ _DEEP_MERGE_KEYS = frozenset({"default_sampling_params", "subtalker_sampling_par
def _deep_merge_stage(base: dict, overlay: dict) -> dict: def _deep_merge_stage(base: dict, overlay: dict) -> dict:
"""Deep-merge ``_DEEP_MERGE_KEYS`` so thin overlays don't drop base keys.""" """Deep-merge ``_DEEP_MERGE_KEYS`` so thin overlays don't drop base keys."""
merged = dict(base) # Deep merge _DEEP_MERGE_KEYS recursively
for k, v in overlay.items(): base_merge_dict = {k: v for k, v in base.items() if k in _DEEP_MERGE_KEYS}
if k in _DEEP_MERGE_KEYS: overlay_merge_dict = {k: v 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): # Get the merge dict; priority is base < overlay < merged sub
merged[k] = {**base_val, **v} merged_subdict = _get_recursively_merged_dict(original=base_merge_dict, update=overlay_merge_dict)
continue merged_dict = {**base, **overlay, **merged_subdict}
# Deep-merge key but at least one side isn't a dict: surface the return merged_dict
# silent clobber so mismatched YAML types don't get past review.
if base_val is not None:
def _get_recursively_merged_dict(original: dict, update: dict) -> dict:
"""Recursively merge two dicts, returning a new dict."""
merged = original.copy()
for k, update_v in update.items():
orig_v = merged.get(k)
if isinstance(orig_v, dict) and isinstance(update_v, dict):
merged[k] = _get_recursively_merged_dict(orig_v, update_v)
else:
if orig_v is not None and (isinstance(orig_v, dict) != isinstance(update_v, dict)):
logger.warning( logger.warning(
"Deep-merge key %r has non-dict value (base=%s, overlay=%s); " "Deep-merge key %r has non-dict value (base=%s, overlay=%s); "
"overlay will fully replace base instead of merging.", "overlay will fully replace base instead of merging.",
k, k,
type(base_val).__name__, type(orig_v).__name__,
type(v).__name__, type(update_v).__name__,
) )
merged[k] = v
merged[k] = update_v
return merged return merged
@@ -779,7 +796,7 @@ def _build_engine_args(
# Per-stage StageDeployConfig values override pipeline-wide settings. # Per-stage StageDeployConfig values override pipeline-wide settings.
if ds is not None: if ds is not None:
for k, v in asdict(ds).items(): for k, v in asdict(ds).items():
if k in _STAGE_NON_ENGINE_KEYS or v is None: if k in _STAGE_RESERVED_KEYS or v is None:
continue continue
engine_args[k] = v engine_args[k] = v
engine_args.update(ds.engine_extras) engine_args.update(ds.engine_extras)
+68 -2
View File
@@ -20,11 +20,16 @@ from vllm.v1.request import Request, RequestStatus, StreamingUpdate
from vllm.v1.spec_decode.metrics import SpecDecodingStats from vllm.v1.spec_decode.metrics import SpecDecodingStats
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
from vllm_omni.core.sched.omni_scheduling_coordinator import (
OmniSchedulingCoordinator,
uses_qwen3_omni_full_payload_input_coordinator,
)
from vllm_omni.core.sched.output import OmniSchedulerOutput from vllm_omni.core.sched.output import OmniSchedulerOutput
from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import ( from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import (
OmniChunkTransferAdapter, OmniChunkTransferAdapter,
) )
from vllm_omni.engine.serialization import deserialize_additional_information from vllm_omni.engine.serialization import deserialize_additional_information
from vllm_omni.outputs import OmniConnectorOutput
logger = init_logger(__name__) logger = init_logger(__name__)
@@ -75,6 +80,14 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
self.chunk_transfer_adapter = None self.chunk_transfer_adapter = None
if getattr(model_config, "async_chunk", False): if getattr(model_config, "async_chunk", False):
self.chunk_transfer_adapter = OmniChunkTransferAdapter(self.vllm_config) self.chunk_transfer_adapter = OmniChunkTransferAdapter(self.vllm_config)
self.input_coordinator: OmniSchedulingCoordinator | None = None
if uses_qwen3_omni_full_payload_input_coordinator(model_config):
self.input_coordinator = OmniSchedulingCoordinator(
scheduler_max_num_seqs=self.vllm_config.scheduler_config.max_num_seqs,
stage_id=getattr(model_config, "stage_id", 0),
async_chunk=False,
)
self._latest_omni_connector_output: OmniConnectorOutput | None = None
# Snapshot prompt length for each streaming input update # Snapshot prompt length for each streaming input update
self._new_prompt_len_snapshot: dict[str, int] = {} self._new_prompt_len_snapshot: dict[str, int] = {}
@@ -190,6 +203,27 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
return False return False
def schedule(self) -> SchedulerOutput: # type: ignore[override] def schedule(self) -> SchedulerOutput: # type: ignore[override]
# Remove FINISHED_ABORTED requests before the upstream scheduler sees
# them. Upstream vllm raises RuntimeError on this status; omni allows
# async abort (e.g. client disconnect during TTS streaming) to leave
# requests in the waiting/running queues temporarily.
for queue in (self.waiting, self.running):
for req in list(queue):
if getattr(req, "status", None) == RequestStatus.FINISHED_ABORTED:
queue.remove(req)
connector_output = self._latest_omni_connector_output
self._latest_omni_connector_output = None
if self.input_coordinator:
if connector_output and connector_output.request_metadata:
self.input_coordinator.update_request_metadata(
self.requests, connector_output.request_metadata, model_mode="ar"
)
self.input_coordinator.process_pending_full_payload_inputs(
self.waiting,
self.running,
connector_output.stage_recv_req_ids if connector_output else set(),
)
if self.chunk_transfer_adapter: if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.process_pending_chunks(self.waiting, self.running) self.chunk_transfer_adapter.process_pending_chunks(self.waiting, self.running)
@@ -199,6 +233,8 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
if self.chunk_transfer_adapter: if self.chunk_transfer_adapter:
# Add request waiting for chunk to the waiting and running queue # Add request waiting for chunk to the waiting and running queue
self.chunk_transfer_adapter.restore_queues(self.waiting, self.running) self.chunk_transfer_adapter.restore_queues(self.waiting, self.running)
if self.input_coordinator:
self.input_coordinator.restore_queues(self.waiting, self.running)
try: try:
# Late import to avoid circulars in some launch modes # Late import to avoid circulars in some launch modes
from .output import OmniNewRequestData from .output import OmniNewRequestData
@@ -222,6 +258,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
lora_request=nr.lora_request, lora_request=nr.lora_request,
# Enrich with omni payloads from the live request object # Enrich with omni payloads from the live request object
prompt_embeds=(getattr(request, "prompt_embeds", None) if request else None), prompt_embeds=(getattr(request, "prompt_embeds", None) if request else None),
prompt_is_token_ids=nr.prompt_is_token_ids,
additional_information=(getattr(request, "additional_information", None) if request else None), additional_information=(getattr(request, "additional_information", None) if request else None),
) )
new_list.append(omni_nr) new_list.append(omni_nr)
@@ -239,9 +276,11 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
# Wrap in omni scheduler output to carry transfer metadata. # Wrap in omni scheduler output to carry transfer metadata.
base_fields = SchedulerOutput.__dataclass_fields__.keys() base_fields = SchedulerOutput.__dataclass_fields__.keys()
base_data = {name: getattr(scheduler_output, name) for name in base_fields} base_data = {name: getattr(scheduler_output, name) for name in base_fields}
input_regs = self.input_coordinator.pending_input_registrations if self.input_coordinator else []
return OmniSchedulerOutput( return OmniSchedulerOutput(
**base_data, **base_data,
finished_requests_needing_kv_transfer=finished_reqs, finished_requests_needing_kv_transfer=finished_reqs,
pending_input_registrations=input_regs,
) )
def update_from_output( def update_from_output(
@@ -389,7 +428,11 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
stopped = True stopped = True
if stopped: if stopped:
routed_experts = self._get_routed_experts(request) if (
model_runner_output.routed_experts_dict is not None
and req_id in model_runner_output.routed_experts_dict
):
routed_experts = model_runner_output.routed_experts_dict[req_id]
# Capture finish_reason BEFORE _handle_stopped_request, which may # Capture finish_reason BEFORE _handle_stopped_request, which may
# reset the status to WAITING for streaming requests that continue. # reset the status to WAITING for streaming requests that continue.
@@ -534,6 +577,16 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
engine_core_outputs[0] = eco = EngineCoreOutputs() engine_core_outputs[0] = eco = EngineCoreOutputs()
eco.scheduler_stats = stats eco.scheduler_stats = stats
omni_output = getattr(model_runner_output, "omni_connector_output", None)
if omni_output is not None:
self._latest_omni_connector_output = omni_output
if self.input_coordinator and omni_output.request_metadata:
self.input_coordinator.update_request_metadata(
self.requests,
omni_output.request_metadata,
model_mode="ar",
)
# Free blocks that were held for transfer (kv_ready and # Free blocks that were held for transfer (kv_ready and
# active_kv_transfers updates already done before the per-request loop). # active_kv_transfers updates already done before the per-request loop).
if kv_extracted_ids: if kv_extracted_ids:
@@ -572,7 +625,12 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
if self.chunk_transfer_adapter: if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.finish_requests(request_ids, finished_status, self.requests) self.chunk_transfer_adapter.finish_requests(request_ids, finished_status, self.requests)
return super().finish_requests(request_ids, finished_status) finished = super().finish_requests(request_ids, finished_status)
input_coordinator = getattr(self, "input_coordinator", None)
if input_coordinator is not None:
for request_id, _ in finished:
self._free_input_coordinator_request(request_id)
return finished
def _update_request_as_session(self, session: Request, update: StreamingUpdate) -> None: def _update_request_as_session(self, session: Request, update: StreamingUpdate) -> None:
""" """
@@ -616,10 +674,14 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
# It triggered but hasn't finished yet. We MUST wait. # It triggered but hasn't finished yet. We MUST wait.
logger.debug(f"[Omni] Request {request_id} finished but transfer is still ACTIVE. Waiting.") logger.debug(f"[Omni] Request {request_id} finished but transfer is still ACTIVE. Waiting.")
self.waiting_for_transfer_free.add(request_id) self.waiting_for_transfer_free.add(request_id)
if self.input_coordinator is not None:
self._free_input_coordinator_request(request_id)
kv_xfer_params = None kv_xfer_params = None
return kv_xfer_params return kv_xfer_params
elif request_id in self.waiting_for_transfer_free: elif request_id in self.waiting_for_transfer_free:
# Blocks held until KV extraction completes in a future step. # Blocks held until KV extraction completes in a future step.
if self.input_coordinator is not None:
self._free_input_coordinator_request(request_id)
return None return None
else: else:
logger.debug( logger.debug(
@@ -654,10 +716,14 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
if isinstance(add_info, dict): if isinstance(add_info, dict):
add_info.update(kv_xfer_params) add_info.update(kv_xfer_params)
if self.input_coordinator is not None:
self._free_input_coordinator_request(request_id)
return kv_xfer_params return kv_xfer_params
# 3. Standard Freeing # 3. Standard Freeing
delay_free_blocks |= connector_delay_free_blocks delay_free_blocks |= connector_delay_free_blocks
if self.input_coordinator is not None:
self._free_input_coordinator_request(request_id)
if not delay_free_blocks: if not delay_free_blocks:
self._free_blocks(request) self._free_blocks(request)
+114 -23
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import time import time
from collections import defaultdict from collections import defaultdict
from typing import Any
from vllm.compilation.cuda_graph import CUDAGraphStat from vllm.compilation.cuda_graph import CUDAGraphStat
from vllm.distributed.kv_events import KVEventBatch from vllm.distributed.kv_events import KVEventBatch
@@ -23,11 +24,15 @@ from vllm.v1.request import Request, RequestStatus, StreamingUpdate
from vllm.v1.spec_decode.metrics import SpecDecodingStats from vllm.v1.spec_decode.metrics import SpecDecodingStats
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
from vllm_omni.core.sched.output import OmniCachedRequestData, OmniNewRequestData from vllm_omni.core.sched.omni_scheduling_coordinator import (
OmniSchedulingCoordinator,
uses_qwen3_omni_full_payload_input_coordinator,
)
from vllm_omni.core.sched.output import OmniCachedRequestData, OmniNewRequestData, OmniSchedulerOutput
from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import ( from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import (
OmniChunkTransferAdapter, OmniChunkTransferAdapter,
) )
from vllm_omni.outputs import OmniModelRunnerOutput from vllm_omni.outputs import OmniConnectorOutput, OmniModelRunnerOutput
logger = init_logger(__name__) logger = init_logger(__name__)
@@ -39,6 +44,15 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
self.chunk_transfer_adapter = None self.chunk_transfer_adapter = None
if getattr(model_config, "async_chunk", False): if getattr(model_config, "async_chunk", False):
self.chunk_transfer_adapter = OmniChunkTransferAdapter(self.vllm_config) self.chunk_transfer_adapter = OmniChunkTransferAdapter(self.vllm_config)
self._pending_finish_reqs: list[Request] = []
self.input_coordinator: OmniSchedulingCoordinator | None = None
if uses_qwen3_omni_full_payload_input_coordinator(model_config):
self.input_coordinator = OmniSchedulingCoordinator(
scheduler_max_num_seqs=self.vllm_config.scheduler_config.max_num_seqs,
stage_id=getattr(model_config, "stage_id", 0),
async_chunk=False,
)
self._latest_omni_connector_output: OmniConnectorOutput | None = None
def schedule(self) -> SchedulerOutput: def schedule(self) -> SchedulerOutput:
"""Diffusion fast path: """Diffusion fast path:
@@ -68,6 +82,18 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
# Temporary queue: preserve waiting order, do not disturb non-diffusion requests # Temporary queue: preserve waiting order, do not disturb non-diffusion requests
skipped_waiting_requests = create_request_queue(self.policy) skipped_waiting_requests = create_request_queue(self.policy)
req_index = 0 req_index = 0
connector_output = self._latest_omni_connector_output
self._latest_omni_connector_output = None
if self.input_coordinator:
if connector_output and connector_output.request_metadata:
self.input_coordinator.update_request_metadata(
self.requests, connector_output.request_metadata, model_mode="generation"
)
self.input_coordinator.process_pending_full_payload_inputs(
self.waiting,
self.running,
connector_output.stage_recv_req_ids if connector_output else set(),
)
if self.chunk_transfer_adapter: if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.process_pending_chunks(self.waiting, self.running) self.chunk_transfer_adapter.process_pending_chunks(self.waiting, self.running)
@@ -86,23 +112,14 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
num_computed_tokens = request.num_computed_tokens num_computed_tokens = request.num_computed_tokens
required_tokens = len(request.prompt_token_ids) - num_computed_tokens required_tokens = len(request.prompt_token_ids) - num_computed_tokens
# async_chunk: don't schedule placeholder tokens when no new chunk is available.
if required_tokens <= 0: if required_tokens <= 0:
if ( if (
self.chunk_transfer_adapter is not None self.chunk_transfer_adapter is not None
and request.request_id in self.chunk_transfer_adapter.finished_requests and request.request_id in self.chunk_transfer_adapter.finished_requests
): ):
# Upstream may finish with no terminal tokens; append one pad token so we can emit FINISHED. self._pending_finish_reqs.append(request)
if len(request.prompt_token_ids) <= num_computed_tokens: req_index += 1
request.prompt_token_ids.append(0) continue
try:
request._all_token_ids.append(0) # type: ignore[attr-defined]
except Exception:
pass
required_tokens = len(request.prompt_token_ids) - num_computed_tokens
else:
req_index += 1
continue
num_new_tokens = min(required_tokens, token_budget) num_new_tokens = min(required_tokens, token_budget)
new_blocks = self.kv_cache_manager.allocate_slots( new_blocks = self.kv_cache_manager.allocate_slots(
request, request,
@@ -149,11 +166,9 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
# async_chunk: wait for the first upstream chunk (don't start with placeholders). # async_chunk: wait for the first upstream chunk (don't start with placeholders).
if self.chunk_transfer_adapter is not None and len(request.prompt_token_ids) == 0: if self.chunk_transfer_adapter is not None and len(request.prompt_token_ids) == 0:
if request.request_id in self.chunk_transfer_adapter.finished_requests: if request.request_id in self.chunk_transfer_adapter.finished_requests:
request.prompt_token_ids.append(0) self.waiting.pop_request()
try: self._pending_finish_reqs.append(request)
request._all_token_ids.append(0) # type: ignore[attr-defined] continue
except Exception:
pass
else: else:
self.waiting.pop_request() self.waiting.pop_request()
skipped_waiting_requests.prepend_request(request) skipped_waiting_requests.prepend_request(request)
@@ -201,7 +216,16 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
self.chunk_transfer_adapter.restore_queues(self.waiting, self.running) self.chunk_transfer_adapter.restore_queues(self.waiting, self.running)
else: else:
res = super().schedule() res = super().schedule()
return res if self.input_coordinator:
self.input_coordinator.restore_queues(self.waiting, self.running)
base_fields = SchedulerOutput.__dataclass_fields__.keys()
base_data = {name: getattr(res, name) for name in base_fields}
return OmniSchedulerOutput(
**base_data,
pending_input_registrations=(
self.input_coordinator.pending_input_registrations if self.input_coordinator else []
),
)
# Compute common prefix blocks (aligned with v1) # Compute common prefix blocks (aligned with v1)
num_common_prefix_blocks = [0] * len(self.kv_cache_config.kv_cache_groups) num_common_prefix_blocks = [0] * len(self.kv_cache_config.kv_cache_groups)
@@ -303,6 +327,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
lora_request=nr.lora_request, lora_request=nr.lora_request,
# Enrich with omni payloads from the live request object # Enrich with omni payloads from the live request object
prompt_embeds=(getattr(request, "prompt_embeds", None) if request else None), prompt_embeds=(getattr(request, "prompt_embeds", None) if request else None),
prompt_is_token_ids=nr.prompt_is_token_ids,
additional_information=(getattr(request, "additional_information", None) if request else None), additional_information=(getattr(request, "additional_information", None) if request else None),
) )
new_list.append(omni_nr) new_list.append(omni_nr)
@@ -321,8 +346,17 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
# internal deques and never scheduled again. # internal deques and never scheduled again.
if self.chunk_transfer_adapter: if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.restore_queues(self.waiting, self.running) self.chunk_transfer_adapter.restore_queues(self.waiting, self.running)
if self.input_coordinator:
self.input_coordinator.restore_queues(self.waiting, self.running)
return scheduler_output base_fields = SchedulerOutput.__dataclass_fields__.keys()
base_data = {name: getattr(scheduler_output, name) for name in base_fields}
return OmniSchedulerOutput(
**base_data,
pending_input_registrations=(
self.input_coordinator.pending_input_registrations if self.input_coordinator else []
),
)
def finish_requests(self, request_ids, finished_status: RequestStatus) -> list[tuple[str, int]]: def finish_requests(self, request_ids, finished_status: RequestStatus) -> list[tuple[str, int]]:
"""Handles the finish signal from outside the scheduler. """Handles the finish signal from outside the scheduler.
@@ -340,7 +374,20 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
if self.chunk_transfer_adapter: if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.finish_requests(request_ids, finished_status, self.requests) self.chunk_transfer_adapter.finish_requests(request_ids, finished_status, self.requests)
return super().finish_requests(request_ids, finished_status) finished = super().finish_requests(request_ids, finished_status)
if self.input_coordinator is not None:
for request_id, _ in finished:
self._free_input_coordinator_request(request_id)
return finished
def _free_request(self, request: Request, delay_free_blocks: bool = False) -> dict[str, Any] | None:
if self.input_coordinator is None:
return super()._free_request(request, delay_free_blocks)
try:
return super()._free_request(request, delay_free_blocks)
finally:
self._free_input_coordinator_request(request.request_id)
""" """
Scheduler for the diffusion model. Scheduler for the diffusion model.
@@ -464,7 +511,11 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
stopped = True stopped = True
if stopped: if stopped:
routed_experts = self._get_routed_experts(request) if (
model_runner_output.routed_experts_dict is not None
and req_id in model_runner_output.routed_experts_dict
):
routed_experts = model_runner_output.routed_experts_dict[req_id]
finish_reason = request.get_finished_reason() finish_reason = request.get_finished_reason()
finished = self._handle_stopped_request(request) finished = self._handle_stopped_request(request)
if finished: if finished:
@@ -520,6 +571,36 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
# Invariant: EngineCore returns no partial prefill outputs. # Invariant: EngineCore returns no partial prefill outputs.
assert not prompt_logprobs_tensors assert not prompt_logprobs_tensors
# Finish async_chunk requests that schedule() collected because their
# upstream completed with no remaining codec tokens.
for request in self._pending_finish_reqs:
if request.is_finished():
continue
request.status = RequestStatus.FINISHED_STOPPED
finish_reason = request.get_finished_reason()
finished = self._handle_stopped_request(request)
kv_transfer_params = None
if finished:
kv_transfer_params = self._free_request(request)
if self.chunk_transfer_adapter is not None:
self.chunk_transfer_adapter.cleanup(
request.request_id,
getattr(request, "external_req_id", None),
)
outputs[request.client_index].append(
EngineCoreOutput(
request_id=request.request_id,
new_token_ids=[],
finish_reason=finish_reason,
stop_reason=request.stop_reason,
events=request.take_events(),
kv_transfer_params=kv_transfer_params,
trace_headers=request.trace_headers,
)
)
stopped_running_reqs.add(request)
self._pending_finish_reqs.clear()
# Remove the stopped requests from the running and waiting queues. # Remove the stopped requests from the running and waiting queues.
if stopped_running_reqs: if stopped_running_reqs:
self.running = remove_all(self.running, stopped_running_reqs) self.running = remove_all(self.running, stopped_running_reqs)
@@ -589,6 +670,16 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
engine_core_outputs[0] = eco = EngineCoreOutputs() engine_core_outputs[0] = eco = EngineCoreOutputs()
eco.scheduler_stats = stats eco.scheduler_stats = stats
omni_output = getattr(model_runner_output, "omni_connector_output", None)
if omni_output is not None:
self._latest_omni_connector_output = omni_output
if self.input_coordinator and omni_output.request_metadata:
self.input_coordinator.update_request_metadata(
self.requests,
omni_output.request_metadata,
model_mode="generation",
)
return engine_core_outputs return engine_core_outputs
def _update_request_as_session(self, session: Request, update: StreamingUpdate) -> None: def _update_request_as_session(self, session: Request, update: StreamingUpdate) -> None:
@@ -7,6 +7,12 @@ from vllm.v1.request import Request, RequestStatus, StreamingUpdate
class OmniSchedulerMixin: class OmniSchedulerMixin:
"""Shared scheduler helpers for omni-specific request handling.""" """Shared scheduler helpers for omni-specific request handling."""
def _free_input_coordinator_request(self, request_id: str) -> None:
"""Prune full-payload coordinator state for a completed request."""
input_coordinator = getattr(self, "input_coordinator", None)
if input_coordinator is not None:
input_coordinator.free_finished_request(request_id)
def _replace_session_with_streaming_update( def _replace_session_with_streaming_update(
self, self,
session: Request, session: Request,
@@ -22,6 +22,15 @@ from vllm.v1.request import Request, RequestStatus
logger = init_logger(__name__) logger = init_logger(__name__)
def uses_qwen3_omni_full_payload_input_coordinator(model_config: Any) -> bool:
return (
getattr(model_config, "stage_id", 0) > 0
and not getattr(model_config, "async_chunk", False)
and getattr(model_config, "model_arch", None) == "Qwen3OmniMoeForConditionalGeneration"
and getattr(model_config, "model_stage", None) in {"talker", "code2wav"}
)
class OmniSchedulingCoordinator: class OmniSchedulingCoordinator:
"""Pure-scheduling coordinator for chunk and full_payload input waiting. """Pure-scheduling coordinator for chunk and full_payload input waiting.
@@ -166,8 +175,10 @@ class OmniSchedulingCoordinator:
to_remove.append(request) to_remove.append(request)
self._waiting_for_input.append(request) self._waiting_for_input.append(request)
self.pending_input_registrations.append(request) self.pending_input_registrations.append(request)
for request in to_remove: if to_remove:
waiting_queue.remove(request) # Use the bulk-remove helper: one O(N) sweep instead of N
# repeated O(N) removes from a list-backed queue.
waiting_queue.remove_requests(to_remove)
def process_pending_full_payload_inputs_legacy( def process_pending_full_payload_inputs_legacy(
self, self,
@@ -255,6 +266,30 @@ class OmniSchedulingCoordinator:
waiting_queue.add_request(request) waiting_queue.add_request(request)
self._waiting_for_input = deque() self._waiting_for_input = deque()
@staticmethod
def _flatten_prompt_token_ids(value: Any) -> list[int]:
"""Normalize connector metadata into flat prompt token ids."""
if value is None:
return []
if hasattr(value, "detach") and hasattr(value, "cpu") and hasattr(value, "tolist"):
value = value.detach().cpu().tolist()
elif hasattr(value, "tolist") and not isinstance(value, (list, tuple)):
value = value.tolist()
if isinstance(value, (list, tuple)):
flattened: list[int] = []
for item in value:
if hasattr(item, "detach") and hasattr(item, "cpu") and hasattr(item, "tolist"):
item = item.detach().cpu().tolist()
elif hasattr(item, "tolist") and not isinstance(item, (list, tuple)):
item = item.tolist()
if isinstance(item, (list, tuple)):
flattened.extend(int(token_id) for token_id in item)
else:
flattened.append(int(item))
return flattened
return [int(value)]
def update_request_metadata( def update_request_metadata(
self, self,
requests: dict[str, Request], requests: dict[str, Request],
@@ -310,7 +345,7 @@ class OmniSchedulingCoordinator:
) )
if model_mode != "ar": if model_mode != "ar":
new_ids = metadata.get("code_predictor_codes", []) new_ids = self._flatten_prompt_token_ids(metadata.get("code_predictor_codes"))
runtime_seed = None runtime_seed = None
if "left_context_size" in metadata: if "left_context_size" in metadata:
runtime_seed = { runtime_seed = {
@@ -319,6 +354,10 @@ class OmniSchedulingCoordinator:
request._omni_initial_model_buffer = runtime_seed request._omni_initial_model_buffer = runtime_seed
if new_ids: if new_ids:
request.prompt_token_ids = new_ids request.prompt_token_ids = new_ids
request.num_prompt_tokens = len(new_ids)
request._all_token_ids.clear()
request._all_token_ids.extend(new_ids)
request._output_token_ids.clear()
request.num_computed_tokens = 0 request.num_computed_tokens = 0
def postprocess_scheduler_output( def postprocess_scheduler_output(
@@ -374,7 +413,3 @@ class OmniSchedulingCoordinator:
if scheduler_output.scheduled_cached_reqs: if scheduler_output.scheduled_cached_reqs:
for req_id in scheduler_output.scheduled_cached_reqs.req_ids: for req_id in scheduler_output.scheduled_cached_reqs.req_ids:
self.requests_with_ready_chunks.discard(req_id) self.requests_with_ready_chunks.discard(req_id)
# Backward-compatible alias
ChunkSchedulingCoordinator = OmniSchedulingCoordinator
+3
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any
from vllm.v1.core.sched.output import CachedRequestData, NewRequestData, SchedulerOutput from vllm.v1.core.sched.output import CachedRequestData, NewRequestData, SchedulerOutput
from vllm.v1.request import Request from vllm.v1.request import Request
@@ -53,6 +54,7 @@ class OmniNewRequestData(NewRequestData):
num_computed_tokens=request.num_computed_tokens, num_computed_tokens=request.num_computed_tokens,
lora_request=request.lora_request, lora_request=request.lora_request,
prompt_embeds=getattr(request, "prompt_embeds", None), prompt_embeds=getattr(request, "prompt_embeds", None),
prompt_is_token_ids=getattr(request, "prompt_is_token_ids", None),
prefill_token_ids=prefill_token_ids, prefill_token_ids=prefill_token_ids,
additional_information=getattr(request, "additional_information", None), additional_information=getattr(request, "additional_information", None),
) )
@@ -75,3 +77,4 @@ class OmniSchedulerOutput(SchedulerOutput):
"""Scheduler output with omni-specific transfer metadata.""" """Scheduler output with omni-specific transfer metadata."""
finished_requests_needing_kv_transfer: dict[str, dict] = field(default_factory=dict) finished_requests_needing_kv_transfer: dict[str, dict] = field(default_factory=dict)
pending_input_registrations: list[Any] = field(default_factory=list)
+7
View File
@@ -22,6 +22,13 @@ connectors:
stages: stages:
- stage_id: 0 - stage_id: 0
# ``is_comprehension`` in vllm-omni names the tokenizer-owning AR stage
# (see config/stage_config.py + serving_chat AR-stage lookup), independent
# of whether the AR's task is comprehension (i2t/t2t) or generation
# (it2i/t2i). HunyuanImage-3.0's stage-0 owns the tokenizer and emits the
# cot+ratio token sequence consumed by stage-1, so it must be marked True
# for the serving path to set AR seed/stop_token_ids on this stage.
is_comprehension: true
final_output: true final_output: true
final_output_type: text final_output_type: text
max_num_seqs: 1 max_num_seqs: 1
+2 -2
View File
@@ -23,7 +23,7 @@ connectors:
stages: stages:
- stage_id: 0 - stage_id: 0
max_num_seqs: 1 max_num_seqs: 1
gpu_memory_utilization: 0.3 gpu_memory_utilization: 0.5
enforce_eager: true enforce_eager: true
trust_remote_code: true trust_remote_code: true
enable_prefix_caching: false enable_prefix_caching: false
@@ -42,7 +42,7 @@ stages:
- stage_id: 1 - stage_id: 1
max_num_seqs: 1 max_num_seqs: 1
gpu_memory_utilization: 0.2 gpu_memory_utilization: 0.3
enforce_eager: true enforce_eager: true
trust_remote_code: true trust_remote_code: true
enable_prefix_caching: false enable_prefix_caching: false
+3 -3
View File
@@ -171,7 +171,7 @@ class Attention(nn.Module):
def _init_kv_cache_quantization(self, config) -> None: def _init_kv_cache_quantization(self, config) -> None:
if config is None: if config is None:
return return
dtype = config.kv_cache_dtype dtype = config.diffusion_kv_cache_dtype
if dtype: if dtype:
if config.parallel_config.ring_degree > 1: if config.parallel_config.ring_degree > 1:
raise ValueError( raise ValueError(
@@ -190,8 +190,8 @@ class Attention(nn.Module):
) )
dtype = None dtype = None
self._kv_cache_dtype = dtype self._kv_cache_dtype = dtype
self._kv_cache_skip_steps = config.kv_cache_skip_step_indices self._kv_cache_skip_steps = config.diffusion_kv_cache_skip_step_indices
self._kv_cache_skip_layers = config.kv_cache_skip_layer_indices self._kv_cache_skip_layers = config.diffusion_kv_cache_skip_layer_indices
def _should_apply_kv_cache_quant(self) -> bool: def _should_apply_kv_cache_quant(self) -> bool:
skip_steps = self._kv_cache_skip_steps skip_steps = self._kv_cache_skip_steps
+63
View File
@@ -1383,6 +1383,68 @@ def enable_cache_for_ernie_image(pipeline: Any, cache_config: Any) -> Callable[[
return refresh_cache_context return refresh_cache_context
def enable_cache_for_helios(pipeline: Any, cache_config: Any) -> Callable[[int], None]:
"""Enable cache-dit for Helios pipeline.
Helios extends Wan2.2 with multi-term memory patches and guidance cross-attention.
Its transformer blocks have the same single-output signature as Wan2.2 blocks
(returns hidden_states only), so we use ForwardPattern.Pattern_2.
Args:
pipeline: The HeliosPipeline instance.
cache_config: DiffusionCacheConfig instance with cache configuration.
Returns:
A refresh function that can be called to update cache context with new num_inference_steps.
"""
db_cache_config = _build_db_cache_config(cache_config)
calibrator_config = None
if cache_config.enable_taylorseer:
taylorseer_order = cache_config.taylorseer_order
calibrator_config = TaylorSeerCalibratorConfig(taylorseer_order=taylorseer_order)
logger.info(f"TaylorSeer enabled with order={taylorseer_order}")
cache_dit.enable_cache(
BlockAdapter(
transformer=pipeline.transformer,
blocks=[pipeline.transformer.blocks],
forward_pattern=[ForwardPattern.Pattern_2],
params_modifiers=[
ParamsModifier(cache_config=db_cache_config, calibrator_config=calibrator_config),
],
has_separate_cfg=False,
),
cache_config=db_cache_config,
calibrator_config=calibrator_config,
)
logger.info(
f"Enabling cache-dit on Helios 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}, "
)
def refresh_cache_context(pipeline: Any, num_inference_steps: int, verbose: bool = True) -> None:
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
def enable_cache_for_hunyuan_video_15(pipeline: Any, cache_config: Any) -> Callable[[int], None]: def enable_cache_for_hunyuan_video_15(pipeline: Any, cache_config: Any) -> Callable[[int], None]:
"""Enable cache-dit for HunyuanVideo 1.5 pipeline. """Enable cache-dit for HunyuanVideo 1.5 pipeline.
@@ -1463,6 +1525,7 @@ CUSTOM_DIT_ENABLERS.update(
"ErnieImagePipeline": enable_cache_for_ernie_image, "ErnieImagePipeline": enable_cache_for_ernie_image,
"HunyuanVideo15Pipeline": enable_cache_for_hunyuan_video_15, "HunyuanVideo15Pipeline": enable_cache_for_hunyuan_video_15,
"HunyuanVideo15I2VPipeline": enable_cache_for_hunyuan_video_15, "HunyuanVideo15I2VPipeline": enable_cache_for_hunyuan_video_15,
"HeliosPipeline": enable_cache_for_helios,
} }
) )
+38 -9
View File
@@ -568,18 +568,18 @@ class OmniDiffusionConfig:
# has already resolved to vLLM's ModelOpt FP8 linear method. # has already resolved to vLLM's ModelOpt FP8 linear method.
force_cutlass_fp8: bool = False force_cutlass_fp8: bool = False
# KV cache dtype for attention. Aligned with upstream vLLM's --kv-cache-dtype. # Diffusion attention KV cache dtype (not vLLM's --kv-cache-dtype for AR models).
# None = native dtype (no quantization). # None = native dtype (no quantization).
# "fp8" = dynamic FP8 (float8_e4m3fn) quantization per forward pass. # "fp8" = dynamic FP8 (float8_e4m3fn) quantization per forward pass.
# On Hopper+FA3: native FP8 attention (memory + compute savings). # On Hopper+FA3: native FP8 attention (memory + compute savings).
# On other backends: no benefit, backends skip quantization. # On other backends: no benefit, backends skip quantization.
kv_cache_dtype: str | None = None diffusion_kv_cache_dtype: str | None = None
# Optional skip selectors for KV-cache quantization. Format: "0-9,20,25-30". # Optional skip selectors for KV-cache quantization. Format: "0-9,20,25-30".
# Listed steps/layers skip quantization; others keep quantized execution. # Listed steps/layers skip quantization; others keep quantized execution.
kv_cache_skip_steps: str | None = None diffusion_kv_cache_skip_steps: str | None = None
kv_cache_skip_layers: str | None = None diffusion_kv_cache_skip_layers: str | None = None
kv_cache_skip_step_indices: set[int] | None = None diffusion_kv_cache_skip_step_indices: set[int] | None = None
kv_cache_skip_layer_indices: set[int] | None = None diffusion_kv_cache_skip_layer_indices: set[int] | None = None
# Diffusion pipeline Profiling config # Diffusion pipeline Profiling config
enable_diffusion_pipeline_profiler: bool = False enable_diffusion_pipeline_profiler: bool = False
@@ -726,8 +726,8 @@ class OmniDiffusionConfig:
# Match vLLM's config flow: parse entrypoint shorthands before the # Match vLLM's config flow: parse entrypoint shorthands before the
# config object is built, and keep a single runtime truth source. # config object is built, and keep a single runtime truth source.
self.diffusion_attention_config = build_attention_config(self.diffusion_attention_config) self.diffusion_attention_config = build_attention_config(self.diffusion_attention_config)
self.kv_cache_skip_step_indices = parse_kv_cache_skip_selector(self.kv_cache_skip_steps) self.diffusion_kv_cache_skip_step_indices = parse_kv_cache_skip_selector(self.diffusion_kv_cache_skip_steps)
self.kv_cache_skip_layer_indices = parse_kv_cache_skip_selector(self.kv_cache_skip_layers) self.diffusion_kv_cache_skip_layer_indices = parse_kv_cache_skip_selector(self.diffusion_kv_cache_skip_layers)
if self.max_cpu_loras is None: if self.max_cpu_loras is None:
self.max_cpu_loras = 1 self.max_cpu_loras = 1
@@ -814,6 +814,15 @@ class OmniDiffusionConfig:
# (non-DiT models don't have a separate transformer folder/config) # (non-DiT models don't have a separate transformer folder/config)
if self.diffusion_load_format == "diffusers": if self.diffusion_load_format == "diffusers":
self.set_tf_model_config(TransformerConfig()) self.set_tf_model_config(TransformerConfig())
try:
diffusers_pipeline_cls_name = config_dict["_class_name"]
self.diffusers_pipeline_cls = getattr(diffusers, diffusers_pipeline_cls_name)
except (KeyError, AttributeError) as exc:
logger.warning(
"Could not find valid _class_name for diffusers pipeline in model_index.json: %s. "
"Without the underlying pipeline class the dummy run may omit required inputs.",
exc,
)
else: else:
tf_config_dict = get_hf_file_to_dict("transformer/config.json", self.model) tf_config_dict = get_hf_file_to_dict("transformer/config.json", self.model)
self.set_tf_model_config(TransformerConfig.from_dict(tf_config_dict)) self.set_tf_model_config(TransformerConfig.from_dict(tf_config_dict))
@@ -824,7 +833,13 @@ class OmniDiffusionConfig:
# (non-DiT models don't have a separate transformer folder/config) # (non-DiT models don't have a separate transformer folder/config)
if self.diffusion_load_format == "diffusers": if self.diffusion_load_format == "diffusers":
self.set_tf_model_config(TransformerConfig()) self.set_tf_model_config(TransformerConfig())
self.update_multimodal_support() logger.warning(
"Could not find valid model_index.json per diffusers format. "
"This model is likely unsupported by the diffusers backend. "
"Also, without knowing the underlying diffusers pipeline class from model_index.json, "
"the dummy run will input only text prompt, which may cause errors for pipelines "
"that require additional inputs."
)
else: else:
cfg = get_hf_file_to_dict("config.json", self.model) cfg = get_hf_file_to_dict("config.json", self.model)
if cfg is None: if cfg is None:
@@ -874,6 +889,20 @@ class OmniDiffusionConfig:
else: else:
kwargs.pop("quantization", None) kwargs.pop("quantization", None)
# Renamed from kv_cache_* to avoid clashing with vLLM's --kv-cache-dtype.
if kwargs.get("diffusion_kv_cache_dtype") is None and "kv_cache_dtype" in kwargs:
kwargs["diffusion_kv_cache_dtype"] = kwargs.pop("kv_cache_dtype")
else:
kwargs.pop("kv_cache_dtype", None)
if kwargs.get("diffusion_kv_cache_skip_steps") is None and "kv_cache_skip_steps" in kwargs:
kwargs["diffusion_kv_cache_skip_steps"] = kwargs.pop("kv_cache_skip_steps")
else:
kwargs.pop("kv_cache_skip_steps", None)
if kwargs.get("diffusion_kv_cache_skip_layers") is None and "kv_cache_skip_layers" in kwargs:
kwargs["diffusion_kv_cache_skip_layers"] = kwargs.pop("kv_cache_skip_layers")
else:
kwargs.pop("kv_cache_skip_layers", None)
# Handle "diffusion_attention_backend" shorthand: merge into # Handle "diffusion_attention_backend" shorthand: merge into
# diffusion_attention_config before field filtering. # diffusion_attention_config before field filtering.
diffusion_attn_backend = kwargs.pop("diffusion_attention_backend", None) diffusion_attn_backend = kwargs.pop("diffusion_attention_backend", None)
+9
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from vllm.utils.import_utils import resolve_obj_by_qualname from vllm.utils.import_utils import resolve_obj_by_qualname
@@ -101,6 +102,14 @@ class DiffusionExecutor(ABC):
"""Check if the executor and workers are healthy.""" """Check if the executor and workers are healthy."""
pass pass
def register_failure_callback(self, callback: Callable[[], None]) -> None:
"""Register a callback invoked when the executor fatally fails.
Executors without a background failure monitor can keep the default
no-op implementation.
"""
return None
@abstractmethod @abstractmethod
def shutdown(self) -> None: def shutdown(self) -> None:
"""Shutdown the executor and release resources.""" """Shutdown the executor and release resources."""
@@ -64,8 +64,11 @@ class InlineStageDiffusionClient(StageClientBase):
self._output_queue: asyncio.Queue[OmniRequestOutput] = asyncio.Queue() self._output_queue: asyncio.Queue[OmniRequestOutput] = asyncio.Queue()
self._tasks: dict[str, asyncio.Task] = {} self._tasks: dict[str, asyncio.Task] = {}
self._engine_dead = False
self._shutting_down = False self._shutting_down = False
self._engine.executor.register_failure_callback(self._mark_engine_dead)
logger.info( logger.info(
"[InlineStageDiffusionClient] stage-%s [rep-%s] initialized inline (batch_size=%d)", "[InlineStageDiffusionClient] stage-%s [rep-%s] initialized inline (batch_size=%d)",
self.stage_id, self.stage_id,
@@ -77,6 +80,16 @@ class InlineStageDiffusionClient(StageClientBase):
"""Load model metadata from HuggingFace and populate od_config fields.""" """Load model metadata from HuggingFace and populate od_config fields."""
self.od_config.enrich_config() self.od_config.enrich_config()
def _mark_engine_dead(self) -> None:
if self._engine_dead:
return
self._engine_dead = True
logger.error(
"[InlineStageDiffusionClient] stage-%s [rep-%s] diffusion executor died unexpectedly.",
self.stage_id,
self.replica_id,
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Request processing # Request processing
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -249,6 +262,8 @@ class InlineStageDiffusionClient(StageClientBase):
try: try:
return self._output_queue.get_nowait() return self._output_queue.get_nowait()
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
if self._engine_dead:
raise EngineDeadError(f"Stage-{self.stage_id} inline diffusion engine is dead")
return None return None
async def abort_requests_async(self, request_ids: list[str]) -> None: async def abort_requests_async(self, request_ids: list[str]) -> None:
@@ -351,7 +366,11 @@ class InlineStageDiffusionClient(StageClientBase):
"""Check if the inline diffusion engine and its workers are healthy.""" """Check if the inline diffusion engine and its workers are healthy."""
if self._shutting_down: if self._shutting_down:
raise EngineDeadError("InlineStageDiffusionClient is shutting down") raise EngineDeadError("InlineStageDiffusionClient is shutting down")
self._engine.executor.check_health() try:
self._engine.executor.check_health()
except EngineDeadError:
self._mark_engine_dead()
raise
def shutdown(self) -> None: def shutdown(self) -> None:
self._shutting_down = True self._shutting_down = True
+6
View File
@@ -13,6 +13,8 @@ class DiffusionModelMetadata:
QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES = 4 QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES = 4
# Upstream HunyuanImage-3.0 "Multi-Image Fusion" caps reference images at 3.
HUNYUAN_IMAGE3_MAX_INPUT_IMAGES = 3
_DIFFUSION_MODEL_METADATA: dict[str, DiffusionModelMetadata] = { _DIFFUSION_MODEL_METADATA: dict[str, DiffusionModelMetadata] = {
@@ -20,6 +22,10 @@ _DIFFUSION_MODEL_METADATA: dict[str, DiffusionModelMetadata] = {
supports_multimodal_inputs=True, supports_multimodal_inputs=True,
max_multimodal_image_inputs=QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES, max_multimodal_image_inputs=QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES,
), ),
"HunyuanImage3Pipeline": DiffusionModelMetadata(
supports_multimodal_inputs=True,
max_multimodal_image_inputs=HUNYUAN_IMAGE3_MAX_INPUT_IMAGES,
),
} }
@@ -471,8 +471,21 @@ class Resolution:
return f"{self.h}x{self.w}" return f"{self.h}x{self.w}"
# Baked-in extras matching the official model's
# `HunyuanImage3ImageProcessor.vae_reso_group` (image_processor.py:147-152).
# These four aspect buckets sit at ratio_token indices 33-36 in the trained
# model and the AR was trained to address them, so any deviation breaks the
# ratio-token vocab → output-shape lookup.
HUNYUAN_IMAGE3_EXTRA_RESOLUTIONS: tuple[str, ...] = (
"1024x768",
"1280x720",
"768x1024",
"720x1280",
)
class ResolutionGroup: class ResolutionGroup:
def __init__(self, base_size=None, step=None, align=1): def __init__(self, base_size=None, step=None, align=1, extra_resolutions=None):
self.align = align self.align = align
self.base_size = base_size self.base_size = base_size
assert base_size % align == 0, f"base_size {base_size} is not divisible by align {align}" assert base_size % align == 0, f"base_size {base_size} is not divisible by align {align}"
@@ -486,6 +499,11 @@ class ResolutionGroup:
self.step = step self.step = step
self.data = self._calc_by_step() self.data = self._calc_by_step()
if extra_resolutions is not None:
for er in extra_resolutions:
if not any(r.ratio == er.ratio for r in self.data):
self.data.append(er)
self.ratio = np.array([x.ratio for x in self.data]) self.ratio = np.array([x.ratio for x in self.data])
self.attr = ["" for _ in range(len(self.data))] self.attr = ["" for _ in range(len(self.data))]
self.prefix_space = 0 self.prefix_space = 0
@@ -1351,7 +1369,10 @@ class HunyuanImage3ImageProcessor:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.reso_group = ResolutionGroup(base_size=config.image_base_size) self.reso_group = ResolutionGroup(
base_size=config.image_base_size,
extra_resolutions=[Resolution(s) for s in HUNYUAN_IMAGE3_EXTRA_RESOLUTIONS],
)
self.vae_processor = transforms.Compose( self.vae_processor = transforms.Compose(
[ [
transforms.ToTensor(), transforms.ToTensor(),
@@ -283,11 +283,13 @@ def get_hunyuan_image_3_pre_process_func(od_config: OmniDiffusionConfig):
cond_image_infos = [_build_cond_joint_image(image) for image in image_list] cond_image_infos = [_build_cond_joint_image(image) for image in image_list]
prompt["additional_information"]["batch_cond_image_info"] = cond_image_infos prompt["additional_information"]["batch_cond_image_info"] = cond_image_infos
bridge_h = prompt.get("height") if isinstance(prompt, dict) else None
bridge_w = prompt.get("width") if isinstance(prompt, dict) else None
first_image_w, first_image_h = _to_pil_image(image_list[0]).size first_image_w, first_image_h = _to_pil_image(image_list[0]).size
if request.sampling_params.width is None: if request.sampling_params.width is None:
request.sampling_params.width = int(first_image_w) request.sampling_params.width = int(bridge_w or first_image_w)
if request.sampling_params.height is None: if request.sampling_params.height is None:
request.sampling_params.height = int(first_image_h) request.sampling_params.height = int(bridge_h or first_image_h)
request.prompts[i] = prompt request.prompts[i] = prompt
@@ -539,7 +541,12 @@ class HunyuanImage3Pipeline(
timestep_scatter_index: BatchRaggedTensor, timestep_scatter_index: BatchRaggedTensor,
): ):
batch_size, seq_len, n_embd = x.shape batch_size, seq_len, n_embd = x.shape
# batch_size x n x n_embd # `_encode_cond_image` returns `t` as list[Tensor] for the
# multi-image branch (outer length = batch_size, currently fixed
# at 1 by the stage runtime `max_batch_size`); flatten to a Tensor
# before reshape.
if isinstance(t, list):
t = torch.cat([ti.reshape(-1) for ti in t], dim=0)
timestep_scatter_src = self.timestep_emb(t.reshape(-1)).reshape(batch_size, -1, n_embd) timestep_scatter_src = self.timestep_emb(t.reshape(-1)).reshape(batch_size, -1, n_embd)
x.scatter_( x.scatter_(
dim=1, dim=1,
@@ -627,7 +634,11 @@ class HunyuanImage3Pipeline(
if isinstance(vae_encode_result, torch.Tensor): if isinstance(vae_encode_result, torch.Tensor):
latents = vae_encode_result latents = vae_encode_result
else: else:
latents = vae_encode_result.latent_dist.sample() # Match HunyuanImage-3's cond encode path: sample the
# posterior, but use a fixed generator so repeated online
# requests are deterministic.
_cond_vae_gen = torch.Generator(device=image.device).manual_seed(0)
latents = vae_encode_result.latent_dist.sample(_cond_vae_gen)
if hasattr(config, "shift_factor") and config.shift_factor: if hasattr(config, "shift_factor") and config.shift_factor:
latents.sub_(config.shift_factor) latents.sub_(config.shift_factor)
if hasattr(config, "scaling_factor") and config.scaling_factor: if hasattr(config, "scaling_factor") and config.scaling_factor:
@@ -1353,25 +1364,21 @@ class HunyuanImage3Pipeline(
use_system_prompt = extra_args.get("use_system_prompt") use_system_prompt = extra_args.get("use_system_prompt")
system_prompt = extra_args.get("system_prompt") system_prompt = extra_args.get("system_prompt")
# Fall back to per-prompt use_system_prompt forwarded by ar2diffusion # Fall back to per-prompt use_system_prompt forwarded by ar2diffusion
if use_system_prompt is None and req.prompts: if req.prompts:
first_prompt = req.prompts[0] first_prompt = req.prompts[0]
if isinstance(first_prompt, dict): if isinstance(first_prompt, dict):
use_system_prompt = first_prompt.get("use_system_prompt") if use_system_prompt is None:
use_system_prompt = first_prompt.get("use_system_prompt")
if system_prompt is None:
system_prompt = first_prompt.get("system_prompt")
if use_system_prompt is not None: if use_system_prompt is not None:
system_prompt = get_system_prompt(use_system_prompt, "image", system_prompt) system_prompt = get_system_prompt(use_system_prompt, "image", system_prompt)
system_prompt = system_prompt.strip() if system_prompt is not None else "" system_prompt = system_prompt.strip() if system_prompt is not None else ""
prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt
# Extract AR-generated CoT/recaption text from each prompt's extra dict. cot_text_list = [
# The AR-side stage input processor (``ar2diffusion``) already prepends (p.get("extra", {}).get("ar_generated_text") if isinstance(p, dict) else None) or None for p in req.prompts
# the trigger tag (e.g. ``<think>``) when the AR used the KV-reuse ]
# pretrain format, so ``ar_generated_text`` is a self-contained string
# and ``get_cot_sections()`` can parse the think/recaption structure
# directly.
cot_text_list = []
for p in req.prompts:
extra = p.get("extra", {}) if isinstance(p, dict) else {}
cot_text_list.append(extra.get("ar_generated_text") or None)
cot_text = ( cot_text = (
[self._normalize_cot_text(t) for t in cot_text_list] if any(t is not None for t in cot_text_list) else None [self._normalize_cot_text(t) for t in cot_text_list] if any(t is not None for t in cot_text_list) else None
) )
@@ -11,8 +11,23 @@ The DiT pipeline (`pipeline_hunyuan_image3.py`) builds prompts through
`JointImageInfo` objects produced by image preprocessing. The example `JointImageInfo` objects produced by image preprocessing. The example
flow uses an `<img>` placeholder + `multi_modal_data` instead, so it flow uses an `<img>` placeholder + `multi_modal_data` instead, so it
needs a lighter-weight builder that only requires a HF tokenizer. This needs a lighter-weight builder that only requires a HF tokenizer. This
module provides that builder; the task -> template mapping below is the module provides that builder; the (task, bot_task) -> template mapping
canonical mapping for both flows. below is the canonical mapping for both flows.
Two orthogonal axes:
* `task` selects the I/O modality combination, which only controls
whether `<img>` placeholders are emitted between `User: ` and the
user prompt: ``i2t`` / ``it2i`` produce them, ``t2t`` / ``t2i`` do
not.
* `bot_task` selects the prompting mode and drives both the system
prompt and the trigger tag appended after ``Assistant: ``. ``None``
(default) gives a plain Assistant turn under the unified prompt;
``think`` / ``recaption`` switch the trigger tag to ``<think>`` /
``<recaption>``; ``think_recaption`` swaps the system prompt for
the dedicated combined-mode template; ``vanilla`` drops the chat
structure entirely (pretrain template, ``t2i`` only).
""" """
from __future__ import annotations from __future__ import annotations
@@ -45,143 +60,234 @@ HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS: dict[str, int] = {
"<img_ratio_36>": 130106, "<img_ratio_36>": 130106,
} }
# task -> (sys_type, bot_task, trigger_tag) # bot_task -> (sys_type, trigger_tag).
# ``vanilla`` is special-cased downstream: it bypasses the chat template
# (no ``User:`` / ``Assistant:`` framing) and is only valid with
# ``task='t2i'``.
_BOT_TASK_PRESETS: dict[str | None, tuple[str, str | None]] = {
None: ("en_unified", None),
"think": ("en_unified", "<think>"),
"recaption": ("en_unified", "<recaption>"),
"think_recaption": ("en_think_recaption", "<think>"),
"vanilla": ("en_vanilla", None),
}
_TASKS: frozenset[str] = frozenset({"t2t", "i2t", "it2i", "t2i"})
class _DefaultBotTask:
pass
_DEFAULT_BOT_TASK = _DefaultBotTask()
# Legacy composite task alias -> (task, bot_task). Keep this during rebase so
# older callers and intermediate commits still resolve cleanly.
_TASK_PRESETS: dict[str, tuple[str, str | None, str | None]] = { _TASK_PRESETS: dict[str, tuple[str, str | None, str | None]] = {
"t2t": ("en_unified", None, None), "t2t": ("en_unified", None, None),
"i2t": ("en_unified", None, None), "i2t": ("en_unified", None, None),
"it2i_think": ("en_unified", "think", "<think>"), "it2i_think": ("en_unified", "think", "<think>"),
"it2i_recaption": ("en_unified", "recaption", "<recaption>"), "it2i_recaption": ("en_unified", "recaption", "<recaption>"),
"it2i_think_recaption": ("en_unified", "think_recaption", "<think>"), "it2i_think_recaption": ("en_unified", "think_recaption", "<think>"),
"t2i": ("en_unified", "image", None), "t2i": ("en_unified", None, None),
"t2i_vanilla": ("en_vanilla", "image", None), "t2i_vanilla": ("en_vanilla", "vanilla", None),
"t2i_think": ("en_unified", "think", "<think>"), "t2i_think": ("en_unified", "think", "<think>"),
"t2i_recaption": ("en_unified", "recaption", "<recaption>"), "t2i_recaption": ("en_unified", "recaption", "<recaption>"),
} }
_LEGACY_COMPOSITE_TASKS: frozenset[str] = frozenset(_TASK_PRESETS) - {"t2t", "i2t", "t2i"}
def _normalize_task_and_bot_task(
task: str,
bot_task: str | None | _DefaultBotTask,
) -> tuple[str, str | None]:
bot_task_was_omitted = bot_task is _DEFAULT_BOT_TASK
if task in _TASK_PRESETS:
_, legacy_bot_task, _ = _TASK_PRESETS[task]
base_task = task.split("_", 1)[0]
if base_task == "t2i" and task == "t2i":
base_task = "t2i"
if task in ("t2t", "i2t", "t2i"):
base_task = task
if bot_task_was_omitted:
bot_task = legacy_bot_task
elif task in _LEGACY_COMPOSITE_TASKS and bot_task is None:
# Composite task names already encode the legacy bot_task. Keep
# calls like build_prompt_tokens(task="it2i_think", bot_task=None)
# on their historical meaning; explicit None is the plain-mode
# escape hatch only for the new two-axis base tasks.
bot_task = legacy_bot_task
task = base_task
elif bot_task_was_omitted:
bot_task = "think"
return task, bot_task
def available_tasks() -> list[str]: def available_tasks() -> list[str]:
"""Sorted list of task keys accepted by `build_prompt` / `build_prompt_tokens`.""" """Sorted list of `task` values accepted by the prompt builders."""
return sorted(_TASK_PRESETS) return sorted(_TASKS)
def available_bot_tasks() -> list[str | None]:
"""Sorted list of `bot_task` values (with ``None`` first)."""
rest = sorted(k for k in _BOT_TASK_PRESETS if k is not None)
return [None, *rest]
def resolve_sys_type(bot_task: str | None) -> str:
"""Default system-prompt type for a given ``bot_task``."""
if bot_task not in _BOT_TASK_PRESETS:
raise ValueError(f"Unknown bot_task {bot_task!r}. Choose from: {available_bot_tasks()}")
return _BOT_TASK_PRESETS[bot_task][0]
def resolve_stop_token_ids( def resolve_stop_token_ids(
task: str = "it2i_think", task: str = "it2i",
bot_task: str = "think", bot_task: str | None | _DefaultBotTask = _DEFAULT_BOT_TASK,
tokenizer: Any | None = None, tokenizer: Any | None = None,
): ) -> list[int]:
"""AR stop-token ids for a given (task, bot_task) generation request.
Image-output tasks (``it2i`` / ``t2i``) stop on any ``<img_ratio_*>``
token. Upstream ``modeling_hunyuan_image_3.py::generate_image``
(line 3289-3303) sets ``final_stop_tokens`` to the full ratio token
range when ``need_ratio`` is true, then strips the trailing ratio
token before passing the cot to the image stage. AR's natural
trajectory under ``_stage_transitions`` is
``</recaption><answer><boi><img_size_base><img_ratio_X>``; stopping
AT the ratio token means KV ends exactly at the prefix DiT reuses,
and ``ar2diffusion`` can read the ratio off the last sampled token
without AR wasting decode steps on ``<|endoftext|>``.
Text-output tasks (``i2t`` / ``t2t``) stop on ``<answer>`` -- the AR
is the final stage, and the comprehension response sits inside the
``<answer>`` body so the answer-open is the natural cot/recaption
terminator.
"""
task, bot_task = _normalize_task_and_bot_task(task, bot_task)
if task not in _TASKS:
raise ValueError(f"Unknown task {task!r}. Choose from: {available_tasks()}")
if bot_task not in _BOT_TASK_PRESETS:
raise ValueError(f"Unknown bot_task {bot_task!r}. Choose from: {available_bot_tasks()}")
if task in ("it2i", "t2i"):
# Main ratio range: <img_ratio_0> .. <img_ratio_32>.
start = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_0>"]
end = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_32>"]
stops = list(range(start, end + 1))
# Other slices (upstream tokenizer ``ratio_token_other_slices``):
# <img_ratio_33> .. <img_ratio_36>.
other_start = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_33>"]
other_end = HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<img_ratio_36>"]
stops.extend(range(other_start, other_end + 1))
return stops
return [HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<answer>"]] return [HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<answer>"]]
# Upstream "Multi-Image Fusion" caps reference images at 3 per request.
MAX_IMAGES_PER_REQUEST = 3
def _validate_num_images(num_images: int) -> None:
if not (1 <= num_images <= MAX_IMAGES_PER_REQUEST):
raise ValueError(f"num_images must be in [1, {MAX_IMAGES_PER_REQUEST}], got {num_images}")
def _resolve_preset(task: str, bot_task: str | None) -> tuple[str, str | None]:
"""Validate (task, bot_task) and return ``(sys_type, trigger_tag)``."""
task, bot_task = _normalize_task_and_bot_task(task, bot_task)
if task not in _TASKS:
raise ValueError(f"Unknown task {task!r}. Choose from: {available_tasks()}")
if bot_task not in _BOT_TASK_PRESETS:
raise ValueError(f"Unknown bot_task {bot_task!r}. Choose from: {available_bot_tasks()}")
if bot_task == "vanilla" and task != "t2i":
raise ValueError(f"bot_task='vanilla' is only valid with task='t2i' (pretrain template); got task={task!r}")
return _BOT_TASK_PRESETS[bot_task]
def build_prompt( def build_prompt(
user_prompt: str, user_prompt: str,
task: str = "it2i_think", task: str = "it2i",
bot_task: str | None | _DefaultBotTask = _DEFAULT_BOT_TASK,
sys_type: str | None = None, sys_type: str | None = None,
custom_system_prompt: str | None = None, custom_system_prompt: str | None = None,
num_images: int = 1,
) -> str: ) -> str:
"""Build a HunyuanImage-3.0 prompt as a string (legacy/compat path). """Build a HunyuanImage-3.0 prompt as a string (legacy/compat path)."""
task, bot_task = _normalize_task_and_bot_task(task, bot_task)
NOTE: when this string is passed to the engine, the engine's tokenizer preset_sys_type, trigger_tag = _resolve_preset(task, bot_task)
will run a single BPE pass over the whole string, which can merge
tokens across segment boundaries (e.g. `。\\n\\n` -> id 3490). For
inputs that need to match HF baseline byte-for-byte, use
`build_prompt_tokens` instead and feed the result via prompt_token_ids.
"""
if task not in _TASK_PRESETS:
raise ValueError(f"Unknown task {task!r}. Choose from: {available_tasks()}")
preset_sys_type, preset_bot_task, trigger_tag = _TASK_PRESETS[task]
effective_sys_type = sys_type or preset_sys_type effective_sys_type = sys_type or preset_sys_type
system_prompt = get_system_prompt(effective_sys_type, preset_bot_task, custom_system_prompt) system_prompt = get_system_prompt(effective_sys_type, bot_task, custom_system_prompt)
sys_text = system_prompt.strip() if system_prompt else "" sys_text = system_prompt or ""
has_image_input = task.startswith("i2t") or task.startswith("it2i") has_image_input = task in ("i2t", "it2i")
if has_image_input:
_validate_num_images(num_images)
# t2i_vanilla: pretrain mode for direct text->image generation. The if bot_task == "vanilla":
# vanilla system prompt drives the model with no chat structure.
if task == "t2i_vanilla":
parts = ["<|startoftext|>"] parts = ["<|startoftext|>"]
if sys_text: if sys_text:
parts.append(sys_text) parts.append(sys_text)
parts.append(user_prompt) parts.append(user_prompt)
return "".join(parts) return "".join(parts)
# All other tasks (t2t / i2t / t2i_think / t2i_recaption /
# it2i_think / it2i_recaption) use HunyuanImage3 Instruct chat template:
# <|startoftext|>{system?}\n\nUser: {<img>?}{user_prompt}\n\nAssistant: {trigger?}
# generation_config.json declares sequence_template="instruct", so the
# AR prefill MUST use this template -- verified to match HF's
# apply_chat_template output token-for-token (modulo BPE boundary merges).
# The trigger_tag (e.g. <think>) MUST come AFTER the `Assistant: ` prefix:
# if it goes BEFORE user_prompt (the old pretrain layout) the model puts
# the user's instructions inside the "thinking section" and collapses
# into repetition garbage under greedy decoding.
parts = ["<|startoftext|>"] parts = ["<|startoftext|>"]
if sys_text: if sys_text:
parts.append(f"{sys_text}\n\n") parts.append(f"{sys_text}\n\n")
parts.append("User: ") parts.append("User: ")
if has_image_input: if has_image_input:
parts.append("<img>") parts.extend(["<img>"] * num_images)
parts.append(user_prompt) parts.append(user_prompt)
parts.append("\n\nAssistant: ") parts.append("\n\nAssistant: ")
if trigger_tag: if trigger_tag:
parts.append(trigger_tag) parts.append(trigger_tag)
return "".join(parts) return "".join(parts)
@dataclass @dataclass
class PromptTokensResult: class PromptTokensResult:
token_ids: list[int] # The tokenized prompt token_ids: list[int]
system_prompt_type: str # The effective system prompt type used system_prompt_type: str
def build_prompt_tokens( def build_prompt_tokens(
user_prompt: str, user_prompt: str,
tokenizer, tokenizer,
task: str = "it2i_think", task: str = "it2i",
bot_task: str | None | _DefaultBotTask = _DEFAULT_BOT_TASK,
sys_type: str | None = None, sys_type: str | None = None,
custom_system_prompt: str | None = None, custom_system_prompt: str | None = None,
num_images: int = 1,
) -> PromptTokensResult: ) -> PromptTokensResult:
"""Segment-by-segment tokenization that matches HF apply_chat_template. """Segment-by-segment tokenization that matches HF apply_chat_template."""
task, bot_task = _normalize_task_and_bot_task(task, bot_task)
Calling tokenizer.encode(build_prompt(...)) on the full string lets BPE preset_sys_type, trigger_tag = _resolve_preset(task, bot_task)
merge tokens across segment boundaries (e.g. user_prompt ends with `。`
and the next segment is `\\n\\n` -> they merge into a single token id
3490 instead of HF's [1811, 271]). HF's apply_chat_template tokenizes
each segment independently and concatenates token_ids, so no cross-
boundary merge happens. We replicate that here and feed the result to
Omni via OmniTokensPrompt (prompt_token_ids).
Returns:
PromptTokensResult
"""
if task not in _TASK_PRESETS:
raise ValueError(f"Unknown task {task!r}. Choose from: {available_tasks()}")
preset_sys_type, preset_bot_task, trigger_tag = _TASK_PRESETS[task]
effective_sys_type = sys_type or preset_sys_type effective_sys_type = sys_type or preset_sys_type
bos_id = tokenizer.convert_tokens_to_ids("<|startoftext|>") bos_id = tokenizer.convert_tokens_to_ids("<|startoftext|>")
img_id = tokenizer.convert_tokens_to_ids("<img>") img_id = tokenizer.convert_tokens_to_ids("<img>")
trig_id = tokenizer.convert_tokens_to_ids(trigger_tag) if trigger_tag else None trig_id = tokenizer.convert_tokens_to_ids(trigger_tag) if trigger_tag else None
has_image_input = task.startswith("i2t") or task.startswith("it2i") has_image_input = task in ("i2t", "it2i")
if has_image_input:
_validate_num_images(num_images)
# t2i_vanilla uses pretrain template with no chat structure; the vanilla if bot_task == "vanilla":
# system prompt drives the model directly. No segment boundaries to s = build_prompt(
# protect, fall back to whole-string encode. user_prompt,
if task == "t2i_vanilla": task=task,
s = build_prompt(user_prompt, task, sys_type, custom_system_prompt) bot_task=bot_task,
sys_type=sys_type,
custom_system_prompt=custom_system_prompt,
)
token_ids = tokenizer.encode(s, add_special_tokens=False) token_ids = tokenizer.encode(s, add_special_tokens=False)
return PromptTokensResult( return PromptTokensResult(
token_ids=token_ids, token_ids=token_ids,
system_prompt_type=effective_sys_type, system_prompt_type=effective_sys_type,
) )
system_prompt = get_system_prompt(effective_sys_type, preset_bot_task, custom_system_prompt) system_prompt = get_system_prompt(effective_sys_type, bot_task, custom_system_prompt)
# Do NOT strip -- HF apply_chat_template keeps the system prompt's
# natural trailing newline; stripping it would shift one token id.
sys_text = system_prompt or "" sys_text = system_prompt or ""
ids: list[int] = [bos_id] ids: list[int] = [bos_id]
@@ -190,7 +296,7 @@ def build_prompt_tokens(
ids += tokenizer.encode("\n\n", add_special_tokens=False) ids += tokenizer.encode("\n\n", add_special_tokens=False)
ids += tokenizer.encode("User: ", add_special_tokens=False) ids += tokenizer.encode("User: ", add_special_tokens=False)
if has_image_input: if has_image_input:
ids += [img_id] ids += [img_id] * num_images
ids += tokenizer.encode(user_prompt, add_special_tokens=False) ids += tokenizer.encode(user_prompt, add_special_tokens=False)
ids += tokenizer.encode("\n\nAssistant: ", add_special_tokens=False) ids += tokenizer.encode("\n\nAssistant: ", add_special_tokens=False)
if trig_id is not None: if trig_id is not None:
@@ -202,4 +308,14 @@ def build_prompt_tokens(
) )
__all__ = ["build_prompt", "build_prompt_tokens", "resolve_stop_token_ids", _TASK_PRESETS] __all__ = [
"HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS",
"MAX_IMAGES_PER_REQUEST",
"_TASK_PRESETS",
"available_bot_tasks",
"available_tasks",
"build_prompt",
"build_prompt_tokens",
"resolve_stop_token_ids",
"resolve_sys_type",
]
@@ -26,10 +26,10 @@ from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.utils import get_local_device from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.models.interface import SupportAudioOutput from vllm_omni.diffusion.models.interface import SupportAudioOutput
from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.model_executor.models.omnivoice.config import OmniVoiceConfig
from vllm_omni.model_executor.models.omnivoice.duration import RuleDurationEstimator from vllm_omni.model_executor.models.omnivoice.duration import RuleDurationEstimator
from vllm_omni.model_executor.models.omnivoice.omnivoice_decoder import OmniVoiceDecoder from vllm_omni.model_executor.models.omnivoice.omnivoice_decoder import OmniVoiceDecoder
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import OmniVoiceGenerator from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import OmniVoiceGenerator
from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig
from vllm_omni.utils.speaker_cache import get_speaker_cache from vllm_omni.utils.speaker_cache import get_speaker_cache
try: try:
@@ -391,6 +391,8 @@ class StageDiffusionClient(StageClientBase):
return self._output_queue.get_nowait() return self._output_queue.get_nowait()
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
if self._engine_dead: if self._engine_dead:
if self._shutting_down:
return None
raise EngineDeadError() raise EngineDeadError()
if not self._shutting_down and self._owns_process and self._proc is not None and not self._proc.is_alive(): 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 self._engine_dead = True
@@ -97,7 +97,7 @@ def _make_diffusion_vllm_model_config(od_config: OmniDiffusionConfig) -> _Diffus
@contextmanager @contextmanager
def _force_cutlass_fp8_linear_kernel(quant_config: object | None) -> Iterator[None]: def _force_cutlass_fp8_linear_kernel(quant_config: object | None) -> Iterator[None]:
from vllm.model_executor.layers.quantization import modelopt as vllm_modelopt import vllm.model_executor.layers.quantization.modelopt as vllm_modelopt
linear_method_cls = getattr(quant_config, "LinearMethodCls", None) linear_method_cls = getattr(quant_config, "LinearMethodCls", None)
if linear_method_cls in { if linear_method_cls in {
@@ -796,6 +796,14 @@ class WorkerProc:
"""Worker initialization and execution loops.""" """Worker initialization and execution loops."""
from vllm_omni.plugins import load_omni_general_plugins from vllm_omni.plugins import load_omni_general_plugins
# Set process title for visibility in nvidia-smi / htop (optional, non-fatal)
try:
import setproctitle
setproctitle.setproctitle(f"vLLM-Omni::StageDiffusionProc-{rank}")
except ImportError:
pass # setproctitle not installed, skip process title setting
load_omni_general_plugins() load_omni_general_plugins()
worker_proc = WorkerProc( worker_proc = WorkerProc(
od_config, od_config,
@@ -1247,6 +1247,9 @@ class OmniKVTransferManager:
get_cfg_group, get_cfg_group,
get_classifier_free_guidance_rank, get_classifier_free_guidance_rank,
get_classifier_free_guidance_world_size, get_classifier_free_guidance_world_size,
get_sequence_parallel_rank,
get_sequence_parallel_world_size,
get_sp_group,
get_world_group, get_world_group,
) )
@@ -1260,6 +1263,9 @@ class OmniKVTransferManager:
cfg_size = 1 cfg_size = 1
cfg_rank = 0 cfg_rank = 0
cfg_group = None cfg_group = None
sp_size = 1
sp_rank = 0
sp_group = None
try: try:
cfg_size = get_classifier_free_guidance_world_size() cfg_size = get_classifier_free_guidance_world_size()
cfg_rank = get_classifier_free_guidance_rank() cfg_rank = get_classifier_free_guidance_rank()
@@ -1269,7 +1275,16 @@ class OmniKVTransferManager:
cfg_rank = 0 cfg_rank = 0
cfg_group = None cfg_group = None
if tp_active and cfg_size <= 1: try:
sp_size = get_sequence_parallel_world_size()
sp_rank = get_sequence_parallel_rank()
sp_group = get_sp_group()
except Exception:
sp_size = 1
sp_rank = 0
sp_group = None
if tp_active and (cfg_size <= 1 and sp_size <= 1):
logger.info( logger.info(
"Rank-aware KV receive: rank %s independently receiving (from_tp=%s, to_tp=%s)", "Rank-aware KV receive: rank %s independently receiving (from_tp=%s, to_tp=%s)",
topo.local_rank, topo.local_rank,
@@ -1278,16 +1293,45 @@ class OmniKVTransferManager:
) )
return self.receive_multi_kv_cache(req, cfg_kv_collect_func, target_device) 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: if tp_active and (cfg_size > 1 or sp_size > 1):
kv_payload: dict[str, object] | None = None kv_payload: dict[str, object] | None = None
if cfg_rank == 0: is_owner = cfg_rank == 0 and sp_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 # step1: only owner need to receive
kv_payload = rank_payloads[0] if is_owner:
for dst_rank in range(1, cfg_size): received = self.receive_multi_kv_cache(
cfg_group.send_object(rank_payloads[dst_rank], dst_rank) req,
else: cfg_kv_collect_func,
torch.device("cpu"),
)
if received:
if cfg_size > 1: # build cfg-local payloads
cfg_rank_payloads = self._build_cfg_rank_local_payloads(
req,
cfg_size,
)
kv_payload = cfg_rank_payloads[0]
# send to other cfg
for dst_cfg_rank in range(1, cfg_size):
cfg_group.send_object(
cfg_rank_payloads[dst_cfg_rank],
dst_cfg_rank,
)
elif sp_size > 1:
kv_payload = self._collect_request_kv_payload(req)
else:
# Owner receive failed: send None sentinel to CFG followers to avoid deadlock
if cfg_size > 1:
for dst_cfg_rank in range(1, cfg_size):
cfg_group.send_object(None, dst_cfg_rank)
elif sp_rank == 0 and cfg_size > 1:
kv_payload = cfg_group.recv_object(0) kv_payload = cfg_group.recv_object(0)
# sp broadcast
if sp_size > 1 and sp_group is not None:
kv_payload = sp_group.broadcast_object(
kv_payload,
src=0,
)
if not kv_payload: if not kv_payload:
return False return False
@@ -62,6 +62,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self.waiting_for_chunk_running_requests: deque[Any] = deque() self.waiting_for_chunk_running_requests: deque[Any] = deque()
self.requests_with_ready_chunks = set() self.requests_with_ready_chunks = set()
self.requests_origin_status = {} self.requests_origin_status = {}
self.requests_num_chunks_sent: dict[str, int] = defaultdict(int)
@classmethod @classmethod
def create_connector(cls, model_config: Any): def create_connector(cls, model_config: Any):
@@ -117,6 +118,17 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
pooling_output: Partial pooling output dictionary pooling_output: Partial pooling output dictionary
request: Request object request: Request object
""" """
# If the request is preempted, skip the already saved chunks.
if request.num_computed_tokens < self.requests_num_chunks_sent.get(request.external_req_id, 0):
logger.warning(
f"Enqueue save_async for request {request.external_req_id}, "
f"request.num_computed_tokens={request.num_computed_tokens}, "
f"previous_chunks_sent={self.requests_num_chunks_sent.get(request.external_req_id, 0)}"
)
return
self.requests_num_chunks_sent[request.external_req_id] = request.num_computed_tokens
task = { task = {
"pooling_output": pooling_output, "pooling_output": pooling_output,
"request": request, "request": request,
@@ -155,8 +167,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
meta = payload_data.get("meta", {}) meta = payload_data.get("meta", {})
if self.model_mode == "ar": if self.model_mode == "ar":
merged_payload = self._update_request_payload(external_req_id, payload_data) request.additional_information = payload_data
request.additional_information = merged_payload
if meta.get("finished"): if meta.get("finished"):
self.finished_requests.add(req_id) self.finished_requests.add(req_id)
else: else:
@@ -198,42 +209,6 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
return False return False
def _update_request_payload(self, req_id: str, payload_data: dict[str, Any]) -> dict[str, Any]:
"""Update the stored payload for *req_id* with the latest chunk."""
if req_id not in self.request_payload:
self.request_payload[req_id] = payload_data
return payload_data
origin = self.request_payload[req_id]
raw_ok = payload_data.get("meta", {}).pop("override_keys", [])
override_keys = {tuple(k) if isinstance(k, list) else k for k in raw_ok}
for key, value in payload_data.items():
if isinstance(value, dict):
origin_sub = origin.get(key)
if not isinstance(origin_sub, dict):
continue
for qual, qval in value.items():
if key == "meta" and qual == "finished":
continue
if (key, qual) in override_keys:
continue
osv = origin_sub.get(qual)
if isinstance(qval, torch.Tensor) and isinstance(osv, torch.Tensor):
value[qual] = torch.cat([osv, qval], dim=0)
elif isinstance(qval, list) and isinstance(osv, list):
value[qual] = osv + qval
else:
if key in override_keys:
continue
ov = origin.get(key)
if isinstance(value, torch.Tensor) and isinstance(ov, torch.Tensor):
payload_data[key] = torch.cat([ov, value], dim=0)
elif isinstance(value, list) and isinstance(ov, list):
payload_data[key] = ov + value
self.request_payload[req_id] = payload_data
return payload_data
def _send_single_request(self, task: dict): def _send_single_request(self, task: dict):
raw_po = task["pooling_output"] raw_po = task["pooling_output"]
pooling_output = unflatten_payload(raw_po) if isinstance(raw_po, dict) else raw_po pooling_output = unflatten_payload(raw_po) if isinstance(raw_po, dict) else raw_po
@@ -290,6 +265,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
if is_finished: if is_finished:
self.code_prompt_token_ids.pop(external_req_id, None) self.code_prompt_token_ids.pop(external_req_id, None)
self.requests_num_chunks_sent.pop(external_req_id, None)
cached_ic = getattr(self, "_cached_ic", None) cached_ic = getattr(self, "_cached_ic", None)
if cached_ic is not None: if cached_ic is not None:
cached_ic.pop(external_req_id, None) cached_ic.pop(external_req_id, None)
@@ -327,6 +303,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self.put_req_chunk.pop(external_req_id, None) self.put_req_chunk.pop(external_req_id, None)
self.request_payload.pop(external_req_id, None) self.request_payload.pop(external_req_id, None)
self.code_prompt_token_ids.pop(external_req_id, None) self.code_prompt_token_ids.pop(external_req_id, None)
self.requests_num_chunks_sent.pop(external_req_id, None)
cached_ic = getattr(self, "_cached_ic", None) cached_ic = getattr(self, "_cached_ic", None)
if cached_ic is not None: if cached_ic is not None:
@@ -399,6 +376,11 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
Add additional info for cached requests and Add additional info for cached requests and
clean up ready chunks from scheduler output. clean up ready chunks from scheduler output.
""" """
stage_id = self.connector.stage_id
if stage_id == 0:
return
if requests is not None: if requests is not None:
self.attach_cached_additional_information(scheduler_output, requests) self.attach_cached_additional_information(scheduler_output, requests)
self._clear_chunk_ready(scheduler_output) self._clear_chunk_ready(scheduler_output)
@@ -414,6 +396,8 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
request = requests.get(req_id) if req_id else None request = requests.get(req_id) if req_id else None
additional_info = getattr(request, "additional_information", None) if request else None additional_info = getattr(request, "additional_information", None) if request else None
cached_reqs.additional_information[req_id] = additional_info cached_reqs.additional_information[req_id] = additional_info
if request and additional_info:
request.additional_information = None
def _process_chunk_queue( def _process_chunk_queue(
self, self,
@@ -5,6 +5,7 @@
import json import json
import sys import sys
import warnings
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -403,7 +404,16 @@ def build_stage_connectors(
connectors_config: dict[str, Any], connectors_config: dict[str, Any],
purpose: str = "request_forwarding", purpose: str = "request_forwarding",
) -> dict[tuple[str, str], Any] | None: ) -> dict[tuple[str, str], Any] | None:
"""Instantiate OmniConnectors for a stage based on config.""" """Instantiate OmniConnectors for a stage based on config.
Deprecated: prefer ``get_stage_connector_config`` plus the unified
connector factory. Kept as a thin shim so legacy callers keep working.
"""
warnings.warn(
"build_stage_connectors is deprecated; use get_stage_connector_config and OmniConnectorFactory instead.",
DeprecationWarning,
stacklevel=2,
)
if not connectors_config: if not connectors_config:
return {} return {}
+3
View File
@@ -94,6 +94,7 @@ class OmniEngineCoreRequest(EngineCoreRequest):
return cls( return cls(
request_id=request.request_id, request_id=request.request_id,
prompt_token_ids=request.prompt_token_ids, prompt_token_ids=request.prompt_token_ids,
prompt_is_token_ids=request.prompt_is_token_ids,
mm_features=request.mm_features, mm_features=request.mm_features,
sampling_params=request.sampling_params, sampling_params=request.sampling_params,
pooling_params=request.pooling_params, pooling_params=request.pooling_params,
@@ -109,6 +110,8 @@ class OmniEngineCoreRequest(EngineCoreRequest):
resumable=request.resumable, resumable=request.resumable,
external_req_id=request.external_req_id, external_req_id=request.external_req_id,
reasoning_ended=request.reasoning_ended, reasoning_ended=request.reasoning_ended,
reasoning_parser_kwargs=request.reasoning_parser_kwargs,
abort_immediately=request.abort_immediately,
additional_information=additional_information, additional_information=additional_information,
) )
-8
View File
@@ -35,13 +35,9 @@ def _register_omni_hf_configs() -> None:
try: try:
from transformers import AutoConfig from transformers import AutoConfig
from vllm_omni.model_executor.models.cosyvoice3.config import CosyVoice3Config
from vllm_omni.model_executor.models.omnivoice.config import OmniVoiceConfig
from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import ( from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import (
Qwen3TTSConfig, Qwen3TTSConfig,
) )
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 except Exception as exc: # pragma: no cover - best-effort optional registration
logger.warning("Skipping omni HF config registration due to import error: %s", exc) logger.warning("Skipping omni HF config registration due to import error: %s", exc)
return return
@@ -56,10 +52,6 @@ def _register_omni_hf_configs() -> None:
for model_type, config_cls in [ for model_type, config_cls in [
("qwen3_tts", Qwen3TTSConfig), ("qwen3_tts", Qwen3TTSConfig),
("cosyvoice3", CosyVoice3Config),
("omnivoice", OmniVoiceConfig),
("voxcpm", VoxCPMConfig),
("voxcpm2", VoxCPM2Config),
]: ]:
try: try:
AutoConfig.register(model_type, config_cls) AutoConfig.register(model_type, config_cls)
+196 -30
View File
@@ -1199,6 +1199,158 @@ class AsyncOmniEngine:
# ---- request helpers ---- # ---- request helpers ----
@staticmethod
def _iter_multimodal_items(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def _ensure_stage_replica_mm_uuids(
self,
prompt: Any,
*,
stage_id: int,
replica_id: int,
) -> None:
"""Make multimodal processor-cache keys local to a stage replica.
vLLM's frontend multimodal sender cache is process-global, while each
vllm-omni stage replica owns a separate EngineCore receiver cache. If
two requests with the same image are routed to different stage-0
replicas, a plain content hash can make the sender omit the tensor for
a replica that has never received it. Prefixing user/content UUIDs with
the selected replica keeps cache reuse within the receiver that owns it.
"""
if not isinstance(prompt, dict):
return
mm_data = prompt.get("multi_modal_data")
if not isinstance(mm_data, dict) or not mm_data:
return
from vllm.multimodal.hasher import MultiModalHasher
existing_uuids = prompt.get("multi_modal_uuids")
if not isinstance(existing_uuids, dict):
existing_uuids = {}
model_id = str(getattr(self, "model", ""))
scoped_uuids: dict[str, list[str | None]] = dict(existing_uuids)
for modality, raw_items in mm_data.items():
items = self._iter_multimodal_items(raw_items)
if not items:
continue
modality_existing = existing_uuids.get(modality)
if not isinstance(modality_existing, list):
modality_existing = [modality_existing] if modality_existing is not None else []
modality_uuids: list[str | None] = []
for idx, item in enumerate(items):
user_uuid = modality_existing[idx] if idx < len(modality_existing) else None
if user_uuid is not None:
base_uuid = str(user_uuid)
elif item is None:
base_uuid = None
else:
base_uuid = MultiModalHasher.hash_kwargs(
model_id=model_id,
**{modality: item},
)
if base_uuid is None:
modality_uuids.append(None)
else:
modality_uuids.append(f"stage{stage_id}:rep{replica_id}:{base_uuid}")
scoped_uuids[modality] = modality_uuids
if scoped_uuids:
prompt["multi_modal_uuids"] = scoped_uuids
@staticmethod
def _stage_pool_replica_count(stage_pool: Any) -> int:
try:
live_num_replicas = getattr(stage_pool, "live_num_replicas", None)
if live_num_replicas is not None:
return int(live_num_replicas)
except Exception:
pass
try:
live_replica_ids = getattr(stage_pool, "live_replica_ids", None)
if callable(live_replica_ids):
return len(live_replica_ids())
except Exception:
pass
try:
clients = getattr(stage_pool, "clients", None)
if clients is not None:
return sum(1 for client in clients if client is not None)
except Exception:
pass
return int(getattr(stage_pool, "num_replicas", 1) or 1)
@staticmethod
def _stage_pool_is_distributed(stage_pool: Any) -> bool:
try:
is_distributed = getattr(stage_pool, "is_distributed", None)
if is_distributed is not None:
return bool(is_distributed() if callable(is_distributed) else is_distributed)
except Exception:
pass
return getattr(stage_pool, "_hub", None) is not None
def _scope_stage0_multimodal_cache_to_replica(
self,
request_id: str,
prompt: Any,
) -> int | None:
stage_pools = getattr(self, "stage_pools", None)
if isinstance(prompt, EngineCoreRequest) or not stage_pools:
return None
stage0_pool = stage_pools[0]
# TODO: Currently only supports the ar -> dit process.
# Future scenarios (e.g., dit -> ar) need to be added, which will require modifications here.
if stage0_pool.stage_type == "diffusion" or self._stage_pool_replica_count(stage0_pool) <= 1:
return None
# This synchronous request path can safely pre-bind local in-process
# replicas. Distributed head mode still uses the async picker inside
# StagePool.submit_initial().
if self._stage_pool_is_distributed(stage0_pool):
logger.debug(
"[AsyncOmniEngine] Skipping stage-0 multimodal cache scoping for distributed routing req=%s",
request_id,
)
return None
prompts = prompt if isinstance(prompt, list) else [prompt]
if not any(isinstance(p, dict) and p.get("multi_modal_data") for p in prompts):
return None
replica_id = stage0_pool.select_replica_id(request_id)
for p in prompts:
self._ensure_stage_replica_mm_uuids(
p,
stage_id=0,
replica_id=replica_id,
)
logger.debug(
"[AsyncOmniEngine] Scoped multimodal cache keys to stage-0 replica-%s for req=%s",
replica_id,
request_id,
)
return replica_id
def _build_add_request_message( def _build_add_request_message(
self, self,
request_id: str, request_id: str,
@@ -1232,6 +1384,7 @@ class AsyncOmniEngine:
# Keep the original prompt for downstream stages (they need the raw # Keep the original prompt for downstream stages (they need the raw
# dict, e.g. for multi_modal_data). # dict, e.g. for multi_modal_data).
original_prompt = prompt original_prompt = prompt
preselected_stage0_replica: int | None = None
stage_type = self.stage_metadata[0].stage_type stage_type = self.stage_metadata[0].stage_type
output_prompt_text: Any = None output_prompt_text: Any = None
@@ -1244,21 +1397,31 @@ class AsyncOmniEngine:
for item in prompt: for item in prompt:
_inject_global_id(item, request_id) _inject_global_id(item, request_id)
preselected_stage0_replica = self._scope_stage0_multimodal_cache_to_replica(
request_id,
prompt,
)
# Full input processing (tokenization, multimodal, etc.) # Full input processing (tokenization, multimodal, etc.)
_t_preprocess = time.perf_counter() _t_preprocess = time.perf_counter()
request = self.input_processor.process_inputs( try:
request_id=request_id, request = self.input_processor.process_inputs(
prompt=prompt, request_id=request_id,
params=params, prompt=prompt,
supported_tasks=self.supported_tasks, params=params,
arrival_time=arrival_time, supported_tasks=self.supported_tasks,
lora_request=lora_request, arrival_time=arrival_time,
tokenization_kwargs=tokenization_kwargs, lora_request=lora_request,
trace_headers=trace_headers, tokenization_kwargs=tokenization_kwargs,
priority=priority, trace_headers=trace_headers,
data_parallel_rank=data_parallel_rank, priority=priority,
resumable=resumable, data_parallel_rank=data_parallel_rank,
) resumable=resumable,
)
except Exception:
if preselected_stage0_replica is not None and self.stage_pools:
self.stage_pools[0].release_binding(request_id)
raise
_preprocess_ms = (time.perf_counter() - _t_preprocess) * 1000.0 _preprocess_ms = (time.perf_counter() - _t_preprocess) * 1000.0
# TODO (Peiqi): add this for Qwen3-TTS only. Other models don't have # TODO (Peiqi): add this for Qwen3-TTS only. Other models don't have
# additional_information field in the prompt. # additional_information field in the prompt.
@@ -1535,9 +1698,9 @@ class AsyncOmniEngine:
"enable_multithread_weight_load": kwargs.get("enable_multithread_weight_load", True), "enable_multithread_weight_load": kwargs.get("enable_multithread_weight_load", True),
"num_weight_load_threads": kwargs.get("num_weight_load_threads", 4), "num_weight_load_threads": kwargs.get("num_weight_load_threads", 4),
"quantization": kwargs.get("quantization", None), "quantization": kwargs.get("quantization", None),
"kv_cache_dtype": kwargs.get("kv_cache_dtype", None), "diffusion_kv_cache_dtype": kwargs.get("diffusion_kv_cache_dtype", None),
"kv_cache_skip_steps": kwargs.get("kv_cache_skip_steps", None), "diffusion_kv_cache_skip_steps": kwargs.get("diffusion_kv_cache_skip_steps", None),
"kv_cache_skip_layers": kwargs.get("kv_cache_skip_layers", None), "diffusion_kv_cache_skip_layers": kwargs.get("diffusion_kv_cache_skip_layers", None),
**({"diffusion_attention_config": attention_config} if attention_config is not None else {}), **({"diffusion_attention_config": attention_config} if attention_config is not None else {}),
"force_cutlass_fp8": bool(kwargs.get("force_cutlass_fp8", False)), "force_cutlass_fp8": bool(kwargs.get("force_cutlass_fp8", False)),
"enable_diffusion_pipeline_profiler": kwargs.get("enable_diffusion_pipeline_profiler", False), "enable_diffusion_pipeline_profiler": kwargs.get("enable_diffusion_pipeline_profiler", False),
@@ -1717,24 +1880,27 @@ class AsyncOmniEngine:
if quantization is not None: if quantization is not None:
if not hasattr(cfg.engine_args, "quantization") or cfg.engine_args.quantization is None: if not hasattr(cfg.engine_args, "quantization") or cfg.engine_args.quantization is None:
cfg.engine_args.quantization = quantization cfg.engine_args.quantization = quantization
kv_cache_dtype = kwargs.get("kv_cache_dtype") diffusion_kv_cache_dtype = kwargs.get("diffusion_kv_cache_dtype")
if kv_cache_dtype is not None: if diffusion_kv_cache_dtype is not None:
if not hasattr(cfg.engine_args, "kv_cache_dtype") or cfg.engine_args.kv_cache_dtype is None:
cfg.engine_args.kv_cache_dtype = kv_cache_dtype
kv_cache_skip_steps = kwargs.get("kv_cache_skip_steps")
if kv_cache_skip_steps is not None:
if ( if (
not hasattr(cfg.engine_args, "kv_cache_skip_steps") not hasattr(cfg.engine_args, "diffusion_kv_cache_dtype")
or cfg.engine_args.kv_cache_skip_steps is None or cfg.engine_args.diffusion_kv_cache_dtype is None
): ):
cfg.engine_args.kv_cache_skip_steps = kv_cache_skip_steps cfg.engine_args.diffusion_kv_cache_dtype = diffusion_kv_cache_dtype
kv_cache_skip_layers = kwargs.get("kv_cache_skip_layers") diffusion_kv_cache_skip_steps = kwargs.get("diffusion_kv_cache_skip_steps")
if kv_cache_skip_layers is not None: if diffusion_kv_cache_skip_steps is not None:
if ( if (
not hasattr(cfg.engine_args, "kv_cache_skip_layers") not hasattr(cfg.engine_args, "diffusion_kv_cache_skip_steps")
or cfg.engine_args.kv_cache_skip_layers is None or cfg.engine_args.diffusion_kv_cache_skip_steps is None
): ):
cfg.engine_args.kv_cache_skip_layers = kv_cache_skip_layers cfg.engine_args.diffusion_kv_cache_skip_steps = diffusion_kv_cache_skip_steps
diffusion_kv_cache_skip_layers = kwargs.get("diffusion_kv_cache_skip_layers")
if diffusion_kv_cache_skip_layers is not None:
if (
not hasattr(cfg.engine_args, "diffusion_kv_cache_skip_layers")
or cfg.engine_args.diffusion_kv_cache_skip_layers is None
):
cfg.engine_args.diffusion_kv_cache_skip_layers = diffusion_kv_cache_skip_layers
except Exception as e: except Exception as e:
logger.warning("Failed to inject LoRA config for stage: %s", e) logger.warning("Failed to inject LoRA config for stage: %s", e)
+105 -137
View File
@@ -32,7 +32,6 @@ from vllm_omni.engine.messages import (
EngineQueueMessage, EngineQueueMessage,
ErrorMessage, ErrorMessage,
OutputMessage, OutputMessage,
ShutdownRequestMessage,
StageMetricsMessage, StageMetricsMessage,
StageSubmissionMessage, StageSubmissionMessage,
) )
@@ -128,8 +127,8 @@ class Orchestrator:
def __init__( def __init__(
self, self,
request_async_queue: janus.AsyncQueue[EngineQueueMessage], request_async_queue: janus.AsyncQueue[EngineQueueMessage],
output_async_queue: janus.AsyncQueue[EngineQueueMessage], output_async_queue: janus.AsyncQueue[dict[str, Any]],
rpc_async_queue: janus.AsyncQueue[EngineQueueMessage], rpc_async_queue: janus.AsyncQueue[dict[str, Any]],
stage_pools: list[StagePool], stage_pools: list[StagePool],
*, *,
async_chunk: bool = False, async_chunk: bool = False,
@@ -174,6 +173,13 @@ class Orchestrator:
await asyncio.gather(request_task, output_task) await asyncio.gather(request_task, output_task)
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except EngineDeadError as e:
# EngineDeadError from _orchestration_loop means the diffusion
# engine died. All pending requests were already notified and
# _shutdown_event was already set by the loop's handler.
# During teardown this is expected; the finally block handles
# proper cleanup. Do not re-raise.
logger.info("[Orchestrator] Engine dead during shutdown: %s", e)
except Exception: except Exception:
logger.exception("[Orchestrator] Fatal error in orchestrator tasks") logger.exception("[Orchestrator] Fatal error in orchestrator tasks")
raise raise
@@ -208,27 +214,32 @@ class Orchestrator:
"""Read messages from the main thread via request_async_queue.""" """Read messages from the main thread via request_async_queue."""
while True: while True:
msg = await self.request_async_queue.get() msg = await self.request_async_queue.get()
msg_type = msg.type
if isinstance(msg, StageSubmissionMessage): if msg_type == "add_request":
if msg.type == "add_request": await self._handle_add_request(msg)
await self._handle_add_request(msg) elif msg_type == "streaming_update":
elif msg.type == "streaming_update": await self._handle_streaming_update(msg)
await self._handle_streaming_update(msg) elif msg_type == "add_companion_request":
else:
logger.warning("[Orchestrator] Unknown stage submission type: %s", msg.type)
elif isinstance(msg, AddCompanionRequestMessage):
await self._handle_add_companion(msg) await self._handle_add_companion(msg)
elif isinstance(msg, AbortRequestMessage): elif msg_type == "abort":
await self._handle_abort(msg) await self._handle_abort(msg)
elif isinstance(msg, CollectiveRPCRequestMessage): elif msg_type == "collective_rpc":
await self._handle_collective_rpc(msg) await self._handle_collective_rpc(msg)
elif isinstance(msg, ShutdownRequestMessage): elif msg_type == "shutdown":
logger.info("[Orchestrator] Received shutdown signal") logger.info("[Orchestrator] Received shutdown signal")
self._shutdown_event.set() self._shutdown_event.set()
# Pre-mark stage clients as shutting down to prevent
# proc_monitor daemon threads from flagging normal
# process exit as EngineDeadError during teardown.
for pool in self.stage_pools:
for client in pool.clients:
if hasattr(client, "_shutting_down"):
client._shutting_down = True
self._shutdown_stages() self._shutdown_stages()
break break
else: else:
logger.warning("[Orchestrator] Unknown message type: %s", type(msg).__name__) logger.warning("[Orchestrator] Unknown message type: %s", msg_type)
async def _handle_add_request(self, msg: StageSubmissionMessage) -> None: async def _handle_add_request(self, msg: StageSubmissionMessage) -> None:
"""Handle an add_request message from the main thread.""" """Handle an add_request message from the main thread."""
@@ -387,8 +398,8 @@ class Orchestrator:
rpc_id = msg.rpc_id rpc_id = msg.rpc_id
method = msg.method method = msg.method
timeout = msg.timeout timeout = msg.timeout
args = msg.args args = tuple(msg.args)
kwargs = dict(msg.kwargs) kwargs = dict(msg.kwargs or {})
requested_stage_ids = msg.stage_ids requested_stage_ids = msg.stage_ids
target_pools: list[StagePool] = [] target_pools: list[StagePool] = []
@@ -793,120 +804,78 @@ class Orchestrator:
"""Forward output from the current logical stage to the next one.""" """Forward output from the current logical stage to the next one."""
next_logical = src_stage_id + 1 next_logical = src_stage_id + 1
next_pool = self.stage_pools[next_logical] next_pool = self.stage_pools[next_logical]
next_client = next_pool.stage_client
if next_pool.stage_type == "diffusion":
await self._forward_to_diffusion_stage(
req_id,
src_stage_id,
output,
req_state,
)
else:
await self._forward_to_llm_stage(
req_id,
src_stage_id,
output,
req_state,
is_streaming_session=is_streaming_session,
is_final_update=is_final_update,
)
async def _forward_to_diffusion_stage(
self,
req_id: str,
src_stage_id: int,
output: Any,
req_state: OrchestratorRequestState,
) -> None:
"""Forward output from an LLM stage to the next diffusion stage."""
next_logical = src_stage_id + 1
next_pool = self.stage_pools[next_logical]
params = req_state.sampling_params_list[next_logical]
source_outputs = [output]
already_submitted = self._next_stage_already_submitted(src_stage_id, req_state)
diffusion_client = next_pool.stage_client
if diffusion_client.custom_process_input_func is not None:
_t_ar2d = _time.perf_counter()
diffusion_prompt = diffusion_client.custom_process_input_func(
source_outputs,
req_state.prompt,
getattr(diffusion_client, "requires_multimodal_data", False),
)
_dt_ar2d = (_time.perf_counter() - _t_ar2d) * 1000
req_state.pipeline_timings["ar2diffusion_ms"] = _dt_ar2d
logger.info(
"[Orchestrator] ar2diffusion req=%s wall_time=%.3fms stage=%d->%d",
req_id,
_dt_ar2d,
src_stage_id,
next_logical,
)
if isinstance(diffusion_prompt, list):
if not diffusion_prompt:
error_output = OmniRequestOutput.from_error(
req_id,
f"Stage-{src_stage_id} produced no valid inputs for diffusion stage-{next_logical}",
)
logger.warning(
"[Orchestrator] req=%s stage=%d produced empty diffusion inputs for stage=%d; "
"routing terminal error output",
req_id,
src_stage_id,
next_logical,
)
await self.output_async_queue.put(
OutputMessage(
request_id=req_id,
stage_id=next_logical,
engine_outputs=error_output,
metrics=None,
finished=True,
)
)
await self._cleanup_request_ids(
[req_id, *self._cfg_tracker.cleanup_parent(req_id)],
)
return
if already_submitted and len(diffusion_prompt) == 1:
diffusion_prompt = diffusion_prompt[0]
else:
diffusion_prompt = req_state.prompt
if already_submitted:
await next_pool.submit_update(req_id, req_state, diffusion_prompt)
else:
await next_pool.submit_initial(
req_id,
req_state,
diffusion_prompt,
submit_kwargs={
"kv_sender_info": self._build_kv_sender_info(
list(getattr(diffusion_client, "engine_input_source", None) or [src_stage_id]),
request_id=req_id,
)
},
params_override=self._maybe_clone_diffusion_params_for_cfg(req_id, params),
)
req_state.stage_submit_ts[next_logical] = _time.time()
async def _forward_to_llm_stage(
self,
req_id: str,
src_stage_id: int,
output: Any,
req_state: OrchestratorRequestState,
*,
is_streaming_session: bool = False,
is_final_update: bool = False,
) -> None:
"""Forward output from the current stage to the next LLM stage."""
next_logical = src_stage_id + 1
next_pool = self.stage_pools[next_logical]
params = req_state.sampling_params_list[next_logical] params = req_state.sampling_params_list[next_logical]
source_outputs = [output] source_outputs = [output]
next_stage_resumable = is_streaming_session and not is_final_update next_stage_resumable = is_streaming_session and not is_final_update
already_submitted = self._next_stage_already_submitted(src_stage_id, req_state) already_submitted = self._next_stage_already_submitted(src_stage_id, req_state)
requires_multimodal_data = getattr(next_client, "requires_multimodal_data", False)
if next_pool.stage_type == "diffusion":
if next_client.custom_process_input_func is not None:
_t_ar2d = _time.perf_counter()
diffusion_prompt = next_client.custom_process_input_func(
source_outputs,
req_state.prompt,
requires_multimodal_data,
)
_dt_ar2d = (_time.perf_counter() - _t_ar2d) * 1000
req_state.pipeline_timings["ar2diffusion_ms"] = _dt_ar2d
logger.info(
"[Orchestrator] ar2diffusion req=%s wall_time=%.3fms stage=%d->%d",
req_id,
_dt_ar2d,
src_stage_id,
next_logical,
)
if isinstance(diffusion_prompt, list):
if not diffusion_prompt:
error_output = OmniRequestOutput.from_error(
req_id,
f"Stage-{src_stage_id} produced no valid inputs for diffusion stage-{next_logical}",
)
logger.warning(
"[Orchestrator] req=%s stage=%d produced empty diffusion inputs for stage=%d; "
"routing terminal error output",
req_id,
src_stage_id,
next_logical,
)
await self.output_async_queue.put(
OutputMessage(
request_id=req_id,
stage_id=next_logical,
engine_outputs=error_output,
metrics=None,
finished=True,
)
)
await self._cleanup_request_ids(
[req_id, *self._cfg_tracker.cleanup_parent(req_id)],
)
return
if already_submitted and len(diffusion_prompt) == 1:
diffusion_prompt = diffusion_prompt[0]
else:
diffusion_prompt = req_state.prompt
if already_submitted:
await next_pool.submit_update(req_id, req_state, diffusion_prompt)
else:
await next_pool.submit_initial(
req_id,
req_state,
diffusion_prompt,
submit_kwargs={
"kv_sender_info": self._build_kv_sender_info(
list(getattr(next_client, "engine_input_source", None) or [src_stage_id]),
request_id=req_id,
)
},
params_override=self._maybe_clone_diffusion_params_for_cfg(req_id, params),
)
req_state.stage_submit_ts[next_logical] = _time.time()
return
# PD disaggregation: prefill → decode routing uses original prompt + KV transfer params # PD disaggregation: prefill → decode routing uses original prompt + KV transfer params
if self._pd_pair is not None and (src_stage_id, next_logical) == self._pd_pair: if self._pd_pair is not None and (src_stage_id, next_logical) == self._pd_pair:
@@ -953,7 +922,6 @@ class Orchestrator:
{}, {},
)[req_id] = req_state.pd_prefill_multimodal_output )[req_id] = req_state.pd_prefill_multimodal_output
next_client = next_pool.llm_stage_client
try: try:
next_inputs = next_client.process_engine_inputs( next_inputs = next_client.process_engine_inputs(
source_outputs, source_outputs,
@@ -972,7 +940,7 @@ class Orchestrator:
for next_input in next_inputs: for next_input in next_inputs:
# Only AR thinker stages consume encoder mm_features; downstream # Only AR thinker stages consume encoder mm_features; downstream
# (talker/code2wav/…) must not see them (avoids encoder-cache misses). # (talker/code2wav/…) must not see them (avoids encoder-cache misses).
model_stage = next_client.model_stage model_stage = getattr(next_client, "model_stage", None)
mm_features = req_state.mm_features if model_stage == "thinker" else None mm_features = req_state.mm_features if model_stage == "thinker" else None
request = build_engine_core_request_from_tokens( request = build_engine_core_request_from_tokens(
request_id=req_id, request_id=req_id,
@@ -1022,7 +990,7 @@ class Orchestrator:
req_state.prompt, req_state.prompt,
submit_kwargs={ submit_kwargs={
"kv_sender_info": self._build_kv_sender_info( "kv_sender_info": self._build_kv_sender_info(
list(next_pool.stage_client.engine_input_source or [next_stage_id - 1]), list(getattr(next_pool.stage_client, "engine_input_source", None) or [next_stage_id - 1]),
request_id=request_id, request_id=request_id,
) )
}, },
@@ -1069,14 +1037,14 @@ class Orchestrator:
continue continue
sender_pool = self.stage_pools[sender_stage_id] sender_pool = self.stage_pools[sender_stage_id]
if sender_pool.stage_type == "diffusion": sender_stage = sender_pool.get_bound_client(request_id) if request_id is not None else None
if sender_stage is None:
sender_stage = sender_pool.stage_client
get_sender_info = getattr(sender_stage, "get_kv_sender_info", None)
if not callable(get_sender_info):
continue continue
sender_stage = sender_pool.get_bound_llm_client(request_id) if request_id is not None else None sender_info = get_sender_info()
if sender_stage is None:
sender_stage = sender_pool.llm_stage_client
sender_info = sender_stage.get_kv_sender_info()
if not sender_info: if not sender_info:
logger.warning( logger.warning(
"[Orchestrator] Stage-%s has no KV sender info available", "[Orchestrator] Stage-%s has no KV sender info available",
@@ -1110,7 +1078,7 @@ class Orchestrator:
msg = self.request_async_queue.get_nowait() msg = self.request_async_queue.get_nowait()
except Exception: except Exception:
break break
if isinstance(msg, StageSubmissionMessage) and msg.type == "add_request": if msg.type == "add_request":
req_id = msg.request_id req_id = msg.request_id
await self.output_async_queue.put( await self.output_async_queue.put(
ErrorMessage( ErrorMessage(
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more