Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce15ab7a0 | ||
|
|
584d78c673 | ||
|
|
89015d5cad | ||
|
|
348cf95a89 | ||
|
|
22dd96ec5c | ||
|
|
e04210d690 | ||
|
|
4ab1b4ff9c | ||
|
|
06e733d3cd | ||
|
|
af3faa6c1e | ||
|
|
322600bfb2 | ||
|
|
c4a14091c9 | ||
|
|
6d4e4ffc7c | ||
|
|
f97af75d1d | ||
|
|
73a312d5b6 | ||
|
|
1a56953d7a | ||
|
|
1f65814fab | ||
|
|
4f9b65074d | ||
|
|
12a5f6fbf3 | ||
|
|
53afbf3a0c | ||
|
|
ee118ab492 | ||
|
|
c27623c29c | ||
|
|
f8fa86fb1f | ||
|
|
422d8fd8f9 | ||
|
|
62e239bf1e | ||
|
|
740f45abcd | ||
|
|
5e4b9f8c6e | ||
|
|
ae811043e9 |
No files matched your search
@@ -8,7 +8,7 @@
|
||||
# NPU naming: {queue}_{resource_class}
|
||||
# a2b3_npu_1 — ascend-a2b3, resource_class npu-1 (A2/B3 CI image)
|
||||
# a2b3_npu_4 — ascend-a2b3, resource_class npu-4
|
||||
# a3_npu_2 — ascend-a3, resource_class npu-2 (A3 CI image tag …:$BUILDKITE_COMMIT-a3)
|
||||
# a3_npu_2 — ascend-a3, resource_class npu-2
|
||||
|
||||
x-npu-k8s-plugins: &npu_k8s_plugins
|
||||
- kubernetes:
|
||||
|
||||
@@ -139,6 +139,11 @@ steps:
|
||||
timeout_in_minutes: 50
|
||||
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'
|
||||
# h100_2, not h100_1: advanced_model asserts transcribe every audio
|
||||
# response with Whisper, and tests/helpers/media.py only uses an
|
||||
# accelerator for Whisper when a spare GPU exists. On a single card it
|
||||
# grades on CPU: ~8-10 min per clip vs ~30 s, which alone overruns the
|
||||
# 50-minute job timeout (build 2956).
|
||||
mirror_hardwares: h100_2
|
||||
|
||||
- label: "Omni · MiniCPM-o 4.5 Duplex Test"
|
||||
@@ -148,10 +153,10 @@ steps:
|
||||
- tests/e2e/online_serving/test_minicpmo_4_5_duplex.py
|
||||
- tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py
|
||||
- tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py
|
||||
- tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py
|
||||
- tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py
|
||||
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py
|
||||
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py
|
||||
- tests/helpers/minicpmo_4_5_duplex.py
|
||||
- tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py
|
||||
- vllm_omni/deploy/minicpmo_4_5_duplex.yaml
|
||||
- vllm_omni/experimental/fullduplex/
|
||||
- vllm_omni/model_executor/models/minicpmo_4_5/
|
||||
@@ -164,7 +169,7 @@ steps:
|
||||
timeout_in_minutes: 50
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'advanced_model and cuda' --run-level 'advanced_model'
|
||||
mirror_hardwares: h100_2
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: "TTS · VoxCPM2 Test"
|
||||
source_file_dependencies:
|
||||
|
||||
@@ -22,7 +22,7 @@ steps:
|
||||
timeout_in_minutes: 50
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py -m "full_model and cuda and H100 and omni" --run-level "full_model"
|
||||
mirror_hardwares: h100_2
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: ":full_moon: Omni · Doc Test with L4"
|
||||
timeout_in_minutes: 90
|
||||
@@ -50,6 +50,19 @@ steps:
|
||||
exit $$EXIT
|
||||
mirror_hardwares: h100_2
|
||||
|
||||
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Accuracy Test"
|
||||
timeout_in_minutes: 180
|
||||
commands:
|
||||
- export SEED_TTS_WER_EVAL=1
|
||||
- export SEED_TTS_EVAL_DEVICE=cuda:1
|
||||
- |
|
||||
set +e
|
||||
pytest -s -v tests/e2e/accuracy/minicpmo_4_5/test_minicpmo_4_5.py -m "full_model and cuda" --run-level full_model
|
||||
EXIT=$$?
|
||||
buildkite-agent artifact upload "tests/e2e/accuracy/minicpmo_4_5/results/*.json"
|
||||
exit $$EXIT
|
||||
mirror_hardwares: h100_2
|
||||
|
||||
- label: ":full_moon: Omni · Perf Test · No Async Chunk"
|
||||
key: nightly-omni-performance-no-async-chunk
|
||||
timeout_in_minutes: 300
|
||||
@@ -102,6 +115,32 @@ steps:
|
||||
exit $$EXIT
|
||||
mirror_hardwares: h100_3
|
||||
|
||||
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Perf Test"
|
||||
key: nightly-omni-performance-minicpmo-4-5
|
||||
timeout_in_minutes: 180
|
||||
commands:
|
||||
- export BENCHMARK_DIR=tests/dfx/perf/results
|
||||
- |
|
||||
set +e
|
||||
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5.json
|
||||
EXIT=$$?
|
||||
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
|
||||
exit $$EXIT
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Duplex Seed-TTS Perf Test"
|
||||
key: nightly-omni-performance-minicpmo-4-5-duplex-seed-tts
|
||||
timeout_in_minutes: 180
|
||||
commands:
|
||||
- export BENCHMARK_DIR=tests/dfx/perf/results
|
||||
- |
|
||||
set +e
|
||||
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --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
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: ":full_moon: Omni · Multi-Replica Startup Test with 4x H100"
|
||||
timeout_in_minutes: 45
|
||||
commands:
|
||||
@@ -427,10 +466,10 @@ steps:
|
||||
- pytest -s -v tests/e2e/accuracy/test_diffusers_backend_similarity.py -k '2v_matches_diffusers' -m full_model --run-level full_model
|
||||
mirror_hardwares: h100_2
|
||||
|
||||
- label: ":full_moon: Diffusion X2V · MiniMax H3 T2VA Accuracy Test"
|
||||
- label: ":full_moon: Diffusion X2V · MiniMax H3 I2VA/Ref2VA Accuracy Test"
|
||||
timeout_in_minutes: 180
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/accuracy/minimax_h3/test_minimax_h3_t2va_similarity.py -m full_model --run-level full_model
|
||||
- pytest -s -v tests/e2e/accuracy/minimax_h3/test_minimax_h3_i2va_ref2va_similarity.py -m full_model --run-level full_model
|
||||
mirror_hardwares: h100_4
|
||||
|
||||
- label: ":full_moon: Diffusion X2V · Other Accuracy Test"
|
||||
|
||||
@@ -128,6 +128,21 @@ steps:
|
||||
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
always-pull: true
|
||||
propagate-environment: true
|
||||
# vLLM 0.27 added a pre-flight capacity check in
|
||||
# distributed/device_communicators/shm_broadcast.py. Without this
|
||||
# the container gets Docker's 64 MiB default and every test in
|
||||
# this step dies at engine start with
|
||||
# RuntimeError: Insufficient space in /dev/shm:
|
||||
# 240 MiB required, 64 MiB available
|
||||
# (9 failed in 8.7s on build #2949). vLLM 0.26 had no such check
|
||||
# and allocated regardless, so this step passed on main while
|
||||
# being under-provisioned the whole time — 0.27 turned a silent
|
||||
# risk into an explicit error.
|
||||
#
|
||||
# This is the only inline docker plugin block in .buildkite/cuda/;
|
||||
# every other step inherits common/ci_mirror_hardwares.yml, which
|
||||
# already sets 8gb. Matching that rather than inventing a value.
|
||||
shm-size: "8gb"
|
||||
environment:
|
||||
- "HF_HOME=/fsx/hf_cache"
|
||||
volumes:
|
||||
@@ -172,7 +187,7 @@ steps:
|
||||
timeout 20m bash -c "
|
||||
pytest -s -v tests/e2e/offline_inference/test_minicpmo_4_5.py -m 'core_model' --run-level 'core_model'
|
||||
"
|
||||
mirror_hardwares: h100_2
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: "Omni · MiniCPM-o 4.5 Online Test"
|
||||
source_file_dependencies:
|
||||
@@ -183,7 +198,7 @@ steps:
|
||||
timeout 20m bash -c "
|
||||
pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5.py -m 'core_model' --run-level 'core_model'
|
||||
"
|
||||
mirror_hardwares: h100_2
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: "Omni · MiniCPM-o 4.5 Duplex Test"
|
||||
source_file_dependencies:
|
||||
@@ -192,10 +207,10 @@ steps:
|
||||
- tests/e2e/online_serving/test_minicpmo_4_5_duplex.py
|
||||
- tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py
|
||||
- tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py
|
||||
- tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py
|
||||
- tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py
|
||||
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py
|
||||
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py
|
||||
- tests/helpers/minicpmo_4_5_duplex.py
|
||||
- tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py
|
||||
- vllm_omni/deploy/minicpmo_4_5_duplex.yaml
|
||||
- vllm_omni/experimental/fullduplex/
|
||||
- vllm_omni/model_executor/models/minicpmo_4_5/
|
||||
@@ -210,7 +225,7 @@ steps:
|
||||
timeout 20m bash -c "
|
||||
pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'core_model and cuda' --run-level 'core_model'
|
||||
"
|
||||
mirror_hardwares: h100_2
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: "TTS · VoxCPM2 Test"
|
||||
source_file_dependencies:
|
||||
|
||||
@@ -39,7 +39,7 @@ steps:
|
||||
EXIT=$$?
|
||||
buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
|
||||
exit $$EXIT
|
||||
- label: ":robot: Omni · MiniCPM-o 4.5 Test"
|
||||
- label: ":full_moon: Omni · MiniCPM-o 4.5 Test"
|
||||
mirror_hardwares: a3_npu_2
|
||||
env:
|
||||
VLLM_WORKER_MULTIPROC_METHOD: spawn
|
||||
@@ -47,6 +47,34 @@ steps:
|
||||
HF_TOKEN: "${HF_TOKEN}"
|
||||
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'
|
||||
- label: ":full_moon: Omni · MiniCPM-o 4.5 · Duplex 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:
|
||||
- >-
|
||||
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 "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:
|
||||
- |
|
||||
set +e
|
||||
pytest -s -v tests/e2e/accuracy/minicpmo_4_5/test_minicpmo_4_5.py -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
|
||||
- group: ":card_index_dividers: TTS Model Test"
|
||||
key: nightly-tts-test-group
|
||||
depends_on: upload-nightly-pipeline
|
||||
|
||||
@@ -3,22 +3,20 @@ steps:
|
||||
key: ready-omni-group
|
||||
depends_on: upload-ready-pipeline
|
||||
steps:
|
||||
- label: ":robot: Omni · MiniCPM-o 4.5 Test"
|
||||
agents:
|
||||
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
|
||||
- label: "Omni · MiniCPM-o 4.5 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/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"
|
||||
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'
|
||||
|
||||
- group: ":card_index_dividers: TTS Model Test"
|
||||
key: ready-tts-group
|
||||
|
||||
+38
-28
@@ -1,9 +1,9 @@
|
||||
# Claude Skills for vLLM-Omni
|
||||
# Repository Skills for vLLM-Omni
|
||||
|
||||
This directory contains Claude Code skills maintained for the `vllm-omni`
|
||||
repository. These skills capture repeatable workflows for common contributor
|
||||
tasks such as model integration, pull request review, and release note
|
||||
generation.
|
||||
This directory contains repository-scale skills maintained for `vllm-omni`.
|
||||
They capture repeatable workflows for common contributor and maintainer tasks
|
||||
such as model integration, CI-aligned testing, performance optimization, and
|
||||
pull request review.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -14,31 +14,41 @@ include:
|
||||
- `references/`: focused reference material used by the skill
|
||||
- `scripts/`: small helper scripts used by the skill
|
||||
|
||||
## Using the Skills
|
||||
|
||||
Coding agents that discover repository skills can invoke the relevant skill by
|
||||
name. For other agents, point them to the skill's `SKILL.md` and ask them to
|
||||
read it before changing code. Combine a domain skill with `vllm-omni-test` when
|
||||
tests or CI coverage change, then use `precheck-pr` for the contributor's final
|
||||
self-check.
|
||||
|
||||
## Available Skills
|
||||
|
||||
- `add-diffusion-model`: guides integration of a new diffusion model into
|
||||
`vllm-omni`
|
||||
- `diffusion-perf-opt`: guides diffusion model performance optimization,
|
||||
including profiling traces, parallel strategies, stage timing analysis, and
|
||||
benchmark-driven tuning
|
||||
- `quantization`: guides quantization method selection, model integration,
|
||||
checkpoint loading, and quality/performance validation for vLLM-Omni
|
||||
- `add-omni-model`: covers addition of new omni-modality model support
|
||||
- `add-tts-model`: covers integration of new TTS models and related serving
|
||||
workflows
|
||||
- `generate-release-note`: helps prepare release notes for repository changes
|
||||
- `precheck-pr`: self-check a branch before creating a PR — validates PR title
|
||||
format, catches dead code, verifies accuracy/perf claims, and confirms merge
|
||||
readiness
|
||||
- `vllm-omni-test`: guides generation and execution of CI-aligned tests (L1–L4),
|
||||
pytest marker selection (`core_model` / `advanced_model` / `full_model`,
|
||||
`omni` / `tts` / `diffusion`), Buildkite wiring (`test-ready.yml`,
|
||||
`test-merge.yml`, `test-nightly.yml`, `test-weekly.yml`), and copy-paste
|
||||
local plus CI-like `pytest` commands; see `references/test-routing.md` for
|
||||
level-to-command mapping
|
||||
- `review-pr`: provides a structured workflow for reviewing pull requests
|
||||
- `vllm-omni-npu-model-runner-upgrade`: upgrades NPU model runners to align with the
|
||||
latest vllm-ascend NPUModelRunner
|
||||
- [`add-diffusion-model`](add-diffusion-model/SKILL.md): guides integration of
|
||||
a new diffusion model into `vllm-omni`
|
||||
- [`add-tts-model`](add-tts-model/SKILL.md): covers integration of new TTS
|
||||
models and related serving workflows
|
||||
- [`diffusion-perf-opt`](diffusion-perf-opt/SKILL.md): guides diffusion model
|
||||
performance optimization, including profiling traces, parallel strategies,
|
||||
stage timing analysis, and benchmark-driven tuning
|
||||
- [`precheck-pr`](precheck-pr/SKILL.md): self-checks a branch before creating a
|
||||
PR by validating title format, dead code, accuracy and performance claims,
|
||||
and merge readiness
|
||||
- [`quantization`](quantization/SKILL.md): guides quantization method selection,
|
||||
model integration, checkpoint loading, and quality/performance validation
|
||||
for vLLM-Omni
|
||||
- [`review-pr`](review-pr/SKILL.md): provides a frozen-snapshot, contract-aware
|
||||
workflow for maintainers and reviewers; contributors should use
|
||||
`precheck-pr` for self-review
|
||||
- [`vllm-omni-npu-model-runner-upgrade`](vllm-omni-npu-upgrade/SKILL.md):
|
||||
upgrades NPU model runners to align with the latest vllm-ascend
|
||||
`NPUModelRunner`
|
||||
- [`vllm-omni-test`](vllm-omni-test/SKILL.md): guides generation and execution
|
||||
of CI-aligned tests (L1–L4), pytest marker selection (`core_model` /
|
||||
`advanced_model` / `full_model`, `omni` / `tts` / `diffusion`), Buildkite
|
||||
wiring (`test-ready.yml`, `test-merge.yml`, `test-nightly.yml`,
|
||||
`test-weekly.yml`), and copy-paste local plus CI-like `pytest` commands; see
|
||||
`references/test-routing.md` for level-to-command mapping
|
||||
|
||||
## Maintenance Guidelines
|
||||
|
||||
|
||||
+110
-28
@@ -1,6 +1,11 @@
|
||||
# CODEOWNERS is used to automatically assign PR reviewers
|
||||
# Format: path pattern @username or @team
|
||||
# More info: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
#
|
||||
# The vllm_omni/** source-tree rules below are derived from the `owners` and
|
||||
# `primary_code_paths` front matter in docs/design/module/*.md. When updating
|
||||
# ownership, update the design doc first, then regenerate the matching rule here.
|
||||
# Last-match-wins: more specific rules must come AFTER broader ones.
|
||||
|
||||
# Default owners (fallback reviewers for all files)
|
||||
* @Gaohan123 @hsliuustc0106 @ywang96
|
||||
@@ -21,25 +26,10 @@
|
||||
# CI
|
||||
/.buildkite/ @yenuo26 @congw729 @NickCao
|
||||
|
||||
# Engine and model_executor
|
||||
/vllm_omni/core/ @tzhouam @yinpeiqi @fake0fan @Sy0307
|
||||
/vllm_omni/engine/ @tzhouam @yinpeiqi @fake0fan @Sy0307
|
||||
/vllm_omni/entrypoints/ @tzhouam @yinpeiqi @fake0fan @alex-jw-brooks @NickCao
|
||||
/vllm_omni/model_executor/ @tzhouam @gcanlin
|
||||
/vllm_omni/worker/ @tzhouam @gcanlin
|
||||
# ComfyUI / external apps
|
||||
/apps/ @fhfuih @alex-jw-brooks
|
||||
|
||||
# Diffusion
|
||||
/vllm_omni/diffusion/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @david6666666 @xuechendi
|
||||
/vllm_omni/lora/ @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @yuanheng-zhao
|
||||
|
||||
# Diffusion CPU offloading
|
||||
/vllm_omni/diffusion/offloader/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @yuanheng-zhao @xuechendi
|
||||
|
||||
# Omni and TTS support
|
||||
/vllm_omni/transformers_utils/ @tzhouam @yuanheng-zhao
|
||||
/vllm_omni/model_executor/models/ @linyueqian @ZeldaHuang @princepride @yuanheng-zhao @amy-why-3459 @Bounty-hunter @Sy0307 @gcanlin @NickCao
|
||||
|
||||
# Benchmarks
|
||||
# Benchmarks (out-of-tree)
|
||||
/benchmarks/accuracy/ @david6666666
|
||||
/benchmarks/build_dataset/ @linyueqian @yuanheng-zhao
|
||||
/benchmarks/diffusion/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @xuechendi
|
||||
@@ -47,20 +37,112 @@
|
||||
/benchmarks/glm_image/ @princepride @JaredforReal
|
||||
/benchmarks/tts/ @linyueqian @ZeldaHuang @princepride @Sy0307 @gcanlin
|
||||
|
||||
# Usability and Observability
|
||||
/vllm_omni/deploy/ @tzhouam @lishunyang12
|
||||
/vllm_omni/metrics/ @lishunyang12
|
||||
/vllm_omni/profiler/ @gcanlin
|
||||
# =====================================================================
|
||||
# Module design ownership (docs/design/module/*.md)
|
||||
# Each section header cites the source design doc.
|
||||
# =====================================================================
|
||||
|
||||
# ComfyUI
|
||||
/apps/ @fhfuih @alex-jw-brooks
|
||||
# Autoregressive runtime — docs/design/module/ar_runtime.md
|
||||
/vllm_omni/core/ @tzhouam @yinpeiqi @fake0fan @Sy0307 @Gaohan123
|
||||
/vllm_omni/worker/ @tzhouam @yinpeiqi @fake0fan @Sy0307 @Gaohan123
|
||||
|
||||
# Large Scale Serving
|
||||
# Engine orchestration & stage runtime — docs/design/module/engine_orchestration.md, stage_runtime.md
|
||||
/vllm_omni/engine/ @tzhouam @fake0fan
|
||||
# File-level overrides: input/output modality contracts own these engine files
|
||||
# — docs/design/module/input_output_modality_contracts.md
|
||||
/vllm_omni/engine/messages.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/engine/serialization.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/engine/mm_outputs.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/engine/output_modality.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/engine/output_processor.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
|
||||
# Input/output modality contracts — docs/design/module/input_output_modality_contracts.md
|
||||
/vllm_omni/inputs/ @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/outputs/ @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/request.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
/vllm_omni/data_entry_keys.py @Sy0307 @amy-why-3459 @Gaohan123 @alex-jw-brooks
|
||||
|
||||
# Entrypoints and serving boundaries — docs/design/module/entrypoints.md
|
||||
/vllm_omni/entrypoints/ @alex-jw-brooks @linyueqian @NickCao
|
||||
# File-level overrides within entrypoints/
|
||||
/vllm_omni/entrypoints/openai/errors.py @alex-jw-brooks @NickCao
|
||||
/vllm_omni/entrypoints/cli/benchmark/ @alex-jw-brooks @Bounty-hunter
|
||||
|
||||
# Error contracts — docs/design/module/error_contracts.md
|
||||
/vllm_omni/errors.py @alex-jw-brooks @NickCao
|
||||
|
||||
# OmniConnector (inter-stage transport) — docs/design/module/omni_connector.md
|
||||
/vllm_omni/distributed/ @princepride @yuanheng-zhao @xuechendi @natureofnature @fake0fan
|
||||
|
||||
# Quantization and configuration
|
||||
# Model integration — docs/design/module/model_integration.md
|
||||
/vllm_omni/model_executor/ @tzhouam @gcanlin
|
||||
/vllm_omni/model_executor/models/ @tzhouam @gcanlin @Sy0307 @amy-why-3459 @linyueqian
|
||||
/vllm_omni/model_extras/ @tzhouam @gcanlin @princepride
|
||||
/vllm_omni/plugins/ @gcanlin @tjtanaa @xuechendi
|
||||
# Config override within model_executor — docs/design/module/vllm_omni_config.md
|
||||
/vllm_omni/model_executor/stage_configs/ @lishunyang12 @alex-jw-brooks
|
||||
|
||||
# Execution platforms (hardware backends) — docs/design/module/execution_platforms.md
|
||||
/vllm_omni/platforms/ @gcanlin @tjtanaa @xuechendi
|
||||
/vllm_omni/attention/ @gcanlin @tjtanaa @xuechendi @david6666666 @lishunyang12
|
||||
|
||||
# Configuration & deploy — docs/design/module/vllm_omni_config.md
|
||||
/vllm_omni/config/ @lishunyang12 @alex-jw-brooks
|
||||
/vllm_omni/deploy/ @lishunyang12 @alex-jw-brooks
|
||||
|
||||
# Quantization — docs/design/module/quantization.md
|
||||
/vllm_omni/quantization/ @david6666666 @Isotr0py @lishunyang12
|
||||
|
||||
# Hardware plugins and accelerators
|
||||
/vllm_omni/platforms/ @gcanlin @tjtanaa @xuechendi
|
||||
# Observability — docs/design/module/observability.md
|
||||
/vllm_omni/metrics/ @lishunyang12 @vraiti
|
||||
|
||||
# Profiling — docs/design/module/profiling.md
|
||||
/vllm_omni/profiler/ @gcanlin
|
||||
|
||||
# Benchmarking (in-tree) — docs/design/module/benchmarking.md
|
||||
/vllm_omni/benchmarks/ @alex-jw-brooks @Bounty-hunter
|
||||
|
||||
# LoRA (no dedicated design doc; ownership intentionally narrowed)
|
||||
/vllm_omni/lora/ @SamitHuang @wtomin
|
||||
|
||||
# Transformers utils (related path only in design docs; preserves prior ownership)
|
||||
/vllm_omni/transformers_utils/ @tzhouam @yuanheng-zhao
|
||||
|
||||
# =====================================================================
|
||||
# Diffusion module family — docs/design/module/diffusion/
|
||||
# Umbrella rule first; sub-module rules after it win via last-match.
|
||||
# =====================================================================
|
||||
|
||||
# Diffusion umbrella (top-level files: data.py, forward_context.py, etc.)
|
||||
# — docs/design/module/diffusion/index.md
|
||||
/vllm_omni/diffusion/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @david6666666 @xuechendi
|
||||
|
||||
# Cache management — docs/design/module/cache_management.md
|
||||
/vllm_omni/diffusion/cache/ @Isotr0py @princepride @SamitHuang
|
||||
|
||||
# Diffusion quantization — docs/design/module/quantization.md
|
||||
/vllm_omni/diffusion/quantization/ @david6666666 @Isotr0py @lishunyang12
|
||||
|
||||
# Diffusion profiling — docs/design/module/profiling.md
|
||||
/vllm_omni/diffusion/profiler/ @gcanlin
|
||||
|
||||
# Diffusion runtime (scheduler, executor, worker)
|
||||
# — docs/design/module/diffusion/diffusion_runtime.md, continuous_batching.md
|
||||
/vllm_omni/diffusion/sched/ @Isotr0py @princepride @SamitHuang @fhfuih
|
||||
/vllm_omni/diffusion/executor/ @Isotr0py @princepride @SamitHuang @fhfuih
|
||||
/vllm_omni/diffusion/worker/ @Isotr0py @princepride @SamitHuang @fhfuih
|
||||
|
||||
# Diffusion model integration (pipelines, registration, checkpoint loading)
|
||||
# — docs/design/module/diffusion/diffusion_model_integration.md
|
||||
/vllm_omni/diffusion/models/ @fhfuih @Bounty-hunter @wtomin
|
||||
/vllm_omni/diffusion/model_loader/ @fhfuih @Bounty-hunter @wtomin @RuixiangMa
|
||||
/vllm_omni/diffusion/lora/ @fhfuih @Bounty-hunter @wtomin @RuixiangMa
|
||||
|
||||
# Diffusion parallelism (rank topology, TP/SP/PP/VAE patch)
|
||||
# — docs/design/module/diffusion/parallelism.md
|
||||
/vllm_omni/diffusion/distributed/ @princepride @yuanheng-zhao @xuechendi @Bounty-hunter
|
||||
/vllm_omni/diffusion/attention/parallel/ @princepride @yuanheng-zhao @xuechendi @Bounty-hunter
|
||||
|
||||
# Diffusion offloader (CPU offloading, DLO)
|
||||
# — docs/design/module/diffusion/offloader.md
|
||||
/vllm_omni/diffusion/offloader/ @yuanheng-zhao @david6666666 @lishunyang12
|
||||
@@ -66,7 +66,10 @@ repos:
|
||||
entry: python tools/pre_commit/check_tts_adapter.py
|
||||
language: python
|
||||
pass_filenames: false
|
||||
files: ^vllm_omni/entrypoints/openai/(serving_speech\.py|tts_adapters/__init__\.py)$
|
||||
files: ^(vllm_omni/entrypoints/openai/(serving_speech\.py|tts_adapters/__init__\.py)|tools/pre_commit/check_tts_adapter\.py)$
|
||||
# The budget-is-now-slack reminder is printed on a PASSING run, and
|
||||
# pre-commit hides output from passing hooks unless the hook is verbose.
|
||||
verbose: true
|
||||
|
||||
# Keep `suggestion` last
|
||||
- id: suggestion
|
||||
|
||||
+19
-2
@@ -1,7 +1,24 @@
|
||||
# Target vllm version: v0.26.0 (releases/v0.26.0 branch).
|
||||
# Target vllm version: v0.27.0 (releases/v0.27.0 branch).
|
||||
# Keep VLLM_BASE_TAG aligned with the target vLLM release on every rebase.
|
||||
#
|
||||
# Release-boundary form: there is no precompiled-wheel pin here, because the
|
||||
# base image already ships exactly the target vLLM. Everything the runtime
|
||||
# needs — torch, flashinfer, nvJitLink, numpy — comes from that image as one
|
||||
# self-consistent set.
|
||||
#
|
||||
# This block reappears only when the rebase targets a vLLM commit that is NOT a
|
||||
# released tag. During the 0.27 rebase, targeting v0.27.0rc1 on top of the
|
||||
# v0.26.0 base image required reinstalling vLLM from wheels.vllm.ai and then
|
||||
# hand-repairing the fallout: pinning torch ahead of flashinfer (installing it
|
||||
# after bound flashinfer's TVM FFI runtime to the old torch and aborted engine
|
||||
# core init), registering libnvJitLink.so.13 with ldconfig, and re-pinning numpy
|
||||
# under numba's ceiling. All of it was compensation for a base image and a vLLM
|
||||
# that disagreed. At a real release boundary they agree, so none of it is
|
||||
# needed. If you pin an untagged commit again, restore the reinstall block —
|
||||
# agent/tasks/20_update_wheel_url.sh fails the run rather than letting the
|
||||
# Dockerfile silently ship the wrong vLLM.
|
||||
ARG VLLM_BASE_IMAGE=vllm/vllm-openai
|
||||
ARG VLLM_BASE_TAG=v0.26.0
|
||||
ARG VLLM_BASE_TAG=v0.27.0
|
||||
|
||||
# We use registry-based layer caching for the dependencies,
|
||||
# where the tag is determined based on the hash of the contents
|
||||
|
||||
@@ -45,6 +45,7 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
|
||||
"hyperpyyaml>=1.2.0" \
|
||||
"onnx>=1.16.0" \
|
||||
"librosa>=0.10.0" \
|
||||
"datasets>=3.0.0" \
|
||||
--index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
|
||||
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
|
||||
--default-timeout=60 && \
|
||||
|
||||
@@ -46,6 +46,7 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
|
||||
"onnx>=1.16.0" \
|
||||
"librosa>=0.10.0" \
|
||||
"torchmetrics>=1.9.0" \
|
||||
"datasets>=3.0.0" \
|
||||
--index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
|
||||
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
|
||||
--default-timeout=60 && \
|
||||
|
||||
+4
-14
@@ -16,31 +16,21 @@ nav:
|
||||
- Examples:
|
||||
- examples/README.md
|
||||
- Offline Inference:
|
||||
- GLM-Image Multistage End-to-End Inference: user_guide/examples/offline_inference/glm_image.md
|
||||
- HunyuanImage-3.0 Image-to-Text Inference: user_guide/examples/offline_inference/hunyuan_image3.md
|
||||
- Image-To-Image: user_guide/examples/offline_inference/image_to_image.md
|
||||
- Image-To-Video: user_guide/examples/offline_inference/image_to_video.md
|
||||
- InternVLA-A1: user_guide/examples/offline_inference/internvla_a1.md
|
||||
- MiMo-Audio: user_guide/examples/offline_inference/mimo_audio.md
|
||||
- Qwen2.5-Omni: user_guide/examples/offline_inference/qwen2_5_omni.md
|
||||
- Qwen3-Omni: user_guide/examples/offline_inference/qwen3_omni.md
|
||||
- Speech-To-Video: user_guide/examples/offline_inference/speech_to_video.md
|
||||
- Text-To-Audio: user_guide/examples/offline_inference/text_to_audio.md
|
||||
- Text-To-Image: user_guide/examples/offline_inference/text_to_image.md
|
||||
- Text-To-Speech: user_guide/examples/offline_inference/text_to_speech.md
|
||||
- Text-To-Video: user_guide/examples/offline_inference/text_to_video.md
|
||||
- X-To-Text: user_guide/examples/offline_inference/x_to_text.md
|
||||
- X-To-Video-Audio: user_guide/examples/offline_inference/x_to_video_audio.md
|
||||
- Online Serving:
|
||||
- BAGEL-7B-MoT: user_guide/examples/online_serving/bagel.md
|
||||
- vLLM-Omni Helm Chart: user_guide/examples/online_serving/chart-helm.md
|
||||
- Diffusers Backend Adapter: user_guide/examples/online_serving/diffusers_pipeline_adapter.md
|
||||
- GLM-Image Online Serving: user_guide/examples/online_serving/glm_image.md
|
||||
- Image-To-Image: user_guide/examples/online_serving/image_to_image.md
|
||||
- Image-To-Video: user_guide/examples/online_serving/image_to_video.md
|
||||
- MiMo-Audio: user_guide/examples/online_serving/mimo_audio.md
|
||||
- Qwen2.5-Omni: user_guide/examples/online_serving/qwen2_5_omni.md
|
||||
- Qwen3-Omni: user_guide/examples/online_serving/qwen3_omni.md
|
||||
- Text-To-Speech: user_guide/examples/online_serving/text_to_speech.md
|
||||
- Speech-To-Video: user_guide/examples/online_serving/speech_to_video.md
|
||||
- Text-To-Image: user_guide/examples/online_serving/text_to_image.md
|
||||
- Text-To-Speech: user_guide/examples/online_serving/text_to_speech.md
|
||||
- Text-To-Video: user_guide/examples/online_serving/text_to_video.md
|
||||
- General:
|
||||
- usage/*
|
||||
|
||||
@@ -76,6 +76,47 @@ Please refer to the [test instructions](./ci/test_execution_guide.md) for detail
|
||||
!!! warning
|
||||
Currently, not all unit tests pass when run on CPU platforms. If you don't have access to a GPU platform to run unit tests locally, rely on the continuous integration system to run the tests for now.
|
||||
|
||||
### Using repository skills with coding agents
|
||||
|
||||
vLLM-Omni maintains [repository-scale skills](https://github.com/vllm-project/vllm-omni/tree/main/.claude/skills) for common coding, testing, performance, and review workflows. These skills capture repository-specific structure, validation requirements, and evidence standards that a general coding agent may not know.
|
||||
|
||||
If your coding agent discovers repository skills automatically, ask it to use the relevant skill by name. Otherwise, point it to the linked `SKILL.md` and ask it to read the file before changing code. Skills can be combined: use a domain skill for the implementation, `vllm-omni-test` for test and CI decisions, and `precheck-pr` before opening the pull request.
|
||||
|
||||
| Task | Repository skill |
|
||||
| --- | --- |
|
||||
| Add or extend a diffusion model | [`add-diffusion-model`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/add-diffusion-model/SKILL.md) |
|
||||
| Add or extend a text-to-speech model | [`add-tts-model`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/add-tts-model/SKILL.md) |
|
||||
| Add, select, or debug quantization | [`quantization`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/quantization/SKILL.md) |
|
||||
| Diagnose or optimize diffusion performance | [`diffusion-perf-opt`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/diffusion-perf-opt/SKILL.md) |
|
||||
| Upgrade an NPU model runner | [`vllm-omni-npu-model-runner-upgrade`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/vllm-omni-npu-upgrade/SKILL.md) |
|
||||
| Add a regression test, choose L1-L4 coverage, or wire Buildkite | [`vllm-omni-test`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/vllm-omni-test/SKILL.md) |
|
||||
| Self-check a branch before opening a pull request | [`precheck-pr`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/precheck-pr/SKILL.md) |
|
||||
| Perform a maintainer or reviewer review of an existing pull request or local branch | [`review-pr`](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/review-pr/SKILL.md) |
|
||||
|
||||
Use the following workflow when collaborating with a coding agent:
|
||||
|
||||
1. **Ground the task.** Provide the issue, RFC, or design document that defines the expected behavior. Ask the agent to state its assumptions and identify the affected module or feature contracts before editing code.
|
||||
2. **Select the narrowest applicable skills.** Start with the domain skill that owns the change. Add `vllm-omni-test` whenever production behavior or tests change. For a bug fix, ask for the smallest reproducer and a regression test tied to the original symptom.
|
||||
3. **Require executable evidence.** Ask the agent to run the narrowest relevant checks and report the exact commands and results. It must identify tests it could not run because of unavailable hardware, model weights, credentials, or dependencies; an unrun check is not a pass.
|
||||
4. **Inspect the resulting diff.** Confirm that the implementation stays within the issue or RFC, does not overwrite unrelated work, and includes the required tests, documentation, and benchmark or accuracy evidence.
|
||||
5. **Run the pre-submit workflow.** Use `precheck-pr`, then run the applicable local tests and pre-commit hooks before opening the pull request.
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
Use add-diffusion-model and vllm-omni-test to implement the linked issue.
|
||||
Read the relevant design documents first, keep the change within the issue,
|
||||
and report every test command, result, and hardware validation gap.
|
||||
```
|
||||
|
||||
```text
|
||||
Use vllm-omni-test to turn this bug reproducer into the smallest deterministic
|
||||
regression test. Prefer L1 CPU coverage; if the failure requires real weights
|
||||
or serving, explain the required L2/L3 environment and provide the exact command.
|
||||
```
|
||||
|
||||
Repository skills guide the agent, but they do not replace contributor judgment, the accepted issue or RFC, required test evidence, or maintainer review. Review all generated changes before submitting them.
|
||||
|
||||
## Issues
|
||||
|
||||
If you encounter a bug or have a feature request, please search existing issues first to see if it has already been reported. If not, please file a new issue, providing as much relevant information as possible.
|
||||
|
||||
@@ -7,6 +7,7 @@ owners:
|
||||
- "@yinpeiqi"
|
||||
- "@fake0fan"
|
||||
- "@Sy0307"
|
||||
- "@Gaohan123"
|
||||
primary_code_paths:
|
||||
- vllm_omni/core/**
|
||||
- vllm_omni/worker/**
|
||||
|
||||
@@ -4,6 +4,7 @@ kind: module
|
||||
status: draft
|
||||
owners:
|
||||
- "@alex-jw-brooks"
|
||||
- "@Bounty-hunter"
|
||||
primary_code_paths:
|
||||
- vllm_omni/benchmarks/**
|
||||
- vllm_omni/entrypoints/cli/benchmark/**
|
||||
|
||||
@@ -5,6 +5,7 @@ status: draft
|
||||
owners:
|
||||
- "@Isotr0py"
|
||||
- "@princepride"
|
||||
- "@SamitHuang"
|
||||
primary_code_paths:
|
||||
- vllm_omni/diffusion/cache/**
|
||||
related_code_paths:
|
||||
|
||||
@@ -3,9 +3,10 @@ title: Diffusion Model Integration
|
||||
kind: module
|
||||
status: draft
|
||||
owners:
|
||||
- "@Isotr0py"
|
||||
- "@fhfuih"
|
||||
- "@Bounty-hunter"
|
||||
- "@wtomin"
|
||||
- "@ZJY0516"
|
||||
- "@RuixiangMa"
|
||||
primary_code_paths:
|
||||
- vllm_omni/diffusion/models/**
|
||||
- vllm_omni/diffusion/model_loader/**
|
||||
|
||||
@@ -6,6 +6,7 @@ owners:
|
||||
- "@Isotr0py"
|
||||
- "@princepride"
|
||||
- "@SamitHuang"
|
||||
- "@fhfuih"
|
||||
primary_code_paths:
|
||||
- vllm_omni/diffusion/sched/**
|
||||
- vllm_omni/diffusion/executor/**
|
||||
|
||||
@@ -4,7 +4,8 @@ kind: module
|
||||
status: draft
|
||||
owners:
|
||||
- "@yuanheng-zhao"
|
||||
- "@Isotr0py"
|
||||
- "@david6666666"
|
||||
- "@lishunyang12"
|
||||
primary_code_paths:
|
||||
- vllm_omni/diffusion/offloader/**
|
||||
related_code_paths:
|
||||
|
||||
@@ -6,6 +6,7 @@ owners:
|
||||
- "@princepride"
|
||||
- "@yuanheng-zhao"
|
||||
- "@xuechendi"
|
||||
- "@Bounty-hunter"
|
||||
primary_code_paths:
|
||||
- vllm_omni/diffusion/distributed/**
|
||||
- vllm_omni/diffusion/attention/parallel/**
|
||||
|
||||
@@ -6,6 +6,7 @@ architecture_state: current-plus-in-flight
|
||||
owners:
|
||||
- "@alex-jw-brooks"
|
||||
- "@linyueqian"
|
||||
- "@NickCao"
|
||||
document_stewards:
|
||||
- "@hsliuustc0106"
|
||||
- "@Gaohan123"
|
||||
|
||||
@@ -8,8 +8,15 @@ owners:
|
||||
- "@xuechendi"
|
||||
primary_code_paths:
|
||||
- vllm_omni/platforms/**
|
||||
related_code_paths:
|
||||
- vllm_omni/attention/**
|
||||
primary_code_path_owners:
|
||||
# Additional owners scoped to attention kernels (quantized/custom attention)
|
||||
- paths:
|
||||
- vllm_omni/attention/**
|
||||
owners:
|
||||
- "@david6666666"
|
||||
- "@lishunyang12"
|
||||
related_code_paths: []
|
||||
depends_on:
|
||||
- ar_runtime.md
|
||||
- diffusion/index.md
|
||||
|
||||
@@ -6,6 +6,8 @@ architecture_state: current
|
||||
owners:
|
||||
- "@Sy0307"
|
||||
- "@amy-why-3459"
|
||||
- "@Gaohan123"
|
||||
- "@alex-jw-brooks"
|
||||
document_stewards:
|
||||
- "@hsliuustc0106"
|
||||
- "@Gaohan123"
|
||||
|
||||
@@ -9,6 +9,14 @@ primary_code_paths:
|
||||
- vllm_omni/model_executor/**
|
||||
- vllm_omni/model_extras/**
|
||||
- vllm_omni/plugins/**
|
||||
primary_code_path_owners:
|
||||
# Additional owners scoped to a sub-path of model_executor/**
|
||||
- paths:
|
||||
- vllm_omni/model_executor/models/**
|
||||
owners:
|
||||
- "@Sy0307"
|
||||
- "@amy-why-3459"
|
||||
- "@linyueqian"
|
||||
related_code_paths:
|
||||
- vllm_omni/transformers_utils/**
|
||||
- vllm_omni/tokenizers/**
|
||||
|
||||
@@ -4,6 +4,7 @@ kind: module
|
||||
status: draft
|
||||
owners:
|
||||
- "@lishunyang12"
|
||||
- "@vraiti"
|
||||
primary_code_paths:
|
||||
- vllm_omni/metrics/**
|
||||
related_code_paths:
|
||||
|
||||
@@ -24,15 +24,22 @@ SERVING_MODE_TITLES = {
|
||||
"online_serving": "Online serving",
|
||||
}
|
||||
|
||||
# Generated example pages keep their source-derived URLs even when their
|
||||
# navigation category changes. Quantization helpers are feature documentation,
|
||||
# not user-serving examples, so keep them under the existing Features /
|
||||
# Quantization section instead of creating a second top-level Quantization
|
||||
# category under Examples.
|
||||
EXAMPLE_NAV_TARGETS = {
|
||||
"quantization": "quantization",
|
||||
}
|
||||
GENERATED_QUANTIZATION_EXAMPLES_TITLE = "Generated Examples"
|
||||
# Keep the Examples navigation focused on reusable modality/task entry points.
|
||||
# Model-specific scripts remain available under ``examples/`` and are surfaced
|
||||
# from these shared pages instead of getting one page per model.
|
||||
GENERAL_EXAMPLE_SLUGS = frozenset(
|
||||
{
|
||||
"image_to_image",
|
||||
"image_to_video",
|
||||
"speech_to_video",
|
||||
"text_to_audio",
|
||||
"text_to_image",
|
||||
"text_to_speech",
|
||||
"text_to_video",
|
||||
"x_to_text",
|
||||
"x_to_video_audio",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def load_model_display_names() -> dict[str, str]:
|
||||
@@ -56,6 +63,13 @@ def load_model_display_names() -> dict[str, str]:
|
||||
MODEL_DISPLAY_NAMES = load_model_display_names()
|
||||
|
||||
|
||||
def is_general_example(example: "Example") -> bool:
|
||||
"""Return whether an example should get a generated serving page."""
|
||||
if example.category not in SERVING_MODE_TITLES:
|
||||
return True
|
||||
return example.path.stem in GENERAL_EXAMPLE_SLUGS
|
||||
|
||||
|
||||
def fix_case(text: str) -> str:
|
||||
subs = {
|
||||
"api": "API",
|
||||
@@ -358,12 +372,9 @@ def update_nav_file(examples: list[Example]):
|
||||
items.append({example.nav_title: rel_path.as_posix()})
|
||||
return items
|
||||
|
||||
# Build the new Examples section - start with preserved items. Categories
|
||||
# not explicitly routed elsewhere retain the historical Examples behavior.
|
||||
# Build the new Examples section - start with preserved items.
|
||||
examples_section = preserved_items.copy()
|
||||
for category in sorted(examples_by_category.keys()):
|
||||
if EXAMPLE_NAV_TARGETS.get(category, "examples") != "examples":
|
||||
continue
|
||||
items = category_items(category)
|
||||
if items:
|
||||
category_title = fix_case(category.replace("_", " ").title())
|
||||
@@ -372,36 +383,6 @@ def update_nav_file(examples: list[Example]):
|
||||
# Update the nav structure
|
||||
nav_list[user_guide_idx]["User Guide"][examples_idx]["Examples"] = examples_section
|
||||
|
||||
features_content = next(
|
||||
(item["Features"] for item in user_guide_content if isinstance(item, dict) and "Features" in item),
|
||||
None,
|
||||
)
|
||||
quantization_content = None
|
||||
if isinstance(features_content, list):
|
||||
quantization_content = next(
|
||||
(item["Quantization"] for item in features_content if isinstance(item, dict) and "Quantization" in item),
|
||||
None,
|
||||
)
|
||||
|
||||
if isinstance(quantization_content, list):
|
||||
# Remove only the group owned by this hook so repeated builds are
|
||||
# idempotent and hand-authored Quantization navigation is retained.
|
||||
quantization_content[:] = [
|
||||
item
|
||||
for item in quantization_content
|
||||
if not (isinstance(item, dict) and GENERATED_QUANTIZATION_EXAMPLES_TITLE in item)
|
||||
]
|
||||
|
||||
quantization_examples = examples_by_category.get("quantization")
|
||||
if quantization_examples:
|
||||
if not isinstance(quantization_content, list):
|
||||
logger.warning(
|
||||
"Could not find 'User Guide' -> 'Features' -> 'Quantization' section; "
|
||||
"omitting generated quantization examples from navigation"
|
||||
)
|
||||
else:
|
||||
quantization_content.append({GENERATED_QUANTIZATION_EXAMPLES_TITLE: category_items("quantization")})
|
||||
|
||||
# Write back to file
|
||||
nav_data["nav"] = nav_list
|
||||
with open(NAV_FILE, "w", encoding="utf-8") as f:
|
||||
@@ -432,6 +413,8 @@ def on_startup(command: Literal["build", "gh-deploy", "serve"], dirty: bool):
|
||||
for path in category.glob("*/*.md"):
|
||||
examples.append(Example(path.parent, category.stem))
|
||||
|
||||
examples = [example for example in examples if is_general_example(example)]
|
||||
|
||||
# Generate the example documentation
|
||||
for example in sorted(examples, key=lambda e: e.path.stem):
|
||||
example_name = f"{example.path.stem}.md"
|
||||
|
||||
@@ -51,6 +51,7 @@ th {
|
||||
| `OvisImagePipeline` | Ovis-Image | `OvisAI/Ovis-Image` | ✅︎ | ✅︎ | | ✅︎ | — |
|
||||
| `LongcatImagePipeline` | LongCat-Image | `meituan-longcat/LongCat-Image` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
|
||||
| `LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
|
||||
| `LongCatVideoAvatarPipeline` | LongCat-Video-Avatar-1.5 A2V/AI2V (native single-speaker and multi-speaker AI2V/AVC) | `meituan-longcat/LongCat-Video-Avatar-1.5` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/meituan-longcat/LongCat-Video-Avatar-1.5.md) |
|
||||
| `BooguImagePipeline` | Boogu-Image | `Boogu/Boogu-Image-0.1-Base`, `Boogu/Boogu-Image-0.1-Edit` | ✅︎ | | | | — |
|
||||
| `StableDiffusionXLPipeline` | Stable-Diffusion-XL | `stabilityai/stable-diffusion-xl-base-1.0` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
|
||||
| `StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | ✅︎ | ✅︎ | | ✅︎ | — |
|
||||
|
||||
@@ -283,23 +283,20 @@ blocks — use Ulysses SP (`ring_degree=1`).
|
||||
|
||||
### Which geometries run sparse
|
||||
|
||||
Sparsity is only applied when the video segment is a **multiple of 128 rows**, where the row count
|
||||
is `latent_t × (height / 32) × (width / 32)`. Otherwise `rf_v2`'s block mask and the kernel's own
|
||||
tiling disagree on the block count, and the block straddling the seam mixes video and prefix rows
|
||||
that selection may then drop. Any resolution still runs — an unaligned geometry falls back to dense
|
||||
attention and logs `RAINFUSION_ATTN staying dense` with the row count it computed — but it gets no
|
||||
speedup, so pick an aligned geometry when you want one.
|
||||
MindIE-SD rf_v2 handles arbitrary video grids by spatially rearranging video first and, when
|
||||
necessary, promoting a real-video suffix to its always-kept prefix segment. It never pads: rf_v2
|
||||
does not consume an attention mask for padded keys. The remaining video rows stay sparse, while the
|
||||
promoted rows remain visible to every query and key.
|
||||
|
||||
Alignment is necessary, not sufficient, for good quality. `rf_v2` groups video positions into 8x8
|
||||
spatial tiles (two tiles fill one 128-row block), which is what makes a selected block a compact
|
||||
patch of the frame. When the latent `h` or `w` is not a multiple of 8, the leftover rows or columns
|
||||
are peeled off and appended as a flat run instead of being tiled, so they get pooled with spatially
|
||||
distant positions and selection can no longer rank them meaningfully. This is silent, and invisible
|
||||
to a `sparsity=0` check, because a fully populated mask does not care how blocks are grouped.
|
||||
For example, MiniMax-H3 at 1344x768 has grid `(62, 24, 42)`: its 62496 video rows leave a 32-row
|
||||
128-block residue, and its two non-8-wide latent columns form a 2928-row rearranged suffix.
|
||||
MindIE-SD promotes 2976 rows, leaving 59520 sparse rows (`465 × 128`). This keeps the block mask
|
||||
and kernel tiling aligned, at the cost of retaining more blocks.
|
||||
|
||||
"Multiple of 8" on the latent grid means **width and height that are multiples of 256**. Aligned
|
||||
resolutions off that grid still run sparse; they just lose more fidelity at the same `sparsity`,
|
||||
which you can buy back with `start_step`.
|
||||
8x8-aligned spatial grids remain preferable for performance. A latent `h` or `w` not divisible by 8
|
||||
creates an always-kept residual suffix and lowers realized sparsity. "Multiple of 8" on the latent
|
||||
grid means **width and height that are multiples of 256**. This path requires a MindIE-SD release
|
||||
that implements protected video tails for rf_v2.
|
||||
|
||||
## End-to-End Benchmark (BF16, sm_120 RTX Pro 6000 Blackwell)
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
# LoRA (Low-Rank Adaptation) Guide
|
||||
|
||||
LoRA (Low-Rank Adaptation) enables fine-tuning diffusion models by adding trainable low-rank matrices to existing model weights. vLLM-Omni currently supports PEFT-style LoRA adapters, allowing you to customize model behavior without modifying the base model weights.
|
||||
LoRA (Low-Rank Adaptation) enables fine-tuning diffusion models by adding trainable low-rank matrices to existing model weights. vLLM-Omni supports two LoRA backends: **PEFT** for PEFT-style adapters, and **Distill** usually for few-steps inference. **PEFT** backend allows you to customize model behavior without modifying the base model weights. **Distill** backend fuses LoRA weights into the base model at initialization.
|
||||
|
||||
## Overview
|
||||
|
||||
LoRA adapters are lightweight, model-specific fine-tuning weights that can be dynamically loaded and applied to diffusion models. vLLM-Omni uses a unified LoRA handling mechanism similar to vLLM with LRU cache management.
|
||||
LoRA adapters are lightweight, model-specific fine-tuning weights that can be applied to diffusion models in two ways:
|
||||
|
||||
- **PEFT backend** (`--lora-backend peft`, default): Loads a PEFT-format adapter folder via `DiffusionLoRAManager`. Adapters are cached (LRU) and activated per request via `LoRARequest`. It uses a unified LoRA handling mechanisms similar to vLLM with LRU cache management.
|
||||
- **Distill backend** (`--lora-backend distill`): Calls `pipeline.load_lora_weights` once at initialization to fuse one or more concrete checkpoint files directly into the base weights. Typically used for distilled few-step LoRAs (e.g. Lightning, LightX2V).
|
||||
|
||||
## LoRA Adapter Format
|
||||
|
||||
LoRA adapters must be in **PEFT (Parameter-Efficient Fine-Tuning)** format. A typical LoRA adapter directory structure:
|
||||
### PEFT (Parameter-Efficient Fine-Tuning) format (default)
|
||||
A typical PEFT-format LoRA adapter directory structure:
|
||||
|
||||
```
|
||||
lora_adapter/
|
||||
@@ -25,19 +29,20 @@ The `adapter_config.json` file contains metadata about the LoRA adapter, includi
|
||||
|
||||
### Offline Inference
|
||||
|
||||
#### Pre-loaded LoRA
|
||||
#### PEFT backend: pre-loaded LoRA
|
||||
|
||||
Load a LoRA adapter at initialization. This adapter is pre-loaded into the cache and can be activated by requests:
|
||||
Load a PEFT-format LoRA adapter at initialization. The adapter is pre-loaded into the cache and can be activated per request:
|
||||
|
||||
```python
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
|
||||
lora_path="/path/to/lora_adapter"
|
||||
lora_path = "/path/to/lora_adapter"
|
||||
|
||||
omni = Omni(
|
||||
model="stabilityai/stable-diffusion-3.5-medium",
|
||||
lora_path=lora_path
|
||||
lora_path=lora_path,
|
||||
lora_backend="peft", # default, can be omitted
|
||||
)
|
||||
|
||||
lora_request = LoRARequest(
|
||||
@@ -53,6 +58,98 @@ outputs = omni.generate(
|
||||
)
|
||||
```
|
||||
|
||||
#### Distill backend: fuse distilled LoRA at init
|
||||
|
||||
For distilled few-step LoRAs, pass `lora_backend="distill"` together with one or more concrete `.safetensors` files. The weights are fused into the base model once at init; subsequent `generate()` calls do not need a `LoRARequest`.
|
||||
|
||||
##### Supported pipelines
|
||||
|
||||
For Qwen-Image and Wan pipelines, the distill backend calls `pipeline.load_lora_weights(...)` during worker initialization.
|
||||
|
||||
| Pipeline | Supported distilled LoRA repo | Notes |
|
||||
|----------|--------------------------------|-------|
|
||||
| `QwenImagePipeline` | `lightx2v/Qwen-Image-2512-Lightning` | Used with Qwen-Image-2512 Lightning-style few-step inference. |
|
||||
| `Wan22Pipeline` | `lightx2v/Wan2.1-Distill-Loras`, `lightx2v/Wan2.2-Distill-Loras` | Wan2.1 uses one LoRA file. For dual-transformer Wan2.2 MoE, pass high-noise then low-noise LoRA files. |
|
||||
| `Wan22I2VPipeline` | `lightx2v/Wan2.2-Distill-Loras` | For dual-transformer Wan2.2 MoE, pass high-noise then low-noise LoRA files. |
|
||||
|
||||
Other diffusion pipelines are not currently listed as supporting distilled LoRA. Use the PEFT backend for request-time adapters, or bake converted weights into a local Diffusers directory before serving.
|
||||
|
||||
Single-file example (Qwen-Image-Lightning):
|
||||
|
||||
```python
|
||||
from vllm_omni import Omni
|
||||
|
||||
omni = Omni(
|
||||
model="Qwen/Qwen-Image-2512",
|
||||
lora_path="/path/to/Qwen-Image-2512-Lightning.safetensors",
|
||||
lora_backend="distill",
|
||||
)
|
||||
|
||||
outputs = omni.generate(prompt="A piece of cheesecake")
|
||||
```
|
||||
|
||||
Multi-file example (Wan2.2 MoE, high + low noise):
|
||||
|
||||
```python
|
||||
from vllm_omni import Omni
|
||||
|
||||
omni = Omni(
|
||||
model="Wan-AI/Wan2.2-T2V-A14B-Diffusers",
|
||||
lora_path=[
|
||||
"/path/to/wan2.2_high_noise_lora.safetensors", # -> transformer
|
||||
"/path/to/wan2.2_low_noise_lora.safetensors", # -> transformer_2
|
||||
],
|
||||
lora_backend="distill",
|
||||
)
|
||||
```
|
||||
|
||||
The CLI examples under `examples/offline_inference/` accept the same flags, e.g.:
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/text_to_video/text_to_video.py \
|
||||
--model Wan-AI/Wan2.2-T2V-A14B-Diffusers \
|
||||
--lora-backend distill \
|
||||
--lora-path /path/to/high.safetensors /path/to/low.safetensors \
|
||||
--prompt "A cat playing with yarn"
|
||||
```
|
||||
|
||||
##### Online serving
|
||||
|
||||
Distilled LoRAs can also be fused when an online diffusion server starts. The
|
||||
adapter remains active for the lifetime of that server, so requests do not pass
|
||||
a per-request `lora` field.
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen-Image-2512 \
|
||||
--omni \
|
||||
--port 8091 \
|
||||
--lora-backend distill \
|
||||
--lora-path /path/to/Qwen-Image-2512-Lightning-4steps.safetensors
|
||||
```
|
||||
|
||||
Then send a normal generation request with the sampling settings expected by
|
||||
the distilled checkpoint:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8091/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{"role": "user", "content": "A Chinese female college student, around 20 years old, with a very short haircut that conveys a gentle, artistic vibe. Her hair naturally falls to partially cover her cheeks, projecting a tomboyish yet charming demeanor. She has cool-toned fair skin and delicate features, with a slightly shy yet subtly confident expression—her mouth crooked in a playful, youthful smirk. She wears an off-shoulder top, revealing one shoulder, with a well-proportioned figure. The image is framed as a close-up selfie: she dominates the foreground, while the background clearly shows her dormitory—a neatly made bed with white linens on the top bunk, a tidy study desk with organized stationery, and wooden cabinets and drawers. The photo is captured on a smartphone under soft, even ambient lighting, with natural tones, high clarity, and a bright, lively atmosphere full of youthful, everyday energy."}
|
||||
],
|
||||
"extra_body": {
|
||||
"height": 1024,
|
||||
"width": 1024,
|
||||
"num_inference_steps": 4,
|
||||
"true_cfg_scale": 1.0,
|
||||
"seed": 42
|
||||
}
|
||||
}' | jq -r '.choices[0].message.content[0].image_url.url' | cut -d',' -f2 | base64 -d > qwen_image_distill_lora_output.png
|
||||
```
|
||||
|
||||
For Wan2.2 MoE serving, pass the high-noise checkpoint first and the low-noise
|
||||
checkpoint second to `--lora-path`.
|
||||
|
||||
!!! note "Server-side Path Requirement"
|
||||
The LoRA adapter path (`local_path`) must be readable on the **server** machine. If your client and server are on different machines, ensure the LoRA adapter is accessible via a shared mount or copied to the server.
|
||||
|
||||
@@ -141,6 +238,7 @@ Notes:
|
||||
|
||||
- This route avoids runtime LoRA loading changes in vLLM-Omni when you choose to bake converted weights into a local Diffusers directory.
|
||||
- Output quality and speed depend on the replacement checkpoints and sampling params you choose.
|
||||
- If you only need to fuse distilled LoRAs into a Wan2.2 checkpoint at load time (without the full LightX2V convert + assemble pipeline), you can instead pass them directly via `--lora-backend distill --lora-path <high>.safetensors <low>.safetensors`. See the [Distill backend](#distill-backend-fuse-distilled-lora-at-init) section above.
|
||||
|
||||
|
||||
## See Also
|
||||
|
||||
@@ -164,10 +164,12 @@ In `DiffusionParallelConfig`:
|
||||
| `ulysses_degree` | int | 1 | Number of GPUs for Ulysses-SP. Uses all-to-all communication. |
|
||||
| `ring_degree` | int | 1 | Number of GPUs for Ring-Attention. Uses P2P ring communication. |
|
||||
| `ulysses_mode` | str | `"default"` | Ulysses attention mode. Set to `"advanced_uaa"` to handle arbitrary sequence lengths and head counts without padding. |
|
||||
| `mask_sp_padding` | bool | `False` | When the sequence length is not divisible by the SP size, tokens are auto-padded with zeros. Set to `True` to mask those padding tokens (strict, but uses the slower varlen attention path); the default `False` leaves them unmasked, keeping the fast path with negligible numerical impact. |
|
||||
|
||||
**Notes:**
|
||||
- Total sequence parallel size equals to `ulysses_degree × ring_degree`
|
||||
- Degrees must evenly divide the sequence length for optimal performance (or use `ulysses_mode="advanced_uaa"` for Ulysses-SP)
|
||||
- `mask_sp_padding` is an experimental feature, currently only supported by `Wan2.2`, `Wan2.2 Vace`, `Qwen-Image`, `Flux 2`, and `HunyuanVideo 1.5`
|
||||
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -159,6 +159,7 @@ The following tables show which models support each feature:
|
||||
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
|
||||
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **Cosmos3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
|
||||
| **LongCat-Video-Avatar-1.5** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **MiniMax-H3** | ✅ (FL2VA) | ✅ | ✅ | ❌ | ✅ (DiT/TE) | ❌ | ✅ | ✅ | ✅ (tile) | ✅ (DiT) | ❌ |
|
||||
|
||||
**Frame Interpolation Support**
|
||||
|
||||
@@ -88,8 +88,9 @@ python text_to_image.py \
|
||||
| `--vae-use-tiling` | flag | off | Enable VAE tiling for memory optimization |
|
||||
| `--cfg-parallel-size` | int | `1` | Set to `2` to enable CFG Parallel |
|
||||
| `--enable-cpu-offload` | flag | off | Enable CPU offloading for diffusion models |
|
||||
| `--lora-path` | str | — | Path to PEFT LoRA adapter folder |
|
||||
| `--lora-path` | str | — | Path to PEFT LoRA adapter folder or checkpoint file |
|
||||
| `--lora-scale` | float | `1.0` | Scale factor for LoRA weights |
|
||||
| `--lora-backend` | str |`"peft"`| LoRA backend for loading LoRA adapters. Default: peft. Available options: peft, distill |
|
||||
|
||||
**NextStep-1.1 specific arguments:**
|
||||
|
||||
|
||||
@@ -125,6 +125,9 @@ python text_to_video.py \
|
||||
- `--audio-sample-rate`: fallback audio sample rate when the pipeline returns audio.
|
||||
- `--quantization`: quantization method (`fp8` for FP8, `gguf` for GGUF).
|
||||
- `--flow-shift`: scheduler flow_shift parameter.
|
||||
- `--lora-path`: path to PEFT LoRA adapter folder or checkpoint file.
|
||||
- `--lora-scale`: scale factor for LoRA weights.
|
||||
- `--lora-backend`: backend for loading LoRA adapters. Default: peft. Available options: peft, distill.
|
||||
|
||||
### Wan2.2-specific
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ a native vLLM-Omni pipeline for those cases.
|
||||
### Attention Backends
|
||||
|
||||
The diffusers backend converts
|
||||
[vLLM-Omni standard of attention backend setting](../../../docs/user_guide/diffusion/attention_backends.md)
|
||||
[vLLM-Omni standard of attention backend setting](../../diffusion/attention_backends.md)
|
||||
to [diffusers standard](https://huggingface.co/docs/diffusers/optimization/attention_backends#available-backends).
|
||||
|
||||
Specifically for `FLASH_ATTN`, it will first attempt to use FlashAttention-3 and then FlashAttention-2.
|
||||
|
||||
@@ -60,6 +60,8 @@ from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.diffusion.utils.param_utils import apply_declared_extra_args
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
from vllm_omni.lora.utils import stable_lora_int_id
|
||||
from vllm_omni.model_extras import (
|
||||
build_image_to_video_prompt,
|
||||
get_extra_body_params,
|
||||
@@ -294,6 +296,30 @@ def parse_args() -> argparse.Namespace:
|
||||
default=None,
|
||||
help='JSON profiler config for torch/cuda profiling, e.g. \'{"profiler":"torch","torch_profiler_dir":"./perf"}\'.',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lora-path",
|
||||
type=str,
|
||||
nargs="+",
|
||||
default=None,
|
||||
help="Path to a LoRA adapter folder (PEFT format) or concrete LoRA checkpoint files. "
|
||||
"For Wan2.2 MoE models, pass the high-noise checkpoint first and the low-noise checkpoint second.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lora-scale",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Scale factor for PEFT LoRA weights (default: 1.0).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lora-backend",
|
||||
type=str,
|
||||
default="peft",
|
||||
choices=["peft", "distill"],
|
||||
help=(
|
||||
"LoRA backend. 'peft' loads a PEFT-format adapter for request-time activation; "
|
||||
"'distill' fuses one or more concrete LoRA checkpoint files at initialization."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra-body",
|
||||
type=functools.partial(parse_json_object, flag_name="--extra-body"),
|
||||
@@ -487,6 +513,12 @@ def main():
|
||||
omni_kwargs["flow_shift"] = flow_shift
|
||||
if args.quantization is not None:
|
||||
omni_kwargs["quantization"] = args.quantization
|
||||
if args.lora_path is not None:
|
||||
lora_path = args.lora_path
|
||||
if len(lora_path) == 1:
|
||||
lora_path = lora_path[0]
|
||||
omni_kwargs["lora_path"] = lora_path
|
||||
omni_kwargs["lora_backend"] = args.lora_backend
|
||||
# Cosmos3 loads its (gated) guardrail models at build time, so the guardrails
|
||||
# gate is an engine-level config (offline analog of the server's --no-guardrails).
|
||||
if args.extra_body and "guardrails" in args.extra_body:
|
||||
@@ -517,6 +549,18 @@ def main():
|
||||
print(f" Video size: {width}x{height}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
lora_request = None
|
||||
if args.lora_path and args.lora_backend == "peft":
|
||||
if len(args.lora_path) != 1:
|
||||
raise ValueError("Only one LoRA path is expected for PEFT backend.")
|
||||
|
||||
lora_path = args.lora_path[0]
|
||||
lora_request = LoRARequest(
|
||||
lora_name=Path(lora_path).stem,
|
||||
lora_int_id=stable_lora_int_id(lora_path),
|
||||
lora_path=lora_path,
|
||||
)
|
||||
|
||||
negative_prompt = args.negative_prompt
|
||||
if negative_prompt is None and not is_ltx2:
|
||||
# Preserve the historical empty-prompt behavior for non-LTX examples.
|
||||
@@ -530,6 +574,11 @@ def main():
|
||||
width=width,
|
||||
num_frames=num_frames,
|
||||
)
|
||||
sampling_extra_args = {"sample_solver": args.sample_solver}
|
||||
if lora_request is not None:
|
||||
sampling_extra_args["lora_request"] = lora_request
|
||||
sampling_extra_args["lora_scale"] = args.lora_scale
|
||||
|
||||
sampling_params = OmniDiffusionSamplingParams(
|
||||
height=height,
|
||||
width=width,
|
||||
@@ -540,7 +589,7 @@ def main():
|
||||
num_inference_steps=num_inference_steps,
|
||||
num_frames=num_frames,
|
||||
frame_rate=frame_rate,
|
||||
extra_args={"sample_solver": args.sample_solver},
|
||||
extra_args=sampling_extra_args,
|
||||
)
|
||||
if flow_shift is not None:
|
||||
sampling_params.extra_args["flow_shift"] = flow_shift
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import wave
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import imageio
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.diffusion.models.longcat_video.pipeline_longcat_video_avatar import (
|
||||
prepare_longcat_video_avatar_model_for_omni,
|
||||
)
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Generate LongCat-Video-Avatar A2V/AI2V video.")
|
||||
parser.add_argument("--model", default="meituan-longcat/LongCat-Video-Avatar-1.5")
|
||||
parser.add_argument("--base-model-dir", default=None, help="Optional local LongCat-Video base model directory.")
|
||||
parser.add_argument("--stage", choices=["at2v", "ai2v"], default="at2v")
|
||||
parser.add_argument("--prompt", default="A person speaks calmly while facing the camera.")
|
||||
parser.add_argument("--negative-prompt", default="low quality, blurry, watermark, text")
|
||||
parser.add_argument("--audio", default=None, help="Input speech audio path.")
|
||||
parser.add_argument("--image", default=None, help="Input reference image path for AI2V.")
|
||||
parser.add_argument("--input-json", default=None, help="Official LongCat Avatar JSON case for AI2V.")
|
||||
parser.add_argument("--output", default="longcat_avatar_output.mp4")
|
||||
parser.add_argument("--resolution", choices=["480p", "720p"], default="480p")
|
||||
parser.add_argument("--num-frames", type=int, default=93)
|
||||
parser.add_argument("--num-segments", default="1", help="Number of AVC segments, or 'auto' to cover full audio.")
|
||||
parser.add_argument("--num-cond-frames", type=int, default=13)
|
||||
parser.add_argument("--ref-img-index", type=int, default=10)
|
||||
parser.add_argument("--mask-frame-range", type=int, default=3)
|
||||
parser.add_argument("--fps", type=int, default=25)
|
||||
parser.add_argument("--num-inference-steps", type=int, default=8)
|
||||
parser.add_argument("--seed", type=int, default=42)
|
||||
parser.add_argument("--use-distill", action=argparse.BooleanOptionalAction, default=True)
|
||||
parser.add_argument("--use-int8", action=argparse.BooleanOptionalAction, default=True)
|
||||
parser.add_argument(
|
||||
"--build-components-on-gpu",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Build large Avatar components directly on GPU for faster startup. Requires more peak VRAM.",
|
||||
)
|
||||
parser.add_argument("--use-kv-cache", action=argparse.BooleanOptionalAction, default=True)
|
||||
parser.add_argument("--offload-kv-cache", action=argparse.BooleanOptionalAction, default=False)
|
||||
parser.add_argument("--enforce-eager", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _extract_frames(output: Any):
|
||||
if isinstance(output, list):
|
||||
output = output[0] if output else None
|
||||
if isinstance(output, OmniRequestOutput):
|
||||
if not output.images:
|
||||
raise ValueError("No video frames found in OmniRequestOutput.")
|
||||
frames = output.images[0] if len(output.images) == 1 else output.images
|
||||
if isinstance(frames, tuple) and len(frames) == 2:
|
||||
frames = frames[0]
|
||||
if isinstance(frames, dict):
|
||||
frames = frames.get("frames") or frames.get("video")
|
||||
else:
|
||||
frames = output
|
||||
if frames is None:
|
||||
raise ValueError("No video frames found in output.")
|
||||
return frames
|
||||
|
||||
|
||||
def _frames_to_uint8(frames):
|
||||
frames_np = np.asarray([np.asarray(frame) for frame in frames])
|
||||
if frames_np.ndim == 5:
|
||||
frames_np = frames_np[0]
|
||||
if np.issubdtype(frames_np.dtype, np.floating):
|
||||
frames_np = (np.clip(frames_np, 0.0, 1.0) * 255).round()
|
||||
return np.clip(frames_np, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _ffmpeg_exe() -> str:
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
|
||||
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||
except Exception as exc:
|
||||
raise RuntimeError("LongCat Avatar example requires the Python imageio[ffmpeg] dependency.") from exc
|
||||
|
||||
|
||||
def _wav_duration(path: Path) -> float:
|
||||
with wave.open(str(path), "rb") as audio:
|
||||
return audio.getnframes() / float(audio.getframerate())
|
||||
|
||||
|
||||
def _save_video_with_audio(frames, output_path: Path, audio_path: str, fps: int) -> None:
|
||||
frames_np = _frames_to_uint8(frames)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="longcat_avatar_export_") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
temp_video = tmp_dir / "video.mp4"
|
||||
crop_audio = tmp_dir / "audio.wav"
|
||||
crop_video = tmp_dir / "video_cropped.mp4"
|
||||
writer = imageio.get_writer(str(temp_video), fps=fps, quality=5)
|
||||
try:
|
||||
for frame in frames_np:
|
||||
writer.append_data(frame)
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
duration = len(frames_np) / fps
|
||||
ffmpeg = _ffmpeg_exe()
|
||||
subprocess.run([ffmpeg, "-y", "-i", audio_path, "-t", f"{duration}", str(crop_audio)], check=True)
|
||||
audio_duration = _wav_duration(crop_audio)
|
||||
subprocess.run(
|
||||
[
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-i",
|
||||
str(temp_video),
|
||||
"-t",
|
||||
f"{audio_duration}",
|
||||
"-c:v",
|
||||
"copy",
|
||||
str(crop_video),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-i",
|
||||
str(crop_video),
|
||||
"-i",
|
||||
str(crop_audio),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-shortest",
|
||||
str(output_path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def _infer_asset_root_from_input_json(input_json: Path) -> Path:
|
||||
resolved = input_json.resolve()
|
||||
parts = resolved.parts
|
||||
if "assets" not in parts:
|
||||
return resolved.parent
|
||||
asset_idx = parts.index("assets")
|
||||
return Path(*parts[:asset_idx])
|
||||
|
||||
|
||||
def _resolve_case_path(path: str, asset_root: Path) -> str:
|
||||
raw_path = Path(path)
|
||||
if raw_path.is_absolute():
|
||||
return str(raw_path)
|
||||
candidate = asset_root / raw_path
|
||||
return str(candidate if candidate.exists() else raw_path)
|
||||
|
||||
|
||||
def _speaker_sort_key(name: str) -> tuple[int, str]:
|
||||
match = re.search(r"(\d+)$", str(name))
|
||||
return (int(match.group(1)) if match else 10_000, str(name))
|
||||
|
||||
|
||||
def _load_input_case(input_json: str) -> tuple[dict[str, Any], Path]:
|
||||
input_json_path = Path(input_json)
|
||||
with input_json_path.open("r", encoding="utf-8") as f:
|
||||
case = json.load(f)
|
||||
return case, _infer_asset_root_from_input_json(input_json_path)
|
||||
|
||||
|
||||
def _create_merged_audio_for_case(
|
||||
audio_paths: dict[str, str],
|
||||
audio_type: str,
|
||||
output_path: Path,
|
||||
) -> str:
|
||||
sorted_paths = [path for _, path in sorted(audio_paths.items(), key=lambda item: _speaker_sort_key(str(item[0])))]
|
||||
if len(sorted_paths) == 1:
|
||||
return sorted_paths[0]
|
||||
inputs = [arg for path in sorted_paths for arg in ("-i", path)]
|
||||
audio_type = audio_type.lower()
|
||||
if audio_type == "para":
|
||||
filter_complex = f"amix=inputs={len(sorted_paths)}:duration=longest:normalize=0"
|
||||
elif audio_type == "add":
|
||||
filter_complex = "".join(f"[{idx}:a]" for idx in range(len(sorted_paths)))
|
||||
filter_complex += f"concat=n={len(sorted_paths)}:v=0:a=1"
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported LongCat Avatar audio_type {audio_type!r}.")
|
||||
subprocess.run([_ffmpeg_exe(), "-y", *inputs, "-filter_complex", filter_complex, str(output_path)], check=True)
|
||||
return str(output_path)
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
input_case = None
|
||||
input_asset_root = None
|
||||
audio_for_mux = args.audio
|
||||
if args.input_json:
|
||||
input_case, input_asset_root = _load_input_case(args.input_json)
|
||||
args.stage = "ai2v"
|
||||
args.prompt = input_case.get("prompt") or args.prompt
|
||||
args.image = _resolve_case_path(input_case["cond_image"], input_asset_root)
|
||||
args.audio = None
|
||||
if not args.input_json and not args.audio:
|
||||
raise ValueError("--audio is required unless --input-json is provided.")
|
||||
if args.stage == "ai2v" and not args.image:
|
||||
raise ValueError("--image is required when --stage=ai2v.")
|
||||
model_for_omni = prepare_longcat_video_avatar_model_for_omni(args.model, args.use_int8)
|
||||
|
||||
additional_config = {
|
||||
"model_type": "avatar-v1.5",
|
||||
"resolution": args.resolution,
|
||||
"use_distill": args.use_distill,
|
||||
"use_int8": args.use_int8,
|
||||
"build_components_on_gpu": args.build_components_on_gpu,
|
||||
}
|
||||
if args.base_model_dir:
|
||||
additional_config["base_model_dir"] = args.base_model_dir
|
||||
|
||||
omni = Omni(
|
||||
model=model_for_omni,
|
||||
model_class_name="LongCatVideoAvatarPipeline",
|
||||
dtype="bfloat16",
|
||||
enforce_eager=args.enforce_eager,
|
||||
additional_config=additional_config,
|
||||
parallel_config=DiffusionParallelConfig(
|
||||
ulysses_degree=1,
|
||||
ring_degree=1,
|
||||
cfg_parallel_size=1,
|
||||
tensor_parallel_size=1,
|
||||
vae_patch_parallel_size=1,
|
||||
pipeline_parallel_size=1,
|
||||
),
|
||||
)
|
||||
|
||||
audio_input: str | dict[str, str] | None = args.audio
|
||||
additional_information = {"stage": args.stage, "resolution": args.resolution}
|
||||
if input_case is not None and input_asset_root is not None:
|
||||
audio_input = {
|
||||
speaker: _resolve_case_path(path, input_asset_root)
|
||||
for speaker, path in sorted(
|
||||
input_case["cond_audio"].items(),
|
||||
key=lambda item: _speaker_sort_key(str(item[0])),
|
||||
)
|
||||
}
|
||||
additional_information.update(
|
||||
{
|
||||
"input_json": args.input_json,
|
||||
"audio_type": input_case.get("audio_type", "para"),
|
||||
"bbox": input_case.get("bbox", {}),
|
||||
}
|
||||
)
|
||||
|
||||
prompt = {
|
||||
"prompt": args.prompt,
|
||||
"negative_prompt": args.negative_prompt,
|
||||
"multi_modal_data": {"audio": audio_input},
|
||||
"additional_information": additional_information,
|
||||
}
|
||||
if args.stage == "ai2v":
|
||||
prompt["multi_modal_data"]["image"] = args.image
|
||||
|
||||
try:
|
||||
output = omni.generate(
|
||||
prompt,
|
||||
OmniDiffusionSamplingParams(
|
||||
generator=torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed),
|
||||
seed=args.seed,
|
||||
guidance_scale=1.0 if args.use_distill else 4.0,
|
||||
guidance_scale_2=1.0 if args.use_distill else 4.0,
|
||||
num_inference_steps=args.num_inference_steps,
|
||||
num_frames=args.num_frames,
|
||||
fps=args.fps,
|
||||
extra_args={
|
||||
"stage": args.stage,
|
||||
"resolution": args.resolution,
|
||||
"save_fps": args.fps,
|
||||
"use_distill": args.use_distill,
|
||||
"use_int8": args.use_int8,
|
||||
"input_json": args.input_json,
|
||||
"num_segments": args.num_segments,
|
||||
"num_cond_frames": args.num_cond_frames,
|
||||
"ref_img_index": args.ref_img_index,
|
||||
"mask_frame_range": args.mask_frame_range,
|
||||
"use_kv_cache": args.use_kv_cache,
|
||||
"offload_kv_cache": args.offload_kv_cache,
|
||||
},
|
||||
),
|
||||
use_tqdm=True,
|
||||
)
|
||||
finally:
|
||||
omni.close()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="longcat_avatar_audio_mux_") as tmp:
|
||||
if input_case is not None and isinstance(audio_input, dict):
|
||||
audio_for_mux = _create_merged_audio_for_case(
|
||||
audio_input,
|
||||
input_case.get("audio_type", "para"),
|
||||
Path(tmp) / "merged_audio.wav",
|
||||
)
|
||||
_save_video_with_audio(_extract_frames(output), Path(args.output), audio_for_mux, args.fps)
|
||||
print(f"Saved video to {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -109,8 +109,9 @@ python text_to_image.py \
|
||||
| `--ring-degree` | int | `1` | Ring sequence parallel degree for hybrid Ulysses + Ring inference |
|
||||
| `--ulysses-mode` | str | `"strict"` | Ulysses SP mode: `"strict"` or `"advanced_uaa"` |
|
||||
| `--enable-cpu-offload` | flag | off | Enable CPU offloading for diffusion models |
|
||||
| `--lora-path` | str | — | Path to PEFT LoRA adapter folder |
|
||||
| `--lora-path` | str | — | Path to PEFT LoRA adapter folder or checkpoint file |
|
||||
| `--lora-scale` | float | `1.0` | Scale factor for LoRA weights |
|
||||
| `--lora-backend` | str |`"peft"`| LoRA backend for loading LoRA adapters. Default: peft. Available options: peft, distill |
|
||||
| `--use-system-prompt` | str | `None` | System prompt preset: `en_unified`, `en_vanilla`, `en_recaption`, `en_think_recaption`, `dynamic`, `None`, or custom text. Recommended: `en_unified`. Only for HunyuanImage-3.0.|
|
||||
| `--system-prompt` | str | `None` | Custom system prompt text. Only used when `--use-system-prompt` is set to `custom`. Only for HunyuanImage-3.0.|
|
||||
| `--auxiliary-text-encoder` | str | `None` | Supplementary auxiliary text encoder parameters model name or path (especially for Hidream-l1-full). |
|
||||
|
||||
@@ -8,7 +8,9 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from diffusers.utils import numpy_to_pil
|
||||
|
||||
from vllm_omni.diffusion.data import logger
|
||||
from vllm_omni.diffusion.utils.image_output import extract_images_from_outputs
|
||||
@@ -54,6 +56,17 @@ def parse_json_object(value: str, flag_name: str = "argument") -> dict[str, Any]
|
||||
parse_profiler_config = functools.partial(parse_json_object, flag_name="--profiler-config")
|
||||
|
||||
|
||||
def _normalize_images_for_save(images: list[Any]) -> list[Any]:
|
||||
"""Convert NumPy diffusion outputs to PIL images before saving."""
|
||||
normalized = []
|
||||
for image in images:
|
||||
if isinstance(image, np.ndarray):
|
||||
normalized.extend(numpy_to_pil(image))
|
||||
else:
|
||||
normalized.append(image)
|
||||
return normalized
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Generate an image with supported diffusion models.")
|
||||
parser.add_argument(
|
||||
@@ -247,6 +260,7 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument(
|
||||
"--lora-path",
|
||||
type=str,
|
||||
nargs="+",
|
||||
default=None,
|
||||
help="Path to LoRA adapter folder (PEFT format). Loaded at initialization and used for generation.",
|
||||
)
|
||||
@@ -256,6 +270,15 @@ def parse_args() -> argparse.Namespace:
|
||||
default=1.0,
|
||||
help="Scale factor for LoRA weights (default: 1.0).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lora-backend",
|
||||
type=str,
|
||||
default="peft",
|
||||
choices=["peft", "distill"],
|
||||
help="LoRA backend for loading LoRA adapters. Default: peft"
|
||||
"'peft' loads a PEFT-format adapter folder, used e.g. for RL"
|
||||
"'distill' fuses one or more concrete LoRA checkpoint files, used e.g. for distilled few-step LoRAs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vae-patch-parallel-size",
|
||||
type=int,
|
||||
@@ -391,8 +414,12 @@ def main():
|
||||
# Prepare LoRA kwargs for Omni initialization
|
||||
lora_args: dict[str, Any] = {}
|
||||
if args.lora_path:
|
||||
lora_args["lora_path"] = args.lora_path
|
||||
print(f"Using LoRA from: {args.lora_path}")
|
||||
lora_path = args.lora_path
|
||||
if len(lora_path) == 1:
|
||||
lora_path = lora_path[0]
|
||||
lora_args["lora_path"] = lora_path
|
||||
lora_args["lora_backend"] = args.lora_backend
|
||||
print(f"Using LoRA from: {lora_path} with backend: {args.lora_backend}")
|
||||
|
||||
# Build quantization kwargs: use quantization_config dict when
|
||||
# ignored_layers is specified so the list flows through OmniDiffusionConfig
|
||||
@@ -481,12 +508,16 @@ def main():
|
||||
|
||||
# Build LoRA request when --lora-path is set
|
||||
lora_request = None
|
||||
if args.lora_path:
|
||||
lora_request_id = stable_lora_int_id(args.lora_path)
|
||||
if args.lora_path and args.lora_backend == "peft":
|
||||
if len(args.lora_path) != 1:
|
||||
raise ValueError("Only one LoRA path is expected for PEFT backend.")
|
||||
|
||||
lora_path = args.lora_path[0]
|
||||
lora_request_id = stable_lora_int_id(lora_path)
|
||||
lora_request = LoRARequest(
|
||||
lora_name=Path(args.lora_path).stem,
|
||||
lora_name=Path(lora_path).stem,
|
||||
lora_int_id=lora_request_id,
|
||||
lora_path=args.lora_path,
|
||||
lora_path=lora_path,
|
||||
)
|
||||
|
||||
generation_start = time.perf_counter()
|
||||
@@ -619,6 +650,7 @@ def main():
|
||||
|
||||
if not images:
|
||||
raise ValueError("No images found in request_output")
|
||||
images = _normalize_images_for_save(images)
|
||||
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -218,6 +218,9 @@ python text_to_video.py \
|
||||
- `--audio-sample-rate`: fallback audio sample rate when the pipeline returns audio.
|
||||
- `--quantization`: quantization method (such as `fp8` for FP8).
|
||||
- `--flow-shift`: scheduler flow_shift parameter.
|
||||
- `--lora-path`: path to PEFT LoRA adapter folder or checkpoint file.
|
||||
- `--lora-scale`: scale factor for LoRA weights.
|
||||
- `--lora-backend`: backend for loading LoRA adapters. Default: peft. Available options: peft, distill.
|
||||
- `--extra-body`: JSON object of model-specific generation params, filtered against the model's declared `extra_body_params` (see [`vllm_omni/model_extras`](../../../vllm_omni/model_extras)). Used by Cosmos3 (see above).
|
||||
|
||||
### Wan2.2-specific
|
||||
|
||||
@@ -14,6 +14,8 @@ from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.diffusion.utils.param_utils import apply_declared_extra_args
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
from vllm_omni.lora.utils import stable_lora_int_id
|
||||
from vllm_omni.model_extras import get_extra_body_params, get_model_class_name
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
@@ -334,6 +336,29 @@ def parse_args() -> argparse.Namespace:
|
||||
action="store_true",
|
||||
help="Enable expert parallelism for MoE layers.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lora-path",
|
||||
type=str,
|
||||
nargs="+",
|
||||
default=None,
|
||||
help="Path to LoRA adapter folder (PEFT format) or concrete LoRA checkpoint files. Loaded at initialization and used for generation."
|
||||
"Note: for Wan2.2 MoE models, two checkpoints positionally mapped to high-noise and low-noise modules.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lora-scale",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Scale factor for LoRA weights (default: 1.0).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lora-backend",
|
||||
type=str,
|
||||
default="peft",
|
||||
choices=["peft", "distill"],
|
||||
help="LoRA backend for loading LoRA adapters. Default: peft"
|
||||
"'peft' loads a PEFT-format adapter folder, used e.g. for RL"
|
||||
"'distill' fuses one or more concrete LoRA checkpoint files, used e.g. for distilled few-step LoRAs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-hsdp",
|
||||
action="store_true",
|
||||
@@ -440,6 +465,12 @@ def main():
|
||||
omni_kwargs["cache_backend"] = args.cache_backend
|
||||
omni_kwargs["cache_config"] = cache_config
|
||||
omni_kwargs["enable_cache_dit_summary"] = args.enable_cache_dit_summary
|
||||
if args.lora_path is not None:
|
||||
lora_path = args.lora_path
|
||||
if len(lora_path) == 1:
|
||||
lora_path = lora_path[0]
|
||||
omni_kwargs["lora_path"] = lora_path
|
||||
omni_kwargs["lora_backend"] = args.lora_backend
|
||||
|
||||
# Cosmos3 loads its (gated) guardrail models at build time, so the guardrails
|
||||
# gate is an engine-level config (offline analog of the server's --no-guardrails).
|
||||
@@ -469,6 +500,19 @@ def main():
|
||||
print(f" Video size: {args.width}x{args.height}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
lora_request = None
|
||||
if args.lora_path and args.lora_backend == "peft":
|
||||
if len(args.lora_path) != 1:
|
||||
raise ValueError("Only one LoRA path is expected for PEFT backend.")
|
||||
|
||||
lora_path = args.lora_path[0]
|
||||
lora_request_id = stable_lora_int_id(lora_path)
|
||||
lora_request = LoRARequest(
|
||||
lora_name=Path(lora_path).stem,
|
||||
lora_int_id=lora_request_id,
|
||||
lora_path=lora_path,
|
||||
)
|
||||
|
||||
prompt_dict = {"prompt": args.prompt}
|
||||
if args.negative_prompt is not None:
|
||||
prompt_dict["negative_prompt"] = args.negative_prompt
|
||||
@@ -476,6 +520,11 @@ def main():
|
||||
# Preserve the historical empty-prompt behavior for non-LTX examples.
|
||||
prompt_dict["negative_prompt"] = ""
|
||||
|
||||
extra_args = {}
|
||||
if lora_request:
|
||||
extra_args["lora_request"] = lora_request
|
||||
extra_args["lora_scale"] = args.lora_scale
|
||||
|
||||
sampling_kwargs = dict(
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
@@ -483,6 +532,7 @@ def main():
|
||||
guidance_scale=args.guidance_scale,
|
||||
num_inference_steps=args.num_inference_steps,
|
||||
num_frames=args.num_frames,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
if args.guidance_scale_high is not None:
|
||||
sampling_kwargs["guidance_scale_2"] = args.guidance_scale_high
|
||||
|
||||
@@ -23,10 +23,10 @@ including `librosa`.
|
||||
|
||||
The deploy config auto-loads via `--omni`.
|
||||
The default `vllm_omni/deploy/minicpmo_4_5.yaml` keeps all three stages on
|
||||
logical device 0 with memory budgets of 55%, 15%, and 15%, leaving headroom
|
||||
logical device 0 with memory budgets of 55%, 15%, and 18%, leaving headroom
|
||||
for runtime kernels such as the HiFi-GAN vocoder's cuDNN workspace. The
|
||||
profile admits at most four sequences per stage and bounds startup video
|
||||
profiling to 32 frames per video. For throughput,
|
||||
profile admits at most four sequences per stage and bounds Talker context
|
||||
to 4096 tokens. For throughput,
|
||||
`minicpmo_4_5_2gpu.yaml` gives the Thinker
|
||||
GPU 0 (90%) and colocates the Talker (55%) and Code2Wav (35%) on GPU 1. That
|
||||
profile admits at most four concurrent sequences per stage.
|
||||
|
||||
@@ -196,6 +196,7 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
args.model,
|
||||
ref_audio=_ref_audio_data_url(args.ref_audio),
|
||||
session_id=args.session_id,
|
||||
temperature=args.temperature,
|
||||
timeout_s=args.timeout_s,
|
||||
)
|
||||
stream_event_cursor = len(client.events.events)
|
||||
@@ -383,6 +384,12 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--output-dir", default="/tmp/minicpmo_realtime_duplex_demo")
|
||||
parser.add_argument("--chunk-ms", type=int, default=200)
|
||||
parser.add_argument("--timeout-s", type=float, default=60.0)
|
||||
parser.add_argument(
|
||||
"--temperature",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Stage0 sampling temperature; response-required soft-interrupt uses 0.0.",
|
||||
)
|
||||
parser.add_argument("--no-realtime-pacing", action="store_true")
|
||||
parser.add_argument("--require-audio", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -115,7 +115,23 @@ python gradio_demo.py
|
||||
|
||||
## LoRA
|
||||
|
||||
This example supports Peft-compatible LoRA (Low-Rank Adaptation) adapters for diffusion models. The LoRA adapter path must be readable on the **server** machine (usually a local path or a mounted directory).
|
||||
This example supports both startup-time distilled LoRAs and request-time
|
||||
PEFT-compatible adapters. The LoRA path must be readable on the **server**
|
||||
machine (usually a local path or a mounted directory).
|
||||
|
||||
### Start Server with a Distilled LoRA
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen-Image-2512 \
|
||||
--omni \
|
||||
--port 8091 \
|
||||
--lora-backend distill \
|
||||
--lora-path /path/to/Qwen-Image-2512-Lightning-4steps.safetensors
|
||||
```
|
||||
|
||||
The distilled LoRA is fused once during server initialization and applies to
|
||||
every request. Use the checkpoint's few-step sampling settings, for example
|
||||
`num_inference_steps=4` and `true_cfg_scale=1.0`.
|
||||
|
||||
### Using Python Client with LoRA
|
||||
|
||||
|
||||
@@ -89,6 +89,14 @@ local = [
|
||||
"FlagEmbedding",
|
||||
]
|
||||
|
||||
# LongCat-Video-Avatar A2V/AI2V runtime dependencies for vocal
|
||||
# separation and loudness normalization.
|
||||
longcat-video-avatar = [
|
||||
"audio-separator==0.44.2",
|
||||
"pyloudnorm==0.1.1",
|
||||
"soundfile>=0.13.1",
|
||||
]
|
||||
|
||||
# Optional Blackwell-only fused-bias FP8 GEMM; package is `quack-kernels` (imported
|
||||
# as `quack`), not the unrelated `quack`. See docs/user_guide/quantization/fp8.md.
|
||||
quack = [
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# MiniMax-H3 on RTX 4090
|
||||
|
||||
This recipe uses BF16 weights, tiled VAE decode, tensor parallelism, optional
|
||||
Ulysses sequence parallelism, and distributed layerwise offload (DLO). It is the
|
||||
24 GiB sibling of [MiniMax-H3-5090.md](MiniMax-H3-5090.md): the two-GPU topology
|
||||
matches that recipe, but the resident DiT-block count is lower so the per-rank
|
||||
peak fits in 24 GiB. Lower resident counts reduce HBM use and increase
|
||||
CPU-to-GPU transfer time.
|
||||
|
||||
## Capacity requirements
|
||||
|
||||
| Resource | Two RTX 4090s | Four RTX 4090s |
|
||||
| --- | ---: | ---: |
|
||||
| GPU HBM | 24 GiB per GPU | 24 GiB per GPU |
|
||||
| Checkpoint storage | 135 GiB per partition | 135 GiB per partition |
|
||||
| Available system RAM | 200 GiB minimum | 200 GiB minimum |
|
||||
| Recommended system RAM | 384 GiB | 384 GiB |
|
||||
|
||||
`FL2VA` and `Ref2VA` are separate 135 GiB checkpoint partitions. Start one
|
||||
server at a time. DLO keeps rank-local weights in pinned host memory; increasing
|
||||
`--dlo-resident-layers` improves latency but does **not** reduce host RAM in the
|
||||
current implementation because resident layers retain pinned CPU master copies.
|
||||
|
||||
A single RTX 4090 is not covered here. The one-GPU DLO profile in the RTX 5090
|
||||
recipe peaked at 26.50 GiB with 12 resident layers, which exceeds 24 GiB. On one
|
||||
4090, use the model-level CPU offload command in
|
||||
[MiniMax-H3.md](MiniMax-H3.md#single-gpu-accuracy-and-memory-first) instead, or
|
||||
lower the resident count and re-measure before trusting it.
|
||||
|
||||
> **Modular H3:** after #5720 lands, preserve this recipe's one-partition
|
||||
> behavior with `--task-type fl2va` or `--task-type ref2va`.
|
||||
|
||||
## Two RTX 4090s: 1024x576, 5 seconds
|
||||
|
||||
Use TP2 and 12 resident DiT layers. This is the `rtx4090` profile from the main
|
||||
recipe. On two RTX 4090s it peaked at 15,620 MiB per rank for T2VA and
|
||||
14,918 MiB per rank for Ref2VA, leaving roughly 9 GiB of headroom per card. See
|
||||
[Target-hardware validation](#target-hardware-validation) for the full numbers.
|
||||
|
||||
```bash
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
export VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400
|
||||
|
||||
CUDA_VISIBLE_DEVICES=0,1 vllm serve /path/to/MiniMax-H3/FL2VA \
|
||||
--omni --trust-remote-code --host 0.0.0.0 --port 8000 \
|
||||
--num-gpus 2 --tensor-parallel-size 2 --text-encoder-tp-size 2 \
|
||||
--usp 1 --ring 1 --vae-patch-parallel-size 2 \
|
||||
--vae-parallel-mode tile --vae-use-tiling \
|
||||
--enable-distributed-layerwise-offload --dlo-no-use-allgather \
|
||||
--dlo-resident-layers 12 --enforce-eager \
|
||||
--diffusion-attention-backend CUDNN_ATTN
|
||||
```
|
||||
|
||||
For Ref2VA, stop the FL2VA server and restart the same command with
|
||||
`/path/to/MiniMax-H3/Ref2VA`. Ref2VA reference image count and prompt length can
|
||||
increase activation memory; begin with one request at a time.
|
||||
|
||||
`VLLM_OMNI_VIDEO_SYNC_TIMEOUT` matters more here than on larger cards. DLO
|
||||
streams non-resident DiT blocks over PCIe on every denoising step, so a 60-step
|
||||
request takes several minutes and would otherwise hit the 600-second default.
|
||||
|
||||
## Four RTX 4090s: 1024x576, 5 seconds
|
||||
|
||||
Keep TP2 and raise Ulysses sequence parallel to 2 so the world size is
|
||||
`TP × USP = 4`. Text-encoder TP and VAE patch parallel follow the GPU count.
|
||||
Resident layers stay at 12: adding USP does not shard DiT weights further, so
|
||||
per-GPU peak HBM stays close to the two-GPU profile while wall-clock improves.
|
||||
|
||||
```bash
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
export VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400
|
||||
|
||||
CUDA_VISIBLE_DEVICES=0,1,2,3 vllm serve /path/to/MiniMax-H3/FL2VA \
|
||||
--omni --trust-remote-code --host 0.0.0.0 --port 8000 \
|
||||
--num-gpus 4 --tensor-parallel-size 2 --text-encoder-tp-size 4 \
|
||||
--usp 2 --ring 1 --vae-patch-parallel-size 4 \
|
||||
--vae-parallel-mode tile --vae-use-tiling \
|
||||
--enable-distributed-layerwise-offload --dlo-no-use-allgather \
|
||||
--dlo-resident-layers 12 --enforce-eager \
|
||||
--diffusion-attention-backend CUDNN_ATTN
|
||||
```
|
||||
|
||||
For Ref2VA, stop the FL2VA server and restart the same command with
|
||||
`/path/to/MiniMax-H3/Ref2VA`. Pin four dedicated GPUs; if the launcher exposes
|
||||
fewer than four devices, startup fails with
|
||||
`Stage 0 requires 4 device(s) based on parallel_config`.
|
||||
|
||||
## Target-hardware validation
|
||||
|
||||
Measured on RTX 4090 (24,564 MiB each, driver 580.126.09) with the serve
|
||||
commands above, at vLLM-Omni `0.26.1.dev55+g81b48e83e`, vLLM `0.26.0`, and
|
||||
PyTorch `2.11.0+cu130`. GPUs used by each server were dedicated. `Client E2E`
|
||||
and `Peak per GPU` come from the `/v1/videos/sync` response headers
|
||||
`x-inference-time-s` and `x-peak-memory-mb`; the latter is the rank-0 CUDA
|
||||
reserved high-water mark, so it is a per-GPU figure.
|
||||
|
||||
| GPUs | Topology | Task | Shape | Frames | Steps | Client E2E | Peak per GPU | Output validation |
|
||||
| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| 2 | TP2 × USP1 | T2VA (`FL2VA`) | 1024x576 | 124 at 24 FPS | 60 | 7 min 9 s | 15.3 GiB | H.264 video + 32 kHz stereo AAC; full `ffmpeg` decode passed |
|
||||
| 2 | TP2 × USP1 | Ref2VA | 1024x576 | 124 at 24 FPS | 60 | 14 min 52 s | 14.6 GiB | H.264 video + 32 kHz stereo AAC; full `ffmpeg` decode passed |
|
||||
| 4 | TP2 × USP2 | T2VA (`FL2VA`) | 1024x576 | 124 at 24 FPS | 60 | 4 min 29 s | 15.2 GiB | H.264 video + 32 kHz stereo AAC; full `ffmpeg` decode passed |
|
||||
| 4 | TP2 × USP2 | Ref2VA | 1024x576 | 124 at 24 FPS | 60 | 9 min 5 s | 16.1 GiB | H.264 video + 32 kHz stereo AAC; successful `/v1/videos/sync` 200 |
|
||||
|
||||
All rows use `seed=1101`, `flow_shift=12`, and `audio_flow_shift=3.0`.
|
||||
|
||||
Two-GPU repeats: T2VA measured 434.9 s and 429.3 s; Ref2VA measured 891.9 s and
|
||||
892.1 s. Peak memory was identical across repeats for both tasks, and the two
|
||||
outputs of each pair agreed to within 115 bytes.
|
||||
|
||||
Four-GPU T2VA repeats: 273.8 s and 269.5 s, peaking at 15,560 MiB and
|
||||
15,612 MiB. Four-GPU Ref2VA is a single successful end-to-end run (545.2 s,
|
||||
16,448 MiB). Follow-up Ref2VA requests on this build completed diffusion but
|
||||
then hit the engine's hardcoded 30 s async output wait
|
||||
(`_ASYNC_OUTPUT_TIMEOUT` in `diffusion_engine.py`) during post-compute D2H,
|
||||
so they are not reported as repeats.
|
||||
|
||||
Relative to the two-GPU baseline on the same shape, four-GPU TP2×USP2 was about
|
||||
1.6× faster for both tasks, while per-GPU peak HBM stayed in the mid-15 GiB
|
||||
range because USP does not further shard resident DiT weights. These are
|
||||
single-request validation runs, not concurrent throughput benchmarks.
|
||||
|
||||
## Request examples
|
||||
|
||||
`t2va` requires an explicit `aspect_ratio`. Without it the request fails with
|
||||
`t2va requires an explicit aspect_ratio`, even when `width` and `height` are set.
|
||||
|
||||
Text to video and audio, against the `FL2VA` server:
|
||||
|
||||
```bash
|
||||
curl -sS -D headers_t2va.txt -o out_t2va.mp4 --max-time 14400 \
|
||||
-X POST "http://127.0.0.1:8000/v1/videos/sync" \
|
||||
-F 'prompt=傍晚的小厨房,夕阳余晖从窗边洒进来,旧木桌上放着洗到一半的马克杯和起雾的玻璃瓶,悬挂的抹布轻轻晃动。画面带有手持拍摄的轻微晃动和逆光曝光波动,生活感十足。环境音是安静的厨房底噪。' \
|
||||
-F 'aspect_ratio=16:9' \
|
||||
-F 'width=1024' -F 'height=576' -F 'fps=24' \
|
||||
-F 'num_inference_steps=60' -F 'flow_shift=12' -F 'seed=1101' \
|
||||
-F 'extra_params={"task":"t2va","duration":5,"audio_flow_shift":3.0}'
|
||||
```
|
||||
|
||||
Reference to video and audio, against the `Ref2VA` server:
|
||||
|
||||
```bash
|
||||
curl -sS -D headers_ref2va.txt -o out_ref2va.mp4 --max-time 14400 \
|
||||
-X POST "http://127.0.0.1:8000/v1/videos/sync" \
|
||||
-F 'prompt=傍晚的小厨房,夕阳余晖从窗边洒进来,旧木桌上放着洗到一半的马克杯和起雾的玻璃瓶,悬挂的抹布轻轻晃动。画面带有手持拍摄的轻微晃动和逆光曝光波动,生活感十足。环境音是安静的厨房底噪。' \
|
||||
-F 'input_reference=@/path/to/reference.jpg;type=image/jpeg' \
|
||||
-F 'audio_reference=</path/to/audio_reference.json' \
|
||||
-F 'width=1024' -F 'height=576' -F 'fps=24' \
|
||||
-F 'num_inference_steps=60' -F 'flow_shift=12' -F 'seed=1101' \
|
||||
-F 'extra_params={"task":"ref2va","duration":5,"audio_flow_shift":3.0}'
|
||||
```
|
||||
|
||||
`audio_reference` is a JSON object, which is why the example loads it from a file
|
||||
with curl's `<` syntax. Its `audio_url` must be an `http(s)` URL or a data URL;
|
||||
a bare filesystem path is rejected with `Invalid audio_reference.audio_url`.
|
||||
|
||||
```json
|
||||
{"audio_url": "https://example.com/reference.wav"}
|
||||
```
|
||||
|
||||
Reference images are validated before inference: the short edge must be at least
|
||||
256 pixels, the long edge at most 5760, and the aspect ratio must fall between
|
||||
0.4 and 2.5.
|
||||
|
||||
The response headers carry the measurements used in the table above:
|
||||
|
||||
```bash
|
||||
cat headers_t2va.txt
|
||||
ffmpeg -v error -i out_t2va.mp4 -f null - && echo DECODE_OK
|
||||
```
|
||||
@@ -79,7 +79,8 @@ sharing the tokenizer, processor, text encoder, and VAEs from `FL2VA`.
|
||||
### Multi-NPU: 768P combined-service configuration
|
||||
|
||||
Use Ulysses sequence parallelism degree 8, text-encoder tensor parallelism
|
||||
degree 8, native tiled VAE patch parallelism degree 8, and layerwise offload:
|
||||
degree 8, native tiled VAE patch parallelism degree 8, and distributed
|
||||
layerwise offload:
|
||||
|
||||
```bash
|
||||
export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7
|
||||
@@ -98,7 +99,7 @@ vllm serve "${MODEL}" \
|
||||
--usp 8 \
|
||||
--ring 1 \
|
||||
--text-encoder-tp-size 8 \
|
||||
--enable-layerwise-offload \
|
||||
--enable-distributed-layerwise-offload \
|
||||
--vae-parallel-mode tile \
|
||||
--vae-use-tiling \
|
||||
--vae-patch-parallel-size 8 \
|
||||
@@ -112,6 +113,11 @@ H3 is CFG-distilled, so `--cfg-parallel-size` must remain 1.
|
||||
The same endpoint accepts `task=t2va`, `task=fl2va`, and `task=ref2va`; no
|
||||
partition restart is required. Layerwise offload applies to both DiTs.
|
||||
|
||||
On Atlas 800I A3 (64 GB HBM per device) the combined service does not fit at
|
||||
768P without offloading or sharding: use distributed layerwise offload (as
|
||||
above) or HSDP — see
|
||||
[§ Memory and attention optimizations](#memory-and-attention-optimizations-a3).
|
||||
|
||||
### Optional optimizations
|
||||
|
||||
Two independent optimizations may be enabled on top of the configuration
|
||||
@@ -134,6 +140,63 @@ Keep `--ring 1` when using RainFusion: the `rf_v2` kernel ranks key blocks
|
||||
over the whole sequence, so ring parallelism would split away the keys it
|
||||
needs. Scale with `--usp` instead.
|
||||
|
||||
## Memory and attention optimizations (A3)
|
||||
|
||||
### Fitting 768P into 64 GB HBM: distributed layerwise offload or HSDP
|
||||
|
||||
Each Atlas 800I A3 NPU has 64 GB of HBM, which is not enough for the
|
||||
combined MiniMax-H3 service at 768P. Enable **one** of the following at
|
||||
server startup:
|
||||
|
||||
**Distributed layerwise offload** — DiT layers are offloaded with parameters
|
||||
gathered across the parallel group instead of replicated per rank:
|
||||
|
||||
```bash
|
||||
--enable-distributed-layerwise-offload
|
||||
```
|
||||
|
||||
Measured peak NPU memory is about 45 GB per device for Ref2VA with a 13.88 s
|
||||
input video generating a 15 s 768P (1344x768) output video. Host (CPU) memory
|
||||
usage is high with this option.
|
||||
|
||||
**HSDP** — hybrid sharded data parallelism for the DiT parameters. The
|
||||
multi-stream memory-reuse knob is mandatory with this flag:
|
||||
|
||||
```bash
|
||||
export MULTI_STREAM_MEMORY_REUSE=2
|
||||
```
|
||||
|
||||
```bash
|
||||
--use-hsdp
|
||||
```
|
||||
|
||||
Host memory usage is small, but large shapes may still OOM; HBM usage
|
||||
optimizations for this path are ongoing.
|
||||
|
||||
### FLASH_ATTN backend with MindIE-SD
|
||||
|
||||
Keep `--diffusion-attention-backend FLASH_ATTN` and install MindIE-SD (see
|
||||
[§ Environment](#environment)). On NPU this backend carries most of the
|
||||
memory-reduction and performance work: a mask-free packed varlen path that
|
||||
never materializes the quadratic `full_qk` padding mask, and K/V prefix
|
||||
slicing, both driven by the packed `cu_seqlens` metadata emitted by the H3
|
||||
transformer.
|
||||
|
||||
### Optional: LaserAttention fused kernel
|
||||
|
||||
For an additional attention speedup, select the Ascend LaserAttention fused
|
||||
kernel before starting the server:
|
||||
|
||||
```bash
|
||||
export MINDIE_SD_FA_TYPE="ascend_laser_attention"
|
||||
```
|
||||
|
||||
This requires the FLASH_ATTN backend and MindIE-SD. H3 automatically applies
|
||||
exact power-of-two input pre-scaling (`laser_input_scale=256`) so the
|
||||
kernel's fp16 workspace cannot overflow on outlier activations. Measured
|
||||
speedup numbers will be added here.
|
||||
|
||||
|
||||
## HTTP API examples
|
||||
|
||||
The request format is identical to the GPU recipe; see
|
||||
@@ -154,8 +217,8 @@ configuration above:
|
||||
|
||||
| Workload | Configuration |
|
||||
|----------|---------------|
|
||||
| T2VA, 209 frames, 1344x768 | TE TP8, layerwise offload, Ulysses 8, VPP8 tile, regional compile |
|
||||
| Ref2VA (prompt + video), 124 frames, 1344x768 | TE TP8, layerwise offload, Ulysses 8, VPP8 tile, regional compile |
|
||||
| T2VA, 209 frames, 1344x768 | TE TP8, distributed layerwise offload, Ulysses 8, VPP8 tile, regional compile |
|
||||
| Ref2VA (prompt + video), 124 frames, 1344x768 | TE TP8, distributed layerwise offload, Ulysses 8, VPP8 tile, regional compile |
|
||||
|
||||
These measurements describe the validated shapes rather than a general
|
||||
throughput guarantee.
|
||||
|
||||
@@ -30,8 +30,9 @@ also provided.
|
||||
`hf_config.version="4.5"`):
|
||||
- Default single-GPU compatibility layout (auto-loaded):
|
||||
[`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml)
|
||||
- 2-GPU and 3-GPU layouts:
|
||||
- Recommended 2-GPU continuous-batching layout:
|
||||
[`vllm_omni/deploy/minicpmo_4_5_2gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_2gpu.yaml),
|
||||
- 3-GPU layout:
|
||||
[`vllm_omni/deploy/minicpmo_4_5_3gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_3gpu.yaml)
|
||||
- 8x RTX 4090 layout:
|
||||
[`vllm_omni/deploy/minicpmo_4_5_8x4090.yaml`](../../vllm_omni/deploy/minicpmo_4_5_8x4090.yaml)
|
||||
|
||||
@@ -55,6 +55,7 @@ recipes/
|
||||
| [`krea/Krea-2.md`](./krea/Krea-2.md) | Text-to-image (Turbo + Raw), offline + online, with LoRA | 1x H100 80GB |
|
||||
| [`LTX/LTX-2.md`](./LTX/LTX-2.md) | LTX-2/LTX-2.3 text-to-video and image-to-video with synchronized audio | H200 141GB / 96GB-class GPU |
|
||||
| [`MammothModa2/MammothModa2.md`](./MammothModa2/MammothModa2.md) | Preview and Dev text-to-image (AR → DiT); Dev text/image understanding | Preview: 1x L40S 48GB / 1x ≥40GB GPU; Dev: 1x NVIDIA GPU with sufficient cache headroom |
|
||||
| [`meituan-longcat/LongCat-Video-Avatar-1.5.md`](./meituan-longcat/LongCat-Video-Avatar-1.5.md) | Audio-driven avatar video generation (AT2V / AI2V, single- and multi-speaker, AVC continuation) | 1x H100 80GB |
|
||||
| [`mistralai/Voxtral-TTS.md`](./mistralai/Voxtral-TTS.md) | Online serving for TTS | 1x RTX 4090 24GB |
|
||||
| [`Robbyant/LingBot-Video.md`](./Robbyant/LingBot-Video.md) | Native dense and MoE text-to-video serving | Dense: 1x L20X; MoE: 1x L20X (~67.7 GiB peak) |
|
||||
| [`cosmos3/Cosmos3-Nano.md`](./cosmos3/Cosmos3-Nano.md) | Text-to-image, text-to-video, image-to-video, video-to-video generation, text to video with sound, action policy | 1x H200 141GB / B300 |
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
# LongCat-Video-Avatar-1.5
|
||||
|
||||
> Audio-driven avatar video generation (AT2V / AI2V)
|
||||
|
||||
## Summary
|
||||
|
||||
- Vendor: meituan-longcat
|
||||
- Model: `meituan-longcat/LongCat-Video-Avatar-1.5`
|
||||
- Task: Audio-to-video (AT2V) and audio-and-image-to-video (AI2V) avatar generation
|
||||
- Mode: Offline inference
|
||||
- Maintainer: Community
|
||||
|
||||
## When to use this recipe
|
||||
|
||||
Use this recipe to drive a talking or singing avatar from a speech clip, either
|
||||
from text alone (AT2V) or from a reference image (AI2V), with the native
|
||||
LongCat-Video-Avatar-1.5 pipeline in vLLM-Omni. It covers:
|
||||
|
||||
1. **Single-speaker AT2V / AI2V** — one 93-frame segment from one audio track.
|
||||
2. **AVC continuation** — chained segments that cover a longer audio track.
|
||||
3. **Multi-speaker AI2V** — several audio tracks placed on a reference image
|
||||
through speaker bounding boxes.
|
||||
|
||||
The example saves an mp4 with the generated frames muxed with the input audio.
|
||||
|
||||
## References
|
||||
|
||||
- Upstream model card: <https://huggingface.co/meituan-longcat/LongCat-Video-Avatar-1.5>
|
||||
- Upstream repository and example assets: <https://github.com/meituan-longcat/LongCat-Video>
|
||||
- Related example under `examples/`: [`longcat_video/end2end.py`](../../examples/offline_inference/longcat_video/end2end.py)
|
||||
|
||||
## Hardware Support
|
||||
|
||||
## CUDA
|
||||
|
||||
### 1× NVIDIA H100 (80 GB)
|
||||
|
||||
#### Environment
|
||||
|
||||
- OS: Linux
|
||||
- Python: 3.10+
|
||||
- Driver: NVIDIA driver with CUDA 12.x
|
||||
- vLLM version: Match the repository requirements for your checkout
|
||||
- vLLM-Omni version or commit: Use the commit you are deploying from
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
Install the LongCat Avatar optional runtime dependencies:
|
||||
|
||||
```bash
|
||||
pip install "vllm-omni[longcat-video-avatar]"
|
||||
```
|
||||
|
||||
The example uses the `imageio_ffmpeg` executable installed by vLLM-Omni's
|
||||
`imageio[ffmpeg]` dependency.
|
||||
|
||||
#### Command
|
||||
|
||||
Audio-to-video (AT2V):
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/longcat_video/end2end.py \
|
||||
--model meituan-longcat/LongCat-Video-Avatar-1.5 \
|
||||
--stage at2v \
|
||||
--audio /path/to/speech.wav \
|
||||
--prompt "A person speaks calmly while facing the camera." \
|
||||
--num-frames 93 \
|
||||
--num-inference-steps 8 \
|
||||
--output longcat_avatar_at2v.mp4
|
||||
```
|
||||
|
||||
Audio-and-image-to-video (AI2V):
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/longcat_video/end2end.py \
|
||||
--model meituan-longcat/LongCat-Video-Avatar-1.5 \
|
||||
--stage ai2v \
|
||||
--audio /path/to/speech.wav \
|
||||
--image /path/to/reference.jpg \
|
||||
--prompt "A person speaks calmly while facing the camera." \
|
||||
--num-frames 93 \
|
||||
--num-inference-steps 8 \
|
||||
--output longcat_avatar_ai2v.mp4
|
||||
```
|
||||
|
||||
#### Official Example Assets
|
||||
|
||||
Download the official Avatar example assets:
|
||||
|
||||
```bash
|
||||
mkdir -p longcat_avatar_assets/assets/avatar/single longcat_avatar_assets/assets/avatar/multi
|
||||
|
||||
curl -L -o longcat_avatar_assets/assets/avatar/single/man.mp3 \
|
||||
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/single/man.mp3
|
||||
curl -L -o longcat_avatar_assets/assets/avatar/single/man.png \
|
||||
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/single/man.png
|
||||
curl -L -o longcat_avatar_assets/assets/avatar/single_example_1.json \
|
||||
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/single_example_1.json
|
||||
|
||||
curl -L -o longcat_avatar_assets/assets/avatar/multi/sing.png \
|
||||
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi/sing.png
|
||||
curl -L -o longcat_avatar_assets/assets/avatar/multi/sing_man.WAV \
|
||||
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi/sing_man.WAV
|
||||
curl -L -o longcat_avatar_assets/assets/avatar/multi/sing_woman.WAV \
|
||||
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi/sing_woman.WAV
|
||||
curl -L -o longcat_avatar_assets/assets/avatar/multi/introduce.png \
|
||||
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi/introduce.png
|
||||
curl -L -o longcat_avatar_assets/assets/avatar/multi/introduce_man.mp3 \
|
||||
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi/introduce_man.mp3
|
||||
curl -L -o longcat_avatar_assets/assets/avatar/multi/introduce_woman.mp3 \
|
||||
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi/introduce_woman.mp3
|
||||
curl -L -o longcat_avatar_assets/assets/avatar/multi_example_1.json \
|
||||
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi_example_1.json
|
||||
curl -L -o longcat_avatar_assets/assets/avatar/multi_example_2.json \
|
||||
https://raw.githubusercontent.com/meituan-longcat/LongCat-Video/main/assets/avatar/multi_example_2.json
|
||||
|
||||
export LONGCAT_VIDEO_ASSET_DIR="$PWD/longcat_avatar_assets/assets/avatar"
|
||||
export AVATAR_MODEL=meituan-longcat/LongCat-Video-Avatar-1.5
|
||||
export PROMPT="$(python -c 'import json, os; print(json.load(open(os.environ["LONGCAT_VIDEO_ASSET_DIR"] + "/single_example_1.json"))["prompt"])')"
|
||||
```
|
||||
|
||||
`--base-model-dir` points at a local LongCat-Video base checkpoint for the
|
||||
tokenizer, text encoder and VAE; omit it to download those components from
|
||||
Hugging Face.
|
||||
|
||||
```bash
|
||||
export BASE_MODEL_DIR=/cache/longcat_models/LongCat-Video
|
||||
```
|
||||
|
||||
#### Official Asset Smoke Test
|
||||
|
||||
Audio-to-video with the official audio:
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/longcat_video/end2end.py \
|
||||
--model "$AVATAR_MODEL" \
|
||||
--base-model-dir "$BASE_MODEL_DIR" \
|
||||
--stage at2v \
|
||||
--audio "$LONGCAT_VIDEO_ASSET_DIR/single/man.mp3" \
|
||||
--prompt "$PROMPT" \
|
||||
--num-frames 93 \
|
||||
--num-inference-steps 8 \
|
||||
--output official_asset_longcat_avatar_at2v.mp4
|
||||
```
|
||||
|
||||
Audio-and-image-to-video with the official audio and image:
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/longcat_video/end2end.py \
|
||||
--model "$AVATAR_MODEL" \
|
||||
--base-model-dir "$BASE_MODEL_DIR" \
|
||||
--stage ai2v \
|
||||
--audio "$LONGCAT_VIDEO_ASSET_DIR/single/man.mp3" \
|
||||
--image "$LONGCAT_VIDEO_ASSET_DIR/single/man.png" \
|
||||
--prompt "$PROMPT" \
|
||||
--num-frames 93 \
|
||||
--num-inference-steps 8 \
|
||||
--output official_asset_longcat_avatar_ai2v.mp4
|
||||
```
|
||||
|
||||
#### Official Single-Speaker AVC Continuation
|
||||
|
||||
`--num-segments` chains AVC segments; `auto` covers the full input audio.
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/longcat_video/end2end.py \
|
||||
--model "$AVATAR_MODEL" \
|
||||
--base-model-dir "$BASE_MODEL_DIR" \
|
||||
--input-json "$LONGCAT_VIDEO_ASSET_DIR/single_example_1.json" \
|
||||
--num-frames 93 \
|
||||
--num-cond-frames 13 \
|
||||
--num-segments 5 \
|
||||
--num-inference-steps 8 \
|
||||
--output official_asset_longcat_avatar_single_example_1_ai2v_5seg.mp4
|
||||
```
|
||||
|
||||
Replace `--num-segments 5` with `--num-segments auto` to run until the full
|
||||
official audio is covered. That is a slow archival validation path rather than a
|
||||
regular smoke test: about 24m28s on a Modal H100 for the full
|
||||
`single_example_1.json` audio with 8 inference steps.
|
||||
|
||||
#### Official Multi-Speaker AI2V
|
||||
|
||||
The multi-speaker cases use several audio tracks with speaker masks and optional
|
||||
bounding boxes. The command below generates the first 93-frame segment, matching
|
||||
the first call to the official `generate_ai2v()`.
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/longcat_video/end2end.py \
|
||||
--model "$AVATAR_MODEL" \
|
||||
--base-model-dir "$BASE_MODEL_DIR" \
|
||||
--input-json "$LONGCAT_VIDEO_ASSET_DIR/multi_example_1.json" \
|
||||
--num-frames 93 \
|
||||
--num-inference-steps 8 \
|
||||
--output official_asset_longcat_avatar_multi_example_1_ai2v.mp4
|
||||
```
|
||||
|
||||
Use `multi_example_2.json` for the sequential-speaking case, and add
|
||||
`--num-cond-frames 13 --num-segments auto` to cover the full audio with AVC
|
||||
continuation.
|
||||
|
||||
#### Verification
|
||||
|
||||
```bash
|
||||
ffprobe -hide_banner longcat_avatar_ai2v.mp4
|
||||
```
|
||||
|
||||
The output must report one video stream and one audio stream: the example muxes
|
||||
the generated frames with the input audio.
|
||||
|
||||
#### Notes
|
||||
|
||||
**Support matrix**
|
||||
|
||||
| Mode | Speakers | Image required | AVC continuation |
|
||||
| --- | --- | --- | --- |
|
||||
| AT2V | Single | No | Yes |
|
||||
| AI2V | Single | Yes | Yes |
|
||||
| AT2V | Multi | Not supported | Not supported |
|
||||
| AI2V | Multi | Yes | Yes |
|
||||
|
||||
Multi-speaker Avatar requires AI2V because speaker bounding boxes and masks are
|
||||
defined on a reference image; AT2V with several tracks fails with an explicit
|
||||
error. AVC continuation uses full-audio embeddings only when `--num-segments` is
|
||||
greater than `1` or set to `auto`; single-segment AT2V/AI2V keeps the shorter
|
||||
first-segment audio path.
|
||||
|
||||
**Sampling parameters**
|
||||
|
||||
| Parameter | LongCat pipeline value | Description |
|
||||
| --- | --- | --- |
|
||||
| `--num-frames` | `num_frames` | Frames generated per segment. The default is 93 frames, or about 3.72 seconds at 25 FPS. LongCat normalizes other values to the temporal-VAE-compatible `4k+1` form. |
|
||||
| `--num-inference-steps` | `steps` | Denoising steps. The distilled Avatar path uses 8 steps. |
|
||||
| `--fps` | `save_fps` | Output frame rate, and the rate used to align audio embeddings with video frames. The default is 25 FPS. |
|
||||
| `sampling_params.guidance_scale` | `text_guidance_scale` | Classifier-free guidance applied to the text prompt. |
|
||||
| `sampling_params.guidance_scale_2` | `audio_guidance_scale` | Classifier-free guidance applied to the speech audio. |
|
||||
|
||||
With the distilled LoRA enabled (the default), the pipeline uses 8 inference
|
||||
steps and sets both guidance scales to `1.0`. With `--no-use-distill`, the
|
||||
example sets both to `4.0`. For AVC continuation every segment holds
|
||||
`num_frames` frames, but each segment after the first reuses `num_cond_frames`
|
||||
conditioning frames, so the unique frame count is
|
||||
`num_frames + (num_segments - 1) * (num_frames - num_cond_frames)`.
|
||||
|
||||
**Key flags**
|
||||
|
||||
- `--stage`: `at2v` for audio-to-video, `ai2v` for audio-and-image-to-video.
|
||||
- `--resolution`: `480p` or `720p`.
|
||||
- `--input-json`: official LongCat Avatar JSON case, used for the single-speaker
|
||||
and multi-speaker AI2V examples.
|
||||
- `--num-segments`: number of AVC segments, or `auto` to cover the full audio.
|
||||
- `--num-cond-frames`: previous-frame conditioning window for AVC continuation.
|
||||
- `--use-int8` / `--no-use-int8`: load the official INT8 Avatar DiT weights by
|
||||
default, or full precision weights.
|
||||
- `--use-distill` / `--no-use-distill`: enable the official distilled LoRA path
|
||||
by default.
|
||||
- `--build-components-on-gpu`: build large Avatar components directly on GPU for
|
||||
faster startup, at a higher peak VRAM cost. Disabled by default.
|
||||
- `--base-model-dir`: local LongCat-Video base model directory for the
|
||||
tokenizer, text encoder and VAE components.
|
||||
|
||||
**Memory usage**
|
||||
|
||||
Measured on a Modal H100 running the official 93-frame AI2V example with the
|
||||
INT8 DiT, distilled LoRA and 8 inference steps:
|
||||
|
||||
| Component build | CLI option | Peak GPU memory | Model loading time |
|
||||
| --- | --- | ---: | ---: |
|
||||
| CPU (default) | None | About 41.0 GiB | About 130s |
|
||||
| GPU | `--build-components-on-gpu` | About 56.8 GiB | About 33s |
|
||||
|
||||
The reported peak covers the end-to-end run. Building components on CPU only
|
||||
reduces initialization memory; denoising still runs on GPU. These numbers are
|
||||
memory guidance rather than a performance benchmark.
|
||||
|
||||
**Known limitations**
|
||||
|
||||
- Single GPU only. Cache acceleration, sequence/CFG/tensor parallelism, HSDP,
|
||||
CPU offload, VAE patch parallelism and quantization are not wired up for this
|
||||
pipeline yet; see the diffusion feature matrix in
|
||||
[`docs/user_guide/diffusion_features.md`](../../docs/user_guide/diffusion_features.md).
|
||||
- Offline inference only. Online serving is not supported yet.
|
||||
@@ -6,13 +6,21 @@ Unit tests for patch.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
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]
|
||||
|
||||
@@ -40,6 +48,156 @@ class MockResponse:
|
||||
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):
|
||||
"""Helper to create SSE formatted chunk"""
|
||||
return f"data: {json.dumps(data_dict)}\n\n".encode()
|
||||
|
||||
@@ -42,7 +42,10 @@ from benchmarks.accuracy.text_to_image.gbench import (
|
||||
summarize_generated_records as summarize_gebench_generated_records,
|
||||
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 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"
|
||||
|
||||
|
||||
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):
|
||||
ns = argparse.Namespace(
|
||||
daily_omni_repo=None,
|
||||
|
||||
@@ -28,10 +28,13 @@ if _MODULE_NAME not in sys.modules:
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import ( # noqa: E402
|
||||
SeedTTSDataset,
|
||||
SeedTTSDesignDataset,
|
||||
SeedTTSDesignSampleRequest,
|
||||
SeedTTSSampleRequest,
|
||||
SeedTTSTextDataset,
|
||||
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):
|
||||
ds = SeedTTSTextDataset(
|
||||
dataset_path=str(seed_tts_root),
|
||||
|
||||
@@ -2206,6 +2206,24 @@ class TestBaseConfigInheritance:
|
||||
class TestPlatformOverrides:
|
||||
"""Test platform-specific deploy config overrides."""
|
||||
|
||||
def test_qwen3_omni_musa_clears_parent_quantization_for_audio_stages(self):
|
||||
pipeline = resolve_pipeline_config(
|
||||
"qwen3_omni_moe",
|
||||
Q3_OMNI_ALL_STAGES_HF_CONFIG,
|
||||
)
|
||||
assert isinstance(pipeline, PipelineConfig)
|
||||
deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml"))
|
||||
|
||||
base_stages = merge_pipeline_deploy(pipeline, load_deploy_config(deploy_path))
|
||||
assert "hf_overrides" not in base_stages[1].yaml_engine_args
|
||||
assert "hf_overrides" not in base_stages[2].yaml_engine_args
|
||||
|
||||
musa = _apply_platform_overrides(load_deploy_config(deploy_path), platform="musa")
|
||||
musa_stages = merge_pipeline_deploy(pipeline, musa)
|
||||
expected = {"quantization_config": None}
|
||||
assert musa_stages[1].yaml_engine_args["hf_overrides"] == expected
|
||||
assert musa_stages[2].yaml_engine_args["hf_overrides"] == expected
|
||||
|
||||
def test_qwen3_tts_rocm_disables_code2wav_outer_cudagraph(self):
|
||||
deploy_path = Path(get_deploy_config_path("qwen3_tts.yaml"))
|
||||
|
||||
@@ -2241,7 +2259,8 @@ class TestPlatformOverrides:
|
||||
load_deploy_config(Path(get_deploy_config_path(filename))), platform="cuda"
|
||||
)
|
||||
replica_stages = merge_pipeline_deploy(pipeline, replica)
|
||||
assert replica_stages[1].yaml_engine_args["kv_cache_memory_bytes"] is None
|
||||
# Explicit null clears the inherited single-GPU 2 GiB CUDA cap.
|
||||
assert replica_stages[1].yaml_engine_args.get("kv_cache_memory_bytes") is None
|
||||
|
||||
def test_npu_overrides(self):
|
||||
deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml"))
|
||||
|
||||
@@ -474,6 +474,8 @@ def test_sub_config_fields_match_structured_scopes():
|
||||
"moe_backend",
|
||||
"hf_overrides",
|
||||
"limit_mm_per_prompt",
|
||||
"interleave_mm_strings",
|
||||
"media_io_kwargs",
|
||||
"active_stream_window",
|
||||
"duplex_max_sessions",
|
||||
"enable_sleep_mode",
|
||||
|
||||
@@ -92,6 +92,9 @@ class _Request:
|
||||
self.num_computed_tokens = 0
|
||||
self.num_in_flight_tokens = 0
|
||||
self.num_output_placeholders = 0
|
||||
# vLLM 0.27 (a0c092ee72): Request gained num_stale_output_tokens to
|
||||
# track in-flight outputs discarded at preemption/streaming-stop.
|
||||
self.num_stale_output_tokens = 0
|
||||
self.has_encoder_inputs = False
|
||||
self.pooling_params = None
|
||||
self.resumable = True
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Investigation tests for the num_stale_output_tokens drain (59b9e719).
|
||||
|
||||
The seed-TTS WER regression appeared with the vLLM 0.27 rebase (nightly
|
||||
2946 -> 2949: mean WER 0.2695 -> 0.3936, median 0.0 in both), whose prime
|
||||
suspect is the reimplemented async-token discard: vLLM 0.26 drained
|
||||
``async_tokens_to_discard`` one arriving frame at a time and suppressed
|
||||
only the token append; the 0.27 adaptation seeds
|
||||
``num_stale_output_tokens`` with the discarded *placeholder count* and
|
||||
drains it by each arriving frame's ``num_tokens_scheduled``, dropping the
|
||||
whole per-request update. Whenever seed and arrivals disagree, the counter
|
||||
outlives the old segment and the drain consumes *valid* frames of the new
|
||||
segment — a hole in the talker's codec stream (garbled audio, text/audio
|
||||
mismatch) — or underflows its own assert on the new segment's prefill
|
||||
frame (engine-core death mid-step).
|
||||
|
||||
The fix seeds the counter from ``num_in_flight_tokens`` — the same
|
||||
scheduled-token units the drain subtracts — so every pre-discard frame
|
||||
drains exactly its own contribution and new-segment frames can never be
|
||||
swallowed. These tests pin that contract one frame at a time; the
|
||||
delivered / swallowed observable is whether ``_update_request_with_output``
|
||||
runs for the frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Imports must run in this order: vllm_omni applies patches to vllm.v1.request
|
||||
# before Request / StreamingUpdate are bound in this module.
|
||||
# isort: off
|
||||
import vllm_omni # noqa: F401 - import for side effects (patch vLLM)
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
from vllm.v1.outputs import ModelRunnerOutput
|
||||
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
|
||||
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
|
||||
|
||||
# isort: on
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _make_session() -> Request:
|
||||
session = Request(
|
||||
request_id="req-stale-drain-test",
|
||||
prompt_token_ids=[1, 2, 3],
|
||||
sampling_params=SamplingParams(max_tokens=32),
|
||||
pooling_params=None,
|
||||
arrival_time=100.0,
|
||||
block_hasher=None,
|
||||
)
|
||||
session.status = RequestStatus.RUNNING
|
||||
if not hasattr(session, "num_stale_output_tokens"):
|
||||
# vLLM 0.27 defines this Request field; 0.26 (local dev) does not.
|
||||
session.num_stale_output_tokens = 0
|
||||
if not hasattr(session, "num_in_flight_tokens"):
|
||||
session.num_in_flight_tokens = 0
|
||||
return session
|
||||
|
||||
|
||||
def _replace_streaming_session(session: Request) -> None:
|
||||
"""Run the real stage-0 streaming replacement (the discard/seed site)."""
|
||||
sched = OmniARScheduler.__new__(OmniARScheduler)
|
||||
sched._new_prompt_len_snapshot = {}
|
||||
sched.vllm_config = SimpleNamespace(model_config=SimpleNamespace(stage_id=0))
|
||||
sched.num_waiting_for_streaming_input = 0
|
||||
sched.log_stats = False
|
||||
sched.chunk_transfer_adapter = None
|
||||
sched.skipped_waiting = set()
|
||||
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
|
||||
update = StreamingUpdate(
|
||||
mm_features=None,
|
||||
prompt_token_ids=[10, 20],
|
||||
max_tokens=32,
|
||||
arrival_time=200.0,
|
||||
sampling_params=SamplingParams(max_tokens=16),
|
||||
)
|
||||
sched._update_request_as_session(session, update)
|
||||
session.status = RequestStatus.RUNNING
|
||||
|
||||
|
||||
def _make_drain_sched(session: Request) -> MagicMock:
|
||||
sched = MagicMock()
|
||||
sched.requests = {session.request_id: session}
|
||||
sched.perf_metrics = None
|
||||
sched.structured_output_manager.should_advance.return_value = False
|
||||
sched._update_request_with_output.return_value = ([42], False)
|
||||
sched._process_kv_transfer_trigger.return_value = False
|
||||
sched.chunk_transfer_adapter = MagicMock()
|
||||
sched.running = [session]
|
||||
sched.waiting_for_transfer_free = set()
|
||||
sched.transfer_triggered_requests = set()
|
||||
sched.active_kv_transfers = set()
|
||||
sched.pending_stop_after_extraction = set()
|
||||
sched.connector = None
|
||||
sched.kv_cache_manager.take_events.return_value = None
|
||||
sched.finished_req_ids_dict = {}
|
||||
sched.make_stats.return_value = None
|
||||
return sched
|
||||
|
||||
|
||||
def _run_step(sched: MagicMock, session: Request, *, num_scheduled: int, token: int) -> bool:
|
||||
"""Feed one model-runner frame through update_from_output.
|
||||
|
||||
Returns True when the frame's tokens were delivered (the append ran),
|
||||
False when the frame was swallowed by the stale drain.
|
||||
"""
|
||||
scheduler_output = MagicMock(spec=SchedulerOutput)
|
||||
scheduler_output.num_scheduled_tokens = {session.request_id: num_scheduled}
|
||||
scheduler_output.scheduled_spec_decode_tokens = {}
|
||||
scheduler_output.num_invalid_spec_tokens = 0
|
||||
|
||||
model_runner_output = MagicMock(spec=ModelRunnerOutput)
|
||||
model_runner_output.sampled_token_ids = [[token]]
|
||||
model_runner_output.logprobs = None
|
||||
model_runner_output.prompt_logprobs_dict = {}
|
||||
model_runner_output.pooler_output = None
|
||||
model_runner_output.num_nans_in_logits = None
|
||||
model_runner_output.kv_connector_output = None
|
||||
model_runner_output.cudagraph_stats = None
|
||||
model_runner_output.req_id_to_index = {session.request_id: 0}
|
||||
model_runner_output.routed_experts = None
|
||||
model_runner_output.inter_stage_outputs = None
|
||||
|
||||
sched._update_request_with_output.reset_mock()
|
||||
OmniARScheduler.update_from_output(sched, scheduler_output, model_runner_output)
|
||||
return sched._update_request_with_output.called
|
||||
|
||||
|
||||
def test_exact_drain_delivers_new_segment_frame() -> None:
|
||||
"""One in-flight decode frame at replacement: its late output drains the
|
||||
counter exactly and the new segment's first frame is delivered."""
|
||||
session = _make_session()
|
||||
session.append_output_token_ids([7, 8, 9])
|
||||
session.num_computed_tokens = 6
|
||||
session.num_output_placeholders = 1
|
||||
session.num_in_flight_tokens = 1
|
||||
|
||||
_replace_streaming_session(session)
|
||||
assert session.num_stale_output_tokens == 1
|
||||
|
||||
sched = _make_drain_sched(session)
|
||||
assert _run_step(sched, session, num_scheduled=1, token=42) is False # late frame dropped
|
||||
assert session.num_stale_output_tokens == 0
|
||||
assert _run_step(sched, session, num_scheduled=1, token=43) is True # new segment survives
|
||||
|
||||
|
||||
def test_two_in_flight_frames_drain_without_swallowing() -> None:
|
||||
"""Two in-flight decode frames: both late frames drop, the new segment's
|
||||
first valid frame is delivered. A placeholder-based seed swallowed it
|
||||
whenever fewer frames arrived than placeholders counted (the driver of
|
||||
the seed-TTS WER regression, nightly 2946 -> 2949: 0.2695 -> 0.3936)."""
|
||||
session = _make_session()
|
||||
session.append_output_token_ids([7, 8, 9])
|
||||
session.num_computed_tokens = 7
|
||||
session.num_output_placeholders = 2
|
||||
session.num_in_flight_tokens = 2
|
||||
|
||||
_replace_streaming_session(session)
|
||||
assert session.num_stale_output_tokens == 2
|
||||
|
||||
sched = _make_drain_sched(session)
|
||||
assert _run_step(sched, session, num_scheduled=1, token=42) is False # late frame 1
|
||||
assert _run_step(sched, session, num_scheduled=1, token=44) is False # late frame 2
|
||||
assert session.num_stale_output_tokens == 0
|
||||
|
||||
delivered = _run_step(sched, session, num_scheduled=1, token=43) # new segment, frame 1
|
||||
assert delivered is True, (
|
||||
"valid new-segment frame was swallowed by leftover stale count "
|
||||
f"(num_stale_output_tokens left at {session.num_stale_output_tokens})"
|
||||
)
|
||||
|
||||
|
||||
def test_spec_placeholder_asymmetry_does_not_swallow_valid_frame() -> None:
|
||||
"""Placeholder counts can exceed scheduled counts (e.g. a draft
|
||||
invalidated before execution): 1 in-flight MTP frame scheduled 2 tokens
|
||||
but contributed 3 placeholders. Seeding from num_in_flight_tokens keeps
|
||||
seed == drain, so the new segment's valid frame is delivered."""
|
||||
session = _make_session()
|
||||
session.append_output_token_ids([7])
|
||||
session.num_computed_tokens = 7
|
||||
session.num_output_placeholders = 3 # 1 sampled + 2 spec placeholders
|
||||
session.num_in_flight_tokens = 2 # the frame actually scheduled 1 + 1 tokens
|
||||
|
||||
_replace_streaming_session(session)
|
||||
assert session.num_stale_output_tokens == 2
|
||||
|
||||
sched = _make_drain_sched(session)
|
||||
assert _run_step(sched, session, num_scheduled=2, token=42) is False # late MTP frame
|
||||
assert session.num_stale_output_tokens == 0
|
||||
delivered = _run_step(sched, session, num_scheduled=1, token=43) # new segment, frame 1
|
||||
assert delivered is True, (
|
||||
"valid new-segment frame swallowed after spec placeholder asymmetry "
|
||||
f"(num_stale_output_tokens left at {session.num_stale_output_tokens})"
|
||||
)
|
||||
|
||||
|
||||
def test_in_flight_prefill_chunk_drains_exactly_without_underflow() -> None:
|
||||
"""An in-flight prefill chunk carries no placeholders but 5 scheduled
|
||||
tokens. The placeholder-based seed missed it entirely (its late output
|
||||
was appended to the rolled-back session) and any leftover counter
|
||||
underflowed 'assert num_stale_output_tokens >= 0' on the chunk's arrival,
|
||||
killing the engine-core step. Seeding from num_in_flight_tokens drops the
|
||||
late chunk and drains to exactly zero."""
|
||||
session = _make_session()
|
||||
session.append_output_token_ids([7, 8, 9])
|
||||
session.num_computed_tokens = 7
|
||||
session.num_output_placeholders = 0 # prefill chunk: no placeholders
|
||||
session.num_in_flight_tokens = 5 # the chunk's scheduled tokens
|
||||
|
||||
_replace_streaming_session(session)
|
||||
assert session.num_stale_output_tokens == 5
|
||||
|
||||
sched = _make_drain_sched(session)
|
||||
assert _run_step(sched, session, num_scheduled=5, token=42) is False # late prefill chunk dropped
|
||||
assert session.num_stale_output_tokens == 0
|
||||
assert _run_step(sched, session, num_scheduled=1, token=43) is True # new segment survives
|
||||
@@ -752,3 +752,12 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
default=None,
|
||||
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)."
|
||||
),
|
||||
)
|
||||
@@ -157,8 +157,19 @@ def benchmark_params(request):
|
||||
}
|
||||
|
||||
|
||||
def assert_result(result, num_prompt) -> None:
|
||||
def assert_result(result, params, num_prompt) -> None:
|
||||
assert result["completed"] == num_prompt, "Request failures exist"
|
||||
expected_audio_turns = params.get("expected_duplex_audio_turns_per_session")
|
||||
if expected_audio_turns is not None:
|
||||
session_metrics = result.get("duplex_session_metrics")
|
||||
assert isinstance(session_metrics, list), "Duplex session metrics are missing"
|
||||
assert len(session_metrics) == num_prompt, (
|
||||
f"Expected {num_prompt} duplex session metric rows, got {len(session_metrics)}"
|
||||
)
|
||||
assert all(
|
||||
isinstance(metric, dict) and metric.get("audio_turn_count") == expected_audio_turns
|
||||
for metric in session_metrics
|
||||
), f"Not every duplex session emitted {expected_audio_turns} audio turns"
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
@@ -212,6 +223,7 @@ def test_performance_benchmark(omni_server, benchmark_params):
|
||||
"enabled",
|
||||
"eval_phase",
|
||||
"trust_remote_code",
|
||||
"expected_duplex_audio_turns_per_session",
|
||||
}
|
||||
|
||||
for key, value in params.items():
|
||||
@@ -251,7 +263,7 @@ def test_performance_benchmark(omni_server, benchmark_params):
|
||||
random_output_len=params.get("random_output_len"),
|
||||
resource_label=resource_label,
|
||||
)
|
||||
assert_result(result, num_prompt)
|
||||
assert_result(result, params, num_prompt)
|
||||
|
||||
# concurrency test
|
||||
for sweep_index, (concurrency, num_prompt) in enumerate(zip(max_concurrency_list, num_prompt_list)):
|
||||
@@ -268,4 +280,4 @@ def test_performance_benchmark(omni_server, benchmark_params):
|
||||
random_output_len=params.get("random_output_len"),
|
||||
resource_label=resource_label,
|
||||
)
|
||||
assert_result(result, num_prompt)
|
||||
assert_result(result, params, num_prompt)
|
||||
@@ -0,0 +1,87 @@
|
||||
[
|
||||
{
|
||||
"test_name": "test_minicpmo_4_5",
|
||||
"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,
|
||||
"trust_remote_code": true,
|
||||
"seed_tts_locale": "en",
|
||||
"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,
|
||||
1.2027
|
||||
],
|
||||
"mean_ttft_ms": [
|
||||
166.9764,
|
||||
307.7133,
|
||||
323.584
|
||||
],
|
||||
"mean_e2el_ms": [
|
||||
1361.1682,
|
||||
3800.6332,
|
||||
6961.8636
|
||||
],
|
||||
"mean_audio_ttfp_ms": [
|
||||
635.8099,
|
||||
1615.6591,
|
||||
2888.9831
|
||||
],
|
||||
"mean_audio_rtf": [
|
||||
0.3151,
|
||||
0.8568,
|
||||
1.5425
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
[
|
||||
{
|
||||
"test_name": "test_minicpmo_4_5_duplex_seed_tts",
|
||||
"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": [
|
||||
1
|
||||
],
|
||||
"max_concurrency": [
|
||||
1
|
||||
],
|
||||
"no_oversample": true,
|
||||
"trust_remote_code": true,
|
||||
"seed_tts_locale": "en",
|
||||
"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": {
|
||||
"H100": {
|
||||
"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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -485,3 +485,30 @@ def test_diffusion_build_run_params_resolves_baseline_per_sweep(tmp_path, monkey
|
||||
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)
|
||||
|
||||
|
||||
def test_omni_duplex_expected_audio_turns_accepts_complete_session():
|
||||
from tests.dfx.perf.scripts.run_benchmark import assert_result
|
||||
|
||||
assert_result(
|
||||
{
|
||||
"completed": 1,
|
||||
"duplex_session_metrics": [{"audio_turn_count": 4}],
|
||||
},
|
||||
{"expected_duplex_audio_turns_per_session": 4},
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
def test_omni_duplex_expected_audio_turns_rejects_incomplete_session():
|
||||
from tests.dfx.perf.scripts.run_benchmark import assert_result
|
||||
|
||||
with pytest.raises(AssertionError, match="emitted 4 audio turns"):
|
||||
assert_result(
|
||||
{
|
||||
"completed": 1,
|
||||
"duplex_session_metrics": [{"audio_turn_count": 3}],
|
||||
},
|
||||
{"expected_duplex_audio_turns_per_session": 4},
|
||||
1,
|
||||
)
|
||||
@@ -50,12 +50,12 @@ def _prompt() -> str:
|
||||
return "What is the capital of China? Answer in 20 words."
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", _MINICPMO_SERVER, indirect=True)
|
||||
def test_invalid_audio_format_rejected(omni_server: OmniServerParams, openai_client: OpenAIClientHandler) -> None:
|
||||
"""
|
||||
Test that invalid audio format is properly rejected.
|
||||
Deploy Setting: default 2GPU
|
||||
Deploy Setting: default single GPU
|
||||
Input Modal: text + invalid audio format
|
||||
Output Modal: error
|
||||
"""
|
||||
|
||||
@@ -315,11 +315,32 @@ def _assert_post_fault_health_terminal(host: str, port: int, *, scenario: str) -
|
||||
pytest.fail(f"[{scenario} health] no terminal post-fault health observed: {last_observation}")
|
||||
|
||||
|
||||
def _assert_server_alive_after_fault(host: str, port: int, *, scenario: str) -> None:
|
||||
"""#4285: a single-stage engine death must NOT take down the API server.
|
||||
|
||||
The process must stay up and keep answering /health (503 is acceptable —
|
||||
errored but listening). A connection-refused means the server process
|
||||
exited, which is the bug this fix closes.
|
||||
"""
|
||||
deadline = time.monotonic() + 15.0
|
||||
responded = False
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status, _ = get_health_raw(host, port, timeout_sec=5)
|
||||
responded = True
|
||||
assert status in (200, 503), f"[{scenario} alive] unexpected /health status={status}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if _looks_like_server_unreachable(exc):
|
||||
pytest.fail(f"[{scenario} alive] server exited after single-stage OOM (#4285): {exc!r}")
|
||||
raise
|
||||
time.sleep(0.5)
|
||||
assert responded, f"[{scenario} alive] /health never responded after fault"
|
||||
|
||||
|
||||
QWEN_PARAMS = create_reliability_omni_server_params(RELIABILITY_SCENARIOS, DEPLOY_CONFIGS_DIR)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip(reason="issue#4285")
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
|
||||
def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech(
|
||||
@@ -386,9 +407,11 @@ def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech(
|
||||
assert "code" in chat_error, f"chat error lacks code field: {chat_error!r}"
|
||||
assert "code" in speech_error, f"speech error lacks code field: {speech_error!r}"
|
||||
|
||||
# #4285: a single dead stage must not take the whole API server down.
|
||||
_assert_server_alive_after_fault(host, port, scenario="oom_contract")
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip(reason="issue#4285")
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
|
||||
def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_function, openai_client_function) -> None:
|
||||
@@ -431,7 +454,6 @@ def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_functi
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip(reason="issue#4285")
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
|
||||
def test_reliability_fault_gpu_oom_concurrent_pressure_failure(omni_server_function, openai_client_function) -> None:
|
||||
@@ -774,7 +796,11 @@ def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup(
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip(reason="issue#4285")
|
||||
@pytest.mark.skip(
|
||||
reason="needs request-scoped OOM recovery: under transient pressure the stage "
|
||||
"EngineCore still dies and is evicted (not respawned), so the engine does not "
|
||||
"recover after the hog stops. Out of scope for the #4285 fault-isolation fix."
|
||||
)
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True)
|
||||
def test_reliability_fault_gpu_oom_state_converges_after_fault_removed(
|
||||
|
||||
@@ -9,6 +9,10 @@ This script tests two main scenarios:
|
||||
2. Case 2: Comparing FlashAttention and SDPA backends for batch_size=2 with padding
|
||||
"""
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
@@ -371,6 +375,264 @@ def test_packed_varlen_metadata_must_be_complete(monkeypatch):
|
||||
impl.forward_cuda(query, query, query, metadata)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NPU mask-free packed attention (PR #5891).
|
||||
#
|
||||
# These tests cover the backend-side NPU branches that ``forward_fa_npu`` adds:
|
||||
# - boundary resolution in ``_resolve_packed_seq_npu`` (pure Python, no device
|
||||
# sync, no MindIE-SD import);
|
||||
# - env dispatch in ``forward_fa_npu`` (which op a packed forward takes);
|
||||
# - the laser input pre-scaling math in ``_forward_prefix_kv_slice_npu``.
|
||||
#
|
||||
# They run on CPU without a real NPU by injecting a fake ``mindiesd`` module
|
||||
# into ``sys.modules`` (same pattern as ``test_paged_attention`` /
|
||||
# ``benchmarks/conftest``). No production code is exercised for real kernels.
|
||||
|
||||
|
||||
def _npu_impl(causal: bool = False) -> FlashAttentionImpl:
|
||||
return FlashAttentionImpl(num_heads=2, head_size=4, softmax_scale=0.5, causal=causal)
|
||||
|
||||
|
||||
def _fake_mindiesd(monkeypatch, *, attention_forward=None, attention_forward_varlen=None):
|
||||
"""Install a stub ``mindiesd`` so local ``from mindiesd import ...`` works.
|
||||
|
||||
Restored automatically by monkeypatch. Defaults are no-op callables so the
|
||||
top-level import in ``forward_fa_npu`` succeeds even when the test mocks the
|
||||
instance methods that would otherwise call them.
|
||||
"""
|
||||
fake = SimpleNamespace(
|
||||
attention_forward=attention_forward or Mock(return_value=torch.zeros(1)),
|
||||
attention_forward_varlen=attention_forward_varlen or Mock(return_value=torch.zeros(1)),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "mindiesd", fake)
|
||||
return fake
|
||||
|
||||
|
||||
# --- Test group A: boundary resolution (_resolve_packed_seq_npu) -------------
|
||||
|
||||
|
||||
def test_resolve_packed_seq_accepts_real_plus_pad_contract():
|
||||
impl = _npu_impl()
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
cu = torch.tensor([0, 5, 8], dtype=torch.int32)
|
||||
extra = {"cu_seqlens_q": cu, "cu_seqlens_k": cu, "max_seqlen_q": 5, "max_seqlen_k": 5}
|
||||
|
||||
assert impl._resolve_packed_seq_npu(q, q, extra) == ([5, 8], [5, 8])
|
||||
|
||||
|
||||
def test_resolve_packed_seq_single_document_no_padding():
|
||||
impl = _npu_impl()
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
cu = torch.tensor([0, 8], dtype=torch.int32)
|
||||
extra = {"cu_seqlens_q": cu, "cu_seqlens_k": cu, "max_seqlen_q": 8, "max_seqlen_k": 8}
|
||||
|
||||
assert impl._resolve_packed_seq_npu(q, q, extra) == ([8], [8])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
"causal",
|
||||
"missing_key",
|
||||
"batch_ne_1",
|
||||
"cu_too_long",
|
||||
"cu_qk_mismatch",
|
||||
"max_seqlen_not_int",
|
||||
"used_gt_total",
|
||||
"used_zero",
|
||||
"pad_longer_than_real",
|
||||
],
|
||||
)
|
||||
def test_resolve_packed_seq_rejects_malformed_metadata(case):
|
||||
"""Any metadata outside the exact [real, pad] contract returns None so the
|
||||
caller falls back to the masked path."""
|
||||
cu3 = torch.tensor([0, 5, 8], dtype=torch.int32)
|
||||
cu2 = torch.tensor([0, 8], dtype=torch.int32)
|
||||
cu4 = torch.tensor([0, 3, 5, 8], dtype=torch.int32)
|
||||
cu_short = torch.tensor([0, 3, 8], dtype=torch.int32)
|
||||
base = {"cu_seqlens_q": cu3, "cu_seqlens_k": cu3, "max_seqlen_q": 5, "max_seqlen_k": 5}
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
|
||||
impl = _npu_impl(causal=(case == "causal"))
|
||||
if case == "missing_key":
|
||||
extra = {"cu_seqlens_q": cu3, "max_seqlen_q": 5} # 3 of 4 keys absent
|
||||
elif case == "batch_ne_1":
|
||||
q = torch.randn(2, 8, 2, 4)
|
||||
extra = base
|
||||
elif case == "cu_too_long":
|
||||
extra = {**base, "cu_seqlens_q": cu4, "cu_seqlens_k": cu4}
|
||||
elif case == "cu_qk_mismatch":
|
||||
extra = {**base, "cu_seqlens_q": cu3, "cu_seqlens_k": cu2}
|
||||
elif case == "max_seqlen_not_int":
|
||||
extra = {**base, "max_seqlen_q": 5.0}
|
||||
elif case == "used_gt_total":
|
||||
extra = {**base, "max_seqlen_q": 9} # 9 > total length 8
|
||||
elif case == "used_zero":
|
||||
extra = {**base, "max_seqlen_q": 0, "max_seqlen_k": 0}
|
||||
elif case == "pad_longer_than_real":
|
||||
# real=3, pad=5: the real document must be the longer one.
|
||||
extra = {
|
||||
"cu_seqlens_q": cu_short,
|
||||
"cu_seqlens_k": cu_short,
|
||||
"max_seqlen_q": 3,
|
||||
"max_seqlen_k": 3,
|
||||
}
|
||||
else:
|
||||
extra = base
|
||||
|
||||
assert impl._resolve_packed_seq_npu(q, q, extra) is None
|
||||
|
||||
|
||||
# --- Test group B: env dispatch in forward_fa_npu (current behavior) --------
|
||||
|
||||
|
||||
def _npu_impl_with_mocked_paths() -> tuple[FlashAttentionImpl, torch.Tensor]:
|
||||
"""FlashAttentionImpl whose two packed paths are Mocks returning a sentinel.
|
||||
|
||||
Lets a test assert which branch ``forward_fa_npu`` dispatched to without
|
||||
running any real kernel.
|
||||
"""
|
||||
impl = _npu_impl()
|
||||
sentinel = torch.tensor([1.0])
|
||||
impl._forward_varlen_packed_npu = Mock(return_value=sentinel)
|
||||
impl._forward_prefix_kv_slice_npu = Mock(return_value=sentinel)
|
||||
return impl, sentinel
|
||||
|
||||
|
||||
def test_npu_env_unset_uses_varlen(monkeypatch):
|
||||
monkeypatch.delenv("MINDIE_SD_FA_TYPE", raising=False)
|
||||
_fake_mindiesd(monkeypatch)
|
||||
impl, _ = _npu_impl_with_mocked_paths()
|
||||
metadata = AttentionMetadata(extra={"npu_attn_varlen": True})
|
||||
|
||||
impl.forward_fa_npu(torch.randn(1, 8, 2, 4), *[torch.randn(1, 8, 2, 4)] * 2, metadata)
|
||||
|
||||
impl._forward_varlen_packed_npu.assert_called_once()
|
||||
impl._forward_prefix_kv_slice_npu.assert_not_called()
|
||||
|
||||
|
||||
def test_npu_env_laser_uses_slice(monkeypatch):
|
||||
monkeypatch.setenv("MINDIE_SD_FA_TYPE", "ascend_laser_attention")
|
||||
_fake_mindiesd(monkeypatch)
|
||||
impl, _ = _npu_impl_with_mocked_paths()
|
||||
metadata = AttentionMetadata(extra={"npu_attn_varlen": True})
|
||||
|
||||
impl.forward_fa_npu(torch.randn(1, 8, 2, 4), *[torch.randn(1, 8, 2, 4)] * 2, metadata)
|
||||
|
||||
impl._forward_prefix_kv_slice_npu.assert_called_once()
|
||||
impl._forward_varlen_packed_npu.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fa_type", ["prompt_flash_attn", "fused_attn_score", "ascend_laser_attenton", "garbage"])
|
||||
def test_npu_env_non_laser_value_falls_back_to_varlen(monkeypatch, fa_type):
|
||||
"""Locks in the CURRENT dispatch behavior (PR #5891).
|
||||
|
||||
Any value other than ``ascend_laser_attention`` — including the valid
|
||||
``prompt_flash_attn``/``fused_attn_score`` and typos — is silently routed to
|
||||
the TND varlen op, which cannot honor ``MINDIE_SD_FA_TYPE``. This is review
|
||||
comment #1 on the PR and is intentionally captured here as a regression
|
||||
net: update this test when the dispatch starts honoring/rejecting these.
|
||||
"""
|
||||
monkeypatch.setenv("MINDIE_SD_FA_TYPE", fa_type)
|
||||
_fake_mindiesd(monkeypatch)
|
||||
impl, _ = _npu_impl_with_mocked_paths()
|
||||
metadata = AttentionMetadata(extra={"npu_attn_varlen": True})
|
||||
|
||||
impl.forward_fa_npu(torch.randn(1, 8, 2, 4), *[torch.randn(1, 8, 2, 4)] * 2, metadata)
|
||||
|
||||
impl._forward_varlen_packed_npu.assert_called_once()
|
||||
impl._forward_prefix_kv_slice_npu.assert_not_called()
|
||||
|
||||
|
||||
def test_npu_varlen_opt_in_unset_takes_mask_path(monkeypatch):
|
||||
"""Models that do not set ``npu_attn_varlen`` (e.g. Wan/Cosmos) keep using
|
||||
the existing masked attention_forward path; the new branches are skipped."""
|
||||
fake_forward = Mock(return_value=torch.zeros(1, 8, 2, 4))
|
||||
_fake_mindiesd(monkeypatch, attention_forward=fake_forward)
|
||||
impl, _ = _npu_impl_with_mocked_paths()
|
||||
metadata = AttentionMetadata(
|
||||
attn_mask=torch.tensor([[True, True, True, True, True, False, False, False]]),
|
||||
extra={}, # no npu_attn_varlen opt-in
|
||||
)
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
|
||||
out = impl.forward_fa_npu(q, q, q, metadata)
|
||||
|
||||
impl._forward_varlen_packed_npu.assert_not_called()
|
||||
impl._forward_prefix_kv_slice_npu.assert_not_called()
|
||||
fake_forward.assert_called_once()
|
||||
assert out is fake_forward.return_value
|
||||
|
||||
|
||||
# --- Test group C: laser input pre-scaling in _forward_prefix_kv_slice_npu --
|
||||
|
||||
|
||||
def test_prefix_kv_slice_applies_laser_input_scaling(monkeypatch):
|
||||
monkeypatch.setenv("MINDIE_SD_FA_TYPE", "ascend_laser_attention")
|
||||
captured: dict = {}
|
||||
|
||||
def fake_attention_forward(query, key, value, **kwargs):
|
||||
captured["query"] = query
|
||||
captured["key"] = key
|
||||
captured["value"] = value
|
||||
captured.update(kwargs)
|
||||
return torch.ones_like(query) # deterministic stand-in kernel output
|
||||
|
||||
_fake_mindiesd(monkeypatch, attention_forward=fake_attention_forward)
|
||||
impl = _npu_impl() # softmax_scale == 0.5
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
cu = torch.tensor([0, 5, 8], dtype=torch.int32)
|
||||
extra = {
|
||||
"cu_seqlens_q": cu,
|
||||
"cu_seqlens_k": cu,
|
||||
"max_seqlen_q": 5,
|
||||
"max_seqlen_k": 5,
|
||||
"laser_input_scale": 256.0,
|
||||
}
|
||||
|
||||
out = impl._forward_prefix_kv_slice_npu(q, q, q, extra)
|
||||
|
||||
# K/V sliced to the valid prefix (used_k == 5); q keeps full length.
|
||||
assert captured["key"].shape == (1, 5, 2, 4)
|
||||
assert captured["value"].shape == (1, 5, 2, 4)
|
||||
assert captured["query"].shape == (1, 8, 2, 4)
|
||||
assert captured["attn_mask"] is None
|
||||
# Pre-divide q/k/v by the factor and compensate the kernel scale by its
|
||||
# square (exact for power-of-two); output scaled back by the factor.
|
||||
torch.testing.assert_close(captured["query"], q / 256.0)
|
||||
torch.testing.assert_close(captured["key"], q[:, :5] / 256.0)
|
||||
assert captured["scale"] == pytest.approx(0.5 * 256.0**2)
|
||||
torch.testing.assert_close(out, torch.ones(1, 8, 2, 4) * 256.0)
|
||||
|
||||
|
||||
def test_prefix_kv_slice_no_scaling_without_factor(monkeypatch):
|
||||
monkeypatch.setenv("MINDIE_SD_FA_TYPE", "ascend_laser_attention")
|
||||
captured: dict = {}
|
||||
|
||||
def fake_attention_forward(query, key, value, **kwargs):
|
||||
captured["query"] = query
|
||||
captured.update(kwargs)
|
||||
return torch.ones_like(query)
|
||||
|
||||
_fake_mindiesd(monkeypatch, attention_forward=fake_attention_forward)
|
||||
impl = _npu_impl()
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
cu = torch.tensor([0, 5, 8], dtype=torch.int32)
|
||||
extra = {
|
||||
"cu_seqlens_q": cu,
|
||||
"cu_seqlens_k": cu,
|
||||
"max_seqlen_q": 5,
|
||||
"max_seqlen_k": 5,
|
||||
# no laser_input_scale → no pre-scaling
|
||||
}
|
||||
|
||||
out = impl._forward_prefix_kv_slice_npu(q, q, q, extra)
|
||||
|
||||
torch.testing.assert_close(captured["query"], q)
|
||||
assert captured["scale"] == pytest.approx(0.5)
|
||||
torch.testing.assert_close(out, torch.ones(1, 8, 2, 4))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running FlashAttention Padding Tests...")
|
||||
print("=" * 60)
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
|
||||
The grids here are the ones MiniMax-H3 FL2VA actually produces for an 8.7s clip:
|
||||
1280x768 gives a 62x24x40 latent grid whose 59520 video rows land on the 128-row
|
||||
kernel block, and 1344x768 gives 62x24x42 whose 62496 rows straddle it. Only the
|
||||
first can run sparse: rf_v2 tiles the sequence as a whole but pools its block
|
||||
mask from the video and prefix segments separately, so the two only agree on the
|
||||
block count when the video segment ends on a boundary.
|
||||
kernel block, and 1344x768 gives 62x24x42 whose 62496 rows do not. Both are
|
||||
handed to MindIE-SD: its rf_v2 preprocessing promotes an irregular video tail
|
||||
to the always-kept prefix segment before generating the block mask.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
@@ -57,10 +56,13 @@ def test_block_aligned_video_segment_runs_sparse():
|
||||
assert plan.latent_shape == list(ALIGNED_GRID)
|
||||
|
||||
|
||||
def test_misaligned_video_segment_stays_dense():
|
||||
# Realigning the sequence would mean padding it, and rf_v2 ignores attn_mask:
|
||||
# pad keys would take a share of every real query's softmax denominator.
|
||||
assert make_impl()._resolve_plan(make_metadata(MISALIGNED_GRID)) is None
|
||||
def test_misaligned_video_segment_runs_sparse_with_updated_mindiesd():
|
||||
plan = make_impl()._resolve_plan(make_metadata(MISALIGNED_GRID))
|
||||
|
||||
assert plan is not None
|
||||
assert plan.prefix_len == PREFIX_ROWS
|
||||
assert plan.used_len == PREFIX_ROWS + 62496
|
||||
assert plan.latent_shape == list(MISALIGNED_GRID)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix_len", [1, 127, _BLOCK_SIZE, 710, 900])
|
||||
@@ -155,3 +157,28 @@ def test_fully_populated_mask_reproduces_dense_attention():
|
||||
).transpose(1, 2)
|
||||
error = (out.float() - reference.float()).abs().mean() / reference.float().abs().mean()
|
||||
assert error < 2e-3, f"mean relative error {error:.4%} against dense attention"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not current_omni_platform.is_npu(), reason="rf_v2 runs on Ascend NPU only.")
|
||||
def test_irregular_video_tail_reproduces_dense_attention_with_updated_mindiesd():
|
||||
"""vLLM forwards the full irregular grid; MindIE-SD owns its tail handling."""
|
||||
torch.manual_seed(1)
|
||||
prefix_len, grid = 384, (4, 16, 10)
|
||||
video_len = grid[0] * grid[1] * grid[2]
|
||||
used = prefix_len + video_len
|
||||
heads, head_dim = 4, 128
|
||||
q, k, v = (torch.randn(1, used, heads, head_dim, dtype=torch.bfloat16, device="npu") for _ in range(3))
|
||||
|
||||
impl = make_impl()
|
||||
impl.rainfusion = dataclasses.replace(impl.rainfusion, sparsity=0.0)
|
||||
plan = RainFusionPlan(prefix_len=prefix_len, used_len=used, latent_shape=list(grid))
|
||||
out = impl._forward_sparse_npu(q, k, v, plan)
|
||||
|
||||
reference = torch.nn.functional.scaled_dot_product_attention(
|
||||
q.transpose(1, 2),
|
||||
k.transpose(1, 2),
|
||||
v.transpose(1, 2),
|
||||
scale=impl.softmax_scale,
|
||||
).transpose(1, 2)
|
||||
error = (out.float() - reference.float()).abs().mean() / reference.float().abs().mean()
|
||||
assert error < 2e-3, f"mean relative error {error:.4%} against dense attention"
|
||||
@@ -488,6 +488,14 @@ class _MiniMaxH3FakeAttention(nn.Module):
|
||||
class FakeBackend:
|
||||
supports_prefix_kv_slicing = False
|
||||
|
||||
# Mirror AttentionBackend.supports_packed_mask_free: H3's packed
|
||||
# attention path calls attn_backend.supports_packed_mask_free() to decide
|
||||
# whether to build the padding mask. Return False so the fake exercises
|
||||
# the masked path (the abstract backend default).
|
||||
@classmethod
|
||||
def supports_packed_mask_free(cls) -> bool:
|
||||
return False
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
del kwargs
|
||||
super().__init__()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.diffusion_kv.metadata import (
|
||||
DiffusionKVContextMetadata,
|
||||
DiffusionKVMetadata,
|
||||
DiffusionKVSequenceMetadata,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_diffusion_kv_metadata_uses_native_cache_group_block_ids() -> None:
|
||||
context = DiffusionKVContextMetadata(
|
||||
context_id="text",
|
||||
cache_role="cross_attention",
|
||||
num_tokens=3,
|
||||
block_ids=([7], [11]),
|
||||
)
|
||||
sequence = DiffusionKVSequenceMetadata(
|
||||
sequence_id=1,
|
||||
prefix_len=4,
|
||||
target_len=2,
|
||||
seq_len=8,
|
||||
block_ids=([1, 2], [5, 6]),
|
||||
context_ids=(context.context_id,),
|
||||
)
|
||||
metadata = DiffusionKVMetadata(
|
||||
request_id="req-0",
|
||||
allocation_generation=3,
|
||||
sequences=(sequence,),
|
||||
contexts=(context,),
|
||||
)
|
||||
|
||||
assert metadata.sequences[0].block_ids == ([1, 2], [5, 6])
|
||||
assert metadata.contexts[0].block_ids == ([7], [11])
|
||||
|
||||
|
||||
def test_cfg_sequences_can_share_one_request_context() -> None:
|
||||
shared_context = DiffusionKVContextMetadata(
|
||||
context_id="shared-text",
|
||||
cache_role="cross_attention",
|
||||
num_tokens=3,
|
||||
block_ids=([7], [11]),
|
||||
)
|
||||
sequences = tuple(
|
||||
DiffusionKVSequenceMetadata(
|
||||
sequence_id=sequence_id,
|
||||
prefix_len=4,
|
||||
target_len=2,
|
||||
seq_len=8,
|
||||
block_ids=([sequence_id + 1],),
|
||||
context_ids=(shared_context.context_id,),
|
||||
)
|
||||
for sequence_id in range(2)
|
||||
)
|
||||
|
||||
metadata = DiffusionKVMetadata(
|
||||
request_id="req-shared-context",
|
||||
allocation_generation=1,
|
||||
sequences=sequences,
|
||||
contexts=(shared_context,),
|
||||
)
|
||||
|
||||
assert metadata.contexts == (shared_context,)
|
||||
assert metadata.sequences[0].context_ids == (shared_context.context_id,)
|
||||
assert metadata.sequences[1].context_ids == (shared_context.context_id,)
|
||||
|
||||
|
||||
def test_cfg_sequences_can_reference_branch_specific_contexts() -> None:
|
||||
conditional_context = DiffusionKVContextMetadata(
|
||||
context_id="conditional-text",
|
||||
cache_role="cross_attention",
|
||||
num_tokens=3,
|
||||
block_ids=([7], [11]),
|
||||
)
|
||||
unconditional_context = DiffusionKVContextMetadata(
|
||||
context_id="unconditional-text",
|
||||
cache_role="cross_attention",
|
||||
num_tokens=3,
|
||||
block_ids=([8], [12]),
|
||||
)
|
||||
metadata = DiffusionKVMetadata(
|
||||
request_id="req-branch-contexts",
|
||||
allocation_generation=1,
|
||||
sequences=(
|
||||
DiffusionKVSequenceMetadata(
|
||||
sequence_id=0,
|
||||
prefix_len=4,
|
||||
target_len=2,
|
||||
seq_len=8,
|
||||
block_ids=([1],),
|
||||
context_ids=(conditional_context.context_id,),
|
||||
),
|
||||
DiffusionKVSequenceMetadata(
|
||||
sequence_id=1,
|
||||
prefix_len=4,
|
||||
target_len=2,
|
||||
seq_len=8,
|
||||
block_ids=([2],),
|
||||
context_ids=(unconditional_context.context_id,),
|
||||
),
|
||||
),
|
||||
contexts=(conditional_context, unconditional_context),
|
||||
)
|
||||
|
||||
assert metadata.sequences[0].context_ids == (conditional_context.context_id,)
|
||||
assert metadata.sequences[1].context_ids == (unconditional_context.context_id,)
|
||||
@@ -0,0 +1,290 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionOutput
|
||||
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
|
||||
from vllm_omni.diffusion.diffusion_kv.metadata import (
|
||||
DiffusionKVMetadata,
|
||||
DiffusionKVSequenceMetadata,
|
||||
)
|
||||
from vllm_omni.diffusion.executor.multiproc_executor import MultiprocDiffusionExecutor
|
||||
from vllm_omni.diffusion.sched.interface import (
|
||||
CachedRequestData,
|
||||
DiffusionSchedulerOutput,
|
||||
NewRequestData,
|
||||
)
|
||||
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
|
||||
from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker, WorkerWrapperBase
|
||||
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def make_metadata(request_id: str = "req-0") -> DiffusionKVMetadata:
|
||||
return DiffusionKVMetadata(
|
||||
request_id=request_id,
|
||||
allocation_generation=1,
|
||||
sequences=(
|
||||
DiffusionKVSequenceMetadata(
|
||||
sequence_id=0,
|
||||
prefix_len=4,
|
||||
target_len=2,
|
||||
seq_len=8,
|
||||
block_ids=([1, 2],),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def make_runner(mode: DiffusionKVCacheMode) -> DiffusionModelRunner:
|
||||
runner = object.__new__(DiffusionModelRunner)
|
||||
runner.od_config = SimpleNamespace(diffusion_kv_mode=mode)
|
||||
return runner
|
||||
|
||||
|
||||
def make_scheduler_output(*new_reqs: NewRequestData) -> DiffusionSchedulerOutput:
|
||||
return DiffusionSchedulerOutput(
|
||||
step_id=0,
|
||||
scheduled_new_reqs=list(new_reqs),
|
||||
scheduled_cached_reqs=CachedRequestData.make_empty(),
|
||||
finished_req_ids=set(),
|
||||
num_running_reqs=len(new_reqs),
|
||||
num_waiting_reqs=0,
|
||||
)
|
||||
|
||||
|
||||
def make_executor() -> tuple[MultiprocDiffusionExecutor, list[tuple]]:
|
||||
executor = object.__new__(MultiprocDiffusionExecutor)
|
||||
executor.od_config = SimpleNamespace(
|
||||
enable_distributed_layerwise_offload=False,
|
||||
dlo_use_allgather=True,
|
||||
)
|
||||
executor._ensure_open = lambda: None
|
||||
calls: list[tuple] = []
|
||||
|
||||
def collective_rpc(method, *, args, unique_reply_rank, exec_all_ranks):
|
||||
calls.append((method, args, unique_reply_rank, exec_all_ranks))
|
||||
return DiffusionOutput(output=None)
|
||||
|
||||
executor.collective_rpc = collective_rpc
|
||||
return executor, calls
|
||||
|
||||
|
||||
def test_new_request_data_carries_scheduler_allocation_atomically() -> None:
|
||||
req = SimpleNamespace(request_id="req-0")
|
||||
state = SimpleNamespace(request_id="req-0", req=req)
|
||||
metadata = make_metadata()
|
||||
|
||||
new_req = NewRequestData.from_state(state, diffusion_kv_metadata=metadata)
|
||||
|
||||
assert new_req.req is req
|
||||
assert new_req.diffusion_kv_metadata is metadata
|
||||
|
||||
|
||||
def test_paged_request_without_metadata_reaches_request_forward() -> None:
|
||||
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
|
||||
expected_output = DiffusionOutput(output=None)
|
||||
runner._execute_request_list = Mock(
|
||||
return_value=SimpleNamespace(
|
||||
runner_outputs=[SimpleNamespace(result=expected_output)],
|
||||
)
|
||||
)
|
||||
req = SimpleNamespace(request_id="req-0")
|
||||
|
||||
assert runner.execute_model(req) is expected_output
|
||||
|
||||
runner._execute_request_list.assert_called_once()
|
||||
|
||||
|
||||
def test_dense_request_rejects_metadata_before_request_forward() -> None:
|
||||
runner = make_runner(DiffusionKVCacheMode.DENSE_LEGACY)
|
||||
runner._execute_request_list = Mock()
|
||||
|
||||
with pytest.raises(ValueError, match="dense_legacy.*must not carry"):
|
||||
runner.execute_model(
|
||||
SimpleNamespace(request_id="req-0"),
|
||||
diffusion_kv_metadata=make_metadata(),
|
||||
)
|
||||
|
||||
runner._execute_request_list.assert_not_called()
|
||||
|
||||
|
||||
def test_paged_request_rejects_mismatched_metadata_identity() -> None:
|
||||
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
|
||||
runner._execute_request_list = Mock()
|
||||
|
||||
with pytest.raises(ValueError, match="request mismatch"):
|
||||
runner.execute_model(
|
||||
SimpleNamespace(request_id="req-0"),
|
||||
diffusion_kv_metadata=make_metadata("stale"),
|
||||
)
|
||||
|
||||
runner._execute_request_list.assert_not_called()
|
||||
|
||||
|
||||
def test_request_rpc_rejects_envelope_request_identity_mismatch() -> None:
|
||||
executor, calls = make_executor()
|
||||
req = SimpleNamespace(request_id="actual-request-id")
|
||||
new_req = NewRequestData(
|
||||
request_id="envelope-id",
|
||||
req=req,
|
||||
diffusion_kv_metadata=make_metadata("envelope-id"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="request identity mismatch"):
|
||||
executor.execute_request(make_scheduler_output(new_req))
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_batch_path_allows_missing_metadata_before_forward() -> None:
|
||||
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
|
||||
expected_output = object()
|
||||
runner._execute_request_list = Mock(return_value=expected_output)
|
||||
new_req = NewRequestData(
|
||||
request_id="req-0",
|
||||
req=SimpleNamespace(request_id="req-0"),
|
||||
)
|
||||
|
||||
assert runner.execute_model_batch(make_scheduler_output(new_req), runner.od_config) is expected_output
|
||||
|
||||
runner._execute_request_list.assert_called_once()
|
||||
|
||||
|
||||
def test_batch_path_rejects_envelope_request_identity_mismatch() -> None:
|
||||
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
|
||||
runner._execute_request_list = Mock()
|
||||
new_req = NewRequestData(
|
||||
request_id="envelope-id",
|
||||
req=SimpleNamespace(request_id="actual-request-id"),
|
||||
diffusion_kv_metadata=make_metadata("envelope-id"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="request identity mismatch"):
|
||||
runner.execute_model_batch(make_scheduler_output(new_req), runner.od_config)
|
||||
|
||||
runner._execute_request_list.assert_not_called()
|
||||
|
||||
|
||||
def test_step_path_allows_missing_metadata_before_step_support_check() -> None:
|
||||
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
|
||||
runner.pipeline = object()
|
||||
runner._supports_step_mode = Mock(return_value=False)
|
||||
new_req = NewRequestData(
|
||||
request_id="req-0",
|
||||
req=SimpleNamespace(request_id="req-0"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not support step execution"):
|
||||
runner.execute_stepwise(make_scheduler_output(new_req))
|
||||
|
||||
runner._supports_step_mode.assert_called_once()
|
||||
|
||||
|
||||
def test_step_path_rejects_envelope_request_identity_mismatch() -> None:
|
||||
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
|
||||
runner.pipeline = object()
|
||||
runner._supports_step_mode = Mock()
|
||||
new_req = NewRequestData(
|
||||
request_id="envelope-id",
|
||||
req=SimpleNamespace(request_id="actual-request-id"),
|
||||
diffusion_kv_metadata=make_metadata("envelope-id"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="request identity mismatch"):
|
||||
runner.execute_stepwise(make_scheduler_output(new_req))
|
||||
|
||||
runner._supports_step_mode.assert_not_called()
|
||||
|
||||
|
||||
def test_dense_request_rpc_keeps_legacy_positional_shape() -> None:
|
||||
executor, calls = make_executor()
|
||||
prepared_layout = object()
|
||||
req = SimpleNamespace(request_id="req-0", prepared_layout=prepared_layout)
|
||||
new_req = NewRequestData(request_id="req-0", req=req)
|
||||
|
||||
result = executor.execute_request(make_scheduler_output(new_req))
|
||||
|
||||
assert result.request_ids == ["req-0"]
|
||||
assert calls == [
|
||||
(
|
||||
"execute_model",
|
||||
(req, executor.od_config, None),
|
||||
0,
|
||||
True,
|
||||
)
|
||||
]
|
||||
assert calls[0][1][0].prepared_layout is prepared_layout
|
||||
|
||||
|
||||
def test_request_rpc_sends_attached_metadata_for_only_that_request() -> None:
|
||||
executor, calls = make_executor()
|
||||
req_0 = SimpleNamespace(request_id="req-0")
|
||||
req_1 = SimpleNamespace(request_id="req-1")
|
||||
metadata_0 = make_metadata("req-0")
|
||||
metadata_1 = make_metadata("req-1")
|
||||
new_reqs = (
|
||||
NewRequestData(request_id="req-0", req=req_0, diffusion_kv_metadata=metadata_0),
|
||||
NewRequestData(request_id="req-1", req=req_1, diffusion_kv_metadata=metadata_1),
|
||||
)
|
||||
|
||||
result = executor.execute_request(make_scheduler_output(*new_reqs))
|
||||
|
||||
assert result.request_ids == ["req-0", "req-1"]
|
||||
assert [call[1] for call in calls] == [
|
||||
(req_0, executor.od_config, None, metadata_0),
|
||||
(req_1, executor.od_config, None, metadata_1),
|
||||
]
|
||||
|
||||
|
||||
def test_worker_wrapper_only_extends_paged_request_rpc() -> None:
|
||||
calls: list[tuple] = []
|
||||
|
||||
class Worker:
|
||||
def execute_model(self, req, od_config, **kwargs):
|
||||
calls.append((req, od_config, kwargs))
|
||||
return DiffusionOutput(output=None)
|
||||
|
||||
wrapper = object.__new__(WorkerWrapperBase)
|
||||
wrapper.worker = Worker()
|
||||
req = SimpleNamespace(request_id="req-0")
|
||||
od_config = SimpleNamespace()
|
||||
metadata = make_metadata()
|
||||
|
||||
wrapper.execute_model(req, od_config)
|
||||
wrapper.execute_model(req, od_config, diffusion_kv_metadata=metadata)
|
||||
|
||||
assert calls == [
|
||||
(req, od_config, {"kv_prefetch_job": None}),
|
||||
(
|
||||
req,
|
||||
od_config,
|
||||
{
|
||||
"kv_prefetch_job": None,
|
||||
"diffusion_kv_metadata": metadata,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_dense_worker_call_does_not_extend_model_runner_signature() -> None:
|
||||
calls: list[tuple] = []
|
||||
|
||||
class DenseModelRunner:
|
||||
def execute_model(self, req, kv_prefetch_job=None):
|
||||
calls.append((req, kv_prefetch_job))
|
||||
return DiffusionOutput(output=None)
|
||||
|
||||
worker = object.__new__(DiffusionWorker)
|
||||
worker.model_runner = DenseModelRunner()
|
||||
worker.lora_manager = None
|
||||
worker._get_profiler = lambda: None
|
||||
req = SimpleNamespace(request_id="req-0")
|
||||
|
||||
worker.execute_model(req, SimpleNamespace())
|
||||
|
||||
assert calls == [(req, None)]
|
||||
@@ -64,6 +64,7 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch):
|
||||
local_rank,
|
||||
backend,
|
||||
group_name,
|
||||
use_all2all=False,
|
||||
):
|
||||
del backend
|
||||
group = _FakeGroup(
|
||||
@@ -73,6 +74,7 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch):
|
||||
device_communicator=object(),
|
||||
reduce_scatter=lambda tensor, **kwargs: tensor,
|
||||
)
|
||||
group.use_all2all = use_all2all
|
||||
created_groups.append(group)
|
||||
return group
|
||||
|
||||
@@ -121,6 +123,9 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch):
|
||||
assert parallel_state.vllm_parallel_state._PCP.device_communicator is not None
|
||||
assert parallel_state.vllm_parallel_state._DP.device_communicator is not None
|
||||
assert parallel_state.vllm_parallel_state._EP.device_communicator is not None
|
||||
# vLLM 0.27's MoE oracle asserts the EP communicator has an all2all
|
||||
# manager, which vLLM only builds for groups created with use_all2all.
|
||||
assert parallel_state.vllm_parallel_state._EP.use_all2all is True
|
||||
assert hasattr(parallel_state.vllm_parallel_state._PCP, "reduce_scatter")
|
||||
assert hasattr(parallel_state.vllm_parallel_state._DP, "reduce_scatter")
|
||||
assert hasattr(parallel_state.vllm_parallel_state._EP, "reduce_scatter")
|
||||
@@ -313,3 +318,35 @@ def test_non_moe_ep_fails_before_vllm_ep_remap(monkeypatch):
|
||||
assert parallel_state.vllm_parallel_state._PCP is None
|
||||
assert parallel_state.vllm_parallel_state._EP is None
|
||||
assert parallel_state._EXPERT_PARALLEL_GROUP_RANKS is None
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.core_model
|
||||
def test_init_vllm_group_forwards_use_all2all_only_when_supported(monkeypatch):
|
||||
"""use_all2all reaches vLLM only on versions whose API accepts it.
|
||||
|
||||
vLLM 0.27 builds the EP all2all manager only for groups created with
|
||||
use_all2all=True (its MoE oracle then asserts the manager exists —
|
||||
build 2953, HunyuanImage3-DIT accuracy). Older vLLM has no such kwarg,
|
||||
so forwarding it unconditionally would TypeError at init.
|
||||
"""
|
||||
received = {}
|
||||
|
||||
def modern(group_ranks, local_rank, backend, group_name=None, use_device_communicator=True, use_all2all=False):
|
||||
received["modern"] = use_all2all
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(parallel_state.vllm_parallel_state, "init_model_parallel_group", modern)
|
||||
parallel_state.init_vllm_model_parallel_group([[0]], 0, "gloo", "ep", use_all2all=True)
|
||||
assert received["modern"] is True
|
||||
|
||||
parallel_state.init_vllm_model_parallel_group([[0]], 0, "gloo", "tp")
|
||||
assert received["modern"] is False
|
||||
|
||||
def legacy(group_ranks, local_rank, backend, group_name=None, use_device_communicator=True):
|
||||
received["legacy"] = True
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(parallel_state.vllm_parallel_state, "init_model_parallel_group", legacy)
|
||||
parallel_state.init_vllm_model_parallel_group([[0]], 0, "gloo", "ep", use_all2all=True)
|
||||
assert received["legacy"] is True
|
||||
@@ -110,7 +110,7 @@ class TestFusedMoEFactory:
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"vllm.model_executor.layers.fused_moe",
|
||||
SimpleNamespace(FusedMoE=mock_vllm_fused_moe),
|
||||
SimpleNamespace(FusedMoEFactory=mock_vllm_fused_moe),
|
||||
)
|
||||
|
||||
result = fused_moe.FusedMoE(prefix="test", a=1, reduce_results=True)
|
||||
@@ -139,7 +139,7 @@ class TestFusedMoEFactory:
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"vllm.model_executor.layers.fused_moe",
|
||||
SimpleNamespace(FusedMoE=mocker.MagicMock(return_value=mock_runner)),
|
||||
SimpleNamespace(FusedMoEFactory=mocker.MagicMock(return_value=mock_runner)),
|
||||
)
|
||||
mocker.patch.object(fused_moe, "current_omni_platform", mocker.MagicMock())
|
||||
mock_set_num_tokens = mocker.patch.object(fused_moe, "_set_forward_context_num_tokens")
|
||||
@@ -169,7 +169,7 @@ class TestFusedMoEFactory:
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"vllm.model_executor.layers.fused_moe",
|
||||
SimpleNamespace(FusedMoE=mocker.MagicMock(return_value=mock_runner)),
|
||||
SimpleNamespace(FusedMoEFactory=mocker.MagicMock(return_value=mock_runner)),
|
||||
)
|
||||
mocker.patch.object(fused_moe, "current_omni_platform", mocker.MagicMock())
|
||||
mock_set_num_tokens = mocker.patch.object(fused_moe, "_set_forward_context_num_tokens")
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
from collections import defaultdict
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from diffusers.loaders.lora_conversion_utils import (
|
||||
_convert_non_diffusers_qwen_lora_to_diffusers,
|
||||
)
|
||||
from pytest_mock import MockerFixture
|
||||
from torch.testing import assert_close
|
||||
|
||||
from vllm_omni.diffusion.lora.loader import (
|
||||
LoraLoaderMixin,
|
||||
QwenImageLoraLoaderMixin,
|
||||
WanLoraLoaderMixin,
|
||||
_prepare_lora_delta,
|
||||
_remap_state_dict_keys,
|
||||
)
|
||||
|
||||
HEAD_DIM = 24
|
||||
RANK = 2
|
||||
NUM_LAYERS = 10
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class DummyTransformerBlock(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
d_model: int,
|
||||
bias: bool = False,
|
||||
stacked_params: bool = False,
|
||||
):
|
||||
super().__init__()
|
||||
self.attn = nn.Module()
|
||||
if stacked_params:
|
||||
self.attn.to_qkv = nn.Linear(d_model * 3, d_model, bias=bias)
|
||||
else:
|
||||
self.attn.to_q = nn.Linear(d_model, d_model, bias=bias)
|
||||
self.attn.to_k = nn.Linear(d_model, d_model, bias=bias)
|
||||
self.attn.to_v = nn.Linear(d_model, d_model, bias=bias)
|
||||
self.attn.to_out = nn.Linear(d_model, d_model, bias=bias)
|
||||
|
||||
|
||||
class DummyTransformer(nn.Module):
|
||||
def __init__(self, num_layers: int, d_model: int, bias: bool = False, stacked_params: bool = False):
|
||||
super().__init__()
|
||||
|
||||
self.blocks = nn.ModuleList(
|
||||
[DummyTransformerBlock(d_model, bias=bias, stacked_params=stacked_params) for _ in range(num_layers)]
|
||||
)
|
||||
if stacked_params:
|
||||
self.stacked_params_mapping = [
|
||||
(".attn.to_qkv", ".attn.to_q", "q"),
|
||||
(".attn.to_qkv", ".attn.to_k", "k"),
|
||||
(".attn.to_qkv", ".attn.to_v", "v"),
|
||||
]
|
||||
|
||||
|
||||
class DummyPipeline(nn.Module, LoraLoaderMixin):
|
||||
def __init__(self, num_layers: int, d_model: int, bias: bool = False, stacked_params: bool = False):
|
||||
super().__init__()
|
||||
self.transformer = DummyTransformer(num_layers, d_model, bias=bias, stacked_params=stacked_params)
|
||||
|
||||
|
||||
# ======================================================
|
||||
# helper functions for test
|
||||
# ======================================================
|
||||
def _filter_parameters(named_params, mode: str = "all"):
|
||||
if mode == "all":
|
||||
return named_params
|
||||
|
||||
filtered = []
|
||||
for name, param in named_params:
|
||||
if name.endswith(mode):
|
||||
filtered.append((name, param))
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def assert_params_equal(module, original_params, mode: str = "all"):
|
||||
for name, param in _filter_parameters(module.named_parameters(), mode):
|
||||
assert_close(param, original_params[name])
|
||||
|
||||
|
||||
def assert_params_not_equal(module, original_params, mode: str = "all"):
|
||||
for name, param in _filter_parameters(module.named_parameters(), mode):
|
||||
assert not torch.allclose(param, original_params[name])
|
||||
|
||||
|
||||
def make_dummy_lora_state_dict(
|
||||
base_key,
|
||||
weight_a: torch.Tensor | None = None,
|
||||
weight_b: torch.Tensor | None = None,
|
||||
weight_bias: torch.Tensor | None = None,
|
||||
bias: bool = False,
|
||||
lora_a_suffix: str = "lora_A.weight",
|
||||
lora_b_suffix: str = "lora_B.weight",
|
||||
lora_bias_suffix: str = "bias",
|
||||
):
|
||||
"""
|
||||
Create a dummy LoRA state dict.
|
||||
|
||||
Returns:
|
||||
tuple: (state_dict, expected_delta)
|
||||
"""
|
||||
key_a = f"{base_key}.{lora_a_suffix}"
|
||||
key_b = f"{base_key}.{lora_b_suffix}"
|
||||
key_bias = f"{base_key}.{lora_bias_suffix}" if bias else None
|
||||
|
||||
if weight_a is None:
|
||||
weight_a = torch.randn(RANK, HEAD_DIM)
|
||||
if weight_b is None:
|
||||
weight_b = torch.randn(HEAD_DIM, RANK)
|
||||
|
||||
state_dict = {}
|
||||
expected_delta = None
|
||||
|
||||
if bias:
|
||||
state_dict[key_a] = weight_a
|
||||
state_dict[key_b] = weight_b
|
||||
weight_bias = torch.randn(HEAD_DIM)
|
||||
state_dict[key_bias] = weight_bias
|
||||
expected_delta = weight_bias
|
||||
else:
|
||||
state_dict[key_a] = weight_a
|
||||
state_dict[key_b] = weight_b
|
||||
expected_delta = weight_b @ weight_a
|
||||
|
||||
return state_dict, expected_delta
|
||||
|
||||
|
||||
def make_lora_state_dict_for_module(
|
||||
module,
|
||||
lora_rank=RANK,
|
||||
bias=False,
|
||||
lora_a_suffix: str = "lora_A.weight",
|
||||
lora_b_suffix: str = "lora_B.weight",
|
||||
lora_bias_suffix: str = "bias",
|
||||
prefix: str = "transformer",
|
||||
remap_proj_out: bool = True,
|
||||
):
|
||||
param_to_weight_names = defaultdict(list)
|
||||
if hasattr(module, "stacked_params_mapping"):
|
||||
for param_name, weight_name, _ in module.stacked_params_mapping:
|
||||
param_to_weight_names[param_name].append(weight_name)
|
||||
|
||||
state_dict = {}
|
||||
for name, param in module.named_parameters(prefix=prefix):
|
||||
if name.endswith(".weight"):
|
||||
base_key = name[: -len(".weight")]
|
||||
if remap_proj_out and base_key.endswith(".to_out"):
|
||||
base_key = base_key.replace(".to_out", ".to_out.0")
|
||||
|
||||
d_in, d_out = param.shape
|
||||
is_stacked_param = False
|
||||
for param_name, weight_names in param_to_weight_names.items():
|
||||
if param_name not in name:
|
||||
continue
|
||||
is_stacked_param = True
|
||||
target_in = d_in // len(weight_names)
|
||||
for weight_name in weight_names:
|
||||
lora_key = base_key.replace(param_name, weight_name)
|
||||
weight_a = torch.randn(lora_rank, d_out)
|
||||
weight_b = torch.randn(target_in, lora_rank)
|
||||
state_dict[f"{lora_key}.{lora_a_suffix}"] = weight_a
|
||||
state_dict[f"{lora_key}.{lora_b_suffix}"] = weight_b
|
||||
|
||||
if is_stacked_param:
|
||||
continue
|
||||
|
||||
weight_a = torch.randn(lora_rank, d_out)
|
||||
weight_b = torch.randn(d_in, lora_rank)
|
||||
key_a = f"{base_key}.{lora_a_suffix}"
|
||||
key_b = f"{base_key}.{lora_b_suffix}"
|
||||
state_dict[key_a] = weight_a
|
||||
state_dict[key_b] = weight_b
|
||||
elif name.endswith(".bias") and bias:
|
||||
base_key = name[: -len(".bias")]
|
||||
if remap_proj_out and base_key.endswith(".to_out"):
|
||||
base_key = base_key.replace(".to_out", ".to_out.0")
|
||||
|
||||
d_bias = param.shape[0]
|
||||
is_stacked_param = False
|
||||
for param_name, weight_names in param_to_weight_names.items():
|
||||
if param_name not in name:
|
||||
continue
|
||||
is_stacked_param = True
|
||||
target_bias = d_bias // len(weight_names)
|
||||
for weight_name in weight_names:
|
||||
lora_key = base_key.replace(param_name, weight_name)
|
||||
weight_bias = torch.randn(target_bias)
|
||||
state_dict[f"{lora_key}.{lora_bias_suffix}"] = weight_bias
|
||||
|
||||
if is_stacked_param:
|
||||
continue
|
||||
|
||||
key_bias = f"{base_key}.{lora_bias_suffix}"
|
||||
state_dict[key_bias] = torch.randn_like(param)
|
||||
|
||||
return state_dict
|
||||
|
||||
|
||||
# ======================================================
|
||||
# Test loader helper functions
|
||||
# ======================================================
|
||||
def test_prepare_lora_delta_with_stacked_params():
|
||||
base_key = "transformer.attn.to_qkv"
|
||||
|
||||
sd0, delta0 = make_dummy_lora_state_dict("transformer.attn.to_q")
|
||||
sd1, delta1 = make_dummy_lora_state_dict("transformer.attn.to_k")
|
||||
sd2, delta2 = make_dummy_lora_state_dict("transformer.attn.to_v")
|
||||
|
||||
lora_state_dict = {}
|
||||
lora_state_dict.update(sd0)
|
||||
lora_state_dict.update(sd1)
|
||||
lora_state_dict.update(sd2)
|
||||
expected_delta = torch.concat([delta0, delta1, delta2])
|
||||
|
||||
stacked_mapping = {".attn.to_qkv": [".attn.to_q", ".attn.to_k", ".attn.to_v"]}
|
||||
|
||||
actual_delta, used_keys = _prepare_lora_delta(
|
||||
lora_state_dict,
|
||||
base_key,
|
||||
stacked_mapping,
|
||||
)
|
||||
|
||||
# validate the delta
|
||||
assert actual_delta is not None
|
||||
assert_close(actual_delta, expected_delta)
|
||||
|
||||
# validate the used keys
|
||||
key_q_a = "transformer.attn.to_q.lora_A.weight"
|
||||
key_q_b = "transformer.attn.to_q.lora_B.weight"
|
||||
key_k_a = "transformer.attn.to_k.lora_A.weight"
|
||||
key_k_b = "transformer.attn.to_k.lora_B.weight"
|
||||
key_v_a = "transformer.attn.to_v.lora_A.weight"
|
||||
key_v_b = "transformer.attn.to_v.lora_B.weight"
|
||||
|
||||
for key in [key_q_a, key_q_b, key_k_a, key_k_b, key_v_a, key_v_b]:
|
||||
assert key in used_keys
|
||||
|
||||
|
||||
def test_remap_state_dict_keys_matched_two():
|
||||
lora_state_dict, _ = make_dummy_lora_state_dict(
|
||||
"transformer.attn.to_out.0", bias=True, lora_bias_suffix="lora_B.bias"
|
||||
)
|
||||
|
||||
lora_state_dict = _remap_state_dict_keys(
|
||||
lora_state_dict,
|
||||
[(".to_out.0.", ".to_out."), (".lora_B.bias", ".bias")],
|
||||
)
|
||||
|
||||
assert len(lora_state_dict) == 3
|
||||
assert "transformer.attn.to_out.bias" in lora_state_dict
|
||||
|
||||
|
||||
# ======================================================
|
||||
# Test LoraLoaderMixin
|
||||
# ======================================================
|
||||
class TestLoraLoaderMixin:
|
||||
@pytest.mark.parametrize(
|
||||
"bias, stacked_params",
|
||||
[
|
||||
(False, False),
|
||||
(True, False),
|
||||
(False, True),
|
||||
(True, True),
|
||||
],
|
||||
ids=[
|
||||
"basic",
|
||||
"bias",
|
||||
"stacked_params",
|
||||
"bias_stacked_params",
|
||||
],
|
||||
)
|
||||
def test_load_lora_into_module(self, bias: bool, stacked_params: bool):
|
||||
pipeline = DummyPipeline(NUM_LAYERS, HEAD_DIM, bias=bias, stacked_params=stacked_params)
|
||||
original_params = {name: param.clone() for name, param in pipeline.transformer.named_parameters()}
|
||||
|
||||
lora_state_dict = make_lora_state_dict_for_module(pipeline.transformer, bias=bias)
|
||||
assert len(lora_state_dict) > 0
|
||||
|
||||
# remap the keys to vllm-omni format
|
||||
lora_state_dict = _remap_state_dict_keys(lora_state_dict, [(".to_out.0.", ".to_out.")])
|
||||
|
||||
used_keys = pipeline.load_lora_into_module(lora_state_dict, pipeline.transformer)
|
||||
# validate the parameters are updated
|
||||
assert_params_not_equal(pipeline.transformer, original_params)
|
||||
|
||||
# validate all keys are used
|
||||
missing_keys = set(lora_state_dict.keys()) - used_keys
|
||||
assert len(missing_keys) == 0
|
||||
|
||||
def test_unload_module_lora(self):
|
||||
pipeline = DummyPipeline(NUM_LAYERS, HEAD_DIM)
|
||||
original_params = {name: param.clone() for name, param in pipeline.transformer.named_parameters()}
|
||||
|
||||
lora_state_dict = make_lora_state_dict_for_module(pipeline.transformer)
|
||||
# validate the state dict is valid
|
||||
assert len(lora_state_dict) > 0
|
||||
|
||||
# remap the keys to vllm-omni format
|
||||
lora_state_dict = _remap_state_dict_keys(lora_state_dict, [(".to_out.0.", ".to_out.")])
|
||||
|
||||
used_keys = pipeline.load_lora_into_module(lora_state_dict, pipeline.transformer)
|
||||
|
||||
# validate the weights are updated
|
||||
assert_params_not_equal(pipeline.transformer, original_params)
|
||||
|
||||
# validate all keys are used
|
||||
missing_keys = set(lora_state_dict.keys()) - used_keys
|
||||
assert len(missing_keys) == 0
|
||||
|
||||
unload_used_keys = pipeline.unload_module_lora(lora_state_dict, pipeline.transformer)
|
||||
missing_keys = set(lora_state_dict.keys()) - unload_used_keys
|
||||
assert len(missing_keys) == 0
|
||||
assert len(unload_used_keys - used_keys) == 0
|
||||
|
||||
# validate the weights are restored
|
||||
assert_params_equal(pipeline.transformer, original_params)
|
||||
|
||||
|
||||
# ======================================================
|
||||
# Test QwenImageLoraLoaderMixin
|
||||
# ======================================================
|
||||
class DummyQwenImagePipeline(nn.Module, QwenImageLoraLoaderMixin):
|
||||
def __init__(self, num_layers: int, d_model: int, bias: bool = False):
|
||||
super().__init__()
|
||||
self.transformer = DummyTransformer(num_layers, d_model, bias=bias, stacked_params=True)
|
||||
|
||||
|
||||
class TestQwenImageLoraLoaderMixin:
|
||||
def test_load_lora_weights(self, mocker: MockerFixture):
|
||||
pipeline = DummyQwenImagePipeline(NUM_LAYERS, HEAD_DIM)
|
||||
original_params = {name: param.clone() for name, param in pipeline.transformer.named_parameters()}
|
||||
|
||||
lora_state_dict = make_lora_state_dict_for_module(pipeline.transformer)
|
||||
assert len(lora_state_dict) > 0
|
||||
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.lora.loader.get_converter_by_pipeline",
|
||||
return_value=_convert_non_diffusers_qwen_lora_to_diffusers,
|
||||
)
|
||||
pipeline.load_lora_weights(lora_state_dict, "adapter0")
|
||||
|
||||
# validate the weights are updated after lora loaded
|
||||
assert_params_not_equal(pipeline.transformer, original_params)
|
||||
|
||||
# validate lora_loaded map is updated after lora loaded
|
||||
assert "adapter0" in pipeline.lora_loaded
|
||||
|
||||
def test_unload_lora_weights(self, mocker: MockerFixture):
|
||||
pipeline = DummyQwenImagePipeline(NUM_LAYERS, HEAD_DIM)
|
||||
original_params = {name: param.clone() for name, param in pipeline.transformer.named_parameters()}
|
||||
|
||||
lora_state_dict = make_lora_state_dict_for_module(pipeline.transformer)
|
||||
assert len(lora_state_dict) > 0
|
||||
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.lora.loader.get_converter_by_pipeline",
|
||||
return_value=_convert_non_diffusers_qwen_lora_to_diffusers,
|
||||
)
|
||||
pipeline.load_lora_weights(lora_state_dict, "adapter0")
|
||||
# validate the weights are updated after lora loaded
|
||||
assert_params_not_equal(pipeline.transformer, original_params)
|
||||
|
||||
# validate lora_loaded map is updated after lora loaded
|
||||
assert "adapter0" in pipeline.lora_loaded
|
||||
|
||||
pipeline.unload_lora_weights("adapter0")
|
||||
# validate the weights are restored after lora unloaded
|
||||
assert_params_equal(pipeline.transformer, original_params)
|
||||
|
||||
# validate lora_loaded map is updated
|
||||
assert "adapter0" not in pipeline.lora_loaded
|
||||
|
||||
def test_load_diffusers_lora_applies_non_unit_alpha(self, mocker: MockerFixture):
|
||||
pipeline = DummyQwenImagePipeline(1, HEAD_DIM)
|
||||
to_out = pipeline.transformer.blocks[0].attn.to_out
|
||||
original_weight = to_out.weight.detach().clone()
|
||||
base_key = "blocks.0.attn.to_out.0"
|
||||
lora_a = torch.randn(RANK, HEAD_DIM)
|
||||
lora_b = torch.randn(HEAD_DIM, RANK)
|
||||
alpha = torch.tensor(6.0)
|
||||
state_dict = {
|
||||
f"{base_key}.lora_A.weight": lora_a,
|
||||
f"{base_key}.lora_B.weight": lora_b,
|
||||
f"{base_key}.alpha": alpha,
|
||||
}
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.lora.loader.get_converter_by_pipeline",
|
||||
return_value=_convert_non_diffusers_qwen_lora_to_diffusers,
|
||||
)
|
||||
|
||||
pipeline.load_lora_weights(state_dict, "adapter0")
|
||||
|
||||
expected_weight = original_weight + (lora_b @ lora_a) * (alpha.item() / RANK)
|
||||
assert_close(to_out.weight, expected_weight)
|
||||
|
||||
pipeline.unload_lora_weights("adapter0")
|
||||
assert_close(to_out.weight, original_weight)
|
||||
|
||||
|
||||
# ======================================================
|
||||
# Test WanLoraLoaderMixin
|
||||
# ======================================================
|
||||
class DummyWanPipeline(nn.Module, WanLoraLoaderMixin):
|
||||
def __init__(self, num_layers: int, d_model: int):
|
||||
super().__init__()
|
||||
self.has_transformer_2 = True
|
||||
self.transformer = DummyTransformer(num_layers, d_model, stacked_params=True)
|
||||
self.transformer_2 = DummyTransformer(num_layers, d_model, stacked_params=True)
|
||||
|
||||
|
||||
def test_wan_load_lora_weights_targets_both_transformers(mocker: MockerFixture):
|
||||
pipeline = DummyWanPipeline(NUM_LAYERS, HEAD_DIM)
|
||||
original_high = {name: param.clone() for name, param in pipeline.transformer.named_parameters()}
|
||||
original_low = {name: param.clone() for name, param in pipeline.transformer_2.named_parameters()}
|
||||
high_state_dict = make_lora_state_dict_for_module(pipeline.transformer)
|
||||
low_state_dict = make_lora_state_dict_for_module(pipeline.transformer_2)
|
||||
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.lora.loader._load_lora_state_dict",
|
||||
side_effect=[high_state_dict, low_state_dict],
|
||||
)
|
||||
|
||||
pipeline.load_lora_weights(["high.safetensors", "low.safetensors"], "adapter0")
|
||||
|
||||
assert_params_not_equal(pipeline.transformer, original_high)
|
||||
assert_params_not_equal(pipeline.transformer_2, original_low)
|
||||
assert set(pipeline.lora_loaded["adapter0"]) == {"transformer", "transformer_2"}
|
||||
|
||||
pipeline.unload_lora_weights("adapter0")
|
||||
|
||||
assert_params_equal(pipeline.transformer, original_high)
|
||||
assert_params_equal(pipeline.transformer_2, original_low)
|
||||
assert "adapter0" not in pipeline.lora_loaded
|
||||
@@ -348,3 +348,53 @@ def test_normalize_lingbot_request_model_size_overrides_sampling_dimensions():
|
||||
num_frames=81,
|
||||
)
|
||||
assert (config.height, config.width) == (480, 480)
|
||||
|
||||
|
||||
def test_normalize_lingbot_request_reads_size_from_extra_args():
|
||||
"""Regression: /v1/videos delivers `extra_params` via sampling.extra_args.
|
||||
|
||||
The nightly TI2V case sends `extra_params={"size": "320x192"}` with no
|
||||
height/width form fields; the size used to be dropped and the video came
|
||||
out at the 480x480 default (build 2952, LingBot Video MoE Function Test).
|
||||
"""
|
||||
config = _normalize(
|
||||
{
|
||||
"prompt": "motion",
|
||||
"modalities": ["video"],
|
||||
"multi_modal_data": {"image": Image.new("RGB", (320, 192))},
|
||||
},
|
||||
num_frames=9,
|
||||
extra_args={"size": "320x192"},
|
||||
)
|
||||
assert config.mode is LingBotGenerationMode.TI2V
|
||||
assert (config.height, config.width) == (192, 320)
|
||||
|
||||
|
||||
def test_normalize_lingbot_request_extra_args_size_overrides_sampling_dimensions():
|
||||
config = _normalize(
|
||||
{"prompt": "motion", "modalities": ["video"]},
|
||||
width=480,
|
||||
height=480,
|
||||
num_frames=81,
|
||||
extra_args={"size": "320x192"},
|
||||
)
|
||||
assert (config.height, config.width) == (192, 320)
|
||||
|
||||
|
||||
def test_normalize_lingbot_request_reads_width_height_from_extra_args():
|
||||
# Parity with the pre-#5311 pipeline, which honored extra_args height/width.
|
||||
config = _normalize(
|
||||
{"prompt": "motion", "modalities": ["video"]},
|
||||
num_frames=81,
|
||||
extra_args={"width": 320, "height": 192},
|
||||
)
|
||||
assert (config.height, config.width) == (192, 320)
|
||||
|
||||
|
||||
def test_resolve_lingbot_output_dimensions_reads_extra_fields():
|
||||
assert resolve_lingbot_output_dimensions(extra_fields={"size": "320x192"}) == (320, 192)
|
||||
assert resolve_lingbot_output_dimensions(
|
||||
sampling_width=480,
|
||||
sampling_height=480,
|
||||
extra_fields={"resolution": "720p", "ratio": "16:9"},
|
||||
) == (736, 1280)
|
||||
@@ -0,0 +1,266 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.models.longcat_video.longcat_video_avatar_transformer import (
|
||||
LongCatVideoAvatarTransformer3DModel,
|
||||
_read_config,
|
||||
replace_linear_with_quantized,
|
||||
)
|
||||
from vllm_omni.diffusion.models.longcat_video.pipeline_longcat_video_avatar import (
|
||||
_avatar_model_allow_patterns,
|
||||
_build_multi_speaker_ref_target_masks,
|
||||
_default_at2v_shape,
|
||||
_infer_asset_root_from_path,
|
||||
_prepare_multi_speaker_audio_arrays,
|
||||
_resolve_num_segments,
|
||||
prepare_longcat_video_avatar_model_for_omni,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("resolution", "expected_shape"),
|
||||
[
|
||||
("480p", (480, 832)),
|
||||
("720p", (736, 1248)),
|
||||
],
|
||||
)
|
||||
def test_longcat_video_avatar_at2v_default_shape_uses_resolution_bucket(
|
||||
resolution: str,
|
||||
expected_shape: tuple[int, int],
|
||||
):
|
||||
assert _default_at2v_shape(resolution) == expected_shape
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("use_int8", "expected_weight_dir", "unexpected_weight_dir"),
|
||||
[
|
||||
(True, "base_model_int8/*", "base_model/*"),
|
||||
(False, "base_model/*", "base_model_int8/*"),
|
||||
],
|
||||
)
|
||||
def test_longcat_video_avatar_allow_patterns_download_one_weight_set(
|
||||
use_int8: bool,
|
||||
expected_weight_dir: str,
|
||||
unexpected_weight_dir: str,
|
||||
):
|
||||
allow_patterns = _avatar_model_allow_patterns(use_int8)
|
||||
|
||||
assert expected_weight_dir in allow_patterns
|
||||
assert unexpected_weight_dir not in allow_patterns
|
||||
assert "whisper-large-v3/*" not in allow_patterns
|
||||
assert "whisper-large-v3/model.safetensors" in allow_patterns
|
||||
assert "whisper-large-v3/pytorch_model.bin" not in allow_patterns
|
||||
assert "whisper-large-v3/flax_model.msgpack" not in allow_patterns
|
||||
assert "vocal_separator/*" in allow_patterns
|
||||
|
||||
|
||||
def test_longcat_video_avatar_prepare_model_adds_omni_metadata(tmp_path):
|
||||
model_dir = tmp_path / "LongCat-Video-Avatar-1.5"
|
||||
base_model_dir = model_dir / "base_model_int8"
|
||||
base_model_dir.mkdir(parents=True)
|
||||
(base_model_dir / "config.json").write_text('{"model_type": "longcat_avatar"}', encoding="utf-8")
|
||||
(model_dir / "model_index.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
prepared = prepare_longcat_video_avatar_model_for_omni(str(model_dir), use_int8=True)
|
||||
|
||||
assert prepared == str(model_dir)
|
||||
model_index = json.loads((model_dir / "model_index.json").read_text(encoding="utf-8"))
|
||||
assert model_index["_class_name"] == "LongCatVideoAvatarPipeline"
|
||||
assert model_index["_diffusers_version"] == "0.38.0"
|
||||
transformer_config = json.loads((model_dir / "transformer" / "config.json").read_text(encoding="utf-8"))
|
||||
assert transformer_config == {"model_type": "longcat_avatar"}
|
||||
|
||||
|
||||
def test_longcat_video_avatar_infers_repo_root_from_official_asset_path(tmp_path):
|
||||
input_json = tmp_path / "assets" / "avatar" / "single_example_1.json"
|
||||
input_json.parent.mkdir(parents=True)
|
||||
input_json.write_text("{}", encoding="utf-8")
|
||||
|
||||
assert _infer_asset_root_from_path(input_json) == tmp_path
|
||||
|
||||
|
||||
def test_longcat_video_avatar_falls_back_to_json_parent_without_official_layout(tmp_path):
|
||||
input_json = tmp_path / "single_example_1.json"
|
||||
input_json.write_text("{}", encoding="utf-8")
|
||||
|
||||
assert _infer_asset_root_from_path(input_json) == tmp_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "audio_duration", "expected"),
|
||||
[
|
||||
(None, 26.6, 1),
|
||||
("1", 26.6, 1),
|
||||
(3, 26.6, 3),
|
||||
("auto", 3.0, 1),
|
||||
("auto", 23.8, 8),
|
||||
("auto", 26.6, 9),
|
||||
],
|
||||
)
|
||||
def test_longcat_video_avatar_resolve_num_segments(
|
||||
value,
|
||||
audio_duration: float,
|
||||
expected: int,
|
||||
):
|
||||
assert (
|
||||
_resolve_num_segments(
|
||||
value,
|
||||
audio_duration=audio_duration,
|
||||
num_frames=93,
|
||||
num_cond_frames=13,
|
||||
save_fps=25,
|
||||
)
|
||||
== expected
|
||||
)
|
||||
|
||||
|
||||
def test_longcat_video_avatar_read_config_filters_non_constructor_metadata(tmp_path):
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"_class_name": "LongCatVideoAvatarTransformer3DModel",
|
||||
"architectures": ["LongCatVideoAvatarTransformer3DModel"],
|
||||
"_diffusers_version": "0.35.1",
|
||||
"model_max_length": 512,
|
||||
"hidden_size": 4096,
|
||||
"depth": 48,
|
||||
"num_heads": 32,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = _read_config(config_path)
|
||||
|
||||
assert config == {
|
||||
"hidden_size": 4096,
|
||||
"depth": 48,
|
||||
"num_heads": 32,
|
||||
}
|
||||
|
||||
|
||||
def test_longcat_video_avatar_load_weights_supports_int8_buffers():
|
||||
model = LongCatVideoAvatarTransformer3DModel(
|
||||
hidden_size=4,
|
||||
depth=0,
|
||||
num_heads=1,
|
||||
caption_channels=4,
|
||||
intermediate_dim=4,
|
||||
output_dim=4,
|
||||
audio_channel=4,
|
||||
context_tokens=1,
|
||||
)
|
||||
replace_linear_with_quantized(model)
|
||||
|
||||
buffer_name = "t_embedder.mlp.0.weight_int8"
|
||||
buffers = dict(model.named_buffers())
|
||||
loaded_weight = torch.ones_like(buffers[buffer_name])
|
||||
|
||||
loaded_params = model.load_weights([(buffer_name, loaded_weight)])
|
||||
|
||||
assert buffer_name in loaded_params
|
||||
assert torch.equal(dict(model.named_buffers())[buffer_name], loaded_weight)
|
||||
|
||||
|
||||
def test_longcat_video_avatar_multi_speaker_para_audio_arrays_are_aligned():
|
||||
left = np.ones(3, dtype=np.float32)
|
||||
right = np.ones(5, dtype=np.float32) * 2
|
||||
|
||||
left_out, right_out = _prepare_multi_speaker_audio_arrays(
|
||||
[left, right],
|
||||
audio_type="para",
|
||||
generate_duration=0.5,
|
||||
sample_rate=10,
|
||||
)
|
||||
|
||||
assert left_out.tolist() == [1, 1, 1, 0, 0]
|
||||
assert right_out.tolist() == [2, 2, 2, 2, 2]
|
||||
|
||||
|
||||
def test_longcat_video_avatar_multi_speaker_add_audio_arrays_are_sequential():
|
||||
left = np.ones(3, dtype=np.float32)
|
||||
right = np.ones(2, dtype=np.float32) * 2
|
||||
|
||||
left_out, right_out = _prepare_multi_speaker_audio_arrays(
|
||||
[left, right],
|
||||
audio_type="add",
|
||||
generate_duration=0.1,
|
||||
sample_rate=10,
|
||||
)
|
||||
|
||||
assert left_out.tolist() == [1, 1, 1, 0, 0]
|
||||
assert right_out.tolist() == [0, 0, 0, 2, 2]
|
||||
|
||||
|
||||
def test_longcat_video_avatar_multi_speaker_masks_use_explicit_bboxes():
|
||||
image = Image.new("RGB", (8, 6))
|
||||
masks, use_background = _build_multi_speaker_ref_target_masks(
|
||||
image,
|
||||
{
|
||||
"person1": [1, 1, 4, 3],
|
||||
"person2": [2, 4, 5, 7],
|
||||
},
|
||||
)
|
||||
|
||||
assert not use_background
|
||||
assert masks.shape == (3, 6, 8)
|
||||
assert masks[0, 1:4, 1:3].sum() == 6
|
||||
assert masks[1, 2:5, 4:7].sum() == 9
|
||||
assert masks[2, 0, 0] == 1
|
||||
assert masks[2, 2, 2] == 0
|
||||
|
||||
|
||||
def test_longcat_video_avatar_multi_speaker_masks_default_to_left_right_split():
|
||||
image = Image.new("RGB", (10, 8))
|
||||
masks, use_background = _build_multi_speaker_ref_target_masks(image, None)
|
||||
|
||||
assert not use_background
|
||||
assert masks.shape == (3, 8, 10)
|
||||
assert masks[0, :, :5].sum() > 0
|
||||
assert masks[0, :, 5:].sum() == 0
|
||||
assert masks[1, :, :5].sum() == 0
|
||||
assert masks[1, :, 5:].sum() > 0
|
||||
|
||||
|
||||
def test_longcat_video_avatar_transformer_accepts_multi_speaker_masks():
|
||||
model = LongCatVideoAvatarTransformer3DModel(
|
||||
hidden_size=4,
|
||||
depth=0,
|
||||
num_heads=1,
|
||||
caption_channels=4,
|
||||
intermediate_dim=4,
|
||||
output_dim=4,
|
||||
audio_channel=4,
|
||||
context_tokens=1,
|
||||
)
|
||||
hidden_states = torch.randn(1, 16, 5, 4, 4)
|
||||
timestep = torch.zeros(1, 5)
|
||||
encoder_hidden_states = torch.randn(1, 1, 4, 4)
|
||||
audio_embs = torch.randn(2, 5, 5, 12, 4)
|
||||
ref_target_masks = torch.zeros(3, 4, 4)
|
||||
ref_target_masks[0, :, :2] = 1
|
||||
ref_target_masks[1, :, 2:] = 1
|
||||
ref_target_masks[2] = torch.where(ref_target_masks[0] + ref_target_masks[1] > 0, 0, 1)
|
||||
|
||||
output = model(
|
||||
hidden_states=hidden_states,
|
||||
timestep=timestep,
|
||||
encoder_hidden_states=encoder_hidden_states,
|
||||
num_cond_latents=1,
|
||||
audio_embs=audio_embs,
|
||||
ref_target_masks=ref_target_masks,
|
||||
)
|
||||
|
||||
assert output.shape == hidden_states.shape
|
||||
@@ -484,6 +484,182 @@ def test_shifted_sigma_schedule_matches_reference_values():
|
||||
)
|
||||
|
||||
|
||||
def test_base_schedule_overrides_the_uniform_sigma_positions():
|
||||
from vllm_omni.diffusion.models.minimax_h3.time_request import (
|
||||
minimax_h3_time_shift_sigmas,
|
||||
)
|
||||
|
||||
base_schedule = [1.0, 0.7, 0.4, 0.15, 0.0]
|
||||
|
||||
video = minimax_h3_time_shift_sigmas(
|
||||
num_steps=len(base_schedule),
|
||||
shift_scale=12.0,
|
||||
base_schedule=base_schedule,
|
||||
)
|
||||
audio = minimax_h3_time_shift_sigmas(
|
||||
num_steps=len(base_schedule),
|
||||
shift_scale=3.0,
|
||||
base_schedule=base_schedule,
|
||||
)
|
||||
|
||||
assert video == pytest.approx([1.0, 0.9655172, 0.8888889, 0.6792453, 0.0], abs=1e-6)
|
||||
assert audio == pytest.approx([1.0, 0.875, 0.6666667, 0.3461539, 0.0], abs=1e-6)
|
||||
assert video != pytest.approx(
|
||||
minimax_h3_time_shift_sigmas(num_steps=len(base_schedule), shift_scale=12.0),
|
||||
abs=1e-6,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_schedule",
|
||||
[
|
||||
[0.9, 0.5, 0.0],
|
||||
[1.0, 0.5, 0.1],
|
||||
[1.0, 0.5, 0.5, 0.0],
|
||||
[1.0],
|
||||
[],
|
||||
[1.0, float("nan"), 0.4, 0.0],
|
||||
[1.0, float("inf"), 0.4, 0.0],
|
||||
],
|
||||
)
|
||||
def test_base_schedule_rejects_malformed_positions(base_schedule):
|
||||
from vllm_omni.diffusion.models.minimax_h3.time_request import (
|
||||
minimax_h3_time_shift_sigmas,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
minimax_h3_time_shift_sigmas(
|
||||
num_steps=max(len(base_schedule), 1),
|
||||
shift_scale=12.0,
|
||||
base_schedule=base_schedule,
|
||||
)
|
||||
|
||||
|
||||
def _distilled_pipeline(diffuse_calls, base_schedule_by_partition):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.sched import DMD2SigmaSchedule
|
||||
|
||||
schedules = {
|
||||
partition: None if positions is None else DMD2SigmaSchedule.from_positions(positions)
|
||||
for partition, positions in base_schedule_by_partition.items()
|
||||
}
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "combined"
|
||||
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
pipeline.default_video_shift = 12.0
|
||||
pipeline.default_audio_shift = 3.0
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.od_config = SimpleNamespace()
|
||||
pipeline._base_schedule_by_partition = schedules
|
||||
pipeline._quality_policy = Mock()
|
||||
pipeline._quality_policy.resolve.return_value = SimpleNamespace(cache_dit=None)
|
||||
pipeline._cache_dit_runtime = SimpleNamespace(prepare=lambda spec: None)
|
||||
pipeline.encode_prompt = Mock(
|
||||
return_value=(
|
||||
torch.ones(1, 2),
|
||||
torch.ones(1, dtype=torch.long),
|
||||
)
|
||||
)
|
||||
|
||||
def diffuse(**kwargs):
|
||||
diffuse_calls.append(kwargs)
|
||||
return torch.zeros(1), torch.zeros(1)
|
||||
|
||||
pipeline.diffuse = diffuse
|
||||
pipeline.decode = Mock(return_value=(torch.zeros(1), torch.zeros(1)))
|
||||
return pipeline
|
||||
|
||||
|
||||
def _t2va_batch(num_inference_steps=None):
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
quality="lossless",
|
||||
width=1344,
|
||||
height=768,
|
||||
fps=24,
|
||||
num_frames=124,
|
||||
num_inference_steps=num_inference_steps,
|
||||
extra_args={"task": "t2va", "aspect_ratio": "16:9"},
|
||||
)
|
||||
return DiffusionRequestBatch(
|
||||
[
|
||||
OmniDiffusionRequest(
|
||||
prompt="distilled schedule",
|
||||
sampling_params=sampling,
|
||||
request_id="distilled",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_base_schedule_is_scoped_to_the_serving_partition():
|
||||
"""A distilled FL2VA must not drag a regular Ref2VA onto its step count."""
|
||||
distilled = [1.0, 0.7, 0.4, 0.15, 0.0]
|
||||
pipeline = _distilled_pipeline([], {"fl2va": distilled, "ref2va": None})
|
||||
|
||||
assert pipeline._base_schedule_for_task("t2va").base_schedule == tuple(distilled)
|
||||
assert pipeline._base_schedule_for_task("fl2va").base_schedule == tuple(distilled)
|
||||
assert pipeline._base_schedule_for_task("ref2va") is None
|
||||
|
||||
|
||||
def test_partially_constructed_pipeline_falls_back_to_the_uniform_schedule():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
|
||||
assert pipeline._base_schedule_for_task("t2va") is None
|
||||
|
||||
|
||||
def test_distilled_forward_reports_denoising_steps_not_sigma_boundaries():
|
||||
diffuse_calls = []
|
||||
base_schedule = [1.0, 0.7, 0.4, 0.15, 0.0]
|
||||
pipeline = _distilled_pipeline(diffuse_calls, {"fl2va": base_schedule, "ref2va": None})
|
||||
|
||||
pipeline.forward(_t2va_batch())
|
||||
|
||||
assert diffuse_calls[0]["base_schedule"] == tuple(base_schedule)
|
||||
# Five boundaries describe four denoising steps.
|
||||
assert diffuse_calls[0]["num_steps"] == 4
|
||||
pipeline._quality_policy.resolve.assert_called_once_with(
|
||||
quality="lossless",
|
||||
num_inference_steps=4,
|
||||
extra_args={"task": "t2va", "aspect_ratio": "16:9"},
|
||||
)
|
||||
|
||||
|
||||
def test_distilled_forward_accepts_the_matching_explicit_step_count():
|
||||
diffuse_calls = []
|
||||
pipeline = _distilled_pipeline(diffuse_calls, {"fl2va": [1.0, 0.7, 0.4, 0.15, 0.0], "ref2va": None})
|
||||
|
||||
pipeline.forward(_t2va_batch(num_inference_steps=4))
|
||||
|
||||
assert diffuse_calls[0]["num_steps"] == 4
|
||||
|
||||
|
||||
def test_distilled_forward_rejects_a_mismatched_explicit_step_count():
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
pipeline = _distilled_pipeline([], {"fl2va": [1.0, 0.7, 0.4, 0.15, 0.0], "ref2va": None})
|
||||
|
||||
with pytest.raises(OmniClientError, match="must be 4 or omitted"):
|
||||
pipeline.forward(_t2va_batch(num_inference_steps=50))
|
||||
|
||||
|
||||
def test_absent_base_schedule_key_differs_from_an_empty_list():
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
_read_base_schedule,
|
||||
)
|
||||
|
||||
assert _read_base_schedule({}) is None
|
||||
assert _read_base_schedule({"base_schedule": [1.0, 0.5, 0.0]}).base_schedule == (1.0, 0.5, 0.0)
|
||||
with pytest.raises(ValueError):
|
||||
_read_base_schedule({"base_schedule": []})
|
||||
|
||||
|
||||
def test_cudnn_packed_attention_uses_python_length_without_padding_mask():
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
|
||||
MiniMaxH3Attention,
|
||||
@@ -492,6 +668,10 @@ def test_cudnn_packed_attention_uses_python_length_without_padding_mask():
|
||||
class FakeBackend:
|
||||
supports_prefix_kv_slicing = True
|
||||
|
||||
@classmethod
|
||||
def supports_packed_mask_free(cls) -> bool:
|
||||
return False
|
||||
|
||||
class FakeAttention(torch.nn.Module):
|
||||
attn_backend = FakeBackend
|
||||
|
||||
@@ -526,6 +706,48 @@ def test_cudnn_packed_attention_uses_python_length_without_padding_mask():
|
||||
assert attention.attention.metadata.extra["valid_kv_length"] == 5
|
||||
|
||||
|
||||
def test_packed_attention_skips_mask_for_packed_mask_free_backend():
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
|
||||
MiniMaxH3Attention,
|
||||
)
|
||||
|
||||
class FakeBackend:
|
||||
supports_prefix_kv_slicing = False
|
||||
|
||||
@classmethod
|
||||
def supports_packed_mask_free(cls) -> bool:
|
||||
return True
|
||||
|
||||
class FakeAttention(torch.nn.Module):
|
||||
attn_backend = FakeBackend
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.metadata = None
|
||||
|
||||
def forward(self, query, key, value, metadata):
|
||||
self.metadata = metadata
|
||||
return query
|
||||
|
||||
attention = object.__new__(MiniMaxH3Attention)
|
||||
torch.nn.Module.__init__(attention)
|
||||
attention.attention = FakeAttention()
|
||||
q = torch.randn(8, 2, 4)
|
||||
|
||||
attention._run_packed_attention(
|
||||
q,
|
||||
q,
|
||||
q,
|
||||
cu_seqlens=torch.tensor([0, 5, 8], dtype=torch.int32),
|
||||
max_seqlen=5,
|
||||
packed_total=8,
|
||||
)
|
||||
|
||||
assert attention.attention.metadata.attn_mask is None
|
||||
assert attention.attention.metadata.extra["valid_kv_length"] == 5
|
||||
assert attention.attention.metadata.extra["npu_attn_varlen"] is True
|
||||
|
||||
|
||||
def test_packed_attention_keeps_padding_mask_for_other_backends():
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
|
||||
MiniMaxH3Attention,
|
||||
@@ -534,6 +756,10 @@ def test_packed_attention_keeps_padding_mask_for_other_backends():
|
||||
class FakeBackend:
|
||||
supports_prefix_kv_slicing = False
|
||||
|
||||
@classmethod
|
||||
def supports_packed_mask_free(cls) -> bool:
|
||||
return False
|
||||
|
||||
class FakeAttention(torch.nn.Module):
|
||||
attn_backend = FakeBackend
|
||||
|
||||
@@ -1196,8 +1422,38 @@ def test_r7_r8_ref2va_video_segment_matrix(monkeypatch, tmp_path, case, start_ti
|
||||
)
|
||||
|
||||
assert prepared[0]["duration_seconds"] == pytest.approx(expected_duration)
|
||||
assert prepared[0]["audio_duration_seconds"] == pytest.approx(min(expected_duration, 209 / 24.0))
|
||||
assert prepared[0]["start_time_seconds"] == pytest.approx(start_time or 0.0)
|
||||
assert transcode_calls[0][1]["duration_seconds"] == pytest.approx(expected_duration)
|
||||
assert transcode_calls[0][1]["target_frame_count"] == 209
|
||||
|
||||
|
||||
def test_ref2va_qwen_sampling_decodes_prepared_video_once(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import reference_video as reference_video_module
|
||||
|
||||
decoded = np.arange(25 * 2 * 2 * 3, dtype=np.uint8).reshape(25, 2, 2, 3)
|
||||
load_calls = []
|
||||
monkeypatch.setattr(
|
||||
reference_video_module,
|
||||
"_probe_video",
|
||||
lambda _path: {"frame_count": len(decoded)},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reference_video_module,
|
||||
"load_video_frames",
|
||||
lambda path: load_calls.append(path) or decoded,
|
||||
)
|
||||
|
||||
sampled = reference_video_module.sample_reference_video_frames("prepared.mp4")
|
||||
|
||||
assert load_calls == ["prepared.mp4"]
|
||||
for frame, expected in zip(
|
||||
sampled["frames"],
|
||||
(decoded[0], decoded[12], decoded[24]),
|
||||
strict=True,
|
||||
):
|
||||
np.testing.assert_array_equal(frame, expected)
|
||||
assert sampled["block_timestamps"] == [0.25, 1.0]
|
||||
|
||||
|
||||
def test_ref2va_two_video_recipe_tolerates_container_rounding(monkeypatch, tmp_path):
|
||||
@@ -1437,6 +1693,47 @@ def test_g4_standalone_audio_duration_and_total_duration_contract():
|
||||
)
|
||||
|
||||
|
||||
def test_ref2va_audio_duration_validation_precedes_rank_branch(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as pipeline_module
|
||||
|
||||
pipeline = object.__new__(pipeline_module.MiniMaxH3Pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
monkeypatch.setattr(pipeline_module, "_dit_rank_world", lambda: (None, 1, 2))
|
||||
|
||||
waveform = torch.zeros(1, 10)
|
||||
with pytest.raises(ValueError, match="max_duration_seconds must be positive"):
|
||||
pipeline._encode_audio_conditions(
|
||||
[(waveform, 10)],
|
||||
max_duration_seconds=0,
|
||||
)
|
||||
|
||||
|
||||
def test_ref2va_standalone_audio_condition_is_bounded_to_output_duration():
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
observed = []
|
||||
|
||||
class FakeAudioVAE:
|
||||
def encode_waveform(self, waveform, sample_rate):
|
||||
observed.append((waveform.clone(), sample_rate))
|
||||
return waveform, waveform.shape[-1]
|
||||
|
||||
pipeline.audio_vae = FakeAudioVAE()
|
||||
waveform = torch.arange(40, dtype=torch.float32).reshape(1, 40)
|
||||
|
||||
encoded, lengths = pipeline._encode_audio_conditions(
|
||||
[(waveform, 10)],
|
||||
max_duration_seconds=2.5,
|
||||
)
|
||||
|
||||
assert observed[0][0].shape == (1, 25)
|
||||
assert observed[0][1] == 10
|
||||
assert encoded.shape == (1, 25)
|
||||
assert lengths == [25]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "message"),
|
||||
[
|
||||
|
||||
@@ -515,8 +515,14 @@ def test_quantization_quality(config: QualityTestConfig):
|
||||
|
||||
generate_fn = _generate_video if config.task == "t2v" else _generate_image
|
||||
|
||||
# Run both arms eager: LPIPS must measure quantization only. With compile
|
||||
# on, a compiler failure in one arm (e.g. inductor's CantSplit on fp8 FLUX)
|
||||
# silently drops that arm to eager while the other stays compiled, and the
|
||||
# metric then includes compile-state differences (fp8_ltx2 rose from ~0.09
|
||||
# to 0.1291 that way in build 2954). Mirrors
|
||||
# vllm_omni/quantization/tools/compare_diffusion_trajectory_similarity.py.
|
||||
# --- BF16 baseline ---
|
||||
bl_kwargs: dict = {"model": config.baseline_ref()}
|
||||
bl_kwargs: dict = {"model": config.baseline_ref(), "enforce_eager": True}
|
||||
if config.enable_cpu_offload:
|
||||
bl_kwargs["enable_cpu_offload"] = True
|
||||
omni_bl = Omni(**bl_kwargs)
|
||||
@@ -528,7 +534,7 @@ def test_quantization_quality(config: QualityTestConfig):
|
||||
|
||||
# --- Quantized ---
|
||||
quantization = config.quantization_ref()
|
||||
qt_kwargs: dict = {"model": config.quantized_ref()}
|
||||
qt_kwargs: dict = {"model": config.quantized_ref(), "enforce_eager": True}
|
||||
if config.enable_cpu_offload:
|
||||
qt_kwargs["enable_cpu_offload"] = True
|
||||
if quantization is None:
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.sched import DMD2SigmaSchedule
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_step_count_is_intervals_not_boundaries():
|
||||
schedule = DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])
|
||||
|
||||
assert schedule.num_inference_steps == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"positions",
|
||||
[
|
||||
[],
|
||||
[1.0],
|
||||
[0.9, 0.5, 0.0],
|
||||
[1.0, 0.5, 0.1],
|
||||
[1.0, 0.4, 0.6, 0.0],
|
||||
[1.0, 0.5, 0.5, 0.0],
|
||||
[1.0, float("nan"), 0.4, 0.0],
|
||||
[1.0, float("inf"), 0.4, 0.0],
|
||||
],
|
||||
)
|
||||
def test_malformed_positions_are_rejected(positions):
|
||||
with pytest.raises(ValueError):
|
||||
DMD2SigmaSchedule.from_positions(positions)
|
||||
|
||||
|
||||
def test_one_base_schedule_drives_several_shift_scales():
|
||||
schedule = DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])
|
||||
|
||||
assert schedule.shifted_sigmas(12.0) == pytest.approx([1.0, 0.9655172, 0.8888889, 0.6792453, 0.0], abs=1e-6)
|
||||
assert schedule.shifted_sigmas(3.0) == pytest.approx([1.0, 0.875, 0.6666667, 0.3461539, 0.0], abs=1e-6)
|
||||
|
||||
|
||||
def test_non_positive_shift_scale_is_rejected():
|
||||
schedule = DMD2SigmaSchedule.from_positions([1.0, 0.5, 0.0])
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
schedule.shifted_sigmas(0.0)
|
||||
|
||||
|
||||
def test_absent_metadata_key_means_undistilled_but_empty_is_malformed():
|
||||
assert DMD2SigmaSchedule.from_metadata({}) is None
|
||||
assert DMD2SigmaSchedule.from_metadata({"base_schedule": [1.0, 0.5, 0.0]}).base_schedule == (1.0, 0.5, 0.0)
|
||||
with pytest.raises(ValueError):
|
||||
DMD2SigmaSchedule.from_metadata({"base_schedule": []})
|
||||
@@ -74,3 +74,37 @@ def test_regionally_compile_does_not_partially_mutate_on_setup_failure(monkeypat
|
||||
regionally_compile(model, dynamic=True)
|
||||
|
||||
assert [block.forward.__func__ for block in model.transformer_blocks] == original_forwards
|
||||
|
||||
|
||||
def test_compiled_block_preserves_forward_signature_for_inspection(monkeypatch):
|
||||
"""cache-dit matches blocks via inspect.signature(block.forward).
|
||||
|
||||
Whatever regionally_compile installs as the block forward must stay
|
||||
signature-transparent: parameter names and the return annotation drive
|
||||
cache-dit's ForwardPattern match (build 2954, Multi-GPU Layered job
|
||||
failed when a bare *args/**kwargs wrapper hid them; torch.compile's own
|
||||
wrapper preserves the signature).
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import torch
|
||||
|
||||
class _SignatureBlock(nn.Module):
|
||||
def forward(self, hidden_states, encoder_hidden_states=None) -> "torch.Tensor":
|
||||
return hidden_states
|
||||
|
||||
class _Model(nn.Module):
|
||||
_repeated_blocks = ["_SignatureBlock"]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.blocks = nn.ModuleList([_SignatureBlock()])
|
||||
|
||||
model = _Model()
|
||||
monkeypatch.setattr(compile_module.torch, "compile", lambda fn, *a, **k: fn)
|
||||
regionally_compile(model)
|
||||
|
||||
sig = inspect.signature(model.blocks[0].forward)
|
||||
assert set(sig.parameters.keys()) == {"hidden_states", "encoder_hidden_states"}
|
||||
assert "torch.Tensor" in str(sig.return_annotation)
|
||||
assert model.blocks[0].forward("x") == "x"
|
||||
@@ -123,6 +123,47 @@ def test_init_accepts_custom_scheduler(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert engine.scheduler is custom_scheduler
|
||||
|
||||
|
||||
def test_scheduler_initialization_failure_closes_scheduler_and_executor(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
od_config = SimpleNamespace(
|
||||
custom_pipeline_args=None,
|
||||
model_class_name="SchedulerInitializationFailurePipeline",
|
||||
streaming_output=False,
|
||||
)
|
||||
initialization_error = RuntimeError("Scheduler initialization failed")
|
||||
custom_scheduler = SimpleNamespace(
|
||||
initialize=Mock(side_effect=initialization_error),
|
||||
close=Mock(),
|
||||
)
|
||||
fake_executor = SimpleNamespace(
|
||||
shutdown=Mock(),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.diffusion_engine.get_diffusion_post_process_func",
|
||||
lambda *args, **kwargs: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.diffusion_engine.get_diffusion_pre_process_func",
|
||||
lambda *args, **kwargs: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.diffusion_engine.DiffusionExecutor.get_class",
|
||||
lambda *args, **kwargs: Mock(return_value=fake_executor),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.diffusion_engine.supports_request_batch",
|
||||
lambda *args, **kwargs: False,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
DiffusionEngine(od_config, scheduler=custom_scheduler)
|
||||
|
||||
assert exc_info.value is initialization_error
|
||||
custom_scheduler.initialize.assert_called_once_with(od_config)
|
||||
custom_scheduler.close.assert_called_once_with()
|
||||
fake_executor.shutdown.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_compatibility_wrapper_returns_final_batch() -> None:
|
||||
engine = _make_engine()
|
||||
|
||||
@@ -341,7 +341,7 @@ def test_execute_stepwise_streaming_returns_chunks_at_boundaries(monkeypatch):
|
||||
monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_allocated", lambda: 0)
|
||||
scheduler_output = SimpleNamespace(
|
||||
finished_req_ids=set(),
|
||||
scheduled_new_reqs=[SimpleNamespace(request_id="req", req=req)],
|
||||
scheduled_new_reqs=[SimpleNamespace(request_id="req", req=req, diffusion_kv_metadata=None)],
|
||||
scheduled_cached_reqs=SimpleNamespace(request_ids=[]),
|
||||
)
|
||||
|
||||
@@ -381,7 +381,7 @@ def test_execute_stepwise_streaming_decodes_final_only_pipeline(monkeypatch):
|
||||
monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_allocated", lambda: 0)
|
||||
scheduler_output = SimpleNamespace(
|
||||
finished_req_ids=set(),
|
||||
scheduled_new_reqs=[SimpleNamespace(request_id="req", req=req)],
|
||||
scheduled_new_reqs=[SimpleNamespace(request_id="req", req=req, diffusion_kv_metadata=None)],
|
||||
scheduled_cached_reqs=SimpleNamespace(request_ids=[]),
|
||||
)
|
||||
|
||||
@@ -561,7 +561,11 @@ def _make_scheduler_output(num_reqs: int):
|
||||
reqs = [_make_request() for _ in range(num_reqs)]
|
||||
for i, req in enumerate(reqs):
|
||||
req.request_id = f"req-{i}"
|
||||
return SimpleNamespace(scheduled_new_reqs=[SimpleNamespace(req=req) for req in reqs])
|
||||
return SimpleNamespace(
|
||||
scheduled_new_reqs=[
|
||||
SimpleNamespace(request_id=req.request_id, req=req, diffusion_kv_metadata=None) for req in reqs
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Regression tests for the inductor factorable-divisibility patch.
|
||||
|
||||
torch 2.13 proves symbolic divisibility with torch's own unevaluated Mod,
|
||||
so ``15360*s31 + 15360*s87`` is no longer provably divisible by
|
||||
``s31 + s87`` and inductor raises CantSplit on FLUX's fp8 graph (nightly
|
||||
builds 2953/2954). The patch adds an exact-polynomial-division proof that
|
||||
is sound for every symbol assignment; on torch versions whose original
|
||||
check already proves the case (<= 2.11, via sympy.Mod), the patch
|
||||
self-extinguishes.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sympy
|
||||
from torch._inductor.sizevars import SizeVarAllocator
|
||||
|
||||
import vllm_omni.patch as patch_module
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
_S1, _S2 = sympy.symbols("s31 s87", positive=True, integer=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("numerator", "denominator", "expected"),
|
||||
[
|
||||
(15360 * _S1 + 15360 * _S2, _S1 + _S2, True), # the CantSplit expression
|
||||
(7 * _S1 + 7 * _S2, _S1 + _S2, True),
|
||||
(_S1 * _S2, _S2, True),
|
||||
(_S1 * _S2 + _S2, _S2, True), # s2*(s1 + 1)
|
||||
(3 * _S1, 2 * _S1, False),
|
||||
(_S1 + 1, _S1, False),
|
||||
(15360 * _S1 + 15359 * _S2, _S1 + _S2, False),
|
||||
(sympy.Integer(12), sympy.Integer(4), True),
|
||||
(sympy.Integer(12), sympy.Integer(5), False),
|
||||
],
|
||||
)
|
||||
def test_provably_factorable_multiple(numerator, denominator, expected):
|
||||
assert patch_module._provably_factorable_multiple(numerator, denominator) is expected
|
||||
|
||||
|
||||
def test_non_polynomial_atoms_never_reach_cancel():
|
||||
from torch.utils._sympy.functions import FloorDiv, ModularIndexing
|
||||
|
||||
assert patch_module._provably_factorable_multiple(FloorDiv(_S1, 2) * 4, _S1) is False
|
||||
assert patch_module._provably_factorable_multiple(ModularIndexing(_S1, 1, 8) * _S2, _S2) is False
|
||||
|
||||
|
||||
def test_divisibility_holds_after_patch_regardless_of_torch_version():
|
||||
"""The end contract on any torch: the CantSplit expression proves divisible.
|
||||
|
||||
On torch <= 2.11 the original method proves it (patch self-extinguished);
|
||||
on torch >= 2.13 the installed wrapper proves it via exact division.
|
||||
"""
|
||||
sv = SizeVarAllocator()
|
||||
method = SizeVarAllocator.statically_known_multiple_of
|
||||
patched = getattr(method, "_vllm_omni_factorable_divisibility", False)
|
||||
assert method(sv, 15360 * _S1 + 15360 * _S2, _S1 + _S2) is True
|
||||
# Negative control must stay non-divisible either way.
|
||||
assert method(sv, 3 * _S1, 2 * _S1) is False
|
||||
if patched:
|
||||
# Wrapper installed: the original alone must have failed the probe.
|
||||
assert patch_module._provably_factorable_multiple(15360 * _S1 + 15360 * _S2, _S1 + _S2) is True
|
||||
|
||||
|
||||
def test_patch_installs_when_original_cannot_prove(monkeypatch):
|
||||
"""Simulate torch 2.13's unevaluated-Mod prover: the wrapper must install."""
|
||||
|
||||
def weak(self, numerator, denominator):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(SizeVarAllocator, "statically_known_multiple_of", weak)
|
||||
patch_module._patch_inductor_factorable_divisibility()
|
||||
method = SizeVarAllocator.statically_known_multiple_of
|
||||
assert getattr(method, "_vllm_omni_factorable_divisibility", False) is True
|
||||
|
||||
sv = SizeVarAllocator()
|
||||
assert method(sv, 15360 * _S1 + 15360 * _S2, _S1 + _S2) is True
|
||||
assert method(sv, 3 * _S1, 2 * _S1) is False
|
||||
# Idempotency: a second install call must not wrap the wrapper.
|
||||
patch_module._patch_inductor_factorable_divisibility()
|
||||
assert SizeVarAllocator.statically_known_multiple_of is method
|
||||
@@ -33,16 +33,19 @@ BAGEL_CI_DEPLOY = get_deploy_config_path("ci/bagel.yaml")
|
||||
# Generated with seed=52, num_inference_steps=15,
|
||||
# prompt='Change the grass color to red',
|
||||
# input image: 2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg
|
||||
# Re-goldened from the vLLM-0.27-aligned output (nightly builds 2953/2954
|
||||
# produced bit-identical pixels across two different torch/base images; only
|
||||
# (512, 336) moved beyond tolerance vs the main-branch goldens from #5898).
|
||||
REFERENCE_PIXELS = [
|
||||
{"position": (100, 100), "rgb": (156, 172, 217)},
|
||||
{"position": (100, 100), "rgb": (157, 172, 217)},
|
||||
{"position": (400, 50), "rgb": (105, 144, 217)},
|
||||
{"position": (700, 100), "rgb": (118, 159, 232)},
|
||||
{"position": (150, 400), "rgb": (180, 22, 52)},
|
||||
{"position": (512, 336), "rgb": (221, 211, 194)},
|
||||
{"position": (700, 400), "rgb": (192, 10, 46)},
|
||||
{"position": (100, 600), "rgb": (102, 12, 22)},
|
||||
{"position": (400, 600), "rgb": (161, 28, 47)},
|
||||
{"position": (700, 600), "rgb": (100, 87, 94)},
|
||||
{"position": (150, 400), "rgb": (185, 21, 55)},
|
||||
{"position": (512, 336), "rgb": (205, 215, 191)},
|
||||
{"position": (700, 400), "rgb": (193, 11, 48)},
|
||||
{"position": (100, 600), "rgb": (103, 14, 25)},
|
||||
{"position": (400, 600), "rgb": (159, 28, 48)},
|
||||
{"position": (700, 600), "rgb": (101, 87, 94)},
|
||||
{"position": (256, 256), "rgb": (181, 201, 221)},
|
||||
]
|
||||
|
||||
@@ -141,13 +144,24 @@ def _validate_pixels(
|
||||
Raises:
|
||||
AssertionError: If any pixel differs beyond tolerance.
|
||||
"""
|
||||
mismatches = []
|
||||
probes = []
|
||||
for ref in reference_pixels:
|
||||
x, y = ref["position"]
|
||||
expected = ref["rgb"]
|
||||
actual = image.getpixel((x, y))[:3]
|
||||
assert all(abs(a - e) <= tolerance for a, e in zip(actual, expected)), (
|
||||
f"Pixel mismatch at ({x}, {y}): expected {expected}, got {actual}"
|
||||
)
|
||||
probes.append(f' {{"position": ({x}, {y}), "rgb": {actual}}},')
|
||||
if not all(abs(a - e) <= tolerance for a, e in zip(actual, expected)):
|
||||
mismatches.append(f"({x}, {y}): expected {expected}, got {actual}")
|
||||
# Report every probe, not just the first mismatch: CI does not upload the
|
||||
# generated image, so this failure message is the only place the full
|
||||
# actual-value table exists when the goldens need to be regenerated.
|
||||
assert not mismatches, (
|
||||
f"Pixel mismatch at {len(mismatches)} probe(s) (tolerance ±{tolerance}):\n "
|
||||
+ "\n ".join(mismatches)
|
||||
+ "\nActual values at all probes (paste as the new reference table if re-goldening):\n"
|
||||
+ "\n".join(probes)
|
||||
)
|
||||
|
||||
|
||||
def _generate_bagel_img2img(
|
||||
|
||||
@@ -1094,6 +1094,12 @@ class _HashableRequest(SimpleNamespace):
|
||||
# request; real Requests initialise it to 0 (vllm/v1/request.py).
|
||||
num_in_flight_tokens = 0
|
||||
|
||||
# vLLM 0.27 (a0c092ee72): the stale-output counter that replaced
|
||||
# async_tokens_to_discard. update_from_output reads it for every scheduled
|
||||
# request, and real Requests initialise it to 0 (vllm/v1/request.py), so the
|
||||
# double needs it too.
|
||||
num_stale_output_tokens = 0
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.request_id)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -47,111 +46,9 @@ def test_model_display_names_require_string_mapping(tmp_path, monkeypatch):
|
||||
generate_examples.load_model_display_names()
|
||||
|
||||
|
||||
def test_quantization_examples_keep_source_urls_under_feature_navigation(tmp_path, monkeypatch):
|
||||
nav_file = tmp_path / "docs" / ".nav.yml"
|
||||
nav_file.parent.mkdir(parents=True)
|
||||
nav_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"nav": [
|
||||
{
|
||||
"User Guide": [
|
||||
{
|
||||
"Examples": [
|
||||
"examples/README.md",
|
||||
{"Offline Inference": [{"Old": "user_guide/examples/offline_inference/old.md"}]},
|
||||
]
|
||||
},
|
||||
{"Features": [{"Quantization": [{"Overview": "user_guide/quantization/overview.md"}]}]},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
sort_keys=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
def test_only_general_serving_examples_get_generated_pages():
|
||||
general_example = SimpleNamespace(category="offline_inference", path=Path("text_to_image"))
|
||||
model_example = SimpleNamespace(category="offline_inference", path=Path("qwen3_omni"))
|
||||
|
||||
monkeypatch.setattr(generate_examples, "ROOT_DIR", tmp_path)
|
||||
monkeypatch.setattr(generate_examples, "NAV_FILE", nav_file)
|
||||
monkeypatch.setattr(generate_examples, "EXAMPLE_DOC_DIR", tmp_path / "docs/user_guide/examples")
|
||||
|
||||
quantization_example = SimpleNamespace(
|
||||
category="quantization",
|
||||
path=tmp_path / "examples/quantization/check_modelopt_fp8_export.py",
|
||||
nav_title="Check Modelopt FP8 Export",
|
||||
)
|
||||
offline_example = SimpleNamespace(
|
||||
category="offline_inference",
|
||||
path=tmp_path / "examples/offline_inference/demo.py",
|
||||
nav_title="Demo",
|
||||
)
|
||||
|
||||
generate_examples.update_nav_file([quantization_example, offline_example])
|
||||
generate_examples.update_nav_file([quantization_example, offline_example])
|
||||
|
||||
nav_data = yaml.safe_load(nav_file.read_text(encoding="utf-8"))
|
||||
user_guide = nav_data["nav"][0]["User Guide"]
|
||||
examples = next(item["Examples"] for item in user_guide if "Examples" in item)
|
||||
features = next(item["Features"] for item in user_guide if "Features" in item)
|
||||
quantization = next(item["Quantization"] for item in features if "Quantization" in item)
|
||||
|
||||
assert not any(isinstance(item, dict) and "Quantization" in item for item in examples)
|
||||
generated_examples = [
|
||||
item[generate_examples.GENERATED_QUANTIZATION_EXAMPLES_TITLE]
|
||||
for item in quantization
|
||||
if generate_examples.GENERATED_QUANTIZATION_EXAMPLES_TITLE in item
|
||||
]
|
||||
assert len(generated_examples) == 1
|
||||
assert generated_examples[0] == [
|
||||
{"Check Modelopt FP8 Export": "user_guide/examples/quantization/check_modelopt_fp8_export.md"}
|
||||
]
|
||||
|
||||
# A later build without quantization examples must not leave a stale
|
||||
# generated group in the hand-authored Quantization section.
|
||||
generate_examples.update_nav_file([offline_example])
|
||||
nav_data = yaml.safe_load(nav_file.read_text(encoding="utf-8"))
|
||||
user_guide = nav_data["nav"][0]["User Guide"]
|
||||
features = next(item["Features"] for item in user_guide if "Features" in item)
|
||||
quantization = next(item["Quantization"] for item in features if "Quantization" in item)
|
||||
assert not any(
|
||||
isinstance(item, dict) and generate_examples.GENERATED_QUANTIZATION_EXAMPLES_TITLE in item
|
||||
for item in quantization
|
||||
)
|
||||
|
||||
|
||||
def test_quantization_examples_are_not_added_to_examples_without_feature_target(tmp_path, monkeypatch, caplog):
|
||||
nav_file = tmp_path / "docs" / ".nav.yml"
|
||||
nav_file.parent.mkdir(parents=True)
|
||||
nav_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"nav": [
|
||||
{
|
||||
"User Guide": [
|
||||
{"Examples": ["examples/README.md"]},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
sort_keys=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(generate_examples, "ROOT_DIR", tmp_path)
|
||||
monkeypatch.setattr(generate_examples, "NAV_FILE", nav_file)
|
||||
monkeypatch.setattr(generate_examples, "EXAMPLE_DOC_DIR", tmp_path / "docs/user_guide/examples")
|
||||
|
||||
quantization_example = SimpleNamespace(
|
||||
category="quantization",
|
||||
path=tmp_path / "examples/quantization/check_modelopt_fp8_export.py",
|
||||
nav_title="Check Modelopt FP8 Export",
|
||||
)
|
||||
|
||||
generate_examples.update_nav_file([quantization_example])
|
||||
|
||||
nav_data = yaml.safe_load(nav_file.read_text(encoding="utf-8"))
|
||||
examples = nav_data["nav"][0]["User Guide"][0]["Examples"]
|
||||
assert not any(isinstance(item, dict) and "Quantization" in item for item in examples)
|
||||
assert "omitting generated quantization examples from navigation" in caplog.text
|
||||
assert generate_examples.is_general_example(general_example)
|
||||
assert not generate_examples.is_general_example(model_example)
|
||||
@@ -0,0 +1 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -0,0 +1,211 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniCPM-o 4.5 Daily-Omni + Seed-TTS accuracy regression coverage.
|
||||
|
||||
Daily-Omni settings follow the MiniCPM interleaved AV recipe that reaches
|
||||
~78% overall accuracy on Daily-Omni (``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``).
|
||||
"""
|
||||
|
||||
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.e2e.online_serving.helpers.minicpmo_4_5_duplex import SERVER_PARAMS as DUPLEX_TEST_PARAMS
|
||||
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"),
|
||||
)
|
||||
)
|
||||
|
||||
_MIN_DAILY_OMNI_ACCURACY = 0.78
|
||||
_MAX_SEED_TTS_MEAN_WER = 0.05
|
||||
# Match the validated Daily-Omni client body from daily_omni_bench.sh.
|
||||
_DAILY_EXTRA_BODY = {
|
||||
"modalities": ["text"],
|
||||
"chat_template_kwargs": {
|
||||
"enable_thinking": False,
|
||||
"use_tts_template": True,
|
||||
},
|
||||
}
|
||||
_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}}',
|
||||
]
|
||||
_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),
|
||||
)
|
||||
]
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _inline_daily_omni_media(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Inline cached Hub/local videos because the test server has no local-media allowlist."""
|
||||
original = _acc_bench.daily_omni_bench_argv
|
||||
|
||||
def _wrapped() -> list[str]:
|
||||
argv = list(original())
|
||||
if "--daily-omni-inline-local-video" not in argv:
|
||||
argv.append("--daily-omni-inline-local-video")
|
||||
return argv
|
||||
|
||||
monkeypatch.setattr(_acc_bench, "daily_omni_bench_argv", _wrapped)
|
||||
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,
|
||||
)
|
||||
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", 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,
|
||||
)
|
||||
argv.extend(
|
||||
[
|
||||
"--result-dir",
|
||||
str(_RESULT_DIR),
|
||||
"--max-seed-tts-mean-wer",
|
||||
str(_MAX_SEED_TTS_MEAN_WER),
|
||||
"--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
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", DUPLEX_TEST_PARAMS, indirect=True)
|
||||
def test_minicpmo_4_5_duplex_seed_tts_wer_bench(omni_server) -> None:
|
||||
"""Gate Seed-TTS WER through the explicit Realtime TTS contract."""
|
||||
_require_vllm_cli()
|
||||
pytest.importorskip("huggingface_hub")
|
||||
|
||||
argv = build_acc_benchmark_cli_argv(
|
||||
omni_server,
|
||||
skip_seed=False,
|
||||
skip_daily=True,
|
||||
num_prompts=50,
|
||||
max_concurrency=1,
|
||||
)
|
||||
argv.extend(
|
||||
[
|
||||
"--result-dir",
|
||||
str(_RESULT_DIR),
|
||||
"--max-seed-tts-mean-wer",
|
||||
str(_MAX_SEED_TTS_MEAN_WER),
|
||||
"--seed-extra-body-json",
|
||||
json.dumps({"save_duplex_request_metrics": True}, separators=(",", ":")),
|
||||
"--seed-backend",
|
||||
"openai-realtime-tts",
|
||||
"--seed-endpoint",
|
||||
"/v1/realtime",
|
||||
"--seed-tts-turns-per-session",
|
||||
"4",
|
||||
"--trust-remote-code",
|
||||
]
|
||||
)
|
||||
|
||||
assert _acc_bench.run_acc_benchmark(_acc_bench.parse_acc_benchmark_args(argv)) == 0
|
||||
@@ -0,0 +1,386 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import torch
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
from tests.e2e.accuracy.helpers import (
|
||||
assert_video_metadata,
|
||||
assert_video_similarity_metrics,
|
||||
probe_binary,
|
||||
probe_video,
|
||||
reset_artifact_dir,
|
||||
)
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniServer
|
||||
|
||||
pytestmark = [pytest.mark.benchmark, pytest.mark.diffusion, pytest.mark.full_model]
|
||||
|
||||
MODEL_REPO_ID = "MiniMaxAI/MiniMax-H3"
|
||||
# The previously pinned revision was removed from the Hugging Face repository.
|
||||
MODEL_REVISION = "main"
|
||||
MODEL_ENV_VAR = "VLLM_TEST_MINIMAX_H3_FL2VA_MODEL"
|
||||
REF2VA_MODEL_ENV_VAR = "VLLM_TEST_MINIMAX_H3_REF2VA_MODEL"
|
||||
I2VA_REFERENCE_VIDEO_URL = "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/assets/i2va.mp4"
|
||||
I2VA_REFERENCE_VIDEO_SHA256 = "5a5d6e0fcdda7e6a5d98cb92d9e5cb4b6b0b44d6e1dfd5a370d16dfaa79b9ae6"
|
||||
I2VA_IMAGE_URL = (
|
||||
"https://cdn.hailuoai.com/prod/hailuo_demo/testsets/H3_AA_I2VA/gallery/"
|
||||
"sr_v17_variants_seed42_43_20260724/inputs/4a3a90bf9100_KDmcbkhzYo5sjjxr9FqcVmWVnzb.png"
|
||||
)
|
||||
I2VA_IMAGE_SHA256 = "352e959e08b886406d3766cee25ebe7bbf679e6d53eeffbd1c607b8dc93e2068"
|
||||
REF2VA_REFERENCE_VIDEO_URL = "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/assets/r2va.mp4"
|
||||
REF2VA_REFERENCE_VIDEO_SHA256 = "5c9d33dfc517438c59e93da7b3d0b2a62e3cf69bc2adb7d1b2d33d7d8df7161f"
|
||||
REF2VA_INPUT_VIDEO_URL = (
|
||||
"https://cdn.hailuoai.com/prod/hailuo_demo/testsets/h3_promo_eval_ref2va/"
|
||||
"gallery/sr_v2p26_trio_seed42_20260724/inputs/"
|
||||
"297573323635_00_%E8%A7%86%E9%A2%911_YnyRbxEwio_video_20260525_163755_1927e9d3.mp4"
|
||||
)
|
||||
REF2VA_INPUT_VIDEO_SHA256 = "d6edc20ac82aa980c9cbd0b05b371f60d12dc8d018492e571b42de0086819c41"
|
||||
REF2VA_INPUT_AUDIO_URL = (
|
||||
"https://cdn.hailuoai.com/prod/hailuo_demo/testsets/h3_promo_eval_ref2va/"
|
||||
"gallery/sr_v2p26_trio_seed42_20260724/inputs/"
|
||||
"f463d523c5ce_01_%E9%9F%B3%E9%A2%911_RSLcbpzJPo_6%E6%9C%885%E6%97%A5(1).mp3"
|
||||
)
|
||||
REF2VA_INPUT_AUDIO_SHA256 = "2a48d447b343b38a2c504d5281a0c97c688db0ba87931ed9282c598bd6752454"
|
||||
|
||||
I2VA_PROMPT = """For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced.
|
||||
|
||||
integrated_multimodal_description: [Shot 1] This is a live-action, cinematic shot with a shallow depth of field. The camera holds a perfectly static shot throughout the entire eight-second duration, capturing a cozy family gathering in a traditional Japanese dining room. The scene opens with a large, intricately patterned blue and white ceramic bowl of ramen in the immediate foreground, rendered in crisp, sharp focus. The bowl sits on a smooth, polished long wooden table. Inside the bowl, a rich, oily golden-brown broth surrounds yellow wavy noodles, topped with two thick, round slices of chashu pork featuring visible fat marbling and a distinct spiral meat pattern. A generous mound of freshly chopped, bright green scallions rests in the center, and a crisp, dark green rectangular sheet of nori seaweed is tucked into the right edge. To the left of the bowl, a pair of light brown wooden chopsticks rests horizontally on a small, dark rectangular chopstick rest, near a small cylindrical ceramic teacup with blue painted patterns. On the right side of the table, a spherical paper lantern with a ribbed bamboo frame sits on a black wooden base. In the background, a large family of seven is gathered around the table, initially appearing as a soft, blurred presence. Behind them, traditional Japanese sliding shoji screens with wooden lattice frames are open, revealing a bright outdoor scene with lush green trees. Early in the clip, the thick, white steam rising from the hot ramen broth immediately intensifies, billowing upwards in thick, swirling clouds that dance continuously above the bowl. As the clip progresses into the middle seconds, the camera maintains its static position while the focus begins a deliberate, smooth shift deeper into the room. The foreground ramen bowl, its vibrant ingredients, and the rising steam gradually soften into a hazy, out-of-focus blur. Simultaneously, the family members in the background come into sharp, detailed clarity. The heavy steam continues to rise from the foreground, creating a dynamic, translucent veil between the camera and the family. With the focus now firmly locked on the background, the vibrant family dinner comes alive. The man in the dark navy blue long-sleeved shirt on the left leans forward, his mouth moving animatedly in a silent exchange. The young girl in the crisp white short-sleeved t-shirt beside him smiles brightly, looking toward the center of the table. The woman on the far left, wearing a soft light blue long-sleeved blouse, turns her head slightly, smiling gently. Across the table, the woman in the light grey button-down shirt smiles broadly, her eyes crinkling, as she rests her hands near her plate. The woman in the dark grey top further back uses her wooden chopsticks to pick up a small piece of food from a central ceramic dish filled with bright red pickled vegetables. The woman in the center back in the light grey sweater smiles gently, her hands clasped softly in front of her, observing the interaction. Throughout the remainder of the clip, the family continues their lively physical interaction, their mouths moving in continuous, silent cadences of conversation, while the thick, white steam from the blurred ramen bowl in the foreground never stops rising, adding a comforting atmosphere to the warm gathering.
|
||||
|
||||
overall_soundscape: The soundscape begins with a quiet room tone mixed with the faint, airy rustle of the thick steam billowing from the hot ramen bowl in the foreground, accompanied by the subtle, continuous hissing and bubbling of the rich broth. As the visual focus shifts deeper into the room, the physical sounds of the bustling family dinner become dominant in the foreground. The clear, sharp clinking of ceramic bowls and wooden chopsticks touching plates is clearly heard as the family members reach for food. This is followed by the faint, muffled thud of a cup being set down on the smooth wooden table, and the subtle, rhythmic rustle of cotton and wool clothing as the family members lean forward and gesture, perfectly capturing the lively, physical atmosphere of the shared meal.
|
||||
|
||||
non_diegetic_music: A gentle, heartwarming acoustic guitar melody plays softly in the background, accompanied by the subtle, resonant notes of a traditional Japanese koto. The music maintains a slow, comforting tempo that enhances the cozy, nostalgic, and joyful atmosphere of the family gathering."""
|
||||
|
||||
REF2VA_PROMPT = """subject_definitions:
|
||||
<Subject 1> is the young man with short wavy blonde hair, wearing a bright pink suit jacket, matching pink trousers, an unbuttoned white shirt, and silver rings, holding a small black lamb in his arms in <Video 1>.
|
||||
<Video 1> is the source video for the editing task.
|
||||
<Audio 1> is the synchronized audio track of <Video 1>, providing the background music.
|
||||
<Audio 2> is the voice timbre reference for <Subject 1>'s voice, containing a spoken male voiceover.
|
||||
|
||||
summary:
|
||||
[video editing + audio reference + audio reuse] The target video is an edited version of <Video 1>. <Subject 1>, wearing a bright pink suit and holding a black lamb, stands in a grassy field with other white lambs in the background. The edit animates <Subject 1>'s face to speak the user-provided dialogue. <Audio 1> is partially reused as the continuous background music, while the target references the calm male voice timbre of <Audio 2> for <Subject 1>'s spoken lines.
|
||||
|
||||
retention_analysis:
|
||||
<Subject 1> (appears in [Shot 1]): fully_preserved - the man retains his identity, wavy blonde hair, pink suit, white shirt, accessories, and the black lamb he holds, with his mouth newly animated to speak.
|
||||
<Video 1> (source video editing): fully_preserved - the original camera framing, warm golden hour lighting, grassy hill setting, and background white lambs are maintained while the central character is edited.
|
||||
<Audio 1>: partially_copy - the atmospheric background music from <Audio 1> is reused in the target video, mixed beneath the newly added spoken dialogue.
|
||||
<Audio 2>: reference - the target audio references the male voice timbre from <Audio 2> to generate <Subject 1>'s spoken dialogue.
|
||||
|
||||
detailed_description:
|
||||
The target video is in realistic photographic style.
|
||||
[Shot 1] The shot begins from the source <Video 1>, showing <Subject 1>, a young man with short wavy blonde hair, wearing a bright pink suit jacket, matching pink trousers, and a casually unbuttoned white shirt. He stands confidently in a sunlit green pasture, gently holding a small black lamb securely in his arms. The warm, golden hour lighting casts soft shadows across his face and the bright pink fabric of his suit. Behind him, several white lambs stand and graze on the rolling grassy hill against a clear, pale blue sky. The atmospheric background music from <Audio 1> plays continuously throughout the scene. <Subject 1> physically speaks, his mouth movements naturally syncing to the new dialogue, with his voice timbre referencing the calm male delivery from <Audio 2>. Looking thoughtfully forward, <Subject 1> (S1) speaks softly, <d>[English] Follow the wind, live free.</d> As he delivers the line, he subtly shifts his weight, cradling the resting black lamb while the camera slowly pushes in. <Subject 1> (S1) continues his thought, <d>[English] Leave worries behind, enjoy the moment.</d> Exactly as his voice stops, his lips meet in a relaxed, peaceful smile, and his jaw ceases speaking motion. He then turns his gaze slightly away toward the horizon, gently stroking the black lamb's fleece with his fingers as the camera holds on this tranquil, sunlit state through the end of the video.
|
||||
|
||||
overall_soundscape:
|
||||
The soundscape consists of the continuous, atmospheric background music from <Audio 1>, overlaid with the clear, calm male dialogue spoken by the main character, referencing the voice timbre of <Audio 2>.
|
||||
|
||||
non_diegetic_music:
|
||||
The atmospheric, sustained background music from <Audio 1> is reused as the continuous score, playing quietly beneath the spoken dialogue.
|
||||
"""
|
||||
|
||||
WIDTH = 1344
|
||||
HEIGHT = 768
|
||||
FPS = 24
|
||||
I2VA_NUM_FRAMES = 192
|
||||
REF2VA_NUM_FRAMES = 124
|
||||
NUM_INFERENCE_STEPS = 50
|
||||
FLOW_SHIFT = 12.0
|
||||
AUDIO_FLOW_SHIFT = 3.0
|
||||
I2VA_DURATION_SECONDS = 8.0
|
||||
REF2VA_DURATION_SECONDS = 5.0
|
||||
SEED = 0
|
||||
SSIM_THRESHOLD = 0.97
|
||||
PSNR_THRESHOLD = 34.0
|
||||
REQUEST_TIMEOUT_SECONDS = 60 * 60
|
||||
|
||||
|
||||
def _model_name(model_env_var: str = MODEL_ENV_VAR, component: str = "FL2VA") -> str:
|
||||
configured = os.environ.get(model_env_var)
|
||||
if configured:
|
||||
return configured
|
||||
|
||||
snapshot_root = snapshot_download(
|
||||
repo_id=MODEL_REPO_ID,
|
||||
revision=MODEL_REVISION,
|
||||
allow_patterns=[f"{component}/**"],
|
||||
)
|
||||
return str(Path(snapshot_root) / component)
|
||||
|
||||
|
||||
def _server_args() -> list[str]:
|
||||
return [
|
||||
"--trust-remote-code",
|
||||
"--num-gpus",
|
||||
"4",
|
||||
"--usp",
|
||||
"4",
|
||||
"--ring",
|
||||
"1",
|
||||
"--use-hsdp",
|
||||
"--hsdp-shard-size",
|
||||
"4",
|
||||
"--text-encoder-tp-size",
|
||||
"4",
|
||||
"--vae-patch-parallel-size",
|
||||
"4",
|
||||
"--vae-parallel-mode",
|
||||
"tile",
|
||||
"--vae-use-tiling",
|
||||
"--diffusion-attention-backend",
|
||||
"FLASH_ATTN",
|
||||
"--stage-init-timeout",
|
||||
"1800",
|
||||
"--init-timeout",
|
||||
"1800",
|
||||
]
|
||||
|
||||
|
||||
def _download_reference_asset(
|
||||
output_dir: Path,
|
||||
*,
|
||||
filename: str,
|
||||
url: str,
|
||||
sha256: str,
|
||||
label: str,
|
||||
) -> Path:
|
||||
reference_path = output_dir / filename
|
||||
download_url = url.replace("/blob/", "/resolve/", 1)
|
||||
response = requests.get(download_url, timeout=120)
|
||||
response.raise_for_status()
|
||||
content = response.content
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
assert digest == sha256, f"Official MiniMax H3 {label} reference changed: got {digest}, expected {sha256}"
|
||||
reference_path.write_bytes(content)
|
||||
return reference_path
|
||||
|
||||
|
||||
def _probe_audio(path: Path) -> dict[str, str | int]:
|
||||
probe_binary("ffprobe")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name,sample_rate,channels",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
streams = json.loads(result.stdout).get("streams", [])
|
||||
assert len(streams) == 1, f"Expected one audio stream in {path}, got {streams}"
|
||||
stream = streams[0]
|
||||
return {
|
||||
"codec_name": str(stream["codec_name"]),
|
||||
"sample_rate": int(stream["sample_rate"]),
|
||||
"channels": int(stream["channels"]),
|
||||
}
|
||||
|
||||
|
||||
def _assert_official_video(
|
||||
generated_path: Path,
|
||||
reference_path: Path,
|
||||
*,
|
||||
frame_count: int,
|
||||
label: str,
|
||||
ssim_threshold: float = SSIM_THRESHOLD,
|
||||
psnr_threshold: float = PSNR_THRESHOLD,
|
||||
) -> None:
|
||||
reference_metadata = probe_video(reference_path)
|
||||
generated_metadata = probe_video(generated_path)
|
||||
assert reference_metadata == generated_metadata
|
||||
assert_video_metadata(
|
||||
generated_metadata,
|
||||
width=WIDTH,
|
||||
height=HEIGHT,
|
||||
fps=FPS,
|
||||
frame_count=frame_count,
|
||||
)
|
||||
|
||||
expected_audio = {"codec_name": "aac", "sample_rate": 32000, "channels": 2}
|
||||
assert _probe_audio(reference_path) == expected_audio
|
||||
assert _probe_audio(generated_path) == expected_audio
|
||||
|
||||
assert_video_similarity_metrics(
|
||||
label=label,
|
||||
online_path=generated_path,
|
||||
offline_path=reference_path,
|
||||
ssim_threshold=ssim_threshold,
|
||||
psnr_threshold=psnr_threshold,
|
||||
)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=4)
|
||||
def test_minimax_h3_i2va_matches_official_reference(
|
||||
accuracy_artifact_root: Path,
|
||||
) -> None:
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("MiniMax H3 I2VA accuracy test requires CUDA.")
|
||||
|
||||
probe_binary("ffmpeg")
|
||||
probe_binary("ffprobe")
|
||||
output_dir = reset_artifact_dir(accuracy_artifact_root / "minimax_h3_i2va")
|
||||
reference_path = _download_reference_asset(
|
||||
output_dir,
|
||||
filename="reference.mp4",
|
||||
url=I2VA_REFERENCE_VIDEO_URL,
|
||||
sha256=I2VA_REFERENCE_VIDEO_SHA256,
|
||||
label="I2VA",
|
||||
)
|
||||
image_path = _download_reference_asset(
|
||||
output_dir,
|
||||
filename="input.png",
|
||||
url=I2VA_IMAGE_URL,
|
||||
sha256=I2VA_IMAGE_SHA256,
|
||||
label="I2VA input image",
|
||||
)
|
||||
generated_path = output_dir / "vllm_omni.mp4"
|
||||
|
||||
server_env = {
|
||||
"FLASHINFER_DISABLE_VERSION_CHECK": "1",
|
||||
"VLLM_WORKER_MULTIPROC_METHOD": "spawn",
|
||||
"VLLM_OMNI_VIDEO_SYNC_TIMEOUT": str(REQUEST_TIMEOUT_SECONDS),
|
||||
"VLLM_OMNI_STORAGE_PATH": str(output_dir / "storage"),
|
||||
}
|
||||
request_data = {
|
||||
"prompt": I2VA_PROMPT,
|
||||
"width": str(WIDTH),
|
||||
"height": str(HEIGHT),
|
||||
"fps": str(FPS),
|
||||
"num_inference_steps": str(NUM_INFERENCE_STEPS),
|
||||
"flow_shift": str(FLOW_SHIFT),
|
||||
"seed": str(SEED),
|
||||
"extra_params": json.dumps(
|
||||
{
|
||||
"task": "fl2va",
|
||||
"duration": I2VA_DURATION_SECONDS,
|
||||
"aspect_ratio": "auto",
|
||||
"frame_indices": [0],
|
||||
"audio_flow_shift": AUDIO_FLOW_SHIFT,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
with OmniServer(
|
||||
_model_name(),
|
||||
_server_args(),
|
||||
env_dict=server_env,
|
||||
use_omni=True,
|
||||
) as server:
|
||||
with image_path.open("rb") as image_file:
|
||||
response = requests.post(
|
||||
f"http://{server.host}:{server.port}/v1/videos/sync",
|
||||
data=request_data,
|
||||
files={"input_reference": ("input.png", image_file, "image/png")},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
assert response.headers["content-type"].startswith("video/mp4")
|
||||
generated_path.write_bytes(response.content)
|
||||
_assert_official_video(
|
||||
generated_path,
|
||||
reference_path,
|
||||
frame_count=I2VA_NUM_FRAMES,
|
||||
label="minimax_h3_i2va_official_reference",
|
||||
)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=4)
|
||||
def test_minimax_h3_ref2va_matches_official_reference(
|
||||
accuracy_artifact_root: Path,
|
||||
) -> None:
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("MiniMax H3 Ref2VA accuracy test requires CUDA.")
|
||||
|
||||
probe_binary("ffmpeg")
|
||||
probe_binary("ffprobe")
|
||||
output_dir = reset_artifact_dir(accuracy_artifact_root / "minimax_h3_ref2va")
|
||||
reference_path = _download_reference_asset(
|
||||
output_dir,
|
||||
filename="reference.mp4",
|
||||
url=REF2VA_REFERENCE_VIDEO_URL,
|
||||
sha256=REF2VA_REFERENCE_VIDEO_SHA256,
|
||||
label="Ref2VA",
|
||||
)
|
||||
input_video_path = _download_reference_asset(
|
||||
output_dir,
|
||||
filename="input_video.mp4",
|
||||
url=REF2VA_INPUT_VIDEO_URL,
|
||||
sha256=REF2VA_INPUT_VIDEO_SHA256,
|
||||
label="Ref2VA input video",
|
||||
)
|
||||
input_audio_path = _download_reference_asset(
|
||||
output_dir,
|
||||
filename="input_audio.mp3",
|
||||
url=REF2VA_INPUT_AUDIO_URL,
|
||||
sha256=REF2VA_INPUT_AUDIO_SHA256,
|
||||
label="Ref2VA input audio",
|
||||
)
|
||||
generated_path = output_dir / "vllm_omni.mp4"
|
||||
|
||||
server_env = {
|
||||
"FLASHINFER_DISABLE_VERSION_CHECK": "1",
|
||||
"VLLM_WORKER_MULTIPROC_METHOD": "spawn",
|
||||
"VLLM_OMNI_VIDEO_SYNC_TIMEOUT": str(REQUEST_TIMEOUT_SECONDS),
|
||||
"VLLM_OMNI_STORAGE_PATH": str(output_dir / "storage"),
|
||||
}
|
||||
request_data = {
|
||||
"prompt": REF2VA_PROMPT,
|
||||
"width": str(WIDTH),
|
||||
"height": str(HEIGHT),
|
||||
"fps": str(FPS),
|
||||
"num_inference_steps": str(NUM_INFERENCE_STEPS),
|
||||
"flow_shift": str(FLOW_SHIFT),
|
||||
"seed": str(SEED),
|
||||
"extra_params": json.dumps(
|
||||
{
|
||||
"task": "ref2va",
|
||||
"duration": REF2VA_DURATION_SECONDS,
|
||||
"aspect_ratio": "auto",
|
||||
"audio_flow_shift": AUDIO_FLOW_SHIFT,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
with OmniServer(
|
||||
_model_name(REF2VA_MODEL_ENV_VAR, "Ref2VA"),
|
||||
_server_args(),
|
||||
env_dict=server_env,
|
||||
use_omni=True,
|
||||
) as server:
|
||||
with input_video_path.open("rb") as video_file, input_audio_path.open("rb") as audio_file:
|
||||
response = requests.post(
|
||||
f"http://{server.host}:{server.port}/v1/videos/sync",
|
||||
data=request_data,
|
||||
files=[
|
||||
("input_references", ("input_video.mp4", video_file, "video/mp4")),
|
||||
("input_references", ("input_audio.mp3", audio_file, "audio/mpeg")),
|
||||
],
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
assert response.headers["content-type"].startswith("video/mp4")
|
||||
generated_path.write_bytes(response.content)
|
||||
_assert_official_video(
|
||||
generated_path,
|
||||
reference_path,
|
||||
frame_count=REF2VA_NUM_FRAMES,
|
||||
label="minimax_h3_ref2va_official_reference",
|
||||
)
|
||||
@@ -1,216 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import torch
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
from tests.e2e.accuracy.helpers import (
|
||||
assert_video_metadata,
|
||||
assert_video_similarity_metrics,
|
||||
probe_binary,
|
||||
probe_video,
|
||||
reset_artifact_dir,
|
||||
)
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniServer
|
||||
|
||||
pytestmark = [pytest.mark.benchmark, pytest.mark.diffusion, pytest.mark.full_model]
|
||||
|
||||
MODEL_REPO_ID = "MiniMaxAI/MiniMax-H3"
|
||||
# The previously pinned revision was removed from the Hugging Face repository.
|
||||
MODEL_REVISION = "main"
|
||||
MODEL_ENV_VAR = "VLLM_TEST_MINIMAX_H3_FL2VA_MODEL"
|
||||
# Keep the official asset link visible in the test report. Hugging Face's
|
||||
# ``blob`` URL is an HTML page, so resolve it to the raw asset for download.
|
||||
REFERENCE_VIDEO_URL = "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/assets/t2va.mp4"
|
||||
REFERENCE_VIDEO_SHA256 = "d66903241362e224085cc93f7a5e70fba6ab378d0ac6ff6af83acf2559849a42"
|
||||
|
||||
# This is the official reproducible 768p T2VA prompt associated with
|
||||
# ``assets/t2va.mp4`` in the MiniMax H3 repository.
|
||||
PROMPT = """integrated_multimodal_description: [Shot 1] Cinematic, medium wide shot, pushing in slowly. In the cavernous, dimly lit bridge of a starship, sleek metallic consoles with glowing amber displays flank a massive, curved observation window. A female captain, in her late 40s with an athletic build and short silver-streaked black hair, stands in the center midground. She wears a structured, high-collared dark navy military tunic with silver chest insignias. Her back is to the camera, silhouetted against the cool, ambient starlight pouring through the thick glass. She stands perfectly still with her hands clasped tightly behind her back. Outside the window, a massive armada of jagged, dark grey dreadnoughts hovers in tight formation against a deep purple space nebula. The fleet's massive rear thrusters begin to glow with an intense, escalating bright blue light. [Shot 2] At 00:04.500, the camera cuts to a close-up of the captain's face and shakes strongly. The brilliant blue-white light from the fleet's gathering energy reflects vividly in her dark eyes. Suddenly, a blinding white flash floods through the window, completely washing out the background as the fleet jumps to hyperspace. The sheer spatial force violently jolts the bridge, causing the captain from Shot 1 to stagger slightly forward, her shoulders tensing as she visibly braces herself against the physical tremors. As the intense white light fades abruptly, leaving only the dim, empty expanse of the purple nebula reflected on her starkly lit skin, her jaw clenches, and she slowly closes her eyes in the newly emptied space.
|
||||
overall_soundscape: A low, resonant hum of the ship's ambient life support systems serves as the baseline, soon drowned out by an audible, escalating, high-pitched electronic whine as the fleet outside charges its hyperdrives. A massive, deafening, bass-heavy boom and sharp crackle erupts during the blinding flash, accompanied by the loud metallic creaking, rattling, and deep thuds of the bridge's bulkheads vibrating under immense physical stress. The intense roaring impact then cuts abruptly back to a hollow, echoing room tone, leaving only the faint, steady hum of the isolated bridge.
|
||||
non_diegetic_music: Cinematic space-opera orchestral score, slow tempo, featuring a solitary, mournful French horn melody over deep, sustained string dissonances that build rapidly in volume and intensity, swelling to a massive orchestral peak before snapping immediately into silence right after the jump."""
|
||||
|
||||
WIDTH = 1344
|
||||
HEIGHT = 768
|
||||
FPS = 24
|
||||
NUM_FRAMES = 243
|
||||
NUM_INFERENCE_STEPS = 50
|
||||
FLOW_SHIFT = 12.0
|
||||
AUDIO_FLOW_SHIFT = 3.0
|
||||
DURATION_SECONDS = 10.0
|
||||
SEED = 0
|
||||
SSIM_THRESHOLD = 0.82
|
||||
PSNR_THRESHOLD = 20.0
|
||||
REQUEST_TIMEOUT_SECONDS = 60 * 60
|
||||
|
||||
|
||||
def _model_name() -> str:
|
||||
configured = os.environ.get(MODEL_ENV_VAR)
|
||||
if configured:
|
||||
return configured
|
||||
|
||||
snapshot_root = snapshot_download(
|
||||
repo_id=MODEL_REPO_ID,
|
||||
revision=MODEL_REVISION,
|
||||
allow_patterns=["FL2VA/**"],
|
||||
)
|
||||
return str(Path(snapshot_root) / "FL2VA")
|
||||
|
||||
|
||||
def _server_args() -> list[str]:
|
||||
return [
|
||||
"--trust-remote-code",
|
||||
"--num-gpus",
|
||||
"4",
|
||||
"--usp",
|
||||
"4",
|
||||
"--ring",
|
||||
"1",
|
||||
"--use-hsdp",
|
||||
"--hsdp-shard-size",
|
||||
"4",
|
||||
"--text-encoder-tp-size",
|
||||
"4",
|
||||
"--vae-patch-parallel-size",
|
||||
"4",
|
||||
"--vae-parallel-mode",
|
||||
"tile",
|
||||
"--vae-use-tiling",
|
||||
"--diffusion-attention-backend",
|
||||
"FLASH_ATTN",
|
||||
"--stage-init-timeout",
|
||||
"1800",
|
||||
"--init-timeout",
|
||||
"1800",
|
||||
]
|
||||
|
||||
|
||||
def _download_reference_video(output_dir: Path) -> Path:
|
||||
reference_path = output_dir / "reference.mp4"
|
||||
download_url = REFERENCE_VIDEO_URL.replace("/blob/", "/resolve/", 1)
|
||||
response = requests.get(download_url, timeout=120)
|
||||
response.raise_for_status()
|
||||
content = response.content
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
assert digest == REFERENCE_VIDEO_SHA256, (
|
||||
f"Official MiniMax H3 T2VA reference changed: got {digest}, expected {REFERENCE_VIDEO_SHA256}"
|
||||
)
|
||||
reference_path.write_bytes(content)
|
||||
return reference_path
|
||||
|
||||
|
||||
def _probe_audio(path: Path) -> dict[str, str | int]:
|
||||
probe_binary("ffprobe")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name,sample_rate,channels",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
streams = json.loads(result.stdout).get("streams", [])
|
||||
assert len(streams) == 1, f"Expected one audio stream in {path}, got {streams}"
|
||||
stream = streams[0]
|
||||
return {
|
||||
"codec_name": str(stream["codec_name"]),
|
||||
"sample_rate": int(stream["sample_rate"]),
|
||||
"channels": int(stream["channels"]),
|
||||
}
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=4)
|
||||
def test_minimax_h3_t2va_matches_official_reference(
|
||||
accuracy_artifact_root: Path,
|
||||
) -> None:
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("MiniMax H3 T2VA accuracy test requires CUDA.")
|
||||
|
||||
probe_binary("ffmpeg")
|
||||
probe_binary("ffprobe")
|
||||
output_dir = reset_artifact_dir(accuracy_artifact_root / "minimax_h3_t2va")
|
||||
reference_path = _download_reference_video(output_dir)
|
||||
generated_path = output_dir / "vllm_omni.mp4"
|
||||
|
||||
server_env = {
|
||||
"FLASHINFER_DISABLE_VERSION_CHECK": "1",
|
||||
"VLLM_WORKER_MULTIPROC_METHOD": "spawn",
|
||||
"VLLM_OMNI_VIDEO_SYNC_TIMEOUT": str(REQUEST_TIMEOUT_SECONDS),
|
||||
"VLLM_OMNI_STORAGE_PATH": str(output_dir / "storage"),
|
||||
}
|
||||
request_data = {
|
||||
"prompt": PROMPT,
|
||||
"width": str(WIDTH),
|
||||
"height": str(HEIGHT),
|
||||
"fps": str(FPS),
|
||||
"num_inference_steps": str(NUM_INFERENCE_STEPS),
|
||||
"flow_shift": str(FLOW_SHIFT),
|
||||
"seed": str(SEED),
|
||||
"extra_params": json.dumps(
|
||||
{
|
||||
"task": "t2va",
|
||||
"duration": DURATION_SECONDS,
|
||||
"aspect_ratio": "16:9",
|
||||
"audio_flow_shift": AUDIO_FLOW_SHIFT,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
with OmniServer(
|
||||
_model_name(),
|
||||
_server_args(),
|
||||
env_dict=server_env,
|
||||
use_omni=True,
|
||||
) as server:
|
||||
response = requests.post(
|
||||
f"http://{server.host}:{server.port}/v1/videos/sync",
|
||||
data=request_data,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
assert response.headers["content-type"].startswith("video/mp4")
|
||||
generated_path.write_bytes(response.content)
|
||||
|
||||
reference_metadata = probe_video(reference_path)
|
||||
generated_metadata = probe_video(generated_path)
|
||||
assert reference_metadata == generated_metadata
|
||||
assert_video_metadata(
|
||||
generated_metadata,
|
||||
width=WIDTH,
|
||||
height=HEIGHT,
|
||||
fps=FPS,
|
||||
frame_count=NUM_FRAMES,
|
||||
)
|
||||
|
||||
expected_audio = {"codec_name": "aac", "sample_rate": 32000, "channels": 2}
|
||||
assert _probe_audio(reference_path) == expected_audio
|
||||
assert _probe_audio(generated_path) == expected_audio
|
||||
|
||||
assert_video_similarity_metrics(
|
||||
label="minimax_h3_t2va_official_reference",
|
||||
online_path=generated_path,
|
||||
offline_path=reference_path,
|
||||
ssim_threshold=SSIM_THRESHOLD,
|
||||
psnr_threshold=PSNR_THRESHOLD,
|
||||
)
|
||||
@@ -19,6 +19,7 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
@@ -121,9 +122,13 @@ def seed_tts_bench_argv(*, locale: str = "en") -> list[str]:
|
||||
|
||||
def find_vllm_cli() -> str:
|
||||
exe = shutil.which("vllm")
|
||||
if not exe:
|
||||
raise FileNotFoundError("Could not find `vllm` on PATH (install vLLM-Omni with CLI entrypoints).")
|
||||
return exe
|
||||
if 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:
|
||||
@@ -150,6 +155,11 @@ def build_serve_common_argv(
|
||||
result_dir: Path,
|
||||
result_filename: str,
|
||||
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]:
|
||||
out = [
|
||||
"bench",
|
||||
@@ -162,9 +172,9 @@ def build_serve_common_argv(
|
||||
"--model",
|
||||
model,
|
||||
"--endpoint",
|
||||
"/v1/chat/completions",
|
||||
endpoint,
|
||||
"--backend",
|
||||
"openai-chat-omni",
|
||||
backend,
|
||||
"--request-rate",
|
||||
"inf",
|
||||
"--num-prompts",
|
||||
@@ -184,6 +194,12 @@ def build_serve_common_argv(
|
||||
]
|
||||
if ready_check_timeout_sec is not None:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -171,7 +171,13 @@ def _preserve_benchmark_dataset_env() -> Any:
|
||||
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(
|
||||
host=ns.host,
|
||||
port=ns.port,
|
||||
@@ -183,6 +189,11 @@ def _build_common_args(ns: argparse.Namespace, *, result_filename: str) -> list[
|
||||
result_dir=ns.result_dir,
|
||||
result_filename=result_filename,
|
||||
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 +212,9 @@ def run_daily_omni(ns: argparse.Namespace, vllm: str) -> Path:
|
||||
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:
|
||||
argv.append("--daily-omni-save-eval-items")
|
||||
print("\n$", vllm, *argv, "\n", flush=True)
|
||||
@@ -217,7 +231,12 @@ def run_seed_tts(ns: argparse.Namespace, vllm: str) -> Path:
|
||||
result_filename = f"qwen_omni_acc_seed_tts_{tag}.json"
|
||||
extra = json.loads(ns.seed_extra_body_json)
|
||||
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-wer-eval",
|
||||
@@ -225,6 +244,8 @@ def run_seed_tts(ns: argparse.Namespace, vllm: str) -> Path:
|
||||
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:
|
||||
argv.append("--seed-tts-wer-save-items")
|
||||
if ns.seed_tts_file_ref_audio:
|
||||
@@ -266,6 +287,23 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
help="If set, forwarded to ``vllm bench serve`` (probe first request until success). "
|
||||
"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(
|
||||
"--result-dir",
|
||||
type=Path,
|
||||
@@ -295,6 +333,13 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
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-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(
|
||||
"--daily-extra-body-json",
|
||||
default='{"modalities":["text"]}',
|
||||
@@ -326,6 +371,22 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
default='{"modalities":["text","audio"]}',
|
||||
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-file-ref-audio",
|
||||
@@ -400,6 +461,11 @@ def run_acc_benchmark(ns: argparse.Namespace) -> int:
|
||||
min_mean_sim=ns.min_seed_tts_mean_sim,
|
||||
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:
|
||||
failed.extend([f"[Seed-TTS] {e}" for e in errs])
|
||||
else:
|
||||
|
||||
@@ -42,10 +42,31 @@ from tests.e2e.accuracy.helpers import (
|
||||
from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniServer, OpenAIClientHandler
|
||||
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter import (
|
||||
CUDA_FLASH_ATTENTION_BACKEND_ATTEMPTS,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.full_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def _set_matched_attention_backend(pipe: DiffusionPipeline) -> None:
|
||||
"""Walk the same backend chain the omni server's diffusers adapter walks.
|
||||
|
||||
The server side (--diffusion-load-format diffusers) resolves its attention
|
||||
backend through the adapter's preference chain, skipping backends that are
|
||||
unavailable on the image (e.g. the FA3 hub kernel has no build variant for
|
||||
the image's torch — build 2953). The reference run must resolve to the
|
||||
same backend or the similarity comparison measures kernel differences.
|
||||
"""
|
||||
for backend in CUDA_FLASH_ATTENTION_BACKEND_ATTEMPTS:
|
||||
try:
|
||||
pipe.transformer.set_attention_backend(backend)
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
pipe.transformer.set_attention_backend("native")
|
||||
|
||||
|
||||
PROMPT = "A photo of a cat sitting on a laptop keyboard, digital art style."
|
||||
NEGATIVE_PROMPT = "blurry, low quality"
|
||||
WIDTH = 512
|
||||
@@ -235,7 +256,7 @@ def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> tuple[Image.I
|
||||
torch_dtype=torch.bfloat16,
|
||||
trust_remote_code=True,
|
||||
).to("cuda")
|
||||
pipe.transformer.set_attention_backend("_flash_3_hub")
|
||||
_set_matched_attention_backend(pipe)
|
||||
_diffusers_dummy_run(pipe)
|
||||
|
||||
latencies: list[float] = []
|
||||
|
||||
@@ -30,6 +30,12 @@ NUM_INFERENCE_STEPS = 20
|
||||
TRUE_CFG_SCALE = 4.0
|
||||
SEED = 42
|
||||
SSIM_THRESHOLD = 0.97
|
||||
# The FA3-hub-matched comparison clears 0.97 with margin. The SDPA-matched
|
||||
# fallback (hub kernel unavailable for the image's torch) landed at 0.9687 in
|
||||
# build 2954 — the attention math matches but the surrounding stacks (vLLM
|
||||
# compiled path vs eager diffusers) still differ slightly. Keep the strict
|
||||
# gate for the FA3 path and a slightly looser one for the fallback only.
|
||||
SSIM_THRESHOLD_SDPA_FALLBACK = 0.96
|
||||
PSNR_THRESHOLD = 30.0
|
||||
|
||||
MODEL_2512_ID = "Qwen/Qwen-Image-2512"
|
||||
@@ -56,6 +62,44 @@ MEAN_ABS_DIFF_THRESHOLD_2512 = 3e-2
|
||||
P99_ABS_DIFF_THRESHOLD_2512 = 4e-1
|
||||
|
||||
|
||||
_FLASH_ATTN3_HUB_AVAILABLE: bool | None = None
|
||||
|
||||
|
||||
def _flash_attn3_hub_available() -> bool:
|
||||
"""Whether kernels-community/flash-attn3 has a build for this torch/CUDA.
|
||||
|
||||
The hub repo only ships prebuilt variants for specific torch versions;
|
||||
a base-image torch bump can outrun it (build 2952 failed with "Cannot
|
||||
find a build variant for this system"). When it is unavailable, both
|
||||
sides of the comparison fall back to torch SDPA together — the test
|
||||
compares implementations, so the attention math must stay matched.
|
||||
"""
|
||||
global _FLASH_ATTN3_HUB_AVAILABLE
|
||||
if _FLASH_ATTN3_HUB_AVAILABLE is None:
|
||||
try:
|
||||
from kernels import get_kernel
|
||||
|
||||
get_kernel("kernels-community/flash-attn3")
|
||||
_FLASH_ATTN3_HUB_AVAILABLE = True
|
||||
except Exception as exc:
|
||||
print(f"kernels-community/flash-attn3 unavailable ({exc}); using matched torch SDPA on both sides.")
|
||||
_FLASH_ATTN3_HUB_AVAILABLE = False
|
||||
return _FLASH_ATTN3_HUB_AVAILABLE
|
||||
|
||||
|
||||
def _omni_server_env() -> dict[str, str] | None:
|
||||
if _flash_attn3_hub_available():
|
||||
return None
|
||||
return {"DIFFUSION_ATTENTION_BACKEND": "TORCH_SDPA"}
|
||||
|
||||
|
||||
def _set_reference_attention_backend(pipe: DiffusionPipeline) -> None:
|
||||
if _flash_attn3_hub_available():
|
||||
pipe.transformer.set_attention_backend("_flash_3_hub")
|
||||
else:
|
||||
pipe.transformer.set_attention_backend("native")
|
||||
|
||||
|
||||
def _model_name() -> str:
|
||||
return os.environ.get(MODEL_ENV_VAR, MODEL_ID)
|
||||
|
||||
@@ -78,7 +122,7 @@ def _run_vllm_omni_qwen_image(*, model: str, output_path: Path) -> Image.Image:
|
||||
"900",
|
||||
"--fa-deterministic",
|
||||
]
|
||||
with OmniServer(model, server_args, use_omni=True) as omni_server:
|
||||
with OmniServer(model, server_args, use_omni=True, env_dict=_omni_server_env()) as omni_server:
|
||||
response = requests.post(
|
||||
f"http://{omni_server.host}:{omni_server.port}/v1/images/generations",
|
||||
json={
|
||||
@@ -114,7 +158,7 @@ def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> Image.Image:
|
||||
trust_remote_code=True,
|
||||
local_files_only=_local_files_only(model),
|
||||
).to("cuda")
|
||||
pipe.transformer.set_attention_backend("_flash_3_hub")
|
||||
_set_reference_attention_backend(pipe)
|
||||
generator = torch.Generator(device="cuda").manual_seed(SEED)
|
||||
result = pipe( # pyright: ignore[reportCallIssue]
|
||||
prompt=PROMPT,
|
||||
@@ -140,7 +184,7 @@ def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> Image.Image:
|
||||
|
||||
def _run_vllm_omni_qwen_image_2512(*, model: str, output_path: Path) -> Image.Image:
|
||||
server_args = ["--num-gpus", "1", "--stage-init-timeout", "300", "--init-timeout", "900"]
|
||||
with OmniServer(model, server_args, use_omni=True) as omni_server:
|
||||
with OmniServer(model, server_args, use_omni=True, env_dict=_omni_server_env()) as omni_server:
|
||||
response = requests.post(
|
||||
f"http://{omni_server.host}:{omni_server.port}/v1/images/generations",
|
||||
json={
|
||||
@@ -176,7 +220,7 @@ def _run_diffusers_qwen_image_2512(*, model: str, output_path: Path) -> Image.Im
|
||||
trust_remote_code=True,
|
||||
local_files_only=_local_files_only(model),
|
||||
).to("cuda")
|
||||
pipe.transformer.set_attention_backend("_flash_3_hub")
|
||||
_set_reference_attention_backend(pipe)
|
||||
generator = torch.Generator(device="cuda").manual_seed(SEED_2512)
|
||||
result = pipe( # pyright: ignore[reportCallIssue]
|
||||
prompt=PROMPT_2512,
|
||||
@@ -215,7 +259,7 @@ def test_qwen_image_matches_diffusers(accuracy_artifact_root: Path) -> None:
|
||||
diffusers_image=diffusers_output,
|
||||
width=WIDTH,
|
||||
height=HEIGHT,
|
||||
ssim_threshold=SSIM_THRESHOLD,
|
||||
ssim_threshold=SSIM_THRESHOLD if _flash_attn3_hub_available() else SSIM_THRESHOLD_SDPA_FALLBACK,
|
||||
psnr_threshold=PSNR_THRESHOLD,
|
||||
)
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ from vllm_omni.experimental.fullduplex.client import (
|
||||
read_pcm16_wav,
|
||||
write_pcm16_wav,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.minicpmo45.policy import (
|
||||
MiniCPMO45DuplexPolicy,
|
||||
)
|
||||
|
||||
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():
|
||||
url = build_realtime_url(
|
||||
"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"
|
||||
|
||||
|
||||
@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():
|
||||
collector = RealtimeEventCollector()
|
||||
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,
|
||||
)
|
||||
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(
|
||||
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,
|
||||
}
|
||||
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():
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
from PIL import Image
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniRunner
|
||||
from vllm_omni.diffusion.models.longcat_video.pipeline_longcat_video_avatar import (
|
||||
prepare_longcat_video_avatar_model_for_omni,
|
||||
)
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
MODEL = "meituan-longcat/LongCat-Video-Avatar-1.5"
|
||||
PROMPT = "A person speaks calmly while facing the camera."
|
||||
NEGATIVE_PROMPT = "low quality, blurry, watermark, text"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def synthetic_audio_path(tmp_path):
|
||||
sample_rate = 16000
|
||||
duration_s = 2.0
|
||||
t = np.arange(int(sample_rate * duration_s), dtype=np.float32) / sample_rate
|
||||
audio = (0.08 * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
|
||||
path = tmp_path / "avatar_reference.wav"
|
||||
sf.write(path, audio, sample_rate)
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def synthetic_multi_audio_paths(tmp_path):
|
||||
sample_rate = 16000
|
||||
duration_s = 2.0
|
||||
t = np.arange(int(sample_rate * duration_s), dtype=np.float32) / sample_rate
|
||||
left = (0.08 * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
|
||||
right = (0.08 * np.sin(2 * np.pi * 330 * t)).astype(np.float32)
|
||||
left_path = tmp_path / "avatar_left.wav"
|
||||
right_path = tmp_path / "avatar_right.wav"
|
||||
sf.write(left_path, left, sample_rate)
|
||||
sf.write(right_path, right, sample_rate)
|
||||
return {"person1": str(left_path), "person2": str(right_path)}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def synthetic_image_path(tmp_path):
|
||||
path = tmp_path / "avatar_reference.png"
|
||||
Image.new("RGB", (768, 512), (130, 104, 88)).save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
def _sampling(
|
||||
stage: str,
|
||||
*,
|
||||
num_frames: int = 5,
|
||||
extra_args: dict | None = None,
|
||||
) -> OmniDiffusionSamplingParams:
|
||||
sampling_extra_args = {
|
||||
"stage": stage,
|
||||
"resolution": "480p",
|
||||
"save_fps": 25,
|
||||
"use_distill": True,
|
||||
"use_int8": True,
|
||||
}
|
||||
if extra_args:
|
||||
sampling_extra_args.update(extra_args)
|
||||
return OmniDiffusionSamplingParams(
|
||||
num_frames=num_frames,
|
||||
fps=25,
|
||||
num_inference_steps=8,
|
||||
guidance_scale=1.0,
|
||||
guidance_scale_2=1.0,
|
||||
seed=42,
|
||||
extra_args=sampling_extra_args,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def longcat_omni_runner() -> Generator[OmniRunner, None, None]:
|
||||
model = prepare_longcat_video_avatar_model_for_omni(MODEL, use_int8=True)
|
||||
with OmniRunner(
|
||||
model,
|
||||
seed=42,
|
||||
stage_configs_path=None,
|
||||
model_class_name="LongCatVideoAvatarPipeline",
|
||||
additional_config={
|
||||
"model_type": "avatar-v1.5",
|
||||
"resolution": "480p",
|
||||
"use_distill": True,
|
||||
"use_int8": True,
|
||||
},
|
||||
) as runner:
|
||||
yield runner
|
||||
|
||||
|
||||
def _generate_avatar_frames(
|
||||
omni_runner: OmniRunner,
|
||||
*,
|
||||
stage: str,
|
||||
audio_path: str | dict[str, str],
|
||||
image_path: str | None = None,
|
||||
additional_information: dict | None = None,
|
||||
num_frames: int = 5,
|
||||
sampling_extra_args: dict | None = None,
|
||||
):
|
||||
prompt = {
|
||||
"prompt": PROMPT,
|
||||
"negative_prompt": NEGATIVE_PROMPT,
|
||||
"multi_modal_data": {"audio": audio_path},
|
||||
"additional_information": additional_information or {},
|
||||
}
|
||||
prompt["additional_information"].update({"stage": stage, "resolution": "480p"})
|
||||
if image_path is not None:
|
||||
prompt["multi_modal_data"]["image"] = image_path
|
||||
|
||||
outputs = omni_runner.generate(
|
||||
[prompt],
|
||||
[_sampling(stage, num_frames=num_frames, extra_args=sampling_extra_args)],
|
||||
)
|
||||
assert outputs
|
||||
output = outputs[0]
|
||||
assert output.images
|
||||
frames = output.images[0]
|
||||
if isinstance(frames, tuple) and len(frames) == 2:
|
||||
frames = frames[0]
|
||||
if isinstance(frames, dict):
|
||||
frames = frames.get("frames") or frames.get("video")
|
||||
assert isinstance(frames, list)
|
||||
return frames
|
||||
|
||||
|
||||
def _assert_frames(frames, *, expected_size: tuple[int, int], expected_count: int = 5):
|
||||
assert len(frames) == expected_count
|
||||
for frame in frames:
|
||||
assert isinstance(frame, Image.Image)
|
||||
assert frame.mode == "RGB"
|
||||
assert frame.size == expected_size
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
|
||||
def test_longcat_video_avatar_at2v(longcat_omni_runner: OmniRunner, synthetic_audio_path):
|
||||
frames = _generate_avatar_frames(
|
||||
longcat_omni_runner,
|
||||
stage="at2v",
|
||||
audio_path=synthetic_audio_path,
|
||||
)
|
||||
_assert_frames(frames, expected_size=(832, 480))
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
|
||||
def test_longcat_video_avatar_ai2v(
|
||||
longcat_omni_runner: OmniRunner,
|
||||
synthetic_audio_path,
|
||||
synthetic_image_path,
|
||||
):
|
||||
frames = _generate_avatar_frames(
|
||||
longcat_omni_runner,
|
||||
stage="ai2v",
|
||||
audio_path=synthetic_audio_path,
|
||||
image_path=synthetic_image_path,
|
||||
)
|
||||
_assert_frames(frames, expected_size=(768, 512))
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
|
||||
def test_longcat_video_avatar_multi_speaker_ai2v(
|
||||
longcat_omni_runner: OmniRunner,
|
||||
synthetic_multi_audio_paths,
|
||||
synthetic_image_path,
|
||||
):
|
||||
frames = _generate_avatar_frames(
|
||||
longcat_omni_runner,
|
||||
stage="ai2v",
|
||||
audio_path=synthetic_multi_audio_paths,
|
||||
image_path=synthetic_image_path,
|
||||
additional_information={
|
||||
"audio_type": "para",
|
||||
"bbox": {
|
||||
"person1": [32, 48, 384, 336],
|
||||
"person2": [32, 432, 384, 720],
|
||||
},
|
||||
},
|
||||
)
|
||||
_assert_frames(frames, expected_size=(768, 512))
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
|
||||
def test_longcat_video_avatar_single_speaker_ai2v_avc_continuation(
|
||||
longcat_omni_runner: OmniRunner,
|
||||
synthetic_audio_path,
|
||||
synthetic_image_path,
|
||||
):
|
||||
frames = _generate_avatar_frames(
|
||||
longcat_omni_runner,
|
||||
stage="ai2v",
|
||||
audio_path=synthetic_audio_path,
|
||||
image_path=synthetic_image_path,
|
||||
num_frames=17,
|
||||
sampling_extra_args={
|
||||
"num_segments": 2,
|
||||
"num_cond_frames": 5,
|
||||
"use_kv_cache": True,
|
||||
},
|
||||
)
|
||||
_assert_frames(frames, expected_size=(768, 512), expected_count=29)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
|
||||
def test_longcat_video_avatar_multi_speaker_ai2v_avc_continuation(
|
||||
longcat_omni_runner: OmniRunner,
|
||||
synthetic_multi_audio_paths,
|
||||
synthetic_image_path,
|
||||
):
|
||||
frames = _generate_avatar_frames(
|
||||
longcat_omni_runner,
|
||||
stage="ai2v",
|
||||
audio_path=synthetic_multi_audio_paths,
|
||||
image_path=synthetic_image_path,
|
||||
additional_information={
|
||||
"audio_type": "para",
|
||||
"bbox": {
|
||||
"person1": [32, 48, 384, 336],
|
||||
"person2": [32, 432, 384, 720],
|
||||
},
|
||||
},
|
||||
num_frames=17,
|
||||
sampling_extra_args={
|
||||
"num_segments": 2,
|
||||
"num_cond_frames": 5,
|
||||
"use_kv_cache": True,
|
||||
},
|
||||
)
|
||||
_assert_frames(frames, expected_size=(768, 512), expected_count=29)
|
||||
@@ -34,7 +34,7 @@ def get_question(prompt_type: str = "text") -> str:
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
|
||||
def test_text_to_text(omni_runner, omni_runner_handler) -> None:
|
||||
"""Test processing text, generating text output."""
|
||||
@@ -44,7 +44,7 @@ def test_text_to_text(omni_runner, omni_runner_handler) -> None:
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
|
||||
def test_audio_to_text(omni_runner, omni_runner_handler) -> None:
|
||||
"""Test processing audio, generating text output."""
|
||||
@@ -57,7 +57,7 @@ def test_audio_to_text(omni_runner, omni_runner_handler) -> None:
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
|
||||
def test_image_to_text(omni_runner, omni_runner_handler) -> None:
|
||||
"""Test processing image, generating text output."""
|
||||
@@ -68,7 +68,7 @@ def test_image_to_text(omni_runner, omni_runner_handler) -> None:
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
|
||||
def test_video_to_text(omni_runner, omni_runner_handler) -> None:
|
||||
"""Test processing video, generating text output."""
|
||||
@@ -79,7 +79,7 @@ def test_video_to_text(omni_runner, omni_runner_handler) -> None:
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
|
||||
def test_text_to_audio(omni_runner, omni_runner_handler) -> None:
|
||||
"""Test processing text and generating audio through Talker and Code2Wav."""
|
||||
@@ -90,7 +90,7 @@ def test_text_to_audio(omni_runner, omni_runner_handler) -> None:
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
|
||||
def test_mix_to_audio(omni_runner, omni_runner_handler) -> None:
|
||||
"""Test processing mixed modalities (image + audio), generating audio output."""
|
||||
@@ -109,7 +109,7 @@ def test_mix_to_audio(omni_runner, omni_runner_handler) -> None:
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
|
||||
def test_video_to_audio(omni_runner, omni_runner_handler) -> None:
|
||||
"""Test processing video, generating audio output."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared helpers for online-serving MiniCPM-o duplex E2E tests."""
|
||||
+10
-41
@@ -19,36 +19,14 @@ 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": {
|
||||
0: {"kv_cache_memory_bytes": 6 * 1024 * 1024 * 1024},
|
||||
1: {"kv_cache_memory_bytes": 512 * 1024 * 1024},
|
||||
2: {"kv_cache_memory_bytes": 256 * 1024 * 1024},
|
||||
},
|
||||
# Platform overrides are applied after ordinary stage settings. Keep
|
||||
# the constrained duplex test's Talker budget ahead of the base CUDA
|
||||
# profile's single-GPU default.
|
||||
"platforms": {
|
||||
"cuda": {
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": 1,
|
||||
"kv_cache_memory_bytes": 512 * 1024 * 1024,
|
||||
}
|
||||
]
|
||||
}
|
||||
1: {"max_model_len": 4096},
|
||||
},
|
||||
},
|
||||
)
|
||||
CORE_DEPLOY_CONFIG = modify_stage_config(
|
||||
DEPLOY_CONFIG,
|
||||
updates={
|
||||
"stages": {
|
||||
0: {"enforce_eager": True},
|
||||
1: {"enforce_eager": True},
|
||||
}
|
||||
},
|
||||
)
|
||||
ASSET_DIR = Path(__file__).resolve().parents[1] / "assets" / "minicpmo_4_5"
|
||||
ASSET_DIR = Path(__file__).resolve().parents[3] / "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"
|
||||
@@ -60,18 +38,7 @@ SERVER_PARAMS = [
|
||||
OmniServerParams(
|
||||
model=MODEL,
|
||||
stage_config_path=DEPLOY_CONFIG,
|
||||
use_stage_cli=True,
|
||||
server_args=["--trust-remote-code"],
|
||||
),
|
||||
id="three-stage-single-gpu",
|
||||
)
|
||||
]
|
||||
CORE_SERVER_PARAMS = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL,
|
||||
stage_config_path=CORE_DEPLOY_CONFIG,
|
||||
use_stage_cli=True,
|
||||
use_stage_cli=False,
|
||||
server_args=["--trust-remote-code"],
|
||||
),
|
||||
id="three-stage-single-gpu",
|
||||
@@ -107,9 +74,11 @@ def validated_soft_interrupt_wav() -> Path:
|
||||
|
||||
|
||||
def resolve_ref_audio(model_prefix: str) -> Path:
|
||||
if model_prefix:
|
||||
model_root = Path(model_prefix) / MODEL
|
||||
else:
|
||||
# ``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():
|
||||
+37
-2
@@ -14,7 +14,7 @@ not a separate model-level barge-in contract.
|
||||
|
||||
Run only after a MiniCPM-o 4.5 vLLM-Omni server is up:
|
||||
|
||||
python tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py \
|
||||
python tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py \
|
||||
--url ws://localhost:8099/v1/realtime?duplex=1 \
|
||||
--model openbmb/MiniCPM-o-4_5 \
|
||||
--input-wav input_16k_mono_pcm16.wav \
|
||||
@@ -37,7 +37,7 @@ import wave
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
@@ -53,6 +53,7 @@ from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
|
||||
RealtimeEventCollector,
|
||||
build_realtime_url,
|
||||
read_pcm16_wav,
|
||||
summarize_session_request_metrics,
|
||||
)
|
||||
|
||||
_url_with_model = build_realtime_url
|
||||
@@ -194,6 +195,38 @@ class DemoState:
|
||||
summaries[response_id] = timing
|
||||
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:
|
||||
for index, event in enumerate(self.events):
|
||||
if event.get("type") != event_type:
|
||||
@@ -1538,6 +1571,8 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
"input_transcription_ok": input_transcription_ok,
|
||||
"completed_response_ids": completed_response_ids,
|
||||
"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,
|
||||
"validation_mode": validation_mode,
|
||||
"scenario": scenario,
|
||||
@@ -8,6 +8,7 @@ import hashlib
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
@@ -16,14 +17,29 @@ import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
if str(SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
REPO_ROOT = SCRIPT_DIR.parents[3]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from minicpmo_realtime_duplex_scenarios import ( # noqa: E402
|
||||
_ref_audio_data_url,
|
||||
_url_with_model,
|
||||
run_demo,
|
||||
)
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _scenario_module():
|
||||
"""Delay heavyweight vLLM imports so ``--help`` remains directly executable."""
|
||||
from tests.e2e.online_serving.helpers 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:
|
||||
|
||||
@@ -314,6 +314,14 @@ def summarize_artifacts(
|
||||
and event["response"].get("status") == "cancelled"
|
||||
)
|
||||
result_ok = result.get("ok") is True
|
||||
# response-required keeps the full listen sandwich around commit. model-policy
|
||||
# only requires a completed audio response that started before final commit:
|
||||
# single-GPU co-location often still drains speak after the WAV ends.
|
||||
commit_listen_contract_ok = (
|
||||
listen_after_response_before_commit and final_listen_after_commit
|
||||
if validation_mode == "response-required"
|
||||
else response_before_final_commit
|
||||
)
|
||||
common_contract_ok = bool(
|
||||
result_ok
|
||||
and not error_events
|
||||
@@ -323,8 +331,7 @@ def summarize_artifacts(
|
||||
and multi_delta_ok
|
||||
and response_audio_contract_ok
|
||||
and response_before_final_commit
|
||||
and listen_after_response_before_commit
|
||||
and final_listen_after_commit
|
||||
and commit_listen_contract_ok
|
||||
)
|
||||
mode_contract_ok = validation_mode == "model-policy" or (
|
||||
second_response_before_final_commit
|
||||
@@ -393,6 +400,11 @@ async def run_soft_interrupt(args: argparse.Namespace) -> dict[str, object]:
|
||||
f"duplex-soft-interrupt-{uuid.uuid4().hex}",
|
||||
]
|
||||
command.extend(["--ref-audio", str(_canonical_path(args.ref_audio))])
|
||||
temperature = getattr(args, "temperature", None)
|
||||
if temperature is None and args.validation_mode == "response-required":
|
||||
temperature = 0.0
|
||||
if temperature is not None:
|
||||
command.extend(["--temperature", str(temperature)])
|
||||
if args.require_audio:
|
||||
command.append("--require-audio")
|
||||
if args.no_realtime_pacing:
|
||||
@@ -453,6 +465,12 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--summary-output")
|
||||
parser.add_argument("--chunk-ms", type=int, default=200)
|
||||
parser.add_argument("--timeout-s", type=float, default=120.0)
|
||||
parser.add_argument(
|
||||
"--temperature",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Stage0 sampling temperature; response-required defaults to 0.0.",
|
||||
)
|
||||
parser.add_argument("--require-audio", action="store_true")
|
||||
parser.add_argument("--no-realtime-pacing", action="store_true")
|
||||
parser.add_argument(
|
||||
|
||||
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user