Compare commits

...
25 Commits
Author SHA1 Message Date
amy-why-3459 ecd9d99da0 [CI][MiniCPM-o] Add NPU Video-MME accuracy nightly as a standalone job (#6596)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-25 14:11:52 +08:00
amy-why-3459 a964efc55b [CI][MiniCPM-o] Switch MiniCPM-o 4.5 perf Seed-TTS to Chinese and retune accuracy gates (#6447)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-21 18:56:49 +08:00
amy-why-3459 4105c717fe [CI][MiniCPM-o] Disable Seed-TTS shuffle in MiniCPM-o 4.5 perf benches (#6140)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-13 14:30:46 +08:00
amy-why-3459 0e97310d03 [CI][MiniCPM-o] Switch Seed-TTS accuracy gate to Chinese split (2020) (#6108)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-13 09:40:35 +08:00
bb0a513df0 [Bugfix][MiniCPM-o] Fix offline dataset loading and stale example sampling params (#6055)
Signed-off-by: psv666 <2693925048@qq.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-12 20:36:09 +08:00
NATURE 49977ce7a3 [Perf] release unused Code2Wav stream caches (#6100)
Signed-off-by: natureofnature <wzliu@connect.hku.hk>
2026-08-12 18:43:40 +08:00
amy-why-3459 b4cc0d6da6 [CI][MiniCPM-o] Add MiniCPM-o 4.5 perf coverage to the ready gate (#6092)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-12 15:21:50 +08:00
psv666andClaude Opus 5 009b80d686 [Bugfix][MiniCPM-o] Wire the duplex sampling hook into the NPU AR model runner (#5895)
Signed-off-by: psv666 <2693925048@qq.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:38:45 +08:00
wangyuandamy-why-3459 0a12ac52ee [CI/Build] Resolve hardware-nested perf baselines per concurrency sweep (#5858)
Signed-off-by: wangyu <410167048@qq.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-07 16:54:38 +08:00
psv666andamy-why-3459 1be47550d0 [CI/Build][MiniCPM-o] Fix NPU accuracy job timeout and silent Daily-O… (#5847)
Signed-off-by: psv666 <2693925048@qq.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-07 12:35:03 +08:00
amy-why-3459 d7a22612db [Benchmark][MiniCPM-o] Add Video-MME dataset support for Omni bench s… (#5868)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-07 01:10:29 +08:00
psv666 271d2f51e2 [Bugfix] npu omni runner alignment (#5846)
Signed-off-by: psv666 <2693925048@qq.com>
2026-08-06 16:07:19 +08:00
ruirui(rein) yang 4a0a481778 [CI] MiniCPM-o4.5 CI concurrency=8 OOM bugfix (#5790)
Signed-off-by: R2-Y <ruiruyang2@gmail.com>
2026-08-05 11:56:21 +08:00
psv666andamy-why-3459 4ce5fcb16c [CI/Build][MiniCPM-o] Add MiniCPM-o 4.5 accuracy and performance nightly coverage (#5710)
Signed-off-by: psv666 <2693925048@qq.com>
Signed-off-by: psv666 <150513104+psv666@users.noreply.github.com>
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-05 00:49:14 +08:00
ruirui(rein) yangandamy-why-3459 a5eb2a9a87 [CI][MiniCPM-o]MiniCPM-o ACC PERF CI (#5575)
Signed-off-by: R2-Y <ruiruyang2@gmail.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-04 18:46:20 +08:00
ZacheryAU 434c220da1 [bugfix][CI][MiniCPM-o] fix CI failure of test_audio_to_text_audio_001 in Buildkit 13077 (#5705) 2026-08-04 11:34:36 +08:00
wangyu 63fdc8e7f0 [CI/Build][MiniCPM-o] Narrow nightly H100 function tests to MiniCPM-o 4.5 (#5696)
Signed-off-by: wangyu <410167048@qq.com>
2026-08-03 15:06:38 +08:00
f05babab84 [Bugfix] Use MiniCPM-o speech template in long audio test (#5623) (#5651)
Signed-off-by: lishunyang12 <lishunyang03@gmail.com>
Co-authored-by: SYLAR <125541396+lishunyang12@users.noreply.github.com>
Co-authored-by: lishunyang12 <lishunyang03@gmail.com>
2026-08-02 17:38:02 +08:00
ruirui(rein) yang 75b93e0c67 [Bugfix][MiniCPM-o]Fix implicit deploy connector resolution for registered pipelines (#5532)
Signed-off-by: R2-Y <ruiruyang2@gmail.com>
2026-07-31 10:58:34 +08:00
amy-why-3459 ed32f4e988 [Bugfix][MiniCPM-o] Align Daily-Omni bench with MiniCPM interleaved AV recipe (#5606)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-07-31 10:58:04 +08:00
ruirui(rein) yang c50408a808 [bugfix][MiniCPM-o]delete duplicate files (#5537)
Signed-off-by: R2-Y <ruiruyang2@gmail.com>
2026-07-30 10:42:42 +08:00
amy-why-3459 a80d1e614d [CI][MiniCPM-o] Prefer standard omni serve over stage CLI in e2e/reli… (#5553)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-07-29 21:04:52 +08:00
amy-why-3459 00d796edf9 [CI][MiniCPM-o] Run e2e tests on single-GPU deploy config (#5506)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-07-29 19:43:31 +08:00
wangyu 0b3898c884 [CI] Point merge/nightly Buildkite triggers at minicpm-challenge (#5497)
Signed-off-by: wangyu <410167048@qq.com>
Signed-off-by: [Your Name] <your.email@example.com>
Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com>
2026-07-28 18:47:20 +08:00
Sy03 ffa9a6af69 [WIP][Bugfix][MiniCPM-o] Fix Full duplex bugs for minicpm-challenge (#5496)
Signed-off-by: Sy03 <1370724210@qq.com>
2026-07-28 15:59:26 +08:00
147 changed files with 11115 additions and 3081 deletions

No files matched your search

@@ -6,6 +6,10 @@
set -euo pipefail set -euo pipefail
# minicpm-challenge branch: skip entire AMD CI (no pipeline upload => no image build / tests).
echo "--- Skipping AMD CI on this branch (including image build)"
exit 0
source .buildkite/common/scripts/resolve_skip_ci.sh source .buildkite/common/scripts/resolve_skip_ci.sh
if [[ -z "${RUN_ALL:-}" ]]; then if [[ -z "${RUN_ALL:-}" ]]; then
+6 -4
View File
@@ -282,7 +282,7 @@ def _git(*args: str) -> subprocess.CompletedProcess[str]:
def _resolve_diff_range() -> str | None: def _resolve_diff_range() -> str | None:
"""Return a git diff range for PR or main builds, or None when unavailable.""" """Return a git diff range for PR or push-branch builds, or None when unavailable."""
is_pr = os.environ.get("BUILDKITE_PULL_REQUEST", "false") != "false" and os.environ.get( is_pr = os.environ.get("BUILDKITE_PULL_REQUEST", "false") != "false" and os.environ.get(
"BUILDKITE_PULL_REQUEST", "" "BUILDKITE_PULL_REQUEST", ""
) )
@@ -302,13 +302,15 @@ def _resolve_diff_range() -> str | None:
return None return None
return f"{base_ref}...{commit}" return f"{base_ref}...{commit}"
if os.environ.get("BUILDKITE_BRANCH", "") == "main": # main: ready/nightly/weekly push CI; minicpm-challenge: merge (L3) push CI
branch = os.environ.get("BUILDKITE_BRANCH", "")
if branch in ("main", "minicpm-challenge"):
if _git("rev-parse", "--verify", f"{commit}^").returncode != 0: if _git("rev-parse", "--verify", f"{commit}^").returncode != 0:
_log("main commit has no parent; using safe defaults") _log(f"{branch} commit has no parent; using safe defaults")
return None return None
return f"{commit}^..{commit}" return f"{commit}^..{commit}"
_log("not PR/main build; using safe defaults") _log("not PR/main/minicpm-challenge build; using safe defaults")
return None return None
+13 -10
View File
@@ -59,11 +59,12 @@ E2E_GROUP_MARKER = "E2E Test"
CI_MIRROR_HARDWARES_PATH = ROOT / ".buildkite/common/ci_mirror_hardwares.yml" CI_MIRROR_HARDWARES_PATH = ROOT / ".buildkite/common/ci_mirror_hardwares.yml"
CUDA_NIGHTLY_ONLY = ( CUDA_NIGHTLY_ONLY = (
'(build.pull_request.labels includes "nightly-test") || (build.branch == "main" && build.env("NIGHTLY") == "1")' '(build.pull_request.labels includes "nightly-test") || '
'(build.branch == "minicpm-challenge" && build.env("NIGHTLY") == "1")'
) )
NPU_NIGHTLY_ONLY = ( NPU_NIGHTLY_ONLY = (
'(build.branch == "main" && build.env("NIGHTLY") == "1") || ' '(build.branch == "minicpm-challenge" && build.env("NIGHTLY") == "1") || '
'(build.branch != "main" && (' '(build.branch != "minicpm-challenge" && ('
'build.pull_request.labels includes "nightly-test" || ' 'build.pull_request.labels includes "nightly-test" || '
'build.pull_request.labels includes "omni-test" || ' 'build.pull_request.labels includes "omni-test" || '
'build.pull_request.labels includes "tts-test" || ' 'build.pull_request.labels includes "tts-test" || '
@@ -103,7 +104,8 @@ def _format_bootstrap_if(expr: str) -> str:
def _compute_bootstrap_if_exprs(*, decision, platform: str) -> dict[str, str]: def _compute_bootstrap_if_exprs(*, decision, platform: str) -> dict[str, str]:
disabled = "false" disabled = "false"
nightly_main = 'build.branch == "main" && build.env("NIGHTLY") == "1"' # L4 nightly scheduled push CI runs on minicpm-challenge (not main).
nightly_main = 'build.branch == "minicpm-challenge" && build.env("NIGHTLY") == "1"'
nightly_only = NPU_NIGHTLY_ONLY if platform == "npu" else CUDA_NIGHTLY_ONLY nightly_only = NPU_NIGHTLY_ONLY if platform == "npu" else CUDA_NIGHTLY_ONLY
if platform == "npu": if platform == "npu":
@@ -118,11 +120,12 @@ def _compute_bootstrap_if_exprs(*, decision, platform: str) -> dict[str, str]:
merge_base = disabled merge_base = disabled
else: else:
ready_pr = 'build.branch != "main" && build.pull_request.labels includes "ready"' ready_pr = 'build.branch != "main" && build.pull_request.labels includes "ready"'
merge_main = 'build.branch == "main" && build.env("NIGHTLY") != "1" && build.env("WEEKLY") != "1"' # L3 merge push CI runs on minicpm-challenge (not main).
merge_pr = 'build.branch != "main" && build.pull_request.labels includes "merge-test"' merge_main = 'build.branch == "minicpm-challenge" && build.env("NIGHTLY") != "1" && build.env("WEEKLY") != "1"'
merge_pr = 'build.branch != "minicpm-challenge" && build.pull_request.labels includes "merge-test"'
nightly_label_if = ( nightly_label_if = (
'(build.branch == "main" && build.env("NIGHTLY") == "1") || ' '(build.branch == "minicpm-challenge" && build.env("NIGHTLY") == "1") || '
'(build.branch != "main" && (' '(build.branch != "minicpm-challenge" && ('
'build.pull_request.labels includes "nightly-test" || ' 'build.pull_request.labels includes "nightly-test" || '
'build.pull_request.labels includes "omni-test" || ' 'build.pull_request.labels includes "omni-test" || '
'build.pull_request.labels includes "tts-test" || ' 'build.pull_request.labels includes "tts-test" || '
@@ -140,8 +143,8 @@ def _compute_bootstrap_if_exprs(*, decision, platform: str) -> dict[str, str]:
weekly_main = 'build.branch == "main" && build.env("WEEKLY") == "1"' weekly_main = 'build.branch == "main" && build.env("WEEKLY") == "1"'
if decision.skip_all: if decision.skip_all:
# Docs / skip-mark only: no PR-label escape hatch. Main scheduled # Docs / skip-mark only: no PR-label escape hatch. Scheduled
# NIGHTLY=1 still runs L4 + L2/L3 (--e2e); WEEKLY=1 still runs L5. # NIGHTLY=1 (minicpm-challenge) still runs L4 + L2/L3 (--e2e); WEEKLY=1 (main) still runs L5.
image_expr = f"({nightly_main}) || ({weekly_main})" if platform == "cuda" else nightly_main image_expr = f"({nightly_main}) || ({weekly_main})" if platform == "cuda" else nightly_main
ready_expr = nightly_main ready_expr = nightly_main
merge_expr = nightly_main if platform == "cuda" else disabled merge_expr = nightly_main if platform == "cuda" else disabled
+5 -5
View File
@@ -8,35 +8,35 @@ steps:
agents: agents:
queue: "cpu_queue_premerge" queue: "cpu_queue_premerge"
# L2 Test — PR ready label (diff-aware) or main+NIGHTLY=1 (--e2e, full E2E group) # L2 Test — PR ready label (diff-aware) or minicpm-challenge+NIGHTLY=1 (--e2e, full E2E group)
- label: "Upload Ready Pipeline" - label: "Upload Ready Pipeline"
depends_on: image-build depends_on: image-build
key: upload-ready-pipeline key: upload-ready-pipeline
commands: commands:
- | - |
EXTRA="" EXTRA=""
if [ "${NIGHTLY:-}" = "1" ] && [ "${BUILDKITE_BRANCH}" = "main" ]; then if [ "${NIGHTLY:-}" = "1" ] && [ "${BUILDKITE_BRANCH}" = "minicpm-challenge" ]; then
EXTRA="--e2e" EXTRA="--e2e"
fi fi
python3 .buildkite/common/scripts/upload_pipeline.py --upload $$EXTRA .buildkite/cuda/test-ready.yml python3 .buildkite/common/scripts/upload_pipeline.py --upload $$EXTRA .buildkite/cuda/test-ready.yml
agents: agents:
queue: "cpu_queue_premerge" queue: "cpu_queue_premerge"
# L3 Test — PR merge-test label (diff-aware) or main+NIGHTLY=1 (--e2e, full E2E group) # L3 Test — PR merge-test label (diff-aware) or minicpm-challenge+NIGHTLY=1 (--e2e, full E2E group)
- label: "Upload Merge Pipeline" - label: "Upload Merge Pipeline"
depends_on: image-build depends_on: image-build
key: upload-merge-pipeline key: upload-merge-pipeline
commands: commands:
- | - |
EXTRA="" EXTRA=""
if [ "${NIGHTLY:-}" = "1" ] && [ "${BUILDKITE_BRANCH}" = "main" ]; then if [ "${NIGHTLY:-}" = "1" ] && [ "${BUILDKITE_BRANCH}" = "minicpm-challenge" ]; then
EXTRA="--e2e" EXTRA="--e2e"
fi fi
python3 .buildkite/common/scripts/upload_pipeline.py --upload $$EXTRA .buildkite/cuda/test-merge.yml python3 .buildkite/common/scripts/upload_pipeline.py --upload $$EXTRA .buildkite/cuda/test-merge.yml
agents: agents:
queue: "cpu_queue_premerge" queue: "cpu_queue_premerge"
# L4 Test — main+NIGHTLY=1 (scheduled), or PR with specific label (e.g. add label then Rebuild) # L4 Test — minicpm-challenge+NIGHTLY=1 (scheduled), or PR with specific label (e.g. add label then Rebuild)
- label: "Upload Nightly Pipeline" - label: "Upload Nightly Pipeline"
depends_on: image-build depends_on: image-build
key: upload-nightly-pipeline key: upload-nightly-pipeline
+204 -234
View File
@@ -18,257 +18,227 @@ steps:
- "timeout 20m pytest -sv -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" - "timeout 20m pytest -sv -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
mirror_hardwares: l4_1 mirror_hardwares: l4_1
- group: ":card_index_dividers: Diffusion Test" # - group: ":card_index_dividers: Diffusion Test"
depends_on: upload-merge-pipeline # depends_on: upload-merge-pipeline
steps: # steps:
- label: "Diffusion · LoRA Test" # - label: "Diffusion · LoRA Test"
timeout_in_minutes: 30 # timeout_in_minutes: 30
commands: # commands:
- pytest -sv tests/diffusion/lora/ -m 'advanced_model and cuda' --run-level "advanced_model" # - pytest -sv tests/diffusion/lora/ -m 'advanced_model and cuda' --run-level "advanced_model"
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "Diffusion · Offloader Test" # - label: "Diffusion · Offloader Test"
timeout_in_minutes: 40 # timeout_in_minutes: 40
commands: # commands:
- timeout 40m pytest -s -v tests/diffusion/offloader -m "advanced_model and cuda" # - timeout 40m pytest -s -v tests/diffusion/offloader -m "advanced_model and cuda"
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "Distributed Test" # - label: "Distributed Test"
depends_on: upload-merge-pipeline # depends_on: upload-merge-pipeline
timeout_in_minutes: 60 # timeout_in_minutes: 60
commands: # commands:
- | # - |
timeout 55m bash -c " # timeout 55m bash -c "
set -e # set -e
export VLLM_IMAGE_FETCH_TIMEOUT=60 # export VLLM_IMAGE_FETCH_TIMEOUT=60
pytest -s -v tests/distributed -m 'advanced_model and cuda and H100' --run-level 'advanced_model' # pytest -s -v tests/distributed -m 'advanced_model and cuda and H100' --run-level 'advanced_model'
" # "
mirror_hardwares: h100_1 # mirror_hardwares: h100_1
- group: ":card_index_dividers: Entrypoints Test" # - group: ":card_index_dividers: Entrypoints Test"
depends_on: upload-merge-pipeline # depends_on: upload-merge-pipeline
steps: # steps:
- label: "Entrypoint Test with H100" # - label: "Entrypoint Test with H100"
timeout_in_minutes: 60 # timeout_in_minutes: 60
commands: # commands:
- pytest -s -v tests/entrypoints/ -m "advanced_model and cuda and H100" --run-level "advanced_model" # - pytest -s -v tests/entrypoints/ -m "advanced_model and cuda and H100" --run-level "advanced_model"
mirror_hardwares: h100_2 # mirror_hardwares: h100_2
- label: "Entrypoint Test with L4" # - label: "Entrypoint Test with L4"
timeout_in_minutes: 30 # timeout_in_minutes: 30
commands: # commands:
- pytest -s -v tests/entrypoints/ -m "advanced_model and cuda and L4" --run-level "advanced_model" # - pytest -s -v tests/entrypoints/ -m "advanced_model and cuda and L4" --run-level "advanced_model"
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- group: ":card_index_dividers: E2E Test" # - group: ":card_index_dividers: E2E Test"
depends_on: upload-merge-pipeline # depends_on: upload-merge-pipeline
steps: # steps:
- label: "TTS · Qwen3-TTS CustomVoice Test" # - label: "TTS · Qwen3-TTS CustomVoice Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_tts_customvoice.py # - tests/e2e/online_serving/test_qwen3_tts_customvoice.py
- tests/e2e/offline_inference/test_qwen3_tts_customvoice.py # - tests/e2e/offline_inference/test_qwen3_tts_customvoice.py
- vllm_omni/model_executor/models/qwen3_tts/ # - vllm_omni/model_executor/models/qwen3_tts/
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py # - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml # - vllm_omni/deploy/qwen3_tts.yaml
commands: # commands:
- | # - |
timeout 40m bash -c " # timeout 40m bash -c "
export VLLM_LOGGING_LEVEL=DEBUG # export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 # export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
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'
" # "
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "TTS · Qwen3-TTS Base Test" # - label: "TTS · Qwen3-TTS Base Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_tts_base.py # - tests/e2e/online_serving/test_qwen3_tts_base.py
- tests/e2e/offline_inference/test_qwen3_tts_base.py # - tests/e2e/offline_inference/test_qwen3_tts_base.py
- vllm_omni/model_executor/models/qwen3_tts/ # - vllm_omni/model_executor/models/qwen3_tts/
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py # - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml # - vllm_omni/deploy/qwen3_tts.yaml
commands: # commands:
- | # - |
timeout 40m bash -c " # timeout 40m bash -c "
export VLLM_LOGGING_LEVEL=DEBUG # export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 # export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m 'advanced_model and cuda' --run-level 'advanced_model' # 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'
" # "
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "TTS · Higgs-Audio-V3 Test" # - label: "TTS · Higgs-Audio-V3 Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/online_serving/test_higgs_audio_v3.py # - tests/e2e/online_serving/test_higgs_audio_v3.py
- vllm_omni/model_executor/models/higgs_audio_v3/ # - vllm_omni/model_executor/models/higgs_audio_v3/
- vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py # - vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py
- vllm_omni/deploy/higgs_multimodal_qwen3.yaml # - vllm_omni/deploy/higgs_multimodal_qwen3.yaml
commands: # commands:
- | # - |
timeout 20m bash -c " # timeout 20m bash -c "
export VLLM_WORKER_MULTIPROC_METHOD=spawn # export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_USE_DEEP_GEMM=0 # export VLLM_USE_DEEP_GEMM=0
export VLLM_MOE_USE_DEEP_GEMM=0 # export VLLM_MOE_USE_DEEP_GEMM=0
pytest -s -v tests/e2e/online_serving/test_higgs_audio_v3.py -m 'advanced_model' --run-level 'advanced_model' # pytest -s -v tests/e2e/online_serving/test_higgs_audio_v3.py -m 'advanced_model' --run-level 'advanced_model'
" # "
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "Omni · Qwen3-Omni Test" # - label: "Omni · Qwen3-Omni Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/offline_inference/test_qwen3_omni.py # - tests/e2e/offline_inference/test_qwen3_omni.py
- tests/e2e/online_serving/test_qwen3_omni.py # - tests/e2e/online_serving/test_qwen3_omni.py
- vllm_omni/model_executor/models/qwen3_omni/ # - vllm_omni/model_executor/models/qwen3_omni/
- vllm_omni/model_executor/stage_input_processors/qwen3_omni.py # - vllm_omni/model_executor/stage_input_processors/qwen3_omni.py
- vllm_omni/deploy/qwen3_omni_moe.yaml # - vllm_omni/deploy/qwen3_omni_moe.yaml
timeout_in_minutes: 50 # timeout_in_minutes: 50
commands: # commands:
- pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py -m 'advanced_model and cuda' --run-level 'advanced_model' # - pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py -m 'advanced_model and cuda' --run-level 'advanced_model'
mirror_hardwares: h100_2 # mirror_hardwares: h100_2
- label: "Omni · MiniCPM-o 4.5 Test" - label: "Omni · MiniCPM-o 4.5 Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_minicpmo_4_5.py
- tests/e2e/online_serving/test_minicpmo_4_5.py
- vllm_omni/deploy/minicpmo_4_5.yaml
timeout_in_minutes: 50 timeout_in_minutes: 50
commands: commands:
- pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5.py -m 'advanced_model and cuda' --run-level 'advanced_model' - pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5.py -m 'advanced_model and cuda' --run-level 'advanced_model'
agents:
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources:
limits:
nvidia.com/gpu: 2
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: hf-token-secret
key: token
nodeSelector:
node.kubernetes.io/instance-type: gpu-h100-sxm
volumes:
- name: devshm
emptyDir:
medium: Memory
- name: hf-cache
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: "TTS · VoxCPM2 Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_voxcpm2_tts.py
- tests/e2e/online_serving/test_voxcpm2_tts.py
- vllm_omni/model_executor/models/voxcpm2/
- vllm_omni/deploy/voxcpm2.yaml
timeout_in_minutes: 30
commands:
- |
timeout 30m bash -c "
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/offline_inference/test_voxcpm2_tts.py tests/e2e/online_serving/test_voxcpm2_tts.py -m 'advanced_model and cuda' --run-level 'advanced_model'
"
mirror_hardwares: l4_1
- label: "TTS · SoulX-Singer Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_soulxsinger.py
- vllm_omni/diffusion/models/soulx_singer/
- vllm_omni/deploy/soulxsinger_svc.yaml
- vllm_omni/deploy/soulxsinger_svs.yaml
timeout_in_minutes: 30
commands:
- |
timeout 30m bash -c "
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/offline_inference/test_soulxsinger.py -k 'svc or precomputed' -m 'advanced_model and cuda' --run-level 'advanced_model'
"
mirror_hardwares: l4_1
- label: "Diffusion · Qwen Image Edit Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image_edit.py
- vllm_omni/diffusion/models/qwen_image/
timeout_in_minutes: 20
commands:
- pytest -s -v tests/e2e/online_serving/test_qwen_image_edit.py -m 'advanced_model and cuda' --run-level 'advanced_model'
mirror_hardwares: h100_1
- label: "Diffusion · Bagel Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_bagel.py
- tests/e2e/online_serving/test_bagel.py
- vllm_omni/model_executor/models/bagel/
- vllm_omni/diffusion/models/bagel/
- vllm_omni/model_executor/stage_input_processors/bagel.py
- vllm_omni/deploy/bagel.yaml
timeout_in_minutes: 20
commands:
- pytest -s -v tests/e2e/offline_inference/test_bagel.py tests/e2e/online_serving/test_bagel.py -m 'advanced_model and cuda' --run-level 'advanced_model'
mirror_hardwares: h100_1 mirror_hardwares: h100_1
- label: "Diffusion · Qwen Image Test" - label: "Omni · MiniCPM-o 4.5 Duplex Test"
source_file_dependencies: timeout_in_minutes: 50
- tests/e2e/online_serving/test_qwen_image.py
- vllm_omni/diffusion/models/qwen_image/
commands: commands:
- | - pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'advanced_model and cuda' --run-level 'advanced_model'
timeout 40m bash -c '
export VLLM_IMAGE_FETCH_TIMEOUT=60
pytest -s -v tests/e2e/online_serving/test_qwen_image.py -m 'advanced_model and cuda' --run-level 'advanced_model'
'
mirror_hardwares: h100_1 mirror_hardwares: h100_1
- label: "Diffusion · Qwen Image Layered Test" # - label: "TTS · VoxCPM2 Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image_layered.py # - tests/e2e/offline_inference/test_voxcpm2_tts.py
- vllm_omni/diffusion/models/qwen_image/ # - tests/e2e/online_serving/test_voxcpm2_tts.py
commands: # - vllm_omni/model_executor/models/voxcpm2/
- | # - vllm_omni/deploy/voxcpm2.yaml
timeout 40m bash -c " # timeout_in_minutes: 30
export VLLM_IMAGE_FETCH_TIMEOUT=60 # commands:
pytest -s -v tests/e2e/online_serving/test_qwen_image_layered.py -m 'advanced_model and cuda' --run-level 'advanced_model' # - |
" # timeout 30m bash -c "
mirror_hardwares: h100_1 # export VLLM_LOGGING_LEVEL=DEBUG
# export VLLM_WORKER_MULTIPROC_METHOD=spawn
# pytest -s -v tests/e2e/offline_inference/test_voxcpm2_tts.py tests/e2e/online_serving/test_voxcpm2_tts.py -m 'advanced_model and cuda' --run-level 'advanced_model'
# "
# mirror_hardwares: l4_1
- label: "Diffusion · Wan22 Test" # - label: "TTS · SoulX-Singer Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/offline_inference/test_wan22_t2v.py # - tests/e2e/offline_inference/test_soulxsinger.py
- tests/e2e/online_serving/test_wan22_t2v.py # - vllm_omni/diffusion/models/soulx_singer/
- vllm_omni/diffusion/models/wan2_2/ # - vllm_omni/deploy/soulxsinger_svc.yaml
commands: # - vllm_omni/deploy/soulxsinger_svs.yaml
- | # timeout_in_minutes: 30
timeout 40m bash -c " # commands:
export VLLM_IMAGE_FETCH_TIMEOUT=60 # - |
pytest -s -v tests/e2e/offline_inference/test_wan22_t2v.py tests/e2e/online_serving/test_wan22_t2v.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' # timeout 30m bash -c "
" # export VLLM_LOGGING_LEVEL=DEBUG
mirror_hardwares: h100_1 # export VLLM_WORKER_MULTIPROC_METHOD=spawn
# pytest -s -v tests/e2e/offline_inference/test_soulxsinger.py -k 'svc or precomputed' -m 'advanced_model and cuda' --run-level 'advanced_model'
# "
# mirror_hardwares: l4_1
- label: "Diffusion · Z Image Test" # - label: "Diffusion · Qwen Image Edit Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/offline_inference/test_z_image.py # - tests/e2e/online_serving/test_qwen_image_edit.py
- tests/e2e/online_serving/test_z_image.py # - vllm_omni/diffusion/models/qwen_image/
- vllm_omni/diffusion/models/z_image/ # timeout_in_minutes: 20
timeout_in_minutes: 30 # commands:
commands: # - pytest -s -v tests/e2e/online_serving/test_qwen_image_edit.py -m 'advanced_model and cuda' --run-level 'advanced_model'
- pytest -s -v tests/e2e/offline_inference/test_z_image.py tests/e2e/online_serving/test_z_image.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' # mirror_hardwares: h100_1
mirror_hardwares: l4_1 # - label: "Diffusion · Bagel Test"
- label: "Diffusion · FLUX.2 Klein Test" # source_file_dependencies:
source_file_dependencies: # - tests/e2e/offline_inference/test_bagel.py
- tests/e2e/offline_inference/test_flux2_klein.py # - tests/e2e/online_serving/test_bagel.py
- vllm_omni/diffusion/models/flux2_klein/ # - vllm_omni/model_executor/models/bagel/
timeout_in_minutes: 30 # - vllm_omni/diffusion/models/bagel/
commands: # - vllm_omni/model_executor/stage_input_processors/bagel.py
- pytest -s -v tests/e2e/offline_inference/test_flux2_klein.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' # - vllm_omni/deploy/bagel.yaml
mirror_hardwares: l4_1 # timeout_in_minutes: 20
# commands:
# - pytest -s -v tests/e2e/offline_inference/test_bagel.py tests/e2e/online_serving/test_bagel.py -m 'advanced_model and cuda' --run-level 'advanced_model'
# mirror_hardwares: h100_1
# - label: "Diffusion · Qwen Image Test"
# source_file_dependencies:
# - tests/e2e/online_serving/test_qwen_image.py
# - vllm_omni/diffusion/models/qwen_image/
# commands:
# - |
# timeout 40m bash -c '
# export VLLM_IMAGE_FETCH_TIMEOUT=60
# pytest -s -v tests/e2e/online_serving/test_qwen_image.py -m 'advanced_model and cuda' --run-level 'advanced_model'
# '
# mirror_hardwares: h100_1
# - label: "Diffusion · Qwen Image Layered Test"
# source_file_dependencies:
# - tests/e2e/online_serving/test_qwen_image_layered.py
# - vllm_omni/diffusion/models/qwen_image/
# commands:
# - |
# timeout 40m bash -c "
# export VLLM_IMAGE_FETCH_TIMEOUT=60
# pytest -s -v tests/e2e/online_serving/test_qwen_image_layered.py -m 'advanced_model and cuda' --run-level 'advanced_model'
# "
# mirror_hardwares: h100_1
# - label: "Diffusion · Wan22 Test"
# source_file_dependencies:
# - tests/e2e/offline_inference/test_wan22_t2v.py
# - tests/e2e/online_serving/test_wan22_t2v.py
# - vllm_omni/diffusion/models/wan2_2/
# commands:
# - |
# timeout 40m bash -c "
# export VLLM_IMAGE_FETCH_TIMEOUT=60
# pytest -s -v tests/e2e/offline_inference/test_wan22_t2v.py tests/e2e/online_serving/test_wan22_t2v.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
# "
# mirror_hardwares: h100_1
# - label: "Diffusion · Z Image Test"
# source_file_dependencies:
# - tests/e2e/offline_inference/test_z_image.py
# - tests/e2e/online_serving/test_z_image.py
# - vllm_omni/diffusion/models/z_image/
# timeout_in_minutes: 30
# commands:
# - pytest -s -v tests/e2e/offline_inference/test_z_image.py tests/e2e/online_serving/test_z_image.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
# mirror_hardwares: l4_1
# - label: "Diffusion · FLUX.2 Klein Test"
# source_file_dependencies:
# - tests/e2e/offline_inference/test_flux2_klein.py
# - vllm_omni/diffusion/models/flux2_klein/
# timeout_in_minutes: 30
# commands:
# - pytest -s -v tests/e2e/offline_inference/test_flux2_klein.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
# mirror_hardwares: l4_1
File diff suppressed because it is too large. Load diff
+252 -289
View File
@@ -18,339 +18,302 @@ steps:
- "timeout 20m pytest -sv -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" - "timeout 20m pytest -sv -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
mirror_hardwares: l4_1 mirror_hardwares: l4_1
- label: "Custom Pipeline Test" # - label: "Custom Pipeline Test"
depends_on: upload-ready-pipeline # depends_on: upload-ready-pipeline
commands: # commands:
- timeout 20m pytest -s -v tests/e2e/offline_inference/custom_pipeline/ -m "core_model" # - timeout 20m pytest -s -v tests/e2e/offline_inference/custom_pipeline/ -m "core_model"
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- group: ":card_index_dividers: Diffusion Test" # - group: ":card_index_dividers: Diffusion Test"
depends_on: upload-ready-pipeline # depends_on: upload-ready-pipeline
steps: # steps:
- label: "Diffusion · Offloader Test" # - label: "Diffusion · Offloader Test"
commands: # commands:
- timeout 40m pytest -s -v tests/diffusion/offloader -m "core_model and cuda" --run-level "core_model" # - timeout 40m pytest -s -v tests/diffusion/offloader -m "core_model and cuda" --run-level "core_model"
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "Diffusion · Cache Test" # - label: "Diffusion · Cache Test"
commands: # commands:
- timeout 15m pytest -sv tests/diffusion/cache -m 'core_model and cuda' --run-level "core_model" # - timeout 15m pytest -sv tests/diffusion/cache -m 'core_model and cuda' --run-level "core_model"
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "Diffusion · Batch Test" # - label: "Diffusion · Batch Test"
commands: # commands:
- timeout 15m pytest -sv tests/diffusion/batching -m 'core_model and cuda' --run-level "core_model" # - timeout 15m pytest -sv tests/diffusion/batching -m 'core_model and cuda' --run-level "core_model"
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "Diffusion · Distributed Test" # - label: "Diffusion · Distributed Test"
source_file_dependencies: # source_file_dependencies:
- tests/diffusion/distributed/ # - tests/diffusion/distributed/
- vllm_omni/diffusion/distributed/ # - vllm_omni/diffusion/distributed/
- vllm_omni/diffusion/attention/parallel/ # - vllm_omni/diffusion/attention/parallel/
- vllm_omni/diffusion/data.py # - vllm_omni/diffusion/data.py
- vllm_omni/diffusion/models/ # - vllm_omni/diffusion/models/
commands: # commands:
- timeout 20m pytest -sv tests/diffusion/distributed -m 'core_model and cuda' --run-level "core_model" # - timeout 20m pytest -sv tests/diffusion/distributed -m 'core_model and cuda' --run-level "core_model"
mirror_hardwares: l4_4 # mirror_hardwares: l4_4
- label: "Diffusion · Model Test" # - label: "Diffusion · Model Test"
commands: # commands:
- timeout 15m pytest -sv tests/diffusion/models/ -m 'core_model and cuda' --run-level "core_model" # - timeout 15m pytest -sv tests/diffusion/models/ -m 'core_model and cuda' --run-level "core_model"
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "Diffusion · Other Test" # - label: "Diffusion · Other Test"
commands: # commands:
- timeout 15m pytest -sv tests/diffusion/*.py -m 'core_model and cuda' --run-level "core_model" # - timeout 15m pytest -sv tests/diffusion/*.py -m 'core_model and cuda' --run-level "core_model"
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- group: ":card_index_dividers: Tiny Model Tests [Single GPU]" # - group: ":card_index_dividers: Tiny Model Tests [Single GPU]"
depends_on: upload-ready-pipeline # depends_on: upload-ready-pipeline
steps: # steps:
- label: "Tiny Model Tests · Diffusion (base)" # - label: "Tiny Model Tests · Diffusion (base)"
commands: # commands:
- timeout 15m pytest -sv tests/model_tests/diffusion -m 'core_model and cuda' --run-level "core_model" # - timeout 15m pytest -sv tests/model_tests/diffusion -m 'core_model and cuda' --run-level "core_model"
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "Engine&Model Executor Test" # - label: "Engine&Model Executor Test"
depends_on: upload-ready-pipeline # depends_on: upload-ready-pipeline
commands: # commands:
- | # - |
timeout 15m bash -c " # timeout 15m bash -c "
pytest -sv tests/engine/ tests/model_executor/ -m 'core_model and cuda' --run-level 'core_model' # pytest -sv tests/engine/ tests/model_executor/ -m 'core_model and cuda' --run-level 'core_model'
" # "
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "Distributed Test" # - label: "Distributed Test"
depends_on: upload-ready-pipeline # depends_on: upload-ready-pipeline
commands: # commands:
- | # - |
timeout 20m bash -c " # timeout 20m bash -c "
pytest -sv tests/distributed/ -m 'core_model and cuda and L4' --run-level 'core_model' # pytest -sv tests/distributed/ -m 'core_model and cuda and L4' --run-level 'core_model'
" # "
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "Entrypoints Test" # - label: "Entrypoints Test"
depends_on: upload-ready-pipeline # depends_on: upload-ready-pipeline
commands: # commands:
- | # - |
timeout 30m bash -c " # timeout 30m bash -c "
export VLLM_WORKER_MULTIPROC_METHOD=spawn # export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -sv tests/entrypoints/ -m 'core_model and cuda' --run-level 'core_model' # pytest -sv tests/entrypoints/ -m 'core_model and cuda' --run-level 'core_model'
" # "
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- group: ":card_index_dividers: E2E Test" - group: ":card_index_dividers: E2E Test"
depends_on: upload-ready-pipeline depends_on: upload-ready-pipeline
steps: steps:
- label: "RLHF · VeRL-Omni E2E Test" # - label: "RLHF · VeRL-Omni E2E Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py # - tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py
- tests/e2e/offline_inference/custom_pipeline/ # - tests/e2e/offline_inference/custom_pipeline/
- vllm_omni/diffusion/ # - vllm_omni/diffusion/
- vllm_omni/engine/ # - vllm_omni/engine/
- vllm_omni/entrypoints/ # - vllm_omni/entrypoints/
- vllm_omni/inputs/ # - vllm_omni/inputs/
- vllm_omni/outputs/ # - vllm_omni/outputs/
commands: # commands:
- | # - |
timeout 20m bash -c " # timeout 20m bash -c "
pytest -s -v tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py # pytest -s -v tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py
" # "
mirror_hardwares: l4_1 # mirror_hardwares: l4_1
- label: "Omni · Qwen3-Omni Test" # - label: "Omni · Qwen3-Omni Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_omni.py # - tests/e2e/online_serving/test_qwen3_omni.py
- vllm_omni/model_executor/models/qwen3_omni/ # - vllm_omni/model_executor/models/qwen3_omni/
- vllm_omni/model_executor/stage_input_processors/qwen3_omni.py # - vllm_omni/model_executor/stage_input_processors/qwen3_omni.py
- vllm_omni/deploy/qwen3_omni_moe.yaml # - vllm_omni/deploy/qwen3_omni_moe.yaml
commands: # commands:
- | # - |
timeout 20m bash -c " # timeout 20m bash -c "
pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m 'core_model' --run-level 'core_model' # pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m 'core_model' --run-level 'core_model'
" # "
mirror_hardwares: h100_2 # mirror_hardwares: h100_2
- label: "Omni · MiniCPM-o 4.5 Offline Test" - label: "Omni · MiniCPM-o 4.5 Offline Test"
source_file_dependencies:
- tests/e2e/offline_inference/test_minicpmo_4_5.py
- vllm_omni/deploy/minicpmo_4_5.yaml
commands: commands:
- | - |
timeout 20m bash -c " timeout 20m bash -c "
pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py -m 'core_model' --run-level 'core_model' pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py -m 'core_model' --run-level 'core_model'
" "
agents: mirror_hardwares: h100_1
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources:
limits:
nvidia.com/gpu: 2
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
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: "Omni · MiniCPM-o 4.5 Online Test" - label: "Omni · MiniCPM-o 4.5 Online Test"
source_file_dependencies:
- tests/e2e/online_serving/test_minicpmo_4_5.py
- vllm_omni/deploy/minicpmo_4_5.yaml
commands: commands:
- | - |
timeout 20m bash -c " timeout 20m bash -c "
pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5.py -m 'core_model' --run-level 'core_model' pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5.py -m 'core_model' --run-level 'core_model'
" "
agents: mirror_hardwares: h100_1
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources:
limits:
nvidia.com/gpu: 2
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
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: "TTS · VoxCPM2 Test" - label: "Omni · MiniCPM-o 4.5 Duplex Test"
source_file_dependencies:
- tests/e2e/online_serving/test_voxcpm2_tts.py
- vllm_omni/model_executor/models/voxcpm2/
- vllm_omni/deploy/voxcpm2.yaml
timeout_in_minutes: 30
commands:
- |
timeout 30m bash -c "
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/online_serving/test_voxcpm2_tts.py -m 'core_model' --run-level 'core_model'
"
mirror_hardwares: l4_1
- label: "TTS · Qwen3-TTS CustomVoice Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_tts_customvoice.py
- vllm_omni/model_executor/models/qwen3_tts/
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
commands: commands:
- | - |
timeout 20m bash -c " timeout 20m bash -c "
export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'core_model and cuda' --run-level 'core_model'
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m 'core_model' --run-level 'core_model'
"
mirror_hardwares: l4_1
- label: "TTS · Qwen3-TTS Base Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_tts_base.py
- vllm_omni/model_executor/models/qwen3_tts/
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
commands:
- |
timeout 20m bash -c "
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
export VLLM_OMNI_USE_V2_RUNNER=1
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m 'core_model' --run-level 'core_model'
"
mirror_hardwares: l4_1
- label: "TTS · Higgs-Audio-V3 Test"
source_file_dependencies:
- tests/e2e/online_serving/test_higgs_audio_v3.py
- vllm_omni/model_executor/models/higgs_audio_v3/
- vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py
- vllm_omni/deploy/higgs_multimodal_qwen3.yaml
commands:
- |
timeout 20m bash -c "
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_USE_DEEP_GEMM=0
export VLLM_MOE_USE_DEEP_GEMM=0
pytest -s -v tests/e2e/online_serving/test_higgs_audio_v3.py -m 'core_model' --run-level 'core_model'
"
mirror_hardwares: l4_1
- label: "Diffusion · Qwen Image Edit Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image_edit.py
- vllm_omni/diffusion/models/qwen_image/
commands:
- |
timeout 20m bash -c "
pytest -s -v tests/e2e/online_serving/test_qwen_image_edit.py -m 'core_model' --run-level 'core_model'
" "
mirror_hardwares: h100_1 mirror_hardwares: h100_1
- label: "Diffusion · Bagel Test" - label: "Omni · MiniCPM-o 4.5 Perf Test"
timeout_in_minutes: 60
source_file_dependencies: source_file_dependencies:
- tests/e2e/online_serving/test_bagel.py - tests/dfx/perf/tests/test_minicpmo_4_5_ready.json
- vllm_omni/model_executor/models/bagel/ - vllm_omni/deploy/minicpmo_4_5.yaml
- vllm_omni/diffusion/models/bagel/ - vllm_omni/model_executor/models/minicpmo_4_5/
- vllm_omni/model_executor/stage_input_processors/bagel.py - vllm_omni/model_executor/models/step_audio2/
- vllm_omni/deploy/bagel.yaml - vllm_omni/model_executor/models/cosyvoice3/code2wav_core/
- vllm_omni/model_executor/models/cosyvoice3/utils.py
- vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py
commands: commands:
- | - export BENCHMARK_DIR=tests/dfx/perf/results
timeout 40m bash -c " - pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5_ready.json -m 'core_model and cuda'
export VLLM_IMAGE_FETCH_TIMEOUT=60
pytest -s -v tests/e2e/online_serving/test_bagel.py -m 'core_model' --run-level 'core_model'
"
mirror_hardwares: h100_1 mirror_hardwares: h100_1
- label: "Diffusion · Wan22 Test" # - label: "TTS · VoxCPM2 Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/online_serving/test_wan22_t2v.py # - tests/e2e/online_serving/test_voxcpm2_tts.py
- vllm_omni/diffusion/models/wan2_2/ # - vllm_omni/model_executor/models/voxcpm2/
commands: # - vllm_omni/deploy/voxcpm2.yaml
- | # timeout_in_minutes: 30
timeout 40m bash -c " # commands:
export VLLM_IMAGE_FETCH_TIMEOUT=60 # - |
pytest -s -v tests/e2e/online_serving/test_wan22_t2v.py -m 'core_model' --run-level 'core_model' # timeout 30m bash -c "
" # export VLLM_LOGGING_LEVEL=DEBUG
mirror_hardwares: h100_1 # export VLLM_WORKER_MULTIPROC_METHOD=spawn
# pytest -s -v tests/e2e/online_serving/test_voxcpm2_tts.py -m 'core_model' --run-level 'core_model'
# "
# mirror_hardwares: l4_1
- label: "Diffusion · Cosmos3 Test" # - label: "TTS · Qwen3-TTS CustomVoice Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/online_serving/test_cosmos3.py # - tests/e2e/online_serving/test_qwen3_tts_customvoice.py
- vllm_omni/diffusion/models/cosmos3/ # - vllm_omni/model_executor/models/qwen3_tts/
commands: # - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- | # - vllm_omni/deploy/qwen3_tts.yaml
timeout 40m bash -c " # commands:
export VLLM_IMAGE_FETCH_TIMEOUT=60 # - |
pytest -s -v tests/e2e/online_serving/test_cosmos3.py -m 'core_model' --run-level 'core_model' # timeout 20m bash -c "
" # export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
mirror_hardwares: h100_1 # pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m 'core_model' --run-level 'core_model'
# "
# mirror_hardwares: l4_1
- label: "Diffusion · Qwen Image Test" # - label: "TTS · Qwen3-TTS Base Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image.py # - tests/e2e/online_serving/test_qwen3_tts_base.py
- vllm_omni/diffusion/models/qwen_image/ # - vllm_omni/model_executor/models/qwen3_tts/
commands: # - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- | # - vllm_omni/deploy/qwen3_tts.yaml
timeout 40m bash -c " # commands:
export VLLM_IMAGE_FETCH_TIMEOUT=60 # - |
pytest -s -v tests/e2e/online_serving/test_qwen_image.py -m 'core_model' --run-level 'core_model' # timeout 20m bash -c "
" # export VLLM_WORKER_MULTIPROC_METHOD=spawn
mirror_hardwares: h100_1 # export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
# export VLLM_OMNI_USE_V2_RUNNER=1
# pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m 'core_model' --run-level 'core_model'
# "
# mirror_hardwares: l4_1
- label: "Diffusion · Qwen Image Layered Test" # - label: "TTS · Higgs-Audio-V3 Test"
source_file_dependencies: # source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image_layered.py # - tests/e2e/online_serving/test_higgs_audio_v3.py
- vllm_omni/diffusion/models/qwen_image/ # - vllm_omni/model_executor/models/higgs_audio_v3/
commands: # - vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py
- | # - vllm_omni/deploy/higgs_multimodal_qwen3.yaml
timeout 40m bash -c " # commands:
export VLLM_IMAGE_FETCH_TIMEOUT=60 # - |
pytest -s -v tests/e2e/online_serving/test_qwen_image_layered.py -m 'core_model' --run-level 'core_model' # timeout 20m bash -c "
" # export VLLM_WORKER_MULTIPROC_METHOD=spawn
mirror_hardwares: h100_1 # export VLLM_USE_DEEP_GEMM=0
# export VLLM_MOE_USE_DEEP_GEMM=0
# pytest -s -v tests/e2e/online_serving/test_higgs_audio_v3.py -m 'core_model' --run-level 'core_model'
# "
# mirror_hardwares: l4_1
- label: "Diffusion · GGUF Plugin Test" # - label: "Diffusion · Qwen Image Edit Test"
soft_fail: true # source_file_dependencies:
source_file_dependencies: # - tests/e2e/online_serving/test_qwen_image_edit.py
- tests/plugin/test_gguf_diffusion.py # - vllm_omni/diffusion/models/qwen_image/
- vllm_omni/diffusion/model_loader # commands:
commands: # - |
- pip install "vllm-gguf-plugin>=0.0.3" # timeout 20m bash -c "
- timeout 30m pytest -sv tests/plugin/test_gguf_diffusion.py # pytest -s -v tests/e2e/online_serving/test_qwen_image_edit.py -m 'core_model' --run-level 'core_model'
mirror_hardwares: l4_1 # "
# mirror_hardwares: h100_1
# - label: "Diffusion · Bagel Test"
# source_file_dependencies:
# - tests/e2e/online_serving/test_bagel.py
# - vllm_omni/model_executor/models/bagel/
# - vllm_omni/diffusion/models/bagel/
# - vllm_omni/model_executor/stage_input_processors/bagel.py
# - vllm_omni/deploy/bagel.yaml
# commands:
# - |
# timeout 40m bash -c "
# export VLLM_IMAGE_FETCH_TIMEOUT=60
# pytest -s -v tests/e2e/online_serving/test_bagel.py -m 'core_model' --run-level 'core_model'
# "
# mirror_hardwares: h100_1
# - label: "Diffusion · Wan22 Test"
# source_file_dependencies:
# - tests/e2e/online_serving/test_wan22_t2v.py
# - vllm_omni/diffusion/models/wan2_2/
# commands:
# - |
# timeout 40m bash -c "
# export VLLM_IMAGE_FETCH_TIMEOUT=60
# pytest -s -v tests/e2e/online_serving/test_wan22_t2v.py -m 'core_model' --run-level 'core_model'
# "
# mirror_hardwares: h100_1
# - label: "Diffusion · Cosmos3 Test"
# source_file_dependencies:
# - tests/e2e/online_serving/test_cosmos3.py
# - vllm_omni/diffusion/models/cosmos3/
# commands:
# - |
# timeout 40m bash -c "
# export VLLM_IMAGE_FETCH_TIMEOUT=60
# pytest -s -v tests/e2e/online_serving/test_cosmos3.py -m 'core_model' --run-level 'core_model'
# "
# mirror_hardwares: h100_1
# - label: "Diffusion · Qwen Image Test"
# source_file_dependencies:
# - tests/e2e/online_serving/test_qwen_image.py
# - vllm_omni/diffusion/models/qwen_image/
# commands:
# - |
# timeout 40m bash -c "
# export VLLM_IMAGE_FETCH_TIMEOUT=60
# pytest -s -v tests/e2e/online_serving/test_qwen_image.py -m 'core_model' --run-level 'core_model'
# "
# mirror_hardwares: h100_1
# - label: "Diffusion · Qwen Image Layered Test"
# source_file_dependencies:
# - tests/e2e/online_serving/test_qwen_image_layered.py
# - vllm_omni/diffusion/models/qwen_image/
# commands:
# - |
# timeout 40m bash -c "
# export VLLM_IMAGE_FETCH_TIMEOUT=60
# pytest -s -v tests/e2e/online_serving/test_qwen_image_layered.py -m 'core_model' --run-level 'core_model'
# "
# mirror_hardwares: h100_1
# - label: "Diffusion · GGUF Plugin Test"
# soft_fail: true
# source_file_dependencies:
# - tests/plugin/test_gguf_diffusion.py
# - vllm_omni/diffusion/model_loader
# commands:
# - pip install "vllm-gguf-plugin>=0.0.3"
# - timeout 30m pytest -sv tests/plugin/test_gguf_diffusion.py
# mirror_hardwares: l4_1
@@ -4,6 +4,10 @@
set -euo pipefail set -euo pipefail
# minicpm-challenge branch: skip entire Intel/XPU CI (no pipeline upload => no image build / tests).
echo "--- Skipping Intel/XPU CI on this branch (including image build)"
exit 0
source .buildkite/common/scripts/resolve_skip_ci.sh source .buildkite/common/scripts/resolve_skip_ci.sh
upload_pipeline() { upload_pipeline() {
+137 -34
View File
@@ -1,25 +1,118 @@
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
HF_HUB_DOWNLOAD_TIMEOUT: 300
HF_HUB_ETAG_TIMEOUT: 60
steps: steps:
- group: ":card_index_dividers: Diffusion X2V Model Test" # - group: ":card_index_dividers: Diffusion X2V Model Test"
key: nightly-diffusion-x2v-group # key: nightly-diffusion-x2v-group
# depends_on: upload-nightly-pipeline
# if: >-
# build.env("NIGHTLY") == "1" ||
# build.pull_request.labels includes "nightly-test" ||
# build.pull_request.labels includes "diffusion-x2v-test"
# steps:
# - label: ":full_moon: Diffusion X2V · Function Test"
# mirror_hardwares: a2b3_npu_4
# env:
# VLLM_WORKER_MULTIPROC_METHOD: spawn
# HF_TOKEN: "${HF_TOKEN}"
# commands:
# - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k npu_i2v --run-level "full_model"
# - group: ":card_index_dividers: TTS Model Test"
# key: nightly-tts-test-group
# depends_on: upload-nightly-pipeline
# if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "tts-test"
# steps:
# - label: ":full_moon: TTS · Perf Test"
# mirror_hardwares: a3_npu_2
# env:
# VLLM_WORKER_MULTIPROC_METHOD: spawn
# VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
# HF_TOKEN: "${HF_TOKEN}"
# commands:
# - export BENCHMARK_DIR=tests/dfx/perf/results
# - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
# - |
# set +e
# pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m npu --test-config-file tests/dfx/perf/tests/test_tts.json
# EXIT=$$?
# buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
# exit $$EXIT
- group: ":card_index_dividers: Omni Model Test"
key: nightly-omni-group
depends_on: upload-nightly-pipeline depends_on: upload-nightly-pipeline
if: >- if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "omni-test"
build.env("NIGHTLY") == "1" ||
build.pull_request.labels includes "nightly-test" ||
build.pull_request.labels includes "diffusion-x2v-test"
steps: steps:
- label: ":full_moon: Diffusion X2V · Function Test" - label: ":full_moon: Omni · MiniCPM-o 4.5 Test"
mirror_hardwares: a2b3_npu_4 timeout_in_minutes: 180
mirror_hardwares: a3_npu_2
env: env:
VLLM_WORKER_MULTIPROC_METHOD: spawn VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
HF_TOKEN: "${HF_TOKEN}" HF_TOKEN: "${HF_TOKEN}"
commands: commands:
- pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k npu_i2v --run-level "full_model" - pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5_expansion.py -m 'full_model and npu and A3 and omni' --run-level 'full_model'
- group: ":card_index_dividers: TTS Model Test" - label: ":full_moon: Omni · MiniCPM-o 4.5 · Duplex Test"
key: nightly-tts-test-group timeout_in_minutes: 180
depends_on: upload-nightly-pipeline mirror_hardwares: a3_npu_2
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "tts-test" env:
steps: VLLM_WORKER_MULTIPROC_METHOD: spawn
- label: ":full_moon: TTS · Perf Test" VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
HF_TOKEN: "${HF_TOKEN}"
commands:
# NPU has no merge-level pipeline, so advanced_model duplex coverage
# rides on nightly alongside full_model.
#
# test_duplex_soft_interrupt is CUDA-only and carries no npu mark, so
# the marker filter below drops it: it needs real-time duplex
# throughput that the current NPU stack does not reach.
- >-
pytest -s -v
tests/e2e/online_serving/test_minicpmo_4_5_duplex.py
tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py
-m "(full_model or advanced_model) and npu and A3 and omni"
--run-level full_model
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Accuracy Test"
timeout_in_minutes: 180
mirror_hardwares: a3_npu_2
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
HF_TOKEN: "${HF_TOKEN}"
commands:
- export SEED_TTS_WER_EVAL=1
- export SEED_TTS_SIM_EVAL=1
- export SEED_TTS_EVAL_DEVICE=npu:1
- |
set +e
pytest -s -v tests/e2e/accuracy/minicpmo_4_5/test_minicpmo_4_5.py -k "not videomme" -m "full_model and npu and A3" --run-level full_model
EXIT=$$?
buildkite-agent artifact upload "tests/e2e/accuracy/minicpmo_4_5/results/*.json"
exit $$EXIT
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Video-MME Accuracy Test"
key: nightly-omni-accuracy-minicpmo-4-5-videomme-npu
timeout_in_minutes: 180
mirror_hardwares: a3_npu_2
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
HF_TOKEN: "${HF_TOKEN}"
commands:
# Video-MME needs no repo override here: the NPU runners pre-stage the dataset under
# the lmms-eval mirror, which is now VIDEOMME_DEFAULT_HF_REPO, so snapshot_download
# resolves it straight out of the hub cache.
- |
set +e
pytest -s -v tests/e2e/accuracy/minicpmo_4_5/test_minicpmo_4_5.py -k videomme -m "full_model and npu and A3" --run-level full_model
EXIT=$$?
buildkite-agent artifact upload "tests/e2e/accuracy/minicpmo_4_5/results/*.json"
exit $$EXIT
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Perf Test"
key: nightly-omni-performance-minicpmo-4-5-npu
timeout_in_minutes: 180
mirror_hardwares: a3_npu_2 mirror_hardwares: a3_npu_2
env: env:
VLLM_WORKER_MULTIPROC_METHOD: spawn VLLM_WORKER_MULTIPROC_METHOD: spawn
@@ -27,32 +120,42 @@ steps:
HF_TOKEN: "${HF_TOKEN}" HF_TOKEN: "${HF_TOKEN}"
commands: commands:
- export BENCHMARK_DIR=tests/dfx/perf/results - export BENCHMARK_DIR=tests/dfx/perf/results
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- | - |
set +e set +e
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m npu --test-config-file tests/dfx/perf/tests/test_tts.json pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m "npu and A3" --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5.json
EXIT=$$? EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json" buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT exit $$EXIT
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Perf Test (A2)"
- group: ":card_index_dividers: Omni Model Test" key: nightly-omni-performance-minicpmo-4-5-npu-a2
key: nightly-omni-group timeout_in_minutes: 180
depends_on: upload-nightly-pipeline mirror_hardwares: a2b3_npu_1
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "omni-test" env:
steps: VLLM_WORKER_MULTIPROC_METHOD: spawn
- label: ":robot: Omni · MiniCPM-o 4.5 Test" VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
agents: HF_TOKEN: "${HF_TOKEN}"
queue: "ascend-a3" HF_HUB_OFFLINE: "1"
resource_class: "npu-2" commands:
image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a3" - export BENCHMARK_DIR=tests/dfx/perf/results
plugins: - |
- kubernetes: set +e
podSpecPatch: pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m "npu and A2" --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5.json
imagePullSecrets: EXIT=$$?
- name: swr-secret buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Duplex Seed-TTS Perf Test"
key: nightly-omni-performance-minicpmo-4-5-duplex-seed-tts-npu
timeout_in_minutes: 180
mirror_hardwares: a3_npu_2
env: env:
VLLM_WORKER_MULTIPROC_METHOD: spawn VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
HF_TOKEN: "${HF_TOKEN}" HF_TOKEN: "${HF_TOKEN}"
commands: commands:
- pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5_expansion.py -m 'full_model' --run-level 'full_model' - export BENCHMARK_DIR=tests/dfx/perf/results
- |
set +e
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m "npu and A3" --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5_duplex_seed_tts.json
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
exit $$EXIT
+32 -23
View File
@@ -3,32 +3,41 @@ steps:
key: ready-omni-group key: ready-omni-group
depends_on: upload-ready-pipeline depends_on: upload-ready-pipeline
steps: steps:
- label: ":robot: Omni · MiniCPM-o 4.5 Test" - label: "Omni · MiniCPM-o 4.5 Test"
agents: mirror_hardwares: a3_npu_2
queue: "ascend-a3"
resource_class: "npu-2"
image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a3"
plugins:
- kubernetes:
podSpecPatch:
imagePullSecrets:
- name: swr-secret
env: env:
VLLM_WORKER_MULTIPROC_METHOD: spawn VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
HF_TOKEN: "${HF_TOKEN}" HF_TOKEN: "${HF_TOKEN}"
commands: commands:
- pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5.py -m 'core_model' --run-level 'core_model' - pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py tests/e2e/online_serving/test_minicpmo_4_5.py -m 'core_model and npu and A3 and omni' --run-level 'core_model'
- label: "Omni · MiniCPM-o 4.5 Duplex Test"
mirror_hardwares: a3_npu_2
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_minicpmo_4_5_duplex.py -m 'core_model and npu and A3 and omni' --run-level 'core_model'
- label: "Omni · MiniCPM-o 4.5 Perf Test"
timeout_in_minutes: 60
mirror_hardwares: a3_npu_2
env:
VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
commands:
- export BENCHMARK_DIR=tests/dfx/perf/results
- pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5_ready.json -m 'core_model and npu and A3 and omni'
- group: ":card_index_dividers: TTS Model Test" # - group: ":card_index_dividers: TTS Model Test"
key: ready-tts-group # key: ready-tts-group
depends_on: upload-ready-pipeline # depends_on: upload-ready-pipeline
steps: # steps:
- label: ":sound: TTS · Qwen3-TTS CustomVoice Test" # - label: ":sound: TTS · Qwen3-TTS CustomVoice Test"
mirror_hardwares: a2b3_npu_1 # mirror_hardwares: a2b3_npu_1
env: # env:
VLLM_WORKER_MULTIPROC_METHOD: spawn # VLLM_WORKER_MULTIPROC_METHOD: spawn
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" # VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
HF_TOKEN: "${HF_TOKEN}" # HF_TOKEN: "${HF_TOKEN}"
commands: # commands:
- 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'
+6
View File
@@ -32,6 +32,7 @@ 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" \
"opencc==1.4.1" \
"psutil>=7.2.0" \ "psutil>=7.2.0" \
"soundfile>=0.13.1" \ "soundfile>=0.13.1" \
"pyttsx3>=2.99" \ "pyttsx3>=2.99" \
@@ -40,6 +41,11 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
"hyperpyyaml>=1.2.0" \ "hyperpyyaml>=1.2.0" \
"onnx>=1.16.0" \ "onnx>=1.16.0" \
"librosa>=0.10.0" \ "librosa>=0.10.0" \
"jiwer>=4.0.0" \
"zhon>=2.1.1" \
"zhconv>=1.4.3" \
"funasr>=1.3.0" \
"datasets>=3.0.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 && \
+6
View File
@@ -32,6 +32,7 @@ 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" \
"opencc==1.4.1" \
"psutil>=7.2.0" \ "psutil>=7.2.0" \
"soundfile>=0.13.1" \ "soundfile>=0.13.1" \
"pyttsx3>=2.99" \ "pyttsx3>=2.99" \
@@ -40,6 +41,11 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
"hyperpyyaml>=1.2.0" \ "hyperpyyaml>=1.2.0" \
"onnx>=1.16.0" \ "onnx>=1.16.0" \
"librosa>=0.10.0" \ "librosa>=0.10.0" \
"jiwer>=4.0.0" \
"zhon>=2.1.1" \
"zhconv>=1.4.3" \
"funasr>=1.3.0" \
"datasets>=3.0.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 && \
@@ -44,9 +44,11 @@ Pass **`--test-config-file`** to run one JSON file as-is, or omit it for the bul
"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": [500, 800], "H100": {
"mean_audio_ttfp_ms": [2000, 3500], "mean_ttft_ms": [500, 800],
"mean_audio_rtf": [0.25, 0.35] "mean_audio_ttfp_ms": [2000, 3500],
"mean_audio_rtf": [0.25, 0.35]
}
} }
} }
] ]
@@ -142,10 +144,6 @@ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py \
--test-config-file tests/dfx/perf/tests/test_bagel_vllm_omni.json --test-config-file tests/dfx/perf/tests/test_bagel_vllm_omni.json
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py \ pytest -s -v tests/dfx/perf/scripts/run_benchmark.py \
--test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json --test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json
# Optional baseline assertion (default off)
pytest -sv tests/dfx/perf/scripts/run_diffusion_benchmark.py --assert-baseline \
--test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
``` ```
See also [Markers for Tests](#markers-for-tests) for registered hardware markers (`H100`, `L4`, `cuda`, `distributed_cuda`, …). See also [Markers for Tests](#markers-for-tests) for registered hardware markers (`H100`, `L4`, `cuda`, `distributed_cuda`, …).
@@ -202,4 +200,4 @@ You can add any benchmark running parameters you need here. For all optional par
| num_prompts | int / array | Yes | 10,[10, 20, 30] | Number of requests. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of qps or max_concurrency, e.g., [10,10,10]. If an array is used, its length must match the number of qps or max_concurrency. | | num_prompts | int / array | Yes | 10,[10, 20, 30] | Number of requests. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of qps or max_concurrency, e.g., [10,10,10]. If an array is used, its length must match the number of qps or max_concurrency. |
| request_rate | float / array | No | 0.5, [0.5, 1, inf] | Queries per second. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of num_prompts, e.g., [1,1,1]. If an array is used, its length must match the number of num_prompts. | | request_rate | float / array | No | 0.5, [0.5, 1, inf] | Queries per second. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of num_prompts, e.g., [1,1,1]. If an array is used, its length must match the number of num_prompts. |
| max_concurrency | int / array | No | 1, [1, 2, 3] | Maximum concurrent in-flight requests. Same array / expansion rules as `request_rate` (mutually exclusive with QPS mode). | | max_concurrency | int / array | No | 1, [1, 2, 3] | Maximum concurrent in-flight requests. Same array / expansion rules as `request_rate` (mutually exclusive with QPS mode). |
| baseline | object | No | see above | Optional per-metric thresholds; keys must match benchmark output fields. Scalar, list (per sweep step), or object (keyed by concurrency or QPS string). | baseline | object | No | see below | Optional thresholds. **Hardware-nested only**: top-level keys must be `[hardware-resource]` pytest markers from `pyproject.toml` (`get_hardware_mark_list()`). Under each hardware key, metric names are free-form; values are scalars or sweep-aligned lists. Example: `{"H100": {"throughput_qps": […], "custom_stage_ms": 1.0}, "A3": {…}}`. Runtime filename aliases (`_RUNTIME_DEVICE_ALIASES`) are separate and do not expand this allowlist. |
@@ -1,8 +1,9 @@
# MiniCPM-o 4.5: Offline inference # MiniCPM-o 4.5: Offline inference
Two-stage pipeline: **thinker** (multimodal understanding) → **talker + Token2Wav** Three-stage pipeline: **Thinker** → **Talker** (codec) → **Code2Wav**
(24 kHz speech). Deploy config auto-loads from (24 kHz speech). Deploy config auto-loads from
`vllm_omni/deploy/minicpmo_4_5.yaml` (2-GPU default). `vllm_omni/deploy/minicpmo_4_5.yaml` (single-GPU default). For higher
throughput, pass `--deploy-config vllm_omni/deploy/minicpmo_4_5_2gpu.yaml`.
## Setup ## Setup
@@ -72,7 +73,7 @@ Supported `--query-type` values:
```bash ```bash
python end2end.py --query-type text \ python end2end.py --query-type text \
--deploy-config /path/to/vllm_omni/deploy/minicpmo_4_5_8x4090.yaml --deploy-config /path/to/vllm_omni/deploy/minicpmo_4_5_2gpu.yaml
``` ```
## Notes ## Notes
@@ -83,7 +84,8 @@ python end2end.py --query-type text \
- Output WAV is **24 kHz mono**. - Output WAV is **24 kHz mono**.
- Placeholders in the prompt are MiniCPM-style: - Placeholders in the prompt are MiniCPM-style:
`(<image>./</image>)`, `(<audio>./</audio>)`, `(<video>./</video>)`. `(<image>./</image>)`, `(<audio>./</audio>)`, `(<video>./</video>)`.
- Default layout needs **2 GPUs**. Async chunking is off in the bundled YAMLs. - Default layout needs **1 GPU**. Use `minicpmo_4_5_2gpu.yaml` for the
recommended two-GPU continuous-batching layout.
## Online serving ## Online serving
+11 -24
View File
@@ -16,7 +16,6 @@ import numpy as np
import soundfile as sf import soundfile as sf
import vllm import vllm
from PIL import Image from PIL import Image
from vllm import SamplingParams
from vllm.assets.audio import AudioAsset from vllm.assets.audio import AudioAsset
from vllm.assets.image import ImageAsset from vllm.assets.image import ImageAsset
from vllm.assets.video import VideoAsset, video_to_ndarrays from vllm.assets.video import VideoAsset, video_to_ndarrays
@@ -26,8 +25,6 @@ from vllm.multimodal.media.audio import load_audio
from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.omni import Omni
from vllm_omni.utils.tracking_parser import TrackingArgumentParser from vllm_omni.utils.tracking_parser import TrackingArgumentParser
SEED = 42
default_system = ( default_system = (
"You are MiniCPM-o, a helpful multimodal assistant that can " "You are MiniCPM-o, a helpful multimodal assistant that can "
"understand images, audio and video, and respond in text and speech." "understand images, audio and video, and respond in text and speech."
@@ -285,27 +282,17 @@ def main(args):
omni_kwargs["trust_remote_code"] = True omni_kwargs["trust_remote_code"] = True
omni = Omni(**omni_kwargs) omni = Omni(**omni_kwargs)
# Stage 0 (thinker): multimodal understanding → text (+ TTS span when enabled). # Per-stage sampling defaults live in the deploy YAML
thinker_sampling_params = SamplingParams( # (``vllm_omni/deploy/minicpmo_4_5*.yaml``, ``default_sampling_params``),
temperature=0.0, # which is the single source of truth that tracks the pipeline topology.
top_p=1.0, # Passing None makes ``resolve_sampling_params_list`` load exactly those,
top_k=-1, # so adding or removing a stage cannot desynchronize this example.
max_tokens=2048, #
seed=SEED, # This used to hardcode a two-element list built for the old two-stage
detokenize=True, # topology (thinker → talker+Token2Wav). Since Code2Wav became its own
repetition_penalty=1.1, # Stage 2, that list was both the wrong length and wrong in content
) # (talker ``max_tokens=1`` truncated the codec stream to one frame).
# Stage 1 (talker + Token2Wav): max_tokens=1 satisfies the scheduler; sampling_params_list = None
# waveform is produced in-process by Token2Wav.
talker_sampling_params = SamplingParams(
temperature=0.0,
top_p=1.0,
top_k=-1,
max_tokens=1,
seed=SEED,
detokenize=False,
)
sampling_params_list = [thinker_sampling_params, talker_sampling_params][: omni.num_stages]
if args.txt_prompts is None: if args.txt_prompts is None:
prompts = [query_result.inputs for _ in range(args.num_prompts)] prompts = [query_result.inputs for _ in range(args.num_prompts)]
+15 -14
View File
@@ -23,17 +23,17 @@ including `librosa`.
The deploy config auto-loads via `--omni`. The deploy config auto-loads via `--omni`.
The default `vllm_omni/deploy/minicpmo_4_5.yaml` keeps all three stages on The default `vllm_omni/deploy/minicpmo_4_5.yaml` keeps all three stages on
logical device 0 with memory budgets of 55%, 22%, and 22%. The profile admits logical device 0 with memory budgets of 55%, 15%, and 18%, leaving headroom
at most four sequences per stage and bounds startup video profiling to 32 for runtime kernels such as the HiFi-GAN vocoder's cuDNN workspace. The
frames per video. For throughput,`minicpmo_4_5_batching.yaml` gives the Thinker profile admits at most four sequences per stage. For throughput,
GPU 0 (90%) and colocates the Talker (55%) and Code2Wav (35%) on GPU 1. That `minicpmo_4_5_2gpu.yaml` gives the Thinker GPU 0 (90%) and colocates the
profile admits at most four concurrent sequences per stage. Talker (55%) and Code2Wav (35%) on GPU 1. That profile admits at most four
concurrent sequences per stage.
| deploy config | GPUs | Notes | | deploy config | GPUs | Notes |
|---|---|---| |---|---|---|
| `minicpmo_4_5.yaml` (default) | 1 | Memory-constrained compatibility layout. | | `minicpmo_4_5.yaml` (default) | 1 | Memory-constrained compatibility layout. |
| `minicpmo_4_5_batching.yaml` | 2 | Recommended continuous-batching layout; Talker and Code2Wav share GPU 1. | | `minicpmo_4_5_2gpu.yaml` | 2 | Recommended continuous-batching layout; Talker and Code2Wav share GPU 1. |
| `minicpmo_4_5_2gpu.yaml` | 2 | Backward-compatible alias for `minicpmo_4_5_batching.yaml`. |
| `minicpmo_4_5_3gpu.yaml` | 3 | One GPU per stage. | | `minicpmo_4_5_3gpu.yaml` | 3 | One GPU per stage. |
| `minicpmo_4_5_8x4090.yaml` | 8 | Full 8x4090 layout. | | `minicpmo_4_5_8x4090.yaml` | 8 | Full 8x4090 layout. |
| `minicpmo_4_5_duplex.yaml` | 1 | Experimental native full-duplex overlay. | | `minicpmo_4_5_duplex.yaml` | 1 | Experimental native full-duplex overlay. |
@@ -89,17 +89,18 @@ python openai_chat_completion_client_for_multimodal_generation.py \
--prompt "Briefly introduce yourself." --prompt "Briefly introduce yourself."
``` ```
Streaming text + audio: Streaming text + audio (use `--stream`):
```bash ```bash
python streaming_chat_completion.py \ python openai_chat_completion_client_for_multimodal_generation.py \
--base-url http://localhost:8099/v1 \ --query-type text \
--output minicpmo_stream.wav --prompt "Briefly introduce yourself." \
--port 8099 \
--stream
``` ```
The example prints text deltas immediately and joins the independently encoded The client prints text deltas as they arrive and saves streamed audio chunks
24 kHz WAV audio deltas into one valid WAV file. Add `--text-only` to skip to WAV files.
Talker and Code2Wav.
Shared helpers also work if you pass MiniCPM defaults yourself: Shared helpers also work if you pass MiniCPM defaults yourself:
@@ -6,6 +6,7 @@ class FullDuplexPcmPlayback extends AudioWorkletProcessor {
this.playedFrames = 0; this.playedFrames = 0;
this.underrunFrames = 0; this.underrunFrames = 0;
this.drain = null; this.drain = null;
this.terminalFadeFrames = 0;
this.started = false; this.started = false;
this.activeResponseId = null; this.activeResponseId = null;
this.initialBufferFrames = Math.round(sampleRate * 0.2); this.initialBufferFrames = Math.round(sampleRate * 0.2);
@@ -31,6 +32,7 @@ class FullDuplexPcmPlayback extends AudioWorkletProcessor {
} }
} else if (message.type === 'drain') { } else if (message.type === 'drain') {
this.drain = { responseId: message.responseId || null }; this.drain = { responseId: message.responseId || null };
this.terminalFadeFrames = Math.min(this.fadeFrames, this.bufferedFrames());
if (!this.started && this.bufferedFrames() > 0) { if (!this.started && this.bufferedFrames() > 0) {
this.bufferWaitFrames = 0; this.bufferWaitFrames = 0;
this.startPlayback(); this.startPlayback();
@@ -42,6 +44,7 @@ class FullDuplexPcmPlayback extends AudioWorkletProcessor {
this.playedFrames = 0; this.playedFrames = 0;
this.underrunFrames = 0; this.underrunFrames = 0;
this.drain = null; this.drain = null;
this.terminalFadeFrames = 0;
this.started = false; this.started = false;
this.activeResponseId = null; this.activeResponseId = null;
this.bufferWaitFrames = this.initialBufferFrames; this.bufferWaitFrames = this.initialBufferFrames;
@@ -76,6 +79,7 @@ class FullDuplexPcmPlayback extends AudioWorkletProcessor {
this.playedFrames = 0; this.playedFrames = 0;
this.underrunFrames = 0; this.underrunFrames = 0;
this.drain = null; this.drain = null;
this.terminalFadeFrames = 0;
this.started = false; this.started = false;
this.activeResponseId = null; this.activeResponseId = null;
this.bufferWaitFrames = this.initialBufferFrames; this.bufferWaitFrames = this.initialBufferFrames;
@@ -116,6 +120,7 @@ class FullDuplexPcmPlayback extends AudioWorkletProcessor {
while (target < output.length && this.queue.length > 0) { while (target < output.length && this.queue.length > 0) {
const pcm = this.queue[0]; const pcm = this.queue[0];
const count = Math.min(output.length - target, pcm.length - this.offset); const count = Math.min(output.length - target, pcm.length - this.offset);
const remainingBeforeChunk = this.bufferedFrames();
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
let sample = pcm[this.offset + index] / 32768; let sample = pcm[this.offset + index] / 32768;
if (this.fadeInFrames > 0) { if (this.fadeInFrames > 0) {
@@ -123,6 +128,16 @@ class FullDuplexPcmPlayback extends AudioWorkletProcessor {
sample *= elapsed / this.fadeFrames; sample *= elapsed / this.fadeFrames;
this.fadeInFrames -= 1; this.fadeInFrames -= 1;
} }
const remainingFrames = remainingBeforeChunk - index;
if (
this.drain
&& this.terminalFadeFrames > 0
&& remainingFrames <= this.terminalFadeFrames
) {
sample *= this.terminalFadeFrames === 1
? 0
: (remainingFrames - 1) / (this.terminalFadeFrames - 1);
}
output[target + index] = sample; output[target + index] = sample;
} }
target += count; target += count;
@@ -1,71 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Stream MiniCPM-o 4.5 text and audio from /v1/chat/completions."""
from __future__ import annotations
import argparse
import base64
import io
import wave
from pathlib import Path
from openai import OpenAI
def append_wav_chunk(encoded: str, pcm_parts: list[bytes]) -> tuple[int, int, int]:
"""Decode one base64 WAV delta and append its PCM frames."""
with wave.open(io.BytesIO(base64.b64decode(encoded)), "rb") as wav:
channels = wav.getnchannels()
sample_width = wav.getsampwidth()
sample_rate = wav.getframerate()
pcm_parts.append(wav.readframes(wav.getnframes()))
return channels, sample_width, sample_rate
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", default="http://localhost:8099/v1")
parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
parser.add_argument("--prompt", default="Say hello, then introduce vLLM in one sentence.")
parser.add_argument("--output", type=Path, default=Path("minicpmo_stream.wav"))
parser.add_argument("--text-only", action="store_true")
args = parser.parse_args()
modalities = ["text"] if args.text_only else ["text", "audio"]
stream = OpenAI(base_url=args.base_url, api_key="EMPTY").chat.completions.create(
model=args.model,
messages=[{"role": "user", "content": args.prompt}],
modalities=modalities,
stream=True,
extra_body={"chat_template_kwargs": {"use_tts_template": not args.text_only}},
)
pcm_parts: list[bytes] = []
audio_format: tuple[int, int, int] | None = None
for chunk in stream:
for choice in chunk.choices:
content = getattr(choice.delta, "content", None)
if not content:
continue
if getattr(chunk, "modality", None) == "audio":
current_format = append_wav_chunk(content, pcm_parts)
if audio_format is not None and current_format != audio_format:
raise ValueError(f"Audio format changed: {audio_format} -> {current_format}")
audio_format = current_format
elif getattr(chunk, "modality", None) == "text":
print(content, end="", flush=True)
print()
if pcm_parts and audio_format is not None:
channels, sample_width, sample_rate = audio_format
with wave.open(str(args.output), "wb") as wav:
wav.setnchannels(channels)
wav.setsampwidth(sample_width)
wav.setframerate(sample_rate)
wav.writeframes(b"".join(pcm_parts))
print(f"Audio saved to {args.output} ({sample_rate} Hz)")
if __name__ == "__main__":
main()
+10 -7
View File
@@ -243,13 +243,16 @@ markers = [
"npu: Tests that run on NPU/Ascend (auto-added)", "npu: Tests that run on NPU/Ascend (auto-added)",
"musa: Tests that run on MUSA/Moore Threads (auto-added)", "musa: Tests that run on MUSA/Moore Threads (auto-added)",
# specified computation resources marks (auto-added) # specified computation resources marks (auto-added)
"H100: Tests that require H100 GPU", # Tag with [hardware-resource] for tests.helpers.mark.get_hardware_mark_list().
"L4: Tests that require L4 GPU", "H100: [hardware-resource] Tests that require H100 GPU",
"MI325: Tests that require MI325 GPU (AMD/ROCm)", "H200: [hardware-resource] Tests that require H200 GPU",
"B60: Tests that require Intel Arc Pro B60 XPU", "L4: [hardware-resource] Tests that require L4 GPU",
"S5000: Tests that require S5000 GPU (Moore Threads/MUSA)", "B200: [hardware-resource] Tests that require B200 GPU",
"A2: Tests that require A2 NPU", "MI325: [hardware-resource] Tests that require MI325 GPU (AMD/ROCm)",
"A3: Tests that require A3 NPU", "B60: [hardware-resource] Tests that require Intel Arc Pro B60 XPU",
"S5000: [hardware-resource] Tests that require S5000 GPU (Moore Threads/MUSA)",
"A2: [hardware-resource] Tests that require A2 NPU",
"A3: [hardware-resource] Tests that require A3 NPU",
"distributed_cuda: Tests that require multi cards on CUDA platform", "distributed_cuda: Tests that require multi cards on CUDA platform",
"distributed_rocm: Tests that require multi cards on ROCm platform", "distributed_rocm: Tests that require multi cards on ROCm platform",
"distributed_xpu: Tests that require multi cards on XPU platform", "distributed_xpu: Tests that require multi cards on XPU platform",
+21 -21
View File
@@ -31,9 +31,8 @@ also provided.
- Default single-GPU compatibility layout (auto-loaded): - Default single-GPU compatibility layout (auto-loaded):
[`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml) [`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml)
- Recommended 2-GPU continuous-batching layout: - Recommended 2-GPU continuous-batching layout:
[`vllm_omni/deploy/minicpmo_4_5_batching.yaml`](../../vllm_omni/deploy/minicpmo_4_5_batching.yaml) [`vllm_omni/deploy/minicpmo_4_5_2gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_2gpu.yaml)
- 2-GPU and 3-GPU layouts: - 3-GPU layout:
[`vllm_omni/deploy/minicpmo_4_5_2gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_2gpu.yaml),
[`vllm_omni/deploy/minicpmo_4_5_3gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_3gpu.yaml) [`vllm_omni/deploy/minicpmo_4_5_3gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_3gpu.yaml)
- 8x RTX 4090 layout: - 8x RTX 4090 layout:
[`vllm_omni/deploy/minicpmo_4_5_8x4090.yaml`](../../vllm_omni/deploy/minicpmo_4_5_8x4090.yaml) [`vllm_omni/deploy/minicpmo_4_5_8x4090.yaml`](../../vllm_omni/deploy/minicpmo_4_5_8x4090.yaml)
@@ -68,8 +67,8 @@ Code2Wav consumes them through a shared-memory async connector.
MiniCPM-o 4.5 now requires the three-stage topology: the Talker owns MiniCPM-o 4.5 now requires the three-stage topology: the Talker owns
request-local codec generation and Code2Wav owns waveform state and request-local codec generation and Code2Wav owns waveform state and
reference-voice prompt features. `minicpmo_4_5.yaml` remains the stable reference-voice prompt features. `minicpmo_4_5.yaml` remains the stable
single-GPU entry point; `minicpmo_4_5_batching.yaml` is the recommended single-GPU entry point; `minicpmo_4_5_2gpu.yaml` is the recommended
two-GPU profile. The removed fused two-stage implementation is not retained as two-GPU continuous-batching profile. The removed fused two-stage implementation is not retained as
a fallback because it would duplicate state machines and correctness paths. a fallback because it would duplicate state machines and correctness paths.
## GPU ## GPU
@@ -79,9 +78,9 @@ a fallback because it would duplicate state machines and correctness paths.
The default The default
[`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml) [`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml)
co-locates Thinker, codec-only Talker, and Code2Wav on GPU 0. Their co-locates Thinker, codec-only Talker, and Code2Wav on GPU 0. Their
`gpu_memory_utilization` budgets are 0.55, 0.22, and 0.22. All stages admit `gpu_memory_utilization` budgets are 0.55, 0.15, and 0.18 so concurrent
up to four sequences. Startup video profiling is bounded to 32 frames per Code2Wav activations keep residual headroom. All stages admit up to four
video. Use the two-GPU profile for production throughput. sequences. Use the two-GPU profile for production throughput.
#### Environment #### Environment
@@ -105,7 +104,7 @@ The deploy config is auto-loaded by the model registry — no
For the recommended two-GPU layout, add: For the recommended two-GPU layout, add:
```bash ```bash
--deploy-config vllm_omni/deploy/minicpmo_4_5_batching.yaml --deploy-config vllm_omni/deploy/minicpmo_4_5_2gpu.yaml
``` ```
#### Performance comparison #### Performance comparison
@@ -175,16 +174,18 @@ in another choice's `message.audio.data` (24 kHz mono, see Notes). With
`modalities: ["text", "audio"]` you typically get two `choices` entries `modalities: ["text", "audio"]` you typically get two `choices` entries
(one text, one audio). (one text, one audio).
**Streaming text + speech**: **Streaming text + speech** (use `--stream`):
```bash ```bash
python examples/online_serving/minicpmo/streaming_chat_completion.py \ python examples/online_serving/minicpmo/openai_chat_completion_client_for_multimodal_generation.py \
--base-url http://localhost:8099/v1 \ --query-type text \
--output minicpmo_stream.wav --prompt "Say hello, then introduce vLLM in one sentence." \
--port 8099 \
--stream
``` ```
The client prints text deltas as they arrive and reconstructs one valid WAV The client prints text deltas as they arrive and saves streamed audio chunks
from the independently encoded audio deltas. to WAV files.
**Gradio demo (text + image + audio + video UI)**: **Gradio demo (text + image + audio + video UI)**:
@@ -202,8 +203,8 @@ speech output (TTS)"** checkbox on / off.
#### Notes #### Notes
- Memory budget: Thinker, Talker, and Code2Wav reserve 0.55, 0.22, and - Memory budget: Thinker, Talker, and Code2Wav reserve 0.55, 0.15, and
0.22 of GPU 0. The larger Thinker share protects its multimodal KV cache; 0.18 of GPU 0 so concurrent Code2Wav activations keep residual headroom;
all three model processes still share one CUDA device. all three model processes still share one CUDA device.
- `--trust-remote-code` is required — the HF repo ships a custom - `--trust-remote-code` is required — the HF repo ships a custom
`MiniCPMO` config / model class. `MiniCPMO` config / model class.
@@ -214,14 +215,13 @@ speech output (TTS)"** checkbox on / off.
contention. Talker AR contention. Talker AR
state and Code2Wav caches are request-owned; Code2Wav batches only state and Code2Wav caches are request-owned; Code2Wav batches only
exact-shape-compatible chunks and does not fall back to serial decode. exact-shape-compatible chunks and does not fall back to serial decode.
- `limit_mm_per_prompt.video.num_frames: 32` bounds startup dummy profiling, - Stage 0 pins `interleave_mm_strings=true` and
not runtime media decoding. Use `media_io_kwargs.video.num_frames` when a `media_io_kwargs.video={fps:1,num_frames:128}` for Daily-Omni MiniCPM packs.
matching URL/file video sampling limit is required.
- `StageRequestStats.batch_size` is a request-scoped placeholder, not the - `StageRequestStats.batch_size` is a request-scoped placeholder, not the
scheduler's execution batch. scheduler's execution batch.
- Single-GPU co-location trades throughput for hardware density: Stage 0/1 - Single-GPU co-location trades throughput for hardware density: Stage 0/1
CUDA Graph replay and eager Stage 2 vocoder kernels compete across three CUDA Graph replay and eager Stage 2 vocoder kernels compete across three
CUDA contexts. Use the 8x4090 config or a custom multi-GPU mapping for CUDA contexts. Use `minicpmo_4_5_2gpu.yaml` or a custom multi-GPU mapping for
throughput-sensitive serving. throughput-sensitive serving.
### 8 x RTX 4090 24GB (consumer-GPU layout) ### 8 x RTX 4090 24GB (consumer-GPU layout)
+5 -4
View File
@@ -164,10 +164,11 @@ curl -s http://localhost:8091/v1/chat/completions \
PR [#2495](https://github.com/vllm-project/vllm-omni/pull/2495) adds PR [#2495](https://github.com/vllm-project/vllm-omni/pull/2495) adds
performance CI configs for the same DiT-only settings. The CI step is performance CI configs for the same DiT-only settings. The CI step is
currently opt-in (gated by `RUN_HUNYUAN_IMAGE3_PERF=1`) with `soft_fail` currently opt-in (gated by `RUN_HUNYUAN_IMAGE3_PERF=1`) with `soft_fail`
enabled, intended for initial data collection. Performance assertions are enabled, intended for initial data collection. Perf runners no longer
skipped (`skip-performance-assertion: true`); the baseline values in the compare metrics against JSON baselines at assert time; the `baseline`
JSON configs are reference-only and will be promoted to regression gates blocks in the configs are reference-only and are copied into result JSON
once enough nightly data has been collected. for reporting. Regression gates can be reintroduced once enough nightly
data has been collected.
The user-facing equivalent is to launch one of the CLI commands above and The user-facing equivalent is to launch one of the CLI commands above and
generate 1024x1024 images with 50 denoising steps. generate 1024x1024 images with 50 denoising steps.
Binary file not shown.
Binary file not shown.
+159 -1
View File
@@ -6,13 +6,21 @@ Unit tests for patch.py
""" """
import asyncio import asyncio
import base64
import json import json
import time
from types import SimpleNamespace
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from vllm.benchmarks.lib.endpoint_request_func import RequestFuncInput from vllm.benchmarks.lib.endpoint_request_func import RequestFuncInput
from vllm_omni.benchmarks.patch.patch import MixRequestFuncOutput, async_request_openai_chat_omni_completions from vllm_omni.benchmarks.patch.patch import (
MixRequestFuncOutput,
async_request_openai_chat_omni_completions,
async_request_openai_realtime_duplex,
)
from vllm_omni.experimental.fullduplex.client import RealtimeEventCollector
pytestmark = [pytest.mark.core_model, pytest.mark.benchmark, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.benchmark, pytest.mark.cpu]
@@ -40,6 +48,156 @@ class MockResponse:
pass pass
@pytest.mark.asyncio
async def test_seed_tts_realtime_duplex_exports_per_request_metrics(monkeypatch):
class FakeRealtimeClient:
last_instance = None
def __init__(self, url):
assert url == "ws://localhost:8000/v1/realtime?duplex=1"
self.events = RealtimeEventCollector()
self.configure_kwargs = None
self.sent = []
self.response_count = 0
self.ack_count = 0
type(self).last_instance = self
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def configure(self, model, **kwargs):
assert model == "openbmb/MiniCPM-o-4_5"
self.configure_kwargs = kwargs
async def send(self, event):
self.sent.append(event)
if event["type"] != "response.create":
return
self.response_count += 1
response_id = f"resp-{self.response_count}"
now = time.monotonic()
self.events.add(
{"type": "response.created", "response": {"id": response_id}},
received_at_s=now + 0.01,
)
self.events.add(
{
"type": "response.audio_transcript.delta",
"response_id": response_id,
"delta": f"turn {self.response_count}",
},
received_at_s=now + 0.02,
)
self.events.add(
{
"type": "response.audio.delta",
"response_id": response_id,
"delta": base64.b64encode(b"\x00\x00" * 2400).decode(),
"sample_rate_hz": 24_000,
"metadata": {"audio_duration_ms": 100},
},
received_at_s=now + 0.03,
)
self.events.add(
{"type": "response.done", "response": {"id": response_id}},
received_at_s=now + 0.04,
)
async def acknowledge_playback(self):
self.ack_count += 1
async def close_session(self, **_kwargs):
return None
monkeypatch.setattr(
"vllm_omni.benchmarks.patch.patch.RealtimeDuplexClient",
FakeRealtimeClient,
)
request_input = RequestFuncInput(
model="openbmb/MiniCPM-o-4_5",
model_name="openbmb/MiniCPM-o-4_5",
prompt="hello",
api_url="http://localhost:8000/v1/realtime",
prompt_len=1,
output_len=20,
logprobs=None,
multi_modal_content=None,
ignore_eos=False,
extra_body={"save_duplex_request_metrics": True},
)
request_input.seed_tts_speech_extra = {"ref_audio": "data:audio/wav;base64,AAAA"}
request_input.seed_tts_system_prompt = "Speak exactly."
request_input.seed_tts_turns = tuple(
SimpleNamespace(utterance_id=f"utt-{index}", target_text=f"text {index}") for index in range(4)
)
output = await async_request_openai_realtime_duplex(
request_input,
session=None,
)
client = FakeRealtimeClient.last_instance
assert client.configure_kwargs["native_duplex"] is False
assert client.configure_kwargs["extra_body"] == {
"ref_audio": "data:audio/wav;base64,AAAA",
}
assert client.sent == [
event
for index in range(4)
for event in (
{
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": f"text {index}"}],
},
},
{"type": "response.create"},
)
]
assert client.ack_count == 4
assert output.success is True
assert output.generated_text == "turn 1 turn 2 turn 3 turn 4"
assert output.audio_duration == pytest.approx(0.4)
assert output.ttft > 0
assert output.audio_ttfp > output.ttft
assert output.audio_rtf > 0
assert output.latency > 0
assert output.tts_turn_pcm_bytes == [b"\x00\x00" * 2400] * 4
assert output.tts_output_pcm_bytes == b"\x00\x00" * 9600
session_id = output.duplex_request_metrics[0]["session_id"]
assert len(output.duplex_request_metrics) == 4
for index, metrics in enumerate(output.duplex_request_metrics):
assert metrics == {
"session_id": session_id,
"request_index": index,
"utterance_id": f"utt-{index}",
"response_id": f"resp-{index + 1}",
"source": "client_monotonic_receive",
"measurement_origin": {
"ttft": "conversation.item.create client send to first non-empty text delta",
"ttfp": "conversation.item.create client send to first audio packet",
"rtf": "request-start-to-last-audio receive time divided by emitted audio duration",
},
"ttft_ms": pytest.approx(20.0, abs=2.0),
"ttfp_ms": pytest.approx(30.0, abs=2.0),
"rtf": pytest.approx(0.3, abs=0.03),
"audio_generation_ms": pytest.approx(30.0, abs=2.0),
"audio_duration_ms": 100.0,
}
assert output.duplex_session_metrics == {
"session_id": session_id,
"audio_turn_count": 4,
"mean_ttft_ms": pytest.approx(20.0, abs=2.0),
"mean_ttfp_ms": pytest.approx(30.0, abs=2.0),
"mean_rtf": pytest.approx(0.3, abs=0.03),
}
def create_sse_chunk(data_dict): def create_sse_chunk(data_dict):
"""Helper to create SSE formatted chunk""" """Helper to create SSE formatted chunk"""
return f"data: {json.dumps(data_dict)}\n\n".encode() return f"data: {json.dumps(data_dict)}\n\n".encode()
+23 -1
View File
@@ -42,7 +42,10 @@ from benchmarks.accuracy.text_to_image.gbench import (
summarize_generated_records as summarize_gebench_generated_records, summarize_generated_records as summarize_gebench_generated_records,
summarize_gebench_results, summarize_gebench_results,
) )
from tests.e2e.accuracy.qwen3_omni.qwen3_omni_acc_bench_core import seed_tts_bench_argv from tests.e2e.accuracy.qwen3_omni.qwen3_omni_acc_bench_core import (
build_serve_common_argv,
seed_tts_bench_argv,
)
from tests.e2e.accuracy.qwen3_omni.run_qwen_omni_acc_benchmark import sync_dataset_env_from_ns from tests.e2e.accuracy.qwen3_omni.run_qwen_omni_acc_benchmark import sync_dataset_env_from_ns
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import resolve_seed_tts_root from vllm_omni.benchmarks.data_modules.seed_tts_dataset import resolve_seed_tts_root
@@ -57,6 +60,25 @@ def test_seed_tts_bench_argv_preserves_hf_repo_id_from_env(monkeypatch):
assert argv[dataset_idx + 1] == "zhaochenyang20/seed-tts-eval" assert argv[dataset_idx + 1] == "zhaochenyang20/seed-tts-eval"
def test_build_serve_common_argv_accepts_realtime_seed_tts_route(tmp_path):
argv = build_serve_common_argv(
host="127.0.0.1",
port=8000,
model="openbmb/MiniCPM-o-4_5",
num_prompts=4,
max_concurrency=1,
num_warmups=0,
percentile_metrics="ttft,audio_ttfp,audio_rtf",
result_dir=tmp_path,
result_filename="result.json",
backend="openai-realtime-tts",
endpoint="/v1/realtime",
)
assert argv[argv.index("--backend") + 1] == "openai-realtime-tts"
assert argv[argv.index("--endpoint") + 1] == "/v1/realtime"
def test_sync_dataset_env_preserves_seed_tts_hf_repo_id(monkeypatch): def test_sync_dataset_env_preserves_seed_tts_hf_repo_id(monkeypatch):
ns = argparse.Namespace( ns = argparse.Namespace(
daily_omni_repo=None, daily_omni_repo=None,
@@ -0,0 +1,152 @@
# SPDX-License-Identifier: Apache-2.0
"""Offline Daily-Omni sources: local mirrors must not fall back to the Hub id."""
import json
import os
import tarfile
from pathlib import Path
import pytest
from vllm_omni.benchmarks.data_modules.daily_omni_dataset import (
DailyOmniDataset,
daily_omni_local_qa_json,
daily_omni_local_videos_dir,
ensure_daily_omni_hub_root,
resolve_daily_omni_local_root,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.benchmark]
_HUB_ID = "liarliar/Daily-Omni"
def _write_qa_json(root: Path) -> Path:
qa = root / "qa.json"
qa.write_text(json.dumps([{"Question": "q", "Answer": "A", "video_id": "vid0"}]), encoding="utf-8")
return qa
def _stage_offline_hub_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, with_qa: bool = True) -> Path:
"""Build a hub cache holding ``liarliar/Daily-Omni`` and cut off all network access.
Reproduces the air-gapped NPU runner: the dataset is pre-staged with ``hf download``, so it
only exists in the *hub* cache -- never in the ``datasets`` cache that ``load_dataset`` reads.
"""
constants = pytest.importorskip("huggingface_hub").constants
cache = tmp_path / "hub"
repo_dir = cache / "datasets--liarliar--Daily-Omni"
snapshot = repo_dir / "snapshots" / "deadbeef"
snapshot.mkdir(parents=True)
if with_qa:
_write_qa_json(snapshot)
(repo_dir / "refs").mkdir()
(repo_dir / "refs" / "main").write_text("deadbeef", encoding="utf-8")
monkeypatch.setattr(constants, "HF_HUB_CACHE", str(cache))
monkeypatch.setattr(constants, "HF_HUB_OFFLINE", True)
return snapshot
def test_resolve_local_root_returns_none_for_hub_id() -> None:
assert resolve_daily_omni_local_root("liarliar/Daily-Omni") is None
assert resolve_daily_omni_local_root("") is None
assert resolve_daily_omni_local_root(None) is None
def test_plain_local_mirror(tmp_path: Path) -> None:
_write_qa_json(tmp_path)
(tmp_path / "Videos" / "vid0").mkdir(parents=True)
root = resolve_daily_omni_local_root(str(tmp_path))
assert root == tmp_path.resolve()
assert daily_omni_local_qa_json(root) == (tmp_path / "qa.json").resolve()
assert daily_omni_local_videos_dir(root) == root / "Videos"
def test_hf_cache_dir_resolves_to_snapshot(tmp_path: Path) -> None:
cache_dir = tmp_path / "datasets--liarliar--Daily-Omni"
snapshot = cache_dir / "snapshots" / "deadbeef"
snapshot.mkdir(parents=True)
_write_qa_json(snapshot)
(cache_dir / "refs").mkdir()
(cache_dir / "refs" / "main").write_text("deadbeef", encoding="utf-8")
root = resolve_daily_omni_local_root(str(cache_dir))
assert root == snapshot.resolve()
assert daily_omni_local_qa_json(root) == (snapshot / "qa.json").resolve()
def test_hf_cache_dir_without_refs_picks_newest_snapshot(tmp_path: Path) -> None:
cache_dir = tmp_path / "datasets--liarliar--Daily-Omni"
older = cache_dir / "snapshots" / "aaa"
newer = cache_dir / "snapshots" / "bbb"
older.mkdir(parents=True)
newer.mkdir(parents=True)
os.utime(older, (1, 1))
os.utime(newer, (10, 10))
assert resolve_daily_omni_local_root(str(cache_dir)) == newer.resolve()
def test_videos_tar_is_extracted(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HF_HOME", str(tmp_path / "hf_home"))
root = tmp_path / "mirror"
(root / "Videos" / "vid0").mkdir(parents=True)
(root / "Videos" / "vid0" / "vid0_video.mp4").write_bytes(b"fake")
with tarfile.open(root / "Videos.tar", "w") as tf:
tf.add(root / "Videos", arcname="Videos")
# Only the tarball is left in the mirror.
for child in (root / "Videos").rglob("*"):
if child.is_file():
child.unlink()
(root / "Videos" / "vid0").rmdir()
(root / "Videos").rmdir()
videos_dir = daily_omni_local_videos_dir(root)
assert videos_dir is not None
assert (videos_dir / "vid0" / "vid0_video.mp4").is_file()
# Second call reuses the extraction instead of redoing it.
assert daily_omni_local_videos_dir(root) == videos_dir
def test_missing_qa_json_reports_none(tmp_path: Path) -> None:
root = resolve_daily_omni_local_root(str(tmp_path))
assert daily_omni_local_qa_json(root) is None
assert daily_omni_local_videos_dir(root) is None
def test_hub_id_resolves_to_offline_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
snapshot = _stage_offline_hub_cache(tmp_path, monkeypatch)
root = ensure_daily_omni_hub_root(_HUB_ID)
assert root == snapshot.resolve()
assert daily_omni_local_qa_json(root) == (snapshot / "qa.json").resolve()
def test_dataset_loads_hub_id_without_network(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""The regression this file exists for: a Hub id must not require ``load_dataset``."""
snapshot = _stage_offline_hub_cache(tmp_path, monkeypatch)
# Any use of `datasets` here means we went back to the path that cannot read the hub cache.
monkeypatch.setattr("vllm_omni.benchmarks.data_modules.daily_omni_dataset.load_dataset", None)
dataset = DailyOmniDataset(dataset_path=_HUB_ID, random_seed=0)
assert dataset.qa_json_path == (snapshot / "qa.json").resolve()
assert [row["video_id"] for row in dataset.data] == ["vid0"]
def test_uncached_hub_id_reports_both_attempts(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_stage_offline_hub_cache(tmp_path, monkeypatch, with_qa=False)
monkeypatch.setattr("vllm_omni.benchmarks.data_modules.daily_omni_dataset.load_dataset", None)
with pytest.raises(RuntimeError) as excinfo:
DailyOmniDataset(dataset_path="someone-else/Daily-Omni", random_seed=0)
message = str(excinfo.value)
assert "someone-else/Daily-Omni" in message
assert "snapshot_download failed" in message
assert "--daily-omni-qa-json" in message
@@ -0,0 +1,237 @@
"""Tests for Daily-Omni ``--daily-omni-pack-mode`` packing recipes.
The ``minicpm-interleave`` mode is an accuracy-critical protocol: MiniCPM-o only reaches
OpenBMB's reported Daily-Omni score when frames and 1s audio segments arrive strictly
interleaved, with ``max_slice_nums=1`` / ``use_image_id=False`` and no Qwen system prompt.
These tests pin that contract so a refactor cannot silently regress the score.
vllm stubs are installed by tests/benchmarks/conftest.py before collection.
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import numpy as np
import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# Load the data module directly (bypasses vllm_omni.__init__ heavy imports).
_REPO_ROOT = Path(__file__).resolve().parents[2]
_MODULE_PATH = _REPO_ROOT / "vllm_omni" / "benchmarks" / "data_modules" / "daily_omni_dataset.py"
_MODULE_NAME = "vllm_omni.benchmarks.data_modules.daily_omni_dataset"
if _MODULE_NAME not in sys.modules:
_spec = importlib.util.spec_from_file_location(_MODULE_NAME, _MODULE_PATH)
_mod = importlib.util.module_from_spec(_spec)
sys.modules[_MODULE_NAME] = _mod
_spec.loader.exec_module(_mod)
from vllm_omni.benchmarks.data_modules.daily_omni_dataset import ( # noqa: E402
DailyOmniDataset,
_uniform_sample_indices,
)
_VIDEO_ID = "vid001"
_NUM_FRAMES = 5
@pytest.fixture
def video_dir(tmp_path: Path) -> Path:
"""Official Daily-Omni ``Videos/`` layout with placeholder media files."""
per_video = tmp_path / "Videos" / _VIDEO_ID
per_video.mkdir(parents=True)
(per_video / f"{_VIDEO_ID}_video.mp4").write_bytes(b"fake-mp4")
(per_video / f"{_VIDEO_ID}_audio.wav").write_bytes(b"fake-wav")
return tmp_path / "Videos"
@pytest.fixture
def qa_json(tmp_path: Path) -> Path:
path = tmp_path / "qa.json"
path.write_text(
json.dumps(
[
{
"video_id": _VIDEO_ID,
"question": "What happens first?",
"choice": {"A": "a", "B": "b", "C": "c", "D": "d"},
"answer": "A",
"task_type": "AV Event Alignment",
"video_duration": "30s",
}
]
),
encoding="utf-8",
)
return path
def _make_dataset(qa_json: Path, video_dir: Path, **kwargs) -> DailyOmniDataset:
return DailyOmniDataset(
qa_json_path=str(qa_json),
video_dir=str(video_dir),
disable_shuffle=True,
**kwargs,
)
def _stub_extract(monkeypatch, dataset: DailyOmniDataset, counter: list[int]) -> None:
"""Replace decord/ffmpeg extraction with deterministic in-memory frames + segments."""
from PIL import Image
def fake_extract(video_path, *, audio_path, include_audio, **_kw):
counter.append(1)
frames = [Image.new("RGB", (16, 16), color=(i, i, i)) for i in range(_NUM_FRAMES)]
segments = [np.zeros(1600, dtype=np.float32) for _ in range(_NUM_FRAMES)] if include_audio else []
return frames, segments
monkeypatch.setattr(dataset, "_extract_minicpm_frame_audio_segments", fake_extract)
# ---------------------------------------------------------------------------
# _uniform_sample_indices
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(("n", "k"), [(10, 4), (100, 64), (7, 7), (3, 5), (1, 1)])
def test_uniform_sample_indices_matches_numpy_linspace(n: int, k: int) -> None:
got = _uniform_sample_indices(n, k)
if n <= k:
assert got == list(range(n))
else:
assert got == [int(i) for i in np.linspace(0, n - 1, k, dtype=int)]
def test_uniform_sample_indices_non_positive_k() -> None:
assert _uniform_sample_indices(10, 0) == []
assert _uniform_sample_indices(10, -1) == []
# ---------------------------------------------------------------------------
# minicpm-interleave packing
# ---------------------------------------------------------------------------
def test_minicpm_interleave_alternates_image_and_audio(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all")
_stub_extract(monkeypatch, ds, [])
parts, extra, position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert position == "first"
assert [p["type"] for p in parts] == ["image_url", "audio_url"] * _NUM_FRAMES
assert extra["mm_processor_kwargs"] == {
"use_audio_in_video": False,
"max_slice_nums": 1,
"use_image_id": False,
}
def test_minicpm_interleave_visual_mode_emits_frames_only(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="visual")
_stub_extract(monkeypatch, ds, [])
parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert [p["type"] for p in parts] == ["image_url"] * _NUM_FRAMES
def test_minicpm_interleave_writes_file_urls_under_video_dir(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all")
_stub_extract(monkeypatch, ds, [])
parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
segment_dir = video_dir / ".minicpm_daily_omni_interleave" / _VIDEO_ID
for part in parts:
url = part[part["type"]]["url"]
assert url.startswith("file://")
assert Path(url[len("file://") :]).parent == segment_dir.resolve()
assert sorted(p.name for p in segment_dir.glob("frame_*.jpg")) == [f"frame_{i:04d}.jpg" for i in range(_NUM_FRAMES)]
def test_minicpm_interleave_inline_mode_emits_data_urls(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(
qa_json,
video_dir,
pack_mode="minicpm-interleave",
input_mode="all",
inline_local_video=True,
)
_stub_extract(monkeypatch, ds, [])
parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert parts[0]["image_url"]["url"].startswith("data:image/jpeg;base64,")
assert parts[1]["audio_url"]["url"].startswith("data:audio/wav;base64,")
assert not (video_dir / ".minicpm_daily_omni_interleave").exists()
def test_minicpm_interleave_caches_file_parts_but_not_inline_payloads(monkeypatch, qa_json, video_dir) -> None:
"""Inline base64 must not be retained: ~700 Daily-Omni videos would pin GBs of RSS."""
file_calls: list[int] = []
ds_file = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all")
_stub_extract(monkeypatch, ds_file, file_calls)
ds_file._compose_daily_omni_multimodal(_VIDEO_ID, None)
ds_file._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert len(file_calls) == 1
assert ds_file._minicpm_interleave_cache
inline_calls: list[int] = []
ds_inline = _make_dataset(
qa_json,
video_dir,
pack_mode="minicpm-interleave",
input_mode="all",
inline_local_video=True,
)
_stub_extract(monkeypatch, ds_inline, inline_calls)
ds_inline._compose_daily_omni_multimodal(_VIDEO_ID, None)
ds_inline._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert len(inline_calls) == 2
assert not ds_inline._minicpm_interleave_cache
def test_minicpm_interleave_missing_local_video_returns_none(qa_json, tmp_path) -> None:
empty = tmp_path / "empty_videos"
empty.mkdir()
ds = _make_dataset(qa_json, empty, pack_mode="minicpm-interleave", input_mode="all")
parts, extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert parts is None
assert extra is None
# ---------------------------------------------------------------------------
# System prompt selection
# ---------------------------------------------------------------------------
def test_minicpm_interleave_omits_qwen_system_message(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all")
_stub_extract(monkeypatch, ds, [])
parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
messages = ds._build_daily_omni_openai_messages(parts, "q?", {"A": "a"})
assert [m["role"] for m in messages] == ["user"]
def test_qwen_pack_mode_keeps_system_message(qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="qwen", input_mode="all")
messages = ds._build_daily_omni_openai_messages([], "q?", {"A": "a"})
assert [m["role"] for m in messages] == ["system", "user"]
assert "Qwen" in messages[0]["content"][0]["text"]
def test_invalid_pack_mode_rejected(qa_json, video_dir) -> None:
with pytest.raises(ValueError, match="pack_mode must be"):
_make_dataset(qa_json, video_dir, pack_mode="bogus")
@@ -28,10 +28,13 @@ if _MODULE_NAME not in sys.modules:
_spec.loader.exec_module(_mod) _spec.loader.exec_module(_mod)
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import ( # noqa: E402 from vllm_omni.benchmarks.data_modules.seed_tts_dataset import ( # noqa: E402
SeedTTSDataset,
SeedTTSDesignDataset, SeedTTSDesignDataset,
SeedTTSDesignSampleRequest, SeedTTSDesignSampleRequest,
SeedTTSSampleRequest,
SeedTTSTextDataset, SeedTTSTextDataset,
SeedTTSTextSampleRequest, SeedTTSTextSampleRequest,
SeedTTSTurn,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -70,6 +73,94 @@ def mock_tokenizer(mocker):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_seed_tts_dataset_groups_four_turns_with_one_reference(seed_tts_root, mock_tokenizer):
ds = SeedTTSDataset(
dataset_path=str(seed_tts_root),
random_seed=0,
locale="en",
disable_shuffle=True,
)
requests = ds.sample(
mock_tokenizer,
num_requests=1,
turns_per_session=4,
no_oversample=True,
)
assert len(requests) == 1
request = requests[0]
assert [turn.utterance_id for turn in request.seed_tts_turns] == [
"utt000",
"utt001",
"utt002",
"utt003",
]
assert [turn.target_text for turn in request.seed_tts_turns] == [
"target text 0",
"target text 1",
"target text 2",
"target text 3",
]
assert request.seed_tts_speech_extra["ref_text"] == "ref text 0"
assert request.seed_tts_ref_wav_path.endswith("utt000.wav")
def test_seed_tts_eval_expands_grouped_pcm_by_turn():
from vllm_omni.benchmarks.data_modules.seed_tts_eval import (
_expand_seed_tts_turn_outputs,
)
request = SeedTTSSampleRequest(
prompt="target 0",
prompt_len=4,
expected_output_len=100,
multi_modal_data=None,
request_id="session-0",
seed_tts_turns=tuple(
SeedTTSTurn(utterance_id=f"utt-{index}", target_text=f"target {index}") for index in range(4)
),
)
output = types.SimpleNamespace(
success=True,
tts_turn_pcm_bytes=[bytes([index]) for index in range(4)],
)
requests, outputs = _expand_seed_tts_turn_outputs([request], [output])
assert [item.prompt for item in requests] == [f"target {index}" for index in range(4)]
assert [item.seed_tts_utterance_id for item in requests] == [f"utt-{index}" for index in range(4)]
assert [item.tts_output_pcm_bytes for item in outputs] == [bytes([index]) for index in range(4)]
def test_seed_tts_eval_keeps_session_pcm_for_single_turn_chat():
"""Chat-omni Seed-TTS sets session PCM only; expand must not wipe it."""
from vllm_omni.benchmarks.data_modules.seed_tts_eval import (
_expand_seed_tts_turn_outputs,
)
request = SeedTTSSampleRequest(
prompt="target 0",
prompt_len=4,
expected_output_len=100,
multi_modal_data=None,
request_id="session-0",
seed_tts_turns=(SeedTTSTurn(utterance_id="utt-0", target_text="target 0"),),
)
pcm = b"\x01\x02\x03\x04"
output = types.SimpleNamespace(
success=True,
tts_output_pcm_bytes=pcm,
tts_turn_pcm_bytes=None,
)
requests, outputs = _expand_seed_tts_turn_outputs([request], [output])
assert len(requests) == 1
assert requests[0].prompt == "target 0"
assert outputs[0].tts_output_pcm_bytes == pcm
def test_seed_tts_text_dataset_omits_ref_audio(seed_tts_root, mock_tokenizer): def test_seed_tts_text_dataset_omits_ref_audio(seed_tts_root, mock_tokenizer):
ds = SeedTTSTextDataset( ds = SeedTTSTextDataset(
dataset_path=str(seed_tts_root), dataset_path=str(seed_tts_root),
@@ -242,9 +242,52 @@ def test_explicit_streaming_payload_replaces_placeholder_prompt() -> None:
"tts_token_ids": [10, 20], "tts_token_ids": [10, 20],
"meta": {"replace_streaming_prompt": True}, "meta": {"replace_streaming_prompt": True},
} }
update.model_intermediate_buffer = {
"ids": {"tts": [41, 42, 99]},
"meta": {"turn_eos_token_id": 99},
}
sched._update_request_as_session(session, update) sched._update_request_as_session(session, update)
assert session.prompt_token_ids == [10, 20] assert session.prompt_token_ids == [10, 20]
assert session.additional_information == update.additional_information assert session.additional_information == update.additional_information
assert session.model_intermediate_buffer == {
"ids": {"tts": [41, 42, 99]},
"meta": {"turn_eos_token_id": 99},
}
assert session.status == RequestStatus.WAITING
def test_model_intermediate_streaming_payload_replaces_computed_prompt() -> None:
sched = _make_scheduler(stage_id=1)
sched.chunk_transfer_adapter = SimpleNamespace(
receives_chunks=False,
segment_finished_requests=set(),
)
session = _make_request()
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
session.prompt_token_ids = [0] * 59
session._all_token_ids.clear()
session._all_token_ids.extend(session.prompt_token_ids)
session.num_prompt_tokens = 59
session.num_computed_tokens = 59
update = _make_update([0] * 10)
update.additional_information = None
update.model_intermediate_buffer = {
"ids": {"tts": list(range(8))},
"hidden_states": {"tts": [[0.0]] * 8},
"meta": {
"replace_streaming_prompt": True,
"next_stage_prompt_len": 10,
},
}
sched._update_request_as_session(session, update)
assert session.prompt_token_ids == [0] * 10
assert list(session._all_token_ids) == [0] * 10
assert session.num_prompt_tokens == 10
assert session.num_computed_tokens == 0
assert session.additional_information is None
assert session.model_intermediate_buffer == update.model_intermediate_buffer
assert session.status == RequestStatus.WAITING assert session.status == RequestStatus.WAITING
@@ -54,6 +54,18 @@ class _SchedulerStub(OmniGenerationScheduler):
raise AssertionError("unexpected enqueue for skipped_waiting miss") raise AssertionError("unexpected enqueue for skipped_waiting miss")
class _AsyncChunkStopSchedulerStub(OmniGenerationScheduler):
def __init__(self) -> None:
self.num_waiting_for_streaming_input = 0
self.chunk_transfer_adapter = SimpleNamespace(receives_chunks=True)
self.enqueued: list[Request] = []
self.enqueued_statuses: list[RequestStatus] = []
def _enqueue_waiting_request(self, session: Request) -> None:
self.enqueued.append(session)
self.enqueued_statuses.append(session.status)
def _make_request(**kwargs) -> Request: def _make_request(**kwargs) -> Request:
sp = SamplingParams(max_tokens=8) sp = SamplingParams(max_tokens=8)
defaults = dict( defaults = dict(
@@ -133,6 +145,21 @@ def test_resumable_generation_stop_marks_segment_boundary() -> None:
assert output.is_segment_finished is True assert output.is_segment_finished is True
def test_async_chunk_resumable_stop_rearms_connector_polling() -> None:
sched = _AsyncChunkStopSchedulerStub()
session = _make_request(request_id="req-async-chunk-next-segment")
session.status = RequestStatus.FINISHED_STOPPED
session.resumable = True
finished = sched._handle_stopped_request(session)
assert finished is False
assert sched.enqueued == [session]
assert sched.enqueued_statuses == [RequestStatus.WAITING]
assert session.status == RequestStatus.WAITING
assert sched.num_waiting_for_streaming_input == 0
class TestReplaceSessionWithStreamingUpdate: class TestReplaceSessionWithStreamingUpdate:
def test_resets_tokens_and_prompt_from_update(self) -> None: def test_resets_tokens_and_prompt_from_update(self) -> None:
sched = _SchedulerStub() sched = _SchedulerStub()
+93 -150
View File
@@ -3,15 +3,13 @@ import os
import re import re
import subprocess import subprocess
import threading import threading
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from tests.dfx.reliability.helpers import list_remote_process_pids_by_pattern, post_chat_completions_raw from tests.helpers.mark import get_hardware_mark_list, hardware_marks
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServerParams from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import modify_stage_config from tests.helpers.stage_config import modify_stage_config
from vllm_omni.platforms import current_omni_platform from vllm_omni.platforms import current_omni_platform
@@ -119,32 +117,6 @@ def create_unique_server_pytest_params(
] ]
def create_benchmark_pytest_params(
benchmark_configs: list[dict[str, Any]],
server_to_benchmark_mapping: dict[str, dict],
) -> list[Any]:
"""Like :func:`create_benchmark_indices`, but wrap each index in ``pytest.param`` with JSON marks."""
marks_by_name = _marks_by_test_name(benchmark_configs)
params: list[Any] = []
seen: set[str] = set()
for config in benchmark_configs:
test_name = config["test_name"]
if test_name in seen:
continue
seen.add(test_name)
params_list = get_benchmark_params_for_server(test_name, server_to_benchmark_mapping)
id_suffixes = _unique_benchmark_param_id_suffixes(params_list)
for idx, id_suffix in enumerate(id_suffixes):
params.append(
pytest.param(
(test_name, idx),
marks=marks_by_name.get(test_name, []),
id=f"{test_name}-{id_suffix}",
)
)
return params
def create_paired_benchmark_pytest_params( def create_paired_benchmark_pytest_params(
server_entries: list[tuple[Any, str]], server_entries: list[tuple[Any, str]],
params_by_test_name: dict[str, list[Any]], params_by_test_name: dict[str, list[Any]],
@@ -357,10 +329,6 @@ def supports_video_generation(model_name: str) -> bool:
return any(key in lower for key in ("wan", "video", "i2v", "t2v")) return any(key in lower for key in ("wan", "video", "i2v", "t2v"))
def supports_chat_generation(model_name: str) -> bool:
return not supports_video_generation(model_name)
def parse_stage_devices(stage_config_path: str) -> str: def parse_stage_devices(stage_config_path: str) -> str:
text = Path(stage_config_path).read_text(encoding="utf-8") text = Path(stage_config_path).read_text(encoding="utf-8")
raw_devices: list[str] = re.findall(r"^\s*devices:\s*\"?([0-9,\s]+)\"?\s*$", text, flags=re.MULTILINE) raw_devices: list[str] = re.findall(r"^\s*devices:\s*\"?([0-9,\s]+)\"?\s*$", text, flags=re.MULTILINE)
@@ -389,48 +357,6 @@ def assert_fault_exception(exc: Exception, error_keywords: tuple[str, ...]) -> N
assert any(key in text for key in error_keywords), f"unexpected error under fault injection: {exc}" assert any(key in text for key in error_keywords), f"unexpected error under fault injection: {exc}"
def wait_chat_request_ready(host: str, port: int, model: str, timeout_sec: int = 180) -> None:
"""Poll a minimal chat request until success."""
deadline = time.time() + timeout_sec
payload = json.dumps(
{
"model": model,
"messages": [{"role": "user", "content": "Say hello in one short sentence."}],
"stream": False,
"modalities": ["text"],
}
)
last_error: str | None = None
while time.time() < deadline:
try:
status, body = post_chat_completions_raw(host, port, payload)
if status == 200:
return
last_error = f"http={status} body={body[:200]!r}"
except Exception as exc: # noqa: BLE001
last_error = str(exc)
time.sleep(2)
raise TimeoutError(f"runtime-teardown warmup request did not succeed within {timeout_sec}s: {last_error}")
def assert_no_extra_worker_processes(
baseline_pids: set[int],
worker_pattern: str,
timeout_sec: int = 60,
) -> None:
"""Ensure no extra worker PIDs remain after teardown."""
deadline = time.time() + timeout_sec
while time.time() < deadline:
current = set(list_remote_process_pids_by_pattern(worker_pattern))
extra = current - baseline_pids
if not extra:
return
time.sleep(2)
current = set(list_remote_process_pids_by_pattern(worker_pattern))
extra = sorted(current - baseline_pids)
assert not extra, f"orphan worker processes remain after container teardown: {extra}"
def create_test_parameter_mapping(configs: list[dict[str, Any]]) -> dict[str, dict]: def create_test_parameter_mapping(configs: list[dict[str, Any]]) -> dict[str, dict]:
mapping = {} mapping = {}
for config in configs: for config in configs:
@@ -520,36 +446,16 @@ def _unique_benchmark_param_id_suffixes(params_list: list[dict[str, Any]]) -> li
return unique return unique
def extract_mark_resource_label(mark_field: Any) -> str: # Device marketing-name tokens for ``get_runtime_resource_label`` only.
"""Return a filename-safe hardware label from ``mark.hardware_marks.res`` values. # Longer / more specific tokens must appear before shorter substrings
# (e.g. ``H200`` before ``H20``, ``910B4`` before ``910``).
Example: ``{"cuda": "H100"}`` -> ``"H100"``; multiple platforms join with ``-``. _RUNTIME_DEVICE_ALIASES: tuple[str, ...] = (
Prefer :func:`get_runtime_resource_label` for perf result filenames so labels
reflect the machine that actually ran the benchmark.
"""
if isinstance(mark_field, list):
for item in mark_field:
if isinstance(item, dict) and "hardware_marks" in item:
return extract_mark_resource_label(item)
return "na"
if not isinstance(mark_field, dict):
return "na"
hw = mark_field.get("hardware_marks")
if not isinstance(hw, dict):
return "na"
res = hw.get("res")
if not isinstance(res, dict) or not res:
return "na"
labels = [_safe_filename_token(value) for value in res.values()]
return "-".join(labels) if labels else "na"
_KNOWN_RUNTIME_RESOURCE_TOKENS: tuple[str, ...] = (
"H100", "H100",
"H800", "H800",
"H200", "H200",
"H20", "H20",
"B200",
"GB200",
"L40S", "L40S",
"L40", "L40",
"L4", "L4",
@@ -578,7 +484,7 @@ def _normalize_runtime_device_label(raw: str) -> str:
if not raw or not str(raw).strip(): if not raw or not str(raw).strip():
return "na" return "na"
upper = str(raw).upper() upper = str(raw).upper()
for token in _KNOWN_RUNTIME_RESOURCE_TOKENS: for token in _RUNTIME_DEVICE_ALIASES:
if token.upper() in upper: if token.upper() in upper:
return _safe_filename_token(token) return _safe_filename_token(token)
compact = re.sub(r"[^a-zA-Z0-9]+", "", str(raw)) compact = re.sub(r"[^a-zA-Z0-9]+", "", str(raw))
@@ -631,34 +537,41 @@ def resource_label_for_filename(resource_label: str | None) -> str:
return token return token
def extract_configs_resource_label(configs: list[dict[str, Any]]) -> str: def is_hardware_nested_baseline(baseline: dict[str, Any]) -> bool:
"""Return runtime hardware label for perf result filenames.""" """True for ``{"H100": {"metric": ...}, "A3": {...}}`` baselines.
del configs
return get_runtime_resource_label() Detection is structural + hardware-label allowlist (not metric-name hints):
- every top-level value is a non-empty dict (per-hardware metric map);
- every top-level key is in :func:`tests.helpers.mark.get_hardware_mark_list`
(``[hardware-resource]`` markers from ``pyproject.toml``).
Metric names under each hardware bucket are unconstrained.
Runtime device aliases (:data:`_RUNTIME_DEVICE_ALIASES`) are unrelated.
"""
if not baseline:
return False
if not all(isinstance(value, dict) and bool(value) for value in baseline.values()):
return False
allowed = {label.upper() for label in get_hardware_mark_list()}
return all((token := str(key).strip()) and token.upper() in allowed for key in baseline.keys())
def resolve_baseline_value( def resolve_baseline_value(
baseline_raw: Any, baseline_raw: Any,
*, *,
sweep_index: int | None, sweep_index: int | None,
max_concurrency: Any = None,
request_rate: Any = None,
) -> Any: ) -> Any:
"""Pick the baseline threshold for this sweep step.""" """Pick the baseline threshold for one metric at this sweep step.
Accepts a sweep-aligned list/tuple (indexed by ``sweep_index``) or a scalar.
"""
if baseline_raw is None: if baseline_raw is None:
return 100000 return None
if isinstance(baseline_raw, dict): if isinstance(baseline_raw, dict):
if max_concurrency is not None: raise TypeError(
for key in (max_concurrency, str(max_concurrency)): "per-metric baseline dict keyed by concurrency/request-rate is not supported; "
if key in baseline_raw: "use a sweep-aligned list or a scalar under each hardware bucket"
return baseline_raw[key]
if request_rate is not None:
for key in (request_rate, str(request_rate)):
if key in baseline_raw:
return baseline_raw[key]
raise KeyError(
f"baseline dict has no key for max_concurrency={max_concurrency!r} "
f"or request_rate={request_rate!r}; keys={list(baseline_raw.keys())!r}"
) )
if isinstance(baseline_raw, (list, tuple)): if isinstance(baseline_raw, (list, tuple)):
if sweep_index is None: if sweep_index is None:
@@ -669,22 +582,62 @@ def resolve_baseline_value(
return baseline_raw return baseline_raw
def _baseline_thresholds_for_step( def resolve_baseline_for_sweep(
baseline_data: dict[str, Any], baseline_config: dict[str, Any] | None,
*, *,
sweep_index: int | None = None, sweep_index: int | None = None,
max_concurrency: Any = None,
request_rate: Any = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Resolve baseline config to one threshold per metric for this iteration.""" """Resolve sweep-dependent baselines while keeping every hardware bucket.
return {
metric_name: resolve_baseline_value( Expects hardware-nested input only::
baseline_raw,
sweep_index=sweep_index, {"H100": {"throughput_qps": [a, b, c]}, "A3": {"throughput_qps": [x, y, z]}}
max_concurrency=max_concurrency,
request_rate=request_rate, with ``sweep_index=2`` (e.g. concurrency 32) becomes::
{"H100": {"throughput_qps": c}, "A3": {"throughput_qps": z}}
Per-metric values may be a sweep-aligned list or a scalar.
"""
if not baseline_config:
return {}
if not is_hardware_nested_baseline(baseline_config):
allowed = sorted(get_hardware_mark_list())
unknown = [
str(key).strip()
for key in baseline_config.keys()
if str(key).strip().upper() not in {label.upper() for label in allowed}
]
nested_shape = all(isinstance(value, dict) and bool(value) for value in baseline_config.values())
if nested_shape and unknown:
raise ValueError(
"baseline top-level keys must be [hardware-resource] pytest markers from "
"pyproject.toml (tool.pytest.ini_options.markers). "
f"Unknown hardware label(s): {unknown!r}. "
f"Known markers: {allowed!r}. "
"Add a marker such as '\"A100: [hardware-resource] Tests that require A100 GPU\"' "
"to pyproject.toml, then retry."
)
raise ValueError(
"baseline must be hardware-nested with [hardware-resource] marker names as "
"top-level keys, e.g. "
'{"H100": {"throughput_qps": [...]}, "A3": {"custom_metric": 1.0}}. '
f"got top-level keys={list(baseline_config.keys())!r}; "
f"known markers={allowed!r}. "
"If you need a new hardware bucket, add it to pyproject.toml markers with "
"the [hardware-resource] tag."
) )
for metric_name, baseline_raw in baseline_data.items()
return {
hardware: {
metric_name: resolve_baseline_value(
baseline_raw,
sweep_index=sweep_index,
)
for metric_name, baseline_raw in metrics.items()
}
for hardware, metrics in baseline_config.items()
if isinstance(metrics, dict)
} }
@@ -697,17 +650,17 @@ def run_benchmark(
*, *,
baseline_config: dict[str, Any] | None = None, baseline_config: dict[str, Any] | None = None,
sweep_index: int | None = None, sweep_index: int | None = None,
request_rate: Any | None = None,
max_concurrency: Any | None = None,
random_input_len: Any | None = None, random_input_len: Any | None = None,
random_output_len: Any | None = None, random_output_len: Any | None = None,
resource_label: str | None = None, resource_label: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Run one ``vllm bench serve --omni`` iteration and return parsed metrics. """Run one ``vllm bench serve --omni`` iteration and return parsed metrics.
After ``vllm bench`` writes the JSON, ``result["baseline"]`` holds the resolved per-metric thresholds After ``vllm bench`` writes the JSON, ``result["baseline"]`` stores the
(when ``baseline_config`` is provided). If the benchmark exits without writing a result file, sweep-resolved baseline (when ``baseline_config`` is provided). Hardware-nested
``result_omni_template.json`` is used as a fallback. maps keep every hardware bucket, but each metric is reduced to the value for
this concurrency / request-rate step. If the benchmark exits without writing a
result file, ``result_omni_template.json`` is used as a fallback.
""" """
current_dt = datetime.now().strftime("%Y%m%d-%H%M%S") current_dt = datetime.now().strftime("%Y%m%d-%H%M%S")
ri = _safe_filename_token(random_input_len) ri = _safe_filename_token(random_input_len)
@@ -774,11 +727,10 @@ def run_benchmark(
result = json.load(f) result = json.load(f)
if baseline_config: if baseline_config:
result["baseline"] = _baseline_thresholds_for_step( # Keep every hardware bucket; resolve list metrics to this sweep step.
result["baseline"] = resolve_baseline_for_sweep(
baseline_config, baseline_config,
sweep_index=sweep_index, sweep_index=sweep_index,
request_rate=request_rate,
max_concurrency=max_concurrency,
) )
else: else:
result["baseline"] = {} result["baseline"] = {}
@@ -800,12 +752,3 @@ def pytest_addoption(parser: pytest.Parser) -> None:
default=None, default=None,
help=("Path to benchmark config JSON. Example: --test-config-file tests/dfx/perf/tests/test_tts.json"), help=("Path to benchmark config JSON. Example: --test-config-file tests/dfx/perf/tests/test_tts.json"),
) )
parser.addoption(
"--assert-baseline",
action="store_true",
default=False,
help=(
"When set, omni/diffusion perf runners compare metrics against the baseline block in the JSON config "
"(default: off)."
),
)
@@ -21,10 +21,12 @@
"num-input-images": 0, "num-input-images": 0,
"enable-negative-prompt": false, "enable-negative-prompt": false,
"baseline": { "baseline": {
"throughput_qps": 0, "H100": {
"latency_mean": 0, "throughput_qps": 0,
"peak_memory_mb_max": 0, "latency_mean": 0,
"peak_memory_mb_mean": 0 "peak_memory_mb_max": 0,
"peak_memory_mb_mean": 0
}
} }
}, },
"result": { "result": {
@@ -46,9 +46,11 @@
"median_audio_duration_s": 0, "median_audio_duration_s": 0,
"p99_audio_duration_s": 0, "p99_audio_duration_s": 0,
"baseline": { "baseline": {
"mean_ttft_ms": 0, "H100": {
"mean_audio_ttfp_ms": 0, "mean_ttft_ms": 0,
"mean_audio_rtf": 0 "mean_audio_ttfp_ms": 0,
"mean_audio_rtf": 0
}
}, },
"random_input_len": 0, "random_input_len": 0,
"random_output_len": 0 "random_output_len": 0
+86 -81
View File
@@ -1,6 +1,8 @@
import json import json
import os import os
import threading import threading
from collections.abc import Callable
from contextlib import AbstractContextManager, ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -13,15 +15,10 @@ from tests.dfx.conftest import (
get_runtime_resource_label, get_runtime_resource_label,
is_diffusion_perf_config, is_diffusion_perf_config,
load_benchmark_configs, load_benchmark_configs,
resolve_baseline_value,
run_benchmark, run_benchmark,
) )
from tests.helpers.runtime import OmniServer from tests.helpers.runtime import OmniServer
# Compare metrics to each test JSON ``baseline`` block only when pytest is run with ``--assert-baseline``
# (registered in ``tests/dfx/conftest.py``; default: off). ``run_benchmark`` and ``_resolve_baseline_value`` are
# defined in the same module.
#
# Optional JSON field ``mark`` is applied as pytest marks via # Optional JSON field ``mark`` is applied as pytest marks via
# ``create_paired_omni_benchmark_pytest_params`` (e.g. ``"mark": [{"hardware_marks": # ``create_paired_omni_benchmark_pytest_params`` (e.g. ``"mark": [{"hardware_marks":
# {"res": {"cuda": "H100"}, "num_cards": 2}}, "full_model", "omni"]``). # {"res": {"cuda": "H100"}, "num_cards": 2}}, "full_model", "omni"]``).
@@ -63,35 +60,78 @@ paired_benchmark_params = create_paired_omni_benchmark_pytest_params(BENCHMARK_C
_omni_server_lock = threading.Lock() _omni_server_lock = threading.Lock()
class _SingleActiveContext:
"""Reuse one active context while its configuration key is unchanged."""
def __init__(self) -> None:
self._key: Any = None
self._stack: ExitStack | None = None
self._value: Any = None
def acquire(self, key: Any, factory: Callable[[], AbstractContextManager[Any]]) -> Any:
if self._stack is not None and key == self._key:
return self._value
self.close()
stack = ExitStack()
value = stack.enter_context(factory())
self._key = key
self._stack = stack
self._value = value
return value
def close(self) -> None:
stack = self._stack
self._key = None
self._stack = None
self._value = None
if stack is not None:
stack.close()
@contextmanager
def _start_omni_server(server_param):
test_name, model, stage_config_path, stage_overrides, extra_cli_args, use_omni = server_param
print(f"Starting OmniServer with test: {test_name}, model: {model}")
server_args: list[str] = []
if use_omni:
server_args += ["--stage-init-timeout", "600", "--init-timeout", "900"]
# --deploy-config and --stage-overrides compose at the CLI (see vllm_omni/entrypoints/utils.py):
# deploy-config sets the base; stage-overrides are applied on top. Both can be set.
if stage_config_path:
server_args = ["--deploy-config", stage_config_path] + server_args
if stage_overrides:
server_args = ["--stage-overrides", stage_overrides] + server_args
if extra_cli_args:
server_args = list(extra_cli_args) + server_args
with OmniServer(model, server_args, use_omni=use_omni) as server:
server.test_name = test_name
print("OmniServer started successfully")
yield server
print("OmniServer stopping...")
print("OmniServer stopped")
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def omni_server(request): def omni_server_context():
"""Start vLLM-Omni server as a subprocess with actual model weights. """Start vLLM-Omni server as a subprocess with actual model weights.
Uses session scope so the server starts only once for the entire test session. Reuse it for adjacent benchmark cases with the same server configuration.
Multi-stage initialization can take 10-20+ minutes. Multi-stage initialization can take 10-20+ minutes.
""" """
with _omni_server_lock: with _omni_server_lock:
test_name, model, stage_config_path, stage_overrides, extra_cli_args, use_omni = request.param active_context = _SingleActiveContext()
try:
yield active_context
finally:
active_context.close()
print(f"Starting OmniServer with test: {test_name}, model: {model}")
server_args: list[str] = [] @pytest.fixture
if use_omni: def omni_server(request, omni_server_context):
server_args += ["--stage-init-timeout", "600", "--init-timeout", "900"] return omni_server_context.acquire(request.param, lambda: _start_omni_server(request.param))
# --deploy-config and --stage-overrides compose at the CLI (see vllm_omni/entrypoints/utils.py):
# deploy-config sets the base; stage-overrides are applied on top. Both can be set.
if stage_config_path:
server_args = ["--deploy-config", stage_config_path] + server_args
if stage_overrides:
server_args = ["--stage-overrides", stage_overrides] + server_args
if extra_cli_args:
server_args = list(extra_cli_args) + server_args
with OmniServer(model, server_args, use_omni=use_omni) as server:
server.test_name = test_name
print("OmniServer started successfully")
yield server
print("OmniServer stopping...")
print("OmniServer stopped")
@pytest.fixture @pytest.fixture
@@ -117,36 +157,19 @@ def benchmark_params(request):
} }
def assert_result( def assert_result(result, params, num_prompt) -> None:
result,
params,
num_prompt,
*,
assert_baseline: bool,
sweep_index: int | None = None,
max_concurrency: Any | None = None,
request_rate: Any | None = None,
) -> None:
assert result["completed"] == num_prompt, "Request failures exist" assert result["completed"] == num_prompt, "Request failures exist"
if not assert_baseline: expected_audio_turns = params.get("expected_duplex_audio_turns_per_session")
return if expected_audio_turns is not None:
baseline_data = params.get("baseline", {}) session_metrics = result.get("duplex_session_metrics")
for metric_name, baseline_raw in baseline_data.items(): assert isinstance(session_metrics, list), "Duplex session metrics are missing"
current_value = result[metric_name] assert len(session_metrics) == num_prompt, (
baseline_value = resolve_baseline_value( f"Expected {num_prompt} duplex session metric rows, got {len(session_metrics)}"
baseline_raw,
sweep_index=sweep_index,
max_concurrency=max_concurrency,
request_rate=request_rate,
) )
if "throughput" in metric_name: assert all(
if current_value <= baseline_value: isinstance(metric, dict) and metric.get("audio_turn_count") == expected_audio_turns
print( for metric in session_metrics
f"ERROR: Throughput test results were below baseline: {metric_name}: {current_value} > {baseline_value}" ), f"Not every duplex session emitted {expected_audio_turns} audio turns"
)
else:
if current_value >= baseline_value:
print(f"ERROR: Test results exceeded baseline: {metric_name}: {current_value} < {baseline_value}")
@pytest.mark.benchmark @pytest.mark.benchmark
@@ -155,7 +178,7 @@ def assert_result(
paired_benchmark_params, paired_benchmark_params,
indirect=["omni_server", "benchmark_params"], indirect=["omni_server", "benchmark_params"],
) )
def test_performance_benchmark(omni_server, benchmark_params, request): def test_performance_benchmark(omni_server, benchmark_params):
test_name = benchmark_params["test_name"] test_name = benchmark_params["test_name"]
params = benchmark_params["params"] params = benchmark_params["params"]
dataset_name = params.get("dataset_name", "") dataset_name = params.get("dataset_name", "")
@@ -167,7 +190,6 @@ def test_performance_benchmark(omni_server, benchmark_params, request):
print(f"Running benchmark for model: {model}") print(f"Running benchmark for model: {model}")
print(f"Benchmark parameters: {benchmark_params}") print(f"Benchmark parameters: {benchmark_params}")
assert_baseline = request.config.getoption("--assert-baseline", default=False)
resource_label = get_runtime_resource_label() resource_label = get_runtime_resource_label()
def to_list(value, default=None): def to_list(value, default=None):
@@ -201,6 +223,7 @@ def test_performance_benchmark(omni_server, benchmark_params, request):
"enabled", "enabled",
"eval_phase", "eval_phase",
"trust_remote_code", "trust_remote_code",
"expected_duplex_audio_turns_per_session",
} }
for key, value in params.items(): for key, value in params.items():
@@ -226,7 +249,7 @@ def test_performance_benchmark(omni_server, benchmark_params, request):
break break
# QPS / request-rate sweep # QPS / request-rate sweep
for i, (qps, num_prompt) in enumerate(zip(qps_list, num_prompt_list)): for sweep_index, (qps, num_prompt) in enumerate(zip(qps_list, num_prompt_list)):
args = args + ["--request-rate", str(qps), "--num-prompts", str(num_prompt)] args = args + ["--request-rate", str(qps), "--num-prompts", str(num_prompt)]
result = run_benchmark( result = run_benchmark(
args=args, args=args,
@@ -235,24 +258,15 @@ def test_performance_benchmark(omni_server, benchmark_params, request):
dataset_name=dataset_name, dataset_name=dataset_name,
num_prompt=num_prompt, num_prompt=num_prompt,
baseline_config=params.get("baseline"), baseline_config=params.get("baseline"),
sweep_index=i, sweep_index=sweep_index,
request_rate=qps,
max_concurrency=None,
random_input_len=params.get("random_input_len"), random_input_len=params.get("random_input_len"),
random_output_len=params.get("random_output_len"), random_output_len=params.get("random_output_len"),
resource_label=resource_label, resource_label=resource_label,
) )
assert_result( assert_result(result, params, num_prompt)
result,
params,
num_prompt,
assert_baseline=assert_baseline,
sweep_index=i,
request_rate=qps,
)
# concurrency test # concurrency test
for i, (concurrency, num_prompt) in enumerate(zip(max_concurrency_list, num_prompt_list)): for sweep_index, (concurrency, num_prompt) in enumerate(zip(max_concurrency_list, num_prompt_list)):
args = args + ["--max-concurrency", str(concurrency), "--num-prompts", str(num_prompt), "--request-rate", "inf"] args = args + ["--max-concurrency", str(concurrency), "--num-prompts", str(num_prompt), "--request-rate", "inf"]
result = run_benchmark( result = run_benchmark(
args=args, args=args,
@@ -261,18 +275,9 @@ def test_performance_benchmark(omni_server, benchmark_params, request):
dataset_name=dataset_name, dataset_name=dataset_name,
num_prompt=num_prompt, num_prompt=num_prompt,
baseline_config=params.get("baseline"), baseline_config=params.get("baseline"),
sweep_index=i, sweep_index=sweep_index,
request_rate=None,
max_concurrency=concurrency,
random_input_len=params.get("random_input_len"), random_input_len=params.get("random_input_len"),
random_output_len=params.get("random_output_len"), random_output_len=params.get("random_output_len"),
resource_label=resource_label, resource_label=resource_label,
) )
assert_result( assert_result(result, params, num_prompt)
result,
params,
num_prompt,
assert_baseline=assert_baseline,
sweep_index=i,
max_concurrency=concurrency,
)
+32 -102
View File
@@ -13,8 +13,6 @@ A config JSON file may be passed via --test-config-file. If omitted, every ``*.j
pytest run_diffusion_benchmark.py -m "diffusion" pytest run_diffusion_benchmark.py -m "diffusion"
pytest run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json pytest run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
Optional: ``--assert-baseline`` compares metrics to the ``baseline`` block in each benchmark entry (default: off).
Optional JSON field ``mark`` is applied as pytest marks on that case via Optional JSON field ``mark`` is applied as pytest marks on that case via
``pytest.param`` (e.g. ``"mark": [{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, "full_model", "diffusion"]``). ``pytest.param`` (e.g. ``"mark": [{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, "full_model", "diffusion"]``).
@@ -46,6 +44,7 @@ from tests.dfx.conftest import (
get_runtime_resource_label, get_runtime_resource_label,
hardware_json_value, hardware_json_value,
is_diffusion_perf_config, is_diffusion_perf_config,
resolve_baseline_for_sweep,
resolve_pytest_marks, resolve_pytest_marks,
resource_label_for_filename, resource_label_for_filename,
) )
@@ -274,11 +273,6 @@ def _write_result_record(record: dict[str, Any]) -> Path:
return target return target
def _append_to_aggregated_file(record: dict[str, Any]) -> None:
"""Backward-compatible wrapper; prefer :func:`_write_result_record`."""
_write_result_record(record)
_server_lock = threading.Lock() _server_lock = threading.Lock()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -355,6 +349,17 @@ def _kill_process_tree(pid: int) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _resolve_offline_model(model: str) -> str:
"""Under HF_HUB_OFFLINE, resolve a HF repo id to its local snapshot dir."""
import huggingface_hub
if not model or os.path.isdir(model) or not huggingface_hub.constants.HF_HUB_OFFLINE:
return model
from huggingface_hub import snapshot_download
return snapshot_download(model, local_files_only=True)
class DiffusionServer: class DiffusionServer:
"""Start a vLLM-Omni diffusion model server as a subprocess. """Start a vLLM-Omni diffusion model server as a subprocess.
@@ -372,7 +377,7 @@ class DiffusionServer:
port: int | None = None, port: int | None = None,
) -> None: ) -> None:
self.server_cfg: dict[str, Any] = server_cfg self.server_cfg: dict[str, Any] = server_cfg
self.model = server_cfg["model"] self.model = _resolve_offline_model(server_cfg["model"])
self.serve_args = server_cfg["serve_args"] self.serve_args = server_cfg["serve_args"]
self.host = "127.0.0.1" self.host = "127.0.0.1"
self.port = port if port is not None else get_open_port(self.host) self.port = port if port is not None else get_open_port(self.host)
@@ -667,7 +672,7 @@ def run_benchmark(
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", prefix="diffusion_bench_tmp_", delete=False) as tmp: with tempfile.NamedTemporaryFile(mode="w", suffix=".json", prefix="diffusion_bench_tmp_", delete=False) as tmp:
tmp_result_file = Path(tmp.name) tmp_result_file = Path(tmp.name)
exclude_keys = {"baseline", "dataset", "task", "name", "skip-performance-assertion"} exclude_keys = {"baseline", "dataset", "task", "name"}
cmd = [ cmd = [
sys.executable, sys.executable,
@@ -761,12 +766,17 @@ def run_benchmark(
completed = metrics.get("completed_requests", metrics.get("completed", 0)) completed = metrics.get("completed_requests", metrics.get("completed", 0))
failed = metrics.get("failed_requests", metrics.get("failed", 0)) failed = metrics.get("failed_requests", metrics.get("failed", 0))
# Persist sweep-resolved baseline from params (already narrowed in _build_run_params).
baseline = params.get("baseline") or {}
metrics["baseline"] = baseline
record: dict[str, Any] = { record: dict[str, Any] = {
"test_name": test_name, "test_name": test_name,
"endpoint": endpoint, "endpoint": endpoint,
"timestamp": timestamp, "timestamp": timestamp,
"server_params": server_cfg.get("server_params"), "server_params": server_cfg.get("server_params"),
"benchmark_params": params, "benchmark_params": params,
"baseline": baseline,
"result": metrics, "result": metrics,
"log_file": str(log_file), "log_file": str(log_file),
"Model": model, "Model": model,
@@ -816,70 +826,14 @@ def run_benchmark(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _resolve_baseline_value(
baseline_raw: Any,
*,
sweep_index: int | None,
max_concurrency: Any = None,
request_rate: Any = None,
) -> Any:
"""Pick the baseline threshold for this sweep step."""
if baseline_raw is None:
return 100000
if isinstance(baseline_raw, dict):
if max_concurrency is not None:
for key in (max_concurrency, str(max_concurrency)):
if key in baseline_raw:
return baseline_raw[key]
if request_rate is not None:
for key in (request_rate, str(request_rate)):
if key in baseline_raw:
return baseline_raw[key]
raise KeyError(
f"baseline dict has no key for max_concurrency={max_concurrency!r} "
f"or request_rate={request_rate!r}; keys={list(baseline_raw.keys())!r}"
)
if isinstance(baseline_raw, (list, tuple)):
if sweep_index is None:
raise ValueError("sweep_index is required when baseline is a list or tuple")
return baseline_raw[sweep_index]
return baseline_raw
def assert_result( def assert_result(
result: dict[str, Any], result: dict[str, Any],
params: dict[str, Any],
num_prompts: int, num_prompts: int,
*,
sweep_index: int | None = None,
max_concurrency: Any = None,
request_rate: Any = None,
assert_baseline: bool = True,
) -> None: ) -> None:
"""Assert that benchmark metrics satisfy the configured baselines.""" """Assert that the expected number of requests completed."""
completed = result.get("completed_requests", result.get("completed", 0)) completed = result.get("completed_requests", result.get("completed", 0))
assert completed == num_prompts, f"Expected {num_prompts} completed requests, got {completed}" assert completed == num_prompts, f"Expected {num_prompts} completed requests, got {completed}"
if not assert_baseline:
return
if params.get("skip-performance-assertion", False):
print("Skipping performance assertions.")
return
for metric, baseline_raw in params.get("baseline", {}).items():
current = result.get(metric)
assert current is not None, f"Metric '{metric}' not found in result: {list(result.keys())}"
threshold = _resolve_baseline_value(
baseline_raw,
sweep_index=sweep_index,
max_concurrency=max_concurrency,
request_rate=request_rate,
)
if "throughput" in metric:
assert current >= threshold, f"{metric}: {current:.4f} < baseline {threshold}"
else:
assert current <= threshold, f"{metric}: {current:.4f} > baseline {threshold}"
def _default_benchmark_endpoint_for_task(task: str) -> str: def _default_benchmark_endpoint_for_task(task: str) -> str:
"""Return the default client-side benchmark endpoint for a diffusion task.""" """Return the default client-side benchmark endpoint for a diffusion task."""
@@ -921,15 +875,11 @@ def _build_run_params(
if max_concurrency is not None: if max_concurrency is not None:
run_params["max-concurrency"] = max_concurrency run_params["max-concurrency"] = max_concurrency
if "baseline" in params: if "baseline" in params:
run_params["baseline"] = { # Keep all hardware buckets; pick the metric value for this sweep step only.
metric: _resolve_baseline_value( run_params["baseline"] = resolve_baseline_for_sweep(
baseline_raw, params.get("baseline"),
sweep_index=sweep_index, sweep_index=sweep_index,
max_concurrency=max_concurrency, )
request_rate=request_rate,
)
for metric, baseline_raw in params["baseline"].items()
}
return run_params return run_params
@@ -952,36 +902,30 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]:
sweep_runs: list[dict[str, Any]] = [] sweep_runs: list[dict[str, Any]] = []
for i, (request_rate, num_prompts) in enumerate(zip(request_rate_list, num_prompt_list)): for sweep_index, (request_rate, num_prompts) in enumerate(zip(request_rate_list, num_prompt_list)):
sweep_runs.append( sweep_runs.append(
{ {
"params": _build_run_params( "params": _build_run_params(
params, params,
request_rate=request_rate, request_rate=request_rate,
num_prompts=num_prompts, num_prompts=num_prompts,
sweep_index=i, sweep_index=sweep_index,
), ),
"num_prompts": num_prompts, "num_prompts": num_prompts,
"sweep_index": i,
"request_rate": request_rate,
"max_concurrency": None,
} }
) )
for i, (max_concurrency, num_prompts) in enumerate(zip(max_concurrency_list, num_prompt_list)): for sweep_index, (max_concurrency, num_prompts) in enumerate(zip(max_concurrency_list, num_prompt_list)):
sweep_runs.append( sweep_runs.append(
{ {
"params": _build_run_params( "params": _build_run_params(
params, params,
max_concurrency=max_concurrency, max_concurrency=max_concurrency,
num_prompts=num_prompts, num_prompts=num_prompts,
sweep_index=i,
request_rate="inf", request_rate="inf",
sweep_index=sweep_index,
), ),
"num_prompts": num_prompts, "num_prompts": num_prompts,
"sweep_index": i,
"request_rate": None,
"max_concurrency": max_concurrency,
} }
) )
@@ -992,11 +936,9 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]:
"params": _build_run_params( "params": _build_run_params(
params, params,
num_prompts=default_num_prompts, num_prompts=default_num_prompts,
sweep_index=0,
), ),
"num_prompts": default_num_prompts, "num_prompts": default_num_prompts,
"sweep_index": None,
"request_rate": None,
"max_concurrency": None,
} }
) )
@@ -1012,14 +954,12 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]:
paired_benchmark_params, paired_benchmark_params,
indirect=["diffusion_server", "benchmark_params"], indirect=["diffusion_server", "benchmark_params"],
) )
def test_diffusion_performance_benchmark(diffusion_server, benchmark_params, request): def test_diffusion_performance_benchmark(diffusion_server, benchmark_params):
"""Run the diffusion performance benchmark and verify request completion. """Run the diffusion performance benchmark and verify request completion.
One server is started per unique parallel configuration (module scope). One server is started per unique parallel configuration (module scope).
For each server, all benchmark parameter sets defined in the config JSON For each server, all benchmark parameter sets defined in the config JSON
are executed sequentially; metrics are recorded to the aggregated result file. are executed sequentially; metrics are recorded to the aggregated result file.
Pass ``--assert-baseline`` to compare ``throughput_qps``, ``latency_mean``, etc. to the JSON ``baseline`` block.
""" """
test_name = benchmark_params["test_name"] test_name = benchmark_params["test_name"]
params = benchmark_params["params"] params = benchmark_params["params"]
@@ -1062,14 +1002,4 @@ def test_diffusion_performance_benchmark(diffusion_server, benchmark_params, req
print(f"\n Aggregated results: {_aggregated_result_file_for_source(str(source))}") print(f"\n Aggregated results: {_aggregated_result_file_for_source(str(source))}")
print("=" * 60) print("=" * 60)
assert_baseline = request.config.getoption("--assert-baseline", default=False) assert_result(result, sweep_run["num_prompts"])
assert_result(
result,
params,
sweep_run["num_prompts"],
sweep_index=sweep_run["sweep_index"],
max_concurrency=sweep_run["max_concurrency"],
request_rate=sweep_run["request_rate"],
assert_baseline=assert_baseline,
)
+24 -16
View File
@@ -40,10 +40,12 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.495, "H100": {
"latency_mean": 2.0238, "throughput_qps": 0.495,
"peak_memory_mb_max": 29393.4286, "latency_mean": 2.0238,
"peak_memory_mb_mean": 29392.7143 "peak_memory_mb_max": 29393.4286,
"peak_memory_mb_mean": 29392.7143
}
} }
} }
] ]
@@ -90,10 +92,12 @@
"enable-negative-prompt": true, "enable-negative-prompt": true,
"num-input-images": 1, "num-input-images": 1,
"baseline": { "baseline": {
"throughput_qps": 0.3451, "H100": {
"latency_mean": 2.9031, "throughput_qps": 0.3451,
"peak_memory_mb_max": 30480.2857, "latency_mean": 2.9031,
"peak_memory_mb_mean": 30479.2857 "peak_memory_mb_max": 30480.2857,
"peak_memory_mb_mean": 30479.2857
}
} }
} }
] ]
@@ -139,10 +143,12 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.5048, "H100": {
"latency_mean": 1.987, "throughput_qps": 0.5048,
"peak_memory_mb_max": 29380.0, "latency_mean": 1.987,
"peak_memory_mb_mean": 29380.0 "peak_memory_mb_max": 29380.0,
"peak_memory_mb_mean": 29380.0
}
} }
} }
] ]
@@ -189,10 +195,12 @@
"enable-negative-prompt": true, "enable-negative-prompt": true,
"num-input-images": 1, "num-input-images": 1,
"baseline": { "baseline": {
"throughput_qps": 0.149, "H100": {
"latency_mean": 6.7157, "throughput_qps": 0.149,
"peak_memory_mb_max": 30574.0, "latency_mean": 6.7157,
"peak_memory_mb_mean": 30574.0 "peak_memory_mb_max": 30574.0,
"peak_memory_mb_mean": 30574.0
}
} }
} }
] ]
@@ -24,10 +24,12 @@
"warmup-num-inference-steps": 28, "warmup-num-inference-steps": 28,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": {"1": 0.0303, "2": 0.0302, "4": 0.0302}, "H100": {
"latency_mean": {"1": 32.6, "2": 62.2, "4": 111.7}, "throughput_qps": [0.0303, 0.0302, 0.0302],
"peak_memory_mb_max": 40500, "latency_mean": [32.6, 62.2, 111.7],
"peak_memory_mb_mean": 40500 "peak_memory_mb_max": 40500,
"peak_memory_mb_mean": 40500
}
} }
} }
] ]
@@ -24,10 +24,12 @@
"warmup-num-inference-steps": 28, "warmup-num-inference-steps": 28,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": {"1": 0.0549, "2": 0.0547, "4": 0.0549}, "H100": {
"latency_mean": {"1": 18.0, "2": 34.3, "4": 61.4}, "throughput_qps": [0.0549, 0.0547, 0.0549],
"peak_memory_mb_max": 40500, "latency_mean": [18.0, 34.3, 61.4],
"peak_memory_mb_mean": 40500 "peak_memory_mb_max": 40500,
"peak_memory_mb_mean": 40500
}
} }
} }
] ]
@@ -48,9 +48,11 @@
"guardrails": false "guardrails": false
}, },
"baseline": { "baseline": {
"throughput_qps": 1.107, "H100": {
"latency_mean": 0.8929, "throughput_qps": 1.107,
"peak_memory_mb_mean": 80000.0 "latency_mean": 0.8929,
"peak_memory_mb_mean": 80000.0
}
} }
} }
] ]
@@ -115,9 +117,11 @@
"use_duration_template": false "use_duration_template": false
}, },
"baseline": { "baseline": {
"throughput_qps": 0.027, "H100": {
"latency_mean": 36.6824, "throughput_qps": 0.027,
"peak_memory_mb_mean": 30155.4 "latency_mean": 36.6824,
"peak_memory_mb_mean": 30155.4
}
} }
} }
] ]
@@ -181,9 +185,11 @@
"guardrails": false "guardrails": false
}, },
"baseline": { "baseline": {
"throughput_qps": 0.027, "H100": {
"latency_mean": 36.968, "throughput_qps": 0.027,
"peak_memory_mb_mean": 30443.6 "latency_mean": 36.968,
"peak_memory_mb_mean": 30443.6
}
} }
} }
] ]
@@ -251,9 +257,11 @@
"condition_video_keep": "first" "condition_video_keep": "first"
}, },
"baseline": { "baseline": {
"throughput_qps": 0.027, "H100": {
"latency_mean": 37.2117, "throughput_qps": 0.027,
"peak_memory_mb_mean": 30135.6 "latency_mean": 37.2117,
"peak_memory_mb_mean": 30135.6
}
} }
} }
] ]
+19 -15
View File
@@ -46,12 +46,14 @@
"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": [ "H100": {
223.3584 "median_audio_ttfp_ms": [
], 223.3584
"median_audio_rtf": [ ],
0.4393 "median_audio_rtf": [
] 0.4393
]
}
} }
}, },
{ {
@@ -70,15 +72,17 @@
"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": [ "H100": {
276.575 "median_audio_ttfp_ms": [
], 276.575
"median_audio_rtf": [ ],
0.5073 "median_audio_rtf": [
], 0.5073
"audio_throughput": [ ],
27.3728 "audio_throughput": [
] 27.3728
]
}
} }
} }
] ]
@@ -140,11 +140,13 @@
"num-prompts": 96, "num-prompts": 96,
"skip-performance-assertion": true, "skip-performance-assertion": true,
"baseline": { "baseline": {
"throughput_qps": 0.1874, "H200": {
"latency_p99": 101.852, "throughput_qps": 0.1874,
"latency_mean": 79.0385, "latency_p99": 101.852,
"peak_memory_mb_max": 70000, "latency_mean": 79.0385,
"peak_memory_mb_mean": 70000 "peak_memory_mb_max": 70000,
"peak_memory_mb_mean": 70000
}
} }
} }
] ]
@@ -36,10 +36,12 @@
"max-concurrency": 1, "max-concurrency": 1,
"skip-performance-assertion": true, "skip-performance-assertion": true,
"baseline": { "baseline": {
"throughput_qps": 0.21, "H200": {
"latency_p99": 4.7469, "throughput_qps": 0.21,
"peak_memory_mb_max": 101100, "latency_p99": 4.7469,
"peak_memory_mb_mean": 101100 "peak_memory_mb_max": 101100,
"peak_memory_mb_mean": 101100
}
} }
} }
] ]
@@ -36,10 +36,12 @@
"max-concurrency": 1, "max-concurrency": 1,
"skip-performance-assertion": true, "skip-performance-assertion": true,
"baseline": { "baseline": {
"throughput_qps": 0.20, "H200": {
"latency_p99": 5.1025, "throughput_qps": 0.2,
"peak_memory_mb_max": 97402, "latency_p99": 5.1025,
"peak_memory_mb_mean": 97402 "peak_memory_mb_max": 97402,
"peak_memory_mb_mean": 97402
}
} }
} }
] ]
@@ -35,11 +35,13 @@
"max-concurrency": 1, "max-concurrency": 1,
"skip-performance-assertion": true, "skip-performance-assertion": true,
"baseline": { "baseline": {
"throughput_qps": 0.213, "H200": {
"latency_mean": 4.6953, "throughput_qps": 0.213,
"latency_p99": 4.8601, "latency_mean": 4.6953,
"peak_memory_mb_max": 56912.0, "latency_p99": 4.8601,
"peak_memory_mb_mean": 56912.0 "peak_memory_mb_max": 56912.0,
"peak_memory_mb_mean": 56912.0
}
} }
} }
] ]
@@ -26,8 +26,7 @@
"warmup-requests": 1, "warmup-requests": 1,
"warmup-concurrency": 1, "warmup-concurrency": 1,
"warmup-num-inference-steps": 2, "warmup-num-inference-steps": 2,
"enable-negative-prompt": true, "enable-negative-prompt": true
"skip-performance-assertion": true
} }
] ]
} }
+144
View File
@@ -0,0 +1,144 @@
[
{
"test_name": "test_minicpmo_4_5_challenge",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H100",
"npu": "A3"
},
"num_cards": 1
}
},
"full_model",
"omni"
],
"server_params": {
"model": "openbmb/MiniCPM-o-4_5",
"extra_cli_args": [
"--deploy-config",
"vllm_omni/deploy/minicpmo_4_5.yaml",
"--trust-remote-code"
]
},
"benchmark_params": [
{
"dataset_name": "seed-tts",
"dataset_path": "zhaochenyang20/seed-tts-eval",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [
32,
64,
128
],
"max_concurrency": [
1,
4,
8
],
"no_oversample": true,
"disable_shuffle": true,
"trust_remote_code": true,
"seed_tts_locale": "zh",
"extra_body": {
"modalities": [
"text",
"audio"
],
"chat_template_kwargs": {
"enable_thinking": false,
"use_tts_template": true
}
},
"percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration",
"baseline": {
"A3": {
"request_throughput": [
0.5383,
0.6042,
0.7547
],
"mean_ttft_ms": [
333.2633,
533.8656,
547.3388
],
"mean_e2el_ms": [
1857.2154,
6600.5101,
10450.6811
],
"mean_audio_ttfp_ms": [
986.4666,
3411.0754,
3352.7515
],
"mean_audio_rtf": [
0.4423,
1.5734,
2.3024
]
}
}
}
]
},
{
"test_name": "test_minicpmo_4_5_a2_challenge",
"mark": [
{
"hardware_marks": {
"res": {
"npu": "A2"
},
"num_cards": 1
}
},
"full_model",
"omni"
],
"server_params": {
"model": "openbmb/MiniCPM-o-4_5",
"extra_cli_args": [
"--deploy-config",
"vllm_omni/deploy/minicpmo_4_5.yaml",
"--trust-remote-code"
]
},
"benchmark_params": [
{
"dataset_name": "seed-tts",
"dataset_path": "zhaochenyang20/seed-tts-eval",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [
32,
64,
128
],
"max_concurrency": [
1,
4,
8
],
"no_oversample": true,
"disable_shuffle": true,
"trust_remote_code": true,
"seed_tts_locale": "zh",
"extra_body": {
"modalities": [
"text",
"audio"
],
"chat_template_kwargs": {
"enable_thinking": false,
"use_tts_template": true
}
},
"percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration"
}
]
}
]
@@ -0,0 +1,69 @@
[
{
"test_name": "test_minicpmo_4_5_duplex_seed_tts_challenge",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H100",
"npu": "A3"
},
"num_cards": 1
}
},
"full_model",
"omni"
],
"server_params": {
"model": "openbmb/MiniCPM-o-4_5",
"extra_cli_args": [
"--deploy-config",
"vllm_omni/deploy/minicpmo_4_5_duplex.yaml",
"--trust-remote-code"
]
},
"benchmark_params": [
{
"dataset_name": "seed-tts",
"dataset_path": "zhaochenyang20/seed-tts-eval",
"backend": "openai-realtime-tts",
"endpoint": "/v1/realtime",
"num_prompts": [
32
],
"max_concurrency": [
1
],
"no_oversample": true,
"disable_shuffle": true,
"trust_remote_code": true,
"seed_tts_locale": "zh",
"seed_tts_turns_per_session": 4,
"expected_duplex_audio_turns_per_session": 4,
"extra_body": {
"save_duplex_request_metrics": true
},
"percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration",
"baseline": {
"A3": {
"request_throughput": [
0.1594
],
"mean_ttft_ms": [
274.164
],
"mean_e2el_ms": [
6250.7696
],
"mean_audio_ttfp_ms": [
994.422
],
"mean_audio_rtf": [
0.46163
]
}
}
}
]
}
]
@@ -0,0 +1,81 @@
[
{
"test_name": "test_minicpmo_4_5_ready",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H100",
"npu": "A3"
},
"num_cards": 1
}
},
"core_model",
"omni"
],
"server_params": {
"model": "openbmb/MiniCPM-o-4_5",
"extra_cli_args": [
"--deploy-config",
"vllm_omni/deploy/minicpmo_4_5.yaml",
"--trust-remote-code"
]
},
"benchmark_params": [
{
"dataset_name": "seed-tts",
"dataset_path": "zhaochenyang20/seed-tts-eval",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [
10,
40
],
"max_concurrency": [
1,
4
],
"no_oversample": true,
"disable_shuffle": true,
"trust_remote_code": true,
"seed_tts_locale": "zh",
"extra_body": {
"modalities": [
"text",
"audio"
],
"chat_template_kwargs": {
"enable_thinking": false,
"use_tts_template": true
}
},
"percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration",
"baseline": {
"H100": {
"request_throughput": [
0.7803,
1.1008
],
"mean_ttft_ms": [
166.9764,
307.7133
],
"mean_e2el_ms": [
1361.1682,
3800.6332
],
"mean_audio_ttfp_ms": [
635.8099,
1615.6591
],
"mean_audio_rtf": [
0.3151,
0.8568
]
}
}
}
]
}
]
@@ -5,7 +5,8 @@
{ {
"hardware_marks": { "hardware_marks": {
"res": { "res": {
"cuda": "H100" "cuda": "H100",
"npu": "A3"
}, },
"num_cards": 2 "num_cards": 2
} }
@@ -43,27 +44,29 @@
"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": [ "H100": {
96.4061, "mean_ttft_ms": [
140.8147, 96.4061,
271.9025, 140.8147,
362.2916, 271.9025,
507.776 362.2916,
], 507.776
"mean_e2el_ms": [ ],
18507.4582, "mean_e2el_ms": [
28365.4956, 18507.4582,
31907.9613, 28365.4956,
48161.5829, 31907.9613,
72630.8601 48161.5829,
], 72630.8601
"mean_audio_rtf": [ ],
0.1175, "mean_audio_rtf": [
0.18, 0.1175,
0.2359, 0.18,
0.3278, 0.2359,
0.6296 0.3278,
] 0.6296
]
}
} }
}, },
{ {
@@ -90,15 +93,17 @@
}, },
"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": [ "H100": {
354.0951 "mean_ttft_ms": [
], 354.0951
"mean_e2el_ms": [ ],
3952.5615 "mean_e2el_ms": [
], 3952.5615
"mean_audio_rtf": [ ],
0.117 "mean_audio_rtf": [
] 0.117
]
}
} }
}, },
{ {
@@ -127,15 +132,17 @@
}, },
"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": [ "H100": {
259.6317 "mean_ttft_ms": [
], 259.6317
"mean_e2el_ms": [ ],
4253.3009 "mean_e2el_ms": [
], 4253.3009
"mean_audio_rtf": [ ],
0.1448 "mean_audio_rtf": [
] 0.1448
]
}
} }
}, },
{ {
@@ -166,15 +173,17 @@
}, },
"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": [ "H100": {
457.4453 "mean_ttft_ms": [
], 457.4453
"mean_e2el_ms": [ ],
5912.5623 "mean_e2el_ms": [
], 5912.5623
"mean_audio_rtf": [ ],
0.1967 "mean_audio_rtf": [
] 0.1967
]
}
} }
}, },
{ {
@@ -205,20 +214,22 @@
}, },
"percentile-metrics": "ttft,tpot,itl,e2el", "percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": { "baseline": {
"mean_ttft_ms": [ "H100": {
90.8033, "mean_ttft_ms": [
202.1779, 90.8033,
326.5864, 202.1779,
544.8914, 326.5864,
961.3929 544.8914,
], 961.3929
"mean_e2el_ms": [ ],
4517.3411, "mean_e2el_ms": [
7857.9711, 4517.3411,
9861.1687, 7857.9711,
13283.3363, 9861.1687,
19541.0736 13283.3363,
] 19541.0736
]
}
} }
}, },
{ {
@@ -250,12 +261,14 @@
}, },
"percentile-metrics": "ttft,tpot,itl,e2el", "percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": { "baseline": {
"mean_ttft_ms": [ "H100": {
318.048 "mean_ttft_ms": [
], 318.048
"mean_e2el_ms": [ ],
807.6114 "mean_e2el_ms": [
] 807.6114
]
}
} }
}, },
{ {
@@ -289,12 +302,14 @@
}, },
"percentile-metrics": "ttft,tpot,itl,e2el", "percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": { "baseline": {
"mean_ttft_ms": [ "H100": {
186.168 "mean_ttft_ms": [
], 186.168
"mean_e2el_ms": [ ],
726.1137 "mean_e2el_ms": [
] 726.1137
]
}
} }
}, },
{ {
@@ -330,12 +345,14 @@
}, },
"percentile-metrics": "ttft,tpot,itl,e2el", "percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": { "baseline": {
"mean_ttft_ms": [ "H100": {
375.5282 "mean_ttft_ms": [
], 375.5282
"mean_e2el_ms": [ ],
1006.1663 "mean_e2el_ms": [
] 1006.1663
]
}
} }
} }
] ]
@@ -35,10 +35,12 @@
"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": [130.5751, 278.6469, 324.7249, 911.6085], "H100": {
"mean_tpot_ms": [7.9854, 11.1209, 16.1507, 20.7485], "mean_ttft_ms": [130.5751, 278.6469, 324.7249, 911.6085],
"mean_audio_ttfp_ms": [486.486, 954.1791, 1289.6514, 2196.8275], "mean_tpot_ms": [7.9854, 11.1209, 16.1507, 20.7485],
"mean_audio_rtf": [0.1896, 0.3962, 0.4098, 0.7322] "mean_audio_ttfp_ms": [486.486, 954.1791, 1289.6514, 2196.8275],
"mean_audio_rtf": [0.1896, 0.3962, 0.4098, 0.7322]
}
} }
}, },
{ {
@@ -61,9 +63,11 @@
}, },
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": { "baseline": {
"mean_ttft_ms": [340.5122], "H100": {
"mean_audio_ttfp_ms": [463.0752], "mean_ttft_ms": [340.5122],
"mean_audio_rtf": [0.1153] "mean_audio_ttfp_ms": [463.0752],
"mean_audio_rtf": [0.1153]
}
} }
}, },
{ {
@@ -88,9 +92,11 @@
}, },
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": { "baseline": {
"mean_ttft_ms": [236.926], "H100": {
"mean_audio_ttfp_ms": [395.2182], "mean_ttft_ms": [236.926],
"mean_audio_rtf": [0.1259] "mean_audio_ttfp_ms": [395.2182],
"mean_audio_rtf": [0.1259]
}
} }
}, },
{ {
@@ -117,9 +123,11 @@
}, },
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": { "baseline": {
"mean_ttft_ms": [470.6126], "H100": {
"mean_audio_ttfp_ms": [665.5474], "mean_ttft_ms": [470.6126],
"mean_audio_rtf": [0.166] "mean_audio_ttfp_ms": [665.5474],
"mean_audio_rtf": [0.166]
}
} }
} }
] ]
@@ -5,7 +5,8 @@
{ {
"hardware_marks": { "hardware_marks": {
"res": { "res": {
"cuda": "H100" "cuda": "H100",
"npu": "A3"
}, },
"num_cards": 2 "num_cards": 2
} }
@@ -43,27 +44,29 @@
"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": [ "H100": {
115.8348, "mean_ttft_ms": [
173.1823, 115.8348,
446.672, 173.1823,
464.5985, 446.672,
3420.0634 464.5985,
], 3420.0634
"mean_e2el_ms": [ ],
25070.9523, "mean_e2el_ms": [
31992.0646, 25070.9523,
37619.7669, 31992.0646,
50393.8257, 37619.7669,
136619.2743 50393.8257,
], 136619.2743
"mean_audio_rtf": [ ],
0.169, "mean_audio_rtf": [
0.2111, 0.169,
0.2475, 0.2111,
0.3551, 0.2475,
0.9266 0.3551,
] 0.9266
]
}
} }
}, },
{ {
@@ -90,15 +93,17 @@
}, },
"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": [ "H100": {
343.1377 "mean_ttft_ms": [
], 343.1377
"mean_e2el_ms": [ ],
4811.1123 "mean_e2el_ms": [
], 4811.1123
"mean_audio_rtf": [ ],
0.1463 "mean_audio_rtf": [
] 0.1463
]
}
} }
}, },
{ {
@@ -127,15 +132,17 @@
}, },
"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": [ "H100": {
245.5446 "mean_ttft_ms": [
], 245.5446
"mean_e2el_ms": [ ],
5019.1256 "mean_e2el_ms": [
], 5019.1256
"mean_audio_rtf": [ ],
0.1713 "mean_audio_rtf": [
] 0.1713
]
}
} }
}, },
{ {
@@ -166,15 +173,17 @@
}, },
"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": [ "H100": {
506.1407 "mean_ttft_ms": [
], 506.1407
"mean_e2el_ms": [ ],
6814.9656 "mean_e2el_ms": [
], 6814.9656
"mean_audio_rtf": [ ],
0.2213 "mean_audio_rtf": [
] 0.2213
]
}
} }
} }
] ]
@@ -51,20 +51,22 @@
}, },
"percentile-metrics": "ttft,tpot,itl,e2el", "percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": { "baseline": {
"mean_ttft_ms": [ "H100": {
69.8081, "mean_ttft_ms": [
173.0651, 69.8081,
271.8108, 173.0651,
413.4079, 271.8108,
497.2546 413.4079,
], 497.2546
"mean_e2el_ms": [ ],
4720.8225, "mean_e2el_ms": [
7937.2532, 4720.8225,
9962.3566, 7937.2532,
13724.5479, 9962.3566,
18324.091 13724.5479,
] 18324.091
]
}
} }
}, },
{ {
@@ -97,12 +99,14 @@
}, },
"percentile-metrics": "ttft,tpot,itl,e2el", "percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": { "baseline": {
"mean_ttft_ms": [ "H100": {
25.9477 "mean_ttft_ms": [
], 25.9477
"mean_e2el_ms": [ ],
517.3155 "mean_e2el_ms": [
] 517.3155
]
}
} }
}, },
{ {
@@ -137,12 +141,14 @@
}, },
"percentile-metrics": "ttft,tpot,itl,e2el", "percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": { "baseline": {
"mean_ttft_ms": [ "H100": {
26.7056 "mean_ttft_ms": [
], 26.7056
"mean_e2el_ms": [ ],
551.308 "mean_e2el_ms": [
] 551.308
]
}
} }
}, },
{ {
@@ -179,12 +185,14 @@
}, },
"percentile-metrics": "ttft,tpot,itl,e2el", "percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": { "baseline": {
"mean_ttft_ms": [ "H100": {
27.6906 "mean_ttft_ms": [
], 27.6906
"mean_e2el_ms": [ ],
596.5284 "mean_e2el_ms": [
] 596.5284
]
}
} }
} }
] ]
@@ -34,10 +34,12 @@
"num-input-images": 2, "num-input-images": 2,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.0721, "H100": {
"latency_mean": 13.8398, "throughput_qps": 0.0721,
"peak_memory_mb_max": 57610.0, "latency_mean": 13.8398,
"peak_memory_mb_mean": 57610.0 "peak_memory_mb_max": 57610.0,
"peak_memory_mb_mean": 57610.0
}
} }
}, },
{ {
@@ -52,10 +54,12 @@
"num-input-images": 2, "num-input-images": 2,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.0186, "H100": {
"latency_mean": 53.6942, "throughput_qps": 0.0186,
"peak_memory_mb_max": 67790.0, "latency_mean": 53.6942,
"peak_memory_mb_mean": 67790.0 "peak_memory_mb_max": 67790.0,
"peak_memory_mb_mean": 67790.0
}
} }
} }
] ]
@@ -66,7 +70,8 @@
{ {
"hardware_marks": { "hardware_marks": {
"res": { "res": {
"cuda": "H100" "cuda": "H100",
"npu": "A3"
}, },
"num_cards": 4 "num_cards": 4
} }
@@ -99,10 +104,12 @@
"num-input-images": 2, "num-input-images": 2,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.0538, "H100": {
"latency_mean": 18.5591, "throughput_qps": 0.0538,
"peak_memory_mb_max": 56449.4286, "latency_mean": 18.5591,
"peak_memory_mb_mean": 56449.4286 "peak_memory_mb_max": 56449.4286,
"peak_memory_mb_mean": 56449.4286
}
} }
} }
] ]
@@ -156,10 +163,12 @@
"num-input-images": 2, "num-input-images": 2,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.2776, "H100": {
"latency_mean": 3.5938, "throughput_qps": 0.2776,
"peak_memory_mb_max": 58113.7143, "latency_mean": 3.5938,
"peak_memory_mb_mean": 58075.1429 "peak_memory_mb_max": 58113.7143,
"peak_memory_mb_mean": 58075.1429
}
} }
}, },
{ {
@@ -174,10 +183,12 @@
"num-input-images": 2, "num-input-images": 2,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.1051, "H100": {
"latency_mean": 9.4918, "throughput_qps": 0.1051,
"peak_memory_mb_max": 68107.4286, "latency_mean": 9.4918,
"peak_memory_mb_mean": 68107.4286 "peak_memory_mb_max": 68107.4286,
"peak_memory_mb_mean": 68107.4286
}
} }
} }
] ]
@@ -33,10 +33,12 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.1129, "H100": {
"latency_mean": 8.8352, "throughput_qps": 0.1129,
"peak_memory_mb_max": 58572.0, "latency_mean": 8.8352,
"peak_memory_mb_mean": 58572.0 "peak_memory_mb_max": 58572.0,
"peak_memory_mb_mean": 58572.0
}
} }
}, },
{ {
@@ -50,10 +52,12 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.024, "H100": {
"latency_mean": 41.5686, "throughput_qps": 0.024,
"peak_memory_mb_max": 66368.0, "latency_mean": 41.5686,
"peak_memory_mb_mean": 66368.0 "peak_memory_mb_max": 66368.0,
"peak_memory_mb_mean": 66368.0
}
} }
} }
] ]
@@ -96,10 +100,12 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.0672, "H100": {
"latency_mean": 14.8517, "throughput_qps": 0.0672,
"peak_memory_mb_max": 57076.0, "latency_mean": 14.8517,
"peak_memory_mb_mean": 57076.0 "peak_memory_mb_max": 57076.0,
"peak_memory_mb_mean": 57076.0
}
} }
} }
] ]
@@ -152,10 +158,12 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.2879, "H100": {
"latency_mean": 3.4701, "throughput_qps": 0.2879,
"peak_memory_mb_max": 58758.0, "latency_mean": 3.4701,
"peak_memory_mb_mean": 58758.0 "peak_memory_mb_max": 58758.0,
"peak_memory_mb_mean": 58758.0
}
} }
}, },
{ {
@@ -169,10 +177,12 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.1113, "H100": {
"latency_mean": 8.9656, "throughput_qps": 0.1113,
"peak_memory_mb_max": 67424.0, "latency_mean": 8.9656,
"peak_memory_mb_mean": 67424.0 "peak_memory_mb_max": 67424.0,
"peak_memory_mb_mean": 67424.0
}
} }
} }
] ]
@@ -33,10 +33,12 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.0703, "H100": {
"latency_mean": 14.208, "throughput_qps": 0.0703,
"peak_memory_mb_max": 63225.1429, "latency_mean": 14.208,
"peak_memory_mb_mean": 63225.1429 "peak_memory_mb_max": 63225.1429,
"peak_memory_mb_mean": 63225.1429
}
} }
}, },
{ {
@@ -50,10 +52,12 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.0417, "H100": {
"latency_mean": 23.914, "throughput_qps": 0.0417,
"peak_memory_mb_max": 63225.1429, "latency_mean": 23.914,
"peak_memory_mb_mean": 63225.1429 "peak_memory_mb_max": 63225.1429,
"peak_memory_mb_mean": 63225.1429
}
} }
} }
] ]
@@ -33,9 +33,11 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.4153, "H100": {
"latency_mean": 2.4065, "throughput_qps": 0.4153,
"peak_memory_mb_mean": 55121.4286 "latency_mean": 2.4065,
"peak_memory_mb_mean": 55121.4286
}
} }
}, },
{ {
@@ -49,9 +51,11 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.0423, "H100": {
"latency_mean": 23.5991, "throughput_qps": 0.0423,
"peak_memory_mb_mean": 65118.0 "latency_mean": 23.5991,
"peak_memory_mb_mean": 65118.0
}
} }
} }
] ]
@@ -91,9 +95,11 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.4175, "H100": {
"latency_mean": 2.3953, "throughput_qps": 0.4175,
"peak_memory_mb_mean": 55088.5714 "latency_mean": 2.3953,
"peak_memory_mb_mean": 55088.5714
}
} }
}, },
{ {
@@ -107,9 +113,11 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.0423, "H100": {
"latency_mean": 23.5768, "throughput_qps": 0.0423,
"peak_memory_mb_mean": 66012.2857 "latency_mean": 23.5768,
"peak_memory_mb_mean": 66012.2857
}
} }
} }
] ]
@@ -152,9 +160,11 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.1214, "H100": {
"latency_mean": 8.2211, "throughput_qps": 0.1214,
"peak_memory_mb_mean": 54501.1429 "latency_mean": 8.2211,
"peak_memory_mb_mean": 54501.1429
}
} }
} }
] ]
@@ -207,9 +217,11 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.559, "H100": {
"latency_mean": 1.794, "throughput_qps": 0.559,
"peak_memory_mb_mean": 55290.5714 "latency_mean": 1.794,
"peak_memory_mb_mean": 55290.5714
}
} }
}, },
{ {
@@ -223,9 +235,11 @@
"max-concurrency": 1, "max-concurrency": 1,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": 0.1918, "H100": {
"latency_mean": 5.2022, "throughput_qps": 0.1918,
"peak_memory_mb_mean": 64838.8571 "latency_mean": 5.2022,
"peak_memory_mb_mean": 64838.8571
}
} }
} }
] ]
@@ -269,9 +283,11 @@
"warmup-num-inference-steps": 20, "warmup-num-inference-steps": 20,
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"throughput_qps": [0.4125, 0.6714, 0.6674, 0.8159], "H100": {
"latency_mean": [2.4215, 2.992, 5.9293, 9.656], "throughput_qps": [0.4125, 0.6714, 0.6674, 0.8159],
"peak_memory_mb_mean": [55147.4286, 55148.0, 55150.4643, 55152.3821] "latency_mean": [2.4215, 2.992, 5.9293, 9.656],
"peak_memory_mb_mean": [55147.4286, 55148.0, 55150.4643, 55152.3821]
}
} }
} }
] ]
+236 -36
View File
@@ -1,6 +1,8 @@
"""Tests for DFX runner metadata field exclusion.""" """Tests for DFX runner metadata field exclusion."""
import json import json
from contextlib import contextmanager
from types import SimpleNamespace
import pytest import pytest
@@ -15,7 +17,7 @@ def test_task_excluded_from_cli_args():
"backend": "openai-audio-speech", "backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech", "endpoint": "/v1/audio/speech",
"percentile-metrics": "audio_rtf,audio_ttfp", "percentile-metrics": "audio_rtf,audio_ttfp",
"baseline": {"mean_audio_rtf": [0.5]}, "baseline": {"H100": {"mean_audio_rtf": [0.5]}},
} }
exclude_keys = { exclude_keys = {
"request_rate", "request_rate",
@@ -121,35 +123,6 @@ def test_resolve_pytest_marks_rejects_legacy_object_format():
) )
def test_extract_mark_resource_label():
from tests.dfx.conftest import extract_mark_resource_label
assert extract_mark_resource_label(None) == "na"
assert (
extract_mark_resource_label(
[
{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}},
"full_model",
]
)
== "H100"
)
assert (
extract_mark_resource_label(
[
{
"hardware_marks": {
"res": {"cuda": "H100", "rocm": "MI325"},
"num_cards": 2,
}
},
"diffusion",
]
)
== "H100-MI325"
)
def test_resource_label_for_filename(): def test_resource_label_for_filename():
from tests.dfx.conftest import resource_label_for_filename from tests.dfx.conftest import resource_label_for_filename
@@ -167,16 +140,14 @@ def test_hardware_json_value():
assert hardware_json_value(None) == "" assert hardware_json_value(None) == ""
def test_extract_configs_resource_label(monkeypatch): def test_get_runtime_resource_label(monkeypatch):
from tests.dfx.conftest import extract_configs_resource_label, get_runtime_resource_label from tests.dfx.conftest import get_runtime_resource_label
monkeypatch.setattr( monkeypatch.setattr(
"tests.dfx.conftest._read_runtime_device_name", "tests.dfx.conftest._read_runtime_device_name",
lambda *, device_id=0: "NVIDIA H100 80GB HBM3", lambda *, device_id=0: "NVIDIA H100 80GB HBM3",
) )
get_runtime_resource_label(refresh=True) assert get_runtime_resource_label(refresh=True) == "H100"
assert extract_configs_resource_label([]) == "H100"
get_runtime_resource_label(refresh=True)
monkeypatch.setattr( monkeypatch.setattr(
"tests.dfx.conftest._read_runtime_device_name", "tests.dfx.conftest._read_runtime_device_name",
lambda *, device_id=0: "Ascend910B2", lambda *, device_id=0: "Ascend910B2",
@@ -254,8 +225,9 @@ def test_benchmark_param_id_suffix_from_task_eval_phase():
] ]
def test_create_paired_omni_benchmark_pytest_params(tmp_path): def test_paired_omni_benchmark_reuses_server_and_preserves_case_metadata(tmp_path, monkeypatch):
from tests.dfx.conftest import create_paired_omni_benchmark_pytest_params from tests.dfx.conftest import create_paired_omni_benchmark_pytest_params
from tests.dfx.perf.scripts import run_benchmark
configs = [ configs = [
{ {
@@ -285,3 +257,231 @@ def test_create_paired_omni_benchmark_pytest_params(tmp_path):
assert bench_row == ("test_tts", 0) assert bench_row == ("test_tts", 0)
assert any(m.name == "tts" for m in by_id["test_tts-p0"].marks) assert any(m.name == "tts" for m in by_id["test_tts-p0"].marks)
assert not any(m.name == "tts" for m in by_id["test_omni-p0"].marks) assert not any(m.name == "tts" for m in by_id["test_omni-p0"].marks)
omni_p0_server = by_id["test_omni-p0"].values[0]
omni_p1_server = by_id["test_omni-p1"].values[0]
tts_server = by_id["test_tts-p0"].values[0]
assert omni_p0_server == omni_p1_server
assert omni_p0_server != tts_server
events = []
@contextmanager
def fake_start(server_param):
events.append(("start", server_param))
yield object()
events.append(("stop", server_param))
monkeypatch.setattr(run_benchmark, "_start_omni_server", fake_start)
active_context = run_benchmark._SingleActiveContext()
try:
first = run_benchmark.omni_server.__wrapped__(
SimpleNamespace(param=omni_p0_server),
active_context,
)
second = run_benchmark.omni_server.__wrapped__(
SimpleNamespace(param=omni_p1_server),
active_context,
)
assert first is second
assert events == [("start", omni_p0_server)]
third = run_benchmark.omni_server.__wrapped__(
SimpleNamespace(param=tts_server),
active_context,
)
assert third is not first
assert events == [
("start", omni_p0_server),
("stop", omni_p0_server),
("start", tts_server),
]
finally:
active_context.close()
assert events == [
("start", omni_p0_server),
("stop", omni_p0_server),
("start", tts_server),
("stop", tts_server),
]
def test_is_hardware_nested_baseline():
from tests.dfx.conftest import (
_RUNTIME_DEVICE_ALIASES,
is_hardware_nested_baseline,
)
from tests.helpers.mark import get_hardware_mark_list
hardware_marks = get_hardware_mark_list()
assert hardware_marks
assert {"H100", "L4", "A3", "MI325", "B200"} <= hardware_marks
assert "cuda" not in hardware_marks
assert "full_model" not in hardware_marks
# Runtime aliases are a full static list (independent of baseline allowlist).
assert "H100" in _RUNTIME_DEVICE_ALIASES
assert "A100" in _RUNTIME_DEVICE_ALIASES
assert is_hardware_nested_baseline(
{
"H100": {"mean_ttft_ms": [1.0, 2.0], "mean_e2el_ms": [10.0, 20.0]},
"A3": {"mean_ttft_ms": [0.9, 1.8], "mean_e2el_ms": [9.0, 18.0]},
}
)
# Custom metric names under a known hardware label are allowed.
assert is_hardware_nested_baseline({"H100": {"custom_stage_ms": 12.0, "foo_bar": [1.0, 2.0]}})
assert is_hardware_nested_baseline({"A3": {"request_throughput": 0.5}})
# Flat metric maps / concurrency-keyed maps are not hardware-nested.
assert not is_hardware_nested_baseline({"mean_ttft_ms": [1.0, 2.0], "mean_e2el_ms": [10.0, 20.0]})
assert not is_hardware_nested_baseline({"mean_ttft_ms": {"1": 1.0, "32": 2.0}})
assert not is_hardware_nested_baseline({})
# Alias-only labels (not [hardware-resource] markers) are rejected for baselines.
assert not is_hardware_nested_baseline({"A100": {"throughput_qps": 1.0}})
# Unknown top-level hardware label is rejected.
assert not is_hardware_nested_baseline({"UnknownGPU": {"throughput_qps": 1.0}})
# Empty per-hardware metric map is rejected.
assert not is_hardware_nested_baseline({"H100": {}})
def test_resolve_baseline_for_sweep_keeps_all_hardware_for_one_concurrency():
from tests.dfx.conftest import resolve_baseline_for_sweep
baseline = {
"H100": {
"mean_ttft_ms": [96.4, 140.8, 271.9, 362.3, 507.8],
"mean_e2el_ms": [18507.0, 28365.0, 31907.0, 48161.0, 72630.0],
},
"A3": {
"mean_ttft_ms": [90.0, 130.0, 250.0, 340.0, 480.0],
"mean_e2el_ms": [17000.0, 26000.0, 30000.0, 45000.0, 70000.0],
},
}
# max_concurrency=[1,4,8,16,32] -> index 4 is concurrency 32
got = resolve_baseline_for_sweep(baseline, sweep_index=4)
assert got == {
"H100": {"mean_ttft_ms": 507.8, "mean_e2el_ms": 72630.0},
"A3": {"mean_ttft_ms": 480.0, "mean_e2el_ms": 70000.0},
}
# First sweep step keeps both hardware buckets too.
got0 = resolve_baseline_for_sweep(baseline, sweep_index=0)
assert set(got0) == {"H100", "A3"}
assert got0["H100"]["mean_ttft_ms"] == 96.4
assert got0["A3"]["mean_ttft_ms"] == 90.0
def test_resolve_baseline_for_sweep_rejects_flat_baseline():
from tests.dfx.conftest import resolve_baseline_for_sweep
with pytest.raises(ValueError, match=r"hardware-nested.*pyproject\.toml"):
resolve_baseline_for_sweep(
{"throughput_qps": [0.4, 0.6], "latency_mean": [1.0, 2.0]},
sweep_index=1,
)
def test_resolve_baseline_for_sweep_rejects_unknown_hardware_label():
from tests.dfx.conftest import resolve_baseline_for_sweep
with pytest.raises(ValueError, match=r"Unknown hardware label\(s\): \['A100'\].*pyproject\.toml"):
resolve_baseline_for_sweep(
{"A100": {"throughput_qps": 1.0}},
sweep_index=0,
)
def test_resolve_baseline_for_sweep_supports_list_and_scalar_under_hardware():
from tests.dfx.conftest import resolve_baseline_for_sweep
# Sweep-aligned lists under each hardware bucket (canonical form).
listed = {
"H100": {"throughput_qps": [0.4, 0.6, 0.8], "latency_mean": [1.0, 2.0, 3.0]},
"A3": {"throughput_qps": [0.5, 0.7, 0.9], "latency_mean": [0.9, 1.8, 2.7]},
}
assert resolve_baseline_for_sweep(listed, sweep_index=1) == {
"H100": {"throughput_qps": 0.6, "latency_mean": 2.0},
"A3": {"throughput_qps": 0.7, "latency_mean": 1.8},
}
# Scalars under hardware stay as-is (single-concurrency cases).
scalar = {"H100": {"throughput_qps": 0.5}}
assert resolve_baseline_for_sweep(scalar, sweep_index=0) == {"H100": {"throughput_qps": 0.5}}
# Custom metric names are preserved.
custom = {"H100": {"custom_stage_ms": [10.0, 20.0]}}
assert resolve_baseline_for_sweep(custom, sweep_index=1) == {"H100": {"custom_stage_ms": 20.0}}
assert resolve_baseline_for_sweep(None) == {}
assert resolve_baseline_for_sweep({}) == {}
def test_resolve_baseline_value_errors():
from tests.dfx.conftest import resolve_baseline_value
with pytest.raises(ValueError, match="sweep_index"):
resolve_baseline_value([1.0, 2.0], sweep_index=None)
with pytest.raises(IndexError):
resolve_baseline_value([1.0], sweep_index=1)
with pytest.raises(TypeError, match="not supported"):
resolve_baseline_value({"1": 0.4}, sweep_index=0)
def test_diffusion_build_run_params_resolves_baseline_per_sweep(tmp_path, monkeypatch):
"""``_build_run_params`` / ``_iter_sweep_runs`` narrow list baselines per concurrency."""
import importlib
import sys
cfg = tmp_path / "mini_diffusion_perf.json"
cfg.write_text(
json.dumps(
[
{
"test_name": "test_mini",
"server_type": "vllm-omni",
"mark": [
{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}},
"diffusion",
"full_model",
],
"server_params": {"model": "m/mini"},
"benchmark_params": [{"name": "p0", "num-prompts": 1, "max-concurrency": 1}],
}
]
),
encoding="utf-8",
)
monkeypatch.setattr(sys, "argv", ["pytest", "--test-config-file", str(cfg)])
sys.modules.pop("tests.dfx.perf.scripts.run_diffusion_benchmark", None)
from tests.dfx.perf.scripts import run_diffusion_benchmark as rdb
importlib.reload(rdb)
params = {
"name": "c_sweep",
"dataset": "random",
"task": "t2i",
"num-prompts": [8, 16, 32],
"max-concurrency": [1, 8, 32],
"baseline": {
"H100": {"throughput_qps": [0.1, 0.2, 0.3], "latency_mean": [10.0, 20.0, 30.0]},
"A3": {"throughput_qps": [0.11, 0.22, 0.33], "latency_mean": [9.0, 19.0, 29.0]},
},
}
run32 = rdb._build_run_params(
params,
num_prompts=32,
max_concurrency=32,
request_rate="inf",
sweep_index=2,
)
assert run32["max-concurrency"] == 32
assert run32["baseline"] == {
"H100": {"throughput_qps": 0.3, "latency_mean": 30.0},
"A3": {"throughput_qps": 0.33, "latency_mean": 29.0},
}
sweeps = rdb._iter_sweep_runs(params)
assert len(sweeps) == 3
assert sweeps[2]["params"]["max-concurrency"] == 32
assert sweeps[2]["params"]["baseline"]["H100"]["throughput_qps"] == 0.3
assert "A3" in sweeps[2]["params"]["baseline"]
assert not isinstance(sweeps[2]["params"]["baseline"]["H100"]["throughput_qps"], list)
+137 -111
View File
@@ -39,12 +39,14 @@
"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": [ "H100": {
141.2791 "median_audio_ttfp_ms": [
], 141.2791
"median_audio_rtf": [ ],
0.1591 "median_audio_rtf": [
] 0.1591
]
}
} }
}, },
{ {
@@ -67,21 +69,23 @@
"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": [ "H100": {
226.747, "median_audio_ttfp_ms": [
647.1272, 226.747,
4777.2714 647.1272,
], 4777.2714
"median_audio_rtf": [ ],
0.3313, "median_audio_rtf": [
0.3642, 0.3313,
1.2282 0.3642,
], 1.2282
"audio_throughput": [ ],
24.2296, "audio_throughput": [
40.3552, 24.2296,
43.2572 40.3552,
] 43.2572
]
}
} }
}, },
{ {
@@ -102,9 +106,11 @@
"seed_tts_wer_eval": true, "seed_tts_wer_eval": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": { "baseline": {
"mean_audio_rtf": [ "H100": {
0.45 "mean_audio_rtf": [
] 0.45
]
}
} }
} }
] ]
@@ -153,12 +159,14 @@
}, },
"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": [ "H100": {
46.8434 "median_audio_ttfp_ms": [
], 46.8434
"median_audio_rtf": [ ],
0.1367 "median_audio_rtf": [
] 0.1367
]
}
} }
}, },
{ {
@@ -186,21 +194,23 @@
}, },
"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": [ "H100": {
75.3341, "median_audio_ttfp_ms": [
713.7727, 75.3341,
5941.7179 713.7727,
], 5941.7179
"median_audio_rtf": [ ],
0.1859, "median_audio_rtf": [
0.3148, 0.1859,
1.1662 0.3148,
], 1.1662
"audio_throughput": [ ],
38.3858, "audio_throughput": [
55.3523, 38.3858,
68.7032 55.3523,
] 68.7032
]
}
} }
}, },
{ {
@@ -226,9 +236,11 @@
"seed_tts_wer_eval": true, "seed_tts_wer_eval": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": { "baseline": {
"mean_audio_rtf": [ "H100": {
0.35 "mean_audio_rtf": [
] 0.35
]
}
} }
}, },
{ {
@@ -252,15 +264,17 @@
}, },
"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": [ "H100": {
56.746 "median_audio_ttfp_ms": [
], 56.746
"median_audio_rtf": [ ],
0.1571 "median_audio_rtf": [
], 0.1571
"audio_throughput": [ ],
11.1226 "audio_throughput": [
] 11.1226
]
}
} }
}, },
{ {
@@ -283,12 +297,14 @@
}, },
"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": [ "H100": {
46.3479 "median_audio_ttfp_ms": [
], 46.3479
"median_audio_rtf": [ ],
0.1352 "median_audio_rtf": [
] 0.1352
]
}
} }
}, },
{ {
@@ -315,21 +331,23 @@
}, },
"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": [ "H100": {
75.5967, "median_audio_ttfp_ms": [
587.5877, 75.5967,
4960.3726 587.5877,
], 4960.3726
"median_audio_rtf": [ ],
0.1871, "median_audio_rtf": [
0.3143, 0.1871,
1.1703 0.3143,
], 1.1703
"audio_throughput": [ ],
38.9378, "audio_throughput": [
55.1846, 38.9378,
71.5708 55.1846,
] 71.5708
]
}
} }
}, },
{ {
@@ -352,15 +370,17 @@
}, },
"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": [ "H100": {
55.5029 "median_audio_ttfp_ms": [
], 55.5029
"median_audio_rtf": [ ],
0.1508 "median_audio_rtf": [
], 0.1508
"audio_throughput": [ ],
9.4885 "audio_throughput": [
] 9.4885
]
}
} }
} }
] ]
@@ -402,12 +422,14 @@
}, },
"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": [ "H100": {
82.1886 "median_audio_ttfp_ms": [
], 82.1886
"median_audio_rtf": [ ],
0.1241 "median_audio_rtf": [
] 0.1241
]
}
} }
}, },
{ {
@@ -429,15 +451,17 @@
}, },
"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": [ "H100": {
160.9168 "median_audio_ttfp_ms": [
], 160.9168
"median_audio_rtf": [ ],
0.1907 "median_audio_rtf": [
], 0.1907
"audio_throughput": [ ],
39.2332 "audio_throughput": [
] 39.2332
]
}
} }
}, },
{ {
@@ -459,15 +483,17 @@
}, },
"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": [ "H100": {
100.1265 "median_audio_ttfp_ms": [
], 100.1265
"median_audio_rtf": [ ],
0.1437 "median_audio_rtf": [
], 0.1437
"audio_throughput": [ ],
11.7605 "audio_throughput": [
] 11.7605
]
}
} }
} }
] ]
+59 -45
View File
@@ -37,12 +37,14 @@
"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": [ "H100": {
137.645 "median_audio_ttfp_ms": [
], 137.645
"median_audio_rtf": [ ],
0.0497 "median_audio_rtf": [
] 0.0497
]
}
} }
}, },
{ {
@@ -61,15 +63,17 @@
"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": [ "H100": {
475.3357 "median_audio_ttfp_ms": [
], 475.3357
"median_audio_rtf": [ ],
0.1243 "median_audio_rtf": [
], 0.1243
"audio_throughput": [ ],
36.6358 "audio_throughput": [
] 36.6358
]
}
} }
}, },
{ {
@@ -90,9 +94,11 @@
"seed_tts_wer_eval": true, "seed_tts_wer_eval": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": { "baseline": {
"mean_audio_rtf": [ "H100": {
0.45 "mean_audio_rtf": [
] 0.45
]
}
} }
}, },
{ {
@@ -114,12 +120,14 @@
}, },
"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": [ "H100": {
79.6927 "median_audio_ttfp_ms": [
], 79.6927
"median_audio_rtf": [ ],
0.0423 "median_audio_rtf": [
] 0.0423
]
}
} }
}, },
{ {
@@ -141,15 +149,17 @@
}, },
"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": [ "H100": {
262.6212 "median_audio_ttfp_ms": [
], 262.6212
"median_audio_rtf": [ ],
0.0753 "median_audio_rtf": [
], 0.0753
"audio_throughput": [ ],
103.5843 "audio_throughput": [
] 103.5843
]
}
} }
}, },
{ {
@@ -171,15 +181,17 @@
}, },
"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": [ "H100": {
80.1095 "median_audio_ttfp_ms": [
], 80.1095
"median_audio_rtf": [ ],
0.0479 "median_audio_rtf": [
], 0.0479
"audio_throughput": [ ],
17.2918 "audio_throughput": [
] 17.2918
]
}
} }
}, },
{ {
@@ -203,9 +215,11 @@
"seed_tts_wer_eval": true, "seed_tts_wer_eval": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": { "baseline": {
"mean_audio_rtf": [ "H100": {
0.35 "mean_audio_rtf": [
] 0.35
]
}
} }
} }
] ]
@@ -42,9 +42,11 @@
} }
], ],
"baseline": { "baseline": {
"throughput_qps": 0.0434, "H100": {
"latency_mean": 23.0654, "throughput_qps": 0.0434,
"peak_memory_mb_mean": 76360.0 "latency_mean": 23.0654,
"peak_memory_mb_mean": 76360.0
}
} }
} }
] ]
@@ -55,7 +57,8 @@
{ {
"hardware_marks": { "hardware_marks": {
"res": { "res": {
"cuda": "H100" "cuda": "H100",
"npu": "A3"
}, },
"num_cards": 2 "num_cards": 2
} }
@@ -96,9 +99,11 @@
} }
], ],
"baseline": { "baseline": {
"throughput_qps": 0.0606, "H100": {
"latency_mean": 16.4928, "throughput_qps": 0.0606,
"peak_memory_mb_mean": 47164.8 "latency_mean": 16.4928,
"peak_memory_mb_mean": 47164.8
}
} }
}, },
{ {
@@ -121,9 +126,11 @@
} }
], ],
"baseline": { "baseline": {
"throughput_qps": 0.0124, "H100": {
"latency_mean": 80.7784, "throughput_qps": 0.0124,
"peak_memory_mb_mean": 55374.3429 "latency_mean": 80.7784,
"peak_memory_mb_mean": 55374.3429
}
} }
} }
] ]
@@ -14,18 +14,15 @@ from tests.helpers.stage_config import get_deploy_config_path
pytestmark = [pytest.mark.slow, pytest.mark.omni, pytest.mark.full_model] pytestmark = [pytest.mark.slow, pytest.mark.omni, pytest.mark.full_model]
_MINICPMO_DEPLOY = get_deploy_config_path("minicpmo_4_5_batching.yaml") _MINICPMO_DEPLOY = get_deploy_config_path("minicpmo_4_5_2gpu.yaml")
_MINICPMO_SERVER = [ _MINICPMO_SERVER = [
pytest.param( pytest.param(
OmniServerParams( OmniServerParams(
model="openbmb/MiniCPM-o-4_5", model="openbmb/MiniCPM-o-4_5",
stage_config_path=_MINICPMO_DEPLOY, stage_config_path=_MINICPMO_DEPLOY,
use_stage_cli=True, use_stage_cli=False,
server_args=[ server_args=["--trust-remote-code"],
"--trust-remote-code",
"--no-async-chunk",
],
), ),
id="minicpmo_4_5", id="minicpmo_4_5",
) )
@@ -51,12 +48,12 @@ def _prompt() -> str:
return "What is the capital of China? Answer in 20 words." return "What is the capital of China? Answer in 20 words."
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", _MINICPMO_SERVER, indirect=True) @pytest.mark.parametrize("omni_server", _MINICPMO_SERVER, indirect=True)
def test_invalid_audio_format_rejected(omni_server: OmniServerParams, openai_client: OpenAIClientHandler) -> None: def test_invalid_audio_format_rejected(omni_server: OmniServerParams, openai_client: OpenAIClientHandler) -> None:
""" """
Test that invalid audio format is properly rejected. Test that invalid audio format is properly rejected.
Deploy Setting: default 2GPU Deploy Setting: default single GPU
Input Modal: text + invalid audio format Input Modal: text + invalid audio format
Output Modal: error Output Modal: error
""" """
@@ -71,6 +71,30 @@ def test_streaming_payload_can_replace_placeholder_prompt(mocker: MockerFixture)
request.update_block_hashes.assert_called_once_with() request.update_block_hashes.assert_called_once_with()
def test_streaming_payload_can_append_exact_prompt_length(mocker: MockerFixture) -> None:
request = SimpleNamespace(
_all_token_ids=[0, 0, 7, 8],
_output_token_ids=[7, 8],
prompt_token_ids=[0, 0],
num_computed_tokens=4,
num_prompt_tokens=2,
update_block_hashes=mocker.Mock(),
)
payload = {
"ids": {"prompt": [1, 2, 3]},
"meta": {"next_stage_prompt_len": 3},
}
construct_next_stage_streaming_input_prompt(payload, request)
assert request.prompt_token_ids == [0, 0, 7, 8, 0, 0, 0]
assert request._all_token_ids == [0, 0, 7, 8, 0, 0, 0]
assert request._output_token_ids == []
assert request.num_computed_tokens == 4
assert request.num_prompt_tokens == 7
request.update_block_hashes.assert_called_once_with()
@pytest.fixture @pytest.fixture
def build_adapter(monkeypatch, mocker: MockerFixture): def build_adapter(monkeypatch, mocker: MockerFixture):
def _build( def _build(
@@ -252,6 +276,37 @@ def test_save_async_uses_confirmed_tokens_for_async_scheduler_watermark(build_ad
assert len(adapter._pending_save_reqs) == 1 assert len(adapter._pending_save_reqs) == 1
def test_send_single_request_terminal_chunk_still_flushes_processor(build_adapter, monkeypatch):
"""A terminal stop is not a segment boundary (#5383), but the producer-side
processor must still receive the flush signal on the terminal chunk.
Passing only ``is_segment_finished`` starved processors of their final
accumulated payload once terminal stops stopped setting it (#5413: the
downstream stage got ``meta.finished`` with the tail data missing).
"""
adapter, connector = build_adapter(stage_id=0)
request = _req("req-terminal", RequestStatus.FINISHED_STOPPED, external_req_id="ext-terminal")
seen_flush_flags = []
def recording_processor(**kwargs):
seen_flush_flags.append(kwargs["is_finished"])
return OmniPayloadStruct(
codes=CodesStruct(audio=torch.tensor([1, 2, 3], dtype=torch.long)),
)
adapter.custom_process_next_stage_input_func = recording_processor
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None)
adapter._send_single_request(
{"multimodal_output": None, "request": request, "is_finished": True, "is_segment_finished": False}
)
assert seen_flush_flags == [True]
sent_payload = connector.put.call_args.kwargs["data"]
assert bool(sent_payload.meta.finished.item()) is True
assert bool(sent_payload.meta.is_segment_finished.item()) is False
def test_send_single_request_struct_without_meta_does_not_crash(build_adapter, monkeypatch): def test_send_single_request_struct_without_meta_does_not_crash(build_adapter, monkeypatch):
"""Producer may return a struct with ``meta=None`` (e.g. payload that """Producer may return a struct with ``meta=None`` (e.g. payload that
carries only ``embed`` or ``codes``). The sender's ``meta is not None`` carries only ``embed`` or ``codes``). The sender's ``meta is not None``
@@ -314,6 +369,23 @@ def test_send_single_request_struct_preserves_segment_finished(build_adapter, mo
assert sent_payload.meta.is_segment_finished.item() is True assert sent_payload.meta.is_segment_finished.item() is True
def test_send_single_request_respects_processor_receiver_boundary(build_adapter, monkeypatch):
adapter, connector = build_adapter(stage_id=1)
request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream")
adapter.custom_process_next_stage_input_func = lambda **kwargs: OmniPayloadStruct(
meta=MetaStruct(is_segment_finished=torch.tensor(False, dtype=torch.bool))
)
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None)
adapter._send_single_request(
{"multimodal_output": None, "request": request, "is_finished": False, "is_segment_finished": True}
)
sent_payload = connector.put.call_args.kwargs["data"]
assert sent_payload.meta.is_segment_finished.item() is False
def test_save_async_skips_stale_resumable_chunk_until_dedup_is_reset(build_adapter): def test_save_async_skips_stale_resumable_chunk_until_dedup_is_reset(build_adapter):
adapter, _ = build_adapter(stage_id=1) adapter, _ = build_adapter(stage_id=1)
request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream") request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream")
@@ -463,7 +535,7 @@ def test_fifo_promotion(build_adapter):
adapter.process_pending_chunks(waiting_queue, running_queue) adapter.process_pending_chunks(waiting_queue, running_queue)
assert list(adapter._active_streams) == ["req-1", "req-2"] assert list(adapter._active_streams) == ["req-1", "req-2"]
assert waiting_queue == reqs[2:] assert waiting_queue == []
assert reqs[0].status == RequestStatus.WAITING_FOR_CHUNK assert reqs[0].status == RequestStatus.WAITING_FOR_CHUNK
assert reqs[1].status == RequestStatus.WAITING_FOR_CHUNK assert reqs[1].status == RequestStatus.WAITING_FOR_CHUNK
assert reqs[2].status == RequestStatus.WAITING assert reqs[2].status == RequestStatus.WAITING
@@ -473,6 +545,7 @@ def test_fifo_promotion(build_adapter):
# (commit c4d95fd9 — otherwise the terminal chunk deadlocks at c=8 K=2). # (commit c4d95fd9 — otherwise the terminal chunk deadlocks at c=8 K=2).
# Simulate it here so promotion can pick up the freed slot. # Simulate it here so promotion can pick up the freed slot.
adapter._evict_finished_active_streams({"req-1"}) adapter._evict_finished_active_streams({"req-1"})
adapter.restore_queues(waiting_queue, running_queue)
adapter.process_pending_chunks(waiting_queue, running_queue) adapter.process_pending_chunks(waiting_queue, running_queue)
assert list(adapter._active_streams) == ["req-2", "req-3"] assert list(adapter._active_streams) == ["req-2", "req-3"]
@@ -481,6 +554,22 @@ def test_fifo_promotion(build_adapter):
assert reqs[2].status == RequestStatus.WAITING_FOR_CHUNK assert reqs[2].status == RequestStatus.WAITING_FOR_CHUNK
def test_non_active_waiting_request_is_held_off_scheduler(build_adapter):
adapter, _ = build_adapter(stage_id=2, max_num_seqs=2, active_stream_window=1)
active = _req("req-active", RequestStatus.WAITING)
non_active = _req("req-non-active", RequestStatus.WAITING)
waiting_queue = DummyWaitingQueue([active, non_active])
running_queue = []
adapter.process_pending_chunks(waiting_queue, running_queue)
assert waiting_queue == []
assert active.status == RequestStatus.WAITING_FOR_CHUNK
assert non_active.status == RequestStatus.WAITING
assert list(adapter._pending_load_reqs) == [active]
assert list(adapter.waiting_for_chunk_waiting_requests) == [active, non_active]
def test_legacy_k0(build_adapter): def test_legacy_k0(build_adapter):
adapter, _ = build_adapter(stage_id=1, max_num_seqs=1, active_stream_window=0) adapter, _ = build_adapter(stage_id=1, max_num_seqs=1, active_stream_window=0)
waiting_req = _req("waiting", RequestStatus.WAITING) waiting_req = _req("waiting", RequestStatus.WAITING)
@@ -509,12 +598,13 @@ def test_finished_releases_slot(build_adapter):
adapter.process_pending_chunks(waiting_queue, running_queue) adapter.process_pending_chunks(waiting_queue, running_queue)
assert list(adapter._active_streams) == ["req-1"] assert list(adapter._active_streams) == ["req-1"]
assert waiting_queue == [req_2] assert waiting_queue == []
adapter.finished_requests.add("req-1") adapter.finished_requests.add("req-1")
# Eviction is deferred to postprocess_scheduler_output in the runtime path # Eviction is deferred to postprocess_scheduler_output in the runtime path
# (commit c4d95fd9). Simulate it so promotion can pick up the freed slot. # (commit c4d95fd9). Simulate it so promotion can pick up the freed slot.
adapter._evict_finished_active_streams({"req-1"}) adapter._evict_finished_active_streams({"req-1"})
adapter.restore_queues(waiting_queue, running_queue)
adapter.process_pending_chunks(waiting_queue, running_queue) adapter.process_pending_chunks(waiting_queue, running_queue)
assert list(adapter._active_streams) == ["req-2"] assert list(adapter._active_streams) == ["req-2"]
@@ -0,0 +1 @@
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,296 @@
# SPDX-License-Identifier: Apache-2.0
"""MiniCPM-o 4.5 Daily-Omni + Seed-TTS + Video-MME accuracy regression coverage.
Daily-Omni settings follow the MiniCPM interleaved AV recipe (``minicpm-interleave``,
``temperature=0``, text modalities with ``use_tts_template``, and server
``--interleave-mm-strings`` + 1fps / 128-frame media-io kwargs, also pinned in
``minicpmo_4_5.yaml``). Baseline **79.5**; gate **77.5** (drop ≤ 2pp).
Video-MME settings follow OpenBMB OmniEvalKit MiniCPM ``videomme`` (w/o subs):
``minicpm-frames``, ``max_frames=96``, ``temperature=0``, ``output_len=128``.
Baseline **69.0**; gate **67.0** (drop ≤ 2pp).
Seed-TTS (zh, seed-tts-eval protocol): WER baseline **1.414%** / gate **≤ 1.56%**
(+10%).
"""
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import pytest
from tests.e2e.accuracy.qwen3_omni import run_qwen_omni_acc_benchmark as _acc_bench
from tests.e2e.accuracy.qwen3_omni.qwen3_omni_acc_bench_core import (
build_acc_benchmark_cli_argv,
find_vllm_cli,
)
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
_MODEL = os.environ.get("VLLM_TEST_MINICPMO_4_5_MODEL", "openbmb/MiniCPM-o-4_5")
_DEPLOY_CONFIG = get_deploy_config_path("minicpmo_4_5.yaml")
_RESULT_DIR = Path(
os.environ.get(
"ACC_BENCH_RESULT_DIR",
str(Path(__file__).resolve().parent / "results"),
)
)
# Accuracy gates: baseline minus allowed drop (≤2pp / ≤10% WER).
_MIN_DAILY_OMNI_ACCURACY = 0.775 # 79.5 - 2pp
_MIN_VIDEOMME_ACCURACY = float(os.environ.get("ACC_BENCH_MIN_VIDEOMME_ACCURACY", "0.67"))
# jiwer reports a 0-1 ratio; table 1.414 / 1.56 is percent → 0.0156.
_MAX_SEED_TTS_MEAN_WER = 0.0156
# Full Seed-TTS zh split (seed-tts-eval/zh/meta.lst) is 2020 rows.
_SEED_TTS_LOCALE = "zh"
_SEED_TTS_NUM_PROMPTS = 2020
# Match the validated Daily-Omni / Video-MME client body from the MiniCPM run scripts.
_DAILY_EXTRA_BODY = {
"modalities": ["text"],
"chat_template_kwargs": {"enable_thinking": False},
}
_VIDEOMME_EXTRA_BODY = {
"modalities": ["text"],
"chat_template_kwargs": {"enable_thinking": False},
}
_SEED_EXTRA_BODY = {
"modalities": ["text", "audio"],
"chat_template_kwargs": {
"enable_thinking": False,
"use_tts_template": True,
},
}
# Server flags required for MiniCPM interleaved image/audio packs (Daily-Omni only).
# Do not reuse these for Seed-TTS: ``--interleave-mm-strings`` + TTS ref_audio can trip
# msgspec ValidationError and kill the orchestrator mid-suite.
# Also pinned in ``minicpmo_4_5.yaml`` stage 0 so they survive config-path routing.
_DAILY_OMNI_SERVER_ARGS = [
"--trust-remote-code",
"--interleave-mm-strings",
"--media-io-kwargs",
'{"video":{"fps":1,"num_frames":128}}',
]
# Video-MME ``minicpm-frames`` sends sampled frames as image_url (no AV interleave).
# Allow local ``file://`` frame-cache URLs so CI does not need megabyte-scale base64
# payloads; the autouse fixture still appends ``--videomme-inline-local-video`` when
# the allowlist is unavailable.
_VIDEOMME_SERVER_ARGS = [
"--trust-remote-code",
"--allowed-local-media-path",
os.environ.get("VLLM_TEST_ALLOWED_LOCAL_MEDIA_PATH", "/"),
]
_SEED_TTS_SERVER_ARGS = [
"--trust-remote-code",
]
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
# Evaluated at setup time, before the ``omni_server`` fixture boots a server. An
# ``importorskip`` inside the test body would skip only after paying several minutes
# of startup, and the resulting skip is easy to miss in a nightly log.
_missing_daily_omni_deps = [name for name in ("datasets", "huggingface_hub") if importlib.util.find_spec(name) is None]
requires_daily_omni_deps = pytest.mark.skipif(
bool(_missing_daily_omni_deps),
reason=f"Daily-Omni accuracy bench needs {', '.join(_missing_daily_omni_deps)}",
)
daily_test_params = [
OmniServerParams(
model=_MODEL,
stage_config_path=_DEPLOY_CONFIG,
server_args=list(_DAILY_OMNI_SERVER_ARGS),
)
]
videomme_test_params = [
OmniServerParams(
model=_MODEL,
stage_config_path=_DEPLOY_CONFIG,
server_args=list(_VIDEOMME_SERVER_ARGS),
)
]
seed_test_params = [
OmniServerParams(
model=_MODEL,
stage_config_path=_DEPLOY_CONFIG,
server_args=list(_SEED_TTS_SERVER_ARGS),
)
]
def _require_vllm_cli() -> None:
try:
find_vllm_cli()
except FileNotFoundError as exc:
pytest.skip(str(exc))
def _optional_local_videomme_dataset_path() -> str | None:
"""Return an explicit local Video-MME root, or ``None`` to use the Hub default.
Same rule as Daily-Omni / Seed-TTS: only an env-provided existing directory counts as
local. Hard-coded workspace paths are intentionally not probed so CI defaults to the
Hub id in ``VIDEOMME_DEFAULT_HF_REPO`` (``lmms-eval/Video-MME``).
"""
for key in ("VLLM_VIDEOMME_DATASET_PATH", "VIDEOMME_ROOT"):
raw = os.environ.get(key, "").strip()
if not raw:
continue
path = Path(raw).expanduser()
if path.is_absolute() and path.is_dir():
return str(path.resolve())
return None
@pytest.fixture(autouse=True)
def _inline_local_media_when_needed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Inline Hub/local media when the server has no usable local-media allowlist.
Daily-Omni always inlines (matches the historical Qwen/MiniCPM accuracy fixture).
Video-MME prefers ``file://`` via ``--allowed-local-media-path`` (see
``_VIDEOMME_SERVER_ARGS``); force inline only when ``VLLM_VIDEOMME_FORCE_INLINE=1``.
"""
original_daily = _acc_bench.daily_omni_bench_argv
original_videomme = _acc_bench.videomme_bench_argv
def _wrap_daily() -> list[str]:
argv = list(original_daily())
if "--daily-omni-inline-local-video" not in argv:
argv.append("--daily-omni-inline-local-video")
return argv
def _wrap_videomme() -> list[str]:
argv = list(original_videomme())
force_inline = os.environ.get("VLLM_VIDEOMME_FORCE_INLINE", "").strip().lower() in {
"1",
"true",
"yes",
}
if force_inline and "--videomme-inline-local-video" not in argv:
argv.append("--videomme-inline-local-video")
return argv
monkeypatch.setattr(_acc_bench, "daily_omni_bench_argv", _wrap_daily)
monkeypatch.setattr(_acc_bench, "videomme_bench_argv", _wrap_videomme)
monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
@requires_daily_omni_deps
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", daily_test_params, indirect=True)
def test_minicpmo_4_5_daily_omni_accuracy_bench(omni_server) -> None:
_require_vllm_cli()
argv = build_acc_benchmark_cli_argv(
omni_server,
skip_seed=True,
skip_daily=False,
skip_videomme=True,
)
argv.extend(
[
"--result-dir",
str(_RESULT_DIR),
"--min-daily-omni-accuracy",
str(_MIN_DAILY_OMNI_ACCURACY),
"--temperature",
"0",
"--output-len",
"512",
"--daily-omni-input-mode",
"all",
"--daily-omni-pack-mode",
"minicpm-interleave",
"--daily-extra-body-json",
json.dumps(_DAILY_EXTRA_BODY, separators=(",", ":")),
"--trust-remote-code",
]
)
assert _acc_bench.run_acc_benchmark(_acc_bench.parse_acc_benchmark_args(argv)) == 0
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", videomme_test_params, indirect=True)
def test_minicpmo_4_5_videomme_accuracy_bench(omni_server) -> None:
"""Gate MiniCPM-o 4.5 Video-MME (w/o subs) overall accuracy at the OmniEvalKit recipe."""
_require_vllm_cli()
pytest.importorskip("datasets")
pytest.importorskip("huggingface_hub")
pytest.importorskip("av")
dataset_path = _optional_local_videomme_dataset_path()
num_prompts = int(os.environ.get("ACC_BENCH_VIDEOMME_NUM_PROMPTS", "2700"))
max_concurrency = int(os.environ.get("ACC_BENCH_VIDEOMME_MAX_CONCURRENCY", "4"))
argv = build_acc_benchmark_cli_argv(
omni_server,
skip_seed=True,
skip_daily=True,
skip_videomme=False,
num_prompts=num_prompts,
max_concurrency=max_concurrency,
)
argv.extend(
[
"--result-dir",
str(_RESULT_DIR),
"--min-videomme-accuracy",
str(_MIN_VIDEOMME_ACCURACY),
"--temperature",
"0",
"--output-len",
"128",
"--videomme-pack-mode",
"minicpm-frames",
"--videomme-max-frames",
"96",
"--videomme-duration",
os.environ.get("ACC_BENCH_VIDEOMME_DURATION", "all"),
"--videomme-extra-body-json",
json.dumps(_VIDEOMME_EXTRA_BODY, separators=(",", ":")),
"--trust-remote-code",
]
)
# Absolute local root only; otherwise --videomme-repo (Hub) from build_acc_benchmark_cli_argv.
if dataset_path is not None:
argv.extend(["--videomme-dataset-path", dataset_path])
assert _acc_bench.run_acc_benchmark(_acc_bench.parse_acc_benchmark_args(argv)) == 0
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", seed_test_params, indirect=True)
def test_minicpmo_4_5_seed_tts_wer_bench(omni_server) -> None:
_require_vllm_cli()
pytest.importorskip("huggingface_hub")
argv = build_acc_benchmark_cli_argv(
omni_server,
skip_seed=False,
skip_daily=True,
skip_videomme=True,
num_prompts=_SEED_TTS_NUM_PROMPTS,
max_concurrency=4,
)
argv.extend(
[
"--result-dir",
str(_RESULT_DIR),
"--max-seed-tts-mean-wer",
str(_MAX_SEED_TTS_MEAN_WER),
"--seed-tts-locale",
_SEED_TTS_LOCALE,
"--seed-extra-body-json",
json.dumps(_SEED_EXTRA_BODY, separators=(",", ":")),
"--trust-remote-code",
]
)
assert _acc_bench.run_acc_benchmark(_acc_bench.parse_acc_benchmark_args(argv)) == 0
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
"""Shared helpers for Qwen3-Omni Daily-Omni / Seed-TTS ``vllm bench serve --omni`` accuracy runs. """Shared helpers for Omni Daily-Omni / Seed-TTS / Video-MME ``vllm bench serve --omni`` accuracy runs.
Local dataset paths are **optional**. When ``VLLM_DAILY_OMNI_QA_JSON`` + ``VLLM_DAILY_OMNI_VIDEO_DIR`` Local dataset paths are **optional**. When ``VLLM_DAILY_OMNI_QA_JSON`` + ``VLLM_DAILY_OMNI_VIDEO_DIR``
point to existing files, those are used with inline video. Otherwise the benchmark falls back to point to existing files, those are used with inline video. Otherwise the benchmark falls back to
@@ -9,6 +9,10 @@ bench request that needs media downloads ``Videos.tar`` from the Hub when no vid
Similarly for Seed-TTS: a local directory wins; otherwise ``--dataset-path`` uses the Hub id Similarly for Seed-TTS: a local directory wins; otherwise ``--dataset-path`` uses the Hub id
and ``huggingface_hub.snapshot_download`` inside ``resolve_seed_tts_root`` pulls files on demand. and ``huggingface_hub.snapshot_download`` inside ``resolve_seed_tts_root`` pulls files on demand.
Video-MME follows the same pattern: an existing local directory via ``VLLM_VIDEOMME_DATASET_PATH`` /
``VIDEOMME_ROOT`` (or ``--videomme-dataset-path`` / ``--dataset-path``) wins; otherwise the Hub id
``lmms-eval/Video-MME`` is used and ``resolve_videomme_root`` downloads parquet + video archives.
Use :func:`build_acc_benchmark_cli_argv` to assemble ``argv`` for a live Omni server (host/port/model Use :func:`build_acc_benchmark_cli_argv` to assemble ``argv`` for a live Omni server (host/port/model
and small bench defaults) before ``parse_args`` / ``run_acc_benchmark`` in the accuracy driver. and small bench defaults) before ``parse_args`` / ``run_acc_benchmark`` in the accuracy driver.
""" """
@@ -19,11 +23,13 @@ import json
import os import os
import shutil import shutil
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
from typing import Any, Protocol from typing import Any, Protocol
DEFAULT_DAILY_OMNI_HF_REPO = "liarliar/Daily-Omni" DEFAULT_DAILY_OMNI_HF_REPO = "liarliar/Daily-Omni"
DEFAULT_SEED_TTS_HF_REPO = "zhaochenyang20/seed-tts-eval" DEFAULT_SEED_TTS_HF_REPO = "zhaochenyang20/seed-tts-eval"
DEFAULT_VIDEOMME_HF_REPO = "lmms-eval/Video-MME"
class OmniBenchServerEndpoint(Protocol): class OmniBenchServerEndpoint(Protocol):
@@ -39,6 +45,7 @@ def build_acc_benchmark_cli_argv(
*, *,
skip_seed: bool, skip_seed: bool,
skip_daily: bool, skip_daily: bool,
skip_videomme: bool = True,
num_prompts: int | None = None, num_prompts: int | None = None,
max_concurrency: int | None = None, max_concurrency: int | None = None,
) -> list[str]: ) -> list[str]:
@@ -48,6 +55,9 @@ def build_acc_benchmark_cli_argv(
``--num-prompts`` / ``--max-concurrency`` defaults (overridable via ``ACC_BENCH_NUM_PROMPTS`` / ``--num-prompts`` / ``--max-concurrency`` defaults (overridable via ``ACC_BENCH_NUM_PROMPTS`` /
``ACC_BENCH_MAX_CONCURRENCY``), and when Daily-Omni runs adds ``--daily-omni-repo`` so Hub QA ``ACC_BENCH_MAX_CONCURRENCY``), and when Daily-Omni runs adds ``--daily-omni-repo`` so Hub QA
matches :func:`daily_omni_bench_argv` once ``run_acc_benchmark`` mirrors ``--daily-omni-repo`` into env. matches :func:`daily_omni_bench_argv` once ``run_acc_benchmark`` mirrors ``--daily-omni-repo`` into env.
Video-MME is **opt-in** (``skip_videomme=True`` by default) so existing Daily-Omni / Seed-TTS
callers stay unchanged; pass ``skip_videomme=False`` (emits ``--run-videomme``) to enable it.
""" """
n_prompts = int(os.environ.get("ACC_BENCH_NUM_PROMPTS", "2000")) if num_prompts is None else int(num_prompts) n_prompts = int(os.environ.get("ACC_BENCH_NUM_PROMPTS", "2000")) if num_prompts is None else int(num_prompts)
n_conc = int(os.environ.get("ACC_BENCH_MAX_CONCURRENCY", "10")) if max_concurrency is None else int(max_concurrency) n_conc = int(os.environ.get("ACC_BENCH_MAX_CONCURRENCY", "10")) if max_concurrency is None else int(max_concurrency)
@@ -70,6 +80,12 @@ def build_acc_benchmark_cli_argv(
argv.append("--skip-seed-tts") argv.append("--skip-seed-tts")
if skip_daily: if skip_daily:
argv.append("--skip-daily-omni") argv.append("--skip-daily-omni")
if skip_videomme:
argv.append("--skip-videomme")
else:
argv.append("--run-videomme")
repo = os.environ.get("VLLM_VIDEOMME_REPO", DEFAULT_VIDEOMME_HF_REPO).strip() or DEFAULT_VIDEOMME_HF_REPO
argv.extend(["--videomme-repo", repo])
return argv return argv
@@ -99,6 +115,43 @@ def daily_omni_bench_argv() -> list[str]:
] ]
def videomme_bench_argv() -> list[str]:
"""CLI args for Video-MME (after ``vllm bench serve --omni``).
Same local-vs-Hub rule as :func:`seed_tts_bench_argv`:
* If ``VLLM_VIDEOMME_DATASET_PATH`` / ``VIDEOMME_ROOT`` names an existing directory,
use that local mirror as ``--dataset-path``.
* Otherwise pass the Hub id (``VLLM_VIDEOMME_REPO`` / ``lmms-eval/Video-MME``); the child
bench downloads via ``huggingface_hub.snapshot_download``.
"""
root = os.environ.get("VLLM_VIDEOMME_DATASET_PATH", "").strip() or os.environ.get("VIDEOMME_ROOT", "").strip()
if root:
p = Path(root).expanduser()
# Preserve Hub ids verbatim. Only canonicalize when the value is a real directory.
if p.exists() and p.is_dir():
return [
"--dataset-name",
"videomme",
"--dataset-path",
str(p.resolve()),
]
# Non-directory values (e.g. accidental Hub id in DATASET_PATH) fall through as path.
return [
"--dataset-name",
"videomme",
"--dataset-path",
root,
]
repo = os.environ.get("VLLM_VIDEOMME_REPO", DEFAULT_VIDEOMME_HF_REPO).strip() or DEFAULT_VIDEOMME_HF_REPO
return [
"--dataset-name",
"videomme",
"--dataset-path",
repo,
]
def seed_tts_bench_argv(*, locale: str = "en") -> list[str]: def seed_tts_bench_argv(*, locale: str = "en") -> list[str]:
"""CLI args for Seed-TTS (after ``vllm bench serve --omni``).""" """CLI args for Seed-TTS (after ``vllm bench serve --omni``)."""
dp = os.environ.get("VLLM_SEED_TTS_DATASET_PATH", "").strip() dp = os.environ.get("VLLM_SEED_TTS_DATASET_PATH", "").strip()
@@ -121,9 +174,13 @@ def seed_tts_bench_argv(*, locale: str = "en") -> list[str]:
def find_vllm_cli() -> str: def find_vllm_cli() -> str:
exe = shutil.which("vllm") exe = shutil.which("vllm")
if not exe: if exe:
raise FileNotFoundError("Could not find `vllm` on PATH (install vLLM-Omni with CLI entrypoints).") return exe
return exe # Fallback when pytest is invoked via absolute venv path without activating PATH.
sibling = Path(sys.executable).resolve().parent / "vllm"
if sibling.is_file() and os.access(sibling, os.X_OK):
return str(sibling)
raise FileNotFoundError("Could not find `vllm` on PATH (install vLLM-Omni with CLI entrypoints).")
def run_vllm_bench_subprocess(vllm: str, argv: list[str], *, extra_env: dict[str, str] | None = None) -> None: def run_vllm_bench_subprocess(vllm: str, argv: list[str], *, extra_env: dict[str, str] | None = None) -> None:
@@ -150,6 +207,11 @@ def build_serve_common_argv(
result_dir: Path, result_dir: Path,
result_filename: str, result_filename: str,
ready_check_timeout_sec: int | None = None, ready_check_timeout_sec: int | None = None,
trust_remote_code: bool = False,
backend: str = "openai-chat-omni",
endpoint: str = "/v1/chat/completions",
temperature: float | None = None,
output_len: int | None = None,
) -> list[str]: ) -> list[str]:
out = [ out = [
"bench", "bench",
@@ -162,9 +224,9 @@ def build_serve_common_argv(
"--model", "--model",
model, model,
"--endpoint", "--endpoint",
"/v1/chat/completions", endpoint,
"--backend", "--backend",
"openai-chat-omni", backend,
"--request-rate", "--request-rate",
"inf", "inf",
"--num-prompts", "--num-prompts",
@@ -184,6 +246,12 @@ def build_serve_common_argv(
] ]
if ready_check_timeout_sec is not None: if ready_check_timeout_sec is not None:
out.extend(["--ready-check-timeout-sec", str(int(ready_check_timeout_sec))]) out.extend(["--ready-check-timeout-sec", str(int(ready_check_timeout_sec))])
if trust_remote_code:
out.append("--trust-remote-code")
if temperature is not None:
out.extend(["--temperature", str(temperature)])
if output_len is not None:
out.extend(["--output-len", str(int(output_len))])
return out return out
@@ -193,6 +261,12 @@ def assert_daily_omni_scored(result: dict[str, Any]) -> None:
assert int(result.get("daily_omni_evaluated_ok", 0) or 0) > 0, "no successful MCQ rows (daily_omni_evaluated_ok==0)" assert int(result.get("daily_omni_evaluated_ok", 0) or 0) > 0, "no successful MCQ rows (daily_omni_evaluated_ok==0)"
def assert_videomme_scored(result: dict[str, Any]) -> None:
acc = result.get("videomme_accuracy")
assert acc is not None, "videomme_accuracy missing — wrong dataset or benchmark wiring"
assert int(result.get("videomme_evaluated_ok", 0) or 0) > 0, "no successful MCQ rows (videomme_evaluated_ok==0)"
def assert_seed_tts_scored(result: dict[str, Any]) -> None: def assert_seed_tts_scored(result: dict[str, Any]) -> None:
err = result.get("seed_tts_eval_setup_error") err = result.get("seed_tts_eval_setup_error")
assert not err, f"Seed-TTS eval deps/setup failed: {err}" assert not err, f"Seed-TTS eval deps/setup failed: {err}"
@@ -11,6 +11,9 @@ H100 / MI325) because they launch the live Omni server inside the test.
run **fails** if accuracy is strictly below **0.69** (``--min-daily-omni-accuracy`` / ``ACC_BENCH_MIN_DAILY_OMNI_ACCURACY``). run **fails** if accuracy is strictly below **0.69** (``--min-daily-omni-accuracy`` / ``ACC_BENCH_MIN_DAILY_OMNI_ACCURACY``).
2. **Seed-TTS** — ``seed-tts-eval``-style metrics when ``--seed-tts-wer-eval`` is used 2. **Seed-TTS** — ``seed-tts-eval``-style metrics when ``--seed-tts-wer-eval`` is used
(WER / SIM / UTMOS keys from :func:`compute_seed_tts_wer_metrics`). (WER / SIM / UTMOS keys from :func:`compute_seed_tts_wer_metrics`).
3. **Video-MME** — opt-in via ``--run-videomme`` (skipped by default so Daily-Omni / Seed-TTS callers
stay unchanged). MiniCPM-o 4.5 reports **70.4** on Video-MME (w/o subs); the default floor is
**0.68** (``--min-videomme-accuracy`` / ``ACC_BENCH_MIN_VIDEOMME_ACCURACY``).
Prerequisites Prerequisites
------------- -------------
@@ -21,7 +24,8 @@ Prerequisites
On L4 you may need a smaller checkpoint, quantization, or tighter engine flags; this script On L4 you may need a smaller checkpoint, quantization, or tighter engine flags; this script
only drives the **client** benchmark. only drives the **client** benchmark.
* ``vllm`` CLI from **vLLM-Omni** (so ``bench serve`` registers ``daily-omni`` / ``seed-tts``). * ``vllm`` CLI from **vLLM-Omni** (so ``bench serve`` registers ``daily-omni`` / ``seed-tts`` /
``videomme``).
* **Daily-Omni** — if local ``qa.json`` + ``Videos/`` are not both provided (CLI or matching env), * **Daily-Omni** — if local ``qa.json`` + ``Videos/`` are not both provided (CLI or matching env),
the client passes ``--dataset-path`` with a Hub id (default ``liarliar/Daily-Omni``). The **child** the client passes ``--dataset-path`` with a Hub id (default ``liarliar/Daily-Omni``). The **child**
@@ -31,6 +35,13 @@ Prerequisites
the dataset repo with ``--daily-omni-repo`` or ``VLLM_DAILY_OMNI_REPO``; override the tar repo the dataset repo with ``--daily-omni-repo`` or ``VLLM_DAILY_OMNI_REPO``; override the tar repo
with ``VLLM_DAILY_OMNI_MEDIA_REPO`` if needed. with ``VLLM_DAILY_OMNI_MEDIA_REPO`` if needed.
* **Video-MME** — by default ``--dataset-path`` is the Hub id ``lmms-eval/Video-MME`` (override with
``--videomme-repo`` / ``VLLM_VIDEOMME_REPO``). The child bench downloads parquet + video archives via
``huggingface_hub.snapshot_download`` (needs ``huggingface_hub``, network or HF cache). Pass an
existing local root with ``--videomme-dataset-path`` / ``VLLM_VIDEOMME_DATASET_PATH`` /
``VIDEOMME_ROOT`` to skip the Hub download. For CI servers without ``--allowed-local-media-path``,
pass ``--videomme-inline-local-video`` (or rely on the pytest fixture that appends it).
* **Seed-TTS** optional extras for WER/SIM/UTMOS:: * **Seed-TTS** optional extras for WER/SIM/UTMOS::
pip install 'vllm-omni[dev]' pip install 'vllm-omni[dev]'
@@ -55,6 +66,18 @@ Skip one suite, tighten gates::
--skip-daily-omni \\ --skip-daily-omni \\
--max-seed-tts-mean-wer 0.35 \\ --max-seed-tts-mean-wer 0.35 \\
--min-seed-tts-mean-sim 0.75 --min-seed-tts-mean-sim 0.75
MiniCPM-o Video-MME (w/o subs)::
python tests/e2e/accuracy/qwen3_omni/run_qwen_omni_acc_benchmark.py \\
--skip-daily-omni --skip-seed-tts --run-videomme \\
--videomme-pack-mode minicpm-frames \\
--videomme-max-frames 96 \\
--temperature 0 --output-len 128 \\
--min-videomme-accuracy 0.68
# Optional local mirror (absolute path) instead of Hub download:
# --videomme-dataset-path /path/to/Video-MME
""" """
from __future__ import annotations from __future__ import annotations
@@ -75,6 +98,7 @@ from tests.e2e.accuracy.qwen3_omni.qwen3_omni_acc_bench_core import (
load_benchmark_result, load_benchmark_result,
run_vllm_bench_subprocess, run_vllm_bench_subprocess,
seed_tts_bench_argv, seed_tts_bench_argv,
videomme_bench_argv,
) )
_REPO_ROOT = Path(__file__).resolve().parents[4] _REPO_ROOT = Path(__file__).resolve().parents[4]
@@ -102,6 +126,20 @@ def _validate_daily_omni(result: dict[str, Any], *, min_accuracy: float | None)
return errs return errs
def _validate_videomme(result: dict[str, Any], *, min_accuracy: float | None) -> list[str]:
errs: list[str] = []
acc = result.get("videomme_accuracy")
if acc is None:
errs.append("Missing videomme_accuracy (wrong dataset or no gold-evaluated rows).")
return errs
ev = int(result.get("videomme_evaluated_ok", 0) or 0)
if ev <= 0:
errs.append("videomme_evaluated_ok is 0; no successful MCQ rows to score.")
if min_accuracy is not None and float(acc) + 1e-12 < float(min_accuracy):
errs.append(f"videomme_accuracy={acc:.6f} < --min-videomme-accuracy={min_accuracy}")
return errs
def _validate_seed_tts( def _validate_seed_tts(
result: dict[str, Any], result: dict[str, Any],
*, *,
@@ -130,7 +168,7 @@ def _validate_seed_tts(
def sync_dataset_env_from_ns(ns: argparse.Namespace) -> None: def sync_dataset_env_from_ns(ns: argparse.Namespace) -> None:
"""Mirror CLI path flags into env vars read by ``daily_omni_bench_argv`` / ``seed_tts_bench_argv``.""" """Mirror CLI path flags into env vars read by dataset ``*_bench_argv`` helpers."""
repo = getattr(ns, "daily_omni_repo", None) repo = getattr(ns, "daily_omni_repo", None)
if repo is not None and str(repo).strip(): if repo is not None and str(repo).strip():
os.environ["VLLM_DAILY_OMNI_REPO"] = str(repo).strip() os.environ["VLLM_DAILY_OMNI_REPO"] = str(repo).strip()
@@ -138,6 +176,14 @@ def sync_dataset_env_from_ns(ns: argparse.Namespace) -> None:
os.environ["VLLM_DAILY_OMNI_QA_JSON"] = str(Path(ns.daily_omni_qa_json).expanduser().resolve()) os.environ["VLLM_DAILY_OMNI_QA_JSON"] = str(Path(ns.daily_omni_qa_json).expanduser().resolve())
if ns.daily_omni_video_dir is not None: if ns.daily_omni_video_dir is not None:
os.environ["VLLM_DAILY_OMNI_VIDEO_DIR"] = str(Path(ns.daily_omni_video_dir).expanduser().resolve()) os.environ["VLLM_DAILY_OMNI_VIDEO_DIR"] = str(Path(ns.daily_omni_video_dir).expanduser().resolve())
vm_repo = getattr(ns, "videomme_repo", None)
if vm_repo is not None and str(vm_repo).strip():
os.environ["VLLM_VIDEOMME_REPO"] = str(vm_repo).strip()
vm_path = getattr(ns, "videomme_dataset_path", None)
if vm_path is not None and str(vm_path).strip():
raw = str(vm_path).strip()
p = Path(raw).expanduser()
os.environ["VLLM_VIDEOMME_DATASET_PATH"] = str(p.resolve()) if p.exists() and p.is_dir() else raw
if ns.seed_tts_dataset_path is not None: if ns.seed_tts_dataset_path is not None:
# ``--seed-tts-dataset-path`` accepts either a local directory or a # ``--seed-tts-dataset-path`` accepts either a local directory or a
# Hugging Face repo id. Only resolve to an absolute filesystem path # Hugging Face repo id. Only resolve to an absolute filesystem path
@@ -157,6 +203,9 @@ def _preserve_benchmark_dataset_env() -> Any:
"VLLM_DAILY_OMNI_REPO", "VLLM_DAILY_OMNI_REPO",
"VLLM_DAILY_OMNI_QA_JSON", "VLLM_DAILY_OMNI_QA_JSON",
"VLLM_DAILY_OMNI_VIDEO_DIR", "VLLM_DAILY_OMNI_VIDEO_DIR",
"VLLM_VIDEOMME_REPO",
"VLLM_VIDEOMME_DATASET_PATH",
"VIDEOMME_ROOT",
"VLLM_SEED_TTS_DATASET_PATH", "VLLM_SEED_TTS_DATASET_PATH",
"SEED_TTS_ROOT", "SEED_TTS_ROOT",
) )
@@ -171,7 +220,13 @@ def _preserve_benchmark_dataset_env() -> Any:
os.environ[key] = value os.environ[key] = value
def _build_common_args(ns: argparse.Namespace, *, result_filename: str) -> list[str]: def _build_common_args(
ns: argparse.Namespace,
*,
result_filename: str,
backend: str = "openai-chat-omni",
endpoint: str = "/v1/chat/completions",
) -> list[str]:
return build_serve_common_argv( return build_serve_common_argv(
host=ns.host, host=ns.host,
port=ns.port, port=ns.port,
@@ -183,6 +238,11 @@ def _build_common_args(ns: argparse.Namespace, *, result_filename: str) -> list[
result_dir=ns.result_dir, result_dir=ns.result_dir,
result_filename=result_filename, result_filename=result_filename,
ready_check_timeout_sec=ns.ready_check_timeout_sec, ready_check_timeout_sec=ns.ready_check_timeout_sec,
trust_remote_code=ns.trust_remote_code,
backend=backend,
endpoint=endpoint,
temperature=getattr(ns, "temperature", None),
output_len=getattr(ns, "output_len", None),
) )
@@ -201,6 +261,9 @@ def run_daily_omni(ns: argparse.Namespace, vllm: str) -> Path:
json.dumps(extra, ensure_ascii=False, separators=(",", ":")), json.dumps(extra, ensure_ascii=False, separators=(",", ":")),
] ]
) )
pack_mode = getattr(ns, "daily_omni_pack_mode", None)
if pack_mode:
argv.extend(["--daily-omni-pack-mode", pack_mode])
if ns.daily_omni_save_eval_items: if ns.daily_omni_save_eval_items:
argv.append("--daily-omni-save-eval-items") argv.append("--daily-omni-save-eval-items")
print("\n$", vllm, *argv, "\n", flush=True) print("\n$", vllm, *argv, "\n", flush=True)
@@ -211,13 +274,58 @@ def run_daily_omni(ns: argparse.Namespace, vllm: str) -> Path:
return out return out
def run_videomme(ns: argparse.Namespace, vllm: str) -> Path:
ns.result_dir.mkdir(parents=True, exist_ok=True)
tag = datetime.now().strftime("%Y%m%d-%H%M%S")
result_filename = f"omni_acc_videomme_{tag}.json"
extra = json.loads(ns.videomme_extra_body_json)
argv = (
_build_common_args(ns, result_filename=result_filename)
+ videomme_bench_argv()
+ [
"--disable-shuffle",
"--videomme-pack-mode",
ns.videomme_pack_mode,
"--videomme-duration",
ns.videomme_duration,
"--extra-body",
json.dumps(extra, ensure_ascii=False, separators=(",", ":")),
]
)
if ns.videomme_max_frames is not None:
argv.extend(["--videomme-max-frames", str(int(ns.videomme_max_frames))])
if ns.videomme_use_subtitle:
argv.append("--videomme-use-subtitle")
if ns.videomme_inline_local_video:
argv.append("--videomme-inline-local-video")
if ns.videomme_save_eval_items:
argv.append("--videomme-save-eval-items")
if ns.videomme_video_dir is not None:
argv.extend(["--videomme-video-dir", str(Path(ns.videomme_video_dir).expanduser().resolve())])
if ns.videomme_parquet is not None:
argv.extend(["--videomme-parquet", str(Path(ns.videomme_parquet).expanduser().resolve())])
if ns.videomme_subtitle_dir is not None:
argv.extend(["--videomme-subtitle-dir", str(Path(ns.videomme_subtitle_dir).expanduser().resolve())])
print("\n$", vllm, *argv, "\n", flush=True)
run_vllm_bench_subprocess(vllm, argv)
out = Path(ns.result_dir) / result_filename
if not out.is_file():
raise FileNotFoundError(f"Expected result JSON at {out}")
return out
def run_seed_tts(ns: argparse.Namespace, vllm: str) -> Path: def run_seed_tts(ns: argparse.Namespace, vllm: str) -> Path:
ns.result_dir.mkdir(parents=True, exist_ok=True) ns.result_dir.mkdir(parents=True, exist_ok=True)
tag = datetime.now().strftime("%Y%m%d-%H%M%S") tag = datetime.now().strftime("%Y%m%d-%H%M%S")
result_filename = f"qwen_omni_acc_seed_tts_{tag}.json" result_filename = f"qwen_omni_acc_seed_tts_{tag}.json"
extra = json.loads(ns.seed_extra_body_json) extra = json.loads(ns.seed_extra_body_json)
argv = ( argv = (
_build_common_args(ns, result_filename=result_filename) _build_common_args(
ns,
result_filename=result_filename,
backend=ns.seed_backend,
endpoint=ns.seed_endpoint,
)
+ seed_tts_bench_argv(locale=ns.seed_tts_locale) + seed_tts_bench_argv(locale=ns.seed_tts_locale)
+ [ + [
"--seed-tts-wer-eval", "--seed-tts-wer-eval",
@@ -225,6 +333,8 @@ def run_seed_tts(ns: argparse.Namespace, vllm: str) -> Path:
json.dumps(extra, ensure_ascii=False, separators=(",", ":")), json.dumps(extra, ensure_ascii=False, separators=(",", ":")),
] ]
) )
if ns.seed_tts_turns_per_session > 1:
argv.extend(["--seed-tts-turns-per-session", str(ns.seed_tts_turns_per_session)])
if ns.seed_tts_wer_save_items: if ns.seed_tts_wer_save_items:
argv.append("--seed-tts-wer-save-items") argv.append("--seed-tts-wer-save-items")
if ns.seed_tts_file_ref_audio: if ns.seed_tts_file_ref_audio:
@@ -266,6 +376,23 @@ def build_arg_parser() -> argparse.ArgumentParser:
help="If set, forwarded to ``vllm bench serve`` (probe first request until success). " help="If set, forwarded to ``vllm bench serve`` (probe first request until success). "
"Omit to use upstream default (typically skip).", "Omit to use upstream default (typically skip).",
) )
p.add_argument(
"--trust-remote-code",
action="store_true",
help="Forward trust_remote_code to ``vllm bench serve`` for models with custom code.",
)
p.add_argument(
"--temperature",
type=float,
default=None,
help="Optional sampling temperature forwarded to ``vllm bench serve`` (e.g. 0 for MiniCPM-o Daily-Omni MCQ).",
)
p.add_argument(
"--output-len",
type=int,
default=None,
help="Optional max output tokens forwarded to ``vllm bench serve`` (e.g. 512 for MiniCPM-o Daily-Omni).",
)
p.add_argument( p.add_argument(
"--result-dir", "--result-dir",
type=Path, type=Path,
@@ -274,6 +401,20 @@ def build_arg_parser() -> argparse.ArgumentParser:
p.add_argument("--skip-daily-omni", action="store_true") p.add_argument("--skip-daily-omni", action="store_true")
p.add_argument("--skip-seed-tts", action="store_true") p.add_argument("--skip-seed-tts", action="store_true")
# Video-MME is opt-in: default skip=True so existing Daily-Omni / Seed-TTS callers stay unchanged.
p.set_defaults(skip_videomme=True)
p.add_argument(
"--skip-videomme",
action="store_true",
dest="skip_videomme",
help="Skip Video-MME (default).",
)
p.add_argument(
"--run-videomme",
action="store_false",
dest="skip_videomme",
help="Enable the Video-MME accuracy suite.",
)
p.add_argument( p.add_argument(
"--daily-omni-repo", "--daily-omni-repo",
@@ -295,6 +436,13 @@ def build_arg_parser() -> argparse.ArgumentParser:
help="Optional local Videos root; if omitted, media is fetched lazily from Hub Videos.tar.", help="Optional local Videos root; if omitted, media is fetched lazily from Hub Videos.tar.",
) )
p.add_argument("--daily-omni-input-mode", choices=("all", "visual", "audio"), default="all") p.add_argument("--daily-omni-input-mode", choices=("all", "visual", "audio"), default="all")
p.add_argument(
"--daily-omni-pack-mode",
choices=("qwen", "minicpm-interleave"),
default=None,
help="Daily-Omni multimodal pack recipe. Use ``minicpm-interleave`` for MiniCPM-o "
"(1fps interleaved image/audio; see PR #5606).",
)
p.add_argument( p.add_argument(
"--daily-extra-body-json", "--daily-extra-body-json",
default='{"modalities":["text"]}', default='{"modalities":["text"]}',
@@ -313,6 +461,87 @@ def build_arg_parser() -> argparse.ArgumentParser:
"Default baseline 0.69; override with env ACC_BENCH_MIN_DAILY_OMNI_ACCURACY or pass 0 to disable the floor.", "Default baseline 0.69; override with env ACC_BENCH_MIN_DAILY_OMNI_ACCURACY or pass 0 to disable the floor.",
) )
p.add_argument(
"--videomme-repo",
type=str,
default=None,
help="Hugging Face dataset id for Video-MME Hub mode (sets VLLM_VIDEOMME_REPO). "
"Default when no local path is set: lmms-eval/Video-MME. "
"Ignored when a local --videomme-dataset-path / VLLM_VIDEOMME_DATASET_PATH is used.",
)
p.add_argument(
"--videomme-dataset-path",
type=str,
default=None,
help="Optional local Video-MME root (parquet + videos). Existing directories skip the Hub "
"download; Hub ids are also accepted and passed through as --dataset-path. "
"Sets VLLM_VIDEOMME_DATASET_PATH.",
)
p.add_argument(
"--videomme-parquet",
type=Path,
default=None,
help="Optional explicit path to videomme/test-*.parquet.",
)
p.add_argument(
"--videomme-video-dir",
type=Path,
default=None,
help="Optional explicit directory of extracted Video-MME videos.",
)
p.add_argument(
"--videomme-subtitle-dir",
type=Path,
default=None,
help="Optional subtitle directory (used with --videomme-use-subtitle).",
)
p.add_argument(
"--videomme-pack-mode",
choices=("minicpm-frames", "minicpm-interleave", "video_url"),
default="minicpm-frames",
help="Video-MME multimodal pack recipe (OmniEvalKit MiniCPM default: minicpm-frames).",
)
p.add_argument(
"--videomme-max-frames",
type=int,
default=None,
help="Override max sampled frames (OmniEvalKit: 96 for minicpm-frames, 64 for interleave).",
)
p.add_argument(
"--videomme-duration",
choices=("all", "short", "medium", "long"),
default="all",
help="Filter Video-MME by duration bucket (default: all).",
)
p.add_argument(
"--videomme-use-subtitle",
action="store_true",
help="Prepend subtitle text to the user prompt (Video-MME w/ subs).",
)
p.add_argument(
"--videomme-inline-local-video",
action="store_true",
help="Embed frames/audio as base64 data URLs (no --allowed-local-media-path on the server).",
)
p.add_argument(
"--videomme-extra-body-json",
default='{"modalities":["text"],"chat_template_kwargs":{"enable_thinking":false}}',
help="JSON merged into each chat request for Video-MME.",
)
p.add_argument(
"--videomme-save-eval-items",
action="store_true",
help="Include per-request Video-MME accuracy rows in the saved JSON.",
)
p.add_argument(
"--min-videomme-accuracy",
type=float,
default=float((os.environ.get("ACC_BENCH_MIN_VIDEOMME_ACCURACY") or "0.68").strip() or "0.68"),
help="Fail when videomme_accuracy is strictly below this threshold (0–1). "
"Default 0.68 (~2pp under MiniCPM-o 4.5 reported 70.4 w/o subs); "
"override with ACC_BENCH_MIN_VIDEOMME_ACCURACY or pass 0 to disable the floor.",
)
p.add_argument( p.add_argument(
"--seed-tts-dataset-path", "--seed-tts-dataset-path",
type=str, type=str,
@@ -326,6 +555,22 @@ def build_arg_parser() -> argparse.ArgumentParser:
default='{"modalities":["text","audio"]}', default='{"modalities":["text","audio"]}',
help="JSON for Seed-TTS chat requests (must include audio for synthesis + PCM capture).", help="JSON for Seed-TTS chat requests (must include audio for synthesis + PCM capture).",
) )
p.add_argument(
"--seed-backend",
default="openai-chat-omni",
help="Seed-TTS benchmark backend (for example openai-realtime-tts).",
)
p.add_argument(
"--seed-endpoint",
default="/v1/chat/completions",
help="Seed-TTS serving endpoint corresponding to --seed-backend.",
)
p.add_argument(
"--seed-tts-turns-per-session",
type=int,
default=1,
help="Group this many Seed-TTS target texts into each Realtime session.",
)
p.add_argument("--seed-tts-wer-save-items", action="store_true") p.add_argument("--seed-tts-wer-save-items", action="store_true")
p.add_argument( p.add_argument(
"--seed-tts-file-ref-audio", "--seed-tts-file-ref-audio",
@@ -366,7 +611,7 @@ def parse_acc_benchmark_args(argv: list[str] | None = None) -> argparse.Namespac
def run_acc_benchmark(ns: argparse.Namespace) -> int: def run_acc_benchmark(ns: argparse.Namespace) -> int:
"""Run Daily-Omni and/or Seed-TTS client benches against a running server; return 0 on success.""" """Run configured client benches against a running server; return 0 on success."""
failed: list[str] = [] failed: list[str] = []
with _preserve_benchmark_dataset_env(): with _preserve_benchmark_dataset_env():
@@ -390,6 +635,21 @@ def run_acc_benchmark(ns: argparse.Namespace) -> int:
flush=True, flush=True,
) )
if not ns.skip_videomme:
path = run_videomme(ns, vllm)
print(f"\n[Video-MME] result JSON: {path}", flush=True)
data = load_benchmark_result(path)
errs = _validate_videomme(data, min_accuracy=ns.min_videomme_accuracy)
if errs:
failed.extend([f"[Video-MME] {e}" for e in errs])
else:
print(
f"[Video-MME] videomme_accuracy={data.get('videomme_accuracy')} "
f"evaluated_ok={data.get('videomme_evaluated_ok')} "
f"per_duration={data.get('videomme_per_duration_accuracy')}",
flush=True,
)
if not ns.skip_seed_tts: if not ns.skip_seed_tts:
path = run_seed_tts(ns, vllm) path = run_seed_tts(ns, vllm)
print(f"\n[Seed-TTS] result JSON: {path}", flush=True) print(f"\n[Seed-TTS] result JSON: {path}", flush=True)
@@ -400,6 +660,11 @@ def run_acc_benchmark(ns: argparse.Namespace) -> int:
min_mean_sim=ns.min_seed_tts_mean_sim, min_mean_sim=ns.min_seed_tts_mean_sim,
min_mean_utmos=ns.min_seed_tts_mean_utmos, min_mean_utmos=ns.min_seed_tts_mean_utmos,
) )
if ns.seed_tts_turns_per_session > 1:
expected_turns = ns.num_prompts * ns.seed_tts_turns_per_session
evaluated_turns = int(data.get("seed_tts_content_evaluated") or 0)
if evaluated_turns != expected_turns:
errs.append(f"seed_tts_content_evaluated={evaluated_turns} != expected {expected_turns}")
if errs: if errs:
failed.extend([f"[Seed-TTS] {e}" for e in errs]) failed.extend([f"[Seed-TTS] {e}" for e in errs])
else: else:
@@ -14,7 +14,7 @@ from tests.helpers.stage_config import get_deploy_config_path
models = ["openbmb/MiniCPM-o-4_5"] models = ["openbmb/MiniCPM-o-4_5"]
_CI_DEPLOY = get_deploy_config_path("minicpmo_4_5_batching.yaml") _CI_DEPLOY = get_deploy_config_path("minicpmo_4_5.yaml")
test_params = [(model, None, {"deploy_config": _CI_DEPLOY, "trust_remote_code": True}) for model in models] test_params = [(model, None, {"deploy_config": _CI_DEPLOY, "trust_remote_code": True}) for model in models]
@@ -31,11 +31,10 @@ def get_question(prompt_type: str = "text") -> str:
return prompts.get(prompt_type, prompts["text"]) return prompts.get(prompt_type, prompts["text"])
@pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/5437")
@pytest.mark.core_model @pytest.mark.core_model
@pytest.mark.advanced_model @pytest.mark.advanced_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True) @pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_text_to_text(omni_runner, omni_runner_handler) -> None: def test_text_to_text(omni_runner, omni_runner_handler) -> None:
"""Test processing text, generating text output.""" """Test processing text, generating text output."""
@@ -45,7 +44,7 @@ def test_text_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.full_model @pytest.mark.full_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True) @pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_audio_to_text(omni_runner, omni_runner_handler) -> None: def test_audio_to_text(omni_runner, omni_runner_handler) -> None:
"""Test processing audio, generating text output.""" """Test processing audio, generating text output."""
@@ -58,7 +57,7 @@ def test_audio_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.full_model @pytest.mark.full_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True) @pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_image_to_text(omni_runner, omni_runner_handler) -> None: def test_image_to_text(omni_runner, omni_runner_handler) -> None:
"""Test processing image, generating text output.""" """Test processing image, generating text output."""
@@ -69,7 +68,7 @@ def test_image_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.full_model @pytest.mark.full_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True) @pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_video_to_text(omni_runner, omni_runner_handler) -> None: def test_video_to_text(omni_runner, omni_runner_handler) -> None:
"""Test processing video, generating text output.""" """Test processing video, generating text output."""
@@ -80,19 +79,18 @@ def test_video_to_text(omni_runner, omni_runner_handler) -> None:
@pytest.mark.full_model @pytest.mark.full_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True) @pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_text_to_audio(omni_runner, omni_runner_handler) -> None: def test_text_to_audio(omni_runner, omni_runner_handler) -> None:
"""Test processing text, generating audio output through the talker token2wav path.""" """Test processing text and generating audio through Talker and Code2Wav."""
request_config = {"prompts": get_question("text"), "modalities": ["audio"]} request_config = {"prompts": get_question("text"), "modalities": ["audio"]}
omni_runner_handler.send_omni_request(request_config) omni_runner_handler.send_omni_request(request_config)
@pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/5437")
@pytest.mark.core_model @pytest.mark.core_model
@pytest.mark.advanced_model @pytest.mark.advanced_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True) @pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_mix_to_audio(omni_runner, omni_runner_handler) -> None: def test_mix_to_audio(omni_runner, omni_runner_handler) -> None:
"""Test processing mixed modalities (image + audio), generating audio output.""" """Test processing mixed modalities (image + audio), generating audio output."""
@@ -111,7 +109,7 @@ def test_mix_to_audio(omni_runner, omni_runner_handler) -> None:
@pytest.mark.full_model @pytest.mark.full_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True) @pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_video_to_audio(omni_runner, omni_runner_handler) -> None: def test_video_to_audio(omni_runner, omni_runner_handler) -> None:
"""Test processing video, generating audio output.""" """Test processing video, generating audio output."""
@@ -53,6 +53,7 @@ from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
RealtimeEventCollector, RealtimeEventCollector,
build_realtime_url, build_realtime_url,
read_pcm16_wav, read_pcm16_wav,
summarize_session_request_metrics,
) )
_url_with_model = build_realtime_url _url_with_model = build_realtime_url
@@ -194,6 +195,38 @@ class DemoState:
summaries[response_id] = timing summaries[response_id] = timing
return summaries return summaries
def session_request_metrics(
self,
*,
session_id: str | None = None,
) -> list[dict[str, object]]:
"""Return benchmark-style metrics for every response in this session."""
requests: list[dict[str, object]] = []
for request_index, (response_id, timing) in enumerate(self.response_timing_summaries().items()):
metrics = timing.get("request_metrics")
if not isinstance(metrics, dict):
continue
requests.append(
{
"session_id": session_id,
"request_index": request_index,
"response_id": response_id,
**metrics,
}
)
return requests
def session_metric_summary(
self,
*,
session_id: str | None = None,
) -> dict[str, object]:
"""Average TTFT, TTFP, and RTF across responses that emitted audio."""
return summarize_session_request_metrics(
self.session_request_metrics(session_id=session_id),
session_id=session_id,
)
def first_index(self, event_type: str, predicate=None) -> int | None: def first_index(self, event_type: str, predicate=None) -> int | None:
for index, event in enumerate(self.events): for index, event in enumerate(self.events):
if event.get("type") != event_type: if event.get("type") != event_type:
@@ -930,12 +963,23 @@ async def _send_clean_turn(
if outcome == "listen": if outcome == "listen":
return None, "listen" return None, "listen"
else: else:
await _wait_for( try:
state, await _wait_for(
lambda: state.count("response.created") > before_created, state,
timeout_s=timeout_s, lambda: state.count("response.created") > before_created,
label=f"{transcript} response.created", timeout_s=timeout_s,
) label=f"{transcript} response.created",
)
except TimeoutError:
# ``auto_response`` sessions only get ``response.created`` once the model
# decides to speak, so report the listen decisions that took its place.
raise TimeoutError(
f"Timed out waiting for {transcript} response.created after "
f"{state.count('input_audio_buffer.committed')} committed input turns; "
f"model_listen={state.model_listen_count - before_model_listen} "
f"buffering_listen={state.buffering_listen_count} "
f"errors={state.count('error')}"
) from None
await _wait_for( await _wait_for(
state, state,
lambda: eligible_response_id() is not None, lambda: eligible_response_id() is not None,
@@ -1174,10 +1218,13 @@ async def _send_listen_only_overlap_pair(
before_second_listen = state.model_listen_count before_second_listen = state.model_listen_count
overlap_started_while_active = not state.response_done(first_response_id) overlap_started_while_active = not state.response_done(first_response_id)
model_unit_ready_while_active = False model_unit_ready_while_active = False
second_unit_event_index: int | None = None
def record_model_unit_ready() -> None: def record_model_unit_ready() -> None:
nonlocal model_unit_ready_while_active nonlocal model_unit_ready_while_active, second_unit_event_index
model_unit_ready_while_active = not state.response_done(first_response_id) model_unit_ready_while_active = not state.response_done(first_response_id)
if model_unit_ready_while_active:
second_unit_event_index = len(state.events)
await _send_pcm16( await _send_pcm16(
ws, ws,
@@ -1239,14 +1286,13 @@ async def _send_listen_only_overlap_pair(
label=transcripts[1], label=transcripts[1],
) )
second_speech_index = _nth_event_index(state, "input_audio_buffer.speech_started", 2)
first_done_index = _event_index_for_response(state, "response.done", first_response_id) first_done_index = _event_index_for_response(state, "response.done", first_response_id)
overlap_ok = ( overlap_ok = (
overlap_started_while_active overlap_started_while_active
and model_unit_ready_while_active and model_unit_ready_while_active
and second_speech_index is not None and second_unit_event_index is not None
and first_done_index is not None and first_done_index is not None
and second_speech_index < first_done_index and second_unit_event_index < first_done_index
) )
return [first_response_id, second_response_id], ["speak", second_outcome], overlap_ok return [first_response_id, second_response_id], ["speak", second_outcome], overlap_ok
@@ -1380,6 +1426,17 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
await ws.send(json.dumps({"type": "session.close"})) await ws.send(json.dumps({"type": "session.close"}))
await _wait_for(state, lambda: state.count("session.closed") > 0, timeout_s=20, label="session.closed") await _wait_for(state, lambda: state.count("session.closed") > 0, timeout_s=20, label="session.closed")
finally: finally:
if state.count("session.created") > 0 and state.count("session.closed") == 0:
try:
await ws.send(json.dumps({"type": "session.close"}))
await _wait_for(
state,
lambda: state.count("session.closed") > 0,
timeout_s=min(args.timeout_s, 5),
label="session.closed during cleanup",
)
except (ConnectionClosed, TimeoutError):
pass
stop.set() stop.set()
reader.cancel() reader.cancel()
try: try:
@@ -1510,6 +1567,8 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
"input_transcription_ok": input_transcription_ok, "input_transcription_ok": input_transcription_ok,
"completed_response_ids": completed_response_ids, "completed_response_ids": completed_response_ids,
"response_timings": state.response_timing_summaries(), "response_timings": state.response_timing_summaries(),
"request_metrics": state.session_request_metrics(session_id=args.session_id),
"session_metrics": state.session_metric_summary(session_id=args.session_id),
"lifecycle_counts_ok": lifecycle_counts_ok, "lifecycle_counts_ok": lifecycle_counts_ok,
"validation_mode": validation_mode, "validation_mode": validation_mode,
"scenario": scenario, "scenario": scenario,
@@ -8,6 +8,7 @@ import hashlib
import json import json
import sys import sys
import uuid import uuid
from functools import lru_cache
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
@@ -19,7 +20,25 @@ SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path: if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR)) sys.path.insert(0, str(SCRIPT_DIR))
from minicpmo_realtime_duplex_scenarios import _url_with_model, run_demo # noqa: E402
@lru_cache(maxsize=1)
def _scenario_module():
"""Delay heavyweight vLLM imports so ``--help`` remains directly executable."""
import minicpmo_realtime_duplex_scenarios
return minicpmo_realtime_duplex_scenarios
def _ref_audio_data_url(path: str) -> str:
return _scenario_module()._ref_audio_data_url(path)
def _url_with_model(*args, **kwargs) -> str:
return _scenario_module()._url_with_model(*args, **kwargs)
async def run_demo(args):
return await _scenario_module().run_demo(args)
def _with_resume_mode(url: str) -> str: def _with_resume_mode(url: str) -> str:
@@ -114,7 +133,12 @@ async def _open_admission_session(
args: argparse.Namespace, args: argparse.Namespace,
session_id: str, session_id: str,
) -> tuple[object, dict[str, object]]: ) -> tuple[object, dict[str, object]]:
url = _url_with_model(args.url, args.model, session_id=session_id) url = _url_with_model(
args.url,
args.model,
autostart=False if getattr(args, "ref_audio", None) else None,
session_id=session_id,
)
ws = await websockets.connect(url, max_size=64 * 1024 * 1024) ws = await websockets.connect(url, max_size=64 * 1024 * 1024)
await ws.send( await ws.send(
json.dumps( json.dumps(
@@ -125,6 +149,7 @@ async def _open_admission_session(
"model": args.model, "model": args.model,
"modalities": ["audio", "text"], "modalities": ["audio", "text"],
"extra_body": {"minicpmo45_native_duplex": True}, "extra_body": {"minicpmo45_native_duplex": True},
**({"ref_audio": _ref_audio_data_url(args.ref_audio)} if getattr(args, "ref_audio", None) else {}),
}, },
} }
) )
@@ -151,7 +176,12 @@ async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str,
accepted.append(await _open_admission_session(args, f"{prefix}-accepted-{index}")) accepted.append(await _open_admission_session(args, f"{prefix}-accepted-{index}"))
overflow_id = f"{prefix}-overflow" overflow_id = f"{prefix}-overflow"
overflow_url = _url_with_model(args.url, args.model, session_id=overflow_id) overflow_url = _url_with_model(
args.url,
args.model,
autostart=False if getattr(args, "ref_audio", None) else None,
session_id=overflow_id,
)
async with websockets.connect(overflow_url, max_size=64 * 1024 * 1024) as overflow: async with websockets.connect(overflow_url, max_size=64 * 1024 * 1024) as overflow:
await overflow.send( await overflow.send(
json.dumps( json.dumps(
@@ -162,6 +192,11 @@ async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str,
"model": args.model, "model": args.model,
"modalities": ["audio", "text"], "modalities": ["audio", "text"],
"extra_body": {"minicpmo45_native_duplex": True}, "extra_body": {"minicpmo45_native_duplex": True},
**(
{"ref_audio": _ref_audio_data_url(args.ref_audio)}
if getattr(args, "ref_audio", None)
else {}
),
}, },
} }
) )
@@ -207,16 +242,27 @@ async def _resume_probe(
session_id: str, session_id: str,
expect_expired: bool = False, expect_expired: bool = False,
) -> dict[str, object]: ) -> dict[str, object]:
url = _url_with_model(args.url, args.model, session_id=session_id) url = _url_with_model(
args.url,
args.model,
autostart=False if getattr(args, "ref_audio", None) else None,
session_id=session_id,
)
async with websockets.connect(url, max_size=64 * 1024 * 1024) as first: async with websockets.connect(url, max_size=64 * 1024 * 1024) as first:
await first.send( await first.send(
json.dumps( json.dumps(
{ {
"type": "session.update", "type": "session.update",
"session": { "session": {
"session_id": session_id,
"model": args.model, "model": args.model,
"modalities": ["audio", "text"], "modalities": ["audio", "text"],
"extra_body": {"minicpmo45_native_duplex": True}, "extra_body": {"minicpmo45_native_duplex": True},
**(
{"ref_audio": _ref_audio_data_url(args.ref_audio)}
if getattr(args, "ref_audio", None)
else {}
),
}, },
} }
) )
@@ -306,7 +352,12 @@ async def _takeover_probe(
*, *,
session_id: str, session_id: str,
) -> dict[str, object]: ) -> dict[str, object]:
url = _url_with_model(args.url, args.model, session_id=session_id) url = _url_with_model(
args.url,
args.model,
autostart=False if getattr(args, "ref_audio", None) else None,
session_id=session_id,
)
first = await websockets.connect(url, max_size=64 * 1024 * 1024) first = await websockets.connect(url, max_size=64 * 1024 * 1024)
second = None second = None
try: try:
@@ -315,9 +366,15 @@ async def _takeover_probe(
{ {
"type": "session.update", "type": "session.update",
"session": { "session": {
"session_id": session_id,
"model": args.model, "model": args.model,
"modalities": ["audio", "text"], "modalities": ["audio", "text"],
"extra_body": {"minicpmo45_native_duplex": True}, "extra_body": {"minicpmo45_native_duplex": True},
**(
{"ref_audio": _ref_audio_data_url(args.ref_audio)}
if getattr(args, "ref_audio", None)
else {}
),
}, },
} }
) )
@@ -451,20 +508,7 @@ async def run_multi_session(args: argparse.Namespace) -> dict[str, object]:
args, args,
session_id=f"takeover-{args.takeover_session_index}-{uuid.uuid4().hex}", session_id=f"takeover-{args.takeover_session_index}-{uuid.uuid4().hex}",
) )
expiry_result = None lifecycle_result = await run_lifecycle_probes(args)
if args.expire_session_index is not None:
if not 0 <= args.expire_session_index < args.sessions:
raise ValueError("--expire-session-index is outside the session range")
expiry_result = await _resume_probe(
args,
session_id=f"expire-{args.expire_session_index}-{uuid.uuid4().hex}",
expect_expired=True,
)
admission_result = (
await _admission_probe(args, limit=args.verify_admission_limit)
if args.verify_admission_limit is not None
else None
)
session_results = await asyncio.gather( session_results = await asyncio.gather(
*(run_demo(_demo_args(args, index)) for index in range(args.sessions)), *(run_demo(_demo_args(args, index)) for index in range(args.sessions)),
@@ -487,16 +531,15 @@ async def run_multi_session(args: argparse.Namespace) -> dict[str, object]:
and semantic_isolation_ok and semantic_isolation_ok
and (resume_result is None or resume_result.get("ok") is True) and (resume_result is None or resume_result.get("ok") is True)
and (takeover_result is None or takeover_result.get("ok") is True) and (takeover_result is None or takeover_result.get("ok") is True)
and (expiry_result is None or expiry_result.get("ok") is True) and lifecycle_result["ok"] is True
and (admission_result is None or admission_result.get("ok") is True)
), ),
"session_count": args.sessions, "session_count": args.sessions,
"identity_isolation_ok": identity_isolation_ok, "identity_isolation_ok": identity_isolation_ok,
"semantic_isolation_ok": semantic_isolation_ok, "semantic_isolation_ok": semantic_isolation_ok,
"resume": resume_result, "resume": resume_result,
"takeover": takeover_result, "takeover": takeover_result,
"expiry": expiry_result, "expiry": lifecycle_result["expiry"],
"admission": admission_result, "admission": lifecycle_result["admission"],
"failures": failures, "failures": failures,
"sessions": completed, "sessions": completed,
} }
@@ -507,6 +550,34 @@ async def run_multi_session(args: argparse.Namespace) -> dict[str, object]:
return result return result
async def run_lifecycle_probes(args: argparse.Namespace) -> dict[str, object]:
"""Run expiry and admission probes without requiring model output."""
if args.sessions < 1:
raise ValueError("--sessions must be positive")
expiry_result = None
if args.expire_session_index is not None:
if not 0 <= args.expire_session_index < args.sessions:
raise ValueError("--expire-session-index is outside the session range")
expiry_result = await _resume_probe(
args,
session_id=f"expire-{args.expire_session_index}-{uuid.uuid4().hex}",
expect_expired=True,
)
admission_result = (
await _admission_probe(args, limit=args.verify_admission_limit)
if args.verify_admission_limit is not None
else None
)
return {
"ok": (
(expiry_result is None or expiry_result.get("ok") is True)
and (admission_result is None or admission_result.get("ok") is True)
),
"expiry": expiry_result,
"admission": admission_result,
}
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--url", default="ws://127.0.0.1:8113/v1/realtime?duplex=1") parser.add_argument("--url", default="ws://127.0.0.1:8113/v1/realtime?duplex=1")
@@ -2,8 +2,8 @@
This E2E driver runs the public ``realtime_duplex_demo.py`` against a live This E2E driver runs the public ``realtime_duplex_demo.py`` against a live
duplex backend. Arbitrary audio defaults to the model-policy lifecycle contract. duplex backend. Arbitrary audio defaults to the model-policy lifecycle contract.
The stronger response-required mode binds the two-response contract to a known The stronger response-required mode binds the multi-response contract to a
input checksum and expected second response. known input checksum and expected follow-up response.
""" """
from __future__ import annotations from __future__ import annotations
@@ -39,6 +39,17 @@ def _validate_input_sha256(path: Path, expected: str) -> str:
return actual return actual
def _input_duration_s(path: Path) -> float:
with wave.open(str(path), "rb") as wav_file:
return wav_file.getnframes() / wav_file.getframerate()
def _client_process_timeout_s(input_wav: Path, protocol_timeout_s: float) -> float:
# The child first streams the WAV in real time, then can independently
# exhaust its post-commit and session-close protocol waits.
return _input_duration_s(input_wav) + 2 * protocol_timeout_s + 30.0
def _read_json(path: Path) -> dict[str, object]: def _read_json(path: Path) -> dict[str, object]:
if not path.is_file(): if not path.is_file():
return {} return {}
@@ -211,7 +222,7 @@ def summarize_artifacts(
validation_mode: str, validation_mode: str,
min_responses: int, min_responses: int,
min_audio_deltas_per_response: int, min_audio_deltas_per_response: int,
expect_second_response_substring: str | None, expect_followup_response_substring: str | None,
) -> dict[str, object]: ) -> dict[str, object]:
if validation_mode not in {"model-policy", "response-required"}: if validation_mode not in {"model-policy", "response-required"}:
raise ValueError(f"unsupported validation mode: {validation_mode}") raise ValueError(f"unsupported validation mode: {validation_mode}")
@@ -274,12 +285,20 @@ def summarize_artifacts(
) )
final_listen_after_commit = commit_index is not None and any(index > commit_index for index in listen_indices) final_listen_after_commit = commit_index is not None and any(index > commit_index for index in listen_indices)
transcript = "".join(str(summary.get("transcript") or "") for summary in response_summaries) transcript = "".join(str(summary.get("transcript") or "") for summary in response_summaries)
second_response_transcript = ( followup_response_transcripts = [
str(response_summaries[1].get("transcript") or "") if len(response_summaries) >= 2 else "" str(summary.get("transcript") or "")
for summary in response_summaries[1:]
if commit_index is not None
and isinstance(summary.get("created_index"), int)
and summary["created_index"] < commit_index
]
followup_response_transcript_ok = any(
bool(_normalize_text(transcript)) for transcript in followup_response_transcripts
)
followup_response_transcript_expectation_ok = not expect_followup_response_substring or any(
_normalize_text(expect_followup_response_substring) in _normalize_text(transcript)
for transcript in followup_response_transcripts
) )
second_response_transcript_expectation_ok = not expect_second_response_substring or _normalize_text(
expect_second_response_substring
) in _normalize_text(second_response_transcript)
error_events = [ error_events = [
event event
for event in events for event in events
@@ -310,9 +329,8 @@ def summarize_artifacts(
mode_contract_ok = validation_mode == "model-policy" or ( mode_contract_ok = validation_mode == "model-policy" or (
second_response_before_final_commit second_response_before_final_commit
and listen_before_first_response and listen_before_first_response
and listen_between_responses
and listen_after_last_done and listen_after_last_done
and second_response_transcript_expectation_ok and followup_response_transcript_ok
) )
ok = common_contract_ok and mode_contract_ok ok = common_contract_ok and mode_contract_ok
return { return {
@@ -337,9 +355,10 @@ def summarize_artifacts(
"listen_after_response_before_commit": listen_after_response_before_commit, "listen_after_response_before_commit": listen_after_response_before_commit,
"final_listen_after_commit": final_listen_after_commit, "final_listen_after_commit": final_listen_after_commit,
"transcript": transcript, "transcript": transcript,
"second_response_transcript": second_response_transcript, "followup_response_transcripts": followup_response_transcripts,
"expect_second_response_substring": expect_second_response_substring, "followup_response_transcript_ok": followup_response_transcript_ok,
"second_response_transcript_expectation_ok": second_response_transcript_expectation_ok, "expect_followup_response_substring": expect_followup_response_substring,
"followup_response_transcript_expectation_ok": followup_response_transcript_expectation_ok,
"error_count": len(error_events), "error_count": len(error_events),
"cancelled_count": cancelled_count, "cancelled_count": cancelled_count,
"compact_sequence": _compact_sequence(events), "compact_sequence": _compact_sequence(events),
@@ -388,7 +407,7 @@ async def run_soft_interrupt(args: argparse.Namespace) -> dict[str, object]:
try: try:
stdout_bytes, stderr_bytes = await asyncio.wait_for( stdout_bytes, stderr_bytes = await asyncio.wait_for(
process.communicate(), process.communicate(),
timeout=args.timeout_s + 30.0, timeout=_client_process_timeout_s(input_wav, args.timeout_s),
) )
except TimeoutError: except TimeoutError:
process.kill() process.kill()
@@ -406,7 +425,7 @@ async def run_soft_interrupt(args: argparse.Namespace) -> dict[str, object]:
validation_mode=args.validation_mode, validation_mode=args.validation_mode,
min_responses=args.min_responses, min_responses=args.min_responses,
min_audio_deltas_per_response=args.min_audio_deltas_per_response, min_audio_deltas_per_response=args.min_audio_deltas_per_response,
expect_second_response_substring=args.expect_second_response_substring, expect_followup_response_substring=args.expect_followup_response_substring,
) )
summary.update( summary.update(
{ {
@@ -444,14 +463,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--min-responses", type=int, default=2) parser.add_argument("--min-responses", type=int, default=2)
parser.add_argument("--min-audio-deltas-per-response", type=int, default=2) parser.add_argument("--min-audio-deltas-per-response", type=int, default=2)
parser.add_argument("--input-sha256") parser.add_argument("--input-sha256")
parser.add_argument("--expect-second-response-substring") parser.add_argument("--expect-followup-response-substring")
args = parser.parse_args() args = parser.parse_args()
if args.validation_mode == "response-required" and args.min_responses < 2: if args.validation_mode == "response-required" and args.min_responses < 2:
parser.error("--min-responses must be at least 2") parser.error("--min-responses must be at least 2")
if args.validation_mode == "response-required" and not args.input_sha256: if args.validation_mode == "response-required" and not args.input_sha256:
parser.error("--input-sha256 is required in response-required mode") parser.error("--input-sha256 is required in response-required mode")
if args.validation_mode == "response-required" and not args.expect_second_response_substring:
parser.error("--expect-second-response-substring is required in response-required mode")
if args.min_audio_deltas_per_response < 1: if args.min_audio_deltas_per_response < 1:
parser.error("--min-audio-deltas-per-response must be positive") parser.error("--min-audio-deltas-per-response must be positive")
return args return args
+36 -30
View File
@@ -1,9 +1,7 @@
""" """E2E online tests for MiniCPM-o 4.5 multimodal input and audio/text output.
E2E Online tests for MiniCPM-o 4.5 model with multimodal input and audio / text output.
MiniCPM-o 4.5 has ``async_chunk: false``, ``max_num_seqs: 1`` on both stages, Exercises async chunk streaming (``--async-chunk``) across separate Thinker,
and the vocoder runs in-process inside the talker stage rather than as a separate Talker, and Code2Wav stages.
Code2Wav stage.
""" """
import os import os
@@ -13,26 +11,28 @@ import pytest
from tests.helpers.mark import hardware_test from tests.helpers.mark import hardware_test
from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video
from tests.helpers.runtime import OmniServerParams, dummy_messages_from_mix_data from tests.helpers.runtime import OmniServerParams, dummy_messages_from_mix_data
from tests.helpers.stage_config import get_deploy_config_path from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
_MODEL = "openbmb/MiniCPM-o-4_5" _MODEL = "openbmb/MiniCPM-o-4_5"
_CI_DEPLOY = get_deploy_config_path("minicpmo_4_5_batching.yaml") _CI_DEPLOY = modify_stage_config(
get_deploy_config_path("minicpmo_4_5.yaml"),
updates={
"stages": {0: {"default_sampling_params.max_tokens": 64}, 1: {"default_sampling_params.max_tokens": 1024}}
},
)
test_params = [ test_params = [
pytest.param( pytest.param(
OmniServerParams( OmniServerParams(
model=_MODEL, model=_MODEL,
stage_config_path=_CI_DEPLOY, stage_config_path=_CI_DEPLOY,
use_stage_cli=True, use_stage_cli=False,
server_args=[ server_args=["--trust-remote-code", "--async-chunk"],
"--trust-remote-code",
"--no-async-chunk",
],
), ),
id="default", id="async_chunk",
) ),
] ]
@@ -65,16 +65,15 @@ def get_max_batch_size(size_type="few"):
return batch_sizes.get(size_type, 5) return batch_sizes.get(size_type, 5)
@pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/5437")
@pytest.mark.core_model @pytest.mark.core_model
@pytest.mark.advanced_model @pytest.mark.advanced_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_text_001(omni_server, openai_client) -> None: def test_text_to_text_001(omni_server, openai_client) -> None:
""" """
Test text-only input generating text output via OpenAI API. Test text-only input generating text output via OpenAI API.
Deploy Setting: default 2GPU Deploy Setting: default single GPU
Input Modal: text Input Modal: text
Output Modal: text Output Modal: text
Input Setting: stream=False Input Setting: stream=False
@@ -94,13 +93,13 @@ def test_text_to_text_001(omni_server, openai_client) -> None:
@pytest.mark.full_model @pytest.mark.full_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_audio_001(omni_server, openai_client) -> None: def test_text_to_audio_001(omni_server, openai_client) -> None:
""" """
Test text-only input generating text + audio output via OpenAI API. Test text-only input generating text + audio output via OpenAI API.
This exercises the talker TTS region detection and in-process token2wav vocoder. This exercises Talker TTS region detection and the Code2Wav stage.
Deploy Setting: default 2GPU Deploy Setting: default single GPU
Input Modal: text Input Modal: text
Output Modal: text + audio Output Modal: text + audio
Input Setting: stream=True Input Setting: stream=True
@@ -119,12 +118,12 @@ def test_text_to_audio_001(omni_server, openai_client) -> None:
@pytest.mark.full_model @pytest.mark.full_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_audio_to_text_audio_001(omni_server, openai_client) -> None: def test_audio_to_text_audio_001(omni_server, openai_client) -> None:
""" """
Test audio input generating text + audio output via OpenAI API. Test audio input generating text + audio output via OpenAI API.
Deploy Setting: default 2GPU Deploy Setting: default single GPU
Input Modal: text + audio Input Modal: text + audio
Output Modal: text + audio Output Modal: text + audio
Input Setting: stream=True Input Setting: stream=True
@@ -133,13 +132,21 @@ def test_audio_to_text_audio_001(omni_server, openai_client) -> None:
messages = dummy_messages_from_mix_data( messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(), system_prompt=get_system_prompt(),
audio_data_url=audio_data_url, audio_data_url=audio_data_url,
content_text=get_prompt("mix"), content_text=get_prompt(),
) )
request_config = { request_config = {
"model": omni_server.model, "model": omni_server.model,
"messages": messages, "messages": messages,
"stream": True, "stream": True,
"key_words": {"text": ["Beijing"]},
"modalities": ["text", "audio"],
"extra_body": {
"chat_template_kwargs": {
"use_tts_template": True,
"enable_thinking": False,
}
},
} }
openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
@@ -147,12 +154,12 @@ def test_audio_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.full_model @pytest.mark.full_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_image_to_text_audio_001(omni_server, openai_client) -> None: def test_image_to_text_audio_001(omni_server, openai_client) -> None:
""" """
Test image input generating text + audio output via OpenAI API. Test image input generating text + audio output via OpenAI API.
Deploy Setting: default 2GPU Deploy Setting: default single GPU
Input Modal: text + image Input Modal: text + image
Output Modal: text + audio Output Modal: text + audio
Input Setting: stream=True Input Setting: stream=True
@@ -175,12 +182,12 @@ def test_image_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.full_model @pytest.mark.full_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_video_to_text_audio_001(omni_server, openai_client) -> None: def test_video_to_text_audio_001(omni_server, openai_client) -> None:
""" """
Test video input generating text + audio output via OpenAI API. Test video input generating text + audio output via OpenAI API.
Deploy Setting: default 2GPU Deploy Setting: default single GPU
Input Modal: text + video Input Modal: text + video
Output Modal: text + audio Output Modal: text + audio
Input Setting: stream=True Input Setting: stream=True
@@ -201,16 +208,15 @@ def test_video_to_text_audio_001(omni_server, openai_client) -> None:
openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
@pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/5437")
@pytest.mark.core_model @pytest.mark.core_model
@pytest.mark.advanced_model @pytest.mark.advanced_model
@pytest.mark.omni @pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_mix_to_text_audio_001(omni_server, openai_client) -> None: def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
""" """
Test multi-modal input (text + audio + video + image) generating text + audio output. Test multi-modal input (text + audio + video + image) generating text + audio output.
Deploy Setting: default 2GPU Deploy Setting: default single GPU
Input Modal: text + audio + video + image Input Modal: text + audio + video + image
Output Modal: text + audio Output Modal: text + audio
Input Setting: stream=True Input Setting: stream=True
@@ -0,0 +1,176 @@
"""CI coverage for the MiniCPM-o 4.5 native-duplex Realtime API."""
from __future__ import annotations
import asyncio
import json
import uuid
from pathlib import Path
import pytest
import websockets
from tests.e2e.online_serving.minicpmo_realtime_duplex_scenarios import (
_ref_audio_data_url,
run_demo,
)
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import (
run_multi_session,
)
from tests.helpers.mark import hardware_test
from tests.helpers.minicpmo_4_5_duplex import (
SERVER_PARAMS,
demo_args,
multi_session_args,
realtime_url,
resolve_ref_audio,
validated_input_wav,
)
from vllm_omni.experimental.fullduplex.client import build_realtime_url
pytestmark = pytest.mark.omni
def _assert_request_metrics(metrics: object, *, expected_count: int) -> None:
assert isinstance(metrics, list)
assert len(metrics) == expected_count
for request_index, request in enumerate(metrics):
assert isinstance(request["session_id"], str)
assert request["request_index"] == request_index
assert isinstance(request["response_id"], str)
assert request["ttft_ms"] is not None and request["ttft_ms"] >= 0
assert request["ttfp_ms"] >= 0
assert request["rtf"] is not None and request["rtf"] >= 0
assert request["audio_generation_ms"] >= 0
assert request["audio_duration_ms"] > 0
def _assert_session_metrics(metrics: object, *, expected_count: int) -> None:
assert isinstance(metrics, dict)
assert isinstance(metrics["session_id"], str)
assert metrics["audio_turn_count"] == expected_count
assert metrics["mean_ttft_ms"] is not None and metrics["mean_ttft_ms"] >= 0
assert metrics["mean_ttfp_ms"] is not None and metrics["mean_ttfp_ms"] >= 0
assert metrics["mean_rtf"] is not None and metrics["mean_rtf"] >= 0
async def _receive_protocol_events(ws, required_types: set[str], *, timeout_s: float) -> list[dict[str, object]]:
async def receive() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
seen: set[str] = set()
while not required_types.issubset(seen):
raw = await ws.recv()
if not isinstance(raw, str):
continue
event = json.loads(raw)
if not isinstance(event, dict):
continue
events.append(event)
event_type = event.get("type")
if event_type == "error":
raise AssertionError(f"WebSocket protocol smoke received an error: {event}")
if isinstance(event_type, str):
seen.add(event_type)
return events
return await asyncio.wait_for(receive(), timeout=timeout_s)
async def _run_protocol_smoke(*, url: str, model: str, ref_audio: Path) -> list[dict[str, object]]:
session_id = f"duplex-ci-protocol-{uuid.uuid4().hex}"
websocket_url = build_realtime_url(url, model, autostart=False, session_id=session_id)
async with websockets.connect(websocket_url, max_size=64 * 1024 * 1024) as ws:
await ws.send(
json.dumps(
{
"type": "session.update",
"session": {
"session_id": session_id,
"model": model,
"modalities": ["audio", "text"],
"ref_audio": _ref_audio_data_url(str(ref_audio)),
"extra_body": {"minicpmo45_native_duplex": True},
},
}
)
)
events = await _receive_protocol_events(
ws,
{"session.created", "session.updated"},
timeout_s=60,
)
await ws.send(json.dumps({"type": "session.close"}))
events.extend(await _receive_protocol_events(ws, {"session.closed"}, timeout_s=60))
return events
@pytest.mark.core_model
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_websocket_protocol_smoke(omni_server, model_prefix: str) -> None:
ref_audio = resolve_ref_audio(model_prefix)
events = asyncio.run(
_run_protocol_smoke(
url=realtime_url(omni_server),
model=omni_server.model,
ref_audio=ref_audio,
)
)
event_types = [event.get("type") for event in events]
assert "session.created" in event_types
assert "session.updated" in event_types
assert event_types[-1] == "session.closed"
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_single_session_response_required(omni_server, model_prefix: str, tmp_path: Path) -> None:
args = demo_args(
omni_server=omni_server,
input_wav=validated_input_wav(),
ref_audio=resolve_ref_audio(model_prefix),
output_dir=tmp_path / "single_session",
)
args.turns = 2
# Every turn replays the same active-speech window as the first one. The default
# shorter follow-up window is a different mid-utterance slice, which the native
# duplex model may legitimately answer with "listen" instead of a response.
args.turn_duration_ms = [args.first_turn_ms] * args.turns
result = asyncio.run(run_demo(args))
assert result["ok"] is True
assert result["audio_delta_count"] > 0
assert result["done_count"] == 2
assert result["error_count"] == 0
assert result["all_audio_responses_have_transcript"] is True
assert result["transcript_delta_done_ok"] is True
_assert_request_metrics(result["request_metrics"], expected_count=2)
_assert_session_metrics(result["session_metrics"], expected_count=2)
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_two_sessions_resume_and_takeover(omni_server, model_prefix: str, tmp_path: Path) -> None:
result = asyncio.run(
run_multi_session(
multi_session_args(
omni_server=omni_server,
input_wav=validated_input_wav(),
ref_audio=resolve_ref_audio(model_prefix),
output_dir=tmp_path / "multi_session",
response_required=True,
)
)
)
assert result["ok"] is True
assert result["session_count"] == 2
assert result["resume"]["ok"] is True
assert result["takeover"]["ok"] is True
assert not result["failures"]
assert all(session["audio_delta_count"] > 0 for session in result["sessions"])
assert all(session["done_count"] == 1 for session in result["sessions"])
assert all(session["error_count"] == 0 for session in result["sessions"])
for session in result["sessions"]:
_assert_request_metrics(session["request_metrics"], expected_count=1)
_assert_session_metrics(session["session_metrics"], expected_count=1)
@@ -0,0 +1,90 @@
"""Nightly lifecycle coverage for the MiniCPM-o 4.5 native-duplex API."""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import (
run_lifecycle_probes,
)
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_soft_interrupt import (
run_soft_interrupt,
)
from tests.helpers.mark import hardware_test
from tests.helpers.minicpmo_4_5_duplex import (
SERVER_PARAMS,
SOFT_INTERRUPT_SHA256,
multi_session_args,
realtime_url,
resolve_ref_audio,
validated_input_wav,
validated_soft_interrupt_wav,
)
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_admission_and_expiry_reaper(omni_server, model_prefix: str, tmp_path: Path) -> None:
args = multi_session_args(
omni_server=omni_server,
input_wav=validated_input_wav(),
ref_audio=resolve_ref_audio(model_prefix),
output_dir=tmp_path / "admission_expiry",
response_required=False,
)
args.sessions = 1
args.disconnect_session_index = None
args.takeover_session_index = None
args.expire_session_index = 0
args.verify_admission_limit = 2
result = asyncio.run(run_lifecycle_probes(args))
assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2)
assert result["expiry"]["ok"] is True
assert result["expiry"]["error_code"] == "session_resume_expired"
assert result["admission"]["ok"] is True
assert result["admission"]["overflow_error_code"] == "resource_exhausted"
# CUDA-only for now: this contract asserts the model speaks while the user is
# still talking, which only holds when the duplex pipeline sustains real-time
# throughput. The current NPU stack runs several times slower than real time,
# so it never reaches a mid-stream decision point.
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_soft_interrupt(omni_server, model_prefix: str, tmp_path: Path) -> None:
input_wav = validated_soft_interrupt_wav()
result = asyncio.run(
run_soft_interrupt(
SimpleNamespace(
url=realtime_url(omni_server),
model=omni_server.model,
input_wav=str(input_wav),
ref_audio=str(resolve_ref_audio(model_prefix)),
output_dir=str(tmp_path / "soft_interrupt"),
summary_output=None,
chunk_ms=200,
timeout_s=180.0,
require_audio=True,
no_realtime_pacing=False,
validation_mode="response-required",
min_responses=2,
min_audio_deltas_per_response=2,
input_sha256=SOFT_INTERRUPT_SHA256,
expect_followup_response_substring=None,
)
)
)
assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2)
assert result["error_count"] == 0
assert result["response_lifecycle_ok"] is True
assert result["response_audio_contract_ok"] is True
assert result["followup_response_transcript_ok"] is True
@@ -1,7 +1,10 @@
""" """E2E online expansion tests for MiniCPM-o 4.5.
E2E Online expansion tests for MiniCPM-o 4.5 covering modality combinations,
in-process token2wav vocoder behavior, max_num_seqs=1 serial execution, These cover modality combinations, separate Code2Wav behavior, sequential
and edge-case validation. request isolation, longer audio output, and Chinese speech.
Like Qwen3-Omni expansion, ``default`` exercises ``--no-async-chunk`` and
``async_chunk`` exercises ``--async-chunk``.
""" """
import os import os
@@ -18,20 +21,29 @@ pytestmark = [pytest.mark.full_model, pytest.mark.omni]
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
_MODEL = "openbmb/MiniCPM-o-4_5" _MODEL = "openbmb/MiniCPM-o-4_5"
_CI_DEPLOY = get_deploy_config_path("minicpmo_4_5_batching.yaml") _CI_DEPLOY = get_deploy_config_path("minicpmo_4_5.yaml")
# Deploy YAML defaults to ``async_chunk: true``; keep sync and async as
# separate parametrizations so both handoff paths are exercised.
test_params = [ test_params = [
pytest.param( pytest.param(
OmniServerParams( OmniServerParams(
model=_MODEL, model=_MODEL,
stage_config_path=_CI_DEPLOY, stage_config_path=_CI_DEPLOY,
use_stage_cli=True, use_stage_cli=False,
server_args=[ server_args=["--trust-remote-code", "--no-async-chunk"],
"--trust-remote-code",
],
), ),
id="default", id="default",
) ),
pytest.param(
OmniServerParams(
model=_MODEL,
stage_config_path=_CI_DEPLOY,
use_stage_cli=False,
server_args=["--trust-remote-code", "--async-chunk"],
),
id="async_chunk",
),
] ]
@@ -63,12 +75,12 @@ def get_max_batch_size(size_type="few"):
return batch_sizes.get(size_type, 5) return batch_sizes.get(size_type, 5)
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_video_to_text_001(omni_server, openai_client) -> None: def test_text_video_to_text_001(omni_server, openai_client) -> None:
""" """
Test text + video input generating text output. Test text + video input generating text output.
Deploy Setting: default 2GPU Deploy Setting: default single GPU
Input Modal: text + video Input Modal: text + video
Output Modal: text Output Modal: text
Input Setting: stream=False Input Setting: stream=False
@@ -90,15 +102,13 @@ def test_text_video_to_text_001(omni_server, openai_client) -> None:
openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_sequential_requests_independent(omni_server, openai_client) -> None: def test_sequential_requests_independent(omni_server, openai_client) -> None:
""" """
Verify that sequential requests produce independent results and state Verify that sequential requests produce independent results and that the
does not leak between requests. MiniCPM-o 4.5 has max_num_seqs=1 so second request does not receive the first request's audio or token state.
requests are processed one-at-a-time; the second request must not Deploy Setting: default single GPU
receive the first request's audio or token state.
Deploy Setting: default 2GPU
Input Modal: text (two different prompts) Input Modal: text (two different prompts)
Output Modal: text + audio (both) Output Modal: text + audio (both)
""" """
@@ -133,33 +143,42 @@ def test_sequential_requests_independent(omni_server, openai_client) -> None:
) )
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_audio_long_output_001(omni_server, openai_client) -> None: def test_text_to_audio_long_output_001(omni_server, openai_client) -> None:
""" """
Test text input generating a longer audio output to exercise the Test text input generating a longer audio output to exercise the
token2wav decoder across multiple frames. Code2Wav stage across multiple frames.
Deploy Setting: default 2GPU Deploy Setting: default single GPU
Input Modal: text (longer prompt) Input Modal: text (longer prompt)
Output Modal: text + audio Output Modal: text + audio
Input Setting: stream=True Input Setting: stream=True
""" """
messages = dummy_messages_from_mix_data( messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(), system_prompt=get_system_prompt(),
content_text="Tell me a short story about a cat in about 50 words.", content_text="What is the capital of China? Answer in 40 words.",
) )
request_config = { request_config = {
"model": omni_server.model, "model": omni_server.model,
"messages": messages, "messages": messages,
"modalities": ["text", "audio"],
"stream": True, "stream": True,
"key_words": {"audio": ["cat"]}, # Delimit the assistant answer with the TTS template so the Talker
# does not spend its codec-token budget speaking hidden reasoning.
"extra_body": {
"chat_template_kwargs": {
"use_tts_template": True,
"enable_thinking": False,
}
},
"key_words": {"audio": ["Beijing"]},
} }
openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_chinese_text_to_audio(omni_server, openai_client) -> None: def test_chinese_text_to_audio(omni_server, openai_client) -> None:
""" """
@@ -12,14 +12,11 @@ import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
DEMO_PATH = Path(__file__).resolve().parents[1] / "e2e/online_serving/minicpmo_realtime_duplex_scenarios.py" DRIVER_DIR = Path(__file__).resolve().parent
MULTI_DEMO_PATH = ( DEMO_PATH = DRIVER_DIR / "minicpmo_realtime_duplex_scenarios.py"
Path(__file__).resolve().parents[1] / "e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py" MULTI_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_multi_session.py"
) PAIR_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_demo_pair.py"
PAIR_DEMO_PATH = Path(__file__).resolve().parents[1] / "e2e/online_serving/run_minicpmo_realtime_duplex_demo_pair.py" SOFT_INTERRUPT_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_soft_interrupt.py"
SOFT_INTERRUPT_DEMO_PATH = (
Path(__file__).resolve().parents[1] / "e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py"
)
def _load_demo_module(): def _load_demo_module():
@@ -338,14 +335,7 @@ def test_realtime_duplex_soft_interrupt_accepts_explicit_ref_audio(monkeypatch):
assert args.validation_mode == "model-policy" assert args.validation_mode == "model-policy"
@pytest.mark.parametrize( def test_realtime_duplex_soft_interrupt_response_required_needs_bound_fixture(monkeypatch):
"extra_args",
[
["--expect-second-response-substring", "一加一等于二"],
["--input-sha256", "a" * 64],
],
)
def test_realtime_duplex_soft_interrupt_response_required_needs_bound_fixture(monkeypatch, extra_args):
demo = _load_soft_interrupt_demo_module() demo = _load_soft_interrupt_demo_module()
monkeypatch.setattr( monkeypatch.setattr(
demo.sys, demo.sys,
@@ -360,7 +350,6 @@ def test_realtime_duplex_soft_interrupt_response_required_needs_bound_fixture(mo
"ref.wav", "ref.wav",
"--validation-mode", "--validation-mode",
"response-required", "response-required",
*extra_args,
], ],
) )
@@ -412,7 +401,6 @@ def test_realtime_duplex_soft_interrupt_accepts_multi_delta_handoff_sequence(tmp
"_client_received_at_s": 2.6, "_client_received_at_s": 2.6,
}, },
{"type": "response.done", "response_id": first_response_id, "_client_received_at_s": 3.0}, {"type": "response.done", "response_id": first_response_id, "_client_received_at_s": 3.0},
{"type": "response.listen", "_client_received_at_s": 3.2},
{"type": "response.created", "response": {"id": second_response_id}, "_client_received_at_s": 4.0}, {"type": "response.created", "response": {"id": second_response_id}, "_client_received_at_s": 4.0},
{"type": "response.speak", "response_id": second_response_id, "_client_received_at_s": 4.0}, {"type": "response.speak", "response_id": second_response_id, "_client_received_at_s": 4.0},
{ {
@@ -458,10 +446,11 @@ def test_realtime_duplex_soft_interrupt_accepts_multi_delta_handoff_sequence(tmp
validation_mode="response-required", validation_mode="response-required",
min_responses=2, min_responses=2,
min_audio_deltas_per_response=2, min_audio_deltas_per_response=2,
expect_second_response_substring="一加一等于二", expect_followup_response_substring="一加一等于二",
) )
assert summary["ok"] is True assert summary["ok"] is True
assert summary["listen_between_responses"] is False
assert summary["second_response_before_final_commit"] is True assert summary["second_response_before_final_commit"] is True
assert summary["final_listen_after_commit"] is True assert summary["final_listen_after_commit"] is True
assert summary["response_audio_contract_ok"] is True assert summary["response_audio_contract_ok"] is True
@@ -498,7 +487,7 @@ def test_realtime_duplex_soft_interrupt_model_policy_accepts_single_response(tmp
validation_mode="model-policy", validation_mode="model-policy",
min_responses=2, min_responses=2,
min_audio_deltas_per_response=2, min_audio_deltas_per_response=2,
expect_second_response_substring=None, expect_followup_response_substring=None,
) )
assert summary["ok"] is True assert summary["ok"] is True
@@ -537,14 +526,14 @@ def test_realtime_duplex_soft_interrupt_response_required_rejects_single_respons
validation_mode="response-required", validation_mode="response-required",
min_responses=2, min_responses=2,
min_audio_deltas_per_response=2, min_audio_deltas_per_response=2,
expect_second_response_substring="一加一等于二", expect_followup_response_substring="一加一等于二",
) )
assert summary["ok"] is False assert summary["ok"] is False
assert summary["enough_responses"] is False assert summary["enough_responses"] is False
def test_realtime_duplex_soft_interrupt_checks_expected_text_in_second_response(tmp_path): def test_realtime_duplex_soft_interrupt_reports_text_expectation_without_gating(tmp_path):
demo = _load_soft_interrupt_demo_module() demo = _load_soft_interrupt_demo_module()
output = tmp_path / "second_response_text" output = tmp_path / "second_response_text"
output.mkdir() output.mkdir()
@@ -611,11 +600,32 @@ def test_realtime_duplex_soft_interrupt_checks_expected_text_in_second_response(
validation_mode="response-required", validation_mode="response-required",
min_responses=2, min_responses=2,
min_audio_deltas_per_response=2, min_audio_deltas_per_response=2,
expect_second_response_substring="一加一等于二", expect_followup_response_substring="一加一等于二",
) )
assert summary["ok"] is False assert summary["ok"] is True
assert summary["second_response_transcript_expectation_ok"] is False assert summary["followup_response_transcript_ok"] is True
assert summary["followup_response_transcript_expectation_ok"] is False
events = [
event
for event in events
if event.get("response_id") != second_response_id or event["type"] != "response.audio_transcript.delta"
]
(output / "events.jsonl").write_text(
"".join(demo.json.dumps(event) + "\n" for event in events),
encoding="utf-8",
)
missing_transcript_summary = demo.summarize_artifacts(
output_dir=output,
validation_mode="response-required",
min_responses=2,
min_audio_deltas_per_response=2,
expect_followup_response_substring="一加一等于二",
)
assert missing_transcript_summary["ok"] is False
assert missing_transcript_summary["followup_response_transcript_ok"] is False
def test_realtime_duplex_multi_session_resume_url_disables_autostart(): def test_realtime_duplex_multi_session_resume_url_disables_autostart():
@@ -789,6 +799,7 @@ def test_realtime_duplex_demo_reads_response_playback_cursor():
def test_realtime_duplex_demo_partitions_timing_by_response_identity(): def test_realtime_duplex_demo_partitions_timing_by_response_identity():
demo = _load_demo_module() demo = _load_demo_module()
state = demo.DemoState() state = demo.DemoState()
state.input_commit_sent_at_s.extend((9.9, 19.9))
for response_id, created_at_s, audio_at_s, token_count in ( for response_id, created_at_s, audio_at_s, token_count in (
("resp-1", 10.0, 10.1, 3), ("resp-1", 10.0, 10.1, 3),
("resp-2", 20.0, 20.2, 5), ("resp-2", 20.0, 20.2, 5),
@@ -816,15 +827,66 @@ def test_realtime_duplex_demo_partitions_timing_by_response_identity():
}, },
received_at_s=audio_at_s, received_at_s=audio_at_s,
) )
state.add(
{
"type": "response.audio_transcript.delta",
"response_id": response_id,
"delta": "audio",
},
received_at_s=created_at_s + 0.05,
)
timings = state.response_timing_summaries() timings = state.response_timing_summaries()
requests = state.session_request_metrics(session_id="seed-tts-session")
session_metrics = state.session_metric_summary(session_id="seed-tts-session")
assert timings["resp-1"]["stage0_tokens"]["output_token_count"] == 3 assert timings["resp-1"]["stage0_tokens"]["output_token_count"] == 3
assert timings["resp-1"]["audio_output"]["response_created_to_first_audio_ms"] == 100.0 assert timings["resp-1"]["audio_output"]["response_created_to_first_audio_ms"] == 100.0
assert timings["resp-1"]["audio_output"]["commit_to_first_audio_ms"] is None assert timings["resp-1"]["audio_output"]["commit_to_first_audio_ms"] == 200.0
assert timings["resp-2"]["stage0_tokens"]["output_token_count"] == 5 assert timings["resp-2"]["stage0_tokens"]["output_token_count"] == 5
assert timings["resp-2"]["audio_output"]["response_created_to_first_audio_ms"] == 200.0 assert timings["resp-2"]["audio_output"]["response_created_to_first_audio_ms"] == 200.0
assert timings["resp-2"]["audio_output"]["commit_to_first_audio_ms"] is None assert timings["resp-2"]["audio_output"]["commit_to_first_audio_ms"] == 300.0
assert requests == [
{
"session_id": "seed-tts-session",
"request_index": 0,
"response_id": "resp-1",
"ttft_ms": 150.0,
"ttfp_ms": 200.0,
"rtf": 2.5,
"audio_generation_ms": 200.0,
"audio_duration_ms": 80.0,
"source": "client_monotonic_receive",
"measurement_origin": {
"ttft": "input_audio_buffer.commit client send to first non-empty text delta",
"ttfp": "input_audio_buffer.commit client send to first audio packet",
"rtf": "commit-to-last-audio receive time divided by emitted audio duration",
},
},
{
"session_id": "seed-tts-session",
"request_index": 1,
"response_id": "resp-2",
"ttft_ms": 150.0,
"ttfp_ms": 300.0,
"rtf": 3.75,
"audio_generation_ms": 300.0,
"audio_duration_ms": 80.0,
"source": "client_monotonic_receive",
"measurement_origin": {
"ttft": "input_audio_buffer.commit client send to first non-empty text delta",
"ttfp": "input_audio_buffer.commit client send to first audio packet",
"rtf": "commit-to-last-audio receive time divided by emitted audio duration",
},
},
]
assert session_metrics == {
"session_id": "seed-tts-session",
"audio_turn_count": 2,
"mean_ttft_ms": 150.0,
"mean_ttfp_ms": 250.0,
"mean_rtf": 3.125,
}
def test_realtime_duplex_demo_model_policy_accepts_one_listen_per_streamed_turn(): def test_realtime_duplex_demo_model_policy_accepts_one_listen_per_streamed_turn():
@@ -1523,7 +1585,7 @@ def test_realtime_duplex_demo_waits_at_each_model_unit_and_stops_after_speak():
assert len(ws.messages) == 10 assert len(ws.messages) == 10
def test_realtime_duplex_demo_listen_only_overlap_sends_next_turn_before_first_done(monkeypatch): def test_realtime_duplex_demo_listen_only_overlap_accepts_silence_unit_before_first_done(monkeypatch):
demo = _load_demo_module() demo = _load_demo_module()
state = demo.DemoState() state = demo.DemoState()
send_calls = 0 send_calls = 0
@@ -1548,7 +1610,6 @@ def test_realtime_duplex_demo_listen_only_overlap_sends_next_turn_before_first_d
del args del args
assert not state.response_done("resp-first") assert not state.response_done("resp-first")
kwargs["on_model_unit_ready"]() kwargs["on_model_unit_ready"]()
state.add({"type": "input_audio_buffer.speech_started", "turn": 2})
_add_response_transcript(state, "resp-first", transcript="第一轮完成", audio=False) _add_response_transcript(state, "resp-first", transcript="第一轮完成", audio=False)
state.add({"type": "response.created", "response": {"id": "resp-second"}}) state.add({"type": "response.created", "response": {"id": "resp-second"}})
_add_response_transcript(state, "resp-second", transcript="第二轮完成", audio=True) _add_response_transcript(state, "resp-second", transcript="第二轮完成", audio=True)
@@ -1568,7 +1629,7 @@ def test_realtime_duplex_demo_listen_only_overlap_sends_next_turn_before_first_d
ws, ws,
state, state,
b"\x00\x00", b"\x00\x00",
b"\x01\x00", b"\x00\x00",
transcripts=("first", "second"), transcripts=("first", "second"),
durations_ms=(None, None), durations_ms=(None, None),
chunk_ms=200, chunk_ms=200,
+50
View File
@@ -300,6 +300,56 @@ def test_strip_single_engine_args_model_does_not_trigger_warning(mocker):
assert "model" not in warned_args assert "model" not in warned_args
def test_new_format_deploy_yaml_does_not_strip_interleave_cli_overrides(tmp_path):
"""New-format deploy YAMLs must not strip interleave_mm_strings / media_io_kwargs.
Pytest historically passed ``--stage-configs-path`` for deploy YAMLs; stripping
those CLI flags dropped MiniCPM interleaved AV packing and cut Daily-Omni
accuracy from ~78% to ~70%.
"""
from vllm_omni.entrypoints.utils import is_new_format_deploy_config
yaml_path = tmp_path / "minicpm_deploy.yaml"
yaml_path.write_text(
"""
pipeline: minicpmo_4_5
async_chunk: true
stages:
- stage_id: 0
devices: "0"
""".strip(),
encoding="utf-8",
)
path = str(yaml_path)
assert is_new_format_deploy_config(path)
kwargs = {
"stage_configs_path": path,
"trust_remote_code": True,
"interleave_mm_strings": True,
"media_io_kwargs": {"video": {"fps": 1, "num_frames": 128}},
"gpu_memory_utilization": 0.55,
}
# Mirror AsyncOmniEngine._resolve_stage_configs strip decision.
if kwargs.get("stage_configs_path") is not None and not is_new_format_deploy_config(kwargs["stage_configs_path"]):
base = AsyncOmniEngine._strip_single_engine_args(dict(kwargs))
else:
base = dict(kwargs)
assert base.get("interleave_mm_strings") is True
assert base.get("media_io_kwargs") == {"video": {"fps": 1, "num_frames": 128}}
# Parent EngineArgs fields also remain available for deploy merge.
assert base.get("gpu_memory_utilization") == 0.55
legacy = tmp_path / "legacy.yaml"
legacy.write_text("stage_args:\n - stage_id: 0\n", encoding="utf-8")
assert not is_new_format_deploy_config(str(legacy))
legacy_kwargs = dict(kwargs, stage_configs_path=str(legacy))
stripped = AsyncOmniEngine._strip_single_engine_args(legacy_kwargs)
assert "interleave_mm_strings" not in stripped
assert "media_io_kwargs" not in stripped
assert "gpu_memory_utilization" not in stripped
# For https://github.com/vllm-project/vllm-omni/issues/3293 # For https://github.com/vllm-project/vllm-omni/issues/3293
def test_tensor_parallel_size_none_is_handled(): def test_tensor_parallel_size_none_is_handled():
"""Ensure the tensor parallel size of None isn't forwarded.""" """Ensure the tensor parallel size of None isn't forwarded."""
@@ -18,6 +18,7 @@ def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state():
accumulated = MultimodalPayload.from_dict( accumulated = MultimodalPayload.from_dict(
{ {
"audio": torch.tensor([1.0]), "audio": torch.tensor([1.0]),
"meta.segment_end": torch.tensor([0]),
"meta.tts_is_last_chunk": torch.tensor([0]), "meta.tts_is_last_chunk": torch.tensor([0]),
"meta.turn_end": torch.tensor([0]), "meta.turn_end": torch.tensor([0]),
"meta.stable_request_value": "keep", "meta.stable_request_value": "keep",
@@ -26,6 +27,7 @@ def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state():
incoming = MultimodalPayload.from_dict( incoming = MultimodalPayload.from_dict(
{ {
"audio": torch.tensor([2.0]), "audio": torch.tensor([2.0]),
"meta.segment_end": torch.tensor([1]),
"meta.tts_is_last_chunk": torch.tensor([1]), "meta.tts_is_last_chunk": torch.tensor([1]),
"meta.turn_end": torch.tensor([1]), "meta.turn_end": torch.tensor([1]),
} }
@@ -41,6 +43,7 @@ def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state():
drain_delta_payload(merged) drain_delta_payload(merged)
assert "audio" not in merged assert "audio" not in merged
assert "meta.segment_end" not in merged
assert "meta.tts_is_last_chunk" not in merged assert "meta.tts_is_last_chunk" not in merged
assert "meta.turn_end" not in merged assert "meta.turn_end" not in merged
assert merged.metadata["meta.stable_request_value"] == "keep" assert merged.metadata["meta.stable_request_value"] == "keep"
+50
View File
@@ -1813,6 +1813,56 @@ async def test_stage_pool_submit_update_refreshes_output_processor_state() -> No
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handle_streaming_update_unknown_request_is_dropped() -> None:
"""A streaming update for an untracked request must not resurrect it.
Updates always follow the first-chunk add_request through the same
ordered submission queue, so an unknown id can only mean the request
already finished or was aborted (e.g. the client disconnected).
Falling back to add_request created headless sessions that kept
cycling through the stages (issue #4271).
"""
class RecordingPool:
def __init__(self) -> None:
self.calls: list[tuple[str, Any]] = []
async def submit_update(self, request_id, req_state, request, *, prompt_text=None) -> int:
self.calls.append((request_id, prompt_text))
return 0
pool = RecordingPool()
orchestrator = object.__new__(Orchestrator)
orchestrator.async_chunk = False
orchestrator.request_states = {}
orchestrator.stage_pools = [pool]
add_request_calls: list[str] = []
async def record_add_request(msg) -> None:
add_request_calls.append(msg.request_id)
orchestrator._handle_add_request = record_add_request
await orchestrator._handle_streaming_update(
StageSubmissionMessage(
type="streaming_update",
request_id="req-unknown",
prompt=SimpleNamespace(request_id="req-unknown", prompt_token_ids=[1], resumable=True),
original_prompt={"prompt": "segment-2"},
output_prompt_text="segment-2",
sampling_params_list=[_sampling_params()],
final_stage_id=0,
preprocess_ms=0.0,
request_timestamp=time.time(),
enqueue_ts=time.perf_counter(),
)
)
assert pool.calls == []
assert add_request_calls == []
assert "req-unknown" not in orchestrator.request_states
async def test_handle_streaming_update_passes_prompt_text_to_stage_pool() -> None: async def test_handle_streaming_update_passes_prompt_text_to_stage_pool() -> None:
class RecordingPool: class RecordingPool:
def __init__(self) -> None: def __init__(self) -> None:
@@ -7,15 +7,24 @@ import asyncio
import queue import queue
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import AsyncMock from unittest.mock import ANY, AsyncMock, MagicMock
import janus import janus
import pytest import pytest
from vllm.outputs import CompletionOutput, RequestOutput from vllm.outputs import CompletionOutput, RequestOutput
from vllm.sampling_params import SamplingParams from vllm.sampling_params import SamplingParams
from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState from vllm_omni.engine.orchestrator import (
Orchestrator,
OrchestratorRequestState,
_OrchestratorDuplexStagePort,
)
from vllm_omni.engine.stage_pool import StagePool from vllm_omni.engine.stage_pool import StagePool
from vllm_omni.experimental.fullduplex.engine.contracts import (
DuplexStageRequestContext,
DuplexStageSubmission,
)
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -107,11 +116,50 @@ class FakePrewarmPool:
async def submit_initial(self, _request_id, _req_state, request, prompt_text=None): async def submit_initial(self, _request_id, _req_state, request, prompt_text=None):
self.submitted.append(request) self.submitted.append(request)
return 0
def get_bound_replica_id(self, _request_id): def get_bound_replica_id(self, _request_id):
return 0 return 0
def _duplex_stage_port_submission():
stage_pools = []
for stage_id in range(3):
pool = SimpleNamespace(
stage_client=SimpleNamespace(default_sampling_params=SamplingParams(max_tokens=1)),
stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
submit_initial=AsyncMock(return_value=stage_id + 10),
submit_update=AsyncMock(return_value=stage_id + 20),
)
stage_pools.append(pool)
request_states: dict[str, OrchestratorRequestState] = {}
prewarm = AsyncMock()
port = _OrchestratorDuplexStagePort(
stage_pools=stage_pools,
request_states=request_states,
running_counter=None,
cleanup_request_ids=AsyncMock(),
async_chunk=True,
prewarm_async_chunk_stages=prewarm,
)
context = DuplexStageRequestContext(
request_id="req-duplex",
session_id="session-duplex",
fence=DuplexFence("session-duplex"),
stage_id=0,
final_stage_id=2,
config_generation=0,
sampling_params=tuple(SamplingParams(max_tokens=1) for _ in range(3)),
)
port.ensure_request(context)
submission = DuplexStageSubmission(
context=context,
prompt={"prompt_token_ids": [1, 2]},
already_submitted=False,
)
return port, stage_pools, request_states, prewarm, submission
def _request_output(request_id: str) -> RequestOutput: def _request_output(request_id: str) -> RequestOutput:
completion = CompletionOutput( completion = CompletionOutput(
index=0, index=0,
@@ -199,11 +247,13 @@ async def test_async_prewarm_skips_outgoing_only_stage() -> None:
stage2 = FakePrewarmPool("receiver") stage2 = FakePrewarmPool("receiver")
orchestrator.stage_pools = [stage0, stage1, stage2] orchestrator.stage_pools = [stage0, stage1, stage2]
orchestrator._emit_tx_edge = lambda **_kwargs: None orchestrator._emit_tx_edge = lambda **_kwargs: None
orchestrator._record_duplex_stage_submission = MagicMock()
req_state = OrchestratorRequestState( req_state = OrchestratorRequestState(
request_id="req-prewarm", request_id="req-prewarm",
prompt={"prompt_token_ids": [1, 2]}, prompt={"prompt_token_ids": [1, 2]},
sampling_params_list=[SamplingParams(max_tokens=1) for _ in range(3)], sampling_params_list=[SamplingParams(max_tokens=1) for _ in range(3)],
final_stage_id=2, final_stage_id=2,
duplex_identity=SimpleNamespace(),
) )
await orchestrator._prewarm_async_chunk_stages( await orchestrator._prewarm_async_chunk_stages(
@@ -216,6 +266,23 @@ async def test_async_prewarm_skips_outgoing_only_stage() -> None:
assert len(stage2.submitted) == 1 assert len(stage2.submitted) == 1
assert 1 not in req_state.stage_submit_ts assert 1 not in req_state.stage_submit_ts
assert 2 in req_state.stage_submit_ts assert 2 in req_state.stage_submit_ts
orchestrator._record_duplex_stage_submission.assert_called_once_with(
2,
"req-prewarm",
0,
req_state,
)
@pytest.mark.asyncio
async def test_duplex_prewarm_runs_after_first_stage0_submission() -> None:
port, stage_pools, request_states, prewarm, submission = _duplex_stage_port_submission()
result = await port.submit(submission)
assert result.stage_id == 0
stage_pools[0].submit_initial.assert_awaited_once()
prewarm.assert_awaited_once_with("req-duplex", ANY, request_states["req-duplex"])
@pytest.mark.asyncio @pytest.mark.asyncio
+122 -1
View File
@@ -14,7 +14,7 @@ from vllm.v1.engine import FinishReason
from vllm.v1.engine.output_processor import OutputProcessor as VLLMOutputProcessor from vllm.v1.engine.output_processor import OutputProcessor as VLLMOutputProcessor
from vllm_omni.outputs import output_processor from vllm_omni.outputs import output_processor
from vllm_omni.outputs.output_modality import OutputModalityNames from vllm_omni.outputs.output_modality import OutputModality, OutputModalityNames
from vllm_omni.outputs.output_processor import MultimodalOutputProcessor, OmniRequestState from vllm_omni.outputs.output_processor import MultimodalOutputProcessor, OmniRequestState
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -452,6 +452,127 @@ def test_no_detokenizer_make_request_output():
assert AUDIO in result.outputs[0].multimodal_output assert AUDIO in result.outputs[0].multimodal_output
def test_no_detokenizer_process_outputs_returns_nonterminal_audio_chunk(monkeypatch):
"""Generation-stage MM-only chunks must reach the orchestrator.
StagePool registers generation requests without a per-request collector,
so the processor return value is the only path from Stage2 to
Orchestrator._handle_processed_outputs().
"""
state = _make_no_detok_state(RequestOutputKind.DELTA)
processor = object.__new__(MultimodalOutputProcessor)
processor.output_modality = OutputModality.AUDIO
processor.request_states = {"r": state}
upstream_result = SimpleNamespace(request_outputs=[], reqs_to_abort=[])
monkeypatch.setattr(
VLLMOutputProcessor,
"process_outputs",
lambda *_args, **_kwargs: upstream_result,
)
engine_output = SimpleNamespace(
request_id="r",
multimodal_output={
"model_outputs": torch.arange(8, dtype=torch.float32),
"sr": torch.tensor(24000, dtype=torch.int32),
},
output_type="audio",
pooling_output=None,
new_token_ids=[],
finish_reason=None,
stop_reason=None,
kv_transfer_params=None,
routed_experts=None,
num_cached_tokens=0,
)
result = processor.process_outputs([engine_output])
assert len(result.request_outputs) == 1
assert result.request_outputs[0].request_id == "r"
assert AUDIO in result.request_outputs[0].outputs[0].multimodal_output
def _make_mm_only_output_processor(monkeypatch):
processor = object.__new__(MultimodalOutputProcessor)
processor.output_modality = OutputModality.AUDIO
processor.request_states = {"r": _make_no_detok_state(RequestOutputKind.DELTA)}
monkeypatch.setattr(
VLLMOutputProcessor,
"process_outputs",
lambda *_args, **_kwargs: SimpleNamespace(
request_outputs=[],
reqs_to_abort=[],
),
)
def finish_request(req_state):
processor.request_states.pop(req_state.request_id, None)
processor._finish_request = finish_request
return processor
def _audio_engine_output(*, is_segment_finished: bool, is_last_chunk: bool):
return SimpleNamespace(
request_id="r",
multimodal_output={
"model_outputs": torch.arange(8, dtype=torch.float32),
"meta.tts_is_last_chunk": torch.tensor([int(is_last_chunk)]),
},
output_type="audio",
pooling_output=None,
new_token_ids=[],
finish_reason=FinishReason.STOP,
stop_reason=None,
kv_transfer_params=None,
routed_experts=None,
num_cached_tokens=0,
is_segment_finished=is_segment_finished,
)
def test_mm_only_segment_finish_retains_request_state_for_next_audio_chunk(monkeypatch):
processor = _make_mm_only_output_processor(monkeypatch)
first = processor.process_outputs([_audio_engine_output(is_segment_finished=True, is_last_chunk=False)])
assert "r" in processor.request_states
assert len(first.request_outputs) == 1
assert first.request_outputs[0].finished is False
second = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)])
assert len(second.request_outputs) == 1
assert second.request_outputs[0].finished is True
assert "r" not in processor.request_states
def test_mm_only_nonfinal_audio_retains_state_without_segment_marker(monkeypatch):
processor = _make_mm_only_output_processor(monkeypatch)
first = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=False)])
assert "r" in processor.request_states
assert len(first.request_outputs) == 1
assert first.request_outputs[0].finished is False
second = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)])
assert len(second.request_outputs) == 1
assert second.request_outputs[0].finished is True
assert "r" not in processor.request_states
def test_mm_only_terminal_finish_removes_request_state(monkeypatch):
processor = _make_mm_only_output_processor(monkeypatch)
result = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)])
assert len(result.request_outputs) == 1
assert result.request_outputs[0].finished is True
assert "r" not in processor.request_states
def test_no_detokenizer_make_request_output_with_routed_experts(): def test_no_detokenizer_make_request_output_with_routed_experts():
"""make_request_output accepts the routed_experts arg that the multimodal """make_request_output accepts the routed_experts arg that the multimodal
output channel (_process_mm_only_outputs) passes positionally, and attaches output channel (_process_mm_only_outputs) passes positionally, and attaches
+49
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import socket
from contextlib import contextmanager from contextlib import contextmanager
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
@@ -11,6 +12,7 @@ import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from vllm.v1.engine.utils import EngineZmqAddresses from vllm.v1.engine.utils import EngineZmqAddresses
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClientBase from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClientBase
from vllm_omni.engine.stage_engine_startup import ( from vllm_omni.engine.stage_engine_startup import (
@@ -189,6 +191,34 @@ class TestOmniMasterServerAllocation:
output_port = int(alloc.output_bind_address.split(":")[-1]) output_port = int(alloc.output_bind_address.split(":")[-1])
assert len({handshake_port, input_port, output_port}) == 3 assert len({handshake_port, input_port, output_port}) == 3
def test_route_ports_remain_os_reserved_until_master_releases_them(self):
server = OmniMasterServer(master_address="127.0.0.1", master_port=15005, stage_ids=[0])
alloc = server.get_allocation(0)
ports = [
int(alloc.handshake_bind_address.rsplit(":", 1)[-1]),
int(alloc.input_bind_address.rsplit(":", 1)[-1]),
int(alloc.output_bind_address.rsplit(":", 1)[-1]),
]
for port in ports:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
with pytest.raises(OSError):
probe.bind(("127.0.0.1", port))
server.release_route_port_reservations(0, handshake=True, data=False)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", ports[0]))
for port in ports[1:]:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
with pytest.raises(OSError):
probe.bind(("127.0.0.1", port))
server.stop()
for port in ports[1:]:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", port))
def test_get_zmq_addresses_returns_bind_addresses(self): def test_get_zmq_addresses_returns_bind_addresses(self):
server = OmniMasterServer(master_address="127.0.0.1", master_port=15002, stage_ids=[0]) server = OmniMasterServer(master_address="127.0.0.1", master_port=15002, stage_ids=[0])
alloc = server.get_allocation(0) alloc = server.get_allocation(0)
@@ -397,6 +427,25 @@ class TestSingleStageModeDetection:
assert engine.single_stage_mode is False assert engine.single_stage_mode is False
assert engine._single_stage_id_filter is None assert engine._single_stage_id_filter is None
def test_stage_configs_path_loads_duplex_runtime_config(self, mocker: MockerFixture):
duplex_session = DuplexSessionRuntimeConfig(max_sessions=2)
mocker.patch(
"vllm_omni.engine.async_omni_engine.StageConfigFactory.get_pipeline_config",
return_value=None,
)
load_deploy_config = mocker.patch(
"vllm_omni.engine.async_omni_engine.load_deploy_config",
return_value=SimpleNamespace(duplex_session=duplex_session),
)
engine = self._make_engine_no_thread(
mocker,
stage_configs_path="/fake/duplex.yaml",
)
load_deploy_config.assert_called_once_with("/fake/duplex.yaml")
assert engine.duplex_session_config is duplex_session
def test_single_stage_mode_without_stage_id_has_no_filter(self, mocker: MockerFixture): def test_single_stage_mode_without_stage_id_has_no_filter(self, mocker: MockerFixture):
engine = self._make_engine_no_thread( engine = self._make_engine_no_thread(
mocker, mocker,
@@ -51,6 +51,19 @@ from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_native_input_append_supports_explicit_session_opt_out():
session = DuplexSession(
"sid-explicit-tts",
DuplexSessionConfig(model="test-model"),
capabilities=DuplexCapabilities.minicpmo45_native(),
)
assert OmniDuplexSessionHandler._uses_native_input_append(session) is True
session.config.extra_body["minicpmo45_native_duplex"] = False
assert OmniDuplexSessionHandler._uses_native_input_append(session) is False
class _ModelConfig: class _ModelConfig:
model = "test-model" model = "test-model"
@@ -2520,6 +2533,52 @@ def test_duplex_auto_response_tts_scheduler_eos_fallback():
assert results[0]["abort_data_plane_request"] is True assert results[0]["abort_data_plane_request"] is True
def test_duplex_auto_response_rearms_tts_boundary_for_next_segment_in_same_turn():
data_plane = _test_data_plane()
request_id = "duplex-sid-next-segment-same-turn-e0-stage0"
session = DuplexSession(
session_id="sid-next-segment-same-turn",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.bind_response_turn(0)
data_plane.begin_request(request_id)
first_segment = _duplex_tts_output(
request_id=request_id,
samples=24000,
finished=False,
tts_is_last_chunk=True,
turn_id=0,
)
first_results = _project_data_plane(
data_plane,
{"data_plane_outputs": [first_segment]},
session=session,
)
assert len(first_results) == 1
assert first_results[0]["abort_data_plane_request"] is True
data_plane.begin_request(request_id)
second_segment = _duplex_tts_output(
request_id=request_id,
samples=48000,
finished=False,
text="hello again",
tts_is_last_chunk=True,
turn_id=0,
)
second_results = _project_data_plane(
data_plane,
{"data_plane_outputs": [second_segment]},
session=session,
)
assert len(second_results) == 1
assert second_results[0]["audio_data"] == "wav-24000"
assert second_results[0]["abort_data_plane_request"] is True
def test_duplex_turn_end_is_not_swallowed_by_finished_segment_fallback(): def test_duplex_turn_end_is_not_swallowed_by_finished_segment_fallback():
data_plane = _test_data_plane() data_plane = _test_data_plane()
request_id = "duplex-sid-turn-end-after-segment-e0-stage0" request_id = "duplex-sid-turn-end-after-segment-e0-stage0"
@@ -6202,19 +6261,27 @@ async def test_minicpmo_native_auto_response_keeps_request_bound_for_segment_con
config_timeout_s=0.1, config_timeout_s=0.1,
idle_timeout_s=1, idle_timeout_s=1,
) )
ws = TimedWebSocket()
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") != "session.created":
return
session = handler._registry.get("sid-native-auto-continuation")
assert session is not None
session.capabilities.chunk_period_ms = 50
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(800),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True})
ws = TimedWebSocket(on_send=on_send)
event = _native_session_create("sid-native-auto-continuation") event = _native_session_create("sid-native-auto-continuation")
event["session"]["extra_body"]["auto_response"] = True event["session"]["extra_body"]["auto_response"] = True
ws.put(event) ws.put(event)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True})
await handler.handle_session(ws) await handler.handle_session(ws)
@@ -6225,6 +6292,112 @@ async def test_minicpmo_native_auto_response_keeps_request_bound_for_segment_con
assert final is False assert final is False
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_real_input_preempts_unsent_silence():
request_id = "duplex-sid-native-real-before-silence-e0-stage0"
class SequencedAppendEngine(FakeEngineClient):
def __init__(self) -> None:
control_result = {
"operation": "append",
"session_id": "sid-native-real-before-silence",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
}
}
],
}
terminal_segment = _duplex_tts_output(
request_id=request_id,
samples=0,
finished=True,
tts_is_last_chunk=True,
token_ids=[151645],
)
super().__init__(
control_result=control_result,
collect_outputs=[[terminal_segment], [], []],
collect_delay_s=0.01,
)
self.append_sequence: list[str] = []
self.first_real_started = asyncio.Event()
self.second_real_started = asyncio.Event()
self.real_count = 0
async def append_duplex_input_async(self, session_id: str, **kwargs):
kwargs.pop("expected_epoch", None)
result = await super().append_duplex_input_async(session_id, **kwargs)
payload = kwargs.get("payload")
is_silence = (
isinstance(payload, dict)
and payload.get("audio") == OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO
)
if is_silence:
self.append_sequence.append("silence")
else:
self.real_count += 1
self.append_sequence.append(f"real-{self.real_count}")
if self.real_count == 1:
self.first_real_started.set()
elif self.real_count == 2:
self.second_real_started.set()
return result
session_created = asyncio.Event()
engine = SequencedAppendEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") != "session.created":
return
session = handler._registry.get("sid-native-real-before-silence")
assert session is not None
session.capabilities.chunk_period_ms = 50
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(800),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
session_created.set()
ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0)
event = _native_session_create("sid-native-real-before-silence")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
handler_task = asyncio.create_task(handler.handle_session(ws))
await asyncio.wait_for(session_created.wait(), timeout=1)
await asyncio.wait_for(engine.first_real_started.wait(), timeout=1)
await asyncio.sleep(0.02)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(800, value=0.07),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
await asyncio.wait_for(engine.second_real_started.wait(), timeout=1)
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert engine.append_sequence[:2] == ["real-1", "real-2"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_minicpmo_native_auto_response_real_input_waits_for_submitted_silence_tail(): async def test_minicpmo_native_auto_response_real_input_waits_for_submitted_silence_tail():
request_id = "duplex-sid-native-silence-tail-e0-stage0" request_id = "duplex-sid-native-silence-tail-e0-stage0"
@@ -6294,18 +6467,26 @@ async def test_minicpmo_native_auto_response_real_input_waits_for_submitted_sile
config_timeout_s=0.1, config_timeout_s=0.1,
idle_timeout_s=1, idle_timeout_s=1,
) )
ws = TimedWebSocket(receive_timeout_s=2.0)
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") != "session.created":
return
session = handler._registry.get("sid-native-silence-tail")
assert session is not None
session.capabilities.chunk_period_ms = 50
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(800, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0)
event = _native_session_create("sid-native-silence-tail") event = _native_session_create("sid-native-silence-tail")
event["session"]["extra_body"]["auto_response"] = True event["session"]["extra_body"]["auto_response"] = True
ws.put(event) ws.put(event)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
handler_task = asyncio.create_task(handler.handle_session(ws)) handler_task = asyncio.create_task(handler.handle_session(ws))
await asyncio.wait_for(engine.silence_append_started.wait(), timeout=1) await asyncio.wait_for(engine.silence_append_started.wait(), timeout=1)
@@ -6330,6 +6511,135 @@ async def test_minicpmo_native_auto_response_real_input_waits_for_submitted_sile
assert engine.append_sequence == ["real-1", "silence-started", "silence-returned", "real-2"] assert engine.append_sequence == ["real-1", "silence-started", "silence-returned", "real-2"]
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_preserves_silence_continuations_across_append_tails():
request_id = "duplex-sid-native-continuation-tails-e0-stage0"
class SequencedAppendEngine(FakeEngineClient):
def __init__(self) -> None:
control_result = {
"operation": "append",
"session_id": "sid-native-continuation-tails",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
}
}
],
}
super().__init__(control_result=control_result)
self.real_append_started = asyncio.Event()
self.release_real_append = asyncio.Event()
self.first_silence_started = asyncio.Event()
self.release_first_silence = asyncio.Event()
self.second_silence_started = asyncio.Event()
self.release_collect = asyncio.Event()
self.silence_count = 0
async def append_duplex_input_async(self, session_id: str, **kwargs):
kwargs.pop("expected_epoch", None)
payload = kwargs.get("payload")
assert isinstance(payload, dict)
result = await super().append_duplex_input_async(session_id, **kwargs)
if payload.get("audio") != OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO:
self.real_append_started.set()
await self.release_real_append.wait()
return result
self.silence_count += 1
if self.silence_count == 1:
self.first_silence_started.set()
await self.release_first_silence.wait()
else:
self.second_silence_started.set()
return result
async def collect_duplex_data_plane_outputs_async(
self,
request_id: str,
*,
response_stage_id: int | None = None,
timeout: float | None = None,
) -> list[object]:
del timeout
self.collected.append((request_id, response_stage_id))
await self.release_collect.wait()
return []
session_created = asyncio.Event()
def on_send(_ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") == "session.created":
session_created.set()
engine = SequencedAppendEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0)
event = _native_session_create("sid-native-continuation-tails")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
handler_task = asyncio.create_task(handler.handle_session(ws))
await asyncio.wait_for(session_created.wait(), timeout=1)
await asyncio.wait_for(engine.real_append_started.wait(), timeout=1)
session = handler._registry.get("sid-native-continuation-tails")
assert session is not None
session.capabilities.chunk_period_ms = 50
session.bind_request(request_id)
response_id = session.begin_response(turn_id=session.turn_id)
native = handler._minicpmo_session_state(session)
scheduler = native.silence_continuation_scheduler
assert scheduler is not None
payload = handler._native_silence_unit_payload()
payload["duplex_turn_id"] = session.turn_id
kwargs = {
"request_id": request_id,
"owner_id": f"response:{response_id}",
"response_id": response_id,
"response_owned": True,
"expected_epoch": session.epoch,
"expected_incarnation": session.incarnation,
"expected_model_turn_id": None,
"send_json": ws.send_json,
}
assert await scheduler(payload, **kwargs) is True
next_schedule = asyncio.create_task(scheduler(payload, **kwargs))
await asyncio.sleep(0.05)
assert not next_schedule.done()
engine.release_real_append.set()
await asyncio.wait_for(engine.first_silence_started.wait(), timeout=1)
assert not next_schedule.done()
engine.release_first_silence.set()
assert await asyncio.wait_for(next_schedule, timeout=1) is True
await asyncio.wait_for(engine.second_silence_started.wait(), timeout=1)
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert engine.silence_count == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_minicpmo_native_duplex_uses_segment_text_metadata_for_transcript_cursor(): async def test_minicpmo_native_duplex_uses_segment_text_metadata_for_transcript_cursor():
request_id = "duplex-sid-native-repeat-text-e0-stage0" request_id = "duplex-sid-native-repeat-text-e0-stage0"
+6 -1
View File
@@ -222,6 +222,7 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker
from vllm_omni.engine.stage_engine_startup import StageRegistrationResponse from vllm_omni.engine.stage_engine_startup import StageRegistrationResponse
stage_cfg = _make_stage_cfg(0, stage_type="llm") stage_cfg = _make_stage_cfg(0, stage_type="llm")
stage_cfg.engine_args["async_chunk"] = True
parallel_config = SimpleNamespace( parallel_config = SimpleNamespace(
data_parallel_size_local=1, data_parallel_size_local=1,
data_parallel_rank=0, data_parallel_rank=0,
@@ -241,7 +242,10 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker
"vllm_omni.distributed.omni_connectors.utils.initialization.resolve_omni_kv_config_for_stage", "vllm_omni.distributed.omni_connectors.utils.initialization.resolve_omni_kv_config_for_stage",
return_value=(None, None, None), return_value=(None, None, None),
) )
mocker.patch("vllm_omni.engine.stage_init_utils.get_stage_connector_spec", return_value={}) mock_connector_spec = mocker.patch(
"vllm_omni.engine.stage_init_utils.get_stage_connector_spec",
return_value={},
)
mocker.patch("vllm_omni.engine.stage_init_utils.build_engine_args_dict", return_value={}) mocker.patch("vllm_omni.engine.stage_init_utils.build_engine_args_dict", return_value={})
mocker.patch( mocker.patch(
"vllm_omni.engine.stage_init_utils.build_vllm_config", "vllm_omni.engine.stage_init_utils.build_vllm_config",
@@ -277,6 +281,7 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker
assert kwargs["omni_stage_config"] is stage_cfg assert kwargs["omni_stage_config"] is stage_cfg
assert kwargs["replica_id"] is None assert kwargs["replica_id"] is None
assert "socket_ownership" not in kwargs assert "socket_ownership" not in kwargs
assert mock_connector_spec.call_args.kwargs["async_chunk"] is True
assert mock_manager_cls.call_count == 1 assert mock_manager_cls.call_count == 1
mgr_kwargs = mock_manager_cls.call_args.kwargs mgr_kwargs = mock_manager_cls.call_args.kwargs
+58
View File
@@ -3,6 +3,8 @@
import os import os
from collections import Counter from collections import Counter
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
import pytest import pytest
import torch import torch
@@ -13,6 +15,10 @@ from vllm_omni.config.yaml_util import create_config
from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.engine.arg_utils import OmniEngineArgs from vllm_omni.engine.arg_utils import OmniEngineArgs
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.engine.stage_init_utils import (
get_stage_connector_spec,
load_omni_transfer_config_for_model,
)
from vllm_omni.entrypoints.utils import ( from vllm_omni.entrypoints.utils import (
_convert_dataclasses_to_dict, _convert_dataclasses_to_dict,
_filter_dict_like_object, _filter_dict_like_object,
@@ -313,6 +319,58 @@ class TestResolveModelConfigPath:
assert result is not None assert result is not None
assert "glm_image.yaml" in result assert "glm_image.yaml" in result
def test_uses_registry_default_deploy_when_hf_model_type_differs(
self,
mocker: MockerFixture,
tmp_path,
):
from vllm_omni.config.stage_config import _DEPLOY_DIR as real_deploy_dir
from vllm_omni.entrypoints import utils as utils_mod
hf_config = SimpleNamespace(
model_type="minicpmo",
architectures=["MiniCPMO"],
version="4.5",
)
model = "local/minicpmo-registry-default-test"
mocker.patch(
"vllm_omni.entrypoints.utils.get_config",
return_value=hf_config,
)
mocker.patch(
"vllm_omni.config.config_factory.get_config",
return_value=hf_config,
)
# A bare deploy/<hf_model_type>.yaml must not win over the registered
# pipeline default when HF model_type is not an OMNI_PIPELINES key.
(tmp_path / "minicpmo.yaml").write_text("stages: []\n", encoding="utf-8")
(tmp_path / "minicpmo_4_5.yaml").write_bytes((real_deploy_dir / "minicpmo_4_5.yaml").read_bytes())
mocker.patch.object(utils_mod, "_DEPLOY_DIR", tmp_path)
result = resolve_model_config_path(model)
assert result is not None
assert Path(result).as_posix().endswith("minicpmo_4_5.yaml")
assert Path(result).name == "minicpmo_4_5.yaml"
transfer_config = load_omni_transfer_config_for_model(model, result)
assert transfer_config is not None
sender = get_stage_connector_spec(
omni_transfer_config=transfer_config,
stage_id=1,
async_chunk=True,
)
receiver = get_stage_connector_spec(
omni_transfer_config=transfer_config,
stage_id=2,
async_chunk=True,
)
assert sender["extra"]["role"] == "sender"
assert receiver["extra"]["role"] == "receiver"
assert sender["extra"]["connector_get_sleep_s"] == 0.01
assert sender["extra"]["connector_get_max_wait_first_chunk"] == 3000
assert sender["extra"]["connector_get_max_wait"] == 300
class TestLoadAndResolveStageConfigs: class TestLoadAndResolveStageConfigs:
def test_load_and_resolve_with_kwargs(self, mocker: MockerFixture): def test_load_and_resolve_with_kwargs(self, mocker: MockerFixture):
@@ -1,29 +0,0 @@
import base64
import io
import runpy
import wave
from pathlib import Path
import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_append_wav_chunk_extracts_pcm_frames() -> None:
example = Path(__file__).parents[3] / "examples" / "online_serving" / "minicpmo" / "streaming_chat_completion.py"
append_wav_chunk = runpy.run_path(example)["append_wav_chunk"]
encoded_buffer = io.BytesIO()
with wave.open(encoded_buffer, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(24000)
wav.writeframes(b"\x01\x02\x03\x04")
pcm_parts: list[bytes] = []
audio_format = append_wav_chunk(
base64.b64encode(encoded_buffer.getvalue()).decode(),
pcm_parts,
)
assert audio_format == (1, 2, 24000)
assert pcm_parts == [b"\x01\x02\x03\x04"]
@@ -207,3 +207,68 @@ def test_playback_worklet_waits_before_playing_and_rebuffers_after_underrun():
capture_output=True, capture_output=True,
text=True, text=True,
) )
def test_playback_worklet_fades_terminal_drain_to_zero():
node = shutil.which("node")
if node is None:
pytest.skip("node is required for the AudioWorklet regression test")
script = textwrap.dedent(
"""
const fs = require('fs');
const vm = require('vm');
global.sampleRate = 1000;
const messages = [];
global.AudioWorkletProcessor = class {
constructor() {
this.port = {
onmessage: null,
postMessage: (message) => messages.push(message),
};
}
};
let Processor = null;
global.registerProcessor = (_name, processor) => { Processor = processor; };
vm.runInThisContext(fs.readFileSync(process.argv[1], 'utf8'));
const processor = new Processor();
const pcm = new Int16Array(100);
pcm.fill(16384);
processor.handleMessage({
type: 'audio',
pcm,
responseId: 'response-terminal',
initialBufferMs: 400,
});
processor.handleMessage({
type: 'drain',
responseId: 'response-terminal',
});
const output = new Float32Array(100);
processor.process([], [[output]]);
const assert = (condition, message) => {
if (!condition) throw new Error(message);
};
assert(output.some((sample) => sample !== 0), 'terminal audio must still play');
assert(output[output.length - 1] === 0, 'terminal drain must fade the final sample to zero');
const tail = Array.from(output.slice(-5), Math.abs);
assert(
tail.every((sample, index) => index === 0 || sample <= tail[index - 1]),
'terminal drain fade must decrease monotonically',
);
assert(
messages.filter((message) => message.type === 'playback-drained').length === 1,
'terminal drain must be reported exactly once after playback',
);
"""
)
subprocess.run(
[node, "-e", script, str(STATIC_ROOT / "playback_worklet.js")],
check=True,
capture_output=True,
text=True,
)
+48
View File
@@ -43,6 +43,54 @@ def test_append_emits_one_model_unit_when_multiple_units_are_buffered():
assert buffer.pending_byte_count == 16_000 * 4 assert buffer.pending_byte_count == 16_000 * 4
def test_speech_marker_does_not_leak_across_irregular_chunk_boundaries():
buffer = MiniCPMO45PcmAppendBuffer()
assert buffer.append(pcm_payload(15_000), chunk_period_ms=1_000) is None
speech_unit = buffer.append(pcm_payload(2_000, speech=False), chunk_period_ms=1_000)
silence_unit = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000)
assert speech_unit is not None
assert speech_unit["is_speech"] is True
assert silence_unit is not None
assert silence_unit["is_speech"] is False
def test_force_listen_marker_does_not_leak_across_irregular_chunk_boundaries():
buffer = MiniCPMO45PcmAppendBuffer()
forced = pcm_payload(15_000, speech=False)
forced["force_listen"] = True
assert buffer.append(forced, chunk_period_ms=1_000) is None
forced_unit = buffer.append(pcm_payload(2_000, speech=False), chunk_period_ms=1_000)
unforced_unit = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000)
assert forced_unit is not None
assert forced_unit["force_listen"] is True
assert unforced_unit is not None
assert unforced_unit["force_listen"] is False
def test_pcm_append_rollback_restores_per_span_speech_markers():
buffer = MiniCPMO45PcmAppendBuffer()
assert buffer.append(pcm_payload(15_000), chunk_period_ms=1_000) is None
reservation = buffer.prepare_append(
pcm_payload(2_000, speech=False),
operation_id="mixed-unit",
chunk_period_ms=1_000,
)
assert reservation is not None
reservation.rollback()
first = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000)
second = buffer.flush(chunk_period_ms=1_000)
assert first is not None
assert first["is_speech"] is True
assert second is not None
assert second["is_speech"] is False
def test_commit_without_speech_does_not_synthesize_terminal_audio(): def test_commit_without_speech_does_not_synthesize_terminal_audio():
buffer = MiniCPMO45PcmAppendBuffer() buffer = MiniCPMO45PcmAppendBuffer()
buffer.append(pcm_payload(8_000, speech=False), chunk_period_ms=1_000) buffer.append(pcm_payload(8_000, speech=False), chunk_period_ms=1_000)
+102
View File
@@ -11,6 +11,9 @@ from vllm_omni.experimental.fullduplex.client import (
read_pcm16_wav, read_pcm16_wav,
write_pcm16_wav, write_pcm16_wav,
) )
from vllm_omni.experimental.fullduplex.minicpmo45.policy import (
MiniCPMO45DuplexPolicy,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -32,6 +35,19 @@ def test_realtime_client_builds_explicit_native_duplex_url():
} }
def test_seed_tts_initial_text_is_part_of_native_duplex_context():
prefix, suffix = MiniCPMO45DuplexPolicy.session_context_texts(
"Speak exactly.",
True,
"The quick brown fox.",
)
assert prefix == "<|im_start|>system\nSpeak exactly.\n<|audio_start|>"
assert suffix == (
"<|audio_end|><|im_end|>\n<|im_start|>user\nThe quick brown fox.<|im_end|>\n<|im_start|>assistant\n"
)
def test_realtime_client_builds_resume_only_url_when_autostart_disabled(): def test_realtime_client_builds_resume_only_url_when_autostart_disabled():
url = build_realtime_url( url = build_realtime_url(
"ws://localhost:8099/v1/realtime?duplex=1", "ws://localhost:8099/v1/realtime?duplex=1",
@@ -86,6 +102,59 @@ async def test_realtime_client_configure_sends_explicit_ref_audio():
assert session["ref_audio"] == "data:audio/wav;base64,AAAA" assert session["ref_audio"] == "data:audio/wav;base64,AAAA"
@pytest.mark.asyncio
async def test_realtime_client_configure_sends_seed_tts_text_condition():
class Client(RealtimeDuplexClient):
def __init__(self):
super().__init__("ws://unused")
self.sent = []
async def send(self, event):
self.sent.append(event)
self.events.add({"type": "session.created"})
client = Client()
await client.configure(
"openbmb/MiniCPM-o-4_5",
instructions="Speak the requested text exactly.",
initial_user_text="The quick brown fox.",
timeout_s=1,
)
session = client.sent[0]["session"]
assert session["instructions"] == "Speak the requested text exactly."
assert session["extra_body"]["duplex_initial_user_text"] == "The quick brown fox."
@pytest.mark.asyncio
async def test_realtime_client_configure_explicit_tts_opts_out_of_native_duplex():
class Client(RealtimeDuplexClient):
def __init__(self):
super().__init__("ws://unused")
self.sent = []
async def send(self, event):
self.sent.append(event)
self.events.add({"type": "session.created"})
client = Client()
await client.configure(
"openbmb/MiniCPM-o-4_5",
native_duplex=False,
auto_response=False,
extra_body={"ref_audio": "data:audio/wav;base64,AAAA"},
timeout_s=1,
)
session_extra_body = client.sent[0]["session"]["extra_body"]
assert session_extra_body == {
"ref_audio": "data:audio/wav;base64,AAAA",
"minicpmo45_native_duplex": False,
}
def test_realtime_event_collector_partitions_audio_by_response(): def test_realtime_event_collector_partitions_audio_by_response():
collector = RealtimeEventCollector() collector = RealtimeEventCollector()
collector.add({"type": "response.created", "response": {"id": "resp-a"}}) collector.add({"type": "response.created", "response": {"id": "resp-a"}})
@@ -134,6 +203,26 @@ def test_realtime_event_collector_reports_engine_token_and_audio_intervals():
}, },
received_at_s=received_at_s, received_at_s=received_at_s,
) )
collector.add(
{
"type": "response.audio_transcript.delta",
"response_id": "resp-a",
"delta": "",
},
received_at_s=10.1,
)
collector.add(
{
"type": "response.audio_transcript.delta",
"response_id": "resp-a",
"delta": "hello",
},
received_at_s=10.15,
)
collector.add(
{"type": "response.done", "response": {"id": "resp-a"}},
received_at_s=10.4,
)
timing = collector.timing_summary( timing = collector.timing_summary(
after_s=10.0, after_s=10.0,
@@ -175,6 +264,19 @@ def test_realtime_event_collector_reports_engine_token_and_audio_intervals():
}, },
"max_chunk_gap_ms": 110.0, "max_chunk_gap_ms": 110.0,
} }
assert timing["request_metrics"] == {
"source": "client_monotonic_receive",
"measurement_origin": {
"ttft": "input_audio_buffer.commit client send to first non-empty text delta",
"ttfp": "input_audio_buffer.commit client send to first audio packet",
"rtf": "commit-to-last-audio receive time divided by emitted audio duration",
},
"ttft_ms": 250.0,
"ttfp_ms": 300.0,
"rtf": pytest.approx(1.916667),
"audio_generation_ms": 460.0,
"audio_duration_ms": 240.0,
}
def test_response_timing_ignores_unowned_session_level_metrics(): def test_response_timing_ignores_unowned_session_level_metrics():
+60 -1
View File
@@ -2,10 +2,69 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Pytest marks and decorators for hardware / resource selection (CUDA, ROCm, …).""" """Pytest marks and decorators for hardware / resource selection (CUDA, ROCm, …)."""
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
import pytest import pytest
from vllm.platforms import current_platform from vllm.platforms import current_platform
# Re-exported from tests.helpers.env (GPU wait + DeviceMemoryMonitor). # Marker description tag in ``pyproject.toml`` ``tool.pytest.ini_options.markers``.
# Example: ``"H100: [hardware-resource] Tests that require H100 GPU"``.
_HARDWARE_RESOURCE_MARKER_TAG = "[hardware-resource]"
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _load_pyproject() -> dict:
path = _repo_root() / "pyproject.toml"
try:
try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
import tomli as tomllib # type: ignore[no-redef]
with path.open("rb") as f:
return tomllib.load(f)
except Exception:
return {}
@lru_cache(maxsize=1)
def get_hardware_mark_list() -> frozenset[str]:
"""Return hardware SKU marker names from ``pyproject.toml``.
A marker is treated as a hardware resource marker when its description
contains the ``[hardware-resource]`` tag, e.g.::
"H100: [hardware-resource] Tests that require H100 GPU"
"""
data = _load_pyproject()
entries = data.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("markers", [])
if not entries:
# Fallback when TOML parsing fails: scan tagged marker lines.
text = (_repo_root() / "pyproject.toml").read_text(encoding="utf-8")
return frozenset(
re.findall(
rf'^\s*"([A-Za-z0-9_]+)\s*:\s*{re.escape(_HARDWARE_RESOURCE_MARKER_TAG)}',
text,
flags=re.M,
)
)
names: set[str] = set()
for entry in entries:
text = str(entry)
if _HARDWARE_RESOURCE_MARKER_TAG not in text:
continue
name = text.split(":", 1)[0].strip()
if name:
names.add(name)
return frozenset(names)
def cuda_marks(*, res: str, num_cards: int): def cuda_marks(*, res: str, num_cards: int):
+161
View File
@@ -0,0 +1,161 @@
"""Shared fixtures and argument builders for MiniCPM-o 4.5 duplex E2E tests."""
from __future__ import annotations
import hashlib
import uuid
import wave
from pathlib import Path
from types import SimpleNamespace
import pytest
from huggingface_hub import snapshot_download
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
MODEL = "openbmb/MiniCPM-o-4_5"
DEPLOY_CONFIG = modify_stage_config(
get_deploy_config_path("minicpmo_4_5_duplex.yaml"),
updates={
"base_config": get_deploy_config_path("minicpmo_4_5.yaml"),
# Talker context is 4096 (tts_config.max_position_embeddings); KV sizing
# is left automatic so duplex matches the simplex deploy profiles.
"stages": {
1: {"max_model_len": 4096},
},
},
)
ASSET_DIR = Path(__file__).resolve().parents[1] / "assets" / "minicpmo_4_5"
RESPONSE_REQUIRED_WAV = ASSET_DIR / "response_required_16k.wav"
RESPONSE_REQUIRED_SHA256 = "2e5fd4eb3ee434ce107ee3a0591fa624a33f7683c7462f45fe651c443c9af941"
SOFT_INTERRUPT_WAV = ASSET_DIR / "soft_interrupt_16k.wav"
SOFT_INTERRUPT_SHA256 = "cadae6d0ddc510310f16f8775d6379f30a5195369ccb54ff10a8e3f9a1f4a2ea"
REF_AUDIO_RELATIVE_PATH = Path("assets") / "HT_ref_audio.wav"
SERVER_PARAMS = [
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=DEPLOY_CONFIG,
use_stage_cli=False,
server_args=["--trust-remote-code"],
),
id="three-stage-single-gpu",
)
]
def validated_wav(path: Path, expected_sha256: str) -> Path:
actual_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if actual_sha256 != expected_sha256:
raise AssertionError(
f"MiniCPM-o duplex fixture SHA256 mismatch: expected {expected_sha256}, got {actual_sha256}"
)
with wave.open(str(path), "rb") as wav_file:
actual_format = (
wav_file.getframerate(),
wav_file.getnchannels(),
wav_file.getsampwidth(),
wav_file.getcomptype(),
)
expected_format = (16_000, 1, 2, "NONE")
if actual_format != expected_format:
raise AssertionError(f"MiniCPM-o duplex fixture must be 16 kHz mono PCM16, got {actual_format}")
return path
def validated_input_wav() -> Path:
return validated_wav(RESPONSE_REQUIRED_WAV, RESPONSE_REQUIRED_SHA256)
def validated_soft_interrupt_wav() -> Path:
return validated_wav(SOFT_INTERRUPT_WAV, SOFT_INTERRUPT_SHA256)
def resolve_ref_audio(model_prefix: str) -> Path:
# ``MODEL`` may be a local checkpoint directory instead of a Hub repo id
# (``Path("/prefix") / "/abs/path"`` collapses to the absolute path), so only
# fall back to the Hub cache when no such directory exists on disk.
model_root = Path(model_prefix) / MODEL if model_prefix else Path(MODEL)
if not model_root.is_dir():
model_root = Path(snapshot_download(MODEL, local_files_only=True))
ref_audio = model_root / REF_AUDIO_RELATIVE_PATH
if not ref_audio.is_file():
raise FileNotFoundError(f"MiniCPM-o checkpoint ref audio is missing: {ref_audio}")
return ref_audio
def realtime_url(omni_server) -> str:
return f"ws://{omni_server.host}:{omni_server.port}/v1/realtime?duplex=1"
def demo_args(
*,
omni_server,
input_wav: Path,
ref_audio: Path,
output_dir: Path,
) -> SimpleNamespace:
return SimpleNamespace(
url=realtime_url(omni_server),
model=omni_server.model,
session_id=f"duplex-ci-single-{uuid.uuid4().hex}",
input_wav=str(input_wav),
ref_audio=str(ref_audio),
turn_input_wav=[],
output_dir=str(output_dir),
output_audio_format="pcm16",
chunk_ms=200,
realtime_input=True,
first_turn_ms=1400,
turn_duration_ms=[],
first_turn_transcript="duplex CI speech",
omit_transcript_hints=False,
validation_mode="response-required",
temperature=0.0,
scenario="sequential",
require_audio=True,
require_distinct_inputs=False,
expect_empty_turn=[],
short_ack_ms=350,
turns=1,
timeout_s=120.0,
model_policy_settle_ms=2000,
)
def multi_session_args(
*,
omni_server,
input_wav: Path,
ref_audio: Path,
output_dir: Path,
response_required: bool,
) -> SimpleNamespace:
return SimpleNamespace(
url=realtime_url(omni_server),
model=omni_server.model,
sessions=2,
input_wav=str(input_wav),
ref_audio=str(ref_audio),
session_input_wav=[],
session_expected_token=[],
turn_input_wav=[],
output_dir=str(output_dir),
realtime_input=True,
chunk_ms=200,
turns=1,
first_turn_ms=1400,
turn_duration_ms=[],
response_required=response_required,
temperature=0.0 if response_required else None,
disconnect_session_index=0,
takeover_session_index=1,
resume_after_ms=100,
expire_session_index=None,
expire_after_s=40.0,
verify_admission_limit=None,
model_policy_settle_ms=2000,
timeout_s=180.0,
)
+6 -1
View File
@@ -2881,7 +2881,12 @@ def iter_omni_server(
print("OmniServer stopping...") print("OmniServer stopping...")
else: else:
if stage_config_path is not None: if stage_config_path is not None:
server_args += ["--stage-configs-path", stage_config_path] from vllm_omni.entrypoints.utils import is_new_format_deploy_config
if is_new_format_deploy_config(stage_config_path):
server_args += ["--deploy-config", stage_config_path]
else:
server_args += ["--stage-configs-path", stage_config_path]
with ( with (
OmniServer( OmniServer(
@@ -19,10 +19,12 @@ class _FakeEncoder(nn.Module):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.calls: list[int] = [] self.calls: list[int] = []
self.last_chunk_calls: list[bool] = []
def forward_chunk(self, xs, last_chunk=False, cnn_cache=None, att_cache=None): def forward_chunk(self, xs, last_chunk=False, cnn_cache=None, att_cache=None):
batch, length, _ = xs.shape batch, length, _ = xs.shape
self.calls.append(batch) self.calls.append(batch)
self.last_chunk_calls.append(last_chunk)
old_length = 0 if att_cache is None else att_cache.shape[3] old_length = 0 if att_cache is None else att_cache.shape[3]
output = xs[:, : max(1, length - 1)] output = xs[:, : max(1, length - 1)]
cnn = xs[:, :1, :].transpose(1, 2).contiguous() cnn = xs[:, :1, :].transpose(1, 2).contiguous()
@@ -48,6 +50,8 @@ class _FakeEstimator(nn.Module):
self.blocks = [_FakeBlock()] self.blocks = [_FakeBlock()]
self.cfg_batches: list[int] = [] self.cfg_batches: list[int] = []
self.speaker_order: list[list[float]] = [] self.speaker_order: list[list[float]] = []
self.register_buffer("att_cache_buffer", torch.ones(1), persistent=False)
self.register_buffer("cnn_cache_buffer", torch.ones(1), persistent=False)
def t_embedder(self, time): def t_embedder(self, time):
return time[:, None] return time[:, None]
@@ -77,6 +81,8 @@ class _FakeDecoder(nn.Module):
self.estimator = _FakeEstimator() self.estimator = _FakeEstimator()
self.inference_cfg_rate = 0.7 self.inference_cfg_rate = 0.7
self.register_buffer("rand_noise", torch.zeros(1, 1, 100), persistent=False) self.register_buffer("rand_noise", torch.zeros(1, 1, 100), persistent=False)
self.register_buffer("att_cache_buffer", torch.ones(1), persistent=False)
self.register_buffer("cnn_cache_buffer", torch.ones(1), persistent=False)
class _FakeFlow(nn.Module): class _FakeFlow(nn.Module):
@@ -156,6 +162,53 @@ def _model():
return model, token2wav return model, token2wav
def test_adapter_releases_unused_upstream_streaming_buffers():
token2wav = _FakeToken2Wav()
decoder = token2wav.flow.decoder
modules = (decoder, decoder.estimator)
assert all(
getattr(module, name) is not None for module in modules for name in ("att_cache_buffer", "cnn_cache_buffer")
)
BatchedToken2Wav(token2wav)
assert all(
name in module._buffers and getattr(module, name) is None
for module in modules
for name in ("att_cache_buffer", "cnn_cache_buffer")
)
def test_code2wav_resolves_hf_model_id_for_assets(mocker, tmp_path):
resolved_root = tmp_path / "snapshot"
resolved_root.mkdir()
config = _config()
config.model_config.model = "openbmb/MiniCPM-o-4_5"
config.model_config.revision = "test-revision"
config.model_config.stage_connector_config["extra"].pop("prompt_wav")
config.load_config = SimpleNamespace(download_dir="/model-cache")
model = MiniCPMO45Code2Wav(vllm_config=config)
mock_download = mocker.patch(
"vllm_omni.model_executor.model_loader.weight_utils.download_weights_from_hf_specific",
return_value=str(resolved_root),
)
assert model._resolve_model_root() == resolved_root
assert model.model_path == str(resolved_root)
assert model._default_prompt_wav == str(resolved_root / "assets" / "HT_ref_audio.wav")
mock_download.assert_called_once_with(
"openbmb/MiniCPM-o-4_5",
"/model-cache",
allow_patterns=[
"assets/HT_ref_audio.wav",
"assets/token2wav/*",
],
revision="test-revision",
require_all=True,
)
def _info( def _info(
request_id: str, request_id: str,
chunk_seq: int, chunk_seq: int,
@@ -212,6 +265,18 @@ def test_adapter_runs_true_batch_cfg_and_splits_request_caches():
assert cache1[0, 0, 0, 0, 0].item() == 20 assert cache1[0, 0, 0, 0, 0].item() == 20
def test_fade_in_out_limits_overlap_to_available_previous_audio():
speech = torch.arange(6, dtype=torch.float32).reshape(1, -1)
previous = torch.full((1, 3), 2.0)
window = torch.hamming_window(8, periodic=False)
actual = BatchedToken2Wav._fade_in_out(speech, previous, window)
expected = speech.clone()
expected[..., :3] = speech[..., :3] * window[:3] + previous * window[-3:]
torch.testing.assert_close(actual, expected)
def test_estimator_cache_stack_split_round_trip_preserves_cfg_rows(): def test_estimator_cache_stack_split_round_trip_preserves_cfg_rows():
token2wav = _FakeToken2Wav() token2wav = _FakeToken2Wav()
adapter = BatchedToken2Wav(token2wav) adapter = BatchedToken2Wav(token2wav)
@@ -259,6 +324,166 @@ def test_model_preserves_output_slots_and_prefers_runtime_codes():
assert token2wav.flow.encoder.calls[-1] == 2 assert token2wav.flow.encoder.calls[-1] == 2
def test_code2wav_projects_duplex_metadata_to_final_audio_output():
model, token2wav = _model()
segment = _info("duplex", 0, [10, 11])
segment_text_utf8 = torch.tensor(list(b"hello"), dtype=torch.uint8)
segment["meta"].update(
{
"duplex_epoch": 3,
"duplex_turn_id": 7,
"llm_output_text_utf8": segment_text_utf8,
"tts_is_last_chunk": True,
"turn_end": False,
}
)
segment_output = _forward(model, [segment])
assert segment_output.multimodal_outputs["meta.turn_end"][0].item() is False
# A Talker unit boundary only drains pending codec tokens. The official
# streaming path keeps Token2wav open until the assistant turn ends.
assert token2wav.flow.encoder.last_chunk_calls[-1] is False
assert "duplex" in model._states
final = _info("duplex", 1, [12, 13], last_chunk=True)
final["meta"].update(segment["meta"])
final["meta"]["chunk_seq"] = 1
final["meta"]["last_chunk"] = True
final["meta"]["turn_end"] = True
output = _forward(model, [final])
payload = output.multimodal_outputs
assert "meta" not in payload
assert payload["meta.duplex_epoch"][0].item() == 3
assert payload["meta.duplex_turn_id"][0].item() == 7
torch.testing.assert_close(
payload["meta.llm_output_text_utf8"][0],
segment_text_utf8,
)
assert payload["meta.tts_is_last_chunk"][0].item() is True
assert payload["meta.turn_end"][0].item() is True
assert token2wav.flow.encoder.last_chunk_calls[-1] is True
assert "duplex" not in model._states
def test_initial_empty_segment_marker_initializes_stream_without_audio():
model, token2wav = _model()
boundary = _info("duplex", 0, [])
boundary["meta"].update(
{
"code_flat_numel": 0,
"tts_is_last_chunk": True,
"turn_end": False,
}
)
output = _forward(model, [boundary])
assert output.multimodal_outputs["model_outputs"][0].numel() == 0
assert "duplex" in model._states
assert token2wav.hift.calls == []
resumed = _info(
"duplex",
1,
[4218, 4218, 4218, 10, 11, 12, 13, 14],
)
output = _forward(model, [resumed])
assert output.multimodal_outputs["model_outputs"][0].numel() > 0
assert "duplex" in model._states
def test_shared_runtime_prompt_recreates_missing_file_before_second_owner(tmp_path, monkeypatch):
monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path))
model, _ = _model()
reference = torch.tensor([0.0, 0.25, -0.25, 0.0])
first = _info("voice-a", 0, [10, 11])
first["codes"]["ref"] = reference
first["meta"]["ref_audio_sr"] = 16000
first["meta"].pop("prompt_cache_id")
_forward(model, [first], request_ids=["internal-a"])
prompt_key = model._request_prompt_keys["internal-a"]
prompt_path = Path(model._runtime_prompts[prompt_key].path)
prompt_path.unlink()
second = _info("voice-b", 0, [12, 13])
second["codes"]["ref"] = reference
second["meta"]["ref_audio_sr"] = 16000
second["meta"].pop("prompt_cache_id")
_forward(model, [second], request_ids=["internal-b"])
assert prompt_path.is_file()
assert model._runtime_prompts[prompt_key].owners == {"internal-a", "internal-b"}
model.on_requests_finished(["internal-a"])
assert prompt_path.is_file()
assert model._runtime_prompts[prompt_key].owners == {"internal-b"}
model.on_requests_finished(["internal-b"])
assert not prompt_path.exists()
assert prompt_key not in model._runtime_prompts
def test_runtime_prompt_write_failure_does_not_publish_partial_file(tmp_path, monkeypatch):
monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path))
model, _ = _model()
reference = torch.tensor([0.0, 0.25, -0.25, 0.0])
def fail_after_partial_write(path, *_args, **_kwargs):
Path(path).write_bytes(b"partial")
raise OSError("simulated write failure")
monkeypatch.setattr(
"vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_code2wav.sf.write",
fail_after_partial_write,
)
with pytest.raises(OSError, match="simulated write failure"):
model._materialize_runtime_prompt(reference, 16000)
assert len(model._runtime_prompts) == 1
entry = next(iter(model._runtime_prompts.values()))
assert not Path(entry.path).exists()
assert list(Path(entry.path).parent.iterdir()) == []
def test_runtime_prompt_files_are_isolated_between_model_instances(tmp_path, monkeypatch):
monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path))
first_model, _ = _model()
second_model, _ = _model()
reference = torch.tensor([0.0, 0.25, -0.25, 0.0])
def runtime_ref_info(request_id: str):
info = _info(request_id, 0, [10, 11])
info["codes"]["ref"] = reference
info["meta"]["ref_audio_sr"] = 16000
info["meta"].pop("prompt_cache_id")
return info
_forward(first_model, [runtime_ref_info("voice-a")], request_ids=["internal-a"])
_forward(second_model, [runtime_ref_info("voice-b")], request_ids=["internal-b"])
first_key = first_model._request_prompt_keys["internal-a"]
second_key = second_model._request_prompt_keys["internal-b"]
first_path = Path(first_model._runtime_prompts[first_key].path)
second_path = Path(second_model._runtime_prompts[second_key].path)
assert first_key == second_key
assert first_path != second_path
assert first_path.is_file()
assert second_path.is_file()
first_model.on_requests_finished(["internal-a"])
assert not first_path.exists()
assert second_path.is_file()
second_model.on_requests_finished(["internal-b"])
assert not second_path.exists()
def test_mixed_final_exact_buckets_keep_order_and_release_only_final_states(): def test_mixed_final_exact_buckets_keep_order_and_release_only_final_states():
model, _ = _model() model, _ = _model()
_forward( _forward(
@@ -485,27 +710,37 @@ def test_reference_voice_and_duplex_metadata_follow_request_lifecycle():
model, _ = _model() model, _ = _model()
first = _info("voice-a", 0, [1, 2]) first = _info("voice-a", 0, [1, 2])
first["codes"]["ref"] = torch.linspace(-0.1, 0.1, 160) first["codes"]["ref"] = torch.linspace(-0.1, 0.1, 160)
segment_text_utf8 = torch.tensor(list(b"hello"), dtype=torch.uint8)
first["meta"].update( first["meta"].update(
ref_audio_sr=16000, ref_audio_sr=16000,
native_duplex_segment_text="hello", llm_output_text_utf8=segment_text_utf8,
duplex_turn_id=7, duplex_turn_id=7,
duplex_epoch=3, duplex_epoch=3,
) )
first["meta"].pop("prompt_cache_id") first["meta"].pop("prompt_cache_id")
output = _forward(model, [first]) output = _forward(model, [first])
prompt_cache_id, prompt_wav = model._owned_prompt_wavs["voice-a"] prompt_key = model._request_prompt_keys["voice-a"]
assert prompt_cache_id.startswith("voice-a:") prompt = model._runtime_prompts[prompt_key]
prompt_cache_id, prompt_wav = prompt.cache_id, prompt.path
assert prompt_cache_id.startswith("runtime-ref-")
assert Path(prompt_wav).is_file() assert Path(prompt_wav).is_file()
assert bytes(output.multimodal_outputs["meta.llm_output_text_utf8"][0].tolist()).decode() == "hello" torch.testing.assert_close(
output.multimodal_outputs["meta.llm_output_text_utf8"][0],
segment_text_utf8,
)
assert output.multimodal_outputs["meta.duplex_turn_id"][0].item() == 7 assert output.multimodal_outputs["meta.duplex_turn_id"][0].item() == 7
assert output.multimodal_outputs["meta.duplex_epoch"][0].item() == 3 assert output.multimodal_outputs["meta.duplex_epoch"][0].item() == 3
final = _info("voice-a", 1, [3, 4], last_chunk=True) final = _info("voice-a", 1, [3, 4], last_chunk=True)
final["meta"].pop("prompt_cache_id") final["meta"].pop("prompt_cache_id")
final["meta"]["tts_is_last_chunk"] = True
output = _forward(model, [final]) output = _forward(model, [final])
assert output.multimodal_outputs["meta.tts_is_last_chunk"][0].item() is True assert output.multimodal_outputs["meta.tts_is_last_chunk"][0].item() is True
assert "voice-a" not in model._owned_prompt_wavs assert model._request_prompt_keys["voice-a"] == prompt_key
model.on_requests_finished(["voice-a"])
assert "voice-a" not in model._request_prompt_keys
assert prompt_key not in model._runtime_prompts
assert not Path(prompt_wav).exists() assert not Path(prompt_wav).exists()
assert (prompt_cache_id, prompt_wav) not in model.backend._prompt_features assert (prompt_cache_id, prompt_wav) not in model.backend._prompt_features
@@ -24,8 +24,6 @@ from vllm_omni.config.pipeline_registry import OMNI_PIPELINES
from vllm_omni.config.stage_config import ( from vllm_omni.config.stage_config import (
PipelineConfig, PipelineConfig,
StageExecutionType, StageExecutionType,
load_deploy_config,
merge_pipeline_deploy,
) )
from vllm_omni.model_executor.models.registry import _OMNI_MODELS from vllm_omni.model_executor.models.registry import _OMNI_MODELS
@@ -36,7 +34,6 @@ _PIPELINE_KEY = "minicpmo_4_5"
_DEPLOY_DIR = Path(__file__).resolve().parents[4] / "vllm_omni" / "deploy" _DEPLOY_DIR = Path(__file__).resolve().parents[4] / "vllm_omni" / "deploy"
_DEPLOY_LAYOUTS = { _DEPLOY_LAYOUTS = {
"minicpmo_4_5.yaml": ["0", "0", "0"], "minicpmo_4_5.yaml": ["0", "0", "0"],
"minicpmo_4_5_batching.yaml": ["0", "1", "1"],
"minicpmo_4_5_2gpu.yaml": ["0", "1", "1"], "minicpmo_4_5_2gpu.yaml": ["0", "1", "1"],
"minicpmo_4_5_3gpu.yaml": ["0", "1", "2"], "minicpmo_4_5_3gpu.yaml": ["0", "1", "2"],
"minicpmo_4_5_8x4090.yaml": ["0,1,2,3", "4", "5"], "minicpmo_4_5_8x4090.yaml": ["0,1,2,3", "4", "5"],
@@ -138,45 +135,6 @@ class TestPipelineTopology:
class TestDeployTopology: class TestDeployTopology:
@pytest.mark.parametrize(("filename", "devices"), _DEPLOY_LAYOUTS.items())
def test_deploy_resolves_three_stage_pipeline(self, filename: str, devices: list[str]) -> None:
deploy = load_deploy_config(_DEPLOY_DIR / filename)
pipeline = OMNI_PIPELINES[deploy.pipeline]
stages = merge_pipeline_deploy(pipeline, deploy)
assert deploy.pipeline == _PIPELINE_KEY
assert deploy.async_chunk is True
assert [stage.stage_id for stage in stages] == [0, 1, 2]
assert [stage.yaml_runtime["devices"] for stage in stages] == devices
assert "additional_config" not in stages[1].yaml_engine_args
assert "skip_mm_profiling" not in stages[0].yaml_engine_args
assert stages[1].yaml_engine_args["skip_mm_profiling"] is True
assert stages[2].yaml_engine_args["skip_mm_profiling"] is True
assert stages[1].yaml_extras["output_connectors"]["to_stage_2"] == "connector_of_shared_memory"
assert stages[2].yaml_extras["input_connectors"]["from_stage_1"] == "connector_of_shared_memory"
connector = deploy.connectors["connector_of_shared_memory"]
assert connector["name"] == "SharedMemoryConnector"
assert connector["extra"]["codec_chunk_frames"] == 25
assert connector["extra"]["codec_left_context_frames"] == 3
assert connector["extra"]["connector_get_max_wait_first_chunk"] == 3000
assert connector["extra"]["connector_get_max_wait"] == 300
expected_processor = "tts2code2wav_async_chunk" if deploy.async_chunk else "tts2code2wav_full_payload"
assert stages[1].yaml_engine_args["custom_process_next_stage_input_func"].endswith(expected_processor)
if filename == "minicpmo_4_5.yaml":
assert [stage.yaml_engine_args["max_num_seqs"] for stage in stages] == [4, 4, 4]
assert [stage.yaml_engine_args["gpu_memory_utilization"] for stage in stages] == [
0.55,
0.22,
0.22,
]
assert stages[0].yaml_engine_args["limit_mm_per_prompt"] == {"video": {"count": 1, "num_frames": 32}}
elif filename in {"minicpmo_4_5_batching.yaml", "minicpmo_4_5_2gpu.yaml"}:
assert [stage.yaml_engine_args["gpu_memory_utilization"] for stage in stages] == [
0.9,
0.55,
0.35,
]
def test_pipeline_exposes_full_and_async_payload_hooks(self) -> None: def test_pipeline_exposes_full_and_async_payload_hooks(self) -> None:
pipeline = OMNI_PIPELINES[_PIPELINE_KEY] pipeline = OMNI_PIPELINES[_PIPELINE_KEY]
talker = pipeline.get_stage(1) talker = pipeline.get_stage(1)
@@ -14,6 +14,7 @@ from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni import (
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_tts import ( from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_tts import (
MiniCPMO45OmniTTSForConditionalGeneration, MiniCPMO45OmniTTSForConditionalGeneration,
_max_audio_tokens, _max_audio_tokens,
_restore_weight_norm_weight,
) )
from vllm_omni.utils.mm_outputs import to_payload_element from vllm_omni.utils.mm_outputs import to_payload_element
@@ -56,6 +57,8 @@ def _make_talker() -> MiniCPMO45OmniTTSForConditionalGeneration:
talker._request_generators = {} talker._request_generators = {}
talker._request_audio_states = {} talker._request_audio_states = {}
talker._deferred_cleanup_ids = set() talker._deferred_cleanup_ids = set()
talker._codec_min_tokens = 50
talker._codec_seed = 42
return talker return talker
@@ -81,6 +84,23 @@ def test_audio_token_limit_scales_with_condition_length(
assert _max_audio_tokens(condition_tokens) == expected assert _max_audio_tokens(condition_tokens) == expected
def test_weight_norm_restore_matches_checkpoint_parametrization_in_bfloat16() -> None:
generator = torch.Generator().manual_seed(42)
weight_v = torch.randn(8, 16, generator=generator, dtype=torch.bfloat16)
weight_g = torch.rand(8, 1, generator=generator, dtype=torch.bfloat16)
linear = nn.utils.parametrizations.weight_norm(
nn.Linear(16, 8, bias=False, dtype=torch.bfloat16),
dim=0,
)
with torch.no_grad():
linear.parametrizations.weight.original0.copy_(weight_g)
linear.parametrizations.weight.original1.copy_(weight_v)
restored = _restore_weight_norm_weight(weight_g, weight_v)
assert torch.equal(restored, linear.weight)
def test_talker_emits_request_aligned_codec_deltas_after_compaction(mocker) -> None: def test_talker_emits_request_aligned_codec_deltas_after_compaction(mocker) -> None:
talker = _make_talker() talker = _make_talker()
seen: list[tuple[str, list[float], list[int]]] = [] seen: list[tuple[str, list[float], list[int]]] = []
@@ -114,9 +134,73 @@ def test_talker_emits_request_aligned_codec_deltas_after_compaction(mocker) -> N
assert _routed(output, 0)["codes"]["audio"].tolist() == [[2]] assert _routed(output, 0)["codes"]["audio"].tolist() == [[2]]
assert _routed(output, 1)["codes"]["audio"].tolist() == [[3]] assert _routed(output, 1)["codes"]["audio"].tolist() == [[3]]
assert _routed(output, 0)["meta"]["finished"].item() is False assert _routed(output, 0)["meta"]["finished"].item() is False
assert set(output.multimodal_outputs["meta"]) == {"finished"}
assert talker.compute_logits(output.text_hidden_states).argmax(dim=-1).tolist() == [0, 0] assert talker.compute_logits(output.text_hidden_states).argmax(dim=-1).tolist() == [0, 0]
def test_talker_projects_request_aligned_duplex_metadata(mocker) -> None:
talker = _make_talker()
mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2))
infos = [
{
"request_id": "req-a",
"native_duplex": True,
"duplex": {"epoch": 3, "turn_id": 7},
"ids": {"tts": [41]},
"meta": {
"native_duplex_segment_text": "first",
"turn_eos_token_id": 99,
},
"audio_codes": {"accumulated": torch.empty(0, dtype=torch.long)},
},
{
"request_id": "req-b",
"native_duplex": True,
"duplex": {"epoch": 4, "turn_id": 8},
"ids": {"tts": [42, 99]},
"meta": {
"native_duplex_segment_text": "second",
"turn_eos_token_id": 99,
},
"audio_codes": {"accumulated": torch.empty(0, dtype=torch.long)},
},
]
output = talker.make_omni_output(
torch.ones(2, 2),
model_intermediate_buffer=infos,
request_token_spans=[(0, 1), (1, 2)],
)
meta = output.multimodal_outputs["meta"]
assert [value.item() for value in meta["native_duplex"]] == [True, True]
assert [value.item() for value in meta["duplex_epoch"]] == [3, 4]
assert [value.item() for value in meta["duplex_turn_id"]] == [7, 8]
assert "native_duplex_segment_text" not in meta
assert [bytes(value.tolist()).decode("utf-8") for value in meta["llm_output_text_utf8"]] == [
"first",
"second",
]
assert [value.item() for value in meta["turn_end"]] == [False, True]
def test_talker_rejects_native_duplex_without_fence_identity(mocker) -> None:
talker = _make_talker()
mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2))
info = {
"request_id": "req-missing-fence",
"native_duplex": True,
"audio_codes": {"accumulated": torch.empty(0, dtype=torch.long)},
}
with pytest.raises(RuntimeError, match="requires non-negative integer epoch and turn_id"):
talker.make_omni_output(
torch.ones(1, 2),
model_intermediate_buffer=[info],
request_token_spans=[(0, 1)],
)
def test_incomplete_prefill_emits_no_code_and_does_not_advance_state(mocker) -> None: def test_incomplete_prefill_emits_no_code_and_does_not_advance_state(mocker) -> None:
talker = _make_talker() talker = _make_talker()
sample = mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2)) sample = mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2))
@@ -179,7 +263,7 @@ def test_eos_is_terminal_once_and_never_enters_codec_history(mocker) -> None:
assert talker.compute_logits(second.text_hidden_states).argmax(dim=-1).tolist() == [1] assert talker.compute_logits(second.text_hidden_states).argmax(dim=-1).tolist() == [1]
def test_max_token_terminal_includes_only_new_codec_delta(mocker) -> None: def test_max_token_terminal_drops_unconsumed_codec_delta(mocker) -> None:
talker = _make_talker() talker = _make_talker()
mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(3)) mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(3))
info = { info = {
@@ -194,8 +278,10 @@ def test_max_token_terminal_includes_only_new_codec_delta(mocker) -> None:
request_token_spans=[(0, 1)], request_token_spans=[(0, 1)],
) )
assert info["audio_codes"]["accumulated"].tolist() == [4, 5, 3] # MiniCPMTTS.generate_chunk samples once at the max-token boundary to
assert output.multimodal_outputs["codes"]["audio"][0].tolist() == [[3]] # advance RNG state, but the sampled code is not fed into KV or returned.
assert info["audio_codes"]["accumulated"].tolist() == [4, 5]
assert output.multimodal_outputs["codes"]["audio"][0].shape == (0, 1)
assert output.multimodal_outputs["meta"]["finished"][0].item() is True assert output.multimodal_outputs["meta"]["finished"][0].item() is True
assert talker.compute_logits(output.text_hidden_states).argmax(dim=-1).tolist() == [1] assert talker.compute_logits(output.text_hidden_states).argmax(dim=-1).tolist() == [1]
@@ -268,6 +354,94 @@ def test_empty_speech_segment_finishes_without_sampling_codes() -> None:
assert decode_embeds.shape == (1, 4) assert decode_embeds.shape == (1, 4)
def test_chunked_prefill_tail_aligns_condition_with_prompt_length(mocker) -> None:
talker = _make_talker()
talker.emb_text = nn.Embedding(1, 2)
condition = torch.arange(18, dtype=torch.float32).reshape(9, 2)
mocker.patch.object(talker, "_build_condition_embeddings", return_value=condition)
_, embeds, _ = talker.preprocess(
torch.zeros(9, dtype=torch.long),
None,
_omni_is_prefill=True,
_omni_num_computed_tokens=59,
_omni_prompt_len=68,
request_id="req-chunked-prefill",
tts_token_ids=torch.tensor([1]),
tts_hidden_states=torch.ones(1, 2),
)
assert torch.equal(embeds, condition)
state = talker._request_audio_states["req-chunked-prefill"]
assert state["min_tokens"] == 50
assert state["max_tokens"] == 64
@pytest.mark.parametrize(
("meta", "expected_min_tokens"),
[
({"turn_start": True}, 0),
({}, 26),
({"turn_end": True}, 0),
],
)
def test_native_duplex_prefill_uses_official_chunk_limits(
mocker,
meta,
expected_min_tokens,
) -> None:
talker = _make_talker()
talker.emb_text = nn.Embedding(1, 2)
mocker.patch.object(
talker,
"_build_condition_embeddings",
return_value=torch.ones(3, 2),
)
talker.preprocess(
torch.zeros(3, dtype=torch.long),
None,
_omni_is_prefill=True,
request_id="req-duplex-chunk",
native_duplex=True,
meta=meta,
tts_token_ids=torch.tensor([1]),
tts_hidden_states=torch.ones(1, 2),
)
state = talker._request_audio_states["req-duplex-chunk"]
assert state["min_tokens"] == expected_min_tokens
assert state["max_tokens"] == 26
def test_native_duplex_condition_matches_official_text_plus_audio_bos() -> None:
talker = _make_talker()
talker.emb_text = nn.Embedding(16, 2)
talker.projector_semantic = nn.Identity()
talker._normalize = False
talker._text_eos_id = 14
talker._tts_bos_id = 15
with torch.no_grad():
talker.emb_text.weight.copy_(torch.arange(32, dtype=torch.float32).reshape(16, 2))
token_ids = torch.tensor([2, 3])
hidden_states = torch.tensor([[0.5, 1.0], [1.5, 2.0]])
condition = talker._build_condition_embeddings(
token_ids,
hidden_states,
native_duplex=True,
)
expected_text = talker.emb_text(token_ids) + hidden_states
expected = torch.cat(
[expected_text, talker.emb_text(torch.tensor([talker._tts_bos_id]))],
dim=0,
)
assert torch.equal(condition, expected)
assert condition.shape[0] == token_ids.shape[0] + 1
def test_request_cleanup_evicts_ar_rng_and_decode_state() -> None: def test_request_cleanup_evicts_ar_rng_and_decode_state() -> None:
talker = _make_talker() talker = _make_talker()
talker._request_generators["req-done"] = torch.Generator() talker._request_generators["req-done"] = torch.Generator()
@@ -43,6 +43,27 @@ def _delta(*codes: int):
} }
def _duplex_delta(
*codes: int,
epoch: int = 3,
turn_id: int = 7,
text: str = "segment",
turn_end: bool = False,
):
text_utf8 = torch.tensor(list(text.encode("utf-8")), dtype=torch.uint8)
return {
"codes": {"audio": torch.tensor(codes, dtype=torch.long).reshape(-1, 1)},
"meta": {
"finished": torch.tensor(False),
"native_duplex": torch.tensor(True),
"duplex_epoch": torch.tensor(epoch),
"duplex_turn_id": torch.tensor(turn_id),
"llm_output_text_utf8": text_utf8,
"turn_end": torch.tensor(turn_end),
},
}
def _codes(payload) -> list[int]: def _codes(payload) -> list[int]:
assert payload.codes is not None assert payload.codes is not None
assert isinstance(payload.codes.audio, torch.Tensor) assert isinstance(payload.codes.audio, torch.Tensor)
@@ -108,29 +129,57 @@ def test_short_final_flushes_silence_prefix_and_tail() -> None:
assert final.meta.finished.item() is True assert final.meta.finished.item() is True
def test_duplex_turn_end_waits_for_terminal_codec_flush() -> None:
manager = _manager()
request = _request("req-duplex")
body = tts2code2wav_async_chunk(
manager,
_duplex_delta(*range(25), turn_end=True),
request,
False,
)
final = tts2code2wav_async_chunk(
manager,
_duplex_delta(turn_end=True),
request,
True,
)
assert body is not None
assert body.meta.last_chunk is False
assert body.meta.turn_end is False
assert final is not None
assert _codes(final) == [22, 23, 24]
assert final.meta.last_chunk is True
assert final.meta.turn_end is True
def test_first_chunk_forwards_reference_voice_and_duplex_identity() -> None: def test_first_chunk_forwards_reference_voice_and_duplex_identity() -> None:
manager = _manager() manager = _manager()
request = _request("req") request = _request("req")
request.additional_information = { request.additional_information = {
"codes": {"ref": [0.1, -0.1]}, "codes": {"ref": [0.1, -0.1]},
"meta": { "meta": {"ref_audio_sr": 16000},
"ref_audio_sr": 16000,
"native_duplex_segment_text": "hello",
"segment_end": True,
"turn_end": True,
},
"duplex": {"epoch": 3, "model_turn_id": 7},
} }
payload = tts2code2wav_async_chunk(manager, _delta(*range(7)), request, True) payload = tts2code2wav_async_chunk(
manager,
_duplex_delta(*range(7), text="hello", turn_end=True),
request,
True,
)
assert payload is not None assert payload is not None
assert payload.codes.ref.tolist() == pytest.approx([0.1, -0.1]) assert payload.codes.ref.tolist() == pytest.approx([0.1, -0.1])
assert payload.meta.ref_audio_sr == 16000 assert payload.meta.ref_audio_sr == 16000
assert payload.meta.native_duplex_segment_text == "hello" torch.testing.assert_close(
payload.meta.llm_output_text_utf8,
torch.tensor(list(b"hello"), dtype=torch.uint8),
)
assert payload.meta.duplex_epoch == 3 assert payload.meta.duplex_epoch == 3
assert payload.meta.duplex_turn_id == 7 assert payload.meta.duplex_turn_id == 7
assert payload.meta.segment_end is True assert payload.meta.tts_is_last_chunk is True
assert payload.meta.turn_end is True assert payload.meta.turn_end is True
@@ -211,6 +260,186 @@ def test_empty_final_releases_wait_gate_once() -> None:
assert duplicate is None assert duplicate is None
def test_empty_duplex_boundary_uses_zero_length_transport_placeholder() -> None:
manager = _manager()
boundary = tts2code2wav_async_chunk(
manager,
_duplex_delta(text="boundary"),
_request("req-duplex"),
True,
)
assert boundary is not None
assert _codes(boundary) == [0]
assert boundary.meta.code_flat_numel == 0
assert boundary.meta.last_chunk is False
assert boundary.meta.is_segment_finished.item() is False
torch.testing.assert_close(
boundary.meta.llm_output_text_utf8,
torch.tensor(list(b"boundary"), dtype=torch.uint8),
)
def test_duplex_segments_preserve_stream_state_without_closing_turn() -> None:
manager = _manager()
request = _request("req-duplex")
request.additional_information = {
"codes": {"ref": [0.1, 0.2, 0.3]},
"meta": {"ref_audio_sr": 16000},
}
first = tts2code2wav_async_chunk(
manager,
_duplex_delta(10, 11, text="first"),
request,
True,
)
second = tts2code2wav_async_chunk(
manager,
_duplex_delta(12, 13, text="second"),
request,
True,
)
assert first is not None
assert second is not None
assert first.meta.last_chunk is False
assert second.meta.last_chunk is False
assert first.codes is not None
assert torch.allclose(first.codes.ref, torch.tensor([0.1, 0.2, 0.3]))
assert first.meta.ref_audio_sr == 16000
assert second.codes is not None
assert second.codes.ref is None
assert second.meta.cache_epoch == first.meta.cache_epoch
assert second.meta.chunk_seq == first.meta.chunk_seq + 1
assert second.meta.duplex_epoch == 3
assert second.meta.duplex_turn_id == 7
torch.testing.assert_close(
second.meta.llm_output_text_utf8,
torch.tensor(list(b"second"), dtype=torch.uint8),
)
assert second.meta.tts_is_last_chunk is True
assert second.meta.turn_end is False
assert first.meta.is_segment_finished.item() is False
assert second.meta.is_segment_finished.item() is False
def test_duplex_short_units_wait_for_minimum_stream_body() -> None:
manager = _manager()
request = _request("req-duplex")
first = tts2code2wav_async_chunk(
manager,
_duplex_delta(10, 11, 12, text="first"),
request,
True,
)
second = tts2code2wav_async_chunk(
manager,
_duplex_delta(13, 14, text="first"),
request,
True,
)
assert first is not None
assert _codes(first) == [0]
assert first.meta.code_flat_numel == 0
assert first.meta.last_chunk is False
assert first.meta.tts_is_last_chunk is True
assert second is not None
assert _codes(second) == [4218, 4218, 4218, 10, 11, 12, 13, 14]
assert second.meta.code_flat_numel == 8
torch.testing.assert_close(
second.meta.llm_output_text_utf8,
torch.tensor(list(b"firstfirst"), dtype=torch.uint8),
)
def test_duplex_empty_finish_callback_does_not_replay_previous_text() -> None:
manager = _manager()
request = _request("req-duplex")
first = tts2code2wav_async_chunk(
manager,
_duplex_delta(*range(25), text="first"),
request,
False,
)
boundary = tts2code2wav_async_chunk(
manager,
_duplex_delta(text="first"),
request,
True,
)
second = tts2code2wav_async_chunk(
manager,
_duplex_delta(*range(25, 50), text="second"),
request,
False,
)
assert first is not None
assert boundary is not None
assert boundary.meta.code_flat_numel == 0
assert second is not None
torch.testing.assert_close(
second.meta.llm_output_text_utf8,
torch.tensor(list(b"second"), dtype=torch.uint8),
)
def test_duplex_short_tail_does_not_replay_previous_segment_text() -> None:
manager = _manager()
request = _request("req-duplex")
def chunk(codes, text: str, finished: bool):
return tts2code2wav_async_chunk(
manager,
_duplex_delta(*codes, text=text),
request,
finished,
)
first = chunk(range(25), "和上海之间", False)
first_tail = chunk([25, 26], "和上海之间", True)
assert first is not None
assert first.meta.llm_output_text_utf8.tolist() == list("和上海之间".encode())
assert first_tail is not None
assert first_tail.meta.code_flat_numel == 0
assert chunk([27, 28, 29], "的距离大约是", False) is None
next_flush = chunk([], "的距离大约是", True)
assert next_flush is not None
assert next_flush.meta.llm_output_text_utf8.tolist() == list("的距离大约是".encode())
def test_duplex_turn_end_closes_epoch_and_next_turn_restarts_sequence() -> None:
manager = _manager()
request = _request("req-duplex")
turn_end = tts2code2wav_async_chunk(
manager,
_duplex_delta(14, turn_id=7, turn_end=True),
request,
True,
)
next_turn = tts2code2wav_async_chunk(
manager,
_duplex_delta(20, turn_id=8),
request,
True,
)
assert turn_end is not None
assert next_turn is not None
assert turn_end.meta.last_chunk is True
assert turn_end.meta.turn_end is True
assert turn_end.meta.is_segment_finished.item() is True
assert next_turn.meta.cache_epoch == turn_end.meta.cache_epoch + 1
assert next_turn.meta.chunk_seq == 0
assert next_turn.meta.last_chunk is False
def test_staggered_requests_keep_accumulators_isolated() -> None: def test_staggered_requests_keep_accumulators_isolated() -> None:
manager = _manager() manager = _manager()
req_a = _request("a") req_a = _request("a")
@@ -128,13 +128,125 @@ def test_native_duplex_speak_segment_reaches_split_talker() -> None:
info = converted["model_intermediate_buffer"] info = converted["model_intermediate_buffer"]
assert info["native_duplex"] is True assert info["native_duplex"] is True
assert info["ids"]["tts"] == [21, 22] assert info["ids"]["tts"] == [21, 22]
assert converted["prompt_token_ids"] == [0, 0, 0, 0] assert converted["prompt_token_ids"] == [0, 0, 0]
assert info["meta"]["replace_streaming_prompt"] is True assert info["meta"]["replace_streaming_prompt"] is True
assert info["meta"]["next_stage_prompt_len"] == 3
assert info["meta"]["turn_start"] is True
assert info["meta"]["segment_end"] is True assert info["meta"]["segment_end"] is True
assert info["duplex"]["epoch"] == 3 assert info["duplex"]["epoch"] == 3
assert info["duplex"]["turn_id"] == 7 assert info["duplex"]["turn_id"] == 7
def test_native_duplex_continuation_appends_only_new_talker_condition() -> None:
prompt_ids = [101, 102]
token_ids = {
"tts_bos_token_id": 9301,
"tts_eos_token_id": 9302,
"listen_token_id": 9303,
"speak_token_id": 9304,
"chunk_eos_token_id": 9308,
"chunk_tts_eos_token_id": 9309,
"turn_eos_token_id": 9310,
}
context = SimpleNamespace(
bridge_states={
"duplex": {
"epoch": 3,
"model_turn_id": 7,
}
}
)
first_ids = [9304, 21, 22, 9308]
first = _output(
prompt_ids=prompt_ids,
output_ids=first_ids,
latent=torch.arange(24, dtype=torch.float32).reshape(6, 4),
multimodal_output={
"duplex_prompt_token_ids": prompt_ids,
"meta": token_ids,
},
)
second_ids = [*first_ids, 9304, 23, 24, 9308]
second = _output(
prompt_ids=prompt_ids,
output_ids=second_ids,
latent=torch.arange(40, dtype=torch.float32).reshape(10, 4),
multimodal_output={
"duplex_prompt_token_ids": prompt_ids,
"meta": token_ids,
},
)
third_ids = [*second_ids, 9304, 25, 26, 9308]
third = _output(
prompt_ids=prompt_ids,
output_ids=third_ids,
latent=torch.arange(56, dtype=torch.float32).reshape(14, 4),
multimodal_output={
"duplex_prompt_token_ids": prompt_ids,
"meta": token_ids,
},
)
first_input = llm2tts([first], prompt=[{}], _streaming_context=context)[0]
second_input = llm2tts([second], prompt=[{}], _streaming_context=context)[0]
context.bridge_states["duplex"]["model_turn_id"] = 8
third_input = llm2tts([third], prompt=[{}], _streaming_context=context)[0]
assert first_input["model_intermediate_buffer"]["ids"]["tts"] == [21, 22]
assert second_input["model_intermediate_buffer"]["ids"]["tts"] == [23, 24]
assert third_input["model_intermediate_buffer"]["ids"]["tts"] == [25, 26]
assert first_input["model_intermediate_buffer"]["meta"]["turn_start"] is True
assert second_input["model_intermediate_buffer"]["meta"]["turn_start"] is False
assert third_input["model_intermediate_buffer"]["meta"]["turn_start"] is True
assert first_input["model_intermediate_buffer"]["meta"]["replace_streaming_prompt"] is True
assert "replace_streaming_prompt" not in second_input["model_intermediate_buffer"]["meta"]
assert third_input["model_intermediate_buffer"]["meta"]["replace_streaming_prompt"] is True
assert second_input["model_intermediate_buffer"]["meta"]["next_stage_prompt_len"] == 3
assert second_input["prompt_token_ids"] == [0, 0, 0]
def test_native_duplex_transcript_decodes_the_talker_condition_slice() -> None:
prompt_ids = [101, 102]
metadata = {
"tts_bos_token_id": 9301,
"tts_eos_token_id": 9302,
"listen_token_id": 9303,
"speak_token_id": 9304,
"chunk_eos_token_id": 9308,
"chunk_tts_eos_token_id": 9309,
"turn_eos_token_id": 9310,
}
token_text = {21: "杭州", 22: "和", 23: "州和", 24: "上海之间", 25: "大"}
def output(token_ids: list[int]):
return _output(
prompt_ids=prompt_ids,
output_ids=token_ids,
latent=torch.zeros((len(prompt_ids) + len(token_ids), 1)),
multimodal_output={
"duplex_prompt_token_ids": prompt_ids,
"meta": metadata,
},
)
context = SimpleNamespace(
bridge_states={"duplex": {"epoch": 3, "model_turn_id": 7}},
source_token_decoder=lambda ids, **_: "".join(token_text.get(int(token_id), "") for token_id in ids),
)
first_ids = [9304, 21, 22, 9308]
first_info = llm2tts([output(first_ids)], prompt=[{}], _streaming_context=context)[0]["model_intermediate_buffer"]
second_info = llm2tts(
[output([*first_ids, 23, 24, 25, 9308])],
prompt=[{}],
_streaming_context=context,
)[0]["model_intermediate_buffer"]
assert first_info["meta"]["native_duplex_segment_text"] == "杭州和"
assert second_info["ids"]["tts"] == [24, 25]
assert second_info["meta"]["native_duplex_segment_text"] == "上海之间大"
def test_native_duplex_requires_tokenizer_boundary_metadata() -> None: def test_native_duplex_requires_tokenizer_boundary_metadata() -> None:
latent = torch.zeros((3, 4)) latent = torch.zeros((3, 4))
source = _output( source = _output(
@@ -4,6 +4,7 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@@ -227,6 +228,47 @@ def test_talker2code2wav_full_payload_keeps_all_zero_codec_rows() -> None:
assert "code_predictor_codes" not in payload assert "code_predictor_codes" not in payload
def test_talker2code2wav_async_chunk_flushes_cached_tail_on_stop_token() -> None:
request_id = "codec_tail"
stop_token_id = 999
transfer_manager = SimpleNamespace(
code_prompt_token_ids=defaultdict(list),
put_req_chunk=defaultdict(int, {request_id: 2}),
connector=SimpleNamespace(
config={
"extra": {
"initial_codec_chunk_frames": 4,
"codec_chunk_frames": 25,
"codec_left_context_frames": 25,
}
}
),
)
transfer_manager.code_prompt_token_ids[request_id] = [
torch.tensor([[frame, frame + 100]], dtype=torch.long) for frame in range(50)
]
request = SimpleNamespace(
external_req_id=request_id,
sampling_params=SimpleNamespace(
stop_token_ids=[stop_token_id],
stop_token_id=None,
),
)
payload = q3.talker2code2wav_async_chunk(
transfer_manager,
{"codes": {"audio": torch.tensor([[stop_token_id, 0]], dtype=torch.long)}},
request,
is_finished=True,
)
assert payload is not None
assert payload.meta.finished.item() is True
# 4 initial frames and one 25-frame chunk were already emitted. The final
# payload contains 25 frames of left context plus the remaining 21 frames.
assert payload.codes.audio.numel() == (25 + 21) * 2
def test_thinker2talker_full_payload_packs_complete_tensors() -> None: def test_thinker2talker_full_payload_packs_complete_tensors() -> None:
"""Full-payload path drops the terminal thinker row before talker prefill.""" """Full-payload path drops the terminal thinker row before talker prefill."""
request = SimpleNamespace( request = SimpleNamespace(
+39
View File
@@ -1160,8 +1160,10 @@ class TestDeployConfigLoading:
assert [stage.to_omegaconf().session_mode for stage in stages] == ["duplex", "duplex", "duplex"] assert [stage.to_omegaconf().session_mode for stage in stages] == ["duplex", "duplex", "duplex"]
assert [stage.yaml_engine_args["async_scheduling"] for stage in stages] == [False, False, False] assert [stage.yaml_engine_args["async_scheduling"] for stage in stages] == [False, False, False]
assert all("Async" not in (stage.scheduler_cls or "") for stage in stages) assert all("Async" not in (stage.scheduler_cls or "") for stage in stages)
assert [stage.devices for stage in deploy.stages] == ["0", "0", "0"]
assert deploy.stages[1].enforce_eager is False assert deploy.stages[1].enforce_eager is False
assert stages[1].yaml_extras["default_sampling_params"]["max_tokens"] == 2048 assert stages[1].yaml_extras["default_sampling_params"]["max_tokens"] == 2048
assert stages[1].yaml_extras["default_sampling_params"]["min_tokens"] == 0
assert stages[1].yaml_extras["default_sampling_params"]["stop_token_ids"] == [1] assert stages[1].yaml_extras["default_sampling_params"]["stop_token_ids"] == [1]
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -2162,6 +2164,43 @@ class TestBaseConfigInheritance:
class TestPlatformOverrides: class TestPlatformOverrides:
"""Test platform-specific deploy config overrides.""" """Test platform-specific deploy config overrides."""
def test_minicpmo_4_5_cuda_caps_talker_kv_cache(self):
pipeline = resolve_pipeline_config("minicpmo_4_5")
assert isinstance(pipeline, PipelineConfig)
deploy_path = Path(get_deploy_config_path("minicpmo_4_5.yaml"))
cuda = _apply_platform_overrides(load_deploy_config(deploy_path), platform="cuda")
cuda_stages = merge_pipeline_deploy(pipeline, cuda)
# Talker KV is left to automatic sizing (no hard kv_cache_memory_bytes cap).
assert "kv_cache_memory_bytes" not in cuda_stages[1].yaml_engine_args
# The CUDA memory budget must not leak into the existing NPU profile.
npu = _apply_platform_overrides(load_deploy_config(deploy_path), platform="npu")
npu_stages = merge_pipeline_deploy(pipeline, npu)
assert "kv_cache_memory_bytes" not in npu_stages[1].yaml_engine_args
# Multi-GPU replica profiles place Talker on dedicated devices and
# also leave KV sizing automatic.
for filename in (
"minicpmo_4_5_3gpu_stage1_replicas.yaml",
"minicpmo_4_5_4gpu_stage1_replicas.yaml",
):
replica = _apply_platform_overrides(
load_deploy_config(Path(get_deploy_config_path(filename))), platform="cuda"
)
replica_stages = merge_pipeline_deploy(pipeline, replica)
assert "kv_cache_memory_bytes" not in replica_stages[1].yaml_engine_args
def test_minicpmo_4_5_single_gpu_admits_four_sequences(self):
"""Single-GPU shares leave room for Code2Wav peak activations at bs<=4."""
deploy = load_deploy_config(Path(get_deploy_config_path("minicpmo_4_5.yaml")))
assert [stage.max_num_seqs for stage in deploy.stages] == [4, 4, 4]
assert [stage.gpu_memory_utilization for stage in deploy.stages] == [
0.55,
0.15,
0.18,
]
def test_npu_overrides(self): def test_npu_overrides(self):
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():
+432 -15
View File
@@ -276,16 +276,16 @@ def test_minicpmo_model_hook_same_speech_row_second_step_does_not_rearm_pending_
next_logits[0, 7] = 30.0 next_logits[0, 7] = 30.0
next_logits[0, 8] = -2.0 next_logits[0, 8] = -2.0
next_logits[0, 10] = 20.0 next_logits[0, 10] = 20.0
original_next_logits = next_logits.clone()
model.prepare_duplex_sampling(next_logits, SimpleNamespace(), (row,)) model.prepare_duplex_sampling(next_logits, SimpleNamespace(), (row,))
assert state.pending_speech_context is False assert state.pending_speech_context is False
assert state.current_turn_ended is False assert state.current_turn_ended is False
assert torch.isneginf(next_logits[0, 7]) assert torch.equal(next_logits, original_next_logits)
assert next_logits[0, 8].item() == 30.0
def test_minicpmo_model_hook_mid_turn_speech_redirects_listen_to_tts_bos(): def test_minicpmo_model_hook_mid_turn_speech_preserves_logits_for_post_sample_redirect():
state = SimpleNamespace( state = SimpleNamespace(
current_turn_ended=False, current_turn_ended=False,
last_terminator_token=None, last_terminator_token=None,
@@ -296,12 +296,11 @@ def test_minicpmo_model_hook_mid_turn_speech_redirects_listen_to_tts_bos():
logits[0, 7] = 30.0 logits[0, 7] = 30.0
logits[0, 8] = -2.0 logits[0, 8] = -2.0
logits[0, 10] = 20.0 logits[0, 10] = 20.0
original_logits = logits.clone()
model.prepare_duplex_sampling(logits, SimpleNamespace(), (row,)) model.prepare_duplex_sampling(logits, SimpleNamespace(), (row,))
assert torch.isneginf(logits[0, 7]) assert torch.equal(logits, original_logits)
assert logits[0, 8].item() == 30.0
assert logits[0, 10].item() == 20.0
def test_minicpmo_model_hook_ignores_serving_new_user_turn_marker(): def test_minicpmo_model_hook_ignores_serving_new_user_turn_marker():
@@ -317,12 +316,12 @@ def test_minicpmo_model_hook_ignores_serving_new_user_turn_marker():
logits = torch.zeros((1, 16), dtype=torch.float32) logits = torch.zeros((1, 16), dtype=torch.float32)
logits[0, 7] = 5.0 logits[0, 7] = 5.0
logits[0, 10] = 20.0 logits[0, 10] = 20.0
original_logits = logits.clone()
model.prepare_duplex_sampling(logits, SimpleNamespace(), (row,)) model.prepare_duplex_sampling(logits, SimpleNamespace(), (row,))
assert state.current_turn_ended is False assert state.current_turn_ended is False
assert state.last_terminator_token is None assert state.last_terminator_token is None
assert torch.isneginf(logits[0, 7]) assert torch.equal(logits, original_logits)
assert logits[0, 8].item() == 5.0
def test_generic_ar_runner_builds_typed_duplex_sampling_rows(): def test_generic_ar_runner_builds_typed_duplex_sampling_rows():
@@ -613,6 +612,180 @@ def test_minicpmo_stage0_rejects_unknown_special_token_fallbacks():
runtime._require_special_token_ids() runtime._require_special_token_ids()
def _minicpmo45_tokenizer_stub():
token_ids = {
token: index
for index, token in enumerate(
(
"<unit>",
"</unit>",
"<|listen|>",
"<|speak|>",
"<|tts_bos|>",
"<|tts_eos|>",
"<|tts_pad|>",
"<|chunk_eos|>",
"<|chunk_tts_eos|>",
"<|turn_eos|>",
),
start=101,
)
}
return SimpleNamespace(
unk_token_id=0,
convert_tokens_to_ids=lambda token: token_ids.get(token, 0),
encode=lambda token, add_special_tokens=False: (
[token_ids[token]] if not add_special_tokens and token in token_ids else [0]
),
)
def _transformers_processor_stub(auto_processor):
class _AutoImageProcessor:
@staticmethod
def register(*_args, **_kwargs):
return None
return SimpleNamespace(
AutoImageProcessor=_AutoImageProcessor,
AutoProcessor=auto_processor,
)
def test_minicpmo_stage0_loads_processor_from_hf_id(monkeypatch):
import sys
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
MiniCPMO45Stage0DuplexRuntime,
)
model_id = "openbmb/MiniCPM-o-4_5"
processor = SimpleNamespace(tokenizer=_minicpmo45_tokenizer_stub())
load_calls = []
class _AutoImageProcessor:
@staticmethod
def register(config_class, image_processor_class):
del image_processor_class
if isinstance(config_class, str):
raise AttributeError("'str' object has no attribute '__module__'")
class _AutoProcessor:
@classmethod
def from_pretrained(cls, model_path, *, trust_remote_code):
load_calls.append((model_path, trust_remote_code))
_AutoImageProcessor.register("MiniCPMVImageProcessor", object)
return processor
monkeypatch.setitem(
sys.modules,
"transformers",
SimpleNamespace(
AutoImageProcessor=_AutoImageProcessor,
AutoProcessor=_AutoProcessor,
),
)
loaded = MiniCPMO45Stage0DuplexRuntime._load_processor_from_path(model_id)
assert loaded is processor
assert load_calls == [(model_id, True)]
with pytest.raises(AttributeError, match="__module__"):
_AutoImageProcessor.register("MiniCPMVImageProcessor", object)
def test_minicpmo_stage0_processor_load_preserves_original_exception(monkeypatch):
import sys
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
MiniCPMO45Stage0DuplexRuntime,
)
original = ValueError("invalid processor config")
class _AutoProcessor:
@classmethod
def from_pretrained(cls, model_path, *, trust_remote_code):
del cls, model_path, trust_remote_code
raise original
monkeypatch.setitem(
sys.modules,
"transformers",
_transformers_processor_stub(_AutoProcessor),
)
with pytest.raises(RuntimeError, match="Failed to load MiniCPM-o duplex processor") as exc_info:
MiniCPMO45Stage0DuplexRuntime._load_processor_from_path("openbmb/MiniCPM-o-4_5")
assert exc_info.value.__cause__ is original
def test_minicpmo_stage0_processor_requires_tokenizer(monkeypatch):
import sys
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
MiniCPMO45Stage0DuplexRuntime,
)
class _AutoProcessor:
@classmethod
def from_pretrained(cls, model_path, *, trust_remote_code):
del cls, model_path, trust_remote_code
return object()
monkeypatch.setitem(
sys.modules,
"transformers",
_transformers_processor_stub(_AutoProcessor),
)
with pytest.raises(RuntimeError, match="does not expose a tokenizer"):
MiniCPMO45Stage0DuplexRuntime._load_processor_from_path("openbmb/MiniCPM-o-4_5")
def test_minicpmo_stage0_loaded_processor_validates_special_tokens(monkeypatch):
import sys
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
MiniCPMO45Stage0DuplexRuntime,
)
processor = SimpleNamespace(tokenizer=_minicpmo45_tokenizer_stub())
class _AutoProcessor:
@classmethod
def from_pretrained(cls, model_path, *, trust_remote_code):
del cls, model_path, trust_remote_code
return processor
monkeypatch.setitem(
sys.modules,
"transformers",
_transformers_processor_stub(_AutoProcessor),
)
runtime = MiniCPMO45Stage0DuplexRuntime(
SimpleNamespace(),
model_path="openbmb/MiniCPM-o-4_5",
device="cpu",
)
assert runtime.processor is processor
assert set(runtime._special_token_ids()) == {
"unit_token_id",
"unit_end_token_id",
"listen_token_id",
"speak_token_id",
"tts_bos_token_id",
"tts_eos_token_id",
"tts_pad_token_id",
"chunk_eos_token_id",
"chunk_tts_eos_token_id",
"turn_eos_token_id",
}
def test_minicpmo_stage0_data_plane_prefill_matches_official_unit_format(): def test_minicpmo_stage0_data_plane_prefill_matches_official_unit_format():
import torch import torch
@@ -1150,6 +1323,88 @@ def test_minicpmo_stage0_data_plane_final_first_chunk_does_not_add_silence_unit(
) )
def test_minicpmo_stage0_final_does_not_promote_first_chunk_alignment_tail_to_unit():
import torch
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
MiniCPMO45Stage0DuplexRuntime,
_MiniCPMO45Stage0SessionState,
)
class _StageModel(torch.nn.Module):
first_chunk_ms = 10
chunk_ms = 8
sample_rate = 1000
def __init__(self):
super().__init__()
self.embed = torch.nn.Embedding(256, 2)
def get_input_embeddings(self):
return self.embed
def get_audio_hidden_states(self, _data):
return [torch.tensor([[0.5, 0.5]], dtype=torch.float32)]
class _MelProcessor:
sample_rate = 1000
is_first = True
def get_config(self):
return {"effective_first_chunk_ms": 9}
class _Processor:
def __init__(self):
self._streaming_mel_processor = _MelProcessor()
def get_streaming_chunk_size(self):
return 9 if self._streaming_mel_processor.is_first else 8
def process_audio_streaming(self, audio, **_kwargs):
self._streaming_mel_processor.is_first = False
return {"audio_features": audio, "audio_feature_lens": [[len(audio)]]}
runtime = MiniCPMO45Stage0DuplexRuntime.__new__(MiniCPMO45Stage0DuplexRuntime)
runtime.stage_model = _StageModel()
runtime.thinker = runtime.stage_model
runtime.tokenizer = SimpleNamespace(
unk_token_id=0,
convert_tokens_to_ids=lambda token: {
"<unit>": 1,
"</unit>": 2,
"<|listen|>": 3,
"<|speak|>": 4,
"<|tts_bos|>": 5,
"<|tts_eos|>": 6,
"<|tts_pad|>": 7,
"<|chunk_eos|>": 8,
"<|chunk_tts_eos|>": 9,
"<|turn_eos|>": 10,
"<|audio|>": 11,
}.get(token, 0),
encode=lambda text, add_special_tokens=False: [],
)
runtime.processor = _Processor()
runtime.device = "cpu"
runtime._init_token_ids()
state = _MiniCPMO45Stage0SessionState(session_id="sid-first-chunk-alignment-tail")
first = runtime._stage_prefill_embeddings_only(state, np.zeros(8, dtype=np.float32), seq=1)
second = runtime._stage_prefill_embeddings_only(state, np.zeros(8, dtype=np.float32), seq=2)
final = runtime._stage_prefill_embeddings_only(
state,
np.zeros(8, dtype=np.float32),
seq=3,
final=True,
)
assert first["input_token_ids"].count(1) == 1
assert second["input_token_ids"].count(1) == 1
assert final["input_token_ids"].count(1) == 1
assert state.audio_chunk_idx == 3
assert len(state.audio_buffer) == 1
def test_minicpmo_stage0_runtime_uses_loaded_vllm_embed_tokens_when_get_input_embeddings_is_broken(): def test_minicpmo_stage0_runtime_uses_loaded_vllm_embed_tokens_when_get_input_embeddings_is_broken():
import torch import torch
@@ -1319,7 +1574,7 @@ def test_minicpmo_stage0_native_sampler_penalizes_text_from_prior_chunk():
model.thinker = SimpleNamespace(get_tokenizer=lambda: _Tokenizer()) model.thinker = SimpleNamespace(get_tokenizer=lambda: _Tokenizer())
session_key = ("sid-cross-chunk-repetition", 0) session_key = ("sid-cross-chunk-repetition", 0)
state = _MiniCPMO45Stage0SessionState(session_id=session_key[0]) state = _MiniCPMO45Stage0SessionState(session_id=session_key[0])
state.generated_text_tokens = [198] * 8 state.generated_tokens = [198] * 8
model._minicpmo45_duplex_data_plane_helper = SimpleNamespace(sessions={session_key: state}) model._minicpmo45_duplex_data_plane_helper = SimpleNamespace(sessions={session_key: state})
model._minicpmo45_duplex_row_sessions = {0: session_key} model._minicpmo45_duplex_row_sessions = {0: session_key}
@@ -1381,7 +1636,7 @@ def test_minicpmo_stage0_native_sampler_matches_official_negative_logit_penalty(
state = _MiniCPMO45Stage0SessionState(session_id=session_key[0]) state = _MiniCPMO45Stage0SessionState(session_id=session_key[0])
repeated = 198 repeated = 198
alternative = 1234 alternative = 1234
state.generated_text_tokens = [repeated] state.generated_tokens = [repeated]
model._minicpmo45_duplex_data_plane_helper = SimpleNamespace(sessions={session_key: state}) model._minicpmo45_duplex_data_plane_helper = SimpleNamespace(sessions={session_key: state})
model._minicpmo45_duplex_row_sessions = {0: session_key} model._minicpmo45_duplex_row_sessions = {0: session_key}
@@ -1405,7 +1660,7 @@ def test_minicpmo_stage0_native_sampler_matches_official_negative_logit_penalty(
assert sampled.sampled_token_ids.tolist() == [[repeated]] assert sampled.sampled_token_ids.tolist() == [[repeated]]
def test_minicpmo_stage0_records_only_bounded_text_repetition_history(): def test_minicpmo_stage0_records_bounded_model_policy_history():
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import ( from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
_MiniCPMO45Stage0SessionState, _MiniCPMO45Stage0SessionState,
) )
@@ -1432,15 +1687,15 @@ def test_minicpmo_stage0_records_only_bounded_text_repetition_history():
} }
for sampled in range(1000, 1520): for sampled in range(1000, 1520):
model._record_minicpmo45_duplex_terminator(0, sampled, token_ids) model._record_minicpmo45_duplex_generation_token(0, sampled)
expected_history = list(range(1008, 1520)) expected_history = list(range(1008, 1520))
assert state.generated_text_tokens == expected_history assert state.generated_tokens == expected_history
for sampled in token_ids.values(): for sampled in token_ids.values():
model._record_minicpmo45_duplex_terminator(0, sampled, token_ids) model._record_minicpmo45_duplex_generation_token(0, sampled)
assert state.generated_text_tokens == expected_history assert state.generated_tokens == [*expected_history[len(token_ids) :], *token_ids.values()]
def test_minicpmo_stage0_native_sampler_does_not_override_model_at_punctuation(): def test_minicpmo_stage0_native_sampler_does_not_override_model_at_punctuation():
@@ -2211,3 +2466,165 @@ def test_minicpmo_stage0_session_context_includes_resolved_ref_audio():
assert state.context_token_ids == [1, 2, 3, 151683, 151683, 4, 5] assert state.context_token_ids == [1, 2, 3, 151683, 151683, 4, 5]
assert len(state.context_embeds) == 6 assert len(state.context_embeds) == 6
# ---------------------------------------------------------------------------
# Cross-platform wiring: every AR model runner must reach the duplex hook.
#
# ``GPUARModelRunner`` and ``NPUARModelRunner`` are siblings -- neither inherits
# the other's ``_sample`` -- so the NPU runner once carried a copy of the GPU
# ``_sample`` with the ``prepare_duplex_sampling`` call dropped. That left the
# MiniCPM-o 4.5 thinker without a row -> session map on Ascend, so every mid-turn
# ``<|listen|>`` was emitted verbatim and the model stopped speaking after turn 1.
#
# These checks parse the source instead of importing it: the NPU runner needs
# ``vllm_ascend``, which is absent on the CPU test images that run this file.
# ---------------------------------------------------------------------------
# method that must call it -> mixin method it must call
_REQUIRED_DUPLEX_CALLS = {
"__init__": "_init_duplex_sampling_state",
"load_model": "_resolve_duplex_sampling_hook",
"_update_states": "_update_duplex_sampling_states",
"_sample": "_apply_duplex_sampling",
}
# The sampler ``_sample`` hands the prepared logits to. ``_apply_duplex_sampling``
# both masks those logits (force_listen) and publishes the row -> session map the
# model's own sampler reads, so calling it *after* this is the same silent no-op as
# not calling it at all.
_MODEL_SAMPLER_CALL = "model_sample"
def _repo_root():
from pathlib import Path
return Path(__file__).parents[2]
def _ar_runner_sources():
"""Every AR model runner in the repo, discovered rather than hand-listed.
A new platform runner is picked up automatically instead of silently escaping
the checks below. ``vllm_omni/worker/gpu_ar_model_runner.py`` is named
separately because it does not live under ``platforms/``.
"""
root = _repo_root()
paths = [root / "vllm_omni/worker/gpu_ar_model_runner.py"]
paths.extend(sorted(root.glob("vllm_omni/platforms/*/worker/*_ar_model_runner.py")))
return paths
def _ar_runner_classdefs():
"""``{class_name: (ClassDef, relative_path)}`` for every ``*ARModelRunner``."""
import ast
root = _repo_root()
found = {}
for path in _ar_runner_sources():
module = ast.parse(path.read_text())
for node in module.body:
if isinstance(node, ast.ClassDef) and node.name.endswith("ARModelRunner"):
found[node.name] = (node, str(path.relative_to(root)))
assert found, "no AR model runners discovered; the glob in _ar_runner_sources() is stale"
return found
def _method_calls(method):
"""``{attribute_name: first lineno}`` for every ``x.y(...)`` call in ``method``."""
import ast
calls = {}
for node in ast.walk(method):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
calls.setdefault(node.func.attr, node.lineno)
return calls
def _wires_duplex_sampling_directly(classdef) -> bool:
import ast
bases = {base.id for base in classdef.bases if isinstance(base, ast.Name)}
if "DuplexSamplingRunnerMixin" not in bases:
return False
methods = {node.name: node for node in classdef.body if isinstance(node, ast.FunctionDef)}
return all(
method_name in methods and required_call in _method_calls(methods[method_name])
for method_name, required_call in _REQUIRED_DUPLEX_CALLS.items()
)
def test_every_ar_runner_reaches_the_duplex_sampling_hook():
"""Each AR runner must wire the hook itself or inherit from one that does.
``XPUARModelRunner`` derives from ``GPUARModelRunner`` and so is wired for free;
``NPUARModelRunner`` derives from ``OmniNPUModelRunner``, which is not an AR
runner, so it has to do the wiring itself. Asserting every discovered class
wires directly would wrongly fail the XPU runner.
"""
import ast
classdefs = _ar_runner_classdefs()
wired = {name for name, (classdef, _path) in classdefs.items() if _wires_duplex_sampling_directly(classdef)}
for class_name, (classdef, path) in sorted(classdefs.items()):
if class_name in wired:
continue
base_names = {base.id for base in classdef.bases if isinstance(base, ast.Name)}
assert base_names & wired, (
f"{path}::{class_name} neither wires the duplex sampling hook nor derives from a "
f"runner that does (wired runners: {sorted(wired)}). Inherit DuplexSamplingRunnerMixin "
f"and call {sorted(_REQUIRED_DUPLEX_CALLS.values())} from "
f"{sorted(_REQUIRED_DUPLEX_CALLS)} respectively."
)
@pytest.mark.parametrize("class_name", ["GPUARModelRunner", "NPUARModelRunner"])
def test_ar_runner_calls_every_duplex_sampling_hook_site(class_name: str):
import ast
classdefs = _ar_runner_classdefs()
assert class_name in classdefs, f"{class_name} not found by _ar_runner_sources()"
classdef, path = classdefs[class_name]
bases = {base.id for base in classdef.bases if isinstance(base, ast.Name)}
assert "DuplexSamplingRunnerMixin" in bases, (
f"{path}::{class_name} must inherit DuplexSamplingRunnerMixin so the duplex "
f"sampling hook is wired the same way on every platform"
)
methods = {node.name: node for node in classdef.body if isinstance(node, ast.FunctionDef)}
for method_name, required_call in _REQUIRED_DUPLEX_CALLS.items():
method = methods.get(method_name)
assert method is not None, f"{path}::{class_name}.{method_name} is missing; it must call {required_call}"
calls = _method_calls(method)
assert required_call in calls, f"{path}::{class_name}.{method_name} must call self.{required_call}"
@pytest.mark.parametrize("class_name", ["GPUARModelRunner", "NPUARModelRunner"])
def test_ar_runner_applies_duplex_sampling_before_the_model_sampler(class_name: str):
"""Ordering is load-bearing: the hook must run before the sampler reads the logits."""
import ast
classdef, path = _ar_runner_classdefs()[class_name]
sample = next(node for node in classdef.body if isinstance(node, ast.FunctionDef) and node.name == "_sample")
apply_lineno = _method_calls(sample).get("_apply_duplex_sampling")
sampler_lineno = next(
(
node.lineno
for node in ast.walk(sample)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == _MODEL_SAMPLER_CALL
),
None,
)
assert apply_lineno is not None, f"{path}::{class_name}._sample must call self._apply_duplex_sampling"
assert sampler_lineno is not None, (
f"{path}::{class_name}._sample no longer calls {_MODEL_SAMPLER_CALL}(); update _MODEL_SAMPLER_CALL "
f"so the ordering check keeps testing something"
)
assert apply_lineno < sampler_lineno, (
f"{path}::{class_name}._sample calls self._apply_duplex_sampling at line {apply_lineno}, after "
f"{_MODEL_SAMPLER_CALL}() at line {sampler_lineno}. The hook masks the logits and publishes the "
f"row -> session map the sampler reads, so running it afterwards is a silent no-op."
)
@@ -7,7 +7,8 @@ Dataset source: https://huggingface.co/datasets/liarliar/Daily-Omni
Supports loading QA metadata from: Supports loading QA metadata from:
- Local JSON file (``qa_json_path``): recommended for offline/air-gapped environments - Local JSON file (``qa_json_path``): recommended for offline/air-gapped environments
- HuggingFace datasets (``dataset_path``): legacy online mode - HuggingFace Hub id (``dataset_path``): resolved with ``snapshot_download`` to the repo's
``qa.json``, which reads the hub cache and therefore also works under ``HF_HUB_OFFLINE``
Video/audio files normally come from extracted ``Videos.tar``. When ``--daily-omni-video-dir`` Video/audio files normally come from extracted ``Videos.tar``. When ``--daily-omni-video-dir``
is not set, the first request that needs on-disk media downloads that archive from the Hugging Face is not set, the first request that needs on-disk media downloads that archive from the Hugging Face
@@ -24,9 +25,9 @@ Why ``BenchmarkDataset`` instead of ``HuggingFaceDataset``?
This class therefore inherits only ``BenchmarkDataset`` (minimal: ``dataset_path``, This class therefore inherits only ``BenchmarkDataset`` (minimal: ``dataset_path``,
``random_seed``, ``self.data``) and implements **two explicit loaders**: ``random_seed``, ``self.data``) and implements **two explicit loaders**:
``_load_from_local_json`` (default path for air-gapped runs) and ``_load_from_huggingface`` ``_load_from_local_json`` (default path for air-gapped runs) and ``_load_from_huggingface``
(optional legacy path for users who prefer ``datasets`` + Hub cache). The latter is **not** (Hub ids, resolved to the repo's ``qa.json`` through the hub cache). The latter is **not**
inheritance; it is the same Hub rows as before, factored into a helper so one class can inheritance; it is the same Hub rows as before, factored into a helper so one class can
serve both deployment modes without mandatory ``datasets`` when using ``qa_json_path``. serve both deployment modes without mandatory ``datasets``.
Usage: Usage:
from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniDataset from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniDataset
@@ -38,7 +39,7 @@ Usage:
random_seed=42, random_seed=42,
) )
# HuggingFace mode (legacy, requires network) # Hub id mode (works offline once the repo is in the HF hub cache)
dataset = DailyOmniDataset( dataset = DailyOmniDataset(
dataset_path="liarliar/Daily-Omni", dataset_path="liarliar/Daily-Omni",
dataset_split="train", dataset_split="train",
@@ -52,11 +53,14 @@ Usage:
""" """
import base64 import base64
import io
import json import json
import logging import logging
import math
import os import os
import shutil import shutil
import tarfile import tarfile
import wave
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
@@ -77,6 +81,10 @@ except ImportError:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Official MiniCPM ``get_video_frame_audio_segments`` default (env ``MAX_NUM_FRAMES``).
_MINICPM_MAX_NUM_FRAMES = int(os.environ.get("MAX_NUM_FRAMES", "64"))
_MINICPM_AUDIO_SR = 16000
def _daily_omni_hf_cache_root() -> Path: def _daily_omni_hf_cache_root() -> Path:
return Path(os.environ.get("HF_HOME", Path.home() / ".cache" / "huggingface")).expanduser().resolve() return Path(os.environ.get("HF_HOME", Path.home() / ".cache" / "huggingface")).expanduser().resolve()
@@ -103,47 +111,17 @@ def _daily_omni_find_videos_root_in_extract(tmp: Path) -> Path:
) )
def ensure_daily_omni_hub_videos_dir(repo_id: str) -> Path: def _extract_daily_omni_videos_tar(tar_path: Path, cache_key: str) -> Path:
"""Download ``Videos.tar`` from the Hugging Face dataset repo and return the ``Videos`` root. """Extract ``Videos.tar`` once into the shared media cache and return the ``Videos`` root.
The returned path matches ``--daily-omni-video-dir`` (directory containing ``{{video_id}}/``). Cached under ``HF_HOME`` / ``vllm_omni/daily_omni_media/<cache_key>``. Reuses a previous
extraction when the tarball fingerprint matches.
Cached under ``HF_HOME`` / ``vllm_omni/daily_omni_media/<repo>``. Reuses extraction when the
tarball fingerprint matches.
Raises:
ImportError: if ``huggingface_hub`` is not installed.
FileNotFoundError / RuntimeError: if the archive is missing or malformed.
""" """
rid = (repo_id or "").strip() safe = cache_key.replace("/", "__").replace("\\", "_")
if not rid:
raise ValueError("repo_id is required to download Daily-Omni Videos.tar")
try:
from huggingface_hub import hf_hub_download
except ImportError as e:
raise ImportError(
"Daily-Omni Hub media download requires huggingface_hub. "
"Install it (e.g. with vLLM) or provide --daily-omni-video-dir with a local extract."
) from e
safe = rid.replace("/", "__").replace("\\", "_")
staging_root = _daily_omni_hf_cache_root() / "vllm_omni" / "daily_omni_media" / safe staging_root = _daily_omni_hf_cache_root() / "vllm_omni" / "daily_omni_media" / safe
videos_dir = staging_root / "Videos" videos_dir = staging_root / "Videos"
marker = staging_root / ".videos_extracted" marker = staging_root / ".videos_extracted"
tar_path: Path | None = None
for fname in ("Videos.tar", "videos.tar"):
try:
tar_path = Path(hf_hub_download(repo_id=rid, filename=fname, repo_type="dataset"))
break
except Exception:
continue
if tar_path is None or not tar_path.is_file():
raise FileNotFoundError(
f"Could not download Videos.tar from Hugging Face dataset {rid!r} (tried Videos.tar / videos.tar)."
)
fp = _daily_omni_tar_fingerprint(tar_path) fp = _daily_omni_tar_fingerprint(tar_path)
if marker.is_file() and videos_dir.is_dir(): if marker.is_file() and videos_dir.is_dir():
try: try:
@@ -160,7 +138,7 @@ def ensure_daily_omni_hub_videos_dir(repo_id: str) -> Path:
shutil.rmtree(work, ignore_errors=True) shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True) work.mkdir(parents=True)
try: try:
logger.info("Extracting Daily-Omni Videos.tar from %s (repo=%s)", tar_path, rid) logger.info("Extracting Daily-Omni Videos.tar from %s (cache_key=%s)", tar_path, cache_key)
with tarfile.open(tar_path, "r:*") as tf: with tarfile.open(tar_path, "r:*") as tf:
tf.extractall(path=work, filter="data") tf.extractall(path=work, filter="data")
found = _daily_omni_find_videos_root_in_extract(work) found = _daily_omni_find_videos_root_in_extract(work)
@@ -171,10 +149,168 @@ def ensure_daily_omni_hub_videos_dir(repo_id: str) -> Path:
shutil.rmtree(work, ignore_errors=True) shutil.rmtree(work, ignore_errors=True)
marker.write_text(fp, encoding="utf-8") marker.write_text(fp, encoding="utf-8")
logger.info("Daily-Omni Hub media ready at %s", videos_dir) logger.info("Daily-Omni media ready at %s", videos_dir)
return videos_dir return videos_dir
def ensure_daily_omni_hub_videos_dir(repo_id: str) -> Path:
"""Download ``Videos.tar`` from the Hugging Face dataset repo and return the ``Videos`` root.
The returned path matches ``--daily-omni-video-dir`` (directory containing ``{{video_id}}/``).
Raises:
ImportError: if ``huggingface_hub`` is not installed.
FileNotFoundError / RuntimeError: if the archive is missing or malformed.
"""
rid = (repo_id or "").strip()
if not rid:
raise ValueError("repo_id is required to download Daily-Omni Videos.tar")
try:
from huggingface_hub import hf_hub_download
except ImportError as e:
raise ImportError(
"Daily-Omni Hub media download requires huggingface_hub. "
"Install it (e.g. with vLLM) or provide --daily-omni-video-dir with a local extract."
) from e
tar_path: Path | None = None
for fname in ("Videos.tar", "videos.tar"):
try:
tar_path = Path(hf_hub_download(repo_id=rid, filename=fname, repo_type="dataset"))
break
except Exception:
continue
if tar_path is None or not tar_path.is_file():
raise FileNotFoundError(
f"Could not download Videos.tar from Hugging Face dataset {rid!r} (tried Videos.tar / videos.tar)."
)
return _extract_daily_omni_videos_tar(tar_path, rid)
def _resolve_hf_cache_snapshot(path: Path) -> Path:
"""Map a HF hub cache dir (``datasets--org--name``) to its checked-out snapshot dir.
Any other directory is returned unchanged.
"""
snapshots = path / "snapshots"
if not snapshots.is_dir():
return path
ref = path / "refs" / "main"
if ref.is_file():
try:
target = snapshots / ref.read_text(encoding="utf-8").strip()
except OSError:
target = snapshots
if target.is_dir():
return target
revisions = [p for p in snapshots.iterdir() if p.is_dir()]
if revisions:
return max(revisions, key=lambda p: p.stat().st_mtime)
return path
#: QA metadata pulled by :func:`ensure_daily_omni_hub_root`. ``Videos.tar`` is deliberately left
#: out: it is ~60GB and only :func:`ensure_daily_omni_hub_videos_dir` needs it, lazily, once a
#: request actually asks for media.
_DAILY_OMNI_QA_ALLOW_PATTERNS = ["qa.json", "QA.json"]
def ensure_daily_omni_hub_root(repo_id: str) -> Path:
"""Resolve a Daily-Omni Hub id to its snapshot directory, downloading QA metadata if needed.
Mirrors :func:`...videomme_dataset.ensure_videomme_hub_root` and Seed-TTS: ``snapshot_download``
resolves the repo through the **hub cache**, so an air-gapped run (``HF_HUB_OFFLINE=1``) keeps
working as long as the dataset was pre-staged there. ``datasets.load_dataset`` cannot do this —
it only consults the separate *datasets* cache, which a ``hf download`` never populates.
Raises:
ImportError: if ``huggingface_hub`` is not installed.
ValueError: if ``repo_id`` is empty.
"""
rid = (repo_id or "").strip()
if not rid:
raise ValueError("repo_id is required to download Daily-Omni from Hugging Face")
try:
from huggingface_hub import snapshot_download
except ImportError as e:
raise ImportError(
"Install huggingface_hub to load Daily-Omni from the Hub, or pass a local "
"--daily-omni-qa-json / --daily-omni-video-dir mirror."
) from e
cache = snapshot_download(
repo_id=rid,
repo_type="dataset",
allow_patterns=_DAILY_OMNI_QA_ALLOW_PATTERNS,
)
root = _resolve_hf_cache_snapshot(Path(cache).resolve())
logger.info("Daily-Omni Hub snapshot ready at %s (repo=%s)", root, rid)
return root
def _daily_omni_qa_load_error(
repo_id: str | None,
snapshot_error: Exception | None,
datasets_error: Exception,
) -> RuntimeError:
"""Compose an actionable error after both QA-metadata sources failed.
Offline CI hits this when the dataset is missing from the hub cache *or* staged under a
different repo id, so the message names both attempts and the flags that bypass them.
"""
parts = [f"Could not load Daily-Omni QA metadata for {repo_id!r}."]
if snapshot_error is not None:
parts.append(f"huggingface_hub snapshot_download failed: {type(snapshot_error).__name__}: {snapshot_error}")
parts.append(f"datasets.load_dataset failed: {type(datasets_error).__name__}: {datasets_error}")
parts.append(
"For offline runs, pre-stage the dataset in the HF hub cache "
"(`hf download --repo-type dataset <repo>`) and make sure the repo id matches, "
"or pass --daily-omni-qa-json / --daily-omni-video-dir pointing at a local mirror."
)
return RuntimeError(" ".join(parts))
def resolve_daily_omni_local_root(dataset_path: str | None) -> Path | None:
"""Return the local Daily-Omni mirror root for ``dataset_path``, or ``None`` if not local.
Accepts a plain directory (``qa.json`` + ``Videos/`` or ``Videos.tar``, i.e. the layout of the
Hub repo) as well as a HuggingFace hub cache directory (``datasets--liarliar--Daily-Omni``),
which is resolved to its snapshot. Hub ids such as ``liarliar/Daily-Omni`` return ``None``.
"""
raw = (dataset_path or "").strip()
if not raw:
return None
path = Path(raw).expanduser()
if not path.is_dir():
return None
return _resolve_hf_cache_snapshot(path.resolve())
def daily_omni_local_qa_json(root: Path) -> Path | None:
"""Locate ``qa.json`` inside a local Daily-Omni mirror (``None`` when absent)."""
for name in ("qa.json", "QA.json"):
candidate = root / name
if candidate.is_file():
return candidate
return None
def daily_omni_local_videos_dir(root: Path) -> Path | None:
"""Return the ``Videos`` root of a local mirror, extracting ``Videos.tar`` when needed."""
for name in ("Videos", "videos"):
candidate = root / name
if candidate.is_dir():
return candidate
for name in ("Videos.tar", "videos.tar"):
tar_path = root / name
if tar_path.is_file():
return _extract_daily_omni_videos_tar(tar_path, f"local__{root.name}")
return None
class _ListDatasetIterator: class _ListDatasetIterator:
"""Simple iterator wrapper around a list to mimic HuggingFace streaming dataset behavior.""" """Simple iterator wrapper around a list to mimic HuggingFace streaming dataset behavior."""
@@ -203,12 +339,130 @@ class _ListDatasetIterator:
# Aligns with Lliar-liar/Daily-Omni CLI ``--input_mode`` (test_model/*/testmodel.py). # Aligns with Lliar-liar/Daily-Omni CLI ``--input_mode`` (test_model/*/testmodel.py).
DailyOmniInputMode = Literal["all", "visual", "audio"] DailyOmniInputMode = Literal["all", "visual", "audio"]
# How multimodal parts are packed into OpenAI chat messages:
# - ``qwen``: one ``video_url`` + one ``audio_url`` (upstream Daily-Omni / Qwen protocol).
# - ``minicpm-interleave``: 1fps ``[image_i, audio_i, ...]`` matching OpenBMB MiniCPM-o
# ``get_video_frame_audio_segments`` + interleaved omni contents (paper ~80% recipe).
DailyOmniPackMode = Literal["qwen", "minicpm-interleave"]
# ``build_conversation()`` in Daily-Omni ``test_model/Qwen2.5-Omni/testmodel.py`` (verbatim). # ``build_conversation()`` in Daily-Omni ``test_model/Qwen2.5-Omni/testmodel.py`` (verbatim).
DAILY_OMNI_SYSTEM_TEXT = ( DAILY_OMNI_SYSTEM_TEXT = (
"You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, " "You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, "
"capable of perceiving auditory and visual inputs, as well as generating text and speech." "capable of perceiving auditory and visual inputs, as well as generating text and speech."
) )
# MiniCPM-o ``get_sys_prompt(mode="omni")`` without ref-audio is an empty system string.
MINICPM_OMNI_SYSTEM_TEXT = ""
def _uniform_sample_indices(n: int, k: int) -> list[int]:
"""Evenly pick ``k`` indices from ``0..n-1`` (MiniCPM ``uniform_sample`` on index lists)."""
if k <= 0:
return []
if n <= k:
return list(range(n))
# Match numpy linspace(..., dtype=int) used upstream.
try:
import numpy as np
return [int(i) for i in np.linspace(0, n - 1, k, dtype=int)]
except ImportError:
if k == 1:
return [0]
return [int(round(i * (n - 1) / (k - 1))) for i in range(k)]
def _probe_video_frames_and_fps(video_path: Path) -> tuple[int, float]:
"""Frame count and average FPS, replacing ``len(vr)`` / ``vr.get_avg_fps()``.
PyAV is used instead of ``decord`` because ``decord`` ships x86_64-only Linux wheels
and is unmaintained, while ``av`` is already a first-class requirement and has
aarch64 wheels (Ascend hosts).
"""
import av
with av.open(str(video_path)) as container:
stream = container.streams.video[0]
avg_fps = float(stream.average_rate) if stream.average_rate else 0.0
num_frames = stream.frames or 0
if num_frames <= 0:
# Containers such as raw webm leave ``frames`` at 0; derive it from duration.
duration: float | None = None
if stream.duration is not None and stream.time_base is not None:
duration = float(stream.duration * stream.time_base)
elif container.duration is not None:
duration = container.duration / av.time_base
if duration and avg_fps:
num_frames = int(math.floor(duration * avg_fps))
if num_frames <= 0:
# Last resort for streams with no usable metadata at all.
stream.thread_type = "AUTO"
num_frames = sum(1 for _ in container.decode(stream))
if num_frames <= 0:
raise ValueError(f"No decodable video frames in {video_path}")
return num_frames, avg_fps or 1.0
def _decode_video_frames_rgb(video_path: Path, frame_idx: list[int]) -> list[Any]:
"""Decode presentation-order ``frame_idx`` into RGB ``PIL.Image``s.
Mirrors ``decord.VideoReader.get_batch(frame_idx)``: one image per requested index,
in the requested order, duplicates repeated. Decoding is a single forward pass
(no seeking) so the mapping from index to picture matches decord's indexing.
"""
import av
wanted = sorted({int(i) for i in frame_idx})
if not wanted:
return []
decoded: dict[int, Any] = {}
with av.open(str(video_path)) as container:
stream = container.streams.video[0]
stream.thread_type = "AUTO"
cursor = 0
for position, frame in enumerate(container.decode(stream)):
if wanted[cursor] > position:
continue
image = frame.to_image()
# ``<=`` also absorbs indices that overshoot the real frame count.
while cursor < len(wanted) and wanted[cursor] <= position:
decoded[wanted[cursor]] = image
cursor += 1
if cursor >= len(wanted):
break
if not decoded:
raise ValueError(f"Decoded no frames from {video_path} for indices {wanted[:8]}")
# Metadata may promise more frames than the stream actually decodes; clamp to the last one.
last_image = decoded[max(decoded)]
return [decoded.get(int(i), last_image) for i in frame_idx]
def _numpy_to_wav_bytes(audio_np: Any, sample_rate: int = _MINICPM_AUDIO_SR) -> bytes:
"""Encode a mono float/int numpy waveform as 16-bit PCM WAV bytes."""
import numpy as np
arr = np.asarray(audio_np)
if arr.ndim > 1:
arr = arr.reshape(-1)
if arr.dtype != np.int16:
arr = np.clip(arr.astype(np.float64), -1.0, 1.0)
arr = (arr * 32767.0).astype(np.int16)
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(arr.tobytes())
return buf.getvalue()
def _pil_to_jpeg_bytes(image: Any, quality: int = 85) -> bytes:
buf = io.BytesIO()
image.save(buf, format="JPEG", quality=quality)
return buf.getvalue()
@dataclass @dataclass
class DailyOmniSampleRequest(SampleRequest): class DailyOmniSampleRequest(SampleRequest):
@@ -242,7 +496,8 @@ class DailyOmniDataset(BenchmarkDataset):
QA metadata can be loaded from: QA metadata can be loaded from:
- Local JSON file (``qa_json_path``): recommended for offline/air-gapped environments - Local JSON file (``qa_json_path``): recommended for offline/air-gapped environments
- HuggingFace datasets (``dataset_path``): legacy online mode - HuggingFace Hub id (``dataset_path``): the repo's ``qa.json`` via ``snapshot_download``
(hub-cache backed, offline-safe); ``datasets.load_dataset`` only as a parquet fallback
Video/audio files normally come from extracted ``Videos.tar``. When ``video_dir`` is not set, Video/audio files normally come from extracted ``Videos.tar``. When ``video_dir`` is not set,
the first sample that needs on-disk media downloads that archive from the Hugging Face dataset the first sample that needs on-disk media downloads that archive from the Hugging Face dataset
@@ -253,7 +508,8 @@ class DailyOmniDataset(BenchmarkDataset):
qa_json_path: Path to local qa.json file (offline mode, preferred). When provided, qa_json_path: Path to local qa.json file (offline mode, preferred). When provided,
``dataset_path`` and ``dataset_split`` are ignored. ``dataset_path`` and ``dataset_split`` are ignored.
dataset_path: HuggingFace dataset path (e.g., "liarliar/Daily-Omni"). Used only if dataset_path: HuggingFace dataset path (e.g., "liarliar/Daily-Omni"). Used only if
``qa_json_path`` is not provided (legacy online mode). ``qa_json_path`` is not provided; resolved through the hub cache, so a pre-staged
repo also loads with ``HF_HUB_OFFLINE=1``.
dataset_split: Dataset split to use (default: "train"). Used only in online mode. dataset_split: Dataset split to use (default: "train"). Used only in online mode.
random_seed: Random seed for shuffling random_seed: Random seed for shuffling
video_dir: Directory containing extracted video files (default: None; may be filled lazily video_dir: Directory containing extracted video files (default: None; may be filled lazily
@@ -262,6 +518,11 @@ class DailyOmniDataset(BenchmarkDataset):
``all`` — video + WAV (default; official audio-visual protocol); ``all`` — video + WAV (default; official audio-visual protocol);
``visual`` — video only; ``visual`` — video only;
``audio`` — extracted WAV only (requires ``{video_id}/{video_id}_audio.wav`` under ``video_dir``). ``audio`` — extracted WAV only (requires ``{video_id}/{video_id}_audio.wav`` under ``video_dir``).
pack_mode: Multimodal packing recipe:
``qwen`` — one ``video_url`` + optional ``audio_url`` (default; Daily-Omni/Qwen);
``minicpm-interleave`` — MiniCPM-o paper recipe: 1fps frames interleaved with
matching 1s audio segments as ``image_url``/``audio_url`` pairs (required to approach
OpenBMB ~80% Daily-Omni; needs local video+WAV under ``video_dir``).
max_duration_seconds: Reserved for future ffprobe-based filtering; currently **not applied** max_duration_seconds: Reserved for future ffprobe-based filtering; currently **not applied**
when building requests (metadata ``video_duration`` is still passed through for eval). when building requests (metadata ``video_duration`` is still passed through for eval).
dataset_subset: Optional HuggingFace subset name (``load_dataset(..., name=...)``); used by bench dataset_subset: Optional HuggingFace subset name (``load_dataset(..., name=...)``); used by bench
@@ -270,7 +531,8 @@ class DailyOmniDataset(BenchmarkDataset):
inline_local_video: If True, embed local MP4 as ``data:video/mp4;base64,...`` in requests so inline_local_video: If True, embed local MP4 as ``data:video/mp4;base64,...`` in requests so
the API server does not need ``--allowed-local-media-path`` (large JSON; use for small runs). the API server does not need ``--allowed-local-media-path`` (large JSON; use for small runs).
When ``input_mode`` is ``audio`` or ``all``, local WAV is embedded the same way When ``input_mode`` is ``audio`` or ``all``, local WAV is embedded the same way
(``data:audio/wav;base64,...``). (``data:audio/wav;base64,...``). For ``minicpm-interleave``, embeds JPEG/WAV segment
data URLs instead of the raw MP4.
trust_remote_code: Whether to trust remote code when loading HuggingFace dataset trust_remote_code: Whether to trust remote code when loading HuggingFace dataset
(online mode only). (online mode only).
""" """
@@ -291,6 +553,7 @@ class DailyOmniDataset(BenchmarkDataset):
random_seed: int = 0, random_seed: int = 0,
video_dir: str | None = None, video_dir: str | None = None,
input_mode: DailyOmniInputMode = "all", input_mode: DailyOmniInputMode = "all",
pack_mode: DailyOmniPackMode = "qwen",
inline_local_video: bool = False, inline_local_video: bool = False,
trust_remote_code: bool = False, trust_remote_code: bool = False,
max_duration_seconds: float | None = None, max_duration_seconds: float | None = None,
@@ -300,6 +563,8 @@ class DailyOmniDataset(BenchmarkDataset):
) -> None: ) -> None:
if input_mode not in ("all", "visual", "audio"): if input_mode not in ("all", "visual", "audio"):
raise ValueError(f"input_mode must be 'all', 'visual', or 'audio', got {input_mode!r}") raise ValueError(f"input_mode must be 'all', 'visual', or 'audio', got {input_mode!r}")
if pack_mode not in ("qwen", "minicpm-interleave"):
raise ValueError(f"pack_mode must be 'qwen' or 'minicpm-interleave', got {pack_mode!r}")
# Validate arguments: need either local JSON or HF path # Validate arguments: need either local JSON or HF path
if qa_json_path is None and dataset_path is None: if qa_json_path is None and dataset_path is None:
@@ -318,11 +583,14 @@ class DailyOmniDataset(BenchmarkDataset):
self.video_dir = Path(video_dir) if video_dir else None self.video_dir = Path(video_dir) if video_dir else None
self.inline_local_video = inline_local_video self.inline_local_video = inline_local_video
self.input_mode: DailyOmniInputMode = input_mode self.input_mode: DailyOmniInputMode = input_mode
self.pack_mode: DailyOmniPackMode = pack_mode
self.max_duration_seconds = max_duration_seconds self.max_duration_seconds = max_duration_seconds
self.trust_remote_code = trust_remote_code self.trust_remote_code = trust_remote_code
#: In-process cache of ffprobe durations only (no disk persistence). #: In-process cache of ffprobe durations only (no disk persistence).
self._video_durations: dict[str, float] = {} self._video_durations: dict[str, float] = {}
#: Cache of MiniCPM 1fps interleaved expansions: video_id -> content parts.
self._minicpm_interleave_cache: dict[str, list[dict[str, Any]]] = {}
# Initialize parent BenchmarkDataset # Initialize parent BenchmarkDataset
super().__init__( super().__init__(
@@ -336,11 +604,13 @@ class DailyOmniDataset(BenchmarkDataset):
# Verify dataset info # Verify dataset info
logger.info( logger.info(
"Loaded Daily-Omni dataset: mode=%s, source=%s, random_seed=%d, input_mode=%s, max_duration=%s", "Loaded Daily-Omni dataset: mode=%s, source=%s, random_seed=%d, "
"input_mode=%s, pack_mode=%s, max_duration=%s",
"local_json" if self.qa_json_path else "huggingface", "local_json" if self.qa_json_path else "huggingface",
str(self.qa_json_path) if self.qa_json_path else f"{dataset_path}/{dataset_split}", str(self.qa_json_path) if self.qa_json_path else f"{dataset_path}/{dataset_split}",
random_seed, random_seed,
input_mode, input_mode,
pack_mode,
f"{max_duration_seconds}s" if max_duration_seconds else "unlimited", f"{max_duration_seconds}s" if max_duration_seconds else "unlimited",
) )
@@ -397,21 +667,42 @@ class DailyOmniDataset(BenchmarkDataset):
self.data = _ListDatasetIterator(data) self.data = _ListDatasetIterator(data)
def _load_from_huggingface(self) -> None: def _load_from_huggingface(self) -> None:
"""Load QA rows via ``datasets.load_dataset`` (legacy / convenience path). """Load QA rows for a Hub id, preferring the repo's ``qa.json`` over ``datasets``.
Kept for backward compatibility: callers can still pass ``dataset_path=liarliar/Daily-Omni`` The Hub repo ships ``qa.json`` (1,197 rows) next to ``Videos.tar``, so the primary path is
and get the same parquet-backed rows as the Hub dataset card, with streaming (or :func:`ensure_daily_omni_hub_root` → :meth:`_load_from_local_json`: ``snapshot_download``
non-streaming if ``no_stream=True``) and shuffle. reads the hub cache and therefore works under ``HF_HUB_OFFLINE``, the same contract as
Seed-TTS and Video-MME. ``datasets.load_dataset`` remains only as a fallback for mirrors
that publish parquet rows instead of ``qa.json``; it consults the *datasets* cache rather
than the hub cache and streams shards over HTTP, so it cannot serve air-gapped runs.
This is intentionally **not** implemented by subclassing ``HuggingFaceDataset``: that base This is intentionally **not** implemented by subclassing ``HuggingFaceDataset``: that base
always runs Hub ``load_dataset`` from its constructor and expects a Hub id as the primary always runs Hub ``load_dataset`` from its constructor and expects a Hub id as the primary
API; Daily-Omni instead chooses the source in ``load_data()`` (JSON vs Hub) while sharing API; Daily-Omni instead chooses the source in ``load_data()`` (JSON vs Hub) while sharing
one ``sample()`` / request-building implementation for both. one ``sample()`` / request-building implementation for both.
""" """
snapshot_error: Exception | None = None
try:
root = ensure_daily_omni_hub_root(self.dataset_path)
except Exception as e: # noqa: BLE001 - any failure just falls through to `datasets`
snapshot_error = e
else:
qa_json = daily_omni_local_qa_json(root)
if qa_json is not None:
self.qa_json_path = qa_json
logger.info("Loading Daily-Omni QA metadata from Hub snapshot: %s", qa_json)
self._load_from_local_json()
return
logger.info(
"Hub snapshot %s ships no qa.json; falling back to `datasets.load_dataset`",
root,
)
if load_dataset is None: if load_dataset is None:
raise ImportError( raise _daily_omni_qa_load_error(
"datasets library is required for HuggingFace mode. " self.dataset_path,
"Install with: pip install datasets, or use local JSON mode instead." snapshot_error,
ImportError("datasets library is not installed (pip install datasets)"),
) )
load_kw: dict[str, Any] = { load_kw: dict[str, Any] = {
@@ -421,7 +712,10 @@ class DailyOmniDataset(BenchmarkDataset):
} }
if self.dataset_subset is not None: if self.dataset_subset is not None:
load_kw["name"] = self.dataset_subset load_kw["name"] = self.dataset_subset
ds = load_dataset(self.dataset_path, **load_kw) try:
ds = load_dataset(self.dataset_path, **load_kw)
except Exception as e:
raise _daily_omni_qa_load_error(self.dataset_path, snapshot_error, e) from e
if not getattr(self, "disable_shuffle", False): if not getattr(self, "disable_shuffle", False):
ds = ds.shuffle(seed=self.random_seed) ds = ds.shuffle(seed=self.random_seed)
self.data = ds self.data = ds
@@ -575,7 +869,8 @@ class DailyOmniDataset(BenchmarkDataset):
messages = self._build_daily_omni_openai_messages(mm_payload, question, choice) messages = self._build_daily_omni_openai_messages(mm_payload, question, choice)
user_text = self._official_daily_omni_user_prompt(question, choice) user_text = self._official_daily_omni_user_prompt(question, choice)
# Text-only length estimate (same as before: no MM token count in bench). # Text-only length estimate (same as before: no MM token count in bench).
prompt_len = len(tokenizer.encode(f"{DAILY_OMNI_SYSTEM_TEXT}\n{user_text}")) system_text = self._system_text_for_pack_mode()
prompt_len = len(tokenizer.encode(f"{system_text}\n{user_text}" if system_text else user_text))
return DailyOmniSampleRequest( return DailyOmniSampleRequest(
prompt=user_text, prompt=user_text,
@@ -766,10 +1061,11 @@ class DailyOmniDataset(BenchmarkDataset):
video_id: str, video_id: str,
video_url: str | None, video_url: str | None,
) -> tuple[dict[str, Any] | list[dict[str, Any]] | None, dict[str, Any] | None, Literal["first", "last"]]: ) -> tuple[dict[str, Any] | list[dict[str, Any]] | None, dict[str, Any] | None, Literal["first", "last"]]:
"""Build ``multi_modal_data`` and request extras for the active ``input_mode``. """Build multimodal OpenAI content parts and request extras for the active modes."""
if self.pack_mode == "minicpm-interleave":
return self._compose_minicpm_interleaved_multimodal(video_id, video_url)
Mirrors upstream Daily-Omni: separate video + WAV with ``use_audio_in_video=False``. # Qwen / upstream Daily-Omni: separate video + WAV with ``use_audio_in_video=False``.
"""
extra: dict[str, Any] = {"mm_processor_kwargs": {"use_audio_in_video": False}} extra: dict[str, Any] = {"mm_processor_kwargs": {"use_audio_in_video": False}}
mode = self.input_mode mode = self.input_mode
@@ -787,6 +1083,215 @@ class DailyOmniDataset(BenchmarkDataset):
return None, None, "first" return None, None, "first"
return [v, a], extra, "first" return [v, a], extra, "first"
def _compose_minicpm_interleaved_multimodal(
self,
video_id: str,
video_url: str | None,
) -> tuple[list[dict[str, Any]] | None, dict[str, Any] | None, Literal["first", "last"]]:
"""Pack media like MiniCPM-o official omni chat: 1fps ``[frame_i, audio_i, ...]``.
OpenBMB's HF / utils path expands ``video_url`` with ``use_audio=True`` into interleaved
PIL frames and 1s audio ndarrays. vLLM-Omni does not implement that expansion, so the
bench client builds ordered ``image_url`` / ``audio_url`` parts instead. MiniCPM-o 5.0's
OpenAI chat template preserves content-part order, so placeholders stay time-aligned.
``max_slice_nums=1`` and ``use_image_id=False`` mirror MiniCPM-o's omni path
(``modeling_minicpmo.py`` passes both for interleaved frames): slicing every frame with
the config default of 9 would blow past ``max_model_len``, and ``<image_id>N</image_id>``
prefixes would label each frame as a separate picture instead of a video timeline.
"""
del video_url # Hub HTTP URLs are not used; local extract is required.
extra: dict[str, Any] = {
"mm_processor_kwargs": {
"use_audio_in_video": False,
"max_slice_nums": 1,
"use_image_id": False,
}
}
mode = self.input_mode
if mode == "audio":
# Fall back to whole-WAV for audio-only ablations.
a = self._get_audio_content(video_id)
return ([a] if a else None), extra, "first"
parts = self._get_minicpm_interleaved_parts(video_id, include_audio=(mode == "all"))
if not parts:
return None, None, "first"
return parts, extra, "first"
def _get_minicpm_interleaved_parts(
self,
video_id: str,
*,
include_audio: bool,
) -> list[dict[str, Any]] | None:
"""Return cached or freshly extracted MiniCPM-style interleaved OpenAI content parts."""
cache_key = f"{video_id}|audio={int(include_audio)}|inline={int(self.inline_local_video)}"
cached = self._minicpm_interleave_cache.get(cache_key)
if cached is not None:
return cached
self._lazy_ensure_hub_media_dir()
video_path = self._resolve_local_video_path(video_id)
if video_path is None:
logger.warning(
"MiniCPM interleave pack requires local video under video_dir=%s for video_id=%r",
self.video_dir,
video_id,
)
return None
audio_path = self._resolve_local_audio_path(video_id) if include_audio else None
if include_audio and audio_path is None:
logger.warning(
"MiniCPM interleave pack (input_mode=all) missing WAV for video_id=%r under %s",
video_id,
self.video_dir,
)
return None
try:
frames, audio_segments = self._extract_minicpm_frame_audio_segments(
video_path,
audio_path=audio_path,
include_audio=include_audio,
)
except Exception:
logger.exception(
"Failed MiniCPM 1fps extract for video_id=%r path=%s",
video_id,
video_path,
)
return None
if not frames:
logger.warning("No frames extracted for video_id=%r", video_id)
return None
if include_audio and len(audio_segments) != len(frames):
logger.warning(
"Frame/audio length mismatch for video_id=%r: frames=%d audios=%d",
video_id,
len(frames),
len(audio_segments),
)
n = min(len(frames), len(audio_segments))
frames = frames[:n]
audio_segments = audio_segments[:n]
cache_root: Path | None = None
if not self.inline_local_video:
assert self.video_dir is not None
cache_root = self.video_dir / ".minicpm_daily_omni_interleave" / video_id
cache_root.mkdir(parents=True, exist_ok=True)
parts: list[dict[str, Any]] = []
for i, frame in enumerate(frames):
jpeg = _pil_to_jpeg_bytes(frame)
if self.inline_local_video:
b64 = base64.b64encode(jpeg).decode("ascii")
parts.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
}
)
else:
assert cache_root is not None
frame_path = cache_root / f"frame_{i:04d}.jpg"
if not frame_path.is_file() or frame_path.stat().st_size == 0:
frame_path.write_bytes(jpeg)
parts.append(
{
"type": "image_url",
"image_url": {"url": frame_path.expanduser().resolve().as_uri()},
}
)
if include_audio:
wav = _numpy_to_wav_bytes(audio_segments[i])
if self.inline_local_video:
b64 = base64.b64encode(wav).decode("ascii")
parts.append(
{
"type": "audio_url",
"audio_url": {"url": f"data:audio/wav;base64,{b64}"},
}
)
else:
assert cache_root is not None
audio_seg_path = cache_root / f"audio_{i:04d}.wav"
if not audio_seg_path.is_file() or audio_seg_path.stat().st_size == 0:
audio_seg_path.write_bytes(wav)
parts.append(
{
"type": "audio_url",
"audio_url": {"url": audio_seg_path.expanduser().resolve().as_uri()},
}
)
# File URLs are tiny; cache them so repeated QA on the same video skips re-extract.
# Inline base64 must not be retained: ~700 Daily-Omni videos would pin GBs of RSS.
if not self.inline_local_video:
self._minicpm_interleave_cache[cache_key] = parts
logger.debug(
"MiniCPM interleave packed video_id=%r frames=%d include_audio=%s parts=%d",
video_id,
len(frames),
include_audio,
len(parts),
)
return parts
@staticmethod
def _extract_minicpm_frame_audio_segments(
video_path: Path,
*,
audio_path: Path | None,
include_audio: bool,
max_num_frames: int = _MINICPM_MAX_NUM_FRAMES,
) -> tuple[list[Any], list[Any]]:
"""Port of MiniCPM ``get_video_frame_audio_segments`` (stack_frames=1, 1fps)."""
import numpy as np
from vllm.multimodal.media.audio import load_audio
num_video_frames, avg_fps = _probe_video_frames_and_fps(video_path)
duration = num_video_frames / avg_fps
num_seconds = max(1, int(math.ceil(duration)))
second_timestamps = list(range(num_seconds))
if duration > max_num_frames:
timestamps = [round(i * 0.1, 1) for i in range(int(duration / 0.1))]
frame_idx = [min(int(ts * avg_fps), num_video_frames - 1) for ts in timestamps]
pick = _uniform_sample_indices(len(frame_idx), max_num_frames)
frame_idx = [frame_idx[i] for i in pick]
timestamps = [timestamps[i] for i in pick]
else:
frame_idx = [min(int(i * avg_fps), num_video_frames - 1) for i in range(num_seconds)]
timestamps = second_timestamps
frames = _decode_video_frames_rgb(video_path, frame_idx)
audio_segments: list[Any] = []
if include_audio:
load_path = str(audio_path) if audio_path is not None else str(video_path)
audio_np, sr = load_audio(load_path, sr=_MINICPM_AUDIO_SR, mono=True)
for i, start_time in enumerate(timestamps):
end_time = timestamps[i + 1] if i < len(timestamps) - 1 else duration
start_sample = int(start_time * sr)
end_sample = int(end_time * sr)
segment = audio_np[start_sample:end_sample]
if i == len(timestamps) - 1 and len(segment) < 1600:
segment = np.concatenate([segment, np.zeros(1600 - len(segment), dtype=segment.dtype)])
if len(segment) == 0:
segment = np.zeros(1600, dtype=np.float32)
audio_segments.append(segment)
return frames, audio_segments
def _system_text_for_pack_mode(self) -> str:
if self.pack_mode == "minicpm-interleave":
return MINICPM_OMNI_SYSTEM_TEXT
return DAILY_OMNI_SYSTEM_TEXT
@staticmethod @staticmethod
def _media_desc_for_official_prompt(mode: DailyOmniInputMode) -> str: def _media_desc_for_official_prompt(mode: DailyOmniInputMode) -> str:
"""``media_desc`` in upstream ``build_conversation``.""" """``media_desc`` in upstream ``build_conversation``."""
@@ -835,14 +1340,16 @@ class DailyOmniDataset(BenchmarkDataset):
question: str, question: str,
choice: Any, choice: Any,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Map upstream conversation to OpenAI Chat Completions ``messages`` (video_url / audio_url parts).""" """Map upstream conversation to OpenAI Chat Completions ``messages``."""
user_text = self._official_daily_omni_user_prompt(question, choice) user_text = self._official_daily_omni_user_prompt(question, choice)
mm_list: list[dict[str, Any]] = mm_payload if isinstance(mm_payload, list) else [mm_payload] mm_list: list[dict[str, Any]] = mm_payload if isinstance(mm_payload, list) else [mm_payload]
user_content: list[dict[str, Any]] = [*mm_list, {"type": "text", "text": user_text}] user_content: list[dict[str, Any]] = [*mm_list, {"type": "text", "text": user_text}]
return [ system_text = self._system_text_for_pack_mode()
{"role": "system", "content": [{"type": "text", "text": DAILY_OMNI_SYSTEM_TEXT}]}, messages: list[dict[str, Any]] = []
{"role": "user", "content": user_content}, if system_text:
] messages.append({"role": "system", "content": [{"type": "text", "text": system_text}]})
messages.append({"role": "user", "content": user_content})
return messages
def sample_by_task_type( def sample_by_task_type(
self, self,
@@ -900,6 +1407,7 @@ class DailyOmniDataset(BenchmarkDataset):
f"dataset_split={self.dataset_split!r}, " f"dataset_split={self.dataset_split!r}, "
f"video_dir={self.video_dir!r}, " f"video_dir={self.video_dir!r}, "
f"input_mode={self.input_mode!r}, " f"input_mode={self.input_mode!r}, "
f"pack_mode={self.pack_mode!r}, "
f"inline_local_video={self.inline_local_video!r}, " f"inline_local_video={self.inline_local_video!r}, "
f"max_duration_seconds={self.max_duration_seconds}, " f"max_duration_seconds={self.max_duration_seconds}, "
f"random_seed={self.random_seed}" f"random_seed={self.random_seed}"
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more