diff --git a/.buildkite/bootstrap-amd-omni.sh b/.buildkite/amd/scripts/bootstrap-amd-omni.sh similarity index 62% rename from .buildkite/bootstrap-amd-omni.sh rename to .buildkite/amd/scripts/bootstrap-amd-omni.sh index 16ed02b26..32097cb8c 100755 --- a/.buildkite/bootstrap-amd-omni.sh +++ b/.buildkite/amd/scripts/bootstrap-amd-omni.sh @@ -6,6 +6,8 @@ set -euo pipefail +source .buildkite/common/scripts/resolve_skip_ci.sh + if [[ -z "${RUN_ALL:-}" ]]; then RUN_ALL=0 fi @@ -22,10 +24,6 @@ if [[ -z "${AMD_MIRROR_HW:-}" ]]; then AMD_MIRROR_HW="amdproduction" fi -if [[ -z "${DOCS_ONLY_DISABLE:-}" ]]; then - DOCS_ONLY_DISABLE=0 -fi - fail_fast() { DISABLE_LABEL="ci-no-fail-fast" # If BUILDKITE_PULL_REQUEST != "false", then we check the PR labels using curl and jq @@ -56,88 +54,6 @@ check_run_all_label() { fi } -is_docs_only_change() { - local file_path - local has_any=0 - - while IFS= read -r file_path; do - [[ -z "${file_path}" ]] && continue - has_any=1 - - if [[ "${file_path}" == docs/* ]]; then - continue - fi - if [[ "${file_path}" == *.md ]]; then - continue - fi - if [[ "${file_path}" == "mkdocs.yml" ]]; then - continue - fi - return 1 - done - - [[ "${has_any}" -eq 1 ]] -} - -resolve_skip_ci() { - local is_pr_build=0 - local files - local base_branch base_ref - - if [[ "${BUILDKITE_PULL_REQUEST:-false}" != "false" && -n "${BUILDKITE_PULL_REQUEST:-}" ]]; then - is_pr_build=1 - fi - - if [[ "${is_pr_build}" -eq 1 ]]; then - base_branch="${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-main}" - if ! git rev-parse --verify "origin/${base_branch}" >/dev/null 2>&1; then - echo "resolve_skip_ci: origin/${base_branch} not found locally; trying fetch" >&2 - git fetch --depth=200 origin "${base_branch}" >/dev/null 2>&1 || true - fi - - base_ref="" - if git rev-parse --verify "origin/${base_branch}" >/dev/null 2>&1; then - base_ref="origin/${base_branch}" - elif git rev-parse --verify "${base_branch}" >/dev/null 2>&1; then - base_ref="${base_branch}" - else - echo "resolve_skip_ci: cannot resolve PR base ${base_branch}; skip-ci=0" >&2 - echo -n 0 - return 0 - fi - - if ! files="$(git diff --name-only "${base_ref}...${BUILDKITE_COMMIT}" 2>/dev/null)"; then - echo "resolve_skip_ci: failed to compute PR changed files; skip-ci=0" >&2 - echo -n 0 - return 0 - fi - elif [[ "${BUILDKITE_BRANCH:-}" == "main" ]]; then - if ! git rev-parse --verify "${BUILDKITE_COMMIT}^" >/dev/null 2>&1; then - echo "resolve_skip_ci: commit has no parent on main; skip-ci=0" >&2 - echo -n 0 - return 0 - fi - if ! files="$(git diff --name-only "${BUILDKITE_COMMIT}^..${BUILDKITE_COMMIT}" 2>/dev/null)"; then - echo "resolve_skip_ci: failed to compute main changed files; skip-ci=0" >&2 - echo -n 0 - return 0 - fi - else - echo "resolve_skip_ci: not PR/main build; skip-ci=0" >&2 - echo -n 0 - return 0 - fi - - if is_docs_only_change <<< "${files}"; then - echo "resolve_skip_ci: docs-only change detected; skip-ci=1" >&2 - echo -n 1 - return 0 - fi - - echo "resolve_skip_ci: non-doc changes detected; skip-ci=0" >&2 - echo -n 0 -} - if [[ -z "${COV_ENABLED:-}" ]]; then COV_ENABLED=0 fi @@ -154,14 +70,14 @@ upload_pipeline() { fi # Use local template file for vllm-omni - cp .buildkite/test-template-amd-omni.j2 .buildkite/test-template.j2 + cp .buildkite/amd/test-template-amd-omni.j2 .buildkite/amd/test-template.j2 # (WIP) Use pipeline generator instead of jinja template - if [ -e ".buildkite/pipeline_generator/pipeline_generator.py" ]; then + if [ -e ".buildkite/amd/pipeline_generator/pipeline_generator.py" ]; then python -m pip install click pydantic - python .buildkite/pipeline_generator/pipeline_generator.py --run_all=$RUN_ALL --list_file_diff="$LIST_FILE_DIFF" --nightly="$NIGHTLY" --mirror_hw="$AMD_MIRROR_HW" - buildkite-agent pipeline upload .buildkite/pipeline.yaml + python .buildkite/amd/pipeline_generator/pipeline_generator.py --run_all=$RUN_ALL --list_file_diff="$LIST_FILE_DIFF" --nightly="$NIGHTLY" --mirror_hw="$AMD_MIRROR_HW" + buildkite-agent pipeline upload .buildkite/amd/pipeline.yaml exit 0 fi echo "List file diff: $LIST_FILE_DIFF" @@ -171,7 +87,7 @@ upload_pipeline() { FAIL_FAST=$(fail_fast) - cd .buildkite + cd .buildkite/amd # Select test definition file: merge suite for main, ready suite for PRs. # For debugging, DEBUG_TEST_YAML can override the selection — accepts @@ -182,7 +98,7 @@ upload_pipeline() { TEST_YAML="test-amd-merge.yml" ;; ready) - TEST_YAML="test-amd-ready.yaml" + TEST_YAML="test-amd-ready.yml" ;; *) echo "ERROR: DEBUG_TEST_YAML must be 'merge' or 'ready', got '$DEBUG_TEST_YAML'" >&2 @@ -193,7 +109,7 @@ upload_pipeline() { elif [[ $BUILDKITE_BRANCH == "main" ]]; then TEST_YAML="test-amd-merge.yml" else - TEST_YAML="test-amd-ready.yaml" + TEST_YAML="test-amd-ready.yml" fi ( @@ -234,23 +150,13 @@ if [[ $BUILDKITE_BRANCH == "main" ]]; then file_diff=$(get_diff_main) fi -# ---------------------------------------------------------------------- -# Early exit start: skip pipeline if conditions are met -# ---------------------------------------------------------------------- - -# Match CUDA Buildkite skip-ci behavior for docs/markdown-only changes. -if [[ "${DOCS_ONLY_DISABLE}" != "1" ]]; then - SKIP_CI="$(resolve_skip_ci)" - if [[ "${SKIP_CI}" == "1" ]]; then - echo "[docs-only] Docs/markdown-only changes detected. Exiting before pipeline upload." - exit 0 - fi +# Early exit: unified skip-ci (docs / skip marks) and CI-yaml-only level targeting. +if [[ $BUILDKITE_BRANCH == "main" ]]; then + gate_bootstrap_ci amd l3 +else + gate_bootstrap_ci amd l2 fi -# ---------------------------------------------------------------------- -# Early exit end -# ---------------------------------------------------------------------- - patterns=( "docker/Dockerfile" "CMakeLists.txt" diff --git a/.buildkite/scripts/hardware_ci/run-amd-test.sh b/.buildkite/amd/scripts/run-amd-test.sh similarity index 98% rename from .buildkite/scripts/hardware_ci/run-amd-test.sh rename to .buildkite/amd/scripts/run-amd-test.sh index f880455d4..c49614e70 100755 --- a/.buildkite/scripts/hardware_ci/run-amd-test.sh +++ b/.buildkite/amd/scripts/run-amd-test.sh @@ -56,7 +56,7 @@ container_name="rocm_${BUILDKITE_COMMIT}_$(tr -dc A-Za-z0-9 < /dev/urandom | hea # # Install AWS CLI to authenticate to ECR Public Gallery to get higher rate limit for pulling images # sudo apt-get update && sudo apt-get install -y awscli ## Use safe docker login helper to prevent race conditions -# source "$(dirname "${BASH_SOURCE[0]}")/../docker_login_ecr_public.sh" +# source "$(dirname "${BASH_SOURCE[0]}")/../../common/scripts/docker_login_ecr_public.sh" # safe_docker_login_ecr_public ## Pull the container from AMD Docker Hub diff --git a/.buildkite/test-amd-merge.yml b/.buildkite/amd/test-amd-merge.yml similarity index 99% rename from .buildkite/test-amd-merge.yml rename to .buildkite/amd/test-amd-merge.yml index 63433cc12..4e80d1968 100644 --- a/.buildkite/test-amd-merge.yml +++ b/.buildkite/amd/test-amd-merge.yml @@ -89,7 +89,7 @@ steps: mirror_hardwares: [amdproduction] grade: Blocking commands: - - pytest -s -v tests/diffusion/distributed/test_zimage_parallelism.py + - pytest -s -v tests/diffusion/distributed/test_tensor_parallel.py - label: "Diffusion Model Test" agent_pool: mi325_1 diff --git a/.buildkite/test-amd-ready.yaml b/.buildkite/amd/test-amd-ready.yml similarity index 98% rename from .buildkite/test-amd-ready.yaml rename to .buildkite/amd/test-amd-ready.yml index 8c645fff0..8f99ce909 100644 --- a/.buildkite/test-amd-ready.yaml +++ b/.buildkite/amd/test-amd-ready.yml @@ -35,7 +35,7 @@ steps: mirror_hardwares: [amdproduction] grade: Blocking commands: - - timeout 20m pytest -s -v tests/e2e/offline_inference/custom_pipeline/ -m "core_model" + - timeout 20m pytest -s -v tests/e2e/features/custom_pipeline/ -m "core_model" - label: "Voxtral TTS CUDA Unit Test" diff --git a/.buildkite/test-template-amd-omni.j2 b/.buildkite/amd/test-template-amd-omni.j2 similarity index 96% rename from .buildkite/test-template-amd-omni.j2 rename to .buildkite/amd/test-template-amd-omni.j2 index aa5ca8bcd..2f1a9aed3 100644 --- a/.buildkite/test-template-amd-omni.j2 +++ b/.buildkite/amd/test-template-amd-omni.j2 @@ -57,7 +57,7 @@ steps: {% endif %} {% set cmd_body = (step.command or (step.commands | join("\n"))) | trim %} {% set indented_cmd = cmd_body | replace("\n", "\n ") %} - command: bash .buildkite/scripts/hardware_ci/run-amd-test.sh + command: bash .buildkite/amd/scripts/run-amd-test.sh env: DOCKER_BUILDKIT: "1" {% if step.env %} @@ -104,7 +104,7 @@ steps: {% endif %} {% set cmd_body = (step.command or (step.commands | join("\n"))) | trim %} {% set indented_cmd = cmd_body | replace("\n", "\n ") %} - command: bash .buildkite/scripts/hardware_ci/run-amd-test.sh + command: bash .buildkite/amd/scripts/run-amd-test.sh env: DOCKER_BUILDKIT: "1" {% if step.env %} diff --git a/.buildkite/bootstrap-intel-omni.sh b/.buildkite/bootstrap-intel-omni.sh deleted file mode 100755 index b99568141..000000000 --- a/.buildkite/bootstrap-intel-omni.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/bash -# vllm-omni Intel bootstrap -# Uses static pipeline-intel.yaml for Intel XPU tests -# Based on: bootstrap-amd-omni.sh - -set -euo pipefail - -if [[ -z "${DOCS_ONLY_DISABLE:-}" ]]; then - DOCS_ONLY_DISABLE=0 -fi - -upload_pipeline() { - echo "--- 🛠 Preparing Intel pipeline" - buildkite-agent pipeline upload .buildkite/pipeline-intel.yaml -} - -get_diff() { - $(git add .) - echo $(git diff --name-only --diff-filter=ACMDR $(git merge-base origin/main HEAD)) -} - -get_diff_main() { - $(git add .) - echo $(git diff --name-only --diff-filter=ACMDR HEAD~1) -} - -file_diff=$(get_diff) -if [[ $BUILDKITE_BRANCH == "main" ]]; then - file_diff=$(get_diff_main) -fi - -# ---------------------------------------------------------------------- -# Early exit: skip pipeline if all changed files are under docs/ -# ---------------------------------------------------------------------- -if [[ "${DOCS_ONLY_DISABLE}" != "1" ]] && [[ -n "${file_diff:-}" ]]; then - docs_only=1 - while IFS= read -r f; do - [[ -z "$f" ]] && continue - if [[ "$f" != docs/* ]]; then - docs_only=0 - break - fi - done < <(printf '%s\n' "$file_diff" | tr ' ' '\n' | tr -d '\r') - - if [[ "$docs_only" -eq 1 ]]; then - buildkite-agent annotate ":memo: CI skipped — docs only" --style "info" - exit 0 - fi -fi - -upload_pipeline diff --git a/.buildkite/common/ci_mirror_hardwares.yml b/.buildkite/common/ci_mirror_hardwares.yml new file mode 100644 index 000000000..b7d40c75d --- /dev/null +++ b/.buildkite/common/ci_mirror_hardwares.yml @@ -0,0 +1,239 @@ +# Uploader-only hardware presets for CUDA / NPU test YAML. +# Referenced as ``mirror_hardwares: l4_1`` on a step; ``upload_pipeline.py`` expands to +# ``agents`` (+ optional top-level ``image`` for NPU) + ``plugins``, then strips +# ``mirror_hardwares`` before Buildkite upload. +# +# CUDA naming: {chip}_{count} +# l4_1, l4_4, h100_1 … h100_4 +# 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) + +x-npu-k8s-plugins: &npu_k8s_plugins + - kubernetes: + podSpecPatch: + imagePullSecrets: + - name: swr-secret + + +mirror_hardwares: + l4_1: + agents: + queue: gpu_1_queue + plugins: + - docker#v5.2.0: + image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + always-pull: true + propagate-environment: true + shm-size: "8gb" + environment: + - HF_HOME=/fsx/hf_cache + - HF_TOKEN + volumes: + - /fsx/hf_cache:/fsx/hf_cache + + l4_4: + agents: + queue: gpu_4_queue + plugins: + - docker#v5.2.0: + image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + always-pull: true + propagate-environment: true + shm-size: "8gb" + environment: + - HF_HOME=/fsx/hf_cache + - HF_TOKEN + volumes: + - /fsx/hf_cache:/fsx/hf_cache + + h100_1: + agents: + queue: mithril-h100-pool + plugins: + - kubernetes: + podSpec: + containers: + - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + resources: + limits: + nvidia.com/gpu: 1 + volumeMounts: + - name: devshm + mountPath: /dev/shm + - name: hf-cache + mountPath: /root/.cache/huggingface + env: + - name: HF_HOME + value: /root/.cache/huggingface + - name: HF_TOKEN + valueFrom: + secretKeyRef: + name: hf-token-secret + key: token + nodeSelector: + node.kubernetes.io/instance-type: gpu-h100-sxm + volumes: + - name: devshm + emptyDir: + medium: Memory + - name: hf-cache + hostPath: + path: /mnt/hf-cache + type: DirectoryOrCreate + + h100_2: + agents: + queue: mithril-h100-pool + plugins: + - kubernetes: + podSpec: + containers: + - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + resources: + limits: + nvidia.com/gpu: 2 + volumeMounts: + - name: devshm + mountPath: /dev/shm + - name: hf-cache + mountPath: /root/.cache/huggingface + env: + - name: HF_HOME + value: /root/.cache/huggingface + - name: HF_TOKEN + valueFrom: + secretKeyRef: + name: hf-token-secret + key: token + nodeSelector: + node.kubernetes.io/instance-type: gpu-h100-sxm + volumes: + - name: devshm + emptyDir: + medium: Memory + - name: hf-cache + hostPath: + path: /mnt/hf-cache + type: DirectoryOrCreate + + h100_3: + agents: + queue: mithril-h100-pool + plugins: + - kubernetes: + podSpec: + containers: + - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + resources: + limits: + nvidia.com/gpu: 3 + volumeMounts: + - name: devshm + mountPath: /dev/shm + - name: hf-cache + mountPath: /root/.cache/huggingface + env: + - name: HF_HOME + value: /root/.cache/huggingface + - name: HF_TOKEN + valueFrom: + secretKeyRef: + name: hf-token-secret + key: token + nodeSelector: + node.kubernetes.io/instance-type: gpu-h100-sxm + volumes: + - name: devshm + emptyDir: + medium: Memory + - name: hf-cache + hostPath: + path: /mnt/hf-cache + type: DirectoryOrCreate + + h100_4: + agents: + queue: mithril-h100-pool + plugins: + - kubernetes: + podSpec: + containers: + - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + resources: + limits: + nvidia.com/gpu: 4 + volumeMounts: + - name: devshm + mountPath: /dev/shm + - name: hf-cache + mountPath: /root/.cache/huggingface + env: + - name: HF_HOME + value: /root/.cache/huggingface + - name: HF_TOKEN + valueFrom: + secretKeyRef: + name: hf-token-secret + key: token + nodeSelector: + node.kubernetes.io/instance-type: gpu-h100-sxm + volumes: + - name: devshm + emptyDir: + medium: Memory + - name: hf-cache + hostPath: + path: /mnt/hf-cache + type: DirectoryOrCreate + + a2b3_npu_1: + agents: + queue: "ascend-a2b3" + resource_class: "npu-1" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}" + plugins: *npu_k8s_plugins + + a2b3_npu_4: + agents: + queue: "ascend-a2b3" + resource_class: "npu-4" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}" + plugins: *npu_k8s_plugins + + + a2b3_npu_8: + agents: + queue: "ascend-a2b3" + resource_class: "npu-8" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}" + plugins: *npu_k8s_plugins + + a3_npu_2: + agents: + queue: "ascend-a3" + resource_class: "npu-2" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a3" + plugins: *npu_k8s_plugins + + a3_npu_4: + agents: + queue: "ascend-a3" + resource_class: "npu-4" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a3" + plugins: *npu_k8s_plugins + + a3_npu_8: + agents: + queue: "ascend-a3" + resource_class: "npu-8" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a3" + plugins: *npu_k8s_plugins + + a3_npu_16: + agents: + queue: "ascend-a3" + resource_class: "npu-16" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a3" + plugins: *npu_k8s_plugins diff --git a/.buildkite/common/scripts/resolve_skip_ci.sh b/.buildkite/common/scripts/resolve_skip_ci.sh new file mode 100644 index 000000000..666573f66 --- /dev/null +++ b/.buildkite/common/scripts/resolve_skip_ci.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Shared Buildkite skip-ci helpers for AMD / Intel bootstrap scripts. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKIP_CI_PY="${SCRIPT_DIR}/skip_ci.py" + +# Resolve git diff once. Exit 0 when this bootstrap should stop uploading. +# Decision details are logged by skip_ci.py; this helper only annotates skip-all. +# Usage: gate_bootstrap_ci +gate_bootstrap_ci() { + local platform="$1" + local level="$2" + local kind + if kind="$(python3 "${SKIP_CI_PY}" gate "${platform}" "${level}")"; then + if [[ "${kind}" == "skip-all" ]]; then + buildkite-agent annotate \ + ":memo: CI skipped — docs or pytest skip-mark changes only" \ + --style "info" 2>/dev/null || true + fi + exit 0 + fi +} diff --git a/.buildkite/common/scripts/skip_ci.py b/.buildkite/common/scripts/skip_ci.py new file mode 100644 index 000000000..b452b5adf --- /dev/null +++ b/.buildkite/common/scripts/skip_ci.py @@ -0,0 +1,617 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Shared Buildkite skip-ci and CI-YAML level targeting logic. + +``resolve_ci_decision(changed_files, ...)`` buckets explicit file lists (tests, dry-run). +``resolve_ci_context_from_git()`` resolves git diff once and returns decision + changed files +for Buildkite bootstrap / upload scripts. + +CLI (for bootstrap scripts; exit code is the signal): + python3 skip_ci.py gate + Exit 0 when bootstrap should stop (skip-all, or that L2/L3 target is off). + Prints ``skip-all`` or ``skip-l23`` on stdout; logs the decision once. + python3 skip_ci.py check-skip-all + Exit 0 when only docs/skip-mark changes → bootstrap skips entire CI upload. + python3 skip_ci.py check-skip-l2-l3 + Exit 0 when that L2/L3 pipeline should be skipped (CI config YAML-only diffs). + python3 skip_ci.py print-annotate + Print decision message for Buildkite annotation. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from dataclasses import dataclass, field, replace +from pathlib import Path + +LOG = "[skip-ci]" +ROOT = Path(__file__).resolve().parent.parent.parent.parent + +PLATFORMS = ("cuda", "amd", "intel", "npu") +L23_LEVELS = frozenset({"l2", "l3"}) + +L2_YAML_FILES: dict[str, str] = { + ".buildkite/cuda/test-ready.yml": "cuda", + ".buildkite/npu/test-npu-ready.yml": "npu", + ".buildkite/amd/test-amd-ready.yml": "amd", + ".buildkite/intel/pipeline-intel.yml": "intel", +} + +L3_YAML_FILES: dict[str, str] = { + ".buildkite/cuda/test-merge.yml": "cuda", + ".buildkite/amd/test-amd-merge.yml": "amd", +} + +L45_YAML_FILES: dict[str, str] = { + ".buildkite/cuda/test-nightly.yml": "cuda", + ".buildkite/cuda/test-weekly.yml": "cuda", + ".buildkite/npu/test-npu-nightly.yml": "npu", +} + + +def _empty_platform_buckets() -> dict[str, list[str]]: + return {platform: [] for platform in PLATFORMS} + + +@dataclass(frozen=True) +class CiDevice: + """Per-platform / per-level skip flags for yaml-gated bootstrap (``skip_l2_l3``).""" + + skip_cuda_l2: bool = False + skip_cuda_l3: bool = False + skip_amd_l2: bool = False + skip_amd_l3: bool = False + skip_intel_l2: bool = False + skip_intel_l3: bool = False + skip_npu_l2: bool = False + skip_npu_l3: bool = False + + def is_skip(self, platform: str, level: str) -> bool: + return bool(getattr(self, f"skip_{platform}_{level}", True)) + + +@dataclass +class DiffBuckets: + """Changed files grouped by doc / skip-mark / CI yaml / other.""" + + docs: list[str] = field(default_factory=list) + skip_tests: list[str] = field(default_factory=list) + other: list[str] = field(default_factory=list) + l2: dict[str, list[str]] = field(default_factory=_empty_platform_buckets) + l3: dict[str, list[str]] = field(default_factory=_empty_platform_buckets) + l45: dict[str, list[str]] = field(default_factory=_empty_platform_buckets) + + @property + def has_doc_changes(self) -> bool: + return bool(self.docs) + + @property + def has_skip_test_changes(self) -> bool: + return bool(self.skip_tests) + + @property + def has_other_changes(self) -> bool: + """Product code or paths outside skip-ci scope.""" + return bool(self.other) + + @property + def has_l23_yaml_changes(self) -> bool: + """At least one whitelisted L2/L3 CI yaml file changed.""" + return any(self.l2[platform] or self.l3[platform] for platform in PLATFORMS) + + @property + def has_l45_yaml_changes(self) -> bool: + """At least one whitelisted L4/L5 (nightly/weekly) CI yaml file changed.""" + return any(self.l45[platform] for platform in PLATFORMS) + + @property + def has_skip_ci_scope_changes(self) -> bool: + """At least one doc / skip-mark / whitelisted CI yaml change.""" + return ( + self.has_doc_changes or self.has_skip_test_changes or self.has_l23_yaml_changes or self.has_l45_yaml_changes + ) + + def l2_platforms(self) -> frozenset[str]: + return frozenset(platform for platform in PLATFORMS if self.l2[platform]) + + def l3_platforms(self) -> frozenset[str]: + return frozenset(platform for platform in PLATFORMS if self.l3[platform]) + + +@dataclass(frozen=True) +class SkipL2L3Basis: + skip_all_l2: bool + skip_all_l3: bool + + @classmethod + def from_buckets(cls, buckets: DiffBuckets) -> SkipL2L3Basis | None: + """L4/L5 yaml → skip both L2 and L3 (L2/L3 buckets may rescue). + + Docs / skip-mark changes do not block this path; they are ignored for + L2/L3 targeting when CI YAML is also present. + """ + if buckets.has_other_changes: + return None + if not buckets.has_l45_yaml_changes: + return None + return cls( + skip_all_l2=not buckets.l2_platforms(), + skip_all_l3=not buckets.l3_platforms(), + ) + + +@dataclass(frozen=True) +class SkipL3Basis: + enable_l2_platforms: frozenset[str] + + @classmethod + def from_buckets(cls, buckets: DiffBuckets) -> SkipL3Basis | None: + """L2 yaml changed → skip L3; keep L2 on touched platforms.""" + platforms = buckets.l2_platforms() + if not platforms: + return None + return cls(enable_l2_platforms=platforms) + + +@dataclass(frozen=True) +class SkipL2Basis: + enable_l3_platforms: frozenset[str] + + @classmethod + def from_buckets(cls, buckets: DiffBuckets) -> SkipL2Basis | None: + """L3 yaml changed → skip L2; keep L3 on touched platforms.""" + platforms = buckets.l3_platforms() + if not platforms: + return None + return cls(enable_l3_platforms=platforms) + + +@dataclass(frozen=True) +class CiDecision: + """Final CI skip decision for one diff.""" + + skip_all: bool = False + skip_l2_l3: bool = False + device: CiDevice = field(default_factory=CiDevice) + message: str = "" + + @classmethod + def skip_all_basis(cls, buckets: DiffBuckets, *, diff_range: str | None) -> bool: + """Docs / pytest skip-mark only → suppress entire default CI.""" + if buckets.has_other_changes: + return False + if (buckets.has_l23_yaml_changes or buckets.has_l45_yaml_changes) and ( + buckets.has_doc_changes or buckets.has_skip_test_changes + ): + return False + if not buckets.has_doc_changes and not buckets.has_skip_test_changes: + return False + if diff_range is None and not buckets.has_doc_changes: + return False + return True + + @classmethod + def from_yaml_basis( + cls, + l2_l3_basis: SkipL2L3Basis | None, + l3_basis: SkipL3Basis | None, + l2_basis: SkipL2Basis | None, + ) -> CiDecision: + """Build a yaml-gated decision by merging L2/L3 skip bases. + + Bases are applied as a union of enables: L2 yaml keeps L2 on touched + platforms, L3 yaml keeps L3 on touched platforms. Cross-platform mixes + (e.g. CUDA ready + AMD merge) must preserve both enables. The resulting + per-platform / per-level matrix is stored on ``device`` as ``skip_*``. + """ + run = {platform: {"l2": True, "l3": True} for platform in PLATFORMS} + + if l2_l3_basis is not None: + if l2_l3_basis.skip_all_l2: + for platform in PLATFORMS: + run[platform]["l2"] = False + if l2_l3_basis.skip_all_l3: + for platform in PLATFORMS: + run[platform]["l3"] = False + + if l3_basis is not None: + for platform in PLATFORMS: + run[platform]["l3"] = False + run[platform]["l2"] = platform in l3_basis.enable_l2_platforms + + if l2_basis is not None: + for platform in PLATFORMS: + if platform in l2_basis.enable_l3_platforms: + run[platform]["l3"] = True + if l3_basis is None or platform not in l3_basis.enable_l2_platforms: + run[platform]["l2"] = False + elif l3_basis is None: + # L3-yaml-only: disable platforms that did not change L3. + run[platform]["l2"] = False + run[platform]["l3"] = False + # else: leave L2 enables from SkipL3Basis; keep this platform's L3 off. + + return cls( + skip_l2_l3=True, + device=cls._device_from_run(run), + ) + + @staticmethod + def _device_from_run(run: dict[str, dict[str, bool]]) -> CiDevice: + return CiDevice( + **{f"skip_{platform}_{level}": not run[platform][level] for platform in PLATFORMS for level in ("l2", "l3")} + ) + + def is_run(self, platform: str, level: str) -> bool: + if self.skip_all: + return False + if not self.skip_l2_l3: + return True + if level not in L23_LEVELS: + return True + return not self.device.is_skip(platform, level) + + +@dataclass(frozen=True) +class CiContext: + """Skip-ci decision plus git diff inputs used to compute it.""" + + decision: CiDecision + changed_files: list[str] | None + diff_range: str | None + + +def _log(message: str) -> None: + print(f"{LOG}: {message}", file=sys.stderr) + + +def _git(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + check=False, + capture_output=True, + text=True, + ) + + +# --- Diff helpers --- + + +def _resolve_diff_range() -> str | None: + """Return a git diff range for PR or main builds, or None when unavailable.""" + is_pr = os.environ.get("BUILDKITE_PULL_REQUEST", "false") != "false" and os.environ.get( + "BUILDKITE_PULL_REQUEST", "" + ) + commit = os.environ.get("BUILDKITE_COMMIT", "") + + if is_pr: + base_branch = os.environ.get("BUILDKITE_PULL_REQUEST_BASE_BRANCH", "main") + base_ref = f"origin/{base_branch}" + if _git("rev-parse", "--verify", base_ref).returncode != 0: + _log(f"origin/{base_branch} not found locally; trying fetch") + _git("fetch", "--depth=200", "origin", base_branch) + if _git("rev-parse", "--verify", base_ref).returncode != 0: + if _git("rev-parse", "--verify", base_branch).returncode == 0: + base_ref = base_branch + else: + _log(f"cannot resolve PR base {base_branch}; using safe defaults") + return None + return f"{base_ref}...{commit}" + + if os.environ.get("BUILDKITE_BRANCH", "") == "main": + if _git("rev-parse", "--verify", f"{commit}^").returncode != 0: + _log("main commit has no parent; using safe defaults") + return None + return f"{commit}^..{commit}" + + _log("not PR/main build; using safe defaults") + return None + + +def _changed_files_for_diff_range(diff_range: str | None) -> list[str] | None: + """Return changed file paths for *diff_range*, or None when unavailable.""" + if diff_range is None: + return None + + result = _git("diff", "--name-only", diff_range) + if result.returncode != 0: + _log(f"git diff failed ({diff_range}); using safe defaults") + return None + + files = [line for line in result.stdout.splitlines() if line.strip()] + _log(f"{len(files)} changed file(s)") + return files + + +def resolve_ci_context_from_git() -> CiContext: + """Resolve git diff once and return skip-ci decision plus changed files.""" + diff_range = _resolve_diff_range() + changed_files = _changed_files_for_diff_range(diff_range) + decision = resolve_ci_decision(changed_files, diff_range=diff_range) + return CiContext(decision=decision, changed_files=changed_files, diff_range=diff_range) + + +# --- Doc / skip-mark helpers --- + + +_SKIP_MARK_RE = re.compile(r"pytest\.mark\.skip(?:if)?\b|pytest\.skip\s*\(") +_PYTESTMARK_SKIP_RE = re.compile(r"pytest\.mark\.skip\b") +_PYTEST_MARK_ONLY_RE = re.compile(r"pytest\.mark\.\w+") + + +def _diff_only_contains_skip_mark_changes(diff_text: str) -> bool: + """Return True when a unified diff only *adds* pytest skip marks. + + Deleting a skip mark re-enables the test and must not suppress CI. Editing an + existing skip (``-`` old / ``+`` new) still qualifies when at least one skip + mark is added. + """ + + def paren_balance(line: str) -> int: + return line.count("(") - line.count(")") + + def is_blank_or_comment(line: str) -> bool: + stripped = line.strip() + return not stripped or stripped.startswith("#") + + def is_skip_mark_line(line: str) -> bool: + return _SKIP_MARK_RE.search(line.strip()) is not None + + def is_pytestmark_adjacent_line(line: str) -> bool: + stripped = line.strip().rstrip(",") + if not stripped or stripped in {"[", "]"} or stripped.startswith("#"): + return True + if stripped.startswith("pytestmark"): + return True + return _PYTEST_MARK_ONLY_RE.search(stripped) is not None + + pending_depth = 0 + saw_change = False + has_added_skip_mark = False + for raw_line in diff_text.splitlines(): + if raw_line.startswith("@@"): + pending_depth = 0 + continue + if not (raw_line.startswith("+") or raw_line.startswith("-")): + continue + if raw_line.startswith("+++") or raw_line.startswith("---"): + continue + + saw_change = True + is_add = raw_line.startswith("+") + content = raw_line[1:] + + if pending_depth > 0: + pending_depth += paren_balance(content) + if pending_depth < 0: + pending_depth = 0 + continue + + if is_blank_or_comment(content): + continue + + if is_skip_mark_line(content): + if is_add: + has_added_skip_mark = True + pending_depth = max(0, paren_balance(content)) + continue + + if is_pytestmark_adjacent_line(content): + continue + + return False + + return saw_change and has_added_skip_mark + + +def _is_skip_testcase_change(file_path: str, *, diff_range: str) -> bool: + """Return True when a test file diff is skip-mark-only (including new-file cases).""" + diff_result = _git("diff", diff_range, "--", file_path) + if diff_result.returncode != 0: + _log(f"skip-mark-only: git diff failed for {file_path}") + return False + + diff_text = diff_result.stdout + if not diff_text.strip(): + _log(f"skip-mark-only: empty diff for {file_path}") + return False + + if _diff_only_contains_skip_mark_changes(diff_text): + return True + + if "new file mode" not in diff_text and "--- /dev/null" not in diff_text: + _log(f"skip-mark-only: non-skip changes in {file_path}") + return False + + commit = os.environ.get("BUILDKITE_COMMIT", "") + if not commit: + return False + + content_result = _git("show", f"{commit}:{file_path}") + if content_result.returncode != 0: + _log(f"skip-mark-only: cannot read {file_path} at {commit}") + return False + + content = content_result.stdout + match = re.search(r"^pytestmark\s*=\s*\[(.*?)^\s*\]", content, re.MULTILINE | re.DOTALL) + if match is not None and _PYTESTMARK_SKIP_RE.search(match.group(1)) is not None: + _log(f"skip-mark-only: new test file with module-level pytest.mark.skip: {file_path}") + return True + if re.search(r"^pytestmark\s*=\s*pytest\.mark\.skip\b", content, re.MULTILINE): + _log(f"skip-mark-only: new test file with module-level pytest.mark.skip: {file_path}") + return True + + _log(f"skip-mark-only: new test file without module-level pytest.mark.skip: {file_path}") + return False + + +def _classify_changed_files_into_buckets(changed_files: list[str], *, diff_range: str | None) -> DiffBuckets: + """Classify each changed file path into a ``DiffBuckets`` category.""" + buckets = DiffBuckets() + for file_path in changed_files: + if not file_path: + continue + if file_path.startswith("docs/") or file_path.endswith(".md") or file_path == "mkdocs.yml": + buckets.docs.append(file_path) + continue + path = Path(file_path) + if path.suffix == ".py" and path.parts and path.parts[0] == "tests": + if diff_range is not None and _is_skip_testcase_change(file_path, diff_range=diff_range): + buckets.skip_tests.append(file_path) + else: + buckets.other.append(file_path) + continue + if file_path in L2_YAML_FILES: + platform = L2_YAML_FILES[file_path] + buckets.l2[platform].append(file_path) + elif file_path in L3_YAML_FILES: + platform = L3_YAML_FILES[file_path] + buckets.l3[platform].append(file_path) + elif file_path in L45_YAML_FILES: + platform = L45_YAML_FILES[file_path] + buckets.l45[platform].append(file_path) + else: + buckets.other.append(file_path) + return buckets + + +# --- Decision --- + + +def _finish(decision: CiDecision, message: str) -> CiDecision: + finished = replace(decision, message=message) + _log(message) + return finished + + +def _format_yaml_gated_message(buckets: DiffBuckets, decision: CiDecision) -> str: + """Human-readable summary of which CI YAML changed and which L2/L3 targets run.""" + changed_parts: list[str] = [] + for level_name, by_platform in (("L2", buckets.l2), ("L3", buckets.l3), ("L4/L5", buckets.l45)): + files = [path for platform in PLATFORMS for path in by_platform[platform]] + if files: + changed_parts.append(f"{level_name}=[{', '.join(files)}]") + + run_targets = [ + f"{platform}/{level}" for platform in PLATFORMS for level in ("l2", "l3") if decision.is_run(platform, level) + ] + skip_targets = [ + f"{platform}/{level}" + for platform in PLATFORMS + for level in ("l2", "l3") + if not decision.is_run(platform, level) + ] + + changed = "; ".join(changed_parts) if changed_parts else "none" + run = ", ".join(run_targets) if run_targets else "none" + skip = ", ".join(skip_targets) if skip_targets else "none" + if buckets.has_doc_changes or buckets.has_skip_test_changes: + return ( + "CI config YAML + docs/skip-mark changed — follow CI YAML gating; " + f"changed: {changed}; run: {run}; skip: {skip}" + ) + return f"only CI config YAML changed — changed: {changed}; run: {run}; skip: {skip}" + + +def resolve_ci_decision( + changed_files: list[str] | None = None, + *, + diff_range: str | None = None, +) -> CiDecision: + if changed_files is None: + return _finish(CiDecision(), "could not resolve changed files; run normal CI") + + buckets = _classify_changed_files_into_buckets(changed_files, diff_range=diff_range) + if buckets.has_other_changes or not buckets.has_skip_ci_scope_changes: + return _finish(CiDecision(), "product or non-whitelisted changes present; run normal CI") + + if CiDecision.skip_all_basis(buckets, diff_range=diff_range): + return _finish( + CiDecision(skip_all=True), + "CI skipped — docs or pytest skip-mark changes only", + ) + + # Docs / skip-mark mixed with whitelisted CI YAML → same yaml-gated path + # as CI-YAML-only (docs/skip-mark do not widen to normal CI). + if not buckets.has_l23_yaml_changes and not buckets.has_l45_yaml_changes: + if buckets.has_skip_test_changes and diff_range is None: + message = ( + "only pytest skip-mark test changes detected, but no git diff range " + "is available to verify them (skip-all requires diff_range); run normal CI" + ) + elif buckets.has_skip_test_changes: + message = "pytest skip-mark test changes did not qualify for skip-all and no ci yaml changed; run normal CI" + else: + message = "doc/skip-mark changes did not qualify for skip-all; run normal CI" + return _finish(CiDecision(), message) + + decision = CiDecision.from_yaml_basis( + SkipL2L3Basis.from_buckets(buckets), + SkipL3Basis.from_buckets(buckets), + SkipL2Basis.from_buckets(buckets), + ) + return _finish(decision, _format_yaml_gated_message(buckets, decision)) + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + gate_parser = subparsers.add_parser( + "gate", + help="exit 0 when bootstrap should stop (skip-all or PLATFORM/LEVEL disabled); logs once", + ) + gate_parser.add_argument("platform", help="cuda, amd, intel, or npu") + gate_parser.add_argument("level", choices=("l2", "l3"), help="test level") + + subparsers.add_parser( + "check-skip-all", + help="exit 0 when docs/skip-mark-only diff should skip entire CI upload", + ) + + skip_parser = subparsers.add_parser( + "check-skip-l2-l3", + help="exit 0 when PLATFORM l2/l3 pipeline should be skipped (CI config YAML-only diffs)", + ) + skip_parser.add_argument("platform", help="cuda, amd, intel, or npu") + skip_parser.add_argument("level", choices=("l2", "l3"), help="test level") + + subparsers.add_parser( + "print-annotate", + help="print human-readable skip reason for Buildkite annotation", + ) + + args = parser.parse_args() + decision = resolve_ci_context_from_git().decision + + if args.command == "gate": + if decision.skip_all: + print("skip-all") + return 0 + if not decision.is_run(args.platform, args.level): + print("skip-l23") + return 0 + return 1 + + if args.command == "check-skip-all": + return 0 if decision.skip_all else 1 + + if args.command == "check-skip-l2-l3": + return 0 if not decision.is_run(args.platform, args.level) else 1 + + if args.command == "print-annotate": + print(decision.message or "CI will run normally") + return 0 + + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.buildkite/common/scripts/upload_pipeline.py b/.buildkite/common/scripts/upload_pipeline.py new file mode 100644 index 000000000..b27f78c3a --- /dev/null +++ b/.buildkite/common/scripts/upload_pipeline.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Render and optionally upload Buildkite pipeline YAML with diff-aware logic. + +Bootstrap mode (``bootstrap-upload-steps.yml``): + - Hook uploads the entry YAML (``pipeline.yml`` / ``pipeline-npu.yml``) with one step that runs + ``upload_pipeline.py --upload /bootstrap-upload-steps.yml``. + - Injects ``if`` by step ``key`` from skip-ci and uploads child steps (image build, L2–L5 upload). + - Detect docs-only, pytest skip-mark-only, or combined skip-ci from git diff. + - When only CI level YAML changes, enable **L2/L3** upload steps for affected levels only. + +Test pipeline mode (e.g. test-merge.yml): + - Drop steps whose ``source_file_dependencies`` do not match changed files. + - Expand uploader-only ``mirror_hardwares: l4_1`` into ``agents`` (+ optional ``image`` + for NPU) + ``plugins`` (see ci_mirror_hardwares.yml). + +Usage: + python3 upload_pipeline.py [--upload] [--all | --e2e] + +Requires PyYAML (``pip install pyyaml``); installs it automatically when missing. +""" + +from __future__ import annotations + +import argparse +import copy +import subprocess +import sys +from functools import lru_cache +from pathlib import Path +from typing import Any + +try: + import yaml +except ModuleNotFoundError: + subprocess.run( + [sys.executable, "-m", "pip", "install", "-q", "pyyaml"], + check=True, + ) + import yaml + +from skip_ci import ( + ROOT, + resolve_ci_context_from_git, +) + +# --- Constants --- + +LOG = "upload_pipeline" +BOOTSTRAP_STEPS_FILENAME = "bootstrap-upload-steps.yml" +BOOTSTRAP_IMAGE_BUILD_KEYS = frozenset({"image-build", "image-build-a2", "image-build-a3"}) +BOOTSTRAP_UPLOAD_IF_KEYS = { + "upload-ready-pipeline": "ready", + "upload-merge-pipeline": "merge", + "upload-nightly-pipeline": "nightly", + "upload-weekly-pipeline": "weekly", +} +E2E_GROUP_MARKER = "E2E Test" +CI_MIRROR_HARDWARES_PATH = ROOT / ".buildkite/common/ci_mirror_hardwares.yml" + +CUDA_NIGHTLY_ONLY = ( + '(build.pull_request.labels includes "nightly-test") || (build.branch == "main" && build.env("NIGHTLY") == "1")' +) +NPU_NIGHTLY_ONLY = ( + '(build.branch == "main" && build.env("NIGHTLY") == "1") || ' + '(build.branch != "main" && (' + 'build.pull_request.labels includes "nightly-test" || ' + 'build.pull_request.labels includes "omni-test" || ' + 'build.pull_request.labels includes "tts-test" || ' + 'build.pull_request.labels includes "diffusion-x2iat-test" || ' + 'build.pull_request.labels includes "diffusion-x2v-test"' + "))" +) + + +# --- Logging --- + + +def _log(message: str) -> None: + print(f"{LOG}: {message}", file=sys.stderr) + + +# --- Bootstrap pipeline (bootstrap-upload-steps.yml) --- + + +def _get_bootstrap_platform(path: Path) -> str: + parts = path.as_posix().split("/") + return "npu" if "npu" in parts else "cuda" + + +def _load_bootstrap_steps(path: Path) -> str: + if path.name != BOOTSTRAP_STEPS_FILENAME: + raise ValueError(f"expected {BOOTSTRAP_STEPS_FILENAME}, got {path.name}") + return path.read_text(encoding="utf-8") + + +def _format_bootstrap_if(expr: str) -> str: + """Return a Buildkite ``if`` string. Buildkite rejects YAML bool ``if`` values.""" + if expr in ("true", "false"): + return expr + return f"({expr})" + + +def _compute_bootstrap_if_exprs(*, decision, platform: str) -> dict[str, str]: + disabled = "false" + nightly_main = 'build.branch == "main" && build.env("NIGHTLY") == "1"' + nightly_only = NPU_NIGHTLY_ONLY if platform == "npu" else CUDA_NIGHTLY_ONLY + + if platform == "npu": + ready_pr = ( + 'build.branch != "main" && (' + 'build.pull_request.labels includes "npu-test" || ' + 'build.pull_request.labels includes "ready"' + ")" + ) + nightly_label_if = nightly_only + weekly_label_if = disabled + merge_base = disabled + else: + ready_pr = 'build.branch != "main" && build.pull_request.labels includes "ready"' + merge_main = 'build.branch == "main" && build.env("NIGHTLY") != "1" && build.env("WEEKLY") != "1"' + merge_pr = 'build.branch != "main" && build.pull_request.labels includes "merge-test"' + nightly_label_if = ( + '(build.branch == "main" && build.env("NIGHTLY") == "1") || ' + '(build.branch != "main" && (' + 'build.pull_request.labels includes "nightly-test" || ' + 'build.pull_request.labels includes "omni-test" || ' + 'build.pull_request.labels includes "tts-test" || ' + 'build.pull_request.labels includes "diffusion-x2iat-test" || ' + 'build.pull_request.labels includes "diffusion-x2v-test"' + "))" + ) + weekly_label_if = ( + '(build.branch == "main" && build.env("WEEKLY") == "1") || ' + '(build.branch != "main" && build.pull_request.labels includes "weekly-test")' + ) + merge_base = f"({nightly_main}) || (({merge_main}) || ({merge_pr}))" + + ready_base = f"({nightly_main}) || ({ready_pr})" + weekly_main = 'build.branch == "main" && build.env("WEEKLY") == "1"' + + if decision.skip_all: + # Docs / skip-mark only: no PR-label escape hatch. Main scheduled + # NIGHTLY=1 still runs L4 + L2/L3 (--e2e); WEEKLY=1 still runs L5. + image_expr = f"({nightly_main}) || ({weekly_main})" if platform == "cuda" else nightly_main + ready_expr = nightly_main + merge_expr = nightly_main if platform == "cuda" else disabled + nightly_expr = nightly_main + weekly_expr = weekly_main if platform == "cuda" else disabled + elif decision.skip_l2_l3: + l2_enabled = decision.is_run("npu", "l2") if platform == "npu" else decision.is_run("cuda", "l2") + l3_enabled = platform == "cuda" and decision.is_run("cuda", "l3") + + ready_expr = ready_base if l2_enabled else disabled + merge_expr = merge_base if l3_enabled else disabled + nightly_expr = nightly_label_if + weekly_expr = weekly_label_if if platform == "cuda" else disabled + + image_parts = [f"({nightly_label_if})"] + if platform == "cuda": + image_parts.append(f"({weekly_label_if})") + if l2_enabled: + image_parts.insert(0, f"({ready_base})") + if l3_enabled: + image_parts.insert(1 if l2_enabled else 0, f"({merge_base})") + image_expr = " || ".join(image_parts) + else: + image_expr = "true" + ready_expr = ready_base + merge_expr = merge_base if platform == "cuda" else disabled + nightly_expr = nightly_label_if + weekly_expr = weekly_label_if if platform == "cuda" else disabled + + return { + "image": _format_bootstrap_if(image_expr), + "ready": _format_bootstrap_if(ready_expr), + "merge": _format_bootstrap_if(merge_expr), + "nightly": _format_bootstrap_if(nightly_expr), + "weekly": _format_bootstrap_if(weekly_expr), + } + + +def _apply_bootstrap_if(steps: list[Any], if_exprs: dict[str, str]) -> list[Any]: + """Inject ``if`` by step key; drop steps that are unconditionally disabled.""" + kept: list[Any] = [] + for step in steps: + if not isinstance(step, dict): + kept.append(step) + continue + nested = step.get("steps") + if isinstance(nested, list): + nested_kept = _apply_bootstrap_if(nested, if_exprs) + if nested_kept: + kept.append({**step, "steps": nested_kept}) + continue + key = step.get("key") + if key in BOOTSTRAP_IMAGE_BUILD_KEYS: + step["if"] = if_exprs["image"] + elif key in BOOTSTRAP_UPLOAD_IF_KEYS: + step["if"] = if_exprs[BOOTSTRAP_UPLOAD_IF_KEYS[key]] + if_expr = step.get("if") + if if_expr == "false": + _log(f"omit disabled bootstrap step {key!r}") + continue + if if_expr == "true": + # Unconditional step: omit ``if`` (Buildkite requires string ``if``, not YAML bool). + step.pop("if", None) + kept.append(step) + return kept + + +def _render_bootstrap_pipeline( + steps_yaml: str, + *, + decision, + path: Path, +) -> str: + """Load bootstrap steps YAML and inject ``if`` expressions from skip-ci decision.""" + doc = yaml.safe_load(steps_yaml) + if not isinstance(doc, dict): + raise ValueError(f"invalid bootstrap steps YAML for {path}") + steps = doc.get("steps") + if not isinstance(steps, list): + raise ValueError(f"bootstrap steps YAML must contain steps: list in {path}") + + platform = _get_bootstrap_platform(path) + if_exprs = _compute_bootstrap_if_exprs(decision=decision, platform=platform) + doc["steps"] = _apply_bootstrap_if(steps, if_exprs) + return yaml.safe_dump(doc, sort_keys=False) + + +# --- Test pipeline (test-ready.yml, test-merge.yml) --- + + +@lru_cache(maxsize=1) +def _load_mirror_hardwares() -> dict[str, dict[str, Any]]: + if not CI_MIRROR_HARDWARES_PATH.is_file(): + raise FileNotFoundError(f"missing CI mirror_hardwares registry: {CI_MIRROR_HARDWARES_PATH}") + doc = yaml.safe_load(CI_MIRROR_HARDWARES_PATH.read_text(encoding="utf-8")) + if not isinstance(doc, dict): + raise ValueError(f"invalid CI mirror_hardwares registry: {CI_MIRROR_HARDWARES_PATH}") + presets = doc.get("mirror_hardwares") + if not isinstance(presets, dict): + raise ValueError(f"mirror_hardwares must be a mapping in {CI_MIRROR_HARDWARES_PATH}") + return presets + + +def _expand_mirror_hardwares(step: dict[str, Any]) -> dict[str, Any]: + """Replace uploader-only ``mirror_hardwares`` with preset fields from ci_mirror_hardwares.yml.""" + hardware = step.get("mirror_hardwares") + if hardware is None: + return step + + if not isinstance(hardware, str) or not hardware.strip(): + raise ValueError( + f"mirror_hardwares must be a non-empty string in step {_get_step_label(step)!r}", + ) + + preset = _load_mirror_hardwares().get(hardware) + if preset is None: + known = ", ".join(sorted(_load_mirror_hardwares())) + raise ValueError( + f"unknown mirror_hardwares {hardware!r} in step {_get_step_label(step)!r}; known: {known}", + ) + + if step.get("agents") is not None or step.get("plugins") is not None or step.get("image") is not None: + raise ValueError( + f"step {_get_step_label(step)!r} sets mirror_hardwares together with agents/plugins/image; " + "use mirror_hardwares only", + ) + + expanded = copy.deepcopy(preset) + return {key: value for key, value in step.items() if key != "mirror_hardwares"} | expanded + + +def _match_source_file(changed_files: list[str], prefixes: list[str]) -> bool: + for path in changed_files: + for prefix in prefixes: + normalized = prefix.rstrip("/") + if path == normalized or path.startswith(f"{normalized}/"): + return True + return False + + +def _get_step_label(step: dict[str, Any]) -> str: + return str(step.get("group") or step.get("label") or "") + + +def _process_test_steps( + steps: list[Any], + changed_files: list[str] | None, +) -> list[Any]: + """Drop steps by ``source_file_dependencies`` when *changed_files* is set; always strip that field.""" + processed: list[Any] = [] + for step in steps: + if not isinstance(step, dict): + processed.append(step) + continue + + deps = step.get("source_file_dependencies") + if deps is not None and not isinstance(deps, list): + raise ValueError( + f"source_file_dependencies must be a list in step {_get_step_label(step)!r}", + ) + if changed_files is not None and deps is not None and not _match_source_file(changed_files, deps): + _log(f"skip {_get_step_label(step)!r} (no changes under {deps})") + continue + + nested = step.get("steps") + if nested is not None: + kept_nested = _process_test_steps(nested, changed_files) + if changed_files is not None and not kept_nested: + _log(f"omit empty group {_get_step_label(step)!r}") + continue + new_step = {key: value for key, value in step.items() if key != "source_file_dependencies"} + new_step["steps"] = kept_nested + processed.append(new_step) + continue + + if deps is not None: + processed.append( + _expand_mirror_hardwares( + {key: value for key, value in step.items() if key != "source_file_dependencies"}, + ), + ) + else: + processed.append(_expand_mirror_hardwares(step)) + + return processed + + +def _select_e2e_group_steps(steps: list[Any]) -> list[Any]: + """Keep only top-level groups whose name contains ``E2E_GROUP_MARKER``.""" + selected = [ + step + for step in steps + if isinstance(step, dict) and isinstance(step.get("group"), str) and E2E_GROUP_MARKER in step["group"] + ] + if not selected: + _log(f"no group matching {E2E_GROUP_MARKER!r} found") + else: + _log(f"keep {len(selected)} group(s) matching {E2E_GROUP_MARKER!r}") + return selected + + +def _render_test_pipeline( + doc: dict[str, Any], + changed_files: list[str] | None, + *, + e2e_only: bool = False, +) -> dict[str, Any]: + """Filter steps by PR diff and strip uploader-only ``source_file_dependencies`` metadata.""" + steps = doc.get("steps") + if not isinstance(steps, list): + return doc + if e2e_only: + steps = _select_e2e_group_steps(steps) + steps = _process_test_steps(steps, changed_files) + return {**doc, "steps": steps} + + +# --- Entry (read file → bootstrap or test render → YAML string) --- + + +def _render_pipeline( + path: Path, + *, + force_all: bool = False, + e2e_only: bool = False, +) -> str: + if path.name == BOOTSTRAP_STEPS_FILENAME: + ctx = resolve_ci_context_from_git() + continuation = _load_bootstrap_steps(path) + return _render_bootstrap_pipeline( + continuation, + decision=ctx.decision, + path=path, + ) + + text = path.read_text(encoding="utf-8") + ctx = resolve_ci_context_from_git() + if force_all or e2e_only: + changed_files = None + else: + changed_files = ctx.changed_files + + doc = yaml.safe_load(text) + if not isinstance(doc, dict): + raise ValueError(f"invalid pipeline YAML: {path}") + + doc = _render_test_pipeline(doc, changed_files, e2e_only=e2e_only) + return yaml.safe_dump(doc, sort_keys=False) + + +def _upload_to_buildkite(content: str) -> None: + subprocess.run( + ["buildkite-agent", "pipeline", "upload"], + input=content, + text=True, + check=True, + ) + + +# --- CLI --- + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "pipeline", + nargs="?", + default=".buildkite/cuda/bootstrap-upload-steps.yml", + help="Pipeline YAML path (default: .buildkite/cuda/bootstrap-upload-steps.yml)", + ) + parser.add_argument( + "--upload", + action="store_true", + help="Pipe rendered YAML to buildkite-agent pipeline upload", + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--all", + action="store_true", + help="Keep all steps (disable diff-aware skipping)", + ) + mode.add_argument( + "--e2e", + action="store_true", + help="Keep only the E2E Test group", + ) + args = parser.parse_args() + + path = Path(args.pipeline) + if not path.is_absolute(): + path = ROOT / path + if not path.is_file(): + _log(f"missing pipeline file: {path}") + return 1 + + rendered = _render_pipeline(path, force_all=args.all, e2e_only=args.e2e) + if args.upload: + _upload_to_buildkite(rendered) + else: + sys.stdout.write(rendered) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.buildkite/cuda/bootstrap-upload-steps.yml b/.buildkite/cuda/bootstrap-upload-steps.yml new file mode 100644 index 000000000..f2467bf42 --- /dev/null +++ b/.buildkite/cuda/bootstrap-upload-steps.yml @@ -0,0 +1,55 @@ +# Uploaded by upload_pipeline.py --upload (not the Buildkite hook). +# ``if`` is injected by step ``key`` from skip-ci at upload time. +steps: + - label: ":docker: Build image" + key: image-build + commands: + - "bash .buildkite/cuda/scripts/build-ci-image.sh" + agents: + queue: "cpu_queue_premerge" + + # L2 Test — PR ready label (diff-aware) or main+NIGHTLY=1 (--e2e, full E2E group) + - label: "Upload Ready Pipeline" + depends_on: image-build + key: upload-ready-pipeline + commands: + - | + EXTRA="" + if [ "${NIGHTLY:-}" = "1" ] && [ "${BUILDKITE_BRANCH}" = "main" ]; then + EXTRA="--e2e" + fi + python3 .buildkite/common/scripts/upload_pipeline.py --upload $$EXTRA .buildkite/cuda/test-ready.yml + agents: + queue: "cpu_queue_premerge" + + # L3 Test — PR merge-test label (diff-aware) or main+NIGHTLY=1 (--e2e, full E2E group) + - label: "Upload Merge Pipeline" + depends_on: image-build + key: upload-merge-pipeline + commands: + - | + EXTRA="" + if [ "${NIGHTLY:-}" = "1" ] && [ "${BUILDKITE_BRANCH}" = "main" ]; then + EXTRA="--e2e" + fi + python3 .buildkite/common/scripts/upload_pipeline.py --upload $$EXTRA .buildkite/cuda/test-merge.yml + agents: + queue: "cpu_queue_premerge" + + # L4 Test — main+NIGHTLY=1 (scheduled), or PR with specific label (e.g. add label then Rebuild) + - label: "Upload Nightly Pipeline" + depends_on: image-build + key: upload-nightly-pipeline + commands: + - python3 .buildkite/common/scripts/upload_pipeline.py --upload .buildkite/cuda/test-nightly.yml + agents: + queue: "cpu_queue_premerge" + + # L5 Test — main+WEEKLY=1 (scheduled), or PR with weekly-test label + - label: "Upload Weekly Pipeline" + depends_on: image-build + key: upload-weekly-pipeline + commands: + - python3 .buildkite/common/scripts/upload_pipeline.py --upload .buildkite/cuda/test-weekly.yml + agents: + queue: "cpu_queue_premerge" diff --git a/.buildkite/cuda/pipeline.yml b/.buildkite/cuda/pipeline.yml new file mode 100644 index 000000000..aeec9d1aa --- /dev/null +++ b/.buildkite/cuda/pipeline.yml @@ -0,0 +1,9 @@ +# Hook uploads this entry YAML; upload_pipeline.py --upload reads bootstrap-upload-steps.yml +# and injects ``if`` by step ``key`` from skip-ci. +steps: + - label: ":github: Resolve skip-ci & upload pipeline" + key: upload-ci-pipeline + commands: + - "python3 .buildkite/common/scripts/upload_pipeline.py --upload .buildkite/cuda/bootstrap-upload-steps.yml" + agents: + queue: "cpu_queue_premerge" diff --git a/.buildkite/cuda/rebase-pipeline.yml b/.buildkite/cuda/rebase-pipeline.yml new file mode 100644 index 000000000..391079c4a --- /dev/null +++ b/.buildkite/cuda/rebase-pipeline.yml @@ -0,0 +1,35 @@ +steps: + - label: ":docker: Build image" + key: image-build + commands: + - "bash .buildkite/cuda/scripts/build-ci-image.sh" + agents: + queue: "cpu_queue_premerge" + + # L2 Test + - label: "Upload Ready Pipeline" + depends_on: image-build + key: upload-ready-pipeline + commands: + - python3 .buildkite/common/scripts/upload_pipeline.py --upload --all .buildkite/cuda/test-ready.yml + agents: + queue: "cpu_queue_premerge" + + # L3 Test + - label: "Upload Merge Pipeline" + depends_on: image-build + key: upload-merge-pipeline + commands: + - python3 .buildkite/common/scripts/upload_pipeline.py --upload --all .buildkite/cuda/test-merge.yml + agents: + queue: "cpu_queue_premerge" + + # L4 Test + - label: "Upload Nightly Pipeline" + depends_on: image-build + key: upload-nightly-pipeline + if: build.env("NIGHTLY") == "1" + commands: + - python3 .buildkite/common/scripts/upload_pipeline.py --upload --all .buildkite/cuda/test-nightly.yml + agents: + queue: "cpu_queue_premerge" diff --git a/.buildkite/cuda/scripts/build-ci-image.sh b/.buildkite/cuda/scripts/build-ci-image.sh new file mode 100755 index 000000000..9da69bc8b --- /dev/null +++ b/.buildkite/cuda/scripts/build-ci-image.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -euo pipefail + +# Build and push the CI Docker image with registry-based layer caching. +# +# Usage: build-ci-image.sh +# +# Expects the following environment variables (set by Buildkite): +# BUILDKITE_COMMIT - commit SHA used as the image tag +ECR_NAMESPACE="public.ecr.aws/q9t5s3a7" +REGISTRY="${ECR_NAMESPACE}/vllm-ci-test-repo" +REGION="us-east-1" +DOCKERFILE="docker/Dockerfile.ci" +BUILDER_NAME="vllm-omni-builder" + +# Ensure that the env vars are actually set, otherwise exit early +if [ -z "${BUILDKITE_COMMIT:-}" ]; then + echo "ERROR: BUILDKITE_COMMIT is not set" + exit 1 +fi +echo "BUILDKITE_COMMIT: ${BUILDKITE_COMMIT}" + +# Authenticate to ECR Public +aws ecr-public get-login-password --region "$REGION" \ + | docker login --username AWS --password-stdin "$ECR_NAMESPACE" + +# Compute the cache tag for the dependencies image as the hash of the contents. +# This is because dependencies don't change often, so most PRs will have the same hash. +# +# The Dockerfile is also included to avoid churn in the cached image based on the vLLM version. +DEP_FILES="pyproject.toml setup.py requirements/*.txt docker/Dockerfile.ci" +CACHE_KEY=$(cat ${DEP_FILES} | sha256sum | cut -c1-16) +CACHE_TAG="deps-cache-${CACHE_KEY}" +echo "Cache key: ${CACHE_TAG}" + +# Set up buildx with docker-container driver; we need to do this +# since cache export is not supported for the default docker driver +# that is running in the CI. +docker buildx inspect $BUILDER_NAME >/dev/null 2>&1 \ +|| docker buildx create --name $BUILDER_NAME --driver docker-container +docker buildx use $BUILDER_NAME + +echo "Building image tag: ${REGISTRY}:${BUILDKITE_COMMIT}" + +# Build + push the image to the registry, using the hash from the dependencies. +# Note that the caching here is layerwise, so we're just making sure we always +# can cache hit on everything but the last copy at the moment. +docker buildx build --push --progress=plain \ + --cache-from "type=registry,ref=${REGISTRY}:${CACHE_TAG}" \ + --cache-to "type=registry,ref=${REGISTRY}:${CACHE_TAG},mode=max,compression=zstd" \ + --file "$DOCKERFILE" \ + -t "${REGISTRY}:${BUILDKITE_COMMIT}" . diff --git a/.buildkite/cuda/test-merge.yml b/.buildkite/cuda/test-merge.yml new file mode 100644 index 000000000..e971acb75 --- /dev/null +++ b/.buildkite/cuda/test-merge.yml @@ -0,0 +1,259 @@ +env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + VLLM_USE_FLASHINFER_MOE_FP16: 0 + HF_HUB_DOWNLOAD_TIMEOUT: 300 + HF_HUB_ETAG_TIMEOUT: 60 + +steps: + - group: ":card_index_dividers: Simple Test" + depends_on: upload-merge-pipeline + steps: + - label: "Simple · Model Executor Test" + commands: + - "timeout 20m pytest -sv tests/model_executor -m 'core_model and cpu' --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" + mirror_hardwares: l4_1 + + - label: "Simple · Diffusion Test" + commands: + - "timeout 20m pytest -sv tests/diffusion -m 'core_model and cpu' --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" + mirror_hardwares: l4_1 + + - label: "Simple · Engine&Entrypoints Test" + commands: + - "timeout 20m pytest -sv tests/entrypoints tests/engine -m 'core_model and cpu' --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" + mirror_hardwares: l4_1 + + - label: "Simple · Other Test" + commands: + - "timeout 20m pytest -sv tests/ -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor --ignore=tests/entrypoints --ignore=tests/engine --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" + mirror_hardwares: l4_1 + + - group: ":card_index_dividers: Diffusion Test" + depends_on: upload-merge-pipeline + steps: + - label: "Diffusion · LoRA Test" + timeout_in_minutes: 30 + commands: + - pytest -sv tests/diffusion/lora/ -m 'advanced_model and cuda' --run-level "advanced_model" + mirror_hardwares: l4_1 + + - label: "Distributed Test" + depends_on: upload-merge-pipeline + timeout_in_minutes: 60 + commands: + - | + timeout 55m bash -c " + set -e + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/distributed -m 'advanced_model and cuda and H100' --run-level 'advanced_model' + " + mirror_hardwares: h100_1 + + - group: ":card_index_dividers: Entrypoints Test" + depends_on: upload-merge-pipeline + steps: + - label: "Entrypoint Test with H100" + # TEMPORARY 60 -> 90: engine startup/sleep-wake is ~13-15 min slower per + # run on the vLLM 0.26 image (model loading ~2x, init ~2x; see builds + # 2915 vs 2916), which pushed this suite past 60 min. Revert once the + # 0.26 startup regression is understood and fixed. + timeout_in_minutes: 90 + commands: + - pytest -s -v tests/entrypoints/ -m "advanced_model and cuda and H100" --run-level "advanced_model" + mirror_hardwares: h100_2 + + - label: "Entrypoint Test with L4" + timeout_in_minutes: 60 + commands: + - pytest -s -v tests/entrypoints/ -m "advanced_model and cuda and L4" --run-level "advanced_model" + mirror_hardwares: l4_1 + + - group: ":card_index_dividers: E2E Test" + depends_on: upload-merge-pipeline + steps: + - label: "TTS · Qwen3-TTS CustomVoice Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen3_tts_customvoice.py + - tests/e2e/offline_inference/test_qwen3_tts_customvoice.py + - vllm_omni/model_executor/models/qwen3_tts/ + - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py + - vllm_omni/deploy/qwen3_tts.yaml + commands: + - | + timeout 40m bash -c " + export VLLM_LOGGING_LEVEL=DEBUG + export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 + pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py tests/e2e/offline_inference/test_qwen3_tts_customvoice.py -m 'advanced_model and cuda' --run-level 'advanced_model' + " + mirror_hardwares: l4_1 + + - label: "TTS · Qwen3-TTS Base Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen3_tts_base.py + - tests/e2e/offline_inference/test_qwen3_tts_base.py + - vllm_omni/model_executor/models/qwen3_tts/ + - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py + - vllm_omni/deploy/qwen3_tts.yaml + commands: + - | + timeout 40m bash -c " + export VLLM_LOGGING_LEVEL=DEBUG + export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 + pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m 'advanced_model and cuda' --run-level 'advanced_model' + " + mirror_hardwares: l4_1 + + - label: "TTS · Higgs-Audio-V3 Test" + source_file_dependencies: + - tests/e2e/online_serving/test_higgs_audio_v3.py + - vllm_omni/model_executor/models/higgs_audio_v3/ + - vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py + - vllm_omni/deploy/higgs_multimodal_qwen3.yaml + commands: + - | + timeout 20m bash -c " + export VLLM_WORKER_MULTIPROC_METHOD=spawn + export VLLM_USE_DEEP_GEMM=0 + export VLLM_MOE_USE_DEEP_GEMM=0 + pytest -s -v tests/e2e/online_serving/test_higgs_audio_v3.py -m 'advanced_model' --run-level 'advanced_model' + " + mirror_hardwares: l4_1 + + - label: "Omni · Qwen3-Omni Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_qwen3_omni.py + - tests/e2e/online_serving/test_qwen3_omni.py + - vllm_omni/model_executor/models/qwen3_omni/ + - vllm_omni/model_executor/stage_input_processors/qwen3_omni.py + - vllm_omni/deploy/qwen3_omni_moe.yaml + timeout_in_minutes: 50 + commands: + - pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py -m 'advanced_model and cuda' --run-level 'advanced_model' + mirror_hardwares: h100_2 + + - label: "Omni · MiniCPM-o 4.5 Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_minicpmo_4_5.py + - tests/e2e/online_serving/test_minicpmo_4_5.py + - vllm_omni/deploy/minicpmo_4_5.yaml + timeout_in_minutes: 50 + 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' + mirror_hardwares: h100_2 + + - label: "Omni · MiniCPM-o 4.5 Duplex Test" + source_file_dependencies: + - tests/assets/minicpmo_4_5/response_required_16k.wav + - tests/assets/minicpmo_4_5/soft_interrupt_16k.wav + - 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/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 + - vllm_omni/deploy/minicpmo_4_5_duplex.yaml + - vllm_omni/experimental/fullduplex/ + - vllm_omni/model_executor/models/minicpmo_4_5/ + - vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py + - vllm_omni/core/sched/ + - vllm_omni/outputs/multimodal_accumulation.py + - vllm_omni/engine/stage_init_utils.py + - vllm_omni/engine/async_omni_engine.py + - vllm_omni/entrypoints/cli/serve.py + 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 + + - label: "TTS · VoxCPM2 Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_voxcpm2_tts.py + - tests/e2e/online_serving/test_voxcpm2_tts.py + - vllm_omni/model_executor/models/voxcpm2/ + - vllm_omni/deploy/voxcpm2.yaml + timeout_in_minutes: 30 + commands: + - | + timeout 30m bash -c " + export VLLM_LOGGING_LEVEL=DEBUG + export VLLM_WORKER_MULTIPROC_METHOD=spawn + pytest -s -v tests/e2e/offline_inference/test_voxcpm2_tts.py tests/e2e/online_serving/test_voxcpm2_tts.py -m 'advanced_model and cuda' --run-level 'advanced_model' + " + mirror_hardwares: l4_1 + + - label: "Diffusion · Qwen Image Edit Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen_image_edit.py + - vllm_omni/diffusion/models/qwen_image/ + timeout_in_minutes: 20 + commands: + - pytest -s -v tests/e2e/online_serving/test_qwen_image_edit.py -m 'advanced_model and cuda' --run-level 'advanced_model' + mirror_hardwares: h100_1 + - label: "Diffusion · Bagel Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_bagel.py + - tests/e2e/online_serving/test_bagel.py + - vllm_omni/model_executor/models/bagel/ + - vllm_omni/diffusion/models/bagel/ + - vllm_omni/model_executor/stage_input_processors/bagel.py + - vllm_omni/deploy/bagel.yaml + timeout_in_minutes: 20 + commands: + - pytest -s -v tests/e2e/offline_inference/test_bagel.py tests/e2e/online_serving/test_bagel.py -m 'advanced_model and cuda' --run-level 'advanced_model' + mirror_hardwares: h100_1 + + - label: "Diffusion · Qwen Image Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen_image.py + - vllm_omni/diffusion/models/qwen_image/ + commands: + - | + timeout 40m bash -c ' + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/e2e/online_serving/test_qwen_image.py -m 'advanced_model and cuda' --run-level 'advanced_model' + ' + mirror_hardwares: h100_1 + + - label: "Diffusion · Qwen Image Layered Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen_image_layered.py + - vllm_omni/diffusion/models/qwen_image/ + commands: + - | + timeout 40m bash -c " + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/e2e/online_serving/test_qwen_image_layered.py -m 'advanced_model and cuda' --run-level 'advanced_model' + " + mirror_hardwares: h100_1 + + - label: "Diffusion · Wan22 Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_wan22_t2v.py + - tests/e2e/online_serving/test_wan22_t2v.py + - vllm_omni/diffusion/models/wan2_2/ + commands: + - | + timeout 40m bash -c " + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/e2e/offline_inference/test_wan22_t2v.py tests/e2e/online_serving/test_wan22_t2v.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' + " + mirror_hardwares: h100_1 + + - label: "Diffusion · Z Image Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_z_image.py + - tests/e2e/online_serving/test_z_image.py + - vllm_omni/diffusion/models/z_image/ + timeout_in_minutes: 30 + commands: + - pytest -s -v tests/e2e/offline_inference/test_z_image.py tests/e2e/online_serving/test_z_image.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' + mirror_hardwares: l4_1 + - label: "Diffusion · FLUX.2 Klein Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_flux2_klein.py + - vllm_omni/diffusion/models/flux2_klein/ + timeout_in_minutes: 30 + commands: + - pytest -s -v tests/e2e/offline_inference/test_flux2_klein.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' + mirror_hardwares: l4_1 diff --git a/.buildkite/cuda/test-nightly.yml b/.buildkite/cuda/test-nightly.yml new file mode 100644 index 000000000..9be72d3d5 --- /dev/null +++ b/.buildkite/cuda/test-nightly.yml @@ -0,0 +1,572 @@ +env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + VLLM_USE_FLASHINFER_MOE_FP16: 0 + HF_HUB_DOWNLOAD_TIMEOUT: 300 + HF_HUB_ETAG_TIMEOUT: 60 + +steps: + # Group: collapses under one heading in the Buildkite UI; child steps still run in parallel. + - group: ":card_index_dividers: Omni Model Test" + key: nightly-omni-test-group + depends_on: upload-nightly-pipeline + if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "omni-test" + steps: + + - label: ":full_moon: Omni · Function Test with H100" + timeout_in_minutes: 120 + commands: + - pytest -sv tests/e2e --run-level "full_model" -m "full_model and H100 and omni" --ignore=tests/e2e/accuracy --ignore=tests/e2e/online_serving/test_qwen3_omni_multi_replicas.py --ignore=tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py + mirror_hardwares: h100_2 + + - label: ":full_moon: Omni · MiniCPM-o 4.5 Duplex Test" + 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 + + - label: ":full_moon: Omni · Doc Test with L4" + timeout_in_minutes: 90 + commands: + - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" + - pytest -s -v tests/examples/ -m "full_model and omni and L4" --run-level "full_model" + mirror_hardwares: l4_4 + + - label: ":full_moon: Omni · Doc Test with H100" + timeout_in_minutes: 90 + commands: + - pytest -s -v tests/examples/ -m "full_model and omni and H100" --run-level "full_model" + mirror_hardwares: h100_2 + + - label: ":full_moon: Omni · 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/qwen3_omni/test_qwen3_omni.py -m "full_model" --run-level full_model + EXIT=$$? + buildkite-agent artifact upload "tests/e2e/accuracy/qwen3_omni/results/qwen_omni_acc/*.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 + 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_qwen3_omni_no_async_chunk.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/*.json" + exit $$EXIT + mirror_hardwares: h100_2 + + - label: ":full_moon: Omni · Perf Test · Async Chunk" + key: nightly-omni-performance-async-chunk + 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_qwen3_omni_async_chunk.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/*.json" + exit $$EXIT + mirror_hardwares: h100_2 + + - label: ":full_moon: Omni · Perf Test · vLLM Text" + key: nightly-omni-performance-vllm-text + 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_qwen3_omni_vllm_text.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/*.json" + exit $$EXIT + mirror_hardwares: h100_2 + + - label: ":full_moon: Omni · Perf Test · Multi-Replica" + key: nightly-omni-performance-multi-replicas + 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_qwen3_omni_multi_replicas.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/*.json" + exit $$EXIT + mirror_hardwares: h100_3 + + - label: ":full_moon: Omni · Multi-Replica Startup Test with 4x H100" + timeout_in_minutes: 45 + commands: + - pytest -s -v tests/e2e/online_serving/test_qwen3_omni_multi_replicas.py -m "full_model" --run-level "core_model" + mirror_hardwares: h100_4 + + - group: ":card_index_dividers: TTS Model Test" + key: nightly-tts-test-group + depends_on: upload-nightly-pipeline + if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "tts-test" + steps: + + - label: ":full_moon: TTS · Function Test with L4" + timeout_in_minutes: 120 + commands: + - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" + - export VLLM_USE_DEEP_GEMM="0" + - export VLLM_MOE_USE_DEEP_GEMM="0" + - pytest -s -v tests/e2e/ -m "full_model and L4 and tts" --run-level "full_model" --ignore=tests/e2e/accuracy --ignore=tests/e2e/online_serving/test_voxcpm2_tts_expansion.py + mirror_hardwares: l4_1 + + - label: ":full_moon: TTS · Accuracy Test · VoxCPM2" + timeout_in_minutes: 30 + commands: + - | + timeout 30m bash -c " + export VLLM_LOGGING_LEVEL=DEBUG + export VLLM_WORKER_MULTIPROC_METHOD=spawn + export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 + pytest -s -v tests/e2e/online_serving/test_voxcpm2_tts_expansion.py -m 'full_model and cuda and tts' --run-level 'full_model' + " + mirror_hardwares: l4_1 + + - label: ":full_moon: TTS · Perf Test" + key: nightly-tts-performance + timeout_in_minutes: 180 + commands: + - export BENCHMARK_DIR=tests/dfx/perf/results + - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_tts.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/*.json" + exit $$EXIT + mirror_hardwares: h100_2 + + - label: ":full_moon: TTS · VoxCPM2 · Perf Test" + key: nightly-tts-performance-voxcpm2 + timeout_in_minutes: 180 + commands: + - export BENCHMARK_DIR=tests/dfx/perf/results + - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_voxcpm2.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/*.json" + exit $$EXIT + mirror_hardwares: h100_1 + + - label: ":full_moon: TTS · Higgs-Audio-V3 · Perf Test" + key: nightly-tts-performance-higgs-audio-v3 + timeout_in_minutes: 180 + commands: + - export BENCHMARK_DIR=tests/dfx/perf/results + - export VLLM_USE_DEEP_GEMM="0" + - export VLLM_MOE_USE_DEEP_GEMM="0" + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_higgs_audio_v3.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/*.json" + exit $$EXIT + mirror_hardwares: h100_1 + + - group: ":card_index_dividers: Tiny Model Tests [Multi-GPU]" + key: nightly-tiny-model-test-group + depends_on: upload-nightly-pipeline + if: >- + build.env("NIGHTLY") == "1" || + build.pull_request.labels includes "nightly-test" + steps: + + - label: ":full_moon: Tiny Model Tests · Diffusion (Multi-GPU accelerations)" + timeout_in_minutes: 30 + # NOTE: Single-GPU (core_model) tiny model tests run on the ready label, + # so nightly only adds the multi-GPU parallelism configurations. + # + # This is a bit confusing, but `full_model` is used to mark the multigpu tests, and + # --run-level `core_model` is used to indicate that we should use tiny model weights + # for the CI, since these tests should pass irrespective of whether or not we use + # the full weights or not. + commands: + - timeout 25m pytest -sv tests/model_tests/diffusion -m 'full_model and cuda' --run-level "core_model" + mirror_hardwares: l4_4 + + - group: ":card_index_dividers: Diffusion X2I(&A&T) Model Test" + key: nightly-diffusion-x2iat-group + depends_on: upload-nightly-pipeline + if: >- + build.env("NIGHTLY") == "1" || + build.pull_request.labels includes "nightly-test" || + build.pull_request.labels includes "diffusion-x2iat-test" + steps: + # Use explicit file shards so single-card cases do not reserve 2+ GPUs, + # and Bagel feature coverage is not duplicated via a broad tests/e2e sweep. + + - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with H100 · Single-GPU" + timeout_in_minutes: 120 + commands: + - >- + pytest -sv + tests/e2e/online_serving/test_qwen_image_expansion.py + tests/e2e/online_serving/test_qwen_image_edit_expansion.py + tests/e2e/online_serving/test_qwen_image_layered_expansion.py + tests/e2e/online_serving/test_boogu_image.py + tests/e2e/online_serving/test_boogu_image_edit.py + tests/e2e/online_serving/test_boogu_image_expansion.py + tests/e2e/online_serving/test_boogu_image_edit_expansion.py + tests/e2e/online_serving/test_bagel_expansion.py + tests/e2e/online_serving/test_krea2_expansion.py + tests/e2e/offline_inference/test_krea2_expansion.py + -m "full_model and diffusion and H100 and not distributed_cuda" + --run-level "full_model" + mirror_hardwares: h100_1 + + - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with H100 · Multi-GPU Qwen-Image" + timeout_in_minutes: 120 + commands: + - >- + pytest -sv + tests/e2e/online_serving/test_qwen_image_expansion.py + tests/e2e/online_serving/test_qwen_image_edit_expansion.py + tests/e2e/offline_inference/test_krea2_expansion.py + -m "full_model and diffusion and H100 and distributed_cuda" + --run-level "full_model" + mirror_hardwares: h100_2 + + - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with H100 · Multi-GPU Layered/LongCat/FLUX" + timeout_in_minutes: 90 + commands: + - >- + pytest -sv + tests/e2e/online_serving/test_qwen_image_layered_expansion.py + -m "full_model and diffusion and H100 and distributed_cuda" + --run-level "full_model" + mirror_hardwares: h100_2 + + - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with H100 · Multi-GPU Bagel" + timeout_in_minutes: 120 + commands: + - >- + pytest -sv + tests/e2e/online_serving/test_bagel_expansion.py + -m "full_model and diffusion and H100 and distributed_cuda" + --run-level "full_model" + mirror_hardwares: h100_2 + + - label: ":full_moon: Diffusion X2I(&A&T) · GLM-Image Function Test with H100" + timeout_in_minutes: 120 + commands: + - >- + pytest -sv + tests/e2e/online_serving/test_glm_image_expansion.py + tests/e2e/offline_inference/test_glm_image_autoround_w4a16_expansion.py + -m "full_model and diffusion and H100" + --run-level "full_model" + mirror_hardwares: h100_2 + + - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with L4" + timeout_in_minutes: 120 + commands: + - pytest -sv tests/e2e/ -k "not test_wan and not test_bagel_expansion and not hunyuan" -m "full_model and diffusion and L4" --run-level "full_model" --ignore=tests/e2e/accuracy + mirror_hardwares: l4_4 + + - label: ":full_moon: Diffusion X2I(&A&T) · Doc Test" + timeout_in_minutes: 60 + commands: + - pytest -s -v tests/examples/*/test_text_to_image.py -m "full_model and example and H100" --run-level "full_model" + mirror_hardwares: h100_2 + + - label: ":full_moon: Diffusion X2I(&A&T) · Accuracy Test" + timeout_in_minutes: 180 + commands: + - export VLLM_HTTP_TIMEOUT_KEEP_ALIVE=120 + - pytest -s -v tests/e2e/accuracy/test_qwen_image*.py --run-level full_model + - pytest -s -v tests/e2e/accuracy/test_diffusers_backend_similarity.py -k '2i_matches_diffusers' -m full_model --run-level full_model + mirror_hardwares: h100_1 + + - label: ":full_moon: HunyuanImage3-DIT · Accuracy Test" + timeout_in_minutes: 180 + commands: + - export HUNYUAN_IMAGE3_MODEL="tencent/HunyuanImage-3.0-Instruct" + - export HUNYUAN_IMAGE3_DEVICES="0,1,2,3" + - pytest -s -v tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py -m full_model --run-level full_model + mirror_hardwares: h100_4 + + - label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image" + key: nightly-diffusion-x2iat-performance-qwen-image + timeout_in_minutes: 180 + commands: + - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results + - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN + - export CACHE_DIT_VERSION=1.3.0 + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" + buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" + exit $$EXIT + mirror_hardwares: h100_4 + + - label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image-Edit" + key: nightly-diffusion-x2iat-performance-qwen-image-edit + timeout_in_minutes: 180 + commands: + - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results + - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN + - export CACHE_DIT_VERSION=1.3.0 + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_edit_vllm_omni.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" + buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" + exit $$EXIT + mirror_hardwares: h100_4 + + - label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image-Edit-2511" + key: nightly-diffusion-x2iat-performance-qwen-image-edit-2511 + timeout_in_minutes: 180 + commands: + - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results + - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN + - export CACHE_DIT_VERSION=1.3.0 + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_edit_2511_vllm_omni.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" + buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" + exit $$EXIT + mirror_hardwares: h100_4 + + - label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image-Layered" + key: nightly-diffusion-x2iat-performance-qwen-image-layered + timeout_in_minutes: 120 + commands: + - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results + - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN + - export CACHE_DIT_VERSION=1.3.0 + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" + buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" + exit $$EXIT + mirror_hardwares: h100_1 + + - label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · BAGEL" + key: nightly-diffusion-x2iat-performance-bagel + timeout_in_minutes: 180 + commands: + - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results + - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN + - export CACHE_DIT_VERSION=1.3.0 + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_bagel_vllm_omni.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" + buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" + exit $$EXIT + mirror_hardwares: h100_1 + + - group: ":card_index_dividers: Diffusion X2V Model Test" + key: nightly-diffusion-x2v-group + depends_on: upload-nightly-pipeline + if: >- + build.env("NIGHTLY") == "1" || + build.pull_request.labels includes "nightly-test" || + build.pull_request.labels includes "diffusion-x2v-test" + steps: + + - label: ":full_moon: Diffusion X2V · Wan2.2 T2V Function Test" + timeout_in_minutes: 90 + commands: + - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k "t2v" -m "full_model and cuda" --run-level "full_model" + mirror_hardwares: h100_2 + + - label: ":full_moon: Diffusion X2V · Wan2.2 I2V/TI2V Function Test" + timeout_in_minutes: 90 + commands: + - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k "i2v" -m "full_model and cuda" --run-level "full_model" + mirror_hardwares: h100_2 + + - label: ":full_moon: Diffusion X2V · LingBot Video MoE Function Test" + timeout_in_minutes: 90 + commands: + - pytest -s -v tests/e2e/online_serving/test_lingbot_video_moe.py -m "full_model and cuda" --run-level "full_model" + mirror_hardwares: h100_1 + + - label: ":full_moon: Diffusion X2V · Doc Test" + timeout_in_minutes: 60 + commands: + - pytest -s -v tests/examples/offline_inference/test_image_to_video.py -m "full_model and example and H100" --run-level "full_model" + mirror_hardwares: h100_1 + + - label: ":full_moon: Diffusion X2V · Other Function Test" + timeout_in_minutes: 90 + commands: + - pytest -s -v tests/e2e/offline_inference/test_wan22_autoround_w4a16_expansion.py -m "full_model and cuda" --run-level "full_model" + - pytest -s -v tests/e2e/online_serving/test_wan_2_1_vace_expansion.py tests/e2e/online_serving/test_ltx2_expansion.py -m "full_model and cuda" --run-level "full_model" + mirror_hardwares: h100_2 + + - label: ":full_moon: Diffusion X2V · Wan2.2 Accuracy Test" + timeout_in_minutes: 180 + commands: + - pytest -s -v tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py -m full_model --run-level full_model + - 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" + 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 + mirror_hardwares: h100_4 + + - label: ":full_moon: Diffusion X2V · Other Accuracy Test" + timeout_in_minutes: 180 + commands: + - pytest -s -v tests/e2e/accuracy/hunyuanvideo15_t2v/test_hunyuanvideo15_t2v_video_similarity.py -m full_model --run-level full_model + - pytest -s -v tests/e2e/accuracy/hunyuanvideo15_i2v/test_hunyuanvideo15_i2v_video_similarity.py -m full_model --run-level full_model + - pytest -s -v 'tests/e2e/accuracy/ltx/test_ltx_official_similarity.py::test_ltx_one_stage_matches_official[ltx2_3_i2v]' -m full_model --run-level full_model + - pytest -s -v tests/e2e/accuracy/test_video_streaming_output_similarity.py -m full_model --run-level full_model + mirror_hardwares: h100_2 + + - label: ":full_moon: Diffusion X2V · Perf Test" + key: nightly-diffusion-x2v-performance + timeout_in_minutes: 180 + commands: + - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results + - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json + EXIT1=$$? + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_cosmos3_vllm_omni.json + EXIT2=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" + buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" + if [ $$EXIT1 -ne 0 ]; then exit $$EXIT1; fi + exit $$EXIT2 + mirror_hardwares: h100_2 + + - label: ":full_moon: Diffusion X2V · Perf Test · MiniMax-H3" + key: nightly-diffusion-x2v-performance-minimax-h3 + timeout_in_minutes: 180 + commands: + - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results + - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_minimax_h3_vllm_omni.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" + buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" + exit $$EXIT + mirror_hardwares: h100_4 + + - group: ":card_index_dividers: Diffusion Test" + key: nightly-diffusion-test-group + depends_on: upload-nightly-pipeline + if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" + steps: + - label: ":full_moon: Diffusion · Distributed Test with L4" + timeout_in_minutes: 60 + commands: + - pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and L4' --run-level "full_model" + mirror_hardwares: l4_4 + + - label: ":full_moon: Diffusion · Distributed Test with H100" + timeout_in_minutes: 60 + commands: + - pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and H100' --run-level "full_model" + mirror_hardwares: h100_4 + + - label: ":full_moon: Diffusion · Offloader Test" + timeout_in_minutes: 60 + commands: + - pytest -sv tests/diffusion/offloader -m "full_model and cuda" + mirror_hardwares: l4_1 + + - label: ":full_moon: Diffusion · Quantization Test with H100 · Single-GPU" + timeout_in_minutes: 60 + commands: + - pytest -sv tests/diffusion/quantization -m 'full_model and cuda and H100' --run-level "full_model" + mirror_hardwares: h100_1 + + - label: ":full_moon: Diffusion · MiniMax H3 FP8 Quality Test with H100 · 2-GPU" + timeout_in_minutes: 60 + commands: + - pytest -sv tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py -m 'full_model and cuda and H100' --run-level "full_model" + mirror_hardwares: h100_2 + + - label: ":full_moon: Diffusion · Quantization Test with L4" + timeout_in_minutes: 60 + commands: + - pip install "vllm-gguf-plugin>=0.0.3" + - pytest -sv tests/diffusion/quantization -m 'full_model and cuda and L4' --run-level "full_model" + mirror_hardwares: l4_1 + + - label: ":bar_chart: Testcase Statistics" + key: nightly-testcase-statistics + timeout_in_minutes: 120 + depends_on: upload-nightly-pipeline + if: build.env("NIGHTLY") == "1" + commands: + - python tools/nightly/buildkite_testcase_statistics.py -o tests/dfx/perf/results/buildkite_testcase_statistics.html + - buildkite-agent artifact upload "tests/dfx/perf/results/*.html" + mirror_hardwares: h100_1 + + # NOTE: ":email: Nightly Collection & Email" is deprecated and will be removed soon. + # Do not add new depends_on entries or extend this step. + - label: ":email: Nightly Collection & Email" + key: nightly-perf-distribution + depends_on: + - nightly-omni-performance-no-async-chunk + - nightly-omni-performance-async-chunk + - nightly-omni-performance-vllm-text + - nightly-omni-performance-multi-replicas + - nightly-tts-performance + - nightly-tts-performance-higgs-audio-v3 + - nightly-diffusion-x2iat-performance-qwen-image + - nightly-diffusion-x2iat-performance-qwen-image-edit + - nightly-diffusion-x2iat-performance-qwen-image-edit-2511 + - nightly-diffusion-x2iat-performance-qwen-image-layered + - nightly-diffusion-x2iat-performance-bagel + - nightly-diffusion-x2v-performance + - nightly-testcase-statistics + if: build.env("NIGHTLY") == "1" && build.env("EMAIL_DISTRIBUTION") == "1" + commands: + - pip install openpyxl + - export DEFAULT_INPUT_DIR=tests/dfx/perf/results + - export DEFAULT_OUTPUT_DIR=tests/dfx/perf/results + - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-tts-performance + - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-tts-performance-higgs-audio-v3 + - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance-no-async-chunk + - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance-async-chunk + - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance-vllm-text + - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance-multi-replicas + - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image + - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image-edit + - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image-edit-2511 + - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image-layered + - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-bagel + - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2v-performance + - buildkite-agent artifact download "tests/dfx/perf/results/*.html" . --step nightly-testcase-statistics + - python tools/nightly/generate_nightly_perf_excel.py + - python tools/nightly/generate_nightly_perf_html.py + - python tools/nightly/send_nightly_email.py --report-file "tests/dfx/perf/results/*.xlsx, tests/dfx/perf/results/*.html" + - buildkite-agent artifact upload "tests/dfx/perf/results/*.xlsx" + - buildkite-agent artifact upload "tests/dfx/perf/results/*.html" + agents: + queue: "cpu_queue_premerge" diff --git a/.buildkite/cuda/test-ready.yml b/.buildkite/cuda/test-ready.yml new file mode 100644 index 000000000..68149036d --- /dev/null +++ b/.buildkite/cuda/test-ready.yml @@ -0,0 +1,346 @@ +env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + VLLM_USE_FLASHINFER_MOE_FP16: 0 + HF_HUB_DOWNLOAD_TIMEOUT: 300 + HF_HUB_ETAG_TIMEOUT: 60 + +steps: + - group: ":card_index_dividers: Simple Test" + depends_on: upload-ready-pipeline + steps: + - label: "Simple · Model Executor Test" + commands: + - "timeout 20m pytest -sv tests/model_executor -m 'core_model and cpu' --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" + mirror_hardwares: l4_1 + + - label: "Simple · Diffusion Test" + commands: + - "timeout 20m pytest -sv tests/diffusion -m 'core_model and cpu' --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" + mirror_hardwares: l4_1 + + - label: "Simple · Engine&Entrypoints Test" + commands: + - "timeout 20m pytest -sv tests/entrypoints tests/engine -m 'core_model and cpu' --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" + mirror_hardwares: l4_1 + + - label: "Simple · Other Test" + commands: + - "timeout 20m pytest -sv tests/ -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor --ignore=tests/entrypoints --ignore=tests/engine --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" + mirror_hardwares: l4_1 + + - group: ":card_index_dividers: Diffusion Test" + depends_on: upload-ready-pipeline + steps: + + - label: "Diffusion · Offloader Test" + commands: + - timeout 40m pytest -s -v tests/diffusion/offloader -m "core_model and cuda" --run-level "core_model" + mirror_hardwares: l4_1 + + - label: "Diffusion · Cache Test" + commands: + - timeout 15m pytest -sv tests/diffusion/cache -m 'core_model and cuda' --run-level "core_model" + mirror_hardwares: l4_1 + + - label: "Diffusion · Batch Test" + commands: + - timeout 15m pytest -sv tests/diffusion/batching -m 'core_model and cuda' --run-level "core_model" + mirror_hardwares: l4_1 + + - label: "Diffusion · Distributed Test" + source_file_dependencies: + - tests/diffusion/distributed/ + - vllm_omni/diffusion/distributed/ + - vllm_omni/diffusion/attention/parallel/ + - vllm_omni/diffusion/data.py + - vllm_omni/diffusion/models/ + commands: + - timeout 20m pytest -sv tests/diffusion/distributed -m 'core_model and cuda' --run-level "core_model" + mirror_hardwares: l4_4 + + - label: "Diffusion · Model Test" + commands: + - timeout 15m pytest -sv tests/diffusion/models/ -m 'core_model and cuda' --run-level "core_model" + mirror_hardwares: l4_1 + + - label: "Diffusion · Other Test" + commands: + - timeout 15m pytest -sv tests/diffusion/*.py -m 'core_model and cuda' --run-level "core_model" + mirror_hardwares: l4_1 + + - group: ":card_index_dividers: Tiny Model Tests [Single GPU]" + depends_on: upload-ready-pipeline + steps: + + - label: "Tiny Model Tests · Diffusion (base)" + commands: + - timeout 15m pytest -sv tests/model_tests/diffusion -m 'core_model and cuda' --run-level "core_model" + mirror_hardwares: l4_1 + + - label: "Engine&Model Executor Test" + depends_on: upload-ready-pipeline + commands: + - | + timeout 15m bash -c " + pytest -sv tests/engine/ tests/model_executor/ -m 'core_model and cuda' --run-level 'core_model' + " + mirror_hardwares: l4_1 + + - label: "Distributed Test" + depends_on: upload-ready-pipeline + commands: + - | + timeout 20m bash -c " + pytest -sv tests/distributed/ -m 'core_model and cuda and L4' --run-level 'core_model' + " + mirror_hardwares: l4_1 + + - label: "Entrypoints Test" + depends_on: upload-ready-pipeline + commands: + - | + timeout 30m bash -c " + export VLLM_WORKER_MULTIPROC_METHOD=spawn + pytest -sv tests/entrypoints/ -m 'core_model and cuda' --run-level 'core_model' + " + mirror_hardwares: l4_1 + + - group: ":card_index_dividers: E2E Test" + depends_on: upload-ready-pipeline + steps: + - label: "Custom Pipeline Test" + source_file_dependencies: + - tests/e2e/features/custom_pipeline/ + - tests/e2e/features/helpers/custom_pipeline.py + - vllm_omni/diffusion/ + - vllm_omni/engine/ + - vllm_omni/entrypoints/ + - vllm_omni/inputs/ + - vllm_omni/outputs/ + commands: + - timeout 20m pytest -s -v tests/e2e/features/custom_pipeline/ -m "core_model" + agents: + queue: "gpu_1_queue" + plugins: + - docker#v5.2.0: + image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + always-pull: true + propagate-environment: true + environment: + - "HF_HOME=/fsx/hf_cache" + volumes: + - "/fsx/hf_cache:/fsx/hf_cache" + + - label: "RLHF · VeRL-Omni E2E Test" + source_file_dependencies: + - tests/e2e/features/rlhf_test/test_verl_omni_e2e.py + - tests/e2e/features/helpers/verl_omni_server.py + - tests/e2e/features/helpers/custom_pipeline.py + - vllm_omni/diffusion/ + - vllm_omni/engine/ + - vllm_omni/entrypoints/ + - vllm_omni/inputs/ + - vllm_omni/outputs/ + commands: + - | + timeout 20m bash -c " + pytest -s -v tests/e2e/features/rlhf_test/test_verl_omni_e2e.py + " + mirror_hardwares: l4_1 + + - label: "Omni · Qwen3-Omni Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen3_omni.py + - vllm_omni/model_executor/models/qwen3_omni/ + - vllm_omni/model_executor/stage_input_processors/qwen3_omni.py + - vllm_omni/deploy/qwen3_omni_moe.yaml + commands: + - | + timeout 20m bash -c " + pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m 'core_model' --run-level 'core_model' + " + mirror_hardwares: h100_2 + + - label: "Omni · MiniCPM-o 4.5 Offline Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_minicpmo_4_5.py + - vllm_omni/deploy/minicpmo_4_5.yaml + commands: + - | + 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 + + - label: "Omni · MiniCPM-o 4.5 Online Test" + source_file_dependencies: + - tests/e2e/online_serving/test_minicpmo_4_5.py + - vllm_omni/deploy/minicpmo_4_5.yaml + commands: + - | + 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 + + - label: "Omni · MiniCPM-o 4.5 Duplex Test" + source_file_dependencies: + - tests/assets/minicpmo_4_5/response_required_16k.wav + - tests/assets/minicpmo_4_5/soft_interrupt_16k.wav + - 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/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 + - vllm_omni/deploy/minicpmo_4_5_duplex.yaml + - vllm_omni/experimental/fullduplex/ + - vllm_omni/model_executor/models/minicpmo_4_5/ + - vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py + - vllm_omni/core/sched/ + - vllm_omni/outputs/multimodal_accumulation.py + - vllm_omni/engine/stage_init_utils.py + - vllm_omni/engine/async_omni_engine.py + - vllm_omni/entrypoints/cli/serve.py + commands: + - | + 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 + + - label: "TTS · VoxCPM2 Test" + source_file_dependencies: + - tests/e2e/online_serving/test_voxcpm2_tts.py + - vllm_omni/model_executor/models/voxcpm2/ + - vllm_omni/deploy/voxcpm2.yaml + timeout_in_minutes: 30 + commands: + - | + timeout 30m bash -c " + export VLLM_LOGGING_LEVEL=DEBUG + export VLLM_WORKER_MULTIPROC_METHOD=spawn + pytest -s -v tests/e2e/online_serving/test_voxcpm2_tts.py -m 'core_model' --run-level 'core_model' + " + mirror_hardwares: l4_1 + + - label: "TTS · Qwen3-TTS CustomVoice Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen3_tts_customvoice.py + - vllm_omni/model_executor/models/qwen3_tts/ + - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py + - vllm_omni/deploy/qwen3_tts.yaml + commands: + - | + timeout 20m bash -c " + export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 + pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m 'core_model' --run-level 'core_model' + " + mirror_hardwares: l4_1 + + - label: "TTS · Qwen3-TTS Base Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen3_tts_base.py + - vllm_omni/model_executor/models/qwen3_tts/ + - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py + - vllm_omni/deploy/qwen3_tts.yaml + commands: + - | + timeout 20m bash -c " + export VLLM_WORKER_MULTIPROC_METHOD=spawn + export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 + export VLLM_OMNI_USE_V2_RUNNER=1 + pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m 'core_model' --run-level 'core_model' + " + mirror_hardwares: l4_1 + + - label: "TTS · Higgs-Audio-V3 Test" + source_file_dependencies: + - tests/e2e/online_serving/test_higgs_audio_v3.py + - vllm_omni/model_executor/models/higgs_audio_v3/ + - vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py + - vllm_omni/deploy/higgs_multimodal_qwen3.yaml + commands: + - | + timeout 20m bash -c " + export VLLM_WORKER_MULTIPROC_METHOD=spawn + export VLLM_USE_DEEP_GEMM=0 + export VLLM_MOE_USE_DEEP_GEMM=0 + pytest -s -v tests/e2e/online_serving/test_higgs_audio_v3.py -m 'core_model' --run-level 'core_model' + " + mirror_hardwares: l4_1 + + - label: "Diffusion · Qwen Image Edit Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen_image_edit.py + - vllm_omni/diffusion/models/qwen_image/ + commands: + - | + timeout 20m bash -c " + pytest -s -v tests/e2e/online_serving/test_qwen_image_edit.py -m 'core_model' --run-level 'core_model' + " + mirror_hardwares: h100_1 + + - label: "Diffusion · Bagel Test" + source_file_dependencies: + - tests/e2e/online_serving/test_bagel.py + - vllm_omni/model_executor/models/bagel/ + - vllm_omni/diffusion/models/bagel/ + - vllm_omni/model_executor/stage_input_processors/bagel.py + - vllm_omni/deploy/bagel.yaml + commands: + - | + timeout 40m bash -c " + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/e2e/online_serving/test_bagel.py -m 'core_model' --run-level 'core_model' + " + mirror_hardwares: h100_1 + + - label: "Diffusion · Wan22 Test" + source_file_dependencies: + - tests/e2e/online_serving/test_wan22_t2v.py + - vllm_omni/diffusion/models/wan2_2/ + commands: + - | + timeout 40m bash -c " + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/e2e/online_serving/test_wan22_t2v.py -m 'core_model' --run-level 'core_model' + " + mirror_hardwares: h100_1 + + - label: "Diffusion · Cosmos3 Test" + source_file_dependencies: + - tests/e2e/online_serving/test_cosmos3.py + - vllm_omni/diffusion/models/cosmos3/ + commands: + - | + timeout 40m bash -c " + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/e2e/online_serving/test_cosmos3.py -m 'core_model' --run-level 'core_model' + " + mirror_hardwares: h100_1 + + - label: "Diffusion · Qwen Image Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen_image.py + - vllm_omni/diffusion/models/qwen_image/ + commands: + - | + timeout 40m bash -c " + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/e2e/online_serving/test_qwen_image.py -m 'core_model' --run-level 'core_model' + " + mirror_hardwares: h100_1 + + - label: "Diffusion · Qwen Image Layered Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen_image_layered.py + - vllm_omni/diffusion/models/qwen_image/ + commands: + - | + timeout 40m bash -c " + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/e2e/online_serving/test_qwen_image_layered.py -m 'core_model' --run-level 'core_model' + " + mirror_hardwares: h100_1 diff --git a/.buildkite/cuda/test-weekly.yml b/.buildkite/cuda/test-weekly.yml new file mode 100644 index 000000000..cc7f99647 --- /dev/null +++ b/.buildkite/cuda/test-weekly.yml @@ -0,0 +1,136 @@ +env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + VLLM_USE_FLASHINFER_MOE_FP16: 0 + HF_HUB_DOWNLOAD_TIMEOUT: 300 + HF_HUB_ETAG_TIMEOUT: 60 + +steps: + + - label: "Reliability Test - qwen3-omni" + timeout_in_minutes: 180 + depends_on: upload-weekly-pipeline + if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test" + commands: + - pytest -s -v tests/dfx/reliability/test_reliability_qwen3_omni.py -m "slow" + mirror_hardwares: h100_2 + + - label: "Reliability Test - wan22" + timeout_in_minutes: 180 + depends_on: upload-weekly-pipeline + if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test" + commands: + - pytest -s -v tests/dfx/reliability/test_reliability_wan22.py -m "slow" + mirror_hardwares: h100_2 + + - label: "Reliability Test - hunyuan-image" + timeout_in_minutes: 180 + depends_on: upload-weekly-pipeline + if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test" + commands: + - pytest -s -v tests/dfx/reliability/test_reliability_hunyuan_image.py -m "slow" + mirror_hardwares: h100_4 + + - label: "Reliability Test - voxcpm2" + timeout_in_minutes: 180 + depends_on: upload-weekly-pipeline + if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test" + commands: + - pytest -s -v tests/dfx/reliability/test_reliability_voxcpm2.py -m "slow" + mirror_hardwares: l4_1 + + - group: "Reliability Test - Invalid parameters Test" + depends_on: upload-weekly-pipeline + if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test" + steps: + + - label: "Invalid parameters Test · H100" + timeout_in_minutes: 120 + commands: + - pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and H100" + mirror_hardwares: h100_2 + + - label: "Invalid parameters Test · L4" + timeout_in_minutes: 120 + commands: + - pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and L4" + mirror_hardwares: l4_1 + + - group: ":card_index_dividers: E2E Tests" + depends_on: upload-weekly-pipeline + if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test" + steps: + + - label: "Omni · L4" + timeout_in_minutes: 90 + commands: + - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" + - pytest -s -v tests/e2e/*/test_qwen2_5_omni_expansion.py --run-level "core_model" + - >- + pytest -s -v tests/e2e/ + --ignore=tests/e2e/online_serving/test_qwen2_5_omni_expansion.py + --ignore=tests/e2e/offline_inference/test_qwen2_5_omni_expansion.py + -m "slow and L4 and omni" + --run-level "full_model" + mirror_hardwares: l4_4 + + - label: "Omni · H100" + timeout_in_minutes: 90 + commands: + - >- + pytest -sv + tests/e2e/ -m "slow and H100 and omni" + --run-level "full_model" + mirror_hardwares: h100_2 + + - label: "TTS · L4" + timeout_in_minutes: 90 + commands: + - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" + - export VLLM_USE_DEEP_GEMM="0" + - export VLLM_MOE_USE_DEEP_GEMM="0" + - | + failed=0 + for f in $(pytest --collect-only -q tests/e2e/ -m "slow and L4 and tts" --run-level "full_model" | grep '\.py::' | sed -E 's|::.*||' | sort -u); do + pytest -s -v "$$f" -m "slow and L4 and tts" --run-level "full_model" || failed=1 + done + exit $$failed + mirror_hardwares: l4_1 + + - label: "TTS · H100" + timeout_in_minutes: 90 + commands: + - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" + - export VLLM_USE_DEEP_GEMM="0" + - export VLLM_MOE_USE_DEEP_GEMM="0" + - >- + pytest -sv + tests/e2e/ -m "slow and H100 and tts" + --run-level "full_model" + mirror_hardwares: h100_1 + + - label: "Diffusion · L4" + timeout_in_minutes: 60 + commands: + - >- + pytest -sv + tests/e2e/ -m "slow and diffusion and L4" + --run-level "full_model" + mirror_hardwares: l4_4 + + - label: "Diffusion · H100 · Single-GPU" + timeout_in_minutes: 120 + commands: + - >- + pytest -sv + tests/e2e/ -m "slow and diffusion and H100 and not distributed_cuda" + --run-level "full_model" + mirror_hardwares: h100_1 + + - label: "Diffusion · H100 · Multi-GPU" + timeout_in_minutes: 90 + commands: + - >- + pytest -sv + tests/e2e/ -m "slow and diffusion and H100 and distributed_cuda" + --run-level "full_model" + mirror_hardwares: h100_2 diff --git a/.buildkite/pipeline-intel.yaml b/.buildkite/intel/pipeline-intel.yml similarity index 73% rename from .buildkite/pipeline-intel.yaml rename to .buildkite/intel/pipeline-intel.yml index d3a2655bf..acb135101 100644 --- a/.buildkite/pipeline-intel.yaml +++ b/.buildkite/intel/pipeline-intel.yml @@ -1,16 +1,16 @@ -# .buildkite/pipeline-intel.yaml +# .buildkite/intel/pipeline-intel.yml steps: - group: "INTEL Tests" steps: - label: "Intel: XPU Test" - command: "bash .buildkite/scripts/hardware_ci/run-xpu-test.sh" + command: "bash .buildkite/intel/scripts/run-xpu-test.sh" agents: queue: "intel-gpu-omni" env: DOCKER_BUILDKIT: "1" # Buildkite will automatically replace this with the actual commit hash VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}" - VLLM_VERSION: "v0.25.0" + VLLM_VERSION: "v0.26.0" priority: 100 timeout_in_minutes: 60 soft_fail: false diff --git a/.buildkite/intel/scripts/bootstrap-intel-omni.sh b/.buildkite/intel/scripts/bootstrap-intel-omni.sh new file mode 100755 index 000000000..a5d3f00a6 --- /dev/null +++ b/.buildkite/intel/scripts/bootstrap-intel-omni.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# vllm-omni Intel bootstrap +# Uses static pipeline-intel.yml for Intel XPU tests + +set -euo pipefail + +source .buildkite/common/scripts/resolve_skip_ci.sh + +upload_pipeline() { + echo "--- 🛠 Preparing Intel pipeline" + buildkite-agent pipeline upload .buildkite/intel/pipeline-intel.yml +} + +gate_bootstrap_ci intel l2 + +upload_pipeline diff --git a/.buildkite/scripts/hardware_ci/run-xpu-test.sh b/.buildkite/intel/scripts/run-xpu-test.sh similarity index 95% rename from .buildkite/scripts/hardware_ci/run-xpu-test.sh rename to .buildkite/intel/scripts/run-xpu-test.sh index 36860522b..6b40d58fb 100755 --- a/.buildkite/scripts/hardware_ci/run-xpu-test.sh +++ b/.buildkite/intel/scripts/run-xpu-test.sh @@ -74,6 +74,6 @@ time timeout -k 30 30m docker run \ cd /workspace/vllm-omni pytest -v -s -m "core_model and xpu and B60" pytest -v -s tests/diffusion/quantization/test_mxfp8_config.py - export VLLM_XPU_USE_SAMPLER_KERNEL=0 # NOTE: Remove this after vLLM v0.21.1 is merged. Fixes Qwen2-5 omni-expansion tests. pytest -v -s -m "advanced_model and xpu and B60" + pytest -v -s -m "omni and xpu and B60" ' diff --git a/.buildkite/npu/bootstrap-upload-steps.yml b/.buildkite/npu/bootstrap-upload-steps.yml new file mode 100644 index 000000000..64b97f895 --- /dev/null +++ b/.buildkite/npu/bootstrap-upload-steps.yml @@ -0,0 +1,181 @@ +# Uploaded by upload_pipeline.py --upload (not the Buildkite hook). +# ``if`` is injected by step ``key`` from skip-ci at upload time. +steps: + - label: ":buildkit: Build and Push NPU Test Image - A2" + key: image-build-a2 + agents: + queue: "ascend-a2b3" + resource_class: "npu-1" + plugins: + - kubernetes: + metadata: + annotations: + vault.hashicorp.com/agent-init-first: "true" + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-inject-perms-ca.pem: "0400" + vault.hashicorp.com/agent-inject-perms-cert.pem: "0400" + vault.hashicorp.com/agent-inject-perms-config.json: "0400" + vault.hashicorp.com/agent-inject-perms-key.pem: "0400" + vault.hashicorp.com/agent-inject-secret-ca.pem: internal/data/ascend/buildkitd + vault.hashicorp.com/agent-inject-secret-cert.pem: internal/data/ascend/buildkitd + vault.hashicorp.com/agent-inject-secret-config.json: internal/data/ascend/buildkitd + vault.hashicorp.com/agent-inject-secret-key.pem: internal/data/ascend/buildkitd + vault.hashicorp.com/agent-inject-template-ca.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.RootCA }}\n{{- end }}" + vault.hashicorp.com/agent-inject-template-cert.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaCert }}\n{{- end }}" + vault.hashicorp.com/agent-inject-template-config.json: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.dockerConfig }}\n{{- end }}" + vault.hashicorp.com/agent-inject-template-key.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaKey }}\n{{- end }}" + vault.hashicorp.com/agent-pre-populate-only: "true" + vault.hashicorp.com/agent-run-as-group: "1000" + vault.hashicorp.com/agent-run-as-user: "1000" + vault.hashicorp.com/agent-service-account-token-volume-name: token-vol + vault.hashicorp.com/role: ascend-gha-runners + vault.hashicorp.com/secret-volume-path: /home/user/.docker/ + vault.hashicorp.com/tls-skip-verify: "true" + podSpecPatch: + volumes: + - name: token-vol + projected: + defaultMode: 420 + sources: + - serviceAccountToken: + audience: api + expirationSeconds: 600 + path: token + env: + VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}" + IMAGE_NAME: "vllm-omni-ci-npu" + IMAGE_REGISTRY: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry" + BUILDKITD_ADDR: "tcp://buildkitd-service.buildkitd:1234" + command: | + set -ex + + echo "--- Building and pushing NPU Test Image" + echo "Image: $${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" + echo "buildkitd address: $${BUILDKITD_ADDR}" + + if ! command -v buildctl &> /dev/null; then + echo "Installing buildctl..." + mkdir -p /tmp/buildkit + BUILDKIT_VERSION="v0.29.0" + wget -q "https://github.com/moby/buildkit/releases/download/$${BUILDKIT_VERSION}/buildkit-$${BUILDKIT_VERSION}.linux-arm64.tar.gz" -O /tmp/buildkit.tar.gz + tar -xzf /tmp/buildkit.tar.gz -C /tmp/buildkit + cp /tmp/buildkit/bin/buildctl /usr/local/bin/ + fi + + export DOCKER_CONFIG=/home/user/.docker + buildctl \ + --addr="$${BUILDKITD_ADDR}" \ + --tlscacert=/home/user/.docker/ca.pem \ + --tlscert=/home/user/.docker/cert.pem \ + --tlskey=/home/user/.docker/key.pem \ + build \ + --frontend dockerfile.v0 \ + --local context=. \ + --local dockerfile=./docker \ + --opt filename=Dockerfile.npu.ci \ + --secret id=dockerconfig,src=/home/user/.docker/config.json \ + --output type=image,name=$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG},push=true \ + --progress=plain + + echo "--- Image pushed successfully" + echo "$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" + + - label: ":buildkit: Build and Push NPU Test Image - A3" + key: image-build-a3 + agents: + queue: "ascend-a2b3" + resource_class: "npu-1" + plugins: + - kubernetes: + metadata: + annotations: + vault.hashicorp.com/agent-init-first: "true" + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-inject-perms-ca.pem: "0400" + vault.hashicorp.com/agent-inject-perms-cert.pem: "0400" + vault.hashicorp.com/agent-inject-perms-config.json: "0400" + vault.hashicorp.com/agent-inject-perms-key.pem: "0400" + vault.hashicorp.com/agent-inject-secret-ca.pem: internal/data/ascend/buildkitd + vault.hashicorp.com/agent-inject-secret-cert.pem: internal/data/ascend/buildkitd + vault.hashicorp.com/agent-inject-secret-config.json: internal/data/ascend/buildkitd + vault.hashicorp.com/agent-inject-secret-key.pem: internal/data/ascend/buildkitd + vault.hashicorp.com/agent-inject-template-ca.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.RootCA }}\n{{- end }}" + vault.hashicorp.com/agent-inject-template-cert.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaCert }}\n{{- end }}" + vault.hashicorp.com/agent-inject-template-config.json: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.dockerConfig }}\n{{- end }}" + vault.hashicorp.com/agent-inject-template-key.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaKey }}\n{{- end }}" + vault.hashicorp.com/agent-pre-populate-only: "true" + vault.hashicorp.com/agent-run-as-group: "1000" + vault.hashicorp.com/agent-run-as-user: "1000" + vault.hashicorp.com/agent-service-account-token-volume-name: token-vol + vault.hashicorp.com/role: ascend-gha-runners + vault.hashicorp.com/secret-volume-path: /home/user/.docker/ + vault.hashicorp.com/tls-skip-verify: "true" + podSpecPatch: + volumes: + - name: token-vol + projected: + defaultMode: 420 + sources: + - serviceAccountToken: + audience: api + expirationSeconds: 600 + path: token + env: + VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}-a3" + IMAGE_NAME: "vllm-omni-ci-npu" + IMAGE_REGISTRY: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry" + BUILDKITD_ADDR: "tcp://buildkitd-service.buildkitd:1234" + command: | + set -ex + + echo "--- Building and pushing NPU Test Image" + echo "Image: $${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" + echo "buildkitd address: $${BUILDKITD_ADDR}" + + if ! command -v buildctl &> /dev/null; then + echo "Installing buildctl..." + mkdir -p /tmp/buildkit + BUILDKIT_VERSION="v0.29.0" + wget -q "https://github.com/moby/buildkit/releases/download/$${BUILDKIT_VERSION}/buildkit-$${BUILDKIT_VERSION}.linux-arm64.tar.gz" -O /tmp/buildkit.tar.gz + tar -xzf /tmp/buildkit.tar.gz -C /tmp/buildkit + cp /tmp/buildkit/bin/buildctl /usr/local/bin/ + fi + + export DOCKER_CONFIG=/home/user/.docker + buildctl \ + --addr="$${BUILDKITD_ADDR}" \ + --tlscacert=/home/user/.docker/ca.pem \ + --tlscert=/home/user/.docker/cert.pem \ + --tlskey=/home/user/.docker/key.pem \ + build \ + --frontend dockerfile.v0 \ + --local context=. \ + --local dockerfile=./docker \ + --opt filename=Dockerfile.npu.ci.a3 \ + --secret id=dockerconfig,src=/home/user/.docker/config.json \ + --output type=image,name=$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG},push=true \ + --progress=plain + + echo "--- Image pushed successfully" + echo "$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" + + - label: "Upload Ready Pipeline" + depends_on: + - image-build-a2 + - image-build-a3 + key: upload-ready-pipeline + agents: + queue: "cpu_queue_premerge" + command: | + python3 .buildkite/common/scripts/upload_pipeline.py --upload .buildkite/npu/test-npu-ready.yml + + # L4 Test — main+NIGHTLY=1 (scheduled), or PR with specific label (e.g. add label then Rebuild) + - label: "Upload Nightly Pipeline" + depends_on: + - image-build-a2 + - image-build-a3 + key: upload-nightly-pipeline + commands: + - python3 .buildkite/common/scripts/upload_pipeline.py --upload .buildkite/npu/test-npu-nightly.yml + agents: + queue: "cpu_queue_premerge" diff --git a/.buildkite/pipeline-npu-a3.yaml b/.buildkite/npu/pipeline-npu-a3.yml similarity index 97% rename from .buildkite/pipeline-npu-a3.yaml rename to .buildkite/npu/pipeline-npu-a3.yml index 8776219e1..a57f5f8e5 100644 --- a/.buildkite/pipeline-npu-a3.yaml +++ b/.buildkite/npu/pipeline-npu-a3.yml @@ -88,7 +88,7 @@ # # queue: "ascend-a3" # # resource_class: "npu-2" # # command: | -# # buildkite-agent pipeline upload .buildkite/test-npu-ready.yaml +# # buildkite-agent pipeline upload .buildkite/npu/test-npu-ready.yml # # L4 Test — main+NIGHTLY=1 (scheduled), or PR with specific label (e.g. add label then Rebuild) # - label: "Upload Nightly Pipeline" @@ -104,7 +104,7 @@ # build.pull_request.labels includes "diffusion-x2v-test" # )) # commands: - # - buildkite-agent pipeline upload .buildkite/test-npu-nightly.yaml + # - buildkite-agent pipeline upload .buildkite/npu/test-npu-nightly.yml # agents: # queue: "ascend-a3" # resource_class: "npu-2" diff --git a/.buildkite/npu/pipeline-npu.yml b/.buildkite/npu/pipeline-npu.yml new file mode 100644 index 000000000..237b65593 --- /dev/null +++ b/.buildkite/npu/pipeline-npu.yml @@ -0,0 +1,9 @@ +# Hook uploads this entry YAML; upload_pipeline.py --upload reads bootstrap-upload-steps.yml +# and injects ``if`` by step ``key`` from skip-ci. +steps: + - label: ":github: Resolve skip-ci & upload pipeline" + key: upload-ci-pipeline + commands: + - "python3 .buildkite/common/scripts/upload_pipeline.py --upload .buildkite/npu/bootstrap-upload-steps.yml" + agents: + queue: "cpu_queue_premerge" diff --git a/.buildkite/npu/test-npu-nightly.yml b/.buildkite/npu/test-npu-nightly.yml new file mode 100644 index 000000000..10626600c --- /dev/null +++ b/.buildkite/npu/test-npu-nightly.yml @@ -0,0 +1,205 @@ +steps: + - group: ":card_index_dividers: Omni Model Test" + key: nightly-omni-test-group + depends_on: upload-nightly-pipeline + if: >- + build.env("NIGHTLY") == "1" || + build.pull_request.labels includes "nightly-test" || + build.pull_request.labels includes "omni-test" + steps: + - label: ":full_moon: Omni · Perf Test · No Async Chunk" + key: nightly-omni-performance-no-async-chunk + timeout_in_minutes: 180 + mirror_hardwares: a3_npu_4 + env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" + HF_TOKEN: "${HF_TOKEN}" + commands: + - export BENCHMARK_DIR=tests/dfx/perf/results + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m npu --test-config-file tests/dfx/perf/tests/test_qwen3_omni_no_async_chunk.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/*.json" + exit $$EXIT + - label: ":full_moon: Omni · Perf Test · Async Chunk" + key: nightly-omni-performance-async-chunk + timeout_in_minutes: 180 + mirror_hardwares: a3_npu_4 + env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" + HF_TOKEN: "${HF_TOKEN}" + commands: + - export BENCHMARK_DIR=tests/dfx/perf/results + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m npu --test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/*.json" + exit $$EXIT + - label: ":robot: 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 tests/e2e/online_serving/test_minicpmo_4_5_expansion.py -m 'full_model' --run-level 'full_model' + - group: ":card_index_dividers: TTS Model Test" + key: nightly-tts-test-group + depends_on: upload-nightly-pipeline + if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "tts-test" + steps: + - label: ":full_moon: TTS · Perf Test" + timeout_in_minutes: 180 + mirror_hardwares: a3_npu_2 + env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" + HF_TOKEN: "${HF_TOKEN}" + commands: + - export BENCHMARK_DIR=tests/dfx/perf/results + - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m npu --test-config-file tests/dfx/perf/tests/test_tts.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/*.json" + exit $$EXIT + - group: ":card_index_dividers: Diffusion X2I(&A&T) Model Test" + key: nightly-diffusion-x2iat-group + depends_on: upload-nightly-pipeline + if: >- + build.env("NIGHTLY") == "1" || + build.pull_request.labels includes "nightly-test" || + build.pull_request.labels includes "diffusion-x2iat-test" + steps: + - label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image-Edit-2511" + key: nightly-diffusion-x2iat-performance-qwen-image-edit-2511 + timeout_in_minutes: 180 + mirror_hardwares: a3_npu_8 + env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" + HF_TOKEN: "${HF_TOKEN}" + DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA + commands: + - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results + - export CACHE_DIT_VERSION=1.3.0 + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m npu --test-config-file tests/dfx/perf/tests/test_qwen_image_edit_2511_vllm_omni.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" + buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" + exit $$EXIT + - group: ":card_index_dividers: Diffusion I2I Model Test" + key: nightly-HunyuanImage3-x2i-test-group + depends_on: upload-nightly-pipeline + if: >- + build.env("NIGHTLY") == "1" || + build.pull_request.labels includes "nightly-test" || + build.pull_request.labels includes "diffusion-x2iat-test" + steps: + - label: ":full_moon: HunyuanImage3-DIT · Accuracy Test" + timeout_in_minutes: 180 + mirror_hardwares: a3_npu_4 + env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + HUNYUAN_IMAGE3_MODEL: "tencent/HunyuanImage-3.0-Instruct" + HUNYUAN_IMAGE3_DEVICES: "0,1,2,3" + DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA + commands: + - pytest -s -v tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py -m "full_model and A3" --run-level full_model + - group: ":card_index_dividers: Diffusion Benchmark Perf Test" + key: nightly-diffusion-benchmark-group + depends_on: upload-nightly-pipeline + if: >- + build.env("NIGHTLY") == "1" || + build.pull_request.labels includes "nightly-test" || + build.pull_request.labels includes "diffusion-x2iat-test" + steps: + - label: ":full_moon: Diffusion Perf · HunyuanImage3 TP=2 CFGP=2" + timeout_in_minutes: 180 + mirror_hardwares: a3_npu_4 + env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + HF_TOKEN: "${HF_TOKEN}" + DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA + commands: + - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m A3 --test-config-file tests/dfx/perf/tests/test_hunyuan_image_tp2_cfgp2.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" + buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" + exit $$EXIT + - label: ":full_moon: Diffusion Perf · HunyuanImage3 TP=2 SP=2" + timeout_in_minutes: 180 + mirror_hardwares: a3_npu_4 + env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + HF_TOKEN: "${HF_TOKEN}" + DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA + commands: + - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m A3 --test-config-file tests/dfx/perf/tests/test_hunyuan_image_tp2_sp2.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" + buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" + exit $$EXIT + - label: ":full_moon: Diffusion Perf · HunyuanImage3 TP=4" + timeout_in_minutes: 180 + mirror_hardwares: a3_npu_4 + env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + HF_TOKEN: "${HF_TOKEN}" + DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA + commands: + - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m A3 --test-config-file tests/dfx/perf/tests/test_hunyuan_image_tp4.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" + buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" + exit $$EXIT + - group: ":card_index_dividers: Diffusion X2V Model Test" + key: nightly-diffusion-x2v-group + depends_on: upload-nightly-pipeline + if: >- + build.env("NIGHTLY") == "1" || + build.pull_request.labels includes "nightly-test" || + build.pull_request.labels includes "diffusion-x2v-test" + steps: + - label: ":full_moon: Diffusion X2V · Function Test" + mirror_hardwares: a2b3_npu_4 + 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_wan22_expansion.py -k npu_i2v --run-level "full_model" + - label: ":full_moon: Diffusion X2V · Perf Test" + key: nightly-diffusion-x2v-performance + timeout_in_minutes: 180 + mirror_hardwares: a3_npu_4 + env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" + HF_TOKEN: "${HF_TOKEN}" + DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA + commands: + - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m npu --test-config-file tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" + buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" + exit $$EXIT diff --git a/.buildkite/npu/test-npu-ready.yml b/.buildkite/npu/test-npu-ready.yml new file mode 100644 index 000000000..c427ff065 --- /dev/null +++ b/.buildkite/npu/test-npu-ready.yml @@ -0,0 +1,34 @@ +steps: + - group: ":card_index_dividers: Omni Model Test" + 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 + 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' + + - group: ":card_index_dividers: TTS Model Test" + key: ready-tts-group + depends_on: upload-ready-pipeline + steps: + - label: ":sound: TTS · Qwen3-TTS CustomVoice Test" + mirror_hardwares: a2b3_npu_1 + env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" + HF_TOKEN: "${HF_TOKEN}" + commands: + - pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m 'core_model and npu' --run-level 'core_model' diff --git a/.buildkite/pipeline-npu.yaml b/.buildkite/pipeline-npu.yaml deleted file mode 100644 index 161b0e944..000000000 --- a/.buildkite/pipeline-npu.yaml +++ /dev/null @@ -1,112 +0,0 @@ -steps: - - label: ":buildkit: Build and Push NPU Test Image - A2" - key: image-build - agents: - queue: "ascend-a2b3" - resource_class: "npu-1" - plugins: - - kubernetes: - metadata: - annotations: - vault.hashicorp.com/agent-init-first: "true" - vault.hashicorp.com/agent-inject: "true" - vault.hashicorp.com/agent-inject-perms-ca.pem: "0400" - vault.hashicorp.com/agent-inject-perms-cert.pem: "0400" - vault.hashicorp.com/agent-inject-perms-config.json: "0400" - vault.hashicorp.com/agent-inject-perms-key.pem: "0400" - vault.hashicorp.com/agent-inject-secret-ca.pem: internal/data/ascend/buildkitd - vault.hashicorp.com/agent-inject-secret-cert.pem: internal/data/ascend/buildkitd - vault.hashicorp.com/agent-inject-secret-config.json: internal/data/ascend/buildkitd - vault.hashicorp.com/agent-inject-secret-key.pem: internal/data/ascend/buildkitd - vault.hashicorp.com/agent-inject-template-ca.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.RootCA }}\n{{- end }}" - vault.hashicorp.com/agent-inject-template-cert.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaCert }}\n{{- end }}" - vault.hashicorp.com/agent-inject-template-config.json: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.dockerConfig }}\n{{- end }}" - vault.hashicorp.com/agent-inject-template-key.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaKey }}\n{{- end }}" - vault.hashicorp.com/agent-pre-populate-only: "true" - vault.hashicorp.com/agent-run-as-group: "1000" - vault.hashicorp.com/agent-run-as-user: "1000" - vault.hashicorp.com/agent-service-account-token-volume-name: token-vol - vault.hashicorp.com/role: ascend-gha-runners - vault.hashicorp.com/secret-volume-path: /home/user/.docker/ - vault.hashicorp.com/tls-skip-verify: "true" - podSpecPatch: - volumes: - - name: token-vol - projected: - defaultMode: 420 - sources: - - serviceAccountToken: - audience: api - expirationSeconds: 600 - path: token - env: - VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}" - IMAGE_NAME: "vllm-omni-ci-npu" - IMAGE_REGISTRY: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry" - BUILDKITD_ADDR: "tcp://buildkitd-service.buildkitd:1234" - command: | - set -ex - - echo "--- Building and pushing NPU Test Image" - echo "Image: $${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" - echo "buildkitd address: $${BUILDKITD_ADDR}" - - if ! command -v buildctl &> /dev/null; then - echo "Installing buildctl..." - mkdir -p /tmp/buildkit - BUILDKIT_VERSION="v0.29.0" - wget -q "https://github.com/moby/buildkit/releases/download/$${BUILDKIT_VERSION}/buildkit-$${BUILDKIT_VERSION}.linux-arm64.tar.gz" -O /tmp/buildkit.tar.gz - tar -xzf /tmp/buildkit.tar.gz -C /tmp/buildkit - cp /tmp/buildkit/bin/buildctl /usr/local/bin/ - fi - - export DOCKER_CONFIG=/home/user/.docker - buildctl \ - --addr="$${BUILDKITD_ADDR}" \ - --tlscacert=/home/user/.docker/ca.pem \ - --tlscert=/home/user/.docker/cert.pem \ - --tlskey=/home/user/.docker/key.pem \ - build \ - --frontend dockerfile.v0 \ - --local context=. \ - --local dockerfile=./docker \ - --opt filename=Dockerfile.npu.ci \ - --secret id=dockerconfig,src=/home/user/.docker/config.json \ - --output type=image,name=$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG},push=true \ - --progress=plain - - echo "--- Image pushed successfully" - echo "$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" - - - label: "Upload Ready Pipeline" - depends_on: image-build - key: upload-ready-pipeline - if: >- - build.branch != "main" && ( - build.pull_request.labels includes "npu-test" || - build.pull_request.labels includes "ready" - ) - agents: - queue: "ascend-a2b3" - resource_class: "npu-1" - command: | - buildkite-agent pipeline upload .buildkite/test-npu-ready.yaml - - # L4 Test — main+NIGHTLY=1 (scheduled), or PR with specific label (e.g. add label then Rebuild) - - label: "Upload Nightly Pipeline" - depends_on: image-build - key: upload-nightly-pipeline - if: >- - (build.branch == "main" && build.env("NIGHTLY") == "1") || - (build.branch != "main" && ( - build.pull_request.labels includes "nightly-test" || - build.pull_request.labels includes "omni-test" || - build.pull_request.labels includes "tts-test" || - build.pull_request.labels includes "diffusion-x2iat-test" || - build.pull_request.labels includes "diffusion-x2v-test" - )) - commands: - - buildkite-agent pipeline upload .buildkite/test-npu-nightly.yaml - agents: - queue: "ascend-a2b3" - resource_class: "npu-1" diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml deleted file mode 100644 index 040393f6c..000000000 --- a/.buildkite/pipeline.yml +++ /dev/null @@ -1,87 +0,0 @@ -# Document 1: Buildkite loads only this block on first parse. The next step resolves docs-only, -# pytest skip-mark-only, or combined skip-ci from git diff, then uploads document 2. When skip-ci -# applies, image-build still runs for the nightly exception in upload_pipeline.py (nightly-test -# label or main NIGHTLY=1); otherwise upload-nightly can be skipped together with test-ready/test-merge. -# Weekly uploads also depend on image-build. - -# Document 2: appended after `---`; same file, read by upload_pipeline.py (not evaluated as a second pipeline by Buildkite). Child uploads include test-ready.yml, test-merge.yml, test-nightly.yml, and test-weekly.yml. -steps: - - label: ":github: Resolve skip-ci (docs / skip marks) & upload pipeline" - key: upload-ci-pipeline - commands: - - "python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/pipeline.yml" - agents: - queue: "cpu_queue_premerge" - ---- -steps: - - label: ":docker: Build image" - key: image-build - if: __IMAGE_BUILD_IF__ - commands: - - "aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/q9t5s3a7" - - "docker build --progress=plain --file docker/Dockerfile.ci -t vllm-omni-ci ." - - "docker tag vllm-omni-ci public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT" - - "docker push public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT" - agents: - queue: "cpu_queue_premerge" - - # L2 Test — PR ready label (diff-aware) or main+NIGHTLY=1 (--e2e, full E2E group) - - label: "Upload Ready Pipeline" - depends_on: image-build - key: upload-ready-pipeline - if: __UPLOAD_READY_IF__ - commands: - - | - EXTRA="" - if [ "${NIGHTLY:-}" = "1" ] && [ "${BUILDKITE_BRANCH}" = "main" ]; then - EXTRA="--e2e" - fi - python3 .buildkite/scripts/upload_pipeline.py --upload $$EXTRA .buildkite/test-ready.yml - agents: - queue: "cpu_queue_premerge" - - # L3 Test — PR merge-test label (diff-aware) or main+NIGHTLY=1 (--e2e, full E2E group) - - label: "Upload Merge Pipeline" - depends_on: image-build - key: upload-merge-pipeline - if: __UPLOAD_MERGE_IF__ - commands: - - | - EXTRA="" - if [ "${NIGHTLY:-}" = "1" ] && [ "${BUILDKITE_BRANCH}" = "main" ]; then - EXTRA="--e2e" - fi - python3 .buildkite/scripts/upload_pipeline.py --upload $$EXTRA .buildkite/test-merge.yml - agents: - queue: "cpu_queue_premerge" - - # L4 Test — main+NIGHTLY=1 (scheduled), or PR with specific label (e.g. add label then Rebuild) - - label: "Upload Nightly Pipeline" - depends_on: image-build - key: upload-nightly-pipeline - if: >- - (build.branch == "main" && build.env("NIGHTLY") == "1") || - (build.branch != "main" && ( - build.pull_request.labels includes "nightly-test" || - build.pull_request.labels includes "omni-test" || - build.pull_request.labels includes "tts-test" || - build.pull_request.labels includes "diffusion-x2iat-test" || - build.pull_request.labels includes "diffusion-x2v-test" - )) - commands: - - buildkite-agent pipeline upload .buildkite/test-nightly.yml - agents: - queue: "cpu_queue_premerge" - - # L5 Test — main+WEEKLY=1 (scheduled), or PR with weekly-test label - - label: "Upload Weekly Pipeline" - depends_on: image-build - key: upload-weekly-pipeline - if: >- - (build.branch == "main" && build.env("WEEKLY") == "1") || - (build.branch != "main" && build.pull_request.labels includes "weekly-test") - commands: - - buildkite-agent pipeline upload .buildkite/test-weekly.yml - agents: - queue: "cpu_queue_premerge" diff --git a/.buildkite/rebase-pipeline.yaml b/.buildkite/rebase-pipeline.yaml deleted file mode 100644 index 796ad19a7..000000000 --- a/.buildkite/rebase-pipeline.yaml +++ /dev/null @@ -1,38 +0,0 @@ -steps: - - label: ":docker: Build image" - key: image-build - commands: - - "aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/q9t5s3a7" - - "docker build --progress=plain --file docker/Dockerfile.ci -t vllm-omni-ci ." - - "docker tag vllm-omni-ci public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT" - - "docker push public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT" - agents: - queue: "cpu_queue_premerge" - - # L2 Test - - label: "Upload Ready Pipeline" - depends_on: image-build - key: upload-ready-pipeline - commands: - - python3 .buildkite/scripts/upload_pipeline.py --upload --all .buildkite/test-ready.yml - agents: - queue: "cpu_queue_premerge" - - # L3 Test - - label: "Upload Merge Pipeline" - depends_on: image-build - key: upload-merge-pipeline - commands: - - python3 .buildkite/scripts/upload_pipeline.py --upload --all .buildkite/test-merge.yml - agents: - queue: "cpu_queue_premerge" - - # L4 Test - - label: "Upload Nightly Pipeline" - depends_on: image-build - key: upload-nightly-pipeline - if: build.env("NIGHTLY") == "1" - commands: - - python3 .buildkite/scripts/upload_pipeline.py --upload --all .buildkite/test-nightly.yml - agents: - queue: "cpu_queue_premerge" diff --git a/.buildkite/release-pipeline.yaml b/.buildkite/release/release-pipeline.yml similarity index 92% rename from .buildkite/release-pipeline.yaml rename to .buildkite/release/release-pipeline.yml index c560fefe1..351e105cc 100644 --- a/.buildkite/release-pipeline.yaml +++ b/.buildkite/release/release-pipeline.yml @@ -16,7 +16,7 @@ steps: - "uv venv --python=3.12 && source .venv/bin/activate" - "uv pip install --upgrade build" - "python3 -m build" - - "bash .buildkite/scripts/upload-nightly-wheels.sh" + - "bash .buildkite/release/scripts/upload-nightly-wheels.sh" - label: "Generate and upload wheel indices" depends_on: "build-wheel" @@ -24,7 +24,7 @@ steps: agents: queue: small_cpu_queue_release commands: - - "bash .buildkite/scripts/generate-and-upload-nightly-index.sh" + - "bash .buildkite/release/scripts/generate-and-upload-nightly-index.sh" - block: "Upload release wheels to PyPI" depends_on: "build-wheel" @@ -38,7 +38,7 @@ steps: agents: queue: small_cpu_queue_release commands: - - "bash .buildkite/scripts/publish-release-wheels-pypi.sh" + - "bash .buildkite/release/scripts/publish-release-wheels-pypi.sh" - block: "Unblock to build release Docker images" depends_on: ~ @@ -100,7 +100,7 @@ steps: agents: queue: small_cpu_queue_release commands: - - "bash .buildkite/scripts/publish-release-images.sh" + - "bash .buildkite/release/scripts/publish-release-images.sh" plugins: - docker-login#v3.0.0: username: vllmbot diff --git a/.buildkite/scripts/generate-and-upload-nightly-index.sh b/.buildkite/release/scripts/generate-and-upload-nightly-index.sh similarity index 92% rename from .buildkite/scripts/generate-and-upload-nightly-index.sh rename to .buildkite/release/scripts/generate-and-upload-nightly-index.sh index b09c13f5c..63f5da596 100755 --- a/.buildkite/scripts/generate-and-upload-nightly-index.sh +++ b/.buildkite/release/scripts/generate-and-upload-nightly-index.sh @@ -36,12 +36,12 @@ mkdir -p "$INDICES_OUTPUT_DIR" # HACK: we do not need regex module here, but it is required by pre-commit hook # To avoid any external dependency, we simply replace it back to the stdlib re module -sed -i.bak 's/import regex as re/import re/g' .buildkite/scripts/generate-nightly-index.py && rm -f .buildkite/scripts/generate-nightly-index.py.bak +sed -i.bak 's/import regex as re/import re/g' .buildkite/release/scripts/generate-nightly-index.py && rm -f .buildkite/release/scripts/generate-nightly-index.py.bak # Generate indices -- the version is just the commit hash (not omni/{commit}) # because relative paths are computed between the index and wheel directories, # both of which live under the omni/ prefix in S3. -$PYTHON .buildkite/scripts/generate-nightly-index.py \ +$PYTHON .buildkite/release/scripts/generate-nightly-index.py \ --version "$BUILDKITE_COMMIT" \ --current-objects "$obj_json" \ --output-dir "$INDICES_OUTPUT_DIR" \ @@ -78,7 +78,7 @@ if [[ "$version" != *"dev"* ]]; then rm -rf "${INDICES_OUTPUT_DIR:?}" mkdir -p "$INDICES_OUTPUT_DIR" # wheel-dir is overridden to be the commit directory, so that the indices point to the correct wheel path - $PYTHON .buildkite/scripts/generate-nightly-index.py \ + $PYTHON .buildkite/release/scripts/generate-nightly-index.py \ --version "$s3_version" \ --wheel-dir "$BUILDKITE_COMMIT" \ --current-objects "$obj_json" \ diff --git a/.buildkite/scripts/generate-nightly-index.py b/.buildkite/release/scripts/generate-nightly-index.py similarity index 100% rename from .buildkite/scripts/generate-nightly-index.py rename to .buildkite/release/scripts/generate-nightly-index.py diff --git a/.buildkite/scripts/publish-release-images.sh b/.buildkite/release/scripts/publish-release-images.sh similarity index 100% rename from .buildkite/scripts/publish-release-images.sh rename to .buildkite/release/scripts/publish-release-images.sh diff --git a/.buildkite/scripts/publish-release-wheels-pypi.sh b/.buildkite/release/scripts/publish-release-wheels-pypi.sh similarity index 100% rename from .buildkite/scripts/publish-release-wheels-pypi.sh rename to .buildkite/release/scripts/publish-release-wheels-pypi.sh diff --git a/.buildkite/scripts/upload-nightly-wheels.sh b/.buildkite/release/scripts/upload-nightly-wheels.sh similarity index 100% rename from .buildkite/scripts/upload-nightly-wheels.sh rename to .buildkite/release/scripts/upload-nightly-wheels.sh diff --git a/.buildkite/scripts/docker_login_ecr_public.sh b/.buildkite/scripts/docker_login_ecr_public.sh deleted file mode 100755 index 51c5e1a5d..000000000 --- a/.buildkite/scripts/docker_login_ecr_public.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -# Helper function to safely login to ECR Public with per-job config isolation -# Uses DOCKER_CONFIG environment variable to prevent race conditions -# -# This script prevents the "device or resource busy" error by giving each -# Buildkite job its own isolated Docker config directory. -# -# Usage: -# source docker_login_ecr_public.sh && safe_docker_login_ecr_public - -set -euo pipefail - -# Configuration -ECR_REGISTRY="public.ecr.aws" - -setup_isolated_docker_config() { - # Use BUILDKITE_JOB_ID for job-specific isolation - # Fallback to PID if running outside Buildkite - local job_id="${BUILDKITE_JOB_ID:-$$}" - - # Set Docker config to job-specific directory - export DOCKER_CONFIG="/tmp/docker-config-${job_id}" - - # Create directory if it doesn't exist - mkdir -p "$DOCKER_CONFIG" - - echo "[docker-config] Using isolated Docker config: $DOCKER_CONFIG" -} - -check_docker_auth() { - # Check if already authenticated to the given registry - # Returns 0 if authenticated, 1 if not - local registry="$1" - - # Check if credentials exist in the isolated config - if [[ -f "$DOCKER_CONFIG/config.json" ]]; then - # Check if registry is present in config - if grep -q "$registry" "$DOCKER_CONFIG/config.json" 2>/dev/null; then - return 0 - fi - fi - - return 1 -} - -safe_docker_login_ecr_public() { - # Setup isolated config first - setup_isolated_docker_config - - local registry="$ECR_REGISTRY" - - # Check if already authenticated (within this job) - if check_docker_auth "$registry"; then - echo "[docker-login] Already authenticated to $registry in this job" - return 0 - fi - - # Perform login to isolated config directory - echo "[docker-login] Logging in to $ECR_REGISTRY (isolated config)..." - if aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin "$ECR_REGISTRY"; then - echo "[docker-login] Login successful (config: $DOCKER_CONFIG)" - return 0 - else - local exit_code=$? - echo "[docker-login] ERROR: Login failed with exit code $exit_code" >&2 - return $exit_code - fi -} - -# Execute if run as script (not sourced) -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - safe_docker_login_ecr_public -fi diff --git a/.buildkite/scripts/hardware_ci/run_npu_test.sh b/.buildkite/scripts/hardware_ci/run_npu_test.sh deleted file mode 100644 index 492699c5f..000000000 --- a/.buildkite/scripts/hardware_ci/run_npu_test.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/bin/bash - -# This script build the Ascend NPU docker image and run the offline inference inside the container. -# It serves a sanity check for compilation and basic model usage. -set -ex - -# Base ubuntu image with basic ascend development libraries and python installed -VLLM_OMNI_REPO="https://github.com/vllm-project/vllm-omni.git" -BASE_IMAGE_NAME="quay.nju.edu.cn/ascend/vllm-ascend:v0.11.0rc2" -image_name="npu/vllm-omni-ci:${BUILDKITE_COMMIT}_${EPOCHSECONDS}" -# image_name="npu/vllm-ci:${BUILDKITE_COMMIT}_${EPOCHSECONDS}" -container_name="npu_${BUILDKITE_COMMIT}_$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 10; echo)" - -# BUILDKITE_AGENT_NAME format is {hostname}-{agent_idx}-{npu_card_num}cards -agent_idx=$(echo "${BUILDKITE_AGENT_NAME}" | awk -F'-' '{print $(NF-1)}') -echo "agent_idx: ${agent_idx}" -builder_name="cachebuilder${agent_idx}" -builder_cache_dir="/mnt/docker-cache${agent_idx}" -mkdir -p ${builder_cache_dir} - -# Try building the docker image -cat <=6.0 pytest-cov modelscope - -COPY . . - -# Install vllm-omni -WORKDIR /workspace -ARG VLLM_OMNI_REPO=https://github.com/vllm-project/vllm-omni.git -ARG VLLM_OMNI_TAG=main -ARG BUILDKITE_PULL_REQUEST -ARG BUILDKITE_PULL_REQUEST_REPO -RUN git config --global url."https://gh-proxy.test.osinfra.cn/https://github.com/".insteadOf "https://github.com/" && \ - if [ "\$BUILDKITE_PULL_REQUEST" != "false" ] && [ -n "\$BUILDKITE_PULL_REQUEST" ]; then \ - echo "Cloning and checking out PR #\$BUILDKITE_PULL_REQUEST..." && \ - git clone \$VLLM_OMNI_REPO /workspace/vllm-omni && \ - cd /workspace/vllm-omni && \ - git fetch origin pull/\$BUILDKITE_PULL_REQUEST/head:pr-\$BUILDKITE_PULL_REQUEST && \ - git checkout pr-\$BUILDKITE_PULL_REQUEST; \ - else \ - echo "Not a PR build, using main branch" && \ - git clone --depth 1 \$VLLM_OMNI_REPO /workspace/vllm-omni; \ - fi - -RUN --mount=type=cache,target=/root/.cache/pip \ - export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi && \ - source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ - source /usr/local/Ascend/nnal/atb/set_env.sh && \ - export LD_LIBRARY_PATH=\$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \ - python3 -m pip install -v -e /workspace/vllm-omni/ - -ENV VLLM_WORKER_MULTIPROC_METHOD=spawn - -WORKDIR /workspace/vllm-omni -CMD ["/bin/bash"] - -EOF - -# Setup cleanup -remove_docker_container() { - docker rm -f "${container_name}" || true; - docker image rm -f "${image_name}" || true; - docker system prune -f || true; -} -trap remove_docker_container EXIT - -# Generate corresponding --device args based on BUILDKITE_AGENT_NAME -# Ascend NPU BUILDKITE_AGENT_NAME format is {hostname}-{agent_idx}-{npu_card_num}cards, and agent_idx starts from 1. -# e.g. atlas-a2-001-1-2cards means this is the 1-th agent on atlas-a2-001 host, and it has 2 NPU cards. -# returns --device /dev/davinci0 --device /dev/davinci1 -parse_and_gen_devices() { - local input="$1" - local index cards_num - if [[ "$input" =~ ([0-9]+)-([0-9]+)cards$ ]]; then - index="${BASH_REMATCH[1]}" - cards_num="${BASH_REMATCH[2]}" - else - echo "parse error" >&2 - return 1 - fi - - local devices="" - local i=0 - while (( i < cards_num )); do - local dev_idx=$(((index - 1)*cards_num + i )) - devices="$devices --device /dev/davinci${dev_idx}" - ((i++)) - done - - # trim leading space - devices="${devices#"${devices%%[![:space:]]*}"}" - # Output devices: assigned to the caller variable - printf '%s' "$devices" -} - -devices=$(parse_and_gen_devices "${BUILDKITE_AGENT_NAME}") || exit 1 - -# Run the image and execute the Out-Of-Tree (OOT) platform interface test case on Ascend NPU hardware. -# This test checks whether the OOT platform interface is functioning properly in conjunction with -# the hardware plugin vllm-ascend. -hf_model_cache_dir=/mnt/hf_cache${agent_idx} -ms_model_cache_dir=/mnt/modelscope${agent_idx} -mkdir -p ${hf_model_cache_dir} -mkdir -p ${ms_model_cache_dir} -docker run \ - --init \ - ${devices} \ - --device /dev/davinci_manager \ - --device /dev/devmm_svm \ - --device /dev/hisi_hdc \ - -v /usr/local/dcmi:/usr/local/dcmi \ - -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \ - -v /usr/local/Ascend/driver/lib64/:/usr/local/Ascend/driver/lib64/ \ - -v /usr/local/Ascend/driver/version.info:/usr/local/Ascend/driver/version.info \ - -v /etc/ascend_install.info:/etc/ascend_install.info \ - -v ${hf_model_cache_dir}:/root/.cache/huggingface \ - -v ${ms_model_cache_dir}:/root/.cache/modelscope \ - --network host \ - --entrypoint="" \ - --name "${container_name}" \ - "${image_name}" \ - bash -c ' - set -e - VLLM_USE_MODELSCOPE=True pytest -s -v tests/e2e/offline_inference/test_qwen2_5_omni_expansion.py -' diff --git a/.buildkite/scripts/upload_pipeline.py b/.buildkite/scripts/upload_pipeline.py deleted file mode 100644 index d9220ed0f..000000000 --- a/.buildkite/scripts/upload_pipeline.py +++ /dev/null @@ -1,547 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: Apache-2.0 -"""Render and optionally upload Buildkite pipeline YAML with diff-aware logic. - -Bootstrap mode (pipeline.yml with __IMAGE_BUILD_IF__ placeholders): - - Detect docs-only, pytest skip-mark-only, or combined docs+skip-mark PR/main changes and - substitute skip-ci ``if`` expressions. - -Test pipeline mode (e.g. test-merge.yml): - - Drop steps or groups whose ``source_file_dependencies`` do not match changed files. - - ``source_file_dependencies`` may be set on a leaf step or on a group step. - - Always strip ``source_file_dependencies`` from uploaded YAML (Buildkite does not - recognize this uploader-only key). - -Usage: - python3 upload_pipeline.py [--upload] [--all | --e2e] - - # Bootstrap (replaces upload_pipeline_with_skip_ci.sh): - python3 upload_pipeline.py --upload .buildkite/pipeline.yml - - # Test pipeline (replaces upload_test_pipeline_with_diff_skip.py): - python3 upload_pipeline.py --upload .buildkite/test-merge.yml - - # Full suite (rebase pipeline): keep all steps, still strip the uploader-only key: - python3 upload_pipeline.py --upload --all .buildkite/test-merge.yml - - # Nightly L2 E2E only: keep the E2E Test group, ignore source_file_dependencies: - python3 upload_pipeline.py --upload --e2e .buildkite/test-ready.yml -""" - -from __future__ import annotations - -import argparse -import os -import re -import subprocess -import sys -from pathlib import Path -from typing import Any - -try: - import yaml -except ImportError: - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "-q", "pyyaml"], - ) - import yaml - -LOG = "upload_pipeline" -ROOT = Path(__file__).resolve().parent.parent.parent -DOC_SEP = "\n---\n" -BOOTSTRAP_MARKER = "__IMAGE_BUILD_IF__" -E2E_GROUP_MARKER = "E2E Test" - - -def _log(message: str) -> None: - print(f"{LOG}: {message}", file=sys.stderr) - - -def _git(*args: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", *args], - check=False, - capture_output=True, - text=True, - ) - - -def resolve_diff_range() -> str | None: - """Return a git diff range for PR or main builds, or None when unavailable.""" - is_pr = os.environ.get("BUILDKITE_PULL_REQUEST", "false") != "false" and os.environ.get( - "BUILDKITE_PULL_REQUEST", "" - ) - commit = os.environ.get("BUILDKITE_COMMIT", "") - - if is_pr: - base_branch = os.environ.get("BUILDKITE_PULL_REQUEST_BASE_BRANCH", "main") - base_ref = f"origin/{base_branch}" - if _git("rev-parse", "--verify", base_ref).returncode != 0: - _log(f"origin/{base_branch} not found locally; trying fetch") - _git("fetch", "--depth=200", "origin", base_branch) - if _git("rev-parse", "--verify", base_ref).returncode != 0: - if _git("rev-parse", "--verify", base_branch).returncode == 0: - base_ref = base_branch - else: - _log(f"cannot resolve PR base {base_branch}; using safe defaults") - return None - return f"{base_ref}...{commit}" - - if os.environ.get("BUILDKITE_BRANCH", "") == "main": - if _git("rev-parse", "--verify", f"{commit}^").returncode != 0: - _log("main commit has no parent; using safe defaults") - return None - return f"{commit}^..{commit}" - - _log("not PR/main build; using safe defaults") - return None - - -def resolve_changed_files() -> list[str] | None: - """Return changed file paths, or None when diff cannot be resolved.""" - diff_range = resolve_diff_range() - if diff_range is None: - return None - - result = _git("diff", "--name-only", diff_range) - if result.returncode != 0: - _log(f"git diff failed ({diff_range}); using safe defaults") - return None - - files = [line for line in result.stdout.splitlines() if line.strip()] - _log(f"{len(files)} changed file(s)") - return files - - -def _is_doc_file(file_path: str) -> bool: - if not file_path: - return False - if file_path.startswith("docs/"): - return True - if file_path.endswith(".md"): - return True - return file_path == "mkdocs.yml" - - -def is_docs_only_change(changed_files: list[str]) -> bool: - has_any = False - for file_path in changed_files: - if not file_path: - continue - has_any = True - if not _is_doc_file(file_path): - return False - return has_any - - -def _is_test_python_file(file_path: str) -> bool: - path = Path(file_path) - return path.suffix == ".py" and bool(path.parts) and path.parts[0] == "tests" - - -_SKIP_MARK_RE = re.compile(r"pytest\.mark\.skip(?:if)?\b|pytest\.skip\s*\(") -_PYTESTMARK_SKIP_RE = re.compile(r"pytest\.mark\.skip\b") -_PYTEST_MARK_ONLY_RE = re.compile(r"pytest\.mark\.\w+") - - -def _paren_balance(line: str) -> int: - return line.count("(") - line.count(")") - - -def _is_skip_mark_related_content(line: str) -> bool: - stripped = line.strip() - if not stripped: - return True - if stripped.startswith("#"): - return True - return _SKIP_MARK_RE.search(stripped) is not None - - -def _is_pytestmark_adjacent_content(line: str) -> bool: - """Allow pytestmark list refactors that only add skip/skipif alongside existing marks.""" - stripped = line.strip().rstrip(",") - if not stripped: - return True - if stripped in {"[", "]"}: - return True - if stripped.startswith("#"): - return True - if stripped.startswith("pytestmark"): - return True - return _PYTEST_MARK_ONLY_RE.search(stripped) is not None - - -def diff_only_contains_skip_mark_changes(diff_text: str) -> bool: - """True when diff only edits skip marks or reformats pytestmark to add them.""" - pending_depth = 0 - saw_change = False - has_skip_mark_edit = False - for raw_line in diff_text.splitlines(): - if raw_line.startswith("@@"): - pending_depth = 0 - continue - if not (raw_line.startswith("+") or raw_line.startswith("-")): - continue - if raw_line.startswith("+++") or raw_line.startswith("---"): - continue - - saw_change = True - content = raw_line[1:] - - if pending_depth > 0: - pending_depth += _paren_balance(content) - if pending_depth < 0: - pending_depth = 0 - continue - - if _is_skip_mark_related_content(content): - has_skip_mark_edit = True - pending_depth = max(0, _paren_balance(content)) - continue - - if _is_pytestmark_adjacent_content(content): - continue - - return False - - return saw_change and has_skip_mark_edit - - -def _git_file_diff(file_path: str, diff_range: str) -> str | None: - result = _git("diff", diff_range, "--", file_path) - if result.returncode != 0: - _log(f"skip-mark-only: git diff failed for {file_path}") - return None - return result.stdout - - -def _is_new_file_diff(diff_text: str) -> bool: - return "new file mode" in diff_text or "--- /dev/null" in diff_text - - -def _file_has_module_level_pytest_skip(content: str) -> bool: - """True when ``pytestmark`` applies unconditional ``pytest.mark.skip`` to the module.""" - match = re.search(r"^pytestmark\s*=\s*\[(.*?)^\s*\]", content, re.MULTILINE | re.DOTALL) - if match is not None: - return _PYTESTMARK_SKIP_RE.search(match.group(1)) is not None - match = re.search(r"^pytestmark\s*=\s*pytest\.mark\.skip\b", content, re.MULTILINE) - return match is not None - - -def _file_content_at_commit(file_path: str, commit: str) -> str | None: - result = _git("show", f"{commit}:{file_path}") - if result.returncode != 0: - _log(f"skip-mark-only: cannot read {file_path} at {commit}") - return None - return result.stdout - - -def _qualifies_as_skip_mark_test_change(file_path: str, diff_range: str) -> bool: - diff_text = _git_file_diff(file_path, diff_range) - if diff_text is None: - return False - if not diff_text.strip(): - _log(f"skip-mark-only: empty diff for {file_path}") - return False - - if diff_only_contains_skip_mark_changes(diff_text): - return True - - if not _is_new_file_diff(diff_text): - _log(f"skip-mark-only: non-skip changes in {file_path}") - return False - - commit = os.environ.get("BUILDKITE_COMMIT", "") - if not commit: - return False - content = _file_content_at_commit(file_path, commit) - if content is None: - return False - if _file_has_module_level_pytest_skip(content): - _log(f"skip-mark-only: new test file with module-level pytest.mark.skip: {file_path}") - return True - - _log(f"skip-mark-only: new test file without module-level pytest.mark.skip: {file_path}") - return False - - -def is_skip_mark_only_change(changed_files: list[str], *, diff_range: str) -> bool: - """True when every changed file is a tests/ module with only skip-mark edits.""" - has_any = False - for file_path in changed_files: - if not file_path: - continue - has_any = True - if not _is_test_python_file(file_path): - return False - if not _qualifies_as_skip_mark_test_change(file_path, diff_range): - return False - return has_any - - -def is_docs_or_skip_mark_only_change(changed_files: list[str], *, diff_range: str) -> bool: - """True when each changed file is a doc path or a qualifying skip-mark test change.""" - has_any = False - for file_path in changed_files: - if not file_path: - continue - has_any = True - if _is_doc_file(file_path): - continue - if _is_test_python_file(file_path) and _qualifies_as_skip_mark_test_change(file_path, diff_range): - continue - _log(f"docs/skip-mark-only: rejecting {file_path}") - return False - return has_any - - -def resolve_skip_ci(changed_files: list[str] | None, *, diff_range: str | None = None) -> bool: - if changed_files is None: - _log("skip-ci=0 (could not resolve changed files)") - return False - - if diff_range is not None and is_docs_or_skip_mark_only_change(changed_files, diff_range=diff_range): - if is_docs_only_change(changed_files): - _log("docs-only change detected; skip-ci=1") - elif is_skip_mark_only_change(changed_files, diff_range=diff_range): - _log("pytest skip-mark-only change detected; skip-ci=1") - else: - _log("docs + pytest skip-mark-only change detected; skip-ci=1") - return True - - if diff_range is None and is_docs_only_change(changed_files): - _log("docs-only change detected; skip-ci=1") - return True - - _log("non-doc/non-skip-mark changes detected; skip-ci=0") - return False - - -def render_bootstrap_pipeline(text: str, *, skip_ci: bool) -> str: - if DOC_SEP in text: - _, continuation = text.split(DOC_SEP, 1) - else: - continuation = text - - nightly_only = ( - '(build.pull_request.labels includes "nightly-test") || (build.branch == "main" && build.env("NIGHTLY") == "1")' - ) - nightly_main = 'build.branch == "main" && build.env("NIGHTLY") == "1"' - ready_pr = 'build.branch != "main" && build.pull_request.labels includes "ready"' - merge_main = 'build.branch == "main" && build.env("NIGHTLY") != "1" && build.env("WEEKLY") != "1"' - merge_pr = 'build.branch != "main" && build.pull_request.labels includes "merge-test"' - if skip_ci: - image_if = f"'{nightly_only}'" - ready_if = f"'({nightly_main})'" - merge_if = f"'({nightly_main})'" - else: - image_if = "'true'" - ready_if = f"'(({nightly_main}) || ({ready_pr}))'" - merge_if = f"'(({nightly_main}) || (({merge_main}) || ({merge_pr})))'" - - return ( - continuation.replace("__IMAGE_BUILD_IF__", image_if) - .replace("__UPLOAD_READY_IF__", ready_if) - .replace("__UPLOAD_MERGE_IF__", merge_if) - ) - - -def _matches_dependencies(changed_files: list[str], prefixes: list[str]) -> bool: - for path in changed_files: - for prefix in prefixes: - normalized = prefix.rstrip("/") - if path == normalized or path.startswith(f"{normalized}/"): - return True - return False - - -def _strip_source_file_dependencies(step: dict[str, Any]) -> dict[str, Any]: - return {key: value for key, value in step.items() if key != "source_file_dependencies"} - - -def _step_label(step: dict[str, Any]) -> str: - return str(step.get("group") or step.get("label") or "") - - -def _filter_steps(steps: list[Any], changed_files: list[str]) -> list[Any]: - filtered: list[Any] = [] - for step in steps: - if not isinstance(step, dict): - filtered.append(step) - continue - - deps = step.get("source_file_dependencies") - if deps is not None and not isinstance(deps, list): - raise ValueError( - f"source_file_dependencies must be a list in step {_step_label(step)!r}", - ) - if deps is not None and not _matches_dependencies(changed_files, deps): - _log( - f"skip {_step_label(step)!r} (no changes under {deps})", - ) - continue - - nested = step.get("steps") - if nested is not None: - kept_nested = _filter_steps(nested, changed_files) - if not kept_nested: - _log(f"omit empty group {_step_label(step)!r}") - continue - new_step = _strip_source_file_dependencies(step) - new_step["steps"] = kept_nested - filtered.append(new_step) - continue - - if deps is not None: - filtered.append(_strip_source_file_dependencies(step)) - else: - filtered.append(step) - - return filtered - - -def _is_e2e_group(step: dict[str, Any]) -> bool: - group = step.get("group") - return isinstance(group, str) and E2E_GROUP_MARKER in group - - -def _select_e2e_group_steps(steps: list[Any]) -> list[Any]: - selected = [step for step in steps if isinstance(step, dict) and _is_e2e_group(step)] - if not selected: - _log(f"no group matching {E2E_GROUP_MARKER!r} found") - else: - _log(f"keep {len(selected)} group(s) matching {E2E_GROUP_MARKER!r}") - return selected - - -def _strip_uploader_metadata_from_steps(steps: list[Any]) -> list[Any]: - """Remove uploader-only keys while keeping all steps (no diff filtering).""" - stripped: list[Any] = [] - for step in steps: - if not isinstance(step, dict): - stripped.append(step) - continue - - deps = step.get("source_file_dependencies") - if deps is not None and not isinstance(deps, list): - raise ValueError( - f"source_file_dependencies must be a list in step {_step_label(step)!r}", - ) - - nested = step.get("steps") - new_step = _strip_source_file_dependencies(step) - if nested is not None: - new_step["steps"] = _strip_uploader_metadata_from_steps(nested) - stripped.append(new_step) - - return stripped - - -def render_test_pipeline( - doc: dict[str, Any], - changed_files: list[str] | None, - *, - e2e_only: bool = False, -) -> dict[str, Any]: - steps = doc.get("steps") - if not isinstance(steps, list): - return doc - if e2e_only: - steps = _select_e2e_group_steps(steps) - steps = _strip_uploader_metadata_from_steps(steps) - elif changed_files is not None: - steps = _filter_steps(steps, changed_files) - else: - steps = _strip_uploader_metadata_from_steps(steps) - return {**doc, "steps": steps} - - -def resolve_pipeline_path(arg: str) -> Path: - path = Path(arg) - if path.is_absolute(): - return path - return ROOT / path - - -def render_pipeline( - path: Path, - *, - force_all: bool = False, - e2e_only: bool = False, -) -> str: - text = path.read_text(encoding="utf-8") - diff_range = resolve_diff_range() - changed_files = resolve_changed_files() - - # ``--all`` forces the keep-all-steps path (no diff-aware skipping) while still - # stripping ``source_file_dependencies``. Used by the rebase pipeline so main builds - # run the full e2e suite (see .buildkite/rebase-pipeline.yaml). - if force_all or e2e_only: - changed_files = None - - if BOOTSTRAP_MARKER in text: - rendered = render_bootstrap_pipeline( - text, - skip_ci=resolve_skip_ci(changed_files, diff_range=diff_range), - ) - return rendered - - doc = yaml.safe_load(text) - if not isinstance(doc, dict): - raise ValueError(f"invalid pipeline YAML: {path}") - - doc = render_test_pipeline(doc, changed_files, e2e_only=e2e_only) - - return yaml.safe_dump(doc, sort_keys=False) - - -def upload_to_buildkite(content: str) -> None: - subprocess.run( - ["buildkite-agent", "pipeline", "upload"], - input=content, - text=True, - check=True, - ) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "pipeline", - nargs="?", - default=".buildkite/pipeline.yml", - help="Pipeline YAML path (default: .buildkite/pipeline.yml)", - ) - parser.add_argument( - "--upload", - action="store_true", - help="Pipe rendered YAML to buildkite-agent pipeline upload", - ) - mode = parser.add_mutually_exclusive_group() - mode.add_argument( - "--all", - action="store_true", - help="Keep all steps (disable diff-aware skipping); still strips source_file_dependencies", - ) - mode.add_argument( - "--e2e", - action="store_true", - help="Keep only the E2E Test group; disable diff-aware skipping for those steps", - ) - args = parser.parse_args() - - path = resolve_pipeline_path(args.pipeline) - if not path.is_file(): - _log(f"missing pipeline file: {path}") - return 1 - - rendered = render_pipeline(path, force_all=args.all, e2e_only=args.e2e) - if args.upload: - upload_to_buildkite(rendered) - else: - sys.stdout.write(rendered) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.buildkite/test-amd.yaml b/.buildkite/test-amd.yaml deleted file mode 100644 index 520c1bbae..000000000 --- a/.buildkite/test-amd.yaml +++ /dev/null @@ -1,118 +0,0 @@ -steps: - -- label: "Diffusion Model Test" - timeout_in_minutes: 30 - agent_pool: mi325_2 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export GPU_ARCHS=gfx942 - - pytest -s -v tests/e2e/offline_inference/test_z_image.py - -- label: "Diffusion Images API LoRA E2E" - timeout_in_minutes: 30 - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export GPU_ARCHS=gfx942 - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -s -v tests/diffusion/lora/test_images_generations_lora.py - -- label: "Diffusion Model CPU offloading Test" - timeout_in_minutes: 20 - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export GPU_ARCHS=gfx942 - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -s -v tests/diffusion/offloader/test_diffusion_cpu_offload.py - - pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py - -- label: "Diffusion Cache Backend Test" - timeout_in_minutes: 15 - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export GPU_ARCHS=gfx942 - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -s -v -m 'core_model and cache and diffusion and not distributed_rocm and MI325' - -- label: "Diffusion Sequence Parallelism Test" - timeout_in_minutes: 20 - agent_pool: mi325_2 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export GPU_ARCHS=gfx942 - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -s -v tests/diffusion/distributed/test_sequence_parallel.py -m core_model - -- label: "Diffusion Tensor Parallelism Test" - timeout_in_minutes: 20 - agent_pool: mi325_2 - depends_on: amd-build - commands: - - export GPU_ARCHS=gfx942 - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -s -v tests/diffusion/distributed/test_zimage_parallelism.py - -- label: "Diffusion GPU Worker Test" - timeout_in_minutes: 20 - agent_pool: mi325_2 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - pytest -s -v tests/diffusion/test_diffusion_worker.py - -- label: "Omni Model Test Qwen2-5-Omni" - timeout_in_minutes: 15 - agent_pool: mi325_2 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export GPU_ARCHS=gfx942 - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -s -v tests/e2e/offline_inference/test_qwen2_5_omni_expansion.py - - pytest -s -v tests/engine/test_async_omni_engine_abort.py - -- label: "Omni Model Test Qwen3-Omni" - timeout_in_minutes: 15 - agent_pool: mi325_2 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - export VLLM_ROCM_USE_AITER=0 - - pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py - - pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py - - -- label: "Omni Sleep Mode Test" - timeout_in_minutes: 40 - agent_pool: mi325_2 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export GPU_ARCHS=gfx942 - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -s -v tests/entrypoints/test_omni_sleep_mode.py -m "advanced_model and omni and MI325" --run-level "advanced_model" diff --git a/.buildkite/test-merge.yml b/.buildkite/test-merge.yml deleted file mode 100644 index 51c4223f2..000000000 --- a/.buildkite/test-merge.yml +++ /dev/null @@ -1,645 +0,0 @@ -env: - VLLM_WORKER_MULTIPROC_METHOD: spawn - VLLM_USE_FLASHINFER_MOE_FP16: 0 - HF_HUB_DOWNLOAD_TIMEOUT: 300 - HF_HUB_ETAG_TIMEOUT: 60 - -steps: - - group: ":card_index_dividers: Simple Test" - depends_on: upload-merge-pipeline - steps: - - label: "Simple · Diffusion & Model Executor Test" - commands: - - "timeout 20m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Simple · Other Test" - commands: - - "timeout 20m pytest -sv -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - group: ":card_index_dividers: Diffusion Test" - depends_on: upload-merge-pipeline - steps: - - label: "Diffusion · LoRA Test" - timeout_in_minutes: 30 - commands: - - pytest -sv tests/diffusion/lora/ -m 'advanced_model and cuda' --run-level "advanced_model" - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Distributed Test" - depends_on: upload-merge-pipeline - timeout_in_minutes: 60 - commands: - - | - timeout 55m bash -c " - set -e - export VLLM_IMAGE_FETCH_TIMEOUT=60 - pytest -s -v tests/distributed -m 'advanced_model and cuda and H100' --run-level 'advanced_model' - " - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - group: ":card_index_dividers: Entrypoints Test" - depends_on: upload-merge-pipeline - steps: - - label: "Entrypoint Test with H100" - timeout_in_minutes: 60 - commands: - - pytest -s -v tests/entrypoints/ -m "advanced_model and cuda and H100" --run-level "advanced_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Entrypoint Test with L4" - timeout_in_minutes: 30 - commands: - - pytest -s -v tests/entrypoints/ -m "advanced_model and cuda and L4" --run-level "advanced_model" - agents: - queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - group: ":card_index_dividers: E2E Test" - depends_on: upload-merge-pipeline - steps: - - label: "TTS · Qwen3-TTS CustomVoice Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen3_tts_customvoice.py - - tests/e2e/offline_inference/test_qwen3_tts_customvoice.py - - vllm_omni/model_executor/models/qwen3_tts/ - - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py - - vllm_omni/deploy/qwen3_tts.yaml - commands: - - | - timeout 40m bash -c " - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 - pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py tests/e2e/offline_inference/test_qwen3_tts_customvoice.py -m 'advanced_model and cuda' --run-level 'advanced_model' - " - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "TTS · Qwen3-TTS Base Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen3_tts_base.py - - tests/e2e/offline_inference/test_qwen3_tts_base.py - - vllm_omni/model_executor/models/qwen3_tts/ - - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py - - vllm_omni/deploy/qwen3_tts.yaml - commands: - - | - timeout 40m bash -c " - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 - pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m 'advanced_model and cuda' --run-level 'advanced_model' - " - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "TTS · Higgs-Audio-V3 Test" - source_file_dependencies: - - tests/e2e/online_serving/test_higgs_audio_v3.py - - vllm_omni/model_executor/models/higgs_audio_v3/ - - vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py - - vllm_omni/deploy/higgs_multimodal_qwen3.yaml - commands: - - | - timeout 20m bash -c " - export VLLM_WORKER_MULTIPROC_METHOD=spawn - export VLLM_USE_DEEP_GEMM=0 - export VLLM_MOE_USE_DEEP_GEMM=0 - pytest -s -v tests/e2e/online_serving/test_higgs_audio_v3.py -m 'advanced_model' --run-level 'advanced_model' - " - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "TTS · IndexTTS2 Test" - source_file_dependencies: - - tests/e2e/online_serving/test_indextts2.py - - tests/e2e/offline_inference/test_indextts2.py - - tests/model_executor/models/indextts2/ - - tests/model_executor/stage_input_processors/test_indextts2.py - - tests/assets/indextts2/ - - vllm_omni/model_executor/models/indextts2/ - - vllm_omni/model_executor/stage_input_processors/indextts2.py - - vllm_omni/deploy/indextts2.yaml - - vllm_omni/deploy/indextts2_low_latency.yaml - - vllm_omni/entrypoints/openai/serving_speech.py - - vllm_omni/entrypoints/openai/tts_adapters/indextts2.py - - vllm_omni/config/pipeline_registry.py - - vllm_omni/model_executor/models/registry.py - timeout_in_minutes: 60 - commands: - - | - timeout 45m bash -c " - export VLLM_LOGGING_LEVEL=DEBUG - pytest -s -v tests/e2e/online_serving/test_indextts2.py tests/e2e/offline_inference/test_indextts2.py -m 'advanced_model and cuda' --run-level 'advanced_model' - " - agents: - queue: "gpu_4_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - gpus: all - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Omni · Qwen3-Omni Test" - source_file_dependencies: - - tests/e2e/offline_inference/test_qwen3_omni.py - - tests/e2e/online_serving/test_qwen3_omni.py - - vllm_omni/model_executor/models/qwen3_omni/ - - vllm_omni/model_executor/stage_input_processors/qwen3_omni.py - - vllm_omni/deploy/qwen3_omni_moe.yaml - timeout_in_minutes: 50 - commands: - - pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py -m 'advanced_model and cuda' --run-level 'advanced_model' - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "TTS · VoxCPM2 Test" - source_file_dependencies: - - tests/e2e/offline_inference/test_voxcpm2_tts.py - - tests/e2e/online_serving/test_voxcpm2_tts.py - - vllm_omni/model_executor/models/voxcpm2/ - - vllm_omni/deploy/voxcpm2.yaml - timeout_in_minutes: 30 - commands: - - | - timeout 30m bash -c " - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -s -v tests/e2e/offline_inference/test_voxcpm2_tts.py tests/e2e/online_serving/test_voxcpm2_tts.py -m 'advanced_model and cuda' --run-level 'advanced_model' - " - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "TTS · SoulX-Singer Test" - source_file_dependencies: - - tests/e2e/offline_inference/test_soulxsinger.py - - vllm_omni/diffusion/models/soulx_singer/ - - vllm_omni/deploy/soulxsinger_svc.yaml - - vllm_omni/deploy/soulxsinger_svs.yaml - timeout_in_minutes: 30 - commands: - - | - timeout 30m bash -c " - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -s -v tests/e2e/offline_inference/test_soulxsinger.py -k 'svc or precomputed' -m 'advanced_model and cuda' --run-level 'advanced_model' - " - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Diffusion · Qwen Image Edit Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen_image_edit.py - - vllm_omni/diffusion/models/qwen_image/ - timeout_in_minutes: 20 - commands: - - pytest -s -v tests/e2e/online_serving/test_qwen_image_edit.py -m 'advanced_model and cuda' --run-level 'advanced_model' - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Diffusion · Bagel Test" - source_file_dependencies: - - tests/e2e/offline_inference/test_bagel.py - - tests/e2e/online_serving/test_bagel.py - - vllm_omni/model_executor/models/bagel/ - - vllm_omni/diffusion/models/bagel/ - - vllm_omni/model_executor/stage_input_processors/bagel.py - - vllm_omni/deploy/bagel.yaml - timeout_in_minutes: 20 - commands: - - pytest -s -v tests/e2e/offline_inference/test_bagel.py tests/e2e/online_serving/test_bagel.py -m 'advanced_model and cuda' --run-level 'advanced_model' - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - - label: "Diffusion · Qwen Image Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen_image.py - - vllm_omni/diffusion/models/qwen_image/ - commands: - - | - timeout 40m bash -c ' - export VLLM_IMAGE_FETCH_TIMEOUT=60 - pytest -s -v tests/e2e/online_serving/test_qwen_image.py -m 'advanced_model and cuda' --run-level 'advanced_model' - ' - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Diffusion · Qwen Image Layered Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen_image_layered.py - - vllm_omni/diffusion/models/qwen_image/ - commands: - - | - timeout 40m bash -c " - export VLLM_IMAGE_FETCH_TIMEOUT=60 - pytest -s -v tests/e2e/online_serving/test_qwen_image_layered.py -m 'advanced_model and cuda' --run-level 'advanced_model' - " - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Diffusion · Wan22 Test" - source_file_dependencies: - - tests/e2e/offline_inference/test_wan22_t2v.py - - tests/e2e/online_serving/test_wan22_t2v.py - - vllm_omni/diffusion/models/wan2_2/ - commands: - - | - timeout 40m bash -c " - export VLLM_IMAGE_FETCH_TIMEOUT=60 - pytest -s -v tests/e2e/offline_inference/test_wan22_t2v.py tests/e2e/online_serving/test_wan22_t2v.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' - " - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Diffusion · Z Image Test" - source_file_dependencies: - - tests/e2e/offline_inference/test_z_image.py - - tests/e2e/online_serving/test_z_image.py - - vllm_omni/diffusion/models/z_image/ - timeout_in_minutes: 30 - commands: - - pytest -s -v tests/e2e/offline_inference/test_z_image.py tests/e2e/online_serving/test_z_image.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Diffusion · FLUX.2 Klein Test" - source_file_dependencies: - - tests/e2e/offline_inference/test_flux2_klein.py - - vllm_omni/diffusion/models/flux2_klein/ - timeout_in_minutes: 30 - commands: - - pytest -s -v tests/e2e/offline_inference/test_flux2_klein.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" diff --git a/.buildkite/test-nightly.yml b/.buildkite/test-nightly.yml deleted file mode 100644 index 783d0ab1f..000000000 --- a/.buildkite/test-nightly.yml +++ /dev/null @@ -1,1673 +0,0 @@ -env: - VLLM_WORKER_MULTIPROC_METHOD: spawn - VLLM_USE_FLASHINFER_MOE_FP16: 0 - HF_HUB_DOWNLOAD_TIMEOUT: 300 - HF_HUB_ETAG_TIMEOUT: 60 - -steps: - # Group: collapses under one heading in the Buildkite UI; child steps still run in parallel. - - group: ":card_index_dividers: Omni Model Test" - key: nightly-omni-test-group - depends_on: upload-nightly-pipeline - if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "omni-test" - steps: - - label: ":full_moon: Omni · Function Test with H100" - timeout_in_minutes: 120 - commands: - - pytest -sv tests/e2e --run-level "full_model" -m "full_model and H100 and omni" --ignore=tests/e2e/accuracy --ignore=tests/e2e/online_serving/test_qwen3_omni_multi_replicas.py - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Omni · Doc Test with L4" - timeout_in_minutes: 90 - commands: - - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" - - pytest -s -v tests/examples/ -m "full_model and omni and L4" --run-level "full_model" - agents: - queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: ":full_moon: Omni · Doc Test with H100" - timeout_in_minutes: 90 - commands: - - pytest -s -v tests/examples/ -m "full_model and omni and H100" --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Omni · 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/qwen3_omni/test_qwen3_omni.py -m "full_model" --run-level full_model - EXIT=$$? - buildkite-agent artifact upload "tests/e2e/accuracy/qwen3_omni/results/qwen_omni_acc/*.json" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Omni · Perf Test · No Async Chunk" - key: nightly-omni-performance-no-async-chunk - timeout_in_minutes: 300 - 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_qwen3_omni_no_async_chunk.json - EXIT=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/*.json" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Omni · Perf Test · Async Chunk" - key: nightly-omni-performance-async-chunk - 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_qwen3_omni_async_chunk.json - EXIT=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/*.json" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Omni · Perf Test · vLLM Text" - key: nightly-omni-performance-vllm-text - 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_qwen3_omni_vllm_text.json - EXIT=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/*.json" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Omni · Perf Test · Multi-Replica" - key: nightly-omni-performance-multi-replicas - 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_qwen3_omni_multi_replicas.json - EXIT=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/*.json" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 3 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - - label: ":full_moon: Omni · Multi-Replica Startup Test with 4x H100" - timeout_in_minutes: 45 - commands: - - pytest -s -v tests/e2e/online_serving/test_qwen3_omni_multi_replicas.py -m "full_model" --run-level "core_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 4 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - group: ":card_index_dividers: TTS Model Test" - key: nightly-tts-test-group - depends_on: upload-nightly-pipeline - if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "tts-test" - steps: - - label: ":full_moon: TTS · Function Test with L4" - timeout_in_minutes: 120 - commands: - - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" - - export VLLM_USE_DEEP_GEMM="0" - - export VLLM_MOE_USE_DEEP_GEMM="0" - - pytest -s -v tests/e2e/ -m "full_model and L4 and tts" --run-level "full_model" --ignore=tests/e2e/accuracy --ignore=tests/e2e/online_serving/test_voxcpm2_tts_expansion.py - agents: - queue: "gpu_1_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - # - label: ":full_moon: TTS · Function Test with H100" - # timeout_in_minutes: 90 - # commands: - # - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" - # - pytest -s -v tests/e2e/ -m "full_model and H100 and tts" --run-level "full_model" --ignore=tests/e2e/accuracy - # agents: - # queue: "mithril-h100-pool" - # plugins: - # - kubernetes: - # podSpec: - # containers: - # - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - # resources: - # limits: - # nvidia.com/gpu: 1 - # volumeMounts: - # - name: devshm - # mountPath: /dev/shm - # - name: hf-cache - # mountPath: /root/.cache/huggingface - # env: - # - name: HF_HOME - # value: /root/.cache/huggingface - # - name: HF_TOKEN - # valueFrom: - # secretKeyRef: - # name: hf-token-secret - # key: token - # nodeSelector: - # node.kubernetes.io/instance-type: gpu-h100-sxm - # volumes: - # - name: devshm - # emptyDir: - # medium: Memory - # - name: hf-cache - # hostPath: - # path: /mnt/hf-cache - # type: DirectoryOrCreate - - - label: ":full_moon: TTS · Accuracy Test · VoxCPM2" - timeout_in_minutes: 30 - commands: - - | - timeout 30m bash -c " - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_WORKER_MULTIPROC_METHOD=spawn - export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 - pytest -s -v tests/e2e/online_serving/test_voxcpm2_tts_expansion.py -m 'full_model and cuda and tts' --run-level 'full_model' - " - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: ":full_moon: TTS · Perf Test" - key: nightly-tts-performance - timeout_in_minutes: 180 - commands: - - export BENCHMARK_DIR=tests/dfx/perf/results - - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" - - | - set +e - pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_tts.json - EXIT=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/*.json" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: TTS · VoxCPM2 · Perf Test" - key: nightly-tts-performance-voxcpm2 - timeout_in_minutes: 180 - commands: - - export BENCHMARK_DIR=tests/dfx/perf/results - - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" - - | - set +e - pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_voxcpm2.json - EXIT=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/*.json" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: TTS · Higgs-Audio-V3 · Perf Test" - key: nightly-tts-performance-higgs-audio-v3 - timeout_in_minutes: 180 - commands: - - export BENCHMARK_DIR=tests/dfx/perf/results - - export VLLM_USE_DEEP_GEMM="0" - - export VLLM_MOE_USE_DEEP_GEMM="0" - - | - set +e - pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_higgs_audio_v3.json - EXIT=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/*.json" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - group: ":card_index_dividers: Tiny Model Tests [Multi-GPU]" - key: nightly-tiny-model-test-group - depends_on: upload-nightly-pipeline - if: >- - build.env("NIGHTLY") == "1" || - build.pull_request.labels includes "nightly-test" - steps: - - label: ":full_moon: Tiny Model Tests · Diffusion (Multi-GPU accelerations)" - timeout_in_minutes: 30 - # NOTE: Single-GPU (core_model) tiny model tests run on the ready label, - # so nightly only adds the multi-GPU parallelism configurations. - # - # This is a bit confusing, but `full_model` is used to mark the multigpu tests, and - # --run-level `core_model` is used to indicate that we should use tiny model weights - # for the CI, since these tests should pass irrespective of whether or not we use - # the full weights or not. - commands: - - timeout 25m pytest -sv tests/model_tests/diffusion -m 'full_model and cuda' --run-level "core_model" - agents: - queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - # Diffusion X2I suite: x2i / x2a / x2t and related non-video paths; x2v is only in "Diffusion X2V Model Test" below. - - group: ":card_index_dividers: Diffusion X2I(&A&T) Model Test" - key: nightly-diffusion-x2iat-group - depends_on: upload-nightly-pipeline - if: >- - build.env("NIGHTLY") == "1" || - build.pull_request.labels includes "nightly-test" || - build.pull_request.labels includes "diffusion-x2iat-test" - steps: - # Use explicit file shards so single-card cases do not reserve 2+ GPUs, - # and Bagel feature coverage is not duplicated via a broad tests/e2e sweep. - - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with H100 · Single-GPU" - timeout_in_minutes: 120 - commands: - - >- - pytest -sv - tests/e2e/online_serving/test_qwen_image_expansion.py - tests/e2e/online_serving/test_qwen_image_edit_expansion.py - tests/e2e/online_serving/test_qwen_image_layered_expansion.py - tests/e2e/online_serving/test_bagel_expansion.py - tests/e2e/online_serving/test_krea2_expansion.py - tests/e2e/offline_inference/test_krea2_expansion.py - -m "full_model and diffusion and H100 and not distributed_cuda" - --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with H100 · Multi-GPU Qwen-Image" - timeout_in_minutes: 120 - commands: - - >- - pytest -sv - tests/e2e/online_serving/test_qwen_image_expansion.py - tests/e2e/online_serving/test_qwen_image_edit_expansion.py - tests/e2e/offline_inference/test_krea2_expansion.py - -m "full_model and diffusion and H100 and distributed_cuda" - --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with H100 · Multi-GPU Layered/LongCat/FLUX" - timeout_in_minutes: 90 - commands: - - >- - pytest -sv - tests/e2e/online_serving/test_qwen_image_layered_expansion.py - -m "full_model and diffusion and H100 and distributed_cuda" - --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with H100 · Multi-GPU Bagel" - timeout_in_minutes: 120 - commands: - - >- - pytest -sv - tests/e2e/online_serving/test_bagel_expansion.py - -m "full_model and diffusion and H100 and distributed_cuda" - --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2I(&A&T) · GLM-Image Function Test with H100" - timeout_in_minutes: 120 - commands: - - >- - pytest -sv - tests/e2e/online_serving/test_glm_image_expansion.py - tests/e2e/offline_inference/test_glm_image_autoround_w4a16_expansion.py - -m "full_model and diffusion and H100" - --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - - label: ":full_moon: Diffusion X2I(&A&T) · Function Test with L4" - timeout_in_minutes: 120 - commands: - - pytest -sv tests/e2e/ -k "not test_wan and not test_bagel_expansion and not hunyuan" -m "full_model and diffusion and L4" --run-level "full_model" --ignore=tests/e2e/accuracy - agents: - queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: ":full_moon: Diffusion X2I(&A&T) · Doc Test" - timeout_in_minutes: 60 - commands: - - pytest -s -v tests/examples/*/test_text_to_image.py -m "full_model and example and H100" --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2I(&A&T) · Accuracy Test" - timeout_in_minutes: 180 - commands: - - pytest -s -v tests/e2e/accuracy/test_qwen_image*.py --run-level full_model - - pytest -s -v tests/e2e/accuracy/test_diffusers_backend_similarity.py -k '2i_matches_diffusers' -m full_model --run-level full_model - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: VLLM_HTTP_TIMEOUT_KEEP_ALIVE - value: "120" - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: HunyuanImage3-DIT · Accuracy Test" - timeout_in_minutes: 180 - commands: - - export HUNYUAN_IMAGE3_MODEL="tencent/HunyuanImage-3.0-Instruct" - - export HUNYUAN_IMAGE3_DEVICES="0,1,2,3" - - pytest -s -v tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py -m full_model --run-level full_model - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 4 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image" - key: nightly-diffusion-x2iat-performance-qwen-image - timeout_in_minutes: 180 - commands: - - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results - - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN - - export CACHE_DIT_VERSION=1.3.0 - - | - set +e - pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json - EXIT=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" - buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 4 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image-Edit" - key: nightly-diffusion-x2iat-performance-qwen-image-edit - timeout_in_minutes: 180 - commands: - - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results - - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN - - export CACHE_DIT_VERSION=1.3.0 - - | - set +e - pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_edit_vllm_omni.json - EXIT=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" - buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 4 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image-Edit-2511" - key: nightly-diffusion-x2iat-performance-qwen-image-edit-2511 - timeout_in_minutes: 180 - commands: - - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results - - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN - - export CACHE_DIT_VERSION=1.3.0 - - | - set +e - pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_edit_2511_vllm_omni.json - EXIT=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" - buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 4 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · Qwen-Image-Layered" - key: nightly-diffusion-x2iat-performance-qwen-image-layered - timeout_in_minutes: 120 - commands: - - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results - - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN - - export CACHE_DIT_VERSION=1.3.0 - - | - set +e - pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json - EXIT=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" - buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · BAGEL" - key: nightly-diffusion-x2iat-performance-bagel - timeout_in_minutes: 180 - commands: - - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results - - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN - - export CACHE_DIT_VERSION=1.3.0 - - | - set +e - pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_bagel_vllm_omni.json - EXIT=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" - buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" - exit $$EXIT - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - # Diffusion x2v only (Wan, HunyuanVideo, …). x2i/x2a/x2t live in the X2I group above, not here. - - group: ":card_index_dividers: Diffusion X2V Model Test" - key: nightly-diffusion-x2v-group - depends_on: upload-nightly-pipeline - if: >- - build.env("NIGHTLY") == "1" || - build.pull_request.labels includes "nightly-test" || - build.pull_request.labels includes "diffusion-x2v-test" - steps: - - label: ":full_moon: Diffusion X2V · Wan2.2 T2V Function Test" - timeout_in_minutes: 90 - commands: - - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k "t2v" -m "full_model and cuda" --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2V · Wan2.2 I2V/TI2V Function Test" - timeout_in_minutes: 90 - commands: - - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k "i2v" -m "full_model and cuda" --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2V · Doc Test" - timeout_in_minutes: 60 - commands: - - pytest -s -v tests/examples/offline_inference/test_image_to_video.py -m "full_model and example and H100" --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2V · Other Function Test" - timeout_in_minutes: 90 - commands: - - pytest -s -v tests/e2e/offline_inference/test_wan22_autoround_w4a16_expansion.py -m "full_model and cuda" --run-level "full_model" - - pytest -s -v tests/e2e/online_serving/test_wan_2_1_vace_expansion.py tests/e2e/online_serving/test_ltx2_expansion.py -m "full_model and cuda" --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2V · Wan2.2 Accuracy Test" - timeout_in_minutes: 180 - commands: - - pytest -s -v tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py -m full_model --run-level full_model - - pytest -s -v tests/e2e/accuracy/test_diffusers_backend_similarity.py -k '2v_matches_diffusers' -m full_model --run-level full_model - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2V · Other Accuracy Test" - timeout_in_minutes: 180 - commands: - - pytest -s -v tests/e2e/accuracy/hunyuanvideo15_t2v/test_hunyuanvideo15_t2v_video_similarity.py -m full_model --run-level full_model - - pytest -s -v tests/e2e/accuracy/hunyuanvideo15_i2v/test_hunyuanvideo15_i2v_video_similarity.py -m full_model --run-level full_model - - pytest -s -v tests/e2e/accuracy/test_ltx2_3_video_similarity.py -m full_model --run-level full_model - - pytest -s -v tests/e2e/accuracy/test_video_streaming_output_similarity.py -m full_model --run-level full_model - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Diffusion X2V · Perf Test" - key: nightly-diffusion-x2v-performance - timeout_in_minutes: 180 - commands: - - export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results - - export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN - - | - set +e - pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json - EXIT1=$$? - pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_cosmos3_vllm_omni.json - EXIT2=$$? - buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" - buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" - if [ $$EXIT1 -ne 0 ]; then exit $$EXIT1; fi - exit $$EXIT2 - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - group: ":card_index_dividers: Diffusion · Quantization Test" - depends_on: upload-nightly-pipeline - if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "diffusion-x2iat-test" - steps: - - label: ":full_moon: Quantization Test with H100" - commands: - - timeout 60m pytest -sv tests/diffusion/quantization -m 'full_model and cuda and H100' --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: ":full_moon: Quantization Test with L4" - timeout_in_minutes: 60 - commands: - - pytest -sv tests/diffusion/quantization -m 'full_model and cuda and L4' --run-level "full_model" - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: ":bar_chart: Testcase Statistics" - key: nightly-testcase-statistics - timeout_in_minutes: 120 - depends_on: upload-nightly-pipeline - if: build.env("NIGHTLY") == "1" - commands: - - python tools/nightly/buildkite_testcase_statistics.py -o tests/dfx/perf/results/buildkite_testcase_statistics.html - - buildkite-agent artifact upload "tests/dfx/perf/results/*.html" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - # No need to run this step for PRs with label nightly-test - - label: ":email: Nightly Collection & Email" - key: nightly-perf-distribution - depends_on: - - nightly-omni-performance-no-async-chunk - - nightly-omni-performance-async-chunk - - nightly-omni-performance-vllm-text - - nightly-omni-performance-multi-replicas - - nightly-tts-performance - - nightly-tts-performance-higgs-audio-v3 - - nightly-diffusion-x2iat-performance-qwen-image - - nightly-diffusion-x2iat-performance-qwen-image-edit - - nightly-diffusion-x2iat-performance-qwen-image-edit-2511 - - nightly-diffusion-x2iat-performance-qwen-image-layered - - nightly-diffusion-x2iat-performance-bagel - - nightly-diffusion-x2v-performance - - nightly-testcase-statistics - if: build.env("NIGHTLY") == "1" && build.env("EMAIL_DISTRIBUTION") == "1" - commands: - - pip install openpyxl - - export DEFAULT_INPUT_DIR=tests/dfx/perf/results - - export DEFAULT_OUTPUT_DIR=tests/dfx/perf/results - - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-tts-performance - - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-tts-performance-higgs-audio-v3 - - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance-no-async-chunk - - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance-async-chunk - - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance-vllm-text - - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-omni-performance-multi-replicas - - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image - - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image-edit - - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image-edit-2511 - - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-qwen-image-layered - - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2iat-performance-bagel - - buildkite-agent artifact download "tests/dfx/perf/results/*.json" . --step nightly-diffusion-x2v-performance - - buildkite-agent artifact download "tests/dfx/perf/results/*.html" . --step nightly-testcase-statistics - - python tools/nightly/generate_nightly_perf_excel.py - - python tools/nightly/generate_nightly_perf_html.py - - python tools/nightly/send_nightly_email.py --report-file "tests/dfx/perf/results/*.xlsx, tests/dfx/perf/results/*.html" - - buildkite-agent artifact upload "tests/dfx/perf/results/*.xlsx" - - buildkite-agent artifact upload "tests/dfx/perf/results/*.html" - agents: - queue: "cpu_queue_premerge" diff --git a/.buildkite/test-npu-nightly.yaml b/.buildkite/test-npu-nightly.yaml deleted file mode 100644 index 2c2372f4c..000000000 --- a/.buildkite/test-npu-nightly.yaml +++ /dev/null @@ -1,24 +0,0 @@ -steps: - - group: ":card_index_dividers: Diffusion X2V Model Test" - key: nightly-diffusion-x2v-group - depends_on: upload-nightly-pipeline - if: >- - build.env("NIGHTLY") == "1" || - build.pull_request.labels includes "nightly-test" || - build.pull_request.labels includes "diffusion-x2v-test" - steps: - - label: ":full_moon: Diffusion X2V · Function Test" - agents: - queue: "ascend-a2b3" - resource_class: "npu-4" - image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}" - plugins: - - kubernetes: - podSpecPatch: - imagePullSecrets: - - name: swr-secret - env: - VLLM_WORKER_MULTIPROC_METHOD: spawn - HF_TOKEN: "${HF_TOKEN}" - commands: - - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k npu_i2v --run-level "full_model" diff --git a/.buildkite/test-npu-ready.yaml b/.buildkite/test-npu-ready.yaml deleted file mode 100644 index e1fe20d60..000000000 --- a/.buildkite/test-npu-ready.yaml +++ /dev/null @@ -1,21 +0,0 @@ -steps: - - group: ":card_index_dividers: TTS Model Test" - key: ready-tts-group - depends_on: upload-ready-pipeline - steps: - - label: ":sound: TTS · Qwen3-TTS CustomVoice Test" - agents: - queue: "ascend-a2b3" - resource_class: "npu-1" - image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}" - plugins: - - kubernetes: - podSpecPatch: - imagePullSecrets: - - name: swr-secret - env: - VLLM_WORKER_MULTIPROC_METHOD: spawn - VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" - HF_TOKEN: "${HF_TOKEN}" - commands: - - pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m 'core_model' --run-level 'core_model' diff --git a/.buildkite/test-ready.yml b/.buildkite/test-ready.yml deleted file mode 100644 index c9b60fe2c..000000000 --- a/.buildkite/test-ready.yml +++ /dev/null @@ -1,707 +0,0 @@ -env: - VLLM_WORKER_MULTIPROC_METHOD: spawn - VLLM_USE_FLASHINFER_MOE_FP16: 0 - HF_HUB_DOWNLOAD_TIMEOUT: 300 - HF_HUB_ETAG_TIMEOUT: 60 - -steps: - - group: ":card_index_dividers: Simple Test" - depends_on: upload-ready-pipeline - steps: - - label: "Simple · Diffusion & Model Executor Test" - commands: - - "timeout 20m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Simple · Other Test" - commands: - - "timeout 20m pytest -sv -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Custom Pipeline Test" - depends_on: upload-ready-pipeline - commands: - - timeout 20m pytest -s -v tests/e2e/offline_inference/custom_pipeline/ -m "core_model" - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - group: ":card_index_dividers: Diffusion Test" - depends_on: upload-ready-pipeline - steps: - - label: "Diffusion · Offloader Test" - commands: - - timeout 40m pytest -s -v tests/diffusion/offloader -m "core_model and cuda" --run-level "core_model" - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Diffusion · Cache Test" - commands: - - timeout 15m pytest -sv tests/diffusion/cache -m 'core_model and cuda' --run-level "core_model" - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Diffusion · Batch Test" - commands: - - timeout 15m pytest -sv tests/diffusion/batching -m 'core_model and cuda' --run-level "core_model" - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Diffusion · Distributed Test" - commands: - - timeout 20m pytest -sv tests/diffusion/distributed -m 'core_model and cuda' --run-level "core_model" - agents: - queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Diffusion · Model Test" - commands: - - timeout 15m pytest -sv tests/diffusion/models/ -m 'core_model and cuda' --run-level "core_model" - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Diffusion · Other Test" - commands: - - timeout 15m pytest -sv tests/diffusion/*.py -m 'core_model and cuda' --run-level "core_model" - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - - group: ":card_index_dividers: Tiny Model Tests [Single GPU]" - depends_on: upload-ready-pipeline - steps: - - label: "Tiny Model Tests · Diffusion (base)" - commands: - - timeout 15m pytest -sv tests/model_tests/diffusion -m 'core_model and cuda' --run-level "core_model" - agents: - # Ready label only runs the cases that don't need parallelism for tiny - # model tests, which is usually just a smoke test for TTI and I2I - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Engine&Model Executor Test" - depends_on: upload-ready-pipeline - commands: - - | - timeout 15m bash -c " - pytest -sv tests/engine/ tests/model_executor/ -m 'core_model and cuda' --run-level 'core_model' - " - agents: - queue: "gpu_1_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - - label: "Distributed Test" - depends_on: upload-ready-pipeline - commands: - - | - timeout 20m bash -c " - pytest -sv tests/distributed/ -m 'core_model and cuda and L4' --run-level 'core_model' - " - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Entrypoints Test" - depends_on: upload-ready-pipeline - commands: - - | - timeout 30m bash -c " - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -sv tests/entrypoints/ -m 'core_model and cuda' --run-level 'core_model' - " - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - group: ":card_index_dividers: E2E Test" - depends_on: upload-ready-pipeline - steps: - - label: "RLHF · VeRL-Omni E2E Test" - source_file_dependencies: - - tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py - - tests/e2e/offline_inference/custom_pipeline/ - - vllm_omni/diffusion/ - - vllm_omni/engine/ - - vllm_omni/entrypoints/ - - vllm_omni/inputs/ - - vllm_omni/outputs/ - commands: - - | - timeout 20m bash -c " - pytest -s -v tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py - " - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Omni · Qwen3-Omni Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen3_omni.py - - vllm_omni/model_executor/models/qwen3_omni/ - - vllm_omni/model_executor/stage_input_processors/qwen3_omni.py - - vllm_omni/deploy/qwen3_omni_moe.yaml - commands: - - | - timeout 20m bash -c " - pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m 'core_model' --run-level 'core_model' - " - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "TTS · VoxCPM2 Test" - source_file_dependencies: - - tests/e2e/online_serving/test_voxcpm2_tts.py - - vllm_omni/model_executor/models/voxcpm2/ - - vllm_omni/deploy/voxcpm2.yaml - timeout_in_minutes: 30 - commands: - - | - timeout 30m bash -c " - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -s -v tests/e2e/online_serving/test_voxcpm2_tts.py -m 'core_model' --run-level 'core_model' - " - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "TTS · Qwen3-TTS CustomVoice Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen3_tts_customvoice.py - - vllm_omni/model_executor/models/qwen3_tts/ - - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py - - vllm_omni/deploy/qwen3_tts.yaml - commands: - - | - timeout 20m bash -c " - export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 - pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m 'core_model' --run-level 'core_model' - " - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "TTS · Qwen3-TTS Base Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen3_tts_base.py - - vllm_omni/model_executor/models/qwen3_tts/ - - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py - - vllm_omni/deploy/qwen3_tts.yaml - commands: - - | - timeout 20m bash -c " - export VLLM_WORKER_MULTIPROC_METHOD=spawn - export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 - export VLLM_OMNI_USE_V2_RUNNER=1 - pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m 'core_model' --run-level 'core_model' - " - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "TTS · Higgs-Audio-V3 Test" - source_file_dependencies: - - tests/e2e/online_serving/test_higgs_audio_v3.py - - vllm_omni/model_executor/models/higgs_audio_v3/ - - vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py - - vllm_omni/deploy/higgs_multimodal_qwen3.yaml - commands: - - | - timeout 20m bash -c " - export VLLM_WORKER_MULTIPROC_METHOD=spawn - export VLLM_USE_DEEP_GEMM=0 - export VLLM_MOE_USE_DEEP_GEMM=0 - pytest -s -v tests/e2e/online_serving/test_higgs_audio_v3.py -m 'core_model' --run-level 'core_model' - " - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Diffusion · Qwen Image Edit Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen_image_edit.py - - vllm_omni/diffusion/models/qwen_image/ - commands: - - | - timeout 20m bash -c " - pytest -s -v tests/e2e/online_serving/test_qwen_image_edit.py -m 'core_model' --run-level 'core_model' - " - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Diffusion · Bagel Test" - source_file_dependencies: - - tests/e2e/online_serving/test_bagel.py - - vllm_omni/model_executor/models/bagel/ - - vllm_omni/diffusion/models/bagel/ - - vllm_omni/model_executor/stage_input_processors/bagel.py - - vllm_omni/deploy/bagel.yaml - commands: - - | - timeout 40m bash -c " - export VLLM_IMAGE_FETCH_TIMEOUT=60 - pytest -s -v tests/e2e/online_serving/test_bagel.py -m 'core_model' --run-level 'core_model' - " - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Diffusion · Wan22 Test" - source_file_dependencies: - - tests/e2e/online_serving/test_wan22_t2v.py - - vllm_omni/diffusion/models/wan2_2/ - commands: - - | - timeout 40m bash -c " - export VLLM_IMAGE_FETCH_TIMEOUT=60 - pytest -s -v tests/e2e/online_serving/test_wan22_t2v.py -m 'core_model' --run-level 'core_model' - " - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Diffusion · Cosmos3 Test" - source_file_dependencies: - - tests/e2e/online_serving/test_cosmos3.py - - vllm_omni/diffusion/models/cosmos3/ - commands: - - | - timeout 40m bash -c " - export VLLM_IMAGE_FETCH_TIMEOUT=60 - pytest -s -v tests/e2e/online_serving/test_cosmos3.py -m 'core_model' --run-level 'core_model' - " - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - - label: "Diffusion · Qwen Image Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen_image.py - - vllm_omni/diffusion/models/qwen_image/ - commands: - - | - timeout 40m bash -c " - export VLLM_IMAGE_FETCH_TIMEOUT=60 - pytest -s -v tests/e2e/online_serving/test_qwen_image.py -m 'core_model' --run-level 'core_model' - " - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Diffusion · Qwen Image Layered Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen_image_layered.py - - vllm_omni/diffusion/models/qwen_image/ - commands: - - | - timeout 40m bash -c " - export VLLM_IMAGE_FETCH_TIMEOUT=60 - pytest -s -v tests/e2e/online_serving/test_qwen_image_layered.py -m 'core_model' --run-level 'core_model' - " - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Diffusion · GGUF Plugin Test" - soft_fail: true - source_file_dependencies: - - tests/plugin/test_gguf_diffusion.py - - vllm_omni/diffusion/model_loader - commands: - - pip install "vllm-gguf-plugin>=0.0.3" - - timeout 30m pytest -sv tests/plugin/test_gguf_diffusion.py - agents: - queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" diff --git a/.buildkite/test-weekly.yml b/.buildkite/test-weekly.yml deleted file mode 100644 index d71bdae0a..000000000 --- a/.buildkite/test-weekly.yml +++ /dev/null @@ -1,451 +0,0 @@ -env: - VLLM_WORKER_MULTIPROC_METHOD: spawn - VLLM_USE_FLASHINFER_MOE_FP16: 0 - HF_HUB_DOWNLOAD_TIMEOUT: 300 - HF_HUB_ETAG_TIMEOUT: 60 - -steps: - - label: "Reliability Test - qwen3-omni" - timeout_in_minutes: 180 - depends_on: upload-weekly-pipeline - if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test" - commands: - - pytest -s -v tests/dfx/reliability/test_reliability_qwen3_omni.py -m "slow" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Reliability Test - wan22" - timeout_in_minutes: 180 - depends_on: upload-weekly-pipeline - if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test" - commands: - - pytest -s -v tests/dfx/reliability/test_reliability_wan22.py -m "slow" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Reliability Test - hunyuan-image" - timeout_in_minutes: 180 - depends_on: upload-weekly-pipeline - if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test" - commands: - - pytest -s -v tests/dfx/reliability/test_reliability_hunyuan_image.py -m "slow" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 4 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Reliability Test - voxcpm2" - timeout_in_minutes: 180 - depends_on: upload-weekly-pipeline - if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test" - commands: - - pytest -s -v tests/dfx/reliability/test_reliability_voxcpm2.py -m "slow" - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - # Invalid HTTP / WS payloads vs live Omni servers (Issue #3649 tracks known skips). - - group: "Reliability Test - Invalid parameters Test" - depends_on: upload-weekly-pipeline - if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test" - steps: - - label: "Invalid parameters Test · H100" - timeout_in_minutes: 120 - commands: - - pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and H100" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - # Stable Audio Open, Qwen3-TTS speech/batch/voices paths marked ``L4``. - - label: "Invalid parameters Test · L4" - timeout_in_minutes: 120 - commands: - - pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and L4" - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - group: ":card_index_dividers: E2E Tests" - depends_on: upload-weekly-pipeline - if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test" - steps: - - label: "Omni · L4" - timeout_in_minutes: 90 - commands: - - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" - - pytest -s -v tests/e2e/*/test_qwen2_5_omni_expansion.py --run-level "core_model" - - >- - pytest -s -v tests/e2e/ - --ignore=tests/e2e/online_serving/test_qwen2_5_omni_expansion.py - --ignore=tests/e2e/offline_inference/test_qwen2_5_omni_expansion.py - -m "slow and L4 and omni" - --run-level "full_model" - agents: - queue: "gpu_4_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Omni · H100" - timeout_in_minutes: 90 - commands: - - >- - pytest -sv - tests/e2e/ -m "slow and H100 and omni" - --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "TTS · L4" - timeout_in_minutes: 90 - commands: - - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" - - export VLLM_USE_DEEP_GEMM="0" - - export VLLM_MOE_USE_DEEP_GEMM="0" - - | - failed=0 - for f in $(pytest --collect-only -q tests/e2e/ -m "slow and L4 and tts" --run-level "full_model" | grep '\.py::' | sed -E 's|::.*||' | sort -u); do - pytest -s -v "$$f" -m "slow and L4 and tts" --run-level "full_model" || failed=1 - done - exit $$failed - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "TTS · H100" - timeout_in_minutes: 90 - commands: - - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" - - export VLLM_USE_DEEP_GEMM="0" - - export VLLM_MOE_USE_DEEP_GEMM="0" - - >- - pytest -sv - tests/e2e/ -m "slow and H100 and tts" - --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Diffusion · L4" - timeout_in_minutes: 60 - commands: - - >- - pytest -sv - tests/e2e/ -m "slow and diffusion and L4" - --run-level "full_model" - agents: - queue: "gpu_4_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "Diffusion · H100 · Single-GPU" - timeout_in_minutes: 120 - commands: - - >- - pytest -sv - tests/e2e/ -m "slow and diffusion and H100 and not distributed_cuda" - --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 1 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate - - - label: "Diffusion · H100 · Multi-GPU" - timeout_in_minutes: 90 - commands: - - >- - pytest -sv - tests/e2e/ -m "slow and diffusion and H100 and distributed_cuda" - --run-level "full_model" - agents: - queue: "mithril-h100-pool" - plugins: - - kubernetes: - podSpec: - containers: - - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - resources: - limits: - nvidia.com/gpu: 2 - volumeMounts: - - name: devshm - mountPath: /dev/shm - - name: hf-cache - mountPath: /root/.cache/huggingface - env: - - name: HF_HOME - value: /root/.cache/huggingface - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token-secret - key: token - nodeSelector: - node.kubernetes.io/instance-type: gpu-h100-sxm - volumes: - - name: devshm - emptyDir: - medium: Memory - - name: hf-cache - hostPath: - path: /mnt/hf-cache - type: DirectoryOrCreate diff --git a/.claude/skills/add-diffusion-model/SKILL.md b/.claude/skills/add-diffusion-model/SKILL.md index 906f3c485..bae8cd1b5 100644 --- a/.claude/skills/add-diffusion-model/SKILL.md +++ b/.claude/skills/add-diffusion-model/SKILL.md @@ -309,7 +309,7 @@ For Omni or custom models, create: Required updates: 1. `docs/user_guide/diffusion/parallelism/overview.md` — parallelism support overview/table -2. `docs/user_guide/diffusion/cpu_offload_diffusion.md` — if CPU offload supported (add to supported models table) +2. `docs/user_guide/diffusion/cpu_offload.md` — if CPU offload supported (add to supported models table) 3. `docs/user_guide/diffusion/cache_acceleration/teacache.md` — if TeaCache supported 4. `docs/user_guide/diffusion/cache_acceleration/cache_dit.md` — if Cache-DiT supported 5. Offline example docs under `examples/offline_inference//` (`README.md` or category-specific `.md`) @@ -317,7 +317,7 @@ Required updates: ### Step 8: Add E2E Tests -**Follow the [vllm-omni-test skill](../vllm-omni-test/SKILL.md)** for markers, file naming, Buildkite wiring, and run commands. Also read [l4_functionality_tests.inc.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/test_examples/l4_functionality_tests.inc.md) and [CI_5levels.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/CI_5levels.md). +**Follow the [vllm-omni-test skill](../vllm-omni-test/SKILL.md)** for markers, file naming, Buildkite wiring, and run commands. Also read [l4_functionality_tests.inc.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/test_examples/l4_functionality_tests.inc.md), [test_system_overview.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/test_system_overview.md), and [test_writing_guide.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/test_writing_guide.md). Classify the model's **CI priority** first: @@ -334,7 +334,7 @@ Classify the model's **CI priority** first: | **L1** | `tests/diffusion/models/{slug}/`, `tests/diffusion/cache/`, transformer unit tests | `core_model` + `cpu` | `test-ready.yml` | Weight remap, `_sp_plan`, cache enabler registration, shape contracts | | **L2** | `tests/e2e/online_serving/test_{slug}.py` (and offline if the category is offline-first) | **`core_model` + `advanced_model`** (both on baseline smoke) + `diffusion` + `@hardware_test` / `hardware_marks` | `test-ready.yml` | Default deploy smoke — minimal `num_inference_steps`, single prompt | | **L3** | `tests/e2e/online_serving/test_{slug}.py` **and** `tests/e2e/offline_inference/test_{slug}.py` when offline matters | Baseline smoke: **`core_model` + `advanced_model`**; heavier cases: `advanced_model` only (+ `diffusion`) | `test-merge.yml` **or** merged into nightly diffusion function job | Real weights, streaming/API paths, LoRA/offload smoke | -| **L4** | `tests/e2e/online_serving/test_{slug}_expansion.py` (+ offline expansion if needed) | `full_model` + `diffusion` | `test-nightly.yml` (X2I / X2V / X2A function groups) | Feature combos per [#1832](https://github.com/vllm-project/vllm-omni/issues/1832); perf → `tests/dfx/perf/tests/test_{model}_vllm_omni.json` | +| **L4** | `tests/e2e/online_serving/test_{slug}_expansion.py` (+ offline expansion if needed) | `full_model` + `diffusion` | `test-nightly.yml` (X2I / X2V / X2A function groups) | Feature combos per [#1832](https://github.com/vllm-project/vllm-omni/issues/1832); perf → `tests/dfx/perf/tests/test_{model}_vllm_omni.json` with per-case **`mark`** (`hardware_marks` + `full_model` + `diffusion`) | **L2 & L3 online — same file, dual marks on the baseline smoke:** The **first / simplest** case in `test_{slug}.py` (default deploy, minimal steps, single prompt) should carry **both** `@pytest.mark.core_model` and `@pytest.mark.advanced_model` on the **same** function so L2 (`test-ready.yml`) and L3 (`test-merge.yml`) share one smoke test. Heavier deploy variants or API paths in the same file use **`advanced_model` only**. When L3 moves to nightly, migrate those heavier cases into `test_{slug}_expansion.py` with `full_model` and remove the dedicated `test-merge.yml` job (see `test_longcat_image_expansion.py`, `test_qwen_image_expansion.py`). @@ -418,7 +418,8 @@ def enable_cache_for_your_model(pipeline, cache_config): #### 9d. Register the custom enabler -Add your enabler to `CUSTOM_DIT_ENABLERS` in `vllm_omni/diffusion/cache/cache_dit_backend.py`: +Add your enabler to `CUSTOM_DIT_ENABLERS` in +`vllm_omni/diffusion/cache/cachedit/model_specific.py`: ```python CUSTOM_DIT_ENABLERS = { diff --git a/.claude/skills/add-diffusion-model/references/cache-dit-patterns.md b/.claude/skills/add-diffusion-model/references/cache-dit-patterns.md index d34ce0e0f..2385a70ee 100644 --- a/.claude/skills/add-diffusion-model/references/cache-dit-patterns.md +++ b/.claude/skills/add-diffusion-model/references/cache-dit-patterns.md @@ -29,8 +29,13 @@ vLLM-Omni integrates cache-dit through `CacheDiTBackend`: | `cache_dit.refresh_context()` | Updates cache context when `num_inference_steps` changes | **Source files**: -- `vllm_omni/diffusion/cache/cache_dit_backend.py` — `CacheDiTBackend`, enablers, `CUSTOM_DIT_ENABLERS` -- `vllm_omni/diffusion/cache/` — cache backend implementations +- `vllm_omni/diffusion/cache/cachedit/__init__.py` — public package API +- `vllm_omni/diffusion/cache/cachedit/backend.py` — generic backend lifecycle and integration +- `vllm_omni/diffusion/cache/cachedit/config.py` — Cache-DiT configuration types +- `vllm_omni/diffusion/cache/cachedit/model_specific.py` — model-specific adapters, enablers, and registry + +Outside `vllm_omni/diffusion/cache`, import Cache-DiT symbols only from +`vllm_omni.diffusion.cache.cachedit`. ## Standard Models: Automatic Support @@ -175,7 +180,8 @@ Inspect your block's `forward()` return type and residual connection pattern to ## Registering Custom Enablers -Add your enabler to `CUSTOM_DIT_ENABLERS` in `vllm_omni/diffusion/cache/cache_dit_backend.py`: +Add your enabler to `CUSTOM_DIT_ENABLERS` in +`vllm_omni/diffusion/cache/cachedit/model_specific.py`: ```python CUSTOM_DIT_ENABLERS = { @@ -248,7 +254,7 @@ Models listed in `_NO_CACHE_ACCELERATION` in `vllm_omni/diffusion/registry.py` d | Model | Path | Notes | |-------|------|-------| -| Standard DiT | `cache_dit_backend.py::enable_cache_for_dit` | Default enabler, automatic | -| Wan2.2 | `cache_dit_backend.py::enable_cache_for_wan22` | Dual-transformer, auto-detects mode | -| LongCat | `cache_dit_backend.py::enable_cache_for_longcat_image` | Multi-block-list | -| BAGEL | `cache_dit_backend.py::enable_cache_for_bagel` | Complex omni model | +| Standard DiT | `cachedit.backend::enable_cache_for_dit` | Default enabler, automatic | +| Wan2.2 | `cachedit.model_specific::enable_cache_for_wan22` | Dual-transformer, auto-detects mode | +| LongCat | `cachedit.config::CacheDiTAdapterConfig` | Declarative multi-block-list adapter | +| BAGEL | `cachedit.model_specific::BagelCachedAdapter` | Custom cached adapter | diff --git a/.claude/skills/add-tts-model/SKILL.md b/.claude/skills/add-tts-model/SKILL.md index 260a7e924..dc423825a 100644 --- a/.claude/skills/add-tts-model/SKILL.md +++ b/.claude/skills/add-tts-model/SKILL.md @@ -307,7 +307,7 @@ def build_voice_clone_prompt(ref_audio_path: str, text: str, codec) -> list: ### Test Case Writing (CI Levels) -**Follow the [vllm-omni-test skill](../vllm-omni-test/SKILL.md)** for markers, file naming (`test_{slug}.py` / `test_{slug}_expansion.py`), Buildkite wiring, and copy-paste run commands. Also read [CI_5levels.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/CI_5levels.md) and [tests_style.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/tests_style.md). +**Follow the [vllm-omni-test skill](../vllm-omni-test/SKILL.md)** for markers, file naming (`test_{slug}.py` / `test_{slug}_expansion.py`), Buildkite wiring, and copy-paste run commands. Also read [test_system_overview.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/test_system_overview.md) and [test_writing_guide.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/test_writing_guide.md). Classify the model's **CI priority** first (high / medium / low). High-priority TTS models are typically those on the integration hot path or listed in tracking issues such as [#1832](https://github.com/vllm-project/vllm-omni/issues/1832); medium and low tiers cover the long tail. When unsure, ask the reviewer which tier applies. diff --git a/.claude/skills/vllm-omni-test/SKILL.md b/.claude/skills/vllm-omni-test/SKILL.md index 40a81f4cf..d5102b3f2 100644 --- a/.claude/skills/vllm-omni-test/SKILL.md +++ b/.claude/skills/vllm-omni-test/SKILL.md @@ -9,7 +9,7 @@ description: Generate and run tests for vllm-project/vllm-omni with CI-aligned l Use this skill to generate minimal, stable test cases and run them with the correct marker/level strategy for [vllm-project/vllm-omni](https://github.com/vllm-project/vllm-omni). -**Link convention:** Paths such as `.buildkite/` and `docs/contributing/` live at the **vllm-omni repo root**. Markdown links use repo-relative paths from this skill file (e.g. `../../../.buildkite/test-ready.yml`, `../../../docs/contributing/ci/CI_5levels.md`). +**Link convention:** Paths such as `.buildkite/` and `docs/contributing/` live at the **vllm-omni repo root**. Markdown links use repo-relative paths from this skill file (e.g. `../../../.buildkite/cuda/test-ready.yml`, `../../../docs/contributing/ci/test_system_overview.md`). Default priorities: @@ -96,8 +96,8 @@ Existing references: `tests/e2e/offline_inference/test_qwen2_5_omni.py` (L2-styl |----------|------------------|---------------------------|---------------------------| | **Offline inference e2e** | `tests/e2e/offline_inference/` | **Module (default):** `omni_runner` + `omni_runner_handler`. **Function (isolation only):** `omni_runner_function` + `omni_runner_handler_function`. Diffusion/TTS may use `Omni(...).generate` directly | L2: `core_model` + **one of** `omni` / `tts` / `diffusion`; `@hardware_test(...)` when GPU/NPU is required | | **Online serving e2e** | `tests/e2e/online_serving/` | **Module (default):** `omni_server` + `openai_client`. **Function (isolation only):** `omni_server_function` + `openai_client_function`. Clients: `send_omni_request` (omni), `send_audio_speech_request` (tts), `send_diffusion_request` / `send_video_diffusion_request` / `send_images_generations_request` (diffusion) | Baseline smoke: **`core_model` + `advanced_model`**; heavier paths: `advanced_model` only; L4 expansion: `full_model` | -| **Documentation / runnable examples** | `tests/examples/offline_inference/`, `tests/examples/online_serving/` | **Offline docs (preferred):** extract Python/Bash blocks from the doc README (e.g. `ReadmeSnippet.extract_readme_snippets`), `pytest.mark.parametrize` each snippet, run via `example_runner.run` with a stable `output_subfolder`. **Online docs:** copy client/request scripts into dedicated tests and keep them in sync with the doc page. | Usually **L4**: `advanced_model`, often `example` plus hardware marks matching the nightly docs-example job (see `.buildkite/test-nightly.yml`). Full conventions: [docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md) (introduced in [PR #1910](https://github.com/vllm-project/vllm-omni/pull/1910): naming, output directory layout, skip rules, avoid trimming `num_inference_steps` without a strong CI reason). | -| **Performance / benchmark** | `tests/dfx/perf/tests/*.json` + `run_*_benchmark.py` | JSON or script-driven server + load config; assert explicit metrics / baselines | L4 Perf: `full_model` + `benchmark`; wire `test-nightly.yml` Perf steps | +| **Documentation / runnable examples** | `tests/examples/offline_inference/`, `tests/examples/online_serving/` | **Offline docs (preferred):** extract Python/Bash blocks from the doc README (e.g. `ReadmeSnippet.extract_readme_snippets`), `pytest.mark.parametrize` each snippet, run via `example_runner.run` with a stable `output_subfolder`. **Online docs:** copy client/request scripts into dedicated tests and keep them in sync with the doc page. | Usually **L4**: `advanced_model`, often `example` plus hardware marks matching the nightly docs-example job (see `.buildkite/cuda/test-nightly.yml`). Full conventions: [docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md) (introduced in [PR #1910](https://github.com/vllm-project/vllm-omni/pull/1910): naming, output directory layout, skip rules, avoid trimming `num_inference_steps` without a strong CI reason). | +| **Performance / benchmark** | `tests/dfx/perf/tests/*.json` + `run_*_benchmark.py` | JSON or script-driven server + load config; assert explicit metrics / baselines | L4 Perf: JSON `mark` with `full_model` + `omni`/`tts`/`diffusion`; wire `test-nightly.yml` Perf steps | | **Invalid parameter / negative HTTP validation** | `tests/dfx/reliability/invalid_param_test/` | Live `omni_server` + low-level `send_*_http_request` with `err_code` / `err_message` | `pytest.mark.slow` + `omni` / `tts` / `diffusion` + `@hardware_marks` (`H100` or `L4`); CI in **`test-weekly.yml`** (not ready/merge/nightly) | **If the user’s test plan includes invalid parameter validation / invalid params / negative HTTP / 400 validation:** do **not** add those `test_*` functions to `tests/e2e/online_serving/test_*.py` or `*_expansion.py`. **Move or author them** under `tests/dfx/reliability/invalid_param_test/` in the **endpoint-matching script** (see **Invalid parameter validation** below). Success-path e2e and invalid-param dfx tests must stay in separate modules. @@ -270,7 +270,7 @@ def test_text_to_video_001(omni_server, openai_client) -> None: *Documentation example tests:* follow the **Preferred Test Strategy** in [l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md): dynamic extraction for offline READMEs; explicit copied client code for online pages until extraction is justified; use the documented **naming**, **output directory** (page folder + case id), and **skipping** rules (e.g. Gradio-only scripts). -*Performance tests:* add or extend entries under `tests/perf/` (and JSON configs where the project uses them), with **explicit baselines** and the same marker/run-level pairing as the CI step that will execute them. +*Performance tests:* add or extend entries under `tests/dfx/perf/tests/` (and JSON configs where the project uses them), with **explicit baselines**, **`mark`** on each case (`hardware_marks` + `full_model` + type marker), and the same nightly Perf step pattern as in-tree configs. **3. Cross-cutting rules** @@ -460,7 +460,7 @@ When the user asks for **invalid parameter validation**, **invalid request bodie 4. **Adding a new model** to invalid-param coverage: append a `pytest.param(OmniServerParams(model="...", stage_config_path=..., server_args=...), ...)` entry to the script’s `_PARAMS` list — do **not** create `test_invalid_.py` unless the route is new. -5. **CI — `.buildkite/test-weekly.yml` only** (not `test-ready.yml` / `test-merge.yml` / `test-nightly.yml`): +5. **CI — `.buildkite/cuda/test-weekly.yml` only** (not `test-ready.yml` / `test-merge.yml` / `test-nightly.yml`): | Weekly step | Command | When your cases run | |-------------|---------|---------------------| @@ -559,7 +559,7 @@ L1 tests under `tests/entrypoints/` may keep **minimal** asserts next to the han #### Omni Test Writing Guidance (L1-L4 Layering) -When the goal is a general Omni/multimodal test case, prioritize mapping the test to the correct purpose, directory, and resource assumptions aligned with the layers (see [CI_5levels.md](../../../docs/contributing/ci/CI_5levels.md)): +When the goal is a general Omni/multimodal test case, prioritize mapping the test to the correct purpose, directory, and resource assumptions aligned with the layers (see [test_system_overview.md](../../../docs/contributing/ci/test_system_overview.md) and [test_writing_guide.md](../../../docs/contributing/ci/test_writing_guide.md)): - **L1**: Unit/logic validation on CPU (`core_model and cpu`). Cover input validation, branches, and exception paths (`tests//test_*.py`). **Mock with `mocker` / `monkeypatch` only — not `unittest.mock`.** - **L2**: Basic e2e (online/offline basic scenarios). Prefer dummy/lightweight models to validate the end-to-end request-to-output-structure/streaming chain (typically `tests/e2e/online_serving/` and `tests/e2e/offline_inference/`). @@ -592,27 +592,31 @@ Implementation strategy: **When the user explicitly asks for L4 perf (or full L4 including perf):** 1. **Do not** put throughput/latency assertions inside `test_*_expansion.py` — perf uses the **dfx benchmark harness**, not `omni_server` e2e fixtures. -2. Add or extend a **JSON config** under `tests/dfx/perf/tests/` (mirror in-tree names: `test_qwen_image_vllm_omni.json`, `test_bagel_vllm_omni.json`). -3. Each JSON entry: `test_name`, `server_params` (`model`, `serve_args`), `benchmark_params[]` with explicit **`baseline`** (`throughput_qps`, `latency_mean`, `peak_memory_mb_mean`, …). +2. Add or extend a **JSON config** under `tests/dfx/perf/tests/` (mirror in-tree names: `test_qwen_image_vllm_omni.json`, `test_cosmos3_vllm_omni.json`, `test_qwen3_omni_async_chunk.json`). +3. Each JSON **case**: `test_name`, optional **`mark`** (`hardware_marks` required when present; `marks`: `full_model` + `omni`/`tts`/`diffusion`), `server_params` (`model`, `serve_args` or `stage_config_name`), `benchmark_params[]` with **`name`** and explicit **`baseline`** (`throughput_qps`, `latency_mean`, `peak_memory_mb_mean`, …). Diffusion cases also set `server_type` (e.g. `"vllm-omni"`) and usually `benchmark_endpoint`. 4. Run locally via the matching script (model type → runner): | Model type | Runner script | Example config | |------------|---------------|----------------| | **Diffusion X2I/X2V** | `tests/dfx/perf/scripts/run_diffusion_benchmark.py` | `tests/dfx/perf/tests/test_qwen_image_vllm_omni.json` | | **TTS** | `tests/dfx/perf/scripts/run_benchmark.py` | `tests/dfx/perf/tests/test_tts.json` (shared) or `test_{slug}.json` (dedicated; use when the model must not join the shared nightly matrix before integration — e.g. `test_voxcpm2.json`, `test_coqui_tts.json`) | -| **Omni** | (see existing Omni Perf steps — `run_benchmark.py` / dedicated omni configs in-tree) | `tests/dfx/perf/tests/test_omni*.json` | +| **Omni** | `tests/dfx/perf/scripts/run_benchmark.py` | `test_qwen3_omni_no_async_chunk.json`, `test_qwen3_omni_async_chunk.json`, `test_qwen3_omni_vllm_text.json`, `test_qwen3_omni_multi_replicas.json` | -5. Wire **`test-nightly.yml` · Perf Test** step for that model (separate from Function Test): export `DIFFUSION_BENCHMARK_DIR` / `BENCHMARK_DIR`, run pytest on the script with `--test-config-file`, **upload artifacts** (`buildkite-agent artifact upload`), often **multi-GPU H100** for diffusion perf. +5. Wire **`test-nightly.yml` · Perf Test** step for that model (separate from Function Test): export `DIFFUSION_BENCHMARK_DIR` / `BENCHMARK_DIR`, run pytest on the script with **`--test-config-file`** (nightly perf steps do not use `-m`), **upload artifacts** (`buildkite-agent artifact upload`), often **multi-GPU H100** for diffusion perf. 6. **One benchmark scenario → one `test_name` block** in JSON (same spirit as one `test_*` per function case). Combine server `serve_args` + workload in one entry; use multiple `benchmark_params` rows for size/step sweeps under the same server config. -**Perf local command (diffusion example):** +**Perf local commands:** ```bash cd tests export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN +# Single file (CI-like) pytest -s -v dfx/perf/scripts/run_diffusion_benchmark.py \ --test-config-file dfx/perf/tests/test__vllm_omni.json +# Bulk load + filter by JSON mark +pytest -sv dfx/perf/scripts/run_diffusion_benchmark.py -m "full_model and diffusion and H100" +pytest -sv dfx/perf/scripts/run_benchmark.py -m "full_model and omni and H100" ``` **Clarify in the test plan** which L4 pillars you deliver: `Function only` | `Function + Perf` | `Function + Accuracy + Perf`. @@ -625,10 +629,10 @@ If the test is not already collected by an existing pipeline command (for exampl | Test level | Edit this file | Typical trigger / intent | |------------|----------------|---------------------------| -| **L1** and **L2** | [`.buildkite/test-ready.yml`](../../../.buildkite/test-ready.yml) | PR **ready** label; L1 CPU + L2 GPU/basic e2e (steps labeled **Omni ·**, **TTS ·**, **Diffusion ·**) | -| **L3** | [`.buildkite/test-merge.yml`](../../../.buildkite/test-merge.yml) | Post-merge; `advanced_model` integration per model type | -| **L4** | [`.buildkite/test-nightly.yml`](../../../.buildkite/test-nightly.yml) | Nightly; grouped by model type (see below) | -| **Invalid param / reliability (weekly)** | [`.buildkite/test-weekly.yml`](../../../.buildkite/test-weekly.yml) | Weekly; `tests/dfx/reliability/invalid_param_test/` — **not** L1–L4 e2e pipelines | +| **L1** and **L2** | [`.buildkite/cuda/test-ready.yml`](../../../.buildkite/cuda/test-ready.yml) | PR **ready** label; L1 CPU + L2 GPU/basic e2e (steps labeled **Omni ·**, **TTS ·**, **Diffusion ·**) | +| **L3** | [`.buildkite/cuda/test-merge.yml`](../../../.buildkite/cuda/test-merge.yml) | Post-merge; `advanced_model` integration per model type | +| **L4** | [`.buildkite/cuda/test-nightly.yml`](../../../.buildkite/cuda/test-nightly.yml) | Nightly; grouped by model type (see below) | +| **Invalid param / reliability (weekly)** | [`.buildkite/cuda/test-weekly.yml`](../../../.buildkite/cuda/test-weekly.yml) | Weekly; `tests/dfx/reliability/invalid_param_test/` — **not** L1–L4 e2e pipelines | **Level-specific Buildkite delivery (follow the user’s requested level):** @@ -805,21 +809,21 @@ Use **repo-relative paths** (no leading `./`). Prefer **directory** entries (`.. When extending an **existing** E2E step to run an additional test file, **append** that file (and any new model/deploy paths) to `source_file_dependencies` on the same step. When authoring a PR, mention the dependency list in the **Buildkite change** section of your test plan. -#### L3 / L4: validating Buildkite from a feature branch (root `pipeline.yml`) +#### L3 / L4: validating Buildkite from a feature branch (`cuda/pipeline.yml`) -Production behavior is driven by [`.buildkite/pipeline.yml`](../../../.buildkite/pipeline.yml): it builds the CI image, then uploads **L2** (`test-ready.yml` on non-`main`), **L3** (`test-merge.yml` when `build.branch == "main"`), and **L4** (`test-nightly.yml` when `build.env("NIGHTLY") == "1"`). +Production behavior is driven by [`.buildkite/cuda/pipeline.yml`](../../../.buildkite/cuda/pipeline.yml): it builds the CI image, then uploads **L2** (`test-ready.yml` on non-`main`), **L3** (`test-merge.yml` when `build.branch == "main"`), and **L4** (`test-nightly.yml` when `build.env("NIGHTLY") == "1"`). When you add or debug **L3** or **L4** tests and need those child pipelines to run **off `main`** or **without** `NIGHTLY=1`, apply **temporary** edits (revert before merge): 1. **Point the upload at the right file (optional shortcut)** - In the **Upload Ready Pipeline** step, the command is `buildkite-agent pipeline upload .buildkite/test-ready.yml`. For a one-off run you may change that path to `.buildkite/test-merge.yml` (L3) or `.buildkite/test-nightly.yml` (L4), and adjust the step’s `if:` so it does not conflict with the other upload steps (avoid double-uploading unless intentional). + In the **Upload Ready Pipeline** step, the command is `buildkite-agent pipeline upload .buildkite/cuda/test-ready.yml`. For a one-off run you may change that path to `.buildkite/cuda/test-merge.yml` (L3) or `.buildkite/cuda/test-nightly.yml` (L4), and adjust the step’s `if:` so it does not conflict with the other upload steps (avoid double-uploading unless intentional). 2. **L3 — merge pipeline** - The gate `if: build.branch == "main"` lives on the **Upload Merge Pipeline** step in `pipeline.yml` (not inside `test-merge.yml`). **Comment out that `if` line** so `test-merge.yml` is uploaded on your feature branch. Alternatively rely on step (1) instead of the dedicated merge upload step. + The gate `if: build.branch == "main"` lives on the **Upload Merge Pipeline** step in `cuda/pipeline.yml` (not inside `test-merge.yml`). **Comment out that `if` line** so `test-merge.yml` is uploaded on your feature branch. Alternatively rely on step (1) instead of the dedicated merge upload step. 3. **L4 — nightly pipeline** - **Comment out** `if: build.env("NIGHTLY") == "1"` on the **Upload Nightly Pipeline** step in `pipeline.yml` so the nightly definition is uploaded without setting the env var. - Child steps in [`test-nightly.yml`](../../../.buildkite/test-nightly.yml) often repeat `if: build.env("NIGHTLY") == "1"`; **comment out those lines on the steps you need to run**, otherwise they will still be skipped after upload. + **Comment out** `if: build.env("NIGHTLY") == "1"` on the **Upload Nightly Pipeline** step in `cuda/pipeline.yml` so the nightly definition is uploaded without setting the env var. + Child steps in [`test-nightly.yml`](../../../.buildkite/cuda/test-nightly.yml) often repeat `if: build.env("NIGHTLY") == "1"`; **comment out those lines on the steps you need to run**, otherwise they will still be skipped after upload. ### Step 6: Run Tests @@ -886,5 +890,5 @@ When completing a request, return: ## Additional Resources - Marker and command routing: [references/test-routing.md](references/test-routing.md) -- CI pipelines (vllm-omni): [test-ready.yml](../../../.buildkite/test-ready.yml) (L1/L2), [test-merge.yml](../../../.buildkite/test-merge.yml) (L3), [test-nightly.yml](../../../.buildkite/test-nightly.yml) (L4), [test-weekly.yml](../../../.buildkite/test-weekly.yml) (invalid param / reliability) +- CI pipelines (vllm-omni): [test-ready.yml](../../../.buildkite/cuda/test-ready.yml) (L1/L2), [test-merge.yml](../../../.buildkite/cuda/test-merge.yml) (L3), [test-nightly.yml](../../../.buildkite/cuda/test-nightly.yml) (L4), [test-weekly.yml](../../../.buildkite/cuda/test-weekly.yml) (invalid param / reliability) - L4 documentation example tests (naming, extraction vs copied scripts, output dirs, skips): [docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md) — see also [PR #1910](https://github.com/vllm-project/vllm-omni/pull/1910) diff --git a/.claude/skills/vllm-omni-test/references/test-routing.md b/.claude/skills/vllm-omni-test/references/test-routing.md index 445a78399..394046b91 100644 --- a/.claude/skills/vllm-omni-test/references/test-routing.md +++ b/.claude/skills/vllm-omni-test/references/test-routing.md @@ -2,7 +2,7 @@ Use this reference to map testing goals to levels, markers, and runnable commands. -**Repo paths** (`.buildkite/`, `docs/contributing/…`): link with repo-relative paths from this file (e.g. `../../../../.buildkite/test-ready.yml`, `../../../../docs/contributing/ci/CI_5levels.md`). +**Repo paths** (`.buildkite/`, `docs/contributing/…`): link with repo-relative paths from this file (e.g. `../../../../.buildkite/cuda/test-ready.yml`, `../../../../docs/contributing/ci/test_system_overview.md`). ## Model-centric e2e filename convention (L2–L4) @@ -127,8 +127,12 @@ pytest -s -v e2e/online_serving/test_qwen_image_expansion.py -m "full_model and cd tests export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN +# CI-like: single JSON file (nightly Perf Test steps) pytest -s -v dfx/perf/scripts/run_diffusion_benchmark.py \ --test-config-file dfx/perf/tests/test_qwen_image_vllm_omni.json +# Local bulk load: all *.json under tests/dfx/perf/tests/, filter by JSON mark +pytest -sv dfx/perf/scripts/run_diffusion_benchmark.py -m "full_model and diffusion and H100" +pytest -sv dfx/perf/scripts/run_benchmark.py -m "full_model and omni and H100" ``` Broad marker sweep (when no explicit file shard): @@ -150,7 +154,26 @@ Nightly **L4** for a model is often **multiple jobs** in `test-nightly.yml`, not **Rule:** “L4 functional cases” → **Function only** by default. State that Perf/Accuracy are separate pillars; add them only when requested. -**Diffusion perf JSON** (`tests/dfx/perf/tests/test__vllm_omni.json`): array of objects with `test_name`, `server_params` (`model`, `serve_args`), `benchmark_params[]`, and per-workload **`baseline`** (`throughput_qps`, `latency_mean`, `peak_memory_mb_mean`). Copy structure from `test_qwen_image_vllm_omni.json` in-tree. +**Diffusion perf JSON** (`tests/dfx/perf/tests/test__vllm_omni.json`): array of **case objects**, each with: + +| Field | Required | Notes | +|-------|----------|-------| +| `test_name` | Yes | Unique server/workload id | +| `mark` | Recommended | When present: **`hardware_marks` required** + optional `marks` (`full_model`, `diffusion`, `local_model`, …). Parsed by `resolve_pytest_marks`; enables local `-m` filtering. | +| `server_type` | Diffusion | e.g. `"vllm-omni"` — routes case to `run_diffusion_benchmark.py` | +| `benchmark_endpoint` | Diffusion | e.g. `/v1/videos`, `/v1/images/generations` | +| `server_params` | Yes | `model`, `serve_args`, … | +| `benchmark_params[]` | Yes | Each row: **`name`** (pytest id suffix), workload fields, **`baseline`** | + +Copy structure from `test_qwen_image_vllm_omni.json` or `test_cosmos3_vllm_omni.json` (2-GPU mark example). + +**Omni perf JSON**: `test_qwen3_omni_*.json` — same `mark` shape with `"marks": ["full_model", "omni"]`; run via `run_benchmark.py`. + +**Runner split**: `is_diffusion_perf_config()` → diffusion when `server_type` is set or `"diffusion"` ∈ `mark.marks`; otherwise omni/tts → `run_benchmark.py`. + +**Param ids**: `{test_name}-{benchmark_params.name}` (e.g. `test_tts-p0`, not `test_tts-0`). + +**Result filenames**: runtime hardware from `get_runtime_resource_label()`; `H100` omitted on default CI pool. **Diffusion perf nightly step** (under **Diffusion X2I(&A&T)**; copy full `kubernetes` plugins from `· Perf Test · Qwen-Image`): @@ -177,9 +200,9 @@ Nightly **L4** for a model is often **multiple jobs** in `test-nightly.yml`, not | Model type | Perf runner | Config example | |------------|-------------|----------------| -| Diffusion X2I/X2V | `dfx/perf/scripts/run_diffusion_benchmark.py` | `test_qwen_image_vllm_omni.json` | -| TTS | `dfx/perf/scripts/run_benchmark.py` | `test_tts.json` (`BENCHMARK_DIR`) | -| Omni | `run_benchmark.py` / omni configs in-tree | `test_omni*.json` | +| Diffusion X2I/X2V | `dfx/perf/scripts/run_diffusion_benchmark.py` | `test_qwen_image_vllm_omni.json`, `test_cosmos3_vllm_omni.json` | +| TTS | `dfx/perf/scripts/run_benchmark.py` | `test_tts.json`, `test_voxcpm2.json`, `test_higgs_audio_v3.json` | +| Omni | `dfx/perf/scripts/run_benchmark.py` | `test_qwen3_omni_async_chunk.json`, `test_qwen3_omni_no_async_chunk.json`, … | Do **not** put throughput/latency baselines inside `test_*_expansion.py` — that belongs in the dfx perf JSON + nightly Perf job. @@ -201,7 +224,7 @@ When the user’s test plan includes **invalid parameter validation**, **invalid | Sleep / wakeup / server control | `test_invalid_server_control.py` | 3. **Style:** `pytestmark = [pytest.mark.slow, pytest.mark.]`; `_PARAMS` + `hardware_marks`; `send_*_http_request` with `err_code` + `err_message`; parametrized `body_spec` rows with `id=`; `_minimal_*_json()` helpers; `_SKIP_ISSUE_3649` when tracked in [#3649](https://github.com/vllm-project/vllm-omni/issues/3649). -4. **CI:** [`.buildkite/test-weekly.yml`](../../../../.buildkite/test-weekly.yml) group **Reliability Test - Invalid parameters Test** — **not** ready/merge/nightly. +4. **CI:** [`.buildkite/cuda/test-weekly.yml`](../../../../.buildkite/cuda/test-weekly.yml) group **Reliability Test - Invalid parameters Test** — **not** ready/merge/nightly. ```bash # Weekly H100 (diffusion / omni / video invalid-param) @@ -268,10 +291,10 @@ When adding or modifying tests, do not stop at “where the file lives” — al | Level | Repo file | Model-type grouping | |-------|-----------|---------------------| -| L1, L2 | [`.buildkite/test-ready.yml`](../../../../.buildkite/test-ready.yml) | Steps prefixed **Omni ·**, **TTS ·**, **Diffusion ·** under **E2E Test** — **`source_file_dependencies` required** | -| L3 | [`.buildkite/test-merge.yml`](../../../../.buildkite/test-merge.yml) | Per-model E2E steps; `-m "advanced_model and …"` — **`source_file_dependencies` required** | -| L4 | [`.buildkite/test-nightly.yml`](../../../../.buildkite/test-nightly.yml) | **Omni / TTS / Diffusion X2I(&A&T) / X2V** — each group may have **Function**, **Accuracy**, **Perf**, **Doc** steps | -| Invalid param (weekly) | [`.buildkite/test-weekly.yml`](../../../../.buildkite/test-weekly.yml) | **Invalid parameters Test · H100** / **· L4** — sweeps `tests/dfx/reliability/invalid_param_test/` | +| L1, L2 | [`.buildkite/cuda/test-ready.yml`](../../../../.buildkite/cuda/test-ready.yml) | Steps prefixed **Omni ·**, **TTS ·**, **Diffusion ·** under **E2E Test** — **`source_file_dependencies` required** | +| L3 | [`.buildkite/cuda/test-merge.yml`](../../../../.buildkite/cuda/test-merge.yml) | Per-model E2E steps; `-m "advanced_model and …"` — **`source_file_dependencies` required** | +| L4 | [`.buildkite/cuda/test-nightly.yml`](../../../../.buildkite/cuda/test-nightly.yml) | **Omni / TTS / Diffusion X2I(&A&T) / X2V** — each group may have **Function**, **Accuracy**, **Perf**, **Doc** steps | +| Invalid param (weekly) | [`.buildkite/cuda/test-weekly.yml`](../../../../.buildkite/cuda/test-weekly.yml) | **Invalid parameters Test · H100** / **· L4** — sweeps `tests/dfx/reliability/invalid_param_test/` | ### `source_file_dependencies` (E2E Test only — ready & merge) @@ -357,7 +380,7 @@ Common patterns in `test-ready.yml` / `test-merge.yml`: When extending an existing step (e.g. add `tests/e2e/offline_inference/test_qwen_image.py` to **Diffusion · Qwen Image Test**), update `source_file_dependencies` and `commands` only; **keep the existing `agents` + `plugins` block unchanged** unless the hardware tier changes. -The root [`.buildkite/pipeline.yml`](../../../../.buildkite/pipeline.yml) decides **which** child file is uploaded. To run **L3** on a feature branch, comment out `if: build.branch == "main"` on the merge upload step. To run **L4** without `NIGHTLY=1`, comment out the nightly upload step’s `if: build.env("NIGHTLY") == "1"` and the same `if` on relevant steps inside `test-nightly.yml`. Revert such edits before merging. +[`.buildkite/cuda/pipeline.yml`](../../../../.buildkite/cuda/pipeline.yml) decides **which** child file is uploaded. To run **L3** on a feature branch, comment out `if: build.branch == "main"` on the merge upload step. To run **L4** without `NIGHTLY=1`, comment out the nightly upload step’s `if: build.env("NIGHTLY") == "1"` and the same `if` on relevant steps inside `test-nightly.yml`. Revert such edits before merging. Platform-specific pipelines (e.g. AMD) follow the same level → file pairing under `.buildkite/`. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6b3ddb474..c95d1df08 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -16,15 +16,15 @@ /.claude/ @hsliuustc0106 # Tests -/tests/ @yenuo26 +/tests/ @yenuo26 @NickCao # CI -/.buildkite/ @yenuo26 @congw729 +/.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 +/vllm_omni/entrypoints/ @tzhouam @yinpeiqi @fake0fan @alex-jw-brooks @NickCao /vllm_omni/model_executor/ @tzhouam @gcanlin /vllm_omni/worker/ @tzhouam @gcanlin @@ -37,7 +37,7 @@ # 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 +/vllm_omni/model_executor/models/ @linyueqian @ZeldaHuang @princepride @yuanheng-zhao @amy-why-3459 @Bounty-hunter @Sy0307 @gcanlin @NickCao # Benchmarks /benchmarks/accuracy/ @david6666666 diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 964fb5abc..2bd0cc133 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -24,3 +24,9 @@ jobs: python-version: "3.12" cache: 'pip' - uses: pre-commit/action@v3.0.1 + # Don't run the pytest mark check against everything in the CI + # for now since a lot of files are still missing them. This check + # is used to help contributors remember to add the marks when they + # commit. + env: + SKIP: check-test-ci-coverage diff --git a/.gitignore b/.gitignore index 224dac867..3c24e40ae 100644 --- a/.gitignore +++ b/.gitignore @@ -259,7 +259,6 @@ configs/production.yaml configs/development.yaml # Docker -.dockerignore Dockerfile.dev discussion tmp_test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6db093e0e..be649785d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,6 +54,13 @@ repos: verbose: true stages: [commit-msg] + - id: check-test-ci-coverage + name: Ensure test files have pytestmarks + entry: python tools/pre_commit/check_test_marks.py + language: python + types: [python] + files: ^tests/ + # Keep `suggestion` last - id: suggestion name: Suggestion diff --git a/README.md b/README.md index e7fc6912e..9d63a4b1e 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,10 @@ Easy, fast, and cheap omni-modality model serving for everyone --- *Latest News* 🔥 +- [2026/08] We released [0.26.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.26.0) - aligned with the vLLM 0.26 release line, featuring [MiniMax H3](recipes/MiniMaxAI/MiniMax-H3.md) joint video/audio generation, an experimental full-duplex realtime runtime for [MiniCPM-o 4.5](recipes/OpenBMB/MiniCPM-o-4_5.md), distributed layerwise diffusion offload, and broader model, hardware, streaming, TTS, and quantization support. - [2026/07] We released [0.24.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.24.0) - aligned with the vLLM 0.24 release line, expanding production-ready coverage across TTS, speech, diffusion, image/video generation, and robot-policy serving, with major Omni stage runtime refactoring, diffusion request-level batching, async output materialization, quantization/cache/memory improvements, and broad CUDA/ROCm/XPU/NPU support. -- [2026/06] We released [0.22.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.22.0) - an **omnimodal world-model** release aligned with vLLM 0.22, featuring [Nvidia Cosmos3](recipes/cosmos3/Cosmos3-Nano.md)/DreamZero world model support, expanded quantization coverage across Blackwell/NPU/XPU, TTS production improvements, new models including MiniCPM-o 4.5, MOSS-TTS, and Lance, plus RL integration with [VeRL-Omni](https://github.com/verl-project/verl-omni). -- [2026/05] We released [0.20.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.20.0) - refreshes the serving/runtime stack for large-scale omni workloads, and improves diffusion model performance, quantization, and hardware readiness across CUDA, ROCm, MUSA, NPU, and XPU backends. -- [2026/03] We released [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0) - strengthens the core runtime through a large entrypoint refactor and scheduler/runtime cleanups, expands unified quantization and diffusion execution, broadens multimodal model coverage, and improves production readiness across audio, omni, image, video, RL, and multi-platform deployments. +- [2026/06] Starting with [0.14.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.14.0), vLLM-Omni publishes a stable release aligned with every even-numbered upstream vLLM minor version. [0.16.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.16.0), [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0), [0.20.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.20.0), and [0.22.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.22.0) continued this cadence, expanding omni and world-model support with [NVIDIA Cosmos3](recipes/cosmos3/Cosmos3-Nano.md) and DreamZero, adding models such as MiniCPM-o 4.5, MOSS-TTS, and Lance, and advancing TTS, diffusion, distributed execution, quantization, RL integration through [VeRL-Omni](https://github.com/verl-project/verl-omni), and CUDA/ROCm/MUSA/NPU/XPU coverage. - [2026/03] Check out our first public [project deepdive](https://youtu.be/sgwNfsNnR9I) at the vLLM Hong Kong Meetup! -- [2026/03] **[vllm-omni-skills](https://github.com/hsliuustc0106/vllm-omni-skills)** is a community-driven collection of AI assistant skills that help developers work with vLLM-Omni more effectively. These skills can be used with popular agentic AI coding assistants like **Cursor IDE**, **Claude**, **Codex**, and more. -- [2026/02] We released [0.16.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.16.0) - A major alignment + capability release that rebases onto **upstream vLLM v0.16.0** and significantly expands performance, distributed execution, and production readiness across **Qwen3-Omni / Qwen3-TTS**, **Bagel**, **MiMo-Audio**, **GLM-Image** and the **Diffusion (DiT) image/video stack**—while also improving platform coverage (CUDA / ROCm / NPU / XPU), CI quality, and documentation. -- [2026/02] We released [0.14.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.14.0) - This is the first **stable release** of vLLM-Omni that expands Omni’s diffusion / image-video generation and audio / TTS stack, improves distributed execution and memory efficiency, and broadens platform/backend coverage (GPU/ROCm/NPU/XPU). It also brings meaningful upgrades to serving APIs, profiling & benchmarking, and overall stability. Please check our latest [paper](https://arxiv.org/abs/2602.02204) for architecture design and performance results. - [2025/11] vLLM community officially released [vllm-project/vllm-omni](https://github.com/vllm-project/vllm-omni) in order to support omni-modality models serving. --- @@ -55,12 +51,13 @@ vLLM-Omni is flexible and easy to use with: - Tensor, pipeline, data and expert parallelism support for distributed inference - Streaming outputs - OpenAI-compatible API server +- Full-duplex realtime serving with streaming audio input and output (experimental) vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including: -- **Omni-modality models** (e.g. Qwen3-Omni, Cosmos3, HunyuanImage, BAGEL) +- **Omni-modality models** (e.g. Qwen3-Omni, MiniCPM-o 4.5, Cosmos3, HunyuanImage, BAGEL) - **TTS models** (e.g. Qwen3-TTS, VoxCPM2, Ming-Omni-TTS, CosyVoice3) -- **Diffusion models** — image, video, and audio generation (e.g. Qwen-Image, Wan2.2, FLUX) +- **Diffusion models** — image, video, and audio generation (e.g. MiniMax H3, Qwen-Image, Wan2.2, FLUX) - **Robot-policy and action models** (e.g. GR00T-N1.7, DreamZero-DROID, InternVLA-A1, Cosmos3 action policy) ## Getting Started diff --git a/apps/ComfyUI-vLLM-Omni/README.md b/apps/ComfyUI-vLLM-Omni/README.md index 54f2fdf2e..cbd53a9a0 100644 --- a/apps/ComfyUI-vLLM-Omni/README.md +++ b/apps/ComfyUI-vLLM-Omni/README.md @@ -47,7 +47,7 @@ If no, check your shell running the ComfyUI process. There may be some error mes This extension offers the following nodes based on the output modalities (at **ComfyUI sidebar -> Node Library**): - **Generate Image** for text-to-image and image-to-image tasks -- **Generate Video** for text-to-video and image-to-video tasks +- **Generate Video** for text-to-video, first-frame/image-to-video, and reference-conditioned video - **Multimodality Understanding** for multimodality-to-text and multimodality-to-audio tasks - **TTS** and **TTS Voice Clone** for TTS tasks @@ -105,7 +105,7 @@ You can configure per-stage sampling parameters for multi-stage models. > [!TIP] > The node automatically choose text-to-image or image-to-image API endpoints depending on whether you connect an image input or not. -### Text-to-video and image-to-video generation (e.g., Wan) +### Text-to-video and image-to-video generation (e.g., Wan, MiniMax-H3) (Also available at **ComfyUI sidebar->Template->vLLM-Omni->vLLM-Omni Video Generation**) @@ -117,7 +117,11 @@ You can configure per-stage sampling parameters for multi-stage models.

> [!TIP] -> The node automatically choose text-to-video or image-to-video API endpoints depending on whether you connect an image input or not. +> Connect a **frame** image for first-frame / image-to-video (e.g., Wan I2V, MiniMax-H3 FL2VA). +> +> For reference-conditioned generation (MiniMax-H3 Ref2VA), connect a **Video References** node instead. +> +> Do not use `frame` and `references` together. Task routing is automatic from which inputs you connect. ### TTS (e.g., Qwen TTS series) diff --git a/apps/ComfyUI-vLLM-Omni/__init__.py b/apps/ComfyUI-vLLM-Omni/__init__.py index e89b2ca1e..268107899 100644 --- a/apps/ComfyUI-vLLM-Omni/__init__.py +++ b/apps/ComfyUI-vLLM-Omni/__init__.py @@ -15,11 +15,13 @@ from .comfyui_vllm_omni.nodes import ( VLLMOmniDiffusionSampling, VLLMOmniGenerateImage, VLLMOmniGenerateVideo, + VLLMOmniMiniMaxH3Params, VLLMOmniQwenTTSParams, VLLMOmniRemoteLoRA, VLLMOmniSamplingParamsList, VLLMOmniTTS, VLLMOmniUnderstanding, + VLLMOmniVideoReferences, VLLMOmniVoiceClone, VLLMOmniWanParams, ) @@ -32,6 +34,7 @@ NODE_CLASS_MAPPINGS = { "VLLMOmniUnderstanding": VLLMOmniUnderstanding, "VLLMOmniTTS": VLLMOmniTTS, "VLLMOmniVoiceClone": VLLMOmniVoiceClone, + "VLLMOmniVideoReferences": VLLMOmniVideoReferences, # === Params === "VLLMOmniARSampling": VLLMOmniARSampling, "VLLMOmniDiffusionSampling": VLLMOmniDiffusionSampling, @@ -39,6 +42,7 @@ NODE_CLASS_MAPPINGS = { "VLLMOmniRemoteLoRA": VLLMOmniRemoteLoRA, "VLLMOmniQwenTTSParams": VLLMOmniQwenTTSParams, "VLLMOmniWanParams": VLLMOmniWanParams, + "VLLMOmniMiniMaxH3Params": VLLMOmniMiniMaxH3Params, } # A dictionary that contains the friendly/humanly readable titles for the nodes @@ -49,6 +53,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "VLLMOmniUnderstanding": "Multimodality Understanding", "VLLMOmniTTS": "TTS (Text to Speech)", "VLLMOmniVoiceClone": "TTS Voice Cloning", + "VLLMOmniVideoReferences": "Video References", # === Params === "VLLMOmniARSampling": "AR Sampling Params", "VLLMOmniDiffusionSampling": "Diffusion Sampling Params", @@ -56,6 +61,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "VLLMOmniRemoteLoRA": "LoRA", "VLLMOmniQwenTTSParams": "Qwen TTS Params", "VLLMOmniWanParams": "Wan Video Params", + "VLLMOmniMiniMaxH3Params": "MiniMax-H3 Video Params", } WEB_DIRECTORY = "./web" diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py index bfea93998..8fd00cd9c 100644 --- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py @@ -10,7 +10,9 @@ from .utils.types import ( AudioFormat, AutoregressionSamplingParams, DiffusionSamplingParams, + MiniMaxH3ModelSpecificParams, QwenTTSModelSpecificParams, + VideoReferences, WanModelSpecificParams, ) from .utils.validators import ( @@ -163,7 +165,8 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase): "num_frames": ("INT", {"default": 41, "min": 1}), }, "optional": { - "image": ("IMAGE",), + "frame": ("IMAGE",), + "references": ("VIDEO_REFERENCES",), "sampling_params": ("SAMPLING_PARAMS",), "lora": ("REMOTE_LORA",), "model_params": ("VIDEO_PARAMS",), @@ -174,6 +177,15 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase): RETURN_NAMES = ("video",) FUNCTION = "generate" + @classmethod + def VALIDATE_INPUTS(cls, url, model, frame=None, references=None, **_kwargs) -> str | Literal[True]: + base = super().VALIDATE_INPUTS(url, model) + if base is not True: + return base + if frame is not None and references is not None: + return "Provide only one of frame or references, not both." + return True + async def generate( self, url: str, @@ -184,7 +196,8 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase): fps: int, num_frames: int, negative_prompt: str | None = None, - image: torch.Tensor | None = None, + frame: torch.Tensor | None = None, + references: dict | None = None, sampling_params: dict | list[dict] | None = None, model_params: dict | None = None, lora: dict | None = None, @@ -207,14 +220,16 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase): if sampling_params is not None: sampling_params.pop("type", None) # internal fields - if model_params is not None: - model_params.pop("type", None) # internal fields + # model_params["type"] is also an internal field, but is used in api_client + # if model_params is not None: + # model_params.pop("type", None) # internal fields client = VLLMOmniClient(url) output = await client.generate_video( model=model, prompt=prompt, - image=image, # image present => i2v, absent => t2v + frame=frame, # frame present => fl2va / Wan I2V + references=references, width=width, height=height, num_frames=num_frames, @@ -734,3 +749,78 @@ class VLLMOmniWanParams: def get_params(self, **kwargs): return (WanModelSpecificParams(kwargs),) + + +class VLLMOmniMiniMaxH3Params: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio_flow_shift": ( + "FLOAT", + {"default": 3.0, "min": 0.0, "max": 100.0, "step": 0.1}, + ), + "flow_shift": ( + "FLOAT", + {"default": 12.0, "min": 0.0, "max": 100.0, "step": 0.1}, + ), + } + } + + RETURN_TYPES = ("VIDEO_PARAMS",) + RETURN_NAMES = ("MiniMax-H3 video params",) + FUNCTION = "get_params" + CATEGORY = "vLLM-Omni/Video Params" + + def get_params(self, **kwargs): + params = MiniMaxH3ModelSpecificParams(kwargs) + params["type"] = "minimax_h3" + return (params,) + + +class VLLMOmniVideoReferences: + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": { + "image_1": ("IMAGE",), + "image_2": ("IMAGE",), + "audio_1": ("AUDIO",), + "audio_2": ("AUDIO",), + "video_1": ("VIDEO",), + "video_2": ("VIDEO",), + }, + } + + RETURN_TYPES = ("VIDEO_REFERENCES",) + RETURN_NAMES = ("references",) + FUNCTION = "get_references" + CATEGORY = "vLLM-Omni" + + def get_references( + self, + image_1: torch.Tensor | None = None, + image_2: torch.Tensor | None = None, + audio_1: AudioInput | None = None, + audio_2: AudioInput | None = None, + video_1: VideoInput | None = None, + video_2: VideoInput | None = None, + **kwargs, + ): + if kwargs: + logger.info("Uncaught kwargs: %s", kwargs) + refs = VideoReferences() + if image_1 is not None: + refs["image_1"] = image_1 + if image_2 is not None: + refs["image_2"] = image_2 + if audio_1 is not None: + refs["audio_1"] = audio_1 + if audio_2 is not None: + refs["audio_2"] = audio_2 + if video_1 is not None: + refs["video_1"] = video_1 + if video_2 is not None: + refs["video_2"] = video_2 + return (refs,) diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py index 8600fe393..cd509244a 100644 --- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py @@ -24,6 +24,7 @@ from .format import ( image_tensor_to_base64, image_tensor_to_png_bytes, video_to_base64, + video_to_bytes, ) from .logger import get_logger, pretty_printer from .models import lookup_model_spec @@ -63,7 +64,11 @@ async def url_bytes(session: aiohttp.ClientSession, url: str, verb: str = "get", class VLLMOmniClient: def __init__( - self, base_url: str, timeout: float | None = None, poll_interval: float = 5.0, max_poll_duration: float = 60 * 5 + self, + base_url: str, + timeout: float | None = None, + poll_interval: float = 5.0, + max_poll_duration: float = 60 * 30, ): self.base_url = base_url self.timeout = aiohttp.ClientTimeout(total=timeout) @@ -261,12 +266,17 @@ class VLLMOmniClient: num_frames: int, fps: int, negative_prompt: str | None = None, - image: torch.Tensor | None = None, + frame: torch.Tensor | None = None, + references: dict | None = None, sampling_params: dict | None = None, model_params: dict | None = None, lora: dict | None = None, - **extra_body, + **extra_params, ) -> VideoInput: + if frame is not None and references is not None: + raise ValueError("Provide only one of frame or references, not both.") + + # === regular payload fields === form = aiohttp.FormData() form.add_field("model", model) form.add_field("prompt", prompt) @@ -279,23 +289,91 @@ class VLLMOmniClient: if sampling_params is not None: for k, v in sampling_params.items(): form.add_field(k, str(v)) - if model_params is not None: - for k, v in model_params.items(): - form.add_field(k, str(v)) if lora is not None: form.add_field("lora", json.dumps(lora, ensure_ascii=False)) - if extra_body: - form.add_field("extra_body", json.dumps(extra_body, ensure_ascii=False)) - if image is not None: + # === multimodal inputs (first-last-frames, references, etc.) === + input_reference_image: torch.Tensor | None = None + audio_reference: AudioInput | None = None + reference_videos: list[VideoInput] = [] + video_task: str | None = None + + if frame is not None: + input_reference_image = frame + video_task = "fl2va" + elif references is not None: + images = [references[k] for k in ("image_1", "image_2") if k in references and references[k] is not None] + audios = [references[k] for k in ("audio_1", "audio_2") if k in references and references[k] is not None] + videos = [references[k] for k in ("video_1", "video_2") if k in references and references[k] is not None] + + if not images and not audios and not videos: + raise ValueError("references is empty; connect at least one image/audio/video.") + + if videos: + if images or audios: + raise ValueError( + "Video references cannot be combined with image or audio references. " + "Connect only video_1/video_2 for multi-video Ref2VA." + ) + reference_videos = videos + video_task = "ref2va" + elif len(images) == 1 and len(audios) == 1: + input_reference_image = images[0] + audio_reference = audios[0] + video_task = "ref2va" + else: + raise ValueError( + "Invalid references combination. Supported modes: " + "(1) one or more videos only, or (2) exactly one image and one audio." + ) + else: + video_task = "t2va" + + if input_reference_image is not None: image_filename = "image.png" # Required for multipart form form.add_field( "input_reference", - image_tensor_to_png_bytes(image, image_filename), + image_tensor_to_png_bytes(input_reference_image, image_filename), filename=image_filename, content_type="image/png", ) + if audio_reference is not None: + form.add_field( + "audio_reference", + json.dumps({"audio_url": audio_to_base64(audio_reference)}, ensure_ascii=False), + ) + + for idx, video in enumerate(reference_videos, start=1): + video_filename = f"reference_{idx}.mp4" + form.add_field( + "input_references", + video_to_bytes(video, video_filename), + filename=video_filename, + content_type="video/mp4", + ) + + # === model specific params. Either use a specialized builder, or add flattened fields as-is === + if model_params is not None: + model_params = dict(model_params) + model_params.pop("type", None) + + spec, _ = lookup_model_spec(model) + params_builder = spec.get("params_builder") if spec else None + if params_builder is not None: + form_fields = params_builder( + model_params or {}, + extra_params={**extra_params, "task": video_task}, + ) + for k, v in form_fields.items(): + form.add_field(k, v if isinstance(v, str) else str(v)) + else: + if model_params is not None: + for k, v in model_params.items(): + form.add_field(k, str(v)) + if extra_params: + form.add_field("extra_params", json.dumps(extra_params, ensure_ascii=False)) + async with aiohttp.ClientSession(timeout=self.timeout) as session: # Start the video generation job url = f"{self.base_url}/videos" diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/models.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/models.py index bfeddd82b..3a37db16d 100644 --- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/models.py +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/models.py @@ -1,7 +1,12 @@ +import json import re +from typing import Any +from .logger import get_logger from .types import Modality, ModelMode, Spec +logger = get_logger(__name__) + def _bagel_payload_preprocessor(payload: dict) -> dict: try: @@ -29,6 +34,30 @@ def _qwen25_payload_preprocessor(payload: dict) -> dict: return payload +def _minimaxh3_params_builder( + model_params: dict[str, Any], + *, + extra_params: dict[str, Any], +) -> dict[str, Any]: + """Build multipart form fields for MiniMax-H3 from model_params + routed task.""" + params = dict(model_params) + params.pop("type", None) + form_fields: dict[str, Any] = {} + merged_extra_params: dict[str, Any] = dict(extra_params or {}) + + if "flow_shift" in params: + form_fields["flow_shift"] = params.pop("flow_shift") + for key in ("audio_flow_shift",): + if key in params: + merged_extra_params[key] = params.pop(key) + if params: + logger.warning("Unused MiniMax-H3 model params ignored: %s", sorted(params)) + + if merged_extra_params: + form_fields["extra_params"] = json.dumps(merged_extra_params, ensure_ascii=False) + return form_fields + + _MODEL_PIPELINE_SPECS: dict[str, Spec] = { r"BAGEL-7B-MoT": { "stages": [ @@ -71,6 +100,21 @@ _MODEL_PIPELINE_SPECS: dict[str, Spec] = { } ], }, + r"MiniMax-H3(/FL2VA|/Ref2VA)?": { + "stages": ["diffusion"], + "modes": [ + { + "mode": ModelMode.VIDEO_GENERATION, + "input_modalities": [ + Modality.TEXT, + Modality.IMAGE, + Modality.VIDEO, + Modality.AUDIO, + ], + } + ], + "params_builder": _minimaxh3_params_builder, + }, } # Convert dict keys to regex patterns MODEL_PIPELINE_SPECS: dict[re.Pattern, Spec] = {} @@ -80,12 +124,10 @@ del _MODEL_PIPELINE_SPECS def lookup_model_spec(model: str) -> tuple[Spec | None, str | None]: - try: - last_component = model.rstrip("/").rsplit("/", 1)[-1] - except IndexError: - last_component = model + normalized = model.rstrip("/").lstrip("/") + lookup_key = normalized.split("/", 1)[-1] for pattern, spec in MODEL_PIPELINE_SPECS.items(): - if pattern.search(last_component): + if pattern.search(lookup_key): return spec, pattern.pattern return None, None @@ -101,6 +143,9 @@ if __name__ == "__main__": "Qwen/Qwen3-Omni-30B-A3B-Instruct", "Custom/Path/UnknownModel-Instruct", "Not/Matching/Anything", + "/path/to/MiniMax-H3/FL2VA", + "/path/to/MiniMax-H3/Ref2VA", + "MiniMaxAI/MiniMax-H3", ] test_payload = {"messages": [{"role": "user", "content": "prompt"}]} @@ -112,7 +157,13 @@ if __name__ == "__main__": if preprocessor := spec.get("payload_preprocessor"): result = preprocessor(test_payload) print(f"✓ {path:<40} → {result}") + elif params_builder := spec.get("params_builder"): + result = params_builder( + {"audio_flow_shift": 3.0, "flow_shift": 12.0, "type": "minimax_h3"}, + extra_params={"task": "t2va"}, + ) + print(f"✓ {path:<40} → {result}") else: - print(f"✓ {path:<40} → No preprocessor") + print(f"✓ {path:<40} → No preprocessor/params_builder") else: print(f"✗ {path:<40} → No match") diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py index c7d254eb9..e1ae43916 100644 --- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py @@ -1,12 +1,8 @@ from collections.abc import Callable from enum import Enum, auto -from typing import ( - Any, - Literal, - NotRequired, - TypeAlias, - TypedDict, -) +from typing import Any, Literal, TypeAlias + +from typing_extensions import NotRequired, TypedDict AudioFormat: TypeAlias = Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] @@ -27,6 +23,14 @@ class WanModelSpecificParams(dict): pass +class MiniMaxH3ModelSpecificParams(dict): + pass + + +class VideoReferences(dict): + pass + + class ModelMode(Enum): IMAGE_GENERATION = auto() VIDEO_GENERATION = auto() @@ -47,9 +51,11 @@ class ModelModeSpec(TypedDict): PayloadPreprocessor: TypeAlias = Callable[[dict[str, Any]], dict[str, Any]] +ParamsBuilder: TypeAlias = Callable[..., dict[str, Any]] class Spec(TypedDict): stages: list[Literal["diffusion", "autoregression"]] modes: list[ModelModeSpec] payload_preprocessor: NotRequired[PayloadPreprocessor] + params_builder: NotRequired[ParamsBuilder] diff --git a/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-video-generation.jpg b/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-video-generation.jpg index a449a4eee..35a28bd5d 100644 Binary files a/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-video-generation.jpg and b/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-video-generation.jpg differ diff --git a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Video Generation.json b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Video Generation.json index 636360ab8..9785845a5 100644 --- a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Video Generation.json +++ b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Video Generation.json @@ -1,513 +1 @@ -{ - "id": "3aea1d08-7de5-4997-ba71-b2f66ad72a21", - "revision": 0, - "last_node_id": 14, - "last_link_id": 28, - "nodes": [ - { - "id": 10, - "type": "MarkdownNote", - "pos": [ - 227.99306819575278, - -231.24143306069843 - ], - "size": [ - 240.3326478922474, - 136.3505820791881 - ], - "flags": {}, - "order": 0, - "mode": 0, - "inputs": [], - "outputs": [], - "title": "Note: Task and Input", - "properties": {}, - "widgets_values": [ - "vLLM-Omni nodes are categorized based on the output modality. The \"Generate Video\" node supports both text-to-video generation and image-to-video generation. The node will create corresponding payload depending on whether an input image is present or not." - ], - "color": "#432", - "bgcolor": "#000" - }, - { - "id": 4, - "type": "LoadImage", - "pos": [ - 496.31859627609606, - -229.71277089860084 - ], - "size": [ - 270, - 314 - ], - "flags": {}, - "order": 1, - "mode": 0, - "inputs": [ - { - "localized_name": "image", - "name": "image", - "type": "COMBO", - "widget": { - "name": "image" - }, - "link": null - }, - { - "localized_name": "choose file to upload", - "name": "upload", - "type": "IMAGEUPLOAD", - "widget": { - "name": "upload" - }, - "link": null - } - ], - "outputs": [ - { - "localized_name": "IMAGE", - "name": "IMAGE", - "type": "IMAGE", - "links": [ - 25 - ] - }, - { - "localized_name": "MASK", - "name": "MASK", - "type": "MASK", - "links": null - } - ], - "properties": { - "Node name for S&R": "LoadImage" - }, - "widgets_values": [ - "cute-cat.jpg", - "image" - ] - }, - { - "id": 8, - "type": "VLLMOmniDiffusionSampling", - "pos": [ - 465.5140218220927, - 148.80072646828987 - ], - "size": [ - 284.205078125, - 226 - ], - "flags": {}, - "order": 2, - "mode": 0, - "inputs": [ - { - "localized_name": "n", - "name": "n", - "type": "INT", - "widget": { - "name": "n" - }, - "link": null - }, - { - "localized_name": "num_inference_steps", - "name": "num_inference_steps", - "type": "INT", - "widget": { - "name": "num_inference_steps" - }, - "link": null - }, - { - "localized_name": "guidance_scale", - "name": "guidance_scale", - "type": "FLOAT", - "widget": { - "name": "guidance_scale" - }, - "link": null - }, - { - "localized_name": "true_cfg_scale", - "name": "true_cfg_scale", - "type": "FLOAT", - "widget": { - "name": "true_cfg_scale" - }, - "link": null - }, - { - "localized_name": "vae_use_slicing", - "name": "vae_use_slicing", - "type": "BOOLEAN", - "widget": { - "name": "vae_use_slicing" - }, - "link": null - }, - { - "localized_name": "vae_use_tiling", - "name": "vae_use_tiling", - "type": "BOOLEAN", - "widget": { - "name": "vae_use_tiling" - }, - "link": null - }, - { - "localized_name": "seed", - "name": "seed", - "type": "INT", - "widget": { - "name": "seed" - }, - "link": null - } - ], - "outputs": [ - { - "localized_name": "diffusion sampling params", - "name": "diffusion sampling params", - "type": "SAMPLING_PARAMS", - "links": [ - 27 - ] - } - ], - "properties": { - "Node name for S&R": "VLLMOmniDiffusionSampling" - }, - "widgets_values": [ - 1, - 50, - 1, - 1, - false, - false, - 1992, - "randomize" - ] - }, - { - "id": 14, - "type": "VLLMOmniWanParams", - "pos": [ - 518.6005521845747, - 428.4455663719664 - ], - "size": [ - 270, - 106 - ], - "flags": {}, - "order": 3, - "mode": 0, - "inputs": [ - { - "localized_name": "guidance_scale_2", - "name": "guidance_scale_2", - "type": "FLOAT", - "widget": { - "name": "guidance_scale_2" - }, - "link": null - }, - { - "localized_name": "boundary_ratio", - "name": "boundary_ratio", - "type": "FLOAT", - "widget": { - "name": "boundary_ratio" - }, - "link": null - }, - { - "localized_name": "flow_shift", - "name": "flow_shift", - "type": "FLOAT", - "widget": { - "name": "flow_shift" - }, - "link": null - } - ], - "outputs": [ - { - "localized_name": "Wan video params", - "name": "Wan video params", - "type": "VIDEO_PARAMS", - "links": [ - 28 - ] - } - ], - "properties": { - "Node name for S&R": "VLLMOmniWanParams" - }, - "widgets_values": [ - 4, - 0.875, - 5 - ] - }, - { - "id": 12, - "type": "VLLMOmniGenerateVideo", - "pos": [ - 827.2566336087867, - -73.77449831827545 - ], - "size": [ - 400, - 346 - ], - "flags": {}, - "order": 4, - "mode": 0, - "inputs": [ - { - "localized_name": "image", - "name": "image", - "shape": 7, - "type": "IMAGE", - "link": 25 - }, - { - "localized_name": "sampling_params", - "name": "sampling_params", - "shape": 7, - "type": "SAMPLING_PARAMS", - "link": 27 - }, - { - "localized_name": "lora", - "name": "lora", - "shape": 7, - "type": "REMOTE_LORA", - "link": null - }, - { - "localized_name": "model_params", - "name": "model_params", - "shape": 7, - "type": "VIDEO_PARAMS", - "link": 28 - }, - { - "localized_name": "url", - "name": "url", - "type": "STRING", - "widget": { - "name": "url" - }, - "link": null - }, - { - "localized_name": "model", - "name": "model", - "type": "STRING", - "widget": { - "name": "model" - }, - "link": null - }, - { - "localized_name": "prompt", - "name": "prompt", - "type": "STRING", - "widget": { - "name": "prompt" - }, - "link": null - }, - { - "localized_name": "negative_prompt", - "name": "negative_prompt", - "type": "STRING", - "widget": { - "name": "negative_prompt" - }, - "link": null - }, - { - "localized_name": "width", - "name": "width", - "type": "INT", - "widget": { - "name": "width" - }, - "link": null - }, - { - "localized_name": "height", - "name": "height", - "type": "INT", - "widget": { - "name": "height" - }, - "link": null - }, - { - "localized_name": "fps", - "name": "fps", - "type": "INT", - "widget": { - "name": "fps" - }, - "link": null - }, - { - "localized_name": "num_frames", - "name": "num_frames", - "type": "INT", - "widget": { - "name": "num_frames" - }, - "link": null - } - ], - "outputs": [ - { - "localized_name": "video", - "name": "video", - "type": "VIDEO", - "links": [ - 26 - ] - } - ], - "properties": { - "Node name for S&R": "VLLMOmniGenerateVideo" - }, - "widgets_values": [ - "http://localhost:8000/v1", - "Wan-AI/Wan2.2-TI2V-5B-Diffusers", - "Make the cat in the attached video yawning. Eye closed and mouth wide open. Keep the original realistic style.", - "Cartoonish", - 900, - 1200, - 12, - 41 - ] - }, - { - "id": 13, - "type": "SaveVideo", - "pos": [ - 1257.5441369501657, - -44.12956394419863 - ], - "size": [ - 374, - 372.34896826553796 - ], - "flags": {}, - "order": 5, - "mode": 0, - "inputs": [ - { - "localized_name": "video", - "name": "video", - "type": "VIDEO", - "link": 26 - }, - { - "localized_name": "filename_prefix", - "name": "filename_prefix", - "type": "STRING", - "widget": { - "name": "filename_prefix" - }, - "link": null - }, - { - "localized_name": "format", - "name": "format", - "type": "COMBO", - "widget": { - "name": "format" - }, - "link": null - }, - { - "localized_name": "codec", - "name": "codec", - "type": "COMBO", - "widget": { - "name": "codec" - }, - "link": null - } - ], - "outputs": [], - "properties": {}, - "widgets_values": [ - "video/ComfyUI", - "auto", - "auto" - ] - } - ], - "links": [ - [ - 25, - 4, - 0, - 12, - 0, - "IMAGE" - ], - [ - 26, - 12, - 0, - 13, - 0, - "VIDEO" - ], - [ - 27, - 8, - 0, - 12, - 1, - "SAMPLING_PARAMS" - ], - [ - 28, - 14, - 0, - 12, - 3, - "VIDEO_PARAMS" - ] - ], - "groups": [ - { - "id": 1, - "title": "Input", - "bounding": [ - 213.1706010087147, - -313.20095750667554, - 560.8246471437027, - 407.4246532472182 - ], - "color": "#3f789e", - "font_size": 24, - "flags": {} - } - ], - "config": {}, - "extra": { - "workflowRendererVersion": "LG", - "ds": { - "scale": 1.1469075819486894, - "offset": [ - 101.7632950847089, - 492.29121889347806 - ] - } - }, - "version": 0.4 -} +{"id":"3aea1d08-7de5-4997-ba71-b2f66ad72a21","revision":0,"last_node_id":24,"last_link_id":35,"nodes":[{"id":13,"type":"SaveVideo","pos":[1315.729153459605,-137.92038948394355],"size":[374,313.7692307692308],"flags":{},"order":9,"mode":0,"inputs":[{"localized_name":"video","name":"video","type":"VIDEO","link":32},{"localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null},{"localized_name":"format","name":"format","type":"COMBO","widget":{"name":"format"},"link":null},{"localized_name":"codec","name":"codec","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"codec"},"link":null}],"outputs":[{"localized_name":"video","name":"video","type":"VIDEO","links":null}],"properties":{},"widgets_values":["video/ComfyUI","auto","auto"]},{"id":10,"type":"MarkdownNote","pos":[183.74636797766794,-571.2529158046766],"size":[240.3326478922474,136.3505820791881],"flags":{},"order":0,"mode":0,"inputs":[],"outputs":[],"title":"Note: Task and Input","properties":{},"widgets_values":["vLLM-Omni nodes are categorized based on the output modality. The \"Generate Video\" node supports both *text-to-video*, *frame-to-video*, and *reference-to-video* generation. The node will create corresponding payload depending on whether an input image is present or not."],"color":"#432","bgcolor":"#000"},{"id":4,"type":"LoadImage","pos":[452.07189605801125,-569.7242536425791],"size":[270,314],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[29]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"LoadImage"},"widgets_values":["rap.png","image"]},{"id":23,"type":"LoadImage","pos":[9.206110246057657,-7.6955291509170785],"size":[270,314],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[34]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"LoadImage"},"widgets_values":["white-cat.jpg","image"]},{"id":22,"type":"MarkdownNote","pos":[-9.783113411416666,-148.8790300038605],"size":[297.3012007349086,88],"flags":{},"order":3,"mode":0,"inputs":[],"outputs":[],"title":"Note: Reference to video (r2v)","properties":{},"widgets_values":["For r2v tasks, connect this Video Reference block to the main Generate Video block. Then disconnect the \"frame\" input above."],"color":"#432","bgcolor":"#000"},{"id":24,"type":"LoadAudio","pos":[317.9929742385805,-141.44821500065095],"size":[282.798828125,136],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"COMBO","widget":{"name":"audio"},"link":null},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"AUDIOUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"AUDIO","name":"AUDIO","type":"AUDIO","links":[35]}],"properties":{"Node name for S&R":"LoadAudio"},"widgets_values":["nothing-beats-a-jet2-holiday.mp3",null,""]},{"id":21,"type":"VLLMOmniVideoReferences","pos":[376.6129172897595,105.41557277556292],"size":[156.9380859375,126],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"image_1","name":"image_1","shape":7,"type":"IMAGE","link":34},{"localized_name":"image_2","name":"image_2","shape":7,"type":"IMAGE","link":null},{"localized_name":"audio_1","name":"audio_1","shape":7,"type":"AUDIO","link":35},{"localized_name":"audio_2","name":"audio_2","shape":7,"type":"AUDIO","link":null},{"localized_name":"video_1","name":"video_1","shape":7,"type":"VIDEO","link":null},{"localized_name":"video_2","name":"video_2","shape":7,"type":"VIDEO","link":null}],"outputs":[{"localized_name":"references","name":"references","type":"VIDEO_REFERENCES","links":null}],"properties":{"Node name for S&R":"VLLMOmniVideoReferences"},"widgets_values":[]},{"id":8,"type":"VLLMOmniDiffusionSampling","pos":[83.24620208113915,422.08543477542605],"size":[284.205078125,226],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"n","name":"n","type":"INT","widget":{"name":"n"},"link":null},{"localized_name":"num_inference_steps","name":"num_inference_steps","type":"INT","widget":{"name":"num_inference_steps"},"link":null},{"localized_name":"guidance_scale","name":"guidance_scale","type":"FLOAT","widget":{"name":"guidance_scale"},"link":null},{"localized_name":"true_cfg_scale","name":"true_cfg_scale","type":"FLOAT","widget":{"name":"true_cfg_scale"},"link":null},{"localized_name":"vae_use_slicing","name":"vae_use_slicing","type":"BOOLEAN","widget":{"name":"vae_use_slicing"},"link":null},{"localized_name":"vae_use_tiling","name":"vae_use_tiling","type":"BOOLEAN","widget":{"name":"vae_use_tiling"},"link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null}],"outputs":[{"localized_name":"diffusion sampling params","name":"diffusion sampling params","type":"SAMPLING_PARAMS","links":[30]}],"properties":{"Node name for S&R":"VLLMOmniDiffusionSampling"},"widgets_values":[1,50,1,1,false,false,213,"randomize"]},{"id":18,"type":"VLLMOmniMiniMaxH3Params","pos":[483.0944676363692,426.7197881452892],"size":[276.56796875,82],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"audio_flow_shift","name":"audio_flow_shift","type":"FLOAT","widget":{"name":"audio_flow_shift"},"link":null},{"localized_name":"flow_shift","name":"flow_shift","type":"FLOAT","widget":{"name":"flow_shift"},"link":null}],"outputs":[{"localized_name":"MiniMax-H3 video params","name":"MiniMax-H3 video params","type":"VIDEO_PARAMS","links":[33]}],"properties":{"Node name for S&R":"VLLMOmniMiniMaxH3Params"},"widgets_values":[3,12]},{"id":16,"type":"VLLMOmniGenerateVideo","pos":[826.1428043381621,-148.53719710697413],"size":[425.5948726785682,551.767376721426],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"frame","name":"frame","shape":7,"type":"IMAGE","link":29},{"localized_name":"references","name":"references","shape":7,"type":"VIDEO_REFERENCES","link":null},{"localized_name":"sampling_params","name":"sampling_params","shape":7,"type":"SAMPLING_PARAMS","link":30},{"localized_name":"lora","name":"lora","shape":7,"type":"REMOTE_LORA","link":null},{"localized_name":"model_params","name":"model_params","shape":7,"type":"VIDEO_PARAMS","link":33},{"localized_name":"url","name":"url","type":"STRING","widget":{"name":"url"},"link":null},{"localized_name":"model","name":"model","type":"STRING","widget":{"name":"model"},"link":null},{"localized_name":"prompt","name":"prompt","type":"STRING","widget":{"name":"prompt"},"link":null},{"localized_name":"negative_prompt","name":"negative_prompt","type":"STRING","widget":{"name":"negative_prompt"},"link":null},{"localized_name":"width","name":"width","type":"INT","widget":{"name":"width"},"link":null},{"localized_name":"height","name":"height","type":"INT","widget":{"name":"height"},"link":null},{"localized_name":"fps","name":"fps","type":"INT","widget":{"name":"fps"},"link":null},{"localized_name":"num_frames","name":"num_frames","type":"INT","widget":{"name":"num_frames"},"link":null}],"outputs":[{"localized_name":"video","name":"video","type":"VIDEO","links":[32]}],"properties":{"Node name for S&R":"VLLMOmniGenerateVideo"},"widgets_values":["http://localhost:8000/v1","/workspace/models/hub/models--MiniMaxAI--MiniMax-H3/snapshots/428986c4aa5280855b9ac26eff8d1a46b5999cd0/FL2VA","A scene of urban fantasy art. A dynamic graffiti art character. A boy made of spray paint comes to life on a concrete wall. He sings an English rap song at high speed while striking a classic, energetic rapper pose. The scene is set under an urban railway bridge at night. The light comes from a single street lamp, creating a cinematic atmosphere full of high energy and amazing detail. The audio of the video consists entirely of the rap, with no other dialogue or noise.","",832,480,24,120]}],"links":[[29,4,0,16,0,"IMAGE"],[30,8,0,16,2,"SAMPLING_PARAMS"],[32,16,0,13,0,"VIDEO"],[33,18,0,16,4,"VIDEO_PARAMS"],[34,23,0,21,0,"IMAGE"],[35,24,0,21,2,"AUDIO"]],"groups":[{"id":1,"title":"Image-to-video input (first-frame-to-video)","bounding":[168.92390079062986,-653.2124402506535,560.8246471437027,407.4246532472182],"color":"#3f789e","flags":{}},{"id":2,"title":"Reference-to-video input","bounding":[-66.75175444110151,-225.24978633666066,805.4597228678848,554.7384400819777],"color":"#3f789e","flags":{}}],"config":{},"extra":{"workflowRendererVersion":"LG","ds":{"scale":0.8,"offset":[1240.4751945653188,770.6151743053379]}},"version":0.4} diff --git a/benchmarks/README.md b/benchmarks/README.md index 37a6264a9..0a8fb8058 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -28,6 +28,14 @@ Benchmarks for GLM-Image performance across HuggingFace baseline, vLLM-Omni offl - **Tasks**: text-to-image and image-to-image - **Key metrics**: request/image throughput, latency percentiles, optional per-stage pipeline timings +### [LingBot-Video](lingbot_video/README.md) — Dense and MoE Parity + +Manual cross-runtime validation for the dense LingBot-Video pipeline and the +LingBot-Video MoE transformer. + +- **Dense pipeline**: decoded-video MAE, MSE, PSNR, latency, and optional steady-state timings +- **MoE transformer**: bitwise router, sparse-block, shared-expert, and full-transformer parity + ### [Distributed](distributed/omni_connectors/README.md) — RDMA Connector Testing RDMA environment setup and transfer tests for `MooncakeTransferEngineConnector`, including pytest-based single-node checks and manual cross-node benchmarks. diff --git a/benchmarks/diffusion/backends.py b/benchmarks/diffusion/backends.py index f8ad1a71e..aeff3ebbb 100644 --- a/benchmarks/diffusion/backends.py +++ b/benchmarks/diffusion/backends.py @@ -1,5 +1,6 @@ import asyncio import base64 +import json import mimetypes import os import time @@ -13,6 +14,17 @@ from tqdm import tqdm DEFAULT_EDITS_BOT_TASK = "think" +def _is_minimax_h3_mixed_reference(model: str) -> bool: + """MiniMax-H3 Ref2VA consumes reference videos as file paths. + + Its reference preparer ffprobes / ffmpegs the reference file, so single + ``video_reference`` uploads are sent as a base64 data URL: the server + persists them to disk and keeps ``source_path``, handing the pipeline a + real file path instead of a decoded frame list. + """ + return "MiniMax-H3" in (model or "") + + @dataclass class RequestFuncInput: prompt: str @@ -355,13 +367,21 @@ async def async_request_v1_videos( ) elif input.video_paths and len(input.video_paths) > 0: video_path = input.video_paths[0] - image_file = open(video_path, "rb") - form.add_field( - "input_reference", - image_file, - filename=os.path.basename(video_path), - content_type=_guess_mime_type(video_path), - ) + if _is_minimax_h3_mixed_reference(input.model): + with open(video_path, "rb") as reference_file: + video_b64 = base64.b64encode(reference_file.read()).decode("ascii") + form.add_field( + "video_reference", + json.dumps([{"video_url": f"data:video/mp4;base64,{video_b64}"}]), + ) + else: + image_file = open(video_path, "rb") + form.add_field( + "input_reference", + image_file, + filename=os.path.basename(video_path), + content_type=_guess_mime_type(video_path), + ) job_id = None job_status = None diff --git a/benchmarks/diffusion/diffusion_benchmark_serving.py b/benchmarks/diffusion/diffusion_benchmark_serving.py index 8f7a02858..6daac9df4 100644 --- a/benchmarks/diffusion/diffusion_benchmark_serving.py +++ b/benchmarks/diffusion/diffusion_benchmark_serving.py @@ -858,7 +858,12 @@ class RandomDataset(BaseDataset): height = int(self.args.height or profile.get("height") or 720) num_frames = int(self.args.num_frames or profile.get("num_frames") or 16) fps = float(self.args.fps or profile.get("fps") or 8) - writer = cv2.VideoWriter(video_path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height)) + # cv2 typically writes MPEG-4 Part 2 (``mp4v``), which pipelines such + # as MiniMax-H3 Ref2VA reject because they require H.264/H.265 + # reference videos. Write the synthetic clip first, then re-encode + # it to H.264 when ffmpeg is available; otherwise keep ``mp4v``. + raw_path = video_path + ".raw.mp4" + writer = cv2.VideoWriter(raw_path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height)) if not writer.isOpened(): raise RuntimeError("cv2.VideoWriter failed to open") for frame_idx in range(num_frames): @@ -871,6 +876,10 @@ class RandomDataset(BaseDataset): frame[max(y - 36, 0) : min(y + 36, height), x : min(x + 96, width), :] = (64, 128, 220) writer.write(frame) writer.release() + if _reencode_video_to_h264(raw_path, video_path): + os.unlink(raw_path) + else: + os.replace(raw_path, video_path) return [video_path] except Exception as e: raise RuntimeError( @@ -878,6 +887,40 @@ class RandomDataset(BaseDataset): ) from e +def _reencode_video_to_h264(source: str, destination: str) -> bool: + """Re-encode a video to H.264/MP4 using ffmpeg when available. + + Returns False when ffmpeg is missing or the re-encode fails so callers can + fall back to the original container/codec. + """ + import shutil + import subprocess + + if shutil.which("ffmpeg") is None: + return False + try: + result = subprocess.run( + [ + "ffmpeg", + "-y", + "-loglevel", + "error", + "-i", + source, + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + destination, + ], + capture_output=True, + text=True, + ) + return result.returncode == 0 and os.path.exists(destination) + except Exception: + return False + + def _compute_expected_latency_ms_from_base(req: RequestFuncInput, args, base_time_ms: float | None) -> float | None: """Compute expected execution time (ms) based on a base per-step-per-frame unit time. diff --git a/benchmarks/lingbot_video/README.md b/benchmarks/lingbot_video/README.md new file mode 100644 index 000000000..687973659 --- /dev/null +++ b/benchmarks/lingbot_video/README.md @@ -0,0 +1,62 @@ +# LingBot-Video Benchmarks + +These manual benchmarks compare the upstream LingBot-Video implementation with +vLLM-Omni. Run all commands from the repository root. + +| Benchmark | Script | Purpose | +|-----------|--------|---------| +| Dense pipeline parity | `dense_pipeline_parity.py` | Compare generated videos and report MAE, MSE, PSNR, latency, and optional steady-state timings | +| MoE numerical parity | `moe_transformer_parity.py` | Check bitwise parity for the sparse MoE block and the full MoE transformer | + +Both benchmarks require a local LingBot-Video checkout and local model files. +They do not run in CI. + +## Dense pipeline parity + +The dense benchmark launches the upstream Diffusers inference script and the +vLLM-Omni offline example with identical prompts and sampling parameters. It +then compares the decoded MP4 frames. + +```bash +CUDA_VISIBLE_DEVICES=0 \ +python benchmarks/lingbot_video/dense_pipeline_parity.py \ + --model /path/to/lingbot-video-dense-1.3b \ + --official-repo /path/to/lingbot-video \ + --output-dir /tmp/lingbot_dense_parity +``` + +Use `--runs N` to additionally measure repeated in-process vLLM-Omni +requests. The video comparison reports MAE, MSE, and PSNR; it is an end-to-end +pipeline diagnostic rather than a bitwise transformer check. + +## MoE numerical parity + +The lightweight block comparison uses deterministic weights and inputs to +cover correction-bias routing, group-limited top-k, routed experts, FP32 +scatter-weighted restore, padding masks, and the shared expert. + +```bash +CUDA_VISIBLE_DEVICES=0 \ +python benchmarks/lingbot_video/moe_transformer_parity.py \ + --scope block \ + --official-repo /path/to/lingbot-video \ + --output-json /tmp/lingbot_moe_block_parity.json +``` + +The full comparison loads the upstream and vLLM-Omni 30B transformers +sequentially and compares their output tensors. It requires a CUDA device with +`torch._grouped_mm` and enough memory for one 30B BF16 transformer. + +```bash +CUDA_VISIBLE_DEVICES=0 \ +python benchmarks/lingbot_video/moe_transformer_parity.py \ + --scope transformer \ + --official-repo /path/to/lingbot-video \ + --model /path/to/lingbot-video-moe-30b-a3b \ + --output-json /tmp/lingbot_moe_transformer_parity.json +``` + +For the correctness oracle, the upstream transformer uses +`diffusers:_native_math` and vLLM-Omni uses +`TORCH_SDPA + SDPBackend.MATH`. The command exits with a nonzero status unless +the selected comparisons are bitwise equal. diff --git a/benchmarks/lingbot_video/dense_pipeline_parity.py b/benchmarks/lingbot_video/dense_pipeline_parity.py new file mode 100644 index 000000000..0c0c1d7a0 --- /dev/null +++ b/benchmarks/lingbot_video/dense_pipeline_parity.py @@ -0,0 +1,217 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +import argparse +import json +import re +import statistics +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + +import imageio.v3 as iio +import numpy as np +import torch + +from vllm_omni.diffusion.data import DiffusionParallelConfig +from vllm_omni.entrypoints.omni import Omni +from vllm_omni.inputs.data import OmniDiffusionSamplingParams +from vllm_omni.platforms import current_omni_platform + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="LingBot dense parity and timing harness.") + parser.add_argument("--model", default="/home/models/lingbot-video-dense-1.3b") + parser.add_argument("--official-repo", default="/tmp/lingbot-video") + parser.add_argument("--output-dir", default="/tmp/lingbot_dense_parity") + parser.add_argument("--prompt", default="a robotic arm picks up a red block") + parser.add_argument("--height", type=int, default=192) + parser.add_argument("--width", type=int, default=320) + parser.add_argument("--num-frames", type=int, default=9) + parser.add_argument("--steps", type=int, default=2) + parser.add_argument("--guidance-scale", type=float, default=3.0) + parser.add_argument("--shift", type=float, default=3.0) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--fps", type=int, default=24) + parser.add_argument("--runs", type=int, default=0, help="Optional in-process steady-state native runs.") + return parser.parse_args() + + +def _run(cmd: list[str], cwd: Path | None = None) -> tuple[float, str]: + start = time.perf_counter() + proc = subprocess.run(cmd, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + elapsed = time.perf_counter() - start + if proc.returncode != 0: + print(proc.stdout) + raise subprocess.CalledProcessError(proc.returncode, proc.args, output=proc.stdout) + return elapsed, proc.stdout + + +def _read_video(path: Path) -> np.ndarray: + frames = iio.imread(path, plugin="pyav") + if frames.dtype != np.float32: + frames = frames.astype(np.float32) / 255.0 + return frames + + +def _compare(a: np.ndarray, b: np.ndarray) -> dict[str, Any]: + if a.shape != b.shape: + return {"shape_a": list(a.shape), "shape_b": list(b.shape), "shape_match": False} + diff = a - b + mse = float(np.mean(diff * diff)) + mae = float(np.mean(np.abs(diff))) + psnr = float("inf") if mse == 0 else float(20.0 * np.log10(1.0 / np.sqrt(mse))) + return { + "shape": list(a.shape), + "shape_match": True, + "mae": mae, + "mse": mse, + "psnr": psnr, + } + + +def _extract_total_generation_seconds(log: str) -> float | None: + match = re.search(r"Total generation time:\s*([0-9.]+)\s*seconds", log) + return float(match.group(1)) if match else None + + +def _peak_memory_mb(output: Any) -> float: + result = output[0] if isinstance(output, list) and output else output + return float(getattr(result, "peak_memory_mb", 0.0) or 0.0) + + +def _benchmark_native_requests(args: argparse.Namespace) -> dict[str, Any]: + load_start = time.perf_counter() + omni = Omni( + model=args.model, + model_class_name="LingBotVideoPipeline", + flow_shift=args.shift, + parallel_config=DiffusionParallelConfig(), + ) + load_seconds = time.perf_counter() - load_start + prompt = {"prompt": args.prompt} + latencies: list[float] = [] + peak_mb = 0.0 + + for _ in range(args.runs): + generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed) + sampling_params = OmniDiffusionSamplingParams( + height=args.height, + width=args.width, + num_frames=args.num_frames, + num_inference_steps=args.steps, + guidance_scale=args.guidance_scale, + generator=generator, + output_type="pt", + extra_args={"shift": args.shift}, + ) + start = time.perf_counter() + output = omni.generate(prompt, sampling_params) + latencies.append(time.perf_counter() - start) + peak_mb = max(peak_mb, _peak_memory_mb(output)) + del output + omni.close() + + return { + "load_seconds": load_seconds, + "run_seconds": latencies, + "mean_seconds": statistics.mean(latencies) if latencies else None, + "median_seconds": statistics.median(latencies) if latencies else None, + "min_seconds": min(latencies) if latencies else None, + "max_seconds": max(latencies) if latencies else None, + "peak_mb": peak_mb, + } + + +def main() -> None: + args = parse_args() + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + official_out = output_dir / "official_diffusers.mp4" + native_out = output_dir / "vllm_omni_native.mp4" + + official_cmd = [ + sys.executable, + str(Path(args.official_repo) / "scripts" / "inference.py"), + "--backend", + "diffusers", + "--model_dir", + args.model, + "--mode", + "t2v", + "--prompt", + args.prompt, + "--output", + str(official_out), + "--height", + str(args.height), + "--width", + str(args.width), + "--num_frames", + str(args.num_frames), + "--steps", + str(args.steps), + "--guidance_scale", + str(args.guidance_scale), + "--shift", + str(args.shift), + "--seed", + str(args.seed), + "--fps", + str(args.fps), + "--quiet_progress", + ] + native_cmd = [ + sys.executable, + "examples/offline_inference/text_to_video/text_to_video_lingbot.py", + "--model", + args.model, + "--prompt", + args.prompt, + "--output", + str(native_out), + "--height", + str(args.height), + "--width", + str(args.width), + "--num-frames", + str(args.num_frames), + "--num-inference-steps", + str(args.steps), + "--guidance-scale", + str(args.guidance_scale), + "--flow-shift", + str(args.shift), + "--seed", + str(args.seed), + "--fps", + str(args.fps), + ] + + official_time, official_log = _run(official_cmd, cwd=Path(args.official_repo)) + native_time, native_log = _run(native_cmd, cwd=Path.cwd()) + + result = { + "official_seconds": official_time, + "native_process_seconds": native_time, + "native_request_seconds": _extract_total_generation_seconds(native_log), + "official_output": str(official_out), + "native_output": str(native_out), + "accuracy": _compare(_read_video(official_out), _read_video(native_out)), + "official_log_tail": official_log.splitlines()[-20:], + "native_log_tail": native_log.splitlines()[-20:], + } + if args.runs > 0: + result["native_steady_state"] = _benchmark_native_requests(args) + + result_path = output_dir / "comparison.json" + result_path.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n") + print(json.dumps(result, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/lingbot_video/moe_transformer_parity.py b/benchmarks/lingbot_video/moe_transformer_parity.py new file mode 100644 index 000000000..cdad31f19 --- /dev/null +++ b/benchmarks/lingbot_video/moe_transformer_parity.py @@ -0,0 +1,399 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Reproduce LingBot-Video MoE block and transformer numerical parity.""" + +from __future__ import annotations + +import argparse +import gc +import importlib +import json +import os +import sys +import time +from pathlib import Path +from typing import Any + +import torch + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--scope", + choices=("block", "transformer", "all"), + default="block", + help="Run the lightweight sparse-block check, the real transformer check, or both.", + ) + parser.add_argument( + "--official-repo", + type=Path, + required=True, + help="Local checkout of https://github.com/Robbyant/lingbot-video.", + ) + parser.add_argument( + "--model", + default=None, + help="Local MoE checkpoint path or a checkpoint already present in the Hugging Face cache.", + ) + parser.add_argument("--transformer-subfolder", default="transformer") + parser.add_argument("--output-json", type=Path, default=None) + parser.add_argument("--device", default="cuda") + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--num-frames", type=int, default=1) + parser.add_argument("--height", type=int, default=8) + parser.add_argument("--width", type=int, default=8) + parser.add_argument("--text-length", type=int, default=16) + return parser.parse_args() + + +def _load_official_module(official_repo: Path): + transformer_file = official_repo / "lingbot_video" / "transformer_lingbot_video.py" + if not transformer_file.is_file(): + raise FileNotFoundError(f"Upstream transformer not found: {transformer_file}") + sys.path.insert(0, str(official_repo)) + module = importlib.import_module("lingbot_video.transformer_lingbot_video") + module_path = Path(module.__file__).resolve() + if official_repo.resolve() not in module_path.parents: + raise RuntimeError(f"Imported LingBot module from {module_path}, not {official_repo.resolve()}") + return module + + +def _load_native_module(): + return importlib.import_module("vllm_omni.diffusion.models.lingbot_video.lingbot_video_transformer") + + +def _tensor_metrics(actual: torch.Tensor, expected: torch.Tensor) -> dict[str, Any]: + actual_float = actual.float() + expected_float = expected.float() + diff = (actual_float - expected_float).abs() + denominator = actual_float.norm() * expected_float.norm() + cosine = ( + float(torch.dot(actual_float.flatten(), expected_float.flatten()) / denominator) + if float(denominator) > 0 + else 1.0 + ) + return { + "shape": list(actual.shape), + "equal": bool(torch.equal(actual, expected)), + "max_abs": float(diff.max()) if diff.numel() else 0.0, + "mean_abs": float(diff.mean()) if diff.numel() else 0.0, + "rmse": float(torch.sqrt(torch.mean(diff.square()))) if diff.numel() else 0.0, + "cosine": cosine, + "finite": bool(torch.isfinite(actual).all() and torch.isfinite(expected).all()), + } + + +def _initialize_block(module: torch.nn.Module, seed: int) -> None: + generator = torch.Generator(device="cpu").manual_seed(seed) + with torch.no_grad(): + for parameter in module.parameters(): + values = torch.randn(parameter.shape, generator=generator, dtype=torch.float32) + parameter.copy_((values * 0.02).to(parameter.dtype)) + module.router.e_score_correction_bias.copy_(torch.tensor([0.9, 0.8, 1.0, 0.0, 0.7, 0.6, 0.5, 0.4])) + + +def _apply_padding( + scores: torch.Tensor, + padding_mask: torch.Tensor, + route_scale: float, +) -> torch.Tensor: + scores = scores * padding_mask.unsqueeze(-1).to(scores.dtype) + scores = scores / (scores.sum(dim=-1, keepdim=True) + 1e-9) + return scores * route_scale + + +def _run_block_parity( + official_module, + native_module, + *, + device: torch.device, + seed: int, +) -> dict[str, Any]: + if not hasattr(torch, "_grouped_mm"): + raise RuntimeError("The block parity check requires torch._grouped_mm.") + + common = { + "hidden_size": 16, + "num_experts": 8, + "top_k": 2, + "moe_intermediate_size": 8, + "score_func": "sigmoid", + "norm_topk_prob": True, + "n_group": 4, + "topk_group": 1, + "routed_scaling_factor": 1.5, + "n_shared_experts": 1, + } + official = official_module.LingBotVideoSparseMoeBlock( + intermediate_size=32, + **common, + ) + native = native_module.LingBotVideoSparseMoeBlock(**common) + _initialize_block(official, seed) + native.load_state_dict(official.state_dict(), strict=True) + official = official.to(device=device, dtype=torch.bfloat16).eval() + native = native.to(device=device, dtype=torch.bfloat16).eval() + + generator = torch.Generator(device=device).manual_seed(seed) + hidden_states = torch.randn( + 2, + 5, + common["hidden_size"], + generator=generator, + device=device, + dtype=torch.bfloat16, + ) + padding_mask = torch.tensor( + [1, 1, 1, 1, 1, 1, 1, 1, 0, 0], + device=device, + dtype=torch.float32, + ) + tokens = hidden_states.reshape(-1, common["hidden_size"]) + + with torch.inference_mode(): + official_router = official.router(tokens) + official_indices, official_scores = official_router[:2] + native_indices, native_scores = native.router(tokens) + + official_scores = _apply_padding( + official_scores, + padding_mask, + official.router.route_scale, + ) + native_scores = _apply_padding( + native_scores, + padding_mask, + native.router.route_scale, + ) + official_routed = official._run_selected_experts( + tokens, + official_scores, + official_indices, + ) + native_routed = native._run_selected_experts( + tokens, + native_scores, + native_indices, + ) + official_shared = official.shared_experts(hidden_states) + native_shared = native.shared_experts(hidden_states) + official_output = official(hidden_states, padding_mask=padding_mask) + native_output = native(hidden_states, padding_mask=padding_mask) + + result = { + "router_indices_equal": bool(torch.equal(native_indices, official_indices)), + "router_scores": _tensor_metrics(native_scores, official_scores), + "routed_output": _tensor_metrics(native_routed, official_routed), + "shared_output": _tensor_metrics(native_shared, official_shared), + "final_output": _tensor_metrics(native_output, official_output), + } + result["exact"] = bool( + result["router_indices_equal"] + and result["router_scores"]["equal"] + and result["routed_output"]["equal"] + and result["shared_output"]["equal"] + and result["final_output"]["equal"] + ) + return result + + +def _release_cuda() -> None: + gc.collect() + torch.accelerator.empty_cache() + torch.accelerator.synchronize() + + +def _make_transformer_inputs( + config, + args: argparse.Namespace, +) -> dict[str, torch.Tensor]: + generator = torch.Generator(device="cpu").manual_seed(args.seed) + hidden_states = torch.randn( + 1, + int(config.in_channels), + args.num_frames, + args.height, + args.width, + generator=generator, + dtype=torch.float32, + ).to(torch.bfloat16) + encoder_hidden_states = torch.randn( + 1, + args.text_length, + int(config.text_dim), + generator=generator, + dtype=torch.float32, + ).to(torch.bfloat16) + return { + "hidden_states": hidden_states, + "timestep": torch.tensor([500.0], dtype=torch.float32), + "encoder_hidden_states": encoder_hidden_states, + "encoder_attention_mask": torch.ones(1, args.text_length, dtype=torch.long), + } + + +def _load_transformer( + transformer_cls, + args: argparse.Namespace, + device: torch.device, +): + start = time.perf_counter() + model = transformer_cls.from_pretrained( + args.model, + subfolder=args.transformer_subfolder, + torch_dtype=torch.bfloat16, + local_files_only=True, + low_cpu_mem_usage=True, + ) + model = model.to(device=device, dtype=torch.bfloat16).eval() + torch.accelerator.synchronize() + return model, time.perf_counter() - start + + +def _forward_transformer( + model, + inputs: dict[str, torch.Tensor], + device: torch.device, + attention_context, +) -> tuple[torch.Tensor, float, float]: + torch.accelerator.reset_peak_memory_stats(device) + gpu_inputs = {name: value.to(device) for name, value in inputs.items()} + start = time.perf_counter() + with torch.inference_mode(), attention_context: + output = model(**gpu_inputs, return_dict=False)[0] + torch.accelerator.synchronize() + elapsed = time.perf_counter() - start + peak_gib = torch.accelerator.max_memory_reserved(device) / (1024**3) + return output.cpu(), elapsed, peak_gib + + +def _run_transformer_parity( + official_module, + native_module, + *, + args: argparse.Namespace, + device: torch.device, +) -> dict[str, Any]: + if args.model is None: + raise ValueError("--model is required for transformer parity.") + + from diffusers.models.attention_dispatch import attention_backend + from torch.nn.attention import SDPBackend, sdpa_kernel + + from vllm_omni.diffusion.config import set_current_diffusion_config + from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, OmniDiffusionConfig + + official, official_load = _load_transformer( + official_module.LingBotVideoTransformer3DModel, + args, + device, + ) + inputs = _make_transformer_inputs(official.config, args) + official_output, official_forward, official_peak = _forward_transformer( + official, + inputs, + device, + attention_backend("_native_math"), + ) + del official + _release_cuda() + + native_config = OmniDiffusionConfig( + diffusion_attention_config=AttentionConfig( + default=AttentionSpec(backend="TORCH_SDPA"), + ), + ) + with set_current_diffusion_config(native_config): + native, native_load = _load_transformer( + native_module.LingBotVideoTransformer3DModel, + args, + device, + ) + native_backend_prefs = {block.attn.attn.backend_pref for block in native.blocks} + if native_backend_prefs != {"TORCH_SDPA"}: + raise RuntimeError( + f"Failed to force the native transformer to TORCH_SDPA: resolved preferences were {native_backend_prefs}." + ) + native_output, native_forward, native_peak = _forward_transformer( + native, + inputs, + device, + sdpa_kernel(SDPBackend.MATH), + ) + del native + _release_cuda() + + metrics = _tensor_metrics(native_output, official_output) + return { + "backends": { + "official": "diffusers:_native_math", + "native": "TORCH_SDPA+SDPBackend.MATH", + }, + "official": { + "load_seconds": official_load, + "forward_seconds": official_forward, + "peak_reserved_gib": official_peak, + }, + "native": { + "load_seconds": native_load, + "forward_seconds": native_forward, + "peak_reserved_gib": native_peak, + }, + "output": metrics, + "exact": metrics["equal"], + } + + +def main() -> int: + args = parse_args() + device = torch.device(args.device) + if device.type != "cuda" or not torch.cuda.is_available(): + raise RuntimeError("LingBot MoE parity requires an available CUDA device.") + + os.environ["DIFFUSERS_ATTN_BACKEND"] = "_native_math" + os.environ["DIFFUSION_ATTENTION_BACKEND"] = "TORCH_SDPA" + os.environ["LINGBOT_MOE_EXPERT_BACKEND"] = "grouped_mm" + os.environ["LINGBOT_MOE_PAD_BACKEND"] = "loop" + os.environ["LINGBOT_MOE_REORDER_BACKEND"] = "sort" + os.environ["LINGBOT_MOE_RESTORE_BACKEND"] = "scatter" + + official_module = _load_official_module(args.official_repo) + native_module = _load_native_module() + + result: dict[str, Any] = { + "settings": { + "scope": args.scope, + "seed": args.seed, + "device": str(device), + "official_repo": str(args.official_repo.resolve()), + "model": args.model, + } + } + if args.scope in {"block", "all"}: + result["block"] = _run_block_parity( + official_module, + native_module, + device=device, + seed=args.seed, + ) + if args.scope in {"transformer", "all"}: + result["transformer"] = _run_transformer_parity( + official_module, + native_module, + args=args, + device=device, + ) + + result["exact"] = all(section["exact"] for name, section in result.items() if name in {"block", "transformer"}) + payload = json.dumps(result, indent=2, sort_keys=True) + print(payload) + if args.output_json is not None: + args.output_json.parent.mkdir(parents=True, exist_ok=True) + args.output_json.write_text(payload + "\n", encoding="utf-8") + return 0 if result["exact"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docker/Dockerfile.ci b/docker/Dockerfile.ci index f4444f8ac..02a7be452 100644 --- a/docker/Dockerfile.ci +++ b/docker/Dockerfile.ci @@ -1,12 +1,26 @@ -# Target vllm version: v0.24.0 (releases/v0.24.0 branch). -# The pinned VLLM_PRECOMPILED_WHEEL_COMMIT and VLLM_BASE_TAG below must -# be updated on every rebase to match the new upstream vllm commit/tag. +# Target vllm version: v0.26.0 (releases/v0.26.0 branch). +# Keep VLLM_BASE_TAG aligned with the target vLLM release on every rebase. ARG VLLM_BASE_IMAGE=vllm/vllm-openai -ARG VLLM_BASE_TAG=v0.25.0 -FROM ${VLLM_BASE_IMAGE}:${VLLM_BASE_TAG} -ARG APP_DIR=/workspace/vllm-omni +ARG VLLM_BASE_TAG=v0.26.0 + +# We use registry-based layer caching for the dependencies, +# where the tag is determined based on the hash of the contents +# of files like pyproject.toml, etc. The deps stage should have +# everything except the actual project dir, since that should not +# change much across branches. +FROM ${VLLM_BASE_IMAGE}:${VLLM_BASE_TAG} AS deps +# Keep APP_DIR outside /workspace so k8s Buildkite workspace mounts cannot +# shadow the editable install target (#5364). Docker WORKDIR creates /opt if +# needed. +ARG APP_DIR=/opt/vllm-omni WORKDIR ${APP_DIR} -COPY . . + +RUN ln -sf /usr/bin/python3 /usr/bin/python + +# Placeholder package tree so editable install can register APP_DIR. +# The final stage COPY --link fills this directory with the real sources; +# the editable .pth / egg-link keeps resolving imports there — no PYTHONPATH. +RUN mkdir -p vllm_omni && touch vllm_omni/__init__.py # Install system dependencies RUN apt-get update && \ @@ -14,9 +28,30 @@ RUN apt-get update && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -RUN uv pip install --system ".[dev]" +# Copy dependency related things first and install deps/ +# We set the version str to 0.0.0 since it doesn't matter +# for CI +COPY pyproject.toml setup.py ./ +COPY requirements/ requirements/ +ENV SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 +# Editable install: deps + project metadata are cached in this layer, while +# the package itself is linked to APP_DIR (populated later by COPY --link). +# Do NOT use a non-editable install here — that copies an empty stub into +# site-packages and breaks ``import vllm_omni.entrypoints``. +RUN uv pip install --system -e ".[dev]" +# The final stage just copies the working dir on top of the +# dependencies image. This uses --link so that we can skip +# the image pull in Buildkite when we have a cache hit. +# +# IMPORTANT: Do not make any modifications that depend on the +# file system after the --link copy, otherwise we'd need to pull +# the heavy base image to build it. Editable install above already +# registered APP_DIR, so we must not re-pip-install after COPY. +FROM deps +ARG APP_DIR=/opt/vllm-omni +WORKDIR ${APP_DIR} -RUN ln -sf /usr/bin/python3 /usr/bin/python +COPY --link . . ENTRYPOINT [] diff --git a/docker/Dockerfile.ci.dockerignore b/docker/Dockerfile.ci.dockerignore new file mode 100644 index 000000000..ab137c2b5 --- /dev/null +++ b/docker/Dockerfile.ci.dockerignore @@ -0,0 +1,37 @@ +# NOTE: this is largely taken from vLLM + +# Exclude .git for CI builds — version is set via SETUPTOOLS_SCM_PRETEND_VERSION +.git +.venv +/build +dist + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +.mypy_cache + +# Distribution / packaging +.Python +/build/ +cmake-build-*/ +CMakeUserPresets.json +develop-eggs/ +/dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +rust/target/ diff --git a/docker/Dockerfile.cuda b/docker/Dockerfile.cuda index 4e9075f2a..f2e80eaf3 100644 --- a/docker/Dockerfile.cuda +++ b/docker/Dockerfile.cuda @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=vllm/vllm-openai:v0.25.0 +ARG BASE_IMAGE=vllm/vllm-openai:v0.26.0 FROM ${BASE_IMAGE} ARG COMMON_WORKDIR=/app diff --git a/docker/Dockerfile.npu b/docker/Dockerfile.npu index 642b89882..05a780542 100644 --- a/docker/Dockerfile.npu +++ b/docker/Dockerfile.npu @@ -1,5 +1,5 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.25.0 +ARG VLLM_ASCEND_TAG=v0.26.0 FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} # WORKDIR /vllm-workspace/vllm @@ -26,6 +26,10 @@ RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \ python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation +RUN python3 -m pip install -v --no-deps \ + "stepaudio2-minicpmo>=0.1.1" \ + "step-audio2>=1.0.0" + ENV VLLM_WORKER_MULTIPROC_METHOD=spawn ENTRYPOINT [] diff --git a/docker/Dockerfile.npu.a3 b/docker/Dockerfile.npu.a3 index 4fe1d56fe..d226a505f 100644 --- a/docker/Dockerfile.npu.a3 +++ b/docker/Dockerfile.npu.a3 @@ -1,5 +1,5 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.25.0-a3 +ARG VLLM_ASCEND_TAG=v0.26.0-a3 FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} # WORKDIR /vllm-workspace/vllm @@ -26,6 +26,10 @@ RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \ python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation +RUN python3 -m pip install -v --no-deps \ + "stepaudio2-minicpmo>=0.1.1" \ + "step-audio2>=1.0.0" + ENV VLLM_WORKER_MULTIPROC_METHOD=spawn ENTRYPOINT [] diff --git a/docker/Dockerfile.npu.ci b/docker/Dockerfile.npu.ci index f5c3cdfaf..fa2c0d45f 100644 --- a/docker/Dockerfile.npu.ci +++ b/docker/Dockerfile.npu.ci @@ -1,11 +1,13 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.25.0 +ARG VLLM_ASCEND_TAG=v0.26.0 FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} ARG APP_DIR=/vllm-workspace/vllm-omni WORKDIR ${APP_DIR} COPY . . +RUN sed -i "s@^\(deb.*\)https\?://[a-z0-9.-]*\.ubuntu\.com@\1http://cache-service.nginx-pypi-cache.svc.cluster.local:8081@g" /etc/apt/sources.list + RUN apt-get update && \ apt-get install -y --no-install-recommends \ ffmpeg \ @@ -33,8 +35,16 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ "psutil>=7.2.0" \ "soundfile>=0.13.1" \ "pyttsx3>=2.99" \ + "opencc==1.4.1" \ "opencv-python>=4.12.0.88" \ "imageio[ffmpeg]>=0.6.0" \ + "jiwer>=3.0.0" \ + "zhon>=2.0.0" \ + "zhconv>=1.4.2" \ + "funasr>=1.0.0" \ + "hyperpyyaml>=1.2.0" \ + "onnx>=1.16.0" \ + "librosa>=0.10.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 && \ @@ -43,6 +53,17 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ --trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \ --default-timeout=60 +# MiniCPM-o 4.5 Stage-2 vocoder assets: s3tokenizer plus flashcosyvoice, which +# is shipped by step-audio2. --no-deps keeps pip from replacing the Ascend torch +# build and from shadowing onnxruntime-cann with the vanilla onnxruntime wheel; +# the deps these two actually need are installed above. +RUN python3 -m pip install -v --no-deps \ + "s3tokenizer>=0.3.0" \ + "step-audio2>=1.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 + ENV VLLM_WORKER_MULTIPROC_METHOD=spawn ENTRYPOINT [] diff --git a/docker/Dockerfile.npu.ci.a3 b/docker/Dockerfile.npu.ci.a3 index 6a8ac4678..383eea892 100644 --- a/docker/Dockerfile.npu.ci.a3 +++ b/docker/Dockerfile.npu.ci.a3 @@ -1,11 +1,13 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.25.0-a3 +ARG VLLM_ASCEND_TAG=v0.26.0-a3 FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} ARG APP_DIR=/vllm-workspace/vllm-omni WORKDIR ${APP_DIR} COPY . . +RUN sed -i "s@^\(deb.*\)https\?://[a-z0-9.-]*\.ubuntu\.com@\1http://cache-service.nginx-pypi-cache.svc.cluster.local:8081@g" /etc/apt/sources.list + RUN apt-get update && \ apt-get install -y --no-install-recommends \ ffmpeg \ @@ -33,8 +35,17 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ "psutil>=7.2.0" \ "soundfile>=0.13.1" \ "pyttsx3>=2.99" \ + "opencc==1.4.1" \ "opencv-python>=4.12.0.88" \ "imageio[ffmpeg]>=0.6.0" \ + "jiwer>=3.0.0" \ + "zhon>=2.0.0" \ + "zhconv>=1.4.2" \ + "funasr>=1.0.0" \ + "hyperpyyaml>=1.2.0" \ + "onnx>=1.16.0" \ + "librosa>=0.10.0" \ + "torchmetrics>=1.9.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 && \ @@ -43,6 +54,17 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ --trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \ --default-timeout=60 +# MiniCPM-o 4.5 Stage-2 vocoder assets: s3tokenizer plus flashcosyvoice, which +# is shipped by step-audio2. --no-deps keeps pip from replacing the Ascend torch +# build and from shadowing onnxruntime-cann with the vanilla onnxruntime wheel; +# the deps these two actually need are installed above. +RUN python3 -m pip install -v --no-deps \ + "s3tokenizer>=0.3.0" \ + "step-audio2>=1.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 + ENV VLLM_WORKER_MULTIPROC_METHOD=spawn ENTRYPOINT [] diff --git a/docker/Dockerfile.rocm b/docker/Dockerfile.rocm index 49c6f02aa..6b5a12309 100644 --- a/docker/Dockerfile.rocm +++ b/docker/Dockerfile.rocm @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=vllm/vllm-openai-rocm:v0.25.0 +ARG BASE_IMAGE=vllm/vllm-openai-rocm:v0.26.0 FROM ${BASE_IMAGE} AS base # Declare a variable to know if we want to use the nightly build or the stable build. @@ -10,14 +10,14 @@ FROM ${BASE_IMAGE} AS base # we should swap over to use stable release ASAP. # We should avoid relying on custom commits. ARG USE_NIGHTLY_BUILD=0 -ARG VLLM_VERSION_OR_COMMIT_HASH=89138b21cc246ae944c741d5c399c148e2b770ab +ARG VLLM_VERSION_OR_COMMIT_HASH=v0.26.0 ARG ARG_PYTORCH_ROCM_ARCH ENV PYTORCH_ROCM_ARCH=${ARG_PYTORCH_ROCM_ARCH:-${PYTORCH_ROCM_ARCH}} ARG COMMON_WORKDIR=/app WORKDIR ${COMMON_WORKDIR} -# Step 1: Setup - Install system dependencies +# Step 1: Setup - Install system dependencies. # Need to include ffmpeg because vllm rocm upstream docker image # does not include it. RUN apt-get update && \ @@ -38,6 +38,26 @@ RUN if [ "${USE_NIGHTLY_BUILD}" = "1" ]; then \ rm -rf vllm/.git; \ fi +# Build TorchCodec after any optional nightly vLLM reinstall so it targets the +# final torch/ROCm ABI. Keep this layer before COPY . so source changes do not +# invalidate the expensive native build. FFmpeg development headers are +# removed after the wheel is built. +COPY tools/install_torchcodec_rocm.sh /tmp/install_torchcodec_rocm.sh +RUN --mount=type=cache,target=/root/.cache/pip \ + --mount=type=cache,target=/root/.cache/torchcodec-wheels \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + pkg-config \ + libavcodec-dev libavformat-dev libavutil-dev \ + libswscale-dev libavdevice-dev libavfilter-dev libswresample-dev && \ + bash /tmp/install_torchcodec_rocm.sh && \ + apt-get purge -y \ + libavcodec-dev libavformat-dev libavutil-dev \ + libswscale-dev libavdevice-dev libavfilter-dev libswresample-dev && \ + rm -f /tmp/install_torchcodec_rocm.sh && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + # Step 3: Copy vllm-omni code and install without uv RUN mkdir -p ${COMMON_WORKDIR}/vllm-omni COPY . ${COMMON_WORKDIR}/vllm-omni @@ -59,7 +79,10 @@ RUN printf '%s\n' \ ' os._exit(_exit_code)' \ > ${COMMON_WORKDIR}/vllm-omni/conftest.py -RUN cd ${COMMON_WORKDIR}/vllm-omni && uv pip install --python "$(python3 -c 'import sys; print(sys.executable)')" --no-cache-dir ".[dev]" --no-build-isolation +RUN cd ${COMMON_WORKDIR}/vllm-omni && \ + uv pip install --python "$(python3 -c 'import sys; print(sys.executable)')" \ + --no-cache-dir ".[dev]" --no-build-isolation && \ + python3 -c "from torchcodec.decoders import VideoDecoder" RUN ln -sf /usr/bin/python3 /usr/bin/python diff --git a/docker/Dockerfile.xpu b/docker/Dockerfile.xpu index b70dd274e..5d298040c 100644 --- a/docker/Dockerfile.xpu +++ b/docker/Dockerfile.xpu @@ -88,7 +88,7 @@ ENV UV_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL} ENV UV_INDEX_STRATEGY="unsafe-best-match" ENV UV_LINK_MODE="copy" -ARG VLLM_VERSION=v0.25.0 +ARG VLLM_VERSION=v0.26.0 RUN git clone -b ${VLLM_VERSION} https://github.com/vllm-project/vllm WORKDIR /workspace/vllm diff --git a/docs/.nav.yml b/docs/.nav.yml index cd5a83807..fc11bb838 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -16,13 +16,11 @@ nav: - Examples: - examples/README.md - Offline Inference: - - BAGEL-7B-MoT: user_guide/examples/offline_inference/bagel.md - 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 - - MammothModa2-Preview: user_guide/examples/offline_inference/mammothmodal2_preview.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 @@ -56,6 +54,7 @@ nav: - Diffusion Features: - Overview: user_guide/diffusion_features.md - Feature Compatibility: user_guide/feature_compatibility.md + - Regional Compilation: user_guide/diffusion/regional_compilation.md - Cache Acceleration: - TeaCache: user_guide/diffusion/cache_acceleration/teacache.md - Cache-DiT: user_guide/diffusion/cache_acceleration/cache_dit.md @@ -69,13 +68,12 @@ nav: - Sequence Parallel: user_guide/diffusion/parallelism/sequence_parallel.md - Tensor Parallel: user_guide/diffusion/parallelism/tensor_parallel.md - VAE Parallelism: user_guide/diffusion/parallelism/vae_parallelism.md - - CPU Offloading: user_guide/diffusion/cpu_offload_diffusion.md + - CPU Offloading: user_guide/diffusion/cpu_offload.md - LoRA: user_guide/diffusion/lora.md - Custom Pipeline: features/custom_pipeline.md - - Request-Level Batching: user_guide/diffusion/request_batching.md - - Step Execution: user_guide/diffusion/step_execution.md + - Execution Modes: user_guide/diffusion/execution_modes.md - Quantization: - - Overview: user_guide/quantization/overview.md + - Quantization: user_guide/quantization/overview.md - Online Quantization: user_guide/quantization/online.md - Quantized KV Cache: user_guide/quantization/quantized_kvcache.md - FP8 W8A8: user_guide/quantization/fp8.md @@ -103,30 +101,49 @@ nav: - CI: contributing/ci - Design Documents: - design/index.md - - design/architecture_overview.md + - Architecture Documents: + - Architecture Overview: design/architecture_overview.md - Feature Design: - - design/feature/disaggregated_inference.md - - design/feature/ray_based_execution.md - - design/feature/omni_connectors/ - - design/feature/prefix_caching.md - - design/feature/cfg_parallel.md - - design/feature/expert_parallel.md - - design/feature/sequence_parallel.md - - design/feature/tensor_parallel.md - - design/feature/vae_parallel.md - - design/feature/hsdp.md - - design/feature/cache_dit.md - - design/feature/teacache.md - - design/feature/async_chunk.md - - design/feature/vae_parallel.md - - design/feature/diffusion_step_execution.md - - design/feature/diffusion_request_level_batching.md - - design/feature/diffusion_continuous_batching.md + - Runtime and Stage Execution: + - Disaggregated Inference: design/feature/disaggregated_inference.md + - Async Chunk: design/feature/async_chunk.md + - Async Diffusion Output: design/feature/async_diffusion_output.md + - Async Omni Output Materialization: design/feature/omni_async_output_materialization.md + - Automatic Prefix Caching: design/feature/prefix_caching.md + - Communication: + - OmniConnector Implementations: + - Mooncake Store Connector: design/feature/omni_connectors/mooncake_store_connector.md + - Mooncake Transfer Engine Connector: design/feature/omni_connectors/mooncake_transfer_engine_connector.md + - Mori Transfer Engine Connector: design/feature/omni_connectors/mori_transfer_engine_connector.md + - Shared Memory Connector: design/feature/omni_connectors/shared_memory_connector.md + - Yuanrong Store Connector: design/feature/omni_connectors/yuanrong_connector.md + - Yuanrong Transfer Engine Connector: design/feature/omni_connectors/yuanrong_transfer_engine_connector.md + - Diffusion Acceleration: + - Parallelism: + - CFG-Parallel: design/feature/cfg_parallel.md + - Expert Parallel: design/feature/expert_parallel.md + - Hybrid Sharded Data Parallel (HSDP): design/feature/hsdp.md + - Pipeline Parallel: design/feature/pipeline_parallel.md + - Sequence Parallel: design/feature/sequence_parallel.md + - Tensor Parallel: design/feature/tensor_parallel.md + - VAE Patch Parallelism: design/feature/vae_parallel.md + - Attention Backends: + - Skip-Softmax: design/feature/skip_softmax.md + - Quantization: design/feature/quantization.md + - Cache-DiT: design/feature/cache_dit.md + - TeaCache: design/feature/teacache.md + - Diffusion Continuous Batching: design/feature/diffusion_continuous_batching.md + - Distributed Layerwise Offload: design/feature/distributed_layerwise_offload.md + - Infrastructure and Performance: + - Prometheus Metrics: design/metrics.md + - Speech Generation Performance Optimizations: design/qwen3_omni_tts_performance_optimization.md - Module Design: - Entrypoints: design/module/entrypoints.md - vLLM-Omni Configuration: design/module/vllm_omni_config.md - Input, Output, and Modality Contracts: design/module/input_output_modality_contracts.md + - Error Contracts: design/module/error_contracts.md - Engine Orchestration: design/module/engine_orchestration.md + - Stage Runtime: design/module/stage_runtime.md - OmniConnector: design/module/omni_connector.md - Model Integration: design/module/model_integration.md - Autoregressive Runtime: design/module/ar_runtime.md diff --git a/docs/README.md b/docs/README.md index f6263483b..f3dfe70c2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,12 +52,13 @@ vLLM-Omni is flexible and easy to use with: - Tensor, pipeline, data and expert parallelism support for distributed inference - Streaming outputs - OpenAI-compatible API server +- Full-duplex realtime serving with streaming audio input and output (experimental) vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including: -- Omni-modality models (e.g. Qwen3-Omni, Cosmos3, HunyuanImage, BAGEL) +- Omni-modality models (e.g. Qwen3-Omni, MiniCPM-o 4.5, Cosmos3, HunyuanImage, BAGEL) - TTS models (e.g. Qwen3-TTS, VoxCPM2, Ming-Omni-TTS, CosyVoice3) -- Diffusion models — image, video, and audio generation (e.g. Qwen-Image, Wan2.2, FLUX) +- Diffusion models — image, video, and audio generation (e.g. MiniMax H3, Qwen-Image, Wan2.2, FLUX) - Robot-policy and action models (e.g. GR00T-N1.7, DreamZero-DROID, InternVLA-A1, Cosmos3 action policy) For more information, checkout the following: diff --git a/docs/api/README.md b/docs/api/README.md index cfea6d175..4a34e3f31 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -62,7 +62,6 @@ Engine classes for offline and online inference. Core scheduling and caching components. -- [vllm_omni.core.sched.omni_ar_scheduler.KVCacheTransferData][] - [vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler][] - [vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler][] - [vllm_omni.core.sched.output.OmniCachedRequestData][] @@ -131,7 +130,7 @@ Worker classes and model runners for distributed inference. - [vllm_omni.diffusion.worker.diffusion_worker.DiffusionWorker][] - [vllm_omni.diffusion.worker.diffusion_worker.WorkerProc][] - [vllm_omni.diffusion.worker.diffusion_worker.WorkerWrapperBase][] -- [vllm_omni.diffusion.worker.utils.DiffusionRequestState][] +- [vllm_omni.diffusion.worker.utils.StepRequestState][] - [vllm_omni.diffusion.worker.utils.RunnerOutput][] - [vllm_omni.platforms.npu.worker.npu_ar_model_runner.ExecuteModelState][] - [vllm_omni.platforms.npu.worker.npu_ar_model_runner.NPUARModelRunner][] diff --git a/docs/assets/WeChat.jpg b/docs/assets/WeChat.jpg index cb7300de2..af1bea53c 100644 Binary files a/docs/assets/WeChat.jpg and b/docs/assets/WeChat.jpg differ diff --git a/docs/cli/README.md b/docs/cli/README.md index 9e3240de4..40858ca1a 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -18,18 +18,12 @@ Specify the port: vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 ``` -For a migrated model, load a custom deploy configuration with `--deploy-config`: +Load a custom deploy configuration with `--deploy-config`: ```bash vllm serve Qwen/Qwen2.5-Omni-7B --omni --deploy-config /path/to/deploy_config.yaml ``` -The deprecated `--stage-configs-path` flag is retained for models that still use the legacy `stage_args` schema: - -```bash -vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --stage-configs-path /path/to/legacy_stage_config.yaml -``` - ## bench Run benchmark tests for online serving throughput. diff --git a/docs/cli/serve.md b/docs/cli/serve.md index 9d1747e0b..cf3d068a6 100644 --- a/docs/cli/serve.md +++ b/docs/cli/serve.md @@ -8,8 +8,6 @@ The stage-based CLI is designed for deployments that require launching each pipe - For **migrated models** that utilize the bundled deployment YAML configurations located in `vllm_omni/deploy/`, the `--deploy-config` flag is only required to override the default configuration. By default, executing `vllm serve MODEL --omni ...` automatically loads the bundled deployment configuration. -- For **legacy models** utilizing configuration files located in - `vllm_omni/model_executor/stage_configs/`, the `--stage-configs-path` parameter remains mandatory. Example: Initializing Stage 0 (Orchestrator and API Server): The commands below show a common device mapping where Stage 0 uses GPU 0 and @@ -33,7 +31,7 @@ CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \ --omni-master-port 26000 ``` -When utilizing a custom deployment YAML based on the new schema, append `--deploy-config /path/to/override.yaml` to each command execution. Conversely, for legacy models, substitute this parameter with `--stage-configs-path /path/to/stage_configs.yaml`. +When utilizing a custom deployment YAML, append `--deploy-config /path/to/override.yaml` to each command execution. In the standard execution paradigm, the `--stage-overrides` argument is utilized to apply stage-specific configurations from a single CLI command. However, under the **stage-based CLI** paradigm, where each process strictly encapsulates a single stage, it is recommended to specify tuning parameters directly via discrete command-line flags for the respective stage, rather than constructing a composite `--stage-overrides` JSON string. diff --git a/docs/community/governance.md b/docs/community/governance.md index 3b0c62c09..63d38cd20 100644 --- a/docs/community/governance.md +++ b/docs/community/governance.md @@ -40,6 +40,7 @@ Committers have write access and merge rights. They typically have deep expertis - [@Isotr0py](https://github.com/Isotr0py): Diffusion and Quantization - [@linyueqian](https://github.com/linyueqian): TTS and Omni Support - [@lishunyang12](https://github.com/lishunyang12): Quantization and Configuration +- [@NickCao](https://github.com/NickCao): CI, Tests, Entrypoint, and TTS Model Support - [@princepride](https://github.com/princepride): Diffusion and Omni Support - [@RuixiangMa](https://github.com/RuixiangMa): Diffusion models, parallel, cache, and docs - [@SamitHuang](https://github.com/SamitHuang): RL, Diffusion, and cache diff --git a/docs/configuration/README.md b/docs/configuration/README.md index 390176e9c..09772ccd1 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -2,7 +2,7 @@ This section lists the most common options for running vLLM-Omni. -For options within a vLLM Engine. Please refer to [vLLM Configuration](https://docs.vllm.ai/en/v0.16.0/configuration/index.html) +For options within a vLLM Engine, please refer to the [vLLM 0.26 configuration guide](https://docs.vllm.ai/en/v0.26.0/configuration/index.html). Currently, the main options are maintained by stage configs for each model. diff --git a/docs/configuration/composable_parallel.md b/docs/configuration/composable_parallel.md index a456de843..473605c69 100644 --- a/docs/configuration/composable_parallel.md +++ b/docs/configuration/composable_parallel.md @@ -34,12 +34,8 @@ Two additional `StrategySpec` slots — `layer_hook_specs` and `kernel_specs` | Flag | Description | |------|-------------| | `--strategy-config PATH` | Path to a `strategy.yaml`. Only takes effect on registry-based deploy paths (`--deploy-config` or the bundled default deploy YAML). Its derived sizing is overlaid onto the merged stages *before* CLI overrides. | -| `--stage-configs-path PATH` | **Legacy.** Loads the old `stage_args` YAML schema for un-migrated models. Passing `--strategy-config` together with this path is silently inapplicable — vLLM-Omni emits a warning and the strategy is ignored (see `vllm_omni/entrypoints/utils.py`, lines 372–380). | | `--omni-lb-policy POLICY` | Orchestrator-wide load-balancer policy. Strategy-derived from any `stage_replica` axis; an explicit CLI value wins and must match the derived one or `AsyncOmniEngine` raises `Conflicting load-balancer policy` at construction. | -!!! warning - `--strategy-config` cannot be used with `--stage-configs-path` (the legacy deploy path). If your model has not been migrated to the registry-based pipeline yet, the strategy is silently dropped with a warning. Migrate to a registry-based model or use `--deploy-config` to apply a strategy. - ## Sub-schemas ### `axis` @@ -216,17 +212,6 @@ Strategy keys MUST be `model_stage` *names* (strings), the same labels stage con !!! warning "Mixing CLI overrides with a strategy on the same axis" A strategy is meant to be the single writer for the axes it declares. If a CLI override (`--tensor-parallel-size 2`, `--stage-overrides '{"0": {"tensor_parallel_size": 2}}'`) sets the same axis, the **CLI value wins** and a warning is emitted from `_reconcile_strategy_with_cli`. The device-layout guard then re-runs against the effective layout, so a CLI override that breaks `tp * dp * pp * num_replicas` will still fail fast at config time. -!!! warning "Passing `--strategy-config` against the legacy `--stage-configs-path`" - Composable parallel only applies on the registry-based deploy path. If a model still resolves through the legacy `stage_args` YAML (loaded via `--stage-configs-path`), `--strategy-config` is silently inapplicable and emits: - - ```text - --strategy-config () was provided but model '' resolves via the - legacy stage_configs YAML path, which does not support composable-parallel - strategies; the strategy is ignored. Use a registry-based model to apply it. - ``` - - Migrate the model to the new deploy schema (see `configuration/stage_configs.md`) before applying a strategy. - !!! warning "Conflicting `omni_lb_policy` across stages" `omni_lb_policy` is a single pipeline-wide knob. If two `stage_replica` axes in different stages derive different policies, `apply_strategy_specs` raises: diff --git a/docs/configuration/pd_disaggregation.md b/docs/configuration/pd_disaggregation.md index 7deb0443a..93c623ebd 100644 --- a/docs/configuration/pd_disaggregation.md +++ b/docs/configuration/pd_disaggregation.md @@ -158,7 +158,7 @@ runtime: ```bash vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \ - --stage-configs-path /path/to/qwen3_omni_pd.yaml + --deploy-config /path/to/qwen3_omni_pd.yaml ``` ## Operational Notes diff --git a/docs/configuration/stage_configs.md b/docs/configuration/stage_configs.md index 1f14d21fd..290c15b94 100644 --- a/docs/configuration/stage_configs.md +++ b/docs/configuration/stage_configs.md @@ -1,9 +1,9 @@ -# Deploy and legacy stage configurations +# Pipeline and deploy configurations -In vLLM-Omni, a model's `PipelineConfig` defines its fixed stage topology, while a deploy configuration controls how those stages run. Models that have not migrated to this split still use legacy stage configurations with a `stage_args` schema. +In vLLM-Omni, a model's `PipelineConfig` defines its fixed stage topology, while a deploy configuration controls how those stages run. !!! note - Default deploy config YAMLs (for example, `vllm_omni/deploy/qwen2_5_omni.yaml`, `vllm_omni/deploy/qwen3_omni_moe.yaml`, and `vllm_omni/deploy/qwen3_tts.yaml`) are bundled and loaded automatically when neither `--stage-configs-path` nor `--deploy-config` is provided — the model registry resolves the right pipeline + deploy YAML by `model_type`. The bundled defaults have been verified on 1xH100 for Qwen2.5-Omni and 2xH100 for Qwen3-Omni. Models that have not yet migrated to the new schema continue to use the legacy `vllm_omni/model_executor/stage_configs/.yaml` files via `--stage-configs-path`. + Default deploy config YAMLs (for example, `vllm_omni/deploy/qwen2_5_omni.yaml`, `vllm_omni/deploy/qwen3_omni_moe.yaml`, and `vllm_omni/deploy/qwen3_tts.yaml`) are bundled and loaded automatically when `--deploy-config` is omitted. The model registry resolves the right pipeline and deploy YAML by `model_type`. ## Deploy configuration schema @@ -65,7 +65,7 @@ connectors: | Connector class | Use case | `extra` keys | |-----------------|----------|--------------| -| `SharedMemoryConnector` | Same-host KV transfer between stages (default for bundled YAMLs). | `shm_threshold_bytes` (int, default `65536`). | +| `SharedMemoryConnector` | Same-host KV transfer between stages (default for bundled YAMLs). | None. All payloads use shared memory. | | `MooncakeStoreConnector` | Cross-host KV transfer over TCP. Required for multi-node deployments. | `host`, `metadata_server`, `master`, `segment` (int bytes), `localbuf` (int bytes), `proto` (`"tcp"` / `"rdma"`). | A stage references a connector by name in its `input_connectors` / `output_connectors`: @@ -86,10 +86,9 @@ stages: | Flag | Description | |------|-------------| -| `--deploy-config PATH` | Load a new-schema deploy YAML. It is mutually exclusive with `--stage-configs-path`. **Optional** — when omitted, the bundled `vllm_omni/deploy/.yaml` is auto-loaded by the model registry. | +| `--deploy-config PATH` | Load a deploy YAML. **Optional** — when omitted, the bundled `vllm_omni/deploy/.yaml` is auto-loaded by the model registry. | | `--stage-overrides JSON` | Per-stage JSON overrides, e.g. `'{"0":{"gpu_memory_utilization":0.5}}'`. Per-stage values always win over global flags. | | `--async-chunk` / `--no-async-chunk` | Flip the deploy YAML's `async_chunk:` bool. Unset (default) leaves the YAML value in force. | -| `--stage-configs-path` | **Deprecated.** Load a legacy `stage_args` YAML. New-schema YAMLs passed to this flag are auto-detected for compatibility, but should use `--deploy-config`. It is mutually exclusive with `--deploy-config` and will be removed in a future release. | ### Stage-Based CLI Paradigm @@ -119,8 +118,7 @@ CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \ --omni-master-port 26000 ``` -When instantiating a custom deployment YAML conforming to the updated schema, append the `--deploy-config /path/to/override.yaml` directive -to all node invocations. For legacy architectures (e.g., BAGEL) configured via deprecated `stage_args:` schemas, continue to specify the relevant configuration via `--stage-configs-path /path/to/config.yaml`. +When instantiating a custom deployment YAML, append the `--deploy-config /path/to/override.yaml` directive to all node invocations. In the context of standard initialization architectures, utilizing the `--stage-overrides` parameter operates as the optimal methodology for delineating stage-specific tuning from the CLI interface: @@ -220,8 +218,7 @@ Therefore, as a core part of vLLM-Omni, a model's pipeline and deployment config To override specific parameters, explicitly inject the customized configuration schema in both online and offline instantiation flows. Use the `--deploy-config` flag -when loading a deploy configuration, reserving the `--stage-configs-path` parameter -exclusively to maintain compatibility with legacy `stage_args` YAML constructs. +when loading a deploy configuration. Examples: @@ -236,11 +233,6 @@ For online serving: vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/deploy_config.yaml ``` -Legacy online serving: - -```bash -vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 --stage-configs-path /path/to/stage_configs_file -``` !!! important We are actively iterating on the definition of deployment configurations, and we welcome feedback from users and developers. @@ -377,7 +369,12 @@ Default: `"0"` #### `engine_args.max_num_seqs` -The maximum number of sequences for concurrent processing in this stage. For LLM stages, this controls the vLLM scheduler's maximum concurrent sequences. For all stage types, this also controls how many tasks can be batched together in the task processing loop. +The maximum number of sequences for concurrent processing in this stage. For +LLM stages, this controls the vLLM scheduler's maximum concurrent sequences. +For diffusion stages, this controls scheduler wave capacity in both request +batch mode and step batch mode. `max_num_seqs: 1` is the serial/conservative +path; values above `1` allow compatible requests to batch when the selected +diffusion pipeline supports that batching mode. Default: `1` @@ -390,8 +387,8 @@ that support request-level batching with `step_execution` disabled. Use this together with `max_num_seqs > 1` for bursty serving traffic. `0` disables admission waiting and preserves the lowest first-request latency. -For diffusion request-level batching tuning, see -[Request-Level Batching](../user_guide/diffusion/request_batching.md). +For diffusion execution and batching tuning, see +[Diffusion Execution Modes](../user_guide/diffusion/execution_modes.md). Default: `0.0` diff --git a/docs/contributing/README.md b/docs/contributing/README.md index e10d8d3b3..4c05fe0f2 100644 --- a/docs/contributing/README.md +++ b/docs/contributing/README.md @@ -71,7 +71,7 @@ For additional features and advanced configurations, refer to the: ### Testing vLLM-Omni uses `pytest` to test the codebase. -Please refer to the [test instructions](./ci/test_guide.md) for detailed testing information. +Please refer to the [test instructions](./ci/test_execution_guide.md) for detailed testing information. !!! 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. @@ -127,11 +127,11 @@ The skill offers two modes: The precheck covers five PR types: Bug Fix, Performance, New Model, Diffusion Model, and General. Each type has a tailored checklist that validates evidence quality (repro steps, A/B benchmarks, registry entries, etc.). See the [precheck-pr skill](https://github.com/vllm-project/vllm-omni/blob/main/.claude/skills/precheck-pr/SKILL.md) for the full checklist. ### Local Test -Please run the L1 and L2 test cases locally first and attach the results before contacting us to add the "ready" label. Please refer to the [test instructions](./ci/test_guide.md) for running the test cases. +Please run the L1 and L2 test cases locally first and attach the results before contacting us to add the "ready" label. Please refer to the [test instructions](./ci/test_execution_guide.md) for running the test cases. ### Automatic skip-ci (docs and pytest skip marks) -On pull requests and `main` pushes, the bootstrap step in [`.buildkite/pipeline.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/pipeline.yml) runs [`.buildkite/scripts/upload_pipeline.py`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/scripts/upload_pipeline.py) against the git diff. When every changed file qualifies, **L2 (`ready`) and L3 (`merge-test`) pipelines are not uploaded**, so the default GPU CI jobs are skipped. +On pull requests and `main` pushes, the bootstrap step in [`.buildkite/cuda/pipeline.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/cuda/pipeline.yml) runs [`.buildkite/common/scripts/upload_pipeline.py`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/scripts/upload_pipeline.py) against the git diff. When every changed file qualifies, **L2 (`ready`) and L3 (`merge-test`) pipelines are not uploaded**, so the default GPU CI jobs are skipped. | Change per file | Examples | | --- | --- | @@ -148,7 +148,7 @@ These PR shapes all trigger skip-ci: Skip-ci does **not** apply when the diff also touches product code (for example `vllm_omni/`), or when test files change assertions, imports, fixtures, or other non-skip logic. If the diff cannot be resolved (non-PR branches outside `main`), CI runs as usual. !!! note - Skipping L2/L3 does **not** disable the Docker image build step. Nightly (L4) upload can still run when the PR has a `nightly-test` label or on scheduled `main` builds with `NIGHTLY=1`. See [CI levels](./ci/CI_5levels.md) for how bootstrap skip-ci relates to diff-gated E2E jobs. + Skipping L2/L3 does **not** disable the Docker image build step. Nightly (L4) upload can still run when the PR has a `nightly-test` label or on scheduled `main` builds with `NIGHTLY=1`. Bootstrap child steps live in `bootstrap-upload-steps.yml`; the hook entry step runs `upload_pipeline.py --upload /bootstrap-upload-steps.yml`, which injects `if` by step `key` from skip-ci before upload. See [CI Settings — Diff-aware CI](./ci/ci_settings.md#diff-aware-ci) and [Test System Overview](./ci/test_system_overview.md). ### Code Quality diff --git a/docs/contributing/ci/.nav.yaml b/docs/contributing/ci/.nav.yaml index 0f187f3a1..867bcc330 100644 --- a/docs/contributing/ci/.nav.yaml +++ b/docs/contributing/ci/.nav.yaml @@ -1,6 +1,6 @@ nav: - - CI_5levels.md + - test_system_overview.md + - ci_settings.md + - test_writing_guide.md - failures.md - - test_guide.md - - test_markers.md - - test_style.md + - test_execution_guide.md diff --git a/docs/contributing/ci/CI_5levels.md b/docs/contributing/ci/CI_5levels.md deleted file mode 100644 index 17b4a3792..000000000 --- a/docs/contributing/ci/CI_5levels.md +++ /dev/null @@ -1,913 +0,0 @@ -# Multi-Level Automated Testing System Documentation - -## Document Overview - -This testing system aims to build a complete, efficient, and well-structured quality assurance framework for the development, integration, and release of model services. It draws on the concept of the test pyramid from modern software engineering, progressively expanding testing activities from basic code logic verification to complex end-to-end (E2E) functionality, performance, accuracy, and even long-term stability validation. - -Through five levels (L1-L5) and common (Common) specifications, the system clarifies the testing objectives, scope, execution frequency, and required resources for different development stages (e.g., each commit, PR merge, daily build, pre-release). This ensures that models meet high standards for functionality, performance, and reliability across various deployment scenarios (online serving and offline inference). - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LevelScope & FocusModel Coverage StrategyFeature Coverage StrategyInterface Coverage StrategyTagsTime CostTest DirDocFrequencyHardware
CommonContribution Guideline & PR checklist//////.github/PULL_REQUEST_TEMPLATE.md Test Style (PR Checklist)//
CI Failure Description////// CI Failures//
L1
(Unit & Logic)
Unit tests for components like entrypoints, models///core_model and cpu<15min/tests/{component_name}/test_xxx - Chapter 1
- Section 1 L1&L2: Purpose, Test Content, Directory Location, Example -
PR with ready label (also can run locally)CPU
L2
(E2E across models & GPU-required UT)
Online (basic deployment scenarios):
dummy, normal inference function (output format, stream), some instance startup UT
High-priority models + online basic scenarios; request success, non-empty output, format match (no Whisper/accuracy)High-priority features (using random lightweight models)High-priority interfaces (using random lightweight models)core_model and hardware_test(H100, L4, etc.) and omni/tts/diffusion - Model tests:
- /tests/e2e/online_serving/test_{model_name}.py
- Feature tests:
- /tests/{component_name}/test_xxx
- Interface tests:
- /tests/entrypoints/test_xxx -
- Chapter 1
- L1&L2: Purpose, Test Content, Directory Location, Example -
PR with ready labelGPU
L3
(Important Perf & Integration & Accuracy)
Online & Offline (multiple deployment scenarios):
real model, normal inference function, normal accuracy
High/medium-priority models + key online/offline scenarios; real weights, Whisper/similarity, preset voice gender, basic accuracyMedium-priority features (using random lightweight models)Medium-priority interfaces (using random lightweight models)advanced_model and hardware_test(H100, L4, etc.) and omni/tts/diffusion<30min - Model tests:
- /tests/e2e/online_serving/test_{model_name}.py
- /tests/e2e/offline_inference/test_{model_name}.py
- Feature tests:
- /tests/{component_name}/test_xxx
- Interface tests:
- /tests/entrypoints/test_xxx -
- Chapter 2
- L3: Purpose, Test Content, Directory Location, Example -
PR Merged (Also run L1&L2 Tests)GPU
L4
(Perf & Integration & Accuracy)
Online: full functional scenarios + performance test + doc test + accuracy testHigh-priority models: function, performance, accuracy, and doc testing
Medium-priority models: function and doc testing
Low-priority features (using real weights)Low-priority interfaces (using real weights)full_model and hardware_test(H100, L4, etc.) and omni/tts/diffusion<3 hour - Model tests:
- /tests/e2e/online_serving/test_{model_name}_expansion.py
- Feature tests:
- /tests/{component_name}/test_xxx
- Interface tests:
- /tests/entrypoints/test_xxx
- Performance:
- /tests/dfx/perf/tests/test_qwen_omni.json (Omni), test_tts.json (TTS),
- and /tests/dfx/perf/tests/test_{diffusion_model}_vllm_omni.json (Diffusion)
- Doc Test:
- tests/example/online_serving/test_{model_name}.py
- tests/example/offline_inference/test_{model_name}.py
- Accuracy Test:
- /tests/e2e/accuracy/test_{model_name}.py -
- Chapter 3
- L4: Purpose, Test Content, Directory Location, Example -
NightlyGPU
L5
(Stability & Reliability)
Online: long-term stability test + reliability testLong-term stability and reliability testing for high-priority models
Low-priority models: function and doc testing
/Invalid-parameter validation for high-priority interfacesslow and hardware_test(H100, L4, etc.) and omni/tts/diffusion Depends on reality - Stability:
- /tests/dfx/stability/tests/test_qwen3_omni.json
- /tests/dfx/stability/tests/test_wan22.json
- Reliability:
- tests/dfx/reliability/test_reliability_{model_key}.py
- (e.g. test_reliability_qwen3_omni.py, test_reliability_wan22.py, test_reliability_hunyuan_image.py, test_reliability_voxcpm2.py) -
- Chapter 4
- L5: Purpose, Test Content, Directory Location, Example -
Weekly / Days before ReleaseGPU
- - ---- -
- The folder structure for tests file based on the 5 levels design -Legend: `✅` = test exists, `⬜` = suggested to add. -``` -vllm_omni/ tests/ -├── config/ → ├── config/ -│ ├── model.py │ └── test_model.py ⬜ -│ └── lora.py │ └── test_lora.py ⬜ -│ -├── core/ → ├── core/ -│ └── sched/ │ └── sched/ -│ ├── omni_ar_scheduler.py │ ├── test_omni_ar_scheduler.py ⬜ -│ ├── omni_generation_scheduler.py │ ├── test_omni_generation_scheduler.py ⬜ -│ └── output.py │ └── test_output.py ✅ currently in entrypoints/test_omni_new_request_data.py (tests output.OmniNewRequestData) -│ -├── diffusion/ → ├── diffusion/ -│ ├── diffusion_engine.py │ ├── test_diffusion_engine.py ⬜ -│ ├── attention/ │ ├── attention/ -│ │ ├── layer.py │ │ ├── test_attention_sp.py ✅ -│ │ └── backends/ │ │ └── test_flash_attn.py ✅ -│ ├── distributed/ │ ├── distributed/ -│ │ └── ... │ │ ├── test_comm.py ✅ -│ │ │ │ ├── test_cfg_parallel.py ✅ -│ │ │ │ └── test_sp_plan_hooks.py ✅ -│ ├── lora/ │ ├── lora/ -│ │ └── ... │ │ ├── test_base_linear.py ✅ -│ │ │ │ └── test_lora_manager.py ✅ -│ ├── models/ │ ├── models/ -│ │ ├── qwen_image/ │ │ ├── qwen_image/ (e2e coverage) -│ │ ├── ovis_image/ │ │ ├── ovis_image/ -│ │ │ └── ... │ │ │ └── test_ovis_image.py ✅ -│ │ ├── z_image/ │ │ └── z_image/ -│ │ └── ... │ │ └── test_zimage_tp_constraints.py ✅ -│ └── worker/ │ └── worker/ -│ ├── diffusion_worker.py │ └── test_diffusion_worker.py ✅ file at diffusion/test_diffusion_worker.py -│ └── diffusion_model_runner.py │ -│ -├── distributed/ → ├── distributed/ -│ └── omni_connectors/ │ └── omni_connectors/ -│ ├── adapter.py │ ├── test_adapter_and_flow.py ✅ -│ ├── kv_transfer_manager.py │ ├── test_basic_connectors.py ✅ -│ ├── connectors/ │ ├── test_kv_flow.py ✅ -│ └── utils/ │ └── test_omni_connector_configs.py ✅ -│ -├── engine/ → ├── engine/ -│ ├── input_processor.py │ ├── test_input_processor.py ⬜ (no processor.py in source) -│ ├── output_processor.py │ └── test_output_processor.py ⬜ -│ └── arg_utils.py │ └── test_arg_utils.py ⬜ -│ -├── entrypoints/ → ├── entrypoints/ -│ ├── stage_utils.py │ ├── test_stage_utils.py ✅ -│ ├── cli/ │ ├── cli/ (benchmarks/test_serve_cli.py covers CLI serve) -│ │ └── ... │ │ └── test_*.py ⬜ -│ └── openai/ │ └── openai_api/ # maps to entrypoints/openai/ -│ ├── api_server.py │ ├── test_api_server.py ⬜ (e2e indirect coverage) -│ ├── serving_chat.py │ ├── test_serving_chat_sampling_params.py ✅ -│ ├── serving_speech.py │ ├── test_serving_speech.py ✅ -│ └── image_api_utils.py │ └── test_image_server.py ✅ -│ -├── inputs/ → ├── inputs/ -│ ├── data.py │ ├── test_data.py ⬜ -│ ├── parse.py │ ├── test_parse.py ⬜ -│ └── preprocess.py │ └── test_preprocess.py ✅ currently in entrypoints/test_omni_input_preprocessor.py -│ -├── model_executor/ → ├── model_executor/ -│ ├── layers/ │ ├── layers/ -│ │ └── mrope.py │ │ └── test_mrope.py ⬜ -│ ├── model_loader/ │ ├── model_loader/ -│ │ └── weight_utils.py │ │ └── test_weight_utils.py ⬜ -│ ├── models/ │ ├── models/ -│ │ ├── qwen2_5_omni/ │ │ ├── qwen2_5_omni/ -│ │ │ ├── qwen2_5_omni_thinker.py │ │ │ ├── test_audio_length.py ✅ -│ │ │ ├── qwen2_5_omni_talker.py │ │ │ ├── test_qwen2_5_omni_thinker.py ⬜ -│ │ │ └── qwen2_5_omni_token2wav.py │ │ │ ├── test_qwen2_5_omni_talker.py ⬜ -│ │ └── qwen3_omni/ │ │ │ └── test_qwen2_5_omni_token2wav.py ⬜ -│ │ └── ... │ │ └── qwen3_omni/ -│ ├── stage_configs/ │ │ └── test_*.py ⬜ -│ │ └── *.yaml │ └── stage_configs/ (used by e2e, test_*.py can be added) ⬜ -│ └── stage_input_processors/ │ └── stage_input_processors/ -│ └── ... │ └── test_*.py ⬜ -│ -├── sample/ → ├── sample/ -│ └── __init__.py │ └── test_*.py ⬜ -│ -├── utils/ → ├── utils/ -│ └── __init__.py │ └── test_*.py ⬜ (no platform_utils.py currently) -│ -├── worker/ → ├── worker/ -│ ├── gpu_ar_model_runner.py │ ├── test_gpu_ar_model_runner.py ⬜ -│ ├── gpu_ar_worker.py │ ├── test_gpu_ar_worker.py ⬜ -│ ├── gpu_generation_model_runner.py │ ├── test_gpu_generation_model_runner.py ✅ -│ ├── gpu_generation_worker.py │ ├── test_gpu_generation_worker.py ⬜ -│ ├── gpu_model_runner.py │ ├── test_omni_gpu_model_runner.py ✅ -│ └── mixins.py │ └── (npu under platforms/npu/worker/) # not worker/npu/ -│ -├── platforms/ → (no tests/platforms/, e2e and stage_configs provide indirect coverage) -│ ├── cuda/ -│ ├── npu/worker/ # NPU worker here, not vllm_omni/worker/npu/ -│ ├── rocm/ -│ └── xpu/worker/ -│ -├── outputs.py → test_outputs.py ✅ (at tests root) -├── (logger, patch, request, version) → (no corresponding unit test) -│ -└── e2e (tests side only) → ├── e2e/ - ├── online_serving/ ✅ non-empty - │ ├── test_qwen2_5_omni.py - │ ├── test_async_omni.py - │ ├── test_qwen3_omni.py - │ ├── test_qwen3_omni_expansion.py - │ ├── test_mimo_audio.py - │ └── test_images_generations_lora.py - └── offline_inference/ ✅ - ├── test_qwen2_5_omni.py - ├── test_qwen3_omni.py - ├── test_bagel_text2img.py - ├── test_z_image.py - ├── test_wan22.py - ├── test_zimage_tensor_parallel.py - ├── test_cache_dit.py - ├── test_teacache.py - ├── test_stable_audio_expansion.py - ├── test_diffusion_cpu_offload.py - ├── test_diffusion_layerwise_offload.py - ├── test_diffusion_lora.py - ├── test_sequence_parallel.py - └── stage_configs/ (legacy schema, still - ├── bagel_*.yaml present for unmigrated - └── npu/, rocm/, etc. models) - -# Migrated models (qwen3_omni_moe, qwen2_5_omni, qwen3_tts) live under -# vllm_omni/deploy/ instead — see docs/configuration/stage_configs.md. -``` - - -
- - -## Common Specifications - -Before entering specific testing levels, the project establishes two common specifications aimed at standardizing the development process and quickly locating issues. - -1. ***PR Checklist ([Tests Style](../ci/tests_style.md))***: This template defines the self-check items that must be completed before submitting a code review (Pull Request). It ensures that each code change meets basic requirements such as code style, dependency updates, and documentation synchronization before entering the automated testing pipeline, serving as the first manual line of defense for quality assurance. -2. ***CI Failure Explanation ([CI Failures](../ci/failures.md))***: This document archives and explains common failure patterns in the Continuous Integration (CI) pipeline, error log interpretation, and preliminary troubleshooting steps. It helps developers and testers quickly diagnose the causes of automated test failures, improving problem-solving efficiency. - -## Notes - -### Diff-aware Buildkite uploads (`source_file_dependencies`) - -L2 (`.buildkite/test-ready.yml`) and L3 (`.buildkite/test-merge.yml`) pipelines can **skip unrelated GPU jobs at upload time** based on the PR diff. This is implemented by `.buildkite/scripts/upload_pipeline.py`, which filters steps before calling `buildkite-agent pipeline upload`. - -#### What `source_file_dependencies` is - -- A **uploader-only** YAML key on a Buildkite step or group. **Buildkite does not understand it**; `upload_pipeline.py` always **removes** it from the YAML that is uploaded. -- A list of path **prefixes** (directories or individual files). If **any** changed file in the diff equals a prefix or starts with `prefix/`, the step is kept; otherwise the step (or entire group) is **omitted** from the uploaded pipeline. - -#### When filtering runs - -| Build context | Changed files used for matching | -| --- | --- | -| Pull request | `git diff --name-only origin/...` | -| `main` branch push | `git diff --name-only ^..` | -| Other (e.g. local dry-run, non-PR branch) | Diff cannot be resolved → **no filtering**; all steps are uploaded and `source_file_dependencies` is still stripped | - -Docs-only PRs are handled earlier in bootstrap (`pipeline.yml`) via skip-ci logic; `source_file_dependencies` applies only to the **uploaded** L2/L3 test pipelines. - -#### Where it is configured - -| Level | Pipeline file | Upload entry | -| --- | --- | --- | -| L2 | `.buildkite/test-ready.yml` | `upload_pipeline.py --upload .buildkite/test-ready.yml` (from `pipeline.yml`) | -| L3 | `.buildkite/test-merge.yml` | `upload_pipeline.py --upload .buildkite/test-merge.yml` (from `pipeline.yml`) | - -Steps **without** `source_file_dependencies` are always uploaded (subject to the usual label conditions: `ready` for L2, `merge-test` for L3). - -#### Current skip policy (L2 / L3) - -To balance CI cost and coverage: - -- **Always run** (no `source_file_dependencies`): baseline groups outside E2E Test—e.g. Simple Test, Diffusion unit tests, Engine/Model Executor, Distributed, Custom Pipeline, Entrypoints (L2), LoRA / Entrypoints (L3). -- **Diff-gated** (`source_file_dependencies` set): **every leaf job under the E2E Test group** in `test-ready.yml` and `test-merge.yml`, regardless of queue (`mithril-h100-pool`, `gpu_1_queue`, or `gpu_4_queue`). Each step lists the smallest set of prefixes that should trigger it—typically: - - pytest file(s) exercised by the job (online and/or offline); - - model code under `vllm_omni/model_executor/models/` or `vllm_omni/diffusion/models/`; - - related `vllm_omni/model_executor/stage_input_processors/` and `vllm_omni/deploy/*.yaml` when applicable. - -Adding a new E2E step: add `source_file_dependencies` on the leaf job with those prefixes. Prefer **per-step** deps rather than a broad group-level list unless every child shares the same paths. - -#### YAML examples - -H100 E2E (kubernetes / `mithril-h100-pool`): - -```yaml - - label: "Diffusion · Qwen Image Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen_image.py - - vllm_omni/diffusion/models/qwen_image/ - commands: - - pytest -s -v tests/e2e/online_serving/test_qwen_image.py -m 'core_model' ... - agents: - queue: "mithril-h100-pool" -``` - -Docker E2E (`gpu_1_queue` / `gpu_4_queue`)—same key, same upload-time filtering: - -```yaml - - label: "TTS · Qwen3-TTS CustomVoice Test" - source_file_dependencies: - - tests/e2e/online_serving/test_qwen3_tts_customvoice.py - - vllm_omni/model_executor/models/qwen3_tts/ - - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py - - vllm_omni/deploy/qwen3_tts.yaml - commands: - - pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py ... - agents: - queue: "gpu_4_queue" -``` - -A **group** may also define `source_file_dependencies`; nested steps inherit filtering as a unit—the whole group is dropped if no prefix matches. - -#### Local dry-run - -```bash -# Render filtered YAML to stdout (no upload) -python3 .buildkite/scripts/upload_pipeline.py .buildkite/test-ready.yml - -# Confirm uploader-only keys are stripped -python3 .buildkite/scripts/upload_pipeline.py .buildkite/test-merge.yml | grep source_file_dependencies -# (no output expected) -``` - -On a PR build, Buildkite logs from `upload_pipeline.py` include lines such as `skip '…' (no changes under …)` for omitted steps. - -#### Related - -- Implementation: `.buildkite/scripts/upload_pipeline.py` -- L2/L3 diff skip does **not** replace label-based triggers (`ready`, `merge-test`); it only reduces which steps appear **after** the pipeline is already scheduled. - -### Test helper environment variables - -Some shared helpers under `tests/helpers/` honor optional environment variables for local debugging. These are **not** set in CI by default. - -| Variable | Accepted values | Description | -| -------- | --------------- | ----------- | -| `VLLM_OMNI_KEEP_REQUEST_MEDIA` | `1`, `true`, `yes` (case-insensitive) | When enabled, temporary WAV files created by `tests.helpers.media.convert_audio_bytes_to_text` are **not** deleted when the pytest process exits. By default, each call writes a unique file under the system temp directory via `tempfile.mkstemp` and registers `atexit` cleanup. Use this when debugging audio output validation (Whisper transcription, keyword checks, text–audio similarity). The saved path is logged as `audio data is saved: `. | - -Example (Linux / macOS): - -```bash -export VLLM_OMNI_KEEP_REQUEST_MEDIA=1 -pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -k test_mix_to_text_audio -``` - -Example (Windows PowerShell): - -```powershell -$env:VLLM_OMNI_KEEP_REQUEST_MEDIA = "1" -pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -k test_mix_to_text_audio -``` - -## Chapter 1: L1 & L2 Level Testing - Unit Testing and Basic End-to-End Verification - -### 1.1 Testing Purpose - -L1 and L2 level testing form the foundation of the quality assurance system. L1 level testing focuses on verifying the internal logic correctness of code units (e.g., functions, classes), ensuring each independent component behaves as designed. - -L2 level testing builds upon L1 by introducing GPU resources and verifying that the end-to-end (E2E) process of the model in basic deployment scenarios is smooth. For example, it uses dummy models to confirm that core interfaces like the inference pipeline, output format, and streaming response work properly. The common goal of these two levels is to provide developers with rapid feedback, discovering and fixing issues early in the development cycle. - - - -### 1.2 Testing Content and Scope - -- ***L1 (Unit & Logic Testing)***: -- - ***Scope***: Tests internal functions and methods of core components such as `entrypoints`, `models`. - - ***Focus***: Branch coverage, exception handling, algorithm logic correctness. Does not involve external dependencies or the complete service stack. - - ***Time Cost***: Execution time is controlled within ***15 minutes*** to ensure fast feedback. -- ***L2 (Basic End-to-End Testing)***: -- - ***Scope***: Covers two basic deployment scenarios: `online` (serving) and `offline` (inference). - - ***Focus***: Uses `dummy` weights (via deploy YAML patching at `core_model`) or lightweight real models to verify that the entire chain from request input to result output works normally, including output data structure, streaming (stream) support, and **cheap payload checks** at `--run-level core_model` (see below). Also includes some unit tests that require launching independent service instances. - - ***L2 response validation (`core_model`)***: Implemented in `tests/helpers/assertions.py` and invoked by `OpenAIClientHandler` based on `--run-level`. At L2 we require **request success** plus minimal output sanity—not full accuracy: - - **Speech / TTS** (`assert_audio_speech_response`): decoded audio must be present and non-empty (or exceed `min_audio_bytes` when set in `request_config`); `response_format` must match the returned content-type (e.g. `wav`, `pcm`). Whisper transcript similarity, PCM HNR, and preset-voice gender checks run only at L3+. - - **Diffusion** (`assert_diffusion_response`): at least one non-empty image, video, or audio artifact. Resolution/frame-count parity and other deep checks run only at L3+. - - **Omni multimodal** (`assert_omni_response`): L2 asserts successful completion; keyword, transcript, and cross-modal similarity checks run only at L3+. - - ***Characteristic***: Requires ***GPU*** resources to perform model computations. - -### 1.3 Test Directory and Execution Files - -A clear directory structure is key to managing test cases efficiently. - -- ***L1 Test Directory***: `/tests/{component_name}/test_xxx.py` -- - Here, `{component_name}` corresponds to modules in the source code, such as `distributed`, `entrypoints`, etc., and `test_xxx.py` is the specific test file. -- ***L2 Test Directory***: -- - Online Serving: `/tests/e2e/online_serving/test_{model_name}.py` - - Offline Inference: `/tests/e2e/offline_inference/test_{model_name}.py` - -### 1.4 Execution Method and Example - -- ***Trigger Timing***: **`PR with ready label`**. That is, when a developer adds a "ready for review" or similar label to a PR on platforms like GitHub, L1 and L2 tests are automatically triggered. -- ***Diff-aware step skipping***: On L2, **E2E Test** jobs may be omitted at pipeline upload when the PR diff does not touch their [`source_file_dependencies`](#diff-aware-buildkite-uploads-source_file_dependencies) prefixes (H100 and docker queues alike); non-E2E groups still always upload. See [Diff-aware Buildkite uploads](#diff-aware-buildkite-uploads-source_file_dependencies). -- ***Execution Environment***: L1 uses ***CPU*** environment; L2 requires ***GPU*** environment. -- ***Script Example***: - -
- L1 Test Examples - -Examples from `tests/model_executor/models/qwen2_5_omni/test_audio_length.py` -```python -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import pytest - -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] - -def test_resolve_max_mel_frames_default(): - from vllm_omni.model_executor.models.qwen2_5_omni.audio_length import resolve_max_mel_frames - - assert resolve_max_mel_frames(None, default=30000) == 30000 - assert resolve_max_mel_frames(None, default=6000) == 6000 - - -def test_resolve_max_mel_frames_explicit(): - from vllm_omni.model_executor.models.qwen2_5_omni.audio_length import resolve_max_mel_frames - - # Explicit argument always wins over default - assert resolve_max_mel_frames(123, default=30000) == 123 - assert resolve_max_mel_frames(6000, default=30000) == 6000 - assert resolve_max_mel_frames(0, default=30000) == 0 - - -@pytest.mark.parametrize("repeats", [2, 4]) -@pytest.mark.parametrize("code_len", [0, 1, 32768]) -@pytest.mark.parametrize("max_mel_frames", [None, -1, 0, 1, 6000, 30000]) -def test_cap_and_align_mel_length_no_mismatch(repeats, code_len, max_mel_frames): - """Guard that any max_mel_frames yields a mel length aligned to repeats, and - consistent with the truncated code length (prevents concat mismatch). - """ - from vllm_omni.model_executor.models.qwen2_5_omni.audio_length import cap_and_align_mel_length - - target_code_len, target_mel_len = cap_and_align_mel_length( - code_len=code_len, - repeats=repeats, - max_mel_frames=max_mel_frames, - ) - - assert isinstance(target_code_len, int) - assert isinstance(target_mel_len, int) - - if code_len == 0: - assert target_code_len == 0 - assert target_mel_len == 0 - return - - assert target_code_len >= 1 - assert target_mel_len >= repeats - assert target_mel_len % repeats == 0 - assert target_mel_len == target_code_len * repeats - assert target_code_len <= code_len - - if max_mel_frames is not None and int(max_mel_frames) > 0 and int(max_mel_frames) >= repeats: - assert target_mel_len <= int(max_mel_frames) -``` -
- -
- L2 Test Examples -You can refer to Test Examples in Chapter 2 to see example test cases that incorporate both L2 and L3 testing logic. -
- -- - ***Run Command***: - - `pytest -s -v /tests/e2e/online_serving/test_{model_name}.py` - `pytest -s -v -m 'core_model and cpu' --run-level=core_model` - -## Chapter 2: L3 Level Testing - Core Integration, Performance, and Accuracy Verification - -### 2.1 Testing Purpose - -L3 level testing executes after code is merged into the main branch. Its core purpose is to verify the integration effect, key performance indicators, and output accuracy of ***real models*** in ***multiple deployment scenarios*** - -. It acts as the "quality gatekeeper" for the main branch, ensuring that no merge breaks the core capabilities of the model service. Testing needs to provide clear conclusions within a relatively short time (<30min), balancing test depth with feedback speed. - - - -### 2.2 Testing Content and Scope - -- ***Deployment Scenarios***: Covers richer `online` and `offline` deployment configurations, which may include different hardware configurations, batch sizes, concurrency levels, etc. -- ***Core Verification***: -- 1. ***Inference Functionality***: Ensures real models can perform forward computation normally and return results. - 2. ***Accuracy Compliance***: Verifies that the model's evaluation metrics (e.g., accuracy) meet the expected baseline, preventing code changes from introducing accuracy issues. - 3. ***Important Performance***: Verifies whether performance (e.g., P99 latency, throughput) in core scenarios meets preset thresholds. -- ***L3 response validation (`advanced_model`)***: At `--run-level advanced_model`, `tests/helpers/assertions.py` adds semantic checks on top of L2 payload gates (see Chapter 1): - - **Speech / TTS** (`assert_audio_speech_response`): Whisper transcript of returned audio vs `request_config["input"]` (cosine similarity > 0.9 when input text is set); optional `min_audio_bytes` floor; PCM harmonic-to-noise ratio when `response_format` is `pcm`; **preset voice gender** via `_assert_preset_voice_gender_from_audio` when `voice` matches a known preset in `_PRESET_VOICE_GENDER_MAP` (pitch/F0-based classifier; skipped for unknown voices or `pcm` output). - - **Omni multimodal** (`assert_omni_response`): non-empty text/audio outputs; `key_words` in transcript or text; text–audio similarity / containment; preset **`speaker`** gender check (same helper as TTS). - - **Diffusion** (`assert_*_diffusion_response`): image/video dimension and frame-count parity with request parameters where configured. - - **Accuracy suites**: pixel/video similarity and metric baselines under `/tests/e2e/accuracy/` (separate from inline helper assertions). - -### 2.3 Test Directory and Execution Files - -- ***Functional Testing***: -- - Online Serving: `/tests/e2e/online_serving/test_{model_name}_expansion.py` - - Offline Inference: `/tests/e2e/offline_inference/test_{model_name}_expansion.py` - - (Note: `_expansion.py` likely means it contains more comprehensive scenario cases compared to L2 tests). - -### 2.4 Execution Method and Example - -- ***Trigger Timing***: **`PR Merged`**. Automatically triggered after code review is approved and merged into the main branch (typically via `merge-test` label on the PR before merge). -- ***Diff-aware step skipping***: Same [`source_file_dependencies`](#diff-aware-buildkite-uploads-source_file_dependencies) mechanism as L2—**all E2E Test** leaf steps are diff-gated; other L3 groups always upload. See [Diff-aware Buildkite uploads](#diff-aware-buildkite-uploads-source_file_dependencies). -- ***Execution Environment***: ***GPU*** servers. -- ***Script Example***: - -???+ example "Test Examples" - - **2.4.1 Mark Declaration Section** - - ```python - @pytest.mark.advanced_model - @pytest.mark.core_model - @pytest.mark.parametrize("omni_server", test_params, indirect=True) - ``` - - **Explanation**: - - @pytest.mark.advanced_model: Marks the test as L3 merge level (`--run-level advanced_model`): real weights, Whisper/similarity/keyword checks, diffusion deep checks, and **preset voice/speaker gender** validation where applicable. @pytest.mark.full_model: Marks L4 nightly-only suites (e.g. `test_*_expansion.py`, doc examples). - - @pytest.mark.core_model: Marks the test as L1 or L2 level. At `--run-level core_model`, validation is limited to request success plus cheap payload checks (e.g. non-empty audio bytes, response format/content-type, non-empty diffusion outputs)—not Whisper, keyword, or accuracy gates. Deploy YAML may use `load_format: dummy` for fast PR feedback. - - @pytest.mark.parametrize: A parameterization decorator that allows abstracting test data into parameters, enabling reuse of the same test logic across different data configurations. indirect=True indicates that parameters will be passed to the fixture for processing. - - **Notes**: If you believe the test case only needs to execute basic run logic at the PR-level CI, you can mark it only with @pytest.mark.core_model. If you believe it only needs to execute deep validation at merge (L3), use @pytest.mark.advanced_model. For L4 nightly-only expansion and doc-example tests, use @pytest.mark.full_model with `--run-level full_model`. If the test case needs both basic run and deep validation, mark with @pytest.mark.core_model and the appropriate L3/L4 marker (`advanced_model` and/or `full_model`). - - **2.4.2 Test Function Definition and Documentation** - - ```python - def test_mix_to_text_audio_001(omni_server, openai_client) -> None: - """ - Test multi-modal input processing and text/audio output generation via OpenAI API. - Deploy Setting: default yaml - Input Modal: text + audio + video + image - Output Modal: text + audio - Input Setting: stream=True - Datasets: single request - """ - ``` - - **Explanation**: - - **Function Naming Convention**: Uses the test_ prefix, describes the test scenario mix_to_text_audio, and the number 001 indicates the first test case for this scenario. - - **Parameter Explanation**: - - omni_server: Omni server instance obtained via fixture, containing model information and configuration. - - openai_client: Unified OpenAI client processing instance, encapsulating request sending and response validation logic. - - Docstring: Describes the test purpose, deployment settings, input/output modalities, streaming settings, and dataset type in detail, providing clear context for test maintenance. - - **2.4.3 Multimodal Data Preparation** - - ```python - video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300)['base64']}" - image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}" - audio_data_url = f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}" - ``` - - **Explanation**: - - **Data Generation Functions**: Use the generate_synthetic_* series of functions to generate synthetic test data, avoiding reliance on external resources and ensuring test reproducibility and stability. - - **Parameter Explanation**: - - Video: width, height, duration_frames - - Image: width, height - - Audio: duration_seconds, channels - - **2.4.4 Request Configuration and Keyword Validation** - - ```python - request_config = { - "model": omni_server.model, - "messages": messages, - "stream": True, - "key_words": { - "audio": ["water", "cricket"], - "video": ["sphere", "globe", "circle", "round"], - "image": ["square", "quadrate"], - "text": ["beijing"] - }, - } - ``` - - **Explanation**: - - **Model Specification**: Uses omni_server.model to ensure the test aligns with the model configured on the server. - - **Keyword Validation Mechanism**: This is an innovative design of the template to address the specific needs of multimodal testing: - - Audio Keywords: Validate whether the generated text's description of audio content contains expected elements (e.g., "water" for water sounds, "cricket" for cricket sounds). If you provide multiple keywords, the validation is considered successful if at least one keyword is present. - - **Video Keywords**: Validate whether the generated text's description of video content contains expected elements. If you provide multiple keywords, the validation is considered successful if at least one keyword is present. - - Image Keywords: Validate whether the generated text's description of image content contains expected elements. If you provide multiple keywords, the validation is considered successful if at least one keyword is present. - - Text Keywords: Validate whether the generated text contains expected elements. If you provide multiple keywords, the validation is considered successful if at least one keyword is present. - - **2.4.5 Request Execution** - - ```python - openai_client.send_omni_request(request_config, request_num=1) # for omni-understanding models - # or - openai_client.send_diffusion_request(request_config, request_num=1) # for diffusion models - ``` - - **Explanation**: - - **Unified Client**: Uses the OpenAIClientHandler instance to send requests. This client encapsulates error handling, retry mechanisms, and response validation logic. - - **Single Request**: The comment clearly states this is a single-request completion test. For concurrent testing, it can be extended to multiple requests using request_num = n. - - **Implicit Validation**: `send_omni_request`, `send_audio_speech_request`, and `send_diffusion_request` call `assert_*_response` helpers with the session `--run-level`. At `core_model`, checks are limited to success plus cheap payload sanity (non-empty audio/media, format/content-type, optional `min_audio_bytes`). At `advanced_model` and `full_model`, deep validation adds Whisper transcripts, keyword/similarity, diffusion dimension checks, PCM HNR, etc. - - **Audio output debugging**: Deep validation may transcribe returned audio via `convert_audio_bytes_to_text` (Whisper). If an audio keyword or text–audio similarity assertion fails, set `VLLM_OMNI_KEEP_REQUEST_MEDIA=1` before running pytest to keep the intermediate WAV files for inspection (see [Test helper environment variables](#test-helper-environment-variables)). - -- ***Run Command (L3 merge)***: `pytest -s -v /tests/e2e/online_serving/test_{model_name}.py -m advanced_model --run-level=advanced_model` - -- ***Run Command (L4 nightly expansion)***: `pytest -s -v /tests/e2e/online_serving/test_{model_name}_expansion.py -m full_model --run-level=full_model` - -## Chapter 3: L4 Level Testing - Full Functionality, Performance, and Documentation Testing - -### 3.1 Testing Purpose - -L4 level testing is a comprehensive quality audit before a version release. It expands upon L3, executing ***full*** functional scenarios, conducting systematic ***performance stress tests***, and simultaneously verifying the correctness of accompanying ***example documentation***. Its purpose is to perform deep validation of the system during off-peak nighttime hours, providing quality trend reports for daytime development and data support for release decisions. - - - -### 3.2 Testing Content and Scope - -- ***Full Functionality Testing***: Executes all test cases defined in `test_{model_name}_expansion.py`, covering all implemented features, positive flows, boundary conditions, and exception handling. -- ***Performance Testing***: Uses `tests/dfx/perf/tests/test_qwen_omni.json`, `tests/dfx/perf/tests/test_tts.json`, and diffusion configs in the form `tests/dfx/perf/tests/test_*_vllm_omni.json` (passed to `run_benchmark.py` via `--test-config-file`) to drive performance testing tools for stress, load, and endurance tests, collecting metrics like throughput, response time, and resource utilization. -- ***Documentation Testing***: Verifies whether the example code provided to users is runnable and its results match the description. - -### 3.3 Test Directory and Execution Files - -- ***Functional Testing***: Same directories as L3. -- ***Performance Test Configuration***: `tests/dfx/perf/tests/test_qwen_omni.json`, `tests/dfx/perf/tests/test_tts.json`, and diffusion configs `tests/dfx/perf/tests/test_*_vllm_omni.json` (e.g. `test_qwen_image_vllm_omni.json`) -- ***Documentation Example Tests***: -- - `tests/example/online_serving/test_{model_name}.py` - - `tests/example/offline_inference/test_{model_name}.py` - -### 3.4 Execution Method and Example - -- ***Trigger Timing***: **`Nightly`**, automatically executed every night. -- ***Execution Environment***: ***GPU*** server clusters to meet the resource demands of performance testing. -- ***Script Example***: - -??? example "Test Examples: Documentation Example Tests" - - --8<-- "docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md" - -??? example "Test Examples: Performance Tests" - - --8<-- "docs/contributing/ci/test_examples/l4_performance_tests.inc.md" - -??? example "Test Examples: Functionality Tests" - - --8<-- "docs/contributing/ci/test_examples/l4_functionality_tests.inc.md" - -- ***Run Command***: (Specific commands would depend on the performance testing tool and configuration defined in `nightly.json`). - -## Chapter 4: L5 Level Testing - Stability and Reliability Testing - -### 4.1 Testing Purpose - -L5 level testing focuses on the performance of model services under ***long-running*** and ***abnormal fault*** scenarios. It aims to uncover deep-seated issues that only manifest under sustained pressure or extreme conditions, such as memory leaks, resource contention, gradual performance degradation, and lack of fault tolerance mechanisms. This is the final, yet crucial, line of defense for ensuring service high availability and production environment robustness. - - - -### 4.2 Testing Content and Scope - -- ***Long-term Stability (Stability) Testing***: Uses JSON under `tests/dfx/stability/tests/` (for example `test_qwen3_omni.json` and `test_wan22.json`) to run the service under moderate load for an extended period (e.g., over 12 hours), monitoring whether metrics like memory/VRAM usage, response time, and throughput degrade over time, and whether the service process remains stable. -- ***Reliability Testing***: Uses pytest suites under `tests/dfx/reliability/` to inject controlled faults against a **live** `vllm_omni serve` instance (same **`omni_server` / `omni_server_function`** fixture style as E2E). Current suites emphasize **GPU memory pressure** (CUDA sidecar “memory hog”), **worker / runtime process kill** (`SIGKILL` on `VLLM::Worker` for Qwen3-Omni, `multiprocessing.spawn` for Wan2.2 video workers, or `vLLM-Omni::` for HunyuanImage DiT workers), **large multimodal chat**, **`/v1/videos`**, **`/v1/images/generations`**, or **`/v1/audio/speech`** jobs under OOM, **`/health` → 503** and **fast-fail / non-hanging concurrent** requests after kill, and **OpenAI-style 5xx error contracts** (e.g. text vs text+audio under OOM). **Post-fault recovery** checks exist where enabled (some cases may be `skip` while issues are tracked). See the Reliability `
` block in Section 4.4 for file-level responsibilities and CI markers (`slow`, `hardware_test`, POSIX-only kill). - -### 4.3 Test Directory and Execution Files - -- ***Stability Test Configuration***: `tests/dfx/stability/tests/test_qwen3_omni.json`, `tests/dfx/stability/tests/test_wan22.json` (one JSON per model / runner family) -- ***Reliability Test Suite*** (`tests/dfx/reliability/`): - - `test_reliability_qwen3_omni.py` — Qwen3-Omni chat / multimodal reliability (GPU OOM, process kill, recovery, error contract under `--async-chunk` vs default). - - `test_reliability_wan22.py` — Wan2.2 I2V video API reliability (`/v1/videos` under OOM and process kill, recovery). - - `test_reliability_hunyuan_image.py` — HunyuanImage-3.0-Instruct DiT-only reliability (`/v1/images/generations` under OOM and process kill; deploy `hunyuan_image3_dit.yaml`, H100 × 4). - - `test_reliability_voxcpm2.py` — VoxCPM2 TTS reliability (`/v1/audio/speech` under OOM and process kill; deploy `voxcpm2.yaml`, L4 × 1). - - `helpers.py` — Shared primitives used by current suites: raw HTTP probes for `/v1/chat/completions` and `/health`, OpenAI-style error parsing, GPU OOM sidecar (`inject_gpu_oom` / `stop_gpu_oom_hogs`), and `pgrep`-based process-kill injector construction (`make_process_kill_fault_injector`). - - `conftest.py` — `fault_injector` and `omni_server_after_fault` / `omni_server_after_fault_function` fixtures to run a callable **after** the server is ready. - - `README.md` — Short local run commands for this directory. - -### 4.4 Execution Method and Example - -- ***Trigger Timing***: **`Weekly`** (weekly) or **`Days before Release`** (several days before a major release). Due to long execution times, the frequency is lower. -- ***Execution Environment***: ***GPU*** servers, requiring a stable and exclusive testing environment. -- ***Script Example***: -
- Test Examples - -When you want to add L5-level stability test cases, add or extend the appropriate JSON file under `tests/dfx/stability/tests/` (for example `test_qwen3_omni.json` for Omni bench traffic, or `test_wan22.json` for diffusion `/v1/videos` workloads). The following illustrates the Qwen3-Omni shape: - -```json -{ - "test_name": "test_qwen3_omni_stability", - "server_params": { - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "stage_config_name": "qwen3_omni.yaml" - }, - "benchmark_params": [ - { - "dataset_name": "random", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "duration_sec": 43200, - "request_rate": 0.5, - "num_prompts_per_batch": 20, - "random_input_len": 2500, - "random_output_len": 900, - "ignore_eos": true, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration" - } - ] -} -``` - -#### Parameter Explanation - -***Overview*** - -| Field | Required | Description | -| ---------------- | -------- | --------------------------------------------------------------------------- | -| test_name | Yes | Unique identifier for the stability test case | -| server_params | Yes | Server-side configuration parameters (model, stage configuration, etc.) | -| benchmark_params | Yes | Stability benchmark running parameters (supports multiple configurations) | - -#### server_params Configuration - -##### Basic Parameters - -| Parameter | Required | Example | Description | -| ----------------- | -------- | ---------------------------------- | ----------------------------------- | -| model | Yes | "Qwen/Qwen3-Omni-30B-A3B-Instruct" | Model name or path | -| stage_config_name | Yes | "qwen3_omni.yaml" | Stage configuration file name | - -##### Dynamic Configuration (update/delete) - -Supports incremental modifications based on the basic configuration: - -| Operation | Description | -| --------- | ------------------------------------ | -| update | Update or add configuration items | -| delete | Delete specified configuration items | - -***Example***: -You can refer to Test Examples in Chapter 3.4 - -#### benchmark_params Configuration - -For stability testing, the key parameters are: - -- **duration_sec**: Total duration (in seconds) during which benchmark traffic is sent. The stability benchmark will keep sending batches until this duration is reached. -- **request_rate** / **max_concurrency**: Exactly one of them must be specified. They control how the traffic is generated for each batch: - - `request_rate`: Number of requests per second. The benchmark will send `num_prompts_per_batch` requests at the given rate. - - `max_concurrency`: Maximum number of concurrent requests. When this is used, `request_rate` is set to `inf` internally. -- **num_prompts_per_batch**: Number of prompts sent in each batch. Multiple batches will be executed sequentially within `duration_sec`. - -All other optional parameters follow the same rules as the in Chapter 3.4. - -
- -
- Reliability test suite (tests/dfx/reliability) - -#### Purpose and relationship to stability - -Reliability tests are **short fault-injection** integration runs (L5 **(b)** in `tests/dfx/reliability/README.md`). They complement **stability** JSON-driven long runs: instead of hours of steady traffic, they **perturb** the server (GPU OOM sidecar, fatal signals on selected processes) and check **failure mode** and **latency bounds** (e.g. chat or `/v1/videos` must not hang under concurrent fault-time load). - -#### Directory layout - -| Path | Responsibility | -| ---- | -------------- | -| `helpers.py` | Shared helpers used by current reliability suites: raw `POST`/`GET` probes (`/v1/chat/completions`, `/health`), OpenAI error parsing (`extract_openai_error_contract_from_bytes`), GPU OOM sidecar lifecycle (`inject_gpu_oom`, `stop_gpu_oom_hogs`), and process-kill injector builder (`make_process_kill_fault_injector`). | -| `conftest.py` | Pytest fixtures: indirect `fault_injector`, `omni_server_after_fault` / `omni_server_after_fault_function` (run injector after server is ready, then yield server). | -| `test_reliability_qwen3_omni.py` | Qwen3-Omni: OOM vs **text vs text+audio** error contract, large multimodal chat under OOM, concurrent pressure, **SIGKILL** on `VLLM::Worker`, `/health` → 503 + fast-fail + concurrent chat; optional OOM recovery scenario (may be skipped while tracked in issues). | -| `test_reliability_wan22.py` | Wan2.2 I2V: large `/v1/videos` under OOM, **SIGKILL** on `multiprocessing.spawn` chain, health / fast-fail / concurrent video requests; optional recovery test (may be skipped). | -| `test_reliability_hunyuan_image.py` | HunyuanImage DiT-only: large `/v1/images/generations` under OOM, **SIGKILL** on `vLLM-Omni::` workers and serve/tree targets, health / fast-fail / concurrent image requests; some OOM/recovery cases may be skipped while tracked in issues. | -| `test_reliability_voxcpm2.py` | VoxCPM2: `/v1/audio/speech` under OOM (error contract), **SIGKILL** on `VLLM::` workers and serve/tree targets, health / fast-fail / concurrent speech requests; some OOM cases may be skipped while tracked in issues. | -| `README.md` | Minimal run / collect examples. | - -#### Parametrization and markers - -- Each test module defines a **`RELIABILITY_SCENARIOS`** list (`test_name`, `server_params`: model, `stage_config_name` or diffusion `server_args`, etc.). **`create_reliability_omni_server_params()`** in `tests/dfx/conftest.py` resolves stage paths (including XPU substitutions where applicable) and builds **`OmniServerParams`** lists consumed by **`@pytest.mark.parametrize(..., indirect=True)`** on `omni_server` or `omni_server_function`. -- Cases are tagged **`@pytest.mark.slow`** for weekly / selective CI. GPU-heavy suites use **`@hardware_test(res={"cuda": "H100"}, num_cards=...)`** or **`@hardware_test(res={"cuda": "L4"}, num_cards=1)`** (Qwen3-Omni **2**× H100; Wan2.2 **1**× H100; HunyuanImage DiT **4**× H100; VoxCPM2 **1**× L4). -- **Process-kill** tests use **`@pytest.mark.skipif(os.name == "nt", ...)`** because injection uses POSIX **`pgrep` / `kill`**. - -#### CI trigger - -Weekly Buildkite (`.buildkite/test-weekly.yml`) runs one step per model suite (trigger: `WEEKLY=1` or PR label `weekly-test`), for example: - -| Buildkite step | Test file | CI hardware | -| -------------- | --------- | ----------- | -| Reliability Test - qwen3-omni | `test_reliability_qwen3_omni.py` | H100 × 2 (`mithril-h100-pool`) | -| Reliability Test - wan22 | `test_reliability_wan22.py` | H100 × 2 (`mithril-h100-pool`) | -| Reliability Test - hunyuan-image | `test_reliability_hunyuan_image.py` | H100 × 4 (`mithril-h100-pool`) | -| Reliability Test - voxcpm2 | `test_reliability_voxcpm2.py` | L4 × 1 (`gpu_1_queue`) | - -```bash -pytest -s -v tests/dfx/reliability/test_reliability_qwen3_omni.py -m "slow" -pytest -s -v tests/dfx/reliability/test_reliability_wan22.py -m "slow" -pytest -s -v tests/dfx/reliability/test_reliability_hunyuan_image.py -m "slow" -pytest -s -v tests/dfx/reliability/test_reliability_voxcpm2.py -m "slow" -``` - -#### Local commands - -```bash -pytest --collect-only tests/dfx/reliability -pytest -s -v tests/dfx/reliability/test_reliability_qwen3_omni.py -m slow -pytest -s -v tests/dfx/reliability/test_reliability_wan22.py -m slow -pytest -s -v tests/dfx/reliability/test_reliability_hunyuan_image.py -m slow -pytest -s -v tests/dfx/reliability/test_reliability_voxcpm2.py -m slow -``` - -#### Adding a new model suite - -1. Add `test_reliability_.py` under `tests/dfx/reliability/`. -2. Define **`RELIABILITY_SCENARIOS`** and pass them through **`create_reliability_omni_server_params()`** with the correct deploy or e2e stage-config directory (same pattern as existing files). -3. Reuse **`helpers`** for OOM / kill / raw HTTP; prefer **`assert_fault_exception()`** and **`resolve_oom_device_spec()`** from `tests/dfx/conftest.py` for consistent device selection vs stage YAML. -4. Register **`slow`** (and **`hardware_test`** if needed); extend **`.buildkite/test-weekly.yml`** when the suite should run in weekly L5. - -
- -- - ***Stability***: `pytest -s -v tests/dfx/stability/scripts/test_stability_qwen3_omni.py` or `pytest -s -v tests/dfx/stability/scripts/test_stability_wan22.py` (or add `test_stability_.py` alongside a matching JSON config) - - ***Reliability***: `pytest -s -v tests/dfx/reliability/test_reliability_.py -m slow` (current suites: `qwen3_omni`, `wan22`, `hunyuan_image`, `voxcpm2`; add `test_reliability_.py` and a matching step in `.buildkite/test-weekly.yml` for new models) - -## Summary - -This multi-level testing system achieves continuous, progressive validation of model service quality by tightly integrating testing activities with the development workflow (commit, review, merge, release). From rapid unit testing to comprehensive end-to-end testing, and further to in-depth performance, stability, and reliability verification, each level has clear objectives, collectively building a robust quality protection net. By following this system, teams can deliver high-quality, highly reliable model services more efficiently. diff --git a/docs/contributing/ci/ci_settings.md b/docs/contributing/ci/ci_settings.md new file mode 100644 index 000000000..ff4b8e107 --- /dev/null +++ b/docs/contributing/ci/ci_settings.md @@ -0,0 +1,344 @@ +# CI Settings + +This document describes **where** Buildkite YAML lives in the repo, **how each platform organizes CI**, and **how to add a new job**. It does not document agent queues, GPU types, or container plugin details—those belong in infra / preset files (for example `.buildkite/common/ci_mirror_hardwares.yml`). + +For CI levels (L1–L5) and triggers, see [Test System Overview](./test_system_overview.md). For test authoring, see [Test Writing Guide](./test_writing_guide.md). For running tests locally or replaying CI jobs, see [Test Execution Guide](./test_execution_guide.md). + +## Directory layout + +Canonical layout (prefer these paths for new changes): + +``` +.buildkite/ +├── common/ # Shared across platforms +│ ├── scripts/ +│ │ ├── skip_ci.py # skip-ci decision (docs / skip-mark / CI YAML paths) +│ │ ├── upload_pipeline.py # Bootstrap + test-pipeline uploader (CUDA/NPU) +│ │ └── resolve_skip_ci.sh # Shell helpers for AMD/Intel bootstrap +│ └── ci_mirror_hardwares.yml # CUDA uploader presets (referenced by name only) +├── cuda/ # Primary NVIDIA CUDA CI +│ ├── pipeline.yml # Bootstrap entry (hook upload) +│ ├── bootstrap-upload-steps.yml # Bootstrap child steps (upload_pipeline --upload) +│ ├── test-ready.yml # L2 +│ ├── test-merge.yml # L3 +│ ├── test-nightly.yml # L4 +│ ├── test-weekly.yml # L5 +│ └── rebase-pipeline.yml +├── npu/ +│ ├── pipeline-npu.yml # Bootstrap entry (hook upload) +│ ├── bootstrap-upload-steps.yml # Bootstrap child steps (upload_pipeline --upload) +│ ├── pipeline-npu-a3.yml # A3 variant (when used) +│ ├── test-npu-ready.yml # L2 +│ ├── test-npu-nightly.yml # L4 +│ └── scripts/ +├── amd/ +│ ├── test-amd-ready.yml # L2 job definitions (template input) +│ ├── test-amd-merge.yml # L3 job definitions +│ ├── test-template-amd-omni.j2 # Renders final pipeline.yaml +│ └── scripts/ +│ ├── bootstrap-amd-omni.sh # Entry: skip-ci → Jinja → upload +│ └── run-amd-test.sh # Wraps pytest inside ROCm docker +├── intel/ +│ ├── pipeline-intel.yml # Static Intel XPU pipeline +│ └── scripts/ +│ ├── bootstrap-intel-omni.sh +│ └── run-xpu-test.sh +└── release/ + ├── release-pipeline.yml + └── scripts/ +``` + +**Placement rules** + +| Rule | Detail | +| ---- | ------ | +| Platform code under platform dir | New CUDA jobs go in `.buildkite/cuda/`; do not add new top-level `.buildkite/test-*.yml` files. | +| Shared logic in `common/` | Skip-ci and CUDA upload rendering stay in `.buildkite/common/scripts/`. | +| Bootstrap vs test YAML | **Bootstrap** (`pipeline*.yml`) builds images and uploads **child** test pipelines. **Test** YAML (`test-*.yml`) lists pytest steps only. | +| Register CI YAML in `skip_ci.py` | If you add a new whitelisted test pipeline file, update `L2_YAML_FILES`, `L3_YAML_FILES`, or `L45_YAML_FILES` in `.buildkite/common/scripts/skip_ci.py` so skip-ci paths stay correct. | + +There are still **legacy copies** at `.buildkite/*.yaml` (without the `cuda/` prefix). Treat `.buildkite/cuda/*` as source of truth. + +## Platform comparison + +| Platform | Bootstrap entry | Test job files | Upload mechanism | Job hardware in YAML | +| -------- | ---------------- | -------------- | ---------------- | -------------------- | +| **CUDA** | `cuda/pipeline.yml` | `test-ready.yml`, `test-merge.yml`, `test-nightly.yml`, `test-weekly.yml` | `upload_pipeline.py --upload` (expands uploader-only keys) | `mirror_hardwares: ` (string) | +| **NPU** | `npu/pipeline-npu.yml` | `test-npu-ready.yml`, `test-npu-nightly.yml` | `upload_pipeline.py --upload` | `mirror_hardwares: a2b3_npu_1` / `a2b3_npu_4` / `a3_npu_2` | +| **AMD** | `amd/scripts/bootstrap-amd-omni.sh` | `test-amd-ready.yml`, `test-amd-merge.yml` | Jinja (`test-template-amd-omni.j2`) → `pipeline upload` | `agent_pool` + `mirror_hardwares: [amdproduction]` (array, template filter) | +| **Intel** | `intel/scripts/bootstrap-intel-omni.sh` | `intel/pipeline-intel.yml` (steps inline) | Direct `pipeline upload` | Inline `agents.queue` on each step | + +## Platform configuration style + +=== "CUDA" + + **Bootstrap:** [`cuda/pipeline.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/cuda/pipeline.yml) (hook upload) + [`cuda/bootstrap-upload-steps.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/cuda/bootstrap-upload-steps.yml) (child steps). Document 1 runs [`upload_pipeline.py --upload .buildkite/cuda/bootstrap-upload-steps.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/scripts/upload_pipeline.py), which injects `if` by step `key` from skip-ci and uploads image build plus L2–L5 child pipeline upload steps (see [Diff-aware CI — Bootstrap skip](#bootstrap-skip)). + + **Test YAML:** `cuda/test-ready.yml` (L2), `test-merge.yml` (L3), `test-nightly.yml` (L4), `test-weekly.yml` (L5). Each file starts with shared `env:` then `steps:`. + + | CI level | File | Typical trigger | + | -------- | ---- | ----------------- | + | L2 | `cuda/test-ready.yml` | `ready` label | + | L3 | `cuda/test-merge.yml` | `merge-test` label / main merge | + | L4 | `cuda/test-nightly.yml` | `nightly-test` label or `NIGHTLY=1` | + | L5 | `cuda/test-weekly.yml` | `weekly-test` label or `WEEKLY=1` | + + **Upload:** `upload_pipeline.py --upload` expands uploader-only keys before Buildkite upload. + + **Hardware in YAML:** `mirror_hardwares: ` (string)—preset names in [`common/ci_mirror_hardwares.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/ci_mirror_hardwares.yml). Do **not** set `agents` / `plugins` on the same step. + + **Conventions** + + - **`depends_on`:** leaf jobs depend on `upload-ready-pipeline`, `upload-merge-pipeline`, etc. + - **`group` / `label`:** `:card_index_dividers:` groups; labels like `Diffusion · Qwen Image Test`. + - **`commands`:** `timeout … pytest …` with markers and `--run-level` for the pipeline level. + - **`source_file_dependencies`:** required on **E2E Test** leaf jobs in L2/L3; see [Step filtering](#step-filtering). + + **Adding a job** + + 1. Pick the level file (ready / merge / nightly / weekly). + 2. Add a step under the right **group** (usually **E2E Test** for model pytest). + 3. Set `label`, `commands`, `mirror_hardwares`, `depends_on: upload--pipeline`. + 4. For L2/L3 E2E, add `source_file_dependencies` (pytest + model + deploy YAML prefixes). + 5. Dry-run: + + ```bash + python3 .buildkite/common/scripts/upload_pipeline.py .buildkite/cuda/test-ready.yml + ``` + +=== "NPU" + + **Bootstrap:** [`npu/pipeline-npu.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/npu/pipeline-npu.yml) + [`npu/bootstrap-upload-steps.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/npu/bootstrap-upload-steps.yml)—same split as CUDA; builds A2/B3 and A3 CI images, then uploads child test pipelines. + + **Test YAML:** `npu/test-npu-ready.yml` (L2), `test-npu-nightly.yml` (L4). + + **Upload:** same as CUDA—`upload_pipeline.py --upload`. + + **Hardware in YAML:** `mirror_hardwares` preset (string), expanded to `agents`, top-level `image`, and `plugins`. Presets: `a2b3_npu_1`, `a2b3_npu_4`, `a3_npu_2` in `common/ci_mirror_hardwares.yml`. + + **Conventions** + + - **`depends_on: upload-ready-pipeline`** (or `upload-nightly-pipeline`) ties jobs to bootstrap upload keys. + - Do not duplicate `agents` / `image` / `plugins` when using `mirror_hardwares`. + + **Adding a job** + + 1. Edit `test-npu-ready.yml` (L2) or `test-npu-nightly.yml` (L4). + 2. Add a step with `mirror_hardwares` (add a new preset in `ci_mirror_hardwares.yml` first if needed). + 3. Set `commands` to your pytest file and markers. + 4. Dry-run: + + ```bash + python3 .buildkite/common/scripts/upload_pipeline.py .buildkite/npu/test-npu-ready.yml + ``` + + 5. Register new pipeline paths in `skip_ci.py` (`L2_YAML_FILES` or `L45_YAML_FILES`) when applicable. + +=== "AMD" + + **Bootstrap:** [`amd/scripts/bootstrap-amd-omni.sh`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/amd/scripts/bootstrap-amd-omni.sh)—skip-ci, diff filtering, Jinja render, then `buildkite-agent pipeline upload`. + + **Test YAML (data):** `amd/test-amd-ready.yml` (L2 / PR), `test-amd-merge.yml` (L3 / main). + + **Rendering:** [`test-template-amd-omni.j2`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/amd/test-template-amd-omni.j2) wraps data steps with `amd-build` image build and `amd_` queues. Do **not** hand-edit generated `pipeline.yaml`. + + **Hardware in YAML:** `agent_pool` (for example `mi325_1`) plus `mirror_hardwares: [amdproduction]` (array—Buildkite template filter, not the CUDA/NPU uploader preset mechanism). + + **Data file fields** + + | Field | Purpose | + | ----- | ------- | + | `agent_pool` | ROCm pool; template maps to `queue: amd_`. | + | `mirror_hardwares` | Which mirror HW runs the step (for example `[amdproduction]`). | + | `commands` | Passed into `run-amd-test.sh` via `TEST_COMMAND`. | + + **Adding a job** + + 1. Edit `test-amd-ready.yml` or `test-amd-merge.yml`. + 2. Copy a neighboring block: `label`, `agent_pool`, `mirror_hardwares`, `commands`, optional `grade`. + 3. Regenerate via bootstrap / Jinja; update `skip_ci.py` if you add a new YAML path. + +=== "Intel" + + **Bootstrap:** [`intel/scripts/bootstrap-intel-omni.sh`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/intel/scripts/bootstrap-intel-omni.sh)—skip-ci, then direct `pipeline upload`. + + **Test YAML:** steps live inline in [`intel/pipeline-intel.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/intel/pipeline-intel.yml); runners under `intel/scripts/` (for example `run-xpu-test.sh`). + + **Upload:** `buildkite-agent pipeline upload` (no `upload_pipeline.py` mirror expansion). + + **Hardware in YAML:** inline `agents.queue` (for example `intel-gpu-omni`) on each step. + + **Adding a job** + + 1. Add a step to `pipeline-intel.yml`, or extend an existing runner script. + 2. Match `agents`, `env`, `timeout_in_minutes`, and `command`/`commands` with sibling steps. + 3. Keep `pipeline-intel.yml` listed in `L2_YAML_FILES` in `skip_ci.py`. + +## Cross-cutting conventions + +### Diff-aware CI {#diff-aware-ci} + +PR diffs drive **two independent skip layers**. Both read changed files from git, but at different pipeline stages and with different granularity: + +| Layer | Script | When | What is skipped | Where you configure | +| ----- | ------ | ---- | --------------- | ------------------- | +| **Bootstrap** | [`skip_ci.py`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/scripts/skip_ci.py) + [`upload_pipeline.py`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/scripts/upload_pipeline.py) | Before child test pipelines upload (`cuda/pipeline.yml`, `npu/pipeline-npu.yml`, AMD/Intel bootstraps) | Entire default CI, or whole L2/L3 upload for a platform | Whitelists in `skip_ci.py`; bootstrap `if` injected by step `key` in `upload_pipeline.py` | +| **Step filter** | [`upload_pipeline.py`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/scripts/upload_pipeline.py) | While uploading CUDA L2/L3 YAML | Individual Buildkite steps inside `test-ready.yml` / `test-merge.yml` | `source_file_dependencies` on each step or group | + +**Changed files** (both layers): + +| Build context | Diff command | +| --- | --- | +| Pull request | `git diff --name-only origin/...` | +| `main` push | `git diff --name-only ^..` | +| Local dry-run / non-PR | Diff unavailable → bootstrap and step filter both keep all steps; uploader-only keys are still stripped | + +Label triggers (`ready`, `merge-test`) are unchanged—diff-aware logic only reduces what runs **after** a pipeline is already scheduled. + +#### Bootstrap skip {#bootstrap-skip} + +[`skip_ci.py`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/common/scripts/skip_ci.py) classifies the git diff into buckets (docs / qualifying skip-mark / whitelisted L2·L3·L4/L5 YAML / other), then picks one decision path. CUDA/NPU apply that decision by injecting Buildkite `if` on `bootstrap-upload-steps.yml` step keys; AMD/Intel call `gate_bootstrap_ci `. + +##### Decision overview + +| Diff shape | Path | Default L2/L3 | +| --- | --- | --- | +| Product code / non-whitelisted paths | normal CI | all on | +| Docs and/or qualifying skip-mark only | `skip_all` | all off (scheduled `main` NIGHTLY/WEEKLY exceptions below) | +| Whitelisted CI YAML only | yaml-gated (`skip_l2_l3`) | per platform/level matrix | +| Docs/skip-mark **+** whitelisted CI YAML | yaml-gated (`skip_l2_l3`) | same as CI-YAML-only (does **not** widen to normal CI) | +| Non-qualifying skip-mark and no CI YAML | normal CI | all on | +| Diff unavailable | normal CI | all on | + +**`skip_all` exceptions (CUDA/NPU bootstrap only):** PR labels (`nightly-test`, `merge-test`, `npu-test`, `weekly-test`, …) do **not** revive jobs. On `main`, scheduled `NIGHTLY=1` still builds the image and uploads L4 plus L2/L3 (with `--e2e`); `WEEKLY=1` still builds the image and uploads L5 (CUDA). + +**Yaml-gated nightly/weekly:** L4/L5 upload steps keep their normal label / `NIGHTLY` / `WEEKLY` conditions (for example PR `nightly-test` still uploads nightly). Only L2/L3 upload steps are matrix-gated. + +##### Whitelisted CI YAML → platform + +Register new files in `L2_YAML_FILES`, `L3_YAML_FILES`, or `L45_YAML_FILES` in `skip_ci.py`. + +| Level | File | Platform | +| --- | --- | --- | +| L2 | `.buildkite/cuda/test-ready.yml` | cuda | +| L2 | `.buildkite/npu/test-npu-ready.yml` | npu | +| L2 | `.buildkite/amd/test-amd-ready.yml` | amd | +| L2 | `.buildkite/intel/pipeline-intel.yml` | intel | +| L3 | `.buildkite/cuda/test-merge.yml` | cuda | +| L3 | `.buildkite/amd/test-amd-merge.yml` | amd | +| L4/L5 | `.buildkite/cuda/test-nightly.yml`, `test-weekly.yml` | cuda | +| L4/L5 | `.buildkite/npu/test-npu-nightly.yml` | npu | + +##### Yaml-gated category branches + +`L2?` / `L3?` / `L45?` = whether any file in that whitelist changed. Untouched platforms/levels end up off. + +| ID | L2? | L3? | L45? | Enabled L2/L3 | +| --- | --- | --- | --- | --- | +| G1 | | | ✓ | none (all L2/L3 off) | +| G2 | ✓ | | | L2 only on platforms whose L2 YAML changed | +| G3 | | ✓ | | L3 only on platforms whose L3 YAML changed | +| G4 | ✓ | ✓ | | union of G2 and G3 (same platform ready+merge → both on) | +| G5 | ✓ | | ✓ | same as G2 (L45 rescued by L2 YAML) | +| G6 | | ✓ | ✓ | same as G3 (L45 rescued by L3 YAML) | +| G7 | ✓ | ✓ | ✓ | same as G4 | + +##### Yaml-gated examples (platform-level) + +Docs/skip-mark mixed with any row below follows the same matrix. + +| Diff (whitelist) | Branch | Enabled L2/L3 | +| --- | --- | --- | +| Only `cuda/test-ready.yml` | G2 | `cuda/l2` | +| Only `amd/test-amd-ready.yml` | G2 | `amd/l2` | +| Only `npu/test-npu-ready.yml` | G2 | `npu/l2` | +| Only `intel/pipeline-intel.yml` | G2 | `intel/l2` | +| Only `cuda/test-merge.yml` | G3 | `cuda/l3` | +| Only `amd/test-amd-merge.yml` | G3 | `amd/l3` | +| CUDA ready + CUDA merge | G4 | `cuda/l2` + `cuda/l3` | +| CUDA ready + AMD merge | G4 | `cuda/l2` + `amd/l3` | +| CUDA ready + AMD ready | G2 | `cuda/l2` + `amd/l2` | +| CUDA merge + AMD merge | G3 | `cuda/l3` + `amd/l3` | +| Only nightly / weekly / npu-nightly YAML | G1 | (none) | +| CUDA ready + nightly | G5 | `cuda/l2` | +| CUDA merge + nightly | G6 | `cuda/l3` | +| CUDA ready + merge + nightly | G7 | `cuda/l2` + `cuda/l3` | + +##### How platforms consume the decision + +| Platform | Mechanism | +| --- | --- | +| **CUDA** | `upload_pipeline.py` injects `if` by step `key` (`image-build`, `upload-ready-pipeline`, `upload-merge-pipeline`, …) from `is_run(cuda, l2/l3)` | +| **NPU** | Same injection; no merge upload step (L3 always off in bootstrap) | +| **AMD / Intel** | `gate_bootstrap_ci ` exits 0 when `skip_all` or that target is off | + +Unit coverage: `tests/buildkite/test_skip_ci.py`. + +#### Step filtering {#step-filtering} + +CUDA **L2** (`.buildkite/cuda/test-ready.yml`) and **L3** (`.buildkite/cuda/test-merge.yml`) only. Bootstrap upload entry: `upload_pipeline.py --upload .buildkite/cuda/bootstrap-upload-steps.yml`. + +**Uploader-only keys** — removed before Buildkite sees the YAML; never used at runtime on agents: + +| Key | Purpose | +| --- | ------- | +| `source_file_dependencies` | List of path **prefixes**. If any changed file equals a prefix or starts with `prefix/`, keep the step (or group); otherwise omit it. | +| `mirror_hardwares` | Expand to `agents` + `plugins` (+ optional `image`) from `ci_mirror_hardwares.yml`. | + +**Policy** + +- **Always uploaded** (no key): groups outside **E2E Test**—Simple Test, Diffusion unit tests, Engine/Model Executor, Distributed, Custom Pipeline, Entrypoints (L2), LoRA / Entrypoints (L3). +- **Diff-gated**: every **E2E Test** leaf job. List the smallest prefix set per step—pytest file(s), model code under `vllm_omni/model_executor/models/` or `vllm_omni/diffusion/models/`, plus `stage_input_processors/` and `vllm_omni/deploy/*.yaml` when applicable. A **group** may define the key instead; the whole group drops if no prefix matches. + +**YAML examples** + +```yaml + - label: "Diffusion · Qwen Image Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen_image.py + - vllm_omni/diffusion/models/qwen_image/ + commands: + - pytest -s -v tests/e2e/online_serving/test_qwen_image.py -m 'core_model' ... + mirror_hardwares: h100_1 + + - label: "TTS · Qwen3-TTS CustomVoice Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen3_tts_customvoice.py + - vllm_omni/model_executor/models/qwen3_tts/ + - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py + - vllm_omni/deploy/qwen3_tts.yaml + commands: + - pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py ... + mirror_hardwares: l4_4 +``` + +**Local dry-run** + +```bash +python3 .buildkite/common/scripts/upload_pipeline.py .buildkite/cuda/test-ready.yml +python3 .buildkite/common/scripts/upload_pipeline.py .buildkite/cuda/test-merge.yml | grep source_file_dependencies +# (no output expected) +``` + +On PR builds, `upload_pipeline.py` logs `skip '…' (no changes under …)` for omitted steps. + +### Labels and grouping + +- Use **groups** for dashboard readability; keep **E2E Test** as the group name CUDA diff filtering expects for `--e2e` nightly runs on main. +- Prefix labels by model domain: **Omni ·**, **TTS ·**, **Diffusion ·**, **Simple ·**, etc., matching existing steps. + +### Validation checklist + +| Check | Command / location | +| ----- | ------------------ | +| CUDA render | `python3 .buildkite/common/scripts/upload_pipeline.py .buildkite/cuda/test-.yml` | +| No leaked uploader keys | `… \| grep -E 'mirror_hardwares|source_file_dependencies'` → empty | +| Skip-ci / upload unit tests | `pytest tests/buildkite/` | +| Local job replay (CUDA L2+) | `tools/run_ready_jobs.sh`, `tools/run_merge_jobs.sh`, `tools/nightly/run_nightly_jobs.sh` (read YAML from `cuda/`) | + +## Related documentation + +- [Test System Overview](./test_system_overview.md) — L1–L5 scope, triggers, test pyramid +- [Test Writing Guide](./test_writing_guide.md) — markers, directories, L1–L5 examples +- [Test Execution Guide](./test_execution_guide.md) — running CI-aligned jobs locally +- Implementation: `.buildkite/common/scripts/upload_pipeline.py`, `.buildkite/common/scripts/skip_ci.py` diff --git a/docs/contributing/ci/failures.md b/docs/contributing/ci/failures.md index 80699e6a5..9743097cf 100644 --- a/docs/contributing/ci/failures.md +++ b/docs/contributing/ci/failures.md @@ -79,7 +79,7 @@ For resilience behavior under injected process signals/OOM pressure (validation ### Running Tests Locally -For instructions on running tests locally (including specific test files, functions, and markers), see the [Running Tests](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/test_guide/#running-tests) section in the Test Guide. +For instructions on running tests locally (including specific test files, functions, and markers), see the [Running Tests](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/test_execution_guide/#running-tests) section in the Test Execution guide. ## Step 4: Raise an Issue or Fix It diff --git a/docs/contributing/ci/test_examples/l4_functionality_tests.inc.md b/docs/contributing/ci/test_examples/l4_functionality_tests.inc.md index 688933f13..5e65d2f48 100644 --- a/docs/contributing/ci/test_examples/l4_functionality_tests.inc.md +++ b/docs/contributing/ci/test_examples/l4_functionality_tests.inc.md @@ -1,6 +1,6 @@ **Scope** -For diffusion models, the L4 functionality test covers all or common *diffusion features* that are supported by this model, including several [parallelism acceleration methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/parallelism_acceleration/), [CPU offloading](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cpu_offload_diffusion/), [TeaCache](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/teacache/) and [Cache-DiT](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cache_dit_acceleration/) cache backends, [quantization methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/quantization/overview/). +For diffusion models, the L4 functionality test covers all or common *diffusion features* that are supported by this model, including several [parallelism acceleration methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/parallelism_acceleration/), [CPU offloading](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cpu_offload/), [TeaCache](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/teacache/) and [Cache-DiT](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cache_dit_acceleration/) cache backends, [quantization methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/quantization/overview/). **Test Case Design** @@ -37,7 +37,7 @@ Currently all the features are available in online serving mode. Hence, only nee **Code Style** - Validation: test that the multimodal output files of your model have the correct shapes. `OpenAIClientHandler.send_diffusion_request` should have taken care of this. -- Test marks: always add `full_model` and `diffusion` for L4 nightly `test_*_expansion.py` cases. Add GPU-related marks if needed. Ref: [Markers for Tests](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/tests_markers/). +- Test marks: always add `full_model` and `diffusion` for L4 nightly `test_*_expansion.py` cases. Add GPU-related marks if needed. Ref: [Markers for Tests](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/test_writing_guide/#markers-for-tests). - To maximize code reuse, you may refer to - `tests/conftest.py` for `omni_server` (running server in subprocess) and `openai_client` fixtures (sending requests and validating output), `generate_synthetic_image` and `assert_XXX_valid` helper. - `tests/helpers/mark.py` for `@hardware_test(...)` and `hardware_marks`. diff --git a/docs/contributing/ci/test_examples/l4_performance_tests.inc.md b/docs/contributing/ci/test_examples/l4_performance_tests.inc.md index f1f3073dc..8ff27ee08 100644 --- a/docs/contributing/ci/test_examples/l4_performance_tests.inc.md +++ b/docs/contributing/ci/test_examples/l4_performance_tests.inc.md @@ -1,14 +1,41 @@ -When you want to add L4-level ***performance test*** cases, you can refer to the following format for case addition in `tests/dfx/perf/tests/test_qwen_omni.json`, `tests/dfx/perf/tests/test_tts.json`, or diffusion configs such as `tests/dfx/perf/tests/test_*_vllm_omni.json` (selected via `pytest ... run_benchmark.py --test-config-file `): +When you want to add L4-level ***performance test*** cases, add entries to JSON files under `tests/dfx/perf/tests/` and run them via `tests/dfx/perf/scripts/run_benchmark.py` (omni / TTS) or `run_diffusion_benchmark.py` (diffusion). + +**Config file layout (in-tree examples)** + +| Model type | Runner | Example JSON files | +| ---------- | ------ | ------------------ | +| Omni | `run_benchmark.py` | `test_qwen3_omni_no_async_chunk.json`, `test_qwen3_omni_async_chunk.json`, `test_qwen3_omni_vllm_text.json`, `test_qwen3_omni_multi_replicas.json` | +| TTS | `run_benchmark.py` | `test_tts.json`, `test_voxcpm2.json`, `test_higgs_audio_v3.json` | +| Diffusion | `run_diffusion_benchmark.py` | `test_qwen_image_vllm_omni.json`, `test_bagel_vllm_omni.json`, `test_wan22_i2v_vllm_omni.json`, `test_cosmos3_vllm_omni.json`, … | + +**How runners pick cases** + +Without **`--test-config-file`**, each runner scans all `*.json` under `tests/dfx/perf/tests/` but only keeps its own model type: + +- **`run_benchmark.py`**: omni and TTS cases only (skips diffusion JSON). +- **`run_diffusion_benchmark.py`**: diffusion cases only (skips omni / TTS JSON). + +Diffusion cases are detected when the JSON has `server_type` (typically `"vllm-omni"`) or `"diffusion"` in the `mark` array. Omni / TTS JSON has neither. + +**Running perf cases** + +Pass **`--test-config-file`** to run one JSON file as-is, or omit it for the bulk scan above and filter with pytest **`-m`** on each case's JSON `mark` (for example `pytest … run_benchmark.py -m "full_model and tts and H100"`). ```JSON { - "test_name": "test_qwen3_omni", + "test_name": "test_qwen3_omni_async_chunk", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 2}}, + "full_model", + "omni" + ], "server_params": { "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", "stage_config_name": "qwen3_omni.yaml" }, "benchmark_params": [ { + "name": "random_10p", "dataset_name": "random", "num_prompts": [10, 20], "max_concurrency": [1, 4], @@ -17,9 +44,11 @@ When you want to add L4-level ***performance test*** cases, you can refer to the "ignore_eos": true, "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "mean_ttft_ms": [500, 800], - "mean_audio_ttfp_ms": [2000, 3500], - "mean_audio_rtf": [0.25, 0.35] + "H100": { + "mean_ttft_ms": [500, 800], + "mean_audio_ttfp_ms": [2000, 3500], + "mean_audio_rtf": [0.25, 0.35] + } } } ] @@ -33,8 +62,91 @@ When you want to add L4-level ***performance test*** cases, you can refer to the | Field | Required | Description | | ---------------- | -------- | --------------------------------------------------------------- | | test_name | Yes | Unique identifier for the test case | +| mark | No | Pytest marks for this case (see **`mark` field** below). Omit only for configs not meant to be filtered by `-m`. | | server_params | Yes | Server-side configuration parameters | | benchmark_params | Yes | Benchmark running parameters (supports multiple configurations) | +| server_type | Diffusion only | When set (e.g. `"vllm-omni"`), routes the case to `run_diffusion_benchmark.py`. | +| benchmark_endpoint | Diffusion only | API path for the benchmark client (e.g. `/v1/videos`, `/v1/images/generations`). | + +**`mark` field** + +Optional top-level field on each perf JSON **case object** (one per `test_name`). `run_benchmark.py` and `run_diffusion_benchmark.py` read it via `tests.dfx.conftest.resolve_pytest_marks` and attach the marks to the corresponding `pytest.param`, so you can filter locally with `-m`. + +When `mark` is present, it must be an **array** with exactly one ``hardware_marks`` object (same shape as `@hardware_test` / `hardware_marks()` in `tests/helpers/mark.py`), followed by registered pytest marker name strings such as `full_model`, `omni`, `tts`, `diffusion`, or `local_model`. + +Supported form: + +| Form | Example | Effect | +| ---- | ------- | ------ | +| Array (single platform) | `"mark": [{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 2}}, "full_model", "diffusion"]` | Hardware marks from `hardware_marks(...)`; `num_cards` > 1 adds `distributed_*` (+ CUDA `skipif_cuda` when applicable). String entries become extra pytest marks. | +| Array (multi-platform) | `"mark": [{"hardware_marks": {"res": {"cuda": "H100", "rocm": "MI325", "npu": "A2"}, "num_cards": {"cuda": 2, "rocm": 2, "npu": 1}}}, "full_model", "omni"]` | Same as above, but declares **multiple platforms** in one case. Filter examples: `-m "full_model and H100 and cuda"`, `-m "full_model and MI325 and rocm"`. | + +Recommended for L4 perf cases: + +```JSON +{ + "test_name": "test_bagel_single_device_single_stage_t2i", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, + "full_model", + "diffusion" + ], + "server_type": "vllm-omni", + "server_params": { "...": "..." }, + "benchmark_params": [ { "name": "1024x1024_steps20", "...": "..." } ] +} +``` + +Multi-GPU diffusion (example: Cosmos3 with `cfg-parallel-size=2`): + +```JSON +{ + "test_name": "test_cosmos3_t2i_official_demo_2gpu", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 2}}, + "full_model", + "diffusion" + ], + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/images/generations", + "server_params": { "...": "..." }, + "benchmark_params": [ { "name": "1024x1024_steps4", "...": "..." } ] +} +``` + +- Omni perf: include `"omni"` in the `mark` array +- TTS perf: include `"tts"` in the `mark` array +- Diffusion perf: include `"diffusion"` in the `mark` array +- HunyuanImage local-weight cases: add `"local_model"` to the `mark` array + +**Parametrization IDs** + +Each `(server, benchmark index)` pair becomes one `pytest.param` with id `{test_name}-{suffix}`. The suffix comes from `benchmark_params[].name` when set (for example `test_tts-p0`, `test_omni-p1`); otherwise it is derived from fields like `task` / `eval_phase`. + +**Benchmark result filenames** + +Result files use the **runtime** hardware label from `get_runtime_resource_label()` (detected GPU/NPU name on the machine that ran the job), **not** `mark.hardware_marks.res`. On the default H100 CI pool, `H100` is **omitted** from filenames (`resource_label_for_filename`). + +Examples: + +- Omni/TTS: `result_{test_name}_{optional_hw}_{dataset}_....json` under `BENCHMARK_DIR` +- Diffusion: one aggregate `diffusion_result_{config_stem}_{optional_hw}_{timestamp}.json` per source JSON file (array of all runs from that file) + +**Local commands** + +```bash +# Bulk load + filter by JSON mark +pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m "full_model and H100 and diffusion" +pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m "full_model and omni and H100" + +# Single file (same as nightly CI Perf steps) +pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py \ + --test-config-file tests/dfx/perf/tests/test_bagel_vllm_omni.json +pytest -s -v tests/dfx/perf/scripts/run_benchmark.py \ + --test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json +``` + +See also [Markers for Tests](#markers-for-tests) for registered hardware markers (`H100`, `L4`, `cuda`, `distributed_cuda`, …). **`server_params` Configuration** @@ -79,11 +191,13 @@ You can add any benchmark running parameters you need here. For all optional par 1. Change the --xxx-xx-xx running parameters to xxx_xx_xx format and fill them as keys in the JSON file. 2. For boolean variables in the running parameters, modify them to forms such as ignore_eos: true/false and fill them into the JSON file. 3. Optionally add a `baseline` object (see **Baseline thresholds** below). If you omit `baseline` or leave it empty, the performance test still runs but does not assert metric thresholds from this field. -4. The qps and concurrency modes are recommended to be mutually exclusive. For detailed explanations, see the table below: +4. Set `"name"` on each `benchmark_params` entry for stable pytest ids and readable result keys. +5. The qps and concurrency modes are recommended to be mutually exclusive. For detailed explanations, see the table below: | Parameter | Type | Required | Example/Values | Description | | --------------- | ----------- | -------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | string | No | `"1024x1024_steps4"` | Stable suffix for pytest param id and logs. | | num_prompts | int / array | Yes | 10,[10, 20, 30] | Number of requests. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of qps or max_concurrency, e.g., [10,10,10]. If an array is used, its length must match the number of qps or max_concurrency. | | request_rate | float / array | No | 0.5, [0.5, 1, inf] | Queries per second. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of num_prompts, e.g., [1,1,1]. If an array is used, its length must match the number of num_prompts. | | max_concurrency | int / array | No | 1, [1, 2, 3] | Maximum concurrent in-flight requests. Same array / expansion rules as `request_rate` (mutually exclusive with QPS mode). | -| baseline | object | No | see above | Optional per-metric thresholds; keys must match benchmark output fields. Scalar, list (per sweep step), or object (keyed by concurrency or QPS string). +| baseline | object | No | see below | Optional thresholds. **Hardware-nested only**: top-level keys must be `[hardware-resource]` pytest markers from `pyproject.toml` (`get_hardware_mark_list()`). Under each hardware key, metric names are free-form; values are scalars or sweep-aligned lists. Example: `{"H100": {"throughput_qps": […], "custom_stage_ms": 1.0}, "A3": {…}}`. Runtime filename aliases (`_RUNTIME_DEVICE_ALIASES`) are separate and do not expand this allowlist. | diff --git a/docs/contributing/ci/test_guide.md b/docs/contributing/ci/test_execution_guide.md similarity index 74% rename from docs/contributing/ci/test_guide.md rename to docs/contributing/ci/test_execution_guide.md index 26996129e..d1c2c560f 100644 --- a/docs/contributing/ci/test_guide.md +++ b/docs/contributing/ci/test_execution_guide.md @@ -1,4 +1,4 @@ -# Test Guide +# Test Execution Guide ## Setting Up the Test Environment ### Creating a Container vLLM-Omni provides an official Docker image for deployment. These images are built upon vLLM Docker images and are available on [Docker Hub](https://hub.docker.com/r/vllm/vllm-omni/tags). The version of vLLM-Omni indicates which vLLM release it is based on. @@ -49,11 +49,11 @@ Failed jobs: 1/2 cd tests pytest -s -v -m "core_model and cpu" ``` - The latest test command is available in the "Simple Unit Test" step of this [pipeline](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-ready.yml). + The latest test command is available in the "Simple Unit Test" step of this [pipeline](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/cuda/test-ready.yml). === "L2 level" - **Recommended:** run CI-aligned jobs from the repo root via [`tools/run_ready_jobs.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/run_ready_jobs.sh). The script reads [`.buildkite/test-ready.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-ready.yml), generates per-step bash wrappers, runs pytest, and tees logs under `logs/ready_jobs/` (see [CI job runners](#ci-job-runners-l2l4-logs-timing-and-timeouts) for timing and timeout behavior). Requires `bash`, `python3`, and PyYAML (`pip install pyyaml`). + **Recommended:** run CI-aligned jobs from the repo root via [`tools/run_ready_jobs.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/run_ready_jobs.sh). The script reads [`.buildkite/cuda/test-ready.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/cuda/test-ready.yml), generates per-step bash wrappers, runs pytest, and tees logs under `logs/ready_jobs/` (see [CI job runners](#ci-job-runners-l2l4-logs-timing-and-timeouts) for timing and timeout behavior). Requires `bash`, `python3`, and PyYAML (`pip install pyyaml`). ```bash # All L2 and L1 jobs (default: every pytest step in test-ready.yml) @@ -85,7 +85,7 @@ Failed jobs: 1/2 === "L3 level" - **Recommended:** run CI-aligned jobs from the repo root via [`tools/run_merge_jobs.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/run_merge_jobs.sh). The script reads [`.buildkite/test-merge.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-merge.yml), generates per-step bash wrappers, runs pytest, and tees logs under `logs/merge_jobs/` (see [CI job runners](#ci-job-runners-l2l4-logs-timing-and-timeouts)). Requires `bash`, `python3`, and PyYAML (`pip install pyyaml`). + **Recommended:** run CI-aligned jobs from the repo root via [`tools/run_merge_jobs.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/run_merge_jobs.sh). The script reads [`.buildkite/cuda/test-merge.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/cuda/test-merge.yml), generates per-step bash wrappers, runs pytest, and tees logs under `logs/merge_jobs/` (see [CI job runners](#ci-job-runners-l2l4-logs-timing-and-timeouts)). Requires `bash`, `python3`, and PyYAML (`pip install pyyaml`). ```bash # All L3 and L1 jobs (default: every pytest step in test-merge.yml) @@ -117,7 +117,7 @@ Failed jobs: 1/2 === "L4 level" - **Recommended:** run CI-aligned nightly jobs from the repo root via [`tools/nightly/run_nightly_jobs.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/nightly/run_nightly_jobs.sh). The script reads [`.buildkite/test-nightly.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-nightly.yml), generates per-step bash wrappers, runs pytest (perf jobs first, then aggregates perf JSON into Excel), and tees logs under `logs/nightly_jobs/` (see [CI job runners](#ci-job-runners-l2l4-logs-timing-and-timeouts); `generate_nightly_perf_excel` is included in the timing summary). Requires `bash`, `python3`, and PyYAML (`pip install pyyaml`). + **Recommended:** run CI-aligned nightly jobs from the repo root via [`tools/nightly/run_nightly_jobs.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/nightly/run_nightly_jobs.sh). The script reads [`.buildkite/cuda/test-nightly.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/cuda/test-nightly.yml), generates per-step bash wrappers, runs pytest (perf jobs first, then aggregates perf JSON into Excel), and tees logs under `logs/nightly_jobs/` (see [CI job runners](#ci-job-runners-l2l4-logs-timing-and-timeouts); `generate_nightly_perf_excel` is included in the timing summary). Requires `bash`, `python3`, and PyYAML (`pip install pyyaml`). ```bash # All nightly jobs (default: test-type all, model-type all) @@ -149,12 +149,18 @@ Failed jobs: 1/2 pytest -s -v -m "full_model and distributed_cuda and L4" --run-level=full_model pytest -s -v -m "full_model and (omni or tts) and H100" --run-level=full_model ``` - - To run a standalone perf benchmark (defaults to ``test_qwen_omni.json``; use ``--test-config-file tests/dfx/perf/tests/test_tts.json`` for TTS): - + If you only want to run specific test cases on a particular platform, you can use: ```bash - pytest -s -v tests/dfx/perf/scripts/run_benchmark.py + pytest -s -v -m "full_model and distributed_cuda and L4" --run-level=full_model ``` + Note: ``run_benchmark.py`` and ``run_diffusion_benchmark.py`` accept an optional ``--test-config-file``. If omitted, each loads every ``*.json`` under ``tests/dfx/perf/tests/`` (omni/tts vs diffusion split by ``is_diffusion_perf_config``) and pytest ``-m`` filters by each case's JSON ``mark``: + ```bash + pytest -sv tests/dfx/perf/scripts/run_benchmark.py -m "full_model and tts and H100" + pytest -sv tests/dfx/perf/scripts/run_diffusion_benchmark.py -m "full_model and diffusion and H100" + pytest -sv tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_tts.json + pytest -sv tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_cosmos3_vllm_omni.json + ``` + Nightly **Perf Test** jobs in ``test-nightly.yml`` use ``--test-config-file`` only (no ``-m``). E2e L4 function tests still use ``full_model`` + ``--run-level full_model`` (see [test-nightly.yml](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-nightly.yml)). Example: === "L5 level" @@ -183,9 +189,9 @@ Failed jobs: 1/2 ``` - The latest L5 CI jobs (reliability + invalid-parameter weekly steps) are in [test-weekly.yml](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-weekly.yml). + The latest L5 CI jobs (reliability + invalid-parameter weekly steps) are in [test-weekly.yml](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/cuda/test-weekly.yml). -You can find more information about markers in the documentation: [marker doc](./tests_markers.md) +You can find more information about markers in [Test Writing Guide — Markers for Tests](./test_writing_guide.md#markers-for-tests). ## Adding New Test Cases -Please refer to the [L5 Layering Specification document](./CI_5levels.md). +Please refer to the [Test System Overview](./test_system_overview.md) and [Test Writing Guide](./test_writing_guide.md). diff --git a/docs/contributing/ci/test_system_overview.md b/docs/contributing/ci/test_system_overview.md new file mode 100644 index 000000000..335ed5c53 --- /dev/null +++ b/docs/contributing/ci/test_system_overview.md @@ -0,0 +1,214 @@ +# Multi-Level Automated Testing System Overview + +## Document Overview + +This testing system aims to build a complete, efficient, and well-structured quality assurance framework for the development, integration, and release of model services. It draws on the concept of the test pyramid from modern software engineering, progressively expanding testing activities from basic code logic verification to complex end-to-end (E2E) functionality, performance, accuracy, and even long-term stability validation. + +Through five levels (L1-L5) and common (Common) specifications, the system clarifies the testing objectives, scope, execution frequency, and required resources for different development stages (e.g., each commit, PR merge, daily build, pre-release). This ensures that models meet high standards for functionality, performance, and reliability across various deployment scenarios (online serving and offline inference). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LevelScope & FocusModel Coverage StrategyFeature Coverage StrategyInterface Coverage StrategyTagsTime CostTest DirDocFrequencyHardware
CommonContribution Guideline & PR checklist//////PR Checklist//
CI Failure Description//////CI Failures//
L1
(Unit & Logic)
Unit tests for components like entrypoints, models///core_model and cpu<15min + tests/{component}/… mirroring vllm_omni/{component}/
+ (e.g. tests/diffusion/, tests/engine/, tests/entrypoints/)
+ Do not add new top-level tests/ dirs unrelated to a component. +
+ L1 & L2
+ Section 1 L1&L2: Purpose, Test Content, Directory Location, Example +
PR with ready label (also can run locally)CPU
L2
(E2E across models & GPU-required UT)
Online (basic deployment scenarios):
dummy, normal inference function (output format, stream), some instance startup UT
High-priority models + online basic scenarios; request success, non-empty output, format match (no Whisper/accuracy)High-priority features and component tests that require launching instancesHigh-priority interfaces (using random lightweight models)core_model and hardware_test(H100, L4, etc.) and omni/tts/diffusion + Model E2E:
+ tests/e2e/online_serving/test_{model}.py
+ tests/e2e/offline_inference/test_{model}.py
+ Feature integration:
+ tests/e2e/features/<feature>/
+ (e.g. fullduplex/, custom_pipeline/, rlhf_test/)
+ Component / interface Test (GPU):
+ tests/{component}/…, tests/entrypoints/… +
+ L1 & L2
+ L1&L2: Purpose, Test Content, Directory Location, Example +
PR with ready labelGPU
L3
(Important Perf & Integration & Accuracy)
Online & Offline (multiple deployment scenarios):
real model, normal inference function, normal accuracy
High/medium-priority models + key online/offline scenarios; real weights, Whisper/similarity, preset voice gender, basic accuracyMedium-priority features and component tests that require launching instancesMedium-priority interfaces (using random lightweight models)advanced_model and hardware_test(H100, L4, etc.) and omni/tts/diffusion<30min + Model E2E:
+ tests/e2e/online_serving/test_{model}.py
+ tests/e2e/offline_inference/test_{model}.py
+ tests/e2e/accuracy/
+ Feature integration:
+ tests/e2e/features/<feature>/
+ Component / interface Test:
+ tests/{component}/…, tests/entrypoints/… +
+ L3
+ L3: Purpose, Test Content, Directory Location, Example +
PR Merged (Also run L1&L2 Tests)GPU
L4
(Perf & Integration & Accuracy)
Online: full functional scenarios + performance test + doc test + accuracy testHigh-priority models: function, performance, accuracy, and doc testing
Medium-priority models: function and doc testing
Low-priority features and component tests that require launching instancesLow-priority interfaces (using real weights)full_model and hardware_test(H100, L4, etc.) and omni/tts/diffusion<3 hour + Model E2E:
+ tests/e2e/online_serving/test_{model}_expansion.py
+ tests/e2e/offline_inference/test_{model}_expansion.py
+ tests/e2e/accuracy/test_{model}.py
+ Feature integration:
+ tests/e2e/features/<feature>/
+ Component / interface Test:
+ tests/{component}/…, tests/entrypoints/…
+ Performance:
+ tests/dfx/perf/tests/
+ Doc examples:
+ tests/examples/online_serving/, tests/examples/offline_inference/ +
+ L4
+ L4: Purpose, Test Content, Directory Location, Example +
NightlyGPU
L5
(Stability & Reliability)
Online: long-term stability test + reliability testLong-term stability and reliability testing for high-priority models
Low-priority models: function and doc testing
/Invalid-parameter validation for high-priority interfacesslow and hardware_test(H100, L4, etc.) and omni/tts/diffusion Depends on reality + Stability:
+ tests/dfx/stability/tests/
+ Reliability:
+ tests/dfx/reliability/test_reliability_{model}.py +
+ L5
+ L5: Purpose, Test Content, Directory Location, Example +
Weekly / Days before ReleaseGPU
+ +**Test Dir placement (summary):** component / unit under `tests/{component}/` mirroring `vllm_omni/`; model E2E under `tests/e2e/online_serving/`, `tests/e2e/offline_inference/`, and `tests/e2e/accuracy/`; feature integration under `tests/e2e/features//`; doc example tests under `tests/examples/online_serving/` and `tests/examples/offline_inference/`; performance under `tests/dfx/perf/`; stability under `tests/dfx/stability/`; reliability under `tests/dfx/reliability/`. Do **not** add new top-level directories under `tests/` that are unrelated to a `vllm_omni` component (or to the established `e2e` / `dfx` / `helpers` / `examples` / `buildkite` layout). + +For per-level test authoring (markers, examples), see [Test Writing Guide](./test_writing_guide.md). + +## Common Specifications + +Before entering specific testing levels, the project establishes two common specifications aimed at standardizing the development process and quickly locating issues. + +1. ***PR Checklist ([`.github/PULL_REQUEST_TEMPLATE.md`](https://github.com/vllm-project/vllm-omni/blob/main/.github/PULL_REQUEST_TEMPLATE.md))***: This template defines the self-check items that must be completed before submitting a code review (Pull Request). It ensures that each code change meets basic requirements such as code style, dependency updates, and documentation synchronization before entering the automated testing pipeline, serving as the first manual line of defense for quality assurance. +2. ***CI Failure Explanation ([CI Failures](./failures.md))***: This document archives and explains common failure patterns in the Continuous Integration (CI) pipeline, error log interpretation, and preliminary troubleshooting steps. It helps developers and testers quickly diagnose the causes of automated test failures, improving problem-solving efficiency. + +## Notes + +### L2 / L3 diff-aware CI (CUDA) + +On CUDA **L2** and **L3**, **E2E Test** Buildkite jobs may be omitted at pipeline upload when the PR diff does not touch their path prefixes; other groups still always upload. Full two-layer mechanics and YAML examples: [CI Settings — Diff-aware CI](./ci_settings.md#diff-aware-ci) ([step filtering](./ci_settings.md#step-filtering)). + +### Test helper environment variables + +Some shared helpers under `tests/helpers/` honor optional environment variables for local debugging. These are **not** set in CI by default. + +| Variable | Accepted values | Description | +| -------- | --------------- | ----------- | +| `VLLM_OMNI_KEEP_REQUEST_MEDIA` | `1`, `true`, `yes` (case-insensitive) | When enabled, temporary WAV files created by `tests.helpers.media.convert_audio_bytes_to_text` are **not** deleted when the pytest process exits. By default, each call writes a unique file under the system temp directory via `tempfile.mkstemp` and registers `atexit` cleanup. Use this when debugging audio output validation (Whisper transcription, keyword checks, text–audio similarity). The saved path is logged as `audio data is saved: `. | + +Example (Linux / macOS): + +```bash +export VLLM_OMNI_KEEP_REQUEST_MEDIA=1 +pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -k test_mix_to_text_audio +``` + +Example (Windows PowerShell): + +```powershell +$env:VLLM_OMNI_KEEP_REQUEST_MEDIA = "1" +pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -k test_mix_to_text_audio +``` + +## Summary + +This multi-level testing system achieves continuous, progressive validation of model service quality by tightly integrating testing activities with the development workflow (commit, review, merge, release). From rapid unit testing to comprehensive end-to-end testing, and further to in-depth performance, stability, and reliability verification, each level has clear objectives, collectively building a robust quality protection net. By following this system, teams can deliver high-quality, highly reliable model services more efficiently. diff --git a/docs/contributing/ci/test_writing_guide.md b/docs/contributing/ci/test_writing_guide.md new file mode 100644 index 000000000..397f5e83c --- /dev/null +++ b/docs/contributing/ci/test_writing_guide.md @@ -0,0 +1,712 @@ +# Test Writing Guide + +This document describes how to author tests for each CI level (L1–L5). For the level matrix, CI triggers, and Buildkite upload behavior, see [Test System Overview](./test_system_overview.md). + +## Test Directory Structure + +Canonical placement by CI level is in the **Test Dir** column of the level matrix in [Test System Overview](./test_system_overview.md). + +Summary: + +- **Component / unit:** `tests/{component}/…` mirroring `vllm_omni/{component}/` +- **Model E2E:** `tests/e2e/online_serving/`, `tests/e2e/offline_inference/`, `tests/e2e/accuracy/` +- **Feature integration:** `tests/e2e/features//` +- **Doc example tests:** `tests/examples/online_serving/`, `tests/examples/offline_inference/` +- **Performance:** `tests/dfx/perf/tests/` (JSON configs; runners under `tests/dfx/perf/scripts/`) +- **Stability:** `tests/dfx/stability/tests/` +- **Reliability:** `tests/dfx/reliability/` +- Do **not** add new top-level `tests/` directories unrelated to a component (or to `e2e` / `dfx` / `helpers` / `examples` / `buildkite`) + +Deploy YAMLs: `vllm_omni/deploy/` via `tests.helpers.stage_config.get_deploy_config_path` (see [stage configs](../../configuration/stage_configs.md)). + +## Markers for Tests + +By adding markers before test functions, tests can later be executed uniformly by simply declaring the corresponding marker type. + +### Current Markers +Defined in `pyproject.toml`: + +| Marker | Description | +| ------------------ | --------------------------------------------------------- | +| `core_model` | L1&L2 tests (run in each PR) | +| `advanced_model` | L3 tests (run on each merge to main) | +| `full_model` | L4 tests (run nightly) | +| `diffusion` | Diffusion model tests | +| `omni` | Omni multimodal model tests | +| `tts` | TTS model tests | +| `cache` | Cache backend tests | +| `parallel` | Parallelism/distributed tests | +| `cpu` | Tests that run on CPU | +| `gpu` | Tests that run on GPU * | +| `cuda` | Tests that run on CUDA * | +| `rocm` | Tests that run on AMD/ROCm * | +| `xpu` | Tests that run on Intel XPU * | +| `npu` | Tests that run on NPU/Ascend * | +| `H100` | Tests that require H100 GPU * | +| `L4` | Tests that require L4 GPU * | +| `MI325` | Tests that require MI325 GPU (AMD/ROCm) * | +| `A2` | Tests that require A2 NPU * | +| `A3` | Tests that require A3 NPU * | +| `distributed_cuda` | Tests that require multi cards on CUDA platform * | +| `distributed_rocm` | Tests that require multi cards on ROCm platform * | +| `distributed_npu` | Tests that require multi cards on NPU platform * | +| `skipif_cuda` | Skip if the num of CUDA cards is less than the required * | +| `skipif_rocm` | Skip if the num of ROCm cards is less than the required * | +| `skipif_npu` | Skip if the num of NPU cards is less than the required * | +| `slow` | Slow tests (may skip in quick CI) | +| `benchmark` | Benchmark tests (decorator on runner test functions; perf JSON uses `full_model` + type marker instead) | +| `local_model` | Tests requiring local / non-HF-hub model weights | + +\* Means those markers are auto-added by `@hardware_test` (parametrization decorator) or `hardware_marks` (only returning the list of marks for flexibility). + +#### Example usage for markers + +```python +from tests.helpers.mark import hardware_test + +@pytest.mark.core_model +@pytest.mark.omni +@hardware_test( + res={"cuda": "L4", "rocm": "MI325", "npu": "A2"}, + num_cards=2, +) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_video_to_audio() + ... +``` + +#### Decorator: `@hardware_test` + +This decorator is intended to make hardware-aware, cross-platform test authoring easier and more robust for CI/CD environments. The `hardware_test` decorator in `vllm-omni/tests/helpers/mark.py` performs the following actions: + +1. **Applies platform and resource markers** + Adds the appropriate pytest markers for each specified hardware platform (e.g., `cuda`, `rocm`, `xpu`, `npu`) and resource type (e.g., `L4`, `H100`, `MI325`, `B60`, `A2`, `A3`). + ``` + @pytest.mark.cuda + @pytest.mark.L4 + ``` +2. **Handles multi-card (distributed) scenarios** + For tests requiring multiple cards, it automatically adds distributed markers such as `distributed_cuda`, `distributed_rocm`, or `distributed_npu`. + ``` + @pytest.mark.distributed_cuda(num_cards=num_cards) + ``` +3. **Supports flexible card requirements** + Accepts `num_cards` as either a single integer for all platforms or as a dictionary with per-platform values. If not specified, defaults to 1 card per platform. + +4. **Integrates resource validation** + On CUDA, adds a skip marker (`skipif_cuda`) if the system does not have the required number of devices. + Support for `skipif_rocm` and `skipif_npu` will be implemented later. + + +5. **Works with pytest filtering** + Allows tests to be filtered and selected at runtime using standard pytest marker expressions (e.g., `-m "distributed_cuda and L4"`). + +##### Example usage for decorator +- Single call for multiple platforms: + ```python + @hardware_test( + res={"cuda": "L4", "rocm": "MI325", "xpu": "B60", "npu": "A2"}, + num_cards={"cuda": 2, "rocm": 2, "xpu": 2, "npu": 2}, + ) + ``` + or + ```python + @hardware_test( + res={"cuda": "L4", "rocm": "MI325", "xpu": "B60", "npu": "A2"}, + num_cards=2, + ) + ``` +- `res` must be a dict; supported resources: CUDA (L4/H100), ROCm (MI325), XPU (B60), MUSA (S5000), NPU (A2/A3) +- `num_cards` can be int (all platforms) or dict (per platform); defaults to 1 when missing +- Distributed markers (`distributed_cuda`, `distributed_rocm`, `distributed_npu`) are auto-added for multi-card cases +- Filtering examples: + - CUDA only: `pytest -m "distributed_cuda and L4"` + - ROCm only: `pytest -m "distributed_rocm and MI325"` + - NPU only: `pytest -m "distributed_npu"` + +#### Function: `hardware_marks` + +`hardware_marks` returns a list of pytest mark objects with the same signature as `@hardware_test`. Use it when you need more flexibility, such as attaching hardware marks to individual `pytest.param` entries rather than an entire test function. + +```python +from tests.helpers.mark import hardware_marks + +MULTI_CARD_MARKS = hardware_marks( + res={"cuda": "H100", "rocm": "MI325", "npu": "A2"}, num_cards=2 +) + +@pytest.mark.parametrize("omni_server", [ + pytest.param(OmniServerParams(...), id="case_001", marks=MULTI_CARD_MARKS), +], indirect=True) +def test_feature(omni_server): + ... +``` + +#### JSON `mark` field (L4 perf configs) + +Perf JSON under `tests/dfx/perf/tests/` can attach pytest marks per **case** (`test_name` block). Parsed by `tests.dfx.conftest.resolve_pytest_marks` and applied to each parametrized run in `run_benchmark.py` / `run_diffusion_benchmark.py`. + +When `mark` is present, it must be an **array** with exactly one ``hardware_marks`` object (same semantics as `hardware_marks()` above), followed by registered pytest marker name strings such as `full_model`, `omni`, `tts`, or `diffusion`. + +```json +{ + "test_name": "test_cosmos3_t2i_official_demo_2gpu", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 2}}, + "full_model", + "diffusion" + ], + "server_type": "vllm-omni", + "server_params": { "...": "..." }, + "benchmark_params": [{ "name": "1024x1024_steps4", "...": "..." }] +} +``` + +- Local bulk load: `pytest -sv tests/dfx/perf/scripts/run_diffusion_benchmark.py -m "full_model and diffusion and H100"` +- Nightly CI perf steps: `--test-config-file tests/dfx/perf/tests/test__vllm_omni.json` (file selects cases; no `-m`) +- Result filenames use **runtime** GPU detection (`get_runtime_resource_label`); `H100` is omitted on the default CI pool + +See [L4 performance test examples](./test_examples/l4_performance_tests.inc.md) for the full schema. + +### Add Support for a New Platform + +If you want to add support for a new platform (e.g., "tpu" for a new accelerator), follow these steps: + +1. **Extend the marker list in your pytest config** so that platform/resource markers are defined: + ```toml + # In pyproject.toml or pytest.ini + [tool.pytest.ini_options] + markers = [ + # ... existing markers ... + "tpu: Tests that require TPU device", + "TPU_V3: Tests that require TPU v3 hardware", + "distributed_tpu: Tests that require multiple TPU devices", + ] + ``` +2. **Implement a marker construction function for your platform** in `vllm-omni/tests/helpers/mark.py`: + ```python + # In vllm-omni/tests/helpers/mark.py + + def tpu_marks(*, res: str, num_cards: int): + test_platform = pytest.mark.tpu + if res == "TPU_V3": + test_resource = pytest.mark.TPU_V3 + else: + raise ValueError( + f"Invalid TPU resource type: {res}. Supported: TPU_V3") + + if num_cards == 1: + return [test_platform, test_resource] + else: + test_distributed = pytest.mark.distributed_tpu(num_cards=num_cards) + # Optionally: add skipif_tpu when implemented + return [test_platform, test_resource, test_distributed] + ``` +3. **Update `hardware_marks` to recognize your new platform**: + In the relevant place (see the `hardware_marks` implementation), add: + ```python + if platform == "tpu": + marks = tpu_marks(res=resource, num_cards=cards) + ``` + (`hardware_test` calls `hardware_marks` internally, so both will pick up the change.) +4. **(Recommended) Add a test using your new markers**: + ```python + @hardware_test( + res={"tpu": "TPU_V3"}, + num_cards=2, + ) + def test_my_tpu_feature(): + ... + ``` + +**Summary**: +- Add pytest markers for your new platform/resources +- Implement a marker function (`xxx_marks`) +- Plug into `hardware_marks` +- You're done: tests using `@hardware_test` or `hardware_marks` with your platform now automatically get the correct markers, distribution, and isolation! + +See code in `vllm-omni/tests/helpers/mark.py` for existing examples (`cuda_marks`, `rocm_marks`, `npu_marks`). + +## Test case style + +### L1 & L2 Level Testing - Unit Testing and Basic End-to-End Verification + +#### 1.1 Testing Purpose + +L1 and L2 level testing form the foundation of the quality assurance system. L1 level testing focuses on verifying the internal logic correctness of code units (e.g., functions, classes), ensuring each independent component behaves as designed. + +L2 level testing builds upon L1 by introducing GPU resources and verifying that the end-to-end (E2E) process of the model in basic deployment scenarios is smooth. For example, it uses dummy models to confirm that core interfaces like the inference pipeline, output format, and streaming response work properly. The common goal of these two levels is to provide developers with rapid feedback, discovering and fixing issues early in the development cycle. + + + +#### 1.2 Testing Content and Scope + +- ***L1 (Unit & Logic Testing)***: +- - ***Scope***: Tests internal functions and methods of core components such as `entrypoints`, `models`. + - ***Focus***: Branch coverage, exception handling, algorithm logic correctness. Does not involve external dependencies or the complete service stack. + - ***Time Cost***: Execution time is controlled within ***15 minutes*** to ensure fast feedback. +- ***L2 (Basic End-to-End Testing)***: +- - ***Scope***: Covers two basic deployment scenarios: `online` (serving) and `offline` (inference). + - ***Focus***: Uses `dummy` weights (via deploy YAML patching at `core_model`) or lightweight real models to verify that the entire chain from request input to result output works normally, including output data structure, streaming (stream) support, and **cheap payload checks** at `--run-level core_model` (see below). Also includes some unit tests that require launching independent service instances. + - ***L2 response validation (`core_model`)***: Implemented in `tests/helpers/assertions.py` and invoked by `OpenAIClientHandler` based on `--run-level`. At L2 we require **request success** plus minimal output sanity—not full accuracy: + - **Speech / TTS** (`assert_audio_speech_response`): decoded audio must be present and non-empty (or exceed `min_audio_bytes` when set in `request_config`); `response_format` must match the returned content-type (e.g. `wav`, `pcm`). Whisper transcript similarity, PCM HNR, and preset-voice gender checks run only at L3+. + - **Diffusion** (`assert_diffusion_response`): at least one non-empty image, video, or audio artifact. Resolution/frame-count parity and other deep checks run only at L3+. + - **Omni multimodal** (`assert_omni_response`): L2 asserts successful completion; keyword, transcript, and cross-modal similarity checks run only at L3+. + - ***Characteristic***: Requires ***GPU*** resources to perform model computations. + +#### 1.3 Test Directory and Execution Files + +A clear directory structure is key to managing test cases efficiently. See the **Test Dir** column in [Test System Overview](./test_system_overview.md) for the canonical placement rules. + +- ***L1 (component / unit)***: `tests/{component_name}/…` mirroring `vllm_omni/{component_name}/` (e.g. `tests/diffusion/`, `tests/entrypoints/`). Do **not** invent new top-level `tests/` folders unrelated to a component. +- ***L2 (basic model E2E)***: + - Online serving: `tests/e2e/online_serving/test_{model_name}.py` + - Offline inference: `tests/e2e/offline_inference/test_{model_name}.py` +- ***Feature-level integration*** (cross-cutting, not a single-model smoke): `tests/e2e/features//` (e.g. `fullduplex/`, `custom_pipeline/`, `rlhf_test/`). + +#### 1.4 Execution Method and Example + +- ***Trigger Timing***: **`PR with ready label`**. That is, when a developer adds a "ready for review" or similar label to a PR on platforms like GitHub, L1 and L2 tests are automatically triggered. +- ***Diff-aware step skipping***: On L2, **E2E Test** jobs may be omitted at pipeline upload when the PR diff does not touch their [`source_file_dependencies`](ci_settings.md#step-filtering) prefixes; non-E2E groups still always upload. See [CI Settings — Diff-aware CI](ci_settings.md#diff-aware-ci). +- ***Execution Environment***: L1 uses ***CPU*** environment; L2 requires ***GPU*** environment. +- ***Script Example***: + +
+ L1 Test Examples + +Examples from `tests/model_executor/models/qwen2_5_omni/test_audio_length.py` +```python +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +def test_resolve_max_mel_frames_default(): + from vllm_omni.model_executor.models.qwen2_5_omni.audio_length import resolve_max_mel_frames + + assert resolve_max_mel_frames(None, default=30000) == 30000 + assert resolve_max_mel_frames(None, default=6000) == 6000 + + +def test_resolve_max_mel_frames_explicit(): + from vllm_omni.model_executor.models.qwen2_5_omni.audio_length import resolve_max_mel_frames + + # Explicit argument always wins over default + assert resolve_max_mel_frames(123, default=30000) == 123 + assert resolve_max_mel_frames(6000, default=30000) == 6000 + assert resolve_max_mel_frames(0, default=30000) == 0 + + +@pytest.mark.parametrize("repeats", [2, 4]) +@pytest.mark.parametrize("code_len", [0, 1, 32768]) +@pytest.mark.parametrize("max_mel_frames", [None, -1, 0, 1, 6000, 30000]) +def test_cap_and_align_mel_length_no_mismatch(repeats, code_len, max_mel_frames): + """Guard that any max_mel_frames yields a mel length aligned to repeats, and + consistent with the truncated code length (prevents concat mismatch). + """ + from vllm_omni.model_executor.models.qwen2_5_omni.audio_length import cap_and_align_mel_length + + target_code_len, target_mel_len = cap_and_align_mel_length( + code_len=code_len, + repeats=repeats, + max_mel_frames=max_mel_frames, + ) + + assert isinstance(target_code_len, int) + assert isinstance(target_mel_len, int) + + if code_len == 0: + assert target_code_len == 0 + assert target_mel_len == 0 + return + + assert target_code_len >= 1 + assert target_mel_len >= repeats + assert target_mel_len % repeats == 0 + assert target_mel_len == target_code_len * repeats + assert target_code_len <= code_len + + if max_mel_frames is not None and int(max_mel_frames) > 0 and int(max_mel_frames) >= repeats: + assert target_mel_len <= int(max_mel_frames) +``` +
+ +
+ L2 Test Examples +You can refer to Test Examples in the L3 section to see example test cases that incorporate both L2 and L3 testing logic. +
+ +- - ***Run Command***: + + `pytest -s -v /tests/e2e/online_serving/test_{model_name}.py` + `pytest -s -v -m 'core_model and cpu' --run-level=core_model` + +### L3 Level Testing - Core Integration, Performance, and Accuracy Verification + +#### 2.1 Testing Purpose + +L3 level testing executes after code is merged into the main branch. Its core purpose is to verify the integration effect, key performance indicators, and output accuracy of ***real models*** in ***multiple deployment scenarios*** + +. It acts as the "quality gatekeeper" for the main branch, ensuring that no merge breaks the core capabilities of the model service. Testing needs to provide clear conclusions within a relatively short time (<30min), balancing test depth with feedback speed. + + + +#### 2.2 Testing Content and Scope + +- ***Deployment Scenarios***: Covers richer `online` and `offline` deployment configurations, which may include different hardware configurations, batch sizes, concurrency levels, etc. +- ***Core Verification***: +- 1. ***Inference Functionality***: Ensures real models can perform forward computation normally and return results. + 2. ***Accuracy Compliance***: Verifies that the model's evaluation metrics (e.g., accuracy) meet the expected baseline, preventing code changes from introducing accuracy issues. + 3. ***Important Performance***: Verifies whether performance (e.g., P99 latency, throughput) in core scenarios meets preset thresholds. +- ***L3 response validation (`advanced_model`)***: At `--run-level advanced_model`, `tests/helpers/assertions.py` adds semantic checks on top of L2 payload gates (see L1 & L2 above): + - **Speech / TTS** (`assert_audio_speech_response`): Whisper transcript of returned audio vs `request_config["input"]` (cosine similarity > 0.9 when input text is set); optional `min_audio_bytes` floor; PCM harmonic-to-noise ratio when `response_format` is `pcm`; **preset voice gender** via `_assert_preset_voice_gender_from_audio` when `voice` matches a known preset in `_PRESET_VOICE_GENDER_MAP` (pitch/F0-based classifier; skipped for unknown voices or `pcm` output). + - **Omni multimodal** (`assert_omni_response`): non-empty text/audio outputs; `key_words` in transcript or text; text–audio similarity / containment; preset **`speaker`** gender check (same helper as TTS). + - **Diffusion** (`assert_*_diffusion_response`): image/video dimension and frame-count parity with request parameters where configured. + - **Accuracy suites**: pixel/video similarity and metric baselines under `/tests/e2e/accuracy/` (separate from inline helper assertions). + +#### 2.3 Test Directory and Execution Files + +- ***Functional / model E2E***: + - Online serving: `tests/e2e/online_serving/test_{model_name}_expansion.py` + - Offline inference: `tests/e2e/offline_inference/test_{model_name}_expansion.py` + - Accuracy / similarity: `tests/e2e/accuracy/` + - (`_expansion.py` holds broader scenario coverage than the L2 smoke file.) +- ***Feature-level integration***: `tests/e2e/features//` (not under a new top-level `tests/` directory). + +#### 2.4 Execution Method and Example + +- ***Trigger Timing***: **`PR Merged`**. Automatically triggered after code review is approved and merged into the main branch (typically via `merge-test` label on the PR before merge). +- ***Diff-aware step skipping***: Same [`source_file_dependencies`](ci_settings.md#step-filtering) mechanism as L2—**all E2E Test** leaf steps are diff-gated; other L3 groups always upload. See [CI Settings — Diff-aware CI](ci_settings.md#diff-aware-ci). +- ***Execution Environment***: ***GPU*** servers. +- ***Script Example***: + +???+ example "Test Examples" + + **2.4.1 Mark Declaration Section** + + ```python + @pytest.mark.advanced_model + @pytest.mark.core_model + @pytest.mark.parametrize("omni_server", test_params, indirect=True) + ``` + + **Explanation**: + + @pytest.mark.advanced_model: Marks the test as L3 merge level (`--run-level advanced_model`): real weights, Whisper/similarity/keyword checks, diffusion deep checks, and **preset voice/speaker gender** validation where applicable. @pytest.mark.full_model: Marks L4 nightly-only suites (e.g. `test_*_expansion.py`, doc examples). + + @pytest.mark.core_model: Marks the test as L1 or L2 level. At `--run-level core_model`, validation is limited to request success plus cheap payload checks (e.g. non-empty audio bytes, response format/content-type, non-empty diffusion outputs)—not Whisper, keyword, or accuracy gates. Deploy YAML may use `load_format: dummy` for fast PR feedback. + + @pytest.mark.parametrize: A parameterization decorator that allows abstracting test data into parameters, enabling reuse of the same test logic across different data configurations. indirect=True indicates that parameters will be passed to the fixture for processing. + + **Notes**: If you believe the test case only needs to execute basic run logic at the PR-level CI, you can mark it only with @pytest.mark.core_model. If you believe it only needs to execute deep validation at merge (L3), use @pytest.mark.advanced_model. For L4 nightly-only expansion and doc-example tests, use @pytest.mark.full_model with `--run-level full_model`. If the test case needs both basic run and deep validation, mark with @pytest.mark.core_model and the appropriate L3/L4 marker (`advanced_model` and/or `full_model`). + + **2.4.2 Test Function Definition and Documentation** + + ```python + def test_mix_to_text_audio_001(omni_server, openai_client) -> None: + """ + Test multi-modal input processing and text/audio output generation via OpenAI API. + Deploy Setting: default yaml + Input Modal: text + audio + video + image + Output Modal: text + audio + Input Setting: stream=True + Datasets: single request + """ + ``` + + **Explanation**: + + **Function Naming Convention**: Uses the test_ prefix, describes the test scenario mix_to_text_audio, and the number 001 indicates the first test case for this scenario. + + **Parameter Explanation**: + + omni_server: Omni server instance obtained via fixture, containing model information and configuration. + + openai_client: Unified OpenAI client processing instance, encapsulating request sending and response validation logic. + + Docstring: Describes the test purpose, deployment settings, input/output modalities, streaming settings, and dataset type in detail, providing clear context for test maintenance. + + **2.4.3 Multimodal Data Preparation** + + ```python + video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300)['base64']}" + image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}" + audio_data_url = f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}" + ``` + + **Explanation**: + + **Data Generation Functions**: Use the generate_synthetic_* series of functions to generate synthetic test data, avoiding reliance on external resources and ensuring test reproducibility and stability. + + **Parameter Explanation**: + + Video: width, height, duration_frames + + Image: width, height + + Audio: duration_seconds, channels + + **2.4.4 Request Configuration and Keyword Validation** + + ```python + request_config = { + "model": omni_server.model, + "messages": messages, + "stream": True, + "key_words": { + "audio": ["water", "cricket"], + "video": ["sphere", "globe", "circle", "round"], + "image": ["square", "quadrate"], + "text": ["beijing"] + }, + } + ``` + + **Explanation**: + + **Model Specification**: Uses omni_server.model to ensure the test aligns with the model configured on the server. + + **Keyword Validation Mechanism**: This is an innovative design of the template to address the specific needs of multimodal testing: + + Audio Keywords: Validate whether the generated text's description of audio content contains expected elements (e.g., "water" for water sounds, "cricket" for cricket sounds). If you provide multiple keywords, the validation is considered successful if at least one keyword is present. + + **Video Keywords**: Validate whether the generated text's description of video content contains expected elements. If you provide multiple keywords, the validation is considered successful if at least one keyword is present. + + Image Keywords: Validate whether the generated text's description of image content contains expected elements. If you provide multiple keywords, the validation is considered successful if at least one keyword is present. + + Text Keywords: Validate whether the generated text contains expected elements. If you provide multiple keywords, the validation is considered successful if at least one keyword is present. + + **2.4.5 Request Execution** + + ```python + openai_client.send_omni_request(request_config, request_num=1) # for omni-understanding models + # or + openai_client.send_diffusion_request(request_config, request_num=1) # for diffusion models + ``` + + **Explanation**: + + **Unified Client**: Uses the OpenAIClientHandler instance to send requests. This client encapsulates error handling, retry mechanisms, and response validation logic. + + **Single Request**: The comment clearly states this is a single-request completion test. For concurrent testing, it can be extended to multiple requests using request_num = n. + + **Implicit Validation**: `send_omni_request`, `send_audio_speech_request`, and `send_diffusion_request` call `assert_*_response` helpers with the session `--run-level`. At `core_model`, checks are limited to success plus cheap payload sanity (non-empty audio/media, format/content-type, optional `min_audio_bytes`). At `advanced_model` and `full_model`, deep validation adds Whisper transcripts, keyword/similarity, diffusion dimension checks, PCM HNR, etc. + + **Audio output debugging**: Deep validation may transcribe returned audio via `convert_audio_bytes_to_text` (Whisper). If an audio keyword or text–audio similarity assertion fails, set `VLLM_OMNI_KEEP_REQUEST_MEDIA=1` before running pytest to keep the intermediate WAV files for inspection (see [Test helper environment variables](test_system_overview.md#test-helper-environment-variables)). + +- ***Run Command (L3 merge)***: `pytest -s -v /tests/e2e/online_serving/test_{model_name}.py -m advanced_model --run-level=advanced_model` + +- ***Run Command (L4 nightly expansion)***: `pytest -s -v /tests/e2e/online_serving/test_{model_name}_expansion.py -m full_model --run-level=full_model` + +### L4 Level Testing - Full Functionality, Performance, and Documentation Testing + +#### 3.1 Testing Purpose + +L4 level testing is a comprehensive quality audit before a version release. It expands upon L3, executing ***full*** functional scenarios, conducting systematic ***performance stress tests***, and simultaneously verifying the correctness of accompanying ***example documentation***. Its purpose is to perform deep validation of the system during off-peak nighttime hours, providing quality trend reports for daytime development and data support for release decisions. + + + +#### 3.2 Testing Content and Scope + +- ***Full Functionality Testing***: Executes all test cases defined in `test_{model_name}_expansion.py`, covering all implemented features, positive flows, boundary conditions, and exception handling. +- ***Performance Testing***: Uses `tests/dfx/perf/tests/test_qwen3_omni_*.json` (Omni), `test_tts.json` / `test_voxcpm2.json` / `test_higgs_audio_v3.json` (TTS), and diffusion configs `tests/dfx/perf/tests/test_*_vllm_omni.json` (passed to `run_benchmark.py` or `run_diffusion_benchmark.py` via `--test-config-file` in nightly **Perf Test** steps) to drive throughput, latency, and memory benchmarks. Each JSON **case** may declare an optional top-level **`mark`** array: exactly one ``hardware_marks`` object plus pytest marker name strings (`full_model`, `omni` / `tts` / `diffusion`, …). Runners attach those marks to each parametrized `(server, benchmark index)` pair so **local** bulk runs can filter with `-m` (for example `-m "full_model and H100 and diffusion"`). Nightly CI perf jobs select workloads by **`--test-config-file`**, not `-m`. Details are in the Performance Tests example below. +- ***Documentation Testing***: Verifies whether the example code provided to users is runnable and its results match the description. + +#### 3.3 Test Directory and Execution Files + +- ***Functional Testing***: Same directories as L3. +- ***Performance Test Configuration***: `tests/dfx/perf/tests/test_qwen3_omni_*.json`, `test_tts.json`, `test_voxcpm2.json`, `test_higgs_audio_v3.json`, and diffusion configs `tests/dfx/perf/tests/test_*_vllm_omni.json` (e.g. `test_qwen_image_vllm_omni.json`, `test_cosmos3_vllm_omni.json`). Optional per-case `mark` for local `-m` filtering is documented in Section 3.4 Performance Tests. +- ***Documentation Example Tests***: + - `tests/examples/online_serving/test_{model_name}.py` + - `tests/examples/offline_inference/test_{model_name}.py` + +#### 3.4 Execution Method and Example + +- ***Trigger Timing***: **`Nightly`**, automatically executed every night. +- ***Execution Environment***: ***GPU*** server clusters to meet the resource demands of performance testing. +- ***Script Example***: + +??? example "Test Examples: Documentation Example Tests" + + --8<-- "docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md" + +??? example "Test Examples: Performance Tests" + + --8<-- "docs/contributing/ci/test_examples/l4_performance_tests.inc.md" + +??? example "Test Examples: Functionality Tests" + + --8<-- "docs/contributing/ci/test_examples/l4_functionality_tests.inc.md" + +- ***Run Command***: (Specific commands would depend on the performance testing tool and configuration defined in `nightly.json`). + +### L5 Level Testing - Stability and Reliability Testing + +#### 4.1 Testing Purpose + +L5 level testing focuses on the performance of model services under ***long-running*** and ***abnormal fault*** scenarios. It aims to uncover deep-seated issues that only manifest under sustained pressure or extreme conditions, such as memory leaks, resource contention, gradual performance degradation, and lack of fault tolerance mechanisms. This is the final, yet crucial, line of defense for ensuring service high availability and production environment robustness. + + + +#### 4.2 Testing Content and Scope + +- ***Long-term Stability (Stability) Testing***: Uses JSON under `tests/dfx/stability/tests/` (for example `test_qwen3_omni.json` and `test_wan22.json`) to run the service under moderate load for an extended period (e.g., over 12 hours), monitoring whether metrics like memory/VRAM usage, response time, and throughput degrade over time, and whether the service process remains stable. +- ***Reliability Testing***: Uses pytest suites under `tests/dfx/reliability/` to inject controlled faults against a **live** `vllm_omni serve` instance (same **`omni_server` / `omni_server_function`** fixture style as E2E). Current suites emphasize **GPU memory pressure** (CUDA sidecar “memory hog”), **worker / runtime process kill** (`SIGKILL` on `VLLM::Worker` for Qwen3-Omni, `multiprocessing.spawn` for Wan2.2 video workers, or `vLLM-Omni::` for HunyuanImage DiT workers), **large multimodal chat**, **`/v1/videos`**, **`/v1/images/generations`**, or **`/v1/audio/speech`** jobs under OOM, **`/health` → 503** and **fast-fail / non-hanging concurrent** requests after kill, and **OpenAI-style 5xx error contracts** (e.g. text vs text+audio under OOM). **Post-fault recovery** checks exist where enabled (some cases may be `skip` while issues are tracked). See the Reliability `
` block in Section 4.4 for file-level responsibilities and CI markers (`slow`, `hardware_test`, POSIX-only kill). + +#### 4.3 Test Directory and Execution Files + +- ***Stability Test Configuration***: `tests/dfx/stability/tests/test_qwen3_omni.json`, `tests/dfx/stability/tests/test_wan22.json` (one JSON per model / runner family) +- ***Reliability Test Suite*** (`tests/dfx/reliability/`): + - `test_reliability_qwen3_omni.py` — Qwen3-Omni chat / multimodal reliability (GPU OOM, process kill, recovery, error contract under `--async-chunk` vs default). + - `test_reliability_wan22.py` — Wan2.2 I2V video API reliability (`/v1/videos` under OOM and process kill, recovery). + - `test_reliability_hunyuan_image.py` — HunyuanImage-3.0-Instruct DiT-only reliability (`/v1/images/generations` under OOM and process kill; deploy `hunyuan_image3_dit.yaml`, H100 × 4). + - `test_reliability_voxcpm2.py` — VoxCPM2 TTS reliability (`/v1/audio/speech` under OOM and process kill; deploy `voxcpm2.yaml`, L4 × 1). + - `helpers.py` — Shared primitives used by current suites: raw HTTP probes for `/v1/chat/completions` and `/health`, OpenAI-style error parsing, GPU OOM sidecar (`inject_gpu_oom` / `stop_gpu_oom_hogs`), and `pgrep`-based process-kill injector construction (`make_process_kill_fault_injector`). + - `conftest.py` — `fault_injector` and `omni_server_after_fault` / `omni_server_after_fault_function` fixtures to run a callable **after** the server is ready. + - `README.md` — Short local run commands for this directory. + +#### 4.4 Execution Method and Example + +- ***Trigger Timing***: **`Weekly`** (weekly) or **`Days before Release`** (several days before a major release). Due to long execution times, the frequency is lower. +- ***Execution Environment***: ***GPU*** servers, requiring a stable and exclusive testing environment. +- ***Script Example***: +
+ Test Examples + +When you want to add L5-level stability test cases, add or extend the appropriate JSON file under `tests/dfx/stability/tests/` (for example `test_qwen3_omni.json` for Omni bench traffic, or `test_wan22.json` for diffusion `/v1/videos` workloads). The following illustrates the Qwen3-Omni shape: + +```json +{ + "test_name": "test_qwen3_omni_stability", + "server_params": { + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "stage_config_name": "qwen3_omni.yaml" + }, + "benchmark_params": [ + { + "dataset_name": "random", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "duration_sec": 43200, + "request_rate": 0.5, + "num_prompts_per_batch": 20, + "random_input_len": 2500, + "random_output_len": 900, + "ignore_eos": true, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration" + } + ] +} +``` + +##### Parameter Explanation + +***Overview*** + +| Field | Required | Description | +| ---------------- | -------- | --------------------------------------------------------------------------- | +| test_name | Yes | Unique identifier for the stability test case | +| server_params | Yes | Server-side configuration parameters (model, stage configuration, etc.) | +| benchmark_params | Yes | Stability benchmark running parameters (supports multiple configurations) | + +##### server_params Configuration + +###### Basic Parameters + +| Parameter | Required | Example | Description | +| ----------------- | -------- | ---------------------------------- | ----------------------------------- | +| model | Yes | "Qwen/Qwen3-Omni-30B-A3B-Instruct" | Model name or path | +| stage_config_name | Yes | "qwen3_omni.yaml" | Stage configuration file name | + +###### Dynamic Configuration (update/delete) + +Supports incremental modifications based on the basic configuration: + +| Operation | Description | +| --------- | ------------------------------------ | +| update | Update or add configuration items | +| delete | Delete specified configuration items | + +***Example***: +You can refer to Test Examples in L4 §3.4 + +##### benchmark_params Configuration + +For stability testing, the key parameters are: + +- **duration_sec**: Total duration (in seconds) during which benchmark traffic is sent. The stability benchmark will keep sending batches until this duration is reached. +- **request_rate** / **max_concurrency**: Exactly one of them must be specified. They control how the traffic is generated for each batch: + - `request_rate`: Number of requests per second. The benchmark will send `num_prompts_per_batch` requests at the given rate. + - `max_concurrency`: Maximum number of concurrent requests. When this is used, `request_rate` is set to `inf` internally. +- **num_prompts_per_batch**: Number of prompts sent in each batch. Multiple batches will be executed sequentially within `duration_sec`. + +All other optional parameters follow the same rules as in L4 §3.4. + +
+ +
+ Reliability test suite (tests/dfx/reliability) + +##### Purpose and relationship to stability + +Reliability tests are **short fault-injection** integration runs (L5 **(b)** in `tests/dfx/reliability/README.md`). They complement **stability** JSON-driven long runs: instead of hours of steady traffic, they **perturb** the server (GPU OOM sidecar, fatal signals on selected processes) and check **failure mode** and **latency bounds** (e.g. chat or `/v1/videos` must not hang under concurrent fault-time load). + +##### Directory layout + +| Path | Responsibility | +| ---- | -------------- | +| `helpers.py` | Shared helpers used by current reliability suites: raw `POST`/`GET` probes (`/v1/chat/completions`, `/health`), OpenAI error parsing (`extract_openai_error_contract_from_bytes`), GPU OOM sidecar lifecycle (`inject_gpu_oom`, `stop_gpu_oom_hogs`), and process-kill injector builder (`make_process_kill_fault_injector`). | +| `conftest.py` | Pytest fixtures: indirect `fault_injector`, `omni_server_after_fault` / `omni_server_after_fault_function` (run injector after server is ready, then yield server). | +| `test_reliability_qwen3_omni.py` | Qwen3-Omni: OOM vs **text vs text+audio** error contract, large multimodal chat under OOM, concurrent pressure, **SIGKILL** on `VLLM::Worker`, `/health` → 503 + fast-fail + concurrent chat; optional OOM recovery scenario (may be skipped while tracked in issues). | +| `test_reliability_wan22.py` | Wan2.2 I2V: large `/v1/videos` under OOM, **SIGKILL** on `multiprocessing.spawn` chain, health / fast-fail / concurrent video requests; optional recovery test (may be skipped). | +| `test_reliability_hunyuan_image.py` | HunyuanImage DiT-only: large `/v1/images/generations` under OOM, **SIGKILL** on `vLLM-Omni::` workers and serve/tree targets, health / fast-fail / concurrent image requests; some OOM/recovery cases may be skipped while tracked in issues. | +| `test_reliability_voxcpm2.py` | VoxCPM2: `/v1/audio/speech` under OOM (error contract), **SIGKILL** on `VLLM::` workers and serve/tree targets, health / fast-fail / concurrent speech requests; some OOM cases may be skipped while tracked in issues. | +| `README.md` | Minimal run / collect examples. | + +##### Parametrization and markers + +- Each test module defines a **`RELIABILITY_SCENARIOS`** list (`test_name`, `server_params`: model, `stage_config_name` or diffusion `server_args`, etc.). **`create_reliability_omni_server_params()`** in `tests/dfx/conftest.py` resolves stage paths (including XPU substitutions where applicable) and builds **`OmniServerParams`** lists consumed by **`@pytest.mark.parametrize(..., indirect=True)`** on `omni_server` or `omni_server_function`. +- Cases are tagged **`@pytest.mark.slow`** for weekly / selective CI. GPU-heavy suites use **`@hardware_test(res={"cuda": "H100"}, num_cards=...)`** or **`@hardware_test(res={"cuda": "L4"}, num_cards=1)`** (Qwen3-Omni **2**× H100; Wan2.2 **1**× H100; HunyuanImage DiT **4**× H100; VoxCPM2 **1**× L4). +- **Process-kill** tests use **`@pytest.mark.skipif(os.name == "nt", ...)`** because injection uses POSIX **`pgrep` / `kill`**. + +##### CI trigger + +Weekly Buildkite (`.buildkite/cuda/test-weekly.yml`) runs one step per model suite (trigger: `WEEKLY=1` or PR label `weekly-test`), for example: + +| Buildkite step | Test file | CI hardware | +| -------------- | --------- | ----------- | +| Reliability Test - qwen3-omni | `test_reliability_qwen3_omni.py` | H100 × 2 (`mithril-h100-pool`) | +| Reliability Test - wan22 | `test_reliability_wan22.py` | H100 × 2 (`mithril-h100-pool`) | +| Reliability Test - hunyuan-image | `test_reliability_hunyuan_image.py` | H100 × 4 (`mithril-h100-pool`) | +| Reliability Test - voxcpm2 | `test_reliability_voxcpm2.py` | L4 × 1 (`gpu_1_queue`) | + +```bash +pytest -s -v tests/dfx/reliability/test_reliability_qwen3_omni.py -m "slow" +pytest -s -v tests/dfx/reliability/test_reliability_wan22.py -m "slow" +pytest -s -v tests/dfx/reliability/test_reliability_hunyuan_image.py -m "slow" +pytest -s -v tests/dfx/reliability/test_reliability_voxcpm2.py -m "slow" +``` + +##### Local commands + +```bash +pytest --collect-only tests/dfx/reliability +pytest -s -v tests/dfx/reliability/test_reliability_qwen3_omni.py -m slow +pytest -s -v tests/dfx/reliability/test_reliability_wan22.py -m slow +pytest -s -v tests/dfx/reliability/test_reliability_hunyuan_image.py -m slow +pytest -s -v tests/dfx/reliability/test_reliability_voxcpm2.py -m slow +``` + +##### Adding a new model suite + +1. Add `test_reliability_.py` under `tests/dfx/reliability/`. +2. Define **`RELIABILITY_SCENARIOS`** and pass them through **`create_reliability_omni_server_params()`** with the correct deploy or e2e stage-config directory (same pattern as existing files). +3. Reuse **`helpers`** for OOM / kill / raw HTTP; prefer **`assert_fault_exception()`** and **`resolve_oom_device_spec()`** from `tests/dfx/conftest.py` for consistent device selection vs stage YAML. +4. Register **`slow`** (and **`hardware_test`** if needed); extend **`.buildkite/cuda/test-weekly.yml`** when the suite should run in weekly L5. + +
+ +- - ***Stability***: `pytest -s -v tests/dfx/stability/scripts/test_stability_qwen3_omni.py` or `pytest -s -v tests/dfx/stability/scripts/test_stability_wan22.py` (or add `test_stability_.py` alongside a matching JSON config) + - ***Reliability***: `pytest -s -v tests/dfx/reliability/test_reliability_.py -m slow` (current suites: `qwen3_omni`, `wan22`, `hunyuan_image`, `voxcpm2`; add `test_reliability_.py` and a matching step in `.buildkite/cuda/test-weekly.yml` for new models) diff --git a/docs/contributing/ci/tests_markers.md b/docs/contributing/ci/tests_markers.md deleted file mode 100644 index cc89cee47..000000000 --- a/docs/contributing/ci/tests_markers.md +++ /dev/null @@ -1,180 +0,0 @@ -# Markers for Tests - -By adding markers before test functions, tests can later be executed uniformly by simply declaring the corresponding marker type. - -## Current Markers -Defined in `pyproject.toml`: - -| Marker | Description | -| ------------------ | --------------------------------------------------------- | -| `core_model` | L1&L2 tests (run in each PR) | -| `advanced_model` | L3 tests (run on each merge to main) | -| `full_model` | L4 tests (run nightly) | -| `diffusion` | Diffusion model tests | -| `omni` | Omni multimodal model tests | -| `tts` | TTS model tests | -| `cache` | Cache backend tests | -| `parallel` | Parallelism/distributed tests | -| `cpu` | Tests that run on CPU | -| `gpu` | Tests that run on GPU * | -| `cuda` | Tests that run on CUDA * | -| `rocm` | Tests that run on AMD/ROCm * | -| `xpu` | Tests that run on Intel XPU * | -| `npu` | Tests that run on NPU/Ascend * | -| `H100` | Tests that require H100 GPU * | -| `L4` | Tests that require L4 GPU * | -| `MI325` | Tests that require MI325 GPU (AMD/ROCm) * | -| `A2` | Tests that require A2 NPU * | -| `A3` | Tests that require A3 NPU * | -| `distributed_cuda` | Tests that require multi cards on CUDA platform * | -| `distributed_rocm` | Tests that require multi cards on ROCm platform * | -| `distributed_npu` | Tests that require multi cards on NPU platform * | -| `skipif_cuda` | Skip if the num of CUDA cards is less than the required * | -| `skipif_rocm` | Skip if the num of ROCm cards is less than the required * | -| `skipif_npu` | Skip if the num of NPU cards is less than the required * | -| `slow` | Slow tests (may skip in quick CI) | -| `benchmark` | Benchmark tests | - -\* Means those markers are auto-added by `@hardware_test` (parametrization decorator) or `hardware_marks` (only returning the list of marks for flexibility). - -### Example usage for markers - -```python -from tests.helpers.mark import hardware_test - -@pytest.mark.core_model -@pytest.mark.omni -@hardware_test( - res={"cuda": "L4", "rocm": "MI325", "npu": "A2"}, - num_cards=2, -) -@pytest.mark.parametrize("omni_server", test_params, indirect=True) -def test_video_to_audio() - ... -``` - -### Decorator: `@hardware_test` - -This decorator is intended to make hardware-aware, cross-platform test authoring easier and more robust for CI/CD environments. The `hardware_test` decorator in `vllm-omni/tests/helpers/mark.py` performs the following actions: - -1. **Applies platform and resource markers** - Adds the appropriate pytest markers for each specified hardware platform (e.g., `cuda`, `rocm`, `xpu`, `npu`) and resource type (e.g., `L4`, `H100`, `MI325`, `B60`, `A2`, `A3`). - ``` - @pytest.mark.cuda - @pytest.mark.L4 - ``` -2. **Handles multi-card (distributed) scenarios** - For tests requiring multiple cards, it automatically adds distributed markers such as `distributed_cuda`, `distributed_rocm`, or `distributed_npu`. - ``` - @pytest.mark.distributed_cuda(num_cards=num_cards) - ``` -3. **Supports flexible card requirements** - Accepts `num_cards` as either a single integer for all platforms or as a dictionary with per-platform values. If not specified, defaults to 1 card per platform. - -4. **Integrates resource validation** - On CUDA, adds a skip marker (`skipif_cuda`) if the system does not have the required number of devices. - Support for `skipif_rocm` and `skipif_npu` will be implemented later. - - -5. **Works with pytest filtering** - Allows tests to be filtered and selected at runtime using standard pytest marker expressions (e.g., `-m "distributed_cuda and L4"`). - -#### Example usage for decorator -- Single call for multiple platforms: - ```python - @hardware_test( - res={"cuda": "L4", "rocm": "MI325", "xpu": "B60", "npu": "A2"}, - num_cards={"cuda": 2, "rocm": 2, "xpu": 2, "npu": 2}, - ) - ``` - or - ```python - @hardware_test( - res={"cuda": "L4", "rocm": "MI325", "xpu": "B60", "npu": "A2"}, - num_cards=2, - ) - ``` -- `res` must be a dict; supported resources: CUDA (L4/H100), ROCm (MI325), NPU (A2/A3) -- `num_cards` can be int (all platforms) or dict (per platform); defaults to 1 when missing -- Distributed markers (`distributed_cuda`, `distributed_rocm`, `distributed_npu`) are auto-added for multi-card cases -- Filtering examples: - - CUDA only: `pytest -m "distributed_cuda and L4"` - - ROCm only: `pytest -m "distributed_rocm and MI325"` - - NPU only: `pytest -m "distributed_npu"` - -### Function: `hardware_marks` - -`hardware_marks` returns a list of pytest mark objects with the same signature as `@hardware_test`. Use it when you need more flexibility, such as attaching hardware marks to individual `pytest.param` entries rather than an entire test function. - -```python -from tests.helpers.mark import hardware_marks - -MULTI_CARD_MARKS = hardware_marks( - res={"cuda": "H100", "rocm": "MI325", "npu": "A2"}, num_cards=2 -) - -@pytest.mark.parametrize("omni_server", [ - pytest.param(OmniServerParams(...), id="case_001", marks=MULTI_CARD_MARKS), -], indirect=True) -def test_feature(omni_server): - ... -``` - -## Add Support for a New Platform - -If you want to add support for a new platform (e.g., "tpu" for a new accelerator), follow these steps: - -1. **Extend the marker list in your pytest config** so that platform/resource markers are defined: - ```toml - # In pyproject.toml or pytest.ini - [tool.pytest.ini_options] - markers = [ - # ... existing markers ... - "tpu: Tests that require TPU device", - "TPU_V3: Tests that require TPU v3 hardware", - "distributed_tpu: Tests that require multiple TPU devices", - ] - ``` -2. **Implement a marker construction function for your platform** in `vllm-omni/tests/helpers/mark.py`: - ```python - # In vllm-omni/tests/helpers/mark.py - - def tpu_marks(*, res: str, num_cards: int): - test_platform = pytest.mark.tpu - if res == "TPU_V3": - test_resource = pytest.mark.TPU_V3 - else: - raise ValueError( - f"Invalid TPU resource type: {res}. Supported: TPU_V3") - - if num_cards == 1: - return [test_platform, test_resource] - else: - test_distributed = pytest.mark.distributed_tpu(num_cards=num_cards) - # Optionally: add skipif_tpu when implemented - return [test_platform, test_resource, test_distributed] - ``` -3. **Update `hardware_marks` to recognize your new platform**: - In the relevant place (see the `hardware_marks` implementation), add: - ```python - if platform == "tpu": - marks = tpu_marks(res=resource, num_cards=cards) - ``` - (`hardware_test` calls `hardware_marks` internally, so both will pick up the change.) -4. **(Recommended) Add a test using your new markers**: - ```python - @hardware_test( - res={"tpu": "TPU_V3"}, - num_cards=2, - ) - def test_my_tpu_feature(): - ... - ``` - -**Summary**: -- Add pytest markers for your new platform/resources -- Implement a marker function (`xxx_marks`) -- Plug into `hardware_marks` -- You're done: tests using `@hardware_test` or `hardware_marks` with your platform now automatically get the correct markers, distribution, and isolation! - -See code in `vllm-omni/tests/helpers/mark.py` for existing examples (`cuda_marks`, `rocm_marks`, `npu_marks`). diff --git a/docs/contributing/ci/tests_style.md b/docs/contributing/ci/tests_style.md deleted file mode 100644 index 420a8821d..000000000 --- a/docs/contributing/ci/tests_style.md +++ /dev/null @@ -1,466 +0,0 @@ -# Test File Structure and Style Guide - -To ensure project maintainability and sustainable development, we encourage contributors to submit test code (unit tests, system tests, or end-to-end tests) alongside their code changes. This document outlines the guidelines for organizing and naming test files. - -## Checklist before submitting your test files - -1. The file is saved in an appropriate place and the file name is clear. -2. The coding style follows the requirements outlined below. -3. All test functions have appropriate pytest markers. -4. For tests that need run in CI, please ensure it labeled as ``@pytest.mark.core_model` the test is configured under the `./buildkite/` folder. - - -## Test Types - -For more details about our [Five Levels Tests design](../ci/CI_5levels.md). - -### Unit Tests and System Tests -For unit tests and system tests, we strongly recommend placing test files in the same directory structure as the source code being tested, using the naming convention `test_*.py`. - -### End-to-End (E2E) Tests for Models -End-to-end tests verify the complete functionality of a system or component. For our project, the E2E tests for different omni models are organized into two subdirectories: - -- **`tests/e2e/offline_inference/`**: Tests for offline inference modes (e.g., Qwen3Omni offline inference) - -- **`tests/e2e/online_serving/`**: Tests for online serving scenarios (e.g., API server tests) - -## Test Directory Structure - -The ideal directory structure mirrors the source code organization. Legend: `✅` = test exists, `⬜` = suggested to add. - -``` -vllm_omni/ tests/ -├── config/ → ├── config/ -│ ├── model.py │ └── test_model.py ⬜ -│ └── lora.py │ └── test_lora.py ⬜ -│ -├── core/ → ├── core/ -│ └── sched/ │ └── sched/ -│ ├── omni_ar_scheduler.py │ ├── test_omni_ar_scheduler.py ⬜ -│ ├── omni_generation_scheduler.py │ ├── test_omni_generation_scheduler.py ⬜ -│ └── output.py │ └── test_output.py ✅ currently in entrypoints/test_omni_new_request_data.py (tests output.OmniNewRequestData) -│ -├── diffusion/ → ├── diffusion/ -│ ├── diffusion_engine.py │ ├── test_diffusion_engine.py ⬜ -│ ├── attention/ │ ├── attention/ -│ │ ├── layer.py │ │ ├── test_attention_sp.py ✅ -│ │ └── backends/ │ │ └── test_flash_attn.py ✅ -│ ├── distributed/ │ ├── distributed/ -│ │ └── ... │ │ ├── test_comm.py ✅ -│ │ │ │ ├── test_cfg_parallel.py ✅ -│ │ │ │ └── test_sp_plan_hooks.py ✅ -│ ├── lora/ │ ├── lora/ -│ │ └── ... │ │ ├── test_base_linear.py ✅ -│ │ │ │ └── test_lora_manager.py ✅ -│ ├── models/ │ ├── models/ -│ │ ├── qwen_image/ │ │ ├── qwen_image/ (e2e coverage) -│ │ ├── ovis_image/ │ │ ├── ovis_image/ -│ │ │ └── ... │ │ │ └── test_ovis_image.py ✅ -│ │ ├── z_image/ │ │ └── z_image/ -│ │ └── ... │ │ └── test_zimage_tp_constraints.py ✅ -│ └── worker/ │ └── worker/ -│ ├── diffusion_worker.py │ └── test_diffusion_worker.py ✅ file at diffusion/test_diffusion_worker.py -│ └── diffusion_model_runner.py │ -│ -├── distributed/ → ├── distributed/ -│ └── omni_connectors/ │ └── omni_connectors/ -│ ├── adapter.py │ ├── test_adapter_and_flow.py ✅ -│ ├── kv_transfer_manager.py │ ├── test_basic_connectors.py ✅ -│ ├── connectors/ │ ├── test_kv_flow.py ✅ -│ └── utils/ │ └── test_omni_connector_configs.py ✅ -│ -├── engine/ → ├── engine/ -│ ├── input_processor.py │ ├── test_input_processor.py ⬜ (no processor.py in source) -│ ├── output_processor.py │ └── test_output_processor.py ⬜ -│ └── arg_utils.py │ └── test_arg_utils.py ⬜ -│ -├── entrypoints/ → ├── entrypoints/ -│ ├── stage_utils.py │ ├── test_stage_utils.py ✅ -│ ├── cli/ │ ├── cli/ (benchmarks/test_serve_cli.py covers CLI serve) -│ │ └── ... │ │ └── test_*.py ⬜ -│ └── openai/ │ └── openai_api/ # maps to entrypoints/openai/ -│ ├── api_server.py │ ├── test_api_server.py ⬜ (e2e indirect coverage) -│ ├── serving_chat.py │ ├── test_serving_chat_sampling_params.py ✅ -│ ├── serving_speech.py │ ├── test_serving_speech.py ✅ -│ └── image_api_utils.py │ └── test_image_server.py ✅ -│ -├── inputs/ → ├── inputs/ -│ ├── data.py │ ├── test_data.py ⬜ -│ ├── parse.py │ ├── test_parse.py ⬜ -│ └── preprocess.py │ └── test_preprocess.py ✅ currently in entrypoints/test_omni_input_preprocessor.py -│ -├── model_executor/ → ├── model_executor/ -│ ├── layers/ │ ├── layers/ -│ │ └── mrope.py │ │ └── test_mrope.py ⬜ -│ ├── model_loader/ │ ├── model_loader/ -│ │ └── weight_utils.py │ │ └── test_weight_utils.py ⬜ -│ ├── models/ │ ├── models/ -│ │ ├── qwen2_5_omni/ │ │ ├── qwen2_5_omni/ -│ │ │ ├── qwen2_5_omni_thinker.py │ │ │ ├── test_audio_length.py ✅ -│ │ │ ├── qwen2_5_omni_talker.py │ │ │ ├── test_qwen2_5_omni_thinker.py ⬜ -│ │ │ └── qwen2_5_omni_token2wav.py │ │ │ ├── test_qwen2_5_omni_talker.py ⬜ -│ │ └── qwen3_omni/ │ │ │ └── test_qwen2_5_omni_token2wav.py ⬜ -│ │ └── ... │ │ └── qwen3_omni/ -│ ├── stage_configs/ │ │ └── test_*.py ⬜ -│ │ └── *.yaml │ └── stage_configs/ (used by e2e, test_*.py can be added) ⬜ -│ └── stage_input_processors/ │ └── stage_input_processors/ -│ └── ... │ └── test_*.py ⬜ -│ -├── sample/ → ├── sample/ -│ └── __init__.py │ └── test_*.py ⬜ -│ -├── utils/ → ├── utils/ -│ └── __init__.py │ └── test_*.py ⬜ (no platform_utils.py currently) -│ -├── worker/ → ├── worker/ -│ ├── gpu_ar_model_runner.py │ ├── test_gpu_ar_model_runner.py ⬜ -│ ├── gpu_ar_worker.py │ ├── test_gpu_ar_worker.py ⬜ -│ ├── gpu_generation_model_runner.py │ ├── test_gpu_generation_model_runner.py ✅ -│ ├── gpu_generation_worker.py │ ├── test_gpu_generation_worker.py ⬜ -│ ├── gpu_model_runner.py │ ├── test_omni_gpu_model_runner.py ✅ -│ └── mixins.py │ └── (npu under platforms/npu/worker/) # not worker/npu/ -│ -├── platforms/ → (no tests/platforms/, e2e and stage_configs provide indirect coverage) -│ ├── cuda/ -│ ├── npu/worker/ # NPU worker here, not vllm_omni/worker/npu/ -│ ├── rocm/ -│ └── xpu/worker/ -│ -├── outputs.py → test_outputs.py ✅ (at tests root) -├── (logger, patch, request, version) → (no corresponding unit test) -│ -└── e2e (tests side only) → ├── e2e/ - ├── online_serving/ ✅ non-empty - │ ├── test_qwen2_5_omni.py - │ ├── test_async_omni.py - │ ├── test_qwen3_omni.py - │ ├── test_qwen3_omni_expansion.py - │ ├── test_mimo_audio.py - │ └── test_images_generations_lora.py - └── offline_inference/ ✅ - ├── test_qwen2_5_omni.py - ├── test_qwen3_omni.py - ├── test_bagel_text2img.py - ├── test_z_image.py - ├── test_wan22.py - ├── test_zimage_tensor_parallel.py - ├── test_cache_dit.py - ├── test_teacache.py - ├── test_stable_audio_expansion.py - ├── test_diffusion_cpu_offload.py - ├── test_diffusion_layerwise_offload.py - ├── test_diffusion_lora.py - ├── test_sequence_parallel.py - ├── test_qwen_image_edit_expansion.py - └── stage_configs/ (legacy schema, still present - ├── bagel_*.yaml for unmigrated models) - └── npu/, rocm/, etc. - -# Migrated models (qwen3_omni_moe, qwen2_5_omni, qwen3_tts) live under -# vllm_omni/deploy/ instead — see docs/configuration/stage_configs.md. -examples/ tests -│ └── examples -├── online_serving/ → ├── online_serving/ -│ └── {doc_page_title}/README.md │ └── test_{doc_page_title}.py ⬜ -└── offline_inference/ → └── offline_inference/ - └── {doc_page_title}/README.md └── test_{doc_page_title}.py ⬜ -``` - - - -### Naming Conventions - -- **Unit Tests**: Use `test_.py` format. Example: `stage_utils.py` → `test_stage_utils.py` - -- **E2E Tests**: Place in `tests/e2e/offline_inference/` or `tests/e2e/online_serving/` with descriptive names. Example: `tests/e2e/offline_inference/test_qwen3_omni.py`, `tests/e2e/offline_inference/test_diffusion_model.py` - -- **Expansion Tests** - -### Best Practices - -1. **Mirror Source Structure**: Test directories should mirror the source code structure -2. **Test Type Indicators**: Use comments to indicate test types (UT for unit tests, E2E for end-to-end tests) -3. **Shared Resources**: Place shared test configurations (e.g., CI configs) in appropriate subdirectories -4. **Consistent Naming**: Follow the `test_*.py` naming convention consistently across all test files - - -## Test codes requirements - -### Coding style - -1. **File header**: Add SPDX license header to all test files -2. **Imports**: Pls don't use manual `sys.path` modifications, use standard imports instead. -3. **Test type differentiation**: - - - Unit tests: Maintain mock style - - E2E tests for models: Consider using OmniRunner uniformly, avoid decorators - -4. **Documentation**: Add docstrings to all test functions -5. **Environment variables**: Set uniformly in `conftest.py` or at the top of files -6. **Type annotations**: Add type annotations to all test function parameters -7. **Pytest Markers**: Add necessary markers like `@pytest.mark.core_model` and use `@hardware_test` to declare hardware requirements (check detailed in [Markers for Tests](../ci/tests_markers.md)). - -### Template -#### E2E - Online serving - -E2E Online tests for Qwen3-Omni model with mix input and audio+text output. Based on `tests/e2e/online_serving/test_qwen3_omni.py`. - -```python -""" -E2E Online tests for Qwen3-Omni model with mix input and audio+text output. -""" - -import os - -os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" - -import threading -from pathlib import Path - -import openai -import pytest - -from tests.helpers.media import ( - convert_audio_bytes_to_text, - cosine_similarity_text, - generate_synthetic_video, -) -from tests.helpers.runtime import OmniServer, dummy_messages_from_mix_data -from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config -from vllm_omni.platforms import current_omni_platform - -# Edit: model name and stage config path -models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"] - -#If you use the default configuration file, you can directly use the following address. -def get_default_config(): - return get_deploy_config_path("ci/qwen3_omni_moe.yaml") - -#If you need to modify the configuration file, you can use modify_stage_config. -def get_chunk_config(): - path = modify_stage_config( - get_default_config(), - updates={ - "async_chunk": True, - "stage_args": { - 0: { - "engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk" - }, - 1: { - "engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_async_chunk" - }, - }, - }, - deletes={"stage_args": {2: ["custom_process_input_func"]}}, - ) - return path - -stage_configs = [get_default_config(), CHUNK_CONFIG_PATH] - -test_params = [(model, stage_config) for model in models for stage_config in stage_configs] - - -#Please use this method to launch the online instance. -_omni_server_lock = threading.Lock() - -@pytest.fixture(scope="module") -def omni_server(request): - """Start vLLM-Omni server as a subprocess. Use module scope for multi-stage init (10-20+ min).""" - with _omni_server_lock: - model, stage_config_path = request.param - with OmniServer( - model, - ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "120"], - ) as server: - yield server - - -@pytest.fixture -def client(omni_server): - """OpenAI client for the running vLLM-Omni server.""" - return openai.OpenAI( - base_url=f"http://{omni_server.host}:{omni_server.port}/v1", - api_key="EMPTY", - ) - -#Please use function definitions above the test function to define the prompts and other parameters you need. -def get_system_prompt(): - return { - "role": "system", - "content": [ - { - "type": "text", - "text": ( - "You are Qwen, a virtual human developed by the Qwen Team, " - "Alibaba Group, capable of perceiving auditory and visual inputs, " - "as well as generating text and speech." - ), - } - ], - } - -... - -#Please define test case tags according to the instructions in the marker documentation. -@pytest.mark.core_model -@pytest.mark.omni -@pytest.mark.parametrize("omni_server", test_params, indirect=True) -def test_mix_to_text_audio_001(client: openai.OpenAI, omni_server, request) -> None: - # PLEASE FOLLOW THESE TEMPLATE INSTRUCTIONS: - # ============================================================================ - # TEMPLATE USAGE GUIDE: - # 1. Copy this entire function as a starting point for multi-modal tests - # 2. Update the test name to reflect your specific test scenario - # 3. Modify input/output modalities as needed (see OPTIONS section below) - # 4. Adjust assertions based on your expected outcomes - # 5. Add custom validation logic for your specific use case - # ============================================================================ - - #Please list the relevant test points. - """ - Test multi-modal input processing and text/audio output generation via OpenAI API. - Deploy Setting: default yaml - Input Modal: text + audio + video + image - Output Modal: text + audio - Input Setting: stream=True - Datasets: single request - """ - # SECTION 1: TEST SETUP AND INITIALIZATION - # ========================================= - # INSTRUCTIONS: Initialize test variables and prepare test environment - # MODIFY: Add any additional test setup required for your scenario - e2e_list = list() - # SECTION 2: TEST DATA GENERATION - # ================================ - # INSTRUCTIONS: Generate or load test data for each input modality - # MODIFY: Replace synthetic generators with your actual data sources - # VIDEO DATA - Generate synthetic video for testing - # FORMAT: data:video/mp4;base64,{base64_encoded_video} - # PARAMETERS: width, height, duration_frames - video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300)['base64']}" - # IMAGE DATA - Generate synthetic image for testing - # FORMAT: data:image/jpeg;base64,{base64_encoded_image} - # PARAMETERS: width, height - image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(224, 224)['base64']}" - # AUDIO DATA - Generate synthetic audio for testing - # FORMAT: data:audio/wav;base64,{base64_encoded_audio} - # PARAMETERS: duration_seconds, channels - audio_data_url = f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}" - - # SECTION 3: MESSAGE CONSTRUCTION - # ================================ - # INSTRUCTIONS: Assemble the complete message payload for API request - # MODIFY: Add/remove modalities or change prompt structure as needed - - # USAGE: Construct a message containing all input modalities - # IMPORTANT: Ensure the message structure matches OpenAI API expectations - # CUSTOMIZATION POINTS: - # - system_prompt: Controls the assistant's behavior - # - content_text: The user's text prompt/question - # - *_data_url: URLs for media content (video/image/audio) - messages = dummy_messages_from_mix_data( - system_prompt=get_system_prompt(), - video_data_url=video_data_url, - image_data_url=image_data_url, - audio_data_url=audio_data_url, - content_text=get_prompt("mix"), - ) - - # SECTION 4: API REQUEST EXECUTION - # ================================= - # INSTRUCTIONS: Make the API call and measure performance - # MODIFY: Add timeout, retry logic, or additional parameters - start_time = time.perf_counter() - chat_completion = client.chat.completions.create(model=omni_server.model, messages=messages, stream=True) - - #Call using your preferred method and obtain the final audio and text outputs. - ... - - # SECTION 5: OUTPUT VALIDATION - # ============================= - # INSTRUCTIONS: Verify that outputs meet expected criteria - # MODIFY: Adjust validation logic for your specific requirements - - # ASSERTION 1: E2E Validation - # PURPOSE: Verify that the E2E latency is less than the baseline. - current_e2e = time.perf_counter() - start_time - print(f"the request e2e is: {current_e2e}") - e2e_list.append(current_e2e) - - print(f"the avg e2e is: {sum(e2e_list) / len(e2e_list)}") - - - - # ASSERTION 2: Text Output Validation - # PURPOSE: Verify that text output was generated with keyword content - assert text_content is not None and len(text_content) >= 2, "No text output is generated" - assert any( - keyword in text_content.lower() for keyword in ["square", "quadrate", "sphere", "globe", "circle", "round"] - ), "The output does not contain any of the keywords." - - - # ASSERTION 3: Cross-Modal Consistency - # PURPOSE: Verify text and audio outputs convey the same information - # CUSTOMIZATION: Adjust similarity threshold (0.9) based on accuracy requirements - assert audio_data is not None, "No audio output is generated" - audio_content = convert_audio_bytes_to_text(audio_data) - print(f"text content is: {text_content}") - print(f"audio content is: {audio_content}") - similarity = cosine_similarity_text(audio_content.lower(), text_content.lower()) - print(f"similarity is: {similarity}") - assert similarity > 0.9, "The audio content is not same as the text" -``` - - -#### E2E - Offline inference -```python -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -""" -Offline E2E smoke test for an omni model (video → audio). -""" - -import os -from pathlib import Path - -import pytest -from vllm.assets.video import VideoAsset - -from tests.helpers.mark import hardware_test -from ..multi_stages.conftest import OmniRunner - -# Optional: set process start method for workers -os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" - -models = ["{your model name}"] #Edit here to load your model -stage_configs = [str(Path(__file__).parent / "stage_configs" / {your model yaml})] #Edit here to load your model yaml - -# Create parameter combinations for model and stage config -test_params = [(model, stage_config) for model in models for stage_config in stage_configs] - -# function name: test_{input_modality}_to_{output_modality} -# modality candidate: text, image, audio, video, mixed_modalities -@pytest.mark.core_model -@pytest.mark.omni -@hardware_test( - res={"cuda": "L4", "rocm": "MI325", "npu": "A2"}, - num_cards=2, -) -@pytest.mark.parametrize("test_config", test_params) -def test_video_to_audio(omni_runner: type[OmniRunner], model: str) -> None: - """Offline inference: video input, audio output.""" - model, stage_config_path = test_config - with omni_runner(model, seed=42, stage_configs_path=stage_config_path) as runner: - # Prepare inputs - video = VideoAsset(name="sample", num_frames=4).np_ndarrays - - outputs = runner.generate_multimodal( - prompts="Describe this video briefly.", - videos=video, - ) - - # Minimal assertions: got outputs and at least one audio result - assert outputs - has_audio = any(o.final_output_type == "audio" for o in outputs) - assert has_audio -``` diff --git a/docs/contributing/model/adding_diffusion_model.md b/docs/contributing/model/adding_diffusion_model.md index b38805112..678797652 100644 --- a/docs/contributing/model/adding_diffusion_model.md +++ b/docs/contributing/model/adding_diffusion_model.md @@ -653,7 +653,7 @@ python end2end.py --model your-org/your-model-name --modality text2img --prompts Mirror BAGEL’s online serving setup: - Server launcher: `examples/online_serving/your_model_name/run_server.sh` - - Wrap `vllm serve ... --omni --port ...` (and `--stage-configs-path ...` if needed) + - Wrap `vllm serve ... --omni --port ...` (and `--deploy-config ...` if needed) - Client: `examples/online_serving/your_model_name/openai_chat_client.py` - Send requests to `POST /v1/chat/completions` - Support multimodal inputs (e.g., base64 image) if your model needs it @@ -685,7 +685,7 @@ For a fair comparison, keep the same **prompt**, **seed**, **resolution**, **num To ensure project maintainability and sustainable development, please submit test code (unit tests, system tests, or end-to-end tests) alongside their code changes. -For comprehensive testing guidelines and the definition of test levels (L1-L5), please refer to the [Multi-Level Automated Testing System Documentation](../ci/CI_5levels.md). You are at least required to add an L4 *functionality* test described in that document. +For the definition of test levels (L1-L5), see [Test System Overview](../ci/test_system_overview.md). You are at least required to add an L4 *functionality* test as described in [Test Writing Guide](../ci/test_writing_guide.md#l4-level-testing-full-functionality-performance-and-documentation-testing). --- @@ -755,7 +755,8 @@ omni = Omni(model="your-model", ulysses_degree=2, ring_degree=2) ### Step Execution -See detailed design guide: [How to add step execution support](../../design/feature/diffusion_step_execution.md) +See the detailed +[Diffusion Continuous Batching design guide](../../design/feature/diffusion_continuous_batching.md). Use this only when your pipeline can be split into stable request-scoped and step-scoped phases. The reference implementation is @@ -769,9 +770,8 @@ step-scoped phases. The reference implementation is Do not enable `step_execution=True` until those four methods are implemented and validated against the request-level path. -If you want the pipeline to work with the experimental batched step-wise path -(`max_num_seqs > 1`), also see: -[Continuous Batching for Step-Wise Diffusion](../../design/feature/diffusion_continuous_batching.md). +The same design guide covers the experimental batched step-wise path used when +`max_num_seqs > 1`. If you expose this in example scripts or recipes, keep it opt-in. Surface runtime features like `step_execution` as optional flags instead of silently @@ -824,7 +824,7 @@ omni = Omni(model="your-model", ### CPU Offload -See detailed guide: [CPU Offloading for Diffusion Models](../../user_guide/diffusion/cpu_offload_diffusion.md) +See detailed guide: [CPU Offloading for Diffusion Models](../../user_guide/diffusion/cpu_offload.md) vLLM-Omni provides two offloading strategies to reduce GPU memory usage: diff --git a/docs/contributing/model/adding_omni_model.md b/docs/contributing/model/adding_omni_model.md index c20e94eda..4698caf9c 100644 --- a/docs/contributing/model/adding_omni_model.md +++ b/docs/contributing/model/adding_omni_model.md @@ -34,6 +34,7 @@ vllm_omni/model_executor/models/ └── your_model_name/ # Model directory (e.g., qwen3_omni) ├── __init__.py # Exports main model class ├── your_model.py # Main unified model class + ├── pipeline.py # Pipeline topology registry entry ├── your_model_stage1_implementation.py # Stage 1 implementation (e.g., thinker) ├── your_model_stage2_implementation.py # Stage 2 implementation (e.g., talker) └── your_model_stage3_implementation.py # Stage 3 implementation (e.g., code2wav) @@ -42,10 +43,14 @@ vllm_omni/model_executor/models/ vllm_omni/model_executor/stage_input_processors/ └── your_model_name.py # Stage transition processors -vllm_omni/model_executor/stage_configs/ -└── your_model_name.yaml # Stage configuration file +vllm_omni/deploy/ +└── your_model_name.yaml # Deployment configuration file ``` +New in-tree models should register their `PipelineConfig` in +`vllm_omni/config/pipeline_registry.py`; use `--deploy-config` for custom +deployment YAMLs. + ## Step-by-Step Implementation ### Step 1: Create the Model Directory @@ -535,7 +540,7 @@ Do **not** add a no-suffix `thinker2talker` when a `sync_process_input_func` is ## Testing -For comprehensive testing guidelines, please refer to the [Test File Structure and Style Guide](../ci/tests_style.md). +For comprehensive testing guidelines, please refer to the [Test Writing Guide](../ci/test_writing_guide.md). ## Adding a Model Recipe diff --git a/docs/contributing/model/adding_tts_model.md b/docs/contributing/model/adding_tts_model.md index 3e5ae30df..6b70fdb1e 100644 --- a/docs/contributing/model/adding_tts_model.md +++ b/docs/contributing/model/adding_tts_model.md @@ -175,15 +175,16 @@ vllm_omni/model_executor/models/ your_model_name/ __init__.py your_model.py # Unified class (stage dispatch) + pipeline.py # Pipeline topology registry entry your_model_ar_stage.py # Stage 0: AR stage your_model_decoder.py # Stage 1: audio decoder vllm_omni/model_executor/stage_input_processors/ your_model_name.py # Stage 0 -> Stage 1 transition -vllm_omni/model_executor/stage_configs/ - your_model_name.yaml # Batch mode config - your_model_name_async_chunk.yaml # Streaming mode config +vllm_omni/deploy/ + your_model_name.yaml # Batch/deploy config + your_model_name_async_chunk.yaml # Streaming/deploy config ``` ### Example placement @@ -445,6 +446,12 @@ Key configuration fields: | `final_output` | Whether this stage produces the final user-facing output | | `final_output_type` | Type of final output (`audio`, `text`, etc.) | +!!! note + New in-tree models should define frozen topology in `models//pipeline.py`, + register it in `vllm_omni/config/pipeline_registry.py`, and put deployment + knobs in `vllm_omni/deploy/.yaml`. Use `--deploy-config` to load a + custom deployment YAML. + ### Batch mode ```yaml @@ -629,7 +636,7 @@ Key points: ## Testing -For general testing conventions, see [tests_style.md](../ci/tests_style.md). +For general testing conventions, see [Test Writing Guide](../ci/test_writing_guide.md). Recommended test cases for a new TTS model: @@ -771,8 +778,9 @@ directly from the model's own generator. vllm_omni/model_executor/models/your_model_name/ __init__.py modeling_your_model_name.py # unified class: load_weights + forward + streaming + pipeline.py # single-stage pipeline topology -vllm_omni/model_executor/stage_configs/your_model_name.yaml +vllm_omni/deploy/your_model_name.yaml ``` No stage input processor is needed. diff --git a/docs/design/ar_diffusion_pipeline_capability.md b/docs/design/ar_diffusion_pipeline_capability.md new file mode 100644 index 000000000..c9e05c8e8 --- /dev/null +++ b/docs/design/ar_diffusion_pipeline_capability.md @@ -0,0 +1,165 @@ +# AR-Diffusion pipeline capability + +> **Status:** experimental and non-normative. This note documents the current +> `experimental/ar_diffusion` contract. It follows the ownership and lifecycle +> direction in [#5137](https://github.com/vllm-project/vllm-omni/issues/5137), +> but it is not yet part of the stable module-design hierarchy. + +`ARDiffusionEngine` is an opt-in diffusion backend for pipelines that keep +autoregressive attention KV across requests. The engine only selects +`ARDiffusionModelRunner`; the runner owns paged KV pools, session lookup, LRU +eviction, and failure cleanup. + +A pipeline opts in by implementing `SupportsARDiffusionPipeline`. Its immutable +`ARDiffusionKVCacheSpec` reports worker-local KV geometry and policy: + +- self-attention layers, TP-local KV heads, head size, and tokens per latent frame; +- latent frames committed by one model block, sliding window, and attention sink; +- logical KV branches mapped to worker-local storage slots; +- named fixed-length cross-attention KV, model-specific scratch geometry, and + the pipeline's requested session-capacity upper bound. + +The runner binds an `ARDiffusionKVState` only around one `forward()` call. The +pipeline may use it during that context but must not retain it. The session +survives subsequent requests with the same `session_id`. Request reset, explicit +close, LRU eviction, and forward exceptions all release runner-owned KV and call +the pipeline's reset/close lifecycle hook. Cross-attention KV is allocated lazily +per session and logical KV branch, and is released on the same path. A pipeline +publishes one named cache transactionally by yielding exactly one `(k, v)` pair +per layer: + +```python +state.populate_cross_attention("main", "text", project_text_kv_by_layer()) +``` + +The cache becomes readable only after the iterable completes successfully. +Logical branches remain independent even when they share a worker-local +`local_index`; that index only maps lazily executed self-attention and scratch +work to worker-local capacity slots. It does not define cross-attention cache +ownership. + +Requests select the session with `extra_args["session_id"]`; setting `reset=True` +replaces it before the forward, while `close_session=True` releases it after a +successful forward. The runner also exposes `reset_session()` and +`close_session()` for an owning serving layer. + +## Current execution limits + +The experimental runner supports request-mode execution with +`max_num_seqs=1`. It rejects step execution and request-batch execution at load +time, and its inherited batch/step entry points also fail explicitly. Supporting +those paths requires a batch-aware state-binding contract rather than silently +bypassing `bind_ar_diffusion_state()`. + +The current implementation retains one session per model instance. A request +for a different `session_id` closes the resident session through the same LRU +lifecycle used by explicit close and failure cleanup before allocating the new +one. Self-attention and cross-attention memory are therefore sized for one +resident session even if the pipeline declares a larger `session_capacity`. +This prioritizes rollout throughput and prevents block-pool exhaustion before a +count-based eviction can run. Multi-session residency can be added later with a +memory-backed capacity calculation or allocation-pressure eviction. + +Scratch storage is also capability-driven. `frames_per_block` reserves space for +an uncommitted current video block, while `max_scratch_tokens_per_branch` +declares the maximum model-specific tokens, such as action/state registers, that +must coexist with it. `AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH` may increase +that derived minimum for deployment experiments but cannot reduce it. + +## DreamZero adapter + +DreamZero implements the capability directly on `DreamZeroPipeline`. Its adapter +reads DreamZero transformer geometry, describes the positive and negative CFG +branches, binds the state with a context manager, and translates the existing +boolean CFG calls to the explicit `"positive"` and `"negative"` KV branch names. +Its optional `ar_diffusion_warmup_requests()` provider owns the robot observation +schema and the one-frame/four-frame warmup convention; none of those details are +present in the generic runner. + +Code migrating from the former experimental KV state API should use the +mechanical mapping below: + +```python +# Before: state.pos, state.neg, state.get_kv_caches(is_negative, ...) +positive = state.adapter("positive") +negative = state.adapter("negative") +contexts = state.get_kv_caches("negative", seq_len=frame_tokens, commit_current=True) +``` + +Direct experimental `ARDiffusionKVCache` construction also migrates +mechanically: replace `local_branches` with explicit `kv_branches`, +`num_frame_per_block` with `frames_per_block`, and the fixed text/image length +arguments with a named `cross_attention_lengths` mapping plus +`session_capacity`. Normal engine users do not construct this object directly. + +Cross-attention writers migrate from incremental publication: + +```python +# Before +for layer_index, (key, value) in enumerate(project_text_kv_by_layer()): + state.write_cross_attention_kv("text", layer_index, branch, key, value) +state.mark_cross_attention_populated(branch, "text") + +# After +state.populate_cross_attention(branch, "text", project_text_kv_by_layer()) +``` + +The iterable is consumed once and must yield exactly `num_layers` pairs. Failed +or incomplete projection leaves the previous complete cache unchanged, or +leaves the cache absent on its first population. + +## Single-KV-branch pipeline sketch + +A future single-branch causal DMD pipeline can opt in without changes to the +runner or KV core: + +```python +class WorldPipeline: + def ar_diffusion_kv_cache_spec(self): + return ARDiffusionKVCacheSpec( + num_layers=self.transformer.num_layers, + num_kv_heads=self.transformer.tp_local_kv_heads, + head_size=self.transformer.head_size, + tokens_per_frame=self.transformer.tokens_per_latent_frame, + frames_per_block=3, + window_frames=self.transformer.sliding_window_frames, + sink_frames=self.transformer.sink_frames, + kv_branches=(ARDiffusionKVBranchSpec("main", 0),), + cross_attention=(ARDiffusionCrossAttentionKVSpec("text", self.text_length),), + max_scratch_tokens_per_branch=self.transformer.max_register_tokens, + session_capacity=1, + ) + + @contextmanager + def bind_ar_diffusion_state(self, session_id, state): + self.current_ar_state = state + try: + yield + finally: + self.current_ar_state = None + + def reset_ar_diffusion_session(self, session_id): + self.model_states.pop(session_id, None) + + def close_ar_diffusion_session(self, session_id): + self.model_states.pop(session_id, None) +``` + +The model writes/reads named cross-attention KV through the bound state and uses +the `"main"` KV branch for paged self-attention. Pipelines without a warmup +provider are loaded normally and skip AR rollout warmup. + +## Relationship to the multi-stage KV manager RFC + +[#5244](https://github.com/vllm-project/vllm-omni/issues/5244) describes a +future multi-stage KV transfer manager, connector integration, and cache-aware +scheduling. This capability has a narrower scope: it owns model-local paged KV +and session lifecycle inside one AR-Diffusion runner. It does not define +cross-stage transfer, connector behavior, or scheduler-visible cache affinity. + +When the experimental runtime migrates toward #5244, the implementation should +reconcile its current full `KVCacheManager` use with the RFC's proposed +`BlockPool`-oriented DiT manager. The pipeline capability should remain the +model-facing geometry and state-binding boundary where possible, while the +experimental `ARDiffusionKVCache` class itself should not be treated as a stable +public API. diff --git a/docs/design/feature/async_diffusion_output.md b/docs/design/feature/async_diffusion_output.md new file mode 100644 index 000000000..a81ed6811 --- /dev/null +++ b/docs/design/feature/async_diffusion_output.md @@ -0,0 +1,174 @@ +# Async Diffusion Output + +## Table of Contents + +1. [Overview](#overview) +2. [Performance](#performance) +3. [Architecture](#architecture) +4. [Model Coverage](#model-coverage) +5. [Configuration](#configuration) +6. [Related Files](#related-files) + +## Overview + +The async diffusion output feature moves the D2H (Device→Host) copy and SHM packing from the Worker main thread to a background daemon thread, so the GPU default stream can immediately start the next request's forward pass. This eliminates the GPU bubble caused by synchronous D2H/packing. + +**Core benefit**: the previous request's output D2H/packing overlaps with the next request's forward pass. + +**Automatically enabled** when `step_execution=False` (default, request-mode). No extra configuration needed. When `step_execution=True` (step-mode), the original synchronous path is used — pump thread and Worker background thread are not started. + +## Performance + +### HunyuanImage-3.0 (TP4) + +| Resolution | Async Off (QPS) | Async On (QPS) | Change | +|---|---|---|---| +| 1024×1024 | 0.4773 | 0.4802 | +0.60% | +| 768×768 | 0.8370 | 0.8533 | +1.95% | + +**Analysis**: smaller resolution → larger QPS gain. D2H/packing is relatively fixed overhead; forward is variable (scales with resolution). Smaller images → shorter forward → D2H/packing occupies a larger fraction of total time → more benefit from overlapping it. + +## Architecture + +### Execution Timeline + +``` +Before (synchronous D2H): +[forward req1] [D2H+SHM pack req1] [forward req2] [D2H+SHM pack req2] + ^^^^^^^^^^^^^^^^ GPU bubble + +After (async D2H): +[forward req1] [forward req2] +[D2H+SHM pack req1 (side stream)] [D2H+SHM pack req2 (side stream)] + ^^^^^^^^^^^^^^^^ bubble eliminated +``` + +### Data Flow + +``` + ┌─────────────────────────────────────────────────────┐ + │ Worker Process │ + │ │ + execute_model ──> │ WorkerBusyLoop ──> forward() ──> DiffusionOutput │ + │ │ │ (GPU tensor)│ + │ │ ┌─────────┴──────────┐ │ + │ │ │ async output path │ │ + │ │ │ │ │ + │ │ compute_done ──> result_mq │ │ + │ │ │ │ │ + │ │ ▼ │ │ + │ │ AsyncOutputThread │ │ + │ │ (side CUDA stream) │ │ + │ │ wait_event(gpu_event) │ │ + │ │ pack_diffusion_output_shm() │ │ + │ │ D2H + SHM write │ │ + │ │ │ │ │ + │ │ output_ready ──> result_mq │ │ + │ │ │ │ │ + │ ▼ ▼ │ │ + │ (dequeue next request) │ │ + └─────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────┐ + │ Executor (main process) │ + │ │ + │ ResultPumpThread (sole reader of result_mq) │ + │ │ │ + │ ├── AsyncDiffusionOutput(COMPUTE_DONE) │ + │ │ → resolve _rpc_futures[rpc_id] │ + │ │ → RunnerOutput(async_output_id=...) │ + │ │ │ + │ ├── AsyncDiffusionOutput(OUTPUT_READY) │ + │ │ → resolve _output_futures[async_output_id]│ + │ │ → or batch split via _batch_split_map │ + │ │ │ + │ └── Non-async message │ + │ → _sync_result_buffer (for other RPCs) │ + │ │ + │ wait_output_ready(async_output_id) → Future │ + └─────────────────────────────────────────────────────┘ +``` + +### Key Components + +1. **AsyncDiffusionOutput** (`data.py`): Protocol envelope for `result_mq`. `kind` field routes messages: + - `COMPUTE_DONE` — forward finished, GPU can start next request + - `OUTPUT_READY` — D2H/SHM packing finished, final output available + - `RPC_RESULT` — ordinary RPC return (including error propagation) + +2. **Worker AsyncOutputThread** (`diffusion_worker.py`): Background daemon thread that: + - Waits for `gpu_event` (cross-stream sync ensuring forward wrote the tensor) + - Runs `pack_diffusion_output_shm()` on a side CUDA stream + - Enqueues `OUTPUT_READY` to `result_mq` when packing completes + +3. **ResultPumpThread** (`multiproc_executor.py`): Sole reader of `result_mq` in request-mode. Dispatches messages: + - `COMPUTE_DONE` / `RPC_RESULT` → resolves `_rpc_futures[rpc_id]` + - `OUTPUT_READY` → resolves `_output_futures[async_output_id]` (with batch split via `_batch_split_map`) + - Non-async messages → `_sync_result_buffer` for other RPCs + +4. **collective_rpc() two-path dispatch** (`multiproc_executor.py`): + - **Path 1**: `execute_model` / `execute_model_batch` → generates `rpc_id`, registers Future, waits for pump to deliver `compute_done` + - **Path 2**: All other RPCs → `_sync_result_buffer` (pump-fed) or `_result_mq` (step-mode, no pump) + +5. **record_device_event** (`platforms/`): Cross-platform GPU event recording for side-stream synchronization. Implemented for CUDA, NPU, ROCm, XPU, MUSA; base class returns `None` (safe no-op). + +6. **IPC side-stream D2H** (`ipc.py`): `pack_diffusion_output_shm()` and helpers accept `d2h_stream` parameter; use `pin_memory` + `copy_(non_blocking=True)` on the side stream instead of synchronous `.cpu()`. + +### Batch Split + +When `execute_model_batch` returns a `COMPUTE_DONE` with a single `async_output_id` for the entire batch, the executor splits it into per-request `async_output_id`s (format `{batch_id}/{request_id}`) via `_batch_split_map`. When `OUTPUT_READY` arrives, the pump extracts per-request results from the batch output and resolves each request's output future independently. + +## Model Coverage + +### Currently Supported (request-mode, `step_execution=False`) + +All models running in request-mode (`step_execution=False`, the default) automatically use async output. + +**Verified models**: + +| Model | Type | `supports_request_batch` | +|---|---|---| +| **HunyuanImage-3.0** | Image | `False` | +| **Qwen-Image** | Image | `True` | + +Other models with `step_execution=False` are also supported but not yet verified. + +### Not Yet Supported (step-mode, `step_execution=True`) + +When `step_execution=True` (or `streaming_output=True`, which auto-enables step-mode), models use `execute_stepwise` instead of `execute_model`, which is not in the async Path 1 whitelist. Async output is not applicable. + +**Verified**: Helios (`step_execution=True`, does not benefit from this feature). + +Other models with `step_execution=True` are also not applicable. + +Adapting step-mode requires additional design because: + +- Each step's `RunnerOutput` must be available synchronously for the scheduler to advance +- Intermediate latent tensors may not need D2H (only the final step needs D2H + postprocess) +- Steps are serial (step N+1 depends on step N output), so the overlap benefit is limited to "final step's D2H overlaps with the next request's forward" + +**Planned approach**: first cover only the final step's async D2H, keep intermediate steps synchronous. This matches the request-mode benefit pattern. + +## Configuration + +No configuration needed. Async output is automatically enabled when `step_execution=False` (default). + +| `step_execution` | Mode | Async Output | Pump Thread | Worker BG Thread | +|---|---|---|---|---| +| `False` (default) | request-mode | ✅ enabled | ✅ started | ✅ started | +| `True` | step-mode | ❌ disabled | ❌ not started | ❌ not started | + +## Related Files + +- `vllm_omni/diffusion/data.py`: `AsyncDiffusionOutput`, `AsyncOutputKind`, `DiffusionOutput.async_output_id` +- `vllm_omni/diffusion/worker/diffusion_worker.py`: `WorkerProc._return_result()`, `_async_output_loop()`, `_generate_async_output_id()` +- `vllm_omni/diffusion/executor/multiproc_executor.py`: `ResultPumpThread`, `collective_rpc()` two-path dispatch, `wait_output_ready()`, `_batch_split_map` +- `vllm_omni/diffusion/diffusion_engine.py`: `step()` / `step_streaming()` / `add_req_and_wait_for_response()` async output waiting +- `vllm_omni/diffusion/sched/request_scheduler.py`: `update_from_output()` — `async_output_id` → `FINISHED_COMPLETED` +- `vllm_omni/diffusion/ipc.py`: `pack_diffusion_output_shm()` side-stream D2H path +- `vllm_omni/platforms/interface.py`: `OmniPlatform.record_device_event()` base class +- `vllm_omni/platforms/cuda/platform.py`: `CudaOmniPlatform.record_device_event()` +- `vllm_omni/platforms/npu/platform.py`: `NPUOmniPlatform.record_device_event()` +- `vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py`: `get_hunyuan_image3_post_process_func()`, postprocess removal +- `vllm_omni/diffusion/registry.py`: `_DIFFUSION_POST_PROCESS_FUNCS` postprocess registry diff --git a/docs/design/feature/cache_dit.md b/docs/design/feature/cache_dit.md index 237a95877..38b597ca3 100644 --- a/docs/design/feature/cache_dit.md +++ b/docs/design/feature/cache_dit.md @@ -32,10 +32,13 @@ The library supports three main caching strategies: vLLM-omni integrates cache-dit through the `CacheDiTBackend` class, which provides a unified interface for managing cache-dit acceleration on diffusion models. +Code outside `vllm_omni/diffusion/cache` must import Cache-DiT symbols from the +package-level API: `vllm_omni.diffusion.cache.cachedit`. + | Method/Class | Purpose | Behavior | |--------------|---------|----------| -| [`CacheDiTBackend`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/#vllm_omni.diffusion.cache.CacheBackend) | Unified backend interface | Automatically handles enabler selection and cache refresh | -| [`enable_cache_for_dit()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/cache_dit_backend/#vllm_omni.diffusion.cache.cache_dit_backend.enable_cache_for_dit) | Apply caching to transformer | Configures DBCache on transformer blocks | +| `CacheDiTBackend` | Unified backend interface | Automatically handles enabler selection and cache refresh | +| `enable_cache_for_dit()` | Apply caching to transformer | Configures DBCache on transformer blocks | **Key APIs from Cache-DiT:** @@ -47,7 +50,7 @@ vLLM-omni integrates cache-dit through the `CacheDiTBackend` class, which provid | `ForwardPattern` | Defines block forward signature patterns: `Pattern_0`, `Pattern_1`, `Pattern_2` | | `ParamsModifier` | Per-transformer or per-block-list cache configuration customization | | `DBCacheConfig` | Configuration for DBCache parameters (warmup steps, cached steps, thresholds) | -| `refresh_context()` | Update cache context | Called when `num_inference_steps` changes | +| `refresh_context()` | Update cache context | Called at every generation boundary | --- @@ -179,7 +182,8 @@ cache_dit.enable_cache( ### Registering Custom Implementations -After writing your custom enabler, register it in `CUSTOM_DIT_ENABLERS` in `vllm_omni/diffusion/cache/cache_dit_backend.py`: +After writing your custom enabler, register it in `CUSTOM_DIT_ENABLERS` in +`vllm_omni/diffusion/cache/cachedit/model_specific.py`: ```python CUSTOM_DIT_ENABLERS = { @@ -266,10 +270,10 @@ Complete examples in the codebase: | Model | Path | Pattern | Notes | |-------|------|---------|-------| -| **Standard DiT** | [`cache_dit_backend.py::enable_cache_for_dit`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/cache_dit_backend/#vllm_omni.diffusion.cache.cache_dit_backend.enable_cache_for_dit) | Default enabler | Single transformer, automatic | -| **Wan2.2** | [`cache_dit_backend.py::enable_cache_for_wan22`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/cache_dit_backend/#vllm_omni.diffusion.cache.cache_dit_backend.enable_cache_for_wan22) | Single or dual-transformer | Auto-detects mode based on transformer_2 presence | -| **LongCat** | [`cache_dit_backend.py::enable_cache_for_longcat_image`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/cache_dit_backend/#vllm_omni.diffusion.cache.cache_dit_backend.enable_cache_for_longcat_image) | Multi-block-list | Two block lists in one transformer | -| **BAGEL** | [`cache_dit_backend.py::enable_cache_for_bagel`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/cache/cache_dit_backend/#vllm_omni.diffusion.cache.cache_dit_backend.enable_cache_for_bagel) | Omni model | Complex architecture | +| **Standard DiT** | `cachedit.backend::enable_cache_for_dit` | Default enabler | Single transformer, automatic | +| **Wan2.2** | `cachedit.model_specific::enable_cache_for_wan22` | Single or dual-transformer | Auto-detects mode based on transformer_2 presence | +| **LongCat** | `cachedit.config::CacheDiTAdapterConfig` | Declarative block adapter | Two block lists in one transformer | +| **BAGEL** | `cachedit.model_specific::BagelCachedAdapter` | Custom cached adapter | Complex architecture | --- diff --git a/docs/design/feature/diffusion_continuous_batching.md b/docs/design/feature/diffusion_continuous_batching.md index f90969369..2ec89b9d1 100644 --- a/docs/design/feature/diffusion_continuous_batching.md +++ b/docs/design/feature/diffusion_continuous_batching.md @@ -1,163 +1,232 @@ -# Continuous Batching for Step-Wise Diffusion +# Diffusion Continuous Batching -!!! warning "Experimental Feature" - This feature is experimental. It currently applies only to native - diffusion pipelines running with `step_execution=True`. - -This document describes the batching extension built on top of -[Diffusion Step Execution](diffusion_step_execution.md). The base -step-execution contract is unchanged. The batching work is mainly in the -scheduler and runner layers. - -## Why It Helps - -Step-wise execution breaks a long denoise loop into scheduler-visible units. -That gives the runtime a place to admit other compatible requests between -steps instead of waiting for an entire request to finish. - -This matters most in low-MFU or bursty serving scenarios: - -- one request's denoise step may not fully saturate the GPU -- several compatible requests can share the same denoise forward pass -- throughput and device utilization can improve without changing request-local - scheduler state - -This is **not** a guaranteed single-request latency win. The main benefit is -usually higher utilization and better throughput when the workload contains -multiple in-flight compatible requests. +This document describes the unified diffusion execution architecture, including +request execution, request-level batching, step execution, continuous batching, +and output delivery. For user-facing configuration and CLI examples, see +[Diffusion Execution Modes](../../user_guide/diffusion/execution_modes.md). ## Overview -With continuous batching enabled: +`DiffusionEngine` selects an execution mode from `step_execution` and uses +`max_num_seqs` as that mode's scheduler capacity: -- the scheduler may keep multiple compatible requests active at the same time -- the runner packs request-local step state into one `InputBatch` -- `denoise_step()` runs on that batch -- `step_scheduler()` and `post_decode()` still run per request +| Configuration | Engine mode | Scheduler | Execution | +|---|---|---|---| +| `step_execution=False`, `max_num_seqs=1` | `REQUEST_BATCH` | `RequestScheduler` | One complete request-level `forward()` | +| `step_execution=False`, `max_num_seqs>1` | `REQUEST_BATCH` | `RequestScheduler` | One fused `forward()` over compatible requests | +| `step_execution=True`, `max_num_seqs=1` | `STEP_BATCH` | `StepScheduler` | One request advanced one denoise step per scheduler tick | +| `step_execution=True`, `max_num_seqs>1` | `STEP_BATCH` | `StepScheduler` | Compatible requests advanced together in step waves | -The current implementation is conservative: +Serial request execution is handled inside `REQUEST_BATCH`; it is not a +separate engine mode. Similarly, continuous batching is the multi-request +configuration of `STEP_BATCH`, not an independent execution mode. -- only compatible requests are batched together -- per-request progress and completion remain independent +The engine performs the following initialization: -Here, "continuous batching" means the step-wise path enabled by -`step_execution=True`. Request-mode `DiffusionRequestBatch` is static -request-level batching for one full pipeline `forward()` call; it does not -admit or remove requests between denoise steps. +1. Resolve model-specific pre- and post-processing hooks. +2. Select `REQUEST_BATCH` or `STEP_BATCH`. +3. Construct the configured `DiffusionExecutor`. +4. Construct `RequestScheduler` or `StepScheduler`, unless a scheduler was + explicitly injected. +5. Bind `execute_batch` or `execute_step`. -## Enablement +`DiffusionEngine.make_engine()` owns startup warmup after engine construction. +This keeps construction, backend selection, and warmup failure cleanup in one +lifecycle. -Use `--step-execution` as the feature gate, then increase `--max-num-seqs` -above `1` if you want batching: +## Unified Output Stream -```bash -vllm serve Qwen/Qwen-Image --omni \ - --port 8091 \ - --step-execution \ - --max-num-seqs 8 +All asynchronous diffusion requests use the same per-request output stream, +regardless of whether the caller exposes streaming results to the user. + +```python +async for output in engine.step_streaming(request): + ... ``` -`--max-num-seqs 1` keeps the step-wise path without enabling batching. +When a request is admitted, the engine creates an +`asyncio.Queue[DiffusionOutput]` keyed by its request ID. The busy loop sends +both intermediate chunks and final results through that queue: -For a reproducible replay flow using the bundled serving benchmark, see the -Qwen-Image replay commands in -[`benchmarks/diffusion/README.md`](gh-file:benchmarks/diffusion/README.md) -and -[`benchmarks/diffusion/performance_dashboard/qwen_image_serving_performance.md`](gh-file:benchmarks/diffusion/performance_dashboard/qwen_image_serving_performance.md). +```text +request + -> scheduler.add_request() + -> scheduler.schedule() + -> execute_batch() or execute_step() + -> scheduler.update_from_output() + -> per-request output queue + -> step_streaming() +``` -## Scheduler +Streaming callers forward every yielded output. Non-streaming callers consume +the same generator to completion and return only its final output. This avoids +maintaining separate future-based and queue-based result paths. -The scheduler derives its batch capacity from `max_num_seqs` through -`max_num_running_reqs`. +`DiffusionEngine.step()` and +`async_add_req_and_wait_for_response()` remain deprecated compatibility +wrappers. New integrations should consume `step_streaming()` or +`async_add_req_and_stream_response()`. -Batch admission is gated by -[`SamplingParamsKey`](gh-file:vllm_omni/diffusion/sched/interface.py), -which is built from shape-sensitive and CFG-sensitive sampling fields. This is -the core correctness rule for batching: requests are only co-batched when they -share the same denoise tensor contract. +### Completion and Cancellation -There are three important details: +Output delivery and scheduler lifecycle are related but separate: -- `num_inference_steps` is not part of the key, so requests with different - total step counts can still share a batch -- requests also do not need to be at the same current denoise progress; active - requests can continue batching even when their current step indices diverge -- admission is still FIFO, so an incompatible request at the head of the - waiting queue blocks later compatible requests +- A terminal scheduler result always finalizes request state. +- A disconnected or cancelled consumer removes only its delivery queue. +- The scheduler may still finish and clean up the request after its queue has + been removed. +- Engine shutdown sends an error output to all remaining streams. -Today that compatibility rule is still shape-sensitive. `height`, `width`, -`num_frames`, and CFG-related fields remain part of the key, so different -resolutions or incompatible guidance settings do **not** co-batch yet. The -key also covers LoRA identity (`lora_int_id`, `lora_scale`), so requests -targeting different adapters or scales run in separate batches and the -worker can activate exactly one adapter per step. +This separation prevents cancelled consumers from leaving active request IDs in +the scheduler. -The scheduler batching unit is one logical `OmniDiffusionRequest`. In the -step-wise path, runtime tensor batching is represented as `StepInputBatch`. For -request-mode prompt semantics, see -[Request-Level Batching](../../user_guide/diffusion/request_batching.md). +## Request-Batch Execution -## Runner +Request-batch execution runs the complete diffusion pipeline for each scheduler +wave. Each `OmniDiffusionRequest` remains an independent logical request with +its own prompt, sampling parameters, seed, request ID, output, error, and abort +state. -The runner keeps persistent per-request execution state in -[`DiffusionRequestState`](gh-file:vllm_omni/diffusion/worker/utils.py), -while the scheduler owns a separate lightweight request state for queueing and -lifecycle tracking. +With `max_num_seqs=1`, `execute_batch()` handles the serial request path. With a +larger value, a pipeline can opt into fused execution by declaring: -For each step, the runner builds an -[`InputBatch`](gh-file:vllm_omni/diffusion/worker/input_batch.py) from the -active request states: +```python +supports_request_batch = True +``` -- prompt embeddings and masks are normalized and padded -- dynamic tensors such as `latents` and `timesteps` are gathered each step -- buffers are reused when batch composition stays the same +Its `forward()` method must accept `DiffusionRequestBatch` and return one +`DiffusionOutput` per request. The runner validates the result count and maps +outputs back to their original request IDs with `BatchRunnerOutput`. -The step-wise batched path is: +Current in-tree request-batch implementations include the Flux, LTX-2, SD3, and +Qwen-Image pipelines. Treat the pipeline capability flag in the source as +authoritative because support continues to expand. -1. Run `prepare_encode()` for newly admitted requests. +### Request-Batch Data Flow + +- `DiffusionSchedulerOutput` carries scheduled request IDs and payloads. +- `DiffusionRequestBatch` provides the pipeline-facing static batch. +- `BatchRunnerOutput` carries per-request results back to the engine. + +Request-local setup, such as seed handling and output/error mapping, remains +per request. Cache refresh, homogeneous LoRA activation, and +`pipeline.forward(req_batch)` can be shared by the batch. + +### Compatibility and Admission + +`RequestBatchSamplingParamsKey` contains fields that affect the tensor contract, +including shape, guidance, output count, and LoRA identity. Only compatible +requests can share a fused forward call. + +Admission is FIFO and conservative. An incompatible request at the front of the +waiting queue can prevent later compatible requests from joining the current +wave. + +`request_batch_max_wait_ms` optionally delays the first schedule operation of a +new wave so bursty arrivals can accumulate. It applies only when: + +- the pipeline supports request batching; +- step execution is disabled; +- no requests are currently running; and +- the configured wait is greater than zero. + +The wait ends when the queue reaches capacity, stabilizes briefly, reaches its +deadline, or the engine stops. + +## Step-Batch Execution + +Step execution exposes denoising progress to the scheduler. A supporting +pipeline implements four stateful operations: + +| Operation | Responsibility | +|---|---| +| `prepare_encode(state)` | Validate input, encode prompts, initialize latents and timesteps, and create request-local scheduler state | +| `denoise_step(input_batch, *, states=...)` | Run one denoise forward for the scheduler-provided request states | +| `step_scheduler(state, noise_pred)` | Update latents and advance request progress | +| `post_decode(state)` | Decode and postprocess a completed request or output boundary | + +Persistent request state lives in `StepRequestState`. Pipeline-specific fields +that do not belong in the shared contract should be stored in `state.extra`. +Queueing and lifecycle metadata remain in the scheduler's request state. + +Current native pipelines that explicitly enable step execution include +Qwen-Image, HunyuanImage3, and Helios. Step execution alone does not imply +continuous-batching support: Qwen-Image accepts batched step states. +HunyuanImage3 accepts batched step states only when its resolved self-attention +backend is `TORCH_SDPA`; otherwise it rejects groups larger than one request. +Configure `DIFFUSION_ATTENTION_BACKEND=TORCH_SDPA` or +`diffusion_attention_config.default.backend=TORCH_SDPA` when +`max_num_seqs>1`. See the +[HunyuanImage-3.0 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Tencent/HunyuanImage-3.0-Instruct.md) +for its validated configuration. Helios supports only a single active step +request and must use `max_num_seqs=1`. + +### Continuous Batching + +When `max_num_seqs>1`, `StepScheduler` can keep multiple compatible requests +active. The runner gathers their state into `InputBatch`, performs one batched +denoise forward, then applies scheduler updates per request: + +1. Receive transferred KV payloads, then run `prepare_encode()` for newly + admitted requests. 2. Build or refresh `InputBatch`. -3. Run one batched `denoise_step(input_batch)`. -4. Slice the batched `noise_pred` back per request. -5. Run per-request `step_scheduler()`. -6. Run `post_decode()` only for requests that finished denoising. -7. Scatter updated latents back into persistent request state with - [`scatter_latents()`](gh-file:vllm_omni/diffusion/worker/input_batch.py). +3. Run one batched `denoise_step(input_batch, states=states)`. +4. Slice noise predictions back to each request. +5. Run each request's `step_scheduler()`. +6. Run `post_decode()` at a chunk boundary or request completion. +7. Scatter updated latents back into persistent request state. -This keeps the shared work limited to the denoise forward pass while preserving -request-local scheduler state and outputs. +`StepBatchSamplingParamsKey` protects the batched tensor contract. Requests can +have different total step counts and current step indices, but shape-sensitive, +CFG-sensitive, and LoRA fields must be compatible. FIFO head-of-line blocking +also applies here. -## Engine +Chunk-capable pipelines may emit intermediate results. Final-only step +pipelines emit when the request finishes even if `streaming_output=True`. -[`DiffusionEngine`](gh-file:vllm_omni/diffusion/diffusion_engine.py) provides -the background loop and async add-request path needed for multiple requests to -accumulate in the scheduler. +## Model Author Guidelines -When `step_execution=True`, the engine routes work through the step-wise -executor path. The continuous batching behavior is defined by scheduler-side -compatibility gating and runner-side `StepInputBatch` packing. +When converting a request-level pipeline: -## Current Limitations +- Reuse the same helpers as `forward()` to avoid behavior drift. +- Copy request-scoped scheduler state into `state.scheduler`. +- Advance `state.step_index` only in `step_scheduler()`. +- Keep model forward work in `denoise_step()`. +- Keep latent mutation in `step_scheduler()`. +- Keep final decoding equivalent to the tail of `forward()`. +- Store masks, condition latents, and other request-local tensors in the + request state rather than on the shared pipeline. -- Experimental feature; use `max_num_seqs=1` for the older conservative path. -- Only native pipelines that already support `step_execution=True`. -- Only homogeneous batches keyed by `SamplingParamsKey` are supported. -- `cache_backend`, KV transfer, and other request-mode extras are not wired - into the batched step-wise path yet. -- Future work can relax the current same-shape restriction with richer - heterogeneous batching policies such as bucketing or padded execution for - different resolutions. +Before setting `supports_step_execution = True`, validate output parity for the +same seed and sampling parameters, scheduler isolation across concurrent +requests, abort cleanup, progress reporting, and all supported CFG paths. -## Related Files +Before setting `supports_request_batch = True`, validate the batch input +contract, one-output-per-request result shape, request identity and error +mapping, seeded concurrency, LoRA compatibility, and tensor IPC. -- Scheduler base: - [`vllm_omni/diffusion/sched/base_scheduler.py`](gh-file:vllm_omni/diffusion/sched/base_scheduler.py) -- Scheduler interface: - [`vllm_omni/diffusion/sched/interface.py`](gh-file:vllm_omni/diffusion/sched/interface.py) -- Step scheduler: - [`vllm_omni/diffusion/sched/step_scheduler.py`](gh-file:vllm_omni/diffusion/sched/step_scheduler.py) -- Runner: - [`vllm_omni/diffusion/worker/diffusion_model_runner.py`](gh-file:vllm_omni/diffusion/worker/diffusion_model_runner.py) -- Input batch: - [`vllm_omni/diffusion/worker/input_batch.py`](gh-file:vllm_omni/diffusion/worker/input_batch.py) -- Tests: - [`tests/diffusion/test_diffusion_scheduler.py`](gh-file:tests/diffusion/test_diffusion_scheduler.py) +## Limitations + +- Request batching requires an explicit pipeline capability when + `max_num_seqs>1`. +- Step execution requires the four-method stateful pipeline contract. +- Both batching paths currently require homogeneous compatibility keys. +- FIFO scheduling can reduce batching opportunities. +- `request_batch_max_wait_ms` trades first-request latency for burst + coalescing. +- All diffusion cache backends are currently unsupported in step mode. +- KV transfer is supported for newly admitted step requests; some other + request-mode extras remain unsupported. +- Step continuous batching remains experimental; use `max_num_seqs=1` for the + conservative step path. + +## Related Implementation + +- Engine: [`vllm_omni/diffusion/diffusion_engine.py`](gh-file:vllm_omni/diffusion/diffusion_engine.py) +- Model contract: [`vllm_omni/diffusion/models/interface.py`](gh-file:vllm_omni/diffusion/models/interface.py) +- Scheduler interface: [`vllm_omni/diffusion/sched/interface.py`](gh-file:vllm_omni/diffusion/sched/interface.py) +- Request scheduler: [`vllm_omni/diffusion/sched/request_scheduler.py`](gh-file:vllm_omni/diffusion/sched/request_scheduler.py) +- Step scheduler: [`vllm_omni/diffusion/sched/step_scheduler.py`](gh-file:vllm_omni/diffusion/sched/step_scheduler.py) +- Runner: [`vllm_omni/diffusion/worker/diffusion_model_runner.py`](gh-file:vllm_omni/diffusion/worker/diffusion_model_runner.py) +- Request batch: [`vllm_omni/diffusion/worker/request_batch.py`](gh-file:vllm_omni/diffusion/worker/request_batch.py) +- Step input batch: [`vllm_omni/diffusion/worker/input_batch.py`](gh-file:vllm_omni/diffusion/worker/input_batch.py) diff --git a/docs/design/feature/diffusion_request_level_batching.md b/docs/design/feature/diffusion_request_level_batching.md deleted file mode 100644 index 57287ada4..000000000 --- a/docs/design/feature/diffusion_request_level_batching.md +++ /dev/null @@ -1,175 +0,0 @@ -# Request-Level Batching for Diffusion - -This document describes the request-mode batching path for diffusion pipelines. -For end-user enablement and tuning, see -[Request-Level Batching](../../user_guide/diffusion/request_batching.md). - -This is separate from -[Continuous Batching for Step-Wise Diffusion](diffusion_continuous_batching.md). -Request-level batching runs one full pipeline `forward()` over a static batch of -compatible requests. Step-wise continuous batching admits work between denoise -steps when `step_execution=True`. - -## Why It Helps - -The request-level design avoids coupling several logical requests to one request -object. This keeps request identity, abort/error handling, and per-request -metadata unambiguous while still allowing one fused pipeline forward pass for -bursty or concurrent traffic. - -## Overview - -With request-level batching enabled: - -- each `OmniDiffusionRequest` contains one `prompt` and one `request_id` -- the scheduler groups compatible waiting requests into one scheduler wave -- `DiffusionRequestBatch` wraps the scheduled requests for pipeline `forward()` -- batch-capable pipelines return `list[DiffusionOutput]`, one output per - request -- `BatchRunnerOutput` maps each result back to its original `request_id` - -Pipelines opt in with `supports_request_batch = True` and a `forward()` method -that accepts `DiffusionRequestBatch` and returns `list[DiffusionOutput]`. -Pipelines that do not opt in keep the existing per-request execution path. - -## Enablement - -Request-level batching is the request-mode path, so `step_execution` must remain -disabled. Increase `max_num_seqs` above `1` to let the scheduler keep multiple -compatible requests active: - -```bash -vllm serve Qwen/Qwen-Image --omni \ - --port 8091 \ - --max-num-seqs 4 -``` - -For bursty online ingress, `request_batch_max_wait_ms` can add a bounded -admission wait before the first `schedule()` of a scheduler wave: - -```bash -vllm serve Qwen/Qwen-Image --omni \ - --port 8091 \ - --max-num-seqs 4 \ - --request-batch-max-wait-ms 20 -``` - -`request_batch_max_wait_ms=0` disables this wait and is the default. - -## Request Contract - -`OmniDiffusionRequest` represents one logical request. It owns one prompt, -sampling parameters, request id, and request-local metadata. Runtime batches are -formed by the scheduler and represented separately from the request payload. - -Runtime batching is represented by: - -- [`DiffusionSchedulerOutput`](gh-file:vllm_omni/diffusion/sched/interface.py) - for scheduled request ids and request payloads -- [`DiffusionRequestBatch`](gh-file:vllm_omni/diffusion/worker/request_batch.py) - for the pipeline-facing request batch -- [`BatchRunnerOutput`](gh-file:vllm_omni/diffusion/worker/utils.py) for - per-request results - -`DiffusionRequestBatch` intentionally exposes compatibility properties such as -`prompts`, `sampling_params`, `request_id`, and `kv_sender_info` so migrated -pipelines can stay close to upstream code while using a batch-aware contract. - -## Scheduler - -The scheduler derives its capacity from `max_num_seqs` through -`max_num_running_reqs`. It exposes waiting/running queue counters so the engine -can decide whether admission wait is useful before scheduling a new wave. - -Batch compatibility is controlled by -[`SamplingParamsKey`](gh-file:vllm_omni/diffusion/sched/interface.py). The key -contains shape-sensitive and guidance-sensitive fields, including output count -and LoRA identity. Requests with incompatible shapes, CFG settings, output -counts, LoRA adapters, or LoRA scales are kept in separate batches. - -Admission is conservative: - -- the scheduler only batches compatible requests -- FIFO ordering is preserved -- an incompatible request at the head of the waiting queue blocks later - compatible requests - -## Engine - -[`DiffusionEngine`](gh-file:vllm_omni/diffusion/diffusion_engine.py) resolves -request-batch capability during initialization from the configured pipeline -class, including custom pipeline classes. - -The capability check uses the pipeline class attribute -`supports_request_batch = True`. Pipelines that set this attribute must implement -a request-batch-compatible `forward()` contract and return one -`DiffusionOutput` per request; the runner validates that return shape at runtime. - -When the selected pipeline is batch-capable and `step_execution=False`, request -mode routes scheduler waves through the batch executor path. Otherwise it keeps -the per-request executor path. - -The optional admission wait runs only when: - -- request batching is supported -- `step_execution=False` -- `request_batch_max_wait_ms > 0` -- no requests are currently running - -The wait exits early when the waiting queue reaches capacity, when the queue is -stable for a short window, when the deadline expires, or when the engine stops. - -## Executor And Runner - -The executor exposes two request-mode entries: - -- `execute_request`: one worker call per scheduled request -- `execute_batch`: one worker call for the whole `DiffusionSchedulerOutput` - -On the batch path, the worker builds a `DiffusionRequestBatch` and runs the -pipeline once. Request-local setup remains per request: - -- KV transfer metadata -- random generator and seed handling -- request output/error/abort mapping - -Shared batch setup happens once per batch when possible: - -- cache refresh -- LoRA activation for the homogeneous adapter key -- pipeline `forward(req_batch)` - -Large tensor IPC still uses the shared-memory packing path. The packer traverses -both normal `RunnerOutput.result` wrappers and nested batch results so batched -outputs do not fall back to pickle IPC for tensor payloads. - -## Current Limitations - -- Only pipelines that declare the request-batch contract use fused batch - execution. -- Batches are homogeneous under `SamplingParamsKey`; heterogeneous resolution or - incompatible guidance settings do not co-batch yet. -- FIFO scheduling can reduce batching opportunities when an incompatible - request is at the front of the queue. -- `request_batch_max_wait_ms` improves burst coalescing but can add latency to - the first request in a scheduler wave. Keep it small for latency-sensitive - serving. -- Step-wise continuous batching is documented separately and only applies when - `step_execution=True`. - -## Related Files - -- Request object and request batch: - [`vllm_omni/diffusion/request.py`](gh-file:vllm_omni/diffusion/request.py) -- Scheduler interface: - [`vllm_omni/diffusion/sched/interface.py`](gh-file:vllm_omni/diffusion/sched/interface.py) -- Scheduler base: - [`vllm_omni/diffusion/sched/base_scheduler.py`](gh-file:vllm_omni/diffusion/sched/base_scheduler.py) -- Engine: - [`vllm_omni/diffusion/diffusion_engine.py`](gh-file:vllm_omni/diffusion/diffusion_engine.py) -- Worker runner: - [`vllm_omni/diffusion/worker/diffusion_model_runner.py`](gh-file:vllm_omni/diffusion/worker/diffusion_model_runner.py) -- Executor interface: - [`vllm_omni/diffusion/executor/abstract.py`](gh-file:vllm_omni/diffusion/executor/abstract.py) -- Tests: - [`tests/diffusion/test_diffusion_engine.py`](gh-file:tests/diffusion/test_diffusion_engine.py) diff --git a/docs/design/feature/diffusion_step_execution.md b/docs/design/feature/diffusion_step_execution.md deleted file mode 100644 index 797a1cb48..000000000 --- a/docs/design/feature/diffusion_step_execution.md +++ /dev/null @@ -1,131 +0,0 @@ -# Adding Step Execution Support for Diffusion Pipelines - -This guide documents vLLM-Omni's stepwise diffusion contract for model authors -and contributors implementing `step_execution=True` support for a diffusion -pipeline. - -For end-user enablement, supported models, and current limitations, see -[Step Execution](../../user_guide/diffusion/step_execution.md). - -This document describes the base step-execution contract only. For the -experimental batching policy layered on top of the step-wise path, see -[Continuous Batching for Step-Wise Diffusion](diffusion_continuous_batching.md). - -## Current Support Scope - -`step_execution` is **not** a generic diffusion toggle. It only works for -pipelines that implement the segmented stateful contract in -[`vllm_omni/diffusion/models/interface.py`](gh-file:vllm_omni/diffusion/models/interface.py). - -This page is intentionally author-facing. Treat runtime enablement -(`step_execution=True` in Python or `--step-execution` in serving) as an -opt-in user knob layered on top of the implementation contract below. - -Current in-tree support: - -| Pipeline | Example models | Step execution | -|----------|----------------|----------------| -| `QwenImagePipeline` | `Qwen/Qwen-Image`, `Qwen/Qwen-Image-2512` | Yes | -| All other diffusion pipelines | `QwenImageEditPipeline`, `QwenImageEditPlusPipeline`, `QwenImageLayeredPipeline`, GLM-Image, Wan, Flux, etc. | No | - -Current engine/runtime limitations: - -- Continuous batching with `max_num_seqs > 1` is experimental and documented in - [Continuous Batching for Step-Wise Diffusion](diffusion_continuous_batching.md). - Keep `max_num_seqs=1` if you want the older conservative behavior. -- `cache_backend` is not supported in step mode. -- Request-mode extras such as KV transfer are not wired into step mode yet. -- Unsupported pipelines now fail early during model loading instead of failing on the first request. - -## Execution Contract - -Step mode is driven by four pipeline methods plus the shared mutable request -state object: - -- `prepare_encode(state)`: one-time request preparation. -- `denoise_step(state)`: compute the noise prediction for the current step. -- `step_scheduler(state, noise_pred)`: mutate latents and advance step state. -- `post_decode(state)`: decode the final output after denoising is complete. - -The state lives in -[`vllm_omni/diffusion/worker/utils.py`](gh-file:vllm_omni/diffusion/worker/utils.py) -as `DiffusionRequestState`. Store request-scoped tensors there, or use -`state.extra` for model-specific fields that do not justify extending the core -dataclass. - -The worker-side step loop lives in -[`vllm_omni/diffusion/worker/diffusion_model_runner.py`](gh-file:vllm_omni/diffusion/worker/diffusion_model_runner.py): - -1. `prepare_encode()` runs once for a new request. -2. `denoise_step()` runs every scheduler tick. -3. `step_scheduler()` mutates `state.latents` and advances `state.step_index`. -4. `post_decode()` runs exactly once after `state.denoise_completed` becomes true. - -## Recommended Split - -When converting an existing request-level `forward()` pipeline, keep the split -strict and mechanical: - -| Request-level phase | Stepwise method | What belongs there | -|---------------------|-----------------|--------------------| -| Input validation, prompt encoding, latent init, timestep prep, per-request scheduler creation | `prepare_encode()` | Anything that should happen once per request | -| Transformer forward / noise prediction | `denoise_step()` | Pure denoise computation for the current timestep | -| `scheduler.step(...)` and `step_index += 1` | `step_scheduler()` | Only latent/state mutation for one step | -| VAE decode / postprocess | `post_decode()` | Final decode only | - -Keep the stepwise path reusing the same helpers as the request-level path -whenever possible. Reimplementing the denoise loop from scratch is the easiest -way to introduce behavioral drift. - -## Qwen-Image Reference - -[`pipeline_qwen_image.py`](gh-file:vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py) -is the reference implementation and is split correctly for the current -contract: - -- `prepare_encode()` reuses `_prepare_generation_context()` so prompt encoding, - latent init, timestep creation, CFG setup, and shape bookkeeping stay aligned - with `forward()`. -- `prepare_encode()` deep-copies `self.scheduler` **after** - `prepare_timesteps()` so request-specific scheduler state is isolated. -- `denoise_step()` reuses `_build_denoise_kwargs()` plus - `predict_noise_maybe_with_cfg()`, so sequential CFG, CFG-parallel, and - non-CFG behavior stay identical to the request-level path. -- `step_scheduler()` only calls - `scheduler_step_maybe_with_cfg(..., per_request_scheduler=state.scheduler)` - and increments `state.step_index`. -- `post_decode()` reuses `_decode_latents()`, so the final image decode matches - the normal `forward()` path. - -That decomposition is the target pattern for future models. - -## Rules For New Pipelines - -- Do not keep request-scoped scheduler state on `self.scheduler`. Copy it into - `state.scheduler` during `prepare_encode()`. -- Do not mutate `state.step_index` inside `denoise_step()`. Only - `step_scheduler()` should advance the step. -- Do not decode partial outputs in `denoise_step()` or `step_scheduler()`. -- If the request-level pipeline has condition latents, masks, or edit-specific - tensors, store them in `state` or `state.extra`, not in global pipeline - attributes. -- Preserve CFG behavior by sharing the same helper path used by `forward()`. -- Keep `post_decode()` equivalent to the tail of `forward()`. - -## Validation Checklist - -Before marking a pipeline as `supports_step_execution = True`, verify: - -- Stepwise output matches request-level output for the same seed and sampling params. -- Per-request scheduler state is isolated across concurrent requests. -- Abort during denoise does not leak cached state. -- `step_index` reported by `RunnerOutput` matches the scheduler progress. -- CFG-parallel and non-CFG paths both work if the request-level pipeline supports them. - -## Related Files - -- Contract: [`vllm_omni/diffusion/models/interface.py`](gh-file:vllm_omni/diffusion/models/interface.py) -- State: [`vllm_omni/diffusion/worker/utils.py`](gh-file:vllm_omni/diffusion/worker/utils.py) -- Runner loop: [`vllm_omni/diffusion/worker/diffusion_model_runner.py`](gh-file:vllm_omni/diffusion/worker/diffusion_model_runner.py) -- Scheduler transport: [`vllm_omni/diffusion/sched/interface.py`](gh-file:vllm_omni/diffusion/sched/interface.py) -- Reference pipeline: [`vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py`](gh-file:vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py) diff --git a/docs/design/feature/disaggregated_inference.md b/docs/design/feature/disaggregated_inference.md index 15d9e09df..c31086c0d 100644 --- a/docs/design/feature/disaggregated_inference.md +++ b/docs/design/feature/disaggregated_inference.md @@ -66,8 +66,6 @@ runtime: connectors: connector_of_shared_memory: name: SharedMemoryConnector - extra: - shm_threshold_bytes: 65536 ``` Wire stages to connectors: diff --git a/docs/design/feature/distributed_layerwise_offload.md b/docs/design/feature/distributed_layerwise_offload.md new file mode 100644 index 000000000..da735d60b --- /dev/null +++ b/docs/design/feature/distributed_layerwise_offload.md @@ -0,0 +1,156 @@ +# Distributed Layerwise Offload + +This document describes distributed layerwise offload (DLO) for diffusion +models. DLO keeps only a small number of DiT blocks on the accelerator and +streams the remaining blocks from host memory. The distributed backend can +either shard those host-side weights across an existing parallel group or keep +the standard loader's rank-local weights and avoid an additional collective. + +For user-facing commands, see the +[distributed layerwise offloading guide](../../user_guide/diffusion/cpu_offload.md) +and the [Cosmos3 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/cosmos3/Cosmos3-DistOffload.md). + +## Status + +DLO is implemented for multi-device diffusion execution. The default +AllGather path is the primary path for DP and SP deployments. The +`--dlo-no-use-allgather` path is a rank-local compatibility mode: it is useful +for standard-loader sharding, workstation bring-up, and systems where an +additional DLO collective is undesirable, but it does not reduce host weight +storage across ranks. + +The compatibility matrix below describes the current implementation. The +unit-level guards are covered, but not every parallelism combination has a +full model-and-hardware end-to-end test. + +## Design + +### DLO consumes the existing parallel topology + +DLO does not create a new DP, TP, or SP topology. It reads the configured +`DiffusionParallelConfig` and attaches offload hooks to the DiT blocks after the +standard distributed groups have been initialized. + +The DLO weight-sharding group is selected as follows: + +1. Use the existing DP group when `data_parallel_size > 1`. +2. When DP is one and SP is greater than one, use the SP group. +3. Otherwise, run rank-locally without a DLO process group. + +TP is deliberately not used as DLO's AllGather group. HSDP has its own +parameter-sharding lifecycle and is not allowed to be sharded a second time by +DLO's AllGather path. + +### AllGather path + +With the default `dlo_use_allgather=True`, each rank stores approximately +`1 / group_size` of each streamable block in pinned host memory. The next +block's shard is copied to a device buffer and reconstructed with +`all_gather_into_tensor` on a communication stream while the current block is +executing. + +```text +Compute: [Block N] [Block N+1] [Block N+2] +H2D: [shard N+1] [shard N+2] +AllGather: [full N+1] [full N+2] +Buffers: [current slot] [prefetch slot] [current slot] +``` + +![DLO double-buffer prefetch pipeline](../figures/dlo/dlo_pipeline.gif) + +The backend uses two shared device buffers, so accelerator weight residency is +bounded by the largest streamed blocks rather than the complete model. + +When DP is greater than one, the engine can process one request per DP rank in +the same denoising wave. Because AllGather is a collective, all participating +requests must take the same execution path at every denoising step. + +### Rank-local path without DLO AllGather + +With `--dlo-no-use-allgather`, DLO forces its internal offload shard size to +one. The regular model loader remains responsible for preparing each rank's +weights, including TP-local tensors or HSDP-managed parameters. DLO then +streams those rank-local tensors block by block using H2D copies only. + +This mode means: + +- DP still provides independent replicas, but DLO does not shard weights + across DP ranks. +- SP still performs its normal activation/attention collectives, but DLO does + not shard weights across SP ranks. +- TP/HSDP/SP collectives, if configured, are not disabled by this flag; only + DLO's additional weight AllGather is disabled. +- Pure DP deployments keep a full host-side model copy per rank, subject to + shared OS page-cache behavior. +- The scheduler does not require a synchronized DP request wave for DLO. + +This path is intentionally not implemented by reusing the AllGather mmap +loader. It relies on the standard loader so model-specific TP/HSDP transforms +remain intact. + +## Parallelism compatibility + +| Parallelism | DLO + AllGather | DLO without AllGather | +|---|---|---| +| **DP** | Supported primary path. DLO shards host weights across the DP group and can run DP multi-concurrency. | Supported rank-local path. DP replicas remain independent; no DLO weight sharding or cross-DP DLO collective. | +| **SP** | Supported in the implementation. With DP=1, DLO uses the SP group for host-weight sharding; SP still shards sequence/activation work. | SP remains active, but DLO keeps standard-loader rank-local weights and adds no SP weight collective. | +| **TP > 1** | Unsupported in the DLO mmap path. TP-aware loader callbacks are bypassed, so the backend rejects this configuration when it enters that path. | Standard loading is retained and TP-local tensors can be streamed. This is the intended compatibility path, but it still needs broader model and hardware validation. | +| **HSDP** | Rejected. HSDP has already sharded parameters, so DLO AllGather would double-shard them. | Accepted by configuration. HSDP owns parameter sharding and its own gathers; DLO only stages rank-local parameters. End-to-end coverage is limited. | + +### Combined dimensions + +- **DP + SP:** DLO uses the DP group for weight sharding when DP is greater + than one; SP continues to use its own sequence-parallel group. If DP is one, + the SP group becomes DLO's sharding group in AllGather mode. +- **DP + TP/SP without AllGather:** standard model loading defines the + rank-local tensor layout. DLO adds no cross-DP, cross-TP, or cross-SP weight + collective. +- **HSDP + SP:** the general parallel configuration permits HSDP over SP, but + DLO must use `--dlo-no-use-allgather`. HSDP remains responsible for weight + materialization and synchronization. +- **HSDP + DP or TP:** rejected independently by the diffusion parallel + configuration. + +## Request and loading constraints + +AllGather DP multi-concurrency requires: + +- explicit `num_inference_steps`; +- the same `num_inference_steps` for all requests in a wave; and +- identical request arguments that affect the collective execution path. + +The no-AllGather path does not impose these DLO-specific synchronized-wave +requirements. + +The mmap loader is used only by the supported DLO+AllGather path when the +model has a compatible checkpoint layout. Online quantization is incompatible +with that sharded mmap path; use `--dlo-no-use-allgather` or disable online +quantization. Models that do not meet the mmap requirements use the regular +loader path. + +## Validation coverage + +Current source-level validation includes: + +- HSDP + DLO + AllGather rejection; +- HSDP + DLO without AllGather acceptance at configuration level; +- TP rejection in the DLO+AllGather mmap path; +- resident-layer requests requiring no-AllGather; +- DP request-wave validation for denoising-step compatibility; +- sharding, double-buffer, AllGather-size, and heterogeneous-block regression + tests. + +The highest-value missing coverage is end-to-end numerical comparison against +ordinary layerwise offload for DP+SP, TP+no-AllGather, and HSDP+SP+no-AllGather +on the target CUDA/NCCL or CANN/HCCL hardware. + +## Recommendations + +- Use **DP + DLO AllGather** for the supported throughput and host-memory + scaling path. +- Use **SP + DLO AllGather** for long-sequence workloads when DP concurrency is + not the goal. +- Use **no-AllGather** to bring up TP or workstation PCIe configurations, with + the expectation of higher host-memory use and lower validation confidence. +- Prefer **HSDP alone** for production HSDP deployments until the combined + HSDP + DLO no-AllGather path has broader end-to-end coverage. diff --git a/docs/design/feature/omni_async_output_materialization.md b/docs/design/feature/omni_async_output_materialization.md new file mode 100644 index 000000000..50a2e7c52 --- /dev/null +++ b/docs/design/feature/omni_async_output_materialization.md @@ -0,0 +1,386 @@ +# Async Omni Output Materialization + +## Table of Contents + +1. [Overview](#overview) +2. [Performance](#performance) +3. [Architecture](#architecture) +4. [Enabling the Feature](#enabling-the-feature) +5. [Compatibility and Fallbacks](#compatibility-and-fallbacks) +6. [Related Files](#related-files) + +## Overview + +Async Omni output materialization moves CPU-side construction of +`OmniModelRunnerOutput` out of the autoregressive (AR) decode critical path. +It complements [Async Chunk](async_chunk.md): async chunk pipelines partial +outputs between stages, while async output materialization prevents the +construction of those partial outputs from blocking the next decode step. + +An AR stage must return sampled token IDs quickly because the scheduler needs +them for the next decode step. However, an Omni stage can also produce hidden +states, multimodal tensors, streaming inter-stage payloads, and connector +metadata. Building these payloads can require device-to-host (D2H) copies, +tensor slicing, flattening, and Python object construction. + +Without this feature, all of that work runs inline in `sample_tokens()`: + +```text +sample tokens + -> update decode state + -> copy hidden and multimodal outputs to CPU + -> build per-request payloads + -> build the streaming wire payload + -> collect connector signals + -> return sampled tokens + -> launch the next decode step +``` + +With async output materialization, only work required by the next decode step +stays on the critical path: + +```text +sample tokens + -> update decode state + -> snapshot output state and start asynchronous D2H copies + -> register sampled tokens + -> launch the next decode step + +background output path + -> wait for the payload snapshot + -> build per-request payloads + -> build the streaming wire payload + -> collect connector signals + -> construct OmniModelRunnerOutput +``` + +This allows payload construction for step `N` to overlap with GPU decode work +for step `N + 1`. It does not change model computation or generated output. + +!!! note "Platform and validation scope" + The performance and correctness validation in this document covers CUDA + and ROCm. This is a validation scope, not a runtime platform guard: XPU + inherits `GPUARModelRunner`, and MUSA selects `GPUARWorker`, so those + backends may enter the asynchronous path when its other guards pass. XPU + and MUSA have not been validated for this optimization. Ascend NPU uses a + separate `NPUARModelRunner` path that still constructs + `OmniModelRunnerOutput` and drains connector output synchronously before + wrapping the result as an asynchronous runner output. + +

+ Decode step gap before and after async output materialization +

+ +

+ + Figure 1: Moving payload construction off the decode path reduces the + observed gap between consecutive Talker steps from about 2.8 ms to 41 µs. + Adapted from the + + Qwen3-Omni optimization blog + . + +

+ +For Qwen3-Omni, the feature is enabled for both AR stages: + +- **Thinker**: Defers hidden-state and multimodal payload materialization. +- **Talker**: Runs its decode-state postprocess eagerly, then defers codec + payload materialization. Because Code2Wav consumes codec codes rather than + Talker hidden states, the Talker also skips unnecessary hidden-state D2H. +- **Code2Wav**: Does not use this path because it is a generation stage rather + than an AR stage. + +Qwen3-TTS uses the same mechanism in its AR Talker stage. The Talker updates +the decode state it needs for the next step before returning, then constructs +the codec payload for Code2Wav in the background. + +## Performance + +In the controlled Qwen3-Omni optimization sweep, enabling async output +materialization on top of CUDA Graph and async chunk produced the following +results at concurrency 64: + +| Configuration | Request throughput | Mean audio TTFP | Mean audio RTF | +|---|---:|---:|---:| +| CUDA Graph + async chunk | 9.3 req/s | 655 ms | 0.63 | +| + async output materialization | 11.3 req/s | 631 ms | 0.47 | +| Change | **+22%** | **-4%** | **-25%** | + +The sweep used `Qwen/Qwen3-Omni-30B-A3B-Instruct`, 640 Seed-TTS English +prompts, and one replica each for Thinker, Talker, and Code2Wav. Async output +materialization recovers throughput by removing CPU payload construction from +the decode critical path without regressing time to first audio packet (TTFP). +See the +[Qwen3-Omni optimization blog](https://vllm.ai/blog/2026-07-01-qwen3-omni-optimization) +for the complete benchmark methodology and results. + +The implementation was also validated against the synchronous safe-copy path. +The text and audio output hashes matched, confirming that overlap does not +change the generated result. See +[PR #4476](https://github.com/vllm-project/vllm-omni/pull/4476) for the +correctness and profiling details. + +## Architecture + +### Output Lifecycle + +`OmniAsyncGPUModelRunnerOutput` extends vLLM's +`AsyncGPUModelRunnerOutput`. It preserves the existing asynchronous +sampled-token feedback while adding a background builder for the complete Omni +output. + +The output lifecycle is: + +1. `GPUARModelRunner.sample_tokens()` samples tokens and performs the + bookkeeping required by the next decode step. +2. The runner snapshots step-local metadata such as request IDs, token spans, + scheduler output, and query start locations. +3. CUDA payload tensors are cloned before reusable model or CUDA Graph buffers + can be overwritten. +4. The cloned payload is copied to pinned CPU memory on a dedicated CUDA + stream. A CUDA event records when the copy is ready. +5. `OmniAsyncGPUModelRunnerOutput` starts a background thread that waits for + the payload event and builds `OmniModelRunnerOutput`. +6. The AR runner immediately registers the sampled-token CPU copy with the + input batch and returns, allowing async scheduling to advance. +7. When the engine calls `get_output()`, it joins the background builder, + propagates any builder exception, and finalizes sampled tokens and logprobs + through the upstream output implementation. + +```text +GPU / decode path Background output path + +forward + sample + | + +-- clone output tensors + +-- enqueue D2H copy --------------> wait for D2H event + +-- register sampled tokens | + +-- return async output +-- slice per request + | +-- build multimodal payloads +next decode step +-- partition streaming payloads + +-- build the wire payload + +-- drain connector signals + +-- build OmniModelRunnerOutput +``` + +### Safe State Snapshots + +The next scheduler step can mutate runner state while the background builder is +still active. Most step-local builder inputs are therefore detached snapshots. +The snapshotted state includes: + +- scheduler token counts and speculative-token metadata +- request IDs and request-to-batch-index mappings +- sampled token IDs, logprobs, and prompt logprobs +- query start locations and scheduled-token spans +- hidden states and multimodal output tensors +- KV-connector and encoder-cache outputs captured for the step + +CUDA tensor cloning is required because CUDA Graph and model output buffers can +be reused by the next forward pass. The dedicated copy stream and pinned host +buffers make the D2H transfer asynchronous; the snapshot retains its cloned +CUDA sources until the transfer event completes. + +### Live Runner State and Ownership + +The complete builder input is not a detached snapshot. Connector output state +remains runner-owned: `get_omni_connector_output()` drains live, per-cycle +connector signals after the streaming payload is built. Correctness therefore +depends on that builder being the only consumer that drains connector signals +for its output cycle. Receive-side connector state written by the background +receiver is coordinated by the connector mixin's `_lock`. +`OmniAsyncGPUModelRunnerOutput.get_output()` joins the builder and is the +completion and exception boundary before the materialized output is consumed. + +The shared output builder also contains a full-payload accumulation branch +that reads `self.requests`. That branch is not reachable when async Omni output +materialization is enabled: this feature requires `async_chunk`, while +`should_accumulate_full_payload_output()` returns `False` whenever +`async_chunk` is enabled. The feature path partitions each step's payload into +streaming inter-stage and client handoffs instead. + +### Output Builder + +The background builder performs the work that previously ran inline: + +- resolves which requests need downstream payloads +- converts hidden and multimodal tensors into per-request CPU payloads +- applies model-specific payload processing +- partitions per-step payloads into inter-stage and client streams +- creates the tensor-only `multimodal_outputs` wire payload +- drains live connector readiness signals after building the streaming payload +- constructs the final `OmniModelRunnerOutput` + +Input-side connector operations remain synchronous with model execution. In +particular, receiving inputs and flushing previously completed connector +outputs are not deferred because they affect scheduler-visible request state. + +### Qwen3-Omni Stage Behavior + +| Stage | Async output behavior | Reason | +|---|---|---| +| Thinker | Snapshots hidden states and multimodal outputs; builds the downstream payload in the background | The Talker needs the payload, but the next Thinker decode step only needs sampled-token feedback | +| Talker | Runs lightweight postprocess eagerly; snapshots codec outputs; omits hidden states from the downstream payload | `hidden_states.last` is needed by the next Talker step, while Code2Wav only needs codec codes | +| Code2Wav | Uses the normal generation-stage output path | Code2Wav is not executed by `GPUARModelRunner` | + +The Qwen3-TTS Talker follows the same pattern as the Qwen3-Omni Talker: +postprocess runs eagerly because the next decode step needs its updated state, +while codec payload construction runs asynchronously. Its Code2Wav stage also +uses the normal generation-stage path. + +## Enabling the Feature + +There is no separate `async_omni_output` command-line or YAML option. The +feature is selected automatically for model stages that opt in when the runtime +conditions are safe. On the validated CUDA and ROCm configurations, using the +bundled deployment profile is enough to turn it on for the supported +Qwen3-Omni and Qwen3-TTS pipelines. The selection logic has no CUDA/ROCm-only +guard; see [Compatibility and Fallbacks](#compatibility-and-fallbacks) for the +other platform paths. The same profile does not enable background Omni +materialization on Ascend NPU. + +### Example 1: Qwen3-Omni + +Start the full Qwen3-Omni pipeline: + +```bash +vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct \ + --omni \ + --port 8091 +``` + +The model registry automatically loads +`vllm_omni/deploy/qwen3_omni_moe.yaml`. Its relevant settings are: + +```yaml +async_chunk: true + +stages: + - stage_id: 0 # Thinker + enable_prefix_caching: false + # async_scheduling defaults to true for AR stages + + - stage_id: 1 # Talker + enable_prefix_caching: false + # async_scheduling defaults to true for AR stages + + - stage_id: 2 # Code2Wav + enable_prefix_caching: false + async_scheduling: false +``` + +With this profile, async output materialization activates automatically for: + +- **Stage 0, Thinker**: Defers hidden-state and multimodal payload + construction. +- **Stage 1, Talker**: Defers codec payload construction after eagerly + updating its decode state. + +Stage 2 does not use this feature because Code2Wav is not an AR stage. + +### Example 2: Qwen3-TTS + +Start Qwen3-TTS with any checkpoint that uses the Qwen3-TTS Talker pipeline. +For example: + +```bash +vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \ + --omni \ + --port 8091 +``` + +The model registry automatically loads `vllm_omni/deploy/qwen3_tts.yaml`. Its +relevant settings are: + +```yaml +async_chunk: true + +stages: + - stage_id: 0 # Talker + async_scheduling: true + enable_prefix_caching: false + + - stage_id: 1 # Code2Wav + enable_prefix_caching: false +``` + +Async output materialization activates only for Stage 0, the AR Talker. The +Talker keeps the state required by its next decode step on the GPU, skips an +unnecessary hidden-state D2H copy, and builds the codec payload for Code2Wav in +the background. Stage 1 uses the generation-stage output path. + +The same behavior applies to the Base and VoiceDesign Qwen3-TTS checkpoints +because they use the same Talker implementation. + +!!! warning + Do not pass `--no-async-chunk` or enable prefix caching when you want this + optimization. Either change causes the runner to fall back to synchronous + output construction. Enabling async chunk alone cannot activate the feature + for a model that has not opted into async Omni output. + +!!! note "Prefix cache compatibility" + Async Omni output materialization and Omni prefix caching cannot currently + run together. `_should_use_async_omni_output()` returns `False` whenever + `self.omni_prefix_cache` is present, so prefix caching selects synchronous + output materialization. Supporting both features requires snapshotting or + otherwise synchronizing prefix-cache merge and update state before the + background output path can consume it safely. + +## Compatibility and Fallbacks + +`GPUARModelRunner` uses the async output path only when all of the following +runtime conditions hold: + +| Requirement | Reason | +|---|---| +| AR async scheduling is enabled | The optimization relies on the scheduler advancing while the prior output is materialized | +| `async_chunk` is enabled | The feature targets incremental downstream Omni payloads | +| The model stage opts in with `use_async_omni_output` | Models must declare that their output lifecycle is safe to defer | +| Omni prefix cache is disabled | Prefix-cache merge and update ordering currently requires synchronous materialization | +| Speculative decoding is disabled | Speculative output state is not included in this deferred path | +| Routed-expert output is disabled | Routed-expert extraction currently requires the synchronous path | +| Postprocess is absent or explicitly runs eagerly | State needed by the next decode step must be updated before the runner returns | + +These checks are evaluated per stage on runners based on `GPUARModelRunner`. +An unsupported combination does not prevent serving; it only falls back to +synchronous output construction for that stage. CUDA and ROCm are the validated +platforms. XPU inherits `GPUARModelRunner`, and MUSA selects `GPUARWorker`, so +they may select the async path when these checks pass, but this optimization is +not yet validated on either backend. + +Ascend NPU does not use this selection path. `NPUARModelRunner.sample_tokens()` +fully constructs `OmniModelRunnerOutput`, calls +`get_omni_connector_output()`, and only then creates the asynchronous wrapper, +so Omni payload materialization remains synchronous. + +!!! note + `use_async_omni_output`, + `eager_omni_postprocess_before_async_output`, and + `omni_pooler_payload_include_hidden` are model implementation contracts, + not user-facing configuration fields. + +## Related Files + +- `vllm_omni/worker/gpu_ar_model_runner.py`: Async output object, safe tensor + snapshots, compatibility guards, and deferred Omni output builder. +- `vllm_omni/platforms/npu/worker/npu_ar_model_runner.py`: Separate Ascend NPU + runner, where Omni output materialization remains synchronous. +- `vllm_omni/model_executor/models/qwen3_omni/qwen3_omni.py`: Thinker and + Talker opt-in behavior. +- `vllm_omni/deploy/qwen3_omni_moe.yaml`: Default Qwen3-Omni deployment + settings. +- `vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py`: Qwen3-TTS + Talker opt-in and eager postprocess behavior. +- `vllm_omni/deploy/qwen3_tts.yaml`: Default Qwen3-TTS deployment settings. +- `tests/worker/test_gpu_ar_model_runner.py`: Snapshot, guard, connector + ordering, and background error-propagation tests. +- [Async Chunk](async_chunk.md): Inter-stage chunking and scheduling design. +- [Qwen3-Omni optimization blog](https://vllm.ai/blog/2026-07-01-qwen3-omni-optimization): + Optimization context and controlled performance results. +- [PR #4476](https://github.com/vllm-project/vllm-omni/pull/4476): Feature + implementation, validation, and review history. diff --git a/docs/design/feature/omni_connectors/shared_memory_connector.md b/docs/design/feature/omni_connectors/shared_memory_connector.md index 5b35014f2..62d77cd6d 100644 --- a/docs/design/feature/omni_connectors/shared_memory_connector.md +++ b/docs/design/feature/omni_connectors/shared_memory_connector.md @@ -8,9 +8,7 @@ auto-configured when no explicit connector is specified for an edge. ## How It Works All payloads are serialized and stored in shared memory (`/dev/shm`); the SHM -segment name is returned in metadata. The configuration exposes a -`shm_threshold_bytes` field for a future inline-vs-SHM split, but the current -implementation always uses shared memory regardless of payload size. +segment name is returned in metadata. ## Configuration @@ -19,8 +17,6 @@ runtime: connectors: connector_of_shared_memory: name: SharedMemoryConnector - extra: - shm_threshold_bytes: 65536 ``` ## Notes @@ -166,27 +162,10 @@ This path is mainly for older code paths and is not the preferred mode for the c ### 6. Key Implementation Characteristics -#### 6.1 Threshold Exists, but the Current Code Always Uses SHM +#### 6.1 All Payloads Use Shared Memory -The class keeps a `shm_threshold_bytes` field and still exposes metrics for inline writes. However, the current implementation uses: - -```python -if True: - ... -``` - -inside `put()`, which means the current code path always writes to shared memory. - -So the design still suggests a future split between: - -- small payloads inline -- large payloads in shared memory - -but the current behavior is effectively: - -- all payloads go through shared memory - -This should be documented because it affects real runtime behavior. +`put()` writes every serialized payload to shared memory. The connector has no +inline-payload path or size threshold. #### 6.2 Cleanup Is Currently Passive diff --git a/docs/design/feature/quantization.md b/docs/design/feature/quantization.md new file mode 100644 index 000000000..37dd5e26b --- /dev/null +++ b/docs/design/feature/quantization.md @@ -0,0 +1,249 @@ +# Quantization + +This document describes the quantization architecture in vLLM-Omni and the +extension points for contributors. For user-facing configuration, supported +hardware, and method-specific instructions, see the +[quantization user guide](../../user_guide/quantization/overview.md). + +## Goals and scope + +The design has four goals: + +1. Provide one `quantization_config` entry point for diffusion-only, + multi-stage omni/TTS, and multi-stage diffusion models. +2. Reuse vLLM's quantization registry and `QuantizationConfig` contract where + a vLLM backend already provides the required behavior. +3. Allow different components of one pipeline to use different quantization + configurations. +4. Preserve the distinction between online quantization of a BF16/FP16 + checkpoint and loading a pre-quantized checkpoint with serialized scales. + +This document covers configuration resolution, component routing, model +integration, backend extension, and validation. Kernel implementations and +user-facing support matrices are outside its scope. + +## Architecture + +The quantization path is split across the following layers: + +| Layer | Responsibility | Source | +| --- | --- | --- | +| Public API | Exposes `build_quant_config`, component routing, and backend registration | `vllm_omni/quantization/__init__.py` | +| Factory | Normalizes method names, selects Omni overrides or vLLM registry backends, detects ModelOpt configs, and reconciles checkpoint metadata | `vllm_omni/quantization/factory.py` | +| Component router | Resolves a quantization config from a layer prefix using longest-prefix matching | `vllm_omni/quantization/component_config.py` | +| Method configs | Implement `QuantizationConfig` and select the platform-specific linear method | `vllm_omni/quantization/*_config.py` | +| Diffusion configuration | Stores the canonical runtime config and propagates checkpoint configuration | `vllm_omni/diffusion/data.py` | +| Worker and model integration | Passes the resolved config into vLLM's model configuration and model layers | `vllm_omni/diffusion/worker/diffusion_worker.py` and `vllm_omni/diffusion/models/` | + +The runtime contract remains vLLM's `QuantizationConfig`: + +```text +user/CLI or model config + | + v +OmniDiffusionConfig.quantization_config + | + +--> build_quant_config() + | | + | +--> Omni override + | +--> vLLM registry backend + | +--> ComponentQuantizationConfig + | + +--> checkpoint metadata reconciliation + | + +--> diffusion worker / vLLM config + | + +--> model layers call get_quant_method(layer, prefix) +``` + +The model layer receives either a method implementation or `None`. Returning +`None` leaves that layer unquantized. + +## Configuration resolution + +`build_quant_config()` accepts the following forms: + +| Input | Resolution | +| --- | --- | +| `None` or `"none"` | Disable quantization. | +| Method string, such as `"fp8"` | Resolve an Omni override first, then the vLLM quantization registry. | +| Flat dictionary with `method` or `quant_method` | Normalize the method and construct its `QuantizationConfig`. | +| Per-component dictionary | Construct a `ComponentQuantizationConfig`. | +| Existing `QuantizationConfig` | Pass through without rebuilding it. | + +Method aliases are normalized case-insensitively, with `-` and `_` treated as +equivalent. `auto-round` and `auto_round` use the Omni INC/AutoRound adapter. +ModelOpt checkpoint metadata is detected from its method, producer metadata, or +`quant_algo`, then mapped to the corresponding vLLM ModelOpt configuration. + +The factory uses Omni-specific overrides for methods whose diffusion behavior +or platform dispatch is not provided by the vLLM registry. The current override +set includes INT8, BitsAndBytes, MXFP8, MXFP4, MXFP4 dual-scale, and INC/ +AutoRound. Other methods, such as FP8, GGUF, and ModelOpt, are resolved through +vLLM's registry when their configuration is compatible. + +## Per-component routing + +Multi-stage models do not necessarily want one quantization policy for every +module. A component map expresses that policy without requiring each pipeline +to construct separate vLLM configurations: + +```python +quantization_config = { + "transformer": {"method": "fp8"}, + "vae": None, + "default": None, +} +``` + +`ComponentQuantizationConfig` applies the following rules: + +1. Component keys are interpreted as runtime layer-prefixes. +2. The longest matching prefix wins. +3. `None` disables quantization for the matching component. +4. `default`, when present, handles prefixes with no explicit match. +5. A matching child config delegates to its normal + `get_quant_method(layer, prefix)` implementation. + +Prefix matching happens after the model's weight-name mapping. New model +integrations must verify the actual runtime prefixes, especially when a +`WeightsMapper` changes checkpoint names. A prefix mismatch can silently route +a layer to the default configuration, so component-routing tests should cover +both positive matches and fall-through behavior. + +## Online and pre-quantized checkpoints + +The same method name can represent two different loading paths: + +- **Online**: the source checkpoint contains BF16/FP16 weights; the method + creates quantized weights or scales while the model is loaded. +- **Pre-quantized**: the checkpoint contains quantized weights and the metadata + required by the method; the loader consumes those serialized tensors. + +Method configuration owns the mode flag, for example +`is_checkpoint_int8_serialized` or `is_checkpoint_mxfp8_serialized`. The +configuration's `get_quant_method()` selects the corresponding linear method. + +For pipelines with transformer-specific `config.json` files, such as cascade +models, `resolve_quant_config_from_disk()` reconciles the active configuration +with each transformer's checkpoint metadata. Its invariants are: + +- If no active configuration exists, valid checkpoint metadata can be used for + auto-detection. +- A method mismatch is an error rather than an implicit conversion. +- Serialized-checkpoint flags rebuild an online configuration into the + matching offline configuration. +- Checkpoint-specific `ignored_layers` are preserved by rebuilding the active + configuration when they differ. +- AutoRound MXFP8 metadata (`data_type="mx_fp"`) selects its offline path. + +This prevents a model from interpreting serialized weights with an online +loader or silently applying the wrong method to one transformer in a +multi-transformer pipeline. + +## Model and layer integration + +Diffusion model constructors receive the resolved configuration and pass it to +quantizable vLLM layers. Each method configuration is responsible for: + +- identifying supported layers in `get_quant_method()`; +- creating the correct parameter shapes and loaders; +- processing weights after loading when conversion is required; and +- applying the platform-specific quantized operation at runtime. + +Model code must keep precision-sensitive or unsupported modules unquantized. +The shared `safe_quant_config()` helper is used by model integrations for +normalization and modulation layers where a generic FP8 configuration would be +unsafe. Text encoders, audio/vision encoders, VAEs, schedulers, and tokenizers +are not implicitly quantized just because the main transformer is quantized; +they require explicit component routing and method support. + +Weight quantization and quantized KV cache are separate paths. The former is +resolved through `quantization_config`; runtime attention/KV-cache quantization +is configured by the diffusion attention configuration and must be validated +independently. + +## Platform and parallelism boundaries + +Platform checks belong in the method implementation rather than in the common +factory. This keeps configuration parsing portable while allowing CUDA, NPU, +and XPU methods to select different kernels or reject unsupported execution at +model construction time. The user guide is the source of truth for the current +hardware support matrix. + +Quantization methods must also respect the weight layout produced by the +configured parallelism strategy: + +- Tensor-parallel layers may receive partitioned weight shapes and loaders. +- Sequence-parallel execution changes activation movement but does not by + itself change the quantization configuration. +- Data-parallel replicas build their own method state; quantization metadata + must be available consistently on every rank. +- `WeightsMapper` transformations must be applied before matching ignored-layer + lists or component prefixes. + +Distributed layerwise offload has an additional boundary: its sharded mmap +AllGather path is not compatible with online quantization because the path +expects already materialized checkpoint weights. Use the rank-local +`--dlo-no-use-allgather` path or a compatible pre-quantized checkpoint when +combining these features. See the [DLO design document](distributed_layerwise_offload.md) +for the full compatibility matrix. + +## Adding a quantization backend + +Contributors should follow this sequence: + +1. Check whether an existing vLLM backend can provide the required weight + format and kernel behavior. Prefer reuse over a duplicate Omni backend. +2. If Omni-specific behavior is required, implement a `QuantizationConfig` and + its `QuantizeMethodBase`/linear method in `vllm_omni/quantization/`. +3. Implement `get_name()`, supported activation dtypes, minimum capability, + checkpoint parsing, ignored-layer handling, and `get_quant_method()`. +4. Add a lazy factory override in `factory.py`. Keep optional platform + dependencies out of package import paths and normalize aliases to one + canonical method name. +5. If the method is pre-quantized, define the checkpoint metadata and the + serialized loading path before adding an online path. +6. Add model-specific component routing only when the model contains stages + that must use different policies. +7. Document user-visible configuration in the quantization user guide and keep + implementation rationale here. + +The public factory registration hook, +`register_quantization_override(method, builder)`, is intended for integrations +that need to register an Omni-compatible builder without changing the vLLM +registry. + +## Validation + +Validation should cover the complete path, not only whether a config object can +be instantiated: + +| Area | Required checks | +| --- | --- | +| Factory | Strings, aliases, flat dictionaries, invalid inputs, passthrough, and component maps | +| Method config | Online/offline selection, ignored layers, platform guards, and weight loading | +| Checkpoint metadata | Auto-detection, method mismatch errors, serialized flags, and per-transformer metadata | +| Model integration | Correct quantization scope, unquantized components, and parallel weight layouts | +| Numerical quality | Same-seed BF16 versus quantized output comparison at representative resolutions and step counts | +| Performance | Peak device memory, generation latency, and any backend-specific throughput metric | + +The unit tests live under `tests/diffusion/quantization/`. The trajectory +similarity tool in +`vllm_omni/quantization/tools/compare_diffusion_trajectory_similarity.py` +provides a repeatable quality and memory comparison for diffusion models. +Method support and feature combinations remain model- and hardware-dependent; +passing factory tests alone does not establish production support. + +## Design invariants + +- `quantization_config` is the single runtime source of truth after config + normalization. +- A checkpoint method mismatch fails loudly instead of risking weight + corruption. +- Components without an explicit supported quantization path remain in their + original precision. +- Optional backend dependencies are imported lazily. +- Method implementations own platform-specific capability checks and kernels. +- User-facing support claims require model, hardware, numerical, and memory + validation in addition to configuration-level tests. diff --git a/docs/design/feature/ray_based_execution.md b/docs/design/feature/ray_based_execution.md deleted file mode 100644 index ae10d661f..000000000 --- a/docs/design/feature/ray_based_execution.md +++ /dev/null @@ -1,59 +0,0 @@ -# Distributed utils - -This directory (vllm_omni/distributed/ray_utils) contains utilities for distributed execution in vllm-omni, supporting both **Ray** and **Multiprocessing** backends. -## 1. Installation -```bash -pip install "ray[default]" -``` -## 2. Ray Utils - -The `ray_utils` module provides helper functions for managing Ray clusters and actors, which is essential for: -* **Multi-node deployment**: Running pipeline stages across different physical machines. -* **Resource management**: Efficient GPU/CPU allocation. - -### 2.1 Basic Usage - -To use the Ray backend, specify `worker_backend="ray"` when initializing the engine. - -**Command Line Example:** -```bash -vllm serve Qwen/Qwen2.5-Omni-7B \ - --omni \ - --port 8091 \ - --worker-backend ray \ - --ray-address auto -``` - -### 2.2 Cluster Setup - -**Step 1: Start Head Node** -Run this on your primary machine: -```bash -ray start --head --port=6399 -``` - -**Step 2: Connect Worker Nodes** -Run this on each worker machine: -```bash -ray start --address=:6399 -``` - -> **Tip**: For a complete cluster setup script, refer to the vLLM example: -> [run_cluster.sh](https://github.com/vllm-project/vllm/blob/main/examples/online_serving/run_cluster.sh) - -### 2.3 Distributed Connector Support - -When running on Ray, the system automatically adapts its communication strategy: - -* **Cross-Node**: Recommended to use `MooncakeTransferEngineConnector` (RDMA, fastest) or `MooncakeStoreConnector` (TCP fallback). -* **Same-Node**: Can still use `SharedMemoryConnector` for efficiency, or Ray's native object store (plasma). -* **SHM threshold default differs**: when `worker_backend="ray"`, the SharedMemoryConnector default threshold is set to `sys.maxsize`, which forces payloads to go inline (no SHM). Override `shm_threshold_bytes` in the connector config if you want SHM for Ray runs. - -### 2.4 Internal Helpers - -* **`initialize_ray_cluster`**: Connects to an existing Ray cluster or starts a local one. - -## 3. Troubleshooting - -* **Connection Issues**: Ensure the Ray head node is accessible and ports (default 6399 in this example) are open. -* **Version Mismatch**: Ensure all nodes run the same version of Ray and Python. diff --git a/docs/design/feature/skip_softmax.md b/docs/design/feature/skip_softmax.md new file mode 100644 index 000000000..8e6375ee8 --- /dev/null +++ b/docs/design/feature/skip_softmax.md @@ -0,0 +1,88 @@ +# Skip-Softmax + +Skip-Softmax is the sparse-attention mode of the `TRTLLM_ATTN` backend. Usage — the config keys +and how to pick an operating point — is in +[Diffusion Attention Backends](../../user_guide/diffusion/attention_backends.md). This page explains +the algorithm. + +## Motivation + +In a long attention row (a video DiT can have tens of thousands of keys), the softmax weight +concentrates on a small fraction of the keys; the rest receive near-zero weight and barely move the +output. Computing softmax and the value-weighted sum over those keys is wasted work. Skip-Softmax +detects, per block of keys, when a block cannot matter and skips its softmax and its value +multiply. + +It is approximate: a skipped block still carries a small non-zero contribution, so the mode is +opt-in and off by default. + +## The online-softmax pass + +Attention is computed in a single streaming pass over the keys, in tiles of 128. Per query row the +kernel maintains three running values: + +- `m` — the largest score seen so far, +- `l` — the running denominator `Σ exp(sⱼ − m)`, +- `O` — the running numerator `Σ exp(sⱼ − m)·vⱼ`, + +and returns `O / l` at the end. For each key tile it computes the tile's scores `Q · K_jᵀ · scale`, +updates `m`, and accumulates that tile's contribution into `l` and `O`. Rescaling `l` and `O` when +`m` grows keeps the single pass numerically exact. + +## The skip test + +Once a tile's scores are known, its largest score `tile_max` is compared against the running +maximum: + +```text +if exp(tile_max − running_max) < threshold: + skip this tile # do not compute its softmax weights or its P·V accumulation +``` + +`exp(tile_max − running_max)` is an upper bound on the softmax weight any key in the tile can +receive: if even the tile's best key is far below the current maximum, every key in the tile is +negligible, and both the softmax (the exponentials) and the `P·V` accumulation for that tile can be +dropped. The tile's contribution to `l` and `O` is simply skipped. + +## What this bounds + +Two properties of the test shape the achievable speedup: + +- **`Q · K_jᵀ` always runs.** The test needs `tile_max`, which comes from the tile's scores, so the + score matmul is never skipped — only the softmax and the `P·V` accumulation are. The score matmul + and the value matmul are comparable in cost, so skipping every eligible tile removes at most + roughly half the attention work; the kernel-level speedup is bounded well under 2×, not unbounded. + +- **The decision is per tile, not per key.** A tile is skipped only when *all* of its keys are + collectively negligible; a single important key keeps the whole tile. How many tiles actually + qualify depends on the data and rounds down to tile granularity, so `target_sparsity` selects an + operating point on a calibrated curve — it is not a promise that a fixed fraction of tiles is + skipped. + +## The threshold + +The per-tile threshold is normalized by sequence length: + +```text +threshold = factor / seqlen +``` + +`factor` comes from one of two sources: + +- **Calibrated** — `factor = a · exp(b · target_sparsity)`, where `a` and `b` are fit per model (and + per expert, for a multi-expert model) so that a given `target_sparsity` lands near that fraction of + skipped tiles on the calibration data. +- **Direct** — `factor = skip_softmax.threshold · seqlen`, the calibration-free path; the user sets + the threshold themselves. + +## Timestep gating + +`disabled_until_timestep = D` keeps the mode off during the early, high-noise denoise steps and +turns it on once the normalized timestep `t` drops to `t ≤ D` (`t` runs `1.0` → `0.0` over the +schedule). The early steps set the global structure of the output and their errors propagate through +every later step, so keeping them dense costs a few skipped-tile opportunities but protects fidelity. + +`t` is the scheduler's own timestep divided by `num_train_timesteps`, published by the pipeline via +`DenoiseProgressMixin.record_denoise_step`. It is deliberately not derived from the step index, +because schedulers space their steps non-uniformly. A pipeline that does not publish a timestep +stays dense when `disabled_until_timestep` is set. diff --git a/docs/design/feature/tensor_parallel.md b/docs/design/feature/tensor_parallel.md index bcafde7e7..a01e2a0e6 100644 --- a/docs/design/feature/tensor_parallel.md +++ b/docs/design/feature/tensor_parallel.md @@ -264,7 +264,7 @@ Complete examples in the codebase: | **Z-Image** | `vllm_omni/diffusion/models/z_image/z_image_transformer.py` | Standard TP | Full implementation with validation | | **FLUX** | `vllm_omni/diffusion/models/flux/flux_transformer.py` | Dual-stream | Image + text streams | | **Qwen-Image** | `vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py` | Standard TP | With RoPE | -| **TP Tests** | `tests/e2e/offline_inference/test_zimage_parallelism.py` | E2E testing | TP correctness and performance | +| **TP Tests** | `tests/diffusion/distributed/test_tensor_parallel.py` | E2E testing | TP correctness and performance | | **Constraint Tests** | `tests/diffusion/models/z_image/test_zimage_tp_constraints.py` | Unit testing | Validation logic | --- diff --git a/docs/design/feature/vae_parallel.md b/docs/design/feature/vae_parallel.md index 00f41378a..c465449f8 100644 --- a/docs/design/feature/vae_parallel.md +++ b/docs/design/feature/vae_parallel.md @@ -445,6 +445,7 @@ Complete examples in the codebase: | **Z-Image** | `vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl.py` | ✅ | ❌ | | **Wan2.2** | `vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py` | ✅ | ✅ | | **Qwen-Image** | `vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_qwenimage.py` | ✅ | ❌ | +| **FLUX.2-dev** | `vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_flux2.py` | ✅ | ✅ | --- diff --git a/docs/design/figures/dlo/dlo_pipeline.gif b/docs/design/figures/dlo/dlo_pipeline.gif new file mode 100644 index 000000000..cc206d03b Binary files /dev/null and b/docs/design/figures/dlo/dlo_pipeline.gif differ diff --git a/docs/design/index.md b/docs/design/index.md index 0a59af4cf..c98af378d 100644 --- a/docs/design/index.md +++ b/docs/design/index.md @@ -1,6 +1,9 @@ # Design Documents -This section contains design documents and architecture specifications for vLLM-Omni. +This section contains design documents and architecture specifications for +vLLM-Omni. The sidebar groups documents by the system concern they describe; +retired module pages are preserved in the legacy archive instead of remaining +in the active navigation. ## Architecture Documents @@ -8,22 +11,63 @@ This section contains design documents and architecture specifications for vLLM- ## Feature Design Documents -- [Disaggregated Inference](feature/disaggregated_inference.md) -- [Ray-based Execution](feature/ray_based_execution.md) -- [Adding Step Execution Support for Diffusion Pipelines](feature/diffusion_step_execution.md) -- [Request-Level Batching for Diffusion](feature/diffusion_request_level_batching.md) -- [Continuous Batching for Step-Wise Diffusion](feature/diffusion_continuous_batching.md) +### Runtime and stage execution -## Infrastructure Design Documents +- [Disaggregated Inference](feature/disaggregated_inference.md) +- [Async Chunk](feature/async_chunk.md) +- [Async Diffusion Output](feature/async_diffusion_output.md) +- [Async Omni Output Materialization](feature/omni_async_output_materialization.md) +- [Automatic Prefix Caching in Omni Models](feature/prefix_caching.md) + +### Communication + +#### OmniConnector implementations + +- [Mooncake Store Connector](feature/omni_connectors/mooncake_store_connector.md) +- [Mooncake Transfer Engine Connector](feature/omni_connectors/mooncake_transfer_engine_connector.md) +- [Mori Transfer Engine Connector](feature/omni_connectors/mori_transfer_engine_connector.md) +- [Shared Memory Connector](feature/omni_connectors/shared_memory_connector.md) +- [Yuanrong Store Connector](feature/omni_connectors/yuanrong_connector.md) +- [Yuanrong Transfer Engine Connector](feature/omni_connectors/yuanrong_transfer_engine_connector.md) + +### Diffusion acceleration + +#### Parallelism + +- [CFG-Parallel](feature/cfg_parallel.md) +- [Expert Parallel](feature/expert_parallel.md) +- [Hybrid Sharded Data Parallel (HSDP)](feature/hsdp.md) +- [Pipeline Parallel](feature/pipeline_parallel.md) +- [Sequence Parallel](feature/sequence_parallel.md) +- [Tensor Parallel](feature/tensor_parallel.md) +- [VAE Patch Parallelism](feature/vae_parallel.md) + +#### Attention Backends + +- [Skip-Softmax](feature/skip_softmax.md) + +#### Quantization + +- [Quantization](feature/quantization.md) + +- [Cache-DiT](feature/cache_dit.md) +- [TeaCache](feature/teacache.md) +- [Diffusion Continuous Batching](feature/diffusion_continuous_batching.md) +- [Distributed Layerwise Offload](feature/distributed_layerwise_offload.md) + +## Infrastructure and Performance - [Prometheus Metrics](metrics.md) +- [Speech Generation Performance Optimizations](qwen3_omni_tts_performance_optimization.md) ## Module Design Documents -- [Entrypoints](module/entrypoints.md) +- [Entrypoints and Serving Boundaries](module/entrypoints.md) - [vLLM-Omni Configuration](module/vllm_omni_config.md) - [Input, Output, and Modality Contracts](module/input_output_modality_contracts.md) +- [Error Classification, Propagation, and Rendering](module/error_contracts.md) - [Engine Orchestration](module/engine_orchestration.md) +- [Stage Runtime and Replica Lifecycle](module/stage_runtime.md) - [OmniConnector](module/omni_connector.md) - [Model Integration](module/model_integration.md) - [Autoregressive Runtime](module/ar_runtime.md) @@ -40,3 +84,7 @@ This section contains design documents and architecture specifications for vLLM- - [Observability](module/observability.md) - [Profiling](module/profiling.md) - [Benchmarking](module/benchmarking.md) + +The pre-#5137 pages are preserved in the +[legacy module archive](module/archive/README.md) for historical reference and +are not active design contracts. diff --git a/docs/design/metrics.md b/docs/design/metrics.md index 09f0907df..6e4946f4e 100644 --- a/docs/design/metrics.md +++ b/docs/design/metrics.md @@ -148,7 +148,13 @@ Upstream vLLM's `Scheduler.make_stats()` runs on every AR generation step, retur `OmniSchedulerMixin.make_stats()` (in `vllm_omni/core/sched/omni_scheduler_mixin.py`) throttles stats emission to at most once per second **per scheduler** — i.e. per `(stage, replica)` since each replica owns its own scheduler instance. Between intervals it returns `None`, which the engine core skips serializing. This keeps gauges fresh enough for Prometheus scrapes (typically 15-30s intervals) while eliminating the per-step overhead. -The orchestrator side does not add its own throttle on top: the per-replica recording loop gates only on `raw_outputs.scheduler_stats is not None` (i.e. this replica's scheduler passed its own 1 Hz gate). A previous global `_last_stats_ts` on the orchestrator was removed because it starved every replica other than the first to emit within each second. +The orchestrator side does not add its own throttle on top. Scheduler gauges are +recorded when `raw_outputs.scheduler_stats is not None` (i.e. this replica's +scheduler passed its own 1 Hz gate), while output-batch request/token metrics +are recorded from `IterationStats` whenever a non-empty output batch is +processed. A previous global `_last_stats_ts` on the orchestrator was removed +because it starved every replica other than the first to emit within each +second. ## Metric Definitions diff --git a/docs/design/module/archive/README.md b/docs/design/module/archive/README.md new file mode 100644 index 000000000..6afc76947 --- /dev/null +++ b/docs/design/module/archive/README.md @@ -0,0 +1,18 @@ +# Archived module design pages + +This directory preserves module-design pages that predate the structure +proposed in [issue #5137](https://github.com/vllm-project/vllm-omni/issues/5137). +They are historical snapshots, not active architectural contracts, and are +intentionally excluded from the documentation navigation. + +| Archived page | Active destination | +| --- | --- | +| [AR Module](ar_module.md) | [Autoregressive Runtime](../ar_runtime.md) | +| [AsyncOmni Architecture](async_omni_architecture.md) | [Engine Orchestration](../engine_orchestration.md) and [Stage Runtime](../stage_runtime.md) | +| [DiT Module](dit_module.md) | [Diffusion module overview](../diffusion/index.md) | +| [Entrypoint Module](entrypoint_module.md) | [Entrypoints and Serving Boundaries](../entrypoints.md) | + +Do not update these pages with current design guidance. Move still-useful +material into the active destination and review it against current code and +tests instead. No compatibility redirect pages are provided at the former +paths. diff --git a/docs/design/module/archive/ar_module.md b/docs/design/module/archive/ar_module.md new file mode 100644 index 000000000..f0085fa42 --- /dev/null +++ b/docs/design/module/archive/ar_module.md @@ -0,0 +1,387 @@ +# AutoRegressive (AR) Module + +## 1. Overview + +The AutoRegressive (AR) module in vLLM-Omni handles autoregressive generation stages, primarily used for text, chain-of-thought(COT), and audio latent tokens generation stages in multi-stage models like Qwen2.5-Omni, Qwen3-Omni, BAGEL, .etc. Unlike some representative non-autoregressive generation stages (e.g., Diffusion), AR stages generate tokens sequentially, one at a time, following the standard transformer decoder pattern. + +The AR module of vLLM-Omni extends vLLM's core components to support: + +- **Multimodal inputs/outputs**: Processing images, videos, and audio alongside text +- **Direct embedding transfer**: Passing pre-computed prompt embeddings between pipeline stages via serialized payloads +- **Additional information flow**: Carrying per-request metadata (tensors, lists) through the pipeline +- **Hidden state exposure**: Exposing per-request hidden representations for downstream stages +- **Basic generator support**: Support some basic heterogeneous architecture such as Convolution, LSTM, etc. + +As shown in the [end2end example](../../../user_guide/examples/offline_inference/qwen3_omni.md), AR module can be widely applied across multiple stages, generating text tokens in thinker(AR), audio latent tokens in talker(AR) and audio wave in code2wav(Convolution). + +## 2. Relationship with vLLM + +The AR module builds upon vLLM main framework through inheritance, extending core classes while preserving compatibility with vLLM's scheduling, batching, KV cache management, and execution mechanisms. + +### Inheritance Hierarchy +- Scheduler + +```mermaid +classDiagram + class VLLMScheduler { + +schedule() SchedulerOutput + +update_from_output() EngineCoreOutputs + } + class OmniARScheduler { + +schedule() SchedulerOutput + } + class OmniGenerationScheduler { + +schedule() SchedulerOutput + +update_from_output() EngineCoreOutputs + } + VLLMScheduler <|-- OmniARScheduler + VLLMScheduler <|-- OmniGenerationScheduler +``` +- Worker + +```mermaid +classDiagram + class GPUWorker { + +init_device() + +model_runner + } + class GPUARWorker { + +init_device() + } + class GPUGenerationWorker { + +init_device() + } + GPUWorker <|-- GPUARWorker + GPUWorker <|-- GPUGenerationWorker +``` +- ModelRunner + +```mermaid +classDiagram + class GPUModelRunner { + +execute_model() + +sample_tokens() + } + class OmniGPUModelRunner { + +_update_states() + +_preprocess() + +_model_forward() + } + class GPUARModelRunner { + +execute_model() + +sample_tokens() + } + class GPUGenerationModelRunner { + +execute_model() + } + GPUModelRunner <|-- OmniGPUModelRunner + OmniGPUModelRunner <|-- GPUARModelRunner + OmniGPUModelRunner <|-- GPUGenerationModelRunner +``` +- InputProcessor/OutputProcessor + +```mermaid +classDiagram + class InputProcessor { + +process_inputs() EngineCoreRequest + } + + class VLLMOutputProcessor { + +process_outputs() OutputProcessorOutput + } + class MultimodalOutputProcessor { + +process_outputs() OutputProcessorOutput + +_route_and_normalize() + } + VLLMOutputProcessor <|-- MultimodalOutputProcessor +``` + +### Key Extensions + +- **Scheduler**: `OmniARScheduler` extends `vllm.v1.core.sched.scheduler.Scheduler` to enrich scheduled requests with omni-specific payloads +- **Worker**: `GPUARWorker` extends `vllm.v1.worker.gpu_worker.Worker` to initialize AR-specific model runners +- **ModelRunner**: `GPUARModelRunner` extends `OmniGPUModelRunner` → `vllm.v1.worker.gpu_model_runner.GPUModelRunner` to expose hidden states and handle multimodal outputs +- **InputProcessor**: Stage-0 uses upstream `vllm.v1.engine.input_processor.InputProcessor`; `AsyncOmniEngine` then restores omni-specific payloads (for example `additional_information` and `prompt_embeds`) when building `OmniEngineCoreRequest` +- **OutputProcessor**: `MultimodalOutputProcessor` extends `vllm.v1.engine.output_processor.OutputProcessor` to route and accumulate multimodal outputs + +## 3. Scheduler Design + +The AR module provides two scheduler implementations: one for standard autoregressive generation and one for basic heterogeneous architectures. + +### Request Flow + +The following diagram illustrates the request flow through the AR module components: + +```mermaid +flowchart TD + A[InputProcessor stage-0 in AsyncOmniEngine] -->|EngineCoreRequest then upgraded to OmniEngineCoreRequest| B[OmniARScheduler] + B -->|schedule: OmniNewRequestData| C[GPUARWorker] + C -->|SchedulerOutput| D[GPUARModelRunner] + D -->|execute_model: None| E[Model Forward Pass] + E -->|hidden_states, logits| D + D -->|sample_tokens: OmniModelRunnerOutput| F[OmniARScheduler] + F -->|update_from_output| G[MultimodalOutputProcessor] + G -->|RequestOutput| H[Client/Downstream Stage] + + style A fill:#e1f5ff + style B fill:#fff4e1 + style C fill:#e8f5e9 + style D fill:#f3e5f5 + style G fill:#fce4ec +``` + +The flow follows vLLM's standard pattern: input processing → scheduling → worker execution → output processing, with omni-specific enrichments at each stage. + +### OmniARScheduler + +`OmniARScheduler` extends the base vLLM scheduler with minimal modifications, focusing on enriching scheduled requests with omni-specific payloads. + +#### Modified API: `schedule()` + +The scheduler wraps base `NewRequestData` entries with `OmniNewRequestData` to include prompt embeddings and additional information: + +```python +def schedule(self) -> SchedulerOutput: + scheduler_output = super().schedule() + # Rewrap base NewRequestData entries with OmniNewRequestData + new_list = [] + for nr in scheduler_output.scheduled_new_reqs: + request = self.requests.get(nr.req_id) + omni_nr = OmniNewRequestData( + req_id=nr.req_id, + prompt_token_ids=nr.prompt_token_ids, + # ... other base fields ... + prompt_embeds=getattr(request, "prompt_embeds", None), + additional_information=getattr(request, "additional_information", None), + ) + new_list.append(omni_nr) + scheduler_output.scheduled_new_reqs = new_list + return scheduler_output +``` + +The `update_from_output()` method remains unchanged, inheriting standard request lifecycle management from the base scheduler. + +### OmniGenerationScheduler + +`OmniGenerationScheduler` implements a fast-path scheduling strategy for basic heterogeneous architectures that process all input tokens in a single step. + +#### Modified API: `schedule()` + +Allocates all input tokens for a request at once (or 1 placeholder if zero), falling back to default scheduling if budget is insufficient: + +```python +def schedule(self) -> SchedulerOutput: + # Fast path: allocate all input tokens at once + while self.waiting and token_budget > 0: + request = self.waiting.peek_request() + required_tokens = max(getattr(request, "num_prompt_tokens", 0), 1) + if required_tokens > token_budget: + break # Fall back to default scheduling + # Allocate and schedule... +``` + +#### Modified API: `update_from_output()` + +Marks requests as finished immediately after one step, since generation models complete in a single forward pass: + +```python +def update_from_output(self, ...) -> dict[int, EngineCoreOutputs]: + # ... + # Diffusion request: completes in one step + request.status = RequestStatus.FINISHED_STOPPED + kv_transfer_params = self._free_request(request) + # ... +``` + +## 4. Worker and ModelRunner Design + +### GPUARWorker + +`GPUARWorker` initializes the AR-specific model runner while maintaining standard device initialization: + +```python +class GPUARWorker(GPUWorker): + def init_device(self): + # ... standard device initialization ... + self.model_runner = GPUARModelRunner(self.vllm_config, self.device) +``` + +### GPUARModelRunner + +`GPUARModelRunner` follows vLLM's two-phase execute/sample flow while exposing hidden states and multimodal outputs. + +#### Two-Phase Execution + +**Phase 1: `execute_model()`** - Runs forward pass and stores state: +- Computes logits from hidden states +- Stores `ExecuteModelState` with hidden states, logits, and multimodal outputs +- Returns `None` to defer sampling + +**Phase 2: `sample_tokens()`** - Samples tokens and builds output: +- Retrieves stored state from `execute_model()` +- Samples tokens using logits +- Extracts per-request hidden states and multimodal outputs +- Builds `OmniModelRunnerOutput` with `pooler_output` containing hidden states + +```python +def sample_tokens(self, grammar_output) -> OmniModelRunnerOutput: + # Retrieve stored state + hidden_states, multimodal_outputs = self.execute_model_state + + # Sample tokens + sampler_output = self._sample(logits, spec_decode_metadata) + + # Extract per-request hidden states + pooler_output = [] + for rid in req_ids: + hidden_slice = hidden_states_cpu[start:end] + payload = {"hidden": hidden_slice} + # Add multimodal outputs if present + pooler_output.append(payload) + + return OmniModelRunnerOutput( + pooler_output=pooler_output, + # ... other fields ... + ) +``` + +### GPUGenerationModelRunner + +`GPUGenerationModelRunner` implements a simplified single-phase execution for basic heterogeneous architectures: + +- No logits computation or token sampling +- Direct generation from forward pass in model implementation +- Returns outputs via `pooler_output` immediately after forward pass + +### OmniGPUModelRunner + +`OmniGPUModelRunner` provides shared functionality for both AR and Generation runners: + +#### Prompt Embeddings Overlay + +During prefill, overlays custom `prompt_embeds` from request state onto `inputs_embeds`: + +```python +def _collect_additional_information_for_prefill(self, num_scheduled_tokens_np): + for req_index, req_id in enumerate(self.input_batch.req_ids): + req_state = self.requests[req_id] + pe_cpu = getattr(req_state, "prompt_embeds_cpu", None) + # Overlay prompt_embeds for prefill portion + if pe_cpu is not None: + src = pe_cpu[num_computed_tokens:num_computed_tokens + overlay_len] + self.inputs_embeds[start_offset:start_offset + overlay_len].copy_(src) +``` + +#### Additional Information Processing + +Decodes and manages `additional_information` payloads: +- Decodes serialized payloads → CPU tensors in request state +- Passes runtime information to model via `runtime_additional_information` kwarg +- Processes model-provided updates via `postprocess()` hook +- Merges updates back into request state + +#### M-RoPE Position Initialization + +For multimodal models using M-RoPE (e.g., Qwen2-VL), computes position encodings from multimodal feature metadata (image grids, video grids, audio features). + +## 5. Input/Output Processing + +### Processing Pipeline + +The input/output processing pipeline handles serialization, routing, and accumulation of multimodal data: + +```mermaid +sequenceDiagram + participant Client + participant AsyncOmniEngine + participant InputProcessor + participant Scheduler + participant ModelRunner + participant MultimodalOutputProcessor + participant Client + + Client->>AsyncOmniEngine: prompt + prompt_embeds + additional_info + AsyncOmniEngine->>InputProcessor: process_inputs() + InputProcessor->>Scheduler: EngineCoreRequest + AsyncOmniEngine->>AsyncOmniEngine: _upgrade_to_omni_request() + serialize_additional_information() + Scheduler->>ModelRunner: OmniNewRequestData (with payloads) + ModelRunner->>ModelRunner: Decode payloads → CPU tensors + ModelRunner->>ModelRunner: Overlay prompt_embeds on inputs_embeds + ModelRunner->>ModelRunner: Forward pass with runtime_additional_information + ModelRunner->>ModelRunner: Extract hidden states + multimodal outputs + ModelRunner->>MultimodalOutputProcessor: OmniModelRunnerOutput (pooler_output) + MultimodalOutputProcessor->>MultimodalOutputProcessor: Route by output_type + MultimodalOutputProcessor->>MultimodalOutputProcessor: Accumulate tensors in OmniRequestState + MultimodalOutputProcessor->>MultimodalOutputProcessor: Consolidate tensor lists + MultimodalOutputProcessor->>Client: RequestOutput (with multimodal_output) +``` + +### Stage-0 Input Processing + +Stage-0 now uses upstream `InputProcessor` directly, and `AsyncOmniEngine` upgrades the request to `OmniEngineCoreRequest` while restoring omni-specific payloads. + +```python +request = self.input_processor.process_inputs( + request_id=request_id, + prompt=prompt, + params=params, + supported_tasks=self.supported_tasks, +) +request = _upgrade_to_omni_request(request, prompt) +``` + +### MultimodalOutputProcessor + +`MultimodalOutputProcessor` routes outputs by modality type and accumulates multimodal tensors. + +#### Output Routing + +Routes `EngineCoreOutput` by `output_type` attribute: +- `"text"`: Standard text generation path +- `"image"`, `"audio"`, `"latents"`: Extract from `pooling_output` or `multimodal_outputs` +- Fallback: Heuristic based on presence of `pooling_output` + +#### Tensor Accumulation + +`OmniRequestState` accumulates multimodal tensors across multiple steps: + +```python +def add_multimodal_tensor(self, payload, mm_type): + # Normalize payload to dict + incoming = {mm_type or "hidden": payload} + + # Accumulate: convert tensors to lists for deferred concatenation + if isinstance(v, torch.Tensor) and isinstance(existing, torch.Tensor): + self.mm_accumulated[k] = [existing, v] # List accumulation +``` + +Before final output, consolidates tensor lists via concatenation: + +```python +def _consolidate_multimodal_tensors(self): + for k, v in self.mm_accumulated.items(): + if isinstance(v, list) and isinstance(v[0], torch.Tensor): + self.mm_accumulated[k] = torch.cat(v, dim=0) # Concatenate +``` + +The consolidated tensors are attached to `RequestOutput.multimodal_output` for consumption by downstream stages or clients. + +## 6. Summary + +The AR module of vLLM-Omni extends vLLM through strategic inheritance and minimal API modifications: + +### Key Design Patterns + +1. **Inheritance over composition**: Extends vLLM classes to preserve compatibility with existing scheduling, batching, and execution mechanisms +2. **Payload serialization**: Uses serialized `additional_information` payloads together with prompt-embedding handoff for efficient inter-stage transfer +3. **Two-phase execution**: Maintains vLLM's execute/sample separation for AR models while supporting single-phase execution for generation models +4. **Multimodal routing**: Routes outputs by `output_type` and accumulates tensors incrementally to support streaming + +### Differences from vLLM + +- **Payload support**: Serialized additional information and prompt embeddings enable direct transfer between pipeline stages +- **Multimodal handling**: Extended input/output processors support images, audio, and other modalities alongside text +- **Hidden state exposure**: AR model runners expose per-request hidden states via `pooler_output` for downstream consumption +- **Generation scheduler**: Fast-path scheduling for basic heterogeneous architectures that complete in one step + +The AR module seamlessly integrates with vLLM's existing infrastructure while adding the necessary extensions for multi-stage, multimodal generation pipelines. diff --git a/docs/design/module/archive/async_omni_architecture.md b/docs/design/module/archive/async_omni_architecture.md new file mode 100644 index 000000000..92b13a3da --- /dev/null +++ b/docs/design/module/archive/async_omni_architecture.md @@ -0,0 +1,203 @@ +# AsyncOmni Architecture (Qwen3-Omni Example) + +## 1. System Architecture + +```text +• ┌─────────────────────────────────────────────────────────────────────────────────┐ + │ API Layer │ + │ ┌─────────────────────────────────────┐ ┌──────────────────────────────────┐ │ + │ │ AsyncOmni (EngineClient) │ │ Omni │ │ + │ │ • generate() / abort() / shutdown() │ │ • generate() │ │ + │ │ • _final_output_handler() │ │ | │ + │ └─────────────────────────────────────┘ └──────────────────────────────────┘ │ + ├─────────────────────────────────────────────────────────────────────────────────┤ + │ Engine Layer (Proxy) │ + │ ┌───────────────────────────────────────────────────────────────────────────┐ │ + │ │ AsyncOmniEngine │ │ + │ │ • _bootstrap_orchestrator() & _initialize_stages() │ │ + │ │ • add_request() / add_request_async() -> input_processor.process_inputs() │ │ + │ │ • try_get_output() / try_get_output_async() │ │ + │ └───────────────────┬─────────────────────────────────▲─────────────────────┘ │ + │ request_queue (janus.Queue) output_queue (janus.Queue) │ + ├──────────────────────┼─────────────────────────────────┼────────────────────────┤ + │ ▼ Orchestration Layer │ │ + │ ┌───────────────────────────────────────────────────────────────────────────┐ │ + │ │ Orchestrator [background thread] │ │ + │ │ • _request_handler() │ │ + │ │ - stage_client.add_request_async() & _prewarm_async_chunk_stages() │ │ + │ │ • _orchestration_output_handler() │ │ + │ │ - _process_stage_outputs() -> output_processors[i].process_outputs() │ │ + │ │ - _route_output() & _forward_to_next_stage() │ │ + │ └──────────┬─────────────────────────┬────────────────────────┬─────────────┘ │ + ├─────────────┼─────────────────────────┼────────────────────────┼────────────────┤ + │ │ Communication Layer │ │ + │ ┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐ │ + │ │ StageEngineCoreClient │ │ StageEngineCoreClient │ │ StageDiffusionClient │ │ + │ │ • ZMQ ROUTER / PULL │ │ • ZMQ ROUTER / PULL │ │ • ZMQ ROUTER / PULL │ │ + │ │ • Msgpack codec │ │ • Msgpack codec │ │ • Msgpack codec │ │ + │ └──────────┬────────────┘ └──────────┬────────────┘ └──────────┬────────────┘ │ + │ ▼ ZMQ IPC ▼ ZMQ IPC ▼ ZMQ IPC │ + ├─────────────────────────────────────────────────────────────────────────────────┤ + │ Execution Layer │ + │ ┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐ │ + │ │ StageCoreProc │ │ StageCoreProc │ │ DiffusionEngine │ │ + │ │ [background process] │ │ [background process] │ │ [background process] │ │ + │ └───────────────────────┘ └───────────────────────┘ └───────────────────────┘ │ + └─────────────────────────────────────────────────────────────────────────────────┘ +``` + +## 2. Execution Flow (Arrow Steps, one generate request) + +```text +[1] App + -> AsyncOmni.generate(prompt, request_id) + +[2] AsyncOmni + -> _final_output_handler() (started on first request) + -> AsyncOmniEngine.add_request(stage_id=0, ...) + +[3] AsyncOmniEngine.add_request + -> (if stage-0 is llm and input is not EngineCoreRequest) + InputProcessor.process_inputs() + OutputProcessor[0].add_request() + -> request_queue.put(add_request_msg) + +[4] Orchestrator._request_handler + -> _handle_add_request(msg) + -> stage_clients[0].add_request_async(...) + +[5] Orchestrator._orchestration_loop (loop) + -> poll stage output + - llm stage: await get_output_async() + - diffusion stage: get_diffusion_output_nowait() + -> (llm stage) output_processors[i].process_outputs(...) + -> _route_output(...) + -> if finished and not final_stage and non-async-chunk: + _forward_to_next_stage(...) + -> next_stage.add_request_async(...) + -> output_queue.put(output) + +[6] AsyncOmni._final_output_loop (background coroutine) + -> AsyncOmniEngine.try_get_output_async() + -> route by request_id to ClientRequestState.queue + +[7] AsyncOmni._process_orchestrator_results + -> read from ClientRequestState.queue + -> _process_single_result(...) + -> yield OmniRequestOutput + +[8] Exit condition + -> receive result["finished"] == True + -> generate() ends +``` + +## 3. Runtime Sequence (one generate request) + +```mermaid +sequenceDiagram + participant APP as App + participant AO as AsyncOmni + participant ENG as AsyncOmniEngine + participant ORCH as Orchestrator + participant S0 as Stage-0 Client + participant SN as Next Stage Client + + APP->>AO: generate + AO->>AO: start output_handler once + AO->>ENG: add_request(stage_id=0, ...) + ENG->>ENG: input_processor.process_inputs() + ENG->>ORCH: request_queue.put(add_request) + + ORCH->>ORCH: _handle_add_request + ORCH->>S0: add_request_async + + loop poll route forward + ORCH->>S0: get_output_async / get_diffusion_output_nowait + ORCH->>ORCH: _route_output + alt need forward to next stage + ORCH->>SN: add_request_async + end + ORCH-->>ENG: output_queue.put + end + + AO->>ENG: try_get_output_async + ENG-->>AO: message + AO-->>APP: yield OmniRequestOutput +``` + +## 4. Comparison + +Previous topology (reference): + +```text +┌────────────────────────────────────────────────────────────────────────────┐ +│ Main Process │ +│ ┌──────────────────────┐ ┌────────────────────────────────────────────┐ │ +│ │ generate() │ │ final_output_handler() │ │ +│ └──────────────────────┘ └────────────────────────────────────────────┘ │ +└──────────┬─────────────────────────┬─────────────────────────┬─────────────┘ + mp.Queue (in_q/out_q) mp.Queue (in_q/out_q) mp.Queue (in_q/out_q) + ▼▲ ▼▲ ▼▲ +┌───────────────────────┐ ┌───────────────────────┐ ┌──────────────────────┐ +│ Worker Proc-0 │ │ Worker Proc-1 │ │ Worker Proc-2 │ +│ (Thinker LLM) │ │ (Talker LLM) │ │ (Vocoder) │ +│ ┌────────────────┐ │ │ ┌────────────────┐ │ │ ┌────────────────┐ │ +│ │_stage_worker │ │ │ │_stage_worker │ │ │ │_stage_worker │ │ +│ │_async() │ │ │ │_async() │ │ │ │_async() │ │ +│ └────────────────┘ │ │ └────────────────┘ │ │ └────────────────┘ │ +│ ┌────────────────┐ │ │ ┌────────────────┐ │ │ ┌────────────────┐ │ +│ │output_handler()│ │ │ │output_handler()│ │ │ │output_handler()│ │ +│ └────────────────┘ │ │ └────────────────┘ │ │ └────────────────┘ │ +└──────────┬────────────┘ └──────────┬────────────┘ └──────────┬───────────┘ + ZMQ ▼ ▲ ZMQ ZMQ ▼ ▲ ZMQ ZMQ ▼ ▲ ZMQ +┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ +│ EngineCore Proc-0 │ │ EngineCore Proc-1 │ │ EngineCore Proc-2 │ +│ (Thinker) │ │ (Talker) │ │ (Vocoder) │ +└──────────────────────┘ └──────────────────────┘ └──────────────────────┘ +``` + +Current topology: + +```text +┌────────────────────────────────────────────────────────────────────────────┐ +│ Main Process │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ Main Thread │ │ +│ │ ┌──────────────────────┐ ┌─────────────────────────────────────┐ │ │ +│ │ │ generate() │ │ final_output_handler() │ │ │ +│ │ └──────────────────────┘ └─────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ janus.Queue (request_queue) ▼ ▲ janus.Queue (output_queue) │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ Orchestrator Thread │ │ +│ │ ┌──────────────────────┐ ┌──────────────────────────────────────┐ │ │ +│ │ │ _request_handler() │ │ _orchestration_output_handler() │ │ │ +│ │ └──────────────────────┘ └──────────────────────────────────────┘ │ │ +│ │ ┌────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ _orchestration_loop(): poll/process/route outputs for all stages│ │ │ +│ │ └────────────────────────────────────────────────────────────────┘ │ │ +│ └───────┬─────────────────────────┬─────────────────────────┬──────────┘ │ +└──────────┬─────────────────────────┬─────────────────────────┬─────────────┘ + ZMQ ▼ ▲ ZMQ ZMQ ▼ ▲ ZMQ ZMQ ▼ ▲ ZMQ + ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ + │ EngineCore Proc-0 │ │ EngineCore Proc-1 │ │ EngineCore Proc-2 │ + │ (Thinker) │ │ (Talker) │ │ (Vocoder) │ + └──────────────────────┘ └──────────────────────┘ └──────────────────────┘ +``` + + +Test scripts: +```bash +# enter offline inference folder. +cd examples/offline_inference/qwen2_5_omni +python end2end.py --output-dir output_audio --query-type use_mixed_modalities + +cd ../qwen3_omni +python end2end.py --output-dir output_audio --query-type text --async-chunk --enable-stats + +cd ../bagel +python end2end.py --prompts "A cute cat" + +cd ../text_to_image +python text_to_image.py --prompt "a cup of coffee on the table" --output output.png +``` diff --git a/docs/design/module/archive/dit_module.md b/docs/design/module/archive/dit_module.md new file mode 100644 index 000000000..f505d1e67 --- /dev/null +++ b/docs/design/module/archive/dit_module.md @@ -0,0 +1,978 @@ +--- +toc_depth: 4 +--- + +# Diffusion Module Architecture Design + +The vLLM-Omni diffusion module (`vllm_omni/diffusion`) is a high-performance inference engine for diffusion models, designed with a modular architecture that separates concerns across multiple components. It provides efficient execution for non-autoregressive generation tasks such as image and video generation. + +This document describes the architecture design of the diffusion module, including the diffusion engine, scheduler, worker, diffusion pipeline, and acceleration components. + +

+ vLLM-Omni Diffusion Module Components +

+

+ Main Components of the Diffusion Module +

+ + +**Table of Content:** + +- [Architecture Overview](#architecture-overview) +- [Diffusion Engine](#1-diffusion-engine) +- [Scheduler](#2-scheduler) +- [Worker](#3-worker) +- [Diffusion Pipeline](#4-diffusion-pipeline) +- [Acceleration Components](#5-acceleration-components) + - [Attention Backends](#51-attention-backends) + - [Parallel Attention](#52-parallel-attention) + - [Cache Backends](#53-cache-backends) + - [Parallel Strategies](#54-parallel-strategies) +- [Data Flow](#6-data-flow) + +--- + +## Architecture Overview + +The diffusion module follows a **multi-process, distributed architecture** with clear separation of concerns: + +

+ vLLM-Omni Diffusion Module Architecture +

+

+ Diffusion Architecture Overview +

+ + +--- + +## 1. Diffusion Engine + +**Location**: `vllm_omni/diffusion/diffusion_engine.py` + +### Responsibilities + +The `DiffusionEngine` is the **orchestrator** of the diffusion inference system. It owns request admission, scheduler/executor coordination, output-stream delivery, cancellation cleanup, and startup warmup. + +### Key Components + +#### 1.1 Initialization + +```python +class DiffusionEngine: + def __init__( + self, + od_config: OmniDiffusionConfig, + scheduler: SchedulerInterface | None = None, + ): + self.od_config = od_config + self._init_process_hooks(od_config) + self.execution_mode = self._resolve_execution_mode(od_config) + self._init_executor(od_config) + self._init_scheduler(od_config, scheduler) + self._init_runtime_state() + self._init_execute_fn() +``` + +**Key Features**: + +- **Pre/Post Processing**: Registers model-specific pre-processing and post-processing functions via registry pattern + +- **Execution Mode Selection**: Selects one of two execution modes: + - `REQUEST_BATCH`: complete requests are scheduled through request-level execution. `max_num_seqs=1` is the serial request path; values above `1` allow compatible requests to use fused `pipeline.forward(batch)` when the pipeline supports it. + - `STEP_BATCH`: requests are advanced one denoising step at a time through `prepare_encode()`, `denoise_step()`, `step_scheduler()`, and `post_decode()`. `max_num_seqs` controls the maximum number of compatible active requests in one step wave. + +- **Scheduler Selection**: Uses `StepScheduler` for step-batch execution and `RequestScheduler` for request-batch execution. Tests and custom integrations may inject a `SchedulerInterface` instance explicitly. + +- **Executor Selection**: Builds the configured `DiffusionExecutor`, then routes scheduled work through `execute_batch` or `execute_step` according to the execution mode. `execute_batch` handles the serial `max_num_seqs=1` request path internally. + +- **Factory-Owned Warmup**: `DiffusionEngine.make_engine()` constructs the selected engine backend and then runs startup warmup through `run_startup_warmup()`. + +#### 1.2 Execution Modes + +```python +class DiffusionExecutionMode(str, Enum): + REQUEST_BATCH = "request_batch" + STEP_BATCH = "step_batch" +``` + +`streaming_output=True` requires step execution. If streaming is enabled without `step_execution=True`, the engine enables step execution so the output stream can receive intermediate chunks or the final step output. + +#### 1.3 Output Stream Lifecycle + +```python +request_id = engine.add_request(request) +async for output in engine.get_output_stream(request_id): + yield output +``` + +**Design Decisions**: + +- **Unified Stream Semantics**: `step_streaming()` is the public async engine path. Streaming callers forward every yielded output. Non-streaming callers consume the same stream to completion and return only the final output. + +- **Queue Delivery**: Each admitted request gets an `asyncio.Queue[DiffusionOutput]`. The busy loop emits chunks/final outputs into that queue. + +- **Cancellation Cleanup**: If a client disconnects or cancels stream consumption, `get_output_stream()` removes only the queue. Scheduler terminal state is still finalized when the scheduler reports the request as finished, so request IDs do not remain active indefinitely. + +#### 1.4 Request Processing Flow + +```python +async def step_streaming(self, request: OmniDiffusionRequest): + # 1. Pre-process requests + request = self.pre_process_func(request) + + # 2. Add request and consume the unified output stream + async for output in self.async_add_req_and_stream_response(request): + # 3. Post-process each delivered output + yield self.postprocess_output(request, output, ...) +``` + +**Flow**: + +1. **Pre-processing**: Applies model-specific transformations + +2. **Scheduling**: Delegates request state transitions to the selected scheduler + +3. **Execution**: Calls the selected executor function for request, request-batch, or step execution + +4. **Output Delivery**: Emits intermediate chunks and final outputs through the request output stream + +5. **Post-processing**: Converts raw outputs to final format (e.g., PIL images) + +--- + +## 2. Scheduler + +**Location**: `vllm_omni/diffusion/sched/` + +### Architecture + +The scheduler is a **request-state scheduler**. It owns request lifecycle management and scheduling decisions, while execution stays in `DiffusionEngine` and the executor. + +### Key Components + +#### 2.1 Request State Model + +```python +class DiffusionRequestStatus(enum.IntEnum): + WAITING = ... + RUNNING = ... + PREEMPTED = ... + FINISHED_COMPLETED = ... + FINISHED_ABORTED = ... + FINISHED_ERROR = ... + +@dataclass +class SchedulerRequestState: + request_id: str + req: OmniDiffusionRequest + sampling_params_key: StepBatchSamplingParamsKey | RequestBatchSamplingParamsKey | None = None + status: DiffusionRequestStatus = DiffusionRequestStatus.WAITING + error: str | None = None +``` + +**Design Features**: + +- **Explicit lifecycle**: Requests move through waiting, running, optional preemption, and terminal states. + +- **Centralized error handling**: Completion, abort, and error states are all normalized in the scheduler layer. + +#### 2.2 Shared Bookkeeping in `BaseScheduler` + +```python +class BaseScheduler: + def __init__(self) -> None: + self._request_states = {} + self._waiting = deque() + self._running = [] + self._finished_req_ids = set() + self.max_num_running_reqs = 1 + + def add_request(self, request: OmniDiffusionRequest) -> str: ... + def schedule(self) -> DiffusionSchedulerOutput: ... + def update_from_output( + self, + sched_output: DiffusionSchedulerOutput, + output: BaseRunnerOutput, + ) -> set[str]: ... +``` + +**Responsibilities**: + +- **Lifecycle contract**: Defines how the engine adds requests, triggers one scheduling cycle, and feeds executor results back. + +- **Stable boundary**: `DiffusionSchedulerOutput` is the only scheduling result consumed by `DiffusionEngine`. + +- **Pluggability**: Different scheduler policies can reuse the same engine integration path. + +**Design Features**: + +- **Common state storage**: Shared request maps and waiting/running sets live in the base class. + +- **Shared cleanup logic**: Duplicate-request checks, finish handling, and state removal are centralized instead of + duplicated in each policy. + +- **Current constraint boundary**: `BaseScheduler` derives `max_num_running_reqs` from `max_num_seqs`. Request-mode diffusion can use that capacity for compatible independent requests when the configured pipeline declares `supports_request_batch = True`; step-wise diffusion uses the same scheduler capacity for compatible step batches. + +#### 2.3 Current `RequestScheduler` Policy + +```python +class RequestScheduler(BaseScheduler): + def schedule(self) -> DiffusionSchedulerOutput: + # 1. keep existing RUNNING requests in the scheduling result + # 2. pull WAITING requests while capacity remains + # 3. move newly admitted requests into RUNNING +``` + +**Behavior**: + +- **FIFO request scheduling**: Waiting requests are promoted in queue order. + +- **Compatible request admission**: `RequestScheduler` admits waiting requests while capacity remains and the request's `RequestBatchSamplingParamsKey` is compatible with the active batch. Request-mode execution keeps each logical request independent, while batch-capable pipelines receive the scheduled requests as a runner-side `DiffusionRequestBatch`. + +- **Executor result feedback**: `update_from_output()` converts executor output into `FINISHED_COMPLETED` or `FINISHED_ERROR` and returns finished request ids. + +#### 2.4 Engine-Driven Execution Loop + +```python +request_id = scheduler.add_request(request) +while True: + sched_output = scheduler.schedule() + output = executor.add_req(req) + finished_req_ids = scheduler.update_from_output(sched_output, output) +``` + +**Design Decisions**: + +- **Separation of concerns**: Scheduler manages state and policy; executor handles runtime execution. + +- **No scheduler-owned IPC**: Scheduler no longer talks to workers directly. + +- **Split concurrency model**: Request-mode diffusion can schedule a static + batch of compatible independent requests for one full pipeline forward when + the pipeline supports request-level batching; the step-wise path can also + admit or remove compatible requests between denoise steps. See + [Diffusion Continuous Batching](../../feature/diffusion_continuous_batching.md). + +--- + +## 3. Worker + +**Location**: `vllm_omni/diffusion/worker/gpu_worker.py` + +### Architecture + +Workers are **independent processes** that execute the actual model inference. Each worker runs on a dedicated GPU and participates in distributed inference. + +### Key Components + +#### 3.1 Worker Process Structure + +```python +class WorkerProc: + def __init__(self, od_config, gpu_id, broadcast_handle): + # Initialize ZMQ context for IPC + self.context = zmq.Context(io_threads=2) + + # Connect to broadcast queue (receive requests) + self.mq = MessageQueue.create_from_handle(broadcast_handle, gpu_id) + + # Create result queue (only rank 0) + if gpu_id == 0: + self.result_mq = MessageQueue(n_reader=1, ...) + + # Initialize GPU worker + self.worker = self._create_worker(gpu_id, od_config, ...) +``` + +**Initialization Steps**: + +1. **IPC Setup**: Creates ZMQ context and message queues + +2. **Distributed Environment Setup**: Initializes PyTorch distributed communication + + - For CUDA GPUs: Uses NCCL (fast GPU communication) + + - For NPU: Uses HCCL (Huawei Collective Communications Library) + + - For other devices: Uses appropriate backend (GLOO, MCCL, etc.) + +3. **Model Loading**: Loads diffusion pipeline on assigned GPU + +4. **Cache Setup**: Enables cache backend if configured. + +#### 3.2 GPU Worker + +```python +class DiffusionWorker: + def init_device(self): + # Set distributed environment variables + os.environ["RANK"] = str(rank) + os.environ["WORLD_SIZE"] = str(world_size) + + # Select the device and initialize distributed execution + self.device = current_omni_platform.get_torch_device(rank) + current_omni_platform.set_device(self.device) + self.vllm_config = _create_diffusion_worker_vllm_config( + self.device, self.od_config + ) + init_distributed_environment(world_size, rank) + parallel_config = self.od_config.parallel_config + initialize_model_parallel( + data_parallel_size=parallel_config.data_parallel_size, + cfg_parallel_size=parallel_config.cfg_parallel_size, + sequence_parallel_size=parallel_config.sequence_parallel_size, + tensor_parallel_size=parallel_config.tensor_parallel_size, + pipeline_parallel_size=parallel_config.pipeline_parallel_size, + ) + + def load_model(self, load_format="default", custom_pipeline_name=None): + self.model_runner.load_model( + load_format=load_format, + custom_pipeline_name=custom_pipeline_name, + ) + + def init_lora_manager(self): + self.lora_manager = DiffusionLoRAManager( + pipeline=self.model_runner.pipeline, + device=self.device, + dtype=self.od_config.dtype, + ) +``` + +**Key Features**: + +- **Tensor Parallelism**: Supports multi-GPU tensor parallelism via PyTorch distributed + +- **Model Loading**: Uses `DiffusersPipelineLoader` for efficient weight loading + +- **Cache Integration**: Enables cache backends (TeaCache, cache-dit, etc.) transparently + +#### 3.3 Worker Busy Loop + +```python +def _worker_busy_loop(self): + while self._running: + # 1. Receive unified message (generation request, RPC request, or shutdown) + msg = self.mq.dequeue(timeout=1.0) + + # 2. Route message based on type + if isinstance(msg, dict) and msg.get("type") == "rpc": + # Handle RPC request + result, should_reply = self._execute_rpc(msg) + if should_reply: + self._return_result(result) + + elif isinstance(msg, dict) and msg.get("type") == "shutdown": + # Handle shutdown message + self._running = False + + else: + # Handle generation request (OmniDiffusionRequest list) + output = self.worker.execute_model(msg, self.od_config) + self._return_result(output) +``` + +**Execution Flow**: + +1. **Receive**: Dequeues unified messages from shared memory queue + +2. **Route**: Handles different message types (generation, RPC, shutdown) + +3. **Execute**: Runs forward pass through pipeline for generation requests + +4. **Respond**: Sends results back (rank 0 for generation, specified rank for RPC) + +#### 3.4 Model Execution + +```python +@torch.inference_mode() +def execute_model(self, reqs: list[OmniDiffusionRequest], od_config): + req = reqs[0] # TODO: support batching + + # Refresh cache backend if enabled + if self.cache_backend is not None and self.cache_backend.is_enabled(): + self.cache_backend.refresh(self.pipeline, req.num_inference_steps) + + # Set forward context for parallelism + with set_forward_context( + vllm_config=self.vllm_config, + omni_diffusion_config=self.od_config + ): + output = self.pipeline.forward(req) + return output +``` + +The model execution leverages multiple parallelism strategies that are transparently applied during the forward pass. The `set_forward_context()` context manager makes parallel group information available throughout the forward pass: + +```python +# Inside transformer layers, parallel groups are accessed via: +from vllm_omni.diffusion.distributed.parallel_state import ( + get_sp_group, get_dp_group, get_cfg_group, get_pp_group +) +``` + +**Optimizations**: + +- **Cache Refresh**: Clears cache state before each generation for clean state + +- **Context Management**: Forward context ensures parallel groups are available during execution + +- **Single Request**: Currently processes one request at a time (batching TODO) + +--- + +## 4. Diffusion Pipeline + +**Location**: `vllm_omni/diffusion/models/*/pipeline_*.py` + +The pipeline is the **model-specific implementation** that orchestrates the diffusion process. Different models (QwenImage, Wan2.2, Z-Image) have their own pipeline implementations. + +Most pipeline implementation are referred from `diffusers`. The multi-step diffusion loop is usually the most time-consuming part during the overall inference process, which is defined by the `diffuse` function in the pipeline class. An example is as follows: + +```python +def diffuse(self, ...): + for i, t in enumerate(timesteps): + # Forward pass for positive prompt + transformer_kwargs = { + "hidden_states": latents, + "timestep": timestep / 1000, + "encoder_hidden_states": prompt_embeds, + } + noise_pred = self.transformer(**transformer_kwargs)[0] + + # Forward pass for negative prompt (CFG) + if do_true_cfg: + neg_transformer_kwargs = {...} + neg_transformer_kwargs["cache_branch"] = "negative" + neg_noise_pred = self.transformer(**neg_transformer_kwargs)[0] + + # Combine predictions + comb_pred = neg_noise_pred + true_cfg_scale * (noise_pred - neg_noise_pred) + noise_pred = comb_pred * (cond_norm / noise_norm) + + # Scheduler step + latents = self.scheduler.step(noise_pred, t, latents)[0] + + return latents +``` + +**Key Features**: + +- **CFG Support**: Handles classifier-free guidance with separate forward passes + +- **Cache Branching**: Uses `cache_branch` parameter for cache-aware execution + +- **True CFG**: Implements advanced CFG with norm preservation + +To learn more about the diffusion pipeline and how to add a new diffusion pipeline, please view [Adding Diffusion Model](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/model/adding_diffusion_model) + +--- + +## 5. Acceleration Components + +### 5.1 Attention Backends + +**Location**: `vllm_omni/diffusion/attention/` + +#### Architecture + +The attention system uses a **role-aware backend selector pattern**. Each `Attention` site declares a semantic `role` (`"self"`, `"cross"`, or a model-specific string). The selector consults the user's `AttentionConfig` to pick a backend per role and falls back to the platform default when nothing is configured. + +#### Backend Selection + +**Location**: `vllm_omni/diffusion/attention/selector.py` + +```python +class Attention(nn.Module): + def __init__(self, num_heads, head_size, causal, softmax_scale, *, + role="self", role_category=None, ...): + # Resolve backend for this role from the active OmniDiffusionConfig + config = get_current_diffusion_config_or_none() + attention_config = config.diffusion_attention_config if config is not None else None + attn_backend_cls, spec = get_attn_backend_for_role( + role=role, + head_size=head_size, + attention_config=attention_config, + role_category=role_category, + ) + self.attn_impl_cls = attn_backend_cls.get_impl_cls() + self.attention = self.attn_impl_cls(..., backend_kwargs=spec.backend_kwargs() if spec else None) +``` + +**Available Backends**: + +- **FlashAttention** (`FLASH_ATTN`): Memory-efficient kernel that dispatches to FA2/FA3 on CUDA, AITER on ROCm, and `mindiesd` on Ascend NPU. + +- **SDPA** (`TORCH_SDPA`): PyTorch's scaled dot-product attention — cross-platform fallback. + +- **SageAttention** (`SAGE_ATTN`): Quantized attention implementation from the SageAttention library. + +These backends provide the **kernel implementations** for attention computation. For attention-level sequence parallelism strategies (Ring Attention, Ulysses), see [Parallel Attention](#52-parallel-attention). + +#### Backend Selection Mechanism + +Selection is driven by `OmniDiffusionConfig.diffusion_attention_config`, which carries a global `default` plus a `per_role` map of `AttentionSpec` entries. A role string is resolved in this order: + +```python +def get_attn_backend_for_role(role, head_size, attention_config=None, role_category=None): + # 1. attention_config.per_role[role] — exact match + # 2. attention_config.per_role[role_category] — category fallback + # 3. attention_config.default — global default + # 4. Platform default — hardware-specific + if attention_config is not None: + spec, source = attention_config.resolve_with_source( + role=role, role_category=role_category, + ) + if spec is not None: + return load_backend(spec.backend), spec + return current_omni_platform.get_diffusion_attn_backend_cls(...), None +``` + +**Selection Priority**: + +1. **Per-role override** (`--diffusion-attention-config.per_role..backend`) — finest control; matched on the layer's exact `role` string. + +2. **Role category fallback** (`--diffusion-attention-config.per_role..backend`) — used when an exact match is missing and the layer declared `role_category` (e.g. a `"mymodel.audio_to_video"` site can fall back to `"cross"`). + +3. **Global default** (`--diffusion-attention-backend FLASH_ATTN`, or `--diffusion-attention-config.default.backend`, or `DIFFUSION_ATTENTION_BACKEND` env var). + +4. **Platform default** — `current_omni_platform.get_diffusion_attn_backend_cls(...)` picks the best-available kernel for the hardware. + +A backend that needs configuration exposes it as a typed field on `AttentionSpec`; `AttentionSpec.backend_kwargs()` serializes it into the `backend_kwargs` the impl consumes. Today those fields are `skip_softmax` (TRTLLM_ATTN) and `block_sparse` (any block-sparse backend, currently RAINFUSION_ATTN), so e.g. `target_sparsity` or `sparsity` can be set without changing model code. + +For the user-facing CLI surface, see [Diffusion Attention Backends](../../../user_guide/diffusion/attention_backends.md). For the role declaration contract on the model side, see [Adding a Diffusion Model](../../../contributing/model/adding_diffusion_model.md). + +**Backend Availability**: + +- **SDPA**: Always available (PyTorch built-in) + +- **FlashAttention**: Requires `flash-attn` on CUDA, `aiter` on ROCm, or `mindiesd` on Ascend NPU + +- **SageAttention**: Requires `sage-attention` package (from THU-ML GitHub) + +#### Attention Backend Registry + +**Location**: `vllm_omni/diffusion/attention/selector.py` + `vllm_omni/platforms//platform.py` + +Backend resolution is delegated to the active platform: `current_omni_platform.get_diffusion_attn_backend_cls(selected_backend, head_size)` returns a fully-qualified class path that `_load_backend_cls` imports lazily, with the result cached by `(backend_name, head_size)`. + +```python +@cache +def _cached_get_backend_cls(backend_name: str | None, head_size: int) -> type[AttentionBackend]: + backend_cls_path = current_omni_platform.get_diffusion_attn_backend_cls( + selected_backend=backend_name, + head_size=head_size, + ) + return _load_backend_cls(backend_cls_path) +``` + +Each platform (`cuda`, `rocm`, `xpu`, `musa`, `npu`) maps backend names like `"FLASH_ATTN"`, `"TORCH_SDPA"`, `"SAGE_ATTN"` to the right backend class path for that hardware, including head-size compatibility checks. Passing `selected_backend=None` lets the platform pick its own default. + +#### Attention Backend Integration + +The `Attention` layer integrates backends through a unified interface. Here's how **FlashAttentionBackend** is integrated as an example: + +```python +# attention/backends/flash_attn.py + +class FlashAttentionBackend(AttentionBackend): + @staticmethod + def get_name() -> str: + return "FLASH_ATTN" + + @staticmethod + def get_impl_cls() -> type["FlashAttentionImpl"]: + return FlashAttentionImpl + + @staticmethod + def get_supported_head_sizes() -> list[int]: + return [64, 96, 128, 192, 256] # FlashAttention supports these head sizes + + +class FlashAttentionImpl(AttentionImpl): + def __init__(self, num_heads, head_size, softmax_scale, causal, ...): + self.num_heads = num_heads + self.causal = causal + self.softmax_scale = softmax_scale + + def forward(self, query, key, value, attn_metadata=None): + # Call FlashAttention kernel + out = flash_attn_func( + query, key, value, + causal=self.causal, + softmax_scale=self.softmax_scale, + ) + return out +``` + +--- + +### 5.2 Parallel Attention + +**Location**: `vllm_omni/diffusion/attention/parallel/` + +#### Architecture + +Parallel attention strategies implement **Sequence Parallelism (SP) at the attention layer level**. These strategies distribute attention computation across multiple GPUs by splitting the sequence dimension, using different communication patterns. They work **on top of** AttentionBackend implementations (FlashAttention, SDPA, etc.), handling the parallelization/communication while the backends handle the actual attention computation. + +**Key Distinction**: Unlike AttentionBackend (which provides kernel implementations), ParallelAttentionStrategy provides communication patterns for multi-GPU attention parallelism. These strategies implement the `ParallelAttentionStrategy` interface and use AttentionBackend implementations internally. + +Both Ring Attention and Ulysses are forms of Sequence Parallelism (SP) that: + +- Split the sequence dimension across GPUs + +- Contribute to `sequence_parallel_size` (via `ring_degree` and `ulysses_degree`) + +- Work at the attention layer level (not model/pipeline level) + +#### Ulysses Sequence Parallelism (USP) + +**Location**: `vllm_omni/diffusion/attention/parallel/ulysses.py` + +USP is a sequence-parallel attention strategy that splits attention computation across multiple GPUs by distributing both the sequence dimension and attention heads. It uses **all-to-all communication** to efficiently parallelize attention for very long sequences. Specifically, it uses **all-to-all** collective operations to redistribute Q/K/V tensors before attention computation and gather results afterward. + +Ulysses splits attention computation in two dimensions: + +1. **Sequence Dimension**: Splits the sequence length across GPUs + +2. **Head Dimension**: Splits attention heads across GPUs + +**Configuration**: `ulysses_degree` contributes to `sequence_parallel_size` + +#### Ring Sequence Parallelism + +**Location**: `vllm_omni/diffusion/attention/parallel/ring.py` + +Ring Attention is a **parallel attention strategy** that implements sequence parallelism using ring-based point-to-point (P2P) communication. Unlike attention backends that provide the attention kernel implementation, Ring Attention is a **communication pattern** that works on top of attention backends (FlashAttention or SDPA). + +Ring Attention splits sequence dimension across GPUs in a ring topology, implemented via the `ParallelAttentionStrategy` interface, instead of `AttentionBackend`. P2P ring communication is applied to circulate Key/Value blocks across GPUs. Internally, `ring_flash_attn_func` or `ring_pytorch_attn_func` is used depending on available backends. + +**Architecture**: +```python +class RingParallelAttention: + """Ring sequence-parallel strategy.""" + + def run_attention(self, query, key, value, attn_metadata, ...): + # Selects underlying attention kernel (FlashAttention or SDPA) + if backend_pref == "sdpa": + return ring_pytorch_attn_func(...) # Uses SDPA kernel + else: + return ring_flash_attn_func(...) # Uses FlashAttention kernel +``` + +**Integration**: + +- Ring Attention is activated when `ring_degree > 1` in parallel config + +- It's selected by `build_parallel_attention_strategy()` in the attention layer + +- The `Attention` layer routes to `_run_ring_attention()` when Ring is enabled + +- Works alongside attention backends: Ring handles communication, backends handle computation + +**Configuration**: `ring_degree` contributes to `sequence_parallel_size` + +#### Relationship with AttentionBackend + +Parallel attention strategies (Ring, Ulysses) work **on top of** AttentionBackend implementations: + +- They use AttentionBackend for the actual attention computation (FlashAttention, SDPA, etc.) + +- They handle the multi-GPU communication/parallelization layer + +- They implement `ParallelAttentionStrategy` interface (not `AttentionBackend`) + +For general parallelism strategies (Data Parallelism, Tensor Parallelism, Pipeline Parallelism), see [Parallel Strategies](#54-parallel-strategies). + +--- + +### 5.3 Cache Backends + +**Location**: `vllm_omni/diffusion/cache/` + +#### Architecture + +Cache backends provide a **unified interface** for applying different caching strategies to accelerate diffusion inference. The system supports multiple backends (TeaCache, cache-dit) with a consistent API for enabling and refreshing cache state. + +#### Cache Backend Interface + +```python +class CacheBackend(ABC): + def __init__(self, config: DiffusionCacheConfig): + self.config = config + self.enabled = False + + @abstractmethod + def enable(self, pipeline: Any) -> None: + """Enable cache on the pipeline.""" + raise NotImplementedError + + @abstractmethod + def refresh(self, pipeline: Any, num_inference_steps: int, verbose: bool = True) -> None: + """Refresh cache state for new generation.""" + raise NotImplementedError + + def is_enabled(self) -> bool: + """Check if cache is enabled.""" + return self.enabled +``` + +**Design Pattern**: + +- **Abstract Base Class**: Defines contract for all cache backends + +- **Pipeline-based**: Works with pipeline instances (not just transformers) + +- **State Management**: Provides refresh mechanism for clean state between generations + +#### Available Backends + +**1. TeaCache Backend** + +**Location**: `vllm_omni/diffusion/cache/teacache/backend.py` + +```python +class TeaCacheBackend(CacheBackend): + def enable(self, pipeline: Any): + # Extract transformer from pipeline + transformer = pipeline.transformer + transformer_type = transformer.__class__.__name__ + + # Create TeaCacheConfig from DiffusionCacheConfig + teacache_config = TeaCacheConfig( + transformer_type=transformer_type, + rel_l1_thresh=self.config.rel_l1_thresh, + coefficients=self.config.coefficients, + ) + + # Apply hooks to transformer + apply_teacache_hook(transformer, teacache_config) + self.enabled = True + + def refresh(self, pipeline: Any, num_inference_steps: int, verbose: bool = True): + transformer = pipeline.transformer + if hasattr(transformer, "_hook_registry"): + transformer._hook_registry.reset_hook(TeaCacheHook._HOOK_NAME) +``` + +**TeaCache Features**: + +- **Timestep-aware**: Caches based on timestep embedding similarity + +- **Adaptive**: Dynamically decides when to reuse cached computations + +- **CFG-aware**: Handles positive/negative branches separately + +- **Custom Hook System**: Uses a custom forward interception mechanism (via `HookRegistry`) that wraps the module's `forward` method, allowing transparent integration without modifying model code + +**2. Cache-DiT Backend** + +**Location**: `vllm_omni/diffusion/cache/cachedit/` + +```python +class CacheDiTBackend(CacheBackend): + def enable(self, pipeline: Any): + # Uses cache-dit library for acceleration + # Supports DBCache, SCM (Step Computation Masking), TaylorSeer + # Works with single and dual-transformer architectures + ... + self.enabled = True + + def refresh(self, pipeline: Any, num_inference_steps: int, verbose: bool = True): + # Updates cache context with new num_inference_steps + ... +``` + +**Cache-DiT Features**: + +- **DBCache**: Dynamic block caching with configurable compute blocks + +- **SCM**: Step Computation Masking for additional speedup + +- **TaylorSeer**: Advanced calibration for cache accuracy + +- **Dual-transformer Support**: Handles models like Wan2.2 with two transformers + +#### Cache Backend Selector + +**Location**: `vllm_omni/diffusion/cache/selector.py` + +```python +def get_cache_backend( + cache_backend: str | None, + cache_config: dict | DiffusionCacheConfig +) -> CacheBackend | None: + """Get cache backend instance based on cache_backend string. + + Args: + cache_backend: Cache backend name ("cache_dit", "tea_cache", or None) + cache_config: Cache configuration (dict or DiffusionCacheConfig) + + Returns: + Cache backend instance or None if cache_backend is "none" + """ + if cache_backend is None or cache_backend == "none": + return None + + if isinstance(cache_config, dict): + cache_config = DiffusionCacheConfig.from_dict(cache_config) + + if cache_backend == "cache_dit": + return CacheDiTBackend(cache_config) + elif cache_backend == "tea_cache": + return TeaCacheBackend(cache_config) + else: + raise ValueError(f"Unsupported cache backend: {cache_backend}") +``` + +**Usage Flow**: + +1. **Selection**: `get_cache_backend()` returns appropriate backend instance + +2. **Enable**: `backend.enable(pipeline)` called during worker initialization + +3. **Refresh**: `backend.refresh(pipeline, num_inference_steps)` called before each generation + +4. **Check**: `backend.is_enabled()` verifies cache is active + +### 5.4 Parallel Strategies + +**Location**: `vllm_omni/diffusion/distributed/parallel_state.py` + +#### Parallelism Types + +The system supports multiple orthogonal parallelism strategies: + +**Sequence Parallelism (SP)** + +- **Purpose**: Split sequence dimension across GPUs + +- **Attention-level SP**: Ring Attention and Ulysses (USP) implement SP at the attention layer level + + - See [Parallel Attention](#52-parallel-attention) for details + + - Configuration: `ulysses_degree` × `ring_degree` = `sequence_parallel_size` + +- **Use Case**: Very long sequences (e.g., high-resolution images) + +**Data Parallelism (DP)** + +- **Purpose**: Replicate model across GPUs, split batch + +- **Use Case**: Batch processing, throughput optimization + +**Tensor Parallelism (TP)** (Experimental) + +- **Purpose**: Split model weights across GPUs + +- **Implementation**: Uses vLLM's tensor parallel groups + +- **Use Case**: Large models that don't fit on single GPU + +**CFG Parallelism** (under development) + +- **Purpose**: Parallelize Classifier-Free Guidance (positive/negative prompts) + +- **Infrastructure**: CFG parallel groups are initialized and available via `get_cfg_group()` + +#### Parallel Group Management + +```python +def initialize_model_parallel( + data_parallel_size: int = 1, + cfg_parallel_size: int = 1, + sequence_parallel_size: int | None = None, + ulysses_degree: int = 1, + ring_degree: int = 1, + tensor_parallel_size: int = 1, + pipeline_parallel_size: int = 1, + vae_parallel_size: int = 0, +): + # Generate orthogonal parallel groups + rank_generator = RankGenerator( + tensor_parallel_size, + sequence_parallel_size, + pipeline_parallel_size, + cfg_parallel_size, + data_parallel_size, + "tp-sp-pp-cfg-dp", + ) + + # Initialize each parallel group + _DP = init_model_parallel_group(rank_generator.get_ranks("dp"), ...) + _CFG = init_model_parallel_group(rank_generator.get_ranks("cfg"), ...) + _SP = init_model_parallel_group(rank_generator.get_ranks("sp"), ...) + _PP = init_model_parallel_group(rank_generator.get_ranks("pp"), ...) + _TP = init_model_parallel_group(rank_generator.get_ranks("tp"), ...) +``` + +**Rank Order**: `tp-sp-pp-cfg-dp` (tensor → sequence → pipeline → cfg → data) + +**Note**: For attention-level Sequence Parallelism implementations (Ring Attention and Ulysses), see [Parallel Attention](#52-parallel-attention). This section covers higher-level parallelism strategies. + + +--- + +## 6. Data Flow + +### Complete Request Flow + +

+ vLLM-Omni Diffusion Module Components +

+

+ End-to-end Data Flow in the vLLM-Omni Diffusion Module +

+ + +``` +1. User Request + └─> Omni.generate(prompt) + └─> Prepare OmniDiffusionRequest + └─> DiffusionEngine.step_streaming(request) + +2. Pre-processing + └─> pre_process_func(request) + └─> Model-specific transformations + +3. Scheduling + └─> scheduler.add_request(request) + └─> scheduler.schedule() + └─> DiffusionEngine selects execute_batch / execute_step + +4. Worker Execution + └─> WorkerProc._worker_busy_loop() + └─> DiffusionWorker.execute_model(req) + └─> DiffusionModelRunner selects execution mode + ├─> REQUEST_BATCH + │ └─> Pipeline.forward(req_or_batch) + │ ├─> encode_prompt() + │ ├─> prepare_latents() + │ ├─> diffuse() [loop] + │ │ ├─> transformer.forward() [with cache backend hooks] + │ │ └─> scheduler.step() + │ └─> vae.decode() + └─> STEP + ├─> pipeline.prepare_encode(state) [new requests] + ├─> pipeline.denoise_step(input_batch) + ├─> pipeline.step_scheduler(state, noise_pred) + └─> pipeline.post_decode(state) [chunk boundary or final step] + +5. Result Collection + └─> Executor returns RunnerOutput / BatchRunnerOutput + └─> scheduler.update_from_output(...) + ├─> DiffusionEngine emits chunk/final output to request queue + └─> DiffusionEngine finalizes terminal scheduler state + +6. Post-processing + └─> post_process_func(output) + └─> Convert to PIL images / final format + +7. Stream Completion / Cancellation + ├─> Streaming callers receive every yielded output + ├─> Non-streaming callers drain the stream and return the final output + └─> Cancelled streams drop their output queue but still allow scheduler state cleanup +``` + +--- diff --git a/docs/design/module/archive/entrypoint_module.md b/docs/design/module/archive/entrypoint_module.md new file mode 100644 index 000000000..7a26fbb7f --- /dev/null +++ b/docs/design/module/archive/entrypoint_module.md @@ -0,0 +1 @@ +Architecture design of the entrypoint (update soon) diff --git a/docs/design/module/engine_orchestration.md b/docs/design/module/engine_orchestration.md index cf8ef1fd0..5bb0ea0df 100644 --- a/docs/design/module/engine_orchestration.md +++ b/docs/design/module/engine_orchestration.md @@ -2,26 +2,56 @@ title: Engine Orchestration kind: module status: draft +architecture_state: current-plus-in-flight owners: - "@tzhouam" - - "@yinpeiqi" - "@fake0fan" +document_stewards: + - "@hsliuustc0106" + - "@Gaohan123" + - "@david6666666" +required_reviewers: + - "@yinpeiqi" - "@Sy0307" + - "@yenuo26" + - "@NickCao" primary_code_paths: - - vllm_omni/engine/** + - vllm_omni/engine/async_omni_engine.py + - vllm_omni/engine/async_engine_utils.py + - vllm_omni/engine/orchestrator.py + - vllm_omni/engine/cfg_companion_tracker.py + - vllm_omni/engine/rpc_result_router.py related_code_paths: + - vllm_omni/engine/messages.py + - vllm_omni/engine/orchestrator_monitor.py + - vllm_omni/engine/stage_client.py + - vllm_omni/engine/stage_pool.py + - vllm_omni/outputs/output_processor.py - vllm_omni/distributed/omni_coordinator/** - - vllm_omni/distributed/ray_utils/** depends_on: - - vllm_omni_config.md - input_output_modality_contracts.md + - error_contracts.md + - stage_runtime.md - omni_connector.md validation_paths: - - tests/engine/** - - tests/distributed/omni_coordinator/** + - tests/engine/test_async_omni_engine_input.py + - tests/engine/test_async_omni_engine_outputs.py + - tests/engine/test_async_omni_engine_abort.py + - tests/engine/test_async_omni_engine_stage_init.py + - tests/engine/test_orchestrator.py + - tests/engine/test_orchestrator_error_handling.py + - tests/engine/test_orchestrator_stage_input_bridge.py + - tests/engine/test_cfg_companion_lifecycle.py + - tests/engine/test_rpc_result_router.py + - tests/e2e/features/fullduplex/engine/** upstream_refs: - - vllm.v1.engine -last_reviewed: 2026-07-16 + - vllm.v1.engine.EngineCoreRequest + - vllm.v1.engine.EngineCoreOutputs + - vllm.v1.engine.exceptions.EngineDeadError + - vllm.outputs.RequestOutput +invariant_namespace: ORCH-INV +last_reviewed: 2026-08-07 +last_verified_commit: 3d7fc3b9ba3cac88d579d4dc35b78b0b641675fc --- # Engine orchestration @@ -29,24 +59,64 @@ last_reviewed: 2026-07-16 Engine orchestration coordinates configured AR and diffusion stages while keeping public clients independent from stage processes and transports. +## Contract status + +This document is a draft description of current behavior. It also identifies +the boundary affected by the in-flight stage client/process refactor in +[#5441](https://github.com/vllm-project/vllm-omni/pull/5441). Names and +responsibilities proposed only by that PR are not current contracts. + +## Ownership boundary + +This document owns `AsyncOmniEngine`, `Orchestrator`, request-state creation, +cross-stage routing, output ordering, companion tracking, control/RPC +correlation, cancellation propagation, and terminal-state convergence. + +It does not own stage placement, replica selection, stage-process startup, +payload schemas, public protocol rendering, connector transport, or semantic +error classification. Those responsibilities belong to the stage runtime, +I/O, entrypoint, connector, and error contracts. + ## Candidate invariants +These identifiers are proposals while the document is `draft`. + ### ORCH-INV-001: The orchestrator owns cross-stage routing **Rule:** Entrypoints and stage clients MUST NOT independently forward a -request to a downstream stage. +request to a downstream logical stage. ### ORCH-INV-002: Stage clients do not own routing policy **Rule:** Stage clients MUST implement communication and lifecycle operations without selecting the next logical stage. -### ORCH-INV-003: Terminal state is monotonic +### ORCH-INV-100: Terminal state is monotonic **Rule:** Once a request reaches a terminal state, orchestration MUST NOT forward new work for that request. +## Invariant namespace + +`ORCH-INV` reserves `001-099` for dependency direction, `100-199` for +request state and ordering, `200-299` for failure/cancellation/cleanup, and +`300-399` for extension points, shutdown ordering, and upstream alignment. +Numbers become append-only after normative promotion. + ## Safe-change guide -Test routing, output ordering, cancellation, failure propagation, shutdown, and -representative multi-stage execution. +Test routing, output ordering, queue correlation, cancellation, failure +propagation, shutdown ordering, and representative multi-stage execution. +Changes that cross into stage runtime or public error behavior require review +from that contract's owners. + +## Promotion gate + +- Reconcile current names and boundaries after #5441 merges, closes, or is + replaced. +- Demonstrate one terminal outcome per request across success, abort, + request-scoped failure, and fatal engine failure. +- Verify queue correlation and orchestrator-before-runtime shutdown ordering + in the cited validation paths. +- Obtain approval from a technical owner and an independent validation + reviewer. diff --git a/docs/design/module/entrypoints.md b/docs/design/module/entrypoints.md index 8d4da8434..80b576f96 100644 --- a/docs/design/module/entrypoints.md +++ b/docs/design/module/entrypoints.md @@ -1,51 +1,134 @@ --- -title: Entrypoints +title: Entrypoints and Serving Boundaries kind: module status: draft +architecture_state: current-plus-in-flight owners: - - "@tzhouam" - - "@yinpeiqi" - - "@fake0fan" - "@alex-jw-brooks" + - "@linyueqian" +document_stewards: + - "@hsliuustc0106" + - "@Gaohan123" + - "@david6666666" +required_reviewers: + - "@tzhouam" + - "@herotai214" + - "@yenuo26" + - "@NickCao" primary_code_paths: - - vllm_omni/entrypoints/** + - vllm_omni/entrypoints/omni.py + - vllm_omni/entrypoints/async_omni.py + - vllm_omni/entrypoints/omni_base.py + - vllm_omni/entrypoints/cli/** + - vllm_omni/entrypoints/openai/** + - vllm_omni/entrypoints/openpi/** + - vllm_omni/entrypoints/client_request_state.py + - vllm_omni/entrypoints/stage_utils.py + - vllm_omni/entrypoints/utils.py +primary_path_exceptions: + - path: vllm_omni/entrypoints/openai/errors.py + owner: error_contracts.md related_code_paths: - - vllm_omni/reasoning/** + - vllm_omni/errors.py + - vllm_omni/inputs/** + - vllm_omni/outputs/** + - vllm_omni/engine/async_omni_engine.py + - vllm_omni/config/** + - vllm_omni/deploy/** depends_on: - - vllm_omni_config.md - input_output_modality_contracts.md + - error_contracts.md - engine_orchestration.md validation_paths: - - tests/entrypoints/** - - tests/reasoning/** + - tests/entrypoints/test_omni_entrypoints.py + - tests/entrypoints/test_async_omni.py + - tests/entrypoints/test_async_omni_duplex.py + - tests/entrypoints/test_serve.py + - tests/entrypoints/test_stream_finish_reason.py + - tests/entrypoints/openai/** + - tests/entrypoints/openai_api/** + - tests/e2e/online_serving/** upstream_refs: - - vllm.entrypoints -last_reviewed: 2026-07-16 + - vllm.engine.protocol.EngineClient + - vllm.entrypoints.openai/** + - vllm.entrypoints.serve/** + - vllm.renderers/** + - vllm.v1.engine.exceptions/** +invariant_namespace: ENTRY-INV +last_reviewed: 2026-08-07 +last_verified_commit: 3d7fc3b9ba3cac88d579d4dc35b78b0b641675fc --- -# Entrypoints +# Entrypoints and serving boundaries Entrypoints translate offline, CLI, and serving requests into stable engine operations and translate engine outputs into public responses. +## Contract status + +This document describes current entrypoint responsibilities plus the boundary +under review in the open roadmap +[#5227](https://github.com/vllm-project/vllm-omni/issues/5227) and helper-move +PR [#5453](https://github.com/vllm-project/vllm-omni/pull/5453). In-flight +helper locations are not treated as current paths. + +## Ownership boundary + +This document owns offline API semantics, CLI and serve composition, +supported OpenAI-compatible routes, request validation and normalization, +response conversion, streaming/session behavior, and engine handoff. + +It does not own configuration precedence, cross-stage routing, stage +placement, payload implementation, or semantic error classification. +`entrypoints/openai/errors.py` is an explicit primary-path exception owned by +`error_contracts.md`. + ## Candidate invariants +These identifiers are proposals while the document is `draft`. + ### ENTRY-INV-001: Entrypoints adapt but do not orchestrate **Rule:** Entrypoints MUST NOT implement cross-stage routing or stage lifecycle policy. -### ENTRY-INV-002: Public requests are normalized once +### ENTRY-INV-100: Public requests are normalized once **Rule:** Public protocol values MUST be validated and converted to internal request contracts before engine submission. -### ENTRY-INV-003: Streaming preserves request identity +### ENTRY-INV-101: Streaming preserves request identity **Rule:** Every streamed response MUST remain associated with the request and output modality that produced it. +The reviewer-proposed rule that model-specific behavior should stay behind a +common adapter or processor abstraction remains an unnumbered candidate until +the entrypoint refactor makes that boundary enforceable. + +## Invariant namespace + +`ENTRY-INV` reserves `001-099` for boundary and dependency direction, +`100-199` for normalization, sessions, and streaming, `200-299` for +disconnect, cancellation, rendering, and cleanup, and `300-399` for upstream +route and renderer compatibility. Numbers become append-only after normative +promotion. + ## Safe-change guide -Test request validation, protocol conversion, streaming, cancellation, and -error mapping for each affected entrypoint. +Test request validation, protocol conversion, model-adapter routing, +streaming, session identity, disconnect/cancellation, and error mapping for +each affected offline or serving entrypoint. + +## Promotion gate + +- Reconcile the page after #5227's P0 ownership work and #5453's final + disposition. +- Publish a supported route/transport matrix rather than claiming blanket + OpenAI parity. +- Verify one normalization and handoff path per route family and one terminal + outcome per streaming transport. +- Promote the `api_server.py` composition-root rule only after helper moves and + import boundaries enforce it. +- Obtain approval from a technical owner and an independent validation + reviewer. diff --git a/docs/design/module/error_contracts.md b/docs/design/module/error_contracts.md new file mode 100644 index 000000000..02469d075 --- /dev/null +++ b/docs/design/module/error_contracts.md @@ -0,0 +1,109 @@ +--- +title: Error Classification, Propagation, and Rendering +kind: module +status: draft +architecture_state: current-plus-rfc +owners: + - "@alex-jw-brooks" + - "@NickCao" +document_stewards: + - "@hsliuustc0106" + - "@Gaohan123" + - "@david6666666" +required_reviewers: + - "@yinpeiqi" + - "@tzhouam" + - "@yenuo26" +primary_code_paths: + - vllm_omni/errors.py + - vllm_omni/entrypoints/openai/errors.py +related_code_paths: + - vllm_omni/engine/messages.py + - vllm_omni/engine/orchestrator.py + - vllm_omni/entrypoints/omni_base.py + - vllm_omni/entrypoints/async_omni.py + - vllm_omni/entrypoints/openai/api_server.py + - vllm_omni/entrypoints/openai/serving_chat.py + - vllm_omni/entrypoints/openai/serving_speech.py + - vllm_omni/entrypoints/openai/serving_audio_generate.py + - vllm_omni/entrypoints/openai/protocol/images.py + - vllm_omni/entrypoints/openai/protocol/videos.py +depends_on: [] +validation_paths: + - tests/engine/test_orchestrator_error_handling.py + - tests/engine/test_async_omni_engine_outputs.py + - tests/entrypoints/test_omni_entrypoints.py + - tests/entrypoints/test_stream_finish_reason.py + - tests/entrypoints/openai_api/test_image_server.py + - tests/entrypoints/openai_api/test_video_server.py + - tests/entrypoints/openai_api/test_serving_audio_generate.py + - tests/entrypoints/openai_api/test_serving_speech.py + - tests/metrics/test_prometheus.py +upstream_refs: + - vllm.v1.engine.exceptions.EngineDeadError + - vllm.v1.engine.exceptions.EngineGenerateError + - vllm.entrypoints.serve.utils.error_response + - vLLM RFC #48227 + - vLLM PR #49665 +invariant_namespace: ERR-INV +last_reviewed: 2026-08-07 +last_verified_commit: 3d7fc3b9ba3cac88d579d4dc35b78b0b641675fc +--- + +# Error classification, propagation, and rendering + +This contract separates the meaning of a failure from the transport used to +carry it and the public protocol used to render it. + +## Contract status + +Current behavior is distributed across engine, message, and entrypoint code. +The semantic hierarchy proposed by +[#5570](https://github.com/vllm-project/vllm-omni/issues/5570) remains an open +RFC. Proposed types and wire records from that RFC are not current contracts. + +## Ownership boundary + +This document owns semantic classification, fatal versus request-scoped +failure, cross-process error context, compatibility policy, sanitization, and +offline, HTTP, SSE, WebSocket, and asynchronous-job rendering rules. + +`engine/messages.py` remains primarily owned by the I/O contract; this page +owns the semantics of its error fields. Route and serving files remain +primarily owned by `entrypoints.md`; this page owns their rendering +constraints. + +## Candidate invariants + +The following topics require owner review before receiving stable IDs: + +- A request-scoped failure terminates only the affected request unless the + underlying engine is no longer usable. +- Every public rendering path preserves a stable classification while + removing sensitive internal context. +- Cancellation remains distinguishable from internal failure across process + and protocol boundaries. + +## Invariant namespace + +`ERR-INV` reserves `001-099` for classification ownership, `100-199` for wire +context and compatibility, `200-299` for fatality, termination, sanitization, +and rendering, and `300-399` for upstream alignment and transport extension +points. Numbers become append-only after normative promotion. + +## Safe-change guide + +Exercise exception classification, stage wire round trips, fatal and +request-scoped propagation, cancellation, sanitization, offline exceptions, +HTTP status/body consistency, streaming terminal events, asynchronous-job +persistence, and metrics classification. + +## Promotion gate + +- Resolve #5570's hierarchy, wire record, sanitization, and mixed-version + questions. +- Add table-driven exception/status/type tests and stage wire round trips. +- Verify all public transports terminate once and do not leak sensitive + payload data. +- Obtain approval from both technical owners and an independent validation + reviewer. diff --git a/docs/design/module/input_output_modality_contracts.md b/docs/design/module/input_output_modality_contracts.md index 10d803d26..1083b09b0 100644 --- a/docs/design/module/input_output_modality_contracts.md +++ b/docs/design/module/input_output_modality_contracts.md @@ -2,46 +2,132 @@ title: Input, Output, and Modality Contracts kind: module status: draft +architecture_state: current owners: + - "@Sy0307" + - "@amy-why-3459" +document_stewards: + - "@hsliuustc0106" + - "@Gaohan123" + - "@david6666666" +required_reviewers: - "@tzhouam" - - "@gcanlin" + - "@linyueqian" + - "@david6666666" + - "@yenuo26" + - "@NickCao" primary_code_paths: - vllm_omni/inputs/** - vllm_omni/outputs/** + - vllm_omni/request.py + - vllm_omni/data_entry_keys.py + - vllm_omni/engine/messages.py + - vllm_omni/engine/serialization.py + - vllm_omni/engine/mm_outputs.py + - vllm_omni/engine/output_modality.py + - vllm_omni/engine/output_processor.py related_code_paths: - - vllm_omni/tokenizers/** -depends_on: [] + - vllm_omni/model_executor/stage_input_processors/** + - vllm_omni/core/sched/** + - vllm_omni/distributed/omni_connectors/** + - vllm_omni/diffusion/request.py + - vllm_omni/diffusion/worker/request_batch.py + - vllm_omni/errors.py +depends_on: + - error_contracts.md validation_paths: - tests/inputs/** + - tests/engine/test_data_entry_keys.py + - tests/engine/test_omni_request.py + - tests/engine/test_omni_request_output.py + - tests/engine/test_multimodal_accumulation.py + - tests/engine/test_output_modality.py + - tests/engine/test_output_processor.py + - tests/engine/test_wire_multimodal_output.py + - tests/utils/test_mm_outputs.py + - tests/utils/test_mm_outputs_partition.py + - tests/diffusion/test_diffusion_output_metadata.py upstream_refs: - - vllm.inputs - - vllm.outputs -last_reviewed: 2026-07-16 + - vllm.inputs/** + - vllm.outputs/** + - vllm.v1.request.Request + - vllm.v1.engine.EngineCoreRequest + - vllm.v1.engine.EngineCoreOutput + - vllm.v1.engine.output_processor.OutputProcessor +invariant_namespace: IO-INV +last_reviewed: 2026-08-07 +last_verified_commit: 3d7fc3b9ba3cac88d579d4dc35b78b0b641675fc --- # Input, output, and modality contracts These contracts define the data that may cross entrypoint, orchestration, -stage, and model boundaries. +stage, connector, and model boundaries. + +## Contract status + +This document is a draft description of the current request, message, +serialization, modality, accumulation, and output types. It does not assign +semantic error ownership to the wire schema. + +## Ownership boundary + +This document owns request identity, prompt/input types, modality keys and +metadata, queue and wire message schemas, serialization, output types, +accumulation, completion semantics, and compatibility shims. + +It does not own route-specific validation or rendering, model-specific +interpretation, connector transport mechanics, scheduling policy, or semantic +error classification. The `ErrorMessage` schema belongs here; the meaning and +public rendering of its error fields belong to `error_contracts.md`. ## Candidate invariants +These identifiers are proposals while the document is `draft`. + ### IO-INV-001: Boundary data has an explicit modality **Rule:** Data crossing a module or stage boundary MUST identify its modality and use the corresponding validated contract. -### IO-INV-002: Request identity is stable +### IO-INV-100: Request identity is stable **Rule:** Request identity MUST be preserved across conversions, stages, streaming updates, cancellation, and errors. -### IO-INV-003: Internal objects do not leak into public protocols +### IO-INV-101: Completion is explicit + +**Rule:** Producers MUST distinguish partial updates from the terminal update +for every output modality. + +### IO-INV-300: Internal objects do not leak into public protocols **Rule:** Entrypoints MUST explicitly translate internal output objects into public response types. +## Invariant namespace + +`IO-INV` reserves `001-099` for schema ownership and dependency direction, +`100-199` for identity, modality, accumulation, ordering, and completion, +`200-299` for serialization failure and payload cleanup, and `300-399` for +upstream extension, deprecation, and compatibility. Numbers become +append-only after normative promotion. + ## Safe-change guide -Test construction, validation, serialization, streaming, optional fields, and -compatibility at every affected producer-consumer boundary. +Test construction, validation, serialization, round trips, streaming, +accumulation, completion, optional fields, unknown fields, and compatibility +at every affected producer-consumer boundary. + +## Promotion gate + +- Add explicit CODEOWNERS for the primary I/O paths or confirm that fallback + ownership is intentional. +- Document the compatibility window for deprecated `vllm_omni.engine` + I/O-related imports. +- Verify wire round trips and rejection of unknown or incompatible schema + fields. +- Define payload-versus-metadata rules and completion semantics for streaming + and batched outputs. +- Obtain approval from a technical owner and independent AR, diffusion, + connector, entrypoint, and validation reviewers as applicable. diff --git a/docs/design/module/stage_runtime.md b/docs/design/module/stage_runtime.md new file mode 100644 index 000000000..aefba5a40 --- /dev/null +++ b/docs/design/module/stage_runtime.md @@ -0,0 +1,126 @@ +--- +title: Stage Runtime and Replica Lifecycle +kind: module +status: draft +architecture_state: current-plus-in-flight +owners: + - "@tzhouam" + - "@fake0fan" +document_stewards: + - "@hsliuustc0106" + - "@Gaohan123" + - "@david6666666" +required_reviewers: + - "@Sy0307" + - "@princepride" + - "@chickeyton" + - "@yenuo26" + - "@NickCao" +primary_code_paths: + - vllm_omni/engine/stage_runtime.py + - vllm_omni/engine/stage_pool.py + - vllm_omni/engine/stage_client.py + - vllm_omni/engine/membership_controller.py + - vllm_omni/engine/stage_engine_startup.py + - vllm_omni/engine/stage_engine_core_client.py + - vllm_omni/engine/stage_engine_core_proc.py + - vllm_omni/engine/stage_engine_core_proc_manager.py + - vllm_omni/engine/stage_init_utils.py +related_code_paths: + - vllm_omni/distributed/omni_coordinator/** + - vllm_omni/distributed/ray_utils/** + - vllm_omni/diffusion/inline_stage_diffusion_client.py + - vllm_omni/diffusion/stage_diffusion_client.py + - vllm_omni/diffusion/stage_diffusion_proc.py + - vllm_omni/config/** +depends_on: + - input_output_modality_contracts.md + - error_contracts.md + - omni_connector.md +validation_paths: + - tests/engine/test_async_omni_engine_stage_init.py + - tests/engine/test_membership_controller.py + - tests/engine/test_single_stage_mode.py + - tests/engine/test_stage_engine_core_client.py + - tests/engine/test_stage_engine_core_proc.py + - tests/diffusion/test_inline_stage_diffusion_client.py + - tests/diffusion/test_stage_diffusion_proc.py + - tests/distributed/omni_coordinator/** +upstream_refs: + - vllm.v1.engine.core_client.AsyncMPClient + - vllm.v1.engine.core_client.DPLBAsyncMPClient + - vllm.v1.engine.core.EngineCoreProc + - vllm.v1.engine.utils.CoreEngineProcManager + - vllm.v1.engine.coordinator.DPCoordinator +invariant_namespace: STAGE-INV +last_reviewed: 2026-08-07 +last_verified_commit: 3d7fc3b9ba3cac88d579d4dc35b78b0b641675fc +--- + +# Stage runtime and replica lifecycle + +The stage runtime turns a logical stage definition into ready local or +distributed replicas and provides the lifecycle boundary used by the +orchestrator. + +## Contract status + +This document describes the current `StageRuntime`, `StagePool`, stage-client, +and stage-process layout. The unified LLM/diffusion direction in +[#5441](https://github.com/vllm-project/vllm-omni/pull/5441) remains in flight +and is not presented as current behavior. + +## Ownership boundary + +This document owns local and distributed stage placement, startup and +readiness, one pool per logical stage, replica identity and membership, +selection and affinity, stage-client/process lifecycle, liveness, draining, +and shutdown. + +It does not own cross-stage request policy, model scheduler policy, connector +transfer semantics, configuration precedence, or public error rendering. + +## Candidate invariants + +These identifiers are proposals while the document is `draft`. + +### STAGE-INV-001: The runtime owns replica lifecycle + +**Rule:** Orchestration MUST acquire stage capacity through the stage runtime +and MUST NOT construct or retire backend replicas directly. + +### STAGE-INV-100: Affinity uses stable replica identity + +**Rule:** Follow-up operations for a request with replica affinity MUST resolve +the same logical replica until the request terminates or the replica is +declared lost. + +### STAGE-INV-200: Shutdown is idempotent + +**Rule:** Repeated shutdown or cleanup signals MUST NOT leak stage clients, +process managers, or membership registrations. + +## Invariant namespace + +`STAGE-INV` reserves `001-099` for placement and ownership, `100-199` for +replica identity/readiness/affinity, `200-299` for liveness loss, draining and +cleanup, and `300-399` for local/distributed and upstream compatibility. +Numbers become append-only after normative promotion. + +## Safe-change guide + +Exercise local and distributed startup, readiness, replica selection, +affinity, membership changes, abort routing, process failure, draining, and +repeated shutdown. Cross-stage policy changes belong in +`engine_orchestration.md`. + +## Promotion gate + +- Reconcile terminology and ownership after #5441 reaches a final state. +- Add or identify a dedicated StageRuntime/StagePool lifecycle suite; current + evidence is distributed across the cited tests. +- Demonstrate affinity preservation for update, interaction, and abort paths. +- Demonstrate membership loss and repeated shutdown without leaked clients or + processes. +- Obtain approval from a technical owner and an independent validation + reviewer. diff --git a/docs/design/module/vllm_omni_config.md b/docs/design/module/vllm_omni_config.md index 5df0cdcb3..085e4cee6 100644 --- a/docs/design/module/vllm_omni_config.md +++ b/docs/design/module/vllm_omni_config.md @@ -2,6 +2,8 @@ title: vLLM-Omni Configuration kind: module status: draft +architecture_state: deferred-pending-refactor +ownership_status: provisional owners: - "@lishunyang12" - "@alex-jw-brooks" @@ -16,32 +18,39 @@ validation_paths: - tests/config/** upstream_refs: - vllm.config -last_reviewed: 2026-07-16 +last_reviewed: 2026-08-07 +last_verified_commit: 3d7fc3b9ba3cac88d579d4dc35b78b0b641675fc --- # vLLM-Omni configuration -Configuration defines how user input becomes validated engine, stage, -deployment, and platform settings. +This page intentionally records discovery paths only. Substantive +configuration ownership and contract work is deferred until the active +configuration refactoring has settled. -## Candidate invariants +## Deferred contract scope -### CONFIG-INV-001: Configuration is validated before startup +The current code and tests remain authoritative for parsing, defaults, +overrides, deployment topology, stage construction, and runtime projection. +This draft does not define a stable precedence model, environment-variable +capture rule, canonical configuration object, or invariant namespace. -**Rule:** Invalid stage graphs, resource assignments, and incompatible options -MUST fail before managed runtime processes start. +The PR review proposal to capture environment-derived values in configuration +objects at initialization is an open design question, not a current +invariant. -### CONFIG-INV-002: Precedence is deterministic +## Safe-change guide while deferred -**Rule:** Defaults, files, environment variables, and CLI overrides MUST have a -documented and deterministic precedence. +Changes should trace every affected producer to its runtime consumer and test +the structured, legacy, CLI, and deployment paths that are actually supported. +Do not infer a stable contract from this placeholder page. -### CONFIG-INV-003: Runtime modules consume validated configuration +## Promotion gate -**Rule:** Runtime modules MUST NOT independently reinterpret raw user -configuration. - -## Safe-change guide - -Test defaults, parsing, override precedence, serialization, invalid -combinations, and representative multi-stage configurations. +- Finish or stabilize the configuration refactor and identify the canonical + runtime configuration representation. +- Confirm technical owners, primary path exceptions, and validation owners. +- Document parsing and override precedence, including when environment values + are captured. +- Allocate an invariant namespace only after those behaviors are enforced and + owner-approved. diff --git a/docs/design/qwen3_omni_tts_performance_optimization.md b/docs/design/qwen3_omni_tts_performance_optimization.md index e4548a17c..bc1e29a7f 100644 --- a/docs/design/qwen3_omni_tts_performance_optimization.md +++ b/docs/design/qwen3_omni_tts_performance_optimization.md @@ -419,8 +419,7 @@ Notes: ```bash vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct \ --omni \ - --port 8091 \ - --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml + --port 8091 ``` #### 3) Key config knobs @@ -494,7 +493,7 @@ The default config (`qwen3_tts.yaml`) enables the full optimization stack: vllm-omni serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \ --omni \ --port 8000 \ - --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts_no_async_chunk.yaml + --no-async-chunk ``` #### 3) Key config knobs diff --git a/docs/features/session_state_manager.md b/docs/features/session_state_manager.md new file mode 100644 index 000000000..c0c055fcb --- /dev/null +++ b/docs/features/session_state_manager.md @@ -0,0 +1,160 @@ +# Session State Manager (experimental) + +!!! warning + This feature is **experimental** (RFC [#4480](https://github.com/vllm-project/vllm-omni/issues/4480)). + The code lives under `vllm_omni/experimental/world_models/` and its APIs may + change without notice. It is **off by default**; when disabled, model + behavior is byte-for-byte unchanged. + +Autoregressive diffusion world models (DreamZero, Cosmos3, and similar) keep +per-session state across `forward()` calls: accumulated video latents, frame +stitching buffers, encode-once conditioning, counters, and reset heuristics. +Historically each model hand-rolled this state. The session state manager +extracts it into a shared, typed contract: + +- `StateObject` — one typed unit of session state (e.g. `LatentBuffer` for + accumulated latents and bounded rings), with a uniform lifecycle + (`allocate` / `commit` / `view` / `reset` / `evict` / `nbytes_by_device`). +- `SessionStateManager` — maps `session_id -> {name: StateObject}`, caps the + number of retained sessions by evicting the least recently used one first + (matching the bespoke caches it replaces), and reports per-device byte usage + via `stats()` for observability. + +Attention KV is *not* managed here: for DreamZero it is owned by the +AR-Diffusion engine's paged KV pool (PR +[#4534](https://github.com/vllm-project/vllm-omni/pull/4534)). The manager +covers the model's non-KV session state; integrating the engine's per-session +KV handle into the same byte accounting is RFC #4480 Phase 1. + +## Enabling + +Via config: + +```yaml +# deploy config +enable_session_state_manager: true +``` + +or programmatically with `OmniDiffusionConfig(enable_session_state_manager=True)`. + +Via environment variables (no config change needed): + +```bash +export OMNI_DIFFUSION_SESSION_STATE_MANAGER=1 # 1/0/true/false/yes/no/on/off +export OMNI_DIFFUSION_SESSION_STATE_MANAGER_MAX_SESSIONS=64 # optional, positive int +``` + +**Precedence:** a *set* environment variable overrides the config value, in +both directions — `OMNI_DIFFUSION_SESSION_STATE_MANAGER=0` force-disables the +manager even if the config enables it. Unset or unparsable values fall back +to the config (an unparsable `MAX_SESSIONS` logs a warning and is ignored). + +**Why both a config field and an environment variable?** The config field is +the API. The environment override exists for A/B equivalence validation: the +manager-backed path must be bit-identical to the bespoke path, and the way to +verify that is to run the *same* deploy config twice with only the flag +flipped per process — no config-file edits, so the two runs cannot drift +apart. (Same pattern as the `DIFFUSION_CACHE_BACKEND` fallback.) + +**Why is `MAX_SESSIONS` not a config field?** The retained-session cap is not +a public tuning knob: it mirrors the bespoke `MAX_DREAMZERO_SESSIONS = 64` +constant (which is not configurable either), so the manager-backed path evicts +exactly like the path it replaces. The environment variable exists only to +stress eviction in tests and experiments. It is deliberately not promoted to +the config, because RFC #4480 Phase 1 replaces count-based capping with a byte +budget — that budget, not this cap, is the knob that deserves a config field. + +## Choosing between an attribute and a `StateObject` + +A session holds its values in two places. Named `StateObject`s live in the +session's object map; everything else lives in `SessionState.attrs`, declared +one line per value with the `SessionAttr` descriptor. The rule for choosing is: + +> A value belongs in a `StateObject` when the **manager** must be able to act on +> it — release it under budget pressure, accumulate into it, stage and commit +> it, or rebuild it from a recorded source. It belongs in `attrs` when the +> manager only needs to **carry** it: the model assigns it wholesale, reads it +> back, and clears it, and no one but the model can decide its fate. + +Byte accounting is independent of that choice. A session reports every byte it +holds in either bucket, because the number describes what the process is +holding, not what the manager may do about it. + +The consequence is worth stating plainly: **a value left in `attrs` is a value +the byte-budget planner cannot free.** In Phase 0, which accounts but does not +enforce, that is legitimate — but it should be a recorded decision rather than +an accident, which is what the table below records. + +Note what the rule does *not* say. "Is it big" is not the criterion, and +neither is "does it grow". The largest allocation a DreamZero session holds — +`vae_enc_feat_map`, 603 MiB — is an *attribute*, because nothing the manager +can do to it is useful: it is replaced wholesale, it has no recompute source, +and freeing it mid-session breaks the encoder stream. Ending the session is the +only release available, and that reclaims attributes anyway. Size alone argues +for counting a value, which the accounting does regardless of bucket; it does +not argue for a lifecycle. + +What would change that answer is a release the manager can perform without +losing the value — offloading it to host memory and copying it back is +transparent, needs no recomputation, and suits a large contiguous cache. The +contract has no such operation: `evict()` frees storage, it does not park it. +So the bucket follows the operations that exist, and moves if they do. + +### Byte accounting + +`SessionStateManager.stats()` reports byte totals keyed by device +(`nbytes:cuda:0`, `nbytes:cpu`) alongside `total_nbytes`. The split is not +cosmetic: device memory is the contended resource and host memory generally is +not, so a single figure covering both answers no question. Which pool a budget +applies to is a question for whoever enforces it, and is not decided here. + +Two details make the numbers honest. Bytes are counted per **storage**, not as +`numel() * element_size()`, so a narrow slice reports the whole allocation it +keeps alive rather than the part it exposes. And storages are **deduplicated** +across the whole session, so two views of one buffer — or an alias between an +attribute and a state object — are counted once. + +## Scope and guarantees + +- **Opt-in and equivalent.** With the flag off, models use their bespoke state + paths unchanged. With the flag on, the manager-backed path is validated + bit-identical to the bespoke path (CPU equivalence tests under + `tests/dreamzero/test_session_state_equivalence.py`, plus GPU A/B runs). +- **Session eviction is count-based (LRU).** Evicting a session drops it from + the lookup table only; an adapter still holding the session keeps its state + (matching bespoke behavior, where the caller holds the state object). +- **Byte budget is recorded, not enforced.** `SessionStateManager.stats()` + reports per-manager, per-device byte totals; budget *enforcement* (and + eviction driven by it) is a later phase of RFC #4480. + +## Supported models + +### DreamZero + +Sizes measured on one A100 at the shipped deploy config (`180x320` per camera, +three cameras stitched to `352x640`, bfloat16), over a 400-step session. + +| Value | Bucket | Size | Notes | +|---|---|---|---| +| `vae_enc_feat_map` | `attrs` | **603 MiB** | Wan encoder causal-conv cache, 24 entries. Constant from ~step 50. The largest thing a session holds: 64 sessions is 37.7 GiB. Counted, not releasable — see the rule above. | +| `video_latents_across_time` | `LatentBuffer` | grows | AR video latent chunks, host memory (`.cpu()`), kept for decode. | +| `stitched_buffer` | `LatentBuffer` | bounded | Stitched pixel frames, host memory, ring of `FRAMES_PER_CHUNK`. | +| `vae_encoder_out` | `LatentBuffer` | 0.43 MiB | Bounded ring of `num_frame_per_block` latent frames — see below. | +| `clip_feas`, `ys`, `language`, `prompt_embeds` | `attrs` | small | Conditioning tensors, written once per session and reread. | +| `vae_pending_body_frames` | `attrs` | ≤2.6 MiB | Raw frames awaiting a 4-frame body chunk; bounded by construction. | +| `call_count`, `current_start_frame`, `vae_stream_initialized` | `attrs` | — | Counters and flags. | + +Attention KV does not appear here: it is engine-owned (PR #4534). + +**The bounded VAE encoder ring.** The bespoke state concatenates every encoded +chunk into one tensor and clears it only on a *session* reset — a window reset +deliberately keeps it — so it grows for as long as the session lives, at +0.215 MiB per latent frame (one per four steps). Its only reader, +`_vae_stream_get_observation_latents`, quantises the whole history and then +keeps the last `num_frame_per_block` latent frames. Since `quant_conv` is a +`WanCausalConv3d` of kernel size 1, pointwise in time, those frames do not +depend on the discarded ones. The manager-backed state therefore retains a ring +of `num_frame_per_block` frames instead: identical output, bounded memory, and +the per-step quantisation no longer scales with session length. Asking for more +frames than the ring retains raises rather than silently returning a shorter +window. The bespoke path is unchanged. diff --git a/docs/getting_started/installation/README.md b/docs/getting_started/installation/README.md index 89562c53c..b49cee794 100644 --- a/docs/getting_started/installation/README.md +++ b/docs/getting_started/installation/README.md @@ -1,5 +1,8 @@ # Installation +!!! important + vLLM-Omni is released against the matching upstream vLLM major/minor version. vLLM-Omni 0.26.x requires vLLM 0.26.x; the stable v0.26.0 instructions pin vLLM 0.26.0. + vLLM-Omni supports the following hardware platforms: - [GPU](gpu.md) diff --git a/docs/getting_started/installation/gpu.md b/docs/getting_started/installation/gpu.md index e21a11cee..59d2aa482 100644 --- a/docs/getting_started/installation/gpu.md +++ b/docs/getting_started/installation/gpu.md @@ -34,7 +34,7 @@ vLLM-Omni is a Python library that supports the following GPU variants. The libr ### Pre-built wheels -Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, 0.18.0, 0.20.0, 0.21.0, 0.22.0, 0.23.0, 0.24.0, and 0.25.0. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source). +Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, 0.18.0, 0.20.0, 0.21.0, 0.22.0, 0.23.0, 0.24.0, 0.25.0, and 0.26.0. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source). === "NVIDIA CUDA" diff --git a/docs/getting_started/installation/gpu/cuda.inc.md b/docs/getting_started/installation/gpu/cuda.inc.md index d4808139e..a68bfdb58 100644 --- a/docs/getting_started/installation/gpu/cuda.inc.md +++ b/docs/getting_started/installation/gpu/cuda.inc.md @@ -5,7 +5,7 @@ # --8<-- [end:requirements] # --8<-- [start:set-up-using-python] -vLLM-Omni depends vLLM. So please follow instructions below mainly for vLLM. +vLLM-Omni depends on the matching major/minor release of vLLM. The vLLM-Omni 0.26.x release line uses vLLM 0.26.x. !!! note PyTorch installed via `conda` will statically link `NCCL` library, which can cause issues when vLLM tries to use `NCCL`. See for more details. @@ -20,7 +20,7 @@ Therefore, it is recommended to install vLLM and vLLM-Omni with a **fresh new** vLLM-Omni is built based on vLLM. Please install it with command below. ```bash -uv pip install vllm==0.25.0 --torch-backend=auto +uv pip install vllm==0.26.0 --torch-backend=auto ``` #### Installation of vLLM-Omni @@ -39,13 +39,13 @@ uv pip install 'vllm-omni[demo]' # --8<-- [start:build-wheel-from-source] #### Installation of vLLM -If you do not need to modify source code of vLLM, you can directly install the stable 0.25.0 release version of the library +If you do not need to modify source code of vLLM, you can directly install the stable 0.26.0 release version of the library ```bash -uv pip install vllm==0.25.0 --torch-backend=auto +uv pip install vllm==0.26.0 --torch-backend=auto ``` -The 0.25.0 release of vLLM ships CUDA 13.0-compatible binaries by default. If you need a different CUDA variant or want to reuse an existing PyTorch installation, build vLLM from source instead. +The 0.26.0 release of vLLM ships CUDA 13.0-compatible binaries by default. If you need a different CUDA variant or want to reuse an existing PyTorch installation, build vLLM from source instead. #### Installation of vLLM-Omni Since vllm-omni is rapidly evolving, it's recommended to install it from source @@ -66,12 +66,12 @@ If you want to check, modify or debug with source code of vLLM, install the libr ```bash git clone https://github.com/vllm-project/vllm.git cd vllm -git checkout v0.25.0 +git checkout v0.26.0 ``` Set up environment variables to get pre-built wheels. If there are internet problems, just download the whl file manually. And set `VLLM_PRECOMPILED_WHEEL_LOCATION` as your local absolute path of whl file. ```bash -#For CUDA 13.0 (the default for v0.25.0; the wheel filename has no `+cu130` suffix) -export VLLM_PRECOMPILED_WHEEL_LOCATION=https://github.com/vllm-project/vllm/releases/download/v0.25.0/vllm-0.25.0-cp38-abi3-manylinux_2_28_x86_64.whl +#For CUDA 13.0 (the default for v0.26.0; the wheel filename has no `+cu130` suffix) +export VLLM_PRECOMPILED_WHEEL_LOCATION=https://github.com/vllm-project/vllm/releases/download/v0.26.0/vllm-0.26.0-cp38-abi3-manylinux_2_28_x86_64.whl ``` Install vllm with command below (If you have no existing PyTorch). ```bash @@ -102,7 +102,7 @@ docker run --runtime nvidia --gpus 2 \ --env "HF_TOKEN=$HF_TOKEN" \ -p 8091:8091 \ --ipc=host \ - vllm/vllm-omni:v0.25.0 \ + vllm/vllm-omni:v0.26.0 \ vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 ``` @@ -124,7 +124,7 @@ If you want to specify the base vLLM version: ```bash DOCKER_BUILDKIT=1 docker build \ -f docker/Dockerfile.cuda \ - --build-arg BASE_IMAGE=vllm/vllm-openai:v0.22.1 \ + --build-arg BASE_IMAGE=vllm/vllm-openai:v0.26.0 \ -t vllm-omni-cuda . ``` diff --git a/docs/getting_started/installation/gpu/rocm.inc.md b/docs/getting_started/installation/gpu/rocm.inc.md index 4a64e8723..c44407299 100644 --- a/docs/getting_started/installation/gpu/rocm.inc.md +++ b/docs/getting_started/installation/gpu/rocm.inc.md @@ -13,7 +13,7 @@ vLLM-Omni current recommends the steps in under setup through Docker Images. vLLM-Omni is built based on vLLM. Please install it with command below. ```bash -uv pip install vllm==0.25.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.25.0/rocm723 +uv pip install vllm==0.26.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.26.0/rocm723 ``` #### Installation of vLLM-Omni @@ -34,13 +34,13 @@ uv pip install onnxruntime-rocm # --8<-- [start:build-wheel-from-source] #### Installation of vLLM -If you do not need to modify source code of vLLM, you can directly install the stable 0.25.0 release version of the library +If you do not need to modify source code of vLLM, you can directly install the stable 0.26.0 release version of the library ```bash -uv pip install vllm==0.25.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.25.0/rocm723 +uv pip install vllm==0.26.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.26.0/rocm723 ``` -The pre-built 0.25.0 vLLM wheel targets ROCm 7.2.3. If you need a different ROCm stack or want to reuse an existing PyTorch installation, build vLLM from source instead. +The pre-built 0.26.0 vLLM wheel targets ROCm 7.2.3. If you need a different ROCm stack or want to reuse an existing PyTorch installation, build vLLM from source instead. #### Installation of vLLM-Omni Since vllm-omni is rapidly evolving, it's recommended to install it from source @@ -58,7 +58,7 @@ If you want to check, modify or debug with source code of vLLM, install the libr ```bash git clone https://github.com/vllm-project/vllm.git cd vllm -git checkout v0.25.0 +git checkout v0.26.0 python3 -m pip install -r requirements/rocm.txt python3 setup.py develop ``` @@ -130,7 +130,7 @@ docker run --rm \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=$HF_TOKEN" \ -p 8091:8091 \ - vllm/vllm-omni-rocm:v0.25.0 \ + vllm/vllm-omni-rocm:v0.26.0 \ --model Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 ``` @@ -149,7 +149,7 @@ docker run --rm -it \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=$HF_TOKEN" \ --entrypoint bash \ - vllm/vllm-omni-rocm:v0.25.0 + vllm/vllm-omni-rocm:v0.26.0 ``` # --8<-- [end:pre-built-images] diff --git a/docs/getting_started/installation/npu/npu.inc.md b/docs/getting_started/installation/npu/npu.inc.md index 7381f5b10..a0d4c86b0 100644 --- a/docs/getting_started/installation/npu/npu.inc.md +++ b/docs/getting_started/installation/npu/npu.inc.md @@ -9,8 +9,8 @@ The recommended way to use vLLM-Omni on NPU is through the vLLM-Ascend pre-built ```bash # vLLM-Ascend image -# Atlas A2: quay.io/atlas-ci/vllm-ascend:v0.25.0 -# Atlas A3: quay.io/atlas-ci/vllm-ascend:v0.25.0-a3 +# Atlas A2: quay.io/atlas-ci/vllm-ascend:v0.26.0 +# Atlas A3: quay.io/atlas-ci/vllm-ascend:v0.26.0-a3 docker run --rm \ --name vllm-omni-npu \ --shm-size=1g \ @@ -28,11 +28,11 @@ docker run --rm \ -v /etc/ascend_install.info:/etc/ascend_install.info \ -v /root/.cache:/root/.cache \ -p 8000:8000 \ - -it quay.io/atlas-ci/vllm-ascend:0.25.0-a3 bash + -it quay.io/atlas-ci/vllm-ascend:v0.26.0-a3 bash # Inside the container, install vLLM-Omni from source cd /vllm-workspace -git clone -b v0.25.0 https://github.com/vllm-project/vllm-omni.git +git clone -b v0.26.0 https://github.com/vllm-project/vllm-omni.git cd vllm-omni pip install -v -e . --no-build-isolation # or VLLM_OMNI_TARGET_DEVICE=npu pip install -v -e . @@ -53,16 +53,20 @@ We are keeping [issue #886](https://github.com/vllm-project/vllm-omni/issues/886 You can also build vLLM-Omni from the latest main branch if you want to use the latest features or bug fixes. (But sometimes it will break for a while. You can check [issue #886](https://github.com/vllm-project/vllm-omni/issues/886) for the status of the latest commit of vLLM-Omni main branch on NPU.) ```bash -# Pin vLLM version to 0.18.0 -git clone -b v0.18.0 https://github.com/vllm-project/vllm.git +# Pin vLLM and vLLM-Ascend to the v0.26 release line +git clone -b v0.26.0 https://github.com/vllm-project/vllm.git +cd vllm VLLM_TARGET_DEVICE=empty pip install -v -e . +cd .. -git clone -b v0.18.0rc1 https://github.com/vllm-project/vllm-ascend.git +git clone -b releases/v0.26.0rc https://github.com/vllm-project/vllm-ascend.git +cd vllm-ascend pip install -v -e . +cd .. # Install vLLM-Omni from the latest main branch git clone https://github.com/vllm-project/vllm-omni.git -cd /vllm-workspace/vllm-omni +cd vllm-omni pip install -v -e . --no-build-isolation # or VLLM_OMNI_TARGET_DEVICE=npu pip install -v -e . export VLLM_WORKER_MULTIPROC_METHOD=spawn @@ -84,8 +88,8 @@ Supported images as following. Here's an example deployment command that has been verified on 4 x NPUs: ```bash -# Atlas A2: quay.io/ascend/vllm-omni:v0.25.0 -# Atlas A3: quay.io/ascend/vllm-omni:v0.25.0-a3 +# Atlas A2: quay.io/ascend/vllm-omni:v0.26.0 +# Atlas A3: quay.io/ascend/vllm-omni:v0.26.0-a3 docker run --rm \ --name vllm-omni-a3 \ --shm-size=4g \ @@ -110,7 +114,7 @@ docker run --rm \ -v /etc/ascend_install.info:/etc/ascend_install.info \ -v ~/.cache:/root/.cache \ -p 8091:8091 \ - -it quay.io/ascend/vllm-omni:v0.25.0-a3 bash + -it quay.io/ascend/vllm-omni:v0.26.0-a3 bash ``` !!! tip @@ -122,9 +126,9 @@ Or build IMAGE from **source code**: git clone https://github.com/vllm-project/vllm-omni.git cd vllm-omni # A2 -docker build -t vllm-omni-dev-image:latest -f ./docker/Dockerfile.npu . +# docker build -t vllm-omni-dev-image:latest -f ./docker/Dockerfile.npu . # A3 -# docker build -t vllm-omni-dev-image:latest -f ./docker/Dockerfile.npu.a3 . +docker build -t vllm-omni-dev-image:latest -f ./docker/Dockerfile.npu.a3 . ``` # --8<-- [end:pre-built-images] diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index 914f1f7b4..f9673eac7 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -19,10 +19,10 @@ uv venv --python 3.12 --seed source .venv/bin/activate # On CUDA -uv pip install vllm==0.25.0 --torch-backend=auto +uv pip install vllm==0.26.0 --torch-backend=auto # On ROCm -uv pip install vllm==0.25.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.25.0/rocm723 +uv pip install vllm==0.26.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.26.0/rocm723 git clone https://github.com/vllm-project/vllm-omni.git cd vllm-omni @@ -34,7 +34,7 @@ For additional installation methods — please see the [installation guide](inst !!! note It is important to install the same major & minor version of vLLM and vLLM Omni, otherwise things may not work as expected. If the versions are misaligned, you will see a warning when you import vLLM Omni. - If you are seeing strange behavior with the `vllm` command not handling the `--omni` flag correctly, you most likely have a version mismatch with vLLM < `0.25.0` and vLLM Omni `0.25.0`, as vLLM Omni no longer hijacks the vLLM entrypoint. Updating vLLM should resolve this issue. + If you are seeing strange behavior with the `vllm` command not handling the `--omni` flag correctly, you most likely have a version mismatch with vLLM < `0.26.0` and vLLM Omni `0.26.0`, as vLLM Omni no longer hijacks the vLLM entrypoint. Updating vLLM should resolve this issue. ## Offline Inference @@ -87,9 +87,8 @@ if __name__ == "__main__": !!! info - For diffusion request-level batching controls such as `max_num_seqs` and - `request_batch_max_wait_ms`, see - [Request-Level Batching](../user_guide/diffusion/request_batching.md). + For diffusion request batching, step execution, and streaming controls, see + [Diffusion Execution Modes](../user_guide/diffusion/execution_modes.md). For more usages, please refer to [offline inference](../user_guide/examples/offline_inference/qwen2_5_omni.md) diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index 99b17d529..d1d4e051d 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -41,24 +41,24 @@ th { | `Wan22S2VPipeline` | Wan2.2-S2V | `Wan-AI/Wan2.2-S2V-14B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `Wan22VACEPipeline` | Wan2.1-VACE | `Wan-AI/Wan2.1-VACE-1.3B-diffusers`, `Wan-AI/Wan2.1-VACE-14B-diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `Wan22VACEPipeline` | Wan2.2-VACE | `Pyros13/Wan2.2-VACE-Fun-A14B-Diffusers` | ✅︎ | | | | -| `LTX2Pipeline` | LTX-2-T2V | `Lightricks/LTX-2` | ✅︎ | ✅︎ | | | -| `LTX2ImageToVideoPipeline` | LTX-2-I2V | `Lightricks/LTX-2` | ✅︎ | ✅︎ | | | -| `LTX2TwoStagesPipeline` | LTX-2-T2V | `rootonchair/LTX-2-19b-distilled` | ✅︎ | ✅︎ | | | -| `LTX2ImageToVideoTwoStagesPipeline` | LTX-2-I2V | `rootonchair/LTX-2-19b-distilled` | ✅︎ | ✅︎ | | | -| `LTX23Pipeline` | LTX-2.3-T2V | `dg845/LTX-2.3-Diffusers` | ✅︎ | ✅︎ | | | -| `LTX23ImageToVideoPipeline` | LTX-2.3-I2V | `dg845/LTX-2.3-Diffusers` | ✅︎ | ✅︎ | | | +| `LTX2Pipeline` | LTX-2 / LTX-2.3 one-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` | ✅︎ | ✅︎ | | | +| `LTX2DistilledPipeline` | LTX-2 distilled two-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled` | ✅︎ | ✅︎ | | | +| `LingBotVideoPipeline` | LingBot-Video dense and MoE T2V | `robbyant/lingbot-video-dense-1.3b`, `robbyant/lingbot-video-moe-30b-a3b` | ✅︎ | | | | +| `MiniMaxH3Pipeline` | MiniMax H3 T2VA, first/last-keyframe FL2VA, and mixed-reference Ref2VA | `MiniMaxAI/MiniMax-H3` | ✅︎ | | | | | `DreamZeroPipeline` | DreamZero-DROID | `GEAR-Dreams/DreamZero-DROID` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `HeliosPipeline`, `HeliosPyramidPipeline` | Helios | `BestWishYsh/Helios-Base`, `BestWishYsh/Helios-Mid`, `BestWishYsh/Helios-Distilled` | ✅︎ | ✅︎ | ✅︎ | | | `MagiHumanPipeline` | MagiHuman | `SII-GAIR/daVinci-MagiHuman-Base-1080p` | ✅︎ | ✅︎ | | | | `OvisImagePipeline` | Ovis-Image | `OvisAI/Ovis-Image` | ✅︎ | ✅︎ | | ✅︎ | | `LongcatImagePipeline` | LongCat-Image | `meituan-longcat/LongCat-Image` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | +| `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` | ✅︎ | ✅︎ | | ✅︎ | | `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | ✅︎ | | `OmniVoiceModel` | OmniVoice | `k2-fsa/OmniVoice` | ✅︎ | | | | | `VoxCPM2TalkerForConditionalGeneration` | VoxCPM2 | `openbmb/VoxCPM2` | ✅︎ | | | | | `MammothModa2ForConditionalGeneration` | MammothModa2-Preview | `bytedance-research/MammothModa2-Preview` | ✅︎ | ✅︎ | | | +| `MammothModa2ForConditionalGeneration` | MammothModa2-Dev (AR-only image understanding) | `bytedance-research/MammothModa2-Dev` | ✅︎ | | | | | `Flux2KleinPipeline` | FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B`, `black-forest-labs/FLUX.2-klein-9B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `FluxKontextPipeline` | FLUX.1-Kontext-dev | `black-forest-labs/FLUX.1-Kontext-dev` | ✅︎ | ✅︎ | | | | `FluxPipeline` | FLUX.1-dev | `black-forest-labs/FLUX.1-dev` | ✅︎ | ✅︎ | | ✅︎ | @@ -83,7 +83,7 @@ th { | `MiMoAudioModel` | MiMo-Audio-7B-Instruct | `XiaomiMiMo/MiMo-Audio-7B-Instruct` | ✅︎ | ✅︎ | | | | `MiMoV2ASRForCausalLM` | MiMo-V2.5-ASR | `XiaomiMiMo/MiMo-V2.5-ASR` | ✅︎ | ✅︎ | | | | `Flux2Pipeline` | FLUX.2-dev | `black-forest-labs/FLUX.2-dev` | ✅︎ | ✅︎ | | | -| `FishSpeechSlowARForConditionalGeneration` | Fish Speech S2 Pro | `fishaudio/s2-pro` | ✅︎ | ✅︎ | | | +| `FishSpeechSlowARForConditionalGeneration` | Fish Speech S2 Pro | `fishaudio/s2-pro` | ✅︎ | ✅︎ | | ✅︎ | | `DreamIDOmniPipeline` | DreamID-Omni | `XuGuo699/DreamID-Omni` | ✅︎ | ✅︎ | | | | `SenseNovaU1Pipeline` | SenseNova-U1 (DiT-only) | `SenseNova/SenseNova-U1-8B-MoT` | ✅︎ | | | | | `LancePipeline` | Lance | `bytedance-research/Lance` | ✅︎ | | | | diff --git a/docs/serving/audio_generate_api.md b/docs/serving/audio_generate_api.md index afe693ae4..3109446c1 100644 --- a/docs/serving/audio_generate_api.md +++ b/docs/serving/audio_generate_api.md @@ -66,7 +66,7 @@ Content-Type: application/json |-----------|------|---------|-------------| | `input` | string | **required** | Text prompt describing the audio to generate | | `model` | string | server's model | Model to use (optional, should match server if specified) | -| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus | +| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, opus | | `speed` | float | 1.0 | Playback speed (0.25 - 4.0) | #### Diffusion Parameters @@ -90,7 +90,6 @@ Returns binary audio data with the appropriate `Content-Type` header: | `mp3` | `audio/mpeg` | | `flac` | `audio/flac` | | `pcm` | `audio/pcm` | -| `aac` | `audio/aac` | | `opus` | `audio/opus` | ## Examples @@ -285,7 +284,7 @@ Pydantic validation failure (e.g. invalid `response_format`, `speed` out of rang "detail": [ { "type": "literal_error", - "msg": "Input should be 'wav', 'pcm', 'flac', 'mp3', 'aac' or 'opus'", + "msg": "Input should be 'wav', 'pcm', 'flac', 'mp3' or 'opus'", ... } ] diff --git a/docs/serving/speech_api.md b/docs/serving/speech_api.md index 27dfda95f..0deea7f8c 100644 --- a/docs/serving/speech_api.md +++ b/docs/serving/speech_api.md @@ -109,7 +109,7 @@ Content-Type: application/json | `input` | string | **required** | The text to synthesize into speech | | `model` | string | server's model | Model to use (optional, should match server if specified) | | `voice` | string | "vivian" | Speaker name (e.g., vivian, ryan, aiden) | -| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus | +| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, opus | | `speed` | float | 1.0 | Playback speed (0.25-4.0) | #### vLLM-Omni Extension Parameters @@ -276,20 +276,42 @@ Client -> Server: | Message | Description | |---------|-------------| -| `{"type": "session.config", ...}` | Session configuration (sent once, first message) | +| `{"type": "session.config", ...}` | Session configuration (first message; may be resent between utterances to change it) | | `{"type": "input.text", "text": "..."}` | Text chunk | -| `{"type": "input.done"}` | End of input, flushes remaining buffer | +| `{"type": "input.done"}` | End of utterance: flushes the buffer and keeps the connection open | +| `{"type": "session.close"}` | End of connection | Server -> Client: | Message | Description | |---------|-------------| -| `{"type": "audio.start", "sentence_index": 0, "sentence_text": "...", "format": "pcm", "sample_rate": 24000}` | Audio generation starting for the buffered input | +| `{"type": "audio.start", "utterance_index": 0, "sentence_index": 0, "sentence_text": "...", "format": "pcm", "sample_rate": 24000}` | Audio generation starting for the buffered input | | Binary frame | Raw audio bytes (one or more PCM chunks when `stream_audio=true`) | -| `{"type": "audio.done", "sentence_index": 0, "total_bytes": 96000, "error": false}` | Audio complete for the buffered input | -| `{"type": "session.done", "total_sentences": N}` | Session complete | +| `{"type": "audio.done", "utterance_index": 0, "sentence_index": 0, "total_bytes": 96000, "error": false}` | Audio complete for the buffered input | +| `{"type": "session.done", "utterance_index": 0, "total_sentences": N}` | Flushed utterance complete | | `{"type": "error", "message": "..."}` | Non-fatal error | +### Flushing vs. Closing + +`input.done` is a flush, not a disconnect. The server synthesizes the buffered +text, emits `session.done`, and then waits on the same connection for the next +utterance, so a client that speaks repeatedly (for example one driven by an +upstream LLM) pays the WebSocket handshake once instead of once per utterance. + +* The session config is sticky. Send `input.text` again straight after + `session.done` to reuse it, or send another `session.config` first to change + voice, format, or reference audio. A `session.config` sent while text is + still buffered is rejected so no pending input is silently dropped. +* An utterance is the flush unit, not a linguistic one: it is whatever text was + buffered when `input.done` arrived, of any length, synthesized as one request. + `utterance_index` counts those flushes across the connection, so it tells you + which `input.done` a frame belongs to. `sentence_index` counts within one + flush and so pairs with `total_sentences`, which means every utterance reports + `sentence_index: 0` of `total_sentences: 1` (or `0` for an empty buffer). +* End the connection with `session.close`, or by closing the socket. An idle + connection is still closed after the server's idle timeout, which now also + applies to the gap between utterances. + ### Session Config Parameters All REST API parameters are supported, plus: diff --git a/docs/serving/videos_api.md b/docs/serving/videos_api.md index 72d79c304..6a94f9dab 100644 --- a/docs/serving/videos_api.md +++ b/docs/serving/videos_api.md @@ -73,7 +73,7 @@ curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o output.mp4 | `audio_reference` | string | null | JSON-encoded audio reference for speech-to-video: `{"audio_url": "..."}` — supports HTTP(s) URLs or base64 data URLs | | `width` | integer | model default | Output video width | | `height` | integer | model default | Output video height | -| `num_frames` | integer | model default | Number of generated frames | +| `num_frames` | integer | 1 | Number of generated frames | | `fps` | integer | model default | Output frames per second | | `num_inference_steps` | integer | model default | Number of diffusion steps | | `guidance_scale` | number | null | CFG guidance scale for the low-noise stage | diff --git a/docs/source/architecture/qwen3-omni-async-output-step-gap.svg b/docs/source/architecture/qwen3-omni-async-output-step-gap.svg new file mode 100644 index 000000000..f1ccea6ee --- /dev/null +++ b/docs/source/architecture/qwen3-omni-async-output-step-gap.svg @@ -0,0 +1,150 @@ + + Qwen3-Omni Talker step gap before and after asynchronous output materialization + Before the optimization, synchronous D2H copy, per-request payload construction, streaming payload handoff, and connector-signal drain separate consecutive Talker forward and sample steps by about 2.8 milliseconds. After the optimization, that Omni output work runs on a background builder while the next Talker step executes, leaving a boundary of about 41 microseconds for snapshot, D2H enqueue, state update, and sampled-token feedback. + + + + + + + + + + + + + + + + + + + Async output: what moves off the Talker decode path + Qwen3-Omni Talker stage · consecutive decode steps in a fixed profiler window + + + + Before + Omni output built + synchronously on the + AR critical path + + AR path + + + + TALKER STAGE · STEP N + forward → sample + + + SYNC OMNI OUTPUT · STEP N + D2H → per-request slice / payload build + streaming handoff → connector-signal drain + + + TALKER STAGE · STEP N+1 + forward → sample + + + ~2.8 ms GPU gap: output work is inline + + + + After + Only step feedback + stays inline; payload + work runs in the + background + + Talker GPU + Output builder + + + + TALKER STAGE · STEP N + forward → sample + + + TALKER STAGE · STEP N+1 + forward → sample + + + + BACKGROUND OMNI OUTPUT · STEP N + wait for D2H → per-request slice / payload build + streaming handoff → connector-signal drain + + + time + + + + + + + + Zoom · Talker step boundary (magnified) + + + + Talker step N + Talker step N+1 + + + ~41 µs + + + Inline at the ~41 µs boundary + • eager Talker state update + • output snapshot + D2H enqueue + • sampled-token feedback + + + + Step-to-step gap + 2.8 ms → 41 µs + ~68× tighter + + + Talker GPU + next step starts immediately + decode workers stay busy + + + Background builder + materializes Omni output + payload work overlaps the next Talker step + diff --git a/docs/user_guide/diffusion/attention_backends.md b/docs/user_guide/diffusion/attention_backends.md index 50e02971f..7dc35b367 100644 --- a/docs/user_guide/diffusion/attention_backends.md +++ b/docs/user_guide/diffusion/attention_backends.md @@ -14,7 +14,8 @@ The full set of backends and their platform defaults is in the **Backend Options | Value | Notes | |---|---| -| `FLASH_ATTN` | Wraps FlashAttention 2. Default on Hopper / Ada / Ampere when `flash-attn` is installed. | +| `TRTLLM_ATTN` | FlashInfer's trtllm-gen FMHA (TensorRT-LLM's generated kernels, vendored by FlashInfer). Dense BF16, GQA native, `head_dim=128`. Datacenter Blackwell only (sm_100 / sm_103). Packed paths can provide `cu_seqlens` directly. Supports optional **Skip-Softmax** sparse attention — see below. Requires `flashinfer`. | +| `FLASH_ATTN` | Wraps FlashAttention 4 on Blackwell when `flash-attn-4` is installed, then falls back to FlashAttention 3/2. Default on Hopper / Ada / Ampere when a compatible FlashAttention package is installed. | | `CUDNN_ATTN` | Pins `sdpa_kernel([CUDNN_ATTENTION])`. Default on Blackwell (sm_10x / sm_12x) with cuDNN ≥ 9.5. Wins on mask-heavy DiTs (HunyuanVideo-1.5: 2× e2e vs SDPA). | | `FLASHINFER_ATTN` | Calls FlashInfer's dense `single_prefill_with_kv_cache` directly with `custom_mask` for non-causal masked attention. Used as Blackwell fallback when cuDNN is unavailable. Requires `flashinfer`. | | `TORCH_SDPA` | PyTorch `scaled_dot_product_attention` with the default backend dispatcher. Most conservative; always available. | @@ -22,6 +23,7 @@ The full set of backends and their platform defaults is in the **Backend Options | `SAGE_ATTN_3` | Requires `sageattn3` from `SageAttention/sageattention3_blackwell`. CUDA only, intended for Blackwell GPUs, with GQA/MQA requests falling back to PyTorch SDPA. | | `FLASH_ATTN_HUB` | FlashAttention 2 from HuggingFace `kernels` library. Useful for train/rollout alignment. | | `FLASH_ATTN_3_HUB` | FlashAttention 3 from HuggingFace `kernels` library. CUDA Hopper (sm_90+) only; falls back to `FLASH_ATTN_HUB` on older GPUs. | +| `RAINFUSION_ATTN` | MindIE-SD **RainFusion** block-sparse video attention — see [below](#rainfusion_attn-backend-and-block-sparse-video-attention). Ascend NPU only; requires `mindiesd`. Delegates to `FLASH_ATTN` for anything that is not a packed video sequence. | ## Configuration @@ -68,11 +70,13 @@ vllm-omni serve \ --diffusion-attention-config '{"default":{"backend":"FLASH_ATTN"},"per_role":{"cross":{"backend":"TORCH_SDPA"}}}' ``` -Backends may also accept backend-specific parameters via `extra`: +A backend that needs configuration exposes it as a typed field on the spec: +`TRTLLM_ATTN`'s Skip-Softmax (see [below](#trtllm_attn-backend-and-skip-softmax)) and +`RAINFUSION_ATTN`'s RainFusion block (see [below](#rainfusion_attn-backend-and-block-sparse-video-attention)): ```bash ---diffusion-attention-config.per_role.self.backend SPARSE_BLOCK \ ---diffusion-attention-config.per_role.self.extra.block_size 128 +--diffusion-attention-config.default.backend TRTLLM_ATTN \ +--diffusion-attention-config.default.skip_softmax.target_sparsity 0.5 ``` ### Programmatic API @@ -101,10 +105,15 @@ A plain dict is also accepted and normalized to `AttentionConfig`. Auto-route preference, in order: -1. `CUDNN_ATTN` — when cuDNN ≥ 9.5 is available (ships in PyTorch 2.5+ wheels) -2. `FLASHINFER_ATTN` — when `flashinfer` is installed but cuDNN < 9.5 -3. `FLASH_ATTN` — when `flash-attn` is installed with the Blackwell CUTE kernel -4. `TORCH_SDPA` — last resort +1. `TRTLLM_ATTN` — on **datacenter** Blackwell (sm_100 / sm_103) when `flashinfer` is installed, the model's `head_dim` is 128, **and the model declares a compatible packed/mask-free path** +2. `CUDNN_ATTN` — when cuDNN ≥ 9.5 is available (ships in PyTorch 2.5+ wheels) +3. `FLASHINFER_ATTN` — when `flashinfer` is installed but cuDNN < 9.5 +4. `FLASH_ATTN` — when `flash-attn` is installed with the Blackwell CUTE kernel +5. `TORCH_SDPA` — last resort + +`TRTLLM_ATTN` is skipped on workstation Blackwell (sm_120 / sm_121) and for any `head_dim != 128`, so those GPUs keep the `CUDNN_ATTN` route described below. + +`TRTLLM_ATTN` outranks `CUDNN_ATTN` on datacenter Blackwell for compatible packed/mask-free pipelines. Workstation Blackwell (sm_120 / sm_121) and pipelines that require attention masks retain their normal fallback. The startup log line `Defaulting to diffusion attention backend CUDNN_ATTN (Blackwell sm_120, cuDNN 91002)` confirms the route. @@ -119,6 +128,158 @@ Auto-route preference: `CUDNN_ATTN` and `FLASHINFER_ATTN` are still selectable via env var on these GPUs but are not in the auto-route — FlashAttention 2 is the well-tuned path on pre-Blackwell hardware. +## TRTLLM_ATTN Backend and Skip-Softmax + +`TRTLLM_ATTN` runs FlashInfer's trtllm-gen FMHA. Dense it is a BF16 backend like the others; on top of +that it can enable **Skip-Softmax**, a sparse-attention mode that trades a little fidelity for speed +(algorithm: [design doc](../../design/feature/skip_softmax.md)). + +Enable it through the typed `skip_softmax` block on the attention spec: + +| Key | Valid values | Meaning | +|---|---|---| +| `target_sparsity` | finite, `[0, 1]` | Operating point on a calibrated curve. Needs a calibration for the model. | +| `threshold` | finite, `≥ 0` | Direct threshold, no calibration needed. Mutually exclusive with `target_sparsity`. | +| `disabled_until_timestep` | finite, `[0, 1]` | Holds early, high-noise steps dense; skip turns on once the normalized timestep `t ≤ D`. | + +```bash +vllm-omni serve Wan-AI/Wan2.2-T2V-A14B-Diffusers \ + --diffusion-attention-config '{"default": {"backend": "TRTLLM_ATTN", + "skip_softmax": {"target_sparsity": 0.65, "disabled_until_timestep": 0.86}}}' +``` + +Programmatically the same block is a typed `SkipSoftmaxSpec` (values validated at construction): + +```python +from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, SkipSoftmaxSpec + +AttentionConfig( + default=AttentionSpec( + backend="TRTLLM_ATTN", + skip_softmax=SkipSoftmaxSpec(target_sparsity=0.65, disabled_until_timestep=0.86), + ), +) +``` + +**Start at `target_sparsity=0.65, disabled_until_timestep=0.86`** and raise `target_sparsity` only +as far as your quality bar allows — the output diverges from dense as it climbs. The gain grows with +sequence length, since Skip-Softmax only accelerates attention. Dense BF16 is `TRTLLM_ATTN` with no +`skip_softmax` block. + +Requires datacenter Blackwell with `head_dim == 128`; elsewhere, or without FlashInfer, selecting +`TRTLLM_ATTN` raises rather than silently degrading. + +## TRTLLM_ATTN SAGE Quantization + +On top of dense/Skip-Softmax, `TRTLLM_ATTN` can run **SAGE** (SageAttention) — the trtllm-gen +FMHA kernel with per-block quantized Q/K and per-channel fp8 V, so the two attention matmuls run +in low precision. Enable it through the typed `quant` block on the attention spec: + +| Key | Valid values | Meaning | +|---|---|---| +| `dtype_qk` | `int8`, `fp8_e4m3` | Quantization dtype for Q and K. Absent ⇒ dense (SAGE off). | +| `q_block_size` | `1`, `4`, `16` | Per-token block size for Q scales (default `1`). | +| `k_block_size` | `1`, `4`, `16` | Per-token block size for K scales (default `16`). | + +V is always quantized per-channel to fp8_e4m3 and K-smoothing is applied inside the routine, so +neither is a user knob. Only block sizes `1`, `4`, `16` have compiled kernels. + +The `quant` block is a shared `AttnQuantSpec` consumed by more than one backend; each reads the +fields that apply and rejects the rest. `TRTLLM_ATTN` (SAGE) reads `dtype_qk` (`int8` / `fp8_e4m3`), +`q_block_size`, and `k_block_size`; `FLASHINFER_ATTN` reads `dtype_qk` (`float16` / `bfloat16`), +`dtype_vo`, and `flashinfer_backend` (see its section). A `dtype_qk` from the wrong set raises. + +```bash +vllm-omni serve Wan-AI/Wan2.2-T2V-A14B-Diffusers \ + --diffusion-attention-config '{"default": {"backend": "TRTLLM_ATTN", + "quant": {"dtype_qk": "fp8_e4m3", "q_block_size": 1, "k_block_size": 16}}}' +``` + +```python +from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, AttnQuantSpec + +AttentionConfig( + default=AttentionSpec( + backend="TRTLLM_ATTN", + quant=AttnQuantSpec(dtype_qk="fp8_e4m3", q_block_size=1, k_block_size=16), + ), +) +``` + +**Requirements.** Needs FlashInfer ≥ 0.6.16rc1. Kernel +availability is arch-dependent: `fp8_e4m3` QK has kernels on both **SM100** (B200) and **SM103** +(B300); `int8` QK kernels are compiled for **SM100 only**. + +## RAINFUSION_ATTN Backend and Block-Sparse Video Attention + +`RAINFUSION_ATTN` runs MindIE-SD's RainFusion (`rf_v2`) kernel on Ascend NPU. It pools each +128-token block of keys, ranks them per query block, and computes attention against only the +top-scoring ones. Video tokens are rearranged into `(t, h, w)` order first, so the kept blocks are +spatiotemporal neighbours rather than arbitrary rows of the packed sequence. + +Only the video segment is sparsified. The prefix rows (text, visual conditions, audio) and the +first-frame blocks are always kept dense, which is why the realized sparsity is lower than the +nominal `sparsity` you configure. Anything the kernel cannot handle — a warmup denoise step, an +exempt layer, a sequence with no published video geometry, a video segment under 32 blocks — +delegates to `FLASH_ATTN`, so a model can select this backend unconditionally. + +Enable it through the typed `block_sparse` block on the attention spec, shared by every +block-sparse backend: + +| Key | Valid values | Meaning | +|---|---|---| +| `sparsity` | finite, `[0, 1]` | Nominal fraction of key blocks dropped per query block. `0` disables sparsity. Defaults to `0.8`. | +| `start_step` | `≥ 0` | Keep the first N denoise steps dense. Layout is decided early, so these steps dominate structural fidelity. | +| `skip_layers` | index selector, e.g. `"0-3,38"` | DiT blocks that always stay dense. | + +```bash +vllm-omni serve MiniMaxAI/MiniMax-H3 \ + --diffusion-attention-config '{"default": {"backend": "RAINFUSION_ATTN", + "block_sparse": {"sparsity": 0.8, "start_step": 0}}}' +``` + +Programmatically the same block is a typed `BlockSparseSpec` (values validated at construction): + +```python +from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, BlockSparseSpec + +AttentionConfig( + default=AttentionSpec( + backend="RAINFUSION_ATTN", + block_sparse=BlockSparseSpec(sparsity=0.8, start_step=0, skip_layers="0-1"), + ), +) +``` + +**Tune in the order `start_step` → `sparsity` → `skip_layers`.** Raise `start_step` first: it is the +cheapest way to recover structure, because the early high-noise steps decide global layout while the +later steps only refine texture. Then walk `sparsity` up to your quality bar. Reach for +`skip_layers` last, once an A/B against dense at the same seed points at specific blocks. + +Requires Ascend NPU with `mindiesd`; selecting it on any other platform raises. It is also +incompatible with ring sequence parallelism, since `rf_v2` needs the whole key sequence to rank +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. + +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. + +"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`. + ## End-to-End Benchmark (BF16, sm_120 RTX Pro 6000 Blackwell) Same prompt and seed across runs. `Total generation time` from `text_to_video.py` / `text_to_image.py`. @@ -145,9 +306,17 @@ DIFFUSION_ATTENTION_BACKEND=FLASHINFER_ATTN python examples/offline_inference/te --model Lightricks/LTX-2 ... ``` -### FA4 not yet integrated +### FlashAttention-4 on Blackwell -FlashAttention-4 (released March 2026) targets Blackwell natively and reportedly beats cuDNN by ~20% on B200. As of this writing the `flash-attn-4 4.0.0b10` wheel crashes with `AttributeError: 'NoneType' object has no attribute '_trait'` during JIT on sm_120. Not yet wired into vLLM-Omni; revisit when stable lands. +Install the optional CUDA 13 extra to use the CuTe-based FA4 path: + +```bash +pip install 'vllm-omni[fa4]' +``` + +`FLASH_ATTN` prefers `flash_attn.cute` on Blackwell and falls back to FA3/FA2 +when it is unavailable. Version `4.0.0b18` is required; earlier beta +wheels had known JIT failures on Blackwell. ## Choosing a Backend Manually @@ -305,64 +474,3 @@ DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN_3 python examples/offline_inference/text_t --tensor-parallel-size 2 \ --output outputs/hv15_sage3.mp4 ``` - -### Mixed backends across roles - -Use `FLASH_ATTN` for self-attention and `TORCH_SDPA` for cross-attention: - -```bash -python examples/offline_inference/text_to_video/text_to_video.py \ - --model Wan-AI/Wan2.2-TI2V-5B-Diffusers \ - --prompt "A dog running across a field of golden wheat." \ - --diffusion-attention-config.per_role.self.backend FLASH_ATTN \ - --diffusion-attention-config.per_role.cross.backend TORCH_SDPA \ - --tensor-parallel-size 2 \ - --output outputs/wan22_mixed.mp4 -``` - -### Compare against FlashAttention - -Unset the backend override, or explicitly use `FLASH_ATTN`: - -```bash -python examples/offline_inference/text_to_video/text_to_video.py \ - --model Wan-AI/Wan2.2-TI2V-5B-Diffusers \ - --prompt "A dog running across a field of golden wheat." \ - --height 704 --width 1280 --num-frames 49 \ - --num-inference-steps 30 --seed 42 --guidance-scale 5.0 \ - --tensor-parallel-size 2 \ - --output outputs/wan22_fa3.mp4 -``` - -## Validation Guidance - -Don't assume a faster attention backend is numerically interchangeable with `TORCH_SDPA`. - -Always compare: - -- End-to-end runtime -- Diffusion-stage runtime (`add_req_and_wait` line in DiffusionEngine.step breakdown) -- Output quality against a known-good baseline (CLIP similarity, frame-level diff, or visual review) - -At minimum, keep the same: - -- model -- prompt -- seed -- resolution -- frame count / step count -- parallel config (TP / CFG-parallel / Ulysses degrees) - -## Reproducing the Benchmark Table - -The end-to-end numbers above were collected by running `text_to_video.py` / -`text_to_image.py` with the same prompt and seed while varying -`DIFFUSION_ATTENTION_BACKEND`. For a quick kernel-level comparison of the -backends without loading a model: - -```bash -python benchmarks/diffusion/bench_attention_backends.py --preset hv15 -``` - -It runs all three BF16 backends on representative DiT attention shapes and -prints a ranking table at the end. diff --git a/docs/user_guide/diffusion/cpu_offload_diffusion.md b/docs/user_guide/diffusion/cpu_offload.md similarity index 56% rename from docs/user_guide/diffusion/cpu_offload_diffusion.md rename to docs/user_guide/diffusion/cpu_offload.md index d6fcd8944..02ab9f074 100644 --- a/docs/user_guide/diffusion/cpu_offload_diffusion.md +++ b/docs/user_guide/diffusion/cpu_offload.md @@ -179,7 +179,145 @@ class Flux2Transformer2DModel(nn.Module): - Support single GPU only for now -### Implementation Notes +## Distributed Layerwise Offloading + +### How It Works + +Distributed layerwise offloading extends single-GPU layerwise offloading to +multi-device deployments. In the default mode, each rank stores only +**1/dp_size** of the model weights in host memory; full layer weights are +reconstructed at runtime via **AllGather** on a dedicated communication stream, +overlapped with computation via a fixed double-buffer scheme. The +`--dlo-no-use-allgather` mode instead streams standard-loader rank-local +weights independently and does not shard weights across ranks. + +For the implementation design and compatibility matrix, see +[Distributed Layerwise Offload](../../design/feature/distributed_layerwise_offload.md). + +**Key features:** +- **Weight sharding + AllGather**: each rank stores 1/dp_size of weights, + reconstructed per-layer via `all_gather_into_tensor` +- **Fixed double-buffer**: exactly 2 transformer blocks on each device at any + time, regardless of model size +- **DP multi-concurrency**: N concurrent requests processed in parallel + (AllGather only gathers weight shards, which are request-independent) +- **mmap weight loading**: weights loaded as mmap views pointing to shared OS + page cache, eliminating O(dp_size × model_size) RSS during model creation +- **Platform-agnostic**: works on NVIDIA GPU (CUDA/NCCL) and Ascend NPU + (CANN/HCCL) via vLLM-Omni's platform abstraction + +**Execution Flow (per device):** + +``` +Compute Stream: [Layer N] [Layer N+1] [Layer N+2] +H2D Stream: [H2D Shard N+1] [H2D Shard N+2] +AllGather: [AG N+1] [AG N+2] +Slot Usage: Slot0: Layer N Slot1: Layer N+1 +``` + +### Usage + +**CLI:** +```bash +# 4× GPU/NPU with AllGather (recommended) +vllm serve /path/to/model --omni \ + --enable-distributed-layerwise-offload \ + --data-parallel-size 4 + +# Without DLO AllGather (each rank streams standard-loader rank-local weights) +vllm serve /path/to/model --omni \ + --enable-distributed-layerwise-offload \ + --data-parallel-size 4 \ + --dlo-no-use-allgather + +# With SP instead of DP (long sequences) +vllm serve /path/to/model --omni \ + --enable-distributed-layerwise-offload \ + --usp 4 +``` + +**Python API:** +```python +from vllm_omni import Omni + +m = Omni( + model="/path/to/model", + enable_distributed_layerwise_offload=True, + dlo_use_allgather=True, # default +) +``` + +### CLI Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--enable-distributed-layerwise-offload` | Enable DLO | `false` | +| `--data-parallel-size N` | Number of DP ranks; DLO weight-sharding group when AllGather is enabled | `1` | +| `--dlo-use-allgather` | Shard + AllGather (saves CPU, requires concurrent requests) | `true` | +| `--dlo-no-use-allgather` | Use standard-loader rank-local weights; no DLO sharding or AllGather | `false` | + +### How model weights are loaded (mmap path) + +When DLO + AllGather is active, the offloader: + +1. **Saves non-persistent buffers** (e.g. RoPE `inv_freq`, timestep `freqs`) + from the normally-created transformer +2. **Converts to meta device** via `to_empty(device="meta")`, releasing random + initialization weights +3. **Loads checkpoint weights as mmap views** via `safe_open().get_tensor()`, + which return views into the OS page cache (shared across all ranks, 0 RSS) +4. **Calls `post_load_weights()`** to apply model-specific dtype conversions + (e.g. Cosmos3's `time_embedder` → FP32) +5. **Restores non-persistent buffers** from saved copies + +This approach requires **zero model-specific code changes** — no pipeline or +transformer modifications are needed. + +### OffloadPlan (declarative topology metadata) + +Models can optionally declare an `OffloadPlan` class variable to provide +topology metadata (block attribute names, submodules to offload) without +any offload-specific logic: + +```python +from vllm_omni.diffusion.offloader import OffloadPlan + +class MyPipeline(nn.Module): + _dit_modules = ["transformer"] + _offload_plan = OffloadPlan( + block_attrs={"transformer": ("blocks",)}, + offload_submodules={"context_encoder": "layers"}, + ) +``` + +When not declared, the offloader falls back to `_layerwise_offload_blocks_attrs` +and heuristic attribute search. This is backward-compatible — existing models +work without any changes. + +### DP Multi-concurrency + +When `--data-parallel-size > 1` and AllGather is enabled, the scheduler batches +up to `dp_size` requests per denoise step. Each DP rank processes a different +request while AllGather synchronizes only weight shards (request-independent). + +**Requirements for concurrent requests:** +- `num_inference_steps` must be specified explicitly (None is not allowed) +- All concurrent requests must have the same `num_inference_steps` value +- These constraints exist because AllGather is a collective that requires + every rank to participate at each denoise step + +### Limitations +- Online quantization (FP8) is incompatible with mmap loading — falls back to + regular loading only when the model is outside the DLO+AllGather mmap path; + online quantization with DLO+AllGather is rejected. Use + `--dlo-no-use-allgather` or disable online quantization. +- Tensor Parallel > 1 is rejected in the DLO+AllGather mmap path because TP-aware + weight-loader callbacks are bypassed. The no-AllGather path retains the + standard TP loader and is experimental. +- HSDP + AllGather is rejected because it would double-shard parameters. HSDP + with no-AllGather is accepted at configuration level, but has limited + end-to-end validation. +- `num_inference_steps=None` is not allowed in DP multi-concurrency mode **Module Discovery** @@ -208,7 +346,10 @@ OffloadBackend (base class) ├── ModelLevelOffloadBackend → uses SequentialOffloadHook (.to() swap) │ (delegates to a pipeline's enable_omni_model_cpu_offload │ for split models like Cosmos3) -└── LayerWiseOffloadBackend → uses LayerwiseOffloadHook +├── LayerWiseOffloadBackend → uses LayerwiseOffloadHook +│ (single-GPU, full weights on host) +└── DistributedLayerwiseOffloadBackend → uses DistributedLayerwiseOffloadHook + (multi-GPU, sharded AllGather or rank-local mode) ``` Factory function `get_offload_backend()` selects the appropriate backend based on @@ -221,19 +362,28 @@ reasoner/generator components inside the model forward pass. ## Supported Models -| Architecture | Example Models | DiT Class | Model-Level Offload | Layerwise Offload | Blocks Attrs (Layerwise specific) | -|--------------|----------------|-----------|---------------------|-------------------|-----------------------------------| -| LongCatImagePipeline | `meituan-longcat/LongCat-Image` | `LongCatImageTransformer2DModel` | - | ✓ | `"transformer_blocks"`, `"single_transformer_blocks"` | -| NextStep11Pipeline | `stepfun-ai/NextStep-1.1` | `NextStepModel` | - | ✓ | `"layers"` | -| OvisImagePipeline | `AIDC-AI/Ovis-Image-7B` | `OvisImageTransformer2DModel` | - | ✓ | `"transformer"` | -| QwenImagePipeline | `Qwen/Qwen-Image` | `QwenImageTransformer2DModel` | ✓ | ✓ | `"transformer_blocks"` | -| StableDiffusionXLPipeline | `stabilityai/stable-diffusion-xl-base-1.0` | `SDXLUNet2DConditionModel` | ✓ | ✓ | `"down_blocks"`, `"up_blocks"` | -| StableDiffusion3Pipeline | `stabilityai/stable-diffusion-3.5-medium` | `SD3Transformer2DModel` | - | ✓ | `"transformer_blocks"` | -| Wan22I2VPipeline | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | `WanTransformer3DModel` | ✓ | ✓ | `"blocks"` | -| Wan22Pipeline | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | `WanTransformer3DModel` | ✓ | ✓ | `"blocks"` | -| SoulXSingerPipeline / SoulXSingerSVCPipeline | `Soul-AILab/SoulX-Singer` | `DiffLlama` (`cfm_decoder.model.diff_estimator`) | ✓ | ✓ | `"layers"` | -| BagelPipeline | `ByteDance-Seed/BAGEL-7B-MoT` | `Qwen2MoTModel` | - | ✓ | `"layers"`, `"customized modules"` | +| Architecture | Example Models | DiT Class | Model-Level Offload | Layerwise Offload | Distributed Layerwise Offload | Blocks Attrs (Layerwise specific) | +|--------------|----------------|-----------|---------------------|-------------------|-------------------------------|-----------------------------------| +| Flux2Pipeline | `black-forest-labs/FLUX.2-dev` | `Flux2Transformer2DModel` | ✓ | ✓ | - | `"transformer_blocks"`, `"single_transformer_blocks"` | +| LongCatImagePipeline | `meituan-longcat/LongCat-Image` | `LongCatImageTransformer2DModel` | - | ✓ | - | `"transformer_blocks"`, `"single_transformer_blocks"` | +| NextStep11Pipeline | `stepfun-ai/NextStep-1.1` | `NextStepModel` | - | ✓ | - | `"layers"` | +| OvisImagePipeline | `AIDC-AI/Ovis-Image-7B` | `OvisImageTransformer2DModel` | - | ✓ | - | `"transformer"` | +| QwenImagePipeline | `Qwen/Qwen-Image` | `QwenImageTransformer2DModel` | ✓ | ✓ | - | `"transformer_blocks"` | +| StableDiffusionXLPipeline | `stabilityai/stable-diffusion-xl-base-1.0` | `SDXLUNet2DConditionModel` | ✓ | ✓ | - | `"down_blocks"`, `"up_blocks"` | +| StableDiffusion3Pipeline | `stabilityai/stable-diffusion-3.5-medium` | `SD3Transformer2DModel` | - | ✓ | - | `"transformer_blocks"` | +| Wan22I2VPipeline | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | `WanTransformer3DModel` | ✓ | ✓ | - | `"blocks"` | +| Wan22Pipeline | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | `WanTransformer3DModel` | ✓ | ✓ | - | `"blocks"` | +| SoulXSingerPipeline / SoulXSingerSVCPipeline | `Soul-AILab/SoulX-Singer` | `DiffLlama` (`cfm_decoder.model.diff_estimator`) | ✓ | ✓ | - | `"layers"` | +| BagelPipeline | `ByteDance-Seed/BAGEL-7B-MoT` | `Qwen2MoTModel` | - | ✓ | - | `"layers"`, `"customized modules"` | +| Cosmos3OmniDiffusersPipeline | `nvidia/Cosmos3-Nano`, `nvidia/Cosmos3-Super` | `Cosmos3VFMTransformer`, `Cosmos3LanguageModel` | ✓ | ✓ | ✓ | `"layers"`, `"gen_layers"` | **Notes:** - Model-Level Offloading is expected to be supported by all common diffusion models (DiT and encoders) naturally - Layerwise Offloading requires DiT class to define `_layerwise_offload_blocks_attrs` pointing to transformer blocks +- Distributed Layerwise Offloading reuses the layerwise block topology, but the + model and parallelism combination still needs validation. AllGather mode + additionally requires a compatible checkpoint/mmap layout; no-AllGather mode + retains the standard loader's rank-local tensors. See the + [DLO design](../../design/feature/distributed_layerwise_offload.md) and + [Cosmos3 DistOffload recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/cosmos3/Cosmos3-DistOffload.md) + for usage examples. diff --git a/docs/user_guide/diffusion/execution_modes.md b/docs/user_guide/diffusion/execution_modes.md new file mode 100644 index 000000000..9611a8210 --- /dev/null +++ b/docs/user_guide/diffusion/execution_modes.md @@ -0,0 +1,252 @@ +# Diffusion Execution Modes + +vLLM-Omni supports complete-request execution and step-wise diffusion +execution. Both modes use the same asynchronous engine output stream; the +configuration controls how work is scheduled and whether intermediate outputs +are exposed. + +## Choose a Mode + +| Goal | CLI configuration | +|---|---| +| Serial request execution | `--max-num-seqs 1` | +| Fused request-level batching | `--max-num-seqs N` | +| Single-request step execution | `--step-execution --max-num-seqs 1` | +| Step-wise continuous batching | `--step-execution --max-num-seqs N` | +| Chunked diffusion output | `--diffusion-streaming-output` | + +`N` must be greater than `1` to allow batching. The selected pipeline must +support request-level batching or batched-step execution as appropriate; +single-request step support alone is not sufficient for step-wise continuous +batching. + +## Request Execution + +Request execution is the default when `--step-execution` is omitted. The +pipeline performs a complete `forward()` for each scheduler wave. + +### Serial Requests + +Use the conservative serial path with any request-mode pipeline: + +```bash +vllm serve MODEL --omni \ + --port 8091 \ + --max-num-seqs 1 +``` + +### Request-Level Batching + +Set `max_num_seqs` above one to combine compatible independent requests into a +single pipeline forward: + +```bash +vllm serve Qwen/Qwen-Image --omni \ + --port 8091 \ + --max-num-seqs 4 +``` + +For bursty traffic, add a small admission window: + +```bash +vllm serve Qwen/Qwen-Image --omni \ + --port 8091 \ + --max-num-seqs 4 \ + --request-batch-max-wait-ms 20 +``` + +`--request-batch-max-wait-ms 0` is the default. A nonzero value can improve +batch formation but adds up to that much latency before a new scheduler wave. +It has no effect in step mode. + +Each prompt remains a separate logical request. Do not submit a top-level list +as one packed prompt. Concurrent serving requests are batched internally when +their shapes, guidance settings, output counts, and LoRA settings are +compatible. + +Only pipelines that declare request-batch support accept +`max_num_seqs>1` in request mode. Unsupported pipelines fail during engine +initialization; use `max_num_seqs=1` instead. + +## Step Execution + +Step execution lets the scheduler advance and abort requests between denoise +steps: + +```bash +vllm serve Qwen/Qwen-Image --omni \ + --port 8091 \ + --step-execution \ + --max-num-seqs 1 +``` + +Set a larger capacity to allow compatible requests to share denoise waves: + +```bash +vllm serve Qwen/Qwen-Image --omni \ + --port 8091 \ + --step-execution \ + --max-num-seqs 8 +``` + +Step continuous batching is experimental. Start with `max_num_seqs=1` when +validating a model or debugging correctness, then increase it for +multi-request throughput. + +Step execution is capability-based, not a generic switch for every diffusion +model. Qwen-Image supports step-wise continuous batching. HunyuanImage3 also +supports it, but only when its resolved self-attention backend is `TORCH_SDPA`; +set `DIFFUSION_ATTENTION_BACKEND=TORCH_SDPA` or configure +`diffusion_attention_config.default.backend=TORCH_SDPA` before using +`--max-num-seqs >1`. See the +[HunyuanImage-3.0 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Tencent/HunyuanImage-3.0-Instruct.md) +for its validated configuration. Helios supports single-request step execution only: use +`--step-execution --max-num-seqs 1` for Helios. Consult the selected pipeline's +documentation and source for the latest support status. + +## Streaming Output + +Use `--diffusion-streaming-output` for a pipeline that can produce intermediate +diffusion outputs: + +```bash +vllm serve BestWishYsh/Helios-Distilled --omni \ + --port 8000 \ + --diffusion-streaming-output +``` + +Streaming output requires step execution. If +`--diffusion-streaming-output` is set without `--step-execution`, the engine +enables step execution automatically. Model initialization fails if the +pipeline does not implement step execution. + +Chunk-capable pipelines emit intermediate and final outputs through the same +request stream. Final-only step pipelines emit only the final result even when +streaming output is enabled. + +The normal non-streaming serving path uses the same internal stream but drains +it and returns only the final output. Users do not need to enable +`--diffusion-streaming-output` to benefit from the unified engine lifecycle. + +## Send Requests + +Execution-mode flags configure the server. They do not change the +OpenAI-compatible client request format. For example, after starting any of +the non-streaming configurations above: + +```bash +curl -X POST http://localhost:8091/v1/images/generations \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "a cat sitting on a windowsill", + "size": "1024x1024", + "num_inference_steps": 50, + "seed": 42 + }' | jq -r '.data[0].b64_json' | base64 -d > cat.png +``` + +To create a request-level batch, send multiple independent requests +concurrently. Do not put several prompts into one `prompt` field: + +```bash +for prompt in "a red fox" "a blue bird" "a green frog"; do + curl -s -X POST http://localhost:8091/v1/images/generations \ + -H "Content-Type: application/json" \ + -d "{\"prompt\":\"${prompt}\",\"size\":\"1024x1024\",\"seed\":42}" \ + > /dev/null & +done +wait +``` + +The scheduler may batch these requests when their sampling parameters are +compatible. See the +[Image Generation API](../../serving/image_generation_api.md) for response +formats and additional client examples. Streaming models can use +model-specific streaming endpoints documented by their serving guide. + +## Python API + +The Python arguments mirror the CLI flags: + +```python +from vllm_omni import Omni + +omni = Omni( + model="Qwen/Qwen-Image", + max_num_seqs=4, + request_batch_max_wait_ms=20.0, +) + +outputs = omni.generate( + [ + "a cup of coffee on a table", + "a toy dinosaur on a sandy beach", + "a fox waking up in bed and yawning", + ] +) +``` + +Each list item passed to `Omni.generate()` becomes an independent logical +request that the scheduler may batch with compatible items. + +For step execution: + +```python +from vllm_omni import Omni +from vllm_omni.inputs.data import OmniDiffusionSamplingParams + +omni = Omni( + model="Qwen/Qwen-Image", + step_execution=True, + max_num_seqs=1, +) + +outputs = omni.generate( + "A cat sitting on a windowsill", + OmniDiffusionSamplingParams(num_inference_steps=50), +) +``` + +## Stage Configuration + +Use the equivalent engine arguments in a deployment YAML: + +```yaml +stage_args: + - stage_id: 0 + stage_type: diffusion + engine_args: + step_execution: false + max_num_seqs: 4 + request_batch_max_wait_ms: 20 +``` + +For step execution, set `step_execution: true` and remove +`request_batch_max_wait_ms`. + +## CLI Reference + +| Flag | Default | Effect | +|---|---:|---| +| `--step-execution` | disabled | Select step-wise scheduling | +| `--max-num-seqs` | `1` for diffusion stages | Set request- or step-scheduler capacity | +| `--request-batch-max-wait-ms` | `0` | Wait for burst coalescing in request mode | +| `--diffusion-streaming-output` | disabled | Expose supported intermediate diffusion outputs and require step execution | + +## Limitations and Troubleshooting + +- Requests batch only when their compatibility-sensitive parameters match. +- Different LoRA adapters or scales run in separate batches. +- FIFO scheduling can cause an incompatible request to block later compatible + requests. +- All diffusion cache backends are unsupported in step mode. KV transfer is + supported for newly admitted step requests; some other request-mode extras + remain unsupported. +- If request-mode startup reports that the pipeline does not support batching, + use `--max-num-seqs 1`. +- If step-mode startup mentions `prepare_encode()`, `denoise_step()`, + `step_scheduler()`, or `post_decode()`, the pipeline does not implement the + required step contract. + +For implementation details and model-author guidance, see +[Diffusion Continuous Batching](../../design/feature/diffusion_continuous_batching.md). diff --git a/docs/user_guide/diffusion/regional_compilation.md b/docs/user_guide/diffusion/regional_compilation.md new file mode 100644 index 000000000..469a62b2c --- /dev/null +++ b/docs/user_guide/diffusion/regional_compilation.md @@ -0,0 +1,38 @@ +# Regional Compilation + +Regional compilation applies `torch.compile` to the repeated transformer blocks +declared by a diffusion model. It is the default compilation scope when +diffusion inference runs without `--enforce-eager`. + +## Configuration + +Dynamic compilation is enabled by default so the compiled regions can handle +mixed resolutions. For a fixed-shape workload, disable it explicitly: + +```bash +vllm serve --omni --no-diffusion-compile-dynamic +``` + +The equivalent per-stage deploy configuration is: + +```yaml +stages: + - stage_id: 0 + diffusion_compile_granularity: regional + diffusion_compile_dynamic: false +``` + +For an experimental whole-transformer compile scope, set +`--diffusion-compile-granularity full` or use +`diffusion_compile_granularity: full` in the stage configuration. Full scope may +still contain graph breaks; it does not force one graph. It is rejected when +HSDP, sequence parallelism, CPU offload, or layerwise offload is enabled. Use +regional scope with those features. + +These settings control the generic model-runner compilation path. Pipelines +that provide their own `setup_compile()` implementation manage their compilation +policy independently. Compilation is lazy, so backend or graph errors can first +surface on the initial request. + +Use `--enforce-eager` to disable the model runner's generic compile setup. +Pipelines that compile internally define their own eager-mode behavior. diff --git a/docs/user_guide/diffusion/request_batching.md b/docs/user_guide/diffusion/request_batching.md deleted file mode 100644 index 5cd24802b..000000000 --- a/docs/user_guide/diffusion/request_batching.md +++ /dev/null @@ -1,110 +0,0 @@ -# Request-Level Batching - -Request-level batching lets diffusion serving combine multiple compatible -logical requests into one pipeline `forward()` call. Each prompt remains a -separate request with its own `request_id`, sampling parameters, seed, output, -error, and abort state. The scheduler decides which requests can run together. - -!!! warning "Prompt List Semantics" - - Diffusion request-level batching does not support a top-level packed - list-prompt request. Submit multiple prompts as independent requests and let - the scheduler batch compatible in-flight requests. Multimodal payloads stay - inside a single prompt dict, for example - `{"prompt": "...", "multi_modal_data": {"image": image}}`. - -## Enablement - -Increase `max_num_seqs` above `1` to allow the request scheduler to keep more -than one compatible request active: - -```bash -vllm serve Qwen/Qwen-Image --omni \ - --port 8091 \ - --max-num-seqs 4 -``` - -For bursty online traffic, you can also set a small admission wait window. This -lets the engine wait briefly before the first `schedule()` of a new wave so -nearby compatible requests can arrive and share the same fused forward pass: - -```bash -vllm serve Qwen/Qwen-Image --omni \ - --port 8091 \ - --max-num-seqs 4 \ - --request-batch-max-wait-ms 20 -``` - -`--request-batch-max-wait-ms 0` is the default and disables admission waiting, -so there is no added wait latency. - -For deploy YAMLs, configure the diffusion stage engine args: - -```yaml -stage_args: - - stage_id: 0 - stage_type: diffusion - engine_args: - max_num_seqs: 4 - request_batch_max_wait_ms: 20 -``` - -## Compatibility - -Only pipelines that declare request-batch support use the fused request-batch -path. The engine validates that the pipeline `forward()` uses the request-batch -contract and returns `list[DiffusionOutput]`. Pipelines that do not support this -contract do not use fused `pipeline.forward(batch)`; scheduled requests are -executed through per-request worker calls. - -The scheduler batches only compatible requests. Compatibility is based on -shape-sensitive and guidance-sensitive sampling fields, including resolution, -frame count, CFG settings, output count, and LoRA identity. Requests with -different LoRA adapters or scales are kept in separate batches so the worker -activates one adapter per batch. - -Request-level batching applies when `step_execution=False`. For the separate -step-wise runtime, see [Step Execution](step_execution.md). - -## Tuning - -- `max_num_seqs` caps the number of active compatible requests in one scheduler - wave. -- `request_batch_max_wait_ms` is an upper bound on extra admission wait before a - new wave starts. Keep it small for latency-sensitive serving; values such as - `10` to `50` ms are a practical starting range for bursty HTTP ingress. -- `0` disables admission waiting and preserves the lowest first-request latency. -- FIFO ordering is conservative: an incompatible request at the front of the - waiting queue can block later compatible requests from joining the current - batch. - -## Python API - -When constructing `Omni`, pass the same engine arguments: - -```python -from vllm_omni.entrypoints.omni import Omni - -omni = Omni( - model="Qwen/Qwen-Image", - max_num_seqs=4, - request_batch_max_wait_ms=20.0, -) - -outputs = omni.generate( - [ - "a cup of coffee on a table", - "a toy dinosaur on a sandy beach", - "a fox waking up in bed and yawning", - ] -) -``` - -`Omni.generate([...])` submits each list item as its own logical diffusion -request. The runtime may batch those requests internally when their sampling -parameters are compatible. - -## For Contributors - -For implementation details and model-author guidance, see -[Request-Level Batching for Diffusion](../../design/feature/diffusion_request_level_batching.md). diff --git a/docs/user_guide/diffusion/step_execution.md b/docs/user_guide/diffusion/step_execution.md deleted file mode 100644 index 07184a787..000000000 --- a/docs/user_guide/diffusion/step_execution.md +++ /dev/null @@ -1,95 +0,0 @@ -# Step Execution - -Step execution is an opt-in diffusion execution mode enabled with -`step_execution=True` when constructing `Omni`. - -It is not a generic diffusion toggle for every pipeline. Only pipelines that -implement the stepwise contract support it today. - -## Quick Start - -### Python API - -```python -from vllm_omni import Omni -from vllm_omni.inputs.data import OmniDiffusionSamplingParams - -omni = Omni( - model="Qwen/Qwen-Image", - step_execution=True, -) - -outputs = omni.generate( - "A cat sitting on a windowsill", - OmniDiffusionSamplingParams( - num_inference_steps=50, - ), -) -``` - -### Serving - -```bash -vllm serve Qwen/Qwen-Image --omni \ - --port 8091 \ - --step-execution \ - --max-num-seqs 8 -``` - -For serving, `--step-execution` enables the step-wise runtime. Continuous -batching only becomes relevant when `--max-num-seqs > 1`. - -## Supported Pipelines - -| Pipeline | Example models | Step execution | -|----------|----------------|----------------| -| `QwenImagePipeline` | `Qwen/Qwen-Image`, `Qwen/Qwen-Image-2512` | Yes | -| All other diffusion pipelines | `QwenImageEditPipeline`, `QwenImageEditPlusPipeline`, `QwenImageLayeredPipeline`, GLM-Image, Wan, Flux, etc. | No | - -!!! warning "Experimental continuous batching" - When `--step-execution` is enabled and `max_num_seqs > 1` is configured, - the step-wise path can batch - compatible requests together. This is experimental. Requests with - incompatible sampling parameters are intentionally kept in separate batches, - and `max_num_seqs=1` remains the conservative default. - -## Current Limitations - -- Continuous batching under `step_execution` is experimental and only batches - compatible requests. -- `cache_backend` is not supported together with step execution. -- Unsupported pipelines fail early during model loading. -- Request-mode extras such as KV transfer are not wired into step mode yet. -- LoRA is supported in step mode, but each batch must use a single adapter: - requests with different `lora_request` or `lora_scale` are scheduled into - separate batches. - -## When To Use It - -Use step execution only when you specifically need the pipeline to run through -its stepwise request state machine. For normal diffusion inference, leave it -disabled unless your workflow depends on this mode. - -For Qwen-Image online serving, the usual progression is: - -- start with `--step-execution --max-num-seqs 1` if you only need the step-wise path -- increase `--max-num-seqs` after that if you want the experimental compatible-request batching behavior - -If you are looking for general diffusion speedups, see -[Diffusion Features Overview](../diffusion_features.md). - -## Troubleshooting - -If model loading fails with a message mentioning `prepare_encode()`, -`denoise_step()`, `step_scheduler()`, and `post_decode()`, the selected -pipeline does not support step execution. - -## For Model Authors - -If you want to add step execution support to a new diffusion pipeline, see the -implementation guide: -[Diffusion Step Execution Design](../../design/feature/diffusion_step_execution.md). - -If you also want that pipeline to participate in the experimental batched -step-wise path, see: -[Continuous Batching for Step-Wise Diffusion](../../design/feature/diffusion_continuous_batching.md). diff --git a/docs/user_guide/diffusion_features.md b/docs/user_guide/diffusion_features.md index d3ed3bd67..83bc3643e 100644 --- a/docs/user_guide/diffusion_features.md +++ b/docs/user_guide/diffusion_features.md @@ -59,7 +59,7 @@ Memory optimization methods help reduce GPU memory usage, enabling inference on | Method | Description | Best For | |--------|-------------|----------| -| **[CPU Offload](diffusion/cpu_offload_diffusion.md)** | Offloads model components to CPU memory | Limited VRAM, large models on consumer GPUs | +| **[CPU Offload](diffusion/cpu_offload.md)** | Offloads model components to CPU memory | Limited VRAM, large models on consumer GPUs | | **[Quantization](quantization/overview.md)** | Reduces transformer stages from BF16 to FP8/INT8/etc. | Limited VRAM, minimal accuracy loss | | **[VAE Parallelism](diffusion/parallelism/vae_parallelism.md)** | Distributes VAE decode work across GPUs | High-resolution generation with reduced VAE memory peak | @@ -80,12 +80,11 @@ denoise steps. | Method | Description | Best For | |--------|-------------|----------| -| **[Request-Level Batching](diffusion/request_batching.md)** | Scheduler batches compatible independent diffusion requests into one pipeline forward pass | Bursty online serving and multi-request throughput | -| **[Step Execution](diffusion/step_execution.md)** | Per-step denoise execution with mid-request abort support | Request cancellation between denoise steps, fine-grained execution control | +| **[Diffusion Execution Modes](diffusion/execution_modes.md)** | Configures serial requests, request batching, step execution, continuous batching, and streaming output | Matching latency, throughput, cancellation, and output-delivery requirements | -**Note:** Request-level batching is available for pipelines that declare the -request-batch forward contract. Step execution is currently supported by -QwenImagePipeline only. See [Supported Models](#supported-models) for details. +**Note:** Request-level batching and step execution are capability-based. +Consult the execution guide and the selected pipeline's documentation for +current support. ### Quantization Methods @@ -105,8 +104,8 @@ The following tables show which models support each feature: > Notes: -> 1. CPU Offload has two methods: Module-wise (default for models with DiT + text encoder) and Layerwise. The tables below show **Layerwise support** only. Split models like Cosmos3 (no separate text encoder) swap their reasoner/generator components for module-wise offload; see the [CPU Offload Guide](diffusion/cpu_offload_diffusion.md). -> 2. The **💾Quantization** column is collapsed for readability. See [Quantization Overview](quantization/overview.md) for per-method and per-model support details. +> 1. CPU Offload has two methods: Module-wise (default for models with DiT + text encoder) and Layerwise. The tables below show **Layerwise support** only. Split models like Cosmos3 (no separate text encoder) swap their reasoner/generator components for module-wise offload; see the [CPU Offload Guide](diffusion/cpu_offload.md). +> 2. The **💾Quantization** column is collapsed for readability. See [Quantization](quantization/overview.md) for per-method and per-model support details. ### ImageGen @@ -116,8 +115,8 @@ The following tables show which models support each feature: | **FLUX.1-dev** | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | | **FLUX.1-schnell** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | | **FLUX.2-klein** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | -| **FLUX.1-Kontext-dev** | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | -| **FLUX.2-dev** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| **FLUX.1-Kontext-dev** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| **FLUX.2-dev** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | | **GLM-Image** | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | **Hidream-I1-Full** | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | **HunyuanImage3** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | @@ -154,13 +153,13 @@ The following tables show which models support each feature: | **Wan2.2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (encode/decode) | ❌ | ❌ | | **Wan2.2-S2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ❌ | ❌ | | **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | -| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | -| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ (decode) | ❌ | ❌ | +| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | +| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | | **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ | | **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | **Cosmos3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ | - +| **MiniMax-H3** | ❌ | ✅ | ✅ | ❌ | ✅ (DiT/TE) | ❌ | ✅ | ✅ | ✅ (tile) | ✅ (DiT) | ❌ | **Frame Interpolation Support** @@ -206,7 +205,7 @@ The following tables show which models support each feature: 3. CPU Offloading (Layerwise) and CPU Offloading (Module-wise) are not compatible. 4. CPU Offloading (Layerwise) supports single-card for now. 5. Using FP8-Quant as an example of qunatization methods. - 6. Step Execution is not compatible with cache backends (TeaCache, Cache-DiT). LoRA is supported, but each scheduled batch must use a single adapter (requests with different `lora_request` or `lora_scale` are kept in separate batches). + 6. Step Execution is not compatible with any diffusion cache backend. LoRA is supported, but each scheduled batch must use a single adapter (requests with different `lora_request` or `lora_scale` are kept in separate batches). ## Multi-Thread Weight Loading @@ -275,9 +274,9 @@ Measured on NVIDIA H800: **Memory Optimization:** -- **[CPU Offload Guide](diffusion/cpu_offload_diffusion.md)** - Offload model components to CPU, reduce GPU memory usage +- **[CPU Offload Guide](diffusion/cpu_offload.md)** - Offload model components to CPU, reduce GPU memory usage - **[VAE Parallelism Guide](diffusion/parallelism/vae_parallelism.md)** - Distribute VAE decode work across GPUs for high-resolution images and videos -- **[Quantization Overview](quantization/overview.md)** - Overview of quantization methods for diffusion, multi-stage omni/TTS, and multi-stage diffusion models +- **[Quantization](quantization/overview.md)** - Quantization methods for diffusion, multi-stage omni/TTS, and multi-stage diffusion models **Extensions:** @@ -286,7 +285,7 @@ Measured on NVIDIA H800: **Execution Modes:** -- **[Step Execution Guide](diffusion/step_execution.md)** - Per-step denoise execution with mid-request abort support +- **[Diffusion Execution Modes](diffusion/execution_modes.md)** - Configure request batching, step execution, continuous batching, and streaming output **Startup Optimization:** diff --git a/docs/user_guide/examples/offline_inference/bagel.md b/docs/user_guide/examples/offline_inference/bagel.md deleted file mode 100644 index 391c41b35..000000000 --- a/docs/user_guide/examples/offline_inference/bagel.md +++ /dev/null @@ -1,287 +0,0 @@ -# BAGEL-7B-MoT - -Source . - -## Setup - -Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup. - -## Architecture - -BAGEL-7B-MoT is a Mixture-of-Transformers (MoT) model supporting both image generation and understanding. It offers two deployment topologies: - -| Topology | Stages | Description | -| :------- | :----- | :---------- | -| **Two-stage** (default) | Stage 0 (Thinker, AR) + Stage 1 (DiT, Diffusion) | Thinker handles text/understanding via vLLM AR engine; DiT handles image generation. KV cache is transferred between stages. | -| **Single-stage** | Stage 0 (DiT, Diffusion) only | The DiT stage contains a full LLM, ViT, VAE, and tokenizer internally. All modalities are handled within a single diffusion process. | - -Both topologies support all four modalities: `text2img`, `img2img`, `img2text`, `text2text`. - -## Quick Start - -```bash -cd examples/offline_inference/bagel - -# Default two-stage mode (auto-detected) -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality text2img \ - --prompts "A cute cat" - -# Single-stage mode -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality text2img \ - --prompts "A cute cat" \ - --deploy-config vllm_omni/deploy/bagel_single_stage.yaml -``` - -> **Note**: These examples work with the default configuration on an **NVIDIA A100 (80GB)**. For dual-GPU setups, modify the deploy YAML to distribute stages across devices. - -## Modality Control - -Control the mode using the `--modality` argument: - -| Modality | Input | Output | Description | -| :------- | :---- | :----- | :---------- | -| `text2img` | Text | Image | Generate images from text prompts | -| `img2img` | Image + Text | Image | Transform images using text guidance | -| `img2text` | Image + Text | Text | Generate text descriptions from images | -| `text2text` | Text | Text | Pure text generation (language model mode) | - -### Text to Image (text2img) - -```bash -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality text2img \ - --prompts "A cute cat" \ - --steps 50 -``` - -### Image to Image (img2img) - -```bash -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality img2img \ - --image-path /path/to/image.jpg \ - --prompts "Let the woman wear a blue dress" -``` - -### Image to Text (img2text) - -```bash -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality img2text \ - --image-path /path/to/image.jpg \ - --prompts "Describe this image in detail" -``` - -### Text to Text (text2text) - -```bash -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality text2text \ - --prompts "What is the capital of France?" - -# Load prompts from a text file (one prompt per line): -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality text2text \ - --txt-prompts /path/to/prompts.txt -``` - -## Think Mode - -Think mode enables the model to generate `...` planning/reasoning tokens before producing the final output. This improves generation quality for complex prompts. - -- **Two-stage**: The Thinker (AR) stage decodes think tokens, then transfers the augmented KV cache to the DiT stage for image generation. -- **Single-stage**: The DiT's internal LLM generates think tokens in-place before proceeding to denoise. - -```bash -# Think + text2img: plan before generating -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality text2img \ - --prompts "A futuristic city with flying cars" \ - --think \ - --max-think-tokens 1000 - -# Think + img2img: reason about the edit -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality img2img \ - --image-path /path/to/image.jpg \ - --prompts "Make it look like a watercolor painting" \ - --think - -# Think + img2text: reason before describing -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality img2text \ - --image-path /path/to/image.jpg \ - --prompts "What is happening in this image?" \ - --think - -# Think + text2text: chain-of-thought reasoning -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality text2text \ - --prompts "Solve: 23 * 47" \ - --think -``` - -Think mode parameters: - -| Argument | Default | Description | -| :------- | :------ | :---------- | -| `--think` | `False` | Enable thinking mode | -| `--max-think-tokens` | `1000` | Maximum tokens for think generation | -| `--do-sample` | `False` | Enable sampling (vs. greedy) for text generation | -| `--text-temperature` | `0.3` | Temperature for text generation sampling | - -## Classifier-Free Guidance (CFG) - -CFG controls the trade-off between prompt fidelity and diversity. These parameters apply to image generation modalities (`text2img`, `img2img`). - -```bash -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality text2img \ - --prompts "A photorealistic portrait" \ - --cfg-text-scale 6.0 \ - --cfg-img-scale 2.0 \ - --negative-prompt "blurry, low quality, distorted" \ - --cfg-interval 0.4 1.0 \ - --cfg-renorm-type global \ - --cfg-renorm-min 0.0 -``` - -| Argument | Default | Description | -| :------- | :------ | :---------- | -| `--cfg-text-scale` | `4.0` | Text CFG scale (higher = more prompt-adherent) | -| `--cfg-img-scale` | `1.5` | Image CFG scale (for img2img) | -| `--negative-prompt` | `None` | Negative prompt for CFG conditioning | -| `--cfg-interval` | pipeline default | CFG active interval `[start, end]` as fractions of total timesteps | -| `--cfg-renorm-type` | `None` | Renormalization type: `global`, `text_channel`, `channel` | -| `--cfg-renorm-min` | `None` | Minimum renormalization value | -| `--cfg-parallel-size` | `1` | CFG parallel size: `1` = batched (single GPU), `2` = 2-branch parallel, `3` = full 3-GPU parallel | - -## Deployment Topologies - -### Two-Stage (Default) - -The default topology auto-detected from the model. No extra flags needed. - -```bash -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality text2img \ - --prompts "A cute cat" -``` - -The pipeline is defined in [`bagel.yaml`](https://github.com/vllm-project/vllm-omni/tree/main/vllm_omni/deploy/bagel.yaml). Stage 0 (Thinker) and Stage 1 (DiT) share GPU 0 by default. For dual-GPU setups, customize the deploy YAML and set `devices: "1"` for stage 1. - -### Single-Stage - -Pass the single-stage deploy config via `--deploy-config`: - -```bash -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality text2img \ - --prompts "A cute cat" \ - --deploy-config vllm_omni/deploy/bagel_single_stage.yaml -``` - -See [`bagel_single_stage.yaml`](https://github.com/vllm-project/vllm-omni/tree/main/vllm_omni/deploy/bagel_single_stage.yaml) for configuration details. The `pipeline: bagel_single_stage` field selects the single-stage topology from the pipeline registry. - -### Tensor Parallelism (TP) - -For larger models or multi-GPU environments, customize the deploy YAML (see [`bagel.yaml`](https://github.com/vllm-project/vllm-omni/tree/main/vllm_omni/deploy/bagel.yaml)) and set per-stage `tensor_parallel_size` and `devices`: - -```yaml -# Example: TP=2 on GPUs 0,1 for the Thinker stage -stages: - - stage_id: 0 - tensor_parallel_size: 2 - devices: "0,1" -``` - -Then pass the custom deploy YAML: - -```bash -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality text2img \ - --prompts "A cute cat" \ - --deploy-config /path/to/custom_bagel.yaml -``` - -### FP8 Quantization - -```bash -python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ - --modality text2img \ - --prompts "A cute cat" \ - --quantization fp8 -``` - -## Command Line Reference - -### Core Arguments - -| Argument | Type | Default | Description | -| :------- | :--- | :------ | :---------- | -| `--model` | string | `ByteDance-Seed/BAGEL-7B-MoT` | Model path or HuggingFace name | -| `--modality` | choice | `text2img` | `text2img`, `img2img`, `img2text`, `text2text` | -| `--prompts` | list | `None` | Input text prompts | -| `--txt-prompts` | string | `None` | Path to text file with one prompt per line | -| `--image-path` | string | `None` | Input image path (required for `img2img`/`img2text`) | -| `--output` | string | `.` | Output directory for saved images | -| `--steps` | int | `50` | Number of diffusion inference steps | -| `--seed` | int | `None` | Random seed for reproducibility | - -### Think Mode Arguments - -| Argument | Type | Default | Description | -| :------- | :--- | :------ | :---------- | -| `--think` | flag | `False` | Enable `...` planning/reasoning | -| `--max-think-tokens` | int | `1000` | Maximum tokens for think generation | -| `--do-sample` | flag | `False` | Use sampling instead of greedy decoding | -| `--text-temperature` | float | `0.3` | Sampling temperature for text generation | - -### CFG Arguments - -| Argument | Type | Default | Description | -| :------- | :--- | :------ | :---------- | -| `--cfg-text-scale` | float | `4.0` | Text CFG guidance scale | -| `--cfg-img-scale` | float | `1.5` | Image CFG guidance scale | -| `--negative-prompt` | string | `None` | Negative prompt for CFG | -| `--cfg-parallel-size` | int | `1` | CFG parallel GPU count (1, 2, or 3) | -| `--cfg-interval` | float[2] | pipeline default | CFG active window `[start, end]` | -| `--cfg-renorm-type` | string | `None` | `global`, `text_channel`, or `channel` | -| `--cfg-renorm-min` | float | `None` | Minimum renormalization value | - -### Engine Arguments - -| Argument | Type | Default | Description | -| :------- | :--- | :------ | :---------- | -| `--deploy-config` | string | `None` | Path to deploy YAML (auto-detected if omitted) | -| `--stage-configs-path` | string | `None` | [Deprecated] Legacy path to `stage_args` YAML; prefer `--deploy-config` | -| `--worker-backend` | choice | `multi_process` | `multi_process` or `ray` | -| `--ray-address` | string | `None` | Ray cluster address | -| `--quantization` | string | `None` | Quantization method (e.g. `fp8`) | -| `--log-stats` | flag | `False` | Enable statistics logging | -| `--init-timeout` | int | `300` | Initialization timeout (seconds) | -| `--batch-timeout` | int | `5` | Batch timeout (seconds) | -| `--enable-diffusion-pipeline-profiler` | flag | `False` | Profile diffusion stage durations | - -## FAQ - -- If you encounter OOM errors, try decreasing `max_model_len` or `gpu_memory_utilization` in the deploy YAML. - -**Two-stage VRAM usage:** - -| Stage | VRAM | -| :---- | :--- | -| Stage 0 (Thinker) | **15.04 GiB + KV Cache** | -| Stage 1 (DiT) | **26.50 GiB** | -| Total | **~42 GiB + KV Cache** | - -**Single-stage VRAM usage:** The DiT loads the full model (~42 GiB) in one process. - -## Example materials - -??? abstract "end2end.py" - ``````py - --8<-- "examples/offline_inference/bagel/end2end.py" - `````` diff --git a/docs/user_guide/examples/offline_inference/image_to_video.md b/docs/user_guide/examples/offline_inference/image_to_video.md index dc1d3d6b2..db7a31ce5 100644 --- a/docs/user_guide/examples/offline_inference/image_to_video.md +++ b/docs/user_guide/examples/offline_inference/image_to_video.md @@ -4,7 +4,7 @@ Source . - - -## Run examples (MammothModa2-Preview) - -Download model -```bash -hf download bytedance-research/MammothModa2-Preview --local-dir ./MammothModa2-Preview -``` - -### Text-to-Image (T2I) - -Text-to-image now runs through the shared offline image example -(`examples/offline_inference/text_to_image/text_to_image.py`). See the recipe -`recipes/MammothModa2/MammothModa2-Preview.md` for the full command and the -`extra_body` knobs (`text_guidance_scale`, `cfg_range`, `num_inference_steps`). - -### Image Summary - -```bash -python examples/offline_inference/mammothmodal2_preview/run_mammothmoda2_image_summarize.py \ - --model ./MammothModa2-Preview \ - --deploy-config ./vllm_omni/deploy/mammoth_moda2_ar.yaml \ - --question "Summarize this image." \ - --image ./image.png -``` - -## Example materials - -??? abstract "run_mammothmoda2_image_summarize.py" - ``````py - --8<-- "examples/offline_inference/mammothmodal2_preview/run_mammothmoda2_image_summarize.py" - `````` diff --git a/docs/user_guide/examples/offline_inference/text_to_image.md b/docs/user_guide/examples/offline_inference/text_to_image.md index 7cd508d29..f1cdb9293 100644 --- a/docs/user_guide/examples/offline_inference/text_to_image.md +++ b/docs/user_guide/examples/offline_inference/text_to_image.md @@ -184,7 +184,7 @@ if __name__ == "__main__": !!! info For diffusion request-level batching controls such as `max_num_seqs`, see - [Request-Level Batching](../../diffusion/request_batching.md). + [Diffusion Execution Modes](../../diffusion/execution_modes.md). ### Negative Prompts diff --git a/docs/user_guide/examples/offline_inference/text_to_video.md b/docs/user_guide/examples/offline_inference/text_to_video.md index 5d89eac34..fcd3e7337 100644 --- a/docs/user_guide/examples/offline_inference/text_to_video.md +++ b/docs/user_guide/examples/offline_inference/text_to_video.md @@ -12,7 +12,7 @@ For backend selection and SageAttention usage, see the [Diffusion Attention Back | Model | Default Resolution | Default Frames | Default Steps | Guidance | VRAM (BF16) | |---|---|---|---|---|---| | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | 720x1280 | 81 | 40 | 4.0 | ~60 GiB | -| `dg845/LTX-2.3-Diffusers` | 384x512 | 25 | 20 | 4.0 | 96GB-class GPU | +| `Lightricks/LTX-2` | 512x768 | 121 | 40 | video 3.0 / audio 7.0 | Model-dependent | | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v` | 480x832 | 121 | 50 | 6.0 | 1×A100 80GB | | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | 720x1280 | 121 | 50 | 6.0 | FP8 + VAE tiling required | @@ -35,45 +35,17 @@ python text_to_video.py \ --output t2v_out.mp4 ``` -LTX2 example: +### LTX-2 ```bash python text_to_video.py \ - --model "Lightricks/LTX-2" \ - --prompt "A cinematic close-up of ocean waves at golden hour." \ - --negative-prompt "worst quality, inconsistent motion, blurry, jittery, distorted" \ - --height 512 \ - --width 768 \ - --num-frames 121 \ - --num-inference-steps 40 \ - --guidance-scale 4.0 \ - --frame-rate 24 \ - --output ltx2_out.mp4 -``` - -### LTX-2.3 - -```bash -python text_to_video.py \ - --model dg845/LTX-2.3-Diffusers \ - --model-class-name LTX23Pipeline \ + --model Lightricks/LTX-2 \ --prompt "Cherry blossoms swaying gently in the breeze with synchronized ambient sound" \ - --negative-prompt "worst quality, inconsistent motion, blurry, jittery, distorted" \ - --height 384 \ - --width 512 \ - --num-frames 25 \ - --num-inference-steps 20 \ - --guidance-scale 4.0 \ - --frame-rate 24 \ - --fps 24 \ - --audio-sample-rate 48000 \ - --output ltx23_t2v_output.mp4 + --output ltx2_output.mp4 ``` -Use a Diffusers-format checkpoint such as `dg845/LTX-2.3-Diffusers`; the -upstream `Lightricks/LTX-2.3` raw safetensors repo is not directly loadable by -this pipeline. Pass `--model-class-name LTX23Pipeline` to select the LTX-2.3 -text-to-video pipeline explicitly. +See the [LTX-2 recipe](../../../../recipes/LTX/LTX-2.md) for all checkpoints, +pipeline selection, I2V, defaults, and advanced options. ### HunyuanVideo-1.5 (480p) @@ -135,8 +107,7 @@ python text_to_video.py \ ### Common - `--model`: Diffusers model ID or local path. -- `--model-class-name`: Optional explicit pipeline class. Use `LTX23Pipeline` - for LTX-2.3 text-to-video. +- `--model-class-name`: Optional explicit pipeline override. - `--prompt`: text description (string). - `--height/--width`: output resolution. Default depends on model. - `--num-frames`: number of frames. Default depends on model. @@ -151,8 +122,7 @@ python text_to_video.py \ - `--enable-cpu-offload`: enable CPU offloading for diffusion models. - `--enable-layerwise-offload`: enable layerwise offloading on DiT modules. - `--frame-rate`: generation FPS for pipelines that require it (e.g., LTX2). -- `--audio-sample-rate`: audio sample rate for embedded audio (when the - pipeline returns audio; LTX-2.3 outputs 48kHz audio). +- `--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. diff --git a/docs/user_guide/examples/online_serving/bagel.md b/docs/user_guide/examples/online_serving/bagel.md index aa8b33de8..4b5c6dde6 100644 --- a/docs/user_guide/examples/online_serving/bagel.md +++ b/docs/user_guide/examples/online_serving/bagel.md @@ -40,7 +40,7 @@ bash run_server_stage_cli.sh --stage 0 bash run_server_stage_cli.sh --stage 1 ``` -To use a custom deploy YAML (note: `--stage-configs-path` is deprecated in favor of `--deploy-config`): +To use a custom deploy YAML: ```bash vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 \ diff --git a/docs/user_guide/examples/online_serving/glm_image.md b/docs/user_guide/examples/online_serving/glm_image.md index ea404faa9..8d41b26fd 100644 --- a/docs/user_guide/examples/online_serving/glm_image.md +++ b/docs/user_guide/examples/online_serving/glm_image.md @@ -11,8 +11,8 @@ memory, sampling params) live in `vllm_omni/deploy/glm_image.yaml`. vllm serve zai-org/GLM-Image --omni --port 8091 ``` -The config system auto-detects the pipeline from the model's `model_index.json` — no -manual `--stage-configs-path` or `--deploy-config` needed. +The config system auto-detects the pipeline from the model's `model_index.json`, so +no manual `--deploy-config` is needed. By default, stage 0 (AR) runs on GPU 0 and stage 1 (Diffusion) on GPU 1. To colocate both stages on a single GPU, override per stage: diff --git a/docs/user_guide/examples/online_serving/qwen2_5_omni.md b/docs/user_guide/examples/online_serving/qwen2_5_omni.md index d8c48a403..a06e6d7a0 100644 --- a/docs/user_guide/examples/online_serving/qwen2_5_omni.md +++ b/docs/user_guide/examples/online_serving/qwen2_5_omni.md @@ -15,9 +15,9 @@ Please refer to [README.md](https://github.com/vllm-project/vllm-omni/tree/main/ vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 ``` -If you have custom stage configs file, launch the server with command below +To use a custom deploy config, launch the server with the command below: ```bash -vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --stage-configs-path /path/to/stage_configs_file +vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/deploy_config.yaml ``` ### Send Multi-modal Request @@ -182,7 +182,7 @@ The script supports the following arguments: - `--model`: Model name/path (default: Qwen/Qwen2.5-Omni-7B) - `--server-port`: Port for vLLM server (default: 8091) - `--gradio-port`: Port for Gradio demo (default: 7861) -- `--stage-configs-path`: Path to custom stage configs YAML file (optional) +- `--deploy-config`: Path to a custom deploy config YAML file (optional) - `--server-host`: Host for vLLM server (default: 0.0.0.0) - `--gradio-ip`: IP for Gradio demo (default: 127.0.0.1) - `--share`: Share Gradio demo publicly (creates a public link) @@ -195,9 +195,9 @@ The script supports the following arguments: vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 ``` -If you have custom stage configs file: +To use a custom deploy config: ```bash -vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --stage-configs-path /path/to/stage_configs_file +vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/deploy_config.yaml ``` **Step 2: Run the Gradio demo** diff --git a/docs/user_guide/examples/online_serving/qwen3_omni.md b/docs/user_guide/examples/online_serving/qwen3_omni.md index ec509d597..b4c05bdbe 100644 --- a/docs/user_guide/examples/online_serving/qwen3_omni.md +++ b/docs/user_guide/examples/online_serving/qwen3_omni.md @@ -140,7 +140,7 @@ You can control output modalities to specify which types of output the model sho | Modalities | Output | |------------|--------| | `["text"]` | Text only | -| `["audio"]` | Text + Audio | +| `["audio"]` | Audio only | | `["text", "audio"]` | Text + Audio | | Not specified | Text + Audio (default) | @@ -161,13 +161,16 @@ curl http://localhost:8091/v1/chat/completions \ #### Text + Audio ```bash -curl http://localhost:8091/v1/chat/completions \ +response=$(curl -s http://localhost:8091/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", "messages": [{"role": "user", "content": "Describe vLLM in brief."}], - "modalities": ["audio"] - }' + "modalities": ["text", "audio"] + }') + +echo "$response" | jq -r '.choices[0].message.content' +echo "$response" | jq -r '.choices[1].message.audio.data' | base64 -d > output.wav ``` ### Using Python client @@ -205,7 +208,7 @@ client = OpenAI(base_url="http://localhost:8091/v1", api_key="EMPTY") response = client.chat.completions.create( model="Qwen/Qwen3-Omni-30B-A3B-Instruct", messages=[{"role": "user", "content": "Describe vLLM in brief."}], - modalities=["audio"] + modalities=["text", "audio"] ) # Response contains two choices: one with text, one with audio print(response.choices[0].message.content) # Text response @@ -303,7 +306,7 @@ The gradio script supports the following arguments: `````` ??? abstract "qwen3_omni_moe_thinking.yaml" ``````yaml - --8<-- "examples/online_serving/qwen3_omni/qwen3_omni_moe_thinking.yaml" + --8<-- "vllm_omni/deploy/qwen3_omni_moe_thinking.yaml" `````` ??? abstract "run_curl_multimodal_generation.sh" ``````sh diff --git a/docs/user_guide/examples/online_serving/text_to_audio.md b/docs/user_guide/examples/online_serving/text_to_audio.md index b1ec33a20..a4833aef6 100644 --- a/docs/user_guide/examples/online_serving/text_to_audio.md +++ b/docs/user_guide/examples/online_serving/text_to_audio.md @@ -139,7 +139,7 @@ Content-Type: application/json |-----------|------|---------|-------------| | `input` | string | **required** | Text prompt describing the audio to generate | | `model` | string | server's model | Model to use (optional, should match server if specified) | -| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus | +| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, opus | | `speed` | float | 1.0 | Playback speed (0.25 - 4.0) | | `audio_length` | float | null | Audio duration in seconds (max ~47s for `stable-audio-open-1.0`) | | `audio_start` | float | 0.0 | Audio start time in seconds | diff --git a/docs/user_guide/examples/online_serving/text_to_speech.md b/docs/user_guide/examples/online_serving/text_to_speech.md index 5640845d8..c6a613723 100644 --- a/docs/user_guide/examples/online_serving/text_to_speech.md +++ b/docs/user_guide/examples/online_serving/text_to_speech.md @@ -422,12 +422,19 @@ curl -X POST http://localhost:8091/v1/audio/speech \ Raw PCM streaming requires `stream_format="audio"`, `response_format="pcm"`, and `async_chunk: true` on the stage config (default in `qwen3_tts.yaml`). `speed` is not supported when streaming. ### Streaming WebSocket -The `/v1/audio/speech/stream` endpoint accepts text incrementally, splits it at sentence boundaries, and emits one PCM stream per sentence: +The `/v1/audio/speech/stream` endpoint accepts text incrementally and synthesizes the buffered text as one continuous request on `input.done`: ```bash python qwen3_tts/streaming_speech_client.py --text "Hello world. How are you? I am fine." python qwen3_tts/streaming_speech_client.py --text "..." --simulate-stt --stt-delay 0.1 ``` +`input.done` flushes without closing, so repeating `--text` synthesizes several utterances over one connection: +```bash +python qwen3_tts/streaming_speech_client.py \ + --text "First utterance." \ + --text "Second utterance, same connection." +``` + ### Gradio demos ```bash ./qwen3_tts/run_gradio_demo.sh # CustomVoice (default) diff --git a/docs/user_guide/examples/online_serving/text_to_video.md b/docs/user_guide/examples/online_serving/text_to_video.md index d4d520d5e..2f00d386b 100644 --- a/docs/user_guide/examples/online_serving/text_to_video.md +++ b/docs/user_guide/examples/online_serving/text_to_video.md @@ -191,7 +191,7 @@ curl -X POST http://localhost:8091/v1/videos \ | `negative_prompt` | str | None | Negative prompt | | `width` | int | None | Video width in pixels | | `height` | int | None | Video height in pixels | -| `num_frames` | int | None | Number of frames to generate | +| `num_frames` | int | 1 | Number of frames to generate; set explicitly for video generation | | `fps` | int | None | Frames per second for output video | | `num_inference_steps` | int | None | Number of denoising steps | | `guidance_scale` | float | None | CFG guidance scale (low-noise stage) | @@ -289,90 +289,12 @@ done ## LTX-2 -### Start Server - -#### Basic Start - ```bash -vllm serve Lightricks/LTX-2 --omni --port 8098 \ - --enforce-eager --flow-shift 1.0 --boundary-ratio 1.0 +vllm serve Lightricks/LTX-2 --omni --port 8098 ``` -For multi-GPU memory reduction, you can enable HSDP: - -```bash -vllm serve Lightricks/LTX-2 --omni --port 8098 \ - --enforce-eager --flow-shift 1.0 --boundary-ratio 1.0 \ - --use-hsdp --hsdp-shard-size 2 -``` - -#### Start with Optimization Presets - -Use the LTX-2 startup script with built-in optimization presets: - -```bash -# Baseline (1 GPU, eager) -bash run_server_ltx2.sh baseline - -# 4-GPU Ulysses sequence parallelism (lossless) -bash run_server_ltx2.sh ulysses4 - -# Cache-DiT lossy acceleration (1 GPU, ~1.4× speedup) -bash run_server_ltx2.sh cache-dit - -# Best combo: 4-GPU Ulysses SP + Cache-DiT (~2.2× speedup) -bash run_server_ltx2.sh best-combo -``` - -#### Optimization Benchmarks - -Benchmarked on H800, online serving (480×768, 41 frames, 20 steps, `seed=42`). -"Inference" is the server-reported inference time; excludes HTTP/poll overhead. - -| Preset | Server Command | Inference (s) | Speedup | Type | -|--------|---------------|---------------|---------|------| -| `baseline` | `--enforce-eager` | 10.3 | 1.00× | — | -| `compile` | *(default, no --enforce-eager)* | ~10.3 (warm) | ~1.00× | Lossless | -| `ulysses4` | `--enforce-eager --usp 4` | ~10.3 | ~1.00× | Lossless | -| `cache-dit` | `--enforce-eager --cache-backend cache_dit` | 7.4 avg | ~1.4× | Lossy | -| `best-combo` | `--enforce-eager --usp 4 --cache-backend cache_dit` | 4.7 avg | **~2.2×** | Lossless + Lossy | - -**Observations**: -- **torch.compile**: On H800, warm-request inference time matches the eager baseline (~10.3s). - The first request pays ~6s compilation overhead. Benefit depends on model architecture and GPU. -- **Ulysses SP (4 GPU)**: No measurable speedup alone for 41-frame generation at this resolution. - Communication overhead outweighs gains at this sequence length. -- **Cache-DiT**: Inference varies per request (6–10s) due to dynamic caching decisions. - Average is ~7.4s (~1.4× speedup) with slight quality tradeoff. -- **Best combo**: 4-GPU Ulysses SP + Cache-DiT synergize well — Cache-DiT reduces per-step - computation, making the communication overhead of Ulysses SP worthwhile. Average ~4.7s - (~2.2× speedup). -- **FP8 quantization**: Reduces VRAM but does not speed up LTX-2 on H800 (compute-bound). - -**Deployment Recommendations**: -- For **production with quality priority**: use `baseline` with `--enforce-eager` -- For **maximum throughput** (4 GPUs, quality tradeoff): use `best-combo` (~2.2× speedup) -- For **single-GPU throughput**: use `cache-dit` (~1.4× speedup) -- `--enforce-eager` is recommended to avoid torch.compile warmup latency on first request - -### Send Requests (curl) - -```bash -# Using the provided script -bash run_curl_ltx2.sh - -# Or directly -curl -sS -X POST http://localhost:8098/v1/videos \ - -H "Accept: application/json" \ - -F "prompt=A serene lakeside sunrise with mist over the water." \ - -F "width=768" \ - -F "height=480" \ - -F "num_frames=41" \ - -F "fps=24" \ - -F "num_inference_steps=20" \ - -F "guidance_scale=3.0" \ - -F "seed=42" -``` +See the [LTX-2 recipe](../../../../recipes/LTX/LTX-2.md) for all checkpoints, +pipeline selection, requests, defaults, and advanced options. ## Example materials diff --git a/docs/user_guide/fault_injection_reliability_matrix.md b/docs/user_guide/fault_injection_reliability_matrix.md index 974982b3b..28271affa 100644 --- a/docs/user_guide/fault_injection_reliability_matrix.md +++ b/docs/user_guide/fault_injection_reliability_matrix.md @@ -65,23 +65,23 @@ More failure modes (for example, network jitter and network interruption) will b ## Source of Conclusions -The behaviors and conclusions above are summarized from current fault injection validation results on **`Qwen3-Omni`**, **`Wan2.2`**, **`HunyuanImage-3.0-Instruct`** (DiT-only, `/v1/images/generations`), and **`VoxCPM2`** (`/v1/audio/speech`). Automated coverage lives under `tests/dfx/reliability/` (`test_reliability_qwen3_omni.py`, `test_reliability_wan22.py`, `test_reliability_hunyuan_image.py`, `test_reliability_voxcpm2.py`) and runs weekly via `.buildkite/test-weekly.yml`. +The behaviors and conclusions above are summarized from current fault injection validation results on **`Qwen3-Omni`**, **`Wan2.2`**, **`HunyuanImage-3.0-Instruct`** (DiT-only, `/v1/images/generations`), and **`VoxCPM2`** (`/v1/audio/speech`). Automated coverage lives under `tests/dfx/reliability/` (`test_reliability_qwen3_omni.py`, `test_reliability_wan22.py`, `test_reliability_hunyuan_image.py`, `test_reliability_voxcpm2.py`) and runs weekly via `.buildkite/cuda/test-weekly.yml`. ### Example: `SIGTERM` fault injection log (Qwen3-Omni) -The following full log is from a real run where a `SIGTERM` signal was sent to the serve root process. +The following log is based on a real run where a `SIGTERM` signal was sent to the serve root process. Its command line has been normalized to the current `--deploy-config` option. ```text -Launching OmniServer with: /workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --stage-configs-path vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml +Launching OmniServer with: /workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --deploy-config vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml Server ready on 127.0.0.1:60675 (OmniServer startup took 206.023s) OmniServer started successfully -[reliability][process-kill] current_server_proc pid=556855 name=python3 cmdline=/workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --stage-configs-path vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml +[reliability][process-kill] current_server_proc pid=556855 name=python3 cmdline=/workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --deploy-config vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml [reliability][process-kill] current_server_proc pid=557118 name=python3 cmdline=/workspace/.venv/bin/python3 -c from multiprocessing.resource_tracker import main;main(57) [reliability][process-kill] current_server_proc pid=557119 name=VLLM::StageEngineCoreProc_noid_replica0_DP0 cmdline=VLLM::StageEngineCoreProc_noid_replica0_DP0 [reliability][process-kill] current_server_proc pid=557122 name=VLLM::StageEngineCoreProc_noid_replica0_DP0 cmdline=VLLM::StageEngineCoreProc_noid_replica0_DP0 [reliability][process-kill] current_server_proc pid=558201 name=VLLM::StageEngineCoreProc_noid_replica0_DP0 cmdline=VLLM::StageEngineCoreProc_noid_replica0_DP0 -[reliability][process-kill] root-kill pid=556855 name=python3 signal=SIGTERM cmdline=/workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --stage-configs-path vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml +[reliability][process-kill] root-kill pid=556855 name=python3 signal=SIGTERM cmdline=/workspace/.venv/bin/python3 -m vllm_omni.entrypoints.cli.main serve Qwen/Qwen3-Omni-30B-A3B-Instruct --host 127.0.0.1 --port 60675 --omni --async-chunk --stage-init-timeout 600 --init-timeout 900 --log-stats --deploy-config vllm-omni/vllm_omni/deploy/qwen3_omni_moe.yaml [0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:24:51 [omni_base.py:463] [AsyncOmni] Shutting down [0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:24:51 [async_omni_engine.py:2397] [AsyncOmniEngine] Shutting down Orchestrator [0;36m(APIServer pid=556855)[0;0m INFO 05-19 12:24:51 [orchestrator.py:351] [Orchestrator] Received shutdown signal diff --git a/docs/user_guide/quantization/fp8.md b/docs/user_guide/quantization/fp8.md index 6114812db..ddca3ea19 100644 --- a/docs/user_guide/quantization/fp8.md +++ b/docs/user_guide/quantization/fp8.md @@ -88,10 +88,12 @@ warmup_quack_fp8([(14040, 2048, 6144), (14040, 2048, 2048)]) | Wan2.2 | Wan2.2 diffusion pipelines | Not validated | Not validated | Validate against BF16 before documenting as supported | TBD | | | Z-Image | `Tongyi-MAI/Z-Image-Turbo` | Yes | Yes | All layers | None | ✅︎ | | FLUX.1 | `black-forest-labs/FLUX.1-dev`, `black-forest-labs/FLUX.1-schnell` | Yes | Yes | All layers | None | | +| FLUX.2-dev | `black-forest-labs/FLUX.2-dev` | Yes | Not validated | All layers | None | ✅︎ | | FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B` | Yes | Yes | All layers | None | | | HunyuanImage-3.0 | `tencent/HunyuanImage-3.0`, `tencent/HunyuanImage-3.0-Instruct` | Yes | Yes | All layers; use the Hunyuan stage config for multi-stage runs | None | | | HunyuanVideo-1.5 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v`, `720p_t2v`, `480p_i2v` | Yes | Yes | All layers | None | | | Cosmos3 | `nvidia/Cosmos3-Nano`, `nvidia/Cosmos3-Super` | Yes | Not validated | All layers | None | | +| MiniMax H3 | `MiniMaxAI/MiniMax-H3` | Yes | Not validated | DiT linears except patch, timestep, and final projections; not compatible with layerwise offload | None | | ### Multi-Stage Omni/TTS Model (Qwen3-Omni, Qwen3-TTS) diff --git a/docs/user_guide/quantization/overview.md b/docs/user_guide/quantization/overview.md index 3a4a45573..ee36f5b60 100644 --- a/docs/user_guide/quantization/overview.md +++ b/docs/user_guide/quantization/overview.md @@ -5,6 +5,9 @@ path. The same configuration entrypoint is used across diffusion-only models, multi-stage omni/TTS models, and multi-stage diffusion models, but each model type has a different quantization scope. +For the internal architecture and backend extension points, see the +[quantization design document](../../design/feature/quantization.md). + ## Quantization Modes | Mode | Guide | Description | Methods | diff --git a/examples/model_display_names.yml b/examples/model_display_names.yml index be3f8e381..7a04ff8fa 100644 --- a/examples/model_display_names.yml +++ b/examples/model_display_names.yml @@ -8,5 +8,4 @@ mimo_audio: MiMo-Audio ming_flash_omni: Ming-flash-omni 2.0 qwen2_5_omni: Qwen2.5-Omni qwen3_omni: Qwen3-Omni -sensenova_u1: SenseNova-U1 step_audio2: Step-Audio2 diff --git a/examples/offline_inference/audex/README.md b/examples/offline_inference/audex/README.md new file mode 100644 index 000000000..793b69eb9 --- /dev/null +++ b/examples/offline_inference/audex/README.md @@ -0,0 +1,114 @@ +# Audex (Nemotron-Labs-Audex-2B) offline inference + +One checkpoint, four tasks — each script builds its own `Omni` engine with +the right deploy yaml, so they run as plain `python + + + diff --git a/examples/online_serving/minicpmo/realtime_web/app/static/app.js b/examples/online_serving/minicpmo/realtime_web/app/static/app.js new file mode 100644 index 000000000..e8a3047c6 --- /dev/null +++ b/examples/online_serving/minicpmo/realtime_web/app/static/app.js @@ -0,0 +1,671 @@ +(() => { + 'use strict'; + + const config = window.FULL_DUPLEX_CONFIG || {}; + const callButton = document.getElementById('callButton'); + const muteButton = document.getElementById('muteButton'); + const cameraButton = document.getElementById('cameraButton'); + const cameraPreview = document.getElementById('cameraPreview'); + const promptPreset = document.getElementById('promptPreset'); + const systemPromptInput = document.getElementById('systemPrompt'); + const connectionState = document.getElementById('connectionState'); + const modelState = document.getElementById('modelState'); + const playbackState = document.getElementById('playbackState'); + const sessionTimer = document.getElementById('sessionTimer'); + const meterFill = document.getElementById('meterFill'); + const conversation = document.getElementById('conversation'); + const emptyConversation = document.getElementById('emptyConversation'); + const eventLog = document.getElementById('eventLog'); + const eventCount = document.getElementById('eventCount'); + const runtimeDetail = document.getElementById('runtimeDetail'); + const clearLogButton = document.getElementById('clearLogButton'); + + const INPUT_RATE = 16000; + const OUTPUT_RATE = 24000; + const SEND_INTERVAL_MS = 200; + const ECHO_GUARD_MS = 300; + const INITIAL_PLAYBACK_BUFFER_MS = 400; + const SESSION_CLOSE_TIMEOUT_MS = 1000; + + // Default prompts mirroring the official MiniCPM-o-Demo presets + // (assets/presets/{omni,audio_duplex}/*.yaml). + const PROMPT_PRESETS = { + omni: 'Streaming Omni Conversation.', + chinese_call: '扮演一个具有以上声音特征的助手。请认真、高质量地回复用户的问题。' + + '请用高自然度的方式和用户聊天。你处于双工模式,可以一边听、一边说。' + + '你是由面壁智能开发的人工智能助手:面壁小钢炮。', + english_call: 'Replicate the tone and style from the input audio. Your task is to be ' + + 'a helpful assistant using this voice pattern. Please answer the user\'s questions ' + + 'seriously and in a high quality. Please chat with the user in a high naturalness ' + + 'style. You are in duplex mode, where you can listen and speak at the same time.', + }; + + let socket = null; + let mediaStream = null; + let captureContext = null; + let captureNode = null; + let playbackContext = null; + let playbackNode = null; + let sendTimer = null; + let clockTimer = null; + let startedAt = 0; + let running = false; + let muted = false; + let assistantActive = false; + let captureRate = INPUT_RATE; + let cameraStream = null; + let cameraTimer = null; + let cameraPendingFrame = null; + const cameraCanvas = document.createElement('canvas'); + let playbackRate = OUTPUT_RATE; + let pendingCapture = []; + let currentResponseId = null; + let responseHasAudio = false; + let logCount = 0; + let liveUserTurn = null; + let liveAssistantTurn = null; + let sessionCloseResolver = null; + + function staticAssetUrl(path) { + const version = String(config.appVersion || '').trim(); + return version ? `${path}?v=${encodeURIComponent(version)}` : path; + } + + if (promptPreset && systemPromptInput) { + promptPreset.addEventListener('change', () => { + const preset = PROMPT_PRESETS[promptPreset.value]; + if (preset !== undefined) systemPromptInput.value = preset; + }); + systemPromptInput.addEventListener('input', () => { + promptPreset.value = 'custom'; + }); + } + + function realtimeUrl() { + const url = new URL(config.realtimePath, window.location.href); + url.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + url.searchParams.set('duplex', '1'); + url.searchParams.set('model', config.model || 'openbmb/MiniCPM-o-4_5'); + url.searchParams.set('minicpmo45_native_duplex', '1'); + url.searchParams.set('autostart', '0'); + return url.toString(); + } + + function setConnection(label, kind) { + connectionState.textContent = label; + connectionState.className = `status status-${kind}`; + } + + function setModel(label) { + modelState.textContent = label; + } + + function setPlayback(label) { + playbackState.textContent = label; + } + + function compactEvent(event) { + const fields = []; + const payload = event.event || event; + const responseId = responseIdOf(event); + if (responseId) fields.push(`response=${responseId}`); + if (event.item_id) fields.push(`item=${event.item_id}`); + if (event.code) fields.push(`code=${event.code}`); + if (event.response && event.response.status) fields.push(`status=${event.response.status}`); + if (payload.played_ms !== undefined) fields.push(`played=${payload.played_ms}ms`); + if (payload.committed_ms !== undefined) fields.push(`committed=${payload.committed_ms}ms`); + if (payload.history_committed !== undefined) fields.push(`history=${payload.history_committed}`); + return fields.join(' '); + } + + function appendLog(message, error = false) { + const time = new Date().toLocaleTimeString([], { hour12: false }); + const line = document.createElement('span'); + if (error) line.className = 'log-error'; + line.textContent = `${time} ${message}\n`; + eventLog.appendChild(line); + eventLog.scrollTop = eventLog.scrollHeight; + logCount += 1; + eventCount.textContent = `${logCount} ${logCount === 1 ? 'event' : 'events'}`; + } + + function appendEventLog(event) { + const detail = compactEvent(event); + appendLog(`${event.type || 'unknown'}${detail ? ` ${detail}` : ''}`, event.type === 'error'); + } + + function responseIdOf(event) { + return event.response_id || (event.response && event.response.id) || null; + } + + function ensureTurn(role) { + const existing = role === 'user' ? liveUserTurn : liveAssistantTurn; + if (existing) return existing; + if (emptyConversation) emptyConversation.remove(); + const row = document.createElement('div'); + row.className = `turn turn-${role} turn-live`; + const label = document.createElement('div'); + label.className = 'turn-role'; + label.textContent = role === 'user' ? 'You' : 'Assistant'; + const text = document.createElement('div'); + text.className = 'turn-text'; + row.append(label, text); + conversation.appendChild(row); + conversation.scrollTop = conversation.scrollHeight; + const turn = { row, text, value: '' }; + if (role === 'user') liveUserTurn = turn; + else liveAssistantTurn = turn; + return turn; + } + + function addTranscript(role, delta) { + if (!delta) return; + const turn = ensureTurn(role); + turn.value += delta; + turn.text.textContent = turn.value; + conversation.scrollTop = conversation.scrollHeight; + } + + function finishTranscript(role, finalText = '') { + const turn = role === 'user' ? liveUserTurn : liveAssistantTurn; + if (!turn && !finalText) return; + const current = turn || ensureTurn(role); + if (finalText) { + current.value = finalText; + current.text.textContent = finalText; + } + current.row.classList.remove('turn-live'); + if (role === 'user') liveUserTurn = null; + else liveAssistantTurn = null; + } + + function bytesToBase64(bytes) { + let binary = ''; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + } + return btoa(binary); + } + + function int16ToBase64(pcm) { + return bytesToBase64(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength)); + } + + function base64ToBytes(encoded) { + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; + } + + function resampleInt16(input, sourceRate, targetRate) { + if (sourceRate === targetRate) return input; + const ratio = sourceRate / targetRate; + const output = new Int16Array(Math.floor(input.length / ratio)); + for (let index = 0; index < output.length; index += 1) { + const start = Math.floor(index * ratio); + const end = Math.max(start + 1, Math.min(input.length, Math.floor((index + 1) * ratio))); + let sum = 0; + for (let source = start; source < end; source += 1) sum += input[source]; + output[index] = sum / (end - start); + } + return output; + } + + async function decodeAudioDelta(event) { + const encoded = event.delta || (event.response && event.response.audio); + if (!encoded) return null; + const bytes = base64ToBytes(encoded); + const format = String(event.format || event.audio_format || 'pcm16').toLowerCase(); + const sourceRate = Number(event.sample_rate_hz || event.sample_rate || OUTPUT_RATE); + if (format.includes('f32')) { + const floats = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4)); + const pcm = new Int16Array(floats.length); + for (let index = 0; index < floats.length; index += 1) { + const sample = Math.max(-1, Math.min(1, floats[index])); + pcm[index] = sample < 0 ? sample * 32768 : sample * 32767; + } + return { pcm, sourceRate }; + } + if (format.includes('wav')) { + const decoded = await playbackContext.decodeAudioData(bytes.buffer.slice(0)); + const channel = decoded.getChannelData(0); + const pcm = new Int16Array(channel.length); + for (let index = 0; index < channel.length; index += 1) { + const sample = Math.max(-1, Math.min(1, channel[index])); + pcm[index] = sample < 0 ? sample * 32768 : sample * 32767; + } + return { pcm, sourceRate: decoded.sampleRate }; + } + return { + pcm: new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2)), + sourceRate, + }; + } + + function updateMeter(pcm) { + let peak = 0; + for (let index = 0; index < pcm.length; index += 8) peak = Math.max(peak, Math.abs(pcm[index])); + meterFill.style.width = `${Math.min(100, (peak / 32768) * 150).toFixed(0)}%`; + } + + function microphoneUploadEnabled() { + return running && !muted; + } + + function flushCapture() { + if (!socket || socket.readyState !== WebSocket.OPEN || pendingCapture.length === 0) return; + if (!microphoneUploadEnabled()) { + pendingCapture = []; + return; + } + const length = pendingCapture.reduce((total, chunk) => total + chunk.length, 0); + const merged = new Int16Array(length); + let offset = 0; + for (const chunk of pendingCapture) { + merged.set(chunk, offset); + offset += chunk.length; + } + pendingCapture = []; + const pcm = resampleInt16(merged, captureRate, INPUT_RATE); + const appendEvent = { + type: 'input_audio_buffer.append', + audio: int16ToBase64(pcm), + format: 'pcm16', + sample_rate_hz: INPUT_RATE, + }; + // Omni duplex: ~1 fps camera frame rides the audio append (official + // MiniCPM-o-Demo contract: one base64 JPEG per ~1 s chunk). + if (cameraPendingFrame) { + appendEvent.video_frames = [cameraPendingFrame]; + cameraPendingFrame = null; + } + socket.send(JSON.stringify(appendEvent)); + } + + function beginAssistant(responseId) { + currentResponseId = responseId || currentResponseId; + responseHasAudio = false; + assistantActive = true; + setModel('Speaking'); + } + + function feedPlayback(decoded, responseId) { + if (!decoded || !decoded.pcm || decoded.pcm.length === 0 || !playbackNode) return; + const pcm = resampleInt16(decoded.pcm, decoded.sourceRate, playbackRate); + responseHasAudio = true; + assistantActive = true; + setPlayback('Buffering'); + playbackNode.port.postMessage({ + type: 'audio', + pcm, + responseId: responseId || currentResponseId, + initialBufferMs: INITIAL_PLAYBACK_BUFFER_MS, + }, [pcm.buffer]); + } + + function requestPlaybackDrain(responseId) { + if (!playbackNode) return; + playbackNode.port.postMessage({ type: 'drain', responseId: responseId || currentResponseId }); + } + + function sendPlaybackAck(responseId, playedMs) { + if (!responseId || !socket || socket.readyState !== WebSocket.OPEN || playedMs <= 0) { + if (!responseId && playedMs > 0) appendLog('playback ack skipped: missing response id', true); + return; + } + socket.send(JSON.stringify({ + type: 'playback.ack', + response_id: responseId, + item_id: `item_${responseId}`, + played_ms: playedMs, + committed_ms: playedMs, + })); + } + + function playbackDrained(message) { + const responseId = message.responseId || currentResponseId; + sendPlaybackAck(responseId, Number(message.playedMs) || 0); + setPlayback('Idle'); + if (message.underrunMs > 0) { + appendLog(`playback underrun ${message.underrunMs} ms`); + } + window.setTimeout(() => { + assistantActive = false; + currentResponseId = null; + responseHasAudio = false; + if (running) setModel('Listening'); + }, ECHO_GUARD_MS); + } + + function handleEvent(event) { + appendEventLog(event); + const responseId = responseIdOf(event); + switch (event.type) { + case 'session.created': + case 'session.updated': + setConnection('Connected', 'online'); + setModel('Listening'); + break; + case 'response.listen': + assistantActive = false; + setModel('Listening'); + break; + case 'response.created': + case 'response.speak': + beginAssistant(responseId); + break; + case 'response.audio.delta': + currentResponseId = responseId || currentResponseId; + assistantActive = true; + setModel('Speaking'); + decodeAudioDelta(event) + .then((decoded) => feedPlayback(decoded, responseId)) + .catch((error) => appendLog(`audio decode failed: ${error.message || error}`, true)); + break; + case 'response.audio.done': + requestPlaybackDrain(responseId); + break; + case 'response.audio_transcript.delta': + addTranscript('assistant', event.delta || ''); + break; + case 'response.audio_transcript.done': + finishTranscript('assistant', event.transcript || ''); + break; + case 'conversation.item.input_audio_transcription.delta': + addTranscript('user', event.delta || ''); + break; + case 'conversation.item.input_audio_transcription.completed': + finishTranscript('user', event.transcript || ''); + break; + case 'response.done': + finishTranscript('assistant'); + if (!responseHasAudio) requestPlaybackDrain(responseId); + break; + case 'playback.acknowledged': + { + const acknowledgement = event.event || event; + runtimeDetail.textContent = `Playback committed ${acknowledgement.committed_ms || 0} ms`; + } + break; + case 'session.closed': + if (sessionCloseResolver) sessionCloseResolver(); + sessionCloseResolver = null; + break; + case 'error': + setConnection('Error', 'error'); + runtimeDetail.textContent = String(event.error || event.code || 'Server error'); + break; + default: + break; + } + } + + async function openPlayback() { + playbackContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: OUTPUT_RATE }); + playbackRate = playbackContext.sampleRate; + await playbackContext.audioWorklet.addModule(staticAssetUrl('static/playback_worklet.js')); + playbackNode = new AudioWorkletNode(playbackContext, 'fullduplex-pcm-playback'); + playbackNode.port.onmessage = (message) => { + if (message.data.type === 'playback-started') setPlayback('Playing'); + else if (message.data.type === 'playback-drained') playbackDrained(message.data); + else if (message.data.type === 'playback-underrun') { + runtimeDetail.textContent = `Playback underrun ${message.data.underrunMs || 0} ms`; + } + }; + playbackNode.connect(playbackContext.destination); + await playbackContext.resume(); + } + + async function openCapture() { + mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + sampleRate: { ideal: INPUT_RATE }, + }, + }); + try { + captureContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: INPUT_RATE }); + } catch (_error) { + captureContext = new (window.AudioContext || window.webkitAudioContext)(); + } + captureRate = captureContext.sampleRate; + await captureContext.audioWorklet.addModule(staticAssetUrl('static/pcm_worklet.js')); + const source = captureContext.createMediaStreamSource(mediaStream); + captureNode = new AudioWorkletNode(captureContext, 'fullduplex-pcm-capture'); + captureNode.port.onmessage = (message) => { + const pcm = new Int16Array(message.data); + updateMeter(pcm); + if (microphoneUploadEnabled()) pendingCapture.push(pcm); + }; + const silentSink = captureContext.createGain(); + silentSink.gain.value = 0; + source.connect(captureNode); + captureNode.connect(silentSink).connect(captureContext.destination); + await captureContext.resume(); + } + + function openSocket() { + return new Promise((resolve, reject) => { + const url = realtimeUrl(); + socket = new WebSocket(url); + let settled = false; + socket.onopen = () => { + settled = true; + const extraBody = { + auto_response: true, + minicpmo45_native_duplex: true, + }; + const session = { + modalities: ['audio', 'text'], + voice: 'default', + extra_body: extraBody, + }; + // Reference voice for TTS cloning, provided by the server via + // --ref-audio (mirrors the official demo's default ref audio). + if (config.refAudio) session.ref_audio = config.refAudio; + const instructions = systemPromptInput ? systemPromptInput.value.trim() : ''; + if (instructions) session.instructions = instructions; + socket.send(JSON.stringify({ type: 'session.update', session })); + runtimeDetail.textContent = `${captureRate} Hz capture / ${playbackRate} Hz playback`; + appendLog(`websocket open ${url}`); + resolve(); + }; + socket.onmessage = (message) => { + if (typeof message.data !== 'string') return; + try { + handleEvent(JSON.parse(message.data)); + } catch (error) { + appendLog(`invalid server event: ${error.message || error}`, true); + } + }; + socket.onerror = () => { + if (!settled) { + settled = true; + reject(new Error(`WebSocket connection failed: ${url}`)); + } + }; + socket.onclose = (event) => { + appendLog(`websocket closed code=${event.code}`); + if (running) { + setConnection('Disconnected', 'error'); + stopSession(); + } + }; + }); + } + + function formatElapsed(seconds) { + const minutes = Math.floor(seconds / 60); + return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; + } + + function startClock() { + startedAt = Date.now(); + clockTimer = window.setInterval(() => { + sessionTimer.textContent = formatElapsed(Math.floor((Date.now() - startedAt) / 1000)); + }, 1000); + } + + async function startSession() { + if (running) return; + callButton.disabled = true; + setConnection('Connecting', 'connecting'); + runtimeDetail.textContent = 'Requesting microphone access'; + try { + await openPlayback(); + await openCapture(); + await openSocket(); + running = true; + muted = false; + assistantActive = false; + sendTimer = window.setInterval(flushCapture, SEND_INTERVAL_MS); + startClock(); + callButton.textContent = 'End session'; + callButton.classList.add('is-active'); + muteButton.disabled = false; + cameraButton.disabled = false; + setConnection('Connected', 'online'); + setModel('Listening'); + appendLog('session started'); + } catch (error) { + appendLog(`start failed: ${error.message || error}`, true); + setConnection('Error', 'error'); + runtimeDetail.textContent = String(error.message || error); + await stopSession(); + } finally { + callButton.disabled = false; + } + } + + async function startCamera() { + if (cameraStream) return; + cameraStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); + cameraPreview.srcObject = cameraStream; + cameraPreview.style.display = ''; + await cameraPreview.play().catch(() => {}); + // Official omni-duplex cadence: one JPEG (quality 0.7) per ~1 s chunk, + // no client-side resize (the server normalizes at scale_resolution=448). + cameraTimer = window.setInterval(() => { + if (!cameraStream || cameraPreview.videoWidth === 0) return; + cameraCanvas.width = cameraPreview.videoWidth; + cameraCanvas.height = cameraPreview.videoHeight; + cameraCanvas.getContext('2d').drawImage(cameraPreview, 0, 0); + cameraPendingFrame = cameraCanvas.toDataURL('image/jpeg', 0.7).split(',')[1]; + }, 1000); + cameraButton.textContent = 'Camera off'; + cameraButton.classList.add('is-active'); + appendLog('camera on (1 fps omni frames)'); + } + + function stopCamera() { + if (cameraTimer !== null) clearInterval(cameraTimer); + cameraTimer = null; + if (cameraStream) { + for (const track of cameraStream.getTracks()) track.stop(); + } + cameraStream = null; + cameraPendingFrame = null; + cameraPreview.srcObject = null; + cameraPreview.style.display = 'none'; + cameraButton.textContent = 'Camera'; + cameraButton.classList.remove('is-active'); + } + + cameraButton.addEventListener('click', () => { + if (cameraStream) { + stopCamera(); + appendLog('camera off'); + return; + } + startCamera().catch((error) => appendLog(`camera failed: ${error.message || error}`, true)); + }); + + function waitForSessionClosed(targetSocket, timeoutMs) { + if (!targetSocket || targetSocket.readyState !== WebSocket.OPEN) return Promise.resolve(); + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + if (sessionCloseResolver === finish) sessionCloseResolver = null; + resolve(); + }; + sessionCloseResolver = finish; + window.setTimeout(finish, timeoutMs); + }); + } + + async function stopSession({ terminal = true } = {}) { + running = false; + assistantActive = false; + pendingCapture = []; + if (sendTimer !== null) clearInterval(sendTimer); + if (clockTimer !== null) clearInterval(clockTimer); + sendTimer = null; + clockTimer = null; + if (socket) { + const closingSocket = socket; + socket = null; + closingSocket.onclose = null; + if (terminal && closingSocket.readyState === WebSocket.OPEN) { + const closed = waitForSessionClosed(closingSocket, SESSION_CLOSE_TIMEOUT_MS); + closingSocket.send(JSON.stringify({ type: 'session.close' })); + await closed; + } + closingSocket.close(1000, 'client stop'); + } + if (playbackNode) playbackNode.port.postMessage({ type: 'clear' }); + if (mediaStream) { + for (const track of mediaStream.getTracks()) track.stop(); + } + mediaStream = null; + stopCamera(); + cameraButton.disabled = true; + if (captureContext) await captureContext.close().catch(() => {}); + if (playbackContext) await playbackContext.close().catch(() => {}); + captureContext = null; + captureNode = null; + playbackContext = null; + playbackNode = null; + currentResponseId = null; + responseHasAudio = false; + meterFill.style.width = '0%'; + sessionTimer.textContent = '00:00'; + callButton.textContent = 'Start session'; + callButton.classList.remove('is-active'); + muteButton.textContent = 'Mute'; + muteButton.classList.remove('is-active'); + muteButton.disabled = true; + setConnection('Offline', 'offline'); + setModel('Idle'); + setPlayback('Idle'); + if (runtimeDetail.textContent.startsWith('Playback committed')) return; + if (!runtimeDetail.textContent.startsWith('start failed')) runtimeDetail.textContent = 'No active connection'; + } + + function toggleMute() { + if (!running) return; + muted = !muted; + pendingCapture = []; + muteButton.textContent = muted ? 'Unmute' : 'Mute'; + muteButton.classList.toggle('is-active', muted); + appendLog(muted ? 'microphone muted' : 'microphone unmuted'); + } + + callButton.addEventListener('click', () => { + if (running) stopSession(); + else startSession(); + }); + muteButton.addEventListener('click', toggleMute); + clearLogButton.addEventListener('click', () => { + eventLog.textContent = ''; + logCount = 0; + eventCount.textContent = '0 events'; + }); + window.addEventListener('beforeunload', () => { stopSession({ terminal: false }); }); +})(); diff --git a/examples/online_serving/minicpmo/realtime_web/app/static/pcm_worklet.js b/examples/online_serving/minicpmo/realtime_web/app/static/pcm_worklet.js new file mode 100644 index 000000000..608ac6b01 --- /dev/null +++ b/examples/online_serving/minicpmo/realtime_web/app/static/pcm_worklet.js @@ -0,0 +1,15 @@ +class FullDuplexPcmCapture extends AudioWorkletProcessor { + process(inputs) { + const channel = inputs[0] && inputs[0][0]; + if (!channel || channel.length === 0) return true; + const pcm = new Int16Array(channel.length); + for (let index = 0; index < channel.length; index += 1) { + const sample = Math.max(-1, Math.min(1, channel[index])); + pcm[index] = sample < 0 ? sample * 32768 : sample * 32767; + } + this.port.postMessage(pcm.buffer, [pcm.buffer]); + return true; + } +} + +registerProcessor('fullduplex-pcm-capture', FullDuplexPcmCapture); diff --git a/examples/online_serving/minicpmo/realtime_web/app/static/playback_worklet.js b/examples/online_serving/minicpmo/realtime_web/app/static/playback_worklet.js new file mode 100644 index 000000000..f0d3d42ae --- /dev/null +++ b/examples/online_serving/minicpmo/realtime_web/app/static/playback_worklet.js @@ -0,0 +1,168 @@ +class FullDuplexPcmPlayback extends AudioWorkletProcessor { + constructor() { + super(); + this.queue = []; + this.offset = 0; + this.playedFrames = 0; + this.underrunFrames = 0; + this.drain = null; + this.terminalFadeFrames = 0; + this.started = false; + this.activeResponseId = null; + this.initialBufferFrames = Math.round(sampleRate * 0.2); + this.bufferWaitFrames = this.initialBufferFrames; + this.rebuffering = false; + this.fadeFrames = Math.max(1, Math.round(sampleRate * 0.005)); + this.fadeInFrames = 0; + this.port.onmessage = (event) => this.handleMessage(event.data || {}); + } + + handleMessage(message) { + if (message.type === 'audio' && message.pcm) { + const wasEmpty = this.queue.length === 0; + if (!this.started && !this.activeResponseId) { + this.activeResponseId = message.responseId || null; + } + if (!this.started && Number.isFinite(message.initialBufferMs)) { + this.initialBufferFrames = Math.max(0, Math.round((sampleRate * message.initialBufferMs) / 1000)); + } + this.queue.push(message.pcm); + if (!this.started && wasEmpty && !this.rebuffering) { + this.bufferWaitFrames = this.initialBufferFrames; + } + } else if (message.type === 'drain') { + this.drain = { responseId: message.responseId || null }; + this.terminalFadeFrames = Math.min(this.fadeFrames, this.bufferedFrames()); + if (!this.started && this.bufferedFrames() > 0) { + this.bufferWaitFrames = 0; + this.startPlayback(); + } + this.notifyIfDrained(); + } else if (message.type === 'clear') { + this.queue = []; + this.offset = 0; + this.playedFrames = 0; + this.underrunFrames = 0; + this.drain = null; + this.terminalFadeFrames = 0; + this.started = false; + this.activeResponseId = null; + this.bufferWaitFrames = this.initialBufferFrames; + this.rebuffering = false; + this.fadeInFrames = 0; + } + } + + bufferedFrames() { + return this.queue.reduce((total, pcm, index) => ( + total + pcm.length - (index === 0 ? this.offset : 0) + ), 0); + } + + startPlayback() { + if (this.started) return; + this.started = true; + this.fadeInFrames = this.fadeFrames; + if (this.playedFrames === 0) { + this.port.postMessage({ type: 'playback-started', responseId: this.activeResponseId }); + } + } + + notifyIfDrained() { + if (!this.drain || this.queue.length > 0) return; + this.port.postMessage({ + type: 'playback-drained', + responseId: this.drain.responseId, + playedMs: Math.round((this.playedFrames * 1000) / sampleRate), + underrunMs: Math.round((this.underrunFrames * 1000) / sampleRate), + }); + this.playedFrames = 0; + this.underrunFrames = 0; + this.drain = null; + this.terminalFadeFrames = 0; + this.started = false; + this.activeResponseId = null; + this.bufferWaitFrames = this.initialBufferFrames; + this.rebuffering = false; + this.fadeInFrames = 0; + } + + reportUnderrun() { + this.port.postMessage({ + type: 'playback-underrun', + responseId: this.activeResponseId, + underrunMs: Math.round((this.underrunFrames * 1000) / sampleRate), + }); + } + + process(_inputs, outputs) { + const output = outputs[0][0]; + output.fill(0); + if (!this.started) { + if (this.rebuffering && !this.drain) { + this.underrunFrames += output.length; + this.reportUnderrun(); + } + if (this.queue.length > 0 && this.bufferWaitFrames > 0) { + this.bufferWaitFrames = Math.max(0, this.bufferWaitFrames - output.length); + return true; + } + if (this.queue.length > 0) { + this.startPlayback(); + this.rebuffering = false; + } + } + if (!this.started) { + this.notifyIfDrained(); + return true; + } + let target = 0; + while (target < output.length && this.queue.length > 0) { + const pcm = this.queue[0]; + const count = Math.min(output.length - target, pcm.length - this.offset); + const remainingBeforeChunk = this.bufferedFrames(); + for (let index = 0; index < count; index += 1) { + let sample = pcm[this.offset + index] / 32768; + if (this.fadeInFrames > 0) { + const elapsed = this.fadeFrames - this.fadeInFrames; + sample *= elapsed / this.fadeFrames; + this.fadeInFrames -= 1; + } + const remainingFrames = remainingBeforeChunk - index; + if ( + this.drain + && this.terminalFadeFrames > 0 + && remainingFrames <= this.terminalFadeFrames + ) { + sample *= this.terminalFadeFrames === 1 + ? 0 + : (remainingFrames - 1) / (this.terminalFadeFrames - 1); + } + output[target + index] = sample; + } + target += count; + this.offset += count; + this.playedFrames += count; + if (this.offset >= pcm.length) { + this.queue.shift(); + this.offset = 0; + } + } + if (target < output.length && !this.drain) { + const fadeCount = Math.min(target, this.fadeFrames); + for (let index = 0; index < fadeCount; index += 1) { + output[target - fadeCount + index] *= (fadeCount - index - 1) / fadeCount; + } + this.underrunFrames += output.length - target; + this.reportUnderrun(); + this.started = false; + this.rebuffering = true; + this.bufferWaitFrames = this.initialBufferFrames; + this.fadeInFrames = 0; + } + this.notifyIfDrained(); + return true; + } +} + +registerProcessor('fullduplex-pcm-playback', FullDuplexPcmPlayback); diff --git a/examples/online_serving/minicpmo/realtime_web/app/static/styles.css b/examples/online_serving/minicpmo/realtime_web/app/static/styles.css new file mode 100644 index 000000000..9b080176b --- /dev/null +++ b/examples/online_serving/minicpmo/realtime_web/app/static/styles.css @@ -0,0 +1,127 @@ +:root { + color-scheme: light; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #20252b; + background: #f4f6f7; + font-synthesis: none; +} + +* { box-sizing: border-box; } + +body { margin: 0; min-width: 320px; } + +button, summary { font: inherit; } + +button:focus-visible, summary:focus-visible { + outline: 3px solid rgba(21, 101, 192, .25); + outline-offset: 2px; +} + +.app-header { + min-height: 104px; + padding: 24px clamp(20px, 5vw, 64px); + background: #ffffff; + border-bottom: 1px solid #d9dee3; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +h1, h2, p { margin: 0; } +h1 { font-size: 24px; line-height: 1.2; letter-spacing: 0; } +h2 { font-size: 18px; line-height: 1.3; letter-spacing: 0; } + +.eyebrow { + margin-bottom: 5px; + color: #68717b; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .08em; +} + +main { width: min(1040px, calc(100% - 40px)); margin: 28px auto 48px; } + +.status { + min-width: 88px; + padding: 7px 10px; + border: 1px solid; + border-radius: 6px; + text-align: center; + font-size: 13px; + font-weight: 700; +} + +.status-offline { color: #616971; background: #f0f2f3; border-color: #cbd1d6; } +.status-connecting { color: #8a4b00; background: #fff5df; border-color: #e6b76a; } +.status-online { color: #176a3a; background: #e9f7ef; border-color: #7dc49b; } +.status-error { color: #a12222; background: #fff0f0; border-color: #df8f8f; } + +.call-bar { + min-height: 86px; + padding: 16px 18px; + background: #252b31; + color: #ffffff; + border-radius: 6px; + display: grid; + grid-template-columns: auto minmax(150px, 1fr) auto; + align-items: center; + gap: 24px; +} + +.call-actions { display: flex; gap: 8px; } +button { border-radius: 5px; border: 1px solid transparent; cursor: pointer; } +button:disabled { opacity: .46; cursor: default; } +.primary { padding: 10px 15px; color: #ffffff; background: #16834b; font-weight: 700; } +.primary.is-active { background: #b43636; } +.secondary { padding: 10px 13px; color: #ffffff; background: #39414a; border-color: #59636d; } +.secondary.is-active { color: #2b2525; background: #ffc9c9; border-color: #e58f8f; } + +.meter { min-width: 0; display: flex; align-items: center; gap: 10px; color: #c8cfd6; font-size: 12px; } +.meter-track { height: 8px; flex: 1; overflow: hidden; background: #444d56; border-radius: 4px; } +.meter-fill { width: 0; height: 100%; background: #54bd7d; transition: width 70ms linear; } + +.runtime-status { margin: 0; display: flex; gap: 24px; } +.runtime-status div { min-width: 68px; } +.runtime-status dt { color: #9fa8b1; font-size: 10px; text-transform: uppercase; } +.runtime-status dd { margin: 3px 0 0; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; } + +.conversation-section { margin-top: 22px; padding: 22px 0 10px; } +.section-heading { display: flex; justify-content: space-between; align-items: end; gap: 16px; padding: 0 2px 14px; border-bottom: 1px solid #cfd5da; } +.policy-label { color: #5e6871; font-size: 12px; } +.conversation { min-height: 260px; max-height: 440px; overflow-y: auto; padding: 18px 2px; } +.empty-state { color: #7a838b; font-size: 14px; } + +.turn { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 12px; padding: 12px 0; border-bottom: 1px solid #e0e4e7; } +.turn-role { padding-top: 2px; color: #68717b; font-size: 11px; font-weight: 800; text-transform: uppercase; } +.turn-user .turn-role { color: #1565c0; } +.turn-assistant .turn-role { color: #a45117; } +.turn-text { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.55; } +.turn-live .turn-text::after { content: ""; display: inline-block; width: 6px; height: 14px; margin-left: 4px; background: #9098a0; vertical-align: -2px; animation: blink 1s step-end infinite; } +@keyframes blink { 50% { opacity: 0; } } + +.diagnostics { margin-top: 18px; border-top: 1px solid #bfc6cc; border-bottom: 1px solid #bfc6cc; background: #ffffff; } +.diagnostics summary { padding: 13px 14px; display: flex; justify-content: space-between; cursor: pointer; font-weight: 700; list-style: none; } +.diagnostics summary::-webkit-details-marker { display: none; } +.event-count { color: #6f7880; font-size: 12px; font-weight: 500; } +.log-toolbar { min-height: 40px; padding: 7px 12px; border-top: 1px solid #e0e4e7; display: flex; align-items: center; justify-content: space-between; gap: 12px; color: #69727a; font-size: 12px; } +.icon-button { padding: 5px 9px; color: #394149; background: #f4f6f7; border-color: #cdd3d8; } +.event-log { height: 200px; margin: 0; padding: 12px 14px; overflow: auto; background: #171b1f; color: #d9e0e6; font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; } +.log-error { color: #ff9f9f; } + +@media (max-width: 760px) { + .app-header { min-height: 88px; padding: 18px 20px; } + h1 { font-size: 20px; } + main { width: min(100% - 24px, 620px); margin-top: 16px; } + .call-bar { grid-template-columns: 1fr; gap: 15px; } + .call-actions button { flex: 1; } + .runtime-status { justify-content: space-between; gap: 10px; } + .section-heading { align-items: start; flex-direction: column; gap: 7px; } + .conversation { min-height: 230px; } + .turn { grid-template-columns: 72px minmax(0, 1fr); } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/examples/online_serving/minicpmo/realtime_web/server.py b/examples/online_serving/minicpmo/realtime_web/server.py new file mode 100644 index 000000000..ee78fff74 --- /dev/null +++ b/examples/online_serving/minicpmo/realtime_web/server.py @@ -0,0 +1,186 @@ +"""Static host and same-origin Realtime proxy for the full-duplex demo.""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import contextlib +import hashlib +import json +import logging +from pathlib import Path +from urllib.parse import urlencode + +import uvicorn +import websockets +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse, Response +from fastapi.staticfiles import StaticFiles + +logger = logging.getLogger(__name__) +APP_DIR = Path(__file__).parent / "app" +STATIC_DIR = APP_DIR / "static" + + +def _join_ws_url(base: str, path: str, query: str) -> str: + return base.rstrip("/") + path + (("?" + query) if query else "") + + +async def _pump_client_to_backend(client: WebSocket, backend) -> None: + try: + while True: + message = await client.receive() + if message["type"] == "websocket.disconnect": + await backend.close() + return + if message.get("text") is not None: + await backend.send(message["text"]) + elif message.get("bytes") is not None: + await backend.send(message["bytes"]) + except (WebSocketDisconnect, websockets.ConnectionClosed): + with contextlib.suppress(Exception): + await backend.close() + + +async def _pump_backend_to_client(client: WebSocket, backend) -> None: + try: + async for message in backend: + if isinstance(message, bytes): + await client.send_bytes(message) + else: + await client.send_text(message) + except (WebSocketDisconnect, websockets.ConnectionClosed): + return + except RuntimeError as exc: + if "websocket.send" in str(exc) and "websocket.close" in str(exc): + return + raise + + +def _expected_proxy_close(exc: BaseException) -> bool: + if isinstance(exc, (WebSocketDisconnect, websockets.ConnectionClosed, asyncio.CancelledError)): + return True + return isinstance(exc, RuntimeError) and "websocket.send" in str(exc) and "websocket.close" in str(exc) + + +def build_app( + *, + ws_backend: str = "ws://127.0.0.1:8099", + model: str = "openbmb/MiniCPM-o-4_5", + public_realtime_url: str | None = None, + ref_audio: str | None = None, +) -> FastAPI: + app = FastAPI(title="Experimental Full-Duplex Web Demo") + index_path = APP_DIR / "index.html" + app_version_hash = hashlib.sha256() + for asset_path in ( + STATIC_DIR / "app.js", + STATIC_DIR / "pcm_worklet.js", + STATIC_DIR / "playback_worklet.js", + ): + app_version_hash.update(asset_path.read_bytes()) + app_version = app_version_hash.hexdigest()[:12] + + ref_audio_uri: str | None = None + if ref_audio: + ref_path = Path(ref_audio) + if not ref_path.is_file(): + raise SystemExit(f"--ref-audio not found: {ref_audio}") + encoded = base64.b64encode(ref_path.read_bytes()).decode("ascii") + ref_audio_uri = f"data:audio/wav;base64,{encoded}" + + @app.get("/", response_class=HTMLResponse) + def index() -> HTMLResponse: + config = json.dumps( + { + "model": model, + "realtimePath": public_realtime_url or "v1/realtime", + "refAudio": ref_audio_uri, + "appVersion": app_version, + }, + ensure_ascii=True, + ) + html = ( + index_path.read_text(encoding="utf-8") + .replace("__FULL_DUPLEX_CONFIG__", config) + .replace("__FULL_DUPLEX_APP_VERSION__", app_version) + ) + return HTMLResponse(html, headers={"Cache-Control": "no-store"}) + + @app.get("/healthz") + def healthz() -> Response: + return Response(content="ok", media_type="text/plain") + + @app.websocket("/v1/realtime") + async def realtime_proxy(websocket: WebSocket) -> None: + await websocket.accept() + query = urlencode(websocket.query_params.multi_items()) + backend_url = _join_ws_url(ws_backend, "/v1/realtime", query) + logger.info("Proxying Realtime WebSocket to %s", backend_url) + try: + async with websockets.connect( + backend_url, + max_size=64 * 1024 * 1024, + ) as backend: + tasks = { + asyncio.create_task(_pump_client_to_backend(websocket, backend)), + asyncio.create_task(_pump_backend_to_client(websocket, backend)), + } + done, pending = await asyncio.wait( + tasks, + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + for result in await asyncio.gather(*done, return_exceptions=True): + if isinstance(result, BaseException) and not _expected_proxy_close(result): + raise result + except (WebSocketDisconnect, websockets.ConnectionClosed): + return + except Exception: + logger.exception("Realtime WebSocket proxy failed") + with contextlib.suppress(Exception): + await websocket.close(code=1011) + + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + return app + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=7862) + parser.add_argument("--ws-backend", default="ws://127.0.0.1:8099") + parser.add_argument( + "--public-realtime-url", + help="Browser-visible ws:// or wss:// Realtime URL; defaults to the same-origin proxy.", + ) + parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5") + parser.add_argument( + "--ref-audio", + required=True, + help=( + "Reference voice wav for TTS voice cloning, e.g. the " + "official MiniCPM-o-Demo assets/ref_audio/ref_minicpm_signature.wav" + ), + ) + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO) + uvicorn.run( + build_app( + ws_backend=args.ws_backend, + model=args.model, + public_realtime_url=args.public_realtime_url, + ref_audio=args.ref_audio, + ), + host=args.host, + port=args.port, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/examples/online_serving/minicpmo/run_curl_multimodal_generation.sh b/examples/online_serving/minicpmo/run_curl_multimodal_generation.sh new file mode 100755 index 000000000..426a2df00 --- /dev/null +++ b/examples/online_serving/minicpmo/run_curl_multimodal_generation.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Default query type +QUERY_TYPE="${1:-text}" + +# Default modalities argument (JSON). Use null for server default (text+audio). +MODALITIES="${2:-null}" + +# Validate query type +if [[ ! "$QUERY_TYPE" =~ ^(text|use_audio|use_image|use_video)$ ]]; then + echo "Error: Invalid query type '$QUERY_TYPE'" + echo "Usage: $0 [text|use_audio|use_image|use_video] [modalities]" + echo " text: Text query" + echo " use_audio: Audio + text query" + echo " use_image: Image + text query" + echo " use_video: Video + text query" + echo " modalities: JSON list or null (default: null → text+audio)" + echo " examples: '[\"text\"]' '[\"text\",\"audio\"]'" + exit 1 +fi + +HOST="${HOST:-localhost}" +PORT="${PORT:-8099}" +MODEL="${MODEL:-openbmb/MiniCPM-o-4_5}" + +thinker_sampling_params='{ + "temperature": 0.0, + "top_p": 1.0, + "top_k": -1, + "max_tokens": 2048, + "seed": 42, + "detokenize": true, + "repetition_penalty": 1.1 +}' + +talker_sampling_params='{ + "temperature": 0.0, + "top_p": 1.0, + "top_k": -1, + "max_tokens": 1, + "seed": 42, + "detokenize": false +}' +# Above is optional; defaults live in vllm_omni/deploy/minicpmo_4_5.yaml. + +MARY_HAD_LAMB_AUDIO_URL="https://vllm-public-assets.s3.us-west-2.amazonaws.com/multimodal_asset/mary_had_lamb.ogg" +CHERRY_BLOSSOM_IMAGE_URL="https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/cherry_blossom.jpg" +SAMPLE_VIDEO_URL="https://huggingface.co/datasets/raushan-testing-hf/videos-test/resolve/main/sample_demo_1.mp4" + +case "$QUERY_TYPE" in + text) + user_content='[ + { + "type": "text", + "text": "Say hello, then introduce vLLM-Omni in one sentence." + } + ]' + ;; + use_audio) + user_content='[ + { + "type": "audio_url", + "audio_url": { + "url": "'"$MARY_HAD_LAMB_AUDIO_URL"'" + } + }, + { + "type": "text", + "text": "What is the content of this audio?" + } + ]' + ;; + use_image) + user_content='[ + { + "type": "image_url", + "image_url": { + "url": "'"$CHERRY_BLOSSOM_IMAGE_URL"'" + } + }, + { + "type": "text", + "text": "What is the content of this image?" + } + ]' + ;; + use_video) + user_content='[ + { + "type": "video_url", + "video_url": { + "url": "'"$SAMPLE_VIDEO_URL"'" + } + }, + { + "type": "text", + "text": "Why is this video funny?" + } + ]' + ;; +esac + +sampling_params_list='[ + '"$thinker_sampling_params"', + '"$talker_sampling_params"' +]' + +# TTS speech path needs use_tts_template at the request root (curl does not +# flatten nested extra_body). Skip it when the caller asked for text-only. +USE_TTS_TEMPLATE=true +if [[ "$MODALITIES" == '["text"]' || "$MODALITIES" == "['text']" ]]; then + USE_TTS_TEMPLATE=false +fi + +echo "Running query type: $QUERY_TYPE (host=$HOST port=$PORT model=$MODEL)" +echo "modalities=$MODALITIES use_tts_template=$USE_TTS_TEMPLATE" +echo "" + +request_body=$(cat < Experimental. Requires a GPU and Hugging Face access to the gated repo +> (`HF_TOKEN` with access to `nvidia/personaplex-7b-v1`). + +## Start the server + +```bash +HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 bash run_server.sh +# or directly: +HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 python -m \ + vllm_omni.experimental.fullduplex.personaplex.serving.server --port 8124 +``` + +## Talk to it + +- **Browser (recommended):** open `http://localhost:8124/` and allow the microphone. + Use headphones so the agent does not hear itself. +- **Headless:** stream a 24 kHz mono WAV and save the reply: + + ```bash + python duplex_client.py --url ws://localhost:8124/api/chat --input user.wav --out reply.wav + ``` + +## Unified Realtime API + +The default `vllm_omni/deploy/personaplex.yaml` also enables main's engine-owned +full-duplex control plane: + +```bash +CUDA_VISIBLE_DEVICES=0 python -m vllm_omni.entrypoints.cli.main serve \ + /path/to/personaplex-7b-v1 \ + --omni \ + --stage-configs-path vllm_omni/deploy/personaplex.yaml +``` + +Validate the actual `/v1/realtime?duplex=1` scheduler path with paced 24 kHz +PCM, two concurrent sessions, overflow admission, per-session slot recycling, +and non-silent output: + +```bash +python tests/e2e/online_serving/personaplex_realtime_duplex.py \ + --model /path/to/personaplex-7b-v1 \ + --input-wav /path/to/speech.wav \ + --output-dir /tmp/personaplex-realtime-duplex +``` + +This is distinct from the standalone `/api/chat` compatibility server above. +The unified endpoint advertises `supports_barge_in=false`: overlapping speech +is native model behavior, but destructive output interruption and model-state +rewind have not been validated for PersonaPlex. + +## Protocol (`/api/chat`) + +Binary WebSocket messages, first byte is a tag (identical to `moshi.server`): + +| Direction | Tag | Payload | +| --- | --- | --- | +| server → client | `\x00` | ready handshake (after the system-prompt prefill) | +| client → server | `\x01` | Opus-encoded mic audio (24 kHz) | +| server → client | `\x01` | Opus-encoded agent audio | +| server → client | `\x02` | UTF-8 inner-monologue text | + +Connect with query params `text_prompt` (persona) and `voice_prompt` (voice file). +A raw-PCM endpoint (`/v1/audio/duplex`, JSON `open` + float32 frames) is also available +for clients that do not want an Opus dependency. + +## Notes + +- **Run the client near the server.** Real-time 80 ms audio is sensitive to network + latency/jitter; over a high-latency remote link playback can stutter regardless of + engine speed. On localhost it is smooth. +- Greedy decoding by default. One conversation per server by default (a second + concurrent connection is rejected with WS close 1013 until the first ends); + pass `--batch-size N` to host N concurrent conversations on one engine + (elastic batching; connections beyond N are rejected until a slot frees). +- Full runbook: [`recipes/NVIDIA/PersonaPlex.md`](../../../recipes/NVIDIA/PersonaPlex.md). diff --git a/examples/online_serving/personaplex/duplex_client.py b/examples/online_serving/personaplex/duplex_client.py new file mode 100644 index 000000000..bb1eaba2b --- /dev/null +++ b/examples/online_serving/personaplex/duplex_client.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Headless client for the PersonaPlex duplex server (official Moshi protocol). + +Streams a 24 kHz mono WAV to ``/api/chat`` exactly as the browser client does +(Opus over WebSocket, tags ``\\x00`` ready / ``\\x01`` audio / ``\\x02`` text), +collects the agent's reply audio + inner-monologue text, and writes a WAV. + +The browser UI at ``http://:/`` is the primary demo; this script is +for headless testing / scripting. + + python duplex_client.py --url ws://localhost:8124/api/chat \ + --input user.wav --out reply.wav + +Deps: ``websockets``, ``sphn``, ``soundfile``, ``numpy``. +""" + +from __future__ import annotations + +import argparse +import asyncio + +import numpy as np +import soundfile as sf +import sphn + +SR = 24000 # PersonaPlex / Mimi codec rate + + +async def converse(url: str, pcm: np.ndarray) -> tuple[np.ndarray, str]: + import websockets + + reader = sphn.OpusStreamReader(SR) # decode agent audio + writer = sphn.OpusStreamWriter(SR) # encode our mic audio + text: list[str] = [] + ready = asyncio.Event() + last = {"t": 0.0} + + async with websockets.connect(url, max_size=None) as ws: + + async def receive() -> None: + loop = asyncio.get_event_loop() + while True: + try: + msg = await ws.recv() + except Exception: + return + if not isinstance(msg, (bytes, bytearray)) or not msg: + continue + tag, payload = msg[0], msg[1:] + if tag == 0: + ready.set() + elif tag == 1: + reader.append_bytes(bytes(payload)) + last["t"] = loop.time() + elif tag == 2: + text.append(payload.decode("utf8", errors="replace")) + + recv_task = asyncio.create_task(receive()) + await asyncio.wait_for(ready.wait(), timeout=120) # waits for system-prompt prefill + + frame = 1920 # 80 ms + for i in range(0, len(pcm), frame): + chunk = pcm[i : i + frame] + if len(chunk) < frame: + chunk = np.concatenate([chunk, np.zeros(frame - len(chunk), dtype=np.float32)]) + writer.append_pcm(chunk) + data = writer.read_bytes() + if data: + await ws.send(b"\x01" + data) + await asyncio.sleep(frame / SR) # pace at real time + + loop = asyncio.get_event_loop() + last["t"] = loop.time() + while loop.time() - last["t"] < 2.5: # drain until the agent stops talking + await asyncio.sleep(0.1) + recv_task.cancel() + + chunks: list[np.ndarray] = [] + while True: + out = reader.read_pcm() + if out is None or out.shape[-1] == 0: + break + chunks.append(np.asarray(out, dtype=np.float32)) + audio = np.concatenate(chunks) if chunks else np.zeros(0, dtype=np.float32) + return audio, "".join(text) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--url", default="ws://localhost:8124/api/chat") + ap.add_argument("--input", required=True, help="24 kHz mono WAV of the user turn") + ap.add_argument("--out", default="personaplex_reply.wav") + ap.add_argument("--tail", type=float, default=10.0, help="trailing silence (s) for the agent reply") + ap.add_argument("--persona", default="") + ap.add_argument("--voice", default="NATF2.pt") + args = ap.parse_args() + + wav, sr = sf.read(args.input, dtype="float32") + if wav.ndim > 1: + wav = wav.mean(axis=1) + if sr != SR: + raise SystemExit(f"input must be {SR} Hz mono; got {sr} Hz. Resample it first.") + wav = np.concatenate([wav, np.zeros(int(args.tail * SR), dtype=np.float32)]) + + url = f"{args.url}?text_prompt={args.persona}&voice_prompt={args.voice}" + audio, text = asyncio.run(converse(url, wav)) + sf.write(args.out, audio if audio.size else np.zeros(SR, dtype=np.float32), SR) + print(f"agent reply: {audio.size / SR:.2f}s -> {args.out}") + print(f"inner-monologue: {text.strip()!r}") + + +if __name__ == "__main__": + main() diff --git a/examples/online_serving/personaplex/run_server.sh b/examples/online_serving/personaplex/run_server.sh new file mode 100755 index 000000000..53f6f0886 --- /dev/null +++ b/examples/online_serving/personaplex/run_server.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# PersonaPlex full-duplex online serving. +# +# Serves the OFFICIAL PersonaPlex web client at / (auto-downloaded from +# nvidia/personaplex-7b-v1) and the Moshi WebSocket protocol at /api/chat, +# backed by the native vLLM-Omni engine. Requires HF access to the gated repo. + +HOST="${HOST:-0.0.0.0}" +PORT="${PORT:-8124}" + +echo "Starting PersonaPlex duplex server on ${HOST}:${PORT}..." +echo "Open http://localhost:${PORT}/ for the official web client (run it near the server)." + +python -m vllm_omni.experimental.fullduplex.personaplex.serving.server \ + --host "$HOST" --port "$PORT" diff --git a/examples/online_serving/qwen2_5_omni/README.md b/examples/online_serving/qwen2_5_omni/README.md index 48406003d..8d30fd314 100644 --- a/examples/online_serving/qwen2_5_omni/README.md +++ b/examples/online_serving/qwen2_5_omni/README.md @@ -12,9 +12,9 @@ Please refer to [README.md](../../../README.md) vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 ``` -If you have custom stage configs file, launch the server with command below +To use a custom deploy config, launch the server with the command below: ```bash -vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --stage-configs-path /path/to/stage_configs_file +vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/deploy_config.yaml ``` ### Send Multi-modal Request @@ -173,7 +173,7 @@ The script supports the following arguments: - `--model`: Model name/path (default: Qwen/Qwen2.5-Omni-7B) - `--server-port`: Port for vLLM server (default: 8091) - `--gradio-port`: Port for Gradio demo (default: 7861) -- `--stage-configs-path`: Path to custom stage configs YAML file (optional) +- `--deploy-config`: Path to a custom deploy config YAML file (optional) - `--server-host`: Host for vLLM server (default: 0.0.0.0) - `--gradio-ip`: IP for Gradio demo (default: 127.0.0.1) - `--share`: Share Gradio demo publicly (creates a public link) @@ -186,9 +186,9 @@ The script supports the following arguments: vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 ``` -If you have custom stage configs file: +To use a custom deploy config: ```bash -vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --stage-configs-path /path/to/stage_configs_file +vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/deploy_config.yaml ``` **Step 2: Run the Gradio demo** diff --git a/examples/online_serving/qwen2_5_omni/run_gradio_demo.sh b/examples/online_serving/qwen2_5_omni/run_gradio_demo.sh index 296cc7fa5..da6232ce1 100755 --- a/examples/online_serving/qwen2_5_omni/run_gradio_demo.sh +++ b/examples/online_serving/qwen2_5_omni/run_gradio_demo.sh @@ -13,7 +13,7 @@ set -e MODEL="Qwen/Qwen2.5-Omni-7B" SERVER_PORT=8091 GRADIO_PORT=7861 -STAGE_CONFIGS_PATH="" +DEPLOY_CONFIG_PATH="" SERVER_HOST="0.0.0.0" GRADIO_IP="127.0.0.1" GRADIO_SHARE=false @@ -33,8 +33,8 @@ while [[ $# -gt 0 ]]; do GRADIO_PORT="$2" shift 2 ;; - --stage-configs-path) - STAGE_CONFIGS_PATH="$2" + --deploy-config) + DEPLOY_CONFIG_PATH="$2" shift 2 ;; --server-host) @@ -56,7 +56,7 @@ while [[ $# -gt 0 ]]; do echo " --model MODEL Model name/path (default: Qwen/Qwen2.5-Omni-7B)" echo " --server-port PORT Port for vLLM server (default: 8091)" echo " --gradio-port PORT Port for Gradio demo (default: 7861)" - echo " --stage-configs-path PATH Path to custom stage configs YAML file (optional)" + echo " --deploy-config PATH Path to custom deploy config YAML file (optional)" echo " --server-host HOST Host for vLLM server (default: 0.0.0.0)" echo " --gradio-ip IP IP for Gradio demo (default: 127.0.0.1)" echo " --share Share Gradio demo publicly" @@ -87,8 +87,8 @@ echo "==========================================" # Build vLLM server command SERVER_CMD=("vllm" "serve" "$MODEL" "--omni" "--port" "$SERVER_PORT" "--host" "$SERVER_HOST") -if [ -n "$STAGE_CONFIGS_PATH" ]; then - SERVER_CMD+=("--stage-configs-path" "$STAGE_CONFIGS_PATH") +if [ -n "$DEPLOY_CONFIG_PATH" ]; then + SERVER_CMD+=("--deploy-config" "$DEPLOY_CONFIG_PATH") fi # Function to cleanup on exit diff --git a/examples/online_serving/qwen3_omni/README.md b/examples/online_serving/qwen3_omni/README.md index 3c3ad2b16..74a18af32 100644 --- a/examples/online_serving/qwen3_omni/README.md +++ b/examples/online_serving/qwen3_omni/README.md @@ -353,13 +353,16 @@ curl http://localhost:8091/v1/chat/completions \ #### Text + Audio ```bash -curl -s http://localhost:8091/v1/chat/completions \ +response=$(curl -s http://localhost:8091/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", "messages": [{"role": "user", "content": "Describe vLLM in brief."}], - "modalities": ["audio"] - }' | jq -r '.choices[0].message.audio.data' | base64 -d > output.wav + "modalities": ["text", "audio"] + }') + +echo "$response" | jq -r '.choices[0].message.content' +echo "$response" | jq -r '.choices[1].message.audio.data' | base64 -d > output.wav ``` ### Using Python client @@ -456,8 +459,8 @@ response = client.chat.completions.create( modalities=["audio"], extra_body={"speaker": "chelsie"} ) -# Audio uses the specified speaker -print(response.choices[1].message.audio) +# Audio-only responses contain a single audio choice +print(response.choices[0].message.audio) ``` Supported speaker names depend on the model (e.g. `Ethan`, `Chelsie`, `Aiden`). Omit `speaker` to use the default. diff --git a/examples/online_serving/qwen3_omni/qwen3_omni_moe_thinking.yaml b/examples/online_serving/qwen3_omni/qwen3_omni_moe_thinking.yaml deleted file mode 100644 index 7ce88f80a..000000000 --- a/examples/online_serving/qwen3_omni/qwen3_omni_moe_thinking.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# Stage config for running Qwen3-Omni-MoE-Thinking (text-only output) -# This config is for models like Qwen3-Omni-30B-A3B-Thinking that only have the -# thinker component and do not support audio output. -# -# Single stage: Thinker (multimodal understanding + text generation) - -# The following config has been verified on 2x H100-80G GPUs. -stage_args: - - stage_id: 0 - runtime: - devices: "0,1" - engine_args: - model_stage: thinker - max_num_seqs: 1 - model_arch: Qwen3OmniMoeForConditionalGeneration - worker_type: ar - scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler - gpu_memory_utilization: 0.9 - enforce_eager: true - trust_remote_code: true - engine_output_type: text - distributed_executor_backend: "mp" - enable_prefix_caching: true - hf_config_name: thinker_config - tensor_parallel_size: 2 - final_output: true - final_output_type: text - is_comprehension: true - default_sampling_params: - temperature: 0.4 - top_p: 0.9 - top_k: 1 - max_tokens: 2048 - seed: 42 - detokenize: True - repetition_penalty: 1.05 diff --git a/examples/online_serving/qwen3_omni/run_gradio_demo.sh b/examples/online_serving/qwen3_omni/run_gradio_demo.sh index 73ce273d9..465304534 100755 --- a/examples/online_serving/qwen3_omni/run_gradio_demo.sh +++ b/examples/online_serving/qwen3_omni/run_gradio_demo.sh @@ -13,7 +13,7 @@ set -e MODEL="Qwen/Qwen3-Omni-30B-A3B-Instruct" SERVER_PORT=8091 GRADIO_PORT=7861 -STAGE_CONFIGS_PATH="" +DEPLOY_CONFIG="" SERVER_HOST="0.0.0.0" GRADIO_IP="127.0.0.1" GRADIO_SHARE=false @@ -33,8 +33,8 @@ while [[ $# -gt 0 ]]; do GRADIO_PORT="$2" shift 2 ;; - --stage-configs-path) - STAGE_CONFIGS_PATH="$2" + --deploy-config) + DEPLOY_CONFIG="$2" shift 2 ;; --server-host) @@ -56,7 +56,7 @@ while [[ $# -gt 0 ]]; do echo " --model MODEL Model name/path (default: Qwen/Qwen3-Omni-30B-A3B-Instruct)" echo " --server-port PORT Port for vLLM server (default: 8091)" echo " --gradio-port PORT Port for Gradio demo (default: 7861)" - echo " --stage-configs-path PATH Path to custom stage configs YAML file (optional)" + echo " --deploy-config PATH Path to custom deploy config YAML file (optional)" echo " --server-host HOST Host for vLLM server (default: 0.0.0.0)" echo " --gradio-ip IP IP for Gradio demo (default: 127.0.0.1)" echo " --share Share Gradio demo publicly" @@ -87,8 +87,8 @@ echo "==========================================" # Build vLLM server command SERVER_CMD=("vllm" "serve" "$MODEL" "--omni" "--port" "$SERVER_PORT" "--host" "$SERVER_HOST") -if [ -n "$STAGE_CONFIGS_PATH" ]; then - SERVER_CMD+=("--stage-configs-path" "$STAGE_CONFIGS_PATH") +if [ -n "$DEPLOY_CONFIG" ]; then + SERVER_CMD+=("--deploy-config" "$DEPLOY_CONFIG") fi # Function to cleanup on exit diff --git a/examples/online_serving/replica_data_parallel/README.md b/examples/online_serving/replica_data_parallel/README.md new file mode 100644 index 000000000..a230d71eb --- /dev/null +++ b/examples/online_serving/replica_data_parallel/README.md @@ -0,0 +1,90 @@ +# Replica Data Parallelism for Video Generation + +A runnable recipe + benchmark for **replica data parallelism** (replica-DP) on a +video DiT. Replica-DP runs `N` independent diffusion engine replicas, one per GPU, +and routes each request to a single replica. It scales **request throughput** +near-linearly; it does **not** change single-request latency (for that, use an +intra-request axis such as tensor/sequence/CFG parallelism). + +This example uses `Wan-AI/Wan2.2-TI2V-5B-Diffusers` with one GPU per replica. + +## Files + +| File | Purpose | +|------|---------| +| `wan2_2_ti2v_dp.yaml` | Deploy config; `num_replicas` + `devices` drive the fan-out. | +| `run_server.sh` | Substitutes `NUM_REPLICAS` / `DEVICES` into the config and serves. | +| `bench_replica_dp.py` | Replica-agnostic load driver; reports throughput, latency, and a per-input isolation check. | + +## Run + +Start the server with the replica count you want (one GPU per replica): + +```bash +# baseline +NUM_REPLICAS=1 DEVICES=0 ./run_server.sh +# 2 replicas +NUM_REPLICAS=2 DEVICES=0,1 ./run_server.sh +# 4 replicas +NUM_REPLICAS=4 DEVICES=0,1,2,3 ./run_server.sh +``` + +In another shell, drive a fixed workload and read the throughput: + +```bash +python bench_replica_dp.py --url http://127.0.0.1:8098 \ + --num-requests 28 --concurrency 8 --label "replicas=4" +``` + +Re-run the server at `NUM_REPLICAS=1,2,4` (same client workload each time) and +compare `videos/min`. Pass `--baseline-tpm ` to print a scaling factor. + +### Isolation check + +Identical outputs under a *shared* prompt+seed prove nothing -- every request +would be identical regardless of isolation. Instead each request gets a distinct +input (a prompt from the pool + a unique seed `base_seed+i`), and we check that a +request's output is unchanged whether it runs alone or concurrently amid other +distinct requests. Capture a per-input baseline on `N=1`, then verify it under +`N>=2` concurrency: + +```bash +# 1) against NUM_REPLICAS=1 (serial), record each input's output hash +python bench_replica_dp.py --num-requests 8 --concurrency 1 \ + --write-baseline baseline.json + +# 2) against NUM_REPLICAS>=2, replay the SAME inputs concurrently and verify +python bench_replica_dp.py --num-requests 8 --concurrency 8 \ + --check-baseline baseline.json +``` + +Step 2 exits non-zero if any request's output differs from its own baseline (a +mismatch means cross-request state bleed or misrouting). Keep `--num-requests`, +`--base-seed`, and the prompt source identical between the two runs so the input +sets match. + +## Measured scaling + +Wan2.2-TI2V-5B (832×480, 33 frames, 30 steps), 4× A800-80GB (NVLink), one GPU per replica: + +| Replicas | Throughput (videos/min) | Scaling | Efficiency | +|----------|-------------------------|---------|------------| +| 1 | 4.71 | 1.00× | — | +| 2 | 9.20 | 1.95× | 98% | +| 4 | 18.02 | 3.83× | 96% | + +Per-request latency stayed flat (~13–14 s) across all replica counts. Isolation +is checked per-input via the baseline/verify protocol above: each request's +output under `N>=2` concurrency must match its own single-replica baseline. + +## Notes on the config surface + +- `runtime.num_replicas: N` fans out `N` replicas; `runtime.devices` assigns their + GPUs. With `tensor_parallel_size = 1`, list one GPU per replica + (`num_replicas * tensor_parallel_size` entries, pool mode). +- Replica fan-out here comes from the deploy config, not a CLI flag. (The headless / + multi-runtime launch path uses the process-local `--omni-dp-size-local`, which + requires `--stage-id`.) +- This recipe serves via `--deploy-config`. Replica fan-out comes from the deploy + config's `num_replicas` / `devices`; stage topology comes from the registered + `WanPipeline`. diff --git a/examples/online_serving/replica_data_parallel/bench_replica_dp.py b/examples/online_serving/replica_data_parallel/bench_replica_dp.py new file mode 100755 index 000000000..977f475ac --- /dev/null +++ b/examples/online_serving/replica_data_parallel/bench_replica_dp.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Throughput-scaling + isolation benchmark for diffusion replica data parallelism. + +Sends `--num-requests` text-to-video requests (up to `--concurrency` in flight) +to a running vLLM-Omni video server via `POST /v1/videos/sync` (blocks until the +clip is produced) and reports: + + completed / failed, makespan (wall clock), throughput (videos/min), + per-request latency p50/p90/max (nearest-rank), and -- when a baseline + manifest is supplied -- a per-request isolation check. + +Each request gets a *distinct* input (a prompt from the pool + a unique seed), +so an output is a deterministic function of its own input and nothing else. + +Measuring replica scaling +------------------------- +This client is replica-agnostic. Vary the *server's* replica count +(`runtime.num_replicas` in the stage config; see run_server.sh) and keep the +client workload fixed, then compare throughput: + + N=1 -> baseline + N=2 -> expect ~2x + N=4 -> expect ~4x (near-linear is the goal) + +Pass the single-replica videos/min via `--baseline-tpm` to print a scaling +factor for the current run. + +Isolation / correctness +----------------------- +Byte-identical outputs under a *shared* prompt+seed prove nothing -- every +request would be identical regardless of isolation. Instead, give each request a +distinct input and check that its output is unchanged whether it runs alone or +concurrently amid other distinct requests: + + 1. Against an N=1 server, capture a per-input baseline: + bench_replica_dp.py --num-requests 8 --concurrency 1 \ + --write-baseline baseline.json + 2. Against an N>=2 server, replay the *same* inputs concurrently and verify + each output matches its own baseline: + bench_replica_dp.py --num-requests 8 --concurrency 8 \ + --check-baseline baseline.json + +A mismatch means a request's output changed under concurrency -- cross-request +state bleed or misrouting -- and the run exits non-zero. + +Requires: requests (`pip install requests`). +""" + +import argparse +import concurrent.futures as cf +import hashlib +import json +import math +import sys +import time +from pathlib import Path + +import requests + +# Distinct prompts, cycled across requests; combined with a per-request unique +# seed this makes every request a distinct input (see input_key()). +DEFAULT_PROMPTS = [ + "A cat playing piano, cinematic, high detail", + "A drone shot over a snowy mountain range at sunrise", + "A red sports car driving through a neon-lit city at night", + "Ocean waves crashing on a rocky shore, slow motion", + "A hot air balloon floating over green rolling hills", + "Timelapse of clouds moving over a desert canyon at dusk", + "A steam train crossing a stone viaduct in autumn", + "A jellyfish drifting through deep blue water, backlit", +] + + +def input_key(prompt, seed, args): + """Stable identity of a request's input; matches baseline<->verify by content, not order.""" + return "\x1f".join([prompt, str(seed), args.size, str(args.num_frames), str(args.steps)]) + + +def nearest_rank(xs, q): + """Nearest-rank percentile; q in [0, 1]. Correct for tiny samples (unlike int(q*n)-1).""" + if not xs: + return float("nan") + s = sorted(xs) + rank = max(1, math.ceil(q * len(s))) + return s[rank - 1] + + +def one_request(args, spec, idx, save): + """Fire one /v1/videos/sync request; return (ok, latency, nbytes, md5, key, err).""" + prompt, seed = spec["prompt"], spec["seed"] + key = input_key(prompt, seed, args) + # multipart form fields match POST /v1/videos/sync in the omni API server + form = { + "prompt": (None, prompt), + "size": (None, args.size), # e.g. "832x480" + "num_frames": (None, str(args.num_frames)), + "num_inference_steps": (None, str(args.steps)), + "seed": (None, str(seed)), # per-request unique -> distinct, reproducible input + } + if args.model: + form["model"] = (None, args.model) + if args.negative_prompt: + form["negative_prompt"] = (None, args.negative_prompt) + + t0 = time.perf_counter() + try: + r = requests.post(f"{args.url}/v1/videos/sync", files=form, timeout=args.timeout) + dt = time.perf_counter() - t0 + if r.status_code != 200: + return (False, dt, 0, "", key, f"HTTP {r.status_code}: {r.text[:160]}") + body = r.content + md5 = hashlib.md5(body).hexdigest() + if save and args.save_dir: + Path(args.save_dir).mkdir(parents=True, exist_ok=True) + (Path(args.save_dir) / f"req{idx:03d}_seed{seed}.mp4").write_bytes(body) + return (True, dt, len(body), md5, key, "") + except Exception as e: # noqa: BLE001 - report any client-side failure + return (False, time.perf_counter() - t0, 0, "", key, repr(e)) + + +def build_specs(args): + """Deterministic list of distinct (prompt, seed) inputs; identical across baseline/verify runs.""" + prompts = DEFAULT_PROMPTS + if args.prompts_file: + prompts = [ln.strip() for ln in Path(args.prompts_file).read_text().splitlines() if ln.strip()] + return [{"prompt": prompts[i % len(prompts)], "seed": args.base_seed + i} for i in range(args.num_requests)] + + +def run_batch(args, specs, save): + """Fire specs concurrently; return (results, makespan). results = list of one_request tuples.""" + results = [] + wall0 = time.perf_counter() + with cf.ThreadPoolExecutor(max_workers=args.concurrency) as ex: + futs = {ex.submit(one_request, args, spec, i, save): i for i, spec in enumerate(specs)} + for done, f in enumerate(cf.as_completed(futs), 1): + res = f.result() + results.append(res) + good, dt, nbytes, _md5, _key, err = res + print(f" [{done}/{len(specs)}] {'ok' if good else 'FAIL'} {dt:6.1f}s {(nbytes / 1e6):5.1f}MB {err}") + return results, time.perf_counter() - wall0 + + +def main(): + ap = argparse.ArgumentParser(description="Diffusion replica-DP throughput + isolation benchmark") + ap.add_argument("--url", default="http://127.0.0.1:8098", help="server base URL (no path)") + ap.add_argument("--num-requests", type=int, default=16, help="total requests to send") + ap.add_argument("--concurrency", type=int, default=4, help="max in-flight (set >= replica count)") + ap.add_argument("--warmup", type=int, default=-1, help="warmup requests before timing (default: =concurrency)") + ap.add_argument("--base-seed", type=int, default=42, help="request i uses seed base_seed+i (distinct inputs)") + ap.add_argument("--prompts-file", default="", help="optional file, one prompt per line (else a built-in pool)") + ap.add_argument("--negative-prompt", default="") + ap.add_argument("--size", default="832x480", help="WIDTHxHEIGHT") + ap.add_argument("--num-frames", type=int, default=33) + ap.add_argument("--steps", type=int, default=30, help="num_inference_steps") + ap.add_argument("--model", default="", help="optional; defaults to the served model") + ap.add_argument("--timeout", type=float, default=1800, help="per-request timeout (s)") + ap.add_argument("--save-dir", default="", help="save returned clips") + ap.add_argument("--label", default="", help="run label, e.g. 'replicas=2'") + ap.add_argument( + "--baseline-tpm", type=float, default=0.0, help="single-replica videos/min, to print a scaling factor" + ) + ap.add_argument("--write-baseline", default="", help="run against N=1: write per-input md5 manifest to this path") + ap.add_argument("--check-baseline", default="", help="run against N>=2: verify each output matches this manifest") + args = ap.parse_args() + + if args.write_baseline and args.check_baseline: + ap.error("--write-baseline and --check-baseline are mutually exclusive") + + specs = build_specs(args) + warmup = args.concurrency if args.warmup < 0 else args.warmup + + print( + f"-> {args.url}/v1/videos/sync | {args.num_requests} reqs " + f"| concurrency {args.concurrency} | warmup {warmup} | {args.size} {args.num_frames}f {args.steps}steps " + f"| {args.label}" + ) + + # Warmup: absorb each replica's first-call compile/init cost so it does not + # skew the measured makespan. Results are discarded and never saved. + if warmup > 0: + print(f"warming up ({warmup} reqs, not timed)...") + run_batch(args, [specs[i % len(specs)] for i in range(warmup)], save=False) + + results, makespan = run_batch(args, specs, save=True) + + ok = sum(1 for r in results if r[0]) + fail = len(results) - ok + lat = [r[1] for r in results if r[0]] + errs = [r[5] for r in results if not r[0]] + got = {r[4]: r[3] for r in results if r[0]} # input_key -> md5 + + print(f"\n===== result {args.label} =====") + print(f"completed {ok} / failed {fail} | makespan {makespan:.1f}s") + if ok: + tpm = ok / makespan * 60.0 + print(f"throughput: {tpm:.2f} videos/min ({ok / makespan:.4f} videos/s)") + print( + f"latency (s): p50={nearest_rank(lat, 0.50):.1f} " + f"p90={nearest_rank(lat, 0.90):.1f} " + f"min={min(lat):.1f} max={max(lat):.1f}" + ) + if args.baseline_tpm > 0: + print(f"scaling vs baseline ({args.baseline_tpm:.2f}/min): {tpm / args.baseline_tpm:.2f}x") + if errs: + print("sample failures:", errs[:3]) + + # Isolation check: compare each request's output against its own single-replica baseline. + isolation_failed = False + if args.write_baseline: + Path(args.write_baseline).write_text(json.dumps(got, indent=2)) + print(f"baseline: wrote {len(got)} per-input hashes -> {args.write_baseline}") + elif args.check_baseline: + baseline = json.loads(Path(args.check_baseline).read_text()) + matched = mismatched = unknown = 0 + for key, md5 in got.items(): + if key not in baseline: + unknown += 1 + print(f" isolation: NO BASELINE for input {key!r} (not in the baseline manifest)") + elif baseline[key] == md5: + matched += 1 + else: + mismatched += 1 + print(f" isolation: MISMATCH for input {key!r} (output changed under concurrency)") + # PASS requires the run to cover the *whole* baseline: a subset (fewer requests, or + # some failed) must not pass just because the inputs it did run happened to match. + unverified = sorted(set(baseline) - set(got)) + for key in unverified: + print(f" isolation: NOT VERIFIED for baseline input {key!r} (absent from this run)") + isolation_failed = mismatched > 0 or unknown > 0 or len(unverified) > 0 + verdict = "PASS (isolated)" if not isolation_failed else "FAIL" + print( + f"isolation: {matched} matched / {mismatched} mismatched / {unknown} unknown / " + f"{len(unverified)} unverified vs baseline({len(baseline)}) -> {verdict}" + ) + + if fail > 0 or ok == 0 or isolation_failed: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/online_serving/replica_data_parallel/run_server.sh b/examples/online_serving/replica_data_parallel/run_server.sh new file mode 100755 index 000000000..6b6f641fd --- /dev/null +++ b/examples/online_serving/replica_data_parallel/run_server.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Serve N independent Wan2.2 TI2V-5B replicas (replica data parallelism). +# +# Usage: +# NUM_REPLICAS=1 DEVICES=0 ./run_server.sh # baseline +# NUM_REPLICAS=2 DEVICES=0,1 ./run_server.sh +# NUM_REPLICAS=4 DEVICES=0,1,2,3 ./run_server.sh +# +# Then drive load with bench_replica_dp.py (see README.md) and compare +# throughput across replica counts. +set -euo pipefail + +MODEL="${MODEL:-Wan-AI/Wan2.2-TI2V-5B-Diffusers}" +HOST="${HOST:-127.0.0.1}" +PORT="${PORT:-8098}" +NUM_REPLICAS="${NUM_REPLICAS:-1}" +DEVICES="${DEVICES:-0}" # one GPU per replica for tensor_parallel_size=1 + +HERE="$(cd "$(dirname "$0")" && pwd)" +TEMPLATE="$HERE/wan2_2_ti2v_dp.yaml" +CFG="$(mktemp --suffix=_wan2_2_dp.yaml)" +trap 'rm -f "$CFG"' EXIT + +sed -e "s/__NUM_REPLICAS__/${NUM_REPLICAS}/" \ + -e "s/__DEVICES__/${DEVICES}/" \ + "$TEMPLATE" > "$CFG" + +echo "Serving ${MODEL}: num_replicas=${NUM_REPLICAS} devices=${DEVICES} (config ${CFG})" + +vllm serve "$MODEL" --omni \ + --host "$HOST" \ + --port "$PORT" \ + --deploy-config "$CFG" diff --git a/examples/online_serving/replica_data_parallel/wan2_2_ti2v_dp.yaml b/examples/online_serving/replica_data_parallel/wan2_2_ti2v_dp.yaml new file mode 100644 index 000000000..52cf99778 --- /dev/null +++ b/examples/online_serving/replica_data_parallel/wan2_2_ti2v_dp.yaml @@ -0,0 +1,27 @@ +# Replica data-parallel recipe for Wan2.2 TI2V-5B (video DiT). +# +# Fans out `num_replicas` independent DiffusionEngine replicas, one per GPU, +# and lets the serving layer route each request to a single replica. This +# scales request throughput near-linearly; it does not change single-request +# latency. See ./README.md for measured numbers and the `run_server.sh` driver. +# +# run_server.sh substitutes __NUM_REPLICAS__ / __DEVICES__ from env before serving. +# For tensor_parallel_size = 1, `devices` lists one GPU per replica (pool mode): +# num_replicas=4 -> devices "0,1,2,3". +# +# Stage topology (diffusion DiT, video output) comes from the registered +# WanPipeline, which auto-detects TI2V-5B mode from model_index.json; this file +# carries deployment knobs only. +trust_remote_code: true +distributed_executor_backend: mp + +stages: + - stage_id: 0 + num_replicas: __NUM_REPLICAS__ + devices: "__DEVICES__" + max_num_seqs: 1 + tensor_parallel_size: 1 + enforce_eager: true + vae_use_tiling: true + default_sampling_params: + seed: 42 diff --git a/examples/online_serving/step_audio2/README.md b/examples/online_serving/step_audio2/README.md index e93f58ccc..25e2a6785 100644 --- a/examples/online_serving/step_audio2/README.md +++ b/examples/online_serving/step_audio2/README.md @@ -10,15 +10,15 @@ Please refer to [README.md](../../../README.md) ```bash # Async chunk mode (recommended — lower first-packet latency for TTS) -vllm serve stepfun-ai/Step-Audio2-mini --omni --port 8092 \ - --stage-configs-path vllm_omni/model_executor/stage_configs/step_audio_2_async_chunk.yaml \ +vllm serve stepfun-ai/Step-Audio-2-mini --omni --port 8092 \ + --deploy-config vllm_omni/deploy/step_audio_2_async_chunk.yaml \ --trust-remote-code --enforce-eager ``` Sequential mode: ```bash -vllm serve stepfun-ai/Step-Audio2-mini --omni --port 8092 \ - --stage-configs-path vllm_omni/model_executor/stage_configs/step_audio_2.yaml \ +vllm serve stepfun-ai/Step-Audio-2-mini --omni --port 8092 \ + --deploy-config vllm_omni/deploy/step_audio_2.yaml \ --trust-remote-code --enforce-eager ``` @@ -51,7 +51,7 @@ Or via curl: ```bash curl -X POST http://localhost:8092/v1/audio/speech \ -H "Content-Type: application/json" \ - -d '{"model":"stepfun-ai/Step-Audio2-mini","input":"你好世界","voice":"default"}' \ + -d '{"model":"stepfun-ai/Step-Audio-2-mini","input":"你好世界","voice":"default"}' \ --output output.wav ``` @@ -139,7 +139,7 @@ Step-Audio2 uses the OpenAI-compatible chat completions API: ```json { - "model": "stepfun-ai/Step-Audio2-mini", + "model": "stepfun-ai/Step-Audio-2-mini", "messages": [ { "role": "system", diff --git a/examples/online_serving/step_audio2/openai_chat_completion_client.py b/examples/online_serving/step_audio2/openai_chat_completion_client.py index 2b91dacb2..94acb5533 100644 --- a/examples/online_serving/step_audio2/openai_chat_completion_client.py +++ b/examples/online_serving/step_audio2/openai_chat_completion_client.py @@ -9,7 +9,7 @@ Step-Audio2 is a two-stage model: Usage examples: # Start server first: - vllm serve stepfun-ai/Step-Audio2-mini --omni --port 8092 + vllm serve stepfun-ai/Step-Audio-2-mini --omni --port 8092 # Audio to text (ASR) python openai_chat_completion_client.py --query-type audio_to_text @@ -311,7 +311,7 @@ def parse_args(): "--model", "-m", type=str, - default="stepfun-ai/Step-Audio2-mini", + default="stepfun-ai/Step-Audio-2-mini", help="Model name", ) parser.add_argument( diff --git a/examples/online_serving/step_audio2/openai_speech_client.py b/examples/online_serving/step_audio2/openai_speech_client.py index 740b36805..762ca72a6 100644 --- a/examples/online_serving/step_audio2/openai_speech_client.py +++ b/examples/online_serving/step_audio2/openai_speech_client.py @@ -87,7 +87,7 @@ def parse_args(): "--model", "-m", type=str, - default="stepfun-ai/Step-Audio2-mini", + default="stepfun-ai/Step-Audio-2-mini", help="Model name/path", ) parser.add_argument( diff --git a/examples/online_serving/step_audio2/run_curl.sh b/examples/online_serving/step_audio2/run_curl.sh index 3811308e7..3a3b1c9f4 100755 --- a/examples/online_serving/step_audio2/run_curl.sh +++ b/examples/online_serving/step_audio2/run_curl.sh @@ -110,7 +110,7 @@ output=$(curl -sS -X POST "${API_BASE}/v1/chat/completions" \ -H "Content-Type: application/json" \ -d @- < None: if value is None: return - if isinstance(value, str) and not value.strip(): - return - payload[key] = value + if isinstance(value, str): + payload[key] = value.strip() + else: + payload[key] = value def _optional_int(value: int | float | None) -> int | None: @@ -43,11 +77,83 @@ def _optional_float(value: int | float | None) -> float | None: return None if value is None else float(value) +def _normalize_prompt_update_row(row: list[Any] | tuple[Any, ...]) -> tuple[Any, Any, Any]: + values = [] + for v in row[: len(PROMPT_UPDATE_HEADERS)]: + if isinstance(v, str): + values.append(v.strip() or None) + elif isinstance(v, float) and math.isnan(v): + values.append(None) + else: + values.append(v) + while len(values) < len(PROMPT_UPDATE_HEADERS): + values.append(None) + return values[0], values[1], values[2] # pyright: ignore[reportIndexIssue] + + +def _prompt_update_row_is_empty(row: list[Any] | tuple[Any, ...]) -> bool: + at, prompt, transition = _normalize_prompt_update_row(row) + return at is None and prompt is None and transition is None + + +def _parse_prompt_update_row(row: list[Any] | tuple[Any, ...], *, index: int) -> ScheduledPromptUpdate: + at_val, prompt_val, transition_val = _normalize_prompt_update_row(row) + row_label = f"Prompt update row {index + 1}" + + if at_val is None or prompt_val is None: + raise gr.Error(f"{row_label} requires both At (s) and Prompt.") + + try: + at = float(at_val) + except (TypeError, ValueError) as exc: + raise gr.Error(f"{row_label}: At (s) must be a number.") from exc + if at < 0: + raise gr.Error(f"{row_label}: At (s) must be >= 0.") + + prompt = str(prompt_val).strip() + if not prompt: + raise gr.Error(f"{row_label}: Prompt must be a non-empty string.") + + transition_chunks = DEFAULT_TRANSITION_CHUNKS + if transition_val is not None: + try: + transition_chunks = int(transition_val) + except (TypeError, ValueError) as exc: + raise gr.Error(f"{row_label}: Transition Chunks must be an integer.") from exc + if transition_chunks < 0: + raise gr.Error(f"{row_label}: Transition Chunks must be >= 0.") + + return { + "at": at, + "prompt": prompt, + "transition_chunks": transition_chunks, + } + + +def _parse_prompt_updates_dataframe(rows: list[list[Any]] | None) -> list[ScheduledPromptUpdate]: + if not rows: + return [] + + # Single row: empty or complete. + if len(rows) == 1: + row = rows[0] + if _prompt_update_row_is_empty(row): + return [] + return [_parse_prompt_update_row(row, index=0)] + + updates: list[ScheduledPromptUpdate] = [] + for index, row in enumerate(rows): + if _prompt_update_row_is_empty(row): + raise gr.Error(f"Prompt update row {index + 1} cannot be empty when multiple rows are present.") + updates.append(_parse_prompt_update_row(row, index=index)) + return sorted(updates, key=lambda update: update["at"]) + + def _build_session_start( *, model: str, prompt: str, - negative_prompt: str, + negative_prompt: str | None, width: int | float | None, height: int | float | None, fps: int | float | None, @@ -101,6 +207,7 @@ def build_browser_stream_config( model: str, prompt: str, negative_prompt: str, + include_negative_prompt: bool, width: int | float | None, height: int | float | None, fps: int | float, @@ -118,6 +225,7 @@ def build_browser_stream_config( flow_shift: int | float | None, include_true_cfg_scale: bool, true_cfg_scale: int | float | None, + prompt_updates_df: list[list[Any]] | None, ) -> tuple[str, Any]: """Build the browser-side WebSocket request config for the HTML/MSE player.""" if not model.strip(): @@ -128,7 +236,7 @@ def build_browser_stream_config( payload = _build_session_start( model=model.strip(), prompt=prompt.strip(), - negative_prompt=negative_prompt, + negative_prompt=negative_prompt if include_negative_prompt else None, width=width, height=height, fps=fps, @@ -141,15 +249,21 @@ def build_browser_stream_config( true_cfg_scale=true_cfg_scale if include_true_cfg_scale else None, seed=seed if include_seed else None, ) + prompt_updates = _parse_prompt_updates_dataframe(prompt_updates_df) config = { "url": f"ws://{host}:{int(port)}/v1/realtime/video", "payload": payload, } + if prompt_updates: + config["prompt_updates"] = prompt_updates return json.dumps(config, ensure_ascii=False), gr.update(value="Streaming...", interactive=False) def create_demo() -> gr.Blocks: - with gr.Blocks(title="Streaming Video Generation") as demo: + with gr.Blocks( + title="Streaming Video Generation", + css="textarea:disabled, input:disabled { color: var(--body-text-color-subdued); -webkit-text-fill-color: var(--body-text-color-subdued); background-color: var(--background-fill-secondary); }", + ) as demo: gr.Markdown("# Streaming Video Generation") gr.Markdown( "Connects to `WS /v1/realtime/video` in the browser and appends fMP4 chunks " @@ -162,7 +276,6 @@ def create_demo() -> gr.Blocks: port = gr.Number(label="Server Port", value=8000, precision=0) model = gr.Textbox(label="Model", value="BestWishYsh/Helios-Distilled") prompt = gr.Textbox(label="Prompt", value=DEFAULT_PROMPT, lines=3) - negative_prompt = gr.Textbox(label="Negative Prompt", value="", lines=2) with gr.Row(): width = gr.Number(label="Width", value=640, precision=0) @@ -170,49 +283,66 @@ def create_demo() -> gr.Blocks: with gr.Row(): fps = gr.Number(label="FPS", value=16, precision=0) - num_frames = gr.Number(label="Num Frames", value=132, precision=0) + num_frames = gr.Number(label="Num Frames", value=429, precision=0) num_inference_steps = gr.Number(label="Num Inference Steps", value=50, precision=0) - with gr.Row(): - with gr.Column(): - guidance_scale = gr.Number( - label="Guidance Scale", - value=1.0, - interactive=True, - scale=4, - ) - include_guidance_scale = gr.Checkbox(label="Send", value=True, scale=1, min_width=70) - with gr.Column(): - guidance_scale_2 = gr.Number( - label="Guidance Scale 2", - value=None, - interactive=False, - scale=4, - ) - include_guidance_scale_2 = gr.Checkbox(label="Send", value=False, scale=1, min_width=70) - with gr.Column(): - true_cfg_scale = gr.Number( - label="True CFG Scale", - value=None, - interactive=False, - scale=4, - ) - include_true_cfg_scale = gr.Checkbox(label="Send", value=False, scale=1, min_width=70) - # I want it to wrap after 3, but it auto wrap after 2. I'll rather fully delegate to auto wrapping. - with gr.Column(): - boundary_ratio = gr.Number( - label="Boundary Ratio", - value=None, - interactive=False, - scale=4, - ) - include_boundary_ratio = gr.Checkbox(label="Send", value=False, scale=1, min_width=70) - with gr.Column(): - flow_shift = gr.Number(label="Flow Shift", value=None, interactive=False, scale=4) - include_flow_shift = gr.Checkbox(label="Send", value=False, scale=1, min_width=70) - with gr.Column(): - seed = gr.Number(label="Seed", value=42, precision=0, interactive=True, scale=4) - include_seed = gr.Checkbox(label="Send", value=True, scale=1, min_width=70) + with gr.Accordion("Advanced Sampling", open=False): + with gr.Row(): + with gr.Column(): + negative_prompt = gr.Textbox(label="Negative Prompt", value="", lines=1, interactive=False) + include_negative_prompt = gr.Checkbox(label="Send", value=False) + with gr.Row(): + with gr.Column(scale=0, min_width=150): + guidance_scale = gr.Number( + label="Guidance Scale", + value=1.0, + interactive=True, + ) + include_guidance_scale = gr.Checkbox(label="Send", value=True) + with gr.Column(scale=0, min_width=150): + guidance_scale_2 = gr.Number( + label="Guidance Scale 2", + value=None, + interactive=False, + ) + include_guidance_scale_2 = gr.Checkbox(label="Send", value=False) + with gr.Column(scale=0, min_width=150): + true_cfg_scale = gr.Number( + label="True CFG Scale", + value=None, + interactive=False, + ) + include_true_cfg_scale = gr.Checkbox(label="Send", value=False) + with gr.Column(scale=0, min_width=150): + boundary_ratio = gr.Number( + label="Boundary Ratio", + value=None, + interactive=False, + ) + include_boundary_ratio = gr.Checkbox(label="Send", value=False) + with gr.Column(scale=0, min_width=150): + flow_shift = gr.Number(label="Flow Shift", value=None, interactive=False) + include_flow_shift = gr.Checkbox(label="Send", value=False) + with gr.Column(scale=0, min_width=150): + seed = gr.Number(label="Seed", value=42, precision=0, interactive=True) + include_seed = gr.Checkbox(label="Send", value=True) + + with gr.Accordion("Prompt Updates", open=True): + gr.Markdown( + "Schedule midway text `session.interaction` messages after `video.start`. " + "With one row, leave it fully empty or fill both **At (s)** and **Prompt**. " + "With multiple rows, every row must be complete." + ) + prompt_updates_df = gr.Dataframe( + headers=PROMPT_UPDATE_HEADERS, + datatype=["number", "str", "number"], + type="array", + row_count=1, + row_limits=(1, None), + column_count=3, + value=PROMPT_UPDATE_VALUES, + interactive=True, + ) with gr.Row(): start_button = gr.Button("Start", variant="primary", elem_id="streaming-video-start") @@ -231,6 +361,7 @@ def create_demo() -> gr.Blocks: model, prompt, negative_prompt, + include_negative_prompt, width, height, fps, @@ -248,8 +379,10 @@ def create_demo() -> gr.Blocks: flow_shift, include_true_cfg_scale, true_cfg_scale, + prompt_updates_df, ] for toggle, field in [ + (include_negative_prompt, negative_prompt), (include_guidance_scale, guidance_scale), (include_guidance_scale_2, guidance_scale_2), (include_seed, seed), diff --git a/examples/online_serving/streaming_video_generation/streaming_video_client.py b/examples/online_serving/streaming_video_generation/streaming_video_client.py index c77113420..e8dcd8cdd 100644 --- a/examples/online_serving/streaming_video_generation/streaming_video_client.py +++ b/examples/online_serving/streaming_video_generation/streaming_video_client.py @@ -13,10 +13,12 @@ Requirements: import argparse import asyncio +import contextlib import io import json import os import time +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -39,17 +41,69 @@ HELIOS_DISTILLED_EXTRA_PARAMS = { "is_amplify_first_chunk": True, } +DEFAULT_TRANSITION_CHUNKS = 3 -def _json_object(value: str, *, argument_name: str) -> dict[str, Any]: + +@dataclass(frozen=True) +class ScheduledPromptUpdate: + """Client-side schedule entry for a midway text ``session.interaction``.""" + + at: float + prompt: str + transition_chunks: int + + +def _parse_extra_params(value: str) -> dict[str, Any]: try: parsed = json.loads(value) except json.JSONDecodeError as exc: - raise argparse.ArgumentTypeError(f"{argument_name} must be valid JSON: {exc}") from exc + raise argparse.ArgumentTypeError(f"--extra-params must be valid JSON: {exc}") from exc if not isinstance(parsed, dict): - raise argparse.ArgumentTypeError(f"{argument_name} must be a JSON object") + raise argparse.ArgumentTypeError("--extra-params must be a JSON object") return parsed +def _parse_prompt_updates(value: str) -> list[ScheduledPromptUpdate]: + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise argparse.ArgumentTypeError(f"--prompt-updates must be valid JSON: {exc}") from exc + if not isinstance(parsed, list): + raise argparse.ArgumentTypeError("--prompt-updates must be a JSON array") + + updates: list[ScheduledPromptUpdate] = [] + for index, item in enumerate(parsed): + try: + at = float(item["at"]) + prompt = item["prompt"] + except (KeyError, TypeError, ValueError) as exc: + raise argparse.ArgumentTypeError( + f"--prompt-updates[{index}] must have valid 'at' and 'prompt' fields" + ) from exc + if at < 0: + raise argparse.ArgumentTypeError(f"--prompt-updates[{index}].at must be >= 0") + if not isinstance(prompt, str) or not prompt.strip(): + raise argparse.ArgumentTypeError(f"--prompt-updates[{index}].prompt must be a non-empty string") + + try: + transition_chunks = item.get("transition_chunks", DEFAULT_TRANSITION_CHUNKS) + transition_chunks = int(transition_chunks) + except (TypeError, ValueError) as exc: + raise argparse.ArgumentTypeError(f"--prompt-updates[{index}].transition_chunks must be an integer") from exc + if transition_chunks < 0: + raise argparse.ArgumentTypeError(f"--prompt-updates[{index}].transition_chunks must be >= 0") + + updates.append( + ScheduledPromptUpdate( + at=at, + prompt=prompt, + transition_chunks=transition_chunks, + ) + ) + + return sorted(updates, key=lambda update: update.at) + + def _maybe_set(payload: dict[str, Any], key: str, value: Any) -> None: if value is not None: payload[key] = value @@ -88,6 +142,31 @@ def build_session_start(args: argparse.Namespace) -> dict[str, Any]: return payload +async def _run_prompt_update_scheduler( + websocket: Any, + updates: list[ScheduledPromptUpdate], + *, + video_started_at: float, + send_lock: asyncio.Lock, +) -> None: + for update in updates: + delay = (video_started_at + update.at) - time.perf_counter() + if delay > 0: + await asyncio.sleep(delay) + + payload = { + "type": "session.interaction", + "interaction": { + "event_id": f"scheduled-{update.at:.3f}", + "event": {"prompt": update.prompt}, + "transition_chunks": update.transition_chunks, + }, + } + async with send_lock: + await websocket.send(json.dumps(payload, ensure_ascii=False)) + print(f"Sent session.interaction at t={update.at:.2f}s: {json.dumps(payload, ensure_ascii=False)}") + + def _count_decoded_frames(video_bytes: bytes, *, stream_format: str) -> int | None: """Return the number of decodable video frames in a streamed byte sequence, if known.""" if not video_bytes: @@ -144,11 +223,15 @@ async def stream_video(args: argparse.Namespace) -> None: stream_format = "m4s" done = False ws = None + send_lock = asyncio.Lock() + prompt_update_task: asyncio.Task[None] | None = None + pending_chunk_metadata: dict[str, Any] | None = None try: async with connect(url, max_size=None) as websocket: ws = websocket - await websocket.send(json.dumps(payload)) + async with send_lock: + await websocket.send(json.dumps(payload)) print(f"Connected: {url}") print(f"Sent session.start: {json.dumps(payload, ensure_ascii=False)}") started_at = time.perf_counter() @@ -159,6 +242,9 @@ async def stream_video(args: argparse.Namespace) -> None: now = time.perf_counter() if isinstance(message, bytes): + # Use chunk metadata just received before video chunks + metadata = pending_chunk_metadata or {} + pending_chunk_metadata = None chunks.append(message) chunk_bytes = len(message) chunk_elapsed = now - last_chunk_at @@ -176,8 +262,11 @@ async def stream_video(args: argparse.Namespace) -> None: total_elapsed = now - started_at print( f"[chunk {len(chunks):03d}] " + f"kind={metadata.get('kind', 'media')} " f"bytes={chunk_bytes} " - f"frames={chunk_frames} " + f"frames={metadata.get('num_frames', chunk_frames)} " + f"generation_chunk={metadata.get('generation_chunk_index')} " + f"active_interactions={metadata.get('active_event_ids', [])} " f"elapsed={chunk_elapsed:.2f}s " f"total_bytes={total_bytes} " f"total_frames={total_frames} " @@ -190,9 +279,26 @@ async def stream_video(args: argparse.Namespace) -> None: if msg_type == "video.start": stream_format = msg.get("format") or stream_format print(f"Video session started: request_id={msg.get('request_id')} format={msg.get('format')}") + if args.prompt_updates and prompt_update_task is None: + video_started_at = time.perf_counter() + prompt_update_task = asyncio.create_task( + _run_prompt_update_scheduler( + websocket, + args.prompt_updates, + video_started_at=video_started_at, + send_lock=send_lock, + ) + ) + elif msg_type == "video.chunk_metadata": + # Save the metadata for the incoming video chunk + pending_chunk_metadata = msg + elif msg_type == "session.interaction.queued": + print(f"Interaction queued by server: event_id={msg.get('event_id')}") elif msg_type == "session.done": print(f"Session complete: {json.dumps(msg, ensure_ascii=False)}") done = True + if prompt_update_task is not None: + prompt_update_task.cancel() break elif msg_type == "error": print(f"ERROR: {msg.get('message', msg)}") @@ -202,12 +308,17 @@ async def stream_video(args: argparse.Namespace) -> None: print("\nInterrupted. Asking server to stop the active session...") if ws is not None: try: - await ws.send(json.dumps({"type": "session.stop"})) + async with send_lock: + await ws.send(json.dumps({"type": "session.stop"})) await ws.close(code=1000, reason="client interrupted") except Exception: pass raise finally: + if prompt_update_task is not None: + prompt_update_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await prompt_update_task print( "Saving video... (May take a while. Remuxing concatenated chunks into one progressive MP4 file with total duration metadata in file header)" ) @@ -232,7 +343,15 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( "--prompt", - default="A vibrant tropical fish swimming gracefully among colorful coral reefs in a clear, turquoise ocean. The fish has bright blue and yellow scales with a small, distinctive orange spot on its side, its fins moving fluidly. The coral reefs are alive with a variety of marine life, including small schools of colorful fish and sea turtles gliding by. The water is crystal clear, allowing for a view of the sandy ocean floor below. The reef itself is adorned with a mix of hard and soft corals in shades of red, orange, and green. The photo captures the fish from a slightly elevated angle, emphasizing its lively movements and the vivid colors of its surroundings. A close-up shot with dynamic movement.", + default=( + "A vibrant tropical fish swimming gracefully among colorful coral reefs in a clear, turquoise ocean. " + "The fish has bright blue and yellow scales with a small, distinctive orange spot on its side, its fins moving fluidly. " + "The coral reefs are alive with a variety of marine life, including small schools of colorful fish and sea turtles gliding by. " + "The water is crystal clear, allowing for a view of the sandy ocean floor below. " + "The reef itself is adorned with a mix of hard and soft corals in shades of red, orange, and green. " + "The photo captures the fish from a slightly elevated angle, emphasizing its lively movements and the vivid colors of its surroundings. " + "A close-up shot with dynamic movement." + ), help="Text prompt for video generation", ) parser.add_argument("--output", default="streaming_video_output.mp4", help="Path for the received video bytes") @@ -241,7 +360,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--size", default=None, help="Video size as WIDTHxHEIGHT; alternative to width/height") parser.add_argument("--seconds", default=None, help="Clip duration in seconds") parser.add_argument("--fps", type=int, default=16, help="Frames per second") - parser.add_argument("--num-frames", type=int, default=99, help="Number of generated frames") + parser.add_argument("--num-frames", type=int, default=429, help="Number of generated frames") parser.add_argument("--negative-prompt", default=None, help="Negative prompt") parser.add_argument("--num-inference-steps", type=int, default=None, help="Number of diffusion steps") @@ -260,10 +379,44 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( "--extra-params", - type=lambda value: _json_object(value, argument_name="--extra-params"), + type=_parse_extra_params, default=None, help="JSON object merged into extra_params; overrides preset keys on conflict.", ) + parser.add_argument( + "--prompt-updates", + type=_parse_prompt_updates, + default=json.dumps( + [ + { + "at": 4, + "prompt": ( + "An underwater tornado appears and affects the ocean floor in a dramatic and chaotic scene. " + "The water is murky, swirling violently, carrying debris and marine life into the vortex. " + "The tropical fish on the scene all swim in panic, trying to avoid the powerful currents. " + "The camera remains stationary, capturing the intensity of the underwater tornado as it disrupts the serene ocean floor. " + "Close-up shot emphasizing the turbulent motion and destruction." + ), + }, + { + "at": 11, + "prompt": ( + "The swirling underwater vortex now seizes a heavy, encrusted treasure chest, its lid flapping open as it is smashed onto the ocean floor. " + "Gold coins and silver trinkets spill out, glittering briefly in the murky water before being swept instantly into the violent funnel. " + "The heavy wooden box tumbles end over end, colliding with floating rocks and adding to the debris field. " + "Swirling sediment and bubbles surround the spilling fortune, highlighting the chaotic power of the storm as it ravages the seabed. " + "Close-up shot emphasizing the turbulent motion and destruction." + ), + # "transition_chunks": 3, + }, + ] + ), + help=( + "JSON array of scheduled prompt updates. Each object requires " + "'at' (seconds after video.start) and 'prompt'. Optional " + f"'transition_chunks' defaults to {DEFAULT_TRANSITION_CHUNKS}." + ), + ) return parser.parse_args() diff --git a/examples/online_serving/streaming_video_generation/video-stream-view.js b/examples/online_serving/streaming_video_generation/video-stream-view.js index 1b0a5024b..46206c0d0 100644 --- a/examples/online_serving/streaming_video_generation/video-stream-view.js +++ b/examples/online_serving/streaming_video_generation/video-stream-view.js @@ -14,6 +14,7 @@ let totalBytes = 0; let done = false; let objectUrl = null; + let promptUpdateTimers = []; function el(id) { return document.getElementById(id); @@ -73,7 +74,50 @@ } catch (_) { } } + function clearPromptUpdateTimers() { + for (const timer of promptUpdateTimers) { + clearTimeout(timer); + } + promptUpdateTimers = []; + } + + function schedulePromptUpdates(updates) { + clearPromptUpdateTimers(); + if (!Array.isArray(updates) || updates.length === 0) return; + + const videoStartedAt = performance.now(); + for (const update of updates) { + const atSeconds = Number(update.at); + if (!Number.isFinite(atSeconds) || atSeconds < 0) continue; + + const delayMs = Math.max(0, atSeconds * 1000 - (performance.now() - videoStartedAt)); + const transitionChunks = Number.isFinite(Number(update.transition_chunks)) + ? Number(update.transition_chunks) + : 3; + + const timer = setTimeout(() => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const payload = { + type: "session.interaction", + interaction: { + event_id: "scheduled-" + atSeconds.toFixed(3), + event: { prompt: update.prompt }, + transition_chunks: transitionChunks, + }, + }; + try { + ws.send(JSON.stringify(payload)); + log("Sent session.interaction at t=" + atSeconds.toFixed(2) + "s: " + JSON.stringify(payload)); + } catch (err) { + log("Failed to send session.interaction: " + err.message); + } + }, delayMs); + promptUpdateTimers.push(timer); + } + } + function stopCurrent() { + clearPromptUpdateTimers(); if (ws && ws.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify({ type: "session.stop" })); } catch (_) { } try { ws.close(); } catch (_) { } @@ -148,6 +192,11 @@ const msg = JSON.parse(event.data); if (msg.type === "video.start") { log("Video session started: request_id=" + (msg.request_id || "") + " format=" + (msg.format || "")); + schedulePromptUpdates(config.prompt_updates); + } else if (msg.type === "video.chunk_metadata") { + log("Chunk metadata: " + JSON.stringify(msg)); + } else if (msg.type === "session.interaction.queued") { + log("Interaction queued by server: event_id=" + (msg.event_id || "")); } else if (msg.type === "session.done") { done = true; setStatus("Done"); diff --git a/examples/online_serving/text_to_speech/README.md b/examples/online_serving/text_to_speech/README.md index 201cb5d78..b331966b3 100644 --- a/examples/online_serving/text_to_speech/README.md +++ b/examples/online_serving/text_to_speech/README.md @@ -336,7 +336,7 @@ vllm serve OpenMOSS-Team/MOSS-TTS-Nano --omni --port 8091 # or: ./moss_tts_nano/run_server.sh ``` -The deploy config at `vllm_omni/deploy/moss_tts_nano.yaml` auto-loads; no `--stage-configs-path`, `--trust-remote-code`, or `--enforce-eager` flags are needed. +The deploy config at `vllm_omni/deploy/moss_tts_nano.yaml` auto-loads; no `--deploy-config`, `--trust-remote-code`, or `--enforce-eager` flags are needed. ### Sending requests ```bash @@ -537,12 +537,19 @@ curl -X POST http://localhost:8091/v1/audio/speech \ Raw PCM streaming requires `stream_format="audio"`, `response_format="pcm"`, and `async_chunk: true` on the stage config (default in `qwen3_tts.yaml`). `speed` is not supported when streaming. ### Streaming WebSocket -The `/v1/audio/speech/stream` endpoint accepts text incrementally, splits it at sentence boundaries, and emits one PCM stream per sentence: +The `/v1/audio/speech/stream` endpoint accepts text incrementally and synthesizes the buffered text as one continuous request on `input.done`: ```bash python qwen3_tts/streaming_speech_client.py --text "Hello world. How are you? I am fine." python qwen3_tts/streaming_speech_client.py --text "..." --simulate-stt --stt-delay 0.1 ``` +`input.done` flushes without closing, so repeating `--text` synthesizes several utterances over one connection: +```bash +python qwen3_tts/streaming_speech_client.py \ + --text "First utterance." \ + --text "Second utterance, same connection." +``` + To receive word-level timestamps, launch the server with a forced aligner: ```bash vllm-omni serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \ diff --git a/examples/online_serving/text_to_speech/qwen3_tts/streaming_speech_client.py b/examples/online_serving/text_to_speech/qwen3_tts/streaming_speech_client.py index 92a40739e..e141a4c48 100644 --- a/examples/online_serving/text_to_speech/qwen3_tts/streaming_speech_client.py +++ b/examples/online_serving/text_to_speech/qwen3_tts/streaming_speech_client.py @@ -7,6 +7,12 @@ Usage: # Send full text at once python streaming_speech_client.py --text "Hello world. How are you? I am fine." + # Several utterances over one connection: input.done flushes each of them + # and the connection stays open, so only the first pays the handshake + python streaming_speech_client.py \ + --text "First utterance." \ + --text "Second utterance, same connection." + # Pick a built-in speaker for CustomVoice models python streaming_speech_client.py \ --text "打开电子枪,关闭扫描,切换到低倍模式。" \ @@ -64,18 +70,27 @@ except ImportError: raise SystemExit(1) -def save_audio_file(output_dir: str, sentence_index: int, response_format: str, chunks: list[bytes]) -> str: +def frame_basename(msg: dict) -> str: + """Name a file after the utterance and sentence a frame belongs to. + + Every utterance is sentence 0, so the utterance index is what keeps the + files of one connection from overwriting each other. + """ + return f"utterance_{msg['utterance_index']:03d}_sentence_{msg['sentence_index']:03d}" + + +def save_audio_file(output_dir: str, basename: str, response_format: str, chunks: list[bytes]) -> str: audio_bytes = b"".join(chunks) if response_format == "pcm": - raw_path = os.path.join(output_dir, f"sentence_{sentence_index:03d}.pcm") + raw_path = os.path.join(output_dir, f"{basename}.pcm") with open(raw_path, "wb") as f: f.write(audio_bytes) - wav_path = os.path.join(output_dir, f"sentence_{sentence_index:03d}.wav") + wav_path = os.path.join(output_dir, f"{basename}.wav") save_pcm_wav(wav_path, audio_bytes) return wav_path - filename = os.path.join(output_dir, f"sentence_{sentence_index:03d}.{response_format}") + filename = os.path.join(output_dir, f"{basename}.{response_format}") with open(filename, "wb") as f: f.write(audio_bytes) return filename @@ -95,183 +110,207 @@ def timestamp_words_text(timestamps: list[dict] | None) -> str: return " ".join(str(item.get("word", "")) for item in timestamps if item.get("word")) +async def send_utterance(ws, text: str, simulate_stt: bool, stt_delay: float) -> None: + """Send one utterance's text, then input.done to flush it.""" + if simulate_stt: + words = text.split(" ") + for i, word in enumerate(words): + chunk = word + (" " if i < len(words) - 1 else "") + await ws.send( + json.dumps( + { + "type": "input.text", + "text": chunk, + } + ) + ) + print(f" Sent: {chunk!r}") + await asyncio.sleep(stt_delay) + else: + await ws.send( + json.dumps( + { + "type": "input.text", + "text": text, + } + ) + ) + print(f"Sent full text: {text!r}") + + await ws.send(json.dumps({"type": "input.done"})) + print("Sent input.done") + + +async def receive_utterance( + ws, + output_dir: str, + response_format: str, + word_timestamps: bool, + save_chunks: bool, +) -> None: + """Consume frames until session.done marks the flushed utterance complete.""" + current_label = "utterance 0" + current_sentence_text = "" + current_chunks: list[bytes] = [] + current_timestamps: list[dict] = [] + # Distinguish `null` (aligner failed) from `[]` (silence / no tokens): + # the sentence is a failure only if no frame ever carried timestamps. + alignment_frame_seen = False + + while True: + message = await ws.recv() + + if isinstance(message, bytes): + current_chunks.append(message) + print(f" Received audio chunk for {current_label}: {len(message)} bytes") + else: + # JSON frame + msg = json.loads(message) + msg_type = msg.get("type") + + if msg_type == "audio.start": + current_label = f"utterance {msg['utterance_index']}" + current_sentence_text = msg.get("sentence_text", "") + current_chunks = [] + current_timestamps = [] + alignment_frame_seen = False + print(f" [{current_label}] Generating: {current_sentence_text!r}") + elif msg_type == "audio.chunk": + audio = base64.b64decode(msg["audio_b64"]) + current_chunks.append(audio) + chunk_timestamps = msg.get("timestamps") + # Sentence-level alignment sends a trailing timestamp-only + # frame with empty audio; don't write an empty chunk file. + if save_chunks and audio: + chunk_base = os.path.join( + output_dir, + f"{frame_basename(msg)}_chunk_{msg['chunk_id']:03d}", + ) + chunk_pcm = f"{chunk_base}.pcm" + chunk_wav = f"{chunk_base}.wav" + chunk_json = f"{chunk_base}_timestamps.json" + with open(chunk_pcm, "wb") as f: + f.write(audio) + save_pcm_wav(chunk_wav, audio, int(msg.get("sample_rate", 24000))) + with open(chunk_json, "w", encoding="utf-8") as f: + json.dump( + { + "utterance_index": msg["utterance_index"], + "sentence_index": msg["sentence_index"], + "chunk_id": msg["chunk_id"], + "chunk_start_ms": msg.get("chunk_start_ms"), + "chunk_end_ms": msg.get("chunk_end_ms"), + "sentence_text": current_sentence_text, + "covered_text": timestamp_words_text(chunk_timestamps), + "timestamps": chunk_timestamps, + }, + f, + ensure_ascii=False, + indent=2, + ) + if chunk_timestamps is None: + print(f" [{current_label}] chunk {msg['chunk_id']}: {len(audio)} bytes, timestamps unavailable") + else: + alignment_frame_seen = True + current_timestamps.extend(chunk_timestamps) + print( + f" [{current_label}] chunk {msg['chunk_id']}: " + f"{len(audio)} bytes, {len(chunk_timestamps)} timestamp(s)" + ) + if save_chunks and audio: + print(f" saved chunk -> {chunk_wav}") + elif msg_type == "audio.done": + filename = save_audio_file( + output_dir, + frame_basename(msg), + response_format, + current_chunks, + ) + if word_timestamps: + # `null` on failure, else the accumulated list (`[]` for silence). + sentence_timestamps = current_timestamps if alignment_frame_seen else None + ts_filename = os.path.join( + output_dir, + f"{frame_basename(msg)}_timestamps.json", + ) + with open(ts_filename, "w", encoding="utf-8") as f: + json.dump( + { + "utterance_index": msg["utterance_index"], + "sentence_index": msg["sentence_index"], + "sentence_text": current_sentence_text, + "covered_text": timestamp_words_text(sentence_timestamps), + "timestamps": sentence_timestamps, + }, + f, + ensure_ascii=False, + indent=2, + ) + print( + f" [{current_label}] Done" + f" bytes={msg.get('total_bytes', len(b''.join(current_chunks)))}" + f" error={msg.get('error', False)}" + f" -> {filename}" + ) + if word_timestamps: + print(f" [{current_label}] Timestamps -> {ts_filename}") + current_chunks = [] + current_timestamps = [] + alignment_frame_seen = False + elif msg_type == "session.done": + print(f"\nUtterance {msg['utterance_index']} complete: {msg['total_sentences']} sentence(s) generated") + return + elif msg_type == "error": + print(f" ERROR: {msg['message']}") + else: + print(f" Unknown message: {msg}") + + async def stream_tts( url: str, - text: str, + texts: list[str], config: dict, output_dir: str, simulate_stt: bool = False, stt_delay: float = 0.1, save_chunks: bool = False, ) -> None: - """Connect to the streaming TTS endpoint and process audio responses.""" + """Connect to the streaming TTS endpoint and process audio responses. + + Every text is synthesized over the same connection: input.done flushes an + utterance without closing, so only the first one pays the handshake. + """ os.makedirs(output_dir, exist_ok=True) + response_format = config.get("response_format", "wav") + word_timestamps = bool(config.get("word_timestamps", False)) async with websockets.connect(url) as ws: - # 1. Send session config + # 1. Send session config; it stays in effect for every utterance below. config_msg = {"type": "session.config", **config} await ws.send(json.dumps(config_msg)) print(f"Sent session config: {config}") - # 2. Send text (either all at once or word-by-word) - async def send_text(): - if simulate_stt: - words = text.split(" ") - for i, word in enumerate(words): - chunk = word + (" " if i < len(words) - 1 else "") - await ws.send( - json.dumps( - { - "type": "input.text", - "text": chunk, - } - ) - ) - print(f" Sent: {chunk!r}") - await asyncio.sleep(stt_delay) - else: - await ws.send( - json.dumps( - { - "type": "input.text", - "text": text, - } - ) - ) - print(f"Sent full text: {text!r}") - - # 3. Signal end of input - await ws.send(json.dumps({"type": "input.done"})) - print("Sent input.done") - - # Run sender and receiver concurrently - sender_task = asyncio.create_task(send_text()) - - response_format = config.get("response_format", "wav") - word_timestamps = bool(config.get("word_timestamps", False)) - current_sentence_index = 0 - current_sentence_text = "" - current_chunks: list[bytes] = [] - current_timestamps: list[dict] = [] - # Distinguish `null` (aligner failed) from `[]` (silence / no tokens): - # the sentence is a failure only if no frame ever carried timestamps. - alignment_frame_seen = False - - try: - while True: - message = await ws.recv() - - if isinstance(message, bytes): - current_chunks.append(message) - print(f" Received audio chunk for sentence {current_sentence_index}: {len(message)} bytes") - else: - # JSON frame - msg = json.loads(message) - msg_type = msg.get("type") - - if msg_type == "audio.start": - current_sentence_index = msg["sentence_index"] - current_sentence_text = msg.get("sentence_text", "") - current_chunks = [] - current_timestamps = [] - alignment_frame_seen = False - print(f" [sentence {msg['sentence_index']}] Generating: {current_sentence_text!r}") - elif msg_type == "audio.chunk": - audio = base64.b64decode(msg["audio_b64"]) - current_chunks.append(audio) - chunk_timestamps = msg.get("timestamps") - # Sentence-level alignment sends a trailing timestamp-only - # frame with empty audio; don't write an empty chunk file. - if save_chunks and audio: - chunk_base = os.path.join( - output_dir, - f"sentence_{msg['sentence_index']:03d}_chunk_{msg['chunk_id']:03d}", - ) - chunk_pcm = f"{chunk_base}.pcm" - chunk_wav = f"{chunk_base}.wav" - chunk_json = f"{chunk_base}_timestamps.json" - with open(chunk_pcm, "wb") as f: - f.write(audio) - save_pcm_wav(chunk_wav, audio, int(msg.get("sample_rate", 24000))) - with open(chunk_json, "w", encoding="utf-8") as f: - json.dump( - { - "sentence_index": msg["sentence_index"], - "chunk_id": msg["chunk_id"], - "chunk_start_ms": msg.get("chunk_start_ms"), - "chunk_end_ms": msg.get("chunk_end_ms"), - "sentence_text": current_sentence_text, - "covered_text": timestamp_words_text(chunk_timestamps), - "timestamps": chunk_timestamps, - }, - f, - ensure_ascii=False, - indent=2, - ) - if chunk_timestamps is None: - print( - f" [sentence {msg['sentence_index']}] chunk {msg['chunk_id']}: " - f"{len(audio)} bytes, timestamps unavailable" - ) - else: - alignment_frame_seen = True - current_timestamps.extend(chunk_timestamps) - print( - f" [sentence {msg['sentence_index']}] chunk {msg['chunk_id']}: " - f"{len(audio)} bytes, {len(chunk_timestamps)} timestamp(s)" - ) - if save_chunks and audio: - print(f" saved chunk -> {chunk_wav}") - elif msg_type == "audio.done": - filename = save_audio_file( - output_dir, - msg["sentence_index"], - response_format, - current_chunks, - ) - if word_timestamps: - # `null` on failure, else the accumulated list (`[]` for silence). - sentence_timestamps = current_timestamps if alignment_frame_seen else None - ts_filename = os.path.join( - output_dir, - f"sentence_{msg['sentence_index']:03d}_timestamps.json", - ) - with open(ts_filename, "w", encoding="utf-8") as f: - json.dump( - { - "sentence_index": msg["sentence_index"], - "sentence_text": current_sentence_text, - "covered_text": timestamp_words_text(sentence_timestamps), - "timestamps": sentence_timestamps, - }, - f, - ensure_ascii=False, - indent=2, - ) - print( - f" [sentence {msg['sentence_index']}] Done" - f" bytes={msg.get('total_bytes', len(b''.join(current_chunks)))}" - f" error={msg.get('error', False)}" - f" -> {filename}" - ) - if word_timestamps: - print(f" [sentence {msg['sentence_index']}] Timestamps -> {ts_filename}") - current_chunks = [] - current_timestamps = [] - alignment_frame_seen = False - elif msg_type == "session.done": - print(f"\nSession complete: {msg['total_sentences']} sentence(s) generated") - break - elif msg_type == "error": - print(f" ERROR: {msg['message']}") - else: - print(f" Unknown message: {msg}") - finally: - sender_task.cancel() + for text in texts: + # 2. Send text (either all at once or word-by-word), then flush. + sender_task = asyncio.create_task(send_utterance(ws, text, simulate_stt, stt_delay)) try: - await sender_task - except asyncio.CancelledError: - pass # Task cancellation is expected during shutdown + await receive_utterance( + ws, + output_dir=output_dir, + response_format=response_format, + word_timestamps=word_timestamps, + save_chunks=save_chunks, + ) + finally: + sender_task.cancel() + try: + await sender_task + except asyncio.CancelledError: + pass # Task cancellation is expected during shutdown + + # 3. Release the connection once there is nothing left to say. + await ws.send(json.dumps({"type": "session.close"})) + print("Sent session.close") print(f"\nAudio files saved to: {output_dir}/") @@ -286,7 +325,8 @@ def main(): parser.add_argument( "--text", required=True, - help="Text to synthesize", + action="append", + help="Text to synthesize; repeat to send several utterances over one connection", ) parser.add_argument( "--output-dir", @@ -385,7 +425,7 @@ def main(): asyncio.run( stream_tts( url=args.url, - text=args.text, + texts=args.text, config=config, output_dir=args.output_dir, simulate_stt=args.simulate_stt, diff --git a/examples/online_serving/text_to_video/README.md b/examples/online_serving/text_to_video/README.md index 93de44a88..51512fe0c 100644 --- a/examples/online_serving/text_to_video/README.md +++ b/examples/online_serving/text_to_video/README.md @@ -248,82 +248,12 @@ done ## LTX-2 -### Start Server - -#### Basic Start - ```bash -vllm serve Lightricks/LTX-2 --omni --port 8098 \ - --enforce-eager --flow-shift 1.0 --boundary-ratio 1.0 +vllm serve Lightricks/LTX-2 --omni --port 8098 ``` -#### Start with Optimization Presets - -Use the LTX-2 startup script with built-in optimization presets: - -```bash -# Baseline (1 GPU, eager) -bash run_server_ltx2.sh baseline - -# 4-GPU Ulysses sequence parallelism (lossless) -bash run_server_ltx2.sh ulysses4 - -# Cache-DiT lossy acceleration (1 GPU, ~1.4× speedup) -bash run_server_ltx2.sh cache-dit - -# Best combo: 4-GPU Ulysses SP + Cache-DiT (~2.2× speedup) -bash run_server_ltx2.sh best-combo -``` - -#### Optimization Benchmarks - -Benchmarked on H800, online serving (480×768, 41 frames, 20 steps, `seed=42`). -"Inference" is the server-reported inference time; excludes HTTP/poll overhead. - -| Preset | Server Command | Inference (s) | Speedup | Type | -|--------|---------------|---------------|---------|------| -| `baseline` | `--enforce-eager` | 10.3 | 1.00× | — | -| `compile` | *(default, no --enforce-eager)* | ~10.3 (warm) | ~1.00× | Lossless | -| `ulysses4` | `--enforce-eager --usp 4` | ~10.3 | ~1.00× | Lossless | -| `cache-dit` | `--enforce-eager --cache-backend cache_dit` | 7.4 avg | ~1.4× | Lossy | -| `best-combo` | `--enforce-eager --usp 4 --cache-backend cache_dit` | 4.7 avg | **~2.2×** | Lossless + Lossy | - -**Observations**: -- **torch.compile**: On H800, warm-request inference time matches the eager baseline (~10.3s). - The first request pays ~6s compilation overhead. Benefit depends on model architecture and GPU. -- **Ulysses SP (4 GPU)**: No measurable speedup alone for 41-frame generation at this resolution. - Communication overhead outweighs gains at this sequence length. -- **Cache-DiT**: Inference varies per request (6–10s) due to dynamic caching decisions. - Average is ~7.4s (~1.4× speedup) with slight quality tradeoff. -- **Best combo**: 4-GPU Ulysses SP + Cache-DiT synergize well — Cache-DiT reduces per-step - computation, making the communication overhead of Ulysses SP worthwhile. Average ~4.7s - (~2.2× speedup). -- **FP8 quantization**: Reduces VRAM but does not speed up LTX-2 on H800 (compute-bound). - -**Deployment Recommendations**: -- For **production with quality priority**: use `baseline` with `--enforce-eager` -- For **maximum throughput** (4 GPUs, quality tradeoff): use `best-combo` (~2.2× speedup) -- For **single-GPU throughput**: use `cache-dit` (~1.4× speedup) -- `--enforce-eager` is recommended to avoid torch.compile warmup latency on first request - -### Send Requests (curl) - -```bash -# Using the provided script -bash run_curl_ltx2.sh - -# Or directly -curl -sS -X POST http://localhost:8098/v1/videos \ - -H "Accept: application/json" \ - -F "prompt=A serene lakeside sunrise with mist over the water." \ - -F "width=768" \ - -F "height=480" \ - -F "num_frames=41" \ - -F "fps=24" \ - -F "num_inference_steps=20" \ - -F "guidance_scale=3.0" \ - -F "seed=42" -``` +See the [LTX-2 recipe](../../../recipes/LTX/LTX-2.md) for all checkpoints, +pipeline selection, requests, defaults, and advanced options. ## Helios diff --git a/examples/online_serving/text_to_video/run_curl_ltx2.sh b/examples/online_serving/text_to_video/run_curl_ltx2.sh index b82f672ea..1cf828bba 100644 --- a/examples/online_serving/text_to_video/run_curl_ltx2.sh +++ b/examples/online_serving/text_to_video/run_curl_ltx2.sh @@ -22,7 +22,6 @@ create_response=$( -F "num_frames=41" \ -F "fps=24" \ -F "num_inference_steps=20" \ - -F "guidance_scale=3.0" \ -F "seed=42" ) diff --git a/examples/online_serving/text_to_video/run_server_ltx2.sh b/examples/online_serving/text_to_video/run_server_ltx2.sh index f4597d3cd..da9351f7f 100644 --- a/examples/online_serving/text_to_video/run_server_ltx2.sh +++ b/examples/online_serving/text_to_video/run_server_ltx2.sh @@ -21,8 +21,6 @@ set -euo pipefail MODEL="${MODEL:-Lightricks/LTX-2}" PORT="${PORT:-8098}" -FLOW_SHIFT="${FLOW_SHIFT:-1.0}" -BOUNDARY_RATIO="${BOUNDARY_RATIO:-1.0}" PRESET="${1:-baseline}" @@ -66,19 +64,13 @@ case "$PRESET" in echo "Environment variables:" echo " MODEL - Model path (default: Lightricks/LTX-2)" echo " PORT - Server port (default: 8098)" - echo " FLOW_SHIFT - Scheduler flow shift (default: 1.0)" - echo " BOUNDARY_RATIO - Boundary ratio (default: 1.0)" exit 1 ;; esac echo "Model: $MODEL" echo "Port: $PORT" -echo "Flow shift: $FLOW_SHIFT" -echo "Boundary ratio: $BOUNDARY_RATIO" vllm serve "$MODEL" --omni \ --port "$PORT" \ - --flow-shift "$FLOW_SHIFT" \ - --boundary-ratio "$BOUNDARY_RATIO" \ "${EXTRA_ARGS[@]}" diff --git a/mkdocs.yml b/mkdocs.yml index 8d5ffaa1b..fd97b9b37 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,6 +71,14 @@ plugins: - search - autorefs - awesome-nav + - redirects: + redirect_maps: + user_guide/diffusion/request_batching.md: user_guide/diffusion/execution_modes.md + user_guide/diffusion/step_execution.md: user_guide/diffusion/execution_modes.md + user_guide/diffusion/cpu_offload_diffusion.md: user_guide/diffusion/cpu_offload.md + user_guide/quantization/index.md: user_guide/quantization/overview.md + design/feature/diffusion_request_level_batching.md: design/feature/diffusion_continuous_batching.md + design/feature/diffusion_step_execution.md: design/feature/diffusion_continuous_batching.md - glightbox - git-revision-date-localized: # exclude files diff --git a/pyproject.toml b/pyproject.toml index 0da2d7b5f..f8328e656 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,44 +36,45 @@ classifiers = [ [project.optional-dependencies] dev = [ - "pytest>=7.0.0", - "pytest-asyncio>=0.21.0", - "pytest-cov>=4.0.0", - "pytest-mock>=3.10.0", - "websockets>=15.0.1", - "pytest-subtests>=0.11.0", - "datasets>=2.14.0", + "pytest==9.1.1", + "pytest-asyncio==1.4.0", + "pytest-cov==7.1.0", + "pytest-mock==3.15.1", + "websockets==16.1", + "pytest-subtests==0.15.0", + "datasets==3.6.0", "mypy==1.11.1", "pre-commit==4.0.1", - "openai-whisper>=20250625", - "psutil>=7.2.0", - "soundfile>=0.13.1", - "imageio[ffmpeg]>=0.6.0", - "opencv-python>=4.12.0.88", - "mooncake-transfer-engine-cuda13>=0.3.9", - "av", # for ComfyUI tests - "openpyxl>=3.0.0", # for nightly CI - "pyttsx3>=2.99", - "opencc>=1.2.0", - "mistune>=3.2.0", # for example tests - "torchmetrics>=1.4.0", # for accuracy similarity metrics - "ray[default]", # for RL test - "jiwer>=3.0.0", - "zhon>=2.0.0", - "zhconv>=1.4.2", - "scipy>=1.10.0", - "funasr>=1.0.0", - "lpips>=0.1.4", # for quantization fp8 - "orjson>=3.11.7", # to make v1/embeddings API fast - "voxcpm>=2.0.2", # for VoxCPM2 path-locating in tests - "torchdiffeq>=0.2.5", # for covo_audio Test - "FlagEmbedding", # for hunyuan_image3 tests - "kernels<0.15.0", # for accuracy tests comparison with diffusers output (0.15.0 incompatible with diffusers 0.38.0) - "s3tokenizer>=0.3.0", # for tts test - "step-audio2>=1.0.0", # Step-Audio2 Token2Wav (flashcosyvoice / hyperpyyaml) + "openai-whisper==20250625", + "psutil==7.2.2", + "soundfile==0.14.0", + "imageio[ffmpeg]==2.37.4", + "opencv-python==5.0.0.93", + "mooncake-transfer-engine-cuda13==0.3.11.post1", + "av==18.0.0", # for ComfyUI tests + "openpyxl==3.1.5", # for nightly CI + "pyttsx3==2.99", + "opencc==1.4.1", + "mistune==3.3.4", # for example tests + "torchmetrics==1.9.0", # for accuracy similarity metrics + "ray[default]==2.56.1", # for RL test + "jiwer==4.0.0", + "zhon==2.1.1", + "zhconv==1.4.3", + "scipy==1.18.0", + "funasr==1.3.29", + "lpips==0.1.4", # for quantization fp8 + "orjson==3.11.9", # to make v1/embeddings API fast + "voxcpm==2.0.3", # for VoxCPM2 path-locating in tests + "torchdiffeq==0.2.5", # for covo_audio Test + "FlagEmbedding==1.4.0", # for hunyuan_image3 tests + "kernels==0.14.1", # for accuracy tests comparison with diffusers output (0.15.0 incompatible with diffusers 0.38.0) + "s3tokenizer==0.3.0", # for tts test + "step-audio2==1.0.0", # Step-Audio2 / MiniCPM-o 4.5 Token2Wav (flashcosyvoice / hyperpyyaml) ] demo = [ + "gradio>=6.7.0", "opencv-python>=4.12.0.88", # cli/stream_client frame reading "requests>=2.28.0", # cli/stream_client HTTP "cn2an>=0.5.22", # Chinese number normalization for TTS (e.g. "1次" -> "一次") @@ -88,24 +89,18 @@ local = [ "FlagEmbedding", ] -# Optional runtime dependencies for the MiniCPM-o 4.5 talker pipeline. -# The talker loads MiniCPMTTS via `trust_remote_code` from the model repo, -# and that module performs `from stepaudio2 import Token2wav` against the -# MiniCPM-o-flavored Token2wav vocoder (PyPI: `stepaudio2-minicpmo`, NOT -# upstream `stepfun-ai/Step-Audio2` — the upstream signature rejects -# `n_timesteps` and crashes at construction). A clean `vllm-omni` install -# does not pull this in by default to avoid forcing the dep onto users -# who only need other models; opt in with `pip install vllm-omni[minicpmo]`. -minicpmo = [ - "stepaudio2-minicpmo", -] - # 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 = [ "quack-kernels>=0.3.11", ] +# Optional Blackwell-native FlashAttention-4 backend. Keep this opt-in because +# the CuTe/CUTLASS dependency is CUDA 13 and Blackwell specific. +fa4 = [ + "flash-attn-4[cu13]==4.0.0b18", +] + docs = [ "mkdocs>=1.5.0", "mkdocs-api-autonav", @@ -113,6 +108,7 @@ docs = [ "mkdocstrings-python", "mkdocs-gen-files", "mkdocs-awesome-nav", + "mkdocs-redirects", "mkdocs-glightbox", "mkdocs-git-revision-date-localized-plugin", "mkdocs-minify-plugin", @@ -151,7 +147,6 @@ include = ["vllm_omni*"] [tool.setuptools.package-data] "vllm_omni" = ["_version.py", "py.typed"] -"vllm_omni.model_executor.stage_configs" = ["*.yaml"] [tool.setuptools_scm] # no extra settings needed, presence enables setuptools-scm @@ -255,13 +250,16 @@ markers = [ "npu: Tests that run on NPU/Ascend (auto-added)", "musa: Tests that run on MUSA/Moore Threads (auto-added)", # specified computation resources marks (auto-added) - "H100: Tests that require H100 GPU", - "L4: Tests that require L4 GPU", - "MI325: Tests that require MI325 GPU (AMD/ROCm)", - "B60: Tests that require Intel Arc Pro B60 XPU", - "S5000: Tests that require S5000 GPU (Moore Threads/MUSA)", - "A2: Tests that require A2 NPU", - "A3: Tests that require A3 NPU", + # Tag with [hardware-resource] for tests.helpers.mark.get_hardware_mark_list(). + "H100: [hardware-resource] Tests that require H100 GPU", + "H200: [hardware-resource] Tests that require H200 GPU", + "L4: [hardware-resource] Tests that require L4 GPU", + "B200: [hardware-resource] Tests that require B200 GPU", + "MI325: [hardware-resource] Tests that require MI325 GPU (AMD/ROCm)", + "B60: [hardware-resource] Tests that require Intel Arc Pro B60 XPU", + "S5000: [hardware-resource] Tests that require S5000 GPU (Moore Threads/MUSA)", + "A2: [hardware-resource] Tests that require A2 NPU", + "A3: [hardware-resource] Tests that require A3 NPU", "distributed_cuda: Tests that require multi cards on CUDA platform", "distributed_rocm: Tests that require multi cards on ROCm platform", "distributed_xpu: Tests that require multi cards on XPU platform", diff --git a/recipes/Bagel/BAGEL-7B-MoT.md b/recipes/Bagel/BAGEL-7B-MoT.md index a7b0524e4..4c2d75aca 100644 --- a/recipes/Bagel/BAGEL-7B-MoT.md +++ b/recipes/Bagel/BAGEL-7B-MoT.md @@ -6,7 +6,7 @@ - Vendor: ByteDance Seed - Model: `ByteDance-Seed/BAGEL-7B-MoT` -- Task: Text-to-image and image-to-image generation +- Task: Text-to-image, image-to-image, text-to-text, and image-to-text - Mode: Offline inference and OpenAI-compatible online serving - Maintainer: Community @@ -26,6 +26,8 @@ parameters through the pipeline-declared `extra_args` contract. [`examples/offline_inference/text_to_image/text_to_image.py`](../../examples/offline_inference/text_to_image/text_to_image.py) - Related offline image-to-image example: [`examples/offline_inference/image_to_image/image_edit.py`](../../examples/offline_inference/image_to_image/image_edit.py) +- Related offline understanding example: + [`examples/offline_inference/x_to_text/x_to_text.py`](../../examples/offline_inference/x_to_text/x_to_text.py) - Related online example: [`examples/online_serving/text_to_image/openai_chat_client.py`](../../examples/online_serving/text_to_image/openai_chat_client.py) - Related online image-to-image example: @@ -118,6 +120,25 @@ The `--extra-args` JSON forwards BAGEL-specific parameters (e.g. `cfg_text_scale `cfg_img_scale`, `cfg_interval`, `cfg_renorm_type`) into `OmniDiffusionSamplingParams.extra_args` via the model-extras registry. +Run text-to-text with the shared understanding example. BAGEL's default +`bagel.yaml` deploy config is discovered from the checkpoint, so no model-specific +example or explicit deploy config is required: + +```bash +python examples/offline_inference/x_to_text/x_to_text.py \ + --model ByteDance-Seed/BAGEL-7B-MoT \ + --prompt "Where is the capital of France?" +``` + +Add `--image` to run image-to-text with the same example: + +```bash +python examples/offline_inference/x_to_text/x_to_text.py \ + --model ByteDance-Seed/BAGEL-7B-MoT \ + --image /path/to/input.jpg \ + --prompt "Please describe this image in detail." +``` + #### Online Commands Start the OpenAI-compatible server: diff --git a/recipes/Boogu/Boogu-Image.md b/recipes/Boogu/Boogu-Image.md new file mode 100644 index 000000000..b9bff789f --- /dev/null +++ b/recipes/Boogu/Boogu-Image.md @@ -0,0 +1,180 @@ +# Boogu-Image + +> Text-to-image and image-editing online serving (Boogu-Image-0.1-Base / -Edit) + +## Summary + +- Vendor: Boogu +- Model: `Boogu/Boogu-Image-0.1-Base` (text-to-image), `Boogu/Boogu-Image-0.1-Edit` (image editing) +- Task: Text-to-image generation and text-guided image editing (TI2I) +- Mode: Online serving with the OpenAI-compatible API +- Maintainer: Community + +## When to use this recipe + +Use this recipe when you want a known-good starting point for serving +`Boogu/Boogu-Image-0.1-Base` with vLLM-Omni's native pipeline (no +`--diffusion-load-format diffusers`, and the upstream `boogu` package is not +required). + +Boogu-Image-0.1 is an Apache-2.0 unified image generation and editing model +family. This recipe covers the Base text-to-image checkpoint, which pairs a +Qwen3-VL multimodal encoder with a Diffusion Transformer (DiT) and a flow-match +Euler scheduler with time-shift. It handles photorealistic generation and +Chinese/English text rendering. + +## References + +- Upstream model card: +- Project page: +- GitHub: +- Related example: [`examples/online_serving/text_to_image/`](../../examples/online_serving/text_to_image/README.md) + +## Hardware Support + +This recipe documents tested configurations for CUDA GPU serving. The native +pipeline runs single-GPU; multi-GPU parallelism, CPU offload, and cache +acceleration are not yet supported for this model (see Notes). + +## GPU + +### 1 x A100/H100 (Single GPU, 40GB+ VRAM) + +The model footprint is roughly 34.6 GiB on GPU, so a 40GB+ card is recommended. + +#### Command + +```bash +vllm serve Boogu/Boogu-Image-0.1-Base --omni --port 8091 +``` + +!!! note + If you hit Out-of-Memory (OOM) on a smaller card, enable VAE slicing and + tiling to reduce peak memory: `--vae-use-slicing --vae-use-tiling`. + +#### Verification + +After the server is ready, test with a simple request: + +```bash +curl -X POST http://localhost:8091/v1/images/generations \ + -H "Content-Type: application/json" \ + -d '{ + "model": "Boogu/Boogu-Image-0.1-Base", + "prompt": "A mountain lake at sunset, photorealistic, cinematic lighting", + "size": "1024x1024", + "num_inference_steps": 28, + "guidance_scale": 4.0, + "seed": 42 + }' | jq -r '.data[0].b64_json' | base64 -d > output.png +``` + +Or via the chat-completions endpoint (parameters go in `extra_body`): + +```bash +curl -s http://localhost:8091/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [ + {"role": "user", "content": "A mountain lake at sunset, photorealistic, cinematic lighting"} + ], + "extra_body": { + "height": 1024, + "width": 1024, + "num_inference_steps": 28, + "guidance_scale": 4.0, + "seed": 42 + } + }' | jq -r '.choices[0].message.content[0].image_url.url' | cut -d',' -f2- | base64 -d > output.png +``` + +#### Notes + +- **Memory usage:** ~34.6 GiB on GPU. Use `--vae-use-slicing --vae-use-tiling` + to trim peak VRAM if needed. +- **Key flags:** + - `--omni` — enables vLLM-Omni diffusion serving. +- **Guidance:** Boogu-Image uses `guidance_scale` (mapped to the upstream + `text_guidance_scale`); the default is `4.0`. Classifier-free guidance is + active whenever `guidance_scale > 1.0`. +- **Recommended settings:** `num_inference_steps=28`-`50`, `guidance_scale=4.0`. + The model's maximum native resolution is 2K. +- **Known limitations (not yet supported):** CPU offload + (`--enable-cpu-offload` / `--enable-layerwise-offload`), Cache-DiT + (`--cache-backend cache_dit`), and multi-GPU parallelism (TP / SP / CFG / + HSDP) are planned follow-ups and are not validated for this model yet. + +## Image editing (Boogu-Image-0.1-Edit) + +The Edit checkpoint is served by the same native pipeline (`BooguImagePipeline`); +the image-editing (TI2I) path activates automatically when a request carries a +reference image. The Base text-to-image path is unaffected (no reference image +is sent). + +#### Command + +```bash +vllm serve Boogu/Boogu-Image-0.1-Edit --omni --port 8091 +``` + +#### Verification + +Edit an image with `/v1/images/edits` (the model-card example — change a photo +to a colored-pencil drawing). Diffusion parameters are plain multipart form +fields; add `guidance_scale_2` to enable image guidance (double-guidance path): + +```bash +curl -s http://localhost:8091/v1/images/edits \ + -F model="Boogu/Boogu-Image-0.1-Edit" \ + -F image="@input.png" \ + -F prompt="Change the style to a colored pencil drawing." \ + -F num_inference_steps=28 \ + -F guidance_scale=5.0 \ + -F guidance_scale_2=2.0 \ + -F seed=42 \ + | jq -r '.data[0].b64_json' | base64 -d > edited.png +``` + +Or via chat completions (attach the image as a data URL; parameters go in +`extra_body`): + +```bash +curl -s http://localhost:8091/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [ + {"role": "user", "content": [ + {"type": "image_url", "image_url": {"url": "data:image/png;base64,"}}, + {"type": "text", "text": "Change the style to a colored pencil drawing."} + ]} + ], + "extra_body": { + "num_inference_steps": 28, + "guidance_scale": 5.0, + "guidance_scale_2": 1.0, + "seed": 42 + } + }' | jq -r '.choices[0].message.content[0].image_url.url' | cut -d',' -f2- | base64 -d > edited.png +``` + +#### Notes + +- **Single reference image:** only one input image is supported for now (the + upstream "Only support 1 reference image for now" limit). +- **Guidance semantics:** + - `guidance_scale` = text guidance (upstream `text_guidance_scale`, default + `4.0`); `> 1.0` enables text CFG. Editing typically uses `5.0`. + - `guidance_scale_2` = image guidance (upstream `image_guidance_scale`, + default `1.0` = off). Setting it `> 1.0` enables the double-guidance path + (3 model predictions per step), steering more strongly toward the reference + image. +- **Output resolution:** the output size follows the reference image (upstream + `align_res`, on by default for a single reference), so `height`/`width` are + derived from the input and requested sizes are not applied for edits. +- **Same limitations** as the Base checkpoint apply (no CPU offload, Cache-DiT, + or multi-GPU parallelism yet). +- **Offline editing:** the shared offline example + [`examples/offline_inference/image_to_image/image_edit.py`](../../examples/offline_inference/image_to_image/image_to_image.md) + supports Boogu-Image-Edit directly + (`--model Boogu/Boogu-Image-0.1-Edit --guidance-scale 5.0`, optional + `--guidance-scale-2`). diff --git a/recipes/JD/JoyAI-VL-Interaction.md b/recipes/JD/JoyAI-VL-Interaction.md index ed767830f..e09d5b16c 100644 --- a/recipes/JD/JoyAI-VL-Interaction.md +++ b/recipes/JD/JoyAI-VL-Interaction.md @@ -168,7 +168,7 @@ the model + orchestrator + WebUI together — point `WEBUI_DIR` at your clone. python examples/online_serving/joyvl_interaction/cli/run_cli_demo.py \ path/to/video.mp4 --query "Alert me if a fire breaks out" -pytest tests/fullduplex # framework + JoyVL unit tests +pytest tests/e2e/features/fullduplex # framework + JoyVL unit tests ``` ## Testing with an RTSP stream (optional) diff --git a/recipes/LTX/LTX-2.3.md b/recipes/LTX/LTX-2.3.md deleted file mode 100644 index c83a14883..000000000 --- a/recipes/LTX/LTX-2.3.md +++ /dev/null @@ -1,154 +0,0 @@ -# LTX-2.3 - -> 22B parameter text-to-video + audio generation model served via vLLM-Omni - -## Summary - -- Vendor: Lightricks -- Model: `dg845/LTX-2.3-Diffusers` -- Task: Text-to-video and image-to-video with synchronized audio generation -- Mode: Online serving (pure diffusion) -- Maintainer: @oglok - -## When to use this recipe - -Use this recipe when you want to serve LTX-2.3 for text-to-video or -image-to-video generation with audio. The model generates videos up to 20+ -seconds at 768x512 resolution with 48kHz audio from text prompts, and can -condition video generation on an initial image. Start with a 96GB-class GPU for -validation because the model combines a 22B parameter transformer with text -encoder, VAE, and vocoder components. - -## References - -- Upstream raw checkpoints: -- Diffusers-format checkpoint: -- Requires `diffusers >= 0.38.0` (install from git: `pip install git+https://github.com/huggingface/diffusers.git`) - -## Serving - -### Command - -```bash -vllm serve dg845/LTX-2.3-Diffusers \ - --omni \ - --model-class-name LTX23Pipeline \ - --stage-init-timeout 600 -``` - -For image-to-video: - -```bash -vllm serve dg845/LTX-2.3-Diffusers \ - --omni \ - --model-class-name LTX23ImageToVideoPipeline \ - --stage-init-timeout 600 -``` - -### T2V Verification - -```bash -# Health check -curl http://localhost:8000/health - -# Generate a 3-second video (81 frames at 24fps) -curl -X POST http://localhost:8000/v1/videos \ - -F "prompt=A majestic bald eagle soaring over a misty mountain valley at dawn, golden sunlight breaking through clouds" \ - -F "negative_prompt=blurry, low quality, distorted, watermark" \ - -F "model=dg845/LTX-2.3-Diffusers" \ - -F "num_frames=81" \ - -F "fps=24" \ - -F "size=768x512" \ - -F "num_inference_steps=30" \ - -F "guidance_scale=4.0" \ - -F "seed=42" - -# Generate a 10-second video (241 frames) -curl -X POST http://localhost:8000/v1/videos \ - -F "prompt=A cozy Japanese ramen shop at night in the rain, steam rising from bowls, neon signs reflecting on wet cobblestone streets" \ - -F "model=dg845/LTX-2.3-Diffusers" \ - -F "num_frames=241" \ - -F "fps=24" \ - -F "size=768x512" \ - -F "num_inference_steps=30" \ - -F "guidance_scale=4.0" - -# Generate a 20-second video (481 frames) -curl -X POST http://localhost:8000/v1/videos \ - -F "prompt=An underwater coral reef teeming with tropical fish, sea turtles gliding gracefully, National Geographic documentary style" \ - -F "model=dg845/LTX-2.3-Diffusers" \ - -F "num_frames=481" \ - -F "fps=24" \ - -F "size=768x512" \ - -F "num_inference_steps=30" \ - -F "guidance_scale=4.0" -``` - -### I2V Verification - -Start the server with `--model-class-name LTX23ImageToVideoPipeline`, then pass -an image reference. The I2V pipeline requires `input_reference` or -`image_reference` because the first frame is the conditioning input. - -```bash -curl http://localhost:8000/health - -curl -X POST http://localhost:8000/v1/videos/sync \ - -F "prompt=A plush toy astronaut gently waving while the camera slowly pushes in." \ - -F "input_reference=@/absolute/path/to/reference.png" \ - -F "model=dg845/LTX-2.3-Diffusers" \ - -F "num_frames=81" \ - -F "fps=24" \ - -F "size=768x512" \ - -F "num_inference_steps=30" \ - -F "guidance_scale=4.0" \ - -F "seed=42" \ - -o ltx23_i2v.mp4 -``` - -### Notes - -- Memory usage: run a latest-head serving smoke or DFX benchmark on the target - GPU before publishing load/peak VRAM numbers. -- Checkpoint format: use a Diffusers-format checkpoint such as - `dg845/LTX-2.3-Diffusers`; the upstream `Lightricks/LTX-2.3` repository ships - raw safetensors and does not contain the subfolder configs required by this - pipeline loader. -- Key flags: - - `--stage-init-timeout 600`: Gives the initial `torch.compile` warmup enough time on large checkpoints - - `--model-class-name LTX23Pipeline`: Selects the LTX-2.3 text-to-video pipeline (not LTX-2) - - `--model-class-name LTX23ImageToVideoPipeline`: Selects the LTX-2.3 image-to-video pipeline - - `input_reference=@...`: Uploads the I2V reference image directly -- Audio: 48kHz AAC via BWE vocoder, automatically synced with video -- CPU offloading: Text encoder (Gemma-3-12B), connectors, VAE, audio VAE, and vocoder stay on CPU and are moved to GPU only when needed -- Supported resolutions: 768x512, 512x384 (must be divisible by 32) -- Frame rate: 24 fps -- Duration: Controlled by `num_frames` (frames = duration_seconds * 24 + 1) -- Known limitations: - - LTX-2.3 I2V currently supports first-frame image conditioning, matching the LTX image-conditioning contract. - - Requires `diffusers >= 0.38.0` (not yet on PyPI, install from git) - -## Hardware Support - -## GPU - -### 1x NVIDIA RTX PRO 6000 Blackwell (96GB) - -#### Environment - -- OS: Ubuntu 22.04 -- Python: 3.10+ -- Driver / runtime: CUDA 13.0, Driver 580.126.09 -- vLLM version: Match the PR or release checkout being validated -- vLLM-Omni version or commit: Use the commit you are deploying from - -### Benchmarking - -Do not copy latency or VRAM numbers between commits or machines. Record them -only after running a latest-head sweep on the target hardware. - -For formal PR benchmarking, reuse -`tests/dfx/perf/tests/test_ltx2_3_vllm_omni.json` with -`tests/dfx/perf/scripts/run_diffusion_benchmark.py`. That config captures the -single-device eager baseline and CFG-parallel=2 case for a small 384x512, -25-frame, 20-step workload. diff --git a/recipes/LTX/LTX-2.md b/recipes/LTX/LTX-2.md index df049f5c6..3142f9fd1 100644 --- a/recipes/LTX/LTX-2.md +++ b/recipes/LTX/LTX-2.md @@ -1,121 +1,337 @@ -# LTX-2 +# LTX-2 Family -> Text-to-video and image-to-video serving +> LTX-2 and LTX-2.3 text-to-video and image-to-video generation with synchronized audio -## Summary +## Pipelines -- Vendor: Lightricks -- Model: `Lightricks/LTX-2` -- Task: Text-to-video generation and image-to-video generation -- Mode: Online serving with the OpenAI-compatible video API -- Maintainer: Community +| Model | Checkpoint | Task | `--model-class-name` | Batching | +|---|---|---|---|---| +| LTX-2 | `Lightricks/LTX-2` | One-stage T2V/I2V | `LTX2Pipeline` | Yes | +| LTX-2 distilled | `rootonchair/LTX-2-19b-distilled` | Two-stage T2V/I2V | `LTX2DistilledPipeline` | No | +| LTX-2.3 | `diffusers/LTX-2.3-Diffusers` | One-stage T2V/I2V | `LTX2Pipeline` | Yes | -## When to use this recipe +`LTX2Pipeline` is the unified one-stage entry. Checkpoint metadata selects the +LTX-2 or LTX-2.3 profile; omitting an image selects T2V, while one initial +image selects I2V. Both one-stage repositories declare this class, so +`--model-class-name` is optional. LTX-2.3 requires the Diffusers checkpoint; +the raw `Lightricks/LTX-2.3` safetensors repository is not directly loadable. -Use this recipe when you want to deploy the base `Lightricks/LTX-2` model -for both text-to-video and image-to-video generation. The same model ID is used for -both tasks; switch the pipeline class with `--model-class-name` depending on -whether you want T2V or I2V. +`LTX2DistilledPipeline` is the unified distilled entry: omit `image` for T2V +or provide one initial image for I2V. As in the official LTX pipelines, +distilled mode is selected explicitly rather than inferred from the checkpoint +name or metadata; always pass `--model-class-name LTX2DistilledPipeline`. + +## API Migration + +Only `req` may be passed positionally to `LTX2Pipeline`; every optional +`forward` argument is keyword-only: + +```python +# No longer supported +pipe(req, prompt) +pipe(req, image, prompt) + +# Supported +pipe(req, prompt=prompt) +pipe(req, image=image, prompt=prompt) +``` + +The consolidation also removes these registry names without aliases: + +| Removed name | Replacement | +|---|---| +| `LTX23Pipeline` | `LTX2Pipeline`; checkpoint metadata selects LTX-2.3 | +| `LTX2ImageToVideoPipeline` | `LTX2Pipeline` with `image=` | +| `LTX23ImageToVideoPipeline` | `LTX2Pipeline` with `image=`; checkpoint metadata selects LTX-2.3 | +| `LTX2TwoStagesPipeline` | `LTX2DistilledPipeline` | +| `LTX2ImageToVideoTwoStagesPipeline` | `LTX2DistilledPipeline` with `image=` | + +Passing any second positional argument now raises `TypeError`. These changes +affect direct Python callers and explicit `--model-class-name` overrides; +offline and serving entrypoints already use named fields and are unaffected. + +## One-Stage Defaults + +| Parameter | LTX-2 | LTX-2.3 | +|---|---:|---:| +| Width × height | 768 × 512 | 768 × 512 | +| Frames / frame rate | 121 / 24 | 121 / 24 | +| Denoise steps | 40 | 30 | +| Video/audio CFG | 3.0 / 7.0 | 3.0 / 7.0 | +| Video/audio STG | 1.0 / 1.0 | 1.0 / 1.0 | +| Video/audio modality guidance | 3.0 / 3.0 | 3.0 / 3.0 | +| Video/audio rescale | 0.7 / 0.7 | 0.7 / 0.7 | +| Video/audio STG blocks | `[29]` / `[29]` | `[28]` / `[28]` | + +The model recipe also supplies the default negative prompt. Top-level +`guidance_scale` overrides both video and audio CFG values. The online video +API defaults `num_frames` to `1`, so set it explicitly; the offline LTX scripts +default to `121`. + +## Distilled Two-Stage Defaults + +| Parameter | Value | +|---|---:| +| Final width × height | 1536 × 1024 | +| Stage 1 width × height | 768 × 512 | +| Frames / frame rate | 121 / 24 | +| Stage 1 / Stage 2 denoise steps | 8 / 3 | +| Guidance | Fixed positive-only | + +The dimensions passed to the API are the final output dimensions and must be +divisible by 64. Both one- and two-stage requests require `num_frames = 8k+1`. +The distilled recipe fixes both sigma schedules; `num_inference_steps` must be +`8` when supplied. + +## Serving + +Start either checkpoint: + +```bash +vllm serve Lightricks/LTX-2 --omni --stage-init-timeout 600 +``` + +```bash +vllm serve diffusers/LTX-2.3-Diffusers --omni --stage-init-timeout 600 +``` + +Start the distilled checkpoint with its two-stage entry: + +```bash +vllm serve rootonchair/LTX-2-19b-distilled --omni \ + --model-class-name LTX2DistilledPipeline --stage-init-timeout 600 +``` + +The same server handles T2V and I2V. A T2V request using the selected recipe's +default guidance is: + +```bash +curl -X POST http://localhost:8000/v1/videos/sync \ + -F "prompt=A cinematic close-up of ocean waves at golden hour." \ + -F "negative_prompt=worst quality, inconsistent motion, blurry, jittery, distorted" \ + -F "size=768x512" \ + -F "num_frames=121" \ + -F "fps=24" \ + -F "seed=42" \ + -o ltx_t2v.mp4 +``` + +For I2V, provide exactly one initial image: + +```bash +curl -X POST http://localhost:8000/v1/videos/sync \ + -F "prompt=A plush toy astronaut gently waving while the camera slowly pushes in." \ + -F "negative_prompt=worst quality, inconsistent motion, blurry, jittery, distorted" \ + -F "input_reference=@/absolute/path/to/reference.png" \ + -F "size=768x512" \ + -F "num_frames=121" \ + -F "fps=24" \ + -F "seed=42" \ + -o ltx_i2v.mp4 +``` + +Use `image_reference` for a URL or JSON-safe image reference. Do not provide it +together with `input_reference`. + +## Guidance + +LTX supports independent video/audio CFG, spatio-temporal guidance (STG), +cross-modality guidance, and rescaling: + +| Parameter | Default | Effect | Alias | +|---|---:|---|---| +| `video_cfg_scale` | 3.0 | Video text CFG; `1.0` disables it | `video_cfg_guidance_scale` | +| `audio_cfg_scale` | 7.0 | Audio text CFG; `1.0` disables it | `audio_cfg_guidance_scale` | +| `video_stg_scale` | 1.0 | Video STG; `0.0` disables it | `video_stg_guidance_scale` | +| `audio_stg_scale` | 1.0 | Audio STG; `0.0` disables it | `audio_stg_guidance_scale` | +| `video_modality_scale` | 3.0 | Audio-to-video guidance; `1.0` disables it | `a2v_guidance_scale` | +| `audio_modality_scale` | 3.0 | Video-to-audio guidance; `1.0` disables it | `v2a_guidance_scale` | +| `video_rescale_scale` | 0.7 | Video guidance rescale; `0.0` disables it | — | +| `audio_rescale_scale` | 0.7 | Audio guidance rescale; `0.0` disables it | — | +| `video_stg_blocks` | `[29]` / `[28]` | Perturbed video transformer blocks | — | +| `audio_stg_blocks` | `[29]` / `[28]` | Perturbed audio transformer blocks | — | + +The STG block defaults are shown as LTX-2 / LTX-2.3. A canonical name wins +over its alias. Explicit top-level `guidance_scale` wins over both CFG fields; +omit it for independent video/audio CFG. + +Pass these fields in online `extra_params` or offline `--extra-body`: + +```bash +# Add to the curl request above +-F 'extra_params={"video_cfg_scale":3.0,"audio_cfg_scale":7.0}' + +# Add to text_to_video.py or image_to_video.py +--extra-body '{"video_cfg_scale":3.0,"audio_cfg_scale":7.0}' +``` + +### Guidance Parallelism + +For LTX, `cfg_parallel_size` is the number of ranks used to execute the +complete guidance plan in parallel. Despite the legacy `cfg` name, it covers +text CFG, STG, and cross-modality guidance passes; guidance rescaling is +applied after all pass predictions are gathered. + +The default one-stage recipe has four Transformer passes per denoise step: +`cond`, `uncond`, `ptb` (STG), and `mod` (cross-modality). The useful balanced +configurations are therefore: + +| `--cfg-parallel-size` | Passes per rank | Guidance-slot utilization | Notes | +|---:|---:|---:|---| +| `1` | 4 | 100% | Single-rank fused guidance batch | +| `2` | 2 | 100% | Recommended two-rank configuration | +| `4` | 1 | 100% | One guidance pass per rank | + +Other positive sizes are accepted. When the pass count is not divisible by +`cfg_parallel_size`, ranks are padded to an equal number of execution slots +and LTX emits a warning with the expected utilization. For example, four +passes on three ranks use six slots and have 66.7% guidance-slot utilization. + +Start an LTX-2.3 server with two-way guidance parallelism: + +```bash +vllm serve diffusers/LTX-2.3-Diffusers --omni \ + --cfg-parallel-size 2 --stage-init-timeout 600 +``` + +The total device count is the product of `cfg_parallel_size` and the other +configured parallel dimensions. Distilled pipelines use positive-only +guidance, so `cfg_parallel_size > 1` does not provide guidance speedup for +them. + +### Python API + +Normal per-request values belong in `OmniDiffusionSamplingParams`. Pass the +resulting `DiffusionRequestBatch` as the only positional argument: + +```python +from vllm_omni.diffusion.request import OmniDiffusionRequest +from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch +from vllm_omni.inputs.data import OmniDiffusionSamplingParams + +params = OmniDiffusionSamplingParams( + width=768, + height=512, + num_frames=121, + frame_rate=24.0, + num_inference_steps=30, + seed=42, + extra_args={"video_cfg_scale": 3.0, "audio_cfg_scale": 7.0}, +) +req = DiffusionRequestBatch([ + OmniDiffusionRequest( + prompt={"prompt": "Cherry blossoms moving in a light breeze"}, + sampling_params=params, + request_id="ltx-example", + ) +]) + +t2v_output = pipe(req) +i2v_output = pipe(req, image=image) +``` + +Direct keyword arguments are low-level fallbacks, for example +`pipe(req, prompt=prompt, width=768)`. Request fields take precedence unless +noted below. + +### Complete `forward` Surface + +| Argument | Type/default | Meaning and constraints | +|---|---|---| +| `req` | `DiffusionRequestBatch`, required | Only positional argument; contains prompts and per-request sampling parameters. | +| `image` | image or batch, `None` | Direct value wins over request images; no image selects T2V. I2V accepts one image per prompt, and a batch cannot mix T2V/I2V. | +| `prompt` | string or list, `None` | Positive-text fallback; request prompts win. Mutually exclusive with `prompt_embeds`. | +| `negative_prompt` | string or list, `None` | Fallback after request values and before the recipe default; mutually exclusive with negative embeddings. | +| `height` | `int`, `None` | Request → direct value → recipe default; divisible by 32 one-stage or 64 distilled. | +| `width` | `int`, `None` | Same precedence and alignment as `height`. | +| `num_frames` | `int`, `None` | Request → direct value → recipe default; must be `8k+1` and also determines audio duration with `frame_rate`. | +| `frame_rate` | `float`, `None` | Request `frame_rate` → request `fps` → direct value → recipe default. | +| `num_inference_steps` | `int`, `None` | Request → direct value → recipe default; minimum 2 one-stage, fixed at 8 for distilled Stage 1. Custom one-stage `sigmas` determine actual steps. | +| `sigmas` | list of float, `None` | One-stage only. Request values win; every request in a fused batch must use the same schedule. | +| `timesteps` | list of int, `None` | Compatibility slot; LTX accepts only `None`. Use `sigmas`. | +| `guidance_scale` | `float`, `None` | One-stage common video/audio CFG fallback; an explicit request value wins. Distilled two-stage uses fixed positive-only guidance. | +| `guidance_rescale` | `float`, `None` | Accepts only `None` or `0.0`; use the modality rescale fields. | +| `noise_scale` | `float`, `0.0` | Compatibility slot; LTX accepts only `0.0`. | +| `num_videos_per_prompt` | `int`, `1` | Output-count fallback; positive request `num_outputs_per_prompt` wins. | +| `generator` | generator or list, `None` | Explicit RNG; otherwise request generators/seeds are collated. Lists must match the effective output batch. | +| `latents` | tensor, `None` | Request tensors win. One-stage accepts packed `[B, S, C]` and validated unpacked video layouts. | +| `audio_latents` | tensor, `None` | One-stage initial audio latents; request tensors win and are collated. | +| `prompt_embeds` | tensor, `None` | Precomputed positive conditioning; requires `prompt_attention_mask` and cannot accompany `prompt`. | +| `negative_prompt_embeds` | tensor, `None` | One-stage precomputed negative conditioning; requires its mask and cannot accompany `negative_prompt`. | +| `prompt_attention_mask` | tensor, `None` | Mask for positive embeddings; request `prompt_attention_mask` or `attention_mask` wins. | +| `negative_prompt_attention_mask` | tensor, `None` | Mask for negative embeddings; request negative mask fields win. | +| `decode_timestep` | float or list, `0.0` | Video-VAE decode timestep; request value wins. Lists may match 1, prompt batch, or output batch. | +| `decode_noise_scale` | float or list, `None` | Same list rules; request value wins. Defaults to `decode_timestep`. | +| `output_type` | string, `"np"` | Request value wins. `"np"` decodes output; `"latent"` skips VAE/vocoder decode. | +| `return_dict` | `bool`, `True` | Compatibility slot; only `True` is accepted. | +| `attention_kwargs` | dict, `None` | Per-call values are unsupported; configure attention at engine startup. | +| `max_sequence_length` | `int`, `None` | Request → direct value → tokenizer limit. | + +Request-object naming differs only in a few places: `num_videos_per_prompt` +maps to `num_outputs_per_prompt`; images and prompt text/embeddings live in the +request prompt payload; LTX guidance fields live in sampling `extra_args`. + +### Recipe-Specific Request Capabilities + +| Override | One-stage | Distilled two-stage | +|---|---|---| +| Guidance | Supported | Fixed positive-only | +| Negative prompt/embeddings | Supported | Rejected | +| `num_inference_steps` | Supported | Fixed at 8 for Stage 1; Stage 2 uses 3 | +| Custom `sigmas` | Supported | Rejected; both phases use fixed schedules | +| Video/audio latents | Supported | Rejected | + +These capability checks apply equally to direct `forward` keywords and +values in `OmniDiffusionSamplingParams`; unsupported values fail instead of +being silently ignored. + +### Custom Sigma Schedules + +One-stage Python requests may set final scheduler boundaries directly: + +```python +params = OmniDiffusionSamplingParams(sigmas=[1.0, 0.75, 0.5, 0.25]) +``` + +Each nonterminal value produces one denoise step, and a terminal `0.0` is +appended when omitted. This schedule overrides `num_inference_steps`. All +requests in a fused batch must use the same list. The video form API and +bundled offline CLI do not currently expose `sigmas`. + +### Constraints + +- LTX rejects non-default `timesteps`, `flow_shift`, `guidance_rescale`, + `noise_scale`, `attention_kwargs`, and `return_dict=False`. Use final + `sigmas`, modality rescale fields, startup attention configuration, and the + standard `DiffusionOutput` respectively. +- Fused one-stage requests must resolve to identical LTX guidance and sigma + schedules. Keep concurrent requests guidance-homogeneous or use + `--max-num-seqs 1`. +- `--cfg-parallel-size` shards the complete LTX guidance plan, including STG, + modality guidance, and rescale-compatible prediction gathering. + +## Operational Notes + +- LTX-2 one-stage previously loaded and peaked at about 73.5 GiB on one H200 + 141GB; remeasure on the deployed commit and hardware. +- LTX-2.3 includes a 22B transformer, Gemma encoder, two VAEs, and a vocoder. + Start on a 96GB-class GPU or use CPU/layerwise offload on smaller devices. +- The distilled upsampler uses the configured pipeline dtype and participates + in component discovery/device placement; it remains resident during denoise + offload because it is used only at the phase boundary. +- The output audio sample rate comes from the loaded components and is not a + request parameter. +- For benchmarks, use `tests/dfx/perf/tests/test_ltx2_vllm_omni.json` with + `tests/dfx/perf/scripts/run_diffusion_benchmark.py`. +- Current two-stage support is limited to the distilled checkpoint; official + LTX two-stage/HQ execution remains out of scope. ## References -- Model: -- User guide: - [`docs/user_guide/examples/online_serving/text_to_video.md`](../../docs/user_guide/examples/online_serving/text_to_video.md) -- Example guide: - [`examples/online_serving/text_to_video/README.md`](../../examples/online_serving/text_to_video/README.md) - [`examples/online_serving/image_to_video/README.md`](../../examples/online_serving/image_to_video/README.md) - -## Hardware Support - -## GPU - -### 1x H200 141GB (Text-to-Video) - -#### Environment - -- OS: Ubuntu 22.04.5 LTS -- Python: 3.12+ -- Driver / runtime: NVIDIA CUDA environment with H200 141GB GPU -- vLLM version: Match the repository requirements from your current checkout -- vLLM-Omni version or commit: Use the commit you are deploying from - -#### Command - -```bash -vllm serve Lightricks/LTX-2 \ - --omni \ - --model-class-name LTX2Pipeline -``` - -#### Verification - -```bash -curl http://localhost:8000/health - -curl -X POST http://localhost:8000/v1/videos \ - -F "prompt=A cinematic close-up of ocean waves at golden hour." \ - -F "negative_prompt=worst quality, inconsistent motion, blurry, jittery, distorted" \ - -F "width=512" \ - -F "height=768" \ - -F "num_frames=121" \ - -F "fps=24" \ - -F "num_inference_steps=40" \ - -F "guidance_scale=4.0" \ - -o ltx2_t2v_h200.mp4 -``` - -#### Notes - -- Memory usage: Model loads at ~73.5GB, peaks at ~73.5 GiB during inference. -- Key flags: - - `--model-class-name LTX2Pipeline` selects the text-to-video pipeline explicitly. -- Known limitations: - - This recipe is tested on 1x H200 validation; add measured latency and VRAM after running it on your target machine. - -### 1x H200 141GB (Image-to-Video) - -#### Environment - -- OS: Ubuntu 22.04.5 LTS -- Python: 3.12+ -- Driver / runtime: NVIDIA CUDA environment with H200 141GB GPU -- vLLM version: Match the repository requirements from your current checkout -- vLLM-Omni version or commit: Use the commit you are deploying from - -#### Command - -```bash -vllm serve Lightricks/LTX-2 \ - --omni \ - --model-class-name LTX2ImageToVideoPipeline -``` - -#### Verification - -```bash -curl http://localhost:8000/health - -curl -X POST http://localhost:8000/v1/videos/sync \ - -F "prompt=A plush toy astronaut gently waving while the camera slowly pushes in." \ - -F "input_reference=@/absolute/path/to/reference.png" \ - -F "width=512" \ - -F "height=768" \ - -F "num_frames=121" \ - -F "fps=24" \ - -F "num_inference_steps=40" \ - -F "guidance_scale=4.0" \ - -o ltx2_i2v_h200.mp4 -``` - -#### Notes - -- Memory usage: Model loads at ~73.5GB, peaks at ~73.5 GiB during inference. -- Key flags: - - `--model-class-name LTX2ImageToVideoPipeline` switches the same `Lightricks/LTX-2` weights into image-to-video mode. - - `input_reference=@...` uploads the reference image directly -- Known limitations: - - Use `image_reference` when you want to pass a URL or JSON-safe image reference -instead of uploading a file. - - Do not send `input_reference` and `image_reference` in the same request. - - This recipe is tested on 1x H200 validation; add measured latency and VRAM after running it on your target machine. +- +- +- +- [Online video generation](../../docs/user_guide/examples/online_serving/text_to_video.md) +- [Diffusion execution modes](../../docs/user_guide/diffusion/execution_modes.md) +- [T2V offline example](../../examples/offline_inference/text_to_video/text_to_video.md) +- [I2V offline example](../../examples/offline_inference/image_to_video/README.md) diff --git a/recipes/MammothModa2/MammothModa2-Preview.md b/recipes/MammothModa2/MammothModa2.md similarity index 58% rename from recipes/MammothModa2/MammothModa2-Preview.md rename to recipes/MammothModa2/MammothModa2.md index 0733b0630..7056a6f8f 100644 --- a/recipes/MammothModa2/MammothModa2-Preview.md +++ b/recipes/MammothModa2/MammothModa2.md @@ -1,19 +1,19 @@ -# MammothModa2-Preview +# MammothModa2 -> MammothModa2-Preview text-to-image generation through the shared offline image example +> MammothModa2-Preview and MammothModa2-Dev unified understanding and generation ## Summary - Vendor: ByteDance Research -- Model: `bytedance-research/MammothModa2-Preview` -- Task: Text-to-image generation (AR → DiT two-stage pipeline) +- Models: `bytedance-research/MammothModa2-Preview`, `bytedance-research/MammothModa2-Dev` +- Tasks: Preview and Dev text-to-image (AR → DiT); Dev text/image understanding - Mode: Offline inference - Maintainer: Community ## When to use this recipe -Use this recipe to run MammothModa2-Preview text-to-image through the shared -offline image example (`text_to_image.py`) instead of a model-specific script. +Use this recipe to run MammothModa2-Preview through shared task-oriented +offline examples. Text-to-image uses the shared image example (`text_to_image.py`) instead of a model-specific script. The generic example formats the AR prompt, drives the AR → DiT stage pipeline, and forwards MammothModa2-specific generation parameters through the pipeline-declared `extra_body` contract. @@ -28,8 +28,12 @@ flags. Image size uses the standard `--height` / `--width` flags. - Upstream model: [`bytedance-research/MammothModa2-Preview`](https://huggingface.co/bytedance-research/MammothModa2-Preview) +- Dev model: + [`bytedance-research/MammothModa2-Dev`](https://huggingface.co/bytedance-research/MammothModa2-Dev) - Related offline example: [`examples/offline_inference/text_to_image/text_to_image.py`](../../examples/offline_inference/text_to_image/text_to_image.py) +- Related T2T/I2T example: + [`examples/offline_inference/x_to_text/x_to_text.py`](../../examples/offline_inference/x_to_text/x_to_text.py) - Declared parameters: [`vllm_omni/model_extras/mammothmodal2_preview.py`](../../vllm_omni/model_extras/mammothmodal2_preview.py) - Deploy config: @@ -99,6 +103,25 @@ so unknown keys for MammothModa2 are silently dropped: `--height` and `--width` must be multiples of 16. +Run text-to-text through the shared understanding example. It recognizes the +MammothModa2 checkpoint and automatically selects `mammoth_moda2_ar.yaml`: + +```bash +python examples/offline_inference/x_to_text/x_to_text.py \ + --model ./MammothModa2-Preview \ + --prompt "Explain multimodal generation in three sentences." +``` + +Add an image for image-to-text or image summarization. The shared example +uses MammothModa2's chat and vision-token template: + +```bash +python examples/offline_inference/x_to_text/x_to_text.py \ + --model ./MammothModa2-Preview \ + --image /path/to/input.jpg \ + --prompt "Please summarize the content of this image." +``` + #### Verification The example writes the generated image to the `--output` path. Confirm the file @@ -108,3 +131,44 @@ exists and is a valid image: ls -lh mammoth_t2i.png python -c "from PIL import Image; print(Image.open('mammoth_t2i.png').size)" ``` + +## MammothModa2-Dev unified inference + +MammothModa2-Dev uses a Qwen3-VL AR backbone, while MammothModa2-Preview uses +Qwen2.5-VL. vLLM-Omni selects the matching implementation from the nested +`llm_config.model_type`; no checkpoint edits or `trust_remote_code` flag are +required. + +Text-to-text and image-to-text use the AR-only deploy. Text-to-image loads the +Qwen3 generation experts (`gen_mlp`), extra visual vocabulary and image head, +then sends the generated visual tokens and hidden states to the DiT stage. + +Download the checkpoint: + +```bash +hf download bytedance-research/MammothModa2-Dev --local-dir ./MammothModa2-Dev +``` + +Run text-to-text through the shared understanding example. It recognizes the +Dev checkpoint as MammothModa2 and automatically selects +`mammoth_moda2_ar.yaml`: + +```bash +python examples/offline_inference/x_to_text/x_to_text.py \ + --model ./MammothModa2-Dev \ + --prompt "Explain multimodal generation in three sentences." +``` + +Add an image for image-to-text or image summarization: + +```bash +python examples/offline_inference/x_to_text/x_to_text.py \ + --model ./MammothModa2-Dev \ + --image ./image.png \ + --prompt "Please summarize the content of this image." +``` + +The Dev checkpoint is approximately 47.55 GiB on disk. In the verified AR-only +run, loaded model weights used approximately 16.97 GiB of GPU memory before KV +and encoder caches. Allow additional GPU memory for those caches and the input +image. diff --git a/recipes/MiniMaxAI/MiniMax-H3-5090.md b/recipes/MiniMaxAI/MiniMax-H3-5090.md new file mode 100644 index 000000000..fb8b0f118 --- /dev/null +++ b/recipes/MiniMaxAI/MiniMax-H3-5090.md @@ -0,0 +1,61 @@ +# MiniMax-H3 on RTX 5090 + +This recipe uses BF16 weights, tiled VAE decode, tensor parallelism where a +second GPU is available, and distributed layerwise offload (DLO). It is a +memory-first serving configuration; lower resident counts reduce HBM use and +increase CPU-to-GPU transfer time. + +## Capacity requirements + +| Resource | One RTX 5090 | Two RTX 5090s | +| --- | ---: | ---: | +| GPU HBM | 32 GiB | 32 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. + +> **Modular H3:** after #5720 lands, preserve this recipe's one-partition +> behavior with `--task-type fl2va` or `--task-type ref2va`. + +## One RTX 5090: 1344x768, 5 seconds + +Use 12 resident DiT layers. A 50-step B300 allocation test with this exact +single-rank topology peaked at 26.50 GiB; re-measure peak HBM on the target +card before increasing the resident count. + +```bash +CUDA_VISIBLE_DEVICES=0 vllm serve /path/to/MiniMax-H3/FL2VA \ + --omni --trust-remote-code --host 0.0.0.0 --port 8000 \ + --num-gpus 1 --tensor-parallel-size 1 --text-encoder-tp-size 1 \ + --usp 1 --ring 1 --vae-patch-parallel-size 1 \ + --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 +``` + +## Two RTX 5090s: 1344x768, 5 seconds + +Use TP2 and 20 resident DiT layers. The two-rank B300 capacity run peaked at +27,726 MiB per rank for this shape and 50 steps. This is a memory/correctness +proxy, not a consumer-GPU latency claim. + +```bash +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 20 --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 video count and prompt length +can increase activation memory; begin with one request at a time. diff --git a/recipes/MiniMaxAI/MiniMax-H3-MUSA.md b/recipes/MiniMaxAI/MiniMax-H3-MUSA.md new file mode 100644 index 000000000..8d5053759 --- /dev/null +++ b/recipes/MiniMaxAI/MiniMax-H3-MUSA.md @@ -0,0 +1,146 @@ +# MiniMax H3 on Moore Threads MUSA + +> Joint video and audio generation on Moore Threads GPUs + +## Summary + +- Vendor: MiniMaxAI +- Model: [`MiniMaxAI/MiniMax-H3`](https://huggingface.co/MiniMaxAI/MiniMax-H3) +- Tasks: T2VA, FL2VA, and Ref2VA +- Mode: OpenAI-compatible `/v1/videos` HTTP serving +- Hardware: 4x Moore Threads MTT S5000 for the validated Ref2VA profile +- Maintainer: Community + +This recipe adapts [MiniMax-H3.md](MiniMax-H3.md) for MUSA environments. +The modular H3 pipeline can load both task-specific DiTs, but this MUSA recipe +uses a **single task partition**: `FL2VA` for `t2va`/`fl2va`, or `Ref2VA` for +`ref2va`. This avoids loading both DiTs. + +## Prerequisites + +### Checkpoint + +Download the partition required by the selected startup task. The commands below +run a single-task server; do not use the modular default (combined) mode on MUSA +until its two-DiT memory and performance profile is validated. + +For T2VA and FL2VA: + +```bash +python -m pip install modelscope +export MODEL_ROOT=/path/to/MiniMax-H3 +modelscope download MiniMax/MiniMax-H3 \ + --local_dir "${MODEL_ROOT}" \ + --max-workers 16 \ + --include 'FL2VA/**' +``` + +For Ref2VA, download `Ref2VA/**` instead. + +### Environment + +Install a compatible PyTorch/torch-musa, torchada, MATE, and vLLM-MUSA +stack before installing vLLM-Omni. Importing vLLM-Omni should report that the +`musa` vLLM platform plugin is active. + +Install vLLM-Omni from a checkout containing MiniMax H3 support: + +```bash +python -m pip install -e . +``` + +MiniMax H3 reference inputs and MP4 output require `soundfile`, `ffmpeg`, and +`ffprobe`. The Python dependency is installed by vLLM-Omni; install the two +executables with the operating system package manager and verify them: + +```bash +python -c 'import soundfile; print(soundfile.__version__)' +ffmpeg -version +ffprobe -version +``` + +When torchaudio cannot load TorchCodec, vLLM-Omni automatically falls back to +soundfile. Formats that libsndfile cannot read are demuxed through ffmpeg. + +## Start a server + +Use the partition path and set `--task-type` explicitly: `fl2va` for T2VA/FL2VA +or `ref2va` for Ref2VA. + +The validated Ref2VA configuration uses tensor parallelism across four MTT S5000 GPUs and offloads inactive model components to CPU: + +```bash +export MODEL="${MODEL_ROOT}/Ref2VA" +export PORT=8091 + +MUSA_VISIBLE_DEVICES=0,1,2,3 \ +VLLM_WORKER_MULTIPROC_METHOD=spawn \ +VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \ +vllm serve "${MODEL}" \ + --omni \ + --host 0.0.0.0 \ + --port "${PORT}" \ + --trust-remote-code \ + --num-gpus 4 \ + --task-type ref2va \ + --tensor-parallel-size 4 \ + --vae-patch-parallel-size 4 \ + --enable-cpu-offload \ + --vae-use-tiling \ + --diffusion-attention-backend FLASH_ATTN +``` + +On MUSA, `FLASH_ATTN` selects the MATE/FlashAttention-3 implementation. Do +not install the CUDA-only `fa4` optional dependency. + +For request examples and parameter definitions, see +[MiniMax-H3.md](MiniMax-H3.md#http-api-examples). + +## MUSA validation + +The following weight-independent gates have been validated on one MTT S5000: + +- packed variable-length attention selects MATE FlashAttention-3 and returns + finite BF16 output; +- MiniMax H3 RoPE casts CPU FP64 position metadata to FP32 before device + arithmetic and returns finite output; +- the video-VAE seeded RNG context is deterministic on MUSA and restores both + CPU and MUSA RNG states; +- the soundfile fallback preserves stereo channel layout, FP32 samples, and + the native sample rate when torchaudio/TorchCodec is unavailable. + +Ref2VA image+audio generation was also validated end to end on one MTT S5000 +with CPU offload and VAE tiling. The smoke used a 448x256 reference image, a +four-second 32 kHz stereo audio reference, two denoising steps, and seed 42. +The synchronous HTTP request completed in 50.6 seconds and returned an MP4 +with the following decoded properties: + +- 107 video frames at 448x256 and 24 FPS; +- non-static video (`pixel_std=13.07`, `temporal_delta=0.49`); +- finite stereo audio at 32 kHz (`audio_rms=0.0458`). + +Official Ref2VA video-reference serving was validated with the expanded +MiniMax prompt at 1344x768, 5 seconds, seed 0, and 50 steps. A true TP4 run on +four MTT S5000 GPUs returned HTTP 200 in 638.6 seconds and produced 124 H.264 +frames at 24 FPS with AAC stereo audio at 32 kHz. + +Keep `--vae-use-tiling` enabled for this serving profile. + +## Known limitations + +- Keep Ring Attention at degree 1. The current Ring path does not preserve + MiniMax H3 packed padding boundaries. +- H3 is CFG-distilled, so `--cfg-parallel-size` must remain 1. +- This MUSA recipe uses an explicitly selected single task partition; combined + serving loads both DiTs. +- H3 currently executes one generation request per diffusion batch. +- FP8 quantization has not been enabled for MiniMax H3. +- MP3, M4A, MP4, and reference-video audio fallback requires `ffmpeg` on + `PATH`; WAV inputs can be read directly through soundfile. + +## Additional resources + +- [MiniMax-H3.md](MiniMax-H3.md) — full model and API guide +- [MiniMax-H3-NPU.md](MiniMax-H3-NPU.md) — Ascend NPU deployment guide +- [Supported models](../../docs/models/supported_models.md) +- [Video API](../../docs/serving/videos_api.md) diff --git a/recipes/MiniMaxAI/MiniMax-H3-NPU.md b/recipes/MiniMaxAI/MiniMax-H3-NPU.md new file mode 100755 index 000000000..930411f1a --- /dev/null +++ b/recipes/MiniMaxAI/MiniMax-H3-NPU.md @@ -0,0 +1,187 @@ +# MiniMax H3 on Ascend NPU + +> Joint video and audio generation with text, first/last keyframes, and mixed +> image/video/audio references — Ascend NPU deployment guide + +## Summary + +- Vendor: MiniMaxAI +- Model: [`MiniMaxAI/MiniMax-H3`](https://huggingface.co/MiniMaxAI/MiniMax-H3) +- Tasks: T2VA, FL2VA, and Ref2VA +- Mode: OpenAI-compatible `/v1/videos` HTTP serving +- Hardware: Atlas 800I A3 +- Maintainer: Community + +This recipe adapts [MiniMax-H3.md](MiniMax-H3.md) for Ascend NPU +environments. Differences from the GPU path: + +- Runs on a CPU-only (aarch64) PyTorch build with `torch_npu`; no CUDA + runtime is present. +- Audio loading does **not** require TorchCodec (whose aarch64 wheels are + built against CUDA torch and fail to load on CPU-only builds). vLLM-Omni + automatically falls back to soundfile / ffmpeg for wav/mp3/m4a/mp4 audio + inputs. + +## Prerequisites + +### Checkpoint + +Same as the GPU recipe — Hugging Face access approval is required. Authenticate +once; `vllm serve` downloads the required nested components automatically: + +```bash +hf auth login +export MODEL=MiniMaxAI/MiniMax-H3 +``` + +### Environment + +- Ascend driver & firmware: 25.5.1 +- CANN toolkit: 9.0.1 +- Python: 3.12 +- PyTorch: 2.10.0+cpu +- torch_npu: 2.10.0.post2 +- Install vLLM-Omni from a checkout with MiniMax H3 support: + +```bash +uv venv +source .venv/bin/activate +uv pip install -e . +``` + +Install the **mindie-sd** operator library to enable Ascend-optimized fused +operators (`adalayernorm`, etc.) and the RainFusion `rf_v2` block-sparse +attention kernel: + +```bash +git clone https://gitcode.com/Ascend/MindIE-SD.git && cd MindIE-SD + +# Comment out the tik_ops build step (not needed for this use case) +sed -i 's|^\(\s*\)source ${current_script_dir}/build_tik_ops.sh|\1# source ${current_script_dir}/build_tik_ops.sh|' build/build_ops.sh + +python setup.py bdist_wheel +cd dist +pip install mindiesd-*.whl +``` + +- `ffmpeg` and `ffprobe` must be available on `PATH`. They are used for + reference-video preparation and MP4 output. +- Reference-video decoding uses `decord` when available and falls back to + PyAV otherwise. +- Audio inputs do not require TorchCodec: wav/mp3/m4a/mp4 files are loaded + at native sample rate through the soundfile / ffmpeg fallback. + +## Start a server + +Pass the repository ID directly. The pipeline loads the two nested DiTs while +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: + +```bash +export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7 +export PORT=9098 +export MODEL=MiniMaxAI/MiniMax-H3 +export VLLM_WORKER_MULTIPROC_METHOD=spawn +export VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 +export PYTHONDONTWRITEBYTECODE=1 + +vllm serve "${MODEL}" \ + --omni \ + --host 0.0.0.0 \ + --port "${PORT}" \ + --trust-remote-code \ + --num-gpus 8 \ + --usp 8 \ + --ring 1 \ + --text-encoder-tp-size 8 \ + --enable-layerwise-offload \ + --vae-parallel-mode tile \ + --vae-use-tiling \ + --vae-patch-parallel-size 8 \ + --diffusion-attention-backend FLASH_ATTN +``` + +Do not add `--enforce-eager`. The first request includes regional +compilation; warm the server once before measuring steady-state latency. +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. + +### Optional optimizations + +Two independent optimizations may be enabled on top of the configuration +above. Both are validated for T2VA only. + +**RainFusion block-sparse attention** — switch the attention backend, keeping +every other flag unchanged: + +```bash + --diffusion-attention-backend RAINFUSION_ATTN +``` + +**INT8 online quantization** — add one flag to the server command above: + +```bash + --quantization int8 +``` + +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. + +## HTTP API examples + +The request format is identical to the GPU recipe; see +[MiniMax-H3.md § HTTP API examples](MiniMax-H3.md#http-api-examples). +Use the validated 768P shapes (e.g. `width=1344 height=768`) on NPU. + +## Key parameters + +Same as the GPU recipe; see +[MiniMax-H3.md § Key parameters](MiniMax-H3.md#key-parameters). +The validated resolution on NPU is 768P (e.g. 1344x768). + +## Validated NPU evidence + +Measured on an Atlas 800I A3 server (8x NPU) with CANN 9.0.1, +PyTorch 2.10.0+cpu, and torch_npu 2.10.0.post2, using the multi-NPU +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 | + +These measurements describe the validated shapes rather than a general +throughput guarantee. + +## Known limitations + +- Combined serving requires sibling `FL2VA` and `Ref2VA` directories, loads + both task-specific DiTs, and loads one copy of every shared component. +- H3 currently executes one generation request per diffusion batch. +- The first regional-compile request is a warmup and should not be included + in steady-state performance measurements. +- The official H3 input matrix and media limits are documented in the [GPU + recipe](MiniMax-H3.md#official-input-matrix-and-limits); this NPU path uses + the same HTTP request contract. +- VAE patch parallelism requires size 1 or the full DiT group size and + supports the H3 native `tile` mode only. +- RainFusion block-sparse attention and INT8 quantization are validated for + T2VA only; use the BF16 dense configuration for FL2VA and Ref2VA. +- Online quantization cannot be combined with distributed layerwise offload + while AllGather is enabled; pass `--dlo-no-use-allgather` in that case. + +## Additional resources + +- [MiniMax-H3.md](MiniMax-H3.md) — full GPU guide +- [Attention backends § RAINFUSION_ATTN](../../docs/user_guide/diffusion/attention_backends.md#rainfusion_attn-backend-and-block-sparse-video-attention) + — RainFusion knobs and tuning +- [Int8 quantization](../../docs/user_guide/quantization/int8.md) +- [Supported models](../../docs/models/supported_models.md) +- [Video API](../../docs/serving/videos_api.md) diff --git a/recipes/MiniMaxAI/MiniMax-H3.md b/recipes/MiniMaxAI/MiniMax-H3.md new file mode 100644 index 000000000..cfe9b5796 --- /dev/null +++ b/recipes/MiniMaxAI/MiniMax-H3.md @@ -0,0 +1,639 @@ +# MiniMax H3 + +> Joint video and audio generation with text, first/last keyframes, and +> mixed image/video/audio references + +## Summary + +- Vendor: MiniMaxAI +- Model: [`MiniMaxAI/MiniMax-H3`](https://huggingface.co/MiniMaxAI/MiniMax-H3) +- Tasks: T2VA, FL2VA, and Ref2VA +- Mode: OpenAI-compatible `/v1/videos` HTTP serving +- Maintainer: Community + +MiniMax H3 is a CFG-distilled joint video/audio diffusion transformer. Its +checkpoint has two task-specific DiT partitions: + +- `FL2VA`: text-to-video+audio (`t2va`) and first-frame-to-video+audio + (`fl2va`) +- `Ref2VA`: up to 9 images, 3 videos, and 3 audio references in supported + image/video/audio combinations (`ref2va`; audio-only is rejected) + +One vLLM-Omni diffusion stage can load both DiTs while instantiating the +tokenizer, processor, Qwen3-VL text encoder, video VAE, and audio VAE only +once. Requests select the DiT with `extra_params.task`. + +The generated MP4 contains H.264 video and synchronized stereo audio. + +## Prerequisites + +The checkpoint requires Hugging Face access approval. Authenticate once; +`vllm serve` downloads the required components automatically: + +```bash +hf auth login +export MODEL=MiniMaxAI/MiniMax-H3 +``` + +The vLLM-Omni pipeline downloads `FL2VA/**`, `Ref2VA/model_index.json`, and +`Ref2VA/transformer/**`. It does not download or load the diffusers-format +`transformer`, `transformer_ref`, or `vae` weights at the repository root, nor +duplicate Ref2VA copies of shared components. + +Install vLLM-Omni from the checkout containing MiniMax H3 support. The +two-GPU RTX 5090/4090 profiles use cuDNN attention and do not need +FlashAttention-4. Install the optional dependency only for the four-GPU +B300/GB200 `FLASH_ATTN` profile: + +```bash +uv venv +source .venv/bin/activate +uv pip install -e . +``` + +To keep FA4 available as an explicit option on Blackwell, install the +FlashAttention-4 extra: + +```bash +uv pip install -e '.[fa4]' +``` + +`ffmpeg` and `ffprobe` must be available on `PATH`. They are used for +reference-video preparation and MP4 output. + +## Start a server + +Pass the repository ID directly. The pipeline uses `FL2VA` for model discovery +and shared components, and loads the second DiT from `Ref2VA/transformer`. + +### Memory and storage requirements + +Treat GPU HBM, host RAM, and checkpoint storage as separate requirements. Each +H3 checkpoint partition (`FL2VA` or `Ref2VA`) contains about **134 GiB** of +BF16 safetensors (about **135 GiB** on disk). Keeping both partitions +locally therefore needs roughly **270 GiB** of model storage. A combined +service downloads both; `--task-type fl2va` or `--task-type ref2va` downloads +only the selected partition. + +CPU offload and distributed layerwise offload reduce GPU residency; they do +not make the model weights disappear. With `--dlo-no-use-allgather`, each +worker retains its standard-loader rank-local weights in host memory, including +pinned CPU buffers used for H2D streaming. Use at least **200 GiB available +system RAM** before starting the two-GPU recipe; a **384 GiB host is +recommended** to leave room for the OS, CUDA/PyTorch allocations, request +inputs, and filesystem cache. Do not run the FL2VA and Ref2VA servers at the +same time on a host sized for this minimum. + +The consumer-GPU profiles below are HBM budgets only. They still require the +host-RAM budget above. + +### Single GPU: accuracy and memory first + +The single-GPU configuration uses model-level CPU offload. +This matches the accuracy-qualified reference path and prevents the Qwen3-VL +encoder and DiT from being resident on the GPU at the same time. + +```bash +export MODEL=MiniMaxAI/MiniMax-H3 +export PORT=8091 + +CUDA_VISIBLE_DEVICES=0 \ +VLLM_WORKER_MULTIPROC_METHOD=spawn \ +VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \ +vllm serve "${MODEL}" \ + --omni \ + --host 0.0.0.0 \ + --port "${PORT}" \ + --trust-remote-code \ + --num-gpus 1 \ + --enable-cpu-offload \ + --diffusion-attention-backend FLASH_ATTN +``` + +Use a GPU with enough memory for the active H3 component and enough system RAM +for both offloaded DiTs plus the shared components. Model-level offload keeps +the two DiTs mutually exclusive on GPU, but adds PCIe/NVLink transfer latency. + +### Two 24/32 GB GPUs: TP2 distributed layerwise offload + +For two PCIe consumer GPUs, combine TP2 with distributed layerwise offload +(DLO). The standard loader first creates the rank-local TP shard. DLO keeps +that shard in pinned host memory and streams the 30 tail DiT blocks through a +shared two-buffer window without a DP AllGather. The first 20 DiT blocks are +copied to the GPUs once per denoise stage, reused by every sampling step, and +released before VAE decode so the decoder can reuse their HBM. + +```bash +export MODEL=MiniMaxAI/MiniMax-H3 +export PORT=8091 + +CUDA_VISIBLE_DEVICES=0,1 \ +VLLM_WORKER_MULTIPROC_METHOD=spawn \ +VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400 \ +vllm serve "${MODEL}" \ + --omni \ + --host 0.0.0.0 \ + --port "${PORT}" \ + --trust-remote-code \ + --task-type fl2va \ + --num-gpus 2 \ + --tensor-parallel-size 2 \ + --usp 1 \ + --ring 1 \ + --text-encoder-tp-size 2 \ + --vae-patch-parallel-size 2 \ + --vae-parallel-mode tile \ + --vae-use-tiling \ + --enable-distributed-layerwise-offload \ + --dlo-no-use-allgather \ + --dlo-resident-layers 20 \ + --enforce-eager \ + --diffusion-attention-backend CUDNN_ATTN +``` + +Use the profile that matches the per-GPU memory capacity: + +| Profile | GPUs | Starting shape | Resident DiT blocks | Attention | Execution | Status | +|---|---:|---:|---:|---|---|---| +| `rtx5090` | 2 x 32 GB | 1344x768 | 20 | cuDNN attention | eager | Target-hardware validated | +| `rtx4090` | 2 x 24 GB | 1024x576 | 12 | cuDNN attention | eager | Capacity-proxy starting point | + +This topology uses all available parallel capacity: TP2 shards both the DiT +and text encoder, `--dlo-no-use-allgather` streams each rank's local TP shard +without reconstructing full blocks, and VAE patch parallelism splits tiled +decode across both GPUs. cuDNN attention is selected explicitly for the RTX +consumer path; the server stays eager to avoid an unqualified compile path. + +The resident count changes placement and transfer frequency only; it does not +quantize or change the BF16/FP32 denoise math. Re-measure peak memory before +increasing it on a different request shape. + +### RTX 5090 target-hardware validation + +At vLLM-Omni commit `ae6577ea`, one full 50-step T2VA request completed on +2 x RTX 5090 without OOM: + +| Shape | Frames | Client E2E | Sampled peak/GPU | Output validation | +|---:|---:|---:|---:|---| +| 1344x768 | 124 at 24 FPS | 8 min 38 s | approximately 22.6 GiB | H.264 video + 32 kHz stereo AAC; full `ffmpeg` decode passed | + +This is a single end-to-end validation run, not a warmed multi-run latency +benchmark. The sampled `nvidia-smi` peak is also not a CUDA allocator +high-water mark. The environment used vLLM 0.26.0, vLLM-Omni +`0.26.1.dev14+gae6577ea`, and PyTorch 2.11.0+cu130. The +[run record](https://github.com/lishunyang12/vllm-omni-rankings/blob/dcd06d7e83cb069842535918c0169ee9f3f29ba0/scripts/%E5%BE%AE%E4%BF%A1%E5%9B%BE%E7%89%87_20260805000034_86_237.png) +captures the environment, output contract, elapsed time, and sampled peak. + +Before the target run, both profiles were exercised on two B300 ranks as an +allocation and correctness proxy. At 1344x768, 124 frames, and 50 steps, the +20-layer profile peaked at 27,726 MiB per rank. At 1024x576, the 12-layer +profile peaked at 18,888 MiB per rank in a 5-step capacity run. The resident +and fully streamed placements produced identical decoded video-frame and audio +hashes for the same shape, step count, prompt, and seed. The B300 result does +not establish RTX 4090 PCIe latency; treat the 4090 profile as a conservative +starting point until it is measured on that GPU. + +To run T2VA, FL2VA, image+audio Ref2VA, and two-video Ref2VA in order, validate +every MP4's H.264/AAC streams, and retain live server and GPU-memory logs: + +```bash +RUN_ROOT=/path/to/run-root \ +MODEL_ROOT=/path/to/MiniMax-H3 \ +GPU_IDS=0,1 \ +PROFILE=rtx5090 \ +bash examples/offline_inference/minimax_h3/run_h3_2gpu_all_tasks.sh +``` + +The script selects 20 resident layers for `PROFILE=rtx5090` and 12 for +`PROFILE=rtx4090`; `DLO_RESIDENT_LAYERS=N` overrides either default. + +### Four GPUs: throughput-oriented combined service + +For a combined service on four high-memory GPUs, use: + +- no CPU or layerwise offload; +- Ulysses sequence parallelism degree 4; +- native tiled VAE patch parallelism degree 4; +- regional `torch.compile` for the repeated DiT blocks; +- dense BF16 `TRTLLM_ATTN`, with Ring and TP left at 1. + +Both DiTs remain resident in this no-offload configuration. If they do not fit, +use model-level CPU offload. + +```bash +export MODEL=MiniMaxAI/MiniMax-H3 +export PORT=8091 + +CUDA_VISIBLE_DEVICES=0,1,2,3 \ +VLLM_WORKER_MULTIPROC_METHOD=spawn \ +VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \ +vllm serve "${MODEL}" \ + --omni \ + --host 0.0.0.0 \ + --port "${PORT}" \ + --trust-remote-code \ + --num-gpus 4 \ + --usp 4 \ + --ring 1 \ + --vae-patch-parallel-size 4 \ + --vae-parallel-mode tile \ + --vae-use-tiling +``` + +Do not add `--enforce-eager` to this performance configuration. The first +request includes regional compilation; warm the server once before measuring +steady-state latency. H3 is CFG-distilled, so `--cfg-parallel-size` must remain +1. The H3 VAE supports its native `tile` mode, not +`spatial_shard_height` or `spatial_shard_width`. + +### Attention Backends + +On datacenter Blackwell GPUs, MiniMax H3 defaults to dense BF16 +`TRTLLM_ATTN`; no attention backend flag is required. To select it explicitly, +use: + +```bash +--diffusion-attention-backend TRTLLM_ATTN +``` + +Stable measurements with the four-GPU profile above put dense `TRTLLM_ATTN` +and FA4 within 2% of each other. `TRTLLM_ATTN` remains the datacenter Blackwell +default and enables the optional optimizations below. Confirm the server log +contains `Defaulting to diffusion attention backend TRTLLM_ATTN` before +recording measurements when using the default selection. + +FA4 remains available by explicitly selecting the `FLASH_ATTN` backend: + +```bash +--diffusion-attention-backend FLASH_ATTN +``` + +On Blackwell, `FLASH_ATTN` selects FA4. Confirm the server log contains +`Using CuTe FlashAttention-4 on Blackwell` before recording FA4 measurements. + +`TRTLLM_ATTN` additionally supports two **lossy** optimizations for the long main +DiT attention sequence: SAGE attention quantization and Skip-Softmax Sparse +Attention. SAGE quantizes Q/K to the configured dtype and V to FP8. This example uses +`fp8_e4m3` for Q/K; B200 also supports `int8` Q/K. The TRTLLM SAGE path fixes V +to FP8, so vLLM-Omni only exposes the Q/K dtype. The token refiner is a short +attention path, so the `per_role` override leaves SAGE and Skip-Softmax disabled +for it. The example enables the calibration-free Skip-Softmax path with +`threshold=0.05`, after the normalized timestep reaches `0.97`: + +```bash +--diffusion-attention-config '{ + "default": { + "backend": "TRTLLM_ATTN", + "quant": { + "dtype_qk": "fp8_e4m3", + "q_block_size": 1, + "k_block_size": 16 + }, + "skip_softmax": { + "threshold": 0.05, + "disabled_until_timestep": 0.97 + } + }, + "per_role": { + "minimax_h3.token_refiner": { + "backend": "TRTLLM_ATTN" + } + } +}' +``` + +For configuration details, see +[TRTLLM_ATTN Backend and Skip-Softmax](https://github.com/vllm-project/vllm-omni/blob/main/docs/user_guide/diffusion/attention_backends.md#trtllm_attn-backend-and-skip-softmax) +and +[TRTLLM_ATTN SAGE Quantization](https://github.com/vllm-project/vllm-omni/blob/main/docs/user_guide/diffusion/attention_backends.md#trtllm_attn-sage-quantization). + +### Text encoder tensor parallelism + +The Qwen3-VL text encoder (~51.5 GB in BF16 for the retained 50 layers) is by +default fully resident on the DiT main rank. On multi-GPU no-offload runs that +rank becomes the peak-memory hotspot. Add `--text-encoder-tp-size N` to shard +the encoder across the first `N` DiT ranks (the encoder is implemented with +vLLM-style tensor-parallel layers and runs with distributed collectives over +its own encoder process group): + +```bash +export MODEL=MiniMaxAI/MiniMax-H3 +export PORT=8091 + +CUDA_VISIBLE_DEVICES=0,1,2,3 \ +VLLM_WORKER_MULTIPROC_METHOD=spawn \ +VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \ +vllm serve "${MODEL}" \ + --omni \ + --host 0.0.0.0 \ + --port "${PORT}" \ + --trust-remote-code \ + --num-gpus 4 \ + --usp 4 \ + --ring 1 \ + --text-encoder-tp-size 4 \ + --vae-patch-parallel-size 4 \ + --vae-parallel-mode tile \ + --vae-use-tiling +``` + +`N` must divide the Qwen3-VL head counts (64 attention heads / 8 KV heads), so +valid values on a 4-GPU server are 1, 2, and 4 (1, 2, 4, 8 on 8 GPUs). The +encoder TP rank set is the first `N` DiT ranks; on 4 GPUs +`--text-encoder-tp-size 4` shards the encoder 4-way, dropping the DiT main +rank's no-offload peak by roughly `(N-1)/N` of the ~51.5 GB encoder while the +other ranks each gain `~51.5/N` GB. The encoder output remains identical to +the reference path within bf16 rounding: every encoder rank all-reduces the +row-parallel projections, so the full `[seq, 5120]` layer-50 hidden state is +replicated on every rank. + +No restart is needed: `task=fl2va` routes to `FL2VA/transformer`, while +`task=ref2va` routes to +`Ref2VA/transformer`. T2VA uses the FL2VA DiT. + +### Online FP8 quantization + +MiniMax H3 supports load-time FP8 quantization of the DiT. The checkpoint +remains BF16 on disk; vLLM-Omni quantizes eligible weights while loading and +uses dynamic activation scaling during inference. By default, attention and +MLP linears in the token refiner and main DiT blocks, the condition +projection, and all AdaLN projections use FP8. Patch, timestep, and final +projections remain FP32; the text encoder and VAEs are unchanged. + +Add this option to an existing H3 server command: + +```bash +--quantization fp8 +``` + +Use `ignored_layers` to keep any otherwise eligible linear in BF16. H3 +resolves the `transformer` component before constructing the DiT, so names do +not start with `transformer.`. Entries are exact runtime linear prefixes; a +parent name such as `blocks.0.attn` does not exclude its children. + +Eligible names are the `attn.qkv_proj`, `attn.out_proj`, `mlp.fc1`, and +`mlp.fc2` children under `token_refiner.blocks.<0-1>` or `blocks.<0-49>`. +The other eligible names are `condition_proj`, +`blocks.<0-49>.adaln_proj.linear`, and `final_layer.adaln_proj.linear`. +For example, keep the first main block's attention projections in BF16 with: + +```bash +--diffusion-quantization-config \ + '{"transformer":{"method":"fp8","ignored_layers":["blocks.0.attn.qkv_proj","blocks.0.attn.out_proj"]}}' +``` + +The structured option replaces `--quantization fp8`. Online FP8 is currently +incompatible with H3 layerwise offload because the offload path produces a +weight stride rejected by the Cutlass FP8 kernel. Use resident FP8 with tensor +parallelism and VAE tiling instead. + +## HTTP API examples + +The following requests use the synchronous endpoint so the returned body can +be saved directly as an MP4. The asynchronous `POST /v1/videos` endpoint can +also be used when job polling is preferred. + +All four tasks use 24 FPS, 50 sigma points, seed values from the validated +workloads, and the checkpoint-reference video/audio flow shifts of 12 and 3. +Decimal durations are passed through `extra_params`. + +Set the endpoint once: + +```bash +export API_URL="http://127.0.0.1:${PORT}/v1/videos/sync" +``` + +### 1. T2VA: text to video and audio + +Run this request against the combined service: + +```bash +curl -sS -X POST "${API_URL}" \ + -F 'prompt=In a snowy blue-purple forest, Ori carefully walks past a sleeping giant; footsteps crunch in the snow while the creature breathes and softly snorts.' \ + -F 'width=1344' \ + -F 'height=768' \ + -F 'aspect_ratio=16:9' \ + -F 'fps=24' \ + -F 'num_inference_steps=50' \ + -F 'flow_shift=12' \ + -F 'seed=1101' \ + -F 'extra_params={"task":"t2va","duration":8.7,"audio_flow_shift":3.0}' \ + -o t2va.mp4 +``` + +### 2. FL2VA: first frame to video and audio + +Run this request against the combined service. When width and height are +omitted, H3 preserves the first-frame aspect ratio and uses a 768-pixel short +edge. + +```bash +export FIRST_FRAME=/path/to/fl2va_first_frame.png + +curl -sS -X POST "${API_URL}" \ + -F 'prompt=A man stands beside a yellow car at night. The car drives away; he follows it with his eyes and begins singing sadly, with synchronized voice and city ambience.' \ + -F 'fps=24' \ + -F 'num_inference_steps=50' \ + -F 'flow_shift=12' \ + -F 'seed=2101' \ + -F 'extra_params={"task":"fl2va","duration":8.7,"audio_flow_shift":3.0}' \ + -F "input_reference=@${FIRST_FRAME};type=image/png" \ + -o fl2va.mp4 +``` + +Use the same `FL2VA` partition for the official tail-keyframe forms. A single +image with `frame_indices=[-1]` conditions the last frame; two ordered images +with `frame_indices=[0,-1]` condition the first and last frames: + +```bash +export LAST_FRAME=/path/to/fl2va_last_frame.png +export FIRST_FRAME=/path/to/fl2va_first_frame.png + +curl -sS -X POST "${API_URL}" \ + -F 'prompt=The subject moves naturally from the first image to the last image.' \ + -F 'num_inference_steps=50' \ + -F 'flow_shift=12' \ + -F 'seed=2102' \ + -F 'extra_params={"task":"fl2va","duration":8.7,"frame_indices":[0,-1],"audio_flow_shift":3.0}' \ + -F "input_references=@${FIRST_FRAME};type=image/png" \ + -F "input_references=@${LAST_FRAME};type=image/png" \ + -o fl2va_first_last.mp4 +``` + +### 3. Ref2VA: image-only, image/audio, or mixed references + +Run these requests against the combined service or a Ref2VA-only service. +Image-only Ref2VA omits `audio_reference`; adding one or more audio references +is optional. The typed fields accept one object or an ordered JSON list. +`audio_reference` accepts an HTTP(S) URL or a `data:` URL. In one terminal, +expose the local reference assets to the serving host: + +```bash +python -m http.server 8092 \ + --bind 127.0.0.1 \ + --directory /path/to/reference_assets +``` + +Then submit an image-only request from another terminal: + +```bash +export REF_IMAGE=/path/to/reference_assets/ref2va_image.png + +curl -sS -X POST "${API_URL}" \ + -F 'prompt=A white cat sits on a beige couch and slowly looks toward the camera.' \ + -F 'aspect_ratio=adaptive' \ + -F 'short_edge=768' \ + -F 'num_inference_steps=50' \ + -F 'flow_shift=12' \ + -F 'seed=3100' \ + -F 'extra_params={"task":"ref2va","duration":8.0,"audio_flow_shift":3.0}' \ + -F "input_reference=@${REF_IMAGE};type=image/png" \ + -o ref2va_image_only.mp4 +``` + +An image-plus-audio request is: + +```bash +export REF_IMAGE=/path/to/reference_assets/ref2va_image.png +export AUDIO_URL=http://127.0.0.1:8092/ref2va_audio.mp3 + +curl -sS -X POST "${API_URL}" \ + -F 'prompt=A white cat with black mustache and eyebrow markings sits on a beige couch, lip-syncing precisely to the complete reference audio before shifting from confusion to deadpan speechlessness.' \ + -F 'width=1344' \ + -F 'height=768' \ + -F 'fps=24' \ + -F 'num_inference_steps=50' \ + -F 'flow_shift=12' \ + -F 'seed=3101' \ + -F 'extra_params={"task":"ref2va","duration":15.0,"audio_flow_shift":3.0}' \ + -F "input_reference=@${REF_IMAGE};type=image/png" \ + -F "audio_reference={\"audio_url\":\"${AUDIO_URL}\"}" \ + -o ref2va_image_audio.mp4 +``` + +The requested duration should cover the complete audio. If `duration` is +shorter, the reference soundtrack is truncated to the generated clip. + +### 4. Ref2VA: video, separate audio, and mixed references + +Run this request against the combined service. Repeat the +`input_references` multipart field once per source video. H3 consumes the +videos in form order and preserves their original soundtracks during +conditioning. + +```bash +export SUBJECT_VIDEO=/path/to/green_screen_subject.mp4 +export BACKGROUND_VIDEO=/path/to/fairytale_background.mov + +curl -sS -X POST "${API_URL}" \ + -F 'prompt=Remove the green screen background of Video 1 and replace it with the fairytale environment from Video 2. Match the background motion to the character actions and relight the character to fit the scene.' \ + -F 'width=1344' \ + -F 'height=768' \ + -F 'fps=24' \ + -F 'num_inference_steps=50' \ + -F 'flow_shift=12' \ + -F 'seed=3101' \ + -F 'extra_params={"task":"ref2va","duration":15.0,"audio_flow_shift":3.0}' \ + -F "input_references=@${SUBJECT_VIDEO};type=video/mp4" \ + -F "input_references=@${BACKGROUND_VIDEO};type=video/quicktime" \ + -o ref2va_video_video.mp4 +``` + +The server stores uploaded references only for the lifetime of the request and +deletes temporary files after generation. A video may use its embedded +soundtrack, a separate `audio_reference`, or both. To send a mixed multipart +request, repeat `input_references` for each image, video, or audio file; the +server classifies them by MIME type and preserves the per-type order. + +Reference videos must be MP4/MOV with H.264/H.265 video, optional AAC/MP3 +audio, 2–15 seconds each, and at most 15 seconds combined. They may still be +longer than the generated clip. Use `start_time_seconds` to select a +synchronized segment; for multiple typed video references, pass one value per +video in `extra_params.start_time_seconds`. + +Reference images accept JPG/JPEG, PNG, WEBP, HEIC, or HEIF up to 30 MiB. Standalone +audio references accept WAV or MP3 up to 15 MiB, with 2–15 seconds per file and +at most 15 seconds combined. + +## Official input matrix and limits + +| Task | Supported references | Limits | +|------|----------------------|--------| +| T2VA | text only | prompt must be non-empty | +| FL2VA | first image, last image, or ordered first+last images | at most 2 images; `frame_indices` is `[0]`, `[-1]`, or `[0,-1]` | +| Ref2VA | image-only, image+image, image+video, video+audio, and mixed image/video/audio | images ≤9, videos ≤3, audios ≤3, total references ≤12; audio requires a visual reference | + +The H3 output contract is 4–15 seconds at 24 FPS, stereo 32 kHz audio, and a +32-pixel canvas multiple. T2VA requires one named output ratio from `21:9`, +`16:9`, `4:3`, `1:1`, `3:4`, or `9:16`. FL2VA always follows the first input +image's ratio and ignores a generic `aspect_ratio` override. Ref2VA defaults to +`16:9`; `adaptive` and SGLang's `auto` spelling are accepted aliases for that +default. `short_edge` controls the 768-pixel canvas and must be `768`. +`num_outputs_per_prompt` accepts 1–10 and derives each output seed as +`seed + output_index`. The asynchronous endpoint returns all +outputs; the synchronous raw-MP4 endpoint returns the first output when more +than one is requested. + +## Key parameters + +| Parameter | Recommended value | Notes | +|-----------|-------------------|-------| +| `task` | `t2va`, `fl2va`, or `ref2va` | Passed in `extra_params`; selects the task-specific DiT | +| `duration` | Workload-specific | Decimal seconds in `extra_params`; converted to H3-compatible frame count | +| `fps` | `24` | H3 output FPS is fixed | +| `num_inference_steps` | `50` | Matches the reference accuracy workloads | +| `flow_shift` | `12` | Video sigma shift | +| `audio_flow_shift` | `3` | Audio sigma shift, passed in `extra_params` | +| `seed` | Task-specific | Use a fixed value for reproducibility | +| `aspect_ratio` | Task-specific | T2VA requires a named ratio; FL2VA follows the input image; Ref2VA defaults to `16:9` | +| `short_edge` | `768` | H3 shape policy requires exactly 768 when `width`/`height` are omitted | +| `num_outputs_per_prompt` | `1` | 1–10; async API returns every output | +| `start_time_seconds` | `0` | Reference-video segment start; use a list in `extra_params` for multiple videos | +| `width`, `height` | Multiples of 32 | Output aspect ratio must be between 1:4 and 4:1 | + +## ComfyUI Frontend + +Users can also use a ComfyUI frontend to interact with a hosted MiniMax-H3 service. The ComfyUI frontend can run in a separate environment or machine. Refer to [vLLM-Omni ComfyUI Integration](../../docs/features/comfyui.md) for details. + +## Validated four-GPU evidence + +The four-GPU recommendation was measured on four NVIDIA B300 GPUs with one +excluded warmup followed by three requests. + +| Workload | Configuration | Observed result | +|----------|---------------|-----------------| +| FL2VA, 209 frames, 1248x768 | no offload, U4, VPP4 tile, regional compile | 86.964 s mean HTTP client latency | +| Two-video Ref2VA, 362 frames, 1344x768 | no offload, U4, VPP4 tile, regional compile | 784.394 s accounted model-stage mean | + +These measurements describe the validated shapes rather than a general +throughput guarantee. Multi-video Ref2VA is much slower because the two +reference videos expand both the Qwen3-VL vision sequence and the packed DiT +attention sequence. + +## Validated FP8 evidence + +With eager DiT/text-encoder TP2 and VAE tiling, the 384x672, 107-frame, +10-step quality case measured LPIPS 0.1156 (limit 0.20), PSNR 23.6316 dB, +audio spectral cosine 0.9589 (minimum 0.80), and audio RMS ratio 0.9342. The +resident per-GPU peak was 68.52 GiB for BF16 and 53.51 GiB for FP8, a 22% +reduction. + +## Known limitations + +- Combined serving requires sibling `FL2VA` and `Ref2VA` directories, loads + both task-specific DiTs, and loads shared components once from `FL2VA`. +- H3 currently executes one generation request per diffusion batch. +- The first regional-compile request is a warmup and should not be included in + steady-state performance measurements. +- Online FP8 is not compatible with layerwise offload. +- Image+audio Ref2VA accepts exactly one image and one audio reference. +- Video Ref2VA accepts one or more video files, but not an additional standalone + audio reference. +- VAE patch parallelism requires size 1 or the full DiT group size and supports + the H3 native `tile` mode only. + +## Additional resources + +- [Supported models](../../docs/models/supported_models.md) +- [Video API](../../docs/serving/videos_api.md) +- [Diffusion parallelism](../../docs/user_guide/diffusion/parallelism/overview.md) diff --git a/recipes/NVIDIA/GR00T-N1.7.md b/recipes/NVIDIA/GR00T-N1.7.md index 91f676a52..0a5270222 100644 --- a/recipes/NVIDIA/GR00T-N1.7.md +++ b/recipes/NVIDIA/GR00T-N1.7.md @@ -69,8 +69,13 @@ Notes: ## Verification ```python -from tests.gr00t.openpi_client_helper import run_policy_session, validate_session_result -validate_session_result(run_policy_session(host="127.0.0.1", port=8000)) +from tests.helpers.runtime import OpenAIClientHandler + +handler = OpenAIClientHandler(host="127.0.0.1", port=8000, log_stats=False) +response = handler.send_robot_openpi_ws_request( + {"run_default_policy_session": True, "session_id": "gr00t-smoke"} +)[0] +assert response.operation_responses[-1]["status"] == "reset successful" ``` Or run the e2e test suite: diff --git a/recipes/NVIDIA/Nemotron-Labs-Audex.md b/recipes/NVIDIA/Nemotron-Labs-Audex.md new file mode 100644 index 000000000..21ee7e655 --- /dev/null +++ b/recipes/NVIDIA/Nemotron-Labs-Audex.md @@ -0,0 +1,142 @@ +# Nemotron-Labs Audex (2B / 30B-A3B) + +> Speech suite on 1×H100: TTS, text-to-audio, audio understanding, and cascaded speech-to-speech + +## Summary + +- Vendor: NVIDIA +- Model: `nvidia/Nemotron-Labs-Audex-2B`, `nvidia/Nemotron-Labs-Audex-30B-A3B` +- Task: Text-to-speech (English), text-to-audio (sound effects), audio understanding (ASR / audio QA), cascaded speech-to-speech +- Mode: Online serving (`/v1/audio/speech`, `/v1/chat/completions`) and offline batch inference +- Maintainer: Community + +## When to use this recipe + +Use this recipe to serve either Audex checkpoint through one of its four +deployment pipelines. Both sizes share the same repo layout, codec token +space, and streaming causal speech decoder (16 kHz output); they differ in +the thinker: the 2B is a dense LM, the 30B-A3B is a hybrid Mamba + MoE +NemotronH (~3B active parameters). Pick the pipeline by task: + +| pipeline (`vllm_omni/deploy/[_30b].yaml`) | audio in | text out | speech out | general audio out | endpoint | +|---|---|---|---|---|---| +| `audex_tts` | ❌ | ❌ | ✅ | ❌ | `/v1/audio/speech` | +| `audex_tta` | ❌ | ❌ | ❌ | ✅ | `/v1/audio/speech` | +| `audex_thinker_only` | ✅ | ✅ | ❌ | ❌ | `/v1/chat/completions` | +| `audex_s2s` | ✅ | ✅ | ✅ | ❌ | both | + +## References + +- Model cards: [Nemotron-Labs-Audex-2B](https://huggingface.co/nvidia/Nemotron-Labs-Audex-2B), [Nemotron-Labs-Audex-30B-A3B](https://huggingface.co/nvidia/Nemotron-Labs-Audex-30B-A3B) +- Offline examples: [`examples/offline_inference/audex/`](../../examples/offline_inference/audex/) +- Online examples: [`examples/online_serving/audex/`](../../examples/online_serving/audex/) + +## Hardware Support + +## GPU + +### 1×H100 80GB — Audex-2B + +#### Environment + +- OS: Linux +- Python: 3.12+ +- CUDA: 12.x +- vLLM version: 0.24.0 +- vLLM-Omni version or commit: PR #4976 (branch `audex`) + +#### Command + +**Online serving** (TTS is the default pipeline; the repo root's +`model_type` auto-resolves `audex_tts.yaml`): + +```bash +vllm-omni serve nvidia/Nemotron-Labs-Audex-2B \ + --host 0.0.0.0 --port 8097 \ + --trust-remote-code --omni +# other modes: add --stage-configs-path vllm_omni/deploy/audex_{tta,thinker_only,s2s}.yaml +# or use the launcher: MODE=s2s PORT=8098 examples/online_serving/audex/run_server.sh +``` + +**Offline batch inference** (one script per task, each defaults to its +correct deploy yaml): + +```bash +python examples/offline_inference/audex/text_to_speech.py \ + --texts "Hello world." --cfg-scale 1.5 --output-dir results/audex_wavs +python examples/offline_inference/audex/text_to_audio.py # sound effects +python examples/offline_inference/audex/audio_qa.py # ASR / audio QA +python examples/offline_inference/audex/speech_to_speech.py # 3-pass cascade +``` + +#### Verification + +```bash +curl -X POST http://localhost:8097/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "model": "nvidia/Nemotron-Labs-Audex-2B", + "input": "Hello, how are you?", + "response_format": "wav", + "extra_params": {"cfg_scale": 1.5} + }' --output hello.wav +# or the universal client (all four modes): +python examples/online_serving/audex/client.py --mode tts --output hello.wav +``` + +#### Notes + +- Memory usage: TTS deploy — stage 0 (thinker) 0.4, stage 1 (decoder) 0.25 + GPU-memory fraction; both stages share GPU 0. +- Key flags: `--trust-remote-code` and `--omni` are required. +- Classifier-free guidance: `extra_params.cfg_scale` (1.0 = off; 1.5 is the + official quality setting; TTA effectively requires its default 3.0). + Measured en-24 self-transcribed CER: 6.87% guided vs 7.24% unguided. +- Output: 16 kHz mono WAV. English, single built-in voice (no cloning). +- TTA decodes through the external XCodec1 checkpoint + (`hf-audio/xcodec-hubert-general-balanced`, auto-downloaded; override + with `XCODEC1_PATH`). + +### 1×H100 80GB — Audex-30B-A3B + +#### Environment + +Same as the 2B row above. + +#### Command + +The 30B REQUIRES its explicit deploy yaml (the shared HF `model_type` +auto-resolves to the 2B-tuned config otherwise): + +```bash +vllm-omni serve nvidia/Nemotron-Labs-Audex-30B-A3B \ + --host 0.0.0.0 --port 8097 \ + --trust-remote-code --omni \ + --stage-configs-path vllm_omni/deploy/audex_tts_30b.yaml +# or: SIZE=30b MODE=tts examples/online_serving/audex/run_server.sh +``` + +Offline: same four scripts with +`--model nvidia/Nemotron-Labs-Audex-30B-A3B --deploy-config vllm_omni/deploy/audex__30b.yaml`. + +#### Verification + +Same curl / `client.py` as the 2B with +`"model": "nvidia/Nemotron-Labs-Audex-30B-A3B"` (the server validates the +model id). First launch downloads ~60 GB. + +#### Notes + +- Memory usage: ~61 GiB weights on one H100 80 GB (stage 0 at 0.85 GPU + fraction + decoder at 0.08 on the same card); verified with healthy KV + headroom. If long sequences OOM, set `tensor_parallel_size: 2` on + stage 0 (documented in the yaml headers). +- `enable_prefix_caching: false` on stage 0 — hybrid Mamba constraint. +- CUDA graphs stay ON for the thinker: the NemotronH stage captures + cleanly and eager mode costs ~7× single-stream TTS RTF (review-measured + 0.30 vs 2.08). +- Quality (en-24 self-transcribed CER): 0.72% unguided baseline; 0.72% at + cfg 1.5 with guided temperature 0.1. +- Known limitation: the S2S chat pass needs the official + formatting-instruction prompt (the bundled examples/client send it); + a bare transcript turn makes the 30B answer in speech-codec tokens. diff --git a/recipes/NVIDIA/PersonaPlex.md b/recipes/NVIDIA/PersonaPlex.md new file mode 100644 index 000000000..62d04a499 --- /dev/null +++ b/recipes/NVIDIA/PersonaPlex.md @@ -0,0 +1,174 @@ +# PersonaPlex + +> Live full-duplex speech-to-speech (talk to the model in your browser, it listens +> while it speaks) with persona and voice control, on the native vLLM-Omni engine. + +## Summary + +- Vendor: NVIDIA +- Model: [`nvidia/personaplex-7b-v1`](https://huggingface.co/nvidia/personaplex-7b-v1) + (gated; a Moshi finetune) +- Task: Full-duplex speech-to-speech. 24 kHz mic audio in, agent speech + + inner-monologue text out, in 80 ms lockstep. Persona (role text) and voice + (zero-shot clone) are set per session. +- Mode: Live duplex WebSocket server with the official browser client, single or + multi-session (`--batch-size`), plus an offline WAV-in / WAV-out example +- Maintainer: [`@linyueqian`](https://github.com/linyueqian) + +## When to use this recipe + +Use this recipe to run `nvidia/personaplex-7b-v1` as a live voice agent on one GPU. +The integration is **moshi-free**: the temporal transformer, depformer, and +streaming Mimi codec are all native vLLM-Omni modules (Mimi runs on +`transformers.MimiModel`), so no vendored fork is installed. Decoding is greedy, +matching the reference implementation frame for frame on the golden replays used +as the acceptance gate. + +PersonaPlex is the first Moshi-class (pure-lockstep) model in the experimental +full-duplex framework (`vllm_omni/experimental/fullduplex/`), and the first with +elastic multi-session batching: one engine hosts `--batch-size` concurrent +conversations, callers join or leave on any 80 ms tick without disturbing live +slots (greedy isolation is bit-exact). + +## References + +- Serving package: + [`vllm_omni/experimental/fullduplex/personaplex/`](../../vllm_omni/experimental/fullduplex/personaplex/) +- Native model modules (temporal / depformer / streaming Mimi): + [`vllm_omni/model_executor/models/personaplex/`](../../vllm_omni/model_executor/models/personaplex/) +- Online example (server + headless client): + [`examples/online_serving/personaplex/`](../../examples/online_serving/personaplex/) +- Offline example: + [`examples/offline_inference/personaplex/personaplex_offline.py`](../../examples/offline_inference/personaplex/personaplex_offline.py) +- Staged offline pipeline (talker -> Mimi code2wav, async-chunk streaming): + [`vllm_omni/deploy/personaplex.yaml`](../../vllm_omni/deploy/personaplex.yaml) +- Integration PR: [vllm-project/vllm-omni#4771](https://github.com/vllm-project/vllm-omni/pull/4771) +- Framework context: [RFC #3745](https://github.com/vllm-project/vllm-omni/issues/3745) + (duplex adapter patterns), [#1335](https://github.com/vllm-project/vllm-omni/issues/1335) + (full-duplex target), [PR #3907](https://github.com/vllm-project/vllm-omni/pull/3907) + (fullduplex core contracts this adapter conforms to) +- Upstream model card: + [`nvidia/personaplex-7b-v1`](https://huggingface.co/nvidia/personaplex-7b-v1) + +## Why it is different from MiniCPM-o / JoyVL duplex + +| | MiniCPM-o 4.5 (#3907) | JoyVL | **PersonaPlex (this)** | +|---|---|---|---| +| Cadence | 1 s chunk groups | ~1 fps frames | **80 ms lockstep (12.5 Hz)** | +| Turn control | learned `⟨listen⟩`/`⟨speak⟩` | ``/`` | **none, pure lockstep** | +| Per step | variable-length token group | text decision | **1 user frame in -> 1 agent frame + 1 text token** | +| Barge-in | at chunk boundary | n/a | **native (model always hears the user)** | +| Session state | chunk-group KV | per-tick HTTP | **persistent ring KV + streaming Mimi state** | + +This is the lockstep ("parallel-frame joint") shape of the duplex adapter +patterns: the adapter declares `DuplexCapability.continuous = True`, and +`core.DuplexRuntime` runs ONE eternal response that consumes input frames as they +arrive and drains on close. The flag is a small, default-off lifecycle mode, so +turn-based adapters (JoyVL, MiniCPM-o) are unaffected. + +## Architecture (Moshi RQ-Transformer, all native) + +- **Mimi codec** 24 kHz, 12.5 Hz, 1920 samples/frame, 8 active codebooks + (card 2048). Streaming encode + decode via `transformers.MimiModel` with the + PersonaPlex reference checkpoint weights. +- **Temporal transformer** (Helium backbone) 4096-d / 32 layers / 32 heads, + sliding window 3000 frames (a 4-minute rolling context) held in a ring KV cache + with per-slot recycle. +- **Depformer** 1024-d / 6 layers / 16 heads, autoregressive over 16 codebooks + per frame (8 vocoded), reset each frame. +- Per-frame token column `[B, 17, 1]`: row 0 = inner-monologue text, + rows 1-8 = agent audio, rows 9-16 = user audio. +- Persona + voice are injected once at session open through the same lockstep + step: voice clone forces the agent stream from reference-audio Mimi codes; + persona forces the text stream from ` ... ` tokens. + +## Hardware Support + +Verified on GPU (Hopper-class). The serving path is plain PyTorch eager plus the +native modules, so other CUDA GPUs with enough memory are expected to work. + +## GPU + +### 1x 141 GB Hopper-class GPU (H20-class, verified) + +#### Environment + +- OS: Linux +- Python: 3.10+ (CI covers 3.11 / 3.12) +- vLLM-Omni: PR #4771 branch or current `main` once merged +- Extra dependency for the live server: `pip install sphn` (Opus framing; + `aiohttp` already ships with vLLM) +- `export HF_TOKEN=...` with access to the gated + `nvidia/personaplex-7b-v1` repo (accept the license on the model page) + +First run auto-downloads from the model repo: `model.safetensors`, +`tokenizer_spm_32k_3.model`, `voices.tgz`, and the Mimi reference checkpoint; +`kyutai/mimi` is fetched via `transformers` for the codec module graph. + +#### Command + +Live duplex server (official browser client served at `/`): + +```bash +HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 \ +python -m vllm_omni.experimental.fullduplex.personaplex.serving.server \ + --port 8124 --voice NATF2.pt +``` + +Multi-session on the same GPU (elastic batching, callers join/leave any tick): + +```bash +HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 \ +python -m vllm_omni.experimental.fullduplex.personaplex.serving.server \ + --port 8124 --batch-size 4 +``` + +Then open `http://localhost:8124/` and allow the microphone (use headphones so +the agent does not hear itself). Headless alternative: + +```bash +python examples/online_serving/personaplex/duplex_client.py \ + --url ws://localhost:8124/api/chat --input user.wav --out reply.wav +``` + +Offline WAV-in / WAV-out (no server): + +```bash +python examples/offline_inference/personaplex/personaplex_offline.py \ + --input-wav question.wav --output-wav reply.wav --output-text reply.json \ + --voice-prompt NATF2.pt --persona "You are a wise and friendly teacher." +``` + +#### Verification + +```bash +# GPU-free contract tests (stubbed FrameStepper) +pytest tests/e2e/features/fullduplex/test_personaplex_adapter.py \ + tests/e2e/features/fullduplex/test_personaplex_session.py \ + tests/e2e/features/fullduplex/test_personaplex_batched.py -q + +# GPU e2e: batched isolation + slot recycle (coherence check needs a real +# question WAV via PPLEX_QUESTION_WAV) +pytest tests/e2e/features/fullduplex/test_personaplex_elastic.py -q +``` + +A green offline run producing a coherent spoken reply (and inner-monologue text +in `reply.json`) is the quickest end-to-end check. + +#### Notes + +- Realtime budget is 80 ms/frame. Verified eager per-tick latency at + `--batch-size 4` is ~70-74 ms on this hardware class, i.e. all four + conversations stay realtime; single-session has comfortable headroom. +- Decoding is greedy only (temperature/top-k knobs are intentionally not + exposed): greedy is what the parity gates pin against the reference + implementation, and sampling amplifies sub-bit numeric drift into divergence. +- With `--batch-size N`, connections beyond N are rejected until a slot frees; + a recycled slot is bit-exact with a fresh engine for the same inputs. +- Voice prompts: bundled `voices.tgz` names (`NATF*`/`NATM*` natural, + `VARF*`/`VARM*` varied) or a path to your own `.pt`/`.wav`. +- Run the browser client near the server. The 80 ms cadence is sensitive to + network jitter; over a high-latency link playback can stutter regardless of + engine speed. On localhost it is smooth. +- Sliding window is 3000 frames (~4 min). The ring KV recycles beyond that; + very long sessions keep running with a rolling context. diff --git a/recipes/OpenBMB/MiniCPM-o-4_5.md b/recipes/OpenBMB/MiniCPM-o-4_5.md index 8b3d2909d..f87ee5b19 100644 --- a/recipes/OpenBMB/MiniCPM-o-4_5.md +++ b/recipes/OpenBMB/MiniCPM-o-4_5.md @@ -1,6 +1,7 @@ # MiniCPM-o 4.5 -> Online serving for omni multimodal chat (text / image / audio / video → text + 24 kHz speech) +> Online serving and offline inference for omni multimodal chat +> (text / image / audio / video → text + 24 kHz speech) ## Summary @@ -9,34 +10,38 @@ - Task: Omni multimodal chat — accepts text / image / audio / video input; emits text and 24 kHz mono speech in the same response - Mode: Online serving via the OpenAI-compatible `/v1/chat/completions` - API, plus a bundled Gradio demo (text + speech UI) + API (plus Gradio demo), and offline inference via `Omni.generate` - Maintainer: [`@tc-mb`](https://github.com/tc-mb) (MiniCPM-V / MiniCPM-o team) ## When to use this recipe Use this recipe as a known-good starting point for serving `openbmb/MiniCPM-o-4_5` on vLLM-Omni. MiniCPM-o 4.5 is the omni member -of the MiniCPM-o family — it pairs a multimodal-understanding thinker -LLM with a streaming `MiniCPMTTS + Token2Wav` talker so a single +of the MiniCPM-o family — it runs a multimodal thinker, a streaming +MiniCPMTTS codec talker, and a separate batched Code2Wav stage so a single `/v1/chat/completions` call can return text and 24 kHz speech in one -shot. The recipe covers three shipped GPU layouts (2 / 3 / 8 GPUs) -selected via `--deploy-config`. +shot. The recommended batching deploy isolates the Thinker on GPU 0 and +co-locates Talker and Code2Wav on GPU 1; 1-GPU, 3-GPU, and 8x4090 layouts are +also provided. ## References - Default deploy configs (auto-loaded by HF `model_type=minicpmo` + `hf_config.version="4.5"`): - - 2-GPU layout (default): + - Default single-GPU compatibility layout (auto-loaded): [`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml) - - 3-GPU layout (thinker TP=2): + - 2-GPU and 3-GPU layouts: + [`vllm_omni/deploy/minicpmo_4_5_2gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_2gpu.yaml), [`vllm_omni/deploy/minicpmo_4_5_3gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_3gpu.yaml) - 8x RTX 4090 layout: [`vllm_omni/deploy/minicpmo_4_5_8x4090.yaml`](../../vllm_omni/deploy/minicpmo_4_5_8x4090.yaml) - Online example + Gradio demo: [`examples/online_serving/minicpmo/`](../../examples/online_serving/minicpmo/) +- Offline end-to-end example: + [`examples/offline_inference/minicpmo/`](../../examples/offline_inference/minicpmo/) - Pipeline / talker source: [`vllm_omni/model_executor/models/minicpmo_4_5/`](../../vllm_omni/model_executor/models/minicpmo_4_5/) -- Stage-input processor (thinker → talker bridge): +- Stage-input processors (thinker → talker and talker → Code2Wav): [`vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py`](../../vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py) - Upstream model card: [`openbmb/MiniCPM-o-4_5`](https://huggingface.co/openbmb/MiniCPM-o-4_5) @@ -45,28 +50,38 @@ selected via `--deploy-config`. ## Hardware Support -Three GPU layouts ship with default deploy configs. Pick the layout that -matches your hardware and pass it via `--deploy-config`; the talker -(`MiniCPMTTS + Token2Wav`) always lives on its own GPU because of the -in-process vocoder, and the thinker is the part that scales out via TP. +Four hardware layouts ship with deploy configs. Every layout uses the +same strict three-stage topology. The Talker emits codec chunks only; +Code2Wav consumes them through a shared-memory async connector. -| Layout | Thinker | Talker + Token2Wav | Typical hardware | -| --- | --- | --- | --- | -| 2-GPU (default) | GPU 0 | GPU 1 | 2x A100/H100/H200 80GB | -| 3-GPU (thinker TP=2) | GPU 0,1 (TP=2) | GPU 2 | 3x mid-tier GPUs | -| 8x RTX 4090 24GB | GPU 0–3 (TP=4) | GPU 4 | 8x RTX 4090 consumer | +| Layout | Thinker | Talker | Code2Wav | Typical hardware | +| --- | --- | --- | --- | --- | +| 1-GPU (default) | GPU 0 | GPU 0 | GPU 0 | 1x large-memory GPU | +| 2-GPU | GPU 0 | GPU 1 | GPU 1 | 2x large-memory GPU | +| 3-GPU | GPU 0 | GPU 1 | GPU 2 | 3x GPU | +| 8x RTX 4090 24GB | GPU 0–3 (TP=4) | GPU 4 | GPU 5 | 8x RTX 4090 consumer | + +### Migration from the fused deployment + +MiniCPM-o 4.5 now requires the three-stage topology: the Talker owns +request-local codec generation and Code2Wav owns waveform state and +reference-voice prompt features. `minicpmo_4_5.yaml` remains the stable +single-GPU entry point; `minicpmo_4_5_2gpu.yaml` is the recommended +two-GPU profile. The removed fused two-stage implementation is not retained as +a fallback because it would duplicate state machines and correctness paths. ## GPU -### 2 x GPU (default — single command) +### 1 x GPU (default — single command) The default [`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml) -puts the thinker on GPU 0 (`~70 %` memory, `enforce_eager: true`, -`max_num_seqs: 1`) and the talker + Token2Wav vocoder on GPU 1 -(`~75 %` memory). This is the recommended starting layout — works on -any pair of 80GB-class GPUs (A100, H100, H200) and on most 40GB+ -pairs as long as the thinker model weights fit. +co-locates Thinker, codec-only Talker, and Code2Wav on GPU 0. Their +`gpu_memory_utilization` budgets are 0.55, 0.15, and 0.15. The remaining +device memory is left available for runtime workspaces such as the HiFi-GAN +vocoder's cuDNN kernels. All stages admit +up to four sequences. Startup video profiling is bounded to 32 frames per +video. Use the two-GPU profile for production throughput. #### Environment @@ -85,7 +100,41 @@ vllm serve openbmb/MiniCPM-o-4_5 --omni \ ``` The deploy config is auto-loaded by the model registry — no -`--deploy-config` flag needed for this default 2-GPU layout. +`--deploy-config` flag needed for this default single-GPU layout. + +For the recommended two-GPU layout, add: + +```bash +--deploy-config vllm_omni/deploy/minicpmo_4_5_2gpu.yaml +``` + +#### Performance comparison + +Compare text-only and text+audio separately. Text-only isolates Thinker +generation; text+audio also schedules Talker and Code2Wav. The following full +Daily-Omni runs used the same two GPUs, 1197 samples, concurrency 10, and +identical `enable_thinking=false` / `use_tts_template=true` request settings. +The `origin/main` fused Talker ran eager because its graph capture copied an +unpinned CPU metadata tensor. + +| Metric | `origin/main` two-stage | Three-stage batching | +| --- | ---: | ---: | +| Accuracy | 64.83% | 64.83% | +| Throughput | 0.62 req/s | 1.97 req/s | +| Mean E2EL | 16.17 s | 5.07 s | +| Mean serving TTFT | 0.92 s | 1.28 s | +| Mean audio TTFP | 16.17 s | 3.24 s | +| Mean audio RTF | 5.97 | 2.11 | +| Stage 0 mean TPOT / ITL | 8.27 / 8.27 ms | 40.08 / 40.11 ms | +| Stage 0 median TPOT / ITL | 7.23 / 7.24 ms | 7.43 / 7.53 ms | + +The split pipeline improves throughput 3.19x and lowers audio TTFP by 80%. +Isolating the Thinker on GPU 0 also removes the prior single-GPU TPOT +regression: 40.08 ms is slightly better than the pre-rebase report (~44 ms). +Its median TPOT is effectively the same as main; the higher mean is queueing +tail latency because this profile bounds each stage to four sequences while +main's Thinker admits 16. Global TPOT/ITL remains zero when serving emits text +as one aggregated chunk, so the table reports Stage 0 metrics. #### Verification @@ -101,10 +150,10 @@ curl http://localhost:8099/v1/chat/completions \ }' ``` -**Text + speech in one response** (the headline 4.5 feature). The TTS -path is gated by a Jinja flag on the chat template. Pass -`use_tts_template=true` via the **top-level** `chat_template_kwargs` -field (curl does not flatten nested `extra_body`): +**Text + speech in one response** (the headline 4.5 feature). The model +bridge conditions the Talker from the generated assistant span, so the +generic serving layer does not inject MiniCPM-specific template defaults. +`use_tts_template=true` remains supported when explicitly requested: ```bash curl http://localhost:8099/v1/chat/completions \ @@ -126,6 +175,19 @@ in another choice's `message.audio.data` (24 kHz mono, see Notes). With `modalities: ["text", "audio"]` you typically get two `choices` entries (one text, one audio). +**Streaming text + speech** (use `--stream`): + +```bash +python examples/online_serving/minicpmo/openai_chat_completion_client_for_multimodal_generation.py \ + --query-type text \ + --prompt "Say hello, then introduce vLLM in one sentence." \ + --port 8099 \ + --stream +``` + +The client prints text deltas as they arrive and saves streamed audio chunks +to WAV files. + **Gradio demo (text + image + audio + video UI)**: ```bash @@ -142,45 +204,36 @@ speech output (TTS)"** checkbox on / off. #### Notes -- Memory budget: thinker weights occupy GPU 0 at `gpu_memory_utilization: - 0.7`; talker + Token2Wav vocoder share GPU 1 at `0.75`. +- Memory budget: Thinker, Talker, and Code2Wav reserve 0.55, 0.15, and + 0.15 of GPU 0. The larger Thinker share protects its multimodal KV cache, + while the unreserved memory remains available to runtime kernels; all three + model processes still share one CUDA device. - `--trust-remote-code` is required — the HF repo ships a custom `MiniCPMO` config / model class. -- Pin: `enforce_eager: true` on both stages (CUDA graph capture is off - by design for the talker's Token2Wav path). -- Stage 1 (talker) is hard-capped to `max_num_seqs: 1`: the talker - only consumes `runtime_additional_information[0]`, so any value > 1 - makes concurrent requests share request-0's audio. This is the same - cap baked into the deploy config. - -### 3 x GPU (thinker TP=2) - -Use -[`vllm_omni/deploy/minicpmo_4_5_3gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_3gpu.yaml) -when you have a third GPU available and want the thinker on 2-way -tensor parallel for higher throughput; the talker stays on its own -GPU (talker has its own in-process Token2Wav vocoder, so co-locating -it with the thinker risks OOM under load). - -#### Command - -```bash -vllm serve openbmb/MiniCPM-o-4_5 --omni \ - --deploy-config vllm_omni/deploy/minicpmo_4_5_3gpu.yaml \ - --trust-remote-code \ - --host 0.0.0.0 --port 8099 -``` - -Verification and Notes mirror the 2-GPU section; thinker latency -roughly halves under load thanks to TP=2. +- Stage 0 Thinker and Stage 1 Talker enable vLLM CUDA Graphs. Stage 2 remains + eager because its request-owned Flow/HiFT caches and variable chunk/cache + shapes are not yet exposed through a static exact-shape graph wrapper. +- All default stages use `max_num_seqs: 4` to reduce cross-process GPU + contention. Talker AR + state and Code2Wav caches are request-owned; Code2Wav batches only + exact-shape-compatible chunks and does not fall back to serial decode. +- `limit_mm_per_prompt.video.num_frames: 32` bounds startup dummy profiling, + not runtime media decoding. Use `media_io_kwargs.video.num_frames` when a + matching URL/file video sampling limit is required. +- `StageRequestStats.batch_size` is a request-scoped placeholder, not the + scheduler's execution batch. +- Single-GPU co-location trades throughput for hardware density: Stage 0/1 + CUDA Graph replay and eager Stage 2 vocoder kernels compete across three + CUDA contexts. Use the 8x4090 config or a custom multi-GPU mapping for + throughput-sensitive serving. ### 8 x RTX 4090 24GB (consumer-GPU layout) Use [`vllm_omni/deploy/minicpmo_4_5_8x4090.yaml`](../../vllm_omni/deploy/minicpmo_4_5_8x4090.yaml) on an 8x RTX 4090 host. Thinker uses 4-way TP across GPUs 0–3 -(`~85 %` mem each ≈ 20.4 GiB/card), talker + Token2Wav lives on GPU 4 -(`~90 %` mem). GPUs 5–7 are left free. +(`~85 %` mem each ≈ 20.4 GiB/card), Talker uses GPU 4, and Code2Wav +uses GPU 5. GPUs 6–7 are left free. #### Command @@ -197,32 +250,38 @@ vllm serve openbmb/MiniCPM-o-4_5 --omni \ 4090s. Raise it if your cards have more headroom (e.g. 4090 D / custom 32 GB SKUs), but verify with a long-prompt run before promoting. -- All other knobs match the 2-GPU section; the only difference is the - per-card memory pressure on the thinker shards. +- All other knobs match the single-GPU section; the only difference is + the per-card memory pressure on the thinker shards. ## Notes (applies to all layouts) -- **Talker dependency**: the `MiniCPM-o 4.5` talker calls - `from stepaudio2 import Token2wav` against the MiniCPM-o-flavored +- **Code2Wav dependency**: Stage 2 loads `Token2wav` from the + MiniCPM-o-flavored vocoder (PyPI package `stepaudio2-minicpmo` — NOT the upstream `stepfun-ai/Step-Audio2`, whose `Token2wav.__init__` signature rejects `n_timesteps`). Install via the published extra: ```bash - pip install 'vllm-omni[minicpmo]' + pip install stepaudio2-minicpmo ``` - Equivalent direct install: `pip install stepaudio2-minicpmo`. A - missing dep raises `ImportError` at first request with the same + A missing dep raises `ImportError` at first request with the same install hint instead of silently emitting empty audio. -- **TTS trigger**: speech output requires - `chat_template_kwargs.use_tts_template=true` so the chat template - appends `<|tts_bos|>` before generation. Without it, Stage-1 talker - receives no TTS token span and returns silent audio (not text-only). - For **curl**, put `chat_template_kwargs` at the request root; nested - `extra_body.chat_template_kwargs` is ignored. The OpenAI Python SDK - may use `extra_body` because it flattens those fields into the root. +- **TTS conditioning**: the MiniCPM stage bridge can condition speech from + the generated assistant span without changing shared serving code. + `chat_template_kwargs.use_tts_template=true` remains supported when an + explicit `<|tts_bos|>` boundary is desired. For **curl**, put + `chat_template_kwargs` at the request root; the OpenAI Python SDK may use + `extra_body` because it flattens those fields into the root. + +- **Reference voice**: request audio is carried on the first codec chunk. + Code2Wav owns the temporary prompt WAV and prompt-feature cache, and removes + both when the stream ends. + +- **Talker sampling**: codec-token sampling reads the checkpoint `tts_config` + and defaults to deterministic seed 42. Stage-1 deploy sampling parameters + control only vLLM's binary continue/stop token. - **Output audio**: 24 kHz mono WAV inside the OpenAI-style `message.audio.data` (base64). The Gradio demo's WAV player decodes @@ -235,6 +294,9 @@ vllm serve openbmb/MiniCPM-o-4_5 --omni \ with this recipe's `--deploy-config` will be rejected at startup rather than silently misrouted. -- **Async chunking**: disabled in all three deploy configs - (`async_chunk: false`) — the talker batches a single full thinker - output, not chunks. +- **Async chunking**: enabled in all deploy configs. Talker sends + 25-code chunks with three-code left context to Code2Wav through + `SharedMemoryConnector`; terminal chunks flush held lookahead state. +- **Response choices**: text and audio are separate choices. SDK clients + should select the choice whose `message.audio.data` is populated rather + than assuming `choices[0]` contains audio. diff --git a/recipes/README.md b/recipes/README.md index d335183ca..a6015d9b0 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -37,6 +37,7 @@ recipes/ | [`audiox/AudioX.md`](./audiox/AudioX.md) | Offline + online unified text/video→audio diffusion | 1x L4 24GB | | [`Baidu/ERNIE-Image.md`](./Baidu/ERNIE-Image.md) | Text-to-image online serving (ERNIE-Image 8B) | 1x or 2x RTX 4090 24GB | | [`Bagel/BAGEL-7B-MoT.md`](./Bagel/BAGEL-7B-MoT.md) | Text-to-image with shared online/offline examples | 1x A100 80GB / 2x CUDA GPUs | +| [`Boogu/Boogu-Image.md`](./Boogu/Boogu-Image.md) | Text-to-image online serving (Boogu-Image-0.1-Base) | 1x A100/H100 40GB+ | | [`BosonAI/Higgs-Audio-V3-TTS.md`](./BosonAI/Higgs-Audio-V3-TTS.md) | Online + offline multilingual TTS with voice cloning | 1x H100 80GB | | [`ByteDance/Lance.md`](./ByteDance/Lance.md) | Unified AR+diffusion: text/img/video gen + understanding (Lance 3B) | 1x B300 / A100 80GB | | [`fishaudio/Fish-Speech-S2-Pro.md`](./fishaudio/Fish-Speech-S2-Pro.md) | Online serving for TTS | 1x A800 80GB | @@ -44,16 +45,20 @@ recipes/ | [`inclusionAI/Ming-flash-omni-2.0.md`](./inclusionAI/Ming-flash-omni-2.0.md) | Online serving for multimodal chat + standalone TTS | 4x H100 / 1x H100 80GB | | [`inclusionAI/Ming-omni-tts.md`](./inclusionAI/Ming-omni-tts.md) | Offline + online dense Ming TTS/audio generation | 1x H100 80GB / 1x AMD MI300X (ROCm 7.2) | | [`IndexTeam/IndexTTS-2.md`](./IndexTeam/IndexTTS-2.md) | Online serving for voice-cloned TTS with optional emotion control | 1x L4 24GB or larger CUDA GPU | +| [`MiniMaxAI/MiniMax-H3.md`](./MiniMaxAI/MiniMax-H3.md) | T2VA, FL2VA, image+audio Ref2VA, and multi-video Ref2VA serving | 1x GPU with CPU offload / 4x B300 | +| [`MiniMaxAI/MiniMax-H3-MUSA.md`](./MiniMaxAI/MiniMax-H3-MUSA.md) | MiniMax H3 T2VA, FL2VA, and Ref2VA on MUSA | 4x MTT S5000 (TP4 Ref2VA profile) | | [`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) | Text-to-video and image-to-video serving | 1x H200 141GB | -| [`LTX/LTX-2.3.md`](./LTX/LTX-2.3.md) | Text-to-video with audio generation (22B) | 1x GPU (96GB VRAM) | -| [`MammothModa2/MammothModa2-Preview.md`](./MammothModa2/MammothModa2-Preview.md) | Text-to-image with the shared offline image example (AR → DiT) | 1x L40S 48GB / 1x ≥40GB GPU | +| [`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 | | [`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 | +| [`cosmos3/Cosmos3-Edge.md`](./cosmos3/Cosmos3-Edge.md) | T2I / T2V / I2V + action (Physical-AI) generation on the Nemotron-based Edge transformer | 1x GPU (~8 GiB) | | [`cosmos3/Cosmos3-Super.md`](./cosmos3/Cosmos3-Super.md) | 64B T2I / T2V / I2V / V2V generation (+ optional audio) / Action policy | 8x H200/H100/A100 / 2x H200 / B300 | | [`OpenBMB/MiniCPM-o-4_5.md`](./OpenBMB/MiniCPM-o-4_5.md) | Online serving for omni multimodal chat (text / image / audio / video → text + 24 kHz speech) | 2x A100/H100 80GB / 3x mid-tier GPU / 8x RTX 4090 24GB | | [`OpenBMB/VoxCPM2.md`](./OpenBMB/VoxCPM2.md) | Online + offline TTS with native AR pipeline (48 kHz, 30+ languages) | 1x RTX 4090 24GB | | [`OpenMOSS/MOSS-TTS.md`](./OpenMOSS/MOSS-TTS.md) | Online + offline multilingual TTS (MOSS-TTS family, 8B) | 1x H100 80GB | +| [`NVIDIA/Nemotron-Labs-Audex.md`](./NVIDIA/Nemotron-Labs-Audex.md) | TTS / text-to-audio / audio understanding / cascaded S2S (Audex 2B + 30B-A3B) | 1x H100 80GB | | [`Qwen/Qwen-Image.md`](./Qwen/Qwen-Image.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt mixed FP8/NVFP4 | 1x A100 80GB / 2x B200 | | [`Qwen/Qwen-Image-2512.md`](./Qwen/Qwen-Image-2512.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt FP8 / mixed FP8/NVFP4 | 1x A800 80GB / 2x B200 | | [`Qwen/Qwen-Image-Edit.md`](./Qwen/Qwen-Image-Edit.md) | Text-guided single-image editing | 1x or 2x H200 141GB | @@ -63,6 +68,7 @@ recipes/ | [`StabilityAI/Stable-Audio-Open.md`](./StabilityAI/Stable-Audio-Open.md) | Offline + online text-to-audio generation (Stable Audio Open) | 1x RTX 4090 24GB | | [`Tencent/Covo-Audio-Chat.md`](./Tencent/Covo-Audio-Chat.md) | Online serving for audio chat | 1x A100 80GB | | [`Tencent/HunyuanImage-3.0-Instruct.md`](./Tencent/HunyuanImage-3.0-Instruct.md) | DiT-only text-to-image serving and benchmark, including ModelOpt mixed FP8/NVFP4 | 4x H100/H800 80GB / 2x B200 | +| [`Wan-AI/Wan2.2-T2V.md`](./Wan-AI/Wan2.2-T2V.md) | Text-to-video serving (Wan2.2 14B) with Skip-Softmax sparse attention | 1x datacenter Blackwell (B200/B300) | | [`Wan-AI/Wan2.2-I2V.md`](./Wan-AI/Wan2.2-I2V.md) | Image-to-video serving (Wan2.2 14B) | 8x Ascend NPU (A2/A3) | | [`Wan-AI/Wan2.2-S2V.md`](./Wan-AI/Wan2.2-S2V.md) | Speech-to-video serving (Wan2.2 14B) | 2x A100/H100 80GB | | [`Wan-AI/Wan2.1-VACE.md`](./Wan-AI/Wan2.1-VACE.md) | Unified T2V, I2V, V2LF, FLF2V, inpaint, and R2V | 1x RTX 5090 (1.3B) / 1x L40S 48GB with layerwise offload (14B) | diff --git a/recipes/Robbyant/LingBot-Video.md b/recipes/Robbyant/LingBot-Video.md new file mode 100644 index 000000000..289d36990 --- /dev/null +++ b/recipes/Robbyant/LingBot-Video.md @@ -0,0 +1,259 @@ +# LingBot-Video + +> Native dense and MoE text-to-video serving for LingBot-Video + +## Summary + +- Vendor: Robbyant +- Models: `robbyant/lingbot-video-dense-1.3b` and `robbyant/lingbot-video-moe-30b-a3b` +- Task: Text-to-video generation +- Mode: Offline generation and online serving with the OpenAI-compatible `/v1/videos` API +- Maintainer: Community + +## When to use this recipe + +Use this recipe when you want to run a dense or MoE LingBot-Video checkpoint +with vLLM-Omni's native pipeline. The runtime path does not import the upstream +`lingbot_video` Python package; it loads checkpoint components directly with +the in-tree `LingBotVideoPipeline`, dense or routed-MoE DiT blocks, shared +FlowUniPC scheduler, Qwen3-VL text encoder, and Wan VAE. + +This first MoE integration is intentionally text-to-video and single-GPU only. +It targets the base BF16 checkpoint without its optional refiner or additional +parallel, cache, quantized, or expert-kernel backends. + +## References + +- Dense checkpoint: +- MoE checkpoint: +- Upstream project: +- Related offline example: [`examples/offline_inference/text_to_video/text_to_video_lingbot.py`](../../examples/offline_inference/text_to_video/text_to_video_lingbot.py) +- Related online video API docs: [`docs/serving/videos_api.md`](../../docs/serving/videos_api.md) + +## Hardware Support + +This recipe documents the CUDA single-GPU dense and BF16 MoE checkpoint paths. +Multi-GPU parallelism, Cache-DiT, quantization, and CPU offload are not +validated for LingBot-Video in this PR. + +## GPU + +### 1 x NVIDIA L20X + +Both checkpoints have been smoke-tested on one NVIDIA L20X at `192x320`, +9 frames, and 2 steps. The MoE smoke reserved approximately `67.70 GiB` of GPU +memory, so use a GPU with at least about 70 GiB of available memory for this +small validation shape. Larger resolutions, frame counts, or concurrent +requests require additional headroom. + +The MoE path is validated with BF16 expert weights. + +#### Dense offline T2V + +```bash +CUDA_VISIBLE_DEVICES=0 \ +python examples/offline_inference/text_to_video/text_to_video_lingbot.py \ + --model robbyant/lingbot-video-dense-1.3b \ + --prompt "a robotic arm picks up a red block" \ + --output lingbot_t2v.mp4 \ + --height 192 \ + --width 320 \ + --num-frames 9 \ + --num-inference-steps 2 \ + --guidance-scale 3.0 \ + --flow-shift 3.0 \ + --seed 42 \ + --fps 24 +``` + +#### MoE offline T2V + +```bash +CUDA_VISIBLE_DEVICES=0 \ +python examples/offline_inference/text_to_video/text_to_video_lingbot.py \ + --model robbyant/lingbot-video-moe-30b-a3b \ + --prompt "a robotic arm picks up a red block" \ + --output lingbot_moe_t2v.mp4 \ + --height 192 \ + --width 320 \ + --num-frames 9 \ + --num-inference-steps 2 \ + --guidance-scale 3.0 \ + --flow-shift 3.0 \ + --seed 42 \ + --fps 24 +``` + +#### Online serving + +```bash +CUDA_VISIBLE_DEVICES=0 \ +vllm serve robbyant/lingbot-video-dense-1.3b \ + --omni \ + --model-class-name LingBotVideoPipeline \ + --default-sampling-params \ + '{"0":{"num_frames":81,"num_inference_steps":40,"guidance_scale":6.0}}' \ + --port 8091 +``` + +For the MoE checkpoint, use the same single-GPU pipeline: + +```bash +CUDA_VISIBLE_DEVICES=0 \ +vllm serve robbyant/lingbot-video-moe-30b-a3b \ + --omni \ + --model-class-name LingBotVideoPipeline \ + --default-sampling-params \ + '{"0":{"num_frames":81,"num_inference_steps":40,"guidance_scale":6.0}}' \ + --port 8091 +``` + +These stage defaults match the LingBot reference pipeline. Request-level +values continue to override them, so the smaller smoke request below remains +unchanged. + +When serving MoE, replace the request's `model` form value below with +`robbyant/lingbot-video-moe-30b-a3b`. + +After the server is ready, submit a text-to-video job: + +```bash +create_response=$(curl -s http://localhost:8091/v1/videos \ + -F "model=robbyant/lingbot-video-dense-1.3b" \ + -F "prompt=a robotic arm picks up a red block" \ + -F "width=320" \ + -F "height=192" \ + -F "num_frames=9" \ + -F "fps=24" \ + -F "num_inference_steps=2" \ + -F "guidance_scale=3.0" \ + -F "flow_shift=3.0" \ + -F "seed=42") + +video_id=$(echo "${create_response}" | jq -r '.id') +while true; do + status=$(curl -s "http://localhost:8091/v1/videos/${video_id}" | jq -r '.status') + if [ "${status}" = "completed" ]; then + break + fi + if [ "${status}" = "failed" ]; then + curl -s "http://localhost:8091/v1/videos/${video_id}" | jq . + exit 1 + fi + sleep 2 +done + +curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o lingbot_t2v.mp4 +``` + +## Key Parameters + +| Parameter | Suggested smoke value | Notes | +|-----------|-----------------------|-------| +| `height` | `192` | Must be a multiple of 16 | +| `width` | `320` | Must be a multiple of 16 | +| `num_frames` | `9` | Must be `1` or `4n + 1`; this PR validates T2V with video outputs | +| `num_inference_steps` | `2` | Use more steps for quality sweeps | +| `guidance_scale` | `3.0` | CFG is active when this is greater than `1.0` | +| `flow_shift` | `3.0` | Scheduler flow-shift; aliases the pipeline's internal `shift` | +| `negative_prompt` | model default | Optional text describing artifacts to avoid | +| `fps` | `24` | Output MP4 frame rate | + +## Validation + +Local dense smoke run: + +- Shape: 9 frames at `192x320` +- Steps: 2 +- Request generation time: `0.2923s` +- Peak reserved GPU memory: `14548 MiB` + +Local dense parity harness against the upstream repository: + +- Shape: `[9, 192, 320, 3]` +- MAE: `0.0065238` +- MSE: `0.00006650` +- PSNR: `41.77 dB` +- Native request time: `0.2875s` + +Local MoE validation on one NVIDIA L20X used checkpoint revision +`f2e538f64afe00cc4ae674db2aeb52e2945edfd5`: + +- Loaded all `977` transformer state keys and `30,084,506,176` parameters. +- Router weights and correction biases stayed in FP32; routed and shared + expert weights loaded in BF16. +- The complete 48-layer transformer forward returned finite + `[1, 16, 1, 8, 8]` BF16 output. +- With both implementations using matched math SDPA backends, the complete + native transformer matched the upstream implementation bitwise + (`max_abs=0`, `mean_abs=0`). +- An isolated sparse MoE block exercising the router, grouped experts, scatter + restore, and shared expert also matched the upstream implementation bitwise. +- The native pipeline generated a 9-frame `192x320` MP4 in 2 steps with + `69326 MiB` (`67.70 GiB`) peak reserved GPU memory. +- The online `/v1/videos` smoke created, polled, and downloaded the generated + MP4 successfully (`1 passed`). + +Do not treat these as production benchmarks; they are functional smoke plus +controlled numerical-parity evidence for small validation inputs. + +## Reproducing MoE numerical parity + +The in-tree parity harness compares the native MoE implementation with a local +checkout of the upstream LingBot-Video implementation. It uses local files +only and does not download checkpoints. + +Set paths for the upstream checkout and the cached MoE checkpoint: + +```bash +export LINGBOT_VIDEO_REPO=/path/to/lingbot-video +export LINGBOT_VIDEO_MOE_MODEL=/path/to/lingbot-video-moe-30b-a3b +``` + +Run the lightweight sparse-block comparison: + +```bash +CUDA_VISIBLE_DEVICES=0 \ +python benchmarks/lingbot_video/moe_transformer_parity.py \ + --scope block \ + --official-repo "${LINGBOT_VIDEO_REPO}" \ + --output-json /tmp/lingbot_moe_block_parity.json +``` + +This path covers bias-corrected router selection, group-limited top-k, routed +experts, FP32 scatter-weighted restore, padding masks, and the shared expert. +It exits with a nonzero status unless the upstream and native block outputs are +bitwise equal. + +The real-checkpoint transformer comparison requires one GPU with enough memory +for the 30B checkpoint. Models are loaded sequentially, so two copies are not +resident on the GPU at the same time: + +```bash +CUDA_VISIBLE_DEVICES=0 \ +python benchmarks/lingbot_video/moe_transformer_parity.py \ + --scope transformer \ + --official-repo "${LINGBOT_VIDEO_REPO}" \ + --model "${LINGBOT_VIDEO_MOE_MODEL}" \ + --output-json /tmp/lingbot_moe_transformer_parity.json +``` + +Transformer parity fixes the official implementation to +`diffusers:_native_math` and the native implementation to +`TORCH_SDPA + SDPBackend.MATH`. The expected result is `exact=true`, +`output.equal=true`, and zero max/mean/RMSE error. Different fused attention +kernels can introduce BF16 rounding differences before MoE routing, so +automatic attention-backend comparisons are diagnostic only and are not the +bitwise correctness oracle. + +## Known Limitations + +- T2V base-transformer inference only. T2I, I2V, TI2V, and the checkpoint's + optional `refiner/` transformer are not supported by this PR. +- Only the BF16 MoE checkpoint is validated in this first integration. +- No HSDP, tensor, sequence, expert, or CFG parallelism is claimed. +- No Cache-DiT, TeaCache, CPU offload, VAE patch parallelism, or quantized + inference is claimed. +- Optional Triton, SGLang, FP8, and alternative fused-expert backends from the + upstream project are not included. +- Only one request per LingBot pipeline batch is currently supported. diff --git a/recipes/SenseNova/SenseNova-U1.md b/recipes/SenseNova/SenseNova-U1.md index 5cb30a21d..be25df570 100644 --- a/recipes/SenseNova/SenseNova-U1.md +++ b/recipes/SenseNova/SenseNova-U1.md @@ -22,8 +22,10 @@ text-to-text chat. ## References -- Offline example: - [`examples/offline_inference/sensenova_u1/end2end.py`](../../examples/offline_inference/sensenova_u1/end2end.py) +- Offline text-to-image: + [`examples/offline_inference/text_to_image/text_to_image.py`](../../examples/offline_inference/text_to_image/text_to_image.py) +- Offline image-to-image: + [`examples/offline_inference/image_to_image/image_edit.py`](../../examples/offline_inference/image_to_image/image_edit.py) - Online serving: [`examples/online_serving/sensenova_u1/`](../../examples/online_serving/sensenova_u1/) - E2E tests: @@ -48,13 +50,14 @@ text-to-text chat. #### Command ```bash -python examples/offline_inference/sensenova_u1/end2end.py \ +python examples/offline_inference/text_to_image/text_to_image.py \ + --model SenseNova/SenseNova-U1-8B-MoT \ --prompt "Close portrait of an elderly woman by a farmhouse window, textured skin, gentle smile, warm natural light, emotional documentary look. The portrait should feel polished and natural, with sharp eyes, realistic skin texture, accurate facial anatomy, and premium lighting that keeps the face as the main focus." \ --width 1536 --height 2720 \ - --seed 42 --num-steps 50 \ - --cfg-scale 4.0 --timestep-shift 3.0 --cfg-norm none \ - --think --print-think \ - --output outputs + --seed 42 --num-inference-steps 50 \ + --cfg-scale 4.0 \ + --extra-body '{"think": true, "cfg_norm": "none", "timestep_shift": 3.0, "t_eps": 0.02}' \ + --output outputs/sensenova_u1_output.png ``` #### Verification @@ -74,8 +77,7 @@ pytest -s -v tests/e2e/offline_inference/test_sensenova_u1_text2img.py \ #### Text-to-Image via the shared example -SenseNova-U1 is also registered with the shared text-to-image example, so you -can run it through `text_to_image.py` instead of `end2end.py`. Forward the +SenseNova-U1 is registered with the shared text-to-image example. Forward SenseNova-specific generation parameters as a JSON object through `--extra-body`: ```bash @@ -94,48 +96,49 @@ The `--extra-body` keys are filtered against the model's declared [`vllm_omni/model_extras/sensenova_u1.py`](../../vllm_omni/model_extras/sensenova_u1.py)): `think`, `cfg_scale`, `cfg_norm`, `timestep_shift`, `t_eps`, `img_cfg_scale`, and `max_tokens`. No deploy YAML is needed — the engine auto-generates a -single-stage diffusion config. For richer task modalities (img2text, text2text) -and CPU offload, use the dedicated `end2end.py` script described above. +single-stage diffusion config. #### Image-to-Image Editing (img2img) ```bash -python examples/offline_inference/sensenova_u1/end2end.py \ +python examples/offline_inference/image_to_image/image_edit.py \ + --model SenseNova/SenseNova-U1-8B-MoT \ --prompt "Turn this into an oil painting" \ --image input.png \ - --width 2048 --height 2048 \ - --seed 42 --num-steps 50 \ - --cfg-scale 4.0 --img-cfg-scale 1.0 --cfg-norm none \ - --think --print-think \ - --output outputs + --resolution 2048 \ + --seed 42 --num-inference-steps 50 \ + --cfg-scale 4.0 \ + --extra-args '{"think": true, "img_cfg_scale": 1.0, "cfg_norm": "none", "timestep_shift": 3.0}' \ + --output outputs/sensenova_u1_edit.png ``` -- img2img uses dual CFG: `--cfg-scale` controls text guidance, `--img-cfg-scale` - controls image guidance (1.0 = image CFG disabled). -- Pass multiple `--image` paths for multi-reference editing. +- img2img uses dual CFG: `--cfg-scale` controls text guidance, while + `img_cfg_scale` in `--extra-args` controls image guidance (1.0 = image CFG + disabled). +- Pass multiple `--image` paths for multi-reference editing when the underlying + pipeline supports them. #### Image Understanding (img2text) ```bash -python examples/offline_inference/sensenova_u1/end2end.py \ - --modality img2text \ +vllm serve SenseNova/SenseNova-U1-8B-MoT --omni --port 8091 +python examples/online_serving/sensenova_u1/openai_chat_client.py \ --prompt "Describe this image in detail" \ - --image photo.jpg \ - --max-tokens 512 + --modality img2text \ + --image-url photo.jpg ``` #### Text-to-Text Chat (text2text) ```bash -python examples/offline_inference/sensenova_u1/end2end.py \ - --modality text2text \ +vllm serve SenseNova/SenseNova-U1-8B-MoT --omni --port 8091 +python examples/online_serving/sensenova_u1/openai_chat_client.py \ --prompt "Explain the theory of relativity in simple terms" \ - --max-tokens 256 + --modality text2text ``` -- For img2text and text2text, image generation parameters (height, width, - num-steps, cfg-scale) are ignored. -- Use `--do-sample --temperature 0.7` for more diverse text responses. +- For img2text and text2text, use the online chat-compatible example. The + offline generic image examples intentionally focus on image-producing tasks. ### 2x H200 (144GB) — TP=2 @@ -149,19 +152,20 @@ python examples/offline_inference/sensenova_u1/end2end.py \ #### Command ```bash -python examples/offline_inference/sensenova_u1/end2end.py \ +python examples/offline_inference/text_to_image/text_to_image.py \ + --model SenseNova/SenseNova-U1-8B-MoT \ --prompt "Close portrait of an elderly woman by a farmhouse window, textured skin, gentle smile, warm natural light, emotional documentary look. The portrait should feel polished and natural, with sharp eyes, realistic skin texture, accurate facial anatomy, and premium lighting that keeps the face as the main focus." \ --width 1536 --height 2720 \ - --seed 42 --num-steps 50 \ - --cfg-scale 4.0 --timestep-shift 3.0 --cfg-norm none \ - --think --print-think \ + --seed 42 --num-inference-steps 50 \ + --cfg-scale 4.0 \ + --extra-body '{"think": true, "cfg_norm": "none", "timestep_shift": 3.0, "t_eps": 0.02}' \ --tensor-parallel-size 2 \ - --output outputs + --output outputs/sensenova_u1_output.png ``` #### Verification -Verify the output image is generated at `outputs/sensenova_u1_output_0.png` +Verify the output image is generated at `outputs/sensenova_u1_output.png` with the expected 1536×2720 resolution. #### Notes diff --git a/recipes/Tencent/HunyuanImage-3.0-Instruct.md b/recipes/Tencent/HunyuanImage-3.0-Instruct.md index 3e6adf130..a9a63d5ce 100644 --- a/recipes/Tencent/HunyuanImage-3.0-Instruct.md +++ b/recipes/Tencent/HunyuanImage-3.0-Instruct.md @@ -8,8 +8,8 @@ - Vendor: Tencent Hunyuan - Model: `tencent/HunyuanImage-3.0-Instruct` -- Task: Text-to-image generation -- Mode: Online serving and performance benchmarking, DiT stage only +- Task: Text-to-image generation; text-to-text and image-to-text understanding +- Mode: Offline understanding, online serving, and performance benchmarking - Maintainer: Community ## When to use this recipe @@ -31,8 +31,10 @@ FP8/NVFP4 configuration: ## References - Model: -- Offline example: +- Existing model-specific offline example: [`examples/offline_inference/hunyuan_image3`](../../examples/offline_inference/hunyuan_image3) +- Shared T2T/I2T example: + [`examples/offline_inference/x_to_text/x_to_text.py`](../../examples/offline_inference/x_to_text/x_to_text.py) - Related PRs: [#2495](https://github.com/vllm-project/vllm-omni/pull/2495) for DiT performance CI, [#3055](https://github.com/vllm-project/vllm-omni/pull/3055) for GEBench accuracy CI, @@ -42,6 +44,31 @@ FP8/NVFP4 configuration: ## GPU +### Shared T2T/I2T offline example + +For text-to-text, the shared x-to-text example automatically selects the +AR-only Hunyuan deploy config and applies the checkpoint's prompt tokens and +stop-token rules: + +```bash +python examples/offline_inference/x_to_text/x_to_text.py \ + --model tencent/HunyuanImage-3.0-Instruct \ + --prompt "Explain why the sky appears blue." +``` + +Add `--image` for image-to-text: + +```bash +python examples/offline_inference/x_to_text/x_to_text.py \ + --model tencent/HunyuanImage-3.0-Instruct \ + --image /path/to/input.jpg \ + --prompt "Describe the content of this image." +``` + +The AR-only default uses four GPUs. Pass `--deploy-config` to override the +layout. Image-output tasks remain documented by the existing model-specific +example and the DiT sections below. + ### 4x H100/H800 80GB #### Environment @@ -137,10 +164,11 @@ curl -s http://localhost:8091/v1/chat/completions \ PR [#2495](https://github.com/vllm-project/vllm-omni/pull/2495) adds performance CI configs for the same DiT-only settings. The CI step is currently opt-in (gated by `RUN_HUNYUAN_IMAGE3_PERF=1`) with `soft_fail` -enabled, intended for initial data collection. Performance assertions are -skipped (`skip-performance-assertion: true`); the baseline values in the -JSON configs are reference-only and will be promoted to regression gates -once enough nightly data has been collected. +enabled, intended for initial data collection. Perf runners no longer +compare metrics against JSON baselines at assert time; the `baseline` +blocks in the configs are reference-only and are copied into result JSON +for reporting. Regression gates can be reintroduced once enough nightly +data has been collected. The user-facing equivalent is to launch one of the CLI commands above and generate 1024x1024 images with 50 denoising steps. diff --git a/recipes/Wan-AI/Wan2.2-T2V.md b/recipes/Wan-AI/Wan2.2-T2V.md new file mode 100644 index 000000000..640129a47 --- /dev/null +++ b/recipes/Wan-AI/Wan2.2-T2V.md @@ -0,0 +1,186 @@ +# Wan2.2 Text-to-Video + +> Text-to-video serving (Wan2.2 14B), with optional Skip-Softmax sparse attention on Blackwell + +## Summary + +- Vendor: Wan-AI +- Model: `Wan-AI/Wan2.2-T2V-A14B-Diffusers` +- Task: Text-to-video generation +- Mode: Online serving with the OpenAI-compatible API (offline `Omni` also supported) +- Maintainer: Community + +## When to use this recipe + +Use this recipe to deploy the Wan2.2 14B text-to-video model with vLLM-Omni. The +standard plan is multi-card serving with sequence/CFG parallelism (below). On +**datacenter Blackwell** (sm_100 / sm_103) you can additionally enable +**Skip-Softmax** through the `TRTLLM_ATTN` backend for extra speed — see the +Blackwell section. + +## References + +- Upstream model card: +- Attention backends guide: [`docs/user_guide/diffusion/attention_backends.md`](../../docs/user_guide/diffusion/attention_backends.md) +- Skip-Softmax design: [`docs/design/feature/skip_softmax.md`](../../docs/design/feature/skip_softmax.md) + +## Hardware Support + +## GPU + +### 8x NVIDIA H20 / H100 / A100 (standard multi-card serving) + +The recommended parallel strategy depends on whether the checkpoint needs +classifier-free guidance: + +1. **Distilled model (no CFG)** — higher throughput; use when the checkpoint does + not require negative-prompt computation. +2. **Official open-source model (with CFG)** — uses CFG parallelism to run the + negative and positive samples for the original released weights. + +#### Environment + +- OS: Linux +- Python: 3.10+ +- Driver / runtime: NVIDIA driver with a CUDA runtime supported by your PyTorch build +- vLLM version: Match the repository requirements for your checkout +- vLLM-Omni version or commit: Use the commit you are deploying from + +#### Command + +**Distilled model (no CFG, recommended for distilled checkpoints):** + +```bash +CUDA_VISIBLE_DEVICES=0,1,2,3,4,5,6,7 \ +vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers \ + --omni \ + --use-hsdp \ + --usp 8 \ + --vae-patch-parallel-size 8 \ + --vae-use-tiling +``` + +**Official open-source model (with CFG):** + +```bash +CUDA_VISIBLE_DEVICES=0,1,2,3,4,5,6,7 \ +vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers \ + --omni \ + --use-hsdp \ + --cfg-parallel-size 2 \ + --usp 4 \ + --vae-patch-parallel-size 8 \ + --vae-use-tiling +``` + +The official model splits the positive and negative CFG branches with +`--cfg-parallel-size 2`, keeping Ulysses sequence parallelism at `--usp 4` so the +two branches cover all 8 GPUs (`usp * cfg = 8`). + +#### Verification + +For online serving client examples and request formats, see +[`examples/online_serving/text_to_video`](../../examples/online_serving/text_to_video). + +#### Notes + +- **Key flags:** + - `--omni` — enables vLLM-Omni diffusion serving. + - `--use-hsdp` — Hybrid Sharded Data Parallelism for the 14B DiT weights. + - `--usp ` — Unified (Ulysses) Sequence Parallelism degree. + - `--cfg-parallel-size ` — CFG parallelism; set to 2 for the official model, + omit for distilled checkpoints. + - `--vae-patch-parallel-size 8` / `--vae-use-tiling` — parallel + tiled VAE + decoding; disabling patch parallelism can significantly increase VAE latency. + +## Accelerating with TRTLLM_ATTN + Skip-Softmax (datacenter Blackwell) + +On **datacenter Blackwell** (sm_100 / sm_103), the mask-free Wan pipeline +auto-routes to the `TRTLLM_ATTN` backend, which can enable **Skip-Softmax** — a +sparse-attention mode that trades a little fidelity for speed. This composes with +the multi-card plan above (Ulysses SP is compatible; ring SP is not). + +### Environment + +- Same as above, plus: datacenter Blackwell (sm_100 / sm_103), `head_dim == 128`, + and `flashinfer` (already a hard dependency of vLLM — no extra install). + +### Command + +Dense BF16 (Skip-Softmax off — `TRTLLM_ATTN` is already the Blackwell auto-route +for mask-free Wan, so no flags are needed): + +```bash +vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers --omni +``` + +With Skip-Softmax (calibrated curve — the `(a, b)` curve is read from the +checkpoint's `sparse_attention_config`; you only pick the operating point): + +```bash +vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers \ + --omni \ + --diffusion-attention-config '{"default": {"backend": "TRTLLM_ATTN", + "skip_softmax": {"target_sparsity": 0.65, "disabled_until_timestep": 0.86}}}' +``` + +Offline, the same config is a typed object (or the equivalent dict) — values are +validated at construction: + +```python +from vllm_omni import Omni +from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, SkipSoftmaxSpec + +llm = Omni( + model="Wan-AI/Wan2.2-T2V-A14B-Diffusers", + diffusion_attention_config=AttentionConfig( + default=AttentionSpec( + backend="TRTLLM_ATTN", + skip_softmax=SkipSoftmaxSpec(target_sparsity=0.65, disabled_until_timestep=0.86), + ), + ), +) +``` + +Calibration-free path (no checkpoint curve needed — set an absolute threshold +instead of `target_sparsity`): + +```bash +--diffusion-attention-config '{"default": {"backend": "TRTLLM_ATTN", + "skip_softmax": {"threshold": 0.02, "disabled_until_timestep": 0.86}}}' +``` + +### Skip-Softmax controls + +| Key | Valid values | Meaning | +|---|---|---| +| `target_sparsity` | finite, `[0, 1]` | Operating point on the checkpoint's calibrated curve (`a·exp(b·s)`). | +| `threshold` | finite, `≥ 0` | Direct skip threshold, no calibration needed. Mutually exclusive with `target_sparsity`. | +| `disabled_until_timestep` | finite, `[0, 1]` | Keeps early, high-noise steps dense; skip turns on once the normalized timestep `t ≤ D`. | + +**Start at `target_sparsity=0.65, disabled_until_timestep=0.86`** (near-lossless) +and raise `target_sparsity` only as far as your quality bar allows — the output +diverges from dense as it climbs. Gate the noisy early steps first (`D`), then +raise sparsity; the timestep gate buys more speed per unit of divergence than +sparsity does. The gain grows with sequence length, since Skip-Softmax only +accelerates attention. + +Measured on B300 / SM103, 1280×720 / 81f / 50 steps, torch.compile, official +ModelOpt calibration (speedup and divergence relative to dense): + +| config | speedup | LPIPS (divergence vs dense) | +|---|---|---| +| dense | 1.000x | - | +| `s=0.65, D=0.86` | 1.062x | 0.041 | +| `s=0.75, D=1.00` | 1.181x | 0.377 | +| `s=1.00` | 1.202x | 0.597 | + +### Notes + +- **Requirements:** datacenter Blackwell (sm_100 / sm_103), `head_dim == 128`, and + `flashinfer`. Elsewhere, or without FlashInfer, selecting `TRTLLM_ATTN` raises + rather than silently degrading. +- **Known limitations:** + - Skip-Softmax is not supported under ring sequence parallelism; use Ulysses SP + (`--usp`), or run Skip-Softmax without ring. + - `TRTLLM_ATTN` is BF16-only (no attention-level quantization). diff --git a/recipes/cosmos3/Cosmos3-DistOffload.md b/recipes/cosmos3/Cosmos3-DistOffload.md new file mode 100644 index 000000000..8eeea5cc2 --- /dev/null +++ b/recipes/cosmos3/Cosmos3-DistOffload.md @@ -0,0 +1,199 @@ +# Cosmos3 — Distributed Layerwise Offload + +> Sharding weights across DP ranks with H2D + AllGather overlap for multi-NPU/GPU deployment + +## When to use this recipe + +Use this recipe when deploying Cosmos3-Super (64B / 124 GB) or Cosmos3-Nano (17B / 33 GB) across multiple devices where the model does not fit on a single device's HBM, or when you need higher throughput via DP multi-concurrency. + +**Key benefits:** +- Each rank stores only 1/dp_size of the model weights on host memory +- Only 2 transformer blocks reside on each device at any time (fixed double-buffer) +- DP multi-concurrency: N concurrent requests with near-linear throughput scaling +- mmap weight loading: shared page cache eliminates O(dp_size × model_size) RSS + +## Prerequisites + +- 2+ NPU or GPU devices (Ascend 910B3, NVIDIA B300, etc.) +- vLLM-Omni with distributed layerwise offload support +- Cosmos3-Nano or Cosmos3-Super checkpoint (local path or HuggingFace repo ID) + +## Serve commands + +### Cosmos3-Nano with DP=4 + AllGather (recommended for max throughput) + +```bash +vllm serve /path/to/Cosmos3-Nano \ + --omni \ + --host 0.0.0.0 --port 8000 \ + --no-guardrails --init-timeout 3600 \ + --vae-use-tiling --vae-patch-parallel-size 1 \ + --enable-distributed-layerwise-offload \ + --data-parallel-size 4 +``` + +### Cosmos3-Super with DP=2 + AllGather (for models > single-card HBM) + +```bash +vllm serve /path/to/Cosmos3-Super \ + --omni \ + --host 0.0.0.0 --port 8000 \ + --no-guardrails --init-timeout 3600 \ + --vae-use-tiling --vae-patch-parallel-size 1 \ + --enable-distributed-layerwise-offload \ + --data-parallel-size 2 +``` + +### Cosmos3-Super with DP=4 + AllGather + +```bash +vllm serve /path/to/Cosmos3-Super \ + --omni \ + --host 0.0.0.0 --port 8000 \ + --no-guardrails --init-timeout 3600 \ + --vae-use-tiling --vae-patch-parallel-size 1 \ + --enable-distributed-layerwise-offload \ + --data-parallel-size 4 +``` + +### Without DLO AllGather (rank-local weights, no DLO sharding) + +Use `--dlo-no-use-allgather` when you want each rank to stream the standard +loader's rank-local weights independently — no DLO AllGather synchronization, +no concurrent request requirement, but no DLO weight sharding and higher host +memory use. In pure DP, this is equivalent to independent full-weight +layerwise-offload replicas; with TP or HSDP, each rank retains the standard +loader's local tensor layout. + +```bash +vllm serve /path/to/Cosmos3-Nano \ + --omni \ + --host 0.0.0.0 --port 8000 \ + --no-guardrails --init-timeout 3600 \ + --vae-use-tiling --vae-patch-parallel-size 1 \ + --enable-distributed-layerwise-offload \ + --data-parallel-size 4 \ + --dlo-no-use-allgather +``` + +### With SP (sequence parallel) instead of DP + +For long-sequence workloads where SP parallelism is preferred: + +```bash +vllm serve /path/to/Cosmos3-Super \ + --omni \ + --host 0.0.0.0 --port 8000 \ + --no-guardrails --init-timeout 3600 \ + --vae-use-tiling --vae-patch-parallel-size 1 \ + --enable-distributed-layerwise-offload \ + --usp 4 +``` + +## Request examples + +### Text-to-Image (T2I) + +```bash +curl -s -o output.png -X POST "http://localhost:8000/v1/images/generations" \ + -H "Content-Type: multipart/form-data" \ + -F "model=/path/to/Cosmos3-Nano" \ + -F "prompt=A robot arm cleaning a plate, cinematic" \ + -F "size=1024x1024" \ + -F "num_inference_steps=50" \ + -F "guidance_scale=6.0" \ + -F "seed=42" +``` + +### Text-to-Video (T2V) + +```bash +curl -s -o output.mp4 -X POST "http://localhost:8000/v1/videos/sync" \ + -H "Accept: video/mp4" \ + -F "model=/path/to/Cosmos3-Nano" \ + -F "prompt=A robot arm cleaning a plate, cinematic shot" \ + -F "negative_prompt=blurry" \ + -F "size=832x480" \ + -F "num_frames=29" \ + -F "fps=24" \ + -F "num_inference_steps=35" \ + -F "guidance_scale=6.0" \ + -F "max_sequence_length=4096" \ + -F "flow_shift=10.0" \ + -F 'extra_params={"use_resolution_template":false,"use_duration_template":false,"guardrails":false}' \ + -F "seed=17" +``` + +> **Important:** When using DP multi-concurrency (AllGather mode), `num_inference_steps` +> must be specified explicitly and must be the same for all concurrent requests. +> `num_inference_steps=None` is not allowed because it may resolve differently +> per request mode (e.g., T2V=35 vs action_mode=30), which would deadlock AllGather. + +### Multiple concurrent requests (DP multi-concurrency) + +With `--data-parallel-size 4`, send 4 concurrent requests to maximize throughput: + +```bash +for i in 1 2 3 4; do + curl -s -o output_${i}.mp4 -X POST "http://localhost:8000/v1/videos/sync" \ + -H "Accept: video/mp4" \ + -F "model=/path/to/Cosmos3-Nano" \ + -F "prompt=A robot arm cleaning a plate, shot ${i}" \ + -F "negative_prompt=blurry" \ + -F "size=832x480" \ + -F "num_frames=29" \ + -F "fps=24" \ + -F "num_inference_steps=35" \ + -F "guidance_scale=6.0" \ + -F "max_sequence_length=4096" \ + -F "flow_shift=10.0" \ + -F 'extra_params={"use_resolution_template":false,"use_duration_template":false,"guardrails":false}' \ + -F "seed=17" & +done +wait +``` + +## CLI flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--enable-distributed-layerwise-offload` | Enable DLO with H2D + AllGather overlap | `false` | +| `--data-parallel-size N` | Number of DP ranks; DLO weight sharding + concurrent requests when AllGather is enabled | `1` | +| `--dlo-use-allgather` | Use shard + AllGather for weight reconstruction (recommended) | `true` | +| `--dlo-no-use-allgather` | Stream standard-loader rank-local weights independently (no DLO sharding, no DLO AllGather) | `false` | +| `--usp N` | Use SP instead of DP for weight sharding (long sequences) | `1` | + +## Mode comparison + +| Mode | CLI flags | CPU/rank | HBM/card | Throughput | Use case | +|------|----------|---------|---------|-----------|----------| +| DLO + AllGather (DP4) | `--enable-distributed-layerwise-offload --data-parallel-size 4` | 1/4 model | 2 blocks | **3.3× HSDP** | Max throughput, short sequences | +| DLO + AllGather (DP2) | `--enable-distributed-layerwise-offload --data-parallel-size 2` | 1/2 model | 2 blocks | 2× HSDP | Balanced throughput + memory | +| DLO + AllGather (SP4) | `--enable-distributed-layerwise-offload --usp 4` | 1/4 model | 2 blocks | 1× (single req) | Long sequences (720p+) | +| DLO no-AllGather | `--enable-distributed-layerwise-offload --dlo-no-use-allgather` | Standard-loader rank-local weights | 2 blocks | 0.4× HSDP | No DLO AllGather overhead, high CPU | +| HSDP | `--use-hsdp --hsdp-shard-size 4` | 0 | 1/4 model | 1× (baseline) | Weights fit in HBM | + +## Memory expectations + +| Model | Config | Host RAM (cgroup) | HBM/card | +|-------|--------|-------------------|----------| +| Nano (33 GB) | DP2 + AllGather | ~38 GB | ~10 GB | +| Nano (33 GB) | DP4 + AllGather | ~47 GB | ~10 GB | +| Super (124 GB) | DP2 + AllGather | ~157 GB | ~15 GB | +| Super (124 GB) | DP4 + AllGather | ~172 GB | ~10 GB | + +> Host RAM is dominated by page cache (1× model_size, shared across ranks). +> HBM only holds 2 transformer blocks + framework + activations, independent of model size. + +## Limitations + +- `--dlo-use-allgather` (default) requires all concurrent requests to have the same + `num_inference_steps` (AllGather is a collective that needs all ranks synchronized) +- Online quantization (FP8) is incompatible with the DLO+AllGather mmap path; + use `--dlo-no-use-allgather` or disable online quantization +- Tensor Parallel > 1 is rejected in the DLO+AllGather mmap path. TP with + `--dlo-no-use-allgather` retains the standard TP loader and should be treated + as an experimental compatibility path +- HSDP + AllGather is rejected (would double-shard weights). HSDP with + `--dlo-no-use-allgather` is accepted at configuration level, but is not yet a + broadly validated production combination diff --git a/recipes/cosmos3/Cosmos3-Edge.md b/recipes/cosmos3/Cosmos3-Edge.md new file mode 100644 index 000000000..eeb5c3083 --- /dev/null +++ b/recipes/cosmos3/Cosmos3-Edge.md @@ -0,0 +1,104 @@ +# Cosmos3-Edge + +> Compact Nemotron-based Cosmos3 checkpoint for T2I / T2V / I2V and action-conditioned (Physical-AI) generation. + +## Summary + +- Vendor: NVIDIA +- Model: `nvidia/Cosmos3-Edge` +- Task: T2I, T2V, I2V, and action-conditioned generation (Physical-AI: `policy` / `forward_dynamics` / `inverse_dynamics`) +- Mode: Online (OpenAI-compatible image/video APIs) plus offline generation +- Maintainer: Community + +## When to use + +The small Cosmos3 checkpoint, for constrained deployments. It runs on the shared +`Cosmos3OmniDiffusersPipeline` but differs from Nano/Super in two ways: + +- **Transformer**: `Cosmos3EdgeVFMTransformer` (Nemotron-based), auto-selected from + `backbone_type: cosmos3_edge_nemotron_dense` — nothing to pass. +- **Defaults** differ. The offline examples auto-detect Edge (from `edge` in the model id / + transformer class) and apply the Edge column below, so you can omit `--height` / `--width` / + `--guidance-scale` / `--flow-shift` and still get native Edge output: + +| | Edge | Nano / Super | +|---|---|---| +| T2I | 640×640 | 1024² | +| T2V / I2V | 480×832 | 1280×720 | +| Video `guidance_scale` | 5.0 | 6.0 | +| Video `flow_shift` | 3.0 | 10.0 | + +> **Important:** these auto-defaults live in the example CLIs +> (`text_to_video.py` / `image_to_video.py`). Do **not** pass the Nano/Super numbers +> (720p / `guidance_scale` 6.0 / `flow_shift` 10.0) to Edge — they produce degenerate output. + +For the larger checkpoints see [Cosmos3-Nano](./Cosmos3-Nano.md) and [Cosmos3-Super](./Cosmos3-Super.md). + +## Support notes + +- **Rejected** (raise at request time): video-to-video, transfer V2V + (`edge` / `blur` / `depth` / `seg` / `wsm`), sound (`generate_sound=true`). +- **Acceleration**: `--quantization fp8` and attention-backend selection are supported. Cache-DiT and + Sequence Parallel are inherited from the base Cosmos3 transformer (Edge overrides neither) and act on + the shared GEN pathway — not separately benchmarked here. Tensor Parallel is untested on this checkpoint. + +## Usage + +Guardrails are on by default (gated `nvidia/Cosmos-1.0-Guardrail`; `pip install cosmos-guardrail` + +an `HF_TOKEN`). The examples pass `"guardrails": false` for a quick local run — you own license +compliance. + +### Offline + +```bash +# Text-to-image -> 640x640 +python examples/offline_inference/text_to_image/text_to_image.py \ + --model nvidia/Cosmos3-Edge \ + --prompt "A photorealistic red sports car at golden hour, cinematic lighting." \ + --extra-body '{"guardrails": false}' --output t2i.png + +# Text-to-video -> 480x832 (for I2V: use image_to_video.py + --image ) +python examples/offline_inference/text_to_video/text_to_video.py \ + --model nvidia/Cosmos3-Edge \ + --prompt "A robot arm is cleaning a plate in the kitchen." \ + --num-frames 49 \ + --extra-body '{"max_sequence_length": 4096, "guardrails": false}' --output t2v.mp4 +``` + +### Online + +```bash +vllm serve nvidia/Cosmos3-Edge --omni --host 0.0.0.0 --port 8000 --init-timeout 1800 +``` + +Add `--no-guardrails` to skip the safety checker. Request formats match the +[Cosmos3-Nano recipe](./Cosmos3-Nano.md#verification), minus the rejected modes above. + +### Action (Physical-AI) + +Action-conditioned generation uses the same shared pipeline — pass +`extra_params={"action_mode": ...}`: + +- `forward_dynamics` — first frame/video **plus** an action trajectory → roll out the video + (sync `POST /v1/videos/sync`). +- `policy` — first frame/video + a language instruction → **predict** the action trajectory + (async `POST /v1/videos`; read the top-level `action` field). +- `inverse_dynamics` — a video → **recover** the action trajectory (async `POST /v1/videos`). + +See the [Cosmos3-Nano action section](./Cosmos3-Nano.md) for request/response shapes. For a +DROID-specialized policy model, use `nvidia/Cosmos3-Edge-Policy-DROID`. + +## Verification + +```bash +python -c "from PIL import Image; im=Image.open('t2i.png'); print('image', im.size, im.mode)" +ffprobe -v error -select_streams v:0 -show_entries stream=codec_type,nb_frames,width,height -of csv=p=0 t2v.mp4 +``` + +Expected: `image (640, 640) RGB` and `video,49,832,480`. + +## References + +- Model card: +- Pipeline: [`pipeline_cosmos3.py`](../../vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py) +- Edge transformer: [`transformer_cosmos3_edge.py`](../../vllm_omni/diffusion/models/cosmos3/transformer_cosmos3_edge.py) diff --git a/recipes/cosmos3/Cosmos3-Nano.md b/recipes/cosmos3/Cosmos3-Nano.md index c3282ba51..b27d1b473 100644 --- a/recipes/cosmos3/Cosmos3-Nano.md +++ b/recipes/cosmos3/Cosmos3-Nano.md @@ -423,6 +423,156 @@ ffprobe -v error -show_entries stream=codec_type,nb_frames,width,height cosmos3_ once in `vllm_omni/model_extras/cosmos3.py` and forwarded through `--extra-body`; unknown keys for the model are dropped. +## ROCm + +### 1x MI350X (Online serving) + +#### Environment + +- OS: Ubuntu 22.04+ +- Python: 3.12+ +- Driver / runtime: ROCm, HIP 7.2, **gfx942 or gfx950** (validated on gfx950 / MI350X, + which exposes 252 GiB of HBM to PyTorch) +- PyTorch: 2.11.0 (ROCm build) +- vLLM version: match the repository requirements from your current checkout +- vLLM-Omni version or commit: use the commit you are deploying from + +No ROCm-specific patch or flag is needed: Cosmos3 runs on the stock code path. + +#### Command + +The NVIDIA command works unchanged. Guardrails need the gated +`nvidia/Cosmos-1.0-Guardrail` repo, so the quick path is to disable them (you are +then responsible for license compliance): + +```bash +vllm serve nvidia/Cosmos3-Nano \ + --omni \ + --no-guardrails \ + --host 0.0.0.0 --port 8000 \ + --init-timeout 1800 +``` + +For 720p video, add `--vae-use-tiling`: it cuts peak VRAM by ~68% for ~13% +latency, which is the difference between needing a 120 GiB card and a 40 GiB one. +`--quantization fp8` and `--enable-layerwise-offload` are also supported; see the +Notes for the measured cost of each. For extra GPUs use `--ulysses-degree N` or +`--tensor-parallel-size N` (not validated on ROCm yet). + +#### Verification + +Use the same `curl` requests as the CUDA section above. The ROCm images do not +ship `ffprobe`, so the CUDA section's `ffprobe` line does not work here. `imageio` +and `pyav` are already installed and report the same thing: + +```bash +python -c "from PIL import Image; im=Image.open('cosmos3_t2i.png'); print(im.size, im.mode)" +python -c "import imageio.v3 as iio; print(iio.improps('cosmos3_t2v.mp4', plugin='pyav').shape)" +``` + +Expect `(1024, 1024) RGB` and `(189, 720, 1280, 3)` for the requests above. +Re-running the same request with the same seed reproduces a byte-identical mp4, +so an md5 comparison across two runs also works as a smoke check. + +#### Notes + +- **Warm up once per output shape before timing anything.** The first generation + of a shape pays a one-time cost that is cached on disk and reused by later + processes in the same container: a ~250 s aiter attention-kernel JIT build plus + a ~390 s build of the VAE decode path *for that shape*, so warming 1024² images + does nothing for 189-frame video. At 720p / 189 frames / 35 steps this is + **540 s cold vs 161 s warm, with byte-identical output**. +- **Measured on 1x MI350X (bf16, guardrails off, warm):** T2I 1024² @ 50 steps + **~2.7 s**; T2V 1280×720 / 189 frames @ 35 steps **~161 s**, of which ~92% is + the DiT denoise loop and ~4% VAE decode, so optimization effort belongs in the + denoise loop. The optional flags act on different terms of the memory bill and + are therefore complementary rather than redundant: + + | Flag | Latency | Peak reserved | Peak allocated | Acts on | + |---|---|---|---|---| + | *(none)* | 161 s | 120 GiB | 95 GiB | — | + | `--vae-use-tiling` | 183 s (+13.5%) | **38 GiB** (−68%) | **36 GiB** (−62%) | decode activations | + | `--enable-layerwise-offload` | 162 s (+0.8%) | 84 GiB (−30%) | 69 GiB (−27%) | weights | + | `--quantization fp8` | 150 s (−6.6%) | 107 GiB (−11%) | 82 GiB (−14%) | weights (online, no calibration) | + + Reserved is the caching-allocator high water mark and runs ~2–25 GiB above + allocated; allocated is the figure to compare against another platform. +- **`--vae-use-tiling` is by far the biggest lever for video.** Of the 95 GiB + allocated by default only ~35 GiB is weights and fixed overhead; the remaining + **60 GiB is activations from decoding all 189 frames in one piece**, which is + why fp8 and offload cannot touch it and why tiling, which leaves under 5 GiB of + them, wins so much. `--vae-use-slicing` is a no-op for a single video request: + peak memory is bit-identical to the default. +- **`--enable-layerwise-offload` is close to free on this part:** per-block DiT + compute is long enough to hide the weight transfer, so on a card this size it + buys headroom for concurrency or higher resolution rather than the + speed-for-memory trade it is on small cards. +- **Attention backend:** the numbers above are aiter FlashAttention, not SDPA. + ROCm picks aiter when it is installed and the encoded device capability + satisfies `90 < major * 10 + minor < 100` (gfx942 and gfx950); otherwise it + falls back to `TORCH_SDPA` and says so only at debug level, which is easy to + miss. If you are reporting numbers, assert the choice instead of trusting the + default: `RocmOmniPlatform.get_diffusion_attn_backend_cls(None, 128)` returns + the resolved path. +- **Determinism is per-configuration:** same seed and same flags reproduce a + byte-identical mp4 across processes, but different configurations do not match + each other — including offload, which nominally only relocates weights, so do + not use it as a bit-exact regression baseline. Quality was not evaluated for + any configuration. + +### 1x MI350X (Offline generation) + +#### Environment + +Same as the online serving section above. + +#### Command + +The standard task examples run unchanged: + +```bash +# Text-to-image +python examples/offline_inference/text_to_image/text_to_image.py \ + --model nvidia/Cosmos3-Nano \ + --prompt "A photorealistic red sports car at golden hour, cinematic lighting." \ + --negative-prompt "blurry, distorted, low quality" \ + --height 1024 --width 1024 --num-inference-steps 50 --guidance-scale 7.0 \ + --extra-body '{"flow_shift": 3.0, "guardrails": false}' \ + --output cosmos3_t2i.png + +# Text-to-video +python examples/offline_inference/text_to_video/text_to_video.py \ + --model nvidia/Cosmos3-Nano \ + --prompt "A robot arm is cleaning a plate in the kitchen." \ + --negative-prompt "blurry, distorted, low quality" \ + --height 720 --width 1280 --num-frames 189 --fps 24 \ + --num-inference-steps 35 --guidance-scale 6.0 --flow-shift 10.0 \ + --extra-body '{"max_sequence_length": 4096, "guardrails": false, + "use_resolution_template": false, "use_duration_template": false}' \ + --output cosmos3_t2v.mp4 +``` + +Both scripts print `Total generation time` and `Worker peak GPU memory +(reserved)`, which is where the latency and reserved figures in the Notes above +come from. Add `--vae-use-tiling`, `--quantization fp8` or +`--enable-layerwise-offload` to reproduce the other rows. The allocated column +needs `VLLM_LOGGING_LEVEL=DEBUG`, which enables a per-request line reporting +reserved, allocated and pool overhead together. + +#### Verification + +Same as the online serving section above. + +#### Notes + +- Run each command twice and read the second run; the first pays the one-time + compilation described above. If you run in a container, keep the container + alive between runs (`docker run --rm` throws the JIT cache away and you pay + the ~250 s kernel build every time). +- Latency and memory figures are identical to the online serving section — they + were taken from this path, because a single process has no client/server + timing ambiguity. + ## NPU ### 1x Ascend 910B / 910C (Atlas A2 / A3) — Online serving diff --git a/recipes/cosmos3/Cosmos3-Super.md b/recipes/cosmos3/Cosmos3-Super.md index c692b100c..54d4d7e32 100644 --- a/recipes/cosmos3/Cosmos3-Super.md +++ b/recipes/cosmos3/Cosmos3-Super.md @@ -125,3 +125,134 @@ curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \ under the top-level `action` field. Verified on the 64B Super under `--cfg-parallel-size 2`: async `policy` returns the predicted action (`[16, 10]`) and the rollout video reliably. + +## NPU + +### 8× Ascend910 (A2, A3) + +#### Environment + +- OS: Linux +- Python: 3.10+ +- Driver / runtime: Ascend NPU driver with CANN toolkit +- Recommended operator library: **mindie-sd** (Ascend high-performance fused + operators — enables `adalayernorm` and other fused kernels automatically upon + installation) +- vLLM version: Match the repository requirements for your checkout +- vLLM-Omni version or commit: Use the commit you are deploying from + +A pre-built Docker image is available on +[Docker Hub](https://hub.docker.com/r/vllm/vllm-omni) and +[Quay.io](https://quay.io/ascend/vllm-omni). Ensure the image tag matches your +vLLM-Omni checkout so that NPU-specific code is in sync with the container. + +#### Prerequisites + +Install the **mindie-sd** operator library to enable Ascend-optimized fused +operators (`adalayernorm`, etc.): + +```bash +git clone https://gitcode.com/Ascend/MindIE-SD.git && cd MindIE-SD + +# Comment out the tik_ops build step (not needed for this use case) +sed -i 's|^\(\s*\)source ${current_script_dir}/build_tik_ops.sh|\1# source ${current_script_dir}/build_tik_ops.sh|' build/build_ops.sh + +python setup.py bdist_wheel +cd dist +pip install mindiesd-*.whl +``` + +After installation, enable the Laser Attention kernel for significant +long-sequence speedups: + +```bash +export MINDIE_SD_FA_TYPE=ascend_laser_attention +``` + +#### Command + +```bash +export MINDIE_SD_FA_TYPE=ascend_laser_attention + +vllm serve nvidia/Cosmos3-Super \ + --omni \ + --host 0.0.0.0 --port 8000 \ + --tensor-parallel-size 8 \ + --model-class-name Cosmos3OmniDiffusersPipeline \ + --no-guardrails \ + --init-timeout 1800 +``` + +#### Verification + +Same requests as the GPU section above — all modes (T2I, T2V, I2V, V2V, +T2VS, I2VS) work identically on NPU. Quick reference with +`--no-guardrails`: + +```bash +curl http://localhost:8000/v1/models + +# T2I (1024x1024, 50 steps) +curl -sS -X POST http://localhost:8000/v1/images/generations \ + -H "Content-Type: application/json" \ + -d '{ + "model": "nvidia/Cosmos3-Super", + "prompt": "A photorealistic red sports car on a city street at golden hour, cinematic lighting.", + "size": "1024x1024", "n": 1, "response_format": "b64_json", + "num_inference_steps": 50, "guidance_scale": 7.0, "seed": 42 + }' | python3 -c "import sys,json,base64; open('cosmos3_super_t2i.png','wb').write(base64.b64decode(json.load(sys.stdin)['data'][0]['b64_json']))" + +# T2V (1280×720, 189 frames, 35 steps — official params) +curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \ + -F "model=nvidia/Cosmos3-Super" -F "prompt=A robot arm is cleaning a plate in the kitchen" \ + -F "size=1280x720" -F "num_frames=189" -F "fps=24" -F "num_inference_steps=35" \ + -F "guidance_scale=6.0" -F "max_sequence_length=4096" -F "flow_shift=10.0" \ + -F 'extra_params={"use_resolution_template":false,"use_duration_template":false,"guardrails":false}' \ + -F "seed=17" -o cosmos3_super_t2v.mp4 + +# I2V — add an uploaded reference image +curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \ + -F "model=nvidia/Cosmos3-Super" -F "prompt=The scene comes to life with smooth, natural motion." \ + -F "size=1280x720" -F "num_frames=189" -F "fps=24" -F "num_inference_steps=35" \ + -F "guidance_scale=6.0" -F "max_sequence_length=4096" -F "flow_shift=10.0" \ + -F 'extra_params={"use_resolution_template":false,"use_duration_template":false,"guardrails":false}' \ + -F "seed=1111" -F "input_reference=@/path/to/reference.jpg;type=image/jpeg" \ + -o cosmos3_super_i2v.mp4 + +# V2V — add an uploaded reference video. condition_video_keep can be "first" or "last". +curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \ + -F "model=nvidia/Cosmos3-Super" -F "prompt=Continue the same scene with smooth natural motion." \ + -F "size=1280x720" -F "num_frames=189" -F "fps=24" -F "num_inference_steps=35" \ + -F "guidance_scale=6.0" -F "max_sequence_length=4096" -F "flow_shift=10.0" \ + -F 'extra_params={"condition_frame_indexes_vision":[0,1],"condition_video_keep":"first","guardrails":false}' \ + -F "seed=2222" -F "input_reference=@/path/to/reference.mp4;type=video/mp4" \ + -o cosmos3_super_v2v.mp4 + +# T2V + sound — add generate_sound/sound_duration (output muxes AAC 48 kHz stereo) +curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \ + -F "model=nvidia/Cosmos3-Super" -F "prompt=A robot arm is cleaning a plate in the kitchen" \ + -F "size=1280x720" -F "num_frames=189" -F "fps=24" -F "num_inference_steps=35" \ + -F "guidance_scale=6.0" -F "max_sequence_length=4096" -F "flow_shift=10.0" \ + -F "generate_sound=true" -F "sound_duration=7.875" \ + -F 'extra_params={"use_resolution_template":false,"use_duration_template":false,"guardrails":false}' \ + -F "seed=17" -o cosmos3_super_t2vs.mp4 + +# I2V + sound — reference image with synchronized audio +curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \ + -F "model=nvidia/Cosmos3-Super" -F "prompt=The scene comes to life with smooth, natural motion and ambient sound." \ + -F "size=1280x720" -F "num_frames=189" -F "fps=24" -F "num_inference_steps=35" \ + -F "guidance_scale=6.0" -F "max_sequence_length=4096" -F "flow_shift=10.0" \ + -F "generate_sound=true" -F "sound_duration=7.875" \ + -F 'extra_params={"use_resolution_template":false,"use_duration_template":false,"guardrails":false}' \ + -F "seed=1111" -F "input_reference=@/path/to/reference.jpg;type=image/jpeg" \ + -o cosmos3_super_i2vs.mp4 +``` + +#### Notes + +- **Parallelism:** `--tensor-parallel-size 8` matches the model's 8 KV heads (`num_key_value_heads: 8`, `num_attention_heads: 64` → GQA). This uses 8 davinci devices (NPU 0–3, both cores each). The remaining NPU 4–7 stay idle. +- **Model config:** 64 layers × 5120 hidden size × 64 attention heads, ~120 GB transformer on disk (27 shards). Each NPU device loads ~15 GB of model weights. +- **Peak HBM:** ~22.7 GB per device at startup (bf16 weights). Additional memory is allocated per-generation for KV cache and activations — resolution and frame count drive the peak. +- **Performance (verified on 8× Ascend910):** T2I 256² / 2 steps / guidance 1.0 → ~1.5 s. +- **Guardrails** are disabled with `--no-guardrails` (guards are on by default). The gated `nvidia/Cosmos-1.0-Guardrail` model and `cosmos-guardrail` package are not shipped. Add `guardrails: false` in `extra_params` for per-request overrides when the server has guardrails enabled. +- **Known limitations:** FP8 quantization not yet validated on Ascend NPU. `--enable-layerwise-offload` is available but untested on NPU. diff --git a/tests/ar_diffusion/test_pipeline_bridge.py b/tests/ar_diffusion/test_pipeline_bridge.py deleted file mode 100644 index 28c3c959d..000000000 --- a/tests/ar_diffusion/test_pipeline_bridge.py +++ /dev/null @@ -1,222 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -"""Tests for the ARDiffusionKVState paged-attention pipeline bridge.""" - -import pytest -import torch - -from vllm_omni.experimental.ar_diffusion.kv_cache import ( - ARDiffusionKVCache, - ARDiffusionKVConfig, - ARDiffusionPagedLayerContext, -) -from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState - -BLOCK = 16 -N_HEADS = 4 -HEAD_DIM = 64 - - -def make_state(num_layers=1, window_chunks=4, cross_attn_length=0): - cfg = ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks) - kv = ARDiffusionKVCache( - cfg, - num_layers=num_layers, - num_kv_heads=N_HEADS, - head_size=HEAD_DIM, - dtype=torch.float32, - block_size=BLOCK, - max_model_len=4096, - available_bytes=1 << 24, - cross_attn_length=cross_attn_length, - device=torch.device("cpu"), - ) - pos = kv.begin_request("r-pos") - neg = kv.begin_request("r-neg") - return kv, ARDiffusionKVState(kv, pos, neg, num_layers=num_layers) - - -def _prepare_and_commit(st: ARDiffusionKVState, is_negative: bool, n_chunks: int) -> None: - ctx = st.get_kv_caches(is_negative, seq_len=n_chunks * BLOCK, commit_current=True)[0].forward_ctx - ctx.ensure_video_slots(torch.device("cpu")) - st.commit_paged_context(is_negative) - - -def test_get_returns_paged_layer_contexts_when_nothing_committed(): - _, st = make_state(num_layers=2) - for neg in (False, True): - contexts = st.get_kv_caches(neg, seq_len=BLOCK, commit_current=False) - assert len(contexts) == 2 - assert all(isinstance(ctx, ARDiffusionPagedLayerContext) for ctx in contexts) - assert contexts[0].history_block_ids == [] - - -def test_managed_paged_context_commits_only_after_forward(): - kv, st = make_state(num_layers=1) - contexts = st.get_kv_caches(False, seq_len=2 * BLOCK, commit_current=True) - ctx = contexts[0].forward_ctx - - assert st.pos.completed_chunks == 0 - assert kv.window_block_ids(st.pos) == [] - ctx.ensure_video_slots(torch.device("cpu")) - assert st.pos.completed_chunks == 0 - assert len(ctx.current_video_block_ids) == 2 - - st.commit_paged_context(False) - - assert st.pos.completed_chunks == 2 - assert st._committed[False] == 2 * BLOCK - assert len(kv.window_block_ids(st.pos)) == 2 - - -def test_scratch_paged_context_does_not_commit(): - kv, st = make_state(num_layers=1) - contexts = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False) - ctx = contexts[0].forward_ctx - ctx.ensure_video_slots(torch.device("cpu")) - - assert ctx.current_video_block_ids == kv.scratch_block_ids(False, 0, 1) - st.commit_paged_context(False) - - assert st.pos.completed_chunks == 0 - assert st._committed[False] == 0 - assert kv.window_block_ids(st.pos) == [] - - -def test_eviction_bounds_resident_window(): - kv, st = make_state(num_layers=1, window_chunks=3) - _prepare_and_commit(st, False, 3) - assert len(kv.window_block_ids(st.pos)) == 3 - - _prepare_and_commit(st, False, 2) - ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False)[0].forward_ctx - visible_blocks, video_len = ctx.video_block_table(torch.device("cpu")) - assert len(visible_blocks) == 3 - assert video_len == 3 * BLOCK - - -def test_branches_are_independent(): - kv, st = make_state() - _prepare_and_commit(st, False, 1) - assert len(kv.window_block_ids(st.pos)) == 1 - assert kv.window_block_ids(st.neg) == [] - - -def test_reset_clears_session_window(): - kv, st = make_state(num_layers=1, window_chunks=4) - _prepare_and_commit(st, False, 2) - _prepare_and_commit(st, True, 1) - assert st._committed[False] == 2 * BLOCK and st._committed[True] == BLOCK - free_before = kv.manager.block_pool.get_num_free_blocks() - - st.reset() - - assert st._committed == {False: 0, True: 0} - assert st._paged_pending == {False: None, True: None} - assert kv.window_block_ids(st.pos) == [] - assert kv.window_block_ids(st.neg) == [] - assert kv.manager.block_pool.get_num_free_blocks() > free_before - _prepare_and_commit(st, False, 1) - assert len(kv.window_block_ids(st.pos)) == 1 - - -def test_get_cross_kv_caches_raises_before_populate(): - """Engine ownership guard: a cross-attn read before _kv_populate_cross must fail loud.""" - _, st = make_state() # cross_attn_length == 0, nothing populated - for neg in (False, True): - with pytest.raises(RuntimeError, match="cross-attn read before"): - st.get_cross_kv_caches(neg) - # Guards the full AND: the populated flag alone is not enough without a pool. - st._cross_text_populated[False] = True - with pytest.raises(RuntimeError, match="cross-attn read before"): - st.get_cross_kv_caches(False) - - -def test_get_cross_kv_caches_returns_pool_dicts_when_populated(): - L = 8 - kv, st = make_state(num_layers=2, cross_attn_length=L) - written = [] - for i in range(2): - k = torch.randn(1, L, N_HEADS, HEAD_DIM) - v = torch.randn(1, L, N_HEADS, HEAD_DIM) - kv.write_cross_kv(i, False, k, v) - written.append((k, v)) - st._cross_text_populated[False] = True - - out = st.get_cross_kv_caches(False) - assert len(out) == 2 - for i, (k, v) in enumerate(written): - assert out[i]["is_init"] is True - assert out[i]["k"].shape == (1, L, N_HEADS, HEAD_DIM) - assert torch.equal(out[i]["k"], k) - assert torch.equal(out[i]["v"], v) - - -def test_kv_create_is_noop_engine_owns_allocation(): - from unittest.mock import MagicMock - - from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline - - p = DreamZeroPipeline.__new__(DreamZeroPipeline) - p._ar_diffusion_kv_state = object() - state = MagicMock() - p._kv_create(state, 1, "float32", "cpu", 24, 4, 64) - state.create_kv_caches.assert_not_called() - - -def test_get_kv_cache_requires_frame_aligned_seqlen(): - _, st = make_state() - with pytest.raises(AssertionError, match="frame-aligned"): - st.get_kv_caches(False, seq_len=BLOCK + 1, commit_current=True) - - -def test_prepare_one_branch_allocates_nothing_for_the_other(): - """CFG-parallel laziness: a rank prepares only the branch it runs.""" - kv, st = make_state(num_layers=1, window_chunks=4) - pos_ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True)[0].forward_ctx - neg_ctx = st.get_kv_caches(True, seq_len=BLOCK, commit_current=True)[0].forward_ctx - - pos_ctx.prepare(device=torch.device("cpu"), action_len=0, query_len=BLOCK) - - assert pos_ctx._allocated_video - assert not neg_ctx._allocated_video - assert kv.window_block_ids(st.neg) == [] - - -def test_failed_forward_tears_down_session(monkeypatch): - """Review follow-up (hsliuustc0106): a forward that dies partway must not - leave allocated-but-uncommitted KV behind — the session is torn down (pool - blocks freed, model-local state dropped) and the error propagates.""" - from types import SimpleNamespace - - from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner - from vllm_omni.experimental.ar_diffusion.runner import ARDiffusionModelRunner - - kv, st = make_state(num_layers=1, window_chunks=4) - _prepare_and_commit(st, False, 2) # session owns pool blocks - free_before_failure = kv.manager.block_pool.get_num_free_blocks() - - runner = object.__new__(ARDiffusionModelRunner) - runner.kv_cache = kv - runner._ar_diffusion_states = __import__("collections").OrderedDict({"s1": st}) - runner._max_ar_diffusion_states = 4 - runner.pipeline = SimpleNamespace(_states={"s1": object()}, _ar_diffusion_kv_state=None) - runner.device = None - runner._perf_e2e_times = [] - - def boom(self, req, kv_prefetch_jobs=None): - raise RuntimeError("layer 17 exploded") - - monkeypatch.setattr(DiffusionModelRunner, "execute_model", boom) - req = SimpleNamespace( - request_id="r0", - sampling_params=SimpleNamespace(extra_args={"session_id": "s1"}), - ) - - with pytest.raises(RuntimeError, match="layer 17 exploded"): - ARDiffusionModelRunner.execute_model(runner, req) - - assert "s1" not in runner._ar_diffusion_states - assert "s1" not in runner.pipeline._states - assert runner.pipeline._ar_diffusion_kv_state is None - # close() freed the session's resident blocks. - assert kv.manager.block_pool.get_num_free_blocks() > free_before_failure diff --git a/tests/assets/hunyuan/hunyuan_baseline_npu.png b/tests/assets/hunyuan/hunyuan_baseline_npu.png new file mode 100644 index 000000000..1de1e8ff0 Binary files /dev/null and b/tests/assets/hunyuan/hunyuan_baseline_npu.png differ diff --git a/tests/e2e/accuracy/assets/hunyuan_image_ref.png b/tests/assets/hunyuan/hunyuan_image_ref.png similarity index 100% rename from tests/e2e/accuracy/assets/hunyuan_image_ref.png rename to tests/assets/hunyuan/hunyuan_image_ref.png diff --git a/tests/assets/minicpmo_4_5/response_required_16k.wav b/tests/assets/minicpmo_4_5/response_required_16k.wav new file mode 100644 index 000000000..ae4052493 Binary files /dev/null and b/tests/assets/minicpmo_4_5/response_required_16k.wav differ diff --git a/tests/assets/minicpmo_4_5/soft_interrupt_16k.wav b/tests/assets/minicpmo_4_5/soft_interrupt_16k.wav new file mode 100644 index 000000000..41b399af0 Binary files /dev/null and b/tests/assets/minicpmo_4_5/soft_interrupt_16k.wav differ diff --git a/tests/e2e/accuracy/assets/qwen_image_edit_2511_test1.png b/tests/assets/qwen_image_edit/qwen_image_edit_2511_test1.png similarity index 100% rename from tests/e2e/accuracy/assets/qwen_image_edit_2511_test1.png rename to tests/assets/qwen_image_edit/qwen_image_edit_2511_test1.png diff --git a/tests/e2e/accuracy/assets/qwen_image_edit_2511_test2.png b/tests/assets/qwen_image_edit/qwen_image_edit_2511_test2.png similarity index 100% rename from tests/e2e/accuracy/assets/qwen_image_edit_2511_test2.png rename to tests/assets/qwen_image_edit/qwen_image_edit_2511_test2.png diff --git a/tests/attention/test_fish_kvcache_attn.py b/tests/attention/test_fish_kvcache_attn.py index c82fd1858..d38380e13 100644 --- a/tests/attention/test_fish_kvcache_attn.py +++ b/tests/attention/test_fish_kvcache_attn.py @@ -100,8 +100,9 @@ def _decode_kv_cache( head_size: int = 128, dtype: torch.dtype = torch.float16, ) -> torch.Tensor: - # vLLM >=0.23.0 KV cache layout: key/value live on dim 1. - return torch.zeros((num_blocks, 2, block_size, num_kv_heads, head_size), dtype=dtype) + # vLLM >=0.23.0 KV cache layout: (num_blocks, num_kv_heads, block_size, + # 2*head_size) with K and V packed in the last dimension. + return torch.zeros((num_blocks, num_kv_heads, block_size, 2 * head_size), dtype=dtype) def test_fish_kvcache_enabled_by_default(monkeypatch): @@ -221,10 +222,11 @@ def _vllm_kv_cache_shape(num_blocks, block_size, num_kv_heads, head_size): def test_fish_kvcache_backend_unbinds_kv_on_vllm_cache_layout(monkeypatch): - # vLLM >=0.23.0 lays the KV cache out as (num_blocks, 2, block_size, - # num_kv_heads, head_size). The backend must unbind on dim 1 so each of - # key/value comes back as the 4-D (num_blocks, block_size, num_kv_heads, - # head_size) tensor the decode kernel expects. + # vLLM >=0.23.0 lays out the KV cache as (num_blocks, num_kv_heads, + # block_size, 2*head_size) with K and V packed in the last dimension. + # The backend must split on the content dim so each of key/value comes + # back as the 4-D (num_blocks, block_size, num_kv_heads, head_size) + # tensor the decode kernel expects. monkeypatch.setenv("VLLM_OMNI_FISH_KVCACHE_ATTN", "1") monkeypatch.setattr(fish_kvcache_backend, "is_available", lambda: True) monkeypatch.setattr(fish_kvcache_backend, "load_error", lambda: None) @@ -232,7 +234,8 @@ def test_fish_kvcache_backend_unbinds_kv_on_vllm_cache_layout(monkeypatch): num_blocks, block_size, num_kv_heads, head_size = 8, 16, 8, 128 cache_shape = _vllm_kv_cache_shape(num_blocks, block_size, num_kv_heads, head_size) - assert cache_shape == (num_blocks, 2, block_size, num_kv_heads, head_size) + # Upstream now returns a 4-D packed shape: (B, H, N, 2*D). + assert cache_shape == (num_blocks, num_kv_heads, block_size, 2 * head_size) captured = {} @@ -262,20 +265,21 @@ def test_fish_kvcache_backend_unbinds_kv_on_vllm_cache_layout(monkeypatch): def test_fish_kvcache_vllm_cache_unbinds_then_falls_back_until_contiguous(monkeypatch): # End-to-end with the real vLLM cache layout and the real (unmocked) guard. # - # The point of the unbind(1) fix: unbind(0) raised "too many values to - # unpack" on the 5-D vLLM 0.23.0 layout, so the request crashed outright. - # unbind(1) makes the request *work* again -- it unpacks cleanly and the - # backend gracefully falls back to the original attention with correct - # output. This test guards that fix and must NOT depend on the fast path - # firing. + # The point of the transpose+split fix: unbind(1) on the old 5-D layout + # raised "too many values to unpack" on the vLLM 0.23.0 layout, and the + # upstream now packs K/V into the last dimension (4-D shape). The + # transpose(1, 2) + split correctly unpacks into separate key/value tensors + # and the backend gracefully falls back to the original attention with + # correct output. This test guards that fix and must NOT depend on the fast + # path firing. # - # It also documents a known limitation (tracked separately): unbind(1) on - # the interleaved (num_blocks, 2, ...) layout yields *non-contiguous* - # key/value views, which the guard rejects on the is_contiguous() check. - # So the dimensions are compatible (4-D, block_size=16, head_size=128) -- - # the only thing keeping the fast path from engaging is contiguity, not - # shape. Making the fast path actually fire would require a .contiguous() - # copy and is out of scope here. + # It also documents a known limitation (tracked separately): + # transpose(1, 2) on the interleaved (B, H, N, 2*D) layout yields + # *non-contiguous* key/value views, which the guard rejects on the + # is_contiguous() check. So the dimensions are compatible (4-D, + # block_size=16, head_size=128) -- the only thing keeping the fast path + # from engaging is contiguity, not shape. Making the fast path actually + # fire would require a .contiguous() copy and is out of scope here. fish_kvcache_backend.reset_fish_kvcache_attn_stats() monkeypatch.setenv("VLLM_OMNI_FISH_KVCACHE_ATTN", "1") monkeypatch.setattr(fish_kvcache_backend, "is_available", lambda: True) @@ -300,13 +304,14 @@ def test_fish_kvcache_vllm_cache_unbinds_then_falls_back_until_contiguous(monkey output = torch.zeros_like(query) kv_cache = torch.zeros(cache_shape, dtype=torch.float16) - # Root cause record: unbind(1) gives the right 4-D shape but a non-contiguous view. - key_cache, value_cache = kv_cache.unbind(1) + # Root cause record: transpose(1,2) + split gives the right 4-D shape but + # a non-contiguous view. + key_cache, value_cache = kv_cache.transpose(1, 2).split(head_size, dim=-1) assert tuple(key_cache.shape) == (num_blocks, block_size, num_kv_heads, head_size) assert not key_cache.is_contiguous() - # unbind(1) no longer crashes (unbind(0) would have raised here) and the - # request completes via the correct fallback path. + # transpose+split no longer crashes (unbind(1) would have raised here) and + # the request completes via the correct fallback path. result = model.layers[0].self_attn.attn.impl.forward(None, query, None, None, kv_cache, _metadata(), output) assert result is output diff --git a/tests/benchmarks/test_daily_omni_pack_modes.py b/tests/benchmarks/test_daily_omni_pack_modes.py new file mode 100644 index 000000000..46ca369e5 --- /dev/null +++ b/tests/benchmarks/test_daily_omni_pack_modes.py @@ -0,0 +1,249 @@ +"""Tests for Daily-Omni ``--daily-omni-pack-mode`` packing recipes. + +The ``minicpm-interleave`` mode is an accuracy-critical protocol: MiniCPM-o only reaches +OpenBMB's reported Daily-Omni score when frames and 1s audio segments arrive strictly +interleaved, with ``max_slice_nums=1`` / ``use_image_id=False`` and an empty MiniCPM +system message (not the Qwen system prompt). These tests pin that contract so a refactor +cannot silently regress the score. + +vllm stubs are installed by tests/benchmarks/conftest.py before collection. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import numpy as np +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +# Load the data module directly (bypasses vllm_omni.__init__ heavy imports). +_REPO_ROOT = Path(__file__).resolve().parents[2] +_MODULE_PATH = _REPO_ROOT / "vllm_omni" / "benchmarks" / "data_modules" / "daily_omni_dataset.py" +_MODULE_NAME = "vllm_omni.benchmarks.data_modules.daily_omni_dataset" + +if _MODULE_NAME not in sys.modules: + _spec = importlib.util.spec_from_file_location(_MODULE_NAME, _MODULE_PATH) + _mod = importlib.util.module_from_spec(_spec) + sys.modules[_MODULE_NAME] = _mod + _spec.loader.exec_module(_mod) + +from vllm_omni.benchmarks.data_modules.daily_omni_dataset import ( # noqa: E402 + DailyOmniDataset, + _uniform_sample_indices, +) + +_VIDEO_ID = "vid001" +_NUM_FRAMES = 5 + + +@pytest.fixture +def video_dir(tmp_path: Path) -> Path: + """Official Daily-Omni ``Videos/`` layout with placeholder media files.""" + per_video = tmp_path / "Videos" / _VIDEO_ID + per_video.mkdir(parents=True) + (per_video / f"{_VIDEO_ID}_video.mp4").write_bytes(b"fake-mp4") + (per_video / f"{_VIDEO_ID}_audio.wav").write_bytes(b"fake-wav") + return tmp_path / "Videos" + + +@pytest.fixture +def qa_json(tmp_path: Path) -> Path: + path = tmp_path / "qa.json" + path.write_text( + json.dumps( + [ + { + "video_id": _VIDEO_ID, + "question": "What happens first?", + "choice": {"A": "a", "B": "b", "C": "c", "D": "d"}, + "answer": "A", + "task_type": "AV Event Alignment", + "video_duration": "30s", + } + ] + ), + encoding="utf-8", + ) + return path + + +def _make_dataset(qa_json: Path, video_dir: Path, **kwargs) -> DailyOmniDataset: + return DailyOmniDataset( + qa_json_path=str(qa_json), + video_dir=str(video_dir), + disable_shuffle=True, + **kwargs, + ) + + +def _stub_extract(monkeypatch, dataset: DailyOmniDataset, counter: list[int]) -> None: + """Replace decord/ffmpeg extraction with deterministic in-memory frames + segments.""" + from PIL import Image + + def fake_extract(video_path, *, audio_path, include_audio, **_kw): + counter.append(1) + frames = [Image.new("RGB", (16, 16), color=(i, i, i)) for i in range(_NUM_FRAMES)] + segments = [np.zeros(1600, dtype=np.float32) for _ in range(_NUM_FRAMES)] if include_audio else [] + return frames, segments + + monkeypatch.setattr(dataset, "_extract_minicpm_frame_audio_segments", fake_extract) + + +# --------------------------------------------------------------------------- +# _uniform_sample_indices +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize(("n", "k"), [(10, 4), (100, 64), (1000, 64), (7, 7), (3, 5), (1, 1)]) +def test_uniform_sample_indices_matches_openbmb_midpoint(n: int, k: int) -> None: + """Pin OpenBMB midpoint-bin sampling (not np.linspace endpoints).""" + got = _uniform_sample_indices(n, k) + if n <= k: + assert got == list(range(n)) + else: + gap = n / k + expected = [int(i * gap + gap / 2) for i in range(k)] + assert got == expected + # Endpoints differ from linspace for typical Daily-Omni downsample sizes. + linspace = [int(i) for i in np.linspace(0, n - 1, k, dtype=int)] + if n == 1000 and k == 64: + assert got[0] == 7 and got[-1] == 992 + assert linspace[0] == 0 and linspace[-1] == 999 + assert got != linspace + + +def test_uniform_sample_indices_non_positive_k() -> None: + assert _uniform_sample_indices(10, 0) == [] + assert _uniform_sample_indices(10, -1) == [] + + +# --------------------------------------------------------------------------- +# minicpm-interleave packing +# --------------------------------------------------------------------------- + + +def test_minicpm_interleave_alternates_image_and_audio(monkeypatch, qa_json, video_dir) -> None: + ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all") + _stub_extract(monkeypatch, ds, []) + + parts, extra, position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None) + + assert position == "first" + assert [p["type"] for p in parts] == ["image_url", "audio_url"] * _NUM_FRAMES + assert extra["mm_processor_kwargs"] == { + "use_audio_in_video": False, + "max_slice_nums": 1, + "use_image_id": False, + } + + +def test_minicpm_interleave_visual_mode_emits_frames_only(monkeypatch, qa_json, video_dir) -> None: + ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="visual") + _stub_extract(monkeypatch, ds, []) + + parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None) + + assert [p["type"] for p in parts] == ["image_url"] * _NUM_FRAMES + + +def test_minicpm_interleave_writes_file_urls_under_video_dir(monkeypatch, qa_json, video_dir) -> None: + ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all") + _stub_extract(monkeypatch, ds, []) + + parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None) + + segment_dir = video_dir / ".minicpm_daily_omni_interleave" / _VIDEO_ID + for part in parts: + url = part[part["type"]]["url"] + assert url.startswith("file://") + assert Path(url[len("file://") :]).parent == segment_dir.resolve() + assert sorted(p.name for p in segment_dir.glob("frame_*.jpg")) == [f"frame_{i:04d}.jpg" for i in range(_NUM_FRAMES)] + + +def test_minicpm_interleave_inline_mode_emits_data_urls(monkeypatch, qa_json, video_dir) -> None: + ds = _make_dataset( + qa_json, + video_dir, + pack_mode="minicpm-interleave", + input_mode="all", + inline_local_video=True, + ) + _stub_extract(monkeypatch, ds, []) + + parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None) + + assert parts[0]["image_url"]["url"].startswith("data:image/jpeg;base64,") + assert parts[1]["audio_url"]["url"].startswith("data:audio/wav;base64,") + assert not (video_dir / ".minicpm_daily_omni_interleave").exists() + + +def test_minicpm_interleave_caches_file_parts_but_not_inline_payloads(monkeypatch, qa_json, video_dir) -> None: + """Inline base64 must not be retained: ~700 Daily-Omni videos would pin GBs of RSS.""" + file_calls: list[int] = [] + ds_file = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all") + _stub_extract(monkeypatch, ds_file, file_calls) + ds_file._compose_daily_omni_multimodal(_VIDEO_ID, None) + ds_file._compose_daily_omni_multimodal(_VIDEO_ID, None) + assert len(file_calls) == 1 + assert ds_file._minicpm_interleave_cache + + inline_calls: list[int] = [] + ds_inline = _make_dataset( + qa_json, + video_dir, + pack_mode="minicpm-interleave", + input_mode="all", + inline_local_video=True, + ) + _stub_extract(monkeypatch, ds_inline, inline_calls) + ds_inline._compose_daily_omni_multimodal(_VIDEO_ID, None) + ds_inline._compose_daily_omni_multimodal(_VIDEO_ID, None) + assert len(inline_calls) == 2 + assert not ds_inline._minicpm_interleave_cache + + +def test_minicpm_interleave_missing_local_video_returns_none(qa_json, tmp_path) -> None: + empty = tmp_path / "empty_videos" + empty.mkdir() + ds = _make_dataset(qa_json, empty, pack_mode="minicpm-interleave", input_mode="all") + + parts, extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None) + + assert parts is None + assert extra is None + + +# --------------------------------------------------------------------------- +# System prompt selection +# --------------------------------------------------------------------------- + + +def test_minicpm_interleave_keeps_empty_system_message(monkeypatch, qa_json, video_dir) -> None: + """Official MiniCPM omni mode still sends role=system with empty content.""" + ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all") + _stub_extract(monkeypatch, ds, []) + parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None) + + messages = ds._build_daily_omni_openai_messages(parts, "q?", {"A": "a"}) + + assert [m["role"] for m in messages] == ["system", "user"] + assert messages[0]["content"][0]["text"] == "" + + +def test_qwen_pack_mode_keeps_system_message(qa_json, video_dir) -> None: + ds = _make_dataset(qa_json, video_dir, pack_mode="qwen", input_mode="all") + + messages = ds._build_daily_omni_openai_messages([], "q?", {"A": "a"}) + + assert [m["role"] for m in messages] == ["system", "user"] + assert "Qwen" in messages[0]["content"][0]["text"] + + +def test_invalid_pack_mode_rejected(qa_json, video_dir) -> None: + with pytest.raises(ValueError, match="pack_mode must be"): + _make_dataset(qa_json, video_dir, pack_mode="bogus") diff --git a/tests/benchmarks/test_serve_cli.py b/tests/benchmarks/test_serve_cli.py index 6970f6e2f..7dd12d000 100644 --- a/tests/benchmarks/test_serve_cli.py +++ b/tests/benchmarks/test_serve_cli.py @@ -1,3 +1,4 @@ +import argparse import json import os import subprocess @@ -6,10 +7,157 @@ from pathlib import Path import pytest +from vllm_omni.entrypoints.cli.benchmark.cli_args import ( + add_omni_args, + extend_omni_choices, + preprocess_serve_args, + update_omni_help, +) +from vllm_omni.utils.tracking_parser import TrackingArgumentParser + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.benchmark] + + +@pytest.mark.parametrize( + "dataset_name", + [ + "daily-omni", + "seed-tts", + "seed-tts-text", + "seed-tts-design", + "ttsd", + "sound-effect", + ], +) +def test_extend_omni_choices_updates_tracking_parser_shadow(dataset_name: str) -> None: + parser = TrackingArgumentParser() + parser.add_argument("--dataset-name", choices=["random"]) + parser.add_argument("--backend", choices=["openai-chat-omni"]) + + extend_omni_choices(parser) + + args = parser.parse_args( + [ + "--dataset-name", + dataset_name, + "--backend", + "openai-image-edits-omni", + ] + ) + + assert args.dataset_name == dataset_name + assert args.backend == "openai-image-edits-omni" + assert args.explicit_keys == {"dataset_name", "backend"} + + +@pytest.mark.parametrize( + ("argv", "dest", "expected"), + [ + (["--print-stage"], "print_stage", True), + (["--daily-omni-input-mode", "audio"], "daily_omni_input_mode", "audio"), + (["--seed-tts-locale", "zh"], "seed_tts_locale", "zh"), + ], +) +def test_add_omni_args_registers_arguments_on_tracking_parser( + argv: list[str], + dest: str, + expected: object, +) -> None: + parser = TrackingArgumentParser() + + add_omni_args(parser) + + args = parser.parse_args(argv) + assert getattr(args, dest) == expected + assert args.explicit_keys == {dest} + + +def test_add_omni_args_preserves_implicit_defaults() -> None: + parser = TrackingArgumentParser() + + add_omni_args(parser) + + args = parser.parse_args([]) + assert args.print_stage is False + assert args.daily_omni_input_mode == "all" + assert args.seed_tts_locale == "en" + assert args.explicit_keys == set() + + +def test_update_omni_help_updates_upstream_actions() -> None: + parser = TrackingArgumentParser() + parser.add_argument("--percentile-metrics", help="Upstream percentile help.") + parser.add_argument("--random-mm-limit-mm-per-prompt", help="Upstream limit help.") + parser.add_argument("--random-mm-bucket-config", help="Upstream bucket help.") + + update_omni_help(parser) + + actions = {action.dest: action for action in parser._actions} + assert all(metric in actions["percentile_metrics"].help for metric in ("ttfc", "tpop", "audio_rtf")) + assert "probabilities are renormalized" in actions["random_mm_limit_mm_per_prompt"].help + assert "Currently allows for 3 modalities" in actions["random_mm_bucket_config"].help + + +@pytest.mark.parametrize( + ("backend", "extra_body", "bot_task", "expected"), + [ + ("openai-chat-omni", None, "think", {}), + ("openai-image-edits-omni", None, "think", {"bot_task": "think"}), + ("openai-image-edits-omni", {"bot_task": "recaption"}, "think", {"bot_task": "recaption"}), + ("openai-image-edits-omni", {"custom": True}, None, {"custom": True}), + ], +) +def test_preprocess_serve_args_merges_bot_task_without_overriding_extra_body( + backend: str, + extra_body: dict | None, + bot_task: str | None, + expected: dict, +) -> None: + args = argparse.Namespace(backend=backend, extra_body=extra_body, bot_task=bot_task) + + preprocess_serve_args(args) + + assert args.extra_body == expected + + +@pytest.mark.parametrize( + ("argv", "expected_extra_body", "expected_explicit"), + [ + ( + [ + "--backend", + "openai-image-edits-omni", + "--print-stage", + "--image-edits-bot-task", + "recaption", + ], + {"bot_task": "recaption"}, + {"backend", "print_stage", "bot_task"}, + ), + ( + ["--extra-body", '{"bot_task":"vanilla"}'], + {"bot_task": "vanilla"}, + {"extra_body"}, + ), + ], +) +def test_omni_args_parse_and_preprocess( + argv: list[str], + expected_extra_body: dict, + expected_explicit: set[str], +) -> None: + parser = TrackingArgumentParser() + parser.add_argument("--extra-body", type=json.loads, default=None) + parser.add_argument("--backend", default="openai-chat-omni") + add_omni_args(parser) + + args = parser.parse_args(argv) + preprocess_serve_args(args) + + assert args.extra_body == expected_extra_body + assert args.explicit_keys == expected_explicit + -@pytest.mark.core_model -@pytest.mark.benchmark -@pytest.mark.cpu def test_bench_serve_cli_mocks_http_request(tmp_path: Path): num_prompts = 5 port = 18000 diff --git a/tests/buildkite/test_skip_ci.py b/tests/buildkite/test_skip_ci.py new file mode 100644 index 000000000..ddefc7271 --- /dev/null +++ b/tests/buildkite/test_skip_ci.py @@ -0,0 +1,370 @@ +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import sys +from dataclasses import asdict +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / ".buildkite" / "common" / "scripts")) + +from skip_ci import ( # noqa: E402 + CiDecision, + _diff_only_contains_skip_mark_changes, + resolve_ci_decision, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _decision(changed_files: list[str]) -> CiDecision: + return resolve_ci_decision(changed_files) + + +def _is_yaml_gated(decision: CiDecision) -> bool: + return decision.skip_l2_l3 and not decision.skip_all + + +def _all_l23_skipped(decision: CiDecision) -> bool: + checks = ( + ("cuda", "l2"), + ("cuda", "l3"), + ("amd", "l2"), + ("amd", "l3"), + ("intel", "l2"), + ("npu", "l2"), + ) + return all(not decision.is_run(platform, level) for platform, level in checks) + + +def test_docs_only_change() -> None: + assert _decision(["docs/foo.md", "README.md", "mkdocs.yml"]).skip_all + + +def test_docs_only_rejects_product_code() -> None: + assert not _decision(["docs/foo.md", "vllm_omni/foo.py"]).skip_all + + +def test_skip_mark_diff_only() -> None: + diff = """\ +diff --git a/tests/foo/test_bar.py b/tests/foo/test_bar.py +--- a/tests/foo/test_bar.py ++++ b/tests/foo/test_bar.py +@@ -1,3 +1,4 @@ + import pytest ++ ++@pytest.mark.skip(reason="temp") + def test_x(): +""" + assert _diff_only_contains_skip_mark_changes(diff) + + +def test_skip_mark_removal_does_not_qualify() -> None: + """Removing a skip decorator re-enables the test; must not suppress CI.""" + diff = """\ +diff --git a/tests/foo/test_bar.py b/tests/foo/test_bar.py +--- a/tests/foo/test_bar.py ++++ b/tests/foo/test_bar.py +@@ -1,5 +1,4 @@ + import pytest +- +-@pytest.mark.skip(reason="temp") + def test_x(): + assert True +""" + assert not _diff_only_contains_skip_mark_changes(diff) + + +def test_skip_mark_reason_edit_still_qualifies() -> None: + diff = """\ +diff --git a/tests/foo/test_bar.py b/tests/foo/test_bar.py +--- a/tests/foo/test_bar.py ++++ b/tests/foo/test_bar.py +@@ -1,4 +1,4 @@ + import pytest +-@pytest.mark.skip(reason="old") ++@pytest.mark.skip(reason="new") + def test_x(): +""" + assert _diff_only_contains_skip_mark_changes(diff) + + +def test_skip_mark_diff_rejects_assertion_change() -> None: + diff = """\ +diff --git a/tests/foo/test_bar.py b/tests/foo/test_bar.py +--- a/tests/foo/test_bar.py ++++ b/tests/foo/test_bar.py +@@ -1,3 +1,3 @@ + def test_x(): +- assert 1 == 1 ++ assert 2 == 2 +""" + assert not _diff_only_contains_skip_mark_changes(diff) + + +def test_cuda_ready_only_runs_cuda_l2() -> None: + decision = _decision([".buildkite/cuda/test-ready.yml"]) + assert _is_yaml_gated(decision) + assert decision.is_run("cuda", "l2") + assert not decision.is_run("cuda", "l3") + assert _all_l23_skipped(decision) is False + + +def test_cuda_merge_only_runs_cuda_l3() -> None: + decision = _decision([".buildkite/cuda/test-merge.yml"]) + assert _is_yaml_gated(decision) + assert not decision.is_run("cuda", "l2") + assert decision.is_run("cuda", "l3") + assert not decision.is_run("amd", "l3") + + +def test_l4_only_skips_all_l23() -> None: + decision = _decision([".buildkite/cuda/test-nightly.yml"]) + assert _is_yaml_gated(decision) + assert _all_l23_skipped(decision) + assert not decision.is_run("cuda", "l2") + assert not decision.is_run("cuda", "l3") + + +def test_weekly_only_skips_all_l23() -> None: + decision = _decision([".buildkite/cuda/test-weekly.yml"]) + assert _is_yaml_gated(decision) + assert _all_l23_skipped(decision) + assert not decision.is_run("cuda", "l2") + assert not decision.is_run("cuda", "l3") + + +def test_npu_nightly_only_skips_all_l23() -> None: + decision = _decision([".buildkite/npu/test-npu-nightly.yml"]) + assert _is_yaml_gated(decision) + assert _all_l23_skipped(decision) + assert not decision.is_run("npu", "l2") + assert not decision.is_run("npu", "l3") + + +def test_nightly_and_weekly_skips_all_l23() -> None: + decision = _decision( + [ + ".buildkite/cuda/test-nightly.yml", + ".buildkite/cuda/test-weekly.yml", + ], + ) + assert _is_yaml_gated(decision) + assert _all_l23_skipped(decision) + assert not decision.is_run("cuda", "l2") + assert not decision.is_run("cuda", "l3") + + +def test_l2_and_l4_skips_l3() -> None: + files = [ + ".buildkite/cuda/test-ready.yml", + ".buildkite/cuda/test-nightly.yml", + ] + decision = _decision(files) + assert _is_yaml_gated(decision) + assert decision.is_run("cuda", "l2") + assert not decision.is_run("cuda", "l3") + + +def test_l3_and_l4_skips_l2() -> None: + files = [ + ".buildkite/cuda/test-merge.yml", + ".buildkite/cuda/test-nightly.yml", + ] + decision = _decision(files) + assert _is_yaml_gated(decision) + assert not decision.is_run("cuda", "l2") + assert decision.is_run("cuda", "l3") + + +def test_ready_and_merge_runs_cuda_l2_l3() -> None: + decision = _decision( + [ + ".buildkite/cuda/test-ready.yml", + ".buildkite/cuda/test-merge.yml", + ], + ) + assert _is_yaml_gated(decision) + assert decision.is_run("cuda", "l2") + assert decision.is_run("cuda", "l3") + + +def test_cross_platform_cuda_l2_and_amd_l3_preserves_both() -> None: + """CUDA ready + AMD merge must keep cuda/l2 and amd/l3 (not wipe L2 outside L3 platforms).""" + decision = _decision( + [ + ".buildkite/cuda/test-ready.yml", + ".buildkite/amd/test-amd-merge.yml", + ], + ) + assert _is_yaml_gated(decision) + assert decision.is_run("cuda", "l2") + assert not decision.is_run("cuda", "l3") + assert not decision.is_run("amd", "l2") + assert decision.is_run("amd", "l3") + assert not decision.is_run("intel", "l2") + assert not decision.is_run("npu", "l2") + assert not decision.device.skip_cuda_l2 + assert decision.device.skip_cuda_l3 + assert decision.device.skip_amd_l2 + assert not decision.device.skip_amd_l3 + + +def test_amd_ready_only_runs_amd_l2() -> None: + decision = _decision([".buildkite/amd/test-amd-ready.yml"]) + assert _is_yaml_gated(decision) + assert decision.is_run("amd", "l2") + assert not decision.is_run("cuda", "l2") + + +def test_npu_ready_only_runs_npu_l2() -> None: + decision = _decision([".buildkite/npu/test-npu-ready.yml"]) + assert _is_yaml_gated(decision) + assert decision.is_run("npu", "l2") + assert not decision.is_run("cuda", "l2") + assert not decision.is_run("npu", "l3") + + +def test_amd_merge_only_runs_amd_l3() -> None: + decision = _decision([".buildkite/amd/test-amd-merge.yml"]) + assert _is_yaml_gated(decision) + assert decision.is_run("amd", "l3") + assert not decision.is_run("amd", "l2") + assert not decision.is_run("cuda", "l3") + + +def test_intel_pipeline_runs_intel_l2() -> None: + decision = _decision([".buildkite/intel/pipeline-intel.yml"]) + assert _is_yaml_gated(decision) + assert decision.is_run("intel", "l2") + assert not decision.is_run("cuda", "l2") + + +def test_cuda_and_amd_ready_runs_both_l2() -> None: + decision = _decision( + [ + ".buildkite/cuda/test-ready.yml", + ".buildkite/amd/test-amd-ready.yml", + ], + ) + assert _is_yaml_gated(decision) + assert decision.is_run("cuda", "l2") + assert decision.is_run("amd", "l2") + assert not decision.is_run("cuda", "l3") + assert not decision.is_run("amd", "l3") + assert not decision.is_run("intel", "l2") + assert not decision.is_run("npu", "l2") + + +def test_cuda_and_amd_merge_runs_both_l3() -> None: + decision = _decision( + [ + ".buildkite/cuda/test-merge.yml", + ".buildkite/amd/test-amd-merge.yml", + ], + ) + assert _is_yaml_gated(decision) + assert decision.is_run("cuda", "l3") + assert decision.is_run("amd", "l3") + assert not decision.is_run("cuda", "l2") + assert not decision.is_run("amd", "l2") + + +def test_ready_merge_and_nightly_runs_cuda_l2_l3() -> None: + """G7: L2+L3+L45 → same matrix as ready+merge (L45 rescued by L2/L3 YAML).""" + decision = _decision( + [ + ".buildkite/cuda/test-ready.yml", + ".buildkite/cuda/test-merge.yml", + ".buildkite/cuda/test-nightly.yml", + ], + ) + assert _is_yaml_gated(decision) + assert decision.is_run("cuda", "l2") + assert decision.is_run("cuda", "l3") + assert not decision.is_run("amd", "l2") + assert not decision.is_run("amd", "l3") + + +def test_non_whitelist_buildkite_runs_normal_ci() -> None: + assert not _is_yaml_gated(_decision([".buildkite/common/scripts/skip_ci.py"])) + assert not _is_yaml_gated(_decision([".buildkite/cuda/pipeline.yml"])) + + +def test_whitelist_plus_non_whitelist_buildkite_runs_normal_ci() -> None: + assert not _is_yaml_gated( + _decision( + [ + ".buildkite/cuda/test-ready.yml", + ".buildkite/cuda/pipeline.yml", + ], + ), + ) + + +def test_mixed_product_code_disables_targeting() -> None: + assert not _is_yaml_gated( + _decision([".buildkite/cuda/test-ready.yml", "vllm_omni/x.py"]), + ) + + +def test_docs_mixed_with_ci_yaml_follows_yaml_gating() -> None: + decision = _decision(["docs/a.md", ".buildkite/cuda/test-ready.yml"]) + assert _is_yaml_gated(decision) + assert not decision.skip_all + assert decision.is_run("cuda", "l2") + assert not decision.is_run("cuda", "l3") + assert not decision.is_run("amd", "l2") + assert "follow CI YAML gating" in decision.message + + +def test_docs_mixed_with_l45_yaml_skips_all_l23() -> None: + decision = _decision(["docs/a.md", ".buildkite/cuda/test-nightly.yml"]) + assert _is_yaml_gated(decision) + assert not decision.skip_all + assert not decision.is_run("cuda", "l2") + assert not decision.is_run("cuda", "l3") + assert not decision.is_run("amd", "l2") + + +def test_non_whitelist_path_disables_targeting() -> None: + assert not _is_yaml_gated(_decision([".buildkite/unknown/foo.yml"])) + assert not _is_yaml_gated(_decision(["vllm_omni/x.py"])) + + +def test_is_run_l23_when_not_yaml_gated() -> None: + decision = _decision(["vllm_omni/x.py"]) + assert decision.is_run("cuda", "l2") + assert decision.is_run("amd", "l3") + + +def test_is_run_l23_respects_decision() -> None: + decision = _decision([".buildkite/cuda/test-ready.yml"]) + assert _is_yaml_gated(decision) + assert decision.is_run("cuda", "l2") + assert not decision.is_run("cuda", "l3") + assert not decision.is_run("amd", "l2") + + +def test_docs_or_skip_mark_only_without_diff_range_helper() -> None: + assert _decision(["docs/a.md"]).skip_all + assert not _decision(["vllm_omni/a.py"]).skip_all + + +def test_decision_serializes_to_dict() -> None: + decision = _decision([".buildkite/cuda/test-ready.yml"]) + assert _is_yaml_gated(decision) + payload = asdict(decision) + assert payload["skip_l2_l3"] is True + assert payload["device"]["skip_cuda_l2"] is False + assert payload["device"]["skip_cuda_l3"] is True + + +def test_yaml_gated_message_lists_changed_and_targets() -> None: + decision = _decision([".buildkite/cuda/test-ready.yml"]) + assert decision.message.startswith("only CI config YAML changed") + assert "changed: L2=[.buildkite/cuda/test-ready.yml]" in decision.message + assert "run: cuda/l2" in decision.message + assert "skip: cuda/l3" in decision.message + assert "amd/l2" in decision.message diff --git a/tests/buildkite/test_upload_pipeline.py b/tests/buildkite/test_upload_pipeline.py new file mode 100644 index 000000000..24a2a731e --- /dev/null +++ b/tests/buildkite/test_upload_pipeline.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / ".buildkite" / "common" / "scripts")) + +from skip_ci import resolve_ci_decision # noqa: E402 +from upload_pipeline import ( # noqa: E402 + _expand_mirror_hardwares, + _load_bootstrap_steps, + _render_bootstrap_pipeline, + _render_test_pipeline, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +CUDA_BOOTSTRAP_STEPS = Path(".buildkite/cuda/bootstrap-upload-steps.yml") +BOOTSTRAP_STEPS_TEMPLATE = """steps: + - key: image-build + - key: upload-ready-pipeline + - key: upload-merge-pipeline + - key: upload-nightly-pipeline + - key: upload-weekly-pipeline +""" + + +def _render(changed_files: list[str]) -> str: + decision = resolve_ci_decision(changed_files) + return _render_bootstrap_pipeline( + BOOTSTRAP_STEPS_TEMPLATE, + decision=decision, + path=CUDA_BOOTSTRAP_STEPS, + ) + + +def test_bootstrap_if_injected_by_step_key() -> None: + import yaml + + rendered = _render_bootstrap_pipeline( + BOOTSTRAP_STEPS_TEMPLATE, + decision=resolve_ci_decision([]), + path=Path(".buildkite/npu/bootstrap-upload-steps.yml"), + ) + doc = yaml.safe_load(rendered) + by_key = {step["key"]: step for step in doc["steps"]} + assert "image-build" in by_key + # Unconditional image has no ``if`` (Buildkite rejects YAML bool if: true). + assert "if" not in by_key["image-build"] + assert isinstance(by_key["upload-ready-pipeline"]["if"], str) + assert isinstance(by_key["upload-nightly-pipeline"]["if"], str) + + +def test_bootstrap_steps_loaded_from_file() -> None: + steps = _load_bootstrap_steps(CUDA_BOOTSTRAP_STEPS) + assert "key: image-build" in steps + assert "key: upload-ready-pipeline" in steps + assert "placeholder:" not in steps + + +def test_docs_only_allows_main_scheduled_nightly_weekly_only() -> None: + """skip_all: no PR labels; main + NIGHTLY=1 / WEEKLY=1 still gates scheduled CI.""" + rendered = _render(["docs/foo.md"]) + assert "key: image-build" in rendered + assert "key: upload-nightly-pipeline" in rendered + assert "key: upload-weekly-pipeline" in rendered + # Scheduled nightly also uploads L2/L3 with --e2e + assert "key: upload-ready-pipeline" in rendered + assert "key: upload-merge-pipeline" in rendered + assert 'build.env("NIGHTLY") == "1"' in rendered + assert 'build.env("WEEKLY") == "1"' in rendered + assert "nightly-test" not in rendered + assert "weekly-test" not in rendered + assert "merge-test" not in rendered + assert 'labels includes "ready"' not in rendered + assert "if: false" not in rendered + + +def test_yaml_gated_l45_only_does_not_unconditionally_build_image() -> None: + rendered = _render([".buildkite/cuda/test-nightly.yml"]) + assert "if: true" not in rendered + assert 'build.pull_request.labels includes "nightly-test"' in rendered + assert 'build.pull_request.labels includes "weekly-test"' in rendered + # L2/L3 upload steps are unconditionally disabled → omitted from pipeline + assert "key: upload-ready-pipeline" not in rendered + assert "key: upload-merge-pipeline" not in rendered + assert "key: upload-weekly-pipeline" in rendered + + +def test_yaml_gated_l2_still_enables_image_via_ready_base() -> None: + rendered = _render([".buildkite/cuda/test-ready.yml"]) + assert 'build.pull_request.labels includes "ready"' in rendered + assert "if: true" not in rendered + + +def test_mirror_hardwares_l4_1_expands_to_agents_and_plugins() -> None: + doc = { + "steps": [ + { + "label": "Simple Test", + "mirror_hardwares": "l4_1", + "commands": ["pytest -sv tests/example"], + }, + ], + } + rendered = _render_test_pipeline(doc, changed_files=None) + step = rendered["steps"][0] + assert "mirror_hardwares" not in step + assert step["agents"]["queue"] == "gpu_1_queue" + assert step["plugins"][0]["docker#v5.2.0"]["image"].endswith("$BUILDKITE_COMMIT") + + +def test_mirror_hardwares_conflicts_with_explicit_agents() -> None: + with pytest.raises(ValueError, match="agents/plugins/image"): + _expand_mirror_hardwares( + {"label": "bad", "mirror_hardwares": "l4_1", "agents": {"queue": "gpu_1_queue"}}, + ) + + +def test_mirror_hardwares_a2b3_npu_4_expands_agents_image_and_plugins() -> None: + doc = { + "steps": [ + { + "label": "NPU X2V Test", + "mirror_hardwares": "a2b3_npu_4", + "commands": ["pytest -sv tests/example"], + }, + ], + } + rendered = _render_test_pipeline(doc, changed_files=None) + step = rendered["steps"][0] + assert "mirror_hardwares" not in step + assert step["agents"]["queue"] == "ascend-a2b3" + assert step["agents"]["resource_class"] == "npu-4" + assert step["image"].endswith("${BUILDKITE_COMMIT}") + assert step["plugins"][0]["kubernetes"]["podSpecPatch"]["imagePullSecrets"] == [ + {"name": "swr-secret"}, + ] diff --git a/tests/buildkite/test_vllm_omni_package_discovery.py b/tests/buildkite/test_vllm_omni_package_discovery.py new file mode 100644 index 000000000..118aedb15 --- /dev/null +++ b/tests/buildkite/test_vllm_omni_package_discovery.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Guard package discovery outside repo-root ``sys.path[0]``. + +Covers the same failure class as: +- ``vllm ... --omni`` (upstream ``find_spec("vllm_omni")``) — see #5364 +- ``python examples/.../end2end.py`` / multimodal client subprocesses, where + ``sys.path[0]`` is the script directory (not the repo root), so a cwd-only + layout can start OmniServer via ``python -m vllm_omni...`` and still fail + example scripts with ``ModuleNotFoundError: No module named 'vllm_omni'``. +""" + +from __future__ import annotations + +import subprocess +import sys +from importlib.util import find_spec +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _assert_subprocess_ok(proc: subprocess.CompletedProcess[str], *, detail: str) -> None: + assert proc.returncode == 0, f"{detail} (exit={proc.returncode})\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}" + + +def test_find_spec_vllm_omni() -> None: + """Same probe as upstream ``vllm`` CLI before delegating to omni_main.""" + assert find_spec("vllm_omni") is not None + assert find_spec("vllm_omni.entrypoints.cli.main") is not None + + +def test_find_spec_vllm_omni_from_non_repo_cwd(tmp_path: Path) -> None: + """Console-script / ``python -c`` style: discovery when cwd is not the repo root.""" + code = ( + "from importlib.util import find_spec; " + "assert find_spec('vllm_omni') is not None; " + "assert find_spec('vllm_omni.entrypoints.cli.main') is not None" + ) + proc = subprocess.run( + [sys.executable, "-c", code], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + _assert_subprocess_ok(proc, detail=f"find_spec failed from cwd={tmp_path}") + + +def test_python_script_off_repo_root_sys_path(tmp_path: Path) -> None: + """``python path/to/script.py``: ``sys.path[0]`` is the script dir, not the repo root. + + One case covers the end2end / online-client / orphan-dir layouts — they all + share this discovery constraint. Use ``find_spec`` only (no heavy imports) + so Simple Test stays fast. + """ + script = tmp_path / "examples" / "offline_inference" / "hunyuan_image3" / "end2end_probe.py" + script.parent.mkdir(parents=True, exist_ok=True) + script.write_text( + "\n".join( + [ + "from importlib.util import find_spec", + "import sys", + 'print("initial_sys_path0", sys.path[0])', + 'assert find_spec("vllm_omni") is not None, (sys.path[0], sys.path[:5])', + 'assert find_spec("vllm_omni.entrypoints.cli.main") is not None', + 'print("ok")', + "", + ] + ), + encoding="utf-8", + ) + cwd = tmp_path / "run_cwd" + cwd.mkdir() + proc = subprocess.run( + [sys.executable, str(script)], + cwd=cwd, + capture_output=True, + text=True, + check=False, + ) + _assert_subprocess_ok( + proc, + detail=f"find_spec failed for python {script} with cwd={cwd}", + ) + assert f"initial_sys_path0 {script.parent}" in proc.stdout diff --git a/tests/config/composable_parallel/test_factory_integration.py b/tests/config/composable_parallel/test_factory_integration.py index ea685541c..468b3db2c 100644 --- a/tests/config/composable_parallel/test_factory_integration.py +++ b/tests/config/composable_parallel/test_factory_integration.py @@ -100,8 +100,7 @@ def test_device_check_survives_cli_override(): # guard: effective world=1, replicas=2 admits only 1 or 2 device ids. pipeline_cfg = OMNI_PIPELINES["qwen2_5_omni"] with pytest.raises(StrategyApplyError): - StageConfigFactory._create_from_registry( - "qwen2_5_omni", + StageConfigFactory._create_legacy_from_registry( pipeline_cfg, cli_overrides={"stage_1_devices": "0,1,2"}, strategy_specs={"talker": [_stage_replica(2, "round_robin")]}, @@ -126,8 +125,7 @@ def test_cli_overrides_strategy_with_warning(): log.addHandler(handler) try: pipeline_cfg = OMNI_PIPELINES["qwen2_5_omni"] - stages, _ = StageConfigFactory._create_from_registry( - "qwen2_5_omni", + stages, _ = StageConfigFactory._create_legacy_from_registry( pipeline_cfg, cli_overrides={"stage_1_num_replicas": 3}, strategy_specs={"talker": [_stage_replica(2, "round_robin")]}, diff --git a/tests/test_config_factory.py b/tests/config/test_config_factory.py similarity index 72% rename from tests/test_config_factory.py rename to tests/config/test_config_factory.py index 54683e67c..33dcd0551 100644 --- a/tests/test_config_factory.py +++ b/tests/config/test_config_factory.py @@ -5,7 +5,6 @@ Unit tests for StageConfigFactory and related classes. """ import importlib -import inspect import warnings from dataclasses import dataclass, fields from pathlib import Path @@ -17,8 +16,10 @@ from transformers import PretrainedConfig, Qwen3OmniMoeConfig from tests.helpers.stage_config import get_deploy_config_path, get_deploy_config_stage from vllm_omni.config.config_factory import StageConfigFactory from vllm_omni.config.endpoint_policy import EndpointRestriction, OmniServingCapability -from vllm_omni.config.pipeline_registry import OMNI_PIPELINES, register_pipeline +from vllm_omni.config.omni_config import VllmOmniConfig +from vllm_omni.config.pipeline_registry import OMNI_PIPELINES, register_pipeline, resolve_pipeline_config from vllm_omni.config.stage_config import ( + _DEPLOY_DIR, DeployConfig, PipelineConfig, StageConfig, @@ -40,13 +41,21 @@ from vllm_omni.engine.arg_utils import SHARED_FIELDS, EngineArgs, internal_black pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +@pytest.fixture(autouse=True) +def _stable_test_platform(monkeypatch): + from vllm_omni import platforms + + platform = platforms.current_omni_platform + monkeypatch.setattr(platform, "device_name", "cpu", raising=False) + monkeypatch.setattr(platform, "device_type", "cpu", raising=False) + + @pytest.fixture(autouse=True) def clear_config_factory_caches(): """Clear cached classmethods from the StageConfigFactory to prevent test pollution.""" yield StageConfigFactory.get_hf_config.cache_clear() StageConfigFactory.try_infer_model_type.cache_clear() - StageConfigFactory.get_pipeline_config.cache_clear() Q3_OMNI_ALL_STAGES_HF_CONFIG = Qwen3OmniMoeConfig(enable_audio_output=True) @@ -122,6 +131,18 @@ class TestStageConfig: assert "max_num_seqs" not in omega_config.engine_args # Legacy field name for backward compatibility assert omega_config.engine_input_source == [] + assert omega_config.session_mode == "turn" + + def test_to_omegaconf_duplex_session_mode(self): + """Test that session mode is preserved as stage metadata.""" + config = StageConfig( + stage_id=0, + model_stage="thinker", + session_mode="duplex", + ) + omega_config = config.to_omegaconf() + + assert omega_config.session_mode == "duplex" def test_to_omegaconf_with_runtime_overrides(self): """Test that runtime overrides are applied to OmegaConf output.""" @@ -315,6 +336,31 @@ class TestStageConfig: assert "ring_degree" not in omega_config.engine_args assert "sequence_parallel_size" not in omega_config.engine_args + def test_to_omegaconf_diffusion_parallel_allgather_override_recomputes_sequence_parallel_size(self): + config = StageConfig( + stage_id=1, + model_stage="diffusion", + stage_type=StageType.DIFFUSION, + yaml_engine_args={ + "parallel_config": { + "sequence_parallel_size": 1, + "ulysses_degree": 1, + "ring_degree": 1, + "allgather_degree": 1, + } + }, + runtime_overrides={ + "allgather_degree": 2, + }, + ) + + omega_config = config.to_omegaconf() + + assert omega_config.engine_args.parallel_config.allgather_degree == 2 + assert omega_config.engine_args.parallel_config.sequence_parallel_size == 2 + assert "allgather_degree" not in omega_config.engine_args + assert "sequence_parallel_size" not in omega_config.engine_args + def test_to_omegaconf_diffusion_parallel_explicit_sequence_parallel_size_is_preserved(self): config = StageConfig( stage_id=1, @@ -534,29 +580,51 @@ class TestPipelineDiscovery: def test_registry_resolver_qwen3_omni_all_stages(self): """Test that providing the HF config for qwen3 omni with audio enabled uses all stages.""" - pipeline = StageConfigFactory.resolve_pipeline_config( + pipeline = resolve_pipeline_config( "qwen3_omni_moe", Q3_OMNI_ALL_STAGES_HF_CONFIG, ) assert isinstance(pipeline, PipelineConfig) assert pipeline.model_type == "qwen3_omni_moe" + assert pipeline.default_deploy_config_name == "qwen3_omni_moe.yaml" assert len(pipeline.stages) == 3 # thinker + talker + code2wav def test_registry_resolver_qwen3_omni_thinker_only(self): """Test that providing the HF config for qwen3 omni without audio is thinker only.""" - pipeline = StageConfigFactory.resolve_pipeline_config( + pipeline = resolve_pipeline_config( "qwen3_omni_moe", Q3_OMNI_THINKER_HF_CONFIG, ) assert isinstance(pipeline, PipelineConfig) assert pipeline.model_type == "qwen3_omni_moe_thinker_only" + assert pipeline.default_deploy_config_name is None assert len(pipeline.stages) == 1 # thinker only + @pytest.mark.parametrize( + "pipeline", + [registered for registered in OMNI_PIPELINES.values() if isinstance(registered, PipelineConfig)], + ids=lambda pipeline: pipeline.model_type, + ) + def test_registered_default_deploy_config_exists(self, pipeline): + if pipeline.default_deploy_config_name is None: + return + assert Path(get_deploy_config_path(pipeline.default_deploy_config_name)).is_file() + + @pytest.mark.parametrize( + "deploy_path", + sorted(_DEPLOY_DIR.glob("*.yaml")), + ids=lambda deploy_path: deploy_path.name, + ) + def test_explicit_deploy_pipeline_is_registered(self, deploy_path): + deploy = load_deploy_config(deploy_path) + if deploy.pipeline is not None: + assert deploy.pipeline in OMNI_PIPELINES + def test_registry_returns_none_for_unknown(self): """Unknown model_types aren't found and resolve to `None`.""" assert "definitely_not_a_real_model" not in OMNI_PIPELINES assert OMNI_PIPELINES.get("definitely_not_a_real_model") is None - assert StageConfigFactory.resolve_pipeline_config("definitely_not_a_real_model") is None + assert resolve_pipeline_config("definitely_not_a_real_model") is None def test_pipeline_config_supports_hf_architectures(self): """PipelineConfig accepts hf_architectures for HF-arch fallback @@ -607,6 +675,236 @@ class TestPipelineConfigNew: class TestPipelineRegistration: + def test_resolve_pipeline_prefers_deploy_pipeline_key(self, clean_pipeline_registry, tmp_path): + deploy_key = "deploy_selected_pipeline" + model_type_key = "hf_model_type_pipeline" + deploy_pipe = PipelineConfig(model_type=deploy_key) + model_type_pipe = PipelineConfig(model_type=model_type_key) + register_pipeline(deploy_pipe) + register_pipeline(model_type_pipe) + + deploy_path = tmp_path / "deploy.yaml" + deploy_path.write_text(f"pipeline: {deploy_key}\n", encoding="utf-8") + + class FakeConfig(PretrainedConfig): + model_type = model_type_key + + fake_config = FakeConfig() + with patch("vllm_omni.config.config_factory.get_config", return_value=fake_config): + pipeline_cfg = StageConfigFactory.get_pipeline_config( + "fake/model", + trust_remote_code=True, + deploy_config_path=str(deploy_path), + ) + + assert pipeline_cfg is deploy_pipe + + def test_resolve_pipeline_rejects_invalid_deploy_pipeline_key( + self, + clean_pipeline_registry, + tmp_path, + ): + model_type_key = "registered_model_type_pipeline" + register_pipeline(PipelineConfig(model_type=model_type_key)) + + deploy_path = tmp_path / "deploy.yaml" + deploy_path.write_text("pipeline: missing_pipeline\n", encoding="utf-8") + + class FakeConfig(PretrainedConfig): + model_type = model_type_key + + with patch("vllm_omni.config.config_factory.get_config", return_value=FakeConfig()): + with pytest.raises(KeyError, match="missing_pipeline"): + StageConfigFactory.get_pipeline_config( + "fake/model", + trust_remote_code=True, + deploy_config_path=str(deploy_path), + ) + with pytest.raises(KeyError, match="missing_pipeline"): + StageConfigFactory.create_from_model( + "fake/model", + trust_remote_code=False, + cli_overrides={}, + deploy_config_path=str(deploy_path), + ) + with pytest.raises(KeyError, match="missing_pipeline"): + StageConfigFactory.create_legacy_stage_configs_from_model( + "fake/model", + trust_remote_code=False, + cli_overrides={}, + deploy_config_path=str(deploy_path), + ) + + def test_resolve_pipeline_matches_hf_architecture_fallback(self, clean_pipeline_registry): + pipeline_key = "architecture_fallback_pipeline" + pipe_cfg = PipelineConfig( + model_type=pipeline_key, + hf_architectures=("ArchitectureFallbackForTest",), + ) + register_pipeline(pipe_cfg) + + class FakeConfig(PretrainedConfig): + model_type = "unregistered_model_type" + + fake_config = FakeConfig() + fake_config.architectures = ["ArchitectureFallbackForTest"] + with patch("vllm_omni.config.config_factory.get_config", return_value=fake_config): + pipeline_cfg = StageConfigFactory.get_pipeline_config( + "fake/model", + trust_remote_code=True, + deploy_config_path=None, + ) + + assert pipeline_cfg is pipe_cfg + + def test_resolve_pipeline_architecture_fallback_respects_predicates(self, clean_pipeline_registry): + shared_arch = "SharedPredicateArchitectureForTest" + + def rejecting_predicate(_hf_config): + return False + + def raising_predicate(_hf_config): + raise ValueError("predicate failure") + + reject_cfg = PipelineConfig( + model_type="rejected_by_predicate", + hf_architectures=(shared_arch,), + hf_config_predicate=rejecting_predicate, + ) + raises_cfg = PipelineConfig( + model_type="raises_in_predicate", + hf_architectures=(shared_arch,), + hf_config_predicate=raising_predicate, + ) + accept_cfg = PipelineConfig( + model_type="accepted_by_predicate", + hf_architectures=(shared_arch,), + ) + register_pipeline(reject_cfg) + register_pipeline(raises_cfg) + register_pipeline(accept_cfg) + + class FakeConfig(PretrainedConfig): + model_type = "unregistered_model_type" + + fake_config = FakeConfig() + fake_config.architectures = [shared_arch] + with patch("vllm_omni.config.config_factory.get_config", return_value=fake_config): + pipeline_cfg = StageConfigFactory.get_pipeline_config( + "fake/model", + trust_remote_code=True, + deploy_config_path=None, + ) + + assert pipeline_cfg is accept_cfg + + def test_resolve_pipeline_architecture_fallback_rejects_non_matching_architecture( + self, + clean_pipeline_registry, + ): + predicate_calls = [] + + def predicate(_hf_config): + predicate_calls.append(_hf_config) + return True + + pipe_cfg = PipelineConfig( + model_type="predicate_without_arch_match", + hf_architectures=("DifferentArchitectureForTest",), + hf_config_predicate=predicate, + ) + register_pipeline(pipe_cfg) + + class FakeConfig(PretrainedConfig): + model_type = "unregistered_model_type" + + fake_config = FakeConfig() + fake_config.architectures = ["RequestedArchitectureForTest"] + with patch("vllm_omni.config.config_factory.get_config", return_value=fake_config): + pipeline_cfg = StageConfigFactory.get_pipeline_config( + "fake/model", + trust_remote_code=True, + deploy_config_path=None, + ) + + assert pipeline_cfg is None + assert predicate_calls == [fake_config] + + def test_resolve_pipeline_architecture_fallback_resolves_callable_pipeline(self, clean_pipeline_registry): + pipeline_key = "callable_architecture_fallback" + resolved_cfg = PipelineConfig( + model_type="callable_resolved_pipeline", + hf_architectures=("CallableArchitectureForTest",), + ) + seen_hf_configs = [] + + def resolver(hf_config): + seen_hf_configs.append(hf_config) + return resolved_cfg + + register_pipeline(resolver, model_type=pipeline_key) + + class FakeConfig(PretrainedConfig): + model_type = "unregistered_model_type" + + fake_config = FakeConfig() + fake_config.architectures = ["CallableArchitectureForTest"] + with patch("vllm_omni.config.config_factory.get_config", return_value=fake_config): + pipeline_cfg = StageConfigFactory.get_pipeline_config( + "fake/model", + trust_remote_code=True, + deploy_config_path=None, + ) + + assert pipeline_cfg is resolved_cfg + assert seen_hf_configs == [fake_config] + + def test_resolve_pipeline_returns_none_without_registry_or_architecture_match(self, clean_pipeline_registry): + class FakeConfig(PretrainedConfig): + model_type = "unregistered_model_type" + + fake_config = FakeConfig() + fake_config.architectures = ["UnmatchedArchitectureForTest"] + with patch("vllm_omni.config.config_factory.get_config", return_value=fake_config): + pipeline_cfg = StageConfigFactory.get_pipeline_config( + "fake/model", + trust_remote_code=True, + deploy_config_path=None, + ) + + assert pipeline_cfg is None + + def test_create_from_model_returns_structured_omni_config(self): + class FakeConfig(PretrainedConfig): + model_type = "qwen3_tts" + + with patch("vllm_omni.config.config_factory.get_config", return_value=FakeConfig()): + omni_config = StageConfigFactory.create_from_model( + "fake/model", + trust_remote_code=False, + cli_overrides={}, + deploy_config_path=None, + ) + + assert isinstance(omni_config, VllmOmniConfig) + assert omni_config.pipeline_config is OMNI_PIPELINES["qwen3_tts"] + assert len(omni_config.stage_configs) == 2 + + def test_create_from_model_preserves_model_on_structured_diffusion_stage(self): + class FakeConfig(PretrainedConfig): + model_type = "dreamzero" + + with patch("vllm_omni.config.config_factory.get_config", return_value=FakeConfig()): + omni_config = StageConfigFactory.create_from_model( + "fake/model", + trust_remote_code=False, + cli_overrides={}, + deploy_config_path=None, + ) + + assert isinstance(omni_config, VllmOmniConfig) + assert omni_config.stage_by_id(0).diffusion_config.model == "fake/model" + def test_pipeline_registration(self, clean_pipeline_registry): """Ensure that we can register and create a custom pipeline config.""" new_model_type = "new_model_type" @@ -622,15 +920,23 @@ class TestPipelineRegistration: model_type = new_model_type # Create the model + fake_config = FakeConfig() with ( - patch("vllm_omni.config.config_factory.get_config", return_value=FakeConfig()), - patch.object(StageConfigFactory, "_create_from_registry") as mock_create, + patch("vllm_omni.config.config_factory.get_config", return_value=fake_config), + patch.object(VllmOmniConfig, "from_pipeline_config") as mock_create, ): - StageConfigFactory.create_from_model("fake/model") + StageConfigFactory.create_from_model( + "fake/model", + trust_remote_code=False, + cli_overrides={}, + deploy_config_path=None, + ) mock_create.assert_called_once() - # Ensure that the passed pipeline_config is the right type - pipeline_cfg = mock_create.call_args[0][1] - assert isinstance(pipeline_cfg, PipelineConfig) + assert mock_create.call_args.args == (pipe_cfg,) + assert mock_create.call_args.kwargs["cli_overrides"] == { + "trust_remote_code": False, + "model": "fake/model", + } assert pipe_cfg.model_type == new_model_type def test_resolver_registration(self, clean_pipeline_registry): @@ -655,26 +961,125 @@ class TestPipelineRegistration: assert OMNI_PIPELINES[new_model_type] is custom_resolver # Create the model + fake_config = FakeConfig() with ( - patch("vllm_omni.config.config_factory.get_config", return_value=FakeConfig()), - patch.object(StageConfigFactory, "_create_from_registry") as mock_create, + patch("vllm_omni.config.config_factory.get_config", return_value=fake_config) as mock_get_config, + patch.object(VllmOmniConfig, "from_pipeline_config") as mock_create, ): - StageConfigFactory.create_from_model("fake/model") + StageConfigFactory.create_from_model( + "fake/model", + trust_remote_code=True, + cli_overrides={}, + deploy_config_path=None, + ) mock_create.assert_called_once() - # Ensure that the passed pipeline_config is the right type - pipeline_cfg = mock_create.call_args[0][1] - assert isinstance(pipeline_cfg, PipelineConfig) - assert pipeline_cfg.model_type == resolved_type + assert mock_create.call_args.args[0].model_type == resolved_type + assert mock_create.call_args.kwargs["cli_overrides"] == { + "trust_remote_code": True, + "model": "fake/model", + } + mock_get_config.assert_called_once_with("fake/model", trust_remote_code=True) + + with patch.object(StageConfigFactory, "_create_legacy_from_registry", return_value=([], None)) as mock_legacy: + StageConfigFactory.create_legacy_stage_configs_from_model( + "fake/model", + trust_remote_code=True, + cli_overrides={}, + deploy_config_path=None, + ) + + assert mock_legacy.call_args.args[0].model_type == resolved_type + assert mock_legacy.call_args.args[1]["trust_remote_code"] is True def test_resolve_when_autodetect_resolves_none(self): """Regression test for: https://github.com/vllm-project/vllm-omni/issues/4726""" deploy_path = get_deploy_config_path("ming_tts.yaml") resolved_config = StageConfigFactory.create_from_model( model="inclusionAI/Ming-omni-tts-0.5B", + trust_remote_code=False, + cli_overrides={}, deploy_config_path=deploy_path, ) assert resolved_config is not None - assert len(resolved_config) > 0 + assert len(resolved_config.stage_configs) > 0 + + def test_legacy_and_structured_paths_share_deploy_pipeline_override(self, clean_pipeline_registry, tmp_path): + pipeline_key = "deploy_only_pipeline" + pipe_cfg = PipelineConfig( + model_type=pipeline_key, + model_arch="DeployOnlyArch", + stages=(StagePipelineConfig(stage_id=0, model_stage="diffusion"),), + ) + register_pipeline(pipe_cfg) + + deploy_path = tmp_path / "deploy_only.yaml" + deploy_path.write_text(f"pipeline: {pipeline_key}\n", encoding="utf-8") + + class FakeConfig(PretrainedConfig): + model_type = "dense" + architectures = ("UnregisteredArch",) + + with patch("vllm_omni.config.config_factory.get_config", return_value=FakeConfig()): + omni_config = StageConfigFactory.create_from_model( + "fake/model", + trust_remote_code=False, + cli_overrides={}, + deploy_config_path=str(deploy_path), + ) + legacy_configs, _ = StageConfigFactory.create_legacy_stage_configs_from_model( + "fake/model", + trust_remote_code=False, + cli_overrides={}, + deploy_config_path=str(deploy_path), + ) + + assert omni_config is not None + assert omni_config.pipeline_config is pipe_cfg + assert legacy_configs is not None + assert len(legacy_configs) == 1 + assert legacy_configs[0].yaml_engine_args["model_arch"] == "DeployOnlyArch" + + def test_structured_path_loads_explicit_deploy_config_once(self, clean_pipeline_registry, tmp_path): + pipeline_key = "single_load_pipeline" + register_pipeline(PipelineConfig(model_type=pipeline_key)) + deploy_path = tmp_path / "single_load.yaml" + deploy_path.write_text(f"pipeline: {pipeline_key}\n", encoding="utf-8") + + class FakeConfig(PretrainedConfig): + model_type = "unregistered" + + with ( + patch("vllm_omni.config.config_factory.get_config", return_value=FakeConfig()), + patch( + "vllm_omni.config.config_factory.load_deploy_config", + wraps=load_deploy_config, + ) as mock_load, + ): + StageConfigFactory.create_from_model( + "fake/model", + trust_remote_code=False, + cli_overrides={}, + deploy_config_path=str(deploy_path), + ) + + mock_load.assert_called_once_with(deploy_path) + + def test_legacy_default_deploy_name_ignores_cwd(self, monkeypatch, tmp_path): + default_name = "pipeline_default.yaml" + (tmp_path / default_name).write_text("stages: []\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + pipeline = PipelineConfig( + model_type="pipeline_with_default", + default_deploy_config_name=default_name, + ) + + with patch( + "vllm_omni.config.config_factory.load_deploy_config", + return_value=DeployConfig(), + ) as mock_load: + StageConfigFactory._create_legacy_from_registry(pipeline, {}) + + mock_load.assert_called_once_with(_DEPLOY_DIR / default_name) def test_deploy_override_uses_correct_endpoint_restrictions(self, clean_pipeline_registry, tmp_path): """Ensure endpoint restrictions must come from the final pipeline @@ -739,6 +1144,63 @@ class TestResolveScheduler: class TestDeployConfigLoading: + def test_load_minicpmo_duplex_deploy_config(self): + deploy_path = Path(get_deploy_config_path("minicpmo_4_5_duplex.yaml")) + + deploy = load_deploy_config(deploy_path) + pipeline = resolve_pipeline_config("minicpmo_4_5") + assert isinstance(pipeline, PipelineConfig) + + stages = merge_pipeline_deploy(pipeline, deploy) + + assert deploy.session_mode == "duplex" + assert deploy.async_chunk is True + assert deploy.active_stream_window == 1 + assert [stage.session_mode for stage in stages] == ["duplex", "duplex", "duplex"] + assert [stage.to_omegaconf().session_mode for stage in stages] == ["duplex", "duplex", "duplex"] + assert [stage.yaml_engine_args["async_scheduling"] for stage in stages] == [False, False, False] + assert all("Async" not in (stage.scheduler_cls or "") for stage in stages) + assert [stage.devices for stage in deploy.stages] == ["0", "0", "0"] + assert deploy.stages[1].enforce_eager is False + assert stages[1].yaml_extras["default_sampling_params"]["max_tokens"] == 2048 + assert stages[1].yaml_extras["default_sampling_params"]["min_tokens"] == 0 + assert stages[1].yaml_extras["default_sampling_params"]["stop_token_ids"] == [1] + + @pytest.mark.parametrize( + ("filename", "stage0_devices", "stage1_devices", "stage2_devices", "stage1_replicas"), + [ + ("minicpmo_4_5_3gpu_stage1_replicas.yaml", "0", "1,2", "0", 2), + ("minicpmo_4_5_4gpu_stage1_replicas.yaml", "0", "1,2,3", "0", 3), + ("minicpmo_4_5_8x4090_stage1_replicas.yaml", "0,1,2,3", "4,5,6,7", "5", 4), + ], + ) + def test_load_minicpmo_replica_deploy_configs( + self, + filename: str, + stage0_devices: str, + stage1_devices: str, + stage2_devices: str, + stage1_replicas: int, + ): + deploy_path = Path(get_deploy_config_path(filename)) + + deploy = load_deploy_config(deploy_path) + pipeline = resolve_pipeline_config("minicpmo_4_5") + assert isinstance(pipeline, PipelineConfig) + + stages = merge_pipeline_deploy(pipeline, deploy) + + assert deploy.session_mode == "duplex" + assert [stage.yaml_runtime["devices"] for stage in stages] == [ + stage0_devices, + stage1_devices, + stage2_devices, + ] + assert stages[0].yaml_runtime["num_replicas"] == 1 + assert stages[1].yaml_runtime["num_replicas"] == stage1_replicas + assert all(stage.yaml_engine_args["async_scheduling"] is False for stage in stages[:2]) + assert all("Async" not in (stage.scheduler_cls or "") for stage in stages[:2]) + def test_custom_voice_dir_is_pipeline_wide_engine_arg(self, tmp_path): deploy_path = tmp_path / "qwen3_tts_custom_voice.yaml" custom_voice_dir = tmp_path / "voices" @@ -756,7 +1218,7 @@ stages: ) deploy = load_deploy_config(deploy_path) - pipeline = StageConfigFactory.resolve_pipeline_config("qwen3_tts") + pipeline = resolve_pipeline_config("qwen3_tts") stages = merge_pipeline_deploy(pipeline, deploy) assert isinstance(pipeline, PipelineConfig) @@ -764,15 +1226,54 @@ stages: assert {s.yaml_engine_args.get("custom_voice_dir") for s in stages} == {str(custom_voice_dir)} def test_load_qwen3_omni_moe_deploy_config(self): - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" + deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml")) deploy = load_deploy_config(deploy_path) assert len(deploy.stages) == 3 assert deploy.async_chunk is True assert deploy.connectors is not None assert deploy.platforms is not None + def test_qwen3_omni_thinker_only_deploy_config_uses_resolved_pipeline(self): + deploy_path = Path(get_deploy_config_path("qwen3_omni_moe_thinking.yaml")) + with patch( + "vllm_omni.config.config_factory.get_config", + return_value=Q3_OMNI_THINKER_HF_CONFIG, + ): + pipeline = StageConfigFactory.get_pipeline_config( + "Qwen/Qwen3-Omni-30B-A3B-Thinking", + trust_remote_code=False, + deploy_config_path=str(deploy_path), + ) + + assert pipeline is not None + assert pipeline.model_type == "qwen3_omni_moe_thinker_only" + + deploy = load_deploy_config(deploy_path) + stages = merge_pipeline_deploy(pipeline, deploy) + + assert len(stages) == 1 + thinker = stages[0] + assert thinker.model_stage == "thinker" + assert thinker.final_output is True + assert thinker.final_output_type == "text" + assert thinker.worker_type == "ar" + assert thinker.scheduler_cls == "vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler" + assert thinker.hf_config_name == "thinker_config" + assert thinker.is_comprehension is True + assert thinker.yaml_runtime["devices"] == "0,1" + assert thinker.yaml_engine_args["engine_output_type"] == "text" + assert thinker.yaml_engine_args["tensor_parallel_size"] == 2 + assert thinker.yaml_engine_args["max_num_seqs"] == 1 + assert thinker.yaml_engine_args["gpu_memory_utilization"] == 0.9 + assert thinker.yaml_engine_args["enforce_eager"] is True + assert thinker.yaml_engine_args["async_scheduling"] is False + assert thinker.yaml_engine_args["distributed_executor_backend"] == "mp" + assert thinker.yaml_engine_args["enable_prefix_caching"] is True + assert thinker.yaml_engine_args["async_chunk"] is False + assert thinker.yaml_extras["default_sampling_params"]["detokenize"] is True + def test_load_voxtral_tts_deploy_config_schema_fields(self): - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "voxtral_tts.yaml" + deploy_path = Path(get_deploy_config_path("voxtral_tts.yaml")) deploy = load_deploy_config(deploy_path) assert deploy.stages[0].config_format == "mistral" assert deploy.stages[0].load_format == "mistral" @@ -782,7 +1283,7 @@ stages: ) def test_load_ming_flash_omni_deploy_config_schema_fields(self): - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "ming_flash_omni.yaml" + deploy_path = Path(get_deploy_config_path("ming_flash_omni.yaml")) deploy = load_deploy_config(deploy_path) assert deploy.stages[0].compilation_config == {"pass_config": {"fuse_allreduce_rms": False}} assert "compilation_config" not in deploy.stages[0].engine_extras @@ -797,11 +1298,18 @@ stages: assert runtime_config == expected_runtime_config @pytest.mark.parametrize( - ("deploy_name", "pipeline_name", "stage_count", "final_output_type"), + ("deploy_name", "pipeline_name", "stage_count", "final_output_type", "declared_pipeline"), [ - ("mammoth_moda2.yaml", "mammoth_moda2", 2, "image"), - ("mammoth_moda2_ar.yaml", "mammoth_moda2_ar", 1, "text"), - ("omnivoice.yaml", "omnivoice", 1, "audio"), + ("mammoth_moda2.yaml", "mammoth_moda2", 2, "image", "mammoth_moda2"), + ("mammoth_moda2_ar.yaml", "mammoth_moda2_ar", 1, "text", "mammoth_moda2_ar"), + ("omnivoice.yaml", "omnivoice", 1, "audio", "omnivoice"), + ("mimo_audio.yaml", "mimo_audio", 2, "audio", None), + ("step_audio_2.yaml", "step_audio_2", 2, "audio", None), + ("step_audio_2_asr.yaml", "step_audio_2_asr", 1, "text", "step_audio_2_asr"), + ("step_audio_2_async_chunk.yaml", "step_audio_2", 2, "audio", None), + ("step_audio2_ci.yaml", "step_audio_2_asr", 1, "text", "step_audio_2_asr"), + ("hunyuan_video_15.yaml", "hunyuan_video_15", 1, "video", None), + ("wan2_2_ti2v.yaml", "wan2_2_ti2v", 1, "video", None), ], ) def test_load_new_registry_backed_deploy_configs( @@ -810,10 +1318,11 @@ stages: pipeline_name: str, stage_count: int, final_output_type: str, + declared_pipeline: str | None, ): deploy_path = Path(get_deploy_config_path(deploy_name)) deploy = load_deploy_config(deploy_path) - assert deploy.pipeline == pipeline_name + assert deploy.pipeline == declared_pipeline with patch("vllm_omni.platforms.current_omni_platform") as platform: platform.device_name = "cuda" @@ -821,13 +1330,104 @@ stages: assert len(stages) == stage_count assert stages[-1].final_output is True assert stages[-1].final_output_type == final_output_type + if deploy.trust_remote_code is not None: + assert {s.yaml_engine_args.get("trust_remote_code") for s in stages} == {deploy.trust_remote_code} + + @pytest.mark.parametrize( + ("config_json", "model_index", "expected_pipeline"), + [ + ({"model_type": "step_audio_2"}, None, "step_audio_2"), + (None, {"_class_name": "HunyuanVideo15Pipeline"}, "hunyuan_video_15"), + (None, {"_class_name": "WanPipeline"}, "wan2_2_ti2v"), + ], + ) + def test_migrated_models_are_discovered_without_explicit_deploy( + self, + config_json, + model_index, + expected_pipeline, + ): + def get_model_file(filename, _model, revision=None): + del revision + if filename == "config.json": + return config_json + if filename == "model_index.json": + return model_index + return None + + with ( + patch.object(StageConfigFactory, "get_hf_config", return_value=None), + patch("vllm_omni.config.config_factory.get_hf_file_to_dict", side_effect=get_model_file), + ): + pipeline = StageConfigFactory.get_pipeline_config( + model="/models/unrelated-checkpoint-name", + trust_remote_code=False, + ) + + assert pipeline is not None + assert pipeline.model_type == expected_pipeline + + @pytest.mark.parametrize("deploy_name", ["step_audio_2.yaml", "step_audio_2_async_chunk.yaml"]) + def test_step_audio2_deploy_configs_fit_two_gpus(self, deploy_name): + deploy = load_deploy_config(Path(get_deploy_config_path(deploy_name))) + + assert deploy.stages[0].devices == "0,1" + assert deploy.stages[0].tensor_parallel_size == 2 + assert deploy.stages[1].devices == "1" + + def test_step_audio2_online_ci_deploy_is_single_thinker(self): + deploy = load_deploy_config(Path(get_deploy_config_path("step_audio2_ci.yaml"))) + + assert deploy.pipeline == "step_audio_2_asr" + assert deploy.async_chunk is False + assert deploy.trust_remote_code is True + assert deploy.enable_prefix_caching is False + assert len(deploy.stages) == 1 + + stage = deploy.stages[0] + assert stage.devices == "0" + assert stage.tensor_parallel_size == 1 + assert stage.max_model_len == 1024 + assert stage.max_num_batched_tokens == 1024 + assert stage.max_num_seqs == 1 + assert stage.gpu_memory_utilization == 0.7 + assert stage.skip_mm_profiling is True + assert stage.enforce_eager is True + assert stage.async_scheduling is False + assert stage.engine_extras["hf_overrides"]["architectures"] == ["StepAudio2ThinkerForConditionalGeneration"] + + stages = merge_pipeline_deploy(OMNI_PIPELINES[deploy.pipeline], deploy) + assert len(stages) == 1 + assert stages[0].final_output is True + assert stages[0].final_output_type == "text" + assert stages[0].yaml_engine_args["engine_output_type"] == "text" + + def test_step_audio2_dispatches_sync_and_async_chunk_processors(self): + pipeline = resolve_pipeline_config("step_audio_2") + assert isinstance(pipeline, PipelineConfig) + + sync_stages = merge_pipeline_deploy(pipeline, DeployConfig(async_chunk=False)) + assert "custom_process_next_stage_input_func" not in sync_stages[0].yaml_engine_args + assert sync_stages[1].custom_process_input_func.endswith("thinker2token2wav") + + async_stages = merge_pipeline_deploy(pipeline, DeployConfig(async_chunk=True)) + assert ( + async_stages[0] + .yaml_engine_args["custom_process_next_stage_input_func"] + .endswith("thinker2token2wav_async_chunk") + ) + assert async_stages[1].custom_process_input_func is None + + def test_no_bundled_legacy_stage_config_yamls(self): + stage_config_dir = Path(__file__).parent.parent / "vllm_omni" / "model_executor" / "stage_configs" + assert not list(stage_config_dir.glob("*.yaml")) def test_merge_pipeline_deploy(self): - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" + deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml")) if not deploy_path.exists(): pytest.skip("Deploy config not found") - pipeline = StageConfigFactory.resolve_pipeline_config( + pipeline = resolve_pipeline_config( "qwen3_omni_moe", Q3_OMNI_ALL_STAGES_HF_CONFIG, ) @@ -844,13 +1444,13 @@ stages: assert s0.yaml_extras["default_sampling_params"]["detokenize"] is True def test_merge_pipeline_deploy_preserves_num_replicas(self, tmp_path): - pipeline = StageConfigFactory.resolve_pipeline_config( + pipeline = resolve_pipeline_config( "qwen3_omni_moe", Q3_OMNI_ALL_STAGES_HF_CONFIG, ) assert isinstance(pipeline, PipelineConfig) - base = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" + base = Path(get_deploy_config_path("qwen3_omni_moe.yaml")) if not base.exists(): pytest.skip("Deploy config not found") @@ -1131,7 +1731,7 @@ stages: class TestQwen3OmniPipeline: def test_registered(self): - p = StageConfigFactory.resolve_pipeline_config( + p = resolve_pipeline_config( "qwen3_omni_moe", Q3_OMNI_ALL_STAGES_HF_CONFIG, ) @@ -1141,7 +1741,7 @@ class TestQwen3OmniPipeline: assert p.validate() == [] def test_thinker(self): - p = StageConfigFactory.resolve_pipeline_config( + p = resolve_pipeline_config( "qwen3_omni_moe", Q3_OMNI_ALL_STAGES_HF_CONFIG, ) @@ -1156,7 +1756,7 @@ class TestQwen3OmniPipeline: assert s.sampling_constraints["detokenize"] is True def test_talker(self): - p = StageConfigFactory.resolve_pipeline_config( + p = resolve_pipeline_config( "qwen3_omni_moe", Q3_OMNI_ALL_STAGES_HF_CONFIG, ) @@ -1173,7 +1773,7 @@ class TestQwen3OmniPipeline: assert s.custom_process_next_stage_input_func is not None def test_code2wav(self): - p = StageConfigFactory.resolve_pipeline_config( + p = resolve_pipeline_config( "qwen3_omni_moe", Q3_OMNI_ALL_STAGES_HF_CONFIG, ) @@ -1188,14 +1788,14 @@ class TestQwen3OmniPipeline: class TestQwen2_5OmniPipeline: def test_registered(self): - p = StageConfigFactory.resolve_pipeline_config("qwen2_5_omni") + p = resolve_pipeline_config("qwen2_5_omni") assert isinstance(p, PipelineConfig) assert p.model_arch == "Qwen2_5OmniForConditionalGeneration" assert len(p.stages) == 3 assert p.validate() == [] def test_thinker(self): - p = StageConfigFactory.resolve_pipeline_config("qwen2_5_omni") + p = resolve_pipeline_config("qwen2_5_omni") assert isinstance(p, PipelineConfig) s = p.get_stage(0) @@ -1207,7 +1807,7 @@ class TestQwen2_5OmniPipeline: assert s.requires_multimodal_data is True def test_talker(self): - p = StageConfigFactory.resolve_pipeline_config("qwen2_5_omni") + p = resolve_pipeline_config("qwen2_5_omni") assert isinstance(p, PipelineConfig) s = p.get_stage(1) @@ -1221,7 +1821,7 @@ class TestQwen2_5OmniPipeline: assert s.sync_process_input_func is not None def test_code2wav(self): - p = StageConfigFactory.resolve_pipeline_config("qwen2_5_omni") + p = resolve_pipeline_config("qwen2_5_omni") assert isinstance(p, PipelineConfig) s = p.get_stage(2) @@ -1233,7 +1833,7 @@ class TestQwen2_5OmniPipeline: class TestQwen3TTSPipeline: def test_registered(self): - p = StageConfigFactory.resolve_pipeline_config("qwen3_tts") + p = resolve_pipeline_config("qwen3_tts") assert isinstance(p, PipelineConfig) assert p is not None assert p.model_arch == "Qwen3TTSTalkerForConditionalGeneration" @@ -1241,7 +1841,7 @@ class TestQwen3TTSPipeline: assert p.validate() == [] def test_talker_stage(self): - p = StageConfigFactory.resolve_pipeline_config("qwen3_tts") + p = resolve_pipeline_config("qwen3_tts") assert isinstance(p, PipelineConfig) s = p.get_stage(0) @@ -1255,7 +1855,7 @@ class TestQwen3TTSPipeline: assert s.model_arch is None def test_code2wav_stage_has_per_stage_model_arch(self): - p = StageConfigFactory.resolve_pipeline_config("qwen3_tts") + p = resolve_pipeline_config("qwen3_tts") assert isinstance(p, PipelineConfig) s = p.get_stage(1) @@ -1270,7 +1870,7 @@ class TestQwen3TTSPipeline: def test_per_stage_model_arch_flows_through_merge(self, tmp_path): """Verify the new ps.model_arch override survives merge_pipeline_deploy.""" - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_tts.yaml" + deploy_path = Path(get_deploy_config_path("qwen3_tts.yaml")) if not deploy_path.exists(): pytest.skip("qwen3_tts deploy yaml not found") @@ -1314,14 +1914,14 @@ class TestQwen3TTSPipeline: class TestMingFlashOmniPipeline: def test_registered(self): - p = StageConfigFactory.resolve_pipeline_config("ming_flash_omni") + p = resolve_pipeline_config("ming_flash_omni") assert isinstance(p, PipelineConfig) assert p.model_arch == "MingFlashOmniForConditionalGeneration" assert len(p.stages) == 2 assert p.validate() == [] def test_thinker_stage(self): - p = StageConfigFactory.resolve_pipeline_config("ming_flash_omni") + p = resolve_pipeline_config("ming_flash_omni") assert isinstance(p, PipelineConfig) s = p.get_stage(0) @@ -1335,7 +1935,7 @@ class TestMingFlashOmniPipeline: assert s.sampling_constraints["detokenize"] is True def test_talker_stage(self): - p = StageConfigFactory.resolve_pipeline_config("ming_flash_omni") + p = resolve_pipeline_config("ming_flash_omni") assert isinstance(p, PipelineConfig) s = p.get_stage(1) @@ -1361,7 +1961,7 @@ class TestMingFlashOmniPipeline: Lazy string references only fail at first inference otherwise — this catches typos in the pipeline declaration at import / registration time. """ - p = StageConfigFactory.resolve_pipeline_config("ming_flash_omni") + p = resolve_pipeline_config("ming_flash_omni") assert isinstance(p, PipelineConfig) s = p.get_stage(1) @@ -1371,14 +1971,14 @@ class TestMingFlashOmniPipeline: assert callable(getattr(module, attr)) def test_tts_pipeline_registered(self): - p = StageConfigFactory.resolve_pipeline_config("ming_flash_omni_tts") + p = resolve_pipeline_config("ming_flash_omni_tts") assert isinstance(p, PipelineConfig) assert p.model_arch == "MingFlashOmniTalkerForConditionalGeneration" assert len(p.stages) == 1 assert p.validate() == [] def test_tts_stage(self): - p = StageConfigFactory.resolve_pipeline_config("ming_flash_omni_tts") + p = resolve_pipeline_config("ming_flash_omni_tts") assert isinstance(p, PipelineConfig) s = p.get_stage(0) @@ -1394,7 +1994,7 @@ class TestMingFlashOmniPipeline: def test_full_yaml_loads_and_merges(self): """deploy/ming_flash_omni.yaml parses and merges with the registered pipeline.""" - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "ming_flash_omni.yaml" + deploy_path = Path(get_deploy_config_path("ming_flash_omni.yaml")) if not deploy_path.exists(): pytest.skip("ming_flash_omni deploy yaml not found") @@ -1405,7 +2005,7 @@ class TestMingFlashOmniPipeline: # We won't test stage 0/1 colocation contract here, # as there could exist more variant of custom device setup - pipeline = StageConfigFactory.resolve_pipeline_config("ming_flash_omni") + pipeline = resolve_pipeline_config("ming_flash_omni") assert isinstance(pipeline, PipelineConfig) stages = merge_pipeline_deploy(pipeline, deploy) assert len(stages) == 2 @@ -1414,7 +2014,7 @@ class TestMingFlashOmniPipeline: def test_tts_yaml_loads_and_merges(self): """deploy/ming_flash_omni_tts.yaml parses and routes to the TTS-only pipeline.""" - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "ming_flash_omni_tts.yaml" + deploy_path = Path(get_deploy_config_path("ming_flash_omni_tts.yaml")) if not deploy_path.exists(): pytest.skip("ming_flash_omni_tts deploy yaml not found") @@ -1428,14 +2028,14 @@ class TestMingFlashOmniPipeline: assert stages[0].yaml_engine_args["model_arch"] == "MingFlashOmniTalkerForConditionalGeneration" def test_thinker_only_pipeline_registered(self): - p = StageConfigFactory.resolve_pipeline_config("ming_flash_omni_thinker_only") + p = resolve_pipeline_config("ming_flash_omni_thinker_only") assert isinstance(p, PipelineConfig) assert p.model_arch == "MingFlashOmniForConditionalGeneration" assert len(p.stages) == 1 assert p.validate() == [] def test_thinker_only_stage(self): - p = StageConfigFactory.resolve_pipeline_config("ming_flash_omni_thinker_only") + p = resolve_pipeline_config("ming_flash_omni_thinker_only") assert isinstance(p, PipelineConfig) s = p.get_stage(0) @@ -1452,7 +2052,7 @@ class TestMingFlashOmniPipeline: def test_thinker_only_yaml_loads_and_merges(self): """deploy/ming_flash_omni_thinker_only.yaml parses and routes to the thinker-only pipeline.""" - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "ming_flash_omni_thinker_only.yaml" + deploy_path = Path(get_deploy_config_path("ming_flash_omni_thinker_only.yaml")) if not deploy_path.exists(): pytest.skip("ming_flash_omni_thinker_only deploy yaml not found") @@ -1460,7 +2060,7 @@ class TestMingFlashOmniPipeline: assert len(deploy.stages) == 1 assert deploy.pipeline == "ming_flash_omni_thinker_only" - pipeline = StageConfigFactory.resolve_pipeline_config("ming_flash_omni_thinker_only") + pipeline = resolve_pipeline_config("ming_flash_omni_thinker_only") assert isinstance(pipeline, PipelineConfig) stages = merge_pipeline_deploy(pipeline, deploy) assert len(stages) == 1 @@ -1474,7 +2074,7 @@ class TestMingFlashOmniPipeline: assert p.validate() == [] def test_image_thinker_stage(self): - s = StageConfigFactory.resolve_pipeline_config("ming_flash_omni_image").get_stage(0) + s = resolve_pipeline_config("ming_flash_omni_image").get_stage(0) assert s.model_stage == "thinker" assert s.execution_type == StageExecutionType.LLM_AR assert s.input_sources == () @@ -1488,7 +2088,7 @@ class TestMingFlashOmniPipeline: assert s.prompt_expand_func is not None def test_image_dit_stage(self): - s = StageConfigFactory.resolve_pipeline_config("ming_flash_omni_image").get_stage(1) + s = resolve_pipeline_config("ming_flash_omni_image").get_stage(1) assert s.model_stage == "dit" assert s.execution_type == StageExecutionType.DIFFUSION assert s.input_sources == (0,) @@ -1500,7 +2100,7 @@ class TestMingFlashOmniPipeline: def test_image_processor_wiring_resolves(self): """The prompt_expand_func and custom_process_input_func strings must point to real callables.""" - pipeline = StageConfigFactory.resolve_pipeline_config("ming_flash_omni_image") + pipeline = resolve_pipeline_config("ming_flash_omni_image") assert isinstance(pipeline, PipelineConfig) thinker = pipeline.get_stage(0) @@ -1512,7 +2112,7 @@ class TestMingFlashOmniPipeline: def test_image_yaml_loads_and_merges(self): """deploy/ming_flash_omni_image.yaml parses and routes to the image pipeline.""" - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "ming_flash_omni_image.yaml" + deploy_path = Path(get_deploy_config_path("ming_flash_omni_image.yaml")) if not deploy_path.exists(): pytest.skip("ming_flash_omni_image deploy yaml not found") @@ -1523,7 +2123,7 @@ class TestMingFlashOmniPipeline: assert deploy.connectors is not None assert "shared_memory_connector" in deploy.connectors - pipeline = StageConfigFactory.resolve_pipeline_config("ming_flash_omni_image") + pipeline = resolve_pipeline_config("ming_flash_omni_image") assert isinstance(pipeline, PipelineConfig) stages = merge_pipeline_deploy(pipeline, deploy) assert len(stages) == 2 @@ -1574,7 +2174,7 @@ class TestBaseConfigInheritance: def test_pure_inheritance_overlay(self, tmp_path): """An overlay with only ``base_config`` inherits everything.""" - base = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" + base = Path(get_deploy_config_path("qwen3_omni_moe.yaml")) if not base.exists(): pytest.skip("Base deploy config not found") @@ -1587,7 +2187,7 @@ class TestBaseConfigInheritance: def test_single_field_overlay(self, tmp_path): """An overlay overriding one stage field merges with the base.""" - base = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" + base = Path(get_deploy_config_path("qwen3_omni_moe.yaml")) if not base.exists(): pytest.skip("Base deploy config not found") @@ -1603,8 +2203,45 @@ class TestBaseConfigInheritance: class TestPlatformOverrides: """Test platform-specific deploy config overrides.""" + def test_qwen3_tts_rocm_disables_code2wav_outer_cudagraph(self): + deploy_path = Path(get_deploy_config_path("qwen3_tts.yaml")) + + base = load_deploy_config(deploy_path) + assert base.stages[0].enforce_eager is None + assert base.stages[1].enforce_eager is False + + rocm = _apply_platform_overrides(base, platform="rocm") + assert rocm.stages[0].enforce_eager is None + assert rocm.stages[1].enforce_eager is True + + def test_minicpmo_4_5_cuda_caps_talker_kv_cache(self): + pipeline = resolve_pipeline_config("minicpmo_4_5") + assert isinstance(pipeline, PipelineConfig) + deploy_path = Path(get_deploy_config_path("minicpmo_4_5.yaml")) + + cuda = _apply_platform_overrides(load_deploy_config(deploy_path), platform="cuda") + cuda_stages = merge_pipeline_deploy(pipeline, cuda) + assert cuda_stages[1].yaml_engine_args["kv_cache_memory_bytes"] == 2 * 1024**3 + + # The CUDA memory budget must not leak into the existing NPU profile. + npu = _apply_platform_overrides(load_deploy_config(deploy_path), platform="npu") + npu_stages = merge_pipeline_deploy(pipeline, npu) + assert "kv_cache_memory_bytes" not in npu_stages[1].yaml_engine_args + + # Multi-GPU replica profiles place Talker on dedicated devices and + # preserve their previous automatic KV-cache sizing. + for filename in ( + "minicpmo_4_5_3gpu_stage1_replicas.yaml", + "minicpmo_4_5_4gpu_stage1_replicas.yaml", + ): + replica = _apply_platform_overrides( + load_deploy_config(Path(get_deploy_config_path(filename))), platform="cuda" + ) + replica_stages = merge_pipeline_deploy(pipeline, replica) + assert replica_stages[1].yaml_engine_args["kv_cache_memory_bytes"] is None + def test_npu_overrides(self): - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" + deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml")) if not deploy_path.exists(): pytest.skip("Deploy config not found") @@ -1618,7 +2255,7 @@ class TestPlatformOverrides: assert deploy.stages[2].enforce_eager is False def test_xpu_overrides(self): - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" + deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml")) if not deploy_path.exists(): pytest.skip("Deploy config not found") @@ -1630,7 +2267,7 @@ class TestPlatformOverrides: assert deploy.stages[0].engine_extras.get("max_cudagraph_capture_size") == 0 def test_unknown_platform_noop(self): - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" + deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml")) if not deploy_path.exists(): pytest.skip("Deploy config not found") @@ -1675,12 +2312,12 @@ class TestCLIOverrideFlow: """Test --stage-overrides JSON merge into StageConfig.""" def test_stage_overrides_merge(self): - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" + deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml")) if not deploy_path.exists(): pytest.skip("Deploy config not found") deploy = load_deploy_config(deploy_path) - pipeline = StageConfigFactory.resolve_pipeline_config( + pipeline = resolve_pipeline_config( "qwen3_omni_moe", Q3_OMNI_ALL_STAGES_HF_CONFIG, ) @@ -1693,12 +2330,12 @@ class TestCLIOverrideFlow: assert stages[0].runtime_overrides["gpu_memory_utilization"] == 0.5 def test_global_override_applies_to_all(self): - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" + deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml")) if not deploy_path.exists(): pytest.skip("Deploy config not found") deploy = load_deploy_config(deploy_path) - pipeline = StageConfigFactory.resolve_pipeline_config( + pipeline = resolve_pipeline_config( "qwen3_omni_moe", Q3_OMNI_ALL_STAGES_HF_CONFIG, ) @@ -1713,19 +2350,18 @@ class TestCLIOverrideFlow: class TestAuraOmniDeploy: def test_aura_omni_deploy_forces_pipeline_override(self): - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "aura_omni.yaml" + deploy_path = Path(get_deploy_config_path("aura_omni.yaml")) deploy = load_deploy_config(deploy_path) assert deploy.pipeline == "aura_omni" def test_aura_omni_deploy_resolves_four_native_stages(self): - pipeline_cfg = StageConfigFactory.resolve_pipeline_config("aura_omni") + pipeline_cfg = resolve_pipeline_config("aura_omni") - stages, _ = StageConfigFactory._create_from_registry( - "qwen3_tts", + stages, _ = StageConfigFactory._create_legacy_from_registry( pipeline_cfg, cli_overrides={}, - deploy_config_path=str(Path(__file__).parent.parent / "vllm_omni" / "deploy" / "aura_omni.yaml"), + deploy_config_path=get_deploy_config_path("aura_omni.yaml"), ) assert [stage.model_stage for stage in stages] == [ @@ -1939,12 +2575,11 @@ class TestSentinelDefaultPrecedence: def _stages(self, cli_overrides): model_type = "qwen3_omni_moe" - pipeline_cfg = StageConfigFactory.resolve_pipeline_config( + pipeline_cfg = resolve_pipeline_config( model_type, Q3_OMNI_ALL_STAGES_HF_CONFIG, ) - stages, _ = StageConfigFactory._create_from_registry( - "qwen3_omni_moe", + stages, _ = StageConfigFactory._create_legacy_from_registry( pipeline_cfg, cli_overrides=cli_overrides, ) @@ -2002,16 +2637,11 @@ class TestSentinelDefaultPrecedence: for stage in stages: assert stage.runtime_overrides == {} - def test_create_from_registry_no_cli_explicit_keys_param(self): - sig = inspect.signature(StageConfigFactory._create_from_registry) - named = [p for p in sig.parameters.values() if p.kind != p.VAR_KEYWORD] - assert "cli_explicit_keys" not in {p.name for p in named} - def test_async_chunk_dispatches_processors(self): """A single ``qwen3_tts`` pipeline picks per-chunk vs end-to-end processors based on ``deploy.async_chunk``, without needing a separate variant pipeline registration.""" - pipeline = StageConfigFactory.resolve_pipeline_config("qwen3_tts") + pipeline = resolve_pipeline_config("qwen3_tts") assert isinstance(pipeline, PipelineConfig) # async_chunk=True → stage 0's per-chunk processor wires up, stage 1 @@ -2045,7 +2675,12 @@ class TestSentinelDefaultPrecedence: from vllm_omni.config.stage_config import DeployConfig, merge_pipeline_deploy pipeline_path = ( - Path(__file__).parent.parent / "vllm_omni" / "model_executor" / "models" / "qwen3_omni" / "pipeline.py" + Path(__file__).parent.parent.parent + / "vllm_omni" + / "model_executor" + / "models" + / "qwen3_omni" + / "pipeline.py" ) pipeline = runpy.run_path(str(pipeline_path))["QWEN3_OMNI_PIPELINE"] @@ -2082,7 +2717,7 @@ class TestSentinelDefaultPrecedence: called ``_build_talker_inputs`` identically. Merge under either async_chunk mode must not re-introduce a stage-0 full-payload hook.""" - pipeline = StageConfigFactory.resolve_pipeline_config("ming_flash_omni") + pipeline = resolve_pipeline_config("ming_flash_omni") assert isinstance(pipeline, PipelineConfig) stage0, stage1 = pipeline.stages @@ -2115,12 +2750,12 @@ class TestSamplingConstraintsPrecedence: """Test that pipeline sampling_constraints override deploy defaults.""" def test_constraints_win(self): - deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" + deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml")) if not deploy_path.exists(): pytest.skip("Deploy config not found") deploy = load_deploy_config(deploy_path) - pipeline = StageConfigFactory.resolve_pipeline_config( + pipeline = resolve_pipeline_config( "qwen3_omni_moe", Q3_OMNI_ALL_STAGES_HF_CONFIG, ) diff --git a/tests/config/test_omni_config.py b/tests/config/test_omni_config.py index bf2dba701..c0a3f20e8 100644 --- a/tests/config/test_omni_config.py +++ b/tests/config/test_omni_config.py @@ -9,7 +9,9 @@ from pathlib import Path import pytest from pydantic import ValidationError +from transformers import Qwen3OmniMoeConfig +from tests.helpers.stage_config import get_deploy_config_path from vllm_omni.config import omni_config as omni_config_module from vllm_omni.config.omni_config import ( BaseVllmOmniStageConfig, @@ -26,7 +28,7 @@ from vllm_omni.config.omni_config import ( VllmOmniDiffusionStageConfig, VllmOmniGenerationStageConfig, ) -from vllm_omni.config.pipeline_registry import OMNI_PIPELINES +from vllm_omni.config.pipeline_registry import OMNI_PIPELINES, resolve_pipeline_config from vllm_omni.config.stage_config import ( _STAGE_DEPLOY_FIELDS, PIPELINE_WIDE_ENGINE_FIELDS, @@ -37,7 +39,7 @@ from vllm_omni.config.stage_config import ( load_deploy_config, merge_pipeline_deploy, ) -from vllm_omni.engine.stage_init_utils import build_engine_args_dict +from vllm_omni.engine.stage_init_utils import build_legacy_engine_args_dict pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -53,28 +55,54 @@ def _stable_test_platform(monkeypatch): monkeypatch.setattr(platform, "device_type", "cpu", raising=False) -def _load_default_deploy(model_type: str) -> DeployConfig: - deploy_path = _DEPLOY_DIR / f"{model_type}.yaml" - if deploy_path.exists(): - return load_deploy_config(deploy_path) +def _load_default_deploy(pipeline: PipelineConfig) -> DeployConfig: + if pipeline.default_deploy_config_name is not None: + return load_deploy_config(_DEPLOY_DIR / pipeline.default_deploy_config_name) return DeployConfig() -def _resolve_pipeline_or_skip(model_type: str) -> PipelineConfig: - registered = OMNI_PIPELINES[model_type] - pipeline = registered(None) if callable(registered) else registered +def _resolve_pipeline_or_skip(model_type: str, hf_config=None) -> PipelineConfig: + pipeline = resolve_pipeline_config(model_type, hf_config) if pipeline is None: pytest.skip(f"Pipeline {model_type!r} requires an HF config to resolve") return pipeline +def _from_pipeline_key( + model_type: str, + hf_config=None, + deploy_config_path: str | None = None, + cli_overrides: dict | None = None, +) -> VllmOmniConfig: + return VllmOmniConfig.from_pipeline_config( + _resolve_pipeline_or_skip(model_type, hf_config), + deploy_config_path=deploy_config_path, + cli_overrides=cli_overrides, + ) + + +def test_duplex_session_capacity_propagates_to_every_structured_stage() -> None: + omni_config = _from_pipeline_key("personaplex") + + assert [stage.model_config.duplex_max_sessions for stage in omni_config.stage_configs] == [2, 2] + + +def test_non_duplex_deploy_keeps_model_session_capacity_at_one(tmp_path: Path) -> None: + deploy_path = tmp_path / "personaplex-turn.yaml" + deploy_path.write_text("session_mode: turn\n", encoding="utf-8") + + omni_config = _from_pipeline_key("personaplex", deploy_config_path=str(deploy_path)) + + assert [stage.model_config.duplex_max_sessions for stage in omni_config.stage_configs] == [1, 1] + + @pytest.mark.parametrize("model_type", sorted(OMNI_PIPELINES)) -def test_vllm_omni_config_from_registry_matches_merge_pipeline_deploy(model_type: str): +def test_vllm_omni_config_from_pipeline_config_matches_merge_pipeline_deploy(model_type: str): pipeline = _resolve_pipeline_or_skip(model_type) - legacy_deploy = _load_default_deploy(model_type) + legacy_deploy = _load_default_deploy(pipeline) legacy_stages = merge_pipeline_deploy(pipeline, legacy_deploy) - omni_config = VllmOmniConfig.from_registry(model_type) + omni_config = VllmOmniConfig.from_pipeline_config(pipeline) assert omni_config.pipeline_config is pipeline assert len(omni_config.stage_configs) == len(legacy_stages) @@ -92,13 +120,20 @@ def test_vllm_omni_config_from_registry_matches_merge_pipeline_deploy(model_type assert omni_stage.runtime_config.num_replicas == legacy_stage.yaml_runtime.get("num_replicas", 1) engine_args = legacy_stage.yaml_engine_args + assert omni_stage.model_config.duplex_max_sessions == engine_args.get("duplex_max_sessions", 1) assert omni_stage.model_config.enforce_eager == engine_args.get("enforce_eager", False) assert omni_stage.load_config.load_format == engine_args.get("load_format", "auto") assert omni_stage.load_config.tokenizer_mode == engine_args.get("tokenizer_mode", "auto") - assert omni_stage.cache_config.gpu_memory_utilization == engine_args.get("gpu_memory_utilization", 0.90) - assert omni_stage.scheduler_config.max_num_seqs == engine_args.get("max_num_seqs", 128) + assert omni_stage.cache_config.gpu_memory_utilization == engine_args.get("gpu_memory_utilization") + assert omni_stage.cache_config.enable_prefix_caching == engine_args.get("enable_prefix_caching") + expected_disable_hybrid = engine_args.get("disable_hybrid_kv_cache_manager") + if omni_stage.stage_pipeline_config.execution_type == StageExecutionType.LLM_GENERATION: + expected_disable_hybrid = True if expected_disable_hybrid is None else expected_disable_hybrid + assert omni_stage.cache_config.disable_hybrid_kv_cache_manager == expected_disable_hybrid + assert omni_stage.scheduler_config.max_num_seqs == engine_args.get("max_num_seqs") assert omni_stage.scheduler_config.max_num_batched_tokens == engine_args.get("max_num_batched_tokens") - assert omni_stage.scheduler_config.async_scheduling == engine_args.get("async_scheduling", True) + assert omni_stage.scheduler_config.enable_chunked_prefill == engine_args.get("enable_chunked_prefill") + assert omni_stage.scheduler_config.async_scheduling == engine_args.get("async_scheduling") legacy_parallel_config = engine_args.get("parallel_config") or {} assert omni_stage.parallel_config.tensor_parallel_size == legacy_parallel_config.get( "tensor_parallel_size", @@ -119,7 +154,7 @@ def test_vllm_omni_config_from_registry_matches_merge_pipeline_deploy(model_type def test_stage_by_id_raises_for_unknown_stage(): - omni_config = VllmOmniConfig.from_registry("qwen3_tts") + omni_config = _from_pipeline_key("qwen3_tts") with pytest.raises(KeyError, match="no stage 99"): omni_config.stage_by_id(99) @@ -130,8 +165,8 @@ def test_resolve_execution_mode_rejects_unknown_execution_type(): omni_config_module._resolve_execution_mode("unknown_execution_type") -def test_from_registry_preserves_current_pipeline_config_object(): - omni_config = VllmOmniConfig.from_registry("minicpmo_4_5") +def test_from_pipeline_config_preserves_current_pipeline_config_object(): + omni_config = _from_pipeline_key("minicpmo_4_5") pipeline = _resolve_pipeline_or_skip("minicpmo_4_5") assert omni_config.pipeline_config is pipeline @@ -140,11 +175,11 @@ def test_from_registry_preserves_current_pipeline_config_object(): assert omni_config.pipeline_config.hf_config_predicate is pipeline.hf_config_predicate -def test_from_registry_normalizes_stage_engine_extras_without_expanding_stage_deploy_config(): +def test_from_pipeline_config_normalizes_stage_engine_extras_without_expanding_stage_deploy_config(): assert not hasattr(StageDeployConfig, "model_config") assert not hasattr(StageDeployConfig, "parallel_config") - stage = VllmOmniConfig.from_registry("dreamzero", deploy_config_path="dreamzero_tp1_cfg2").stage_by_id(0) + stage = _from_pipeline_key("dreamzero", deploy_config_path="dreamzero_tp1_cfg2").stage_by_id(0) assert isinstance(stage, VllmOmniDiffusionStageConfig) assert stage.parallel_config.tensor_parallel_size == 1 @@ -152,8 +187,8 @@ def test_from_registry_normalizes_stage_engine_extras_without_expanding_stage_de assert stage.diffusion_config.model_config["default_robot_embodiment"] == "roboarena" -def test_from_registry_applies_cli_overrides_without_stage_config_runtime_bridge(): - omni_config = VllmOmniConfig.from_registry( +def test_from_pipeline_config_applies_cli_overrides_without_stage_config_runtime_bridge(): + omni_config = _from_pipeline_key( "qwen3_tts", cli_overrides={ "stage_0_max_num_seqs": 7, @@ -169,8 +204,66 @@ def test_from_registry_applies_cli_overrides_without_stage_config_runtime_bridge assert stage1.runtime_config.num_gpus == stage1.parallel_config.world_size +@pytest.mark.parametrize( + "cli_overrides", + [ + {"enable_lora": True}, + {"stage_0_enable_lora": True}, + ], + ids=["global", "stage-scoped"], +) +def test_from_pipeline_config_rejects_explicit_unowned_engine_cli_fields(cli_overrides): + with pytest.raises(ValueError, match=r"no structured config owner: enable_lora"): + _from_pipeline_key("qwen3_tts", cli_overrides=cli_overrides) + + +@pytest.mark.parametrize( + ("engine_extras", "unowned_field"), + [ + ({"enable_lora": True}, "enable_lora"), + ({"parallel_config": {"cfg_parallel_size": 2}}, "parallel_config.cfg_parallel_size"), + ], + ids=["top-level", "nested-parallel-config"], +) +def test_from_pipeline_config_rejects_unowned_deploy_engine_extras(engine_extras, unowned_field): + pipeline = _resolve_pipeline_or_skip("qwen3_tts") + deploy = DeployConfig(stages=[StageDeployConfig(stage_id=0, engine_extras=engine_extras)]) + + with pytest.raises(ValueError, match=rf"no structured config owner: {unowned_field}"): + VllmOmniConfig.from_pipeline_config(pipeline, user_deploy_config=deploy) + + +@pytest.mark.parametrize( + ("cli_overrides", "stage_id"), + [ + ({"kv_cache_dtype": "fp8"}, 0), + ({"stage_0_kv_cache_dtype": "fp8"}, 0), + ({"stage_1_kv_cache_dtype": "fp8"}, 1), + ], + ids=["global", "ar-stage", "generation-stage"], +) +def test_from_pipeline_config_rejects_diffusion_only_cli_fields_for_llm_stages(cli_overrides, stage_id): + with pytest.raises(ValueError, match=rf"Stage {stage_id} .*no structured config owner: kv_cache_dtype"): + _from_pipeline_key("qwen3_tts", cli_overrides=cli_overrides) + + +def test_from_pipeline_config_accepts_diffusion_only_cli_fields_for_diffusion_stage(): + stage = _from_pipeline_key( + "dreamzero", + deploy_config_path="dreamzero_tp1_cfg2", + cli_overrides={"kv_cache_dtype": "fp8"}, + ).stage_by_id(0) + + assert isinstance(stage, VllmOmniDiffusionStageConfig) + assert stage.diffusion_config.diffusion_kv_cache_dtype == "fp8" + + +def test_stage_cli_field_selection_defers_ownership_validation_until_sources_are_merged(): + assert omni_config_module._stage_cli_overrides(0, {"enable_lora": True}) == {"enable_lora": True} + + def test_runtime_num_gpus_is_derived_from_parallel_world_size(): - omni_config = VllmOmniConfig.from_registry("hunyuan_image3_dit") + omni_config = _from_pipeline_key("hunyuan_image3_dit") stage = omni_config.stage_by_id(0) assert stage.parallel_config.tensor_parallel_size == 4 @@ -179,7 +272,7 @@ def test_runtime_num_gpus_is_derived_from_parallel_world_size(): def test_runtime_num_gpus_ignores_stale_runtime_override(): - omni_config = VllmOmniConfig.from_registry( + omni_config = _from_pipeline_key( "hunyuan_image3_dit", cli_overrides={ "stage_0_num_gpus": 1, @@ -191,8 +284,8 @@ def test_runtime_num_gpus_ignores_stale_runtime_override(): assert stage.runtime_config.num_gpus == 4 -def test_from_registry_does_not_route_server_cli_keys_to_diffusion_stage(): - omni_config = VllmOmniConfig.from_registry( +def test_from_pipeline_config_does_not_route_server_cli_keys_to_diffusion_stage(): + omni_config = _from_pipeline_key( "dreamzero", deploy_config_path="dreamzero_tp1_cfg2", cli_overrides={ @@ -220,7 +313,7 @@ def test_pipeline_deploy_cli_fields_reuse_legacy_pipeline_wide_engine_fields(): def test_pipeline_wide_model_fields_are_retained_on_structured_stage_configs(tmp_path): custom_voice_dir = tmp_path / "voices" - omni_config = VllmOmniConfig.from_registry( + omni_config = _from_pipeline_key( "qwen3_tts", cli_overrides={ "active_stream_window": 2, @@ -267,8 +360,8 @@ def test_public_config_exports_use_stage_specific_sub_config_names(): }.issubset(config_pkg.__all__) -def test_from_registry_keeps_worker_backend_separate_from_distributed_executor_backend(): - omni_config = VllmOmniConfig.from_registry("dreamzero", deploy_config_path="dreamzero_tp1_cfg2") +def test_from_pipeline_config_keeps_worker_backend_separate_from_distributed_executor_backend(): + omni_config = _from_pipeline_key("dreamzero", deploy_config_path="dreamzero_tp1_cfg2") stage = omni_config.stage_by_id(0) assert isinstance(stage, VllmOmniDiffusionStageConfig) @@ -276,8 +369,8 @@ def test_from_registry_keeps_worker_backend_separate_from_distributed_executor_b assert omni_config.orchestrator_config.worker_backend == "multi_process" -def test_from_registry_maps_orchestrator_cli_overrides(): - omni_config = VllmOmniConfig.from_registry( +def test_from_pipeline_config_maps_orchestrator_cli_overrides(): + omni_config = _from_pipeline_key( "qwen3_tts", cli_overrides={ "stage_init_timeout": 1200, @@ -289,7 +382,6 @@ def test_from_registry_maps_orchestrator_cli_overrides(): "omni_dp_size_local": 2, "omni_lb_policy": "round_robin", "omni_heartbeat_timeout": 9.5, - "shm_threshold_bytes": 4096, "batch_timeout": 3, }, ) @@ -304,25 +396,24 @@ def test_from_registry_maps_orchestrator_cli_overrides(): assert orchestrator_config.omni_dp_size_local == 2 assert orchestrator_config.omni_lb_policy == "round_robin" assert orchestrator_config.omni_heartbeat_timeout == 9.5 - assert orchestrator_config.shm_threshold_bytes == 4096 assert orchestrator_config.batch_timeout == 3 -def test_from_registry_records_loaded_deploy_path_on_orchestrator_config(): - omni_config = VllmOmniConfig.from_registry("dreamzero", deploy_config_path="dreamzero_tp1_cfg2") +def test_from_pipeline_config_records_loaded_deploy_path_on_orchestrator_config(): + omni_config = _from_pipeline_key("dreamzero", deploy_config_path="dreamzero_tp1_cfg2") assert omni_config.pipeline_config.model_type == "dreamzero" assert omni_config.orchestrator_config.deploy_config_path == str(_DEPLOY_DIR / "dreamzero_tp1_cfg2.yaml") -def test_from_registry_dispatches_async_chunk_processors_without_mutating_topology(): +def test_from_pipeline_config_dispatches_async_chunk_processors_without_mutating_topology(): pipeline = _resolve_pipeline_or_skip("qwen3_tts") - async_config = VllmOmniConfig.from_registry("qwen3_tts") + async_config = _from_pipeline_key("qwen3_tts") assert async_config.stage_by_id(0).custom_process_next_stage_input_func.endswith("talker2code2wav_async_chunk") assert async_config.stage_by_id(1).custom_process_input_func is None - sync_config = VllmOmniConfig.from_registry("qwen3_tts", cli_overrides={"async_chunk": False}) + sync_config = _from_pipeline_key("qwen3_tts", cli_overrides={"async_chunk": False}) assert sync_config.stage_by_id(0).custom_process_next_stage_input_func.endswith("talker2code2wav_full_payload") assert sync_config.stage_by_id(1).custom_process_input_func.endswith("talker2code2wav_token_only") @@ -353,8 +444,10 @@ def test_vllm_omni_stage_config_public_fields_use_typed_stage_realizations(): assert {f.name for f in fields(VllmOmniGenerationStageConfig)} == public_fields -def test_runtime_config_fields_match_rfc_runtime_scope(): +def test_runtime_config_fields_match_structured_runtime_scope(): assert {f.name for f in fields(OmniStageRuntimeConfig)} == { + "distributed_executor_backend", + "worker_cls", "devices", "num_replicas", "env", @@ -365,9 +458,23 @@ def test_runtime_config_fields_match_rfc_runtime_scope(): } -def test_sub_config_fields_match_rfc_scopes(): +def test_sub_config_fields_match_structured_scopes(): assert {f.name for f in fields(OmniStageModelConfig)} == { + "model", + "model_arch", + "revision", + "tokenizer_revision", + "code_revision", + "seed", + "logits_processors", + "trust_remote_code", + "dtype", + "attention_backend", + "moe_backend", + "hf_overrides", + "limit_mm_per_prompt", "active_stream_window", + "duplex_max_sessions", "enable_sleep_mode", "default_sampling_params", "subtalker_sampling_params", @@ -376,19 +483,29 @@ def test_sub_config_fields_match_rfc_scopes(): "task_type", "codec_frame_rate_hz", "enforce_eager", + "max_cudagraph_capture_size", "enable_flashinfer_autotune", "compilation_config", "enable_multithread_weight_load", "num_weight_load_threads", "disable_autocast", + # Per-stage checkpoint resolution for repos whose stages live in + # subfolders (e.g. Audex): mirrors StagePipelineConfig on the + # legacy engine-args path. + "model_subdir", + "tokenizer_subdir", } assert {f.name for f in fields(OmniStageLoadConfig)} == { + "tokenizer", + "download_dir", + "skip_tokenizer_init", "load_format", "tokenizer_mode", "config_format", "skip_mm_profiling", } assert {f.name for f in fields(OmniStageCacheConfig)} == { + "kv_cache_memory_bytes", "gpu_memory_utilization", "enable_prefix_caching", "disable_hybrid_kv_cache_manager", @@ -402,6 +519,8 @@ def test_sub_config_fields_match_rfc_scopes(): "async_scheduling", } assert {f.name for f in fields(OmniStageConnectorConfig)} == { + "async_chunk", + "omni_kv_config", "stage_connector", "output_connectors", "input_connectors", @@ -420,9 +539,11 @@ def test_sub_config_fields_match_rfc_scopes(): "sequence_parallel_size", "ulysses_degree", "ring_degree", + "allgather_degree", "ulysses_mode", "cfg_parallel_size", "vae_patch_parallel_size", + "text_encoder_tp_size", "vae_parallel_mode", "use_hsdp", "mask_sp_padding", @@ -503,8 +624,8 @@ def test_diffusion_parallel_config_rejects_hsdp_with_tp_or_dp(): OmniStageDiffusionParallelConfig(data_parallel_size=2, use_hsdp=True, hsdp_shard_size=2) -def test_from_registry_preserves_legacy_pp_dp_for_world_size(): - cfg = VllmOmniConfig.from_registry("hunyuan_image3_dit").stage_by_id(0).parallel_config +def test_from_pipeline_config_preserves_legacy_pp_dp_for_world_size(): + cfg = _from_pipeline_key("hunyuan_image3_dit").stage_by_id(0).parallel_config assert cfg.pipeline_parallel_size == 1 assert cfg.data_parallel_size == 1 @@ -512,7 +633,7 @@ def test_from_registry_preserves_legacy_pp_dp_for_world_size(): assert cfg.world_size == 4 -def test_from_registry_derives_sequence_parallel_size_from_degrees(tmp_path): +def test_from_pipeline_config_derives_sequence_parallel_size_from_degrees(tmp_path): deploy_path = tmp_path / "dreamzero_derived_parallel.yaml" deploy_path.write_text( "\n".join( @@ -529,23 +650,54 @@ def test_from_registry_derives_sequence_parallel_size_from_degrees(tmp_path): ) ) - stage = VllmOmniConfig.from_registry("dreamzero", deploy_config_path=str(deploy_path)).stage_by_id(0) + stage = _from_pipeline_key("dreamzero", deploy_config_path=str(deploy_path)).stage_by_id(0) assert isinstance(stage, VllmOmniDiffusionStageConfig) assert stage.parallel_config.sequence_parallel_size == 6 assert stage.parallel_config.world_size == 6 -def test_diffusion_parallel_config_rejects_cfg_parallel_size_outside_current_bound(): +def test_from_pipeline_config_derives_sequence_parallel_size_from_allgather_degree(tmp_path): + deploy_path = tmp_path / "dreamzero_allgather_parallel.yaml" + deploy_path.write_text( + "\n".join( + [ + "pipeline: dreamzero", + "async_chunk: false", + "stages:", + " - stage_id: 0", + " parallel_config:", + " sequence_parallel_size: 99", + " allgather_degree: 2", + ] + ) + ) + + stage = _from_pipeline_key("dreamzero", deploy_config_path=str(deploy_path)).stage_by_id(0) + + assert isinstance(stage, VllmOmniDiffusionStageConfig) + assert stage.parallel_config.allgather_degree == 2 + assert stage.parallel_config.sequence_parallel_size == 2 + assert stage.parallel_config.world_size == 2 + + +def test_diffusion_parallel_config_accepts_four_way_guidance_parallelism(): + cfg = OmniStageDiffusionParallelConfig(cfg_parallel_size=4) + + assert cfg.cfg_parallel_size == 4 + assert cfg.world_size == 4 + + +def test_diffusion_parallel_config_rejects_allgather_with_ulysses_or_ring(): with pytest.raises(ValidationError): - OmniStageDiffusionParallelConfig(cfg_parallel_size=4) + OmniStageDiffusionParallelConfig(allgather_degree=2, ulysses_degree=2) def test_stage_realizations_use_stage_specific_parallel_config_types(): - qwen_config = VllmOmniConfig.from_registry("qwen3_tts") + qwen_config = _from_pipeline_key("qwen3_tts") ar_stage = qwen_config.stage_by_id(0) generation_stage = qwen_config.stage_by_id(1) - diffusion_stage = VllmOmniConfig.from_registry("hunyuan_image3_dit").stage_by_id(0) + diffusion_stage = _from_pipeline_key("hunyuan_image3_dit").stage_by_id(0) assert isinstance(ar_stage, VllmOmniARStageConfig) assert type(ar_stage.parallel_config) is OmniStageParallelConfig @@ -566,7 +718,7 @@ def test_stage_realizations_use_stage_specific_parallel_config_types(): assert diffusion_stage.parallel_config.ulysses_degree == 1 -def test_from_registry_preserves_diffusion_parallel_mask_sp_padding(tmp_path): +def test_from_pipeline_config_preserves_diffusion_parallel_mask_sp_padding(tmp_path): deploy_path = tmp_path / "dreamzero_mask_sp_padding.yaml" deploy_path.write_text( "\n".join( @@ -581,17 +733,58 @@ def test_from_registry_preserves_diffusion_parallel_mask_sp_padding(tmp_path): ) ) - stage = VllmOmniConfig.from_registry("dreamzero", deploy_config_path=str(deploy_path)).stage_by_id(0) + stage = _from_pipeline_key("dreamzero", deploy_config_path=str(deploy_path)).stage_by_id(0) assert isinstance(stage, VllmOmniDiffusionStageConfig) assert stage.parallel_config.mask_sp_padding is True -def test_from_registry_matches_stage_config_to_omegaconf_behavior_for_representative_stage(): +def test_from_pipeline_config_routes_regional_compile_dynamic(tmp_path): + deploy_path = tmp_path / "dreamzero_compile.yaml" + deploy_path.write_text( + "\n".join( + [ + "pipeline: dreamzero", + "async_chunk: false", + "stages:", + " - stage_id: 0", + " diffusion_compile_granularity: regional", + " diffusion_compile_dynamic: false", + ] + ) + ) + + configured_stage = _from_pipeline_key("dreamzero", deploy_config_path=str(deploy_path)).stage_by_id(0) + overridden_stage = _from_pipeline_key( + "dreamzero", + deploy_config_path=str(deploy_path), + cli_overrides={ + "diffusion_compile_granularity": "full", + "diffusion_compile_dynamic": True, + }, + ).stage_by_id(0) + + assert configured_stage.diffusion_config.diffusion_compile_granularity == "regional" + assert configured_stage.diffusion_config.diffusion_compile_dynamic is False + assert overridden_stage.diffusion_config.diffusion_compile_granularity == "full" + assert overridden_stage.diffusion_config.diffusion_compile_dynamic is True + + +def test_structured_diffusion_config_rejects_non_boolean_compile_dynamic(): + with pytest.raises(ValidationError, match="diffusion_compile_dynamic"): + omni_config_module._DiffusionConfigProjection(diffusion_compile_dynamic="false") + + +def test_structured_diffusion_config_rejects_invalid_compile_granularity(): + with pytest.raises(ValidationError, match="diffusion_compile_granularity"): + omni_config_module._DiffusionConfigProjection(diffusion_compile_granularity="block") + + +def test_from_pipeline_config_matches_stage_config_to_omegaconf_behavior_for_representative_stage(): pipeline = _resolve_pipeline_or_skip("qwen3_tts") - legacy_stage = merge_pipeline_deploy(pipeline, _load_default_deploy("qwen3_tts"))[0] + legacy_stage = merge_pipeline_deploy(pipeline, _load_default_deploy(pipeline))[0] omega_stage = legacy_stage.to_omegaconf() - omni_stage = VllmOmniConfig.from_registry("qwen3_tts").stage_by_id(legacy_stage.stage_id) + omni_stage = _from_pipeline_key("qwen3_tts").stage_by_id(legacy_stage.stage_id) assert omega_stage.stage_id == omni_stage.stage_id assert omega_stage.stage_type == omni_stage.stage_type.value @@ -606,11 +799,93 @@ def test_from_registry_matches_stage_config_to_omegaconf_behavior_for_representa assert omega_stage.runtime.requires_multimodal_data == omni_stage.requires_multimodal_data -def test_from_registry_matches_to_omegaconf_diffusion_parallel_config(): +def test_from_pipeline_config_uses_hf_config_for_callable_resolver(): + hf_config = Qwen3OmniMoeConfig() + hf_config.enable_audio_output = False + + omni_config = _from_pipeline_key("qwen3_omni_moe", hf_config=hf_config) + + assert omni_config.pipeline_config.model_type == "qwen3_omni_moe_thinker_only" + assert len(omni_config.stage_configs) == 1 + assert omni_config.orchestrator_config.deploy_config_path is None + + thinker = omni_config.stage_configs[0] + assert thinker.model_stage == "thinker" + assert thinker.model_config.default_sampling_params == {"detokenize": True} + + +def test_from_pipeline_config_accepts_pre_resolved_pipeline(): + resolved_pipeline = PipelineConfig(model_type="callable_resolved_variant") + + omni_config = VllmOmniConfig.from_pipeline_config(resolved_pipeline) + + assert omni_config.pipeline_config is resolved_pipeline + + +def test_from_pipeline_config_prefers_loaded_user_deploy_config(monkeypatch): + pipeline = _resolve_pipeline_or_skip("qwen3_tts") + user_deploy_config = DeployConfig( + stages=[StageDeployConfig(stage_id=0, max_num_seqs=7)], + ) + monkeypatch.setattr( + omni_config_module, + "load_deploy_config", + lambda _path: pytest.fail("default deploy config should not be loaded"), + ) + + omni_config = VllmOmniConfig.from_pipeline_config( + pipeline, + user_deploy_config=user_deploy_config, + ) + + assert omni_config.stage_by_id(0).scheduler_config.max_num_seqs == 7 + + +def test_from_pipeline_config_default_deploy_name_ignores_cwd(monkeypatch, tmp_path): + default_name = "pipeline_default.yaml" + (tmp_path / default_name).write_text("stages: []\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + pipeline = PipelineConfig( + model_type="pipeline_with_default", + default_deploy_config_name=default_name, + ) + loaded_paths = [] + + def _load_deploy_config(path): + loaded_paths.append(Path(path)) + return DeployConfig() + + monkeypatch.setattr(omni_config_module, "load_deploy_config", _load_deploy_config) + + omni_config = VllmOmniConfig.from_pipeline_config(pipeline) + + assert omni_config.orchestrator_config.deploy_config_path == str(_DEPLOY_DIR / default_name) + assert loaded_paths == [_DEPLOY_DIR / default_name] + + +def test_from_pipeline_config_uses_resolved_deploy_pipeline(): + deploy_path = get_deploy_config_path("aura_omni.yaml") + pipeline = _resolve_pipeline_or_skip("aura_omni") + + omni_config = VllmOmniConfig.from_pipeline_config( + pipeline, + deploy_config_path=str(deploy_path), + ) + + assert omni_config.pipeline_config.model_type == "aura_omni" + assert [stage.model_stage for stage in omni_config.stage_configs] == [ + "asr", + "aura", + "qwen3_tts", + "code2wav", + ] + + +def test_from_pipeline_config_matches_to_omegaconf_diffusion_parallel_config(): pipeline = _resolve_pipeline_or_skip("hunyuan_image3_dit") - legacy_stage = merge_pipeline_deploy(pipeline, _load_default_deploy("hunyuan_image3_dit"))[0] + legacy_stage = merge_pipeline_deploy(pipeline, _load_default_deploy(pipeline))[0] omega_stage = legacy_stage.to_omegaconf() - omni_stage = VllmOmniConfig.from_registry("hunyuan_image3_dit").stage_by_id(legacy_stage.stage_id) + omni_stage = _from_pipeline_key("hunyuan_image3_dit").stage_by_id(legacy_stage.stage_id) assert ( omega_stage.engine_args.parallel_config.pipeline_parallel_size @@ -631,19 +906,19 @@ def test_from_registry_matches_to_omegaconf_diffusion_parallel_config(): ) -def test_from_registry_matches_build_engine_args_dict_behavior_for_representative_stage(monkeypatch): +def test_from_pipeline_config_matches_build_engine_args_dict_behavior_for_representative_stage(monkeypatch): from vllm_omni.engine import stage_init_utils monkeypatch.setattr(stage_init_utils, "resolve_worker_cls", lambda engine_args: None) pipeline = _resolve_pipeline_or_skip("qwen3_tts") - legacy_stage = merge_pipeline_deploy(pipeline, _load_default_deploy("qwen3_tts"))[0] + legacy_stage = merge_pipeline_deploy(pipeline, _load_default_deploy(pipeline))[0] omega_stage = legacy_stage.to_omegaconf() - legacy_engine_args = build_engine_args_dict( + legacy_engine_args = build_legacy_engine_args_dict( omega_stage, model="/tmp/qwen3-tts", stage_connector_spec={"name": "SharedMemoryConnector", "extra": {}}, ) - omni_stage = VllmOmniConfig.from_registry("qwen3_tts").stage_by_id(legacy_stage.stage_id) + omni_stage = _from_pipeline_key("qwen3_tts").stage_by_id(legacy_stage.stage_id) assert legacy_engine_args["model"] == "/tmp/qwen3-tts" assert legacy_engine_args["stage_id"] == omni_stage.stage_id @@ -657,8 +932,8 @@ def test_from_registry_matches_build_engine_args_dict_behavior_for_representativ assert omni_stage.model_config.has_sampling_extra_args == legacy_engine_args["has_sampling_extra_args"] -def test_from_registry_derives_has_sampling_extra_args_from_stage_defaults(): - stage = VllmOmniConfig.from_registry("voxtral_tts").stage_by_id(0) +def test_from_pipeline_config_derives_has_sampling_extra_args_from_stage_defaults(): + stage = _from_pipeline_key("voxtral_tts").stage_by_id(0) assert (stage.model_config.default_sampling_params or {}).get("extra_args") assert stage.model_config.has_sampling_extra_args is True @@ -709,7 +984,7 @@ def test_diffusion_config_from_kwargs_reuses_legacy_normalization(monkeypatch): assert cfg.diffusers_call_kwargs == {} -def test_from_registry_normalizes_diffusion_config_aliases_from_engine_args(tmp_path): +def test_from_pipeline_config_normalizes_diffusion_config_aliases_from_engine_args(tmp_path): deploy_path = tmp_path / "dreamzero_diffusion_aliases.yaml" deploy_path.write_text( "\n".join( @@ -723,7 +998,7 @@ def test_from_registry_normalizes_diffusion_config_aliases_from_engine_args(tmp_ ) ) - stage = VllmOmniConfig.from_registry( + stage = _from_pipeline_key( "dreamzero", deploy_config_path=str(deploy_path), ).stage_by_id(0) @@ -733,6 +1008,8 @@ def test_from_registry_normalizes_diffusion_config_aliases_from_engine_args(tmp_ def test_diffusion_config_field_classification_covers_current_fields(): + from vllm_omni.diffusion.data import OmniDiffusionConfig + classified_fields = ( omni_config_module._DIFFUSION_SHARED_CONFIG_FIELDS | omni_config_module._DIFFUSION_RUNTIME_CONFIG_FIELDS @@ -740,8 +1017,12 @@ def test_diffusion_config_field_classification_covers_current_fields(): ) assert classified_fields == {f.name for f in fields(omni_config_module._DiffusionConfigProjection)} + assert {f.name for f in fields(OmniDiffusionConfig)} <= ( + classified_fields | omni_config_module._DIFFUSION_MOVED_SHARED_FIELDS + ) assert { "enable_prompt_embed_cache", + "enable_session_state_manager", "prompt_embed_cache_size", "diffusion_kv_cache_dtype", } <= omni_config_module._DIFFUSION_ONLY_CONFIG_FIELDS diff --git a/tests/conftest.py b/tests/conftest.py index 11619b782..b453bbcbf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ Root pytest entrypoint for the vLLM-Omni test suite. - `tests/conftest.py` stays thin: plugin registration + compatibility re-exports. - Importable utilities live under `tests/helpers/`. +- Helper unit tests live under `tests/helpers/tests/`. - Fixtures live under `tests/helpers/fixtures/` and are loaded via `pytest_plugins`. """ diff --git a/tests/core/sched/test_omni_ar_scheduler_free_request_cleanup.py b/tests/core/sched/test_omni_ar_scheduler_free_request_cleanup.py new file mode 100644 index 000000000..73c71fd63 --- /dev/null +++ b/tests/core/sched/test_omni_ar_scheduler_free_request_cleanup.py @@ -0,0 +1,74 @@ +"""Regression tests for vllm-project/vllm-omni#5349 (P1): normal completion +goes through _free_request(), not finish_requests() (the external +abort/cancel entry point). Without a cleanup_receiver() call there, +_active_streams never releases on ordinary completion, so the bounded-K +window fills with stale entries after K completions. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +# Imports must run in this order: vllm_omni applies patches to vllm.v1.request before +# Request / RequestStatus are bound in this module. Ruff isort would reorder them. +# isort: off +import vllm_omni # noqa: F401 - import for side effects (patch vLLM) +from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler + +# isort: on + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _make_scheduler(*, chunk_transfer_adapter=None) -> OmniARScheduler: + """Minimal OmniARScheduler exercising _free_request()'s no-KV-transfer + happy path.""" + sched = OmniARScheduler.__new__(OmniARScheduler) + sched._omits_kv_transfer_cache = {} + sched._connector_finished = lambda request: (False, None) + sched.encoder_cache_manager = MagicMock() + sched.finished_req_ids = set() + sched._new_prompt_len_snapshot = {} + sched.finished_req_ids_dict = None + sched._should_transfer_kv_for_request = lambda req_id: False + sched._free_blocks = MagicMock() + sched._free_input_coordinator_request = MagicMock() + sched.chunk_transfer_adapter = chunk_transfer_adapter + return sched + + +class _FakeFinishedRequest: + """Not a SimpleNamespace: SimpleNamespace defines __eq__, which makes it + unhashable, and _free_request() puts the request in a set.""" + + def __init__(self, request_id: str) -> None: + self.request_id = request_id + + def is_finished(self) -> bool: + return True + + +def _make_finished_request(request_id: str = "req-1"): + return _FakeFinishedRequest(request_id) + + +def test_free_request_releases_chunk_transfer_adapter_receiver_state(): + adapter = MagicMock() + sched = _make_scheduler(chunk_transfer_adapter=adapter) + request = _make_finished_request("req-1") + + sched._free_request(request) + + adapter.cleanup_receiver.assert_called_once_with("req-1") + + +def test_free_request_is_safe_without_a_chunk_transfer_adapter(): + """Most stages have no chunk transfer adapter configured.""" + sched = _make_scheduler(chunk_transfer_adapter=None) + request = _make_finished_request("req-1") + + sched._free_request(request) # must not raise + + sched._free_input_coordinator_request.assert_called_once_with("req-1") diff --git a/tests/core/sched/test_omni_ar_scheduler_kv_transfer.py b/tests/core/sched/test_omni_ar_scheduler_kv_transfer.py new file mode 100644 index 000000000..cffe3f289 --- /dev/null +++ b/tests/core/sched/test_omni_ar_scheduler_kv_transfer.py @@ -0,0 +1,57 @@ +from types import SimpleNamespace + +import pytest +from vllm import SamplingParams +from vllm.v1.engine import EngineCoreRequest + +from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler +from vllm_omni.engine.async_engine_utils import apply_omni_final_stage_metadata +from vllm_omni.engine.serialization import deserialize_additional_information + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _make_engine_request() -> EngineCoreRequest: + return EngineCoreRequest( + request_id="req", + prompt_token_ids=[1], + mm_features=None, + sampling_params=SamplingParams(max_tokens=1), + pooling_params=None, + arrival_time=0.0, + lora_request=None, + cache_salt=None, + data_parallel_rank=None, + ) + + +def _request_omits_kv_transfer(*, force_kv_transfer: bool) -> tuple[bool, dict]: + tagged = apply_omni_final_stage_metadata( + _make_engine_request(), + final_stage_id=0, + force_kv_transfer=force_kv_transfer, + ) + scheduler = OmniARScheduler.__new__(OmniARScheduler) + scheduler._omits_kv_transfer_cache = {} + request = SimpleNamespace( + request_id="req", + additional_information=tagged.additional_information, + ) + result = scheduler._request_omits_kv_transfer_to_next_stage(request) + metadata = deserialize_additional_information(tagged.additional_information) + return result, metadata + + +def test_stage_zero_request_omits_kv_transfer(): + omits_transfer, metadata = _request_omits_kv_transfer(force_kv_transfer=False) + + assert omits_transfer + assert "omni_force_kv_transfer" not in metadata + + +def test_cfg_companion_forces_kv_transfer_without_downstream_payload(): + omits_transfer, metadata = _request_omits_kv_transfer(force_kv_transfer=True) + + assert not omits_transfer + assert metadata["omni_final_stage_id"] == 0 + assert metadata["omni_force_kv_transfer"] is True diff --git a/tests/core/sched/test_omni_ar_scheduler_logprobs.py b/tests/core/sched/test_omni_ar_scheduler_logprobs.py new file mode 100644 index 000000000..b8dc2ee9e --- /dev/null +++ b/tests/core/sched/test_omni_ar_scheduler_logprobs.py @@ -0,0 +1,266 @@ +"""Tests for the AR sampled-token logprob contract.""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable +from types import MethodType, SimpleNamespace + +import numpy as np +import pytest +from vllm.v1.engine import FinishReason +from vllm.v1.outputs import LogprobsLists +from vllm.v1.request import RequestStatus + +from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler, _slice_sampled_logprobs +from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +# Shared update_from_output helpers moved onto OmniSchedulerMixin; bind them on +# the lightweight stub so direct method calls still exercise the real paths. +_MIXIN_UPDATE_HELPERS = ( + "_remove_stopped_requests_from_queues", + "_handle_failed_kv_load_outputs", + "_aggregate_kv_connector_stats", + "_publish_kv_cache_events", + "_attach_finished_request_sets", + "_attach_scheduler_stats", + "_capture_omni_connector_output", +) + + +def _logprobs( + token_ids: list[list[int]], + values: list[list[float]], + *, + cumulative_rows: list[int] | None = None, +) -> LogprobsLists: + return LogprobsLists( + logprob_token_ids=np.asarray(token_ids, dtype=np.int32), + logprobs=np.asarray(values, dtype=np.float32), + sampled_token_ranks=np.zeros(len(token_ids), dtype=np.int32), + cu_num_generated_tokens=cumulative_rows, + ) + + +def test_slices_requested_rows_and_preserves_sampled_token_values() -> None: + all_logprobs = _logprobs( + [[3, 8], [4, 9], [11, 2], [12, 1]], + [[-0.3, -1.0], [-0.4, -1.1], [-0.11, -2.0], [-0.12, -2.1]], + cumulative_rows=[0, 2], + ) + + sliced = _slice_sampled_logprobs(all_logprobs, req_index=1, sampled_token_ids=[11, 12]) + + np.testing.assert_array_equal(sliced.logprob_token_ids[:, 0], [11, 12]) + np.testing.assert_allclose(sliced.logprobs[:, 0], [-0.11, -0.12]) + + +def test_rejects_missing_logprobs() -> None: + with pytest.raises(RuntimeError, match="model runner returned none"): + _slice_sampled_logprobs(None, req_index=0, sampled_token_ids=[7]) + + +def test_rejects_row_count_mismatch() -> None: + logprobs = _logprobs([[7, 1]], [[-0.7, -1.0]]) + + with pytest.raises(RuntimeError, match="row count does not match"): + _slice_sampled_logprobs(logprobs, req_index=0, sampled_token_ids=[7, 8]) + + +def test_rejects_sampled_token_mismatch() -> None: + logprobs = _logprobs([[9, 7]], [[-0.9, -1.0]]) + + with pytest.raises(RuntimeError, match="generated_token=7 logprob_token=9"): + _slice_sampled_logprobs(logprobs, req_index=0, sampled_token_ids=[7]) + + +def test_rejects_non_finite_sampled_logprob() -> None: + logprobs = _logprobs([[7, 1]], [[float("nan"), -1.0]]) + + with pytest.raises(RuntimeError, match="non-finite values at rows \\[0\\]"): + _slice_sampled_logprobs(logprobs, req_index=0, sampled_token_ids=[7]) + + +class _Request: + def __init__(self, request_id: str) -> None: + self.request_id = request_id + self.client_index = 0 + self.status = RequestStatus.RUNNING + self.sampling_params = SimpleNamespace(num_logprobs=1) + self.num_computed_tokens = 0 + self.num_in_flight_tokens = 0 + self.num_output_placeholders = 0 + self.has_encoder_inputs = False + self.pooling_params = None + self.resumable = True + self.stop_reason = None + self.trace_headers = None + self.num_nans_in_logits = 0 + + def is_finished(self) -> bool: + return RequestStatus.is_finished(self.status) + + def get_finished_reason(self): + return RequestStatus.get_finished_reason(self.status) + + def take_events(self): + return None + + def take_prefill_stats(self): + return None + + +class _RequestQueue(list): + def remove_requests(self, requests) -> None: + for request in requests: + if request in self: + self.remove(request) + + +def _make_scheduler_stub(requests: list[_Request]) -> SimpleNamespace: + """Build a minimal stub for OmniARScheduler.update_from_output().""" + scheduler = SimpleNamespace( + perf_metrics=None, + connector=None, + chunk_transfer_adapter=None, + requests={request.request_id: request for request in requests}, + running=list(requests), + waiting=_RequestQueue(), + skipped_waiting=_RequestQueue(), + structured_output_manager=SimpleNamespace(should_advance=lambda _request: False), + transfer_triggered_requests=set(), + active_kv_transfers=set(), + pending_stop_after_extraction=set(), + waiting_for_transfer_free=set(), + _new_prompt_len_snapshot={}, + finished_req_ids=set(), + finished_req_ids_dict=defaultdict(set), + kv_cache_manager=SimpleNamespace(take_events=lambda: None), + kv_event_publisher=SimpleNamespace(publish=lambda _events: None), + recompute_kv_load_failures=False, + ) + for name in _MIXIN_UPDATE_HELPERS: + setattr(scheduler, name, MethodType(getattr(OmniSchedulerMixin, name), scheduler)) + scheduler._cleanup_kv_tracking = MethodType(OmniARScheduler._cleanup_kv_tracking, scheduler) + scheduler.make_spec_decoding_stats = lambda *args, **kwargs: None + scheduler.make_stats = lambda *args, **kwargs: None + return scheduler + + +def _bind_request_lifecycle( + scheduler: SimpleNamespace, + *, + update_request: Callable, + handle_stopped: Callable | None = None, +) -> None: + def free_request(request): + scheduler.requests.pop(request.request_id, None) + scheduler.finished_req_ids.add(request.request_id) + scheduler.finished_req_ids_dict[request.client_index].add(request.request_id) + # vLLM 0.26 contract: (kv_xfer_params, ec_xfer_params) + return None, None + + scheduler._update_request_with_output = update_request + scheduler._process_kv_transfer_trigger = lambda _request, _tokens: False + scheduler._handle_stopped_request = handle_stopped or (lambda _request: True) + scheduler._free_request = free_request + + +def test_mid_step_stop_trims_logprob_rows_with_token_ids() -> None: + """Spec decode can sample tokens past a stop; the trailing tokens are + trimmed by _update_request_with_output, and the emitted logprob rows must + be trimmed with them (upstream slices logprobs after the trim).""" + request = _Request("req") + request.num_computed_tokens = 8 + scheduler = _make_scheduler_stub([request]) + + def update_request_trimming(req, token_ids): + # Mirror vllm Scheduler._update_request_with_output: the first sampled + # token hits EOS, so the spec-accepted tokens behind it are trimmed + # in place and the request stops. + del token_ids[1:] + req.status = RequestStatus.FINISHED_STOPPED + return token_ids, True + + _bind_request_lifecycle(scheduler, update_request=update_request_trimming) + + scheduler_output = SimpleNamespace( + num_scheduled_tokens={"req": 3}, + scheduled_spec_decode_tokens={"req": [8, 9]}, + num_invalid_spec_tokens=0, + ) + model_runner_output = SimpleNamespace( + sampled_token_ids=[[7, 8, 9]], + logprobs=_logprobs( + [[7, 1], [8, 2], [9, 3]], + [[-0.7, -1.0], [-0.8, -1.1], [-0.9, -1.2]], + cumulative_rows=[0], + ), + prompt_logprobs_dict={}, + pooler_output=None, + num_nans_in_logits=None, + kv_connector_output=None, + cudagraph_stats=None, + req_id_to_index={"req": 0}, + routed_experts=None, + ) + + outputs = OmniARScheduler.update_from_output(scheduler, scheduler_output, model_runner_output) + (output,) = outputs[0].outputs + + assert output.new_token_ids == [7] + assert output.finish_reason is FinishReason.STOP + token_rows = np.asarray(output.new_logprobs.logprob_token_ids) + value_rows = np.asarray(output.new_logprobs.logprobs) + assert token_rows.shape[0] == len(output.new_token_ids) + assert value_rows.shape[0] == len(output.new_token_ids) + np.testing.assert_array_equal(token_rows[:, 0], [7]) + + +def test_invalid_logprobs_finish_only_the_affected_scheduler_request() -> None: + """A bad runner response must not bring down unrelated batch requests.""" + bad = _Request("bad") + good = _Request("good") + scheduler = _make_scheduler_stub([bad, good]) + update_calls: list[str] = [] + + def update_request(request, token_ids): + update_calls.append(request.request_id) + return token_ids, False + + _bind_request_lifecycle(scheduler, update_request=update_request) + + scheduler_output = SimpleNamespace( + num_scheduled_tokens={"bad": 1, "good": 1}, + scheduled_spec_decode_tokens={}, + num_invalid_spec_tokens=0, + ) + model_runner_output = SimpleNamespace( + sampled_token_ids=[[7], [8]], + logprobs=_logprobs( + [[9, 1], [8, 2]], + [[-0.9, -1.0], [-0.8, -1.0]], + cumulative_rows=[0, 1], + ), + prompt_logprobs_dict={}, + pooler_output=None, + num_nans_in_logits=None, + kv_connector_output=None, + cudagraph_stats=None, + req_id_to_index={"bad": 0, "good": 1}, + routed_experts=None, + ) + + outputs = OmniARScheduler.update_from_output(scheduler, scheduler_output, model_runner_output) + output_by_id = {output.request_id: output for output in outputs[0].outputs} + + assert update_calls == ["good"] + assert bad.status is RequestStatus.FINISHED_ERROR + assert bad.stop_reason is not None and "misaligned" in bad.stop_reason + assert "bad" not in scheduler.requests + assert output_by_id["bad"].finish_reason is FinishReason.ERROR + assert output_by_id["bad"].new_token_ids == [] + assert output_by_id["good"].new_token_ids == [8] + np.testing.assert_array_equal(output_by_id["good"].new_logprobs.logprob_token_ids[:, 0], [8]) diff --git a/tests/core/sched/test_omni_ar_scheduler_streaming.py b/tests/core/sched/test_omni_ar_scheduler_streaming.py index 41fb414cb..ca80323a7 100644 --- a/tests/core/sched/test_omni_ar_scheduler_streaming.py +++ b/tests/core/sched/test_omni_ar_scheduler_streaming.py @@ -1,8 +1,12 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + """Unit tests for Omni AR streaming-session async placeholder handling.""" from __future__ import annotations from types import SimpleNamespace +from unittest.mock import MagicMock import pytest @@ -11,6 +15,8 @@ import pytest # 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 @@ -26,6 +32,7 @@ def _make_scheduler(*, stage_id: int = 0) -> OmniARScheduler: sched.num_waiting_for_streaming_input = 0 sched.log_stats = False sched.chunk_transfer_adapter = None + sched.skipped_waiting = set() return sched @@ -50,6 +57,156 @@ def _make_update(prompt_token_ids: list[int] | None = None) -> StreamingUpdate: ) +def _run_resumable_segment_stop( + session: Request, + *, + session_finished: bool = False, +): + sched = MagicMock() + sched.requests = {session.request_id: session} + sched.perf_metrics = None + sched.structured_output_manager.should_advance.return_value = False + + def stop_request(request: Request, _token_ids: list[int]): + request.status = RequestStatus.FINISHED_STOPPED + return [42], True + + sched._update_request_with_output.side_effect = stop_request + sched._handle_stopped_request.return_value = session_finished + # vLLM 0.26 returns (kv_xfer_params, ec_xfer_params); an unconfigured + # MagicMock iterates empty and fails to unpack at the call site. + sched._free_request.return_value = (None, None) + sched.chunk_transfer_adapter = None + 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 + + scheduler_output = MagicMock(spec=SchedulerOutput) + scheduler_output.num_scheduled_tokens = {session.request_id: 1} + 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 = [[42]] + 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 + + return OmniARScheduler.update_from_output(sched, scheduler_output, model_runner_output) + + +@pytest.mark.parametrize("outstanding_async_tokens", [0, 1, 2]) +def test_resumable_segment_stop_reconciles_async_placeholders( + outstanding_async_tokens: int, +) -> None: + """A segment stop discards and rolls back only in-flight async tokens.""" + session = _make_request() + session.status = RequestStatus.RUNNING + session.resumable = True + session.append_output_token_ids([7, 8]) + session.num_computed_tokens = session.num_tokens + outstanding_async_tokens + session.num_output_placeholders = outstanding_async_tokens + session.spec_token_ids = [-1] * outstanding_async_tokens + + _run_resumable_segment_stop(session) + + assert session.async_tokens_to_discard == outstanding_async_tokens + assert session.num_computed_tokens == session.num_tokens + assert session.num_output_placeholders == 0 + assert session.spec_token_ids == [] + assert session._output_token_ids == [] + + +def test_resumable_session_terminal_is_not_marked_as_segment_boundary() -> None: + session = _make_request() + session.status = RequestStatus.RUNNING + session.resumable = True + + outputs = _run_resumable_segment_stop(session, session_finished=True) + + output = outputs[session.client_index].outputs[0] + assert output.finish_reason is not None + assert output.is_segment_finished is False + + +def test_update_from_output_settles_in_flight_tokens() -> None: + """vLLM 0.26: schedule() increments num_in_flight_tokens per scheduled + token; update_from_output must decrement it symmetrically. If the + decrement is dropped the counter grows monotonically and both readers + (allocate_slots, _connector_finished) clamp + max(0, num_computed_tokens - num_in_flight_tokens) to zero forever, + silently freezing sliding-window block freeing. + """ + session = _make_request() + session.status = RequestStatus.RUNNING + session.num_in_flight_tokens = 1 # as left by schedule() for this step + + _run_resumable_segment_stop(session) + + assert session.num_in_flight_tokens == 0 + + +def test_running_decode_step_without_inter_stage_payload_does_not_raise() -> None: + """A decode step that neither stops nor carries an inter-stage payload. + + ``finished`` is only assigned when the request stops, yet the async-chunk + save condition reads it for every request, so this step used to raise + ``UnboundLocalError: cannot access local variable 'finished'``. + """ + session = _make_request() + session.status = RequestStatus.RUNNING + + 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 + + scheduler_output = MagicMock(spec=SchedulerOutput) + scheduler_output.num_scheduled_tokens = {session.request_id: 1} + 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 = [[42]] + 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 + + OmniARScheduler.update_from_output(sched, scheduler_output, model_runner_output) + + # Nothing to hand downstream: no payload, no segment boundary, not finished. + sched.chunk_transfer_adapter.save_async.assert_not_called() + + def test_stage0_streaming_update_discards_outstanding_async_placeholder_token() -> None: sched = _make_scheduler(stage_id=0) session = _make_request() @@ -90,3 +247,67 @@ def test_stage0_streaming_update_keeps_all_computed_tokens_without_placeholder() assert session._output_token_ids == [] assert session.num_prompt_tokens == 8 assert sched._new_prompt_len_snapshot[session.request_id] == 2 + + +def test_explicit_streaming_payload_replaces_placeholder_prompt() -> None: + sched = _make_scheduler(stage_id=1) + sched.chunk_transfer_adapter = SimpleNamespace( + receives_chunks=False, + segment_finished_requests=set(), + ) + session = _make_request() + session.status = RequestStatus.WAITING_FOR_STREAMING_REQ + update = _make_update([10, 20]) + update.additional_information = { + "tts_token_ids": [10, 20], + "meta": {"replace_streaming_prompt": True}, + } + update.model_intermediate_buffer = { + "ids": {"tts": [41, 42, 99]}, + "meta": {"turn_eos_token_id": 99}, + } + + sched._update_request_as_session(session, update) + + assert session.prompt_token_ids == [10, 20] + assert session.additional_information == update.additional_information + assert session.model_intermediate_buffer == { + "ids": {"tts": [41, 42, 99]}, + "meta": {"turn_eos_token_id": 99}, + } + assert session.status == RequestStatus.WAITING + + +def test_model_intermediate_streaming_payload_replaces_computed_prompt() -> None: + sched = _make_scheduler(stage_id=1) + sched.chunk_transfer_adapter = SimpleNamespace( + receives_chunks=False, + segment_finished_requests=set(), + ) + session = _make_request() + session.status = RequestStatus.WAITING_FOR_STREAMING_REQ + session.prompt_token_ids = [0] * 59 + session._all_token_ids.clear() + session._all_token_ids.extend(session.prompt_token_ids) + session.num_prompt_tokens = 59 + session.num_computed_tokens = 59 + update = _make_update([0] * 10) + update.additional_information = None + update.model_intermediate_buffer = { + "ids": {"tts": list(range(8))}, + "hidden_states": {"tts": [[0.0]] * 8}, + "meta": { + "replace_streaming_prompt": True, + "next_stage_prompt_len": 10, + }, + } + + sched._update_request_as_session(session, update) + + assert session.prompt_token_ids == [0] * 10 + assert list(session._all_token_ids) == [0] * 10 + assert session.num_prompt_tokens == 10 + assert session.num_computed_tokens == 0 + assert session.additional_information is None + assert session.model_intermediate_buffer == update.model_intermediate_buffer + assert session.status == RequestStatus.WAITING diff --git a/tests/core/sched/test_omni_generation_scheduler_update_session.py b/tests/core/sched/test_omni_generation_scheduler_update_session.py index d94996014..0f654715a 100644 --- a/tests/core/sched/test_omni_generation_scheduler_update_session.py +++ b/tests/core/sched/test_omni_generation_scheduler_update_session.py @@ -9,6 +9,8 @@ update payloads early. from __future__ import annotations from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import MagicMock import pytest @@ -17,7 +19,10 @@ import pytest # 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.engine import EngineCoreEventType +from vllm.v1.metrics.stats import PrefillStats +from vllm.v1.outputs import ModelRunnerOutput from vllm.v1.request import Request, RequestStatus, StreamingUpdate from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin @@ -50,6 +55,18 @@ class _SchedulerStub(OmniGenerationScheduler): raise AssertionError("unexpected enqueue for skipped_waiting miss") +class _AsyncChunkStopSchedulerStub(OmniGenerationScheduler): + def __init__(self) -> None: + self.num_waiting_for_streaming_input = 0 + self.chunk_transfer_adapter = SimpleNamespace(receives_chunks=True) + self.enqueued: list[Request] = [] + self.enqueued_statuses: list[RequestStatus] = [] + + def _enqueue_waiting_request(self, session: Request) -> None: + self.enqueued.append(session) + self.enqueued_statuses.append(session.status) + + def _make_request(**kwargs) -> Request: sp = SamplingParams(max_tokens=8) defaults = dict( @@ -77,6 +94,97 @@ def _make_update(**kwargs) -> StreamingUpdate: return StreamingUpdate(**defaults) +def test_generation_scheduler_records_prefill_stats_for_metrics() -> None: + request = _make_request(prompt_token_ids=[1, 2, 3, 4]) + request.prefill_stats = PrefillStats() + + OmniGenerationScheduler._record_prefill_stats(request) + + assert request.prefill_stats.num_prompt_tokens == 4 + assert request.prefill_stats.num_computed_tokens == 4 + assert request.prefill_stats.num_cached_tokens == 0 + + +def test_resumable_generation_stop_marks_segment_boundary() -> None: + session = _make_request(request_id="req-generation-segment") + session.status = RequestStatus.RUNNING + session.resumable = True + session.num_computed_tokens = len(session.prompt_token_ids) + # schedule() has counted this step's token as in flight; update_from_output() + # must settle it before handling the segment boundary. + session.num_in_flight_tokens = 1 + + sched = MagicMock() + sched.requests = {session.request_id: session} + sched.perf_metrics = None + sched.chunk_transfer_adapter = SimpleNamespace( + is_done_receiving_chunks=lambda _request_id: True, + segment_finished_requests={session.request_id}, + ) + + def rearm_resumable_request(request: Request) -> bool: + request.status = RequestStatus.WAITING + return False + + sched._handle_stopped_request.side_effect = rearm_resumable_request + sched.running = [session] + sched.waiting = MagicMock() + sched.skipped_waiting = MagicMock() + sched.structured_output_manager.should_advance.return_value = False + # Async scheduling can observe the segment boundary in the next schedule() + # before this model output is applied and defer the same request for finish. + # update_from_output() must not re-arm the resumable request twice. + sched._pending_finish_reqs = [session] + sched.recompute_kv_load_failures = False + sched.connector = None + sched.kv_cache_manager.take_events.return_value = None + sched.finished_req_ids_dict = {} + sched.make_stats.return_value = None + + scheduler_output = MagicMock(spec=SchedulerOutput) + scheduler_output.num_scheduled_tokens = {session.request_id: 1} + 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 = [[]] + 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 + + outputs = OmniGenerationScheduler.update_from_output( + sched, + scheduler_output, + model_runner_output, + ) + + output = outputs[session.client_index].outputs[0] + assert output.finish_reason is not None + assert output.is_segment_finished is True + sched._handle_stopped_request.assert_called_once_with(session) + assert sched._pending_finish_reqs == [] + + +def test_async_chunk_resumable_stop_rearms_connector_polling() -> None: + sched = _AsyncChunkStopSchedulerStub() + session = _make_request(request_id="req-async-chunk-next-segment") + session.status = RequestStatus.FINISHED_STOPPED + session.resumable = True + + finished = sched._handle_stopped_request(session) + + assert finished is False + assert sched.enqueued == [session] + assert sched.enqueued_statuses == [RequestStatus.WAITING] + assert session.status == RequestStatus.WAITING + assert sched.num_waiting_for_streaming_input == 0 + + class TestReplaceSessionWithStreamingUpdate: def test_resets_tokens_and_prompt_from_update(self) -> None: sched = _SchedulerStub() diff --git a/tests/core/sched/test_omni_scheduler_finish_requests_purge.py b/tests/core/sched/test_omni_scheduler_finish_requests_purge.py index 9b564d7d5..fbfeb384f 100644 --- a/tests/core/sched/test_omni_scheduler_finish_requests_purge.py +++ b/tests/core/sched/test_omni_scheduler_finish_requests_purge.py @@ -76,8 +76,9 @@ def test_finish_requests_purges_finished_request_from_running( ) def fake_finish_requests(self, request_ids, finished_status): - # super() returning the abort tuple but leaving self.running untouched - return [(finished.request_id, 0)] + # vLLM 0.26 super() returns the finished Request objects but here + # leaves self.running untouched to simulate the missed removal. + return [finished] monkeypatch.setattr(scheduler_mod.VLLMScheduler, "finish_requests", fake_finish_requests) @@ -88,7 +89,7 @@ def test_finish_requests_purges_finished_request_from_running( ) assert scheduler.running == [] - assert result == [(finished.request_id, 0)] + assert result == [finished] @pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS) @@ -111,7 +112,7 @@ def test_finish_requests_purges_untracked_request_from_running( ) def fake_finish_requests(self, request_ids, finished_status): - return [(untracked.request_id, 0)] + return [untracked] monkeypatch.setattr(scheduler_mod.VLLMScheduler, "finish_requests", fake_finish_requests) @@ -122,7 +123,7 @@ def test_finish_requests_purges_untracked_request_from_running( ) assert scheduler.running == [] - assert result == [(untracked.request_id, 0)] + assert result == [untracked] @pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS) @@ -136,7 +137,7 @@ def test_finish_requests_leaves_healthy_running_intact( is conservative and won't aggressively drop healthy entries. """ alive = _StubRequest("req-alive", RequestStatus.RUNNING) - leaving_id = "req-leaving" + leaving = _StubRequest("req-leaving", RequestStatus.FINISHED_STOPPED) scheduler = _make_scheduler( scheduler_cls, requests={alive.request_id: alive}, @@ -145,13 +146,13 @@ def test_finish_requests_leaves_healthy_running_intact( ) def fake_finish_requests(self, request_ids, finished_status): - return [(leaving_id, 0)] + return [leaving] monkeypatch.setattr(scheduler_mod.VLLMScheduler, "finish_requests", fake_finish_requests) scheduler_cls.finish_requests( scheduler, - [leaving_id], + [leaving.request_id], RequestStatus.FINISHED_STOPPED, ) diff --git a/tests/core/sched/test_omni_scheduler_input_coordinator_cleanup.py b/tests/core/sched/test_omni_scheduler_input_coordinator_cleanup.py index fcac8172b..008e52636 100644 --- a/tests/core/sched/test_omni_scheduler_input_coordinator_cleanup.py +++ b/tests/core/sched/test_omni_scheduler_input_coordinator_cleanup.py @@ -53,7 +53,11 @@ def test_finish_requests_cleans_input_coordinator_for_finished_ids( def fake_finish_requests(self, request_ids, finished_status): assert request_ids == ["req-a", "req-b"] assert finished_status == RequestStatus.FINISHED_STOPPED - return [("req-a", 0), ("req-b", 1)] + # vLLM 0.26 returns the finished Request objects, not (req_id, client_index) tuples. + return [ + SimpleNamespace(request_id="req-a", client_index=0), + SimpleNamespace(request_id="req-b", client_index=1), + ] monkeypatch.setattr(scheduler_mod.VLLMScheduler, "finish_requests", fake_finish_requests) @@ -63,7 +67,7 @@ def test_finish_requests_cleans_input_coordinator_for_finished_ids( RequestStatus.FINISHED_STOPPED, ) - assert result == [("req-a", 0), ("req-b", 1)] + assert [(r.request_id, r.client_index) for r in result] == [("req-a", 0), ("req-b", 1)] assert coordinator.freed == ["req-a", "req-b"] @@ -71,6 +75,7 @@ def test_ar_free_request_cleans_input_coordinator_on_normal_free() -> None: coordinator = FakeInputCoordinator() scheduler = OmniARScheduler.__new__(OmniARScheduler) scheduler.input_coordinator = coordinator + scheduler.chunk_transfer_adapter = None scheduler._omits_kv_transfer_cache = {"req-free": True} scheduler.encoder_cache_manager = SimpleNamespace(free=lambda request: None) scheduler.finished_req_ids = set() @@ -82,7 +87,7 @@ def test_ar_free_request_cleans_input_coordinator_on_normal_free() -> None: result = OmniARScheduler._free_request(scheduler, DummyRequest()) - assert result is None + assert result == (None, None) assert coordinator.freed == ["req-free"] assert scheduler._omits_kv_transfer_cache == {} assert scheduler._new_prompt_len_snapshot == {} diff --git a/tests/core/sched/test_omni_scheduler_mixin_shared.py b/tests/core/sched/test_omni_scheduler_mixin_shared.py new file mode 100644 index 000000000..9aaa4c656 --- /dev/null +++ b/tests/core/sched/test_omni_scheduler_mixin_shared.py @@ -0,0 +1,90 @@ +from collections import defaultdict +from types import SimpleNamespace + +import pytest +from vllm.v1.engine import FinishReason + +from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin +from vllm_omni.core.sched.output import OmniChunkRecvHandle + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _Scheduler(OmniSchedulerMixin): + pass + + +def test_schedule_lifecycle_helpers_process_and_restore_both_input_paths(): + calls = [] + scheduler = _Scheduler() + scheduler.waiting = ["waiting"] + scheduler.running = ["running"] + scheduler.requests = {"request": object()} + scheduler._consume_pending_connector_output = lambda mode: calls.append(("consume", mode)) + scheduler._process_pending_input_timeouts = lambda: calls.append(("timeouts",)) + scheduler.chunk_transfer_adapter = SimpleNamespace( + process_pending_chunks=lambda waiting, running, scheduler_requests: calls.append( + ("process", waiting, running, scheduler_requests) + ), + restore_queues=lambda waiting, running, scheduler_requests: calls.append( + ("restore-chunks", waiting, running, scheduler_requests) + ), + ) + scheduler.input_coordinator = SimpleNamespace( + restore_queues=lambda waiting: calls.append(("restore-full", waiting)) + ) + + scheduler._process_pending_omni_inputs("ar") + scheduler._restore_omni_wait_queues() + + assert calls == [ + ("consume", "ar"), + ("timeouts",), + ("process", scheduler.waiting, scheduler.running, scheduler.requests), + ("restore-chunks", scheduler.waiting, scheduler.running, scheduler.requests), + ("restore-full", scheduler.waiting), + ] + + +@pytest.mark.parametrize( + ("synthesize_abort_outputs", "expected_finish_reason"), + [(False, None), (True, FinishReason.ABORT)], +) +def test_finished_request_attachment_keeps_ar_abort_policy_explicit( + synthesize_abort_outputs, + expected_finish_reason, +): + scheduler = _Scheduler() + scheduler.finished_req_ids_dict = defaultdict(set, {2: {"req-finished"}}) + outputs = {} + + scheduler._attach_finished_request_sets( + outputs, + synthesize_abort_outputs=synthesize_abort_outputs, + ) + + assert outputs[2].finished_requests == {"req-finished"} + if expected_finish_reason is None: + assert outputs[2].outputs == [] + else: + assert outputs[2].outputs[0].finish_reason == expected_finish_reason + assert scheduler.finished_req_ids_dict == {} + + +def test_chunk_receive_handle_carries_minimal_registration_fields(): + handle = OmniChunkRecvHandle(request_id="req", external_req_id="external") + assert handle.request_id == "req" + assert handle.external_req_id == "external" + + +def test_output_helper_preserves_required_nan_counter_default(): + scheduler = _Scheduler() + request = SimpleNamespace( + request_id="req-output", + trace_headers=None, + take_events=lambda: [], + ) + + output = scheduler._make_omni_engine_output(request, new_token_ids=[]) + + assert output.num_nans_in_logits == 0 diff --git a/tests/core/sched/test_omni_scheduler_mixin_timeouts.py b/tests/core/sched/test_omni_scheduler_mixin_timeouts.py index 053c926cc..5fad4340c 100644 --- a/tests/core/sched/test_omni_scheduler_mixin_timeouts.py +++ b/tests/core/sched/test_omni_scheduler_mixin_timeouts.py @@ -18,6 +18,8 @@ import pytest from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + class _FakeCoordinator: def __init__(self, timed_out_ids): diff --git a/tests/core/sched/test_omni_scheduling_coordinator.py b/tests/core/sched/test_omni_scheduling_coordinator.py index 068cbd362..a36a94e7d 100644 --- a/tests/core/sched/test_omni_scheduling_coordinator.py +++ b/tests/core/sched/test_omni_scheduling_coordinator.py @@ -4,6 +4,9 @@ These tests use mock request objects and mock queues. They do not require GPU, vLLM runtime, or any connector. + +Chunk waiting (WAITING_FOR_CHUNK / process_pending_chunks) lives on +OmniChunkTransferAdapter — see tests/distributed/omni_connectors/. """ from __future__ import annotations @@ -11,6 +14,7 @@ from __future__ import annotations import unittest from types import SimpleNamespace +import pytest import torch import vllm_omni.core.sched.omni_scheduling_coordinator as coord_mod @@ -19,6 +23,8 @@ from vllm_omni.core.sched.omni_scheduling_coordinator import ( uses_full_payload_input_coordinator, ) +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + # ------------------------------------------------------------------ # # Mock helpers # ------------------------------------------------------------------ # @@ -27,7 +33,6 @@ from vllm_omni.core.sched.omni_scheduling_coordinator import ( class _RequestStatus: WAITING = "waiting" RUNNING = "running" - WAITING_FOR_CHUNK = "waiting_for_chunk" WAITING_FOR_INPUT = "waiting_for_input" FINISHED_STOPPED = "finished_stopped" @@ -114,7 +119,12 @@ class TestFullPayloadCoordinatorSelection(unittest.TestCase): ("CovoAudioForConditionalGeneration", "code2wav"), ("MiMoAudioModel", "code2wav"), ("Qwen3TTSCode2Wav", "code2wav"), + ("MiniCPMO45Code2Wav", "code2wav"), ("CosyVoice3Model", "cosyvoice3_code2wav"), + # audex: sync-path TTS decoder (async_chunk: false deploys) and + # the TTA XCodec1 stage (always sync full-payload). + ("AudexCode2Wav", "audex_code2wav"), + ("AudexXCodec1", "audex_xcodec"), ("IndexTTS2S2MelDecoder", "indextts2_s2mel_decoder"), ("DyninOmniForConditionalGeneration", "token2image"), ("DyninOmniForConditionalGeneration", "token2audio"), @@ -185,105 +195,12 @@ class TestFullPayloadCoordinatorSelection(unittest.TestCase): ) -class TestChunkCoordinatorStateTransition(unittest.TestCase): - """Test 5: process_pending_chunks transitions WAITING_FOR_CHUNK → target.""" - - def test_ready_request_transitions_to_waiting(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1, async_chunk=True) - - req = _make_request("r1", status=RequestStatus.WAITING_FOR_CHUNK) - waiting = MockQueue([req]) - running: list = [] - - coord.process_pending_chunks( - waiting, - running, - chunk_ready_req_ids={"r1"}, - chunk_finished_req_ids=set(), - ) - - self.assertEqual(req.status, RequestStatus.WAITING) - self.assertIn("r1", coord.requests_with_ready_chunks) - - def test_non_ready_stays_waiting_for_chunk(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1, async_chunk=True) - - req = _make_request("r1", status=RequestStatus.WAITING_FOR_CHUNK) - waiting = MockQueue([req]) - running: list = [] - - coord.process_pending_chunks( - waiting, - running, - chunk_ready_req_ids=set(), - chunk_finished_req_ids=set(), - ) - - self.assertEqual(req.status, RequestStatus.WAITING_FOR_CHUNK) - - def test_stage_0_is_noop(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=0) - req = _make_request("r1") - waiting = MockQueue([req]) - running: list = [] - - coord.process_pending_chunks( - waiting, - running, - chunk_ready_req_ids={"r1"}, - chunk_finished_req_ids=set(), - ) - self.assertNotEqual(req.status, RequestStatus.WAITING_FOR_CHUNK) - - -class TestChunkCoordinatorRestoreQueues(unittest.TestCase): - """Test 6: restore_queues returns waiting-for-chunk requests.""" - - def test_restore(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) - - r1 = _make_request("r1") - r2 = _make_request("r2") - coord._waiting_for_chunk_waiting.append(r1) - coord._waiting_for_chunk_running.append(r2) - - waiting = MockQueue() - running: list = [] - - coord.restore_queues(waiting, running) - - self.assertIn(r1, waiting) - self.assertIn(r2, running) - self.assertEqual(len(coord._waiting_for_chunk_waiting), 0) - self.assertEqual(len(coord._waiting_for_chunk_running), 0) - - -class TestChunkCoordinatorFinishedSignal(unittest.TestCase): - """Test 8: chunk_finished_req_ids → finished_requests.""" - - def test_finished_signal(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1, async_chunk=True) - - req = _make_request("r1", status=RequestStatus.WAITING_FOR_CHUNK) - waiting = MockQueue([req]) - running: list = [] - - coord.process_pending_chunks( - waiting, - running, - chunk_ready_req_ids={"r1"}, - chunk_finished_req_ids={"r1"}, - ) - - self.assertIn("r1", coord.finished_requests) - - -class TestChunkCoordinatorUpdateRequestMetadata(unittest.TestCase): +class TestCoordinatorUpdateRequestMetadata(unittest.TestCase): """Test update_request_metadata applies scheduling metadata to requests.""" def test_ar_mode_no_longer_sets_additional_information(self): """AR mode only processes scheduling metadata, not full payloads.""" - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) + coord = OmniSchedulingCoordinator(stage_id=1) req = _make_request("r1") requests = {"r1": req} @@ -300,7 +217,7 @@ class TestChunkCoordinatorUpdateRequestMetadata(unittest.TestCase): self.assertIsNone(getattr(req, "additional_information", None)) def test_generation_mode(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) + coord = OmniSchedulingCoordinator(stage_id=1) req = _make_request("r1") req.prompt_token_ids = [0, 0, 0] @@ -313,7 +230,6 @@ class TestChunkCoordinatorUpdateRequestMetadata(unittest.TestCase): request_metadata = { "r1": { "code_predictor_codes": [10, 20, 30], - "left_context_size": 25, } } @@ -325,10 +241,9 @@ class TestChunkCoordinatorUpdateRequestMetadata(unittest.TestCase): self.assertEqual(req._all_token_ids, [10, 20, 30]) self.assertEqual(req._output_token_ids, []) self.assertIsNone(req.additional_information) - self.assertEqual(req._omni_initial_model_buffer, {"meta": {"left_context_size": 25}}) def test_generation_mode_flattens_tensor_code_predictor_codes(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) + coord = OmniSchedulingCoordinator(stage_id=1) req = _make_request("r1") req.prompt_token_ids = [9] @@ -349,7 +264,7 @@ class TestChunkCoordinatorUpdateRequestMetadata(unittest.TestCase): self.assertEqual(req._output_token_ids, []) def test_generation_mode_flattens_nested_code_predictor_codes(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) + coord = OmniSchedulingCoordinator(stage_id=1) req = _make_request("r1") req.prompt_token_ids = [9] @@ -370,53 +285,30 @@ class TestChunkCoordinatorUpdateRequestMetadata(unittest.TestCase): self.assertEqual(req._output_token_ids, []) -class TestChunkCoordinatorPostprocess(unittest.TestCase): - """Test postprocess_scheduler_output clears ready chunks.""" - - def test_clear_ready(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) - coord.requests_with_ready_chunks = {"r1", "r2"} - - new_req = SimpleNamespace(req_id="r1") - cached_reqs = SimpleNamespace(req_ids=["r2"]) - scheduler_output = SimpleNamespace( - scheduled_new_reqs=[new_req], - scheduled_cached_reqs=cached_reqs, - ) - - coord.postprocess_scheduler_output(scheduler_output) - - self.assertEqual(coord.requests_with_ready_chunks, set()) - - class TestWaitingForInputTransition(unittest.TestCase): - """Test B8: process_pending_full_payload_inputs transitions WAITING_FOR_INPUT.""" + """Test process_pending_full_payload_inputs transitions WAITING_FOR_INPUT.""" def test_transition_on_recv(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) + coord = OmniSchedulingCoordinator(stage_id=1) req = _make_request("r1", status=RequestStatus.WAITING_FOR_INPUT) waiting = MockQueue([req]) - running: list = [] coord.process_pending_full_payload_inputs( waiting, - running, stage_recv_req_ids={"r1"}, ) self.assertEqual(req.status, RequestStatus.WAITING) def test_stays_waiting_for_input_if_not_received(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) + coord = OmniSchedulingCoordinator(stage_id=1) req = _make_request("r1", status=RequestStatus.WAITING_FOR_INPUT) waiting = MockQueue([req]) - running: list = [] coord.process_pending_full_payload_inputs( waiting, - running, stage_recv_req_ids=set(), ) @@ -424,49 +316,39 @@ class TestWaitingForInputTransition(unittest.TestCase): self.assertEqual(len(coord._waiting_for_input), 1) def test_stage_0_is_noop(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=0) + coord = OmniSchedulingCoordinator(stage_id=0) req = _make_request("r1", status=RequestStatus.WAITING_FOR_INPUT) waiting = MockQueue([req]) - running: list = [] coord.process_pending_full_payload_inputs( waiting, - running, stage_recv_req_ids={"r1"}, ) self.assertEqual(req.status, RequestStatus.WAITING_FOR_INPUT) def test_restore_queues_includes_waiting_for_input(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) + coord = OmniSchedulingCoordinator(stage_id=1) r1 = _make_request("r1") coord._waiting_for_input.append(r1) waiting = MockQueue() - running: list = [] - coord.restore_queues(waiting, running) + coord.restore_queues(waiting) self.assertIn(r1, waiting) self.assertEqual(len(coord._waiting_for_input), 0) def test_full_payload_mode_auto_transitions_waiting_to_waiting_for_input(self): - """In full_payload_mode (async_chunk=False), fresh WAITING requests on - non-Stage-0 should be transitioned to WAITING_FOR_INPUT.""" - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - async_chunk=False, - ) + """Fresh downstream WAITING requests enter WAITING_FOR_INPUT.""" + coord = OmniSchedulingCoordinator(stage_id=1) req = _make_request("r1", status=RequestStatus.WAITING) waiting = MockQueue([req]) - running: list = [] coord.process_pending_full_payload_inputs( waiting, - running, stage_recv_req_ids=set(), ) @@ -474,37 +356,14 @@ class TestWaitingForInputTransition(unittest.TestCase): self.assertEqual(len(coord._waiting_for_input), 1) self.assertEqual(len(coord.pending_input_registrations), 1) - def test_async_chunk_mode_does_not_auto_transition(self): - """In async_chunk mode, fresh WAITING requests should NOT be - transitioned to WAITING_FOR_INPUT.""" - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - async_chunk=True, - ) - - req = _make_request("r1", status=RequestStatus.WAITING) - waiting = MockQueue([req]) - running: list = [] - - coord.process_pending_full_payload_inputs( - waiting, - running, - stage_recv_req_ids=set(), - ) - - self.assertEqual(req.status, RequestStatus.WAITING) - def test_pending_input_registrations(self): - coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1) + coord = OmniSchedulingCoordinator(stage_id=1) req = _make_request("r1", status=RequestStatus.WAITING_FOR_INPUT) waiting = MockQueue([req]) - running: list = [] coord.process_pending_full_payload_inputs( waiting, - running, stage_recv_req_ids=set(), ) @@ -512,18 +371,13 @@ class TestWaitingForInputTransition(unittest.TestCase): self.assertEqual(coord.pending_input_registrations[0].request_id, "r1") def test_idle_cycles_retain_received_marker_before_request_appears(self): - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - async_chunk=False, - ) + coord = OmniSchedulingCoordinator(stage_id=1) coord._full_payload_input_received.add("late") coord.finished_requests.add("late") waiting = MockQueue() - running: list = [] - coord.process_pending_full_payload_inputs(waiting, running, stage_recv_req_ids=set()) + coord.process_pending_full_payload_inputs(waiting, stage_recv_req_ids=set()) self.assertIn("late", coord._full_payload_input_received) self.assertIn("late", coord.finished_requests) @@ -531,7 +385,7 @@ class TestWaitingForInputTransition(unittest.TestCase): late_req = _make_request("late", status=RequestStatus.WAITING) waiting.add_request(late_req) - coord.process_pending_full_payload_inputs(waiting, running, stage_recv_req_ids=set()) + coord.process_pending_full_payload_inputs(waiting, stage_recv_req_ids=set()) self.assertEqual(late_req.status, RequestStatus.WAITING) self.assertEqual(coord.pending_input_registrations, []) @@ -542,65 +396,18 @@ class TestWaitingForInputTransition(unittest.TestCase): class TestTimeoutDetection(unittest.TestCase): """Regression tests for orphaned pending-recv timeout detection. - Covers the full lifecycle: - 1. Request enters WAITING_FOR_CHUNK from either waiting or running queue - 2. restore_queues() moves it back to the scheduler queue - 3. Timeout fires via collect_timed_out_request_ids() - 4. Scheduler removes from both queues and calls _free_request() + Covers WAITING_FOR_INPUT lifecycle timeouts. Chunk waiting timeouts are + covered by OmniChunkTransferAdapter tests. """ - def test_waiting_since_recorded_on_chunk_wait(self): - """_waiting_since is set when a request enters WAITING_FOR_CHUNK.""" - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - async_chunk=True, - ) - req = _make_request("r1", status=RequestStatus.WAITING) - waiting = MockQueue([req]) - - coord.process_pending_chunks( - waiting, - [], - chunk_ready_req_ids=set(), - chunk_finished_req_ids=set(), - ) - - self.assertIn("r1", coord._waiting_since) - self.assertEqual(req.status, RequestStatus.WAITING_FOR_CHUNK) - - def test_waiting_since_cleared_on_chunk_arrival(self): - """_waiting_since is cleared when a chunk arrives.""" - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - async_chunk=True, - ) - req = _make_request("r1", status=RequestStatus.WAITING_FOR_CHUNK) - waiting = MockQueue([req]) - - coord.process_pending_chunks( - waiting, - [], - chunk_ready_req_ids={"r1"}, - chunk_finished_req_ids=set(), - ) - - self.assertNotIn("r1", coord._waiting_since) - def test_waiting_since_recorded_on_input_wait(self): """_waiting_since is set when a request enters WAITING_FOR_INPUT.""" - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - async_chunk=False, - ) + coord = OmniSchedulingCoordinator(stage_id=1) req = _make_request("r1", status=RequestStatus.WAITING) waiting = MockQueue([req]) coord.process_pending_full_payload_inputs( waiting, - [], stage_recv_req_ids=set(), ) @@ -608,11 +415,7 @@ class TestTimeoutDetection(unittest.TestCase): def test_waiting_since_cleared_on_input_arrival(self): """_waiting_since is cleared when input data arrives.""" - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - async_chunk=False, - ) + coord = OmniSchedulingCoordinator(stage_id=1) req = _make_request("r1", status=RequestStatus.WAITING_FOR_INPUT) coord._waiting_for_input.append(req) coord._waiting_since["r1"] = 0.0 @@ -620,7 +423,6 @@ class TestTimeoutDetection(unittest.TestCase): waiting = MockQueue() coord.process_pending_full_payload_inputs( waiting, - [], stage_recv_req_ids={"r1"}, ) @@ -629,10 +431,7 @@ class TestTimeoutDetection(unittest.TestCase): def test_collect_timed_out_request_ids_no_timeout(self): """No IDs returned when nothing has timed out.""" - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - ) + coord = OmniSchedulingCoordinator(stage_id=1) import time coord._waiting_since["r1"] = time.monotonic() @@ -642,10 +441,7 @@ class TestTimeoutDetection(unittest.TestCase): def test_collect_timed_out_request_ids_expired(self): """Timed-out IDs are returned and _waiting_since is cleared.""" - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - ) + coord = OmniSchedulingCoordinator(stage_id=1) coord._waiting_since["r1"] = 0.0 # epoch → definitely expired coord._waiting_since["r2"] = 0.0 @@ -662,29 +458,19 @@ class TestTimeoutDetection(unittest.TestCase): def test_collect_removes_from_coordinator_queues(self): """Timed-out requests are defensively removed from internal queues.""" - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - ) + coord = OmniSchedulingCoordinator(stage_id=1) r1 = _make_request("r1") - r2 = _make_request("r2") - coord._waiting_for_chunk_waiting.append(r1) - coord._waiting_for_input.append(r2) + coord._waiting_for_input.append(r1) coord._waiting_since["r1"] = 0.0 - coord._waiting_since["r2"] = 0.0 result = coord.collect_timed_out_request_ids(timeout_s=1.0) - self.assertEqual(result, {"r1", "r2"}) - self.assertEqual(len(coord._waiting_for_chunk_waiting), 0) + self.assertEqual(result, {"r1"}) self.assertEqual(len(coord._waiting_for_input), 0) def test_free_finished_request_clears_waiting_since(self): """free_finished_request clears coordinator lifecycle markers.""" - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - ) + coord = OmniSchedulingCoordinator(stage_id=1) coord._waiting_since["r1"] = 0.0 coord._full_payload_input_received.add("r1") coord.finished_requests.add("r1") @@ -693,174 +479,6 @@ class TestTimeoutDetection(unittest.TestCase): self.assertNotIn("r1", coord._full_payload_input_received) self.assertNotIn("r1", coord.finished_requests) - def test_timeout_from_running_queue_full_lifecycle(self): - """End-to-end: request from running → WAITING_FOR_CHUNK → restore → - timeout → removed from running list. - - This is the critical regression case: WAITING_FOR_CHUNK requests - that originated from self.running are placed back into self.running - by restore_queues(), but their status remains WAITING_FOR_CHUNK. - The scheduler must remove from BOTH queues unconditionally. - """ - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - async_chunk=True, - ) - - # 1) Request starts in running queue with WAITING status - req = _make_request("r1", status=RequestStatus.WAITING) - running = [req] - waiting = MockQueue() - - # 2) process_pending_chunks: moves to WAITING_FOR_CHUNK - coord.process_pending_chunks( - waiting, - running, - chunk_ready_req_ids=set(), - chunk_finished_req_ids=set(), - ) - self.assertEqual(req.status, RequestStatus.WAITING_FOR_CHUNK) - self.assertIn("r1", coord._waiting_since) - self.assertEqual(len(coord._waiting_for_chunk_running), 1) - - # 3) restore_queues: back to running (status stays WAITING_FOR_CHUNK) - coord.restore_queues(waiting, running) - self.assertIn(req, running) - self.assertEqual(len(coord._waiting_for_chunk_running), 0) - self.assertEqual(req.status, RequestStatus.WAITING_FOR_CHUNK) - - # 4) Force timeout by setting _waiting_since to epoch - coord._waiting_since["r1"] = 0.0 - - timed_out_ids = coord.collect_timed_out_request_ids(timeout_s=1.0) - self.assertEqual(timed_out_ids, {"r1"}) - - # 5) Scheduler removes from both queues (simulating the scheduler path) - timed_out_id_set = {id(req)} - running = [r for r in running if id(r) not in timed_out_id_set] - waiting.remove_requests([req]) - - self.assertNotIn(req, running) - self.assertEqual(len(waiting), 0) - - def test_timeout_from_waiting_queue_full_lifecycle(self): - """End-to-end: request from waiting → WAITING_FOR_CHUNK → restore → - timeout → removed from waiting queue.""" - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=10, - stage_id=1, - async_chunk=True, - ) - - req = _make_request("r1", status=RequestStatus.WAITING) - waiting = MockQueue([req]) - running: list = [] - - coord.process_pending_chunks( - waiting, - running, - chunk_ready_req_ids=set(), - chunk_finished_req_ids=set(), - ) - self.assertEqual(len(coord._waiting_for_chunk_waiting), 1) - - coord.restore_queues(waiting, running) - self.assertIn(req, waiting) - - coord._waiting_since["r1"] = 0.0 - timed_out_ids = coord.collect_timed_out_request_ids(timeout_s=1.0) - self.assertEqual(timed_out_ids, {"r1"}) - - waiting.remove_requests([req]) - self.assertEqual(len(waiting), 0) - - -class TestOverflowPreemption(unittest.TestCase): - """Tests for P1-1: overflow requests must get WAITING status. - - Overflow happens when multiple WAITING_FOR_CHUNK requests in - ``_waiting_for_chunk_running`` receive their chunk in the same cycle. - ``_process_chunk_queue`` restores them to RUNNING (``continue`` - path) while RUNNING requests without chunks are moved out. If the - net result exceeds ``scheduler_max_num_seqs``, the tail is pushed - to ``waiting_queue`` and must have status == WAITING. - """ - - def test_overflow_sets_waiting_status(self): - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=1, - stage_id=1, - async_chunk=True, - ) - - # r1 is currently RUNNING in the queue. - # r2, r3 were previously moved to _waiting_for_chunk_running. - r1 = _make_request("r1", status=RequestStatus.RUNNING) - r2 = _make_request("r2", status=RequestStatus.WAITING_FOR_CHUNK) - r3 = _make_request("r3", status=RequestStatus.WAITING_FOR_CHUNK) - - running = [r1] - waiting = MockQueue([]) - coord._waiting_for_chunk_running.extend([r2, r3]) - - # restore_queues puts r2, r3 back into running - coord.restore_queues(waiting, running) - self.assertEqual(len(running), 3) - - # Now process_pending_chunks with r2, r3 chunks ready: - # _process_chunk_queue will: - # r1 (RUNNING) → no chunk → move to _waiting_for_chunk_running - # r2 (WAITING_FOR_CHUNK, chunk ready) → set RUNNING, stay in running - # r3 (WAITING_FOR_CHUNK, chunk ready) → set RUNNING, stay in running - # running = [r2, r3], len=2 > max=1 → overflow - coord.process_pending_chunks( - waiting, - running, - chunk_ready_req_ids={"r2", "r3"}, - chunk_finished_req_ids=set(), - ) - - self.assertEqual(len(running), 1) - self.assertEqual(len(waiting), 1) - overflow_req = list(waiting)[0] - self.assertEqual( - overflow_req.status, - RequestStatus.WAITING, - f"Overflowed request should have WAITING status, got {overflow_req.status}", - ) - - def test_overflow_does_not_strand_request(self): - """Without the fix, the overflowed request would keep its - RUNNING status in the waiting queue and never be re-scheduled.""" - coord = OmniSchedulingCoordinator( - scheduler_max_num_seqs=1, - stage_id=1, - async_chunk=True, - ) - - r1 = _make_request("r1", status=RequestStatus.WAITING_FOR_CHUNK) - r2 = _make_request("r2", status=RequestStatus.WAITING_FOR_CHUNK) - coord._waiting_for_chunk_running.extend([r1, r2]) - - running: list = [] - waiting = MockQueue([]) - - coord.restore_queues(waiting, running) - self.assertEqual(len(running), 2) - - coord.process_pending_chunks( - waiting, - running, - chunk_ready_req_ids={"r1", "r2"}, - chunk_finished_req_ids=set(), - ) - - self.assertEqual(len(running), 1) - self.assertEqual(len(waiting), 1) - for req in waiting: - self.assertNotEqual(req.status, RequestStatus.RUNNING, "Overflowed request must not keep RUNNING status") - if __name__ == "__main__": unittest.main() diff --git a/tests/core/sched/test_sched_package_imports.py b/tests/core/sched/test_sched_package_imports.py new file mode 100644 index 000000000..bc4ec9d44 --- /dev/null +++ b/tests/core/sched/test_sched_package_imports.py @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_importing_scheduler_output_does_not_eagerly_import_scheduler_implementations() -> None: + package_init = Path(__file__).parents[3] / "vllm_omni/core/sched/__init__.py" + module = ast.parse(package_init.read_text()) + eager_modules = { + node.module + for node in ast.walk(module) + if isinstance(node, ast.ImportFrom) + and node.level == 1 + and node.module in {"omni_ar_scheduler", "omni_generation_scheduler"} + } + + assert eager_modules == set() + assert any(isinstance(node, ast.FunctionDef) and node.name == "__getattr__" for node in module.body) diff --git a/tests/core/test_prefix_cache.py b/tests/core/test_prefix_cache.py index 2111556f2..4f91a95b7 100644 --- a/tests/core/test_prefix_cache.py +++ b/tests/core/test_prefix_cache.py @@ -1,8 +1,14 @@ +import math +from typing import NamedTuple + import pytest import torch from vllm_omni.core.prefix_cache import OmniTensorPrefixCache +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + DEFAULT_SEQ_LEN = 15 NUM_BLOCKS = 10 BLOCK_SIZE = 4 @@ -314,6 +320,103 @@ def test_deferred_multimodal_cache_write_commits_on_completion(): assert torch.equal(rows[8:16], expected) +def test_discard_deferred_mm_outputs_drops_pending_chunks(): + """discard_deferred_mm_outputs is the abort path: a request that + leaves without a cache commit must not leak its staged chunks into + the cache if commit_deferred_mm_outputs is later called for it.""" + cache = get_omni_pcache() + mm_key = "codes.audio" + block_table = torch.tensor( + [ + [2, 3, 4, 5], + [6, 7, 8, 9], + ], + dtype=torch.long, + ) + input_batch = MockInputBatch( + num_computed_tokens_cpu=torch.tensor([4, 0], dtype=torch.long), + block_table=block_table, + ) + + cache.update_omni_tensor_prefix_cache( + hidden_states=None, + multimodal_outputs={mm_key: torch.arange(8, dtype=torch.long).reshape(4, 2)}, + num_tokens_unpadded=4, + slot_mapping=torch.arange(8, 12), + skip_mm_cache_keys={mm_key}, + ) + + chunk = torch.arange(8, dtype=torch.long).reshape(4, 2) + cache.stage_deferred_mm_outputs( + query_start_loc=torch.tensor([0, 4], dtype=torch.long), + input_batch=input_batch, + multimodal_outputs={mm_key: chunk}, + num_scheduled_tokens={"req1": 4, "req2": 0}, + deferred_mm_cache_keys={mm_key}, + ) + assert "req1" in cache._deferred_mm_outputs + + cache.discard_deferred_mm_outputs("req1") + assert "req1" not in cache._deferred_mm_outputs + + # A later commit call for the now-discarded req_id must be a no-op, + # not a resurrection of the dropped chunk. + cache.commit_deferred_mm_outputs({"req1"}, input_batch) + rows = cache.mm_outputs_cache[mm_key].view(-1, 2) + assert torch.all(rows[8:12] == 0) + + +def test_deferred_mm_outputs_request_id_reused_after_discard(): + """A request ID reused after discard_deferred_mm_outputs must start + clean: the new occupant's committed data must not be mixed with the + previous (discarded) occupant's staged chunks.""" + cache = get_omni_pcache() + mm_key = "codes.audio" + block_table = torch.tensor( + [ + [2, 3, 4, 5], + [6, 7, 8, 9], + ], + dtype=torch.long, + ) + input_batch = MockInputBatch( + num_computed_tokens_cpu=torch.tensor([4, 0], dtype=torch.long), + block_table=block_table, + ) + + cache.update_omni_tensor_prefix_cache( + hidden_states=None, + multimodal_outputs={mm_key: torch.arange(8, dtype=torch.long).reshape(4, 2)}, + num_tokens_unpadded=4, + slot_mapping=torch.arange(8, 12), + skip_mm_cache_keys={mm_key}, + ) + + stale_chunk = torch.full((4, 2), -1, dtype=torch.long) + cache.stage_deferred_mm_outputs( + query_start_loc=torch.tensor([0, 4], dtype=torch.long), + input_batch=input_batch, + multimodal_outputs={mm_key: stale_chunk}, + num_scheduled_tokens={"req1": 4, "req2": 0}, + deferred_mm_cache_keys={mm_key}, + ) + cache.discard_deferred_mm_outputs("req1") + + # "req1" is reused by a brand-new request with different data. + fresh_chunk = torch.arange(8, dtype=torch.long).reshape(4, 2) + cache.stage_deferred_mm_outputs( + query_start_loc=torch.tensor([0, 4], dtype=torch.long), + input_batch=input_batch, + multimodal_outputs={mm_key: fresh_chunk}, + num_scheduled_tokens={"req1": 4, "req2": 0}, + deferred_mm_cache_keys={mm_key}, + ) + cache.commit_deferred_mm_outputs({"req1"}, input_batch) + + rows = cache.mm_outputs_cache[mm_key].view(-1, 2) + assert torch.equal(rows[8:12], fresh_chunk) + + def test_deferred_multimodal_cache_can_be_merged_on_full_block_hit(): cache = get_omni_pcache() mm_key = "codes.audio" @@ -596,3 +699,219 @@ def test_get_merged_multimodal_outputs(feat_dims, num_tokens_padded, mocker): assert req2_merged_mm_outputs.shape == torch.Size([num_new_toks_req2, curr_feat_dim]) assert torch.all(req2_merged_mm_outputs == req2_new_mm_outputs) + + +@pytest.mark.parametrize( + "passthrough_shape", + [(5,), (5, 3)], + ids=["1d_token_metadata", "2d_feature_tensor"], +) +def test_get_merged_multimodal_outputs_slices_passthrough_tensor_per_request(passthrough_shape, mocker): + """A passthrough key that is itself a real tensor (not the string/list + passthrough already covered above) must be sliced per request through + to_payload_element's tensor branch, under the same mixed cache + hit/miss + uneven scheduled-length batch as test_get_merged_multimodal_outputs. + + Covers both a 1D token-aligned metadata tensor (e.g. ref_code_len, + codec_streaming -- shape (total_tokens,)) and a 2D per-token feature + tensor (shape (total_tokens, feat_dim)), since W2's batching could + easily special-case one and silently mishandle the other. + """ + feat_dims = {"foo": 100, "bar": 100} + cache = get_omni_pcache_with_mm_tensors(feat_dims, seq_len=DEFAULT_SEQ_LEN) + + orig_num_tokens_unpadded = 8 + slot_offset = 8 + orig_slot_mapping = torch.arange(slot_offset, slot_offset + orig_num_tokens_unpadded) + feature_dims = {key: val.shape[-1] for key, val in cache.mm_outputs_cache.items()} + orig_mm_outputs = { + key: torch.rand((orig_num_tokens_unpadded, feature_dims[key]), dtype=DTYPE) for key in cache.mm_cache_keys + } + + cache.update_omni_tensor_prefix_cache( + hidden_states=None, + multimodal_outputs=orig_mm_outputs, + num_tokens_unpadded=orig_num_tokens_unpadded, + slot_mapping=orig_slot_mapping, + num_tokens_padded=None, + ) + + num_new_toks_req1 = 3 + num_new_toks_req2 = 2 + cache.add_prefix_cached_new_req_id("req1") + + num_scheduled_tokens = { + "req1": num_new_toks_req1, + "req2": num_new_toks_req2, + } + + new_mm_outputs = {} + for mm_key in cache.mm_cache_keys: + new_mm_outputs[mm_key] = torch.rand( + (num_new_toks_req1 + num_new_toks_req2, feature_dims[mm_key]), + dtype=DTYPE, + ) + total_scheduled_tokens = num_new_toks_req1 + num_new_toks_req2 + assert passthrough_shape[0] == total_scheduled_tokens + passthrough_tensor = torch.arange(int(torch.prod(torch.tensor(passthrough_shape))), dtype=DTYPE).reshape( + passthrough_shape + ) + new_mm_outputs["passthrough_tensor"] = passthrough_tensor + + input_batch = MockInputBatch(num_computed_tokens_cpu=torch.Tensor([orig_num_tokens_unpadded, 0])) + + mocker.patch( + "vllm_omni.core.prefix_cache.OmniTensorPrefixCache._get_cached_block_ids", + new=fake_get_cached_block_ids, + ) + merged_mm_outputs = cache.get_merged_multimodal_states( + query_start_loc=[0, num_new_toks_req1], + input_batch=input_batch, + multimodal_outputs=new_mm_outputs, + num_scheduled_tokens=num_scheduled_tokens, + ) + + assert "passthrough_tensor" not in cache.mm_cache_keys + + passthrough_out = merged_mm_outputs["passthrough_tensor"] + # req1 (cache hit) gets tokens [0:3), req2 (cache miss) gets tokens [3:5) -- + # a passthrough tensor is sliced by scheduled position, not merged with + # any cached history (unlike the mm_cache_keys tensors above). + assert torch.equal(passthrough_out["req1"], passthrough_tensor[0:num_new_toks_req1]) + assert torch.equal(passthrough_out["req2"], passthrough_tensor[num_new_toks_req1:total_scheduled_tokens]) + + +class MergeHarness(NamedTuple): + """A primed cache plus the arguments get_merged_multimodal_states needs.""" + + cache: OmniTensorPrefixCache + input_batch: MockInputBatch + num_scheduled_tokens: dict[str, int] + query_start_loc: list[int] + total_scheduled_tokens: int + new_mm_outputs: dict[str, object] + + +def build_mixed_hit_miss_harness(mocker, num_new_toks_req1: int = 3, num_new_toks_req2: int = 2) -> MergeHarness: + """Two requests over one cache key: req1 hits 8 cached tokens, req2 misses, + and their scheduled lengths differ -- the same batch shape as + test_get_merged_multimodal_outputs, which is what makes a per-request + boundary error observable. Callers add their passthrough keys to + ``new_mm_outputs`` before calling get_merged_multimodal_states. + """ + cache = get_omni_pcache_with_mm_tensors({"foo": 100}, seq_len=DEFAULT_SEQ_LEN) + + orig_num_tokens_unpadded = 8 + feature_dims = {key: val.shape[-1] for key, val in cache.mm_outputs_cache.items()} + cache.update_omni_tensor_prefix_cache( + hidden_states=None, + multimodal_outputs={ + key: torch.rand((orig_num_tokens_unpadded, feature_dims[key]), dtype=DTYPE) for key in cache.mm_cache_keys + }, + num_tokens_unpadded=orig_num_tokens_unpadded, + slot_mapping=torch.arange(8, 8 + orig_num_tokens_unpadded), + num_tokens_padded=None, + ) + cache.add_prefix_cached_new_req_id("req1") + + total_scheduled_tokens = num_new_toks_req1 + num_new_toks_req2 + mocker.patch( + "vllm_omni.core.prefix_cache.OmniTensorPrefixCache._get_cached_block_ids", + new=fake_get_cached_block_ids, + ) + return MergeHarness( + cache=cache, + input_batch=MockInputBatch(num_computed_tokens_cpu=torch.Tensor([orig_num_tokens_unpadded, 0])), + num_scheduled_tokens={"req1": num_new_toks_req1, "req2": num_new_toks_req2}, + query_start_loc=[0, num_new_toks_req1], + total_scheduled_tokens=total_scheduled_tokens, + new_mm_outputs={ + key: torch.rand((total_scheduled_tokens, feature_dims[key]), dtype=DTYPE) for key in cache.mm_cache_keys + }, + ) + + +def test_get_merged_multimodal_outputs_passes_per_request_tensor_list_through(mocker): + """The passthrough shape qwen3-tts actually emits: a nested ``codes``/``meta`` + dict whose ``codes.ref`` holds one tensor per request + (qwen3_tts_talker.make_omni_output), carried through to_payload_element's + pass_lists_through branch beside 1D int metadata that IS sliced per request. + + Each request must receive the list *whole*. Per-request indexing happens + later, in the runner's _unwrap_lists; a W2 that split the list here would + make that second index select a different request's reference codes. The + existing list coverage passes a list of strings, which cannot catch a + tensor-specific regression, and no other test covers an int dtype. + """ + harness = build_mixed_hit_miss_harness(mocker) + + ref_codes = [torch.tensor([11, 12], dtype=torch.long), torch.tensor([21], dtype=torch.long)] + ref_code_len = torch.arange(harness.total_scheduled_tokens, dtype=torch.int32) + harness.new_mm_outputs["codes"] = {"ref": ref_codes} + harness.new_mm_outputs["meta"] = {"ref_code_len": ref_code_len} + + merged = harness.cache.get_merged_multimodal_states( + query_start_loc=harness.query_start_loc, + input_batch=harness.input_batch, + multimodal_outputs=harness.new_mm_outputs, + num_scheduled_tokens=harness.num_scheduled_tokens, + ) + + assert "codes" not in harness.cache.mm_cache_keys + for req_id in ("req1", "req2"): + req_refs = merged["codes"][req_id]["ref"] + assert len(req_refs) == len(ref_codes) + for got, want in zip(req_refs, ref_codes): + assert torch.equal(got, want) + assert got.dtype == torch.long + + # Per-request clones: the branch exists so one request's downstream mutation + # cannot reach another request's payload or the source list. + merged["codes"]["req1"]["ref"][0] += 100 + assert torch.equal(merged["codes"]["req2"]["ref"][0], torch.tensor([11, 12], dtype=torch.long)) + assert torch.equal(ref_codes[0], torch.tensor([11, 12], dtype=torch.long)) + + # Token-aligned 1D metadata is still split per request, unlike the list. + req1_len = harness.num_scheduled_tokens["req1"] + assert torch.equal(merged["meta"]["req1"]["ref_code_len"], ref_code_len[:req1_len]) + assert torch.equal(merged["meta"]["req2"]["ref_code_len"], ref_code_len[req1_len:]) + assert merged["meta"]["req1"]["ref_code_len"].dtype == torch.int32 + + +@pytest.mark.parametrize( + "passthrough_shape", + [(3, 4), (1, 4), (7,)], + ids=["shorter_2d", "single_row_2d", "longer_1d"], +) +def test_get_merged_multimodal_outputs_broadcasts_non_token_aligned_passthrough(passthrough_shape, mocker): + """A passthrough tensor whose first dimension is not the scheduled-token + count is request-invariant: to_payload_element hands every request the whole + tensor, cloned, rather than slicing it. + + W2 batching most plausibly breaks by assuming every passthrough tensor is + token-aligned; that assumption would slice these and give each request a + truncated view, or an empty one once start exceeds the first dimension. + """ + harness = build_mixed_hit_miss_harness(mocker) + assert passthrough_shape[0] != harness.total_scheduled_tokens + + passthrough = torch.arange(math.prod(passthrough_shape), dtype=DTYPE).reshape(passthrough_shape) + expected = passthrough.clone() + harness.new_mm_outputs["speaker_embedding"] = passthrough + + merged = harness.cache.get_merged_multimodal_states( + query_start_loc=harness.query_start_loc, + input_batch=harness.input_batch, + multimodal_outputs=harness.new_mm_outputs, + num_scheduled_tokens=harness.num_scheduled_tokens, + ) + + assert "speaker_embedding" not in harness.cache.mm_cache_keys + out = merged["speaker_embedding"] + for req_id in ("req1", "req2"): + assert out[req_id].shape == passthrough_shape + assert torch.equal(out[req_id], expected) + + out["req1"] += 100 + assert torch.equal(out["req2"], expected) + assert torch.equal(passthrough, expected) diff --git a/tests/core/test_prefix_cache_async_write.py b/tests/core/test_prefix_cache_async_write.py new file mode 100644 index 000000000..fa1980227 --- /dev/null +++ b/tests/core/test_prefix_cache_async_write.py @@ -0,0 +1,119 @@ +import pytest +import torch + +from vllm_omni.core.prefix_cache import OmniTensorPrefixCache + +# `cpu` is the lane selector, not a hardware claim: the only Buildkite steps that +# reach tests/ select `core_model and cpu`, and they run on GPU hardware (l4_1). +# Marking this `cuda` collects it nowhere. Real hardware need is the skipif below. +pytestmark = [ + pytest.mark.core_model, + pytest.mark.cpu, + pytest.mark.skipif(not torch.cuda.is_available(), reason="requires a CUDA device"), +] + +NUM_BLOCKS = 10 +BLOCK_SIZE = 4 +HIDDEN_SIZE = 2 +DTYPE = torch.float32 + + +def get_omni_pcache() -> OmniTensorPrefixCache: + """Build an OmniTensorPrefixCache, but don't init mm tensors.""" + return OmniTensorPrefixCache( + num_blocks=NUM_BLOCKS, + block_size=BLOCK_SIZE, + hidden_size=HIDDEN_SIZE, + hs_dtype=DTYPE, + ) + + +def test_drain_ready_async_writes_is_a_noop_before_the_event_is_ready(monkeypatch): + """drain_ready_async_writes() must not touch the cache while the + scheduled D2H's event has not completed, and must consume it exactly + once after it has. + + Real GPU timing (e.g. via torch.cuda._sleep to stall the copy stream) + was tried and found unreliable on this box: cross-stream wait_stream/ + wait_event did not reliably delay event.query() from reporting ready + immediately, likely a virtualization/driver quirk of this cloud GPU. + Forcing the event's query() result directly tests the branching logic + in drain_ready_async_writes/_consume_pending_write deterministically, + independent of real CUDA scheduling behavior. + """ + cache = get_omni_pcache() + mm_key = "codes.audio" + cache.maybe_init_missing_mm_cache_keys({mm_key: torch.zeros(4, HIDDEN_SIZE)}, seq_len=4) + + num_tokens = 4 + slot_mapping_gpu = torch.arange(8, 8 + num_tokens, dtype=torch.int64, device="cuda") + mm_gpu = { + mm_key: torch.arange(num_tokens * HIDDEN_SIZE, dtype=torch.float32, device="cuda").reshape( + num_tokens, HIDDEN_SIZE + ) + } + + cache.schedule_async_write( + hidden_states_gpu=None, + multimodal_outputs_gpu=mm_gpu, + slot_mapping_gpu=slot_mapping_gpu, + num_tokens_unpadded=num_tokens, + num_tokens_padded=num_tokens, + ) + + pending_event = cache._pending_write.event + rows = cache.mm_outputs_cache[mm_key].view(-1, HIDDEN_SIZE) + + monkeypatch.setattr(pending_event, "query", lambda: False) + assert cache.drain_ready_async_writes() == 0 + assert torch.all(rows[8 : 8 + num_tokens] == 0) + assert cache._pending_write is not None + + monkeypatch.setattr(pending_event, "query", lambda: True) + assert cache.drain_ready_async_writes() == 1 + assert torch.equal(rows[8 : 8 + num_tokens], mm_gpu[mm_key].cpu()) + assert cache._pending_write is None + + # Nothing pending anymore: a second drain is a no-op, not a re-scatter. + assert cache.drain_ready_async_writes() == 0 + + +def test_schedule_async_write_consumes_previous_pending_write_first(): + """Scheduling step N+1's write must first consume (scatter) step N's + pending write, even if the caller never explicitly called + drain_ready_async_writes() in between -- otherwise step N's data would + be silently dropped when overwritten by step N+1's pending write.""" + cache = get_omni_pcache() + mm_key = "codes.audio" + cache.maybe_init_missing_mm_cache_keys({mm_key: torch.zeros(4, HIDDEN_SIZE)}, seq_len=4) + + num_tokens = 4 + first_slots = torch.arange(8, 8 + num_tokens, dtype=torch.int64, device="cuda") + first_gpu = {mm_key: torch.full((num_tokens, HIDDEN_SIZE), 1.0, device="cuda")} + second_slots = torch.arange(12, 12 + num_tokens, dtype=torch.int64, device="cuda") + second_gpu = {mm_key: torch.full((num_tokens, HIDDEN_SIZE), 2.0, device="cuda")} + + cache.schedule_async_write( + hidden_states_gpu=None, + multimodal_outputs_gpu=first_gpu, + slot_mapping_gpu=first_slots, + num_tokens_unpadded=num_tokens, + num_tokens_padded=num_tokens, + ) + torch.accelerator.synchronize() + + # This call's internal _consume_pending_write() must flush the FIRST + # write before the second one is scheduled; the caller never drained. + cache.schedule_async_write( + hidden_states_gpu=None, + multimodal_outputs_gpu=second_gpu, + slot_mapping_gpu=second_slots, + num_tokens_unpadded=num_tokens, + num_tokens_padded=num_tokens, + ) + torch.accelerator.synchronize() + cache.drain_ready_async_writes() + + rows = cache.mm_outputs_cache[mm_key].view(-1, HIDDEN_SIZE) + assert torch.all(rows[8:12] == 1.0) + assert torch.all(rows[12:16] == 2.0) diff --git a/tests/dfx/conftest.py b/tests/dfx/conftest.py index a22f760b2..f81dee11c 100644 --- a/tests/dfx/conftest.py +++ b/tests/dfx/conftest.py @@ -3,19 +3,160 @@ import os import re import subprocess import threading -import time from datetime import datetime from pathlib import Path from typing import Any import pytest -from tests.dfx.reliability.helpers import list_remote_process_pids_by_pattern, post_chat_completions_raw +from tests.helpers.mark import get_hardware_mark_list, hardware_marks from tests.helpers.runtime import OmniServerParams from tests.helpers.stage_config import modify_stage_config from vllm_omni.platforms import current_omni_platform +def _named_pytest_marks(names: list[str]) -> list[pytest.MarkDecorator]: + marks: list[pytest.MarkDecorator] = [] + for name in names: + name = name.strip() + if not name: + raise ValueError("mark name must be a non-empty string") + marks.append(getattr(pytest.mark, name)) + return marks + + +def _hardware_marks_from_dict(hw: Any) -> list[pytest.MarkDecorator]: + if not isinstance(hw, dict): + raise ValueError(f"mark.hardware_marks must be a dict, got {type(hw).__name__}") + res = hw.get("res") + if not isinstance(res, dict): + raise ValueError(f"mark.hardware_marks.res must be a dict, got {type(res).__name__}") + num_cards = hw.get("num_cards", 1) + return list(hardware_marks(res=res, num_cards=num_cards)) + + +def resolve_pytest_marks(mark_field: Any) -> list[pytest.MarkDecorator]: + """Convert a JSON ``mark`` field into pytest mark decorators. + + Supported form (per test-case object in perf/stability JSON):: + + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 2}}, + "full_model", + "diffusion" + ] + + Exactly one ``hardware_marks`` object is required when ``mark`` is present. + ``hardware_marks`` delegates to :func:`tests.helpers.mark.hardware_marks` + (same shape as ``@hardware_test``). Additional array entries are registered + pytest marker names (strings). + """ + if mark_field is None: + return [] + + if isinstance(mark_field, list): + marks: list[pytest.MarkDecorator] = [] + hw_seen = False + for item in mark_field: + if isinstance(item, dict) and "hardware_marks" in item: + if hw_seen: + raise ValueError("mark array must contain at most one hardware_marks object") + hw_seen = True + marks.extend(_hardware_marks_from_dict(item["hardware_marks"])) + unknown_keys = set(item) - {"hardware_marks"} + if unknown_keys: + raise ValueError( + f"mark hardware_marks object only allows hardware_marks; unknown keys: {sorted(unknown_keys)}" + ) + elif isinstance(item, str): + item = item.strip() + if not item: + raise ValueError("mark name must be a non-empty string") + marks.extend(_named_pytest_marks([item])) + else: + raise ValueError( + f"mark array entries must be hardware_marks objects or marker name strings; got {type(item).__name__}" + ) + if not hw_seen: + raise ValueError("mark array must contain a hardware_marks object") + return marks + + raise ValueError(f"mark must be a list; got {type(mark_field).__name__}") + + +def _mark_names(mark_field: Any) -> set[str]: + if isinstance(mark_field, list): + return {str(item) for item in mark_field if isinstance(item, str)} + return set() + + +def is_diffusion_perf_config(cfg: dict[str, Any]) -> bool: + """True for perf JSON cases intended for ``run_diffusion_benchmark.py``.""" + if cfg.get("server_type") is not None: + return True + return "diffusion" in _mark_names(cfg.get("mark")) + + +def _marks_by_test_name(configs: list[dict[str, Any]]) -> dict[str, list[pytest.MarkDecorator]]: + return {str(cfg["test_name"]): resolve_pytest_marks(cfg.get("mark")) for cfg in configs} + + +def create_unique_server_pytest_params( + configs: list[dict[str, Any]], + stage_configs_dir: Path, +) -> list[Any]: + """Like :func:`create_unique_server_params`, but wrap each row in ``pytest.param`` with JSON marks.""" + marks_by_name = _marks_by_test_name(configs) + return [ + pytest.param( + row, + marks=marks_by_name.get(row[0], []), + id=row[0], + ) + for row in create_unique_server_params(configs, stage_configs_dir) + ] + + +def create_paired_benchmark_pytest_params( + server_entries: list[tuple[Any, str]], + params_by_test_name: dict[str, list[Any]], + marks_by_name: dict[str, list[pytest.MarkDecorator]], +) -> list[Any]: + """One ``pytest.param`` per ``(server entry, benchmark index)`` pair. + + Pass ``server_entry`` and ``(test_name, idx)`` as separate ``pytest.param`` + arguments for ``@pytest.mark.parametrize("server_fixture,benchmark_params", ...)``. + """ + pairs: list[Any] = [] + for server_entry, test_name in server_entries: + params_list = params_by_test_name.get(test_name, []) + id_suffixes = _unique_benchmark_param_id_suffixes(params_list) + for idx, id_suffix in enumerate(id_suffixes): + pairs.append( + pytest.param( + server_entry, + (test_name, idx), + marks=marks_by_name.get(test_name, []), + id=f"{test_name}-{id_suffix}", + ) + ) + return pairs + + +def create_paired_omni_benchmark_pytest_params( + configs: list[dict[str, Any]], + stage_configs_dir: Path, +) -> list[Any]: + """Paired params for ``run_benchmark.py`` (omni/tts).""" + mapping = create_test_parameter_mapping(configs) + marks_by_name = _marks_by_test_name(configs) + server_entries = [(row, row[0]) for row in create_unique_server_params(configs, stage_configs_dir)] + params_by_test_name = { + test_name: get_benchmark_params_for_server(test_name, mapping) for _, test_name in server_entries + } + return create_paired_benchmark_pytest_params(server_entries, params_by_test_name, marks_by_name) + + def load_configs(config_path: str) -> list[dict[str, Any]]: try: abs_path = Path(config_path).resolve() @@ -32,6 +173,29 @@ def load_configs(config_path: str) -> list[dict[str, Any]]: raise RuntimeError(f"Failed to load configuration file: {str(e)}") +def load_benchmark_configs( + config_path: str | None = None, + *, + config_dir: Path | None = None, +) -> list[dict[str, Any]]: + """Load one benchmark JSON file, or merge all ``*.json`` under *config_dir*. + + When *config_path* is omitted, every ``*.json`` in *config_dir* is loaded and + concatenated. Pytest ``-m`` expressions then select cases via each entry's + ``mark`` field (e.g. ``-m tts`` after bulk load). + """ + if config_path is not None: + return load_configs(config_path) + if config_dir is None: + raise ValueError("load_benchmark_configs requires config_path or config_dir") + configs: list[dict[str, Any]] = [] + for path in sorted(config_dir.glob("*.json")): + configs.extend(load_configs(str(path))) + if not configs: + raise ValueError(f"No benchmark JSON files found under {config_dir}") + return configs + + def modify_stage(default_path: str, updates: dict[str, Any] | None, deletes: dict[str, Any] | None) -> str: kwargs: dict[str, Any] = {} if updates is not None: @@ -165,10 +329,6 @@ def supports_video_generation(model_name: str) -> bool: return any(key in lower for key in ("wan", "video", "i2v", "t2v")) -def supports_chat_generation(model_name: str) -> bool: - return not supports_video_generation(model_name) - - def parse_stage_devices(stage_config_path: str) -> str: text = Path(stage_config_path).read_text(encoding="utf-8") raw_devices: list[str] = re.findall(r"^\s*devices:\s*\"?([0-9,\s]+)\"?\s*$", text, flags=re.MULTILINE) @@ -197,48 +357,6 @@ def assert_fault_exception(exc: Exception, error_keywords: tuple[str, ...]) -> N assert any(key in text for key in error_keywords), f"unexpected error under fault injection: {exc}" -def wait_chat_request_ready(host: str, port: int, model: str, timeout_sec: int = 180) -> None: - """Poll a minimal chat request until success.""" - deadline = time.time() + timeout_sec - payload = json.dumps( - { - "model": model, - "messages": [{"role": "user", "content": "Say hello in one short sentence."}], - "stream": False, - "modalities": ["text"], - } - ) - last_error: str | None = None - while time.time() < deadline: - try: - status, body = post_chat_completions_raw(host, port, payload) - if status == 200: - return - last_error = f"http={status} body={body[:200]!r}" - except Exception as exc: # noqa: BLE001 - last_error = str(exc) - time.sleep(2) - raise TimeoutError(f"runtime-teardown warmup request did not succeed within {timeout_sec}s: {last_error}") - - -def assert_no_extra_worker_processes( - baseline_pids: set[int], - worker_pattern: str, - timeout_sec: int = 60, -) -> None: - """Ensure no extra worker PIDs remain after teardown.""" - deadline = time.time() + timeout_sec - while time.time() < deadline: - current = set(list_remote_process_pids_by_pattern(worker_pattern)) - extra = current - baseline_pids - if not extra: - return - time.sleep(2) - current = set(list_remote_process_pids_by_pattern(worker_pattern)) - extra = sorted(current - baseline_pids) - assert not extra, f"orphan worker processes remain after container teardown: {extra}" - - def create_test_parameter_mapping(configs: list[dict[str, Any]]) -> dict[str, dict]: mapping = {} for config in configs: @@ -293,28 +411,167 @@ def _safe_filename_token(value: Any | None, *, default: str = "na") -> str: return s if s else default +def _benchmark_param_id_suffix(param: dict[str, Any], *, idx: int) -> str: + """Derive a readable pytest id suffix from one ``benchmark_params`` entry.""" + name = param.get("name") + if isinstance(name, str) and name.strip(): + return _safe_filename_token(name.strip()) + + parts: list[str] = [] + for key in ("task", "eval_phase", "dataset_name", "dataset"): + value = param.get(key) + if value is None or value == "": + continue + token = _safe_filename_token(str(value)) + if token != "na": + parts.append(token) + if parts: + return "_".join(parts) + + return f"case{idx}" + + +def _unique_benchmark_param_id_suffixes(params_list: list[dict[str, Any]]) -> list[str]: + """Return unique pytest id suffixes for a server's benchmark param list.""" + raw = [_benchmark_param_id_suffix(param, idx=idx) for idx, param in enumerate(params_list)] + seen: dict[str, int] = {} + unique: list[str] = [] + for suffix in raw: + count = seen.get(suffix, 0) + seen[suffix] = count + 1 + if count == 0: + unique.append(suffix) + else: + unique.append(f"{suffix}_{count}") + return unique + + +# Device marketing-name tokens for ``get_runtime_resource_label`` only. +# Longer / more specific tokens must appear before shorter substrings +# (e.g. ``H200`` before ``H20``, ``910B4`` before ``910``). +_RUNTIME_DEVICE_ALIASES: tuple[str, ...] = ( + "H100", + "H800", + "H200", + "H20", + "B200", + "GB200", + "L40S", + "L40", + "L4", + "A100", + "A800", + "A10G", + "A10", + "A30", + "MI325", + "MI300", + "MI250", + "B60", + "S5000", + "910B4", + "910B", + "910", + "310P", + "A2", + "A3", +) +_RUNTIME_RESOURCE_LABEL: str | None = None + + +def _normalize_runtime_device_label(raw: str) -> str: + """Map a platform device name to a short filename-safe resource token.""" + if not raw or not str(raw).strip(): + return "na" + upper = str(raw).upper() + for token in _RUNTIME_DEVICE_ALIASES: + if token.upper() in upper: + return _safe_filename_token(token) + compact = re.sub(r"[^a-zA-Z0-9]+", "", str(raw)) + for prefix in ("NVIDIA", "AMD", "ASCEND", "HUAWEI"): + if compact.upper().startswith(prefix): + compact = compact[len(prefix) :] + break + return _safe_filename_token(compact[:48]) if compact else "na" + + +def _read_runtime_device_name(*, device_id: int = 0) -> str | None: + """Device name from the active Omni platform.""" + if current_omni_platform.device_count() <= device_id: + return None + get_name = getattr(current_omni_platform, "get_device_name", None) + if not callable(get_name): + return None + raw = get_name(device_id) + if isinstance(raw, str) and raw.strip(): + return raw.strip() + return None + + +def get_runtime_resource_label(*, device_id: int = 0, refresh: bool = False) -> str: + """Return a filename-safe hardware label detected on the running machine.""" + global _RUNTIME_RESOURCE_LABEL + if not refresh and _RUNTIME_RESOURCE_LABEL is not None: + return _RUNTIME_RESOURCE_LABEL + raw = _read_runtime_device_name(device_id=device_id) + label = _normalize_runtime_device_label(raw) if raw else "na" + if not refresh: + _RUNTIME_RESOURCE_LABEL = label + return label + + +_FILENAME_OMIT_RESOURCE_LABELS = frozenset({"H100"}) + + +def hardware_json_value(resource_label: str | None) -> str: + """Hardware token stored in perf result JSON (empty when unknown).""" + token = _safe_filename_token(resource_label) + return "" if token == "na" else token + + +def resource_label_for_filename(resource_label: str | None) -> str: + """Hardware token embedded in result filenames (H100 omitted on default CI pool).""" + token = _safe_filename_token(resource_label) + if token in _FILENAME_OMIT_RESOURCE_LABELS: + return "" + return token + + +def is_hardware_nested_baseline(baseline: dict[str, Any]) -> bool: + """True for ``{"H100": {"metric": ...}, "A3": {...}}`` baselines. + + Detection is structural + hardware-label allowlist (not metric-name hints): + + - every top-level value is a non-empty dict (per-hardware metric map); + - every top-level key is in :func:`tests.helpers.mark.get_hardware_mark_list` + (``[hardware-resource]`` markers from ``pyproject.toml``). + + Metric names under each hardware bucket are unconstrained. + Runtime device aliases (:data:`_RUNTIME_DEVICE_ALIASES`) are unrelated. + """ + if not baseline: + return False + if not all(isinstance(value, dict) and bool(value) for value in baseline.values()): + return False + allowed = {label.upper() for label in get_hardware_mark_list()} + return all((token := str(key).strip()) and token.upper() in allowed for key in baseline.keys()) + + def resolve_baseline_value( baseline_raw: Any, *, sweep_index: int | None, - max_concurrency: Any = None, - request_rate: Any = None, ) -> Any: - """Pick the baseline threshold for this sweep step.""" + """Pick the baseline threshold for one metric at this sweep step. + + Accepts a sweep-aligned list/tuple (indexed by ``sweep_index``) or a scalar. + """ if baseline_raw is None: - return 100000 + return None if isinstance(baseline_raw, dict): - if max_concurrency is not None: - for key in (max_concurrency, str(max_concurrency)): - if key in baseline_raw: - return baseline_raw[key] - if request_rate is not None: - for key in (request_rate, str(request_rate)): - if key in baseline_raw: - return baseline_raw[key] - raise KeyError( - f"baseline dict has no key for max_concurrency={max_concurrency!r} " - f"or request_rate={request_rate!r}; keys={list(baseline_raw.keys())!r}" + raise TypeError( + "per-metric baseline dict keyed by concurrency/request-rate is not supported; " + "use a sweep-aligned list or a scalar under each hardware bucket" ) if isinstance(baseline_raw, (list, tuple)): if sweep_index is None: @@ -325,22 +582,62 @@ def resolve_baseline_value( return baseline_raw -def _baseline_thresholds_for_step( - baseline_data: dict[str, Any], +def resolve_baseline_for_sweep( + baseline_config: dict[str, Any] | None, *, sweep_index: int | None = None, - max_concurrency: Any = None, - request_rate: Any = None, ) -> dict[str, Any]: - """Resolve baseline config to one threshold per metric for this iteration.""" - return { - metric_name: resolve_baseline_value( - baseline_raw, - sweep_index=sweep_index, - max_concurrency=max_concurrency, - request_rate=request_rate, + """Resolve sweep-dependent baselines while keeping every hardware bucket. + + Expects hardware-nested input only:: + + {"H100": {"throughput_qps": [a, b, c]}, "A3": {"throughput_qps": [x, y, z]}} + + with ``sweep_index=2`` (e.g. concurrency 32) becomes:: + + {"H100": {"throughput_qps": c}, "A3": {"throughput_qps": z}} + + Per-metric values may be a sweep-aligned list or a scalar. + """ + if not baseline_config: + return {} + if not is_hardware_nested_baseline(baseline_config): + allowed = sorted(get_hardware_mark_list()) + unknown = [ + str(key).strip() + for key in baseline_config.keys() + if str(key).strip().upper() not in {label.upper() for label in allowed} + ] + nested_shape = all(isinstance(value, dict) and bool(value) for value in baseline_config.values()) + if nested_shape and unknown: + raise ValueError( + "baseline top-level keys must be [hardware-resource] pytest markers from " + "pyproject.toml (tool.pytest.ini_options.markers). " + f"Unknown hardware label(s): {unknown!r}. " + f"Known markers: {allowed!r}. " + "Add a marker such as '\"A100: [hardware-resource] Tests that require A100 GPU\"' " + "to pyproject.toml, then retry." + ) + raise ValueError( + "baseline must be hardware-nested with [hardware-resource] marker names as " + "top-level keys, e.g. " + '{"H100": {"throughput_qps": [...]}, "A3": {"custom_metric": 1.0}}. ' + f"got top-level keys={list(baseline_config.keys())!r}; " + f"known markers={allowed!r}. " + "If you need a new hardware bucket, add it to pyproject.toml markers with " + "the [hardware-resource] tag." ) - for metric_name, baseline_raw in baseline_data.items() + + return { + hardware: { + metric_name: resolve_baseline_value( + baseline_raw, + sweep_index=sweep_index, + ) + for metric_name, baseline_raw in metrics.items() + } + for hardware, metrics in baseline_config.items() + if isinstance(metrics, dict) } @@ -353,21 +650,26 @@ def run_benchmark( *, baseline_config: dict[str, Any] | None = None, sweep_index: int | None = None, - request_rate: Any | None = None, - max_concurrency: Any | None = None, random_input_len: Any | None = None, random_output_len: Any | None = None, + resource_label: str | None = None, ) -> dict[str, Any]: """Run one ``vllm bench serve --omni`` iteration and return parsed metrics. - After ``vllm bench`` writes the JSON, ``result["baseline"]`` holds the resolved per-metric thresholds - (when ``baseline_config`` is provided). If the benchmark exits without writing a result file, - ``result_omni_template.json`` is used as a fallback. + After ``vllm bench`` writes the JSON, ``result["baseline"]`` stores the + sweep-resolved baseline (when ``baseline_config`` is provided). Hardware-nested + maps keep every hardware bucket, but each metric is reduced to the value for + this concurrency / request-rate step. If the benchmark exits without writing a + result file, ``result_omni_template.json`` is used as a fallback. """ current_dt = datetime.now().strftime("%Y%m%d-%H%M%S") ri = _safe_filename_token(random_input_len) ro = _safe_filename_token(random_output_len) - result_filename = f"result_{test_name}_{dataset_name}_{flow}_{num_prompt}_in{ri}_out{ro}_{current_dt}.json" + hw = resource_label_for_filename(resource_label) + if hw: + result_filename = f"result_{test_name}_{hw}_{dataset_name}_{flow}_{num_prompt}_in{ri}_out{ro}_{current_dt}.json" + else: + result_filename = f"result_{test_name}_{dataset_name}_{flow}_{num_prompt}_in{ri}_out{ro}_{current_dt}.json" if "--result-filename" in args: print(f"The result file will be overwritten by {result_filename}") command = ( @@ -425,11 +727,10 @@ def run_benchmark( result = json.load(f) if baseline_config: - result["baseline"] = _baseline_thresholds_for_step( + # Keep every hardware bucket; resolve list metrics to this sweep step. + result["baseline"] = resolve_baseline_for_sweep( baseline_config, sweep_index=sweep_index, - request_rate=request_rate, - max_concurrency=max_concurrency, ) else: result["baseline"] = {} @@ -437,6 +738,7 @@ def run_benchmark( result["random_input_len"] = random_input_len if random_output_len is not None: result["random_output_len"] = random_output_len + result["Hardware"] = hardware_json_value(resource_label) with open(result_path, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) return result @@ -450,12 +752,3 @@ 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)." - ), - ) diff --git a/tests/dfx/perf/scripts/diffusion_result_template.json b/tests/dfx/perf/scripts/diffusion_result_template.json index ba16a993d..abb41c48b 100644 --- a/tests/dfx/perf/scripts/diffusion_result_template.json +++ b/tests/dfx/perf/scripts/diffusion_result_template.json @@ -21,10 +21,12 @@ "num-input-images": 0, "enable-negative-prompt": false, "baseline": { - "throughput_qps": 0, - "latency_mean": 0, - "peak_memory_mb_max": 0, - "peak_memory_mb_mean": 0 + "H100": { + "throughput_qps": 0, + "latency_mean": 0, + "peak_memory_mb_max": 0, + "peak_memory_mb_mean": 0 + } } }, "result": { diff --git a/tests/dfx/perf/scripts/result_omni_template.json b/tests/dfx/perf/scripts/result_omni_template.json index 1d6132140..ca94be0f7 100644 --- a/tests/dfx/perf/scripts/result_omni_template.json +++ b/tests/dfx/perf/scripts/result_omni_template.json @@ -46,9 +46,11 @@ "median_audio_duration_s": 0, "p99_audio_duration_s": 0, "baseline": { - "mean_ttft_ms": 0, - "mean_audio_ttfp_ms": 0, - "mean_audio_rtf": 0 + "H100": { + "mean_ttft_ms": 0, + "mean_audio_ttfp_ms": 0, + "mean_audio_rtf": 0 + } }, "random_input_len": 0, "random_output_len": 0 diff --git a/tests/dfx/perf/scripts/run_benchmark.py b/tests/dfx/perf/scripts/run_benchmark.py index 13329a209..4ab7959d2 100644 --- a/tests/dfx/perf/scripts/run_benchmark.py +++ b/tests/dfx/perf/scripts/run_benchmark.py @@ -1,27 +1,27 @@ import json import os import threading +from collections.abc import Callable +from contextlib import AbstractContextManager, ExitStack, contextmanager from pathlib import Path from typing import Any import pytest from tests.dfx.conftest import ( - create_benchmark_indices, + create_paired_omni_benchmark_pytest_params, create_test_parameter_mapping, - create_unique_server_params, get_benchmark_params_for_server, - load_configs, - resolve_baseline_value, + get_runtime_resource_label, + is_diffusion_perf_config, + load_benchmark_configs, run_benchmark, ) from tests.helpers.runtime import OmniServer -pytestmark = [pytest.mark.full_model] - -# Compare metrics to each test JSON ``baseline`` block only when pytest is run with ``--assert-baseline`` -# (registered in ``tests/dfx/conftest.py``; default: off). ``run_benchmark`` and ``_resolve_baseline_value`` are -# defined in the same module. +# Optional JSON field ``mark`` is applied as pytest marks via +# ``create_paired_omni_benchmark_pytest_params`` (e.g. ``"mark": [{"hardware_marks": +# {"res": {"cuda": "H100"}, "num_cards": 2}}, "full_model", "omni"]``). os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" @@ -40,67 +40,104 @@ def _get_config_file_from_argv() -> str | None: _PERF_TESTS_DIR = Path(__file__).resolve().parent.parent / "tests" -_DEFAULT_CONFIG_FILE = str(_PERF_TESTS_DIR / "test_qwen_omni.json") CONFIG_FILE_PATH = _get_config_file_from_argv() if CONFIG_FILE_PATH is None: + _all_configs = load_benchmark_configs(config_dir=_PERF_TESTS_DIR) + BENCHMARK_CONFIGS = [cfg for cfg in _all_configs if not is_diffusion_perf_config(cfg)] print( - "No --test-config-file in argv, using default: tests/dfx/perf/tests/test_qwen_omni.json " - "(override with e.g. --test-config-file tests/dfx/perf/tests/test_tts.json)" + f"No --test-config-file: loaded {len(BENCHMARK_CONFIGS)} omni/tts case(s) from " + f"{_PERF_TESTS_DIR}/*.json (skipped {len(_all_configs) - len(BENCHMARK_CONFIGS)} diffusion; " + f"use -m to filter, e.g. -m tts)" ) - CONFIG_FILE_PATH = _DEFAULT_CONFIG_FILE - -BENCHMARK_CONFIGS = load_configs(CONFIG_FILE_PATH) - +else: + BENCHMARK_CONFIGS = load_benchmark_configs(CONFIG_FILE_PATH) DEPLOY_CONFIGS_DIR = Path(__file__).parent.parent / "deploy" -test_params = create_unique_server_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) +paired_benchmark_params = create_paired_omni_benchmark_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) _omni_server_lock = threading.Lock() +class _SingleActiveContext: + """Reuse one active context while its configuration key is unchanged.""" + + def __init__(self) -> None: + self._key: Any = None + self._stack: ExitStack | None = None + self._value: Any = None + + def acquire(self, key: Any, factory: Callable[[], AbstractContextManager[Any]]) -> Any: + if self._stack is not None and key == self._key: + return self._value + + self.close() + stack = ExitStack() + value = stack.enter_context(factory()) + self._key = key + self._stack = stack + self._value = value + return value + + def close(self) -> None: + stack = self._stack + self._key = None + self._stack = None + self._value = None + if stack is not None: + stack.close() + + +@contextmanager +def _start_omni_server(server_param): + test_name, model, stage_config_path, stage_overrides, extra_cli_args, use_omni = server_param + + print(f"Starting OmniServer with test: {test_name}, model: {model}") + + server_args: list[str] = [] + if use_omni: + server_args += ["--stage-init-timeout", "600", "--init-timeout", "900"] + # --deploy-config and --stage-overrides compose at the CLI (see vllm_omni/entrypoints/utils.py): + # deploy-config sets the base; stage-overrides are applied on top. Both can be set. + if stage_config_path: + server_args = ["--deploy-config", stage_config_path] + server_args + if stage_overrides: + server_args = ["--stage-overrides", stage_overrides] + server_args + if extra_cli_args: + server_args = list(extra_cli_args) + server_args + with OmniServer(model, server_args, use_omni=use_omni) as server: + server.test_name = test_name + print("OmniServer started successfully") + yield server + print("OmniServer stopping...") + + print("OmniServer stopped") + + @pytest.fixture(scope="module") -def omni_server(request): +def omni_server_context(): """Start vLLM-Omni server as a subprocess with actual model weights. - Uses session scope so the server starts only once for the entire test session. + Reuse it for adjacent benchmark cases with the same server configuration. Multi-stage initialization can take 10-20+ minutes. """ with _omni_server_lock: - test_name, model, stage_config_path, stage_overrides, extra_cli_args, use_omni = request.param - - print(f"Starting OmniServer with test: {test_name}, model: {model}") - - server_args: list[str] = [] - if use_omni: - server_args += ["--stage-init-timeout", "600", "--init-timeout", "900"] - # --deploy-config and --stage-overrides compose at the CLI (see vllm_omni/entrypoints/utils.py): - # deploy-config sets the base; stage-overrides are applied on top. Both can be set. - if stage_config_path: - server_args = ["--deploy-config", stage_config_path] + server_args - if stage_overrides: - server_args = ["--stage-overrides", stage_overrides] + server_args - if extra_cli_args: - server_args = list(extra_cli_args) + server_args - with OmniServer(model, server_args, use_omni=use_omni) as server: - server.test_name = test_name - print("OmniServer started successfully") - yield server - print("OmniServer stopping...") - - print("OmniServer stopped") - - -benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping) + active_context = _SingleActiveContext() + try: + yield active_context + finally: + active_context.close() @pytest.fixture -def benchmark_params(request, omni_server): - """Benchmark parameters fixture with proper parametrization""" - test_name, param_index = request.param +def omni_server(request, omni_server_context): + return omni_server_context.acquire(request.param, lambda: _start_omni_server(request.param)) - if test_name != omni_server.test_name: - pytest.skip(f"Skipping parameter for {test_name} - current server is {omni_server.test_name}") + +@pytest.fixture +def benchmark_params(request): + """Benchmark parameters fixture; paired with ``omni_server`` via parametrization.""" + test_name, param_index = request.param all_params = get_benchmark_params_for_server(test_name, server_to_benchmark_mapping) @@ -120,42 +157,17 @@ def benchmark_params(request, omni_server): } -def assert_result( - result, - params, - num_prompt, - *, - assert_baseline: bool, - sweep_index: int | None = None, - max_concurrency: Any | None = None, - request_rate: Any | None = None, -) -> None: +def assert_result(result, num_prompt) -> None: assert result["completed"] == num_prompt, "Request failures exist" - if not assert_baseline: - return - baseline_data = params.get("baseline", {}) - for metric_name, baseline_raw in baseline_data.items(): - current_value = result[metric_name] - baseline_value = resolve_baseline_value( - baseline_raw, - sweep_index=sweep_index, - max_concurrency=max_concurrency, - request_rate=request_rate, - ) - if "throughput" in metric_name: - if current_value <= baseline_value: - print( - f"ERROR: Throughput test results were below baseline: {metric_name}: {current_value} > {baseline_value}" - ) - else: - if current_value >= baseline_value: - print(f"ERROR: Test results exceeded baseline: {metric_name}: {current_value} < {baseline_value}") @pytest.mark.benchmark -@pytest.mark.parametrize("omni_server", test_params, indirect=True) -@pytest.mark.parametrize("benchmark_params", benchmark_indices, indirect=True) -def test_performance_benchmark(omni_server, benchmark_params, request): +@pytest.mark.parametrize( + "omni_server,benchmark_params", + paired_benchmark_params, + indirect=["omni_server", "benchmark_params"], +) +def test_performance_benchmark(omni_server, benchmark_params): test_name = benchmark_params["test_name"] params = benchmark_params["params"] dataset_name = params.get("dataset_name", "") @@ -167,7 +179,7 @@ def test_performance_benchmark(omni_server, benchmark_params, request): print(f"Running benchmark for model: {model}") print(f"Benchmark parameters: {benchmark_params}") - assert_baseline = request.config.getoption("--assert-baseline", default=False) + resource_label = get_runtime_resource_label() def to_list(value, default=None): if value is None: @@ -225,7 +237,7 @@ def test_performance_benchmark(omni_server, benchmark_params, request): break # QPS / request-rate sweep - for i, (qps, num_prompt) in enumerate(zip(qps_list, num_prompt_list)): + for sweep_index, (qps, num_prompt) in enumerate(zip(qps_list, num_prompt_list)): args = args + ["--request-rate", str(qps), "--num-prompts", str(num_prompt)] result = run_benchmark( args=args, @@ -234,23 +246,15 @@ def test_performance_benchmark(omni_server, benchmark_params, request): dataset_name=dataset_name, num_prompt=num_prompt, baseline_config=params.get("baseline"), - sweep_index=i, - request_rate=qps, - max_concurrency=None, + sweep_index=sweep_index, random_input_len=params.get("random_input_len"), random_output_len=params.get("random_output_len"), + resource_label=resource_label, ) - assert_result( - result, - params, - num_prompt, - assert_baseline=assert_baseline, - sweep_index=i, - request_rate=qps, - ) + assert_result(result, num_prompt) # concurrency test - for i, (concurrency, num_prompt) in enumerate(zip(max_concurrency_list, num_prompt_list)): + for sweep_index, (concurrency, num_prompt) in enumerate(zip(max_concurrency_list, num_prompt_list)): args = args + ["--max-concurrency", str(concurrency), "--num-prompts", str(num_prompt), "--request-rate", "inf"] result = run_benchmark( args=args, @@ -259,17 +263,9 @@ def test_performance_benchmark(omni_server, benchmark_params, request): dataset_name=dataset_name, num_prompt=num_prompt, baseline_config=params.get("baseline"), - sweep_index=i, - request_rate=None, - max_concurrency=concurrency, + sweep_index=sweep_index, random_input_len=params.get("random_input_len"), random_output_len=params.get("random_output_len"), + resource_label=resource_label, ) - assert_result( - result, - params, - num_prompt, - assert_baseline=assert_baseline, - sweep_index=i, - max_concurrency=concurrency, - ) + assert_result(result, num_prompt) diff --git a/tests/dfx/perf/scripts/run_diffusion_benchmark.py b/tests/dfx/perf/scripts/run_diffusion_benchmark.py index b0860978b..75d1bdc21 100644 --- a/tests/dfx/perf/scripts/run_diffusion_benchmark.py +++ b/tests/dfx/perf/scripts/run_diffusion_benchmark.py @@ -8,15 +8,19 @@ This runner separates two concepts: 2. ``benchmark_endpoint``: which serving API the benchmark client calls. Examples: ``/v1/chat/completions`` and ``/v1/videos``. -A config JSON file is REQUIRED via --test-config-file: +A config JSON file may be passed via --test-config-file. If omitted, every ``*.json`` under +``tests/dfx/perf/tests/`` is loaded and pytest ``-m`` filters by each case's ``mark``: + pytest run_diffusion_benchmark.py -m "diffusion" pytest run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json -Optional: ``--assert-baseline`` compares metrics to the ``baseline`` block in each benchmark entry (default: off). +Optional JSON field ``mark`` is applied as pytest marks on that case via +``pytest.param`` (e.g. ``"mark": [{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, "full_model", "diffusion"]``). -All benchmark results for a session are consolidated into a single JSON file under -BENCHMARK_RESULT_DIR (override via the DIFFUSION_BENCHMARK_DIR environment variable). -Each entry in the file contains the test metadata (test_name, endpoint, benchmark_params, -timestamp) together with the raw metrics returned by the benchmark script. +All benchmark results are written under BENCHMARK_RESULT_DIR (override via the +DIFFUSION_BENCHMARK_DIR environment variable). Each source JSON file gets one +aggregated ``diffusion_result_{config_stem}_{hardware}_{timestamp}.json`` (JSON array +of all runs from cases in that file). Bulk load without ``--test-config-file`` uses +the same per-file aggregation; ``-m`` only selects which cases run. """ import json @@ -35,8 +39,16 @@ import psutil import pytest from benchmarks.diffusion.backends import endpoint_filename_token, normalize_endpoint - -pytestmark = [pytest.mark.diffusion, pytest.mark.full_model, pytest.mark.local_model] +from tests.dfx.conftest import ( + create_paired_benchmark_pytest_params, + get_runtime_resource_label, + hardware_json_value, + is_diffusion_perf_config, + resolve_baseline_for_sweep, + resolve_pytest_marks, + resource_label_for_filename, +) +from tests.helpers.runtime import get_open_port os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" os.environ.setdefault("DIFFUSION_ATTENTION_BACKEND", "FLASH_ATTN") @@ -126,6 +138,10 @@ _BRANCHPOINT_COMMIT_SHA: str | None = None DIFFUSION_RESULT_TEMPLATE_PATH = Path(__file__).parent / "diffusion_result_template.json" +_DIFFUSION_SOURCE_CONFIG_KEY = "_source_config_file" +_PERF_TESTS_DIR = Path(__file__).resolve().parent.parent / "tests" + + def _get_config_file_from_argv() -> str | None: """Read --test-config-file from sys.argv at import time so pytest parametrize can use it. @@ -142,9 +158,6 @@ def _get_config_file_from_argv() -> str | None: CONFIG_FILE_PATH = _get_config_file_from_argv() -if CONFIG_FILE_PATH is None: - print("No config file provided, using default config file: tests/dfx/perf/tests/test_qwen_image_vllm_omni.json") - CONFIG_FILE_PATH = "tests/dfx/perf/tests/test_qwen_image_vllm_omni.json" # --------------------------------------------------------------------------- # Config loading @@ -184,28 +197,80 @@ def load_configs(config_path: str) -> list[dict[str, Any]]: raise RuntimeError(f"Failed to load configuration file: {str(e)}") -BENCHMARK_CONFIGS = load_configs(CONFIG_FILE_PATH) - -_config_stem = Path(CONFIG_FILE_PATH).stem # e.g. "test_qwen_image_vllm_omni" -AGGREGATED_RESULT_FILE = BENCHMARK_RESULT_DIR / f"diffusion_result_{_config_stem}_{_SESSION_TIMESTAMP}.json" +def load_diffusion_benchmark_configs( + config_path: str | None = None, + *, + config_dir: Path | None = None, +) -> list[dict[str, Any]]: + """Load one diffusion benchmark JSON, or merge all ``*.json`` under *config_dir*.""" + if config_path is not None: + configs = load_configs(config_path) + source = str(Path(config_path).resolve()) + for cfg in configs: + cfg.setdefault(_DIFFUSION_SOURCE_CONFIG_KEY, source) + return configs + if config_dir is None: + raise ValueError("load_diffusion_benchmark_configs requires config_path or config_dir") + configs: list[dict[str, Any]] = [] + for path in sorted(config_dir.glob("*.json")): + source = str(path.resolve()) + for cfg in load_configs(str(path)): + cfg[_DIFFUSION_SOURCE_CONFIG_KEY] = source + configs.append(cfg) + if not configs: + raise ValueError(f"No benchmark JSON files found under {config_dir}") + return configs -def _append_to_aggregated_file(record: dict[str, Any]) -> None: - """Thread-safe append of *record* to the session-level aggregated JSON file. +if CONFIG_FILE_PATH is None: + _all_configs = load_diffusion_benchmark_configs(config_dir=_PERF_TESTS_DIR) + BENCHMARK_CONFIGS = [cfg for cfg in _all_configs if is_diffusion_perf_config(cfg)] + print( + f"No --test-config-file: loaded {len(BENCHMARK_CONFIGS)} diffusion case(s) from " + f"{_PERF_TESTS_DIR}/*.json (skipped {len(_all_configs) - len(BENCHMARK_CONFIGS)} omni/tts; " + f"use -m to filter, e.g. -m diffusion)" + ) +else: + BENCHMARK_CONFIGS = load_diffusion_benchmark_configs(CONFIG_FILE_PATH) - The file contains a JSON array; each call loads the existing array (or - starts a new one), appends the record, and writes the file back atomically. - """ +_AGGREGATED_RESULT_FILES_BY_SOURCE: dict[str, Path] = {} + + +def _normalized_source_path(source_file: str) -> str: + return str(Path(source_file).resolve()) + + +def _aggregated_result_file_for_source(source_file: str) -> Path: + """One session aggregate per source JSON (same naming as single ``--test-config-file``).""" + key = _normalized_source_path(source_file) + if key not in _AGGREGATED_RESULT_FILES_BY_SOURCE: + stem = Path(key).stem + resource = resource_label_for_filename(get_runtime_resource_label()) + if resource: + result_name = f"diffusion_result_{stem}_{resource}_{_SESSION_TIMESTAMP}.json" + else: + result_name = f"diffusion_result_{stem}_{_SESSION_TIMESTAMP}.json" + _AGGREGATED_RESULT_FILES_BY_SOURCE[key] = BENCHMARK_RESULT_DIR / result_name + return _AGGREGATED_RESULT_FILES_BY_SOURCE[key] + + +def _write_result_record(record: dict[str, Any]) -> Path: + """Append one benchmark record to the aggregate file for its source JSON.""" + source_file = record.get("source_file") or CONFIG_FILE_PATH + if not source_file: + raise ValueError("benchmark record missing source_file") + target = _aggregated_result_file_for_source(str(source_file)) with _RESULT_LOCK: BENCHMARK_RESULT_DIR.mkdir(parents=True, exist_ok=True) - if AGGREGATED_RESULT_FILE.exists(): - with open(AGGREGATED_RESULT_FILE, encoding="utf-8") as f: + if target.exists(): + with open(target, encoding="utf-8") as f: records: list[dict] = json.load(f) else: records = [] records.append(record) - with open(AGGREGATED_RESULT_FILE, "w", encoding="utf-8") as f: + with open(target, "w", encoding="utf-8") as f: json.dump(records, f, indent=2, ensure_ascii=False) + return target _server_lock = threading.Lock() @@ -215,14 +280,6 @@ _server_lock = threading.Lock() # --------------------------------------------------------------------------- -def _get_open_port() -> int: - """Return an available TCP port on localhost.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("", 0)) - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - return s.getsockname()[1] - - def _wait_for_port(host: str, port: int, timeout: int = 1200, proc: subprocess.Popen | None = None) -> None: """Block until the given host:port accepts connections or timeout expires. @@ -292,6 +349,56 @@ def _kill_process_tree(pid: int) -> None: # --------------------------------------------------------------------------- +def _resolve_offline_model(model: str) -> str: + """Resolve a model string to a local directory. + + Local paths pass through unchanged. HF repo ids are resolved to their local + snapshot dir when ``HF_HUB_OFFLINE`` is set. ``repo_id/subfolder`` (e.g. + ``MiniMaxAI/MiniMax-H3/FL2VA``) is resolved to the matching partition + subfolder of the snapshot, downloading only that subfolder when online. + """ + import huggingface_hub + + if not model or os.path.isdir(model): + return model + + # MiniMax-H3 custom code uses relative imports across files, which breaks + # when the HF cache snapshot is symlinked (``get_class_from_dynamic_module`` + # resolves ``realpath`` into the blobs dir). Mirror the accuracy test's env + # overrides so CI / local runs can point at a materialized partition. The + # repo-root key (used with ``--task-type fl2va|ref2va``) expects a + # materialized root containing the FL2VA/Ref2VA subfolders. + model_env_overrides = { + "MiniMaxAI/MiniMax-H3": "VLLM_TEST_MINIMAX_H3_MODEL", + "MiniMaxAI/MiniMax-H3/FL2VA": "VLLM_TEST_MINIMAX_H3_FL2VA_MODEL", + "MiniMaxAI/MiniMax-H3/Ref2VA": "VLLM_TEST_MINIMAX_H3_REF2VA_MODEL", + } + env_name = model_env_overrides.get(model) + if env_name: + env_model = os.environ.get(env_name) + if env_model: + return env_model + + parts = model.split("/") + if len(parts) >= 3: + repo_id = "/".join(parts[:2]) + subfolder = "/".join(parts[2:]) + from huggingface_hub import snapshot_download + + snapshot_root = snapshot_download( + repo_id, + allow_patterns=[f"{subfolder}/**"], + local_files_only=huggingface_hub.constants.HF_HUB_OFFLINE, + ) + return str(Path(snapshot_root) / subfolder) + + if not huggingface_hub.constants.HF_HUB_OFFLINE: + return model + from huggingface_hub import snapshot_download + + return snapshot_download(model, local_files_only=True) + + class DiffusionServer: """Start a vLLM-Omni diffusion model server as a subprocess. @@ -309,16 +416,21 @@ class DiffusionServer: port: int | None = None, ) -> None: self.server_cfg: dict[str, Any] = server_cfg - self.model = server_cfg["model"] + self.model = _resolve_offline_model(server_cfg["model"]) self.serve_args = server_cfg["serve_args"] self.host = "127.0.0.1" - self.port = port if port is not None else _get_open_port() + self.port = port if port is not None else get_open_port(self.host) self.proc: subprocess.Popen | None = None self.test_name: str = "" def _start_server(self) -> None: env = os.environ.copy() env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + # Give each run its own writable video-storage dir. The server default + # (/tmp/storage) may be missing or not writable on shared hosts / CI. + storage_path = Path(os.environ.get("VLLM_OMNI_STORAGE_PATH", str(BENCHMARK_RESULT_DIR / "storage"))) + storage_path.mkdir(parents=True, exist_ok=True) + env["VLLM_OMNI_STORAGE_PATH"] = str(storage_path) cmd = [ sys.executable, @@ -482,6 +594,7 @@ def _unique_server_params(configs: list[dict[str, Any]]) -> list[dict[str, Any]] "serve_args": _build_serve_args(serve_args_dict), "benchmark_endpoint": cfg.get("benchmark_endpoint", cfg.get("benchmark_backend")), "server_params": cfg["server_params"], + "mark": cfg.get("mark"), } ) return result @@ -496,19 +609,29 @@ def _test_param_mapping(configs: list[dict[str, Any]]) -> dict[str, list[dict]]: return mapping -def _make_server(server_cfg: dict[str, Any]) -> DiffusionServer: - """Factory: return a vLLM-Omni diffusion server instance for the config.""" - return DiffusionServer(server_cfg=server_cfg) +def _marks_by_test_name(configs: list[dict[str, Any]]) -> dict[str, list[pytest.MarkDecorator]]: + return {str(cfg["test_name"]): resolve_pytest_marks(cfg.get("mark")) for cfg in configs} + + +def _paired_diffusion_benchmark_pytest_params(configs: list[dict[str, Any]]) -> list[Any]: + """Paired params for ``run_diffusion_benchmark.py``; same shape as omni runner.""" + test_param_map = _test_param_mapping(configs) + server_entries = [(cfg, cfg["test_name"]) for cfg in _unique_server_params(configs)] + return create_paired_benchmark_pytest_params(server_entries, test_param_map, _marks_by_test_name(configs)) # --------------------------------------------------------------------------- # Parametrize data # --------------------------------------------------------------------------- -server_params = _unique_server_params(BENCHMARK_CONFIGS) test_param_map = _test_param_mapping(BENCHMARK_CONFIGS) +paired_benchmark_params = _paired_diffusion_benchmark_pytest_params(BENCHMARK_CONFIGS) + + +def _make_server(server_cfg: dict[str, Any]) -> DiffusionServer: + """Factory: return a vLLM-Omni diffusion server instance for the config.""" + return DiffusionServer(server_cfg=server_cfg) -benchmark_indices: list[int] = list(range(max(len(v) for v in test_param_map.values()))) # --------------------------------------------------------------------------- # Pytest fixtures @@ -534,16 +657,13 @@ def diffusion_server(request): @pytest.fixture -def benchmark_params(request, diffusion_server): - """Yield the benchmark params dict for the current (server, index) pair.""" - param_index: int = request.param - test_name = diffusion_server.test_name +def benchmark_params(request): + """Benchmark params for the paired server/index parametrization.""" + test_name, param_index = request.param params_list = test_param_map.get(test_name, []) if not params_list: raise ValueError(f"No benchmark params for test: {test_name}") - if param_index >= len(params_list): - pytest.skip(f"Param index {param_index} out of range for {test_name} (has {len(params_list)} params)") current = param_index + 1 total = len(params_list) @@ -575,8 +695,8 @@ def run_benchmark( The raw metrics are written to a temporary file by the subprocess. After the run completes the metrics are merged with full metadata (test_name, endpoint, benchmark_params, timestamp, flat reporting fields) and appended - to the session-wide aggregated JSON file (AGGREGATED_RESULT_FILE). The - temporary file is removed afterwards. Subprocess stdout/stderr are tee'd + to ``diffusion_result_{config_stem}_{hardware}_{timestamp}.json`` for the + source JSON file. The temporary file is removed afterwards. Subprocess stdout/stderr are tee'd to a .log file under BENCHMARK_RESULT_DIR/logs/; its path is stored in the record. """ @@ -586,12 +706,17 @@ def run_benchmark( log_dir = BENCHMARK_RESULT_DIR / "logs" log_dir.mkdir(parents=True, exist_ok=True) endpoint_label = endpoint_filename_token(endpoint) - log_file = log_dir / f"{test_name}_{endpoint_label}_{timestamp}.log" + resource_label = get_runtime_resource_label() + hw_for_filename = resource_label_for_filename(resource_label) + if hw_for_filename: + log_file = log_dir / f"{test_name}_{hw_for_filename}_{endpoint_label}_{timestamp}.log" + else: + log_file = log_dir / f"{test_name}_{endpoint_label}_{timestamp}.log" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", prefix="diffusion_bench_tmp_", delete=False) as tmp: tmp_result_file = Path(tmp.name) - exclude_keys = {"baseline", "dataset", "task", "name", "skip-performance-assertion"} + exclude_keys = {"baseline", "dataset", "task", "name"} cmd = [ sys.executable, @@ -685,18 +810,23 @@ def run_benchmark( completed = metrics.get("completed_requests", metrics.get("completed", 0)) failed = metrics.get("failed_requests", metrics.get("failed", 0)) + # Persist sweep-resolved baseline from params (already narrowed in _build_run_params). + baseline = params.get("baseline") or {} + metrics["baseline"] = baseline + record: dict[str, Any] = { "test_name": test_name, "endpoint": endpoint, "timestamp": timestamp, "server_params": server_cfg.get("server_params"), "benchmark_params": params, + "baseline": baseline, "result": metrics, "log_file": str(log_file), "Model": model, "Framework": server_type, "API Endpoint": endpoint, - "Hardware": "", + "Hardware": hardware_json_value(resource_label), "Deployment": "", "Task": params.get("task", "t2i"), "Dataset": params.get("dataset", "random"), @@ -728,8 +858,8 @@ def run_benchmark( "build_url": os.environ.get("BUILDKITE_BUILD_URL", ""), "source_file": source_file, } - _append_to_aggregated_file(record) - print(f"\n Result appended to: {AGGREGATED_RESULT_FILE}") + result_path = _write_result_record(record) + print(f"\n Result saved to: {result_path}") print(f" Log saved to: {log_file}") return metrics @@ -740,70 +870,14 @@ def run_benchmark( # --------------------------------------------------------------------------- -def _resolve_baseline_value( - baseline_raw: Any, - *, - sweep_index: int | None, - max_concurrency: Any = None, - request_rate: Any = None, -) -> Any: - """Pick the baseline threshold for this sweep step.""" - if baseline_raw is None: - return 100000 - if isinstance(baseline_raw, dict): - if max_concurrency is not None: - for key in (max_concurrency, str(max_concurrency)): - if key in baseline_raw: - return baseline_raw[key] - if request_rate is not None: - for key in (request_rate, str(request_rate)): - if key in baseline_raw: - return baseline_raw[key] - raise KeyError( - f"baseline dict has no key for max_concurrency={max_concurrency!r} " - f"or request_rate={request_rate!r}; keys={list(baseline_raw.keys())!r}" - ) - if isinstance(baseline_raw, (list, tuple)): - if sweep_index is None: - raise ValueError("sweep_index is required when baseline is a list or tuple") - return baseline_raw[sweep_index] - return baseline_raw - - def assert_result( result: dict[str, Any], - params: dict[str, Any], num_prompts: int, - *, - sweep_index: int | None = None, - max_concurrency: Any = None, - request_rate: Any = None, - assert_baseline: bool = True, ) -> None: - """Assert that benchmark metrics satisfy the configured baselines.""" + """Assert that the expected number of requests completed.""" completed = result.get("completed_requests", result.get("completed", 0)) assert completed == num_prompts, f"Expected {num_prompts} completed requests, got {completed}" - if not assert_baseline: - return - if params.get("skip-performance-assertion", False): - print("Skipping performance assertions.") - return - - for metric, baseline_raw in params.get("baseline", {}).items(): - current = result.get(metric) - assert current is not None, f"Metric '{metric}' not found in result: {list(result.keys())}" - threshold = _resolve_baseline_value( - baseline_raw, - sweep_index=sweep_index, - max_concurrency=max_concurrency, - request_rate=request_rate, - ) - if "throughput" in metric: - assert current >= threshold, f"{metric}: {current:.4f} < baseline {threshold}" - else: - assert current <= threshold, f"{metric}: {current:.4f} > baseline {threshold}" - def _default_benchmark_endpoint_for_task(task: str) -> str: """Return the default client-side benchmark endpoint for a diffusion task.""" @@ -845,15 +919,11 @@ def _build_run_params( if max_concurrency is not None: run_params["max-concurrency"] = max_concurrency if "baseline" in params: - run_params["baseline"] = { - metric: _resolve_baseline_value( - baseline_raw, - sweep_index=sweep_index, - max_concurrency=max_concurrency, - request_rate=request_rate, - ) - for metric, baseline_raw in params["baseline"].items() - } + # Keep all hardware buckets; pick the metric value for this sweep step only. + run_params["baseline"] = resolve_baseline_for_sweep( + params.get("baseline"), + sweep_index=sweep_index, + ) return run_params @@ -876,36 +946,30 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]: sweep_runs: list[dict[str, Any]] = [] - for i, (request_rate, num_prompts) in enumerate(zip(request_rate_list, num_prompt_list)): + for sweep_index, (request_rate, num_prompts) in enumerate(zip(request_rate_list, num_prompt_list)): sweep_runs.append( { "params": _build_run_params( params, request_rate=request_rate, num_prompts=num_prompts, - sweep_index=i, + sweep_index=sweep_index, ), "num_prompts": num_prompts, - "sweep_index": i, - "request_rate": request_rate, - "max_concurrency": None, } ) - for i, (max_concurrency, num_prompts) in enumerate(zip(max_concurrency_list, num_prompt_list)): + for sweep_index, (max_concurrency, num_prompts) in enumerate(zip(max_concurrency_list, num_prompt_list)): sweep_runs.append( { "params": _build_run_params( params, max_concurrency=max_concurrency, num_prompts=num_prompts, - sweep_index=i, request_rate="inf", + sweep_index=sweep_index, ), "num_prompts": num_prompts, - "sweep_index": i, - "request_rate": None, - "max_concurrency": max_concurrency, } ) @@ -916,11 +980,9 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]: "params": _build_run_params( params, num_prompts=default_num_prompts, + sweep_index=0, ), "num_prompts": default_num_prompts, - "sweep_index": None, - "request_rate": None, - "max_concurrency": None, } ) @@ -932,20 +994,16 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]: # --------------------------------------------------------------------------- @pytest.mark.benchmark @pytest.mark.parametrize( - "diffusion_server", - server_params, - ids=[p["test_name"] for p in server_params], - indirect=True, + "diffusion_server,benchmark_params", + paired_benchmark_params, + indirect=["diffusion_server", "benchmark_params"], ) -@pytest.mark.parametrize("benchmark_params", benchmark_indices, indirect=True) -def test_diffusion_performance_benchmark(diffusion_server, benchmark_params, request): +def test_diffusion_performance_benchmark(diffusion_server, benchmark_params): """Run the diffusion performance benchmark and verify request completion. One server is started per unique parallel configuration (module scope). For each server, all benchmark parameter sets defined in the config JSON are executed sequentially; metrics are recorded to the aggregated result file. - - Pass ``--assert-baseline`` to compare ``throughput_qps``, ``latency_mean``, etc. to the JSON ``baseline`` block. """ test_name = benchmark_params["test_name"] params = benchmark_params["params"] @@ -962,7 +1020,10 @@ def test_diffusion_performance_benchmark(diffusion_server, benchmark_params, req test_name=test_name, endpoint=endpoint, server_cfg=server_cfg, - source_file=cast(str, CONFIG_FILE_PATH), + source_file=server_cfg.get( + _DIFFUSION_SOURCE_CONFIG_KEY, + CONFIG_FILE_PATH or f"{_PERF_TESTS_DIR}/*.json", + ), ) print(f"\n{'=' * 60}") @@ -980,17 +1041,9 @@ def test_diffusion_performance_benchmark(diffusion_server, benchmark_params, req if key in result: print(f" {key}: {result[key]:.4f}") - print(f"\n Aggregated results: {AGGREGATED_RESULT_FILE}") + source = server_cfg.get(_DIFFUSION_SOURCE_CONFIG_KEY) or CONFIG_FILE_PATH + if source: + print(f"\n Aggregated results: {_aggregated_result_file_for_source(str(source))}") print("=" * 60) - assert_baseline = request.config.getoption("--assert-baseline", default=False) - - assert_result( - result, - params, - sweep_run["num_prompts"], - sweep_index=sweep_run["sweep_index"], - max_concurrency=sweep_run["max_concurrency"], - request_rate=sweep_run["request_rate"], - assert_baseline=assert_baseline, - ) + assert_result(result, sweep_run["num_prompts"]) diff --git a/tests/dfx/perf/tests/test_bagel_vllm_omni.json b/tests/dfx/perf/tests/test_bagel_vllm_omni.json index 09fc96030..9f6ff0241 100644 --- a/tests/dfx/perf/tests/test_bagel_vllm_omni.json +++ b/tests/dfx/perf/tests/test_bagel_vllm_omni.json @@ -1,6 +1,18 @@ [ { "test_name": "test_bagel_single_device_single_stage_t2i", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], "description": "Single-stage BAGEL (TP=1, CFG=1), t2i Task", "server_type": "vllm-omni", "server_params": { @@ -28,16 +40,30 @@ "max-concurrency": 1, "enable-negative-prompt": true, "baseline": { - "throughput_qps": 0.3678, - "latency_mean": 2.691, - "peak_memory_mb_max": 33083.6, - "peak_memory_mb_mean": 33082.5 + "H100": { + "throughput_qps": 0.495, + "latency_mean": 2.0238, + "peak_memory_mb_max": 29393.4286, + "peak_memory_mb_mean": 29392.7143 + } } } ] }, { "test_name": "test_bagel_single_device_single_stage_i2i", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], "description": "Single-stage BAGEL (TP=1, CFG=1), i2i Task", "server_type": "vllm-omni", "server_params": { @@ -66,16 +92,30 @@ "enable-negative-prompt": true, "num-input-images": 1, "baseline": { - "throughput_qps": 0.3124, - "latency_mean": 3.1686, - "peak_memory_mb_max": 33448.8, - "peak_memory_mb_mean": 33447.7 + "H100": { + "throughput_qps": 0.3451, + "latency_mean": 2.9031, + "peak_memory_mb_max": 30480.2857, + "peak_memory_mb_mean": 30479.2857 + } } } ] }, { "test_name": "test_bagel_single_device_multi_stage_t2i", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], "description": "Two-stage BAGEL (bagel.yaml), TP=1, CFG=1, t2i Task", "server_type": "vllm-omni", "server_params": { @@ -103,16 +143,30 @@ "max-concurrency": 1, "enable-negative-prompt": true, "baseline": { - "throughput_qps": 0.3746, - "latency_mean": 2.6425, - "peak_memory_mb_max": 33077.0, - "peak_memory_mb_mean": 33077.0 + "H100": { + "throughput_qps": 0.5048, + "latency_mean": 1.987, + "peak_memory_mb_max": 29380.0, + "peak_memory_mb_mean": 29380.0 + } } } ] }, { "test_name": "test_bagel_single_device_multi_stage_i2i", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], "description": "Two-stage BAGEL (bagel.yaml), TP=1, CFG=1, i2i Task", "server_type": "vllm-omni", "server_params": { @@ -141,10 +195,12 @@ "enable-negative-prompt": true, "num-input-images": 1, "baseline": { - "throughput_qps": 0.1349, - "latency_mean": 7.339, - "peak_memory_mb_max": 33321.2, - "peak_memory_mb_mean": 33321.2 + "H100": { + "throughput_qps": 0.149, + "latency_mean": 6.7157, + "peak_memory_mb_max": 30574.0, + "peak_memory_mb_mean": 30574.0 + } } } ] diff --git a/tests/dfx/perf/tests/test_boogu_image_edit_vllm_omni.json b/tests/dfx/perf/tests/test_boogu_image_edit_vllm_omni.json new file mode 100644 index 000000000..e2f96f626 --- /dev/null +++ b/tests/dfx/perf/tests/test_boogu_image_edit_vllm_omni.json @@ -0,0 +1,37 @@ +[ + { + "test_name": "test_boogu_image_edit_single_device", + "description": "Single-device baseline (no parallelism), concurrency sweep 1/2/4 at 512x512, 28 steps. Text-only edit path (2 predictions/step); a double-guidance (guidance_scale_2) case is a follow-up since the random dataset cannot inject extra_body params. Baselines recorded on 1x A40 46GB (measured c1/c2/c4: qps 0.0337/0.0336/0.0336, latency_mean 29.64/56.50/101.48 s, peak mem 36754 MB) with ~10% margin.", + "server_type": "vllm-omni", + "server_params": { + "model": "Boogu/Boogu-Image-0.1-Edit", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "512x512_steps28_i2i", + "dataset": "random", + "task": "i2i", + "width": 512, + "height": 512, + "num-inference-steps": 28, + "num-prompts": 10, + "max-concurrency": [1, 2, 4], + "warmup-requests": 4, + "warmup-concurrency": 4, + "warmup-num-inference-steps": 28, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": [0.0303, 0.0302, 0.0302], + "latency_mean": [32.6, 62.2, 111.7], + "peak_memory_mb_max": 40500, + "peak_memory_mb_mean": 40500 + } + } + } + ] + } +] diff --git a/tests/dfx/perf/tests/test_boogu_image_vllm_omni.json b/tests/dfx/perf/tests/test_boogu_image_vllm_omni.json new file mode 100644 index 000000000..22218268e --- /dev/null +++ b/tests/dfx/perf/tests/test_boogu_image_vllm_omni.json @@ -0,0 +1,37 @@ +[ + { + "test_name": "test_boogu_image_single_device", + "description": "Single-device baseline (no parallelism), concurrency sweep 1/2/4 at 512x512, 28 steps. Baselines recorded on 1x A40 46GB (measured c1/c2/c4: qps 0.0611/0.0609/0.0611, latency_mean 16.37/31.22/55.77 s, peak mem 36754 MB) with ~10% margin.", + "server_type": "vllm-omni", + "server_params": { + "model": "Boogu/Boogu-Image-0.1-Base", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "512x512_steps28", + "dataset": "random", + "task": "t2i", + "width": 512, + "height": 512, + "num-inference-steps": 28, + "num-prompts": 10, + "max-concurrency": [1, 2, 4], + "warmup-requests": 4, + "warmup-concurrency": 4, + "warmup-num-inference-steps": 28, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": [0.0549, 0.0547, 0.0549], + "latency_mean": [18.0, 34.3, 61.4], + "peak_memory_mb_max": 40500, + "peak_memory_mb_mean": 40500 + } + } + } + ] + } +] diff --git a/tests/dfx/perf/tests/test_cosmos3_vllm_omni.json b/tests/dfx/perf/tests/test_cosmos3_vllm_omni.json index b0fc9a5a4..c4d2305e6 100644 --- a/tests/dfx/perf/tests/test_cosmos3_vllm_omni.json +++ b/tests/dfx/perf/tests/test_cosmos3_vllm_omni.json @@ -1,213 +1,269 @@ [ - { - "test_name": "test_cosmos3_t2i_official_demo_2gpu", - "description": "Cosmos3-Nano text-to-image official demo workload on 2 GPUs", - "server_type": "vllm-omni", - "benchmark_endpoint": "/v1/images/generations", - "server_params": { - "model": "nvidia/Cosmos3-Nano", - "serve_args": { - "cfg-parallel-size": 2, - "use-hsdp": true, - "hsdp-shard-size": 2, - "vae-patch-parallel-size": 2, - "vae-use-tiling": true, - "model-class-name": "Cosmos3OmniDiffusersPipeline", - "no-guardrails": true, - "stage-init-timeout": 900, - "init-timeout": 1200, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1024x1024_steps4", - "dataset": "random", - "task": "t2i", - "width": 1024, - "height": 1024, - "num-inference-steps": 4, - "num-prompts": 3, - "max-concurrency": 1, - "seed": 42, - "extra-body": { - "guidance_scale": 7.0, - "flow_shift": 3.0, - "guardrails": false - }, - "baseline": { - "throughput_qps": 1.107, - "latency_mean": 0.8929, - "peak_memory_mb_mean": 80000.0 - } - } - ] + { + "test_name": "test_cosmos3_t2i_official_demo_2gpu", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "Cosmos3-Nano text-to-image official demo workload on 2 GPUs", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/images/generations", + "server_params": { + "model": "nvidia/Cosmos3-Nano", + "serve_args": { + "cfg-parallel-size": 2, + "use-hsdp": true, + "hsdp-shard-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "model-class-name": "Cosmos3OmniDiffusersPipeline", + "no-guardrails": true, + "stage-init-timeout": 900, + "init-timeout": 1200, + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_cosmos3_t2v_official_demo_2gpu", - "description": "Cosmos3-Nano text-to-video official demo workload on 2 GPUs", - "server_type": "vllm-omni", - "benchmark_endpoint": "/v1/videos", - "server_params": { - "model": "nvidia/Cosmos3-Nano", - "serve_args": { - "cfg-parallel-size": 2, - "use-hsdp": true, - "hsdp-shard-size": 2, - "vae-patch-parallel-size": 2, - "vae-use-tiling": true, - "model-class-name": "Cosmos3OmniDiffusersPipeline", - "no-guardrails": true, - "stage-init-timeout": 900, - "init-timeout": 1200, - "enable-diffusion-pipeline-profiler": true - } + "benchmark_params": [ + { + "name": "1024x1024_steps4", + "dataset": "random", + "task": "t2i", + "width": 1024, + "height": 1024, + "num-inference-steps": 4, + "num-prompts": 3, + "max-concurrency": 1, + "seed": 42, + "extra-body": { + "guidance_scale": 7.0, + "flow_shift": 3.0, + "guardrails": false }, - "benchmark_params": [ - { - "name": "1280x720_frames189_steps4", - "dataset": "random", - "task": "t2v", - "num-prompts": 3, - "max-concurrency": 1, - "seed": 42, - "random-request-config": [ - { - "width": 1280, - "height": 720, - "prompt": "A robot arm is cleaning a plate in the kitchen.", - "num_inference_steps": 4, - "num_frames": 189, - "fps": 24, - "weight": 1 - } - ], - "extra-body": { - "guidance_scale": 6.0, - "flow_shift": 10.0, - "max_sequence_length": 4096, - "guardrails": false, - "use_resolution_template": false, - "use_duration_template": false - }, - "baseline": { - "throughput_qps": 0.027, - "latency_mean": 36.6824, - "peak_memory_mb_mean": 30155.4 - } - } - ] + "baseline": { + "H100": { + "throughput_qps": 1.107, + "latency_mean": 0.8929, + "peak_memory_mb_mean": 80000.0 + } + } + } + ] + }, + { + "test_name": "test_cosmos3_t2v_official_demo_2gpu", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "Cosmos3-Nano text-to-video official demo workload on 2 GPUs", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "nvidia/Cosmos3-Nano", + "serve_args": { + "cfg-parallel-size": 2, + "use-hsdp": true, + "hsdp-shard-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "model-class-name": "Cosmos3OmniDiffusersPipeline", + "no-guardrails": true, + "stage-init-timeout": 900, + "init-timeout": 1200, + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_cosmos3_i2v_official_demo_2gpu", - "description": "Cosmos3-Nano image-to-video official demo workload on 2 GPUs", - "server_type": "vllm-omni", - "benchmark_endpoint": "/v1/videos", - "server_params": { - "model": "nvidia/Cosmos3-Nano", - "serve_args": { - "cfg-parallel-size": 2, - "use-hsdp": true, - "hsdp-shard-size": 2, - "vae-patch-parallel-size": 2, - "vae-use-tiling": true, - "model-class-name": "Cosmos3OmniDiffusersPipeline", - "no-guardrails": true, - "stage-init-timeout": 900, - "init-timeout": 1200, - "enable-diffusion-pipeline-profiler": true - } + "benchmark_params": [ + { + "name": "1280x720_frames189_steps4", + "dataset": "random", + "task": "t2v", + "num-prompts": 3, + "max-concurrency": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1280, + "height": 720, + "prompt": "A robot arm is cleaning a plate in the kitchen.", + "num_inference_steps": 4, + "num_frames": 189, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "guidance_scale": 6.0, + "flow_shift": 10.0, + "max_sequence_length": 4096, + "guardrails": false, + "use_resolution_template": false, + "use_duration_template": false }, - "benchmark_params": [ - { - "name": "1280x720_frames189_steps4", - "dataset": "random", - "task": "i2v", - "num-prompts": 3, - "max-concurrency": 1, - "num-input-images": 1, - "seed": 42, - "random-request-config": [ - { - "width": 1280, - "height": 720, - "prompt": "The scene comes to life with smooth, natural motion.", - "num_inference_steps": 4, - "num_frames": 189, - "fps": 24, - "weight": 1 - } - ], - "extra-body": { - "guidance_scale": 6.0, - "flow_shift": 10.0, - "max_sequence_length": 4096, - "guardrails": false - }, - "baseline": { - "throughput_qps": 0.027, - "latency_mean": 36.968, - "peak_memory_mb_mean": 30443.6 - } - } - ] + "baseline": { + "H100": { + "throughput_qps": 0.027, + "latency_mean": 36.6824, + "peak_memory_mb_mean": 30155.4 + } + } + } + ] + }, + { + "test_name": "test_cosmos3_i2v_official_demo_2gpu", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "Cosmos3-Nano image-to-video official demo workload on 2 GPUs", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "nvidia/Cosmos3-Nano", + "serve_args": { + "cfg-parallel-size": 2, + "use-hsdp": true, + "hsdp-shard-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "model-class-name": "Cosmos3OmniDiffusersPipeline", + "no-guardrails": true, + "stage-init-timeout": 900, + "init-timeout": 1200, + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_cosmos3_v2v_official_demo_2gpu", - "description": "Cosmos3-Nano video-to-video official demo workload on 2 GPUs", - "server_type": "vllm-omni", - "benchmark_endpoint": "/v1/videos", - "server_params": { - "model": "nvidia/Cosmos3-Nano", - "serve_args": { - "cfg-parallel-size": 2, - "use-hsdp": true, - "hsdp-shard-size": 2, - "vae-patch-parallel-size": 2, - "vae-use-tiling": true, - "model-class-name": "Cosmos3OmniDiffusersPipeline", - "no-guardrails": true, - "stage-init-timeout": 900, - "init-timeout": 1200, - "enable-diffusion-pipeline-profiler": true - } + "benchmark_params": [ + { + "name": "1280x720_frames189_steps4", + "dataset": "random", + "task": "i2v", + "num-prompts": 3, + "max-concurrency": 1, + "num-input-images": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1280, + "height": 720, + "prompt": "The scene comes to life with smooth, natural motion.", + "num_inference_steps": 4, + "num_frames": 189, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "guidance_scale": 6.0, + "flow_shift": 10.0, + "max_sequence_length": 4096, + "guardrails": false }, - "benchmark_params": [ - { - "name": "1280x720_frames189_steps4", - "dataset": "random", - "task": "v2v", - "num-prompts": 3, - "max-concurrency": 1, - "seed": 42, - "random-request-config": [ - { - "width": 1280, - "height": 720, - "prompt": "Continue the same scene with smooth natural motion.", - "num_inference_steps": 4, - "num_frames": 189, - "fps": 24, - "weight": 1 - } - ], - "extra-body": { - "guidance_scale": 6.0, - "flow_shift": 10.0, - "max_sequence_length": 4096, - "guardrails": false, - "condition_frame_indexes_vision": [ - 0, - 1 - ], - "condition_video_keep": "first" - }, - "baseline": { - "throughput_qps": 0.027, - "latency_mean": 37.2117, - "peak_memory_mb_mean": 30135.6 - } - } - ] - } + "baseline": { + "H100": { + "throughput_qps": 0.027, + "latency_mean": 36.968, + "peak_memory_mb_mean": 30443.6 + } + } + } + ] + }, + { + "test_name": "test_cosmos3_v2v_official_demo_2gpu", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "Cosmos3-Nano video-to-video official demo workload on 2 GPUs", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "nvidia/Cosmos3-Nano", + "serve_args": { + "cfg-parallel-size": 2, + "use-hsdp": true, + "hsdp-shard-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "model-class-name": "Cosmos3OmniDiffusersPipeline", + "no-guardrails": true, + "stage-init-timeout": 900, + "init-timeout": 1200, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1280x720_frames189_steps4", + "dataset": "random", + "task": "v2v", + "num-prompts": 3, + "max-concurrency": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1280, + "height": 720, + "prompt": "Continue the same scene with smooth natural motion.", + "num_inference_steps": 4, + "num_frames": 189, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "guidance_scale": 6.0, + "flow_shift": 10.0, + "max_sequence_length": 4096, + "guardrails": false, + "condition_frame_indexes_vision": [ + 0, + 1 + ], + "condition_video_keep": "first" + }, + "baseline": { + "H100": { + "throughput_qps": 0.027, + "latency_mean": 37.2117, + "peak_memory_mb_mean": 30135.6 + } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_higgs_audio_v3.json b/tests/dfx/perf/tests/test_higgs_audio_v3.json index 6fdaec580..75e5983b0 100644 --- a/tests/dfx/perf/tests/test_higgs_audio_v3.json +++ b/tests/dfx/perf/tests/test_higgs_audio_v3.json @@ -1,6 +1,18 @@ [ { "test_name": "test_higgs_audio_v3", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "tts" + ], "server_params": { "model": "bosonai/higgs-audio-v3-tts-4b", "stage_overrides": { @@ -34,12 +46,14 @@ "seed_tts_locale": "en", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 3000 - ], - "median_audio_rtf": [ - 0.5 - ] + "H100": { + "median_audio_ttfp_ms": [ + 223.3584 + ], + "median_audio_rtf": [ + 0.4393 + ] + } } }, { @@ -58,15 +72,17 @@ "seed_tts_locale": "en", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 5000 - ], - "median_audio_rtf": [ - 1.2 - ], - "audio_throughput": [ - 30 - ] + "H100": { + "median_audio_ttfp_ms": [ + 276.575 + ], + "median_audio_rtf": [ + 0.5073 + ], + "audio_throughput": [ + 27.3728 + ] + } } } ] diff --git a/tests/dfx/perf/tests/test_hunyuan_image3_it2i.json b/tests/dfx/perf/tests/test_hunyuan_image3_it2i.json index fa955c763..c4ef34b96 100644 --- a/tests/dfx/perf/tests/test_hunyuan_image3_it2i.json +++ b/tests/dfx/perf/tests/test_hunyuan_image3_it2i.json @@ -1,140 +1,148 @@ [ - { - "test_name": "test_hunyuan_image3_ti2i_inline", - "description": "AR (TP=2) + 2 * DiT (TP=2) - using inline dataset and deploy-config", - "server_type": "vllm-omni", - "benchmark_endpoint": "/v1/images/edits", - "server_params": { - "model": "tencent/HunyuanImage-3.0-Instruct", - "serve_args": { - "trust-remote-code": true, - "deploy-config-inline": { - "type": "yaml", - "content": { - "pipeline": "hunyuan_image_3_moe", - "async_chunk": false, - "trust_remote_code": true, - "connectors": { - "rdma_connector": { - "name": "MooncakeTransferEngineConnector", - "extra": { - "host": "auto", - "zmq_port": 50051, - "protocol": "rdma", - "device_name": "", - "memory_pool_size": 17179869184, - "memory_pool_device": "cpu" - } - } - }, - "stages": [ - { - "stage_id": 0, - "is_comprehension": true, - "final_output": true, - "final_output_type": "text", - "max_num_seqs": 16, - "gpu_memory_utilization": 0.95, - "enforce_eager": false, - "enable_prefix_caching": false, - "max_num_batched_tokens": 32768, - "devices": "0,1", - "tensor_parallel_size": 2, - "hf_overrides": { - "rope_parameters": { - "mrope_section": [ - 0, - 32, - 32 - ], - "rope_type": "default" - } - }, - "omni_kv_config": { - "need_send_cache": true - }, - "output_connectors": { - "to_stage_1": "rdma_connector" - }, - "default_sampling_params": { - "temperature": 0.0, - "top_p": 1, - "top_k": -1, - "max_tokens": 8192, - "detokenize": true, - "skip_special_tokens": false, - "stop_token_ids": [ - 128025 - ] - } - }, - { - "stage_id": 1, - "max_num_seqs": 1, - "enforce_eager": true, - "devices": "2,3", - "distributed_executor_backend": "mp", - "omni_kv_config": { - "need_recv_cache": true - }, - "parallel_config": { - "tensor_parallel_size": 2, - "vae_patch_parallel_size": 2, - "enable_expert_parallel": true - }, - "input_connectors": { - "from_stage_0": "rdma_connector" - }, - "default_sampling_params": { - "num_inference_steps": 50, - "guidance_scale": 0 - } - } - ], - "edges": [ - { - "from": 0, - "to": 1, - "window_size": -1, - "max_inflight": 1 - } - ] - } + { + "test_name": "test_hunyuan_image3_ti2i_inline", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100", + "npu": "A3" + }, + "num_cards": 4 + } + }, + "diffusion", + "local_model" + ], + "description": "AR (TP=2) + 2 * DiT (TP=2) - using inline dataset and deploy-config", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/images/edits", + "server_params": { + "model": "tencent/HunyuanImage-3.0-Instruct", + "serve_args": { + "trust-remote-code": true, + "deploy-config-inline": { + "type": "yaml", + "content": { + "pipeline": "hunyuan_image_3_moe", + "async_chunk": false, + "trust_remote_code": true, + "connectors": { + "shared_mem_connector": { + "name": "SharedMemoryConnector", + "extra": {} + } + }, + "stages": [ + { + "stage_id": 0, + "is_comprehension": true, + "final_output": true, + "final_output_type": "text", + "max_num_seqs": 16, + "gpu_memory_utilization": 0.95, + "enforce_eager": false, + "enable_prefix_caching": false, + "max_num_batched_tokens": 32768, + "devices": "0,1", + "tensor_parallel_size": 2, + "hf_overrides": { + "rope_parameters": { + "mrope_section": [ + 0, + 32, + 32 + ], + "rope_type": "default" + } }, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "inline_dataset_5_requests", - "dataset": "custom", - "dataset-path-inline": { - "type": "jsonl", - "content": [ - { - "prompt": "新年宠物海报,Q版圆润的可爱标题“新年快乐汪”,副标题“HAPPY NEW YEAR”。 鱼眼镜头,背景是房间门口,近景,上传的主体歪头笑,围着红色围巾,戴着红色毛线帽,高清,绒毛细节,面部特写。 宝丽莱相纸,超现实主义,写实主义,胶片摄影,打印颗粒感肌理。肌理,超写实,复古感。", - "image_urls": [ - "https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_0_0.png" - ], - "num_inference_steps": 8, - "seed": 42, - "guidance_scale": 1.0 - } - ] + "omni_kv_config": { + "need_send_cache": true }, - "task": "ti2i", - "bot-task": "think_recaption", - "max-concurrency": 16, - "num-prompts": 96, - "skip-performance-assertion": true, - "baseline": { - "throughput_qps": 0.1874, - "latency_p99": 101.852, - "latency_mean": 79.0385, - "peak_memory_mb_max": 70000, - "peak_memory_mb_mean": 70000 + "output_connectors": { + "to_stage_1": "shared_mem_connector" + }, + "default_sampling_params": { + "temperature": 0.0, + "top_p": 1, + "top_k": -1, + "max_tokens": 8192, + "detokenize": true, + "skip_special_tokens": false, + "stop_token_ids": [ + 128025 + ] } + }, + { + "stage_id": 1, + "max_num_seqs": 1, + "enforce_eager": true, + "devices": "2,3", + "distributed_executor_backend": "mp", + "omni_kv_config": { + "need_recv_cache": true + }, + "parallel_config": { + "tensor_parallel_size": 2, + "vae_patch_parallel_size": 2, + "enable_expert_parallel": true + }, + "vae_use_tiling": true, + "input_connectors": { + "from_stage_0": "shared_mem_connector" + }, + "default_sampling_params": { + "num_inference_steps": 50, + "guidance_scale": 0 + } + } + ], + "edges": [ + { + "from": 0, + "to": 1, + "window_size": -1, + "max_inflight": 1 + } + ] + } + }, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "inline_dataset_5_requests", + "dataset": "custom", + "dataset-path-inline": { + "type": "jsonl", + "content": [ + { + "prompt": "新年宠物海报,Q版圆润的可爱标题“新年快乐汪”,副标题“HAPPY NEW YEAR”。 鱼眼镜头,背景是房间门口,近景,上传的主体歪头笑,围着红色围巾,戴着红色毛线帽,高清,绒毛细节,面部特写。 宝丽莱相纸,超现实主义,写实主义,胶片摄影,打印颗粒感肌理。肌理,超写实,复古感。", + "image_urls": [ + "https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_0_0.png" + ], + "num_inference_steps": 8, + "seed": 42, + "guidance_scale": 1.0 } - ] - } + ] + }, + "task": "ti2i", + "bot-task": "think_recaption", + "max-concurrency": 16, + "num-prompts": 96, + "baseline": { + "H200": { + "throughput_qps": 0.1874, + "latency_p99": 101.852, + "latency_mean": 79.0385, + "peak_memory_mb_max": 70000, + "peak_memory_mb_mean": 70000 + } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_hunyuan_image_tp2_cfgp2.json b/tests/dfx/perf/tests/test_hunyuan_image_tp2_cfgp2.json index e6d7ce659..a86a6a3d5 100644 --- a/tests/dfx/perf/tests/test_hunyuan_image_tp2_cfgp2.json +++ b/tests/dfx/perf/tests/test_hunyuan_image_tp2_cfgp2.json @@ -1,35 +1,51 @@ [ - { - "test_name": "test_hunyuan_image_tp2_cfgp2", - "description": "TP=2 CfgP=2 baseline", - "server_type": "vllm-omni", - "server_params": { - "model": "tencent/HunyuanImage-3.0-Instruct", - "serve_args": { - "deploy_config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml", - "tensor-parallel-size": 2, - "cfg-parallel-size": 2, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1024x1024_steps8", - "dataset": "random", - "task": "t2i", - "width": 1024, - "height": 1024, - "num-inference-steps": 8, - "num-prompts": 10, - "max-concurrency": 1, - "skip-performance-assertion": true, - "baseline": { - "throughput_qps": 0.21, - "latency_p99": 4.7469, - "peak_memory_mb_max": 101100, - "peak_memory_mb_mean": 101100 - } - } - ] - } + { + "test_name": "test_hunyuan_image_tp2_cfgp2", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100", + "npu": "A3" + }, + "num_cards": 4 + } + }, + "diffusion", + "local_model" + ], + "description": "TP=2 CfgP=2 baseline", + "server_type": "vllm-omni", + "server_params": { + "model": "tencent/HunyuanImage-3.0-Instruct", + "serve_args": { + "deploy_config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml", + "tensor-parallel-size": 2, + "cfg-parallel-size": 2, + "vae-patch-parallel-size": 4, + "vae-use-tiling": true, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1024x1024_steps8", + "dataset": "random", + "task": "t2i", + "width": 1024, + "height": 1024, + "num-inference-steps": 8, + "num-prompts": 10, + "max-concurrency": 1, + "baseline": { + "H200": { + "throughput_qps": 0.21, + "latency_p99": 4.7469, + "peak_memory_mb_max": 101100, + "peak_memory_mb_mean": 101100 + } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_hunyuan_image_tp2_sp2.json b/tests/dfx/perf/tests/test_hunyuan_image_tp2_sp2.json index fbbe1643e..e49cd0543 100644 --- a/tests/dfx/perf/tests/test_hunyuan_image_tp2_sp2.json +++ b/tests/dfx/perf/tests/test_hunyuan_image_tp2_sp2.json @@ -1,35 +1,51 @@ [ - { - "test_name": "test_hunyuan_image_tp2_sp2", - "description": "TP=2 SP=2 baseline", - "server_type": "vllm-omni", - "server_params": { - "model": "tencent/HunyuanImage-3.0-Instruct", - "serve_args": { - "deploy-config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml", - "tensor-parallel-size": 2, - "ulysses-degree": 2, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1024x1024_steps8", - "dataset": "random", - "task": "t2i", - "width": 1024, - "height": 1024, - "num-inference-steps": 8, - "num-prompts": 10, - "max-concurrency": 1, - "skip-performance-assertion": true, - "baseline": { - "throughput_qps": 0.20, - "latency_p99": 5.1025, - "peak_memory_mb_max": 97402, - "peak_memory_mb_mean": 97402 - } - } - ] - } + { + "test_name": "test_hunyuan_image_tp2_sp2", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100", + "npu": "A3" + }, + "num_cards": 4 + } + }, + "diffusion", + "local_model" + ], + "description": "TP=2 SP=2 baseline", + "server_type": "vllm-omni", + "server_params": { + "model": "tencent/HunyuanImage-3.0-Instruct", + "serve_args": { + "deploy-config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml", + "tensor-parallel-size": 2, + "ulysses-degree": 2, + "vae-patch-parallel-size": 4, + "vae-use-tiling": true, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1024x1024_steps8", + "dataset": "random", + "task": "t2i", + "width": 1024, + "height": 1024, + "num-inference-steps": 8, + "num-prompts": 10, + "max-concurrency": 1, + "baseline": { + "H200": { + "throughput_qps": 0.20, + "latency_p99": 5.1025, + "peak_memory_mb_max": 97402, + "peak_memory_mb_mean": 97402 + } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_hunyuan_image_tp4.json b/tests/dfx/perf/tests/test_hunyuan_image_tp4.json index eb37f7a22..54631471a 100644 --- a/tests/dfx/perf/tests/test_hunyuan_image_tp4.json +++ b/tests/dfx/perf/tests/test_hunyuan_image_tp4.json @@ -1,35 +1,51 @@ [ - { - "test_name": "test_hunyuan_image_tp4", - "description": "TP=4 baseline", - "server_type": "vllm-omni", - "server_params": { - "model": "tencent/HunyuanImage-3.0-Instruct", - "serve_args": { - "deploy-config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml", - "tensor-parallel-size": 4, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1024x1024_steps8", - "dataset": "random", - "task": "t2i", - "width": 1024, - "height": 1024, - "num-inference-steps": 8, - "num-prompts": 10, - "max-concurrency": 1, - "skip-performance-assertion": true, - "baseline": { - "throughput_qps": 0.213, - "latency_mean": 4.6953, - "latency_p99": 4.8601, - "peak_memory_mb_max": 56912.0, - "peak_memory_mb_mean": 56912.0 - } - } - ] - } + { + "test_name": "test_hunyuan_image_tp4", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100", + "npu": "A3" + }, + "num_cards": 4 + } + }, + "diffusion", + "local_model" + ], + "description": "TP=4 baseline", + "server_type": "vllm-omni", + "server_params": { + "model": "tencent/HunyuanImage-3.0-Instruct", + "serve_args": { + "deploy-config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml", + "tensor-parallel-size": 4, + "vae-patch-parallel-size": 4, + "vae-use-tiling": true, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1024x1024_steps8", + "dataset": "random", + "task": "t2i", + "width": 1024, + "height": 1024, + "num-inference-steps": 8, + "num-prompts": 10, + "max-concurrency": 1, + "baseline": { + "H200": { + "throughput_qps": 0.213, + "latency_mean": 4.6953, + "latency_p99": 4.8601, + "peak_memory_mb_max": 56912.0, + "peak_memory_mb_mean": 56912.0 + } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_lingbot_video_vllm_omni.json b/tests/dfx/perf/tests/test_lingbot_video_vllm_omni.json new file mode 100644 index 000000000..07403e78f --- /dev/null +++ b/tests/dfx/perf/tests/test_lingbot_video_vllm_omni.json @@ -0,0 +1,33 @@ +[ + { + "test_name": "test_lingbot_video_single_device", + "description": "Single-device LingBot dense T2V baseline at 320x192, 9 frames, 2 steps.", + "server_type": "vllm-omni", + "server_params": { + "model": "robbyant/lingbot-video-dense-1.3b", + "serve_args": { + "model-class-name": "LingBotVideoPipeline", + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "320x192_frames9_steps2", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 320, + "height": 192, + "num-frames": 9, + "fps": 24, + "num-inference-steps": 2, + "num-prompts": 3, + "max-concurrency": 1, + "warmup-requests": 1, + "warmup-concurrency": 1, + "warmup-num-inference-steps": 2, + "enable-negative-prompt": true + } + ] + } +] diff --git a/tests/dfx/perf/tests/test_ltx2_vllm_omni.json b/tests/dfx/perf/tests/test_ltx2_vllm_omni.json index 4a6f9e350..1a0f31e98 100644 --- a/tests/dfx/perf/tests/test_ltx2_vllm_omni.json +++ b/tests/dfx/perf/tests/test_ltx2_vllm_omni.json @@ -1,217 +1,273 @@ [ - { - "test_name": "test_ltx2_baseline_eager", - "description": "Single-device baseline with enforce-eager (no torch.compile)", - "server_type": "vllm-omni", - "server_params": { - "model": "Lightricks/LTX-2", - "serve_args": { - "enforce-eager": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "256x256_145f_steps6", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 256, - "height": 256, - "num-frames": 145, - "fps": 24, - "num-inference-steps": 6, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - }, - { - "name": "480x768_41f_steps20", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 768, - "height": 480, - "num-frames": 41, - "fps": 24, - "num-inference-steps": 20, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - } - ] + { + "test_name": "test_ltx2_baseline_eager", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline with enforce-eager (no torch.compile)", + "server_type": "vllm-omni", + "server_params": { + "model": "Lightricks/LTX-2", + "serve_args": { + "enforce-eager": true, + "enable-diffusion-pipeline-profiler": true + } }, - - { - "test_name": "test_ltx2_torch_compile", - "description": "Single-device with torch.compile (default, no enforce-eager)", - "server_type": "vllm-omni", - "server_params": { - "model": "Lightricks/LTX-2", - "serve_args": { - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "256x256_145f_steps6", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 256, - "height": 256, - "num-frames": 145, - "fps": 24, - "num-inference-steps": 6, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - }, - { - "name": "480x768_41f_steps20", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 768, - "height": 480, - "num-frames": 41, - "fps": 24, - "num-inference-steps": 20, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - } - ] + "benchmark_params": [ + { + "name": "256x256_145f_steps6", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 256, + "height": 256, + "num-frames": 145, + "fps": 24, + "num-inference-steps": 6, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + }, + { + "name": "480x768_41f_steps20", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 768, + "height": 480, + "num-frames": 41, + "fps": 24, + "num-inference-steps": 20, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + } + ] + }, + { + "test_name": "test_ltx2_torch_compile", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device with torch.compile (default, no enforce-eager)", + "server_type": "vllm-omni", + "server_params": { + "model": "Lightricks/LTX-2", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } }, - - { - "test_name": "test_ltx2_cfg2_eager", - "description": "CFG-parallel=2 with enforce-eager", - "server_type": "vllm-omni", - "server_params": { - "model": "Lightricks/LTX-2", - "serve_args": { - "cfg-parallel-size": 2, - "enforce-eager": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "256x256_145f_steps6", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 256, - "height": 256, - "num-frames": 145, - "fps": 24, - "num-inference-steps": 6, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - }, - { - "name": "480x768_41f_steps20", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 768, - "height": 480, - "num-frames": 41, - "fps": 24, - "num-inference-steps": 20, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - } - ] + "benchmark_params": [ + { + "name": "256x256_145f_steps6", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 256, + "height": 256, + "num-frames": 145, + "fps": 24, + "num-inference-steps": 6, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + }, + { + "name": "480x768_41f_steps20", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 768, + "height": 480, + "num-frames": 41, + "fps": 24, + "num-inference-steps": 20, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + } + ] + }, + { + "test_name": "test_ltx2_cfg2_eager", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "CFG-parallel=2 with enforce-eager", + "server_type": "vllm-omni", + "server_params": { + "model": "Lightricks/LTX-2", + "serve_args": { + "cfg-parallel-size": 2, + "enforce-eager": true, + "enable-diffusion-pipeline-profiler": true + } }, - - { - "test_name": "test_ltx2_cfg2_compile", - "description": "CFG-parallel=2 with torch.compile", - "server_type": "vllm-omni", - "server_params": { - "model": "Lightricks/LTX-2", - "serve_args": { - "cfg-parallel-size": 2, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "256x256_145f_steps6", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 256, - "height": 256, - "num-frames": 145, - "fps": 24, - "num-inference-steps": 6, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - }, - { - "name": "480x768_41f_steps20", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 768, - "height": 480, - "num-frames": 41, - "fps": 24, - "num-inference-steps": 20, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - } - ] + "benchmark_params": [ + { + "name": "256x256_145f_steps6", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 256, + "height": 256, + "num-frames": 145, + "fps": 24, + "num-inference-steps": 6, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + }, + { + "name": "480x768_41f_steps20", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 768, + "height": 480, + "num-frames": 41, + "fps": 24, + "num-inference-steps": 20, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + } + ] + }, + { + "test_name": "test_ltx2_cfg2_compile", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "CFG-parallel=2 with torch.compile", + "server_type": "vllm-omni", + "server_params": { + "model": "Lightricks/LTX-2", + "serve_args": { + "cfg-parallel-size": 2, + "enable-diffusion-pipeline-profiler": true + } }, - - { - "test_name": "test_ltx2_cache_dit_eager", - "description": "CacheDiT with enforce-eager", - "server_type": "vllm-omni", - "server_params": { - "model": "Lightricks/LTX-2", - "serve_args": { - "cache-backend": "cache_dit", - "enforce-eager": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "256x256_145f_steps6", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 256, - "height": 256, - "num-frames": 145, - "fps": 24, - "num-inference-steps": 6, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - }, - { - "name": "480x768_41f_steps20", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 768, - "height": 480, - "num-frames": 41, - "fps": 24, - "num-inference-steps": 20, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - } - ] - } + "benchmark_params": [ + { + "name": "256x256_145f_steps6", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 256, + "height": 256, + "num-frames": 145, + "fps": 24, + "num-inference-steps": 6, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + }, + { + "name": "480x768_41f_steps20", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 768, + "height": 480, + "num-frames": 41, + "fps": 24, + "num-inference-steps": 20, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + } + ] + }, + { + "test_name": "test_ltx2_cache_dit_eager", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "CacheDiT with enforce-eager", + "server_type": "vllm-omni", + "server_params": { + "model": "Lightricks/LTX-2", + "serve_args": { + "cache-backend": "cache_dit", + "enforce-eager": true, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "256x256_145f_steps6", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 256, + "height": 256, + "num-frames": 145, + "fps": 24, + "num-inference-steps": 6, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + }, + { + "name": "480x768_41f_steps20", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 768, + "height": 480, + "num-frames": 41, + "fps": 24, + "num-inference-steps": 20, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_minimax_h3_vllm_omni.json b/tests/dfx/perf/tests/test_minimax_h3_vllm_omni.json new file mode 100644 index 000000000..a28aab96c --- /dev/null +++ b/tests/dfx/perf/tests/test_minimax_h3_vllm_omni.json @@ -0,0 +1,295 @@ +[ + { + "test_name": "test_minimax_h3_t2v_4gpu", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "MiniMax-H3 T2VA text-to-video+audio on 4 GPUs (USP4 + HSDP4, encoder-TP4, VAE-PP4 tile)", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "MiniMaxAI/MiniMax-H3", + "serve_args": { + "trust-remote-code": true, + "task-type": "fl2va", + "num-gpus": 4, + "usp": 4, + "ring": 1, + "use-hsdp": true, + "hsdp-shard-size": 4, + "text-encoder-tp-size": 4, + "vae-patch-parallel-size": 4, + "vae-parallel-mode": "tile", + "vae-use-tiling": true, + "diffusion-attention-backend": "FLASH_ATTN", + "stage-init-timeout": 1800, + "init-timeout": 1800, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1344x768_frames209_steps8", + "dataset": "random", + "task": "t2v", + "num-prompts": 3, + "max-concurrency": 1, + "warmup-requests": 1, + "warmup-concurrency": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1344, + "height": 768, + "prompt": "Cinematic drone shot pushing through misty mountain ridges at sunrise, golden light catching the clouds, slow and smooth.", + "num_inference_steps": 8, + "num_frames": 209, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "flow_shift": 12.0, + "extra_params": "{\"task\":\"t2va\",\"duration\":8.7,\"aspect_ratio\":\"16:9\",\"audio_flow_shift\":3.0}" + }, + "baseline": { + "H100": { + "throughput_qps": 0.0211, + "latency_mean": 38.3252, + "peak_memory_mb_mean": 54219.6 + } + } + } + ] + }, + { + "test_name": "test_minimax_h3_ti2v_4gpu", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "MiniMax-H3 FL2VA first-frame image-to-video+audio on 4 GPUs (USP4 + HSDP4, encoder-TP4, VAE-PP4 tile)", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "MiniMaxAI/MiniMax-H3", + "serve_args": { + "trust-remote-code": true, + "task-type": "fl2va", + "num-gpus": 4, + "usp": 4, + "ring": 1, + "use-hsdp": true, + "hsdp-shard-size": 4, + "text-encoder-tp-size": 4, + "vae-patch-parallel-size": 4, + "vae-parallel-mode": "tile", + "vae-use-tiling": true, + "diffusion-attention-backend": "FLASH_ATTN", + "stage-init-timeout": 1800, + "init-timeout": 1800, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1344x768_frames209_steps8", + "dataset": "random", + "task": "ti2v", + "num-prompts": 3, + "max-concurrency": 1, + "warmup-requests": 1, + "warmup-concurrency": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1344, + "height": 768, + "prompt": "Continue the scene: the camera slowly pushes in on the subject as the background city lights drift and blur, with the actor singing softly and emotionally.", + "num_inference_steps": 8, + "num_frames": 209, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "flow_shift": 12.0, + "extra_params": "{\"task\":\"fl2va\",\"duration\":8.7,\"audio_flow_shift\":3.0}" + }, + "num-input-images": 1, + "baseline": { + "H100": { + "throughput_qps": 0.0212, + "latency_mean": 38.1385, + "peak_memory_mb_mean": 54622.8 + } + } + } + ] + }, + { + "test_name": "test_minimax_h3_v2v_4gpu", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "MiniMax-H3 Ref2VA video-reference-to-video+audio on 4 GPUs (USP4 + HSDP4, encoder-TP4, VAE-PP4 tile)", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "MiniMaxAI/MiniMax-H3", + "serve_args": { + "trust-remote-code": true, + "task-type": "ref2va", + "num-gpus": 4, + "usp": 4, + "ring": 1, + "use-hsdp": true, + "hsdp-shard-size": 4, + "text-encoder-tp-size": 4, + "vae-patch-parallel-size": 4, + "vae-parallel-mode": "tile", + "vae-use-tiling": true, + "diffusion-attention-backend": "FLASH_ATTN", + "stage-init-timeout": 1800, + "init-timeout": 1800, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1344x768_frames209_steps8", + "dataset": "random", + "task": "v2v", + "num-prompts": 3, + "max-concurrency": 1, + "warmup-requests": 1, + "warmup-concurrency": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1344, + "height": 768, + "prompt": "Restyle the reference footage into a cinematic night scene with neon reflections, keeping the same camera motion and subject.", + "num_inference_steps": 8, + "num_frames": 209, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "flow_shift": 12.0, + "extra_params": "{\"task\":\"ref2va\",\"duration\":8.7,\"aspect_ratio\":\"16:9\",\"audio_flow_shift\":3.0}" + }, + "baseline": { + "H100": { + "throughput_qps": 0.0077, + "latency_mean": 105.7508, + "peak_memory_mb_mean": 55180.8 + } + } + } + ] + }, + { + "test_name": "test_minimax_h3_t2v_dlo_4gpu", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "MiniMax-H3 T2VA with 4-card distributed layerwise offload (DLO), 8 denoise steps (USP4 + HSDP4, encoder-TP4, VAE-PP4 tile)", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "MiniMaxAI/MiniMax-H3", + "serve_args": { + "trust-remote-code": true, + "task-type": "fl2va", + "num-gpus": 4, + "usp": 4, + "ring": 1, + "use-hsdp": true, + "hsdp-shard-size": 4, + "text-encoder-tp-size": 4, + "vae-patch-parallel-size": 4, + "vae-parallel-mode": "tile", + "vae-use-tiling": true, + "diffusion-attention-backend": "FLASH_ATTN", + "stage-init-timeout": 1800, + "init-timeout": 1800, + "enable-diffusion-pipeline-profiler": true, + "enable-distributed-layerwise-offload": true, + "dlo-no-use-allgather": true, + "dlo-resident-layers": 8, + "enforce-eager": true + } + }, + "benchmark_params": [ + { + "name": "1344x768_frames209_steps8", + "dataset": "random", + "task": "t2v", + "num-prompts": 3, + "max-concurrency": 1, + "warmup-requests": 1, + "warmup-concurrency": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1344, + "height": 768, + "prompt": "Cinematic drone shot pushing through misty mountain ridges at sunrise, golden light catching the clouds, slow and smooth.", + "num_inference_steps": 8, + "num_frames": 209, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "flow_shift": 12.0, + "extra_params": "{\"task\":\"t2va\",\"duration\":8.7,\"aspect_ratio\":\"16:9\",\"audio_flow_shift\":3.0}" + }, + "baseline": { + "H100": { + "throughput_qps": 0.0212, + "latency_mean": 38.2089, + "peak_memory_mb_mean": 36218.4 + } + } + } + ] + } +] diff --git a/tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json b/tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json index 459554f63..a73abc5fb 100644 --- a/tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json +++ b/tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json @@ -1,331 +1,360 @@ [ - { - "test_name": "test_qwen3_omni_chunk", - "server_params": { - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "extra_cli_args": [ - "--async-chunk" + { + "test_name": "test_qwen3_omni_chunk", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100", + "npu": "A3" + }, + "num_cards": 2 + } + }, + "full_model", + "omni" + ], + "server_params": { + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "extra_cli_args": [ + "--async-chunk" + ] + }, + "benchmark_params": [ + { + "dataset_name": "random", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 4, + 16, + 32, + 64, + 128 + ], + "max_concurrency": [ + 1, + 4, + 8, + 16, + 32 + ], + "random_input_len": 2500, + "random_output_len": 900, + "ignore_eos": true, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 96.4061, + 140.8147, + 271.9025, + 362.2916, + 507.776 + ], + "mean_e2el_ms": [ + 18507.4582, + 28365.4956, + 31907.9613, + 48161.5829, + 72630.8601 + ], + "mean_audio_rtf": [ + 0.1175, + 0.18, + 0.2359, + 0.3278, + 0.6296 ] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 10 + ], + "request_rate": [ + 0.1 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 1, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "audio": 1 }, - "benchmark_params": [ - { - "dataset_name": "random", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 4, - 16, - 32, - 64, - 128 - ], - "max_concurrency": [ - 1, - 4, - 8, - 16, - 32 - ], - "random_input_len": 2500, - "random_output_len": 900, - "ignore_eos": true, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 135.4379, - 187.0896, - 400, - 435.675, - 724.6836 - ], - "mean_e2el_ms": [ - 17619.9851, - 34811.8335, - 43656.9501, - 56657.1548, - 85097.6016 - ], - "mean_audio_rtf": [ - 0.1279, - 0.2238, - 0.2735, - 0.3712, - 0.5634 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 10 - ], - "request_rate": [ - 0.1 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 1, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "audio": 1 - }, - "random_mm_bucket_config": { - "(0, 60, 3)": 1.0 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 340.329 - ], - "mean_e2el_ms": [ - 4305.477 - ], - "mean_audio_rtf": [ - 0.1305 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 40 - ], - "request_rate": [ - 0.5 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 2, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.5, - "(720, 1280, 2)": 0.5 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 220.0909 - ], - "mean_e2el_ms": [ - 5475.9565 - ], - "mean_audio_rtf": [ - 0.1818 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 100 - ], - "request_rate": [ - 1.0 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 3, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1, - "audio": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.34, - "(720, 1280, 2)": 0.33, - "(0, 60, 3)": 0.33 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 450.0155 - ], - "mean_e2el_ms": [ - 8516.8316 - ], - "mean_audio_rtf": [ - 0.2717 - ] - } - }, - { - "dataset_name": "random", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 4, - 16, - 32, - 64, - 128 - ], - "max_concurrency": [ - 1, - 4, - 8, - 16, - 32 - ], - "random_input_len": 2500, - "random_output_len": 900, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 78.4278, - 195.8037, - 333.7318, - 561.805, - 1038.043 - ], - "mean_e2el_ms": [ - 4288.3029, - 7429.9429, - 9451.6184, - 12607.5292, - 18153.2547 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 10 - ], - "request_rate": [ - 0.1 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "random_mm_base_items_per_request": 1, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "audio": 1 - }, - "random_mm_bucket_config": { - "(0, 60, 3)": 1.0 - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 275.3875 - ], - "mean_e2el_ms": [ - 730.6995 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 40 - ], - "request_rate": [ - 0.5 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "random_mm_base_items_per_request": 2, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.5, - "(720, 1280, 2)": 0.5 - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 166.2201 - ], - "mean_e2el_ms": [ - 673.5789 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 100 - ], - "request_rate": [ - 1.0 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "random_mm_base_items_per_request": 3, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1, - "audio": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.34, - "(720, 1280, 2)": 0.33, - "(0, 60, 3)": 0.33 - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 357.7781 - ], - "mean_e2el_ms": [ - 953.3493 - ] - } - } - ] - } + "random_mm_bucket_config": { + "(0, 60, 3)": 1.0 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 354.0951 + ], + "mean_e2el_ms": [ + 3952.5615 + ], + "mean_audio_rtf": [ + 0.117 + ] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 40 + ], + "request_rate": [ + 0.5 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 2, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.5, + "(720, 1280, 2)": 0.5 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 259.6317 + ], + "mean_e2el_ms": [ + 4253.3009 + ], + "mean_audio_rtf": [ + 0.1448 + ] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 100 + ], + "request_rate": [ + 1.0 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 3, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1, + "audio": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.34, + "(720, 1280, 2)": 0.33, + "(0, 60, 3)": 0.33 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 457.4453 + ], + "mean_e2el_ms": [ + 5912.5623 + ], + "mean_audio_rtf": [ + 0.1967 + ] + } + } + }, + { + "dataset_name": "random", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 4, + 16, + 32, + 64, + 128 + ], + "max_concurrency": [ + 1, + 4, + 8, + 16, + 32 + ], + "random_input_len": 2500, + "random_output_len": 900, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 90.8033, + 202.1779, + 326.5864, + 544.8914, + 961.3929 + ], + "mean_e2el_ms": [ + 4517.3411, + 7857.9711, + 9861.1687, + 13283.3363, + 19541.0736 + ] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 10 + ], + "request_rate": [ + 0.1 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "random_mm_base_items_per_request": 1, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "audio": 1 + }, + "random_mm_bucket_config": { + "(0, 60, 3)": 1.0 + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 318.048 + ], + "mean_e2el_ms": [ + 807.6114 + ] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 40 + ], + "request_rate": [ + 0.5 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "random_mm_base_items_per_request": 2, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.5, + "(720, 1280, 2)": 0.5 + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 186.168 + ], + "mean_e2el_ms": [ + 726.1137 + ] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 100 + ], + "request_rate": [ + 1.0 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "random_mm_base_items_per_request": 3, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1, + "audio": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.34, + "(720, 1280, 2)": 0.33, + "(0, 60, 3)": 0.33 + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 375.5282 + ], + "mean_e2el_ms": [ + 1006.1663 + ] + } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen3_omni_multi_replicas.json b/tests/dfx/perf/tests/test_qwen3_omni_multi_replicas.json index bee799a77..a3d8aaea8 100644 --- a/tests/dfx/perf/tests/test_qwen3_omni_multi_replicas.json +++ b/tests/dfx/perf/tests/test_qwen3_omni_multi_replicas.json @@ -1,115 +1,135 @@ [ - { - "test_name": "test_qwen3_omni_3gpu_replica2", - "server_params": { - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "stage_overrides": { - "1": {"num_replicas": 2, "devices": "1,2"}, - "2": {"num_replicas": 2, "devices": "1,2"} - }, - "extra_cli_args": [ - "--async-chunk" - ] - }, - "benchmark_params": [ - { - "dataset_name": "random", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [128, 128, 128, 128], - "max_concurrency": [8, 16, 24, 32], - "random_input_len": 2500, - "random_output_len": 900, - "ignore_eos": true, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [203, 283, 470, 869], - "mean_tpot_ms": [28, 36, 41, 45], - "mean_audio_ttfp_ms": [861, 1200, 1528, 2100], - "mean_audio_rtf": [0.24, 0.30, 0.35, 0.39] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [10], - "request_rate": [0.1], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 1, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "audio": 1 - }, - "random_mm_bucket_config": { - "(0, 60, 3)": 1.0 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [377], - "mean_audio_ttfp_ms": [684], - "mean_audio_rtf": [0.14] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [40], - "request_rate": [0.5], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 2, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.5, - "(720, 1280, 2)": 0.5 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [228], - "mean_audio_ttfp_ms": [686], - "mean_audio_rtf": [0.16] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [100], - "request_rate": [1.0], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 3, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1, - "audio": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.34, - "(720, 1280, 2)": 0.33, - "(0, 60, 3)": 0.33 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [522], - "mean_audio_ttfp_ms": [1119], - "mean_audio_rtf": [0.21] - } + { + "test_name": "test_qwen3_omni_3gpu_replica2", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 3 + } + }, + "full_model", + "omni" + ], + "server_params": { + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "stage_overrides": { + "1": {"num_replicas": 2, "devices": "1,2"}, + "2": {"num_replicas": 2, "devices": "1,2"} + }, + "extra_cli_args": [ + "--async-chunk" + ] + }, + "benchmark_params": [ + { + "dataset_name": "random", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [128, 128, 128, 128], + "max_concurrency": [8, 16, 24, 32], + "random_input_len": 2500, + "random_output_len": 900, + "ignore_eos": true, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "H100": { + "mean_ttft_ms": [130.5751, 278.6469, 324.7249, 911.6085], + "mean_tpot_ms": [7.9854, 11.1209, 16.1507, 20.7485], + "mean_audio_ttfp_ms": [486.486, 954.1791, 1289.6514, 2196.8275], + "mean_audio_rtf": [0.1896, 0.3962, 0.4098, 0.7322] } - ] - } + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [10], + "request_rate": [0.1], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 1, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "audio": 1 + }, + "random_mm_bucket_config": { + "(0, 60, 3)": 1.0 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "H100": { + "mean_ttft_ms": [340.5122], + "mean_audio_ttfp_ms": [463.0752], + "mean_audio_rtf": [0.1153] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [40], + "request_rate": [0.5], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 2, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.5, + "(720, 1280, 2)": 0.5 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "H100": { + "mean_ttft_ms": [236.926], + "mean_audio_ttfp_ms": [395.2182], + "mean_audio_rtf": [0.1259] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [100], + "request_rate": [1.0], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 3, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1, + "audio": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.34, + "(720, 1280, 2)": 0.33, + "(0, 60, 3)": 0.33 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "H100": { + "mean_ttft_ms": [470.6126], + "mean_audio_ttfp_ms": [665.5474], + "mean_audio_rtf": [0.166] + } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen3_omni_no_async_chunk.json b/tests/dfx/perf/tests/test_qwen3_omni_no_async_chunk.json index d01ca06b4..38dbfb2e3 100644 --- a/tests/dfx/perf/tests/test_qwen3_omni_no_async_chunk.json +++ b/tests/dfx/perf/tests/test_qwen3_omni_no_async_chunk.json @@ -1,170 +1,191 @@ [ - { - "test_name": "test_qwen3_omni", - "server_params": { - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "extra_cli_args": [ - "--no-async-chunk" + { + "test_name": "test_qwen3_omni", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100", + "npu": "A3" + }, + "num_cards": 2 + } + }, + "full_model", + "omni" + ], + "server_params": { + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "extra_cli_args": [ + "--no-async-chunk" + ] + }, + "benchmark_params": [ + { + "dataset_name": "random", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 4, + 16, + 32, + 64, + 128 + ], + "max_concurrency": [ + 1, + 4, + 8, + 16, + 32 + ], + "random_input_len": 2500, + "random_output_len": 900, + "ignore_eos": true, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 115.8348, + 173.1823, + 446.672, + 464.5985, + 3420.0634 + ], + "mean_e2el_ms": [ + 25070.9523, + 31992.0646, + 37619.7669, + 50393.8257, + 136619.2743 + ], + "mean_audio_rtf": [ + 0.169, + 0.2111, + 0.2475, + 0.3551, + 0.9266 ] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 10 + ], + "request_rate": [ + 0.1 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 1, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "audio": 1 }, - "benchmark_params": [ - { - "dataset_name": "random", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 4, - 16, - 32, - 64, - 128 - ], - "max_concurrency": [ - 1, - 4, - 8, - 16, - 32 - ], - "random_input_len": 2500, - "random_output_len": 900, - "ignore_eos": true, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 109.1218, - 247.4411, - 354.9384, - 447.7885, - 694.7487 - ], - "mean_e2el_ms": [ - 22413.667, - 30808.9715, - 35677.844, - 46345.8978, - 68350.4853 - ], - "mean_audio_rtf": [ - 0.1695, - 0.2033, - 0.2467, - 0.3234, - 0.4438 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 10 - ], - "request_rate": [ - 0.1 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 1, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "audio": 1 - }, - "random_mm_bucket_config": { - "(0, 60, 3)": 1.0 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 323.3573 - ], - "mean_e2el_ms": [ - 4623.4173 - ], - "mean_audio_rtf": [ - 0.1406 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 40 - ], - "request_rate": [ - 0.5 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 2, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.5, - "(720, 1280, 2)": 0.5 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 215.4502 - ], - "mean_e2el_ms": [ - 4924.149 - ], - "mean_audio_rtf": [ - 0.1637 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 100 - ], - "request_rate": [ - 1.0 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 3, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1, - "audio": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.34, - "(720, 1280, 2)": 0.33, - "(0, 60, 3)": 0.33 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 442.5815 - ], - "mean_e2el_ms": [ - 6441.0986 - ], - "mean_audio_rtf": [ - 0.2091 - ] - } - } - ] - } + "random_mm_bucket_config": { + "(0, 60, 3)": 1.0 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 343.1377 + ], + "mean_e2el_ms": [ + 4811.1123 + ], + "mean_audio_rtf": [ + 0.1463 + ] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 40 + ], + "request_rate": [ + 0.5 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 2, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.5, + "(720, 1280, 2)": 0.5 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 245.5446 + ], + "mean_e2el_ms": [ + 5019.1256 + ], + "mean_audio_rtf": [ + 0.1713 + ] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 100 + ], + "request_rate": [ + 1.0 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 3, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1, + "audio": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.34, + "(720, 1280, 2)": 0.33, + "(0, 60, 3)": 0.33 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 506.1407 + ], + "mean_e2el_ms": [ + 6814.9656 + ], + "mean_audio_rtf": [ + 0.2213 + ] + } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen3_omni_vllm_text.json b/tests/dfx/perf/tests/test_qwen3_omni_vllm_text.json index f608eca0e..8775ee381 100644 --- a/tests/dfx/perf/tests/test_qwen3_omni_vllm_text.json +++ b/tests/dfx/perf/tests/test_qwen3_omni_vllm_text.json @@ -1,180 +1,200 @@ [ - { - "test_name": "test_qwen3_omni_vllm_text", - "server_params": { - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "use_omni": false, - "extra_cli_args": [ - "--no-enable-prefix-caching", - "--skip-mm-profiling" - ] + { + "test_name": "test_qwen3_omni_vllm_text", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "omni" + ], + "server_params": { + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "use_omni": false, + "extra_cli_args": [ + "--no-enable-prefix-caching", + "--skip-mm-profiling" + ] + }, + "benchmark_params": [ + { + "dataset_name": "random", + "backend": "vllm", + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "endpoint": "/v1/completions", + "num_prompts": [ + 4, + 16, + 32, + 64, + 128 + ], + "max_concurrency": [ + 1, + 4, + 8, + 16, + 32 + ], + "random_input_len": 2500, + "random_output_len": 900, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] }, - "benchmark_params": [ - { - "dataset_name": "random", - "backend": "vllm", - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "endpoint": "/v1/completions", - "num_prompts": [ - 4, - 16, - 32, - 64, - 128 - ], - "max_concurrency": [ - 1, - 4, - 8, - 16, - 32 - ], - "random_input_len": 2500, - "random_output_len": 900, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 69.1276, - 255.1882, - 310.639, - 422.0337, - 532.0582 - ], - "mean_e2el_ms": [ - 4382.7211, - 7510.3815, - 9440.4854, - 12553.0186, - 16932.6065 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "vllm", - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "endpoint": "/v1/completions", - "num_prompts": [ - 10 - ], - "request_rate": [ - 0.1 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "random_mm_base_items_per_request": 1, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "audio": 1 - }, - "random_mm_bucket_config": { - "(0, 60, 3)": 1.0 - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 26.3381 - ], - "mean_e2el_ms": [ - 485.2436 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "vllm", - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "endpoint": "/v1/completions", - "num_prompts": [ - 40 - ], - "request_rate": [ - 0.5 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "random_mm_base_items_per_request": 2, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.5, - "(720, 1280, 2)": 0.5 - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 26.4517 - ], - "mean_e2el_ms": [ - 517.2017 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "vllm", - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "endpoint": "/v1/completions", - "num_prompts": [ - 100 - ], - "request_rate": [ - 1.0 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "random_mm_base_items_per_request": 3, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1, - "audio": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.34, - "(720, 1280, 2)": 0.33, - "(0, 60, 3)": 0.33 - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 27.6972 - ], - "mean_e2el_ms": [ - 561.3684 - ] - } - } - ] - } + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 69.8081, + 173.0651, + 271.8108, + 413.4079, + 497.2546 + ], + "mean_e2el_ms": [ + 4720.8225, + 7937.2532, + 9962.3566, + 13724.5479, + 18324.091 + ] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "vllm", + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "endpoint": "/v1/completions", + "num_prompts": [ + 10 + ], + "request_rate": [ + 0.1 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "random_mm_base_items_per_request": 1, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "audio": 1 + }, + "random_mm_bucket_config": { + "(0, 60, 3)": 1.0 + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 25.9477 + ], + "mean_e2el_ms": [ + 517.3155 + ] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "vllm", + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "endpoint": "/v1/completions", + "num_prompts": [ + 40 + ], + "request_rate": [ + 0.5 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "random_mm_base_items_per_request": 2, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.5, + "(720, 1280, 2)": 0.5 + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 26.7056 + ], + "mean_e2el_ms": [ + 551.308 + ] + } + } + }, + { + "dataset_name": "random-mm", + "backend": "vllm", + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "endpoint": "/v1/completions", + "num_prompts": [ + 100 + ], + "request_rate": [ + 1.0 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "random_mm_base_items_per_request": 3, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1, + "audio": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.34, + "(720, 1280, 2)": 0.33, + "(0, 60, 3)": 0.33 + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "H100": { + "mean_ttft_ms": [ + 27.6906 + ], + "mean_e2el_ms": [ + 596.5284 + ] + } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen_image_edit_2511_vllm_omni.json b/tests/dfx/perf/tests/test_qwen_image_edit_2511_vllm_omni.json index 118fa6733..dcd2188c5 100644 --- a/tests/dfx/perf/tests/test_qwen_image_edit_2511_vllm_omni.json +++ b/tests/dfx/perf/tests/test_qwen_image_edit_2511_vllm_omni.json @@ -1,149 +1,196 @@ [ - { - "test_name": "test_qwen_image_edit_2511_single_device", - "description": "Single-device baseline (two input images)", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Edit-2511", - "serve_args": { - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "512x512_steps20_i2i_2img", - "dataset": "random", - "task": "i2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 2, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0721, - "latency_mean": 13.8398, - "peak_memory_mb_max": 54731.4, - "peak_memory_mb_mean": 54731.4 - } - }, - { - "name": "1536x1536_steps35_i2i_2img", - "dataset": "random", - "task": "i2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 2, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0186, - "latency_mean": 53.6942, - "peak_memory_mb_max": 64402.4, - "peak_memory_mb_mean": 64402.4 - } - } - ] + { + "test_name": "test_qwen_image_edit_2511_single_device", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline (two input images)", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Edit-2511", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_edit_2511_ulysses2_cfg2_vae_patch4", - "description": "Ulysses SP=2 + CFG=2 + VAE patch parallel=4", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Edit-2511", - "serve_args": { - "ulysses-degree": 2, - "cfg-parallel-size": 2, - "vae-patch-parallel-size": 4, - "vae-use-tiling": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1536x1536_steps35_i2i_2img", - "dataset": "random", - "task": "i2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 2, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0538, - "latency_mean": 18.5591, - "peak_memory_mb_max": 53485.0, - "peak_memory_mb_mean": 53485.0 - } - } - ] + "benchmark_params": [ + { + "name": "512x512_steps20_i2i_2img", + "dataset": "random", + "task": "i2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 2, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.0721, + "latency_mean": 13.8398, + "peak_memory_mb_max": 57610.0, + "peak_memory_mb_mean": 57610.0 + } + } + }, + { + "name": "1536x1536_steps35_i2i_2img", + "dataset": "random", + "task": "i2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 2, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.0186, + "latency_mean": 53.6942, + "peak_memory_mb_max": 67790.0, + "peak_memory_mb_mean": 67790.0 + } + } + } + ] + }, + { + "test_name": "test_qwen_image_edit_2511_ulysses2_cfg2_vae_patch4", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100", + "npu": "A3" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "Ulysses SP=2 + CFG=2 + VAE patch parallel=4", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Edit-2511", + "serve_args": { + "ulysses-degree": 2, + "cfg-parallel-size": 2, + "vae-patch-parallel-size": 4, + "vae-use-tiling": true, + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_edit_2511_ulysses2_cfg2_cache_dit", - "description": "Ulysses SP=2 + CFG=2 + CacheDiT", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Edit-2511", - "serve_args": { - "ulysses-degree": 2, - "cfg-parallel-size": 2, - "cache-backend": "cache_dit", - "cache-config": { - "Fn_compute_blocks": 1, - "Bn_compute_blocks": 0, - "max_warmup_steps": 4, - "residual_diff_threshold": 0.24, - "max_continuous_cached_steps": 3, - "enable_taylorseer": false, - "taylorseer_order": 1, - "scm_steps_mask_policy": null, - "scm_steps_policy": "dynamic" - }, - "enable-diffusion-pipeline-profiler": true - } + "benchmark_params": [ + { + "name": "1536x1536_steps35_i2i_2img", + "dataset": "random", + "task": "i2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 2, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.0538, + "latency_mean": 18.5591, + "peak_memory_mb_max": 56449.4286, + "peak_memory_mb_mean": 56449.4286 + } + } + } + ] + }, + { + "test_name": "test_qwen_image_edit_2511_ulysses2_cfg2_cache_dit", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "Ulysses SP=2 + CFG=2 + CacheDiT", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Edit-2511", + "serve_args": { + "ulysses-degree": 2, + "cfg-parallel-size": 2, + "cache-backend": "cache_dit", + "cache-config": { + "Fn_compute_blocks": 1, + "Bn_compute_blocks": 0, + "max_warmup_steps": 4, + "residual_diff_threshold": 0.24, + "max_continuous_cached_steps": 3, + "enable_taylorseer": false, + "taylorseer_order": 1, + "scm_steps_mask_policy": null, + "scm_steps_policy": "dynamic" }, - "benchmark_params": [ - { - "name": "512x512_steps20_i2i_2img", - "dataset": "random", - "task": "i2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 2, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.2776, - "latency_mean": 3.5938, - "peak_memory_mb_max": 55214.0, - "peak_memory_mb_mean": 55214.0 - } - }, - { - "name": "1536x1536_steps35_i2i_2img", - "dataset": "random", - "task": "i2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 2, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.1051, - "latency_mean": 9.4918, - "peak_memory_mb_max": 64265.6, - "peak_memory_mb_mean": 64265.6 - } - } - ] - } + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "512x512_steps20_i2i_2img", + "dataset": "random", + "task": "i2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 2, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.2776, + "latency_mean": 3.5938, + "peak_memory_mb_max": 58113.7143, + "peak_memory_mb_mean": 58075.1429 + } + } + }, + { + "name": "1536x1536_steps35_i2i_2img", + "dataset": "random", + "task": "i2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 2, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.1051, + "latency_mean": 9.4918, + "peak_memory_mb_max": 68107.4286, + "peak_memory_mb_mean": 68107.4286 + } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen_image_edit_vllm_omni.json b/tests/dfx/perf/tests/test_qwen_image_edit_vllm_omni.json index da3288182..686bd6160 100644 --- a/tests/dfx/perf/tests/test_qwen_image_edit_vllm_omni.json +++ b/tests/dfx/perf/tests/test_qwen_image_edit_vllm_omni.json @@ -1,144 +1,190 @@ [ - { - "test_name": "test_qwen_image_edit_single_device", - "description": "Single-device baseline", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Edit", - "serve_args": { - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "512x512_steps20_i2i", - "dataset": "random", - "task": "i2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.1129, - "latency_mean": 8.8352, - "peak_memory_mb_max": 55356.5, - "peak_memory_mb_mean": 55356.5 - } - }, - { - "name": "1536x1536_steps35_i2i", - "dataset": "random", - "task": "i2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0240, - "latency_mean": 41.5686, - "peak_memory_mb_max": 62762.7, - "peak_memory_mb_mean": 62762.7 - } - } - ] + { + "test_name": "test_qwen_image_edit_single_device", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Edit", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_edit_ulysses2_cfg2_vae_patch4", - "description": "Ulysses SP=2 + CFG=2 + VAE patch parallel=4", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Edit", - "serve_args": { - "ulysses-degree": 2, - "cfg-parallel-size": 2, - "vae-patch-parallel-size": 4, - "vae-use-tiling": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1536x1536_steps35_i2i", - "dataset": "random", - "task": "i2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0672, - "latency_mean": 14.8517, - "peak_memory_mb_max": 53931.5, - "peak_memory_mb_mean": 53931.5 - } - } - ] + "benchmark_params": [ + { + "name": "512x512_steps20_i2i", + "dataset": "random", + "task": "i2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.1129, + "latency_mean": 8.8352, + "peak_memory_mb_max": 58572.0, + "peak_memory_mb_mean": 58572.0 + } + } + }, + { + "name": "1536x1536_steps35_i2i", + "dataset": "random", + "task": "i2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.024, + "latency_mean": 41.5686, + "peak_memory_mb_max": 66368.0, + "peak_memory_mb_mean": 66368.0 + } + } + } + ] + }, + { + "test_name": "test_qwen_image_edit_ulysses2_cfg2_vae_patch4", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "Ulysses SP=2 + CFG=2 + VAE patch parallel=4", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Edit", + "serve_args": { + "ulysses-degree": 2, + "cfg-parallel-size": 2, + "vae-patch-parallel-size": 4, + "vae-use-tiling": true, + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_edit_ulysses2_cfg2_cache_dit", - "description": "Ulysses SP=2 + CFG=2 + CacheDiT", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Edit", - "serve_args": { - "ulysses-degree": 2, - "cfg-parallel-size": 2, - "cache-backend": "cache_dit", - "cache-config": { - "Fn_compute_blocks": 1, - "Bn_compute_blocks": 0, - "max_warmup_steps": 4, - "residual_diff_threshold": 0.24, - "max_continuous_cached_steps": 3, - "enable_taylorseer": false, - "taylorseer_order": 1, - "scm_steps_mask_policy": null, - "scm_steps_policy": "dynamic" - }, - "enable-diffusion-pipeline-profiler": true - } + "benchmark_params": [ + { + "name": "1536x1536_steps35_i2i", + "dataset": "random", + "task": "i2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.0672, + "latency_mean": 14.8517, + "peak_memory_mb_max": 57076.0, + "peak_memory_mb_mean": 57076.0 + } + } + } + ] + }, + { + "test_name": "test_qwen_image_edit_ulysses2_cfg2_cache_dit", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "Ulysses SP=2 + CFG=2 + CacheDiT", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Edit", + "serve_args": { + "ulysses-degree": 2, + "cfg-parallel-size": 2, + "cache-backend": "cache_dit", + "cache-config": { + "Fn_compute_blocks": 1, + "Bn_compute_blocks": 0, + "max_warmup_steps": 4, + "residual_diff_threshold": 0.24, + "max_continuous_cached_steps": 3, + "enable_taylorseer": false, + "taylorseer_order": 1, + "scm_steps_mask_policy": null, + "scm_steps_policy": "dynamic" }, - "benchmark_params": [ - { - "name": "512x512_steps20_i2i", - "dataset": "random", - "task": "i2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.2879, - "latency_mean": 3.4701, - "peak_memory_mb_max": 55525.6, - "peak_memory_mb_mean": 55525.6 - } - }, - { - "name": "1536x1536_steps35_i2i", - "dataset": "random", - "task": "i2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.1113, - "latency_mean": 8.9656, - "peak_memory_mb_max": 63758.3, - "peak_memory_mb_mean": 63758.3 - } - } - ] - } + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "512x512_steps20_i2i", + "dataset": "random", + "task": "i2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.2879, + "latency_mean": 3.4701, + "peak_memory_mb_max": 58758.0, + "peak_memory_mb_mean": 58758.0 + } + } + }, + { + "name": "1536x1536_steps35_i2i", + "dataset": "random", + "task": "i2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.1113, + "latency_mean": 8.9656, + "peak_memory_mb_max": 67424.0, + "peak_memory_mb_mean": 67424.0 + } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json b/tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json index a94db56f5..bc4d93a24 100644 --- a/tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json +++ b/tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json @@ -1,49 +1,65 @@ [ - { - "test_name": "test_qwen_image_layered_single_device", - "description": "Single-device baseline", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Layered", - "serve_args": { - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "640x640_steps20_i2i", - "dataset": "random", - "task": "i2i", - "width": 640, - "height": 640, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0703, - "latency_mean": 14.208, - "peak_memory_mb_max": 60543.5, - "peak_memory_mb_mean": 60543.5 - } - }, - { - "name": "1024x1024_steps35_i2i", - "dataset": "random", - "task": "i2i", - "width": 1024, - "height": 1024, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0417, - "latency_mean": 23.914, - "peak_memory_mb_max": 60543.5, - "peak_memory_mb_mean": 60543.5 - } - } - ] - } + { + "test_name": "test_qwen_image_layered_single_device", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Layered", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "640x640_steps20_i2i", + "dataset": "random", + "task": "i2i", + "width": 640, + "height": 640, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.0703, + "latency_mean": 14.208, + "peak_memory_mb_max": 63225.1429, + "peak_memory_mb_mean": 63225.1429 + } + } + }, + { + "name": "1024x1024_steps35_i2i", + "dataset": "random", + "task": "i2i", + "width": 1024, + "height": 1024, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.0417, + "latency_mean": 23.914, + "peak_memory_mb_max": 63225.1429, + "peak_memory_mb_mean": 63225.1429 + } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen_image_vllm_omni.json b/tests/dfx/perf/tests/test_qwen_image_vllm_omni.json index 5ca6ed61a..ebf9ccc29 100644 --- a/tests/dfx/perf/tests/test_qwen_image_vllm_omni.json +++ b/tests/dfx/perf/tests/test_qwen_image_vllm_omni.json @@ -1,219 +1,295 @@ [ - { - "test_name": "test_qwen_image_single_device", - "description": "Single-device baseline (no parallelism)", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image", - "serve_args": { - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "512x512_steps20", - "dataset": "random", - "task": "t2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.4153, - "latency_mean": 2.4065, - "peak_memory_mb_mean": 52289.9 - } - }, - { - "name": "1536x1536_steps35", - "dataset": "random", - "task": "t2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0423, - "latency_mean": 23.5991, - "peak_memory_mb_mean": 61360.5 - } - } - ] + { + "test_name": "test_qwen_image_single_device", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline (no parallelism)", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_single_device_step_execution", - "description": "Single-device baseline (no parallelism) with step execution", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image", - "serve_args": { - "enable-diffusion-pipeline-profiler": true, - "step-execution": true - } - }, - "benchmark_params": [ - { - "name": "512x512_steps20", - "dataset": "random", - "task": "t2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.4175, - "latency_mean": 2.3953, - "peak_memory_mb_mean": 56000 - } - }, - { - "name": "1536x1536_steps35", - "dataset": "random", - "task": "t2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0423, - "latency_mean": 23.5768, - "peak_memory_mb_mean": 65000 - } - } - ] + "benchmark_params": [ + { + "name": "512x512_steps20", + "dataset": "random", + "task": "t2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.4153, + "latency_mean": 2.4065, + "peak_memory_mb_mean": 55121.4286 + } + } + }, + { + "name": "1536x1536_steps35", + "dataset": "random", + "task": "t2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.0423, + "latency_mean": 23.5991, + "peak_memory_mb_mean": 65118.0 + } + } + } + ] + }, + { + "test_name": "test_qwen_image_single_device_step_execution", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline (no parallelism) with step execution", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image", + "serve_args": { + "enable-diffusion-pipeline-profiler": true, + "step-execution": true + } }, - { - "test_name": "test_qwen_image_ulysses2_cfg2_vae_patch4", - "description": "Ulysses SP=2 + CFG-parallel=2 + VAE Patch Parallel=4", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image", - "serve_args": { - "ulysses-degree": 2, - "cfg-parallel-size": 2, - "vae-patch-parallel-size": 4, - "vae-use-tiling": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1536x1536_steps35", - "dataset": "random", - "task": "t2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.1214, - "latency_mean": 8.2211, - "peak_memory_mb_mean": 51704.7 - } - } - ] + "benchmark_params": [ + { + "name": "512x512_steps20", + "dataset": "random", + "task": "t2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.4175, + "latency_mean": 2.3953, + "peak_memory_mb_mean": 55088.5714 + } + } + }, + { + "name": "1536x1536_steps35", + "dataset": "random", + "task": "t2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.0423, + "latency_mean": 23.5768, + "peak_memory_mb_mean": 66012.2857 + } + } + } + ] + }, + { + "test_name": "test_qwen_image_ulysses2_cfg2_vae_patch4", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "Ulysses SP=2 + CFG-parallel=2 + VAE Patch Parallel=4", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image", + "serve_args": { + "ulysses-degree": 2, + "cfg-parallel-size": 2, + "vae-patch-parallel-size": 4, + "vae-use-tiling": true, + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_ulysses2_cfg2_cache_dit", - "description": "Ulysses SP=2 + CFG-parallel=2 + CacheDiT acceleration", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image", - "serve_args": { - "ulysses-degree": 2, - "cfg-parallel-size": 2, - "cache-backend": "cache_dit", - "cache-config": { - "Fn_compute_blocks": 1, - "Bn_compute_blocks": 0, - "max_warmup_steps": 4, - "residual_diff_threshold": 0.24, - "max_continuous_cached_steps": 3, - "enable_taylorseer": false, - "taylorseer_order": 1, - "scm_steps_mask_policy": null, - "scm_steps_policy": "dynamic" - }, - "enable-diffusion-pipeline-profiler": true - } + "benchmark_params": [ + { + "name": "1536x1536_steps35", + "dataset": "random", + "task": "t2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.1214, + "latency_mean": 8.2211, + "peak_memory_mb_mean": 54501.1429 + } + } + } + ] + }, + { + "test_name": "test_qwen_image_ulysses2_cfg2_cache_dit", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "Ulysses SP=2 + CFG-parallel=2 + CacheDiT acceleration", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image", + "serve_args": { + "ulysses-degree": 2, + "cfg-parallel-size": 2, + "cache-backend": "cache_dit", + "cache-config": { + "Fn_compute_blocks": 1, + "Bn_compute_blocks": 0, + "max_warmup_steps": 4, + "residual_diff_threshold": 0.24, + "max_continuous_cached_steps": 3, + "enable_taylorseer": false, + "taylorseer_order": 1, + "scm_steps_mask_policy": null, + "scm_steps_policy": "dynamic" }, - "benchmark_params": [ - { - "name": "512x512_steps20", - "dataset": "random", - "task": "t2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.559, - "latency_mean": 1.794, - "peak_memory_mb_mean": 52397.6775 - } - }, - { - "name": "1536x1536_steps35", - "dataset": "random", - "task": "t2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.1918, - "latency_mean": 5.2022, - "peak_memory_mb_mean": 61468.8 - } - } - ] + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_single_device_step_execution_high_concurrency", - "description": "Single-device Qwen-Image step-level execution benchmark at 512x512, 20 steps, concurrency sweep", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image", - "serve_args": { - "enable-diffusion-pipeline-profiler": true, - "step-execution": true, - "max-num-seqs": 8 + "benchmark_params": [ + { + "name": "512x512_steps20", + "dataset": "random", + "task": "t2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.559, + "latency_mean": 1.794, + "peak_memory_mb_mean": 55290.5714 + } + } + }, + { + "name": "1536x1536_steps35", + "dataset": "random", + "task": "t2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": 0.1918, + "latency_mean": 5.2022, + "peak_memory_mb_mean": 64838.8571 + } + } + } + ] + }, + { + "test_name": "test_qwen_image_single_device_step_execution_high_concurrency", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device Qwen-Image step-level execution benchmark at 512x512, 20 steps, concurrency sweep", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image", + "serve_args": { + "enable-diffusion-pipeline-profiler": true, + "step-execution": true, + "max-num-seqs": 8 + } + }, + "benchmark_params": [ + { + "name": "512x512_steps20_high_concurrency", + "dataset": "random", + "task": "t2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": [20, 40, 80, 160], + "max-concurrency": [1, 2, 4, 8], + "warmup-requests": 8, + "warmup-concurrency": 8, + "warmup-num-inference-steps": 20, + "enable-negative-prompt": true, + "baseline": { + "H100": { + "throughput_qps": [0.4125, 0.6714, 0.6674, 0.8159], + "latency_mean": [2.4215, 2.992, 5.9293, 9.656], + "peak_memory_mb_mean": [55147.4286, 55148.0, 55150.4643, 55152.3821] } - }, - "benchmark_params": [ - { - "name": "512x512_steps20_high_concurrency", - "dataset": "random", - "task": "t2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": [20, 40, 80, 160], - "max-concurrency": [1, 2, 4, 8], - "warmup-requests": 8, - "warmup-concurrency": 8, - "warmup-num-inference-steps": 20, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": [0.4125, 0.6714, 0.6674, 0.8159], - "latency_mean": [2.4215, 2.992, 5.9293, 9.656], - "peak_memory_mb_mean": [67000, 67000, 67000, 67000] - } - } - ] - } + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_runner_metadata.py b/tests/dfx/perf/tests/test_runner_metadata.py index a195c4546..81035a272 100644 --- a/tests/dfx/perf/tests/test_runner_metadata.py +++ b/tests/dfx/perf/tests/test_runner_metadata.py @@ -1,6 +1,8 @@ """Tests for DFX runner metadata field exclusion.""" import json +from contextlib import contextmanager +from types import SimpleNamespace import pytest @@ -15,7 +17,7 @@ def test_task_excluded_from_cli_args(): "backend": "openai-audio-speech", "endpoint": "/v1/audio/speech", "percentile-metrics": "audio_rtf,audio_ttfp", - "baseline": {"mean_audio_rtf": [0.5]}, + "baseline": {"H100": {"mean_audio_rtf": [0.5]}}, } exclude_keys = { "request_rate", @@ -86,3 +88,400 @@ def test_enabled_false_entry_is_skipped(): # Only the enabled=True entry should appear assert len(params) == 1 assert params[0].get("task") == "voice_clone" + + +def test_resolve_pytest_marks_hardware_dict_with_extra(): + from tests.dfx.conftest import resolve_pytest_marks + + marks = resolve_pytest_marks( + [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 2}}, + "full_model", + "diffusion", + "local_model", + ] + ) + names = {m.name for m in marks} + assert "H100" in names + assert "cuda" in names + assert "gpu" in names + assert "distributed_cuda" in names + assert "full_model" in names + assert "diffusion" in names + assert "local_model" in names + + +def test_resolve_pytest_marks_rejects_legacy_object_format(): + from tests.dfx.conftest import resolve_pytest_marks + + with pytest.raises(ValueError, match="mark must be a list"): + resolve_pytest_marks( + { + "hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}, + "marks": ["full_model"], + } + ) + + +def test_resource_label_for_filename(): + from tests.dfx.conftest import resource_label_for_filename + + assert resource_label_for_filename("H100") == "" + assert resource_label_for_filename("L4") == "L4" + assert resource_label_for_filename("910B") == "910B" + assert resource_label_for_filename("na") == "na" + + +def test_hardware_json_value(): + from tests.dfx.conftest import hardware_json_value + + assert hardware_json_value("H100") == "H100" + assert hardware_json_value("na") == "" + assert hardware_json_value(None) == "" + + +def test_get_runtime_resource_label(monkeypatch): + from tests.dfx.conftest import get_runtime_resource_label + + monkeypatch.setattr( + "tests.dfx.conftest._read_runtime_device_name", + lambda *, device_id=0: "NVIDIA H100 80GB HBM3", + ) + assert get_runtime_resource_label(refresh=True) == "H100" + monkeypatch.setattr( + "tests.dfx.conftest._read_runtime_device_name", + lambda *, device_id=0: "Ascend910B2", + ) + assert get_runtime_resource_label(refresh=True) == "910B" + + +def test_load_benchmark_configs_from_dir(tmp_path): + from tests.dfx.conftest import load_benchmark_configs + + (tmp_path / "a.json").write_text( + json.dumps([{"test_name": "test_a", "server_params": {"model": "m/a"}, "benchmark_params": []}]), + encoding="utf-8", + ) + (tmp_path / "b.json").write_text( + json.dumps([{"test_name": "test_b", "server_params": {"model": "m/b"}, "benchmark_params": []}]), + encoding="utf-8", + ) + configs = load_benchmark_configs(config_dir=tmp_path) + assert [c["test_name"] for c in configs] == ["test_a", "test_b"] + + +def test_create_unique_server_pytest_params_applies_marks(tmp_path): + from tests.dfx.conftest import create_unique_server_pytest_params + + configs = [ + { + "test_name": "test_with_mark", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, + "full_model", + ], + "server_params": {"model": "some/model"}, + "benchmark_params": [{"name": "p0", "num_prompts": 1}], + }, + { + "test_name": "test_without_mark", + "server_params": {"model": "other/model"}, + "benchmark_params": [{"name": "p0", "num_prompts": 1}], + }, + ] + params = create_unique_server_pytest_params(configs, tmp_path) + by_id = {p.id: p for p in params} + assert len(by_id["test_with_mark"].values) == 1 + assert isinstance(by_id["test_with_mark"].values[0], tuple) + assert any(m.name == "H100" for m in by_id["test_with_mark"].marks) + assert not any(m.name == "H100" for m in by_id["test_without_mark"].marks) + + +def test_is_diffusion_perf_config(): + from tests.dfx.conftest import is_diffusion_perf_config + + assert not is_diffusion_perf_config( + {"test_name": "omni_a", "mark": [{"hardware_marks": {"res": {"cuda": "H100"}}}, "omni"]} + ) + assert is_diffusion_perf_config( + { + "test_name": "diff_a", + "server_type": "vllm-omni", + "mark": [{"hardware_marks": {"res": {"cuda": "H100"}}}, "diffusion"], + } + ) + + +def test_benchmark_param_id_suffix_from_task_eval_phase(): + from tests.dfx.conftest import _unique_benchmark_param_id_suffixes + + params = [ + {"task": "default_voice", "eval_phase": "latency"}, + {"task": "default_voice", "eval_phase": "throughput"}, + ] + assert _unique_benchmark_param_id_suffixes(params) == [ + "default_voice_latency", + "default_voice_throughput", + ] + + +def test_paired_omni_benchmark_reuses_server_and_preserves_case_metadata(tmp_path, monkeypatch): + from tests.dfx.conftest import create_paired_omni_benchmark_pytest_params + from tests.dfx.perf.scripts import run_benchmark + + configs = [ + { + "test_name": "test_omni", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, + "omni", + ], + "server_params": {"model": "m/omni"}, + "benchmark_params": [{"name": "p0", "num_prompts": 1}, {"name": "p1", "num_prompts": 2}], + }, + { + "test_name": "test_tts", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, + "tts", + ], + "server_params": {"model": "m/tts"}, + "benchmark_params": [{"name": "p0", "num_prompts": 1}], + }, + ] + params = create_paired_omni_benchmark_pytest_params(configs, tmp_path) + by_id = {p.id: p for p in params} + assert set(by_id) == {"test_omni-p0", "test_omni-p1", "test_tts-p0"} + omni_row, bench_row = by_id["test_tts-p0"].values + assert omni_row[0] == "test_tts" + assert bench_row == ("test_tts", 0) + assert any(m.name == "tts" for m in by_id["test_tts-p0"].marks) + assert not any(m.name == "tts" for m in by_id["test_omni-p0"].marks) + + omni_p0_server = by_id["test_omni-p0"].values[0] + omni_p1_server = by_id["test_omni-p1"].values[0] + tts_server = by_id["test_tts-p0"].values[0] + assert omni_p0_server == omni_p1_server + assert omni_p0_server != tts_server + + events = [] + + @contextmanager + def fake_start(server_param): + events.append(("start", server_param)) + yield object() + events.append(("stop", server_param)) + + monkeypatch.setattr(run_benchmark, "_start_omni_server", fake_start) + active_context = run_benchmark._SingleActiveContext() + try: + first = run_benchmark.omni_server.__wrapped__( + SimpleNamespace(param=omni_p0_server), + active_context, + ) + second = run_benchmark.omni_server.__wrapped__( + SimpleNamespace(param=omni_p1_server), + active_context, + ) + assert first is second + assert events == [("start", omni_p0_server)] + + third = run_benchmark.omni_server.__wrapped__( + SimpleNamespace(param=tts_server), + active_context, + ) + assert third is not first + assert events == [ + ("start", omni_p0_server), + ("stop", omni_p0_server), + ("start", tts_server), + ] + finally: + active_context.close() + + assert events == [ + ("start", omni_p0_server), + ("stop", omni_p0_server), + ("start", tts_server), + ("stop", tts_server), + ] + + +def test_is_hardware_nested_baseline(): + from tests.dfx.conftest import ( + _RUNTIME_DEVICE_ALIASES, + is_hardware_nested_baseline, + ) + from tests.helpers.mark import get_hardware_mark_list + + hardware_marks = get_hardware_mark_list() + assert hardware_marks + assert {"H100", "L4", "A3", "MI325", "B200"} <= hardware_marks + assert "cuda" not in hardware_marks + assert "full_model" not in hardware_marks + + # Runtime aliases are a full static list (independent of baseline allowlist). + assert "H100" in _RUNTIME_DEVICE_ALIASES + assert "A100" in _RUNTIME_DEVICE_ALIASES + + assert is_hardware_nested_baseline( + { + "H100": {"mean_ttft_ms": [1.0, 2.0], "mean_e2el_ms": [10.0, 20.0]}, + "A3": {"mean_ttft_ms": [0.9, 1.8], "mean_e2el_ms": [9.0, 18.0]}, + } + ) + # Custom metric names under a known hardware label are allowed. + assert is_hardware_nested_baseline({"H100": {"custom_stage_ms": 12.0, "foo_bar": [1.0, 2.0]}}) + assert is_hardware_nested_baseline({"A3": {"request_throughput": 0.5}}) + # Flat metric maps / concurrency-keyed maps are not hardware-nested. + assert not is_hardware_nested_baseline({"mean_ttft_ms": [1.0, 2.0], "mean_e2el_ms": [10.0, 20.0]}) + assert not is_hardware_nested_baseline({"mean_ttft_ms": {"1": 1.0, "32": 2.0}}) + assert not is_hardware_nested_baseline({}) + # Alias-only labels (not [hardware-resource] markers) are rejected for baselines. + assert not is_hardware_nested_baseline({"A100": {"throughput_qps": 1.0}}) + # Unknown top-level hardware label is rejected. + assert not is_hardware_nested_baseline({"UnknownGPU": {"throughput_qps": 1.0}}) + # Empty per-hardware metric map is rejected. + assert not is_hardware_nested_baseline({"H100": {}}) + + +def test_resolve_baseline_for_sweep_keeps_all_hardware_for_one_concurrency(): + from tests.dfx.conftest import resolve_baseline_for_sweep + + baseline = { + "H100": { + "mean_ttft_ms": [96.4, 140.8, 271.9, 362.3, 507.8], + "mean_e2el_ms": [18507.0, 28365.0, 31907.0, 48161.0, 72630.0], + }, + "A3": { + "mean_ttft_ms": [90.0, 130.0, 250.0, 340.0, 480.0], + "mean_e2el_ms": [17000.0, 26000.0, 30000.0, 45000.0, 70000.0], + }, + } + # max_concurrency=[1,4,8,16,32] -> index 4 is concurrency 32 + got = resolve_baseline_for_sweep(baseline, sweep_index=4) + assert got == { + "H100": {"mean_ttft_ms": 507.8, "mean_e2el_ms": 72630.0}, + "A3": {"mean_ttft_ms": 480.0, "mean_e2el_ms": 70000.0}, + } + # First sweep step keeps both hardware buckets too. + got0 = resolve_baseline_for_sweep(baseline, sweep_index=0) + assert set(got0) == {"H100", "A3"} + assert got0["H100"]["mean_ttft_ms"] == 96.4 + assert got0["A3"]["mean_ttft_ms"] == 90.0 + + +def test_resolve_baseline_for_sweep_rejects_flat_baseline(): + from tests.dfx.conftest import resolve_baseline_for_sweep + + with pytest.raises(ValueError, match=r"hardware-nested.*pyproject\.toml"): + resolve_baseline_for_sweep( + {"throughput_qps": [0.4, 0.6], "latency_mean": [1.0, 2.0]}, + sweep_index=1, + ) + + +def test_resolve_baseline_for_sweep_rejects_unknown_hardware_label(): + from tests.dfx.conftest import resolve_baseline_for_sweep + + with pytest.raises(ValueError, match=r"Unknown hardware label\(s\): \['A100'\].*pyproject\.toml"): + resolve_baseline_for_sweep( + {"A100": {"throughput_qps": 1.0}}, + sweep_index=0, + ) + + +def test_resolve_baseline_for_sweep_supports_list_and_scalar_under_hardware(): + from tests.dfx.conftest import resolve_baseline_for_sweep + + # Sweep-aligned lists under each hardware bucket (canonical form). + listed = { + "H100": {"throughput_qps": [0.4, 0.6, 0.8], "latency_mean": [1.0, 2.0, 3.0]}, + "A3": {"throughput_qps": [0.5, 0.7, 0.9], "latency_mean": [0.9, 1.8, 2.7]}, + } + assert resolve_baseline_for_sweep(listed, sweep_index=1) == { + "H100": {"throughput_qps": 0.6, "latency_mean": 2.0}, + "A3": {"throughput_qps": 0.7, "latency_mean": 1.8}, + } + + # Scalars under hardware stay as-is (single-concurrency cases). + scalar = {"H100": {"throughput_qps": 0.5}} + assert resolve_baseline_for_sweep(scalar, sweep_index=0) == {"H100": {"throughput_qps": 0.5}} + # Custom metric names are preserved. + custom = {"H100": {"custom_stage_ms": [10.0, 20.0]}} + assert resolve_baseline_for_sweep(custom, sweep_index=1) == {"H100": {"custom_stage_ms": 20.0}} + assert resolve_baseline_for_sweep(None) == {} + assert resolve_baseline_for_sweep({}) == {} + + +def test_resolve_baseline_value_errors(): + from tests.dfx.conftest import resolve_baseline_value + + with pytest.raises(ValueError, match="sweep_index"): + resolve_baseline_value([1.0, 2.0], sweep_index=None) + with pytest.raises(IndexError): + resolve_baseline_value([1.0], sweep_index=1) + with pytest.raises(TypeError, match="not supported"): + resolve_baseline_value({"1": 0.4}, sweep_index=0) + + +def test_diffusion_build_run_params_resolves_baseline_per_sweep(tmp_path, monkeypatch): + """``_build_run_params`` / ``_iter_sweep_runs`` narrow list baselines per concurrency.""" + import importlib + import sys + + cfg = tmp_path / "mini_diffusion_perf.json" + cfg.write_text( + json.dumps( + [ + { + "test_name": "test_mini", + "server_type": "vllm-omni", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, + "diffusion", + "full_model", + ], + "server_params": {"model": "m/mini"}, + "benchmark_params": [{"name": "p0", "num-prompts": 1, "max-concurrency": 1}], + } + ] + ), + encoding="utf-8", + ) + monkeypatch.setattr(sys, "argv", ["pytest", "--test-config-file", str(cfg)]) + sys.modules.pop("tests.dfx.perf.scripts.run_diffusion_benchmark", None) + from tests.dfx.perf.scripts import run_diffusion_benchmark as rdb + + importlib.reload(rdb) + + params = { + "name": "c_sweep", + "dataset": "random", + "task": "t2i", + "num-prompts": [8, 16, 32], + "max-concurrency": [1, 8, 32], + "baseline": { + "H100": {"throughput_qps": [0.1, 0.2, 0.3], "latency_mean": [10.0, 20.0, 30.0]}, + "A3": {"throughput_qps": [0.11, 0.22, 0.33], "latency_mean": [9.0, 19.0, 29.0]}, + }, + } + run32 = rdb._build_run_params( + params, + num_prompts=32, + max_concurrency=32, + request_rate="inf", + sweep_index=2, + ) + assert run32["max-concurrency"] == 32 + assert run32["baseline"] == { + "H100": {"throughput_qps": 0.3, "latency_mean": 30.0}, + "A3": {"throughput_qps": 0.33, "latency_mean": 29.0}, + } + + sweeps = rdb._iter_sweep_runs(params) + assert len(sweeps) == 3 + assert sweeps[2]["params"]["max-concurrency"] == 32 + assert sweeps[2]["params"]["baseline"]["H100"]["throughput_qps"] == 0.3 + assert "A3" in sweeps[2]["params"]["baseline"] + assert not isinstance(sweeps[2]["params"]["baseline"]["H100"]["throughput_qps"], list) diff --git a/tests/dfx/perf/tests/test_tts.json b/tests/dfx/perf/tests/test_tts.json index 1ba273a2c..f398e3271 100644 --- a/tests/dfx/perf/tests/test_tts.json +++ b/tests/dfx/perf/tests/test_tts.json @@ -1,6 +1,19 @@ [ { "test_name": "test_qwen3_tts_base", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100", + "npu": "A3" + }, + "num_cards": 2 + } + }, + "full_model", + "tts" + ], "server_params": { "model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base", "stage_overrides": { @@ -26,12 +39,14 @@ "seed_tts_locale": "en", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 139.9434 - ], - "median_audio_rtf": [ - 0.1683 - ] + "H100": { + "median_audio_ttfp_ms": [ + 141.2791 + ], + "median_audio_rtf": [ + 0.1591 + ] + } } }, { @@ -54,21 +69,23 @@ "seed_tts_locale": "en", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 227.5653, - 647.1272, - 4777.2714 - ], - "median_audio_rtf": [ - 0.3313, - 0.3642, - 1.2282 - ], - "audio_throughput": [ - 23.501, - 40.634, - 43.0758 - ] + "H100": { + "median_audio_ttfp_ms": [ + 226.747, + 647.1272, + 4777.2714 + ], + "median_audio_rtf": [ + 0.3313, + 0.3642, + 1.2282 + ], + "audio_throughput": [ + 24.2296, + 40.3552, + 43.2572 + ] + } } }, { @@ -89,15 +106,29 @@ "seed_tts_wer_eval": true, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "mean_audio_rtf": [ - 0.45 - ] + "H100": { + "mean_audio_rtf": [ + 0.45 + ] + } } } ] }, { "test_name": "test_qwen3_tts_customvoice", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "tts" + ], "server_params": { "model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", "stage_overrides": { @@ -128,12 +159,14 @@ }, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 49.7908 - ], - "median_audio_rtf": [ - 0.1425 - ] + "H100": { + "median_audio_ttfp_ms": [ + 46.8434 + ], + "median_audio_rtf": [ + 0.1367 + ] + } } }, { @@ -161,21 +194,23 @@ }, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 75.3341, - 713.7727, - 5941.7179 - ], - "median_audio_rtf": [ - 0.1859, - 0.3148, - 1.1662 - ], - "audio_throughput": [ - 36.465, - 49.6268, - 48.3091 - ] + "H100": { + "median_audio_ttfp_ms": [ + 75.3341, + 713.7727, + 5941.7179 + ], + "median_audio_rtf": [ + 0.1859, + 0.3148, + 1.1662 + ], + "audio_throughput": [ + 38.3858, + 55.3523, + 68.7032 + ] + } } }, { @@ -201,9 +236,11 @@ "seed_tts_wer_eval": true, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "mean_audio_rtf": [ - 0.35 - ] + "H100": { + "mean_audio_rtf": [ + 0.35 + ] + } } }, { @@ -227,15 +264,17 @@ }, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 56.6977 - ], - "median_audio_rtf": [ - 0.1555 - ], - "audio_throughput": [ - 11.5292 - ] + "H100": { + "median_audio_ttfp_ms": [ + 56.746 + ], + "median_audio_rtf": [ + 0.1571 + ], + "audio_throughput": [ + 11.1226 + ] + } } }, { @@ -258,12 +297,14 @@ }, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 52.0447 - ], - "median_audio_rtf": [ - 0.1452 - ] + "H100": { + "median_audio_ttfp_ms": [ + 46.3479 + ], + "median_audio_rtf": [ + 0.1352 + ] + } } }, { @@ -290,21 +331,23 @@ }, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 75.5967, - 587.5877, - 4960.3726 - ], - "median_audio_rtf": [ - 0.1871, - 0.3143, - 1.1703 - ], - "audio_throughput": [ - 40.2198, - 48.9046, - 48.0505 - ] + "H100": { + "median_audio_ttfp_ms": [ + 75.5967, + 587.5877, + 4960.3726 + ], + "median_audio_rtf": [ + 0.1871, + 0.3143, + 1.1703 + ], + "audio_throughput": [ + 38.9378, + 55.1846, + 71.5708 + ] + } } }, { @@ -327,21 +370,35 @@ }, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 56.3961 - ], - "median_audio_rtf": [ - 0.1542 - ], - "audio_throughput": [ - 9.6672 - ] + "H100": { + "median_audio_ttfp_ms": [ + 55.5029 + ], + "median_audio_rtf": [ + 0.1508 + ], + "audio_throughput": [ + 9.4885 + ] + } } } ] }, { "test_name": "test_voxtral_tts", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "tts" + ], "server_params": { "model": "mistralai/Voxtral-4B-TTS-2603" }, @@ -365,12 +422,14 @@ }, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 150 - ], - "median_audio_rtf": [ - 0.15 - ] + "H100": { + "median_audio_ttfp_ms": [ + 82.1886 + ], + "median_audio_rtf": [ + 0.1241 + ] + } } }, { @@ -392,15 +451,17 @@ }, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 1500 - ], - "median_audio_rtf": [ - 0.3 - ], - "audio_throughput": [ - 30.0 - ] + "H100": { + "median_audio_ttfp_ms": [ + 160.9168 + ], + "median_audio_rtf": [ + 0.1907 + ], + "audio_throughput": [ + 39.2332 + ] + } } }, { @@ -422,15 +483,17 @@ }, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 3000 - ], - "median_audio_rtf": [ - 0.25 - ], - "audio_throughput": [ - 4.0 - ] + "H100": { + "median_audio_ttfp_ms": [ + 100.1265 + ], + "median_audio_rtf": [ + 0.1437 + ], + "audio_throughput": [ + 11.7605 + ] + } } } ] diff --git a/tests/dfx/perf/tests/test_voxcpm2.json b/tests/dfx/perf/tests/test_voxcpm2.json index 5c99f2596..e5163e5bb 100644 --- a/tests/dfx/perf/tests/test_voxcpm2.json +++ b/tests/dfx/perf/tests/test_voxcpm2.json @@ -1,6 +1,18 @@ [ { "test_name": "test_voxcpm2", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "tts" + ], "server_params": { "model": "openbmb/VoxCPM2", "trust_remote_code": true, @@ -25,12 +37,14 @@ "seed_tts_locale": "en", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 130.2115 - ], - "median_audio_rtf": [ - 0.0488 - ] + "H100": { + "median_audio_ttfp_ms": [ + 137.645 + ], + "median_audio_rtf": [ + 0.0497 + ] + } } }, { @@ -49,15 +63,17 @@ "seed_tts_locale": "en", "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 475.3357 - ], - "median_audio_rtf": [ - 0.1671 - ], - "audio_throughput": [ - 22.8717 - ] + "H100": { + "median_audio_ttfp_ms": [ + 475.3357 + ], + "median_audio_rtf": [ + 0.1243 + ], + "audio_throughput": [ + 36.6358 + ] + } } }, { @@ -78,9 +94,11 @@ "seed_tts_wer_eval": true, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "mean_audio_rtf": [ - 0.45 - ] + "H100": { + "mean_audio_rtf": [ + 0.45 + ] + } } }, { @@ -102,12 +120,14 @@ }, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 75.7427 - ], - "median_audio_rtf": [ - 0.0419 - ] + "H100": { + "median_audio_ttfp_ms": [ + 79.6927 + ], + "median_audio_rtf": [ + 0.0423 + ] + } } }, { @@ -129,15 +149,17 @@ }, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 351.5855 - ], - "median_audio_rtf": [ - 0.1207 - ], - "audio_throughput": [ - 73.7632 - ] + "H100": { + "median_audio_ttfp_ms": [ + 262.6212 + ], + "median_audio_rtf": [ + 0.0753 + ], + "audio_throughput": [ + 103.5843 + ] + } } }, { @@ -159,15 +181,17 @@ }, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "median_audio_ttfp_ms": [ - 80.1095 - ], - "median_audio_rtf": [ - 0.0485 - ], - "audio_throughput": [ - 18.3715 - ] + "H100": { + "median_audio_ttfp_ms": [ + 80.1095 + ], + "median_audio_rtf": [ + 0.0479 + ], + "audio_throughput": [ + 17.2918 + ] + } } }, { @@ -191,9 +215,11 @@ "seed_tts_wer_eval": true, "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "mean_audio_rtf": [ - 0.35 - ] + "H100": { + "mean_audio_rtf": [ + 0.35 + ] + } } } ] diff --git a/tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json b/tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json index 07873a64b..5cea0c013 100644 --- a/tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json +++ b/tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json @@ -1,107 +1,138 @@ [ - { - "test_name": "test_wan22_i2v_single_device", - "description": "Single-device baseline", - "server_type": "vllm-omni", - "server_params": { - "model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers", - "serve_args": { - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "832x480_frames81_steps4", - "dataset": "random", - "task": "i2v", - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 1, - "seed": 42, - "enable-negative-prompt": true, - "random-request-config": [ - { - "width": 832, - "height": 480, - "num_inference_steps": 4, - "num_frames": 81, - "fps": 16, - "weight": 1 - } - ], - "baseline": { - "throughput_qps": 0.0465, - "latency_mean": 21.4354, - "peak_memory_mb_mean": 72557.58 - } - } - ] + { + "test_name": "test_wan22_i2v_single_device", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline", + "server_type": "vllm-omni", + "server_params": { + "model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_wan22_i2v_usp2_vae_patch2_hsdp_slicing", - "description": "USP=2 + VAE patch parallel=2 + HSDP + VAE slicing", - "server_type": "vllm-omni", - "server_params": { - "model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers", - "serve_args": { - "usp": 2, - "vae-patch-parallel-size": 2, - "use-hsdp": true, - "vae-use-slicing": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "832x480_frames81_steps4", - "dataset": "random", - "task": "i2v", - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 1, - "seed": 42, - "enable-negative-prompt": true, - "random-request-config": [ - { - "width": 832, - "height": 480, - "num_inference_steps": 4, - "num_frames": 81, - "fps": 16, - "weight": 1 - } - ], - "baseline": { - "throughput_qps": 0.0642, - "latency_mean": 15.5589, - "peak_memory_mb_mean": 45742.88 - } - }, - { - "name": "1280x720_frames121_steps4", - "dataset": "random", - "task": "i2v", - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 1, - "seed": 42, - "enable-negative-prompt": true, - "random-request-config": [ - { - "width": 1280, - "height": 720, - "num_inference_steps": 4, - "num_frames": 121, - "fps": 16, - "weight": 1 - } - ], - "baseline": { - "throughput_qps": 0.0128, - "latency_mean": 77.8565, - "peak_memory_mb_mean": 53947.84 - } - } - ] - } + "benchmark_params": [ + { + "name": "832x480_frames81_steps4", + "dataset": "random", + "task": "i2v", + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 1, + "seed": 42, + "enable-negative-prompt": true, + "random-request-config": [ + { + "width": 832, + "height": 480, + "num_inference_steps": 4, + "num_frames": 81, + "fps": 16, + "weight": 1 + } + ], + "baseline": { + "H100": { + "throughput_qps": 0.0434, + "latency_mean": 23.0654, + "peak_memory_mb_mean": 76360.0 + } + } + } + ] + }, + { + "test_name": "test_wan22_i2v_usp2_vae_patch2_hsdp_slicing", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100", + "npu": "A3" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "USP=2 + VAE patch parallel=2 + HSDP + VAE slicing", + "server_type": "vllm-omni", + "server_params": { + "model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers", + "serve_args": { + "usp": 2, + "vae-patch-parallel-size": 2, + "use-hsdp": true, + "vae-use-slicing": true, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "832x480_frames81_steps4", + "dataset": "random", + "task": "i2v", + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 1, + "seed": 42, + "enable-negative-prompt": true, + "random-request-config": [ + { + "width": 832, + "height": 480, + "num_inference_steps": 4, + "num_frames": 81, + "fps": 16, + "weight": 1 + } + ], + "baseline": { + "H100": { + "throughput_qps": 0.0606, + "latency_mean": 16.4928, + "peak_memory_mb_mean": 47164.8 + } + } + }, + { + "name": "1280x720_frames121_steps4", + "dataset": "random", + "task": "i2v", + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 1, + "seed": 42, + "enable-negative-prompt": true, + "random-request-config": [ + { + "width": 1280, + "height": 720, + "num_inference_steps": 4, + "num_frames": 121, + "fps": 16, + "weight": 1 + } + ], + "baseline": { + "H100": { + "throughput_qps": 0.0124, + "latency_mean": 80.7784, + "peak_memory_mb_mean": 55374.3429 + } + } + } + ] + } ] diff --git a/tests/dfx/reliability/invalid_param_test/test_invalid_audio_speech.py b/tests/dfx/reliability/invalid_param_test/test_invalid_audio_speech.py index 0de862ae0..61cd30ec8 100644 --- a/tests/dfx/reliability/invalid_param_test/test_invalid_audio_speech.py +++ b/tests/dfx/reliability/invalid_param_test/test_invalid_audio_speech.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""HTTP + WebSocket validation for Qwen3-TTS Base and Higgs Audio V3 ``/v1/audio/speech`` (and related routes).""" +"""HTTP + WebSocket validation for Qwen3-TTS Base, Higgs Audio V3 and Audex TTA ``/v1/audio/speech`` (and related routes).""" from __future__ import annotations @@ -70,6 +70,19 @@ _HIGGS_AUDIO_V3_SPEECH = [ ), ] +_AUDEX_TTA_CAPTION = "Heavy rain falling on a tin roof." +_AUDEX_TTA_SPEECH = [ + pytest.param( + OmniServerParams( + model="nvidia/Nemotron-Labs-Audex-2B", + stage_config_path=get_deploy_config_path("audex_tta.yaml"), + server_args=_SPEECH_SERVER_ARGS, + ), + id="audex_tta", + marks=_L4_SPEECH_HW, + ), +] + def _apply_batch_overrides(body: dict[str, object], *, loc: str, overrides: dict[str, object]) -> None: if loc == "batch": @@ -325,6 +338,37 @@ def test_speech_higgs_invalid_field_values( ) +# Audex TTA (``audex_tta`` deployment): the serving layer builds the caption +# prompt and injects the RVQ phase contract + default guidance server-side, so +# guidance knobs and speech-only fields must be rejected before generation. +@pytest.mark.parametrize( + "overrides, err_message", + [ + pytest.param({"extra_params": {"cfg_scale": 99}}, "cfg_scale", id="cfg_scale_out_of_range"), + pytest.param({"extra_params": {"cfg_scale": "big"}}, "cfg_scale", id="cfg_scale_wrong_type"), + pytest.param({"extra_params": {"tta_rvq": {}}}, "tta_rvq", id="tta_rvq_not_overridable"), + pytest.param({"input": " "}, "caption", id="caption_whitespace_only"), + pytest.param({"voice": "alloy"}, "voice", id="voice_not_supported"), + pytest.param( + {"ref_audio": "https://example.com/ref.wav"}, + "reference audio", + id="ref_audio_not_supported", + ), + ], +) +@pytest.mark.parametrize("omni_server", _AUDEX_TTA_SPEECH, indirect=True) +def test_speech_audex_tta_invalid_field_values( + omni_server: OmniServer, + openai_client: OpenAIClientHandler, + overrides: dict[str, object], + err_message: str | tuple[str, ...], +) -> None: + body = {"model": omni_server.model, "input": _AUDEX_TTA_CAPTION, **overrides} + openai_client.send_audio_speech_http_request( + {"json": body, "timeout": 120, "err_code": 400, "err_message": err_message} + ) + + # ───────────────────────────────────────────────────────────────────────────── # POST /v1/audio/speech/batch (JSON) # ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/dfx/reliability/invalid_param_test/test_invalid_minicpmo_4_5_omni.py b/tests/dfx/reliability/invalid_param_test/test_invalid_minicpmo_4_5_omni.py new file mode 100644 index 000000000..9c6880d68 --- /dev/null +++ b/tests/dfx/reliability/invalid_param_test/test_invalid_minicpmo_4_5_omni.py @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Invalid audio format rejection on MiniCPM-o 4.5: ``POST /v1/chat/completions``.""" + +import os + +os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn") + +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data +from tests.helpers.stage_config import get_deploy_config_path + +pytestmark = [pytest.mark.slow, pytest.mark.omni, pytest.mark.full_model] + +_MINICPMO_DEPLOY = get_deploy_config_path("minicpmo_4_5.yaml") + +_MINICPMO_SERVER = [ + pytest.param( + OmniServerParams( + model="openbmb/MiniCPM-o-4_5", + stage_config_path=_MINICPMO_DEPLOY, + use_stage_cli=False, + server_args=[ + "--trust-remote-code", + ], + ), + id="minicpmo_4_5", + ) +] + + +def _system_prompt() -> dict[str, object]: + return { + "role": "system", + "content": [ + { + "type": "text", + "text": ( + "You are MiniCPM-o 4.5, a virtual human capable of perceiving " + "auditory and visual inputs, as well as generating text and speech." + ), + } + ], + } + + +def _prompt() -> str: + return "What is the capital of China? Answer in 20 words." + + +@hardware_test(res={"cuda": "H100", "npu": "A2"}, 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 + Input Modal: text + invalid audio format + Output Modal: error + """ + fake_base64 = "AAAA" # not a real audio file + + responses = openai_client.send_chat_completions_http_request( + { + "json": { + "model": omni_server.model, + "messages": dummy_messages_from_mix_data( + system_prompt=_system_prompt(), + audio_data_url=f"data:audio/invalid;base64,{fake_base64}", + content_text=_prompt(), + ), + "stream": True, + }, + }, + err_code=400, + err_message="Invalid or unsupported audio file", + ) + assert not responses[0].success diff --git a/tests/dfx/reliability/invalid_param_test/test_invalid_server_control.py b/tests/dfx/reliability/invalid_param_test/test_invalid_server_control.py index 0a8240c4b..ad3f659f1 100644 --- a/tests/dfx/reliability/invalid_param_test/test_invalid_server_control.py +++ b/tests/dfx/reliability/invalid_param_test/test_invalid_server_control.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Omni sleep/wakeup JSON validation (live ``Qwen/Qwen-Image`` process).""" +"""Omni sleep/wakeup JSON validation and level-2 wake contract (live ``Qwen/Qwen-Image``).""" from __future__ import annotations @@ -15,9 +15,17 @@ pytestmark = [pytest.mark.slow, pytest.mark.diffusion, pytest.mark.omni] _SKIP_ISSUE_3649 = pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/3649") +# One module-scoped server for this file. ``--enable-sleep-mode`` is required by the +# level-2 wake contract test; invalid-JSON cases only hit request validation and are +# unaffected. Do not mix ``omni_server`` (module) with ``omni_server_function`` here: +# ``iter_omni_server`` holds ``omni_fixture_lock`` across the module yield, so a +# function-scoped second server would deadlock / contend for the GPU. _QWEN_IMAGE = [ pytest.param( - OmniServerParams(model="Qwen/Qwen-Image"), + OmniServerParams( + model="Qwen/Qwen-Image", + server_args=["--enable-sleep-mode"], + ), id="qwen_image", marks=hardware_marks(res={"cuda": "H100"}), ), @@ -93,3 +101,37 @@ def test_omni_sleep_wakeup_invalid_requests( openai_client.send_omni_sleep_http_request(cfg) else: openai_client.send_omni_wakeup_http_request(cfg) + + +# Keep this after the invalid-JSON cases: sleep(level=2) discards weights and the +# shared module server is not usable afterward; module teardown then stops it. +@pytest.mark.parametrize("omni_server", _QWEN_IMAGE, indirect=True) +def test_wakeup_after_level2_sleep_fails( + omni_server: OmniServer, + openai_client: OpenAIClientHandler, +) -> None: + """Regression for #4473 Repro A via HTTP: wake after sleep(level=2) → 501. + + Moved from ``tests/entrypoints/test_omni_sleep_mode.py`` (BAGEL cold start). + Live serve surfaces ``NotImplementedError`` as OpenAI-style JSON with code 501. + """ + assert omni_server is not None + sleep_resps = openai_client.send_omni_sleep_http_request( + { + "json": {"stage_ids": [0], "level": 2}, + "timeout": 180, + } + ) + sleep_resp = sleep_resps[0] + assert sleep_resp.status_code == 200, sleep_resp + assert sleep_resp.json_body is not None + assert sleep_resp.json_body.get("status") == "SUCCESS", sleep_resp.json_body + + openai_client.send_omni_wakeup_http_request( + { + "json": {"stage_ids": [0]}, + "timeout": 120, + "err_code": 501, + "err_message": ("sleep(level=2)", "not yet implemented"), + } + ) diff --git a/tests/dfx/reliability/test_reliability_qwen3_omni.py b/tests/dfx/reliability/test_reliability_qwen3_omni.py index 28e8dedf7..428323638 100644 --- a/tests/dfx/reliability/test_reliability_qwen3_omni.py +++ b/tests/dfx/reliability/test_reliability_qwen3_omni.py @@ -241,12 +241,12 @@ def _fault_keywords_match_response(*, status: int, body: bytes) -> bool: return any(key in text for key in body_fault_hints) -def _stage_config_path_from_omni_server(omni_server: _HasServeArgs) -> str | None: +def _deploy_config_path_from_omni_server(omni_server: _HasServeArgs) -> str | None: args: list[str] = omni_server.serve_args for i, arg in enumerate(args): - if arg == "--stage-configs-path" and i + 1 < len(args): + if arg == "--deploy-config" and i + 1 < len(args): return args[i + 1] - if arg.startswith("--stage-configs-path="): + if arg.startswith("--deploy-config="): return arg.split("=", 1)[1] return None @@ -331,7 +331,7 @@ def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech( """ device_spec = resolve_oom_device_spec( OOM_INJECTION_CONFIG, - _stage_config_path_from_omni_server(omni_server_function), + _deploy_config_path_from_omni_server(omni_server_function), ) handle = inject_gpu_oom( device=device_spec, @@ -394,7 +394,7 @@ def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech( def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_function, openai_client_function) -> None: device_spec = resolve_oom_device_spec( OOM_INJECTION_CONFIG, - _stage_config_path_from_omni_server(omni_server_function), + _deploy_config_path_from_omni_server(omni_server_function), ) handle = inject_gpu_oom( device=device_spec, @@ -437,7 +437,7 @@ def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_functi def test_reliability_fault_gpu_oom_concurrent_pressure_failure(omni_server_function, openai_client_function) -> None: device_spec = resolve_oom_device_spec( OOM_INJECTION_CONFIG, - _stage_config_path_from_omni_server(omni_server_function), + _deploy_config_path_from_omni_server(omni_server_function), ) handle = inject_gpu_oom( device=device_spec, @@ -791,7 +791,7 @@ def test_reliability_fault_gpu_oom_state_converges_after_fault_removed( """ device_spec = resolve_oom_device_spec( OOM_RECOVER_INJECTION_CONFIG, - _stage_config_path_from_omni_server(omni_server_function), + _deploy_config_path_from_omni_server(omni_server_function), ) handle = inject_gpu_oom( device=device_spec, diff --git a/tests/dfx/reliability/test_reliability_voxcpm2.py b/tests/dfx/reliability/test_reliability_voxcpm2.py index 014bb4e89..632c51081 100644 --- a/tests/dfx/reliability/test_reliability_voxcpm2.py +++ b/tests/dfx/reliability/test_reliability_voxcpm2.py @@ -43,6 +43,9 @@ RELIABILITY_SCENARIOS: list[dict[str, Any]] = [ "server_params": { "model": "openbmb/VoxCPM2", "stage_config_name": "voxcpm2.yaml", + # VoxCPM2 uses a custom HF tokenizer; CLI default trust_remote_code=False + # would otherwise override deploy YAML settings during server startup. + "server_args": ["--trust-remote-code", "--disable-log-stats"], }, }, ] diff --git a/tests/ar_diffusion/__init__.py b/tests/diffusion/ar_diffusion/__init__.py similarity index 100% rename from tests/ar_diffusion/__init__.py rename to tests/diffusion/ar_diffusion/__init__.py diff --git a/tests/diffusion/ar_diffusion/test_capability_runner.py b/tests/diffusion/ar_diffusion/test_capability_runner.py new file mode 100644 index 000000000..7b94fe80f --- /dev/null +++ b/tests/diffusion/ar_diffusion/test_capability_runner.py @@ -0,0 +1,317 @@ +# SPDX-License-Identifier: Apache-2.0 +"""CPU contracts for capability-driven AR-Diffusion sessions.""" + +from __future__ import annotations + +from collections import OrderedDict +from contextlib import contextmanager +from types import SimpleNamespace + +import pytest +import torch + +from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner +from vllm_omni.experimental.ar_diffusion.capability import ( + ARDiffusionCrossAttentionKVSpec, + ARDiffusionKVBranchSpec, + ARDiffusionKVCacheSpec, +) +from vllm_omni.experimental.ar_diffusion.kv_cache import ARDiffusionKVConfig +from vllm_omni.experimental.ar_diffusion.runner import ARDiffusionModelRunner + +BLOCK = 16 +POS = "positive" +NEG = "negative" + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def lingbot_like_spec(*, capacity: int = 2) -> ARDiffusionKVCacheSpec: + """Single-kv_branch causal DMD: 3 latent frames/block + sink/window + text KV.""" + return ARDiffusionKVCacheSpec( + num_layers=2, + num_kv_heads=4, # already TP-local + head_size=64, + tokens_per_frame=BLOCK, + frames_per_block=3, + window_frames=5, + sink_frames=1, + kv_branches=(ARDiffusionKVBranchSpec("main", 0),), + session_capacity=capacity, + cross_attention=(ARDiffusionCrossAttentionKVSpec("text", 8),), + ) + + +def dreamzero_like_spec(*, capacity: int = 2) -> ARDiffusionKVCacheSpec: + return ARDiffusionKVCacheSpec( + num_layers=2, + num_kv_heads=4, + head_size=64, + tokens_per_frame=BLOCK, + frames_per_block=4, + window_frames=6, + kv_branches=(ARDiffusionKVBranchSpec(POS, 0), ARDiffusionKVBranchSpec(NEG, 1)), + session_capacity=capacity, + cross_attention=(ARDiffusionCrossAttentionKVSpec("text", 8),), + ) + + +class CapablePipeline: + def __init__(self, spec: ARDiffusionKVCacheSpec) -> None: + self.spec = spec + self.bound_state = None + self.binds: list[str] = [] + self.resets: list[str] = [] + self.closes: list[str] = [] + + def ar_diffusion_kv_cache_spec(self) -> ARDiffusionKVCacheSpec: + return self.spec + + @contextmanager + def bind_ar_diffusion_state(self, session_id: str, state): + assert self.bound_state is None + self.bound_state = state + self.binds.append(session_id) + try: + yield + finally: + self.bound_state = None + + def reset_ar_diffusion_session(self, session_id: str) -> None: + self.resets.append(session_id) + + def close_ar_diffusion_session(self, session_id: str) -> None: + self.closes.append(session_id) + + +class WarmupPipeline(CapablePipeline): + def __init__(self, spec: ARDiffusionKVCacheSpec, requests: list[object]) -> None: + super().__init__(spec) + self.requests = requests + + def ar_diffusion_warmup_requests(self, session_id: str): + assert session_id == ARDiffusionModelRunner._WARMUP_SID + return iter(self.requests) + + +class BatchCapablePipeline(CapablePipeline): + supports_request_batch = True + + +def make_runner( + pipeline: object, + *, + available_bytes: int = 1 << 28, + step_execution: bool = False, +) -> ARDiffusionModelRunner: + runner = object.__new__(ARDiffusionModelRunner) + runner.od_config = SimpleNamespace( + max_num_seqs=1, + dtype=torch.float32, + enforce_eager=True, + step_execution=step_execution, + ) + runner.device = torch.device("cpu") + runner.pipeline = pipeline + runner.ar_diffusion_kv_config = ARDiffusionKVConfig(enable=True) + runner.kv_cache = None + runner._ar_diffusion_capability = None + runner._ar_diffusion_kv_cache_spec = None + runner._sessions = OrderedDict() + runner._session_capacity = 0 + runner._perf_e2e_times = [] + runner._preallocate_kv_cache(available_bytes=available_bytes) + return runner + + +def commit_one_frame(runner: ARDiffusionModelRunner, session_id: str, kv_branch: str): + state = runner._get_or_create_session(session_id) + ctx = state.get_kv_caches(kv_branch, seq_len=BLOCK, commit_current=True)[0].forward_ctx + ctx.ensure_video_slots(torch.device("cpu")) + state.commit_paged_context(kv_branch) + return state + + +def test_ar_runner_rejects_pipeline_without_capability(): + runner = object.__new__(ARDiffusionModelRunner) + runner.od_config = SimpleNamespace(max_num_seqs=1) + runner.pipeline = object() + with pytest.raises(TypeError, match="SupportsARDiffusionPipeline"): + runner._preallocate_kv_cache(available_bytes=1 << 20) + + +def test_lingbot_like_single_branch_session_reuse_reset_and_close(): + pipeline = CapablePipeline(lingbot_like_spec()) + runner = make_runner(pipeline) + kv = runner.kv_cache + assert kv is not None + assert kv.num_local_kv_branches == 1 + assert kv.frames_per_block == 3 + assert kv.spec.window_chunks == 5 + assert kv.spec.sink_chunks == 1 + assert kv.cross_attention_lengths == {"text": 8} + + first = commit_one_frame(runner, "s1", "main") + assert runner._get_or_create_session("s1") is first + k = torch.randn(1, 8, 4, 64) + v = torch.randn(1, 8, 4, 64) + first.populate_cross_attention("main", "text", [(k, v)] * first.num_layers) + assert first.get_cross_attention_kv("main", "text")[0]["k"].shape == k.shape + + runner.reset_session("s1") + assert "s1" not in runner._sessions + assert "s1" not in kv._cross_sessions + assert pipeline.resets == ["s1"] + second = runner._get_or_create_session("s1") + assert second is not first + + runner.close_session("s1") + assert "s1" not in runner._sessions + assert pipeline.closes == ["s1"] + + +def test_lingbot_like_sink_survives_sliding_window_eviction(): + runner = make_runner(CapablePipeline(lingbot_like_spec())) + kv = runner.kv_cache + assert kv is not None + + for _ in range(8): + state = commit_one_frame(runner, "s1", "main") + + table = kv.block_table(state.adapter("main")) + assert table[0] != kv.null_block_id + assert table[1] == kv.null_block_id + + ctx = state.get_kv_caches("main", seq_len=BLOCK, commit_current=False)[0].forward_ctx + visible, _ = ctx.video_block_table(torch.device("cpu")) + assert visible[0] == table[0] + assert len(visible) == 6 # sink + recent window, including current scratch + + +def test_dreamzero_like_two_branches_are_independent(): + runner = make_runner(CapablePipeline(dreamzero_like_spec())) + kv = runner.kv_cache + assert kv is not None and kv.num_local_kv_branches == 2 + state = commit_one_frame(runner, "s1", POS) + assert len(kv.window_block_ids(state.adapter(POS))) == 1 + assert kv.window_block_ids(state.adapter(NEG)) == [] + + +def test_lru_eviction_releases_blocks_and_notifies_pipeline(): + pipeline = CapablePipeline(lingbot_like_spec(capacity=8)) + runner = make_runner(pipeline) + kv = runner.kv_cache + assert kv is not None + assert runner._session_capacity == 1 + assert kv.session_capacity == 1 + free_total = kv.manager.block_pool.get_num_free_blocks() + old = commit_one_frame(runner, "old", "main") + k = torch.randn(1, 8, 4, 64) + old.populate_cross_attention("main", "text", [(k, k)] * old.num_layers) + assert kv.manager.block_pool.get_num_free_blocks() < free_total + + runner._get_or_create_session("new") + + assert tuple(runner._sessions) == ("new",) + assert pipeline.closes == ["old"] + assert "old" not in kv._cross_sessions + assert kv.manager.block_pool.get_num_free_blocks() == free_total + + +def test_forward_exception_releases_pending_allocation_and_model_state(monkeypatch): + pipeline = CapablePipeline(lingbot_like_spec()) + runner = make_runner(pipeline) + kv = runner.kv_cache + assert kv is not None + free_total = kv.manager.block_pool.get_num_free_blocks() + + def boom(self, req, kv_prefetch_job=None): + state = pipeline.bound_state + ctx = state.get_kv_caches("main", seq_len=BLOCK, commit_current=True)[0].forward_ctx + ctx.ensure_video_slots(torch.device("cpu")) + raise RuntimeError("layer exploded") + + monkeypatch.setattr(DiffusionModelRunner, "execute_model", boom) + request = SimpleNamespace(sampling_params=SimpleNamespace(extra_args={"session_id": "broken"})) + + with pytest.raises(RuntimeError, match="layer exploded"): + runner.execute_model(request) + + assert pipeline.bound_state is None + assert pipeline.closes == ["broken"] + assert not runner._sessions + assert not kv._adapters + assert kv.manager.block_pool.get_num_free_blocks() == free_total + + +def test_synchronize_exception_uses_forward_cleanup_path(monkeypatch): + pipeline = CapablePipeline(lingbot_like_spec()) + runner = make_runner(pipeline) + kv = runner.kv_cache + assert kv is not None + free_total = kv.manager.block_pool.get_num_free_blocks() + + def return_after_allocation(self, req, kv_prefetch_job=None): + state = pipeline.bound_state + ctx = state.get_kv_caches("main", seq_len=BLOCK, commit_current=True)[0].forward_ctx + ctx.ensure_video_slots(torch.device("cpu")) + return object() + + def synchronize_boom(device): + raise RuntimeError("asynchronous kernel failed") + + monkeypatch.setattr(DiffusionModelRunner, "execute_model", return_after_allocation) + monkeypatch.setattr(torch.accelerator, "synchronize", synchronize_boom) + runner.device = torch.device("cuda") + request = SimpleNamespace(sampling_params=SimpleNamespace(extra_args={"session_id": "broken"})) + + with pytest.raises(RuntimeError, match="asynchronous kernel failed"): + runner.execute_model(request) + + assert pipeline.bound_state is None + assert pipeline.closes == ["broken"] + assert not runner._sessions + assert not kv._adapters + assert not runner._perf_e2e_times + assert kv.manager.block_pool.get_num_free_blocks() == free_total + + +def test_ar_runner_rejects_step_and_request_batch_modes(): + with pytest.raises(ValueError, match="step_execution=True"): + make_runner(CapablePipeline(lingbot_like_spec()), step_execution=True) + with pytest.raises(ValueError, match="request-batch execution"): + make_runner(BatchCapablePipeline(lingbot_like_spec())) + + +def test_ar_runner_defensively_rejects_inherited_batch_and_step_entrypoints(): + runner = object.__new__(ARDiffusionModelRunner) + with pytest.raises(RuntimeError, match="request-batch execution"): + runner.execute_model_batch(None, None) + with pytest.raises(RuntimeError, match="step execution"): + runner.execute_stepwise(None) + + +def test_model_specific_warmup_provider_is_consumed(monkeypatch): + requests = [object(), object()] + pipeline = WarmupPipeline(lingbot_like_spec(), requests) + runner = make_runner(pipeline) + seen: list[object] = [] + monkeypatch.setattr(runner, "execute_model", seen.append) + + runner._warmup_ar_rollout() + + assert seen == requests + assert pipeline.closes == [runner._WARMUP_SID] + + +def test_pipeline_without_warmup_provider_is_safely_skipped(monkeypatch): + pipeline = CapablePipeline(lingbot_like_spec()) + runner = make_runner(pipeline) + execute = SimpleNamespace(called=False) + + def fail_if_called(request): + execute.called = True + + monkeypatch.setattr(runner, "execute_model", fail_if_called) + runner._warmup_ar_rollout() + assert execute.called is False diff --git a/tests/ar_diffusion/test_engine_routing.py b/tests/diffusion/ar_diffusion/test_engine_routing.py similarity index 95% rename from tests/ar_diffusion/test_engine_routing.py rename to tests/diffusion/ar_diffusion/test_engine_routing.py index fb55e01e9..19c4cb529 100644 --- a/tests/ar_diffusion/test_engine_routing.py +++ b/tests/diffusion/ar_diffusion/test_engine_routing.py @@ -21,6 +21,8 @@ from vllm_omni.experimental.ar_diffusion.engine import ( ARDiffusionEngine, ) +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + def _cfg(backend): return SimpleNamespace(engine_backend=backend) @@ -136,8 +138,8 @@ def test_runner_rejects_multi_sequence_config(): ARDiffusionModelRunner._preallocate_kv_cache(fake) -def test_execute_model_accepts_kv_prefetch_jobs_kwarg(): - """Regression: the worker passes ``kv_prefetch_jobs`` to every runner +def test_execute_model_accepts_kv_prefetch_job_kwarg(): + """Regression: the worker passes ``kv_prefetch_job`` to every runner (added with the KV prefetch path, #4448); the AR-Diffusion override must accept and forward it or DreamZero crashes on the warm-up dummy run.""" import inspect @@ -147,4 +149,4 @@ def test_execute_model_accepts_kv_prefetch_jobs_kwarg(): for cls in (DiffusionModelRunner, ARDiffusionModelRunner): params = inspect.signature(cls.execute_model).parameters - assert "kv_prefetch_jobs" in params, cls + assert "kv_prefetch_job" in params, cls diff --git a/tests/ar_diffusion/test_kv_cache.py b/tests/diffusion/ar_diffusion/test_kv_cache.py similarity index 73% rename from tests/ar_diffusion/test_kv_cache.py rename to tests/diffusion/ar_diffusion/test_kv_cache.py index beb575a0b..d09c1c1b6 100644 --- a/tests/ar_diffusion/test_kv_cache.py +++ b/tests/diffusion/ar_diffusion/test_kv_cache.py @@ -12,6 +12,7 @@ from vllm.v1.kv_cache_interface import KVCacheSpecKind, get_kv_cache_spec_kind from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry from vllm.v1.request import RequestStatus +from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec from vllm_omni.experimental.ar_diffusion.kv_cache import ( ARDiffusionKVCache, ARDiffusionKVConfig, @@ -24,6 +25,9 @@ from vllm_omni.experimental.ar_diffusion.kv_cache import ( ) from vllm_omni.experimental.ar_diffusion.kv_cache.paged import chunk_window_skipped_tokens +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + BLOCK = 16 @@ -194,16 +198,29 @@ def test_cross_attn_pool_deducted_from_self_attn_budget(): block_size=BLOCK, max_model_len=4096, available_bytes=avail, - cross_attn_length=L, + kv_branches=(ARDiffusionKVBranchSpec("positive", 0), ARDiffusionKVBranchSpec("negative", 0)), + session_capacity=1, + cross_attention_lengths={"text": L}, device=torch.device("cpu"), ) cross_bytes = 2 * 2 * L * 4 * 64 * torch.float16.itemsize * 2 # K+V, pos+neg, layers expected = compute_num_blocks(avail - cross_bytes, 0.5, kv.spec.page_size_bytes * 2) assert kv.num_blocks == expected - assert expected > 2 * (2 + 1) + 2 # above the min-blocks floor, so the deduction is what's tested + assert expected > 1 * (2 + 1) + 2 # above the local-slot floor, so the cross deduction is what's tested -def _make_kv(*, local_branches, num_frame_per_block=2, window_chunks=9): +def _make_kv( + *, + local_branches, + num_frame_per_block=2, + window_chunks=9, + max_scratch_tokens_per_branch=0, +): + kv_branches = ( + (ARDiffusionKVBranchSpec("positive", 0), ARDiffusionKVBranchSpec("negative", 0)) + if local_branches == 1 + else (ARDiffusionKVBranchSpec("positive", 0), ARDiffusionKVBranchSpec("negative", 1)) + ) return ARDiffusionKVCache( ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks), num_layers=1, @@ -214,41 +231,75 @@ def _make_kv(*, local_branches, num_frame_per_block=2, window_chunks=9): max_model_len=4096, available_bytes=1 << 16, # tiny -> the floor binds device=torch.device("cpu"), - local_branches=local_branches, - num_frame_per_block=num_frame_per_block, + kv_branches=kv_branches, + session_capacity=1, + frames_per_block=num_frame_per_block, + max_scratch_tokens_per_branch=max_scratch_tokens_per_branch, ) def test_pool_floor_is_branch_aware(): - """CFG-parallel rank (one local branch) sizes for one window + in-flight chunk; - a single-process run (both branches) sizes for two. Scratch scales the same way.""" + """CFG-parallel rank (one local kv_branch) sizes for one window + in-flight chunk; + a single-process run (both kv_branches) sizes for two. Scratch scales the same way.""" one = _make_kv(local_branches=1) two = _make_kv(local_branches=2) assert one.managed_num_blocks == 1 * (9 + 2) + 2 # 13 assert two.managed_num_blocks == 2 * (9 + 2) + 2 # 24 - assert one.scratch_num_blocks == one.scratch_blocks_per_branch - assert two.scratch_num_blocks == 2 * two.scratch_blocks_per_branch - assert one.num_blocks_total == 13 + one.scratch_blocks_per_branch + assert one.scratch_num_blocks == one.scratch_blocks_per_kv_branch + assert two.scratch_num_blocks == 2 * two.scratch_blocks_per_kv_branch + assert one.scratch_blocks_per_kv_branch == 2 + assert one.num_blocks_total == 13 + one.scratch_blocks_per_kv_branch + + +def test_scratch_capacity_is_derived_from_declared_geometry(monkeypatch): + monkeypatch.delenv("AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH", raising=False) + kv = _make_kv( + local_branches=1, + num_frame_per_block=6, + max_scratch_tokens_per_branch=BLOCK + 1, + ) + assert kv.scratch_blocks_per_kv_branch == 8 # six video blocks + two auxiliary blocks + + +def test_scratch_env_override_cannot_reduce_declared_minimum(monkeypatch): + monkeypatch.setenv("AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH", "1") + kv = _make_kv( + local_branches=1, + num_frame_per_block=6, + max_scratch_tokens_per_branch=BLOCK + 1, + ) + assert kv.scratch_blocks_per_kv_branch == 8 def test_scratch_maps_to_slot_zero_with_one_local_branch(): - """A CFG-parallel rank runs exactly one branch: whichever CFG side it is, + """A CFG-parallel rank runs exactly one kv_branch: whichever CFG side it is, its scratch lands in the rank's single slot (no dead second slot).""" one = _make_kv(local_branches=1) - assert one.scratch_block_ids(True, 0, 2) == one.scratch_block_ids(False, 0, 2) + assert one.scratch_block_ids("positive", 0, 2) == one.scratch_block_ids("negative", 0, 2) two = _make_kv(local_branches=2) - assert two.scratch_block_ids(True, 0, 2) != two.scratch_block_ids(False, 0, 2) + assert two.scratch_block_ids("positive", 0, 2) != two.scratch_block_ids("negative", 0, 2) def test_scratch_exhaustion_still_raises(): one = _make_kv(local_branches=1) - cap = one.scratch_blocks_per_branch + cap = one.scratch_blocks_per_kv_branch with pytest.raises(RuntimeError, match="scratch blocks exhausted"): - one.scratch_block_ids(False, 0, cap + 1) + one.scratch_block_ids("positive", 0, cap + 1) -def test_invalid_local_branches_rejected(): - with pytest.raises(ValueError, match="local_branches"): - _make_kv(local_branches=3) +def test_non_contiguous_branch_indices_rejected(): + with pytest.raises(ValueError, match="contiguous"): + ARDiffusionKVCache( + ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=2), + num_layers=1, + num_kv_heads=4, + head_size=64, + dtype=torch.float32, + block_size=BLOCK, + max_model_len=4096, + available_bytes=1 << 16, + kv_branches=(ARDiffusionKVBranchSpec("main", 1),), + session_capacity=1, + ) diff --git a/tests/ar_diffusion/test_kv_orchestration.py b/tests/diffusion/ar_diffusion/test_kv_orchestration.py similarity index 83% rename from tests/ar_diffusion/test_kv_orchestration.py rename to tests/diffusion/ar_diffusion/test_kv_orchestration.py index 4ba5cb49b..32603bf88 100644 --- a/tests/ar_diffusion/test_kv_orchestration.py +++ b/tests/diffusion/ar_diffusion/test_kv_orchestration.py @@ -4,9 +4,19 @@ import pytest import torch +from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec from vllm_omni.experimental.ar_diffusion.kv_cache import ARDiffusionKVCache, ARDiffusionKVConfig -DIMS = dict(num_layers=2, num_kv_heads=4, head_size=64, dtype=torch.float16, block_size=16) +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] +DIMS = dict( + num_layers=2, + num_kv_heads=4, + head_size=64, + dtype=torch.float16, + block_size=16, + kv_branches=(ARDiffusionKVBranchSpec("positive", 0), ARDiffusionKVBranchSpec("negative", 1)), + session_capacity=2, +) def make_cache(*, chunk_size=16, window_chunks=2, available_bytes=1 << 24): @@ -67,7 +77,7 @@ def test_num_computed_advances_per_chunk(): def test_state_close_frees_both_branch_blocks(): - """ARDiffusionKVState.close() returns both CFG branches' pool blocks to the pool. + """ARDiffusionKVState.close() returns both CFG kv_branches' pool blocks to the pool. This is the primitive the runner's LRU eviction relies on: when a session is evicted, close() must free the blocks both adapters hold, or session churn @@ -80,12 +90,17 @@ def test_state_close_frees_both_branch_blocks(): pos = kv.begin_request("bde__s") neg = kv.begin_request("bde__s__neg") - state = ARDiffusionKVState(kv, pos, neg, num_layers=kv.num_layers) + state = ARDiffusionKVState( + kv, + "s", + {"positive": pos, "negative": neg}, + num_layers=kv.num_layers, + ) for _ in range(3): for adapter in (pos, neg): kv.allocate_chunk(adapter) kv.commit_chunk(adapter) - # Both branches hold resident blocks now. + # Both kv_branches hold resident blocks now. assert kv.manager.block_pool.get_num_free_blocks() < free_total state.close() diff --git a/tests/ar_diffusion/test_paged_attention.py b/tests/diffusion/ar_diffusion/test_paged_attention.py similarity index 81% rename from tests/ar_diffusion/test_paged_attention.py rename to tests/diffusion/ar_diffusion/test_paged_attention.py index 1836656f9..0b66c01a0 100644 --- a/tests/ar_diffusion/test_paged_attention.py +++ b/tests/diffusion/ar_diffusion/test_paged_attention.py @@ -11,6 +11,7 @@ from unittest.mock import MagicMock import pytest import torch +from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec from vllm_omni.experimental.ar_diffusion.kv_cache import ( ARDiffusionKVCache, ARDiffusionKVConfig, @@ -21,9 +22,14 @@ from vllm_omni.experimental.ar_diffusion.kv_cache import ( ) from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + BLOCK = 16 N_HEADS = 4 HEAD_DIM = 64 +POS = "positive" +NEG = "negative" def make_state(*, num_layers=1, window_chunks=2, dtype=torch.float32, device=torch.device("cpu")): @@ -37,11 +43,15 @@ def make_state(*, num_layers=1, window_chunks=2, dtype=torch.float32, device=tor block_size=BLOCK, max_model_len=4096, available_bytes=1 << 26, + kv_branches=(ARDiffusionKVBranchSpec(POS, 0), ARDiffusionKVBranchSpec(NEG, 1)), + session_capacity=2, + frames_per_block=2, + max_scratch_tokens_per_branch=BLOCK, device=device, ) pos = kv.begin_request("r-pos") neg = kv.begin_request("r-neg") - return kv, ARDiffusionKVState(kv, pos, neg, num_layers=num_layers) + return kv, ARDiffusionKVState(kv, "s1", {POS: pos, NEG: neg}, num_layers=num_layers) def _dense_attention(query: torch.Tensor, key: torch.Tensor, value: torch.Tensor) -> torch.Tensor: @@ -79,51 +89,51 @@ def _commit_video_span( kv: ARDiffusionKVCache, st: ARDiffusionKVState, *, - is_negative: bool, + kv_branch: str, n_chunks: int, dtype: torch.dtype, device: torch.device, ) -> tuple[torch.Tensor, torch.Tensor]: - ctx = st.get_kv_caches(is_negative, seq_len=n_chunks * BLOCK, commit_current=True)[0].forward_ctx + ctx = st.get_kv_caches(kv_branch, seq_len=n_chunks * BLOCK, commit_current=True)[0].forward_ctx ctx.ensure_video_slots(device) k = torch.randn(1, n_chunks * BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device) v = torch.randn(1, n_chunks * BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device) kv._k_pools[0][ctx.current_video_slot_mapping] = k[0] kv._v_pools[0][ctx.current_video_slot_mapping] = v[0] - st.commit_paged_context(is_negative) + st.commit_paged_context(kv_branch) return k, v def test_paged_context_allocates_lazily_and_commits_after_forward(): _, st = make_state() - contexts = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True) + contexts = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=True) ctx = contexts[0].forward_ctx assert isinstance(contexts[0], ARDiffusionPagedLayerContext) - assert st.pos.completed_chunks == 0 + assert st.adapter(POS).completed_chunks == 0 assert ctx.current_video_slot_mapping is None ctx.ensure_video_slots(torch.device("cpu")) - assert st.pos.completed_chunks == 0 + assert st.adapter(POS).completed_chunks == 0 assert len(ctx.current_video_block_ids) == 1 - st.commit_paged_context(False) - assert st.pos.completed_chunks == 1 - assert st._committed[False] == BLOCK + st.commit_paged_context(POS) + assert st.adapter(POS).completed_chunks == 1 + assert st._committed[POS] == BLOCK def test_scratch_video_and_action_blocks_do_not_commit(): kv, st = make_state() - ctx = st.get_kv_caches(False, seq_len=2 * BLOCK, commit_current=False)[0].forward_ctx + ctx = st.get_kv_caches(POS, seq_len=2 * BLOCK, commit_current=False)[0].forward_ctx ctx.ensure_video_slots(torch.device("cpu")) ctx.ensure_action_slots(3, torch.device("cpu")) - assert ctx.current_video_block_ids == kv.scratch_block_ids(False, 0, 2) - assert ctx.action_scratch_block_ids == kv.scratch_block_ids(False, 2, 1) - st.commit_paged_context(False) - assert st.pos.completed_chunks == 0 - assert st._committed[False] == 0 + assert ctx.current_video_block_ids == kv.scratch_block_ids(POS, 0, 2) + assert ctx.action_scratch_block_ids == kv.scratch_block_ids(POS, 2, 1) + st.commit_paged_context(POS) + assert st.adapter(POS).completed_chunks == 0 + assert st._committed[POS] == 0 def test_pipeline_kv_get_paged_path_has_no_gather_backend(): @@ -155,7 +165,7 @@ def test_paged_attention_matches_dense_reference_cpu(history_chunks, action_len, k, v = _commit_video_span( kv, st, - is_negative=False, + kv_branch=POS, n_chunks=history_chunks, dtype=dtype, device=device, @@ -163,7 +173,7 @@ def test_paged_attention_matches_dense_reference_cpu(history_chunks, action_len, history_k_parts.append(k) history_v_parts.append(v) - ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=commit_current)[0].forward_ctx + ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=commit_current)[0].forward_ctx ctx.ensure_video_slots(device) current_k = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device) current_v = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device) @@ -212,9 +222,9 @@ def test_paged_attention_matches_dense_reference_cpu(history_chunks, action_len, torch.testing.assert_close(paged, ref, rtol=1e-5, atol=1e-5) - before = st.pos.completed_chunks - st.commit_paged_context(False) - assert st.pos.completed_chunks == before + (1 if commit_current else 0) + before = st.adapter(POS).completed_chunks + st.commit_paged_context(POS) + assert st.adapter(POS).completed_chunks == before + (1 if commit_current else 0) @pytest.mark.skipif(not _cuda_flash_attn_usable(), reason="usable CUDA FlashAttention is required") @@ -231,13 +241,13 @@ def test_paged_attention_matches_dense_reference_gpu(history_chunks, action_len, history_k, history_v = _commit_video_span( kv, st, - is_negative=False, + kv_branch=POS, n_chunks=history_chunks, dtype=dtype, device=device, ) - layer_ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=commit_current)[0] + layer_ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=commit_current)[0] ctx = layer_ctx.forward_ctx current_k = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device) current_v = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device) @@ -294,13 +304,13 @@ def test_block_table_padded_to_fixed_width(): device = torch.device("cpu") kv, st = make_state(window_chunks=2) - ctx1 = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True)[0].forward_ctx + ctx1 = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=True)[0].forward_ctx ctx1.prepare(device=device, action_len=0, query_len=BLOCK) - st.commit_paged_context(False) + st.commit_paged_context(POS) - ctx2 = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True)[0].forward_ctx + ctx2 = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=True)[0].forward_ctx ctx2.prepare(device=device, action_len=0, query_len=BLOCK) - st.commit_paged_context(False) + st.commit_paged_context(POS) # 1-block vs 2-block visible history: same table width, same max_seq_len. assert ctx1.block_table.shape == ctx2.block_table.shape @@ -315,7 +325,7 @@ def test_block_table_padded_to_fixed_width(): def test_prepare_is_idempotent_and_layers_share_metadata(): device = torch.device("cpu") _, st = make_state(num_layers=2) - contexts = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False) + contexts = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False) fctx = contexts[0].forward_ctx fctx.prepare(device=device, action_len=0, query_len=BLOCK) table = fctx.block_table @@ -335,7 +345,7 @@ def test_prepare_is_idempotent_and_layers_share_metadata(): def test_layer_inputs_before_prepare_raises(): _, st = make_state() - layer_ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False)[0] + layer_ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0] with pytest.raises(RuntimeError, match="before prepare"): layer_ctx.to_layer_inputs() @@ -363,7 +373,7 @@ def test_custom_op_compiles_fullgraph_without_recompile_on_value_change(): kv, st = make_state(num_layers=2, window_chunks=2) def run_one_forward(commit): - contexts = st.get_kv_caches(False, seq_len=BLOCK, commit_current=commit) + contexts = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=commit) fctx = contexts[0].forward_ctx fctx.prepare(device=device, action_len=0, query_len=BLOCK) q = torch.randn(BLOCK, N_HEADS, HEAD_DIM) @@ -374,21 +384,26 @@ def test_custom_op_compiles_fullgraph_without_recompile_on_value_change(): # block-forward code object in production). for layer_ctx in contexts: out = compiled(layer_ctx.to_layer_inputs(), q, k, v) - st.commit_paged_context(False) + st.commit_paged_context(POS) return out torch._dynamo.reset() - from torch._dynamo.testing import CompileCounter + try: + from torch._dynamo.testing import CompileCounter - counter = CompileCounter() + counter = CompileCounter() - def fn(inputs, q, k, v): - return paged_write_attn(inputs, q, k, v, None, None, HEAD_DIM**-0.5) * 1.0 + def fn(inputs, q, k, v): + return paged_write_attn(inputs, q, k, v, None, None, HEAD_DIM**-0.5) * 1.0 - compiled = torch.compile(fn, backend=counter, fullgraph=True) + compiled = torch.compile(fn, backend=counter, fullgraph=True) - run_one_forward(commit=True) # history grows between calls -> - run_one_forward(commit=True) # block-table VALUES change, shapes don't - run_one_forward(commit=False) + run_one_forward(commit=True) # history grows between calls -> + run_one_forward(commit=True) # block-table VALUES change, shapes don't + run_one_forward(commit=False) - assert counter.frame_count == 1, f"recompiled: frame_count={counter.frame_count}" + assert counter.frame_count == 1, f"recompiled: frame_count={counter.frame_count}" + finally: + # Leave a clean dynamo state for later suites in the same pytest process + # (e.g. model_executor transformers models). + torch._dynamo.reset() diff --git a/tests/diffusion/ar_diffusion/test_pipeline_bridge.py b/tests/diffusion/ar_diffusion/test_pipeline_bridge.py new file mode 100644 index 000000000..ec60290b9 --- /dev/null +++ b/tests/diffusion/ar_diffusion/test_pipeline_bridge.py @@ -0,0 +1,280 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the ARDiffusionKVState paged-attention pipeline bridge.""" + +import pytest +import torch + +from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec +from vllm_omni.experimental.ar_diffusion.kv_cache import ( + ARDiffusionKVCache, + ARDiffusionKVConfig, + ARDiffusionPagedLayerContext, +) +from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + +BLOCK = 16 +N_HEADS = 4 +HEAD_DIM = 64 +POS = "positive" +NEG = "negative" + + +def make_state(num_layers=1, window_chunks=4, cross_attn_length=0, shared_local_index=False): + cfg = ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks) + kv = ARDiffusionKVCache( + cfg, + num_layers=num_layers, + num_kv_heads=N_HEADS, + head_size=HEAD_DIM, + dtype=torch.float32, + block_size=BLOCK, + max_model_len=4096, + available_bytes=1 << 24, + kv_branches=( + ARDiffusionKVBranchSpec(POS, 0), + ARDiffusionKVBranchSpec(NEG, 0 if shared_local_index else 1), + ), + session_capacity=2, + cross_attention_lengths={"text": cross_attn_length} if cross_attn_length else None, + device=torch.device("cpu"), + ) + pos = kv.begin_request("r-pos") + neg = kv.begin_request("r-neg") + return kv, ARDiffusionKVState(kv, "s1", {POS: pos, NEG: neg}, num_layers=num_layers) + + +def _prepare_and_commit(st: ARDiffusionKVState, kv_branch: str, n_chunks: int) -> None: + ctx = st.get_kv_caches(kv_branch, seq_len=n_chunks * BLOCK, commit_current=True)[0].forward_ctx + ctx.ensure_video_slots(torch.device("cpu")) + st.commit_paged_context(kv_branch) + + +def test_get_returns_paged_layer_contexts_when_nothing_committed(): + _, st = make_state(num_layers=2) + for kv_branch in (POS, NEG): + contexts = st.get_kv_caches(kv_branch, seq_len=BLOCK, commit_current=False) + assert len(contexts) == 2 + assert all(isinstance(ctx, ARDiffusionPagedLayerContext) for ctx in contexts) + assert contexts[0].history_block_ids == [] + + +def test_managed_paged_context_commits_only_after_forward(): + kv, st = make_state(num_layers=1) + contexts = st.get_kv_caches(POS, seq_len=2 * BLOCK, commit_current=True) + ctx = contexts[0].forward_ctx + + assert st.adapter(POS).completed_chunks == 0 + assert kv.window_block_ids(st.adapter(POS)) == [] + ctx.ensure_video_slots(torch.device("cpu")) + assert st.adapter(POS).completed_chunks == 0 + assert len(ctx.current_video_block_ids) == 2 + + st.commit_paged_context(POS) + + assert st.adapter(POS).completed_chunks == 2 + assert st._committed[POS] == 2 * BLOCK + assert len(kv.window_block_ids(st.adapter(POS))) == 2 + + +def test_scratch_paged_context_does_not_commit(): + kv, st = make_state(num_layers=1) + contexts = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False) + ctx = contexts[0].forward_ctx + ctx.ensure_video_slots(torch.device("cpu")) + + assert ctx.current_video_block_ids == kv.scratch_block_ids(POS, 0, 1) + st.commit_paged_context(POS) + + assert st.adapter(POS).completed_chunks == 0 + assert st._committed[POS] == 0 + assert kv.window_block_ids(st.adapter(POS)) == [] + + +def test_eviction_bounds_resident_window(): + kv, st = make_state(num_layers=1, window_chunks=3) + _prepare_and_commit(st, POS, 3) + assert len(kv.window_block_ids(st.adapter(POS))) == 3 + + _prepare_and_commit(st, POS, 2) + ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0].forward_ctx + visible_blocks, video_len = ctx.video_block_table(torch.device("cpu")) + assert len(visible_blocks) == 3 + assert video_len == 3 * BLOCK + + +def test_branches_are_independent(): + kv, st = make_state() + _prepare_and_commit(st, POS, 1) + assert len(kv.window_block_ids(st.adapter(POS))) == 1 + assert kv.window_block_ids(st.adapter(NEG)) == [] + + +def test_reset_clears_session_window(): + kv, st = make_state(num_layers=1, window_chunks=4) + _prepare_and_commit(st, POS, 2) + _prepare_and_commit(st, NEG, 1) + assert st._committed[POS] == 2 * BLOCK and st._committed[NEG] == BLOCK + free_before = kv.manager.block_pool.get_num_free_blocks() + + st.reset() + + assert st._committed == {POS: 0, NEG: 0} + assert st._paged_pending == {POS: None, NEG: None} + assert kv.window_block_ids(st.adapter(POS)) == [] + assert kv.window_block_ids(st.adapter(NEG)) == [] + assert kv.manager.block_pool.get_num_free_blocks() > free_before + _prepare_and_commit(st, POS, 1) + assert len(kv.window_block_ids(st.adapter(POS))) == 1 + + +def test_get_cross_attention_kv_raises_before_populate(): + _, st = make_state(cross_attn_length=8) + for kv_branch in (POS, NEG): + with pytest.raises(RuntimeError, match="before it was populated"): + st.get_cross_attention_kv(kv_branch, "text") + + +def test_get_cross_attention_kv_returns_session_pool_dicts_when_populated(): + L = 8 + kv, st = make_state(num_layers=2, cross_attn_length=L) + written = [] + for _ in range(2): + k = torch.randn(1, L, N_HEADS, HEAD_DIM) + v = torch.randn(1, L, N_HEADS, HEAD_DIM) + written.append((k, v)) + st.populate_cross_attention(POS, "text", written) + + out = st.get_cross_attention_kv(POS, "text") + assert len(out) == 2 + for i, (k, v) in enumerate(written): + assert out[i]["is_init"] is True + assert out[i]["k"].shape == (1, L, N_HEADS, HEAD_DIM) + assert torch.equal(out[i]["k"], k) + assert torch.equal(out[i]["v"], v) + + +def test_window_reset_can_keep_named_cross_attention_cache(): + kv, st = make_state(num_layers=1, cross_attn_length=8) + k = torch.randn(1, 8, N_HEADS, HEAD_DIM) + v = torch.randn(1, 8, N_HEADS, HEAD_DIM) + st.populate_cross_attention(POS, "text", [(k, v)]) + old_adapter = st.adapter(POS) + + st.reset(keep_cross_attention=("text",)) + + assert st.adapter(POS) is not old_adapter + assert torch.equal(st.get_cross_attention_kv(POS, "text")[0]["k"], k) + assert "s1" in kv._cross_sessions + + st.reset() + assert "s1" not in kv._cross_sessions + assert not st.is_cross_attention_populated(POS, "text") + + +def test_cross_attention_uses_logical_branch_when_local_slot_is_shared(): + _, st = make_state(num_layers=2, cross_attn_length=8, shared_local_index=True) + assert st.kv_cache.num_local_kv_branches == 1 + pos_layers = [ + (torch.full((1, 8, N_HEADS, HEAD_DIM), 1.0), torch.full((1, 8, N_HEADS, HEAD_DIM), 2.0)) for _ in range(2) + ] + neg_layers = [ + (torch.full((1, 8, N_HEADS, HEAD_DIM), 3.0), torch.full((1, 8, N_HEADS, HEAD_DIM), 4.0)) for _ in range(2) + ] + + st.populate_cross_attention(POS, "text", pos_layers) + st.populate_cross_attention(NEG, "text", neg_layers) + + for layer in st.get_cross_attention_kv(POS, "text"): + assert torch.count_nonzero(layer["k"] != 1.0) == 0 + assert torch.count_nonzero(layer["v"] != 2.0) == 0 + for layer in st.get_cross_attention_kv(NEG, "text"): + assert torch.count_nonzero(layer["k"] != 3.0) == 0 + assert torch.count_nonzero(layer["v"] != 4.0) == 0 + + +def test_cross_attention_population_is_transactional(): + kv, st = make_state(num_layers=2, cross_attn_length=8) + original = [ + (torch.full((1, 8, N_HEADS, HEAD_DIM), value), torch.full((1, 8, N_HEADS, HEAD_DIM), -value)) + for value in (1.0, 2.0) + ] + replacement = ( + torch.full((1, 8, N_HEADS, HEAD_DIM), 9.0), + torch.full((1, 8, N_HEADS, HEAD_DIM), -9.0), + ) + + def interrupted_population(): + yield replacement + raise RuntimeError("projection failed") + + with pytest.raises(RuntimeError, match="projection failed"): + st.populate_cross_attention(POS, "text", interrupted_population()) + assert not st.is_cross_attention_populated(POS, "text") + assert "s1" not in kv._cross_sessions + + st.populate_cross_attention(POS, "text", original) + + with pytest.raises(RuntimeError, match="projection failed"): + st.populate_cross_attention(POS, "text", interrupted_population()) + out = st.get_cross_attention_kv(POS, "text") + for layer_index, layer in enumerate(out): + assert torch.equal(layer["k"], original[layer_index][0]) + assert torch.equal(layer["v"], original[layer_index][1]) + + for invalid_layers in (original[:1], [*original, replacement]): + with pytest.raises(ValueError, match="expected 2 layers"): + st.populate_cross_attention(POS, "text", invalid_layers) + out = st.get_cross_attention_kv(POS, "text") + for layer_index, layer in enumerate(out): + assert torch.equal(layer["k"], original[layer_index][0]) + assert torch.equal(layer["v"], original[layer_index][1]) + + invalid_shape = (torch.empty(1, 7, N_HEADS, HEAD_DIM), original[0][1]) + with pytest.raises(ValueError, match="expected k/v shape"): + st.populate_cross_attention(POS, "text", [invalid_shape, original[1]]) + out = st.get_cross_attention_kv(POS, "text") + assert torch.equal(out[0]["k"], original[0][0]) + + +def test_closed_state_cannot_repopulate_cross_attention(): + kv, st = make_state(num_layers=1, cross_attn_length=8) + layer = (torch.ones(1, 8, N_HEADS, HEAD_DIM), torch.ones(1, 8, N_HEADS, HEAD_DIM)) + st.close() + + with pytest.raises(RuntimeError, match="is closed"): + st.populate_cross_attention(POS, "text", [layer]) + assert "s1" not in kv._cross_sessions + + +def test_kv_create_is_noop_engine_owns_allocation(): + from unittest.mock import MagicMock + + from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline + + p = DreamZeroPipeline.__new__(DreamZeroPipeline) + p._ar_diffusion_kv_state = object() + state = MagicMock() + p._kv_create(state, 1, "float32", "cpu", 24, 4, 64) + state.create_kv_caches.assert_not_called() + + +def test_get_kv_cache_requires_frame_aligned_seqlen(): + _, st = make_state() + with pytest.raises(AssertionError, match="frame-aligned"): + st.get_kv_caches(POS, seq_len=BLOCK + 1, commit_current=True) + + +def test_prepare_one_branch_allocates_nothing_for_the_other(): + """CFG-parallel laziness: a rank prepares only the kv_branch it runs.""" + kv, st = make_state(num_layers=1, window_chunks=4) + pos_ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=True)[0].forward_ctx + neg_ctx = st.get_kv_caches(NEG, seq_len=BLOCK, commit_current=True)[0].forward_ctx + + pos_ctx.prepare(device=torch.device("cpu"), action_len=0, query_len=BLOCK) + + assert pos_ctx._allocated_video + assert not neg_ctx._allocated_video + assert kv.window_block_ids(st.adapter(NEG)) == [] diff --git a/tests/ar_diffusion/test_slot_mapping.py b/tests/diffusion/ar_diffusion/test_slot_mapping.py similarity index 97% rename from tests/ar_diffusion/test_slot_mapping.py rename to tests/diffusion/ar_diffusion/test_slot_mapping.py index 061df4d9c..4c1f82537 100644 --- a/tests/ar_diffusion/test_slot_mapping.py +++ b/tests/diffusion/ar_diffusion/test_slot_mapping.py @@ -13,6 +13,9 @@ from vllm_omni.experimental.ar_diffusion.kv_cache import ( resident_block_ids, ) +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + BLOCK = 16 diff --git a/tests/diffusion/attention/test_attention_config.py b/tests/diffusion/attention/test_attention_config.py index a4c67851a..4a2c65cbb 100644 --- a/tests/diffusion/attention/test_attention_config.py +++ b/tests/diffusion/attention/test_attention_config.py @@ -31,21 +31,94 @@ from vllm_omni.diffusion.data import ( parse_attention_config, ) +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + class TestAttentionSpec: - def test_construct_no_extra(self): + def test_construct_no_skip_softmax(self): spec = AttentionSpec(backend="FLASH_ATTN") - assert spec.extra == {} + assert spec.skip_softmax is None - def test_mapping_extra_normalized(self): - spec = AttentionSpec(backend="SAGE_ATTN", extra={"quant": "int8"}) - assert spec.backend == "SAGE_ATTN" - assert spec.extra == {"quant": "int8"} + def test_skip_softmax_mapping_coerced(self): + spec = AttentionSpec(backend="TRTLLM_ATTN", skip_softmax={"target_sparsity": 0.5}) + assert spec.backend == "TRTLLM_ATTN" + assert spec.skip_softmax.target_sparsity == 0.5 def test_invalid_backend_type(self): with pytest.raises(TypeError): AttentionSpec(backend=123) # type: ignore[arg-type] + def test_skip_softmax_rejected_on_non_trtllm(self): + with pytest.raises(ValueError, match="only supported by the TRTLLM_ATTN"): + AttentionSpec(backend="TORCH_SDPA", skip_softmax={"target_sparsity": 0.5}) + + def test_quant_serialized_with_defaults_and_overrides(self): + assert AttentionSpec(backend="TRTLLM_ATTN", quant={"dtype_qk": "fp8_e4m3"}).backend_kwargs()["quant"] == { + "dtype_qk": "fp8_e4m3", + "q_block_size": 1, + "k_block_size": 16, + } + assert AttentionSpec( + backend="TRTLLM_ATTN", quant={"dtype_qk": "int8", "q_block_size": 4, "k_block_size": 16} + ).backend_kwargs()["quant"] == {"dtype_qk": "int8", "q_block_size": 4, "k_block_size": 16} + + def test_quant_superset_fields_passed_through(self): + # FLASHINFER_ATTN-style config (dtype_vo / flashinfer_backend) round-trips on the shared spec. + bk = AttentionSpec( + backend="FLASHINFER_ATTN", + quant={"dtype_qk": "bfloat16", "dtype_vo": "fp8_e4m3", "flashinfer_backend": "trtllm-gen"}, + ).backend_kwargs()["quant"] + assert bk["dtype_qk"] == "bfloat16" + assert bk["dtype_vo"] == "fp8_e4m3" + assert bk["flashinfer_backend"] == "trtllm-gen" + + def test_quant_and_skip_softmax_coexist(self): + bk = AttentionSpec( + backend="TRTLLM_ATTN", quant={"dtype_qk": "int8"}, skip_softmax={"target_sparsity": 0.5} + ).backend_kwargs() + assert bk["target_sparsity"] == 0.5 and bk["quant"]["dtype_qk"] == "int8" + + @pytest.mark.parametrize( + "spec, match", + [ + ({"backend": "TORCH_SDPA", "quant": {"dtype_qk": "int8"}}, "only supported by the TRTLLM_ATTN"), + ({"backend": "TRTLLM_ATTN", "quant": {"dtype_qk": "int4"}}, "quant.dtype_qk"), + ({"backend": "TRTLLM_ATTN", "quant": {"dtype_qk": "int8", "k_block_size": 8}}, "quant.k_block_size"), + ], + ) + def test_quant_validation_rejects(self, spec, match): + with pytest.raises(ValueError, match=match): + AttentionSpec(**spec) + + def test_block_sparse_defaults_applied_when_backend_selected(self): + spec = AttentionSpec(backend="RAINFUSION_ATTN") + assert spec.block_sparse.sparsity == 0.8 + assert spec.backend_kwargs() == {"sparsity": 0.8, "start_step": 0} + + def test_block_sparse_skip_layers_selector_expanded(self): + spec = AttentionSpec( + backend="RAINFUSION_ATTN", + block_sparse={"sparsity": 0.9, "start_step": 12, "skip_layers": "0-2,38"}, + ) + assert spec.block_sparse.skip_layer_indices == {0, 1, 2, 38} + assert spec.backend_kwargs() == { + "sparsity": 0.9, + "start_step": 12, + "skip_layers": [0, 1, 2, 38], + } + + def test_block_sparse_rejected_on_dense_backend(self): + with pytest.raises(ValueError, match="block_sparse is only supported by"): + AttentionSpec(backend="FLASH_ATTN", block_sparse={"sparsity": 0.8}) + + @pytest.mark.parametrize( + "block_sparse", + [{"sparsity": 1.5}, {"start_step": -1}], + ) + def test_block_sparse_invalid_values(self, block_sparse): + with pytest.raises(ValueError): + AttentionSpec(backend="RAINFUSION_ATTN", block_sparse=block_sparse) + class TestAttentionConfig: def test_empty_config(self): @@ -57,13 +130,13 @@ class TestAttentionConfig: config = AttentionConfig( default={"backend": "FLASH_ATTN"}, per_role={ - "self": {"backend": "SPARSE_BLOCK", "extra": {"block_size": 128}}, + "self": {"backend": "TRTLLM_ATTN", "skip_softmax": {"target_sparsity": 0.5}}, "cross": "SAGE_ATTN", }, ) assert config.default.backend == "FLASH_ATTN" - assert config.per_role["self"].backend == "SPARSE_BLOCK" - assert config.per_role["self"].extra == {"block_size": 128} + assert config.per_role["self"].backend == "TRTLLM_ATTN" + assert config.per_role["self"].skip_softmax.target_sparsity == 0.5 assert config.per_role["cross"].backend == "SAGE_ATTN" def test_constructor_flattens_nested_per_role_tree(self): @@ -178,10 +251,10 @@ class TestAttentionConfig: config = AttentionConfig( default=AttentionSpec(backend="FLASH_ATTN"), per_role={ - "self": AttentionSpec(backend="SPARSE_BLOCK", extra={"block_size": 128}), + "self": AttentionSpec(backend="SPARSE_BLOCK"), "cross": AttentionSpec(backend="SAGE_ATTN"), "ltx2.audio_self": AttentionSpec(backend="FLASH_ATTN"), - "ltx2.audio_to_video": AttentionSpec(backend="FLASH_ATTN", extra={"causal_window": 64}), + "ltx2.audio_to_video": AttentionSpec(backend="FLASH_ATTN"), }, ) @@ -200,7 +273,6 @@ class TestAttentionConfig: # audio-to-video → exact match spec, _ = config.resolve_with_source("ltx2.audio_to_video", "cross") assert spec.backend == "FLASH_ATTN" - assert spec.extra == {"causal_window": 64} # video-to-audio → category fallback to "cross" assert config.resolve_with_source("ltx2.video_to_audio", "cross")[0].backend == "SAGE_ATTN" @@ -283,6 +355,13 @@ class TestBuildAttentionConfig: class TestOmniDiffusionConfigAttentionParsing: """Test OmniDiffusionConfig attention shorthand and structured config.""" + @pytest.fixture(autouse=True) + def _clear_diffusion_attention_backend_env(self, monkeypatch): + # OmniDiffusionConfig.__post_init__ applies DIFFUSION_ATTENTION_BACKEND via + # build_attention_config(); clear it so these tests assert config defaults, + # not whatever the process inherited from CI / sibling tests. + monkeypatch.delenv("DIFFUSION_ATTENTION_BACKEND", raising=False) + def test_diffusion_attention_backend_sets_default(self): config = OmniDiffusionConfig.from_kwargs(diffusion_attention_backend="SAGE_ATTN") assert isinstance(config.diffusion_attention_config, AttentionConfig) @@ -364,12 +443,13 @@ class TestAttentionInitUsesCurrentDiffusionConfig: head_size, attention_config=None, role_category=None, + allow_trtllm_default=False, ): captured["role"] = role captured["head_size"] = head_size captured["role_category"] = role_category captured["attention_config"] = attention_config - return _FakeBackend, AttentionSpec(backend="TORCH_SDPA", extra={"block_size": 128}) + return _FakeBackend, AttentionSpec(backend="TRTLLM_ATTN", skip_softmax={"target_sparsity": 0.5}) class _FakeRingParallelAttention: def __init__(self, sp_group, attn_backend_pref=None): @@ -391,7 +471,7 @@ class TestAttentionInitUsesCurrentDiffusionConfig: od_config = SimpleNamespace( diffusion_attention_config=AttentionConfig( default=AttentionSpec(backend="FLASH_ATTN"), - per_role={"cross": AttentionSpec(backend="TORCH_SDPA", extra={"block_size": 128})}, + per_role={"cross": AttentionSpec(backend="TRTLLM_ATTN", skip_softmax={"target_sparsity": 0.5})}, ), parallel_config=SimpleNamespace(ring_degree=2), diffusion_kv_cache_dtype=None, @@ -414,12 +494,12 @@ class TestAttentionInitUsesCurrentDiffusionConfig: assert captured["role_category"] == "cross" assert captured["head_size"] == 64 assert captured["attention_config"] is od_config.diffusion_attention_config - assert attn.backend_pref == "TORCH_SDPA" - assert attn.attention.kwargs["backend_kwargs"] == {"block_size": 128} + assert attn.backend_pref == "TRTLLM_ATTN" + assert attn.attention.kwargs["backend_kwargs"] == {"target_sparsity": 0.5} assert attn.attention.kwargs["qkv_layout"] == "BSND" assert attn.use_ring is True assert attn.ring_runner is not None - assert attn.ring_runner.attn_backend_pref == "TORCH_SDPA" + assert attn.ring_runner.attn_backend_pref == "TRTLLM_ATTN" class TestDiffusionKvCacheQuantization: @@ -453,7 +533,10 @@ class TestDiffusionKvCacheQuantization: monkeypatch.setattr( layer_mod, "get_attn_backend_for_role", - lambda role, head_size, attention_config=None, role_category=None: (_FakeBackend, None), + lambda role, head_size, attention_config=None, role_category=None, allow_trtllm_default=False: ( + _FakeBackend, + None, + ), ) monkeypatch.setattr(layer_mod.SDPABackend, "get_impl_cls", staticmethod(lambda: _FakeAttentionImpl)) monkeypatch.setattr(layer_mod, "build_parallel_attention_strategy", lambda **kwargs: object()) diff --git a/tests/diffusion/attention/test_attention_sp.py b/tests/diffusion/attention/test_attention_sp.py index e5fb93d71..0f1faacd1 100644 --- a/tests/diffusion/attention/test_attention_sp.py +++ b/tests/diffusion/attention/test_attention_sp.py @@ -31,7 +31,11 @@ import tempfile import pytest import torch +from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata, QueryRange from vllm_omni.diffusion.attention.layer import Attention +from vllm_omni.diffusion.attention.parallel.allgather_kv import ( + AllGatherKVParallelAttention, +) from vllm_omni.diffusion.config import set_current_diffusion_config from vllm_omni.diffusion.data import ( DiffusionParallelConfig, @@ -45,6 +49,8 @@ from vllm_omni.diffusion.distributed.parallel_state import ( from vllm_omni.diffusion.forward_context import set_forward_context from vllm_omni.platforms import current_omni_platform +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + def update_environment_variables(envs_dict: dict[str, str]): """Update multiple environment variables with logging.""" @@ -155,14 +161,267 @@ class TestMultiLayerAttentionModel(torch.nn.Module): return hidden_states +class _MockAllGatherSPGroup: + def __init__(self, *, rank: int, gather_chunks: list[list[torch.Tensor]]) -> None: + self.allgather_group = object() + self.allgather_world_size = len(gather_chunks[0]) + self.allgather_rank = rank + self._gather_chunks = list(gather_chunks) + self.gathered_input_shapes: list[tuple[int, ...]] = [] + + def all_gather(self, input_: torch.Tensor, dim: int = 0, separate_tensors: bool = False, group=None): + assert not separate_tensors + assert group is self.allgather_group + self.gathered_input_shapes.append(tuple(input_.shape)) + chunks = self._gather_chunks.pop(0) + return torch.cat(chunks, dim=dim) + + +def test_allgather_kv_slices_full_dense_mask_to_local_query_rows(): + rank = 1 + joint_len = 1 + img_seq_local = 2 + img_seq_full = 4 + key_chunks = [ + torch.full((1, img_seq_local, 1, 1), 10.0), + torch.full((1, img_seq_local, 1, 1), 20.0), + ] + value_chunks = [ + torch.full((1, img_seq_local, 1, 1), 30.0), + torch.full((1, img_seq_local, 1, 1), 40.0), + ] + strategy = AllGatherKVParallelAttention( + _MockAllGatherSPGroup(rank=rank, gather_chunks=[key_chunks, value_chunks]), + ) + + query = torch.zeros((1, img_seq_local, 1, 1)) + key = key_chunks[rank] + value = value_chunks[rank] + joint = torch.ones((1, joint_len, 1, 1)) + mask = torch.arange((joint_len + img_seq_full) * (joint_len + img_seq_full)) + mask = (mask % 2 == 0).view(1, 1, joint_len + img_seq_full, joint_len + img_seq_full) + metadata = AttentionMetadata( + attn_mask=mask, + joint_query=joint, + joint_key=joint, + joint_value=joint, + joint_strategy="front", + ) + + q_local, k_full, v_full, metadata_local, _ = strategy.pre_attention(query, key, value, metadata) + + expected_rows = torch.cat( + [ + mask[..., :joint_len, :], + mask[ + ..., + joint_len + rank * img_seq_local : joint_len + (rank + 1) * img_seq_local, + :, + ], + ], + dim=-2, + ) + assert q_local.shape[1] == joint_len + img_seq_local + assert k_full.shape[1] == joint_len + img_seq_full + assert v_full.shape[1] == joint_len + img_seq_full + torch.testing.assert_close(k_full[:, joint_len:], torch.cat(key_chunks, dim=1)) + torch.testing.assert_close(v_full[:, joint_len:], torch.cat(value_chunks, dim=1)) + assert metadata_local is not None + assert torch.equal(metadata_local.attn_mask, expected_rows) + + +def test_allgather_kv_slices_rear_joint_dense_mask(): + rank = 1 + joint_len = 1 + img_seq_local = 2 + img_seq_full = 4 + key_chunks = [torch.zeros((1, img_seq_local, 1, 1)) for _ in range(2)] + value_chunks = [torch.zeros_like(chunk) for chunk in key_chunks] + strategy = AllGatherKVParallelAttention( + _MockAllGatherSPGroup(rank=rank, gather_chunks=[key_chunks, value_chunks]), + ) + query = torch.zeros((1, img_seq_local, 1, 1)) + joint = torch.ones((1, joint_len, 1, 1)) + mask = torch.arange((img_seq_full + joint_len) ** 2) + mask = (mask % 2 == 0).view(1, 1, img_seq_full + joint_len, img_seq_full + joint_len) + metadata = AttentionMetadata( + attn_mask=mask, + joint_query=joint, + joint_key=joint, + joint_value=joint, + joint_strategy="rear", + ) + + q_local, _, _, metadata_local, _ = strategy.pre_attention(query, key_chunks[rank], value_chunks[rank], metadata) + + img_start = rank * img_seq_local + expected_rows = torch.cat( + [ + mask[..., img_start : img_start + img_seq_local, :], + mask[..., img_seq_full : img_seq_full + joint_len, :], + ], + dim=-2, + ) + assert q_local.shape[1] == img_seq_local + joint_len + assert metadata_local is not None + assert torch.equal(metadata_local.attn_mask, expected_rows) + + +def test_allgather_kv_rejects_invalid_joint_strategy(): + chunks = [[torch.zeros((1, 2, 1, 1)) for _ in range(2)] for _ in range(2)] + strategy = AllGatherKVParallelAttention(_MockAllGatherSPGroup(rank=0, gather_chunks=chunks)) + joint = torch.ones((1, 1, 1, 1)) + metadata = AttentionMetadata( + joint_query=joint, + joint_key=joint, + joint_value=joint, + joint_strategy="back", + ) + + with pytest.raises(ValueError, match="Unsupported joint_strategy"): + strategy.pre_attention(torch.zeros((1, 2, 1, 1)), chunks[0][0], chunks[1][0], metadata) + + +def test_allgather_kv_preserves_global_spans_and_sets_query_ranges(): + strategy = AllGatherKVParallelAttention( + _MockAllGatherSPGroup( + rank=1, + gather_chunks=[ + [torch.zeros((1, 2, 1, 1)), torch.zeros((1, 2, 1, 1))], + [torch.zeros((1, 2, 1, 1)), torch.zeros((1, 2, 1, 1))], + ], + ), + ) + query = torch.zeros((1, 2, 1, 1)) + key = torch.zeros((1, 2, 1, 1)) + value = torch.zeros((1, 2, 1, 1)) + joint = torch.ones((1, 1, 1, 1)) + metadata = AttentionMetadata( + joint_query=joint, + joint_key=joint, + joint_value=joint, + full_attn_spans=[ + [ + (0, 1), # joint span: kept on every rank. + (2, 5), # image span crossing rank 0 and rank 1 image shards. + ] + ], + ) + + _, _, _, metadata_out, _ = strategy.pre_attention(query, key, value, metadata) + + assert metadata_out is not None + assert metadata_out.full_attn_spans == [[(0, 1), (2, 5)]] + assert metadata_out.query_ranges == ( + QueryRange(0, 1, 0), + QueryRange(1, 3, 3), + ) + + +def test_allgather_kv_query_ranges_include_reused_prefix_offset(): + strategy = AllGatherKVParallelAttention( + _MockAllGatherSPGroup( + rank=1, + gather_chunks=[ + [torch.zeros((1, 2, 1, 1)), torch.zeros((1, 2, 1, 1))], + [torch.zeros((1, 2, 1, 1)), torch.zeros((1, 2, 1, 1))], + ], + ), + ) + query = torch.zeros((1, 2, 1, 1)) + key = torch.zeros((1, 2, 1, 1)) + value = torch.zeros((1, 2, 1, 1)) + joint_query = torch.ones((1, 1, 1, 1)) + # K/V retain two reused AR-prefix tokens that have already been removed + # from Q and from the attention mask's query rows. + joint_key = torch.ones((1, 3, 1, 1)) + joint_value = torch.ones((1, 3, 1, 1)) + mask = torch.arange(5 * 7).view(1, 1, 5, 7) + metadata = AttentionMetadata( + attn_mask=mask, + joint_query=joint_query, + joint_key=joint_key, + joint_value=joint_value, + full_attn_spans=[[(0, 7)]], + ) + + _, _, _, metadata_out, _ = strategy.pre_attention(query, key, value, metadata) + + assert metadata_out is not None + assert metadata_out.query_ranges == ( + QueryRange(0, 1, 2), + QueryRange(1, 3, 5), + ) + expected_mask = torch.cat([mask[..., :1, :], mask[..., 3:5, :]], dim=-2) + assert torch.equal(metadata_out.attn_mask, expected_mask) + + +def test_allgather_kv_allows_empty_full_attn_spans(): + strategy = AllGatherKVParallelAttention( + _MockAllGatherSPGroup( + rank=0, + gather_chunks=[ + [torch.zeros((1, 2, 1, 1)), torch.zeros((1, 2, 1, 1))], + [torch.zeros((1, 2, 1, 1)), torch.zeros((1, 2, 1, 1))], + ], + ), + ) + query = torch.zeros((1, 2, 1, 1)) + key = torch.zeros((1, 2, 1, 1)) + value = torch.zeros((1, 2, 1, 1)) + metadata = AttentionMetadata(full_attn_spans=[[]]) + + _, _, _, metadata_out, _ = strategy.pre_attention(query, key, value, metadata) + + assert metadata_out is not None + assert metadata_out.full_attn_spans == [[]] + assert metadata_out.query_ranges == (QueryRange(0, 2, 0),) + + +def test_allgather_kv_keeps_gathered_kv_compressed_for_gqa(): + rank = 0 + img_seq_local = 2 + kv_heads = 2 + repeat_num = 2 + q_heads = kv_heads * repeat_num + key_chunks = [ + torch.arange(1 * img_seq_local * kv_heads * 1, dtype=torch.float32).view(1, img_seq_local, kv_heads, 1), + torch.arange(100, 100 + 1 * img_seq_local * kv_heads * 1, dtype=torch.float32).view( + 1, img_seq_local, kv_heads, 1 + ), + ] + value_chunks = [chunk + 1000 for chunk in key_chunks] + sp_group = _MockAllGatherSPGroup(rank=rank, gather_chunks=[key_chunks, value_chunks]) + strategy = AllGatherKVParallelAttention(sp_group) + + query = torch.zeros((1, img_seq_local, q_heads, 1)) + _, k_full, v_full, _, _ = strategy.pre_attention(query, key_chunks[rank], value_chunks[rank], AttentionMetadata()) + + assert sp_group.gathered_input_shapes == [ + (1, img_seq_local, kv_heads, 1), + (1, img_seq_local, kv_heads, 1), + ] + assert k_full.shape == (1, img_seq_local * 2, kv_heads, 1) + assert v_full.shape == (1, img_seq_local * 2, kv_heads, 1) + expected_key = torch.cat(key_chunks, dim=1) + expected_value = torch.cat(value_chunks, dim=1) + torch.testing.assert_close(k_full, expected_key) + torch.testing.assert_close(v_full, expected_value) + + @pytest.mark.parametrize( "test_model_cls", [ TestMultiLayerAttentionModel, ], ) -@pytest.mark.parametrize("ulysses_degree", [2]) -@pytest.mark.parametrize("ring_degree", [2]) +@pytest.mark.parametrize( + ("ulysses_degree", "ring_degree", "allgather_degree"), + [ + pytest.param(2, 2, 1, id="ulysses-ring"), + pytest.param(1, 1, 2, id="allgather-kv"), + ], +) @pytest.mark.parametrize("batch_size", [2]) @pytest.mark.parametrize("seq_len", [16]) @pytest.mark.parametrize("num_heads", [8]) @@ -175,6 +434,7 @@ class TestMultiLayerAttentionModel(torch.nn.Module): def test_sequence_parallel( ulysses_degree: int, ring_degree: int, + allgather_degree: int, test_model_cls: type[torch.nn.Module], dtype: torch.dtype, causal: bool, @@ -186,8 +446,8 @@ def test_sequence_parallel( num_heads: int, head_size: int, ): - """Test Ulysses attention by comparing with and without SP enabled.""" - sequence_parallel_size = ulysses_degree * ring_degree + """Compare Ulysses/Ring and AllGather-KV SP against a single-rank run.""" + sequence_parallel_size = allgather_degree if allgather_degree > 1 else ulysses_degree * ring_degree # Skip if not enough GPUs available available_gpus = current_omni_platform.get_device_count() @@ -223,6 +483,7 @@ def test_sequence_parallel( use_compile, 1, # ulysses_degree = 1 1, # ring_degree = 1 + 1, # allgather_degree = 1 (baseline: no AllGather-KV SP) 1, # sequence_parallel_size = 1 baseline_output_file, model_state_file, @@ -233,7 +494,10 @@ def test_sequence_parallel( ) # Step 2: Run with SP enabled - print(f"\n[SP Test] Running with SP (ulysses_degree={ulysses_degree}, ring_degree={ring_degree})...") + print( + "\n[SP Test] Running with SP " + f"(ulysses={ulysses_degree}, ring={ring_degree}, allgather={allgather_degree})..." + ) torch.multiprocessing.spawn( ulysses_attention_on_test_model, args=( @@ -250,6 +514,7 @@ def test_sequence_parallel( use_compile, ulysses_degree, ring_degree, + allgather_degree, sequence_parallel_size, sp_output_file, model_state_file, @@ -345,6 +610,7 @@ def ulysses_attention_on_test_model( use_compile: bool, ulysses_degree: int, ring_degree: int, + allgather_degree: int, sequence_parallel_size: int, output_file: str, model_state_file: str, @@ -356,7 +622,11 @@ def ulysses_attention_on_test_model( RANDOM_SEED = 42 seed_everything(RANDOM_SEED) - mode_str = "Baseline (no SP)" if is_baseline else f"SP (ulysses={ulysses_degree}, ring={ring_degree})" + if allgather_degree > 1: + sp_kind = f"allgather={allgather_degree}" + else: + sp_kind = f"ulysses={ulysses_degree}, ring={ring_degree}" + mode_str = "Baseline (no SP)" if is_baseline else f"SP ({sp_kind})" print(f"\n[{mode_str}] Rank {local_rank}/{world_size} - Random seed set to {RANDOM_SEED}") device = torch.device(f"{current_omni_platform.device_type}:{local_rank}") @@ -384,6 +654,7 @@ def ulysses_attention_on_test_model( sequence_parallel_size=sequence_parallel_size, ulysses_degree=ulysses_degree, ring_degree=ring_degree, + allgather_degree=allgather_degree, cfg_parallel_size=1, ) @@ -400,6 +671,7 @@ def ulysses_attention_on_test_model( sequence_parallel_size=sequence_parallel_size, ulysses_degree=ulysses_degree, ring_degree=ring_degree, + allgather_degree=allgather_degree, tensor_parallel_size=1, pipeline_parallel_size=1, ) @@ -519,7 +791,11 @@ def ulysses_attention_on_test_model( with open(output_file, "wb") as f: pickle.dump(output_np, f) - mode_str = "baseline (no SP)" if is_baseline else f"SP (ulysses={ulysses_degree}, ring={ring_degree})" + if allgather_degree > 1: + sp_kind = f"allgather={allgather_degree}" + else: + sp_kind = f"ulysses={ulysses_degree}, ring={ring_degree}" + mode_str = "baseline (no SP)" if is_baseline else f"SP ({sp_kind})" print( f"\n[{mode_str}] ✓ Saved output with shape {full_output.shape}:\n" f" - batch_size={batch_size}, seq_len={seq_len}\n" diff --git a/tests/diffusion/attention/test_cudnn_attn.py b/tests/diffusion/attention/test_cudnn_attn.py new file mode 100644 index 000000000..de1adf8c7 --- /dev/null +++ b/tests/diffusion/attention/test_cudnn_attn.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from contextlib import nullcontext + +import pytest +import torch + +from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata +from vllm_omni.diffusion.attention.backends.cudnn_attn import CuDNNAttentionImpl + +pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model] + + +def test_cudnn_slices_valid_kv_prefix_without_padding_mask(monkeypatch): + observed = {} + + def fake_sdpa(query, key, value, **kwargs): + observed.update(query=query, key=key, value=value, kwargs=kwargs) + return query + + monkeypatch.setattr( + "vllm_omni.diffusion.attention.backends.cudnn_attn.sdpa_kernel", + lambda _backends: nullcontext(), + ) + monkeypatch.setattr(torch.nn.functional, "scaled_dot_product_attention", fake_sdpa) + impl = CuDNNAttentionImpl( + num_heads=2, + head_size=4, + softmax_scale=0.5, + ) + query = torch.randn(1, 8, 2, 4) + key = torch.randn_like(query) + value = torch.randn_like(query) + + output = impl.forward_cuda( + query, + key, + value, + AttentionMetadata(extra={"valid_kv_length": 5}), + ) + + assert output.shape == query.shape + assert observed["query"].shape == (1, 2, 8, 4) + assert observed["key"].shape == (1, 2, 5, 4) + assert observed["value"].shape == (1, 2, 5, 4) + assert observed["kwargs"]["attn_mask"] is None + + +def test_cudnn_rejects_invalid_valid_kv_length(): + impl = CuDNNAttentionImpl( + num_heads=2, + head_size=4, + softmax_scale=0.5, + ) + query = torch.randn(1, 8, 2, 4) + + with pytest.raises(ValueError, match="valid_kv_length"): + impl.forward_cuda( + query, + query, + query, + AttentionMetadata(extra={"valid_kv_length": 9}), + ) diff --git a/tests/diffusion/attention/test_flash_attn.py b/tests/diffusion/attention/test_flash_attn.py index a6fcf8fcb..53673dd58 100644 --- a/tests/diffusion/attention/test_flash_attn.py +++ b/tests/diffusion/attention/test_flash_attn.py @@ -18,6 +18,8 @@ from vllm_omni.diffusion.attention.backends.sdpa import SDPAImpl from vllm_omni.diffusion.attention.backends.utils import fa # noqa: E402 from vllm_omni.platforms import current_omni_platform +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + is_gpu = current_omni_platform.is_cuda_alike() or current_omni_platform.is_xpu() HAS_FLASH_ATTN = fa.HAS_FLASH_ATTN flash_attn_func = fa.flash_attn_func # noqa: N813 @@ -297,6 +299,78 @@ def test_flash_attn_func_preferred_over_varlen(): print("✓ flash_attn_func forward works correctly!") +def test_piecewise_flash_attn_uses_varlen_fallback(monkeypatch): + calls = [] + + def fake_varlen_func(q, k, v, **kwargs): + assert q.shape[1] == 4 + assert k.shape[1] == 2 + assert v.shape[1] == 2 + calls.append(kwargs) + return q + + monkeypatch.setattr(fa, "flash_attn_func", None) + monkeypatch.setattr(fa, "flash_attn_varlen_func", fake_varlen_func) + monkeypatch.setattr(fa, "HAS_FLASH_ATTN", True) + + impl = FlashAttentionImpl(num_heads=4, num_kv_heads=2, head_size=4, softmax_scale=0.5, causal=False) + query = torch.randn(1, 3, 4, 4) + key = torch.randn(1, 3, 2, 4) + value = torch.randn(1, 3, 2, 4) + metadata = AttentionMetadata(full_attn_spans=[[(0, 3)]]) + + output = impl.forward_cuda(query, key, value, metadata) + + assert output.shape == query.shape + assert len(calls) == 1 + assert calls[0]["max_seqlen_q"] == 3 + assert calls[0]["max_seqlen_k"] == 3 + assert calls[0]["causal"] is False + assert calls[0]["softmax_scale"] == 0.5 + + +def test_packed_varlen_metadata_bypasses_mask_unpadding(monkeypatch): + calls = [] + + def fake_varlen_func(q, k, v, **kwargs): + calls.append((q.shape, k.shape, v.shape, kwargs)) + return q + + monkeypatch.setattr(fa, "HAS_FLASH_ATTN", True) + monkeypatch.setattr(fa, "flash_attn_varlen_func", fake_varlen_func) + + impl = FlashAttentionImpl(num_heads=2, head_size=4, softmax_scale=0.5, causal=False) + query = torch.randn(1, 8, 2, 4) + cu_seqlens = torch.tensor([0, 6, 8], dtype=torch.int32) + metadata = AttentionMetadata( + attn_mask=torch.tensor([[True] * 6 + [False] * 2]), + extra={ + "cu_seqlens_q": cu_seqlens, + "cu_seqlens_k": cu_seqlens, + "max_seqlen_q": 6, + "max_seqlen_k": 6, + }, + ) + + output = impl.forward_cuda(query, query, query, metadata) + + assert torch.equal(output, query) + assert len(calls) == 1 + assert calls[0][0] == torch.Size([8, 2, 4]) + assert calls[0][3]["cu_seqlens_q"] is cu_seqlens + assert calls[0][3]["max_seqlen_q"] == 6 + + +def test_packed_varlen_metadata_must_be_complete(monkeypatch): + monkeypatch.setattr(fa, "HAS_FLASH_ATTN", True) + impl = FlashAttentionImpl(num_heads=2, head_size=4, softmax_scale=0.5, causal=False) + query = torch.randn(1, 8, 2, 4) + metadata = AttentionMetadata(extra={"cu_seqlens_q": torch.tensor([0, 8], dtype=torch.int32)}) + + with pytest.raises(ValueError, match="Incomplete packed FlashAttention metadata"): + impl.forward_cuda(query, query, query, metadata) + + if __name__ == "__main__": print("Running FlashAttention Padding Tests...") print("=" * 60) diff --git a/tests/diffusion/attention/test_piecewise_attn.py b/tests/diffusion/attention/test_piecewise_attn.py index 656087623..05967e28f 100644 --- a/tests/diffusion/attention/test_piecewise_attn.py +++ b/tests/diffusion/attention/test_piecewise_attn.py @@ -19,10 +19,17 @@ import pytest import torch import torch.nn.functional as F +import vllm_omni.diffusion.attention.backends.utils.piecewise_attn as piecewise_module +from vllm_omni.diffusion.attention.backends.abstract import QueryRange from vllm_omni.diffusion.attention.backends.utils.piecewise_attn import ( + Segment, + build_segments, piecewise_attn, ) +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + DEVICE = torch.device("cpu") @@ -71,6 +78,12 @@ BATCH_CASES = [ ] +def test_build_segments_full_span_retains_global_kv_end(): + segments = build_segments([(2, 7)], query_offset=4, query_len=2) + + assert segments == [Segment(q_start=4, q_end=6, kv_end=7, mode="full")] + + @pytest.mark.parametrize("global_spans", SPAN_CASES) @pytest.mark.parametrize("q_range", Q_RANGE_CASES) @pytest.mark.parametrize("batch_size", BATCH_CASES) @@ -124,3 +137,80 @@ def test_piecewise_span_fully_before_qstart(): ) expected = _full_reference(query, key, value, global_spans, q_start, q_end, softmax_scale) torch.testing.assert_close(got, expected, atol=1e-5, rtol=1e-5) + + +def test_piecewise_noncontiguous_query_ranges_match_full_mask(): + torch.manual_seed(0) + B, H, D, Sk = 1, 2, 8, 8 + key = torch.randn(B, Sk, H, D, device=DEVICE) + value = torch.randn(B, Sk, H, D, device=DEVICE) + query = torch.randn(B, 4, H, D, device=DEVICE) + spans = [(2, 7)] + query_ranges = ( + QueryRange(0, 1, 0), + QueryRange(1, 4, 4), + ) + softmax_scale = D**-0.5 + + got = piecewise_attn( + query, + key, + value, + full_attn_spans=[spans], + softmax_scale=softmax_scale, + attn_func=_sdpa_attn_func, + query_ranges=query_ranges, + ) + expected = torch.cat( + [ + _full_reference(query[:, :1], key, value, spans, 0, 1, softmax_scale), + _full_reference(query[:, 1:], key, value, spans, 4, 7, softmax_scale), + ], + dim=1, + ) + torch.testing.assert_close(got, expected, atol=1e-5, rtol=1e-5) + + +def test_piecewise_rejects_incomplete_query_ranges(): + query = torch.zeros(1, 3, 1, 2) + key = torch.zeros(1, 3, 1, 2) + value = torch.zeros_like(key) + + with pytest.raises(ValueError, match="cover local query contiguously"): + piecewise_attn( + query, + key, + value, + full_attn_spans=[[]], + softmax_scale=1.0, + attn_func=_sdpa_attn_func, + query_ranges=(QueryRange(1, 3, 1),), + ) + + +def test_piecewise_combines_noncontiguous_query_ranges_with_one_cat(monkeypatch): + query = torch.randn(1, 4, 2, 8) + key = torch.randn(1, 8, 2, 8) + value = torch.randn_like(key) + cat_calls = 0 + original_cat = torch.cat + + def counted_cat(tensors, dim=0): + nonlocal cat_calls + cat_calls += 1 + return original_cat(tensors, dim=dim) + + monkeypatch.setattr(piecewise_module.torch, "cat", counted_cat) + + output = piecewise_attn( + query, + key, + value, + full_attn_spans=[[(0, 8)]], + softmax_scale=1.0, + attn_func=lambda q, k, v, **kwargs: q, + query_ranges=(QueryRange(0, 1, 0), QueryRange(1, 4, 4)), + ) + + assert cat_calls == 1 + torch.testing.assert_close(output, query) diff --git a/tests/diffusion/attention/test_rainfusion_plan.py b/tests/diffusion/attention/test_rainfusion_plan.py new file mode 100644 index 000000000..e88a973a9 --- /dev/null +++ b/tests/diffusion/attention/test_rainfusion_plan.py @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Geometry contract RAINFUSION_ATTN enforces before handing a forward to rf_v2. + +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. +""" + +import dataclasses + +import pytest +import torch + +from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata, VideoTokenLayout +from vllm_omni.diffusion.attention.backends.rainfusion_attn import ( + _BLOCK_SIZE, + RainFusionAttentionImpl, + RainFusionPlan, +) +from vllm_omni.platforms import current_omni_platform + +PREFIX_ROWS = 710 # 14 text rows + 696 audio rows +ALIGNED_GRID = (62, 24, 40) # 1280x768 -> 59520 video rows, 465 blocks +MISALIGNED_GRID = (62, 24, 42) # 1344x768 -> 62496 video rows, 488 blocks + 32 rows + + +def make_impl(**backend_kwargs): + return RainFusionAttentionImpl( + num_heads=8, + head_size=128, + softmax_scale=128**-0.5, + prefix="transformer_blocks.0.attn", + qkv_layout="BSND", + backend_kwargs={"sparsity": 0.8, **backend_kwargs}, + ) + + +def make_metadata(grid, prefix_len=PREFIX_ROWS, max_seqlen_q=None): + video_rows = grid[0] * grid[1] * grid[2] + return AttentionMetadata( + extra={"max_seqlen_q": prefix_len + video_rows if max_seqlen_q is None else max_seqlen_q}, + video_layout=VideoTokenLayout(prefix_len=prefix_len, latent_grid=grid), + ) + + +def test_block_aligned_video_segment_runs_sparse(): + plan = make_impl()._resolve_plan(make_metadata(ALIGNED_GRID)) + + assert plan is not None + assert plan.prefix_len == PREFIX_ROWS + assert plan.used_len == PREFIX_ROWS + 59520 + 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 + + +@pytest.mark.parametrize("prefix_len", [1, 127, _BLOCK_SIZE, 710, 900]) +def test_prefix_length_does_not_affect_alignment(prefix_len): + # Only the video segment has to land on a block boundary; rf_v2 pools the + # prefix separately and keeps every one of its blocks. + plan = make_impl()._resolve_plan(make_metadata(ALIGNED_GRID, prefix_len=prefix_len)) + + assert plan is not None + assert plan.prefix_len == prefix_len + + +def test_sparsity_zero_never_resolves_a_plan(): + assert make_impl(sparsity=0.0)._resolve_plan(make_metadata(ALIGNED_GRID)) is None + + +def test_missing_video_layout_falls_back_to_dense(): + assert make_impl()._resolve_plan(AttentionMetadata(extra={"max_seqlen_q": 60230})) is None + + +def test_missing_max_seqlen_falls_back_to_dense(): + metadata = AttentionMetadata( + video_layout=VideoTokenLayout(prefix_len=PREFIX_ROWS, latent_grid=ALIGNED_GRID), + ) + + assert make_impl()._resolve_plan(metadata) is None + + +def test_video_segment_must_be_the_tail_of_packed_document_zero(): + metadata = make_metadata(ALIGNED_GRID, max_seqlen_q=PREFIX_ROWS + 59520 + 128) + + assert make_impl()._resolve_plan(metadata) is None + + +@pytest.mark.parametrize("grid", [(4, 24, 40), (1, 24, 40)]) +def test_short_video_stays_dense(grid): + assert make_impl()._resolve_plan(make_metadata(grid)) is None + + +@pytest.mark.parametrize("qkv_layout", ["BNSD", "BSH"]) +def test_explicitly_wrong_layout_is_rejected(qkv_layout): + with pytest.raises(ValueError, match="BSND"): + RainFusionAttentionImpl( + num_heads=8, + head_size=128, + softmax_scale=128**-0.5, + qkv_layout=qkv_layout, + backend_kwargs={"sparsity": 0.8}, + ) + + +def test_undeclared_layout_stays_dense(): + # An undeclared layout is not an error -- the layer keeps working, just + # densely, and the fallback sees the same absent layout plain FLASH_ATTN would. + impl = RainFusionAttentionImpl( + num_heads=8, + head_size=128, + softmax_scale=128**-0.5, + prefix="transformer_blocks.0.attn", + backend_kwargs={"sparsity": 0.8}, + ) + + assert impl._resolve_plan(make_metadata(ALIGNED_GRID)) is None + assert impl.dense_fallback.qkv_layout is None + + +@pytest.mark.skipif(not current_omni_platform.is_npu(), reason="rf_v2 runs on Ascend NPU only.") +def test_fully_populated_mask_reproduces_dense_attention(): + """At sparsity=0 every key block is kept, so rf_v2 must match dense attention. + + This is the guard on the geometry the backend hands the kernel: a wrong + prefix length or latent grid still produces plausible output under sparse + selection, but shows up here as a mismatch against SDPA. + """ + torch.manual_seed(0) + prefix_len, grid = 384, (4, 16, 64) # 4096 video rows = 32 blocks + 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" diff --git a/tests/diffusion/attention/test_ring_fa4.py b/tests/diffusion/attention/test_ring_fa4.py new file mode 100644 index 000000000..b24ce2f20 --- /dev/null +++ b/tests/diffusion/attention/test_ring_fa4.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from types import SimpleNamespace + +import pytest +import torch + +from vllm_omni.diffusion.attention.backends.ring import ring_kernels +from vllm_omni.diffusion.attention.backends.ring.ring_selector import AttnType +from vllm_omni.diffusion.attention.parallel import ring as ring_parallel + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +def test_fa4_wrapper_returns_lse_and_normalizes_unbounded_window(monkeypatch): + expected_out = object() + expected_lse = object() + captured = {} + + def fake_fa4(q, k, v, **kwargs): + captured.update(q=q, k=k, v=v, **kwargs) + return expected_out, expected_lse + + monkeypatch.setattr(ring_kernels, "HAS_FA4", True) + monkeypatch.setattr(ring_kernels, "fa4_attn_func", fake_fa4) + + out, lse = ring_kernels.flash_attn4_func_forward( + "q", + "k", + "v", + softmax_scale=0.125, + causal=True, + window_size=(-1, -1), + ) + + assert (out, lse) == (expected_out, expected_lse) + assert captured == { + "q": "q", + "k": "k", + "v": "v", + "softmax_scale": 0.125, + "causal": True, + "window_size": (None, None), + "softcap": 0.0, + "return_lse": True, + } + + +def test_ring_prefers_fa4_on_blackwell(monkeypatch): + captured = {} + + def fake_ring_flash_attn_func(*args, **kwargs): + captured.update(kwargs) + return "output" + + monkeypatch.setattr(ring_parallel, "HAS_FA4", True) + monkeypatch.setattr(ring_parallel, "HAS_FA3", True) + monkeypatch.setattr(torch.cuda, "get_device_capability", lambda _device: (10, 3)) + + from vllm_omni.diffusion.attention.backends import ring_flash_attn + + monkeypatch.setattr(ring_flash_attn, "ring_flash_attn_func", fake_ring_flash_attn_func) + + query = SimpleNamespace( + dtype=torch.bfloat16, + shape=(1, 16, 4, 128), + is_cuda=True, + device=torch.device("cuda:0"), + ) + strategy = ring_parallel.RingParallelAttention(SimpleNamespace(ring_group=object())) + + assert strategy.run_attention(query, "key", "value", None) == "output" + assert captured["attn_type"] is AttnType.FA4 diff --git a/tests/diffusion/attention/test_sage_attn3.py b/tests/diffusion/attention/test_sage_attn3.py index e4d913974..5c46ea5d0 100644 --- a/tests/diffusion/attention/test_sage_attn3.py +++ b/tests/diffusion/attention/test_sage_attn3.py @@ -9,6 +9,9 @@ import pytest import torch from vllm.platforms import current_platform +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + SAGE_ATTN3_MODULE = "vllm_omni.diffusion.attention.backends.sage_attn3" diff --git a/tests/diffusion/attention/test_sdpa.py b/tests/diffusion/attention/test_sdpa.py new file mode 100644 index 000000000..d02642517 --- /dev/null +++ b/tests/diffusion/attention/test_sdpa.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +import torch + +import vllm_omni.diffusion.attention.backends.sdpa as sdpa_backend +from vllm_omni.diffusion.attention.backends.sdpa import SDPAImpl + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_sdpa_expands_kv_when_native_gqa_kernel_is_unavailable(monkeypatch): + calls = [] + + def fake_sdpa(query, key, value, **kwargs): + calls.append((query.shape, key.shape, value.shape, kwargs)) + return query + + monkeypatch.setattr(sdpa_backend, "can_sdpa_use_fused_gqa", lambda *args: False) + monkeypatch.setattr(torch.nn.functional, "scaled_dot_product_attention", fake_sdpa) + + impl = SDPAImpl(num_heads=4, num_kv_heads=2, head_size=8, softmax_scale=0.5) + output = impl.forward_cuda( + torch.randn(1, 3, 4, 8), + torch.randn(1, 3, 2, 8), + torch.randn(1, 3, 2, 8), + ) + + query_shape, key_shape, value_shape, kwargs = calls[0] + assert query_shape == key_shape == value_shape == (1, 4, 3, 8) + assert kwargs["enable_gqa"] is False + assert output.shape == (1, 3, 4, 8) + + +def test_sdpa_keeps_compressed_kv_when_native_gqa_kernel_is_available(monkeypatch): + calls = [] + + def fake_sdpa(query, key, value, **kwargs): + calls.append((query.shape, key.shape, value.shape, kwargs)) + return query + + monkeypatch.setattr(sdpa_backend, "can_sdpa_use_fused_gqa", lambda *args: True) + monkeypatch.setattr(torch.nn.functional, "scaled_dot_product_attention", fake_sdpa) + + impl = SDPAImpl(num_heads=4, num_kv_heads=2, head_size=8, softmax_scale=0.5) + output = impl.forward_cuda( + torch.randn(1, 3, 4, 8), + torch.randn(1, 3, 2, 8), + torch.randn(1, 3, 2, 8), + ) + + query_shape, key_shape, value_shape, kwargs = calls[0] + assert query_shape == (1, 4, 3, 8) + assert key_shape == value_shape == (1, 2, 3, 8) + assert kwargs["enable_gqa"] is True + assert output.shape == (1, 3, 4, 8) diff --git a/tests/diffusion/attention/test_selector.py b/tests/diffusion/attention/test_selector.py new file mode 100644 index 000000000..79755ac2e --- /dev/null +++ b/tests/diffusion/attention/test_selector.py @@ -0,0 +1,138 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for diffusion attention backend selection.""" + +from types import SimpleNamespace + +import pytest + +import vllm_omni.diffusion.attention.selector as selector +from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + +class _ConfiguredBackend: + @classmethod + def get_name(cls) -> str: + return "CONFIGURED" + + +class _PlatformBackend: + @classmethod + def get_name(cls) -> str: + return "PLATFORM" + + +@pytest.fixture(autouse=True) +def clear_selector_caches(): + selector._cached_get_backend_cls.cache_clear() + selector._log_backend_resolution.cache_clear() + yield + selector._cached_get_backend_cls.cache_clear() + selector._log_backend_resolution.cache_clear() + + +@pytest.mark.parametrize( + ("role", "role_category", "expected_backend"), + [ + ("ltx2.audio_to_video", "cross", "EXACT"), + ("ltx2.video_to_audio", "cross", "CATEGORY"), + ("joint", None, "DEFAULT"), + ], +) +def test_configured_selection_precedence( + monkeypatch, + role: str, + role_category: str | None, + expected_backend: str, +): + config = AttentionConfig( + default=AttentionSpec(backend="DEFAULT"), + per_role={ + "cross": AttentionSpec(backend="CATEGORY"), + "ltx2.audio_to_video": AttentionSpec(backend="EXACT"), + }, + ) + calls = [] + + def fake_get_backend(backend_name, head_size, allow_trtllm_default=True): + calls.append((backend_name, head_size, allow_trtllm_default)) + return _ConfiguredBackend + + monkeypatch.setattr(selector, "_cached_get_backend_cls", fake_get_backend) + + backend, spec = selector.get_attn_backend_for_role( + role=role, + role_category=role_category, + head_size=128, + attention_config=config, + ) + + assert backend is _ConfiguredBackend + assert spec is not None + assert spec.backend == expected_backend + assert calls == [(expected_backend, 128, True)] + + +@pytest.mark.parametrize("attention_config", [None, AttentionConfig()]) +def test_platform_default_used_without_resolved_spec(monkeypatch, attention_config): + calls = [] + + def fake_get_backend(backend_name, head_size, allow_trtllm_default=True): + calls.append((backend_name, head_size, allow_trtllm_default)) + return _PlatformBackend + + monkeypatch.setattr(selector, "_cached_get_backend_cls", fake_get_backend) + + backend, spec = selector.get_attn_backend_for_role( + role="self", + head_size=64, + attention_config=attention_config, + allow_trtllm_default=False, + ) + + assert backend is _PlatformBackend + assert spec is None + assert calls == [(None, 64, False)] + + +def test_backend_class_resolution_is_cached(monkeypatch): + platform_calls = [] + load_calls = [] + fake_platform = SimpleNamespace( + get_diffusion_attn_backend_cls=lambda **kwargs: (platform_calls.append(kwargs) or "fake.module.Backend") + ) + + monkeypatch.setattr("vllm_omni.platforms.current_omni_platform", fake_platform) + + def fake_load_backend(path): + load_calls.append(path) + return _ConfiguredBackend + + monkeypatch.setattr(selector, "_load_backend_cls", fake_load_backend) + + first = selector._cached_get_backend_cls("FLASH_ATTN", 128, False) + second = selector._cached_get_backend_cls("FLASH_ATTN", 128, False) + + assert first is second is _ConfiguredBackend + assert platform_calls == [ + { + "selected_backend": "FLASH_ATTN", + "head_size": 128, + "allow_trtllm_default": False, + } + ] + assert load_calls == ["fake.module.Backend"] + + +def test_load_backend_cls_reports_missing_module(): + with pytest.raises(ImportError, match="Failed to import module missing_attention_backend"): + selector._load_backend_cls("missing_attention_backend.Backend") + + +def test_load_backend_cls_reports_missing_class(monkeypatch): + monkeypatch.setattr(selector.importlib, "import_module", lambda _: SimpleNamespace()) + + with pytest.raises(AttributeError, match="Class MissingBackend not found in module"): + selector._load_backend_cls("fake.module.MissingBackend") diff --git a/tests/diffusion/attention/test_trtllm_attn.py b/tests/diffusion/attention/test_trtllm_attn.py new file mode 100644 index 000000000..76daf9d3b --- /dev/null +++ b/tests/diffusion/attention/test_trtllm_attn.py @@ -0,0 +1,577 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import math + +import pytest +import torch +import torch.nn.functional as F + +from vllm_omni.diffusion.attention.backends import trtllm_attn as tg +from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata +from vllm_omni.diffusion.attention.backends.trtllm_attn import TrtllmAttentionImpl + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cuda] + + +def _has_trtllm_attn() -> bool: + if not torch.cuda.is_available(): + return False + if torch.cuda.get_device_capability()[0] < 10: + return False + try: + import flashinfer # noqa: F401 + except Exception: + return False + return tg.HAS_FLASHINFER + + +requires_trtllm_attn = pytest.mark.skipif( + not _has_trtllm_attn(), reason="requires Blackwell SM100+ GPU with flashinfer" +) + + +def _impl(**bk): + return TrtllmAttentionImpl(8, 128, 1.0 / math.sqrt(128), causal=False, num_kv_heads=8, backend_kwargs=bk) + + +def test_skip_config_pure_resolution(): + from vllm_omni.diffusion.attention.backends.trtllm_attn import SkipSoftmaxConfig + + assert not SkipSoftmaxConfig().enabled + assert SkipSoftmaxConfig().resolve_factor(4096, timestep=0.3) is None + + cfg = SkipSoftmaxConfig.from_backend_kwargs({"skip_softmax_threshold": 0.01, "disabled_until_timestep": 0.6}) + assert cfg.enabled and cfg.gated + assert cfg.resolve_factor(4096, timestep=0.9) is None + assert cfg.resolve_factor(4096, timestep=0.3) == pytest.approx(0.01 * 4096) + assert cfg.resolve_factor(4096, timestep=None) == pytest.approx(0.01 * 4096) + + +def test_quant_config_from_backend_kwargs(): + from vllm_omni.diffusion.attention.backends.trtllm_attn import QuantConfig + + assert not QuantConfig.from_backend_kwargs(None).enabled + assert not QuantConfig.from_backend_kwargs({}).enabled + assert QuantConfig.from_backend_kwargs({"quant": {"dtype_qk": "fp8_e4m3"}}) == QuantConfig("fp8_e4m3", 1, 16) + qc = QuantConfig.from_backend_kwargs({"quant": {"dtype_qk": "int8", "q_block_size": 4, "k_block_size": 16}}) + assert qc.enabled and (qc.dtype_qk, qc.q_block_size, qc.k_block_size) == ("int8", 4, 16) + + +def test_quant_rejects_non_sage_dtype(): + with pytest.raises(RuntimeError, match="supports dtype_qk"): + _impl(quant={"dtype_qk": "bfloat16"}) + + +def test_quant_quantize_requires_flashinfer_routine(monkeypatch): + import vllm_omni.diffusion.attention.backends.trtllm_attn as mod + + monkeypatch.setattr(mod, "_sage_kernel_available", lambda: True) + monkeypatch.setattr(mod, "_sage_quantize_fn", lambda: None) + with pytest.raises(RuntimeError, match="trtllm_sage_attention_quantize"): + _impl(quant={"dtype_qk": "int8"}) + + +def test_quant_quantize_calls_routine_and_shapes_sfs(): + from vllm_omni.diffusion.attention.backends.trtllm_attn import QuantConfig + + captured = {} + + def fake_quantize(q, k, v, q_block_size, k_block_size, qk_quant_dtype): + captured.update(q_block_size=q_block_size, k_block_size=k_block_size, qk_quant_dtype=qk_quant_dtype) + return "qq", "kq", "vq", "qsfs", "ksfs", "vsfs" + + q_q, k_q, v_q, sfs, blk = QuantConfig(dtype_qk="int8").quantize(object(), object(), object(), fake_quantize) + assert (q_q, k_q, v_q, sfs, blk) == ("qq", "kq", "vq", ("qsfs", "ksfs", None, "vsfs"), (1, 16, 0, 1)) + assert captured == {"q_block_size": 1, "k_block_size": 16, "qk_quant_dtype": torch.int8} + + +def test_skip_factor_none_without_curve(): + assert _impl(target_sparsity=0.5)._resolve_skip_factor(4096) is None + + +def _calibrated(a, b, **bk): + impl = _impl(**bk) + impl.set_layer_calibration(a, b) + return impl + + +def test_skip_factor_from_curve(): + assert _calibrated(100.0, 0.0, target_sparsity=0.5)._resolve_skip_factor(4096) == pytest.approx(100.0) + f2 = _calibrated(2.0, 1.0, target_sparsity=0.5)._resolve_skip_factor(4096) + assert f2 == pytest.approx(2.0 * math.exp(0.5)) + + +def test_skip_factor_none_until_stamped(): + assert _impl(target_sparsity=0.5)._resolve_skip_factor(4096) is None + + +def test_skip_factor_from_threshold_no_calibration(): + impl = _impl(skip_softmax_threshold=0.01) + assert impl._resolve_skip_factor(4096) == pytest.approx(0.01 * 4096) + assert impl._resolve_skip_factor(8192) == pytest.approx(0.01 * 8192) + + impl2 = _calibrated(100.0, 0.0, skip_softmax_threshold=0.02, target_sparsity=0.5) + assert impl2._resolve_skip_factor(4096) == pytest.approx(0.02 * 4096) + + +def test_skip_timestep_gate(monkeypatch): + from vllm_omni.diffusion import forward_context as fc + + impl = _calibrated(100.0, 0.0, target_sparsity=0.5, disabled_until_timestep=0.6) + ctx = fc.ForwardContext(denoise_timestep=0.9) + monkeypatch.setattr(fc, "_forward_context", ctx) + assert impl._resolve_skip_factor(4096) is None + ctx.denoise_timestep = 0.3 + assert impl._resolve_skip_factor(4096) == pytest.approx(100.0) + + +def test_denoise_progress_mixin(monkeypatch): + import types + + from vllm_omni.diffusion import forward_context as fc + + class _Pipe(fc.DenoiseProgressMixin): + scheduler = types.SimpleNamespace(config=types.SimpleNamespace(num_train_timesteps=1000)) + + ctx = fc.ForwardContext() + monkeypatch.setattr(fc, "_forward_context", ctx) + _Pipe().record_denoise_step(3, 250) + assert ctx.denoise_step_idx == 3 + assert ctx.denoise_timestep == pytest.approx(0.25) + + _Pipe().record_denoise_step(4) + assert ctx.denoise_step_idx == 4 and ctx.denoise_timestep == pytest.approx(0.25) + + _Pipe().record_denoise_step(5, normalized_timestep=0.7) + assert ctx.denoise_step_idx == 5 and ctx.denoise_timestep == pytest.approx(0.7) + + +@pytest.mark.parametrize( + "bk, field", + [ + ({"target_sparsity": 1.5}, "target_sparsity"), + ({"target_sparsity": -0.1}, "target_sparsity"), + ({"disabled_until_timestep": 1.2}, "disabled_until_timestep"), + ({"skip_softmax_threshold": -1.0}, "skip_softmax_threshold"), + ({"target_sparsity": float("nan")}, "target_sparsity"), + ({"skip_softmax_threshold": float("inf")}, "skip_softmax_threshold"), + ], +) +def test_skip_control_validation_rejects_out_of_range(bk, field): + from vllm_omni.diffusion.attention.backends.trtllm_attn import SkipSoftmaxConfig + + with pytest.raises(ValueError, match=field): + SkipSoftmaxConfig.from_backend_kwargs(bk) + + +def test_skip_control_validation_accepts_valid(): + from vllm_omni.diffusion.attention.backends.trtllm_attn import SkipSoftmaxConfig + + cfg = SkipSoftmaxConfig.from_backend_kwargs( + {"target_sparsity": 0.65, "disabled_until_timestep": 0.86, "skip_softmax_threshold": 0.01} + ) + assert cfg.target_sparsity == pytest.approx(0.65) + assert cfg.disabled_until_timestep == pytest.approx(0.86) + assert cfg.threshold == pytest.approx(0.01) + + +def test_ring_sp_with_skip_softmax_rejected(): + import types + + from vllm_omni.diffusion.attention.layer import Attention + + fake = types.SimpleNamespace(attention=_impl(target_sparsity=0.5), ring_runner=None) + with pytest.raises(NotImplementedError, match="ring sequence parallelism"): + Attention._run_ring_attention(fake, None, None, None, None) + + +def test_mask_free_allowlist_gates_trtllm_default(): + from vllm_omni.diffusion.model_metadata import get_diffusion_model_metadata as meta + + assert meta("WanPipeline").attention_mask_free is True + assert meta("WanVACEPipeline").attention_mask_free is True + assert meta("MiniMaxH3Pipeline").attention_mask_free is True + assert meta("FluxPipeline").attention_mask_free is False + assert meta(None).attention_mask_free is False + + +def test_masked_layer_raises(): + b, s, h, d = 1, 8, 8, 128 + q, k, v = (torch.zeros(b, s, h, d) for _ in range(3)) + mask = torch.ones(b, s, dtype=torch.bool) + mask[:, s // 2 :] = False + + class _Meta: + attn_mask = mask + + with pytest.raises(ValueError, match="does not support attn_mask"): + _impl().forward_cuda(q, k, v, _Meta()) + + +def test_packed_metadata_trims_padding_mask(monkeypatch): + b, s, h, d = 1, 8, 8, 128 + q, k, v = (torch.zeros(b, s, h, d) for _ in range(3)) + mask = torch.tensor([[True, True, True, True, True, True, False, False]]) + cu_seqlens = torch.tensor([0, 6, 8], dtype=torch.int32) + metadata = AttentionMetadata( + attn_mask=mask, + extra={ + "cu_seqlens_q": cu_seqlens, + "cu_seqlens_k": cu_seqlens, + "max_seqlen_q": 6, + "max_seqlen_k": 6, + }, + ) + captured = {} + + def fake_attention(**kwargs): + captured.update(kwargs) + return torch.zeros_like(kwargs["query"]) + + monkeypatch.setattr(tg, "HAS_FLASHINFER", True) + monkeypatch.setattr(tg, "trtllm_ragged_attention_deepseek", fake_attention) + monkeypatch.setattr(TrtllmAttentionImpl, "_get_workspace", classmethod(lambda cls, device: torch.empty(0))) + + out = _impl().forward_cuda(q, k, v, metadata) + + assert out.shape == q.shape + assert captured["query"].shape[0] == 6 + assert captured["key"].shape[0] == 6 + assert captured["value"].shape[0] == 6 + assert captured["batch_size"] == 1 + assert captured["seq_lens"].tolist() == [6] + assert captured["cum_seq_lens_q"].tolist() == [0, 6] + assert captured["cum_seq_lens_kv"].tolist() == [0, 6] + assert captured["max_q_len"] == 6 + assert captured["max_kv_len"] == 6 + assert torch.count_nonzero(out[:, 6:]) == 0 + + +def test_packed_metadata_rejects_non_prefix_mask(monkeypatch): + b, s, h, d = 1, 8, 8, 128 + q, k, v = (torch.zeros(b, s, h, d) for _ in range(3)) + cu_seqlens = torch.tensor([0, 6, 8], dtype=torch.int32) + metadata = AttentionMetadata( + attn_mask=torch.tensor([[True, True, False, True, True, True, False, False]]), + extra={ + "cu_seqlens_q": cu_seqlens, + "cu_seqlens_k": cu_seqlens, + "max_seqlen_q": 6, + "max_seqlen_k": 6, + }, + ) + monkeypatch.setattr(tg, "HAS_FLASHINFER", True) + + with pytest.raises(ValueError, match="prefix-valid"): + _impl().forward_cuda(q, k, v, metadata) + + +def test_packed_metadata_must_be_complete(): + b, s, h, d = 1, 8, 8, 128 + q, k, v = (torch.zeros(b, s, h, d) for _ in range(3)) + metadata = AttentionMetadata(extra={"cu_seqlens_q": torch.tensor([0, s], dtype=torch.int32)}) + + with pytest.raises(ValueError, match="Incomplete packed TRTLLM attention metadata"): + _impl().forward_cuda(q, k, v, metadata) + + +def test_packed_metadata_must_cover_inputs(monkeypatch): + b, s, h, d = 1, 8, 8, 128 + q, k, v = (torch.zeros(b, s, h, d) for _ in range(3)) + cu_seqlens = torch.tensor([0, 6], dtype=torch.int32) + metadata = AttentionMetadata( + extra={ + "cu_seqlens_q": cu_seqlens, + "cu_seqlens_k": cu_seqlens, + "max_seqlen_q": 6, + "max_seqlen_k": 6, + } + ) + monkeypatch.setattr(tg, "HAS_FLASHINFER", True) + + with pytest.raises(ValueError, match="must cover all Q/K/V tokens"): + _impl().forward_cuda(q, k, v, metadata) + + +def test_propagate_calibration_045_schema(monkeypatch): + import types + + from vllm_omni.diffusion.data import ( + AttentionConfig, + AttentionSpec, + OmniDiffusionConfig, + TransformerConfig, + ) + + cfg = AttentionConfig(default=AttentionSpec(backend="TRTLLM_ATTN")) + tf = TransformerConfig.from_dict( + { + "sparse_attention_config": { + "config_groups": { + "group_0": { + "algorithm": "skip_softmax", + "targets": ["WanAttention"], + "ignore": ["blocks.0.attn2", "blocks.1.attn2"], + "threshold_scale_factor": { + "formula": "a * exp(b * target_sparsity)", + "coefficients": {"a": 2142.7, "b": 4.28}, + }, + "target_sparsity": 0.5, + } + } + } + } + ) + monkeypatch.setattr( + "vllm_omni.diffusion.attention.backends.trtllm_calibration.parse_secondary_expert_calibration", + lambda *a, **k: None, + ) + OmniDiffusionConfig._propagate_skip_softmax_calibration( + types.SimpleNamespace( + diffusion_attention_config=cfg, + model="/x", + ), + tf, + ) + spec, _ = cfg.resolve_with_source("self") + cal = spec.skip_calibration["by_expert"]["transformer"] + assert cal["a"] == pytest.approx(2142.7) + assert cal["b"] == pytest.approx(4.28) + assert cal["ignore"] == ["blocks.0.attn2", "blocks.1.attn2"] + assert "transformer_2" not in spec.skip_calibration["by_expert"] + + +def test_target_sparsity_without_calibration_raises(): + import types + + from vllm_omni.diffusion.data import ( + AttentionConfig, + AttentionSpec, + OmniDiffusionConfig, + TransformerConfig, + ) + + def _cfg(skip_softmax): + return types.SimpleNamespace( + diffusion_attention_config=AttentionConfig( + default=AttentionSpec(backend="TRTLLM_ATTN", skip_softmax=skip_softmax) + ), + model="/models/no-such-calibration", + ) + + with pytest.raises(ValueError, match="no skip-softmax calibration"): + OmniDiffusionConfig._propagate_skip_softmax_calibration( + _cfg({"target_sparsity": 0.5}), TransformerConfig.from_dict({}) + ) + + OmniDiffusionConfig._propagate_skip_softmax_calibration(_cfg({"threshold": 0.02}), TransformerConfig.from_dict({})) + + +def _sdpa_ref(q, k, v, scale): + qp, kp, vp = (x.permute(0, 2, 1, 3) for x in (q, k, v)) + return F.scaled_dot_product_attention(qp, kp, vp, is_causal=False, scale=scale).permute(0, 2, 1, 3).float() + + +@requires_trtllm_attn +def test_bf16_dense_matches_sdpa(): + torch.manual_seed(0) + B, S, H, D = 2, 256, 8, 128 + scale = 1.0 / math.sqrt(D) + q, k, v = (torch.randn(B, S, H, D, device="cuda", dtype=torch.bfloat16) for _ in range(3)) + out = _impl().forward_cuda(q, k, v, None).float() + ref = _sdpa_ref(q, k, v, scale) + rel = (out - ref).abs().mean() / ref.abs().mean() + assert rel < 0.01, f"BF16 dense rel err {rel:.4f} too high" + + +@requires_trtllm_attn +def test_bf16_packed_padding_matches_sdpa(): + torch.manual_seed(0) + b, s, used, h, d = 1, 256, 192, 8, 128 + scale = 1.0 / math.sqrt(d) + q, k, v = (torch.randn(b, s, h, d, device="cuda", dtype=torch.bfloat16) for _ in range(3)) + cu_seqlens = torch.tensor([0, used, s], dtype=torch.int32, device="cuda") + metadata = AttentionMetadata( + attn_mask=torch.arange(s, device="cuda")[None] < used, + extra={ + "cu_seqlens_q": cu_seqlens, + "cu_seqlens_k": cu_seqlens, + "max_seqlen_q": used, + "max_seqlen_k": used, + }, + ) + + out = _impl().forward_cuda(q, k, v, metadata).float() + ref_used = _sdpa_ref(q[:, :used], k[:, :used], v[:, :used], scale) + used_rel = (out[:, :used] - ref_used).abs().mean() / ref_used.abs().mean() + assert used_rel < 0.01, f"BF16 packed valid-token rel err {used_rel:.4f} too high" + assert torch.count_nonzero(out[:, used:]) == 0 + + +requires_sage = pytest.mark.skipif( + not (_has_trtllm_attn() and tg._sage_quantize_fn() is not None), + reason="requires Blackwell SM100+ GPU with flashinfer >= 0.6.16rc1 (trtllm_sage_attention_quantize)", +) + + +@requires_sage +@pytest.mark.parametrize("dtype_qk", ["fp8_e4m3", "int8"]) +def test_sage_quant_matches_sdpa(dtype_qk): + torch.manual_seed(0) + B, S, H, D = 2, 256, 8, 128 + scale = 1.0 / math.sqrt(D) + q, k, v = (torch.randn(B, S, H, D, device="cuda", dtype=torch.bfloat16) for _ in range(3)) + impl = _impl(quant={"dtype_qk": dtype_qk, "q_block_size": 1, "k_block_size": 16}) + try: + out = impl.forward_cuda(q, k, v, None) + except RuntimeError as e: # pragma: no cover - arch-dependent (int8 is SM100-only) + if "Missing TRTLLM-GEN kernel" in str(e): + pytest.skip(f"no trtllm-gen SAGE kernel for dtype_qk={dtype_qk} on this GPU arch") + raise + assert out.shape == (B, S, H, D) and torch.isfinite(out).all() + ref = _sdpa_ref(q, k, v, scale) + rel = (out.float() - ref).abs().mean() / ref.abs().mean() + assert rel < 0.1, f"SAGE({dtype_qk}) rel err {rel:.4f} too high" + + +@requires_sage +def test_sage_packed_non_aligned_length_matches_sdpa(): + torch.manual_seed(0) + b, s, used, h, d = 1, 208, 194, 8, 128 + scale = 1.0 / math.sqrt(d) + q, k, v = (torch.randn(b, s, h, d, device="cuda", dtype=torch.bfloat16) for _ in range(3)) + cu_seqlens = torch.tensor([0, used, s], dtype=torch.int32, device="cuda") + metadata = AttentionMetadata( + attn_mask=torch.arange(s, device="cuda")[None] < used, + extra={ + "cu_seqlens_q": cu_seqlens, + "cu_seqlens_k": cu_seqlens, + "max_seqlen_q": used, + "max_seqlen_k": used, + }, + ) + + out = _impl(quant={"dtype_qk": "fp8_e4m3", "q_block_size": 1, "k_block_size": 16}).forward_cuda(q, k, v, metadata) + assert torch.isfinite(out).all() + ref = _sdpa_ref(q[:, :used], k[:, :used], v[:, :used], scale) + rel = (out[:, :used].float() - ref).abs().mean() / ref.abs().mean() + assert rel < 0.2, f"SAGE packed valid-token rel err {rel:.4f} too high" + assert torch.count_nonzero(out[:, used:]) == 0 + + +@requires_sage +def test_sage_short_sequence_uses_dense_kernel(): + torch.manual_seed(0) + b, s, h, d = 1, 14, 8, 128 + scale = 1.0 / math.sqrt(d) + q, k, v = (torch.randn(b, s, h, d, device="cuda", dtype=torch.bfloat16) for _ in range(3)) + + impl = _impl(quant={"dtype_qk": "fp8_e4m3", "q_block_size": 1, "k_block_size": 16}) + impl._sage_quantize_fn = lambda *args, **kwargs: pytest.fail("short sequence must not use SAGE") + out = impl.forward_cuda(q, k, v) + ref = _sdpa_ref(q, k, v, scale) + rel = (out.float() - ref).abs().mean() / ref.abs().mean() + assert torch.isfinite(out).all() + assert rel < 0.01, f"SAGE short-sequence dense fallback rel err {rel:.4f} too high" + + +@requires_trtllm_attn +def test_skip_tiny_threshold_matches_dense(): + torch.manual_seed(0) + B, S, H, D = 2, 512, 8, 128 + scale = 1.0 / math.sqrt(D) + q, k, v = (torch.randn(B, S, H, D, device="cuda", dtype=torch.bfloat16) for _ in range(3)) + + out = _impl(skip_softmax_threshold=1e-9).forward_cuda(q, k, v, None).float() + ref = _sdpa_ref(q, k, v, scale) + rel = (out - ref).abs().mean() / ref.abs().mean() + assert rel < 0.02, f"skip(tiny) rel err {rel:.4f} should be ~dense" + + +@requires_trtllm_attn +def test_skip_threshold_engages(): + torch.manual_seed(0) + B, S, H, D = 2, 512, 8, 128 + q, k, v = (torch.randn(B, S, H, D, device="cuda", dtype=torch.bfloat16) for _ in range(3)) + dense = _impl().forward_cuda(q, k, v, None).float() + skipped = _impl(skip_softmax_threshold=2.0).forward_cuda(q, k, v, None).float() + assert torch.isfinite(skipped).all() + assert (skipped - dense).abs().mean() / dense.abs().mean() > 0.02 + + +@requires_trtllm_attn +def test_gqa_matches_sdpa(): + torch.manual_seed(0) + B, S, Hq, Hkv, D = 1, 256, 64, 8, 128 + scale = 1.0 / math.sqrt(D) + q = torch.randn(B, S, Hq, D, device="cuda", dtype=torch.bfloat16) + k = torch.randn(B, S, Hkv, D, device="cuda", dtype=torch.bfloat16) + v = torch.randn(B, S, Hkv, D, device="cuda", dtype=torch.bfloat16) + out = ( + TrtllmAttentionImpl(Hq, D, scale, causal=False, num_kv_heads=Hkv, backend_kwargs={}) + .forward_cuda(q, k, v, None) + .float() + ) + qp, kp, vp = (x.permute(0, 2, 1, 3) for x in (q, k, v)) + ref = ( + F.scaled_dot_product_attention(qp, kp, vp, is_causal=False, scale=scale, enable_gqa=True) + .permute(0, 2, 1, 3) + .float() + ) + rel = (out - ref).abs().mean() / ref.abs().mean() + assert rel < 0.01, f"GQA rel err {rel:.4f} vs SDPA(GQA) too high" + + +@requires_trtllm_attn +def test_unsupported_head_dim_raises(): + q = k = v = torch.randn(1, 32, 8, 64, device="cuda", dtype=torch.bfloat16) + with pytest.raises(Exception, match="[Uu]nsupported head dim"): + _impl().forward_cuda(q, k, v, None) + + +def test_skip_end_to_end_config_path(monkeypatch): + import types + + from vllm_omni.diffusion import forward_context as fc + from vllm_omni.diffusion.data import ( + AttentionConfig, + AttentionSpec, + OmniDiffusionConfig, + TransformerConfig, + ) + + cfg = AttentionConfig( + default=AttentionSpec( + backend="TRTLLM_ATTN", + skip_softmax={"target_sparsity": 0.65, "disabled_until_timestep": 0.86}, + ) + ) + + tf = TransformerConfig.from_dict( + {"sparse_attention_config": {"threshold_scale_factor": {"prefill": {"a": 7.93, "b": 8.61}}}} + ) + monkeypatch.setattr( + "vllm_omni.diffusion.attention.backends.trtllm_calibration.parse_secondary_expert_calibration", + lambda *a, **k: None, + ) + OmniDiffusionConfig._propagate_skip_softmax_calibration( + types.SimpleNamespace( + diffusion_attention_config=cfg, + model="/x", + ), + tf, + ) + spec, _ = cfg.resolve_with_source("self") + cal = spec.skip_calibration["by_expert"]["transformer"] + assert cal["a"] == 7.93 and cal["b"] == 8.61 + + impl = TrtllmAttentionImpl( + 8, 128, 1.0 / math.sqrt(128), causal=False, num_kv_heads=8, backend_kwargs=spec.backend_kwargs() + ) + impl.set_layer_calibration(cal["a"], cal["b"]) + expected = 7.93 * math.exp(8.61 * 0.65) + + ctx = fc.ForwardContext(denoise_timestep=0.3) + monkeypatch.setattr(fc, "_forward_context", ctx) + assert impl._resolve_skip_factor(4096) == pytest.approx(expected) diff --git a/tests/diffusion/attention/test_trtllm_calibration.py b/tests/diffusion/attention/test_trtllm_calibration.py new file mode 100644 index 000000000..4db7eaa16 --- /dev/null +++ b/tests/diffusion/attention/test_trtllm_calibration.py @@ -0,0 +1,224 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest + +from vllm_omni.diffusion.attention.backends.trtllm_calibration import ( + is_ignored, + layer_match_names, + parse_sparse_attention_config, + resolve_layer_calibration, + select_expert, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + +def test_layer_match_names_strips_expert_prefix(): + names = layer_match_names("transformer_2.blocks.5.attn1") + assert "transformer_2.blocks.5.attn1" in names + assert "blocks.5.attn1" in names + + +def test_layer_match_names_strips_orig_mod(): + names = layer_match_names("transformer.blocks.5._orig_mod.attn1") + assert "transformer.blocks.5.attn1" in names or "blocks.5.attn1" in names + + +def test_is_ignored_matches_relative_pattern(): + assert is_ignored("transformer.blocks.5.attn2", ["blocks.*.attn2"]) + assert not is_ignored("transformer.blocks.5.attn1", ["blocks.*.attn2"]) + + +def test_is_ignored_empty_patterns(): + assert not is_ignored("transformer.blocks.5.attn1", []) + assert not is_ignored("transformer.blocks.5.attn1", None) + + +def test_is_ignored_ancestor_match_with_attn_suffix(): + assert is_ignored("transformer.blocks.0.attn2.attn", ["blocks.0.attn2"]) + + assert not is_ignored("transformer.blocks.11.attn2.attn", ["blocks.1.attn2"]) + + assert not is_ignored("transformer.blocks.5.attn1.attn", ["blocks.0.attn2", "blocks.1.attn2"]) + + +def test_select_expert_longest_prefix_wins(): + keys = ("transformer", "transformer_2") + assert select_expert("transformer_2.blocks.5.attn1", keys) == "transformer_2" + assert select_expert("transformer.blocks.5.attn1", keys) == "transformer" + + +def test_select_expert_no_match(): + assert select_expert("blocks.5.attn1", ("transformer", "transformer_2")) is None + + +def _two_expert_calib(): + return { + "by_expert": { + "transformer": { + "a": 2142.7, + "b": 4.28, + "target_sparsity": 0.45, + "formula": "a*exp(b*target_sparsity)", + "ignore": ["blocks.*.attn2"], + }, + "transformer_2": { + "a": 314.68, + "b": 6.17, + "target_sparsity": 0.45, + "formula": "a*exp(b*target_sparsity)", + "ignore": ["blocks.*.attn2"], + }, + } + } + + +def test_resolve_routes_to_correct_expert(): + calib = _two_expert_calib() + hi = resolve_layer_calibration("transformer.blocks.5.attn1", calib) + lo = resolve_layer_calibration("transformer_2.blocks.5.attn1", calib) + assert hi["a"] == pytest.approx(2142.7) + assert lo["a"] == pytest.approx(314.68) + + +def test_resolve_ignored_layer_is_dense(): + assert resolve_layer_calibration("transformer.blocks.5.attn2", _two_expert_calib()) is None + + +def test_resolve_single_transformer(): + calib = { + "a": 104.76, + "b": 7.81, + "target_sparsity": 0.45, + "formula": "a*exp(b*target_sparsity)", + "ignore": ["refiner_blocks.*"], + } + assert resolve_layer_calibration("transformer_blocks.5.attn", calib)["a"] == pytest.approx(104.76) + assert resolve_layer_calibration("refiner_blocks.0.attn", calib) is None + + +def test_resolve_none_calibration(): + assert resolve_layer_calibration("transformer.blocks.5.attn1", None) is None + + +def test_resolve_unmatched_component_is_uncalibrated(): + calib = {"by_expert": {"transformer": {"a": 1.0, "b": 2.0, "ignore": []}}} + assert resolve_layer_calibration("transformer.blocks.5.attn1", calib)["a"] == pytest.approx(1.0) + assert resolve_layer_calibration("transformer_2.blocks.5.attn1", calib) is None + assert resolve_layer_calibration("blocks.5.attn1", calib) is None + + +def test_parse_045_config_groups_schema(): + sac = { + "config_groups": { + "group_0": { + "algorithm": "skip_softmax", + "ignore": ["blocks.0.attn1"], + "target_sparsity": 0.45, + "threshold_scale_factor": { + "formula": "a * exp(b * target_sparsity)", + "coefficients": {"a": 2142.7, "b": 4.28}, + }, + } + } + } + parsed = parse_sparse_attention_config(sac) + assert parsed["a"] == pytest.approx(2142.7) + assert parsed["b"] == pytest.approx(4.28) + assert parsed["ignore"] == ["blocks.0.attn1"] + + +def test_parse_unsupported_formula_raises(): + sac = { + "config_groups": { + "group_0": { + "algorithm": "skip_softmax", + "threshold_scale_factor": {"formula": "a * b ** target_sparsity", "coefficients": {"a": 1.0, "b": 2.0}}, + } + } + } + with pytest.raises(ValueError, match="unsupported skip-softmax formula"): + parse_sparse_attention_config(sac) + + +def test_parse_empty_returns_none(): + assert parse_sparse_attention_config(None) is None + assert parse_sparse_attention_config({}) is None + + +class _FakeImpl: + def __init__(self): + self.stamped = None + + def set_layer_calibration(self, a, b): + self.stamped = (a, b) + + +def _fake_pipeline(): + import torch.nn as nn + + def _attn_layer(): + layer = nn.Module() + layer.attention = _FakeImpl() + return layer + + def _wan_attn(): + wa = nn.Module() + wa.attn = _attn_layer() + return wa + + def _transformer(): + tf = nn.Module() + blocks = nn.ModuleList() + for _ in range(3): + blk = nn.Module() + blk.attn1 = _wan_attn() + blk.attn2 = _wan_attn() + blocks.append(blk) + tf.blocks = blocks + return tf + + pipe = nn.Module() + pipe.transformer = _transformer() + pipe.transformer_2 = _transformer() + return pipe + + +def test_apply_to_pipeline_routes_and_ignores(): + from vllm_omni.diffusion.attention.backends.trtllm_calibration import apply_to_pipeline + + pipe = _fake_pipeline() + + calib = { + "by_expert": { + "transformer": { + "a": 2142.7, + "b": 4.28, + "target_sparsity": 0.5, + "formula": "a*exp(b*target_sparsity)", + "ignore": ["blocks.*.attn2", "blocks.0.attn1"], + }, + "transformer_2": { + "a": 314.68, + "b": 6.17, + "target_sparsity": 0.5, + "formula": "a*exp(b*target_sparsity)", + "ignore": ["blocks.*.attn2", "blocks.0.attn1"], + }, + } + } + stamped = apply_to_pipeline(pipe, calib) + + assert stamped == 4 + + def impl(expert, blk, attn): + return getattr(getattr(pipe, expert).blocks[blk], attn).attn.attention + + assert impl("transformer", 1, "attn1").stamped == pytest.approx((2142.7, 4.28)) + + assert impl("transformer_2", 1, "attn1").stamped == pytest.approx((314.68, 6.17)) + + assert impl("transformer", 1, "attn2").stamped is None + + assert impl("transformer", 0, "attn1").stamped is None diff --git a/tests/diffusion/attention/test_ulysses_uaa.py b/tests/diffusion/attention/test_ulysses_uaa.py index a9e2021c3..6bb588156 100644 --- a/tests/diffusion/attention/test_ulysses_uaa.py +++ b/tests/diffusion/attention/test_ulysses_uaa.py @@ -22,6 +22,8 @@ from vllm_omni.diffusion.distributed.parallel_state import ( from vllm_omni.diffusion.forward_context import get_forward_context, set_forward_context from vllm_omni.platforms import current_omni_platform +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + def _find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: diff --git a/tests/diffusion/cache/test_cache_backends.py b/tests/diffusion/cache/test_cache_backends.py index f5981b927..1137cfbc0 100644 --- a/tests/diffusion/cache/test_cache_backends.py +++ b/tests/diffusion/cache/test_cache_backends.py @@ -17,13 +17,14 @@ import cache_dit import pytest from cache_dit import ForwardPattern -from vllm_omni.diffusion.cache.cache_dit_backend import ( +from vllm_omni.diffusion.cache.cachedit import ( CacheDiTAdapterConfig, CacheDiTBackend, + CacheDiTConfig, ) -from vllm_omni.diffusion.cache.magcache.backend import MagCacheBackend +from vllm_omni.diffusion.cache.magcache import MagCacheBackend from vllm_omni.diffusion.cache.selector import get_cache_backend -from vllm_omni.diffusion.cache.teacache.backend import TeaCacheBackend +from vllm_omni.diffusion.cache.teacache import TeaCacheBackend from vllm_omni.diffusion.data import DiffusionCacheConfig pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -32,6 +33,19 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu] class TestCacheDiTBackend: """Test CacheDiTBackend implementation.""" + def test_config_projection_contains_only_cache_dit_fields(self): + shared_config = DiffusionCacheConfig( + Fn_compute_blocks=4, + max_warmup_steps=8, + rel_l1_thresh=0.3, + ) + + config = CacheDiTConfig.from_diffusion_config(shared_config) + + assert config.Fn_compute_blocks == 4 + assert config.max_warmup_steps == 8 + assert not hasattr(config, "rel_l1_thresh") + def test_init_with_dict(self): """Test initialization with dictionary config.""" config_dict = {"Fn_compute_blocks": 4, "max_warmup_steps": 8} @@ -47,8 +61,8 @@ class TestCacheDiTBackend: assert backend.config.Fn_compute_blocks == 4 assert backend.enabled is False - @patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter") - @patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit") + @patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter") + @patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit") def test_enable_single_transformer(self, mock_cache_dit, mock_block_adapter): """Test enabling cache-dit on single-transformer pipeline.""" # Mock pipeline @@ -72,13 +86,90 @@ class TestCacheDiTBackend: # Verify cache-dit was enabled assert backend.enabled is True - assert backend._refresh_func is not None + assert backend._refresh_funcs mock_cache_dit.enable_cache.assert_called_once() - @patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter") - @patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit") + @patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter") + @patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit") + def test_enable_and_refresh_every_declared_dit(self, mock_cache_dit, mock_block_adapter): + """Combined pipelines enable and refresh Cache-DiT for every DiT.""" + pipeline = Mock() + pipeline.__class__.__name__ = "MiniMaxH3Pipeline" + pipeline._dit_modules = ["transformer", "transformers_ref"] + pipeline.transformer = Mock() + pipeline.transformers_ref = Mock() + for transformer in (pipeline.transformer, pipeline.transformers_ref): + transformer._cache_dit_adapter_config = CacheDiTAdapterConfig( + block_forward_patterns={"blocks": ForwardPattern.Pattern_3} + ) + + backend = CacheDiTBackend({"Fn_compute_blocks": 2}) + backend.enable(pipeline) + backend.refresh(pipeline, num_inference_steps=20) + + assert len(backend._refresh_funcs) == 2 + assert mock_cache_dit.enable_cache.call_count == 2 + assert mock_cache_dit.refresh_context.call_count == 2 + assert {call.args[0] for call in mock_cache_dit.refresh_context.call_args_list} == { + pipeline.transformer, + pipeline.transformers_ref, + } + + @patch("vllm_omni.diffusion.cache.cachedit.backend.logger") + @patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit") + def test_enable_without_model_adapter_uses_cache_dit_registry(self, mock_cache_dit, mock_logger): + """A missing model-declared adapter falls back to Cache-DiT's registry.""" + + class BuiltinTransformer: + pass + + class BuiltinPipeline: + transformer = BuiltinTransformer() + + pipeline = BuiltinPipeline() + backend = CacheDiTBackend({"Fn_compute_blocks": 2}) + + backend.enable(pipeline) + + assert backend.enabled is True + assert backend._refresh_funcs + assert mock_cache_dit.enable_cache.call_args.args[0] is pipeline.transformer + mock_logger.info.assert_any_call( + "Transformer %s does not declare _cache_dit_adapter_config; " + "falling back to Cache-DiT's built-in adapter registry.", + "BuiltinTransformer", + ) + + @patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit") + def test_enable_without_compatible_adapter_has_contextual_error(self, mock_cache_dit): + """An unsupported fallback reports both pipeline and transformer names.""" + + class UnsupportedTransformer: + pass + + class UnsupportedPipeline: + transformer = UnsupportedTransformer() + + pipeline = UnsupportedPipeline() + backend = CacheDiTBackend({"Fn_compute_blocks": 2}) + mock_cache_dit.enable_cache.side_effect = ValueError("unsupported") + + with pytest.raises( + ValueError, + match=( + "Failed to enable Cache-DiT for pipeline UnsupportedPipeline with transformer UnsupportedTransformer" + ), + ) as exc_info: + backend.enable(pipeline) + + assert isinstance(exc_info.value.__cause__, ValueError) + assert backend.enabled is False + assert backend._refresh_funcs == [] + + @patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter") + @patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit") def test_refresh(self, mock_cache_dit, mock_block_adapter): - """Test refreshing cache context with SCM mask policy updates when num_inference_steps changes.""" + """Test refreshing cache context with SCM mask policy updates.""" # Mock pipeline mock_pipeline = Mock() mock_pipeline.__class__.__name__ = "DiTPipeline" @@ -107,8 +198,6 @@ class TestCacheDiTBackend: # First refresh with 50 steps backend.refresh(mock_pipeline, num_inference_steps=50) - assert backend._last_num_inference_steps == 50 - # Verify steps_mask was called with mask policy (not direct steps mask) mock_cache_dit.steps_mask.assert_called_with(mask_policy="fast", total_steps=50) assert mock_cache_dit.steps_mask.call_count == 1 @@ -137,10 +226,28 @@ class TestCacheDiTBackend: call_args = mock_cache_dit.refresh_context.call_args assert call_args[0][0] == mock_transformer assert "cache_config" in call_args[1] - assert backend._last_num_inference_steps == 100 + assert mock_cache_dit.refresh_context.call_count == 1 - @patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter") - @patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit") + @patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter") + @patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit") + def test_refresh_resets_same_step_count(self, mock_cache_dit, mock_block_adapter): + """Every generation must reset context, even when the step count is unchanged.""" + mock_pipeline = Mock() + mock_pipeline.__class__.__name__ = "DiTPipeline" + mock_pipeline.transformer = Mock() + mock_pipeline.transformer._cache_dit_adapter_config = CacheDiTAdapterConfig( + block_forward_patterns={"layers": ForwardPattern.Pattern_0} + ) + + backend = CacheDiTBackend(DiffusionCacheConfig()) + backend.enable(mock_pipeline) + backend.refresh(mock_pipeline, num_inference_steps=20) + backend.refresh(mock_pipeline, num_inference_steps=20) + + assert mock_cache_dit.refresh_context.call_count == 2 + + @patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter") + @patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit") def test_enable_hunyuan_pipeline_uses_model_transformer(self, mock_cache_dit, mock_block_adapter): """Test HunyuanImage3 uses pipeline.transformer for cache enable/refresh. @@ -164,7 +271,7 @@ class TestCacheDiTBackend: backend.enable(mock_pipeline) assert backend.enabled is True - assert backend._refresh_func is not None + assert backend._refresh_funcs mock_block_adapter.assert_called_once() adapter_kwargs = mock_block_adapter.call_args.kwargs assert adapter_kwargs["transformer"] is mock_pipeline.model @@ -179,8 +286,8 @@ class TestCacheDiTBackend: assert call_args[0][0] is mock_pipeline.model assert call_args[1]["num_inference_steps"] == 12 - @patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter") - @patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit") + @patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter") + @patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit") def test_enable_dreamid_pipeline_uses_fused_blocks(self, mock_cache_dit, mock_block_adapter): """Test DreamID uses pipeline.transformer for cache enable/refresh. @@ -204,7 +311,7 @@ class TestCacheDiTBackend: backend.enable(mock_pipeline) assert backend.enabled is True - assert backend._refresh_func is not None + assert backend._refresh_funcs mock_block_adapter.assert_called_once() adapter_kwargs = mock_block_adapter.call_args.kwargs assert adapter_kwargs["transformer"] is mock_pipeline.transformer @@ -221,8 +328,8 @@ class TestCacheDiTBackend: assert call_args[1]["num_inference_steps"] == 12 @pytest.mark.parametrize("num_inference_steps", [1, 7]) - @patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter") - @patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit") + @patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter") + @patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit") def test_refresh_scm_bypassed_for_unsupported_step_counts( self, mock_cache_dit, mock_block_adapter, num_inference_steps ): @@ -362,8 +469,6 @@ class TestCacheSelector: class TestMagCacheBackend: """Test MagCacheBackend implementation.""" - from vllm_omni.diffusion.cache.magcache.backend import MagCacheBackend - def test_init(self): """Test initialization.""" config = DiffusionCacheConfig(mag_threshold=0.1, mag_max_skip_steps=2, mag_calibrate=True) diff --git a/tests/diffusion/cache/test_cache_dit.py b/tests/diffusion/cache/test_cache_dit.py index 22f9b8611..fa224cf20 100644 --- a/tests/diffusion/cache/test_cache_dit.py +++ b/tests/diffusion/cache/test_cache_dit.py @@ -5,15 +5,18 @@ Model specific tests for CacheDiT enablement. """ +import ast import sys +from pathlib import Path from unittest.mock import Mock, patch import pytest import torch from cache_dit.caching.cache_blocks.pattern_0_1_2 import CachedBlocks_Pattern_0_1_2 -import vllm_omni.diffusion.cache.cache_dit_backend as cd_backend -from vllm_omni.diffusion.cache.cache_dit_backend import CacheDiTAdapterConfig, CacheDiTBackend, cache_summary +import vllm_omni.diffusion.cache.cachedit as cd_backend +import vllm_omni.diffusion.cache.cachedit.model_specific as cd_model_specific +from vllm_omni.diffusion.cache.cachedit import CacheDiTAdapterConfig, CacheDiTBackend, cache_summary from vllm_omni.diffusion.data import DiffusionCacheConfig from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3VFMTransformer from vllm_omni.diffusion.models.helios.helios_transformer import HeliosTransformer3DModel @@ -42,8 +45,73 @@ SEPARATE_CFG_TRANSFORMERS = [ SAMPLE_CACHE_CONFIG = DiffusionCacheConfig() +def test_custom_cache_dit_enablers_are_registered_explicitly(): + expected_enablers = { + "Wan22Pipeline": cd_model_specific.enable_cache_for_wan22, + "Wan22I2VPipeline": cd_model_specific.enable_cache_for_wan22, + "Wan22TI2VPipeline": cd_model_specific.enable_cache_for_wan22, + "Wan22VACEPipeline": cd_model_specific.enable_cache_for_wan22, + "Wan22S2VPipeline": cd_model_specific.enable_cache_for_wan22_s2v, + "Cosmos3OmniDiffusersPipeline": cd_model_specific.enable_cache_for_cosmos3, + "Cosmos3OmniPipeline": cd_model_specific.enable_cache_for_cosmos3, + "Krea2Pipeline": cd_model_specific.enable_cache_for_krea2, + } + + with patch.dict(cd_backend.CUSTOM_DIT_ENABLERS, {}, clear=True): + cd_model_specific.register_custom_dit_enablers() + assert cd_backend.CUSTOM_DIT_ENABLERS == expected_enablers + + def test_wan22_vace_uses_wan22_custom_cache_dit_enabler(): - assert cd_backend.CUSTOM_DIT_ENABLERS["Wan22VACEPipeline"] is cd_backend.enable_cache_for_wan22 + assert cd_backend.CUSTOM_DIT_ENABLERS["Wan22VACEPipeline"] is cd_model_specific.enable_cache_for_wan22 + + +@pytest.mark.parametrize( + "pipeline_name", + ["Cosmos3OmniDiffusersPipeline", "Cosmos3OmniPipeline"], +) +def test_cosmos3_aliases_use_cosmos3_custom_cache_dit_enabler(pipeline_name: str): + assert cd_backend.CUSTOM_DIT_ENABLERS[pipeline_name] is cd_model_specific.enable_cache_for_cosmos3 + + +def test_cachedit_public_api_is_explicit(): + assert set(cd_backend.__all__) == { + "BagelCachedAdapter", + "CUSTOM_DIT_ENABLERS", + "CacheDiTAdapterConfig", + "CacheDiTBackend", + "CacheDiTConfig", + "SensenovaCachedAdapter", + "cache_summary", + "enable_cache_for_dit", + } + assert not hasattr(cd_backend, "enable_cache_for_wan22") + assert not hasattr(cd_backend, "enable_cache_for_wan22_s2v") + + +def test_cachedit_consumers_use_package_api(): + cache_dir = Path(cd_backend.__file__).resolve().parents[1] + package_root = cache_dir.parents[1] + legacy_module = "vllm_omni.diffusion.cache.cache_dit_backend" + internal_prefix = "vllm_omni.diffusion.cache.cachedit." + + invalid_imports = [] + for source_path in package_root.rglob("*.py"): + if source_path.is_relative_to(cache_dir): + continue + tree = ast.parse(source_path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + modules = [] + if isinstance(node, ast.ImportFrom) and node.module is not None: + modules.append(node.module) + elif isinstance(node, ast.Import): + modules.extend(alias.name for alias in node.names) + + for module in modules: + if module == legacy_module or module.startswith(internal_prefix): + invalid_imports.append(f"{source_path.relative_to(package_root)}:{node.lineno}: {module}") + + assert not invalid_imports, "Cache-DiT consumers must use the package API:\n" + "\n".join(invalid_imports) @pytest.mark.parametrize("transformer_model", SEPARATE_CFG_TRANSFORMERS) @@ -54,12 +122,12 @@ def test_cache_dit_configs_have_separate_cfg(transformer_model): assert transformer_model._cache_dit_adapter_config.has_separate_cfg is True -@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter") -@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit") +@patch("vllm_omni.diffusion.cache.cachedit.model_specific.BlockAdapter") +@patch("vllm_omni.diffusion.cache.cachedit.model_specific.cache_dit") def test_separate_wan22_custom_enabler_has_separate_cfg(mock_cache_dit, mock_block_adapter): """Ensure that Wan22, which has a custom enabler, setts custom CFG correctly.""" mock_pipeline = Mock() - cd_backend.enable_cache_for_wan22(mock_pipeline, SAMPLE_CACHE_CONFIG) + cd_model_specific.enable_cache_for_wan22(mock_pipeline, SAMPLE_CACHE_CONFIG) mock_cache_dit.enable_cache.assert_called_once() adapter_kwargs = mock_block_adapter.call_args.kwargs @@ -67,8 +135,8 @@ def test_separate_wan22_custom_enabler_has_separate_cfg(mock_cache_dit, mock_blo @pytest.mark.parametrize("has_transformer_2", [False, True]) -@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter") -@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit") +@patch("vllm_omni.diffusion.cache.cachedit.model_specific.BlockAdapter") +@patch("vllm_omni.diffusion.cache.cachedit.model_specific.cache_dit") def test_wan22_custom_enabler_passes_taylorseer_calibrator( mock_cache_dit, mock_block_adapter, @@ -82,7 +150,7 @@ def test_wan22_custom_enabler_passes_taylorseer_calibrator( mock_pipeline.transformer_2 = None cache_config = DiffusionCacheConfig(enable_taylorseer=True, taylorseer_order=1) - cd_backend.enable_cache_for_wan22(mock_pipeline, cache_config) + cd_model_specific.enable_cache_for_wan22(mock_pipeline, cache_config) enable_cache_kwargs = mock_cache_dit.enable_cache.call_args.kwargs calibrator_config = enable_cache_kwargs["calibrator_config"] @@ -94,8 +162,8 @@ def test_wan22_custom_enabler_passes_taylorseer_calibrator( assert modifier._context_kwargs["calibrator_config"] is calibrator_config -@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter") -@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit") +@patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter") +@patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit") def test_cosmos3_cache_dit_wraps_gen_layers(mock_cache_dit, mock_block_adapter): """Cosmos3 should cache only the repeated GEN pathway blocks.""" mock_pipeline = Mock() @@ -103,7 +171,7 @@ def test_cosmos3_cache_dit_wraps_gen_layers(mock_cache_dit, mock_block_adapter): mock_pipeline.transformer.gen_layers = gen_layers mock_pipeline.transformer._cache_dit_adapter_config = Cosmos3VFMTransformer._cache_dit_adapter_config - cd_backend.enable_cache_for_cosmos3(mock_pipeline, SAMPLE_CACHE_CONFIG) + cd_model_specific.enable_cache_for_cosmos3(mock_pipeline, SAMPLE_CACHE_CONFIG) mock_cache_dit.enable_cache.assert_called_once() adapter_kwargs = mock_block_adapter.call_args.kwargs diff --git a/tests/diffusion/cache/test_magcache_hook.py b/tests/diffusion/cache/test_magcache_hook.py new file mode 100644 index 000000000..4a6fa0a76 --- /dev/null +++ b/tests/diffusion/cache/test_magcache_hook.py @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +""" +Unit tests for the MagCache hook forward numerics. + +The cached MagCache residual is the residual of the whole block stack +(tail output - head input). On a skipped step the head hook applies it +once; every other hooked block must pass its inputs through unchanged, +matching the reference implementation in diffusers.hooks.mag_cache. +""" + +import pytest +import torch +import torch.nn as nn + +from vllm_omni.diffusion.cache.magcache.config import MagCacheConfig +from vllm_omni.diffusion.cache.magcache.hook import ( + _MAG_CACHE_LEADER_BLOCK_HOOK, + apply_mag_cache_hook, +) +from vllm_omni.diffusion.hooks.base import HookRegistry + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _CountingBlock(nn.Module): + """Toy dual-stream block: adds a constant delta to hidden_states. + + Returns (encoder_hidden_states, hidden_states) to match the default + TransformerBlockMetadata(return_hidden_states_index=1, + return_encoder_hidden_states_index=0) registered by the hook. + """ + + def __init__(self, delta: float): + super().__init__() + self.delta = delta + self.forward_calls = 0 + + def forward(self, hidden_states, encoder_hidden_states): + self.forward_calls += 1 + return encoder_hidden_states, hidden_states + self.delta + + +class _ToyTransformer(nn.Module): + def __init__(self, n_blocks: int, delta: float = 1.0): + super().__init__() + self.blocks = nn.ModuleList(_CountingBlock(delta) for _ in range(n_blocks)) + + def forward(self, hidden_states, encoder_hidden_states): + for block in self.blocks: + encoder_hidden_states, hidden_states = block(hidden_states, encoder_hidden_states) + return hidden_states + + @property + def total_forward_calls(self) -> int: + return sum(block.forward_calls for block in self.blocks) + + +def _make_config(num_inference_steps: int = 2) -> MagCacheConfig: + # retention_ratio=0.5 with 2 steps -> retention_step = 1: step 0 always + # computes, step 1 with mag_ratio 1.0 accumulates zero error, which is a + # guaranteed cache hit. + return MagCacheConfig( + transformer_type="Toy", + threshold=0.24, + max_skip_steps=5, + retention_ratio=0.5, + num_inference_steps=num_inference_steps, + mag_ratios=torch.ones(num_inference_steps), + ) + + +def test_skipped_step_applies_full_stack_residual_once(): + n_blocks, delta = 3, 1.0 + model = _ToyTransformer(n_blocks=n_blocks, delta=delta) + apply_mag_cache_hook(model, _make_config()) + + x = torch.zeros(1, 4, 8) + e = torch.zeros(1, 2, 8) + + # Step 0 computes and caches residual = tail_output - head_input. + out_computed = model(x, e) + assert torch.allclose(out_computed, x + n_blocks * delta) + assert model.total_forward_calls == n_blocks + + # Step 1 is a cache hit: no block computes, and the cached residual is + # applied exactly once, so the output matches the computed step. + out_skipped = model(x, e) + assert model.total_forward_calls == n_blocks, "step 1 was not skipped" + assert torch.allclose(out_skipped, out_computed), ( + f"cached residual over-applied on skip: expected " + f"{out_computed.flatten()[0].item()}, got {out_skipped.flatten()[0].item()}" + ) + + +def test_single_block_advances_schedule_on_skip(): + model = _ToyTransformer(n_blocks=1) + apply_mag_cache_hook(model, _make_config(num_inference_steps=2)) + + x = torch.zeros(1, 4, 8) + e = torch.zeros(1, 2, 8) + + model(x, e) # step 0: computed + assert model.total_forward_calls == 1 + model(x, e) # step 1: cache hit + assert model.total_forward_calls == 1, "step 1 was not skipped" + + # The head hook is also the tail here, so a skipped step must still + # advance the schedule; step 1 was the last step, which resets the + # state for the next inference run. + registry = HookRegistry.check_if_exists_or_initialize(model.blocks[0]) + hook = registry.get_hook(_MAG_CACHE_LEADER_BLOCK_HOOK) + state = hook.state_manager.get_state() + assert state.step_index == 0 + assert state._is_first_step + assert state.previous_residual is None + + # A new run therefore starts by computing, not by replaying the stale + # residual from the previous run. + model(x, e) + assert model.total_forward_calls == 2, "new run reused stale skip state" diff --git a/tests/diffusion/distributed/test_cfg_parallel.py b/tests/diffusion/distributed/test_cfg_parallel.py index af81f00ef..8991ddec3 100644 --- a/tests/diffusion/distributed/test_cfg_parallel.py +++ b/tests/diffusion/distributed/test_cfg_parallel.py @@ -1,18 +1,23 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for CFG (Classifier-Free Guidance) parallel functionality. +"""Tests for CFG (Classifier-Free Guidance) parallel functionality. -This test verifies that predict_noise_maybe_with_cfg and -predict_noise_with_multi_branch_cfg produce numerically equivalent results -with and without CFG parallel using fixed random inputs. +CPU tests mock the CFG process group and validate mixin logic without GPUs. +Nightly GPU parity tests compare sequential CFG (cfg_parallel_size=1) against +real multi-GPU CFG parallel under fixed seeds. """ +from __future__ import annotations + import os +from typing import Any import pytest import torch -from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin +from tests.helpers.mark import hardware_marks, hardware_test +from vllm_omni.diffusion.distributed import parallel_state +from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin, _dispatch_branches, _wrap from vllm_omni.diffusion.distributed.parallel_state import ( destroy_distributed_env, get_classifier_free_guidance_rank, @@ -22,445 +27,105 @@ from vllm_omni.diffusion.distributed.parallel_state import ( ) from vllm_omni.platforms import current_omni_platform +_L4_TWO_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=2) +_L4_THREE_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=3) -def update_environment_variables(envs_dict: dict[str, str]): - """Update multiple environment variables.""" - for k, v in envs_dict.items(): - os.environ[k] = v + +def _set_random_seeds(seed: int) -> None: + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +def _init_model_weights(module: torch.nn.Module, seed: int) -> None: + _set_random_seeds(seed) + for param in module.parameters(): + torch.nn.init.normal_(param, mean=0.0, std=0.02) + + +def _cfg_close_tolerance(dtype: torch.dtype) -> tuple[float, float]: + if dtype == torch.float32: + return 1e-5, 1e-5 + if dtype == torch.bfloat16: + return 1e-2, 1e-2 + return 1e-3, 1e-3 + + +def _assert_cfg_outputs_close( + actual: torch.Tensor, + expected: torch.Tensor, + *, + dtype: torch.dtype, + msg: str, +) -> None: + rtol, atol = _cfg_close_tolerance(dtype) + torch.testing.assert_close(actual, expected, rtol=rtol, atol=atol, msg=msg) + + +def _update_environment_variables(envs_dict: dict[str, str]) -> None: + for key, value in envs_dict.items(): + os.environ[key] = value class SimpleTransformer(torch.nn.Module): - """Simple transformer model for testing with random initialization. - - Contains: - - Input projection (conv to hidden_dim) - - QKV projection layers - - Self-attention layer - - Output projection - """ - def __init__(self, in_channels: int = 4, hidden_dim: int = 128, num_heads: int = 8): super().__init__() self.hidden_dim = hidden_dim self.num_heads = num_heads self.head_dim = hidden_dim // num_heads + assert hidden_dim % num_heads == 0 - assert hidden_dim % num_heads == 0, "hidden_dim must be divisible by num_heads" - - # Input projection: (B, C, H, W) -> (B, hidden_dim, H, W) self.input_proj = torch.nn.Conv2d(in_channels, hidden_dim, 1) - - # QKV projection layers self.q_proj = torch.nn.Linear(hidden_dim, hidden_dim) self.k_proj = torch.nn.Linear(hidden_dim, hidden_dim) self.v_proj = torch.nn.Linear(hidden_dim, hidden_dim) - - # Output projection after attention self.out_proj = torch.nn.Linear(hidden_dim, hidden_dim) - - # Final output projection: (B, hidden_dim, H, W) -> (B, C, H, W) self.final_proj = torch.nn.Conv2d(hidden_dim, in_channels, 1) - - # Layer norm self.norm1 = torch.nn.LayerNorm(hidden_dim) self.norm2 = torch.nn.LayerNorm(hidden_dim) def forward(self, x: torch.Tensor, **kwargs) -> tuple[torch.Tensor]: - """Forward pass with self-attention. - - Args: - x: Input tensor of shape (B, C, H, W) - - Returns: - Output tensor of shape (B, C, H, W) - """ - B, C, H, W = x.shape - - # Input projection - x = self.input_proj(x) # (B, hidden_dim, H, W) - - # Reshape to sequence: (B, hidden_dim, H, W) -> (B, H*W, hidden_dim) - x = x.flatten(2).transpose(1, 2) # (B, H*W, hidden_dim) - - # Self-attention with residual connection + del kwargs + batch_size, _channels, height, width = x.shape + x = self.input_proj(x) + x = x.flatten(2).transpose(1, 2) residual = x x = self.norm1(x) - - # QKV projection - q = self.q_proj(x) # (B, H*W, hidden_dim) - k = self.k_proj(x) # (B, H*W, hidden_dim) - v = self.v_proj(x) # (B, H*W, hidden_dim) - - # Reshape for multi-head attention: (B, H*W, hidden_dim) -> (B, num_heads, H*W, head_dim) - seq_len = H * W - q = q.view(B, seq_len, self.num_heads, self.head_dim).transpose(1, 2) - k = k.view(B, seq_len, self.num_heads, self.head_dim).transpose(1, 2) - v = v.view(B, seq_len, self.num_heads, self.head_dim).transpose(1, 2) - - # Scaled dot-product attention + q = self.q_proj(x) + k = self.k_proj(x) + v = self.v_proj(x) + seq_len = height * width + q = q.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) + k = k.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) + v = v.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) scale = self.head_dim**-0.5 - attn_scores = torch.matmul(q, k.transpose(-2, -1)) * scale # (B, num_heads, H*W, H*W) + attn_scores = torch.matmul(q, k.transpose(-2, -1)) * scale attn_weights = torch.nn.functional.softmax(attn_scores, dim=-1) attn_output = torch.matmul(attn_weights, v) - attn_output = attn_output.transpose(1, 2).contiguous().view(B, seq_len, self.hidden_dim) - + attn_output = attn_output.transpose(1, 2).contiguous().view(batch_size, seq_len, self.hidden_dim) attn_output = self.out_proj(attn_output) - x = residual + attn_output residual = x x = self.norm2(x) x = residual + x - x = x.transpose(1, 2).view(B, self.hidden_dim, H, W) - - out = self.final_proj(x) - - return (out,) + x = x.transpose(1, 2).view(batch_size, self.hidden_dim, height, width) + return (self.final_proj(x),) class TestCFGPipeline(CFGParallelMixin): - """Test pipeline using CFGParallelMixin.""" - def __init__(self, in_channels: int = 4, hidden_dim: int = 128, seed: int = 42): - # Set seed BEFORE creating transformer to ensure consistent layer initialization - torch.manual_seed(seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(seed) - + _set_random_seeds(seed) self.transformer = SimpleTransformer(in_channels, hidden_dim) - - # Re-initialize all parameters with fixed seed for full reproducibility - torch.manual_seed(seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(seed) - for param in self.transformer.parameters(): - torch.nn.init.normal_(param, mean=0.0, std=0.02) - - -def _test_cfg_parallel_worker( - local_rank: int, - world_size: int, - cfg_parallel_size: int, - dtype: torch.dtype, - test_config: dict, - result_queue: torch.multiprocessing.Queue, -): - """Worker function for CFG parallel test.""" - device = torch.device(f"{current_omni_platform.device_type}:{local_rank}") - current_omni_platform.set_device(device) - - update_environment_variables( - { - "RANK": str(local_rank), - "LOCAL_RANK": str(local_rank), - "WORLD_SIZE": str(world_size), - "MASTER_ADDR": "localhost", - "MASTER_PORT": "29502", - } - ) - - init_distributed_environment() - initialize_model_parallel(cfg_parallel_size=cfg_parallel_size) - - cfg_rank = get_classifier_free_guidance_rank() - cfg_world_size = get_classifier_free_guidance_world_size() - - assert cfg_world_size == cfg_parallel_size - - # Create pipeline with same seed to ensure identical model weights across all ranks - # Note: model_seed is set inside TestCFGPipeline.__init__ - pipeline = TestCFGPipeline( - in_channels=test_config["channels"], - hidden_dim=test_config["hidden_dim"], - seed=test_config["model_seed"], - ) - pipeline.transformer = pipeline.transformer.to(device=device, dtype=dtype) - pipeline.transformer.eval() # Set to eval mode for deterministic behavior - - # Create fixed inputs with explicit seed setting for reproducibility - # Set both CPU and CUDA seeds to ensure identical inputs across all ranks - torch.manual_seed(test_config["input_seed"]) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(test_config["input_seed"]) - - batch_size = test_config["batch_size"] - channels = test_config["channels"] - height = test_config["height"] - width = test_config["width"] - - # Positive input - positive_input = torch.randn(batch_size, channels, height, width, dtype=dtype, device=device) - - # Negative input with different seed - torch.manual_seed(test_config["input_seed"] + 1) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(test_config["input_seed"] + 1) - negative_input = torch.randn(batch_size, channels, height, width, dtype=dtype, device=device) - - # Prepare kwargs for predict_noise_maybe_with_cfg - positive_kwargs = {"x": positive_input} - negative_kwargs = {"x": negative_input} - - with torch.no_grad(): - # Call predict_noise_maybe_with_cfg - noise_pred = pipeline.predict_noise_maybe_with_cfg( - do_true_cfg=True, - true_cfg_scale=test_config["cfg_scale"], - positive_kwargs=positive_kwargs, - negative_kwargs=negative_kwargs, - cfg_normalize=test_config["cfg_normalize"], - kwargs=test_config["kwargs"], - ) - - # CFG parallel returns the combined prediction on every rank. - assert noise_pred is not None - result_queue.put((cfg_rank, noise_pred.cpu())) - - destroy_distributed_env() - - -def _test_cfg_sequential_worker( - local_rank: int, - world_size: int, - dtype: torch.dtype, - test_config: dict, - result_queue: torch.multiprocessing.Queue, -): - """Worker function for sequential CFG test (baseline).""" - device = torch.device(f"{current_omni_platform.device_type}:{local_rank}") - current_omni_platform.set_device(device) - - update_environment_variables( - { - "RANK": str(local_rank), - "LOCAL_RANK": str(local_rank), - "WORLD_SIZE": str(world_size), - "MASTER_ADDR": "localhost", - "MASTER_PORT": "29503", - } - ) - - init_distributed_environment() - initialize_model_parallel(cfg_parallel_size=1) # No CFG parallel - - cfg_world_size = get_classifier_free_guidance_world_size() - assert cfg_world_size == 1 - - # Create pipeline with same seed to ensure identical model weights as CFG parallel - # Note: model_seed is set inside TestCFGPipeline.__init__ - pipeline = TestCFGPipeline( - in_channels=test_config["channels"], - hidden_dim=test_config["hidden_dim"], - seed=test_config["model_seed"], - ) - pipeline.transformer = pipeline.transformer.to(device=device, dtype=dtype) - pipeline.transformer.eval() - - # Create fixed inputs (same seed as CFG parallel to ensure identical inputs) - # Set both CPU and CUDA seeds for full reproducibility - torch.manual_seed(test_config["input_seed"]) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(test_config["input_seed"]) - - batch_size = test_config["batch_size"] - channels = test_config["channels"] - height = test_config["height"] - width = test_config["width"] - - # Positive input - positive_input = torch.randn(batch_size, channels, height, width, dtype=dtype, device=device) - - # Negative input with different seed - torch.manual_seed(test_config["input_seed"] + 1) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(test_config["input_seed"] + 1) - negative_input = torch.randn(batch_size, channels, height, width, dtype=dtype, device=device) - - positive_kwargs = {"x": positive_input} - negative_kwargs = {"x": negative_input} - - with torch.no_grad(): - noise_pred = pipeline.predict_noise_maybe_with_cfg( - do_true_cfg=True, - true_cfg_scale=test_config["cfg_scale"], - positive_kwargs=positive_kwargs, - negative_kwargs=negative_kwargs, - cfg_normalize=test_config["cfg_normalize"], - kwargs=test_config["kwargs"], - ) - - # Sequential CFG always returns output - assert noise_pred is not None - result_queue.put(noise_pred.cpu()) - - destroy_distributed_env() - - -@pytest.mark.parametrize("cfg_parallel_size", [2]) -@pytest.mark.parametrize("dtype", [torch.bfloat16]) -@pytest.mark.parametrize("batch_size", [2]) -@pytest.mark.parametrize("cfg_normalize", [False, True]) -@pytest.mark.parametrize("kwargs", [None, {"step_i": 1}]) -def test_predict_noise_maybe_with_cfg( - cfg_parallel_size: int, dtype: torch.dtype, batch_size: int, cfg_normalize: bool, kwargs: dict | None -): - """ - Test that predict_noise_maybe_with_cfg produces identical results - with and without CFG parallel. - - Args: - cfg_parallel_size: Number of GPUs for CFG parallel - dtype: Data type for computation - batch_size: Batch size for testing - cfg_normalize: Whether to normalize CFG output - """ - available_gpus = current_omni_platform.get_device_count() - if available_gpus < cfg_parallel_size: - pytest.skip(f"Test requires {cfg_parallel_size} GPUs but only {available_gpus} available") - - test_config = { - "batch_size": batch_size, - "channels": 4, - "height": 16, - "width": 16, - "hidden_dim": 128, - "cfg_scale": 7.5, - "cfg_normalize": cfg_normalize, - "model_seed": 42, # Fixed seed for model initialization - "input_seed": 123, # Fixed seed for input generation - "kwargs": kwargs, # Additional kwargs to test passing through CFG parallel - } - - mp_context = torch.multiprocessing.get_context("spawn") - - manager = mp_context.Manager() - baseline_queue = manager.Queue() - cfg_parallel_queue = manager.Queue() - - # Run baseline (sequential CFG) on single GPU - torch.multiprocessing.spawn( - _test_cfg_sequential_worker, - args=(1, dtype, test_config, baseline_queue), - nprocs=1, - ) - - # Run CFG parallel on multiple GPUs - torch.multiprocessing.spawn( - _test_cfg_parallel_worker, - args=(cfg_parallel_size, cfg_parallel_size, dtype, test_config, cfg_parallel_queue), - nprocs=cfg_parallel_size, - ) - - # Get results from queues - baseline_output = baseline_queue.get() - cfg_parallel_outputs = [cfg_parallel_queue.get() for _ in range(cfg_parallel_size)] - cfg_parallel_outputs.sort(key=lambda item: item[0]) - cfg_parallel_output = cfg_parallel_outputs[0][1] - - for cfg_rank, rank_output in cfg_parallel_outputs[1:]: - torch.testing.assert_close( - rank_output, - cfg_parallel_output, - rtol=0, - atol=0, - msg=f"CFG parallel ranks produced different outputs (rank 0 vs rank {cfg_rank})", - ) - - # Verify shapes match - assert baseline_output.shape == cfg_parallel_output.shape, ( - f"Shape mismatch: baseline {baseline_output.shape} vs CFG parallel {cfg_parallel_output.shape}" - ) - - # Verify numerical equivalence with appropriate tolerances - if dtype == torch.float32: - rtol, atol = 1e-5, 1e-5 - elif dtype == torch.bfloat16: - rtol, atol = 1e-2, 1e-2 - else: - rtol, atol = 1e-3, 1e-3 - - torch.testing.assert_close( - cfg_parallel_output, - baseline_output, - rtol=rtol, - atol=atol, - msg=( - f"CFG parallel output differs from sequential CFG\n" - f" dtype={dtype}, batch_size={batch_size}, cfg_normalize={cfg_normalize}\n" - f" Max diff: {(cfg_parallel_output - baseline_output).abs().max().item():.6e}" - ), - ) - - print( - f"✓ Test passed: cfg_size={cfg_parallel_size}, dtype={dtype}, " - f"batch_size={batch_size}, cfg_normalize={cfg_normalize}" - ) - - -@pytest.mark.parametrize("dtype", [torch.bfloat16]) -def test_predict_noise_without_cfg(dtype: torch.dtype): - """ - Test predict_noise_maybe_with_cfg when do_true_cfg=False. - - When CFG is disabled, only the positive branch should be computed. - This test runs on a single GPU without distributed environment. - """ - available_gpus = current_omni_platform.get_device_count() - if available_gpus < 1: - pytest.skip("Test requires at least 1 GPU") - - device = torch.device(f"{current_omni_platform.device_type}:0") - current_omni_platform.set_device(device) - - # Create pipeline without distributed environment - pipeline = TestCFGPipeline(in_channels=4, hidden_dim=128, seed=42) - pipeline.transformer = pipeline.transformer.to(device=device, dtype=dtype) - pipeline.transformer.eval() - - # Set seed for input generation - torch.manual_seed(123) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(123) - positive_input = torch.randn(1, 4, 16, 16, dtype=dtype, device=device) - - with torch.no_grad(): - noise_pred = pipeline.predict_noise_maybe_with_cfg( - do_true_cfg=False, # No CFG - true_cfg_scale=7.5, - positive_kwargs={"x": positive_input}, - negative_kwargs=None, - cfg_normalize=False, - ) - - # Should always return output when do_true_cfg=False - assert noise_pred is not None - assert noise_pred.shape == (1, 4, 16, 16) - - print(f"✓ Test passed: predict_noise without CFG (dtype={dtype})") + _init_model_weights(self.transformer, seed) class MultiBranchTestPipeline(CFGParallelMixin): - """Test pipeline with custom 3-branch combine logic (like OmniGen2).""" - def __init__(self, in_channels: int = 4, hidden_dim: int = 128, seed: int = 42): - torch.manual_seed(seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(seed) - + _set_random_seeds(seed) self.transformer = SimpleTransformer(in_channels, hidden_dim) - - torch.manual_seed(seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(seed) - for param in self.transformer.parameters(): - torch.nn.init.normal_(param, mean=0.0, std=0.02) + _init_model_weights(self.transformer, seed) def combine_multi_branch_cfg_noise(self, predictions, true_cfg_scale, cfg_normalize=False): - """N-branch combine with weighted sum for testing. - - - 2-branch: standard CFG formula (true_cfg_scale is float) - - 3-branch: OmniGen2-style dual guidance scale (true_cfg_scale is dict) - - 4-branch: DreamID-style weighted sum (true_cfg_scale is dict) - """ if len(predictions) == 4: text_scale = true_cfg_scale["text"] image_scale = true_cfg_scale["image"] @@ -486,6 +151,561 @@ class MultiBranchTestPipeline(CFGParallelMixin): return combined +class FakeCfgGroup: + def __init__(self, *, world_size: int, rank_in_group: int, rank_tensors: list[torch.Tensor]): + self.world_size = world_size + self.rank_in_group = rank_in_group + self._rank_tensors = rank_tensors + + def all_gather( + self, + input_: torch.Tensor, + dim: int = 0, + separate_tensors: bool = False, + ) -> torch.Tensor | list[torch.Tensor]: + del dim, input_ + assert separate_tensors + return [tensor.clone() for tensor in self._rank_tensors] + + +class FakeMultiBranchCfgGroup: + def __init__( + self, + *, + world_size: int, + rank_in_group: int, + slots_per_rank: list[list[tuple[torch.Tensor, ...]]], + ): + self.world_size = world_size + self.rank_in_group = rank_in_group + self._slots_per_rank = slots_per_rank + # Production path re-runs predict_noise, so gathered tensors are value-equal + # but not identity-equal to the precomputed slot table. Index by call order + # instead: slot-major then element-major, matching _predict_multi_branch_parallel. + self._call_idx = 0 + + def all_gather( + self, + input_: torch.Tensor, + dim: int = 0, + separate_tensors: bool = False, + ) -> torch.Tensor | list[torch.Tensor]: + del dim, input_ + assert separate_tensors + n_elems = len(self._slots_per_rank[0][0]) + slot_idx, elem_idx = divmod(self._call_idx, n_elems) + self._call_idx += 1 + assert slot_idx < len(self._slots_per_rank[self.rank_in_group]), ( + f"all_gather call {self._call_idx} exceeds slot table " + f"(slot_idx={slot_idx}, n_slots={len(self._slots_per_rank[self.rank_in_group])})" + ) + return [self._slots_per_rank[rank][slot_idx][elem_idx].clone() for rank in range(self.world_size)] + + +def _patch_cfg_state( + monkeypatch: pytest.MonkeyPatch, + *, + world_size: int, + rank: int, + fake_group: FakeCfgGroup | FakeMultiBranchCfgGroup, +) -> None: + # Patch both the source module and cfg_parallel's bound imports. + # cfg_parallel does `from ...parallel_state import get_cfg_group`, so + # patching parallel_state alone does not affect CFGParallelMixin. + from vllm_omni.diffusion.distributed import cfg_parallel as cfg_parallel_mod + + for module in (parallel_state, cfg_parallel_mod): + monkeypatch.setattr(module, "get_classifier_free_guidance_world_size", lambda: world_size) + monkeypatch.setattr(module, "get_classifier_free_guidance_rank", lambda: rank) + monkeypatch.setattr(module, "get_cfg_group", lambda: fake_group) + + +def _make_two_branch_inputs( + *, + batch_size: int, + channels: int, + height: int, + width: int, + dtype: torch.dtype, + device: torch.device, + input_seed: int, +) -> tuple[dict[str, Any], dict[str, Any]]: + _set_random_seeds(input_seed) + positive = torch.randn(batch_size, channels, height, width, dtype=dtype, device=device) + _set_random_seeds(input_seed + 1) + negative = torch.randn(batch_size, channels, height, width, dtype=dtype, device=device) + return {"x": positive}, {"x": negative} + + +def _make_multi_branch_inputs( + *, + n_branches: int, + batch_size: int, + channels: int, + height: int, + width: int, + dtype: torch.dtype, + device: torch.device, + input_seed: int, +) -> list[dict[str, Any]]: + branches_kwargs: list[dict[str, Any]] = [] + for branch_idx in range(n_branches): + _set_random_seeds(input_seed + branch_idx) + x = torch.randn(batch_size, channels, height, width, dtype=dtype, device=device) + branches_kwargs.append({"x": x}) + return branches_kwargs + + +def _multi_branch_cfg_scale(n_branches: int) -> float | dict[str, float]: + if n_branches == 2: + return 5.0 + if n_branches == 3: + return {"text": 5.0, "image": 2.0} + return {"text": 5.0, "image": 2.0, "vid_ref": 1.5} + + +def _build_multi_branch_slot_table( + *, + n_branches: int, + cfg_world_size: int, + branch_preds: list[torch.Tensor | tuple[torch.Tensor, ...]], +) -> list[list[tuple[torch.Tensor, ...]]]: + assignments = _dispatch_branches(n_branches, cfg_world_size) + max_per_rank = max(len(branch_ids) for branch_ids in assignments) + ref_pred = _wrap(branch_preds[0]) + slots_per_rank: list[list[tuple[torch.Tensor, ...]]] = [] + for branch_ids in assignments: + slots = [_wrap(branch_preds[branch_id]) for branch_id in branch_ids] + while len(slots) < max_per_rank: + slots.append(tuple(torch.zeros_like(tensor) for tensor in ref_pred)) + slots_per_rank.append(slots) + return slots_per_rank + + +def _run_two_branch_cfg_parallel_mock( + pipeline: TestCFGPipeline, + monkeypatch: pytest.MonkeyPatch, + *, + cfg_world_size: int, + positive_kwargs: dict[str, Any], + negative_kwargs: dict[str, Any], + true_cfg_scale: float, + cfg_normalize: bool, + extra_kwargs: dict[str, Any] | None, +) -> torch.Tensor: + with torch.no_grad(): + pos_tensor = _wrap(pipeline.predict_noise(**positive_kwargs))[0] + neg_tensor = _wrap(pipeline.predict_noise(**negative_kwargs))[0] + + outputs: list[torch.Tensor] = [] + for cfg_rank in range(cfg_world_size): + fake_group = FakeCfgGroup( + world_size=cfg_world_size, + rank_in_group=cfg_rank, + rank_tensors=[pos_tensor, neg_tensor], + ) + _patch_cfg_state(monkeypatch, world_size=cfg_world_size, rank=cfg_rank, fake_group=fake_group) + with torch.no_grad(): + output = pipeline.predict_noise_maybe_with_cfg( + do_true_cfg=True, + true_cfg_scale=true_cfg_scale, + positive_kwargs=positive_kwargs, + negative_kwargs=negative_kwargs, + cfg_normalize=cfg_normalize, + kwargs=extra_kwargs, + ) + assert output is not None + outputs.append(output) + + for rank_output in outputs[1:]: + torch.testing.assert_close( + rank_output, + outputs[0], + rtol=0, + atol=0, + msg="CFG parallel ranks produced different outputs in mock path", + ) + return outputs[0] + + +def _run_multi_branch_cfg_parallel_mock( + pipeline: MultiBranchTestPipeline, + monkeypatch: pytest.MonkeyPatch, + *, + cfg_world_size: int, + branches_kwargs: list[dict[str, Any]], + true_cfg_scale: float | dict[str, float], + cfg_normalize: bool, +) -> torch.Tensor: + with torch.no_grad(): + branch_preds = [pipeline.predict_noise(**kwargs) for kwargs in branches_kwargs] + slots_per_rank = _build_multi_branch_slot_table( + n_branches=len(branches_kwargs), + cfg_world_size=cfg_world_size, + branch_preds=branch_preds, + ) + + outputs: list[torch.Tensor] = [] + for cfg_rank in range(cfg_world_size): + fake_group = FakeMultiBranchCfgGroup( + world_size=cfg_world_size, + rank_in_group=cfg_rank, + slots_per_rank=slots_per_rank, + ) + _patch_cfg_state(monkeypatch, world_size=cfg_world_size, rank=cfg_rank, fake_group=fake_group) + with torch.no_grad(): + output = pipeline.predict_noise_with_multi_branch_cfg( + do_true_cfg=True, + true_cfg_scale=true_cfg_scale, + branches_kwargs=branches_kwargs, + cfg_normalize=cfg_normalize, + ) + assert output is not None + outputs.append(output) + + for rank_output in outputs[1:]: + torch.testing.assert_close( + rank_output, + outputs[0], + rtol=0, + atol=0, + msg="Multi-branch CFG parallel ranks produced different outputs in mock path", + ) + return outputs[0] + + +def _build_pipeline_on_device( + pipeline_cls: type, + *, + hidden_dim: int, + model_seed: int, + dtype: torch.dtype, + device: torch.device, +): + pipeline = pipeline_cls(in_channels=4, hidden_dim=hidden_dim, seed=model_seed) + pipeline.transformer = pipeline.transformer.to(device=device, dtype=dtype) + pipeline.transformer.eval() + return pipeline + + +# --------------------------------------------------------------------------- +# CPU: mixin logic via mocked CFG groups (no GPU / no torch.distributed spawn) +# --------------------------------------------------------------------------- + + +@pytest.mark.core_model +@pytest.mark.diffusion +@pytest.mark.cpu +@pytest.mark.parametrize("batch_size", [2]) +@pytest.mark.parametrize("cfg_normalize", [False, True]) +@pytest.mark.parametrize("extra_kwargs", [None, {"step_i": 1}]) +def test_predict_noise_with_cfg( + monkeypatch: pytest.MonkeyPatch, + batch_size: int, + cfg_normalize: bool, + extra_kwargs: dict[str, Any] | None, +): + dtype = torch.float32 + device = torch.device("cpu") + pipeline = _build_pipeline_on_device(TestCFGPipeline, hidden_dim=128, model_seed=42, dtype=dtype, device=device) + positive_kwargs, negative_kwargs = _make_two_branch_inputs( + batch_size=batch_size, + channels=4, + height=16, + width=16, + dtype=dtype, + device=device, + input_seed=123, + ) + + _patch_cfg_state( + monkeypatch, + world_size=1, + rank=0, + fake_group=FakeCfgGroup(world_size=1, rank_in_group=0, rank_tensors=[]), + ) + with torch.no_grad(): + baseline = pipeline.predict_noise_maybe_with_cfg( + do_true_cfg=True, + true_cfg_scale=7.5, + positive_kwargs=positive_kwargs, + negative_kwargs=negative_kwargs, + cfg_normalize=cfg_normalize, + kwargs=extra_kwargs, + ) + + parallel = _run_two_branch_cfg_parallel_mock( + pipeline, + monkeypatch, + cfg_world_size=2, + positive_kwargs=positive_kwargs, + negative_kwargs=negative_kwargs, + true_cfg_scale=7.5, + cfg_normalize=cfg_normalize, + extra_kwargs=extra_kwargs, + ) + assert baseline is not None + _assert_cfg_outputs_close( + parallel, + baseline, + dtype=dtype, + msg=f"mock CFG parallel differs from sequential (cfg_normalize={cfg_normalize})", + ) + + +@pytest.mark.core_model +@pytest.mark.diffusion +@pytest.mark.cpu +def test_predict_noise_without_cfg(): + dtype = torch.float32 + device = torch.device("cpu") + pipeline = _build_pipeline_on_device(TestCFGPipeline, hidden_dim=128, model_seed=42, dtype=dtype, device=device) + positive_kwargs, _ = _make_two_branch_inputs( + batch_size=1, + channels=4, + height=16, + width=16, + dtype=dtype, + device=device, + input_seed=123, + ) + + with torch.no_grad(): + noise_pred = pipeline.predict_noise_maybe_with_cfg( + do_true_cfg=False, + true_cfg_scale=7.5, + positive_kwargs=positive_kwargs, + negative_kwargs=None, + cfg_normalize=False, + ) + + assert noise_pred is not None + assert noise_pred.shape == (1, 4, 16, 16) + + +@pytest.mark.core_model +@pytest.mark.diffusion +@pytest.mark.cpu +@pytest.mark.parametrize( + "cfg_parallel_size,n_branches", + [(2, 2), (2, 3), (3, 3), (2, 4)], +) +@pytest.mark.parametrize("batch_size", [2]) +@pytest.mark.parametrize("cfg_normalize", [False, True]) +def test_predict_noise_with_multi_branch_cfg( + monkeypatch: pytest.MonkeyPatch, + cfg_parallel_size: int, + n_branches: int, + batch_size: int, + cfg_normalize: bool, +): + dtype = torch.float32 + device = torch.device("cpu") + pipeline = _build_pipeline_on_device( + MultiBranchTestPipeline, + hidden_dim=128, + model_seed=42, + dtype=dtype, + device=device, + ) + branches_kwargs = _make_multi_branch_inputs( + n_branches=n_branches, + batch_size=batch_size, + channels=4, + height=16, + width=16, + dtype=dtype, + device=device, + input_seed=123, + ) + cfg_scale = _multi_branch_cfg_scale(n_branches) + + _patch_cfg_state( + monkeypatch, + world_size=1, + rank=0, + fake_group=FakeCfgGroup(world_size=1, rank_in_group=0, rank_tensors=[]), + ) + with torch.no_grad(): + baseline = pipeline.predict_noise_with_multi_branch_cfg( + do_true_cfg=True, + true_cfg_scale=cfg_scale, + branches_kwargs=branches_kwargs, + cfg_normalize=cfg_normalize, + ) + + parallel = _run_multi_branch_cfg_parallel_mock( + pipeline, + monkeypatch, + cfg_world_size=cfg_parallel_size, + branches_kwargs=branches_kwargs, + true_cfg_scale=cfg_scale, + cfg_normalize=cfg_normalize, + ) + assert baseline is not None + _assert_cfg_outputs_close( + parallel, + baseline, + dtype=dtype, + msg=( + f"mock multi-branch CFG parallel differs from sequential " + f"(n_branches={n_branches}, cfg_parallel_size={cfg_parallel_size})" + ), + ) + + +@pytest.mark.core_model +@pytest.mark.diffusion +@pytest.mark.cpu +def test_multi_branch_without_cfg(): + dtype = torch.float32 + device = torch.device("cpu") + pipeline = _build_pipeline_on_device( + MultiBranchTestPipeline, + hidden_dim=128, + model_seed=42, + dtype=dtype, + device=device, + ) + branches_kwargs = _make_multi_branch_inputs( + n_branches=3, + batch_size=1, + channels=4, + height=16, + width=16, + dtype=dtype, + device=device, + input_seed=123, + ) + + with torch.no_grad(): + noise_pred = pipeline.predict_noise_with_multi_branch_cfg( + do_true_cfg=False, + true_cfg_scale=5.0, + branches_kwargs=branches_kwargs, + cfg_normalize=False, + ) + + assert noise_pred is not None + assert noise_pred.shape == (1, 4, 16, 16) + + +# --------------------------------------------------------------------------- +# GPU nightly: same-seed sequential vs real multi-GPU CFG parallel parity +# --------------------------------------------------------------------------- + + +def _test_cfg_parallel_worker( + local_rank: int, + world_size: int, + cfg_parallel_size: int, + dtype: torch.dtype, + test_config: dict, + result_queue: torch.multiprocessing.Queue, +): + device = torch.device(f"{current_omni_platform.device_type}:{local_rank}") + current_omni_platform.set_device(device) + _update_environment_variables( + { + "RANK": str(local_rank), + "LOCAL_RANK": str(local_rank), + "WORLD_SIZE": str(world_size), + "MASTER_ADDR": "localhost", + "MASTER_PORT": "29502", + } + ) + + init_distributed_environment() + initialize_model_parallel(cfg_parallel_size=cfg_parallel_size) + assert get_classifier_free_guidance_world_size() == cfg_parallel_size + + pipeline = _build_pipeline_on_device( + TestCFGPipeline, + hidden_dim=test_config["hidden_dim"], + model_seed=test_config["model_seed"], + dtype=dtype, + device=device, + ) + positive_kwargs, negative_kwargs = _make_two_branch_inputs( + batch_size=test_config["batch_size"], + channels=test_config["channels"], + height=test_config["height"], + width=test_config["width"], + dtype=dtype, + device=device, + input_seed=test_config["input_seed"], + ) + + with torch.no_grad(): + noise_pred = pipeline.predict_noise_maybe_with_cfg( + do_true_cfg=True, + true_cfg_scale=test_config["cfg_scale"], + positive_kwargs=positive_kwargs, + negative_kwargs=negative_kwargs, + cfg_normalize=test_config["cfg_normalize"], + kwargs=test_config["kwargs"], + ) + + assert noise_pred is not None + result_queue.put((get_classifier_free_guidance_rank(), noise_pred.cpu())) + destroy_distributed_env() + + +def _test_cfg_sequential_worker( + local_rank: int, + world_size: int, + dtype: torch.dtype, + test_config: dict, + result_queue: torch.multiprocessing.Queue, +): + device = torch.device(f"{current_omni_platform.device_type}:{local_rank}") + current_omni_platform.set_device(device) + _update_environment_variables( + { + "RANK": str(local_rank), + "LOCAL_RANK": str(local_rank), + "WORLD_SIZE": str(world_size), + "MASTER_ADDR": "localhost", + "MASTER_PORT": "29503", + } + ) + + init_distributed_environment() + initialize_model_parallel(cfg_parallel_size=1) + assert get_classifier_free_guidance_world_size() == 1 + + pipeline = _build_pipeline_on_device( + TestCFGPipeline, + hidden_dim=test_config["hidden_dim"], + model_seed=test_config["model_seed"], + dtype=dtype, + device=device, + ) + positive_kwargs, negative_kwargs = _make_two_branch_inputs( + batch_size=test_config["batch_size"], + channels=test_config["channels"], + height=test_config["height"], + width=test_config["width"], + dtype=dtype, + device=device, + input_seed=test_config["input_seed"], + ) + + with torch.no_grad(): + noise_pred = pipeline.predict_noise_maybe_with_cfg( + do_true_cfg=True, + true_cfg_scale=test_config["cfg_scale"], + positive_kwargs=positive_kwargs, + negative_kwargs=negative_kwargs, + cfg_normalize=test_config["cfg_normalize"], + kwargs=test_config["kwargs"], + ) + + assert noise_pred is not None + result_queue.put(noise_pred.cpu()) + destroy_distributed_env() + + def _test_multi_branch_parallel_worker( local_rank: int, world_size: int, @@ -494,11 +714,9 @@ def _test_multi_branch_parallel_worker( test_config: dict, result_queue: torch.multiprocessing.Queue, ): - """Worker function for multi-branch CFG parallel test.""" device = torch.device(f"{current_omni_platform.device_type}:{local_rank}") current_omni_platform.set_device(device) - - update_environment_variables( + _update_environment_variables( { "RANK": str(local_rank), "LOCAL_RANK": str(local_rank), @@ -510,33 +728,25 @@ def _test_multi_branch_parallel_worker( init_distributed_environment() initialize_model_parallel(cfg_parallel_size=cfg_parallel_size) + assert get_classifier_free_guidance_world_size() == cfg_parallel_size - cfg_rank = get_classifier_free_guidance_rank() - cfg_world_size = get_classifier_free_guidance_world_size() - assert cfg_world_size == cfg_parallel_size - - pipeline = MultiBranchTestPipeline( - in_channels=test_config["channels"], + pipeline = _build_pipeline_on_device( + MultiBranchTestPipeline, hidden_dim=test_config["hidden_dim"], - seed=test_config["model_seed"], + model_seed=test_config["model_seed"], + dtype=dtype, + device=device, + ) + branches_kwargs = _make_multi_branch_inputs( + n_branches=test_config["n_branches"], + batch_size=test_config["batch_size"], + channels=test_config["channels"], + height=test_config["height"], + width=test_config["width"], + dtype=dtype, + device=device, + input_seed=test_config["input_seed"], ) - pipeline.transformer = pipeline.transformer.to(device=device, dtype=dtype) - pipeline.transformer.eval() - - n_branches = test_config["n_branches"] - batch_size = test_config["batch_size"] - channels = test_config["channels"] - height = test_config["height"] - width = test_config["width"] - - # Create N branch inputs with distinct seeds - branches_kwargs = [] - for b in range(n_branches): - torch.manual_seed(test_config["input_seed"] + b) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(test_config["input_seed"] + b) - x = torch.randn(batch_size, channels, height, width, dtype=dtype, device=device) - branches_kwargs.append({"x": x}) with torch.no_grad(): noise_pred = pipeline.predict_noise_with_multi_branch_cfg( @@ -547,8 +757,7 @@ def _test_multi_branch_parallel_worker( ) assert noise_pred is not None - result_queue.put((cfg_rank, noise_pred.cpu())) - + result_queue.put((get_classifier_free_guidance_rank(), noise_pred.cpu())) destroy_distributed_env() @@ -559,11 +768,9 @@ def _test_multi_branch_sequential_worker( test_config: dict, result_queue: torch.multiprocessing.Queue, ): - """Worker function for sequential multi-branch CFG test (baseline).""" device = torch.device(f"{current_omni_platform.device_type}:{local_rank}") current_omni_platform.set_device(device) - - update_environment_variables( + _update_environment_variables( { "RANK": str(local_rank), "LOCAL_RANK": str(local_rank), @@ -575,31 +782,25 @@ def _test_multi_branch_sequential_worker( init_distributed_environment() initialize_model_parallel(cfg_parallel_size=1) + assert get_classifier_free_guidance_world_size() == 1 - cfg_world_size = get_classifier_free_guidance_world_size() - assert cfg_world_size == 1 - - pipeline = MultiBranchTestPipeline( - in_channels=test_config["channels"], + pipeline = _build_pipeline_on_device( + MultiBranchTestPipeline, hidden_dim=test_config["hidden_dim"], - seed=test_config["model_seed"], + model_seed=test_config["model_seed"], + dtype=dtype, + device=device, + ) + branches_kwargs = _make_multi_branch_inputs( + n_branches=test_config["n_branches"], + batch_size=test_config["batch_size"], + channels=test_config["channels"], + height=test_config["height"], + width=test_config["width"], + dtype=dtype, + device=device, + input_seed=test_config["input_seed"], ) - pipeline.transformer = pipeline.transformer.to(device=device, dtype=dtype) - pipeline.transformer.eval() - - n_branches = test_config["n_branches"] - batch_size = test_config["batch_size"] - channels = test_config["channels"] - height = test_config["height"] - width = test_config["width"] - - branches_kwargs = [] - for b in range(n_branches): - torch.manual_seed(test_config["input_seed"] + b) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(test_config["input_seed"] + b) - x = torch.randn(batch_size, channels, height, width, dtype=dtype, device=device) - branches_kwargs.append({"x": x}) with torch.no_grad(): noise_pred = pipeline.predict_noise_with_multi_branch_cfg( @@ -611,58 +812,86 @@ def _test_multi_branch_sequential_worker( assert noise_pred is not None result_queue.put(noise_pred.cpu()) - destroy_distributed_env() -@pytest.mark.parametrize( - "cfg_parallel_size,n_branches", - [ - (2, 2), # 2 branches on 2 GPUs: [[0],[1]] - (2, 3), # 3 branches on 2 GPUs: [[0,2],[1]] - (3, 3), # 3 branches on 3 GPUs: [[0],[1],[2]] - (2, 4), # 4 branches on 2 GPUs: [[0,2],[1,3]] - ], -) -@pytest.mark.parametrize("dtype", [torch.bfloat16]) -@pytest.mark.parametrize("batch_size", [2]) -@pytest.mark.parametrize("cfg_normalize", [False, True]) -def test_predict_noise_with_multi_branch_cfg( +def _run_two_branch_gpu_parity( + *, cfg_parallel_size: int, - n_branches: int, dtype: torch.dtype, batch_size: int, cfg_normalize: bool, -): - """ - Test that predict_noise_with_multi_branch_cfg produces identical results - with and without CFG parallel for N-branch models. - - Args: - cfg_parallel_size: Number of GPUs for CFG parallel - n_branches: Number of CFG branches - dtype: Data type for computation - batch_size: Batch size for testing - cfg_normalize: Whether to normalize CFG output - """ - available_gpus = current_omni_platform.get_device_count() - if available_gpus < cfg_parallel_size: - pytest.skip(f"Test requires {cfg_parallel_size} GPUs but only {available_gpus} available") - - if n_branches == 2: - cfg_scale = 5.0 - elif n_branches == 3: - cfg_scale = {"text": 5.0, "image": 2.0} - else: - cfg_scale = {"text": 5.0, "image": 2.0, "vid_ref": 1.5} - + extra_kwargs: dict[str, Any] | None, +) -> None: test_config = { "batch_size": batch_size, "channels": 4, "height": 16, "width": 16, "hidden_dim": 128, - "cfg_scale": cfg_scale, + "cfg_scale": 7.5, + "cfg_normalize": cfg_normalize, + "model_seed": 42, + "input_seed": 123, + "kwargs": extra_kwargs, + } + + mp_context = torch.multiprocessing.get_context("spawn") + manager = mp_context.Manager() + baseline_queue = manager.Queue() + cfg_parallel_queue = manager.Queue() + + torch.multiprocessing.spawn( + _test_cfg_sequential_worker, + args=(1, dtype, test_config, baseline_queue), + nprocs=1, + ) + torch.multiprocessing.spawn( + _test_cfg_parallel_worker, + args=(cfg_parallel_size, cfg_parallel_size, dtype, test_config, cfg_parallel_queue), + nprocs=cfg_parallel_size, + ) + + baseline_output = baseline_queue.get() + cfg_parallel_outputs = [cfg_parallel_queue.get() for _ in range(cfg_parallel_size)] + cfg_parallel_outputs.sort(key=lambda item: item[0]) + cfg_parallel_output = cfg_parallel_outputs[0][1] + + for cfg_rank, rank_output in cfg_parallel_outputs[1:]: + torch.testing.assert_close( + rank_output, + cfg_parallel_output, + rtol=0, + atol=0, + msg=f"CFG parallel ranks differ (rank 0 vs rank {cfg_rank})", + ) + + _assert_cfg_outputs_close( + cfg_parallel_output, + baseline_output, + dtype=dtype, + msg=( + f"GPU CFG parallel output differs from sequential CFG " + f"(cfg_parallel_size={cfg_parallel_size}, cfg_normalize={cfg_normalize})" + ), + ) + + +def _run_multi_branch_gpu_parity( + *, + cfg_parallel_size: int, + n_branches: int, + dtype: torch.dtype, + batch_size: int, + cfg_normalize: bool, +) -> None: + test_config = { + "batch_size": batch_size, + "channels": 4, + "height": 16, + "width": 16, + "hidden_dim": 128, + "cfg_scale": _multi_branch_cfg_scale(n_branches), "cfg_normalize": cfg_normalize, "model_seed": 42, "input_seed": 123, @@ -674,14 +903,11 @@ def test_predict_noise_with_multi_branch_cfg( baseline_queue = manager.Queue() cfg_parallel_queue = manager.Queue() - # Run baseline (sequential, cfgp=1) torch.multiprocessing.spawn( _test_multi_branch_sequential_worker, args=(1, dtype, test_config, baseline_queue), nprocs=1, ) - - # Run CFG parallel torch.multiprocessing.spawn( _test_multi_branch_parallel_worker, args=(cfg_parallel_size, cfg_parallel_size, dtype, test_config, cfg_parallel_queue), @@ -693,7 +919,6 @@ def test_predict_noise_with_multi_branch_cfg( cfg_parallel_outputs.sort(key=lambda item: item[0]) cfg_parallel_output = cfg_parallel_outputs[0][1] - # All ranks should produce identical output for cfg_rank, rank_output in cfg_parallel_outputs[1:]: torch.testing.assert_close( rank_output, @@ -703,73 +928,62 @@ def test_predict_noise_with_multi_branch_cfg( msg=f"Multi-branch CFG parallel ranks differ (rank 0 vs rank {cfg_rank})", ) - assert baseline_output.shape == cfg_parallel_output.shape, ( - f"Shape mismatch: baseline {baseline_output.shape} vs CFG parallel {cfg_parallel_output.shape}" - ) - - if dtype == torch.float32: - rtol, atol = 1e-5, 1e-5 - elif dtype == torch.bfloat16: - rtol, atol = 1e-2, 1e-2 - else: - rtol, atol = 1e-3, 1e-3 - - torch.testing.assert_close( + _assert_cfg_outputs_close( cfg_parallel_output, baseline_output, - rtol=rtol, - atol=atol, + dtype=dtype, msg=( - f"Multi-branch CFG parallel output differs from sequential\n" - f" n_branches={n_branches}, cfg_parallel_size={cfg_parallel_size}\n" - f" dtype={dtype}, cfg_normalize={cfg_normalize}\n" - f" Max diff: {(cfg_parallel_output - baseline_output).abs().max().item():.6e}" + f"GPU multi-branch CFG parallel differs from sequential " + f"(n_branches={n_branches}, cfg_parallel_size={cfg_parallel_size})" ), ) - print( - f"✓ Test passed: multi_branch n_branches={n_branches}, " - f"cfg_size={cfg_parallel_size}, dtype={dtype}, cfg_normalize={cfg_normalize}" + +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.parallel +@hardware_test(res={"cuda": "L4"}, num_cards=2) +@pytest.mark.parametrize("batch_size", [2]) +@pytest.mark.parametrize("cfg_normalize", [False, True]) +@pytest.mark.parametrize("extra_kwargs", [None, {"step_i": 1}]) +def test_predict_noise_with_cfg_parity( + batch_size: int, + cfg_normalize: bool, + extra_kwargs: dict[str, Any] | None, +): + _run_two_branch_gpu_parity( + cfg_parallel_size=2, + dtype=torch.bfloat16, + batch_size=batch_size, + cfg_normalize=cfg_normalize, + extra_kwargs=extra_kwargs, ) -@pytest.mark.parametrize("dtype", [torch.bfloat16]) -def test_multi_branch_without_cfg(dtype: torch.dtype): - """ - Test predict_noise_with_multi_branch_cfg when do_true_cfg=False. - - When CFG is disabled, only the first branch (positive) should be computed. - This test runs on a single GPU without distributed environment. - """ - available_gpus = current_omni_platform.get_device_count() - if available_gpus < 1: - pytest.skip("Test requires at least 1 GPU") - - device = torch.device(f"{current_omni_platform.device_type}:0") - current_omni_platform.set_device(device) - - pipeline = MultiBranchTestPipeline(in_channels=4, hidden_dim=128, seed=42) - pipeline.transformer = pipeline.transformer.to(device=device, dtype=dtype) - pipeline.transformer.eval() - - # Create 3 branch inputs (only first should be used) - branches_kwargs = [] - for b in range(3): - torch.manual_seed(123 + b) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(123 + b) - x = torch.randn(1, 4, 16, 16, dtype=dtype, device=device) - branches_kwargs.append({"x": x}) - - with torch.no_grad(): - noise_pred = pipeline.predict_noise_with_multi_branch_cfg( - do_true_cfg=False, # No CFG - true_cfg_scale=5.0, - branches_kwargs=branches_kwargs, - cfg_normalize=False, - ) - - assert noise_pred is not None - assert noise_pred.shape == (1, 4, 16, 16) - - print(f"✓ Test passed: multi_branch predict_noise without CFG (dtype={dtype})") +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.parallel +@pytest.mark.parametrize( + "cfg_parallel_size,n_branches", + [ + pytest.param(2, 2, marks=_L4_TWO_GPU), + pytest.param(2, 3, marks=_L4_TWO_GPU), + pytest.param(3, 3, marks=_L4_THREE_GPU), + pytest.param(2, 4, marks=_L4_TWO_GPU), + ], +) +@pytest.mark.parametrize("batch_size", [2]) +@pytest.mark.parametrize("cfg_normalize", [False, True]) +def test_predict_noise_with_multi_branch_cfg_parity( + cfg_parallel_size: int, + n_branches: int, + batch_size: int, + cfg_normalize: bool, +): + _run_multi_branch_gpu_parity( + cfg_parallel_size=cfg_parallel_size, + n_branches=n_branches, + dtype=torch.bfloat16, + batch_size=batch_size, + cfg_normalize=cfg_normalize, + ) diff --git a/tests/diffusion/distributed/test_comm.py b/tests/diffusion/distributed/test_comm.py index 3e408c10a..647389048 100644 --- a/tests/diffusion/distributed/test_comm.py +++ b/tests/diffusion/distributed/test_comm.py @@ -1,12 +1,20 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for SeqAllToAll4D and SeqAllToAll5D communication primitives.""" +"""Tests for SeqAllToAll4D, SeqAllToAll5D, and RingComm communication primitives. + +CPU tests use gloo on CPU tensors (no GPU). Nightly parity tests run the same +checks on real multi-GPU NCCL collectives. +""" + +from __future__ import annotations import os +from typing import Literal import pytest import torch +from tests.helpers.mark import hardware_marks from vllm_omni.diffusion.distributed.comm import RingComm, SeqAllToAll4D, SeqAllToAll5D from vllm_omni.diffusion.distributed.parallel_state import ( destroy_distributed_env, @@ -16,56 +24,51 @@ from vllm_omni.diffusion.distributed.parallel_state import ( ) from vllm_omni.platforms import current_omni_platform +_L4_TWO_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=2) +_L4_FOUR_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=4) -def update_environment_variables(envs_dict: dict[str, str]): - """Update multiple environment variables with logging.""" - for k, v in envs_dict.items(): - os.environ[k] = v +DeviceKind = Literal["cpu", "cuda"] -@pytest.mark.parametrize("world_size", [2, 4]) -@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) -@pytest.mark.parametrize("batch_size", [2]) -@pytest.mark.parametrize("seq_len_per_rank", [8]) -@pytest.mark.parametrize("num_heads", [8]) -@pytest.mark.parametrize("head_size", [32]) -@pytest.mark.parametrize("use_sync", [False, True]) -def test_4d_identity( +def _update_environment_variables(envs_dict: dict[str, str]) -> None: + for key, value in envs_dict.items(): + os.environ[key] = value + + +def _worker_device(local_rank: int, device_kind: DeviceKind) -> torch.device: + if device_kind == "cpu": + return torch.device("cpu") + return torch.device(f"{current_omni_platform.device_type}:{local_rank}") + + +def _init_worker( + local_rank: int, world_size: int, - dtype: torch.dtype, - batch_size: int, - seq_len_per_rank: int, - num_heads: int, - head_size: int, - use_sync: bool, -): - """Test that two consecutive all-to-all operations return the original input.""" - # Skip if not enough GPUs available - available_gpus = current_omni_platform.get_device_count() - if available_gpus < world_size: - pytest.skip(f"Test requires {world_size} GPUs but only {available_gpus} available") - - # Ensure num_heads is divisible by world_size - if num_heads % world_size != 0: - pytest.skip(f"num_heads ({num_heads}) not divisible by world_size ({world_size})") - - # Run test with multiprocessing spawn - torch.multiprocessing.spawn( - _test_4d_identity_worker, - args=( - world_size, - dtype, - batch_size, - seq_len_per_rank, - num_heads, - head_size, - use_sync, - ), - nprocs=world_size, + master_port: int, + device_kind: DeviceKind, +) -> None: + _update_environment_variables( + { + "RANK": str(local_rank), + "LOCAL_RANK": str(local_rank), + "WORLD_SIZE": str(world_size), + "MASTER_ADDR": "localhost", + "MASTER_PORT": str(master_port), + } ) + backend = "gloo" if device_kind == "cpu" else None + init_distributed_environment(backend=backend) -def _test_4d_identity_worker( +def _close_tolerance(dtype: torch.dtype) -> tuple[float, float]: + if dtype == torch.bfloat16: + return 1e-3, 1e-3 + if dtype == torch.float16: + return 1e-3, 1e-3 + return 1e-5, 1e-5 + + +def _run_4d_identity( local_rank: int, world_size: int, dtype: torch.dtype, @@ -74,29 +77,17 @@ def _test_4d_identity_worker( num_heads: int, head_size: int, use_sync: bool, -): - """Worker function for test_4d_identity.""" - # Set device - device = torch.device(f"{current_omni_platform.device_type}:{local_rank}") - current_omni_platform.set_device(device) + device_kind: DeviceKind, + master_port: int, +) -> None: + device = _worker_device(local_rank, device_kind) + if device_kind == "cuda": + current_omni_platform.set_device(device) - # Set environment variables for distributed training - update_environment_variables( - { - "RANK": str(local_rank), - "LOCAL_RANK": str(local_rank), - "WORLD_SIZE": str(world_size), - "MASTER_ADDR": "localhost", - "MASTER_PORT": "29500", - } - ) + _init_worker(local_rank, world_size, master_port, device_kind) + initialize_model_parallel(ulysses_degree=world_size) + sp_group = get_sp_group().ulysses_group - # Initialize distributed environment - init_distributed_environment() - initialize_model_parallel(ulysses_degree=world_size) # test ulysses sp by default - sp_group = get_sp_group().ulysses_group # get ulysses sp group not ring sp group - - # Create input tensor: (bs, seqlen/P, hc, hs) torch.manual_seed(42 + local_rank) input_tensor = torch.randn( batch_size, @@ -106,100 +97,36 @@ def _test_4d_identity_worker( dtype=dtype, device=device, ) - - # Save original input for comparison original_input = input_tensor.clone() - # First all-to-all: (bs, seqlen/P, hc, hs) -> (bs, seqlen, hc/P, hs) - intermediate = SeqAllToAll4D.apply( - sp_group, - input_tensor, - 2, # scatter head dimension - 1, # gather sequence dimension - use_sync, - ) - - # Verify intermediate shape - expected_shape = ( + intermediate = SeqAllToAll4D.apply(sp_group, input_tensor, 2, 1, use_sync) + expected_intermediate_shape = ( batch_size, seq_len_per_rank * world_size, num_heads // world_size, head_size, ) - assert intermediate.shape == expected_shape, ( - f"Intermediate shape mismatch: expected {expected_shape}, got {intermediate.shape}" + assert intermediate.shape == expected_intermediate_shape, ( + f"Intermediate shape mismatch: expected {expected_intermediate_shape}, got {intermediate.shape}" ) - # Second all-to-all: (bs, seqlen, hc/P, hs) -> (bs, seqlen/P, hc, hs) - output = SeqAllToAll4D.apply( - sp_group, - intermediate, - 1, # scatter sequence dimension - 2, # gather head dimension - use_sync, - ) - - # Verify output shape matches input + output = SeqAllToAll4D.apply(sp_group, intermediate, 1, 2, use_sync) assert output.shape == original_input.shape, ( f"Output shape mismatch: expected {original_input.shape}, got {output.shape}" ) - # Verify output matches original input + rtol, atol = _close_tolerance(dtype) torch.testing.assert_close( output, original_input, - rtol=1e-5, - atol=1e-5, + rtol=rtol, + atol=atol, msg="Output does not match original input after two all-to-all operations", ) - - # Cleanup distributed environment destroy_distributed_env() -@pytest.mark.parametrize("world_size", [2, 4]) -@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) -@pytest.mark.parametrize("batch_size", [2]) -@pytest.mark.parametrize("seq_len_per_rank", [8]) -@pytest.mark.parametrize("num_heads", [8]) -@pytest.mark.parametrize("head_size", [32]) -@pytest.mark.parametrize("use_sync", [False, True]) -def test_5d_identity( - world_size: int, - dtype: torch.dtype, - batch_size: int, - seq_len_per_rank: int, - num_heads: int, - head_size: int, - use_sync: bool, -): - """Test that two consecutive all-to-all operations return the original input.""" - # Skip if not enough GPUs available - available_gpus = current_omni_platform.get_device_count() - if available_gpus < world_size: - pytest.skip(f"Test requires {world_size} GPUs but only {available_gpus} available") - - # Ensure num_heads is divisible by world_size - if num_heads % world_size != 0: - pytest.skip(f"num_heads ({num_heads}) not divisible by world_size ({world_size})") - - # Run test with multiprocessing spawn - torch.multiprocessing.spawn( - _test_5d_identity_worker, - args=( - world_size, - dtype, - batch_size, - seq_len_per_rank, - num_heads, - head_size, - use_sync, - ), - nprocs=world_size, - ) - - -def _test_5d_identity_worker( +def _run_5d_identity( local_rank: int, world_size: int, dtype: torch.dtype, @@ -208,199 +135,329 @@ def _test_5d_identity_worker( num_heads: int, head_size: int, use_sync: bool, -): - """Worker function for test_5d_identity.""" - # Set device - device = torch.device(f"{current_omni_platform.device_type}:{local_rank}") - current_omni_platform.set_device(device) + device_kind: DeviceKind, + master_port: int, +) -> None: + device = _worker_device(local_rank, device_kind) + if device_kind == "cuda": + current_omni_platform.set_device(device) - # Set environment variables for distributed training - update_environment_variables( - { - "RANK": str(local_rank), - "LOCAL_RANK": str(local_rank), - "WORLD_SIZE": str(world_size), - "MASTER_ADDR": "localhost", - "MASTER_PORT": "29500", - } - ) + _init_worker(local_rank, world_size, master_port, device_kind) + initialize_model_parallel(ulysses_degree=world_size) + sp_group = get_sp_group().ulysses_group - # Initialize distributed environment - init_distributed_environment() - initialize_model_parallel(ulysses_degree=world_size) # test ulysses sp by default - sp_group = get_sp_group().ulysses_group # get ulysses sp group not ring sp group - - # Create input tensor: (bs, seqlen/P, 3, hc, hs) - # The '3' dimension is for Q, K, V torch.manual_seed(42 + local_rank) input_tensor = torch.randn( batch_size, seq_len_per_rank, - 3, # Q, K, V + 3, num_heads, head_size, dtype=dtype, device=device, ) - - # Save original input for comparison original_input = input_tensor.clone() - # First all-to-all: (bs, seqlen/P, 3, hc, hs) -> (bs, seqlen, 3, hc/P, hs) - intermediate = SeqAllToAll5D.apply( - sp_group, - input_tensor, - 3, # scatter head dimension - 1, # gather sequence dimension - use_sync, - ) - - # Verify intermediate shape - expected_shape = ( + intermediate = SeqAllToAll5D.apply(sp_group, input_tensor, 3, 1, use_sync) + expected_intermediate_shape = ( batch_size, seq_len_per_rank * world_size, 3, num_heads // world_size, head_size, ) - assert intermediate.shape == expected_shape, ( - f"Intermediate shape mismatch: expected {expected_shape}, got {intermediate.shape}" + assert intermediate.shape == expected_intermediate_shape, ( + f"Intermediate shape mismatch: expected {expected_intermediate_shape}, got {intermediate.shape}" ) - # Second all-to-all: (bs, seqlen, 3, hc/P, hs) -> (bs, seqlen/P, 3, hc, hs) - output = SeqAllToAll5D.apply( - sp_group, - intermediate, - 1, # scatter sequence dimension - 3, # gather head dimension - use_sync, - ) - - # Verify output shape matches input + output = SeqAllToAll5D.apply(sp_group, intermediate, 1, 3, use_sync) assert output.shape == original_input.shape, ( f"Output shape mismatch: expected {original_input.shape}, got {output.shape}" ) - # Verify output matches original input + rtol, atol = _close_tolerance(dtype) torch.testing.assert_close( output, original_input, - rtol=1e-5, - atol=1e-5, + rtol=rtol, + atol=atol, msg="Output does not match original input after two all-to-all operations", ) - - # Cleanup distributed environment destroy_distributed_env() -@pytest.mark.parametrize("world_size", [2, 4]) -@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) -@pytest.mark.parametrize("batch_size", [2]) -@pytest.mark.parametrize("num_heads", [8]) -@pytest.mark.parametrize("head_size", [128]) -def test_ring_p2p( - world_size: int, - dtype: torch.dtype, - batch_size: int, - num_heads: int, - head_size: int, -): - """Test Ring P2P communication (send_recv).""" - # Skip if not enough GPUs available - available_gpus = current_omni_platform.get_device_count() - if available_gpus < world_size: - pytest.skip(f"Test requires {world_size} GPUs but only {available_gpus} available") - - torch.multiprocessing.spawn( - _test_ring_p2p_worker, - args=(world_size, dtype, batch_size, num_heads, head_size), - nprocs=world_size, - ) - - -def _test_ring_p2p_worker( +def _run_ring_p2p( local_rank: int, world_size: int, dtype: torch.dtype, batch_size: int, num_heads: int, head_size: int, -): - """Worker for Ring P2P test.""" - import sys + device_kind: DeviceKind, + master_port: int, +) -> None: + device = _worker_device(local_rank, device_kind) + if device_kind == "cuda": + current_omni_platform.set_device(device) - # Set device - device = torch.device(f"{current_omni_platform.device_type}:{local_rank}") - current_omni_platform.set_device(device) - - # Set env vars - # Use a different port to avoid conflict with other tests if run in parallel - update_environment_variables( - { - "RANK": str(local_rank), - "LOCAL_RANK": str(local_rank), - "WORLD_SIZE": str(world_size), - "MASTER_ADDR": "localhost", - "MASTER_PORT": "29501", - } - ) - - # Init distributed try: - init_distributed_environment() - # Ring degree = world_size to test ring group + _init_worker(local_rank, world_size, master_port, device_kind) initialize_model_parallel(ring_degree=world_size) sp_group = get_sp_group() - - print(f"[Rank {local_rank}] Initialized. Ring group size: {sp_group.ring_group.size()}") - sys.stdout.flush() - - # Create RingComm comm = RingComm(sp_group.ring_group) - # Create tensor: rank-specific data - # (batch, num_heads, head_size) - # Fill with rank value + 1 to avoid 0 and make verification easy input_tensor = torch.full( - (batch_size, num_heads, head_size), fill_value=float(local_rank + 1), dtype=dtype, device=device + (batch_size, num_heads, head_size), + fill_value=float(local_rank + 1), + dtype=dtype, + device=device, ) - - print(f"[Rank {local_rank}] Input sum: {input_tensor.sum().item()}") - sys.stdout.flush() - - # Send input, receive from prev - # RingComm.send_recv sends to next, receives from prev - t0 = __import__("time").time() recv_tensor = comm.send_recv(input_tensor) comm.commit() comm.wait() - t1 = __import__("time").time() - print(f"[Rank {local_rank}] Communication done in {t1 - t0:.4f}s") - - # Verify - # Expected value: from (rank - 1) % world_size prev_rank = (local_rank - 1 + world_size) % world_size expected_value = float(prev_rank + 1) - - recv_sum = recv_tensor.sum().item() - print(f"[Rank {local_rank}] Received sum: {recv_sum}, Expected value: {expected_value}") - sys.stdout.flush() - expected_tensor = torch.full_like(recv_tensor, fill_value=expected_value) - - # Use a slightly loose tolerance for bfloat16 + rtol, atol = _close_tolerance(dtype) torch.testing.assert_close( - recv_tensor, expected_tensor, rtol=1e-3, atol=1e-3, msg=f"[Rank {local_rank}] Data mismatch!" + recv_tensor, + expected_tensor, + rtol=rtol, + atol=atol, + msg=f"[Rank {local_rank}] Ring P2P data mismatch", ) - print(f"[Rank {local_rank}] Verification PASSED") - - except Exception as e: - print(f"[Rank {local_rank}] FAILED with error: {e}") - import traceback - - traceback.print_exc() - raise e finally: destroy_distributed_env() + + +def _spawn_4d_identity( + *, + world_size: int, + dtype: torch.dtype, + batch_size: int, + seq_len_per_rank: int, + num_heads: int, + head_size: int, + use_sync: bool, + device_kind: DeviceKind, + master_port: int, +) -> None: + torch.multiprocessing.spawn( + _run_4d_identity, + args=( + world_size, + dtype, + batch_size, + seq_len_per_rank, + num_heads, + head_size, + use_sync, + device_kind, + master_port, + ), + nprocs=world_size, + ) + + +def _spawn_5d_identity( + *, + world_size: int, + dtype: torch.dtype, + batch_size: int, + seq_len_per_rank: int, + num_heads: int, + head_size: int, + use_sync: bool, + device_kind: DeviceKind, + master_port: int, +) -> None: + torch.multiprocessing.spawn( + _run_5d_identity, + args=( + world_size, + dtype, + batch_size, + seq_len_per_rank, + num_heads, + head_size, + use_sync, + device_kind, + master_port, + ), + nprocs=world_size, + ) + + +def _spawn_ring_p2p( + *, + world_size: int, + dtype: torch.dtype, + batch_size: int, + num_heads: int, + head_size: int, + device_kind: DeviceKind, + master_port: int, +) -> None: + torch.multiprocessing.spawn( + _run_ring_p2p, + args=(world_size, dtype, batch_size, num_heads, head_size, device_kind, master_port), + nprocs=world_size, + ) + + +def _require_heads_divisible(num_heads: int, world_size: int) -> None: + if num_heads % world_size != 0: + pytest.skip(f"num_heads ({num_heads}) not divisible by world_size ({world_size})") + + +def _require_gpus(world_size: int) -> None: + available_gpus = current_omni_platform.get_device_count() + if available_gpus < world_size: + pytest.skip(f"Test requires {world_size} GPUs but only {available_gpus} available") + + +# --------------------------------------------------------------------------- +# CPU: gloo collectives on CPU tensors (no GPU) +# --------------------------------------------------------------------------- + + +@pytest.mark.core_model +@pytest.mark.diffusion +@pytest.mark.cpu +@pytest.mark.parametrize("world_size", [2, 4]) +@pytest.mark.parametrize("use_sync", [False, True]) +def test_4d_identity(world_size: int, use_sync: bool): + _require_heads_divisible(8, world_size) + _spawn_4d_identity( + world_size=world_size, + dtype=torch.float32, + batch_size=2, + seq_len_per_rank=8, + num_heads=8, + head_size=32, + use_sync=use_sync, + device_kind="cpu", + master_port=29610, + ) + + +@pytest.mark.core_model +@pytest.mark.diffusion +@pytest.mark.cpu +@pytest.mark.parametrize("world_size", [2, 4]) +@pytest.mark.parametrize("use_sync", [False, True]) +def test_5d_identity(world_size: int, use_sync: bool): + _require_heads_divisible(8, world_size) + _spawn_5d_identity( + world_size=world_size, + dtype=torch.float32, + batch_size=2, + seq_len_per_rank=8, + num_heads=8, + head_size=32, + use_sync=use_sync, + device_kind="cpu", + master_port=29611, + ) + + +@pytest.mark.core_model +@pytest.mark.diffusion +@pytest.mark.cpu +@pytest.mark.parametrize("world_size", [2, 4]) +def test_ring_p2p(world_size: int): + _spawn_ring_p2p( + world_size=world_size, + dtype=torch.float32, + batch_size=2, + num_heads=8, + head_size=128, + device_kind="cpu", + master_port=29612, + ) + + +# --------------------------------------------------------------------------- +# Nightly: same checks on real multi-GPU NCCL collectives +# --------------------------------------------------------------------------- + + +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.parallel +@pytest.mark.parametrize( + "world_size", + [ + pytest.param(2, marks=_L4_TWO_GPU), + pytest.param(4, marks=_L4_FOUR_GPU), + ], +) +@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) +@pytest.mark.parametrize("use_sync", [False, True]) +def test_4d_identity_parity(world_size: int, dtype: torch.dtype, use_sync: bool): + _require_gpus(world_size) + _require_heads_divisible(8, world_size) + _spawn_4d_identity( + world_size=world_size, + dtype=dtype, + batch_size=2, + seq_len_per_rank=8, + num_heads=8, + head_size=32, + use_sync=use_sync, + device_kind="cuda", + master_port=29500, + ) + + +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.parallel +@pytest.mark.parametrize( + "world_size", + [ + pytest.param(2, marks=_L4_TWO_GPU), + pytest.param(4, marks=_L4_FOUR_GPU), + ], +) +@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) +@pytest.mark.parametrize("use_sync", [False, True]) +def test_5d_identity_parity(world_size: int, dtype: torch.dtype, use_sync: bool): + _require_gpus(world_size) + _require_heads_divisible(8, world_size) + _spawn_5d_identity( + world_size=world_size, + dtype=dtype, + batch_size=2, + seq_len_per_rank=8, + num_heads=8, + head_size=32, + use_sync=use_sync, + device_kind="cuda", + master_port=29502, + ) + + +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.parallel +@pytest.mark.parametrize( + "world_size", + [ + pytest.param(2, marks=_L4_TWO_GPU), + pytest.param(4, marks=_L4_FOUR_GPU), + ], +) +@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) +def test_ring_p2p_parity(world_size: int, dtype: torch.dtype): + _require_gpus(world_size) + _spawn_ring_p2p( + world_size=world_size, + dtype=dtype, + batch_size=2, + num_heads=8, + head_size=128, + device_kind="cuda", + master_port=29501, + ) diff --git a/tests/diffusion/distributed/test_expert_parallel_layout.py b/tests/diffusion/distributed/test_expert_parallel_layout.py index c4d3c6ae7..bea7e9ccf 100644 --- a/tests/diffusion/distributed/test_expert_parallel_layout.py +++ b/tests/diffusion/distributed/test_expert_parallel_layout.py @@ -193,7 +193,7 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch): @pytest.mark.cpu @pytest.mark.core_model def test_cfg_parallel_keeps_diffusion_dp_without_ep(monkeypatch): - """vLLM DP should keep diffusion DP when expert parallelism is not enabled.""" + """Without EP, keep diffusion DP and retain rank metadata for MC2.""" local_rank = 0 world_size = 8 @@ -249,7 +249,9 @@ def test_cfg_parallel_keeps_diffusion_dp_without_ep(monkeypatch): assert parallel_state.vllm_parallel_state._DP.local_group == [0, 4] 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 + assert parallel_state._EXPERT_PARALLEL_GROUP_RANKS == [ + [0, 1, 2, 3, 4, 5, 6, 7], + ] @pytest.mark.cpu diff --git a/tests/diffusion/distributed/test_flux2_vae_patch_parallel.py b/tests/diffusion/distributed/test_flux2_vae_patch_parallel.py new file mode 100644 index 000000000..e861bd61a --- /dev/null +++ b/tests/diffusion/distributed/test_flux2_vae_patch_parallel.py @@ -0,0 +1,210 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""FLUX.2-dev VAE patch-parallel correctness tests. + +Compares vae_patch_parallel_size=1 vs 2 on TP=2 with tiling enabled: +- Text-to-image decode path +- Image-to-image encode + decode path (conditioning image) +""" + +import os +from pathlib import Path + +import numpy as np +import pytest +import torch +from PIL import Image + +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniRunner +from vllm_omni.diffusion.data import DiffusionParallelConfig +from vllm_omni.inputs.data import OmniDiffusionSamplingParams +from vllm_omni.outputs import OmniRequestOutput +from vllm_omni.platforms import current_omni_platform + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +PROMPT = "a photo of a cat sitting on a laptop keyboard" +NEGATIVE_PROMPT = "blurry, low quality" + +VAE_PP_MEAN_THRESHOLD = 3e-2 +VAE_PP_P99_THRESHOLD = 1e-1 + + +def _get_flux2_dev_model() -> str: + return os.environ.get("VLLM_TEST_FLUX2_DEV_MODEL", "black-forest-labs/FLUX.2-dev") + + +def _pil_to_float_rgb_tensor(img: Image.Image) -> torch.Tensor: + arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0 + return torch.from_numpy(arr) + + +def _diff_metrics(a: Image.Image, b: Image.Image) -> tuple[float, float]: + ta = _pil_to_float_rgb_tensor(a) + tb = _pil_to_float_rgb_tensor(b) + assert ta.shape == tb.shape, f"Image shapes differ: {ta.shape} vs {tb.shape}" + abs_diff = torch.abs(ta - tb) + p99_abs_diff = torch.quantile(abs_diff.flatten(), 0.99).item() + return abs_diff.mean().item(), p99_abs_diff + + +def _extract_single_image(outputs) -> Image.Image: + first_output = outputs[0] + assert first_output.final_output_type == "image" + if not hasattr(first_output, "request_output") or not first_output.request_output: + raise ValueError("No request_output found in OmniRequestOutput") + + req_out = first_output.request_output + if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"): + raise ValueError("Invalid request_output structure or missing 'images' key") + + images = req_out.images + if images is None or len(images) != 1: + raise ValueError(f"Expected 1 image, got {0 if images is None else len(images)}") + return images[0] + + +def _run_flux2_generate( + *, + tp_size: int, + height: int, + width: int, + num_inference_steps: int, + seed: int, + vae_patch_parallel_size: int, + conditioning_image: Image.Image | None = None, +) -> Image.Image: + current_omni_platform.empty_cache() + + request: dict = {"prompt": PROMPT, "negative_prompt": NEGATIVE_PROMPT} + if conditioning_image is not None: + request["multi_modal_data"] = {"image": conditioning_image} + + with OmniRunner( + _get_flux2_dev_model(), + parallel_config=DiffusionParallelConfig( + tensor_parallel_size=tp_size, + vae_patch_parallel_size=vae_patch_parallel_size, + ), + enable_cpu_offload=True, + vae_use_tiling=True, + ) as runner: + outputs = list( + runner.omni.generate( + request, + OmniDiffusionSamplingParams( + height=height, + width=width, + num_inference_steps=num_inference_steps, + guidance_scale=4.0, + seed=seed, + num_outputs_per_prompt=1, + ), + ) + ) + + return _extract_single_image(outputs) + + +@pytest.mark.advanced_model +@pytest.mark.diffusion +@pytest.mark.parallel +@hardware_test(res={"cuda": "H100"}, num_cards=2) +def test_flux2_vae_patch_parallel_decode_tp2(tmp_path: Path): + if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2: + pytest.skip("FLUX.2-dev VAE patch parallel requires >= 2 devices.") + + height = 1152 + width = 1152 + num_inference_steps = 2 + seed = 42 + + baseline_img = _run_flux2_generate( + tp_size=2, + height=height, + width=width, + num_inference_steps=num_inference_steps, + seed=seed, + vae_patch_parallel_size=1, + ) + pp2_img = _run_flux2_generate( + tp_size=2, + height=height, + width=width, + num_inference_steps=num_inference_steps, + seed=seed, + vae_patch_parallel_size=2, + ) + + baseline_path = tmp_path / "flux2_tp2_vae_pp1.png" + pp2_path = tmp_path / "flux2_tp2_vae_pp2.png" + baseline_img.save(baseline_path) + pp2_img.save(pp2_path) + + assert baseline_img.width == width and baseline_img.height == height + assert pp2_img.width == width and pp2_img.height == height + + mean_abs_diff, p99_abs_diff = _diff_metrics(baseline_img, pp2_img) + print( + "FLUX.2-dev VAE patch parallel decode diff (TP=2, pp=1 vs pp=2): " + f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; " + f"thresholds: mean<={VAE_PP_MEAN_THRESHOLD:.6e}, p99<={VAE_PP_P99_THRESHOLD:.6e}; " + f"pp1_img={baseline_path}, pp2_img={pp2_path}" + ) + assert mean_abs_diff <= VAE_PP_MEAN_THRESHOLD and p99_abs_diff <= VAE_PP_P99_THRESHOLD, ( + f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} " + f"(thresholds: mean<={VAE_PP_MEAN_THRESHOLD:.6e}, p99<={VAE_PP_P99_THRESHOLD:.6e})" + ) + + +@pytest.mark.advanced_model +@pytest.mark.diffusion +@pytest.mark.parallel +@hardware_test(res={"cuda": "H100"}, num_cards=2) +def test_flux2_vae_patch_parallel_i2i_tp2(tmp_path: Path): + if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2: + pytest.skip("FLUX.2-dev VAE patch parallel I2I requires >= 2 devices.") + + height = 1152 + width = 1152 + num_inference_steps = 2 + seed = 42 + conditioning_image = Image.new("RGB", (width, height), (120, 80, 40)) + + baseline_img = _run_flux2_generate( + tp_size=2, + height=height, + width=width, + num_inference_steps=num_inference_steps, + seed=seed, + vae_patch_parallel_size=1, + conditioning_image=conditioning_image, + ) + pp2_img = _run_flux2_generate( + tp_size=2, + height=height, + width=width, + num_inference_steps=num_inference_steps, + seed=seed, + vae_patch_parallel_size=2, + conditioning_image=conditioning_image, + ) + + baseline_path = tmp_path / "flux2_i2i_tp2_vae_pp1.png" + pp2_path = tmp_path / "flux2_i2i_tp2_vae_pp2.png" + baseline_img.save(baseline_path) + pp2_img.save(pp2_path) + + mean_abs_diff, p99_abs_diff = _diff_metrics(baseline_img, pp2_img) + print( + "FLUX.2-dev VAE patch parallel I2I diff (TP=2, pp=1 vs pp=2): " + f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; " + f"thresholds: mean<={VAE_PP_MEAN_THRESHOLD:.6e}, p99<={VAE_PP_P99_THRESHOLD:.6e}; " + f"pp1_img={baseline_path}, pp2_img={pp2_path}" + ) + assert mean_abs_diff <= VAE_PP_MEAN_THRESHOLD and p99_abs_diff <= VAE_PP_P99_THRESHOLD, ( + f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} " + f"(thresholds: mean<={VAE_PP_MEAN_THRESHOLD:.6e}, p99<={VAE_PP_P99_THRESHOLD:.6e})" + ) diff --git a/tests/diffusion/distributed/test_parallel_state_sp_groups.py b/tests/diffusion/distributed/test_parallel_state_sp_groups.py index 9dda5610b..c12882a22 100644 --- a/tests/diffusion/distributed/test_parallel_state_sp_groups.py +++ b/tests/diffusion/distributed/test_parallel_state_sp_groups.py @@ -40,7 +40,7 @@ def test_set_seq_parallel_pg_uses_explicit_sp_groups(rank, expected_ulysses, exp # tp=2, sp=2 -> SP groups are non-contiguous: [0,2] and [1,3] sp_group_ranks = RankGenerator(2, 2, 1, 1, 1, "tp-sp-pp-cfg-dp").get_ranks("sp") - ulysses_pg, ring_pg = set_seq_parallel_pg( + ulysses_pg, ring_pg, allgather_pg = set_seq_parallel_pg( sp_ulysses_degree=2, sp_ring_degree=1, rank=rank, @@ -50,6 +50,43 @@ def test_set_seq_parallel_pg_uses_explicit_sp_groups(rank, expected_ulysses, exp assert ulysses_pg.ranks == expected_ulysses assert ring_pg.ranks == expected_ring + assert allgather_pg.ranks == [rank] + + +@pytest.mark.cpu +@pytest.mark.parametrize("rank", [0, 1, 2, 3]) +def test_set_seq_parallel_pg_builds_independent_allgather_group(rank, monkeypatch): + created_groups: list[SimpleNamespace] = [] + monkeypatch.setattr(torch.distributed, "new_group", _fake_new_group_factory(created_groups)) + sp_group_ranks = [[0, 2], [1, 3]] + + ulysses_pg, ring_pg, allgather_pg = set_seq_parallel_pg( + sp_ulysses_degree=1, + sp_ring_degree=1, + sp_allgather_degree=2, + rank=rank, + world_size=4, + sp_group_ranks=sp_group_ranks, + ) + + assert ulysses_pg.ranks == [rank] + assert ring_pg.ranks == [rank] + assert allgather_pg.ranks == ([0, 2] if rank % 2 == 0 else [1, 3]) + + +@pytest.mark.cpu +def test_set_seq_parallel_pg_rejects_allgather_with_ulysses_or_ring(monkeypatch): + created_groups: list[SimpleNamespace] = [] + monkeypatch.setattr(torch.distributed, "new_group", _fake_new_group_factory(created_groups)) + + with pytest.raises(ValueError, match="mutually exclusive"): + set_seq_parallel_pg( + sp_ulysses_degree=2, + sp_ring_degree=1, + sp_allgather_degree=2, + rank=0, + world_size=4, + ) @pytest.mark.cpu diff --git a/tests/diffusion/distributed/test_pipeline_parallel.py b/tests/diffusion/distributed/test_pipeline_parallel.py index b76bf8c89..0b48ed266 100644 --- a/tests/diffusion/distributed/test_pipeline_parallel.py +++ b/tests/diffusion/distributed/test_pipeline_parallel.py @@ -1,8 +1,17 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for PipelineParallelMixin and related PP communication helpers. + +CPU unit tests exercise mixin logic without a process group. Distributed CPU +tests use gloo on CPU tensors; nightly parity tests run the same checks on +real multi-GPU NCCL collectives. +""" + +from __future__ import annotations import os import socket +from typing import Literal import pytest import torch @@ -11,6 +20,7 @@ from vllm.sequence import IntermediateTensors from vllm.v1.worker.gpu_worker import AsyncIntermediateTensors import vllm_omni.diffusion.distributed.pipeline_parallel as pp_module +from tests.helpers.mark import hardware_marks from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin from vllm_omni.diffusion.distributed.parallel_state import ( destroy_distributed_env, @@ -22,7 +32,12 @@ from vllm_omni.diffusion.distributed.parallel_state import ( from vllm_omni.diffusion.distributed.pipeline_parallel import AsyncLatents, PipelineParallelMixin from vllm_omni.platforms import current_omni_platform -pytestmark = [pytest.mark.parallel] +_L4_TWO_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=2) +_L4_THREE_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=3) +_L4_FOUR_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=4) + +DeviceKind = Literal["cpu", "cuda"] +_UNIT_MARKS = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] def _find_free_port() -> str: @@ -36,6 +51,22 @@ def update_environment_variables(envs_dict: dict[str, str]) -> None: os.environ[k] = v +def _cleanup_distributed() -> None: + """Destroy omni distributed state and clear test-process leftovers. + + ``destroy_distributed_env`` leaves ``vllm.distributed.parallel_state._PP`` + aliased to the destroyed group. Parent-process baselines must clear that + dangling reference (and RANK/MASTER_* env) so later unit tests that call + ``vllm.distributed.initialize_model_parallel`` do not see stale state. + """ + destroy_distributed_env() + import vllm.distributed.parallel_state as vllm_parallel_state + + vllm_parallel_state._PP = None + for key in ("MASTER_ADDR", "MASTER_PORT", "RANK", "WORLD_SIZE", "LOCAL_RANK"): + os.environ.pop(key, None) + + # --------------------------------------------------------------------------- # Shared stubs used by both unit and distributed tests # --------------------------------------------------------------------------- @@ -143,7 +174,7 @@ class MockPipelineParallel(PipelineParallelMixin, CFGParallelMixin): class TestAsyncLatents: """Verifies the lazy-resolution behaviour of AsyncLatents without a real process group.""" - pytestmark = [pytest.mark.cpu] + pytestmark = _UNIT_MARKS def _make(self, tensor: torch.Tensor, handles: list | None = None, postproc: list | None = None) -> AsyncLatents: return AsyncLatents({"latents": tensor}, handles or [], postproc or []) @@ -214,7 +245,7 @@ class TestAsyncLatents: class TestSyncPPSend: """Verifies PipelineParallelMixin's internal PP-send flush.""" - pytestmark = [pytest.mark.cpu] + pytestmark = _UNIT_MARKS @staticmethod def _make_pipeline() -> PipelineParallelMixin: @@ -247,7 +278,7 @@ class TestSyncPPSend: class TestDiffuseWrapper: """Verifies that PipelineParallelMixin flushes pending sends when diffuse() exits.""" - pytestmark = [pytest.mark.cpu] + pytestmark = _UNIT_MARKS def test_diffuse_flushes_pending_sends_on_success(self): work = FakeWork() @@ -289,7 +320,7 @@ class TestDiffuseWrapper: class TestVaeDecodeGuard: - pytestmark = [pytest.mark.cpu] + pytestmark = _UNIT_MARKS @staticmethod def _make_pipeline(distributed_enabled: bool = False) -> PipelineParallelMixin: @@ -351,6 +382,8 @@ class TestVaeDecodeGuard: assert pipeline.vae.decode.__doc__ == "Original decode docstring." +@pytest.mark.core_model +@pytest.mark.diffusion @pytest.mark.cpu def test_pipeline_parallel_requires_cfg_mixin(): with pytest.raises(TypeError, match="inherits PipelineParallelMixin but not CFGParallelMixin"): @@ -359,6 +392,8 @@ def test_pipeline_parallel_requires_cfg_mixin(): pass +@pytest.mark.core_model +@pytest.mark.diffusion @pytest.mark.cpu def test_pipeline_parallel_requires_mro_before_cfg_mixin(): with pytest.raises(TypeError, match="must inherit PipelineParallelMixin before CFGParallelMixin"): @@ -372,10 +407,22 @@ def test_pipeline_parallel_requires_mro_before_cfg_mixin(): # --------------------------------------------------------------------------- -def init_dist(local_rank: int, world_size: int, master_port: str) -> torch.device: +def _worker_device(local_rank: int, device_kind: DeviceKind) -> torch.device: + if device_kind == "cpu": + return torch.device("cpu") + return torch.device(f"{current_omni_platform.device_type}:{local_rank}") + + +def init_dist( + local_rank: int, + world_size: int, + master_port: str, + device_kind: DeviceKind, +) -> torch.device: """Initialise the distributed environment for a spawned worker.""" - device = torch.device(f"{current_omni_platform.device_type}:{local_rank}") - current_omni_platform.set_device(device) + device = _worker_device(local_rank, device_kind) + if device_kind == "cuda": + current_omni_platform.set_device(device) update_environment_variables( { "RANK": str(local_rank), @@ -385,13 +432,31 @@ def init_dist(local_rank: int, world_size: int, master_port: str) -> torch.devic "MASTER_PORT": master_port, } ) - init_distributed_environment() + backend = "gloo" if device_kind == "cpu" else None + init_distributed_environment(backend=backend) return device +def _mp_backend(device_kind: DeviceKind) -> str | None: + """Backend for model-parallel process groups. + + ``GroupCoordinator.all_gather`` always uses ``device_group``. CPU tensors + therefore require gloo device groups; otherwise a CUDA host's default NCCL + backend breaks PP+CFG CPU tests at CFG all_gather (pp2-cfg2). + isend/irecv of CPU tensors can already fall back to ``cpu_group``. + """ + return "gloo" if device_kind == "cpu" else None + + +def _seed_device(input_seed: int, device: torch.device) -> None: + torch.manual_seed(input_seed) + if device.type == "cuda": + torch.cuda.manual_seed_all(input_seed) + + def make_pipeline_and_inputs( test_config: dict, dtype: torch.dtype, device: torch.device, do_true_cfg: bool = False -) -> tuple["MockPipelineParallel", dict, dict | None]: +) -> tuple[MockPipelineParallel, dict, dict | None]: """Create a MockPipelineParallel and seeded inputs from a test_config dict. Must be called after ``initialize_model_parallel`` so that ``make_layers`` @@ -408,16 +473,12 @@ def make_pipeline_and_inputs( dtype=dtype, ) - torch.manual_seed(test_config["input_seed"]) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(test_config["input_seed"]) + _seed_device(test_config["input_seed"], device) pos_x = {"x": torch.randn(test_config["batch_size"], test_config["dim"], dtype=dtype, device=device)} negative_kwargs = None if do_true_cfg: - torch.manual_seed(test_config["input_seed"] + 1) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(test_config["input_seed"] + 1) + _seed_device(test_config["input_seed"] + 1, device) neg_x = torch.randn(test_config["batch_size"], test_config["dim"], dtype=dtype, device=device) negative_kwargs = {"x": neg_x} @@ -425,19 +486,23 @@ def make_pipeline_and_inputs( # --------------------------------------------------------------------------- -# 3. isend_tensor_dict / irecv_tensor_dict (2 GPUs) +# 3. isend_tensor_dict / irecv_tensor_dict # --------------------------------------------------------------------------- -def isend_irecv_worker(local_rank: int, world_size: int, master_port: str, result_queue): - device = init_dist(local_rank, world_size, master_port) - initialize_model_parallel(pipeline_parallel_size=world_size) +def isend_irecv_worker( + local_rank: int, + world_size: int, + master_port: str, + device_kind: DeviceKind, + result_queue, +): + device = init_dist(local_rank, world_size, master_port, device_kind) + initialize_model_parallel(pipeline_parallel_size=world_size, backend=_mp_backend(device_kind)) pp_group = get_pp_group() if pp_group.is_first_rank: - torch.manual_seed(77) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(77) + _seed_device(77, device) tensor = torch.randn(3, 5, dtype=torch.float32, device=device) handles = pp_group.isend_tensor_dict({"t": tensor}) for h in handles: @@ -449,27 +514,42 @@ def isend_irecv_worker(local_rank: int, world_size: int, master_port: str, resul if torch.distributed.is_initialized(): torch.distributed.barrier() - destroy_distributed_env() + _cleanup_distributed() -@pytest.mark.gpu -@pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs") -@pytest.mark.parametrize("pp_size", [2]) -def test_isend_irecv_tensor_dict(pp_size: int): - """isend_tensor_dict / irecv_tensor_dict transfer a tensor dict without loss.""" +def _run_isend_irecv(pp_size: int, device_kind: DeviceKind, master_port: str) -> None: mp_context = torch.multiprocessing.get_context("spawn") manager = mp_context.Manager() q = manager.Queue() - - port = _find_free_port() - torch.multiprocessing.spawn(isend_irecv_worker, args=(pp_size, port, q), nprocs=pp_size) - + torch.multiprocessing.spawn( + isend_irecv_worker, + args=(pp_size, master_port, device_kind, q), + nprocs=pp_size, + ) results = {label: tensor for label, tensor in [q.get(), q.get()]} torch.testing.assert_close( results["received"], results["sent"], rtol=0, atol=0, msg="isend/irecv transferred tensor incorrectly" ) +@pytest.mark.core_model +@pytest.mark.diffusion +@pytest.mark.cpu +@pytest.mark.parametrize("pp_size", [2]) +def test_isend_irecv_tensor_dict(pp_size: int): + """isend_tensor_dict / irecv_tensor_dict transfer a tensor dict without loss.""" + _run_isend_irecv(pp_size, device_kind="cpu", master_port=_find_free_port()) + + +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.parallel +@pytest.mark.parametrize("pp_size", [pytest.param(2, marks=_L4_TWO_GPU)]) +def test_isend_irecv_tensor_dict_parity(pp_size: int): + """Nightly: isend/irecv on real multi-GPU NCCL.""" + _run_isend_irecv(pp_size, device_kind="cuda", master_port=_find_free_port()) + + # --------------------------------------------------------------------------- # 4. predict_noise_maybe_with_cfg # --------------------------------------------------------------------------- @@ -477,12 +557,13 @@ def test_isend_irecv_tensor_dict(pp_size: int): _baseline_cache: dict[tuple, torch.Tensor] = {} -def compute_single_gpu_baseline(test_config: dict, dtype: torch.dtype, do_true_cfg: bool) -> torch.Tensor: - """Compute expected single-GPU output using the same MockPipelineParallel. - - Initializes a trivial distributed env (world_size=1) so that ``make_layers`` and the PP/CFG mixins work normally. - Results are cached so identical configs are only computed once. - """ +def compute_single_gpu_baseline( + test_config: dict, + dtype: torch.dtype, + do_true_cfg: bool, + device_kind: DeviceKind, +) -> torch.Tensor: + """Compute expected single-process output using the same MockPipelineParallel.""" key = ( test_config["num_layers"], test_config["dim"], @@ -492,27 +573,29 @@ def compute_single_gpu_baseline(test_config: dict, dtype: torch.dtype, do_true_c test_config["cfg_scale"], dtype, do_true_cfg, + device_kind, ) if key in _baseline_cache: return _baseline_cache[key] - device = init_dist(0, 1, _find_free_port()) - initialize_model_parallel(pipeline_parallel_size=1) + device = init_dist(0, 1, _find_free_port(), device_kind) + try: + initialize_model_parallel(pipeline_parallel_size=1, backend=_mp_backend(device_kind)) - pipeline, positive_kwargs, negative_kwargs = make_pipeline_and_inputs( - test_config, dtype, device, do_true_cfg=do_true_cfg - ) - - with torch.inference_mode(): - noise_pred = pipeline.predict_noise_maybe_with_cfg( - do_true_cfg=do_true_cfg, - true_cfg_scale=test_config["cfg_scale"], - positive_kwargs=positive_kwargs, - negative_kwargs=negative_kwargs, - cfg_normalize=False, + pipeline, positive_kwargs, negative_kwargs = make_pipeline_and_inputs( + test_config, dtype, device, do_true_cfg=do_true_cfg ) - destroy_distributed_env() + with torch.inference_mode(): + noise_pred = pipeline.predict_noise_maybe_with_cfg( + do_true_cfg=do_true_cfg, + true_cfg_scale=test_config["cfg_scale"], + positive_kwargs=positive_kwargs, + negative_kwargs=negative_kwargs, + cfg_normalize=False, + ) + finally: + _cleanup_distributed() _baseline_cache[key] = noise_pred.cpu() return _baseline_cache[key] @@ -527,11 +610,16 @@ def predict_noise_worker( do_true_cfg: bool, dtype: torch.dtype, test_config: dict, + device_kind: DeviceKind, result_queue, ): """Generic predict-noise worker parameterized by PP and CFG topology.""" - device = init_dist(local_rank, world_size, master_port) - initialize_model_parallel(pipeline_parallel_size=pp_size, cfg_parallel_size=cfg_size) + device = init_dist(local_rank, world_size, master_port, device_kind) + initialize_model_parallel( + pipeline_parallel_size=pp_size, + cfg_parallel_size=cfg_size, + backend=_mp_backend(device_kind), + ) pp_group = get_pp_group() cfg_rank = get_classifier_free_guidance_rank() @@ -548,8 +636,6 @@ def predict_noise_worker( negative_kwargs=negative_kwargs, cfg_normalize=False, ) - # This worker exercises predict_noise_maybe_with_cfg directly, bypassing diffuse(). - # Flush the non-last PP rank's async send before barrier / process teardown. pipeline._sync_pp_send() if pp_group.is_last_rank: @@ -561,77 +647,22 @@ def predict_noise_worker( if torch.distributed.is_initialized(): torch.distributed.barrier() - destroy_distributed_env() + _cleanup_distributed() -@pytest.mark.gpu -@pytest.mark.parametrize( - "pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_seed, rtol, atol", - [ - pytest.param( - 2, - 1, - False, - torch.float32, - 4, - 100, - 1e-5, - 1e-5, - marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs"), - id="pp2-no_cfg-float32", - ), - pytest.param( - 2, - 1, - False, - torch.bfloat16, - 4, - 100, - 1e-2, - 1e-2, - marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs"), - id="pp2-no_cfg-bfloat16", - ), - pytest.param( - 2, - 1, - True, - torch.bfloat16, - 4, - 100, - 1e-2, - 1e-2, - marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs"), - id="pp2-seq_cfg-bfloat16", - ), - pytest.param( - 2, - 2, - True, - torch.bfloat16, - 4, - 100, - 1e-2, - 1e-2, - marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 4, reason="Need at least 4 GPUs"), - id="pp2-cfg2-bfloat16", - ), - pytest.param( - 3, - 1, - False, - torch.bfloat16, - 6, - 100, - 1e-2, - 1e-2, - marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 3, reason="Need at least 3 GPUs"), - id="pp3-no_cfg-bfloat16", - ), - ], -) -def test_predict_noise(pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_seed, rtol, atol): - """predict_noise_maybe_with_cfg output matches the single-GPU baseline across PP / CFG topologies.""" +def _run_predict_noise( + *, + pp_size: int, + cfg_size: int, + do_true_cfg: bool, + dtype: torch.dtype, + num_layers: int, + input_seed: int, + rtol: float, + atol: float, + device_kind: DeviceKind, + master_port: str, +) -> None: test_config = { "num_layers": num_layers, "dim": 64, @@ -641,22 +672,20 @@ def test_predict_noise(pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_ "input_seed": input_seed, } - baseline_out = compute_single_gpu_baseline(test_config, dtype, do_true_cfg) + baseline_out = compute_single_gpu_baseline(test_config, dtype, do_true_cfg, device_kind) mp_context = torch.multiprocessing.get_context("spawn") manager = mp_context.Manager() pp_q = manager.Queue() world_size = pp_size * cfg_size - port = _find_free_port() torch.multiprocessing.spawn( predict_noise_worker, - args=(world_size, port, pp_size, cfg_size, do_true_cfg, dtype, test_config, pp_q), + args=(world_size, master_port, pp_size, cfg_size, do_true_cfg, dtype, test_config, device_kind, pp_q), nprocs=world_size, ) pp_out = pp_q.get() - assert baseline_out.shape == pp_out.shape torch.testing.assert_close( pp_out, @@ -667,35 +696,157 @@ def test_predict_noise(pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_ ) +_CPU_PREDICT_NOISE_CASES = [ + pytest.param(2, 1, False, 4, 100, 1e-5, 1e-5, id="pp2-no_cfg"), + pytest.param(2, 1, True, 4, 100, 1e-5, 1e-5, id="pp2-seq_cfg"), + pytest.param(2, 2, True, 4, 100, 1e-5, 1e-5, id="pp2-cfg2"), + pytest.param(3, 1, False, 6, 100, 1e-5, 1e-5, id="pp3-no_cfg"), +] + +_GPU_PREDICT_NOISE_CASES = [ + pytest.param( + 2, + 1, + False, + torch.float32, + 4, + 100, + 1e-5, + 1e-5, + marks=_L4_TWO_GPU, + id="pp2-no_cfg-float32", + ), + pytest.param( + 2, + 1, + False, + torch.bfloat16, + 4, + 100, + 1e-2, + 1e-2, + marks=_L4_TWO_GPU, + id="pp2-no_cfg-bfloat16", + ), + pytest.param( + 2, + 1, + True, + torch.bfloat16, + 4, + 100, + 1e-2, + 1e-2, + marks=_L4_TWO_GPU, + id="pp2-seq_cfg-bfloat16", + ), + pytest.param( + 2, + 2, + True, + torch.bfloat16, + 4, + 100, + 1e-2, + 1e-2, + marks=_L4_FOUR_GPU, + id="pp2-cfg2-bfloat16", + ), + pytest.param( + 3, + 1, + False, + torch.bfloat16, + 6, + 100, + 1e-2, + 1e-2, + marks=_L4_THREE_GPU, + id="pp3-no_cfg-bfloat16", + ), +] + + +@pytest.mark.core_model +@pytest.mark.diffusion +@pytest.mark.cpu +@pytest.mark.parametrize( + "pp_size, cfg_size, do_true_cfg, num_layers, input_seed, rtol, atol", + _CPU_PREDICT_NOISE_CASES, +) +def test_predict_noise(pp_size, cfg_size, do_true_cfg, num_layers, input_seed, rtol, atol): + """predict_noise_maybe_with_cfg output matches baseline across PP / CFG topologies.""" + _run_predict_noise( + pp_size=pp_size, + cfg_size=cfg_size, + do_true_cfg=do_true_cfg, + dtype=torch.float32, + num_layers=num_layers, + input_seed=input_seed, + rtol=rtol, + atol=atol, + device_kind="cpu", + master_port=_find_free_port(), + ) + + +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.parallel +@pytest.mark.parametrize( + "pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_seed, rtol, atol", + _GPU_PREDICT_NOISE_CASES, +) +def test_predict_noise_parity(pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_seed, rtol, atol): + """Nightly: predict_noise parity on real multi-GPU NCCL.""" + _run_predict_noise( + pp_size=pp_size, + cfg_size=cfg_size, + do_true_cfg=do_true_cfg, + dtype=dtype, + num_layers=num_layers, + input_seed=input_seed, + rtol=rtol, + atol=atol, + device_kind="cuda", + master_port=_find_free_port(), + ) + + # --------------------------------------------------------------------------- # 5. scheduler_step_maybe_with_cfg # --------------------------------------------------------------------------- -def compute_scheduler_step_baseline(test_config: dict, do_true_cfg: bool) -> torch.Tensor: - """Single-GPU reference: predict_noise + scheduler_step.""" - device = init_dist(0, 1, _find_free_port()) - initialize_model_parallel(pipeline_parallel_size=1) +def compute_scheduler_step_baseline( + test_config: dict, + do_true_cfg: bool, + device_kind: DeviceKind, +) -> torch.Tensor: + """Single-process reference: predict_noise + scheduler_step.""" + device = init_dist(0, 1, _find_free_port(), device_kind) + try: + initialize_model_parallel(pipeline_parallel_size=1, backend=_mp_backend(device_kind)) - pipeline, positive_kwargs, negative_kwargs = make_pipeline_and_inputs( - test_config, torch.float32, device, do_true_cfg=do_true_cfg - ) - latents = positive_kwargs["x"] - t = torch.tensor(500, device=device) - - with torch.inference_mode(): - noise_pred = pipeline.predict_noise_maybe_with_cfg( - do_true_cfg=do_true_cfg, - true_cfg_scale=test_config["cfg_scale"], - positive_kwargs=positive_kwargs, - negative_kwargs=negative_kwargs, - cfg_normalize=False, - ) - result = pipeline.scheduler_step_maybe_with_cfg( - noise_pred=noise_pred, t=t, latents=latents, do_true_cfg=do_true_cfg + pipeline, positive_kwargs, negative_kwargs = make_pipeline_and_inputs( + test_config, torch.float32, device, do_true_cfg=do_true_cfg ) + latents = positive_kwargs["x"] + t = torch.tensor(500, device=device) - destroy_distributed_env() + with torch.inference_mode(): + noise_pred = pipeline.predict_noise_maybe_with_cfg( + do_true_cfg=do_true_cfg, + true_cfg_scale=test_config["cfg_scale"], + positive_kwargs=positive_kwargs, + negative_kwargs=negative_kwargs, + cfg_normalize=False, + ) + result = pipeline.scheduler_step_maybe_with_cfg( + noise_pred=noise_pred, t=t, latents=latents, do_true_cfg=do_true_cfg + ) + finally: + _cleanup_distributed() return result.cpu() @@ -707,10 +858,15 @@ def scheduler_step_worker( cfg_size: int, do_true_cfg: bool, test_config: dict, + device_kind: DeviceKind, result_queue, ): - device = init_dist(local_rank, world_size, master_port) - initialize_model_parallel(pipeline_parallel_size=pp_size, cfg_parallel_size=cfg_size) + device = init_dist(local_rank, world_size, master_port, device_kind) + initialize_model_parallel( + pipeline_parallel_size=pp_size, + cfg_parallel_size=cfg_size, + backend=_mp_backend(device_kind), + ) pp_group = get_pp_group() cfg_rank = get_classifier_free_guidance_rank() @@ -732,43 +888,25 @@ def scheduler_step_worker( latents = pipeline.scheduler_step_maybe_with_cfg( noise_pred=noise_pred, t=t, latents=latents, do_true_cfg=do_true_cfg ) - # This worker exercises scheduler_step_maybe_with_cfg directly, bypassing diffuse(). - # Flush the last PP rank's async latent send before barrier / process teardown. pipeline._sync_pp_send() if pp_group.is_first_rank and cfg_rank == 0: - resolved = latents.contiguous() - result_queue.put(resolved.cpu()) + result_queue.put(latents.contiguous().cpu()) if torch.distributed.is_initialized(): torch.distributed.barrier() - destroy_distributed_env() + _cleanup_distributed() -@pytest.mark.gpu -@pytest.mark.parametrize( - "pp_size, cfg_size, do_true_cfg, input_seed", - [ - pytest.param( - 2, - 1, - False, - 300, - marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs"), - id="pp2-no_cfg", - ), - pytest.param( - 2, - 2, - True, - 600, - marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 4, reason="Need at least 4 GPUs"), - id="pp2-cfg2-true_cfg", - ), - ], -) -def test_scheduler_step(pp_size, cfg_size, do_true_cfg, input_seed): - """Rank 0 latents after scheduler_step match the single-GPU baseline across PP / CFG topologies.""" +def _run_scheduler_step( + *, + pp_size: int, + cfg_size: int, + do_true_cfg: bool, + input_seed: int, + device_kind: DeviceKind, + master_port: str, +) -> None: test_config = { "num_layers": 4, "dim": 64, @@ -778,17 +916,16 @@ def test_scheduler_step(pp_size, cfg_size, do_true_cfg, input_seed): "input_seed": input_seed, } - baseline = compute_scheduler_step_baseline(test_config, do_true_cfg) + baseline = compute_scheduler_step_baseline(test_config, do_true_cfg, device_kind) mp_context = torch.multiprocessing.get_context("spawn") manager = mp_context.Manager() q = manager.Queue() - port = _find_free_port() world_size = pp_size * cfg_size torch.multiprocessing.spawn( scheduler_step_worker, - args=(world_size, port, pp_size, cfg_size, do_true_cfg, test_config, q), + args=(world_size, master_port, pp_size, cfg_size, do_true_cfg, test_config, device_kind, q), nprocs=world_size, ) @@ -800,3 +937,47 @@ def test_scheduler_step(pp_size, cfg_size, do_true_cfg, input_seed): atol=0, msg=f"PP={pp_size} CFG={cfg_size} scheduler step latents on rank 0 do not match single-GPU baseline", ) + + +@pytest.mark.core_model +@pytest.mark.diffusion +@pytest.mark.cpu +@pytest.mark.parametrize( + "pp_size, cfg_size, do_true_cfg, input_seed", + [ + pytest.param(2, 1, False, 300, id="pp2-no_cfg"), + pytest.param(2, 2, True, 600, id="pp2-cfg2-true_cfg"), + ], +) +def test_scheduler_step(pp_size, cfg_size, do_true_cfg, input_seed): + """Rank 0 latents after scheduler_step match baseline across PP / CFG topologies.""" + _run_scheduler_step( + pp_size=pp_size, + cfg_size=cfg_size, + do_true_cfg=do_true_cfg, + input_seed=input_seed, + device_kind="cpu", + master_port=_find_free_port(), + ) + + +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.parallel +@pytest.mark.parametrize( + "pp_size, cfg_size, do_true_cfg, input_seed", + [ + pytest.param(2, 1, False, 300, marks=_L4_TWO_GPU, id="pp2-no_cfg"), + pytest.param(2, 2, True, 600, marks=_L4_FOUR_GPU, id="pp2-cfg2-true_cfg"), + ], +) +def test_scheduler_step_parity(pp_size, cfg_size, do_true_cfg, input_seed): + """Nightly: scheduler_step parity on real multi-GPU NCCL.""" + _run_scheduler_step( + pp_size=pp_size, + cfg_size=cfg_size, + do_true_cfg=do_true_cfg, + input_seed=input_seed, + device_kind="cuda", + master_port=_find_free_port(), + ) diff --git a/tests/diffusion/distributed/test_zimage_parallelism.py b/tests/diffusion/distributed/test_tensor_parallel.py similarity index 53% rename from tests/diffusion/distributed/test_zimage_parallelism.py rename to tests/diffusion/distributed/test_tensor_parallel.py index ab330ee9a..9f39112ae 100644 --- a/tests/diffusion/distributed/test_zimage_parallelism.py +++ b/tests/diffusion/distributed/test_tensor_parallel.py @@ -1,16 +1,14 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Z-Image end-to-end tests for diffusion parallelism. +"""DiT tensor-parallel parity and perf checks for diffusion models. -This file currently covers: -- DiT tensor parallelism (TP=2) vs TP=1. -- VAE patch parallelism (vae_patch_parallel_size=2) vs baseline on TP=2. - -Note: CUDA-only (>=2 GPUs). We use `enforce_eager=False` (default) to enable -`torch.compile`. +Compares TP=2 against TP=1 on output correctness, latency, and peak memory. +Uses ``Tongyi-MAI/Z-Image-Turbo`` as the parity model. """ +from __future__ import annotations + import os import time from pathlib import Path @@ -31,22 +29,15 @@ from vllm_omni.platforms import current_omni_platform os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" PROMPT = "a photo of a cat sitting on a laptop keyboard" - - -def _get_zimage_model() -> str: - # Allow overriding the model for local/offline environments. - # Can be either a HuggingFace repo id or a local path. - return os.environ.get("VLLM_TEST_ZIMAGE_MODEL", "Tongyi-MAI/Z-Image-Turbo") +DEFAULT_MODEL = "Tongyi-MAI/Z-Image-Turbo" def _pil_to_float_rgb_tensor(img: Image.Image) -> torch.Tensor: - """Convert PIL image to float32 RGB tensor in [0, 1] with shape [H, W, 3].""" arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0 return torch.from_numpy(arr) def _diff_metrics(a: Image.Image, b: Image.Image) -> tuple[float, float]: - """Return (mean_abs_diff, p99_abs_diff) over RGB pixels in [0, 1].""" ta = _pil_to_float_rgb_tensor(a) tb = _pil_to_float_rgb_tensor(b) assert ta.shape == tb.shape, f"Image shapes differ: {ta.shape} vs {tb.shape}" @@ -71,7 +62,7 @@ def _extract_single_image(outputs) -> Image.Image: return images[0] -def _run_zimage_generate( +def _run_generate( *, tp_size: int, height: int, @@ -79,8 +70,6 @@ def _run_zimage_generate( num_inference_steps: int, seed: int, enforce_eager: bool, - vae_use_tiling: bool = False, - vae_patch_parallel_size: int = 1, num_requests: int = 4, ) -> tuple[Image.Image, float, float]: if num_requests < 2: @@ -91,25 +80,11 @@ def _run_zimage_generate( monitor = DeviceMemoryMonitor(device_index=device_index, interval=0.02) monitor.start() try: - # Each run needs a distinct DiffusionParallelConfig; use OmniRunner per call (not the - # parametrized omni_runner fixture, which is fixed per module). with OmniRunner( - _get_zimage_model(), - parallel_config=DiffusionParallelConfig( - tensor_parallel_size=tp_size, - vae_patch_parallel_size=vae_patch_parallel_size, - ), + DEFAULT_MODEL, + parallel_config=DiffusionParallelConfig(tensor_parallel_size=tp_size), enforce_eager=enforce_eager, - vae_use_tiling=vae_use_tiling, ) as runner: - # NOTE: Omni closes itself when a generate() call is exhausted. - # To avoid measuring teardown time (process shutdown, memory cleanup), - # we measure the latency to produce *subsequent* outputs within a single - # generator run. - # - # This also serves as a warmup: the first output may include extra - # compilation/caching overhead, while later outputs are closer to - # steady-state inference. gen = runner.omni.generate( [PROMPT] * num_requests, OmniDiffusionSamplingParams( @@ -134,14 +109,11 @@ def _run_zimage_generate( per_request_times_s.append(t_now - t_prev) t_prev = t_now - # Ensure the generator is fully consumed so it can clean up. for _ in gen: pass median_time_s = float(np.median(per_request_times_s)) - peak_memory_mb = monitor.peak_used_mb - return _extract_single_image([last_output]), median_time_s, peak_memory_mb finally: monitor.stop() @@ -151,20 +123,20 @@ def _run_zimage_generate( @pytest.mark.diffusion @pytest.mark.parallel @hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2}) -def test_zimage_tensor_parallel_tp2(tmp_path: Path): +def test_tensor_parallel_tp2(tmp_path: Path): if current_omni_platform.is_npu(): - pytest.skip("Z-Image TP e2e test is only supported on CUDA and ROCm for now.") + pytest.skip("Tensor parallel parity test is only supported on CUDA and ROCm for now.") if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2: - pytest.skip("Z-Image TP=2 requires >= 2 devices.") + pytest.skip("Tensor parallel TP=2 requires >= 2 devices.") + model_name = DEFAULT_MODEL enforce_eager = False - height = 512 width = 512 num_inference_steps = 2 seed = 42 - tp1_img, tp1_time_s, tp1_peak_mem = _run_zimage_generate( + tp1_img, tp1_time_s, tp1_peak_mem = _run_generate( tp_size=1, height=height, width=width, @@ -172,7 +144,7 @@ def test_zimage_tensor_parallel_tp2(tmp_path: Path): seed=seed, enforce_eager=enforce_eager, ) - tp2_img, tp2_time_s, tp2_peak_mem = _run_zimage_generate( + tp2_img, tp2_time_s, tp2_peak_mem = _run_generate( tp_size=2, height=height, width=width, @@ -181,8 +153,8 @@ def test_zimage_tensor_parallel_tp2(tmp_path: Path): enforce_eager=enforce_eager, ) - tp1_path = tmp_path / "zimage_tp1.png" - tp2_path = tmp_path / "zimage_tp2.png" + tp1_path = tmp_path / "tp1.png" + tp2_path = tmp_path / "tp2.png" tp1_img.save(tp1_path) tp2_img.save(tp2_path) @@ -193,7 +165,7 @@ def test_zimage_tensor_parallel_tp2(tmp_path: Path): mean_threshold = 3e-2 p99_threshold = 2.5e-1 print( - "Z-Image TP image diff stats (TP=1 vs TP=2): " + f"{model_name} TP image diff (TP=1 vs TP=2): " f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; " f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; " f"tp1_img={tp1_path}, tp2_img={tp2_path}" @@ -203,73 +175,11 @@ def test_zimage_tensor_parallel_tp2(tmp_path: Path): f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})" ) - print(f"Z-Image TP perf (lower is better): tp1_time_s={tp1_time_s:.6f}, tp2_time_s={tp2_time_s:.6f}") - # ROCm is not optimized TP2 can be slower than TP1 + print(f"TP perf (lower is better): tp1_time_s={tp1_time_s:.6f}, tp2_time_s={tp2_time_s:.6f}") if not current_omni_platform.is_rocm(): assert tp2_time_s < tp1_time_s, f"Expected TP=2 to be faster than TP=1 (tp1={tp1_time_s}, tp2={tp2_time_s})" - print(f"Z-Image TP peak memory (MB): tp1_peak_mem={tp1_peak_mem:.2f}, tp2_peak_mem={tp2_peak_mem:.2f}") + print(f"TP peak memory (MB): tp1_peak_mem={tp1_peak_mem:.2f}, tp2_peak_mem={tp2_peak_mem:.2f}") assert tp2_peak_mem < tp1_peak_mem, ( f"Expected TP=2 to use less peak memory than TP=1 (tp1={tp1_peak_mem}, tp2={tp2_peak_mem})" ) - - -@pytest.mark.advanced_model -@pytest.mark.diffusion -@pytest.mark.parallel -@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2}) -def test_zimage_vae_patch_parallel_tp2(tmp_path: Path): - if current_omni_platform.is_npu(): - pytest.skip("Z-Image VAE patch parallel e2e test is only supported on CUDA and ROCm for now.") - if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2: - pytest.skip("Z-Image VAE patch parallel TP=2 requires >= 2 devices.") - - enforce_eager = False - - # Use a larger image to ensure there are multiple VAE tiles. - height = 1152 - width = 1152 - num_inference_steps = 2 - seed = 42 - - baseline_img, _baseline_time_s, _baseline_peak_mem = _run_zimage_generate( - tp_size=2, - height=height, - width=width, - num_inference_steps=num_inference_steps, - seed=seed, - enforce_eager=enforce_eager, - vae_use_tiling=True, - vae_patch_parallel_size=1, - num_requests=2, - ) - pp2_img, _pp2_time_s, _pp2_peak_mem = _run_zimage_generate( - tp_size=2, - height=height, - width=width, - num_inference_steps=num_inference_steps, - seed=seed, - enforce_eager=enforce_eager, - vae_use_tiling=True, - vae_patch_parallel_size=2, - num_requests=2, - ) - - baseline_path = tmp_path / "zimage_tp2_vae_pp1.png" - pp2_path = tmp_path / "zimage_tp2_vae_pp2.png" - baseline_img.save(baseline_path) - pp2_img.save(pp2_path) - - mean_abs_diff, p99_abs_diff = _diff_metrics(baseline_img, pp2_img) - mean_threshold = 5e-3 - p99_threshold = 1e-1 - print( - "Z-Image VAE patch parallel image diff stats (TP=2, pp=1 vs pp=2): " - f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; " - f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; " - f"pp1_img={baseline_path}, pp2_img={pp2_path}" - ) - assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, ( - f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} " - f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})" - ) diff --git a/tests/diffusion/distributed/test_vae_decode_parallelism.py b/tests/diffusion/distributed/test_vae_decode_parallelism.py index 2fd531e3e..c3996fc23 100644 --- a/tests/diffusion/distributed/test_vae_decode_parallelism.py +++ b/tests/diffusion/distributed/test_vae_decode_parallelism.py @@ -1,151 +1,181 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""VAE patch-parallel decode parity tests for diffusion models. + +Compares ``vae_patch_parallel_size=2`` against ``vae_patch_parallel_size=1`` on +TP=2 with VAE tiling enabled. Covers one video model (Wan T2V) and one image +model (Qwen-Image). CUDA/ROCm-only (>=2 devices). +""" + +from __future__ import annotations + import os -import sys from pathlib import Path +from typing import Any import numpy as np import pytest import torch from PIL import Image -from vllm.distributed.parallel_state import cleanup_dist_env_and_memory +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniRunner from vllm_omni.diffusion.data import DiffusionParallelConfig from vllm_omni.inputs.data import OmniDiffusionSamplingParams - -# ruff: noqa: E402 -REPO_ROOT = Path(__file__).resolve().parents[2] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) - -import time - -from tests.helpers.runtime import OmniRunner from vllm_omni.platforms import current_omni_platform os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" -all_model_configs = [ - { - "model_name": "Wan-AI/Wan2.2-TI2V-5B-Diffusers", - "out_width": 1280, - "out_height": 720, - "out_frames": 16, - }, - { - "model_name": "Wan-AI/Wan2.2-T2V-A14B-Diffusers", - "out_width": 1280, - "out_height": 720, - "out_frames": 16, - }, - { - "model_name": "Wan-AI/Wan2.2-I2V-A14B-Diffusers", - "out_width": 1280, - "out_height": 720, - "out_frames": 16, - }, - { - "model_name": "Qwen/Qwen-Image", - "out_width": 3072, - "out_height": 3072, - "out_frames": 1, - }, - # { - # "model_name": "stepfun-ai/NextStep-1.1", - # "out_width": 3072, - # "out_height": 3072, - # "out_frames": 1, - # }, +PROMPT = "A cat sitting on a table" + +MODEL_CASES = [ + pytest.param( + { + "model_name": "Wan-AI/Wan2.2-T2V-A14B-Diffusers", + "height": 512, + "width": 512, + "num_frames": 5, + "needs_image": False, + "is_moe": False, + "mean_threshold": 3e-2, + "p99_threshold": 4e-2, + }, + id="wan22_t2v_a14b", + ), + pytest.param( + { + "model_name": "Qwen/Qwen-Image", + "height": 1152, + "width": 1152, + "num_frames": 1, + "needs_image": False, + "is_moe": False, + "mean_threshold": 5e-3, + "p99_threshold": 1e-1, + }, + id="qwen_image", + ), ] -def is_nextstep_model(model_name: str) -> bool: - """Check if the model is a NextStep model by reading its config.""" - from vllm.transformers_utils.config import get_hf_file_to_dict - - try: - cfg = get_hf_file_to_dict("config.json", model_name) - if cfg and cfg.get("model_type") == "nextstep": - return True - except Exception: - pass - return False +def _to_float_array(data: Any) -> np.ndarray: + if isinstance(data, Image.Image): + return np.asarray(data.convert("RGB"), dtype=np.float32) / 255.0 + if isinstance(data, torch.Tensor): + arr = data.detach().cpu().numpy() + else: + arr = np.asarray(data) + if arr.dtype == np.uint8: + return arr.astype(np.float32) / 255.0 + return arr.astype(np.float32) -def model_run(model_configs, tp, out_height, out_width, out_frames, using_tile, vae_patch_parallel_size=1): +def _extract_output_array(output: Any) -> np.ndarray: + req_out = output.request_output + payload = req_out.images[0] + return _to_float_array(payload) + + +def _diff_metrics(a: np.ndarray, b: np.ndarray) -> tuple[float, float]: + assert a.shape == b.shape, f"Output shapes differ: {a.shape} vs {b.shape}" + abs_diff = np.abs(a - b) + return float(abs_diff.mean()), float(np.quantile(abs_diff, 0.99)) + + +def _save_preview(arr: np.ndarray, path: Path) -> None: + """Save a single RGB frame preview; video tensors use the first frame.""" + preview = np.asarray(arr) + # (B, T, H, W, C) video + if preview.ndim == 5: + preview = preview[0, 0] + # (T, H, W, C) video or (B, H, W, C) image batch + elif preview.ndim == 4: + preview = preview[0] + if preview.ndim != 3 or preview.shape[-1] not in (3, 4): + raise ValueError(f"Expected HWC RGB(A) preview, got shape {arr.shape} -> {preview.shape}") + preview = np.clip(preview[..., :3], 0.0, 1.0) + Image.fromarray((preview * 255.0).astype(np.uint8)).save(path) + + +def _build_sampling_params(model_case: dict[str, Any], seed: int) -> OmniDiffusionSamplingParams: + kwargs: dict[str, Any] = { + "height": model_case["height"], + "width": model_case["width"], + "num_frames": model_case["num_frames"], + "num_inference_steps": 2, + "seed": seed, + } + if model_case["model_name"].startswith("Wan-AI/"): + kwargs["guidance_scale"] = 4.0 + if model_case["is_moe"]: + kwargs["guidance_scale_2"] = 1.0 + kwargs["boundary_ratio"] = 0.5 + return OmniDiffusionSamplingParams(**kwargs) + + +def _run_generate( + *, + model_case: dict[str, Any], + vae_patch_parallel_size: int, + seed: int, +) -> np.ndarray: + current_omni_platform.empty_cache() parallel_config = DiffusionParallelConfig( - tensor_parallel_size=tp, + tensor_parallel_size=2, vae_patch_parallel_size=vae_patch_parallel_size, ) - - omni_kwargs = { - "vae_use_tiling": using_tile, - "parallel_config": parallel_config, - } - use_nextstep = is_nextstep_model(model_configs["model_name"]) - if use_nextstep: - # NextStep-1.1 requires explicit pipeline class - omni_kwargs["model_class_name"] = "NextStep11Pipeline" - - with OmniRunner(model_configs["model_name"], **omni_kwargs) as runner: - m = runner.omni - image = Image.new("RGB", (out_width, out_height), (0, 0, 0)) - start = time.perf_counter() - outputs = m.generate( - { - "prompt": "A cat sitting on a table", - "multi_modal_data": {"image": image}, - }, - sampling_params_list=OmniDiffusionSamplingParams( - height=model_configs["out_height"], - width=model_configs["out_width"], - num_frames=model_configs["out_frames"], - num_inference_steps=2, - generator=torch.Generator(current_omni_platform.device_type).manual_seed(42), - ), + with OmniRunner( + model_case["model_name"], + parallel_config=parallel_config, + vae_use_tiling=True, + enforce_eager=False, + ) as runner: + request: dict[str, Any] = {"prompt": model_case.get("prompt", PROMPT)} + if model_case["needs_image"]: + request["multi_modal_data"] = { + "image": Image.new("RGB", (model_case["width"], model_case["height"]), (0, 0, 0)) + } + outputs = runner.omni.generate( + request, + sampling_params_list=_build_sampling_params(model_case, seed), ) - end = time.perf_counter() - first_output = outputs[0] - req_out = first_output.request_output - frames = req_out.images[0] - if isinstance(frames, torch.Tensor): - frames = frames.detach().cpu().numpy() - elif isinstance(frames, Image.Image): - frames = np.array(frames) - # frames shape: (batch, num_frames, height, width, channels) - cost = (end - start) * 1000 - return frames, cost - cleanup_dist_env_and_memory() + return _extract_output_array(outputs[0]) -@pytest.mark.parametrize("model_configs", all_model_configs) -def test_vae_parallel_model(model_configs: str): - parallel_result, parallel_time = model_run( - model_configs=model_configs, - tp=2, - out_width=1280, - out_height=704, - out_frames=5, - using_tile=True, - vae_patch_parallel_size=2, - ) - non_parallel_result, non_parallel_time = model_run( - model_configs=model_configs, - tp=2, - out_width=1280, - out_height=704, - out_frames=5, - using_tile=True, - vae_patch_parallel_size=1, - ) - result_diff = np.abs(non_parallel_result - parallel_result) +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.parallel +@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 4}) +@pytest.mark.parametrize("model_case", MODEL_CASES) +def test_vae_patch_parallel_tp2(model_case: dict[str, Any], tmp_path: Path): + if current_omni_platform.is_npu(): + pytest.skip("VAE patch parallel test is only supported on CUDA and ROCm for now.") + if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2: + pytest.skip("VAE patch parallel TP=2 requires >= 2 devices.") - mean_threshold = 3e-2 - max_threshold = 3e-2 # they should be totally same + seed = 42 + model_id = model_case["model_name"].split("/")[-1] + + baseline = _run_generate(model_case=model_case, vae_patch_parallel_size=1, seed=seed) + parallel = _run_generate(model_case=model_case, vae_patch_parallel_size=2, seed=seed) + + baseline_path = tmp_path / f"{model_id}_tp2_vae_pp1.png" + parallel_path = tmp_path / f"{model_id}_tp2_vae_pp2.png" + _save_preview(baseline, baseline_path) + _save_preview(parallel, parallel_path) + + mean_abs_diff, p99_abs_diff = _diff_metrics(baseline, parallel) + mean_threshold = model_case["mean_threshold"] + p99_threshold = model_case["p99_threshold"] print( - f"{model_configs['model_name']} TP = 2 (tile + parallel vs tile): " - f"mean_abs_diff={result_diff.mean():.6e}, max_abs_diff={result_diff.max():.6e}; " - f"thresholds: mean<={mean_threshold:.6e}, max<={max_threshold:.6e}; " - f"parallel generate take time: {parallel_time:.2f} ms, non-parallel take time: {non_parallel_time:.2f} ms" + f"{model_case['model_name']} VAE patch parallel diff (TP=2, pp=1 vs pp=2): " + f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; " + f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; " + f"pp1_preview={baseline_path}, pp2_preview={parallel_path}" + ) + assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, ( + f"Output diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, " + f"p99_abs_diff={p99_abs_diff:.6e} " + f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})" ) - # assert non_parallel_time > parallel_time - assert result_diff.mean() < mean_threshold - assert result_diff.max() < max_threshold diff --git a/tests/diffusion/distributed/test_wan_spatial_shard.py b/tests/diffusion/distributed/test_wan_spatial_shard.py index 6d668df3a..72a2a45f5 100644 --- a/tests/diffusion/distributed/test_wan_spatial_shard.py +++ b/tests/diffusion/distributed/test_wan_spatial_shard.py @@ -6,18 +6,19 @@ import torch import torch.distributed as dist import torch.multiprocessing as mp +from tests.helpers.mark import hardware_test from vllm_omni.diffusion.distributed.autoencoders import wan_spatial_shard from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import ( DistributedAutoencoderKLWan, ) from vllm_omni.platforms import current_omni_platform -# Module-level markers apply to the CPU unit tests below. The multi-GPU -# correctness test at the end of the file adds the nightly/distributed markers -# and a skip guard so it only runs where >= 2 CUDA devices are available. -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +# CPU unit tests are marked core_model + cpu. The multi-GPU correctness test at +# the end uses hardware_test (H100 x2) plus full_model / diffusion / parallel. +@pytest.mark.core_model +@pytest.mark.cpu def test_split_for_parallel_decode_pads_uneven_height(): x = torch.arange(1 * 1 * 1 * 5 * 2, dtype=torch.float32).reshape(1, 1, 1, 5, 2) @@ -34,6 +35,8 @@ def test_split_for_parallel_decode_pads_uneven_height(): assert torch.equal(local[..., 1, :], torch.zeros_like(local[..., 1, :])) +@pytest.mark.core_model +@pytest.mark.cpu def test_split_for_parallel_decode_pads_uneven_width(): x = torch.arange(1 * 1 * 1 * 2 * 5, dtype=torch.float32).reshape(1, 1, 1, 2, 5) @@ -51,6 +54,8 @@ def test_split_for_parallel_decode_pads_uneven_width(): assert torch.equal(local[..., :, 1], torch.zeros_like(local[..., :, 1])) +@pytest.mark.core_model +@pytest.mark.cpu def test_split_for_parallel_decode_rejects_invalid_split_dim(): x = torch.zeros((1, 1, 1, 4, 4), dtype=torch.float32) @@ -64,6 +69,8 @@ def test_split_for_parallel_decode_rejects_invalid_split_dim(): ) +@pytest.mark.core_model +@pytest.mark.cpu def test_split_for_parallel_decode_rejects_zero_world_size(): x = torch.zeros((1, 1, 1, 4, 4), dtype=torch.float32) @@ -76,6 +83,8 @@ def test_split_for_parallel_decode_rejects_zero_world_size(): ) +@pytest.mark.core_model +@pytest.mark.cpu def test_split_for_parallel_decode_rejects_rank_out_of_range(): x = torch.zeros((1, 1, 1, 4, 4), dtype=torch.float32) @@ -88,6 +97,8 @@ def test_split_for_parallel_decode_rejects_rank_out_of_range(): ) +@pytest.mark.core_model +@pytest.mark.cpu def test_gather_and_trim_height(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (0, 3)) @@ -104,6 +115,8 @@ def test_gather_and_trim_height(monkeypatch: pytest.MonkeyPatch): assert torch.equal(out.flatten(), torch.tensor([0.0, 0.0, 1.0, 1.0, 2.0])) +@pytest.mark.core_model +@pytest.mark.cpu def test_gather_and_trim_width(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (0, 3)) @@ -120,6 +133,8 @@ def test_gather_and_trim_width(monkeypatch: pytest.MonkeyPatch): assert torch.equal(out.flatten(), torch.tensor([0.0, 0.0, 1.0, 1.0, 2.0])) +@pytest.mark.core_model +@pytest.mark.cpu def test_gather_and_trim_rank0_only_assembles_on_rank0(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (0, 3)) @@ -136,6 +151,8 @@ def test_gather_and_trim_rank0_only_assembles_on_rank0(monkeypatch: pytest.Monke assert torch.equal(out.flatten(), torch.tensor([0.0, 0.0, 1.0, 1.0, 2.0])) +@pytest.mark.core_model +@pytest.mark.cpu def test_gather_and_trim_rank0_only_returns_empty_on_non_zero_rank(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (1, 3)) @@ -156,6 +173,8 @@ def test_gather_and_trim_rank0_only_returns_empty_on_non_zero_rank(monkeypatch: assert out.numel() == 0 +@pytest.mark.core_model +@pytest.mark.cpu def test_reshard_from_trimmed_height_pads_invalid_rows(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (2, 3)) @@ -183,6 +202,8 @@ def test_reshard_from_trimmed_height_pads_invalid_rows(monkeypatch: pytest.Monke assert torch.equal(out.flatten(), torch.tensor([4.0, 0.0])) +@pytest.mark.core_model +@pytest.mark.cpu def test_reshard_from_trimmed_width_pads_invalid_columns(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (2, 3)) @@ -210,6 +231,8 @@ def test_reshard_from_trimmed_width_pads_invalid_columns(monkeypatch: pytest.Mon assert torch.equal(out.flatten(), torch.tensor([4.0, 0.0])) +@pytest.mark.core_model +@pytest.mark.cpu def test_halo_exchange_single_rank_noop(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (0, 1)) @@ -225,6 +248,8 @@ def test_halo_exchange_single_rank_noop(monkeypatch: pytest.MonkeyPatch): assert recv_bottom is None +@pytest.mark.core_model +@pytest.mark.cpu def test_dist_zero_pad_only_applies_global_height_edges(monkeypatch: pytest.MonkeyPatch): x = torch.ones((1, 1, 2, 2)) @@ -239,6 +264,8 @@ def test_dist_zero_pad_only_applies_global_height_edges(monkeypatch: pytest.Monk assert last.shape == (1, 1, 3, 3) +@pytest.mark.core_model +@pytest.mark.cpu def test_dist_zero_pad_only_applies_global_width_edges(monkeypatch: pytest.MonkeyPatch): x = torch.ones((1, 1, 2, 2)) @@ -253,6 +280,8 @@ def test_dist_zero_pad_only_applies_global_width_edges(monkeypatch: pytest.Monke assert last.shape == (1, 1, 2, 3) +@pytest.mark.core_model +@pytest.mark.cpu def test_spatial_shard_height_gate_falls_back_for_partial_group(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan.dist.get_world_size", @@ -270,6 +299,8 @@ def test_spatial_shard_height_gate_falls_back_for_partial_group(monkeypatch: pyt assert vae._spatial_shard_decode_enabled(z) is False +@pytest.mark.core_model +@pytest.mark.cpu def test_spatial_shard_width_gate_selects_width(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan.dist.get_world_size", @@ -286,6 +317,8 @@ def test_spatial_shard_width_gate_selects_width(monkeypatch: pytest.MonkeyPatch) assert vae._spatial_shard_decode_enabled(z) is True +@pytest.mark.core_model +@pytest.mark.cpu def test_tile_mode_disables_spatial_shard_decode(): vae = DistributedAutoencoderKLWan.__new__(DistributedAutoencoderKLWan) vae.distributed_executor = SimpleNamespace(group=object(), parallel_size=2, parallel_mode="tile") @@ -298,7 +331,7 @@ def test_tile_mode_disables_spatial_shard_decode(): # ============================================================================= -# Multi-GPU numerical-correctness test (nightly / full_model, distributed CUDA) +# Multi-GPU numerical-correctness test (nightly Diffusion Test group, H100 x2) # # Spawns a small process group and verifies that spatial_shard_height/spatial_shard_width decode match # a single-process (non-distributed) reference decode of the same latent within @@ -383,11 +416,7 @@ def _spatial_shard_decode_worker(rank: int, split_dim: str, return_dict, master_ @pytest.mark.full_model @pytest.mark.diffusion @pytest.mark.parallel -@pytest.mark.distributed_cuda -@pytest.mark.skipif( - current_omni_platform.get_device_count() < _SPATIAL_SHARD_WORLD_SIZE, - reason="Requires >= 2 accelerator devices", -) +@hardware_test(res={"cuda": "H100"}, num_cards=_SPATIAL_SHARD_WORLD_SIZE) @pytest.mark.parametrize("split_dim", ["height", "width"]) def test_spatial_shard_decode_matches_reference(split_dim: str): manager = mp.get_context("spawn").Manager() diff --git a/tests/diffusion/hooks/test_hook_registry.py b/tests/diffusion/hooks/test_hook_registry.py index 6c8535cfe..3ee93a073 100644 --- a/tests/diffusion/hooks/test_hook_registry.py +++ b/tests/diffusion/hooks/test_hook_registry.py @@ -12,6 +12,9 @@ from torch import nn from vllm_omni.diffusion.hooks.base import HookRegistry, ModelHook +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + DEFAULT_OUT = "ECHO" OVERRIDE_OUT = "OVERRIDE" INPUT_KWARG = "inp" diff --git a/tests/diffusion/layers/test_fused_moe.py b/tests/diffusion/layers/test_fused_moe.py new file mode 100644 index 000000000..983e146ef --- /dev/null +++ b/tests/diffusion/layers/test_fused_moe.py @@ -0,0 +1,215 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for the diffusion FusedMoE adapter.""" + +import sys +from types import SimpleNamespace + +import pytest +import torch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class TestSetForwardContextNumTokens: + """Test _set_forward_context_num_tokens defensive fix for vLLM 0.18.0.""" + + def test_sets_num_tokens_when_context_available(self, mocker): + """num_tokens should be set on ForwardContext when available.""" + import vllm_omni.diffusion.layers.fused_moe as fused_moe + + mock_ctx = mocker.MagicMock() + del mock_ctx.in_profile_run # simulate missing attr + mocker.patch.object(fused_moe._vllm_fc, "is_forward_context_available", return_value=True) + mocker.patch.object(fused_moe._vllm_fc, "get_forward_context", return_value=mock_ctx) + + fused_moe._set_forward_context_num_tokens(1024) + + assert mock_ctx.num_tokens == 1024 + assert mock_ctx.in_profile_run is False + + def test_sets_in_profile_run_only_if_missing(self, mocker): + """in_profile_run should not be overwritten if already set.""" + import vllm_omni.diffusion.layers.fused_moe as fused_moe + + mock_ctx = mocker.MagicMock() + mock_ctx.in_profile_run = True # already set + mocker.patch.object(fused_moe._vllm_fc, "is_forward_context_available", return_value=True) + mocker.patch.object(fused_moe._vllm_fc, "get_forward_context", return_value=mock_ctx) + + fused_moe._set_forward_context_num_tokens(512) + + assert mock_ctx.num_tokens == 512 + assert mock_ctx.in_profile_run is True # not overwritten + + def test_noop_when_context_unavailable(self, mocker): + """Should do nothing when ForwardContext is not available.""" + import vllm_omni.diffusion.layers.fused_moe as fused_moe + + mocker.patch.object(fused_moe._vllm_fc, "is_forward_context_available", return_value=False) + mock_get = mocker.patch.object(fused_moe._vllm_fc, "get_forward_context") + + fused_moe._set_forward_context_num_tokens(256) + + mock_get.assert_not_called() + + +class TestSetForwardContextDPMetadata: + """Test diffusion MoE DP metadata setup for vLLM 0.24 dispatch.""" + + def test_sets_dp_metadata_from_vllm_dp_group(self, mocker): + import vllm_omni.diffusion.layers.fused_moe as fused_moe + + mock_ctx = mocker.MagicMock() + mock_ctx.dp_metadata = None + fake_group = mocker.MagicMock() + fake_group.world_size = 2 + fake_group.cpu_group = object() + + def _all_gather_object(output, obj, group): + assert group is fake_group.cpu_group + output[:] = [obj, obj + 1] + + mocker.patch.object(fused_moe._vllm_fc, "is_forward_context_available", return_value=True) + mocker.patch.object(fused_moe._vllm_fc, "get_forward_context", return_value=mock_ctx) + mocker.patch.object(fused_moe._vllm_ps, "_DP", fake_group, create=True) + mocker.patch.object(fused_moe.torch.distributed, "all_gather_object", side_effect=_all_gather_object) + + fused_moe._set_forward_context_dp_metadata(1024) + + assert mock_ctx.dp_metadata is not None + assert mock_ctx.dp_metadata.num_tokens_across_dp_cpu.tolist() == [1024, 1025] + + def test_keeps_existing_dp_metadata(self, mocker): + import vllm_omni.diffusion.layers.fused_moe as fused_moe + + existing_metadata = object() + mock_ctx = mocker.MagicMock() + mock_ctx.dp_metadata = existing_metadata + mocker.patch.object(fused_moe._vllm_fc, "is_forward_context_available", return_value=True) + mocker.patch.object(fused_moe._vllm_fc, "get_forward_context", return_value=mock_ctx) + mock_all_gather = mocker.patch.object(fused_moe.torch.distributed, "all_gather_object") + + fused_moe._set_forward_context_dp_metadata(1024) + + assert mock_ctx.dp_metadata is existing_metadata + mock_all_gather.assert_not_called() + + +class TestFusedMoEFactory: + """Test FusedMoE factory __new__ and mapping delegation.""" + + def test_new_returns_vllm_runner_and_registers_hooks(self, mocker, monkeypatch): + import vllm_omni.diffusion.layers.fused_moe as fused_moe + + mock_platform = mocker.MagicMock() + mocker.patch.object(fused_moe, "current_omni_platform", mock_platform) + + mock_runner = mocker.MagicMock() + mock_vllm_fused_moe = mocker.MagicMock(return_value=mock_runner) + monkeypatch.setitem( + sys.modules, + "vllm.model_executor.layers.fused_moe", + SimpleNamespace(FusedMoE=mock_vllm_fused_moe), + ) + + result = fused_moe.FusedMoE(prefix="test", a=1, reduce_results=True) + + assert result is mock_runner + mock_platform.prepare_diffusion_op_runtime.assert_called_once_with("fused_moe") + mock_vllm_fused_moe.assert_called_once_with(prefix="test", a=1) + mock_runner.register_forward_pre_hook.assert_called_once_with( + mocker.ANY, + with_kwargs=True, + ) + mock_platform.register_additional_diffusion_fused_moe_hooks.assert_called_once_with(mock_runner) + + def test_num_tokens_pre_hook_uses_kwargs_hidden_states(self, mocker, monkeypatch): + import vllm_omni.diffusion.layers.fused_moe as fused_moe + + captured_hook = None + + class MockRunner: + def register_forward_pre_hook(self, hook, *, with_kwargs): + nonlocal captured_hook + assert with_kwargs is True + captured_hook = hook + + mock_runner = MockRunner() + monkeypatch.setitem( + sys.modules, + "vllm.model_executor.layers.fused_moe", + SimpleNamespace(FusedMoE=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") + mock_set_dp_metadata = mocker.patch.object(fused_moe, "_set_forward_context_dp_metadata") + + fused_moe.FusedMoE(prefix="") + assert captured_hook is not None + + hidden_states = torch.empty(17, 32) + captured_hook(mock_runner, (), {"hidden_states": hidden_states}) + + mock_set_num_tokens.assert_called_once_with(17) + mock_set_dp_metadata.assert_called_once_with(17) + + def test_num_tokens_pre_hook_uses_positional_hidden_states(self, mocker, monkeypatch): + import vllm_omni.diffusion.layers.fused_moe as fused_moe + + captured_hook = None + + class MockRunner: + def register_forward_pre_hook(self, hook, *, with_kwargs): + nonlocal captured_hook + assert with_kwargs is True + captured_hook = hook + + mock_runner = MockRunner() + monkeypatch.setitem( + sys.modules, + "vllm.model_executor.layers.fused_moe", + SimpleNamespace(FusedMoE=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") + mock_set_dp_metadata = mocker.patch.object(fused_moe, "_set_forward_context_dp_metadata") + + fused_moe.FusedMoE(prefix="") + assert captured_hook is not None + + hidden_states = torch.empty(23, 32) + captured_hook(mock_runner, (hidden_states,), {}) + + mock_set_num_tokens.assert_called_once_with(23) + mock_set_dp_metadata.assert_called_once_with(23) + + def test_make_expert_params_mapping_delegates_to_vllm_function(self, mocker, monkeypatch): + import vllm_omni.diffusion.layers.fused_moe as fused_moe + + expected_mapping = [("a", "b", 0, "c")] + mock_mapping_fn = mocker.MagicMock(return_value=expected_mapping) + monkeypatch.setitem( + sys.modules, + "vllm.model_executor.layers.fused_moe", + SimpleNamespace(fused_moe_make_expert_params_mapping=mock_mapping_fn), + ) + + result = fused_moe.FusedMoE.make_expert_params_mapping( + model=None, + ckpt_gate_proj_name="gate", + ckpt_down_proj_name="down", + ckpt_up_proj_name="up", + num_experts=4, + num_redundant_experts=0, + ) + + assert result == expected_mapping + mock_mapping_fn.assert_called_once_with( + None, + ckpt_gate_proj_name="gate", + ckpt_down_proj_name="down", + ckpt_up_proj_name="up", + num_experts=4, + num_redundant_experts=0, + ) diff --git a/tests/diffusion/layers/test_norm.py b/tests/diffusion/layers/test_norm.py index 694f8f8e9..71125684b 100644 --- a/tests/diffusion/layers/test_norm.py +++ b/tests/diffusion/layers/test_norm.py @@ -2,6 +2,9 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Unit tests for LayerNorm and RMSNorm custom ops in diffusion layers.""" +import sys +import types + import pytest import torch from pytest_mock import MockerFixture @@ -299,6 +302,44 @@ def test_rmsnorm_numerical_correctness(): torch.testing.assert_close(out, expected, atol=1e-5, rtol=1e-5) +def test_rmsnorm_dtype_matches_parameter_and_fp32_accumulation_contract(): + """dtype controls gamma storage; reduced-precision inputs still use fp32 accumulation.""" + from vllm_omni.diffusion.layers.norm import RMSNorm + + hidden_size = 64 + eps = 1e-6 + norm = RMSNorm(hidden_size, eps=eps, dtype=torch.bfloat16) + x = torch.randn(2, 4, hidden_size, dtype=torch.bfloat16) + + assert norm.weight.dtype == torch.bfloat16 + + x_fp32 = x.float() + expected = (x_fp32 * torch.rsqrt(x_fp32.square().mean(-1, keepdim=True) + eps) * norm.weight.float()).to(x.dtype) + + torch.testing.assert_close(norm.forward_native(x), expected, atol=0, rtol=0) + + +def test_rmsnorm_npu_receives_gamma_with_configured_dtype(monkeypatch: pytest.MonkeyPatch): + """The NPU fused op receives the bf16 checkpoint gamma used by H3.""" + from vllm_omni.diffusion.layers.norm import RMSNorm + + captured: dict[str, torch.Tensor] = {} + + def npu_rms_norm(x: torch.Tensor, gamma: torch.Tensor, epsilon: float): + captured["gamma"] = gamma + captured["epsilon"] = torch.tensor(epsilon) + return (x,) + + monkeypatch.setitem(sys.modules, "torch_npu", types.SimpleNamespace(npu_rms_norm=npu_rms_norm)) + norm = RMSNorm(64, eps=1e-5, dtype=torch.bfloat16) + x = torch.randn(2, 4, 64, dtype=torch.bfloat16) + + assert norm.forward_npu(x) is x + assert captured["gamma"] is norm.weight + assert captured["gamma"].dtype == torch.bfloat16 + assert captured["epsilon"].item() == pytest.approx(1e-5) + + # ── RMSNorm compile-path regression tests ── diff --git a/tests/diffusion/layers/test_rope_broadcast.py b/tests/diffusion/layers/test_rope_broadcast.py index 29fcf1804..e1238ba44 100644 --- a/tests/diffusion/layers/test_rope_broadcast.py +++ b/tests/diffusion/layers/test_rope_broadcast.py @@ -10,10 +10,13 @@ forward_hip, and apply_rotary_emb_mindiesd paths. from __future__ import annotations +import sys +import types + import pytest import torch -from vllm_omni.diffusion.layers.rope import RotaryEmbedding +from vllm_omni.diffusion.layers.rope import RotaryEmbedding, apply_rotary_emb_mindiesd pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -234,3 +237,33 @@ class TestRotaryEmbeddingNativeShapeRegression: output = rope.forward_native(x, cos, sin) assert output.shape == x.shape assert not torch.isnan(output).any() + + +def test_mindie_full_dim_rope_keeps_h3_cos_sin_layout(monkeypatch: pytest.MonkeyPatch) -> None: + """H3 passes complete rotary dims to MindIE instead of the CUDA half-dim layout.""" + captured: dict[str, object] = {} + + def rotary_position_embedding(x, cos, sin, **kwargs): + captured.update(x=x, cos=cos, sin=sin, kwargs=kwargs) + return x + + monkeypatch.setitem( + sys.modules, + "mindiesd", + types.SimpleNamespace(rotary_position_embedding=rotary_position_embedding), + ) + x = torch.randn(11, 3, 96, dtype=torch.bfloat16) + cos = torch.randn(11, 96, dtype=torch.bfloat16) + sin = torch.randn(11, 96, dtype=torch.bfloat16) + + actual = apply_rotary_emb_mindiesd(x, cos, sin, interleaved=False, half_head_dim=False) + + assert actual is x + assert captured["x"] is x + assert captured["cos"] is cos + assert captured["sin"] is sin + assert captured["kwargs"] == { + "rotated_mode": "rotated_half", + "head_first": False, + "fused": True, + } diff --git a/tests/diffusion/layers/test_rotary_emb_equivalence.py b/tests/diffusion/layers/test_rotary_emb_equivalence.py index 2fbb7a31f..09aef0373 100644 --- a/tests/diffusion/layers/test_rotary_emb_equivalence.py +++ b/tests/diffusion/layers/test_rotary_emb_equivalence.py @@ -13,6 +13,8 @@ from __future__ import annotations import pytest import torch +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + def _apply_rotary_emb_helios_original( hidden_states: torch.Tensor, diff --git a/tests/diffusion/model_loader/test_modelopt_fp8_adapter.py b/tests/diffusion/model_loader/test_modelopt_fp8_adapter.py index 66fa87a3c..8c429a00f 100644 --- a/tests/diffusion/model_loader/test_modelopt_fp8_adapter.py +++ b/tests/diffusion/model_loader/test_modelopt_fp8_adapter.py @@ -9,6 +9,7 @@ import torch.nn as nn from vllm_omni.diffusion.model_loader.checkpoint_adapters import ( ModelOptFp8CheckpointAdapter, + ModelOptNvFp4CheckpointAdapter, ) pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] @@ -89,6 +90,23 @@ class _QuantizedRemappedModelOptModel(nn.Module): return name +class _RemappedNvFp4Model(nn.Module): + def __init__(self) -> None: + super().__init__() + self.runtime = nn.Module() + self.runtime.proj = nn.Module() + self.runtime.proj.register_parameter( + "weight", + nn.Parameter(torch.empty(1, 1, dtype=torch.uint8), requires_grad=False), + ) + for name in ("input_scale", "weight_scale", "weight_scale_2"): + self.runtime.proj.register_parameter(name, nn.Parameter(torch.empty(1), requires_grad=False)) + + @staticmethod + def remap_checkpoint_key(name: str) -> str: + return name.replace("transformer.orig.proj.", "runtime.proj.") + + def _make_source() -> SimpleNamespace: return SimpleNamespace( subfolder="transformer", @@ -188,13 +206,36 @@ def test_modelopt_adapter_remaps_and_keeps_scales_for_quantized_target(): emitted = dict(adapted) assert len(adapted) == len(emitted) == 3 - # Both scales are retained (the dropped-scale regression). They keep their - # source-checkpoint names; the model's load_weights remaps them downstream. - assert torch.equal(emitted["transformer.orig.proj.weight_scale"], weight_scale) - assert torch.equal(emitted["transformer.orig.proj.input_scale"], input_scale) + # Both scales are retained and emitted under the remapped runtime names. + assert torch.equal(emitted["runtime.proj.weight_scale"], weight_scale) + assert torch.equal(emitted["runtime.proj.input_scale"], input_scale) # The FP8 weight is passed through unchanged under the remapped target name # (the quantized param is FP8, so it must NOT be dequantized). Compare via # float32 since torch.equal has no FP8 CPU kernel. assert emitted["runtime.proj.weight"].dtype == torch.float8_e4m3fn assert torch.equal(emitted["runtime.proj.weight"].to(torch.float32), fp8_weight.to(torch.float32)) + + +def test_modelopt_nvfp4_adapter_remaps_quantized_weights_and_scales(): + adapter = ModelOptNvFp4CheckpointAdapter(_RemappedNvFp4Model(), _make_source()) + prefix = "transformer.orig.proj" + checkpoint_tensors = [ + (f"{prefix}.input_scale", torch.tensor([1.0])), + (f"{prefix}.weight_scale", torch.tensor([0.5])), + (f"{prefix}.weight_scale_2", torch.tensor([0.25])), + (f"{prefix}.weight", torch.tensor([[1]], dtype=torch.uint8)), + ] + + adapted = list(adapter.adapt(iter(checkpoint_tensors))) + + assert [name for name, _ in adapted] == [ + name.replace(f"{prefix}.", "runtime.proj.") for name, _ in checkpoint_tensors + ] + + +def test_modelopt_nvfp4_adapter_rejects_unconsumed_pre_quant_scale(): + adapter = ModelOptNvFp4CheckpointAdapter(nn.Module(), _make_source()) + + with pytest.raises(ValueError, match="does not consume pre_quant_scale"): + list(adapter.adapt(iter([("transformer.proj.pre_quant_scale", torch.tensor([1.0]))]))) diff --git a/tests/diffusion/models/bagel/test_negative_prompt.py b/tests/diffusion/models/bagel/test_negative_prompt.py new file mode 100644 index 000000000..37715600c --- /dev/null +++ b/tests/diffusion/models/bagel/test_negative_prompt.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +import torch +from pytest_mock import MockerFixture + +from vllm_omni.diffusion.models.bagel.pipeline_bagel import BagelPipeline +from vllm_omni.diffusion.request import OmniDiffusionRequest +from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch +from vllm_omni.inputs.data import OmniDiffusionSamplingParams + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _NegativePromptPreparedError(Exception): + pass + + +@pytest.mark.parametrize( + ("prompt_negative", "extra_negative", "expected"), + [ + ("prompt negative", "legacy negative", "prompt negative"), + (None, "legacy negative", "legacy negative"), + ], + ids=["prompt-owner", "legacy-extra-args"], +) +def test_forward_reads_negative_prompt_from_prompt_with_legacy_fallback( + mocker: MockerFixture, + prompt_negative: str | None, + extra_negative: str, + expected: str, +) -> None: + pipeline = object.__new__(BagelPipeline) + torch.nn.Module.__init__(pipeline) + pipeline.device = torch.device("cpu") + pipeline.od_config = mocker.Mock(dtype=torch.float32) + pipeline.tokenizer = mocker.sentinel.tokenizer + pipeline.new_token_ids = {} + pipeline.language_model = mocker.Mock(vocab_size=10) + pipeline.image_processor = None + pipeline.vae = None + + bagel = mocker.MagicMock() + bagel.max_latent_size = 32 + bagel.latent_downsample = 8 + bagel.config.llm_config.num_hidden_layers = 1 + prepared_prompts: list[str] = [] + + def prepare_prompts(*, prompts, **_): + prepared_prompts.append(prompts[0]) + if len(prepared_prompts) == 1: + return {"packed_text_ids": torch.tensor([1])}, [1], [1] + raise _NegativePromptPreparedError + + bagel.prepare_prompts.side_effect = prepare_prompts + pipeline.bagel = bagel + + prompt = { + "prompt": "positive", + "negative_prompt": prompt_negative, + } + request = DiffusionRequestBatch( + requests=[ + OmniDiffusionRequest( + prompt=prompt, + sampling_params=OmniDiffusionSamplingParams( + extra_args={"negative_prompt": extra_negative}, + ), + request_id="test", + ) + ] + ) + + with pytest.raises(_NegativePromptPreparedError): + pipeline.forward(request) + + assert prepared_prompts == ["positive", expected] diff --git a/tests/e2e/offline_inference/rlhf_test/__init__.py b/tests/diffusion/models/boogu_image/__init__.py similarity index 100% rename from tests/e2e/offline_inference/rlhf_test/__init__.py rename to tests/diffusion/models/boogu_image/__init__.py diff --git a/tests/diffusion/models/boogu_image/test_boogu_image_scheduler.py b/tests/diffusion/models/boogu_image/test_boogu_image_scheduler.py new file mode 100644 index 000000000..a92d9e979 --- /dev/null +++ b/tests/diffusion/models/boogu_image/test_boogu_image_scheduler.py @@ -0,0 +1,161 @@ +# SPDX-License-Identifier: Apache-2.0 +"""L1 unit tests for the Boogu time-shifting flow-match Euler scheduler. + +Golden values were generated from the upstream boogu scheduler +(scripts/test_ported_scheduler_parity.py) with the released checkpoint's +scheduler_config.json, so CI does not need the `boogu` package installed. +""" + +import numpy as np +import pytest +import torch + +from vllm_omni.diffusion.models.boogu_image import FlowMatchEulerDiscreteScheduler + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +# Ground truth from the released checkpoint's scheduler/scheduler_config.json. +CHECKPOINT_CONFIG = dict( + num_train_timesteps=1000, + do_shift=True, + dynamic_time_shift=False, + time_shift_version="v1", + seq_len=4096, + base_shift=0.5, + max_shift=1.15, +) + +NUM_TOKENS = 4096 * 4 # 1024x1024 resolution + +# Generated from the upstream boogu scheduler with CHECKPOINT_CONFIG. +GOLDEN_TIMESTEPS = { + 4: [0.00000000, 0.09546924, 0.24048907, 0.48715585], + 10: [ + 0.00000000, + 0.03398621, + 0.07335263, + 0.11948693, + 0.17429835, + 0.24048907, + 0.32201332, + 0.42489707, + 0.55880034, + 0.74024153, + ], +} + + +def make_scheduler(**overrides) -> FlowMatchEulerDiscreteScheduler: + return FlowMatchEulerDiscreteScheduler(**{**CHECKPOINT_CONFIG, **overrides}) + + +@pytest.mark.parametrize("n_steps", sorted(GOLDEN_TIMESTEPS)) +def test_golden_timesteps_checkpoint_config(n_steps): + scheduler = make_scheduler() + scheduler.set_timesteps(num_inference_steps=n_steps, num_tokens=NUM_TOKENS) + np.testing.assert_allclose( + scheduler.timesteps.numpy(), + np.asarray(GOLDEN_TIMESTEPS[n_steps], dtype=np.float32), + atol=1e-7, + ) + + +@pytest.mark.parametrize("n_steps", [4, 10, 30]) +def test_timestep_grid_structure(n_steps): + scheduler = make_scheduler() + scheduler.set_timesteps(num_inference_steps=n_steps, num_tokens=NUM_TOKENS) + + assert scheduler.timesteps.shape == (n_steps,) + assert scheduler._timesteps.shape == (n_steps + 1,) + # Ascending 0 -> 1 convention with the terminal 1.0 appended. + assert scheduler.timesteps[0].item() == 0.0 + assert torch.all(scheduler._timesteps[1:] > scheduler._timesteps[:-1]) + assert scheduler._timesteps[-1].item() == 1.0 + assert torch.equal(scheduler._timesteps[:-1], scheduler.timesteps) + + +def test_no_shift_is_uniform_grid(): + n_steps = 8 + scheduler = make_scheduler(do_shift=False) + scheduler.set_timesteps(num_inference_steps=n_steps, num_tokens=NUM_TOKENS) + expected = np.linspace(0, 1, n_steps + 1, dtype=np.float32)[:-1] + np.testing.assert_allclose(scheduler.timesteps.numpy(), expected, atol=1e-7) + + +def test_static_shift_ignores_num_tokens(): + # dynamic_time_shift=False: the shift comes from configured seq_len, so + # per-call num_tokens must not change the schedule. + scheduler_a = make_scheduler() + scheduler_a.set_timesteps(num_inference_steps=10, num_tokens=NUM_TOKENS) + scheduler_b = make_scheduler() + scheduler_b.set_timesteps(num_inference_steps=10, num_tokens=320 * 320) + assert torch.equal(scheduler_a.timesteps, scheduler_b.timesteps) + + +def test_step_arithmetic_and_index_advancement(): + n_steps = 4 + scheduler = make_scheduler() + scheduler.set_timesteps(num_inference_steps=n_steps, num_tokens=NUM_TOKENS) + + sample = torch.zeros(1, 4, 8, 8) + model_output = torch.ones(1, 4, 8, 8) + + assert scheduler.step_index is None + out = scheduler.step(model_output, scheduler.timesteps[0], sample) + assert scheduler.step_index == 1 + + # prev_sample = sample + (t_next - t) * model_output + expected_dt = scheduler._timesteps[1] - scheduler._timesteps[0] + assert torch.allclose(out.prev_sample, expected_dt * model_output) + + # Full loop reaches the end of the grid. + sample = out.prev_sample + for t in scheduler.timesteps[1:]: + sample = scheduler.step(model_output, t, sample, return_dict=False)[0] + assert scheduler.step_index == n_steps + # Integrating a constant velocity of 1 over t in [0, 1] gives exactly 1. + assert torch.allclose(sample, torch.ones_like(sample), atol=1e-6) + + +def test_step_rejects_integer_timestep(): + scheduler = make_scheduler() + scheduler.set_timesteps(num_inference_steps=4, num_tokens=NUM_TOKENS) + with pytest.raises(ValueError, match="integer indices"): + scheduler.step(torch.zeros(1), 0, torch.zeros(1)) + + +def test_set_begin_index(): + scheduler = make_scheduler() + scheduler.set_timesteps(num_inference_steps=4, num_tokens=NUM_TOKENS) + scheduler.set_begin_index(2) + assert scheduler.begin_index == 2 + + scheduler.step(torch.zeros(1), scheduler.timesteps[2], torch.zeros(1)) + assert scheduler.step_index == 3 + + +def test_len_and_config_roundtrip(): + scheduler = make_scheduler() + assert len(scheduler) == CHECKPOINT_CONFIG["num_train_timesteps"] + # ConfigMixin must retain the checkpoint config (needed for from_pretrained). + assert scheduler.config.do_shift is True + assert scheduler.config.dynamic_time_shift is False + assert scheduler.config.time_shift_version == "v1" + assert scheduler.config.seq_len == 4096 + + +def test_matches_upstream_boogu_scheduler(): + """Differential check against the upstream boogu package, when installed.""" + boogu_schedulers = pytest.importorskip("boogu.schedulers.scheduling_flow_match_euler_discrete_time_shifting") + UpstreamScheduler = boogu_schedulers.FlowMatchEulerDiscreteScheduler + + for dynamic in (False, True): + for version in ("v1", "v2"): + for n_steps in (4, 10, 30): + config = {**CHECKPOINT_CONFIG, "dynamic_time_shift": dynamic, "time_shift_version": version} + upstream = UpstreamScheduler(**config) + ported = make_scheduler(**config) + upstream.set_timesteps(num_inference_steps=n_steps, num_tokens=NUM_TOKENS) + ported.set_timesteps(num_inference_steps=n_steps, num_tokens=NUM_TOKENS) + assert torch.equal(upstream.timesteps, ported.timesteps), (dynamic, version, n_steps) + assert torch.equal(upstream._timesteps, ported._timesteps), (dynamic, version, n_steps) diff --git a/tests/diffusion/models/boogu_image/test_boogu_image_transformer.py b/tests/diffusion/models/boogu_image/test_boogu_image_transformer.py new file mode 100644 index 000000000..08ba4af09 --- /dev/null +++ b/tests/diffusion/models/boogu_image/test_boogu_image_transformer.py @@ -0,0 +1,450 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import logging +import os +from types import SimpleNamespace + +import pytest +import torch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + +# Small mock dimensions (head_dim = HIDDEN_SIZE / NUM_HEADS = 16 must equal +# sum(AXES_DIM_ROPE)). +HIDDEN_SIZE = 64 +NUM_HEADS = 4 +NUM_KV_HEADS = 2 +HEAD_DIM = HIDDEN_SIZE // NUM_HEADS +AXES_DIM_ROPE = (8, 4, 4) +AXES_LENS = (32, 16, 16) +MULTIPLE_OF = 32 +NORM_EPS = 1e-5 + + +@pytest.fixture(autouse=True) +def _init_distributed(): + """Initialize the minimal single-rank distributed environment required by + the vLLM parallel linear layers (tensor-parallel group must exist).""" + from vllm.distributed.parallel_state import ( + cleanup_dist_env_and_memory, + init_distributed_environment, + initialize_model_parallel, + ) + + os.environ.setdefault("MASTER_ADDR", "localhost") + os.environ.setdefault("MASTER_PORT", "29512") + init_distributed_environment( + world_size=1, + rank=0, + local_rank=0, + distributed_init_method="env://", + ) + initialize_model_parallel() + yield + cleanup_dist_env_and_memory() + + +@pytest.fixture(autouse=True) +def _force_default_gemm(monkeypatch): + """Force CPU-compatible GEMM dispatch for tests using CPU tensors. + + vLLM's dispatch_unquantized_gemm() selects the backend by platform, not by + tensor device; CPU test tensors can crash on non-default backends.""" + from vllm.model_executor.layers.utils import default_unquantized_gemm + + monkeypatch.setattr( + "vllm.model_executor.layers.linear.dispatch_unquantized_gemm", + lambda: default_unquantized_gemm, + ) + + +def _randomize_parameters(module: torch.nn.Module) -> None: + """Fill parameters with small random values. + + vLLM parallel linears allocate weights with `torch.empty` (real weights + arrive via `load_weights`), so uninitialized memory must be overwritten + before a forward pass.""" + with torch.no_grad(): + for param in module.parameters(): + param.uniform_(-0.02, 0.02) + + +def _identity_rotary_emb(batch_size: int, seq_len: int) -> torch.Tensor: + """Complex rotary frequencies encoding a zero rotation.""" + return torch.polar( + torch.ones(batch_size, seq_len, HEAD_DIM // 2), + torch.zeros(batch_size, seq_len, HEAD_DIM // 2), + ) + + +def _tiny_tf_model_config(**overrides): + config = { + "patch_size": 2, + "in_channels": 4, + "hidden_size": HIDDEN_SIZE, + "num_layers": 4, + "num_double_stream_layers": 2, + "num_refiner_layers": 2, + "num_attention_heads": NUM_HEADS, + "num_kv_heads": NUM_KV_HEADS, + "multiple_of": MULTIPLE_OF, + "norm_eps": NORM_EPS, + "axes_dim_rope": list(AXES_DIM_ROPE), + "axes_lens": list(AXES_LENS), + "instruction_feature_configs": { + "instruction_feat_dim": 32, + "reduce_type": "mean", + "num_instruction_feature_layers": 1, + }, + "prompt_tuning_configs": {"use_prompt_tuning": False}, + "timestep_scale": 1.0, + } + config.update(overrides) + return config + + +def _tiny_od_config(**overrides): + from vllm_omni.diffusion.data import TransformerConfig + + return SimpleNamespace( + tf_model_config=TransformerConfig.from_dict(_tiny_tf_model_config(**overrides)), + dtype=torch.float32, + ) + + +def test_boogu_image_transformer_import(): + from vllm_omni.diffusion.models.boogu_image import BooguImageTransformer2DModel + + assert BooguImageTransformer2DModel is not None + + +def test_single_stream_block_shape(): + from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import ( + BooguImageTransformerBlock, + ) + + block = BooguImageTransformerBlock( + dim=HIDDEN_SIZE, + num_attention_heads=NUM_HEADS, + num_kv_heads=NUM_KV_HEADS, + multiple_of=MULTIPLE_OF, + ffn_dim_multiplier=None, + norm_eps=NORM_EPS, + modulation=True, + ) + _randomize_parameters(block) + + batch_size, seq_len = 1, 16 + hidden_states = torch.randn(batch_size, seq_len, HIDDEN_SIZE) + attention_mask = torch.ones(batch_size, seq_len, dtype=torch.bool) + rotary_emb = _identity_rotary_emb(batch_size, seq_len) + temb = torch.randn(batch_size, min(HIDDEN_SIZE, 1024)) + + out = block(hidden_states, attention_mask, rotary_emb, temb) + assert out.shape == hidden_states.shape + assert torch.isfinite(out).all() + + with pytest.raises(ValueError, match="temb"): + block(hidden_states, attention_mask, rotary_emb, None) + + +def test_single_stream_block_no_modulation(): + from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import ( + BooguImageTransformerBlock, + ) + + block = BooguImageTransformerBlock( + dim=HIDDEN_SIZE, + num_attention_heads=NUM_HEADS, + num_kv_heads=NUM_KV_HEADS, + multiple_of=MULTIPLE_OF, + ffn_dim_multiplier=None, + norm_eps=NORM_EPS, + modulation=False, + ) + _randomize_parameters(block) + + batch_size, seq_len = 2, 8 + hidden_states = torch.randn(batch_size, seq_len, HIDDEN_SIZE) + out = block(hidden_states, None, _identity_rotary_emb(batch_size, seq_len)) + assert out.shape == hidden_states.shape + assert torch.isfinite(out).all() + + +def test_double_stream_block_shape(): + from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import ( + BooguImageDoubleStreamTransformerBlock, + ) + + block = BooguImageDoubleStreamTransformerBlock( + dim=HIDDEN_SIZE, + num_attention_heads=NUM_HEADS, + num_kv_heads=NUM_KV_HEADS, + multiple_of=MULTIPLE_OF, + ffn_dim_multiplier=None, + norm_eps=NORM_EPS, + modulation=True, + ) + _randomize_parameters(block) + + batch_size = 1 + img_len, instruct_len = 16, 8 + total_len = img_len + instruct_len + + img_hidden_states = torch.randn(batch_size, img_len, HIDDEN_SIZE) + instruct_hidden_states = torch.randn(batch_size, instruct_len, HIDDEN_SIZE) + img_attention_mask = torch.ones(batch_size, img_len, dtype=torch.bool) + joint_attention_mask = torch.ones(batch_size, total_len, dtype=torch.bool) + image_rotary_emb = _identity_rotary_emb(batch_size, img_len) + rotary_emb = _identity_rotary_emb(batch_size, total_len) + temb = torch.randn(batch_size, min(HIDDEN_SIZE, 1024)) + + img_out, instruct_out = block( + img_hidden_states, + instruct_hidden_states, + img_attention_mask, + joint_attention_mask, + image_rotary_emb, + rotary_emb, + temb=temb, + encoder_seq_lengths=[instruct_len] * batch_size, + seq_lengths=[total_len] * batch_size, + ) + + assert img_out.shape == img_hidden_states.shape + assert instruct_out.shape == instruct_hidden_states.shape + assert torch.isfinite(img_out).all() + assert torch.isfinite(instruct_out).all() + + +def test_transformer_instantiates(): + from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import ( + BooguImageTransformer2DModel, + ) + + model = BooguImageTransformer2DModel(od_config=_tiny_od_config()) + + assert BooguImageTransformer2DModel._repeated_blocks == [ + "BooguImageTransformerBlock", + "BooguImageContextRefinerTransformerBlock", + "BooguImageSingleStreamTransformerBlock", + ] + assert BooguImageTransformer2DModel._layerwise_offload_blocks_attrs == [ + "single_stream_layers", + "double_stream_layers", + ] + + assert len(model.noise_refiner) == 2 + assert len(model.ref_image_refiner) == 2 + assert len(model.context_refiner) == 2 + assert len(model.double_stream_layers) == 2 + assert len(model.single_stream_layers) == 2 # num_layers - num_double_stream_layers + assert model.image_index_embedding.shape == (5, HIDDEN_SIZE) + # patch_size^2 * in_channels -> hidden_size + assert model.x_embedder.in_features == 2 * 2 * 4 + assert model.x_embedder.out_features == HIDDEN_SIZE + + +def test_transformer_preprocesses_multiple_instruction_feature_layers(): + from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import ( + BooguImageTransformer2DModel, + ) + + instruction_feature_configs = { + "instruction_feat_dim": 32, + "reduce_type": "concat", + "num_instruction_feature_layers": 2, + } + model = BooguImageTransformer2DModel( + od_config=_tiny_od_config(instruction_feature_configs=instruction_feature_configs) + ) + hidden_states = [torch.randn(1, 8, 32), torch.randn(1, 8, 32)] + + processed = model.preprocess_instruction_hidden_states(hidden_states) + + assert model.preprocessed_instruction_feat_dim == 64 + assert processed.shape == (1, 8, 64) + assert torch.equal(processed, torch.cat(hidden_states, dim=-1)) + + +def test_transformer_validates_rope_dims(): + from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import ( + BooguImageTransformer2DModel, + ) + + with pytest.raises(ValueError, match="axes_dim_rope"): + BooguImageTransformer2DModel(od_config=_tiny_od_config(axes_dim_rope=[8, 8, 8])) + + +def test_transformer_rejects_prompt_tuning(): + from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import ( + BooguImageTransformer2DModel, + ) + + with pytest.raises(NotImplementedError, match="[Pp]rompt tuning"): + BooguImageTransformer2DModel(od_config=_tiny_od_config(prompt_tuning_configs={"use_prompt_tuning": True})) + + +def _native_to_checkpoint_name(name: str) -> str: + """Inverse of ``load_weights`` remapping: native param name -> diffusers name. + + - ``.to_out.`` -> ``.to_out.0.`` (diffusers ModuleList wrap). + - promoted joint-attention projections move back under ``.processor.``. + """ + if ".to_out." in name: + name = name.replace(".to_out.", ".to_out.0.") + for proj in ( + "img_to_q", + "img_to_k", + "img_to_v", + "instruct_to_q", + "instruct_to_k", + "instruct_to_v", + "instruct_out", + "img_out", + ): + token = f".img_instruct_attn.{proj}." + if token in name: + name = name.replace(token, f".img_instruct_attn.processor.{proj}.") + break + return name + + +def test_transformer_load_weights_round_trip(): + from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import ( + BooguImageTransformer2DModel, + ) + + model = BooguImageTransformer2DModel(od_config=_tiny_od_config()) + native_params = dict(model.named_parameters()) + + # Build synthetic diffusers-named weights (one per native parameter). + checkpoint_weights = {} + for native_name, param in native_params.items(): + checkpoint_weights[_native_to_checkpoint_name(native_name)] = torch.randn_like(param) + + # The remapping must be a bijection over the parameter set. + assert len(checkpoint_weights) == len(native_params) + + loaded = model.load_weights(list(checkpoint_weights.items())) + + # No missing / unexpected parameters. + assert loaded == set(native_params.keys()) + + # Values landed on the right parameters (TP=1: weight_loader copies verbatim). + reloaded = dict(model.named_parameters()) + for native_name in native_params: + expected = checkpoint_weights[_native_to_checkpoint_name(native_name)] + assert torch.allclose(reloaded[native_name], expected) + + +def test_transformer_load_weights_warns_for_unexpected_and_unloaded(): + from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import ( + BooguImageTransformer2DModel, + ) + + # vllm_omni's logger hierarchy hangs off vLLM's `vllm` logger, which sets + # propagate=False, so records never reach the root logger that pytest's + # caplog listens on. Capture at the emitting logger instead. + messages: list[str] = [] + + class _Capture(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + messages.append(record.getMessage()) + + log = logging.getLogger("vllm_omni.diffusion.models.boogu_image.boogu_image_transformer") + handler = _Capture(level=logging.WARNING) + log.addHandler(handler) + try: + model = BooguImageTransformer2DModel(od_config=_tiny_od_config()) + native_params = dict(model.named_parameters()) + loaded_name = next(iter(native_params)) + checkpoint_name = _native_to_checkpoint_name(loaded_name) + + loaded = model.load_weights( + [ + (checkpoint_name, torch.randn_like(native_params[loaded_name])), + ("unexpected.weight", torch.ones(1)), + ] + ) + finally: + log.removeHandler(handler) + + text = "\n".join(messages) + assert loaded == {loaded_name} + assert "Skipping unexpected checkpoint weight unexpected.weight" in text + assert "Model parameters not loaded from checkpoint" in text + assert next(name for name in native_params if name != loaded_name) in text + + +def test_transformer_forward_t2i_shape(): + from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import ( + BooguImageDoubleStreamRotaryPosEmbed, + BooguImageTransformer2DModel, + ) + + model = BooguImageTransformer2DModel(od_config=_tiny_od_config()) + _randomize_parameters(model) + model.eval() + + batch_size = 1 + in_channels = 4 + latent_h = latent_w = 8 # multiples of patch_size (2) + instruct_len = 8 + instruction_feat_dim = 32 # matches _tiny_tf_model_config + + latents = torch.randn(batch_size, in_channels, latent_h, latent_w) + timestep = torch.full((batch_size,), 0.5) + instruction_hidden_states = torch.randn(batch_size, instruct_len, instruction_feat_dim) + instruction_attention_mask = torch.ones(batch_size, instruct_len, dtype=torch.bool) + freqs_cis = BooguImageDoubleStreamRotaryPosEmbed.get_freqs_cis(model.axes_dim_rope, model.axes_lens, theta=10000) + + with torch.no_grad(): + out = model(latents, timestep, instruction_hidden_states, freqs_cis, instruction_attention_mask) + + assert out.shape == (batch_size, model.out_channels, latent_h, latent_w) + assert torch.isfinite(out).all() + + +def test_transformer_forward_ti2i_shape(): + """Editing path: a non-empty ``ref_image_hidden_states`` exercises the + reference-image patch embedder + refiner and must not change the output + shape (the output tracks the noise-latent dimensions).""" + from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import ( + BooguImageDoubleStreamRotaryPosEmbed, + BooguImageTransformer2DModel, + ) + + model = BooguImageTransformer2DModel(od_config=_tiny_od_config()) + _randomize_parameters(model) + model.eval() + + batch_size = 1 + in_channels = 4 + latent_h = latent_w = 8 + ref_h, ref_w = 6, 10 # a differently-sized reference latent + instruct_len = 8 + instruction_feat_dim = 32 + + latents = torch.randn(batch_size, in_channels, latent_h, latent_w) + timestep = torch.full((batch_size,), 0.5) + instruction_hidden_states = torch.randn(batch_size, instruct_len, instruction_feat_dim) + instruction_attention_mask = torch.ones(batch_size, instruct_len, dtype=torch.bool) + freqs_cis = BooguImageDoubleStreamRotaryPosEmbed.get_freqs_cis(model.axes_dim_rope, model.axes_lens, theta=10000) + + # One sample, one reference image (Boogu editing supports a single ref). + ref_image_hidden_states = [[torch.randn(in_channels, ref_h, ref_w)]] + + with torch.no_grad(): + out = model( + latents, + timestep, + instruction_hidden_states, + freqs_cis, + instruction_attention_mask, + ref_image_hidden_states=ref_image_hidden_states, + ) + + assert out.shape == (batch_size, model.out_channels, latent_h, latent_w) + assert torch.isfinite(out).all() diff --git a/tests/diffusion/models/boogu_image/test_pipeline_boogu_image.py b/tests/diffusion/models/boogu_image/test_pipeline_boogu_image.py new file mode 100644 index 000000000..d38be97fa --- /dev/null +++ b/tests/diffusion/models/boogu_image/test_pipeline_boogu_image.py @@ -0,0 +1,900 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""L1 unit tests for the native Boogu-Image pipeline. + +Two groups: + +1. Constructor tests: all ``from_pretrained`` calls are mocked (Ovis pattern) + so the pipeline ``__init__`` wiring is exercised without downloading + weights or building the real transformer. +2. Prompt-encoding tests: a pipeline shell (``object.__new__``) is wired with + small deterministic fakes so the ported chat templating, processor kwargs, + CFG handling, and reshape logic can be verified numerically on CPU. +""" + +from types import SimpleNamespace + +import pytest +import torch +from torch import nn + +from vllm_omni.diffusion.data import DiffusionParallelConfig, OmniDiffusionConfig, TransformerConfig + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + +_MODULE = "vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image" + +_EMBED_DIM = 8 +_SEQ_LEN = 6 + + +# --------------------------------------------------------------------------- +# Constructor tests (mocked from_pretrained) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_dependencies(mocker, monkeypatch): + """Mock external components so ``__init__`` runs without real weights.""" + # The full VLM wrapper has an ``lm_head``; the pipeline must strip it and + # keep the inner ``.model`` as the encoder. + inner_encoder = mocker.MagicMock(name="inner_qwen3vl_model") + inner_encoder.dtype = torch.float32 + mllm_wrapper = mocker.MagicMock(name="qwen3vl_wrapper") + mllm_wrapper.model.to.return_value = inner_encoder + + mock_processor = mocker.MagicMock(name="processor") + + mock_vae = mocker.MagicMock(name="vae") + mock_vae.config.block_out_channels = [128, 256, 512, 512] # scale factor 8 + mock_vae.to.return_value = mock_vae + + mock_scheduler = mocker.MagicMock(name="scheduler") + + monkeypatch.setattr( + f"{_MODULE}.FlowMatchEulerDiscreteScheduler.from_pretrained", + lambda *a, **k: mock_scheduler, + ) + monkeypatch.setattr( + f"{_MODULE}.Qwen3VLForConditionalGeneration.from_pretrained", + lambda *a, **k: mllm_wrapper, + ) + monkeypatch.setattr( + f"{_MODULE}.Qwen3VLProcessor.from_pretrained", + lambda *a, **k: mock_processor, + ) + monkeypatch.setattr( + f"{_MODULE}.AutoencoderKL.from_pretrained", + lambda *a, **k: mock_vae, + ) + + mock_transformer_cls = mocker.MagicMock(name="transformer_cls") + mock_transformer_instance = mocker.MagicMock(name="transformer") + mock_transformer_cls.return_value = mock_transformer_instance + monkeypatch.setattr(f"{_MODULE}.BooguImageTransformer2DModel", mock_transformer_cls) + + # Treat the dummy model id as a local path: skips hub prefetch. + mocker.patch("os.path.exists", return_value=True) + + return { + "inner_encoder": inner_encoder, + "mllm_wrapper": mllm_wrapper, + "processor": mock_processor, + "vae": mock_vae, + "scheduler": mock_scheduler, + "transformer": mock_transformer_instance, + } + + +@pytest.fixture +def boogu_pipeline(mock_dependencies): + from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import ( + BooguImagePipeline, + ) + + od_config = OmniDiffusionConfig( + model="dummy-boogu", + tf_model_config=TransformerConfig(params={}), + dtype=torch.float32, + num_gpus=1, + ) + return BooguImagePipeline(od_config=od_config) + + +def test_boogu_image_pipeline_import(): + from vllm_omni.diffusion.models.boogu_image import BooguImagePipeline + + assert BooguImagePipeline is not None + + +def test_component_discovery_declarations(): + from vllm_omni.diffusion.models.boogu_image import BooguImagePipeline + + # CPU offload / HSDP discovery must find ``mllm`` (there is no + # ``text_encoder`` attribute on this pipeline). + assert BooguImagePipeline._dit_modules == ["transformer"] + assert BooguImagePipeline._encoder_modules == ["mllm"] + assert BooguImagePipeline._vae_modules == ["vae"] + + +def test_constructor_wires_components(boogu_pipeline, mock_dependencies): + assert boogu_pipeline.scheduler is mock_dependencies["scheduler"] + assert boogu_pipeline.processor is mock_dependencies["processor"] + assert boogu_pipeline.vae is mock_dependencies["vae"] + assert boogu_pipeline.transformer is mock_dependencies["transformer"] + assert boogu_pipeline.vae_scale_factor == 8 + assert boogu_pipeline.default_sample_size == 128 + assert hasattr(boogu_pipeline, "load_weights") + + +def test_constructor_strips_mllm_lm_head(boogu_pipeline, mock_dependencies): + # Upstream encodes with the inner Qwen3VLModel, not the generation wrapper. + assert boogu_pipeline.mllm is mock_dependencies["inner_encoder"] + + +def test_constructor_weights_sources(boogu_pipeline): + (source,) = boogu_pipeline.weights_sources + assert source.model_or_path == "dummy-boogu" + assert source.subfolder == "transformer" + assert source.prefix == "transformer." + assert source.fall_back_to_pt is True + + +@pytest.mark.parametrize( + ("parallel_config", "cache_backend", "message"), + [ + (DiffusionParallelConfig(tensor_parallel_size=2), "none", "Tensor parallelism"), + (DiffusionParallelConfig(ulysses_degree=2), "none", "Sequence parallelism"), + (DiffusionParallelConfig(ring_degree=2), "none", "Sequence parallelism"), + (DiffusionParallelConfig(cfg_parallel_size=2), "none", "CFG parallelism"), + ( + DiffusionParallelConfig(use_hsdp=True, hsdp_shard_size=2), + "none", + "HSDP", + ), + (DiffusionParallelConfig(), "cache_dit", "Cache backend 'cache_dit'"), + (DiffusionParallelConfig(), "tea_cache", "Cache backend 'tea_cache'"), + ], +) +def test_constructor_rejects_unsupported_execution_modes( + mock_dependencies, + parallel_config, + cache_backend, + message, +): + from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import ( + BooguImagePipeline, + ) + + od_config = OmniDiffusionConfig( + model="dummy-boogu", + tf_model_config=TransformerConfig(params={}), + dtype=torch.float32, + parallel_config=parallel_config, + cache_backend=cache_backend, + ) + + with pytest.raises(NotImplementedError, match=message): + BooguImagePipeline(od_config=od_config) + + # Validation happens before any checkpoint component is constructed. + mock_dependencies["mllm_wrapper"].model.to.assert_not_called() + + +# --------------------------------------------------------------------------- +# Prompt-encoding tests (deterministic fakes, no constructor) +# --------------------------------------------------------------------------- + + +class _RecordingProcessor: + """Fake Qwen3VLProcessor: deterministic token ids derived from the text.""" + + def __init__(self): + self.calls = [] + + def apply_chat_template(self, prompts, **kwargs): + self.calls.append({"prompts": prompts, "kwargs": kwargs}) + batch = len(prompts) + input_ids = torch.zeros(batch, _SEQ_LEN, dtype=torch.long) + for i, messages in enumerate(prompts): + system_text = messages[0]["content"][0]["text"] + user_text = messages[1]["content"][0]["text"] + input_ids[i, 0] = len(system_text) % 997 + input_ids[i, 1] = len(user_text) % 997 + input_ids[i, 2:] = torch.arange(2, _SEQ_LEN) + i + attention_mask = torch.ones(batch, _SEQ_LEN, dtype=torch.long) + attention_mask[:, -1] = 0 # fake right-padding + return {"input_ids": input_ids, "attention_mask": attention_mask} + + +class _FakeMLLM: + """Fake Qwen3VLModel: hidden states are a deterministic function of ids.""" + + dtype = torch.bfloat16 + + def __init__(self): + self.calls = [] + + def __call__(self, input_ids=None, attention_mask=None, output_hidden_states=False, **kwargs): + self.calls.append({"output_hidden_states": output_hidden_states, **kwargs}) + base = input_ids.to(torch.float32).unsqueeze(-1).repeat(1, 1, _EMBED_DIM) + hidden = base + torch.arange(_EMBED_DIM, dtype=torch.float32) + return SimpleNamespace(last_hidden_state=hidden, hidden_states=(hidden - 1, hidden)) + + +def _make_encode_pipeline(num_instruction_feature_layers: int = 1): + from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import ( + SYSTEM_PROMPT_4_T2I_UNIFIED, + SYSTEM_PROMPT_4_TI2I_UNIFIED, + BooguImagePipeline, + ) + + pipeline = object.__new__(BooguImagePipeline) + nn.Module.__init__(pipeline) + pipeline._execution_device = torch.device("cpu") + pipeline.processor = _RecordingProcessor() + pipeline.mllm = _FakeMLLM() + pipeline.transformer = SimpleNamespace( + instruction_feature_configs={ + "instruction_feat_dim": _EMBED_DIM, + "num_instruction_feature_layers": num_instruction_feature_layers, + "reduce_type": "mean", + }, + dtype=torch.float32, + ) + pipeline.SYSTEM_PROMPT_4_T2I = SYSTEM_PROMPT_4_T2I_UNIFIED + pipeline.SYSTEM_PROMPT_DROP = SYSTEM_PROMPT_4_TI2I_UNIFIED + return pipeline + + +def test_apply_chat_template_system_prompt_selection(): + pipeline = _make_encode_pipeline() + + messages = pipeline._apply_chat_template("a cat on a mat") + assert messages[0]["role"] == "system" + assert messages[0]["content"][0]["text"] == pipeline.SYSTEM_PROMPT_4_T2I + assert messages[1]["role"] == "user" + assert messages[1]["content"][0]["text"] == "a cat on a mat" + + # Empty and whitespace-only instructions select the DROP prompt (this is + # the path the default negative prompt "" takes). + for empty in ("", " ", None): + messages = pipeline._apply_chat_template(empty) + assert messages[0]["content"][0]["text"] == pipeline.SYSTEM_PROMPT_DROP + + +def test_processor_called_with_upstream_kwargs(): + pipeline = _make_encode_pipeline() + pipeline.encode_prompt("a dog", do_classifier_free_guidance=False) + + (call,) = pipeline.processor.calls + kwargs = call["kwargs"] + assert kwargs["padding"] == "longest" + assert kwargs["padding_side"] == "right" + assert kwargs["truncation"] is False + assert kwargs["max_length"] == 1280 # upstream __call__ default + assert kwargs["tokenize"] is True + assert kwargs["return_dict"] is True + assert kwargs["return_tensors"] == "pt" + + +def test_encode_prompt_shapes_and_dtype(): + pipeline = _make_encode_pipeline() + embeds, mask, neg_embeds, neg_mask = pipeline.encode_prompt(["a dog", "a cat"]) + + assert embeds.shape == (2, _SEQ_LEN, _EMBED_DIM) + assert mask.shape == (2, _SEQ_LEN) + assert neg_embeds.shape == (2, _SEQ_LEN, _EMBED_DIM) + assert neg_mask.shape == (2, _SEQ_LEN) + # Cast to the MLLM dtype, mask passed through from the processor. + assert embeds.dtype == torch.bfloat16 + assert neg_embeds.dtype == torch.bfloat16 + assert torch.equal(mask[:, -1], torch.zeros(2, dtype=torch.long)) + + +def test_single_layer_encoding_requests_hidden_states_once(): + pipeline = _make_encode_pipeline() + + pipeline._get_instruction_feature_embeds("a dog") + + assert pipeline.mllm.calls == [{"output_hidden_states": True, "return_dict": True}] + + +def test_single_layer_encoding_propagates_mllm_failure(): + pipeline = _make_encode_pipeline() + failure = RuntimeError("mllm failed") + + class _FailingMLLM: + dtype = torch.bfloat16 + + def __init__(self): + self.calls = 0 + + def __call__(self, **kwargs): + self.calls += 1 + raise failure + + pipeline.mllm = _FailingMLLM() + + with pytest.raises(RuntimeError, match="mllm failed") as exc_info: + pipeline._get_instruction_feature_embeds("a dog") + + assert exc_info.value is failure + assert pipeline.mllm.calls == 1 + + +def test_encode_prompt_cfg_negative_default_is_empty_string(): + pipeline = _make_encode_pipeline() + pipeline.encode_prompt("a dog") + + assert len(pipeline.processor.calls) == 2 + negative_messages = pipeline.processor.calls[1]["prompts"] + assert len(negative_messages) == 1 + # The default "" negative prompt goes through the DROP system prompt. + assert negative_messages[0][0]["content"][0]["text"] == pipeline.SYSTEM_PROMPT_DROP + assert negative_messages[0][1]["content"][0]["text"] == "" + + +def test_encode_prompt_without_cfg_skips_negative(): + pipeline = _make_encode_pipeline() + embeds, mask, neg_embeds, neg_mask = pipeline.encode_prompt("a dog", do_classifier_free_guidance=False) + + assert len(pipeline.processor.calls) == 1 + assert embeds.shape == (1, _SEQ_LEN, _EMBED_DIM) + assert neg_embeds is None + assert neg_mask is None + + +def test_encode_prompt_explicit_negative_prompt(): + pipeline = _make_encode_pipeline() + pipeline.encode_prompt("a dog", negative_prompt="blurry, low quality") + + negative_messages = pipeline.processor.calls[1]["prompts"] + assert negative_messages[0][0]["content"][0]["text"] == pipeline.SYSTEM_PROMPT_4_T2I + assert negative_messages[0][1]["content"][0]["text"] == "blurry, low quality" + + +def test_encode_prompt_negative_batch_mismatch_raises(): + pipeline = _make_encode_pipeline() + with pytest.raises(ValueError, match="batch size"): + pipeline.encode_prompt(["a dog", "a cat"], negative_prompt=["only one"]) + + +def test_encode_prompt_num_images_per_prompt_repeats_embeds(): + pipeline = _make_encode_pipeline() + embeds, mask, neg_embeds, neg_mask = pipeline.encode_prompt("a dog", num_images_per_prompt=3) + + assert embeds.shape == (3, _SEQ_LEN, _EMBED_DIM) + assert mask.shape == (3, _SEQ_LEN) + assert torch.equal(embeds[0], embeds[1]) + assert torch.equal(embeds[0], embeds[2]) + assert torch.equal(mask[0], mask[1]) + assert neg_embeds.shape == (3, _SEQ_LEN, _EMBED_DIM) + + +def test_encode_prompt_precomputed_embeds_bypass_encoder(): + pipeline = _make_encode_pipeline() + precomputed = torch.randn(1, _SEQ_LEN, _EMBED_DIM) + precomputed_mask = torch.ones(1, _SEQ_LEN, dtype=torch.long) + neg_precomputed = torch.randn(1, _SEQ_LEN, _EMBED_DIM) + + embeds, mask, neg_embeds, _ = pipeline.encode_prompt( + "ignored", + prompt_embeds=precomputed, + prompt_attention_mask=precomputed_mask, + negative_prompt_embeds=neg_precomputed, + ) + + assert len(pipeline.processor.calls) == 0 + assert torch.equal(embeds, precomputed) + assert torch.equal(neg_embeds, neg_precomputed) + + +def test_reshape_embeds_and_mask_list_branch(): + # Multi-layer configs return a list of per-layer tensors; the reshape + # helper must handle both forms. + pipeline = _make_encode_pipeline() + layer = torch.arange(2 * _SEQ_LEN * _EMBED_DIM, dtype=torch.float32).view(2, _SEQ_LEN, _EMBED_DIM) + mask = torch.ones(2, _SEQ_LEN, dtype=torch.long) + + batch_size, seq_len, reshaped, reshaped_mask = pipeline._reshape_embeds_and_mask([layer, layer + 1], mask, 2) + + assert batch_size == 2 + assert seq_len == _SEQ_LEN + assert isinstance(reshaped, list) and len(reshaped) == 2 + assert reshaped[0].shape == (4, _SEQ_LEN, _EMBED_DIM) + # repeat(1, n, 1).view(b*n, ...) interleaves per-sample: rows [s0, s0, s1, s1] + assert torch.equal(reshaped[0][0], reshaped[0][1]) + assert torch.equal(reshaped[0][2], reshaped[0][3]) + + +class _FakeTransformer: + """Fake denoiser: velocity 0 (identity flow) with the config the loop reads.""" + + in_channels = 4 + axes_dim_rope = (8, 4, 4) + axes_lens = (32, 16, 16) + dtype = torch.float32 + instruction_feature_configs = { + "instruction_feat_dim": _EMBED_DIM, + "num_instruction_feature_layers": 1, + "reduce_type": "mean", + } + + def __call__(self, latents, timestep, instruction_embeds, freqs_cis, instruction_attention_mask, **kwargs): + return torch.zeros_like(latents) + + +class _FakeScheduler: + def set_timesteps(self, num_inference_steps, device=None, num_tokens=None): + self.timesteps = torch.linspace(0, 1, num_inference_steps + 1)[:-1] + + def step(self, model_output, t, latents, return_dict=False): + return (latents,) + + +class _FakeDecodeVAE: + dtype = torch.float32 + + def __init__(self): + self.config = SimpleNamespace(scaling_factor=1.0, shift_factor=0.0, block_out_channels=[128, 256, 512, 512]) + + def decode(self, latents, return_dict=False): + batch = latents.shape[0] + return (torch.zeros(batch, 3, 16, 16),) + + +def _make_forward_pipeline(): + pipeline = _make_encode_pipeline() + pipeline.transformer = _FakeTransformer() + pipeline.scheduler = _FakeScheduler() + pipeline.vae = _FakeDecodeVAE() + pipeline.vae_scale_factor = 8 + pipeline.default_sample_size = 128 + return pipeline + + +def _make_request_batch(prompt, **sampling_overrides): + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + sampling = OmniDiffusionSamplingParams(**sampling_overrides) + return SimpleNamespace(prompts=[prompt], sampling_params=sampling) + + +def test_forward_returns_diffusion_output(): + from vllm_omni.diffusion.data import DiffusionOutput + + pipeline = _make_forward_pipeline() + req = _make_request_batch("a cat", height=64, width=64, num_inference_steps=2) + + out = pipeline.forward(req) + + assert isinstance(out, DiffusionOutput) + assert isinstance(out.output, torch.Tensor) + assert out.output.shape[0] == 1 + assert torch.isfinite(out.output).all() + # CFG default is on (text guidance 4.0), so the encoder ran twice (pos + neg). + assert len(pipeline.processor.calls) == 2 + + +def test_forward_cfg_off_when_guidance_one(): + pipeline = _make_forward_pipeline() + req = _make_request_batch("a cat", height=64, width=64, num_inference_steps=2, guidance_scale=1.0) + # guidance_scale=1.0 is falsy-adjacent but explicitly disables CFG; the + # request layer would set guidance_scale_provided, so emulate that here. + req.sampling_params.guidance_scale_provided = True + + pipeline.forward(req) + + # Only the positive prompt is encoded when CFG is off. + assert len(pipeline.processor.calls) == 1 + + +# --------------------------------------------------------------------------- +# Editing / TI2I: pre-process function +# --------------------------------------------------------------------------- + + +def _make_edit_od_config(tmp_path, block_out_channels=(128, 256, 512, 512)): + import json + + vae_dir = tmp_path / "vae" + vae_dir.mkdir(parents=True, exist_ok=True) + (vae_dir / "config.json").write_text(json.dumps({"block_out_channels": list(block_out_channels)})) + return OmniDiffusionConfig( + model=str(tmp_path), + tf_model_config=TransformerConfig(params={}), + dtype=torch.float32, + num_gpus=1, + ) + + +@pytest.mark.parametrize( + "factory_name", + ["get_boogu_image_pre_process_func", "get_boogu_image_post_process_func"], +) +@pytest.mark.parametrize("config_contents", [None, "{not valid json"]) +def test_process_factory_reports_invalid_vae_config(tmp_path, factory_name, config_contents): + import importlib + + vae_dir = tmp_path / "vae" + vae_dir.mkdir() + if config_contents is not None: + (vae_dir / "config.json").write_text(config_contents) + + od_config = OmniDiffusionConfig( + model=str(tmp_path), + tf_model_config=TransformerConfig(params={}), + dtype=torch.float32, + num_gpus=1, + ) + factory = getattr(importlib.import_module(_MODULE), factory_name) + + with pytest.raises(RuntimeError, match=r"Failed to load Boogu VAE config from .*vae/config\.json"): + factory(od_config) + + +@pytest.mark.parametrize(("height", "width"), [(0, 16), (16, 0), (0, 0)]) +def test_image_processor_rejects_non_positive_dimensions(height, width): + from vllm_omni.diffusion.models.boogu_image.image_processor import BooguImageProcessor + + image_processor = BooguImageProcessor() + image = torch.zeros(1, 3, 16, 16) + + with pytest.raises(ValueError, match=rf"height={height}, width={width}"): + image_processor.get_new_height_width(image, height=height, width=width) + + +def _make_diffusion_request(prompt, **sampling_overrides): + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + sampling = OmniDiffusionSamplingParams(**sampling_overrides) + return OmniDiffusionRequest(prompt=prompt, sampling_params=sampling, request_id="req-0") + + +def test_pre_process_no_image_is_noop(tmp_path): + import PIL.Image # noqa: F401 (import guard: PIL must be available) + + from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import ( + get_boogu_image_pre_process_func, + ) + + pre = get_boogu_image_pre_process_func(_make_edit_od_config(tmp_path)) + + # Text-to-image request: no multimodal image -> returned unchanged. + req = _make_diffusion_request({"prompt": "a cat"}, height=123, width=456) + out = pre(req) + assert "additional_information" not in out.prompt + assert out.sampling_params.height == 123 + assert out.sampling_params.width == 456 + + # A plain-string prompt cannot carry an image either. + str_req = _make_diffusion_request("a cat") + assert pre(str_req).prompt == "a cat" + + +def test_pre_process_populates_reference_and_align_res(tmp_path): + import PIL.Image + + from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import ( + get_boogu_image_pre_process_func, + ) + + pre = get_boogu_image_pre_process_func(_make_edit_od_config(tmp_path)) + + image = PIL.Image.new("RGB", (1000, 500)) # (width, height) + req = _make_diffusion_request({"prompt": "make it winter", "multi_modal_data": {"image": image}}) + out = pre(req) + + ai = out.prompt["additional_information"] + assert "prompt_image" in ai and "preprocessed_image" in ai + + # VLM copy is a PIL image, downscaled, never upscaled, aligned to 16. + prompt_image = ai["prompt_image"] + assert isinstance(prompt_image, PIL.Image.Image) + assert prompt_image.width % 16 == 0 and prompt_image.height % 16 == 0 + assert prompt_image.width <= 1000 and prompt_image.height <= 500 + assert max(prompt_image.width, prompt_image.height) <= 768 + + # VAE copy is a normalized [1, C, H, W] tensor aligned to 16. + vae = ai["preprocessed_image"] + assert isinstance(vae, torch.Tensor) and vae.ndim == 4 and vae.shape[0] == 1 + assert vae.shape[-1] % 16 == 0 and vae.shape[-2] % 16 == 0 + assert -1.0 <= float(vae.min()) and float(vae.max()) <= 1.0 + + # align_res: the request resolution follows the VAE-encoded reference dims. + assert out.sampling_params.height == vae.shape[-2] + assert out.sampling_params.width == vae.shape[-1] + + +def test_pre_process_rejects_multiple_images(tmp_path): + import PIL.Image + + from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import ( + get_boogu_image_pre_process_func, + ) + + pre = get_boogu_image_pre_process_func(_make_edit_od_config(tmp_path)) + imgs = [PIL.Image.new("RGB", (64, 64)), PIL.Image.new("RGB", (64, 64))] + req = _make_diffusion_request({"prompt": "combine", "multi_modal_data": {"image": imgs}}) + + with pytest.raises(ValueError, match="single reference image"): + pre(req) + + +def test_pre_process_single_image_in_list_is_accepted(tmp_path): + import PIL.Image + + from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import ( + get_boogu_image_pre_process_func, + ) + + pre = get_boogu_image_pre_process_func(_make_edit_od_config(tmp_path)) + req = _make_diffusion_request({"prompt": "edit", "multi_modal_data": {"image": [PIL.Image.new("RGB", (128, 128))]}}) + out = pre(req) + assert "preprocessed_image" in out.prompt["additional_information"] + + +# --------------------------------------------------------------------------- +# Editing / TI2I: chat template + image-aware encoding +# --------------------------------------------------------------------------- + + +def _make_edit_encode_pipeline(): + from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import ( + SYSTEM_PROMPT_4_TI2I_UNIFIED, + ) + + pipeline = _make_encode_pipeline() + pipeline.SYSTEM_PROMPT_4_TI2I = SYSTEM_PROMPT_4_TI2I_UNIFIED + pipeline.SYSTEM_PROMPT_4_I2I = SYSTEM_PROMPT_4_TI2I_UNIFIED + return pipeline + + +def test_apply_chat_template_ti2i_places_image_before_text(): + import PIL.Image + + pipeline = _make_edit_encode_pipeline() + image = PIL.Image.new("RGB", (16, 16)) + + messages = pipeline._apply_chat_template("turn day into night", [image]) + assert messages[0]["content"][0]["text"] == pipeline.SYSTEM_PROMPT_4_TI2I + user_content = messages[1]["content"] + # Image content comes first, then the instruction text. + assert user_content[0]["type"] == "image" + assert user_content[0]["image"] is image + assert user_content[-1] == {"type": "text", "text": "turn day into night"} + + # Empty instruction with an image selects the I2I system prompt. + empty_messages = pipeline._apply_chat_template("", [image]) + assert empty_messages[0]["content"][0]["text"] == pipeline.SYSTEM_PROMPT_4_I2I + + +class _ImageAwareRecordingProcessor: + """Records whether reference images reached the processor.""" + + def __init__(self): + self.calls = [] + + def apply_chat_template(self, prompts, **kwargs): + has_image = [] + for messages in prompts: + user_content = messages[1]["content"] + has_image.append(any(c.get("type") == "image" for c in user_content)) + self.calls.append({"prompts": prompts, "kwargs": kwargs, "has_image": has_image}) + batch = len(prompts) + input_ids = torch.arange(batch * _SEQ_LEN, dtype=torch.long).view(batch, _SEQ_LEN) + attention_mask = torch.ones(batch, _SEQ_LEN, dtype=torch.long) + return {"input_ids": input_ids, "attention_mask": attention_mask} + + +def test_encode_prompt_attaches_images_to_positive_only(): + import PIL.Image + + pipeline = _make_edit_encode_pipeline() + pipeline.processor = _ImageAwareRecordingProcessor() + image = PIL.Image.new("RGB", (16, 16)) + + pipeline.encode_prompt( + "add a rainbow", + do_classifier_free_guidance=True, + input_images=[[image]], + ) + + # Positive call carries the image; the negative (CFG) call is image-free. + assert pipeline.processor.calls[0]["has_image"] == [True] + assert pipeline.processor.calls[1]["has_image"] == [False] + + +# --------------------------------------------------------------------------- +# Editing / TI2I: reference-latent VAE encode +# --------------------------------------------------------------------------- + + +class _FakeRefVAE: + """Fake VAE whose ``encode`` yields a known latent for shape/scaling checks.""" + + dtype = torch.float32 + + def __init__(self, scaling_factor=2.0, shift_factor=0.5): + self.config = SimpleNamespace(scaling_factor=scaling_factor, shift_factor=shift_factor) + self._latent = torch.ones(1, 4, 3, 5) + + def encode(self, img): + dist = SimpleNamespace(sample=lambda generator=None: self._latent.clone()) + return SimpleNamespace(latent_dist=dist) + + +def test_build_ref_latents_shape_and_scaling(): + pipeline = _make_encode_pipeline() + pipeline.vae = _FakeRefVAE(scaling_factor=2.0, shift_factor=0.5) + + preprocessed = torch.zeros(1, 3, 48, 80) # normalized image tensor + ref_latents = pipeline._build_ref_latents([preprocessed], num_images_per_prompt=1, device=torch.device("cpu")) + + assert len(ref_latents) == 1 + (sample_latents,) = ref_latents + assert isinstance(sample_latents, list) and len(sample_latents) == 1 + latent = sample_latents[0] + # squeeze(0) -> [C, H, W]; (1 - shift) * scaling = (1 - 0.5) * 2 = 1.0 + assert latent.shape == (4, 3, 5) + assert torch.allclose(latent, torch.ones(4, 3, 5)) + + +def test_build_ref_latents_expands_per_output_and_handles_none(): + pipeline = _make_encode_pipeline() + pipeline.vae = _FakeRefVAE() + + preprocessed = torch.zeros(1, 3, 48, 80) + ref_latents = pipeline._build_ref_latents([preprocessed, None], num_images_per_prompt=2, device=torch.device("cpu")) + + # Two samples x 2 outputs each = 4 entries; the None sample stays None. + assert len(ref_latents) == 4 + assert ref_latents[0] is ref_latents[1] # same sample repeated + assert ref_latents[2] is None and ref_latents[3] is None + + +# --------------------------------------------------------------------------- +# Editing / TI2I: forward CFG branch selection +# --------------------------------------------------------------------------- + + +class _RecordingRefTransformer(_FakeTransformer): + """Counts predictions per step and records the reference-latent argument.""" + + def __init__(self): + self.calls = [] + + def __call__(self, latents, timestep, instruction_embeds, freqs_cis, instruction_attention_mask, **kwargs): + self.calls.append(kwargs.get("ref_image_hidden_states")) + return torch.zeros_like(latents) + + +class _EditForwardVAE(_FakeDecodeVAE): + """Adds a fake ``encode`` so the editing forward path can build ref latents.""" + + def encode(self, img): + dist = SimpleNamespace(sample=lambda generator=None: torch.zeros(1, 4, 8, 8)) + return SimpleNamespace(latent_dist=dist) + + +def _make_edit_forward_pipeline(): + pipeline = _make_edit_encode_pipeline() + # Image-aware processor: reference images appear as ``{"type": "image"}`` + # content entries, which the default text-first fake cannot parse. + pipeline.processor = _ImageAwareRecordingProcessor() + pipeline.transformer = _RecordingRefTransformer() + pipeline.scheduler = _FakeScheduler() + pipeline.vae = _EditForwardVAE() + pipeline.vae_scale_factor = 8 + pipeline.default_sample_size = 128 + return pipeline + + +def _make_edit_request(**sampling_overrides): + import PIL.Image + + image = PIL.Image.new("RGB", (64, 64)) + prompt = { + "prompt": "make it winter", + "additional_information": { + "prompt_image": image, + "preprocessed_image": torch.zeros(1, 3, 64, 64), + }, + } + sampling = _sampling(**sampling_overrides) + return SimpleNamespace(prompts=[prompt], sampling_params=sampling) + + +def _sampling(**overrides): + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + return OmniDiffusionSamplingParams(**overrides) + + +def _count_per_step(calls, num_steps): + assert len(calls) % num_steps == 0 + return len(calls) // num_steps + + +def test_forward_ti2i_text_only_two_predictions_with_ref(): + pipeline = _make_edit_forward_pipeline() + num_steps = 2 + req = _make_edit_request(height=64, width=64, num_inference_steps=num_steps, guidance_scale=5.0) + req.sampling_params.guidance_scale_provided = True + + pipeline.forward(req) + + # Text-only ti2i: cond+ref and neg+ref -> 2 predictions/step, both carry ref. + assert _count_per_step(pipeline.transformer.calls, num_steps) == 2 + assert all(ref is not None for ref in pipeline.transformer.calls) + + +def test_forward_ti2i_double_guidance_three_predictions(): + pipeline = _make_edit_forward_pipeline() + num_steps = 2 + req = _make_edit_request( + height=64, width=64, num_inference_steps=num_steps, guidance_scale=5.0, guidance_scale_2=2.0 + ) + req.sampling_params.guidance_scale_provided = True + req.sampling_params.guidance_scale_2_provided = True + + pipeline.forward(req) + + # Double guidance: cond+ref, neg+ref, neg+no-ref -> 3 predictions/step. + assert _count_per_step(pipeline.transformer.calls, num_steps) == 3 + # Exactly one of the three per step drops the reference (neg+no-ref). + per_step = [pipeline.transformer.calls[i : i + 3] for i in range(0, len(pipeline.transformer.calls), 3)] + for step_calls in per_step: + assert sum(ref is None for ref in step_calls) == 1 + + +def test_forward_ti2i_image_only_two_predictions_drop_ref(): + pipeline = _make_edit_forward_pipeline() + num_steps = 2 + # text guidance 1.0 (off, provided) + image guidance 2.0 (provided). + req = _make_edit_request( + height=64, width=64, num_inference_steps=num_steps, guidance_scale=1.0, guidance_scale_2=2.0 + ) + req.sampling_params.guidance_scale_provided = True + req.sampling_params.guidance_scale_2_provided = True + + pipeline.forward(req) + + # Image-only ti2i: cond+ref and cond+no-ref -> 2 predictions/step. + assert _count_per_step(pipeline.transformer.calls, num_steps) == 2 + per_step = [pipeline.transformer.calls[i : i + 2] for i in range(0, len(pipeline.transformer.calls), 2)] + for step_calls in per_step: + assert sum(ref is None for ref in step_calls) == 1 + + +def test_forward_ti2i_no_guidance_single_prediction_with_ref(): + pipeline = _make_edit_forward_pipeline() + num_steps = 2 + # Both guidances off/unprovided -> image guidance forced to 1.0, no CFG. + req = _make_edit_request(height=64, width=64, num_inference_steps=num_steps, guidance_scale=1.0) + req.sampling_params.guidance_scale_provided = True + + pipeline.forward(req) + + assert _count_per_step(pipeline.transformer.calls, num_steps) == 1 + assert all(ref is not None for ref in pipeline.transformer.calls) + + +def test_forward_image_guidance_ignored_without_reference(): + # guidance_scale_2 is set but there is no reference image (t2i request); + # image guidance must be forced off so this stays plain t2i CFG. + pipeline = _make_edit_forward_pipeline() + num_steps = 2 + sampling = _sampling(height=64, width=64, num_inference_steps=num_steps, guidance_scale=5.0, guidance_scale_2=2.0) + sampling.guidance_scale_provided = True + sampling.guidance_scale_2_provided = True + req = SimpleNamespace(prompts=[{"prompt": "a cat"}], sampling_params=sampling) + + pipeline.forward(req) + + # t2i text CFG: cond + uncond -> 2 predictions/step, no ref anywhere. + assert _count_per_step(pipeline.transformer.calls, num_steps) == 2 + assert all(ref is None for ref in pipeline.transformer.calls) diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py index ba482e793..3093e6ceb 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py @@ -36,20 +36,36 @@ class StubScheduler: timesteps: list[int] | None = None, *, flow_shift: float = 1.0, - use_karras_sigmas: bool = True, ) -> None: self.timesteps = torch.tensor(timesteps or [9, 3], dtype=torch.int64) self.config = SimpleNamespace( num_train_timesteps=1000, flow_shift=flow_shift, - use_karras_sigmas=use_karras_sigmas, ) - self.set_timesteps_calls: list[tuple[int, torch.device]] = [] + self.set_timesteps_calls: list[dict[str, Any]] = [] self.step_calls: list[tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = [] - def set_timesteps(self, num_steps: int, device: torch.device) -> None: - self.set_timesteps_calls.append((num_steps, device)) - self.timesteps = torch.arange(num_steps, 0, -1, dtype=torch.int64, device=device) + def set_timesteps( + self, + num_inference_steps: int | None = None, + device: str | torch.device | None = None, + *, + shift: float | None = None, + sigmas: list[float] | None = None, + ) -> None: + self.set_timesteps_calls.append( + { + "num_inference_steps": num_inference_steps, + "device": device, + "shift": shift, + "sigmas": sigmas, + } + ) + if sigmas is not None: + self.timesteps = torch.tensor(sigmas, device=device) + else: + assert num_inference_steps is not None + self.timesteps = torch.arange(num_inference_steps, 0, -1, dtype=torch.int64, device=device) def step(self, noise_pred: torch.Tensor, timestep: torch.Tensor, latents: torch.Tensor, **kwargs): del kwargs @@ -200,6 +216,7 @@ def fake_cosmos3_guardrails(monkeypatch: pytest.MonkeyPatch): def make_cosmos3_pipeline(): def _make(): from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import ( + COSMOS3_VIDEO_DEFAULT_FLOW_SHIFT, Cosmos3OmniDiffusersPipeline, ) @@ -213,11 +230,10 @@ def make_cosmos3_pipeline(): pipeline.vae_scale_factor_temporal = 4 pipeline.vae_scale_factor_spatial = 8 pipeline.scheduler = StubScheduler([9, 3], flow_shift=1.0) - pipeline._base_scheduler_config = pipeline.scheduler.config - pipeline._engine_init_flow_shift = 1.0 - pipeline._current_flow_shift = 1.0 - pipeline._base_scheduler_use_karras_sigmas = True - pipeline._current_scheduler_use_karras_sigmas = True + pipeline._engine_init_flow_shift = COSMOS3_VIDEO_DEFAULT_FLOW_SHIFT + pipeline._current_flow_shift = COSMOS3_VIDEO_DEFAULT_FLOW_SHIFT + pipeline.is_distilled_model = False + pipeline.is_edge_model = False pipeline._guidance_scale = None pipeline._num_timesteps = None pipeline._cosmos3_branch_caches = None @@ -243,6 +259,7 @@ def make_sampling_params(**overrides: Any) -> SimpleNamespace: "num_frames": None, "num_inference_steps": None, "guidance_scale": None, + "guidance_scale_provided": False, "generator": None, "seed": 123, "num_outputs_per_prompt": 1, @@ -288,8 +305,167 @@ def _mask() -> torch.Tensor: return torch.ones(1, 1, dtype=torch.long) +def _capture_tokenize_calls(pipeline: Any) -> list[dict[str, Any]]: + calls: list[dict[str, Any]] = [] + + def _tokenize( + text: str, + max_sequence_length: int, + use_system_prompt: bool = False, + system_prompt: str | None = None, + ) -> tuple[torch.Tensor, torch.Tensor]: + index = len(calls) + 1 + calls.append( + { + "text": text, + "max_sequence_length": max_sequence_length, + "use_system_prompt": use_system_prompt, + "system_prompt": system_prompt, + } + ) + return _ids(index), torch.full((1, 1), index, dtype=torch.long) + + pipeline._tokenize_prompt = _tokenize + return calls + + +@pytest.mark.parametrize( + ("provided", "value", "default", "is_distilled", "expected"), + [ + (False, 1.0, 7.0, False, 7.0), + (True, 1.0, 7.0, False, 1.0), + (True, 4.5, 7.0, False, 4.5), + (False, 1.0, 7.0, True, 1.0), + (True, 4.5, 7.0, True, 1.0), + ], +) +def test_resolve_guidance_scale( + make_cosmos3_pipeline, + provided: bool, + value: float, + default: float, + is_distilled: bool, + expected: float, +) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.is_distilled_model = is_distilled + sp = make_sampling_params( + guidance_scale=value, + guidance_scale_provided=provided, + ) + + assert pipeline._resolve_guidance_scale(sp, default) == expected + + +def test_distilled_generation_accepts_t2i_and_i2v(make_cosmos3_pipeline) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.is_distilled_model = True + common = { + "action_enabled": False, + "transfer_config": None, + "is_v2v": False, + "sound_enabled": False, + } + + pipeline._validate_distilled_generation_mode( + is_t2i=True, + image_tensor=None, + **common, + ) + pipeline._validate_distilled_generation_mode( + is_t2i=False, + image_tensor=torch.zeros(1), + **common, + ) + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"action_enabled": True}, "action requests are unsupported"), + ({"transfer_config": SimpleNamespace()}, "transfer requests are unsupported"), + ({"is_v2v": True}, "video-to-video requests are unsupported"), + ({"sound_enabled": True}, "sound generation is unsupported"), + ( + {"is_t2i": False, "image_tensor": None}, + "text-to-video requests are unsupported", + ), + ( + {"is_t2i": True, "image_tensor": torch.zeros(1)}, + "image-conditioned image generation is unsupported", + ), + ], +) +def test_distilled_generation_rejects_unsupported_modes( + make_cosmos3_pipeline, + overrides: dict[str, Any], + message: str, +) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.is_distilled_model = True + kwargs = { + "is_t2i": True, + "image_tensor": None, + "action_enabled": False, + "transfer_config": None, + "is_v2v": False, + "sound_enabled": False, + } + kwargs.update(overrides) + + with pytest.raises(ValueError, match=message): + pipeline._validate_distilled_generation_mode(**kwargs) + + +def test_distilled_generation_rejects_robolab_policy(make_cosmos3_pipeline) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.is_distilled_model = True + + with pytest.raises(ValueError, match="RoboLab/action policy requests are unsupported"): + pipeline._forward_robolab_policy(make_sampling_params(), None, 0.0) + + +@pytest.mark.parametrize( + ("prompt", "sampling_params", "message"), + [ + ( + {"prompt": "x", "modalities": ["video"], "generate_sound": True}, + make_sampling_params(), + "do not support sound generation", + ), + ( + {"prompt": "x", "modalities": ["video"]}, + make_sampling_params(extra_args={"edge": {"control_path": "/tmp/control.mp4"}}), + "do not support transfer inference", + ), + ( + { + "prompt": "x", + "modalities": ["video"], + "additional_information": { + "preprocessed_video": torch.zeros(1, 3, 5, 16, 16), + }, + }, + make_sampling_params(height=16, width=16, num_frames=5), + "do not support video-to-video generation", + ), + ], +) +def test_edge_forward_rejects_unsupported_modes( + make_cosmos3_pipeline, + prompt: dict[str, Any], + sampling_params: SimpleNamespace, + message: str, +) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.is_edge_model = True + + with pytest.raises(ValueError, match=message): + pipeline.forward(SimpleNamespace(prompts=[prompt], sampling_params=sampling_params)) + + def test_pipeline_registered_and_exported() -> None: - from vllm_omni.diffusion.cache.cache_dit_backend import CUSTOM_DIT_ENABLERS + from vllm_omni.diffusion.cache.cachedit import CUSTOM_DIT_ENABLERS from vllm_omni.diffusion.models import cosmos3 from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin @@ -303,17 +479,45 @@ def test_pipeline_registered_and_exported() -> None: assert issubclass(Cosmos3OmniDiffusersPipeline, nn.Module) assert issubclass(Cosmos3OmniDiffusersPipeline, ProgressBarMixin) assert Cosmos3OmniDiffusersPipeline.support_image_input is True - assert _DIFFUSION_MODELS["Cosmos3OmniDiffusersPipeline"] == ( - "cosmos3", - "pipeline_cosmos3", - "Cosmos3OmniDiffusersPipeline", - ) - assert _DIFFUSION_PRE_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_pre_process_func" - assert _DIFFUSION_POST_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_post_process_func" - assert _DIFFUSION_IR_OP_PRIORITY_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_ir_op_priority_func" - assert "Cosmos3OmniDiffusersPipeline" in CUSTOM_DIT_ENABLERS assert "Cosmos3OmniDiffusersPipeline" in cosmos3.__all__ + for pipeline_name in ("Cosmos3OmniDiffusersPipeline", "Cosmos3OmniPipeline"): + assert _DIFFUSION_MODELS[pipeline_name] == ( + "cosmos3", + "pipeline_cosmos3", + "Cosmos3OmniDiffusersPipeline", + ) + assert _DIFFUSION_PRE_PROCESS_FUNCS[pipeline_name] == "get_cosmos3_pre_process_func" + assert _DIFFUSION_POST_PROCESS_FUNCS[pipeline_name] == "get_cosmos3_post_process_func" + assert _DIFFUSION_IR_OP_PRIORITY_FUNCS[pipeline_name] == "get_cosmos3_ir_op_priority_func" + assert pipeline_name in CUSTOM_DIT_ENABLERS + + +@pytest.mark.parametrize( + "pipeline_name", + ["Cosmos3OmniDiffusersPipeline", "Cosmos3OmniPipeline"], +) +def test_cosmos3_model_index_resolves_pipeline( + tmp_path, + pipeline_name: str, +) -> None: + import json + + from vllm_omni.diffusion.data import OmniDiffusionConfig + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import ( + Cosmos3OmniDiffusersPipeline, + ) + from vllm_omni.diffusion.registry import DiffusionModelRegistry + + (tmp_path / "model_index.json").write_text(json.dumps({"_class_name": pipeline_name})) + + config = OmniDiffusionConfig(model=str(tmp_path)) + config.enrich_config() + + assert config.model_class_name == pipeline_name + resolved_pipeline_cls = DiffusionModelRegistry._try_load_model_cls(config.model_class_name) + assert resolved_pipeline_cls is Cosmos3OmniDiffusersPipeline + @pytest.fixture def stub_real_pipeline_init(monkeypatch: pytest.MonkeyPatch): @@ -335,25 +539,24 @@ def stub_real_pipeline_init(monkeypatch: pytest.MonkeyPatch): return self class _StubDiffusersScheduler: + load_config_calls: list[dict[str, Any]] = [] from_config_calls: list[dict[str, Any]] = [] - def __init__(self, *, flow_shift: float = 1.0, use_karras_sigmas: bool = True) -> None: - self.config = SimpleNamespace(flow_shift=flow_shift, use_karras_sigmas=use_karras_sigmas) + def __init__(self, *, flow_shift: float = 1.0) -> None: + self.config = SimpleNamespace(flow_shift=flow_shift) @classmethod - def from_pretrained(cls, *args, **kwargs): - return cls() + def load_config(cls, *args, **kwargs): + cls.load_config_calls.append({"args": args, "kwargs": dict(kwargs)}) + return { + "_class_name": "UniPCMultistepScheduler", + "flow_shift": 1.0, + } @classmethod def from_config(cls, config, **kwargs): cls.from_config_calls.append({"config": config, "kwargs": dict(kwargs)}) - return cls( - flow_shift=kwargs.get("flow_shift", getattr(config, "flow_shift", 1.0)), - use_karras_sigmas=kwargs.get( - "use_karras_sigmas", - getattr(config, "use_karras_sigmas", True), - ), - ) + return cls(flow_shift=kwargs.get("shift", config.get("flow_shift", 1.0))) class _StubVideoProcessor: def __init__(self, *args, **kwargs) -> None: @@ -361,13 +564,18 @@ def stub_real_pipeline_init(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(pipeline_cosmos3, "AutoTokenizer", _StubAutoTokenizer) monkeypatch.setattr(pipeline_cosmos3, "DistributedAutoencoderKLWan", _StubDiffusersVAE) - monkeypatch.setattr(pipeline_cosmos3, "UniPCMultistepScheduler", _StubDiffusersScheduler) + monkeypatch.setattr(pipeline_cosmos3, "FlowUniPCMultistepScheduler", _StubDiffusersScheduler) monkeypatch.setattr(pipeline_cosmos3, "VideoProcessor", _StubVideoProcessor) monkeypatch.setattr(pipeline_cosmos3, "get_local_device", lambda: torch.device("cpu")) return _StubDiffusersScheduler -def _make_od_config(*, sound_gen: bool) -> SimpleNamespace: +def _make_od_config( + *, + sound_gen: bool, + tf_model_config_overrides: dict[str, Any] | None = None, + model_config: dict[str, Any] | None = None, +) -> SimpleNamespace: tf_model_config = { "hidden_size": 8, "num_hidden_layers": 0, @@ -382,6 +590,8 @@ def _make_od_config(*, sound_gen: bool) -> SimpleNamespace: } if sound_gen: tf_model_config["sound_gen"] = True + if tf_model_config_overrides: + tf_model_config.update(tf_model_config_overrides) return SimpleNamespace( enable_cpu_offload=False, enable_diffusion_pipeline_profiler=False, @@ -390,7 +600,7 @@ def _make_od_config(*, sound_gen: bool) -> SimpleNamespace: flow_shift=None, quantization_config=None, custom_pipeline_args={}, - model_config={}, + model_config=model_config or {}, tf_model_config=tf_model_config, ) @@ -400,16 +610,13 @@ def test_pipeline_init_skips_tokenizer_when_sound_disabled(stub_real_pipeline_in pipeline = Cosmos3OmniDiffusersPipeline(od_config=_make_od_config(sound_gen=False)) - assert stub_real_pipeline_init.from_config_calls == [] assert pipeline._sound_tokenizer is None assert pipeline.transformer.sound_gen is False assert not hasattr(pipeline.transformer, "audio_proj_in") assert not hasattr(pipeline.transformer, "audio_proj_out") - assert pipeline._base_scheduler_use_karras_sigmas is True - assert pipeline._current_scheduler_use_karras_sigmas is True -def test_pipeline_init_rebuilds_scheduler_with_cosmos3_defaults(stub_real_pipeline_init) -> None: +def test_pipeline_init_uses_flow_unipc_with_cosmos3_defaults(stub_real_pipeline_init) -> None: from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline od_config = _make_od_config(sound_gen=False) @@ -417,55 +624,301 @@ def test_pipeline_init_rebuilds_scheduler_with_cosmos3_defaults(stub_real_pipeli pipeline = Cosmos3OmniDiffusersPipeline(od_config=od_config) - assert stub_real_pipeline_init.from_config_calls - call = stub_real_pipeline_init.from_config_calls[-1] - assert getattr(call["config"], "flow_shift") == 1.0 - assert call["kwargs"] == {"flow_shift": 2.5} + assert len(stub_real_pipeline_init.load_config_calls) == 1 + assert len(stub_real_pipeline_init.from_config_calls) == 1 + call = stub_real_pipeline_init.from_config_calls[0] + assert call["config"]["_class_name"] == "UniPCMultistepScheduler" + assert call["kwargs"] == { + "shift": 1.0, + "use_dynamic_shifting": False, + "prediction_type": "flow_prediction", + } + assert pipeline.is_distilled_model is False assert pipeline._engine_init_flow_shift == 2.5 - assert pipeline._base_scheduler_use_karras_sigmas is True -def test_set_flow_shift_restores_checkpoint_scheduler_mode( - make_cosmos3_pipeline, +@pytest.mark.parametrize( + ("scheduler_class_name", "expected_distilled"), + [ + ("FlowMatchEulerDiscreteScheduler", True), + ("UniPCMultistepScheduler", False), + ], +) +def test_pipeline_resolves_scheduler_class_from_checkpoint_file( + tmp_path, + stub_real_pipeline_init, monkeypatch: pytest.MonkeyPatch, + scheduler_class_name: str, + expected_distilled: bool, ) -> None: + import json + from vllm_omni.diffusion.models.cosmos3 import pipeline_cosmos3 + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline + from vllm_omni.diffusion.models.schedulers.scheduling_flow_unipc_multistep import ( + FlowUniPCMultistepScheduler as RealFlowUniPCMultistepScheduler, + ) - class RecordingScheduler: - from_config_calls: list[dict[str, Any]] = [] + t_list = [1.0, 0.75, 0.5, 0.25] + scheduler_dir = tmp_path / "scheduler" + scheduler_dir.mkdir() + scheduler_config = {"_class_name": scheduler_class_name} + if expected_distilled: + scheduler_config["fixed_step_sampler_config"] = {"sample_type": "sde", "t_list": t_list} + (scheduler_dir / "scheduler_config.json").write_text(json.dumps(scheduler_config)) - def __init__(self, *, flow_shift: float, use_karras_sigmas: bool) -> None: - self.config = SimpleNamespace(flow_shift=flow_shift, use_karras_sigmas=use_karras_sigmas) + class StubFlowUniPCScheduler(StubScheduler): + from_config_calls: list[tuple[Any, dict[str, Any]]] = [] + + @classmethod + def load_config(cls, *args, **kwargs): + return RealFlowUniPCMultistepScheduler.load_config(*args, **kwargs) @classmethod def from_config(cls, config, **kwargs): - cls.from_config_calls.append({"config": config, "kwargs": dict(kwargs)}) - return cls( - flow_shift=kwargs.get("flow_shift", getattr(config, "flow_shift", 1.0)), - use_karras_sigmas=kwargs.get( - "use_karras_sigmas", - getattr(config, "use_karras_sigmas", True), - ), - ) + cls.from_config_calls.append((config, dict(kwargs))) + return cls() - monkeypatch.setattr(pipeline_cosmos3, "UniPCMultistepScheduler", RecordingScheduler) - pipeline = make_cosmos3_pipeline() + class StubFlowMatchScheduler(StubScheduler): + from_config_calls: list[tuple[Any, dict[str, Any]]] = [] - pipeline._set_flow_shift(10.0, use_karras_sigmas=False) - assert RecordingScheduler.from_config_calls[-1]["kwargs"] == { - "flow_shift": 10.0, - "use_karras_sigmas": False, + @classmethod + def from_config(cls, config, **kwargs): + cls.from_config_calls.append((config, dict(kwargs))) + scheduler = cls() + scheduler.config.fixed_step_sampler_config = config["fixed_step_sampler_config"] + return scheduler + + monkeypatch.setattr(pipeline_cosmos3, "FlowUniPCMultistepScheduler", StubFlowUniPCScheduler) + monkeypatch.setattr( + pipeline_cosmos3, + "FlowMatchEulerDiscreteScheduler", + StubFlowMatchScheduler, + ) + + od_config = _make_od_config(sound_gen=False) + od_config.model = str(tmp_path) + pipeline = Cosmos3OmniDiffusersPipeline(od_config=od_config) + + assert pipeline.is_distilled_model is expected_distilled + if expected_distilled: + assert len(StubFlowMatchScheduler.from_config_calls) == 1 + assert StubFlowMatchScheduler.from_config_calls[0][1] == {"stochastic_sampling": True} + assert StubFlowUniPCScheduler.from_config_calls == [] + assert pipeline._scheduler_init_t_list == t_list + else: + assert len(StubFlowUniPCScheduler.from_config_calls) == 1 + assert StubFlowMatchScheduler.from_config_calls == [] + assert not hasattr(pipeline, "_scheduler_init_t_list") + + +def test_distilled_pipeline_initializes_sde_scheduler( + stub_real_pipeline_init, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import ( + Cosmos3OmniDiffusersPipeline, + FlowMatchEulerDiscreteScheduler, + ) + + t_list = [1.0, 0.9375, 0.8333333333333334, 0.625] + scheduler_config = { + "_class_name": "FlowMatchEulerDiscreteScheduler", + "shift": 1.0, + "stochastic_sampling": False, + "fixed_step_sampler_config": { + "sample_type": "sde", + "t_list": t_list, + }, } - assert pipeline._current_flow_shift == 10.0 - assert pipeline._current_scheduler_use_karras_sigmas is False + monkeypatch.setattr( + stub_real_pipeline_init, + "load_config", + classmethod(lambda cls, *args, **kwargs: scheduler_config), + ) - pipeline._set_flow_shift(10.0) - assert RecordingScheduler.from_config_calls[-1]["kwargs"] == {"flow_shift": 10.0} - assert pipeline._current_flow_shift == 10.0 - assert pipeline._current_scheduler_use_karras_sigmas is True + pipeline = Cosmos3OmniDiffusersPipeline(od_config=_make_od_config(sound_gen=False)) + assert pipeline.is_distilled_model is True + assert isinstance(pipeline.scheduler, FlowMatchEulerDiscreteScheduler) + assert pipeline.scheduler.config.stochastic_sampling is True + assert pipeline._scheduler_init_t_list == t_list - pipeline._set_flow_shift(10.0) - assert len(RecordingScheduler.from_config_calls) == 2 + +@pytest.mark.parametrize( + ("fixed_step_config", "error_pattern"), + [ + pytest.param( + {"t_list": [1.0, 0.5]}, + r"fixed_step_sampler_config\.sample_type=sde", + id="missing-sample-type", + ), + pytest.param( + {"sample_type": "ode", "t_list": [1.0, 0.5]}, + r"fixed_step_sampler_config\.sample_type=sde", + id="unsupported-sample-type", + ), + pytest.param( + {"sample_type": "sde"}, + r"non-empty fixed_step_sampler_config\.t_list", + id="missing-t-list", + ), + pytest.param( + {"sample_type": "sde", "t_list": []}, + r"non-empty fixed_step_sampler_config\.t_list", + id="empty-t-list", + ), + ], +) +def test_distilled_scheduler_validates_fixed_step_config( + stub_real_pipeline_init, + monkeypatch: pytest.MonkeyPatch, + fixed_step_config: dict[str, Any], + error_pattern: str, +) -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import ( + Cosmos3OmniDiffusersPipeline, + ) + + scheduler_config = { + "_class_name": "FlowMatchEulerDiscreteScheduler", + "fixed_step_sampler_config": fixed_step_config, + } + monkeypatch.setattr( + stub_real_pipeline_init, + "load_config", + classmethod(lambda cls, *args, **kwargs: scheduler_config), + ) + + with pytest.raises(ValueError, match=error_pattern): + Cosmos3OmniDiffusersPipeline(od_config=_make_od_config(sound_gen=False)) + + +def test_pipeline_init_selects_edge_transformer_from_backbone_type(stub_real_pipeline_init) -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import ( + COSMOS3_EDGE_VIDEO_DEFAULT_FLOW_SHIFT, + Cosmos3OmniDiffusersPipeline, + ) + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + COSMOS3_EDGE_BACKBONE_TYPE, + Cosmos3EdgeVFMTransformer, + ) + + pipeline = Cosmos3OmniDiffusersPipeline( + od_config=_make_od_config( + sound_gen=False, + tf_model_config_overrides={ + "backbone_type": COSMOS3_EDGE_BACKBONE_TYPE, + "qk_norm_for_text": False, + "latent_channel": 48, + "latent_patch_size": 2, + "temporal_compression_factor": 4, + "layer_norm_epsilon": 1e-5, + }, + ) + ) + + assert isinstance(pipeline.transformer, Cosmos3EdgeVFMTransformer) + assert pipeline.is_edge_model is True + assert pipeline._engine_init_flow_shift == COSMOS3_EDGE_VIDEO_DEFAULT_FLOW_SHIFT + + +def test_pipeline_init_does_not_select_edge_from_model_index_class_name(stub_real_pipeline_init) -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3VFMTransformer + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import Cosmos3EdgeVFMTransformer + + pipeline = Cosmos3OmniDiffusersPipeline( + od_config=_make_od_config( + sound_gen=False, + model_config={"_class_name": "Cosmos3EdgeOmniDiffusersPipeline"}, + ) + ) + + assert isinstance(pipeline.transformer, Cosmos3VFMTransformer) + assert not isinstance(pipeline.transformer, Cosmos3EdgeVFMTransformer) + + +def test_flow_unipc_reuses_scheduler_and_forwards_each_request_shift(make_cosmos3_pipeline) -> None: + pipeline = make_cosmos3_pipeline() + scheduler = pipeline.scheduler + + assert pipeline._engine_init_flow_shift == 10.0 + assert pipeline._current_flow_shift == 10.0 + assert scheduler.config.flow_shift == 1.0 + + for shift in (3.0, 10.0): + pipeline._set_flow_shift(shift) + pipeline._set_timesteps(4, torch.device("cpu"), shift=pipeline._current_flow_shift) + + assert pipeline.scheduler is scheduler + assert pipeline._current_flow_shift == 10.0 + assert [call["shift"] for call in scheduler.set_timesteps_calls] == [3.0, 10.0] + assert all(call["num_inference_steps"] == 4 for call in scheduler.set_timesteps_calls) + assert all(call["sigmas"] is None for call in scheduler.set_timesteps_calls) + + +def test_flow_unipc_reproducible_with_same_seed(make_cosmos3_pipeline) -> None: + from vllm_omni.diffusion.models.schedulers.scheduling_flow_unipc_multistep import ( + FlowUniPCMultistepScheduler, + ) + + pipeline = make_cosmos3_pipeline() + pipeline.scheduler = FlowUniPCMultistepScheduler( + shift=1.0, + use_dynamic_shifting=False, + prediction_type="flow_prediction", + ) + pipeline._format_and_tokenize_prompts = lambda *args, **kwargs: ( + _ids(1), + _mask(), + _ids(0), + _mask(), + ) + + def run(seed: int) -> torch.Tensor: + request = make_request_batch( + {"prompt": "A test video.", "modalities": ["video"]}, + make_sampling_params( + seed=seed, + guidance_scale=1.0, + guidance_scale_provided=True, + num_inference_steps=4, + num_frames=5, + height=16, + width=16, + extra_args={"flow_shift": 3.0}, + ), + ) + output = pipeline.forward(request) + return output.output["video"] + + first = run(123) + second = run(123) + different_seed = run(456) + + torch.testing.assert_close(first, second) + assert not torch.equal(first, different_seed) + + +def test_distilled_set_timesteps_uses_fixed_sigma_schedule(make_cosmos3_pipeline) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.is_distilled_model = True + pipeline._scheduler_init_t_list = [1.0, 0.75, 0.5, 0.25] + + pipeline._set_timesteps( + num_inference_steps=99, + device=torch.device("cpu"), + shift=7.0, + ) + + assert pipeline.scheduler.set_timesteps_calls == [ + { + "num_inference_steps": None, + "device": torch.device("cpu"), + "shift": None, + "sigmas": pipeline._scheduler_init_t_list, + } + ] def test_pipeline_init_passes_tokenizer_attrs_into_transformer( @@ -806,12 +1259,35 @@ def test_ir_op_priority_hook_preserves_platform_fields(monkeypatch: pytest.Monke assert merged.custom_op == ["platform_kernel", "native"] -def test_prompt_formatting_and_checkpoint_key_remap(make_cosmos3_pipeline) -> None: - from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline +def test_format_and_tokenize_prompts_leaves_plain_prompts_unchanged_by_default(make_cosmos3_pipeline) -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import COSMOS3_SYSTEM_PROMPT pipeline = make_cosmos3_pipeline() - captured: list[str] = [] - pipeline._tokenize_prompt = lambda text, *args, **kwargs: (captured.append(text) or _ids(len(captured)), _mask()) + calls = _capture_tokenize_calls(pipeline) + + result = pipeline._format_and_tokenize_prompts( + " A robot. ", + " bad. ", + num_frames=48, + frame_rate=24, + height=720, + width=1280, + max_sequence_length=32, + sp=SimpleNamespace(extra_args={}), + use_system_prompt=False, + is_t2i=False, + ) + + assert [call["text"] for call in calls] == ["A robot.", "bad."] + assert all(call["max_sequence_length"] == 32 for call in calls) + assert all(call["use_system_prompt"] is False for call in calls) + assert all(call["system_prompt"] == COSMOS3_SYSTEM_PROMPT for call in calls) + assert [tensor.item() for tensor in result] == [1, 1, 2, 2] + + +def test_format_and_tokenize_prompts_applies_video_templates_and_system_override(make_cosmos3_pipeline) -> None: + pipeline = make_cosmos3_pipeline() + calls = _capture_tokenize_calls(pipeline) pipeline._format_and_tokenize_prompts( "A robot", @@ -821,21 +1297,167 @@ def test_prompt_formatting_and_checkpoint_key_remap(make_cosmos3_pipeline) -> No height=720, width=1280, max_sequence_length=32, + sp=SimpleNamespace( + system_prompt="direct system prompt", + extra_args={ + "use_duration_template": True, + "use_resolution_template": True, + "system_prompt": "API system prompt", + }, + ), + use_system_prompt=True, + is_t2i=False, + ) + + assert calls == [ + { + "text": ("A robot. The video is 2.0 seconds long and is of 24 FPS. This video is of 720x1280 resolution."), + "max_sequence_length": 32, + "use_system_prompt": True, + "system_prompt": "API system prompt", + }, + { + "text": ( + "bad. The video is not 2.0 seconds long and is not of 24 FPS. This video is not of 720x1280 resolution." + ), + "max_sequence_length": 32, + "use_system_prompt": True, + "system_prompt": "API system prompt", + }, + ] + + +def test_format_and_tokenize_prompts_uses_image_templates_for_t2i(make_cosmos3_pipeline) -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import COSMOS3_T2I_SYSTEM_PROMPT + + pipeline = make_cosmos3_pipeline() + calls = _capture_tokenize_calls(pipeline) + + pipeline._format_and_tokenize_prompts( + "A robot", + "bad", + num_frames=1, + frame_rate=24, + height=1024, + width=768, + max_sequence_length=64, sp=SimpleNamespace( extra_args={ - "negative_metadata_mode": "inverse", - # Duration/resolution metadata templates are off by default - # (commit "Change resolution and duration templates to off by - # default"); enable them explicitly to exercise the formatting. "use_duration_template": True, "use_resolution_template": True, } ), use_system_prompt=True, + is_t2i=True, + ) + + assert [call["text"] for call in calls] == [ + "A robot. This image is of 1024x768 resolution.", + "bad. This image is not of 1024x768 resolution.", + ] + assert all("seconds" not in call["text"] and "FPS" not in call["text"] for call in calls) + assert all(call["system_prompt"] == COSMOS3_T2I_SYSTEM_PROMPT for call in calls) + + +def test_format_and_tokenize_prompts_rewrites_json_object_metadata(make_cosmos3_pipeline) -> None: + import json + + pipeline = make_cosmos3_pipeline() + calls = _capture_tokenize_calls(pipeline) + + pipeline._format_and_tokenize_prompts( + ('{"caption": "A robot", "duration": "old", "fps": 1, "resolution": {"H": 1, "W": 2}, "aspect_ratio": "1:1"}'), + "bad", + num_frames=48, + frame_rate=24, + height=720, + width=1280, + max_sequence_length=32, + sp=SimpleNamespace( + extra_args={ + "aspect_ratio": "16:9", + "use_duration_template": True, + "use_resolution_template": True, + } + ), is_t2i=False, ) - assert "The video is 2.0 seconds long" in captured[0] - assert "The video is not 2.0 seconds long" in captured[1] + + assert json.loads(calls[0]["text"]) == { + "caption": "A robot", + "duration": "2s", + "fps": 24.0, + "resolution": {"H": 720, "W": 1280}, + "aspect_ratio": "16:9", + } + assert "The video is 2.0 seconds long" not in calls[0]["text"] + assert calls[1]["text"] == ( + "bad. The video is not 2.0 seconds long and is not of 24 FPS. This video is not of 720x1280 resolution." + ) + + +def test_format_and_tokenize_prompts_removes_video_metadata_from_t2i_json(make_cosmos3_pipeline) -> None: + import json + + pipeline = make_cosmos3_pipeline() + calls = _capture_tokenize_calls(pipeline) + + pipeline._format_and_tokenize_prompts( + '{"caption": "A robot", "duration": "2s", "fps": 24}', + "", + num_frames=1, + frame_rate=24, + height=1024, + width=768, + max_sequence_length=32, + sp=SimpleNamespace(extra_args={}), + is_t2i=True, + ) + + assert json.loads(calls[0]["text"]) == { + "caption": "A robot", + "resolution": {"H": 1024, "W": 768}, + } + + +@pytest.mark.parametrize( + "prompt", + [ + "{malformed", + '["A robot"]', + ], +) +def test_format_and_tokenize_prompts_falls_back_for_non_object_json( + make_cosmos3_pipeline, + prompt: str, +) -> None: + pipeline = make_cosmos3_pipeline() + calls = _capture_tokenize_calls(pipeline) + + pipeline._format_and_tokenize_prompts( + prompt, + "", + num_frames=48, + frame_rate=24, + height=720, + width=1280, + max_sequence_length=32, + sp=SimpleNamespace( + extra_args={ + "use_duration_template": True, + "use_resolution_template": True, + } + ), + is_t2i=False, + ) + + assert calls[0]["text"] == ( + f"{prompt}. The video is 2.0 seconds long and is of 24 FPS. This video is of 720x1280 resolution." + ) + + +def test_checkpoint_key_remap() -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline remaps = { "embed_tokens.weight": "transformer.language_model.embed_tokens.weight", @@ -847,9 +1469,14 @@ def test_prompt_formatting_and_checkpoint_key_remap(make_cosmos3_pipeline) -> No "layers.3.self_attn.to_q.weight": "transformer.language_model.layers.3.self_attn.to_q.weight", "layers.3.self_attn.to_out.weight": "transformer.language_model.layers.3.self_attn.to_out.weight", "layers.3.self_attn.norm_q.weight": "transformer.language_model.layers.3.self_attn.norm_q.weight", + "layers.3.self_attn.k_norm_und_for_gen.weight": ( + "transformer.language_model.layers.3.self_attn.k_norm_und_for_gen.weight" + ), "layers.3.self_attn.add_q_proj.weight": "transformer.gen_layers.3.cross_attention.to_q.weight", "layers.3.self_attn.to_add_out.weight": "transformer.gen_layers.3.cross_attention.to_out.weight", "layers.3.self_attn.norm_added_q.weight": "transformer.gen_layers.3.cross_attention.norm_q.weight", + "layers.3.mlp_moe_gen.up_proj.weight": "transformer.gen_layers.3.mlp.up_proj.weight", + "layers.3.mlp_moe_gen.down_proj.weight": "transformer.gen_layers.3.mlp.down_proj.weight", "transformer.model.layers.3.self_attn.add_k_proj.weight": ( "transformer.gen_layers.3.cross_attention.to_k.weight" ), @@ -1193,9 +1820,9 @@ def test_forward_transfer_uses_source_fps_except_wsm(make_cosmos3_pipeline, hint pipeline._prepare_transfer_latents = fake_prepare pipeline.diffuse_transfer = fake_diffuse_transfer - def fake_set_flow_shift(target, *, use_karras_sigmas=None): + def fake_set_flow_shift(target): captured.setdefault("flow_shifts", []).append(target) - captured.setdefault("scheduler_use_karras_sigmas", []).append(use_karras_sigmas) + pipeline._current_flow_shift = float(target) pipeline._set_flow_shift = fake_set_flow_shift pipeline._decode_latents = lambda latents: torch.zeros(1, 3, 5, 16, 16, device="meta") @@ -1230,8 +1857,8 @@ def test_forward_transfer_uses_source_fps_except_wsm(make_cosmos3_pipeline, hint assert captured["format_frame_rate"] == expected_fps assert captured["shared_kwargs"]["fps"] == expected_fps assert captured["flow_shifts"] == [10.0] - # Transfer uses the V2V flow-sigma schedule (karras off) so flow_shift applies. - assert captured["scheduler_use_karras_sigmas"] == [False] + # Transfer applies the V2V flow shift when building its timestep schedule. + assert [call["shift"] for call in pipeline.scheduler.set_timesteps_calls] == [10.0] assert output.output["metadata"]["video"]["fps"] == expected_fps assert output.output["payload"]["video"].device.type == "meta" @@ -1391,9 +2018,9 @@ class TestForwardRouting: pipeline._format_and_tokenize_prompts = fake_format pipeline._prepare_latents = fake_prepare - def fake_set_flow_shift(target, *, use_karras_sigmas=None): + def fake_set_flow_shift(target): captured.setdefault("flow_shifts", []).append(target) - captured.setdefault("scheduler_use_karras_sigmas", []).append(use_karras_sigmas) + pipeline._current_flow_shift = float(target) pipeline._set_flow_shift = fake_set_flow_shift pipeline.diffuse = fake_diffuse @@ -1420,7 +2047,7 @@ class TestForwardRouting: { "key": "video", "is_t2i": False, - "flow": [1.0], + "flow": [10.0], "steps": [35], "frames": 189, }, @@ -1443,8 +2070,8 @@ class TestForwardRouting: assert captured["format"]["is_t2i"] is expected["is_t2i"] assert captured["format"]["num_frames"] == expected["frames"] assert captured["flow_shifts"] == expected["flow"] - assert captured["scheduler_use_karras_sigmas"] == [None] - assert [call[0] for call in pipeline.scheduler.set_timesteps_calls] == expected["steps"] + assert [call["num_inference_steps"] for call in pipeline.scheduler.set_timesteps_calls] == expected["steps"] + assert all(call["shift"] == expected["flow"][0] for call in pipeline.scheduler.set_timesteps_calls) def test_forward_i2v_sound_and_action_routes(self, make_cosmos3_pipeline) -> None: pipeline = make_cosmos3_pipeline() @@ -1491,7 +2118,6 @@ class TestForwardRouting: ) ) assert captured["flow_shifts"][-1] == 10.0 - assert captured["scheduler_use_karras_sigmas"][-1] is False assert captured["format"]["negative_prompt"] == "" assert captured["diffuse_calls"][-1]["shared_kwargs"]["noisy_frame_mask"] is v2v_mask assert captured["diffuse_calls"][-1]["condition_latents"] is v2v_condition diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py b/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py index 13d1c399b..5dd8a875a 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py @@ -15,6 +15,25 @@ from vllm_omni.platforms import current_omni_platform pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] +@pytest.fixture(autouse=True) +def _single_rank_tensor_parallel(monkeypatch: pytest.MonkeyPatch) -> None: + """Provide the TP metadata required by vLLM parallel linear layers.""" + from vllm.model_executor import parameter + from vllm.model_executor.layers import linear + + from vllm_omni.diffusion.models.cosmos3 import ( + transformer_cosmos3, + transformer_cosmos3_edge, + ) + + monkeypatch.setattr(linear, "get_tensor_model_parallel_rank", lambda: 0) + monkeypatch.setattr(linear, "get_tensor_model_parallel_world_size", lambda: 1) + monkeypatch.setattr(parameter, "get_tensor_model_parallel_rank", lambda: 0) + monkeypatch.setattr(parameter, "get_tensor_model_parallel_world_size", lambda: 1) + monkeypatch.setattr(transformer_cosmos3, "get_tensor_model_parallel_world_size", lambda: 1) + monkeypatch.setattr(transformer_cosmos3_edge, "get_tensor_model_parallel_world_size", lambda: 1) + + @pytest.fixture def accelerator_device() -> torch.device: """Provide an accelerator device, skipping when none is available.""" @@ -40,6 +59,22 @@ def _tiny_cosmos3_config(**overrides): return config +def _tiny_cosmos3_edge_config(**overrides): + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + COSMOS3_EDGE_BACKBONE_TYPE, + ) + + config = _tiny_cosmos3_config( + latent_patch_size=2, + latent_channel=48, + temporal_compression_factor=4, + backbone_type=COSMOS3_EDGE_BACKBONE_TYPE, + qk_norm_for_text=False, + ) + config.update(overrides) + return config + + def test_mrope_position_ids_cover_text_video_sound_and_action() -> None: from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import ( compute_mrope_position_ids_action, @@ -78,6 +113,16 @@ def test_mrope_position_ids_cover_text_video_sound_and_action() -> None: assert action_offset == 14 +def test_timestep_embedder_stores_frequencies_in_fp32() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import ( + TimestepEmbedder, + ) + + embedder = TimestepEmbedder(hidden_size=8, frequency_embedding_size=16) + + assert embedder.freqs.dtype == torch.float32 + + @pytest.mark.parametrize( ("key", "value"), [ @@ -97,30 +142,245 @@ def test_validate_supported_config_rejects_unsupported_flags(key: str, value) -> Cosmos3VFMTransformer._validate_supported_config(None) +def test_edge_config_resolves_nemotron_defaults() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeVFMTransformer, + ) + + model = Cosmos3EdgeVFMTransformer( + SimpleNamespace( + tf_model_config=_tiny_cosmos3_edge_config( + layer_norm_epsilon=1e-5, + rope_scaling={}, + rope_parameters={"mrope_section": [24, 20, 20]}, + ), + dtype=torch.float32, + ) + ) + + assert model.rms_norm_eps == 1e-5 + assert model.rope_theta == 100_000_000 + assert model.mrope_section == [24, 20, 20] + assert model.latent_channel_size == 48 + assert model.latent_patch_size == 2 + assert model.temporal_compression_factor == 4 + + +def test_edge_config_requires_backbone_type() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeVFMTransformer, + ) + + config = _tiny_cosmos3_edge_config() + config.pop("backbone_type") + + with pytest.raises(ValueError, match="must declare backbone_type"): + Cosmos3EdgeVFMTransformer( + SimpleNamespace( + tf_model_config=config, + dtype=torch.float32, + ) + ) + + +def test_edge_und_and_gen_use_relu2_and_no_und_qk_norm() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeVFMTransformer, + Cosmos3Relu2MLP, + ) + + model = Cosmos3EdgeVFMTransformer( + SimpleNamespace( + tf_model_config=_tiny_cosmos3_edge_config( + num_hidden_layers=1, + use_und_k_norm_for_gen=False, + ), + dtype=torch.float32, + ) + ) + + layer = model.language_model.layers[0] + gen_layer = model.gen_layers[0] + assert isinstance(layer.mlp, Cosmos3Relu2MLP) + assert not hasattr(layer.mlp, "gate_proj") + assert isinstance(gen_layer.mlp, Cosmos3Relu2MLP) + assert not hasattr(gen_layer.mlp, "gate_proj") + assert not hasattr(layer.self_attn, "norm_q") + assert not hasattr(layer.self_attn, "norm_k") + assert layer.self_attn.k_norm_und_for_gen is None + + +def test_edge_creates_gen_k_norm_when_enabled() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import RMSNorm + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeVFMTransformer, + ) + + model = Cosmos3EdgeVFMTransformer( + SimpleNamespace( + tf_model_config=_tiny_cosmos3_edge_config( + num_hidden_layers=1, + qk_norm_for_diffusion=True, + use_und_k_norm_for_gen=True, + ), + dtype=torch.float32, + ) + ) + + k_norm = model.language_model.layers[0].self_attn.k_norm_und_for_gen + assert model.use_und_k_norm_for_gen is True + assert isinstance(k_norm, RMSNorm) + assert isinstance(model.gen_layers[0].cross_attention.norm_q, RMSNorm) + assert isinstance(model.gen_layers[0].cross_attention.norm_k, RMSNorm) + + +def test_edge_supports_no_gen_qk_norm_variant() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeVFMTransformer, + ) + + model = Cosmos3EdgeVFMTransformer( + SimpleNamespace( + tf_model_config=_tiny_cosmos3_edge_config( + num_hidden_layers=1, + qk_norm_for_diffusion=False, + use_und_k_norm_for_gen=False, + ), + dtype=torch.float32, + ) + ) + + assert model.qk_norm_for_diffusion is False + assert model.language_model.layers[0].self_attn.k_norm_und_for_gen is None + assert model.gen_layers[0].cross_attention.qk_norm is False + assert not hasattr(model.gen_layers[0].cross_attention, "norm_q") + assert not hasattr(model.gen_layers[0].cross_attention, "norm_k") + + +def test_edge_validates_required_relu2_weights() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeVFMTransformer, + ) + + model = object.__new__(Cosmos3EdgeVFMTransformer) + nn.Module.__init__(model) + model.num_hidden_layers = 1 + model.qk_norm_for_diffusion = True + model.use_und_k_norm_for_gen = True + + complete = { + "transformer.language_model.layers.0.mlp.up_proj.weight", + "transformer.language_model.layers.0.mlp.down_proj.weight", + "transformer.language_model.layers.0.self_attn.k_norm_und_for_gen.weight", + "transformer.gen_layers.0.mlp.up_proj.weight", + "transformer.gen_layers.0.mlp.down_proj.weight", + } + model.validate_loaded_weights(complete) + + mlp_weights = complete - { + "transformer.language_model.layers.0.self_attn.k_norm_und_for_gen.weight", + } + for missing_weight in mlp_weights: + with pytest.raises(ValueError, match="missing required weights"): + model.validate_loaded_weights(complete - {missing_weight}) + + missing_k_norm = complete - {"transformer.language_model.layers.0.self_attn.k_norm_und_for_gen.weight"} + with pytest.raises(ValueError, match=r"self_attn\.k_norm_und_for_gen"): + model.validate_loaded_weights(missing_k_norm) + + model.use_und_k_norm_for_gen = False + model.validate_loaded_weights(missing_k_norm) + + +def test_edge_gen_cached_k_is_normalized_but_reasoner_uses_raw_k() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import RMSNorm + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeCausalAttention, + ) + + class ScaleProjection(nn.Module): + def __init__(self, scale: float) -> None: + super().__init__() + self.scale = scale + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x * self.scale + + class IdentityOutput(nn.Module): + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x + + class RawKAttention(nn.Module): + def forward(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor) -> torch.Tensor: + del v + return q + k + + attn = object.__new__(Cosmos3EdgeCausalAttention) + nn.Module.__init__(attn) + attn.hidden_size = 4 + attn.num_heads = 2 + attn.num_kv_heads = 2 + attn.head_dim = 2 + attn.num_heads_local = 2 + attn.num_kv_heads_local = 2 + attn.to_q = ScaleProjection(1.0) + attn.to_k = ScaleProjection(1.0) + attn.to_v = ScaleProjection(1.0) + attn.to_out = IdentityOutput() + attn.k_norm_und_for_gen = RMSNorm(2, eps=1e-5) + attn.attn = RawKAttention() + + hidden_states = torch.tensor( + [[[1.0, 2.0, 3.0, 4.0], [2.0, 1.0, 4.0, 3.0]]], + dtype=torch.float32, + ) + freqs_cos = torch.ones(1, 2, 1, 2) + freqs_sin = torch.zeros(1, 2, 1, 2) + + reasoner_out, gen_k, gen_v = attn(hidden_states, freqs_cos, freqs_sin) + attn.to_k.scale = 3.0 + scaled_reasoner_out, scaled_gen_k, scaled_gen_v = attn(hidden_states, freqs_cos, freqs_sin) + + torch.testing.assert_close(gen_k, scaled_gen_k) + torch.testing.assert_close(gen_v, scaled_gen_v) + assert not torch.allclose(reasoner_out, scaled_reasoner_out) + + def test_transformer_sharding_offload_and_patch_round_trip_contracts() -> None: from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3LanguageModel, Cosmos3VFMTransformer + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeLanguageModel, + Cosmos3EdgeVFMTransformer, + ) - model = object.__new__(Cosmos3VFMTransformer) - nn.Module.__init__(model) - model.language_model = nn.Module() - model.language_model.layers = nn.ModuleList([nn.Linear(2, 2) for _ in range(2)]) - model.gen_layers = nn.ModuleList([nn.Linear(2, 2)]) - model.norm_moe_gen = nn.LayerNorm(2) + for transformer_cls, language_model_cls in ( + (Cosmos3VFMTransformer, Cosmos3LanguageModel), + (Cosmos3EdgeVFMTransformer, Cosmos3EdgeLanguageModel), + ): + model = object.__new__(transformer_cls) + nn.Module.__init__(model) + model.language_model = nn.Module() + model.language_model.layers = nn.ModuleList([nn.Linear(2, 2) for _ in range(2)]) + model.gen_layers = nn.ModuleList([nn.Linear(2, 2)]) + model.norm_moe_gen = nn.LayerNorm(2) - matched = [ - name - for name, module in model.named_modules() - if any(condition(name, module) for condition in model._hsdp_shard_conditions) - ] - assert matched == ["language_model.layers.0", "language_model.layers.1", "gen_layers.0"] - assert Cosmos3VFMTransformer._layerwise_offload_blocks_attrs == ["gen_layers"] - assert Cosmos3LanguageModel._layerwise_offload_blocks_attrs == ["layers"] - assert Cosmos3VFMTransformer._repeated_blocks == ["Cosmos3GenDecoderLayer"] + matched = [ + name + for name, module in model.named_modules() + if any(condition(name, module) for condition in model._hsdp_shard_conditions) + ] + assert matched == ["language_model.layers.0", "language_model.layers.1", "gen_layers.0"] + assert transformer_cls._layerwise_offload_blocks_attrs == ["gen_layers"] + assert language_model_cls._layerwise_offload_blocks_attrs == ["layers"] + assert transformer_cls._repeated_blocks == ["Cosmos3GenDecoderLayer"] - model.latent_patch_size = 2 - model.latent_channel_size = 3 - latents = torch.arange(1 * 3 * 1 * 3 * 5, dtype=torch.float32).reshape(1, 3, 1, 3, 5) - torch.testing.assert_close(model.unpatchify(model.patchify(latents, t=1, h=3, w=5), t=1, h=3, w=5), latents) + model.latent_patch_size = 2 + model.latent_channel_size = 3 + latents = torch.arange(1 * 3 * 1 * 3 * 5, dtype=torch.float32).reshape(1, 3, 1, 3, 5) + torch.testing.assert_close( + model.unpatchify(model.patchify(latents, t=1, h=3, w=5), t=1, h=3, w=5), + latents, + ) def test_forward_returns_video_prediction(monkeypatch: pytest.MonkeyPatch) -> None: @@ -142,6 +402,17 @@ def test_forward_returns_video_prediction(monkeypatch: pytest.MonkeyPatch) -> No assert tuple(output.shape) == (1, 2, 1, 2, 2) +def test_qwen_gen_mlp_remains_gated() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3GatedMLP, Cosmos3VFMTransformer + + model = Cosmos3VFMTransformer( + SimpleNamespace(tf_model_config=_tiny_cosmos3_config(num_hidden_layers=1), dtype=torch.float32) + ) + + assert isinstance(model.gen_layers[0].mlp, Cosmos3GatedMLP) + assert hasattr(model.gen_layers[0].mlp, "gate_proj") + + def test_model_cpu_offload_swaps_back_to_generator(monkeypatch: pytest.MonkeyPatch) -> None: from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3 diff --git a/tests/diffusion/models/dmd2/test_dmd2_request_sanitization.py b/tests/diffusion/models/dmd2/test_dmd2_request_sanitization.py index 21679b5b7..5fe027a20 100644 --- a/tests/diffusion/models/dmd2/test_dmd2_request_sanitization.py +++ b/tests/diffusion/models/dmd2/test_dmd2_request_sanitization.py @@ -6,12 +6,16 @@ import pytest import torch from vllm_omni.diffusion.models.flux.pipeline_flux import FluxDMD2Pipeline, FluxPipeline -from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline, LTX2T2VDMD2Pipeline -from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_image2video import LTX2I2VDMD2Pipeline, LTX2ImageToVideoPipeline +from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import ( + LTX2I2VDMD2Pipeline, + LTX2Pipeline, + LTX2T2VDMD2Pipeline, +) from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image import QwenImageDMD2Pipeline, QwenImagePipeline from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline, WanT2VDMD2Pipeline from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v import Wan22I2VPipeline, WanI2VDMD2Pipeline from vllm_omni.diffusion.request import OmniDiffusionRequest, OmniDiffusionSamplingParams +from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -20,7 +24,7 @@ _DMD2_BASE = { WanT2VDMD2Pipeline: Wan22Pipeline, WanI2VDMD2Pipeline: Wan22I2VPipeline, LTX2T2VDMD2Pipeline: LTX2Pipeline, - LTX2I2VDMD2Pipeline: LTX2ImageToVideoPipeline, + LTX2I2VDMD2Pipeline: LTX2Pipeline, FluxDMD2Pipeline: FluxPipeline, QwenImageDMD2Pipeline: QwenImagePipeline, } @@ -152,6 +156,28 @@ def test_string_prompt_not_mutated(pipeline): assert req.prompt == "a cat dancing" +def test_request_batch_sanitizes_each_request(pipeline): + requests = [ + _make_request( + prompt={"prompt": f"prompt-{index}", "negative_prompt": "blurry"}, + num_inference_steps=40 + index, + guidance_scale=5.0 + index, + guidance_scale_provided=True, + ) + for index in range(2) + ] + + pipeline._sanitize_dmd2_request(DiffusionRequestBatch(requests)) + + for request in requests: + assert request.sampling_params.num_inference_steps == pipeline.dmd2_config.num_inference_steps + assert request.sampling_params.guidance_scale == pipeline.dmd2_config.guidance_scale + assert request.sampling_params.guidance_scale_provided is False + assert request.sampling_params.do_classifier_free_guidance is False + assert request.sampling_params.is_cfg_negative is False + assert "negative_prompt" not in request.prompt + + # --------------------------------------------------------------------------- # Clean request — nothing changes # --------------------------------------------------------------------------- diff --git a/tests/diffusion/models/dmd2/test_dmd2_scheduler.py b/tests/diffusion/models/dmd2/test_dmd2_scheduler.py index 0b6ca3a9e..4df65f1fe 100644 --- a/tests/diffusion/models/dmd2/test_dmd2_scheduler.py +++ b/tests/diffusion/models/dmd2/test_dmd2_scheduler.py @@ -6,8 +6,11 @@ import pytest import torch from vllm_omni.diffusion.models.flux.pipeline_flux import FluxDMD2Pipeline, FluxPipeline -from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline, LTX2T2VDMD2Pipeline -from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_image2video import LTX2I2VDMD2Pipeline, LTX2ImageToVideoPipeline +from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import ( + LTX2I2VDMD2Pipeline, + LTX2Pipeline, + LTX2T2VDMD2Pipeline, +) from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image import QwenImageDMD2Pipeline, QwenImagePipeline from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline, WanT2VDMD2Pipeline from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v import Wan22I2VPipeline, WanI2VDMD2Pipeline @@ -23,7 +26,7 @@ _DMD2_BASE = { WanT2VDMD2Pipeline: Wan22Pipeline, WanI2VDMD2Pipeline: Wan22I2VPipeline, LTX2T2VDMD2Pipeline: LTX2Pipeline, - LTX2I2VDMD2Pipeline: LTX2ImageToVideoPipeline, + LTX2I2VDMD2Pipeline: LTX2Pipeline, FluxDMD2Pipeline: FluxPipeline, QwenImageDMD2Pipeline: QwenImagePipeline, } diff --git a/tests/unit/higgs_audio_v3/__init__.py b/tests/diffusion/models/dreamzero/__init__.py similarity index 100% rename from tests/unit/higgs_audio_v3/__init__.py rename to tests/diffusion/models/dreamzero/__init__.py diff --git a/tests/dreamzero/test_crossattn_cache.py b/tests/diffusion/models/dreamzero/test_crossattn_cache.py similarity index 100% rename from tests/dreamzero/test_crossattn_cache.py rename to tests/diffusion/models/dreamzero/test_crossattn_cache.py diff --git a/tests/diffusion/models/dreamzero/test_deploy_configs.py b/tests/diffusion/models/dreamzero/test_deploy_configs.py new file mode 100644 index 000000000..8f9770add --- /dev/null +++ b/tests/diffusion/models/dreamzero/test_deploy_configs.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Every shipped DreamZero deploy config must select the AR-Diffusion engine. + +``DreamZeroPipeline.__init__`` rejects any other engine, so a config missing +``engine_backend`` fails at worker startup rather than at request time. That +only surfaces in slow multi-GPU e2e serving tests (see issue #5046), so assert +it here on CPU instead. +""" + +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml + +from tests.helpers.stage_config import get_deploy_config_path +from vllm_omni.diffusion.diffusion_engine import DiffusionEngine +from vllm_omni.experimental.ar_diffusion.engine import ARDiffusionEngine + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +# Discover siblings of a known config so a future dreamzero*.yaml is covered +# without editing this test. +DEPLOY_DIR = Path(get_deploy_config_path("dreamzero.yaml")).parent + + +def _dreamzero_stages() -> list[tuple[str, int, dict]]: + stages = [] + for name in sorted(path.name for path in DEPLOY_DIR.glob("dreamzero*.yaml")): + config = yaml.safe_load(Path(get_deploy_config_path(name)).read_text()) + for index, stage in enumerate(config.get("stages") or []): + stages.append((name, index, stage)) + return stages + + +def test_dreamzero_deploy_configs_exist() -> None: + assert _dreamzero_stages(), f"no DreamZero deploy configs found under {DEPLOY_DIR}" + + +@pytest.mark.parametrize( + ("config_name", "stage_index", "stage"), + _dreamzero_stages(), + ids=lambda value: value if isinstance(value, str) else None, +) +def test_dreamzero_stage_selects_ar_diffusion_engine(config_name: str, stage_index: int, stage: dict) -> None: + backend = stage.get("engine_backend") + assert backend, ( + f"{config_name} stage {stage_index} does not set engine_backend; " + "DreamZeroPipeline requires the AR-Diffusion engine" + ) + # Resolving also proves the import path is real, not just a lookalike string. + engine_class = DiffusionEngine.resolve_engine_class(SimpleNamespace(engine_backend=backend)) + assert issubclass(engine_class, ARDiffusionEngine), ( + f"{config_name} stage {stage_index} resolves to {engine_class.__name__}, not an AR-Diffusion engine" + ) diff --git a/tests/dreamzero/test_dreamzero_utils.py b/tests/diffusion/models/dreamzero/test_dreamzero_utils.py similarity index 100% rename from tests/dreamzero/test_dreamzero_utils.py rename to tests/diffusion/models/dreamzero/test_dreamzero_utils.py diff --git a/tests/dreamzero/test_fused_qk_rms_norm.py b/tests/diffusion/models/dreamzero/test_fused_qk_rms_norm.py similarity index 100% rename from tests/dreamzero/test_fused_qk_rms_norm.py rename to tests/diffusion/models/dreamzero/test_fused_qk_rms_norm.py diff --git a/tests/diffusion/models/dreamzero/test_pipeline_state.py b/tests/diffusion/models/dreamzero/test_pipeline_state.py new file mode 100644 index 000000000..366876ef7 --- /dev/null +++ b/tests/diffusion/models/dreamzero/test_pipeline_state.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from collections import OrderedDict +from types import SimpleNamespace + +import pytest + +from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline +from vllm_omni.diffusion.models.dreamzero.state_dreamzero import DreamZeroState + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _empty_pipeline() -> DreamZeroPipeline: + pipeline = DreamZeroPipeline.__new__(DreamZeroPipeline) + pipeline._states = OrderedDict() + return pipeline + + +def test_dreamzero_pipeline_state_is_session_keyed() -> None: + pipeline = _empty_pipeline() + + session_a = pipeline._get_or_create_state("session-a") + session_b = pipeline._get_or_create_state("session-b") + session_a.call_count = 7 + session_b.call_count = 3 + + assert pipeline._get_or_create_state("session-a") is session_a + assert pipeline._get_or_create_state("session-b") is session_b + assert session_a.call_count == 7 + assert session_b.call_count == 3 + + +def test_dreamzero_pipeline_state_follows_runner_lifecycle_notifications() -> None: + pipeline = _empty_pipeline() + + session_a = pipeline._get_or_create_state("session-a") + session_b = pipeline._get_or_create_state("session-b") + pipeline.state = session_b + + pipeline.close_ar_diffusion_session("session-a") + assert pipeline.state is session_b + pipeline.reset_ar_diffusion_session("session-b") + + assert not pipeline._states + assert pipeline.state is None + assert pipeline._get_or_create_state("session-a") is not session_a + assert pipeline._get_or_create_state("session-b") is not session_b + + +def test_close_session_clears_active_state_alias() -> None: + pipeline = _empty_pipeline() + session_a = pipeline._get_or_create_state("session-a") + pipeline.state = session_a + + pipeline.close_ar_diffusion_session("session-a") + + assert not pipeline._states + assert pipeline.state is None + + +def test_dreamzero_warmup_provider_builds_session_scoped_requests() -> None: + provider = SimpleNamespace( + ar_diffusion_kv_cache_spec=lambda: SimpleNamespace(window_frames=5, frames_per_block=4), + od_config=SimpleNamespace( + ar_diffusion_kv_config=None, + model_config={"policy_server_config": {"image_resolution": [8, 16]}}, + ), + _ar_warmup_robot_obs=DreamZeroPipeline._ar_warmup_robot_obs, + ) + + requests = list(DreamZeroPipeline.ar_diffusion_warmup_requests(provider, "warmup-session")) + + assert [request.prompt for request in requests] == ["warmup", "warmup"] + assert all(request.sampling_params.extra_args["session_id"] == "warmup-session" for request in requests) + assert requests[0].sampling_params.extra_args["robot_obs"]["observation/exterior_image_0_left"].shape == ( + 8, + 16, + 3, + ) + assert requests[1].sampling_params.extra_args["robot_obs"]["observation/exterior_image_0_left"].shape == ( + 4, + 8, + 16, + 3, + ) + + +def test_dreamzero_state_owns_no_kv_caches() -> None: + """KV (self- and cross-attention) is engine-owned since the AR-Diffusion + paged backend: the model-local cache accessors must be gone so nothing can + silently bypass the engine pool.""" + state = DreamZeroState() + + for removed in ("get_kv_caches", "create_kv_caches", "update_kv_cache", "get_crossattn_caches"): + assert not hasattr(state, removed) diff --git a/tests/dreamzero/test_qkv_fusion.py b/tests/diffusion/models/dreamzero/test_qkv_fusion.py similarity index 100% rename from tests/dreamzero/test_qkv_fusion.py rename to tests/diffusion/models/dreamzero/test_qkv_fusion.py diff --git a/tests/diffusion/models/dreamzero/test_session_state_accounting.py b/tests/diffusion/models/dreamzero/test_session_state_accounting.py new file mode 100644 index 000000000..443a02404 --- /dev/null +++ b/tests/diffusion/models/dreamzero/test_session_state_accounting.py @@ -0,0 +1,201 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Per-device byte accounting and the bounded VAE encoder ring (RFC #4480). + +Two things are pinned here. First, that a session reports the bytes it actually +holds: both buckets walked, storages rather than logical element counts, and +aliases counted once. Second, that bounding the adapter's VAE encoder history +changes no numbers -- ``quant_conv`` is pointwise in time, so the frames the +reader keeps do not depend on the ones the ring drops. + +All CPU, tiny tensors, no model. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import torch + +from vllm_omni.experimental.world_models.adapters.state_dreamzero_adapter import ( + DreamZeroStateAdapter, +) +from vllm_omni.experimental.world_models.session_state import ( + LatentBuffer, + SessionStateManager, + device_bytes, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _bytes(value: object) -> dict[str, int]: + return device_bytes(value, set()) + + +# -- device_bytes ------------------------------------------------------------ + + +def test_counts_tensor_storage_under_its_device() -> None: + tensor = torch.zeros(64, dtype=torch.float32) + assert _bytes(tensor) == {"cpu": 256} + + +def test_views_on_one_storage_counted_once() -> None: + tensor = torch.zeros(64, dtype=torch.float32) + seen: set[tuple[str, int]] = set() + first = device_bytes(tensor, seen) + second = device_bytes(tensor[:8], seen) + assert first == {"cpu": 256} + assert second == {} + + +def test_slice_reports_the_storage_it_pins() -> None: + # The whole point of counting storages: this slice keeps all 256 bytes + # alive, and ``numel() * element_size()`` would claim 32. + sliced = torch.zeros(64, dtype=torch.float32)[:8] + assert _bytes(sliced) == {"cpu": 256} + + +def test_containers_recurse_and_scalars_contribute_nothing() -> None: + payload = { + "tensors": [torch.zeros(16, dtype=torch.float32), None], + "count": 7, + "flag": True, + "name": "not-a-buffer", + } + assert _bytes(payload) == {"cpu": 64} + + +def test_numpy_arrays_land_on_the_host() -> None: + assert _bytes(np.zeros(32, dtype=np.float32)) == {"cpu": 128} + + +# -- SessionState / manager -------------------------------------------------- + + +def test_session_counts_both_buckets_and_dedupes_across_them() -> None: + manager = SessionStateManager() + session = manager.get_or_create_session("s") + + buffer: LatentBuffer[torch.Tensor] = LatentBuffer() + buffer.allocate(maxlen=None) + shared = torch.zeros(64, dtype=torch.float32) # 256 bytes + buffer.append(shared) + session.put("latents", buffer) + session.attrs["conditioning"] = torch.zeros(16, dtype=torch.float32) # 64 bytes + + assert session.nbytes_by_device() == {"cpu": 320} + + # The same storage reachable from both buckets is one allocation, not two. + session.attrs["alias"] = shared + assert session.nbytes_by_device() == {"cpu": 320} + + +def test_stats_reports_per_device_totals() -> None: + manager = SessionStateManager() + manager.get_or_create_session("s").attrs["x"] = torch.zeros(64, dtype=torch.float32) + + stats = manager.stats() + assert stats["nbytes:cpu"] == 256 + assert stats["total_nbytes"] == 256 + + +def test_attribute_bytes_were_previously_invisible() -> None: + """The defect behind tzhouam's review comment on PR #4487. + + Every byte here lives in ``attrs``, and the old ``nbytes`` walked only the + typed objects -- so a session holding real memory reported zero. + """ + manager = SessionStateManager() + session = manager.get_or_create_session("s") + session.attrs["conv_cache"] = [torch.zeros(1024, dtype=torch.float32)] + + assert session.nbytes_by_device() == {"cpu": 4096} + + +# -- the largest value DreamZero holds --------------------------------------- + + +def test_feat_map_bytes_are_counted() -> None: + """``vae_enc_feat_map`` is the largest thing a DreamZero session holds -- + 603 MiB at the shipped config -- and it stays an attribute, so this is + exactly the memory the old accounting missed.""" + manager = SessionStateManager() + adapter = DreamZeroStateAdapter("s", manager) + adapter.vae_enc_feat_map = [torch.zeros(1024, dtype=torch.float32), None] + + assert manager.stats()["nbytes:cpu"] == 4096 + + # Freeing it piecemeal is not something the manager can do: the cache has no + # recompute source and the stream breaks without it. Ending the stream (or + # the session) is the only release, and that reclaims attributes too. + adapter.reset_vae_encoder_stream() + assert manager.stats().get("nbytes:cpu", 0) == 0 + + +# -- bounded VAE encoder ring ------------------------------------------------ + + +def _quantize(encoder_out: torch.Tensor) -> torch.Tensor: + """Stand-in for ``_vae_quantize_encoder_out``. + + The real one is ``WanCausalConv3d(z_dim*2, z_dim*2, 1)`` followed by a + per-channel affine rescale. Every operation is pointwise in time, which is + the property the ring relies on; a 1x1x1 convolution reproduces it. + """ + weight = torch.arange(1, encoder_out.shape[1] + 1, dtype=encoder_out.dtype).view(1, -1, 1, 1, 1) + return encoder_out * weight + 0.5 + + +def test_ring_is_bounded() -> None: + adapter = DreamZeroStateAdapter("s", SessionStateManager(), vae_encoder_window=2) + adapter.vae_encoder_out = torch.randn(1, 4, 1, 2, 2) + for _ in range(10): + adapter.append_vae_encoder_chunk(torch.randn(1, 4, 1, 2, 2)) + + assert adapter.vae_encoder_out is not None + assert adapter.vae_encoder_out.shape[2] == 2 + + +def test_ring_matches_unbounded_history_for_the_frames_that_are_read() -> None: + window = 2 + adapter = DreamZeroStateAdapter("s", SessionStateManager(), vae_encoder_window=window) + seed = torch.randn(1, 4, 1, 2, 2) + chunks = [torch.randn(1, 4, 1, 2, 2) for _ in range(9)] + + adapter.vae_encoder_out = seed + unbounded = seed + for chunk in chunks: + adapter.append_vae_encoder_chunk(chunk) + unbounded = torch.cat([unbounded, chunk], dim=2) + + # What the reader actually returns: quantise, then keep the last `window`. + from_ring = _quantize(adapter.vae_encoder_out)[:, :, -window:] + from_history = _quantize(unbounded)[:, :, -window:] + torch.testing.assert_close(from_ring, from_history, rtol=0, atol=0) + + +def test_ring_holds_a_single_frame_before_the_first_body_chunk() -> None: + # After `_vae_stream_seed` there is exactly one latent frame, which is what + # sends the reader down its padding branch. + adapter = DreamZeroStateAdapter("s", SessionStateManager(), vae_encoder_window=2) + seed = torch.randn(1, 4, 1, 2, 2) + adapter.vae_encoder_out = seed + + assert adapter.vae_encoder_out is not None + assert adapter.vae_encoder_out.shape[2] == 1 + torch.testing.assert_close(adapter.vae_encoder_out, seed, rtol=0, atol=0) + + +def test_resetting_the_stream_clears_the_ring() -> None: + adapter = DreamZeroStateAdapter("s", SessionStateManager(), vae_encoder_window=2) + adapter.vae_encoder_out = torch.randn(1, 4, 1, 2, 2) + adapter.reset_vae_encoder_stream() + + assert adapter.vae_encoder_out is None + + +def test_window_must_be_positive() -> None: + with pytest.raises(ValueError, match="vae_encoder_window"): + DreamZeroStateAdapter("s", SessionStateManager(), vae_encoder_window=0) diff --git a/tests/diffusion/models/dreamzero/test_session_state_equivalence.py b/tests/diffusion/models/dreamzero/test_session_state_equivalence.py new file mode 100644 index 000000000..a346a4039 --- /dev/null +++ b/tests/diffusion/models/dreamzero/test_session_state_equivalence.py @@ -0,0 +1,257 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Bit-equivalence tests: DreamZeroStateAdapter vs bespoke DreamZeroState. + +These drive both state objects directly with tiny CPU tensors -- no model, no +GPU -- and assert the adapter (backed by the SessionStateManager) stores and +returns exactly what the bespoke DreamZeroState does. This gates routing +DreamZero through the shared session state manager (RFC #4480). + +DreamZero's attention KV is engine-owned (AR-Diffusion paged pool, PR #4534) +and out of the adapter's scope; the state surface tested here is the model's +non-KV session state. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import torch + +from vllm_omni.diffusion.models.dreamzero.state_dreamzero import ( + FRAMES_PER_CHUNK, + DreamZeroState, +) +from vllm_omni.experimental.world_models.adapters.state_dreamzero_adapter import DreamZeroStateAdapter +from vllm_omni.experimental.world_models.session_state import SessionStateManager + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _create_both() -> tuple[DreamZeroState, DreamZeroStateAdapter]: + return DreamZeroState(), DreamZeroStateAdapter("session-0", SessionStateManager()) + + +def test_accumulate_frames_and_call_count_match() -> None: + bespoke, adapter = _create_both() + rng = np.random.default_rng(0) + for _ in range(2 * FRAMES_PER_CHUNK + 1): + frame = rng.integers(0, 255, size=(5, 6, 3), dtype=np.uint8) + b_out = bespoke.accumulate_frames(frame) + a_out = adapter.accumulate_frames(frame) + np.testing.assert_array_equal(b_out, a_out) + assert bespoke.call_count == adapter.call_count + assert len(bespoke.stitched_buffer) == len(adapter.stitched_buffer) + # Deque rollover capped at FRAMES_PER_CHUNK. + assert len(adapter.stitched_buffer) == FRAMES_PER_CHUNK + + +def test_accumulate_frames_4d_extend_matches() -> None: + bespoke, adapter = _create_both() + clip = np.random.default_rng(1).integers(0, 255, size=(3, 5, 6, 3), dtype=np.uint8) + np.testing.assert_array_equal(bespoke.accumulate_frames(clip), adapter.accumulate_frames(clip)) + assert len(bespoke.stitched_buffer) == len(adapter.stitched_buffer) + + +def test_video_latents_roundtrip_matches() -> None: + bespoke, adapter = _create_both() + assert bespoke.get_concatenated_video_latents() is None + assert adapter.get_concatenated_video_latents() is None + + rng = torch.Generator().manual_seed(0) + for _ in range(3): + # (B, T, C, H, W) as returned by the denoise loop. + chunk = torch.randn(1, 2, 3, 4, 4, generator=rng) + bespoke.append_video_latents(chunk) + adapter.append_video_latents(chunk) + torch.testing.assert_close( + bespoke.get_concatenated_video_latents(), + adapter.get_concatenated_video_latents(), + ) + + bespoke.clear_video_latents() + adapter.clear_video_latents() + assert bespoke.get_concatenated_video_latents() is None + assert adapter.get_concatenated_video_latents() is None + + +def test_vae_stream_fields_match_bespoke() -> None: + bespoke, adapter = _create_both() + for state in (bespoke, adapter): + assert state.vae_stream_initialized is False + assert state.vae_enc_feat_map is None + assert state.vae_encoder_out is None + assert state.vae_pending_body_frames is None + + feat_map: list[torch.Tensor | None] = [torch.ones(1, 2), None] + encoder_out = torch.randn(1, 4, 2, 3, 3) + pending = torch.randn(1, 3, 2, 8, 8) + for state in (bespoke, adapter): + state.vae_enc_feat_map = feat_map + state.vae_encoder_out = encoder_out + state.vae_pending_body_frames = pending + state.vae_stream_initialized = True + + assert adapter.vae_stream_initialized is bespoke.vae_stream_initialized is True + torch.testing.assert_close(adapter.vae_encoder_out, bespoke.vae_encoder_out) + torch.testing.assert_close(adapter.vae_pending_body_frames, bespoke.vae_pending_body_frames) + assert adapter.vae_enc_feat_map is feat_map + + for state in (bespoke, adapter): + state.reset_vae_encoder_stream() + assert state.vae_stream_initialized is False + assert state.vae_enc_feat_map is None + assert state.vae_encoder_out is None + assert state.vae_pending_body_frames is None + + +def test_reset_clears_state_and_should_reset_matches() -> None: + bespoke, adapter = _create_both() + tokens = torch.tensor([1, 2, 3]) + # language unset -> both want reset. + assert bespoke.should_reset(tokens, 4, -1) is adapter.should_reset(tokens, 4, -1) is True + + for state in (bespoke, adapter): + state.language = tokens + state.current_start_frame = 2 + # same language -> no reset; both agree. + assert bespoke.should_reset(tokens, 4, -1) == adapter.should_reset(tokens, 4, -1) is False + # language change -> reset. + other = torch.tensor([9, 9]) + assert bespoke.should_reset(other, 4, -1) == adapter.should_reset(other, 4, -1) is True + # local_attn_size exceeded -> reset ("inference", not "session"). + assert bespoke.reset_reason(tokens, 4, 1) == adapter.reset_reason(tokens, 4, 1) == "inference" + + # A session reset clears everything, including the prompt-embed cache and + # the VAE encoder stream (mirrors DreamZeroState.reset()). + for state in (bespoke, adapter): + state.prompt_embeds = torch.ones(1, 2) + state.vae_encoder_out = torch.ones(1, 4, 1, 2, 2) + state.vae_stream_initialized = True + state.reset() + assert state.language is None + assert state.prompt_embeds is None + assert state.current_start_frame == 0 + assert state.call_count == 0 + assert state.vae_stream_initialized is False + assert state.vae_encoder_out is None + + +def test_window_reset_preserves_latents_prompt_and_vae_stream() -> None: + # reset(clear_video_latents=False) is the window ("inference") reset: the + # prompt is unchanged and the VAE feat_cache history is independent of the + # DiT attention window, so both survive alongside the video latents. + bespoke, adapter = _create_both() + tokens = torch.tensor([1, 2, 3]) + embeds = torch.randn(1, 4) + encoder_out = torch.randn(1, 4, 2, 3, 3) + chunk = torch.randn(1, 2, 3, 4, 4) + for state in (bespoke, adapter): + state.language = tokens + state.prompt_embeds = embeds + state.vae_encoder_out = encoder_out + state.vae_stream_initialized = True + state.current_start_frame = 7 + state.append_video_latents(chunk) + + state.reset_inference_state() + + assert state.current_start_frame == 0 + assert state.call_count == 0 + assert state.clip_feas is None + torch.testing.assert_close(state.language, tokens) + torch.testing.assert_close(state.prompt_embeds, embeds) + assert state.vae_stream_initialized is True + torch.testing.assert_close(state.vae_encoder_out, encoder_out) + assert state.get_concatenated_video_latents() is not None + + torch.testing.assert_close( + bespoke.get_concatenated_video_latents(), + adapter.get_concatenated_video_latents(), + ) + + +def test_metadata_fields_persist_across_adapter_instances() -> None: + # A fresh adapter for the same session must see prior metadata (the manager + # is the single source of truth). + manager = SessionStateManager() + first = DreamZeroStateAdapter("s", manager) + first.current_start_frame = 5 + first.clip_feas = torch.ones(2, 2) + first.append_video_latents(torch.ones(1, 2, 3, 4, 4)) + + second = DreamZeroStateAdapter("s", manager) + assert second.current_start_frame == 5 + torch.testing.assert_close(second.clip_feas, torch.ones(2, 2)) + # Video latents appended via `first` are visible via `second`. + assert second.get_concatenated_video_latents() is not None + + +def test_session_lru_caps_retained_sessions() -> None: + manager = SessionStateManager(max_sessions=3) + for i in range(5): + DreamZeroStateAdapter(f"s{i}", manager) + assert len(manager) == 3 + # Oldest two evicted; newest three retained. + assert "s0" not in manager + assert "s1" not in manager + for i in (2, 3, 4): + assert f"s{i}" in manager + + +def test_adapter_keeps_state_when_its_session_is_evicted() -> None: + # Regression: an adapter mid-generation must not lose its data when the + # manager evicts its session to make room for newer ones. The adapter pins + # the session, so its stored latents survive eviction from the lookup table. + manager = SessionStateManager(max_sessions=2) + adapter = DreamZeroStateAdapter("active", manager) + chunk = torch.randn(1, 2, 3, 4, 4) + adapter.append_video_latents(chunk) + adapter.language = torch.tensor([1, 2, 3]) + + # Push "active" out of the manager's table with newer sessions. + for i in range(3): + DreamZeroStateAdapter(f"other{i}", manager) + assert "active" not in manager + + # The in-use adapter still returns its own data. + assert adapter.get_concatenated_video_latents() is not None + torch.testing.assert_close(adapter.language, torch.tensor([1, 2, 3])) + + +def test_session_reset_clears_metadata() -> None: + # Regression: resetting a session must not leave stale metadata behind, so a + # fresh adapter for the same session sees defaults, not the old values. + manager = SessionStateManager() + adapter = DreamZeroStateAdapter("s", manager) + adapter.language = torch.tensor([1, 2, 3]) + adapter.current_start_frame = 5 + adapter.clip_feas = torch.ones(2, 2) + adapter.prompt_embeds = torch.ones(1, 2) + + adapter.reset() + + fresh = DreamZeroStateAdapter("s", manager) + assert fresh.language is None + assert fresh.current_start_frame == 0 + assert fresh.clip_feas is None + assert fresh.prompt_embeds is None + + +def test_bespoke_path_without_memory_manager() -> None: + # Regression: a pipeline built via __new__ (lightweight fixtures) seeds only + # the bespoke fields and never sets _memory_manager; _get_or_create_state + # must fall through to the bespoke DreamZeroState path without AttributeError. + from collections import OrderedDict + + from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import ( + MAX_DREAMZERO_SESSIONS, + DreamZeroPipeline, + ) + + pipe = DreamZeroPipeline.__new__(DreamZeroPipeline) + pipe._states = OrderedDict() + pipe._max_session_states = MAX_DREAMZERO_SESSIONS + + state = DreamZeroPipeline._get_or_create_state(pipe, "x") + assert isinstance(state, DreamZeroState) diff --git a/tests/diffusion/models/dreamzero/test_session_state_lifecycle.py b/tests/diffusion/models/dreamzero/test_session_state_lifecycle.py new file mode 100644 index 000000000..a67578cc7 --- /dev/null +++ b/tests/diffusion/models/dreamzero/test_session_state_lifecycle.py @@ -0,0 +1,115 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Session end-of-life routing (RFC #4480, adapting to #5271). + +The AR-Diffusion runner raises an explicit end-of-session signal on reset, +close, eviction, and failed forwards (``reset_ar_diffusion_session`` / +``close_ar_diffusion_session``). On the bespoke path those pop ``_states``; on +the opt-in manager path the session lives in the manager instead, so the hooks +must release it there or every closed session leaks its buffers. + +All CPU, tiny tensors, no model. +""" + +from __future__ import annotations + +from collections import OrderedDict + +import pytest +import torch + +from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline +from vllm_omni.diffusion.models.dreamzero.state_dreamzero import DreamZeroState +from vllm_omni.experimental.world_models.adapters.state_dreamzero_adapter import ( + DreamZeroStateAdapter, +) +from vllm_omni.experimental.world_models.session_state import ( + LatentBuffer, + SessionStateManager, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +# -- SessionStateManager.drop_session --------------------------------------- + + +def test_drop_session_absent_returns_false() -> None: + manager = SessionStateManager() + assert manager.drop_session("missing") is False + + +def test_drop_session_removes_and_frees() -> None: + manager = SessionStateManager() + session = manager.get_or_create_session("s") + buffer: LatentBuffer[torch.Tensor] = LatentBuffer() + buffer.allocate(maxlen=None) + buffer.append(torch.zeros(256, dtype=torch.float32)) + session.put("payload", buffer) + assert manager.nbytes_by_device().get("cpu", 0) >= 1024 + + assert manager.drop_session("s") is True + assert "s" not in manager + assert len(manager) == 0 + # The session is gone from the table and its buffers were reset, so the + # manager reports no held bytes. + assert manager.nbytes_by_device() == {} + + +def test_drop_session_recreates_fresh() -> None: + manager = SessionStateManager() + manager.get_or_create_session("s").attrs["k"] = 1 + manager.drop_session("s") + assert manager.get_or_create_session("s").attrs == {} + + +# -- pipeline hook routing --------------------------------------------------- + + +def _manager_pipe(manager: SessionStateManager, session_id: str) -> DreamZeroPipeline: + """A pipeline built via __new__ whose state is a manager-backed adapter.""" + pipe = DreamZeroPipeline.__new__(DreamZeroPipeline) + pipe._states = OrderedDict() + pipe._memory_manager = manager + pipe.state = DreamZeroStateAdapter(session_id, manager, vae_encoder_window=2) + return pipe + + +@pytest.mark.parametrize("hook", ["close_ar_diffusion_session", "reset_ar_diffusion_session"]) +def test_hook_releases_manager_session_and_clears_alias(hook: str) -> None: + manager = SessionStateManager() + pipe = _manager_pipe(manager, "sess") + assert "sess" in manager + + getattr(DreamZeroPipeline, hook)(pipe, "sess") + + assert "sess" not in manager + # The alias viewed the released session, so it is dropped and rebuilt lazily. + assert pipe.state is None + + +def test_hook_keeps_alias_for_a_different_session() -> None: + manager = SessionStateManager() + pipe = _manager_pipe(manager, "default") + manager.get_or_create_session("other") + + DreamZeroPipeline.close_ar_diffusion_session(pipe, "other") + + assert "other" not in manager + # The alias views "default", not the closed session, so it survives. + assert isinstance(pipe.state, DreamZeroStateAdapter) + assert pipe.state.session_id == "default" + + +def test_bespoke_hook_pops_states_and_clears_alias() -> None: + pipe = DreamZeroPipeline.__new__(DreamZeroPipeline) + pipe._states = OrderedDict() + pipe._memory_manager = None + state = DreamZeroState() + pipe._states["sess"] = state + pipe.state = state + + DreamZeroPipeline.close_ar_diffusion_session(pipe, "sess") + + assert "sess" not in pipe._states + assert pipe.state is None diff --git a/tests/diffusion/models/flux2/test_flux2_transformer_tp.py b/tests/diffusion/models/flux2/test_flux2_transformer_tp.py index c613bb0b4..c935871a0 100644 --- a/tests/diffusion/models/flux2/test_flux2_transformer_tp.py +++ b/tests/diffusion/models/flux2/test_flux2_transformer_tp.py @@ -253,3 +253,28 @@ class TestFlux2PackedModuleMapping: assert len(loaded_params) == 2, "Should load two weights" assert "x_embedder.weight" in loaded_params assert any("to_qkv" in p for p in loaded_params), "to_q should be mapped to to_qkv" + + +@pytest.mark.core_model +@pytest.mark.cpu +class TestFlux2TransformerLayerwiseOffload: + def test_layerwise_offload_blocks_attrs(self): + assert Flux2Transformer2DModel._layerwise_offload_blocks_attrs == [ + "transformer_blocks", + "single_transformer_blocks", + ] + + def test_get_blocks_from_dit(self): + from vllm_omni.diffusion.offloader.layerwise_backend import LayerWiseOffloadBackend + + # Block discovery only needs the ModuleList structure, not production dims. + model = Flux2Transformer2DModel( + num_layers=2, + num_single_layers=2, + num_attention_heads=2, + attention_head_dim=4, + joint_attention_dim=16, + ) + attr_names, blocks = LayerWiseOffloadBackend.get_blocks_from_dit(model) + assert attr_names == ["transformer_blocks", "single_transformer_blocks"] + assert len(blocks) == 4 diff --git a/tests/diffusion/models/flux2/test_pipeline_flux2_quantization.py b/tests/diffusion/models/flux2/test_pipeline_flux2_quantization.py new file mode 100644 index 000000000..3900c0c94 --- /dev/null +++ b/tests/diffusion/models/flux2/test_pipeline_flux2_quantization.py @@ -0,0 +1,50 @@ +from unittest.mock import Mock, patch + +import pytest +import torch + +from vllm_omni.diffusion.models.flux2.pipeline_flux2 import ( + Flux2Pipeline, + _resolve_component_quant_config, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_resolve_component_quant_config_routes_components(): + text_encoder_config = object() + component_config = Mock() + component_config.resolve.side_effect = { + "text_encoder": text_encoder_config, + "transformer": None, + }.get + + assert _resolve_component_quant_config(component_config, "text_encoder") is text_encoder_config + assert _resolve_component_quant_config(component_config, "transformer") is None + + +def test_resolve_component_quant_config_preserves_global_config(): + global_config = object() + assert _resolve_component_quant_config(global_config, "transformer") is global_config + assert _resolve_component_quant_config(None, "text_encoder") is None + + +def test_offload_transformer_before_text_encoder_weights(): + pipeline = object.__new__(Flux2Pipeline) + pipeline.transformer = Mock() + weights = [ + ("transformer.layer.weight", torch.empty(1)), + ("text_encoder.layer.weight", torch.empty(1)), + ("text_encoder.layer.bias", torch.empty(1)), + ] + + iterator = iter(pipeline._offload_transformer_before_text_encoder(weights)) + assert next(iterator)[0] == "transformer.layer.weight" + pipeline.transformer.to.assert_not_called() + + with patch("vllm_omni.diffusion.models.flux2.pipeline_flux2.current_omni_platform.empty_cache") as empty_cache: + assert next(iterator)[0] == "text_encoder.layer.weight" + assert next(iterator)[0] == "text_encoder.layer.bias" + + pipeline.transformer.to.assert_called_once_with("cpu") + empty_cache.assert_called_once_with() diff --git a/tests/diffusion/models/hunyuan_image3/test_hunyuan_fused_moe.py b/tests/diffusion/models/hunyuan_image3/test_hunyuan_fused_moe.py deleted file mode 100644 index e8ade2e51..000000000 --- a/tests/diffusion/models/hunyuan_image3/test_hunyuan_fused_moe.py +++ /dev/null @@ -1,197 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for HunyuanFusedMoE (Support HunyuanImage3 Diffusion Model, 5a779b4).""" - -import pytest - -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] - - -class TestSetForwardContextNumTokens: - """Test _set_forward_context_num_tokens defensive fix for vLLM 0.18.0.""" - - def test_sets_num_tokens_when_context_available(self, mocker): - """num_tokens should be set on ForwardContext when available.""" - import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe - - mock_ctx = mocker.MagicMock() - del mock_ctx.in_profile_run # simulate missing attr - mocker.patch.object(hunyuan_moe._vllm_fc, "is_forward_context_available", return_value=True) - mocker.patch.object(hunyuan_moe._vllm_fc, "get_forward_context", return_value=mock_ctx) - - hunyuan_moe._set_forward_context_num_tokens(1024) - - assert mock_ctx.num_tokens == 1024 - assert mock_ctx.in_profile_run is False - - def test_sets_in_profile_run_only_if_missing(self, mocker): - """in_profile_run should not be overwritten if already set.""" - import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe - - mock_ctx = mocker.MagicMock() - mock_ctx.in_profile_run = True # already set - mocker.patch.object(hunyuan_moe._vllm_fc, "is_forward_context_available", return_value=True) - mocker.patch.object(hunyuan_moe._vllm_fc, "get_forward_context", return_value=mock_ctx) - - hunyuan_moe._set_forward_context_num_tokens(512) - - assert mock_ctx.num_tokens == 512 - assert mock_ctx.in_profile_run is True # not overwritten - - def test_noop_when_context_unavailable(self, mocker): - """Should do nothing when ForwardContext is not available.""" - import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe - - mocker.patch.object(hunyuan_moe._vllm_fc, "is_forward_context_available", return_value=False) - mock_get = mocker.patch.object(hunyuan_moe._vllm_fc, "get_forward_context") - - hunyuan_moe._set_forward_context_num_tokens(256) - - mock_get.assert_not_called() - - -class TestSetForwardContextDPMetadata: - """Test diffusion MoE DP metadata setup for vLLM 0.24 all2all dispatch.""" - - def test_sets_dp_metadata_from_vllm_dp_group(self, mocker): - import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe - - mock_ctx = mocker.MagicMock() - mock_ctx.dp_metadata = None - fake_group = mocker.MagicMock() - fake_group.world_size = 2 - fake_group.cpu_group = object() - - def _all_gather_object(output, obj, group): - assert group is fake_group.cpu_group - output[:] = [obj, obj + 1] - - mocker.patch.object(hunyuan_moe._vllm_fc, "is_forward_context_available", return_value=True) - mocker.patch.object(hunyuan_moe._vllm_fc, "get_forward_context", return_value=mock_ctx) - mocker.patch.object(hunyuan_moe._vllm_ps, "_DP", fake_group, create=True) - mocker.patch.object(hunyuan_moe.torch.distributed, "all_gather_object", side_effect=_all_gather_object) - - hunyuan_moe._set_forward_context_dp_metadata(1024) - - assert mock_ctx.dp_metadata is not None - assert mock_ctx.dp_metadata.num_tokens_across_dp_cpu.tolist() == [1024, 1025] - - def test_keeps_existing_dp_metadata(self, mocker): - import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe - - existing_metadata = object() - mock_ctx = mocker.MagicMock() - mock_ctx.dp_metadata = existing_metadata - mocker.patch.object(hunyuan_moe._vllm_fc, "is_forward_context_available", return_value=True) - mocker.patch.object(hunyuan_moe._vllm_fc, "get_forward_context", return_value=mock_ctx) - mock_all_gather = mocker.patch.object(hunyuan_moe.torch.distributed, "all_gather_object") - - hunyuan_moe._set_forward_context_dp_metadata(1024) - - assert mock_ctx.dp_metadata is existing_metadata - mock_all_gather.assert_not_called() - - -class TestHunyuanFusedMoEPlatformDispatch: - """Test platform dispatch via platform qualname hooks.""" - - def test_default_platform_uses_default_impl_qualname(self, mocker): - """HunyuanFusedMoE should resolve the impl class from the platform hook.""" - import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe - - mock_platform = mocker.MagicMock() - mock_platform.get_diffusion_model_impl_qualname.return_value = ( - "vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe.HunyuanFusedMoEDefault" - ) - - mocker.patch.object( - hunyuan_moe, - "current_omni_platform", - mock_platform, - ) - mock_resolve = mocker.patch.object(hunyuan_moe, "resolve_obj_by_qualname") - mock_impl = mocker.MagicMock() - mock_resolve.return_value = mock_impl - - from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import ( - HunyuanFusedMoE, - ) - - HunyuanFusedMoE(prefix="") - - mock_platform.prepare_diffusion_op_runtime.assert_called_once_with("hunyuan_fused_moe") - mock_platform.get_diffusion_model_impl_qualname.assert_called_once_with("hunyuan_fused_moe") - mock_resolve.assert_called_once_with( - "vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe.HunyuanFusedMoEDefault" - ) - mock_impl.assert_called_once_with(prefix="") - - -class TestHunyuanFusedMoEFactory: - """Test HunyuanFusedMoE factory __new__ and make_expert_params_mapping delegation.""" - - def test_new_delegates_to_impl_class(self, mocker): - """HunyuanFusedMoE(prefix=..., **kwargs) should instantiate and return impl instance.""" - import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe - - class MockImpl: - def __init__(self, *, prefix: str = "", **kwargs): - self.prefix = prefix - self.kwargs = kwargs - - mock_platform = mocker.MagicMock() - mock_platform.get_diffusion_model_impl_qualname.return_value = "mock.impl.Qualname" - mocker.patch.object(hunyuan_moe, "current_omni_platform", mock_platform) - - mock_impl_class = mocker.MagicMock(return_value=MockImpl(prefix="test", a=1)) - mocker.patch.object(hunyuan_moe, "resolve_obj_by_qualname", return_value=mock_impl_class) - - from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import ( - HunyuanFusedMoE, - ) - - result = HunyuanFusedMoE(prefix="test", a=1) - - assert isinstance(result, MockImpl) - assert result.prefix == "test" - assert result.kwargs == {"a": 1} - mock_platform.prepare_diffusion_op_runtime.assert_called_once_with("hunyuan_fused_moe") - mock_platform.get_diffusion_model_impl_qualname.assert_called_once_with("hunyuan_fused_moe") - mock_impl_class.assert_called_once_with(prefix="test", a=1) - - def test_make_expert_params_mapping_delegates_to_impl(self, mocker): - """make_expert_params_mapping should delegate to impl class method.""" - import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe - - expected_mapping = [("a", "b", 0, "c")] - mock_platform = mocker.MagicMock() - mock_platform.get_diffusion_model_impl_qualname.return_value = "mock.impl.Qualname" - mocker.patch.object(hunyuan_moe, "current_omni_platform", mock_platform) - - mock_impl_class = mocker.MagicMock() - mock_impl_class.make_expert_params_mapping = mocker.MagicMock(return_value=expected_mapping) - mocker.patch.object(hunyuan_moe, "resolve_obj_by_qualname", return_value=mock_impl_class) - - from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import ( - HunyuanFusedMoE, - ) - - result = HunyuanFusedMoE.make_expert_params_mapping( - model=None, - ckpt_gate_proj_name="gate", - ckpt_down_proj_name="down", - ckpt_up_proj_name="up", - num_experts=4, - num_redundant_experts=0, - ) - - assert result == expected_mapping - mock_platform.get_diffusion_model_impl_qualname.assert_called_once_with("hunyuan_fused_moe") - mock_impl_class.make_expert_params_mapping.assert_called_once_with( - None, - ckpt_gate_proj_name="gate", - ckpt_down_proj_name="down", - ckpt_up_proj_name="up", - num_experts=4, - num_redundant_experts=0, - ) diff --git a/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_step_execution.py b/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_step_execution.py index b4f1e7493..d9d310ee4 100644 --- a/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_step_execution.py +++ b/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_step_execution.py @@ -20,7 +20,7 @@ from vllm_omni.diffusion.models.hunyuan_image3.pipeline_hunyuan_image3 import ( ) from vllm_omni.diffusion.worker.input_batch import InputBatch from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch -from vllm_omni.diffusion.worker.utils import DiffusionRequestState +from vllm_omni.diffusion.worker.utils import StepRequestState pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] @@ -38,8 +38,8 @@ def _pipeline(): return pipeline -def _state(request_id: str, step_index: int) -> DiffusionRequestState: - state = DiffusionRequestState( +def _state(request_id: str, step_index: int) -> StepRequestState: + state = StepRequestState( request_id=request_id, sampling=SimpleNamespace(), prompt="prompt", @@ -133,7 +133,7 @@ def test_prepare_encode_preserves_normal_hunyuan_bot_task_semantics( monkeypatch.setattr(hy3_module, "get_system_prompt", fake_get_system_prompt) pipeline.prepare_model_inputs = fake_prepare_model_inputs - state = DiffusionRequestState( + state = StepRequestState( request_id="req-bot-task", sampling=sampling, prompt=prompt_item, diff --git a/tests/diffusion/models/hunyuan_image3/test_image_kv_cache_manager.py b/tests/diffusion/models/hunyuan_image3/test_image_kv_cache_manager.py index 8a35d8aaa..543b93a25 100644 --- a/tests/diffusion/models/hunyuan_image3/test_image_kv_cache_manager.py +++ b/tests/diffusion/models/hunyuan_image3/test_image_kv_cache_manager.py @@ -45,6 +45,8 @@ def patched_mgr_env(sp_size=1): target = _TRANSFORMER_MODULE patches = [ patch(f"{target}.get_sequence_parallel_world_size", return_value=sp_size), + patch(f"{target}.get_allgather_parallel_world_size", return_value=sp_size), + patch(f"{target}.get_ulysses_parallel_world_size", return_value=1, create=True), patch(f"{target}.get_sequence_parallel_rank", return_value=0), patch(f"{target}.Attention", MockAttention), ] diff --git a/tests/gr00t/__init__.py b/tests/diffusion/models/lingbot_video/__init__.py similarity index 100% rename from tests/gr00t/__init__.py rename to tests/diffusion/models/lingbot_video/__init__.py diff --git a/tests/diffusion/models/lingbot_video/test_lingbot_video_moe_cuda.py b/tests/diffusion/models/lingbot_video/test_lingbot_video_moe_cuda.py new file mode 100644 index 000000000..5ea19948b --- /dev/null +++ b/tests/diffusion/models/lingbot_video/test_lingbot_video_moe_cuda.py @@ -0,0 +1,183 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +import torch +import torch.nn.functional as F + +from tests.helpers.mark import hardware_test + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion] + + +def _upstream_sparse_moe_reference(block, hidden_states, padding_mask=None): + """Independent eager reference for the upstream LingBot sparse MoE path.""" + batch_size, _, hidden_size = hidden_states.shape + tokens = hidden_states.reshape(-1, hidden_size) + + logits = F.linear(tokens.float(), block.router.weight.float()) + if block.router.score_func == "softmax": + scores = F.softmax(logits, dim=-1) + else: + scores = logits.sigmoid() + scores_for_choice = scores + block.router.e_score_correction_bias.unsqueeze(0) + + if block.router.n_group is not None and block.router.n_group > 1: + experts_per_group = block.router.num_experts // block.router.n_group + grouped = scores_for_choice.view(-1, block.router.n_group, experts_per_group) + group_scores = grouped.topk(2, dim=-1)[0].sum(dim=-1) + group_idx = torch.topk( + group_scores, + k=block.router.topk_group, + dim=-1, + sorted=False, + )[1] + group_mask = torch.zeros_like(group_scores) + group_mask.scatter_(1, group_idx, 1) + score_mask = group_mask.unsqueeze(-1).expand_as(grouped).reshape_as(scores_for_choice) + scores_for_choice = scores_for_choice.masked_fill(~score_mask.bool(), float("-inf")) + + top_indices = torch.topk( + scores_for_choice, + k=block.router.top_k, + dim=-1, + sorted=False, + )[1] + top_scores = scores.gather(1, top_indices) + if block.router.top_k > 1 and block.router.norm_topk_prob: + top_scores = top_scores / (top_scores.sum(dim=-1, keepdim=True) + 1e-20) + top_scores = top_scores.to(tokens.dtype) * block.router.route_scale + + if padding_mask is not None: + mask = padding_mask.unsqueeze(-1).to(top_scores.dtype) + top_scores = top_scores * mask + top_scores = top_scores / (top_scores.sum(dim=-1, keepdim=True) + 1e-9) + top_scores = top_scores * block.router.route_scale + + routed = torch.zeros( + tokens.shape, + dtype=torch.float32, + device=tokens.device, + ) + for expert_idx in range(block.router.num_experts): + token_idx, route_idx = torch.where(top_indices == expert_idx) + if token_idx.numel() == 0: + continue + expert_tokens = tokens[token_idx] + h = F.silu(expert_tokens @ block.experts.w1[expert_idx].transpose(-2, -1)) + h = h * (expert_tokens @ block.experts.w3[expert_idx].transpose(-2, -1)) + expert_output = h @ block.experts.w2[expert_idx].transpose(-2, -1) + weighted = expert_output.float() * top_scores[token_idx, route_idx, None].float() + routed.index_add_(0, token_idx, weighted) + + output = routed.to(tokens.dtype).reshape(batch_size, -1, hidden_size) + if block.shared_experts is not None: + shared = F.linear(hidden_states, block.shared_experts.gate_proj.weight) + shared = F.silu(shared) * F.linear(hidden_states, block.shared_experts.up_proj.weight) + shared = F.linear(shared, block.shared_experts.down_proj.weight) + output = output + shared + return output, top_indices, top_scores + + +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_grouped_mm_matches_per_expert_reference() -> None: + from vllm_omni.diffusion.models.lingbot_video import lingbot_video_transformer as module + + if not hasattr(torch, "_grouped_mm"): + pytest.skip("torch._grouped_mm is unavailable") + + torch.manual_seed(42) + block = module.LingBotVideoSparseMoeBlock( + hidden_size=16, + num_experts=4, + top_k=2, + moe_intermediate_size=8, + score_func="sigmoid", + norm_topk_prob=True, + n_group=None, + topk_group=None, + routed_scaling_factor=1.0, + n_shared_experts=None, + ).to(device="cuda", dtype=torch.bfloat16) + with torch.no_grad(): + block.experts.w1.normal_(mean=0.0, std=0.02) + block.experts.w2.normal_(mean=0.0, std=0.02) + block.experts.w3.normal_(mean=0.0, std=0.02) + tokens = torch.randn(23, 16, device="cuda", dtype=torch.bfloat16) + counts = torch.tensor([5, 0, 9, 9], device="cuda", dtype=torch.int64) + + expected = block._run_experts_for_loop(tokens, counts) + actual = block._run_grouped_experts(tokens, counts) + + torch.testing.assert_close(actual, expected) + + +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_sparse_moe_block_matches_upstream_reference() -> None: + from vllm_omni.diffusion.models.lingbot_video import lingbot_video_transformer as module + + if not hasattr(torch, "_grouped_mm"): + pytest.skip("torch._grouped_mm is unavailable") + + torch.manual_seed(42) + block = module.LingBotVideoSparseMoeBlock( + hidden_size=16, + num_experts=8, + top_k=2, + moe_intermediate_size=8, + score_func="sigmoid", + norm_topk_prob=True, + n_group=4, + topk_group=1, + routed_scaling_factor=1.5, + n_shared_experts=1, + ) + with torch.no_grad(): + block.router.weight.zero_() + block.router.e_score_correction_bias.copy_(torch.tensor([0.9, 0.8, 1.0, 0.0, 0.7, 0.6, 0.5, 0.4])) + for parameter in ( + block.experts.w1, + block.experts.w2, + block.experts.w3, + block.shared_experts.gate_proj.weight, + block.shared_experts.up_proj.weight, + block.shared_experts.down_proj.weight, + ): + parameter.normal_(mean=0.0, std=0.02) + block = block.to(device="cuda", dtype=torch.bfloat16) + + hidden_states = torch.randn(2, 5, 16, device="cuda", dtype=torch.bfloat16) + padding_mask = torch.tensor( + [1, 1, 1, 1, 1, 1, 1, 1, 0, 0], + device="cuda", + dtype=torch.float32, + ) + + with torch.no_grad(): + expected, expected_indices, expected_scores = _upstream_sparse_moe_reference( + block, + hidden_states, + padding_mask, + ) + actual_indices, actual_scores = block.router(hidden_states.reshape(-1, 16)) + actual_scores = actual_scores * padding_mask.unsqueeze(-1).to(actual_scores.dtype) + actual_scores = actual_scores / (actual_scores.sum(dim=-1, keepdim=True) + 1e-9) + actual_scores = actual_scores * block.router.route_scale + actual = block(hidden_states, padding_mask=padding_mask) + + corrected_scores = ( + torch.full( + (8,), + 0.5, + device="cuda", + ) + + block.router.e_score_correction_bias + ) + unrestricted_indices = torch.topk(corrected_scores, k=2, sorted=False)[1] + + assert set(actual_indices[0].tolist()) == {0, 1} + assert set(unrestricted_indices.tolist()) == {0, 2} + assert torch.equal(actual_indices, expected_indices) + torch.testing.assert_close(actual_scores, expected_scores, rtol=0, atol=0) + torch.testing.assert_close(actual, expected, rtol=0, atol=0) + assert torch.isfinite(actual).all() diff --git a/tests/diffusion/models/lingbot_video/test_lingbot_video_transformer.py b/tests/diffusion/models/lingbot_video/test_lingbot_video_transformer.py new file mode 100644 index 000000000..e9fad3825 --- /dev/null +++ b/tests/diffusion/models/lingbot_video/test_lingbot_video_transformer.py @@ -0,0 +1,266 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +import torch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +def _tiny_transformer(**overrides): + from vllm_omni.diffusion.models.lingbot_video import LingBotVideoTransformer3DModel + + config = { + "patch_size": (1, 1, 1), + "in_channels": 2, + "out_channels": 2, + "hidden_size": 16, + "num_attention_heads": 1, + "depth": 0, + "intermediate_size": 32, + "text_dim": 8, + "freq_dim": 8, + "axes_dims": (4, 4, 8), + "axes_lens": (32, 32, 32), + } + config.update(overrides) + return LingBotVideoTransformer3DModel(**config) + + +def test_joint_position_ids_video_then_text_order(): + from vllm_omni.diffusion.models.lingbot_video.lingbot_video_transformer import make_joint_position_ids + + positions = make_joint_position_ids(text_len=3, grid_t=1, grid_h=2, grid_w=2, device=torch.device("cpu")) + + assert positions.shape == (7, 3) + assert positions[:4, 0].tolist() == [4, 4, 4, 4] + assert positions[:4, 1:].tolist() == [[0, 0], [0, 1], [1, 0], [1, 1]] + assert positions[4:].tolist() == [[1, 0, 0], [2, 0, 0], [3, 0, 0]] + + +def test_tiny_transformer_depth_zero_forward_shape(): + model = _tiny_transformer() + hidden_states = torch.randn(1, 2, 1, 2, 2) + timestep = torch.tensor([300.0]) + encoder_hidden_states = torch.randn(1, 3, 8) + encoder_attention_mask = torch.ones(1, 3, dtype=torch.long) + + with torch.no_grad(): + out = model( + hidden_states, + timestep, + encoder_hidden_states, + encoder_attention_mask=encoder_attention_mask, + return_dict=False, + )[0] + + assert out.shape == hidden_states.shape + assert torch.isfinite(out).all() + + +def test_packed_attention_uses_sdpa_fallback_without_flash_varlen(monkeypatch): + from vllm_omni.diffusion.models.lingbot_video import lingbot_video_transformer as module + + monkeypatch.setattr(module, "flash_attn_varlen_func_v3", None) + attn = module.LingBotVideoAttention( + hidden_size=8, + num_heads=2, + norm_eps=1e-6, + qkv_bias=False, + out_bias=False, + ) + captured = {} + + def fake_sdpa_forward(query, key, value, attn_metadata): + captured["mask"] = attn_metadata.attn_mask + return torch.zeros_like(query) + + monkeypatch.setattr(attn.attn.sdpa_fallback, "forward", fake_sdpa_forward) + x = torch.randn(1, 5, 8) + rotary = torch.ones(1, 5, 2, dtype=torch.complex64) + packed_indices = { + "cu_seqlens_kv": torch.tensor([0, 2, 5], dtype=torch.int32), + "max_seqlen_in_batch_kv": 3, + "attention_mask": module._packed_block_attention_mask([2, 3], x.device), + } + + out = attn(x, rotary, packed_indices=packed_indices) + + assert out.shape == x.shape + mask = captured["mask"] + assert mask.shape == (1, 1, 5, 5) + assert mask[0, 0, :2, :2].all() + assert mask[0, 0, 2:, 2:].all() + assert not mask[0, 0, :2, 2:].any() + assert not mask[0, 0, 2:, :2].any() + + +def test_tiny_transformer_rejects_invalid_rope_dims(): + from vllm_omni.diffusion.models.lingbot_video import LingBotVideoTransformer3DModel + + with pytest.raises(AssertionError, match="head_dim"): + LingBotVideoTransformer3DModel( + hidden_size=16, + num_attention_heads=1, + axes_dims=(4, 4, 4), + depth=0, + ) + + +def test_transformer_to_keeps_sensitive_modules_in_fp32(): + model = _tiny_transformer() + + model.to(dtype=torch.bfloat16) + + assert model.patch_embedder.weight.dtype == torch.bfloat16 + assert model.time_embedder.linear_1.weight.dtype == torch.float32 + assert model.norm_out_modulation[1].weight.dtype == torch.float32 + + +def test_router_group_limited_topk_uses_bias_corrected_choice(): + from vllm_omni.diffusion.models.lingbot_video import lingbot_video_transformer as module + + router = module.LingBotVideoRouter( + hidden_size=2, + num_experts=4, + top_k=2, + score_func="sigmoid", + norm_topk_prob=True, + n_group=2, + topk_group=1, + route_scale=2.5, + ) + with torch.no_grad(): + router.weight.copy_( + torch.tensor( + [ + [8.0, 0.0], + [7.0, 0.0], + [-8.0, 0.0], + [-7.0, 0.0], + ] + ) + ) + router.e_score_correction_bias.copy_(torch.tensor([0.0, 0.0, 2.0, 2.0])) + + top_indices, top_scores = router(torch.tensor([[1.0, 0.0]])) + + assert set(top_indices[0].tolist()) == {2, 3} + assert torch.allclose(top_scores.sum(dim=-1), torch.tensor([2.5])) + low_score, high_score = sorted(top_scores[0].tolist()) + assert low_score < 0.8 + assert high_score > 1.7 + + +def test_sparse_moe_block_masks_padding_tokens(): + from vllm_omni.diffusion.models.lingbot_video import lingbot_video_transformer as module + + block = module.LingBotVideoSparseMoeBlock( + hidden_size=4, + num_experts=2, + top_k=1, + moe_intermediate_size=3, + score_func="sigmoid", + norm_topk_prob=True, + n_group=None, + topk_group=None, + routed_scaling_factor=1.0, + n_shared_experts=None, + ) + with torch.no_grad(): + block.router.weight.copy_( + torch.tensor( + [ + [8.0, 0.0, 0.0, 0.0], + [-8.0, 0.0, 0.0, 0.0], + ] + ) + ) + block.router.e_score_correction_bias.zero_() + block.experts.w1.fill_(0.5) + block.experts.w2.fill_(0.5) + block.experts.w3.fill_(0.5) + + hidden_states = torch.tensor([[[1.0, 0.0, 0.0, 0.0], [-1.0, 0.0, 0.0, 0.0]]]) + padding_mask = torch.tensor([1.0, 0.0]) + + out = block(hidden_states, padding_mask=padding_mask) + + assert out.shape == hidden_states.shape + assert torch.isfinite(out).all() + assert not torch.allclose(out[0, 0], torch.zeros_like(out[0, 0])) + assert torch.allclose(out[0, 1], torch.zeros_like(out[0, 1])) + + +@pytest.mark.parametrize( + "counts_list", + [ + [0, 0, 0, 0], + [1, 0, 7, 8, 9], + [16, 0, 1, 0], + [0, 0, 33, 0], + ], +) +def test_grouped_padding_matches_reference(counts_list): + from vllm_omni.diffusion.models.lingbot_video import lingbot_video_transformer as module + + align = 8 + counts = torch.tensor(counts_list, dtype=torch.int64) + num_tokens = int(counts.sum()) + tokens = torch.arange(num_tokens * 4, dtype=torch.float32).reshape(num_tokens, 4) + + actual = module.LingBotVideoSparseMoeBlock._pad_grouped_tokens( + tokens, + counts, + align, + ) + + num_experts = len(counts_list) + max_len = ((num_tokens + num_experts * align + align - 1) // align) * align + aligned_counts = [((max(count, align) + align - 1) // align) * align for count in counts_list] + expected_indices = [num_tokens] * max_len + source_start = 0 + write_start = 0 + for count, aligned_count in zip(counts_list, aligned_counts): + expected_indices[write_start : write_start + count] = range( + source_start, + source_start + count, + ) + source_start += count + write_start += aligned_count + + expected_indices_tensor = torch.tensor(expected_indices, dtype=torch.int64) + tokens_with_pad = torch.vstack((tokens, tokens.new_zeros((tokens.shape[-1],)))) + expected_aligned_counts = torch.tensor(aligned_counts, dtype=torch.int32) + + assert actual[0] == tokens_with_pad.shape + torch.testing.assert_close(actual[1], tokens_with_pad[expected_indices_tensor]) + torch.testing.assert_close(actual[2], expected_indices_tensor) + torch.testing.assert_close(actual[3], expected_aligned_counts) + + +def test_tiny_transformer_constructs_moe_and_dense_layers(): + from vllm_omni.diffusion.models.lingbot_video import lingbot_video_transformer as module + + model = _tiny_transformer( + depth=2, + num_experts=4, + num_experts_per_tok=2, + moe_intermediate_size=8, + decoder_sparse_step=1, + mlp_only_layers=(1,), + n_shared_experts=1, + n_group=2, + topk_group=1, + routed_scaling_factor=2.5, + ) + + assert isinstance(model.blocks[0].ffn, module.LingBotVideoSparseMoeBlock) + assert isinstance(model.blocks[1].ffn, module.LingBotVideoMLP) + assert "blocks.0.ffn.experts.w1" in model.state_dict() + assert "blocks.0.ffn.shared_experts.gate_proj.weight" in model.state_dict() + + model.to(dtype=torch.bfloat16) + + assert model.blocks[0].ffn.router.weight.dtype == torch.float32 + assert model.blocks[0].ffn.experts.w1.dtype == torch.bfloat16 diff --git a/tests/diffusion/models/lingbot_video/test_pipeline_lingbot_video.py b/tests/diffusion/models/lingbot_video/test_pipeline_lingbot_video.py new file mode 100644 index 000000000..1322a542f --- /dev/null +++ b/tests/diffusion/models/lingbot_video/test_pipeline_lingbot_video.py @@ -0,0 +1,219 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from types import SimpleNamespace + +import numpy as np +import pytest +import torch +from torch import nn + +from vllm_omni.diffusion.request import OmniDiffusionRequest +from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch +from vllm_omni.inputs.data import OmniDiffusionSamplingParams + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +def _make_pipeline(): + from vllm_omni.diffusion.models.lingbot_video.pipeline_lingbot_video import LingBotVideoPipeline + + pipeline = object.__new__(LingBotVideoPipeline) + nn.Module.__init__(pipeline) + pipeline.device = torch.device("cpu") + pipeline.default_negative_prompt = "default negative" + pipeline.od_config = SimpleNamespace(flow_shift=None) + return pipeline + + +def _make_request_batch(prompt, **sampling_overrides): + sampling = OmniDiffusionSamplingParams(**sampling_overrides) + request = OmniDiffusionRequest(prompt=prompt, sampling_params=sampling, request_id="lingbot-test") + return DiffusionRequestBatch([request]) + + +def test_lingbot_video_pipeline_import_and_registry(): + from vllm_omni.diffusion.models.lingbot_video import ( + LingBotVideoPipeline, + LingBotVideoTransformer3DModel, + get_lingbot_video_post_process_func, + ) + from vllm_omni.diffusion.registry import _DIFFUSION_MODELS, _DIFFUSION_POST_PROCESS_FUNCS + + assert LingBotVideoPipeline is not None + assert LingBotVideoTransformer3DModel is not None + assert get_lingbot_video_post_process_func is not None + assert _DIFFUSION_MODELS["LingBotVideoPipeline"] == ( + "lingbot_video", + "pipeline_lingbot_video", + "LingBotVideoPipeline", + ) + assert _DIFFUSION_POST_PROCESS_FUNCS["LingBotVideoPipeline"] == "get_lingbot_video_post_process_func" + + +def test_component_discovery_declarations(): + from vllm_omni.diffusion.models.lingbot_video import LingBotVideoPipeline + + assert LingBotVideoPipeline._dit_modules == ["transformer"] + assert LingBotVideoPipeline._encoder_modules == ["text_encoder"] + assert LingBotVideoPipeline._vae_modules == ["vae"] + assert LingBotVideoPipeline.supports_step_execution is False + + +def test_extra_body_params_include_video_flow_shift_alias(): + from vllm_omni.model_extras import get_extra_body_params + + params = get_extra_body_params("LingBotVideoPipeline") + assert "shift" in params + assert "flow_shift" in params + assert "batch_cfg" in params + + +def test_extract_prompt_string_and_mapping(): + from vllm_omni.diffusion.models.lingbot_video.pipeline_lingbot_video import _extract_prompt + + string_req = SimpleNamespace(prompt="a robot waves") + assert _extract_prompt(string_req) == ("a robot waves", None) + + mapping_req = SimpleNamespace(prompt={"prompt": "a robot walks", "negative_prompt": "low quality"}) + assert _extract_prompt(mapping_req) == ("a robot walks", "low quality") + + with pytest.raises(TypeError, match="Unsupported LingBot prompt type"): + _extract_prompt(SimpleNamespace(prompt=["not", "supported"])) + + +def test_check_inputs_accepts_t2v_shapes_and_rejects_invalid_values(): + from vllm_omni.diffusion.models.lingbot_video import LingBotVideoPipeline + + LingBotVideoPipeline.check_inputs(height=192, width=320, num_frames=1) + LingBotVideoPipeline.check_inputs(height=192, width=320, num_frames=9) + + with pytest.raises(ValueError, match="num_frames"): + LingBotVideoPipeline.check_inputs(height=192, width=320, num_frames=8) + with pytest.raises(ValueError, match="height"): + LingBotVideoPipeline.check_inputs(height=190, width=320, num_frames=9) + + +def test_postprocess_keeps_torch_by_default_and_converts_np_when_requested(): + from vllm_omni.diffusion.models.lingbot_video import get_lingbot_video_post_process_func + + frames = torch.ones(2, 4, 4, 3) + post = get_lingbot_video_post_process_func(SimpleNamespace()) + + assert post(frames, SimpleNamespace(output_type="pt")) is frames + array = post(frames, SimpleNamespace(output_type="np")) + assert isinstance(array, np.ndarray) + assert array.shape == (2, 4, 4, 3) + + +def test_forward_resolves_t2v_sampling_and_flow_shift_alias(): + pipeline = _make_pipeline() + calls = [] + + def fake_generate(**kwargs): + calls.append(kwargs) + return torch.zeros(9, 192, 320, 3) + + pipeline._generate = fake_generate + req = _make_request_batch( + {"prompt": "a robot arm", "negative_prompt": "bad hands"}, + height=192, + width=320, + num_frames=9, + num_inference_steps=2, + guidance_scale=3.0, + seed=42, + output_type="pt", + extra_args={"flow_shift": 4.0, "batch_cfg": True}, + ) + + out = pipeline.forward(req) + + assert torch.equal(out.output, torch.zeros(9, 192, 320, 3)) + assert len(calls) == 1 + call = calls[0] + assert call["prompt"] == "a robot arm" + assert call["negative_prompt"] == "bad hands" + assert call["height"] == 192 + assert call["width"] == 320 + assert call["num_frames"] == 9 + assert call["num_inference_steps"] == 2 + assert call["guidance_scale"] == 3.0 + assert call["shift"] == 4.0 + assert call["batch_cfg"] is True + assert call["output_type"] == "pt" + + +def test_forward_uses_lingbot_guidance_default_when_omitted(): + pipeline = _make_pipeline() + calls = [] + + def fake_generate(**kwargs): + calls.append(kwargs) + return torch.zeros(1) + + pipeline._generate = fake_generate + req = _make_request_batch( + "a robot arm", + height=192, + width=320, + num_frames=81, + num_inference_steps=2, + seed=42, + ) + + pipeline.forward(req) + + assert calls[0]["num_frames"] == 81 + assert calls[0]["guidance_scale"] == 6.0 + + +def test_forward_prefers_shift_over_flow_shift_and_defaults_negative_prompt(): + pipeline = _make_pipeline() + calls = [] + + def fake_generate(**kwargs): + calls.append(kwargs) + return torch.zeros(5, 192, 192, 3) + + pipeline._generate = fake_generate + req = _make_request_batch( + "a robot arm", + height=192, + width=192, + num_frames=5, + num_inference_steps=2, + guidance_scale=1.0, + seed=42, + extra_args={"shift": 5.0, "flow_shift": 4.0}, + ) + + pipeline.forward(req) + + assert calls[0]["shift"] == 5.0 + assert calls[0]["negative_prompt"] == "default negative" + + +def test_forward_rejects_multi_request_batches(): + pipeline = _make_pipeline() + first = OmniDiffusionRequest( + prompt="first", + sampling_params=OmniDiffusionSamplingParams(seed=1), + request_id="lingbot-first", + ) + second = OmniDiffusionRequest( + prompt="second", + sampling_params=OmniDiffusionSamplingParams(seed=2), + request_id="lingbot-second", + ) + + with pytest.raises(ValueError, match="only supports one request"): + pipeline.forward(DiffusionRequestBatch([first, second])) + + +def test_load_weights_rejects_external_weight_stream(): + pipeline = _make_pipeline() + + assert pipeline.load_weights([]) == set() + with pytest.raises(RuntimeError, match="components are loaded directly"): + pipeline.load_weights([("transformer.weight", torch.zeros(1))]) diff --git a/tests/diffusion/models/ltx2/test_ltx2_3_image2video_pipeline.py b/tests/diffusion/models/ltx2/test_ltx2_3_image2video_pipeline.py deleted file mode 100644 index 9b883ff34..000000000 --- a/tests/diffusion/models/ltx2/test_ltx2_3_image2video_pipeline.py +++ /dev/null @@ -1,244 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -"""Unit tests for the LTX-2.3 image-to-video pipeline.""" - -from types import SimpleNamespace - -import pytest -import torch - -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] - - -def _make_ltx23_request_pipe(cls): - pipe = object.__new__(cls) - torch.nn.Module.__init__(pipe) - pipe.device = torch.device("cpu") - pipe.tokenizer_max_length = 99 - return pipe - - -class TestLTX23ImageToVideoForwardStages: - def test_forward_resolves_request_image_and_delegates_to_shared_forward_impl(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline - from vllm_omni.diffusion.request import OmniDiffusionRequest - from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch - from vllm_omni.inputs.data import OmniDiffusionSamplingParams - - pipe = _make_ltx23_request_pipe(LTX23ImageToVideoPipeline) - image = torch.zeros(3, 8, 8) - req = DiffusionRequestBatch( - [ - OmniDiffusionRequest( - prompt={ - "prompt": "make the image move", - "negative_prompt": "jitter", - "multi_modal_data": {"image": image}, - }, - sampling_params=OmniDiffusionSamplingParams( - height=384, - width=512, - num_frames=25, - num_inference_steps=2, - ), - request_id="ltx23-i2v-forward-stage-delegation", - ) - ] - ) - seen = {} - - def fake_forward_impl(req_arg, request_inputs, **kwargs): - seen["req"] = req_arg - seen["request_inputs"] = request_inputs - seen["kwargs"] = kwargs - return ["i2v-delegated"] - - object.__setattr__(pipe, "_forward_impl", fake_forward_impl) - - output = pipe.forward(req, noise_scale=0.5) - - assert output == ["i2v-delegated"] - assert seen["req"] is req - assert seen["request_inputs"].prompt == ["make the image move"] - assert seen["request_inputs"].negative_prompt == ["jitter"] - assert seen["kwargs"]["image"] is image - assert seen["kwargs"]["noise_scale"] == 0.5 - - def test_denoise_timestep_kwargs_masks_video_only(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline - - pipe = object.__new__(LTX23ImageToVideoPipeline) - ts = torch.tensor([2.0, 4.0]) - denoise_ctx = SimpleNamespace( - conditioning_mask=torch.tensor([[1.0, 0.0]]), - conditioning_mask_for_model=torch.tensor([[1.0, 0.0], [0.0, 1.0]]), - ) - - kwargs = pipe._denoise_timestep_kwargs( - ts, - SimpleNamespace(cfg_parallel_ready=False), - denoise_ctx, - ) - - torch.testing.assert_close(kwargs["timestep"], torch.tensor([[0.0, 2.0], [4.0, 0.0]])) - torch.testing.assert_close(kwargs["audio_timestep"], ts) - torch.testing.assert_close(kwargs["sigma"], ts) - - -class TestLTX23ImageToVideoPipeline: - def test_ltx23_i2v_pipeline_reuses_ltx23_semantics(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline - - assert issubclass(LTX23ImageToVideoPipeline, LTX23Pipeline) - assert LTX23ImageToVideoPipeline.support_image_input is True - - def test_ltx23_i2v_rejects_multi_image_prompt_list(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline - - image = object() - - assert LTX23ImageToVideoPipeline._resolve_single_prompt_image([image]) is image - with pytest.raises(ValueError, match="exactly one image per prompt"): - LTX23ImageToVideoPipeline._resolve_single_prompt_image([object(), object()]) - - def test_ltx23_i2v_additional_image_resolution_is_tensor_safe(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline - - image = torch.zeros(1, 3, 4, 4) - additional = { - "preprocessed_image": None, - "pixel_values": image, - "image": torch.ones_like(image), - } - - assert LTX23ImageToVideoPipeline._resolve_additional_image(additional) is image - - def test_ltx23_i2v_packed_latents_are_not_noised(self, monkeypatch): - import vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video as ltx23_i2v - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline - - pipe = object.__new__(LTX23ImageToVideoPipeline) - torch.nn.Module.__init__(pipe) - pipe.vae_spatial_compression_ratio = 1 - pipe.vae_temporal_compression_ratio = 1 - pipe.transformer_spatial_patch_size = 1 - pipe.transformer_temporal_patch_size = 1 - - def fake_randn_tensor(shape, generator=None, device=None, dtype=None): - raise AssertionError("packed I2V latents should not be noised") - - monkeypatch.setattr(ltx23_i2v, "randn_tensor", fake_randn_tensor) - - latents = torch.tensor([[[10.0, 11.0], [20.0, 21.0], [30.0, 31.0]]]) - - out, conditioning_mask = pipe.prepare_latents( - image=None, - batch_size=1, - num_channels_latents=2, - height=1, - width=1, - num_frames=3, - noise_scale=1.0, - dtype=torch.float32, - device=torch.device("cpu"), - latents=latents, - ) - - torch.testing.assert_close(conditioning_mask, torch.tensor([[1.0, 0.0, 0.0]])) - torch.testing.assert_close(out, latents) - - def test_ltx23_i2v_5d_latents_noise_preserves_conditioning_frame(self, monkeypatch): - import vllm_omni.diffusion.models.ltx2.pipeline_ltx2 as ltx2 - import vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video as ltx23_i2v - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline - - pipe = object.__new__(LTX23ImageToVideoPipeline) - torch.nn.Module.__init__(pipe) - pipe.vae_spatial_compression_ratio = 1 - pipe.vae_temporal_compression_ratio = 1 - pipe.transformer_spatial_patch_size = 1 - pipe.transformer_temporal_patch_size = 1 - pipe.vae = SimpleNamespace( - latents_mean=torch.zeros(2), - latents_std=torch.ones(2), - config=SimpleNamespace(scaling_factor=1.0), - ) - - def fake_randn_tensor(shape, generator=None, device=None, dtype=None): - return torch.ones(shape, device=device, dtype=dtype) - - monkeypatch.setattr(ltx23_i2v, "randn_tensor", fake_randn_tensor) - monkeypatch.setattr(ltx2, "randn_tensor", fake_randn_tensor) - - latents = torch.tensor([[[[[10.0]], [[20.0]], [[30.0]]], [[[11.0]], [[21.0]], [[31.0]]]]]) - - out, conditioning_mask = pipe.prepare_latents( - image=None, - batch_size=1, - num_channels_latents=2, - height=1, - width=1, - num_frames=3, - noise_scale=1.0, - dtype=torch.float32, - device=torch.device("cpu"), - latents=latents, - ) - - torch.testing.assert_close(conditioning_mask, torch.tensor([[1.0, 0.0, 0.0]])) - torch.testing.assert_close(out, torch.tensor([[[10.0, 11.0], [1.0, 1.0], [1.0, 1.0]]])) - - def test_ltx23_i2v_video_step_preserves_conditioning_frame(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline - - pipe = object.__new__(LTX23ImageToVideoPipeline) - torch.nn.Module.__init__(pipe) - pipe.transformer_spatial_patch_size = 1 - pipe.transformer_temporal_patch_size = 1 - - class FakeScheduler: - def step(self, noise_pred, t, latents, return_dict=False): - return (latents + noise_pred + t,) - - pipe.scheduler = FakeScheduler() - latents = torch.tensor([[[1.0], [2.0], [3.0]]]) - noise_pred = torch.full_like(latents, 10.0) - - out = pipe._step_video_latents_i2v( - noise_pred, - latents, - torch.tensor(0.5), - latent_num_frames=3, - latent_height=1, - latent_width=1, - ) - - torch.testing.assert_close(out[:, :1], latents[:, :1]) - torch.testing.assert_close(out[:, 1:], latents[:, 1:] + noise_pred[:, 1:] + 0.5) - - -class TestLTX23ImageToVideoModule: - """Test that pipeline_ltx2_3_image2video.py exposes I2V entry points.""" - - def test_i2v_classes_importable(self): - """I2V classes must be importable from the implementation module.""" - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline - - assert LTX23ImageToVideoPipeline is not None - - def test_post_process_func_importable(self): - """get_ltx2_post_process_func must be importable for registry lookup.""" - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import get_ltx2_post_process_func - - assert callable(get_ltx2_post_process_func) - - def test_i2v_class_matches_package_export(self): - """Package export must point at the I2V implementation module.""" - from vllm_omni.diffusion.models.ltx2 import LTX23ImageToVideoPipeline as PackageExported - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import ( - LTX23ImageToVideoPipeline as ModuleExported, - ) - - assert PackageExported is ModuleExported diff --git a/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py b/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py deleted file mode 100644 index 25aae4510..000000000 --- a/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py +++ /dev/null @@ -1,646 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -"""Unit tests for LTX-2.3 pipeline integration. - -These tests verify: -- Pipeline is properly registered in the diffusion registry -- Post-process function is registered -- Cache-DiT enablers are registered -- Pipeline does NOT inherit from LTX2Pipeline -- Vocoder sample rate detection logic -- Re-export module works correctly -""" - -import json -import os -import tempfile -from types import SimpleNamespace - -import pytest -import torch - -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] - - -def _make_ltx23_pipeline(sequence_parallel_size: int = 1): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - - pipeline = object.__new__(LTX23Pipeline) - torch.nn.Module.__init__(pipeline) - pipeline.audio_vae_temporal_compression_ratio = 4 - pipeline.audio_vae_mel_compression_ratio = 4 - pipeline.od_config = SimpleNamespace(parallel_config=SimpleNamespace(sequence_parallel_size=sequence_parallel_size)) - pipeline.audio_vae = SimpleNamespace( - latents_mean=torch.tensor(0.0), - latents_std=torch.tensor(1.0), - ) - return pipeline - - -def _make_ltx23_request_pipe(cls): - pipe = object.__new__(cls) - torch.nn.Module.__init__(pipe) - pipe.device = torch.device("cpu") - pipe.tokenizer_max_length = 99 - return pipe - - -def _resolve_request_inputs_for_test(pipe, req): - return pipe._resolve_request_inputs( - req, - prompt=None, - negative_prompt=None, - height=None, - width=None, - num_frames=None, - frame_rate=None, - num_inference_steps=None, - timesteps=None, - guidance_scale=4.0, - num_videos_per_prompt=1, - generator=None, - latents=None, - audio_latents=None, - prompt_embeds=None, - negative_prompt_embeds=None, - prompt_attention_mask=None, - negative_prompt_attention_mask=None, - decode_timestep=0.0, - decode_noise_scale=None, - output_type="np", - max_sequence_length=None, - ) - - -class TestLTX23RequestParsing: - def test_t2v_and_i2v_share_request_input_resolution(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline - from vllm_omni.diffusion.request import OmniDiffusionRequest - from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch - from vllm_omni.inputs.data import OmniDiffusionSamplingParams - - prompt_embeds = torch.tensor([[1.0, 2.0]]) - negative_prompt_embeds = torch.tensor([[3.0, 4.0]]) - prompt_attention_mask = torch.tensor([True, False]) - negative_attention_mask = torch.tensor([False, True]) - video_latents = torch.ones(1, 2, 3) - audio_latents = torch.zeros(1, 2, 3) - generator = torch.Generator().manual_seed(123) - - req = DiffusionRequestBatch( - [ - OmniDiffusionRequest( - prompt={ - "prompt": "shared prompt", - "negative_prompt": "shared negative", - "additional_information": { - "prompt_embeds": prompt_embeds, - "negative_prompt_embeds": negative_prompt_embeds, - "attention_mask": prompt_attention_mask, - "negative_attention_mask": negative_attention_mask, - }, - }, - sampling_params=OmniDiffusionSamplingParams( - height=384, - width=512, - num_frames=25, - frame_rate=12.5, - num_inference_steps=1, - num_outputs_per_prompt=2, - guidance_scale=6.0, - generator=generator, - latents=video_latents, - extra_args={"audio_latents": audio_latents}, - decode_timestep=[0.1], - decode_noise_scale=[0.2], - output_type="latent", - max_sequence_length=17, - ), - request_id="ltx23-shared-request-inputs", - ) - ] - ) - - resolved_t2v = _resolve_request_inputs_for_test( - _make_ltx23_request_pipe(LTX23Pipeline), - req, - ) - resolved_i2v = _resolve_request_inputs_for_test( - _make_ltx23_request_pipe(LTX23ImageToVideoPipeline), - req, - ) - - assert resolved_i2v.prompt == resolved_t2v.prompt is None - assert resolved_i2v.negative_prompt == resolved_t2v.negative_prompt is None - assert resolved_i2v.height == resolved_t2v.height == 384 - assert resolved_i2v.width == resolved_t2v.width == 512 - assert resolved_i2v.num_frames == resolved_t2v.num_frames == 25 - assert resolved_i2v.frame_rate == resolved_t2v.frame_rate == 12.5 - assert resolved_i2v.num_inference_steps == resolved_t2v.num_inference_steps == 2 - assert resolved_i2v.guidance_scale == resolved_t2v.guidance_scale == 6.0 - assert resolved_i2v.num_videos_per_prompt == resolved_t2v.num_videos_per_prompt == 2 - assert isinstance(resolved_i2v.generator, list) - assert isinstance(resolved_t2v.generator, list) - assert [gen.initial_seed() for gen in resolved_i2v.generator] == [123, 123] - assert [gen.initial_seed() for gen in resolved_t2v.generator] == [123, 123] - assert resolved_i2v.decode_timestep == resolved_t2v.decode_timestep == [0.1] - assert resolved_i2v.decode_noise_scale == resolved_t2v.decode_noise_scale == [0.2] - assert resolved_i2v.output_type == resolved_t2v.output_type == "latent" - assert resolved_i2v.max_sequence_length == resolved_t2v.max_sequence_length == 17 - torch.testing.assert_close(resolved_i2v.latents, video_latents) - torch.testing.assert_close(resolved_t2v.latents, video_latents) - torch.testing.assert_close(resolved_i2v.audio_latents, audio_latents) - torch.testing.assert_close(resolved_t2v.audio_latents, audio_latents) - torch.testing.assert_close(resolved_i2v.prompt_embeds, torch.stack([prompt_embeds])) - torch.testing.assert_close(resolved_t2v.prompt_embeds, torch.stack([prompt_embeds])) - torch.testing.assert_close(resolved_i2v.negative_prompt_embeds, torch.stack([negative_prompt_embeds])) - torch.testing.assert_close(resolved_t2v.negative_prompt_embeds, torch.stack([negative_prompt_embeds])) - torch.testing.assert_close(resolved_i2v.prompt_attention_mask, torch.stack([prompt_attention_mask])) - torch.testing.assert_close(resolved_t2v.prompt_attention_mask, torch.stack([prompt_attention_mask])) - torch.testing.assert_close( - resolved_i2v.negative_prompt_attention_mask, - torch.stack([negative_attention_mask]), - ) - torch.testing.assert_close( - resolved_t2v.negative_prompt_attention_mask, - torch.stack([negative_attention_mask]), - ) - - def test_request_input_resolution_rejects_mixed_precomputed_prompt_fields(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - from vllm_omni.diffusion.request import OmniDiffusionRequest - from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch - from vllm_omni.inputs.data import OmniDiffusionSamplingParams - - pipe = _make_ltx23_request_pipe(LTX23Pipeline) - req = DiffusionRequestBatch( - [ - OmniDiffusionRequest( - prompt={ - "prompt": "with embeds", - "additional_information": {"prompt_embeds": torch.tensor([[1.0]])}, - }, - sampling_params=OmniDiffusionSamplingParams( - height=384, - width=512, - num_frames=25, - num_inference_steps=2, - ), - request_id="ltx23-mixed-precomputed-fields-0", - ), - OmniDiffusionRequest( - prompt={"prompt": "without embeds"}, - sampling_params=OmniDiffusionSamplingParams( - height=384, - width=512, - num_frames=25, - num_inference_steps=2, - ), - request_id="ltx23-mixed-precomputed-fields-1", - ), - ] - ) - - with pytest.raises(ValueError, match="mix of provided and missing prompt_embeds"): - _resolve_request_inputs_for_test(pipe, req) - - -class TestLTX23ForwardStages: - def test_encode_prompt_repeats_precomputed_embeds_for_num_outputs(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - - pipe = _make_ltx23_request_pipe(LTX23Pipeline) - prompt_embeds = torch.tensor([[[1.0], [2.0]], [[3.0], [4.0]]]) - negative_prompt_embeds = torch.tensor([[[-1.0], [-2.0]], [[-3.0], [-4.0]]]) - prompt_attention_mask = torch.tensor([[True, False], [False, True]]) - negative_prompt_attention_mask = torch.tensor([[False, False], [True, True]]) - - ( - repeated_prompt_embeds, - repeated_prompt_attention_mask, - repeated_negative_prompt_embeds, - repeated_negative_prompt_attention_mask, - ) = pipe.encode_prompt( - prompt=None, - do_classifier_free_guidance=True, - num_videos_per_prompt=2, - prompt_embeds=prompt_embeds, - negative_prompt_embeds=negative_prompt_embeds, - prompt_attention_mask=prompt_attention_mask, - negative_prompt_attention_mask=negative_prompt_attention_mask, - device=torch.device("cpu"), - ) - - torch.testing.assert_close( - repeated_prompt_embeds, - torch.tensor([[[1.0], [2.0]], [[1.0], [2.0]], [[3.0], [4.0]], [[3.0], [4.0]]]), - ) - torch.testing.assert_close( - repeated_negative_prompt_embeds, - torch.tensor([[[-1.0], [-2.0]], [[-1.0], [-2.0]], [[-3.0], [-4.0]], [[-3.0], [-4.0]]]), - ) - torch.testing.assert_close( - repeated_prompt_attention_mask, - torch.tensor([[True, False], [True, False], [False, True], [False, True]]), - ) - torch.testing.assert_close( - repeated_negative_prompt_attention_mask, - torch.tensor([[False, False], [False, False], [True, True], [True, True]]), - ) - - def test_t2v_forward_delegates_to_shared_forward_impl(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - from vllm_omni.diffusion.request import OmniDiffusionRequest - from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch - from vllm_omni.inputs.data import OmniDiffusionSamplingParams - - pipe = _make_ltx23_request_pipe(LTX23Pipeline) - req = DiffusionRequestBatch( - [ - OmniDiffusionRequest( - prompt="stage refactor prompt", - sampling_params=OmniDiffusionSamplingParams( - height=384, - width=512, - num_frames=25, - frame_rate=16, - num_inference_steps=3, - guidance_scale=4.5, - output_type="latent", - ), - request_id="ltx23-t2v-forward-stage-delegation", - ) - ] - ) - sigmas = [1.0, 0.5] - timesteps = [1000, 500] - attention_kwargs = {"scale": 1.0} - seen = {} - - def fake_forward_impl(req_arg, request_inputs, **kwargs): - seen["req"] = req_arg - seen["request_inputs"] = request_inputs - seen["kwargs"] = kwargs - return ["delegated"] - - object.__setattr__(pipe, "_forward_impl", fake_forward_impl) - - output = pipe.forward( - req, - sigmas=sigmas, - timesteps=timesteps, - noise_scale=0.25, - attention_kwargs=attention_kwargs, - ) - - assert output == ["delegated"] - assert seen["req"] is req - assert seen["request_inputs"].height == 384 - assert seen["request_inputs"].width == 512 - assert seen["request_inputs"].num_frames == 25 - assert seen["request_inputs"].frame_rate == 16.0 - assert seen["request_inputs"].guidance_scale == 4.5 - assert seen["request_inputs"].output_type == "latent" - assert seen["kwargs"] == { - "noise_scale": 0.25, - "sigmas": sigmas, - "timesteps": timesteps, - "attention_kwargs": attention_kwargs, - } - - -class TestPipelineIndependence: - """Verify LTX23Pipeline is fully independent from LTX2Pipeline.""" - - def test_ltx23_pipeline_does_not_inherit_from_ltx2(self): - """LTX23Pipeline must NOT inherit from LTX2Pipeline.""" - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - - assert not issubclass(LTX23Pipeline, LTX2Pipeline), ( - "LTX23Pipeline should be fully independent and not inherit from LTX2Pipeline" - ) - - def test_ltx23_pipeline_is_nn_module(self): - """LTX23Pipeline must be an nn.Module.""" - import torch.nn as nn - - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - - assert issubclass(LTX23Pipeline, nn.Module) - - def test_ltx23_pipeline_has_progress_bar(self): - """LTX23Pipeline must mix in ProgressBarMixin.""" - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin - - assert issubclass(LTX23Pipeline, ProgressBarMixin) - - def test_ltx23_pipeline_declares_offload_components(self): - """LTX23Pipeline must expose LTX-2.3-specific modules to offload discovery.""" - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - from vllm_omni.diffusion.offloader.module_collector import ModuleDiscovery - - pipe = object.__new__(LTX23Pipeline) - torch.nn.Module.__init__(pipe) - pipe.transformer = torch.nn.Linear(1, 1) - pipe.text_encoder = torch.nn.Linear(1, 1) - pipe.connectors = torch.nn.Linear(1, 1) - pipe.vae = torch.nn.Linear(1, 1) - pipe.audio_vae = torch.nn.Linear(1, 1) - pipe.vocoder = torch.nn.Linear(1, 1) - - modules = ModuleDiscovery.discover(pipe) - - assert LTX23Pipeline._dit_modules == ["transformer"] - assert LTX23Pipeline._encoder_modules == ["text_encoder", "connectors"] - assert LTX23Pipeline._vae_modules == ["vae", "audio_vae"] - assert LTX23Pipeline._resident_modules == ["vocoder"] - assert modules.dit_names == ["transformer"] - assert modules.encoder_names == ["text_encoder", "connectors"] - assert modules.resident_names == ["vocoder"] - assert len(modules.vaes) == 2 - - def test_ltx23_pipeline_has_diffusion_pipeline_profiler_mixin(self): - """LTX23Pipeline must support lightweight stage timing.""" - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin - - assert issubclass(LTX23Pipeline, DiffusionPipelineProfilerMixin) - - -class TestLTX23DecodeConditioning: - def test_decode_conditioning_expands_per_prompt_values_to_effective_batch(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import _expand_per_prompt_decode_value - - assert _expand_per_prompt_decode_value( - [0.1, 0.2], - prompt_batch_size=2, - effective_batch_size=4, - field_name="decode_timestep", - ) == [0.1, 0.1, 0.2, 0.2] - assert _expand_per_prompt_decode_value( - [0.3], - prompt_batch_size=2, - effective_batch_size=4, - field_name="decode_timestep", - ) == [0.3, 0.3, 0.3, 0.3] - assert _expand_per_prompt_decode_value( - [0.1, 0.2, 0.3, 0.4], - prompt_batch_size=2, - effective_batch_size=4, - field_name="decode_timestep", - ) == [0.1, 0.2, 0.3, 0.4] - - def test_decode_conditioning_rejects_ambiguous_lengths(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import _expand_per_prompt_decode_value - - with pytest.raises(ValueError, match="decode_timestep"): - _expand_per_prompt_decode_value( - [0.1, 0.2, 0.3], - prompt_batch_size=2, - effective_batch_size=4, - field_name="decode_timestep", - ) - - -class TestRegistryIntegration: - """Verify all LTX-2.3 pipeline variants are registered.""" - - def test_pipeline_models_registered(self): - """LTX-2.3 pipeline variants must be in _DIFFUSION_MODELS.""" - from vllm_omni.diffusion.registry import _DIFFUSION_MODELS - - expected = [ - "LTX23Pipeline", - "LTX23ImageToVideoPipeline", - ] - for name in expected: - assert name in _DIFFUSION_MODELS, f"{name} not found in _DIFFUSION_MODELS" - - def test_pipeline_module_paths(self): - """Registry entries must point to the correct modules.""" - from vllm_omni.diffusion.registry import _DIFFUSION_MODELS - - # T2V -> pipeline_ltx2_3 - assert _DIFFUSION_MODELS["LTX23Pipeline"] == ("ltx2", "pipeline_ltx2_3", "LTX23Pipeline") - - # I2V -> pipeline_ltx2_3_image2video - assert _DIFFUSION_MODELS["LTX23ImageToVideoPipeline"] == ( - "ltx2", - "pipeline_ltx2_3_image2video", - "LTX23ImageToVideoPipeline", - ) - - def test_post_process_funcs_registered(self): - """Pipeline variants must map to get_ltx2_post_process_func.""" - from vllm_omni.diffusion.registry import _DIFFUSION_POST_PROCESS_FUNCS - - expected = [ - "LTX23Pipeline", - "LTX23ImageToVideoPipeline", - ] - for name in expected: - assert name in _DIFFUSION_POST_PROCESS_FUNCS, f"{name} not in _DIFFUSION_POST_PROCESS_FUNCS" - assert _DIFFUSION_POST_PROCESS_FUNCS[name] == "get_ltx2_post_process_func" - - def test_cache_dit_for_ltx2_does_not_have_custom_enablers_registered(self): - """Pipeline variants are *not* registered in CUSTOM_DIT_ENABLERS.""" - from vllm_omni.diffusion.cache.cache_dit_backend import CUSTOM_DIT_ENABLERS - - # NOTE: We used to have custom enablers for this model, but refactored to handle - # it more generically. Now we only need to ensure it has git cache adapter config. - expected = [ - "LTX23Pipeline", - "LTX23ImageToVideoPipeline", - ] - for name in expected: - assert name not in CUSTOM_DIT_ENABLERS, f"{name} not in CUSTOM_DIT_ENABLERS" - - -class TestVocoderSampleRateDetection: - """Test _detect_vocoder_output_sample_rate logic.""" - - def test_detects_48khz_from_config(self): - """Should detect output_sampling_rate=48000 from vocoder/config.json.""" - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import _detect_vocoder_output_sample_rate - - with tempfile.TemporaryDirectory() as tmpdir: - vocoder_dir = os.path.join(tmpdir, "vocoder") - os.makedirs(vocoder_dir) - with open(os.path.join(vocoder_dir, "config.json"), "w") as f: - json.dump({"output_sampling_rate": 48000, "input_sampling_rate": 16000}, f) - - result = _detect_vocoder_output_sample_rate(tmpdir) - assert result == 48000 - - def test_returns_none_for_no_output_sr(self): - """Should return None if vocoder config has no output_sampling_rate.""" - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import _detect_vocoder_output_sample_rate - - with tempfile.TemporaryDirectory() as tmpdir: - vocoder_dir = os.path.join(tmpdir, "vocoder") - os.makedirs(vocoder_dir) - with open(os.path.join(vocoder_dir, "config.json"), "w") as f: - json.dump({"sampling_rate": 16000}, f) - - result = _detect_vocoder_output_sample_rate(tmpdir) - assert result is None - - def test_returns_none_for_missing_directory(self): - """Should return None if vocoder directory doesn't exist.""" - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import _detect_vocoder_output_sample_rate - - result = _detect_vocoder_output_sample_rate("/nonexistent/path") - assert result is None - - -class TestPostProcessFunction: - """Test the post-process function factory.""" - - def test_post_process_includes_audio_sample_rate(self): - """Post-process func should include audio_sample_rate when detected.""" - import torch - - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import get_ltx2_post_process_func - - with tempfile.TemporaryDirectory() as tmpdir: - vocoder_dir = os.path.join(tmpdir, "vocoder") - os.makedirs(vocoder_dir) - with open(os.path.join(vocoder_dir, "config.json"), "w") as f: - json.dump({"output_sampling_rate": 48000}, f) - - # Create a minimal od_config mock - class MockConfig: - model = tmpdir - - func = get_ltx2_post_process_func(MockConfig()) - - video = torch.zeros(1, 3, 4, 64, 64) - audio = torch.zeros(1, 1, 48000) - result = func((video, audio)) - - assert isinstance(result, dict) - assert "video" in result - assert "audio" in result - assert result["audio_sample_rate"] == 48000 - - def test_post_process_without_vocoder_config(self): - """Post-process func should work without vocoder config (no audio_sample_rate key).""" - import torch - - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import get_ltx2_post_process_func - - class MockConfig: - model = "/nonexistent/path" - - func = get_ltx2_post_process_func(MockConfig()) - - video = torch.zeros(1, 3, 4, 64, 64) - audio = torch.zeros(1, 1, 16000) - result = func((video, audio)) - - assert isinstance(result, dict) - assert "video" in result - assert "audio" in result - assert "audio_sample_rate" not in result - - -class TestInitExports: - """Test that __init__.py exports all LTX-2.3 classes.""" - - def test_all_ltx23_classes_exported(self): - """All LTX-2.3 pipeline classes must be in the ltx2 package __all__.""" - from vllm_omni.diffusion.models import ltx2 - - expected_classes = [ - "LTX23Pipeline", - "LTX23ImageToVideoPipeline", - ] - for name in expected_classes: - assert hasattr(ltx2, name), f"{name} not exported from ltx2 package" - assert name in ltx2.__all__, f"{name} not in ltx2.__all__" - - -class TestAudioLatentSPPadding: - def test_prepare_audio_latents_pads_generated_dummy_length_for_sp(self): - pipeline = _make_ltx23_pipeline(sequence_parallel_size=2) - - latents, original_num_frames, padded_num_frames = pipeline.prepare_audio_latents( - batch_size=1, - num_channels_latents=8, - num_mel_bins=64, - audio_latent_length=1, - dtype=torch.float32, - device=torch.device("cpu"), - ) - - assert original_num_frames == 1 - assert padded_num_frames == 2 - assert latents.shape == (1, 2, 128) - - def test_prepare_audio_latents_pads_provided_packed_sequence_dim_for_sp(self): - pipeline = _make_ltx23_pipeline(sequence_parallel_size=4) - latents = torch.arange(40, dtype=torch.float32).view(1, 10, 4) - - padded, original_num_frames, padded_num_frames = pipeline.prepare_audio_latents( - batch_size=1, - num_channels_latents=2, - num_mel_bins=8, - audio_latent_length=10, - dtype=torch.float32, - device=torch.device("cpu"), - latents=latents, - ) - - assert original_num_frames == 10 - assert padded_num_frames == 12 - assert padded.shape == (1, 12, 4) - torch.testing.assert_close(padded[:, :10], latents) - torch.testing.assert_close(padded[:, 10:], torch.zeros(1, 2, 4)) - - def test_prepare_audio_latents_accepts_already_padded_4d_latents_for_sp(self): - pipeline = _make_ltx23_pipeline(sequence_parallel_size=4) - latents = torch.arange(96, dtype=torch.float32).view(1, 2, 12, 4) - - audio_latent_length = pipeline._resolve_audio_latent_length(10, latents) - padded, original_num_frames, padded_num_frames = pipeline.prepare_audio_latents( - batch_size=1, - num_channels_latents=2, - num_mel_bins=16, - audio_latent_length=audio_latent_length, - dtype=torch.float32, - device=torch.device("cpu"), - latents=latents, - ) - - assert audio_latent_length == 10 - assert original_num_frames == 10 - assert padded_num_frames == 12 - assert padded.shape == (1, 12, 8) - torch.testing.assert_close(padded, pipeline._pack_audio_latents(latents)) - - def test_resolve_audio_latent_length_preserves_legacy_4d_shape_inference(self): - pipeline = _make_ltx23_pipeline(sequence_parallel_size=4) - latents = torch.zeros(1, 2, 13, 4) - - audio_latent_length = pipeline._resolve_audio_latent_length(10, latents) - - assert audio_latent_length == 13 - - def test_prepare_audio_latents_rejects_incompatible_provided_length(self): - pipeline = _make_ltx23_pipeline(sequence_parallel_size=4) - latents = torch.zeros(1, 11, 4) - - with pytest.raises(ValueError, match="incompatible audio frame count"): - pipeline.prepare_audio_latents( - batch_size=1, - num_channels_latents=2, - num_mel_bins=8, - audio_latent_length=10, - dtype=torch.float32, - device=torch.device("cpu"), - latents=latents, - ) diff --git a/tests/diffusion/models/ltx2/test_ltx2_cfg_parallel_adaptation.py b/tests/diffusion/models/ltx2/test_ltx2_cfg_parallel_adaptation.py deleted file mode 100644 index bbfe63dfa..000000000 --- a/tests/diffusion/models/ltx2/test_ltx2_cfg_parallel_adaptation.py +++ /dev/null @@ -1,58 +0,0 @@ -from types import SimpleNamespace - -import pytest -import torch - -from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline - -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] - - -def _make_pipeline(sequence_parallel_size: int = 1) -> LTX2Pipeline: - pipeline = object.__new__(LTX2Pipeline) - torch.nn.Module.__init__(pipeline) - pipeline.audio_vae_temporal_compression_ratio = 4 - pipeline.audio_vae_mel_compression_ratio = 4 - pipeline.od_config = SimpleNamespace(parallel_config=SimpleNamespace(sequence_parallel_size=sequence_parallel_size)) - # Mock audio_vae with identity normalization (mean=0, std=1) so - # _normalize_audio_latents is a no-op and test values are preserved. - pipeline.audio_vae = SimpleNamespace( - latents_mean=torch.tensor(0.0), - latents_std=torch.tensor(1.0), - ) - return pipeline - - -def test_prepare_audio_latents_pads_packed_sequence_dim_for_provided_latents(): - pipeline = _make_pipeline(sequence_parallel_size=4) - latents = torch.arange(40, dtype=torch.float32).view(1, 10, 4) - - padded, original_num_frames, padded_num_frames = pipeline.prepare_audio_latents( - batch_size=1, - num_channels_latents=2, - num_mel_bins=8, - audio_latent_length=10, - dtype=torch.float32, - device=torch.device("cpu"), - latents=latents, - ) - - assert original_num_frames == 10 - assert padded_num_frames == 12 - assert padded.shape == (1, 12, 4) - torch.testing.assert_close(padded[:, :10], latents) - torch.testing.assert_close(padded[:, 10:], torch.zeros(1, 2, 4)) - - -def test_unpad_audio_latents_restores_original_frames_before_unpack(): - pipeline = _make_pipeline() - original = torch.arange(40, dtype=torch.float32).view(1, 10, 4) - padded = torch.cat([original, torch.full((1, 2, 4), 999.0)], dim=1) - - unpadded = pipeline._unpad_audio_latents(padded, 10) - unpacked = pipeline._unpack_audio_latents(unpadded, latent_length=10, num_mel_bins=2) - expected = pipeline._unpack_audio_latents(original, latent_length=10, num_mel_bins=2) - - assert unpacked.shape == (1, 2, 10, 2) - assert not (unpacked == 999.0).any() - torch.testing.assert_close(unpacked, expected) diff --git a/tests/diffusion/models/ltx2/test_ltx2_conditioning.py b/tests/diffusion/models/ltx2/test_ltx2_conditioning.py new file mode 100644 index 000000000..e309dbf47 --- /dev/null +++ b/tests/diffusion/models/ltx2/test_ltx2_conditioning.py @@ -0,0 +1,403 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Unit tests for LTX image-to-video input and conditioning behavior.""" + +from types import SimpleNamespace + +import pytest +import torch +from PIL import Image + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _make_ltx23_request_pipe(cls): + pipe = object.__new__(cls) + torch.nn.Module.__init__(pipe) + pipe.device = torch.device("cpu") + pipe.tokenizer_max_length = 99 + pipe.vae_spatial_compression_ratio = 32 + pipe.vae_temporal_compression_ratio = 8 + return pipe + + +class TestLTXImageToVideoForwardStages: + def test_i2v_pil_preprocessing_matches_official_center_crop(self): + from vllm_omni.diffusion.models.ltx2.ltx2_conditioning import _preprocess_i2v_pil_images + + pixels = torch.zeros(4, 8, 3, dtype=torch.uint8) + pixels[:, :, 0] = torch.arange(8, dtype=torch.uint8) + image = Image.fromarray(pixels.numpy()) + + actual = _preprocess_i2v_pil_images(image, height=4, width=4) + expected = pixels[:, 2:6].permute(2, 0, 1).unsqueeze(0).float() / 127.5 - 1.0 + + torch.testing.assert_close(actual, expected) + + def test_forward_resolves_request_image_and_delegates_to_shared_recipe_runtime(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + pipe = _make_ltx23_request_pipe(LTX2Pipeline) + image = torch.zeros(3, 8, 8) + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt={ + "prompt": "make the image move", + "negative_prompt": "jitter", + "multi_modal_data": {"image": image}, + }, + sampling_params=OmniDiffusionSamplingParams( + height=384, + width=512, + num_frames=25, + num_inference_steps=2, + ), + request_id="ltx23-i2v-forward-stage-delegation", + ) + ] + ) + seen = {} + + def fake_run_recipe(req_arg, request_inputs, **kwargs): + seen["req"] = req_arg + seen["request_inputs"] = request_inputs + seen["kwargs"] = kwargs + return ["i2v-delegated"] + + object.__setattr__(pipe, "_run_recipe", fake_run_recipe) + + output = pipe.forward(req) + + assert output == ["i2v-delegated"] + assert seen["req"] is req + assert seen["request_inputs"].prompt == ["make the image move"] + assert seen["request_inputs"].negative_prompt == ["jitter"] + assert seen["kwargs"]["image"] is image + assert seen["kwargs"]["request_sigmas"] is None + + def test_unified_entry_selects_t2v_without_images_and_rejects_mixed_batches(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + + pipe = object.__new__(LTX2Pipeline) + request_inputs = SimpleNamespace(latents=None) + t2v_req = SimpleNamespace(prompts=["text only", {"prompt": "also text only"}]) + + assert pipe._resolve_request_image(t2v_req, None, request_inputs) is None + + mixed_req = SimpleNamespace( + prompts=[ + "text only", + {"prompt": "image conditioned", "multi_modal_data": {"image": torch.zeros(3, 8, 8)}}, + ] + ) + with pytest.raises(ValueError, match="cannot mix text-to-video and image-to-video"): + pipe._resolve_request_image(mixed_req, None, request_inputs) + + def test_denoise_timestep_kwargs_masks_video_only(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + + pipe = object.__new__(LTX2Pipeline) + ts = torch.tensor([2.0, 4.0]) + denoise_ctx = SimpleNamespace( + conditioning_mask=torch.tensor([[1.0, 0.0]]), + conditioning_mask_for_model=torch.tensor([[1.0, 0.0], [0.0, 1.0]]), + ) + + kwargs = pipe._denoise_timestep_kwargs( + ts, + SimpleNamespace(guidance_parallel_ready=False), + denoise_ctx, + video_token_count=2, + audio_token_count=1, + ) + + torch.testing.assert_close(kwargs["timestep"], torch.tensor([[0.0, 2.0], [4.0, 0.0]])) + torch.testing.assert_close(kwargs["audio_timestep"], ts[:, None]) + torch.testing.assert_close(kwargs["sigma"], ts) + + +class TestLTXImageToVideoConditioning: + def test_ltx23_i2v_supports_image_input(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + + assert LTX2Pipeline.support_image_input is True + + def test_ltx23_i2v_rejects_multi_image_prompt_list(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + + image = object() + + assert LTX2Pipeline._resolve_single_prompt_image([image]) is image + with pytest.raises(ValueError, match="exactly one image per prompt"): + LTX2Pipeline._resolve_single_prompt_image([object(), object()]) + + def test_ltx23_i2v_additional_image_resolution_is_tensor_safe(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + + image = torch.zeros(1, 3, 4, 4) + additional = { + "preprocessed_image": None, + "pixel_values": image, + "image": torch.ones_like(image), + } + + assert LTX2Pipeline._resolve_additional_image(additional) is image + + def test_ltx23_i2v_packed_latents_are_not_noised(self, monkeypatch): + import vllm_omni.diffusion.models.ltx2.ltx2_conditioning as ltx2_conditioning + import vllm_omni.diffusion.models.ltx2.ltx2_latents as ltx2_latents + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + + pipe = object.__new__(LTX2Pipeline) + torch.nn.Module.__init__(pipe) + pipe.vae_spatial_compression_ratio = 1 + pipe.vae_temporal_compression_ratio = 1 + pipe.transformer_spatial_patch_size = 1 + pipe.transformer_temporal_patch_size = 1 + object.__setattr__( + pipe, + "_encode_i2v_image_latents", + lambda *_args, **_kwargs: torch.tensor([[[[[40.0]]], [[[41.0]]]]]), + ) + + def fake_randn_tensor(shape, generator=None, device=None, dtype=None): + raise AssertionError("packed I2V latents should not be noised") + + monkeypatch.setattr(ltx2_conditioning, "randn_tensor", fake_randn_tensor) + monkeypatch.setattr(ltx2_latents, "randn_tensor", fake_randn_tensor) + + latents = torch.tensor([[[10.0, 11.0], [20.0, 21.0], [30.0, 31.0]]]) + + out, conditioning_mask = pipe.prepare_latents( + image=torch.zeros(1, 3, 1, 1), + batch_size=1, + num_channels_latents=2, + height=1, + width=1, + num_frames=3, + noise_scale=1.0, + dtype=torch.float32, + device=torch.device("cpu"), + latents=latents, + ) + + torch.testing.assert_close(conditioning_mask, torch.tensor([[1.0, 0.0, 0.0]])) + torch.testing.assert_close(out, torch.tensor([[[40.0, 41.0], [20.0, 21.0], [30.0, 31.0]]])) + + def test_ltx23_i2v_5d_latents_noise_preserves_conditioning_frame(self, monkeypatch): + import vllm_omni.diffusion.models.ltx2.ltx2_conditioning as ltx2_conditioning + import vllm_omni.diffusion.models.ltx2.ltx2_latents as ltx2_latents + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + + pipe = object.__new__(LTX2Pipeline) + torch.nn.Module.__init__(pipe) + pipe.vae_spatial_compression_ratio = 1 + pipe.vae_temporal_compression_ratio = 1 + pipe.transformer_spatial_patch_size = 1 + pipe.transformer_temporal_patch_size = 1 + pipe.vae = SimpleNamespace( + latents_mean=torch.zeros(2), + latents_std=torch.ones(2), + config=SimpleNamespace(scaling_factor=1.0), + ) + object.__setattr__( + pipe, + "_encode_i2v_image_latents", + lambda *_args, **_kwargs: torch.tensor([[[[[40.0]]], [[[41.0]]]]]), + ) + + sampled_shapes = [] + + def fake_randn_tensor(shape, generator=None, device=None, dtype=None): + sampled_shapes.append(tuple(shape)) + return torch.ones(shape, device=device, dtype=dtype) + + monkeypatch.setattr(ltx2_conditioning, "randn_tensor", fake_randn_tensor) + monkeypatch.setattr(ltx2_latents, "randn_tensor", fake_randn_tensor) + + latents = torch.tensor([[[[[10.0]], [[20.0]], [[30.0]]], [[[11.0]], [[21.0]], [[31.0]]]]]) + + out, conditioning_mask = pipe.prepare_latents( + image=torch.zeros(1, 3, 1, 1), + batch_size=1, + num_channels_latents=2, + height=1, + width=1, + num_frames=3, + noise_scale=1.0, + dtype=torch.float32, + device=torch.device("cpu"), + latents=latents, + ) + + torch.testing.assert_close(conditioning_mask, torch.tensor([[1.0, 0.0, 0.0]])) + torch.testing.assert_close(out, torch.tensor([[[40.0, 41.0], [1.0, 1.0], [1.0, 1.0]]])) + assert sampled_shapes == [(1, 3, 2)] + + def test_ltx23_i2v_image_noise_is_sampled_after_packing(self, monkeypatch): + import vllm_omni.diffusion.models.ltx2.ltx2_conditioning as ltx2_conditioning + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + + pipe = object.__new__(LTX2Pipeline) + torch.nn.Module.__init__(pipe) + pipe.vae_spatial_compression_ratio = 1 + pipe.vae_temporal_compression_ratio = 1 + pipe.transformer_spatial_patch_size = 1 + pipe.transformer_temporal_patch_size = 1 + pipe.vae = SimpleNamespace( + encode=lambda image: image, + latents_mean=torch.zeros(2), + latents_std=torch.ones(2), + ) + monkeypatch.setattr( + ltx2_conditioning, + "retrieve_latents", + lambda *_args, **_kwargs: torch.tensor([[[[[10.0]]], [[[11.0]]]]]), + ) + sampled_shapes = [] + + def fake_randn_tensor(shape, generator=None, device=None, dtype=None): + sampled_shapes.append(tuple(shape)) + return torch.ones(shape, device=device, dtype=dtype) + + monkeypatch.setattr(ltx2_conditioning, "randn_tensor", fake_randn_tensor) + + out, conditioning_mask = pipe.prepare_latents( + image=torch.zeros(1, 3, 1, 1), + batch_size=1, + num_channels_latents=2, + height=1, + width=1, + num_frames=3, + noise_scale=1.0, + dtype=torch.float32, + device=torch.device("cpu"), + ) + + assert sampled_shapes == [(1, 3, 2)] + torch.testing.assert_close(conditioning_mask, torch.tensor([[1.0, 0.0, 0.0]])) + torch.testing.assert_close(out, torch.tensor([[[10.0, 11.0], [1.0, 1.0], [1.0, 1.0]]])) + + def test_ltx23_i2v_video_step_preserves_conditioning_frame(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + + pipe = object.__new__(LTX2Pipeline) + torch.nn.Module.__init__(pipe) + pipe.transformer_spatial_patch_size = 1 + pipe.transformer_temporal_patch_size = 1 + + class FakeScheduler: + sigmas = torch.tensor([1.0, 0.5]) + + pipe.scheduler = FakeScheduler() + latents = torch.tensor([[[1.0], [2.0], [3.0]]]) + noise_pred = torch.full_like(latents, 10.0) + + out = pipe._step_video_latents_i2v( + noise_pred, + latents, + 0, + latent_num_frames=3, + latent_height=1, + latent_width=1, + ) + + torch.testing.assert_close(out[:, :1], latents[:, :1]) + torch.testing.assert_close(out[:, 1:], latents[:, 1:] - 0.5 * noise_pred[:, 1:]) + + def test_shared_step_adapter_uses_official_euler_for_t2v(self): + from vllm_omni.diffusion.models.ltx2.ltx2_denoise import LTXVideoAudioStepAdapter + + pipeline = SimpleNamespace(scheduler=SimpleNamespace(sigmas=torch.tensor([1.0, 0.5]))) + adapter = LTXVideoAudioStepAdapter( + pipeline, + SimpleNamespace(sigmas=torch.tensor([1.0, 0.25])), + latent_num_frames=1, + latent_height=1, + latent_width=1, + image_conditioned=False, + ) + video = torch.tensor([[[2.0]]]) + audio = torch.tensor([[[4.0]]]) + + ((video, audio),) = adapter.step( + (torch.tensor([[[2.0]]]), torch.tensor([[[4.0]]])), + (torch.tensor(1.0), torch.tensor(1.0)), + (video, audio), + ) + + torch.testing.assert_close(video, torch.tensor([[[1.0]]])) + torch.testing.assert_close(audio, torch.tensor([[[1.0]]])) + + def test_shared_step_adapter_uses_official_euler_for_i2v_audio(self): + from vllm_omni.diffusion.models.ltx2.ltx2_denoise import LTXVideoAudioStepAdapter + + class FakePipeline: + scheduler = object() + + def __init__(self): + self.step_indices = [] + + def _step_video_latents_i2v( + self, + noise_pred, + latents, + step_index, + latent_num_frames, + latent_height, + latent_width, + ): + del noise_pred, latent_num_frames, latent_height, latent_width + self.step_indices.append(step_index) + return latents + + pipeline = FakePipeline() + adapter = LTXVideoAudioStepAdapter( + pipeline, + SimpleNamespace(sigmas=torch.tensor([1.0, 0.5, 0.0])), + latent_num_frames=1, + latent_height=1, + latent_width=1, + image_conditioned=True, + ) + video = torch.tensor([[[1.0]]]) + audio = torch.tensor([[[2.0]]]) + video_velocity = torch.zeros_like(video) + audio_velocity = torch.tensor([[[4.0]]]) + + ((video, audio),) = adapter.step( + (video_velocity, audio_velocity), + (torch.tensor(1.0), torch.tensor(1.0)), + (video, audio), + ) + torch.testing.assert_close(audio, torch.tensor([[[0.0]]])) + + ((video, audio),) = adapter.step( + (video_velocity, audio_velocity), + (torch.tensor(0.5), torch.tensor(0.5)), + (video, audio), + ) + torch.testing.assert_close(audio, torch.tensor([[[-2.0]]])) + assert pipeline.step_indices == [0, 1] + + def test_i2v_guidance_uses_zero_sigma_for_conditioned_tokens(self): + from vllm_omni.diffusion.models.ltx2.ltx2_denoise import LTXDenoiseContext + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + + pipe = object.__new__(LTX2Pipeline) + denoise_ctx = LTXDenoiseContext( + latents=torch.empty(1, 3, 1), + audio_latents=torch.empty(1, 1, 1), + video_coords=torch.empty(1), + audio_coords=torch.empty(1), + conditioning_mask=torch.tensor([[1.0, 0.0, 0.0]]), + ) + + actual = pipe._video_guidance_model_sigma(torch.tensor(0.75), denoise_ctx) + + torch.testing.assert_close(actual, torch.tensor([[[0.0], [0.75], [0.75]]])) diff --git a/tests/diffusion/models/ltx2/test_ltx2_hsdp.py b/tests/diffusion/models/ltx2/test_ltx2_hsdp.py deleted file mode 100644 index 4dd07e1bf..000000000 --- a/tests/diffusion/models/ltx2/test_ltx2_hsdp.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest -import torch.nn as nn - -from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel - -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] - - -def test_ltx2_exposes_hsdp_shard_conditions_for_transformer_blocks(): - model = object.__new__(LTX2VideoTransformer3DModel) - nn.Module.__init__(model) - model.transformer_blocks = nn.ModuleList([nn.Linear(4, 4) for _ in range(2)]) - model.norm_out = nn.LayerNorm(4) - - conditions = getattr(model, "_hsdp_shard_conditions", None) - - assert conditions is not None - assert len(conditions) == 1 - - matched = [] - for name, module in model.named_modules(): - if any(cond(name, module) for cond in conditions): - matched.append(name) - - assert matched == ["transformer_blocks.0", "transformer_blocks.1"] diff --git a/tests/diffusion/models/ltx2/test_ltx2_latents.py b/tests/diffusion/models/ltx2/test_ltx2_latents.py new file mode 100644 index 000000000..36f7e9687 --- /dev/null +++ b/tests/diffusion/models/ltx2/test_ltx2_latents.py @@ -0,0 +1,165 @@ +from types import SimpleNamespace + +import pytest +import torch + +from vllm_omni.diffusion.models.ltx2 import ltx2_latents as latent_ops +from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _make_pipeline(pipeline_cls, sequence_parallel_size: int = 1): + pipeline = object.__new__(pipeline_cls) + torch.nn.Module.__init__(pipeline) + pipeline.audio_vae_temporal_compression_ratio = 4 + pipeline.audio_vae_mel_compression_ratio = 4 + pipeline.od_config = SimpleNamespace(parallel_config=SimpleNamespace(sequence_parallel_size=sequence_parallel_size)) + # Mock audio_vae with identity normalization (mean=0, std=1). + pipeline.audio_vae = SimpleNamespace( + latents_mean=torch.tensor(0.0), + latents_std=torch.tensor(1.0), + ) + return pipeline + + +def test_prepare_video_latents_samples_directly_in_packed_token_space(): + pipeline = _make_pipeline(LTX2Pipeline) + pipeline.vae_spatial_compression_ratio = 8 + pipeline.vae_temporal_compression_ratio = 8 + pipeline.transformer_spatial_patch_size = 2 + pipeline.transformer_temporal_patch_size = 1 + + expected_generator = torch.Generator().manual_seed(42) + expected = torch.randn((1, 32, 16), generator=expected_generator) + actual = pipeline.prepare_latents( + batch_size=1, + num_channels_latents=4, + height=64, + width=64, + num_frames=9, + dtype=torch.float32, + device=torch.device("cpu"), + generator=torch.Generator().manual_seed(42), + ) + + torch.testing.assert_close(actual, expected, rtol=0, atol=0) + + +def test_prepare_audio_latents_samples_directly_in_packed_token_space(): + pipeline = _make_pipeline(LTX2Pipeline) + + expected_generator = torch.Generator().manual_seed(42) + expected = torch.randn((1, 3, 32), generator=expected_generator) + actual, original_num_frames, padded_num_frames = pipeline.prepare_audio_latents( + batch_size=1, + num_channels_latents=2, + num_mel_bins=64, + audio_latent_length=3, + dtype=torch.float32, + device=torch.device("cpu"), + generator=torch.Generator().manual_seed(42), + ) + + assert original_num_frames == 3 + assert padded_num_frames == 3 + torch.testing.assert_close(actual, expected, rtol=0, atol=0) + + +def test_prepare_audio_latents_pads_generated_dummy_length_for_sp(): + pipeline = _make_pipeline(LTX2Pipeline, sequence_parallel_size=2) + + latents, original_num_frames, padded_num_frames = pipeline.prepare_audio_latents( + batch_size=1, + num_channels_latents=8, + num_mel_bins=64, + audio_latent_length=1, + dtype=torch.float32, + device=torch.device("cpu"), + ) + + assert original_num_frames == 1 + assert padded_num_frames == 2 + assert latents.shape == (1, 2, 128) + + +def test_prepare_audio_latents_pads_packed_sequence_dim_for_provided_latents(): + pipeline = _make_pipeline(LTX2Pipeline, sequence_parallel_size=4) + latents = torch.arange(40, dtype=torch.float32).view(1, 10, 4) + + padded, original_num_frames, padded_num_frames = pipeline.prepare_audio_latents( + batch_size=1, + num_channels_latents=2, + num_mel_bins=8, + audio_latent_length=10, + dtype=torch.float32, + device=torch.device("cpu"), + latents=latents, + ) + + assert original_num_frames == 10 + assert padded_num_frames == 12 + assert padded.shape == (1, 12, 4) + torch.testing.assert_close(padded[:, :10], latents) + torch.testing.assert_close(padded[:, 10:], torch.zeros(1, 2, 4)) + + +def test_unpad_audio_latents_restores_original_frames_before_unpack(): + original = torch.arange(40, dtype=torch.float32).view(1, 10, 4) + padded = torch.cat([original, torch.full((1, 2, 4), 999.0)], dim=1) + + unpadded = latent_ops.unpad_audio_latents(padded, 10) + unpacked = latent_ops.unpack_audio_latents(unpadded, num_mel_bins=2) + expected = latent_ops.unpack_audio_latents(original, num_mel_bins=2) + + assert unpacked.shape == (1, 2, 10, 2) + assert not (unpacked == 999.0).any() + torch.testing.assert_close(unpacked, expected) + + +def test_prepare_audio_latents_accepts_already_padded_4d_latents_for_sp(): + pipeline = _make_pipeline(LTX2Pipeline, sequence_parallel_size=4) + pipeline.preserve_sp_padded_audio_duration = True + latents = torch.arange(96, dtype=torch.float32).view(1, 2, 12, 4) + + audio_latent_length = pipeline._resolve_audio_latent_length(10, latents) + padded, original_num_frames, padded_num_frames = pipeline.prepare_audio_latents( + batch_size=1, + num_channels_latents=2, + num_mel_bins=16, + audio_latent_length=audio_latent_length, + dtype=torch.float32, + device=torch.device("cpu"), + latents=latents, + ) + + assert audio_latent_length == 10 + assert original_num_frames == 10 + assert padded_num_frames == 12 + assert padded.shape == (1, 12, 8) + torch.testing.assert_close(padded, latent_ops.pack_audio_latents(latents)) + + +def test_resolve_audio_latent_length_preserves_legacy_4d_shape_inference(): + pipeline = _make_pipeline(LTX2Pipeline, sequence_parallel_size=4) + latents = torch.zeros(1, 2, 13, 4) + + audio_latent_length = pipeline._resolve_audio_latent_length(10, latents) + + assert audio_latent_length == 13 + + +def test_prepare_audio_latents_rejects_incompatible_provided_length(): + pipeline = _make_pipeline(LTX2Pipeline, sequence_parallel_size=4) + latents = torch.zeros(1, 11, 4) + + with pytest.raises(ValueError, match="incompatible audio frame count"): + pipeline.prepare_audio_latents( + batch_size=1, + num_channels_latents=2, + num_mel_bins=8, + audio_latent_length=10, + dtype=torch.float32, + device=torch.device("cpu"), + latents=latents, + ) diff --git a/tests/diffusion/models/ltx2/test_ltx2_3_forward_parallel.py b/tests/diffusion/models/ltx2/test_ltx2_parallel.py similarity index 57% rename from tests/diffusion/models/ltx2/test_ltx2_3_forward_parallel.py rename to tests/diffusion/models/ltx2/test_ltx2_parallel.py index 3e592f14b..8d8c22e79 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_3_forward_parallel.py +++ b/tests/diffusion/models/ltx2/test_ltx2_parallel.py @@ -1,8 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for LTX-2.3 forward-parallel behavior.""" +"""Unit tests for LTX guidance and forward-parallel behavior.""" +from contextlib import nullcontext from types import SimpleNamespace import pytest @@ -11,31 +12,13 @@ import torch pytestmark = [pytest.mark.core_model, pytest.mark.cpu] -class TestCFGParallelIntegration: - def test_ltx23_pipeline_has_cfg_parallel_mixin(self): - """LTX23Pipeline must use the shared CFG parallel implementation.""" - from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - - assert issubclass(LTX23Pipeline, CFGParallelMixin) - - def test_ltx2_transformer_has_dit_cache_config(self): - """Ensure LTX2 has a Cache DiT adapter config and that it uses separate CFG.""" - from vllm_omni.diffusion.cache.cache_dit_backend import CacheDiTAdapterConfig - from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel - - adapter_config = getattr(LTX2VideoTransformer3DModel, "_cache_dit_adapter_config") - assert isinstance(adapter_config, CacheDiTAdapterConfig) - assert adapter_config.has_separate_cfg - - class TestCFGParallelHelpers: """Test LTX-2.3 CFG helper math without loading model weights.""" def test_combine_cfg_noise_matches_x0_space_formula(self): - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline - pipe = object.__new__(LTX23Pipeline) + pipe = object.__new__(LTX2Pipeline) video_sample = torch.tensor([[[1.0, -2.0]]]) audio_sample = torch.tensor([[[0.5, 3.0]]]) video_pos = torch.tensor([[[0.2, -0.3]]]) @@ -68,89 +51,208 @@ class TestCFGParallelHelpers: assert torch.allclose(video_combined, expected_video) assert torch.allclose(audio_combined, expected_audio) - def test_two_rank_cfg_parallel_smoke_uses_rank_local_branch_and_x0_formula(self, monkeypatch): - from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2_3 as ltx23 + @pytest.mark.parametrize("guidance_world_size", [1, 2, 3, 4]) + def test_guidance_parallel_accepts_official_guidance(self, guidance_world_size): + from vllm_omni.diffusion.models.ltx2.ltx2_guidance import ( + LTX_GUIDANCE_EXECUTOR, + LTXGuidancePlan, + ) + from vllm_omni.diffusion.models.ltx2.ltx2_recipes import LTX23_ONE_STAGE_RECIPE - pipe = object.__new__(ltx23.LTX23Pipeline) - video_sample = torch.tensor([[[1.0, -2.0]]]) - audio_sample = torch.tensor([[[0.5, 3.0, -1.0]]]) - video_pos = torch.tensor([[[0.2, -0.3]]]) - video_neg = torch.tensor([[[-0.4, 0.1]]]) - audio_pos = torch.tensor([[[0.7, -0.2, 0.3]]]) - audio_neg = torch.tensor([[[0.1, 0.4, -0.5]]]) - video_sigma = torch.tensor(0.25) - audio_sigma = torch.tensor(0.5) - scale = 4.0 + plan = LTXGuidancePlan.build(LTX23_ONE_STAGE_RECIPE.request_guidance) + + LTX_GUIDANCE_EXECUTOR.validate_guidance_world_size(plan, guidance_world_size) + + def test_guidance_parallel_dummy_warms_full_guidance_plan(self, monkeypatch): + from vllm_omni.diffusion.models.ltx2 import ltx2_runtime + from vllm_omni.diffusion.models.ltx2.ltx2_recipes import LTX23_ONE_STAGE_RECIPE + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + + monkeypatch.setattr(ltx2_runtime, "get_guidance_parallel_world_size", lambda: 2) + pipe = object.__new__(LTX2Pipeline) + req = SimpleNamespace(is_dummy_run=lambda: True) + request_inputs = SimpleNamespace(guidance=LTX23_ONE_STAGE_RECIPE.request_guidance) + + guidance_parallel_ready = pipe._setup_forward_runtime(req, request_inputs, attention_kwargs=None) + + assert guidance_parallel_ready is True + assert pipe._guidance_plan.names == ("cond", "uncond", "ptb", "mod") + assert pipe._guidance_plan.spec.video.cfg_scale == 3.0 + assert pipe._guidance_plan.spec.audio.cfg_scale == 7.0 + + def test_guidance_parallel_warns_for_imbalanced_passes(self, monkeypatch): + from vllm_omni.diffusion.models.ltx2 import ltx2_guidance + + spec = ltx2_guidance.LTXGuidanceSpec( + video=ltx2_guidance.LTXModalityGuidance( + cfg_scale=3.0, + stg_scale=1.0, + stg_blocks=(1,), + ) + ) + plan = ltx2_guidance.LTXGuidancePlan.build(spec) + captured = [] + monkeypatch.setattr(ltx2_guidance, "get_guidance_parallel_rank", lambda: 0) + monkeypatch.setattr(ltx2_guidance.logger, "warning_once", lambda *args: captured.append(args)) + + ltx2_guidance.LTX_GUIDANCE_EXECUTOR.warn_if_imbalanced(plan, 4, "hq_generate") + + assert plan.names == ("cond", "uncond", "ptb") + assert len(captured) == 1 + assert captured[0][1:] == ("hq_generate", 3, 4, 1, 75.0) + + @pytest.mark.parametrize("guidance_rank", [0, 1]) + def test_default_guidance_is_sharded_and_combined(self, monkeypatch, guidance_rank): + from vllm_omni.diffusion.models.ltx2 import ltx2_guidance + from vllm_omni.diffusion.models.ltx2.ltx2_denoise import LTXDenoiseContext + from vllm_omni.diffusion.models.ltx2.ltx2_latents import LTXAVState + from vllm_omni.diffusion.models.ltx2.ltx2_recipes import LTX23_ONE_STAGE_RECIPE + + executor = ltx2_guidance.LTX_GUIDANCE_EXECUTOR + plan = ltx2_guidance.LTXGuidancePlan.build(LTX23_ONE_STAGE_RECIPE.request_guidance) + video_predictions = { + "cond": torch.tensor([[[0.2, -0.3]]]), + "uncond": torch.tensor([[[-0.4, 0.1]]]), + "ptb": torch.tensor([[[0.5, -0.2]]]), + "mod": torch.tensor([[[-0.1, 0.6]]]), + } + audio_predictions = { + "cond": torch.tensor([[[0.7, -0.2]]]), + "uncond": torch.tensor([[[0.1, 0.4]]]), + "ptb": torch.tensor([[[-0.3, 0.2]]]), + "mod": torch.tensor([[[0.4, -0.5]]]), + } + state = LTXAVState( + video=torch.tensor([[[1.0, -2.0]]]), + audio=torch.tensor([[[0.5, 3.0]]]), + ) + denoise_ctx = LTXDenoiseContext( + latents=state.video, + audio_latents=state.audio, + video_coords=torch.zeros(1, 1, 3), + audio_coords=torch.zeros(1, 1, 1), + ) + executor.prepare_denoise_context(plan, True, 2, denoise_ctx) + + class FakeGuidanceGroup: + def __init__(self): + self.call_index = 0 - class FakeCfgGroup: def all_gather(self, tensor, separate_tensors=True): assert separate_tensors - if tensor.shape == video_pos.shape: - return [video_pos, video_neg] - return [audio_pos, audio_neg] + gathered = ( + [video_predictions["cond"], video_predictions["uncond"]], + [video_predictions["ptb"], video_predictions["mod"]], + [audio_predictions["cond"], audio_predictions["uncond"]], + [audio_predictions["ptb"], audio_predictions["mod"]], + )[self.call_index] + torch.testing.assert_close(tensor, gathered[guidance_rank]) + self.call_index += 1 + return gathered - monkeypatch.setattr(ltx23, "get_classifier_free_guidance_world_size", lambda: 2) - monkeypatch.setattr(ltx23, "get_cfg_group", lambda: FakeCfgGroup()) + group = FakeGuidanceGroup() + monkeypatch.setattr(ltx2_guidance, "get_guidance_parallel_world_size", lambda: 2) + monkeypatch.setattr(ltx2_guidance, "get_guidance_parallel_rank", lambda: guidance_rank) + monkeypatch.setattr(ltx2_guidance, "get_guidance_parallel_group", lambda: group) - expected_video = ltx23.LTX23Pipeline._combine_x0_space_cfg( - video_sample, - video_pos, - video_neg, + local_names = ("cond", "ptb") if guidance_rank == 0 else ("uncond", "mod") + captured = {} + + class FakeTransformer: + def __call__(self, **kwargs): + captured.update(kwargs) + return ( + torch.cat([video_predictions[name] for name in local_names]), + torch.cat([audio_predictions[name] for name in local_names]), + ) + + video_sigma = torch.tensor(0.25) + audio_sigma = torch.tensor(0.5) + + class FakePipeline: + scheduler = SimpleNamespace(sigmas=torch.stack([video_sigma])) + transformer = FakeTransformer() + + @staticmethod + def _transformer_cache_context(name): + assert name == "guided" + return nullcontext() + + @staticmethod + def _build_transformer_kwargs(forward_ctx, denoise_ctx, **kwargs): + del forward_ctx, denoise_ctx + return kwargs + + @staticmethod + def _video_guidance_model_sigma(sigma, denoise_ctx): + del denoise_ctx + return sigma + + positive_context = torch.ones(1, 1, 1) + negative_context = torch.zeros(1, 1, 1) + forward_ctx = SimpleNamespace( + prompt_context=SimpleNamespace( + positive_connector_prompt_embeds=positive_context, + positive_connector_audio_prompt_embeds=positive_context, + negative_connector_prompt_embeds=negative_context, + negative_connector_audio_prompt_embeds=negative_context, + ), + attention_kwargs=None, + audio_scheduler=SimpleNamespace(sigmas=torch.stack([audio_sigma])), + ) + + actual_video, actual_audio = executor.predict_parallel_guidance( + FakePipeline(), + plan, + index=0, + timestep=torch.tensor(1.0), + state=state, + forward_ctx=forward_ctx, + denoise_ctx=denoise_ctx, + ) + + expected_video = executor._guide_modality( + state.video, + video_predictions, video_sigma, - scale, + plan.spec.video, ) - expected_audio = ltx23.LTX23Pipeline._combine_x0_space_cfg( - audio_sample, - audio_pos, - audio_neg, + expected_audio = executor._guide_modality( + state.audio, + audio_predictions, audio_sigma, - scale, + plan.spec.audio, ) - - for rank, expected_branch in ((0, "positive"), (1, "negative")): - calls = [] - monkeypatch.setattr(ltx23, "get_classifier_free_guidance_rank", lambda rank=rank: rank) - - def fake_predict_noise(**kwargs): - calls.append(kwargs["branch"]) - if kwargs["branch"] == "positive": - return video_pos, audio_pos - return video_neg, audio_neg - - object.__setattr__(pipe, "predict_noise", fake_predict_noise) - video_combined, audio_combined = pipe.predict_noise_with_parallel_cfg( - true_cfg_scale=scale, - positive_kwargs={"branch": "positive"}, - negative_kwargs={"branch": "negative"}, - cfg_normalize=False, - video_latents=video_sample, - audio_latents=audio_sample, - video_sigma=video_sigma, - audio_sigma=audio_sigma, - ) - - assert calls == [expected_branch] - torch.testing.assert_close(video_combined, expected_video) - torch.testing.assert_close(audio_combined, expected_audio) - - assert "_cfg_video_latents" not in pipe.__dict__ - assert "_cfg_audio_latents" not in pipe.__dict__ + torch.testing.assert_close(actual_video, expected_video) + torch.testing.assert_close(actual_audio, expected_audio) + assert group.call_index == 4 + assert captured["hidden_states"].shape[0] == 2 + perturbations = captured["attention_kwargs"]["ltx_perturbation_kwargs"] + if guidance_rank == 0: + assert "video_self_attention_mask" in perturbations + assert "a2v_cross_attention_mask" not in perturbations + else: + assert "video_self_attention_mask" not in perturbations + assert "a2v_cross_attention_mask" in perturbations class TestCFGParallelForwardPath: """Test the LTX-2.3 CFG-parallel denoising path without loading model weights.""" def test_forward_collates_request_prompt_embeds_and_mask_aliases(self, monkeypatch): - from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2_3 as ltx23 + from vllm_omni.diffusion.models.ltx2 import ltx2_runtime + from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2 as ltx23 from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch from vllm_omni.inputs.data import OmniDiffusionSamplingParams - pipe = object.__new__(ltx23.LTX23Pipeline) + pipe = object.__new__(ltx23.LTX2Pipeline) torch.nn.Module.__init__(pipe) pipe.device = torch.device("cpu") pipe.tokenizer_max_length = 4 - monkeypatch.setattr(ltx23, "get_classifier_free_guidance_world_size", lambda: 1) + pipe.vae_spatial_compression_ratio = 32 + pipe.vae_temporal_compression_ratio = 8 + monkeypatch.setattr(ltx2_runtime, "get_guidance_parallel_world_size", lambda: 1) class StopAtEncodePromptError(Exception): pass @@ -238,7 +340,7 @@ class TestCFGParallelForwardPath: ("frame_rate_input", "audio_sampling_rate", "expected_frame_rate"), [(1.0, 1, 1.0), (None, 24, 24.0)], ) - def test_forward_cfg_parallel_steps_video_and_audio_scheduler( + def test_forward_cfg_parallel_uses_shared_video_audio_euler_step( self, monkeypatch, cfg_rank, @@ -247,12 +349,13 @@ class TestCFGParallelForwardPath: audio_sampling_rate, expected_frame_rate, ): - from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2_3 as ltx23 + from vllm_omni.diffusion.models.ltx2 import ltx2_denoise, ltx2_guidance, ltx2_runtime + from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2 as ltx23 from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch from vllm_omni.inputs.data import OmniDiffusionSamplingParams - pipe = object.__new__(ltx23.LTX23Pipeline) + pipe = object.__new__(ltx23.LTX2Pipeline) torch.nn.Module.__init__(pipe) pipe.device = torch.device("cpu") pipe.tokenizer_max_length = 1 @@ -291,15 +394,16 @@ class TestCFGParallelForwardPath: return [audio_pos, audio_neg] raise AssertionError(f"Unexpected gathered tensor: {tensor}") - monkeypatch.setattr(ltx23, "get_classifier_free_guidance_world_size", lambda: 2) - monkeypatch.setattr(ltx23, "get_classifier_free_guidance_rank", lambda: cfg_rank) - monkeypatch.setattr(ltx23, "get_cfg_group", lambda: FakeCfgGroup()) + monkeypatch.setattr(ltx2_runtime, "get_guidance_parallel_world_size", lambda: 2) + monkeypatch.setattr(ltx2_guidance, "get_guidance_parallel_world_size", lambda: 2) + monkeypatch.setattr(ltx2_guidance, "get_guidance_parallel_rank", lambda: cfg_rank) + monkeypatch.setattr(ltx2_guidance, "get_guidance_parallel_group", lambda: FakeCfgGroup()) def fake_retrieve_timesteps(scheduler, num_inference_steps, device, timesteps, sigmas=None, mu=None): scheduler.sigmas = torch.tensor([0.25, 0.25], device=device) return torch.tensor([1.0, 0.5], device=device), 2 - monkeypatch.setattr(ltx23, "retrieve_timesteps", fake_retrieve_timesteps) + monkeypatch.setattr(ltx2_denoise, "retrieve_timesteps", fake_retrieve_timesteps) class FakeScheduler: def __init__(self, name="video", calls=None): @@ -326,7 +430,7 @@ class TestCFGParallelForwardPath: def __call__(self, prompt_embeds, prompt_attention_mask, padding_side): assert padding_side == "left" - assert prompt_embeds.shape[0] == 2 + assert prompt_embeds.shape[0] == 1 return prompt_embeds, prompt_embeds, prompt_attention_mask rope_video_fps: list[float] = [] @@ -392,7 +496,16 @@ class TestCFGParallelForwardPath: num_frames=1, frame_rate=frame_rate_input, num_inference_steps=2, - guidance_scale=4.0, + extra_args={ + "video_cfg_scale": 3.0, + "audio_cfg_scale": 7.0, + "video_stg_scale": 0.0, + "audio_stg_scale": 0.0, + "video_modality_scale": 1.0, + "audio_modality_scale": 1.0, + "video_rescale_scale": 0.0, + "audio_rescale_scale": 0.0, + }, latents=video_latents, audio_latents=audio_latents, output_type="latent", @@ -400,35 +513,31 @@ class TestCFGParallelForwardPath: request_id="ltx23-cfg-parallel-forward-test", ) - output = pipe.forward(DiffusionRequestBatch(requests=[req]))[0] + output = pipe.forward(DiffusionRequestBatch(requests=[req]), sigmas=[0.25, 0.25])[0] - expected_video_noise = ltx23.LTX23Pipeline._combine_x0_space_cfg( + expected_video_noise = ltx2_guidance.combine_velocity_via_x0( video_latents, video_pos, video_neg, pipe.scheduler.sigmas[0], - 4.0, + 3.0, ) - expected_audio_noise = ltx23.LTX23Pipeline._combine_x0_space_cfg( + expected_audio_noise = ltx2_guidance.combine_velocity_via_x0( audio_latents, audio_pos, audio_neg, pipe.scheduler.sigmas[0], - 4.0, + 7.0, ) - scheduler_call_names = [call[0] for call in pipe.scheduler.calls] - assert scheduler_call_names == ["video", "audio", "video", "audio"] + assert pipe.scheduler.calls == [] assert len(pipe.transformer.calls) == 2 - torch.testing.assert_close(pipe.scheduler.calls[0][1], expected_video_noise) - torch.testing.assert_close(pipe.scheduler.calls[1][1], expected_audio_noise) - torch.testing.assert_close(pipe.scheduler.calls[2][1], expected_video_noise) - torch.testing.assert_close(pipe.scheduler.calls[3][1], expected_audio_noise) - torch.testing.assert_close(pipe.scheduler.calls[2][3], video_latents - expected_video_noise) - torch.testing.assert_close(pipe.scheduler.calls[3][3], audio_latents - expected_audio_noise) video_out, audio_out = output.output - torch.testing.assert_close(video_out, (video_latents - 2 * expected_video_noise).reshape(1, 2, 1, 1, 1)) - torch.testing.assert_close(audio_out, (audio_latents - 2 * expected_audio_noise).reshape(1, 1, 1, 2)) + sigma_delta = pipe.scheduler.sigmas[-1] - pipe.scheduler.sigmas[0] + expected_video = video_latents + expected_video_noise * sigma_delta + expected_audio = audio_latents + expected_audio_noise * sigma_delta + torch.testing.assert_close(video_out, expected_video.reshape(1, 2, 1, 1, 1)) + torch.testing.assert_close(audio_out, expected_audio.reshape(1, 1, 1, 2)) # fps regression guard: an omitted request fps (frame_rate_input=None) must resolve # to the model's own 24.0 default, not crash on None; a provided rate is passed through. diff --git a/tests/diffusion/models/ltx2/test_ltx2_pipeline.py b/tests/diffusion/models/ltx2/test_ltx2_pipeline.py new file mode 100644 index 000000000..d70820f41 --- /dev/null +++ b/tests/diffusion/models/ltx2/test_ltx2_pipeline.py @@ -0,0 +1,1629 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Unit tests for the shared LTX pipeline runtime and public contracts.""" + +import json +import os +import tempfile +from dataclasses import replace +from types import SimpleNamespace + +import pytest +import torch + +from vllm_omni.diffusion.data import DiffusionOutput +from vllm_omni.diffusion.models.ltx2 import ltx2_components +from vllm_omni.diffusion.models.ltx2.ltx2_components import ( + LTX2_COMPONENT_PROFILE, + LTX2_DISTILLED_COMPONENT_PROFILE, + LTX23_COMPONENT_PROFILE, + _install_connector_attention, + detect_ltx_model_version, +) +from vllm_omni.diffusion.models.ltx2.ltx2_conditioning import LTXI2VConditioningMixin +from vllm_omni.diffusion.models.ltx2.ltx2_denoise import ( + LTXDenoiseExecutor, + LTXPhaseResult, + _official_ltx_sigmas, + prepare_scheduler_stage, +) +from vllm_omni.diffusion.models.ltx2.ltx2_guidance import ( + LTX_GUIDANCE_EXECUTOR, + LTXGuidancePlan, + LTXGuidanceSpec, + LTXModalityGuidance, + build_perturbation_kwargs, + combine_guided_x0, +) +from vllm_omni.diffusion.models.ltx2.ltx2_latents import LTXAVState +from vllm_omni.diffusion.models.ltx2.ltx2_recipes import ( + LTX2_DISTILLED_TWO_STAGE_RECIPE, + LTX2_ONE_STAGE_RECIPE, + LTX23_ONE_STAGE_RECIPE, + LTX_DEFAULT_NEGATIVE_PROMPT, + LTX_POSITIVE_ONLY_RECIPE, +) +from vllm_omni.diffusion.models.ltx2.ltx2_request import LTXRequestInputs, validate_pipeline_request +from vllm_omni.diffusion.models.ltx2.ltx2_runtime import LTXRuntime +from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import ( + LTX2I2VDMD2Pipeline, + LTX2Pipeline, + LTX2T2VDMD2Pipeline, +) +from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_two_stage import LTX2DistilledPipeline + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _make_ltx_request_pipe(cls): + pipe = object.__new__(cls) + torch.nn.Module.__init__(pipe) + pipe.device = torch.device("cpu") + pipe.tokenizer_max_length = 99 + pipe.vae_spatial_compression_ratio = 32 + pipe.vae_temporal_compression_ratio = 8 + return pipe + + +def _resolve_request_inputs_for_test( + pipe, + req, + *, + guidance_scale=4.0, + negative_prompt=None, +): + return pipe._resolve_request_inputs( + req, + prompt=None, + negative_prompt=negative_prompt, + height=None, + width=None, + num_frames=None, + frame_rate=None, + num_inference_steps=None, + guidance_scale=guidance_scale, + num_videos_per_prompt=1, + generator=None, + latents=None, + audio_latents=None, + prompt_embeds=None, + negative_prompt_embeds=None, + prompt_attention_mask=None, + negative_prompt_attention_mask=None, + decode_timestep=0.0, + decode_noise_scale=None, + output_type="np", + max_sequence_length=None, + ) + + +def test_ltx_public_entries_share_runtime_and_keep_recipe_boundaries(): + from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import ( + DistributedAutoencoderKLLTX2Video, + ) + + assert issubclass(LTX2Pipeline, LTXRuntime) + assert issubclass(LTX2DistilledPipeline, LTXRuntime) + assert LTX2Pipeline.component_profile is LTX2_COMPONENT_PROFILE + assert LTX2Pipeline.pipeline_recipe is LTX2_ONE_STAGE_RECIPE + assert LTX2DistilledPipeline.component_profile is LTX2_DISTILLED_COMPONENT_PROFILE + assert LTX2_COMPONENT_PROFILE.video_vae_cls is DistributedAutoencoderKLLTX2Video + assert LTX23_COMPONENT_PROFILE.video_vae_cls is DistributedAutoencoderKLLTX2Video + + +def test_ltx_checkpoint_version_detection_uses_metadata(tmp_path): + (tmp_path / "model_index.json").write_text(json.dumps({"vocoder": ["ltx2", "LTX2VocoderWithBWE"]})) + assert detect_ltx_model_version(str(tmp_path)) == "2.3" + + (tmp_path / "model_index.json").write_text(json.dumps({"model_version": "2.3.1"})) + assert detect_ltx_model_version(str(tmp_path)) == "2.3" + + (tmp_path / "model_index.json").write_text(json.dumps({"vocoder": ["ltx2", "LTX2Vocoder"]})) + assert detect_ltx_model_version(str(tmp_path)) == "2" + + +def test_ltx23_checkpoint_selects_version_specific_one_stage_profile(tmp_path, monkeypatch): + from vllm_omni.diffusion.models.ltx2 import ltx2_runtime + + (tmp_path / "model_index.json").write_text(json.dumps({"vocoder": ["ltx2", "LTX2VocoderWithBWE"]})) + + def stub_components(pipe, od_config): + pipe.od_config = od_config + pipe.vae_spatial_compression_ratio = 32 + + monkeypatch.setattr(ltx2_runtime, "initialize_pipeline_components", stub_components) + monkeypatch.setattr(LTXRuntime, "setup_diffusion_pipeline_profiler", lambda *_args, **_kwargs: None) + + pipe = LTX2Pipeline(od_config=SimpleNamespace(model=str(tmp_path), enable_diffusion_pipeline_profiler=False)) + + assert pipe.model_version == "2.3" + assert pipe.component_profile is LTX23_COMPONENT_PROFILE + assert pipe.pipeline_recipe is LTX23_ONE_STAGE_RECIPE + assert pipe.preserve_sp_padded_audio_duration + assert pipe.reports_stage_durations + + +def test_ltx_one_stage_entry_exposes_batch_image_and_distributed_decode_contracts(): + assert LTX2Pipeline.supports_request_batch + assert LTX2Pipeline.support_image_input + assert LTX2Pipeline.unified_text_image_entry + assert LTX2Pipeline.distributed_video_decode + assert not LTX2DistilledPipeline.supports_request_batch + assert LTX2DistilledPipeline.support_image_input + assert LTX2DistilledPipeline.unified_text_image_entry + + +def test_ltx_dmd2_entries_retain_their_task_boundaries(): + assert not LTX2T2VDMD2Pipeline.support_image_input + assert LTX2I2VDMD2Pipeline.support_image_input + assert not LTX2I2VDMD2Pipeline.unified_text_image_entry + + +def test_ltx_request_batch_decode_splits_video_and_audio_per_request(): + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + requests = [ + OmniDiffusionRequest( + prompt=f"prompt-{index}", + sampling_params=OmniDiffusionSamplingParams(), + request_id=f"request-{index}", + ) + for index in range(2) + ] + request_batch = DiffusionRequestBatch(requests) + video = torch.tensor([[1.0], [2.0]]) + audio = torch.tensor([[3.0], [4.0]]) + pipe = object.__new__(LTX2Pipeline) + torch.nn.Module.__init__(pipe) + object.__setattr__(pipe, "_decode_output", lambda **_kwargs: DiffusionOutput(output=(video, audio))) + forward_ctx = SimpleNamespace( + req=request_batch, + request_inputs=SimpleNamespace( + output_type="np", + generator=None, + decode_timestep=0.0, + decode_noise_scale=None, + ), + prompt_context=SimpleNamespace(connector_prompt_embeds=torch.empty(2, 1)), + device=torch.device("cpu"), + batch_size=2, + num_videos_per_prompt=1, + ) + + outputs = pipe.decode_phase( + LTXPhaseResult( + forward_context=forward_ctx, + video=video, + audio=audio, + ) + ) + + assert len(outputs) == 2 + torch.testing.assert_close(outputs[0].output[0], video[:1]) + torch.testing.assert_close(outputs[0].output[1], audio[:1]) + torch.testing.assert_close(outputs[1].output[0], video[1:]) + torch.testing.assert_close(outputs[1].output[1], audio[1:]) + + +def test_ltx_public_entries_share_runtime_forward_template(): + assert "_forward_request" not in LTX2Pipeline.__dict__ + assert "forward" not in LTX2Pipeline.__dict__ + assert LTX2Pipeline._forward_request is LTXRuntime._forward_request + assert LTX2Pipeline._run_recipe is LTXRuntime._run_recipe + + +@pytest.mark.parametrize( + ("kwargs", "field_name"), + [ + ({"timesteps": [1]}, "timesteps"), + ({"guidance_rescale": 0.5}, "guidance_rescale"), + ({"noise_scale": 1.0}, "noise_scale"), + ({"return_dict": False}, "return_dict"), + ({"attention_kwargs": {}}, "attention_kwargs"), + ], +) +def test_ltx_public_forward_rejects_unsupported_generic_options(kwargs, field_name): + pipe = object.__new__(LTX2Pipeline) + + with pytest.raises(ValueError, match=field_name): + pipe.forward(SimpleNamespace(), **kwargs) + + +def test_ltx2_canonical_forward_requires_keyword_arguments(): + pipe = object.__new__(LTX2Pipeline) + + with pytest.raises(TypeError): + pipe.forward(SimpleNamespace(), "prompt") + + +def test_ltx_versions_share_request_prompt_and_step_templates(): + shared_methods = ( + "_get_gemma_prompt_embeds", + "encode_prompt", + "check_inputs", + "_resolve_request_inputs", + "_prepare_prompt_context", + "_denoise_step", + ) + for method_name in shared_methods: + base_method = getattr(LTXRuntime, method_name) + assert getattr(LTX2Pipeline, method_name) is base_method + + +def test_ltx_versions_select_guidance_without_overriding_control_flow(): + assert LTX2Pipeline.guidance_executor is LTX_GUIDANCE_EXECUTOR + assert LTX2Pipeline._predict_noise_for_step is LTXRuntime._predict_noise_for_step + assert LTX2Pipeline.combine_cfg_noise is LTXRuntime.combine_cfg_noise + + +def test_ltx_official_recipes_build_all_guidance_passes(): + ltx2_plan = LTXGuidancePlan.build(LTX2_ONE_STAGE_RECIPE.request_guidance) + ltx23_plan = LTXGuidancePlan.build(LTX23_ONE_STAGE_RECIPE.request_guidance) + + assert ltx2_plan.names == ltx23_plan.names == ("cond", "uncond", "ptb", "mod") + assert LTX2_ONE_STAGE_RECIPE.request_guidance.video.stg_blocks == (29,) + assert LTX23_ONE_STAGE_RECIPE.request_guidance.video.stg_blocks == (28,) + assert LTX23_ONE_STAGE_RECIPE.request_guidance.video.cfg_scale == 3.0 + assert LTX23_ONE_STAGE_RECIPE.request_guidance.audio.cfg_scale == 7.0 + assert LTX2_ONE_STAGE_RECIPE.phases[0].use_official_sigma_schedule + assert LTX23_ONE_STAGE_RECIPE.phases[0].use_official_sigma_schedule + assert not LTX_POSITIVE_ONLY_RECIPE.phases[0].use_official_sigma_schedule + + +def test_ltx_guidance_combines_official_x0_deltas_in_fp32(): + cond = torch.tensor([[1.0, 3.0]], dtype=torch.bfloat16) + uncond = torch.tensor([[0.0, 1.0]], dtype=torch.bfloat16) + perturbed = torch.tensor([[0.5, 2.0]], dtype=torch.bfloat16) + isolated = torch.tensor([[0.25, 2.5]], dtype=torch.bfloat16) + guidance = LTXModalityGuidance( + cfg_scale=3.0, + stg_scale=0.5, + modality_scale=2.0, + stg_blocks=(0,), + ) + + actual = combine_guided_x0( + cond=cond, + uncond_text=uncond, + uncond_perturbed=perturbed, + uncond_modality=isolated, + guidance=guidance, + ) + expected = ( + cond.float() + + 2.0 * (cond.float() - uncond.float()) + + 0.5 * (cond.float() - perturbed.float()) + + (cond.float() - isolated.float()) + ).to(cond.dtype) + + torch.testing.assert_close(actual, expected) + + +def test_ltx_guidance_skips_stg_without_perturbed_blocks(): + cond = torch.tensor([[[1.0, 2.0], [3.0, 4.0]]]) + guidance = LTXModalityGuidance(stg_scale=1.0, stg_blocks=()) + + plan = LTXGuidancePlan.build(LTXGuidanceSpec(video=guidance)) + actual = combine_guided_x0( + cond=cond, + uncond_text=0.0, + uncond_perturbed=0.0, + uncond_modality=0.0, + guidance=guidance, + ) + + assert plan.names == ("cond",) + torch.testing.assert_close(actual, cond) + + +def test_ltx_guidance_uses_official_close_to_disabled_semantics(): + guidance = LTXModalityGuidance( + cfg_scale=1.0 + 1e-10, + stg_scale=0.0, + modality_scale=1.0 + 1e-10, + stg_blocks=(0,), + ) + + assert LTXGuidancePlan.build(LTXGuidanceSpec(video=guidance)).names == ("cond",) + + +def test_ltx_guidance_rescale_is_invariant_to_request_batching(): + cond = torch.tensor( + [ + [[1.0, 2.0], [3.0, 4.0]], + [[10.0, 30.0], [50.0, 70.0]], + ] + ) + uncond = torch.stack((torch.zeros_like(cond[0]), cond[1])) + guidance = LTXModalityGuidance(cfg_scale=2.0, rescale_scale=1.0) + + actual = combine_guided_x0( + cond=cond, + uncond_text=uncond, + uncond_perturbed=0.0, + uncond_modality=0.0, + guidance=guidance, + ) + individually = torch.cat( + [ + combine_guided_x0( + cond=cond[index : index + 1], + uncond_text=uncond[index : index + 1], + uncond_perturbed=0.0, + uncond_modality=0.0, + guidance=guidance, + ) + for index in range(cond.shape[0]) + ] + ) + + # Fusing independent requests must not change either request's output. + torch.testing.assert_close(actual, individually) + + +def test_ltx_guidance_rescale_handles_zero_variance_prediction(): + cond = torch.tensor([[[1.0, 2.0], [3.0, 4.0]]]) + guidance = LTXModalityGuidance(cfg_scale=2.0, rescale_scale=1.0) + + actual = combine_guided_x0( + cond=cond, + uncond_text=2 * cond, + uncond_perturbed=0.0, + uncond_modality=0.0, + guidance=guidance, + ) + + assert torch.isfinite(actual).all() + torch.testing.assert_close(actual, torch.zeros_like(cond)) + + +def test_ltx_guidance_builds_per_sample_stg_and_modality_masks(): + plan = LTXGuidancePlan.build(LTX23_ONE_STAGE_RECIPE.request_guidance) + perturbations = build_perturbation_kwargs(plan, batch_size=2, reference=torch.ones(8, 1, 1)) + + assert perturbations["video_self_attention_blocks"] == (28,) + assert perturbations["audio_self_attention_blocks"] == (28,) + assert perturbations["video_self_attention_mask"].flatten().tolist() == [1, 1, 1, 1, 0, 0, 1, 1] + assert perturbations["a2v_cross_attention_mask"].flatten().tolist() == [1, 1, 1, 1, 1, 1, 0, 0] + torch.testing.assert_close( + perturbations["a2v_cross_attention_mask"], + perturbations["v2a_cross_attention_mask"], + ) + + +def test_ltx_default_sigma_schedule_matches_official_shift_and_stretch(): + scheduler = SimpleNamespace( + config={ + "base_image_seq_len": 1024, + "max_image_seq_len": 4096, + "base_shift": 0.95, + "max_shift": 2.05, + "shift_terminal": 0.1, + } + ) + + sigmas = _official_ltx_sigmas(scheduler, steps=4, device=torch.device("cpu")) + + torch.testing.assert_close( + sigmas, + torch.tensor([1.0, 0.8671, 0.6316, 0.1, 0.0]), + rtol=1e-4, + atol=1e-4, + ) + + +def test_ltx_sigma_schedule_does_not_stretch_when_terminal_config_is_missing(): + scheduler = SimpleNamespace( + config={ + "base_image_seq_len": 1024, + "max_image_seq_len": 4096, + "base_shift": 0.95, + "max_shift": 2.05, + } + ) + + sigmas = _official_ltx_sigmas(scheduler, steps=4, device=torch.device("cpu")) + + torch.testing.assert_close( + sigmas, + torch.tensor([1.0, 0.9589, 0.8859, 0.7214, 0.0]), + rtol=1e-4, + atol=1e-4, + ) + + +def test_ltx_sigma_schedule_does_not_stretch_when_terminal_is_disabled(): + scheduler = SimpleNamespace( + config={ + "base_image_seq_len": 1024, + "max_image_seq_len": 4096, + "base_shift": 0.95, + "max_shift": 2.05, + "shift_terminal": None, + } + ) + + sigmas = _official_ltx_sigmas(scheduler, steps=4, device=torch.device("cpu")) + + torch.testing.assert_close( + sigmas, + torch.tensor([1.0, 0.9589, 0.8859, 0.7214, 0.0]), + rtol=1e-4, + atol=1e-4, + ) + + +def test_ltx_component_loader_installs_omni_connector_attention(monkeypatch): + installed = [] + kernels = [] + + class OmniAttention: + def __init__(self, **kwargs): + kernels.append(kwargs) + + class Attention: + heads = 32 + head_dim = 128 + inner_kv_dim = 4096 + + def set_processor(self, processor): + installed.append(processor) + + monkeypatch.setattr(ltx2_components, "OmniAttention", OmniAttention) + connectors = SimpleNamespace( + video_connector=SimpleNamespace( + learnable_registers=object(), + transformer_blocks=[SimpleNamespace(attn1=Attention())], + ), + audio_connector=SimpleNamespace( + learnable_registers=object(), + transformer_blocks=[SimpleNamespace(attn1=Attention())], + ), + ) + + _install_connector_attention(connectors) + + assert len(installed) == 2 + assert all(processor.__class__.__name__ == "_LTXConnectorAttnProcessor" for processor in installed) + assert all(processor.has_learned_registers for processor in installed) + assert all(kernel["role"] == "ltx2.connector" for kernel in kernels) + assert all(kernel["role_category"] == "self" for kernel in kernels) + assert all(kernel["skip_sequence_parallel"] for kernel in kernels) + assert all(kernel["disable_kv_quant"] for kernel in kernels) + + +@pytest.mark.parametrize("has_learned_registers", [False, True]) +def test_ltx_connector_attention_dispatches_through_omni_kernel(has_learned_registers): + calls = [] + + class Backend: + @staticmethod + def get_name(): + return "FLASH_ATTN" + + class OmniAttention: + attn_backend = Backend + + def __call__(self, query, key, value, metadata): + calls.append((query, key, value, metadata)) + return query + + attention = SimpleNamespace( + heads=2, + head_dim=4, + inner_kv_dim=8, + norm_q=torch.nn.Identity(), + norm_k=torch.nn.Identity(), + to_q=torch.nn.Identity(), + to_k=torch.nn.Identity(), + to_v=torch.nn.Identity(), + to_out=(torch.nn.Identity(), torch.nn.Identity()), + to_gate_logits=None, + rope_type="split", + omni_attention=OmniAttention(), + ) + hidden_states = torch.randn(2, 3, 8) + additive_mask = torch.zeros(2, 1, 3, 3) + + output = ltx2_components._LTXConnectorAttnProcessor(has_learned_registers=has_learned_registers)( + attention, + hidden_states, + attention_mask=additive_mask, + ) + + query, key, value, metadata = calls[0] + assert query.shape == key.shape == value.shape == (2, 3, 2, 4) + if has_learned_registers: + assert metadata is None + else: + assert metadata.attn_mask.shape == (2, 3) + assert metadata.attn_mask.dtype == torch.bool + assert metadata.attn_mask.all() + torch.testing.assert_close(output, hidden_states) + + +def test_ltx2_distilled_pipeline_shares_recipe_runtime(): + assert issubclass(LTX2DistilledPipeline, LTXI2VConditioningMixin) + assert issubclass(LTX2DistilledPipeline, LTX2Pipeline) + assert LTX2DistilledPipeline._run_recipe is LTXRuntime._run_recipe + assert "_forward_request" not in LTX2DistilledPipeline.__dict__ + + +def test_ltx_variants_share_denoise_loop_and_i2v_conditioning(): + assert "_denoise_loop" not in LTX2Pipeline.__dict__ + assert issubclass(LTX2Pipeline, LTXI2VConditioningMixin) + assert LTX2Pipeline.prepare_latents is LTXI2VConditioningMixin.prepare_latents + assert LTX2Pipeline.prepare_audio_latents is LTXRuntime.prepare_audio_latents + assert LTX2Pipeline.decode_phase is LTXRuntime.decode_phase + + +def test_denoise_executor_owns_progress_and_interrupt(): + updates = [] + + class Progress: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def update(self): + updates.append(True) + + pipeline = SimpleNamespace( + interrupt=False, + progress_bar=lambda total: Progress(), + ) + seen: list[tuple[int, float]] = [] + timesteps = torch.tensor([3.0, 2.0, 1.0]) + initial_state = LTXAVState(video=torch.tensor(0.0), audio=torch.tensor(10.0)) + + def step(index, timestep, state): + seen.append((index, timestep.item())) + pipeline.interrupt = True + return LTXAVState(video=state.video + 1, audio=state.audio + 1) + + state = LTXDenoiseExecutor.run(pipeline, initial_state, timesteps, step) + + assert seen == [(0, 3.0)] + assert updates == [True] + torch.testing.assert_close(state.video, torch.tensor(1.0)) + torch.testing.assert_close(state.audio, torch.tensor(11.0)) + + +def test_ltx2_distilled_two_stage_executes_declarative_phase_plan(): + request_inputs = LTXRequestInputs( + prompt="prompt", + negative_prompt="", + height=64, + width=64, + num_frames=1, + frame_rate=24.0, + num_inference_steps=8, + guidance=LTXGuidanceSpec.positive_only(), + num_videos_per_prompt=1, + generator=None, + latents=None, + audio_latents=None, + prompt_embeds=None, + negative_prompt_embeds=None, + prompt_attention_mask=None, + negative_prompt_attention_mask=None, + decode_timestep=0.25, + decode_noise_scale=0.5, + output_type="np", + max_sequence_length=16, + ) + prompt_context = object() + phase_calls = [] + + def resolve_request_inputs(req, **kwargs): + return request_inputs + + def run_phase(req, inputs, *, prompt_context=None, **kwargs): + phase_recipe = kwargs["phase_recipe"] + assert kwargs["image"] is source_image + phase_calls.append((phase_recipe, inputs, prompt_context)) + if len(phase_calls) == 1: + assert prompt_context is None + assert inputs.decode_timestep == 0.25 + assert inputs.decode_noise_scale == 0.5 + context = prompt_context_sentinel + assert phase_recipe.name == "generate_lowres" + assert (inputs.height, inputs.width) == (32, 32) + assert inputs.num_inference_steps == 8 + assert kwargs["noise_scale"] == 1.0 + video = torch.ones(1, 128, 1, 1, 1) + audio = torch.full((1, 8, 1, 2), 2.0) + audio_for_next_phase = torch.full((1, 8, 1, 2), 5.0) + else: + assert prompt_context is prompt_context_sentinel + assert phase_recipe.name == "refine" + assert (inputs.height, inputs.width) == (64, 64) + assert inputs.latents.shape == (1, 128, 1, 2, 2) + torch.testing.assert_close(inputs.audio_latents, torch.full((1, 8, 1, 2), 5.0)) + assert inputs.audio_latents_normalized + assert inputs.guidance_scale == 1.0 + assert inputs.num_inference_steps == 3 + assert inputs.decode_timestep == 0.0 + assert inputs.decode_noise_scale is None + context = prompt_context + video = torch.full((1, 128, 1, 2, 2), 3.0) + audio = torch.full((1, 8, 1, 2), 4.0) + audio_for_next_phase = None + return LTXPhaseResult( + forward_context=SimpleNamespace(prompt_context=context), + video=video, + audio=audio, + audio_for_next_phase=audio_for_next_phase, + ) + + def decode_phase(phase): + return DiffusionOutput(output=(phase.video, phase.audio)) + + class FakeUpsampler(torch.nn.Module): + dtype = torch.float32 + + def forward(self, latents): + return latents.repeat_interleave(2, dim=-2).repeat_interleave(2, dim=-1) + + prompt_context_sentinel = prompt_context + pipeline = object.__new__(LTX2DistilledPipeline) + torch.nn.Module.__init__(pipeline) + pipeline.device = torch.device("cpu") + pipeline.vae_spatial_compression_ratio = 32 + pipeline.vae_temporal_compression_ratio = 8 + pipeline.latent_upsampler = FakeUpsampler() + object.__setattr__(pipeline, "_resolve_request_inputs", resolve_request_inputs) + object.__setattr__(pipeline, "run_phase", run_phase) + object.__setattr__(pipeline, "decode_phase", decode_phase) + + source_image = object() + output = pipeline.forward(SimpleNamespace(sampling_params_list=[], prompts=[]), image=source_image) + + assert len(phase_calls) == 2 + assert phase_calls[1][2] is prompt_context_sentinel + torch.testing.assert_close(output.output[0], torch.full((1, 128, 1, 2, 2), 3.0)) + torch.testing.assert_close(output.output[1], torch.full((1, 8, 1, 2), 4.0)) + + +def test_ltx2_distilled_stage2_reapplies_final_resolution_i2v_conditioning(): + from vllm_omni.diffusion.models.ltx2 import ltx2_latents + + pipeline = object.__new__(LTX2DistilledPipeline) + torch.nn.Module.__init__(pipeline) + pipeline.vae_spatial_compression_ratio = 32 + pipeline.vae_temporal_compression_ratio = 8 + pipeline.transformer_spatial_patch_size = 1 + pipeline.transformer_temporal_patch_size = 1 + pipeline.vae = SimpleNamespace( + latents_mean=torch.zeros(2), + latents_std=torch.ones(2), + config=SimpleNamespace(scaling_factor=1.0), + ) + object.__setattr__( + pipeline, + "_encode_i2v_image_latents", + lambda image, **_kwargs: torch.full((image.shape[0], 2, 1, 2, 2), 7.0), + ) + + packed, conditioning_mask = pipeline.prepare_latents( + image=torch.zeros(1, 3, 64, 64), + batch_size=1, + num_channels_latents=2, + height=64, + width=64, + num_frames=9, + noise_scale=0.0, + dtype=torch.float32, + device=torch.device("cpu"), + latents=torch.ones(1, 2, 2, 2, 2), + ) + unpacked = ltx2_latents.unpack_latents(packed, 2, 2, 2) + + torch.testing.assert_close(unpacked[:, :, :1], torch.full((1, 2, 1, 2, 2), 7.0)) + torch.testing.assert_close(unpacked[:, :, 1:], torch.ones(1, 2, 1, 2, 2)) + assert conditioning_mask.shape == (1, 8) + assert conditioning_mask[:, :4].all() + assert not conditioning_mask[:, 4:].any() + + +def test_ltx2_distilled_two_stage_recipe_is_fixed_positive_only(): + stage1, stage2 = LTX2_DISTILLED_TWO_STAGE_RECIPE.phases + + assert stage1.spatial_downscale == 2 + assert stage1.guidance == stage2.guidance == LTXGuidanceSpec.positive_only() + assert LTXGuidancePlan.build(stage1.guidance).names == ("cond",) + assert LTXGuidancePlan.build(stage2.guidance).names == ("cond",) + assert not stage1.allow_guidance_override + assert not stage2.allow_guidance_override + assert stage1.noise_scale == 1.0 + assert stage1.sigmas == (1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0) + assert stage2.sigmas == (0.909375, 0.725, 0.421875, 0.0) + assert not LTX2_DISTILLED_TWO_STAGE_RECIPE.allow_request_sigmas + assert not LTX2_DISTILLED_TWO_STAGE_RECIPE.allow_request_latents + assert not LTX2_DISTILLED_TWO_STAGE_RECIPE.allow_negative_prompt + assert LTX2_DISTILLED_TWO_STAGE_RECIPE.fixed_num_inference_steps + assert (LTX2_DISTILLED_TWO_STAGE_RECIPE.height, LTX2_DISTILLED_TWO_STAGE_RECIPE.width) == (1024, 1536) + + +@pytest.mark.parametrize( + ("request_inputs", "error"), + [ + (lambda request: replace(request, height=32), "divisible by 64"), + (lambda request: replace(request, num_frames=8), "8 \\* k \\+ 1"), + (lambda request: replace(request, num_inference_steps=7), "uses 8 fixed Stage 1"), + (lambda request: replace(request, latents=torch.empty(1)), "request-provided"), + (lambda request: replace(request, audio_latents=torch.empty(1)), "request-provided"), + (lambda request: replace(request, negative_prompt="negative"), "negative conditioning"), + (lambda request: replace(request, negative_prompt_embeds=torch.empty(1)), "negative conditioning"), + (lambda request: replace(request, guidance=LTX2_ONE_STAGE_RECIPE.request_guidance), "positive-only"), + ], +) +def test_ltx_two_stage_recipe_rejects_unsupported_request_values(request_inputs, error): + request = LTXRequestInputs( + prompt="prompt", + negative_prompt="", + height=64, + width=64, + num_frames=1, + frame_rate=24.0, + num_inference_steps=8, + guidance=LTXGuidanceSpec.positive_only(), + num_videos_per_prompt=1, + generator=None, + latents=None, + audio_latents=None, + prompt_embeds=None, + negative_prompt_embeds=None, + prompt_attention_mask=None, + negative_prompt_attention_mask=None, + decode_timestep=0.0, + decode_noise_scale=None, + output_type="np", + max_sequence_length=16, + ) + + with pytest.raises(ValueError, match=error): + validate_pipeline_request( + request_inputs(request), + pipeline_recipe=LTX2_DISTILLED_TWO_STAGE_RECIPE, + vae_spatial_compression_ratio=32, + vae_temporal_compression_ratio=8, + pipeline_name="LTX2DistilledPipeline", + ) + + +def test_ltx_two_stage_recipe_rejects_sigmas(): + request = LTXRequestInputs( + prompt="prompt", + negative_prompt="", + height=64, + width=64, + num_frames=1, + frame_rate=24.0, + num_inference_steps=8, + guidance=LTXGuidanceSpec.positive_only(), + num_videos_per_prompt=1, + generator=None, + latents=None, + audio_latents=None, + prompt_embeds=None, + negative_prompt_embeds=None, + prompt_attention_mask=None, + negative_prompt_attention_mask=None, + decode_timestep=0.0, + decode_noise_scale=None, + output_type="np", + max_sequence_length=16, + ) + + with pytest.raises(ValueError, match="fixed phase sigma schedules"): + validate_pipeline_request( + request, + pipeline_recipe=LTX2_DISTILLED_TWO_STAGE_RECIPE, + vae_spatial_compression_ratio=32, + vae_temporal_compression_ratio=8, + pipeline_name="LTX2DistilledPipeline", + request_sigmas=[1.0, 0.0], + ) + + +@pytest.mark.parametrize( + ("direct_kwargs", "sampling_kwargs", "error"), + [ + ({"sigmas": [1.0, 0.0]}, {}, "fixed phase sigma schedules"), + ({}, {"sigmas": [1.0, 0.0]}, "fixed phase sigma schedules"), + ({"latents": torch.empty(1)}, {}, "request-provided video or audio latents"), + ({}, {"latents": torch.empty(1)}, "request-provided video or audio latents"), + ({"audio_latents": torch.empty(1)}, {}, "request-provided video or audio latents"), + ( + {}, + {"extra_args": {"audio_latents": torch.empty(1)}}, + "request-provided video or audio latents", + ), + ({"negative_prompt": "negative"}, {}, "negative conditioning"), + ({"negative_prompt_embeds": torch.empty(1)}, {}, "negative conditioning"), + ({}, {"num_inference_steps": 7}, "uses 8 fixed Stage 1"), + ({"guidance_scale": 4.0}, {}, "fixed positive-only guidance"), + ({}, {"guidance_scale": 4.0}, "fixed positive-only guidance"), + ], + ids=( + "direct-sigmas", + "request-sigmas", + "direct-video-latents", + "request-video-latents", + "direct-audio-latents", + "request-audio-latents", + "direct-negative-prompt", + "direct-negative-embeds", + "request-num-steps", + "direct-guidance", + "request-guidance", + ), +) +def test_ltx_distilled_forward_rejects_fixed_recipe_overrides(direct_kwargs, sampling_kwargs, error): + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + sampling_values = { + "height": 64, + "width": 64, + "num_frames": 1, + "num_inference_steps": 8, + **sampling_kwargs, + } + sampling = OmniDiffusionSamplingParams(**sampling_values) + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt="prompt", + sampling_params=sampling, + request_id="ltx-distilled-fixed-inputs", + ) + ] + ) + pipeline = _make_ltx_request_pipe(LTX2DistilledPipeline) + + with pytest.raises(ValueError, match=error): + pipeline.forward(req, **direct_kwargs) + + +def test_ltx_distilled_dummy_run_uses_fixed_recipe_values(): + from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID, OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt="dummy run", + sampling_params=OmniDiffusionSamplingParams( + height=512, + width=512, + num_frames=LTX2DistilledPipeline.dummy_run_num_frames, + num_inference_steps=1, + guidance_scale=0.0, + ), + request_id=DUMMY_DIFFUSION_REQUEST_ID, + ) + ] + ) + pipeline = _make_ltx_request_pipe(LTX2DistilledPipeline) + seen = {} + + def fake_run_recipe(_req, request_inputs, **_kwargs): + seen["request_inputs"] = request_inputs + return "dummy-output" + + object.__setattr__(pipeline, "_run_recipe", fake_run_recipe) + + assert pipeline.forward(req) == "dummy-output" + assert seen["request_inputs"].num_frames == 1 + assert seen["request_inputs"].num_inference_steps == 8 + assert seen["request_inputs"].guidance == LTXGuidanceSpec.positive_only() + + +def test_ltx_custom_sigmas_bypass_scheduler_shifting(): + from diffusers import FlowMatchEulerDiscreteScheduler + + scheduler = FlowMatchEulerDiscreteScheduler( + use_dynamic_shifting=True, + base_shift=0.95, + max_shift=2.05, + shift_terminal=0.1, + ) + pipeline = SimpleNamespace(scheduler=scheduler) + sigmas = [1.0, 0.75, 0.25, 0.0] + + audio_scheduler, _, timesteps = prepare_scheduler_stage( + pipeline, + SimpleNamespace(num_inference_steps=3), + device=torch.device("cpu"), + sigmas=sigmas, + timesteps=None, + latent_num_frames=1, + latent_height=1, + latent_width=1, + use_official_sigma_schedule=False, + ) + + expected = torch.tensor(sigmas, dtype=torch.float32) + torch.testing.assert_close(pipeline.scheduler.sigmas, expected) + torch.testing.assert_close(audio_scheduler.sigmas, expected) + torch.testing.assert_close(timesteps, expected[:-1] * scheduler.config.num_train_timesteps) + + +class TestLTXRequestParsing: + def test_request_resolves_official_and_explicit_negative_prompts_per_sample(self): + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt=prompt, + sampling_params=OmniDiffusionSamplingParams(num_inference_steps=2), + request_id=f"ltx-negative-prompt-{index}", + ) + for index, prompt in enumerate( + ( + "use the official default", + {"prompt": "disable the default", "negative_prompt": ""}, + {"prompt": "custom negative", "negative_prompt": "custom"}, + ) + ) + ] + ) + + resolved = _resolve_request_inputs_for_test(_make_ltx_request_pipe(LTX2Pipeline), req) + + assert resolved.negative_prompt == [LTX_DEFAULT_NEGATIVE_PROMPT, "", "custom"] + + def test_forward_negative_prompt_fallback_is_not_discarded(self): + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt="prompt", + sampling_params=OmniDiffusionSamplingParams(num_inference_steps=2), + request_id="ltx-forward-negative-prompt", + ) + ] + ) + + resolved = _resolve_request_inputs_for_test( + _make_ltx_request_pipe(LTX2Pipeline), + req, + negative_prompt="forward fallback", + ) + + assert resolved.negative_prompt == ["forward fallback"] + + def test_unified_t2v_i2v_entry_resolves_request_inputs(self): + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + prompt_embeds = torch.tensor([[1.0, 2.0]]) + negative_prompt_embeds = torch.tensor([[3.0, 4.0]]) + prompt_attention_mask = torch.tensor([True, False]) + negative_attention_mask = torch.tensor([False, True]) + video_latents = torch.ones(1, 2, 3) + audio_latents = torch.zeros(1, 2, 3) + generator = torch.Generator().manual_seed(123) + + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt={ + "prompt": "shared prompt", + "negative_prompt": "shared negative", + "additional_information": { + "prompt_embeds": prompt_embeds, + "negative_prompt_embeds": negative_prompt_embeds, + "attention_mask": prompt_attention_mask, + "negative_attention_mask": negative_attention_mask, + }, + }, + sampling_params=OmniDiffusionSamplingParams( + height=384, + width=512, + num_frames=25, + frame_rate=12.5, + num_inference_steps=1, + num_outputs_per_prompt=2, + guidance_scale=6.0, + generator=generator, + latents=video_latents, + extra_args={"audio_latents": audio_latents}, + decode_timestep=[0.1], + decode_noise_scale=[0.2], + output_type="latent", + max_sequence_length=17, + ), + request_id="ltx23-shared-request-inputs", + ) + ] + ) + + resolved = _resolve_request_inputs_for_test(_make_ltx_request_pipe(LTX2Pipeline), req) + + assert resolved.prompt is None + assert resolved.negative_prompt is None + assert resolved.height == 384 + assert resolved.width == 512 + assert resolved.num_frames == 25 + assert resolved.frame_rate == 12.5 + assert resolved.num_inference_steps == 2 + assert resolved.guidance_scale == 6.0 + assert resolved.num_videos_per_prompt == 2 + assert isinstance(resolved.generator, list) + assert [gen.initial_seed() for gen in resolved.generator] == [123, 123] + assert resolved.decode_timestep == [0.1] + assert resolved.decode_noise_scale == [0.2] + assert resolved.output_type == "latent" + assert resolved.max_sequence_length == 17 + torch.testing.assert_close(resolved.latents, video_latents) + torch.testing.assert_close(resolved.audio_latents, audio_latents) + torch.testing.assert_close(resolved.prompt_embeds, torch.stack([prompt_embeds])) + torch.testing.assert_close(resolved.negative_prompt_embeds, torch.stack([negative_prompt_embeds])) + torch.testing.assert_close(resolved.prompt_attention_mask, torch.stack([prompt_attention_mask])) + torch.testing.assert_close(resolved.negative_prompt_attention_mask, torch.stack([negative_attention_mask])) + + def test_nonbatch_distilled_request_uses_precomputed_positive_embeddings(self): + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + prompt_embeds = torch.tensor([[1.0, 2.0]]) + prompt_attention_mask = torch.tensor([True, False]) + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt={ + "prompt": "must not be forwarded with embeddings", + "additional_information": { + "prompt_embeds": prompt_embeds, + "attention_mask": prompt_attention_mask, + }, + }, + sampling_params=OmniDiffusionSamplingParams(num_inference_steps=8), + request_id="ltx-distilled-prompt-embeds", + ) + ] + ) + + resolved = _resolve_request_inputs_for_test( + _make_ltx_request_pipe(LTX2DistilledPipeline), + req, + guidance_scale=None, + ) + + assert resolved.prompt is None + assert resolved.negative_prompt == [""] + torch.testing.assert_close(resolved.prompt_embeds, torch.stack([prompt_embeds])) + torch.testing.assert_close(resolved.prompt_attention_mask, torch.stack([prompt_attention_mask])) + + def test_request_resolves_per_modality_guidance_and_common_cfg_override(self): + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt={"prompt": "prompt", "negative_prompt": "negative"}, + sampling_params=OmniDiffusionSamplingParams( + num_inference_steps=2, + extra_args={ + "video_cfg_scale": 4.0, + "audio_cfg_guidance_scale": 6.0, + "video_stg_guidance_scale": 0.5, + "audio_stg_scale": 0.25, + "a2v_guidance_scale": 2.0, + "v2a_guidance_scale": 4.0, + "video_rescale_scale": 0.2, + "audio_rescale_scale": 0.3, + "video_stg_blocks": [3, 4], + "audio_stg_blocks": 5, + }, + ), + request_id="ltx-guidance-overrides", + ) + ] + ) + pipe = _make_ltx_request_pipe(LTX2Pipeline) + + resolved = _resolve_request_inputs_for_test(pipe, req, guidance_scale=None) + + assert resolved.guidance.video == LTXModalityGuidance(4.0, 0.5, 2.0, 0.2, (3, 4)) + assert resolved.guidance.audio == LTXModalityGuidance(6.0, 0.25, 4.0, 0.3, (5,)) + + common = _resolve_request_inputs_for_test(pipe, req, guidance_scale=2.0) + assert common.guidance.video.cfg_scale == common.guidance.audio.cfg_scale == 2.0 + # LTX exposes independent video/audio rescale controls, not the + # model-agnostic sampling field. + assert common.guidance.video.rescale_scale == 0.2 + assert common.guidance.audio.rescale_scale == 0.3 + + def test_request_rejects_unsupported_generic_scheduler_and_rescale_controls(self): + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + cases = ( + (OmniDiffusionSamplingParams(timesteps=torch.tensor([1.0])), "timesteps"), + (OmniDiffusionSamplingParams(guidance_rescale=0.5), "guidance_rescale"), + (OmniDiffusionSamplingParams(extra_args={"flow_shift": 3.0}), "flow_shift"), + ) + pipe = _make_ltx_request_pipe(LTX2Pipeline) + + for index, (sampling, field_name) in enumerate(cases): + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt="prompt", + sampling_params=sampling, + request_id=f"ltx-unsupported-{index}", + ) + ] + ) + with pytest.raises(ValueError, match=field_name): + _resolve_request_inputs_for_test(pipe, req) + + def test_request_batch_rejects_mixed_guidance_specs(self): + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt="prompt-0", + sampling_params=OmniDiffusionSamplingParams( + num_inference_steps=2, + extra_args={"video_cfg_scale": scale}, + ), + request_id=f"ltx-mixed-guidance-{index}", + ) + for index, scale in enumerate((3.0, 4.0)) + ] + ) + + with pytest.raises(ValueError, match="identical video/audio guidance"): + _resolve_request_inputs_for_test( + _make_ltx_request_pipe(LTX2Pipeline), + req, + guidance_scale=None, + ) + + def test_request_input_resolution_rejects_mixed_precomputed_prompt_fields(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + pipe = _make_ltx_request_pipe(LTX2Pipeline) + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt={ + "prompt": "with embeds", + "additional_information": {"prompt_embeds": torch.tensor([[1.0]])}, + }, + sampling_params=OmniDiffusionSamplingParams( + height=384, + width=512, + num_frames=25, + num_inference_steps=2, + ), + request_id="ltx23-mixed-precomputed-fields-0", + ), + OmniDiffusionRequest( + prompt={"prompt": "without embeds"}, + sampling_params=OmniDiffusionSamplingParams( + height=384, + width=512, + num_frames=25, + num_inference_steps=2, + ), + request_id="ltx23-mixed-precomputed-fields-1", + ), + ] + ) + + with pytest.raises(ValueError, match="mix of provided and missing prompt_embeds"): + _resolve_request_inputs_for_test(pipe, req) + + +class TestLTXForwardStages: + def test_encode_prompt_batches_positive_and_negative_gemma_inputs(self): + pipe = _make_ltx_request_pipe(LTX2Pipeline) + calls = [] + + def fake_get_gemma_prompt_embeds(**kwargs): + calls.append(kwargs) + count = len(kwargs["prompt"]) + embeds = torch.arange(count, dtype=torch.float32).view(count, 1, 1) + mask = torch.arange(count).view(count, 1) + return embeds, mask + + object.__setattr__(pipe, "_get_gemma_prompt_embeds", fake_get_gemma_prompt_embeds) + + prompt_embeds, prompt_mask, negative_embeds, negative_mask = pipe.encode_prompt( + prompt=["positive-0", "positive-1"], + negative_prompt=["negative-0", "negative-1"], + do_classifier_free_guidance=True, + num_videos_per_prompt=2, + device=torch.device("cpu"), + ) + + assert len(calls) == 1 + assert calls[0]["prompt"] == ["positive-0", "positive-1", "negative-0", "negative-1"] + assert calls[0]["num_videos_per_prompt"] == 1 + torch.testing.assert_close(prompt_embeds.flatten(), torch.tensor([0.0, 0.0, 1.0, 1.0])) + torch.testing.assert_close(negative_embeds.flatten(), torch.tensor([2.0, 2.0, 3.0, 3.0])) + torch.testing.assert_close(prompt_mask.flatten(), torch.tensor([0, 0, 1, 1])) + torch.testing.assert_close(negative_mask.flatten(), torch.tensor([2, 2, 3, 3])) + + def test_encode_prompt_repeats_precomputed_embeds_for_num_outputs(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + + pipe = _make_ltx_request_pipe(LTX2Pipeline) + prompt_embeds = torch.tensor([[[1.0], [2.0]], [[3.0], [4.0]]]) + negative_prompt_embeds = torch.tensor([[[-1.0], [-2.0]], [[-3.0], [-4.0]]]) + prompt_attention_mask = torch.tensor([[True, False], [False, True]]) + negative_prompt_attention_mask = torch.tensor([[False, False], [True, True]]) + + ( + repeated_prompt_embeds, + repeated_prompt_attention_mask, + repeated_negative_prompt_embeds, + repeated_negative_prompt_attention_mask, + ) = pipe.encode_prompt( + prompt=None, + do_classifier_free_guidance=True, + num_videos_per_prompt=2, + prompt_embeds=prompt_embeds, + negative_prompt_embeds=negative_prompt_embeds, + prompt_attention_mask=prompt_attention_mask, + negative_prompt_attention_mask=negative_prompt_attention_mask, + device=torch.device("cpu"), + ) + + torch.testing.assert_close( + repeated_prompt_embeds, + torch.tensor([[[1.0], [2.0]], [[1.0], [2.0]], [[3.0], [4.0]], [[3.0], [4.0]]]), + ) + torch.testing.assert_close( + repeated_negative_prompt_embeds, + torch.tensor([[[-1.0], [-2.0]], [[-1.0], [-2.0]], [[-3.0], [-4.0]], [[-3.0], [-4.0]]]), + ) + torch.testing.assert_close( + repeated_prompt_attention_mask, + torch.tensor([[True, False], [True, False], [False, True], [False, True]]), + ) + torch.testing.assert_close( + repeated_negative_prompt_attention_mask, + torch.tensor([[False, False], [False, False], [True, True], [True, True]]), + ) + + def test_t2v_forward_delegates_to_shared_recipe_runtime(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + pipe = _make_ltx_request_pipe(LTX2Pipeline) + request_sigmas = [1.0, 0.4] + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt="stage refactor prompt", + sampling_params=OmniDiffusionSamplingParams( + height=384, + width=512, + num_frames=25, + frame_rate=16, + num_inference_steps=3, + guidance_scale=4.5, + output_type="latent", + sigmas=request_sigmas, + ), + request_id="ltx23-t2v-forward-stage-delegation", + ) + ] + ) + fallback_sigmas = [1.0, 0.5] + seen = {} + + def fake_run_recipe(req_arg, request_inputs, **kwargs): + seen["req"] = req_arg + seen["request_inputs"] = request_inputs + seen["kwargs"] = kwargs + return ["delegated"] + + object.__setattr__(pipe, "_run_recipe", fake_run_recipe) + + output = pipe.forward(req, sigmas=fallback_sigmas) + + assert output == ["delegated"] + assert seen["req"] is req + assert seen["request_inputs"].height == 384 + assert seen["request_inputs"].width == 512 + assert seen["request_inputs"].num_frames == 25 + assert seen["request_inputs"].frame_rate == 16.0 + assert seen["request_inputs"].guidance_scale == 4.5 + assert seen["request_inputs"].output_type == "latent" + assert seen["kwargs"] == { + "request_sigmas": request_sigmas, + "image": None, + } + + def test_t2v_forward_prefers_request_sigmas(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + request_sigmas = [1.0, 0.25] + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt="custom sigma schedule", + sampling_params=OmniDiffusionSamplingParams( + num_inference_steps=2, + sigmas=request_sigmas, + ), + request_id="ltx23-request-sigmas", + ) + ] + ) + pipe = _make_ltx_request_pipe(LTX2Pipeline) + seen = {} + + def fake_run_recipe(req_arg, request_inputs, **kwargs): + del req_arg, request_inputs + seen.update(kwargs) + return ["delegated"] + + object.__setattr__(pipe, "_run_recipe", fake_run_recipe) + + output = pipe.forward(req, sigmas=[1.0, 0.5]) + + assert output == ["delegated"] + assert seen["request_sigmas"] == request_sigmas + + +class TestPipelineComponents: + def test_ltx_pipeline_declares_offload_components(self): + """LTX2Pipeline exposes all one-stage modules to offload discovery.""" + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline + from vllm_omni.diffusion.offloader.module_collector import ModuleDiscovery + + pipe = object.__new__(LTX2Pipeline) + torch.nn.Module.__init__(pipe) + pipe.transformer = torch.nn.Linear(1, 1) + pipe.text_encoder = torch.nn.Linear(1, 1) + pipe.connectors = torch.nn.Linear(1, 1) + pipe.vae = torch.nn.Linear(1, 1) + pipe.audio_vae = torch.nn.Linear(1, 1) + pipe.vocoder = torch.nn.Linear(1, 1) + + modules = ModuleDiscovery.discover(pipe) + + assert LTX2Pipeline._dit_modules == ["transformer"] + assert LTX2Pipeline._encoder_modules == ["text_encoder", "connectors"] + assert LTX2Pipeline._vae_modules == ["vae", "audio_vae"] + assert LTX2Pipeline._resident_modules == ["vocoder"] + assert modules.dit_names == ["transformer"] + assert modules.encoder_names == ["text_encoder", "connectors"] + assert modules.resident_names == ["vocoder"] + assert len(modules.vaes) == 2 + + def test_distilled_profile_keeps_upsampler_in_managed_resident_components(self): + assert LTX2_DISTILLED_COMPONENT_PROFILE.resident_modules == ("vocoder", "latent_upsampler") + + +class TestLTX23DecodeConditioning: + def test_decode_conditioning_expands_per_prompt_values_to_effective_batch(self): + from vllm_omni.diffusion.models.ltx2.ltx2_runtime import _expand_per_prompt_decode_value + + assert _expand_per_prompt_decode_value( + [0.1, 0.2], + prompt_batch_size=2, + effective_batch_size=4, + field_name="decode_timestep", + ) == [0.1, 0.1, 0.2, 0.2] + assert _expand_per_prompt_decode_value( + [0.3], + prompt_batch_size=2, + effective_batch_size=4, + field_name="decode_timestep", + ) == [0.3, 0.3, 0.3, 0.3] + assert _expand_per_prompt_decode_value( + [0.1, 0.2, 0.3, 0.4], + prompt_batch_size=2, + effective_batch_size=4, + field_name="decode_timestep", + ) == [0.1, 0.2, 0.3, 0.4] + + def test_decode_conditioning_rejects_ambiguous_lengths(self): + from vllm_omni.diffusion.models.ltx2.ltx2_runtime import _expand_per_prompt_decode_value + + with pytest.raises(ValueError, match="decode_timestep"): + _expand_per_prompt_decode_value( + [0.1, 0.2, 0.3], + prompt_batch_size=2, + effective_batch_size=4, + field_name="decode_timestep", + ) + + +class TestRegistryIntegration: + """Verify the unified one-stage and existing two-stage entries.""" + + def test_pipeline_module_paths(self): + """Registry entries must point to the correct modules.""" + from vllm_omni.diffusion.registry import _DIFFUSION_MODELS + + assert _DIFFUSION_MODELS["LTX2Pipeline"] == ("ltx2", "pipeline_ltx2", "LTX2Pipeline") + assert _DIFFUSION_MODELS["LTX2DistilledPipeline"] == ( + "ltx2", + "pipeline_ltx2_two_stage", + "LTX2DistilledPipeline", + ) + removed_entries = { + "LTX2ImageToVideoPipeline", + "LTX23Pipeline", + "LTX23ImageToVideoPipeline", + "LTX2TwoStagesPipeline", + "LTX2ImageToVideoTwoStagesPipeline", + } + assert removed_entries.isdisjoint(_DIFFUSION_MODELS) + + def test_post_process_funcs_registered(self): + """Pipeline variants must map to get_ltx2_post_process_func.""" + from vllm_omni.diffusion.registry import _DIFFUSION_POST_PROCESS_FUNCS + + expected = [ + "LTX2Pipeline", + "LTX2DistilledPipeline", + "LTX2T2VDMD2Pipeline", + "LTX2I2VDMD2Pipeline", + ] + for name in expected: + assert name in _DIFFUSION_POST_PROCESS_FUNCS, f"{name} not in _DIFFUSION_POST_PROCESS_FUNCS" + assert _DIFFUSION_POST_PROCESS_FUNCS[name] == "get_ltx2_post_process_func" + + @pytest.mark.parametrize( + "model_class_name", + [ + "LTX2Pipeline", + "LTX2DistilledPipeline", + "LTX2T2VDMD2Pipeline", + "LTX2I2VDMD2Pipeline", + ], + ) + def test_post_process_func_resolves_from_every_entry_module(self, model_class_name): + from vllm_omni.diffusion.registry import get_diffusion_post_process_func + + with tempfile.TemporaryDirectory() as tmpdir: + vocoder_dir = os.path.join(tmpdir, "vocoder") + os.makedirs(vocoder_dir) + with open(os.path.join(vocoder_dir, "config.json"), "w") as config_file: + json.dump({"output_sampling_rate": 48000}, config_file) + + post_process = get_diffusion_post_process_func( + SimpleNamespace(model_class_name=model_class_name, model=tmpdir) + ) + + result = post_process((torch.zeros(1), torch.zeros(1))) + assert result["audio_sample_rate"] == 48000 + + def test_cache_dit_for_ltx2_does_not_have_custom_enablers_registered(self): + """Pipeline variants are *not* registered in CUSTOM_DIT_ENABLERS.""" + from vllm_omni.diffusion.cache.cachedit import CUSTOM_DIT_ENABLERS + + # NOTE: We used to have custom enablers for this model, but refactored to handle + # it more generically. Now we only need to ensure it has git cache adapter config. + expected = ["LTX2Pipeline"] + for name in expected: + assert name not in CUSTOM_DIT_ENABLERS, f"{name} not in CUSTOM_DIT_ENABLERS" + + +class TestVocoderSampleRateDetection: + """Test _detect_vocoder_output_sample_rate logic.""" + + def test_detects_48khz_from_config(self): + """Should detect output_sampling_rate=48000 from vocoder/config.json.""" + from vllm_omni.diffusion.models.ltx2.ltx2_components import _detect_vocoder_output_sample_rate + + with tempfile.TemporaryDirectory() as tmpdir: + vocoder_dir = os.path.join(tmpdir, "vocoder") + os.makedirs(vocoder_dir) + with open(os.path.join(vocoder_dir, "config.json"), "w") as f: + json.dump({"output_sampling_rate": 48000, "input_sampling_rate": 16000}, f) + + result = _detect_vocoder_output_sample_rate(tmpdir) + assert result == 48000 + + def test_returns_none_for_no_output_sr(self): + """Should return None if vocoder config has no output_sampling_rate.""" + from vllm_omni.diffusion.models.ltx2.ltx2_components import _detect_vocoder_output_sample_rate + + with tempfile.TemporaryDirectory() as tmpdir: + vocoder_dir = os.path.join(tmpdir, "vocoder") + os.makedirs(vocoder_dir) + with open(os.path.join(vocoder_dir, "config.json"), "w") as f: + json.dump({"sampling_rate": 16000}, f) + + result = _detect_vocoder_output_sample_rate(tmpdir) + assert result is None + + def test_returns_none_for_missing_directory(self): + """Should return None if vocoder directory doesn't exist.""" + from vllm_omni.diffusion.models.ltx2.ltx2_components import _detect_vocoder_output_sample_rate + + result = _detect_vocoder_output_sample_rate("/nonexistent/path") + assert result is None + + +class TestPostProcessFunction: + """Test the post-process function factory.""" + + def test_post_process_includes_audio_sample_rate(self): + """Post-process func should include audio_sample_rate when detected.""" + import torch + + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import get_ltx2_post_process_func + + with tempfile.TemporaryDirectory() as tmpdir: + vocoder_dir = os.path.join(tmpdir, "vocoder") + os.makedirs(vocoder_dir) + with open(os.path.join(vocoder_dir, "config.json"), "w") as f: + json.dump({"output_sampling_rate": 48000}, f) + + # Create a minimal od_config mock + class MockConfig: + model = tmpdir + + func = get_ltx2_post_process_func(MockConfig()) + + video = torch.zeros(1, 3, 4, 64, 64) + audio = torch.zeros(1, 1, 48000) + result = func((video, audio)) + + assert isinstance(result, dict) + assert "video" in result + assert "audio" in result + assert result["audio_sample_rate"] == 48000 + + def test_post_process_without_vocoder_config(self): + """Post-process func should work without vocoder config (no audio_sample_rate key).""" + import torch + + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import get_ltx2_post_process_func + + class MockConfig: + model = "/nonexistent/path" + + func = get_ltx2_post_process_func(MockConfig()) + + video = torch.zeros(1, 3, 4, 64, 64) + audio = torch.zeros(1, 1, 16000) + result = func((video, audio)) + + assert isinstance(result, dict) + assert "video" in result + assert "audio" in result + assert "audio_sample_rate" not in result diff --git a/tests/diffusion/models/ltx2/test_ltx2_transformer.py b/tests/diffusion/models/ltx2/test_ltx2_transformer.py index e71253297..73e10463e 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_transformer.py +++ b/tests/diffusion/models/ltx2/test_ltx2_transformer.py @@ -1,16 +1,23 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import pytest from torch import nn -from vllm_omni.diffusion.models.ltx2.ltx2_transformer import _make_rms_norm +from vllm_omni.diffusion.cache.cachedit import CacheDiTAdapterConfig +from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel, _make_rms_norm + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] -def test_ltx_rms_norm_no_affine_identity_weight_is_non_persistent_buffer(): +def test_ltx_rms_norm_no_affine_matches_official_torch_module(): norm = _make_rms_norm(8, eps=1e-6, elementwise_affine=False) assert "weight" not in dict(norm.named_parameters()) - assert "weight" in dict(norm.named_buffers()) + assert norm.weight is None + assert "weight" not in dict(norm.named_buffers()) assert "weight" not in norm.state_dict() @@ -20,3 +27,42 @@ def test_ltx_rms_norm_affine_weight_remains_parameter(): assert isinstance(dict(norm.named_parameters())["weight"], nn.Parameter) assert "weight" not in dict(norm.named_buffers()) assert "weight" in norm.state_dict() + + +def test_ltx_transformer_has_separate_cfg_cache_dit_config(): + adapter_config = getattr(LTX2VideoTransformer3DModel, "_cache_dit_adapter_config") + + assert isinstance(adapter_config, CacheDiTAdapterConfig) + assert adapter_config.has_separate_cfg + + +def test_ltx_transformer_exposes_hsdp_shard_conditions_for_blocks(): + model = object.__new__(LTX2VideoTransformer3DModel) + nn.Module.__init__(model) + model.transformer_blocks = nn.ModuleList([nn.Linear(4, 4) for _ in range(2)]) + model.norm_out = nn.LayerNorm(4) + + conditions = getattr(model, "_hsdp_shard_conditions", None) + + assert conditions is not None + assert len(conditions) == 1 + + matched = [] + for name, module in model.named_modules(): + if any(condition(name, module) for condition in conditions): + matched.append(name) + + assert matched == ["transformer_blocks.0", "transformer_blocks.1"] + + +@pytest.mark.parametrize("rope_type", ["interleaved", "split"]) +def test_ltx_sp_plan_shards_video_and_audio_timesteps_together(rope_type): + root_plan = LTX2VideoTransformer3DModel._build_sp_plan(rope_type)[""] + + video_timestep = root_plan["timestep"] + audio_timestep = root_plan["audio_timestep"] + + assert video_timestep.split_dim == audio_timestep.split_dim == 1 + assert video_timestep.expected_dims == audio_timestep.expected_dims == 2 + assert not video_timestep.split_output + assert not audio_timestep.split_output diff --git a/tests/diffusion/models/ltx2/test_ltx2_3_vae_tiling.py b/tests/diffusion/models/ltx2/test_ltx2_vae.py similarity index 82% rename from tests/diffusion/models/ltx2/test_ltx2_3_vae_tiling.py rename to tests/diffusion/models/ltx2/test_ltx2_vae.py index 9b53bb4f8..108bb960d 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_3_vae_tiling.py +++ b/tests/diffusion/models/ltx2/test_ltx2_vae.py @@ -11,24 +11,64 @@ import torch pytestmark = [pytest.mark.core_model, pytest.mark.cpu] -class TestLTX23OutputRank: - def test_single_process_rank_is_output_rank(self, monkeypatch): - import vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 as ltx23 +class TestLTXOutputRank: + @pytest.mark.parametrize( + ("distributed_vae_state", "expected_decode_calls"), + [(False, 0), (True, 1), (RuntimeError("unavailable"), None)], + ) + def test_non_output_rank_only_enters_collective_vae_decode( + self, + monkeypatch, + distributed_vae_state, + expected_decode_calls, + ): + import vllm_omni.diffusion.models.ltx2.ltx2_runtime as ltx_runtime + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline - monkeypatch.setattr(ltx23.torch.distributed, "is_initialized", lambda: False) + class FakeVae: + dtype = torch.float32 + config = SimpleNamespace(timestep_conditioning=False) - assert ltx23._is_output_rank() is True - assert ltx23._should_decode_video_on_rank(SimpleNamespace(is_distributed_enabled=lambda: False)) is True + def __init__(self): + self.decode_calls = 0 - def test_non_output_rank_skips_decode_unless_vae_decode_is_distributed(self, monkeypatch): - import vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 as ltx23 + def is_distributed_enabled(self): + if isinstance(distributed_vae_state, Exception): + raise distributed_vae_state + return distributed_vae_state - monkeypatch.setattr(ltx23.torch.distributed, "is_initialized", lambda: True) - monkeypatch.setattr(ltx23.torch.distributed, "get_rank", lambda: 1) + def decode(self, *_args, **_kwargs): + self.decode_calls += 1 + return (torch.ones(1, 1),) - assert ltx23._is_output_rank() is False - assert ltx23._should_decode_video_on_rank(SimpleNamespace(is_distributed_enabled=lambda: False)) is False - assert ltx23._should_decode_video_on_rank(SimpleNamespace(is_distributed_enabled=lambda: True)) is True + monkeypatch.setattr(ltx_runtime.torch.distributed, "is_initialized", lambda: True) + monkeypatch.setattr(ltx_runtime.torch.distributed, "get_rank", lambda: 1) + pipe = object.__new__(LTX2Pipeline) + torch.nn.Module.__init__(pipe) + pipe.vae = FakeVae() + + decode_kwargs = { + "latents": torch.ones(1, 1), + "audio_latents": torch.ones(1, 1), + "output_type": "np", + "connector_prompt_embeds": torch.ones(1, 1), + "generator": None, + "device": torch.device("cpu"), + "decode_timestep": 0.0, + "decode_noise_scale": None, + "prompt_batch_size": 1, + } + if isinstance(distributed_vae_state, Exception): + with pytest.raises(RuntimeError, match="unavailable"): + pipe._decode_output(**decode_kwargs) + assert pipe.vae.decode_calls == 0 + return + + output = pipe._decode_output(**decode_kwargs) + + assert pipe.vae.decode_calls == expected_decode_calls + assert output.output[0].numel() == 0 + assert output.output[1].numel() == 0 class TestLTX23VaeDistributedDecode: diff --git a/tests/diffusion/models/minimax_h3/__init__.py b/tests/diffusion/models/minimax_h3/__init__.py new file mode 100644 index 000000000..208f01a7c --- /dev/null +++ b/tests/diffusion/models/minimax_h3/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py new file mode 100644 index 000000000..70f00f4b7 --- /dev/null +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py @@ -0,0 +1,1403 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import json +from multiprocessing.reduction import ForkingPickler +from types import SimpleNamespace +from unittest.mock import Mock + +import numpy as np +import pytest +import torch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +def test_pipeline_import_registry_and_component_discovery(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + from vllm_omni.diffusion.registry import ( + _DIFFUSION_MODELS, + _DIFFUSION_POST_PROCESS_FUNCS, + ) + + assert _DIFFUSION_MODELS["MiniMaxH3Pipeline"] == ( + "minimax_h3", + "pipeline_minimax_h3", + "MiniMaxH3Pipeline", + ) + assert _DIFFUSION_MODELS["MiniMaxH3ModularPipeline"] == _DIFFUSION_MODELS["MiniMaxH3Pipeline"] + assert _DIFFUSION_POST_PROCESS_FUNCS["MiniMaxH3Pipeline"] == "get_minimax_h3_post_process_func" + assert ( + _DIFFUSION_POST_PROCESS_FUNCS["MiniMaxH3ModularPipeline"] == _DIFFUSION_POST_PROCESS_FUNCS["MiniMaxH3Pipeline"] + ) + assert MiniMaxH3Pipeline._dit_modules == ["transformer", "transformers_ref"] + assert MiniMaxH3Pipeline._encoder_modules == ["text_encoder"] + assert MiniMaxH3Pipeline._vae_modules == ["video_vae", "audio_vae"] + + +def _write_partition_index(path, *, partition, tasks): + path.mkdir(parents=True) + (path / "model_index.json").write_text( + json.dumps( + { + "_class_name": "MiniMaxH3Pipeline", + "_diffusers_version": "0.32.2", + "_minimax_h3": { + "partition": partition, + "tasks": tasks, + "sigma_shift_scales": {"video": 12.0, "audio": 3.0}, + }, + } + ), + encoding="utf-8", + ) + + +def test_modular_diffusers_index_is_resolved_generically(tmp_path): + from vllm_omni.diffusion.data import OmniDiffusionConfig, resolve_model_class_name + from vllm_omni.diffusion.utils.hf_utils import is_diffusion_model + from vllm_omni.entrypoints.utils import resolve_model_config_path + + (tmp_path / "modular_model_index.json").write_text( + json.dumps( + { + "_class_name": "MiniMaxH3ModularPipeline", + "_diffusers_version": "0.36.0.dev0", + } + ), + encoding="utf-8", + ) + + assert is_diffusion_model(str(tmp_path)) + assert resolve_model_class_name(str(tmp_path)) == "MiniMaxH3ModularPipeline" + assert resolve_model_config_path(str(tmp_path)) is None + + config = OmniDiffusionConfig(model=str(tmp_path)) + config.enrich_config() + assert config.model_class_name == "MiniMaxH3ModularPipeline" + assert config.supports_multimodal_inputs + assert config.max_multimodal_image_inputs == 9 + assert config.supports_mixed_reference_inputs + + +@pytest.mark.parametrize( + ("task_type", "partition"), + [ + (None, "combined"), + ("auto", "combined"), + ("t2va", "fl2va"), + ("fl2va", "fl2va"), + ("ref2va", "ref2va"), + ], +) +def test_startup_task_selects_weight_partition(task_type, partition): + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import _minimax_h3_partition_for_task + + assert _minimax_h3_partition_for_task(task_type) == partition + + +def test_startup_auto_task_uses_explicit_local_partition(tmp_path): + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import _minimax_h3_partition_for_task + + for directory, partition, tasks in ( + ("FL2VA", "fl2va", ["t2va", "fl2va"]), + ("Ref2VA", "ref2va", ["ref2va"]), + ): + model_path = tmp_path / directory + _write_partition_index(model_path, partition=partition, tasks=tasks) + assert _minimax_h3_partition_for_task(None, str(model_path)) == partition + assert _minimax_h3_partition_for_task("auto", str(model_path)) == partition + assert _minimax_h3_partition_for_task("combined", str(model_path)) == "combined" + + +def test_startup_task_rejects_unsupported_value(): + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import _minimax_h3_partition_for_task + + with pytest.raises(ValueError, match="task_type must be one of"): + _minimax_h3_partition_for_task("unsupported") + + +def test_combined_task_inference_and_transformer_routing(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + torch.nn.Module.__init__(pipeline) + pipeline.partition = "combined" + pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"}) + pipeline.transformer = torch.nn.Identity() + pipeline.transformers_ref = torch.nn.Linear(1, 1) + assert pipeline._resolve_task(None, {}) == "t2va" + assert pipeline._resolve_task(None, {"image": object()}) == "fl2va" + assert pipeline._resolve_task(None, {"audio": object()}) == "ref2va" + assert pipeline._resolve_task(None, {"video": object()}) == "ref2va" + pipeline.partition = "ref2va" + pipeline.supported_tasks = frozenset({"ref2va"}) + assert pipeline._resolve_task(None, {"image": object()}) == "ref2va" + + pipeline.partition = "combined" + pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"}) + assert pipeline._transformer_for_task("fl2va") is pipeline.transformer + assert pipeline._transformer_for_task("ref2va") is pipeline.transformers_ref + + +@pytest.mark.parametrize( + ( + "task_type", + "expected_partition", + "expected_dits", + "component_partition", + "source_partitions", + "expected_tasks", + ), + [ + (None, "combined", 2, "FL2VA", ["FL2VA", "Ref2VA"], {"t2va", "fl2va", "ref2va"}), + ("fl2va", "fl2va", 1, "FL2VA", ["FL2VA"], {"t2va", "fl2va"}), + ("ref2va", "ref2va", 1, "Ref2VA", ["Ref2VA"], {"ref2va"}), + ], +) +def test_pipeline_loads_task_selected_dits_and_shared_components_once( + monkeypatch, + tmp_path, + task_type, + expected_partition, + expected_dits, + component_partition, + source_partitions, + expected_tasks, +): + from vllm_omni.diffusion.data import ( + DiffusionParallelConfig, + OmniDiffusionConfig, + ) + from vllm_omni.diffusion.models.minimax_h3 import ( + pipeline_minimax_h3 as pipeline_module, + ) + + _write_partition_index( + tmp_path / "FL2VA", + partition="fl2va", + tasks=["t2va", "fl2va"], + ) + _write_partition_index( + tmp_path / "Ref2VA", + partition="ref2va", + tasks=["ref2va"], + ) + for partition_name in ("FL2VA", "Ref2VA"): + for component in ( + "transformer", + "tokenizer", + "processor", + "text_encoder", + "video_vae", + "audio_vae", + ): + (tmp_path / partition_name / component).mkdir() + + created = {"dit": [], "text_encoder": [], "video_vae": [], "audio_vae": []} + + class FakeModule(torch.nn.Module): + def __init__(self, *args, **kwargs): + super().__init__() + + class FakeDiT(FakeModule): + def __init__(self, *args, **kwargs): + super().__init__() + created["dit"].append((args, kwargs)) + + def component_factory(name): + def create(path, *args, **kwargs): + created[name].append(str(path)) + return FakeModule() + + return create + + tokenizer_calls = [] + processor_calls = [] + tokenizer_cls = Mock(spec=pipeline_module.Qwen2TokenizerFast) + tokenizer_cls.from_pretrained.side_effect = lambda *args, **kwargs: ( + tokenizer_calls.append((args, kwargs)) or object() + ) + processor_cls = Mock(spec=pipeline_module.Qwen3VLProcessor) + processor_cls.from_pretrained.side_effect = lambda *args, **kwargs: ( + processor_calls.append((args, kwargs)) or object() + ) + monkeypatch.setattr(pipeline_module, "MiniMaxH3DiTModel", FakeDiT) + monkeypatch.setattr( + pipeline_module, + "MiniMaxH3Qwen3VLEncoder", + component_factory("text_encoder"), + ) + monkeypatch.setattr( + pipeline_module, + "MiniMaxH3VideoVAE", + component_factory("video_vae"), + ) + monkeypatch.setattr( + pipeline_module, + "MiniMaxH3AudioVAE", + component_factory("audio_vae"), + ) + monkeypatch.setattr( + pipeline_module, + "Qwen2TokenizerFast", + tokenizer_cls, + ) + monkeypatch.setattr( + pipeline_module, + "Qwen3VLProcessor", + processor_cls, + ) + monkeypatch.setattr( + pipeline_module, + "_dit_rank_world", + lambda: (None, 0, 1), + ) + monkeypatch.setattr( + pipeline_module, + "get_local_device", + lambda: torch.device("cpu"), + ) + download_calls = [] + + def fake_download(**kwargs): + download_calls.append(kwargs) + return str(tmp_path) + + monkeypatch.setattr( + pipeline_module, + "download_weights_from_hf_specific", + fake_download, + ) + + od_config = OmniDiffusionConfig( + model="MiniMaxAI/MiniMax-H3", + revision=None, + task_type=task_type, + quantization_config=None, + parallel_config=DiffusionParallelConfig( + cfg_parallel_size=1, + text_encoder_tp_size=1, + ), + ) + pipeline = pipeline_module.MiniMaxH3Pipeline(od_config=od_config) + + assert pipeline.partition == expected_partition + assert pipeline.supported_tasks == expected_tasks + assert len(created["dit"]) == expected_dits + component_path = tmp_path / component_partition + assert created["text_encoder"] == [str(component_path / "text_encoder")] + assert created["video_vae"] == [str(component_path / "video_vae")] + assert created["audio_vae"] == [str(component_path / "audio_vae")] + assert len(tokenizer_calls) == 1 + assert len(processor_calls) == 1 + expected_dit_modules = ["transformer", "transformers_ref"] if expected_dits == 2 else ["transformer"] + assert pipeline._dit_modules == expected_dit_modules + assert hasattr(pipeline, "transformers_ref") is (expected_dits == 2) + if expected_partition == "ref2va": + assert pipeline._transformer_for_task("ref2va") is pipeline.transformer + assert [source.model_or_path for source in pipeline.weights_sources] == [ + str(tmp_path / partition_name) for partition_name in source_partitions + ] + expected_patterns = ( + pipeline_module.MINIMAX_H3_DOWNLOAD_PATTERNS + if expected_partition == "combined" + else pipeline_module.MINIMAX_H3_TASK_DOWNLOAD_PATTERNS[expected_partition] + ) + assert download_calls == [ + { + "model_name_or_path": "MiniMaxAI/MiniMax-H3", + "cache_dir": None, + "allow_patterns": expected_patterns, + "revision": None, + "require_all": True, + } + ] + + +def test_combined_weight_loader_routes_each_contiguous_partition(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + class FakeTransformer(torch.nn.Module): + def __init__(self): + super().__init__() + self.loaded = [] + self.post_load_calls = 0 + + def load_weights(self, weights): + self.loaded = [name for name, _ in weights] + return set(self.loaded) + + def post_load_weights(self): + self.post_load_calls += 1 + + pipeline = object.__new__(MiniMaxH3Pipeline) + torch.nn.Module.__init__(pipeline) + pipeline.transformer = FakeTransformer() + pipeline.transformers_ref = FakeTransformer() + pipeline.text_encoder = torch.nn.Identity() + pipeline.video_vae = torch.nn.Identity() + pipeline.audio_vae = torch.nn.Identity() + loaded = pipeline.load_weights( + iter( + [ + ("transformer.a", torch.ones(1)), + ("transformer.b", torch.ones(1)), + ("transformers_ref.a", torch.ones(1)), + ] + ) + ) + + assert pipeline.transformer.loaded == ["a", "b"] + assert pipeline.transformers_ref.loaded == ["a"] + assert pipeline.transformer.post_load_calls == 1 + assert pipeline.transformers_ref.post_load_calls == 1 + assert loaded == {"transformer.a", "transformer.b", "transformers_ref.a"} + + +def test_dlo_offload_plan_includes_token_refiner(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + assert MiniMaxH3Pipeline._offload_plan.offload_submodules == {"token_refiner": "blocks"} + assert MiniMaxH3Pipeline._offload_plan.resident_dit_paths == frozenset({"transformer"}) + + +def test_joint_postprocess_is_multiprocessing_picklable(): + from vllm_omni.diffusion.models.minimax_h3 import ( + get_minimax_h3_post_process_func, + ) + + postprocess = get_minimax_h3_post_process_func(SimpleNamespace()) + postprocess = ForkingPickler.loads(ForkingPickler.dumps(postprocess)) + video = torch.linspace(0, 1, 2 * 3 * 2 * 4 * 5).reshape(2, 3, 2, 4, 5) + audio = torch.arange(12, dtype=torch.float32).reshape(1, 2, 6) + + result = postprocess((video, audio), output_type="np") + + assert isinstance(result["video"], list) + assert result["video"][0].shape == (2, 4, 5, 3) + np.testing.assert_array_equal(result["audio"], audio.numpy()) + assert result["audio_sample_rate"] == 32000 + assert result["fps"] == 24 + + +def test_cfg_parallel_is_rejected_for_distilled_checkpoint(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + od_config = SimpleNamespace( + parallel_config=SimpleNamespace(cfg_parallel_size=2), + ) + with pytest.raises(ValueError, match="CFG-distilled"): + MiniMaxH3Pipeline(od_config=od_config) + + +def test_shape_contract_matches_three_reference_tasks(): + from vllm_omni.diffusion.models.minimax_h3.time_request import ( + MINIMAX_H3_SHAPE_PLANNER, + minimax_h3_align_frame_count, + ) + + assert minimax_h3_align_frame_count(round(8.7 * 24)) == 209 + assert MINIMAX_H3_SHAPE_PLANNER.video_latent_t(209) == 62 + assert MINIMAX_H3_SHAPE_PLANNER.audio_latent_t(8.7) == 348 + assert minimax_h3_align_frame_count(round(5.0 * 24)) == 124 + assert MINIMAX_H3_SHAPE_PLANNER.video_latent_t(124) == 37 + assert MINIMAX_H3_SHAPE_PLANNER.audio_latent_t(5.0) == 200 + assert MINIMAX_H3_SHAPE_PLANNER.frame_count_from_video_latent_t(62) == 209 + + +def test_shifted_sigma_schedule_matches_reference_values(): + from vllm_omni.diffusion.models.minimax_h3.time_request import ( + minimax_h3_time_shift_sigmas, + ) + + sigmas = minimax_h3_time_shift_sigmas(num_steps=5, shift_scale=12.0) + + assert sigmas == pytest.approx( + [1.0, 0.9729729891, 0.9230769277, 0.8000000119, 0.0], + abs=1e-7, + ) + + +def test_cudnn_packed_attention_uses_python_length_without_padding_mask(): + from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import ( + MiniMaxH3Attention, + ) + + class FakeBackend: + supports_prefix_kv_slicing = 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() + # Model-side Ulysses shards rows before Attention gathers them again. + # The Python packed_total must therefore remain global even though q is + # local here. + q = torch.randn(4, 2, 4) + cu_seqlens = torch.tensor([0, 5, 8], dtype=torch.int32) + + output = attention._run_packed_attention( + q, + q, + q, + cu_seqlens=cu_seqlens, + max_seqlen=5, + packed_total=8, + ) + + assert output.shape == q.shape + assert attention.attention.metadata.attn_mask is None + assert attention.attention.metadata.extra["valid_kv_length"] == 5 + + +def test_packed_attention_keeps_padding_mask_for_other_backends(): + from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import ( + MiniMaxH3Attention, + ) + + class FakeBackend: + supports_prefix_kv_slicing = False + + 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 torch.equal( + attention.attention.metadata.attn_mask, + torch.tensor([[True, True, True, True, True, False, False, False]]), + ) + + +def test_reference_image_resize_contract(): + from PIL import Image + + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import ( + _reference_image_shape, + ) + + assert _reference_image_shape(Image.new("RGB", (1080, 1440))) == ( + 2048, + 2720, + ) + with pytest.raises(ValueError, match="aspect ratio"): + _reference_image_shape(Image.new("RGB", (100, 501))) + + +def test_fl2va_supports_first_last_and_explicit_frame_index_contracts(): + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import ( + _resolve_fl2va_keyframe_indices, + ) + + assert _resolve_fl2va_keyframe_indices({}, 1) == [0] + assert _resolve_fl2va_keyframe_indices({}, 2) == [0, -1] + assert _resolve_fl2va_keyframe_indices({"frame_index": -1}, 1) == [-1] + assert _resolve_fl2va_keyframe_indices({"frame_indices": [0, -1]}, 2) == [0, -1] + with pytest.raises(ValueError, match="frame_indices"): + _resolve_fl2va_keyframe_indices({"frame_indices": [0, 1]}, 2) + + +def test_minimax_h3_uses_the_official_output_canvas_policy(): + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import ( + _resolve_output_canvas, + ) + + assert _resolve_output_canvas(21 / 9, 768) == (672, 1536) + assert _resolve_output_canvas(16 / 9, 768) == (768, 1344) + assert _resolve_output_canvas(9 / 16, 768) == (1344, 768) + with pytest.raises(ValueError, match="short_edge"): + _resolve_output_canvas(16 / 9, 720) + + +def test_minimax_h3_accepts_sglang_auto_aspect_ratio_alias(): + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + sampling = SimpleNamespace( + fps=24, + num_frames=1, + height=None, + width=None, + extra_args={"duration": 5.0, "aspect_ratio": "auto"}, + ) + height, width, *_ = pipeline._resolve_shape("ref2va", sampling, None) + assert (height, width) == (768, 1344) + + +def test_minimax_h3_advertises_the_official_ref2va_image_limit(): + from vllm_omni.diffusion.model_metadata import get_diffusion_model_metadata + + assert get_diffusion_model_metadata("MiniMaxH3Pipeline").max_multimodal_image_inputs == 9 + + +def test_encoder_forward_uses_hook_compatible_encode_entrypoint(): + from vllm_omni.diffusion.models.minimax_h3.encoder import ( + MiniMaxH3Qwen3VLEncoder, + ) + + encoder = object.__new__(MiniMaxH3Qwen3VLEncoder) + torch.nn.Module.__init__(encoder) + expected = torch.ones(2, 3) + encoder.encode_ids = Mock(return_value=expected) + input_ids = torch.tensor([1, 2]) + pixel_values = torch.ones(1, 4) + image_grid_thw = torch.tensor([[1, 1, 1]]) + + actual = encoder( + input_ids, + pixel_values=pixel_values, + image_grid_thw=image_grid_thw, + ) + + assert actual is expected + encoder.encode_ids.assert_called_once_with( + input_ids, + pixel_values=pixel_values, + image_grid_thw=image_grid_thw, + ) + + +def test_encoder_forward_forwards_video_inputs(): + from vllm_omni.diffusion.models.minimax_h3.encoder import ( + MiniMaxH3Qwen3VLEncoder, + ) + + encoder = object.__new__(MiniMaxH3Qwen3VLEncoder) + torch.nn.Module.__init__(encoder) + expected = torch.ones(2, 3) + encoder.encode_ids = Mock(return_value=expected) + input_ids = torch.tensor([1, 2]) + pixel_values_videos = torch.ones(1, 4) + video_grid_thw = torch.tensor([[2, 1, 1]]) + + actual = encoder( + input_ids, + pixel_values_videos=pixel_values_videos, + video_grid_thw=video_grid_thw, + ) + + assert actual is expected + encoder.encode_ids.assert_called_once_with( + input_ids, + pixel_values=None, + image_grid_thw=None, + pixel_values_videos=pixel_values_videos, + video_grid_thw=video_grid_thw, + ) + + +def test_reference_video_shape_uses_h3_adapt_shape_policy(): + from vllm_omni.diffusion.models.minimax_h3.reference_video import ( + _reference_video_shape, + ) + + assert _reference_video_shape(1280, 720) == (1344, 768) + assert _reference_video_shape(3844, 2160) == (1344, 768) + + +def test_text_encoder_stub_constructs_without_group_or_weights(): + from vllm_omni.diffusion.models.minimax_h3.encoder import ( + MiniMaxH3Qwen3VLEncoder, + ) + + encoder = MiniMaxH3Qwen3VLEncoder( + "/nonexistent/text_encoder", + device=torch.device("cpu"), + load_model=False, + encoder_group=None, + ) + assert not encoder.is_loaded + assert encoder.tp_size == 1 + # The stub has no parameters, so it never contributes to the runner's + # strict missing-parameter check on non-encoder ranks. + assert list(encoder.named_parameters()) == [] + + +def test_no_offload_keeps_text_encoder_resident(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + torch.nn.Module.__init__(pipeline) + pipeline.od_config = SimpleNamespace( + enable_cpu_offload=False, + enable_layerwise_offload=False, + ) + pipeline.text_encoder = Mock() + expected = torch.ones(2, 3) + pipeline.text_encoder.encode_ids.return_value = expected + input_ids = torch.tensor([1, 2]) + + actual = pipeline._encode_text_hidden(input_ids, {}) + + assert actual is expected + pipeline.text_encoder.load_to_device.assert_called_once_with() + pipeline.text_encoder.offload_to_cpu.assert_not_called() + + +def test_model_offload_uses_hooked_text_encoder_call(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + torch.nn.Module.__init__(pipeline) + pipeline.od_config = SimpleNamespace( + enable_cpu_offload=True, + enable_layerwise_offload=False, + ) + expected = torch.ones(2, 3) + pipeline.text_encoder = Mock(return_value=expected) + input_ids = torch.tensor([1, 2]) + vision_kwargs = {"pixel_values": torch.ones(1, 4)} + + actual = pipeline._encode_text_hidden(input_ids, vision_kwargs) + + assert actual is expected + pipeline.text_encoder.assert_called_once_with(input_ids, **vision_kwargs) + pipeline.text_encoder.load_to_device.assert_not_called() + + +def test_layerwise_offload_releases_text_encoder(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + torch.nn.Module.__init__(pipeline) + pipeline.od_config = SimpleNamespace( + enable_cpu_offload=False, + enable_layerwise_offload=True, + ) + pipeline.text_encoder = Mock() + expected = torch.ones(2, 3) + pipeline.text_encoder.encode_ids.return_value = expected + + actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {}) + + assert actual is expected + pipeline.text_encoder.load_to_device.assert_called_once_with() + pipeline.text_encoder.offload_to_cpu.assert_called_once_with() + + +def test_distributed_layerwise_offload_releases_text_encoder(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + torch.nn.Module.__init__(pipeline) + pipeline.od_config = SimpleNamespace( + enable_cpu_offload=False, + enable_layerwise_offload=False, + enable_distributed_layerwise_offload=True, + ) + pipeline.text_encoder = Mock() + expected = torch.ones(2, 3) + pipeline.text_encoder.encode_ids.return_value = expected + + actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {}) + + assert actual is expected + pipeline.text_encoder.load_to_device.assert_called_once_with() + pipeline.text_encoder.offload_to_cpu.assert_called_once_with() + + +def test_distributed_layerwise_offload_stages_vae_component(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + torch.nn.Module.__init__(pipeline) + pipeline.od_config = SimpleNamespace( + enable_layerwise_offload=False, + enable_distributed_layerwise_offload=True, + ) + component = Mock() + + with pipeline._component_on_device(component): + component.load_to_device.assert_called_once_with() + component.offload_to_cpu.assert_not_called() + + component.offload_to_cpu.assert_called_once_with() + + +def test_distributed_layerwise_resident_blocks_are_stage_scoped(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + torch.nn.Module.__init__(pipeline) + controller = Mock() + pipeline._dlo_residency_controller = controller + + with pipeline._resident_dit_layers_on_device(): + controller.load_resident_layers.assert_called_once_with() + controller.offload_resident_layers.assert_not_called() + + controller.offload_resident_layers.assert_called_once_with() + + +def test_distributed_layerwise_resident_blocks_release_on_failure(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + torch.nn.Module.__init__(pipeline) + controller = Mock() + pipeline._dlo_residency_controller = controller + + with pytest.raises(RuntimeError, match="denoise failed"): + with pipeline._resident_dit_layers_on_device(): + raise RuntimeError("denoise failed") + + controller.load_resident_layers.assert_called_once_with() + controller.offload_resident_layers.assert_called_once_with() + + +def test_distributed_layerwise_resident_blocks_can_be_skipped(): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + torch.nn.Module.__init__(pipeline) + controller = Mock() + pipeline._dlo_residency_controller = controller + + with pipeline._resident_dit_layers_on_device(enabled=False): + pass + + controller.load_resident_layers.assert_not_called() + controller.offload_resident_layers.assert_not_called() + + +def test_encoder_layerwise_offload_keeps_tp_blocks_rank_local(monkeypatch): + from vllm_omni.diffusion.models.minimax_h3.encoder import ( + MiniMaxH3Qwen3VLEncoder, + ) + + class Stack(torch.nn.Module): + def __init__(self, count): + super().__init__() + self.blocks = torch.nn.ModuleList([torch.nn.Linear(2, 2) for _ in range(count)]) + + class TextStack(torch.nn.Module): + def __init__(self, count): + super().__init__() + self.layers = torch.nn.ModuleList([torch.nn.Linear(2, 2) for _ in range(count)]) + + hooks = [] + + def fake_apply(block, next_block, device, stream, pin_memory): + hook = SimpleNamespace( + block=block, + next_block=next_block, + device=device, + stream=stream, + pin_memory=pin_memory, + _prev_hook=None, + offload_layer=Mock(), + ) + hooks.append(hook) + return hook + + monkeypatch.setattr( + "vllm_omni.diffusion.offloader.layerwise_backend.apply_block_hook", + fake_apply, + ) + monkeypatch.setattr( + "vllm_omni.platforms.current_omni_platform.Stream", + Mock(return_value="copy-stream"), + ) + encoder = object.__new__(MiniMaxH3Qwen3VLEncoder) + torch.nn.Module.__init__(encoder) + encoder.device_target = torch.device("cpu") + encoder.vision = Stack(2) + encoder.text_model = TextStack(3) + + encoder.enable_omni_layerwise_offload(pin_memory=False) + + assert len(hooks) == 5 + assert all(hook._prev_hook is not None for hook in hooks) + assert all(hook.device == torch.device("cpu") for hook in hooks) + + +def test_video_vae_keeps_reference_fp32_weights(monkeypatch): + from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module + + class FakeRemote(torch.nn.Module): + def __init__(self): + super().__init__() + self.model = torch.nn.Linear(1, 1).half() + + monkeypatch.setattr( + vae_module, + "_load_component_config", + lambda _path: { + "latent_channels": 1, + "latents_mean": [0.0], + "latents_std": [1.0], + }, + ) + monkeypatch.setattr( + vae_module, + "_load_remote_component", + lambda _path, _config: FakeRemote(), + ) + + video_vae = vae_module.MiniMaxH3VideoVAE( + "unused", + device=torch.device("cpu"), + ) + + assert next(video_vae.parameters()).dtype == torch.float32 + + +def test_video_vae_can_load_on_cpu_for_staged_gpu_residency(monkeypatch): + from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module + + class FakeRemote(torch.nn.Module): + def __init__(self): + super().__init__() + self.model = torch.nn.Linear(1, 1) + + monkeypatch.setattr( + vae_module, + "_load_component_config", + lambda _path: { + "latent_channels": 1, + "latents_mean": [0.0], + "latents_std": [1.0], + }, + ) + monkeypatch.setattr( + vae_module, + "_load_remote_component", + lambda _path, _config: FakeRemote(), + ) + + video_vae = vae_module.MiniMaxH3VideoVAE( + "unused", + device=torch.device("meta"), + load_device=torch.device("cpu"), + ) + + assert next(video_vae.parameters()).device.type == "cpu" + assert video_vae._device_target.type == "meta" + + +def test_video_vae_uses_snapshot_stager_for_accelerator_target(monkeypatch): + from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module + + class FakeRemote(torch.nn.Module): + def __init__(self): + super().__init__() + self.model = torch.nn.Linear(1, 1) + + stager = Mock() + stager_cls = Mock(return_value=stager) + monkeypatch.setattr( + vae_module, + "_load_component_config", + lambda _path: { + "latent_channels": 1, + "latents_mean": [0.0], + "latents_std": [1.0], + }, + ) + monkeypatch.setattr( + vae_module, + "_load_remote_component", + lambda _path, _config: FakeRemote(), + ) + monkeypatch.setattr(vae_module, "PinnedModuleStager", stager_cls) + + video_vae = vae_module.MiniMaxH3VideoVAE( + "unused", + device=torch.device("cuda"), + load_device=torch.device("cpu"), + ) + video_vae.load_to_device() + video_vae.offload_to_cpu() + + stager_cls.assert_called_once_with( + video_vae.remote, + torch.device("cuda"), + pin_memory=True, + ) + stager.load.assert_called_once_with() + stager.offload.assert_called_once_with() + + +def test_video_vae_encode_uses_configured_parallel_tiling(): + from vllm_omni.diffusion.models.minimax_h3.vae import ( + MiniMaxH3VideoVAE, + ) + + class FakeModel(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.ones(1)) + self.parallel_tiling = True + self.encode_calls = [] + + def encode_videos(self, frames, *, use_fp16_latent): + assert self.parallel_tiling + self.encode_calls.append((frames, use_fp16_latent)) + return [torch.ones(1, 1, 2, 2, 2)] + + video_vae = object.__new__(MiniMaxH3VideoVAE) + torch.nn.Module.__init__(video_vae) + video_vae.model = FakeModel() + video_vae.config_dict = { + "latent_channels": 1, + "latents_mean": [0.0], + "latents_std": [1.0], + } + + rows, shape = video_vae.encode_video("frames") + + assert video_vae.model.parallel_tiling + assert video_vae.model.encode_calls == [("frames", True)] + assert rows.shape == (2, 4) + assert shape == (2, 2, 2) + + +def test_distributed_video_vae_encodes_references_sequentially(monkeypatch): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + from vllm_omni.diffusion.models.minimax_h3 import ( + pipeline_minimax_h3 as pipeline_module, + ) + + prepared = [ + {"prepared_path": "video-1.mp4"}, + {"prepared_path": "video-2.mp4"}, + ] + + class FakeVideoVAE: + def __init__(self): + self.calls = [] + + def is_distributed_enabled(self): + return True + + def encode_video(self, frames): + self.calls.append(frames) + index = len(self.calls) + return torch.full((1, 2), index, dtype=torch.float32), (index, 2, 3) + + pipeline = object.__new__(MiniMaxH3Pipeline) + torch.nn.Module.__init__(pipeline) + pipeline.device = torch.device("cpu") + pipeline.video_vae = FakeVideoVAE() + + monkeypatch.setattr( + pipeline_module, + "_dit_rank_world", + lambda: ("dit-group", 1, 4), + ) + + def fake_broadcast_object_list(values, *, src, group, device): + assert values == [None] + assert (src, group, device) == (0, "dit-group", torch.device("cpu")) + values[0] = prepared + + monkeypatch.setattr( + pipeline_module.dist, + "broadcast_object_list", + fake_broadcast_object_list, + ) + monkeypatch.setattr( + pipeline_module, + "load_video_frames", + lambda path: f"frames:{path}", + ) + + rows, shapes = pipeline._encode_video_conditions(None, count=2) + + assert pipeline.video_vae.calls == [ + "frames:video-1.mp4", + "frames:video-2.mp4", + ] + torch.testing.assert_close( + rows, + torch.tensor([[1.0, 1.0], [2.0, 2.0]]), + ) + assert shapes == [(1, 2, 3), (2, 2, 3)] + + +@pytest.mark.parametrize( + ("case", "extra", "image_count", "expected"), + [ + ("F1", {"frame_index": -1}, 1, [-1]), + ("F2", {"frame_indices": [0, -1]}, 2, [0, -1]), + ], +) +def test_f1_f2_official_fl2va_keyframe_matrix(case, extra, image_count, expected): + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import ( + _resolve_fl2va_keyframe_indices, + ) + + assert case in {"F1", "F2"} + assert _resolve_fl2va_keyframe_indices(extra, image_count) == expected + + +@pytest.mark.parametrize( + ("case", "counts"), + [ + ("R1", (3, 0, 0)), + ("R2", (1, 0, 0)), + ("R3", (1, 1, 0)), + ("R4", (0, 1, 1)), + ("R5", (1, 1, 1)), + ("R6", (1, 0, 2)), + ], +) +def test_r1_r6_ref2va_reference_count_matrix(case, counts): + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import ( + _validate_ref2va_reference_counts, + ) + + assert case.startswith("R") + _validate_ref2va_reference_counts(*counts) + + +def test_ref2va_reference_count_validation_preserves_client_error_metadata(): + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import ( + _validate_ref2va_reference_counts, + ) + from vllm_omni.errors import OmniClientError + + with pytest.raises(OmniClientError, match="at least one image or video"): + _validate_ref2va_reference_counts(0, 0, 0) + + +@pytest.mark.parametrize( + ("case", "start_time", "expected_duration"), + [ + ("R7", None, 10.0), + ("R8", 4.0, 6.0), + ], +) +def test_r7_r8_ref2va_video_segment_matrix(monkeypatch, tmp_path, case, start_time, expected_duration): + from vllm_omni.diffusion.models.minimax_h3 import reference_video as reference_video_module + + source = tmp_path / f"{case}.mp4" + source.touch() + metadata = { + "width": 1280, + "height": 720, + "fps": 24.0, + "frame_count": 240, + "duration": 10.0, + "format_names": ("mp4",), + "video_codec": "h264", + "audio_codecs": (), + "file_size": 1024, + } + transcode_calls = [] + monkeypatch.setattr(reference_video_module, "_probe_video", lambda _path: metadata) + monkeypatch.setattr( + reference_video_module, + "_transcode_reference_video", + lambda source, **kwargs: transcode_calls.append((source, kwargs)) or "prepared.mp4", + ) + + prepared = reference_video_module.prepare_reference_videos( + [str(source)], + target_frame_count=209, + workdir=str(tmp_path / "work"), + start_time_seconds=start_time, + ) + + assert prepared[0]["duration_seconds"] == pytest.approx(expected_duration) + assert prepared[0]["start_time_seconds"] == pytest.approx(start_time or 0.0) + assert transcode_calls[0][1]["duration_seconds"] == pytest.approx(expected_duration) + + +def test_ref2va_two_video_recipe_tolerates_container_rounding(monkeypatch, tmp_path): + from vllm_omni.diffusion.models.minimax_h3 import reference_video as reference_video_module + + first = tmp_path / "first.mp4" + second = tmp_path / "second.mp4" + first.touch() + second.touch() + metadata = { + str(first): { + "width": 1280, + "height": 720, + "fps": 24.0, + "frame_count": 241, + "duration": 10.041667, + "format_names": ("mp4",), + "video_codec": "h264", + "audio_codecs": (), + "file_size": 1024, + }, + str(second): { + "width": 1280, + "height": 720, + "fps": 24.0, + "frame_count": 120, + "duration": 4.966667, + "format_names": ("mp4",), + "video_codec": "h264", + "audio_codecs": (), + "file_size": 1024, + }, + } + transcode_calls = [] + monkeypatch.setattr(reference_video_module, "_probe_video", lambda path: metadata[str(path)]) + monkeypatch.setattr( + reference_video_module, + "_transcode_reference_video", + lambda source, **kwargs: transcode_calls.append((source, kwargs)) or f"{source}.prepared.mp4", + ) + + prepared = reference_video_module.prepare_reference_videos( + [str(first), str(second)], + target_frame_count=124, + workdir=str(tmp_path / "work"), + ) + + assert [item["duration_seconds"] for item in prepared] == pytest.approx([10.041667, 4.958333]) + assert sum(item["duration_seconds"] for item in prepared) == pytest.approx(15.0) + assert transcode_calls[1][1]["duration_seconds"] == pytest.approx(4.958333) + + +def test_ref2va_two_video_recipe_rejects_real_duration_overflow(monkeypatch, tmp_path): + from vllm_omni.diffusion.models.minimax_h3 import reference_video as reference_video_module + from vllm_omni.errors import OmniClientError + + first = tmp_path / "first.mp4" + second = tmp_path / "second.mp4" + first.touch() + second.touch() + metadata = { + str(first): { + "width": 1280, + "height": 720, + "fps": 24.0, + "frame_count": 240, + "duration": 10.0, + "format_names": ("mp4",), + "video_codec": "h264", + "audio_codecs": (), + "file_size": 1024, + }, + str(second): { + "width": 1280, + "height": 720, + "fps": 24.0, + "frame_count": 120, + "duration": 5.02, + "format_names": ("mp4",), + "video_codec": "h264", + "audio_codecs": (), + "file_size": 1024, + }, + } + monkeypatch.setattr(reference_video_module, "_probe_video", lambda path: metadata[str(path)]) + monkeypatch.setattr(reference_video_module, "_transcode_reference_video", lambda source, **kwargs: "prepared.mp4") + + with pytest.raises(OmniClientError, match="15 seconds"): + reference_video_module.prepare_reference_videos( + [str(first), str(second)], + target_frame_count=124, + workdir=str(tmp_path / "work"), + ) + + +@pytest.mark.parametrize("duration", [4.0, 15.0]) +def test_g2_output_duration_accepts_official_boundaries(duration): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + sampling = SimpleNamespace( + fps=24, + num_frames=1, + height=None, + width=None, + extra_args={"duration": duration}, + ) + + height, width, *_ = pipeline._resolve_shape("ref2va", sampling, None) + assert height > 0 and width > 0 + + +@pytest.mark.parametrize("duration", [3.99, 15.01, float("nan"), "not-a-duration"]) +def test_g2_output_duration_rejects_out_of_contract_values(duration): + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + sampling = SimpleNamespace( + fps=24, + num_frames=1, + height=None, + width=None, + extra_args={"duration": duration}, + ) + with pytest.raises(ValueError, match="duration"): + pipeline._resolve_shape("ref2va", sampling, None) + + +def test_g1_fanout_uses_incrementing_output_seeds(): + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import ( + _minimax_h3_output_seeds, + _resolve_minimax_h3_num_outputs, + ) + + assert _resolve_minimax_h3_num_outputs(3) == 3 + assert _minimax_h3_output_seeds(100, 3) == [100, 101, 102] + with pytest.raises(ValueError, match="num_outputs_per_prompt"): + _resolve_minimax_h3_num_outputs(11) + + +def test_g3_task_specific_aspect_ratio_policy(): + from PIL import Image + + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import ( + _resolve_minimax_h3_aspect_ratio, + ) + + image = Image.new("RGB", (1280, 720)) + assert _resolve_minimax_h3_aspect_ratio("fl2va", "9:16", image) == pytest.approx(16 / 9) + assert _resolve_minimax_h3_aspect_ratio("ref2va", None, None) == pytest.approx(16 / 9) + assert _resolve_minimax_h3_aspect_ratio("ref2va", "auto", None) == pytest.approx(16 / 9) + with pytest.raises(ValueError, match="requires an explicit"): + _resolve_minimax_h3_aspect_ratio("t2va", None, None) + with pytest.raises(ValueError, match="one of"): + _resolve_minimax_h3_aspect_ratio("t2va", "2:1", None) + + +def test_g3_t2va_shape_requires_a_named_ratio_and_fl2va_ignores_override(): + from PIL import Image + + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline + + pipeline = object.__new__(MiniMaxH3Pipeline) + t2va_sampling = SimpleNamespace( + fps=24, + num_frames=1, + height=None, + width=None, + extra_args={"duration": 5.0}, + ) + with pytest.raises(ValueError, match="requires an explicit aspect_ratio"): + pipeline._resolve_shape("t2va", t2va_sampling, None) + + fl2va_sampling = SimpleNamespace( + fps=24, + num_frames=1, + height=None, + width=None, + extra_args={"duration": 5.0, "aspect_ratio": "9:16"}, + ) + height, width, *_ = pipeline._resolve_shape("fl2va", fl2va_sampling, Image.new("RGB", (1280, 720))) + assert (height, width) == (768, 1344) + + +def test_g4_reference_image_boundaries_and_aspect_ratio(): + from PIL import Image + + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import ( + _validate_reference_image, + ) + + _validate_reference_image(Image.new("RGB", (256, 256))) + _validate_reference_image(Image.new("RGB", (5760, 2304))) + with pytest.raises(ValueError, match="dimensions"): + _validate_reference_image(Image.new("RGB", (255, 255))) + with pytest.raises(ValueError, match="aspect ratio"): + _validate_reference_image(Image.new("RGB", (256, 641))) + + +def test_g4_reference_image_file_format_and_size_contract(tmp_path): + from PIL import Image + + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import ( + _load_image, + ) + + valid_path = tmp_path / "reference.png" + Image.new("RGB", (256, 256)).save(valid_path) + assert _load_image(valid_path).size == (256, 256) + + invalid_path = tmp_path / "reference.bmp" + Image.new("RGB", (256, 256)).save(invalid_path) + with pytest.raises(ValueError, match="must use"): + _load_image(invalid_path) + + +def test_g4_standalone_audio_duration_and_total_duration_contract(): + from vllm_omni.diffusion.models.minimax_h3.reference_video import ( + validate_reference_audio_waveforms, + ) + + sample_rate = 16000 + validate_reference_audio_waveforms( + [ + (torch.zeros(1, 2 * sample_rate), sample_rate), + (torch.zeros(1, 13 * sample_rate), sample_rate), + ] + ) + with pytest.raises(ValueError, match="duration"): + validate_reference_audio_waveforms([(torch.zeros(1, int(1.9 * sample_rate)), sample_rate)]) + with pytest.raises(ValueError, match="at most 15 seconds in total"): + validate_reference_audio_waveforms( + [ + (torch.zeros(1, 8 * sample_rate), sample_rate), + (torch.zeros(1, 8 * sample_rate), sample_rate), + ] + ) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("fps", 23.0, "FPS"), + ("duration", 1.0, "duration"), + ("format_names", ("avi",), "container"), + ("video_codec", "vp9", "H.264"), + ("audio_codecs", ("opus",), "AAC"), + ("file_size", 50 * 1024 * 1024 + 1, "size"), + ], +) +def test_g4_reference_video_metadata_validation(field, value, message, tmp_path): + from vllm_omni.diffusion.models.minimax_h3.reference_video import ( + _validate_reference_video_metadata, + ) + + metadata = { + "width": 1280, + "height": 720, + "fps": 24.0, + "duration": 10.0, + "format_names": ("mp4",), + "video_codec": "h264", + "audio_codecs": ("aac",), + "file_size": 1024, + } + metadata[field] = value + with pytest.raises(ValueError, match=message): + _validate_reference_video_metadata(metadata, index=0, source=str(tmp_path / "reference.mp4")) diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_e2e.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_e2e.py new file mode 100644 index 000000000..ec9d378ff --- /dev/null +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_e2e.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import os + +import numpy as np +import pytest + +pytestmark = [ + pytest.mark.local_model, + pytest.mark.cuda, + pytest.mark.diffusion, + pytest.mark.parallel, +] + +MODEL_ENV = "VLLM_TEST_MINIMAX_H3_FL2VA_MODEL" + + +def _assert_joint_output(outputs): + assert len(outputs) == 1 + frames = np.asarray(outputs[0].images[0]) + assert frames.shape == (39, 256, 448, 3) + multimodal = outputs[0].multimodal_output + assert multimodal is not None + assert np.asarray(multimodal["audio"]).shape[1] == 2 + assert multimodal["audio_sample_rate"] == 32000 + assert multimodal["fps"] == 24 + + +@pytest.mark.skipif( + not os.environ.get(MODEL_ENV), + reason=f"set {MODEL_ENV} to an authorized FL2VA checkpoint path", +) +def test_minimax_h3_t2va_ulysses8_smoke(): + import torch + + from vllm_omni.diffusion.data import DiffusionParallelConfig + from vllm_omni.entrypoints.omni import Omni + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + if torch.accelerator.device_count() < 8: + pytest.skip("MiniMax H3 Ulysses smoke requires eight GPUs") + + engine = Omni( + model=os.environ[MODEL_ENV], + parallel_config=DiffusionParallelConfig(ulysses_degree=8), + trust_remote_code=True, + enable_cpu_offload=True, + enforce_eager=True, + ) + try: + outputs = engine.generate( + "A quiet cinematic night scene with matching ambient sound.", + OmniDiffusionSamplingParams( + height=256, + width=448, + num_frames=29, + fps=24, + num_inference_steps=2, + seed=42, + output_type="np", + extra_args={ + "task": "t2va", + "duration": 4.0, + "aspect_ratio": "16:9", + "flow_shift": 12.0, + "audio_flow_shift": 3.0, + }, + ), + use_tqdm=False, + ) + finally: + engine.close() + + assert len(outputs) == 1 + frames = np.asarray(outputs[0].images[0]) + assert frames.shape == (107, 256, 448, 3) + multimodal = outputs[0].multimodal_output + assert multimodal is not None + assert np.asarray(multimodal["audio"]).shape[1] == 2 + assert multimodal["audio_sample_rate"] == 32000 + assert multimodal["fps"] == 24 + + +@pytest.mark.skipif( + not os.environ.get(MODEL_ENV), + reason=f"set {MODEL_ENV} to an authorized FL2VA checkpoint path", +) +def test_minimax_h3_t2va_fp8_single_gpu_smoke(): + import torch + + from vllm_omni.diffusion.data import DiffusionParallelConfig + from vllm_omni.entrypoints.omni import Omni + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + if torch.accelerator.device_count() < 1: + pytest.skip("MiniMax H3 FP8 smoke requires a CUDA device") + + engine = Omni( + model=os.environ[MODEL_ENV], + quantization_config={ + "transformer": {"method": "fp8"}, + "text_encoder": None, + "video_vae": None, + "audio_vae": None, + }, + parallel_config=DiffusionParallelConfig(ulysses_degree=1), + trust_remote_code=True, + enable_cpu_offload=True, + enforce_eager=True, + ) + try: + outputs = engine.generate( + "A quiet cinematic night scene with matching ambient sound.", + OmniDiffusionSamplingParams( + height=256, + width=448, + num_frames=29, + fps=24, + num_inference_steps=2, + seed=42, + output_type="np", + extra_args={ + "task": "t2va", + "flow_shift": 12.0, + "audio_flow_shift": 3.0, + }, + ), + use_tqdm=False, + ) + finally: + engine.close() + + _assert_joint_output(outputs) diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_packing.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_packing.py new file mode 100644 index 000000000..efe53f826 --- /dev/null +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_packing.py @@ -0,0 +1,163 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +import torch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +def test_video_patchify_round_trip_preserves_values(): + from vllm_omni.diffusion.models.minimax_h3.packed_tokens import ( + minimax_h3_patchify_video_latent, + minimax_h3_unpatchify_video_tokens, + ) + + latent = torch.arange(2 * 3 * 2 * 4 * 6).reshape(2, 3, 2, 4, 6) + rows = minimax_h3_patchify_video_latent( + latent, + patch_size=(1, 2, 2), + ) + restored = minimax_h3_unpatchify_video_tokens( + rows, + latent_shape=(2, 2, 3, 3), + patch_size=(1, 2, 2), + ) + + torch.testing.assert_close(restored, latent) + + +def test_audio_pack_round_trip_preserves_channel_major_order(): + from vllm_omni.diffusion.models.minimax_h3.packed_tokens import ( + minimax_h3_pack_audio_latent, + minimax_h3_unpack_audio_tokens, + ) + + latent = torch.arange(2 * 4 * 5).reshape(2, 4, 5) + rows = minimax_h3_pack_audio_latent(latent) + restored = minimax_h3_unpack_audio_tokens( + rows, + audio_t=10, + audio_channel=2, + ) + + torch.testing.assert_close(restored, latent) + + +def test_t2va_and_fl2va_packing_keep_update_rows_separate(): + from vllm_omni.diffusion.models.minimax_h3.packed_sequence import ( + minimax_h3_packed_sequence, + ) + + common = dict( + text_len=4, + latent_t=2, + latent_h=4, + latent_w=6, + audio_t=3, + ) + t2va = minimax_h3_packed_sequence( + **common, + include_keyframe_cond=False, + ) + fl2va = minimax_h3_packed_sequence( + **common, + include_keyframe_cond=True, + keyframe_frame_indices=[0], + frame_count=5, + ) + + assert int(t2va["seq_len"]) == 64 + assert t2va["img_pos"].numel() == 12 + assert t2va["update_mask"].all() + assert fl2va["img_pos"].numel() == 18 + assert fl2va["update_mask"].sum().item() == 12 + assert (~fl2va["update_mask"]).sum().item() == 6 + assert t2va["audio_pos"].numel() == fl2va["audio_pos"].numel() == 6 + + +def test_ref2va_packing_tracks_video_and_audio_update_masks(): + from vllm_omni.diffusion.models.minimax_h3.packed_sequence import ( + minimax_h3_packed_sequence_ref2va_blocks, + ) + + packed = minimax_h3_packed_sequence_ref2va_blocks( + text_len=4, + latent_t=2, + latent_h=4, + latent_w=6, + audio_t=3, + ref_blocks=[ + {"kind": "image", "latent_h": 4, "latent_w": 4}, + {"kind": "audio", "ref_audio_t": 2}, + ], + ) + + assert int(packed["seq_len"]) == 64 + assert packed["img_pos"].numel() == 16 + assert packed["update_mask"].sum().item() == 12 + assert packed["audio_pos"].numel() == 10 + assert packed["audio_update_mask"].sum().item() == 6 + assert packed["cu_seqlens"].tolist() == [0, 30, 64] + + +def test_condition_noise_is_seeded_and_keeps_clean_anchor_at_timestep_one(): + from vllm_omni.diffusion.models.minimax_h3.condition_noise import ( + minimax_h3_audio_cond_noise_aug_rows, + minimax_h3_imgvid_cond_noise_aug_rows, + ) + + image_rows = torch.randn(4, 96) + audio_rows = torch.randn(6, 32) + + torch.testing.assert_close( + minimax_h3_imgvid_cond_noise_aug_rows( + image_rows, + condition_shapes=[(1, 4, 4)], + target_latent_t=2, + imgvid_cond_num_frames=1, + seed=42, + noise_aug=1.0, + ), + image_rows, + ) + torch.testing.assert_close( + minimax_h3_audio_cond_noise_aug_rows( + audio_rows, + condition_audio_t=[3], + seed=42, + noise_aug=1.0, + ), + audio_rows, + ) + + first = minimax_h3_audio_cond_noise_aug_rows( + audio_rows, + condition_audio_t=[3], + seed=42, + noise_aug=0.25, + ) + second = minimax_h3_audio_cond_noise_aug_rows( + audio_rows, + condition_audio_t=[3], + seed=42, + noise_aug=0.25, + ) + torch.testing.assert_close(first, second) + + +def test_condition_noise_accepts_a_reference_video_longer_than_the_target(): + from vllm_omni.diffusion.models.minimax_h3.condition_noise import ( + minimax_h3_imgvid_cond_noise_aug_rows, + ) + + rows = torch.zeros(4 * 2 * 2, 96) + result = minimax_h3_imgvid_cond_noise_aug_rows( + rows, + condition_shapes=[(4, 4, 4)], + target_latent_t=2, + imgvid_cond_num_frames=1, + seed=7, + noise_aug=0.5, + ) + assert result.shape == rows.shape diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_parallel.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_parallel.py new file mode 100644 index 000000000..bcd074483 --- /dev/null +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_parallel.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +import torch +import torch.nn as nn + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +def test_grouped_qkv_checkpoint_reorder(): + from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import ( + _reorder_grouped_qkv_to_qkv, + ) + + # Two groups with rows [q, k, v] become [q0, q1, k0, k1, v0, v1]. + grouped = torch.arange(6, dtype=torch.float32).reshape(6, 1) + reordered = _reorder_grouped_qkv_to_qkv( + grouped, + num_query_groups=2, + heads_per_group=1, + head_dim=1, + ) + + assert reordered[:, 0].tolist() == [0, 3, 1, 4, 2, 5] + + +def test_transformer_declares_cache_sp_layerwise_offload_and_hsdp(): + from cache_dit import ForwardPattern + + from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import ( + MiniMaxH3DiTModel, + ) + + assert MiniMaxH3DiTModel._repeated_blocks == ["MiniMaxH3DiTBlock"] + assert MiniMaxH3DiTModel._layerwise_offload_blocks_attrs == ["blocks"] + assert MiniMaxH3DiTModel._cache_dit_adapter_config.block_forward_patterns["blocks"] == ForwardPattern.Pattern_3 + assert not MiniMaxH3DiTModel._cache_dit_adapter_config.has_separate_cfg + assert set(MiniMaxH3DiTModel._sp_plan) == {"sp_prepare", "sp_gather"} + + model = object.__new__(MiniMaxH3DiTModel) + nn.Module.__init__(model) + model.blocks = nn.ModuleList([nn.Linear(4, 4) for _ in range(2)]) + model.token_refiner = nn.Module() + model.token_refiner.blocks = nn.ModuleList([nn.Linear(4, 4)]) + model.final_layer = nn.Linear(4, 4) + + matched = [ + name + for name, module in model.named_modules() + if any(condition(name, module) for condition in MiniMaxH3DiTModel._hsdp_shard_conditions) + ] + assert matched == ["blocks.0", "blocks.1"] + + +def test_packed_attention_is_a_regional_compile_boundary(): + from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import ( + MiniMaxH3Attention, + ) + + assert getattr(MiniMaxH3Attention._run_packed_attention, "_torchdynamo_disable", False) + + +def test_h3_fused_rope_matches_reference_and_preserves_unrotated_dims(): + from vllm_omni.diffusion.layers.rope import RotaryEmbedding + from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import ( + MiniMaxH3Attention, + ) + + attention = object.__new__(MiniMaxH3Attention) + nn.Module.__init__(attention) + attention.rope = RotaryEmbedding(is_neox_style=True, half_head_dim=False) + attention.rope._forward_method = attention.rope.forward_native + + x = torch.randn(11, 3, 128, dtype=torch.bfloat16) + freqs_half = torch.randn(11, 48) + freqs = torch.cat((freqs_half, freqs_half), dim=-1) + actual = attention._apply_rope(x, freqs) + + cos = torch.cos(freqs).to(x.dtype).unsqueeze(1) + sin = torch.sin(freqs).to(x.dtype).unsqueeze(1) + x_rot = x[..., :96] + x1, x2 = x_rot.chunk(2, dim=-1) + expected_rot = x_rot * cos + torch.cat((-x2, x1), dim=-1) * sin + expected = torch.cat((expected_rot, x[..., 96:]), dim=-1) + + torch.testing.assert_close(actual, expected, atol=0, rtol=0) + torch.testing.assert_close(actual[..., 96:], x[..., 96:], atol=0, rtol=0) + + +@pytest.mark.parametrize( + ("tp_size", "message"), + [ + (3, "num_attention_heads"), + (5, "num_attention_heads"), + ], +) +def test_tp_rejects_non_divisible_head_counts(tp_size, message): + from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import ( + MiniMaxH3DiTArchConfig, + MiniMaxH3DiTModel, + ) + + model = object.__new__(MiniMaxH3DiTModel) + with pytest.raises(ValueError, match=message): + model._validate_tp_config( + arch=MiniMaxH3DiTArchConfig(), + tp_size=tp_size, + ) + + +def test_tp_accepts_checkpoint_supported_sizes(): + from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import ( + MiniMaxH3DiTArchConfig, + MiniMaxH3DiTModel, + ) + + model = object.__new__(MiniMaxH3DiTModel) + arch = MiniMaxH3DiTArchConfig() + for tp_size in (1, 2, 4, 7): + model._validate_tp_config(arch=arch, tp_size=tp_size) diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization.py new file mode 100644 index 000000000..b9ae3335c --- /dev/null +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization.py @@ -0,0 +1,213 @@ +# SPDX-License-Identifier: Apache-2.0 + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +import torch +import torch.nn as nn + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +class _FakeLinear(nn.Module): + def __init__( + self, + *args, + bias=False, + params_dtype=None, + quant_config=None, + prefix="", + total_num_heads=None, + total_num_kv_heads=None, + **kwargs, + ): + del args, kwargs + super().__init__() + self.prefix = prefix + self.quant_config = quant_config + self.weight = nn.Parameter(torch.empty(1, dtype=params_dtype)) + if bias: + self.bias = nn.Parameter(torch.empty(1, dtype=params_dtype)) + else: + self.register_parameter("bias", None) + self.num_heads = total_num_heads + self.num_kv_heads = total_num_kv_heads + + +class _FakeAttention(nn.Module): + def __init__(self, **kwargs): + del kwargs + super().__init__() + + +def _small_od_config(): + arch = { + "num_layers": 1, + "token_refiner_num_layers": 1, + "hidden_size": 8, + "num_attention_heads": 2, + "attention_head_dim": 4, + "ffn_hidden_size": 16, + "latents_dim": 2, + "audio_latents_dim": 2, + "patch_size": (1, 2, 2), + "text_dim": 6, + "timestep_input_dim": 4, + "time_embed_hidden_size": 8, + "time_embed_dim": 4, + "adaln_out_features": 18 * 8, + "final_adaln_out_features": 2 * 8, + "rope_inv_freq_len": 2, + } + return SimpleNamespace( + tf_model_config=arch, + parallel_config=SimpleNamespace(ulysses_degree=1), + ) + + +def test_fp8_scope_and_prefix_propagation(monkeypatch): + from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod + from vllm.model_executor.layers.quantization.fp8 import Fp8Config + from vllm.model_executor.layers.quantization.utils.quant_utils import ( + is_layer_skipped, + ) + + from vllm_omni.diffusion.models.minimax_h3 import minimax_h3_transformer as h3 + + monkeypatch.setattr(h3, "ColumnParallelLinear", _FakeLinear) + monkeypatch.setattr(h3, "MergedColumnParallelLinear", _FakeLinear) + monkeypatch.setattr(h3, "QKVParallelLinear", _FakeLinear) + monkeypatch.setattr(h3, "RowParallelLinear", _FakeLinear) + monkeypatch.setattr(h3, "Attention", _FakeAttention) + monkeypatch.setattr(h3, "get_tensor_model_parallel_world_size", lambda: 1) + + ignored_layers = { + "token_refiner.blocks.0.mlp.fc2", + "blocks.0.attn.qkv_proj", + "condition_proj", + "blocks.0.adaln_proj.linear", + "final_layer.adaln_proj.linear", + } + fp8_config = Fp8Config(ignored_layers=sorted(ignored_layers)) + model = h3.MiniMaxH3DiTModel( + _small_od_config(), + quant_config=fp8_config, + ) + linears = {module.prefix: module.quant_config for module in model.modules() if isinstance(module, _FakeLinear)} + + quantized = { + "token_refiner.blocks.0.attn.qkv_proj", + "token_refiner.blocks.0.attn.out_proj", + "token_refiner.blocks.0.mlp.fc1", + "token_refiner.blocks.0.mlp.fc2", + "blocks.0.attn.qkv_proj", + "blocks.0.attn.out_proj", + "blocks.0.mlp.fc1", + "blocks.0.mlp.fc2", + "condition_proj", + "blocks.0.adaln_proj.linear", + "final_layer.adaln_proj.linear", + } + full_precision = { + "video_patch_proj", + "audio_patch_proj", + "time_embedder.proj_in", + "time_embedder.proj_out", + "final_layer.video_out", + "final_layer.audio_out", + } + + assert quantized <= linears.keys() + assert full_precision <= linears.keys() + assert all(linears[prefix] is fp8_config for prefix in quantized) + assert all(linears[prefix] is None for prefix in full_precision) + assert {prefix for prefix in quantized if is_layer_skipped(prefix, fp8_config.ignored_layers)} == ignored_layers + linear = Mock(spec=LinearBase) + assert all( + isinstance( + fp8_config.get_quant_method(linear, prefix), + UnquantizedLinearMethod, + ) + for prefix in ignored_layers + ) + + +class _WeightTarget(nn.Module): + def __init__(self, loader): + super().__init__() + self.weight = nn.Parameter(torch.empty(1)) + self.weight.weight_loader = loader + + +def test_model_load_weights_transforms_before_calling_vllm_loader(): + from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import ( + MiniMaxH3DiTArchConfig, + MiniMaxH3DiTModel, + ) + + qkv_calls = [] + fc1_calls = [] + + def qkv_loader(param, loaded_weight): + del param + qkv_calls.append(loaded_weight.clone()) + + def fc1_loader(param, loaded_weight, shard_id): + del param + fc1_calls.append((shard_id, loaded_weight.clone())) + + model = object.__new__(MiniMaxH3DiTModel) + nn.Module.__init__(model) + model.arch = MiniMaxH3DiTArchConfig( + hidden_size=1, + num_attention_heads=2, + attention_head_dim=1, + ffn_hidden_size=2, + ) + model.blocks = nn.ModuleList([nn.Module()]) + model.blocks[0].attn = nn.Module() + model.blocks[0].attn.qkv_proj = _WeightTarget(qkv_loader) + model.blocks[0].mlp = nn.Module() + model.blocks[0].mlp.fc1 = _WeightTarget(fc1_loader) + + qkv = torch.arange(6, dtype=torch.float32).reshape(6, 1) + fc1 = torch.arange(4, dtype=torch.float32).reshape(4, 1) + loaded = model.load_weights( + [ + ("blocks.0.attn.qkv_proj.weight", qkv), + ("blocks.0.mlp.fc1.weight", fc1), + ] + ) + + assert loaded == { + "blocks.0.attn.qkv_proj.weight", + "blocks.0.mlp.fc1.weight", + } + assert qkv_calls[0][:, 0].tolist() == [0, 3, 1, 4, 2, 5] + assert [(shard_id, tensor[:, 0].tolist()) for shard_id, tensor in fc1_calls] == [ + (0, [0, 1]), + (1, [2, 3]), + ] + + +def test_pipeline_resolves_transformer_component_quant_config(): + from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import ( + _resolve_component_quant_config, + ) + from vllm_omni.quantization import build_quant_config + + ignored_layers = ["blocks.0.attn.qkv_proj"] + component_config = build_quant_config( + { + "transformer": { + "method": "fp8", + "ignored_layers": ignored_layers, + } + } + ) + transformer_config = component_config.resolve("transformer") + + assert transformer_config.ignored_layers == ignored_layers + assert _resolve_component_quant_config(component_config, "transformer") is transformer_config + assert _resolve_component_quant_config(transformer_config, "transformer") is transformer_config diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py new file mode 100644 index 000000000..80d99895f --- /dev/null +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py @@ -0,0 +1,298 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from pathlib import Path + +import numpy as np +import pytest +import torch + +from tests.diffusion.quantization.test_quantization_quality import ( + _OUTPUT_DIR, + QualityTestConfig, + _compute_lpips, + _compute_psnr_and_mae, + _free_gpu_memory, + _maybe_save_output, +) +from tests.helpers.mark import hardware_marks + +_MINIMAX_H3_REPO = "MiniMaxAI/MiniMax-H3" +_MINIMAX_H3_REVISION = "48d93ede732756e404a3b1b2f3b3a9b5a22f6cfc" +_MINIMAX_H3_AUDIO_SAMPLE_RATE = 32000 +_MIN_AUDIO_SPECTRAL_COSINE = 0.80 +_MIN_AUDIO_RMS_RATIO = 0.50 +_MAX_AUDIO_RMS_RATIO = 2.00 + +_QUALITY_CONFIG = QualityTestConfig( + id="fp8_minimax_h3_fl2va", + model=_MINIMAX_H3_REPO, + quantization={"transformer": {"method": "fp8"}}, + task="t2v", + prompt=( + "A bright daytime cinematic tracking shot of a golden retriever running through a sunlit meadow " + "filled with colorful wildflowers, vivid blue sky, crisp natural colors." + ), + max_lpips=0.20, + height=384, + width=672, + num_frames=107, + num_inference_steps=10, +) + + +def _resolve_fl2va_model_ref() -> str: + from huggingface_hub import snapshot_download + + repo_root = Path( + snapshot_download( + repo_id=_MINIMAX_H3_REPO, + revision=_MINIMAX_H3_REVISION, + allow_patterns=["FL2VA/**"], + ) + ) + fl2va_root = repo_root / "FL2VA" + if not (fl2va_root / "model_index.json").is_file(): + raise FileNotFoundError(f"MiniMax H3 FL2VA model_index.json not found under {repo_root}") + return str(fl2va_root) + + +def _canonical_audio(audio: object) -> np.ndarray: + """Return H3 audio as float32 [channels, samples].""" + array = np.asarray(audio, dtype=np.float32) + while array.ndim > 2 and array.shape[0] == 1: + array = array[0] + if array.ndim == 1: + array = array[None, :] + if array.ndim != 2: + raise ValueError(f"Expected mono/stereo audio, got shape {array.shape}") + if array.shape[0] > array.shape[1] and array.shape[1] <= 8: + array = array.T + if not 1 <= array.shape[0] <= 8: + raise ValueError(f"Expected channel-first audio, got shape {array.shape}") + if not np.isfinite(array).all(): + raise ValueError("Audio contains non-finite samples") + return np.ascontiguousarray(array) + + +def _audio_quality_metrics(baseline: object, quantized: object) -> tuple[float, float]: + """Compare phase-tolerant spectral structure and overall signal energy.""" + baseline_array = _canonical_audio(baseline) + quantized_array = _canonical_audio(quantized) + if baseline_array.shape != quantized_array.shape: + raise ValueError( + "Audio shapes do not match for metric computation: " + f"baseline={baseline_array.shape}, quantized={quantized_array.shape}" + ) + + baseline_tensor = torch.from_numpy(baseline_array) + quantized_tensor = torch.from_numpy(quantized_array) + similarities: list[float] = [] + for n_fft in (512, 1024, 2048): + window = torch.hann_window(n_fft) + baseline_magnitude = torch.stft( + baseline_tensor, + n_fft=n_fft, + hop_length=n_fft // 4, + window=window, + return_complex=True, + ).abs() + quantized_magnitude = torch.stft( + quantized_tensor, + n_fft=n_fft, + hop_length=n_fft // 4, + window=window, + return_complex=True, + ).abs() + cosine = torch.nn.functional.cosine_similarity( + baseline_magnitude.flatten(1), + quantized_magnitude.flatten(1), + dim=1, + eps=1e-12, + ) + similarities.extend(float(value) for value in cosine) + + baseline_rms = float(torch.sqrt(torch.mean(baseline_tensor.square()))) + quantized_rms = float(torch.sqrt(torch.mean(quantized_tensor.square()))) + rms_ratio = quantized_rms / max(baseline_rms, 1e-12) + return float(np.mean(similarities)), rms_ratio + + +def _generate_joint_output(omni, config: QualityTestConfig): + """Generate H3 video/audio, returning both payloads and peak GPU memory.""" + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + from vllm_omni.platforms import current_omni_platform + + generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(config.seed) + torch.accelerator.reset_peak_memory_stats() + outputs = omni.generate( + {"prompt": config.prompt, "negative_prompt": config.negative_prompt}, + OmniDiffusionSamplingParams( + height=config.height, + width=config.width, + generator=generator, + num_inference_steps=config.num_inference_steps, + num_frames=config.num_frames, + guidance_scale=config.guidance_scale, + sigmas=config.sigmas, + extra_args={"aspect_ratio": "16:9"}, + ), + ) + main_process_peak_mem = torch.accelerator.max_memory_allocated() / (1024**3) + + first = outputs[0] + worker_peak_memory_mb = float(getattr(first, "peak_memory_mb", 0.0) or 0.0) + if hasattr(first, "request_output") and isinstance(first.request_output, list): + first = first.request_output[0] + worker_peak_memory_mb = max( + worker_peak_memory_mb, + float(getattr(first, "peak_memory_mb", 0.0) or 0.0), + ) + if not hasattr(first, "images") or not first.images: + raise ValueError("Could not extract video frames from H3 output") + frames = first.images[0] + if isinstance(frames, torch.Tensor): + frames = frames.detach().cpu() + if frames.dim() == 5: + frames = frames[0] + if frames.dim() == 4 and frames.shape[0] in (3, 4): + frames = frames.permute(1, 2, 3, 0) + if frames.is_floating_point(): + frames = frames.clamp(-1, 1) * 0.5 + 0.5 + video = frames.float().numpy() + else: + video = np.asarray(frames) + if video.ndim == 5: + video = video[0] + + multimodal = getattr(first, "multimodal_output", None) + if not multimodal or "audio" not in multimodal: + raise ValueError("Could not extract audio from H3 output") + sample_rate = int(multimodal.get("audio_sample_rate", 0)) + peak_mem = worker_peak_memory_mb / 1024 if worker_peak_memory_mb > 0 else main_process_peak_mem + return video, _canonical_audio(multimodal["audio"]), sample_rate, peak_mem + + +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.parametrize( + "config", + [ + pytest.param( + _QUALITY_CONFIG, + id=_QUALITY_CONFIG.id, + marks=hardware_marks(res={"cuda": _QUALITY_CONFIG.gpu}, num_cards=2), + ) + ], +) +def test_minimax_h3_quantization_quality(config: QualityTestConfig): + from vllm_omni.entrypoints.omni import Omni + + model_ref = _resolve_fl2va_model_ref() + common_kwargs = { + "model": model_ref, + "enforce_eager": True, + "tensor_parallel_size": 2, + "text_encoder_tp_size": 2, + "vae_use_tiling": True, + } + + omni_bl = Omni(**common_kwargs) + baseline_out, baseline_audio, baseline_sample_rate, bl_mem = _generate_joint_output(omni_bl, config) + omni_bl.shutdown() + del omni_bl + _free_gpu_memory() + _maybe_save_output(_OUTPUT_DIR, config, "baseline", baseline_out) + + quantization = config.quantization_ref() + omni_qt = Omni(**common_kwargs, quantization_config=quantization) + quant_out, quant_audio, quant_sample_rate, qt_mem = _generate_joint_output(omni_qt, config) + omni_qt.shutdown() + del omni_qt + _free_gpu_memory() + _maybe_save_output(_OUTPUT_DIR, config, "quantized", quant_out) + + lpips_score = _compute_lpips(baseline_out, quant_out, config.task) + psnr_score, mae_score = _compute_psnr_and_mae(baseline_out, quant_out, config.task) + if baseline_sample_rate != _MINIMAX_H3_AUDIO_SAMPLE_RATE or quant_sample_rate != baseline_sample_rate: + raise AssertionError( + f"Unexpected H3 audio sample rate: baseline={baseline_sample_rate}, quantized={quant_sample_rate}" + ) + audio_spectral_cosine, audio_rms_ratio = _audio_quality_metrics(baseline_audio, quant_audio) + assert lpips_score <= config.max_lpips, ( + f"LPIPS {lpips_score:.4f} exceeds threshold {config.max_lpips} " + f"for {config.quantization_ref()} on {config.quantized_ref()}" + ) + assert audio_spectral_cosine >= _MIN_AUDIO_SPECTRAL_COSINE, ( + f"Audio spectral cosine {audio_spectral_cosine:.4f} is below threshold " + f"{_MIN_AUDIO_SPECTRAL_COSINE} for {config.id}" + ) + assert _MIN_AUDIO_RMS_RATIO <= audio_rms_ratio <= _MAX_AUDIO_RMS_RATIO, ( + f"Audio RMS ratio {audio_rms_ratio:.4f} is outside " + f"[{_MIN_AUDIO_RMS_RATIO}, {_MAX_AUDIO_RMS_RATIO}] for {config.id}" + ) + + mem_reduction = (bl_mem - qt_mem) / bl_mem * 100 if bl_mem > 0 else 0 + print(f"\n{'=' * 60}") + print(f"Quantization Quality: {config.id}") + print(f"{'=' * 60}") + print(f" Baseline: {config.baseline_ref()}") + print(f" Quantized: {config.quantized_ref()}") + print(f" Method: {config.quantization_ref()}") + print(f" LPIPS: {lpips_score:.4f} (threshold: {config.max_lpips})") + print(f" PSNR: {psnr_score:.4f} dB (higher is better)") + print(f" MAE: {mae_score:.6f} (lower is better)") + print(f" Audio cosine: {audio_spectral_cosine:.4f} (threshold: {_MIN_AUDIO_SPECTRAL_COSINE})") + print(f" Audio RMS: {audio_rms_ratio:.4f}x (range: {_MIN_AUDIO_RMS_RATIO}-{_MAX_AUDIO_RMS_RATIO})") + print(f" BF16 memory: {bl_mem:.2f} GiB") + print(f" Quant memory: {qt_mem:.2f} GiB ({mem_reduction:.0f}% reduction)") + print(" Result: PASS") + print(f"{'=' * 60}\n") + + assert np.isfinite(psnr_score) or np.isinf(psnr_score), f"PSNR is invalid for {config.id}: {psnr_score}" + assert np.isfinite(mae_score), f"MAE is not finite for {config.id}: {mae_score}" + + +def test_resolve_fl2va_model_ref(tmp_path, monkeypatch): + fl2va_root = tmp_path / "FL2VA" + fl2va_root.mkdir() + (fl2va_root / "model_index.json").write_text("{}", encoding="utf-8") + + def fake_snapshot_download(*, repo_id, revision, allow_patterns): + assert repo_id == _MINIMAX_H3_REPO + assert revision == _MINIMAX_H3_REVISION + assert allow_patterns == ["FL2VA/**"] + return str(tmp_path) + + monkeypatch.setattr("huggingface_hub.snapshot_download", fake_snapshot_download) + assert _resolve_fl2va_model_ref() == str(fl2va_root) + + +def test_audio_quality_metrics_ignore_phase_but_reject_spectral_drift(): + sample_rate = _MINIMAX_H3_AUDIO_SAMPLE_RATE + time = np.arange(sample_rate, dtype=np.float32) / sample_rate + baseline = np.stack( + [ + np.sin(2 * np.pi * 440 * time), + np.sin(2 * np.pi * 880 * time), + ] + ) + phase_shifted = np.stack( + [ + np.sin(2 * np.pi * 440 * time + np.pi / 2), + np.sin(2 * np.pi * 880 * time + np.pi / 2), + ] + ) + different = np.stack( + [ + np.sin(2 * np.pi * 3000 * time), + np.sin(2 * np.pi * 4000 * time), + ] + ) + + phase_cosine, phase_rms_ratio = _audio_quality_metrics(baseline, phase_shifted) + different_cosine, _ = _audio_quality_metrics(baseline, different) + + assert phase_cosine >= _MIN_AUDIO_SPECTRAL_COSINE + assert phase_rms_ratio == pytest.approx(1.0, abs=1e-5) + assert different_cosine < _MIN_AUDIO_SPECTRAL_COSINE diff --git a/tests/diffusion/models/mistral_encoder/test_mistral_encoder_tp.py b/tests/diffusion/models/mistral_encoder/test_mistral_encoder_tp.py index 27d1e757e..20024abe7 100644 --- a/tests/diffusion/models/mistral_encoder/test_mistral_encoder_tp.py +++ b/tests/diffusion/models/mistral_encoder/test_mistral_encoder_tp.py @@ -5,6 +5,7 @@ from types import SimpleNamespace import pytest import torch from vllm.config import DeviceConfig, VllmConfig, set_current_vllm_config +from vllm.model_executor.layers.linear import UnquantizedLinearMethod from vllm_omni.diffusion.models.mistral_encoder.mistral_encoder import ( MistralEncoderModel, @@ -491,3 +492,57 @@ class TestComputeLogits: assert logits.dim() == 3 assert logits.shape[0] == 1 assert logits.shape[1] == 4 + + +class TestQuantConfig: + """Verify quant_config is threaded into weight linears only, with correct prefixes.""" + + def test_quant_config_on_weight_linears(self, mocker): + quant_config = mocker.MagicMock(name="QuantizationConfig") + # Keep construction on the CPU-safe unquantized path while still + # verifying LinearBase consults the supplied quant_config. + quant_config.get_quant_method.return_value = UnquantizedLinearMethod() + + config = _make_config(num_hidden_layers=1) + model = MistralEncoderModel(config, prefix="text_encoder", quant_config=quant_config) + + layer = model.language_model.model.layers[0] + for linear in ( + layer.self_attn.qkv_proj, + layer.self_attn.o_proj, + layer.mlp.gate_up_proj, + layer.mlp.down_proj, + ): + assert linear.quant_config is quant_config + + # get_quant_method should be consulted for the four weight linears. + assert quant_config.get_quant_method.call_count == 4 + seen_prefixes = set() + for call in quant_config.get_quant_method.call_args_list: + if "prefix" in call.kwargs: + seen_prefixes.add(call.kwargs["prefix"]) + elif len(call.args) > 1: + seen_prefixes.add(call.args[1]) + assert any(p.endswith("self_attn.qkv_proj") for p in seen_prefixes) + assert any(p.endswith("self_attn.o_proj") for p in seen_prefixes) + assert any(p.endswith("mlp.gate_up_proj") for p in seen_prefixes) + assert any(p.endswith("mlp.down_proj") for p in seen_prefixes) + + # Embeddings / lm_head stay unquantized (quant_config never passed). + embed = model.language_model.model.embed_tokens + lm_head = model.language_model.lm_head + assert getattr(embed, "quant_config", None) is None + assert getattr(lm_head, "quant_config", None) is None + + def test_layer_prefixes_include_text_encoder(self): + config = _make_config(num_hidden_layers=2) + model = MistralEncoderModel(config, prefix="text_encoder") + + layer0 = model.language_model.model.layers[0] + assert layer0.self_attn.qkv_proj.prefix.startswith("text_encoder.language_model.model.layers.0") + assert layer0.self_attn.o_proj.prefix.startswith("text_encoder.language_model.model.layers.0") + assert layer0.mlp.gate_up_proj.prefix.startswith("text_encoder.language_model.model.layers.0") + assert layer0.mlp.down_proj.prefix.startswith("text_encoder.language_model.model.layers.0") + + layer1 = model.language_model.model.layers[1] + assert "layers.1" in layer1.self_attn.qkv_proj.prefix diff --git a/tests/diffusion/models/sensenova_u1/test_sensenova_u1_cfg.py b/tests/diffusion/models/sensenova_u1/test_sensenova_u1_cfg.py index 818d3d7c0..276e9e059 100644 --- a/tests/diffusion/models/sensenova_u1/test_sensenova_u1_cfg.py +++ b/tests/diffusion/models/sensenova_u1/test_sensenova_u1_cfg.py @@ -6,6 +6,8 @@ import torch from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import SenseNovaU1Pipeline +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + def _pipeline_without_init() -> SenseNovaU1Pipeline: return object.__new__(SenseNovaU1Pipeline) diff --git a/tests/diffusion/models/wan2_2/test_wan22_pipeline_diffuse.py b/tests/diffusion/models/wan2_2/test_wan22_pipeline_diffuse.py index 198c31908..8c06df034 100644 --- a/tests/diffusion/models/wan2_2/test_wan22_pipeline_diffuse.py +++ b/tests/diffusion/models/wan2_2/test_wan22_pipeline_diffuse.py @@ -9,7 +9,9 @@ import torch from torch import nn from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline +from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch +from vllm_omni.inputs.data import OmniDiffusionSamplingParams pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] @@ -91,7 +93,20 @@ def _make_pipeline() -> Wan22Pipeline: return pipeline -def test_forward_delegates_denoising_to_diffuse() -> None: +@pytest.mark.parametrize( + ("sampling_params_kwargs", "expected_low", "expected_high"), + [ + ({}, 4.0, 4.0), + ({"guidance_scale": 0.0}, 0.0, 0.0), + ({"guidance_scale": 1.0}, 1.0, 1.0), + ({"guidance_scale": 3.0, "guidance_scale_2": 5.0}, 3.0, 5.0), + ], +) +def test_forward_delegates_denoising_to_diffuse( + sampling_params_kwargs: dict[str, float], + expected_low: float, + expected_high: float, +) -> None: pipeline = _make_pipeline() captured: dict[str, object] = {} @@ -101,25 +116,15 @@ def test_forward_delegates_denoising_to_diffuse() -> None: pipeline.diffuse = _fake_diffuse # type: ignore[method-assign] - mock_req = SimpleNamespace( + mock_req = OmniDiffusionRequest( prompt="prompt", request_id="test-req", - sampling_params=SimpleNamespace( - height=None, - width=None, + sampling_params=OmniDiffusionSamplingParams( num_frames=1, num_inference_steps=2, - guidance_scale_provided=True, - guidance_scale=1.0, - guidance_scale_2=None, - boundary_ratio=None, - generator=None, - seed=None, - num_outputs_per_prompt=1, max_sequence_length=32, - latents=None, output_type="latent", - extra_args={}, + **sampling_params_kwargs, ), ) batch = DiffusionRequestBatch(requests=[mock_req]) @@ -129,8 +134,8 @@ def test_forward_delegates_denoising_to_diffuse() -> None: assert torch.equal(output.output, torch.ones((1, 4, 1, 8, 8))) assert torch.equal(captured["prompt_embeds"], torch.zeros(1, 32, 8)) assert torch.equal(captured["timesteps"], pipeline.scheduler.timesteps) - assert captured["guidance_low"] == 1.0 - assert captured["guidance_high"] == 1.0 + assert captured["guidance_low"] == expected_low + assert captured["guidance_high"] == expected_high assert captured["boundary_timestep"] == pytest.approx(875.0) assert captured["latent_condition"] is None assert captured["first_frame_mask"] is None diff --git a/tests/diffusion/offloader/test_diffusion_cpu_offload.py b/tests/diffusion/offloader/test_diffusion_cpu_offload.py index 22f6839fd..549a98142 100644 --- a/tests/diffusion/offloader/test_diffusion_cpu_offload.py +++ b/tests/diffusion/offloader/test_diffusion_cpu_offload.py @@ -5,7 +5,6 @@ import pytest import torch from vllm.distributed.parallel_state import cleanup_dist_env_and_memory -from tests.helpers import skip_if_gated_repo_inaccessible from tests.helpers.env import DeviceMemoryMonitor from tests.helpers.mark import hardware_test from tests.helpers.runtime import OmniRunner @@ -16,25 +15,26 @@ AUDIO_MODEL = { "stabilityai/stable-audio-open-1.0": {"cuda": 100, "rocm": None}, } -IMAGE_VIDEO_MODELS = { +IMAGE_MODELS = { "riverclouds/qwen_image_random": {"cuda": 2200, "rocm": 2100}, - "Tongyi-MAI/Z-Image-Turbo": {"cuda": 2500, "rocm": 2100}, - "OmniGen2/OmniGen2": {"cuda": 2500, "rocm": 2100}, } -MODELS = {**AUDIO_MODEL, **IMAGE_VIDEO_MODELS} +MODELS = {**AUDIO_MODEL, **IMAGE_MODELS} + +MODEL_MARKS = { + "riverclouds/qwen_image_random": pytest.mark.core_model, + "stabilityai/stable-audio-open-1.0": pytest.mark.full_model, +} _GATED_MODELS = {"stabilityai/stable-audio-open-1.0"} -# Aliased for backward compatibility (imported by test_diffusion_layerwise_offload.py). -_skip_if_gated_repo_inaccessible = skip_if_gated_repo_inaccessible AUDIO_MODEL_PARAMS = { "runner_params": {}, "sampler_params": {}, } -IMAGE_VIDEO_MODELS_PARAMS = { +IMAGE_MODEL_PARAMS = { "runner_params": {}, "sampler_params": { "height": 256, @@ -53,7 +53,7 @@ def inference(model_name: str, offload: bool = True): if model_name in AUDIO_MODEL: params = AUDIO_MODEL_PARAMS else: - params = IMAGE_VIDEO_MODELS_PARAMS + params = IMAGE_MODEL_PARAMS with OmniRunner( model_name, @@ -97,24 +97,19 @@ def check_audio_determinism(audio1, audio2, atol=1e-2): return True -@pytest.mark.core_model @pytest.mark.diffusion @hardware_test(res={"cuda": "L4", "rocm": "MI325"}) -@pytest.mark.parametrize("model_name", list(MODELS.keys())) +@pytest.mark.parametrize( + "model_name", + [pytest.param(name, marks=MODEL_MARKS[name]) for name in MODELS], +) def test_cpu_offload_diffusion_model(model_name: str): - if model_name == "OmniGen2/OmniGen2": - pytest.skip("issue #4537") - if model_name in _GATED_MODELS: - _skip_if_gated_repo_inaccessible(model_name) try: offload_peak_memory, output_offload = inference(model_name, offload=True) cleanup_dist_env_and_memory() no_offload_peak_memory, output_no_offload = inference(model_name, offload=False) except ValueError as exc: - # omni_snapshot_download wraps GatedRepoError in a ValueError. - # If the pre-flight guard above did not catch it (e.g. partial - # HF_TOKEN where config.json is accessible but weight shards are - # blocked), skip instead of failing. + # omni_snapshot_download wraps GatedRepoError in a ValueError; skip instead of failing. if "Access to model" in str(exc) and "is restricted" in str(exc): pytest.skip( f"Skipping: gated HF repo {model_name!r} inaccessible " diff --git a/tests/diffusion/offloader/test_diffusion_layerwise_offload.py b/tests/diffusion/offloader/test_diffusion_layerwise_offload.py index f02da6fab..342c9833b 100644 --- a/tests/diffusion/offloader/test_diffusion_layerwise_offload.py +++ b/tests/diffusion/offloader/test_diffusion_layerwise_offload.py @@ -3,11 +3,8 @@ import pytest import torch from vllm.distributed.parallel_state import cleanup_dist_env_and_memory -from tests.diffusion.offloader.test_diffusion_cpu_offload import ( - _GATED_MODELS, - _skip_if_gated_repo_inaccessible, -) from tests.helpers.env import DeviceMemoryMonitor +from tests.helpers.mark import hardware_test from tests.helpers.runtime import OmniRunner from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.platforms import current_omni_platform @@ -23,6 +20,19 @@ IMAGE_VIDEO_MODELS = { MODELS = {**AUDIO_MODEL, **IMAGE_VIDEO_MODELS} +LAYERWISE_MODEL_CASES = [ + pytest.param( + "stabilityai/stable-audio-open-1.0", + marks=[pytest.mark.full_model, pytest.mark.diffusion], + id="stable_audio", + ), + pytest.param( + "riverclouds/qwen_image_random", + marks=[pytest.mark.core_model, pytest.mark.diffusion], + id="qwen_image_random", + ), +] + AUDIO_MODEL_PARAMS = { "runner_params": {}, "sampler_params": {}, @@ -91,7 +101,8 @@ def run_inference( return peak, output -@pytest.mark.parametrize("model_name", list(MODELS.keys())) +@hardware_test(res={"cuda": "L4", "rocm": "MI325"}) +@pytest.mark.parametrize("model_name", LAYERWISE_MODEL_CASES) def test_layerwise_offload_diffusion_model(model_name: str): """Test that layerwise offloading reduces GPU memory usage. @@ -100,8 +111,6 @@ def test_layerwise_offload_diffusion_model(model_name: str): offloader keeps only a single transformer block on GPU at a time, with prefetching for compute-memory overlap. """ - if model_name in _GATED_MODELS: - _skip_if_gated_repo_inaccessible(model_name) try: # Run without layerwise offloading (baseline) no_offload_peak_memory, output_no_offload = run_inference(model_name, layerwise_offload=False) @@ -111,10 +120,7 @@ def test_layerwise_offload_diffusion_model(model_name: str): layerwise_offload_peak_memory, output_offload = run_inference(model_name, layerwise_offload=True) cleanup_dist_env_and_memory() except ValueError as exc: - # omni_snapshot_download wraps GatedRepoError in a ValueError. - # If the pre-flight guard above did not catch it (e.g. partial - # HF_TOKEN where config.json is accessible but weight shards are - # blocked), skip instead of failing. + # omni_snapshot_download wraps GatedRepoError in a ValueError; skip instead of failing. if "Access to model" in str(exc) and "is restricted" in str(exc): pytest.skip( f"Skipping: gated HF repo {model_name!r} inaccessible " diff --git a/tests/diffusion/offloader/test_distributed_layerwise_backend.py b/tests/diffusion/offloader/test_distributed_layerwise_backend.py new file mode 100644 index 000000000..fd004d056 --- /dev/null +++ b/tests/diffusion/offloader/test_distributed_layerwise_backend.py @@ -0,0 +1,1331 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Unit tests for DistributedLayerwiseOffloadHook and backend utilities.""" + +import gc +import json +import os +import socket +from contextlib import contextmanager +from types import SimpleNamespace + +import pytest +import torch +import torch.distributed as dist +from safetensors.torch import save_file +from torch import nn +from torch.distributed.tensor import DeviceMesh, DTensor, Replicate + +import vllm_omni.diffusion.offloader.distributed_layerwise_backend as dist_backend_module +from vllm_omni.diffusion.offloader.base import OffloadConfig, OffloadStrategy +from vllm_omni.diffusion.offloader.block_discovery import ( + get_blocks_attr_names, + get_blocks_from_dit, + set_blocks_attr_names, +) +from vllm_omni.diffusion.offloader.distributed_layerwise_backend import ( + DistributedLayerwiseOffloadBackend, + DistributedLayerwiseOffloadHook, + PinnedResidentLayerGroup, +) +from vllm_omni.diffusion.offloader.module_residency import PinnedModuleStager +from vllm_omni.diffusion.offloader.offload_plan import OffloadPlan, get_offload_plan +from vllm_omni.platforms import current_omni_platform + +pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model] + + +class DummyStream: + def wait_stream(self, _stream) -> None: + return None + + def wait_event(self, _event) -> None: + return None + + +class DummyEvent: + def record(self, _stream) -> None: + return None + + +@contextmanager +def dummy_stream(_stream): + yield None + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +def _set_dist_env(*, rank: int, world_size: int, master_port: int) -> None: + os.environ["RANK"] = str(rank) + os.environ["LOCAL_RANK"] = str(rank) + os.environ["WORLD_SIZE"] = str(world_size) + os.environ["MASTER_ADDR"] = "127.0.0.1" + os.environ["MASTER_PORT"] = str(master_port) + + +def _cleanup_distributed() -> None: + if dist.is_initialized(): + dist.destroy_process_group() + + for key in ["MASTER_ADDR", "MASTER_PORT", "RANK", "WORLD_SIZE", "LOCAL_RANK"]: + os.environ.pop(key, None) + + gc.collect() + if current_omni_platform.is_available(): + current_omni_platform.empty_cache() + current_omni_platform.synchronize() + + +@pytest.fixture(scope="module") +def dist_group(): + master_port = _find_free_port() + _set_dist_env(rank=0, world_size=1, master_port=master_port) + + dist.init_process_group("gloo", rank=0, world_size=1) + try: + yield + finally: + _cleanup_distributed() + + +@pytest.fixture +def patched_offload_runtime(monkeypatch): + monkeypatch.setattr(dist_backend_module.current_omni_platform, "Stream", DummyStream) + monkeypatch.setattr(dist_backend_module.current_omni_platform, "Event", DummyEvent) + monkeypatch.setattr(dist_backend_module.current_omni_platform, "current_stream", lambda: DummyStream()) + monkeypatch.setattr(dist_backend_module.current_omni_platform, "stream", dummy_stream) + monkeypatch.setattr(dist_backend_module.current_omni_platform, "synchronize", lambda: None) + + +class TinyBlock(nn.Module): + def __init__(self, values: torch.Tensor): + super().__init__() + mesh = DeviceMesh("cpu", [0]) + dtensor = DTensor.from_local(values, mesh, [Replicate()]) + self.weight = nn.Parameter(dtensor) + + +def _make_values(start: float) -> torch.Tensor: + return torch.arange(start, start + 4, dtype=torch.float32) + + +class TestDistributedLayerwiseOffloadHook: + def test_shard_and_pin_single_rank(self, dist_group, patched_offload_runtime): + """With dp_size=1, the shard should equal the full weights.""" + current_block = TinyBlock(_make_values(1.0)) + next_block = TinyBlock(_make_values(10.0)) + + hook = DistributedLayerwiseOffloadHook( + next_block=next_block, + device=torch.device("cpu"), + dp_group=None, + dp_size=1, + rank=0, + copy_stream=DummyStream(), + comm_stream=DummyStream(), + pin_memory=False, + ) + + hook.initialize_hook(current_block) + + # With dp_size=1, the CPU shard should contain all 4 elements + assert next_block.weight.dtype in hook.cpu_shards + shard = hook.cpu_shards[next_block.weight.dtype] + assert shard.numel() == 4 + assert torch.equal(shard, _make_values(10.0)) + + def test_dtensor_wrapper_preserved_across_prefetch_and_offload(self, dist_group, patched_offload_runtime): + """DTensor wrapper should be preserved through prefetch/offload cycle.""" + current_block = TinyBlock(_make_values(1.0)) + next_block = TinyBlock(_make_values(10.0)) + + hook = DistributedLayerwiseOffloadHook( + next_block=next_block, + device=torch.device("cpu"), + dp_group=None, + dp_size=1, + rank=0, + copy_stream=DummyStream(), + comm_stream=DummyStream(), + pin_memory=False, + ) + + hook.initialize_hook(current_block) + + # After init, next_block weights should be placeholders + assert isinstance(next_block.weight, DTensor) + assert next_block.weight.to_local().is_meta + + # Prefetch into slot 0 + hook.prefetch_layer(slot=0, non_blocking=False) + + # After prefetch, next_block weights should be materialized + assert isinstance(next_block.weight, DTensor) + assert torch.equal(next_block.weight.to_local(), _make_values(10.0)) + + # Offload current block + hook.offload_layer() + assert isinstance(current_block.weight, DTensor) + assert current_block.weight.to_local().is_meta + assert not hook.is_materialized + + def test_double_buffer_slot_swapping(self, dist_group, patched_offload_runtime): + """Verify slot swapping works correctly after each layer.""" + current_block = TinyBlock(_make_values(1.0)) + next_block = TinyBlock(_make_values(10.0)) + + hook = DistributedLayerwiseOffloadHook( + next_block=next_block, + device=torch.device("cpu"), + dp_group=None, + dp_size=1, + rank=0, + copy_stream=DummyStream(), + comm_stream=DummyStream(), + pin_memory=False, + ) + + hook.initialize_hook(current_block) + + assert hook.current_slot == 0 + + # Prefetch into slot 1 (next slot) + hook.prefetch_layer(slot=1, non_blocking=False) + + # Simulate post_forward: offload + swap + hook.offload_layer() + hook.current_slot = 1 - hook.current_slot + + assert hook.current_slot == 1 + + # Next iteration: prefetch into slot 0 + hook.prefetch_layer(slot=0, non_blocking=False) + hook.offload_layer() + hook.current_slot = 1 - hook.current_slot + + assert hook.current_slot == 0 + + def test_device_buffers_allocated(self, dist_group, patched_offload_runtime): + """Verify exactly two device buffers are allocated.""" + current_block = TinyBlock(_make_values(1.0)) + next_block = TinyBlock(_make_values(10.0)) + + hook = DistributedLayerwiseOffloadHook( + next_block=next_block, + device=torch.device("cpu"), + dp_group=None, + dp_size=1, + rank=0, + pin_memory=False, + ) + + hook.initialize_hook(current_block) + + # Two slots should be allocated + assert hook.gpu_buffers[0] is not None + assert hook.gpu_buffers[1] is not None + + # Each slot should have one dtype entry (float32) + assert len(hook.gpu_buffers[0]) == 1 + assert len(hook.gpu_buffers[1]) == 1 + + # Buffer size should match total numel + dtype = next_block.weight.dtype + assert hook.gpu_buffers[0][dtype].numel() == 4 + assert hook.gpu_buffers[1][dtype].numel() == 4 + + def test_sharding_multiple_ranks(self, dist_group, patched_offload_runtime): + """Verify weight sharding splits correctly across ranks.""" + block = nn.Module() + block.weight = nn.Parameter(torch.arange(8, dtype=torch.float32)) + + next_block = nn.Module() + next_block.weight = nn.Parameter(torch.arange(100, 108, dtype=torch.float32)) + + # Simulate dp_size=4, rank=1 + hook = DistributedLayerwiseOffloadHook( + next_block=next_block, + device=torch.device("cpu"), + dp_group=None, + dp_size=4, + rank=1, + pin_memory=False, + ) + + hook.initialize_hook(block) + + shard = hook.cpu_shards[torch.float32] + # 8 elements / 4 ranks = 2 elements per shard + assert shard.numel() == 2 + # Rank 1 should have elements [102, 103] + assert torch.equal(shard, torch.tensor([102.0, 103.0])) + + def test_sharding_with_remainder(self, dist_group, patched_offload_runtime): + """Verify sharding handles non-even division with padding.""" + block = nn.Module() + block.weight = nn.Parameter(torch.arange(3, dtype=torch.float32)) + + next_block = nn.Module() + next_block.weight = nn.Parameter(torch.arange(100, 103, dtype=torch.float32)) + + # 3 elements, dp_size=2: both ranks get ceil(3/2)=2 elements (padded) + hook0 = DistributedLayerwiseOffloadHook( + next_block=next_block, + device=torch.device("cpu"), + dp_group=None, + dp_size=2, + rank=0, + pin_memory=False, + ) + # Need fresh next_block for each hook since init replaces tensors + next_block0 = nn.Module() + next_block0.weight = nn.Parameter(torch.arange(100, 103, dtype=torch.float32)) + hook0.next_block = next_block0 + hook0.initialize_hook(block) + + shard0 = hook0.cpu_shards[torch.float32] + assert shard0.numel() == 2 + assert torch.equal(shard0, torch.tensor([100.0, 101.0])) + + next_block1 = nn.Module() + next_block1.weight = nn.Parameter(torch.arange(100, 103, dtype=torch.float32)) + block1 = nn.Module() + block1.weight = nn.Parameter(torch.arange(3, dtype=torch.float32)) + hook1 = DistributedLayerwiseOffloadHook( + next_block=next_block1, + device=torch.device("cpu"), + dp_group=None, + dp_size=2, + rank=1, + pin_memory=False, + ) + hook1.initialize_hook(block1) + + shard1 = hook1.cpu_shards[torch.float32] + # Equal-sized shards: rank 1 gets [102, 0] (zero-padded) + assert shard1.numel() == 2 + assert torch.equal(shard1, torch.tensor([102.0, 0.0])) + + def test_mmap_layout_transform_is_applied_before_sharding(self, dist_group, patched_offload_runtime): + block = nn.Module() + block.weight = nn.Parameter(torch.zeros(4, dtype=torch.float32)) + next_block = nn.Module() + next_block.weight = nn.Parameter(torch.arange(4, dtype=torch.float32)) + next_block.weight.mmap_weight_transform = lambda tensor: tensor.flip(0) + next_block.weight.mmap_weight_transform_pending = True + + hook = DistributedLayerwiseOffloadHook( + next_block=next_block, + device=torch.device("cpu"), + dp_group=None, + dp_size=2, + rank=0, + pin_memory=False, + ) + hook.initialize_hook(block) + + assert torch.equal(hook.cpu_shards[torch.float32], torch.tensor([3.0, 2.0])) + + def test_regular_loader_weight_does_not_reapply_mmap_transform(self, dist_group, patched_offload_runtime): + block = nn.Module() + block.weight = nn.Parameter(torch.zeros(4, dtype=torch.float32)) + next_block = nn.Module() + next_block.weight = nn.Parameter(torch.arange(4, dtype=torch.float32)) + next_block.weight.mmap_weight_transform = lambda tensor: tensor.flip(0) + + hook = DistributedLayerwiseOffloadHook( + next_block=next_block, + device=torch.device("cpu"), + dp_group=None, + dp_size=2, + rank=0, + pin_memory=False, + ) + hook.initialize_hook(block) + + assert torch.equal(hook.cpu_shards[torch.float32], torch.tensor([0.0, 1.0])) + + +class TestPinnedResidentLayerGroup: + def test_load_offload_reuses_pinned_master_weights(self, patched_offload_runtime): + blocks = [nn.Linear(2, 2), nn.Linear(2, 2)] + expected = [] + for index, block in enumerate(blocks): + block.weight.data.fill_(index + 1) + block.bias.data.fill_(index + 3) + expected.append((block.weight.detach().clone(), block.bias.detach().clone())) + + group = PinnedResidentLayerGroup( + blocks, + device=torch.device("cpu"), + copy_stream=DummyStream(), + pin_memory=False, + ) + + assert all(block.weight.numel() == 0 for block in blocks) + + group.load() + for block, (weight, bias) in zip(blocks, expected): + assert torch.equal(block.weight, weight) + assert torch.equal(block.bias, bias) + + # Device-side changes must not overwrite the persistent host master. + blocks[0].weight.data.zero_() + group.offload() + assert all(block.weight.numel() == 0 for block in blocks) + + group.load() + for block, (weight, bias) in zip(blocks, expected): + assert torch.equal(block.weight, weight) + assert torch.equal(block.bias, bias) + + group.offload() + + def test_mmap_loader_attrs_survive_to_empty_parameter_replacement(self): + module = nn.Linear(2, 2, bias=False) + + def transform(tensor): + return tensor.flip(0) + + module.weight.mmap_weight_transform = transform + + backend = object.__new__(DistributedLayerwiseOffloadBackend) + backend._remember_mmap_param_attrs(module) + module.to_empty(device="meta") + + replacement = nn.Parameter(torch.arange(4).reshape(2, 2).float()) + backend._attach_mmap_param_attrs("weight", replacement, module.weight) + + assert replacement.mmap_weight_transform is transform + assert replacement.mmap_weight_transform_pending is True + + def test_shared_allgather_output_is_narrowed_to_current_block( + self, dist_group, patched_offload_runtime, monkeypatch + ): + block = nn.Module() + block.weight = nn.Parameter(torch.zeros(3, dtype=torch.float32)) + next_block = nn.Module() + next_block.weight = nn.Parameter(torch.arange(3, dtype=torch.float32)) + + hook = DistributedLayerwiseOffloadHook( + next_block=next_block, + device=torch.device("cpu"), + dp_group=object(), + dp_size=2, + rank=0, + pin_memory=False, + ) + hook.initialize_hook(block) + # Simulate unified buffers sized for a larger heterogeneous block. + hook.gpu_buffers = [ + {torch.float32: torch.empty(12)}, + {torch.float32: torch.empty(12)}, + ] + hook.gpu_shard_buffers = [ + {torch.float32: torch.empty(6)}, + {torch.float32: torch.empty(6)}, + ] + output_sizes = [] + + def fake_allgather(output, local_shard, *, group): + del group + output_sizes.append(output.numel()) + output.zero_() + output[: local_shard.numel()].copy_(local_shard) + + monkeypatch.setattr(torch.distributed, "all_gather_into_tensor", fake_allgather) + + hook.prefetch_layer(slot=0, non_blocking=False) + + assert output_sizes == [4] + + +class TestPinnedModuleStager: + def test_offload_rebinds_immutable_cpu_master(self, patched_offload_runtime): + module = nn.Linear(2, 2) + expected_weight = module.weight.detach().clone() + expected_bias = module.bias.detach().clone() + stager = PinnedModuleStager( + module, + torch.device("cpu"), + pin_memory=False, + copy_stream=DummyStream(), + ) + + stager.load() + module.weight.data.zero_() + module.bias.data.zero_() + stager.offload() + + assert torch.equal(module.weight, expected_weight) + assert torch.equal(module.bias, expected_bias) + stager.load() + assert torch.equal(module.weight, expected_weight) + assert torch.equal(module.bias, expected_bias) + stager.offload() + + +class _DummyBlock(nn.Module): + def __init__(self): + super().__init__() + self.weight = nn.Parameter(torch.randn(10, 10)) + + +class _SingleBlockModel(nn.Module): + _layerwise_offload_blocks_attrs = ["blocks"] + + def __init__(self, num_blocks: int = 3): + super().__init__() + self.blocks = nn.ModuleList([_DummyBlock() for _ in range(num_blocks)]) + + +class _MultiBlockModel(nn.Module): + _layerwise_offload_blocks_attrs = ["transformer_blocks", "single_transformer_blocks"] + + def __init__(self, num_transformer: int = 2, num_single: int = 2): + super().__init__() + self.transformer_blocks = nn.ModuleList([_DummyBlock() for _ in range(num_transformer)]) + self.single_transformer_blocks = nn.ModuleList([_DummyBlock() for _ in range(num_single)]) + + +class _EmptyBlocksModel(nn.Module): + _layerwise_offload_blocks_attrs = ["blocks"] + + def __init__(self): + super().__init__() + self.blocks = nn.ModuleList([]) + + +class _NoAttrsModel(nn.Module): + def __init__(self, num_blocks: int = 2): + super().__init__() + self.blocks = nn.ModuleList([_DummyBlock() for _ in range(num_blocks)]) + + +class _MmapPostLoadModel(nn.Module): + _layerwise_offload_blocks_attrs = ["blocks"] + + def __init__(self): + super().__init__() + self.time_embedder = nn.Linear(2, 2, bias=False) + self.blocks = nn.ModuleList([nn.Linear(2, 2, bias=False) for _ in range(2)]) + self.post_load_calls = 0 + + def post_load_weights(self) -> None: + self.time_embedder.to(torch.float32) + self.post_load_calls += 1 + + +class _MmapPostLoadPipeline(nn.Module): + def __init__(self): + super().__init__() + with torch.device("meta"): + self.transformer = _MmapPostLoadModel() + + @staticmethod + def _remap_ckpt_key(key: str) -> str: + return key + + +class TestMmapWeightLoading: + def test_runs_model_post_load_hook(self, tmp_path, patched_offload_runtime): + pipeline = _MmapPostLoadPipeline() + weights = {name: torch.ones(param.shape, dtype=torch.bfloat16) for name, param in pipeline.named_parameters()} + weight_file = tmp_path / "model.safetensors" + save_file(weights, str(weight_file)) + weight_map = {name: weight_file.name for name in weights} + (tmp_path / "model.safetensors.index.json").write_text(json.dumps({"weight_map": weight_map})) + + backend = DistributedLayerwiseOffloadBackend( + OffloadConfig( + strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE, + pin_cpu_memory=False, + model_path=str(tmp_path), + ), + torch.device("cpu"), + ) + modules = SimpleNamespace( + dits=[pipeline.transformer], + dit_names=["transformer"], + ) + + backend._load_weights_via_mmap(pipeline, modules) + + assert pipeline.transformer.post_load_calls == 1 + assert pipeline.transformer.time_embedder.weight.dtype == torch.float32 + assert pipeline.transformer.blocks[0].weight.dtype == torch.bfloat16 + + +class TestGetBlocksFromDit: + def test_get_blocks_from_dit_single_block_attr(self): + model = _SingleBlockModel(num_blocks=3) + attr_names, blocks = get_blocks_from_dit(model) + assert attr_names == ["blocks"] + assert len(blocks) == 3 + assert all(isinstance(b, _DummyBlock) for b in blocks) + + def test_get_blocks_from_dit_multi_block_attrs(self): + model = _MultiBlockModel(num_transformer=2, num_single=3) + attr_names, blocks = get_blocks_from_dit(model) + assert set(attr_names) == {"transformer_blocks", "single_transformer_blocks"} + assert len(blocks) == 5 + + def test_get_blocks_from_dit_empty_blocks(self): + model = _EmptyBlocksModel() + attr_names, blocks = get_blocks_from_dit(model) + assert attr_names == [] + assert blocks == [] + + def test_get_blocks_from_dit_no_attrs_defined(self): + model = _NoAttrsModel(num_blocks=3) + attr_names, blocks = get_blocks_from_dit(model) + assert attr_names == [] + assert blocks == [] + + +class TestGetBlocksAttrNames: + def test_get_blocks_attr_names_new_format(self): + model = _MultiBlockModel() + attrs = get_blocks_attr_names(model) + assert attrs == ["transformer_blocks", "single_transformer_blocks"] + + def test_get_blocks_attr_names_no_attrs(self): + model = _NoAttrsModel() + attrs = get_blocks_attr_names(model) + assert attrs == [] + + def test_set_blocks_attr_names(self): + model = _NoAttrsModel() + set_blocks_attr_names(model, ["new_blocks"]) + assert hasattr(model.__class__, "_layerwise_offload_blocks_attrs") + assert model.__class__._layerwise_offload_blocks_attrs == ["new_blocks"] + + +class TestCrossGroupSharedBuffer: + """Regression test: when multiple DiT groups share the same 2 GPU + buffers, the first block of each group must always sync-prefetch on + entry, because another group may have overwritten the shared slot. + """ + + def test_group_first_hook_forces_sync_prefetch(self, dist_group, patched_offload_runtime): + """Verify _is_group_first=True forces sync-prefetch in pre_forward + even when is_materialized returns True.""" + block_a = TinyBlock(_make_values(1.0)) + block_b = TinyBlock(_make_values(10.0)) + + hook_a = DistributedLayerwiseOffloadHook( + next_block=block_b, + device=torch.device("cpu"), + dp_group=None, + dp_size=1, + rank=0, + pin_memory=False, + ) + hook_a.initialize_hook(block_a) + + # Prefetch to make block_a "materialized" (non-empty params) + hook_a.prefetch_layer(hook_a.current_slot, non_blocking=False) + assert hook_a.is_materialized + + # Track sync-prefetch calls + sync_called = [False] + orig = hook_a.prefetch_layer + + def tracking(slot, non_blocking=True): + if not non_blocking: + sync_called[0] = True + orig(slot, non_blocking=non_blocking) + + hook_a.prefetch_layer = tracking + + # Case 1: _is_group_first=False, materialized → no sync-prefetch + hook_a._is_group_first = False + sync_called[0] = False + hook_a.pre_forward(block_a) + assert not sync_called[0], "Materialized block should not sync-prefetch without _is_group_first" + + # Case 2: _is_group_first=True, materialized, slot contaminated + # (no _shared_slot_group set → defaults to contaminated) → MUST sync-prefetch + hook_a._is_group_first = True + hook_a._prev_hook = hook_a + sync_called[0] = False + hook_a.pre_forward(block_a) + assert sync_called[0], "_is_group_first must force sync-prefetch when slot is contaminated" + + # Case 3: _is_group_first=True, slot NOT contaminated (same group owns slot) + # → skip sync-prefetch, just wait for async event + hook_a._shared_slot_group = [hook_a._group_id, -1] + sync_called[0] = False + hook_a.pre_forward(block_a) + assert not sync_called[0], "Should skip sync-prefetch when slot_group matches (non-contaminated)" + + +class TestAllGatherOutputSlicing: + """Regression test: with heterogeneous block sizes (e.g. MiniMax H3's + token_refiner with blocks of ~1231MB and ~239MB), the shared AllGather + output buffers are sized to the *largest* block across all groups. + prefetch_layer must slice the output buffer down to each block's actual + AllGather output size, otherwise output.numel() != dp_size * input.numel() + and all_gather_into_tensor raises during enable()'s first prefetch. + """ + + def test_prefetch_slices_shared_output_buffer_to_block_size(self, dist_group, patched_offload_runtime, monkeypatch): + dp_size = 2 + + # Heterogeneous block sizes: 8 vs 32 elements. + small_block = nn.Module() + small_block.weight = nn.Parameter(torch.arange(8, dtype=torch.float32)) + large_block = nn.Module() + large_block.weight = nn.Parameter(torch.arange(100, 132, dtype=torch.float32)) + + def make_hook(next_block: nn.Module) -> DistributedLayerwiseOffloadHook: + hook = DistributedLayerwiseOffloadHook( + next_block=next_block, + device=torch.device("cpu"), + dp_group=dist.group.WORLD, # non-None -> AllGather path (mocked below) + dp_size=dp_size, + rank=0, + pin_memory=False, + ) + current_block = nn.Module() + current_block.weight = nn.Parameter(torch.zeros(4, dtype=torch.float32)) + hook.initialize_hook(current_block) + return hook + + hook_small = make_hook(small_block) + hook_large = make_hook(large_block) + hooks = [hook_small, hook_large] + + # Assign max-sized shared buffers exactly as backend.enable() does. + shared_buffers = DistributedLayerwiseOffloadBackend._allocate_shared_buffers(hooks) + shared_shard_buffers = DistributedLayerwiseOffloadBackend._allocate_shared_shard_buffers(hooks) + for hook in hooks: + hook.gpu_buffers = shared_buffers + hook.gpu_shard_buffers = shared_shard_buffers + + # Shared output buffer must be sized to the largest block (32). + assert shared_buffers[0][torch.float32].numel() == 32 + + # Fake AllGather that enforces the real torch size contract and + # emulates reconstruction of the full block from all ranks' shards. + full_values = { + 8: torch.arange(8, dtype=torch.float32), + 32: torch.arange(100, 132, dtype=torch.float32), + } + ag_output_numels: list[int] = [] + + def fake_all_gather(output, input, group=None): + assert output.numel() == dp_size * input.numel(), ( + f"AllGather contract violated: output.numel()={output.numel()} " + f"!= {dp_size} * input.numel()={input.numel()}" + ) + ag_output_numels.append(output.numel()) + output.copy_(full_values[output.numel()]) + + monkeypatch.setattr(dist, "all_gather_into_tensor", fake_all_gather) + + # Small block: output must be sliced to dp_size * shard = 8, not 32. + hook_small.prefetch_layer(slot=0, non_blocking=False) + assert ag_output_numels[-1] == 8 + assert torch.equal(small_block.weight, full_values[8]) + + # Large block: uses the full max-sized buffer. + hook_large.prefetch_layer(slot=1, non_blocking=False) + assert ag_output_numels[-1] == 32 + assert torch.equal(large_block.weight, full_values[32]) + + +class TestOffloadPlan: + """Test declarative OffloadPlan metadata.""" + + def test_get_offload_plan_returns_none_when_not_declared(self): + """Pipelines without _offload_plan should return None.""" + model = _SingleBlockModel(num_blocks=3) + assert get_offload_plan(model) is None + + def test_get_offload_plan_returns_declared_plan(self): + """Pipelines with _offload_plan should return it.""" + plan = OffloadPlan( + block_attrs={"transformer": ("gen_layers",)}, + offload_submodules={"context_encoder": "layers"}, + resident_dit_paths=frozenset({"transformer"}), + ) + + class PipelineWithPlan(nn.Module): + _offload_plan = plan + + model = PipelineWithPlan() + result = get_offload_plan(model) + assert result is plan + assert result.block_attrs == {"transformer": ("gen_layers",)} + assert result.offload_submodules == {"context_encoder": "layers"} + assert result.resident_dit_paths == frozenset({"transformer"}) + + def test_offload_plan_defaults_to_empty(self): + """OffloadPlan with no arguments should have empty dicts.""" + plan = OffloadPlan() + assert plan.block_attrs == {} + assert plan.offload_submodules == {} + assert plan.resident_dit_paths == frozenset() + + def test_offload_plan_is_frozen(self): + """OffloadPlan should be immutable (frozen=True).""" + plan = OffloadPlan() + with pytest.raises(Exception): + plan.block_attrs = {"x": ("y",)} # type: ignore + + def test_all_resident_dit_still_prepares_planned_submodules(self, patched_offload_runtime): + class TokenRefiner(nn.Module): + def __init__(self): + super().__init__() + self.blocks = nn.ModuleList([nn.Linear(2, 2), nn.Linear(2, 2)]) + + class Transformer(nn.Module): + _layerwise_offload_blocks_attrs = ["blocks"] + + def __init__(self): + super().__init__() + self.blocks = nn.ModuleList([nn.Linear(2, 2), nn.Linear(2, 2)]) + self.token_refiner = TokenRefiner() + + class Pipeline(nn.Module): + _offload_plan = OffloadPlan( + offload_submodules={"token_refiner": "blocks"}, + resident_dit_paths=frozenset({"transformer"}), + ) + + def __init__(self): + super().__init__() + self.transformer = Transformer() + + pipeline = Pipeline() + backend = DistributedLayerwiseOffloadBackend( + OffloadConfig( + strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE, + pin_cpu_memory=False, + dlo_use_allgather=False, + dlo_resident_layers=2, + ), + torch.device("cpu"), + ) + + backend.enable(pipeline) + + assert len(backend._resident_blocks) == 2 + assert len(backend._all_hook_groups) == 1 + assert len(backend._all_hook_groups[0]) == 2 + assert backend.enabled + + backend.disable() + + +class TestMmapValidation: + """Tests for mmap loader validation: TP rejection, strict check, validate_loaded_weights.""" + + def test_tp_rejected_when_tp_world_size_gt_1(self, tmp_path, patched_offload_runtime, monkeypatch): + """DLO+AllGather should reject when TP world size > 1.""" + import json + from types import SimpleNamespace + + from safetensors.torch import save_file + + from vllm_omni.diffusion.offloader.base import OffloadConfig, OffloadStrategy + + # Mock TP world size = 2 + monkeypatch.setattr( + "vllm.distributed.parallel_state.get_tensor_model_parallel_world_size", + lambda: 2, + ) + + class SimpleModel(nn.Module): + _layerwise_offload_blocks_attrs = ["blocks"] + + def __init__(self): + super().__init__() + self.blocks = nn.ModuleList([nn.Linear(4, 4, bias=False)]) + + class SimplePipeline(nn.Module): + def __init__(self): + super().__init__() + self.transformer = SimpleModel() + + @staticmethod + def _remap_ckpt_key(key): + return key + + pipeline = SimplePipeline() + weights = {name: torch.ones(p.shape, dtype=p.dtype) for name, p in pipeline.named_parameters() if not p.is_meta} + save_file(weights, str(tmp_path / "model.safetensors")) + (tmp_path / "model.safetensors.index.json").write_text( + json.dumps({"weight_map": {k: "model.safetensors" for k in weights}}) + ) + + backend = DistributedLayerwiseOffloadBackend( + OffloadConfig( + strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE, + pin_cpu_memory=False, + model_path=str(tmp_path), + ), + torch.device("cpu"), + ) + modules = SimpleNamespace(dits=[pipeline.transformer], dit_names=["transformer"]) + + with pytest.raises(ValueError, match="Tensor Parallel"): + backend._load_weights_via_mmap(pipeline, modules) + + def test_tp_allowed_when_tp_world_size_eq_1(self, tmp_path, patched_offload_runtime, monkeypatch): + """DLO+AllGather should work when TP world size = 1, even if params + have custom weight_loader attributes (e.g. QKVParallelLinear at TP=1).""" + import json + from types import SimpleNamespace + + from safetensors.torch import save_file + + from vllm_omni.diffusion.offloader.base import OffloadConfig, OffloadStrategy + + # Mock TP world size = 1 (default) + monkeypatch.setattr( + "vllm.distributed.parallel_state.get_tensor_model_parallel_world_size", + lambda: 1, + ) + + def custom_weight_loader(param, weight): + param.data.copy_(weight) + + class ModelWithCustomLoader(nn.Module): + _layerwise_offload_blocks_attrs = ["blocks"] + + def __init__(self): + super().__init__() + self.linear = nn.Linear(4, 4, bias=False) + # Simulate QKVParallelLinear: custom weight_loader even at TP=1 + self.linear.weight.weight_loader = custom_weight_loader + self.blocks = nn.ModuleList([nn.Linear(4, 4, bias=False)]) + + class PipelineWithLoader(nn.Module): + def __init__(self): + super().__init__() + self.transformer = ModelWithCustomLoader() + + @staticmethod + def _remap_ckpt_key(key): + return key + + pipeline = PipelineWithLoader() + weights = {name: torch.ones(p.shape, dtype=p.dtype) for name, p in pipeline.named_parameters() if not p.is_meta} + save_file(weights, str(tmp_path / "model.safetensors")) + (tmp_path / "model.safetensors.index.json").write_text( + json.dumps({"weight_map": {k: "model.safetensors" for k in weights}}) + ) + + backend = DistributedLayerwiseOffloadBackend( + OffloadConfig( + strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE, + pin_cpu_memory=False, + model_path=str(tmp_path), + ), + torch.device("cpu"), + ) + modules = SimpleNamespace(dits=[pipeline.transformer], dit_names=["transformer"]) + + # Should NOT raise — TP=1 is allowed even with custom weight_loader + backend._load_weights_via_mmap(pipeline, modules) + + def test_validate_loaded_weights_called(self, tmp_path, patched_offload_runtime): + """validate_loaded_weights should be called after mmap loading.""" + import json + from types import SimpleNamespace + + from safetensors.torch import save_file + + from vllm_omni.diffusion.offloader.base import OffloadConfig, OffloadStrategy + + class ModelWithValidate(nn.Module): + _layerwise_offload_blocks_attrs = ["blocks"] + validate_called = False + + def __init__(self): + super().__init__() + self.blocks = nn.ModuleList([nn.Linear(4, 4, bias=False) for _ in range(2)]) + + def post_load_weights(self): + pass + + def validate_loaded_weights(self, loaded): + self.validate_called = True + assert len(loaded) > 0, "Should have loaded weights" + + class PipelineWithValidate(nn.Module): + def __init__(self): + super().__init__() + self.transformer = ModelWithValidate() + + @staticmethod + def _remap_ckpt_key(key): + return key + + pipeline = PipelineWithValidate() + # Save on meta to trigger mmap path + with torch.device("meta"): + pipeline.transformer = ModelWithValidate() + + weights = { + name: torch.ones(param.shape, dtype=param.dtype if not param.is_meta else torch.float32) + for name, param in pipeline.named_parameters() + } + save_file(weights, str(tmp_path / "model.safetensors")) + (tmp_path / "model.safetensors.index.json").write_text( + json.dumps({"weight_map": {k: "model.safetensors" for k in weights}}) + ) + + backend = DistributedLayerwiseOffloadBackend( + OffloadConfig( + strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE, pin_cpu_memory=False, model_path=str(tmp_path) + ), + torch.device("cpu"), + ) + modules = SimpleNamespace(dits=[pipeline.transformer], dit_names=["transformer"]) + + backend._load_weights_via_mmap(pipeline, modules) + assert pipeline.transformer.validate_called, "validate_loaded_weights should be called" + + +class TestConfigValidation: + """Tests for configuration validation in OffloadConfig / execute_request.""" + + def test_hsdp_with_allgather_rejected(self): + """HSDP + DLO + AllGather should raise ValueError (double sharding).""" + from vllm_omni.diffusion.offloader.base import OffloadConfig + + class FakePC: + data_parallel_size = 1 + use_hsdp = True + hsdp_shard_size = 2 + hsdp_replicate_size = 1 + sequence_parallel_size = 1 + tensor_parallel_size = 1 + cfg_parallel_size = 1 + pipeline_parallel_size = 1 + ulysses_degree = 1 + ring_degree = 1 + allgather_degree = 1 + enable_expert_parallel = False + + class FakeODConfig: + enable_cpu_offload = False + enable_layerwise_offload = False + enable_distributed_layerwise_offload = True + dlo_use_allgather = True + pin_cpu_memory = True + parallel_config = FakePC() + model = "/fake/path" + + with pytest.raises(ValueError, match="incompatible with HSDP"): + OffloadConfig.from_od_config(FakeODConfig()) + + def test_hsdp_without_allgather_allowed(self): + """HSDP + DLO + no-AllGather uses standard-loader rank-local weights.""" + from vllm_omni.diffusion.offloader.base import OffloadConfig + + class FakePC: + data_parallel_size = 1 + use_hsdp = True + hsdp_shard_size = 2 + hsdp_replicate_size = 1 + sequence_parallel_size = 1 + tensor_parallel_size = 1 + cfg_parallel_size = 1 + pipeline_parallel_size = 1 + ulysses_degree = 1 + ring_degree = 1 + allgather_degree = 1 + enable_expert_parallel = False + + class FakeODConfig: + enable_cpu_offload = False + enable_layerwise_offload = False + enable_distributed_layerwise_offload = True + dlo_use_allgather = False # no AllGather → should be allowed + dlo_resident_layers = 20 + pin_cpu_memory = True + parallel_config = FakePC() + model = "/fake/path" + + config = OffloadConfig.from_od_config(FakeODConfig()) + assert config.dp_size == 1 # forced to 1 when no AllGather + assert config.dlo_resident_layers == 20 + + def test_resident_layers_with_allgather_rejected(self): + class FakePC: + data_parallel_size = 2 + use_hsdp = False + sequence_parallel_size = 1 + + class FakeODConfig: + enable_cpu_offload = False + enable_layerwise_offload = False + enable_distributed_layerwise_offload = True + dlo_use_allgather = True + dlo_resident_layers = 20 + pin_cpu_memory = True + parallel_config = FakePC() + model = "/fake/path" + + with pytest.raises(ValueError, match="requires --dlo-no-use-allgather"): + OffloadConfig.from_od_config(FakeODConfig()) + + def test_num_inference_steps_none_rejected(self): + """DP multi-concurrency should reject None num_inference_steps.""" + from types import SimpleNamespace + + # Mock requests with None steps + reqs = [ + SimpleNamespace( + req=SimpleNamespace( + request_id=f"req-{i}", + sampling_params=SimpleNamespace(num_inference_steps=None), + ) + ) + for i in range(2) + ] + + # We can't easily instantiate the full executor, but we can test + # the validation logic by checking that the code path raises. + # The validation is in execute_request, which needs self._ensure_open(). + # Instead, test the validation logic directly: + step_counts = { + r.req.sampling_params.num_inference_steps + for r in reqs + if r.req.sampling_params.num_inference_steps is not None + } + has_none = any(r.req.sampling_params.num_inference_steps is None for r in reqs) + assert has_none, "Test setup: should have None steps" + assert len(step_counts) == 0, "Test setup: no explicit steps" + + # The validation condition: (len(step_counts) > 1) or has_none → should reject + should_reject = (len(step_counts) > 1) or has_none + assert should_reject, "None steps should trigger rejection" + + def test_num_inference_steps_same_explicit_allowed(self): + """DP multi-concurrency should allow same explicit num_inference_steps.""" + from types import SimpleNamespace + + reqs = [ + SimpleNamespace( + req=SimpleNamespace( + request_id=f"req-{i}", + sampling_params=SimpleNamespace(num_inference_steps=35), + ) + ) + for i in range(4) + ] + + step_counts = { + r.req.sampling_params.num_inference_steps + for r in reqs + if r.req.sampling_params.num_inference_steps is not None + } + has_none = any(r.req.sampling_params.num_inference_steps is None for r in reqs) + + should_reject = (len(step_counts) > 1) or has_none + assert not should_reject, "Same explicit steps should be allowed" + assert step_counts == {35} + + def test_num_inference_steps_different_explicit_rejected(self): + """DP multi-concurrency should reject different explicit steps.""" + from types import SimpleNamespace + + reqs = [ + SimpleNamespace( + req=SimpleNamespace( + request_id="req-0", + sampling_params=SimpleNamespace(num_inference_steps=35), + ) + ), + SimpleNamespace( + req=SimpleNamespace( + request_id="req-1", + sampling_params=SimpleNamespace(num_inference_steps=30), + ) + ), + ] + + step_counts = { + r.req.sampling_params.num_inference_steps + for r in reqs + if r.req.sampling_params.num_inference_steps is not None + } + has_none = any(r.req.sampling_params.num_inference_steps is None for r in reqs) + + should_reject = (len(step_counts) > 1) or has_none + assert should_reject, "Different steps should trigger rejection" + assert len(step_counts) == 2 + + +class TestDynamicSlotTracking: + """Tests for dynamic slot tracking with odd and even block counts. + + With i%2 initialization alone, odd block counts cause the circular + tail→head prefetch to collide with the head's read slot (both map + to slot 0). Dynamic slot tracking via _prefetched_slot corrects + this by reading the actual slot from the previous hook's prefetch. + """ + + @staticmethod + def _trace_slots(num_blocks: int, num_iterations: int = 1): + """Trace slot assignments through forward passes (pure logic, no hooks). + + Returns a list of (read_slot, write_slot) per block per iteration. + """ + # Initial i%2 assignment (same as enable()) + current_slots = [i % 2 for i in range(num_blocks)] + prefetched_slots: list[int | None] = [None] * num_blocks + + # Sync-prefetch: tail hook prefetches into slot 0 + prefetched_slots[-1] = 0 + + results = [] + for _ in range(num_iterations): + for i in range(num_blocks): + prev_idx = (i - 1) % num_blocks + + # Dynamic slot tracking: read from prev's prefetched slot + if prefetched_slots[prev_idx] is not None: + current_slots[i] = prefetched_slots[prev_idx] + + read_slot = current_slots[i] + + # Prefetch into other slot + next_slot = 1 - read_slot + current_slots[i] = next_slot + prefetched_slots[i] = next_slot + + results.append((read_slot, next_slot)) + + return results + + def test_odd_block_count_no_collision(self): + """3 blocks (odd): no read/write slot collision.""" + results = self._trace_slots(3) + for i, (read, write) in enumerate(results): + assert read != write, f"Block {i % 3} (step {i}): read={read} == write={write}" + + def test_even_block_count_no_collision(self): + """4 blocks (even): no read/write slot collision.""" + results = self._trace_slots(4) + for i, (read, write) in enumerate(results): + assert read != write, f"Block {i % 4} (step {i}): read={read} == write={write}" + + def test_large_odd_block_count(self): + """7 blocks (odd): no collision across all blocks.""" + results = self._trace_slots(7) + for i, (read, write) in enumerate(results): + assert read != write, f"Block {i % 7} (step {i}): read={read} == write={write}" + + def test_5_blocks_two_iterations(self): + """5 blocks (odd), 2 iterations: no collision in either pass.""" + results = self._trace_slots(5, num_iterations=2) + n = 5 + for iteration in range(2): + for i in range(n): + idx = iteration * n + i + read, write = results[idx] + assert read != write, f"Iter {iteration} block {i}: read={read} == write={write}" + + def test_3_blocks_three_iterations(self): + """3 blocks (odd), 3 iterations: no collision across all passes.""" + results = self._trace_slots(3, num_iterations=3) + n = 3 + for iteration in range(3): + for i in range(n): + idx = iteration * n + i + read, write = results[idx] + assert read != write, f"Iter {iteration} block {i}: read={read} == write={write}" + + def test_72_blocks_cosmos3(self): + """72 blocks (Cosmos3, even): no collision.""" + results = self._trace_slots(72) + for i, (read, write) in enumerate(results): + assert read != write, f"Block {i % 72} (step {i}): read={read} == write={write}" + + def test_slots_alternate_within_iteration(self): + """Within one iteration, consecutive blocks should use alternating slots.""" + results = self._trace_slots(6) + for i in range(6): + read, write = results[i] + # Each block reads from one slot and writes to the other + assert {read, write} == {0, 1}, f"Block {i}: slots {read},{write} don't cover both" + + def test_prefetched_slot_set_after_prefetch(self, dist_group, patched_offload_runtime): + """prefetch_layer should set _prefetched_slot to the slot argument.""" + block = TinyBlock(_make_values(1.0)) + next_block = TinyBlock(_make_values(2.0)) + + hook = DistributedLayerwiseOffloadHook( + next_block=next_block, + device=torch.device("cpu"), + dp_group=None, + dp_size=1, + rank=0, + copy_stream=DummyStream(), + comm_stream=DummyStream(), + pin_memory=False, + ) + hook.initialize_hook(block) + + assert hook._prefetched_slot is None # initially None + + hook.prefetch_layer(slot=0, non_blocking=False) + assert hook._prefetched_slot == 0 + + hook.prefetch_layer(slot=1, non_blocking=False) + assert hook._prefetched_slot == 1 + + def test_dynamic_slot_overrides_initial_assignment(self, dist_group, patched_offload_runtime): + """When _prev_hook._prefetched_slot is set, pre_forward should use + that value instead of the initial i%2 assignment.""" + block_a = TinyBlock(_make_values(1.0)) + block_b = TinyBlock(_make_values(2.0)) + + hook_a = DistributedLayerwiseOffloadHook( + next_block=block_b, + device=torch.device("cpu"), + dp_group=None, + dp_size=1, + rank=0, + copy_stream=DummyStream(), + comm_stream=DummyStream(), + pin_memory=False, + ) + hook_a.initialize_hook(block_a) + + hook_b = DistributedLayerwiseOffloadHook( + next_block=block_a, + device=torch.device("cpu"), + dp_group=None, + dp_size=1, + rank=0, + copy_stream=DummyStream(), + comm_stream=DummyStream(), + pin_memory=False, + ) + hook_b.initialize_hook(block_b) + + # Wire: hook_b._prev_hook = hook_a + hook_b._prev_hook = hook_a + + # hook_a prefetched into slot 1 + hook_a.prefetch_layer(slot=1, non_blocking=False) + assert hook_a._prefetched_slot == 1 + + # hook_b's initial slot is 0 (from i%2) + hook_b.current_slot = 0 + + # pre_forward should override to slot 1 + hook_b.pre_forward(block_b) + assert hook_b.current_slot == 1, "pre_forward should read _prev_hook._prefetched_slot=1, not keep initial 0" diff --git a/tests/plugin/test_gguf_diffusion.py b/tests/diffusion/quantization/test_gguf_diffusion.py similarity index 97% rename from tests/plugin/test_gguf_diffusion.py rename to tests/diffusion/quantization/test_gguf_diffusion.py index 44de4a1fb..fa87e8a7c 100644 --- a/tests/plugin/test_gguf_diffusion.py +++ b/tests/diffusion/quantization/test_gguf_diffusion.py @@ -5,10 +5,10 @@ Validates that GGUF-quantized diffusion models generate valid images and use less peak GPU memory than BF16 baseline. -Requires vllm-omni to be installed alongside the plugin. +Requires vllm-omni to be installed alongside the vllm-gguf-plugin package. Usage: - pytest tests/diffusion/test_gguf_memory.py -v + pytest tests/diffusion/quantization/test_gguf_diffusion.py -v """ from __future__ import annotations @@ -112,7 +112,6 @@ def _generate_single_stage_image( @hardware_test(res={"cuda": "L4"}) @pytest.mark.full_model @pytest.mark.diffusion -@pytest.mark.slow @pytest.mark.parametrize("model", [Z_IMAGE_CONFIG, FLUX_CONFIG], ids=["Z-Image-Turbo", "FLUX.2-klein"]) def test_single_stage_diffusion_gguf(model: DiffusionGGUFTestConfig, monkeypatch: pytest.MonkeyPatch) -> None: """Z-Image-Turbo GGUF generates valid images and uses less memory than BF16.""" diff --git a/tests/diffusion/quantization/test_int8_config.py b/tests/diffusion/quantization/test_int8_config.py index 875277ece..f706cc68b 100644 --- a/tests/diffusion/quantization/test_int8_config.py +++ b/tests/diffusion/quantization/test_int8_config.py @@ -147,6 +147,130 @@ def test_get_npu_quant_method(mocker: MockerFixture, monkeypatch: pytest.MonkeyP assert isinstance(method, UnquantizedLinearMethod) +def test_fused_layer_can_be_ignored_by_its_prefix(monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture): + """A layer opts out of quantization under the prefix its module was built with. + + MiniMax H3 stores qkv as one checkpoint tensor, so the fused name is the + only handle a user has on it. + """ + from vllm_omni.quantization.int8_config import NPUInt8OnlineLinearMethod + + monkeypatch.setattr(current_omni_platform, "is_cuda", lambda: False) + monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True) + config = build_quant_config("int8", ignored_layers=["blocks.0.attn.qkv_proj"]) + layer = mocker.Mock(spec=LinearBase) + + assert isinstance(config.get_quant_method(layer, "blocks.0.attn.qkv_proj"), UnquantizedLinearMethod) + assert isinstance(config.get_quant_method(layer, "blocks.1.attn.qkv_proj"), NPUInt8OnlineLinearMethod) + assert isinstance(config.get_quant_method(layer, "blocks.0.mlp.fc1"), NPUInt8OnlineLinearMethod) + + +class TestNPUQuantMatmulShapeFallback: + """npu_quant_matmul rejects wide outputs, so those layers stay unquantized. + + The limit applies to the per-rank shard, which is what the kernel sees. The + same layer can therefore be quantized at a higher TP degree. + """ + + @pytest.fixture(autouse=True) + def _mock_tp(self, mocker): + # The fallback delegates to UnquantizedLinearMethod, which reads the TP + # group; stand one in so the shape gating can be tested without a + # distributed init. + mock_group = mocker.Mock() + mock_group.rank_in_group = 0 + mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_world_size", return_value=1) + mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_rank", return_value=0) + mocker.patch("vllm.distributed.parallel_state.get_tp_group", return_value=mock_group) + + @pytest.fixture + def quant_config(self): + from vllm_omni.quantization.int8_config import DiffusionInt8Config + + return DiffusionInt8Config(is_checkpoint_int8_serialized=False, activation_scheme="dynamic") + + def create_weights(self, method, layer, output_partition_sizes): + # Narrow input dim: the fallback path allocates for real, and only the + # output dimension is under test. + method.create_weights( + layer, + input_size_per_partition=8, + output_partition_sizes=output_partition_sizes, + input_size=8, + output_size=sum(output_partition_sizes), + params_dtype=torch.bfloat16, + weight_loader=lambda param, loaded_weight, *args, **kwargs: None, + ) + + @pytest.mark.parametrize( + "method_name", + ["NPUInt8LinearMethod", "NPUInt8OnlineLinearMethod"], + ) + def test_over_wide_layer_falls_back_to_unquantized(self, quant_config, method_name): + import vllm_omni.quantization.int8_config as int8_config + + method = getattr(int8_config, method_name)(quant_config) + layer = Module() + layer.quant_method = method + + self.create_weights(method, layer, [int8_config.NPU_QUANT_MATMUL_MAX_OUT_FEATURES + 1]) + + assert isinstance(layer.quant_method, UnquantizedLinearMethod) + assert layer.weight.dtype == torch.bfloat16 + + def test_layer_within_the_limit_keeps_int8(self, quant_config): + from vllm_omni.quantization.int8_config import ( + NPU_QUANT_MATMUL_MAX_OUT_FEATURES, + NPUInt8OnlineLinearMethod, + ) + + method = NPUInt8OnlineLinearMethod(quant_config) + layer = Module() + layer.quant_method = method + + self.create_weights(method, layer, [NPU_QUANT_MATMUL_MAX_OUT_FEATURES]) + + assert layer.quant_method is method + + def test_tensor_parallel_sharding_brings_a_wide_layer_back(self, quant_config): + from vllm_omni.quantization.int8_config import ( + NPU_QUANT_MATMUL_MAX_OUT_FEATURES, + NPUInt8OnlineLinearMethod, + ) + + global_out_features = 4 * NPU_QUANT_MATMUL_MAX_OUT_FEATURES + method = NPUInt8OnlineLinearMethod(quant_config) + layer = Module() + layer.quant_method = method + + self.create_weights(method, layer, [global_out_features // 4]) + + assert layer.quant_method is method + + +class TestOffloadAfterQuant: + def test_only_methods_advertising_the_capability_are_asked(self): + from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader + from vllm_omni.quantization.int8_config import DiffusionInt8Config, NPUInt8OnlineLinearMethod + + class MetaDeviceOnlyMethod: + """Stands in for upstream online FP8: lazy, but no offload hook.""" + + uses_meta_device = True + + model = torch.nn.Sequential(Module(), Module()) + lazy_int8 = NPUInt8OnlineLinearMethod( + DiffusionInt8Config(is_checkpoint_int8_serialized=False, activation_scheme="dynamic") + ) + model[0].quant_method = lazy_int8 + model[1].quant_method = MetaDeviceOnlyMethod() + + marked = DiffusersPipelineLoader._request_offload_after_quant(model) + + assert marked == 1 + assert lazy_int8._offload_after_quant + + class TestInt8LinearMethod: @pytest.fixture def mock_quant_config(self, mocker): diff --git a/tests/diffusion/quantization/test_quantization_quality.py b/tests/diffusion/quantization/test_quantization_quality.py index 5f0dc4d62..d3cee6b6a 100644 --- a/tests/diffusion/quantization/test_quantization_quality.py +++ b/tests/diffusion/quantization/test_quantization_quality.py @@ -82,6 +82,8 @@ class QualityTestConfig: gpu: str = "H100" # minimum GPU requirement negative_prompt: str = "" guidance_scale: float | None = None + sigmas: list[float] | None = None + enable_cpu_offload: bool = False def baseline_ref(self) -> str: return self.baseline_model or self.model or "" @@ -181,6 +183,18 @@ QUALITY_CONFIGS = [ max_lpips=0.20, num_inference_steps=10, ), + QualityTestConfig( + id="fp8_flux2_dev_text_encoder", + model="black-forest-labs/FLUX.2-dev", + quantization={"text_encoder": "fp8", "transformer": None, "vae": None}, + task="t2i", + prompt="a cup of coffee on a wooden table, morning light", + max_lpips=0.15, + num_inference_steps=10, + enable_cpu_offload=True, + height=1024, + width=1024, + ), QualityTestConfig( id="fp8_qwen_image", model="Qwen/Qwen-Image", @@ -202,6 +216,19 @@ QUALITY_CONFIGS = [ width=256, num_frames=25, num_inference_steps=8, + # Preserve the final scheduler trajectory used when this FP8 quality + # gate was established. Explicit LTX sigmas bypass dynamic shifting. + sigmas=[ + 1.0, + 0.92185378074646, + 0.8327768445014954, + 0.7303033471107483, + 0.6111654043197632, + 0.4709382653236389, + 0.30347853899002075, + 0.10000002384185791, + 0.0, + ], ), ] @@ -309,6 +336,7 @@ def _generate_video(omni, config: QualityTestConfig): num_inference_steps=config.num_inference_steps, num_frames=config.num_frames, guidance_scale=config.guidance_scale, + sigmas=config.sigmas, ), ) @@ -403,6 +431,8 @@ def _free_gpu_memory(): # --------------------------------------------------------------------------- +@pytest.mark.core_model +@pytest.mark.cpu def test_benchmark_generate_image_unwraps_nested_omni_request_output(monkeypatch): from benchmarks.diffusion.quantization_quality import _generate_image as benchmark_generate_image from vllm_omni.outputs import OmniRequestOutput @@ -433,6 +463,33 @@ def test_benchmark_generate_image_unwraps_nested_omni_request_output(monkeypatch assert peak_mem == 0.0 +def test_generate_video_forwards_sigmas(monkeypatch): + from vllm_omni.platforms import current_omni_platform + + monkeypatch.setattr(current_omni_platform, "device_type", "cpu", raising=False) + monkeypatch.setattr(torch.accelerator, "reset_peak_memory_stats", lambda: None, raising=False) + monkeypatch.setattr(torch.accelerator, "max_memory_allocated", lambda: 0, raising=False) + captured = SimpleNamespace(sampling=None) + + class DummyOmni: + def generate(self, _prompt, sampling): + captured.sampling = sampling + return [SimpleNamespace(images=[np.zeros((1, 2, 2, 3), dtype=np.float32)])] + + config = QualityTestConfig( + id="ltx-sigmas", + model="unused", + quantization="fp8", + task="t2v", + prompt="test", + max_lpips=0.1, + sigmas=[1.0, 0.5], + ) + _generate_video(DummyOmni(), config) + + assert captured.sampling.sigmas == [1.0, 0.5] + + _marks = hardware_marks(res={"cuda": "H100"}) _OUTPUT_DIR = Path(os.environ["VLLM_OMNI_QUALITY_OUTPUT_DIR"]) if "VLLM_OMNI_QUALITY_OUTPUT_DIR" in os.environ else None @@ -459,7 +516,10 @@ def test_quantization_quality(config: QualityTestConfig): generate_fn = _generate_video if config.task == "t2v" else _generate_image # --- BF16 baseline --- - omni_bl = Omni(model=config.baseline_ref()) + bl_kwargs: dict = {"model": config.baseline_ref()} + if config.enable_cpu_offload: + bl_kwargs["enable_cpu_offload"] = True + omni_bl = Omni(**bl_kwargs) baseline_out, bl_mem = generate_fn(omni_bl, config) omni_bl.shutdown() del omni_bl @@ -468,10 +528,13 @@ def test_quantization_quality(config: QualityTestConfig): # --- Quantized --- quantization = config.quantization_ref() + qt_kwargs: dict = {"model": config.quantized_ref()} + if config.enable_cpu_offload: + qt_kwargs["enable_cpu_offload"] = True if quantization is None: - omni_qt = Omni(model=config.quantized_ref()) + omni_qt = Omni(**qt_kwargs) else: - omni_qt = Omni(model=config.quantized_ref(), quantization_config=quantization) + omni_qt = Omni(**qt_kwargs, quantization_config=quantization) quant_out, qt_mem = generate_fn(omni_qt, config) omni_qt.shutdown() del omni_qt diff --git a/tests/diffusion/quantization/test_trajectory_similarity_tool.py b/tests/diffusion/quantization/test_trajectory_similarity_tool.py index 252e6f1c1..963ed5685 100644 --- a/tests/diffusion/quantization/test_trajectory_similarity_tool.py +++ b/tests/diffusion/quantization/test_trajectory_similarity_tool.py @@ -22,6 +22,8 @@ from vllm_omni.quantization.tools.compare_diffusion_trajectory_similarity import summarize_output_image_metrics, ) +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + def test_compute_tensor_metrics_identical_tensors(): metrics = compute_tensor_metrics(torch.ones(2, 3), torch.ones(2, 3)) diff --git a/tests/diffusion/test_async_output_worker.py b/tests/diffusion/test_async_output_worker.py new file mode 100644 index 000000000..8cda3be1c --- /dev/null +++ b/tests/diffusion/test_async_output_worker.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for WorkerProc async output logic.""" + +import queue +from unittest.mock import MagicMock + +import pytest + +from vllm_omni.diffusion.data import ( + AsyncDiffusionOutput, + AsyncOutputKind, + DiffusionOutput, + OmniACK, +) +from vllm_omni.diffusion.worker.diffusion_worker import WorkerProc +from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _make_worker_proc(step_execution=False): + """Create a minimal WorkerProc-like object with async output state.""" + proc = object.__new__(WorkerProc) + proc.od_config = MagicMock() + proc.od_config.step_execution = step_execution + proc.gpu_id = 0 + proc.result_mq = MagicMock() + proc._running = True + proc._async_output_queue = queue.Queue() if not step_execution else None + proc._async_output_thread = None + return proc + + +class TestGenerateAsyncOutputId: + """Test _generate_async_output_id uniqueness.""" + + def test_returns_hex_string(self): + id1 = WorkerProc._generate_async_output_id() + assert isinstance(id1, str) + assert len(id1) == 32 # uuid4 hex is 32 chars + + def test_ids_are_unique(self): + ids = {WorkerProc._generate_async_output_id() for _ in range(100)} + assert len(ids) == 100 + + +class TestReturnResultAsyncPath: + """Test _return_result in request-mode (step_execution=False).""" + + def test_omni_ack_bypasses_async_path(self): + """OmniACK should always use sync path regardless of step_execution.""" + proc = _make_worker_proc(step_execution=False) + ack = OmniACK(task_id="task1", status="done") + + proc._return_result(ack) + proc.result_mq.enqueue.assert_called_once_with(ack) + assert proc._async_output_queue.empty() # nothing enqueued for bg thread + + def test_diffusion_output_triggers_async_path(self, mocker): + """DiffusionOutput in request-mode should enqueue compute_done + bg work.""" + proc = _make_worker_proc(step_execution=False) + + mock_platform = mocker.MagicMock() + mock_platform.record_device_event.return_value = MagicMock() + mocker.patch( + "vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform", + mock_platform, + ) + + output = DiffusionOutput(output="data") + proc._return_result(output, rpc_id="1") + + # Should have enqueued COMPUTE_DONE to result_mq + enqueued_msg = proc.result_mq.enqueue.call_args[0][0] + assert isinstance(enqueued_msg, AsyncDiffusionOutput) + assert enqueued_msg.kind == AsyncOutputKind.COMPUTE_DONE + assert enqueued_msg.rpc_id == "1" + assert enqueued_msg.async_output_id is not None + + # Should have placed work item in async_output_queue + assert not proc._async_output_queue.empty() + item = proc._async_output_queue.get_nowait() + assert item[0] is output + assert item[1] == enqueued_msg.async_output_id + + def test_batch_runner_output_triggers_async_path(self, mocker): + """BatchRunnerOutput in request-mode should also go through async path.""" + proc = _make_worker_proc(step_execution=False) + + mock_platform = mocker.MagicMock() + mock_platform.record_device_event.return_value = MagicMock() + mocker.patch( + "vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform", + mock_platform, + ) + + batch = BatchRunnerOutput( + runner_outputs=[RunnerOutput(request_id="r1", finished=True)], + ) + proc._return_result(batch) + + enqueued_msg = proc.result_mq.enqueue.call_args[0][0] + assert isinstance(enqueued_msg, AsyncDiffusionOutput) + assert enqueued_msg.kind == AsyncOutputKind.COMPUTE_DONE + + def test_none_result_mq_skips_all_paths(self): + """If result_mq is None, _return_result should do nothing.""" + proc = _make_worker_proc(step_execution=False) + proc.result_mq = None + + proc._return_result(DiffusionOutput()) + # No crash, no enqueue attempted + + +class TestReturnResultSyncPath: + """Test _return_result in step-execution mode (sync).""" + + def test_diffusion_output_uses_sync_path_when_step_execution(self, mocker): + """In step_execution mode, DiffusionOutput goes through sync SHM pack.""" + proc = _make_worker_proc(step_execution=True) + proc._async_output_queue = None # step mode doesn't have async queue + + mock_pack = mocker.patch( + "vllm_omni.diffusion.worker.diffusion_worker.pack_diffusion_output_shm", + ) + output = DiffusionOutput(output="data") + proc._return_result(output) + + mock_pack.assert_called_once_with(output) + proc.result_mq.enqueue.assert_called_once_with(output) + + def test_non_diffusion_output_skips_async_in_request_mode(self, mocker): + """Non-DiffusionOutput/BatchRunnerOutput in request mode uses sync path.""" + proc = _make_worker_proc(step_execution=False) + + mock_pack = mocker.patch( + "vllm_omni.diffusion.worker.diffusion_worker.pack_diffusion_output_shm", + ) + # A plain dict is neither DiffusionOutput nor BatchRunnerOutput + output = {"type": "other"} + proc._return_result(output) + + mock_pack.assert_called_once_with(output) + proc.result_mq.enqueue.assert_called_once_with(output) + + +class TestAsyncOutputLoopLogic: + """Test _async_output_loop background thread logic (mocked, no device).""" + + def test_bg_thread_enqueues_output_ready_after_pack(self, mocker): + """The real _async_output_loop packs via SHM with d2h_stream and + enqueues an OUTPUT_READY message.""" + import threading + import time + + proc = _make_worker_proc(step_execution=False) + + mock_pack = mocker.patch( + "vllm_omni.diffusion.worker.diffusion_worker.pack_diffusion_output_shm", + ) + # Mock torch to avoid device initialization + mock_torch = mocker.MagicMock() + mock_stream = mocker.MagicMock() + mock_torch.Stream.return_value = mock_stream + mock_torch.accelerator.current_accelerator.return_value.type = "cpu" + mock_torch.device.return_value = mocker.MagicMock() + mocker.patch( + "vllm_omni.diffusion.worker.diffusion_worker.torch", + mock_torch, + ) + + output = DiffusionOutput(output="data") + gpu_event = mocker.MagicMock() + proc._async_output_queue.put((output, "abc123", gpu_event)) + + # Run the real _async_output_loop in a background thread. + t = threading.Thread(target=proc._async_output_loop, daemon=True) + t.start() + + # Give the loop time to process, then unblock and stop. + time.sleep(0.5) + proc._running = False + proc._async_output_queue.put((DiffusionOutput(), "sentinel", None)) + t.join(timeout=2.0) + + mock_pack.assert_called() + # Verify an OUTPUT_READY with the correct async_output_id was enqueued. + output_ready_enqueued = any( + c[0][0].kind == AsyncOutputKind.OUTPUT_READY and c[0][0].async_output_id == "abc123" + for c in proc.result_mq.enqueue.call_args_list + ) + assert output_ready_enqueued, "Expected OUTPUT_READY with async_output_id='abc123' in enqueue calls" diff --git a/tests/diffusion/test_compile.py b/tests/diffusion/test_compile.py index ca898da95..447d8c75f 100644 --- a/tests/diffusion/test_compile.py +++ b/tests/diffusion/test_compile.py @@ -54,3 +54,23 @@ def test_regionally_compile_matches_wrapped_blocks_by_declared_container_attr(mo assert all(not block.compile_called for block in model.transformer_blocks) assert not model.other_blocks[0].compile_called assert model.transformer_blocks[0].forward("ok") == "compiled:ok" + + +def test_regionally_compile_does_not_partially_mutate_on_setup_failure(monkeypatch): + model = _ModelWithWrappedRepeatedBlocks() + original_forwards = [block.forward.__func__ for block in model.transformer_blocks] + compile_calls = 0 + + def _compile(fn, *args, **kwargs): + nonlocal compile_calls + compile_calls += 1 + if compile_calls == 2: + raise RuntimeError("compile setup failed") + return lambda *fn_args, **fn_kwargs: fn(*fn_args, **fn_kwargs) + + monkeypatch.setattr(compile_module.torch, "compile", _compile) + + with pytest.raises(RuntimeError, match="compile setup failed"): + regionally_compile(model, dynamic=True) + + assert [block.forward.__func__ for block in model.transformer_blocks] == original_forwards diff --git a/tests/test_diffusion_config_fields.py b/tests/diffusion/test_diffusion_config_fields.py similarity index 97% rename from tests/test_diffusion_config_fields.py rename to tests/diffusion/test_diffusion_config_fields.py index b87ceec1d..d62e7bdc8 100644 --- a/tests/test_diffusion_config_fields.py +++ b/tests/diffusion/test_diffusion_config_fields.py @@ -29,7 +29,7 @@ def test_diffusion_stage_configs_only_contain_valid_fields(): Regression test for https://github.com/vllm-project/vllm-omni/issues/2563 """ # Scan both main configs and test configs - repo_root = Path(__file__).parent.parent + repo_root = Path(__file__).parent.parent.parent config_dirs = [ repo_root / "vllm_omni" / "model_executor" / "stage_configs", ] diff --git a/tests/test_diffusion_config_propagation.py b/tests/diffusion/test_diffusion_config_propagation.py similarity index 97% rename from tests/test_diffusion_config_propagation.py rename to tests/diffusion/test_diffusion_config_propagation.py index 10fe00168..205b2e491 100644 --- a/tests/test_diffusion_config_propagation.py +++ b/tests/diffusion/test_diffusion_config_propagation.py @@ -131,6 +131,11 @@ def test_qwen_image_edit_plus_sets_generic_multimodal_limit(): assert od_config.max_multimodal_image_inputs == QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES +def test_task_type_roundtrip(): + od = _roundtrip_diffusion_config(model="x", task_type="model-defined-task") + assert od.task_type == "model-defined-task" + + def test_additional_config_roundtrip(): additional_config = {"torchair_graph_config": {"enabled": True}} od = _roundtrip_diffusion_config(model="x", additional_config=additional_config) diff --git a/tests/diffusion/test_diffusion_engine.py b/tests/diffusion/test_diffusion_engine.py index 5f9a5a5c0..e26211e9c 100644 --- a/tests/diffusion/test_diffusion_engine.py +++ b/tests/diffusion/test_diffusion_engine.py @@ -14,8 +14,13 @@ import torch from pytest_mock import MockerFixture import vllm_omni.diffusion.diffusion_engine as diffusion_engine_module +from tests.helpers.mark import hardware_test from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig -from vllm_omni.diffusion.diffusion_engine import DiffusionEngine, _move_tensor_tree_to_cpu +from vllm_omni.diffusion.diffusion_engine import ( + DiffusionEngine, + DiffusionExecutionMode, + _move_tensor_tree_to_cpu, +) from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.sched.interface import ( CachedRequestData, @@ -26,6 +31,8 @@ from vllm_omni.diffusion.sched.interface import ( ) from vllm_omni.inputs.data import OmniDiffusionSamplingParams +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion] + @dataclass class DiffusionSchedulerOutput: @@ -52,6 +59,8 @@ class MockScheduler: def __init__(self): self._waiting_queue = [] self._step_id = 0 + # Match RequestScheduler API used by request-batch admission wait. + self.max_num_running_reqs = 5 def add_request(self, request): self._waiting_queue.append(request) @@ -60,6 +69,12 @@ class MockScheduler: def has_requests(self): return len(self._waiting_queue) > 0 + def num_waiting_requests(self) -> int: + return len(self._waiting_queue) + + def num_running_requests(self) -> int: + return 0 + def schedule(self) -> DiffusionSchedulerOutput: if not self._waiting_queue: return DiffusionSchedulerOutput(step_id=self._step_id) @@ -77,6 +92,21 @@ class MockScheduler: # assume all new req finished return [req.request_id for req in sched_output.scheduled_new_reqs] + def get_request_state(self, request_id): + return None + + def pop_request_state(self, request_id): + return None + + def finish_requests(self, request_id, status): + pass + + def close(self): + pass + + def initialize(self, od_config): + pass + class _BatchCapablePipeline: supports_request_batch = True @@ -221,7 +251,7 @@ class TestRequestBatchCapability: diffusion_engine_module.supports_request_batch(od_config) registry_load.assert_not_called() - def test_engine_disables_batch_dispatch_for_single_request_pipeline( + def test_engine_uses_request_batch_mode_for_single_request_pipeline( self, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, @@ -230,6 +260,7 @@ class TestRequestBatchCapability: model_class_name="SinglePipeline", custom_pipeline_args=None, streaming_output=False, + max_num_seqs=1, ) fake_executor = SimpleNamespace( execute_request=mocker.Mock(return_value="per-request"), @@ -255,15 +286,50 @@ class TestRequestBatchCapability: "_try_load_model_cls", lambda model_class_name: _SingleRequestPipeline, ) - monkeypatch.setattr(DiffusionEngine, "_dummy_run", lambda self: None) engine = DiffusionEngine(od_config) - output = engine.execute_fn(_make_request_mode_sched_output("req-a", "req-b")) + output = engine.execute_fn(_make_request_mode_sched_output("req-a")) + assert engine.execution_mode == DiffusionExecutionMode.REQUEST_BATCH assert engine.supports_request_batch is False - assert output == "per-request" - fake_executor.execute_request.assert_called_once() - fake_executor.execute_batch.assert_not_called() + assert output == "batch" + fake_executor.execute_batch.assert_called_once() + fake_executor.execute_request.assert_not_called() + + def test_engine_rejects_multi_seq_request_batch_for_single_request_pipeline( + self, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + ) -> None: + od_config = SimpleNamespace( + model_class_name="SinglePipeline", + custom_pipeline_args=None, + streaming_output=False, + max_num_seqs=2, + ) + fake_executor_cls = mocker.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: fake_executor_cls, + ) + monkeypatch.setattr( + diffusion_engine_module.DiffusionModelRegistry, + "_try_load_model_cls", + lambda model_class_name: _SingleRequestPipeline, + ) + + with pytest.raises(ValueError, match="max_num_seqs=1"): + DiffusionEngine(od_config) + fake_executor_cls.assert_not_called() @pytest.mark.parametrize("request_ids", [("req-a",), ("req-a", "req-b")]) def test_engine_enables_batch_dispatch_for_request_batch_pipeline( @@ -276,6 +342,7 @@ class TestRequestBatchCapability: model_class_name="BatchPipeline", custom_pipeline_args=None, streaming_output=False, + max_num_seqs=2, ) fake_executor = SimpleNamespace( execute_request=mocker.Mock(return_value="per-request"), @@ -301,16 +368,106 @@ class TestRequestBatchCapability: "_try_load_model_cls", lambda model_class_name: _BatchCapablePipeline, ) - monkeypatch.setattr(DiffusionEngine, "_dummy_run", lambda self: None) engine = DiffusionEngine(od_config) output = engine.execute_fn(_make_request_mode_sched_output(*request_ids)) + assert engine.execution_mode == DiffusionExecutionMode.REQUEST_BATCH assert engine.supports_request_batch is True assert output == "batch" fake_executor.execute_batch.assert_called_once() fake_executor.execute_request.assert_not_called() + def test_make_engine_runs_startup_warmup(self, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture) -> None: + od_config = SimpleNamespace( + model_class_name="SinglePipeline", + custom_pipeline_args=None, + engine_backend="default", + streaming_output=False, + ) + fake_executor = SimpleNamespace( + execute_request=mocker.Mock(), + execute_batch=mocker.Mock(), + execute_step=mocker.Mock(), + ) + fake_executor_cls = mocker.Mock(return_value=fake_executor) + warmup = mocker.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: fake_executor_cls, + ) + monkeypatch.setattr( + diffusion_engine_module.DiffusionModelRegistry, + "_try_load_model_cls", + lambda model_class_name: _SingleRequestPipeline, + ) + monkeypatch.setattr(DiffusionEngine, "run_startup_warmup", warmup) + + engine = DiffusionEngine.make_engine(od_config) + + assert isinstance(engine, DiffusionEngine) + warmup.assert_called_once_with() + + +class TestDiffusionCompileConfig: + pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + def test_config_defaults_to_dynamic_regional_compile(self) -> None: + config = OmniDiffusionConfig(model="test") + + assert config.diffusion_compile_granularity == "regional" + assert config.diffusion_compile_dynamic is True + + def test_from_kwargs_preserves_compile_controls(self) -> None: + config = OmniDiffusionConfig.from_kwargs( + model="test", + diffusion_compile_granularity="full", + diffusion_compile_dynamic=False, + ) + + assert config.diffusion_compile_granularity == "full" + assert config.diffusion_compile_dynamic is False + + def test_config_rejects_invalid_compile_granularity(self) -> None: + with pytest.raises(ValueError, match="diffusion_compile_granularity"): + OmniDiffusionConfig(model="test", diffusion_compile_granularity="block") + + def test_config_rejects_non_boolean_compile_dynamic(self) -> None: + with pytest.raises(TypeError, match="diffusion_compile_dynamic"): + OmniDiffusionConfig(model="test", diffusion_compile_dynamic="false") + + @pytest.mark.parametrize( + "kwargs, feature", + [ + ({"parallel_config": {"ulysses_degree": 2}, "num_gpus": 2}, "sequence parallelism"), + ( + { + "parallel_config": {"use_hsdp": True, "hsdp_shard_size": 2}, + "num_gpus": 2, + }, + "HSDP", + ), + ({"enable_cpu_offload": True}, "CPU offload"), + ({"enable_layerwise_offload": True}, "layerwise offload"), + ], + ) + def test_full_compile_rejects_incompatible_features(self, kwargs, feature) -> None: + with pytest.raises(ValueError, match=feature): + OmniDiffusionConfig( + model="test", + diffusion_compile_granularity="full", + **kwargs, + ) + class TestRequestBatchAdmission: pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] @@ -451,9 +608,7 @@ def test_move_tensor_tree_returns_non_tensor_values_unchanged() -> None: assert moved is value -@pytest.mark.diffusion -@pytest.mark.cuda -@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") +@hardware_test(res={"cuda": "L4"}, num_cards=1) def test_move_tensor_tree_moves_nested_cuda_tensors_to_cpu() -> None: tensor = torch.arange(8, dtype=torch.float32, device="cuda") other = torch.arange(4, dtype=torch.int64, device="cuda") @@ -470,23 +625,45 @@ def test_move_tensor_tree_moves_nested_cuda_tensors_to_cpu() -> None: assert moved["items"][1][0] == "keep" +async def _consume_final_output(generator): + final_output = None + async for output in generator: + final_output = output + if final_output is None: + raise RuntimeError("Diffusion execution finished without output.") + return final_output + + +@pytest.mark.cpu @pytest.mark.asyncio -async def test_async_add_req_and_wait_for_response(): +async def test_async_add_req_and_stream_response(): engine = object.__new__(DiffusionEngine) engine.scheduler = MockScheduler() - engine._out_queue = {} + engine._out_streams = {} engine.abort_queue: queue.Queue[str] = queue.Queue() engine._rpc_queue = queue.Queue() engine._rpc_lock = threading.RLock() engine._cv = threading.Condition(engine._rpc_lock) engine._init_lock = asyncio.Lock() engine._closed = False - engine.od_config = SimpleNamespace(streaming_output=False) + # Enable admission wait so concurrent adds land in one schedule wave; + # otherwise the first request can execute alone (~1s) and the rest in a + # second wave (~2s), failing the latency-spread assertion. + engine.od_config = SimpleNamespace( + streaming_output=False, + request_batch_max_wait_ms=500.0, + ) engine._loop_started = False engine.main_loop = None - engine.supports_request_batch = False + engine.supports_request_batch = True + engine.step_execution = False + engine.execution_mode = DiffusionExecutionMode.REQUEST_BATCH - engine._finalize_finished_request = lambda rid, out, err: out.result + def _finalize(rid, out, err=None, **kwargs): + # Stream consumers stop on ``finished``; keep result_data for assertions. + return SimpleNamespace(result_data=out.result.result_data, finished=True) + + engine._finalize_finished_request = _finalize def mock_execute_batch(sched_output): request_ids = sched_output.scheduled_request_ids @@ -512,7 +689,7 @@ async def test_async_add_req_and_wait_for_response(): async def run_task(rid): req = SimpleNamespace(request_id=rid) start = time.time() - res = await engine.async_add_req_and_wait_for_response(req) + res = await _consume_final_output(engine.async_add_req_and_stream_response(req)) return rid, res, time.time() - start task_ids = [f"req_{i}" for i in range(5)] diff --git a/tests/diffusion/test_diffusion_engine_cleanup.py b/tests/diffusion/test_diffusion_engine_cleanup.py index 3cc3d48f7..b7a9a345f 100644 --- a/tests/diffusion/test_diffusion_engine_cleanup.py +++ b/tests/diffusion/test_diffusion_engine_cleanup.py @@ -5,15 +5,17 @@ import asyncio import queue import threading from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest +from vllm_omni.diffusion import diffusion_engine as diffusion_engine_module from vllm_omni.diffusion.data import DiffusionOutput -from vllm_omni.diffusion.diffusion_engine import DiffusionEngine +from vllm_omni.diffusion.diffusion_engine import DiffusionEngine, DiffusionExecutionMode from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.sched import DiffusionRequestStatus, RequestScheduler from vllm_omni.inputs.data import OmniDiffusionSamplingParams +from vllm_omni.outputs import OmniRequestOutput pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] @@ -33,8 +35,7 @@ def _make_engine() -> DiffusionEngine: engine.executor = SimpleNamespace(shutdown=Mock()) engine._rpc_lock = threading.RLock() engine._cv = threading.Condition(engine._rpc_lock) - engine._out_queue = {} - engine._out_queue_streaming = {} + engine._out_streams = {} engine._closed = False engine._shutdown_complete = False engine.abort_queue = queue.Queue() @@ -44,28 +45,13 @@ def _make_engine() -> DiffusionEngine: return engine -def test_close_completes_pending_async_waiters() -> None: - engine = _make_engine() - event_loop = asyncio.new_event_loop() - try: - future = event_loop.create_future() - engine._out_queue["pending-req"] = future - - engine.close() - - assert future.done() - assert future.result().error == "DiffusionEngine is closed." - finally: - event_loop.close() - - -def test_close_completes_pending_streaming_waiters() -> None: +def test_close_completes_pending_output_streams() -> None: engine = _make_engine() event_loop = asyncio.new_event_loop() try: engine.main_loop = event_loop queue: asyncio.Queue[DiffusionOutput] = asyncio.Queue() - engine._out_queue_streaming["pending-stream"] = queue + engine._out_streams["pending-stream"] = queue engine.close() @@ -76,21 +62,101 @@ def test_close_completes_pending_streaming_waiters() -> None: event_loop.close() -def test_handle_finished_requests_ignores_already_drained_waiter() -> None: +def test_emit_finished_outputs_finalizes_already_drained_waiter() -> None: class RacingOutQueue(dict): - def __contains__(self, key): - return True - - def pop(self, key, default=None): + def get(self, key, default=None): return default engine = _make_engine() - engine._out_queue = RacingOutQueue() - engine._finalize_finished_request = Mock(side_effect=AssertionError("should not finalize drained waiters")) + request_id = engine.scheduler.add_request(_make_request("pending-req")) + engine.scheduler.finish_requests(request_id, DiffusionRequestStatus.FINISHED_ABORTED) + engine._out_streams = RacingOutQueue() - engine._handle_finished_requests({"pending-req"}) + engine._emit_finished_outputs({request_id}) - engine._finalize_finished_request.assert_not_called() + assert engine.scheduler.get_request_state(request_id) is None + + +def test_emit_step_outputs_finalizes_finished_request_without_stream() -> None: + engine = _make_engine() + engine.execution_mode = DiffusionExecutionMode.STEP_BATCH + request_id = engine.scheduler.add_request(_make_request("step-drained")) + engine.scheduler.finish_requests(request_id, DiffusionRequestStatus.FINISHED_ABORTED) + + engine._emit_outputs({request_id}, [request_id], SimpleNamespace(get_request_output=lambda _request_id: None)) + + assert engine.scheduler.get_request_state(request_id) is None + + +def test_init_accepts_custom_scheduler(monkeypatch: pytest.MonkeyPatch) -> None: + od_config = SimpleNamespace( + custom_pipeline_args=None, + model_class_name="CustomSchedulerPipeline", + streaming_output=False, + ) + custom_scheduler = RequestScheduler() + fake_executor = SimpleNamespace( + execute_request=Mock(), + execute_batch=Mock(), + execute_step=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, + ) + + engine = DiffusionEngine(od_config, scheduler=custom_scheduler) + + assert engine.scheduler is custom_scheduler + + +@pytest.mark.asyncio +async def test_step_compatibility_wrapper_returns_final_batch() -> None: + engine = _make_engine() + first = [OmniRequestOutput.from_diffusion(request_id="req", images=[], finished=False)] + final = [OmniRequestOutput.from_diffusion(request_id="req", images=[], finished=True)] + + async def _step_streaming(_request): + yield first + yield final + + engine.step_streaming = _step_streaming # type: ignore[method-assign] + with patch.object(diffusion_engine_module.logger, "warning_once") as warning_once: + output = await engine.step(_make_request("req")) + + assert output is final + warning_once.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_wait_compatibility_wrapper_returns_final_output() -> None: + engine = _make_engine() + first = DiffusionOutput(output="chunk", finished=False) + final = DiffusionOutput(output="final", finished=True) + + async def _stream_response(_request): + yield first + yield final + + engine.async_add_req_and_stream_response = _stream_response # type: ignore[method-assign] + with patch.object(diffusion_engine_module.logger, "warning_once") as warning_once: + output = await engine.async_add_req_and_wait_for_response(_make_request("req")) + + assert output is final + warning_once.assert_called_once() def test_abort_request_id_aborts_scheduler_request() -> None: diff --git a/tests/diffusion/test_diffusion_engine_metrics.py b/tests/diffusion/test_diffusion_engine_metrics.py index 2e8eae242..8f434c0e2 100644 --- a/tests/diffusion/test_diffusion_engine_metrics.py +++ b/tests/diffusion/test_diffusion_engine_metrics.py @@ -12,6 +12,11 @@ from __future__ import annotations import ast import os +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + _ENGINE_PATH = os.path.normpath( os.path.join( os.path.dirname(__file__), @@ -80,27 +85,23 @@ class TestMetricKeys: "format_diffusion_outputs() — should only use 'preprocess_time_ms'" ) - def test_metric_key_naming_consistency(self) -> None: - """Metric keys should map to the explicit timing fields.""" + def test_timing_metric_key_naming_consistency(self) -> None: + """Timing metrics should be attached by the engine orchestration path.""" source = _read_source(_FORMATTER_PATH) formatter_source = _get_function_source(source, None, "format_diffusion_outputs") + engine_source = _read_source(_ENGINE_PATH) + step_streaming_source = _get_function_source(engine_source, "DiffusionEngine", "step_streaming") lines = formatter_source.split("\n") - found_exec = False - found_total = False for line in lines: if '"diffusion_engine_exec_time_ms"' in line: - found_exec = True - assert "timings.exec_time_s" in line, ( - "diffusion_engine_exec_time_ms should measure executor time only (timings.exec_time_s)" - ) + raise AssertionError("diffusion_engine_exec_time_ms should be attached in step_streaming()") if '"diffusion_engine_total_time_ms"' in line: - found_total = True - assert "timings.total_time_ms" in line, ( - "diffusion_engine_total_time_ms should measure full step time (timings.total_time_ms)" - ) - assert found_exec, "diffusion_engine_exec_time_ms key not found in format_diffusion_outputs()" - assert found_total, "diffusion_engine_total_time_ms key not found in format_diffusion_outputs()" + raise AssertionError("diffusion_engine_total_time_ms should be attached in step_streaming()") + + assert '"diffusion_engine_exec_time_ms": exec_total_time * 1000' in step_streaming_source + assert '"diffusion_engine_total_time_ms": step_total_ms' in step_streaming_source + assert '"postprocess_time_ms": postprocess_time * 1000' in step_streaming_source class TestDummyRunAllocation: diff --git a/tests/diffusion/test_diffusion_engine_rpc_routing.py b/tests/diffusion/test_diffusion_engine_rpc_routing.py index 4b7d0efbb..dff5ac7bc 100644 --- a/tests/diffusion/test_diffusion_engine_rpc_routing.py +++ b/tests/diffusion/test_diffusion_engine_rpc_routing.py @@ -30,16 +30,16 @@ from typing import Any import pytest from vllm_omni.diffusion.data import DiffusionOutput -from vllm_omni.diffusion.diffusion_engine import DiffusionEngine, _RpcTask +from vllm_omni.diffusion.diffusion_engine import DiffusionEngine, DiffusionExecutionMode, _RpcTask from vllm_omni.diffusion.sched import RequestScheduler from vllm_omni.diffusion.sched.interface import RequestBatchSamplingParamsKey from vllm_omni.diffusion.worker.utils import RunnerOutput # Default values for every batch-key field, so SimpleNamespace-based -# sampling_params satisfy ``get_sampling_params_key``'s attribute lookups. +# sampling_params satisfy ``RequestScheduler._build_sampling_params_key``'s attribute lookups. _SAMPLING_KEY_DEFAULTS = {f.name: f.default for f in _dc_fields(RequestBatchSamplingParamsKey)} -pytestmark = [pytest.mark.diffusion, pytest.mark.cpu] +pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model] # ───────────────────────────────────────── helpers ───────────────────────── @@ -91,7 +91,7 @@ class _ConcurrencyTrackingExecutor: # args[0] is the request-like object for execute_model. req = args[0] if args else None tag = req.request_id if req is not None and hasattr(req, "request_id") else "unknown" - return DiffusionOutput(error=f"result_for_{tag}") + return DiffusionOutput(error=f"result_for_{tag}", finished=True) tag = args[0] if args else method return DiffusionOutput(error=f"rpc_result_for_{tag}") finally: @@ -149,12 +149,12 @@ def _make_engine_with_loop( engine.scheduler = sched engine.step_execution = False engine.supports_request_batch = False + engine.execution_mode = DiffusionExecutionMode.REQUEST_BATCH engine.execute_fn = engine.executor.execute_request engine._rpc_lock = threading.RLock() engine._cv = threading.Condition(engine._rpc_lock) - engine._out_queue = {} - engine._out_queue_streaming = {} + engine._out_streams = {} engine._closed = False engine.abort_queue = queue.Queue() engine._rpc_queue = queue.Queue() @@ -187,6 +187,15 @@ def _stop_engine(engine: DiffusionEngine) -> None: assert not engine.worker_thread.is_alive(), "Busy loop thread did not stop" +async def _consume_final_output(generator): + final_output = None + async for output in generator: + final_output = output + if final_output is None: + raise RuntimeError("Diffusion execution finished without output.") + return final_output + + # ─────────────────────── single-thread invariant ─────────────────────────── @@ -199,7 +208,7 @@ async def test_executor_only_called_from_busy_loop_thread(): busy_tid = engine.worker_thread.ident try: # Per-request path - await engine.async_add_req_and_wait_for_response(_make_request("req1")) + await _consume_final_output(engine.async_add_req_and_stream_response(_make_request("req1"))) # Raw RPC path (sync from a worker thread) result = await asyncio.to_thread(engine.collective_rpc, "ping", args=("a",), unique_reply_rank=0) assert result.error == "rpc_result_for_a" @@ -228,7 +237,7 @@ async def test_executor_calls_never_overlap_under_load(): return await engine.async_collective_rpc("ping", args=(f"x{i}",), unique_reply_rank=0) async def _request(i: int): - return await engine.async_add_req_and_wait_for_response(_make_request(f"r{i}")) + return await _consume_final_output(engine.async_add_req_and_stream_response(_make_request(f"r{i}"))) tasks = [_rpc(i) for i in range(20)] + [_request(i) for i in range(5)] results = await asyncio.gather(*tasks) @@ -510,7 +519,9 @@ async def test_rpc_and_request_results_do_not_swap(): engine = _make_engine_with_loop(loop, rpc_delay=0.02) try: rpc_task = asyncio.create_task(engine.async_collective_rpc("ping", args=("rpc1",), unique_reply_rank=0)) - req_task = asyncio.create_task(engine.async_add_req_and_wait_for_response(_make_request("req1"))) + req_task = asyncio.create_task( + _consume_final_output(engine.async_add_req_and_stream_response(_make_request("req1"))) + ) rpc_res, req_res = await asyncio.gather(rpc_task, req_task) finally: _stop_engine(engine) diff --git a/tests/diffusion/test_diffusion_model_runner.py b/tests/diffusion/test_diffusion_model_runner.py index 5d4ef5d72..a7f230d93 100644 --- a/tests/diffusion/test_diffusion_model_runner.py +++ b/tests/diffusion/test_diffusion_model_runner.py @@ -98,7 +98,39 @@ class _ChunkStepPipeline: return output -def _make_request(skip_cache_refresh: bool = True): +class _FinalOnlyStepPipeline: + device = torch.device("cpu") + supports_step_execution = True + + def prepare_encode(self, state): + state.prompt_embeds = torch.zeros(1, 1, 1) + state.latents = torch.zeros(1, 1) + state.timesteps = torch.tensor([1.0]) + state.step_index = 0 + return state + + def denoise_step(self, input_batch, states): + del states + return torch.ones_like(input_batch.latents) + + def step_scheduler(self, state, noise_pred): + state.latents = noise_pred + state.step_index += 1 + + def post_decode(self, state): + return DiffusionOutput(output=state.latents.clone()) + + +class _CompileTrackingModel: + def __init__(self): + self.compile_calls = [] + + def compile(self, *args, **kwargs): + self.compile_calls.append((args, kwargs)) + return self + + +def _make_request(): sampling_params = SimpleNamespace( generator=None, seed=None, @@ -109,7 +141,6 @@ def _make_request(skip_cache_refresh: bool = True): request_id="req-test", prompt="a prompt", sampling_params=sampling_params, - skip_cache_refresh=skip_cache_refresh, kv_sender_info=None, ) @@ -120,7 +151,6 @@ def _make_request_with_params(req_id: str, sampling_params): prompt=f"prompt-{req_id}", prompts=[f"prompt-{req_id}"], sampling_params=sampling_params, - skip_cache_refresh=True, ) @@ -156,10 +186,20 @@ def _make_runner(cache_backend, cache_backend_name: str, enable_cache_dit_summar return runner -def _make_compile_runner(*, use_hsdp: bool): +def _make_compile_runner( + model=None, + *, + compile_granularity: str = "regional", + compile_dynamic: bool = True, + use_hsdp: bool = False, +): runner = object.__new__(DiffusionModelRunner) - runner.pipeline = SimpleNamespace(transformer=SimpleNamespace()) - runner.od_config = SimpleNamespace(parallel_config=SimpleNamespace(use_hsdp=use_hsdp)) + runner.pipeline = SimpleNamespace(transformer=model or SimpleNamespace()) + runner.od_config = SimpleNamespace( + diffusion_compile_granularity=compile_granularity, + diffusion_compile_dynamic=compile_dynamic, + parallel_config=SimpleNamespace(use_hsdp=use_hsdp), + ) return runner @@ -187,6 +227,65 @@ def test_compile_transformer_regionally_compiles_blocks(monkeypatch, use_hsdp): ] +@pytest.mark.core_model +@pytest.mark.cpu +def test_compile_transformer_uses_regional_dynamic_false_config(monkeypatch): + model = _CompileTrackingModel() + runner = _make_compile_runner(model, compile_dynamic=False) + compiled_model = object() + regional_calls = [] + + def _regionally_compile(target, **kwargs): + regional_calls.append((target, kwargs)) + return compiled_model + + monkeypatch.setattr(model_runner_module, "regionally_compile", _regionally_compile) + + DiffusionModelRunner._compile_transformer(runner, "transformer") + + assert regional_calls == [(model, {"dynamic": False})] + assert model.compile_calls == [] + assert runner.pipeline.transformer is compiled_model + + +@pytest.mark.core_model +@pytest.mark.cpu +def test_compile_transformer_uses_full_granularity(monkeypatch): + model = _CompileTrackingModel() + runner = _make_compile_runner(model, compile_granularity="full", compile_dynamic=False) + regional_calls = [] + + monkeypatch.setattr( + model_runner_module, + "regionally_compile", + lambda *args, **kwargs: regional_calls.append((args, kwargs)), + ) + + DiffusionModelRunner._compile_transformer(runner, "transformer") + + assert model.compile_calls == [((), {"dynamic": False})] + assert regional_calls == [] + assert runner.pipeline.transformer is model + + +@pytest.mark.core_model +@pytest.mark.cpu +def test_compile_transformer_falls_back_after_synchronous_setup_failure(monkeypatch, caplog): + model = _CompileTrackingModel() + runner = _make_compile_runner(model) + + def _regionally_compile(*args, **kwargs): + raise RuntimeError("compile setup failed") + + monkeypatch.setattr(model_runner_module, "regionally_compile", _regionally_compile) + + DiffusionModelRunner._compile_transformer(runner, "transformer") + + assert runner.pipeline.transformer is model + assert "failed before activation" in caplog.text + assert "lazy compilation errors" in caplog.text + + @pytest.mark.core_model @pytest.mark.cpu def test_execute_stepwise_streaming_returns_chunks_at_boundaries(monkeypatch): @@ -199,7 +298,7 @@ def test_execute_stepwise_streaming_returns_chunks_at_boundaries(monkeypatch): runner.pipeline = _ChunkStepPipeline(chunks) runner.od_config.streaming_output = True runner.od_config.step_execution = True - req = _make_request(skip_cache_refresh=True) + req = _make_request() req.request_id = "req" monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context) @@ -230,12 +329,41 @@ def test_execute_stepwise_streaming_returns_chunks_at_boundaries(monkeypatch): assert fourth.get_request_output("req").finished is True +@pytest.mark.core_model +@pytest.mark.cpu +def test_execute_stepwise_streaming_decodes_final_only_pipeline(monkeypatch): + """Step streaming still returns a final output when no chunk boundaries exist.""" + runner = _make_runner(cache_backend=None, cache_backend_name=None) + runner.pipeline = _FinalOnlyStepPipeline() + runner.od_config.streaming_output = True + runner.od_config.step_execution = True + req = _make_request() + req.request_id = "req" + req.sampling_params.num_inference_steps = 1 + + monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context) + monkeypatch.setattr(model_runner_module.current_omni_platform, "reset_peak_memory_stats", lambda: None) + monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_reserved", lambda: 0) + 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_cached_reqs=SimpleNamespace(request_ids=[]), + ) + + output = DiffusionModelRunner.execute_stepwise(runner, scheduler_output).get_request_output("req") + + assert output.finished is True + assert output.result is not None + assert torch.equal(output.result.output, torch.ones(1, 1)) + + @pytest.mark.core_model @hardware_test(res={"cuda": "L4"}, num_cards=1) def test_execute_model_skips_cache_summary_without_active_cache_backend(monkeypatch): """Guard cache diagnostics with runtime backend state to avoid stale-config crashes.""" runner = _make_runner(cache_backend=None, cache_backend_name="cache_dit") - req = _make_request(skip_cache_refresh=True) + req = _make_request() cache_summary_calls = [] @@ -259,11 +387,18 @@ def test_execute_model_skips_cache_summary_without_active_cache_backend(monkeypa @hardware_test(res={"cuda": "L4"}, num_cards=1) def test_execute_model_emits_cache_summary_with_active_cache_dit_backend(monkeypatch): class _EnabledCacheBackend: + def __init__(self): + self.refresh_calls = [] + def is_enabled(self): return True - runner = _make_runner(cache_backend=_EnabledCacheBackend(), cache_backend_name="cache_dit") - req = _make_request(skip_cache_refresh=True) + def refresh(self, pipeline, num_inference_steps, verbose=True): + self.refresh_calls.append((pipeline, num_inference_steps, verbose)) + + cache_backend = _EnabledCacheBackend() + runner = _make_runner(cache_backend=cache_backend, cache_backend_name="cache_dit") + req = _make_request() cache_summary_calls = [] @@ -281,6 +416,7 @@ def test_execute_model_emits_cache_summary_with_active_cache_dit_backend(monkeyp assert output.output == "ok" assert cache_summary_calls == [(runner.pipeline, True)] + assert cache_backend.refresh_calls == [(runner.pipeline, 4, True)] @pytest.mark.core_model @@ -288,7 +424,7 @@ def test_execute_model_emits_cache_summary_with_active_cache_dit_backend(monkeyp def test_execute_model_passes_single_request_batch_to_non_admission_pipeline(monkeypatch): runner = _make_runner(cache_backend=None, cache_backend_name="none") runner.pipeline = _SingleRequestBatchPipeline() - req = _make_request(skip_cache_refresh=True) + req = _make_request() monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context) @@ -304,7 +440,7 @@ def test_execute_model_passes_single_request_batch_to_non_admission_pipeline(mon def test_execute_model_accepts_bare_diffusion_output_from_single_request_pipeline(monkeypatch): runner = _make_runner(cache_backend=None, cache_backend_name="none") runner.pipeline = _SingleRequestDiffusionOutputPipeline() - req = _make_request(skip_cache_refresh=True) + req = _make_request() monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context) @@ -515,7 +651,7 @@ def test_execute_model_runs_forward_after_kv_receive(monkeypatch): """execute_model runs the pipeline forward after the KV receive step.""" runner = _make_runner(cache_backend=None, cache_backend_name=None) runner.kv_transfer_manager.receive_multi_kv_cache_distributed = lambda *a, **k: True - req = _make_request(skip_cache_refresh=True) + req = _make_request() monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context) monkeypatch.setattr(model_runner_module.current_omni_platform, "reset_peak_memory_stats", lambda: None) diff --git a/tests/diffusion/test_diffusion_output_formatter.py b/tests/diffusion/test_diffusion_output_formatter.py index 22b305eab..eea53210c 100644 --- a/tests/diffusion/test_diffusion_output_formatter.py +++ b/tests/diffusion/test_diffusion_output_formatter.py @@ -11,7 +11,6 @@ import torch from vllm_omni.diffusion import output_formatter from vllm_omni.diffusion.data import DiffusionOutput from vllm_omni.diffusion.output_formatter import ( - DiffusionStepTimings, format_diffusion_outputs, format_empty_diffusion_outputs, normalize_diffusion_postprocess_output, @@ -42,15 +41,6 @@ def _config(model_class_name: str = "mock_model") -> SimpleNamespace: return SimpleNamespace(model_class_name=model_class_name) -def _timings() -> DiffusionStepTimings: - return DiffusionStepTimings( - preprocess_time_s=0.01, - exec_time_s=0.02, - postprocess_time_s=0.03, - total_time_ms=60.0, - ) - - def test_formatter_preserves_single_video_audio_actions_and_metadata( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -80,7 +70,6 @@ def test_formatter_preserves_single_video_audio_actions_and_metadata( ), output_data={"raw": "output"}, postprocess_output=postprocess_output, - timings=_timings(), ) assert len(results) == 1 @@ -102,12 +91,10 @@ def test_formatter_preserves_single_video_audio_actions_and_metadata( assert result.stage_durations == {"execute": 1.25} assert result.peak_memory_mb == 321.0 assert result.metrics == { - "preprocess_time_ms": 10.0, - "diffusion_engine_exec_time_ms": 20.0, - "diffusion_engine_total_time_ms": 60.0, "image_num": 1, "resolution": 512, - "postprocess_time_ms": 30.0, + "width": None, + "height": None, } @@ -160,7 +147,6 @@ def test_formatter_normalizes_payload_metadata_envelope( diffusion_output=DiffusionOutput(output=None), output_data={"raw": "output"}, postprocess_output=postprocess_output, - timings=_timings(), ) assert result.multimodal_output == { @@ -196,7 +182,6 @@ def test_formatter_maps_trajectory_payload_to_request_output( diffusion_output=DiffusionOutput(output=None), output_data={"raw": "output"}, postprocess_output=postprocess_output, - timings=_timings(), ) assert result.images == ["image-0"] @@ -229,7 +214,6 @@ def test_formatter_preserves_text_envelope_metadata(monkeypatch: pytest.MonkeyPa diffusion_output=DiffusionOutput(output=None), output_data={"payload": {"text": "caption"}}, postprocess_output=postprocess_output, - timings=_timings(), ) assert result.images == [] @@ -241,6 +225,40 @@ def test_formatter_preserves_text_envelope_metadata(monkeypatch: pytest.MonkeyPa } +@pytest.mark.parametrize( + "text_metadata", + [ + pytest.param({"think_text": "reasoning"}, id="sensenova-bagel-thinking"), + pytest.param({"ar_generated_text": "reasoning"}, id="hunyuan-image3-cot"), + ], +) +def test_formatter_preserves_image_with_reasoning_metadata( + monkeypatch: pytest.MonkeyPatch, + text_metadata: dict[str, str], +) -> None: + monkeypatch.setattr(output_formatter, "supports_audio_output", lambda _: False) + metadata = {"text": text_metadata} + postprocess_output = normalize_diffusion_postprocess_output( + { + "payload": {"image": "image-0"}, + "metadata": metadata, + } + ) + + [result] = format_diffusion_outputs( + request=_request("generate an image"), + od_config=_config(), + diffusion_output=DiffusionOutput(output=None), + output_data=None, + postprocess_output=postprocess_output, + ) + + assert postprocess_output.primary_key == "image" + assert result.images == ["image-0"] + assert result.final_output_type == "image" + assert result.multimodal_output == {"metadata": metadata} + + def test_formatter_preserves_audio_output_with_model_sample_rate_fallback( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -261,7 +279,6 @@ def test_formatter_preserves_audio_output_with_model_sample_rate_fallback( diffusion_output=DiffusionOutput(output=["waveform"]), output_data=["waveform"], postprocess_output=postprocess_output, - timings=_timings(), ) assert result.images == [] @@ -293,7 +310,6 @@ def test_formatter_preserves_audio_model_video_audio_and_actions( diffusion_output=DiffusionOutput(output=None), output_data={"raw": "output"}, postprocess_output=postprocess_output, - timings=_timings(), ) assert result.images == ["frame-0"] @@ -328,7 +344,6 @@ def test_formatter_preserves_audio_only_postprocess_dict( diffusion_output=DiffusionOutput(output=None), output_data={"raw": "output"}, postprocess_output=postprocess_output, - timings=_timings(), ) assert result.images == [] @@ -360,7 +375,6 @@ def test_formatter_preserves_single_prompt_multiple_audio_outputs( diffusion_output=DiffusionOutput(output=["waveform-0", "waveform-1"]), output_data=["waveform-0", "waveform-1"], postprocess_output=postprocess_output, - timings=_timings(), ) assert result.images == [] @@ -388,7 +402,6 @@ def test_formatter_preserves_single_prompt_audio_and_action_payloads( diffusion_output=DiffusionOutput(output=None), output_data={"raw": "output"}, postprocess_output=postprocess_output, - timings=_timings(), ) assert len(results) == 1 diff --git a/tests/diffusion/test_diffusion_request.py b/tests/diffusion/test_diffusion_request.py index ffaab3c53..259492c1e 100644 --- a/tests/diffusion/test_diffusion_request.py +++ b/tests/diffusion/test_diffusion_request.py @@ -72,3 +72,40 @@ def test_tp_seed_same_across_ranks_and_varies_across_requests(): # Seeds must vary across requests (non-determinism preserved). assert len(set(seeds)) == n_requests, f"Expected {n_requests} unique seeds but got {len(set(seeds))}: {seeds}" + + +def _request_with_guidance(**sampling_kwargs) -> OmniDiffusionRequest: + return OmniDiffusionRequest( + prompt={"prompt": "a cup of coffee on a table"}, + sampling_params=OmniDiffusionSamplingParams(num_inference_steps=1, **sampling_kwargs), + request_id="request-test", + ) + + +def test_omitted_guidance_scale_is_unset_and_resolved_to_one(): + # No guidance_scale passed: the caller did not set it, so it must NOT count as + # "provided" (each pipeline applies its own default), and __post_init__ resolves the + # None "unset" sentinel to a concrete float so no consumer ever observes None. + req = _request_with_guidance() + + assert req.sampling_params.guidance_scale_provided is False + assert req.sampling_params.guidance_scale == 1.0 + + +def test_explicit_zero_guidance_scale_is_honored(): + # Regression (#4998): an explicit guidance_scale=0 (disable CFG) must be honored, + # not mistaken for "unset" and overridden by the pipeline's model default. This is + # the sentinel collision that the None default + ``is not None`` check fixes. + req = _request_with_guidance(guidance_scale=0.0) + + assert req.sampling_params.guidance_scale_provided is True + assert req.sampling_params.guidance_scale == 0.0 + + +@pytest.mark.parametrize("value", [0.999, 1.0, 5.0]) +def test_explicit_nonzero_guidance_scale_is_honored(value): + # Any explicitly-set value (truthy or not) is honored as provided and preserved. + req = _request_with_guidance(guidance_scale=value) + + assert req.sampling_params.guidance_scale_provided is True + assert req.sampling_params.guidance_scale == value diff --git a/tests/diffusion/test_diffusion_scheduler.py b/tests/diffusion/test_diffusion_scheduler.py index 7d6d2d11b..43ce7a45b 100644 --- a/tests/diffusion/test_diffusion_scheduler.py +++ b/tests/diffusion/test_diffusion_scheduler.py @@ -11,13 +11,13 @@ import torch from pytest_mock import MockerFixture from vllm_omni.diffusion.data import DiffusionOutput, DiffusionRequestAbortedError -from vllm_omni.diffusion.diffusion_engine import DiffusionEngine +from vllm_omni.diffusion.diffusion_engine import DiffusionEngine, DiffusionExecutionMode from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.sched import ( + BaseScheduler, DiffusionRequestStatus, RequestScheduler, Scheduler, - SchedulerInterface, StepScheduler, ) from vllm_omni.diffusion.sched.interface import CachedRequestData, NewRequestData @@ -85,7 +85,7 @@ def _cached_ids(sched_output) -> list[str]: return list(sched_output.scheduled_cached_reqs.request_ids) -class _StubScheduler(SchedulerInterface): +class _StubScheduler: def __init__(self, request: OmniDiffusionRequest, output) -> None: self._request = request self._output = output @@ -154,8 +154,14 @@ class _StubScheduler(SchedulerInterface): return None -class TestGetSamplingParamsKey: - """Pure-function tests for the batch-compatibility key builder.""" +class _ConcreteScheduler(BaseScheduler): + def update_from_output(self, sched_output, output) -> set[str]: + del sched_output, output + return set() + + +class TestGetStepBatchSamplingParamsKey: + """Tests for the step-batch compatibility key builder on BaseScheduler.""" @staticmethod def _make(lora_int_id: int | None = None, lora_scale: float = 1.0) -> OmniDiffusionRequest: @@ -176,34 +182,32 @@ class TestGetSamplingParamsKey: ) def test_distinguishes_lora_id(self) -> None: - from vllm_omni.diffusion.sched.base_scheduler import get_sampling_params_key - - assert get_sampling_params_key(self._make(lora_int_id=1)) != get_sampling_params_key(self._make(lora_int_id=2)) + scheduler = _ConcreteScheduler() + assert scheduler._build_sampling_params_key(self._make(lora_int_id=1)) != scheduler._build_sampling_params_key( + self._make(lora_int_id=2) + ) def test_distinguishes_lora_scale(self) -> None: - from vllm_omni.diffusion.sched.base_scheduler import get_sampling_params_key - - assert get_sampling_params_key(self._make(lora_int_id=1, lora_scale=0.5)) != get_sampling_params_key( - self._make(lora_int_id=1, lora_scale=1.0) - ) + scheduler = _ConcreteScheduler() + assert scheduler._build_sampling_params_key( + self._make(lora_int_id=1, lora_scale=0.5) + ) != scheduler._build_sampling_params_key(self._make(lora_int_id=1, lora_scale=1.0)) def test_treats_no_lora_as_distinct_bucket(self) -> None: - from vllm_omni.diffusion.sched.base_scheduler import get_sampling_params_key - - assert get_sampling_params_key(self._make(lora_int_id=None)) != get_sampling_params_key( - self._make(lora_int_id=1) - ) + scheduler = _ConcreteScheduler() + assert scheduler._build_sampling_params_key( + self._make(lora_int_id=None) + ) != scheduler._build_sampling_params_key(self._make(lora_int_id=1)) def test_equal_for_same_lora_identity(self) -> None: - from vllm_omni.diffusion.sched.base_scheduler import get_sampling_params_key - - a = get_sampling_params_key(self._make(lora_int_id=1, lora_scale=0.5)) - b = get_sampling_params_key(self._make(lora_int_id=1, lora_scale=0.5)) + scheduler = _ConcreteScheduler() + a = scheduler._build_sampling_params_key(self._make(lora_int_id=1, lora_scale=0.5)) + b = scheduler._build_sampling_params_key(self._make(lora_int_id=1, lora_scale=0.5)) assert a == b class TestGetRequestBatchSamplingParamsKey: - """Pure-function tests for the request-batch compatibility key builder.""" + """Tests for the request-batch compatibility key builder on RequestScheduler.""" @staticmethod def _make( @@ -220,21 +224,19 @@ class TestGetRequestBatchSamplingParamsKey: return OmniDiffusionRequest(prompt="prompt", sampling_params=sp, request_id=f"req-{num_inference_steps}") def test_distinguishes_num_inference_steps(self) -> None: - from vllm_omni.diffusion.sched.base_scheduler import get_request_batch_sampling_params_key - - assert get_request_batch_sampling_params_key( + scheduler = RequestScheduler() + assert scheduler._build_sampling_params_key( self._make(num_inference_steps=2) - ) != get_request_batch_sampling_params_key(self._make(num_inference_steps=4)) + ) != scheduler._build_sampling_params_key(self._make(num_inference_steps=4)) def test_ignores_seed_and_generator(self) -> None: - from vllm_omni.diffusion.sched.base_scheduler import get_request_batch_sampling_params_key - + scheduler = RequestScheduler() gen_a = torch.Generator(device="cpu").manual_seed(1) gen_b = torch.Generator(device="cpu").manual_seed(2) - assert get_request_batch_sampling_params_key( + assert scheduler._build_sampling_params_key( self._make(seed=1, generator=gen_a) - ) == get_request_batch_sampling_params_key(self._make(seed=2, generator=gen_b)) + ) == scheduler._build_sampling_params_key(self._make(seed=2, generator=gen_b)) class TestRequestScheduler: @@ -530,13 +532,11 @@ class TestDiffusionEngine: assert output is runner_output.result engine.execute_fn.assert_called_once() - def test_initializes_injected_scheduler( + def test_initializes_default_request_scheduler( self, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, ) -> None: - request = _make_request("init") - scheduler = _StubScheduler(request, DiffusionOutput(output=None)) od_config = SimpleNamespace(model_class_name="mock_model", streaming_output=False) fake_executor_cls = mocker.Mock(return_value=mocker.Mock()) @@ -552,11 +552,11 @@ class TestDiffusionEngine: "vllm_omni.diffusion.diffusion_engine.DiffusionExecutor.get_class", lambda *args, **kwargs: fake_executor_cls, ) - monkeypatch.setattr(DiffusionEngine, "_dummy_run", lambda self: None) - DiffusionEngine(od_config, scheduler=scheduler) + engine = DiffusionEngine(od_config) - assert scheduler.initialized_with is od_config + assert isinstance(engine.scheduler, RequestScheduler) + assert engine.scheduler.max_num_running_reqs == 1 fake_executor_cls.assert_called_once_with(od_config) def test_scheduler_alias_keeps_default_request_scheduler(self) -> None: @@ -571,16 +571,19 @@ class TestDiffusionEngine: assert scheduler.get_request_state(req_id).status == DiffusionRequestStatus.FINISHED_COMPLETED @pytest.mark.asyncio - async def test_step_raises_aborted_error(self, mocker: MockerFixture) -> None: + async def test_step_streaming_raises_aborted_error(self, mocker: MockerFixture) -> None: engine = DiffusionEngine.__new__(DiffusionEngine) engine._check_and_start_background_loop = mocker.AsyncMock() engine.pre_process_func = None - engine.async_add_req_and_wait_for_response = mocker.AsyncMock( - return_value=DiffusionOutput(aborted=True, abort_message="Request req-abort aborted.") - ) + + async def _stream(_request): + yield DiffusionOutput(aborted=True, abort_message="Request req-abort aborted.") + + engine.async_add_req_and_stream_response = mocker.Mock(return_value=_stream(None)) with pytest.raises(DiffusionRequestAbortedError, match="Request req-abort aborted"): - await engine.step(_make_request("req-abort")) + async for _ in engine.step_streaming(_make_request("req-abort")): + pass def test_abort_queue_marks_request_finished_aborted(self) -> None: engine = DiffusionEngine.__new__(DiffusionEngine) @@ -617,12 +620,13 @@ class TestDiffusionEngine: engine.scheduler.initialize(SimpleNamespace()) engine._rpc_lock = threading.RLock() engine._cv = threading.Condition(engine._rpc_lock) - engine._out_queue_streaming = {} + engine._out_streams = {} + engine.execution_mode = DiffusionExecutionMode.STEP_BATCH engine.main_loop = asyncio.get_running_loop() req_id = engine.scheduler.add_request(_make_request("stream-engine")) queue: asyncio.Queue[DiffusionOutput] = asyncio.Queue() - engine._out_queue_streaming[req_id] = queue + engine._out_streams[req_id] = queue sched_output = engine.scheduler.schedule() chunk = RunnerOutput( @@ -632,7 +636,7 @@ class TestDiffusionEngine: result=DiffusionOutput(output="chunk-0", finished=False, chunk_index=0, total_chunks=2), ) finished_req_ids = engine.scheduler.update_from_output(sched_output, chunk) - engine._handle_step_streaming_runner_output(finished_req_ids, sched_output.scheduled_request_ids, chunk) + engine._emit_outputs(finished_req_ids, sched_output.scheduled_request_ids, chunk) notified_chunk = await asyncio.wait_for(queue.get(), timeout=1) assert notified_chunk.output == "chunk-0" @@ -646,7 +650,7 @@ class TestDiffusionEngine: result=DiffusionOutput(output="chunk-1", finished=True, chunk_index=1, total_chunks=2), ) finished_req_ids = engine.scheduler.update_from_output(sched_output, final_chunk) - engine._handle_step_streaming_runner_output(finished_req_ids, sched_output.scheduled_request_ids, final_chunk) + engine._emit_outputs(finished_req_ids, sched_output.scheduled_request_ids, final_chunk) notified_final = await asyncio.wait_for(queue.get(), timeout=1) assert notified_final.output == "chunk-1" @@ -660,15 +664,15 @@ class TestDiffusionEngine: engine.scheduler.initialize(SimpleNamespace()) engine._rpc_lock = threading.RLock() engine._cv = threading.Condition(engine._rpc_lock) - engine._out_queue_streaming = {} + engine._out_streams = {} engine.main_loop = asyncio.get_running_loop() req_id = engine.scheduler.add_request(_make_request("stream-abort")) queue: asyncio.Queue[DiffusionOutput] = asyncio.Queue() - engine._out_queue_streaming[req_id] = queue + engine._out_streams[req_id] = queue engine.scheduler.finish_requests(req_id, DiffusionRequestStatus.FINISHED_ABORTED) - engine._handle_empty_streaming_requests({req_id}) + engine._emit_finished_outputs({req_id}) output = await asyncio.wait_for(queue.get(), timeout=1) assert output.aborted is True @@ -697,9 +701,9 @@ class TestDiffusionEngine: "vllm_omni.diffusion.diffusion_engine.DiffusionExecutor.get_class", lambda *args, **kwargs: fake_executor_cls, ) - monkeypatch.setattr(DiffusionEngine, "_dummy_run", lambda self: None) engine = DiffusionEngine(od_config) + assert engine.execution_mode == DiffusionExecutionMode.STEP_BATCH assert isinstance(engine.scheduler, StepScheduler) assert engine.execute_fn is fake_executor.execute_step fake_executor_cls.assert_called_once_with(od_config) diff --git a/tests/diffusion/test_diffusion_step_pipeline.py b/tests/diffusion/test_diffusion_step_pipeline.py index 7e18996ed..0964833d7 100644 --- a/tests/diffusion/test_diffusion_step_pipeline.py +++ b/tests/diffusion/test_diffusion_step_pipeline.py @@ -43,7 +43,7 @@ from vllm_omni.diffusion.sched.interface import ( from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker from vllm_omni.diffusion.worker.input_batch import InputBatch -from vllm_omni.diffusion.worker.utils import DiffusionRequestState, RunnerOutput +from vllm_omni.diffusion.worker.utils import RunnerOutput, StepRequestState from vllm_omni.engine.async_omni_engine import AsyncOmniEngine from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.platforms import current_omni_platform @@ -369,8 +369,8 @@ def _make_batch_scheduler_output(reqs, *, step_id=0, finished_req_ids=None): ) -def _make_input_batch_state(request_id: str, latent_value: float) -> DiffusionRequestState: - state = DiffusionRequestState( +def _make_input_batch_state(request_id: str, latent_value: float) -> StepRequestState: + state = StepRequestState( request_id=request_id, sampling=SimpleNamespace(), prompt=None, diff --git a/tests/diffusion/test_diffusion_streaming_output.py b/tests/diffusion/test_diffusion_streaming_output.py index ae5f9eee8..824902ad4 100644 --- a/tests/diffusion/test_diffusion_streaming_output.py +++ b/tests/diffusion/test_diffusion_streaming_output.py @@ -341,7 +341,9 @@ class TestPipelineStreamingOutputToEntrypoint: with client.websocket_connect("/v1/realtime/video") as ws: ws.send_json({"type": "session.start", "prompt": "integration test"}) assert ws.receive_json()["type"] == "video.start" + assert ws.receive_json()["type"] == "video.chunk_metadata" assert ws.receive_bytes() == b"pipeline-fmp4-0" + assert ws.receive_json()["type"] == "video.chunk_metadata" assert ws.receive_bytes() == b"pipeline-fmp4-1" done = ws.receive_json() assert done["type"] == "session.done" @@ -453,7 +455,6 @@ class TestPipelineStreamingOutputToEntrypoint: omni.final_output_task = None omni.event_resolver = AsyncEventResolver() omni._enable_ar_profiler = False - omni._is_sleeping = False omni.prom_metrics = MagicMock() omni.mod_metrics = MagicMock() omni.resolve_sampling_params_list = lambda params, allow_delta_coercion: params diff --git a/tests/diffusion/test_diffusion_worker.py b/tests/diffusion/test_diffusion_worker.py index fb45cd41d..6a49d0909 100644 --- a/tests/diffusion/test_diffusion_worker.py +++ b/tests/diffusion/test_diffusion_worker.py @@ -5,7 +5,6 @@ Unit tests for DiffusionWorker class. This module tests the DiffusionWorker implementation: -- load_weights: Loading model weights - sleep: Putting worker into sleep mode (levels 1 and 2) - wake_up: Waking worker from sleep mode """ @@ -59,38 +58,6 @@ def mock_gpu_worker(mocker: MockerFixture, mock_od_config): return worker -class TestDiffusionWorkerLoadWeights: - """Test DiffusionWorker.load_weights method.""" - - def test_load_weights_calls_pipeline(self, mocker: MockerFixture, mock_gpu_worker): - """Test that load_weights delegates to model_runner.load_weights.""" - # Setup mock weights - mock_weights = [ - ("layer1.weight", torch.randn(10, 10)), - ("layer2.weight", torch.randn(20, 20)), - ] - expected_loaded = {"layer1.weight", "layer2.weight"} - - # Configure model_runner mock - mock_gpu_worker.model_runner.load_weights = mocker.Mock(return_value=expected_loaded) - - # Call load_weights - result = mock_gpu_worker.load_weights(mock_weights) - - # Verify model_runner.load_weights was called with the weights - mock_gpu_worker.model_runner.load_weights.assert_called_once_with(mock_weights) - assert result == expected_loaded - - def test_load_weights_empty_iterable(self, mocker: MockerFixture, mock_gpu_worker): - """Test load_weights with empty weights iterable.""" - mock_gpu_worker.model_runner.load_weights = mocker.Mock(return_value=set()) - - result = mock_gpu_worker.load_weights([]) - - mock_gpu_worker.model_runner.load_weights.assert_called_once_with([]) - assert result == set() - - def test_diffusion_vllm_model_config_supplies_dtype_for_quant_methods(): from types import SimpleNamespace diff --git a/tests/diffusion/test_inline_stage_diffusion_client.py b/tests/diffusion/test_inline_stage_diffusion_client.py index b94f86db0..4981f7f92 100644 --- a/tests/diffusion/test_inline_stage_diffusion_client.py +++ b/tests/diffusion/test_inline_stage_diffusion_client.py @@ -54,9 +54,12 @@ def client(mock_engine): @pytest.mark.asyncio async def test_inline_dispatch_request_success(client, mock_engine): - # Setup mock engine step to return a successful result mock_result = OmniRequestOutput.from_diffusion(request_id="req-1", images=[MagicMock()]) - mock_engine.step.return_value = [mock_result] + + async def _step_streaming(_request): + yield [mock_result] + + mock_engine.step_streaming = _step_streaming sampling_params = OmniDiffusionSamplingParams() await client.add_request_async("req-1", "A test prompt", sampling_params) @@ -70,7 +73,6 @@ async def test_inline_dispatch_request_success(client, mock_engine): assert output is not None assert output.request_id == "req-1" - mock_engine.step.assert_called_once() @pytest.mark.asyncio @@ -104,8 +106,11 @@ async def test_inline_dispatch_request_streaming_success(client, mock_engine): @pytest.mark.asyncio async def test_inline_dispatch_request_error(client, mock_engine): - # Setup mock engine step to raise an exception - mock_engine.step.side_effect = RuntimeError("Engine failure") + async def _step_streaming(_request): + raise RuntimeError("Engine failure") + yield # pragma: no cover + + mock_engine.step_streaming = _step_streaming sampling_params = OmniDiffusionSamplingParams() await client.add_request_async("req-err", "A test prompt", sampling_params) diff --git a/tests/diffusion/test_input_batch_txt_seq_lens.py b/tests/diffusion/test_input_batch_txt_seq_lens.py index 315f0f703..88fb60a5e 100644 --- a/tests/diffusion/test_input_batch_txt_seq_lens.py +++ b/tests/diffusion/test_input_batch_txt_seq_lens.py @@ -2,14 +2,14 @@ import pytest import torch from vllm_omni.diffusion.worker.input_batch import _prepare_request_prompt_field -from vllm_omni.diffusion.worker.utils import DiffusionRequestState +from vllm_omni.diffusion.worker.utils import StepRequestState from vllm_omni.inputs.data import OmniDiffusionSamplingParams -pytestmark = [pytest.mark.cpu] +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] def test_prepare_request_prompt_field_refreshes_txt_seq_lens_after_padding(): - state = DiffusionRequestState( + state = StepRequestState( request_id="req-0", sampling=OmniDiffusionSamplingParams(), prompt_embeds=torch.randn(1, 10, 4), diff --git a/tests/diffusion/test_ipc_async.py b/tests/diffusion/test_ipc_async.py new file mode 100644 index 000000000..c54d2f793 --- /dev/null +++ b/tests/diffusion/test_ipc_async.py @@ -0,0 +1,159 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for IPC async D2H stream packing.""" + +import pytest +import torch + +from vllm_omni.diffusion.data import DiffusionOutput +from vllm_omni.diffusion.ipc import ( + _pack_diffusion_fields, + _pack_tensor_if_large, + _pack_value_if_large, + pack_diffusion_output_shm, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class TestPackTensorIfLargeWithD2hStream: + """Test _pack_tensor_if_large with mock d2h_stream parameter.""" + + def test_small_tensor_not_packed_with_stream(self, mocker): + """Small tensors pass through even when d2h_stream is provided.""" + small_tensor = torch.zeros(10) # way below threshold + mock_stream = mocker.MagicMock() + result = _pack_tensor_if_large(small_tensor, d2h_stream=mock_stream) + # Small tensor should be returned as-is (not a SHM handle dict) + assert isinstance(result, torch.Tensor) + + def test_large_tensor_packed_without_stream(self, mocker): + """Large tensor is packed via SHM when no d2h_stream.""" + large_tensor = torch.randn(500_000) # > 1MB for float32 + result = _pack_tensor_if_large(large_tensor, d2h_stream=None) + assert isinstance(result, dict) + assert result.get("__tensor_shm__") is True + + def test_pack_value_if_large_passes_stream_to_tensor_pack(self, mocker): + """_pack_value_if_large propagates d2h_stream to _pack_tensor_if_large.""" + mock_pack = mocker.patch( + "vllm_omni.diffusion.ipc._pack_tensor_if_large", + return_value="packed", + ) + tensor = torch.zeros(1) + d2h = mocker.MagicMock() + _pack_value_if_large(tensor, d2h_stream=d2h) + mock_pack.assert_called_once_with(tensor, d2h_stream=d2h) + + def test_pack_value_if_large_passes_stream_through_dict(self, mocker): + """d2h_stream propagates through dict container packing.""" + mock_pack = mocker.patch( + "vllm_omni.diffusion.ipc._pack_tensor_if_large", + return_value="packed", + ) + d2h = mocker.MagicMock() + data = {"image": torch.zeros(1), "video": torch.zeros(1)} + _pack_value_if_large(data, d2h_stream=d2h) + assert mock_pack.call_count == 2 + for call in mock_pack.call_args_list: + assert call.kwargs.get("d2h_stream") is d2h or call.args[1] is d2h + + def test_pack_value_if_large_passes_stream_through_list(self, mocker): + """d2h_stream propagates through list container packing.""" + mock_pack = mocker.patch( + "vllm_omni.diffusion.ipc._pack_tensor_if_large", + return_value="packed", + ) + d2h = mocker.MagicMock() + data = [torch.zeros(1), torch.zeros(1)] + _pack_value_if_large(data, d2h_stream=d2h) + assert mock_pack.call_count == 2 + + def test_pack_value_if_large_passes_stream_through_tuple(self, mocker): + """d2h_stream propagates through tuple container packing.""" + mock_pack = mocker.patch( + "vllm_omni.diffusion.ipc._pack_tensor_if_large", + return_value="packed", + ) + d2h = mocker.MagicMock() + data = (torch.zeros(1), torch.zeros(1)) + result = _pack_value_if_large(data, d2h_stream=d2h) + assert isinstance(result, tuple) + assert mock_pack.call_count == 2 + + +class TestPackDiffusionFieldsWithD2hStream: + """Test _pack_diffusion_fields propagates d2h_stream.""" + + def test_output_field_packed_with_stream(self, mocker): + mock_pack_value = mocker.patch( + "vllm_omni.diffusion.ipc._pack_value_if_large", + return_value="packed", + ) + d2h = mocker.MagicMock() + out = DiffusionOutput(output=torch.zeros(1)) + _pack_diffusion_fields(out, d2h_stream=d2h) + mock_pack_value.assert_called_once_with(torch.zeros(1), d2h_stream=d2h) + + def test_trajectory_fields_packed_with_stream(self, mocker): + mock_pack_tensor = mocker.patch( + "vllm_omni.diffusion.ipc._pack_tensor_if_large", + return_value="packed", + ) + d2h = mocker.MagicMock() + out = DiffusionOutput( + trajectory_latents=torch.zeros(1), + trajectory_log_probs=torch.zeros(1), + ) + _pack_diffusion_fields(out, d2h_stream=d2h) + # Should pack trajectory_latents and trajectory_log_probs + assert mock_pack_tensor.call_count == 2 + + +class TestPackDiffusionOutputShmWithD2hStream: + """Test pack_diffusion_output_shm top-level dispatch with d2h_stream.""" + + def test_diffusion_output_path_with_stream(self, mocker): + mock_pack_fields = mocker.patch( + "vllm_omni.diffusion.ipc._pack_diffusion_fields", + ) + d2h = mocker.MagicMock() + out = DiffusionOutput() + pack_diffusion_output_shm(out, d2h_stream=d2h) + mock_pack_fields.assert_called_once_with(out, d2h_stream=d2h) + + def test_rpc_envelope_path_with_stream(self, mocker): + mock_pack_fields = mocker.patch( + "vllm_omni.diffusion.ipc._pack_diffusion_fields", + ) + d2h = mocker.MagicMock() + result = DiffusionOutput() + envelope = {"type": "diffusion_rpc_result", "result": result} + pack_diffusion_output_shm(envelope, d2h_stream=d2h) + # envelope["result"] is replaced in-place by pack_diffusion_output_shm; + # assert with the original reference, not the replaced one. + mock_pack_fields.assert_called_once_with( + result, + d2h_stream=d2h, + ) + + def test_runner_output_path_with_stream(self, mocker): + mock_pack_fields = mocker.patch( + "vllm_omni.diffusion.ipc._pack_diffusion_fields", + ) + d2h = mocker.MagicMock() + + from vllm_omni.diffusion.worker.utils import RunnerOutput + + result = DiffusionOutput() + runner_out = RunnerOutput( + request_id="req1", + result=result, + ) + pack_diffusion_output_shm(runner_out, d2h_stream=d2h) + # runner_out.result is replaced in-place by pack_diffusion_output_shm; + # assert with the original reference, not the replaced one. + mock_pack_fields.assert_called_once_with( + result, + d2h_stream=d2h, + ) diff --git a/tests/diffusion/test_multiproc_engine_concurrency.py b/tests/diffusion/test_multiproc_engine_concurrency.py index 070d899e6..f1c48ba30 100644 --- a/tests/diffusion/test_multiproc_engine_concurrency.py +++ b/tests/diffusion/test_multiproc_engine_concurrency.py @@ -56,11 +56,9 @@ def _make_executor(num_gpus: int = 1): Returns ``(executor, request_queue, result_queue)``. """ - od_cfg = SimpleNamespace(num_gpus=num_gpus, streaming_output=False) - monkeypatch = pytest.MonkeyPatch() - monkeypatch.setattr(MultiprocDiffusionExecutor, "_init_executor", lambda self: None) - executor = MultiprocDiffusionExecutor(od_cfg) - monkeypatch.undo() + od_cfg = SimpleNamespace(num_gpus=num_gpus, streaming_output=False, step_execution=True) + executor = object.__new__(MultiprocDiffusionExecutor) + executor.od_config = od_cfg req_q: queue.Queue = queue.Queue() res_q: queue.Queue = queue.Queue() @@ -73,7 +71,7 @@ def _make_executor(num_gpus: int = 1): executor._result_mq = mock_rmq executor._closed = False executor._processes = [] - executor.is_failed = False + executor._is_failed = False executor._failure_callbacks = [] return executor, req_q, res_q @@ -93,7 +91,7 @@ def _make_engine(num_gpus: int = 1): engine._loop_started = False engine._rpc_queue = queue.Queue() engine.abort_queue = queue.Queue() - engine.execute_fn = executor.execute_request + engine.execute_fn = executor.execute_batch return engine, executor, req_q, res_q @@ -104,7 +102,10 @@ def _start_worker(req_q, res_q, count=2): def _run(): for _ in range(count): - req = req_q.get(timeout=10) + try: + req = req_q.get(timeout=10) + except queue.Empty: + break method = req.get("method", "") args = req.get("args", ()) if method == "execute_model_batch" and args and isinstance(args[0], DiffusionSchedulerOutput): @@ -148,7 +149,7 @@ def _inject_interleave(executor): orig_enqueue(item) if threading.current_thread().name == "thread_a": a_enqueued.set() # tell B: "A has enqueued" - b_complete.wait(5) # block A until B finishes + b_complete.wait(1) # block A until B finishes executor._broadcast_mq.enqueue = _controlled return a_enqueued, b_complete @@ -175,8 +176,8 @@ class TestConcurrentRequestExecution: results["B"] = engine.add_req_and_wait_for_response(_mock_request("B")) b_complete.set() # release A - ta = threading.Thread(target=_a, name="thread_a") - tb = threading.Thread(target=_b, name="thread_b") + ta = threading.Thread(target=_a, name="thread_a", daemon=True) + tb = threading.Thread(target=_b, name="thread_b", daemon=True) ta.start() tb.start() ta.join(10) @@ -246,6 +247,56 @@ class TestRequestModeDispatch: results = {ro.request_id: ro.result.error for ro in out.runner_outputs} assert results == {request_id: f"result_for_{request_id}" for request_id in request_ids} + def test_dlo_dp_routes_multiple_requests_without_pipeline_batching(self): + executor, _, _ = _make_executor(num_gpus=2) + executor.od_config = SimpleNamespace( + step_execution=False, + parallel_config=SimpleNamespace(data_parallel_size=2), + enable_distributed_layerwise_offload=True, + dlo_use_allgather=True, + ) + executor.execute_request = Mock(return_value="dlo-dp") + executor.collective_rpc = Mock() + scheduler_output = _make_sched_output("A", "B") + + result = executor.execute_batch(scheduler_output) + + assert result == "dlo-dp" + executor.execute_request.assert_called_once_with(scheduler_output) + executor.collective_rpc.assert_not_called() + + def test_dlo_dp_multi_rank_reply_uses_synchronous_rpc_collection(self): + executor, req_q, res_q = _make_executor(num_gpus=2) + executor.od_config = SimpleNamespace( + step_execution=False, + parallel_config=SimpleNamespace(data_parallel_size=2), + ) + executor._sync_result_buffer = res_q + + def worker(): + request = req_q.get(timeout=2) + assert "rpc_id" not in request + wave_id = request["wave_id"] + for dp_rank in range(2): + res_q.put( + { + "dp_rank": dp_rank, + "output": _tagged_output(str(dp_rank)), + "wave_id": wave_id, + } + ) + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + results = executor.collective_rpc( + "execute_model", + unique_reply_rank=None, + exec_all_ranks=True, + ) + thread.join(timeout=2) + + assert [result.error for result in results] == ["0", "1"] + # ───────────────── concurrent collective RPC ───────────────── @@ -276,8 +327,8 @@ class TestConcurrentCollectiveRpc: ) b_complete.set() - ta = threading.Thread(target=_a, name="thread_a") - tb = threading.Thread(target=_b, name="thread_b") + ta = threading.Thread(target=_a, name="thread_a", daemon=True) + tb = threading.Thread(target=_b, name="thread_b", daemon=True) ta.start() tb.start() ta.join(10) @@ -313,8 +364,8 @@ class TestConcurrentRequestExecutionAndCollectiveRpc: ) b_complete.set() - ta = threading.Thread(target=_a, name="thread_a") - tb = threading.Thread(target=_b, name="thread_b") + ta = threading.Thread(target=_a, name="thread_a", daemon=True) + tb = threading.Thread(target=_b, name="thread_b", daemon=True) ta.start() tb.start() ta.join(10) @@ -528,7 +579,7 @@ class TestWorkerProcRpcRankStatus: monkeypatch.setattr(torch.distributed, "all_gather_object", _all_gather_object) - result, should_reply = proc.execute_rpc( + result, should_reply = proc._execute_rpc( { "method": "remove_lora", "args": (), @@ -551,7 +602,7 @@ class TestWorkerProcRpcRankStatus: proc.worker.execute_method = Mock(side_effect=RuntimeError("local boom")) monkeypatch.setattr(torch.distributed, "is_initialized", lambda: False) - result, should_reply = proc.execute_rpc( + result, should_reply = proc._execute_rpc( { "method": "add_lora", "args": (), @@ -577,7 +628,7 @@ class TestWorkerProcRpcRankStatus: proc = self._make_worker_proc() with pytest.raises(ValueError, match="collect_rank_status requires exec_all_ranks=True"): - proc.execute_rpc( + proc._execute_rpc( { "method": "ping", "args": (), @@ -596,7 +647,7 @@ class TestWorkerProcRpcRankStatus: proc.worker.execute_method = Mock(side_effect=original) with pytest.raises(ValueError) as excinfo: - proc.execute_rpc( + proc._execute_rpc( { "method": "bad", "args": (), @@ -618,11 +669,12 @@ class TestMultiprocExecutorRaisesEngineDeadError: def test_collective_rpc_raises_when_is_failed(self): executor = object.__new__(MultiprocDiffusionExecutor) + executor.od_config = SimpleNamespace(step_execution=True) executor._closed = False executor._broadcast_mq = MagicMock() executor._result_mq = MagicMock() executor._result_mq.dequeue = MagicMock(side_effect=TimeoutError) - executor.is_failed = True + executor._is_failed = True with pytest.raises(EngineDeadError): executor.collective_rpc( @@ -643,7 +695,7 @@ class TestMultiprocExecutorRaisesEngineDeadError: nonlocal call_count call_count += 1 if call_count == 1: - executor.is_failed = True + executor._is_failed = True raise TimeoutError return orig_dequeue(timeout=timeout) @@ -663,7 +715,7 @@ class TestMultiprocExecutorStepStreamingOutput: def test_execute_step_allows_streaming_output_mode(self): executor, req_q, res_q = _make_executor() - executor.od_config = SimpleNamespace(streaming_output=True) # pyright: ignore[reportAttributeAccessIssue] + executor.od_config = SimpleNamespace(streaming_output=True, step_execution=True) # pyright: ignore[reportAttributeAccessIssue] runner_outputs = [ RunnerOutput( request_id="sched-stream", @@ -684,7 +736,10 @@ class TestMultiprocExecutorStepStreamingOutput: def _worker(): for runner_output in runner_outputs: - req_q.get(timeout=10) + try: + req_q.get(timeout=10) + except queue.Empty: + break res_q.put(runner_output) thread = threading.Thread(target=_worker, daemon=True) @@ -920,7 +975,7 @@ def _make_short_lived_process() -> mp.Process: class TestMultiprocExecutorWorkerMonitor: - """Integration tests for ``start_worker_monitor``. + """Integration tests for ``_start_worker_monitor``. Uses real short-lived subprocesses so that OS-level sentinel fd readiness is exercised end-to-end. @@ -928,19 +983,27 @@ class TestMultiprocExecutorWorkerMonitor: def test_worker_monitor_sets_is_failed_and_calls_callbacks_on_death(self): """When a worker process dies, the monitor thread must: - 1. Set ``is_failed = True`` + 1. Set ``_is_failed = True`` 2. Call ``shutdown()`` (which sets ``_closed = True``) 3. Invoke all registered failure callbacks """ executor = object.__new__(MultiprocDiffusionExecutor) executor._closed = False - executor.is_failed = False + executor._is_failed = False executor._failure_callbacks = [] executor._broadcast_mq = None executor._result_mq = None - executor.resources = None + executor._shutdown_cleaner = None # Use a no-op so shutdown() doesn't crash on None resources. executor._finalizer = lambda: None + # ------------------------------------------------------------------ + # Attributes added by remove_bubble_v2 (async D2H); shutdown() iterates + # over them, so they need to exist even when constructed via __new__. + executor._pump_stop = threading.Event() + executor._futures_lock = threading.RLock() + executor._rpc_futures = {} + executor._output_futures = {} + executor._batch_split_map = {} proc = _make_short_lived_process() executor._processes = [proc] @@ -948,35 +1011,38 @@ class TestMultiprocExecutorWorkerMonitor: callback_called = threading.Event() executor.register_failure_callback(callback_called.set) - executor.start_worker_monitor() + executor._start_worker_monitor() # Wait for the process to exit and the monitor to react. proc.join(5) - assert _poll_flag(lambda: executor.is_failed), "is_failed was not set" + assert _poll_flag(lambda: executor._is_failed), "_is_failed was not set" assert executor._closed, "shutdown() was not called" assert callback_called.wait(timeout=2), "failure callback was not invoked" + assert executor.is_dead def test_worker_monitor_noop_when_already_closed(self): """If ``_closed`` is already True when the process dies (orderly - shutdown), the monitor must *not* set ``is_failed``.""" + shutdown), the monitor must *not* set ``_is_failed``.""" executor = object.__new__(MultiprocDiffusionExecutor) executor._closed = True # already shut down - executor.is_failed = False + executor._is_failed = False executor._failure_callbacks = [] executor._broadcast_mq = None executor._result_mq = None - executor.resources = None + executor._shutdown_cleaner = None executor._finalizer = lambda: None proc = _make_short_lived_process() executor._processes = [proc] - executor.start_worker_monitor() + executor._start_worker_monitor() proc.join(5) # Give the monitor thread a chance to run (it should early-return). time.sleep(0.3) - assert not executor.is_failed, "is_failed should remain False on orderly shutdown" + assert not executor._is_failed, "_is_failed should remain False on orderly shutdown" + # Orderly close still reports dead via the public accessor. + assert executor.is_dead class TestStageDiffusionClientProcMonitor: diff --git a/tests/diffusion/test_omni_diffusion_config_offline_model_path.py b/tests/diffusion/test_omni_diffusion_config_offline_model_path.py new file mode 100644 index 000000000..674f8d93f --- /dev/null +++ b/tests/diffusion/test_omni_diffusion_config_offline_model_path.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for OmniDiffusionConfig HF-offline model-path resolution.""" + +import os + +import huggingface_hub +import pytest + +import vllm_omni.diffusion.data as diffusion_data +from vllm_omni.diffusion.data import OmniDiffusionConfig + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +_LOCAL_SNAPSHOT = "/root/.cache/huggingface/hub/models--foo--bar/snapshots/deadbeef" + + +@pytest.fixture +def record_get_model_path(monkeypatch: pytest.MonkeyPatch): + calls: list[tuple[str, str | None]] = [] + + def fake_get_model_path(model, revision=None): + calls.append((model, revision)) + if os.path.exists(model): + return model + return _LOCAL_SNAPSHOT + + monkeypatch.setattr(diffusion_data, "get_model_path", fake_get_model_path) + return calls + + +class TestOmniDiffusionConfigOfflineModelPath: + def test_offline_repo_id_replaced_with_local_path( + self, monkeypatch: pytest.MonkeyPatch, record_get_model_path + ) -> None: + monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_OFFLINE", True) + config = OmniDiffusionConfig(model="foo/bar") + assert config.model == _LOCAL_SNAPSHOT + assert record_get_model_path == [("foo/bar", None)] + + def test_online_leaves_model_untouched(self, monkeypatch: pytest.MonkeyPatch, record_get_model_path) -> None: + monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_OFFLINE", False) + config = OmniDiffusionConfig(model="foo/bar") + assert config.model == "foo/bar" + assert record_get_model_path == [] + + def test_offline_local_path_unchanged(self, monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_OFFLINE", True) + local = str(tmp_path) + config = OmniDiffusionConfig(model=local) + assert config.model == local + + @pytest.mark.parametrize("model", [None, ""]) + def test_offline_empty_model_unchanged( + self, monkeypatch: pytest.MonkeyPatch, record_get_model_path, model: str | None + ) -> None: + monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_OFFLINE", True) + config = OmniDiffusionConfig(model=model) + assert config.model == model + assert record_get_model_path == [] diff --git a/tests/diffusion/test_prompt_update.py b/tests/diffusion/test_prompt_update.py new file mode 100644 index 000000000..cdee35979 --- /dev/null +++ b/tests/diffusion/test_prompt_update.py @@ -0,0 +1,574 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for midway prompt update across runner, batch, pipeline, and entrypoint layers.""" + +from __future__ import annotations + +import asyncio +import queue +import time +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import MagicMock, patch + +import pytest +import torch + +from tests.engine.test_orchestrator import ( + OrchestratorFixture, + _build_harness, + _wait_for, +) +from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig +from vllm_omni.diffusion.inline_stage_diffusion_client import InlineStageDiffusionClient +from vllm_omni.diffusion.models.helios.pipeline_helios import HeliosPipeline +from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner +from vllm_omni.diffusion.worker.input_batch import InputBatch +from vllm_omni.diffusion.worker.utils import StepRequestState +from vllm_omni.engine.async_omni_engine import StageRuntimeInfo +from vllm_omni.engine.messages import ( + ErrorMessage, + InteractionMessage, + ShutdownRequestMessage, + StageSubmissionMessage, +) +from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState +from vllm_omni.engine.stage_client import StagePoolClient +from vllm_omni.engine.stage_init_utils import StageMetadata +from vllm_omni.engine.stage_pool import StagePool +from vllm_omni.entrypoints.async_omni import AsyncEventResolver, AsyncOmni +from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniInteractionPrompt +from vllm_omni.outputs import OmniRequestOutput + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + +@pytest.fixture +def pipeline() -> HeliosPipeline: + pipeline = object.__new__(HeliosPipeline) + pipeline.device = torch.device("cpu") + pipeline.transformer = SimpleNamespace(dtype=torch.float32) # pyright: ignore[reportAttributeAccessIssue] + pipeline.encode_prompt = MagicMock( + return_value=( + torch.full((1, 4, 2), 2.0), + None, + ) + ) + pipeline._prepare_next_chunk = MagicMock() + return pipeline + + +def _make_diffusion_request_state(*, request_id: str = "req-1") -> StepRequestState: + state = StepRequestState( + request_id=request_id, + sampling=SimpleNamespace(num_outputs_per_prompt=1, max_sequence_length=226), # pyright: ignore[reportArgumentType] + prompt="hello", + ) + state.prompt_embeds = torch.zeros(1, 4, 2) + state.extra = {} + return state + + +def _make_diffusion_model_runner(*, pipeline, streaming_output: bool = True) -> DiffusionModelRunner: + runner = object.__new__(DiffusionModelRunner) + runner.pipeline = pipeline + runner.state_cache = {} + runner.od_config = SimpleNamespace( # pyright: ignore[reportAttributeAccessIssue] + model_class_name="HeliosPipeline", + streaming_output=streaming_output, + ) + runner._supports_step_mode = lambda: True + return runner + + +def _prompt_interaction( + prompt: str = "updated", + *, + event_id: str = "ui-update-1", + transition_chunks: int | None = None, +) -> OmniInteractionPrompt: + interaction: dict[str, Any] = { + "event_id": event_id, + "event": {"prompt": prompt}, + } + if transition_chunks is not None: + interaction["transition_chunks"] = transition_chunks + return cast(OmniInteractionPrompt, interaction) + + +class TestPromptUpdateExecution: + """Runner, InputBatch cache, and pipeline chunk-boundary behavior.""" + + def test_runner_interaction_delegates_to_helios_pipeline(self, pipeline: HeliosPipeline) -> None: + """Runner encodes the new prompt and queues pending embeds on the request state.""" + runner = _make_diffusion_model_runner(pipeline=pipeline) + state = _make_diffusion_request_state() + runner.state_cache["req-1"] = state + + runner.submit_interaction("req-1", _prompt_interaction(transition_chunks=2)) + + pipeline.encode_prompt.assert_called_once() # pyright: ignore[reportAttributeAccessIssue] + pending = state.extra["pending_prompt_update"] + assert pending["event_id"] == "ui-update-1" + assert pending["transition_chunks"] == 2 + assert torch.equal(pending["target_prompt_embeds"], torch.full((1, 4, 2), 2.0)) + + def test_runner_prompt_update_rejects_unsupported_pipeline(self) -> None: + """Runner rejects prompt updates when the pipeline lacks prompt-update support.""" + + class _UnsupportedPipeline: + supports_step_execution = True + + runner = _make_diffusion_model_runner(pipeline=_UnsupportedPipeline()) + with pytest.raises(ValueError, match="not supported"): + runner.submit_interaction("req-1", _prompt_interaction()) + + def test_runner_prompt_update_rejects_missing_request(self, pipeline) -> None: + """Runner rejects prompt updates when no active request state exists.""" + runner = _make_diffusion_model_runner(pipeline=pipeline) + with pytest.raises(ValueError, match="No active request state"): + runner.submit_interaction("missing", _prompt_interaction()) + + @pytest.mark.parametrize( + "interaction", + [ + {}, + {"multi_modal_data": {"camera": {"type": "pose"}}}, + {"event": {"prompt": "updated", "multi_modal_data": {"camera": {"type": "pose"}}}}, + ], + ) + def test_runner_interaction_rejects_structural_payloads_until_implemented( + self, + pipeline: HeliosPipeline, + interaction: dict[str, Any], + ) -> None: + """Unsupported interaction dict shapes are preserved to and rejected by the runner.""" + runner = _make_diffusion_model_runner(pipeline=pipeline) + runner.state_cache["req-1"] = _make_diffusion_request_state() + + with pytest.raises(NotImplementedError, match="Only text-only prompt update interactions"): + runner.submit_interaction("req-1", cast(Any, interaction)) + + def test_input_batch_refreshes_prompt_embeds_on_version_change(self) -> None: + """InputBatch rebuilds prompt_embeds when prompt_update_version changes.""" + state = StepRequestState( + request_id="req-1", + sampling=SimpleNamespace(), # pyright: ignore[reportArgumentType] + prompt="hello", + ) + state.prompt_embeds = torch.zeros(1, 2, 3) + state.latents = torch.zeros(1, 2) + state.timesteps = torch.tensor([1.0]) + state.extra["prompt_update_version"] = 0 + + batch = InputBatch.make_batch([state]) + assert torch.equal(batch.prompt_embeds, torch.zeros(1, 2, 3)) # pyright: ignore[reportArgumentType] + + state.prompt_embeds = torch.ones(1, 2, 3) + state.extra["prompt_update_version"] = 1 + refreshed = InputBatch.make_batch([state], cached_batch=batch) + assert torch.equal(refreshed.prompt_embeds, torch.ones(1, 2, 3)) # pyright: ignore[reportArgumentType] + + def test_helios_prepare_prompt_update_queues_pending_target(self, pipeline: HeliosPipeline) -> None: + """HeliosPipeline queues target embeds without mutating current prompt_embeds.""" + state = _make_diffusion_request_state() + + pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=2) + + pending = state.extra["pending_prompt_update"] + assert torch.equal(pending["target_prompt_embeds"], torch.full((1, 4, 2), 2.0)) + assert pending["transition_chunks"] == 2 + assert torch.equal(state.prompt_embeds, torch.zeros(1, 4, 2)) # pyright: ignore[reportArgumentType] + + def test_helios_prepare_prompt_update_rejects_before_initial_generation(self, pipeline: HeliosPipeline) -> None: + """Reject prompt updates submitted before initial prompt embeds exist.""" + state = _make_diffusion_request_state() + state.prompt_embeds = None + + with pytest.raises( + ValueError, + match="prompt_update is not allowed before initial generation has started", + ): + pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=2) + + assert "pending_prompt_update" not in state.extra + + def test_helios_apply_prompt_update_at_chunk_boundary_starts_transition(self, pipeline: HeliosPipeline) -> None: + """At chunk boundary, starts transition state and bumps prompt_update_version.""" + state = _make_diffusion_request_state() + pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=2) + + pipeline._apply_prompt_update_at_chunk_boundary(state) + + assert state.extra.get("prompt_update_state") is not None + assert torch.equal(state.prompt_embeds, torch.ones(1, 4, 2)) # pyright: ignore[reportArgumentType] + assert state.extra["prompt_update_version"] == 1 + assert state.extra["prompt_update_chunk_metadata"] == { + "started_event_ids": ["ui-update-1"], + "active_event_ids": ["ui-update-1"], + "completed_event_ids": [], + } + + def test_helios_apply_prompt_update_advances_transition_over_chunks(self, pipeline: HeliosPipeline) -> None: + """At chunk boundary, interpolates embeds until the target prompt is reached.""" + state = _make_diffusion_request_state() + pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=3) + pipeline._apply_prompt_update_at_chunk_boundary(state) + + assert torch.allclose(state.prompt_embeds, torch.full((1, 4, 2), 2.0 / 3.0)) # pyright: ignore[reportArgumentType] + + for _ in range(2): + pipeline._apply_prompt_update_at_chunk_boundary(state) + + assert torch.allclose(state.prompt_embeds, torch.full((1, 4, 2), 2.0)) # pyright: ignore[reportArgumentType] + assert state.extra["prompt_update_version"] == 3 + + # Further chunk boundaries after completion must not bump the version. + pipeline._apply_prompt_update_at_chunk_boundary(state) + assert state.extra["prompt_update_version"] == 3 + + +class TestPromptUpdateIntegration: + """AsyncOmni prompt update through orchestrator, inline client, and runner.""" + + @pytest.mark.asyncio + async def test_orchestrator_prompt_update_failure_surfaces_non_fatal_error( + self, + ) -> None: + """Worker-side prompt_update rejection is reported without crashing the orchestrator.""" + + class _RejectingStagePool: + async def submit_interaction(self, *args: Any, **kwargs: Any) -> None: + del args, kwargs + raise ValueError("prompt embeds are not ready") + + output_queue: asyncio.Queue[ErrorMessage] = asyncio.Queue() + orchestrator = object.__new__(Orchestrator) + orchestrator.stage_pools = [_RejectingStagePool()] # pyright: ignore[reportAttributeAccessIssue] + orchestrator.output_async_queue = output_queue # pyright: ignore[reportAttributeAccessIssue] + orchestrator.request_states = {"req-1": OrchestratorRequestState(request_id="req-1")} + + await orchestrator._handle_interaction( + InteractionMessage( + request_id="req-1", + interaction=_prompt_interaction("new scene", event_id="ui-update-1", transition_chunks=2), + ) + ) + + msg = output_queue.get_nowait() + assert msg == ErrorMessage( + error="Failed interaction for request req-1: prompt embeds are not ready", + fatal=False, + request_id="req-1", + event_id="ui-update-1", + stage_id=0, + ) + + @pytest.mark.asyncio + async def test_runner_prompt_update_failure_surfaces_non_fatal_error(self, pipeline: HeliosPipeline) -> None: + """Runner-side prepare_prompt_update rejection is reported through the orchestrator.""" + pipeline.prepare_prompt_update = MagicMock( # pyright: ignore[reportAttributeAccessIssue, reportMethodAssignment] + side_effect=ValueError("prompt embeds are not ready") + ) + runner = _make_diffusion_model_runner(pipeline=pipeline) + runner.state_cache["req-1"] = _make_diffusion_request_state(request_id="req-1") + prompt_update_engine = self._PromptUpdateEngine(self._IncrementalStreamingPipeline(), runner) + inline_client = self._make_inline_pipeline_client(prompt_update_engine) + + output_queue: asyncio.Queue[ErrorMessage] = asyncio.Queue() + orchestrator = object.__new__(Orchestrator) + orchestrator.stage_pools = [ # pyright: ignore[reportAttributeAccessIssue] + StagePool(0, cast(StagePoolClient, inline_client)) + ] + orchestrator.output_async_queue = output_queue # pyright: ignore[reportAttributeAccessIssue] + orchestrator.request_states = {"req-1": OrchestratorRequestState(request_id="req-1")} + + try: + await orchestrator._handle_interaction( + InteractionMessage( + request_id="req-1", + interaction=_prompt_interaction("new scene", event_id="ui-update-1", transition_chunks=2), + ) + ) + finally: + inline_client.shutdown() + + pipeline.prepare_prompt_update.assert_called_once_with( # pyright: ignore[reportAttributeAccessIssue] + runner.state_cache["req-1"], + "new scene", + "ui-update-1", + 2, + ) + msg = output_queue.get_nowait() + assert msg == ErrorMessage( + error="Failed interaction for request req-1: prompt embeds are not ready", + fatal=False, + request_id="req-1", + event_id="ui-update-1", + stage_id=0, + ) + + @pytest.mark.asyncio + async def test_prompt_update_reaches_runner_from_async_omni(self, pipeline: HeliosPipeline) -> None: + """Midway prompt update submitted via AsyncOmni reaches the diffusion runner.""" + streaming_pipeline = self._IncrementalStreamingPipeline() + runner = _make_diffusion_model_runner(pipeline=pipeline) + prompt_update_engine = self._PromptUpdateEngine(streaming_pipeline, runner) + inline_client = self._make_inline_pipeline_client(prompt_update_engine) + fixture = _build_harness([inline_client]) + omni = self._make_async_omni(self._OrchestratorBridgeEngine(fixture)) + + generate_task: asyncio.Task[list[OmniRequestOutput]] | None = None + try: + generate_task = asyncio.create_task(self._collect_generate_outputs(omni)) + + await _wait_for( + lambda: ( + len(runner.state_cache) > 0 + and any(state.external_request_id == "req-omni" for state in omni.request_states.values()) + ) + ) + internal_request_id = next(iter(runner.state_cache)) + + await omni.submit_interaction_async( + "req-omni", + interaction=_prompt_interaction("new scene", event_id="ui-update-1", transition_chunks=2), + ) + + outputs = await generate_task + + pending = runner.state_cache[internal_request_id].extra["pending_prompt_update"] + assert pending["transition_chunks"] == 2 + assert torch.equal(pending["target_prompt_embeds"], torch.full((1, 4, 2), 2.0)) + pipeline.encode_prompt.assert_called_once() # pyright: ignore[reportAttributeAccessIssue] + assert [output.custom_output["chunk"] for output in outputs] == [0, 1] + finally: + if generate_task is not None and not generate_task.done(): + generate_task.cancel() + await asyncio.gather(generate_task, return_exceptions=True) + await self._shutdown_pipeline_omni_harness(omni, fixture, inline_client) + + @staticmethod + async def _collect_generate_outputs(omni: AsyncOmni) -> list[OmniRequestOutput]: + outputs: list[OmniRequestOutput] = [] + async for output in omni.generate( + prompt={"prompt": "a cat"}, + request_id="req-omni", + sampling_params_list=[OmniDiffusionSamplingParams()], + output_modalities=["image"], + ): + outputs.append(output) + return outputs + + @classmethod + def _make_inline_pipeline_client(cls, engine: _PromptUpdateEngine) -> InlineStageDiffusionClient: + metadata = StageMetadata( + stage_id=0, + stage_type="diffusion", + engine_output_type="image", + is_comprehension=False, + requires_multimodal_data=False, + engine_input_source=[], + final_output=True, + final_output_type="image", + default_sampling_params=OmniDiffusionSamplingParams(), + custom_process_input_func=None, + model_stage=None, + runtime_cfg=None, + ) + with patch.object(InlineStageDiffusionClient, "_enrich_config"): + with patch( + "vllm_omni.diffusion.inline_stage_diffusion_client.DiffusionEngine.make_engine", + return_value=engine, + ): + od_config = MagicMock(spec=OmniDiffusionConfig) + od_config.streaming_output = True + return InlineStageDiffusionClient( + model="test_model", + od_config=od_config, + metadata=metadata, + batch_size=1, + ) + + @staticmethod + def _make_async_omni(engine: _OrchestratorBridgeEngine) -> AsyncOmni: + omni = object.__new__(AsyncOmni) + omni.engine = engine # pyright: ignore[reportAttributeAccessIssue] + omni.log_stats = False + omni._pause_cond = asyncio.Condition() + omni._paused = False + omni.request_states = {} + omni.final_output_task = None + omni.event_resolver = AsyncEventResolver() + omni._enable_ar_profiler = False + omni.prom_metrics = MagicMock() + omni.mod_metrics = MagicMock() + omni.resolve_sampling_params_list = lambda params, allow_delta_coercion: params # pyright: ignore[reportAttributeAccessIssue] + omni._compute_final_stage_id = lambda output_modalities: 0 + omni._compute_final_output_stage_ids = lambda output_modalities: [0] + omni.default_sampling_params_list = engine.default_sampling_params_list # pyright: ignore[reportAttributeAccessIssue] + omni._log_summary_and_cleanup = lambda request_id: omni.request_states.pop(request_id, None) # pyright: ignore[reportAttributeAccessIssue] + return omni + + @staticmethod + async def _shutdown_pipeline_omni_harness( + omni: AsyncOmni, + fixture: OrchestratorFixture, + inline_client: InlineStageDiffusionClient, + ) -> None: + if omni.final_output_task is not None: + omni.final_output_task.cancel() + await asyncio.gather(omni.final_output_task, return_exceptions=True) + inline_client.shutdown() + fixture.request_sync_q.put_nowait(ShutdownRequestMessage()) + await asyncio.to_thread(fixture.thread.join, 5) + + class _IncrementalStreamingPipeline: + """Returns one streaming chunk per ``step_outputs`` call.""" + + supports_step_execution = True + + def __init__(self) -> None: + self.requests: list[Any] = [] + self._step_index = 0 + + def step_outputs(self, request): + self.requests.append(request) + if self._step_index == 0: + self._step_index += 1 + return [DiffusionOutput(output={"chunk": 0}, finished=False)] + return [DiffusionOutput(output={"chunk": 1}, finished=True)] + + class _OrchestratorBridgeEngine: + """Minimal AsyncOmni engine facade backed by a live Orchestrator harness.""" + + def __init__(self, fixture: OrchestratorFixture) -> None: + self._fixture = fixture + self.stage_metadata = [ + StageRuntimeInfo( + stage_type="diffusion", + final_output=True, + final_output_type="image", + ) + ] + self.stage_configs: list[Any] = [SimpleNamespace(stage_type="diffusion")] + self.default_sampling_params_list = [OmniDiffusionSamplingParams()] + self.num_stages = 1 + self.supported_tasks = ("generate",) + self._alive = True + + async def add_request_async( + self, + *, + request_id: str, + prompt: Any, + sampling_params_list: list[Any], + final_stage_id: int, + **kwargs: Any, + ) -> None: + self._fixture.request_sync_q.put_nowait( + StageSubmissionMessage( + type="add_request", + request_id=request_id, + prompt=prompt, + original_prompt=prompt, + output_prompt_text=None, + sampling_params_list=sampling_params_list, + final_stage_id=final_stage_id, + final_output_stage_ids=kwargs.get("final_output_stage_ids"), + preprocess_ms=0.0, + request_timestamp=kwargs.get("arrival_time", time.time()), + enqueue_ts=time.perf_counter(), + ) + ) + + async def submit_interaction_async( + self, + request_id: str, + *, + interaction: dict[str, Any], + ) -> None: + self._fixture.request_sync_q.put_nowait( + InteractionMessage( + request_id=request_id, + interaction=cast(Any, interaction), + ) + ) + + async def try_get_output_async(self) -> Any | None: + try: + return self._fixture.output_sync_q.get_nowait() + except queue.Empty: + return None + + def get_stage_metadata(self, stage_id: int) -> StageRuntimeInfo: + return self.stage_metadata[stage_id] + + def is_alive(self) -> bool: + return self._fixture.thread.is_alive() + + async def abort_async(self, request_ids: list[str]) -> None: + del request_ids + + class _PromptUpdateEngine: + """DiffusionEngine stand-in that streams chunks and routes prompt_update to a runner.""" + + def __init__( + self, + streaming_pipeline: TestPromptUpdateIntegration._IncrementalStreamingPipeline, + runner: DiffusionModelRunner, + ) -> None: + self.streaming_pipeline = streaming_pipeline + self._runner = runner + self.executor = SimpleNamespace( + register_failure_callback=MagicMock(), + check_health=MagicMock(), + ) + + async def step_streaming(self, request): + if request.request_id not in self._runner.state_cache: + state = _make_diffusion_request_state(request_id=request.request_id) + self._runner.state_cache[request.request_id] = state + + state = self._runner.state_cache[request.request_id] + + outputs = self.streaming_pipeline.step_outputs(request) + output = outputs[0] + custom_output = cast(dict[str, Any], output.output) or {} + yield [ + OmniRequestOutput.from_diffusion( + request_id=request.request_id, + images=list(custom_output.get("images") or []), + custom_output=custom_output, + finished=output.finished, + ) + ] + + deadline = time.monotonic() + 5.0 + while "pending_prompt_update" not in state.extra: + if time.monotonic() >= deadline: + raise TimeoutError("timed out waiting for prompt update during streaming") + await asyncio.sleep(0.01) + + outputs = self.streaming_pipeline.step_outputs(request) + output = outputs[0] + custom_output = cast(dict[str, Any], output.output) or {} + yield [ + OmniRequestOutput.from_diffusion( + request_id=request.request_id, + images=list(custom_output.get("images") or []), + custom_output=custom_output, + finished=output.finished, + ) + ] + + def collective_rpc(self, method, timeout, args, kwargs, unique_reply_rank): + del timeout, kwargs, unique_reply_rank + if method == "submit_interaction": + request_id, interaction = args + self._runner.submit_interaction(request_id, interaction) + return None + raise NotImplementedError(f"collective_rpc not mocked for {method!r}") + + def abort(self, request_id: str) -> None: + del request_id diff --git a/tests/diffusion/test_result_pump.py b/tests/diffusion/test_result_pump.py new file mode 100644 index 000000000..fe49df47e --- /dev/null +++ b/tests/diffusion/test_result_pump.py @@ -0,0 +1,256 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for MultiprocDiffusionExecutor async result pump and wait_output_ready.""" + +import concurrent.futures +import queue +import threading +import time +from unittest.mock import MagicMock + +import pytest + +from vllm_omni.diffusion.data import AsyncDiffusionOutput, AsyncOutputKind, DiffusionOutput + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _make_executor(step_execution=False): + """Create a minimal MultiprocDiffusionExecutor-like object with pump state.""" + from vllm_omni.diffusion.executor.multiproc_executor import MultiprocDiffusionExecutor + + od_config = MagicMock() + od_config.step_execution = step_execution + + executor = object.__new__(MultiprocDiffusionExecutor) + executor.od_config = od_config + executor._rpc_id_counter = 0 + executor._rpc_id_lock = threading.Lock() + executor._rpc_futures = {} + executor._output_futures = {} + executor._completed_outputs = {} + executor._batch_split_map = {} + executor._futures_lock = threading.RLock() + executor._pump_running = False + executor._pump_stop = threading.Event() + executor._sync_result_buffer = queue.Queue() + executor._result_mq = MagicMock() + executor._broadcast_mq = MagicMock() + executor._closed = False + executor._is_failed = False + executor._finalizer = MagicMock() # no-op in tests + executor._shutdown_cleaner = None + executor._processes = [] + return executor + + +def _feed_one_msg_to_pump(executor, msg): + """Run _result_pump in a daemon thread, feed one *msg*, then stop.""" + call_count = [0] + + def mock_dequeue(timeout=None): + call_count[0] += 1 + if call_count[0] == 1: + return msg + executor._pump_stop.set() + time.sleep(0.05) + raise TimeoutError + + executor._result_mq.dequeue = mock_dequeue + t = threading.Thread(target=executor._result_pump, daemon=True) + t.start() + t.join(timeout=2.0) + + +@pytest.fixture(autouse=True) +def _mock_unpack(mocker): + """Real _result_pump calls unpack_diffusion_output_shm; mock it away.""" + mocker.patch( + "vllm_omni.diffusion.executor.multiproc_executor.unpack_diffusion_output_shm", + ) + + +class TestNextRpcId: + """Test _next_rpc_id counter.""" + + def test_counter_increments(self): + executor = _make_executor() + id1 = executor._next_rpc_id() + id2 = executor._next_rpc_id() + id3 = executor._next_rpc_id() + assert id1 == "1" + assert id2 == "2" + assert id3 == "3" + + def test_counter_is_threadsafe(self): + executor = _make_executor() + ids = [] + + def get_id(): + ids.append(executor._next_rpc_id()) + + threads = [threading.Thread(target=get_id) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + # All IDs should be unique + assert len(set(ids)) == 10 + + +class TestWaitOutputReady: + """Test wait_output_ready future creation and caching.""" + + def test_returns_new_future_when_not_cached(self): + executor = _make_executor() + fut = executor.wait_output_ready("abc123") + assert isinstance(fut, concurrent.futures.Future) + assert not fut.done() + + def test_future_resolves_when_output_arrives(self): + executor = _make_executor() + fut = executor.wait_output_ready("abc123") + output = DiffusionOutput(output="data") + + # Simulate pump resolving the future + with executor._futures_lock: + executor._output_futures.pop("abc123") + if not fut.done(): + fut.set_result(output) + + assert fut.result(timeout=1.0) is output + + def test_returns_cached_future_when_already_completed(self): + executor = _make_executor() + output = DiffusionOutput(output="cached_data") + fut = concurrent.futures.Future() + fut.set_result(output) + with executor._futures_lock: + executor._completed_outputs["abc123"] = fut + + fut = executor.wait_output_ready("abc123") + assert fut.done() + assert fut.result(timeout=1.0) is output + + def test_removes_from_cache_after_retrieval(self): + executor = _make_executor() + output = DiffusionOutput(output="cached_data") + with executor._futures_lock: + executor._completed_outputs["abc123"] = output + + executor.wait_output_ready("abc123") + # Second call should not find cached result + with executor._futures_lock: + assert "abc123" not in executor._completed_outputs + + +class TestResultPumpDispatch: + """Test _result_pump message routing (running the real pump in a thread).""" + + def test_non_async_message_placed_in_sync_buffer(self): + executor = _make_executor() + msg = DiffusionOutput(output="sync_result") + _feed_one_msg_to_pump(executor, msg) + + assert not executor._sync_result_buffer.empty() + retrieved = executor._sync_result_buffer.get_nowait() + assert isinstance(retrieved, DiffusionOutput) + + def test_compute_done_routes_to_rpc_future(self): + executor = _make_executor() + rpc_id = "42" + fut = concurrent.futures.Future() + with executor._futures_lock: + executor._rpc_futures[rpc_id] = fut + + msg = AsyncDiffusionOutput( + kind=AsyncOutputKind.COMPUTE_DONE, + rpc_id=rpc_id, + async_output_id="abc", + ) + _feed_one_msg_to_pump(executor, msg) + + assert fut.done() + result = fut.result(timeout=1.0) + assert result.kind == AsyncOutputKind.COMPUTE_DONE + + def test_output_ready_routes_to_output_future(self): + executor = _make_executor() + async_output_id = "abc123" + output = DiffusionOutput(output="final") + fut = concurrent.futures.Future() + with executor._futures_lock: + executor._output_futures[async_output_id] = fut + + msg = AsyncDiffusionOutput( + kind=AsyncOutputKind.OUTPUT_READY, + async_output_id=async_output_id, + output=output, + ) + _feed_one_msg_to_pump(executor, msg) + + assert fut.done() + assert fut.result(timeout=1.0) is output + + def test_output_ready_with_error_routes_to_future_as_exception(self): + executor = _make_executor() + async_output_id = "abc123" + fut = concurrent.futures.Future() + with executor._futures_lock: + executor._output_futures[async_output_id] = fut + + msg = AsyncDiffusionOutput( + kind=AsyncOutputKind.OUTPUT_READY, + async_output_id=async_output_id, + error="Background D2H/SHM packing failed", + ) + _feed_one_msg_to_pump(executor, msg) + + assert fut.done() + with pytest.raises(RuntimeError, match="Background D2H/SHM packing failed"): + fut.result(timeout=1.0) + + def test_output_ready_caches_when_no_future_waiting(self): + """When OUTPUT_READY arrives but no future is waiting, result is cached.""" + executor = _make_executor() + async_output_id = "abc123" + output = DiffusionOutput(output="orphan") + + msg = AsyncDiffusionOutput( + kind=AsyncOutputKind.OUTPUT_READY, + async_output_id=async_output_id, + output=output, + ) + _feed_one_msg_to_pump(executor, msg) + + # Later call to wait_output_ready should find it cached + fut = executor.wait_output_ready(async_output_id) + assert fut.done() + assert fut.result(timeout=1.0) is output + + +class TestShutdownCleansUpFutures: + """Test that shutdown cancels pending futures.""" + + def test_shutdown_sets_exception_on_pending_futures(self): + executor = _make_executor() + + rpc_fut = concurrent.futures.Future() + output_fut = concurrent.futures.Future() + with executor._futures_lock: + executor._rpc_futures["1"] = rpc_fut + executor._output_futures["abc"] = output_fut + + executor.shutdown() + + assert rpc_fut.done() + with pytest.raises(RuntimeError, match="Executor shut down"): + rpc_fut.result(timeout=1.0) + + assert output_fut.done() + with pytest.raises(RuntimeError, match="Executor shut down"): + output_fut.result(timeout=1.0) + + assert len(executor._rpc_futures) == 0 + assert len(executor._output_futures) == 0 diff --git a/tests/diffusion/test_stage_diffusion_proc.py b/tests/diffusion/test_stage_diffusion_proc.py index c96aa8ba1..697465097 100644 --- a/tests/diffusion/test_stage_diffusion_proc.py +++ b/tests/diffusion/test_stage_diffusion_proc.py @@ -27,7 +27,7 @@ DELAY_BASE = 0.01 class MockDiffusionEngine: - async def step(self, request): + async def step_streaming(self, request): def simulate_step_delay(height, width, num_inference_steps) -> float: return (height / BASE_HEIGHT) * (width / BASE_WIDTH) * (num_inference_steps / BASE_INFER_STEPS) @@ -37,7 +37,7 @@ class MockDiffusionEngine: ) delay = DELAY_BASE + delay_scale * DELAY_BASE await asyncio.sleep(delay) - return [MockOmniRequestOutput(request_id=request.request_id)] + yield [MockOmniRequestOutput(request_id=request.request_id)] @pytest.mark.asyncio diff --git a/tests/diffusion/test_worker_wrapper_base.py b/tests/diffusion/test_worker_wrapper_base.py index ba22743fc..170faec80 100644 --- a/tests/diffusion/test_worker_wrapper_base.py +++ b/tests/diffusion/test_worker_wrapper_base.py @@ -161,19 +161,6 @@ class TestWorkerWrapperBaseExtension: class TestWorkerWrapperBaseDelegation: """Test WorkerWrapperBase delegation to wrapped worker.""" - def test_generate_delegation(self, mocker: MockerFixture, mock_od_config): - """Test that generate() delegates to worker.generate().""" - mocker.patch.object(DiffusionWorker, "__init__", return_value=None) - wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker) - mock_output = mocker.Mock() - wrapper.worker.generate = mocker.Mock(return_value=mock_output) - - mock_requests = [mocker.Mock()] - result = wrapper.generate(mock_requests) - - wrapper.worker.generate.assert_called_once_with(mock_requests) - assert result == mock_output - def test_execute_model_delegation(self, mocker: MockerFixture, mock_od_config): """Test that execute_model() delegates to worker.execute_model().""" mocker.patch.object(DiffusionWorker, "__init__", return_value=None) @@ -181,25 +168,12 @@ class TestWorkerWrapperBaseDelegation: mock_output = mocker.Mock() wrapper.worker.execute_model = mocker.Mock(return_value=mock_output) - mock_reqs = [mocker.Mock()] - result = wrapper.execute_model(mock_reqs, mock_od_config) + mock_req = mocker.Mock() + result = wrapper.execute_model(mock_req, mock_od_config) - wrapper.worker.execute_model.assert_called_once_with(mock_reqs, mock_od_config, kv_prefetch_jobs=None) + wrapper.worker.execute_model.assert_called_once_with(mock_req, mock_od_config, kv_prefetch_job=None) assert result == mock_output - def test_load_weights_delegation(self, mocker: MockerFixture, mock_od_config): - """Test that load_weights() delegates to worker.load_weights().""" - mocker.patch.object(DiffusionWorker, "__init__", return_value=None) - wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker) - expected_result = {"weight1", "weight2"} - wrapper.worker.load_weights = mocker.Mock(return_value=expected_result) - - mock_weights = [("weight1", mocker.Mock()), ("weight2", mocker.Mock())] - result = wrapper.load_weights(mock_weights) - - wrapper.worker.load_weights.assert_called_once_with(mock_weights) - assert result == expected_result - def test_sleep_delegation(self, mocker: MockerFixture, mock_od_config): """Test that sleep() delegates to worker.sleep().""" mocker.patch.object(DiffusionWorker, "__init__", return_value=None) diff --git a/tests/diffusion/utils/test_flow_matching.py b/tests/diffusion/utils/test_flow_matching.py new file mode 100644 index 000000000..7f08bb624 --- /dev/null +++ b/tests/diffusion/utils/test_flow_matching.py @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import numpy as np +import pytest +import torch + +from vllm_omni.diffusion.utils import flow_matching + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + +def test_safe_linalg_solve_uses_torch_native_path(monkeypatch: pytest.MonkeyPatch): + matrix = torch.tensor([[3.0, 1.0], [1.0, 2.0]], dtype=torch.float32) + rhs = torch.tensor([9.0, 8.0], dtype=torch.float32) + expected = torch.tensor([2.0, 3.0], dtype=torch.float32) + + def fake_torch_solve(solve_matrix: torch.Tensor, solve_rhs: torch.Tensor) -> torch.Tensor: + assert torch.equal(solve_matrix, matrix) + assert torch.equal(solve_rhs, rhs) + return expected + + def fail_numpy(*_args, **_kwargs): + raise AssertionError("NumPy fallback should not run") + + monkeypatch.setattr(flow_matching.torch.linalg, "solve", fake_torch_solve) + monkeypatch.setattr(flow_matching.np.linalg, "solve", fail_numpy) + + result = flow_matching.safe_linalg_solve(matrix, rhs) + + assert result is expected + + +def test_safe_linalg_solve_falls_back_on_missing_cpu_lapack(monkeypatch: pytest.MonkeyPatch): + matrix = torch.tensor([[3.0, 1.0], [1.0, 2.0]], dtype=torch.float32) + rhs = torch.tensor([9.0, 8.0], dtype=torch.float32) + expected = np.array([2.0, 3.0], dtype=np.float32) + + def fail_with_missing_lapack(*_args, **_kwargs): + raise RuntimeError( + "Calling torch.linalg.lu_factor on a CPU tensor requires compiling PyTorch with LAPACK. " + "Please use PyTorch built with LAPACK support." + ) + + monkeypatch.setattr(flow_matching.torch.linalg, "solve", fail_with_missing_lapack) + monkeypatch.setattr(flow_matching.np.linalg, "solve", lambda *_args, **_kwargs: expected) + + result = flow_matching.safe_linalg_solve(matrix, rhs) + + assert torch.equal(result, torch.from_numpy(expected)) + assert result.dtype == matrix.dtype + assert result.device == matrix.device + + +def test_safe_linalg_solve_reraises_unrelated_runtime_error(monkeypatch: pytest.MonkeyPatch): + matrix = torch.tensor([[3.0, 1.0], [1.0, 2.0]], dtype=torch.float32) + rhs = torch.tensor([9.0, 8.0], dtype=torch.float32) + + def fail_with_unrelated_error(*_args, **_kwargs): + raise RuntimeError("solver exploded for another reason") + + def fail_numpy(*_args, **_kwargs): + raise AssertionError("NumPy fallback should not run") + + monkeypatch.setattr(flow_matching.torch.linalg, "solve", fail_with_unrelated_error) + monkeypatch.setattr(flow_matching.np.linalg, "solve", fail_numpy) + + with pytest.raises(RuntimeError, match="solver exploded for another reason"): + flow_matching.safe_linalg_solve(matrix, rhs) diff --git a/tests/distributed/omni_connectors/test_adapter_and_flow.py b/tests/distributed/omni_connectors/test_adapter_and_flow.py index 548d16eb6..1cabaf3b3 100644 --- a/tests/distributed/omni_connectors/test_adapter_and_flow.py +++ b/tests/distributed/omni_connectors/test_adapter_and_flow.py @@ -8,6 +8,7 @@ from vllm_omni.distributed.omni_connectors.adapter import try_recv_via_connector from vllm_omni.distributed.omni_connectors.connectors.shm_connector import SharedMemoryConnector from vllm_omni.distributed.omni_connectors.utils.config import ConnectorSpec, OmniTransferConfig from vllm_omni.distributed.omni_connectors.utils.initialization import get_connectors_config_for_stage +from vllm_omni.engine.stage_init_utils import get_stage_connector_spec pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -142,11 +143,10 @@ def test_recv_no_connector(): def test_shm_connector_flow(mocker: MockerFixture): """ Verify the full flow: Send -> Adapter -> Connector -> Adapter -> Recv. - Using real SharedMemoryConnector (inline mode for simplicity). + Using real SharedMemoryConnector and key-addressed SHM metadata. """ # 1. Setup Connector - config = {"shm_threshold_bytes": 1024, "inline_small_payloads": True} - connector = SharedMemoryConnector(config) + connector = SharedMemoryConnector({}) connectors_map = {("0", "1"): connector} # 2. Setup Data @@ -218,6 +218,39 @@ def test_get_connectors_for_stage(): assert stage_2_config["from_stage_1"]["spec"]["name"] == "C2" +@pytest.mark.parametrize("async_chunk", [False, True]) +def test_outgoing_stage_uses_sender_connector_spec(async_chunk: bool): + config = OmniTransferConfig( + connectors={ + ("1", "2"): ConnectorSpec( + name="SharedMemoryConnector", + extra={"codec_chunk_frames": 25}, + ) + } + ) + + spec = get_stage_connector_spec(config, stage_id=1, async_chunk=async_chunk) + + assert spec["name"] == "SharedMemoryConnector" + assert spec["extra"] == {"codec_chunk_frames": 25, "role": "sender"} + + +def test_full_payload_consumer_uses_receiver_connector_spec(): + config = OmniTransferConfig( + connectors={ + ("1", "2"): ConnectorSpec( + name="SharedMemoryConnector", + extra={"codec_chunk_frames": 25}, + ) + } + ) + + spec = get_stage_connector_spec(config, stage_id=2, async_chunk=False) + + assert spec["name"] == "SharedMemoryConnector" + assert spec["extra"] == {"codec_chunk_frames": 25, "role": "receiver"} + + def test_recv_with_missing_metadata(mocker: MockerFixture): """Test recv when queue payload is malformed (missing metadata).""" # Connector expects metadata but task doesn't have it diff --git a/tests/distributed/omni_connectors/test_bagel_shared_memory_connector.py b/tests/distributed/omni_connectors/test_bagel_shared_memory_connector.py index c21af394a..995835024 100644 --- a/tests/distributed/omni_connectors/test_bagel_shared_memory_connector.py +++ b/tests/distributed/omni_connectors/test_bagel_shared_memory_connector.py @@ -228,6 +228,7 @@ def _resolve_deploy_config(config_path: str, run_level: str) -> str: @pytest.mark.advanced_model +@pytest.mark.skip(reason="#issue 5475") @pytest.mark.diffusion @hardware_test(res={"cuda": "H100", "rocm": "MI325"}) def test_bagel_img2img_shared_memory_connector(run_level): diff --git a/tests/distributed/omni_connectors/test_basic_connectors.py b/tests/distributed/omni_connectors/test_basic_connectors.py index 1518e98b1..787d5594b 100644 --- a/tests/distributed/omni_connectors/test_basic_connectors.py +++ b/tests/distributed/omni_connectors/test_basic_connectors.py @@ -60,10 +60,9 @@ def test_ndarray_serialization(): def test_create_shm_connector(): """Test creating SharedMemoryConnector via Factory.""" - spec = ConnectorSpec(name="SharedMemoryConnector", extra={"shm_threshold_bytes": 1024}) + spec = ConnectorSpec(name="SharedMemoryConnector") connector = OmniConnectorFactory.create_connector(spec) assert isinstance(connector, SharedMemoryConnector) - assert connector.threshold == 1024 def test_create_unknown_connector(): @@ -75,21 +74,20 @@ def test_create_unknown_connector(): @pytest.fixture def shm_connector(): - config = {"shm_threshold_bytes": 100, "inline_small_payloads": True} - return SharedMemoryConnector(config) + connector = SharedMemoryConnector({}) + yield connector + connector.close() -def test_put_get_inline(shm_connector): - """Test inline transfer for small data.""" +def test_put_get_small_payload_uses_shm(shm_connector): + """Small payloads use key-addressed SHM; inline metadata is retired.""" data = {"small": "data"} success, size, metadata = shm_connector.put("stage_0", "stage_1", "req_1", data) assert success is True - assert "inline_bytes" in metadata - assert "shm" not in metadata + assert "shm" in metadata + assert "inline_bytes" not in metadata assert "size" in metadata - assert shm_connector._metrics["inline_writes"] == 1 - assert shm_connector._metrics["shm_writes"] == 0 # Retrieve retrieved_data, ret_size = shm_connector.get("stage_0", "stage_1", "req_1", metadata) @@ -195,3 +193,42 @@ def test_mooncake_connector_defaults_missing_host_to_detected_ip(monkeypatch: py assert connector.get_connection_info()["host"] == "10.20.30.40" finally: connector.close() + + +def test_mooncake_get_counts_zmq_timeout_separately(mocker: MockerFixture): + import vllm_omni.distributed.omni_connectors.connectors.mooncake_transfer_engine_connector as mooncake_module + + connector = object.__new__(mooncake_module.MooncakeTransferEngineConnector) + connector._closed = False + connector._metrics = {"errors": 0, "timeouts": 0} + connector._resolve_metadata = mocker.Mock( + return_value={"source_host": "127.0.0.1", "source_port": 1234, "data_size": 8} + ) + recv_buffer = mocker.Mock() + connector._alloc_recv_buffer = mocker.Mock(return_value=(recv_buffer, 1000)) + connector._request_transfer = mocker.Mock(side_effect=mooncake_module.zmq.Again()) + + assert connector.get("s0", "s1", "req") is None + assert connector._metrics == {"errors": 0, "timeouts": 1} + recv_buffer.release.assert_called_once() + connector._closed = True + + +def test_mooncake_get_counts_cuda_sync_failure_as_error(mocker: MockerFixture): + import vllm_omni.distributed.omni_connectors.connectors.mooncake_transfer_engine_connector as mooncake_module + + connector = object.__new__(mooncake_module.MooncakeTransferEngineConnector) + connector._closed = False + connector._metrics = {"errors": 0, "timeouts": 0} + connector._resolve_metadata = mocker.Mock( + return_value={"source_host": "127.0.0.1", "source_port": 1234, "data_size": 8} + ) + recv_buffer = mocker.Mock() + connector._alloc_recv_buffer = mocker.Mock(return_value=(recv_buffer, 1000)) + connector._request_transfer = mocker.Mock(return_value=mooncake_module.TRANS_DONE) + connector._sync_receive_buffer_if_needed = mocker.Mock(side_effect=RuntimeError("CUDA sync failed")) + + assert connector.get("s0", "s1", "req") is None + assert connector._metrics == {"errors": 1, "timeouts": 0} + recv_buffer.release.assert_called_once() + connector._closed = True diff --git a/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py b/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py index 9c6a86ed3..b1ff5db94 100644 --- a/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py +++ b/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py @@ -10,9 +10,12 @@ import pytest import torch from pytest_mock import MockerFixture from vllm.v1.core.sched.scheduler import Scheduler as VLLMScheduler +from vllm.v1.metrics.stats import PrefillStats, PromptTokenStats from vllm.v1.request import RequestStatus +from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler from vllm_omni.data_entry_keys import CodesStruct, MetaStruct, OmniPayload, OmniPayloadStruct +from vllm_omni.distributed.omni_connectors.adapter import construct_next_stage_streaming_input_prompt from vllm_omni.distributed.omni_connectors.transfer_adapter.base import OmniTransferAdapterBase from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import ( OmniChunkTransferAdapter, @@ -36,13 +39,66 @@ def _req(req_id: str, status: RequestStatus, external_req_id: str | None = None) external_req_id=external_req_id or req_id, status=status, prompt_token_ids=[], + num_prompt_tokens=0, num_computed_tokens=0, num_output_placeholders=0, + prefill_stats=None, additional_information=None, is_finished=lambda: status == RequestStatus.FINISHED_STOPPED, ) +def test_streaming_payload_can_replace_placeholder_prompt(mocker: MockerFixture) -> None: + request = SimpleNamespace( + _all_token_ids=[0, 0, 7, 8], + _output_token_ids=[7, 8], + prompt_token_ids=[0, 0], + num_computed_tokens=4, + num_prompt_tokens=2, + update_block_hashes=mocker.Mock(), + ) + payload = { + "ids": {"prompt": [1, 2, 3]}, + "meta": { + "replace_streaming_prompt": True, + "next_stage_prompt_len": 7, + }, + } + + construct_next_stage_streaming_input_prompt(payload, request) + + assert request.prompt_token_ids == [0] * 7 + assert request._all_token_ids == [0] * 7 + assert request._output_token_ids == [] + assert request.num_computed_tokens == 0 + assert request.num_prompt_tokens == 7 + request.update_block_hashes.assert_called_once_with() + + +def test_streaming_payload_can_append_exact_prompt_length(mocker: MockerFixture) -> None: + request = SimpleNamespace( + _all_token_ids=[0, 0, 7, 8], + _output_token_ids=[7, 8], + prompt_token_ids=[0, 0], + num_computed_tokens=4, + num_prompt_tokens=2, + update_block_hashes=mocker.Mock(), + ) + payload = { + "ids": {"prompt": [1, 2, 3]}, + "meta": {"next_stage_prompt_len": 3}, + } + + construct_next_stage_streaming_input_prompt(payload, request) + + assert request.prompt_token_ids == [0, 0, 7, 8, 0, 0, 0] + assert request._all_token_ids == [0, 0, 7, 8, 0, 0, 0] + assert request._output_token_ids == [] + assert request.num_computed_tokens == 4 + assert request.num_prompt_tokens == 7 + request.update_block_hashes.assert_called_once_with() + + @pytest.fixture def build_adapter(monkeypatch, mocker: MockerFixture): def _build( @@ -81,6 +137,10 @@ def build_adapter(monkeypatch, mocker: MockerFixture): worker_type=model_mode, max_num_seqs=max_num_seqs, active_stream_window=active_stream_window, + stage_connector_config={ + "name": "SharedMemoryConnector", + "extra": connector_extra or {}, + }, ) scheduler_config = SimpleNamespace(max_num_seqs=max_num_seqs) adapter = OmniChunkTransferAdapter( @@ -136,7 +196,7 @@ def test_load_poll(build_adapter): assert request.additional_information == payload assert adapter.get_req_chunk["req-1"] == 1 assert "req-1" in adapter._finished_load_reqs - assert "req-1" in adapter.finished_requests + assert "req-1" in adapter.upstream_exhausted_requests assert "req-1" not in adapter._pending_load_reqs @@ -220,6 +280,37 @@ def test_save_async_uses_confirmed_tokens_for_async_scheduler_watermark(build_ad assert len(adapter._pending_save_reqs) == 1 +def test_send_single_request_terminal_chunk_still_flushes_processor(build_adapter, monkeypatch): + """A terminal stop is not a segment boundary (#5383), but the producer-side + processor must still receive the flush signal on the terminal chunk. + Passing only ``is_segment_finished`` starved processors of their final + accumulated payload once terminal stops stopped setting it (#5413: the + downstream stage got ``meta.finished`` with the tail data missing). + """ + adapter, connector = build_adapter(stage_id=0) + request = _req("req-terminal", RequestStatus.FINISHED_STOPPED, external_req_id="ext-terminal") + + seen_flush_flags = [] + + def recording_processor(**kwargs): + seen_flush_flags.append(kwargs["is_finished"]) + return OmniPayloadStruct( + codes=CodesStruct(audio=torch.tensor([1, 2, 3], dtype=torch.long)), + ) + + adapter.custom_process_next_stage_input_func = recording_processor + monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None) + + adapter._send_single_request( + {"multimodal_output": None, "request": request, "is_finished": True, "is_segment_finished": False} + ) + + assert seen_flush_flags == [True] + sent_payload = connector.put.call_args.kwargs["data"] + assert bool(sent_payload.meta.finished.item()) is True + assert bool(sent_payload.meta.is_segment_finished.item()) is False + + def test_send_single_request_struct_without_meta_does_not_crash(build_adapter, monkeypatch): """Producer may return a struct with ``meta=None`` (e.g. payload that carries only ``embed`` or ``codes``). The sender's ``meta is not None`` @@ -282,6 +373,122 @@ def test_send_single_request_struct_preserves_segment_finished(build_adapter, mo assert sent_payload.meta.is_segment_finished.item() is True +def test_send_single_request_respects_processor_receiver_boundary(build_adapter, monkeypatch): + adapter, connector = build_adapter(stage_id=1) + request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream") + + adapter.custom_process_next_stage_input_func = lambda **kwargs: OmniPayloadStruct( + meta=MetaStruct(is_segment_finished=torch.tensor(False, dtype=torch.bool)) + ) + monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None) + + adapter._send_single_request( + {"multimodal_output": None, "request": request, "is_finished": False, "is_segment_finished": True} + ) + + sent_payload = connector.put.call_args.kwargs["data"] + assert sent_payload.meta.is_segment_finished.item() is False + + +def test_send_single_request_personaplex_pending_frame_is_not_segment_boundary( + build_adapter, +): + from vllm_omni.model_executor.stage_input_processors.personaplex import ( + talker2code2wav_async_chunk, + ) + + adapter, connector = build_adapter( + stage_id=0, + connector_extra={ + "initial_codec_chunk_frames": 1, + "codec_chunk_frames": 5, + }, + ) + request = _req( + "req-personaplex", + RequestStatus.WAITING, + external_req_id="ext-personaplex", + ) + request.resumable = True + request.additional_information = { + "codes": { + "audio": torch.arange(8, dtype=torch.long).reshape(1, 8), + } + } + adapter.custom_process_next_stage_input_func = talker2code2wav_async_chunk + + adapter._send_single_request( + { + "multimodal_output": None, + "request": request, + "is_finished": False, + "is_segment_finished": True, + } + ) + + sent_payload = connector.put.call_args.kwargs["data"] + assert sent_payload.codes is None + assert sent_payload.meta.finished.item() is False + assert sent_payload.meta.is_segment_finished.item() is False + + +def test_personaplex_sender_cleanup_drops_delayed_frame_state(build_adapter): + from vllm_omni.model_executor.stage_input_processors.personaplex import ( + talker2code2wav_async_chunk, + ) + + adapter, _ = build_adapter( + stage_id=0, + connector_extra={ + "initial_codec_chunk_frames": 1, + "codec_chunk_frames": 5, + }, + ) + request = _req( + "req-personaplex-first", + RequestStatus.WAITING, + external_req_id="ext-personaplex-reused", + ) + request.resumable = True + request.additional_information = { + "codes": { + "audio": torch.arange(8, dtype=torch.long).reshape(1, 8), + } + } + + first = talker2code2wav_async_chunk( + adapter, + multimodal_output=None, + request=request, + is_finished=True, + ) + assert first is not None + assert first.codes is None + + adapter.cleanup_sender(request.external_req_id) + + replacement = _req( + "req-personaplex-replacement", + RequestStatus.WAITING, + external_req_id=request.external_req_id, + ) + replacement.resumable = True + replacement.additional_information = { + "codes": { + "audio": torch.arange(8, 16, dtype=torch.long).reshape(1, 8), + } + } + second = talker2code2wav_async_chunk( + adapter, + multimodal_output=None, + request=replacement, + is_finished=True, + ) + + assert second is not None + assert second.codes is None + + def test_save_async_skips_stale_resumable_chunk_until_dedup_is_reset(build_adapter): adapter, _ = build_adapter(stage_id=1) request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream") @@ -355,7 +562,7 @@ def test_load_poll_non_ar_merges_into_existing_additional_information(build_adap assert request.additional_information["meta"]["phase"] == "decode" assert request.additional_information["kv_metadata"] == {"foo": "bar"} assert "req-non-ar" in adapter._finished_load_reqs - assert "req-non-ar" in adapter.finished_requests + assert "req-non-ar" in adapter.upstream_exhausted_requests def test_load_poll_ar_request_additional_information_concats_tensors(build_adapter): @@ -381,6 +588,68 @@ def test_load_poll_ar_request_additional_information_concats_tensors(build_adapt assert request.additional_information["meta"]["finished"].item() is True +def test_non_ar_poll_reinitializes_prefill_stats_for_later_chunks(build_adapter): + adapter, connector = build_adapter(stage_id=2, model_mode="generation") + request = _req("req-later-chunk", RequestStatus.WAITING, external_req_id="ext-later-chunk") + request.prefill_stats = PrefillStats() + adapter.request_ids_mapping[request.request_id] = request.external_req_id + + connector.get.return_value = ( + { + "codes": {"audio": torch.tensor([7, 8], dtype=torch.long)}, + "meta": {"finished": torch.tensor(False, dtype=torch.bool)}, + }, + 8, + ) + assert adapter._poll_single_request(request) is True + OmniGenerationScheduler._record_prefill_stats(request) + first_chunk_stats = request.prefill_stats + request.prefill_stats = None + + connector.get.return_value = ( + { + "codes": {"audio": torch.tensor([9, 10, 11], dtype=torch.long)}, + "meta": {"finished": torch.tensor(True, dtype=torch.bool)}, + }, + 8, + ) + assert adapter._poll_single_request(request) is True + assert isinstance(request.prefill_stats, PrefillStats) + OmniGenerationScheduler._record_prefill_stats(request) + second_chunk_stats = request.prefill_stats + + assert first_chunk_stats is not None + assert first_chunk_stats.num_prompt_tokens == 2 + assert second_chunk_stats is not None + assert second_chunk_stats.num_prompt_tokens == 3 + + prompt_token_stats = PromptTokenStats() + prompt_token_stats.update_from_output(first_chunk_stats) + prompt_token_stats.update_from_output(second_chunk_stats) + assert prompt_token_stats.total == 5 + assert prompt_token_stats.computed == 5 + + +def test_sender_only_adapter_does_not_park_or_clear_requests(build_adapter): + adapter, _ = build_adapter(stage_id=1, connector_extra={"role": "sender"}) + request = _req("req-1", RequestStatus.WAITING) + request.additional_information = {"tts_token_ids": torch.tensor([1])} + waiting_queue = DummyWaitingQueue([request]) + running_queue = [] + + adapter.load_async(request) + adapter.process_pending_chunks( + waiting_queue, + running_queue, + scheduler_requests={request.request_id: request}, + ) + + assert waiting_queue == [request] + assert request.status == RequestStatus.WAITING + assert request.additional_information["tts_token_ids"].item() == 1 + assert adapter._pending_load_reqs == deque() + + def test_process_and_restore_queues(build_adapter): adapter, _ = build_adapter(stage_id=1, max_num_seqs=8) waiting_req = _req("w1", RequestStatus.WAITING) @@ -403,7 +672,7 @@ def test_process_and_restore_queues(build_adapter): def test_fifo_promotion(build_adapter): - adapter, _ = build_adapter(stage_id=1, max_num_seqs=2, active_stream_window=2) + adapter, _ = build_adapter(stage_id=1, model_mode="generation", max_num_seqs=2, active_stream_window=2) reqs = [_req(f"req-{idx}", RequestStatus.WAITING) for idx in range(1, 5)] waiting_queue = DummyWaitingQueue(reqs) running_queue = [] @@ -411,16 +680,18 @@ def test_fifo_promotion(build_adapter): adapter.process_pending_chunks(waiting_queue, running_queue) assert list(adapter._active_streams) == ["req-1", "req-2"] - assert waiting_queue == reqs[2:] + assert waiting_queue == [] assert reqs[0].status == RequestStatus.WAITING_FOR_CHUNK assert reqs[1].status == RequestStatus.WAITING_FOR_CHUNK assert reqs[2].status == RequestStatus.WAITING - adapter.finished_requests.add("req-1") + # Ordinary completion calls cleanup_receiver(), not finish_requests() + # (the abort path) -- see test_omni_ar_scheduler_free_request_cleanup.py. # Eviction is deferred to postprocess_scheduler_output in the runtime path - # (commit c4d95fd9 — otherwise the terminal chunk deadlocks at c=8 K=2). - # Simulate it here so promotion can pick up the freed slot. - adapter._evict_finished_active_streams({"req-1"}) + # (commit c4d95fd9 -- otherwise the terminal chunk deadlocks at c=8 K=2). + # Simulate the restore step here so promotion can pick up the freed slot. + adapter.cleanup_receiver("req-1") + adapter.restore_queues(waiting_queue, running_queue) adapter.process_pending_chunks(waiting_queue, running_queue) assert list(adapter._active_streams) == ["req-2", "req-3"] @@ -429,6 +700,22 @@ def test_fifo_promotion(build_adapter): assert reqs[2].status == RequestStatus.WAITING_FOR_CHUNK +def test_non_active_waiting_request_is_held_off_scheduler(build_adapter): + adapter, _ = build_adapter(stage_id=2, max_num_seqs=2, active_stream_window=1) + active = _req("req-active", RequestStatus.WAITING) + non_active = _req("req-non-active", RequestStatus.WAITING) + waiting_queue = DummyWaitingQueue([active, non_active]) + running_queue = [] + + adapter.process_pending_chunks(waiting_queue, running_queue) + + assert waiting_queue == [] + assert active.status == RequestStatus.WAITING_FOR_CHUNK + assert non_active.status == RequestStatus.WAITING + assert list(adapter._pending_load_reqs) == [active] + assert list(adapter.waiting_for_chunk_waiting_requests) == [active, non_active] + + def test_legacy_k0(build_adapter): adapter, _ = build_adapter(stage_id=1, max_num_seqs=1, active_stream_window=0) waiting_req = _req("waiting", RequestStatus.WAITING) @@ -449,7 +736,7 @@ def test_legacy_k0(build_adapter): def test_finished_releases_slot(build_adapter): - adapter, _ = build_adapter(stage_id=1, max_num_seqs=1, active_stream_window=1) + adapter, _ = build_adapter(stage_id=1, model_mode="generation", max_num_seqs=1, active_stream_window=1) req_1 = _req("req-1", RequestStatus.WAITING) req_2 = _req("req-2", RequestStatus.WAITING) waiting_queue = DummyWaitingQueue([req_1, req_2]) @@ -457,12 +744,14 @@ def test_finished_releases_slot(build_adapter): adapter.process_pending_chunks(waiting_queue, running_queue) assert list(adapter._active_streams) == ["req-1"] - assert waiting_queue == [req_2] + assert waiting_queue == [] - adapter.finished_requests.add("req-1") - # Eviction is deferred to postprocess_scheduler_output in the runtime path - # (commit c4d95fd9). Simulate it so promotion can pick up the freed slot. - adapter._evict_finished_active_streams({"req-1"}) + # req-1 finishes ordinarily (see test_fifo_promotion for why this is + # cleanup_receiver(), not finish_requests()). Eviction is deferred to + # postprocess_scheduler_output in the runtime path (commit c4d95fd9); + # simulate the restore step so promotion can pick up the freed slot. + adapter.cleanup_receiver("req-1") + adapter.restore_queues(waiting_queue, running_queue) adapter.process_pending_chunks(waiting_queue, running_queue) assert list(adapter._active_streams) == ["req-2"] @@ -489,6 +778,79 @@ def test_postprocess_scheduler_output(build_adapter): assert adapter.requests_with_ready_chunks == {"leftover"} +@pytest.mark.parametrize("model_mode", ["ar", "generation"]) +def test_active_stream_window_stalls_lone_running_request_after_upstream_finishes(build_adapter, model_mode): + """Regression test for vllm-project/vllm-omni#5349. + + upstream_exhausted_requests means the upstream sent its terminal chunk, + not that this stage's own generation is done. A downstream stage can + still have a long decode ahead after its upstream finishes. Evicting on + that signal and then permanently denying re-admission wedges the + request out of running_queue forever, with no error. + + requests_with_ready_chunks is set up front so the request stays in + running_queue this tick instead of legitimately parking in + waiting_for_chunk_running_requests (a separate mechanism this test must + not conflate with the bug). + + Parametrized over both worker types: the real issue hit an "ar" stage + (Qwen3-Omni's talker); must not regress "generation" (Qwen3-TTS's + Code2Wav), which this feature was designed for. + """ + adapter, _ = build_adapter(stage_id=1, model_mode=model_mode, max_num_seqs=2, active_stream_window=2) + running_req = _req("req-1", RequestStatus.RUNNING) + running_queue = [running_req] + waiting_queue = DummyWaitingQueue([]) + + adapter._active_streams["req-1"] = running_req + adapter.requests_with_ready_chunks.add("req-1") + + # Upstream terminal chunk arrives; this stage hasn't finished. + adapter.upstream_exhausted_requests.add("req-1") + scheduler_output = SimpleNamespace( + scheduled_new_reqs=[], + scheduled_cached_reqs=SimpleNamespace(req_ids=["req-1"]), + ) + adapter.postprocess_scheduler_output(scheduler_output) + assert not running_req.is_finished() # sanity: not done + + # No competition for the slot; req-1 must stay schedulable. + adapter.process_pending_chunks(waiting_queue, running_queue) + + assert running_queue == [running_req], "req-1 must still be schedulable -- losing it here is #5349" + assert not adapter.waiting_for_chunk_running_requests + assert running_req not in adapter._held_non_active + assert list(adapter._active_streams) == ["req-1"] + + +def test_cleanup_receiver_releases_multiple_slots_in_sequence(build_adapter): + """Companion to test_fifo_promotion: after EACH of the K active streams + finishes (not just one), promotion of a new K-sized batch must still + work -- the window must not stay exhausted by stale entries. See + test_omni_ar_scheduler_free_request_cleanup.py for the scheduler-level + proof that ordinary completion calls cleanup_receiver(). + """ + adapter, _ = build_adapter(stage_id=1, model_mode="generation", max_num_seqs=2, active_stream_window=2) + reqs = [_req(f"req-{idx}", RequestStatus.WAITING) for idx in range(1, 5)] + waiting_queue = DummyWaitingQueue(reqs) + running_queue = [] + + adapter.process_pending_chunks(waiting_queue, running_queue) + assert list(adapter._active_streams) == ["req-1", "req-2"] + + adapter.cleanup_receiver("req-1") + adapter.cleanup_receiver("req-2") + # Eviction is deferred to postprocess_scheduler_output in the runtime + # path; simulate the restore step so promotion can pick up the freed + # slots (see test_fifo_promotion). + adapter.restore_queues(waiting_queue, running_queue) + adapter.process_pending_chunks(waiting_queue, running_queue) + + assert list(adapter._active_streams) == ["req-3", "req-4"], ( + "both freed slots must be reusable after ordinary completion" + ) + + # --------------------------------------------------------------- # Cleanup tests # --------------------------------------------------------------- @@ -496,7 +858,7 @@ def test_postprocess_scheduler_output(build_adapter): def _populate_adapter_state(adapter, req_id="req-1", ext_id="ext-1"): """Fill every per-request structure so cleanup can be verified.""" - adapter.finished_requests.add(req_id) + adapter.upstream_exhausted_requests.add(req_id) adapter._active_streams[req_id] = SimpleNamespace(request_id=req_id) adapter.get_req_chunk[req_id] = 3 adapter.requests_with_ready_chunks.add(req_id) @@ -517,7 +879,7 @@ def test_cleanup_clears_all_state(build_adapter): adapter.cleanup(req_id, ext_id) - assert req_id not in adapter.finished_requests + assert req_id not in adapter.upstream_exhausted_requests assert req_id not in adapter._active_streams assert req_id not in adapter.get_req_chunk assert req_id not in adapter.requests_with_ready_chunks @@ -570,7 +932,7 @@ def test_cleanup_request_id_reuse_not_polluted(build_adapter): adapter.cleanup(req_id, ext_id) - assert req_id not in adapter.finished_requests + assert req_id not in adapter.upstream_exhausted_requests assert req_id not in adapter.get_req_chunk @@ -596,7 +958,7 @@ def test_cleanup_only_affects_target_request(build_adapter): adapter.cleanup("req-a", "ext-a") - assert "req-b" in adapter.finished_requests + assert "req-b" in adapter.upstream_exhausted_requests assert "req-b" in adapter.get_req_chunk assert "ext-b" in adapter.put_req_chunk assert "ext-b" in adapter.request_payload @@ -619,13 +981,13 @@ def test_cleanup_after_poll_flow(build_adapter): connector.get.return_value = (payload, 8) adapter._poll_single_request(request) - assert "req-flow" in adapter.finished_requests + assert "req-flow" in adapter.upstream_exhausted_requests assert adapter.get_req_chunk["req-flow"] == 1 assert "req-flow" in adapter.request_ids_mapping adapter.cleanup("req-flow", "ext-flow") - assert "req-flow" not in adapter.finished_requests + assert "req-flow" not in adapter.upstream_exhausted_requests assert "req-flow" not in adapter.get_req_chunk assert "req-flow" not in adapter.request_ids_mapping assert "ext-flow" not in adapter.request_payload @@ -659,7 +1021,7 @@ def test_finish_requests_removes_zombies_from_chunk_waiting_deques(build_adapter adapter.waiting_for_chunk_waiting_requests = deque([zombie, other]) adapter.waiting_for_chunk_running_requests = deque([other, zombie]) adapter.requests_with_ready_chunks.add("req-zombie") - adapter.finished_requests.add("req-zombie") + adapter.upstream_exhausted_requests.add("req-zombie") requests_map = { "req-zombie": zombie, "req-live": other, @@ -674,7 +1036,29 @@ def test_finish_requests_removes_zombies_from_chunk_waiting_deques(build_adapter assert [req.request_id for req in adapter.waiting_for_chunk_waiting_requests] == ["req-live"] assert [req.request_id for req in adapter.waiting_for_chunk_running_requests] == ["req-live"] assert "req-zombie" not in adapter.requests_with_ready_chunks - assert "req-zombie" not in adapter.finished_requests + assert "req-zombie" not in adapter.upstream_exhausted_requests + + +def test_finish_requests_releases_active_stream_slot(build_adapter): + adapter, _ = build_adapter(stage_id=1, max_num_seqs=1, active_stream_window=1) + aborted = _req("req-aborted", RequestStatus.RUNNING) + waiting = _req("req-waiting", RequestStatus.WAITING) + waiting_queue = DummyWaitingQueue([waiting]) + running_queue = [] + adapter._active_streams[aborted.request_id] = aborted + adapter._held_non_active.append(aborted) + + adapter.finish_requests( + [aborted.request_id], + RequestStatus.FINISHED_ABORTED, + {aborted.request_id: aborted}, + ) + adapter.process_pending_chunks(waiting_queue, running_queue) + + assert aborted.request_id not in adapter._active_streams + assert [request.request_id for request in adapter._held_non_active] == [] + assert list(adapter._active_streams) == [waiting.request_id] + assert waiting.status == RequestStatus.WAITING_FOR_CHUNK def test_restore_queues_skips_requests_missing_from_scheduler_requests(build_adapter): @@ -706,6 +1090,10 @@ def test_restore_queues_skips_requests_missing_from_scheduler_requests(build_ada class _HashableRequest(SimpleNamespace): """SimpleNamespace that can be added to a set (needed by scheduler internals).""" + # vLLM 0.26: update_from_output settles this counter for every scheduled + # request; real Requests initialise it to 0 (vllm/v1/request.py). + num_in_flight_tokens = 0 + def __hash__(self): return hash(self.request_id) @@ -718,7 +1106,7 @@ def test_generation_scheduler_calls_cleanup_on_finished(monkeypatch, mocker: Moc cleanup_calls = [] adapter_mock = mocker.MagicMock() - adapter_mock.finished_requests = {"req-s1"} + adapter_mock.upstream_exhausted_requests = {"req-s1"} adapter_mock.cleanup = lambda *a, **kw: cleanup_calls.append((a, kw)) from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler @@ -759,7 +1147,7 @@ def test_generation_scheduler_calls_cleanup_on_finished(monkeypatch, mocker: Moc scheduler.requests = {"req-s1": request} scheduler._handle_stopped_request = mocker.MagicMock(return_value=True) - scheduler._free_request = mocker.MagicMock(return_value=None) + scheduler._free_request = mocker.MagicMock(return_value=(None, None)) scheduler._get_routed_experts = mocker.MagicMock(return_value=None) scheduler.running = [request] scheduler.waiting = mocker.MagicMock() @@ -844,7 +1232,7 @@ def test_ar_scheduler_defers_cleanup_and_queues_save_on_finished(mocker: MockerF scheduler._update_request_with_output = mocker.MagicMock(return_value=([], True)) scheduler._process_kv_transfer_trigger = mocker.MagicMock(return_value=False) scheduler._handle_stopped_request = mocker.MagicMock(return_value=True) - scheduler._free_request = mocker.MagicMock(return_value=None) + scheduler._free_request = mocker.MagicMock(return_value=(None, None)) scheduler._get_routed_experts = mocker.MagicMock(return_value=None) scheduler.running = [request] scheduler.waiting = mocker.MagicMock() @@ -954,7 +1342,7 @@ def test_wire_round_trip_struct_to_dict_contract(): def _build_deferred_finish_scheduler(mocker, *, running, pending_finish_reqs): """Build a mock scheduler with requests queued for deferred finish.""" adapter_mock = mocker.MagicMock() - adapter_mock.finished_requests = {r.request_id for r in pending_finish_reqs} + adapter_mock.upstream_exhausted_requests = {r.request_id for r in pending_finish_reqs} cleanup_calls = [] adapter_mock.cleanup = lambda *a, **kw: cleanup_calls.append((a, kw)) @@ -973,7 +1361,7 @@ def _build_deferred_finish_scheduler(mocker, *, running, pending_finish_reqs): scheduler._pending_finish_reqs = list(pending_finish_reqs) scheduler._handle_stopped_request = mocker.MagicMock(return_value=True) - scheduler._free_request = mocker.MagicMock(return_value=None) + scheduler._free_request = mocker.MagicMock(return_value=(None, None)) scheduler._get_routed_experts = mocker.MagicMock(return_value=None) scheduler.running = list(running) scheduler.waiting = mocker.MagicMock() @@ -1029,7 +1417,7 @@ def test_deferred_finish_emits_finished_output(mocker: MockerFixture): running=[request], pending_finish_reqs=[request], ) - scheduler._free_request.return_value = {"mock": "kv_params"} + scheduler._free_request.return_value = ({"mock": "kv_params"}, None) result = OmniGenerationScheduler.update_from_output(scheduler, sched_out, model_out) diff --git a/tests/distributed/omni_connectors/test_kv_flow.py b/tests/distributed/omni_connectors/test_kv_flow.py index 83206fefb..b738c095e 100644 --- a/tests/distributed/omni_connectors/test_kv_flow.py +++ b/tests/distributed/omni_connectors/test_kv_flow.py @@ -330,6 +330,31 @@ def test_normalize_layer_kv_rejects_invalid_inputs(kv_config, common_constants, assert normalized is None +def test_normalize_layer_kv_unpacks_packed_layout(common_constants): + """The vLLM >= 0.23 packed layout (num_blocks, n_heads, block_size, + 2*head_dim) is unpacked into (num_blocks, block_size, n_heads, head_dim) + key/value blocks when block_size is provided.""" + block_size = common_constants["block_size"] + num_heads = common_constants["num_heads"] + head_dim = common_constants["head_dim"] + req_id = common_constants["req_id"] + num_blocks = 10 + + layer_kv = torch.randn(num_blocks, num_heads, block_size, 2 * head_dim) + + # Without block_size the 4-D packed layout is not recognized. + assert normalize_layer_kv(layer_kv, req_id=req_id, layer_idx=0) is None + + normalized = normalize_layer_kv(layer_kv, req_id=req_id, layer_idx=0, block_size=block_size) + assert normalized is not None + key_blocks, value_blocks = normalized + assert key_blocks.shape == (num_blocks, block_size, num_heads, head_dim) + assert value_blocks.shape == (num_blocks, block_size, num_heads, head_dim) + reference = layer_kv.transpose(1, 2) + assert torch.equal(key_blocks, reference[..., :head_dim]) + assert torch.equal(value_blocks, reference[..., head_dim:]) + + def test_manager_reception(kv_config, mock_connector, common_constants): """Test reception and injection logic in OmniKVTransferManager.""" num_layers = common_constants["num_layers"] diff --git a/tests/distributed/omni_connectors/test_kv_recv_tp_consensus.py b/tests/distributed/omni_connectors/test_kv_recv_tp_consensus.py new file mode 100644 index 000000000..a7e4b8562 --- /dev/null +++ b/tests/distributed/omni_connectors/test_kv_recv_tp_consensus.py @@ -0,0 +1,257 @@ +"""Pure-TP KV receive consensus (#5627) — CPU-testable mechanism. + +In pure TP (LOCAL role) each rank fetches its own KV shard independently and +``distribute_kv_cache`` is a no-op, so one rank's silent receive timeout used +to send only that rank into the pipeline's no-KV prefill — desyncing TP +collectives until the NCCL watchdog killed the worker. These tests cover the +identical-state verdict exchange: skip guards, both rank roles, KV dropping +on any cross-rank divergence (primary or CFG companion roles), that the +exchange runs on the TP group and never on ``world`` (which also holds DP/PP +ranks working on other requests), and the wiring from both distributed-receive +entry points. +""" + +from types import SimpleNamespace + +import pytest + +from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID +from vllm_omni.distributed.omni_connectors.kv_transfer_manager import ( + OmniKVCacheConfig, + OmniKVTransferManager, + ReceiveRole, + _TransferTopoConfig, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _FakeGroup: + """Scripted stand-in for one rank's view of a GroupCoordinator.""" + + def __init__(self, rank_in_group, world_size, peer_signatures=None, verdict=None): + self.rank_in_group = rank_in_group + self.world_size = world_size + self._peer_signatures = list(peer_signatures or []) + self._verdict = verdict + self.sent = [] + self.broadcasts = [] + + def recv_object(self, src): + return self._peer_signatures.pop(0) + + def send_object(self, obj, dst): + self.sent.append((obj, dst)) + + def broadcast_object(self, obj=None, src=0): + self.broadcasts.append(obj) + return obj if obj is not None else self._verdict + + @property + def touched(self): + return bool(self.sent or self.broadcasts) + + +def _topo(role=ReceiveRole.LOCAL, tp_active=True, tp_group=None, world=None): + tp_size = tp_group.world_size if tp_group is not None else 1 + return _TransferTopoConfig( + role=role, + tp_active=tp_active, + cfg_size=1, + cfg_rank=0, + cfg_group=None, + sp_size=1, + sp_rank=0, + sp_group=None, + world=world, + tp_group=tp_group, + tp_size=tp_size, + ) + + +def _manager(topo, need_recv_cache=True): + mgr = OmniKVTransferManager(OmniKVCacheConfig(need_recv_cache=need_recv_cache)) + mgr._topo_config = topo + return mgr + + +def _req_with_kv(rid="r1", cfg_text=False): + kv = SimpleNamespace(key_cache=[], value_cache=[]) + sp = SimpleNamespace(past_key_values=kv, kv_metadata={"ropes": [3]}) + if cfg_text: + sp.cfg_text_past_key_values = SimpleNamespace(key_cache=[], value_cache=[]) + return SimpleNamespace( + request_id=rid, + past_key_values=kv, + kv_metadata={"ropes": [3]}, + sampling_params=sp, + ) + + +def _signature_of(req, received=True): + return (received, tuple(sorted(OmniKVTransferManager._collect_request_kv_payload(req)))) + + +# --------------------------------------------------------------------------- # +# Skip guards — no exchange may happen (a one-sided exchange would deadlock) +# --------------------------------------------------------------------------- # + + +def test_pass_through_outside_pure_tp(): + group = _FakeGroup(0, 2, peer_signatures=[(False, ())]) + for topo in ( + _topo(role=ReceiveRole.LEADER, tp_active=True, tp_group=group), + _topo(role=ReceiveRole.LOCAL, tp_active=False, tp_group=group), + _topo(role=ReceiveRole.LOCAL, tp_active=True, tp_group=None), + ): + mgr = _manager(topo) + assert mgr._tp_local_receive_consensus(_req_with_kv(), True) is True + assert not group.touched + + +def test_pass_through_single_rank_group_no_recv_config_and_dummy(): + group = _FakeGroup(0, 1) + assert _manager(_topo(tp_group=group))._tp_local_receive_consensus(_req_with_kv(), True) is True + + group2 = _FakeGroup(0, 2, peer_signatures=[(False, ())]) + mgr = _manager(_topo(tp_group=group2), need_recv_cache=False) + assert mgr._tp_local_receive_consensus(_req_with_kv(), True) is True + + mgr3 = _manager(_topo(tp_group=group2)) + dummy = _req_with_kv(rid=DUMMY_DIFFUSION_REQUEST_ID) + assert mgr3._tp_local_receive_consensus(dummy, False) is False + assert not group2.touched + + +def test_world_group_is_never_used_for_the_exchange(): + """DP replicas / PP stages live in ``world`` and work on other requests — + the exchange must be scoped to the TP group (review #5636, P1).""" + tp_group = _FakeGroup(0, 2, peer_signatures=[(True, ())]) + world = _FakeGroup(0, 4, peer_signatures=[(False, ()), (False, ()), (False, ())]) + mgr = _manager(_topo(tp_group=tp_group, world=world)) + req = SimpleNamespace(request_id="r1", sampling_params=SimpleNamespace()) + + assert mgr._tp_local_receive_consensus(req, True) is True + assert tp_group.touched + assert not world.touched + + +# --------------------------------------------------------------------------- # +# Rank 0: collects signatures, decides, broadcasts +# --------------------------------------------------------------------------- # + + +def test_rank0_drops_kv_when_peer_missed_primary(): + req = _req_with_kv() + peer_sig = (False, ()) + group = _FakeGroup(0, 2, peer_signatures=[peer_sig]) + mgr = _manager(_topo(tp_group=group)) + + assert mgr._tp_local_receive_consensus(req, True) is False + assert group.broadcasts == [False] # peers were told to fall back too + assert req.past_key_values is None + assert req.kv_metadata is None + assert req.sampling_params.past_key_values is None + assert req.sampling_params.kv_metadata is None + + +def test_rank0_keeps_kv_when_all_ranks_match(): + req = _req_with_kv() + group = _FakeGroup(0, 2, peer_signatures=[_signature_of(req)]) + mgr = _manager(_topo(tp_group=group)) + + assert mgr._tp_local_receive_consensus(req, True) is True + assert group.broadcasts == [True] + assert req.past_key_values is not None + assert req.sampling_params.past_key_values is not None + + +def test_rank0_drops_kv_on_companion_role_divergence(): + """Primary received everywhere but cfg_text only landed on this rank — + divergent branch caches would desync TP collectives (review #5636, P1).""" + req = _req_with_kv(cfg_text=True) + peer_sig = _signature_of(_req_with_kv(cfg_text=False)) # peer lacks cfg_text + group = _FakeGroup(0, 2, peer_signatures=[peer_sig]) + mgr = _manager(_topo(tp_group=group)) + + assert mgr._tp_local_receive_consensus(req, True) is False + assert group.broadcasts == [False] + assert req.past_key_values is None + assert req.sampling_params.cfg_text_past_key_values is None + + +def test_rank0_keeps_symmetric_partial_state(): + """cfg_text absent on every rank is consistent — Bagel's empty + unconditional branch — and must NOT be cleared or failed.""" + req = _req_with_kv(cfg_text=False) + group = _FakeGroup(0, 2, peer_signatures=[_signature_of(req)]) + mgr = _manager(_topo(tp_group=group)) + + assert mgr._tp_local_receive_consensus(req, True) is True + assert req.past_key_values is not None + + +def test_rank0_own_miss_fails_verdict_even_if_peers_received(): + req = SimpleNamespace(request_id="r1", sampling_params=SimpleNamespace()) + group = _FakeGroup(0, 3, peer_signatures=[(True, ("past_key_values",)), (True, ("past_key_values",))]) + mgr = _manager(_topo(tp_group=group)) + assert mgr._tp_local_receive_consensus(req, False) is False + assert group.broadcasts == [False] + + +# --------------------------------------------------------------------------- # +# Rank > 0: reports signature, honors verdict +# --------------------------------------------------------------------------- # + + +def test_follower_rank_drops_kv_on_negative_verdict(): + req = _req_with_kv() + sig_before_clear = _signature_of(req) + group = _FakeGroup(1, 2, verdict=False) + mgr = _manager(_topo(tp_group=group)) + + assert mgr._tp_local_receive_consensus(req, True) is False + assert group.sent == [(sig_before_clear, 0)] + assert req.past_key_values is None + assert req.sampling_params.past_key_values is None + + +def test_follower_rank_keeps_kv_on_positive_verdict(): + req = _req_with_kv() + group = _FakeGroup(1, 2, verdict=True) + mgr = _manager(_topo(tp_group=group)) + + assert mgr._tp_local_receive_consensus(req, True) is True + assert group.sent == [(_signature_of(req), 0)] + assert req.past_key_values is not None + + +# --------------------------------------------------------------------------- # +# Wiring — both distributed-receive entry points run the consensus +# --------------------------------------------------------------------------- # + + +def _wired_manager(monkeypatch, consensus_calls): + mgr = _manager(_topo(tp_group=_FakeGroup(0, 2))) + + def fake_consensus(req, received): + consensus_calls.append(received) + return False + + monkeypatch.setattr(mgr, "_tp_local_receive_consensus", fake_consensus) + monkeypatch.setattr(mgr, "receive_multi_kv_cache", lambda *a, **k: True) + return mgr + + +def test_receive_multi_kv_cache_distributed_runs_consensus(monkeypatch): + calls = [] + mgr = _wired_manager(monkeypatch, calls) + assert mgr.receive_multi_kv_cache_distributed(_req_with_kv()) is False + assert calls == [True] + + +def test_consume_and_distribute_kv_cache_runs_consensus(monkeypatch): + calls = [] + mgr = _wired_manager(monkeypatch, calls) + assert mgr.consume_and_distribute_kv_cache(_req_with_kv()) is False + assert calls == [True] diff --git a/tests/distributed/omni_connectors/test_omni_connector_configs.py b/tests/distributed/omni_connectors/test_omni_connector_configs.py index 3dc80b57b..6048de40a 100644 --- a/tests/distributed/omni_connectors/test_omni_connector_configs.py +++ b/tests/distributed/omni_connectors/test_omni_connector_configs.py @@ -2,10 +2,15 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from pathlib import Path +from types import SimpleNamespace import pytest # Use the new import path for initialization utilities +from vllm_omni.distributed.omni_connectors.utils.config import ( + stage_receives_chunks, + stage_sends_async_output, +) from vllm_omni.distributed.omni_connectors.utils.initialization import load_omni_transfer_config pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -38,6 +43,26 @@ def get_config_files(): config_files = get_config_files() +@pytest.mark.parametrize( + ("role", "stage_id", "receives", "sends"), + [ + ("sender", 0, False, True), + ("receiver", 1, True, False), + (None, 0, True, False), + (None, None, True, True), + ], +) +def test_stage_chunk_direction_helpers(role, stage_id, receives, sends): + extra = {} if role is None else {"role": role} + model_config = SimpleNamespace( + stage_id=stage_id, + stage_connector_config={"extra": extra}, + ) + + assert stage_receives_chunks(model_config) is receives + assert stage_sends_async_output(model_config) is sends + + @pytest.mark.skipif(len(config_files) == 0, reason="No config files found or directory missing") @pytest.mark.parametrize("yaml_file", config_files, ids=lambda p: p.name) def test_load_qwen_yaml_configs(yaml_file): @@ -48,7 +73,6 @@ def test_load_qwen_yaml_configs(yaml_file): print(f"Testing config load: {yaml_file.name}") try: # Attempt to load the config - # default_shm_threshold doesn't matter much for loading correctness, using default config = load_omni_transfer_config(yaml_file) assert config is not None, "Config should not be None" diff --git a/tests/distributed/omni_connectors/test_shm_connector.py b/tests/distributed/omni_connectors/test_shm_connector.py index 572376aef..bba6002a0 100644 --- a/tests/distributed/omni_connectors/test_shm_connector.py +++ b/tests/distributed/omni_connectors/test_shm_connector.py @@ -2,7 +2,10 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Unit tests for SharedMemoryConnector focusing on TP / CFG / metadata fallback.""" +import os + import pytest +import torch from vllm_omni.distributed.omni_connectors.connectors.shm_connector import ( SharedMemoryConnector, @@ -13,7 +16,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @pytest.fixture() def connector(): - c = SharedMemoryConnector({"shm_threshold_bytes": 0}) + c = SharedMemoryConnector({}) yield c c.close() @@ -36,6 +39,30 @@ class TestKeyBasedReadWrite: assert obj == data assert rsize == size assert "test_key_1" not in connector._pending_keys + assert connector._metrics["gets"] == 1 + + def test_tensor_payload_removes_lock_file(self, connector): + key = "tensor_payload" + payload = torch.ones(2, 2) + ok, _, metadata = connector.put("s0", "s1", key, payload) + assert ok + + result = connector.get("s0", "s1", key, metadata=metadata) + + assert result is not None + assert torch.equal(result[0], payload) + assert not os.path.exists(f"/dev/shm/shm_{key}_lockfile.lock") + + def test_falsey_payload_removes_lock_file(self, connector): + key = "falsey_payload" + ok, _, metadata = connector.put("s0", "s1", key, 0) + assert ok + + result = connector.get("s0", "s1", key, metadata=metadata) + + assert result is not None + assert result[0] == 0 + assert not os.path.exists(f"/dev/shm/shm_{key}_lockfile.lock") def test_get_nonexistent_key_returns_none(self, connector): result = connector.get("s0", "s1", "no_such_key_xyz", metadata=None) diff --git a/tests/distributed/omni_connectors/test_tp_rank_aware.py b/tests/distributed/omni_connectors/test_tp_rank_aware.py index 261de0b32..3fdc4820a 100644 --- a/tests/distributed/omni_connectors/test_tp_rank_aware.py +++ b/tests/distributed/omni_connectors/test_tp_rank_aware.py @@ -682,6 +682,10 @@ class TestDistributedReceive: mgr = _make_manager(from_tp=2, to_tp=2, local_rank=1) req = SimpleNamespace(request_id="req-1", sampling_params=SimpleNamespace()) world_group = _MockBroadcastGroup(world_size=2, rank_in_group=1) + # broadcast_value=True: rank 0's verdict says every TP rank has the + # same KV state, so the per-rank receive result stands (see #5627 + # consensus). The exchange runs on the TP group, not world. + tp_group = _MockBroadcastGroup(world_size=2, rank_in_group=1, broadcast_value=True) mgr.receive_multi_kv_cache = MagicMock(return_value=True) with ( @@ -704,10 +708,19 @@ class TestDistributedReceive: return_value=0, ), patch("vllm_omni.diffusion.distributed.parallel_state.get_sp_group", return_value=None), + patch("vllm.distributed.parallel_state.get_tp_group", return_value=tp_group), ): assert mgr.receive_multi_kv_cache_distributed(req, target_device=torch.device("cpu")) is True mgr.receive_multi_kv_cache.assert_called_once_with(req, None, torch.device("cpu")) + # Pure TP still fetches per-rank (no leader→follower distribution), + # but each rank now reports its KV-state signature on the TP group + # for the identical-state verdict instead of silently diverging on a + # miss (#5627). World must stay untouched — it holds DP/PP ranks. + assert len(tp_group.send_calls) == 1 + dst, sig = tp_group.send_calls[0] + assert dst == 0 and sig[0] is True + assert world_group.send_calls == [] # ── SP-only scenarios ──────────────────────────────────────────── diff --git a/tests/docs/test_generate_examples.py b/tests/docs/test_generate_examples.py index 0b29837a9..57d7d4832 100644 --- a/tests/docs/test_generate_examples.py +++ b/tests/docs/test_generate_examples.py @@ -6,6 +6,9 @@ from pathlib import Path import pytest +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + ROOT_DIR = Path(__file__).parents[2] HOOK_PATH = ROOT_DIR / "docs/mkdocs/hooks/generate_examples.py" SPEC = importlib.util.spec_from_file_location("generate_examples", HOOK_PATH) diff --git a/tests/dreamzero/openpi_client_helper.py b/tests/dreamzero/openpi_client_helper.py deleted file mode 100644 index 8918e5c36..000000000 --- a/tests/dreamzero/openpi_client_helper.py +++ /dev/null @@ -1,307 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -from __future__ import annotations - -import uuid -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import numpy as np - -try: - import cv2 -except ImportError: # pragma: no cover - optional e2e dependency - cv2 = None - -try: - import websockets.sync.client as websockets_client -except ImportError: # pragma: no cover - optional e2e dependency - websockets_client = None - -try: - from openpi_client import msgpack_numpy -except ImportError: # pragma: no cover - optional e2e dependency - msgpack_numpy = None - -PING_INTERVAL_SECS = 300 -PING_TIMEOUT_SECS = 3600 -DEFAULT_HOST = "127.0.0.1" -DEFAULT_PORT = 8000 -DEFAULT_PATH = "/v1/realtime/robot/openpi" -DEFAULT_PROMPT = "Move the pan forward and use the brush in the middle of the plates to brush the inside of the pan" -ACTION_HORIZON = 24 -DEFAULT_ACTION_DIM = 8 -RELATIVE_OFFSETS = [-23, -16, -8, 0] -CAMERA_FILES = { - "observation/exterior_image_0_left": "exterior_image_1_left.mp4", - "observation/exterior_image_1_left": "exterior_image_2_left.mp4", - "observation/wrist_image_left": "wrist_image_left.mp4", -} - - -def _decode_action_response(response: bytes | str) -> np.ndarray: - if isinstance(response, str): - raise RuntimeError(f"Inference failed: {response}") - decoded = msgpack_numpy.unpackb(response) - if isinstance(decoded, dict) and decoded.get("type") == "error": - message = decoded.get("message", decoded) - raise RuntimeError(f"Inference failed: {message}") - return np.asarray(decoded, dtype=np.float32) - - -def require_dependencies() -> None: - missing = [] - if cv2 is None: - missing.append("opencv-python") - if websockets_client is None: - missing.append("websockets") - if msgpack_numpy is None: - missing.append("openpi-client") - if missing: - raise ModuleNotFoundError(f"DreamZero OpenPI test dependencies are missing: {', '.join(missing)}") - - -@dataclass(frozen=True) -class DreamZeroServerMetadata: - image_resolution: tuple[int, int] - n_external_cameras: int - needs_wrist_camera: bool - needs_stereo_camera: bool - needs_session_id: bool - action_space: str - - @classmethod - def from_dict(cls, payload: dict[str, Any]) -> DreamZeroServerMetadata: - required_keys = ( - "image_resolution", - "n_external_cameras", - "needs_wrist_camera", - "needs_stereo_camera", - "needs_session_id", - "action_space", - ) - missing_keys = [key for key in required_keys if key not in payload] - if missing_keys: - raise ValueError(f"Missing DreamZero metadata keys: {missing_keys}") - - image_resolution = payload["image_resolution"] - if not isinstance(image_resolution, (list, tuple)) or len(image_resolution) != 2: - raise ValueError(f"Invalid image_resolution: {image_resolution!r}") - - return cls( - image_resolution=(int(image_resolution[0]), int(image_resolution[1])), - n_external_cameras=int(payload["n_external_cameras"]), - needs_wrist_camera=bool(payload["needs_wrist_camera"]), - needs_stereo_camera=bool(payload["needs_stereo_camera"]), - needs_session_id=bool(payload["needs_session_id"]), - action_space=str(payload["action_space"]), - ) - - -class OpenPIWebsocketClient: - def __init__( - self, - *, - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - path: str = DEFAULT_PATH, - ) -> None: - require_dependencies() - self._uri = f"ws://{host}:{port}{path}" - self._packer = msgpack_numpy.Packer() - self._ws, self._server_metadata = self._connect() - - def _connect(self): - conn = websockets_client.connect( - self._uri, - compression=None, - max_size=None, - ping_interval=PING_INTERVAL_SECS, - ping_timeout=PING_TIMEOUT_SECS, - ) - metadata = msgpack_numpy.unpackb(conn.recv()) - if not isinstance(metadata, dict): - raise TypeError(f"Expected dict metadata from server, got {type(metadata)!r}") - return conn, metadata - - def get_server_metadata(self) -> dict[str, Any]: - return dict(self._server_metadata) - - def infer(self, obs: dict[str, Any]) -> np.ndarray: - payload = dict(obs) - payload["endpoint"] = "infer" - self._ws.send(self._packer.pack(payload)) - response = self._ws.recv() - return _decode_action_response(response) - - def reset(self, reset_info: dict[str, Any] | None = None) -> str: - payload = dict(reset_info or {}) - payload["endpoint"] = "reset" - self._ws.send(self._packer.pack(payload)) - response = self._ws.recv() - if isinstance(response, str): - return response - decoded = msgpack_numpy.unpackb(response) - if not isinstance(decoded, dict) or decoded.get("status") != "reset successful": - raise RuntimeError(f"Unexpected reset response: {decoded!r}") - return str(decoded["status"]) - - def close(self) -> None: - self._ws.close() - - -def load_all_frames(video_path: Path) -> np.ndarray: - require_dependencies() - cap = cv2.VideoCapture(str(video_path)) - frames = [] - while True: - ok, frame = cap.read() - if not ok: - break - frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) - cap.release() - if not frames: - raise RuntimeError(f"No frames loaded from {video_path}") - return np.stack(frames, axis=0) - - -def load_camera_frames(video_dir: Path) -> dict[str, np.ndarray]: - camera_frames: dict[str, np.ndarray] = {} - for camera_key, file_name in CAMERA_FILES.items(): - video_path = video_dir / file_name - if not video_path.exists(): - raise FileNotFoundError(f"Missing DreamZero test asset: {video_path}") - camera_frames[camera_key] = load_all_frames(video_path) - return camera_frames - - -def build_frame_schedule(total_frames: int, num_chunks: int) -> list[list[int]]: - chunks: list[list[int]] = [] - current_frame = 23 - for _ in range(num_chunks): - indices = [max(current_frame + offset, 0) for offset in RELATIVE_OFFSETS] - if indices[-1] >= total_frames: - break - chunks.append(indices) - current_frame += ACTION_HORIZON - return chunks - - -def make_obs_from_video( - camera_frames: dict[str, np.ndarray], - frame_indices: list[int], - *, - prompt: str, - session_id: str, -) -> dict[str, Any]: - obs: dict[str, Any] = {} - for camera_key, all_frames in camera_frames.items(): - selected = all_frames[frame_indices] - obs[camera_key] = selected[0] if len(frame_indices) == 1 else selected - - obs["observation/joint_position"] = np.zeros(7, dtype=np.float32) - obs["observation/cartesian_position"] = np.zeros(6, dtype=np.float32) - obs["observation/gripper_position"] = np.zeros(1, dtype=np.float32) - obs["prompt"] = prompt - obs["session_id"] = session_id - return obs - - -def build_demo_observations( - camera_frames: dict[str, np.ndarray], - *, - prompt: str, - session_id: str, - num_chunks: int = 2, -) -> list[dict[str, Any]]: - if num_chunks < 1: - raise ValueError("num_chunks must be at least 1") - - total_frames = min(frames.shape[0] for frames in camera_frames.values()) - observations = [ - make_obs_from_video( - camera_frames, - [0], - prompt=prompt, - session_id=session_id, - ) - ] - for indices in build_frame_schedule(total_frames, num_chunks - 1): - observations.append( - make_obs_from_video( - camera_frames, - indices, - prompt=prompt, - session_id=session_id, - ) - ) - return observations - - -def validate_session_result( - result: dict[str, Any], - *, - expected_action_horizon: int = ACTION_HORIZON, - expected_action_dim: int = DEFAULT_ACTION_DIM, -) -> None: - metadata = DreamZeroServerMetadata.from_dict(result["metadata"]) - if metadata.image_resolution != (180, 320): - raise AssertionError(f"Unexpected image_resolution: {metadata.image_resolution}") - if metadata.n_external_cameras != 2: - raise AssertionError(f"Unexpected n_external_cameras: {metadata.n_external_cameras}") - if not metadata.needs_wrist_camera: - raise AssertionError("DreamZero test expects wrist camera metadata") - if metadata.action_space != "joint_position": - raise AssertionError(f"Unexpected action_space: {metadata.action_space}") - - actions = result["actions"] - if len(actions) != 3: - raise AssertionError(f"Expected 3 action tensors, got {len(actions)}") - for index, action in enumerate(actions): - if action.shape != (expected_action_horizon, expected_action_dim): - raise AssertionError( - f"Action {index} shape mismatch: expected " - f"{(expected_action_horizon, expected_action_dim)}, got {action.shape}" - ) - if not np.isfinite(action).all(): - raise AssertionError(f"Action {index} contains non-finite values") - - if result["reset_status"] != "reset successful": - raise AssertionError(f"Unexpected reset status: {result['reset_status']!r}") - - -def run_policy_session( - *, - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - path: str = DEFAULT_PATH, - video_dir: Path, - prompt: str = DEFAULT_PROMPT, - session_id: str | None = None, - num_chunks: int = 2, -) -> dict[str, Any]: - session_id = session_id or str(uuid.uuid4()) - camera_frames = load_camera_frames(video_dir) - observations = build_demo_observations( - camera_frames, - prompt=prompt, - session_id=session_id, - num_chunks=num_chunks, - ) - - client = OpenPIWebsocketClient(host=host, port=port, path=path) - try: - metadata = client.get_server_metadata() - actions = [client.infer(obs) for obs in observations] - reset_status = client.reset({}) - actions.append(client.infer(observations[0])) - return { - "metadata": metadata, - "actions": actions, - "reset_status": reset_status, - "session_id": session_id, - } - finally: - client.close() diff --git a/tests/dreamzero/test_openpi_client_helper.py b/tests/dreamzero/test_openpi_client_helper.py deleted file mode 100644 index 76950a9ed..000000000 --- a/tests/dreamzero/test_openpi_client_helper.py +++ /dev/null @@ -1,46 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import numpy as np -import pytest - -from tests.dreamzero import openpi_client_helper - -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] - - -class FakeMsgpackNumpy: - payloads = {} - - @staticmethod - def packb(obj): - key = str(len(FakeMsgpackNumpy.payloads)).encode() - FakeMsgpackNumpy.payloads[key] = obj - return key - - @staticmethod - def unpackb(data): - return FakeMsgpackNumpy.payloads[data] - - -def test_decode_action_response_surfaces_structured_error(monkeypatch): - monkeypatch.setattr(openpi_client_helper, "msgpack_numpy", FakeMsgpackNumpy) - payload = FakeMsgpackNumpy.packb( - { - "type": "error", - "message": "Internal inference error", - } - ) - - with pytest.raises(RuntimeError, match="Internal inference error"): - openpi_client_helper._decode_action_response(payload) - - -def test_decode_action_response_converts_action_payload_to_float32(monkeypatch): - monkeypatch.setattr(openpi_client_helper, "msgpack_numpy", FakeMsgpackNumpy) - payload = FakeMsgpackNumpy.packb(np.asarray([[1.0, 2.0]], dtype=np.float64)) - - actions = openpi_client_helper._decode_action_response(payload) - - assert actions.dtype == np.float32 - np.testing.assert_array_equal(actions, np.asarray([[1.0, 2.0]], dtype=np.float32)) diff --git a/tests/dreamzero/test_pipeline_state.py b/tests/dreamzero/test_pipeline_state.py deleted file mode 100644 index f00caf53c..000000000 --- a/tests/dreamzero/test_pipeline_state.py +++ /dev/null @@ -1,55 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -from collections import OrderedDict - -import pytest - -from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline -from vllm_omni.diffusion.models.dreamzero.state_dreamzero import DreamZeroState - -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] - - -def _empty_pipeline() -> DreamZeroPipeline: - pipeline = DreamZeroPipeline.__new__(DreamZeroPipeline) - pipeline._states = OrderedDict() - pipeline._max_session_states = 2 - return pipeline - - -def test_dreamzero_pipeline_state_is_session_keyed() -> None: - pipeline = _empty_pipeline() - - session_a = pipeline._get_or_create_state("session-a") - session_b = pipeline._get_or_create_state("session-b") - session_a.call_count = 7 - session_b.call_count = 3 - - assert pipeline._get_or_create_state("session-a") is session_a - assert pipeline._get_or_create_state("session-b") is session_b - assert session_a.call_count == 7 - assert session_b.call_count == 3 - - -def test_dreamzero_pipeline_state_lru_caps_retained_sessions() -> None: - pipeline = _empty_pipeline() - - session_a = pipeline._get_or_create_state("session-a") - pipeline._get_or_create_state("session-b") - assert pipeline._get_or_create_state("session-a") is session_a - - pipeline._get_or_create_state("session-c") - - assert list(pipeline._states) == ["session-a", "session-c"] - assert "session-b" not in pipeline._states - - -def test_dreamzero_state_owns_no_kv_caches() -> None: - """KV (self- and cross-attention) is engine-owned since the AR-Diffusion - paged backend: the model-local cache accessors must be gone so nothing can - silently bypass the engine pool.""" - state = DreamZeroState() - - for removed in ("get_kv_caches", "create_kv_caches", "update_kv_cache", "get_crossattn_caches"): - assert not hasattr(state, removed) diff --git a/tests/dreamzero/upstream/openpi_test_client_ar.py b/tests/dreamzero/upstream/openpi_test_client_ar.py deleted file mode 100644 index 7c5aea1d9..000000000 --- a/tests/dreamzero/upstream/openpi_test_client_ar.py +++ /dev/null @@ -1,381 +0,0 @@ -#!/usr/bin/env python3 -""" -Copied from the DreamZero repository's `test_client_AR.py`: -https://github.com/dreamzero0/dreamzero/blob/main/test_client_AR.py - -Kept here for end-to-end compatibility / parity testing against the vLLM -OpenPI server. - -Test client for AR_droid policy server using roboarena interface. - -Sends real video frames from debug_image/ directory instead of zero dummy images. - -Frame schedule (matching debug_inference.py): - - Step 0 (initial): send frame [0] (1 frame, H W 3) - - Step 1: send frames [0, 7, 15, 23] (4 frames, 4 H W 3) - - Step 2: send frames [24, 31, 39, 47] (4 frames) - - Step 3: send frames [48, 55, 63, 71] (4 frames) - - ... - -Expected server configuration: - - image_resolution: (180, 320) - - n_external_cameras: 2 - - needs_wrist_camera: True - - action_space: "joint_position" - -Usage: - # Start server with roboarena interface: - torchrun --nproc_per_node=8 socket_test_optimized_AR.py --port 8000 - - # Run against the original DreamZero websocket server (default path): - python test_client_AR.py --host --port 8000 - - # Run against the vLLM OpenPI server: - python test_client_AR.py --host --port 8000 --path /v1/realtime/robot/openpi - - # Use zero images instead of real video (old behavior): - python test_client_AR.py --host --port 8000 --use-zero-images -""" - -import argparse -import logging -import os -import sys -import time -import uuid -from pathlib import Path - -import cv2 -import numpy as np -from openpi_client import msgpack_numpy - -_DREAMZERO_REPO_ENV = os.environ.get("DREAMZERO_REPO") -DREAMZERO_REPO = Path(_DREAMZERO_REPO_ENV).expanduser() if _DREAMZERO_REPO_ENV else None - - -def _import_upstream_policy_modules(): - if DREAMZERO_REPO is None: - raise ImportError("Set DREAMZERO_REPO to an upstream DreamZero checkout before using this helper.") - if DREAMZERO_REPO.exists() and str(DREAMZERO_REPO) not in sys.path: - sys.path.insert(0, str(DREAMZERO_REPO)) - - import eval_utils.policy_server as policy_server - from eval_utils.policy_client import WebsocketClientPolicy - - return policy_server, WebsocketClientPolicy - - -policy_server, WebsocketClientPolicy = _import_upstream_policy_modules() - -VIDEO_DIR = os.environ.get( - "DREAMZERO_VIDEO_DIR", - str(DREAMZERO_REPO / "debug_image") if DREAMZERO_REPO is not None else "debug_image", -) - -# roboarena key -> video filename -CAMERA_FILES = { - "observation/exterior_image_0_left": "exterior_image_1_left.mp4", - "observation/exterior_image_1_left": "exterior_image_2_left.mp4", - "observation/wrist_image_left": "wrist_image_left.mp4", -} - -# Frame schedule constants (matching debug_inference.py) -RELATIVE_OFFSETS = [-23, -16, -8, 0] -ACTION_HORIZON = 24 -DEFAULT_WEBSOCKET_PATH = "" - - -class OpenPIWebsocketClientPolicy(WebsocketClientPolicy): - """DreamZero websocket client with a configurable path suffix. - - The original DreamZero client connects to ``ws://host:port``. - vLLM serves the compatible robot policy endpoint at - ``/v1/realtime/robot/openpi`` when ``path`` is set accordingly. - """ - - def __init__( - self, - host: str = "0.0.0.0", - port: int = 8000, - path: str = DEFAULT_WEBSOCKET_PATH, - ) -> None: - self._uri = f"ws://{host}:{port}{path}" - self._packer = msgpack_numpy.Packer() - self._ws, self._server_metadata = self._wait_for_server() - - -def load_all_frames(video_path: str) -> np.ndarray: - """Load all frames from a video file. Returns (N, H, W, 3) uint8 array (RGB).""" - cap = cv2.VideoCapture(video_path) - frames = [] - while True: - ret, frame = cap.read() - if not ret: - break - frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) - cap.release() - if not frames: - raise RuntimeError(f"No frames loaded from {video_path}") - return np.stack(frames, axis=0) - - -def load_camera_frames() -> dict[str, np.ndarray]: - """Load all video frames for each camera from the debug_image/ directory. - - Returns: - Dict mapping roboarena camera keys to (N, H, W, 3) uint8 arrays. - """ - camera_frames: dict[str, np.ndarray] = {} - for cam_key, fname in CAMERA_FILES.items(): - path = os.path.join(VIDEO_DIR, fname) - camera_frames[cam_key] = load_all_frames(path) - logging.info(f"Loaded {cam_key}: {camera_frames[cam_key].shape}") - return camera_frames - - -def build_frame_schedule(total_frames: int, num_chunks: int) -> list[list[int]]: - """Build the frame index schedule for multi-frame chunks. - - Returns a list of frame-index lists. Each inner list has 4 indices. - """ - chunks: list[list[int]] = [] - current_frame = 23 # first anchor frame - for _ in range(num_chunks): - indices = [max(current_frame + off, 0) for off in RELATIVE_OFFSETS] - if indices[-1] >= total_frames: - logging.info(f"Frame {indices[-1]} >= {total_frames}, stopping at {len(chunks)} chunks") - break - chunks.append(indices) - current_frame += ACTION_HORIZON - return chunks - - -def _make_obs_from_video( - camera_frames: dict[str, np.ndarray], - frame_indices: list[int], - prompt: str, - session_id: str, -) -> dict: - """Build an observation dict from real video frames. - - For 1 frame: each image key is (H, W, 3). - For 4 frames: each image key is (4, H, W, 3). - """ - obs: dict = {} - for cam_key, all_frames in camera_frames.items(): - selected = all_frames[frame_indices] # (T, H, W, 3) - if len(frame_indices) == 1: - selected = selected[0] # (H, W, 3) - obs[cam_key] = selected - - obs["observation/joint_position"] = np.zeros(7, dtype=np.float32) - obs["observation/cartesian_position"] = np.zeros(6, dtype=np.float32) - obs["observation/gripper_position"] = np.zeros(1, dtype=np.float32) - obs["prompt"] = prompt - obs["session_id"] = session_id - return obs - - -def _make_zero_observation( - server_config: policy_server.PolicyServerConfig, - prompt: str = "pick up the object", - session_id: str | None = None, -) -> dict: - """Create a dummy observation matching AR_droid expectations. - - AR_droid expects: - - 2 external cameras (exterior_image_0_left, exterior_image_1_left) - - 1 wrist camera (wrist_image_left) - - Image resolution: 180x320 (H x W) - - joint_position: 7 DoF - - gripper_position: 1 DoF - """ - obs = {} - - # Determine image resolution - if server_config.image_resolution is not None: - h, w = server_config.image_resolution - else: - # Default for AR_droid - h, w = 180, 320 - - # External cameras (0-indexed in roboarena) - for i in range(server_config.n_external_cameras): - obs[f"observation/exterior_image_{i}_left"] = np.zeros((h, w, 3), dtype=np.uint8) - if server_config.needs_stereo_camera: - obs[f"observation/exterior_image_{i}_right"] = np.zeros((h, w, 3), dtype=np.uint8) - - # Wrist camera - if server_config.needs_wrist_camera: - obs["observation/wrist_image_left"] = np.zeros((h, w, 3), dtype=np.uint8) - if server_config.needs_stereo_camera: - obs["observation/wrist_image_right"] = np.zeros((h, w, 3), dtype=np.uint8) - - # Session ID - should be passed in to ensure consistency within a session - if server_config.needs_session_id: - import uuid - - # Generate unique session ID if not provided - obs["session_id"] = session_id if session_id else str(uuid.uuid4()) - - # State observations (AR_droid: 7 DoF arm + 1 gripper) - obs["observation/joint_position"] = np.zeros(7, dtype=np.float32) - obs["observation/cartesian_position"] = np.zeros(6, dtype=np.float32) - obs["observation/gripper_position"] = np.zeros(1, dtype=np.float32) - - # Language prompt - obs["prompt"] = prompt - - return obs - - -def test_ar_droid_policy_server( - host: str = "localhost", - port: int = 8000, - path: str = DEFAULT_WEBSOCKET_PATH, - num_chunks: int = 15, - prompt: str = "Move the pan forward and use the brush in the middle of the plates to brush the inside of the pan", - use_zero_images: bool = False, -): - """Test the AR_droid policy server with roboarena interface. - - When use_zero_images is False (default), loads real video frames from - debug_image/ and follows the frame schedule from debug_inference.py. - """ - logging.info(f"Connecting to AR_droid server at ws://{host}:{port}{path} ...") - - client = OpenPIWebsocketClientPolicy(host=host, port=port, path=path) - - # Validate server metadata - metadata = client.get_server_metadata() - logging.info(f"Server metadata: {metadata}") - assert isinstance(metadata, dict), "Metadata should be a dict" - - try: - server_config = policy_server.PolicyServerConfig(**metadata) - except Exception as e: - logging.error(f"Error parsing metadata: {e}") - raise e - - # Validate expected AR_droid configuration - logging.info(f"Server config: {server_config}") - assert server_config.n_external_cameras == 2, f"Expected 2 external cameras, got {server_config.n_external_cameras}" - assert server_config.needs_wrist_camera, "Expected wrist camera to be enabled" - assert server_config.action_space == "joint_position", ( - f"Expected joint_position action space, got {server_config.action_space}" - ) - - logging.info("Server configuration validated for AR_droid") - - # Generate unique session ID for this test run - session_id = str(uuid.uuid4()) - logging.info(f"Session ID: {session_id}") - - # ── Zero-image fallback mode ────────────────────────────────────── - if use_zero_images: - logging.info("Using ZERO dummy images (legacy mode)") - for i in range(num_chunks): - obs = _make_zero_observation(server_config, prompt=prompt, session_id=session_id) - logging.info(f"Inference {i + 1}/{num_chunks}: prompt='{prompt}'") - t0 = time.time() - actions = client.infer(obs) - dt = time.time() - t0 - _log_action(actions, dt) - - logging.info("Sending reset...") - client.reset({}) - logging.info("Done (zero-image mode).") - return - - # ── Real video frame mode ───────────────────────────────────────── - logging.info("Loading real video frames from debug_image/ directory") - camera_frames = load_camera_frames() - - total_frames = min(v.shape[0] for v in camera_frames.values()) - logging.info(f"Total frames available: {total_frames}") - - # Build frame schedule - chunks = build_frame_schedule(total_frames, num_chunks) - - logging.info("Frame schedule:") - logging.info(" Initial: [0]") - for i, indices in enumerate(chunks): - logging.info(f" Chunk {i}: {indices}") - - # Step 0: initial single frame - logging.info("=== Initial: frame [0] ===") - obs = _make_obs_from_video(camera_frames, [0], prompt, session_id) - t0 = time.time() - actions = client.infer(obs) - dt = time.time() - t0 - _log_action(actions, dt) - - # Subsequent chunks: send 4 frames at a time - for chunk_idx, frame_indices in enumerate(chunks): - logging.info(f"=== Chunk {chunk_idx}: frames {frame_indices} ===") - obs = _make_obs_from_video(camera_frames, frame_indices, prompt, session_id) - t0 = time.time() - actions = client.infer(obs) - dt = time.time() - t0 - _log_action(actions, dt) - - # Reset triggers video save on the server - logging.info("Sending reset to save video...") - client.reset({}) - - logging.info("Done.") - - -def _log_action(actions: np.ndarray, dt: float) -> None: - """Pretty-print action shape, range, and timing.""" - assert isinstance(actions, np.ndarray), f"Expected numpy array, got {type(actions)}" - assert actions.ndim == 2, f"Expected 2D array, got shape {actions.shape}" - assert actions.shape[-1] == 8, f"Expected 8 action dims (7 joints + 1 gripper), got {actions.shape[-1]}" - logging.info(f" Action shape: {actions.shape}, range: [{actions.min():.4f}, {actions.max():.4f}], time: {dt:.2f}s") - - -def main(): - parser = argparse.ArgumentParser(description="Test AR_droid policy server with real video frames from debug_image/") - parser.add_argument("--host", default="localhost", help="Server hostname") - parser.add_argument("--port", type=int, default=8000, help="Server port") - parser.add_argument( - "--path", - default=DEFAULT_WEBSOCKET_PATH, - help="WebSocket path suffix (default: empty string for the original DreamZero server)", - ) - parser.add_argument( - "--num-chunks", - type=int, - default=15, - help="Number of 4-frame chunks to send after the initial frame (default: 15)", - ) - parser.add_argument( - "--prompt", - default="Move the pan forward and use the brush in the middle of the plates to brush the inside of the pan", - help="Language prompt for the policy", - ) - parser.add_argument( - "--use-zero-images", - action="store_true", - help="Use zero dummy images instead of real video frames (legacy mode)", - ) - - args = parser.parse_args() - - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - ) - - test_ar_droid_policy_server( - host=args.host, - port=args.port, - path=args.path, - num_chunks=args.num_chunks, - prompt=args.prompt, - use_zero_images=args.use_zero_images, - ) - - -if __name__ == "__main__": - main() diff --git a/tests/dreamzero/upstream/test_openpi_e2e_source_parity.py b/tests/dreamzero/upstream/test_openpi_e2e_source_parity.py deleted file mode 100644 index e41cff903..000000000 --- a/tests/dreamzero/upstream/test_openpi_e2e_source_parity.py +++ /dev/null @@ -1,358 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -"""Formal OpenPI end-to-end parity: upstream DreamZero server vs `vllm serve`. - -This test uses DreamZero's own client-side observation builders from -`${DREAMZERO_REPO}/test_client_AR.py`, and client-side websocket protocol from -`${DREAMZERO_REPO}/eval_utils/policy_client.py`. - -The only client-side adaptation for vLLM is the websocket path: -DreamZero's upstream server serves at `/`, while vLLM serves OpenPI at -`/v1/realtime/robot/openpi`. - -Current scope for this test: -- default two-GPU run (`nproc_per_node=2` on upstream, `--cfg-parallel-size 2` on `vllm serve`) -- non-`torch.compile` (upstream launched through - `upstream_socket_server_no_compile.py`, vLLM with `--enforce-eager`) -- non-DiT-cache / non-skip-schedule (`NUM_DIT_STEPS=16`) - -Serving contract locked by this test: -- upstream DreamZero still boots from the local checkpoint directory -- vLLM boots from the official DreamZero HF repo name (`GEAR-Dreams/DreamZero-DROID`) - rather than a prepared local bundle path -""" - -from __future__ import annotations - -import os -import shutil -import subprocess -import sys -import time -from pathlib import Path - -import numpy as np -import pytest -import torch - -from tests.helpers.runtime import get_open_port - -msgpack_numpy = pytest.importorskip("openpi_client.msgpack_numpy") - -_DREAMZERO_REPO_ENV = os.environ.get("DREAMZERO_REPO") -DREAMZERO_REPO = Path(_DREAMZERO_REPO_ENV).expanduser() if _DREAMZERO_REPO_ENV else None -if DREAMZERO_REPO is not None and str(DREAMZERO_REPO) not in sys.path: - sys.path.insert(0, str(DREAMZERO_REPO)) - -try: - import test_client_AR as dreamzero_client - from eval_utils.policy_client import WebsocketClientPolicy -except Exception: # pragma: no cover - guarded by pytest skip below - dreamzero_client = None - WebsocketClientPolicy = None -_BaseWebsocketClientPolicy = WebsocketClientPolicy if WebsocketClientPolicy is not None else object - -CHECKPOINT_DIR = DREAMZERO_REPO / "checkpoints" / "dreamzero" if DREAMZERO_REPO is not None else None -VLLM_MODEL = os.environ.get("VLLM_DREAMZERO_MODEL", "GEAR-Dreams/DreamZero-DROID") -SERVICE_READY_TIMEOUT_S = int(os.environ.get("OPENPI_SERVICE_READY_TIMEOUT_S", "900")) -PROMPT = "Move the pan forward and use the brush in the middle of the plates to brush the inside of the pan" -SESSION_ID = "openpi-e2e-parity-session" - -pytestmark = [ - pytest.mark.skipif(not torch.cuda.is_available(), reason="GPU required"), - pytest.mark.skipif( - dreamzero_client is None or WebsocketClientPolicy is None, - reason="DreamZero client modules are required on PYTHONPATH", - ), - pytest.mark.skipif( - DREAMZERO_REPO is None or not DREAMZERO_REPO.exists(), - reason="DreamZero source repo is required at DREAMZERO_REPO", - ), - pytest.mark.skipif( - CHECKPOINT_DIR is None or not CHECKPOINT_DIR.exists(), reason="DreamZero local checkpoint is required" - ), -] - - -class OpenPIWebsocketClientPolicy(_BaseWebsocketClientPolicy): - """DreamZero client protocol with an OpenPI websocket path suffix.""" - - def __init__( - self, - host: str = "127.0.0.1", - port: int = 8000, - path: str = "/v1/realtime/robot/openpi", - ) -> None: - self._uri = f"ws://{host}:{port}{path}" - self._packer = msgpack_numpy.Packer() - self._ws, self._server_metadata = self._wait_for_server() - - -def _vllm_executable() -> str: - fallback = Path(sys.executable).with_name("vllm") - if fallback.exists(): - return str(fallback) - exe = shutil.which("vllm") - if exe: - return exe - raise FileNotFoundError("Unable to locate `vllm` executable in current environment.") - - -def _cfg_parallel_size() -> int: - return int(os.environ.get("OPENPI_E2E_CFG_PARALLEL_SIZE", "2")) - - -def _pick_test_gpus() -> list[str]: - cfg_parallel_size = _cfg_parallel_size() - override = os.environ.get("OPENPI_E2E_GPUS") or os.environ.get("OPENPI_E2E_GPU") - if override is not None: - gpus = [part.strip() for part in override.split(",") if part.strip()] - if not gpus: - raise ValueError("OPENPI_E2E_GPUS is set but empty.") - if len(gpus) < cfg_parallel_size: - raise RuntimeError(f"Need {cfg_parallel_size} GPUs, but OPENPI_E2E_GPUS only provided {gpus}.") - return gpus - - query = subprocess.check_output( - [ - "nvidia-smi", - "--query-gpu=index,memory.used", - "--format=csv,noheader,nounits", - ], - text=True, - ) - gpu_rows = [] - for line in query.strip().splitlines(): - gpu_index, used_mb = [part.strip() for part in line.split(",", maxsplit=1)] - gpu_rows.append((int(used_mb), gpu_index)) - gpu_rows.sort() - gpus = [gpu_index for _, gpu_index in gpu_rows[: max(cfg_parallel_size, 1)]] - if len(gpus) < cfg_parallel_size: - raise RuntimeError( - f"Need {cfg_parallel_size} GPUs for cfg_parallel_size={cfg_parallel_size}, " - f"but found only {len(gpus)} candidates." - ) - return gpus - - -def _torchrun_argv(script: str, port: int) -> list[str]: - return [ - sys.executable, - "-m", - "torch.distributed.run", - "--standalone", - "--nproc_per_node", - str(_cfg_parallel_size()), - script, - "--port", - str(port), - "--model_path", - str(CHECKPOINT_DIR), - ] - - -def _run_upstream_service(port: int, log_path: Path) -> subprocess.Popen[str]: - env = os.environ.copy() - env.setdefault("PYTHONPATH", "") - env["PYTHONPATH"] = f"{Path.cwd()}:{DREAMZERO_REPO}:{env['PYTHONPATH']}".rstrip(":") - env["CUDA_VISIBLE_DEVICES"] = ",".join(_pick_test_gpus()) - env.setdefault("NO_ALBUMENTATIONS_UPDATE", "1") - env["ATTENTION_BACKEND"] = "torch" - env.setdefault("ENABLE_TENSORRT", "false") - env["ENABLE_DIT_CACHE"] = "false" - env["NUM_DIT_STEPS"] = "16" - env["DYNAMIC_CACHE_SCHEDULE"] = "false" - argv = _torchrun_argv( - str(Path("tests/dreamzero/upstream/upstream_socket_server_no_compile.py")), - port, - ) - log_file = log_path.open("w") - proc = subprocess.Popen( - argv, - stdout=log_file, - stderr=subprocess.STDOUT, - text=True, - cwd=str(Path.cwd()), - env=env, - ) - proc._codex_log_file = log_file # type: ignore[attr-defined] - return proc - - -def _run_vllm_service(port: int, log_path: Path) -> subprocess.Popen[str]: - env = os.environ.copy() - gpus = _pick_test_gpus() - cfg_parallel_size = _cfg_parallel_size() - if cfg_parallel_size > len(gpus): - raise RuntimeError( - f"cfg_parallel_size={cfg_parallel_size} requires at least {cfg_parallel_size} GPUs, but only got {gpus}." - ) - env["CUDA_VISIBLE_DEVICES"] = ",".join(gpus[:cfg_parallel_size]) - env.setdefault("ATTENTION_BACKEND", "torch") - env.setdefault("DIFFUSION_ATTENTION_BACKEND", "TORCH_SDPA") - env.setdefault("MASTER_PORT", str(get_open_port())) - argv = [ - _vllm_executable(), - "serve", - VLLM_MODEL, - "--omni", - "--host", - "127.0.0.1", - "--port", - str(port), - "--served-model-name", - "dreamzero-droid", - "--enforce-eager", - ] - if cfg_parallel_size > 1: - argv.extend(["--cfg-parallel-size", str(cfg_parallel_size)]) - log_file = log_path.open("w") - proc = subprocess.Popen( - argv, - stdout=log_file, - stderr=subprocess.STDOUT, - text=True, - env=env, - cwd=str(Path.cwd()), - ) - proc._codex_log_file = log_file # type: ignore[attr-defined] - return proc - - -def _stop_process(proc: subprocess.Popen[str]) -> None: - log_file = getattr(proc, "_codex_log_file", None) - if proc.poll() is None: - proc.terminate() - try: - proc.wait(timeout=30) - except subprocess.TimeoutExpired: # pragma: no cover - cleanup path - proc.kill() - proc.wait(timeout=10) - if log_file is not None: - log_file.close() - - -def _build_obs_sequence() -> tuple[dict, dict]: - camera_frames = dreamzero_client.load_camera_frames() - chunks = dreamzero_client.build_frame_schedule( - min(v.shape[0] for v in camera_frames.values()), - 1, - ) - obs0 = dreamzero_client._make_obs_from_video(camera_frames, [0], PROMPT, SESSION_ID) - obs1 = dreamzero_client._make_obs_from_video(camera_frames, chunks[0], PROMPT, SESSION_ID) - return obs0, obs1 - - -def _wait_for_client_ready(client_factory, timeout_s: float, proc=None, log_path: Path | None = None): - deadline = time.time() + timeout_s - last_err: Exception | None = None - while time.time() < deadline: - if proc is not None and proc.poll() is not None: - details = "" - if log_path is not None and log_path.exists(): - details = log_path.read_text(errors="replace")[-8000:] - raise RuntimeError(f"Service exited before becoming ready with code {proc.returncode}.\n{details}") - try: - return client_factory() - except Exception as exc: # pragma: no cover - retry path - last_err = exc - time.sleep(1) - raise TimeoutError(f"Timed out waiting for websocket service: {last_err}") - - -def _collect_outputs_with_client(client) -> tuple[dict, list[np.ndarray]]: - metadata = client.get_server_metadata() - obs0, obs1 = _build_obs_sequence() - outputs = [ - client.infer(dict(obs0)), - client.infer(dict(obs1)), - ] - assert _normalize_reset_response(client.reset({})) == "reset successful" - outputs.append(client.infer(dict(obs0))) - client._ws.close() - return metadata, outputs - - -def _normalize_reset_response(response) -> str: - if isinstance(response, str): - return response - decoded = msgpack_numpy.unpackb(response) - if isinstance(decoded, dict): - return str(decoded.get("status")) - return str(decoded) - - -def _normalize_metadata(metadata: dict) -> dict: - normalized = dict(metadata) - if isinstance(normalized.get("image_resolution"), tuple): - normalized["image_resolution"] = list(normalized["image_resolution"]) - return normalized - - -def _assert_logs_clean(log_path: Path) -> None: - text = log_path.read_text(errors="replace") - if "SignalException: Process" in text and "got signal: 15" in text: - text = text.split("Traceback (most recent call last):", 1)[0] - assert "Traceback" not in text, text - assert "RuntimeError:" not in text, text - - -def _assert_upstream_log_matches_vllm_baseline(log_path: Path) -> None: - text = log_path.read_text(errors="replace") - assert "DIT Compute Steps 8 steps" not in text, text - assert "DIT Compute Steps 16 steps" in text, text - - -def test_openpi_service_matches_upstream_server_noncompile(tmp_path: Path) -> None: - expected_metadata = { - "image_resolution": [180, 320], - "n_external_cameras": 2, - "needs_wrist_camera": True, - "needs_stereo_camera": False, - "needs_session_id": True, - "action_space": "joint_position", - } - - upstream_port = get_open_port() - upstream_log = tmp_path / "dreamzero_upstream.log" - upstream_proc = _run_upstream_service(upstream_port, upstream_log) - try: - upstream_client = _wait_for_client_ready( - lambda: WebsocketClientPolicy(host="127.0.0.1", port=upstream_port), - timeout_s=SERVICE_READY_TIMEOUT_S, - proc=upstream_proc, - log_path=upstream_log, - ) - upstream_metadata, upstream_outputs = _collect_outputs_with_client(upstream_client) - finally: - _stop_process(upstream_proc) - _assert_logs_clean(upstream_log) - _assert_upstream_log_matches_vllm_baseline(upstream_log) - - vllm_port = get_open_port() - vllm_log = tmp_path / "vllm_openpi.log" - vllm_proc = _run_vllm_service(vllm_port, vllm_log) - try: - vllm_client = _wait_for_client_ready( - lambda: OpenPIWebsocketClientPolicy(host="127.0.0.1", port=vllm_port), - timeout_s=SERVICE_READY_TIMEOUT_S, - proc=vllm_proc, - log_path=vllm_log, - ) - vllm_metadata, vllm_outputs = _collect_outputs_with_client(vllm_client) - finally: - _stop_process(vllm_proc) - _assert_logs_clean(vllm_log) - - assert _normalize_metadata(upstream_metadata) == expected_metadata - assert _normalize_metadata(vllm_metadata) == expected_metadata - - for idx, (actual, expected) in enumerate(zip(vllm_outputs, upstream_outputs, strict=True)): - np.testing.assert_allclose( - actual, - expected, - rtol=1e-2, - atol=1e-3, - err_msg=f"OpenPI step {idx} output mismatch", - ) diff --git a/tests/e2e/accuracy/__init__.py b/tests/e2e/accuracy/__init__.py new file mode 100644 index 000000000..d0b74930d --- /dev/null +++ b/tests/e2e/accuracy/__init__.py @@ -0,0 +1 @@ +"""End-to-end accuracy tests.""" diff --git a/tests/e2e/accuracy/conftest.py b/tests/e2e/accuracy/conftest.py index 6c0258aa2..750a81095 100644 --- a/tests/e2e/accuracy/conftest.py +++ b/tests/e2e/accuracy/conftest.py @@ -1,54 +1,15 @@ from __future__ import annotations -import os -import subprocess -from contextlib import contextmanager -from dataclasses import dataclass from io import BytesIO from pathlib import Path import pytest import requests -import torch from PIL import Image -from tests.helpers.runtime import OmniServer, OmniServerParams - def pytest_addoption(parser): group = parser.getgroup("accuracy-e2e") - group.addoption("--gebench-root", action="store", default=None, help="Local GEBench dataset root") - group.addoption("--gedit-root", action="store", default=None, help="Local GEdit-Bench dataset root") - group.addoption( - "--gebench-model", action="store", default="Qwen/Qwen-Image-2512", help="Generate model for GEBench smoke" - ) - group.addoption( - "--gedit-model", action="store", default="Qwen/Qwen-Image-Edit", help="Generate model for GEdit-Bench smoke" - ) - group.addoption( - "--accuracy-judge-model", - action="store", - default="QuantTrio/Qwen3-VL-30B-A3B-Instruct-AWQ", - help="Judge model path", - ) - group.addoption("--accuracy-gpu", action="store", default="0", help="Single GPU id used sequentially") - group.addoption("--gebench-port", action="store", type=int, default=8093, help="Generate port for GEBench") - group.addoption("--gedit-port", action="store", type=int, default=8093, help="Generate port for GEdit-Bench") - group.addoption( - "--gebench-samples-per-type", - action="store", - type=int, - default=10, - help="Balanced sample count per GEBench type", - ) - group.addoption( - "--gedit-samples-per-group", - action="store", - type=int, - default=20, - help="Balanced sample count per GEdit task group", - ) - group.addoption("--accuracy-workers", action="store", type=int, default=1, help="Worker count for accuracy benches") group.addoption( "--wan22-i2v-image-source", action="store", @@ -77,99 +38,6 @@ def pytest_addoption(parser): ) -def _hf_cache_root() -> Path: - return Path(os.environ.get("HF_HOME", "/root/.cache/huggingface")) - - -def _dataset_cache_dirs(dataset_id: str) -> list[Path]: - cache_root = _hf_cache_root() / "hub" / f"datasets--{dataset_id.replace('/', '--')}" / "snapshots" - if not cache_root.exists(): - return [] - return sorted( - (path for path in cache_root.iterdir() if path.is_dir()), key=lambda path: path.stat().st_mtime, reverse=True - ) - - -def _ensure_dataset_snapshot(dataset_id: str) -> Path: - candidates = _dataset_cache_dirs(dataset_id) - if candidates: - return candidates[0] - - subprocess.run( - ["hf", "download", "--repo-type", "dataset", dataset_id], - check=True, - ) - candidates = _dataset_cache_dirs(dataset_id) - if not candidates: - raise FileNotFoundError( - f"Dataset {dataset_id} was downloaded but no snapshot was found under {_hf_cache_root()}" - ) - return candidates[0] - - -def _resolve_dataset_root(request: pytest.FixtureRequest, option_name: str, dataset_id: str) -> Path: - value = request.config.getoption(option_name) - if value: - path = Path(value) - if not path.exists(): - pytest.skip(f"Dataset path does not exist: {path}") - return path - return _ensure_dataset_snapshot(dataset_id) - - -@dataclass -class AccuracyServerConfig: - generate_params: OmniServerParams - judge_params: OmniServerParams - run_level: str - model_prefix: str - - @contextmanager - def generate_server(self): - params = self.generate_params - model = self.model_prefix + params.model - server_args = params.server_args or [] - if params.use_omni and params.stage_init_timeout is not None: - server_args = ["--stage-init-timeout", str(params.stage_init_timeout), *server_args] - with OmniServer( - model, - server_args, - port=params.port, - env_dict=params.env_dict, - use_omni=params.use_omni, - ) as server: - yield server - - @contextmanager - def judge_server(self): - params = self.judge_params - model = self.model_prefix + params.model - server_args = params.server_args or [] - with OmniServer( - model, - server_args, - port=params.port, - env_dict=params.env_dict, - use_omni=params.use_omni, - ) as server: - yield server - - -@pytest.fixture(scope="session") -def gebench_dataset_root(request: pytest.FixtureRequest) -> Path: - return _resolve_dataset_root(request, "gebench_root", "stepfun-ai/GEBench") - - -@pytest.fixture(scope="session") -def gedit_dataset_root(request: pytest.FixtureRequest) -> Path: - return _resolve_dataset_root(request, "gedit_root", "stepfun-ai/GEdit-Bench") - - -@pytest.fixture(scope="session") -def accuracy_workers(request: pytest.FixtureRequest) -> int: - return int(request.config.getoption("accuracy_workers")) - - @pytest.fixture(scope="session") def wan22_i2v_image_source(request: pytest.FixtureRequest) -> str | None: value = request.config.getoption("wan22_i2v_image_source") @@ -192,16 +60,6 @@ def hunyuanvideo15_online_timeout_seconds(request: pytest.FixtureRequest) -> int return int(request.config.getoption("hunyuanvideo15_online_timeout_seconds")) -@pytest.fixture(scope="session") -def gebench_samples_per_type(request: pytest.FixtureRequest) -> int: - return int(request.config.getoption("gebench_samples_per_type")) - - -@pytest.fixture(scope="session") -def gedit_samples_per_group(request: pytest.FixtureRequest) -> int: - return int(request.config.getoption("gedit_samples_per_group")) - - @pytest.fixture(scope="session") def accuracy_artifact_root() -> Path: root = Path(__file__).resolve().parent / "artifacts" @@ -209,12 +67,6 @@ def accuracy_artifact_root() -> Path: return root -@pytest.fixture(scope="session") -def accuracy_assets_root() -> Path: - root = Path(__file__).resolve().parent / "assets" - return root - - @pytest.fixture(scope="session") def qwen_bear_image(accuracy_artifact_root: Path): """Download the Qwen bear image from the URL and save it to the accuracy artifact root.""" @@ -249,80 +101,3 @@ def rabbit_image(accuracy_artifact_root: Path): image.save(image_path) yield image image.close() - - -def _build_accuracy_server_config( - *, - generate_model: str, - judge_model: str, - shared_gpu: str, - port: int, - run_level: str, - model_prefix: str, -) -> AccuracyServerConfig: - if torch.accelerator.device_count() < 1: - pytest.skip("Need at least 1 CUDA GPU for accuracy benchmark smoke tests.") - - if not generate_model: - pytest.skip("No generate model configured for accuracy benchmark test.") - generate_server_args = ["--num-gpus", "1"] - judge_server_args = [ - "--max-model-len", - "32768", - "--gpu-memory-utilization", - "0.8", - ] - - judge_env = {"CUDA_VISIBLE_DEVICES": shared_gpu} - - return AccuracyServerConfig( - generate_params=OmniServerParams( - model=generate_model, - port=port, - server_args=generate_server_args, - env_dict={"CUDA_VISIBLE_DEVICES": shared_gpu}, - use_omni=True, - stage_init_timeout=300, - ), - judge_params=OmniServerParams( - model=judge_model, - port=port, - server_args=judge_server_args, - env_dict=judge_env, - use_omni=False, - ), - run_level=run_level, - model_prefix=model_prefix, - ) - - -@pytest.fixture -def gebench_accuracy_servers( - request: pytest.FixtureRequest, - run_level: str, - model_prefix: str, -) -> AccuracyServerConfig: - return _build_accuracy_server_config( - generate_model=request.config.getoption("gebench_model"), - judge_model=request.config.getoption("accuracy_judge_model"), - shared_gpu=str(request.config.getoption("accuracy_gpu")), - port=int(request.config.getoption("gebench_port")), - run_level=run_level, - model_prefix=model_prefix, - ) - - -@pytest.fixture -def gedit_accuracy_servers( - request: pytest.FixtureRequest, - run_level: str, - model_prefix: str, -) -> AccuracyServerConfig: - return _build_accuracy_server_config( - generate_model=request.config.getoption("gedit_model"), - judge_model=request.config.getoption("accuracy_judge_model"), - shared_gpu=str(request.config.getoption("accuracy_gpu")), - port=int(request.config.getoption("gedit_port")), - run_level=run_level, - model_prefix=model_prefix, - ) diff --git a/tests/e2e/accuracy/helpers.py b/tests/e2e/accuracy/helpers.py index ebe635ea8..99880fc73 100644 --- a/tests/e2e/accuracy/helpers.py +++ b/tests/e2e/accuracy/helpers.py @@ -3,8 +3,12 @@ import os import re import shutil import subprocess +import sys import time +from abc import ABC, abstractmethod from base64 import b64decode, b64encode +from collections.abc import Callable, Iterator +from contextlib import contextmanager from fractions import Fraction from hashlib import sha1 from io import BytesIO @@ -53,8 +57,6 @@ def env_to_apply_ftfy_mock_in_subproc(env: dict[str, str] | None = None) -> dict def reset_artifact_dir(path: Path) -> Path: - import shutil - if path.exists(): shutil.rmtree(path) path.mkdir(parents=True, exist_ok=True) @@ -609,3 +611,344 @@ def download_images(urls: list[str]) -> list[Image.Image]: images.append(image) return images + + +_DREAMZERO_UPSTREAM_SERVER_SCRIPT = ( + Path(__file__).resolve().parent / "upstream_mock" / "upstream_socket_server_no_compile.py" +) +_DREAMZERO_DEFAULT_MODEL = "GEAR-Dreams/DreamZero-DROID" +_DREAMZERO_DEFAULT_READY_TIMEOUT_S = int(os.environ.get("OPENPI_SERVICE_READY_TIMEOUT_S", "900")) +_DREAMZERO_DEFAULT_PROMPT = ( + "Move the pan forward and use the brush in the middle of the plates to brush the inside of the pan" +) +_DREAMZERO_DEFAULT_SESSION_ID = "openpi-e2e-parity-session" +_DREAMZERO_OPENPI_VLLM_PATH = "/v1/realtime/robot/openpi" + + +def require_dreamzero_parity_env() -> tuple[Path, Path]: + repo_env = os.environ.get("DREAMZERO_REPO") + if repo_env is None: + raise ValueError("DREAMZERO_REPO environment variable is required.") + repo = Path(repo_env).expanduser() + if not repo.is_dir(): + raise FileNotFoundError(f"DREAMZERO_REPO does not exist: {repo}") + checkpoint_dir = repo / "checkpoints" / "dreamzero" + if not checkpoint_dir.is_dir(): + raise FileNotFoundError(f"DreamZero checkpoint not found: {checkpoint_dir}") + if str(repo) not in sys.path: + sys.path.insert(0, str(repo)) + return repo, checkpoint_dir + + +def load_dreamzero_upstream_modules(): + require_dreamzero_parity_env() + import test_client_AR as dreamzero_upstream_client + from eval_utils.policy_client import WebsocketClientPolicy + + return dreamzero_upstream_client, WebsocketClientPolicy + + +def dreamzero_cfg_parallel_size() -> int: + return int(os.environ.get("OPENPI_E2E_CFG_PARALLEL_SIZE", "2")) + + +def _dreamzero_parity_vllm_env() -> dict[str, str]: + from tests.helpers.env import pick_least_used_device_indices + from tests.helpers.runtime import get_open_port + + parallel_size = dreamzero_cfg_parallel_size() + gpus = [str(index) for index in pick_least_used_device_indices(parallel_size)] + if parallel_size > len(gpus): + raise RuntimeError( + f"cfg_parallel_size={parallel_size} requires at least {parallel_size} GPUs, but only got {gpus}." + ) + return { + "CUDA_VISIBLE_DEVICES": ",".join(gpus[:parallel_size]), + "ATTENTION_BACKEND": "torch", + "DIFFUSION_ATTENTION_BACKEND": "TORCH_SDPA", + "VLLM_DISABLE_COMPILE_CACHE": "1", + "MASTER_PORT": str(get_open_port()), + } + + +def dreamzero_parity_vllm_params(model: str | None = None): + from tests.helpers.runtime import OmniServerParams + from tests.helpers.stage_config import get_deploy_config_path + + parallel_size = dreamzero_cfg_parallel_size() + server_args = [ + "--served-model-name", + "dreamzero-droid", + "--enforce-eager", + ] + if parallel_size > 1: + server_args.extend(["--cfg-parallel-size", str(parallel_size)]) + return OmniServerParams( + model=model or os.environ.get("VLLM_DREAMZERO_MODEL", _DREAMZERO_DEFAULT_MODEL), + stage_config_path=get_deploy_config_path("dreamzero_tp1_cfg2.yaml"), + server_args=server_args, + env_dict=_dreamzero_parity_vllm_env(), + ) + + +def _dreamzero_omni_server_from_params(params): + from tests.helpers.runtime import OmniServer + + server_args = list(params.server_args or []) + if params.stage_config_path is not None: + server_args += ["--deploy-config", params.stage_config_path] + return OmniServer( + params.model, + server_args, + port=params.port, + env_dict=params.env_dict, + use_omni=params.use_omni, + ) + + +@contextmanager +def dreamzero_parity_vllm_client( + params=None, + *, + ready_timeout_s: float = _DREAMZERO_DEFAULT_READY_TIMEOUT_S, +) -> Iterator["DreamZeroParityClient"]: + """Start vLLM via ``OmniServer`` and return a ready OpenPI parity client.""" + with _dreamzero_omni_server_from_params(params or dreamzero_parity_vllm_params()) as server: + client = _dreamzero_wait_for_client_ready( + lambda: _dreamzero_create_openpi_vllm_client(server.host, server.port), + timeout_s=ready_timeout_s, + proc=server.proc, + ) + yield DreamZeroParityClient(client) + + +def _dreamzero_create_openpi_vllm_client(host: str, port: int) -> Any: + from openpi_client import msgpack_numpy + + _, WebsocketClientPolicy = load_dreamzero_upstream_modules() + + class _OpenPIWebsocketClientPolicy(WebsocketClientPolicy): + def __init__( + self, + host: str = "127.0.0.1", + port: int = 8000, + path: str = _DREAMZERO_OPENPI_VLLM_PATH, + ) -> None: + self._uri = f"ws://{host}:{port}{path}" + self._packer = msgpack_numpy.Packer() + self._ws, self._server_metadata = self._wait_for_server() + + return _OpenPIWebsocketClientPolicy(host=host, port=port) + + +def _dreamzero_stop_process(proc: subprocess.Popen[str]) -> None: + log_file = getattr(proc, "_codex_log_file", None) + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=30) + except subprocess.TimeoutExpired: # pragma: no cover - cleanup path + proc.kill() + proc.wait(timeout=10) + if log_file is not None: + log_file.close() + + +def _dreamzero_wait_for_client_ready( + client_factory: Callable[[], Any], + *, + timeout_s: float, + proc: subprocess.Popen[str] | None = None, + log_path: Path | None = None, +) -> Any: + deadline = time.time() + timeout_s + last_err: Exception | None = None + while time.time() < deadline: + if proc is not None and proc.poll() is not None: + details = "" + if log_path is not None and log_path.exists(): + details = log_path.read_text(errors="replace")[-8000:] + raise RuntimeError(f"Service exited before becoming ready with code {proc.returncode}.\n{details}") + try: + return client_factory() + except Exception as exc: # pragma: no cover - retry path + last_err = exc + time.sleep(1) + raise TimeoutError(f"Timed out waiting for websocket service: {last_err}") + + +def _dreamzero_normalize_reset_response(response: Any) -> str: + from openpi_client import msgpack_numpy + + if isinstance(response, str): + return response + decoded = msgpack_numpy.unpackb(response) + if isinstance(decoded, dict): + return str(decoded.get("status")) + return str(decoded) + + +def normalize_dreamzero_metadata(metadata: dict[str, Any]) -> dict[str, Any]: + normalized = dict(metadata) + if isinstance(normalized.get("image_resolution"), tuple): + normalized["image_resolution"] = list(normalized["image_resolution"]) + return normalized + + +def assert_dreamzero_logs_clean(log_path: Path) -> None: + text = log_path.read_text(errors="replace") + if "SignalException: Process" in text and "got signal: 15" in text: + text = text.split("Traceback (most recent call last):", 1)[0] + assert "Traceback" not in text, text + assert "RuntimeError:" not in text, text + + +def assert_dreamzero_upstream_log_matches_vllm_baseline(log_path: Path) -> None: + text = log_path.read_text(errors="replace") + assert "DIT Compute Steps 8 steps" not in text, text + assert "DIT Compute Steps 16 steps" in text, text + + +class DreamZeroParityClient: + """Connected OpenPI websocket client for DreamZero parity inference.""" + + def __init__(self, client: Any) -> None: + self._client = client + + @property + def raw_client(self) -> Any: + return self._client + + def build_obs_sequence(self) -> tuple[dict[str, Any], dict[str, Any]]: + dreamzero_upstream_client, _ = load_dreamzero_upstream_modules() + camera_frames = dreamzero_upstream_client.load_camera_frames() + chunks = dreamzero_upstream_client.build_frame_schedule( + min(v.shape[0] for v in camera_frames.values()), + 1, + ) + obs0 = dreamzero_upstream_client._make_obs_from_video( + camera_frames, + [0], + _DREAMZERO_DEFAULT_PROMPT, + _DREAMZERO_DEFAULT_SESSION_ID, + ) + obs1 = dreamzero_upstream_client._make_obs_from_video( + camera_frames, + chunks[0], + _DREAMZERO_DEFAULT_PROMPT, + _DREAMZERO_DEFAULT_SESSION_ID, + ) + return obs0, obs1 + + def collect_parity_outputs(self) -> tuple[dict[str, Any], list[np.ndarray]]: + metadata = self._client.get_server_metadata() + obs0, obs1 = self.build_obs_sequence() + outputs = [ + self._client.infer(dict(obs0)), + self._client.infer(dict(obs1)), + ] + assert _dreamzero_normalize_reset_response(self._client.reset({})) == "reset successful" + outputs.append(self._client.infer(dict(obs0))) + self._client._ws.close() + return metadata, outputs + + +class _DreamZeroParityServer(ABC): + host = "127.0.0.1" + + def __init__( + self, + *, + log_path: Path, + port: int | None = None, + ready_timeout_s: float = _DREAMZERO_DEFAULT_READY_TIMEOUT_S, + ) -> None: + from tests.helpers.runtime import get_open_port + + self.log_path = log_path + self.port = get_open_port() if port is None else port + self.ready_timeout_s = ready_timeout_s + self.proc: subprocess.Popen[str] | None = None + self._log_file = None + + @abstractmethod + def _build_argv(self) -> list[str]: + raise NotImplementedError + + @abstractmethod + def _build_env(self) -> dict[str, str]: + raise NotImplementedError + + @abstractmethod + def _create_client(self) -> Any: + raise NotImplementedError + + def _start(self) -> None: + self.log_path.parent.mkdir(parents=True, exist_ok=True) + self._log_file = self.log_path.open("w") + self.proc = subprocess.Popen( + self._build_argv(), + stdout=self._log_file, + stderr=subprocess.STDOUT, + text=True, + cwd=str(Path.cwd()), + env=self._build_env(), + ) + self.proc._codex_log_file = self._log_file # type: ignore[attr-defined] + + def __enter__(self) -> DreamZeroParityClient: + self._start() + client = _dreamzero_wait_for_client_ready( + self._create_client, + timeout_s=self.ready_timeout_s, + proc=self.proc, + log_path=self.log_path, + ) + return DreamZeroParityClient(client) + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + if self.proc is not None: + _dreamzero_stop_process(self.proc) + self.proc = None + + +class DreamZeroUpstreamServer(_DreamZeroParityServer): + """Official DreamZero upstream reference server launched via torchrun.""" + + def _build_argv(self) -> list[str]: + _, checkpoint_dir = require_dreamzero_parity_env() + return [ + sys.executable, + "-m", + "torch.distributed.run", + "--standalone", + "--nproc_per_node", + str(dreamzero_cfg_parallel_size()), + str(_DREAMZERO_UPSTREAM_SERVER_SCRIPT), + "--port", + str(self.port), + "--model_path", + str(checkpoint_dir), + ] + + def _build_env(self) -> dict[str, str]: + from tests.helpers.env import pick_least_used_device_indices + + dreamzero_repo, _ = require_dreamzero_parity_env() + env = os.environ.copy() + env.setdefault("PYTHONPATH", "") + env["PYTHONPATH"] = f"{Path.cwd()}:{dreamzero_repo}:{env['PYTHONPATH']}".rstrip(":") + env["CUDA_VISIBLE_DEVICES"] = ",".join( + str(index) for index in pick_least_used_device_indices(max(dreamzero_cfg_parallel_size(), 1)) + ) + env.setdefault("NO_ALBUMENTATIONS_UPDATE", "1") + env["ATTENTION_BACKEND"] = "torch" + env.setdefault("ENABLE_TENSORRT", "false") + env["ENABLE_DIT_CACHE"] = "false" + env["NUM_DIT_STEPS"] = "16" + env["DYNAMIC_CACHE_SCHEDULE"] = "false" + return env + + def _create_client(self) -> Any: + _, WebsocketClientPolicy = load_dreamzero_upstream_modules() + return WebsocketClientPolicy(host=self.host, port=self.port) diff --git a/tests/e2e/accuracy/hunyuanvideo15_i2v/test_hunyuanvideo15_i2v_video_similarity.py b/tests/e2e/accuracy/hunyuanvideo15_i2v/test_hunyuanvideo15_i2v_video_similarity.py index 84e032770..b830cdc6c 100644 --- a/tests/e2e/accuracy/hunyuanvideo15_i2v/test_hunyuanvideo15_i2v_video_similarity.py +++ b/tests/e2e/accuracy/hunyuanvideo15_i2v/test_hunyuanvideo15_i2v_video_similarity.py @@ -236,7 +236,8 @@ def test_hunyuanvideo15_i2v_serving_matches_offline_video_similarity( probe_binary("ffmpeg") probe_binary("ffprobe") image_source = _resolve_image_source(hunyuanvideo15_i2v_image_source) - validate_image_source(image_source) + # The input image was already consumed by the prerequisite generation + # tests. Avoid re-fetching a remote source when only comparing artifacts. online_path, offline_path = _artifact_paths(image_source) if not online_path.exists(): pytest.skip(f"Missing online artifact from prerequisite test: {online_path}") diff --git a/tests/e2e/accuracy/ltx/__init__.py b/tests/e2e/accuracy/ltx/__init__.py new file mode 100644 index 000000000..08143263b --- /dev/null +++ b/tests/e2e/accuracy/ltx/__init__.py @@ -0,0 +1 @@ +"""LTX family official-reference accuracy tests.""" diff --git a/tests/e2e/accuracy/ltx/run_ltx_reference.py b/tests/e2e/accuracy/ltx/run_ltx_reference.py new file mode 100644 index 000000000..a4a180047 --- /dev/null +++ b/tests/e2e/accuracy/ltx/run_ltx_reference.py @@ -0,0 +1,331 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Generate raw LTX video and audio outputs with the official or Omni runtime.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any + +import numpy as np +import torch +from PIL import Image + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--backend", choices=("official", "omni"), required=True) + parser.add_argument("--request", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--model") + parser.add_argument("--model-class-name") + parser.add_argument("--official-root", type=Path) + parser.add_argument("--checkpoint", type=Path) + parser.add_argument("--gemma-root", type=Path) + parser.add_argument("--enable-layerwise-offload", action="store_true") + return parser.parse_args() + + +def _save_outputs( + output_dir: Path, + *, + video: torch.Tensor, + audio: torch.Tensor, + audio_sample_rate: int, + metadata: dict[str, Any], +) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + video = video.detach().float().cpu().clamp(0.0, 1.0) + audio = audio.detach().float().cpu() + np.save(output_dir / "video.npy", video.numpy()) + np.save(output_dir / "audio.npy", audio.numpy()) + + frame_indices = sorted({0, video.shape[0] // 2, video.shape[0] - 1}) + for index in frame_indices: + frame = video[index].mul(255.0).round().to(torch.uint8).numpy() + Image.fromarray(frame).save(output_dir / f"frame_{index:04d}.png") + + metadata.update( + { + "video_shape": list(video.shape), + "audio_shape": list(audio.shape), + "audio_sample_rate": int(audio_sample_rate), + "frame_indices": frame_indices, + } + ) + (output_dir / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n") + + +def _insert_official_paths(official_root: Path) -> None: + for relative_path in ("packages/ltx-core/src", "packages/ltx-pipelines/src"): + path = str((official_root / relative_path).resolve()) + if path not in sys.path: + sys.path.insert(0, path) + + +def _configure_official_sdpa(pipeline: Any) -> None: + """Use PyTorch SDPA for official connector and denoiser attention.""" + from ltx_core.loader.attention_ops import set_attention_module_op + from ltx_core.model.transformer.attention import PytorchAttention + + class AllValidSDPA(PytorchAttention): + def __call__( + self, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + heads: int, + mask: torch.Tensor | None = None, + ) -> torch.Tensor: + if mask is not None and torch.count_nonzero(mask).item(): + raise ValueError("The LTX accuracy guard cannot discard a non-empty attention mask") + return super().__call__(query, key, value, heads, mask=None) + + attention = AllValidSDPA() + module_op = set_attention_module_op( + attention=attention, + masked_attention=attention, + ) + owners_and_attributes = ( + (pipeline.stage, "_transformer_builder"), + (pipeline.prompt_encoder, "_embeddings_processor_builder"), + ) + for owner, attribute in owners_and_attributes: + builder = getattr(owner, attribute) + setattr( + owner, + attribute, + builder.with_module_ops((*builder.module_ops, module_op)), + ) + + +@torch.inference_mode() +def _run_official(args: argparse.Namespace, request: dict[str, Any]) -> None: + if args.official_root is None or args.checkpoint is None or args.gemma_root is None: + raise ValueError("Official backend requires --official-root, --checkpoint, and --gemma-root") + _insert_official_paths(args.official_root) + # vLLM disables cuDNN SDPA during import; mirror the worker's Gemma dispatch. + torch.backends.cuda.enable_cudnn_sdp(False) + + from ltx_core.components.guiders import MultiModalGuiderParams + from ltx_pipelines.ti2vid_one_stage import TI2VidOneStagePipeline + from ltx_pipelines.utils.args import ImageConditioningInput + from ltx_pipelines.utils.types import OffloadMode + + pipeline = TI2VidOneStagePipeline( + checkpoint_path=str(args.checkpoint), + gemma_root=str(args.gemma_root), + loras=(), + offload_mode=OffloadMode.CPU if args.enable_layerwise_offload else OffloadMode.NONE, + ) + _configure_official_sdpa(pipeline) + image_path = request.get("image") + images = ( + [] + if image_path is None + else [ + ImageConditioningInput( + path=str(image_path), + frame_idx=0, + strength=1.0, + # Both runtimes must receive the same source pixels. The + # official CLI's optional H.264 preprocessing is not part of + # the seeded model trajectory under test. + crf=0, + ) + ] + ) + video, audio = pipeline( + prompt=request["prompt"], + negative_prompt=request["negative_prompt"], + seed=request["seed"], + height=request["height"], + width=request["width"], + num_frames=request["num_frames"], + frame_rate=request["fps"], + num_inference_steps=request["num_inference_steps"], + video_guider_params=MultiModalGuiderParams( + cfg_scale=request["video_cfg_scale"], + stg_scale=request["video_stg_scale"], + rescale_scale=request["video_rescale_scale"], + modality_scale=request["video_modality_scale"], + skip_step=0, + stg_blocks=request["video_stg_blocks"], + ), + audio_guider_params=MultiModalGuiderParams( + cfg_scale=request["audio_cfg_scale"], + stg_scale=request["audio_stg_scale"], + rescale_scale=request["audio_rescale_scale"], + modality_scale=request["audio_modality_scale"], + skip_step=0, + stg_blocks=request["audio_stg_blocks"], + ), + images=images, + max_batch_size=4, + ) + video_tensor = torch.cat([chunk.detach().cpu() for chunk in video], dim=0) + _save_outputs( + args.output_dir, + video=_canonical_video(video_tensor), + audio=audio.waveform, + audio_sample_rate=audio.sampling_rate, + metadata={ + "backend": "official", + "attention_backend": "torch_sdpa", + "official_revision": os.environ.get("VLLM_TEST_LTX_OFFICIAL_REVISION"), + "checkpoint": str(args.checkpoint), + }, + ) + + +def _unwrap_omni_output(output: Any) -> tuple[Any, Any, int]: + from vllm_omni.outputs import OmniRequestOutput + + audio = None + audio_sample_rate = None + frames = output[0] if isinstance(output, list) and output else output + if isinstance(frames, OmniRequestOutput): + multimodal_output = frames.multimodal_output or {} + audio = multimodal_output.get("audio") + audio_sample_rate = multimodal_output.get("audio_sample_rate") + if frames.is_pipeline_output and isinstance(frames.request_output, OmniRequestOutput): + frames = frames.request_output + multimodal_output = frames.multimodal_output or {} + audio = multimodal_output.get("audio", audio) + audio_sample_rate = multimodal_output.get("audio_sample_rate", audio_sample_rate) + if isinstance(frames, OmniRequestOutput): + if not frames.images: + raise ValueError("No video frames found in OmniRequestOutput") + frames = frames.images + + if isinstance(frames, list) and len(frames) == 1: + frames = frames[0] + if isinstance(frames, tuple) and len(frames) == 2: + frames, audio = frames + elif isinstance(frames, dict): + audio = frames.get("audio", audio) + audio_sample_rate = frames.get("audio_sample_rate", audio_sample_rate) + frames = frames.get("frames", frames.get("video")) + + if frames is None or audio is None or audio_sample_rate is None: + raise ValueError("Omni output did not contain video, audio, and audio_sample_rate") + return frames, audio, int(audio_sample_rate) + + +def _canonical_video(video: Any) -> torch.Tensor: + if isinstance(video, list): + if len(video) == 1: + return _canonical_video(video[0]) + tensors = [torch.as_tensor(np.asarray(item) if not isinstance(item, torch.Tensor) else item) for item in video] + if all(item.ndim == 3 for item in tensors): + video = torch.stack(tensors) + elif all(item.ndim == 4 for item in tensors): + video = torch.cat(tensors) + else: + raise ValueError(f"Cannot combine video items with dimensions {[item.ndim for item in tensors]}") + tensor = torch.as_tensor(np.asarray(video) if not isinstance(video, torch.Tensor) else video).detach().cpu() + if tensor.ndim == 5: + tensor = tensor[0] + if tensor.ndim != 4: + raise ValueError(f"Expected a 4D video tensor, got {tuple(tensor.shape)}") + if tensor.shape[-1] in (3, 4): + tensor = tensor[..., :3] + elif tensor.shape[1] in (3, 4): + tensor = tensor[:, :3].permute(0, 2, 3, 1) + elif tensor.shape[0] in (3, 4): + tensor = tensor[:3].permute(1, 2, 3, 0) + else: + raise ValueError(f"Cannot infer video channel dimension from {tuple(tensor.shape)}") + tensor = tensor.float() + if tensor.numel() and tensor.max() > 1: + tensor = tensor / 255.0 + elif tensor.numel() and tensor.min() < 0: + tensor = tensor.clamp(-1.0, 1.0).add(1.0).mul(0.5) + return tensor.clamp(0.0, 1.0) + + +@torch.inference_mode() +def _run_omni(args: argparse.Namespace, request: dict[str, Any]) -> None: + if args.model is None or args.model_class_name is None: + raise ValueError("Omni backend requires --model and --model-class-name") + + attention_config = {"default": {"backend": "TORCH_SDPA"}} + + 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.model_extras import get_extra_body_params, get_model_class_name + from vllm_omni.platforms import current_omni_platform + + generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(request["seed"]) + omni = Omni( + model=args.model, + model_class_name=args.model_class_name, + enforce_eager=True, + enable_layerwise_offload=args.enable_layerwise_offload, + diffusion_attention_config=attention_config, + parallel_config=DiffusionParallelConfig(), + ) + try: + model_class_name = get_model_class_name(omni) + sampling_params = OmniDiffusionSamplingParams( + height=request["height"], + width=request["width"], + generator=generator, + guidance_scale=None, + num_inference_steps=request["num_inference_steps"], + num_frames=request["num_frames"], + fps=request["fps"], + frame_rate=float(request["fps"]), + output_type="np", + ) + guidance = { + key: value for key, value in request.items() if key.startswith("video_") or key.startswith("audio_") + } + apply_declared_extra_args(sampling_params, get_extra_body_params(model_class_name), guidance) + prompt: dict[str, Any] = { + "prompt": request["prompt"], + "negative_prompt": request["negative_prompt"], + } + image_path = request.get("image") + if image_path is not None: + with Image.open(str(image_path)) as source_image: + image = source_image.convert("RGB") + image.load() + prompt["multi_modal_data"] = {"image": image} + output = omni.generate(prompt, sampling_params) + video, audio, audio_sample_rate = _unwrap_omni_output(output) + audio_tensor = torch.as_tensor(np.asarray(audio) if not isinstance(audio, torch.Tensor) else audio) + _save_outputs( + args.output_dir, + video=_canonical_video(video), + audio=audio_tensor, + audio_sample_rate=audio_sample_rate, + metadata={ + "backend": "omni", + "attention_backend": "torch_sdpa", + "model": args.model, + "model_class_name": model_class_name, + }, + ) + finally: + omni.shutdown() + + +def main() -> None: + args = _parse_args() + request = json.loads(args.request.read_text()) + if args.backend == "official": + _run_official(args, request) + else: + _run_omni(args, request) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/accuracy/ltx/test_ltx_official_similarity.py b/tests/e2e/accuracy/ltx/test_ltx_official_similarity.py new file mode 100644 index 000000000..647ece7a7 --- /dev/null +++ b/tests/e2e/accuracy/ltx/test_ltx_official_similarity.py @@ -0,0 +1,440 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""E2E accuracy guard against a pinned Lightricks LTX pipeline revision. + +The comparison runs both runtimes through PyTorch SDPA and uses +``max_batch_size=4`` in the official reference to match Omni's fused guidance +batch. Video and audio guidance use the official non-HQ one-stage defaults; +only the generation shape and step count are reduced for CI runtime. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import pytest +import torch +from huggingface_hub import hf_hub_download, snapshot_download +from torchmetrics.image import PeakSignalNoiseRatio, StructuralSimilarityIndexMeasure + +from tests.e2e.accuracy.helpers import reset_artifact_dir +from tests.helpers.mark import hardware_test + +OFFICIAL_REPOSITORY = "https://github.com/Lightricks/LTX-2.git" +OFFICIAL_REVISION = "9377758131b1ffde4b7f766804590a6617bf2ab9" +# Version selected by this revision's uv.lock. Keep it out of Omni's runtime and dev dependencies. +OFFICIAL_OPENIMAGEIO_VERSION = "3.1.11.0" +PROMPT = ( + "A space shuttle launches vertically above a desert launch pad. Bright exhaust flames and a dense white " + "plume billow beneath it while the camera remains fixed." +) +NEGATIVE_PROMPT = ( + "blurry, out of focus, overexposed, underexposed, low contrast, washed out colors, excessive noise, " + "grainy texture, poor lighting, flickering, motion blur, distorted proportions, unnatural skin tones, " + "deformed facial features, asymmetrical face, missing facial features, extra limbs, disfigured hands, " + "wrong hand count, artifacts around text, inconsistent perspective, camera shake, incorrect depth of field, " + "background too sharp, background clutter, distracting reflections, harsh shadows, inconsistent lighting " + "direction, color banding, cartoonish rendering, 3D CGI look, unrealistic materials, uncanny valley effect, " + "incorrect ethnicity, wrong gender, exaggerated expressions, wrong gaze direction, mismatched lip sync, " + "silent or muted audio, distorted voice, robotic voice, echo, background noise, off-sync audio, incorrect " + "dialogue, added dialogue, repetitive speech, jittery movement, awkward pauses, incorrect timing, unnatural " + "transitions, inconsistent framing, tilted camera, flat lighting, inconsistent tone, cinematic oversaturation, " + "stylized filters, or AI artifacts." +) + +# Both runtimes use PyTorch SDPA with the current Torch dispatch defaults. +ATTENTION_BACKEND = "torch_sdpa" +VIDEO_SSIM_MEAN_THRESHOLD = 0.95 +VIDEO_SSIM_MIN_THRESHOLD = 0.90 +VIDEO_PSNR_MEAN_THRESHOLD = 30.0 +AUDIO_RELATIVE_L2_THRESHOLD = 0.2 +AUDIO_COSINE_THRESHOLD = 0.95 + + +@dataclass(frozen=True) +class LTXAccuracyCase: + name: str + model_id: str + model_revision: str + model_env: str + model_class_name: str + checkpoint_repo: str + checkpoint_filename: str + checkpoint_revision: str + checkpoint_env: str + stg_block: int + prompt: str = PROMPT + image_repo: str | None = None + image_filename: str | None = None + image_revision: str | None = None + + +CASES = ( + LTXAccuracyCase( + name="ltx2", + model_id="Lightricks/LTX-2", + model_revision="47da56e2ad66ce4125a9922b4a8826bf407f9d0a", + model_env="VLLM_TEST_LTX2_MODEL", + model_class_name="LTX2Pipeline", + checkpoint_repo="Lightricks/LTX-2", + checkpoint_filename="ltx-2-19b-dev.safetensors", + checkpoint_revision="47da56e2ad66ce4125a9922b4a8826bf407f9d0a", + checkpoint_env="VLLM_TEST_LTX2_OFFICIAL_CHECKPOINT", + stg_block=29, + ), + LTXAccuracyCase( + name="ltx2_3", + model_id="diffusers/LTX-2.3-Diffusers", + model_revision="8eee8edcf067e838b843f926ec4d4cc9b2be1aaf", + model_env="VLLM_TEST_LTX23_MODEL", + model_class_name="LTX2Pipeline", + checkpoint_repo="Lightricks/LTX-2.3", + checkpoint_filename="ltx-2.3-22b-dev.safetensors", + checkpoint_revision="4229404625088d21c4f112eb640fb04a0900ee25", + checkpoint_env="VLLM_TEST_LTX23_OFFICIAL_CHECKPOINT", + stg_block=28, + ), + LTXAccuracyCase( + name="ltx2_3_i2v", + model_id="diffusers/LTX-2.3-Diffusers", + model_revision="8eee8edcf067e838b843f926ec4d4cc9b2be1aaf", + model_env="VLLM_TEST_LTX23_MODEL", + model_class_name="LTX2Pipeline", + checkpoint_repo="Lightricks/LTX-2.3", + checkpoint_filename="ltx-2.3-22b-dev.safetensors", + checkpoint_revision="4229404625088d21c4f112eb640fb04a0900ee25", + checkpoint_env="VLLM_TEST_LTX23_OFFICIAL_CHECKPOINT", + stg_block=28, + image_repo="huggingface/documentation-images", + image_filename="diffusers/svd/rocket.png", + image_revision="645d8364f0c7a101180b364811b5a11a362e4010", + ), +) + + +def _run(command: list[str], *, env: dict[str, str], timeout: int = 1800) -> None: + start = time.perf_counter() + subprocess.run(command, env=env, timeout=timeout, check=True) + print(f"{' '.join(command[:3])} finished in {time.perf_counter() - start:.1f}s") + + +def _clone_official_source(root: Path, revision: str) -> None: + root.mkdir(parents=True) + repository = os.environ.get("VLLM_TEST_LTX_OFFICIAL_REPOSITORY", OFFICIAL_REPOSITORY) + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "remote", "add", "origin", repository], check=True) + last_error: subprocess.CalledProcessError | None = None + for attempt in range(3): + try: + subprocess.run( + ["git", "-C", str(root), "fetch", "--depth", "1", "origin", revision], + check=True, + ) + last_error = None + break + except subprocess.CalledProcessError as error: + last_error = error + if attempt < 2: + time.sleep(5 * (attempt + 1)) + if last_error is not None: + raise last_error + subprocess.run(["git", "-C", str(root), "checkout", "-q", "--detach", "FETCH_HEAD"], check=True) + + +def _git_revision(root: Path) -> str | None: + try: + return subprocess.check_output( + ["git", "-C", str(root), "rev-parse", "HEAD"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except subprocess.CalledProcessError: + return None + + +def _official_source(artifact_root: Path) -> tuple[Path, str]: + revision = os.environ.get("VLLM_TEST_LTX_OFFICIAL_REVISION", OFFICIAL_REVISION) + configured_root = os.environ.get("VLLM_TEST_LTX_OFFICIAL_ROOT") + root = Path(configured_root) if configured_root else artifact_root / f"official-source-{revision[:12]}" + actual_revision = _git_revision(root) if root.exists() else None + if actual_revision != revision and configured_root: + raise AssertionError(f"Official source revision mismatch: {actual_revision} != {revision}") + if actual_revision != revision: + if root.exists(): + shutil.rmtree(root) + _clone_official_source(root, revision) + actual_revision = _git_revision(root) + assert actual_revision == revision, f"Official source revision mismatch: {actual_revision} != {revision}" + return root, revision + + +def _official_runner_prefix() -> list[str]: + """Run the reference with its missing binary dependency isolated from CI.""" + uv = shutil.which("uv") + assert uv is not None, "uv is required to run the pinned official LTX reference" + return [ + uv, + "run", + "--no-project", + "--with", + f"openimageio=={OFFICIAL_OPENIMAGEIO_VERSION}", + "--python", + sys.executable, + "python", + ] + + +def _resolve_model(case: LTXAccuracyCase) -> Path: + configured_model = os.environ.get(case.model_env) + if configured_model and Path(configured_model).exists(): + return Path(configured_model) + model_id = configured_model or case.model_id + revision = os.environ.get(f"{case.model_env}_REVISION") + if revision is None and model_id == case.model_id: + revision = case.model_revision + return Path( + snapshot_download( + repo_id=model_id, + revision=revision, + allow_patterns=[ + "model_index.json", + "audio_vae/*", + "connectors/*", + "processor/*", + "scheduler/*", + "text_encoder/config.json", + "text_encoder/generation_config.json", + "text_encoder/model*", + "tokenizer/*", + "transformer/*", + "vae/*", + "vocoder/*", + ], + ) + ) + + +def _resolve_gemma_root(model: Path) -> Path: + configured_root = os.environ.get("VLLM_TEST_LTX_GEMMA_ROOT") + if configured_root: + root = Path(configured_root) + assert root.is_dir(), f"Gemma root not found: {root}" + return root + return model + + +def _resolve_checkpoint(case: LTXAccuracyCase, model: Path) -> Path: + configured_checkpoint = os.environ.get(case.checkpoint_env) + if configured_checkpoint: + checkpoint = Path(configured_checkpoint) + assert checkpoint.is_file(), f"Official checkpoint not found: {checkpoint}" + return checkpoint + model_checkpoint = model / case.checkpoint_filename + if model_checkpoint.is_file(): + return model_checkpoint + return Path( + hf_hub_download( + repo_id=case.checkpoint_repo, + filename=case.checkpoint_filename, + revision=case.checkpoint_revision, + ) + ) + + +def _resolve_image(case: LTXAccuracyCase) -> Path | None: + if case.image_filename is None: + return None + if case.image_repo is None or case.image_revision is None: + raise ValueError(f"Incomplete image source for LTX accuracy case {case.name!r}.") + configured_image = os.environ.get("VLLM_TEST_LTX_I2V_IMAGE") + if configured_image: + image = Path(configured_image) + assert image.is_file(), f"LTX I2V conditioning image not found: {image}" + return image + return Path( + hf_hub_download( + repo_id=case.image_repo, + repo_type="dataset", + filename=case.image_filename, + revision=case.image_revision, + ) + ) + + +def _request(case: LTXAccuracyCase, image: Path | None) -> dict[str, object]: + request: dict[str, object] = { + "prompt": case.prompt, + "negative_prompt": NEGATIVE_PROMPT, + "width": 512, + "height": 384, + "num_frames": 25, + "fps": 24, + "num_inference_steps": 20, + "seed": 42, + "video_cfg_scale": 3.0, + "audio_cfg_scale": 7.0, + "video_stg_scale": 1.0, + "audio_stg_scale": 1.0, + "video_modality_scale": 3.0, + "audio_modality_scale": 3.0, + "video_rescale_scale": 0.7, + "audio_rescale_scale": 0.7, + "video_stg_blocks": [case.stg_block], + "audio_stg_blocks": [case.stg_block], + } + if image is not None: + request["image"] = str(image.resolve()) + return request + + +def _video_metrics(reference: np.ndarray, prediction: np.ndarray) -> dict[str, float]: + assert reference.shape == prediction.shape + assert reference.ndim == 4 and reference.shape[-1] == 3 + ssim_scores: list[float] = [] + psnr_scores: list[float] = [] + for reference_frame, prediction_frame in zip(reference, prediction, strict=True): + reference_tensor = torch.from_numpy(reference_frame).permute(2, 0, 1).unsqueeze(0) + prediction_tensor = torch.from_numpy(prediction_frame).permute(2, 0, 1).unsqueeze(0) + ssim_scores.append(float(StructuralSimilarityIndexMeasure(data_range=1.0)(prediction_tensor, reference_tensor))) + psnr_scores.append(float(PeakSignalNoiseRatio(data_range=1.0)(prediction_tensor, reference_tensor))) + difference = np.abs(reference.astype(np.float64) - prediction.astype(np.float64)) + return { + "ssim_mean": float(np.mean(ssim_scores)), + "ssim_min": float(np.min(ssim_scores)), + "psnr_mean_db": float(np.mean(psnr_scores)), + "max_abs": float(difference.max()), + "mean_abs": float(difference.mean()), + } + + +def _canonical_audio(audio: np.ndarray) -> np.ndarray: + while audio.ndim > 2 and audio.shape[0] == 1: + audio = audio[0] + return audio.astype(np.float64) + + +def _audio_metrics(reference: np.ndarray, prediction: np.ndarray) -> dict[str, float | bool]: + reference = _canonical_audio(reference) + prediction = _canonical_audio(prediction) + assert reference.shape == prediction.shape + difference = reference - prediction + reference_norm = max(float(np.linalg.norm(reference)), 1e-12) + prediction_norm = max(float(np.linalg.norm(prediction)), 1e-12) + return { + "bitwise_equal": bool(np.array_equal(reference, prediction)), + "max_abs": float(np.abs(difference).max()), + "mean_abs": float(np.abs(difference).mean()), + "relative_l2": float(np.linalg.norm(difference) / reference_norm), + "cosine_similarity": float(np.vdot(reference.ravel(), prediction.ravel()) / (reference_norm * prediction_norm)), + } + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: case.name) +@pytest.mark.full_model +@pytest.mark.benchmark +@pytest.mark.diffusion +@hardware_test(res={"cuda": "H100"}, num_cards=1) +def test_ltx_one_stage_matches_official(case: LTXAccuracyCase, accuracy_artifact_root: Path) -> None: + """Compare official and Omni raw AV outputs from the same E2E request.""" + output_root = reset_artifact_dir(accuracy_artifact_root / "ltx_official" / case.name) + official_root, official_revision = _official_source(accuracy_artifact_root / "ltx_official") + model = _resolve_model(case) + gemma_root = _resolve_gemma_root(model) + checkpoint = _resolve_checkpoint(case, model) + image = _resolve_image(case) + request_path = output_root / "request.json" + request_path.write_text(json.dumps(_request(case, image), indent=2) + "\n") + + runner = Path(__file__).with_name("run_ltx_reference.py") + runner_args = [ + str(runner), + "--request", + str(request_path), + ] + if os.environ.get("VLLM_TEST_LTX_ENABLE_LAYERWISE_OFFLOAD", "").lower() in {"1", "true", "yes", "on"}: + runner_args.append("--enable-layerwise-offload") + env = os.environ.copy() + env["VLLM_TEST_LTX_OFFICIAL_REVISION"] = official_revision + env["PYTHONUNBUFFERED"] = "1" + repository_root = Path(__file__).resolve().parents[4] + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(repository_root) if not existing_pythonpath else f"{repository_root}{os.pathsep}{existing_pythonpath}" + ) + + official_output = output_root / "official" + _run( + _official_runner_prefix() + + runner_args + + [ + "--backend", + "official", + "--output-dir", + str(official_output), + "--official-root", + str(official_root), + "--checkpoint", + str(checkpoint), + "--gemma-root", + str(gemma_root), + ], + env=env, + ) + + omni_output = output_root / "omni" + _run( + [sys.executable] + + runner_args + + [ + "--backend", + "omni", + "--output-dir", + str(omni_output), + "--model", + str(model), + "--model-class-name", + case.model_class_name, + ], + env=env, + ) + + official_metadata = json.loads((official_output / "metadata.json").read_text()) + omni_metadata = json.loads((omni_output / "metadata.json").read_text()) + assert official_metadata["attention_backend"] == ATTENTION_BACKEND + assert omni_metadata["attention_backend"] == ATTENTION_BACKEND + assert official_metadata["audio_sample_rate"] == omni_metadata["audio_sample_rate"] + video_metrics = _video_metrics( + np.load(official_output / "video.npy"), + np.load(omni_output / "video.npy"), + ) + audio_metrics = _audio_metrics( + np.load(official_output / "audio.npy"), + np.load(omni_output / "audio.npy"), + ) + result = { + "case": case.name, + "task": "i2v" if image is not None else "t2v", + "attention_backend": ATTENTION_BACKEND, + "official_revision": official_revision, + "model_revision": case.model_revision, + "checkpoint_revision": case.checkpoint_revision, + "video": video_metrics, + "audio": audio_metrics, + } + (output_root / "metrics.json").write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result, indent=2)) + + assert video_metrics["ssim_mean"] >= VIDEO_SSIM_MEAN_THRESHOLD + assert video_metrics["ssim_min"] >= VIDEO_SSIM_MIN_THRESHOLD + assert video_metrics["psnr_mean_db"] >= VIDEO_PSNR_MEAN_THRESHOLD + assert audio_metrics["relative_l2"] <= AUDIO_RELATIVE_L2_THRESHOLD + assert audio_metrics["cosine_similarity"] >= AUDIO_COSINE_THRESHOLD diff --git a/tests/e2e/accuracy/minimax_h3/__init__.py b/tests/e2e/accuracy/minimax_h3/__init__.py new file mode 100644 index 000000000..0dbed1df5 --- /dev/null +++ b/tests/e2e/accuracy/minimax_h3/__init__.py @@ -0,0 +1 @@ +"""MiniMax H3 accuracy tests.""" diff --git a/tests/e2e/accuracy/minimax_h3/test_minimax_h3_t2va_similarity.py b/tests/e2e/accuracy/minimax_h3/test_minimax_h3_t2va_similarity.py new file mode 100644 index 000000000..33c8ae065 --- /dev/null +++ b/tests/e2e/accuracy/minimax_h3/test_minimax_h3_t2va_similarity.py @@ -0,0 +1,216 @@ +# 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, + ) diff --git a/tests/e2e/accuracy/test_cosmos3_similarity.py b/tests/e2e/accuracy/test_cosmos3_similarity.py index ff2350096..efa095fb1 100644 --- a/tests/e2e/accuracy/test_cosmos3_similarity.py +++ b/tests/e2e/accuracy/test_cosmos3_similarity.py @@ -42,8 +42,6 @@ def _server_args() -> list[str]: return [ "--num-gpus", "1", - "--model-class-name", - "Cosmos3OmniDiffusersPipeline", "--stage-init-timeout", "900", "--init-timeout", diff --git a/tests/e2e/accuracy/test_diffusers_backend_similarity.py b/tests/e2e/accuracy/test_diffusers_backend_similarity.py index 529152290..9e5485361 100644 --- a/tests/e2e/accuracy/test_diffusers_backend_similarity.py +++ b/tests/e2e/accuracy/test_diffusers_backend_similarity.py @@ -56,6 +56,10 @@ SEED = 42 SSIM_THRESHOLD = 0.97 PSNR_THRESHOLD = 30.0 +# A single-shot latency measurement on this benchmark is dominated by CI variance +# (see issue #5108). Warm up once, then report the median of several timed runs. +NUM_TIMED_RUNS = 2 + VIDEO_PROMPT = "The bear in the image dances happily" VIDEO_WIDTH = 832 VIDEO_HEIGHT = 480 @@ -181,25 +185,38 @@ def _run_vllm_omni_qwen_image(*, model: str, output_path: Path) -> tuple[Image.I # "--enable-diffusion-pipeline-profiler", ] with OmniServer(model, server_args, use_omni=True) as omni_server: - start_time = time.perf_counter() - response = requests.post( - f"http://{omni_server.host}:{omni_server.port}/v1/images/generations", - json={ - "model": omni_server.model, - "prompt": PROMPT, - "size": f"{WIDTH}x{HEIGHT}", - "n": 1, - "response_format": "b64_json", - "negative_prompt": NEGATIVE_PROMPT, - "num_inference_steps": NUM_INFERENCE_STEPS, - "true_cfg_scale": TRUE_CFG_SCALE, - "seed": SEED, - }, - timeout=600, - ) - end_time = time.perf_counter() - e2e_latency = end_time - start_time - response.raise_for_status() + url = f"http://{omni_server.host}:{omni_server.port}/v1/images/generations" + request_json = { + "model": omni_server.model, + "prompt": PROMPT, + "size": f"{WIDTH}x{HEIGHT}", + "n": 1, + "response_format": "b64_json", + "negative_prompt": NEGATIVE_PROMPT, + "num_inference_steps": NUM_INFERENCE_STEPS, + "true_cfg_scale": TRUE_CFG_SCALE, + "seed": SEED, + } + + def _timed_request() -> tuple[requests.Response, float]: + start_time = time.perf_counter() + response = requests.post(url, json=request_json, timeout=600) + end_time = time.perf_counter() + response.raise_for_status() + return response, end_time - start_time + + # Warm up the client request path so first-request lazy init is not charged + # to the measurement, mirroring _diffusers_dummy_run on the diffusers side. + _timed_request() + + latencies: list[float] = [] + response: requests.Response | None = None + for _ in range(NUM_TIMED_RUNS): + response, latency = _timed_request() + latencies.append(latency) + e2e_latency = float(np.median(latencies)) + + assert response is not None payload = response.json() assert len(payload["data"]) == 1 image_bytes = base64.b64decode(payload["data"][0]["b64_json"]) @@ -221,20 +238,25 @@ def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> tuple[Image.I pipe.transformer.set_attention_backend("_flash_3_hub") _diffusers_dummy_run(pipe) - generator = torch.Generator(device="cuda").manual_seed(SEED) + latencies: list[float] = [] + result = None with torch.inference_mode(): - start_time = time.perf_counter() - result = pipe( # pyright: ignore[reportCallIssue] - prompt=PROMPT, - negative_prompt=NEGATIVE_PROMPT, - width=WIDTH, - height=HEIGHT, - num_inference_steps=NUM_INFERENCE_STEPS, - true_cfg_scale=TRUE_CFG_SCALE, - generator=generator, - ) - end_time = time.perf_counter() - e2e_latency = end_time - start_time + for _ in range(NUM_TIMED_RUNS): + generator = torch.Generator(device="cuda").manual_seed(SEED) + start_time = time.perf_counter() + result = pipe( # pyright: ignore[reportCallIssue] + prompt=PROMPT, + negative_prompt=NEGATIVE_PROMPT, + width=WIDTH, + height=HEIGHT, + num_inference_steps=NUM_INFERENCE_STEPS, + true_cfg_scale=TRUE_CFG_SCALE, + generator=generator, + ) + end_time = time.perf_counter() + latencies.append(end_time - start_time) + e2e_latency = float(np.median(latencies)) + assert result is not None output_image = result.images[0].convert("RGB") output_image.save(output_path) return output_image, e2e_latency @@ -276,8 +298,14 @@ def test_diffusers_backend_t2i_matches_diffusers(model_id: str, accuracy_artifac ssim_threshold=SSIM_THRESHOLD, psnr_threshold=PSNR_THRESHOLD, ) + # Latency is checked against a warmed median of NUM_TIMED_RUNS runs (see the + # _run_* helpers), so the assertion reflects steady-state rather than a single + # cold shot -- which is what previously made this check flaky (issue #5108). + # Correctness is additionally gated by the SSIM/PSNR assertions above. assert vllm_latency <= latency_threshold, ( - f"VLLM latency ({vllm_latency:.2f}ms) is greater than {latency_threshold_factor * 100}% more than Diffusers latency ({diffusers_latency:.2f}ms)." + f"VLLM latency ({vllm_latency:.2f}ms) is greater than " + f"{latency_threshold_factor * 100}% more than Diffusers latency " + f"({diffusers_latency:.2f}ms)." ) diff --git a/tests/e2e/accuracy/test_dreamzero.py b/tests/e2e/accuracy/test_dreamzero.py new file mode 100644 index 000000000..2507530b3 --- /dev/null +++ b/tests/e2e/accuracy/test_dreamzero.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""DreamZero OpenPI upstream parity accuracy test.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import numpy as np +import pytest + +from tests.e2e.accuracy.helpers import ( + DreamZeroUpstreamServer, + assert_dreamzero_logs_clean, + assert_dreamzero_upstream_log_matches_vllm_baseline, + dreamzero_parity_vllm_client, + load_dreamzero_upstream_modules, + normalize_dreamzero_metadata, +) +from tests.helpers.mark import hardware_test + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" +os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0" + +pytestmark = [pytest.mark.diffusion, pytest.mark.slow] + +EXPECTED_METADATA = { + "image_resolution": [180, 320], + "n_external_cameras": 2, + "needs_wrist_camera": True, + "needs_stereo_camera": False, + "needs_session_id": True, + "action_space": "joint_position", +} + + +@hardware_test(res={"cuda": "H100"}, num_cards=2) +def test_dreamzero_openpi_upstream_parity(tmp_path: Path) -> None: + """Assert vLLM OpenPI actions match the upstream DreamZero reference server.""" + load_dreamzero_upstream_modules() + + upstream_log = tmp_path / "dreamzero_upstream.log" + with DreamZeroUpstreamServer(log_path=upstream_log) as upstream_client: + upstream_metadata, upstream_outputs = upstream_client.collect_parity_outputs() + assert_dreamzero_logs_clean(upstream_log) + assert_dreamzero_upstream_log_matches_vllm_baseline(upstream_log) + + with dreamzero_parity_vllm_client() as vllm_client: + vllm_metadata, vllm_outputs = vllm_client.collect_parity_outputs() + + assert normalize_dreamzero_metadata(upstream_metadata) == EXPECTED_METADATA + assert normalize_dreamzero_metadata(vllm_metadata) == EXPECTED_METADATA + + for idx, (vllm_action, upstream_action) in enumerate(zip(vllm_outputs, upstream_outputs, strict=True)): + np.testing.assert_allclose( + vllm_action, + upstream_action, + rtol=1e-2, + atol=1e-3, + err_msg=f"OpenPI step {idx} output mismatch", + ) diff --git a/tests/e2e/accuracy/test_gebench_h100_smoke.py b/tests/e2e/accuracy/test_gebench_h100_smoke.py deleted file mode 100644 index b17c951e5..000000000 --- a/tests/e2e/accuracy/test_gebench_h100_smoke.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -from benchmarks.accuracy.text_to_image.gbench import main as gbench_main -from tests.e2e.accuracy.helpers import infer_model_label, reset_artifact_dir -from tests.helpers.mark import hardware_test - -pytestmark = [pytest.mark.diffusion, pytest.mark.full_model] - - -@pytest.mark.benchmark -@hardware_test(res={"cuda": "H100"}, num_cards=1) -@pytest.mark.skip(reason="#3256") -def test_gebench_h100_smoke( - gebench_accuracy_servers, - accuracy_artifact_root: Path, - gebench_dataset_root: Path, - accuracy_workers: int, -) -> None: - model_label = infer_model_label(gebench_accuracy_servers.generate_params.model).lower() - output_root = reset_artifact_dir(accuracy_artifact_root / f"gebench_{model_label}") - - with gebench_accuracy_servers.generate_server() as generate_server: - for data_type in ("type3", "type4"): - assert ( - gbench_main( - [ - "generate", - "--dataset-root", - str(gebench_dataset_root), - "--output-root", - str(output_root), - "--base-url", - f"http://{generate_server.host}:{generate_server.port}", - "--model", - generate_server.model, - "--data-type", - data_type, - "--width", - "768", - "--height", - "576", - "--output-compression", - "98", - "--num-inference-steps", - "8", - "--workers", - str(accuracy_workers), - ] - ) - == 0 - ) - - with gebench_accuracy_servers.judge_server() as judge_server: - for data_type in ("type3", "type4"): - assert ( - gbench_main( - [ - "evaluate", - "--dataset-root", - str(gebench_dataset_root), - "--output-root", - str(output_root), - "--data-type", - data_type, - "--judge-base-url", - f"http://{judge_server.host}:{judge_server.port}", - "--judge-model", - judge_server.model, - "--judge-api-key", - "EMPTY", - "--workers", - str(accuracy_workers), - ] - ) - == 0 - ) - - assert gbench_main(["summarize", "--output-root", str(output_root)]) == 0 - - summary = json.loads((output_root / "summary.json").read_text(encoding="utf-8")) - assert "generation" in summary - assert "evaluation" in summary - - for data_type in ("type3", "type4"): - assert data_type in summary["generation"]["by_type"] - assert summary["generation"]["by_type"][data_type]["count"] > 0 - assert data_type in summary["evaluation"]["by_type"] - assert summary["evaluation"]["by_type"][data_type]["count"] > 0 - - assert summary["evaluation"]["overall_mean"] >= 0.45 - assert summary["evaluation"]["by_type"]["type3"]["overall_mean"] >= 0.45 - assert summary["evaluation"]["by_type"]["type4"]["overall_mean"] >= 0.45 diff --git a/tests/e2e/accuracy/test_gedit_bench_h100_smoke.py b/tests/e2e/accuracy/test_gedit_bench_h100_smoke.py deleted file mode 100644 index a7831fac8..000000000 --- a/tests/e2e/accuracy/test_gedit_bench_h100_smoke.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -from benchmarks.accuracy.image_to_image.gedit_bench import GROUPS -from benchmarks.accuracy.image_to_image.gedit_bench import main as gedit_main -from tests.e2e.accuracy.helpers import infer_model_label, reset_artifact_dir -from tests.helpers.mark import hardware_test - -pytestmark = [pytest.mark.diffusion, pytest.mark.full_model] - - -@pytest.mark.benchmark -@hardware_test(res={"cuda": "H100"}, num_cards=1) -@pytest.mark.skip(reason="#3257") -def test_gedit_bench_h100_smoke( - gedit_accuracy_servers, - accuracy_artifact_root: Path, - gedit_dataset_root: Path, - gedit_samples_per_group: int, - accuracy_workers: int, -) -> None: - model_label = infer_model_label(gedit_accuracy_servers.generate_params.model).lower() - output_root = reset_artifact_dir(accuracy_artifact_root / f"gedit_results_{model_label}") - score_root = reset_artifact_dir(accuracy_artifact_root / f"gedit_scores_{model_label}") - model_name = model_label - - with gedit_accuracy_servers.generate_server() as generate_server: - assert ( - gedit_main( - [ - "generate", - "--dataset-ref", - str(gedit_dataset_root), - "--output-root", - str(output_root), - "--base-url", - f"http://{generate_server.host}:{generate_server.port}", - "--model", - generate_server.model, - "--model-name", - model_name, - "--task-type", - "all", - "--instruction-language", - "all", - "--samples-per-group", - str(gedit_samples_per_group), - "--workers", - str(accuracy_workers), - ] - ) - == 0 - ) - - with gedit_accuracy_servers.judge_server() as judge_server: - assert ( - gedit_main( - [ - "evaluate", - "--dataset-ref", - str(gedit_dataset_root), - "--output-root", - str(output_root), - "--model-name", - model_name, - "--save-dir", - str(score_root), - "--task-type", - "all", - "--instruction-language", - "all", - "--judge-base-url", - f"http://{judge_server.host}:{judge_server.port}", - "--judge-model", - judge_server.model, - "--judge-api-key", - "EMPTY", - "--samples-per-group", - str(gedit_samples_per_group), - "--workers", - str(accuracy_workers), - ] - ) - == 0 - ) - - csv_path = score_root / f"{model_name}_all_all_vie_score.csv" - assert gedit_main(["summarize", "--csv-path", str(csv_path), "--language", "all"]) == 0 - - summary_path = score_root / f"{model_name}_all_all_summary.json" - summary = json.loads(summary_path.read_text(encoding="utf-8")) - assert set(summary["languages"]) == {"en", "cn"} - - for language in ["en", "cn"]: - language_summary = summary["languages"][language] - assert language_summary["overall"]["count"] is not None - assert language_summary["intersection"]["count"] is not None - assert language_summary["overall"]["Q_SC"] is not None - assert language_summary["overall"]["Q_PQ"] is not None - assert language_summary["overall"]["Q_O"] is not None - - for group in GROUPS: - group_summary = language_summary["by_group"][group] - assert set(group_summary) == {"count", "Q_SC", "Q_PQ", "Q_O"} - - assert summary["languages"]["en"]["overall"]["Q_SC"] >= 6.95 - assert summary["languages"]["en"]["overall"]["Q_PQ"] >= 5.8 - assert summary["languages"]["en"]["overall"]["Q_O"] >= 6.15 - assert summary["languages"]["cn"]["overall"]["Q_SC"] >= 6.9 - assert summary["languages"]["cn"]["overall"]["Q_PQ"] >= 5.7 - assert summary["languages"]["cn"]["overall"]["Q_O"] >= 6.1 diff --git a/tests/e2e/accuracy/test_hunyuan_image3.py b/tests/e2e/accuracy/test_hunyuan_image3.py index 00e77fc8f..f28ac16b9 100644 --- a/tests/e2e/accuracy/test_hunyuan_image3.py +++ b/tests/e2e/accuracy/test_hunyuan_image3.py @@ -28,6 +28,7 @@ from tests.e2e.accuracy.helpers import ( model_output_dir, ) from tests.helpers.mark import hardware_test +from tests.helpers.media import get_asset_path from tests.helpers.runtime import OmniRunner, OmniServer from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import ( build_prompt_tokens, @@ -94,6 +95,7 @@ def _default_ar_dit_devices() -> tuple[str, str]: AR_DEVICES, DIT_DEVICES = _default_ar_dit_devices() MODEL_NAME = "tencent/HunyuanImage-3.0-Instruct" +HUNYUAN_IMAGE_REF_PATH = get_asset_path("hunyuan/hunyuan_image_ref.png") NUM_INFERENCE_STEPS = 50 GUIDANCE_SCALE = 2.5 @@ -141,7 +143,6 @@ _DEPLOY_CONFIG = { "connectors": { "shared_memory_connector": { "name": "SharedMemoryConnector", - "extra": {"shm_threshold_bytes": 65536}, }, }, "stages": [ @@ -298,7 +299,7 @@ def _run_offline(deploy_config_path: str, output_path: Path) -> tuple[Image.Imag system_prompt_type = result.system_prompt_type ar_stop_token_ids = resolve_stop_token_ids(task="it2i", bot_task="think_recaption", tokenizer=tokenizer) - with OmniRunner(MODEL_PATH, deploy_config=deploy_config_path) as runner: + with OmniRunner(MODEL_PATH, deploy_config=deploy_config_path, trust_remote_code=True) as runner: params_list = list(runner.omni.default_sampling_params_list) for sp in params_list: if isinstance(sp, OmniDiffusionSamplingParams): @@ -358,16 +359,17 @@ def _run_offline(deploy_config_path: str, output_path: Path) -> tuple[Image.Imag return image, cot_text, elapsed -def _run_online(stage_configs_path: str, output_path: Path) -> tuple[Image.Image, str, float]: +def _run_online(deploy_config_path: str, output_path: Path) -> tuple[Image.Image, str, float]: from benchmarks.accuracy.common import decode_base64_image, pil_to_png_bytes server_args = [ - "--stage-configs-path", - stage_configs_path, + "--deploy-config", + deploy_config_path, "--stage-init-timeout", "300", "--init-timeout", "900", + "--trust-remote-code", ] try: with OmniServer(MODEL_PATH, server_args, use_omni=True) as omni_server: @@ -414,7 +416,7 @@ def _run_online(stage_configs_path: str, output_path: Path) -> tuple[Image.Image torch.accelerator.device_count() < AR_TP_SIZE + DIT_TP_SIZE, reason=f"Needs {AR_TP_SIZE + DIT_TP_SIZE}+ GPUs ({AR_TP_SIZE} AR + {DIT_TP_SIZE} DiT)", ) -def test_image_to_image_alignment_online(accuracy_artifact_root: Path, accuracy_assets_root: Path) -> None: +def test_image_to_image_alignment_online(accuracy_artifact_root: Path) -> None: """Online API test: same pipeline, same seed as offline → PSNR >= 10 dB.""" if importlib.util.find_spec("FlagEmbedding") is None: raise ImportError("Missing dependency: FlagEmbedding\nInstall with: pip install FlagEmbedding") @@ -431,7 +433,7 @@ def test_image_to_image_alignment_online(accuracy_artifact_root: Path, accuracy_ scorer = SemanticSimilarityScorer() clip_scorer = CLIPScorer() cot_results = scorer.text_similarity(online_cot, COT_REF) - image_ref = Image.open(str(accuracy_assets_root / "hunyuan_image_ref.png")).convert("RGB") + image_ref = Image.open(HUNYUAN_IMAGE_REF_PATH).convert("RGB") image_clip_score = clip_scorer.image_image_score(online_image, image_ref) ssim_value, psnr_value = compute_image_ssim_psnr(prediction=online_image, reference=image_ref, compare_mode="RGB") @@ -491,7 +493,12 @@ def _run_dit_model( os.environ["VLLM_NVFP4_GEMM_BACKEND"] = nvfp4_backend try: - with OmniRunner(model, deploy_config=deploy_config_path, mode="text-to-image") as runner: + with OmniRunner( + model, + deploy_config=deploy_config_path, + mode="text-to-image", + trust_remote_code=True, + ) as runner: generator = torch.Generator(device=current_omni_platform.device_type or "cuda").manual_seed(SEED) params = OmniDiffusionSamplingParams( height=QUANT_HEIGHT, @@ -524,7 +531,7 @@ def _run_dit_model( torch.accelerator.device_count() < AR_TP_SIZE + DIT_TP_SIZE, reason=f"Needs {AR_TP_SIZE + DIT_TP_SIZE}+ GPUs ({AR_TP_SIZE} AR + {DIT_TP_SIZE} DiT)", ) -def test_image_to_image_alignment(accuracy_artifact_root: Path, accuracy_assets_root: Path) -> None: +def test_image_to_image_alignment(accuracy_artifact_root: Path) -> None: if importlib.util.find_spec("FlagEmbedding") is None: raise ImportError("Missing dependency: FlagEmbedding\nInstall with: pip install FlagEmbedding") from tabulate import tabulate # lazy import @@ -540,7 +547,7 @@ def test_image_to_image_alignment(accuracy_artifact_root: Path, accuracy_assets_ scorer = SemanticSimilarityScorer() clip_scorer = CLIPScorer() cot_results = scorer.text_similarity(omni_cot, COT_REF) - image_ref = Image.open(str(accuracy_assets_root / "hunyuan_image_ref.png")).convert("RGB") + image_ref = Image.open(HUNYUAN_IMAGE_REF_PATH).convert("RGB") image_clip_score = clip_scorer.image_image_score(omni_image, image_ref) ssim_value, psnr_value = compute_image_ssim_psnr(prediction=omni_image, reference=image_ref, compare_mode="RGB") diff --git a/tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py b/tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py index 6ac930f46..16cd9c13c 100644 --- a/tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py +++ b/tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 import copy import io +import logging import os import tempfile from pathlib import Path @@ -14,10 +15,13 @@ from PIL import Image from tests.e2e.accuracy.helpers import assert_images_pixel_close, assert_similarity, model_output_dir from tests.helpers.mark import hardware_test +from tests.helpers.media import get_asset_path from tests.helpers.runtime import OmniServer pytestmark = [pytest.mark.full_model, pytest.mark.diffusion] +logger = logging.getLogger(__name__) + MODEL_NAME = "tencent/HunyuanImage-3.0-Instruct" SEED = 42 NUM_INFERENCE_STEPS = 50 @@ -28,10 +32,28 @@ PROMPT = "A brown and white dog is running on the grass." MEAN_THRESHOLD = 3e-2 P99_THRESHOLD = 3e-1 SSIM_THRESHOLD = 0.97 -PSNR_THRESHOLD = 30.0 +PSNR_THRESHOLD_CUDA = 30.0 +PSNR_THRESHOLD_NPU = 26.0 + + +def _psnr_threshold() -> float: + from vllm_omni.platforms import current_omni_platform + + return PSNR_THRESHOLD_NPU if current_omni_platform.is_npu() else PSNR_THRESHOLD_CUDA + _REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent -BASELINE_PATH = _REPO_ROOT / "tests" / "assets" / "hunyuan" / "hunyuan_baseline.png" + + +def _baseline_path() -> Path: + from vllm_omni.platforms import current_omni_platform + + suffix = "_npu" if current_omni_platform.is_npu() else "" + path = get_asset_path(f"hunyuan/hunyuan_baseline{suffix}.png") + assert path.exists(), f"Baseline image not found at {path}" + return path + + _OFFLINE_SCRIPT = _REPO_ROOT / "examples" / "offline_inference" / "hunyuan_image3" / "end2end.py" # DiT-only deploy config with trust_remote_code (based on hunyuan_image3_dit.yaml). @@ -48,15 +70,17 @@ _DEPLOY_CONFIG = { "trust_remote_code": True, "devices": "0,1,2,3", # set dynamically by _write_deploy_config "vae_use_slicing": False, + "moe_backend": "flashinfer_cutlass", "vae_use_tiling": False, "parallel_config": { "pipeline_parallel_size": 1, "data_parallel_size": 1, - "tensor_parallel_size": 4, + "tensor_parallel_size": 1, "enable_expert_parallel": True, "sequence_parallel_size": 1, "ulysses_degree": 1, "ring_degree": 1, + "allgather_degree": 4, "cfg_parallel_size": 1, "vae_patch_parallel_size": 1, "use_hsdp": False, @@ -68,6 +92,23 @@ _DEPLOY_CONFIG = { }, }, ], + "platforms": { + "npu": { + "stages": [ + { + "stage_id": 0, + "gpu_memory_utilization": 0.65, + "devices": "0,1,2,3", + "moe_backend": "auto", + "max_num_batched_tokens": 32768, + "parallel_config": { + "tensor_parallel_size": 4, + "enable_expert_parallel": True, + }, + }, + ], + }, + }, } @@ -82,9 +123,48 @@ def _devices() -> str: def _write_deploy_config(path: Path) -> None: config = copy.deepcopy(_DEPLOY_CONFIG) devices = _devices() + num_devices = len(devices.split(",")) config["stages"][0]["devices"] = devices - config["stages"][0]["parallel_config"]["tensor_parallel_size"] = len(devices.split(",")) - path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + parallel_config = config["stages"][0]["parallel_config"] + if parallel_config.get("allgather_degree", 1) > 1: + parallel_config["allgather_degree"] = num_devices + elif parallel_config.get("tensor_parallel_size", 1) > 1: + parallel_config["tensor_parallel_size"] = num_devices + + sequence_parallel_size = ( + parallel_config.get("allgather_degree", 1) + if parallel_config.get("allgather_degree", 1) > 1 + else parallel_config.get("ulysses_degree", 1) * parallel_config.get("ring_degree", 1) + ) + configured_world_size = ( + parallel_config.get("pipeline_parallel_size", 1) + * parallel_config.get("data_parallel_size", 1) + * parallel_config.get("tensor_parallel_size", 1) + * sequence_parallel_size + * parallel_config.get("cfg_parallel_size", 1) + ) + assert configured_world_size == num_devices, ( + f"Configured parallel world size ({configured_world_size}) does not match " + f"the number of devices ({num_devices}): {parallel_config}" + ) + + parallel_config["sequence_parallel_size"] = sequence_parallel_size + config_text = yaml.dump(config, default_flow_style=False, sort_keys=False) + path.write_text(config_text) + logger.info( + "HunyuanImage3 pixel accuracy deploy config:\n" + " path=%s\n" + " CUDA_VISIBLE_DEVICES=%s\n" + " devices=%s\n" + " tensor_parallel_size=%s\n" + " allgather_degree=%s\n%s", + path, + os.environ.get("CUDA_VISIBLE_DEVICES", ""), + devices, + parallel_config.get("tensor_parallel_size", 1), + parallel_config.get("allgather_degree", 1), + config_text, + ) def _run_vllm_omni_hunyuan_image3_online(*, model: str, deploy_config: str, output_path: Path) -> Image.Image: @@ -171,8 +251,8 @@ def _run_vllm_omni_hunyuan_image3_offline(*, model: str, deploy_config: str, out def _assert_against_baseline(image: Image.Image, label: str) -> None: - assert BASELINE_PATH.exists(), f"Baseline image not found at {BASELINE_PATH}" - baseline_image = Image.open(BASELINE_PATH).convert("RGB") + baseline_path = _baseline_path() + baseline_image = Image.open(baseline_path).convert("RGB") assert_images_pixel_close( model_name=f"{MODEL_NAME} ({label} vs baseline)", @@ -186,11 +266,11 @@ def _assert_against_baseline(image: Image.Image, label: str) -> None: vllm_image=image, diffusers_image=baseline_image, ssim_threshold=SSIM_THRESHOLD, - psnr_threshold=PSNR_THRESHOLD, + psnr_threshold=_psnr_threshold(), ) -@hardware_test(res={"cuda": "H100"}, num_cards=4) +@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=4) def test_hunyuan_image3_pixel_accuracy_online(accuracy_artifact_root: Path) -> None: model = _model_name() output_dir = model_output_dir(accuracy_artifact_root, MODEL_NAME) @@ -204,7 +284,7 @@ def test_hunyuan_image3_pixel_accuracy_online(accuracy_artifact_root: Path) -> N _assert_against_baseline(image, "online") -@hardware_test(res={"cuda": "H100"}, num_cards=4) +@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=4) def test_hunyuan_image3_pixel_accuracy_offline(accuracy_artifact_root: Path) -> None: model = _model_name() output_dir = model_output_dir(accuracy_artifact_root, MODEL_NAME) diff --git a/tests/e2e/accuracy/test_ltx2_3_video_similarity.py b/tests/e2e/accuracy/test_ltx2_3_video_similarity.py deleted file mode 100644 index 7fb2297a2..000000000 --- a/tests/e2e/accuracy/test_ltx2_3_video_similarity.py +++ /dev/null @@ -1,410 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -""" -SSIM/PSNR accuracy tests for LTX-2.3. - -1. **Transformer parity** (``test_ltx2_3_transformer_matches_diffusers``): - Swaps our custom transformer into diffusers' ``LTX2Pipeline`` to measure - numerical parity in isolation. Thresholds: SSIM >= 0.95, PSNR >= 28 dB. - Result: SSIM 0.999987 (bit-identical). - -2. **Full pipeline** (``test_ltx2_3_pipeline_matches_diffusers``): - Runs the full vLLM-Omni serving stack (``OmniServer`` -> HTTP API) and - compares per-frame against stock diffusers. Currently skipped because - the OmniServer subprocess creates a different RNG state than in-process - diffusers, producing different initial latents from the same seed. - This is a test infrastructure limitation, not a model accuracy issue. -""" - -from __future__ import annotations - -import gc -import os -import tempfile -from pathlib import Path - -import diffusers -import numpy as np -import pytest -import requests -import torch -from PIL import Image - -from tests.e2e.accuracy.helpers import compute_image_ssim_psnr, model_output_dir -from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup -from tests.helpers.mark import hardware_test -from tests.helpers.runtime import OmniServer - -# Parse diffusers version for compatibility check -_DIFFUSERS_VERSION = tuple(int(x) for x in diffusers.__version__.split(".")[:2] if x.isdigit()) -_DIFFUSERS_038 = _DIFFUSERS_VERSION >= (0, 38) - -MODEL_ID = "dg845/LTX-2.3-Diffusers" -MODEL_ENV_VAR = "VLLM_TEST_LTX23_MODEL" -PROMPT = "A lighthouse on a rocky cliff at sunset, waves crashing below, golden hour lighting" -NEGATIVE_PROMPT = "blurry, low quality, distorted, watermark" -WIDTH = 512 -HEIGHT = 384 -NUM_FRAMES = 25 # ~1 second at 24fps -NUM_INFERENCE_STEPS = 20 -GUIDANCE_SCALE = 4.0 -SEED = 42 - -# Transformer-swap test: near-identical output expected -TRANSFORMER_SSIM_THRESHOLD = 0.95 -TRANSFORMER_PSNR_THRESHOLD = 28.0 - -# Full-pipeline test: allows minor divergence from RNG / pipeline differences -PIPELINE_SSIM_THRESHOLD = 0.94 -PIPELINE_PSNR_THRESHOLD = 28.0 - - -def _model_name() -> str: - return os.environ.get(MODEL_ENV_VAR, MODEL_ID) - - -def _local_files_only(model: str) -> bool: - return Path(model).exists() - - -# --------------------------------------------------------------------------- -# Frame extraction helpers -# --------------------------------------------------------------------------- - - -def _video_to_frames(video_np: np.ndarray) -> list[Image.Image]: - """Convert numpy video to list of PIL Images.""" - while video_np.ndim > 4: - video_np = video_np[0] - if video_np.dtype in (np.float32, np.float64, np.float16): - video_np = np.clip(video_np * 255, 0, 255).astype(np.uint8) - return [Image.fromarray(video_np[t]) for t in range(video_np.shape[0])] - - -def _extract_diffusers_frames(result) -> list[Image.Image]: - """Extract frames from diffusers pipeline output.""" - video = result.frames - if isinstance(video, np.ndarray): - return _video_to_frames(video) - if isinstance(video, list): - if isinstance(video[0], list): - return [img.convert("RGB") for img in video[0]] - if isinstance(video[0], Image.Image): - return [img.convert("RGB") for img in video] - raise ValueError(f"Unexpected output type: {type(video)}") - - -def _extract_mp4_frames(mp4_bytes: bytes) -> list[Image.Image]: - """Extract frames from an MP4 video using ffmpeg.""" - import subprocess - - with tempfile.TemporaryDirectory() as tmpdir: - mp4_path = os.path.join(tmpdir, "video.mp4") - with open(mp4_path, "wb") as f: - f.write(mp4_bytes) - - # Extract video frames as PNG files using ffmpeg - frame_pattern = os.path.join(tmpdir, "frame_%04d.png") - subprocess.run( - ["ffmpeg", "-i", mp4_path, "-vsync", "0", frame_pattern], - capture_output=True, - check=True, - ) - - # Load frames in order - frames = [] - i = 1 - while True: - fpath = os.path.join(tmpdir, f"frame_{i:04d}.png") - if not os.path.exists(fpath): - break - frames.append(Image.open(fpath).convert("RGB").copy()) - i += 1 - return frames - - -# --------------------------------------------------------------------------- -# Comparison helper -# --------------------------------------------------------------------------- - - -def _assert_video_similarity( - *, - model_name: str, - vllm_frames: list[Image.Image], - diffusers_frames: list[Image.Image], - ssim_threshold: float, - psnr_threshold: float, -) -> tuple[float, float]: - """Compare video frames and assert SSIM/PSNR meet thresholds.""" - min_frames = min(len(vllm_frames), len(diffusers_frames)) - assert min_frames > 0, "No frames to compare" - - ssim_scores = [] - psnr_scores = [] - for i in range(min_frames): - ssim_val, psnr_val = compute_image_ssim_psnr( - prediction=vllm_frames[i], - reference=diffusers_frames[i], - ) - ssim_scores.append(ssim_val) - psnr_scores.append(psnr_val) - - avg_ssim = sum(ssim_scores) / len(ssim_scores) - avg_psnr = sum(psnr_scores) / len(psnr_scores) - - print(f"\n{model_name} video similarity ({min_frames} frames):") - print(f" SSIM: avg={avg_ssim:.6f}, min={min(ssim_scores):.6f}, threshold>={ssim_threshold:.6f}") - print(f" PSNR: avg={avg_psnr:.6f} dB, min={min(psnr_scores):.6f} dB, threshold>={psnr_threshold:.6f} dB") - - assert avg_ssim >= ssim_threshold, f"SSIM below threshold: got {avg_ssim:.6f}, expected >= {ssim_threshold:.6f}." - assert avg_psnr >= psnr_threshold, f"PSNR below threshold: got {avg_psnr:.6f}, expected >= {psnr_threshold:.6f}." - return avg_ssim, avg_psnr - - -# --------------------------------------------------------------------------- -# Diffusers baseline (shared by both tests) -# --------------------------------------------------------------------------- - - -def _run_diffusers_baseline(model: str, output_dir: Path) -> list[Image.Image]: - """Generate video using stock diffusers LTX2Pipeline.""" - from diffusers import LTX2Pipeline - - run_pre_test_cleanup() - pipe = None - try: - pipe = LTX2Pipeline.from_pretrained( - model, torch_dtype=torch.bfloat16, local_files_only=_local_files_only(model) - ).to("cuda") - - generator = torch.Generator(device="cuda").manual_seed(SEED) - result = pipe( - prompt=PROMPT, - negative_prompt=NEGATIVE_PROMPT, - width=WIDTH, - height=HEIGHT, - num_frames=NUM_FRAMES, - num_inference_steps=NUM_INFERENCE_STEPS, - guidance_scale=GUIDANCE_SCALE, - generator=generator, - output_type="np", - ) - frames = _extract_diffusers_frames(result) - for i, f in enumerate(frames): - f.save(output_dir / f"diffusers_frame_{i:04d}.png") - return frames - finally: - del pipe - gc.collect() - if torch.cuda.is_available(): - torch.accelerator.empty_cache() - run_post_test_cleanup() - - -# --------------------------------------------------------------------------- -# Test 1: Transformer-swap parity -# --------------------------------------------------------------------------- - - -def _run_with_custom_transformer(model: str, output_dir: Path) -> list[Image.Image]: - """Run diffusers pipeline with our custom transformer swapped in.""" - from contextlib import nullcontext - - from diffusers import LTX2Pipeline - from vllm.config import VllmConfig, set_current_vllm_config - from vllm.distributed.parallel_state import init_distributed_environment, initialize_model_parallel - - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import create_transformer_from_config, load_transformer_config - - vllm_config = VllmConfig() - ctx = set_current_vllm_config(vllm_config) - ctx.__enter__() - - if not torch.distributed.is_initialized(): - os.environ.setdefault("MASTER_ADDR", "localhost") - os.environ.setdefault("MASTER_PORT", "29503") - os.environ.setdefault("RANK", "0") - os.environ.setdefault("WORLD_SIZE", "1") - init_distributed_environment(world_size=1, rank=0, local_rank=0) - initialize_model_parallel(tensor_model_parallel_size=1) - - local = _local_files_only(model) - pipe = LTX2Pipeline.from_pretrained(model, torch_dtype=torch.bfloat16, local_files_only=local) - - transformer_config = load_transformer_config(model, "transformer", local) - our_transformer = create_transformer_from_config(transformer_config) - - diffusers_state = dict(pipe.transformer.named_parameters()) - - def _weight_iter(): - for name, param in diffusers_state.items(): - yield name, param.data - - our_transformer.load_weights(_weight_iter()) - our_transformer = our_transformer.to(dtype=torch.bfloat16, device="cuda").eval() - - # Compatibility shims for diffusers pipeline - our_transformer.dtype = torch.bfloat16 - if not hasattr(our_transformer, "cache_context"): - our_transformer.cache_context = lambda name: nullcontext() - - del pipe.transformer - pipe.transformer = our_transformer - for name, component in pipe.components.items(): - if name != "transformer" and hasattr(component, "to"): - try: - component.to("cuda") - except Exception: - pass - - generator = torch.Generator(device="cuda").manual_seed(SEED) - result = pipe( - prompt=PROMPT, - negative_prompt=NEGATIVE_PROMPT, - width=WIDTH, - height=HEIGHT, - num_frames=NUM_FRAMES, - num_inference_steps=NUM_INFERENCE_STEPS, - guidance_scale=GUIDANCE_SCALE, - generator=generator, - output_type="np", - ) - frames = _extract_diffusers_frames(result) - for i, f in enumerate(frames): - f.save(output_dir / f"vllm_transformer_frame_{i:04d}.png") - - del pipe, result, our_transformer - gc.collect() - if torch.cuda.is_available(): - torch.accelerator.empty_cache() - return frames - - -@pytest.mark.full_model -@pytest.mark.benchmark -@pytest.mark.diffusion -@pytest.mark.skipif( - not _DIFFUSERS_038, reason="LTX-2.3 requires diffusers >= 0.38.0 for cross_attn_mod and BWE vocoder" -) -@hardware_test(res={"cuda": "H100"}, num_cards=1) -def test_ltx2_3_transformer_matches_diffusers(accuracy_artifact_root: Path) -> None: - """Transformer-level parity: swap our transformer into diffusers pipeline. - - Isolates transformer numerical accuracy from pipeline-level differences. - Both runs use diffusers' denoising loop, CFG, scheduler, and RNG. - """ - model = _model_name() - output_dir = model_output_dir(accuracy_artifact_root, MODEL_ID) - - diffusers_frames = _run_diffusers_baseline(model=model, output_dir=output_dir) - vllm_frames = _run_with_custom_transformer(model=model, output_dir=output_dir) - - _assert_video_similarity( - model_name=f"{MODEL_ID} (transformer-swap)", - vllm_frames=vllm_frames, - diffusers_frames=diffusers_frames, - ssim_threshold=TRANSFORMER_SSIM_THRESHOLD, - psnr_threshold=TRANSFORMER_PSNR_THRESHOLD, - ) - - -# --------------------------------------------------------------------------- -# Test 2: Full pipeline (OmniServer → HTTP API vs diffusers) -# --------------------------------------------------------------------------- - - -def _run_vllm_omni_serving(model: str, output_dir: Path) -> list[Image.Image]: - """Generate video via the full vLLM-Omni serving stack.""" - server_args = [ - "--model-class-name", - "LTX23Pipeline", - "--stage-init-timeout", - "600", - ] - with OmniServer(model, server_args, use_omni=True) as server: - # Submit generation request - response = requests.post( - f"http://{server.host}:{server.port}/v1/videos", - files={ - "prompt": (None, PROMPT), - "negative_prompt": (None, NEGATIVE_PROMPT), - "model": (None, server.model), - "num_frames": (None, str(NUM_FRAMES)), - "fps": (None, "24"), - "size": (None, f"{WIDTH}x{HEIGHT}"), - "num_inference_steps": (None, str(NUM_INFERENCE_STEPS)), - "guidance_scale": (None, str(GUIDANCE_SCALE)), - "seed": (None, str(SEED)), - }, - timeout=120, - ) - response.raise_for_status() - video_id = response.json()["id"] - - # Poll for completion - import time - - for _ in range(120): - status_resp = requests.get( - f"http://{server.host}:{server.port}/v1/videos/{video_id}", - timeout=30, - ) - status_resp.raise_for_status() - status = status_resp.json()["status"] - if status == "completed": - break - if status in ("error", "failed"): - raise RuntimeError(f"Video generation failed: {status_resp.json()}") - time.sleep(5) - else: - raise TimeoutError(f"Video generation timed out after 600s (id={video_id})") - - # Download video content - content_resp = requests.get( - f"http://{server.host}:{server.port}/v1/videos/{video_id}/content", - timeout=120, - ) - content_resp.raise_for_status() - mp4_bytes = content_resp.content - - # Save MP4 - mp4_path = output_dir / "vllm_omni_pipeline.mp4" - with open(mp4_path, "wb") as f: - f.write(mp4_bytes) - - # Extract frames - frames = _extract_mp4_frames(mp4_bytes) - for i, frame in enumerate(frames): - frame.save(output_dir / f"vllm_pipeline_frame_{i:04d}.png") - return frames - - -@pytest.mark.full_model -@pytest.mark.benchmark -@pytest.mark.diffusion -@pytest.mark.skipif( - not _DIFFUSERS_038, reason="LTX-2.3 requires diffusers >= 0.38.0 for cross_attn_mod and BWE vocoder" -) -@hardware_test(res={"cuda": "H100"}, num_cards=1) -def test_ltx2_3_pipeline_matches_diffusers(accuracy_artifact_root: Path) -> None: - """Full-pipeline parity: vLLM-Omni serving stack vs diffusers. - - Runs the complete vLLM-Omni OmniServer (subprocess, HTTP API, video - encoding) and compares per-frame against stock diffusers output. - Follows the Wan2.2 / Qwen Image pattern with seed-based determinism. - """ - model = _model_name() - output_dir = model_output_dir(accuracy_artifact_root, MODEL_ID) - - diffusers_frames = _run_diffusers_baseline(model=model, output_dir=output_dir) - vllm_frames = _run_vllm_omni_serving(model=model, output_dir=output_dir) - - _assert_video_similarity( - model_name=f"{MODEL_ID} (full-pipeline)", - vllm_frames=vllm_frames, - diffusers_frames=diffusers_frames, - ssim_threshold=PIPELINE_SSIM_THRESHOLD, - psnr_threshold=PIPELINE_PSNR_THRESHOLD, - ) diff --git a/tests/e2e/accuracy/test_qwen_image_edit.py b/tests/e2e/accuracy/test_qwen_image_edit.py index 338758cc0..9ee9c2c87 100644 --- a/tests/e2e/accuracy/test_qwen_image_edit.py +++ b/tests/e2e/accuracy/test_qwen_image_edit.py @@ -14,6 +14,7 @@ from benchmarks.accuracy.common import decode_base64_image, pil_to_png_bytes from tests.e2e.accuracy.helpers import assert_images_pixel_close, assert_similarity, model_output_dir from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup from tests.helpers.mark import hardware_test +from tests.helpers.media import get_asset_path from tests.helpers.runtime import OmniServer pytestmark = [pytest.mark.full_model, pytest.mark.diffusion] @@ -46,10 +47,9 @@ EDIT_2511_PROMPT = ( "(帽子紧贴头顶,未贴合部分可调整帽子摆放角度达到完全贴合), 头部与身体过渡必须自然,无拼接痕迹," "无额外元素出现在头部四周,纯白背景,不要出现绿色圆圈" ) -EDIT_2511_ASSET_DIR = Path(__file__).resolve().parent / "assets" EDIT_2511_IMAGE_PATHS = [ - EDIT_2511_ASSET_DIR / "qwen_image_edit_2511_test1.png", - EDIT_2511_ASSET_DIR / "qwen_image_edit_2511_test2.png", + get_asset_path("qwen_image_edit/qwen_image_edit_2511_test1.png"), + get_asset_path("qwen_image_edit/qwen_image_edit_2511_test2.png"), ] PROMPT_SINGLE_IMAGE = "The input is a 2D cartoon bear mascot. Restyle it into a painterly oil artwork with warm colors while preserving the main structure." diff --git a/tests/dreamzero/upstream/upstream_socket_server_no_compile.py b/tests/e2e/accuracy/upstream_mock/upstream_socket_server_no_compile.py similarity index 98% rename from tests/dreamzero/upstream/upstream_socket_server_no_compile.py rename to tests/e2e/accuracy/upstream_mock/upstream_socket_server_no_compile.py index a9ef7a3d0..e6fccdd23 100644 --- a/tests/dreamzero/upstream/upstream_socket_server_no_compile.py +++ b/tests/e2e/accuracy/upstream_mock/upstream_socket_server_no_compile.py @@ -24,7 +24,7 @@ PyTorch SDPA (`ATTENTION_BACKEND=torch`) for this subprocess only. Usage: PYTHONPATH="${DREAMZERO_REPO}" \\ .venv/bin/python -m torch.distributed.run --standalone --nproc_per_node=2 \\ - tests/dreamzero/upstream/upstream_socket_server_no_compile.py --port 18081 \\ + tests/e2e/accuracy/upstream_mock/upstream_socket_server_no_compile.py --port 18081 \\ --model_path "${DREAMZERO_REPO}/checkpoints/dreamzero" """ diff --git a/tests/comfyui/conftest.py b/tests/e2e/features/comfyui/conftest.py similarity index 97% rename from tests/comfyui/conftest.py rename to tests/e2e/features/comfyui/conftest.py index 4280d3506..c3ac99bf2 100644 --- a/tests/comfyui/conftest.py +++ b/tests/e2e/features/comfyui/conftest.py @@ -25,7 +25,7 @@ def _setup_comfyui_test_environment(): """Set up the test environment for ComfyUI plugin testing.""" # === Add ComfyUI plugin path to allow importing comfyui_vllm_omni === _COMFYUI_PLUGIN_PATH = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "apps", "ComfyUI-vLLM-Omni") + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "apps", "ComfyUI-vLLM-Omni") ) if not os.path.isdir(_COMFYUI_PLUGIN_PATH): raise FileNotFoundError( diff --git a/tests/comfyui/test_comfyui_integration.py b/tests/e2e/features/comfyui/test_comfyui_integration.py similarity index 81% rename from tests/comfyui/test_comfyui_integration.py rename to tests/e2e/features/comfyui/test_comfyui_integration.py index f532c350f..e8d68b159 100644 --- a/tests/comfyui/test_comfyui_integration.py +++ b/tests/e2e/features/comfyui/test_comfyui_integration.py @@ -6,11 +6,13 @@ It ensures that 2. The sampling parameters are correctly passed from the node to AsyncOmni through the API layer. """ +from __future__ import annotations + import multiprocessing import time import traceback from collections.abc import Iterable, Sequence -from enum import StrEnum, auto +from enum import Enum from types import SimpleNamespace from typing import Any, NamedTuple @@ -23,9 +25,15 @@ from comfyui_vllm_omni.nodes import ( VLLMOmniGenerateVideo, VLLMOmniTTS, VLLMOmniUnderstanding, + VLLMOmniVideoReferences, VLLMOmniVoiceClone, ) -from comfyui_vllm_omni.utils.types import AutoregressionSamplingParams, DiffusionSamplingParams, WanModelSpecificParams +from comfyui_vllm_omni.utils.types import ( + AutoregressionSamplingParams, + DiffusionSamplingParams, + MiniMaxH3ModelSpecificParams, + WanModelSpecificParams, +) from PIL import Image from pytest_mock import MockerFixture from vllm import SamplingParams @@ -33,7 +41,7 @@ from vllm.outputs import CompletionOutput, RequestOutput from vllm_omni.entrypoints.async_omni import AsyncOmni as RealAsyncOmni from vllm_omni.entrypoints.cli.serve import OmniServeCommand -from vllm_omni.inputs.data import OmniSamplingParams +from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniSamplingParams from vllm_omni.outputs import OmniRequestOutput from vllm_omni.utils.tracking_parser import TrackingArgumentParser @@ -52,20 +60,22 @@ class ServerCase(NamedTuple): class SamplingCase(NamedTuple): """Parametrizing the input sampling parameters.""" - kind: "SamplingKind" + kind: SamplingKind sampling_params: dict | list[dict] | None lora: dict | None = None -class SamplingKind(StrEnum): - IMAGE_NONE = auto() - IMAGE_DIFFUSION_SINGLE = auto() - UNDERSTANDING_NONE = auto() - UNDERSTANDING_AR_LIST = auto() - TTS_NONE = auto() - TTS_DIFFUSION_SINGLE = auto() - VIDEO_NONE = auto() - VIDEO_DIFFUSION_SINGLE = auto() +class SamplingKind(str, Enum): + IMAGE_NONE = "image_none" + IMAGE_DIFFUSION_SINGLE = "image_diffusion_single" + UNDERSTANDING_NONE = "understanding_none" + UNDERSTANDING_AR_LIST = "understanding_ar_list" + TTS_NONE = "tts_none" + TTS_DIFFUSION_SINGLE = "tts_diffusion_single" + VIDEO_NONE = "video_none" + VIDEO_DIFFUSION_SINGLE = "video_diffusion_single" + VIDEO_REF2VA_IMAGE_AUDIO = "video_ref2va_image_audio" + VIDEO_REF2VA_MULTI_VIDEO = "video_ref2va_multi_video" # Pre-defined arguments to be used in function calls during the tests @@ -125,6 +135,15 @@ AR_LIST_SAMPLING_PARAMS = [ VIDEO_MODEL_PARAMS = WanModelSpecificParams({"guidance_scale_2": 5.0, "boundary_ratio": 0.98, "flow_shift": 12.0}) +# Matches MiniMax-H3 recipe Ref2VA defaults (task is auto-routed by the client). +H3_MODEL_PARAMS = MiniMaxH3ModelSpecificParams( + { + "type": "minimax_h3", + "audio_flow_shift": 3.0, + "flow_shift": 12.0, + } +) + LORA_PARAMS = {"local_path": "test_lora_path", "name": "test_name", "scale": 0.7, "int_id": 10} @@ -304,6 +323,7 @@ def _build_mock_outputs(outputs: Iterable[OmniRequestOutput], sampling_case: Sam received_sampling_params_list: Sequence[OmniSamplingParams] | None = ( args[2] if len(args) > 2 else kwargs.get("sampling_params_list") ) + prompt = args[0] if len(args) > 0 else kwargs.get("prompt") assert received_sampling_params_list is not None, ( "In the current codebase, the API layer always provides not-None sampling parameter list when calling `AsyncOmni.generate`" @@ -370,6 +390,59 @@ def _build_mock_outputs(outputs: Iterable[OmniRequestOutput], sampling_case: Sam LORA_PARAMS, ) _assert_model_param_values(received_sampling_params_list[0], VIDEO_MODEL_PARAMS) + elif sampling_case.kind is SamplingKind.VIDEO_REF2VA_IMAGE_AUDIO: + assert len(received_sampling_params_list) == 1 + assert isinstance(prompt, dict) + multi_modal_data = prompt.get("multi_modal_data") + assert isinstance(multi_modal_data, dict) + assert isinstance(multi_modal_data.get("image"), Image.Image) + assert isinstance(multi_modal_data.get("audio"), str) + assert "video" not in multi_modal_data + _assert_sampling_param_values( + received_sampling_params_list[0], + { + "width": VIDEO_WIDTH, + "height": VIDEO_HEIGHT, + "num_frames": VIDEO_NUM_FRAMES, + "fps": VIDEO_FPS, + }, + ) + _assert_model_param_values( + received_sampling_params_list[0], + { + "flow_shift": 12.0, + "task": "ref2va", + "audio_flow_shift": 3.0, + }, + ) + elif sampling_case.kind is SamplingKind.VIDEO_REF2VA_MULTI_VIDEO: + assert len(received_sampling_params_list) == 1 + assert isinstance(prompt, dict) + multi_modal_data = prompt.get("multi_modal_data") + assert isinstance(multi_modal_data, dict) + input_videos = multi_modal_data.get("video") + assert isinstance(input_videos, list) + assert len(input_videos) == 2 + assert all(isinstance(path, str) for path in input_videos) + assert "image" not in multi_modal_data + assert "audio" not in multi_modal_data + _assert_sampling_param_values( + received_sampling_params_list[0], + { + "width": VIDEO_WIDTH, + "height": VIDEO_HEIGHT, + "num_frames": VIDEO_NUM_FRAMES, + "fps": VIDEO_FPS, + }, + ) + _assert_model_param_values( + received_sampling_params_list[0], + { + "flow_shift": 12.0, + "task": "ref2va", + "audio_flow_shift": 3.0, + }, + ) else: raise AssertionError(f"Unknown sampling case: {sampling_case.kind}") @@ -415,7 +488,7 @@ def mock_async_omni( mock_instance.stage_configs = server_case.stage_configs mock_instance.output_modalities = _build_output_modalities(server_case.stage_configs) mock_instance.default_sampling_params_list = [ - SamplingParams() if _stage_type(stage) != "diffusion" else mocker.MagicMock() + OmniDiffusionSamplingParams() if _stage_type(stage) == "diffusion" else SamplingParams() for stage in server_case.stage_configs ] mock_instance.errored = False @@ -773,7 +846,7 @@ async def test_video_generation_node(api_server: str, model: str, image_input: b "model_params": VIDEO_MODEL_PARAMS, } if image_input: - kwargs["image"] = torch.zeros((1, VIDEO_HEIGHT, VIDEO_WIDTH, 3), dtype=torch.float32) + kwargs["frame"] = torch.zeros((1, VIDEO_HEIGHT, VIDEO_WIDTH, 3), dtype=torch.float32) if sampling_case.sampling_params is not None: kwargs["sampling_params"] = sampling_case.sampling_params if sampling_case.lora: @@ -784,3 +857,79 @@ async def test_video_generation_node(api_server: str, model: str, image_input: b assert isinstance(result, tuple) assert len(result) == 1 assert isinstance(result[0], VideoInput) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "server_case,sampling_case,ref_mode", + [ + pytest.param( + ServerCase( + served_model="MiniMaxAI/MiniMax-H3", + stage_list=["diffusion"], + stage_configs=[{"stage_type": "diffusion"}], + outputs=[_build_diffusion_video_output()], + ), + SamplingCase(kind=SamplingKind.VIDEO_REF2VA_IMAGE_AUDIO, sampling_params=None), + "image_audio", + id="ref2va-image-audio", + ), + pytest.param( + ServerCase( + served_model="MiniMaxAI/MiniMax-H3", + stage_list=["diffusion"], + stage_configs=[{"stage_type": "diffusion"}], + outputs=[_build_diffusion_video_output()], + ), + SamplingCase(kind=SamplingKind.VIDEO_REF2VA_MULTI_VIDEO, sampling_params=None), + "multi_video", + id="ref2va-multi-video", + ), + ], + indirect=["server_case", "sampling_case"], +) +async def test_video_generation_node_minimax_h3_ref2va( + api_server: str, + sampling_case: SamplingCase, + ref_mode: str, +): + """MiniMax-H3 Ref2VA paths matching recipes/MiniMaxAI/MiniMax-H3.md.""" + node = VLLMOmniGenerateVideo() + refs_node = VLLMOmniVideoReferences() + + if ref_mode == "image_audio": + (references,) = refs_node.get_references( + image_1=torch.zeros((1, VIDEO_HEIGHT, VIDEO_WIDTH, 3), dtype=torch.float32), + audio_1={"waveform": torch.zeros((1, 1, 24000), dtype=torch.float32), "sample_rate": 24000}, + ) + prompt = ( + "A white cat with black mustache and eyebrow markings sits on a beige couch, " + "lip-syncing precisely to the complete reference audio." + ) + elif ref_mode == "multi_video": + (references,) = refs_node.get_references( + video_1=VideoInput(b"subject-video"), # type: ignore[reportAbstractUsage] + video_2=VideoInput(b"background-video"), # type: ignore[reportAbstractUsage] + ) + prompt = ( + "Remove the green screen background of Video 1 and replace it with the fairytale environment from Video 2." + ) + else: + raise AssertionError(f"Unknown ref_mode: {ref_mode}") + + result = await node.generate( + url=api_server, + model="MiniMaxAI/MiniMax-H3", + prompt=prompt, + negative_prompt="", + width=VIDEO_WIDTH, + height=VIDEO_HEIGHT, + fps=VIDEO_FPS, + num_frames=VIDEO_NUM_FRAMES, + references=references, + model_params=H3_MODEL_PARAMS, + ) + + assert isinstance(result, tuple) + assert len(result) == 1 + assert isinstance(result[0], VideoInput) diff --git a/tests/e2e/offline_inference/custom_pipeline/test_async_omni_collective_rpc.py b/tests/e2e/features/custom_pipeline/test_async_omni_collective_rpc.py similarity index 96% rename from tests/e2e/offline_inference/custom_pipeline/test_async_omni_collective_rpc.py rename to tests/e2e/features/custom_pipeline/test_async_omni_collective_rpc.py index dc701af7a..04e83caeb 100644 --- a/tests/e2e/offline_inference/custom_pipeline/test_async_omni_collective_rpc.py +++ b/tests/e2e/features/custom_pipeline/test_async_omni_collective_rpc.py @@ -16,7 +16,7 @@ exercises when it calls ``await self.engine.list_loras()`` before dispatching a generation request. Usage: - pytest tests/e2e/offline_inference/custom_pipeline/test_async_omni_collective_rpc.py -v -s + pytest tests/e2e/features/custom_pipeline/test_async_omni_collective_rpc.py -v -s """ from __future__ import annotations @@ -34,12 +34,8 @@ from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.outputs import OmniRequestOutput MODEL = "tiny-random/Qwen-Image" -CUSTOM_PIPELINE_CLASS = ( - "tests.e2e.offline_inference.custom_pipeline.qwen_image_pipeline_with_logprob.QwenImagePipelineWithLogProbForTest" -) -WORKER_EXTENSION_CLASS = ( - "tests.e2e.offline_inference.custom_pipeline.worker_extension.vLLMOmniColocateWorkerExtensionForTest" -) +CUSTOM_PIPELINE_CLASS = "tests.e2e.features.helpers.custom_pipeline.QwenImagePipelineWithLogProbForTest" +WORKER_EXTENSION_CLASS = "tests.e2e.features.helpers.custom_pipeline.vLLMOmniColocateWorkerExtensionForTest" def _create_inline_engine() -> AsyncOmni: diff --git a/tests/e2e/offline_inference/custom_pipeline/test_async_omni_qwen_image_generate.py b/tests/e2e/features/custom_pipeline/test_async_omni_qwen_image_generate.py similarity index 96% rename from tests/e2e/offline_inference/custom_pipeline/test_async_omni_qwen_image_generate.py rename to tests/e2e/features/custom_pipeline/test_async_omni_qwen_image_generate.py index 0681687fe..b24f364e4 100644 --- a/tests/e2e/offline_inference/custom_pipeline/test_async_omni_qwen_image_generate.py +++ b/tests/e2e/features/custom_pipeline/test_async_omni_qwen_image_generate.py @@ -24,12 +24,8 @@ from vllm_omni.entrypoints.async_omni import AsyncOmni from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.outputs import OmniRequestOutput -CUSTOM_PIPELINE_CLASS = ( - "tests.e2e.offline_inference.custom_pipeline.qwen_image_pipeline_with_logprob.QwenImagePipelineWithLogProbForTest" -) -WORKER_EXTENSION_CLASS = ( - "tests.e2e.offline_inference.custom_pipeline.worker_extension.vLLMOmniColocateWorkerExtensionForTest" -) +CUSTOM_PIPELINE_CLASS = "tests.e2e.features.helpers.custom_pipeline.QwenImagePipelineWithLogProbForTest" +WORKER_EXTENSION_CLASS = "tests.e2e.features.helpers.custom_pipeline.vLLMOmniColocateWorkerExtensionForTest" # Use your specified HF repo name/path directly here MODEL = "tiny-random/Qwen-Image" @@ -158,6 +154,7 @@ async def test_async_omni_generate(): custom_pipeline_args={"pipeline_class": CUSTOM_PIPELINE_CLASS}, worker_extension_cls=WORKER_EXTENSION_CLASS, enforce_eager=True, + max_num_seqs=1, ) after.callback(engine.shutdown) @@ -183,6 +180,7 @@ async def test_async_omni_generate_with_logprobs(): custom_pipeline_args={"pipeline_class": CUSTOM_PIPELINE_CLASS}, worker_extension_cls=WORKER_EXTENSION_CLASS, enforce_eager=True, + max_num_seqs=1, ) after.callback(engine.shutdown) @@ -220,6 +218,7 @@ async def test_async_omni_generate_concurrent(): custom_pipeline_args={"pipeline_class": CUSTOM_PIPELINE_CLASS}, worker_extension_cls=WORKER_EXTENSION_CLASS, enforce_eager=True, + max_num_seqs=1, ) after.callback(engine.shutdown) diff --git a/tests/e2e/offline_inference/custom_pipeline/test_worker_extension.py b/tests/e2e/features/custom_pipeline/test_worker_extension.py similarity index 59% rename from tests/e2e/offline_inference/custom_pipeline/test_worker_extension.py rename to tests/e2e/features/custom_pipeline/test_worker_extension.py index 16dddde40..da7b0ac17 100644 --- a/tests/e2e/offline_inference/custom_pipeline/test_worker_extension.py +++ b/tests/e2e/features/custom_pipeline/test_worker_extension.py @@ -7,37 +7,19 @@ from contextlib import ExitStack import pytest -from tests.e2e.offline_inference.custom_pipeline.qwen_image_pipeline_with_logprob import ( +from tests.e2e.features.helpers.custom_pipeline import ( QwenImagePipelineWithLogProbForTest, ) -from tests.e2e.offline_inference.custom_pipeline.worker_extension import ( - vLLMOmniColocateWorkerExtensionForTest, -) from tests.helpers.mark import hardware_test -from vllm_omni.diffusion.worker.diffusion_worker import CustomPipelineWorkerExtension from vllm_omni.entrypoints.async_omni import AsyncOmni CUSTOM_PIPELINE_CLASS = QwenImagePipelineWithLogProbForTest -WORKER_EXTENSION_CLASS = ( - "tests.e2e.offline_inference.custom_pipeline.worker_extension.vLLMOmniColocateWorkerExtensionForTest" -) +WORKER_EXTENSION_CLASS = "tests.e2e.features.helpers.custom_pipeline.vLLMOmniColocateWorkerExtensionForTest" MODEL = "tiny-random/Qwen-Image" pytestmark = [pytest.mark.core_model] -@pytest.mark.cpu -def test_worker_extension_inheritance(): - assert issubclass(vLLMOmniColocateWorkerExtensionForTest, CustomPipelineWorkerExtension) - - -@pytest.mark.cpu -def test_worker_extension_test_function(): - assert ( - vLLMOmniColocateWorkerExtensionForTest.test_extension_name() == "vllm-omni-colocate-worker-extension-for-test" - ) - - @pytest.mark.diffusion @hardware_test(res={"cuda": "L4"}, num_cards=1) @pytest.mark.asyncio diff --git a/tests/e2e/features/fullduplex/engine/test_duplex_control_client.py b/tests/e2e/features/fullduplex/engine/test_duplex_control_client.py new file mode 100644 index 000000000..c1fa9a592 --- /dev/null +++ b/tests/e2e/features/fullduplex/engine/test_duplex_control_client.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +import pytest + +from vllm_omni.experimental.fullduplex.engine.duplex_control_client import ( + DuplexControlClient, + DuplexControlRequestError, +) +from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseActivity +from vllm_omni.experimental.fullduplex.engine.messages import ( + DuplexControlError, + DuplexControlResultMessage, + DuplexFence, + ResumeDuplexSessionMessage, + TouchDuplexSessionMessage, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _Transport: + def __init__(self) -> None: + self.calls = [] + + def execute(self, key, message, **kwargs): + self.calls.append((key, message, kwargs)) + return DuplexControlResultMessage( + control_id=message.control_id, + fence=message.fence, + operation="touch" if isinstance(message, TouchDuplexSessionMessage) else "resume", + session_id=message.session_id, + ok=True, + stage_results=[], + ) + + +class _UnsupportedTransport: + def execute(self, key, message, **kwargs): + del key, kwargs + return DuplexControlResultMessage( + control_id=message.control_id, + fence=message.fence, + operation="touch", + session_id=message.session_id, + ok=False, + stage_results=[{"result": {"supported": False}}], + unsupported_count=1, + error=DuplexControlError(code="invalid_capability", message="unsupported", retryable=False), + accepted_fence=DuplexFence(message.session_id, epoch=2), + lease_generation=7, + ) + + +def test_control_client_routes_touch_and_resume_by_control_id() -> None: + transport = _Transport() + control_ids = iter(("touch-id", "resume-id")) + client = DuplexControlClient(transport, control_id_factory=lambda: next(control_ids)) + fence = DuplexFence("sid-client") + + assert ( + client.touch( + fence.session_id, + fence=fence, + activity=DuplexLeaseActivity.PLAYBACK_ACK, + timeout=2.0, + )["ok"] + is True + ) + assert ( + client.resume( + fence.session_id, + fence=fence, + expected_lease_generation=7, + timeout=3.0, + )["ok"] + is True + ) + + touch_key, touch_message, touch_kwargs = transport.calls[0] + assert touch_key == ("duplex", "touch-id") + assert isinstance(touch_message, TouchDuplexSessionMessage) + assert touch_message.activity == DuplexLeaseActivity.PLAYBACK_ACK.value + assert touch_kwargs["timeout"] == 2.0 + + resume_key, resume_message, resume_kwargs = transport.calls[1] + assert resume_key == ("duplex", "resume-id") + assert isinstance(resume_message, ResumeDuplexSessionMessage) + assert resume_message.expected_lease_generation == 7 + assert resume_kwargs["timeout"] == 3.0 + + +def test_control_client_treats_ok_false_as_authoritative_failure() -> None: + client = DuplexControlClient(_UnsupportedTransport(), control_id_factory=lambda: "unsupported") + fence = DuplexFence("sid-unsupported") + + with pytest.raises(DuplexControlRequestError, match="duplex touch failed") as exc_info: + client.touch( + fence.session_id, + fence=fence, + activity=DuplexLeaseActivity.HEARTBEAT, + timeout=1.0, + ) + + assert exc_info.value.code == "invalid_capability" + assert exc_info.value.retryable is False + assert exc_info.value.accepted_fence == DuplexFence(fence.session_id, epoch=2) + assert exc_info.value.lease_generation == 7 diff --git a/tests/e2e/features/fullduplex/engine/test_duplex_control_plane.py b/tests/e2e/features/fullduplex/engine/test_duplex_control_plane.py new file mode 100644 index 000000000..46df33ffa --- /dev/null +++ b/tests/e2e/features/fullduplex/engine/test_duplex_control_plane.py @@ -0,0 +1,1270 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import FrozenInstanceError + +import msgspec +import pytest + +from vllm_omni.experimental.fullduplex.engine.duplex_control_plane import ( + DuplexControlPlane, + DuplexOutputContext, + DuplexRequestIdentity, + DuplexStageRequestContext, + DuplexStageSubmission, + DuplexStageSubmissionResult, +) +from vllm_omni.experimental.fullduplex.engine.duplex_runtime import ( + DuplexAppendPlan, + DuplexInputMode, + DuplexRuntimeCapabilities, +) +from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseActivity, DuplexLeaseConfig +from vllm_omni.experimental.fullduplex.engine.messages import ( + AppendDuplexInputMessage, + CloseDuplexSessionMessage, + DuplexFence, + DuplexSessionLifecycleMessage, + OpenDuplexSessionMessage, + ResumeDuplexSessionMessage, + SignalDuplexTurnMessage, + TouchDuplexSessionMessage, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _Extension: + def configure_sampling_params(self, *, runtime_config, defaults): + del runtime_config + return tuple(f"configured-{stage_id}" for stage_id, _ in enumerate(defaults)) + + def plan_append( + self, + *, + request_id, + fence, + session_config, + runtime_config, + seq, + turn_seq, + mode, + payload, + final, + sampling_params, + ): + del request_id, fence, session_config, runtime_config, seq, turn_seq, mode, payload, final + assert sampling_params == "configured-0" + return DuplexAppendPlan(prompt={"prompt_token_ids": [1, 2, 3]}) + + def decide_output(self, **kwargs): + del kwargs + return None + + +class _TypedStagePort: + stage_count = 2 + + def __init__(self) -> None: + self.ensure_calls: list[DuplexStageRequestContext] = [] + self.submit_calls: list[DuplexStageSubmission] = [] + self.cleanup_calls: list[tuple[list[str], bool]] = [] + + def sampling_defaults(self) -> tuple[object, ...]: + return ("default-0", "default-1") + + def ensure_request(self, context: DuplexStageRequestContext) -> None: + self.ensure_calls.append(context) + + async def submit(self, submission: DuplexStageSubmission) -> DuplexStageSubmissionResult: + self.submit_calls.append(submission) + return DuplexStageSubmissionResult( + request_id=submission.context.request_id, + stage_id=submission.context.stage_id, + replica_id=3, + ) + + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + self.cleanup_calls.append((request_ids, abort)) + + +class _Clock: + def __init__(self) -> None: + self.value = 0.0 + + def __call__(self) -> float: + return self.value + + def advance(self, seconds: float) -> None: + self.value += seconds + + +@pytest.mark.asyncio +async def test_control_plane_uses_frozen_typed_stage_context_without_request_state() -> None: + stage_port = _TypedStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane( + extension=_Extension(), + stage_port=stage_port, + result_sink=result_sink, + ) + fence = DuplexFence("typed-port") + + await plane.handle( + OpenDuplexSessionMessage( + control_id="open", + fence=fence, + session_id=fence.session_id, + capabilities={ + "input_modes": [DuplexInputMode.APPEND_AUDIO_CHUNK.value], + }, + session_config={"voice": "test"}, + runtime_config={"runtime": "test"}, + ) + ) + assert (await result_sink.get()).ok is True + + context = stage_port.ensure_calls[-1] + assert context.session_id == fence.session_id + assert context.fence == fence + assert context.stage_id == 0 + assert context.final_stage_id == 1 + assert context.sampling_params == ("configured-0", "configured-1") + assert context.session_config == {"voice": "test"} + assert context.runtime_config == {"runtime": "test"} + with pytest.raises(FrozenInstanceError): + context.stage_id = 1 # type: ignore[misc] + + await plane.handle( + AppendDuplexInputMessage( + control_id="append", + fence=fence, + session_id=fence.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"audio": b"pcm"}, + final=True, + ) + ) + append_result = await result_sink.get() + assert append_result.ok is True + assert append_result.stage_results[0]["replica_id"] == 3 + assert append_result.stage_results[0]["result"]["response_stage_id"] == 1 + + submission = stage_port.submit_calls[-1] + assert submission.context == stage_port.ensure_calls[-1] + assert submission.prompt == {"prompt_token_ids": [1, 2, 3]} + assert submission.already_submitted is False + assert not hasattr(submission, "request_state") + + +def test_control_plane_accepts_only_typed_duplex_messages() -> None: + plane = DuplexControlPlane( + extension=None, + stage_port=_TypedStagePort(), + result_sink=asyncio.Queue(), + ) + + assert plane.accepts( + OpenDuplexSessionMessage( + control_id="open", + fence=DuplexFence("typed-message"), + session_id="typed-message", + capabilities=DuplexRuntimeCapabilities().__dict__, + ) + ) + assert plane.accepts(type("Lookalike", (), {"type": "open_duplex_session"})()) is False + + +@pytest.mark.parametrize( + "message", + [ + TouchDuplexSessionMessage( + control_id="touch-1", + fence=DuplexFence("sid-message"), + session_id="sid-message", + activity=DuplexLeaseActivity.HEARTBEAT.value, + ), + ResumeDuplexSessionMessage( + control_id="resume-1", + fence=DuplexFence("sid-message"), + session_id="sid-message", + expected_lease_generation=3, + ), + DuplexSessionLifecycleMessage( + fence=DuplexFence("sid-message"), + session_id="sid-message", + event="expired", + reason="idle_ttl_expired", + lease_generation=4, + submitted_request_ids=["req-submitted"], + reserved_request_ids=["req-reserved"], + ), + ], +) +def test_duplex_lease_messages_round_trip(message) -> None: + encoded = msgspec.json.encode(message) + decoded = msgspec.json.decode(encoded, type=type(message)) + + assert decoded == message + + +@pytest.mark.asyncio +async def test_control_plane_touches_append_signal_and_explicit_activity() -> None: + clock = _Clock() + stage_port = _TypedStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + lifecycle_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane( + extension=_Extension(), + stage_port=stage_port, + result_sink=result_sink, + lifecycle_sink=lifecycle_sink, + lease_config=DuplexLeaseConfig(idle_ttl_s=30.0, disconnect_grace_s=5.0), + clock=clock, + ) + fence = DuplexFence("sid-touch") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open", + fence=fence, + session_id=fence.session_id, + capabilities={"input_modes": [DuplexInputMode.APPEND_AUDIO_CHUNK.value]}, + ) + ) + await result_sink.get() + session = plane.sessions.require(fence.session_id) + + clock.advance(1.0) + await plane.handle( + AppendDuplexInputMessage( + control_id="append", + fence=fence, + session_id=fence.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"audio": b"pcm"}, + ) + ) + assert (await result_sink.get()).ok is True + assert session.lease.last_activity == 1.0 + + clock.advance(1.0) + await plane.handle( + SignalDuplexTurnMessage( + control_id="signal", + fence=fence, + session_id=fence.session_id, + event="session.update", + ) + ) + assert (await result_sink.get()).ok is True + assert session.lease.last_activity == 2.0 + + submit_count = len(stage_port.submit_calls) + clock.advance(1.0) + await plane.handle( + TouchDuplexSessionMessage( + control_id="heartbeat", + fence=fence, + session_id=fence.session_id, + activity=DuplexLeaseActivity.HEARTBEAT.value, + ) + ) + touch_result = await result_sink.get() + assert touch_result.ok is True + assert session.lease.last_activity == 3.0 + assert len(stage_port.submit_calls) == submit_count + assert lifecycle_sink.empty() + + +@pytest.mark.asyncio +async def test_control_plane_resume_requires_expected_lease_generation() -> None: + clock = _Clock() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane( + extension=None, + stage_port=_TypedStagePort(), + result_sink=result_sink, + lifecycle_sink=asyncio.Queue(), + lease_config=DuplexLeaseConfig(), + clock=clock, + ) + fence = DuplexFence("sid-resume-control") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open", + fence=fence, + session_id=fence.session_id, + capabilities={}, + ) + ) + await result_sink.get() + await plane.handle( + TouchDuplexSessionMessage( + control_id="detach", + fence=fence, + session_id=fence.session_id, + activity=DuplexLeaseActivity.DETACH.value, + ) + ) + await result_sink.get() + + await plane.handle( + ResumeDuplexSessionMessage( + control_id="resume", + fence=fence, + session_id=fence.session_id, + expected_lease_generation=0, + ) + ) + result = await result_sink.get() + + assert result.ok is True + assert result.stage_results[0]["result"]["lease_generation"] == 1 + + await plane.handle( + ResumeDuplexSessionMessage( + control_id="stale-resume", + fence=fence, + session_id=fence.session_id, + expected_lease_generation=0, + ) + ) + stale_result = await result_sink.get() + assert stale_result.ok is False + assert stale_result.accepted_fence == fence + assert stale_result.lease_generation == 1 + + +@pytest.mark.asyncio +async def test_control_plane_reaps_one_session_through_cleanup_and_lifecycle_sink() -> None: + clock = _Clock() + stage_port = _TypedStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + lifecycle_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane( + extension=None, + stage_port=stage_port, + result_sink=result_sink, + lifecycle_sink=lifecycle_sink, + lease_config=DuplexLeaseConfig(idle_ttl_s=2.0, disconnect_grace_s=1.0), + clock=clock, + ) + for session_id in ("sid-a", "sid-b"): + fence = DuplexFence(session_id) + await plane.handle( + OpenDuplexSessionMessage( + control_id=f"open-{session_id}", + fence=fence, + session_id=session_id, + capabilities={}, + ) + ) + await result_sink.get() + clock.advance(1.0) + plane.sessions.require("sid-b").touch(DuplexFence("sid-b"), DuplexLeaseActivity.HEARTBEAT) + clock.advance(1.1) + + expired_count = await plane.reap_expired() + + assert expired_count == 1 + assert plane.sessions.get("sid-a") is None + assert plane.sessions.get("sid-b") is not None + assert stage_port.cleanup_calls == [] + lifecycle = await lifecycle_sink.get() + assert isinstance(lifecycle, DuplexSessionLifecycleMessage) + assert lifecycle.session_id == "sid-a" + assert lifecycle.reason == "idle_ttl_expired" + assert result_sink.empty() + + +@pytest.mark.asyncio +async def test_control_plane_retries_expired_cleanup_before_publishing_lifecycle() -> None: + class _FailOnceStagePort(_TypedStagePort): + def __init__(self) -> None: + super().__init__() + self.failures_remaining = 1 + + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + if self.failures_remaining: + self.failures_remaining -= 1 + raise RuntimeError("transient cleanup failure") + await super().cleanup(request_ids, abort=abort) + + clock = _Clock() + stage_port = _FailOnceStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + lifecycle_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane( + extension=None, + stage_port=stage_port, + result_sink=result_sink, + lifecycle_sink=lifecycle_sink, + lease_config=DuplexLeaseConfig(idle_ttl_s=1.0, disconnect_grace_s=1.0), + clock=clock, + ) + fence = DuplexFence("sid-retry-expiry") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-retry", + fence=fence, + session_id=fence.session_id, + capabilities={}, + ) + ) + await result_sink.get() + request_id = plane.stage_request_id(fence, stage_id=0) + plane.sessions.require(fence.session_id).reserve_stage_request(0, request_id, fence=fence) + clock.advance(2.0) + + assert await plane.reap_expired() == 0 + assert lifecycle_sink.empty() + + assert await plane.reap_expired() == 1 + assert (await lifecycle_sink.get()).session_id == fence.session_id + + +@pytest.mark.asyncio +async def test_expired_session_holds_admission_slot_while_cleanup_is_blocked() -> None: + class _BlockedCleanupStagePort(_TypedStagePort): + def __init__(self) -> None: + super().__init__() + self.cleanup_started = asyncio.Event() + self.release_cleanup = asyncio.Event() + + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + self.cleanup_started.set() + await self.release_cleanup.wait() + await super().cleanup(request_ids, abort=abort) + + clock = _Clock() + stage_port = _BlockedCleanupStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane( + extension=None, + stage_port=stage_port, + result_sink=result_sink, + lease_config=DuplexLeaseConfig(idle_ttl_s=1.0, disconnect_grace_s=1.0), + clock=clock, + max_sessions=1, + ) + old_fence = DuplexFence("sid-expired-blocked") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-expired-blocked", + fence=old_fence, + session_id=old_fence.session_id, + capabilities={}, + ) + ) + assert (await result_sink.get()).ok is True + old_session = plane.sessions.require(old_fence.session_id) + old_session.bind_stage_request(0, "req-expired-blocked", fence=old_fence) + clock.advance(2.0) + + reap_task = asyncio.create_task(plane.reap_expired()) + await asyncio.wait_for(stage_port.cleanup_started.wait(), timeout=1.0) + replacement_fence = DuplexFence("sid-replacement-blocked") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-replacement-blocked", + fence=replacement_fence, + session_id=replacement_fence.session_id, + capabilities={}, + ) + ) + + blocked_result = await result_sink.get() + assert blocked_result.ok is False + assert blocked_result.error is not None + assert blocked_result.error.code == "resource_exhausted" + assert plane.sessions.get(old_fence.session_id) is old_session + + stage_port.release_cleanup.set() + assert await reap_task == 1 + assert plane.sessions.get(old_fence.session_id) is None + + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-replacement-after-cleanup", + fence=replacement_fence, + session_id=replacement_fence.session_id, + capabilities={}, + ) + ) + assert (await result_sink.get()).ok is True + + +@pytest.mark.asyncio +async def test_failed_expiry_cleanup_retains_admission_slot_for_retry() -> None: + class _FailingCleanupStagePort(_TypedStagePort): + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + del request_ids, abort + raise RuntimeError("cleanup failed") + + clock = _Clock() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane( + extension=None, + stage_port=_FailingCleanupStagePort(), + result_sink=result_sink, + lease_config=DuplexLeaseConfig(idle_ttl_s=1.0, disconnect_grace_s=1.0), + clock=clock, + max_sessions=1, + ) + old_fence = DuplexFence("sid-expired-failed") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-expired-failed", + fence=old_fence, + session_id=old_fence.session_id, + capabilities={}, + ) + ) + assert (await result_sink.get()).ok is True + old_session = plane.sessions.require(old_fence.session_id) + old_session.bind_stage_request(0, "req-expired-failed", fence=old_fence) + clock.advance(2.0) + + assert await plane.reap_expired() == 0 + replacement_fence = DuplexFence("sid-replacement-failed") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-replacement-failed", + fence=replacement_fence, + session_id=replacement_fence.session_id, + capabilities={}, + ) + ) + + blocked_result = await result_sink.get() + assert blocked_result.ok is False + assert blocked_result.error is not None + assert blocked_result.error.code == "resource_exhausted" + assert plane.sessions.get(old_fence.session_id) is old_session + + +@pytest.mark.asyncio +async def test_capacity_rejection_is_an_info_event(caplog: pytest.LogCaptureFixture) -> None: + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane( + extension=None, + stage_port=_TypedStagePort(), + result_sink=result_sink, + max_sessions=1, + ) + first_fence = DuplexFence("sid-capacity-first") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-capacity-first", + fence=first_fence, + session_id=first_fence.session_id, + capabilities={}, + ) + ) + assert (await result_sink.get()).ok is True + caplog.clear() + + rejected_fence = DuplexFence("sid-capacity-rejected") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-capacity-rejected", + fence=rejected_fence, + session_id=rejected_fence.session_id, + capabilities={}, + ) + ) + + result = await result_sink.get() + assert result.error.code == "resource_exhausted" + assert not [record for record in caplog.records if record.levelno >= logging.ERROR] + + +@pytest.mark.asyncio +async def test_failed_request_cleanup_retries_before_releasing_admission() -> None: + class _FailOnceCleanupStagePort(_TypedStagePort): + def __init__(self) -> None: + super().__init__() + self.attempts = 0 + + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + self.cleanup_calls.append((list(request_ids), abort)) + self.attempts += 1 + if self.attempts == 1: + raise RuntimeError("transient request cleanup failure") + + stage_port = _FailOnceCleanupStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane( + extension=None, + stage_port=stage_port, + result_sink=result_sink, + max_sessions=1, + ) + old_fence = DuplexFence("sid-request-cleanup") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-request-cleanup", + fence=old_fence, + session_id=old_fence.session_id, + capabilities={}, + ) + ) + assert (await result_sink.get()).ok is True + old_session = plane.sessions.require(old_fence.session_id) + old_session.bind_stage_request(0, "req-request-cleanup", fence=old_fence) + + closed = plane.close_sessions_for_request_ids( + ["req-request-cleanup"], + abort=True, + ) + assert closed == {old_fence.session_id: ["req-request-cleanup"]} + assert plane.sessions.get(old_fence.session_id) is old_session + with pytest.raises(RuntimeError, match="transient request cleanup failure"): + await stage_port.cleanup(closed[old_fence.session_id], abort=True) + + replacement_fence = DuplexFence("sid-request-cleanup-replacement") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-request-cleanup-replacement-blocked", + fence=replacement_fence, + session_id=replacement_fence.session_id, + capabilities={}, + ) + ) + assert (await result_sink.get()).error.code == "resource_exhausted" + + assert await plane.reap_expired() == 1 + assert plane.sessions.get(old_fence.session_id) is None + assert stage_port.cleanup_calls == [ + (["req-request-cleanup"], True), + (["req-request-cleanup"], True), + ] + + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-request-cleanup-replacement", + fence=replacement_fence, + session_id=replacement_fence.session_id, + capabilities={}, + ) + ) + assert (await result_sink.get()).ok is True + + +@pytest.mark.asyncio +async def test_stale_request_cleanup_does_not_finalize_reopened_incarnation() -> None: + stage_port = _TypedStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane( + extension=None, + stage_port=stage_port, + result_sink=result_sink, + max_sessions=1, + ) + old_fence = DuplexFence("sid-request-cleanup-reopen") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-request-cleanup-old", + fence=old_fence, + session_id=old_fence.session_id, + capabilities={}, + ) + ) + assert (await result_sink.get()).ok is True + old_session = plane.sessions.require(old_fence.session_id) + old_session.bind_stage_request(0, "req-request-cleanup-old", fence=old_fence) + closed = plane.close_sessions_for_request_ids( + ["req-request-cleanup-old"], + abort=True, + cleanup_in_progress=True, + ) + assert closed == {old_fence.session_id: ["req-request-cleanup-old"]} + plane.defer_request_cleanups([old_fence.session_id]) + + await plane.handle( + CloseDuplexSessionMessage( + control_id="close-request-cleanup-old", + fence=old_fence, + session_id=old_fence.session_id, + reason="client_close", + ) + ) + assert (await result_sink.get()).ok is True + assert plane.sessions.get(old_fence.session_id) is None + + new_fence = DuplexFence(old_fence.session_id, incarnation=1) + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-request-cleanup-new", + fence=new_fence, + session_id=new_fence.session_id, + capabilities={}, + ) + ) + assert (await result_sink.get()).ok is True + new_session = plane.sessions.require(new_fence.session_id) + assert new_session.resume(new_fence, expected_lease_generation=0) == 1 + + assert await plane.reap_expired() == 1 + assert plane.sessions.get(new_fence.session_id) is new_session + + +@pytest.mark.asyncio +async def test_failed_open_cleanup_is_retried_before_releasing_admission() -> None: + class _FailOpenAndFirstCleanupStagePort(_TypedStagePort): + def __init__(self) -> None: + super().__init__() + self.ensure_attempts = 0 + self.cleanup_attempts = 0 + + def ensure_request(self, context: DuplexStageRequestContext) -> None: + self.ensure_attempts += 1 + if self.ensure_attempts == 1: + raise RuntimeError("initial stage setup failed") + super().ensure_request(context) + + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + self.cleanup_calls.append((list(request_ids), abort)) + self.cleanup_attempts += 1 + if self.cleanup_attempts == 1: + raise RuntimeError("transient open rollback failure") + + stage_port = _FailOpenAndFirstCleanupStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane( + extension=_Extension(), + stage_port=stage_port, + result_sink=result_sink, + max_sessions=1, + ) + failed_fence = DuplexFence("sid-open-rollback") + + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-fails-during-stage-setup", + fence=failed_fence, + session_id=failed_fence.session_id, + capabilities={"input_modes": ["append_audio_chunk"]}, + ) + ) + + failed_result = await result_sink.get() + assert failed_result.ok is False + assert plane.sessions.get(failed_fence.session_id) is not None + + replacement_fence = DuplexFence("sid-open-rollback-replacement") + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-replacement-before-rollback", + fence=replacement_fence, + session_id=replacement_fence.session_id, + capabilities={"input_modes": ["append_audio_chunk"]}, + ) + ) + assert (await result_sink.get()).error.code == "resource_exhausted" + + assert await plane.reap_expired() == 0 + assert plane.sessions.get(failed_fence.session_id) is None + + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-replacement-after-rollback", + fence=replacement_fence, + session_id=replacement_fence.session_id, + capabilities={"input_modes": ["append_audio_chunk"]}, + ) + ) + assert (await result_sink.get()).ok is True + + +@pytest.mark.asyncio +async def test_expired_cleanup_failure_does_not_block_other_sessions() -> None: + class _OneStuckExpiryStagePort(_TypedStagePort): + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + if request_ids == [stuck_request_id]: + raise RuntimeError("stuck expiry cleanup") + await super().cleanup(request_ids, abort=abort) + + clock = _Clock() + stage_port = _OneStuckExpiryStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + lifecycle_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane( + extension=None, + stage_port=stage_port, + result_sink=result_sink, + lifecycle_sink=lifecycle_sink, + lease_config=DuplexLeaseConfig(idle_ttl_s=1.0, disconnect_grace_s=1.0), + clock=clock, + ) + stuck_fence = DuplexFence("sid-expiry-stuck") + ready_fence = DuplexFence("sid-expiry-ready") + stuck_request_id = plane.stage_request_id(stuck_fence, stage_id=0) + ready_request_id = plane.stage_request_id(ready_fence, stage_id=0) + for fence in (stuck_fence, ready_fence): + await plane.handle( + OpenDuplexSessionMessage( + control_id=f"open-{fence.session_id}", + fence=fence, + session_id=fence.session_id, + capabilities={}, + ) + ) + assert (await result_sink.get()).ok is True + plane.sessions.require(fence.session_id).reserve_stage_request( + 0, + plane.stage_request_id(fence, stage_id=0), + fence=fence, + ) + clock.advance(2.0) + + assert await plane.reap_expired() == 1 + + assert (await lifecycle_sink.get()).session_id == ready_fence.session_id + assert stage_port.cleanup_calls == [([ready_request_id], False)] + + +@pytest.mark.asyncio +async def test_cancel_retains_request_resources_until_abort_succeeds() -> None: + class _FailOnceStagePort(_TypedStagePort): + def __init__(self) -> None: + super().__init__() + self.failures_remaining = 1 + + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + if self.failures_remaining: + self.failures_remaining -= 1 + raise RuntimeError("transient abort failure") + await super().cleanup(request_ids, abort=abort) + + stage_port = _FailOnceStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane(extension=None, stage_port=stage_port, result_sink=result_sink) + cancelled_fence = DuplexFence("sid-cancel-retry") + next_fence = DuplexFence("sid-cancel-retry", epoch=1) + session = plane.sessions.open_session(cancelled_fence) + session.bind_stage_request(0, "req-cancel-retry", fence=cancelled_fence) + + def signal(control_id: str) -> SignalDuplexTurnMessage: + return SignalDuplexTurnMessage( + control_id=control_id, + fence=cancelled_fence, + next_fence=next_fence, + session_id=cancelled_fence.session_id, + event="input.cancel", + ) + + await plane.handle(signal("cancel-first")) + assert (await result_sink.get()).ok is False + assert session.fence == next_fence + assert session.resource_request_ids(cancelled_fence) == ["req-cancel-retry"] + + await plane.handle(signal("cancel-retry")) + assert (await result_sink.get()).ok is True + assert session.resource_request_ids(cancelled_fence) == [] + assert stage_port.cleanup_calls == [(["req-cancel-retry"], True)] + + +@pytest.mark.asyncio +async def test_close_retains_session_resources_until_cleanup_succeeds() -> None: + class _FailOnceStagePort(_TypedStagePort): + def __init__(self) -> None: + super().__init__() + self.failures_remaining = 1 + + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + if self.failures_remaining: + self.failures_remaining -= 1 + raise RuntimeError("transient close cleanup failure") + await super().cleanup(request_ids, abort=abort) + + stage_port = _FailOnceStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane(extension=None, stage_port=stage_port, result_sink=result_sink) + fence = DuplexFence("sid-close-retry") + session = plane.sessions.open_session(fence) + session.bind_stage_request(0, "req-close-retry", fence=fence) + + def close(control_id: str) -> CloseDuplexSessionMessage: + return CloseDuplexSessionMessage( + control_id=control_id, + fence=fence, + session_id=fence.session_id, + reason="client_close", + ) + + await plane.handle(close("close-first")) + assert (await result_sink.get()).ok is False + assert plane.sessions.get(fence.session_id) is session + assert session.resource_request_ids() == ["req-close-retry"] + + await plane.handle(close("close-retry")) + assert (await result_sink.get()).ok is True + assert plane.sessions.get(fence.session_id) is None + assert stage_port.cleanup_calls == [(["req-close-retry"], True)] + + +@pytest.mark.asyncio +async def test_control_cleanup_failure_does_not_block_other_sessions() -> None: + class _IndependentFailureStagePort(_TypedStagePort): + def __init__(self) -> None: + super().__init__() + self.attempts: dict[str, int] = {} + + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + request_id = request_ids[0] + self.attempts[request_id] = self.attempts.get(request_id, 0) + 1 + if request_id == "req-stuck" or self.attempts[request_id] == 1: + raise RuntimeError(f"cleanup failed for {request_id}") + await super().cleanup(request_ids, abort=abort) + + stage_port = _IndependentFailureStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane(extension=None, stage_port=stage_port, result_sink=result_sink) + + for session_id, request_id in (("sid-stuck", "req-stuck"), ("sid-ready", "req-ready")): + fence = DuplexFence(session_id) + session = plane.sessions.open_session(fence) + session.bind_stage_request(0, request_id, fence=fence) + await plane.handle( + CloseDuplexSessionMessage( + control_id=f"close-{session_id}", + fence=fence, + session_id=session_id, + reason="client_close", + ) + ) + assert (await result_sink.get()).ok is False + + await plane.reap_expired() + + assert plane.sessions.get("sid-stuck") is not None + assert plane.sessions.get("sid-ready") is None + assert stage_port.cleanup_calls == [(["req-ready"], True)] + + +@pytest.mark.asyncio +async def test_control_cleanup_is_single_flight_with_concurrent_reaper() -> None: + class _SlowCleanupStagePort(_TypedStagePort): + def __init__(self) -> None: + super().__init__() + self.cleanup_started = asyncio.Event() + self.release_cleanup = asyncio.Event() + self.cleanup_attempts = 0 + + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + self.cleanup_attempts += 1 + self.cleanup_started.set() + await self.release_cleanup.wait() + await super().cleanup(request_ids, abort=abort) + + stage_port = _SlowCleanupStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane(extension=None, stage_port=stage_port, result_sink=result_sink) + fence = DuplexFence("sid-concurrent-cleanup") + session = plane.sessions.open_session(fence) + session.bind_stage_request(0, "req-concurrent-cleanup", fence=fence) + + close_task = asyncio.create_task( + plane.handle( + CloseDuplexSessionMessage( + control_id="close-concurrent-cleanup", + fence=fence, + session_id=fence.session_id, + reason="client_close", + ) + ) + ) + await asyncio.wait_for(stage_port.cleanup_started.wait(), timeout=1) + + reaper_task = asyncio.create_task(plane.reap_expired()) + await asyncio.sleep(0) + await asyncio.sleep(0) + assert stage_port.cleanup_attempts == 1 + + stage_port.release_cleanup.set() + await asyncio.gather(close_task, reaper_task) + + assert (await result_sink.get()).ok is True + assert plane.sessions.get(fence.session_id) is None + assert stage_port.cleanup_calls == [(["req-concurrent-cleanup"], True)] + + +@pytest.mark.asyncio +async def test_unknown_input_mode_rejects_open_without_registering_session() -> None: + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane(extension=None, stage_port=_TypedStagePort(), result_sink=result_sink) + fence = DuplexFence("sid-unknown-mode") + + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-unknown-mode", + fence=fence, + session_id=fence.session_id, + capabilities={"input_modes": ["future_mode"]}, + ) + ) + + result = await result_sink.get() + assert result.ok is False + assert result.error is not None + assert result.error.code == "invalid_capability" + assert plane.sessions.get(fence.session_id) is None + + +@pytest.mark.asyncio +async def test_extension_free_turn_commit_does_not_allocate_or_submit_stage_request() -> None: + stage_port = _TypedStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane(extension=None, stage_port=stage_port, result_sink=result_sink) + fence = DuplexFence("sid-turn-commit") + + await plane.handle( + OpenDuplexSessionMessage( + control_id="open-turn-commit", + fence=fence, + session_id=fence.session_id, + capabilities={"input_modes": [DuplexInputMode.TURN_COMMIT_ONLY.value]}, + ) + ) + assert (await result_sink.get()).ok is True + + await plane.handle( + AppendDuplexInputMessage( + control_id="append-turn-commit", + fence=fence, + session_id=fence.session_id, + mode=DuplexInputMode.TURN_COMMIT_ONLY.value, + payload=None, + final=True, + ) + ) + result = await result_sink.get() + + assert result.ok is True + assert result.stage_results[0]["result"]["data_plane_append"] is False + assert stage_port.ensure_calls == [] + assert stage_port.submit_calls == [] + + +@pytest.mark.asyncio +async def test_session_update_replaces_both_configs_with_one_generation() -> None: + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane(extension=_Extension(), stage_port=_TypedStagePort(), result_sink=result_sink) + fence = DuplexFence("sid-atomic-config") + session = plane.sessions.open_session(fence) + + await plane.handle( + SignalDuplexTurnMessage( + control_id="update-config", + fence=fence, + session_id=fence.session_id, + event="session.update", + session_config={"voice": "new"}, + runtime_config={"temperature": 0.5}, + ) + ) + + assert (await result_sink.get()).ok is True + assert session.config_generation == 1 + assert session.session_config == {"voice": "new"} + assert session.runtime_config == {"temperature": 0.5} + + +@pytest.mark.asyncio +async def test_stage_submission_is_compensated_when_local_commit_fails() -> None: + stage_port = _TypedStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane(extension=_Extension(), stage_port=stage_port, result_sink=result_sink) + fence = DuplexFence("sid-submit-compensation") + session = plane.sessions.open_session( + fence, + capabilities=DuplexRuntimeCapabilities(input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}), + ) + request_id = plane.stage_request_id(fence, stage_id=0) + + def fail_commit(_reservation): + raise RuntimeError("local commit failed") + + session.commit_append = fail_commit # type: ignore[method-assign] + await plane.handle( + AppendDuplexInputMessage( + control_id="append-compensation", + fence=fence, + session_id=fence.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"audio": b"pcm"}, + ) + ) + + assert (await result_sink.get()).ok is False + assert stage_port.cleanup_calls == [([request_id], True)] + + +@pytest.mark.asyncio +async def test_failed_submission_compensation_blocks_append_until_reaper_cleans_it() -> None: + class _FailingCleanupStagePort(_TypedStagePort): + def __init__(self) -> None: + super().__init__() + self.cleanup_failures = 2 + + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + await super().cleanup(request_ids, abort=abort) + if self.cleanup_failures > 0: + self.cleanup_failures -= 1 + raise RuntimeError("stage abort failed") + + stage_port = _FailingCleanupStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane(extension=_Extension(), stage_port=stage_port, result_sink=result_sink) + fence = DuplexFence("sid-durable-submit-compensation") + session = plane.sessions.open_session( + fence, + capabilities=DuplexRuntimeCapabilities(input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}), + ) + original_commit = session.commit_append + + def fail_commit(_reservation): + raise RuntimeError("local commit failed") + + session.commit_append = fail_commit # type: ignore[method-assign] + first = AppendDuplexInputMessage( + control_id="append-compensation-1", + fence=fence, + session_id=fence.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"audio": b"pcm"}, + ) + await plane.handle(first) + assert (await result_sink.get()).ok is False + assert plane.pending_submission_cleanup_count == 1 + assert len(stage_port.submit_calls) == 1 + + await plane.handle( + AppendDuplexInputMessage( + control_id="append-compensation-2", + fence=fence, + session_id=fence.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"audio": b"pcm"}, + ) + ) + assert (await result_sink.get()).ok is False + assert plane.pending_submission_cleanup_count == 1 + assert len(stage_port.submit_calls) == 1 + + session.commit_append = original_commit # type: ignore[method-assign] + stage_port.cleanup_failures = 0 + await plane.reap_expired() + + assert plane.pending_submission_cleanup_count == 0 + assert session.resource_request_ids() == [] + + +def test_stale_output_is_rejected_before_extension_decision() -> None: + class _CountingExtension(_Extension): + def __init__(self) -> None: + self.decision_calls = 0 + + def decide_output(self, **kwargs): + del kwargs + self.decision_calls += 1 + return None + + extension = _CountingExtension() + plane = DuplexControlPlane(extension=extension, stage_port=_TypedStagePort(), result_sink=asyncio.Queue()) + current = DuplexFence("sid-stale-output", epoch=1) + plane.sessions.open_session(current) + context = DuplexOutputContext( + identity=DuplexRequestIdentity( + session_id=current.session_id, + fence=DuplexFence(current.session_id, epoch=0), + ), + final_stage_id=1, + segment_finished=True, + ) + + assert plane.decide_output(0, object(), context) is None + assert extension.decision_calls == 0 + + +@pytest.mark.asyncio +async def test_control_dispatch_is_ordered_per_session_without_blocking_other_sessions() -> None: + class _BlockingStagePort(_TypedStagePort): + def __init__(self) -> None: + super().__init__() + self.started = asyncio.Event() + self.release = asyncio.Event() + + async def submit(self, submission: DuplexStageSubmission) -> DuplexStageSubmissionResult: + if submission.context.session_id == "sid-blocked": + self.started.set() + await self.release.wait() + return await super().submit(submission) + + stage_port = _BlockingStagePort() + result_sink: asyncio.Queue = asyncio.Queue() + plane = DuplexControlPlane(extension=_Extension(), stage_port=stage_port, result_sink=result_sink) + blocked = DuplexFence("sid-blocked") + independent = DuplexFence("sid-independent") + capabilities = DuplexRuntimeCapabilities(input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}) + plane.sessions.open_session(blocked, capabilities=capabilities) + plane.sessions.open_session(independent, capabilities=capabilities) + + plane.dispatch( + AppendDuplexInputMessage( + control_id="blocked-append", + fence=blocked, + session_id=blocked.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"audio": b"pcm"}, + operation_id="blocked-operation", + ) + ) + await asyncio.wait_for(stage_port.started.wait(), timeout=1) + plane.dispatch( + AppendDuplexInputMessage( + control_id="blocked-append-retry", + fence=blocked, + session_id=blocked.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"audio": b"pcm"}, + operation_id="blocked-operation", + ) + ) + plane.dispatch( + TouchDuplexSessionMessage( + control_id="blocked-touch", + fence=blocked, + session_id=blocked.session_id, + activity=DuplexLeaseActivity.HEARTBEAT.value, + ) + ) + plane.dispatch( + TouchDuplexSessionMessage( + control_id="independent-touch", + fence=independent, + session_id=independent.session_id, + activity=DuplexLeaseActivity.HEARTBEAT.value, + ) + ) + + independent_result = await asyncio.wait_for(result_sink.get(), timeout=1) + assert independent_result.control_id == "independent-touch" + + stage_port.release.set() + await plane.drain() + assert [result_sink.get_nowait().control_id for _ in range(3)] == [ + "blocked-append", + "blocked-append-retry", + "blocked-touch", + ] + assert [ + submission.context.session_id + for submission in stage_port.submit_calls + if submission.context.session_id == blocked.session_id + ] == [blocked.session_id] diff --git a/tests/e2e/features/fullduplex/engine/test_duplex_deploy_config.py b/tests/e2e/features/fullduplex/engine/test_duplex_deploy_config.py new file mode 100644 index 000000000..abb387573 --- /dev/null +++ b/tests/e2e/features/fullduplex/engine/test_duplex_deploy_config.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Focused tests for typed deployment-only runtime configuration.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from vllm_omni.config.stage_config import ( + DuplexSessionRuntimeConfig, + load_deploy_config, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_duplex_session_runtime_defaults_are_typed_and_immutable(tmp_path) -> None: + deploy_path = tmp_path / "duplex.yaml" + deploy_path.write_text("duplex_session: {}\nstages: []\n", encoding="utf-8") + + deploy = load_deploy_config(deploy_path) + + assert isinstance(deploy.duplex_session, DuplexSessionRuntimeConfig) + assert deploy.duplex_session.idle_ttl_s == 300.0 + assert deploy.duplex_session.disconnect_grace_s == 30.0 + assert deploy.duplex_session.reaper_interval_s == 5.0 + assert deploy.duplex_session.resume_replay_ttl_s == 60.0 + assert deploy.duplex_session.resume_replay_max_bytes_per_session == 8 * 1024 * 1024 + assert deploy.duplex_session.max_pending_input_bytes_per_session == 16 * 1024 * 1024 + assert deploy.duplex_session.max_pending_turns_per_session == 4 + assert deploy.duplex_session.max_sessions == 1 + assert deploy.duplex_session.completed_append_cache_size == 256 + with pytest.raises(FrozenInstanceError): + deploy.duplex_session.idle_ttl_s = 1.0 # type: ignore[misc] + + +def test_duplex_session_runtime_accepts_disabled_idle_expiry(tmp_path) -> None: + deploy_path = tmp_path / "duplex.yaml" + deploy_path.write_text("duplex_session:\n idle_ttl_s: null\nstages: []\n", encoding="utf-8") + + deploy = load_deploy_config(deploy_path) + + assert deploy.duplex_session.idle_ttl_s is None + + +@pytest.mark.parametrize( + ("name", "value"), + [ + ("idle_ttl_s", 0), + ("idle_ttl_s", -1), + ("disconnect_grace_s", 0), + ("reaper_interval_s", -1), + ("resume_replay_ttl_s", 0), + ("resume_replay_max_bytes_per_session", -1), + ("max_pending_input_bytes_per_session", 0), + ("max_pending_turns_per_session", -1), + ("max_sessions", 0), + ("completed_append_cache_size", 0), + ], +) +def test_duplex_session_runtime_rejects_non_positive_values(tmp_path, name: str, value: int) -> None: + deploy_path = tmp_path / "duplex.yaml" + deploy_path.write_text(f"duplex_session:\n {name}: {value}\nstages: []\n", encoding="utf-8") + + with pytest.raises(ValueError, match=rf"duplex_session\.{name} must be positive"): + load_deploy_config(deploy_path) diff --git a/tests/e2e/features/fullduplex/engine/test_duplex_intermediate.py b/tests/e2e/features/fullduplex/engine/test_duplex_intermediate.py new file mode 100644 index 000000000..37602e62e --- /dev/null +++ b/tests/e2e/features/fullduplex/engine/test_duplex_intermediate.py @@ -0,0 +1,14 @@ +import pytest + +from vllm_omni.experimental.fullduplex.engine.intermediate import get_stream_request_key + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_get_stream_request_key_requires_stable_identifier(): + with pytest.raises(ValueError, match="stable request id"): + get_stream_request_key({"ids": {"tts": [1, 2, 3]}}) + + +def test_get_stream_request_key_accepts_global_request_id(): + assert get_stream_request_key({"global_request_id": ["duplex-sid-stage0"]}) == "duplex-sid-stage0" diff --git a/tests/e2e/features/fullduplex/engine/test_duplex_lease.py b/tests/e2e/features/fullduplex/engine/test_duplex_lease.py new file mode 100644 index 000000000..80e7a73eb --- /dev/null +++ b/tests/e2e/features/fullduplex/engine/test_duplex_lease.py @@ -0,0 +1,224 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from vllm_omni.experimental.fullduplex.engine.duplex_runtime import DuplexInputMode +from vllm_omni.experimental.fullduplex.engine.duplex_session import DuplexSessionRuntimeManager +from vllm_omni.experimental.fullduplex.engine.lease import ( + DuplexLeaseActivity, + DuplexLeaseConfig, +) +from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class FakeMonotonicClock: + def __init__(self, value: float) -> None: + self.value = value + + def __call__(self) -> float: + return self.value + + def advance(self, seconds: float) -> None: + self.value += seconds + + +def _lease_config(*, idle_ttl_s: float | None = 300.0) -> DuplexLeaseConfig: + return DuplexLeaseConfig( + idle_ttl_s=idle_ttl_s, + disconnect_grace_s=30.0, + ) + + +def test_open_touch_detach_and_idle_expiry_use_monotonic_time() -> None: + clock = FakeMonotonicClock(100.0) + manager = DuplexSessionRuntimeManager(clock=clock) + fence = DuplexFence("sid-expiry") + session = manager.open_session(fence, lease_config=_lease_config()) + + assert session.lease.last_activity == 100.0 + clock.advance(10.0) + session.touch(fence, DuplexLeaseActivity.HEARTBEAT) + session.detach(fence) + clock.advance(29.0) + assert session.lease.disconnect_grace_expired(clock()) is False + assert manager.collect_expired() == [] + + clock.advance(272.0) + expired = manager.collect_expired() + + assert [item.session_id for item in expired] == ["sid-expiry"] + assert expired[0].reason == "disconnect_grace_expired" + assert manager.get("sid-expiry") is session + manager.finalize_close_session(session) + assert manager.get("sid-expiry") is None + + +def test_detach_grace_and_resume_advance_token_independent_lease_generation() -> None: + clock = FakeMonotonicClock(10.0) + manager = DuplexSessionRuntimeManager(clock=clock) + fence = DuplexFence("sid-resume") + session = manager.open_session(fence, lease_config=_lease_config()) + + session.detach(fence) + clock.advance(29.0) + assert session.lease.disconnect_grace_expired(clock()) is False + clock.advance(2.0) + assert session.lease.disconnect_grace_expired(clock()) is True + + generation = session.resume(fence, expected_lease_generation=0) + + assert generation == 1 + assert session.lease.detached_at is None + assert session.lease.disconnect_grace_expired(clock()) is False + with pytest.raises(ValueError, match="lease generation mismatch"): + session.resume(fence, expected_lease_generation=0) + + +def test_active_operation_prevents_mid_transaction_expiry() -> None: + clock = FakeMonotonicClock(0.0) + manager = DuplexSessionRuntimeManager(clock=clock) + fence = DuplexFence("sid-operation") + session = manager.open_session(fence, lease_config=_lease_config(idle_ttl_s=5.0)) + + session.begin_operation(fence, "append-1") + clock.advance(10.0) + assert manager.collect_expired() == [] + + session.end_operation(fence, "append-1") + assert manager.collect_expired() == [] + clock.advance(6.0) + assert [item.session_id for item in manager.collect_expired()] == ["sid-operation"] + + +def test_sessions_have_independent_activity_deadlines_and_resources() -> None: + clock = FakeMonotonicClock(0.0) + manager = DuplexSessionRuntimeManager(clock=clock) + fence_a = DuplexFence("sid-a") + fence_b = DuplexFence("sid-b") + session_a = manager.open_session(fence_a, lease_config=_lease_config(idle_ttl_s=10.0)) + session_b = manager.open_session(fence_b, lease_config=_lease_config(idle_ttl_s=10.0)) + session_a.reserve_stage_request(0, "req-a-reserved", fence=fence_a) + session_a.bind_stage_request(1, "req-a-submitted", fence=fence_a) + session_b.bind_stage_request(0, "req-b", fence=fence_b) + + clock.advance(6.0) + session_b.touch(fence_b, DuplexLeaseActivity.MODEL_OUTPUT) + clock.advance(5.0) + expired = manager.collect_expired() + + assert len(expired) == 1 + assert expired[0].session_id == "sid-a" + assert expired[0].reserved_request_ids == ("req-a-reserved",) + assert expired[0].submitted_request_ids == ("req-a-submitted",) + assert manager.get("sid-b") is session_b + assert session_b.resource_request_ids() == ["req-b"] + + +def test_close_and_reaper_select_exactly_one_terminal_transition() -> None: + clock = FakeMonotonicClock(0.0) + manager = DuplexSessionRuntimeManager(clock=clock) + fence = DuplexFence("sid-race") + manager.open_session(fence, lease_config=_lease_config(idle_ttl_s=1.0)) + clock.advance(2.0) + + assert len(manager.collect_expired()) == 1 + assert manager.close_session(fence, reason="explicit_close") is None + assert manager.collect_expired() == [] + + fence_2 = DuplexFence("sid-race-2") + manager.open_session(fence_2, lease_config=_lease_config(idle_ttl_s=1.0)) + assert manager.close_session(fence_2, reason="explicit_close") is not None + clock.advance(2.0) + assert manager.collect_expired() == [] + + +def test_stale_fence_cannot_touch_detach_or_resume_lease() -> None: + clock = FakeMonotonicClock(0.0) + manager = DuplexSessionRuntimeManager(clock=clock) + current = DuplexFence("sid-fence", epoch=1) + stale = DuplexFence("sid-fence", epoch=0) + session = manager.open_session(current, lease_config=_lease_config()) + + with pytest.raises(RuntimeError, match="fence mismatch"): + session.touch(stale, DuplexLeaseActivity.APPEND) + with pytest.raises(RuntimeError, match="fence mismatch"): + session.detach(stale) + with pytest.raises(RuntimeError, match="fence mismatch"): + session.resume(stale, expected_lease_generation=0) + + +def test_disabled_idle_expiry_never_collects_session() -> None: + clock = FakeMonotonicClock(0.0) + manager = DuplexSessionRuntimeManager(clock=clock) + manager.open_session(DuplexFence("sid-no-expiry"), lease_config=_lease_config(idle_ttl_s=None)) + + clock.advance(1_000_000.0) + + assert manager.collect_expired() == [] + + +def test_detached_session_expires_at_disconnect_grace_when_idle_ttl_is_disabled() -> None: + clock = FakeMonotonicClock(0.0) + manager = DuplexSessionRuntimeManager(clock=clock) + fence = DuplexFence("sid-disconnect-grace") + session = manager.open_session(fence, lease_config=_lease_config(idle_ttl_s=None)) + + session.detach(fence) + clock.advance(29.0) + assert manager.collect_expired() == [] + + clock.advance(1.0) + expired = manager.collect_expired() + + assert [item.session_id for item in expired] == [fence.session_id] + assert expired[0].reason == "disconnect_grace_expired" + + +def test_runtime_manager_enforces_server_owned_session_admission() -> None: + manager = DuplexSessionRuntimeManager(max_sessions=2) + manager.open_session(DuplexFence("sid-admission-a")) + manager.open_session(DuplexFence("sid-admission-b")) + + with pytest.raises(RuntimeError, match="duplex_session_capacity_exhausted"): + manager.open_session(DuplexFence("sid-admission-c")) + + +def test_completed_append_cache_is_bounded() -> None: + manager = DuplexSessionRuntimeManager(completed_append_limit=2) + fence = DuplexFence("sid-completed-cache") + session = manager.open_session(fence) + + for index in range(3): + session.record_completed_append( + f"operation-{index}", + fence=fence, + mode=DuplexInputMode.TURN_COMMIT_ONLY, + final=True, + stage_results=[{"index": index}], + ) + + assert list(session.completed_appends) == ["operation-1", "operation-2"] + + session.accept_fence(DuplexFence(fence.session_id, epoch=1)) + assert session.completed_appends == {} + + +def test_lease_config_and_expiry_record_are_immutable() -> None: + config = _lease_config() + with pytest.raises(FrozenInstanceError): + config.idle_ttl_s = 1.0 # type: ignore[misc] + + clock = FakeMonotonicClock(0.0) + manager = DuplexSessionRuntimeManager(clock=clock) + manager.open_session(DuplexFence("sid-record"), lease_config=_lease_config(idle_ttl_s=1.0)) + clock.advance(2.0) + record = manager.collect_expired()[0] + with pytest.raises(FrozenInstanceError): + record.reason = "changed" # type: ignore[misc] diff --git a/tests/e2e/features/fullduplex/engine/test_duplex_runtime.py b/tests/e2e/features/fullduplex/engine/test_duplex_runtime.py new file mode 100644 index 000000000..7cce93161 --- /dev/null +++ b/tests/e2e/features/fullduplex/engine/test_duplex_runtime.py @@ -0,0 +1,480 @@ +from dataclasses import FrozenInstanceError +from types import SimpleNamespace + +import pytest +from vllm.sampling_params import SamplingParams + +from vllm_omni.experimental.fullduplex.engine import duplex_runtime +from vllm_omni.experimental.fullduplex.engine.contracts import ( + duplex_data_plane_request_info, + duplex_resource_request_belongs_to_session, + duplex_resource_request_id, +) +from vllm_omni.experimental.fullduplex.engine.duplex_runtime import ( + DuplexInputMode, + DuplexOutputAction, + DuplexOutputDecision, + DuplexRuntimeCapabilities, + DuplexSessionRuntimeManager, +) +from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence +from vllm_omni.experimental.fullduplex.minicpmo45.runtime import ( + MiniCPMO45DuplexRuntimeExtension, + build_duplex_data_plane_prompt, + duplex_scheduler_token_budget, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_duplex_fence_is_immutable(): + fence = DuplexFence("session") + + with pytest.raises(FrozenInstanceError): + fence.epoch = 1 # type: ignore[misc] + + assert not hasattr(fence, "__dict__") + + +def test_duplex_runtime_tracks_stage_bindings_and_barge_in_epoch(): + manager = DuplexSessionRuntimeManager() + session = manager.open_session( + DuplexFence("sid-1"), + capabilities=DuplexRuntimeCapabilities( + input_modes={DuplexInputMode.APPEND_TOKENS}, + ), + ) + session.bind_stage_request(stage_id=0, request_id="req-stage0", fence=session.fence) + session.bind_stage_request(stage_id=1, request_id="req-stage1", fence=session.fence) + + update = session.append_input(mode=DuplexInputMode.APPEND_TOKENS, fence=session.fence) + next_fence = DuplexFence("sid-1", epoch=1) + stale_request_ids = session.release_fence(session.fence) + session.accept_fence(next_fence) + + assert update.seq == 1 + assert session.fence == next_fence + assert stale_request_ids == ["req-stage0", "req-stage1"] + assert session.stage_bindings == {} + + +def test_duplex_runtime_tracks_same_request_id_for_each_pipeline_stage(): + manager = DuplexSessionRuntimeManager() + fence = DuplexFence("sid-shared-pipeline-request") + session = manager.open_session(fence) + + session.reserve_stage_request(0, "req-shared", fence=fence) + session.bind_stage_request(0, "req-shared", fence=fence) + session.bind_stage_request(1, "req-shared", fence=fence) + + assert session.stage_bindings[0].request_id == "req-shared" + assert session.stage_bindings[1].request_id == "req-shared" + assert session.resource_request_ids() == ["req-shared"] + assert session.input_seq == 0 + + +def test_duplex_runtime_extension_validation_rejects_missing_methods(): + class IncompleteExtension: + def configure_sampling_params(self, *, runtime_config, defaults): + del runtime_config + return defaults + + with pytest.raises(TypeError, match="plan_append"): + duplex_runtime.validate_duplex_runtime_extension(IncompleteExtension()) + + +def test_duplex_runtime_extension_validation_rejects_stage_count_mismatch(): + class WrongStageCountExtension(MiniCPMO45DuplexRuntimeExtension): + def configure_sampling_params(self, *, runtime_config, defaults): + del runtime_config + return defaults[:1] + + with pytest.raises(ValueError, match="one sampling parameter per stage"): + duplex_runtime.validate_duplex_runtime_extension( + WrongStageCountExtension(), + sampling_defaults=(SamplingParams(), SamplingParams()), + ) + + +def test_duplex_runtime_extension_validation_rejects_sampling_type_mismatch(): + class WrongSamplingTypeExtension(MiniCPMO45DuplexRuntimeExtension): + def configure_sampling_params(self, *, runtime_config, defaults): + del runtime_config + return tuple(object() for _ in defaults) + + with pytest.raises(TypeError, match="sampling parameter type"): + duplex_runtime.validate_duplex_runtime_extension( + WrongSamplingTypeExtension(), + sampling_defaults=(SamplingParams(), SamplingParams()), + ) + + +def test_duplex_output_decision_metadata_is_immutable(): + decision = DuplexOutputDecision( + action=DuplexOutputAction.DIRECT_RESPONSE, + metadata={"model_listen": True}, + ) + + with pytest.raises(TypeError): + decision.metadata["model_listen"] = False + + +def _decide_minicpmo_output( + output: object, + *, + segment_token_ids: tuple[int, ...] = (), + segment_output_metadata: dict | None = None, +): + return MiniCPMO45DuplexRuntimeExtension().decide_output( + stage_id=0, + final_stage_id=1, + segment_finished=True, + segment_token_ids=segment_token_ids, + segment_output_metadata=segment_output_metadata or {}, + output=output, + ) + + +def test_minicpmo_extension_owns_stage_sampling_overrides(): + defaults = ( + SamplingParams(max_tokens=4), + SamplingParams(max_tokens=8), + ) + + configured = MiniCPMO45DuplexRuntimeExtension().configure_sampling_params( + runtime_config={ + "duplex_stage_max_tokens": {"0": 20}, + "duplex_stage_sampling_params": {"1": {"stop_token_ids": [151645]}}, + }, + defaults=defaults, + ) + + assert configured[0].max_tokens == 20 + assert configured[1].stop_token_ids == [151645] + assert defaults[0].max_tokens == 4 + assert 151645 not in (defaults[1].stop_token_ids or []) + + +def test_minicpmo_output_decision_uses_raw_streaming_token_snapshot(): + decision = _decide_minicpmo_output( + SimpleNamespace(outputs=[SimpleNamespace()]), + segment_token_ids=(151705,), + segment_output_metadata={"special_token_ids": {"listen_token_id": 151705}}, + ) + + assert decision is not None + assert decision.action is DuplexOutputAction.DIRECT_RESPONSE + assert decision.metadata["duplex_native_decision"] == "listen" + assert decision.metadata["model_listen"] is True + + +@pytest.mark.parametrize("attr", ["token_ids", "cumulative_token_ids"]) +def test_minicpmo_output_decision_ignores_output_level_token_history(attr): + output = SimpleNamespace( + multimodal_output={"special_token_ids": {"listen_token_id": 151705}}, + outputs=[SimpleNamespace()], + **{attr: [42, 151705]}, + ) + + assert _decide_minicpmo_output(output) is None + + +@pytest.mark.parametrize("attr", ["token_ids", "cumulative_token_ids"]) +def test_minicpmo_output_decision_uses_completion_token_ids(attr): + output = SimpleNamespace( + multimodal_output={"special_token_ids": {"listen_token_id": 151705}}, + outputs=[SimpleNamespace(**{attr: [42, 151705]})], + ) + + assert _decide_minicpmo_output(output) is not None + + +def test_minicpmo_output_decision_uses_completion_stop_reason(): + output = SimpleNamespace( + multimodal_output={"special_token_ids": {"listen_token_id": 151705}}, + outputs=[SimpleNamespace(stop_reason=151705)], + ) + + assert _decide_minicpmo_output(output) is not None + + +def test_duplex_runtime_cancel_fence_rejects_late_append_and_accepts_next_epoch(): + manager = DuplexSessionRuntimeManager() + cancelled_fence = DuplexFence("sid-cancel-race") + next_fence = DuplexFence("sid-cancel-race", epoch=1) + session = manager.open_session( + cancelled_fence, + capabilities=DuplexRuntimeCapabilities( + input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}, + ), + ) + session.bind_stage_request( + stage_id=0, + request_id="req-cancelled", + fence=cancelled_fence, + ) + + stale_request_ids = session.cancel_fence(cancelled_fence, next_fence) + + assert stale_request_ids == ["req-cancelled"] + assert session.fence == next_fence + assert session.stage_bindings == {} + with pytest.raises(RuntimeError, match="fence mismatch"): + session.append_input( + mode=DuplexInputMode.APPEND_AUDIO_CHUNK, + fence=cancelled_fence, + ) + update = session.append_input( + mode=DuplexInputMode.APPEND_AUDIO_CHUNK, + fence=next_fence, + ) + assert update.seq == 1 + + +def test_duplex_runtime_stale_close_preserves_live_session_and_bindings(): + manager = DuplexSessionRuntimeManager() + current_fence = DuplexFence("sid-stale-close", epoch=1) + session = manager.open_session( + current_fence, + capabilities=DuplexRuntimeCapabilities( + input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}, + ), + ) + session.bind_stage_request(0, "req-live", fence=current_fence) + + with pytest.raises(RuntimeError, match="fence mismatch"): + manager.close_session(DuplexFence("sid-stale-close")) + + assert manager.get("sid-stale-close") is session + assert session.stage_request_ids() == ["req-live"] + + +def test_duplex_runtime_reopen_rejects_late_append_from_old_incarnation(): + manager = DuplexSessionRuntimeManager() + old_fence = DuplexFence("sid-reopen", incarnation=0) + old_session = manager.open_session( + old_fence, + capabilities=DuplexRuntimeCapabilities( + input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}, + ), + ) + manager.close_session(old_fence) + + new_fence = DuplexFence("sid-reopen", incarnation=1) + new_session = manager.open_session( + new_fence, + capabilities=old_session.capabilities, + ) + + with pytest.raises(RuntimeError, match="fence mismatch"): + new_session.append_input( + mode=DuplexInputMode.APPEND_AUDIO_CHUNK, + fence=old_fence, + ) + assert ( + new_session.append_input( + mode=DuplexInputMode.APPEND_AUDIO_CHUNK, + fence=new_fence, + ).seq + == 1 + ) + + +def test_duplex_prompt_expands_incarnation_metadata(): + fence = DuplexFence("sid-incarnation", incarnation=3) + + prompt = build_duplex_data_plane_prompt( + request_id=duplex_resource_request_id(fence, "stage0"), + fence=fence, + session_config={}, + runtime_config={}, + seq=1, + turn_seq=1, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK, + payload={"is_speech": True}, + final=False, + ) + + assert prompt["model_intermediate_buffer"]["duplex"]["incarnation"] == 3 + + +def test_duplex_runtime_tracks_turn_local_append_sequence(): + manager = DuplexSessionRuntimeManager() + session = manager.open_session( + DuplexFence("sid-turn-seq"), + capabilities=DuplexRuntimeCapabilities( + input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}, + ), + ) + + first = session.append_input(mode=DuplexInputMode.APPEND_AUDIO_CHUNK, fence=session.fence) + second = session.append_input(mode=DuplexInputMode.APPEND_AUDIO_CHUNK, fence=session.fence) + next_turn = DuplexFence("sid-turn-seq", turn_id=1, response_seq=1) + third = session.append_input( + mode=DuplexInputMode.APPEND_AUDIO_CHUNK, + fence=next_turn, + ) + fourth = session.append_input( + mode=DuplexInputMode.APPEND_AUDIO_CHUNK, + fence=next_turn, + ) + + assert [first.seq, second.seq, third.seq, fourth.seq] == [1, 2, 3, 4] + assert [first.turn_seq, second.turn_seq, third.turn_seq, fourth.turn_seq] == [1, 2, 1, 2] + assert [first.turn_id, second.turn_id, third.turn_id, fourth.turn_id] == [0, 0, 1, 1] + + +def test_duplex_runtime_rejects_unsupported_append_mode(): + manager = DuplexSessionRuntimeManager() + session = manager.open_session( + DuplexFence("sid-2"), + capabilities=DuplexRuntimeCapabilities(input_modes={DuplexInputMode.TURN_COMMIT_ONLY}), + ) + + with pytest.raises(ValueError, match="not supported"): + session.append_input(mode=DuplexInputMode.APPEND_TOKENS, fence=session.fence) + + +def test_duplex_data_plane_request_info_extracts_structured_stage_result(): + request_id, response_stage_id = duplex_data_plane_request_info( + { + "stage_results": [ + {"result": {"supported": True}}, + { + "result": { + "data_plane_append": True, + "request_id": "duplex-sid-e0-stage0-s1", + "response_stage_id": 1, + } + }, + ] + } + ) + + assert request_id == "duplex-sid-e0-stage0-s1" + assert response_stage_id == 1 + + +def test_duplex_data_plane_request_info_rejects_missing_request_id(): + assert duplex_data_plane_request_info( + { + "stage_results": [ + { + "result": { + "data_plane_append": True, + "request_id": "", + "response_stage_id": 1, + } + } + ] + } + ) == (None, None) + + +def test_duplex_scheduler_token_budget_estimates_pcm_slots(): + assert ( + duplex_scheduler_token_budget( + { + "audio": "AAAAAA==", + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + == 16 + ) + + +def test_duplex_scheduler_token_budget_ignores_client_budget_fields(): + assert ( + duplex_scheduler_token_budget( + { + "audio": "AAAAAA==", + "format": "pcm_f32le", + "duplex_num_input_tokens": 999, + "num_input_tokens": 999, + } + ) + == 16 + ) + + +def test_resource_state_rejects_fence_regression_and_requires_explicit_fence(): + current = DuplexFence("sid", epoch=2, turn_id=3, response_seq=4) + manager = DuplexSessionRuntimeManager() + session = manager.open_session( + current, + capabilities=DuplexRuntimeCapabilities(input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}), + ) + + for stale in ( + DuplexFence("sid", epoch=1, turn_id=99, response_seq=99), + DuplexFence("sid", epoch=2, turn_id=2, response_seq=4), + DuplexFence("sid", epoch=2, turn_id=3, response_seq=3), + ): + with pytest.raises(RuntimeError, match="fence mismatch"): + session.accept_fence(stale) + assert session.fence == current + + with pytest.raises(TypeError, match="fence"): + session.bind_stage_request(0, "request") + with pytest.raises(TypeError, match="fence"): + session.append_input(mode=DuplexInputMode.APPEND_AUDIO_CHUNK) + with pytest.raises(TypeError, match="DuplexFence"): + manager.open_session("legacy-session") + with pytest.raises(TypeError, match="DuplexFence"): + manager.close_session("legacy-session") + + +def test_resource_request_id_is_derived_from_fence_and_role(): + fence = DuplexFence("sid-with-dashes", epoch=7, turn_id=11, response_seq=13) + + assert duplex_resource_request_id(fence, "stage0") == "duplex-s.c2lkLXdpdGgtZGFzaGVz.i.0.e.7.r.stage0" + assert duplex_resource_request_id(fence, "stage1") == "duplex-s.c2lkLXdpdGgtZGFzaGVz.i.0.e.7.r.stage1" + + +def test_resource_request_id_codec_separates_session_id_from_incarnation(): + embedded_incarnation = duplex_resource_request_id( + DuplexFence("foo-i1", incarnation=0), + "stage0", + ) + actual_incarnation = duplex_resource_request_id( + DuplexFence("foo", incarnation=1), + "stage0", + ) + + assert embedded_incarnation != actual_incarnation + + +def test_resource_request_id_session_membership_uses_encoded_identity(): + request_id = duplex_resource_request_id( + DuplexFence("sid-with-dashes", incarnation=2, epoch=7), + "stage0", + ) + + assert duplex_resource_request_belongs_to_session(request_id, "sid-with-dashes") is True + assert duplex_resource_request_belongs_to_session(request_id, "sid") is False + assert duplex_resource_request_belongs_to_session("duplex-s.invalid.i.x.e.7.r.stage0", "sid") is False + + +def test_placeholder_budget_is_planned_inside_omni_engine_boundary(): + fence = DuplexFence("sid", turn_id=1, response_seq=1) + prompt = build_duplex_data_plane_prompt( + request_id=duplex_resource_request_id(fence, "stage0"), + fence=fence, + session_config={}, + runtime_config={}, + seq=2, + turn_seq=1, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK, + payload={ + "audio": "AAAAAA==", + "format": "pcm_f32le", + "duplex_num_input_tokens": 999, + "num_input_tokens": 999, + }, + final=False, + ) + + assert len(prompt["prompt_token_ids"]) == 16 + assert prompt["model_intermediate_buffer"]["duplex"]["fence"] == fence + assert prompt["model_intermediate_buffer"]["duplex"]["scheduler_token_budget"] == 16 diff --git a/vllm_omni/model_executor/stage_configs/__init__.py b/tests/e2e/features/fullduplex/minicpmo45/__init__.py similarity index 100% rename from vllm_omni/model_executor/stage_configs/__init__.py rename to tests/e2e/features/fullduplex/minicpmo45/__init__.py diff --git a/tests/e2e/features/fullduplex/minicpmo45/test_commit_policy.py b/tests/e2e/features/fullduplex/minicpmo45/test_commit_policy.py new file mode 100644 index 000000000..5ee66b41a --- /dev/null +++ b/tests/e2e/features/fullduplex/minicpmo45/test_commit_policy.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import pytest + +from vllm_omni.experimental.fullduplex.openai.commit_policy import ( + CommitAction, + CommitSnapshot, + decide_commit_action, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +@pytest.mark.parametrize( + ("case", "snapshot", "expected"), + [ + ( + "first idle commit", + CommitSnapshot( + auto_responds=True, + speech_since_commit=True, + active_response_id=None, + overlap_speech_ms=0, + native_response_in_progress=False, + playback_active=False, + ), + CommitAction.START_AUTO_RESPONSE, + ), + ( + "native auto-response remains model-owned during playback", + CommitSnapshot( + auto_responds=True, + speech_since_commit=True, + active_response_id=None, + overlap_speech_ms=800, + native_response_in_progress=True, + playback_active=True, + ), + CommitAction.START_AUTO_RESPONSE, + ), + ( + "native auto-response commit without speech", + CommitSnapshot( + auto_responds=True, + speech_since_commit=False, + active_response_id=None, + overlap_speech_ms=0, + native_response_in_progress=True, + playback_active=False, + ), + CommitAction.COMMIT_ONLY, + ), + ( + "non-auto response overlap", + CommitSnapshot( + auto_responds=False, + speech_since_commit=True, + active_response_id=None, + overlap_speech_ms=800, + native_response_in_progress=True, + playback_active=False, + ), + CommitAction.DEFER_ACTIVE_RESPONSE, + ), + ( + "non-auto playback overlap", + CommitSnapshot( + auto_responds=False, + speech_since_commit=True, + active_response_id=None, + overlap_speech_ms=800, + native_response_in_progress=False, + playback_active=True, + ), + CommitAction.DEFER_ACTIVE_RESPONSE, + ), + ( + "non-auto commit without overlap", + CommitSnapshot( + auto_responds=False, + speech_since_commit=True, + active_response_id=None, + overlap_speech_ms=0, + native_response_in_progress=False, + playback_active=False, + ), + CommitAction.COMMIT_ONLY, + ), + ], + ids=lambda value: value if isinstance(value, str) else None, +) +def test_decide_commit_action_covers_realtime_lifecycle_sequences( + case: str, + snapshot: CommitSnapshot, + expected: CommitAction, +) -> None: + del case + + assert decide_commit_action(snapshot) is expected diff --git a/tests/e2e/features/fullduplex/minicpmo45/test_input.py b/tests/e2e/features/fullduplex/minicpmo45/test_input.py new file mode 100644 index 000000000..5562fb237 --- /dev/null +++ b/tests/e2e/features/fullduplex/minicpmo45/test_input.py @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import base64 + +import numpy as np +import pytest + +from vllm_omni.experimental.fullduplex.minicpmo45.input import MiniCPMO45PcmAppendBuffer + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def pcm_payload(samples: int, *, speech: bool = True) -> dict[str, object]: + audio = np.ones(samples, dtype=np.float32).tobytes() + return { + "type": "audio", + "audio": base64.b64encode(audio).decode("ascii"), + "format": "pcm_f32le", + "sample_rate_hz": 16_000, + "is_speech": speech, + } + + +def test_commit_does_not_add_silence_after_incremental_audio_was_drained(): + buffer = MiniCPMO45PcmAppendBuffer() + + emitted = buffer.append(pcm_payload(16_000), chunk_period_ms=1_000) + committed = buffer.commit(chunk_period_ms=1_000) + + assert emitted is not None + assert not buffer.has_pending() + assert committed is None + + +def test_append_emits_one_model_unit_when_multiple_units_are_buffered(): + buffer = MiniCPMO45PcmAppendBuffer() + + emitted = buffer.append(pcm_payload(32_000), chunk_period_ms=1_000) + + assert emitted is not None + assert len(base64.b64decode(emitted["audio"])) == 16_000 * 4 + assert buffer.pending_byte_count == 16_000 * 4 + + +def test_speech_marker_does_not_leak_across_irregular_chunk_boundaries(): + buffer = MiniCPMO45PcmAppendBuffer() + + assert buffer.append(pcm_payload(15_000), chunk_period_ms=1_000) is None + speech_unit = buffer.append(pcm_payload(2_000, speech=False), chunk_period_ms=1_000) + silence_unit = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000) + + assert speech_unit is not None + assert speech_unit["is_speech"] is True + assert silence_unit is not None + assert silence_unit["is_speech"] is False + + +def test_force_listen_marker_does_not_leak_across_irregular_chunk_boundaries(): + buffer = MiniCPMO45PcmAppendBuffer() + forced = pcm_payload(15_000, speech=False) + forced["force_listen"] = True + + assert buffer.append(forced, chunk_period_ms=1_000) is None + forced_unit = buffer.append(pcm_payload(2_000, speech=False), chunk_period_ms=1_000) + unforced_unit = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000) + + assert forced_unit is not None + assert forced_unit["force_listen"] is True + assert unforced_unit is not None + assert unforced_unit["force_listen"] is False + + +def test_pcm_append_rollback_restores_per_span_speech_markers(): + buffer = MiniCPMO45PcmAppendBuffer() + assert buffer.append(pcm_payload(15_000), chunk_period_ms=1_000) is None + reservation = buffer.prepare_append( + pcm_payload(2_000, speech=False), + operation_id="mixed-unit", + chunk_period_ms=1_000, + ) + + assert reservation is not None + reservation.rollback() + first = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000) + second = buffer.flush(chunk_period_ms=1_000) + + assert first is not None + assert first["is_speech"] is True + assert second is not None + assert second["is_speech"] is False + + +def test_commit_without_speech_does_not_synthesize_terminal_audio(): + buffer = MiniCPMO45PcmAppendBuffer() + buffer.append(pcm_payload(8_000, speech=False), chunk_period_ms=1_000) + + committed = buffer.commit(chunk_period_ms=1_000) + + assert committed is None + + +def test_commit_resets_cumulative_turn_accounting(): + buffer = MiniCPMO45PcmAppendBuffer() + buffer.append(pcm_payload(16_000), chunk_period_ms=1_000) + buffer.commit(chunk_period_ms=1_000) + + empty = buffer.commit(chunk_period_ms=1_000) + + assert empty is None + + +def test_pcm_append_reservation_rollback_restores_emitted_audio(): + buffer = MiniCPMO45PcmAppendBuffer() + original = pcm_payload(16_000) + + reservation = buffer.prepare_append( + original, + operation_id="append-1", + chunk_period_ms=1_000, + ) + + assert reservation is not None + assert not buffer.has_pending() + reservation.rollback() + assert buffer.has_pending() + + retried = buffer.flush(chunk_period_ms=1_000) + assert retried is not None + assert base64.b64decode(retried["audio"]) == base64.b64decode(original["audio"]) + + +def test_pcm_commit_keeps_prior_append_reservation_active(): + buffer = MiniCPMO45PcmAppendBuffer() + append_reservation = buffer.prepare_append( + pcm_payload(16_000), + operation_id="append-before-commit", + chunk_period_ms=1_000, + ) + + commit_reservation = buffer.prepare_commit( + operation_id="commit-after-append", + chunk_period_ms=1_000, + ) + + assert append_reservation is not None + assert append_reservation.active + assert commit_reservation.payload is None + append_reservation.commit() + commit_reservation.commit() + + +def test_pcm_commit_reservation_rollback_restores_residual_audio(): + buffer = MiniCPMO45PcmAppendBuffer() + original = pcm_payload(8_000) + assert ( + buffer.prepare_append( + original, + operation_id="buffer-half-chunk", + chunk_period_ms=1_000, + ) + is None + ) + + reservation = buffer.prepare_commit( + operation_id="final-half-chunk", + chunk_period_ms=1_000, + ) + + assert reservation.payload is not None + assert reservation.payload["final"] is True + reservation.rollback() + + retried = buffer.prepare_commit( + operation_id="retry-final-half-chunk", + chunk_period_ms=1_000, + ) + assert retried.payload is not None + assert base64.b64decode(retried.payload["audio"]) == (base64.b64decode(original["audio"]) + b"\x00" * (8_000 * 4)) diff --git a/tests/e2e/features/fullduplex/openai/test_duplex_audio.py b/tests/e2e/features/fullduplex/openai/test_duplex_audio.py new file mode 100644 index 000000000..535856976 --- /dev/null +++ b/tests/e2e/features/fullduplex/openai/test_duplex_audio.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import base64 + +import numpy as np +import pytest + +from vllm_omni.experimental.fullduplex.openai.audio import convert_input_audio_with_rate + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_pcm16_input_conversion_is_pure_and_reports_target_rate(): + pcm16 = np.array([-32768, 0, 32767], dtype=" None: + repo_root = Path(__file__).resolve().parents[3] + script = f""" +import sys + +import {module_name} + +loaded = sorted( + name + for name in sys.modules + if name == "vllm_omni.experimental.fullduplex.minicpmo45" + or name.startswith("vllm_omni.experimental.fullduplex.minicpmo45.") +) +if loaded: + raise SystemExit("generic OpenAI runtime loaded MiniCPM-o: " + ", ".join(loaded)) +""" + + result = subprocess.run( + [sys.executable, "-c", script], + cwd=repo_root, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_generic_handler_requires_explicit_serving_runtime_adapter() -> None: + repo_root = Path(__file__).resolve().parents[3] + script = """ +from types import SimpleNamespace + +from vllm_omni.experimental.fullduplex.openai.serving import OmniDuplexSessionHandler + +chat_service = SimpleNamespace(engine_client=SimpleNamespace()) +try: + OmniDuplexSessionHandler(chat_service=chat_service) +except ValueError as exc: + if "serving runtime adapter" not in str(exc).lower(): + raise +else: + raise SystemExit("generic handler silently selected a model serving adapter") +""" + + result = subprocess.run( + [sys.executable, "-c", script], + cwd=repo_root, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def _valid_runtime_adapter() -> SimpleNamespace: + data_plane = SimpleNamespace( + begin_request=lambda request_id: None, + is_terminal=lambda request_id: False, + mark_terminal=lambda request_id: None, + close_stream=lambda request_id: None, + close_session=lambda session_id, **kwargs: None, + project=lambda result, **kwargs: (), + ) + return SimpleNamespace( + adapter_id="test-adapter", + session_states={}, + data_plane=data_plane, + clean_response_done_prefix="clean", + interrupted_tts_prefix="interrupted", + private_runtime_config_keys=frozenset({"private_key"}), + create_session_state=lambda: object(), + session_state=lambda session_id: object(), + remove_session_state=lambda session_id: None, + is_enabled=lambda config: True, + capabilities=lambda **kwargs: object(), + validate_client_extra_body=lambda extra_body: None, + prepare_runtime_config=lambda config, **kwargs: {}, + runtime_config_for_update=lambda config, current: {}, + data_plane_context=lambda **kwargs: object(), + ) + + +@pytest.mark.parametrize( + ("attribute", "invalid_value"), + [ + ("session_states", None), + ("clean_response_done_prefix", None), + ("interrupted_tts_prefix", None), + ("private_runtime_config_keys", {"private_key"}), + ], +) +def test_runtime_adapter_validator_rejects_invalid_protocol_attributes( + attribute: str, + invalid_value: object, +) -> None: + adapter = _valid_runtime_adapter() + setattr(adapter, attribute, invalid_value) + + with pytest.raises(TypeError, match=attribute): + validate_serving_runtime_adapter(adapter) + + +def test_runtime_adapter_validator_rejects_incomplete_data_plane() -> None: + adapter = _valid_runtime_adapter() + adapter.data_plane.close_session = None + + with pytest.raises(TypeError, match="data_plane.*close_session"): + validate_serving_runtime_adapter(adapter) diff --git a/tests/e2e/features/fullduplex/openai/test_runtime_control_redaction.py b/tests/e2e/features/fullduplex/openai/test_runtime_control_redaction.py new file mode 100644 index 000000000..16bea686d --- /dev/null +++ b/tests/e2e/features/fullduplex/openai/test_runtime_control_redaction.py @@ -0,0 +1,115 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Regression tests for duplex ``runtime_control`` payload redaction. + +The redacted result is emitted on the WebSocket (for example +``session.created.runtime_control``), so it must always survive +``json.dumps``. Control results carry typed values straight from the engine -- +``fence`` and ``accepted_fence`` are ``DuplexFence`` instances added by +``DuplexControlClient.execute`` -- which previously reached +``WebSocket.send_json`` unconverted and killed the session at handshake. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from enum import Enum + +import pytest + +from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence +from vllm_omni.experimental.fullduplex.openai.runtime_bridge import NativeRuntimeBridgeMixin + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +_redact = NativeRuntimeBridgeMixin._redact_runtime_control_result + + +def _control_result() -> dict[str, object]: + """The shape ``DuplexControlClient.execute`` returns for an open.""" + return { + "ok": True, + "operation": "open", + "session_id": "sess-1", + "fence": DuplexFence("sess-1", epoch=0, turn_id=0, response_seq=0, incarnation=0), + "accepted_fence": DuplexFence("sess-1", epoch=1, turn_id=2, response_seq=3, incarnation=4), + "stage_results": [{"stage_id": -1, "replica_id": -1, "result": {"supported": True}}], + } + + +def test_redacted_control_result_is_json_serializable() -> None: + """The whole payload must serialize the way send_json does.""" + redacted = _redact(_control_result()) + # Must not raise. + json.dumps({"type": "session.created", "runtime_control": redacted}) + + +def test_fences_are_converted_to_plain_dicts() -> None: + redacted = _redact(_control_result()) + assert redacted["fence"] == { + "session_id": "sess-1", + "epoch": 0, + "turn_id": 0, + "response_seq": 0, + "incarnation": 0, + } + # Field values are preserved, not just the type. + assert redacted["accepted_fence"]["epoch"] == 1 + assert redacted["accepted_fence"]["turn_id"] == 2 + assert redacted["accepted_fence"]["response_seq"] == 3 + assert redacted["accepted_fence"]["incarnation"] == 4 + + +def test_nested_dataclasses_are_converted() -> None: + """Fences can appear inside stage results, not only at the top level.""" + redacted = _redact( + { + "stage_results": [ + {"stage_id": 0, "result": {"fence": DuplexFence("sess-2", epoch=7)}}, + ] + } + ) + json.dumps(redacted) + assert redacted["stage_results"][0]["result"]["fence"]["epoch"] == 7 + + +def test_enums_are_reduced_to_values() -> None: + class _Mode(str, Enum): + APPEND = "append_audio_chunk" + + redacted = _redact({"mode": _Mode.APPEND}) + json.dumps(redacted) + assert redacted["mode"] == "append_audio_chunk" + + +def test_redaction_still_drops_heavy_payload_keys() -> None: + """The pre-existing contract must be preserved.""" + redacted = _redact( + { + "ok": True, + "stage_handoff": object(), + "tts_handoff": object(), + "omni_payload": object(), + "tts_hidden_states": object(), + "tts_token_ids": object(), + "traceback": "long trace", + } + ) + assert redacted == {"ok": True} + + +def test_plain_values_pass_through_unchanged() -> None: + payload = {"ok": True, "count": 3, "name": "x", "missing": None, "items": [1, "two"]} + assert _redact(payload) == payload + + +def test_dataclass_types_are_not_treated_as_instances() -> None: + """A class object is not a dataclass instance and must not be asdict'd.""" + + @dataclass + class _Shape: + x: int = 1 + + assert _redact(_Shape) is _Shape diff --git a/tests/e2e/features/fullduplex/openai/test_websocket_actor.py b/tests/e2e/features/fullduplex/openai/test_websocket_actor.py new file mode 100644 index 000000000..37924af52 --- /dev/null +++ b/tests/e2e/features/fullduplex/openai/test_websocket_actor.py @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import asyncio + +import pytest + +from vllm_omni.experimental.fullduplex.openai.websocket import ( + DuplexWebSocketActor, + normalize_duplex_input_event, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class FakeWebSocket: + def __init__(self) -> None: + self.sent: list[dict[str, object]] = [] + + async def send_json(self, payload: dict[str, object]) -> None: + self.sent.append(dict(payload)) + + +@pytest.mark.parametrize( + ("event", "expected"), + [ + ({"type": "signal_turn", "event": "barge_in"}, {"type": "turn.signal", "event": "barge_in"}), + ({"type": "close_session"}, {"type": "session.close"}), + ({"type": "audio.playback_ack", "played_ms": 1}, {"type": "playback.ack", "played_ms": 1}), + ({"type": "input_text.append", "text": "a"}, {"type": "input.text.append", "text": "a"}), + ({"type": "push_text", "text": "b"}, {"type": "input.text.append", "text": "b"}), + ( + {"type": "input.audio.append", "audio": "wav"}, + {"type": "input_audio_buffer.append", "audio": "wav", "format": "wav"}, + ), + ( + {"type": "push_chunk", "audio": "wav", "format": "pcm_f32le"}, + {"type": "input_audio_buffer.append", "audio": "wav", "format": "pcm_f32le"}, + ), + ], +) +def test_input_aliases_normalize_once_at_mailbox_boundary(event, expected): + assert normalize_duplex_input_event(event) == expected + + +def test_actor_uses_one_fifo_mailbox_for_inbound_events(): + actor = DuplexWebSocketActor(FakeWebSocket()) + + assert isinstance(actor.mailbox, asyncio.Queue) + assert not isinstance(actor.mailbox, asyncio.PriorityQueue) + assert not hasattr(actor, "control_queue") + assert not hasattr(actor, "input_queue") + assert not hasattr(actor, "event_queue") + assert not hasattr(actor, "lifecycle_state") + assert not hasattr(actor, "last_response_id") + assert not hasattr(actor, "overlap_speech_ms") + assert not hasattr(actor, "transition") + assert not hasattr(actor, "output_generation_in_flight") + assert not hasattr(actor, "session") + assert not hasattr(actor, "runtime_opened") + assert not hasattr(actor, "runtime_closed") + assert not hasattr(actor, "drain_input_queue") + assert not hasattr(actor, "control_events_seen") + assert not hasattr(actor, "input_events_seen") + assert not hasattr(actor, "cancel_count") + + +@pytest.mark.asyncio +async def test_control_event_does_not_overtake_earlier_audio_input(): + actor = DuplexWebSocketActor(FakeWebSocket()) + await actor.enqueue_event({"type": "input_audio_buffer.append", "audio": "pcm"}) + await actor.enqueue_event({"type": "response.cancel"}) + + assert await actor.next_event() == {"type": "input_audio_buffer.append", "audio": "pcm"} + assert await actor.next_event() == {"type": "response.cancel"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("event_type", ["input.cancel", "session.close", "close_session"]) +async def test_terminal_control_preserves_wire_order(event_type: str): + actor = DuplexWebSocketActor(FakeWebSocket()) + await actor.enqueue_event({"type": "input_audio_buffer.append", "audio": "pcm"}) + await actor.enqueue_event({"type": event_type}) + + assert await actor.next_event() == {"type": "input_audio_buffer.append", "audio": "pcm"} + assert await actor.next_event() == {"type": event_type} + + +@pytest.mark.asyncio +async def test_mailbox_delivers_every_enqueued_event_exactly_once(): + actor = DuplexWebSocketActor(FakeWebSocket()) + events = [ + {"type": "input_audio_buffer.append", "audio": str(index)} + if index % 2 + else {"type": "response.created", "response_id": f"resp-{index}"} + for index in range(20) + ] + + await asyncio.gather(*(actor.enqueue_event(dict(event)) for event in events)) + received = [await actor.next_event() for _ in events] + + assert sorted(received, key=repr) == sorted(events, key=repr) + + +@pytest.mark.asyncio +async def test_writer_is_single_owner_of_websocket_send(): + websocket = FakeWebSocket() + actor = DuplexWebSocketActor(websocket) + writer = asyncio.create_task(actor.writer_loop()) + + await actor.send_json({"type": "one"}) + await actor.send_json({"type": "two"}) + await actor.close_writer() + await writer + + assert websocket.sent == [{"type": "one"}, {"type": "two"}] + + +@pytest.mark.asyncio +async def test_stale_fence_payload_is_dropped_before_websocket_send(): + websocket = FakeWebSocket() + actor = DuplexWebSocketActor(websocket, current_epoch=lambda: 2) + writer = asyncio.create_task(actor.writer_loop()) + + await actor.send_json({"type": "response.audio.delta", "epoch": 1}) + await actor.send_json({"type": "response.audio.delta", "epoch": 2}) + await actor.close_writer() + await writer + + assert websocket.sent == [{"type": "response.audio.delta", "epoch": 2}] + assert actor.stale_output_dropped == 1 + + +@pytest.mark.asyncio +async def test_writer_does_not_revoke_accepted_terminal_after_close_starts(): + websocket = FakeWebSocket() + actor = DuplexWebSocketActor(websocket, current_epoch=lambda: 1) + await actor.send_json({"type": "response.done", "epoch": 1, "response_id": "resp-1"}) + actor.closing = True + await actor.close_writer() + + await actor.writer_loop() + + assert websocket.sent == [{"type": "response.done", "epoch": 1, "response_id": "resp-1"}] + assert actor.stale_output_dropped == 0 diff --git a/tests/e2e/features/fullduplex/personaplex/test_import_boundary.py b/tests/e2e/features/fullduplex/personaplex/test_import_boundary.py new file mode 100644 index 000000000..463c0c98e --- /dev/null +++ b/tests/e2e/features/fullduplex/personaplex/test_import_boundary.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import subprocess +import sys + + +def test_batched_runtime_import_does_not_require_optional_opus_dependency() -> None: + script = """ +import builtins + +real_import = builtins.__import__ + +def guarded_import(name, *args, **kwargs): + if name == "sphn": + raise ModuleNotFoundError("blocked optional dependency: sphn") + return real_import(name, *args, **kwargs) + +builtins.__import__ = guarded_import +from vllm_omni.experimental.fullduplex.personaplex.serving.batched import BatchedSessionManager +assert BatchedSessionManager is not None +""" + result = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/e2e/features/fullduplex/personaplex/test_stage0_runtime.py b/tests/e2e/features/fullduplex/personaplex/test_stage0_runtime.py new file mode 100644 index 000000000..3bdc4456b --- /dev/null +++ b/tests/e2e/features/fullduplex/personaplex/test_stage0_runtime.py @@ -0,0 +1,303 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import base64 +from types import SimpleNamespace + +import numpy as np +import pytest +import torch + +from vllm_omni.experimental.fullduplex.personaplex.policy import ( + SILENCE_TOKENS, + SINE_TOKENS, +) +from vllm_omni.experimental.fullduplex.personaplex.stage0 import ( + PersonaPlexStage0DuplexRuntime, +) +from vllm_omni.model_executor.models.personaplex.personaplex_talker import ( + PersonaPlexTalkerForConditionalGeneration, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _FakeCodec: + def __init__(self) -> None: + self.encode_calls = 0 + self.reset_calls = 0 + + def streaming_init(self, batch_size: int) -> None: + assert batch_size == 1 + + def encode_frame(self, pcm: torch.Tensor) -> torch.Tensor: + assert pcm.shape == (1, 1920) + self.encode_calls += 1 + return torch.full((1, 8), self.encode_calls, dtype=torch.long) + + def reset_streaming(self) -> None: + self.reset_calls += 1 + + +class _FakeTalker: + def __init__(self) -> None: + self.device = torch.device("cpu") + self.dtype = torch.float32 + self.frame_calls: list[dict[str, torch.Tensor | None]] = [] + + def _build_prefill_embed( + self, + tokens, + offset, + span, + device, + silence=None, + user_sine=None, + ): + del offset, silence, user_sine + values = tokens[:span].to(device=device, dtype=torch.float32) + return values[:, None].expand(-1, 4).contiguous() + + def _build_frame_embed( + self, + text_token, + last_agent, + prev_agent, + device, + user_d0=None, + user_d1=None, + ): + self.frame_calls.append( + { + "text_token": text_token.clone(), + "last_agent": None if last_agent is None else last_agent.clone(), + "prev_agent": None if prev_agent is None else prev_agent.clone(), + "user_d0": None if user_d0 is None else user_d0.clone(), + "user_d1": None if user_d1 is None else user_d1.clone(), + } + ) + value = float(user_d0[0].item()) if user_d0 is not None else -1.0 + return torch.full((1, 4), value, device=device) + + +def _duplex_info(*, seq: int, session_id: str = "session", incarnation: int = 1): + pcm = np.zeros(1920, dtype=" PersonaPlexStage0DuplexRuntime: + voice_embeddings = torch.arange(8, dtype=torch.float32).reshape(2, 1, 1, 4) + codec_args = {"codec": codecs[0]} + if len(codecs) > 1: + available = iter(codecs) + codec_args = { + "codec_factory": lambda: next(available), + "max_sessions": len(codecs), + } + return PersonaPlexStage0DuplexRuntime( + _FakeTalker(), + model_path="/unused", + device="cpu", + **codec_args, + tokenizer=lambda _text: [7, 8, 9], + voice_loader=lambda _voice: { + "embeddings": voice_embeddings, + "cache": torch.zeros((1, 17, 4), dtype=torch.long), + }, + ) + + +def test_first_append_prepends_voice_and_persona_once() -> None: + codec = _FakeCodec() + runtime = _runtime(codec) + + first = runtime.prepare_append(_duplex_info(seq=1), prompt_len=18) + second = runtime.prepare_append(_duplex_info(seq=2), prompt_len=18) + + assert first.prefill_applied is True + assert first.prompt_offset == 0 + assert first.user_codes.shape == (1, 8) + assert first.inputs_embeds.shape == (18, 4) + assert second.prefill_applied is False + assert second.prompt_offset == 17 + assert second.user_codes.shape == (2, 8) + assert second.inputs_embeds.shape == (1, 4) + assert codec.encode_calls == 2 + + +@pytest.mark.parametrize( + ("append_count", "expected_tokens", "expected_provided"), + [ + ( + 1, + [*SILENCE_TOKENS, 1, *SINE_TOKENS[1:]], + [False, *([True] * 15)], + ), + ( + 2, + [*SILENCE_TOKENS, 2, *([1] * 7)], + [*([False] * 8), *([True] * 8)], + ), + ], +) +def test_prepare_append_preserves_native_depformer_teacher_forcing( + append_count: int, + expected_tokens: list[int], + expected_provided: list[bool], +) -> None: + runtime = _runtime(_FakeCodec()) + + result = None + for seq in range(1, append_count + 1): + result = runtime.prepare_append(_duplex_info(seq=seq), prompt_len=18) + + assert result is not None + assert result.info_update["pplex_depformer_audio_tokens"].tolist() == expected_tokens + assert result.info_update["pplex_depformer_audio_provided"].tolist() == expected_provided + + +def test_frame_embed_uses_previous_effective_agent_frame() -> None: + fake_talker = SimpleNamespace( + config=SimpleNamespace( + num_audio_codebooks=16, + audio_vocab_size=2048, + ), + input_embeddings=lambda stack: stack, + ) + last_agent = torch.arange(10, 18) + + embeds = PersonaPlexTalkerForConditionalGeneration._build_frame_embed( + fake_talker, + torch.tensor([3]), + last_agent, + torch.arange(8), + torch.device("cpu"), + ) + + assert embeds.shape == (1, 17) + assert torch.equal(embeds[0, 1:9], last_agent) + + +def test_repeated_append_identity_does_not_advance_codec() -> None: + codec = _FakeCodec() + runtime = _runtime(codec) + + info = _duplex_info(seq=1) + first = runtime.prepare_append(info, prompt_len=18, request_id="req") + retry = runtime.prepare_append(info, prompt_len=18, request_id="req") + + assert codec.encode_calls == 1 + assert torch.equal(first.inputs_embeds, retry.inputs_embeds) + assert torch.equal(first.user_codes, retry.user_codes) + + +def test_next_append_uses_prior_sample_and_causally_delayed_user_frame() -> None: + runtime = _runtime(_FakeCodec()) + + runtime.prepare_append(_duplex_info(seq=1), prompt_len=18, request_id="req") + first_agent = torch.arange(8, dtype=torch.long) + runtime.record_sample(request_id="req", text_token=torch.tensor(101), agent_codes=first_agent) + runtime.prepare_append(_duplex_info(seq=2), prompt_len=19, request_id="req") + second_agent = torch.arange(10, 18, dtype=torch.long) + runtime.record_sample(request_id="req", text_token=torch.tensor(102), agent_codes=second_agent) + runtime.prepare_append(_duplex_info(seq=3), prompt_len=20, request_id="req") + + silence = torch.tensor(SILENCE_TOKENS, dtype=torch.long) + sine = torch.tensor(SINE_TOKENS, dtype=torch.long) + first_call = runtime.stage_model.frame_calls[0] + assert all(torch.equal(first_call[key], silence) for key in ("last_agent", "prev_agent")) + assert all(torch.equal(first_call[key], sine) for key in ("user_d0", "user_d1")) + + second_call = runtime.stage_model.frame_calls[1] + assert second_call["text_token"].tolist() == [101] + expected_first_effective = torch.cat([first_agent[:1], silence[1:]]) + assert torch.equal(second_call["last_agent"], expected_first_effective) + assert torch.equal(second_call["prev_agent"], expected_first_effective) + assert torch.equal(second_call["user_d0"], torch.full((8,), 1, dtype=torch.long)) + assert torch.equal(second_call["user_d1"], sine) + + third_call = runtime.stage_model.frame_calls[2] + assert third_call["text_token"].tolist() == [102] + assert torch.equal(third_call["last_agent"], second_agent) + assert torch.equal(third_call["prev_agent"], second_agent) + assert torch.equal(third_call["user_d0"], torch.full((8,), 2, dtype=torch.long)) + assert torch.equal(third_call["user_d1"], torch.full((8,), 1, dtype=torch.long)) + + +def test_decoded_pcm_is_writable_for_torch_zero_copy() -> None: + pcm = PersonaPlexStage0DuplexRuntime._decode_pcm(_duplex_info(seq=1)["payload"]) + + assert pcm.flags.writeable + + +def _prepare_two_sessions( + runtime: PersonaPlexStage0DuplexRuntime, +) -> tuple[object, object, object, object]: + first_1 = runtime.prepare_append(_duplex_info(seq=1), prompt_len=18) + second_1 = runtime.prepare_append( + _duplex_info(seq=1, session_id="other", incarnation=2), + prompt_len=18, + ) + first_2 = runtime.prepare_append(_duplex_info(seq=2), prompt_len=19) + second_2 = runtime.prepare_append( + _duplex_info(seq=2, session_id="other", incarnation=2), + prompt_len=19, + ) + return first_1, second_1, first_2, second_2 + + +def test_live_sessions_keep_independent_streaming_encoders() -> None: + first_codec = _FakeCodec() + second_codec = _FakeCodec() + runtime = _runtime(first_codec, second_codec) + + first_1, second_1, first_2, second_2 = _prepare_two_sessions(runtime) + + assert first_1.user_codes[:, 0].tolist() == [1] + assert second_1.user_codes[:, 0].tolist() == [1] + assert first_2.user_codes[:, 0].tolist() == [1, 2] + assert second_2.user_codes[:, 0].tolist() == [1, 2] + + +def test_stage0_session_capacity_fails_before_codec_state_is_shared() -> None: + runtime = _runtime(_FakeCodec(), _FakeCodec()) + _prepare_two_sessions(runtime) + + with pytest.raises(RuntimeError, match="capacity 2"): + runtime.prepare_append( + _duplex_info(seq=1, session_id="third", incarnation=3), + prompt_len=18, + ) + + +def test_close_session_resets_and_reuses_released_codec() -> None: + first_codec = _FakeCodec() + second_codec = _FakeCodec() + runtime = _runtime(first_codec, second_codec) + _prepare_two_sessions(runtime) + + runtime.close_session("session", 1) + + assert first_codec.reset_calls == 1 + assert second_codec.reset_calls == 0 + replacement = runtime.prepare_append( + _duplex_info(seq=1, session_id="replacement", incarnation=4), + prompt_len=18, + ) + assert replacement.user_codes[:, 0].tolist() == [3] diff --git a/tests/e2e/features/fullduplex/personaplex/test_streaming_code2wav.py b/tests/e2e/features/fullduplex/personaplex/test_streaming_code2wav.py new file mode 100644 index 000000000..abe2146b6 --- /dev/null +++ b/tests/e2e/features/fullduplex/personaplex/test_streaming_code2wav.py @@ -0,0 +1,316 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from types import SimpleNamespace + +import pytest +import torch +from torch import nn + +from vllm_omni.model_executor.models.personaplex import ( + personaplex_code2wav, + personaplex_mimi, +) +from vllm_omni.model_executor.models.personaplex.personaplex_code2wav import ( + PersonaPlexCode2Wav, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _FakeStreamingMimi(nn.Module): + def __init__(self, stream_value: int = 0) -> None: + super().__init__() + self.stream_value = stream_value + self.decode_frame_calls = 0 + self.reset_calls = 0 + + def decode(self, codes: torch.Tensor, return_dict: bool = True): + assert return_dict + frames = int(codes.shape[-1]) + return SimpleNamespace(audio_values=torch.arange(frames * 4, dtype=torch.float32).reshape(1, 1, -1)) + + def streaming_init(self, batch_size: int) -> None: + assert batch_size == 1 + + def decode_frame(self, codes: torch.Tensor) -> torch.Tensor: + assert codes.shape == (1, 2) + self.decode_frame_calls += 1 + value = self.stream_value + self.decode_frame_calls + return torch.full((1, 4), float(value), dtype=torch.float32) + + def reset_streaming(self) -> None: + self.reset_calls += 1 + + +class _FakeChunkedMimi(_FakeStreamingMimi): + def __init__(self) -> None: + super().__init__() + self.decode_frames_calls = 0 + self.decode_frames_shapes: list[tuple[int, ...]] = [] + + def decode_frames(self, codes: torch.Tensor) -> torch.Tensor: + self.decode_frames_calls += 1 + self.decode_frames_shapes.append(tuple(codes.shape)) + return torch.arange(codes.shape[-1] * 4, dtype=torch.float32).reshape(1, -1) + + +class _FakeMimiModel(nn.Module): + def __init__(self, _config) -> None: + super().__init__() + self.encoder_transformer = nn.Linear(1, 1, bias=False) + self.decoder_transformer = nn.Linear(1, 1, bias=False) + self.quantizer = nn.Linear(1, 1, bias=False) + self.encoder = SimpleNamespace(layers=[]) + self.downsample = SimpleNamespace(conv=nn.Conv1d(1, 1, 1)) + self.upsample = SimpleNamespace(conv=nn.ConvTranspose1d(1, 1, 1)) + self.decoder = SimpleNamespace(layers=[]) + + def load_state_dict(self, _state_dict, strict: bool = True): + assert not strict + return SimpleNamespace( + missing_keys=[ + "encoder_transformer.weight", + "decoder_transformer.weight", + ], + unexpected_keys=[], + ) + + +class _FakeMimiTransformer(nn.Module): + def __init__(self) -> None: + super().__init__() + self.weight = nn.Parameter(torch.zeros(1)) + + def load_weights(self, _state_dict, _prefix: str) -> int: + return 80 + + +def test_moshi_checkpoint_mapping_drops_replaced_transformer_weights( + monkeypatch: pytest.MonkeyPatch, +) -> None: + mapper = getattr(personaplex_mimi, "_map_moshi_codec_weights", None) + assert callable(mapper), "PersonaPlex Mimi must load its bundled codec weights without Hugging Face" + + source = { + "encoder.model.3.conv.conv.weight": torch.ones(1), + "downsample.conv.conv.conv.weight": torch.ones(1), + "quantizer.rvq_first.vq.layers.0._codebook.embedding_sum": torch.ones(1), + "quantizer.rvq_rest.vq.layers.3._codebook._initialized": torch.ones(1), + "encoder_transformer.transformer.layers.0.norm1.weight": torch.ones(1), + } + + mapped = mapper(source) + + assert set(mapped) == { + "encoder.layers.3.conv.weight", + "downsample.conv.weight", + "quantizer.semantic_residual_vector_quantizer.layers.0.codebook.embed_sum", + "quantizer.acoustic_residual_vector_quantizer.layers.3.codebook.initialized", + } + assert mapped["encoder.layers.3.conv.weight"] is source["encoder.model.3.conv.conv.weight"] + monkeypatch.setattr("transformers.MimiConfig", lambda: object()) + monkeypatch.setattr("transformers.MimiModel", _FakeMimiModel) + monkeypatch.setattr("safetensors.torch.load_file", lambda *_args, **_kwargs: {}) + monkeypatch.setattr(personaplex_mimi, "_MimiStreamingTransformer", _FakeMimiTransformer) + + codec = personaplex_mimi.PersonaPlexMimiCodec( + checkpoint="/unused", + device="cpu", + ) + + assert not hasattr(codec.model, "encoder_transformer") + assert not hasattr(codec.model, "decoder_transformer") + assert not any( + name.startswith(("model.encoder_transformer.", "model.decoder_transformer.")) + for name, _ in codec.named_parameters() + ) + + +def test_mimi_full_stream_reset_reuses_all_state_storage_and_clears_offsets() -> None: + codec = personaplex_mimi.PersonaPlexMimiCodec.__new__(personaplex_mimi.PersonaPlexMimiCodec) + nn.Module.__init__(codec) + codec.device = torch.device("cpu") + codec.dtype = torch.float32 + codec._enc_stages = [("conv", personaplex_mimi._StreamConv1d(nn.Conv1d(1, 1, 3)))] + codec._dec_stages = [] + codec._downsample = personaplex_mimi._StreamConv1d(nn.Conv1d(1, 1, 3)) + codec._upsample = personaplex_mimi._StreamConvTr1d(nn.ConvTranspose1d(1, 1, 3)) + codec.encoder_transformer = personaplex_mimi._MimiStreamingTransformer( + num_layers=1, + dim=4, + num_heads=1, + context=3, + ) + codec.decoder_transformer = personaplex_mimi._MimiStreamingTransformer( + num_layers=1, + dim=4, + num_heads=1, + context=3, + ) + codec.streaming_init(batch_size=2) + + conv_states = list(codec._conv_states()) + conv_buffers = [ + state.prev if isinstance(state, personaplex_mimi._StreamConv1d) else state.partial for state in conv_states + ] + transformers = [codec.encoder_transformer, codec.decoder_transformer] + kv_states = [kv for transformer in transformers for kv in transformer._kv] + state_tensors = [ + *conv_buffers, + *(kv.cache for kv in kv_states), + *(kv.end_offset for kv in kv_states), + *(kv.start_offset for kv in kv_states), + *(transformer._offset for transformer in transformers), + ] + for tensor in state_tensors: + tensor.fill_(1) + for state in conv_states: + state._fresh.zero_() + storage = [tensor.data_ptr() for tensor in state_tensors] + + codec.reset_streaming() + + assert storage == [tensor.data_ptr() for tensor in state_tensors] + cleared = [ + *conv_buffers, + *(kv.end_offset for kv in kv_states), + *(kv.start_offset for kv in kv_states), + *(transformer._offset for transformer in transformers), + ] + assert all(not tensor.any() for tensor in cleared) + assert all(state._fresh.all() for state in conv_states) + + +def _model(*, max_sessions: int = 1) -> tuple[PersonaPlexCode2Wav, _FakeStreamingMimi]: + mimi_config = SimpleNamespace(num_codebooks=2, sample_rate=24000, samples_per_frame=4, mimi_name=None) + config = SimpleNamespace(mimi_config=mimi_config, mimi_name=None) + vllm_config = SimpleNamespace( + model_config=SimpleNamespace( + model="/unused", + hf_config=config, + duplex_max_sessions=max_sessions, + ), + device_config=SimpleNamespace(device="cpu"), + ) + model = PersonaPlexCode2Wav(vllm_config=vllm_config) + mimi = _FakeStreamingMimi() + model.mimi = mimi + model._mimi_device = torch.device("cpu") + return model, mimi + + +def _codes(frames: int, *, start: int = 0) -> torch.Tensor: + return torch.stack([torch.arange(start + offset, start + offset + frames) for offset in (0, 100)]).reshape(-1) + + +def _audio(output) -> torch.Tensor: + return output.multimodal_outputs["model_outputs"][0] + + +@pytest.mark.parametrize("second_codes", [_codes(3), _codes(1, start=100)]) +def test_resumable_codes_emit_only_new_pcm(second_codes: torch.Tensor) -> None: + model, mimi = _model() + + first = model(input_ids=_codes(2), request_ids=["req"]) + second = model(input_ids=second_codes, request_ids=["req"]) + + assert _audio(first).numel() == 8 + assert _audio(second).numel() == 4 + assert mimi.decode_frame_calls == 3 + + +@pytest.mark.parametrize( + ("frames", "expected_shapes"), + [ + (5, [(1, 2, 5)]), + (12, [(1, 2, 5), (1, 2, 5), (1, 2, 2)]), + ], +) +def test_streaming_decode_uses_bounded_multi_frame_calls( + frames: int, + expected_shapes: list[tuple[int, ...]], +) -> None: + model, _ = _model() + mimi = _FakeChunkedMimi() + model.mimi = mimi + + output = model(input_ids=_codes(frames), request_ids=["req"]) + + assert _audio(output).numel() == frames * 4 + assert mimi.decode_frames_shapes == expected_shapes + assert mimi.decode_frame_calls == 0 + + +def test_profile_inputs_skip_decode_while_malformed_online_input_warns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + model, mimi = _model() + runtime_info = [{"meta": {"personaplex_dummy_profile": True}}] + warnings: list[tuple] = [] + monkeypatch.setattr(personaplex_code2wav.logger, "warning", lambda *args: warnings.append(args)) + + output = model( + input_ids=torch.arange(3), + runtime_additional_information=runtime_info, + ) + + assert _audio(output).numel() == 0 + assert mimi.decode_frame_calls == 0 + assert warnings == [] + + output = model(input_ids=torch.arange(3)) + + assert _audio(output).numel() == 0 + assert mimi.decode_frame_calls == 0 + assert len(warnings) == 1 + assert "not divisible by" in warnings[0][0] + assert model.get_dummy_runtime_additional_information(2) == [ + {"meta": {"personaplex_dummy_profile": True}}, + {"meta": {"personaplex_dummy_profile": True}}, + ] + + +def test_request_id_falls_back_to_runtime_information() -> None: + model, _ = _model() + info = [{"request_id": "runtime-req"}] + + model(input_ids=_codes(2), runtime_additional_information=info) + second = model(input_ids=_codes(3), runtime_additional_information=info) + + assert _audio(second).numel() == 4 + + +def test_decoder_slot_lifecycle_isolated_capacity_and_reuse() -> None: + model, _ = _model(max_sessions=2) + first = _FakeStreamingMimi(stream_value=10) + second = _FakeStreamingMimi(stream_value=20) + model._set_mimi_codecs([first, second]) + assert model._max_codec_sessions == 2 + + initial = model( + input_ids=torch.cat([_codes(1), _codes(1)]), + request_ids=["first", "second"], + seq_token_counts=[2, 2], + ) + continued = model( + input_ids=torch.cat([_codes(1, start=10), _codes(1, start=20)]), + request_ids=["first", "second"], + seq_token_counts=[2, 2], + ) + + initial_audio = initial.multimodal_outputs["model_outputs"] + continued_audio = continued.multimodal_outputs["model_outputs"] + assert initial_audio[0].tolist() == [11.0] * 4 + assert initial_audio[1].tolist() == [21.0] * 4 + assert continued_audio[0].tolist() == [12.0] * 4 + assert continued_audio[1].tolist() == [22.0] * 4 + assert model._request_codec_slots == {"first": 0, "second": 1} + with pytest.raises(RuntimeError, match="decoder capacity 2 is exhausted"): + model._codec_for_request("overflow") + + model.on_requests_finished(["first"]) + assert first.reset_calls == 1 + assert second.reset_calls == 0 + replacement = model(input_ids=_codes(1), request_ids=["replacement"]) + assert _audio(replacement).tolist() == [13.0] * 4 diff --git a/tests/e2e/features/fullduplex/personaplex/test_unified_runtime.py b/tests/e2e/features/fullduplex/personaplex/test_unified_runtime.py new file mode 100644 index 000000000..f4a3c994e --- /dev/null +++ b/tests/e2e/features/fullduplex/personaplex/test_unified_runtime.py @@ -0,0 +1,364 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import base64 +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +from vllm.sampling_params import SamplingParams + +from tests.e2e.online_serving import personaplex_realtime_duplex as e2e_driver +from vllm_omni.config.stage_config import ( + load_deploy_config, + merge_pipeline_deploy, +) +from vllm_omni.experimental.fullduplex.engine.duplex_runtime import ( + DuplexInputMode, +) +from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence +from vllm_omni.experimental.fullduplex.personaplex.data_plane import ( + PersonaPlexDataPlaneContext, + PersonaPlexDataPlaneSession, +) +from vllm_omni.experimental.fullduplex.personaplex.input import ( + PersonaPlexPcmAppendBuffer, +) +from vllm_omni.experimental.fullduplex.personaplex.runtime_extension import ( + PersonaPlexDuplexRuntimeExtension, +) +from vllm_omni.experimental.fullduplex.personaplex.serving_adapter import ( + PersonaPlexServingRuntimeAdapter, +) +from vllm_omni.model_executor.models.personaplex.pipeline import ( + PERSONAPLEX_PIPELINE, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _pcm_payload(samples: np.ndarray, *, sample_rate_hz: int = 24000) -> dict[str, object]: + samples = np.ascontiguousarray(samples, dtype=" SimpleNamespace: + frames = np.full((frame_count, e2e_driver.FRAME_SAMPLES), 7, dtype=" None: + silent = np.zeros(e2e_driver.FRAME_SAMPLES, dtype=" None: + partial_frame = np.zeros(e2e_driver.FRAME_SAMPLES - 1, dtype=" None: + args = SimpleNamespace( + max_frame_deficit=4, + voiced_frame_rms_threshold=1e-3, + min_voiced_frames=5, + ) + + with pytest.raises(AssertionError, match="audible speech"): + e2e_driver._session_result( + _audio_client(frame_count), + input_frames=frame_count, + args=args, + minimum_chunks=1, + ) + + +def test_e2e_driver_uses_absolute_audible_floor() -> None: + args = SimpleNamespace( + max_frame_deficit=4, + voiced_frame_rms_threshold=1e-3, + min_voiced_frames=5, + ) + + e2e_driver._session_result( + _audio_client(200, voiced_frames=5), + input_frames=200, + args=args, + minimum_chunks=1, + ) + + +def test_e2e_driver_defaults_replacement_to_full_input() -> None: + args = e2e_driver.parse_args( + [ + "--model", + "/model", + "--input-wav", + "/input.wav", + ] + ) + + assert args.replacement_frames == 0 + assert args.min_voiced_frames == 5 + + +def test_e2e_driver_records_and_validates_input_sha256(tmp_path: Path) -> None: + input_wav = tmp_path / "input.wav" + input_wav.write_bytes(b"fixture-wav") + + identity = e2e_driver._input_identity( + input_wav, + expected_sha256="bade4b3c163edde390ff391207d34a887257d8a9cc3b621cc8c618b6e6761304", + ) + + assert identity == { + "path": str(input_wav.resolve()), + "sha256": "bade4b3c163edde390ff391207d34a887257d8a9cc3b621cc8c618b6e6761304", + } + + +def test_e2e_driver_rejects_unexpected_input_sha256(tmp_path: Path) -> None: + input_wav = tmp_path / "input.wav" + input_wav.write_bytes(b"fixture-wav") + + with pytest.raises(ValueError, match="input WAV SHA-256 mismatch"): + e2e_driver._input_identity(input_wav, expected_sha256="0" * 64) + + +@pytest.mark.parametrize( + ("attribute", "expected"), + [ + ( + "duplex_runtime_extension", + "vllm_omni.experimental.fullduplex.personaplex.runtime_extension.PersonaPlexDuplexRuntimeExtension", + ), + ( + "duplex_serving_adapter", + "vllm_omni.experimental.fullduplex.personaplex.serving_adapter.PersonaPlexServingRuntimeAdapter", + ), + ], +) +def test_personaplex_pipeline_registers_unified_duplex_components( + attribute: str, + expected: str, +) -> None: + assert PERSONAPLEX_PIPELINE.duplex_control_enabled is True + assert getattr(PERSONAPLEX_PIPELINE, attribute) == expected + + +@pytest.mark.parametrize( + ("engine_arg", "expected"), + [ + ("skip_tokenizer_init", True), + ("enable_prefix_caching", False), + ], +) +def test_personaplex_stage_engine_args( + engine_arg: str, + expected: bool, +) -> None: + deploy_path = Path(__file__).parents[5] / "vllm_omni" / "deploy" / "personaplex.yaml" + stages = merge_pipeline_deploy( + PERSONAPLEX_PIPELINE, + load_deploy_config(deploy_path), + ) + + assert [stage.yaml_engine_args.get(engine_arg) for stage in stages] == [ + expected, + expected, + ] + + +def test_personaplex_duplex_capacity_is_propagated_to_all_model_stages() -> None: + deploy_path = Path(__file__).parents[5] / "vllm_omni" / "deploy" / "personaplex.yaml" + deploy = load_deploy_config(deploy_path) + stages = merge_pipeline_deploy(PERSONAPLEX_PIPELINE, deploy) + + assert deploy.duplex_session.max_sessions == 2 + assert [stage.yaml_engine_args.get("duplex_max_sessions") for stage in stages] == [2, 2] + assert "personaplex_codec_max_sessions" not in deploy.connectors["connector_of_shared_memory"]["extra"] + + +def test_personaplex_capabilities_are_honest() -> None: + single = PersonaPlexServingRuntimeAdapter(lambda *_: None).capabilities(max_sessions=1) + multi = PersonaPlexServingRuntimeAdapter(lambda *_: None).capabilities(max_sessions=2) + + assert multi.implementation_level == "model_native_duplex" + assert multi.input_modes == ["append_audio_chunk"] + assert multi.chunk_period_ms == 80 + assert single.supports_multi_session is False + assert multi.supports_multi_session is True + assert multi.supports_multi_session_same_replica is True + assert multi.supports_barge_in is False + + +def test_pcm_buffer_emits_one_80ms_frame_transactionally() -> None: + buffer = PersonaPlexPcmAppendBuffer() + reservation = buffer.prepare_append( + _pcm_payload(np.arange(1920, dtype=np.float32)), + operation_id="op-1", + chunk_period_ms=80, + allow_emit=True, + ) + + assert reservation is not None + assert reservation.byte_count == 1920 * 4 + assert buffer.pending_byte_count == 0 + reservation.rollback() + assert buffer.pending_byte_count == 1920 * 4 + + +def test_pcm_buffer_rejects_changed_sample_rate() -> None: + buffer = PersonaPlexPcmAppendBuffer() + assert ( + buffer.prepare_append( + _pcm_payload(np.zeros(100, np.float32)), + operation_id="op-1", + chunk_period_ms=80, + allow_emit=False, + ) + is None + ) + + with pytest.raises(ValueError, match="sample_rate"): + buffer.prepare_append( + _pcm_payload(np.zeros(100, np.float32), sample_rate_hz=16000), + operation_id="op-2", + chunk_period_ms=80, + allow_emit=False, + ) + + +def test_pcm_buffer_rejects_non_finite_samples() -> None: + buffer = PersonaPlexPcmAppendBuffer() + + with pytest.raises(ValueError, match="finite"): + buffer.prepare_append( + _pcm_payload(np.array([0.0, np.nan], np.float32)), + operation_id="op-1", + chunk_period_ms=80, + allow_emit=False, + ) + + +def test_runtime_extension_builds_one_frame_scheduler_append() -> None: + extension = PersonaPlexDuplexRuntimeExtension() + defaults = ( + SamplingParams(temperature=0.8, top_k=10, max_tokens=10), + SamplingParams(max_tokens=1024), + ) + + configured = extension.configure_sampling_params(runtime_config={}, defaults=defaults) + assert configured[0].temperature == 0.0 + assert configured[0].top_k == 1 + assert configured[0].max_tokens == 1 + assert configured[1] is defaults[1] + + plan = extension.plan_append( + request_id="req", + fence=DuplexFence("session", incarnation=2, epoch=3), + session_config={"instructions": "Be concise."}, + runtime_config={"personaplex_prefill_slots": 4}, + seq=1, + turn_seq=1, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK, + payload=_pcm_payload(np.zeros(1920, np.float32)), + final=False, + sampling_params=configured[0], + ) + assert len(plan.prompt["prompt_token_ids"]) == 5 + duplex = plan.prompt["model_intermediate_buffer"]["duplex"] + assert duplex["session_id"] == "session" + assert duplex["incarnation"] == 2 + assert duplex["epoch"] == 3 + assert duplex["seq"] == 1 + assert duplex["data_plane"] is True + + +def test_projector_emits_only_cumulative_audio_and_text_suffixes() -> None: + encoded_sizes: list[int] = [] + + def encode_audio(audio, _sample_rate, _response_format, _speed): + size = int(np.asarray(audio, dtype=np.float32).size) + encoded_sizes.append(size) + return f"audio-{size}" + + projector = PersonaPlexDataPlaneSession(encode_audio) + projector.begin_request("req") + context = PersonaPlexDataPlaneContext(response_format="wav") + + first = SimpleNamespace( + request_id="req", + outputs=[SimpleNamespace(text="he", multimodal_output={})], + multimodal_output={"model_outputs": np.arange(4, dtype=np.float32), "sr": 24000}, + finished=False, + ) + second = SimpleNamespace( + request_id="req", + outputs=[SimpleNamespace(text="hello", multimodal_output={})], + multimodal_output={"model_outputs": np.arange(6, dtype=np.float32), "sr": 24000}, + finished=False, + ) + + projected = list( + projector.project( + {"data_plane_outputs": [first, second]}, + context=context, + ) + ) + + assert encoded_sizes == [4, 2] + assert [item["text"] for item in projected] == ["he", "llo"] + assert [item["audio_data"] for item in projected] == ["audio-4", "audio-2"] + assert all(item["sample_rate_hz"] == 24000 for item in projected) + assert all(item["end_of_turn"] is False for item in projected) diff --git a/tests/e2e/features/fullduplex/test_client.py b/tests/e2e/features/fullduplex/test_client.py new file mode 100644 index 000000000..218050eb1 --- /dev/null +++ b/tests/e2e/features/fullduplex/test_client.py @@ -0,0 +1,240 @@ +import base64 +import wave +from urllib.parse import parse_qs, urlsplit + +import pytest + +from vllm_omni.experimental.fullduplex.client import ( + RealtimeDuplexClient, + RealtimeEventCollector, + build_realtime_url, + read_pcm16_wav, + write_pcm16_wav, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_realtime_client_builds_explicit_native_duplex_url(): + url = build_realtime_url( + "ws://localhost:8099/v1/realtime?custom=1", + "openbmb/MiniCPM-o-4_5", + session_id="session-a", + ) + + query = parse_qs(urlsplit(url).query) + assert query == { + "custom": ["1"], + "duplex": ["1"], + "model": ["openbmb/MiniCPM-o-4_5"], + "minicpmo45_native_duplex": ["1"], + "session_id": ["session-a"], + } + + +def test_realtime_client_builds_resume_only_url_when_autostart_disabled(): + url = build_realtime_url( + "ws://localhost:8099/v1/realtime?duplex=1", + "openbmb/MiniCPM-o-4_5", + autostart=False, + ) + + query = parse_qs(urlsplit(url).query) + assert query["autostart"] == ["0"] + assert query["minicpmo45_native_duplex"] == ["1"] + + +@pytest.mark.asyncio +async def test_realtime_client_configure_omits_ref_audio_by_default(): + 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", timeout_s=1) + + session = client.sent[0]["session"] + assert "ref_audio" not in session + + +@pytest.mark.asyncio +async def test_realtime_client_configure_sends_explicit_ref_audio(): + 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", + ref_audio="data:audio/wav;base64,AAAA", + timeout_s=1, + ) + + session = client.sent[0]["session"] + assert session["ref_audio"] == "data:audio/wav;base64,AAAA" + + +def test_realtime_event_collector_partitions_audio_by_response(): + collector = RealtimeEventCollector() + collector.add({"type": "response.created", "response": {"id": "resp-a"}}) + collector.add( + { + "type": "response.audio.delta", + "response_id": "resp-a", + "delta": base64.b64encode(b"audio-a").decode("ascii"), + "sample_rate_hz": 16_000, + } + ) + + assert collector.response_ids == ["resp-a"] + assert collector.audio_bytes("resp-a") == b"audio-a" + assert collector.output_sample_rate_hz == 16_000 + assert collector.first_received_at("response.created") is not None + assert collector.last_received_at("response.audio.delta") is not None + + +def test_realtime_event_collector_reports_engine_token_and_audio_intervals(): + collector = RealtimeEventCollector() + collector.add( + {"type": "response.created", "response": {"id": "resp-a"}}, + received_at_s=10.0, + ) + stage_metrics = { + "0": { + "num_tokens_out": 4, + "vllm_ttft_ms": 120.0, + "vllm_tpot_ms": 15.0, + "vllm_itl_ms": 14.0, + "vllm_itls_ms": [10.0, 14.0, 18.0], + } + } + for received_at_s, cumulative_audio_ms in ((10.2, 80), (10.25, 160), (10.36, 240)): + collector.add( + { + "type": "response.audio.delta", + "response_id": "resp-a", + "delta": base64.b64encode(b"audio").decode("ascii"), + "sample_rate_hz": 16_000, + "metadata": { + "audio_duration_ms": cumulative_audio_ms, + "vllm_omni": {"stage_metrics": stage_metrics}, + }, + }, + received_at_s=received_at_s, + ) + + timing = collector.timing_summary( + after_s=10.0, + input_committed_at_s=9.9, + response_id="resp-a", + ) + + assert timing["stage0_tokens"] == { + "source": "engine_stage_metrics", + "output_token_count": 4, + "ttft_ms": 120.0, + "tpot_ms": 15.0, + "inter_token_interval_ms": { + "count": 3, + "mean": 14.0, + "p50": 14.0, + "p95": 18.0, + "max": 18.0, + }, + } + assert timing["audio_output"] == { + "source": "client_monotonic_receive", + "chunk_count": 3, + "response_created_to_first_audio_ms": 200.0, + "commit_to_first_audio_ms": 300.0, + "inter_chunk_interval_ms": { + "count": 2, + "mean": 80.0, + "p50": 50.0, + "p95": 110.0, + "max": 110.0, + }, + "chunk_duration_ms": { + "count": 3, + "mean": 80.0, + "p50": 80.0, + "p95": 80.0, + "max": 80.0, + }, + "max_chunk_gap_ms": 110.0, + } + + +def test_response_timing_ignores_unowned_session_level_metrics(): + collector = RealtimeEventCollector() + collector.add( + {"type": "response.created", "response": {"id": "resp-a"}}, + received_at_s=10.0, + ) + collector.add( + { + "type": "response.audio.delta", + "response_id": "resp-a", + "delta": base64.b64encode(b"audio").decode("ascii"), + "metadata": { + "vllm_omni": { + "stage_metrics": { + "0": { + "num_tokens_out": 20, + "vllm_ttft_ms": 157.0, + "vllm_tpot_ms": 16.0, + "vllm_itls_ms": [15.0, 17.0], + } + } + } + }, + }, + received_at_s=10.2, + ) + collector.add( + { + "type": "response.listen", + "metadata": { + "vllm_omni": { + "stage_metrics": { + "0": { + "num_tokens_out": 2, + "vllm_ttft_ms": 106.0, + "vllm_tpot_ms": 0.0, + "vllm_itls_ms": [], + } + } + } + }, + }, + received_at_s=10.3, + ) + + timing = collector.timing_summary(after_s=10.0, response_id="resp-a") + + assert timing["stage0_tokens"]["output_token_count"] == 20 + assert timing["stage0_tokens"]["ttft_ms"] == 157.0 + + +def test_realtime_client_pcm16_wav_round_trip(tmp_path): + path = tmp_path / "audio.wav" + pcm16 = b"\x01\x00\x02\x00" + + write_pcm16_wav(path, pcm16, sample_rate_hz=16_000) + + with wave.open(str(path), "rb") as wav_file: + assert wav_file.getnchannels() == 1 + assert wav_file.getframerate() == 16_000 + assert read_pcm16_wav(path) == pcm16 diff --git a/tests/e2e/features/fullduplex/test_core_contracts.py b/tests/e2e/features/fullduplex/test_core_contracts.py new file mode 100644 index 000000000..2a514b9f2 --- /dev/null +++ b/tests/e2e/features/fullduplex/test_core_contracts.py @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Contract tests for the fullduplex core (PR #3907 alignment). + +Covers the DuplexFence identity value, the session identity rules (epoch only +advances on barge-in, response_index once per begin_response), stale-output +dropping in the turn lifecycle. The lockstep (single-eternal-response) +lifecycle is model-owned (see +tests/e2e/features/fullduplex/test_personaplex_adapter.py). +""" + +import asyncio +import dataclasses + +import pytest + +from vllm_omni.experimental.fullduplex.core import protocol as ev +from vllm_omni.experimental.fullduplex.core.adapter import ( + DuplexAdapter, + DuplexCapability, + OutputChunk, +) +from vllm_omni.experimental.fullduplex.core.runtime import DuplexRuntime +from vllm_omni.experimental.fullduplex.core.session import ( + DuplexSession, + DuplexSessionConfig, + DuplexState, +) +from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_fence_is_frozen_value_identity(): + f = DuplexFence(session_id="s1") + assert (f.epoch, f.turn_id, f.response_seq) == (0, 0, 0) + with pytest.raises(dataclasses.FrozenInstanceError): + f.epoch = 1 # type: ignore[misc] + assert DuplexFence("s1", 1, 2, 3) == DuplexFence("s1", 1, 2, 3) + assert DuplexFence("s1", 1, 2, 3) != DuplexFence("s1", 2, 2, 3) + + +def test_session_identity_rules(): + s = DuplexSession(session_id="test", config=DuplexSessionConfig()) + r1, e1 = s.begin_response() + r2, e2 = s.begin_response() + assert r2 == r1 + 1, "response index advances exactly once per begin" + assert e1 == e2 == 0, "epoch does not move on response begin" + s.end_response() + assert s.epoch == 0, "epoch does not move on response end" + new_epoch = s.barge_in() + assert new_epoch == 1, "epoch advances only on barge-in" + assert s.is_stale(e1) and not s.is_stale(new_epoch) + + +class _TurnAdapter(DuplexAdapter): + """Emits chunks until cancelled; counts respond() lifecycles.""" + + def __init__(self): + self.responses = 0 + self.barge_ins = 0 + + def capabilities(self) -> DuplexCapability: + return DuplexCapability( + input_modalities=frozenset({"audio"}), + output_modalities=frozenset({"audio"}), + proactive=True, + ) + + async def on_input(self, session, modality, data) -> None: + pass + + def should_respond(self, session) -> bool: + return True + + async def on_barge_in(self, session) -> None: + self.barge_ins += 1 + + async def respond(self, session): + self.responses += 1 + for i in range(100): + await asyncio.sleep(0.001) + yield OutputChunk("audio", i) + + +async def _drive(runtime, events): + async def gen(): + for e in events: + await asyncio.sleep(0.005) + yield e + + out = [] + + async def emit(e): + out.append(e) + + await runtime.run(gen(), emit) + return out + + +def test_turn_mode_supersede_and_stale_drop(): + adapter = _TurnAdapter() + session = DuplexSession(session_id="test", config=DuplexSessionConfig(proactive=True)) + runtime = DuplexRuntime(session, adapter) + out = asyncio.run( + _drive( + runtime, + [ + {"type": ev.RESPONSE_CREATE}, + {"type": ev.RESPONSE_CREATE}, # supersedes the first + {"type": ev.CLOSE}, + ], + ) + ) + assert adapter.responses == 2 + assert adapter.barge_ins == 1, "superseding an in-flight response barges in" + created = [e for e in out if e["type"] == ev.RESPONSE_CREATED] + done = [e for e in out if e["type"] == ev.RESPONSE_DONE] + assert len(created) == 2 + assert len(done) == 1, "stale first response must not emit done" + assert done[0]["response_index"] == created[1]["response_index"] + assert session.state is DuplexState.CLOSED diff --git a/tests/fullduplex/test_joyvl_brain.py b/tests/e2e/features/fullduplex/test_joyvl_brain.py similarity index 100% rename from tests/fullduplex/test_joyvl_brain.py rename to tests/e2e/features/fullduplex/test_joyvl_brain.py diff --git a/tests/fullduplex/test_joyvl_policy.py b/tests/e2e/features/fullduplex/test_joyvl_policy.py similarity index 100% rename from tests/fullduplex/test_joyvl_policy.py rename to tests/e2e/features/fullduplex/test_joyvl_policy.py diff --git a/tests/e2e/features/fullduplex/test_personaplex_adapter.py b/tests/e2e/features/fullduplex/test_personaplex_adapter.py new file mode 100644 index 000000000..458333627 --- /dev/null +++ b/tests/e2e/features/fullduplex/test_personaplex_adapter.py @@ -0,0 +1,262 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""PersonaPlex adapter driven through the model-owned lockstep runtime. + +PersonaPlexDuplexRuntime starts ONE response for the whole session, streams one +agent frame per user frame, and drains on close (core stays verbatim upstream; +the lockstep lifecycle is model policy) — all with a GPU-free stub stepper. +""" + +import asyncio + +import numpy as np +import pytest + +from vllm_omni.experimental.fullduplex.core import protocol as ev +from vllm_omni.experimental.fullduplex.core.session import ( + DuplexSession, + DuplexSessionConfig, + DuplexState, +) +from vllm_omni.experimental.fullduplex.personaplex.adapter import ( + PersonaPlexDuplexAdapter, + PersonaPlexDuplexRuntime, +) +from vllm_omni.experimental.fullduplex.personaplex.config import FRAME_SIZE +from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class StubStepper: + """Deterministic FrameStepper: frame i -> audio filled with i, text 't{i}' on odds.""" + + sample_rate = 24000 + frame_size = FRAME_SIZE + + def __init__(self) -> None: + self.opened: tuple[str | None, str | None] | None = None + self.steps = 0 + + def open_session(self, voice_prompt=None, persona=None) -> None: + self.opened = (voice_prompt, persona) + self.steps = 0 + + def step(self, user_pcm): + assert user_pcm.shape[0] == self.frame_size + self.steps += 1 + i = self.steps + return FrameOutput( + audio=np.full(self.frame_size, float(i), dtype=np.float32), + text=(f"t{i}" if i % 2 else None), + ) + + +class RecordingAdapter(PersonaPlexDuplexAdapter): + def __init__(self, stepper: StubStepper) -> None: + super().__init__(stepper) + self.closed = False + + async def on_close(self, session: DuplexSession) -> None: + self.closed = True + await super().on_close(session) + + +def _collector(): + out: list[dict] = [] + + async def emit(event: dict) -> None: + out.append(event) + + return out, emit + + +async def _feed(events): + for e in events: + yield e + + +async def _raising_feed(frame): + yield {"type": ev.INPUT_APPEND, "modality": "audio", "data": frame} + await asyncio.sleep(0) + raise RuntimeError("input failed") + + +def _respond_tasks() -> list[asyncio.Task]: + current = asyncio.current_task() + return [ + task + for task in asyncio.all_tasks() + if task is not current and "_respond_once" in getattr(task.get_coro(), "__qualname__", "") + ] + + +def _session(): + cfg = DuplexSessionConfig( + input_modalities=("audio",), + output_modalities=("audio", "text"), + ) + return DuplexSession("s", cfg) + + +@pytest.mark.asyncio +async def test_lockstep_one_agent_frame_per_user_frame(): + stub = StubStepper() + adapter = PersonaPlexDuplexAdapter(stub, voice_prompt="NATF2.pt", persona="be terse") + rt = PersonaPlexDuplexRuntime(_session(), adapter) + out, emit = _collector() + + frame = np.zeros(FRAME_SIZE, dtype=np.float32) + events = [{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame} for _ in range(3)] + events.append({"type": ev.CLOSE}) + await rt.run(_feed(events), emit) + + types = [e["type"] for e in out] + # exactly one response for the whole session, drained cleanly on close + assert types.count(ev.RESPONSE_CREATED) == 1 + assert types.count(ev.RESPONSE_DONE) == 1 + audio = [e for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "audio"] + text = [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "text"] + assert len(audio) == 3 # one agent frame per user frame + assert [int(e["data"][0]) for e in audio] == [1, 2, 3] # lockstep order + assert text == ["t1", "t3"] + assert stub.steps == 3 + assert stub.opened == ("NATF2.pt", "be terse") + + +@pytest.mark.asyncio +async def test_unaligned_chunks_are_reframed_to_80ms(): + stub = StubStepper() + adapter = PersonaPlexDuplexAdapter(stub) + rt = PersonaPlexDuplexRuntime(_session(), adapter) + out, emit = _collector() + + # 2.5 frames in one chunk + 0.5 frame in another => 3 whole frames + events = [ + {"type": ev.INPUT_APPEND, "modality": "audio", "data": np.zeros(FRAME_SIZE * 5 // 2, dtype=np.float32)}, + {"type": ev.INPUT_APPEND, "modality": "audio", "data": np.zeros(FRAME_SIZE // 2, dtype=np.float32)}, + {"type": ev.CLOSE}, + ] + await rt.run(_feed(events), emit) + audio = [e for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "audio"] + assert len(audio) == 3 + assert stub.steps == 3 + + +@pytest.mark.asyncio +async def test_close_without_input_is_clean(): + adapter = PersonaPlexDuplexAdapter(StubStepper()) + rt = PersonaPlexDuplexRuntime(_session(), adapter) + out, emit = _collector() + await rt.run(_feed([{"type": ev.CLOSE}]), emit) + # no input -> response never started -> no created/done, no error + assert not any(e["type"] == ev.ERROR for e in out) + assert not any(e["type"] == ev.RESPONSE_CREATED for e in out) + + +@pytest.mark.asyncio +async def test_iterator_eof_without_close_drains_and_stops(): + # Transport drop: the input iterator ends WITHOUT an explicit CLOSE event. + # The runtime must still signal on_close so the eternal response drains + # instead of blocking on its inbox forever. + stub = StubStepper() + adapter = PersonaPlexDuplexAdapter(stub) + rt = PersonaPlexDuplexRuntime(_session(), adapter) + out, emit = _collector() + frame = np.zeros(FRAME_SIZE, dtype=np.float32) + events = [{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame}] + await asyncio.wait_for(rt.run(_feed(events), emit), timeout=5.0) + types = [e["type"] for e in out] + assert types.count(ev.RESPONSE_DONE) == 1 + assert stub.steps == 1 + + +@pytest.mark.asyncio +async def test_input_exception_closes_response_and_runtime() -> None: + stub = StubStepper() + adapter = RecordingAdapter(stub) + rt = PersonaPlexDuplexRuntime(_session(), adapter) + out, emit = _collector() + frame = np.zeros(FRAME_SIZE, dtype=np.float32) + + with pytest.raises(RuntimeError, match="input failed"): + await rt.run(_raising_feed(frame), emit) + + leaked = _respond_tasks() + for task in leaked: + task.cancel() + if leaked: + await asyncio.gather(*leaked, return_exceptions=True) + + assert adapter.closed is True + assert rt.session.state is DuplexState.CLOSED + assert not leaked + + +@pytest.mark.asyncio +async def test_default_session_config_gets_lockstep_lifecycle(): + # The lifecycle comes from the model-owned runtime class, not a session + # flag: a plain default-constructed session config runs lockstep. + stub = StubStepper() + adapter = PersonaPlexDuplexAdapter(stub) + session = DuplexSession( + "s", + DuplexSessionConfig(input_modalities=("audio",), output_modalities=("audio", "text")), + ) + rt = PersonaPlexDuplexRuntime(session, adapter) + out, emit = _collector() + frame = np.zeros(FRAME_SIZE, dtype=np.float32) + events = [{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame}, {"type": ev.CLOSE}] + await rt.run(_feed(events), emit) + types = [e["type"] for e in out] + assert types.count(ev.RESPONSE_CREATED) == 1 + assert types.count(ev.RESPONSE_DONE) == 1 + assert stub.steps == 1 + + +@pytest.mark.asyncio +async def test_partial_tail_is_padded_and_stepped_on_close(): + # A trailing sub-frame chunk is zero-padded and stepped at close (same + # contract as PersonaPlexSession.flush) rather than silently dropped. + stub = StubStepper() + adapter = PersonaPlexDuplexAdapter(stub) + rt = PersonaPlexDuplexRuntime(_session(), adapter) + out, emit = _collector() + events = [ + {"type": ev.INPUT_APPEND, "modality": "audio", "data": np.ones(FRAME_SIZE // 2, dtype=np.float32)}, + {"type": ev.CLOSE}, + ] + await rt.run(_feed(events), emit) + audio = [e for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "audio"] + assert len(audio) == 1 + assert stub.steps == 1 + + +@pytest.mark.asyncio +async def test_single_eternal_response_across_barge_in(): + # Moved from the core contract tests when the lockstep lifecycle became + # model-owned: ONE response for the whole session, RESPONSE_CANCEL bumps the + # epoch fence (playback cursor) but the stream keeps flowing, and close + # drains via on_close. + stub = StubStepper() + adapter = PersonaPlexDuplexAdapter(stub) + session = _session() + rt = PersonaPlexDuplexRuntime(session, adapter) + out, emit = _collector() + + frame = np.zeros(FRAME_SIZE, dtype=np.float32) + events = [ + {"type": ev.INPUT_APPEND, "modality": "audio", "data": frame}, + {"type": ev.RESPONSE_CANCEL}, # barge-in: epoch bumps, stream continues + {"type": ev.INPUT_APPEND, "modality": "audio", "data": frame}, + {"type": ev.CLOSE}, + ] + await rt.run(_feed(events), emit) + + types = [e["type"] for e in out] + assert types.count(ev.RESPONSE_CREATED) == 1, "exactly one eternal response" + assert types.count(ev.RESPONSE_DONE) == 1 + audio = [e for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "audio"] + assert len(audio) == 2, "stream keeps flowing across barge-in" + assert session.epoch == 1, "cancel still advanced the epoch fence" + assert stub.steps == 2 diff --git a/tests/e2e/features/fullduplex/test_personaplex_batched.py b/tests/e2e/features/fullduplex/test_personaplex_batched.py new file mode 100644 index 000000000..f2ee73729 --- /dev/null +++ b/tests/e2e/features/fullduplex/test_personaplex_batched.py @@ -0,0 +1,178 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""GPU-free tests for the batched PersonaPlex slot manager (stub engine).""" + +from types import SimpleNamespace + +import numpy as np +import pytest + +from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput, PrefillStep +from vllm_omni.experimental.fullduplex.personaplex.serving.batched import ( + BatchedSessionManager, + SlotPhase, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +FRAME = 4 + + +class StubEngine: + """Records the slot-API call sequence; echoes each row's pcm as its output.""" + + frame_size = FRAME + + def __init__(self, batch_size: int = 4, prefill_len: int = 2) -> None: + self.config = SimpleNamespace(batch_size=batch_size) + self.prefill_len = prefill_len + self.recycled: list[int] = [] + self.voice_ended: list[int] = [] + self.prefill_done: list[int] = [] + self.steps: list[tuple[np.ndarray, dict | None]] = [] + + def prefill_steps(self, persona=None): + steps = [PrefillStep("sacrifice"), PrefillStep("voice", embedding=None), PrefillStep("voice_end")] + steps += [PrefillStep("tokens", moshi_tokens=None, text_token=3, user_sine=True)] * self.prefill_len + return steps + + def recycle_slot(self, b): + self.recycled.append(b) + + def voice_end(self, b): + self.voice_ended.append(b) + + def end_prefill(self, b): + self.prefill_done.append(b) + + def step_batch(self, pcm, prefill=None): + self.steps.append((pcm.copy(), dict(prefill) if prefill else None)) + return [FrameOutput(audio=pcm[b].copy(), text=None) for b in range(self.config.batch_size)] + + +def test_all_idle_does_not_step(): + eng = StubEngine() + mgr = BatchedSessionManager(eng) + assert mgr.tick() is False + assert eng.steps == [] + + +def test_pristine_slot_is_live_immediately(): + eng = StubEngine() + mgr = BatchedSessionManager(eng) + slot, ready_now = mgr.acquire() + assert ready_now is True + assert slot.phase is SlotPhase.LIVE + assert eng.recycled == [] + + +def test_custom_persona_prefills_and_recycles_on_tick_thread(): + eng = StubEngine(prefill_len=2) + mgr = BatchedSessionManager(eng) + slot, ready_now = mgr.acquire(persona="custom") + assert ready_now is False + assert slot.phase is SlotPhase.PREFILL + assert eng.recycled == [] # deferred: reset must run between engine steps + + ready = [] + slot.on_ready = lambda: ready.append(True) + + mgr.tick() # recycle + sacrifice + assert eng.recycled == [slot.index] + assert eng.steps[-1][1][slot.index].kind == "sacrifice" + + mgr.tick() # voice frame + assert eng.steps[-1][1][slot.index].kind == "voice" + assert eng.voice_ended == [] + + mgr.tick() # voice_end executed BETWEEN ticks, then first tokens frame + assert eng.voice_ended == [slot.index] + assert eng.steps[-1][1][slot.index].kind == "tokens" + + mgr.tick() # second tokens frame + assert not ready + mgr.tick() # prefill exhausted -> live + assert eng.prefill_done == [slot.index] + assert slot.phase is SlotPhase.LIVE + assert ready == [True] + + +def test_pristine_slot_after_ticking_requires_recycle(): + # step_batch advances EVERY row each tick, so a pristine idle row that has + # lived through ticks has drifted from its boot prefill and must be + # recycled before a client may join it. + eng = StubEngine() + mgr = BatchedSessionManager(eng) + first, ready_now = mgr.acquire() + assert ready_now is True # claimed before any tick: truly pristine + mgr.tick() + second, ready_now2 = mgr.acquire() + assert second.index != first.index + assert ready_now2 is False + assert second.phase is SlotPhase.PREFILL + + +def test_used_slot_requires_recycle_even_with_default_persona(): + eng = StubEngine() + mgr = BatchedSessionManager(eng) + slot, ready_now = mgr.acquire() + assert ready_now + mgr.release(slot) + slot2, ready_now2 = mgr.acquire() + assert slot2.index == slot.index + assert ready_now2 is False + assert slot2.phase is SlotPhase.PREFILL + + +def test_acquire_returns_none_when_full(): + eng = StubEngine(batch_size=2) + mgr = BatchedSessionManager(eng) + assert mgr.acquire() is not None + assert mgr.acquire() is not None + assert mgr.acquire() is None + + +def test_feed_frames_epoch_guard_and_backlog_cap(): + eng = StubEngine() + mgr = BatchedSessionManager(eng, max_buffered_frames=3) + slot, _ = mgr.acquire() + mgr.feed(slot, np.arange(FRAME * 2, dtype=np.float32), slot.epoch) + assert len(slot.inq) == 2 + mgr.feed(slot, np.zeros(3, dtype=np.float32), slot.epoch) # partial tail buffered + assert len(slot.inq) == 2 and slot.pending.shape[0] == 3 + mgr.feed(slot, np.zeros(FRAME * 5, dtype=np.float32), slot.epoch) + assert len(slot.inq) == 3 # oldest dropped beyond the cap + mgr.feed(slot, np.zeros(FRAME, dtype=np.float32), slot.epoch - 1) # stale epoch ignored + assert len(slot.inq) == 3 + + +def test_live_slot_consumes_input_and_emits_output(): + eng = StubEngine() + mgr = BatchedSessionManager(eng) + slot, _ = mgr.acquire() + got = [] + slot.on_output = got.append + frame = np.full(FRAME, 0.5, dtype=np.float32) + mgr.feed(slot, frame, slot.epoch) + mgr.tick() + assert len(got) == 1 + np.testing.assert_array_equal(got[0].audio, frame) + # queue drained: next tick feeds zeros + mgr.tick() + np.testing.assert_array_equal(got[1].audio, np.zeros(FRAME, dtype=np.float32)) + + +def test_release_mid_flight_stops_emission(): + eng = StubEngine() + mgr = BatchedSessionManager(eng) + slot, _ = mgr.acquire() + got = [] + slot.on_output = got.append + mgr.tick() + assert len(got) == 1 + mgr.release(slot) + # another slot keeps the batch non-idle + other, _ = mgr.acquire() # same slot index, now PREFILL (used) + assert other.phase is SlotPhase.PREFILL + mgr.tick() + assert len(got) == 1 # no further output for the released epoch diff --git a/tests/e2e/features/fullduplex/test_personaplex_elastic.py b/tests/e2e/features/fullduplex/test_personaplex_elastic.py new file mode 100644 index 000000000..453d90153 --- /dev/null +++ b/tests/e2e/features/fullduplex/test_personaplex_elastic.py @@ -0,0 +1,240 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""GPU e2e for PersonaPlex elastic batching (bit-exact isolation + recycle). + +Greedy decoding makes every cross-slot leak observable as a bit difference: + +1. Isolation (bit-exact): recycling one slot mid-flight leaves every other + slot's output bit-identical to a run that never recycled. +2. Replay exactness: a recycled slot's opening live ticks match a pristine boot + at numerical-noise level (a broken KV mask / staging roll garbles frame 0). +3. Coherence (behavioral): a recycled slot fed a real spoken question produces + a real reply in its inner-monologue text stream. + +Run (moshi-free native stack; ~30 GB GPU + HF access to the gated repo; set +PPLEX_QUESTION_WAV to a spoken-question wav to enable the coherence test): + pytest tests/e2e/features/fullduplex/test_personaplex_elastic.py -v -s + +Bench (B sweep, ms/tick vs the 80 ms budget): + python tests/e2e/features/fullduplex/test_personaplex_elastic.py --bench 2 4 8 16 +""" + +from __future__ import annotations + +import numpy as np +import pytest + +torch = pytest.importorskip("torch") + +# slow + H100 + omni routes this module into the weekly Buildkite E2E group +# ("Omni · H100" in .buildkite/cuda/test-weekly.yml: -m "slow and H100 and +# omni" --run-level full_model); the ready/merge CPU sweeps exclude it by mark. +pytestmark = [pytest.mark.full_model, pytest.mark.omni, pytest.mark.slow, pytest.mark.H100] + +if not torch.cuda.is_available(): # noqa: E402 + pytest.skip("needs CUDA", allow_module_level=True) + +from vllm_omni.experimental.fullduplex.personaplex.config import PersonaPlexConfig # noqa: E402 +from vllm_omni.experimental.fullduplex.personaplex.runtime import ( # noqa: E402 + PersonaPlexEngine, +) + +B = 4 +LIVE_TICKS = 40 # live frames compared per claim +RECYCLE_AT = 10 + + +def _make_engine() -> PersonaPlexEngine: + cfg = PersonaPlexConfig(batch_size=B) # native stepper is greedy-only + try: + return PersonaPlexEngine(cfg).load() + except Exception as exc: # gated-repo access is environment-dependent + name = type(exc).__name__ + if "Gated" in name or "401" in str(exc) or "403" in str(exc): + pytest.skip(f"no access to the gated nvidia/personaplex-7b-v1 repo: {name}") + raise + + +@pytest.fixture(scope="module") +def engine() -> PersonaPlexEngine: + return _make_engine() + + +def _slot_stream(slot: int, tick: int, frame: int) -> np.ndarray: + """Deterministic per-slot input: distinct low-amplitude tones.""" + sr = 24000 + freq = 180.0 + 90.0 * slot + t0 = tick * frame + t = (np.arange(frame) + t0) / sr + return (0.12 * np.sin(2 * np.pi * freq * t)).astype(np.float32) + + +def _run(engine, total_ticks: int, recycle: bool): + """Run the lockstep loop; optionally recycle slot 1 at RECYCLE_AT. + + Returns per-slot lists of (audio | None) per live tick index, where the + recycled slot's list restarts its input stream at its post-prefill tick. + """ + frame = engine.frame_size + outs: list[list[np.ndarray | None]] = [[] for _ in range(B)] + stream_tick = [0] * B # per-slot conversation clock driving its input + prefill = None + tick = 0 + while tick < total_ticks: + if recycle and tick == RECYCLE_AT and prefill is None: + engine.recycle_slot(1) + prefill = list(engine.prefill_steps(None)) + pcm = np.zeros((B, frame), dtype=np.float32) + step_for_1 = None + if prefill: + while prefill and prefill[0].kind == "voice_end": + engine.voice_end(1) + prefill.pop(0) + if prefill: + step_for_1 = prefill.pop(0) + else: + engine.end_prefill(1) + prefill = None + stream_tick[1] = 0 # restart slot 1's input stream from its own t=0 + for b in range(B): + if b == 1 and step_for_1 is not None: + continue # engine overrides the user channel for prefill rows + pcm[b] = _slot_stream(b, stream_tick[b], frame) + stream_tick[b] += 1 + frame_outs = engine.step_batch(pcm, {1: step_for_1} if step_for_1 is not None else None) + for b in range(B): + if b == 1 and step_for_1 is not None: + continue # prefill tick: nothing conversational to record + frame_out = frame_outs[b] + outs[b].append(None if frame_out is None else frame_out.audio) + tick += 1 + return outs + + +def test_recycle_isolation_and_fresh_equivalence(engine): + engine.open_batch() + prefill_len = sum(1 for s in engine.prefill_steps(None) if s.kind != "voice_end") + total = RECYCLE_AT + prefill_len + LIVE_TICKS + + base = _run(engine, total, recycle=False) + + engine.open_batch() # deterministic re-boot (greedy, fixed voice/persona) + rec = _run(engine, total, recycle=True) + + # Claim 1 -- isolation: untouched slots bit-identical to the no-recycle run. + for b in (0, 2, 3): + assert len(base[b]) == total and len(rec[b]) == total + for i, (x, y) in enumerate(zip(base[b], rec[b])): + assert (x is None) == (y is None), f"slot {b} tick {i} validity diverged" + if x is not None: + assert np.array_equal(x, y), f"slot {b} tick {i} audio diverged (recycle leaked)" + + # Claim 2 -- replay exactness: the recycled slot's opening live ticks match a + # pristine boot at numerical-noise level. A broken KV mask / staging-ring + # roll / Mimi reset garbles the audio from the very first frame (diffs of + # 0.1-1.0); a correct replay differs only by decoder-state rounding (~1e-4). + # + # Full-length bitwise identity is NOT expected, for two structural reasons: + # the replayed system prompt sits at a different ABSOLUTE RoPE offset (bf16 + # rounds differently there), and the recycled Mimi ENCODER cannot restore a + # fresh stream's start padding (a batch-shared scalar), so from the first + # non-silent user frame it emits different-but-equally-valid codes for the + # same audio. Once any near-tie flips a token, the two equally valid + # trajectories legitimately diverge; coherence of the recycled slot is + # asserted behaviorally in test_recycled_slot_converses. + fresh = [a for a in base[1] if a is not None][:LIVE_TICKS] + reborn = [a for a in rec[1][RECYCLE_AT:] if a is not None][:LIVE_TICKS] + assert len(reborn) >= LIVE_TICKS // 2, "recycled slot produced too few live frames" + diffs = [float(np.max(np.abs(x - y))) for x, y in zip(fresh, reborn)] + first_flip = next((i for i, d in enumerate(diffs) if d > 5e-2), len(diffs)) + print( + f"[elastic] replay exactness: {first_flip}/{len(diffs)} opening ticks at noise level " + f"(head diffs: {[f'{d:.1e}' for d in diffs[:6]]})" + ) + assert first_flip >= 4, ( + f"recycled slot diverged from fresh behavior at tick {first_flip} " + f"(diffs {diffs[: first_flip + 2]}); the system-prompt replay is broken" + ) + assert max(diffs[:4]) < 2e-2, f"reborn audio not at noise level: {max(diffs[:4])}" + + +def test_recycled_slot_converses(engine): + """Behavioral guarantee: a recycled slot holds a coherent conversation. + + Feed a real spoken question into a slot that was recycled mid-flight (while + other slots keep running); the model's own inner-monologue text stream must + produce a real reply. This is the end-user property the encoder-state + caveats above cannot break. + """ + import os + + import sphn + + wav_path = os.environ.get("PPLEX_QUESTION_WAV") + if not wav_path: + pytest.skip("set PPLEX_QUESTION_WAV to a spoken-question wav to run") + wav, _ = sphn.read(wav_path, sample_rate=24000) + wav = wav[0].astype(np.float32) + frame = engine.frame_size + pad = (-len(wav)) % frame + wav = np.concatenate([wav, np.zeros(pad, dtype=np.float32)]) + q_frames = [wav[i : i + frame] for i in range(0, len(wav), frame)] + + engine.open_batch() + pcm = np.zeros((B, frame), dtype=np.float32) + for _ in range(5): # everyone live briefly + engine.step_batch(pcm) + engine.recycle_slot(1) + prefill = list(engine.prefill_steps(None)) + while prefill: + while prefill and prefill[0].kind == "voice_end": + engine.voice_end(1) + prefill.pop(0) + if not prefill: + break + engine.step_batch(pcm, {1: prefill.pop(0)}) + engine.end_prefill(1) + + text = "" + total = len(q_frames) + 120 # question + ~10 s to answer + for i in range(total): + pcm = np.zeros((B, frame), dtype=np.float32) + if i < len(q_frames): + pcm[1] = q_frames[i] + outs = engine.step_batch(pcm) + if outs[1] is not None and outs[1].text: + text += outs[1].text + alpha = sum(c.isalpha() for c in text) + print(f"[elastic] recycled slot inner monologue: {text!r}") + assert alpha >= 15, f"recycled slot produced no coherent reply: {text!r}" + + +def _bench(batch_sizes: list[int]) -> None: + import time + + for bs in batch_sizes: + cfg = PersonaPlexConfig(batch_size=max(bs, 2)) + eng = PersonaPlexEngine(cfg).load() + eng.open_batch() + frame = eng.frame_size + pcm = np.zeros((cfg.batch_size, frame), dtype=np.float32) + for _ in range(10): # warm + eng.step_batch(pcm) + torch.cuda.synchronize() + t0 = time.monotonic() + n = 100 + for _ in range(n): + eng.step_batch(pcm) + torch.cuda.synchronize() + ms = (time.monotonic() - t0) / n * 1000 + mem = torch.cuda.max_memory_allocated() / 2**30 + print(f"B={cfg.batch_size:3d} {ms:6.1f} ms/tick RTF/stream {ms / 80:.3f} {mem:.1f} GiB") + del eng + torch.cuda.empty_cache() + + +if __name__ == "__main__": + import sys + + sizes = [int(x) for x in sys.argv[2:]] if len(sys.argv) > 2 and sys.argv[1] == "--bench" else [2, 4, 8] + _bench(sizes) diff --git a/tests/e2e/features/fullduplex/test_personaplex_server_lease.py b/tests/e2e/features/fullduplex/test_personaplex_server_lease.py new file mode 100644 index 000000000..451ffb714 --- /dev/null +++ b/tests/e2e/features/fullduplex/test_personaplex_server_lease.py @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Single-session lease teardown: a cancelled engine call must be drained. + +Cancelling an asyncio task blocked on a worker-thread engine call returns +immediately while the thread keeps running (executor futures cannot be +interrupted). The server therefore tracks the in-flight future on the session +state and waits it out before releasing the capacity-one lease; otherwise a +fast reconnect could call ``open()`` on the shared engine while the previous +connection's final ``step()`` is still mutating it. +""" + +import asyncio +import contextlib +import sys +import threading +import types + +import pytest + +try: + import sphn # noqa: F401 +except ImportError: + # These lease tests never encode/decode Opus. Keep the optional serving + # dependency from hiding dependency-free teardown regressions. + sys.modules["sphn"] = types.ModuleType("sphn") + +from vllm_omni.experimental.fullduplex.personaplex.config import PersonaPlexConfig +from vllm_omni.experimental.fullduplex.personaplex.serving.server import DuplexServer +from vllm_omni.experimental.fullduplex.personaplex.session import PersonaPlexServingSessionState + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +@pytest.mark.asyncio +async def test_cancelled_engine_call_is_drained_before_lease_release(): + server = DuplexServer(PersonaPlexConfig()) # engine never loaded: stub calls only + state = PersonaPlexServingSessionState() + + started = threading.Event() + release = threading.Event() + finished = threading.Event() + + def slow_engine_op(): + started.set() + release.wait(timeout=5.0) + finished.set() + return [] + + task = asyncio.create_task(server._engine_call(state, slow_engine_op)) + await asyncio.to_thread(started.wait, 5.0) + + # Cancel while the worker thread is mid-call: the await returns at once, + # the thread keeps running, and its completion event stays tracked on the + # state (the asyncio future itself reads as cancelled/done here, which is + # exactly why the drain must not rely on it). + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + assert state.inflight is not None + assert not state.inflight.is_set() + + # The drain must genuinely wait for the worker, not just return. + drain = asyncio.create_task(server._drain_inflight(state)) + await asyncio.sleep(0.05) + assert not drain.done() + + release.set() + await asyncio.wait_for(drain, timeout=5.0) + assert finished.is_set() + assert state.inflight.is_set() + + +@pytest.mark.asyncio +async def test_completed_engine_call_clears_inflight(): + server = DuplexServer(PersonaPlexConfig()) + state = PersonaPlexServingSessionState() + result = await server._engine_call(state, lambda: "ok") + assert result == "ok" + assert state.inflight is None + await server._drain_inflight(state) # no-op on a clean state + + +@pytest.mark.asyncio +async def test_drain_timeout_keeps_lease_until_worker_finishes(): + server = DuplexServer(PersonaPlexConfig()) + server._active = True + state = PersonaPlexServingSessionState() + state.inflight = threading.Event() + + drained = await server._release_lease_after_drain(state, timeout=0.01) + + assert drained is False + assert server._active is True + + state.inflight.set() + for _ in range(100): + if not server._active: + break + await asyncio.sleep(0.01) + assert server._active is False diff --git a/tests/e2e/features/fullduplex/test_personaplex_session.py b/tests/e2e/features/fullduplex/test_personaplex_session.py new file mode 100644 index 000000000..f838de4ff --- /dev/null +++ b/tests/e2e/features/fullduplex/test_personaplex_session.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Lockstep-contract tests for the PersonaPlex session driver (no GPU / no moshi). + +A stub :class:`FrameStepper` stands in for the real Moshi backend so we can assert +the framing + 1-frame-in / 1-frame-out invariants without weights. +""" + +import numpy as np +import pytest + +from vllm_omni.experimental.fullduplex.personaplex.config import FRAME_SIZE, PersonaPlexConfig +from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput, FrameStepper +from vllm_omni.experimental.fullduplex.personaplex.session import PersonaPlexSession + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class StubStepper: + """Deterministic FrameStepper: frame i -> audio filled with i, text 't{i}' on odds.""" + + sample_rate = 24000 + frame_size = FRAME_SIZE + + def __init__(self) -> None: + self.opened: tuple[str | None, str | None] | None = None + self.steps = 0 + + def open_session(self, voice_prompt=None, persona=None) -> None: + self.opened = (voice_prompt, persona) + self.steps = 0 + + def step(self, user_pcm): + assert user_pcm.shape[0] == self.frame_size + self.steps += 1 + i = self.steps + return FrameOutput( + audio=np.full(self.frame_size, float(i), dtype=np.float32), + text=(f"t{i}" if i % 2 else None), + ) + + +def test_stub_satisfies_protocol(): + assert isinstance(StubStepper(), FrameStepper) + + +def test_feed_emits_one_output_per_complete_frame(): + s = PersonaPlexSession(StubStepper(), PersonaPlexConfig()) + s.open(voice_prompt="NATF2.pt", persona="be terse") + out = s.feed(np.zeros(FRAME_SIZE * 3, dtype=np.float32)) + assert len(out) == 3 + # lockstep order preserved: frame i -> audio filled with i + assert [int(o.audio[0]) for o in out] == [1, 2, 3] + assert [o.text for o in out] == ["t1", None, "t3"] + + +def test_partial_frames_are_buffered_until_complete(): + stub = StubStepper() + s = PersonaPlexSession(stub, PersonaPlexConfig()) + s.open() + assert s.feed(np.zeros(FRAME_SIZE // 2, dtype=np.float32)) == [] # nothing yet + assert stub.steps == 0 + out = s.feed(np.zeros(FRAME_SIZE, dtype=np.float32)) # 1.5 frames total -> 1 frame + assert len(out) == 1 + assert stub.steps == 1 + + +def test_flush_pads_the_tail(): + stub = StubStepper() + s = PersonaPlexSession(stub, PersonaPlexConfig()) + s.open() + s.feed(np.ones(FRAME_SIZE + 10, dtype=np.float32)) # 1 frame + 10-sample tail + assert stub.steps == 1 + tail = s.flush() + assert len(tail) == 1 and stub.steps == 2 + assert s.flush() == [] # nothing left + + +def test_run_concatenates_audio_and_joins_text(): + s = PersonaPlexSession(StubStepper(), PersonaPlexConfig()) + s.open() + audio, text = s.run(np.zeros(FRAME_SIZE * 4, dtype=np.float32)) + assert audio.shape[0] == FRAME_SIZE * 4 + assert text == "t1t3" # frames 1..4, text on odds + + +def test_open_forwards_voice_and_persona(): + stub = StubStepper() + s = PersonaPlexSession(stub, PersonaPlexConfig()) + s.open(voice_prompt="NATM1.pt", persona="pirate") + assert stub.opened == ("NATM1.pt", "pirate") + + +def test_feed_before_open_raises(): + s = PersonaPlexSession(StubStepper(), PersonaPlexConfig()) + with pytest.raises(RuntimeError): + s.feed(np.zeros(FRAME_SIZE, dtype=np.float32)) diff --git a/tests/fullduplex/test_runtime.py b/tests/e2e/features/fullduplex/test_runtime.py similarity index 100% rename from tests/fullduplex/test_runtime.py rename to tests/e2e/features/fullduplex/test_runtime.py diff --git a/tests/e2e/offline_inference/custom_pipeline/qwen_image_pipeline_with_logprob.py b/tests/e2e/features/helpers/custom_pipeline.py similarity index 59% rename from tests/e2e/offline_inference/custom_pipeline/qwen_image_pipeline_with_logprob.py rename to tests/e2e/features/helpers/custom_pipeline.py index 193d1b1e2..63cb21056 100644 --- a/tests/e2e/offline_inference/custom_pipeline/qwen_image_pipeline_with_logprob.py +++ b/tests/e2e/features/helpers/custom_pipeline.py @@ -1,27 +1,299 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Detailed custom Qwen-Image pipeline for E2E tests. +"""Test doubles for custom-pipeline E2E and RLHF integration. -This pipeline follows the structure of the user's reference implementation: -- supports pre-tokenized prompt IDs via OmniCustomPrompt-style dict input -- uses an SDE scheduler that can return step logprobs -- returns structured trajectory_* fields (latents, timesteps, log_probs) - consistent with the BAGEL trajectory recording design +Three layers: + +* ``FlowMatchSDEDiscreteSchedulerForTest`` — SDE scheduler with per-step logprobs. +* ``vLLMOmniColocateWorkerExtensionForTest`` — verl-omni colocate worker extension mirror. +* ``QwenImagePipelineWithLogProbForTest`` — Qwen-Image pipeline returning trajectory_* fields. """ from __future__ import annotations +import ctypes +import logging +import math import os +import platform +import signal +from dataclasses import dataclass from typing import Any, Literal import torch +from diffusers import FlowMatchEulerDiscreteScheduler +from diffusers.utils import BaseOutput +from diffusers.utils.torch_utils import randn_tensor +from msgspec import field -from tests.e2e.offline_inference.custom_pipeline.flow_match_sde_scheduler import FlowMatchSDEDiscreteSchedulerForTest from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig from vllm_omni.diffusion.distributed.utils import get_local_device from vllm_omni.diffusion.models.qwen_image import QwenImagePipeline +from vllm_omni.diffusion.worker.diffusion_worker import CustomPipelineWorkerExtension from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch +from vllm_omni.lora.request import LoRARequest as OmniLoRARequest + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Scheduler layer +# --------------------------------------------------------------------------- + + +@dataclass +class FlowMatchSDEDiscreteSchedulerOutputForTest(BaseOutput): + """ + Output class for the scheduler's `step` function output. + + Args: + prev_sample (`torch.FloatTensor` of shape `(batch_size, sequence_length, num_channels)` for images): + Computed sample `(x_{t-1})` of previous timestep. `prev_sample` should be used as next model input in the + denoising loop. + log_prob (`torch.FloatTensor` of shape `(batch_size,)`, *optional*): + The log probability of the previous sample. + prev_sample_mean (`torch.FloatTensor` of shape `(batch_size, sequence_length, num_channels)` for images): + The mean of the computed sample of previous timestep. + std_dev_t (`torch.FloatTensor` of shape `(batch_size, 1, 1)`): + The standard deviation used to compute `prev_sample`. + """ + + prev_sample: torch.FloatTensor + log_prob: torch.FloatTensor | None + prev_sample_mean: torch.FloatTensor + std_dev_t: torch.FloatTensor + + +class FlowMatchSDEDiscreteSchedulerForTest(FlowMatchEulerDiscreteScheduler): + """SDE version of the FlowMatchEulerDiscreteScheduler. + The implementation is based on FlowGRPO paper (https://arxiv.org/abs/2505.05470) + and diffusers v0.37 branch. + """ + + def step( + self, + model_output: torch.FloatTensor, + timestep: float | torch.FloatTensor, + sample: torch.FloatTensor, + s_churn: float = 0.0, + s_tmin: float = 0.0, + s_tmax: float = float("inf"), + s_noise: float = 1.0, + generator: torch.Generator | None = None, + per_token_timesteps: torch.Tensor | None = None, + return_dict: bool = True, + noise_level: float = 0.7, + prev_sample: torch.FloatTensor | None = None, + sde_type: Literal["sde", "cps"] = "sde", + return_logprobs: bool = True, + ) -> FlowMatchSDEDiscreteSchedulerOutputForTest | tuple: + """ + Predict the sample from the previous timestep by reversing the SDE. This function propagates the diffusion + process from the learned model outputs (most often the predicted noise). + + Modified from https://github.com/yifan123/flow_grpo/blob/main/flow_grpo/diffusers_patch/sd3_sde_with_logprob.py + + Args: + model_output (`torch.FloatTensor`): + The direct output from learned diffusion model. + timestep (`float`): + The current discrete timestep in the diffusion chain. + sample (`torch.FloatTensor`): + A current instance of a sample created by the diffusion process. + s_churn (`float`): + s_tmin (`float`): + s_tmax (`float`): + s_noise (`float`, defaults to 1.0): + Scaling factor for noise added to the sample. + generator (`torch.Generator`, *optional*): + A random number generator. + per_token_timesteps (`torch.Tensor`, *optional*): + The timesteps for each token in the sample. + return_dict (`bool`): + Whether or not to return a + [`~schedulers.scheduling_flow_match_euler_discrete.FlowMatchSDEDiscreteSchedulerOutput`] or tuple. + noise_level (`float`, *optional*, defaults to 0.7): + The noise level used in the SDE. + prev_sample (`torch.FloatTensor`, *optional*): + The sample from the previous timestep. If not provided, it will be sampled inside the function. + sde_type (`str`, *optional*, defaults to "sde"): + The type of SDE to use. Choose between "sde" and "cps". + return_logprobs (`bool`, *optional*, defaults to True): + Whether to return log probabilities of the previous sample. + """ + + if isinstance(timestep, (int, torch.IntTensor, torch.LongTensor)): + raise ValueError( + ( + "Passing integer indices (e.g. from `enumerate(timesteps)`) as timesteps to" + " `FlowMatchEulerDiscreteScheduler.step()` is not supported. Make sure to pass" + " one of the `scheduler.timesteps` as a timestep." + ), + ) + + if self.step_index is None: + self._init_step_index(timestep) + + # Upcast to avoid precision issues when computing prev_sample + sample = sample.to(torch.float32) + if prev_sample is not None: + prev_sample = prev_sample.to(torch.float32) + + prev_sample, log_prob, prev_sample_mean, std_dev_t = self.sample_previous_step( + sample=sample, + model_output=model_output, + generator=generator, + per_token_timesteps=per_token_timesteps, + noise_level=noise_level, + prev_sample=prev_sample, + sde_type=sde_type, + return_logprobs=return_logprobs, + ) + + # upon completion increase step index by one + self._step_index += 1 + if per_token_timesteps is None: + # Cast sample back to model compatible dtype + prev_sample = prev_sample.to(model_output.dtype) + + if not return_dict: + return (prev_sample, log_prob, prev_sample_mean, std_dev_t) + + return FlowMatchSDEDiscreteSchedulerOutputForTest( + prev_sample=prev_sample, log_prob=log_prob, prev_sample_mean=prev_sample_mean, std_dev_t=std_dev_t + ) + + def sample_previous_step( + self, + sample: torch.Tensor, + model_output: torch.Tensor, + timestep: torch.FloatTensor | None = None, + generator: torch.Generator | None = None, + per_token_timesteps: torch.Tensor | None = None, + noise_level: float = 0.7, + prev_sample: torch.Tensor | None = None, + sde_type: Literal["cps", "sde"] = "sde", + return_logprobs: bool = True, + ): + assert sde_type in ["sde", "cps"] + assert sample.dtype == torch.float32 + if prev_sample is not None: + assert prev_sample.dtype == torch.float32 + + if per_token_timesteps is not None: + raise NotImplementedError("per_token_timesteps is not supported yet for FlowMatchSDEDiscreteScheduler.") + else: + if timestep is None: + sigma_idx = self.step_index + sigma = self.sigmas[sigma_idx] + sigma_prev = self.sigmas[sigma_idx + 1] + else: + sigma_idx = torch.tensor([self.index_for_timestep(t) for t in timestep]) + sigma = self.sigmas[sigma_idx].view(-1, *([1] * (len(sample.shape) - 1))) + sigma_prev = self.sigmas[sigma_idx + 1].view(-1, *([1] * (len(sample.shape) - 1))) + + sigma_max = self.sigmas[1] + dt = sigma_prev - sigma + + if sde_type == "sde": + std_dev_t = torch.sqrt(sigma / (1 - torch.where(sigma == 1, sigma_max, sigma))) * noise_level + + prev_sample_mean = ( + sample * (1 + std_dev_t**2 / (2 * sigma) * dt) + + model_output * (1 + std_dev_t**2 * (1 - sigma) / (2 * sigma)) * dt + ) + + if prev_sample is None: + variance_noise = randn_tensor( + model_output.shape, + generator=generator, + device=model_output.device, + dtype=model_output.dtype, + ) + prev_sample = prev_sample_mean + std_dev_t * torch.sqrt(-1 * dt) * variance_noise + + if return_logprobs: + log_prob = ( + -((prev_sample.detach() - prev_sample_mean) ** 2) / (2 * ((std_dev_t * torch.sqrt(-1 * dt)) ** 2)) + - torch.log(std_dev_t * torch.sqrt(-1 * dt)) + - torch.log(torch.sqrt(2 * torch.as_tensor(math.pi))) + ) + else: + log_prob = None + + elif sde_type == "cps": + std_dev_t = sigma_prev * math.sin(noise_level * math.pi / 2) + pred_original_sample = sample - sigma * model_output + noise_estimate = sample + model_output * (1 - sigma) + prev_sample_mean = pred_original_sample * (1 - sigma_prev) + noise_estimate * torch.sqrt( + sigma_prev**2 - std_dev_t**2 + ) + + if prev_sample is None: + variance_noise = randn_tensor( + model_output.shape, + generator=generator, + device=model_output.device, + dtype=model_output.dtype, + ) + prev_sample = prev_sample_mean + std_dev_t * variance_noise + + if return_logprobs: + log_prob = -((prev_sample.detach() - prev_sample_mean) ** 2) + else: + log_prob = None + + # mean along all but batch dimension + log_prob = log_prob.mean(dim=tuple(range(1, log_prob.ndim))) if log_prob is not None else None + return prev_sample, log_prob, prev_sample_mean, std_dev_t + + +# --------------------------------------------------------------------------- +# Worker extension layer +# --------------------------------------------------------------------------- + + +class OmniTensorLoRARequestForTest(OmniLoRARequest): + peft_config: dict = field(default=None) + lora_tensors: dict = field(default=None) + + +def set_death_signal() -> None: + """Verbatim port of ``verl.workers.rollout.vllm_rollout.utils.set_death_signal``.""" + if platform.system() != "Linux": + return + try: + libc = ctypes.CDLL("libc.so.6") + libc.prctl(1, signal.SIGKILL) + if os.getppid() == 1: + os.kill(os.getpid(), signal.SIGKILL) + except Exception: # noqa: BLE001 + # libc.so.6 may not be present (musl, alpine) — best-effort only. + pass + + +class vLLMOmniColocateWorkerExtensionForTest(CustomPipelineWorkerExtension): + """Mirror of ``vLLMOmniColocateWorkerExtension`` (verl-omni). + + The production ``__new__`` runs ``set_death_signal`` + ``VLLMOmniHijack.hijack`` + on every vLLM worker process. Replicating both ensures this test + reproduces the same monkey-patched environment. + """ + + def __new__(cls, **kwargs): + set_death_signal() + return super().__new__(cls) + + @staticmethod + def test_extension_name() -> str: + """Return a stable identifier for assertions in unit tests.""" + return "vllm-omni-colocate-worker-extension-for-test" + + +# --------------------------------------------------------------------------- +# Pipeline layer +# --------------------------------------------------------------------------- def _maybe_to_cpu(v): @@ -30,8 +302,6 @@ def _maybe_to_cpu(v): return v -# Custom pipeline class for QwenImage that returns log probabilities during the diffusion process. -# This is for test class QwenImagePipelineWithLogProbForTest(QwenImagePipeline): supports_request_batch = False diff --git a/tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py b/tests/e2e/features/helpers/verl_omni_server.py similarity index 53% rename from tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py rename to tests/e2e/features/helpers/verl_omni_server.py index 69952435e..d53e0936f 100644 --- a/tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py +++ b/tests/e2e/features/helpers/verl_omni_server.py @@ -1,30 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""E2E test that follows the EXACT same flow as -``verl-omni/tests/workers/rollout/rollout_vllm/test_vllm_omni_generate.py`` -but inlines all the ``verl`` / ``verl_omni`` symbols the flow depends on -(stripped down to only what the diffusion-mode test actually exercises). +"""Ray-serializable verl-omni HTTP server test double for RLHF E2E tests. -Flow (identical to the verl-omni test): - -1. Build two ``OmegaConf`` configs (``DiffusionRolloutConfig`` / - ``DiffusionModelConfig`` shape). -2. ``ServerCls = ray.remote(vLLMOmniHttpServerLocal)`` then - ``ServerCls.options(...).remote(config=..., model_config=..., - rollout_mode=RolloutMode.STANDALONE, workers=[], replica_rank=0, - node_rank=0, gpus_per_node=1, nnodes=1, cuda_visible_devices="0")``. -3. ``ray.get(server.launch_server.remote())``. -4. ``server.generate.remote(prompt_ids=..., sampling_params=..., request_id=...)`` - and assert the returned ``DiffusionOutput``. - -The inlined classes / helpers mirror their ``verl`` / ``verl_omni`` -counterparts; bits not reached by this test (AR mode, LoRA, -quantization, run_uvicorn, headless, profiler, port wiring) are removed. - -Usage: - pytest tests/e2e/offline_inference/rlhf_test/\ -test_ray_async_omni_qwen_image_generate.py -v -s +The actor class must live in an importable module (not a pytest test file) so +Ray workers can unpickle ``vLLMOmniHttpServerLocal`` via +``tests.e2e.features.helpers.verl_omni_server``. """ from __future__ import annotations @@ -35,23 +16,15 @@ import logging import os from dataclasses import asdict from enum import Enum -from functools import lru_cache from typing import Any -from uuid import uuid4 -import pytest -import ray -import torch import torchvision.transforms as T -from huggingface_hub import snapshot_download from omegaconf import DictConfig, OmegaConf from pydantic import BaseModel, ConfigDict -from transformers import AutoTokenizer from vllm.entrypoints.openai.api_server import build_app from vllm.utils.argparse_utils import FlexibleArgumentParser import vllm_omni.entrypoints.cli.serve -from tests.helpers.mark import hardware_test from vllm_omni.engine.arg_utils import OmniEngineArgs from vllm_omni.entrypoints.async_omni import AsyncOmni from vllm_omni.entrypoints.openai.api_server import omni_init_app_state @@ -60,33 +33,8 @@ from vllm_omni.outputs import OmniRequestOutput logger = logging.getLogger(__name__) -CUSTOM_PIPELINE_CLASS = ( - "tests.e2e.offline_inference.custom_pipeline.qwen_image_pipeline_with_logprob.QwenImagePipelineWithLogProbForTest" -) -WORKER_EXTENSION_CLASS = ( - "tests.e2e.offline_inference.custom_pipeline.worker_extension.vLLMOmniColocateWorkerExtensionForTest" -) - -# Use your specified HF repo name/path directly here -MODEL = "tiny-random/Qwen-Image" - -TOKENIZER_MODEL = "Qwen/Qwen2-1.5B-Instruct" - - -def _resolve_model_path(repo_id: str) -> str: - """Resolve an HF repo ID to a local snapshot path, downloading if needed.""" - # Allow overriding with a pre-existing local path (skips download). - if os.path.isdir(repo_id): - return repo_id - return snapshot_download(repo_id=repo_id) - - -_MIN_PROMPT_TOKENS = 35 - - -# --------------------------------------------------------------------- -# Inlined: verl.workers.rollout.replica.RolloutMode -# --------------------------------------------------------------------- +CUSTOM_PIPELINE_CLASS = "tests.e2e.features.helpers.custom_pipeline.QwenImagePipelineWithLogProbForTest" +WORKER_EXTENSION_CLASS = "tests.e2e.features.helpers.custom_pipeline.vLLMOmniColocateWorkerExtensionForTest" class RolloutMode(Enum): @@ -95,11 +43,6 @@ class RolloutMode(Enum): STANDALONE = "standalone" -# --------------------------------------------------------------------- -# Inlined: verl_omni.workers.rollout.replica.DiffusionOutput -# --------------------------------------------------------------------- - - class DiffusionOutput(BaseModel): """Pydantic mirror of ``verl_omni.workers.rollout.replica.DiffusionOutput``.""" @@ -112,11 +55,6 @@ class DiffusionOutput(BaseModel): extra_fields: dict[str, Any] = {} -# --------------------------------------------------------------------- -# Inlined: verl.utils.tokenizer.normalize_token_ids -# --------------------------------------------------------------------- - - def normalize_token_ids(tokenized_output) -> list[int]: token_ids = tokenized_output if isinstance(tokenized_output, dict): @@ -142,11 +80,6 @@ def normalize_token_ids(tokenized_output) -> list[int]: return out -# --------------------------------------------------------------------- -# Inlined: verl.workers.rollout.vllm_rollout.utils.build_cli_args_from_config -# --------------------------------------------------------------------- - - def build_cli_args_from_config(config: dict[str, Any]) -> list[str]: cli_args: list[str] = [] for k, v in config.items(): @@ -166,34 +99,6 @@ def build_cli_args_from_config(config: dict[str, Any]) -> list[str]: return cli_args -# --------------------------------------------------------------------- -# Tokenizer helper (test-side) -# --------------------------------------------------------------------- - - -@lru_cache(maxsize=1) -def _get_tokenizer(): - return AutoTokenizer.from_pretrained(TOKENIZER_MODEL, trust_remote_code=True) - - -def _tokenize_prompt(text: str) -> list[int]: - tokenizer = _get_tokenizer() - messages = [{"role": "user", "content": text}] - token_ids = normalize_token_ids(tokenizer.apply_chat_template(messages, tokenize=True, add_generation_prompt=False)) - assert len(token_ids) > _MIN_PROMPT_TOKENS, ( - f"Prompt too short ({len(token_ids)} tokens, need >{_MIN_PROMPT_TOKENS}). " - "The pipeline drops the first 34 chat-template prefix tokens; " - "use a longer prompt so content tokens remain after the drop." - ) - return token_ids - - -# --------------------------------------------------------------------- -# Inlined: vLLMOmniHttpServer (verl-omni) + relevant vLLMHttpServer (verl) -# bits, stripped to the diffusion-mode path the test exercises. -# --------------------------------------------------------------------- - - def _cfg_get(cfg: Any, key: str, default: Any = None) -> Any: """Uniform getter that works for both DictConfig and dataclass-like objects.""" if isinstance(cfg, DictConfig): @@ -202,13 +107,8 @@ def _cfg_get(cfg: Any, key: str, default: Any = None) -> Any: class vLLMOmniHttpServerLocal: - """Self-contained simulation of ``vLLMOmniHttpServer`` for diffusion mode. + """Self-contained simulation of ``vLLMOmniHttpServer`` for diffusion mode.""" - Constructor signature matches ``vLLMHttpServer.__init__`` exactly so the - Ray-actor call site in the test is identical to the verl-omni one. - """ - - # ------------------------------------------------------------------ init def __init__( self, config, @@ -224,8 +124,6 @@ class vLLMOmniHttpServerLocal: os.environ.setdefault("CUDA_VISIBLE_DEVICES", cuda_visible_devices) os.environ["VERL_REPLICA_RANK"] = str(replica_rank) - # Resolve configs. verl uses omega_conf_to_dataclass; here we keep - # them as DictConfig — every read site below uses ``_cfg_get``. self.config = OmegaConf.create(config) if not isinstance(config, DictConfig) else config self.model_config = OmegaConf.create(model_config) if not isinstance(model_config, DictConfig) else model_config @@ -237,8 +135,6 @@ class vLLMOmniHttpServerLocal: self.nnodes = nnodes self.global_steps = 0 self.engine: AsyncOmni | None = None - - # Diffusion-mode parity with ``vLLMOmniHttpServer._post_init``. self._to_tensor = T.PILToTensor() logger.info( @@ -249,11 +145,7 @@ class vLLMOmniHttpServerLocal: cuda_visible_devices, ) - # ---------------------------------------------------------- launch_server async def launch_server(self) -> None: - """Diffusion-only mirror of ``vLLMHttpServer.launch_server``.""" - - # vLLMOmniHttpServer._get_engine_kwargs_key() returns "vllm_omni". engine_kwargs_root = _cfg_get(self.config, "engine_kwargs", {}) or {} if isinstance(engine_kwargs_root, (dict, DictConfig)): engine_kwargs = engine_kwargs_root.get("vllm_omni", {}) or {} @@ -261,10 +153,8 @@ class vLLMOmniHttpServerLocal: engine_kwargs = {} engine_kwargs = {k: v for k, v in dict(engine_kwargs).items() if v is not None} - # vLLMOmniHttpServer._preprocess_engine_kwargs (diffusion path). engine_kwargs.pop("output_mode", None) - # ``compilation_config`` handling — verbatim from parent launch_server. compilation_config = engine_kwargs.pop("compilation_config", None) or {} if isinstance(compilation_config, str): compilation_config = json.loads(compilation_config) @@ -273,8 +163,6 @@ class vLLMOmniHttpServerLocal: compilation_config.setdefault("cudagraph_mode", "FULL_AND_PIECEWISE") compilation_config = json.dumps(compilation_config) - # Args dict (subset of parent's; the diffusion override drops AR-only - # entries via ``_get_override_generation_config`` returning ``{}``). args = { "dtype": _cfg_get(self.config, "dtype", "bfloat16"), "load_format": _cfg_get(self.config, "load_format", "auto"), @@ -283,7 +171,7 @@ class vLLMOmniHttpServerLocal: "worker_extension_cls": WORKER_EXTENSION_CLASS, "trust_remote_code": _cfg_get(self.model_config, "trust_remote_code", True), "max_model_len": _cfg_get(self.config, "max_model_len", 1058), - "max_num_seqs": _cfg_get(self.config, "max_num_seqs", 256), + "max_num_seqs": _cfg_get(self.config, "max_num_seqs", 1), "enable_chunked_prefill": _cfg_get(self.config, "enable_chunked_prefill", False), "max_num_batched_tokens": _cfg_get(self.config, "max_num_batched_tokens", 8192), "enable_prefix_caching": _cfg_get(self.config, "enable_prefix_caching", False), @@ -303,8 +191,6 @@ class vLLMOmniHttpServerLocal: model_path = str(_cfg_get(self.model_config, "local_path", None) or _cfg_get(self.model_config, "path")) server_args_list = ["serve", model_path] + build_cli_args_from_config(args) - # Same parser pipeline as parent launch_server (with vllm-omni's - # cmd_init injected, matching ``_get_cli_modules`` + ``_get_cli_description``). parser = FlexibleArgumentParser(description="vLLM-Omni CLI") subparsers = parser.add_subparsers(required=False, dest="subparser") cmds: dict[str, Any] = {} @@ -316,19 +202,12 @@ class vLLMOmniHttpServerLocal: if parsed.subparser in cmds: cmds[parsed.subparser].validate(parsed) - # node_rank == 0 → run_server (the test never sets node_rank != 0). await self.run_server(parsed) - # ------------------------------------------------------------- run_server async def run_server(self, args: argparse.Namespace) -> None: - """Diffusion branch of ``vLLMOmniHttpServer.run_server``.""" - engine_args = OmniEngineArgs.from_cli_args(args) engine_args = asdict(engine_args) - # Diffusion path: pin the in-repo test pipeline. The production code - # calls ``VllmOmniPipelineBase.get_pipeline_path(architecture)``; we - # bypass the registry and bind the test pipeline directly. engine_args["enable_dummy_pipeline"] = True engine_args["custom_pipeline_args"] = {"pipeline_class": CUSTOM_PIPELINE_CLASS} @@ -337,10 +216,7 @@ class vLLMOmniHttpServerLocal: await omni_init_app_state(engine_client, app.state, args) self.engine = engine_client - # ``run_uvicorn`` intentionally skipped — the test drives the engine - # via Ray RPC, not HTTP. - # ---------------------------------------------------------------- generate async def generate( self, prompt_ids: list[int], @@ -351,8 +227,6 @@ class vLLMOmniHttpServerLocal: negative_prompt_ids: list[int] | None = None, priority: int = 0, # noqa: ARG002 (signature parity) ) -> DiffusionOutput: - # Verbatim copy of ``vLLMOmniHttpServer._generate_diffusion`` minus - # the LoRA branch (the test does not enable ``lora_as_adapter``). prompt_ids = normalize_token_ids(prompt_ids) multi_modal_data: dict[str, Any] = {} @@ -444,148 +318,3 @@ class vLLMOmniHttpServerLocal: num_preempted=num_preempted, extra_fields=extra_fields, ) - - -# --------------------------------------------------------------------- -# Fixtures & tests -# --------------------------------------------------------------------- - - -@pytest.fixture -def init_server(): - """Create and launch a vLLMOmniHttpServerLocal Ray actor with Qwen-Image.""" - model_path = _resolve_model_path(MODEL) - - ray.init( - runtime_env={ - "env_vars": { - "TOKENIZERS_PARALLELISM": "true", - "NCCL_DEBUG": "WARN", - "VLLM_LOGGING_LEVEL": "INFO", - } - }, - ignore_reinit_error=True, - ) - - rollout_cfg = OmegaConf.create( - { - "name": "vllm_omni", - "mode": "async", - "tensor_model_parallel_size": 1, - "data_parallel_size": 1, - "pipeline_model_parallel_size": 1, - "gpu_memory_utilization": 0.8, - "max_num_batched_tokens": 8192, - "max_num_seqs": 256, - "max_model_len": 1058, - "dtype": "bfloat16", - "load_format": "auto", - "enforce_eager": True, - "enable_chunked_prefill": False, - "enable_prefix_caching": False, - "enable_sleep_mode": False, - "free_cache_engine": True, - "disable_log_stats": True, - "logprobs_mode": "processed_logprobs", - "scheduling_policy": "fcfs", - "seed": 0, - "n": 4, - "pipeline": { - "height": 512, - "width": 512, - "num_inference_steps": 10, - }, - } - ) - - model_cfg = OmegaConf.create( - { - "path": str(model_path), - "local_path": str(model_path), - "tokenizer_path": TOKENIZER_MODEL, - "trust_remote_code": True, - "load_tokenizer": True, - } - ) - - ServerCls = ray.remote(vLLMOmniHttpServerLocal) - server = ServerCls.options( - runtime_env={ - "env_vars": { - "RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES": "1", - "RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES": "1", - "NCCL_CUMEM_ENABLE": "0", - } - }, - max_concurrency=10, - num_gpus=1, - ).remote( - config=rollout_cfg, - model_config=model_cfg, - rollout_mode=RolloutMode.STANDALONE, - workers=[], - replica_rank=0, - node_rank=0, - gpus_per_node=1, - nnodes=1, - cuda_visible_devices="0", - ) - - ray.get(server.launch_server.remote()) - - yield server - - ray.shutdown() - - -@pytest.mark.core_model -@pytest.mark.diffusion -@hardware_test(res={"cuda": "L4"}, num_cards=1) -def test_generate(init_server): - """Concurrent generate() calls covering basic output, logprobs, and multi-request correctness.""" - server = init_server - - prompts = [ - "a beautiful sunset over the ocean with vibrant orange and purple clouds " - "reflecting on the calm water surface near a rocky coastline", - "a fluffy orange cat sitting on a wooden windowsill looking outside at " - "a garden full of colorful flowers on a bright sunny afternoon", - "a majestic mountain landscape covered with fresh white snow under a " - "clear blue sky with pine trees in the foreground and a frozen lake", - "a futuristic city at night with neon lights glowing on tall glass " - "skyscrapers and flying vehicles soaring between the buildings", - ] - - refs = [] - for i, prompt in enumerate(prompts): - rid = f"test_{i}_{uuid4().hex[:8]}" - ref = server.generate.remote( - prompt_ids=_tokenize_prompt(prompt), - sampling_params={ - "num_inference_steps": 10, - "true_cfg_scale": 4.0, - "height": 512, - "width": 512, - "logprobs": i == 0, # first request includes logprobs - }, - request_id=rid, - ) - refs.append(ref) - - results = ray.get(refs, timeout=600) - - for i, output in enumerate(results): - assert isinstance(output, DiffusionOutput), f"Request {i}: expected DiffusionOutput" - img = output.diffusion_output - assert isinstance(img, torch.Tensor) and img.ndim == 3, ( - f"Request {i}: expected CHW torch.Tensor, got {type(img).__name__} ndim={getattr(img, 'ndim', None)}" - ) - c, h, w = img.shape - assert c == 3, f"Request {i}: expected 3 channels (CHW), got {c}" - assert h > 0 and w > 0, f"Request {i}: image dimensions must be positive" - assert output.stop_reason in ("completed", "aborted", None), ( - f"Request {i}: unexpected stop_reason {output.stop_reason!r}" - ) - assert 0.0 <= float(img[0, 0, 0]) <= 1.0, f"Request {i}: pixel values must be in [0, 1]" - - print(f"All {len(prompts)} concurrent requests returned valid DiffusionOutput") diff --git a/tests/e2e/features/rlhf_test/test_verl_omni_e2e.py b/tests/e2e/features/rlhf_test/test_verl_omni_e2e.py new file mode 100644 index 000000000..dab5feef1 --- /dev/null +++ b/tests/e2e/features/rlhf_test/test_verl_omni_e2e.py @@ -0,0 +1,217 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""E2E test that follows the EXACT same flow as +``verl-omni/tests/workers/rollout/rollout_vllm/test_vllm_omni_generate.py`` +but inlines all the ``verl`` / ``verl_omni`` symbols the flow depends on +(stripped down to only what the diffusion-mode test actually exercises). + +Flow (identical to the verl-omni test): + +1. Build two ``OmegaConf`` configs (``DiffusionRolloutConfig`` / + ``DiffusionModelConfig`` shape). +2. ``ServerCls = ray.remote(vLLMOmniHttpServerLocal)`` then + ``ServerCls.options(...).remote(config=..., model_config=..., + rollout_mode=RolloutMode.STANDALONE, workers=[], replica_rank=0, + node_rank=0, gpus_per_node=1, nnodes=1, cuda_visible_devices="0")``. +3. ``ray.get(server.launch_server.remote())``. +4. ``server.generate.remote(prompt_ids=..., sampling_params=..., request_id=...)`` + and assert the returned ``DiffusionOutput``. + +The Ray actor lives in ``tests.e2e.features.helpers.verl_omni_server`` so +workers can import it after directory migration (pytest test modules are not +importable as ``test_verl_omni_e2e`` in Ray child processes). + +Usage: + pytest tests/e2e/features/rlhf_test/test_verl_omni_e2e.py -v -s +""" + +from __future__ import annotations + +import os +from functools import lru_cache +from uuid import uuid4 + +import pytest +import ray +import torch +from huggingface_hub import snapshot_download +from omegaconf import OmegaConf +from transformers import AutoTokenizer + +from tests.e2e.features.helpers.verl_omni_server import ( + DiffusionOutput, + RolloutMode, + normalize_token_ids, + vLLMOmniHttpServerLocal, +) +from tests.helpers.mark import hardware_test + +MODEL = "tiny-random/Qwen-Image" +TOKENIZER_MODEL = "Qwen/Qwen2-1.5B-Instruct" +_MIN_PROMPT_TOKENS = 35 + + +def _resolve_model_path(repo_id: str) -> str: + if os.path.isdir(repo_id): + return repo_id + return snapshot_download(repo_id=repo_id) + + +@lru_cache(maxsize=1) +def _get_tokenizer(): + return AutoTokenizer.from_pretrained(TOKENIZER_MODEL, trust_remote_code=True) + + +def _tokenize_prompt(text: str) -> list[int]: + tokenizer = _get_tokenizer() + messages = [{"role": "user", "content": text}] + token_ids = normalize_token_ids(tokenizer.apply_chat_template(messages, tokenize=True, add_generation_prompt=False)) + assert len(token_ids) > _MIN_PROMPT_TOKENS, ( + f"Prompt too short ({len(token_ids)} tokens, need >{_MIN_PROMPT_TOKENS}). " + "The pipeline drops the first 34 chat-template prefix tokens; " + "use a longer prompt so content tokens remain after the drop." + ) + return token_ids + + +@pytest.fixture +def init_server(): + """Create and launch a vLLMOmniHttpServerLocal Ray actor with Qwen-Image.""" + model_path = _resolve_model_path(MODEL) + + ray.init( + runtime_env={ + "env_vars": { + "TOKENIZERS_PARALLELISM": "true", + "NCCL_DEBUG": "WARN", + "VLLM_LOGGING_LEVEL": "INFO", + } + }, + ignore_reinit_error=True, + ) + + rollout_cfg = OmegaConf.create( + { + "name": "vllm_omni", + "mode": "async", + "tensor_model_parallel_size": 1, + "data_parallel_size": 1, + "pipeline_model_parallel_size": 1, + "gpu_memory_utilization": 0.8, + "max_num_batched_tokens": 8192, + # QwenImagePipeline (and the logprob custom subclass) only supports + # serial request execution; max_num_seqs>1 enables request-level batching. + "max_num_seqs": 1, + "max_model_len": 1058, + "dtype": "bfloat16", + "load_format": "auto", + "enforce_eager": True, + "enable_chunked_prefill": False, + "enable_prefix_caching": False, + "enable_sleep_mode": False, + "free_cache_engine": True, + "disable_log_stats": True, + "logprobs_mode": "processed_logprobs", + "scheduling_policy": "fcfs", + "seed": 0, + "n": 4, + "pipeline": { + "height": 512, + "width": 512, + "num_inference_steps": 10, + }, + } + ) + + model_cfg = OmegaConf.create( + { + "path": str(model_path), + "local_path": str(model_path), + "tokenizer_path": TOKENIZER_MODEL, + "trust_remote_code": True, + "load_tokenizer": True, + } + ) + + ServerCls = ray.remote(vLLMOmniHttpServerLocal) + server = ServerCls.options( + runtime_env={ + "env_vars": { + "RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES": "1", + "RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES": "1", + "NCCL_CUMEM_ENABLE": "0", + } + }, + max_concurrency=10, + num_gpus=1, + ).remote( + config=rollout_cfg, + model_config=model_cfg, + rollout_mode=RolloutMode.STANDALONE, + workers=[], + replica_rank=0, + node_rank=0, + gpus_per_node=1, + nnodes=1, + cuda_visible_devices="0", + ) + + ray.get(server.launch_server.remote()) + + yield server + + ray.shutdown() + + +@pytest.mark.core_model +@pytest.mark.diffusion +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_generate(init_server): + """Concurrent generate() calls covering basic output, logprobs, and multi-request correctness.""" + server = init_server + + prompts = [ + "a beautiful sunset over the ocean with vibrant orange and purple clouds " + "reflecting on the calm water surface near a rocky coastline", + "a fluffy orange cat sitting on a wooden windowsill looking outside at " + "a garden full of colorful flowers on a bright sunny afternoon", + "a majestic mountain landscape covered with fresh white snow under a " + "clear blue sky with pine trees in the foreground and a frozen lake", + "a futuristic city at night with neon lights glowing on tall glass " + "skyscrapers and flying vehicles soaring between the buildings", + ] + + refs = [] + for i, prompt in enumerate(prompts): + rid = f"test_{i}_{uuid4().hex[:8]}" + ref = server.generate.remote( + prompt_ids=_tokenize_prompt(prompt), + sampling_params={ + "num_inference_steps": 10, + "true_cfg_scale": 4.0, + "height": 512, + "width": 512, + "logprobs": i == 0, + }, + request_id=rid, + ) + refs.append(ref) + + results = ray.get(refs, timeout=600) + + for i, output in enumerate(results): + assert isinstance(output, DiffusionOutput), f"Request {i}: expected DiffusionOutput" + img = output.diffusion_output + assert isinstance(img, torch.Tensor) and img.ndim == 3, ( + f"Request {i}: expected CHW torch.Tensor, got {type(img).__name__} ndim={getattr(img, 'ndim', None)}" + ) + c, h, w = img.shape + assert c == 3, f"Request {i}: expected 3 channels (CHW), got {c}" + assert h > 0 and w > 0, f"Request {i}: image dimensions must be positive" + assert output.stop_reason in ("completed", "aborted", None), ( + f"Request {i}: unexpected stop_reason {output.stop_reason!r}" + ) + assert 0.0 <= float(img[0, 0, 0]) <= 1.0, f"Request {i}: pixel values must be in [0, 1]" + + print(f"All {len(prompts)} concurrent requests returned valid DiffusionOutput") diff --git a/tests/e2e/offline_inference/custom_pipeline/__init__.py b/tests/e2e/offline_inference/custom_pipeline/__init__.py deleted file mode 100644 index 99663a46a..000000000 --- a/tests/e2e/offline_inference/custom_pipeline/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -from tests.e2e.offline_inference.custom_pipeline.qwen_image_pipeline_with_logprob import ( - QwenImagePipelineWithLogProbForTest, -) -from tests.e2e.offline_inference.custom_pipeline.worker_extension import ( - vLLMOmniColocateWorkerExtensionForTest, -) - -__all__ = [ - "QwenImagePipelineWithLogProbForTest", - "vLLMOmniColocateWorkerExtensionForTest", -] diff --git a/tests/e2e/offline_inference/custom_pipeline/flow_match_sde_scheduler.py b/tests/e2e/offline_inference/custom_pipeline/flow_match_sde_scheduler.py deleted file mode 100644 index f85eb6602..000000000 --- a/tests/e2e/offline_inference/custom_pipeline/flow_match_sde_scheduler.py +++ /dev/null @@ -1,220 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -from __future__ import annotations - -import math -from dataclasses import dataclass -from typing import Literal - -import torch -from diffusers import FlowMatchEulerDiscreteScheduler -from diffusers.utils import BaseOutput -from diffusers.utils.torch_utils import randn_tensor - - -@dataclass -class FlowMatchSDEDiscreteSchedulerOutputForTest(BaseOutput): - """ - Output class for the scheduler's `step` function output. - - Args: - prev_sample (`torch.FloatTensor` of shape `(batch_size, sequence_length, num_channels)` for images): - Computed sample `(x_{t-1})` of previous timestep. `prev_sample` should be used as next model input in the - denoising loop. - log_prob (`torch.FloatTensor` of shape `(batch_size,)`, *optional*): - The log probability of the previous sample. - prev_sample_mean (`torch.FloatTensor` of shape `(batch_size, sequence_length, num_channels)` for images): - The mean of the computed sample of previous timestep. - std_dev_t (`torch.FloatTensor` of shape `(batch_size, 1, 1)`): - The standard deviation used to compute `prev_sample`. - """ - - prev_sample: torch.FloatTensor - log_prob: torch.FloatTensor | None - prev_sample_mean: torch.FloatTensor - std_dev_t: torch.FloatTensor - - -class FlowMatchSDEDiscreteSchedulerForTest(FlowMatchEulerDiscreteScheduler): - """SDE version of the FlowMatchEulerDiscreteScheduler. - The implementation is based on FlowGRPO paper (https://arxiv.org/abs/2505.05470) - and diffusers v0.37 branch. - """ - - def step( - self, - model_output: torch.FloatTensor, - timestep: float | torch.FloatTensor, - sample: torch.FloatTensor, - s_churn: float = 0.0, - s_tmin: float = 0.0, - s_tmax: float = float("inf"), - s_noise: float = 1.0, - generator: torch.Generator | None = None, - per_token_timesteps: torch.Tensor | None = None, - return_dict: bool = True, - noise_level: float = 0.7, - prev_sample: torch.FloatTensor | None = None, - sde_type: Literal["sde", "cps"] = "sde", - return_logprobs: bool = True, - ) -> FlowMatchSDEDiscreteSchedulerOutputForTest | tuple: - """ - Predict the sample from the previous timestep by reversing the SDE. This function propagates the diffusion - process from the learned model outputs (most often the predicted noise). - - Modified from https://github.com/yifan123/flow_grpo/blob/main/flow_grpo/diffusers_patch/sd3_sde_with_logprob.py - - Args: - model_output (`torch.FloatTensor`): - The direct output from learned diffusion model. - timestep (`float`): - The current discrete timestep in the diffusion chain. - sample (`torch.FloatTensor`): - A current instance of a sample created by the diffusion process. - s_churn (`float`): - s_tmin (`float`): - s_tmax (`float`): - s_noise (`float`, defaults to 1.0): - Scaling factor for noise added to the sample. - generator (`torch.Generator`, *optional*): - A random number generator. - per_token_timesteps (`torch.Tensor`, *optional*): - The timesteps for each token in the sample. - return_dict (`bool`): - Whether or not to return a - [`~schedulers.scheduling_flow_match_euler_discrete.FlowMatchSDEDiscreteSchedulerOutput`] or tuple. - noise_level (`float`, *optional*, defaults to 0.7): - The noise level used in the SDE. - prev_sample (`torch.FloatTensor`, *optional*): - The sample from the previous timestep. If not provided, it will be sampled inside the function. - sde_type (`str`, *optional*, defaults to "sde"): - The type of SDE to use. Choose between "sde" and "cps". - return_logprobs (`bool`, *optional*, defaults to True): - Whether to return log probabilities of the previous sample. - """ - - if isinstance(timestep, (int, torch.IntTensor, torch.LongTensor)): - raise ValueError( - ( - "Passing integer indices (e.g. from `enumerate(timesteps)`) as timesteps to" - " `FlowMatchEulerDiscreteScheduler.step()` is not supported. Make sure to pass" - " one of the `scheduler.timesteps` as a timestep." - ), - ) - - if self.step_index is None: - self._init_step_index(timestep) - - # Upcast to avoid precision issues when computing prev_sample - sample = sample.to(torch.float32) - if prev_sample is not None: - prev_sample = prev_sample.to(torch.float32) - - prev_sample, log_prob, prev_sample_mean, std_dev_t = self.sample_previous_step( - sample=sample, - model_output=model_output, - generator=generator, - per_token_timesteps=per_token_timesteps, - noise_level=noise_level, - prev_sample=prev_sample, - sde_type=sde_type, - return_logprobs=return_logprobs, - ) - - # upon completion increase step index by one - self._step_index += 1 - if per_token_timesteps is None: - # Cast sample back to model compatible dtype - prev_sample = prev_sample.to(model_output.dtype) - - if not return_dict: - return (prev_sample, log_prob, prev_sample_mean, std_dev_t) - - return FlowMatchSDEDiscreteSchedulerOutputForTest( - prev_sample=prev_sample, log_prob=log_prob, prev_sample_mean=prev_sample_mean, std_dev_t=std_dev_t - ) - - def sample_previous_step( - self, - sample: torch.Tensor, - model_output: torch.Tensor, - timestep: torch.FloatTensor | None = None, - generator: torch.Generator | None = None, - per_token_timesteps: torch.Tensor | None = None, - noise_level: float = 0.7, - prev_sample: torch.Tensor | None = None, - sde_type: Literal["cps", "sde"] = "sde", - return_logprobs: bool = True, - ): - assert sde_type in ["sde", "cps"] - assert sample.dtype == torch.float32 - if prev_sample is not None: - assert prev_sample.dtype == torch.float32 - - if per_token_timesteps is not None: - raise NotImplementedError("per_token_timesteps is not supported yet for FlowMatchSDEDiscreteScheduler.") - else: - if timestep is None: - sigma_idx = self.step_index - sigma = self.sigmas[sigma_idx] - sigma_prev = self.sigmas[sigma_idx + 1] - else: - sigma_idx = torch.tensor([self.index_for_timestep(t) for t in timestep]) - sigma = self.sigmas[sigma_idx].view(-1, *([1] * (len(sample.shape) - 1))) - sigma_prev = self.sigmas[sigma_idx + 1].view(-1, *([1] * (len(sample.shape) - 1))) - - sigma_max = self.sigmas[1] - dt = sigma_prev - sigma - - if sde_type == "sde": - std_dev_t = torch.sqrt(sigma / (1 - torch.where(sigma == 1, sigma_max, sigma))) * noise_level - - prev_sample_mean = ( - sample * (1 + std_dev_t**2 / (2 * sigma) * dt) - + model_output * (1 + std_dev_t**2 * (1 - sigma) / (2 * sigma)) * dt - ) - - if prev_sample is None: - variance_noise = randn_tensor( - model_output.shape, - generator=generator, - device=model_output.device, - dtype=model_output.dtype, - ) - prev_sample = prev_sample_mean + std_dev_t * torch.sqrt(-1 * dt) * variance_noise - - if return_logprobs: - log_prob = ( - -((prev_sample.detach() - prev_sample_mean) ** 2) / (2 * ((std_dev_t * torch.sqrt(-1 * dt)) ** 2)) - - torch.log(std_dev_t * torch.sqrt(-1 * dt)) - - torch.log(torch.sqrt(2 * torch.as_tensor(math.pi))) - ) - else: - log_prob = None - - elif sde_type == "cps": - std_dev_t = sigma_prev * math.sin(noise_level * math.pi / 2) - pred_original_sample = sample - sigma * model_output - noise_estimate = sample + model_output * (1 - sigma) - prev_sample_mean = pred_original_sample * (1 - sigma_prev) + noise_estimate * torch.sqrt( - sigma_prev**2 - std_dev_t**2 - ) - - if prev_sample is None: - variance_noise = randn_tensor( - model_output.shape, - generator=generator, - device=model_output.device, - dtype=model_output.dtype, - ) - prev_sample = prev_sample_mean + std_dev_t * variance_noise - - if return_logprobs: - log_prob = -((prev_sample.detach() - prev_sample_mean) ** 2) - else: - log_prob = None - - # mean along all but batch dimension - log_prob = log_prob.mean(dim=tuple(range(1, log_prob.ndim))) if log_prob is not None else None - return prev_sample, log_prob, prev_sample_mean, std_dev_t diff --git a/tests/e2e/offline_inference/custom_pipeline/worker_extension.py b/tests/e2e/offline_inference/custom_pipeline/worker_extension.py deleted file mode 100644 index 6ed29d5a8..000000000 --- a/tests/e2e/offline_inference/custom_pipeline/worker_extension.py +++ /dev/null @@ -1,77 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -"""Test worker extension that mirrors -``verl_omni.workers.rollout.vllm_rollout.utils.vLLMOmniColocateWorkerExtension`` - -Fidelity goals: - -* ``__new__`` calls ``set_death_signal()`` (Linux ``PR_SET_PDEATHSIG``) so - spawned vLLM workers die with the parent. -* ``__new__`` calls ``VLLMOmniHijackForTest.hijack()`` which monkey-patches - ``vllm_omni.diffusion.lora.manager.DiffusionLoRAManager._load_adapter`` - to accept in-memory LoRA tensors (verbatim port of - ``verl_omni.utils.vllm_omni.utils.VLLMOmniHijack.hijack``). - -The ``verl.utils.vllm.VLLMHijack.hijack()`` call (which patches vLLM's -``LRUCacheWorkerLoRAManager._load_adapter`` for AR/text mode) is -intentionally skipped because (a) ``verl`` is not importable from this -repo and (b) the diffusion test path never reaches that manager. -""" - -from __future__ import annotations - -import ctypes -import logging -import os -import platform -import signal - -from msgspec import field - -from vllm_omni.diffusion.worker.diffusion_worker import CustomPipelineWorkerExtension -from vllm_omni.lora.request import LoRARequest as OmniLoRARequest - -logger = logging.getLogger(__name__) - - -class OmniTensorLoRARequestForTest(OmniLoRARequest): - peft_config: dict = field(default=None) - lora_tensors: dict = field(default=None) - - -def set_death_signal() -> None: - """Verbatim port of ``verl.workers.rollout.vllm_rollout.utils.set_death_signal``.""" - if platform.system() != "Linux": - return - try: - libc = ctypes.CDLL("libc.so.6") - libc.prctl(1, signal.SIGKILL) - if os.getppid() == 1: - os.kill(os.getpid(), signal.SIGKILL) - except Exception: # noqa: BLE001 - # libc.so.6 may not be present (musl, alpine) — best-effort only. - pass - - -# --------------------------------------------------------------------- -# The worker extension itself -# --------------------------------------------------------------------- - - -class vLLMOmniColocateWorkerExtensionForTest(CustomPipelineWorkerExtension): - """Mirror of ``vLLMOmniColocateWorkerExtension`` (verl-omni). - - The production ``__new__`` runs ``set_death_signal`` + ``VLLMOmniHijack.hijack`` - on every vLLM worker process. Replicating both ensures this test - reproduces the same monkey-patched environment. - """ - - def __new__(cls, **kwargs): - set_death_signal() - return super().__new__(cls) - - @staticmethod - def test_extension_name() -> str: - """Return a stable identifier for assertions in unit tests.""" - return "vllm-omni-colocate-worker-extension-for-test" diff --git a/tests/e2e/offline_inference/stage_configs/step_audio2_ci.yaml b/tests/e2e/offline_inference/stage_configs/step_audio2_ci.yaml deleted file mode 100644 index f9dd4f9af..000000000 --- a/tests/e2e/offline_inference/stage_configs/step_audio2_ci.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# Stage config for Step-Audio2 CI tests -# Single Thinker stage (audio/text understanding → text). ASR/text-output -# tests don't need Token2Wav; a two-stage config would deadlock on stage-1 skip. - -# The following config has been verified on 2x 24GB GPU (L4/RTX3090/RTX4090). -stage_args: - - stage_id: 0 - runtime: - process: true - devices: "0" - max_batch_size: 1 - engine_args: - model_stage: thinker - model_arch: StepAudio2ThinkerForConditionalGeneration - tensor_parallel_size: 1 - worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker - scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler - max_model_len: 4096 - max_num_batched_tokens: 4096 - max_num_seqs: 1 - gpu_memory_utilization: 0.8 - skip_mm_profiling: true - enforce_eager: true - trust_remote_code: true - enable_prefix_caching: false - engine_output_type: text # Output text + audio tokens - is_comprehension: true - final_output: true - final_output_type: text - default_sampling_params: - temperature: 0.7 - top_p: 0.9 - top_k: -1 - max_tokens: 256 - seed: 42 - detokenize: true - repetition_penalty: 1.05 diff --git a/tests/e2e/offline_inference/test_audex_s2s.py b/tests/e2e/offline_inference/test_audex_s2s.py new file mode 100644 index 000000000..d24412f79 --- /dev/null +++ b/tests/e2e/offline_inference/test_audex_s2s.py @@ -0,0 +1,262 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Offline E2E test for the Audex cascaded speech-to-speech pipeline. + +One ``audex_s2s`` deployment serves all three official +passes. The routing contract is the hard gate here: text-modality passes +(ASR, chat) finish at stage 0 and must never produce stage-1 audio; the +audio-modality TTS pass streams through the causal speech decoder. +""" + +from __future__ import annotations + +import copy +import os +import re +import time + +import numpy as np +import pytest +from vllm.assets.audio import AudioAsset + +from tests.helpers.mark import hardware_test +from tests.helpers.media import concat_audio +from tests.helpers.runtime import OmniRunner +from tests.helpers.stage_config import get_deploy_config_path +from vllm_omni.model_executor.models.audex.prompt import build_cond_prompt + +MODEL = "nvidia/Nemotron-Labs-Audex-2B" +MODEL_DIR_ENV = "VLLM_OMNI_AUDEX_MODEL_DIR" +SAMPLE_RATE = 16_000 + +ASR_PROMPT = ( + "<|im_start|>user\n\nTranscribe the input speech.<|im_end|>\n<|im_start|>assistant\n" +) + +# Official chat-pass recipe (examples/offline_inference/audex/speech_to_speech.py, +# mirroring the official cascaded web demo) — 30B-A3B ONLY. The +# formatting-instruction prefix is load-bearing on the 30B-A3B: without it a bare +# transcript turn is treated as a TTS request and the model emits literal +# tokens instead of text, and under the yaml's near-greedy +# defaults its reasoning block never closes. The 2B must NOT use this recipe: +# it is long-verified stable on a bare prompt + engine-default sampling, while +# the official temp-1.0 seed-0 draw hallucinates off-language text (yet closes +# cleanly, so the seed retry never fires) and fails the round-trip +# WER gate — measured, reproduced byte-identically. +SYSTEM_PROMPT = "You are a helpful and harmless assistant.\n\nYou are not allowed to use any tools." +TEXT_INSTRUCTION = ( + "SYSTEM & FORMATTING INSTRUCTION: You are Audex, created by NVIDIA based on the Nemotron-Cascade-2 architecture. " + "You may output your reasoning, followed by your final response. " + "You may format your reasoning block however you like. " + "However, your final response must strictly follow these rules: " + "* Write in plain, unformatted prose like a book or newspaper article. " + "* Do not use markdown, bullet points, lists, or headers. " + "* Standard numbers, abbreviations, and symbols are acceptable. " + "* [CRITICAL] You must press enter after every single sentence, placing each sentence on its own separate line." +) +CHAT_MAX_TOKENS = 2048 # official --text-max-tokens default +# The official demo samples this pass unseeded; some trajectories deliberate +# past the token budget without ever closing the think block, so retry a few +# deterministic seeds until one closes. +CHAT_SEEDS = (0, 1, 2) + +_audex_deployment = get_deploy_config_path("audex_s2s.yaml") +_audex_model = os.environ.get(MODEL_DIR_ENV) or MODEL +_OMNI_RUNNER_PARAMS = [ + pytest.param( + (_audex_model, _audex_deployment, {"async_chunk": True}), + id="s2s_full", + ), +] +# 30B-A3B variant: opt-in (pulls ~60 GB); enable with AUDEX_E2E_30B=1. +_AUDEX_30B_MODEL = os.environ.get("VLLM_OMNI_AUDEX_30B_MODEL_DIR") or "nvidia/Nemotron-Labs-Audex-30B-A3B" +if os.environ.get("AUDEX_E2E_30B") == "1": + _OMNI_RUNNER_PARAMS.append( + pytest.param( + (_AUDEX_30B_MODEL, get_deploy_config_path("audex_s2s_30b.yaml"), {"async_chunk": True}), + id="s2s_30b", + ) + ) +pytestmark = [ + pytest.mark.slow, + pytest.mark.tts, + pytest.mark.parametrize("omni_runner", _OMNI_RUNNER_PARAMS, indirect=True), +] + + +def _normalize(text: str) -> list[str]: + return "".join(c.lower() if c.isalnum() or c.isspace() else " " for c in text).split() + + +def _wer(hyp: str, ref: str) -> float: + hyp_words, ref_words = _normalize(hyp), _normalize(ref) + if not ref_words: + return 0.0 + rows = list(range(len(hyp_words) + 1)) + for i, ref_word in enumerate(ref_words, 1): + prev, rows[0] = rows[0], i + for j, hyp_word in enumerate(hyp_words, 1): + cur = min(rows[j] + 1, rows[j - 1] + 1, prev + (ref_word != hyp_word)) + prev, rows[j] = rows[j], cur + return rows[-1] / len(ref_words) + + +def _chat_prompt(user_text: str) -> str: + # Official chat-pass recipe: system prompt + formatting instruction + # prefixed to the user text, thinking ENABLED via an open block + # (chat_template.jinja with enable_thinking=True renders + # "<|im_start|>assistant\n\n"). Priming the block open keeps the + # reasoning inside ... so _extract_final_response can drop + # it; with a closed block the 30B leaks the reasoning as plain text. + return ( + f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n" + f"<|im_start|>user\n{TEXT_INSTRUCTION}\n\n{user_text}<|im_end|>\n" + "<|im_start|>assistant\n\n" + ) + + +def _extract_final_response(text: str) -> str: + # Mirror the official demo's extract_spoken_answer: keep only what + # follows the LAST closing think tag, falling back to the full text when + # the model skipped reasoning or never closed the block. Benign no-op for + # the 2B, whose answers carry no think block. + if "" in text: + answer = text.rsplit("", 1)[-1].strip() + if answer: + return answer + return text.strip() + + +def _chat_params(runner: OmniRunner, seed: int): + # Official demo text-pass sampling (--text-temperature 1.0, --text-top-p + # 0.95, --text-max-tokens 2048). The yaml stage-0 defaults (temperature + # 0.1, top_k 80) are TTS-thinker settings; under near-greedy decoding the + # 30B loops inside its reasoning block until the token budget runs out + # and never emits . + params = copy.deepcopy(runner.omni.resolve_sampling_params_list(None)) + stage0 = params[0] + stage0.temperature = 1.0 + stage0.top_p = 0.95 + stage0.top_k = -1 + stage0.max_tokens = CHAT_MAX_TOKENS + stage0.seed = seed + return params + + +def _chat_once(runner: OmniRunner, prompt: str, seed: int) -> tuple[str, bool]: + (req_output,) = runner.omni.generate([{"prompt": prompt, "modalities": ["text"]}], _chat_params(runner, seed)) + out = req_output.outputs[0] + text = (out.text or "").strip() + truncated = getattr(out, "finish_reason", None) == "length" or len(out.token_ids or []) >= CHAT_MAX_TOKENS + return text, truncated + + +@hardware_test(res={"cuda": "H100"}, num_cards=1) +def test_audex_s2s_cascade_round_trip(omni_runner: OmniRunner, run_level: str) -> None: + """ASR → chat → TTS over one deployment; text passes never emit audio.""" + real_weights = run_level in {"advanced_model", "full_model"} + # Spoken question: vLLM's public audio asset (downloaded on first use). + audio, sr = AudioAsset("mary_had_lamb").audio_and_sample_rate + + # Pass 1 — ASR (text-final). + asr_outputs = omni_runner.omni.generate( + [ + { + "prompt": ASR_PROMPT, + "multi_modal_data": {"audio": (audio, sr)}, + "modalities": ["text"], + } + ] + ) + assert len(asr_outputs) == 1 + asr_out = asr_outputs[0] + transcript = (asr_out.outputs[0].text or "").strip() + mm = getattr(asr_out, "multimodal_output", None) or {} + stage1_audio = concat_audio(mm.get("audio")) if "audio" in mm else np.zeros((0,), dtype=np.float32) + assert stage1_audio.size == 0, "Text-modality ASR pass produced stage-1 audio (routing regression)" + + # Pass 2 — chat (text-final). The recipe is size-conditional (see the + # SYSTEM_PROMPT comment above): the 30B requires the official recipe, + # the 2B regresses under it. + if omni_runner.model_name == _AUDEX_30B_MODEL: + # Official recipe. The raw completion is + # ; speak only the final response. + # Retry with the next seed only when the budget ran out before the + # reasoning block closed (a natural stop without is a + # complete answer); if every seed spins, keep the full text + # (official demo behavior). + chat_prompt = _chat_prompt(transcript or "Hello.") + raw = "" + for seed in CHAT_SEEDS: + raw, truncated = _chat_once(omni_runner, chat_prompt, seed) + if "" in raw or not truncated: + break + answer = _extract_final_response(raw) + else: + # 2B: bare prompt with a closed think block, engine-default sampling + # (yaml near-greedy), no retry; the answer is used as-is. + chat_prompt = f"<|im_start|>user\n{transcript or 'Hello.'}<|im_end|>\n<|im_start|>assistant\n" + (chat_output,) = omni_runner.omni.generate([{"prompt": chat_prompt, "modalities": ["text"]}]) + answer = (chat_output.outputs[0].text or "").strip() + + if real_weights: + assert transcript, "ASR pass produced an empty transcript" + assert answer, "Chat pass produced an empty answer" + + # Pass 3 — TTS (audio-final; streams through code2wav). Both final + # stages may emit an output record; collect the audio-bearing one. + # The formatting instruction puts every sentence on its own line; + # flatten to plain prose for the TTS thinker. + tts_text = re.sub(r"\s+", " ", answer).strip() if answer else "The weather is nice today." + tts_text = tts_text[:200] + tts_prompt = {"prompt": build_cond_prompt(tts_text), "modalities": ["audio"]} + tts_outputs = omni_runner.omni.generate([tts_prompt]) + assert tts_outputs, "TTS pass returned no outputs" + speech = np.zeros((0,), dtype=np.float32) + for req_output in tts_outputs: + mm = getattr(req_output, "multimodal_output", None) or {} + if "audio" in mm: + speech = concat_audio(mm["audio"]) + break + assert speech.size > 0, "TTS pass produced empty audio" + + if real_weights: + rms = float(np.sqrt(np.mean(np.square(speech)))) + assert rms > 1e-3, f"TTS-pass audio near-silent (rms={rms})" + + # Pass 4 — output verification: transcribe the synthesized answer + # with the SAME deployment's ASR pass and compare to the answer + # text (self-contained; no external ASR dependency). + verify_outputs = omni_runner.omni.generate( + [ + { + "prompt": ASR_PROMPT, + "multi_modal_data": {"audio": (speech, SAMPLE_RATE)}, + "modalities": ["text"], + } + ] + ) + verify_text = (verify_outputs[0].outputs[0].text or "").strip() + assert verify_text, "Output-verification ASR pass returned empty text" + # The full checkpoint prefixes transcripts with a language-tag + # sentence and quotes the content; extract the quoted payload. + if "'" in verify_text and len(verify_text.split("'")) >= 3: + verify_text = verify_text.split("'")[1].strip() + wer = _wer(verify_text, tts_text) + # Hard gate: the synthesized WAV must transcribe back to the answer + # (lenient threshold absorbs ASR quoting/prefix differences). + assert wer <= 0.5, f"Output WAV does not match the answer (WER={wer:.2f}): {verify_text!r} vs {tts_text!r}" + print(f"[trend] Audex S2S output-ASR WER vs answer: {wer:.3f} ({verify_text!r})") + + # Final pass — time-to-first-audio for the streaming TTS path. This + # MUST be the last engine call: the py_generator API closes the engine + # when the generator is exhausted. + t_start = time.perf_counter() + first_audio_s: float | None = None + for req_output in omni_runner.omni.generate([tts_prompt], py_generator=True): + mm = getattr(req_output, "multimodal_output", None) or {} + if "audio" in mm and concat_audio(mm["audio"]).size > 0: + if first_audio_s is None: + first_audio_s = time.perf_counter() - t_start + assert first_audio_s is not None, "streaming TTS never produced an audio-bearing chunk" + print(f"[trend] Audex S2S TTS-pass first-audio latency: {first_audio_s * 1000.0:.0f} ms") diff --git a/tests/e2e/offline_inference/test_audex_s2t.py b/tests/e2e/offline_inference/test_audex_s2t.py new file mode 100644 index 000000000..230ab6e02 --- /dev/null +++ b/tests/e2e/offline_inference/test_audex_s2t.py @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Offline E2E smoke test for Audex audio understanding (thinker-only). + +Single-stage ``audex_thinker_only`` pipeline on +``checkpoint_folder_full``: WAV (+ instruction) in, text out. The input clip +is vLLM's public ``mary_had_lamb`` audio asset (downloaded on first use), so +its transcript is known. + +WER is a trend metric (recorded in the assertion message, no numeric hard +gate); the hard gates are structural: non-empty, non-degenerate text. +""" + +from __future__ import annotations + +import os + +import pytest +from vllm.assets.audio import AudioAsset + +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniRunner +from tests.helpers.stage_config import get_deploy_config_path + +MODEL = "nvidia/Nemotron-Labs-Audex-2B" +MODEL_DIR_ENV = "VLLM_OMNI_AUDEX_MODEL_DIR" + +# Full transcript of the mary_had_lamb asset (Edison's phonograph recording); +# a shortened reference would count the extra spoken words as insertions and +# inflate WER past the trend threshold. +_REFERENCE = ( + "the first words i spoke in the original phonograph a little piece of practical poetry " + "mary had a little lamb its fleece was white as snow and everywhere that mary went the " + "lamb was sure to go" +) + +ASR_PROMPT = ( + "<|im_start|>user\n\nTranscribe the input speech.<|im_end|>\n<|im_start|>assistant\n" +) + +_audex_deployment = get_deploy_config_path("audex_thinker_only.yaml") +_audex_model = os.environ.get(MODEL_DIR_ENV) or MODEL +_OMNI_RUNNER_PARAMS = [ + pytest.param( + (_audex_model, _audex_deployment, {"async_chunk": False}), + id="thinker_only", + ), +] +# 30B-A3B variant: opt-in (pulls ~60 GB); enable with AUDEX_E2E_30B=1. +_AUDEX_30B_MODEL = os.environ.get("VLLM_OMNI_AUDEX_30B_MODEL_DIR") or "nvidia/Nemotron-Labs-Audex-30B-A3B" +if os.environ.get("AUDEX_E2E_30B") == "1": + _OMNI_RUNNER_PARAMS.append( + pytest.param( + (_AUDEX_30B_MODEL, get_deploy_config_path("audex_thinker_only_30b.yaml"), {"async_chunk": False}), + id="thinker_only_30b", + ) + ) +pytestmark = [ + pytest.mark.slow, + pytest.mark.tts, + pytest.mark.parametrize("omni_runner", _OMNI_RUNNER_PARAMS, indirect=True), +] + + +def _normalize(text: str) -> list[str]: + return "".join(c.lower() if c.isalnum() or c.isspace() else " " for c in text).split() + + +def _wer(hyp: str, ref: str) -> float: + hyp_words, ref_words = _normalize(hyp), _normalize(ref) + if not ref_words: + return 0.0 + rows = list(range(len(hyp_words) + 1)) + for i, ref_word in enumerate(ref_words, 1): + prev, rows[0] = rows[0], i + for j, hyp_word in enumerate(hyp_words, 1): + cur = min( + rows[j] + 1, + rows[j - 1] + 1, + prev + (ref_word != hyp_word), + ) + prev, rows[j] = rows[j], cur + return rows[-1] / len(ref_words) + + +@pytest.mark.advanced_model +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_audex_offline_asr_smoke(omni_runner: OmniRunner, run_level: str) -> None: + """Transcribing the public audio asset yields coherent, non-degenerate text.""" + audio, sr = AudioAsset("mary_had_lamb").audio_and_sample_rate + outputs = omni_runner.omni.generate([{"prompt": ASR_PROMPT, "multi_modal_data": {"audio": (audio, sr)}}]) + + assert len(outputs) == 1 + text = outputs[0].outputs[0].text if outputs[0].outputs else "" + assert isinstance(text, str) + + if run_level in {"advanced_model", "full_model"}: + words = _normalize(text) + assert words, f"Empty transcription: {text!r}" + # Degenerate repetition guard: no word may dominate the output. + most_common = max(words.count(w) for w in set(words)) + assert most_common <= max(3, len(words) // 2), f"Degenerate transcript: {text!r}" + wer = _wer(text, _REFERENCE) + # Trend metric (recorded, not gated): substantially-correct check only. + assert wer <= 0.9, f"Transcript unrelated to reference (WER={wer:.2f}): {text!r}" + print(f"[trend] Audex S2T WER vs reference: {wer:.3f} ({text!r})") diff --git a/tests/e2e/offline_inference/test_audex_tta.py b/tests/e2e/offline_inference/test_audex_tta.py new file mode 100644 index 000000000..d23c9c36b --- /dev/null +++ b/tests/e2e/offline_inference/test_audex_tta.py @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Offline E2E smoke test for Audex text-to-audio (TTA). + +Caption → RVQ-phase-masked tokens → XCodec1 waveform through +the ``audex_tta`` pipeline. RVQ phase validity is enforced by +the mask logits processor (unit-tested against the official validator); the +hard gates here are decode-side: non-silent, finite audio at the documented +sample rate. CFG runs at the official TTA default (3.0). +""" + +from __future__ import annotations + +import copy +import os + +import numpy as np +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.media import concat_audio +from tests.helpers.runtime import OmniRunner +from tests.helpers.stage_config import get_deploy_config_path +from vllm_omni.model_executor.models.audex.prompt import build_tta_cond_prompt + +MODEL = "nvidia/Nemotron-Labs-Audex-2B" +MODEL_DIR_ENV = "VLLM_OMNI_AUDEX_MODEL_DIR" +SAMPLE_RATE = 16_000 + +# The acceptance gate requires eight captions decoding to non-silent, +# finite audio through XCodec1. +CAPTIONS = ( + "Heavy rain falling on a tin roof.", + "A dog barking in the distance while birds chirp.", + "Ocean waves crashing on a rocky shore.", + "A crackling campfire at night with crickets.", + "Thunder rumbling during a storm.", + "A steam train whistle blowing as it departs.", + "Wind chimes ringing in a gentle breeze.", + "Footsteps crunching through fresh snow.", +) + +_audex_deployment = get_deploy_config_path("audex_tta.yaml") +_audex_model = os.environ.get(MODEL_DIR_ENV) or MODEL +_OMNI_RUNNER_PARAMS = [ + pytest.param( + (_audex_model, _audex_deployment, {"async_chunk": False}), + id="tta_full_payload", + ), +] +# 30B-A3B variant: opt-in (pulls ~60 GB); enable with AUDEX_E2E_30B=1. +_AUDEX_30B_MODEL = os.environ.get("VLLM_OMNI_AUDEX_30B_MODEL_DIR") or "nvidia/Nemotron-Labs-Audex-30B-A3B" +if os.environ.get("AUDEX_E2E_30B") == "1": + _OMNI_RUNNER_PARAMS.append( + pytest.param( + (_AUDEX_30B_MODEL, get_deploy_config_path("audex_tta_30b.yaml"), {"async_chunk": False}), + id="tta_30b", + ) + ) +pytestmark = [ + pytest.mark.slow, + pytest.mark.tts, + pytest.mark.parametrize("omni_runner", _OMNI_RUNNER_PARAMS, indirect=True), +] + + +@pytest.fixture(scope="module") +def tta_tokenizer_bits(): + from transformers import AutoTokenizer + + from vllm_omni.model_executor.models.audex.checkpoint import ensure_audex_snapshot + from vllm_omni.model_executor.models.audex.tta import build_tta_phase_token_ids + + root = ensure_audex_snapshot(_audex_model, profile="tta") + tokenizer = AutoTokenizer.from_pretrained(os.path.join(root, "checkpoint_folder_audiogen"), trust_remote_code=True) + phase_token_ids, start_tid, end_tid = build_tta_phase_token_ids(tokenizer) + return tokenizer, phase_token_ids, start_tid, end_tid + + +def _tta_sampling_params(runner: OmniRunner, cond_prompt: str, tokenizer_bits, cfg_scale: float = 3.0): + from vllm_omni.model_executor.models.audex.prompt import build_tta_null_prompt + + tokenizer, phase_token_ids, start_tid, end_tid = tokenizer_bits + params = copy.deepcopy(runner.omni.resolve_sampling_params_list(None)) + stage0 = params[0] + if stage0.extra_args is None: + stage0.extra_args = {} + stage0.extra_args["tta_rvq"] = { + "phase_token_ids": phase_token_ids, + "start_tid": start_tid, + "end_tid": end_tid, + "codec_cap": 4000, + "start_in_prompt": True, + } + if cfg_scale > 1.0: + stage0.extra_args.update( + { + "cfg_scale": float(cfg_scale), + "cfg_role": "cond", + "cfg_pair_id": f"e2e-tta-{abs(hash(cond_prompt)) % 10_000_000}", + "cfg_null_prompt": build_tta_null_prompt(cond_prompt, tokenizer), + } + ) + return params + + +@pytest.mark.advanced_model +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_audex_offline_tta_eight_captions(omni_runner: OmniRunner, run_level: str, tta_tokenizer_bits) -> None: + """All eight captions decode through XCodec1 to non-silent, finite audio. + + RVQ phase validity is enforced by the mask logits processor by + construction; the final user output carries only decoded audio (the + audio-final pipeline emits no stage-0 token record), and disabling the + mask in-engine as an e2e control would require a second engine boot + with a different logits-processor config. The mask's sensitivity is + therefore proven at unit level (see test_audex_tta.py's mask tests and + the unmasked-sampling control) against the official validator. + """ + real_weights = run_level in {"advanced_model", "full_model"} + for caption in CAPTIONS: + prompt = build_tta_cond_prompt(caption) + params = _tta_sampling_params(omni_runner, prompt, tta_tokenizer_bits) + outputs = omni_runner.omni.generate([prompt], params) + + assert len(outputs) == 1, f"{caption!r}: expected one output, got {len(outputs)}" + audio_mm = outputs[0].multimodal_output + assert "audio" in audio_mm, f"{caption!r}: no audio output: {list(audio_mm.keys())}" + audio = concat_audio(audio_mm["audio"]) + assert audio.size > 0, f"{caption!r}: generated audio is empty" + assert np.isfinite(audio).all(), f"{caption!r}: audio contains NaN/Inf" + + sr_val = audio_mm.get("sr", SAMPLE_RATE) + if isinstance(sr_val, list) and sr_val: + sr_val = sr_val[-1] + if hasattr(sr_val, "item"): + sr_val = sr_val.item() + assert int(sr_val) == SAMPLE_RATE, f"{caption!r}: unexpected sample_rate={sr_val}" + + if real_weights: + duration_s = audio.size / SAMPLE_RATE + # The decode cap is 500 frames = 10 s at 50 fps. + assert 0.1 <= duration_s <= 10.5, f"{caption!r}: unexpected duration={duration_s:.3f}s" + rms = float(np.sqrt(np.mean(np.square(audio)))) + assert rms > 1e-4, f"{caption!r}: audio is near-silent (rms={rms})" diff --git a/tests/e2e/offline_inference/test_audex_tts.py b/tests/e2e/offline_inference/test_audex_tts.py new file mode 100644 index 000000000..e186c22d1 --- /dev/null +++ b/tests/e2e/offline_inference/test_audex_tts.py @@ -0,0 +1,161 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Offline E2E smoke test for Audex (Nemotron-Labs-Audex-2B) TTS. + +Passes the HF repo ROOT and verifies the 2-stage pipeline (thinker → +streaming causal speech decoder): root-path/subfolder resolution, codec-token +extraction, per-request streaming sessions, and the lookahead flush at EOS. +""" + +from __future__ import annotations + +import os + +import numpy as np +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.media import concat_audio +from tests.helpers.runtime import OmniRunner +from tests.helpers.stage_config import get_deploy_config_path +from vllm_omni.model_executor.models.audex.prompt import build_cond_prompt + +MODEL = "nvidia/Nemotron-Labs-Audex-2B" +MODEL_DIR_ENV = "VLLM_OMNI_AUDEX_MODEL_DIR" +SAMPLE_RATE = 16_000 + +SYNTH_TEXTS = ( + "The weather is so good, and I want to enjoy the beautiful morning in the park.", + "The quick brown fox jumps over the lazy dog.", +) + + +_audex_deployment = get_deploy_config_path("audex_tts.yaml") +# Collection must not download anything: pass the repo id (or a local +# override) and let the engine's stage-init path resolve/download the +# required snapshot subset at execution time (ensure_audex_snapshot). +_audex_model = os.environ.get(MODEL_DIR_ENV) or MODEL +_OMNI_RUNNER_PARAMS = [ + pytest.param( + (_audex_model, _audex_deployment, {"async_chunk": True}), + id="async_chunk", + ), +] +# 30B-A3B variant: opt-in (pulls ~60 GB); enable with AUDEX_E2E_30B=1. +_AUDEX_30B_MODEL = os.environ.get("VLLM_OMNI_AUDEX_30B_MODEL_DIR") or "nvidia/Nemotron-Labs-Audex-30B-A3B" +if os.environ.get("AUDEX_E2E_30B") == "1": + _OMNI_RUNNER_PARAMS.append( + pytest.param( + (_AUDEX_30B_MODEL, get_deploy_config_path("audex_tts_30b.yaml"), {"async_chunk": True}), + id="tts_30b", + ) + ) +pytestmark = [ + pytest.mark.slow, + pytest.mark.tts, + pytest.mark.parametrize("omni_runner", _OMNI_RUNNER_PARAMS, indirect=True), +] + + +@pytest.mark.advanced_model +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_audex_offline_tts_smoke(omni_runner: OmniRunner, run_level: str) -> None: + """Audex TTS from the repo root should produce sane 16 kHz audio per prompt. + + At ``core_model`` level the runner loads DUMMY weights (structural smoke + only); real-speech assertions apply from ``advanced_model`` up. + """ + prompts = [build_cond_prompt(text) for text in SYNTH_TEXTS] + outputs = omni_runner.omni.generate(prompts) + + real_weights = run_level in {"advanced_model", "full_model"} + assert len(outputs) == len(SYNTH_TEXTS), f"expected {len(SYNTH_TEXTS)} outputs, got {len(outputs)}" + for output in outputs: + audio_mm = output.multimodal_output + assert "audio" in audio_mm, f"No audio output found: {list(audio_mm.keys())}" + audio = concat_audio(audio_mm["audio"]) + assert audio.size > 0, "Generated audio is empty" + + sr_val = audio_mm.get("sr", SAMPLE_RATE) + if isinstance(sr_val, list) and sr_val: + sr_val = sr_val[-1] + if hasattr(sr_val, "item"): + sr_val = sr_val.item() + assert int(sr_val) == SAMPLE_RATE, f"Unexpected sample_rate={sr_val}" + + if real_weights: + duration_s = audio.size / SAMPLE_RATE + assert 0.5 <= duration_s <= 20.0, f"Unexpected duration={duration_s:.3f}s" + # Real speech, not silence. + rms = float(np.sqrt(np.mean(np.square(audio)))) + assert rms > 1e-3, f"Audio is near-silent (rms={rms})" + + +def _cfg_sampling_params(runner: OmniRunner, cfg_scale: float, pair_id: str, cond_prompt: str): + """Stage sampling params carrying the CFG pair contract for one request.""" + import copy + + from transformers import AutoTokenizer + + from vllm_omni.model_executor.models.audex.checkpoint import ensure_audex_snapshot + from vllm_omni.model_executor.models.audex.prompt import build_null_prompt + + # Resolve the ACTIVE param's model (2B or 30B), not the module default: + # the null prompt must be built with the same tokenizer the engine uses. + root = ensure_audex_snapshot(runner.model_name) + tokenizer = AutoTokenizer.from_pretrained(os.path.join(root, "checkpoint_folder_audiogen"), trust_remote_code=True) + params = copy.deepcopy(runner.omni.resolve_sampling_params_list(None)) + stage0 = params[0] + if stage0.extra_args is None: + stage0.extra_args = {} + stage0.extra_args.update( + { + "cfg_scale": float(cfg_scale), + "cfg_role": "cond", + "cfg_pair_id": pair_id, + "cfg_null_prompt": build_null_prompt(cond_prompt, tokenizer), + } + ) + return params + + +@pytest.mark.advanced_model +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_audex_offline_cfg_guided_single_stream(omni_runner: OmniRunner, run_level: str) -> None: + """A guided request must yield exactly ONE audio stream (companion suppressed).""" + prompt = build_cond_prompt(SYNTH_TEXTS[0]) + params = _cfg_sampling_params(omni_runner, 1.5, "e2e-cfg-pair", prompt) + outputs = omni_runner.omni.generate([prompt], params) + + assert len(outputs) == 1, f"expected exactly one output (no companion leak), got {len(outputs)}" + audio = concat_audio(outputs[0].multimodal_output["audio"]) + assert audio.size > 0, "Guided generation produced empty audio" + if run_level in {"advanced_model", "full_model"}: + rms = float(np.sqrt(np.mean(np.square(audio)))) + assert rms > 1e-3, f"Guided audio is near-silent (rms={rms})" + duration_s = audio.size / SAMPLE_RATE + assert 0.5 <= duration_s <= 20.0, f"Unexpected duration={duration_s:.3f}s" + + +@pytest.mark.advanced_model +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_audex_offline_cfg_disabled_is_byte_identical(omni_runner: OmniRunner, run_level: str) -> None: + """cfg_scale=1.0 must be indistinguishable from not passing cfg at all.""" + prompt = build_cond_prompt(SYNTH_TEXTS[1]) + + baseline = omni_runner.omni.generate([prompt]) + base_audio = concat_audio(baseline[0].multimodal_output["audio"]) + + import copy + + params = copy.deepcopy(omni_runner.omni.resolve_sampling_params_list(None)) + if params[0].extra_args is None: + params[0].extra_args = {} + params[0].extra_args["cfg_scale"] = 1.0 + disabled = omni_runner.omni.generate([prompt], params) + disabled_audio = concat_audio(disabled[0].multimodal_output["audio"]) + + assert base_audio.shape == disabled_audio.shape, ( + f"cfg_scale=1.0 changed output length: {base_audio.shape} vs {disabled_audio.shape}" + ) + np.testing.assert_array_equal(base_audio, disabled_audio) diff --git a/tests/e2e/offline_inference/test_covo_audio_expansion.py b/tests/e2e/offline_inference/test_covo_audio_expansion.py index ca05932ef..11672686e 100644 --- a/tests/e2e/offline_inference/test_covo_audio_expansion.py +++ b/tests/e2e/offline_inference/test_covo_audio_expansion.py @@ -14,7 +14,7 @@ from vllm_omni.model_executor.models.covo_audio.prompt_utils import ( models = ["tencent/Covo-Audio-Chat"] stage_config = get_deploy_config_path("covo_audio.yaml") -test_params = [(model, stage_config) for model in models] +test_params = [(model, stage_config, {"trust_remote_code": True}) for model in models] def get_question(): diff --git a/tests/e2e/offline_inference/test_dynin_omni_expansion.py b/tests/e2e/offline_inference/test_dynin_omni_expansion.py index 13b9e5cb3..19a847332 100644 --- a/tests/e2e/offline_inference/test_dynin_omni_expansion.py +++ b/tests/e2e/offline_inference/test_dynin_omni_expansion.py @@ -29,7 +29,7 @@ _DEFAULT_STAGE_CONFIG_PATH = Path(get_deploy_config_path("dynin_omni_ci.yaml")) models = ["snu-aidas/Dynin-Omni"] stage_configs = [str(_DEFAULT_STAGE_CONFIG_PATH)] -test_params = [(model, stage_config) for model in models for stage_config in stage_configs] +test_params = [(model, stage_config, {"trust_remote_code": True}) for model in models for stage_config in stage_configs] DYNIN_CONFIG_PATH = str(_DEFAULT_DYNIN_CONFIG_PATH) if _DEFAULT_DYNIN_CONFIG_PATH is not None else None diff --git a/tests/e2e/offline_inference/test_glm_tts_expansion.py b/tests/e2e/offline_inference/test_glm_tts_expansion.py index d6a8729f0..e54dd5480 100644 --- a/tests/e2e/offline_inference/test_glm_tts_expansion.py +++ b/tests/e2e/offline_inference/test_glm_tts_expansion.py @@ -98,6 +98,7 @@ def test_offline_voice_clone_zh(async_chunk: bool) -> None: MODEL, stage_configs_path=_get_deploy_config(async_chunk=async_chunk), stage_init_timeout=600, + trust_remote_code=True, ) as omni_runner: outputs = omni_runner.omni.generate( [ diff --git a/tests/e2e/offline_inference/test_hunyuanimage3.py b/tests/e2e/offline_inference/test_hunyuanimage3.py index ac1cb13cb..ebd338fe8 100644 --- a/tests/e2e/offline_inference/test_hunyuanimage3.py +++ b/tests/e2e/offline_inference/test_hunyuanimage3.py @@ -275,6 +275,7 @@ def omni() -> Generator[Omni, None, None]: MODEL_NAME, deploy_config=str(DEPLOY_CONFIG_PATH), mode="text-to-image", + trust_remote_code=True, ) as runner: yield runner.omni diff --git a/tests/e2e/offline_inference/test_hunyuanimage3_i2t_expansion.py b/tests/e2e/offline_inference/test_hunyuanimage3_i2t_expansion.py index 5178a40a6..008f0d2a9 100644 --- a/tests/e2e/offline_inference/test_hunyuanimage3_i2t_expansion.py +++ b/tests/e2e/offline_inference/test_hunyuanimage3_i2t_expansion.py @@ -53,6 +53,7 @@ def omni() -> Generator[Omni, None, None]: with OmniRunner( MODEL_NAME, stage_configs_path=str(STAGE_CONFIG_PATH), + trust_remote_code=True, ) as runner: yield runner.omni diff --git a/tests/e2e/offline_inference/test_indextts2.py b/tests/e2e/offline_inference/test_indextts2_expansion.py similarity index 98% rename from tests/e2e/offline_inference/test_indextts2.py rename to tests/e2e/offline_inference/test_indextts2_expansion.py index 6c2786efa..ba5b984f9 100644 --- a/tests/e2e/offline_inference/test_indextts2.py +++ b/tests/e2e/offline_inference/test_indextts2_expansion.py @@ -33,7 +33,7 @@ ASSETS_DIR = Path(__file__).resolve().parents[2] / "assets" / "indextts2" REF_AUDIO = str(ASSETS_DIR / "ref_audio.wav") pytestmark = [ - pytest.mark.advanced_model, + pytest.mark.slow, pytest.mark.tts, pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True), ] diff --git a/tests/e2e/offline_inference/test_krea2_expansion.py b/tests/e2e/offline_inference/test_krea2_expansion.py index f00b4bb0b..a8f63032b 100644 --- a/tests/e2e/offline_inference/test_krea2_expansion.py +++ b/tests/e2e/offline_inference/test_krea2_expansion.py @@ -31,7 +31,7 @@ LORA_PROMPT = f"a fox in the snow, {LORA_TRIGGER}" pytestmark = [ pytest.mark.diffusion, - pytest.mark.full_model, + pytest.mark.slow, ] diff --git a/tests/e2e/offline_inference/test_minicpmo_4_5.py b/tests/e2e/offline_inference/test_minicpmo_4_5.py new file mode 100644 index 000000000..12f24e3dc --- /dev/null +++ b/tests/e2e/offline_inference/test_minicpmo_4_5.py @@ -0,0 +1,118 @@ +""" +E2E offline tests for MiniCPM-o 4.5 model with multimodal input and audio / text output. +""" + +import os + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video +from tests.helpers.stage_config import get_deploy_config_path + +models = ["openbmb/MiniCPM-o-4_5"] + +_CI_DEPLOY = get_deploy_config_path("minicpmo_4_5.yaml") + + +test_params = [(model, None, {"deploy_config": _CI_DEPLOY, "trust_remote_code": True}) for model in models] + + +def get_question(prompt_type: str = "text") -> str: + prompts = { + "text": "What is the capital of China? Answer in 20 words.", + "audio": "Describe the audio briefly.", + "image": "What color are the squares in this image?", + "video": "Describe the video briefly.", + "mix": "Describe what is in the image and audio.", + } + return prompts.get(prompt_type, prompts["text"]) + + +@pytest.mark.core_model +@pytest.mark.advanced_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, 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.""" + request_config = {"prompts": get_question("text"), "modalities": ["text"]} + omni_runner_handler.send_omni_request(request_config) + + +@pytest.mark.full_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, 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.""" + audio = generate_synthetic_audio(1, 1, 16000)["np_array"] + if len(audio.shape) == 2: + audio = audio.squeeze() + request_config = {"prompts": get_question("audio"), "audios": (audio, 16000), "modalities": ["text"]} + omni_runner_handler.send_omni_request(request_config) + + +@pytest.mark.full_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, 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.""" + image = generate_synthetic_image(16, 16)["np_array"] + request_config = {"prompts": get_question("image"), "images": image, "modalities": ["text"]} + omni_runner_handler.send_omni_request(request_config) + + +@pytest.mark.full_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, 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.""" + video = generate_synthetic_video(24, 24, 20)["np_array"] + request_config = {"prompts": get_question("video"), "videos": video, "modalities": ["text"]} + omni_runner_handler.send_omni_request(request_config) + + +@pytest.mark.full_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, 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.""" + request_config = {"prompts": get_question("text"), "modalities": ["audio"]} + omni_runner_handler.send_omni_request(request_config) + + +@pytest.mark.core_model +@pytest.mark.advanced_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, 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.""" + audio = generate_synthetic_audio(1, 1, 16000)["np_array"] + if len(audio.shape) == 2: + audio = audio.squeeze() + image = generate_synthetic_image(16, 16)["np_array"] + request_config = { + "prompts": get_question("mix"), + "audios": (audio, 16000), + "images": image, + "modalities": ["audio"], + } + omni_runner_handler.send_omni_request(request_config) + + +@pytest.mark.full_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, 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.""" + video = generate_synthetic_video(24, 24, 20)["np_array"] + request_config = {"prompts": get_question("video"), "videos": video, "modalities": ["audio"]} + omni_runner_handler.send_omni_request(request_config) diff --git a/tests/e2e/offline_inference/test_moss_tts_expansion.py b/tests/e2e/offline_inference/test_moss_tts_expansion.py index 73e919d1c..81a64fcd9 100644 --- a/tests/e2e/offline_inference/test_moss_tts_expansion.py +++ b/tests/e2e/offline_inference/test_moss_tts_expansion.py @@ -76,7 +76,11 @@ pytestmark = [ pytest.mark.slow, pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4700"), pytest.mark.tts, - pytest.mark.parametrize("omni_runner", [(MODEL, _get_test_config())], indirect=True), + pytest.mark.parametrize( + "omni_runner", + [(MODEL, _get_test_config(), {"trust_remote_code": True})], + indirect=True, + ), ] diff --git a/tests/e2e/offline_inference/test_moss_tts_nano_expansion.py b/tests/e2e/offline_inference/test_moss_tts_nano_expansion.py index 2d247f691..d3e7fcb0f 100644 --- a/tests/e2e/offline_inference/test_moss_tts_nano_expansion.py +++ b/tests/e2e/offline_inference/test_moss_tts_nano_expansion.py @@ -29,6 +29,7 @@ STAGE_CONFIG = get_deploy_config_path("moss_tts_nano.yaml") _OMNI_RUNNER_PARAM = ( MODEL_NAME, STAGE_CONFIG, + {"trust_remote_code": True}, ) pytestmark = [ diff --git a/tests/e2e/offline_inference/test_moss_tts_realtime.py b/tests/e2e/offline_inference/test_moss_tts_realtime.py index 5034f0f1d..892a395ba 100644 --- a/tests/e2e/offline_inference/test_moss_tts_realtime.py +++ b/tests/e2e/offline_inference/test_moss_tts_realtime.py @@ -98,7 +98,11 @@ pytestmark = [ pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4700"), pytest.mark.full_model, pytest.mark.tts, - pytest.mark.parametrize("omni_runner", [(MODEL, _get_test_config())], indirect=True), + pytest.mark.parametrize( + "omni_runner", + [(MODEL, _get_test_config(), {"trust_remote_code": True})], + indirect=True, + ), ] # --------------------------------------------------------------------------- diff --git a/tests/e2e/offline_inference/test_moss_tts_realtime_expansion.py b/tests/e2e/offline_inference/test_moss_tts_realtime_expansion.py index 80b12e2e4..b84e5a0ff 100644 --- a/tests/e2e/offline_inference/test_moss_tts_realtime_expansion.py +++ b/tests/e2e/offline_inference/test_moss_tts_realtime_expansion.py @@ -24,7 +24,11 @@ from tests.helpers.stage_config import get_deploy_config_path MODEL = "OpenMOSS-Team/MOSS-TTS-Realtime" DEPLOY_CONFIG = get_deploy_config_path("moss_tts_realtime.yaml") -_OMNI_RUNNER_PARAM = (MODEL, DEPLOY_CONFIG, {"stage_init_timeout": 300}) +_OMNI_RUNNER_PARAM = ( + MODEL, + DEPLOY_CONFIG, + {"stage_init_timeout": 300, "trust_remote_code": True}, +) pytestmark = [ pytest.mark.slow, diff --git a/tests/e2e/offline_inference/test_moss_tts_v1_5_expansion.py b/tests/e2e/offline_inference/test_moss_tts_v1_5_expansion.py index 43bf80415..25c671e05 100644 --- a/tests/e2e/offline_inference/test_moss_tts_v1_5_expansion.py +++ b/tests/e2e/offline_inference/test_moss_tts_v1_5_expansion.py @@ -22,7 +22,11 @@ from tests.helpers.stage_config import get_deploy_config_path MODEL = "OpenMOSS-Team/MOSS-TTS-v1.5" DEPLOY_CONFIG = get_deploy_config_path("moss_tts.yaml") -_OMNI_RUNNER_PARAM = (MODEL, DEPLOY_CONFIG, {"stage_init_timeout": 600}) +_OMNI_RUNNER_PARAM = ( + MODEL, + DEPLOY_CONFIG, + {"stage_init_timeout": 600, "trust_remote_code": True}, +) pytestmark = [ pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4643"), diff --git a/tests/e2e/offline_inference/test_qwen3_tts_base.py b/tests/e2e/offline_inference/test_qwen3_tts_base.py index ecd769166..a076526a4 100644 --- a/tests/e2e/offline_inference/test_qwen3_tts_base.py +++ b/tests/e2e/offline_inference/test_qwen3_tts_base.py @@ -68,7 +68,7 @@ def get_prompt(): @pytest.mark.advanced_model @pytest.mark.tts -@hardware_test(res={"cuda": "L4"}, num_cards=1) +@hardware_test(res={"cuda": "L4", "xpu": "B60"}, num_cards=1) @pytest.mark.parametrize("omni_runner", tts_server_params, indirect=True) def test_text_to_audio_001(omni_runner, omni_runner_handler) -> None: """ diff --git a/tests/e2e/offline_inference/test_sensenova_u1_img2img_expansion.py b/tests/e2e/offline_inference/test_sensenova_u1_img2img_expansion.py index 242d6a12e..1ac76da28 100644 --- a/tests/e2e/offline_inference/test_sensenova_u1_img2img_expansion.py +++ b/tests/e2e/offline_inference/test_sensenova_u1_img2img_expansion.py @@ -9,13 +9,14 @@ an input image and text prompt that match expected reference pixel values within a ±10 tolerance. Equivalent to running: - python examples/offline_inference/sensenova_u1/end2end.py \ + python examples/offline_inference/image_to_image/image_edit.py \ + --model SenseNova/SenseNova-U1-8B-MoT \ --prompt "Turn this into an oil painting" \ --image 2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg \ - --width 2560 --height 1669 \ - --seed 42 --num-steps 50 \ - --cfg-scale 4.0 --img-cfg-scale 1.0 --cfg-norm none \ - --think --print-think + --resolution 2048 \ + --seed 42 --num-inference-steps 50 \ + --cfg-scale 4.0 \ + --extra-args '{"think": true, "img_cfg_scale": 1.0, "cfg_norm": "none", "timestep_shift": 3.0}' """ import os @@ -33,11 +34,14 @@ from vllm_omni.entrypoints.omni import Omni from vllm_omni.inputs.data import OmniDiffusionSamplingParams # Reference pixel data extracted from the known-good output image generated by: -# python examples/offline_inference/sensenova_u1/end2end.py \ +# python examples/offline_inference/image_to_image/image_edit.py \ +# --model SenseNova/SenseNova-U1-8B-MoT \ # --prompt "Turn this into an oil painting" \ # --image 2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg \ -# --width 2560 --height 1669 --seed 42 --num-steps 50 \ -# --cfg-scale 4.0 --img-cfg-scale 1.0 --cfg-norm none --think +# --width 2560 --height 1669 \ +# --seed 42 --num-inference-steps 50 \ +# --cfg-scale 4.0 \ +# --extra-args '{"think": true, "img_cfg_scale": 1.0, "cfg_norm": "none", "timestep_shift": 3.0}' REFERENCE_PIXELS = [ {"position": (100, 100), "rgb": (95, 131, 174)}, {"position": (400, 50), "rgb": (142, 153, 174)}, diff --git a/tests/e2e/offline_inference/test_sensenova_u1_text2img_expansion.py b/tests/e2e/offline_inference/test_sensenova_u1_text2img_expansion.py index 65b11d3ee..092da16b9 100644 --- a/tests/e2e/offline_inference/test_sensenova_u1_text2img_expansion.py +++ b/tests/e2e/offline_inference/test_sensenova_u1_text2img_expansion.py @@ -30,10 +30,12 @@ from vllm_omni.entrypoints.omni import Omni from vllm_omni.inputs.data import OmniDiffusionSamplingParams # Reference pixel data extracted from the known-good output image generated by: -# python examples/offline_inference/sensenova_u1/end2end.py \ +# python examples/offline_inference/text_to_image/text_to_image.py \ +# --model SenseNova/SenseNova-U1-8B-MoT \ # --prompt "Close portrait of an elderly woman ..." \ -# --width 1536 --height 2720 --seed 42 --num-steps 50 \ -# --cfg-scale 4.0 --timestep-shift 3.0 --cfg-norm none --think +# --width 1536 --height 2720 --seed 42 --num-inference-steps 50 \ +# --cfg-scale 4.0 \ +# --extra-body '{"think": true, "cfg_norm": "none", "timestep_shift": 3.0, "t_eps": 0.02}' REFERENCE_PIXELS = [ {"position": (100, 100), "rgb": (247, 249, 250)}, {"position": (768, 200), "rgb": (176, 135, 97)}, diff --git a/tests/e2e/offline_inference/test_soulxsinger.py b/tests/e2e/offline_inference/test_soulxsinger_expansion.py similarity index 99% rename from tests/e2e/offline_inference/test_soulxsinger.py rename to tests/e2e/offline_inference/test_soulxsinger_expansion.py index 465f5547b..c3a6091d6 100644 --- a/tests/e2e/offline_inference/test_soulxsinger.py +++ b/tests/e2e/offline_inference/test_soulxsinger_expansion.py @@ -38,7 +38,7 @@ if not PROMPT_AUDIO.is_file() or not TARGET_AUDIO.is_file(): allow_module_level=True, ) -pytestmark = [pytest.mark.advanced_model, pytest.mark.diffusion, pytest.mark.tts] +pytestmark = [pytest.mark.slow, pytest.mark.diffusion, pytest.mark.tts] _CASES = ( pytest.param( diff --git a/tests/e2e/offline_inference/test_step_audio2_expansion.py b/tests/e2e/offline_inference/test_step_audio2_expansion.py index 362f2f33f..cac4fbe52 100644 --- a/tests/e2e/offline_inference/test_step_audio2_expansion.py +++ b/tests/e2e/offline_inference/test_step_audio2_expansion.py @@ -6,7 +6,6 @@ E2E expansion tests for Step-Audio2 offline inference (nightly CI). Full-model inference with audio input and audio output. """ -from pathlib import Path from typing import Any import numpy as np @@ -15,11 +14,12 @@ import pytest from tests.helpers.mark import hardware_test from tests.helpers.media import generate_synthetic_audio from tests.helpers.runtime import OmniRunner +from tests.helpers.stage_config import get_deploy_config_path from vllm_omni.outputs import OmniRequestOutput MODEL = "stepfun-ai/Step-Audio-2-mini" -STAGE_CONFIG = str(Path(__file__).parent / "stage_configs" / "step_audio2_ci.yaml") -TEST_PARAMS = [(MODEL, STAGE_CONFIG)] +DEPLOY_CONFIG = get_deploy_config_path("step_audio2_ci.yaml") +TEST_PARAMS = [(MODEL, None, {"deploy_config": DEPLOY_CONFIG, "trust_remote_code": True})] pytestmark = [pytest.mark.slow, pytest.mark.tts] diff --git a/tests/e2e/offline_inference/test_voxcpm2_tts.py b/tests/e2e/offline_inference/test_voxcpm2_tts.py index 5c91db45a..c10694a40 100644 --- a/tests/e2e/offline_inference/test_voxcpm2_tts.py +++ b/tests/e2e/offline_inference/test_voxcpm2_tts.py @@ -14,8 +14,8 @@ VOXCPM2_MODEL = "openbmb/VoxCPM2" DEPLOY_CONFIG = get_deploy_config_path("voxcpm2.yaml") SAMPLE_RATE = 48000 -# (model, stage_config_path) — see ``omni_runner`` in tests.helpers.fixtures.runtime -_OMNI_RUNNER_PARAM = (VOXCPM2_MODEL, DEPLOY_CONFIG) +# VoxCPM2 ships a custom tokenizer, so remote code must be explicitly enabled. +_OMNI_RUNNER_PARAM = (VOXCPM2_MODEL, DEPLOY_CONFIG, {"trust_remote_code": True}) pytestmark = pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True) diff --git a/tests/e2e/offline_inference/test_voxtral_tts_expansion.py b/tests/e2e/offline_inference/test_voxtral_tts_expansion.py index 56529dbbe..2441c46dc 100644 --- a/tests/e2e/offline_inference/test_voxtral_tts_expansion.py +++ b/tests/e2e/offline_inference/test_voxtral_tts_expansion.py @@ -58,7 +58,7 @@ def _compose_request(model_name: str, text: str, voice: str) -> dict: @pytest.mark.parametrize("omni_runner_function", [_OMNI_RUNNER_PARAM], indirect=True) -@hardware_test(res={"cuda": "L4"}, num_cards=1) +@hardware_test(res={"cuda": "L4", "xpu": "B60"}, num_cards=1) def test_voxtral_tts_offline_basic(omni_runner_function: OmniRunner) -> None: """Offline sync path; function-scoped runner so AsyncOmni test does not overlap a live Omni.""" omni = omni_runner_function.omni diff --git a/tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py b/tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py new file mode 100644 index 000000000..6a2809040 --- /dev/null +++ b/tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py @@ -0,0 +1,1701 @@ +"""Strict MiniCPM-o 4.5 Realtime duplex E2E scenario harness. + +This script is intentionally scenario-based instead of a generic chat client. +It has two explicit validation contracts: + +1. ``model-policy`` accepts either a model-owned listen decision or a complete + spoken response for each streamed user turn; +2. ``response-required`` uses a known fixture and requires a complete audio + response with an independent transcript for every requested turn. + +Explicit serving-side barge-in is intentionally not part of this smoke test. +MiniCPM-o native duplex currently exposes model-owned listen/speak switching, +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 \ + --url ws://localhost:8099/v1/realtime?duplex=1 \ + --model openbmb/MiniCPM-o-4_5 \ + --input-wav input_16k_mono_pcm16.wav \ + --validation-mode model-policy \ + --output-dir /tmp/minicpmo_duplex_demo +""" + +from __future__ import annotations + +import argparse +import array +import asyncio +import base64 +import hashlib +import inspect +import json +import sys +import time +import wave +from dataclasses import dataclass, field +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[3] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +try: + import websockets + from websockets.exceptions import ConnectionClosed +except ImportError as exc: # pragma: no cover - demo dependency. + raise SystemExit("Install websockets first: pip install websockets") from exc + +from vllm_omni.experimental.fullduplex.client import ( # noqa: E402 + PCM16_BYTES_PER_SAMPLE, + PCM16_SAMPLE_RATE, + RealtimeEventCollector, + build_realtime_url, + read_pcm16_wav, +) + +_url_with_model = build_realtime_url +_read_wav_pcm16 = read_pcm16_wav + + +def _ref_audio_data_url(path: str | None) -> str | None: + if path is None: + return None + ref_path = Path(path).expanduser() + return "data:audio/wav;base64," + base64.b64encode(ref_path.read_bytes()).decode("ascii") + + +@dataclass +class DemoState: + events: list[dict[str, object]] = field(default_factory=list) + timing_events: RealtimeEventCollector = field(default_factory=RealtimeEventCollector) + audio_deltas: list[bytes] = field(default_factory=list) + response_audio_deltas: dict[str, list[bytes]] = field(default_factory=dict) + response_ids: list[str] = field(default_factory=list) + assistant_item_ids: list[str] = field(default_factory=list) + done_count: int = 0 + cancelled_count: int = 0 + listen_count: int = 0 + model_listen_count: int = 0 + buffering_listen_count: int = 0 + model_speak_event_count: int = 0 + model_speak_delta_count: int = 0 + playback_ack_count: int = 0 + playback_history_committed_count: int = 0 + truncate_count: int = 0 + input_transcription_count: int = 0 + audio_marks_seen: bool = False + overlap_decisions: list[dict[str, object]] = field(default_factory=list) + output_sample_rate_hz: int = 24000 + input_commit_sent_at_s: list[float] = field(default_factory=list) + + def add(self, event: dict[str, object], *, received_at_s: float | None = None) -> None: + self.events.append(event) + self.timing_events.add(event, received_at_s=received_at_s) + event_type = event.get("type") + if event_type == "response.created": + response = event.get("response") + response_id = response.get("id") if isinstance(response, dict) else event.get("response_id") + if isinstance(response_id, str) and response_id not in self.response_ids: + self.response_ids.append(response_id) + elif event_type == "conversation.item.added": + item = event.get("item") + if isinstance(item, dict) and item.get("role") == "assistant": + item_id = item.get("id") + if isinstance(item_id, str) and item_id not in self.assistant_item_ids: + self.assistant_item_ids.append(item_id) + elif event_type == "response.audio.delta": + delta = event.get("delta") or event.get("audio") + if isinstance(delta, str) and delta: + try: + decoded = base64.b64decode(delta) + self.audio_deltas.append(decoded) + response_id = self._event_response_id(event) + if isinstance(response_id, str): + self.response_audio_deltas.setdefault(response_id, []).append(decoded) + except Exception: + pass + metadata = event.get("metadata") + if isinstance(metadata, dict): + if metadata.get("model_speak") is True: + self.model_speak_delta_count += 1 + if isinstance(metadata.get("audio_text_marks"), list): + self.audio_marks_seen = True + sample_rate_hz = event.get("sample_rate_hz") + if isinstance(sample_rate_hz, int) and sample_rate_hz > 0: + self.output_sample_rate_hz = sample_rate_hz + elif event_type == "response.done": + self.done_count += 1 + response = event.get("response") + if isinstance(response, dict) and response.get("status") == "cancelled": + self.cancelled_count += 1 + elif event_type in {"audio.cancelled", "input.cancelled"}: + self.cancelled_count += 1 + elif event_type == "response.listen": + self.listen_count += 1 + response = event.get("response") + metadata = response.get("metadata") if isinstance(response, dict) else None + if isinstance(metadata, dict) and metadata.get("model_listen") is True: + self.model_listen_count += 1 + if isinstance(metadata, dict) and metadata.get("buffering") is True: + self.buffering_listen_count += 1 + elif event_type == "response.speak": + self.model_speak_event_count += 1 + elif event_type == "overlap.decision": + self.overlap_decisions.append(event) + elif event_type == "playback.acknowledged": + self.playback_ack_count += 1 + payload = event.get("event") + if isinstance(payload, dict) and payload.get("history_committed") is True: + self.playback_history_committed_count += 1 + elif event_type == "conversation.item.truncated": + self.truncate_count += 1 + elif event_type == "conversation.item.input_audio_transcription.completed": + self.input_transcription_count += 1 + + def count(self, event_type: str) -> int: + return sum(1 for event in self.events if event.get("type") == event_type) + + def response_timing_summaries(self) -> dict[str, dict[str, object]]: + summaries: dict[str, dict[str, object]] = {} + for response_id in self.response_ids: + created_at_s = next( + ( + received_at_s + for event, received_at_s in zip( + self.timing_events.events, + self.timing_events.event_received_at_s, + strict=True, + ) + if event.get("type") == "response.created" and self._event_response_id(event) == response_id + ), + None, + ) + if created_at_s is None: + continue + input_committed_at_s = next( + ( + committed_at_s + for committed_at_s in reversed(self.input_commit_sent_at_s) + if committed_at_s <= created_at_s + ), + None, + ) + timing = self.timing_events.timing_summary( + after_s=created_at_s, + input_committed_at_s=input_committed_at_s, + response_id=response_id, + ) + timing["measurement_origin"] = { + "response": "response.created client receive", + "input": "input_audio_buffer.commit client send" if input_committed_at_s is not None else None, + } + summaries[response_id] = timing + return summaries + + def first_index(self, event_type: str, predicate=None) -> int | None: + for index, event in enumerate(self.events): + if event.get("type") != event_type: + continue + if predicate is not None and not predicate(event): + continue + return index + return None + + @staticmethod + def _event_response_id(event: dict[str, object]) -> str | None: + response_id = event.get("response_id") + if isinstance(response_id, str) and response_id: + return response_id + response = event.get("response") + if isinstance(response, dict): + response_id = response.get("id") + if isinstance(response_id, str) and response_id: + return response_id + return None + + @staticmethod + def _event_item_id(event: dict[str, object]) -> str | None: + item_id = event.get("item_id") + if isinstance(item_id, str) and item_id: + return item_id + item = event.get("item") + if isinstance(item, dict): + item_id = item.get("id") + if isinstance(item_id, str) and item_id: + return item_id + return None + + def first_response_lifecycle_indices(self) -> dict[str, int]: + response_created_index = self.first_index("response.created") + if response_created_index is None: + return {} + response_id = self._event_response_id(self.events[response_created_index]) + if not response_id: + return {} + item_id = f"item_{response_id}" + indices: dict[str, int] = {"response.created": response_created_index} + for event_type in ( + "conversation.item.added", + "response.output_item.added", + "response.content_part.added", + "response.speak", + "response.audio.delta", + "response.audio.done", + "response.content_part.done", + "response.output_item.done", + "response.done", + ): + index = self.first_index( + event_type, + lambda event, event_type=event_type: ( + self._event_item_id(event) == item_id + if event_type == "conversation.item.added" + else self._event_response_id(event) == response_id + ), + ) + if index is None and event_type not in { + "response.speak", + "response.audio.delta", + "response.audio.done", + }: + return {} + if index is not None: + indices[event_type] = index + return indices + + def event_order_ok( + self, + *, + require_input_commit: bool = True, + require_commit_before_response: bool = True, + ) -> bool: + if not self.events or self.events[0].get("type") != "session.created": + return False + first_commit_index = self.first_index("input_audio_buffer.committed") + first_response_index = self.first_index("response.created") + if first_response_index is None: + return False + if require_input_commit and first_commit_index is None: + return False + if ( + require_input_commit + and require_commit_before_response + and first_commit_index is not None + and first_commit_index > first_response_index + ): + return False + indices_by_type = self.first_response_lifecycle_indices() + if not indices_by_type: + return False + required_types = [ + "response.created", + "conversation.item.added", + "response.output_item.added", + "response.content_part.added", + "response.content_part.done", + "response.output_item.done", + "response.done", + ] + if any(event_type not in indices_by_type for event_type in required_types): + return False + ordered_types = [ + "response.created", + "conversation.item.added", + "response.output_item.added", + "response.content_part.added", + "response.speak", + "response.audio.delta", + "response.audio.done", + "response.content_part.done", + "response.output_item.done", + "response.done", + ] + if "response.audio.delta" not in indices_by_type: + listen_index = self.first_index( + "response.listen", + lambda event: ( + isinstance(event.get("response"), dict) + and isinstance(event["response"].get("metadata"), dict) + and event["response"]["metadata"].get("model_listen") is True + ), + ) + return ( + listen_index is not None + and indices_by_type["response.created"] < listen_index < indices_by_type["response.done"] + ) + if any(event_type not in indices_by_type for event_type in ordered_types): + return False + indices = [indices_by_type[event_type] for event_type in ordered_types] + return indices == sorted(indices) + + def model_policy_event_order_ok( + self, + *, + expected_turns: int, + require_input_commit: bool = True, + ) -> bool: + if not self.events or self.events[0].get("type") != "session.created": + return False + commit_indices = [ + index for index, event in enumerate(self.events) if event.get("type") == "input_audio_buffer.committed" + ] + decision_indices = [ + index + for index, event in enumerate(self.events) + if event.get("type") == "response.created" + or ( + event.get("type") == "response.listen" + and isinstance(event.get("response"), dict) + and isinstance(event["response"].get("metadata"), dict) + and event["response"]["metadata"].get("model_listen") is True + ) + ] + first_input_index = self.first_index("input_audio_buffer.speech_started") + if first_input_index is None: + first_input_index = commit_indices[0] if commit_indices else None + if first_input_index is None and not require_input_commit: + first_input_index = 0 + if ( + first_input_index is None + or (require_input_commit and len(commit_indices) < expected_turns) + or len(decision_indices) < expected_turns + ): + return False + return decision_indices[0] > first_input_index + + def model_speak_before_audio_ok(self) -> bool: + speak_index = self.first_index("response.speak") + audio_index = self.first_index("response.audio.delta") + return speak_index is not None and audio_index is not None and speak_index < audio_index + + def response_done(self, response_id: str | None) -> bool: + if not response_id: + return False + return any( + event.get("type") == "response.done" and self._event_response_id(event) == response_id + for event in self.events + ) + + def response_audio_delta_count(self, response_id: str | None) -> int: + if not response_id: + return 0 + return sum( + 1 + for event in self.events + if event.get("type") == "response.audio.delta" and self._event_response_id(event) == response_id + ) + + def response_playback_sent_ms(self, response_id: str | None) -> int: + if not response_id: + return 0 + for event in reversed(self.events): + if event.get("type") != "response.done" or self._event_response_id(event) != response_id: + continue + response = event.get("response") + metadata = response.get("metadata") if isinstance(response, dict) else None + playback = metadata.get("playback") if isinstance(metadata, dict) else event.get("playback") + sent_ms = playback.get("sent_ms") if isinstance(playback, dict) else None + if isinstance(sent_ms, int | float): + return max(0, int(sent_ms)) + return 0 + + def response_playback_history_committed(self, response_id: str) -> bool: + item_id = f"item_{response_id}" + for event in self.events: + if event.get("type") != "playback.acknowledged": + continue + payload = event.get("event") + if not isinstance(payload, dict): + continue + if payload.get("item_id") == item_id and payload.get("history_committed") is True: + return True + return False + + def response_transcript_delta(self, response_id: str) -> str: + return "".join( + str(event.get("delta", "")) + for event in self.events + if event.get("type") == "response.audio_transcript.delta" and self._event_response_id(event) == response_id + ) + + def response_transcript_done(self, response_id: str) -> list[str]: + return [ + str(event.get("transcript", "")) + for event in self.events + if event.get("type") == "response.audio_transcript.done" and self._event_response_id(event) == response_id + ] + + def completed_response_ids(self) -> list[str]: + response_ids: list[str] = [] + for event in self.events: + if event.get("type") != "response.done": + continue + response_id = self._event_response_id(event) + if isinstance(response_id, str): + response_ids.append(response_id) + return response_ids + + def stale_audio_delta_count(self) -> int: + cancelled_epochs_by_index: list[tuple[int, int]] = [] + for index, event in enumerate(self.events): + if event.get("type") != "response.done": + continue + response = event.get("response") + if not isinstance(response, dict) or response.get("status") != "cancelled": + continue + metadata = response.get("metadata") + if not isinstance(metadata, dict): + continue + cancelled_epoch = metadata.get("cancelled_epoch") + if isinstance(cancelled_epoch, int): + cancelled_epochs_by_index.append((index, cancelled_epoch)) + if not cancelled_epochs_by_index: + return 0 + stale = 0 + for index, event in enumerate(self.events): + if event.get("type") != "response.audio.delta": + continue + metadata = event.get("metadata") + if not isinstance(metadata, dict): + continue + event_epoch = metadata.get("epoch") + for cancel_index, cancelled_epoch in cancelled_epochs_by_index: + if index > cancel_index and event_epoch == cancelled_epoch: + stale += 1 + break + return stale + + +def _session_update_event(args: argparse.Namespace) -> dict[str, object]: + session_payload: dict[str, object] = { + "model": args.model, + "modalities": ["audio", "text"], + "input_audio_format": "pcm16", + "output_audio_format": args.output_audio_format, + "turn_detection": None, + "overlap_policy": "listen_only", + "overlap_short_ack_ms": args.short_ack_ms, + "playback_commit_policy": "ack_only", + "extra_body": { + "auto_response": True, + "minicpmo45_native_duplex": True, + "force_listen_count": 0, + }, + } + temperature = getattr(args, "temperature", None) + if temperature is None and getattr(args, "validation_mode", None) == "response-required": + temperature = 0.0 + if temperature is not None: + session_payload["temperature"] = temperature + ref_audio = _ref_audio_data_url(getattr(args, "ref_audio", None)) + if ref_audio is not None: + session_payload["ref_audio"] = ref_audio + event: dict[str, object] = { + "type": "session.update", + "session": session_payload, + } + session_id = getattr(args, "session_id", None) + if session_id: + session_payload["session_id"] = session_id + return event + + +def _turn_input_paths(primary: Path, additional: list[str], *, turns: int) -> list[Path]: + turn_count = max(1, turns) + if not additional: + return [primary] * turn_count + if len(additional) != turn_count - 1: + raise ValueError( + f"provide exactly one --turn-input-wav for each turn after the first " + f"(expected {turn_count - 1}, got {len(additional)})" + ) + return [primary, *(Path(path) for path in additional)] + + +def _turn_inputs_are_distinct(paths: list[Path], pcm16_inputs: list[bytes]) -> bool: + if len(paths) != len(pcm16_inputs): + return False + distinct_paths = len({str(path.resolve()) for path in paths}) == len(paths) + distinct_audio = len({hashlib.sha256(pcm16).digest() for pcm16 in pcm16_inputs}) == len(pcm16_inputs) + return distinct_paths and distinct_audio + + +def _turn_durations( + explicit: list[int], + *, + turns: int, + first_turn_ms: int, +) -> list[int | None]: + turn_count = max(1, turns) + if not explicit: + return [first_turn_ms, *([min(first_turn_ms, 1200)] * (turn_count - 1))] + if len(explicit) != turn_count: + raise ValueError( + f"provide exactly one --turn-duration-ms for each turn (expected {turn_count}, got {len(explicit)})" + ) + if any(duration_ms < 0 for duration_ms in explicit): + raise ValueError("--turn-duration-ms values must be non-negative") + return [None if duration_ms == 0 else duration_ms for duration_ms in explicit] + + +def _turn_transcripts(first: str, *, turns: int) -> list[str]: + turn_count = max(1, turns) + transcripts = [first, "继续", "再说一次"][:turn_count] + transcripts.extend(f"turn-{turn_index + 1}" for turn_index in range(len(transcripts), turn_count)) + return transcripts + + +def _write_wav(path: Path, pcm_bytes: bytes, *, sample_rate_hz: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(PCM16_BYTES_PER_SAMPLE) + wf.setframerate(sample_rate_hz) + wf.writeframes(pcm_bytes) + + +def _write_demo_artifacts(state: DemoState, output_dir: Path, *, output_audio_format: str) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + if state.audio_deltas and output_audio_format == "pcm16": + _write_wav( + output_dir / "joined_audio_deltas.wav", + b"".join(state.audio_deltas), + sample_rate_hz=state.output_sample_rate_hz, + ) + elif state.audio_deltas: + (output_dir / "joined_audio_deltas.bin").write_bytes(b"".join(state.audio_deltas)) + for index, response_id in enumerate(state.response_ids, start=1): + response_audio = state.response_audio_deltas.get(response_id, []) + if not response_audio: + continue + payload = b"".join(response_audio) + if output_audio_format == "pcm16": + _write_wav( + output_dir / f"response_{index:02d}.wav", + payload, + sample_rate_hz=state.output_sample_rate_hz, + ) + else: + (output_dir / f"response_{index:02d}.bin").write_bytes(payload) + (output_dir / "events.jsonl").write_text( + "\n".join(json.dumps(event, ensure_ascii=False) for event in state.events) + "\n", + encoding="utf-8", + ) + + +def _pcm16_silence(duration_ms: int) -> bytes: + samples = PCM16_SAMPLE_RATE * max(0, duration_ms) // 1000 + return b"\x00\x00" * samples + + +def _pcm16_slice(pcm16: bytes, duration_ms: int) -> bytes: + byte_count = PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * max(1, duration_ms) // 1000 + return pcm16[: min(len(pcm16), byte_count)] + + +def _pcm16_active_slice(pcm16: bytes, duration_ms: int) -> bytes: + byte_count = PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * max(1, duration_ms) // 1000 + byte_count = min(len(pcm16), max(PCM16_BYTES_PER_SAMPLE, byte_count)) + byte_count -= byte_count % PCM16_BYTES_PER_SAMPLE + if byte_count <= 0: + return _pcm16_slice(pcm16, duration_ms) + step = max(PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * 20 // 1000, PCM16_BYTES_PER_SAMPLE) + step -= step % PCM16_BYTES_PER_SAMPLE + best_offset = 0 + best_energy = -1.0 + for offset in range(0, max(1, len(pcm16) - byte_count + 1), max(PCM16_BYTES_PER_SAMPLE, step)): + chunk = pcm16[offset : offset + byte_count] + samples = array.array("h") + samples.frombytes(chunk) + if not samples: + continue + energy = sum(abs(sample) for sample in samples) / len(samples) + if energy > best_energy: + best_energy = energy + best_offset = offset + return pcm16[best_offset : best_offset + byte_count] + + +def _select_turn_audio(pcm16: bytes, duration_ms: int | None) -> bytes: + if duration_ms is None: + return pcm16 + return _pcm16_active_slice(pcm16, duration_ms) + + +def _canonical_transcript(text: str) -> str: + return "".join(text.split()) + + +def _reuses_previous_turn_tail(previous: str, current: str) -> bool: + if not previous or not current: + return False + if len(previous) >= 4 and previous in current: + return True + max_overlap = min(len(previous), len(current)) + return any(previous.endswith(current[:overlap]) for overlap in range(max_overlap, 2, -1)) + + +def _has_terminal_punctuation(text: str) -> bool: + stripped = text.rstrip("\"'”’))]} ") + return bool(stripped) and (len(stripped) <= 4 or stripped[-1] in "。!?!?…") + + +def _all_audio_responses_have_transcript(state: DemoState, response_ids: list[str]) -> bool: + return all( + state.response_audio_delta_count(response_id) == 0 + or bool(_canonical_transcript(state.response_transcript_delta(response_id))) + for response_id in response_ids + ) + + +def _all_response_playback_history_committed(state: DemoState, response_ids: list[str]) -> bool: + return all( + state.response_playback_history_committed(response_id) + for response_id in response_ids + if state.response_playback_sent_ms(response_id) > 0 + ) + + +def _response_cardinality_ok( + completed_response_ids: list[str], + *, + expected_turns: int, + validation_mode: str, +) -> bool: + if validation_mode == "response-required": + return len(completed_response_ids) == expected_turns + return True + + +def _requires_cross_turn_independence( + *, + validation_mode: str, + distinct_inputs_required: bool, +) -> bool: + return distinct_inputs_required and validation_mode == "response-required" + + +def _input_transcription_ok(count: int, *, transcript_hints_enabled: bool) -> bool: + return count > 0 if transcript_hints_enabled else True + + +def _unexpected_error_events(state: DemoState) -> list[dict[str, object]]: + return [event for event in state.events if event.get("type") == "error"] + + +def _evaluate_response_speak_contract(state: DemoState) -> dict[str, object]: + speak_counts: dict[str, int] = {} + invalid_response_speak_count = 0 + text_bearing_response_speak_count = 0 + + for event in state.events: + if event.get("type") != "response.speak": + continue + response_id = state._event_response_id(event) + if not response_id or response_id not in state.response_ids: + invalid_response_speak_count += 1 + else: + speak_counts[response_id] = speak_counts.get(response_id, 0) + 1 + if "text" in event: + text_bearing_response_speak_count += 1 + + duplicate_response_speak_ids = sorted(response_id for response_id, count in speak_counts.items() if count > 1) + return { + "response_speak_contract_ok": not duplicate_response_speak_ids + and invalid_response_speak_count == 0 + and text_bearing_response_speak_count == 0, + "duplicate_response_speak_ids": duplicate_response_speak_ids, + "invalid_response_speak_count": invalid_response_speak_count, + "text_bearing_response_speak_count": text_bearing_response_speak_count, + } + + +def _evaluate_transcript_integrity( + state: DemoState, + response_ids: list[str], + *, + expected_empty_response_ids: set[str], + require_cross_turn_independence: bool, + require_terminal_punctuation: bool = False, +) -> dict[str, object]: + details: list[dict[str, object]] = [] + transcripts: list[str] = [] + transcript_delta_done_ok = True + empty_turns_ok = True + nonempty_audio_has_transcript_ok = True + terminal_punctuation_ok = True + + for response_id in response_ids: + transcript = state.response_transcript_delta(response_id) + done_transcripts = state.response_transcript_done(response_id) + canonical_transcript = _canonical_transcript(transcript) + response_delta_done = ( + len(done_transcripts) == 1 and _canonical_transcript(done_transcripts[0]) == canonical_transcript + ) or (not done_transcripts and not canonical_transcript) + expected_empty = response_id in expected_empty_response_ids + audio_delta_count = state.response_audio_delta_count(response_id) + response_empty_ok = not expected_empty or (not canonical_transcript and audio_delta_count == 0) + response_audio_has_transcript = expected_empty or audio_delta_count == 0 or bool(canonical_transcript) + response_terminal_punctuation_ok = ( + expected_empty + or not canonical_transcript + or not require_terminal_punctuation + or _has_terminal_punctuation(canonical_transcript) + ) + transcript_delta_done_ok = transcript_delta_done_ok and response_delta_done + empty_turns_ok = empty_turns_ok and response_empty_ok + nonempty_audio_has_transcript_ok = nonempty_audio_has_transcript_ok and response_audio_has_transcript + terminal_punctuation_ok = terminal_punctuation_ok and response_terminal_punctuation_ok + transcripts.append(canonical_transcript) + details.append( + { + "response_id": response_id, + "transcript": transcript, + "delta_done_ok": response_delta_done, + "expected_empty": expected_empty, + "empty_ok": response_empty_ok, + "audio_delta_count": audio_delta_count, + "audio_has_transcript": response_audio_has_transcript, + "terminal_punctuation_ok": response_terminal_punctuation_ok, + } + ) + + cross_turn_independent_ok = True + if require_cross_turn_independence: + for index, current in enumerate(transcripts): + for previous in transcripts[:index]: + if _reuses_previous_turn_tail(previous, current): + cross_turn_independent_ok = False + break + if not cross_turn_independent_ok: + break + + return { + "transcript_delta_done_ok": transcript_delta_done_ok, + "cross_turn_independent_ok": cross_turn_independent_ok, + "empty_turns_ok": empty_turns_ok, + "nonempty_audio_has_transcript_ok": nonempty_audio_has_transcript_ok, + "terminal_punctuation_ok": terminal_punctuation_ok, + "transcript_integrity": details, + } + + +async def _reader(ws, state: DemoState, stop: asyncio.Event) -> None: + try: + while not stop.is_set(): + raw = await ws.recv() + if not isinstance(raw, str): + continue + event = json.loads(raw) + if isinstance(event, dict): + state.add(event) + except ConnectionClosed: + return + + +async def _send_pcm16( + ws, + pcm16: bytes, + *, + chunk_ms: int, + realtime_delay: bool, + hints: dict[str, object] | None = None, + first_chunk_hints: dict[str, object] | None = None, + on_model_unit_ready=None, + frame_b64: str | None = None, +) -> None: + hints = hints or {} + first_chunk_hints = first_chunk_hints or {} + chunk_bytes = max(PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * chunk_ms // 1000, PCM16_BYTES_PER_SAMPLE) + model_unit_bytes = PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE + audio_ms = 0 + next_model_unit_bytes = model_unit_bytes + frames_sent = 0 + for offset in range(0, len(pcm16), chunk_bytes): + chunk = pcm16[offset : offset + chunk_bytes] + duration_ms = int(len(chunk) / (PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE) * 1000) + audio_ms += duration_ms + chunk_hints = dict(hints) + if offset == 0: + chunk_hints.update(first_chunk_hints) + if frame_b64 is not None and audio_ms > frames_sent * 1000: + # Omni duplex cadence: one camera frame per 1 s of audio. + chunk_hints["video_frames"] = [frame_b64] + frames_sent += 1 + await ws.send( + json.dumps( + { + "type": "input_audio_buffer.append", + "audio": base64.b64encode(chunk).decode("ascii"), + "input_audio_format": "pcm16", + "sample_rate_hz": PCM16_SAMPLE_RATE, + "duration_ms": duration_ms, + "audio_end_ms": audio_ms, + **chunk_hints, + } + ) + ) + while offset + len(chunk) >= next_model_unit_bytes: + next_model_unit_bytes += model_unit_bytes + if on_model_unit_ready is None: + continue + should_continue = on_model_unit_ready() + if inspect.isawaitable(should_continue): + should_continue = await should_continue + if should_continue is False: + return + if realtime_delay: + await asyncio.sleep(duration_ms / 1000) + + +async def _wait_for( + state: DemoState, + predicate, + *, + timeout_s: float, + label: str, +) -> None: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if predicate(): + return + await asyncio.sleep(0.02) + raise TimeoutError(f"Timed out waiting for {label}") + + +async def _wait_for_model_policy_outcome( + state: DemoState, + *, + before_created: int, + before_model_listen: int, + timeout_s: float, + listen_settle_s: float, + label: str, +) -> str: + deadline = time.monotonic() + timeout_s + listen_deadline: float | None = None + while time.monotonic() < deadline: + if state.count("response.created") > before_created: + return "speak" + if state.model_listen_count > before_model_listen: + if listen_deadline is None: + listen_deadline = time.monotonic() + max(0.0, listen_settle_s) + if time.monotonic() >= listen_deadline: + return "listen" + await asyncio.sleep(0.02) + raise TimeoutError(f"Timed out waiting for {label}") + + +async def _send_clean_turn( + ws, + state: DemoState, + pcm16: bytes, + *, + transcript: str, + duration_ms: int | None, + chunk_ms: int, + timeout_s: float, + require_audio: bool, + validation_mode: str, + send_transcript_hint: bool = True, + realtime_input: bool = False, + model_policy_settle_s: float = 2.0, + commit_input: bool = True, + frame_b64: str | None = None, +) -> tuple[str | None, str]: + before_created = state.count("response.created") + before_model_listen = state.model_listen_count + + def eligible_response_id() -> str | None: + for response_id in state.response_ids[before_created:]: + if not state.response_done(response_id): + continue + if require_audio and state.response_audio_delta_count(response_id) == 0: + continue + if require_audio and not _canonical_transcript(state.response_transcript_delta(response_id)): + continue + return response_id + return None + + await _send_pcm16( + ws, + _select_turn_audio(pcm16, duration_ms), + chunk_ms=chunk_ms, + realtime_delay=realtime_input, + hints={"transcript": transcript} if send_transcript_hint else {}, + frame_b64=frame_b64, + ) + if commit_input: + state.input_commit_sent_at_s.append(time.monotonic()) + await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True})) + if validation_mode == "model-policy": + outcome = await _wait_for_model_policy_outcome( + state, + before_created=before_created, + before_model_listen=before_model_listen, + timeout_s=timeout_s, + listen_settle_s=model_policy_settle_s, + label=f"{transcript} model speak/listen decision", + ) + if outcome == "listen": + return None, "listen" + else: + await _wait_for( + state, + lambda: state.count("response.created") > before_created, + timeout_s=timeout_s, + label=f"{transcript} response.created", + ) + await _wait_for( + state, + lambda: eligible_response_id() is not None, + timeout_s=timeout_s, + label=f"{transcript} completed response", + ) + response_id = ( + eligible_response_id() + if validation_mode == "response-required" + else (state.response_ids[before_created] if len(state.response_ids) > before_created else None) + ) + if require_audio: + await _wait_for( + state, + lambda: state.response_audio_delta_count(response_id) > 0, + timeout_s=timeout_s, + label=f"{transcript} response.audio.delta", + ) + await _wait_for( + state, + lambda: state.response_done(response_id), + timeout_s=timeout_s, + label=f"{transcript} response.done", + ) + if not isinstance(response_id, str): + raise TimeoutError(f"Missing response id for {transcript}") + await _ack_response_playback( + ws, + state, + response_id, + timeout_s=timeout_s, + label=transcript, + ) + return response_id, "speak" + + +async def _ack_response_playback( + ws, + state: DemoState, + response_id: str, + *, + timeout_s: float, + label: str, +) -> None: + played_ms = state.response_playback_sent_ms(response_id) + if played_ms <= 0: + return + before_ack = state.playback_ack_count + await ws.send( + json.dumps( + { + "type": "playback.ack", + "response_id": response_id, + "item_id": f"item_{response_id}", + "played_ms": played_ms, + "committed_ms": played_ms, + } + ) + ) + await _wait_for( + state, + lambda: state.playback_ack_count > before_ack and state.response_playback_history_committed(response_id), + timeout_s=timeout_s, + label=f"{label} playback history committed", + ) + + +async def _ack_all_completed_response_playback( + ws, + state: DemoState, + *, + timeout_s: float, +) -> None: + for response_id in state.completed_response_ids(): + if state.response_playback_sent_ms(response_id) <= 0: + continue + if state.response_playback_history_committed(response_id): + continue + await _ack_response_playback( + ws, + state, + response_id, + timeout_s=timeout_s, + label=response_id, + ) + + +async def _drain_model_policy_responses( + ws, + state: DemoState, + *, + timeout_s: float, + settle_s: float, +) -> None: + deadline = time.monotonic() + timeout_s + settle_s = max(0.0, settle_s) + observed_created_count = state.count("response.created") + quiet_since: float | None = None + + while True: + remaining_s = deadline - time.monotonic() + active_response_ids = [ + response_id for response_id in state.response_ids if not state.response_done(response_id) + ] + if remaining_s <= 0: + raise TimeoutError(f"Timed out draining model-policy responses; active response ids: {active_response_ids}") + + await _ack_all_completed_response_playback( + ws, + state, + timeout_s=remaining_s, + ) + + now = time.monotonic() + created_count = state.count("response.created") + if created_count != observed_created_count: + observed_created_count = created_count + quiet_since = None + + active_response_ids = [ + response_id for response_id in state.response_ids if not state.response_done(response_id) + ] + if active_response_ids: + quiet_since = None + else: + if quiet_since is None: + quiet_since = now + if now - quiet_since >= settle_s: + return + + await asyncio.sleep(min(0.02, max(0.0, deadline - time.monotonic()))) + + +def _event_index_for_response(state: DemoState, event_type: str, response_id: str) -> int | None: + return state.first_index( + event_type, + lambda event: state._event_response_id(event) == response_id, + ) + + +def _nth_event_index(state: DemoState, event_type: str, occurrence: int) -> int | None: + seen = 0 + for index, event in enumerate(state.events): + if event.get("type") != event_type: + continue + seen += 1 + if seen == occurrence: + return index + return None + + +def _continuous_overlap_terminal_is_outcome( + state: DemoState, + *, + validation_mode: str, + model_unit_ready_while_active: bool, + before_created: int, + before_model_listen: int, +) -> bool: + """Accept current-response termination as a model-policy overlap outcome.""" + return ( + validation_mode == "model-policy" + and model_unit_ready_while_active + and state.count("response.created") == before_created + and state.model_listen_count == before_model_listen + ) + + +async def _send_listen_only_overlap_pair( + ws, + state: DemoState, + first_pcm16: bytes, + second_pcm16: bytes, + *, + transcripts: tuple[str, str], + durations_ms: tuple[int | None, int | None], + chunk_ms: int, + timeout_s: float, + send_transcript_hint: bool, + realtime_input: bool, + model_policy_settle_s: float, + validation_mode: str, + silence_ms: int, + frame_b64: str | None = None, +) -> tuple[list[str | None], list[str], bool]: + if not realtime_input: + raise ValueError("listen-only-overlap requires --realtime-input") + + before_first_created = state.count("response.created") + observed_first_created = before_first_created + observed_first_listen = state.model_listen_count + + async def continue_until_first_speak() -> bool: + nonlocal observed_first_created, observed_first_listen + outcome = await _wait_for_model_policy_outcome( + state, + before_created=observed_first_created, + before_model_listen=observed_first_listen, + timeout_s=timeout_s, + listen_settle_s=model_policy_settle_s, + label=f"{transcripts[0]} model-unit speak/listen decision", + ) + observed_first_created = state.count("response.created") + observed_first_listen = state.model_listen_count + return outcome != "speak" + + first_input = _select_turn_audio(first_pcm16, durations_ms[0]) + _pcm16_silence(silence_ms) + await _send_pcm16( + ws, + first_input, + chunk_ms=chunk_ms, + realtime_delay=True, + hints={"transcript": transcripts[0]} if send_transcript_hint else {}, + on_model_unit_ready=continue_until_first_speak, + frame_b64=frame_b64, + ) + state.input_commit_sent_at_s.append(time.monotonic()) + await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True})) + await _wait_for( + state, + lambda: state.count("response.created") > before_first_created, + timeout_s=timeout_s, + label=f"{transcripts[0]} response.created", + ) + first_response_id = state.response_ids[before_first_created] + await _wait_for( + state, + lambda: state.response_audio_delta_count(first_response_id) > 0, + timeout_s=timeout_s, + label=f"{transcripts[0]} response.audio.delta", + ) + if state.response_done(first_response_id): + raise RuntimeError("first response completed before overlap input could start") + + before_second_created = state.count("response.created") + before_second_listen = state.model_listen_count + overlap_started_while_active = not state.response_done(first_response_id) + model_unit_ready_while_active = False + second_unit_event_index: int | None = None + + def record_model_unit_ready() -> None: + nonlocal model_unit_ready_while_active, second_unit_event_index + model_unit_ready_while_active = not state.response_done(first_response_id) + if model_unit_ready_while_active: + second_unit_event_index = len(state.events) + + await _send_pcm16( + ws, + _select_turn_audio(second_pcm16, durations_ms[1]), + chunk_ms=chunk_ms, + realtime_delay=True, + hints={"transcript": transcripts[1]} if send_transcript_hint else {}, + on_model_unit_ready=record_model_unit_ready, + frame_b64=frame_b64, + ) + state.input_commit_sent_at_s.append(time.monotonic()) + await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True})) + + await _wait_for( + state, + lambda: state.response_done(first_response_id), + timeout_s=timeout_s, + label=f"{transcripts[0]} response.done", + ) + await _ack_response_playback( + ws, + state, + first_response_id, + timeout_s=timeout_s, + label=transcripts[0], + ) + + if _continuous_overlap_terminal_is_outcome( + state, + validation_mode=validation_mode, + model_unit_ready_while_active=model_unit_ready_while_active, + before_created=before_second_created, + before_model_listen=before_second_listen, + ): + second_outcome = "continuous_terminal" + else: + second_outcome = await _wait_for_model_policy_outcome( + state, + before_created=before_second_created, + before_model_listen=before_second_listen, + timeout_s=timeout_s, + listen_settle_s=model_policy_settle_s, + label=f"{transcripts[1]} model speak/listen decision", + ) + second_response_id: str | None = None + if second_outcome == "speak": + second_response_id = state.response_ids[before_second_created] + await _wait_for( + state, + lambda: state.response_done(second_response_id), + timeout_s=timeout_s, + label=f"{transcripts[1]} response.done", + ) + await _ack_response_playback( + ws, + state, + second_response_id, + timeout_s=timeout_s, + label=transcripts[1], + ) + + first_done_index = _event_index_for_response(state, "response.done", first_response_id) + overlap_ok = ( + overlap_started_while_active + and model_unit_ready_while_active + and second_unit_event_index is not None + and first_done_index is not None + and second_unit_event_index < first_done_index + ) + return [first_response_id, second_response_id], ["speak", second_outcome], overlap_ok + + +async def run_demo(args: argparse.Namespace) -> dict[str, object]: + demo_frame_b64: str | None = None + if getattr(args, "frame_image", None): + demo_frame_b64 = base64.b64encode(Path(args.frame_image).read_bytes()).decode("ascii") + turn_input_paths = _turn_input_paths( + Path(args.input_wav), + list(getattr(args, "turn_input_wav", []) or []), + turns=args.turns, + ) + turn_pcm16 = [_read_wav_pcm16(path) for path in turn_input_paths] + if any(not pcm16 for pcm16 in turn_pcm16): + raise ValueError("input WAV has no audio") + turn_durations = _turn_durations( + list(getattr(args, "turn_duration_ms", []) or []), + turns=args.turns, + first_turn_ms=args.first_turn_ms, + ) + expected_empty_turns = set(getattr(args, "expect_empty_turn", []) or []) + invalid_empty_turns = sorted( + turn_number for turn_number in expected_empty_turns if turn_number < 1 or turn_number > max(1, args.turns) + ) + if invalid_empty_turns: + raise ValueError( + f"--expect-empty-turn values are 1-based and must refer to an existing turn: {invalid_empty_turns}" + ) + distinct_turn_inputs = _turn_inputs_are_distinct(turn_input_paths, turn_pcm16) + if getattr(args, "require_distinct_inputs", False) and not distinct_turn_inputs: + raise ValueError("--require-distinct-inputs requires a different WAV path and audio payload for every turn") + url = _url_with_model( + args.url, + args.model, + autostart=False if getattr(args, "ref_audio", None) else None, + session_id=getattr(args, "session_id", None), + ) + state = DemoState() + stop = asyncio.Event() + output_dir = Path(args.output_dir) + turn_response_ids: list[str | None] = [] + turn_outcomes: list[str] = [] + validation_mode = getattr(args, "validation_mode", "response-required") + scenario = getattr(args, "scenario", "sequential") + transcript_hints_enabled = not getattr(args, "omit_transcript_hints", False) + realtime_input = getattr(args, "realtime_input", False) + continuous_input = getattr(args, "continuous_input", False) + listen_only_overlap_ok = scenario != "listen-only-overlap" + if scenario == "listen-only-overlap" and args.turns < 2: + raise ValueError("listen-only-overlap requires at least two turns") + + async with websockets.connect(url, max_size=64 * 1024 * 1024) as ws: + reader = asyncio.create_task(_reader(ws, state, stop)) + try: + await ws.send(json.dumps(_session_update_event(args))) + await _wait_for(state, lambda: state.count("session.created") > 0, timeout_s=20, label="session.created") + + turn_transcripts = _turn_transcripts(args.first_turn_transcript, turns=args.turns) + turn_specs = list(zip(turn_transcripts, turn_durations, strict=True)) + if scenario == "listen-only-overlap": + turn_response_ids, turn_outcomes, listen_only_overlap_ok = await _send_listen_only_overlap_pair( + ws, + state, + turn_pcm16[0], + turn_pcm16[1], + transcripts=(turn_specs[0][0], turn_specs[1][0]), + durations_ms=(turn_specs[0][1], turn_specs[1][1]), + chunk_ms=args.chunk_ms, + timeout_s=args.timeout_s, + send_transcript_hint=transcript_hints_enabled, + realtime_input=realtime_input, + model_policy_settle_s=max(0.0, args.model_policy_settle_ms / 1000), + validation_mode=validation_mode, + silence_ms=max(0, args.silence_ms), + frame_b64=demo_frame_b64, + ) + for turn_index in range(2, len(turn_specs)): + transcript, duration_ms = turn_specs[turn_index] + response_id, outcome = await _send_clean_turn( + ws, + state, + turn_pcm16[turn_index], + transcript=transcript, + duration_ms=duration_ms, + chunk_ms=args.chunk_ms, + timeout_s=args.timeout_s, + require_audio=args.require_audio and (turn_index + 1) not in expected_empty_turns, + validation_mode=validation_mode, + send_transcript_hint=transcript_hints_enabled, + realtime_input=realtime_input, + model_policy_settle_s=max(0.0, args.model_policy_settle_ms / 1000), + commit_input=not continuous_input, + frame_b64=demo_frame_b64, + ) + turn_response_ids.append(response_id) + turn_outcomes.append(outcome) + else: + for turn_index, (transcript, duration_ms) in enumerate(turn_specs): + response_id, outcome = await _send_clean_turn( + ws, + state, + turn_pcm16[turn_index], + transcript=transcript, + duration_ms=duration_ms, + chunk_ms=args.chunk_ms, + timeout_s=args.timeout_s, + require_audio=args.require_audio and (turn_index + 1) not in expected_empty_turns, + validation_mode=validation_mode, + send_transcript_hint=transcript_hints_enabled, + realtime_input=realtime_input, + model_policy_settle_s=max(0.0, args.model_policy_settle_ms / 1000), + commit_input=not continuous_input, + frame_b64=demo_frame_b64, + ) + turn_response_ids.append(response_id) + turn_outcomes.append(outcome) + + if validation_mode == "model-policy": + await _drain_model_policy_responses( + ws, + state, + timeout_s=args.timeout_s, + settle_s=max(0.0, args.model_policy_settle_ms / 1000), + ) + await _ack_all_completed_response_playback( + ws, + state, + timeout_s=args.timeout_s, + ) + await ws.send(json.dumps({"type": "session.close"})) + await _wait_for(state, lambda: state.count("session.closed") > 0, timeout_s=20, label="session.closed") + finally: + if state.count("session.created") > 0 and state.count("session.closed") == 0: + try: + await ws.send(json.dumps({"type": "session.close"})) + await _wait_for( + state, + lambda: state.count("session.closed") > 0, + timeout_s=min(args.timeout_s, 5), + label="session.closed during cleanup", + ) + except (ConnectionClosed, TimeoutError): + pass + stop.set() + reader.cancel() + try: + await reader + except asyncio.CancelledError: + pass + _write_demo_artifacts(state, output_dir, output_audio_format=args.output_audio_format) + + overlap_barge_in = any(decision.get("action") == "barge_in" for decision in state.overlap_decisions) + expected_turns = max(1, args.turns) + event_order_ok = ( + state.model_policy_event_order_ok( + expected_turns=expected_turns, + require_input_commit=not continuous_input, + ) + if validation_mode == "model-policy" + else state.event_order_ok( + require_input_commit=not continuous_input, + require_commit_before_response=False, + ) + ) + input_transcription_ok = _input_transcription_ok( + state.input_transcription_count, + transcript_hints_enabled=transcript_hints_enabled and not continuous_input, + ) + model_speak_event_ok = state.model_speak_before_audio_ok() + realtime_audio_lifecycle_ok = state.count("response.audio.delta") > 0 and state.count("response.audio.done") > 0 + completed_response_ids = state.completed_response_ids() + observed_turn_response_ids = [response_id for response_id in turn_response_ids if isinstance(response_id, str)] + expected_empty_response_ids = { + response_id + for turn_number in expected_empty_turns + if turn_number <= len(turn_response_ids) + and isinstance((response_id := turn_response_ids[turn_number - 1]), str) + } + transcript_integrity = _evaluate_transcript_integrity( + state, + observed_turn_response_ids, + expected_empty_response_ids=expected_empty_response_ids, + require_cross_turn_independence=_requires_cross_turn_independence( + validation_mode=validation_mode, + distinct_inputs_required=getattr(args, "require_distinct_inputs", False), + ), + require_terminal_punctuation=getattr(args, "require_distinct_inputs", False), + ) + response_speak_contract = _evaluate_response_speak_contract(state) + expected_audio_turns = expected_turns - len(expected_empty_turns) + lifecycle_counts_ok = ( + state.count("response.created") == state.count("response.done") + and len(state.response_ids) == len(completed_response_ids) + and len(completed_response_ids) == len(set(completed_response_ids)) + and state.count("response.audio.done") <= state.count("response.done") + and _response_cardinality_ok( + completed_response_ids, + expected_turns=expected_turns, + validation_mode=validation_mode, + ) + ) + turn_outcomes_ok = len(turn_outcomes) == expected_turns and ( + validation_mode == "model-policy" or all(outcome == "speak" for outcome in turn_outcomes) + ) + clean_turn_audio_ok = all( + state.response_audio_delta_count(response_id) > 0 + for response_id in observed_turn_response_ids + if response_id not in expected_empty_response_ids + ) + playback_history_committed_ok = _all_response_playback_history_committed( + state, + completed_response_ids, + ) + if validation_mode == "response-required": + full_audio_response_ok = ( + len(observed_turn_response_ids) == expected_audio_turns + len(expected_empty_turns) + and clean_turn_audio_ok + and model_speak_event_ok + and state.model_speak_delta_count > 0 + and realtime_audio_lifecycle_ok + and state.audio_marks_seen + ) + else: + full_audio_response_ok = not observed_turn_response_ids or ( + realtime_audio_lifecycle_ok and state.model_speak_delta_count > 0 + ) + stale_audio_delta_count = state.stale_audio_delta_count() + terminal_activity_ok = state.count("response.done") > 0 or ( + validation_mode == "model-policy" and state.model_listen_count > 0 + ) + all_audio_responses_have_transcript = ( + validation_mode != "response-required" + or _all_audio_responses_have_transcript( + state, + completed_response_ids, + ) + ) + unexpected_error_events = _unexpected_error_events(state) + continuous_input_ok = not continuous_input or state.count("input_audio_buffer.committed") == 0 + result = { + "ok": terminal_activity_ok + and state.count("session.closed") > 0 + and state.cancelled_count == 0 + and not overlap_barge_in + and state.truncate_count == 0 + and event_order_ok + and input_transcription_ok + and stale_audio_delta_count == 0 + and lifecycle_counts_ok + and turn_outcomes_ok + and full_audio_response_ok, + "event_counts": { + event_type: state.count(event_type) + for event_type in sorted({str(event.get("type")) for event in state.events}) + }, + "audio_delta_count": len(state.audio_deltas), + "done_count": state.done_count, + "cancelled_count": state.cancelled_count, + "listen_count": state.listen_count, + "model_listen_count": state.model_listen_count, + "buffering_listen_count": state.buffering_listen_count, + "model_speak_event_count": state.model_speak_event_count, + "model_speak_delta_count": state.model_speak_delta_count, + "playback_ack_count": state.playback_ack_count, + "playback_history_committed_count": state.playback_history_committed_count, + "playback_history_committed_ok": playback_history_committed_ok, + "truncate_count": state.truncate_count, + "input_transcription_count": state.input_transcription_count, + "audio_marks_seen": state.audio_marks_seen, + "overlap_decisions": state.overlap_decisions, + "overlap_barge_in": overlap_barge_in, + "event_order_ok": event_order_ok, + "terminal_activity_ok": terminal_activity_ok, + "input_transcription_ok": input_transcription_ok, + "completed_response_ids": completed_response_ids, + "response_timings": state.response_timing_summaries(), + "lifecycle_counts_ok": lifecycle_counts_ok, + "validation_mode": validation_mode, + "scenario": scenario, + "listen_only_overlap_ok": listen_only_overlap_ok, + "transcript_hints_enabled": transcript_hints_enabled, + "realtime_input": realtime_input, + "continuous_input": continuous_input, + "continuous_input_ok": continuous_input_ok, + "input_chunk_ms": args.chunk_ms, + "model_policy_settle_ms": args.model_policy_settle_ms, + "turn_outcomes": turn_outcomes, + "turn_outcomes_ok": turn_outcomes_ok, + "clean_turn_audio_ok": clean_turn_audio_ok, + "full_audio_response_ok": full_audio_response_ok, + "model_speak_event_ok": model_speak_event_ok, + "realtime_audio_lifecycle_ok": realtime_audio_lifecycle_ok, + "stale_audio_delta_count": stale_audio_delta_count, + "all_audio_responses_have_transcript": all_audio_responses_have_transcript, + "distinct_turn_inputs": distinct_turn_inputs, + "error_count": len(unexpected_error_events), + "errors": unexpected_error_events, + **response_speak_contract, + **transcript_integrity, + "turn_inputs": [ + { + "path": str(path), + "source_duration_ms": len(pcm16) * 1000 // (PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE), + "requested_duration_ms": duration_ms, + "sent_duration_ms": len(_select_turn_audio(pcm16, duration_ms)) + * 1000 + // (PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE), + } + for path, pcm16, duration_ms in zip(turn_input_paths, turn_pcm16, turn_durations, strict=True) + ], + "output_dir": str(output_dir), + } + result["ok"] = bool( + result["ok"] + and transcript_integrity["transcript_delta_done_ok"] + and transcript_integrity["cross_turn_independent_ok"] + and transcript_integrity["empty_turns_ok"] + and transcript_integrity["nonempty_audio_has_transcript_ok"] + and transcript_integrity["terminal_punctuation_ok"] + and all_audio_responses_have_transcript + and playback_history_committed_ok + and listen_only_overlap_ok + and not unexpected_error_events + and response_speak_contract["response_speak_contract_ok"] + and continuous_input_ok + and (distinct_turn_inputs or not getattr(args, "require_distinct_inputs", False)) + ) + return result + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--url", default="ws://localhost:8099/v1/realtime?duplex=1") + parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5") + parser.add_argument("--session-id", help="Use an explicit public session ID, including for close/reopen tests.") + parser.add_argument("--input-wav", required=True) + parser.add_argument("--ref-audio", help="Optional WAV used as the MiniCPM-o voice prompt.") + parser.add_argument( + "--frame-image", + default=None, + help="Optional image file sent as an omni-duplex camera frame (one per 1 s of audio)", + ) + parser.add_argument( + "--turn-input-wav", + action="append", + default=[], + help="WAV for each turn after the first; repeat exactly turns-1 times.", + ) + parser.add_argument("--output-dir", default="/tmp/minicpmo_realtime_duplex_demo") + parser.add_argument( + "--output-audio-format", + default="pcm16", + choices=["pcm16", "wav", "g711_ulaw", "g711_alaw"], + ) + parser.add_argument("--chunk-ms", type=int, default=200) + parser.add_argument( + "--model-policy-settle-ms", + type=int, + default=2000, + help="Wait for a delayed response.created after model-listen and before closing the session.", + ) + parser.add_argument("--first-turn-ms", type=int, default=1400) + parser.add_argument( + "--turn-duration-ms", + action="append", + type=int, + default=[], + help="Audio duration for each turn; repeat turns times. Use 0 to send the complete WAV.", + ) + parser.add_argument("--first-turn-transcript", default="demo input speech") + parser.add_argument( + "--omit-transcript-hints", + action="store_true", + help="Keep local turn labels but do not send transcript hints to the server.", + ) + parser.add_argument( + "--realtime-input", + action="store_true", + help="Pace each input chunk according to its audio duration.", + ) + parser.add_argument( + "--continuous-input", + action="store_true", + help="Model-owned browser mode: stream PCM continuously without input_audio_buffer.commit.", + ) + parser.add_argument( + "--scenario", + choices=["sequential", "listen-only-overlap"], + default="sequential", + help="Run sequential turns or stream turn 2 while turn 1 audio is active without interruption.", + ) + parser.add_argument( + "--validation-mode", + choices=["model-policy", "response-required"], + default="response-required", + help="Allow model listen decisions, or require at least one response for every input turn.", + ) + parser.add_argument( + "--temperature", + type=float, + default=None, + help="Stage0 sampling temperature; response-required defaults to deterministic sampling.", + ) + parser.add_argument( + "--require-distinct-inputs", + action="store_true", + help="Require distinct WAV paths and audio payloads, and reject cross-turn transcript reuse.", + ) + parser.add_argument( + "--expect-empty-turn", + action="append", + type=int, + default=[], + help="1-based turn expected to end without text or audio; repeat for multiple turns.", + ) + parser.add_argument("--short-ack-ms", type=int, default=350) + parser.add_argument("--silence-ms", type=int, default=500) + parser.add_argument("--playback-ack-ms", type=int, default=500) + parser.add_argument("--turns", type=int, default=3) + parser.add_argument("--timeout-s", type=float, default=60.0) + parser.add_argument( + "--require-audio", + action="store_true", + help="Fail if the first native response listens instead of producing audio.", + ) + return parser.parse_args() + + +def main() -> None: + result = asyncio.run(run_demo(parse_args())) + print(json.dumps(result, ensure_ascii=False, indent=2)) + if not result["ok"]: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/online_serving/personaplex_realtime_duplex.py b/tests/e2e/online_serving/personaplex_realtime_duplex.py new file mode 100644 index 000000000..62ad9e972 --- /dev/null +++ b/tests/e2e/online_serving/personaplex_realtime_duplex.py @@ -0,0 +1,492 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Strict PersonaPlex validation for the unified OpenAI Realtime duplex path.""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import hashlib +import json +import math +import uuid +import wave +from collections.abc import Sequence +from pathlib import Path +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import numpy as np + +from vllm_omni.experimental.fullduplex.client import ( + RealtimeDuplexClient, + wait_for, + write_pcm16_wav, +) + +SAMPLE_RATE_HZ = 24_000 +FRAME_SAMPLES = 1_920 +FRAME_PERIOD_S = FRAME_SAMPLES / SAMPLE_RATE_HZ + + +def _input_identity( + path: Path, + *, + expected_sha256: str | None, +) -> dict[str, str]: + resolved = path.resolve() + digest = hashlib.sha256() + with resolved.open("rb") as input_file: + for chunk in iter(lambda: input_file.read(1024 * 1024), b""): + digest.update(chunk) + actual = digest.hexdigest() + if expected_sha256 is not None and actual != expected_sha256.lower(): + raise ValueError( + f"input WAV SHA-256 mismatch: expected={expected_sha256.lower()}, actual={actual}, path={resolved}" + ) + return {"path": str(resolved), "sha256": actual} + + +def _realtime_url(base_url: str, model: str, session_id: str) -> str: + parts = urlsplit(base_url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query.update(duplex="1", model=model, autostart="0", session_id=session_id) + return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) + + +def _read_wav_as_float32(path: Path) -> np.ndarray: + with wave.open(str(path), "rb") as wav_file: + channels = wav_file.getnchannels() + sample_width = wav_file.getsampwidth() + source_rate = wav_file.getframerate() + frames = wav_file.readframes(wav_file.getnframes()) + dtypes = {1: np.uint8, 2: np.dtype(" 1: + pcm = pcm.reshape(-1, channels).mean(axis=1) + if source_rate != SAMPLE_RATE_HZ and pcm.size: + output_samples = max(1, round(pcm.size * SAMPLE_RATE_HZ / source_rate)) + pcm = np.interp( + np.linspace(0, pcm.size - 1, output_samples), + np.arange(pcm.size), + pcm, + ).astype(np.float32) + pcm = np.ascontiguousarray(pcm, dtype=" list[dict[str, object]]: + return [event for event in client.events.events if event.get("type") == event_type] + + +async def _open_session( + args: argparse.Namespace, + *, + session_id: str, + persona: str, + expect_error: bool = False, +) -> tuple[RealtimeDuplexClient, dict[str, object]]: + client = RealtimeDuplexClient(_realtime_url(args.url, args.model, session_id)) + await client.__aenter__() + await client.send( + { + "type": "session.update", + "session": { + "session_id": session_id, + "model": args.model, + "modalities": ["audio", "text"], + "input_audio_format": "pcm_f32le", + "output_audio_format": "pcm16", + "voice": args.voice, + "instructions": persona, + "turn_detection": None, + "extra_body": {"auto_response": True}, + }, + } + ) + event_type = "error" if expect_error else "session.created" + await wait_for( + lambda: client.events.count(event_type) > 0, + timeout_s=args.timeout_s, + label=event_type, + ) + return client, _events(client, event_type)[-1] + + +async def _close_session(client: RealtimeDuplexClient, *, timeout_s: float) -> None: + await client.send({"type": "session.close"}) + await wait_for( + lambda: client.events.count("session.closed") > 0, + timeout_s=timeout_s, + label="session.closed", + ) + await client.__aexit__(None, None, None) + + +async def _stream_frames( + client: RealtimeDuplexClient, + pcm: np.ndarray, + *, + max_frames: int | None = None, +) -> int: + frame_count = math.ceil(pcm.size / FRAME_SAMPLES) + if max_frames is not None: + if max_frames < 0: + raise ValueError(f"max_frames must be non-negative, got {max_frames}") + if max_frames > 0: + frame_count = min(frame_count, max_frames) + for seq in range(frame_count): + frame = pcm[seq * FRAME_SAMPLES : (seq + 1) * FRAME_SAMPLES] + frame = np.pad(frame, (0, FRAME_SAMPLES - frame.size)) + frame = np.ascontiguousarray(frame, dtype="= 1e-4), + } + ) + await asyncio.sleep(FRAME_PERIOD_S) + return frame_count + + +def _audio_frame_stats( + raw: bytes, + *, + input_frames: int, + voiced_frame_rms_threshold: float, +) -> dict[str, int | float]: + pcm = np.frombuffer(raw, dtype="= voiced_frame_rms_threshold)) + return { + "output_frames": int(output_frames), + "voiced_frames": voiced_frames, + "silent_frames": int(output_frames - voiced_frames), + "frame_deficit": int(input_frames - output_frames), + "frame_coverage_ratio": float(output_frames / input_frames), + } + + +def _response_ids(client: RealtimeDuplexClient) -> set[str]: + return { + str(event["response_id"]) + for event in _events(client, "response.audio.delta") + if isinstance(event.get("response_id"), str) + } + + +def _session_result( + client: RealtimeDuplexClient, + *, + input_frames: int, + args: argparse.Namespace, + minimum_chunks: int, +) -> tuple[bytes, set[str], dict[str, object]]: + chunks = sum(len(items) for items in client.events.response_audio.values()) + raw = client.events.audio_bytes() + if chunks < minimum_chunks or not raw or len(raw) % 2: + raise AssertionError(f"invalid PCM16 output: chunks={chunks}, bytes={len(raw)}") + pcm = np.frombuffer(raw, dtype=" dict[str, object]: + session = created.get("session") + capabilities = session.get("capabilities") if isinstance(session, dict) else None + if not isinstance(capabilities, dict): + raise AssertionError(f"session.created omitted capabilities: {created}") + return capabilities + + +def _save(output_dir: Path, name: str, client: RealtimeDuplexClient, raw: bytes) -> None: + (output_dir / f"{name}-events.jsonl").write_text( + "".join(json.dumps(event, ensure_ascii=False) + "\n" for event in client.events.events), + encoding="utf-8", + ) + write_pcm16_wav(output_dir / f"{name}-output.wav", raw, sample_rate_hz=SAMPLE_RATE_HZ) + + +async def run(args: argparse.Namespace) -> dict[str, object]: + input_path = Path(args.input_wav) + input_identity = _input_identity( + input_path, + expected_sha256=args.expected_input_sha256, + ) + pcm = _read_wav_as_float32(input_path) + if args.tail_s > 0: + pcm = np.concatenate([pcm, np.zeros(round(args.tail_s * SAMPLE_RATE_HZ), dtype=np.float32)]) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + ids = {name: f"personaplex-{name}-{uuid.uuid4().hex}" for name in ("primary", "secondary", "replacement")} + primary, created = await _open_session(args, session_id=ids["primary"], persona=args.persona) + secondary, secondary_created = await _open_session( + args, + session_id=ids["secondary"], + persona=args.secondary_persona, + ) + capabilities = _capabilities(created) + if _capabilities(secondary_created) != capabilities: + raise AssertionError("concurrent sessions returned different capabilities") + expected_capabilities = { + "implementation_level": "model_native_duplex", + "chunk_period_ms": 80, + "supports_multi_session": True, + "supports_multi_session_same_replica": True, + "supports_barge_in": False, + } + if any(capabilities.get(key) != value for key, value in expected_capabilities.items()): + raise AssertionError(f"unexpected PersonaPlex capabilities: {capabilities}") + + overflow, error = await _open_session( + args, + session_id=f"personaplex-overflow-{uuid.uuid4().hex}", + persona=args.persona, + expect_error=True, + ) + error_body = error.get("error") + overflow_code = error_body.get("code") if isinstance(error_body, dict) else error.get("code") + await overflow.__aexit__(None, None, None) + if overflow_code != "resource_exhausted": + raise AssertionError(f"overflow session was not rejected: {error}") + + primary_frames, secondary_frames = await asyncio.gather( + _stream_frames(primary, pcm), + _stream_frames(secondary, pcm), + ) + await asyncio.gather( + wait_for( + lambda: len(primary.events.audio_bytes()) // (2 * FRAME_SAMPLES) >= primary_frames - args.max_frame_deficit, + timeout_s=args.timeout_s, + label="primary frame coverage", + ), + wait_for( + lambda: ( + len(secondary.events.audio_bytes()) // (2 * FRAME_SAMPLES) >= secondary_frames - args.max_frame_deficit + ), + timeout_s=args.timeout_s, + label="secondary frame coverage", + ), + ) + await asyncio.sleep(args.drain_s) + primary_audio, primary_response_ids, primary_stats = _session_result( + primary, + input_frames=primary_frames, + args=args, + minimum_chunks=args.minimum_audio_chunks, + ) + secondary_audio, secondary_response_ids, _ = _session_result( + secondary, + input_frames=secondary_frames, + args=args, + minimum_chunks=args.minimum_audio_chunks, + ) + if primary_response_ids & secondary_response_ids: + raise AssertionError("concurrent sessions shared a response id") + _save(output_dir, "primary", primary, primary_audio) + await _close_session(primary, timeout_s=args.timeout_s) + + replacement, replacement_created = await _open_session( + args, + session_id=ids["replacement"], + persona=args.replacement_persona, + ) + if _capabilities(replacement_created) != capabilities: + raise AssertionError("replacement session returned different capabilities") + continuation_frames, replacement_frames = await asyncio.gather( + _stream_frames(secondary, pcm, max_frames=args.replacement_frames), + _stream_frames(replacement, pcm, max_frames=args.replacement_frames), + ) + secondary_total_frames = secondary_frames + continuation_frames + await asyncio.gather( + wait_for( + lambda: ( + len(secondary.events.audio_bytes()) // (2 * FRAME_SAMPLES) + >= secondary_total_frames - args.max_frame_deficit + ), + timeout_s=args.timeout_s, + label="survivor frame coverage after slot recycle", + ), + wait_for( + lambda: ( + len(replacement.events.audio_bytes()) // (2 * FRAME_SAMPLES) + >= replacement_frames - args.max_frame_deficit + ), + timeout_s=args.timeout_s, + label="replacement frame coverage", + ), + ) + await asyncio.sleep(args.drain_s) + secondary_audio, secondary_after_ids, secondary_stats = _session_result( + secondary, + input_frames=secondary_total_frames, + args=args, + minimum_chunks=args.minimum_audio_chunks + 1, + ) + replacement_audio, replacement_response_ids, replacement_stats = _session_result( + replacement, + input_frames=replacement_frames, + args=args, + minimum_chunks=1, + ) + if secondary_after_ids != secondary_response_ids: + raise AssertionError("survivor response changed while another session slot was recycled") + if replacement_response_ids & (primary_response_ids | secondary_response_ids): + raise AssertionError("replacement session reused another session's response id") + _save(output_dir, "secondary", secondary, secondary_audio) + _save(output_dir, "replacement", replacement, replacement_audio) + await asyncio.gather( + _close_session(secondary, timeout_s=args.timeout_s), + _close_session(replacement, timeout_s=args.timeout_s), + ) + + errors = primary.events.errors() + secondary.events.errors() + replacement.events.errors() + result = { + "ok": not errors, + "model": args.model, + "input": {**input_identity, "tail_s": args.tail_s}, + "capabilities": capabilities, + "overflow_error_code": overflow_code, + "primary": {"session_id": ids["primary"], "input_frames": primary_frames, **primary_stats}, + "secondary": { + "session_id": ids["secondary"], + "input_frames": secondary_total_frames, + "continuation_frames_after_recycle": continuation_frames, + **secondary_stats, + }, + "replacement": { + "session_id": ids["replacement"], + "input_frames": replacement_frames, + **replacement_stats, + }, + "errors": errors, + "output_dir": str(output_dir), + } + (output_dir / "result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + if errors: + raise AssertionError(f"Realtime sessions emitted errors: {errors}") + return result + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--url", default="ws://127.0.0.1:8099/v1/realtime?duplex=1") + parser.add_argument("--model", required=True) + parser.add_argument("--input-wav", required=True) + parser.add_argument( + "--expected-input-sha256", + help="Fail before opening sessions when the input WAV does not match this SHA-256.", + ) + parser.add_argument("--output-dir", default="/tmp/personaplex-realtime-duplex") + parser.add_argument("--voice", default="NATF2.pt") + parser.add_argument("--persona", default="You are a concise and helpful assistant.") + parser.add_argument("--secondary-persona", default="You are a calm and precise assistant.") + parser.add_argument("--replacement-persona", default="You are a cheerful travel assistant.") + parser.add_argument("--tail-s", type=float, default=2.0) + parser.add_argument("--drain-s", type=float, default=2.0) + parser.add_argument("--timeout-s", type=float, default=180.0) + parser.add_argument( + "--max-frame-deficit", + type=int, + default=4, + help=( + "Maximum unflushed tail frames. PersonaPlex Code2Wav emits five-frame " + "chunks, so a session cutoff can leave at most four pending frames." + ), + ) + parser.add_argument("--voiced-frame-rms-threshold", type=float, default=1e-3) + parser.add_argument( + "--min-voiced-frames", + type=int, + default=5, + help=( + "Require at least one normal five-frame Code2Wav chunk (400 ms) above " + "the per-frame RMS threshold; this detects inaudible output, not speech quality." + ), + ) + parser.add_argument("--minimum-audio-chunks", type=int, default=2) + parser.add_argument( + "--replacement-frames", + type=int, + default=0, + help="Frames sent after slot reuse; 0 replays the full input so the replacement must produce audible output.", + ) + return parser.parse_args(argv) + + +def main() -> None: + print(json.dumps(asyncio.run(run(parse_args())), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/online_serving/run_minicpmo_realtime_duplex_demo_pair.py b/tests/e2e/online_serving/run_minicpmo_realtime_duplex_demo_pair.py new file mode 100644 index 000000000..f5539968b --- /dev/null +++ b/tests/e2e/online_serving/run_minicpmo_realtime_duplex_demo_pair.py @@ -0,0 +1,388 @@ +"""Run two MiniCPM-o Realtime duplex demo processes concurrently. + +This E2E driver covers the same process boundary users exercise manually: +two independent ``realtime_duplex_demo.py`` invocations, two distinct input +WAVs, and two distinct output directories. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import hashlib +import io +import json +import sys +import uuid +import wave +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +DEMO_PATH = REPO_ROOT / "examples/online_serving/minicpmo/realtime_duplex_demo.py" +AUDIO_DELTA_EVENTS = {"response.audio.delta", "response.output_audio.delta"} + + +def _canonical_path(path: str) -> Path: + return Path(path).expanduser().resolve(strict=False) + + +def _sha256_file(path: Path) -> str: + hasher = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def _validate_pair_args(args: argparse.Namespace) -> dict[str, object]: + input_a = _canonical_path(args.input_wav_a) + input_b = _canonical_path(args.input_wav_b) + output_a = _canonical_path(args.output_dir_a) + output_b = _canonical_path(args.output_dir_b) + if output_a == output_b: + raise ValueError("output directories must be different") + if not input_a.is_file(): + raise ValueError(f"input WAV does not exist: {input_a}") + if not input_b.is_file(): + raise ValueError(f"input WAV does not exist: {input_b}") + sha_a = _sha256_file(input_a) + sha_b = _sha256_file(input_b) + if sha_a == sha_b: + raise ValueError("input WAV files must have different content") + return { + "input_wav_a": str(input_a), + "input_wav_b": str(input_b), + "input_sha256_a": sha_a, + "input_sha256_b": sha_b, + "output_dir_a": str(output_a), + "output_dir_b": str(output_b), + } + + +def _event_response_id(event: dict[str, object]) -> str | None: + response_id = event.get("response_id") + if isinstance(response_id, str): + return response_id + response = event.get("response") + if isinstance(response, dict) and isinstance(response.get("id"), str): + return response["id"] + return None + + +def _read_json(path: Path) -> dict[str, object]: + if not path.is_file(): + return {} + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + + +def _read_events(path: Path) -> list[dict[str, object]]: + if not path.is_file(): + return [] + events: list[dict[str, object]] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + event = json.loads(line) + if isinstance(event, dict): + events.append(event) + return events + + +def _response_ids_from_artifacts(result: dict[str, object], events: list[dict[str, object]]) -> list[str]: + response_ids = result.get("response_ids") + if isinstance(response_ids, list): + ids = [item for item in response_ids if isinstance(item, str)] + if ids: + return ids + seen: list[str] = [] + for event in events: + response_id = _event_response_id(event) + if response_id is not None and response_id not in seen: + seen.append(response_id) + return seen + + +def _audio_delta_duration_ms(event: dict[str, object]) -> float | None: + encoded = event.get("delta") or event.get("audio") + if not isinstance(encoded, str) or not encoded: + return None + try: + raw = base64.b64decode(encoded) + except ValueError: + return None + fmt = str(event.get("format") or event.get("audio_format") or "pcm16").lower() + sample_rate = event.get("sample_rate_hz") or event.get("sample_rate") or 24_000 + if not isinstance(sample_rate, int | float) or sample_rate <= 0: + return None + if "wav" in fmt: + try: + with wave.open(io.BytesIO(raw), "rb") as wav: + return round((wav.getnframes() * 1000.0) / wav.getframerate(), 3) + except (EOFError, wave.Error): + return None + bytes_per_sample = 4 if "f32" in fmt else 2 + return round((len(raw) * 1000.0) / (bytes_per_sample * float(sample_rate)), 3) + + +def _audio_delta_received_at_s(event: dict[str, object]) -> float | None: + raw = event.get("_client_received_at_s") + if isinstance(raw, int | float): + return float(raw) + return None + + +def _session_audio_contract_ok( + *, + audio_delta_count: int, + delta_response_ids: list[str], + response_ids: list[str], + first_audio_delta_before_done: bool, +) -> bool: + if audio_delta_count <= 0 or not response_ids: + return False + if len(delta_response_ids) != audio_delta_count: + return False + delta_id_set = set(delta_response_ids) + if len(delta_id_set) != 1: + return False + return first_audio_delta_before_done and next(iter(delta_id_set)) in set(response_ids) + + +def _summarize_session( + *, + label: str, + input_wav: str, + output_dir: str, + returncode: int, + stdout: str, + stderr: str, +) -> dict[str, object]: + output_path = Path(output_dir) + result = _read_json(output_path / "result.json") + events = _read_events(output_path / "events.jsonl") + audio_delta_indices = [index for index, event in enumerate(events) if event.get("type") in AUDIO_DELTA_EVENTS] + done_indices = [index for index, event in enumerate(events) if event.get("type") == "response.done"] + response_ids = _response_ids_from_artifacts(result, events) + delta_response_ids = [ + response_id + for response_id in (_event_response_id(events[index]) for index in audio_delta_indices) + if response_id is not None + ] + first_audio_delta_before_done = bool(audio_delta_indices and done_indices) and min(audio_delta_indices) < min( + done_indices + ) + audio_delta_events = [events[index] for index in audio_delta_indices] + first_received_at_s = next( + ( + received_at + for received_at in (_audio_delta_received_at_s(event) for event in audio_delta_events) + if received_at is not None + ), + None, + ) + audio_delta_timings = [] + for event in audio_delta_events: + received_at_s = _audio_delta_received_at_s(event) + audio_delta_timings.append( + { + "response_id": _event_response_id(event), + "received_at_s": received_at_s, + "arrival_offset_ms": ( + round((received_at_s - first_received_at_s) * 1000.0, 3) + if received_at_s is not None and first_received_at_s is not None + else None + ), + "audio_duration_ms": _audio_delta_duration_ms(event), + } + ) + all_audio_deltas_same_response = ( + len(delta_response_ids) == len(audio_delta_indices) and len(set(delta_response_ids)) == 1 + ) + response_id_nonempty = bool(response_ids) + response_audio_contract_ok = _session_audio_contract_ok( + audio_delta_count=len(audio_delta_indices), + delta_response_ids=delta_response_ids, + response_ids=response_ids, + first_audio_delta_before_done=first_audio_delta_before_done, + ) + return { + "label": label, + "input_wav": input_wav, + "output_dir": output_dir, + "returncode": returncode, + "ok": returncode == 0 and result.get("ok") is True and response_audio_contract_ok, + "result_ok": result.get("ok"), + "response_ids": response_ids, + "response_id_nonempty": response_id_nonempty, + "audio_bytes": result.get("audio_bytes", 0), + "audio_delta_count": len(audio_delta_indices), + "audio_delta_response_ids": delta_response_ids, + "audio_delta_timings": audio_delta_timings, + "all_audio_deltas_same_response": all_audio_deltas_same_response, + "first_audio_delta_before_done": first_audio_delta_before_done, + "response_audio_contract_ok": response_audio_contract_ok, + "event_count": len(events), + "stdout_tail": stdout[-4000:], + "stderr_tail": stderr[-4000:], + } + + +def _identity_isolation_ok(sessions: list[dict[str, object]]) -> bool: + seen: set[str] = set() + for session in sessions: + ids = {response_id for response_id in session.get("response_ids", []) if isinstance(response_id, str)} + if not ids: + return False + if ids & seen: + return False + seen.update(ids) + return True + + +async def _run_demo_process( + *, + label: str, + args: argparse.Namespace, + input_wav: str, + output_dir: str, +) -> dict[str, object]: + command = [ + sys.executable, + str(DEMO_PATH), + "--url", + args.url, + "--model", + args.model, + "--input-wav", + input_wav, + "--output-dir", + output_dir, + "--chunk-ms", + str(args.chunk_ms), + "--timeout-s", + str(args.timeout_s), + "--session-id", + f"duplex-pair-{label}-{uuid.uuid4().hex}", + ] + if args.no_realtime_pacing: + command.append("--no-realtime-pacing") + if args.require_audio: + command.append("--require-audio") + command.extend(["--ref-audio", str(args.ref_audio)]) + + process = await asyncio.create_subprocess_exec( + *command, + cwd=str(REPO_ROOT), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout_bytes, stderr_bytes = await asyncio.wait_for( + process.communicate(), + timeout=args.timeout_s + 15.0, + ) + except TimeoutError: + process.kill() + stdout_bytes, stderr_bytes = await process.communicate() + return _summarize_session( + label=label, + input_wav=input_wav, + output_dir=output_dir, + returncode=-1, + stdout=stdout_bytes.decode(errors="replace"), + stderr=(stderr_bytes.decode(errors="replace") + "\nclient timeout"), + ) + return _summarize_session( + label=label, + input_wav=input_wav, + output_dir=output_dir, + returncode=process.returncode if process.returncode is not None else -1, + stdout=stdout_bytes.decode(errors="replace"), + stderr=stderr_bytes.decode(errors="replace"), + ) + + +async def run_pair(args: argparse.Namespace) -> dict[str, object]: + validated = _validate_pair_args(args) + Path(validated["output_dir_a"]).mkdir(parents=True, exist_ok=True) + Path(validated["output_dir_b"]).mkdir(parents=True, exist_ok=True) + + sessions = await asyncio.gather( + _run_demo_process( + label="a", + args=args, + input_wav=str(validated["input_wav_a"]), + output_dir=str(validated["output_dir_a"]), + ), + _run_demo_process( + label="b", + args=args, + input_wav=str(validated["input_wav_b"]), + output_dir=str(validated["output_dir_b"]), + ), + ) + identity_isolation_ok = _identity_isolation_ok(sessions) + min_audio_delta_ok = all( + int(session.get("audio_delta_count", 0)) >= args.min_audio_deltas_per_session for session in sessions + ) + response_audio_contract_ok = all(session.get("response_audio_contract_ok") is True for session in sessions) + result = { + "ok": ( + all(session.get("ok") is True for session in sessions) + and identity_isolation_ok + and min_audio_delta_ok + and response_audio_contract_ok + ), + "input_wavs_distinct": True, + "output_dirs_distinct": True, + "identity_isolation_ok": identity_isolation_ok, + "min_audio_deltas_per_session": args.min_audio_deltas_per_session, + "min_audio_delta_ok": min_audio_delta_ok, + "response_audio_contract_ok": response_audio_contract_ok, + "inputs": { + "a": validated["input_wav_a"], + "b": validated["input_wav_b"], + "sha256_a": validated["input_sha256_a"], + "sha256_b": validated["input_sha256_b"], + }, + "sessions": sessions, + } + summary_output = ( + Path(args.summary_output) if args.summary_output else Path(validated["output_dir_a"]).parent / "summary.json" + ) + summary_output.parent.mkdir(parents=True, exist_ok=True) + summary_output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + return result + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--url", default="ws://127.0.0.1:8099/v1/realtime?duplex=1") + parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5") + parser.add_argument("--input-wav-a", required=True) + parser.add_argument("--input-wav-b", required=True) + parser.add_argument("--ref-audio", required=True) + parser.add_argument("--output-dir-a", required=True) + parser.add_argument("--output-dir-b", required=True) + 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("--no-realtime-pacing", action="store_true") + parser.add_argument("--require-audio", action="store_true") + parser.add_argument("--min-audio-deltas-per-session", type=int, default=2) + args = parser.parse_args() + if args.min_audio_deltas_per_session < 0: + parser.error("--min-audio-deltas-per-session must be non-negative") + return args + + +def main() -> None: + result = asyncio.run(run_pair(parse_args())) + print(json.dumps(result, ensure_ascii=False, indent=2)) + raise SystemExit(0 if result["ok"] else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py b/tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py new file mode 100644 index 000000000..e317c412c --- /dev/null +++ b/tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py @@ -0,0 +1,612 @@ +"""Concurrent MiniCPM-o Realtime duplex and resumable-session E2E driver.""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import sys +import uuid +from pathlib import Path +from types import SimpleNamespace +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +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)) + +from minicpmo_realtime_duplex_scenarios import ( # noqa: E402 + _ref_audio_data_url, + _url_with_model, + run_demo, +) + + +def _with_resume_mode(url: str) -> str: + parts = urlsplit(url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["resume"] = "1" + return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) + + +def _response_ids(result: dict[str, object]) -> set[str]: + values = result.get("completed_response_ids") + return {value for value in values if isinstance(value, str)} if isinstance(values, list) else set() + + +def _event_session_id(event: dict[str, object]) -> str | None: + session_id = event.get("session_id") + if isinstance(session_id, str): + return session_id + inner = event.get("event") + if isinstance(inner, dict): + nested_session_id = inner.get("session_id") + if isinstance(nested_session_id, str): + return nested_session_id + return None + + +def _error_code(event: dict[str, object]) -> str | None: + error = event.get("error") + if isinstance(error, dict) and isinstance(error.get("code"), str): + return error["code"] + code = event.get("code") + return code if isinstance(code, str) else None + + +def _validate_identity_isolation(results: list[dict[str, object]]) -> bool: + seen: set[str] = set() + for result in results: + current = _response_ids(result) + if current & seen: + return False + seen.update(current) + return True + + +def _validate_semantic_isolation( + results: list[dict[str, object]], + *, + input_wavs: list[str], + expected_tokens: list[str], +) -> bool: + if not input_wavs: + return True + if len(results) != len(input_wavs): + return False + input_hashes = [hashlib.sha256(Path(path).read_bytes()).digest() for path in input_wavs] + if len(set(input_hashes)) != len(input_hashes): + return False + if not expected_tokens: + return True + if len(expected_tokens) != len(results): + return False + for result, expected_token in zip(results, expected_tokens, strict=True): + details = result.get("transcript_integrity") + transcripts = ( + [str(item.get("transcript", "")) for item in details if isinstance(item, dict)] + if isinstance(details, list) + else [] + ) + if expected_token not in "".join(transcripts): + return False + return True + + +async def _receive_until(ws, event_type: str, *, timeout_s: float) -> tuple[dict[str, object], list[dict[str, object]]]: + async def receive() -> tuple[dict[str, object], list[dict[str, object]]]: + events: list[dict[str, object]] = [] + while True: + raw = await ws.recv() + if not isinstance(raw, str): + continue + event = json.loads(raw) + if not isinstance(event, dict): + continue + events.append(event) + if event.get("type") == event_type: + return event, events + + return await asyncio.wait_for(receive(), timeout=timeout_s) + + +async def _open_admission_session( + args: argparse.Namespace, + session_id: str, +) -> tuple[object, dict[str, object]]: + url = _url_with_model( + args.url, + args.model, + autostart=False if getattr(args, "ref_audio", None) else None, + session_id=session_id, + ) + ws = await websockets.connect(url, max_size=64 * 1024 * 1024) + await ws.send( + json.dumps( + { + "type": "session.update", + "session": { + "session_id": session_id, + "model": args.model, + "modalities": ["audio", "text"], + "extra_body": {"minicpmo45_native_duplex": True}, + **({"ref_audio": _ref_audio_data_url(args.ref_audio)} if getattr(args, "ref_audio", None) else {}), + }, + } + ) + ) + created, _ = await _receive_until(ws, "session.created", timeout_s=args.timeout_s) + return ws, created + + +async def _close_admission_session(ws, *, timeout_s: float) -> None: + await ws.send(json.dumps({"type": "session.close"})) + await _receive_until(ws, "session.closed", timeout_s=timeout_s) + await ws.close() + + +async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str, object]: + if limit < 1: + raise ValueError("admission limit must be positive") + prefix = f"admission-{uuid.uuid4().hex}" + accepted: list[tuple[object, dict[str, object]]] = [] + replacement: tuple[object, dict[str, object]] | None = None + overflow_code = None + try: + for index in range(limit): + accepted.append(await _open_admission_session(args, f"{prefix}-accepted-{index}")) + + overflow_id = f"{prefix}-overflow" + overflow_url = _url_with_model( + args.url, + args.model, + autostart=False if getattr(args, "ref_audio", None) else None, + session_id=overflow_id, + ) + async with websockets.connect(overflow_url, max_size=64 * 1024 * 1024) as overflow: + await overflow.send( + json.dumps( + { + "type": "session.update", + "session": { + "session_id": overflow_id, + "model": args.model, + "modalities": ["audio", "text"], + "extra_body": {"minicpmo45_native_duplex": True}, + **( + {"ref_audio": _ref_audio_data_url(args.ref_audio)} + if getattr(args, "ref_audio", None) + else {} + ), + }, + } + ) + ) + error, _ = await _receive_until(overflow, "error", timeout_s=args.timeout_s) + overflow_code = _error_code(error) + + first_ws, _ = accepted.pop(0) + await _close_admission_session(first_ws, timeout_s=args.timeout_s) + replacement = await _open_admission_session(args, f"{prefix}-replacement") + + first_capabilities = accepted[0][1].get("session") if accepted else replacement[1].get("session") + capabilities = first_capabilities.get("capabilities") if isinstance(first_capabilities, dict) else None + advertised_multi = capabilities.get("supports_multi_session") if isinstance(capabilities, dict) else None + admission_mode = capabilities.get("session_admission_mode") if isinstance(capabilities, dict) else None + return { + "ok": ( + overflow_code == "resource_exhausted" + and replacement[1].get("type") == "session.created" + and admission_mode == "engine_managed" + ), + "configured_limit": limit, + "accepted_before_rejection": limit, + "overflow_error_code": overflow_code, + "replacement_accepted": True, + "advertised_multi_session": advertised_multi, + "session_admission_mode": admission_mode, + } + finally: + cleanup = list(accepted) + if replacement is not None: + cleanup.append(replacement) + for ws, _ in cleanup: + try: + await _close_admission_session(ws, timeout_s=args.timeout_s) + except Exception: + await ws.close() + + +async def _resume_probe( + args: argparse.Namespace, + *, + session_id: str, + expect_expired: bool = False, +) -> dict[str, object]: + url = _url_with_model( + args.url, + args.model, + autostart=False if getattr(args, "ref_audio", None) else None, + session_id=session_id, + ) + async with websockets.connect(url, max_size=64 * 1024 * 1024) as first: + await first.send( + json.dumps( + { + "type": "session.update", + "session": { + "session_id": session_id, + "model": args.model, + "modalities": ["audio", "text"], + "extra_body": {"minicpmo45_native_duplex": True}, + **( + {"ref_audio": _ref_audio_data_url(args.ref_audio)} + if getattr(args, "ref_audio", None) + else {} + ), + }, + } + ) + ) + created, first_events = await _receive_until(first, "session.created", timeout_s=args.timeout_s) + token = created.get("resume_token") + incarnation = created.get("incarnation") + generation = created.get("attachment_generation") + if not isinstance(token, str) or not isinstance(incarnation, int): + raise RuntimeError("session.created omitted resumable credentials") + last_seq = max( + ( + event.get("server_event_seq", 0) + for event in first_events + if isinstance(event.get("server_event_seq"), int) + ), + default=0, + ) + + delay_s = args.expire_after_s if expect_expired else args.resume_after_ms / 1000 + if delay_s > 0: + await asyncio.sleep(delay_s) + + resume_url = _with_resume_mode(url) + async with websockets.connect(resume_url, max_size=64 * 1024 * 1024) as second: + await second.send( + json.dumps( + { + "type": "session.resume", + "session_id": session_id, + "incarnation": incarnation, + "resume_token": token, + "last_received_server_event_seq": last_seq, + } + ) + ) + if expect_expired: + error, error_events = await _receive_until(second, "error", timeout_s=args.timeout_s) + error_payload = error.get("error") + code = error_payload.get("code") if isinstance(error_payload, dict) else error.get("code") + return { + "ok": code == "session_resume_expired", + "session_id": session_id, + "expired": True, + "error_code": code, + "event_count": len(error_events), + } + resumed, replay = await _receive_until(second, "session.resumed", timeout_s=args.timeout_s) + rotated = resumed.get("resume_token") + if not isinstance(rotated, str) or rotated == token: + raise RuntimeError("session.resume did not rotate the resume token") + await second.send(json.dumps({"type": "session.heartbeat"})) + heartbeat, heartbeat_events = await _receive_until( + second, + "session.heartbeat_ack", + timeout_s=args.timeout_s, + ) + await second.send(json.dumps({"type": "session.close"})) + closed, close_events = await _receive_until(second, "session.closed", timeout_s=args.timeout_s) + + replay_sequences = [event["server_event_seq"] for event in replay if isinstance(event.get("server_event_seq"), int)] + return { + "ok": ( + resumed.get("session_id") == session_id + and isinstance(generation, int) + and resumed.get("attachment_generation") == generation + 1 + and _event_session_id(heartbeat) == session_id + and _event_session_id(closed) == session_id + and replay_sequences == sorted(replay_sequences) + ), + "session_id": session_id, + "resumed_session_id": resumed.get("session_id"), + "heartbeat_session_id": _event_session_id(heartbeat), + "closed_session_id": _event_session_id(closed), + "initial_attachment_generation": generation, + "resumed_attachment_generation": resumed.get("attachment_generation"), + "replayed_event_count": len(replay_sequences), + "replayed_event_sequences": replay_sequences, + "heartbeat_event_count": len(heartbeat_events), + "close_event_count": len(close_events), + "token_rotated": True, + } + + +async def _takeover_probe( + args: argparse.Namespace, + *, + session_id: str, +) -> dict[str, object]: + url = _url_with_model( + args.url, + args.model, + autostart=False if getattr(args, "ref_audio", None) else None, + session_id=session_id, + ) + first = await websockets.connect(url, max_size=64 * 1024 * 1024) + second = None + try: + await first.send( + json.dumps( + { + "type": "session.update", + "session": { + "session_id": session_id, + "model": args.model, + "modalities": ["audio", "text"], + "extra_body": {"minicpmo45_native_duplex": True}, + **( + {"ref_audio": _ref_audio_data_url(args.ref_audio)} + if getattr(args, "ref_audio", None) + else {} + ), + }, + } + ) + ) + created, first_events = await _receive_until(first, "session.created", timeout_s=args.timeout_s) + token = created.get("resume_token") + incarnation = created.get("incarnation") + generation = created.get("attachment_generation") + if not isinstance(token, str) or not isinstance(incarnation, int) or not isinstance(generation, int): + raise RuntimeError("session.created omitted takeover credentials") + last_seq = max( + ( + event.get("server_event_seq", 0) + for event in first_events + if isinstance(event.get("server_event_seq"), int) + ), + default=0, + ) + + second = await websockets.connect(_with_resume_mode(url), max_size=64 * 1024 * 1024) + await second.send( + json.dumps( + { + "type": "session.resume", + "session_id": session_id, + "incarnation": incarnation, + "resume_token": token, + "last_received_server_event_seq": last_seq, + } + ) + ) + resumed, replay_events = await _receive_until(second, "session.resumed", timeout_s=args.timeout_s) + replaced, replaced_events = await _receive_until(first, "session.replaced", timeout_s=args.timeout_s) + await asyncio.wait_for(first.wait_closed(), timeout=args.timeout_s) + + rejected_old_writes = 0 + for _ in range(4): + try: + await first.send(json.dumps({"type": "session.heartbeat"})) + except ConnectionClosed: + rejected_old_writes += 1 + + await second.send(json.dumps({"type": "session.heartbeat"})) + heartbeat, heartbeat_events = await _receive_until( + second, + "session.heartbeat_ack", + timeout_s=args.timeout_s, + ) + await second.send(json.dumps({"type": "session.close"})) + closed, close_events = await _receive_until(second, "session.closed", timeout_s=args.timeout_s) + rotated_token = resumed.get("resume_token") + return { + "ok": ( + resumed.get("session_id") == session_id + and resumed.get("attachment_generation") == generation + 1 + and isinstance(rotated_token, str) + and rotated_token != token + and _event_session_id(replaced) == session_id + and replaced.get("attachment_generation") == generation + and rejected_old_writes == 4 + and _event_session_id(heartbeat) == session_id + and _event_session_id(closed) == session_id + ), + "session_id": session_id, + "initial_attachment_generation": generation, + "resumed_attachment_generation": resumed.get("attachment_generation"), + "replaced_attachment_generation": replaced.get("attachment_generation"), + "token_rotated": isinstance(rotated_token, str) and rotated_token != token, + "old_attachment_closed": True, + "rejected_old_writes": rejected_old_writes, + "replay_event_count": len(replay_events), + "replaced_event_count": len(replaced_events), + "heartbeat_event_count": len(heartbeat_events), + "close_event_count": len(close_events), + } + finally: + if second is not None: + await second.close() + await first.close() + + +def _demo_args(args: argparse.Namespace, index: int) -> SimpleNamespace: + validation_mode = "response-required" if args.response_required else "model-policy" + input_wav = args.session_input_wav[index] if args.session_input_wav else args.input_wav + return SimpleNamespace( + url=args.url, + model=args.model, + session_id=f"multi-{index}-{uuid.uuid4().hex}", + input_wav=input_wav, + ref_audio=args.ref_audio, + turn_input_wav=list(args.turn_input_wav), + output_dir=str(Path(args.output_dir) / f"session_{index:02d}"), + output_audio_format="pcm16", + chunk_ms=args.chunk_ms, + realtime_input=args.realtime_input, + first_turn_ms=args.first_turn_ms, + turn_duration_ms=list(args.turn_duration_ms), + first_turn_transcript=f"session {index} input", + omit_transcript_hints=True, + validation_mode=validation_mode, + temperature=args.temperature, + scenario="sequential", + require_audio=args.response_required, + require_distinct_inputs=False, + expect_empty_turn=[], + short_ack_ms=350, + turns=args.turns, + timeout_s=args.timeout_s, + model_policy_settle_ms=args.model_policy_settle_ms, + ) + + +async def run_multi_session(args: argparse.Namespace) -> dict[str, object]: + if args.sessions < 1: + raise ValueError("--sessions must be positive") + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + resume_result = None + if args.disconnect_session_index is not None: + if not 0 <= args.disconnect_session_index < args.sessions: + raise ValueError("--disconnect-session-index is outside the session range") + resume_result = await _resume_probe( + args, + session_id=f"resume-{args.disconnect_session_index}-{uuid.uuid4().hex}", + ) + takeover_result = None + if args.takeover_session_index is not None: + if not 0 <= args.takeover_session_index < args.sessions: + raise ValueError("--takeover-session-index is outside the session range") + takeover_result = await _takeover_probe( + args, + session_id=f"takeover-{args.takeover_session_index}-{uuid.uuid4().hex}", + ) + lifecycle_result = await run_lifecycle_probes(args) + + session_results = await asyncio.gather( + *(run_demo(_demo_args(args, index)) for index in range(args.sessions)), + return_exceptions=True, + ) + failures = [repr(result) for result in session_results if isinstance(result, BaseException)] + completed = [result for result in session_results if isinstance(result, dict)] + identity_isolation_ok = _validate_identity_isolation(completed) + semantic_isolation_ok = _validate_semantic_isolation( + completed, + input_wavs=list(args.session_input_wav), + expected_tokens=list(args.session_expected_token), + ) + result = { + "ok": ( + not failures + and len(completed) == args.sessions + and all(item.get("ok") is True for item in completed) + and identity_isolation_ok + and semantic_isolation_ok + and (resume_result is None or resume_result.get("ok") is True) + and (takeover_result is None or takeover_result.get("ok") is True) + and lifecycle_result["ok"] is True + ), + "session_count": args.sessions, + "identity_isolation_ok": identity_isolation_ok, + "semantic_isolation_ok": semantic_isolation_ok, + "resume": resume_result, + "takeover": takeover_result, + "expiry": lifecycle_result["expiry"], + "admission": lifecycle_result["admission"], + "failures": failures, + "sessions": completed, + } + (output_dir / "summary.json").write_text( + json.dumps(result, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return result + + +async def run_lifecycle_probes(args: argparse.Namespace) -> dict[str, object]: + """Run expiry and admission probes without requiring model output.""" + if args.sessions < 1: + raise ValueError("--sessions must be positive") + expiry_result = None + if args.expire_session_index is not None: + if not 0 <= args.expire_session_index < args.sessions: + raise ValueError("--expire-session-index is outside the session range") + expiry_result = await _resume_probe( + args, + session_id=f"expire-{args.expire_session_index}-{uuid.uuid4().hex}", + expect_expired=True, + ) + admission_result = ( + await _admission_probe(args, limit=args.verify_admission_limit) + if args.verify_admission_limit is not None + else None + ) + return { + "ok": ( + (expiry_result is None or expiry_result.get("ok") is True) + and (admission_result is None or admission_result.get("ok") is True) + ), + "expiry": expiry_result, + "admission": admission_result, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--url", default="ws://127.0.0.1:8113/v1/realtime?duplex=1") + parser.add_argument("--base-url", help="Deprecated alias; /v1/realtime is appended when supplied.") + parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5") + parser.add_argument("--sessions", type=int, default=2) + parser.add_argument("--input-wav", required=True) + parser.add_argument("--ref-audio", help="Optional WAV used as the MiniCPM-o voice prompt for every session.") + parser.add_argument("--session-input-wav", action="append", default=[]) + parser.add_argument("--session-expected-token", action="append", default=[]) + parser.add_argument("--turn-input-wav", action="append", default=[]) + parser.add_argument("--output-dir", default="/tmp/minicpmo_pr3907_multi_session_e2e") + parser.add_argument("--realtime-input", action="store_true") + parser.add_argument("--chunk-ms", type=int, default=200) + parser.add_argument("--turns", type=int, default=1) + parser.add_argument("--first-turn-ms", type=int, default=1400) + parser.add_argument("--turn-duration-ms", type=int, action="append", default=[]) + parser.add_argument("--response-required", action="store_true") + parser.add_argument("--temperature", type=float, default=None) + parser.add_argument("--disconnect-session-index", type=int) + parser.add_argument("--takeover-session-index", type=int) + parser.add_argument("--resume-after-ms", type=int, default=1000) + parser.add_argument("--expire-session-index", type=int) + parser.add_argument("--expire-after-s", type=float, default=6.0) + parser.add_argument("--verify-admission-limit", type=int) + parser.add_argument("--model-policy-settle-ms", type=int, default=600) + parser.add_argument("--timeout-s", type=float, default=120.0) + args = parser.parse_args() + if args.base_url: + args.url = args.base_url.rstrip("/") + "/v1/realtime?duplex=1" + if args.session_input_wav and len(args.session_input_wav) != args.sessions: + parser.error("provide exactly one --session-input-wav per session") + if args.session_expected_token and len(args.session_expected_token) != args.sessions: + parser.error("provide exactly one --session-expected-token per session") + if args.session_expected_token and not args.session_input_wav: + parser.error("--session-expected-token requires --session-input-wav") + return args + + +def main() -> None: + result = asyncio.run(run_multi_session(parse_args())) + print(json.dumps(result, ensure_ascii=False, indent=2)) + raise SystemExit(0 if result["ok"] else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py b/tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py new file mode 100644 index 000000000..57d9e433f --- /dev/null +++ b/tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py @@ -0,0 +1,484 @@ +"""Validate MiniCPM-o Realtime duplex soft-interrupt delta streaming. + +This E2E driver runs the public ``realtime_duplex_demo.py`` against a live +duplex backend. Arbitrary audio defaults to the model-policy lifecycle contract. +The stronger response-required mode binds the multi-response contract to a +known input checksum and expected follow-up response. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import hashlib +import io +import json +import sys +import uuid +import wave +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +DEMO_PATH = REPO_ROOT / "examples/online_serving/minicpmo/realtime_duplex_demo.py" +AUDIO_DELTA_EVENTS = {"response.audio.delta", "response.output_audio.delta"} +TRANSCRIPT_DELTA_EVENTS = { + "response.audio_transcript.delta", + "response.output_text.delta", +} + + +def _canonical_path(path: str) -> Path: + return Path(path).expanduser().resolve(strict=False) + + +def _validate_input_sha256(path: Path, expected: str) -> str: + actual = hashlib.sha256(path.read_bytes()).hexdigest() + if actual != expected.lower(): + raise ValueError(f"input WAV SHA256 mismatch: expected {expected.lower()}, got {actual}") + return actual + + +def _input_duration_s(path: Path) -> float: + with wave.open(str(path), "rb") as wav_file: + return wav_file.getnframes() / wav_file.getframerate() + + +def _client_process_timeout_s(input_wav: Path, protocol_timeout_s: float) -> float: + # The child first streams the WAV in real time, then can independently + # exhaust its post-commit and session-close protocol waits. + return _input_duration_s(input_wav) + 2 * protocol_timeout_s + 30.0 + + +def _read_json(path: Path) -> dict[str, object]: + if not path.is_file(): + return {} + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + + +def _read_events(path: Path) -> list[dict[str, object]]: + if not path.is_file(): + return [] + events: list[dict[str, object]] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + event = json.loads(line) + if isinstance(event, dict): + events.append(event) + return events + + +def _event_response_id(event: dict[str, object]) -> str | None: + response_id = event.get("response_id") + if isinstance(response_id, str): + return response_id + response = event.get("response") + if isinstance(response, dict) and isinstance(response.get("id"), str): + return response["id"] + return None + + +def _event_received_at_s(event: dict[str, object]) -> float | None: + received_at = event.get("_client_received_at_s") + if isinstance(received_at, int | float): + return float(received_at) + return None + + +def _first_event_index(events: list[dict[str, object]], event_type: str) -> int | None: + for index, event in enumerate(events): + if event.get("type") == event_type: + return index + return None + + +def _event_counts(events: list[dict[str, object]]) -> dict[str, int]: + counts: dict[str, int] = {} + for event in events: + event_type = str(event.get("type")) + counts[event_type] = counts.get(event_type, 0) + 1 + return counts + + +def _response_created_ids(events: list[dict[str, object]]) -> list[str]: + response_ids: list[str] = [] + for event in events: + if event.get("type") != "response.created": + continue + response_id = _event_response_id(event) + if response_id and response_id not in response_ids: + response_ids.append(response_id) + return response_ids + + +def _audio_delta_duration_ms(event: dict[str, object]) -> float | None: + encoded = event.get("delta") or event.get("audio") + if not isinstance(encoded, str) or not encoded: + return None + try: + raw = base64.b64decode(encoded) + except ValueError: + return None + fmt = str(event.get("format") or event.get("audio_format") or "pcm16").lower() + sample_rate = event.get("sample_rate_hz") or event.get("sample_rate") or 24_000 + if not isinstance(sample_rate, int | float) or sample_rate <= 0: + return None + if "wav" in fmt: + try: + with wave.open(io.BytesIO(raw), "rb") as wav_file: + return round((wav_file.getnframes() * 1000.0) / wav_file.getframerate(), 3) + except (EOFError, wave.Error): + return None + bytes_per_sample = 4 if "f32" in fmt else 2 + return round((len(raw) * 1000.0) / (bytes_per_sample * float(sample_rate)), 3) + + +def _normalize_text(text: str) -> str: + return "".join(text.split()) + + +def _compact_sequence(events: list[dict[str, object]]) -> list[str]: + sequence: list[str] = [] + for event in events: + event_type = event.get("type") + response_id = _event_response_id(event) + suffix = f" {response_id[-8:]}" if response_id else "" + if event_type == "response.listen": + sequence.append("listen") + elif event_type == "response.created": + sequence.append(f"response.created{suffix}") + elif event_type == "response.speak": + sequence.append(f"speak{suffix}") + elif event_type in AUDIO_DELTA_EVENTS: + sequence.append(f"audio.delta{suffix}") + elif event_type in TRANSCRIPT_DELTA_EVENTS: + delta = str(event.get("delta") or "") + sequence.append(f"text.delta{suffix}> {delta}") + elif event_type == "response.done": + sequence.append(f"response.done{suffix}") + elif event_type == "input_audio_buffer.committed": + sequence.append("input.committed") + return sequence + + +def _response_summary( + events: list[dict[str, object]], + response_id: str, + *, + t0: float | None, +) -> dict[str, object]: + created_indices = [ + index + for index, event in enumerate(events) + if event.get("type") == "response.created" and _event_response_id(event) == response_id + ] + done_indices = [ + index + for index, event in enumerate(events) + if event.get("type") == "response.done" and _event_response_id(event) == response_id + ] + audio_indices = [ + index + for index, event in enumerate(events) + if event.get("type") in AUDIO_DELTA_EVENTS and _event_response_id(event) == response_id + ] + transcript_indices = [ + index + for index, event in enumerate(events) + if event.get("type") in TRANSCRIPT_DELTA_EVENTS and _event_response_id(event) == response_id + ] + done_index = done_indices[0] if done_indices else None + audio_before_done_ok = done_index is not None and all(index < done_index for index in audio_indices) + stale_audio_count = sum(index > done_index for index in audio_indices) if done_index is not None else 0 + + def offset_ms(index: int) -> float | None: + if t0 is None: + return None + received_at = _event_received_at_s(events[index]) + return round((received_at - t0) * 1000.0, 3) if received_at is not None else None + + return { + "response_id": response_id, + "created_index": created_indices[0] if created_indices else None, + "done_indices": done_indices, + "created_offset_ms": offset_ms(created_indices[0]) if created_indices else None, + "done_offset_ms": offset_ms(done_indices[0]) if done_indices else None, + "audio_delta_count": len(audio_indices), + "transcript_delta_count": len(transcript_indices), + "audio_delta_offsets_ms": [offset_ms(index) for index in audio_indices], + "audio_duration_ms": [_audio_delta_duration_ms(events[index]) for index in audio_indices], + "transcript": "".join(str(events[index].get("delta") or "") for index in transcript_indices), + "one_done": len(done_indices) == 1, + "audio_before_done_ok": audio_before_done_ok, + "stale_audio_count": stale_audio_count, + } + + +def summarize_artifacts( + *, + output_dir: Path, + validation_mode: str, + min_responses: int, + min_audio_deltas_per_response: int, + expect_followup_response_substring: str | None, +) -> dict[str, object]: + if validation_mode not in {"model-policy", "response-required"}: + raise ValueError(f"unsupported validation mode: {validation_mode}") + result = _read_json(output_dir / "result.json") + events = _read_events(output_dir / "events.jsonl") + response_ids = _response_created_ids(events) + times = [_event_received_at_s(event) for event in events] + clean_times = [received_at for received_at in times if received_at is not None] + t0 = min(clean_times) if clean_times else None + response_summaries = [_response_summary(events, response_id, t0=t0) for response_id in response_ids] + commit_index = _first_event_index(events, "input_audio_buffer.committed") + first_created_index = next( + (summary["created_index"] for summary in response_summaries if isinstance(summary.get("created_index"), int)), + None, + ) + first_done_index = ( + response_summaries[0]["done_indices"][0] + if response_summaries and response_summaries[0]["done_indices"] + else None + ) + second_created_index = ( + response_summaries[1]["created_index"] + if len(response_summaries) >= 2 and isinstance(response_summaries[1].get("created_index"), int) + else None + ) + last_done_index = next( + (summary["done_indices"][0] for summary in reversed(response_summaries) if summary.get("done_indices")), + None, + ) + listen_indices = [index for index, event in enumerate(events) if event.get("type") == "response.listen"] + effective_min_responses = min_responses if validation_mode == "response-required" else 1 + enough_responses = len(response_summaries) >= effective_min_responses + response_lifecycle_ok = enough_responses and all(bool(summary.get("one_done")) for summary in response_summaries) + multi_delta_ok = enough_responses and all( + int(summary.get("audio_delta_count", 0)) >= min_audio_deltas_per_response for summary in response_summaries + ) + response_audio_contract_ok = enough_responses and all( + summary.get("audio_before_done_ok") is True and int(summary.get("stale_audio_count", 0)) == 0 + for summary in response_summaries + ) + response_before_final_commit = ( + first_created_index is not None and commit_index is not None and first_created_index < commit_index + ) + second_response_before_final_commit = ( + second_created_index is not None and commit_index is not None and second_created_index < commit_index + ) + listen_before_first_response = first_created_index is not None and any( + index < first_created_index for index in listen_indices + ) + listen_between_responses = ( + first_done_index is not None + and second_created_index is not None + and any(first_done_index < index < second_created_index for index in listen_indices) + ) + listen_after_last_done = last_done_index is not None and any(index > last_done_index for index in listen_indices) + listen_after_response_before_commit = ( + last_done_index is not None + and commit_index is not None + and any(last_done_index < index < commit_index for index in listen_indices) + ) + final_listen_after_commit = commit_index is not None and any(index > commit_index for index in listen_indices) + transcript = "".join(str(summary.get("transcript") or "") for summary in response_summaries) + followup_response_transcripts = [ + str(summary.get("transcript") or "") + for summary in response_summaries[1:] + if commit_index is not None + and isinstance(summary.get("created_index"), int) + and summary["created_index"] < commit_index + ] + followup_response_transcript_ok = any( + bool(_normalize_text(transcript)) for transcript in followup_response_transcripts + ) + followup_response_transcript_expectation_ok = not expect_followup_response_substring or any( + _normalize_text(expect_followup_response_substring) in _normalize_text(transcript) + for transcript in followup_response_transcripts + ) + error_events = [ + event + for event in events + if event.get("type") == "error" + or str(event.get("type") or "").endswith(".cancelled") + or str(event.get("type") or "").endswith(".truncated") + ] + cancelled_count = sum( + 1 + for event in events + if event.get("type") == "response.done" + and isinstance(event.get("response"), dict) + and event["response"].get("status") == "cancelled" + ) + result_ok = result.get("ok") is True + common_contract_ok = bool( + result_ok + and not error_events + and cancelled_count == 0 + and enough_responses + and response_lifecycle_ok + 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 + ) + mode_contract_ok = validation_mode == "model-policy" or ( + second_response_before_final_commit + and listen_before_first_response + and listen_after_last_done + and followup_response_transcript_ok + ) + ok = common_contract_ok and mode_contract_ok + return { + "ok": ok, + "result_ok": result_ok, + "validation_mode": validation_mode, + "event_counts": _event_counts(events), + "response_ids": response_ids, + "response_summaries": response_summaries, + "min_responses": min_responses, + "effective_min_responses": effective_min_responses, + "min_audio_deltas_per_response": min_audio_deltas_per_response, + "enough_responses": enough_responses, + "response_lifecycle_ok": response_lifecycle_ok, + "multi_delta_ok": multi_delta_ok, + "response_audio_contract_ok": response_audio_contract_ok, + "response_before_final_commit": response_before_final_commit, + "second_response_before_final_commit": second_response_before_final_commit, + "listen_before_first_response": listen_before_first_response, + "listen_between_responses": listen_between_responses, + "listen_after_last_done": listen_after_last_done, + "listen_after_response_before_commit": listen_after_response_before_commit, + "final_listen_after_commit": final_listen_after_commit, + "transcript": transcript, + "followup_response_transcripts": followup_response_transcripts, + "followup_response_transcript_ok": followup_response_transcript_ok, + "expect_followup_response_substring": expect_followup_response_substring, + "followup_response_transcript_expectation_ok": followup_response_transcript_expectation_ok, + "error_count": len(error_events), + "cancelled_count": cancelled_count, + "compact_sequence": _compact_sequence(events), + "output_dir": str(output_dir), + } + + +async def run_soft_interrupt(args: argparse.Namespace) -> dict[str, object]: + input_wav = _canonical_path(args.input_wav) + output_dir = _canonical_path(args.output_dir) + if not input_wav.is_file(): + raise ValueError(f"input WAV does not exist: {input_wav}") + input_sha256 = hashlib.sha256(input_wav.read_bytes()).hexdigest() + if args.input_sha256: + input_sha256 = _validate_input_sha256(input_wav, args.input_sha256) + command = [ + sys.executable, + str(DEMO_PATH), + "--url", + args.url, + "--model", + args.model, + "--input-wav", + str(input_wav), + "--output-dir", + str(output_dir), + "--chunk-ms", + str(args.chunk_ms), + "--timeout-s", + str(args.timeout_s), + "--session-id", + f"duplex-soft-interrupt-{uuid.uuid4().hex}", + ] + command.extend(["--ref-audio", str(_canonical_path(args.ref_audio))]) + if args.require_audio: + command.append("--require-audio") + if args.no_realtime_pacing: + command.append("--no-realtime-pacing") + + process = await asyncio.create_subprocess_exec( + *command, + cwd=str(REPO_ROOT), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout_bytes, stderr_bytes = await asyncio.wait_for( + process.communicate(), + timeout=_client_process_timeout_s(input_wav, args.timeout_s), + ) + except TimeoutError: + process.kill() + stdout_bytes, stderr_bytes = await process.communicate() + return { + "ok": False, + "returncode": -1, + "stdout_tail": stdout_bytes.decode(errors="replace")[-4000:], + "stderr_tail": (stderr_bytes.decode(errors="replace") + "\nclient timeout")[-4000:], + "output_dir": str(output_dir), + } + + summary = summarize_artifacts( + output_dir=output_dir, + validation_mode=args.validation_mode, + min_responses=args.min_responses, + min_audio_deltas_per_response=args.min_audio_deltas_per_response, + expect_followup_response_substring=args.expect_followup_response_substring, + ) + summary.update( + { + "returncode": process.returncode if process.returncode is not None else -1, + "input_wav": str(input_wav), + "input_sha256": input_sha256, + "stdout_tail": stdout_bytes.decode(errors="replace")[-4000:], + "stderr_tail": stderr_bytes.decode(errors="replace")[-4000:], + } + ) + summary["ok"] = bool(summary["ok"] and summary["returncode"] == 0) + summary_output = Path(args.summary_output) if args.summary_output else output_dir / "summary.json" + summary_output.parent.mkdir(parents=True, exist_ok=True) + summary_output.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + return summary + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--url", default="ws://127.0.0.1:8099/v1/realtime?duplex=1") + parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5") + parser.add_argument("--input-wav", required=True) + parser.add_argument("--ref-audio", required=True) + parser.add_argument("--output-dir", required=True) + 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("--require-audio", action="store_true") + parser.add_argument("--no-realtime-pacing", action="store_true") + parser.add_argument( + "--validation-mode", + choices=("model-policy", "response-required"), + default="model-policy", + ) + parser.add_argument("--min-responses", type=int, default=2) + parser.add_argument("--min-audio-deltas-per-response", type=int, default=2) + parser.add_argument("--input-sha256") + parser.add_argument("--expect-followup-response-substring") + args = parser.parse_args() + if args.validation_mode == "response-required" and args.min_responses < 2: + parser.error("--min-responses must be at least 2") + if args.validation_mode == "response-required" and not args.input_sha256: + parser.error("--input-sha256 is required in response-required mode") + if args.min_audio_deltas_per_response < 1: + parser.error("--min-audio-deltas-per-response must be positive") + return args + + +def main() -> None: + result = asyncio.run(run_soft_interrupt(parse_args())) + print(json.dumps(result, ensure_ascii=False, indent=2)) + raise SystemExit(0 if result["ok"] else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/online_serving/stage_configs/step_audio2_ci.yaml b/tests/e2e/online_serving/stage_configs/step_audio2_ci.yaml deleted file mode 100644 index 703a7cdcc..000000000 --- a/tests/e2e/online_serving/stage_configs/step_audio2_ci.yaml +++ /dev/null @@ -1,81 +0,0 @@ -# Stage config for Step-Audio2 CI tests -# Stage 0: Thinker (audio understanding → text + audio tokens) -# Stage 1: Token2Wav (audio tokens → waveform) - -# The following config has been verified on 2x 24GB GPU (L4/RTX3090/RTX4090). -stage_args: - - stage_id: 0 - runtime: - process: true - devices: "0" - max_batch_size: 1 - engine_args: - model_stage: thinker - model_arch: StepAudio2ThinkerForConditionalGeneration - tensor_parallel_size: 1 - hf_overrides: - architectures: ["StepAudio2ThinkerForConditionalGeneration"] - - worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker - scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler - max_model_len: 1024 - max_num_batched_tokens: 1024 - max_num_seqs: 1 - gpu_memory_utilization: 0.7 - skip_mm_profiling: true - enforce_eager: true - trust_remote_code: true - enable_prefix_caching: false - engine_output_type: text # Output text + audio tokens - is_comprehension: true - final_output: true - final_output_type: text - default_sampling_params: - temperature: 0.7 - top_p: 0.9 - top_k: -1 - max_tokens: 256 - seed: 42 - detokenize: true - repetition_penalty: 1.05 - - - stage_id: 1 - runtime: - process: true - devices: "0" - max_batch_size: 1 - engine_args: - - model_stage: token2wav - model_arch: StepAudio2Token2WavModel - worker_cls: vllm_omni.worker.gpu_generation_worker.GPUGenerationWorker - scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler - hf_overrides: - architectures: ["StepAudio2Token2WavModel"] - gpu_memory_utilization: 0.2 - enforce_eager: true - trust_remote_code: true - enable_prefix_caching: false - max_num_batched_tokens: 1024 - engine_output_type: audio # Output waveform - engine_input_source: [0] - custom_process_input_func: vllm_omni.model_executor.stage_input_processors.step_audio2.thinker2token2wav - final_output: true - final_output_type: audio - default_sampling_params: - temperature: 0.0 - top_p: 1.0 - top_k: -1 - max_tokens: 1 - seed: 42 - detokenize: false - -runtime: - enabled: true - defaults: - window_size: -1 - max_inflight: 1 - edges: - - from: 0 - to: 1 - window_size: -1 diff --git a/tests/e2e/online_serving/test_audex_tta.py b/tests/e2e/online_serving/test_audex_tta.py new file mode 100644 index 000000000..cfcb722d0 --- /dev/null +++ b/tests/e2e/online_serving/test_audex_tta.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""E2E online tests for Audex (Nemotron-Labs-Audex-2B) text-to-audio. + +Covers /v1/audio/speech through the ``audex_tta`` deployment: +the ``audex_tta`` adapter builds the caption prompt and the serving layer +injects the RVQ phase contract plus the official default guidance +(cfg_scale 3.0) server-side. +""" + +import os + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniServerParams +from tests.helpers.stage_config import get_deploy_config_path + +pytestmark = [pytest.mark.slow, pytest.mark.tts] + +MODEL = "nvidia/Nemotron-Labs-Audex-2B" +CAPTION = "Heavy rain falling on a tin roof." +SAMPLE_RATE = 16_000 + +audex_tta_server_params = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=get_deploy_config_path("audex_tta.yaml"), + server_args=["--trust-remote-code"], + ), + id="audex_tta", + ) +] +# 30B-A3B variant: opt-in (pulls ~60 GB); enable with AUDEX_E2E_30B=1. +_AUDEX_30B_MODEL = os.environ.get("VLLM_OMNI_AUDEX_30B_MODEL_DIR") or "nvidia/Nemotron-Labs-Audex-30B-A3B" +if os.environ.get("AUDEX_E2E_30B") == "1": + audex_tta_server_params.append( + pytest.param( + OmniServerParams( + model=_AUDEX_30B_MODEL, + stage_config_path=get_deploy_config_path("audex_tta_30b.yaml"), + server_args=["--trust-remote-code"], + ), + id="online_tta_30b", + ) + ) + + +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", audex_tta_server_params, indirect=True) +def test_audex_tta_online_smoke(omni_server, openai_client, run_level) -> None: + """A caption returns a valid finite non-silent WAV at the default cfg 3.0.""" + import io + import wave + + import numpy as np + import requests + + url = f"{openai_client.base_url.rstrip('/')}/v1/audio/speech" + resp = requests.post( + url, + json={"model": omni_server.model, "input": CAPTION, "response_format": "wav"}, + timeout=600, + ) + assert resp.status_code == 200, resp.text[:200] + assert resp.content[:4] == b"RIFF", "not a WAV response" + + with wave.open(io.BytesIO(resp.content)) as wav: + assert wav.getframerate() == SAMPLE_RATE, f"unexpected sample rate {wav.getframerate()}" + frames = wav.readframes(wav.getnframes()) + duration_s = wav.getnframes() / wav.getframerate() + + audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0 + assert audio.size > 0, "empty audio body" + assert np.isfinite(audio).all(), "audio contains NaN/Inf" + + if run_level in {"advanced_model", "full_model"}: + # The decode cap is 500 frames = 10 s at 50 fps. + assert 0.1 <= duration_s <= 10.5, f"unexpected duration={duration_s:.3f}s" + rms = float(np.sqrt(np.mean(np.square(audio)))) + assert rms > 1e-4, f"audio is near-silent (rms={rms})" diff --git a/tests/e2e/online_serving/test_audex_tts.py b/tests/e2e/online_serving/test_audex_tts.py new file mode 100644 index 000000000..94f39c7b8 --- /dev/null +++ b/tests/e2e/online_serving/test_audex_tts.py @@ -0,0 +1,230 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""E2E online tests for Audex (Nemotron-Labs-Audex-2B) TTS. + +Covers /v1/audio/speech non-streaming and audio-byte streaming, plus the +Audex request policies: single built-in voice (missing/empty/"default" only), +no reference audio, and cfg_scale reserved at 1.0. +""" + +import os + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniServerParams +from tests.helpers.stage_config import get_deploy_config_path + +pytestmark = [pytest.mark.slow, pytest.mark.tts] + +MODEL = "nvidia/Nemotron-Labs-Audex-2B" +PROMPT = "The weather is so good, and I want to enjoy the beautiful morning in the park." + +audex_server_params = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=get_deploy_config_path("audex_tts.yaml"), + server_args=["--trust-remote-code"], + ), + id="audex", + ) +] +# 30B-A3B variant: opt-in (pulls ~60 GB); enable with AUDEX_E2E_30B=1. +_AUDEX_30B_MODEL = os.environ.get("VLLM_OMNI_AUDEX_30B_MODEL_DIR") or "nvidia/Nemotron-Labs-Audex-30B-A3B" +if os.environ.get("AUDEX_E2E_30B") == "1": + audex_server_params.append( + pytest.param( + OmniServerParams( + model=_AUDEX_30B_MODEL, + stage_config_path=get_deploy_config_path("audex_tts_30b.yaml"), + server_args=["--trust-remote-code"], + ), + id="online_tts_30b", + ) + ) + + +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True) +def test_audex_tts_nonstream(omni_server, openai_client) -> None: + """Plain English TTS, non-streaming WAV response.""" + request_config = { + "model": omni_server.model, + "input": PROMPT, + "stream": False, + "response_format": "wav", + } + openai_client.send_audio_speech_request(request_config) + + +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True) +def test_audex_tts_stream_audio(omni_server, openai_client) -> None: + """Plain English TTS, raw audio-byte streaming.""" + request_config = { + "model": omni_server.model, + "input": PROMPT, + "stream": True, + "stream_format": "audio", + "response_format": "wav", + } + openai_client.send_audio_speech_request(request_config) + + +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True) +def test_audex_tts_max_new_tokens_caps_duration(omni_server, openai_client, run_level) -> None: + """max_new_tokens must cap stage-0 generation (review P2 regression).""" + import io + import wave + + import requests + + if run_level not in {"advanced_model", "full_model"}: + pytest.skip("duration cap needs real weights") + + url = f"{openai_client.base_url.rstrip('/')}/v1/audio/speech" + resp = requests.post( + url, + json={ + "model": omni_server.model, + "input": PROMPT, + "response_format": "wav", + "max_new_tokens": 25, + }, + ) + assert resp.status_code == 200, resp.text + with wave.open(io.BytesIO(resp.content)) as wav: + duration_s = wav.getnframes() / wav.getframerate() + # 25 codec frames at 50 fps = 0.5 s (+ small lookahead tail); the full + # sentence would be ~3 s, so a capped result proves the override applied. + assert duration_s <= 1.0, f"max_new_tokens ignored: duration={duration_s:.2f}s" + + +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True) +def test_audex_tts_request_policies(omni_server, openai_client) -> None: + """Unsupported voice / CFG / empty input must be rejected with 400.""" + import requests + + url = f"{openai_client.base_url.rstrip('/')}/v1/audio/speech" + + ok = requests.post(url, json={"model": omni_server.model, "input": PROMPT, "voice": "default"}) + assert ok.status_code == 200, ok.text + + for bad_payload, needle in ( + ({"input": PROMPT, "voice": "alloy"}, "voice"), + ({"input": " "}, "non-empty"), + ({"input": PROMPT, "extra_params": {"cfg_scale": 0.5}}, "cfg_scale"), + ({"input": PROMPT, "extra_params": {"cfg_scale": 10.5}}, "cfg_scale"), + ({"input": PROMPT, "extra_params": {"cfg_scale": "big"}}, "cfg_scale"), + ({"input": PROMPT, "extra_params": {"cfg_role": "cond"}}, "cfg_role"), + ({"input": PROMPT, "extra_params": {"cfg_pair_id": "x"}}, "cfg_pair_id"), + ({"input": PROMPT, "ref_audio": "https://example.com/ref.wav"}, "reference audio"), + ): + resp = requests.post(url, json={"model": omni_server.model, **bad_payload}) + assert resp.status_code == 400, f"{bad_payload} -> {resp.status_code}: {resp.text[:200]}" + assert needle in resp.text, f"{bad_payload} error missing {needle!r}: {resp.text[:200]}" + + +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True) +def test_audex_tts_cfg_nonstream(omni_server, openai_client) -> None: + """Guided (cfg_scale=1.5) non-streaming request returns one WAV.""" + request_config = { + "model": omni_server.model, + "input": PROMPT, + "stream": False, + "response_format": "wav", + "extra_params": {"cfg_scale": 1.5}, + } + openai_client.send_audio_speech_request(request_config) + + +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True) +def test_audex_tts_cfg_stream_audio(omni_server, openai_client) -> None: + """Guided (cfg_scale=1.5) streaming request yields a single audio stream.""" + request_config = { + "model": omni_server.model, + "input": PROMPT, + "stream": True, + "stream_format": "audio", + "response_format": "wav", + "extra_params": {"cfg_scale": 1.5}, + } + openai_client.send_audio_speech_request(request_config) + + +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True) +def test_audex_tts_mixed_cfg_concurrency(omni_server, openai_client) -> None: + """Mixed guided/unguided requests at concurrency 4 all complete with audio.""" + import concurrent.futures + + import requests + + url = f"{openai_client.base_url.rstrip('/')}/v1/audio/speech" + payloads = [ + {"model": omni_server.model, "input": f"{PROMPT} Take {i}.", "response_format": "wav"} + | ({"extra_params": {"cfg_scale": 1.5}} if i % 2 == 0 else {}) + for i in range(4) + ] + + def _post(payload): + return requests.post(url, json=payload, timeout=300) + + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + responses = list(pool.map(_post, payloads)) + + for payload, resp in zip(payloads, responses): + assert resp.status_code == 200, f"{payload} -> {resp.status_code}: {resp.text[:200]}" + assert resp.content[:4] == b"RIFF", f"{payload}: not a WAV response" + assert len(resp.content) > 44, f"{payload}: empty audio body" + + +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True) +def test_audex_tts_stream_abort_leaves_server_healthy(omni_server, openai_client) -> None: + """Aborting a guided streaming request mid-flight must not wedge the server. + + The abort cascades to the CFG companion (cfg_companion_tracker); a + follow-up guided request on the same server must still succeed. + """ + import requests + + url = f"{openai_client.base_url.rstrip('/')}/v1/audio/speech" + long_input = " ".join([PROMPT] * 4) + + with requests.post( + url, + json={ + "model": omni_server.model, + "input": long_input, + "stream": True, + "stream_format": "audio", + "response_format": "wav", + "extra_params": {"cfg_scale": 1.5}, + }, + stream=True, + timeout=300, + ) as resp: + assert resp.status_code == 200, resp.text + # Read one chunk, then abort the connection mid-stream. + next(resp.iter_content(chunk_size=4096)) + + follow_up = requests.post( + url, + json={ + "model": omni_server.model, + "input": PROMPT, + "response_format": "wav", + "extra_params": {"cfg_scale": 1.5}, + }, + timeout=300, + ) + assert follow_up.status_code == 200, follow_up.text[:200] + assert follow_up.content[:4] == b"RIFF" and len(follow_up.content) > 44 diff --git a/tests/e2e/online_serving/test_boogu_image.py b/tests/e2e/online_serving/test_boogu_image.py new file mode 100644 index 000000000..5a5494d7d --- /dev/null +++ b/tests/e2e/online_serving/test_boogu_image.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +""" +Online serving tests for ``Boogu/Boogu-Image-0.1-Base`` (text-to-image). + +- ``test_text_to_image_001``: single chat request, default server (``_get_default_case``). +- ``test_batch_001``: concurrent prompts via ``send_diffusion_request([cfg0, cfg1, ...])`` — one + dict per prompt; each entry carries its own ``messages`` / ``negative_prompt`` (see + ``TEST_PROMPTS``). + +Boogu-Image's pipeline reads ``sp.guidance_scale`` (not ``true_cfg_scale``), so the +``extra_body`` uses ``guidance_scale``. These are smoke tests (minimal steps); numeric +parity vs. the Diffusers baseline is covered separately (see the support plan, step 14). + +From ``tests/``:: + + pytest -s -v e2e/online_serving/test_boogu_image.py \ + -m "full_model and diffusion and H100" --run-level=full_model +""" + +import os + +import pytest + +from tests.helpers.mark import hardware_marks +from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +pytestmark = [pytest.mark.diffusion, pytest.mark.full_model] + +MODEL = "Boogu/Boogu-Image-0.1-Base" +T2I_PROMPT = "A mountain lake at sunset, photorealistic, cinematic lighting" +NEGATIVE_PROMPT = "" + +SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}) + +TEST_PROMPTS: list[dict[str, str]] = [ + {"prompt": "a cup of coffee on a table", "negative_prompt": ""}, + {"prompt": "a toy dinosaur on a sandy beach", "negative_prompt": ""}, + {"prompt": "a futuristic city skyline at sunset", "negative_prompt": ""}, +] + + +def _get_default_case(model: str): + """Return a single default ``OmniServerParams`` row (no extra ``server_args``).""" + return [ + pytest.param( + OmniServerParams(model=model), + id="default", + marks=SINGLE_CARD_FEATURE_MARKS, + ), + ] + + +@pytest.mark.parametrize("omni_server", _get_default_case(MODEL), indirect=True) +def test_text_to_image_001(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: + """Default Boogu-Image T2I smoke (single ``default`` server config).""" + messages = dummy_messages_from_mix_data(content_text=T2I_PROMPT) + request_config = { + "model": omni_server.model, + "messages": messages, + "extra_body": { + "height": 512, + "width": 512, + "num_inference_steps": 2, + "negative_prompt": NEGATIVE_PROMPT, + "guidance_scale": 4.0, + "seed": 42, + }, + } + openai_client.send_diffusion_request(request_config) + + +@pytest.mark.parametrize("omni_server", _get_default_case(MODEL), indirect=True) +def test_batch_001(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: + """Concurrent T2I: one ``request_config`` dict per prompt (``send_diffusion_request`` list mode).""" + request_config = [ + { + "model": omni_server.model, + "messages": dummy_messages_from_mix_data(content_text=prompt["prompt"]), + "extra_body": { + "height": 512, + "width": 512, + "num_inference_steps": 2, + "negative_prompt": prompt["negative_prompt"], + "guidance_scale": 4.0, + "seed": 42, + }, + } + for prompt in TEST_PROMPTS + ] + openai_client.send_diffusion_request(request_config) diff --git a/tests/e2e/online_serving/test_boogu_image_edit.py b/tests/e2e/online_serving/test_boogu_image_edit.py new file mode 100644 index 000000000..45f999d26 --- /dev/null +++ b/tests/e2e/online_serving/test_boogu_image_edit.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +""" +Online serving tests for Boogu-Image-0.1-Edit (image-to-image via chat completions). + +The Edit checkpoint shares the ``BooguImagePipeline`` class with the Base +text-to-image checkpoint; the editing (TI2I) path activates when the request +carries a reference image. Text guidance rides on ``guidance_scale`` (upstream +default 4.0), and image guidance rides on ``guidance_scale_2`` (upstream default +1.0 = off; a value > 1 enables the double-guidance path). Only a single +reference image is supported. + +- ``test_single_image_to_image_001``: one reference image, text guidance only. +- ``test_double_guidance_001``: one reference image, text + image guidance. +- ``test_images_edits_endpoint_001``: same text-guided edit via ``/v1/images/edits``. +- ``test_images_edits_endpoint_double_guidance_001``: double guidance via ``/v1/images/edits``. + +From ``tests/``:: + + pytest -s -v e2e/online_serving/test_boogu_image_edit.py \ + -m "full_model and diffusion and H100" --run-level=full_model +""" + +import base64 + +import pytest + +from tests.helpers.mark import hardware_marks +from tests.helpers.media import generate_synthetic_image +from tests.helpers.runtime import ( + OmniServer, + OmniServerParams, + OpenAIClientHandler, + dummy_messages_from_mix_data, +) + +pytestmark = [pytest.mark.diffusion, pytest.mark.full_model] + +EDIT_PROMPT = "Change the style to a colored pencil drawing." +SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}) + + +def _get_diffusion_feature_cases(model: str): + """Return one ``default`` ``OmniServerParams`` row for ``model`` (no extra ``server_args``).""" + return [ + pytest.param( + OmniServerParams( + model=model, + ), + id="default", + marks=SINGLE_CARD_FEATURE_MARKS, + ), + ] + + +@pytest.mark.parametrize( + "omni_server", + _get_diffusion_feature_cases("Boogu/Boogu-Image-0.1-Edit"), + indirect=True, +) +def test_single_image_to_image_001(omni_server: OmniServer, openai_client: OpenAIClientHandler): + """Single-reference text-guided edit smoke for ``Boogu/Boogu-Image-0.1-Edit``.""" + image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}" + + messages = dummy_messages_from_mix_data(image_data_url=image_data_url, content_text=EDIT_PROMPT) + + request_config = { + "model": omni_server.model, + "messages": messages, + "extra_body": { + "num_inference_steps": 2, + # Boogu text guidance (upstream default 4.0); > 1 enables CFG. + "guidance_scale": 5.0, + "seed": 42, + }, + } + + openai_client.send_diffusion_request(request_config) + + +@pytest.mark.parametrize( + "omni_server", + _get_diffusion_feature_cases("Boogu/Boogu-Image-0.1-Edit"), + indirect=True, +) +def test_double_guidance_001(omni_server: OmniServer, openai_client: OpenAIClientHandler): + """Single-reference double-guidance edit (text + image guidance) smoke.""" + image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}" + + messages = dummy_messages_from_mix_data(image_data_url=image_data_url, content_text=EDIT_PROMPT) + + request_config = { + "model": omni_server.model, + "messages": messages, + "extra_body": { + "num_inference_steps": 2, + "guidance_scale": 5.0, + # Image guidance > 1 activates the 3-prediction double-guidance path. + "guidance_scale_2": 2.0, + "seed": 42, + }, + } + + openai_client.send_diffusion_request(request_config) + + +def _edits_request_config(model: str, *, guidance_scale_2: float | None = None) -> dict: + """Multipart ``/v1/images/edits`` config: one synthetic reference + smoke-depth params.""" + image_bytes = base64.b64decode(generate_synthetic_image(512, 512)["base64"]) + data = { + "model": model, + "prompt": EDIT_PROMPT, + "num_inference_steps": 2, + # Boogu text guidance (upstream default 4.0); > 1 enables CFG. + "guidance_scale": 5.0, + "seed": 42, + } + if guidance_scale_2 is not None: + data["guidance_scale_2"] = guidance_scale_2 + return { + "data": data, + "files": {"image": ("ref.jpg", image_bytes, "image/jpeg")}, + "timeout": 300, + } + + +def _assert_edits_response_has_image(responses) -> None: + assert responses and responses[0].success, "Expected a successful /v1/images/edits response" + body = responses[0].json_body + assert body and body.get("data"), "No image data returned by /v1/images/edits" + assert body["data"][0].get("b64_json"), "Missing b64_json in /v1/images/edits response" + + +@pytest.mark.parametrize( + "omni_server", + _get_diffusion_feature_cases("Boogu/Boogu-Image-0.1-Edit"), + indirect=True, +) +def test_images_edits_endpoint_001(omni_server: OmniServer, openai_client: OpenAIClientHandler): + """Single-reference text-guided edit via the OpenAI-compatible ``/v1/images/edits`` endpoint.""" + responses = openai_client.send_images_edits_http_request(_edits_request_config(omni_server.model)) + _assert_edits_response_has_image(responses) + + +@pytest.mark.parametrize( + "omni_server", + _get_diffusion_feature_cases("Boogu/Boogu-Image-0.1-Edit"), + indirect=True, +) +def test_images_edits_endpoint_double_guidance_001(omni_server: OmniServer, openai_client: OpenAIClientHandler): + """Double-guidance edit (text + image guidance) via ``/v1/images/edits``.""" + responses = openai_client.send_images_edits_http_request( + _edits_request_config(omni_server.model, guidance_scale_2=2.0) + ) + _assert_edits_response_has_image(responses) diff --git a/tests/e2e/online_serving/test_boogu_image_edit_expansion.py b/tests/e2e/online_serving/test_boogu_image_edit_expansion.py new file mode 100644 index 000000000..efc968eb6 --- /dev/null +++ b/tests/e2e/online_serving/test_boogu_image_edit_expansion.py @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +""" +L4 expansion coverage for ``Boogu/Boogu-Image-0.1-Edit`` (image editing / TI2I). + +The nightly smoke module (``test_boogu_image_edit.py``) already covers single-image +text-guided editing and the text+image double-guidance path. This expansion +module adds the two edit paths smoke does not exercise: + +- ``test_image_only_guidance`` — ``guidance_scale=1.0`` (text CFG off) with + ``guidance_scale_2=2.0`` (image CFG on): the image-only branch of upstream's + CFG-branch priority (double > text-only > image-only > t2i), which no existing + e2e test reaches. +- ``test_align_res_output_size`` — a non-square reference with no requested + ``height`` / ``width``: asserts the output resolution follows the reference + (upstream ``align_res``, on by default for a single reference). + +The Edit checkpoint shares ``BooguImagePipeline`` with the Base checkpoint; the +TI2I path activates when the request carries a reference image. Only a single +reference image is supported. Cases stay smoke-depth (``num_inference_steps=2``). +CPU offload, Cache-DiT, and multi-GPU parallelism are not yet supported for this +model and are intentionally omitted. + +From ``tests/``:: + + pytest -s -v e2e/online_serving/test_boogu_image_edit_expansion.py \ + -m "full_model and diffusion and H100" --run-level=full_model +""" + +import os + +import pytest + +from tests.helpers.mark import hardware_marks +from tests.helpers.media import generate_synthetic_image +from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +pytestmark = [pytest.mark.diffusion, pytest.mark.full_model] + +MODEL = "Boogu/Boogu-Image-0.1-Edit" +EDIT_PROMPT = "Change the style to a colored pencil drawing." + +SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}) + + +def _get_diffusion_feature_cases(model: str): + """Return the single-card server configuration supported for Boogu-Image-Edit today.""" + return [ + pytest.param( + OmniServerParams(model=model), + id="default", + marks=SINGLE_CARD_FEATURE_MARKS, + ), + ] + + +@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) +def test_image_only_guidance(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: + """Image-only guidance: text CFG off (1.0), image CFG on (2.0) -> image-only CFG branch.""" + image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}" + messages = dummy_messages_from_mix_data(image_data_url=image_data_url, content_text=EDIT_PROMPT) + request_config = { + "model": omni_server.model, + "messages": messages, + "extra_body": { + "num_inference_steps": 2, + # Text guidance off, image guidance on -> upstream's image-only branch. + "guidance_scale": 1.0, + "guidance_scale_2": 2.0, + "seed": 42, + }, + } + openai_client.send_diffusion_request(request_config) + + +@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) +def test_align_res_output_size(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: + """Output resolution follows the reference image (upstream ``align_res``). + + No ``height`` / ``width`` is requested. The reference is 512x384 — both + already multiples of 32 (>= the ``vae_scale_factor * 2`` alignment) and well + under the VAE ``max_pixels`` / ``max_side`` limits — so the align-res target + equals the reference dims regardless of the exact VAE scale factor. + """ + ref_width, ref_height = 512, 384 + image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(ref_width, ref_height)['base64']}" + messages = dummy_messages_from_mix_data(image_data_url=image_data_url, content_text=EDIT_PROMPT) + request_config = { + "model": omni_server.model, + "messages": messages, + "extra_body": { + "num_inference_steps": 2, + "guidance_scale": 5.0, + "seed": 42, + }, + } + responses = openai_client.send_diffusion_request(request_config) + + assert responses and responses[0].images, "No image returned for the edit request" + out_image = responses[0].images[0] + assert (out_image.width, out_image.height) == (ref_width, ref_height), ( + f"align_res should size the output to the reference dims {(ref_width, ref_height)}; " + f"got {(out_image.width, out_image.height)}" + ) diff --git a/tests/e2e/online_serving/test_boogu_image_expansion.py b/tests/e2e/online_serving/test_boogu_image_expansion.py new file mode 100644 index 000000000..ef6d3a62e --- /dev/null +++ b/tests/e2e/online_serving/test_boogu_image_expansion.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +""" +L4 expansion coverage for ``Boogu/Boogu-Image-0.1-Base`` (text-to-image). + +The nightly smoke module (``test_boogu_image.py``) already covers the default +512x512 single request and a concurrent batch. This expansion module adds the +request-level paths that smoke does not exercise, run against every currently +supported single-GPU server configuration: + +Server rows (single card): +- ``default`` — no extra ``server_args``. +- ``vae_slicing_tiling`` — ``--vae-use-slicing --vae-use-tiling`` (the recipe's + OOM mitigation; applied generically in ``registry.initialize_model``). + +Cases (one per test, each parametrized over both server rows): +- ``test_non_square_resolution`` — 768x1024, exercises the patchify / RoPE path + at a non-square aspect ratio. +- ``test_cfg_off`` — ``guidance_scale=1.0``, exercises the no-CFG denoise branch + (single prediction per step). +- ``test_high_resolution`` — 1024x1024, the recipe-recommended resolution. + +Boogu-Image reads ``sp.guidance_scale`` (not ``true_cfg_scale``). These stay +smoke-depth (``num_inference_steps=2``); numeric parity vs. Diffusers is covered +by the local parity harness (support plan, step 14). CPU offload, Cache-DiT, and +multi-GPU parallelism are not yet supported for this model and are intentionally +omitted (add rows here when those features land). + +From ``tests/``:: + + pytest -s -v e2e/online_serving/test_boogu_image_expansion.py \ + -m "full_model and diffusion and H100" --run-level=full_model +""" + +import os + +import pytest + +from tests.helpers.mark import hardware_marks +from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +pytestmark = [pytest.mark.diffusion, pytest.mark.full_model] + +MODEL = "Boogu/Boogu-Image-0.1-Base" +T2I_PROMPT = "A mountain lake at sunset, photorealistic, cinematic lighting" +NEGATIVE_PROMPT = "" + +SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}) + + +def _get_diffusion_feature_cases(model: str): + """Return the single-card server configurations supported for Boogu-Image today.""" + return [ + pytest.param( + OmniServerParams(model=model), + id="default", + marks=SINGLE_CARD_FEATURE_MARKS, + ), + pytest.param( + OmniServerParams( + model=model, + server_args=["--vae-use-slicing", "--vae-use-tiling"], + ), + id="vae_slicing_tiling", + marks=SINGLE_CARD_FEATURE_MARKS, + ), + ] + + +@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) +def test_non_square_resolution(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: + """Non-square (768x1024) T2I exercises the patchify / RoPE path off the square grid.""" + messages = dummy_messages_from_mix_data(content_text=T2I_PROMPT) + request_config = { + "model": omni_server.model, + "messages": messages, + "extra_body": { + "height": 1024, + "width": 768, + "num_inference_steps": 2, + "negative_prompt": NEGATIVE_PROMPT, + "guidance_scale": 4.0, + "seed": 42, + }, + } + openai_client.send_diffusion_request(request_config) + + +@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) +def test_cfg_off(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: + """``guidance_scale=1.0`` exercises the no-CFG denoise branch (one prediction per step).""" + messages = dummy_messages_from_mix_data(content_text=T2I_PROMPT) + request_config = { + "model": omni_server.model, + "messages": messages, + "extra_body": { + "height": 512, + "width": 512, + "num_inference_steps": 2, + "negative_prompt": NEGATIVE_PROMPT, + "guidance_scale": 1.0, + "seed": 42, + }, + } + openai_client.send_diffusion_request(request_config) + + +@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) +def test_high_resolution(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: + """1024x1024 T2I at the recipe-recommended resolution.""" + messages = dummy_messages_from_mix_data(content_text=T2I_PROMPT) + request_config = { + "model": omni_server.model, + "messages": messages, + "extra_body": { + "height": 1024, + "width": 1024, + "num_inference_steps": 2, + "negative_prompt": NEGATIVE_PROMPT, + "guidance_scale": 4.0, + "seed": 42, + }, + } + openai_client.send_diffusion_request(request_config) diff --git a/tests/e2e/online_serving/test_dreamzero_expansion.py b/tests/e2e/online_serving/test_dreamzero_expansion.py index 38e7d4b16..66d558120 100644 --- a/tests/e2e/online_serving/test_dreamzero_expansion.py +++ b/tests/e2e/online_serving/test_dreamzero_expansion.py @@ -1,34 +1,37 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""E2E online serving test for DreamZero OpenPI websocket serving.""" +"""E2E online serving tests for DreamZero OpenPI websocket serving.""" from __future__ import annotations import os from pathlib import Path +import cv2 import numpy as np import pytest -from tests.dreamzero import openpi_client_helper as openpi_client from tests.helpers.mark import hardware_test -from tests.helpers.runtime import OmniServerParams, get_open_port +from tests.helpers.runtime import ( + DREAMZERO_ACTION_DIM, + DREAMZERO_ACTION_HORIZON, + DREAMZERO_CAMERA_FILES, + OmniServerParams, + OpenPIWebSocketResponse, + get_open_port, +) +from tests.helpers.stage_config import get_deploy_config_path os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0" MODEL = "GEAR-Dreams/DreamZero-DROID" - test_params = [ OmniServerParams( model=MODEL, - port=8091, - server_args=[ - "--deploy-config", - "vllm_omni/deploy/dreamzero_tp1_cfg2.yaml", - ], + stage_config_path=get_deploy_config_path("dreamzero_tp1_cfg2.yaml"), env_dict={ "ATTENTION_BACKEND": "torch", "DIFFUSION_ATTENTION_BACKEND": "TORCH_SDPA", @@ -39,10 +42,10 @@ test_params = [ ] -def _write_synthetic_video(path: Path, cv2_module, *, channel: int) -> None: +def _write_synthetic_video(path: Path, *, channel: int) -> None: path.parent.mkdir(parents=True, exist_ok=True) height, width, num_frames = 180, 320, 24 - writer = cv2_module.VideoWriter(str(path), cv2_module.VideoWriter_fourcc(*"mp4v"), 15.0, (width, height)) + writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), 15.0, (width, height)) if not writer.isOpened(): raise RuntimeError(f"Failed to open video writer for {path}") try: @@ -50,39 +53,47 @@ def _write_synthetic_video(path: Path, cv2_module, *, channel: int) -> None: frame = np.zeros((height, width, 3), dtype=np.uint8) frame[..., channel] = (frame_idx * 7) % 255 frame[..., (channel + 1) % 3] = 64 - writer.write(cv2_module.cvtColor(frame, cv2_module.COLOR_RGB2BGR)) + writer.write(cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)) finally: writer.release() -def _write_synthetic_dreamzero_videos(client_mod, video_dir: Path) -> None: - for channel, file_name in enumerate(client_mod.CAMERA_FILES.values()): - _write_synthetic_video(video_dir / file_name, client_mod.cv2, channel=channel) +def _write_synthetic_dreamzero_videos(video_dir: Path) -> None: + for channel, file_name in enumerate(DREAMZERO_CAMERA_FILES.values()): + _write_synthetic_video(video_dir / file_name, channel=channel) + + +def _validate_dreamzero_openpi_session(response: OpenPIWebSocketResponse) -> None: + metadata = response.server_metadata + assert tuple(metadata["image_resolution"]) == (180, 320) + assert metadata["n_external_cameras"] == 2 + assert metadata["needs_wrist_camera"] is True + assert metadata["needs_stereo_camera"] is False + assert metadata["needs_session_id"] is True + assert metadata["action_space"] == "joint_position" + assert response.operation_responses[-1]["status"] == "reset successful" + + action_tensors = response.action_tensors + assert action_tensors is not None + assert len(action_tensors) == 3 + for index, action in enumerate(action_tensors): + assert action.shape == (DREAMZERO_ACTION_HORIZON, DREAMZERO_ACTION_DIM) + assert action.dtype == np.float32 + assert np.isfinite(action).all() @pytest.mark.slow @pytest.mark.diffusion -@pytest.mark.distributed_cuda @hardware_test(res={"cuda": "H100"}, num_cards=2) @pytest.mark.parametrize("omni_server", test_params, indirect=True) -def test_dreamzero_openpi_online(omni_server, tmp_path: Path) -> None: - try: - openpi_client.require_dependencies() - except ModuleNotFoundError as exc: - pytest.skip(str(exc)) - +def test_dreamzero_openpi_online(omni_server, openai_client, tmp_path: Path) -> None: video_dir = tmp_path / "dreamzero_videos" - _write_synthetic_dreamzero_videos(openpi_client, video_dir) - result = openpi_client.run_policy_session( - host=omni_server.host, - port=omni_server.port, - video_dir=video_dir, - session_id="dreamzero-online-e2e", - ) - - openpi_client.validate_session_result(result) - - metadata = result["metadata"] - assert metadata["needs_session_id"] is True - assert metadata["needs_stereo_camera"] is False - assert tuple(metadata["image_resolution"]) == (180, 320) + _write_synthetic_dreamzero_videos(video_dir) + response = openai_client.send_robot_openpi_ws_request( + { + "run_dreamzero_policy_session": True, + "video_dir": video_dir, + "session_id": "dreamzero-online-e2e", + } + )[0] + _validate_dreamzero_openpi_session(response) diff --git a/tests/e2e/online_serving/test_dynin_omni_expansion.py b/tests/e2e/online_serving/test_dynin_omni_expansion.py index 348191b6e..1f16a584b 100644 --- a/tests/e2e/online_serving/test_dynin_omni_expansion.py +++ b/tests/e2e/online_serving/test_dynin_omni_expansion.py @@ -26,7 +26,14 @@ T2I_PROMPT = "A high quality detailed living room interior photo." T2S_PROMPT = "Please read this sentence naturally: Hello from online serving." I2I_PROMPT = "Transform this outdoor nature boardwalk scene into a painting style with vivid colors." -TEST_PARAMS = [OmniServerParams(model=MODEL, stage_config_path=STAGE_CONFIG, stage_init_timeout=600)] +TEST_PARAMS = [ + OmniServerParams( + model=MODEL, + stage_config_path=STAGE_CONFIG, + stage_init_timeout=600, + server_args=["--trust-remote-code"], + ) +] _STAGE_COUNT = 3 _I2I_STAGE_SAMPLING = {"max_tokens": 1, "temperature": 0.0, "top_p": 1.0, "detokenize": False} diff --git a/tests/e2e/online_serving/test_flux_2_dev_expansion.py b/tests/e2e/online_serving/test_flux_2_dev_expansion.py index 77a7a9271..9bc3308b8 100644 --- a/tests/e2e/online_serving/test_flux_2_dev_expansion.py +++ b/tests/e2e/online_serving/test_flux_2_dev_expansion.py @@ -2,9 +2,11 @@ End-to-end diffusion coverage for FLUX.2-dev in online serving mode. Coverage: -- CPU offload +- CPU offload (model-level) +- Layerwise CPU offload - Ulysses sequence parallelism - Ring sequence parallelism +- VAE patch parallel encode/decode This test verifies that FLUX.2-dev can be launched with CPU offload enabled, accepts text-to-image requests through the OpenAI-compatible API, and returns @@ -43,6 +45,16 @@ def _get_flux_2_dev_feature_cases(model: str): id="cpu_offload", marks=SINGLE_CARD_FEATURE_MARKS, ), + pytest.param( + OmniServerParams( + model=model, + server_args=[ + "--enable-layerwise-offload", + ], + ), + id="layerwise_offload", + marks=SINGLE_CARD_FEATURE_MARKS, + ), pytest.param( OmniServerParams( model=model, @@ -79,6 +91,21 @@ def _get_flux_2_dev_feature_cases(model: str): id="ring_2", marks=PARALLEL_FEATURE_MARKS, ), + pytest.param( + OmniServerParams( + model=model, + server_args=[ + "--enable-cpu-offload", + "--tensor-parallel-size", + "2", + "--vae-patch-parallel-size", + "2", + "--vae-use-tiling", + ], + ), + id="vae_patch_parallel_2", + marks=PARALLEL_FEATURE_MARKS, + ), ] diff --git a/tests/e2e/online_serving/test_flux_kontext_expansion.py b/tests/e2e/online_serving/test_flux_kontext_expansion.py index 5fa4eb2a1..5714ef711 100644 --- a/tests/e2e/online_serving/test_flux_kontext_expansion.py +++ b/tests/e2e/online_serving/test_flux_kontext_expansion.py @@ -5,6 +5,7 @@ and are supported by the FluxKontext model. import pytest +from tests.helpers.mark import hardware_marks from tests.helpers.media import generate_synthetic_image from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data @@ -13,6 +14,7 @@ pytestmark = [pytest.mark.diffusion, pytest.mark.slow] EDIT_PROMPT = "Transform this modern, geometrist image into a Vincent van Gogh style impressionist painting." NEGATIVE_PROMPT = "blurry, low quality, modern, geometrist" MODEL = "black-forest-labs/FLUX.1-Kontext-dev" +PARALLEL_FEATURE_MARKS = hardware_marks(res={"cuda": "L4"}, num_cards=2) def _get_diffusion_feature_cases(model: str): @@ -26,7 +28,33 @@ def _get_diffusion_feature_cases(model: str): "--enable-cpu-offload", ], ), - id="parallel_001", + id="parallel_tp_2", + marks=PARALLEL_FEATURE_MARKS, + ), + pytest.param( + OmniServerParams( + model=model, + server_args=[ + "--enable-cpu-offload", + "--cfg-parallel-size", + "2", + ], + ), + id="parallel_cfg_2", + marks=PARALLEL_FEATURE_MARKS, + ), + pytest.param( + OmniServerParams( + model=model, + server_args=[ + "--tensor-parallel-size", + "2", + "--enable-cpu-offload", + "--cache-backend", + "cache_dit", + ], + ), + id="parallel_002", ), ] diff --git a/tests/e2e/online_serving/test_gr00t_openpi.py b/tests/e2e/online_serving/test_gr00t_openpi_expansion.py similarity index 66% rename from tests/e2e/online_serving/test_gr00t_openpi.py rename to tests/e2e/online_serving/test_gr00t_openpi_expansion.py index 40b8293d6..70e11088d 100644 --- a/tests/e2e/online_serving/test_gr00t_openpi.py +++ b/tests/e2e/online_serving/test_gr00t_openpi_expansion.py @@ -7,14 +7,19 @@ import os import numpy as np import pytest -from tests.gr00t import openpi_client_helper as openpi_client from tests.helpers.mark import hardware_test -from tests.helpers.runtime import OmniServerParams +from tests.helpers.runtime import ( + OmniServerParams, + OpenPIWebSocketResponse, + build_openpi_droid_observation, +) from tests.helpers.stage_config import get_deploy_config_path os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" MODEL = "nvidia/GR00T-N1.7-3B" +ACTION_KEYS = frozenset({"eef_9d", "gripper_position", "joint_position"}) +EXPECTED_EMBODIMENT_TAG = "OXE_DROID_RELATIVE_EEF_RELATIVE_JOINT" pytest.importorskip("websockets") pytest.importorskip("openpi_client.msgpack_numpy") @@ -34,7 +39,7 @@ test_params = [ ] # Reference values captured from the Isaac-GR00T ZMQ server (nvidia/GR00T-N1.7-3B, -# OXE_DROID_RELATIVE_EEF_RELATIVE_JOINT embodiment) using build_droid_observation() +# OXE_DROID_RELATIVE_EEF_RELATIVE_JOINT embodiment) using build_openpi_droid_observation() # inputs: zero images (256×256), identity eef_9d, zero gripper/joint, "pick up the object". # Outputs are bit-reproducible across resets and across runs (max_diff=0.0). _REF_EEF_9D = np.array( @@ -112,32 +117,65 @@ _REF_JOINT = np.array( ) # rows = step 0, step 4, step 39 -@pytest.mark.full_model +def _validate_gr00t_openpi_session(response: OpenPIWebSocketResponse) -> None: + metadata = response.server_metadata + for key in ("action_horizon", "action_keys", "embodiment_tag", "needs_session_id"): + assert key in metadata, f"Missing GR00T metadata key: {key}" + + assert metadata["embodiment_tag"] == EXPECTED_EMBODIMENT_TAG + assert metadata["needs_session_id"] is True + assert frozenset(metadata["action_keys"]) == ACTION_KEYS + assert response.operation_responses[-1]["status"] == "reset successful" + + actions = response.actions + assert actions is not None + assert frozenset(actions) == ACTION_KEYS + + action_horizon = int(metadata["action_horizon"]) + expected_shapes = { + "eef_9d": (1, action_horizon, 9), + "gripper_position": (1, action_horizon, 1), + "joint_position": (1, action_horizon, 7), + } + for key, expected_shape in expected_shapes.items(): + assert actions[key].shape == expected_shape + assert actions[key].dtype == np.float32 + assert np.isfinite(actions[key]).all() + + +@pytest.mark.slow @pytest.mark.diffusion @hardware_test(res={"cuda": "L4"}, num_cards=1) @pytest.mark.parametrize("omni_server", test_params, indirect=True) -def test_gr00t_n1d7_openpi_online(omni_server) -> None: - result = openpi_client.run_policy_session( - host=omni_server.host, - port=omni_server.port, - session_id="gr00t-online-e2e", - ) - openpi_client.validate_session_result(result) +def test_gr00t_n1d7_openpi_online(omni_server, openai_client) -> None: + response = openai_client.send_robot_openpi_ws_request( + { + "run_default_policy_session": True, + "session_id": "gr00t-online-e2e", + } + )[0] + _validate_gr00t_openpi_session(response) -@pytest.mark.full_model +@pytest.mark.slow @pytest.mark.diffusion @hardware_test(res={"cuda": "L4"}, num_cards=1) @pytest.mark.parametrize("omni_server", test_params, indirect=True) -def test_gr00t_n1d7_openpi_precision(omni_server) -> None: +def test_gr00t_n1d7_openpi_precision(omni_server, openai_client) -> None: """Assert actions match Isaac-GR00T reference (GR00T_NOISE_SEED=42, zero inputs).""" - client = openpi_client.OpenPIWebsocketClient(host=omni_server.host, port=omni_server.port) - try: - obs = openpi_client.build_droid_observation(session_id="gr00t-precision-e2e") - client.reset({}) - actions = client.infer(obs) - finally: - client.close() + response = openai_client.send_robot_openpi_ws_request( + { + "operations": [ + {"endpoint": "reset", "payload": {}}, + { + "endpoint": "infer", + "payload": build_openpi_droid_observation(session_id="gr00t-precision-e2e"), + }, + ], + } + )[0] + actions = response.actions + assert actions is not None steps = [0, 4, 39] np.testing.assert_allclose( diff --git a/tests/e2e/online_serving/test_higgs_audio_v2_expansion.py b/tests/e2e/online_serving/test_higgs_audio_v2_expansion.py index e7371fe17..8927eb039 100644 --- a/tests/e2e/online_serving/test_higgs_audio_v2_expansion.py +++ b/tests/e2e/online_serving/test_higgs_audio_v2_expansion.py @@ -6,7 +6,7 @@ E2E Online expansion tests for higgs-audio v2 against /v1/audio/speech. v1 scope is plain text -> 24 kHz speech plus shallow voice clone via ref_audio + ref_text (inline) or voice= (after POST /v1/audio/voices). The model-aware request validator -(vllm_omni/entrypoints/openai/serving_speech.py::_validate_higgs_audio_v2_request) +(vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v2::validate) rejects multi-speaker tags, language overrides, task_type, and bare voice= for names that do not match an uploaded speaker — this suite exercises both the happy path (plain text in, audio bytes out) and the @@ -92,6 +92,15 @@ class TestHiggsAudioV2OnlineHappyPath: higgs_audio_v2.yaml pins ``codec_streaming: true`` + ``async_chunk: false``, so the only streaming surface exposed to clients is the WAV/PCM bytes served chunk-by-chunk from Stage 1 — exercise it directly. + + NOTE: ``min_hnr_db=-3.0`` keeps the check as a catastrophic-codec-failure + guard (white noise / sample scramble measure around -10 dB) while + tolerating Higgs sampling variance: legitimate speech for this prompt + measured -1.03 dB on L4 (weekly CI, issue #5045) and 1.34 dB on H100, so + the default 1.0 dB threshold flakes on hardware-dependent tail samples. + Same rationale as the higgs_audio_v3 streaming thresholds (issue #4411); + content correctness is separately guarded by the full_model transcript + check. """ openai_client.send_audio_speech_request( { @@ -102,6 +111,7 @@ class TestHiggsAudioV2OnlineHappyPath: "response_format": "pcm", "timeout": DEFAULT_SPEECH_TIMEOUT_S, "min_audio_bytes": _MIN_AUDIO_BYTES, + "min_hnr_db": -3.0, } ) diff --git a/tests/e2e/online_serving/test_indextts2.py b/tests/e2e/online_serving/test_indextts2_expansion.py similarity index 97% rename from tests/e2e/online_serving/test_indextts2.py rename to tests/e2e/online_serving/test_indextts2_expansion.py index 5ab0d1f8d..d971ef166 100644 --- a/tests/e2e/online_serving/test_indextts2.py +++ b/tests/e2e/online_serving/test_indextts2_expansion.py @@ -19,7 +19,7 @@ from tests.helpers.media import load_test_audio_data_url from tests.helpers.runtime import OmniServerParams from tests.helpers.stage_config import get_deploy_config_path -pytestmark = [pytest.mark.advanced_model, pytest.mark.tts] +pytestmark = [pytest.mark.slow, pytest.mark.tts] MODEL = "IndexTeam/IndexTTS-2" diff --git a/tests/e2e/online_serving/test_krea2_expansion.py b/tests/e2e/online_serving/test_krea2_expansion.py index 10aa520fb..f76a567f4 100644 --- a/tests/e2e/online_serving/test_krea2_expansion.py +++ b/tests/e2e/online_serving/test_krea2_expansion.py @@ -31,7 +31,7 @@ from PIL import Image from tests.helpers.mark import hardware_marks from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data -pytestmark = [pytest.mark.diffusion, pytest.mark.full_model] +pytestmark = [pytest.mark.diffusion, pytest.mark.slow] MODEL = os.environ.get("KREA2_MODEL", "krea/Krea-2-Turbo") # vLLM-Omni-compatible PEFT repackaging of krea/Krea-2-LoRA-darkbrush (264 modules, r=alpha=32). diff --git a/tests/e2e/online_serving/test_lingbot_video.py b/tests/e2e/online_serving/test_lingbot_video.py new file mode 100644 index 000000000..e7af994cb --- /dev/null +++ b/tests/e2e/online_serving/test_lingbot_video.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +""" +Online serving smoke for ``robbyant/lingbot-video-dense-1.3b``. + +The LingBot dense PR supports text-to-video only, so this module exercises the +native ``/v1/videos`` path with a small 9-frame request. +""" + +import os + +import pytest + +from tests.helpers.mark import hardware_marks +from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +MODEL = "robbyant/lingbot-video-dense-1.3b" +PROMPT = "a robotic arm picks up a red block" +NEGATIVE_PROMPT = "low quality, blurry, watermark, text" +DEFAULT_SAMPLING_PARAMS = '{"0":{"num_frames":81,"num_inference_steps":40,"guidance_scale":6.0}}' + +SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}) + + +def _get_diffusion_feature_cases(model: str): + return [ + pytest.param( + OmniServerParams( + model=model, + server_args=[ + "--model-class-name", + "LingBotVideoPipeline", + "--default-sampling-params", + DEFAULT_SAMPLING_PARAMS, + ], + ), + id="default", + marks=SINGLE_CARD_FEATURE_MARKS, + ), + ] + + +@pytest.mark.core_model +@pytest.mark.advanced_model +@pytest.mark.diffusion +@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) +def test_text_to_video_001(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: + request_config = { + "model": omni_server.model, + "form_data": { + "model": omni_server.model, + "prompt": PROMPT, + "negative_prompt": NEGATIVE_PROMPT, + "height": 192, + "width": 320, + "num_frames": 9, + "fps": 24, + "num_inference_steps": 2, + "guidance_scale": 3.0, + "flow_shift": 3.0, + "seed": 42, + }, + } + openai_client.send_video_diffusion_request(request_config) diff --git a/tests/e2e/online_serving/test_lingbot_video_expansion.py b/tests/e2e/online_serving/test_lingbot_video_expansion.py new file mode 100644 index 000000000..cb5a314ff --- /dev/null +++ b/tests/e2e/online_serving/test_lingbot_video_expansion.py @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +""" +L4 expansion coverage for ``robbyant/lingbot-video-dense-1.3b``. + +This file remains dense-only. Basic single-GPU MoE serving is covered by +``test_lingbot_video_moe.py``; T2I, I2V, TI2V, and multi-GPU feature rows +belong in follow-up PRs. +""" + +import json +import os + +import pytest + +from tests.helpers.mark import hardware_marks +from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +pytestmark = [pytest.mark.diffusion, pytest.mark.full_model] + +MODEL = "robbyant/lingbot-video-dense-1.3b" +PROMPT = "a robotic arm picks up a red block" +NEGATIVE_PROMPT = "low quality, blurry, watermark, text" + +SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}) + + +def _get_diffusion_feature_cases(model: str): + return [ + pytest.param( + OmniServerParams( + model=model, + server_args=["--model-class-name", "LingBotVideoPipeline"], + ), + id="default", + marks=SINGLE_CARD_FEATURE_MARKS, + ), + ] + + +@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) +def test_cfg_off(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: + request_config = { + "model": omni_server.model, + "form_data": { + "model": omni_server.model, + "prompt": PROMPT, + "height": 192, + "width": 192, + "num_frames": 9, + "fps": 24, + "num_inference_steps": 2, + "guidance_scale": 1.0, + "flow_shift": 3.0, + "seed": 42, + }, + } + openai_client.send_video_diffusion_request(request_config) + + +@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) +def test_batch_cfg_extra_params(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: + request_config = { + "model": omni_server.model, + "form_data": { + "model": omni_server.model, + "prompt": PROMPT, + "negative_prompt": NEGATIVE_PROMPT, + "height": 192, + "width": 320, + "num_frames": 9, + "fps": 24, + "num_inference_steps": 2, + "guidance_scale": 3.0, + "flow_shift": 3.0, + "seed": 42, + "extra_params": json.dumps({"batch_cfg": True}, separators=(",", ":")), + }, + } + openai_client.send_video_diffusion_request(request_config) diff --git a/tests/e2e/online_serving/test_lingbot_video_moe.py b/tests/e2e/online_serving/test_lingbot_video_moe.py new file mode 100644 index 000000000..32a6cbf2f --- /dev/null +++ b/tests/e2e/online_serving/test_lingbot_video_moe.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Single-GPU serving smoke for ``robbyant/lingbot-video-moe-30b-a3b``.""" + +import os + +import pytest + +from tests.helpers.mark import hardware_marks +from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +MODEL = "robbyant/lingbot-video-moe-30b-a3b" +PROMPT = "a robotic arm picks up a red block" +NEGATIVE_PROMPT = "low quality, blurry, watermark, text" +SMOKE_DEFAULT_SAMPLING_PARAMS = '{"0":{"num_frames":81,"num_inference_steps":2,"guidance_scale":3.0}}' + +SINGLE_CARD_MARKS = hardware_marks(res={"cuda": "H100"}) + + +def _get_server_cases(model: str): + return [ + pytest.param( + OmniServerParams( + model=model, + server_args=[ + "--model-class-name", + "LingBotVideoPipeline", + "--default-sampling-params", + SMOKE_DEFAULT_SAMPLING_PARAMS, + ], + ), + id="default", + marks=SINGLE_CARD_MARKS, + ), + ] + + +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.parametrize("omni_server", _get_server_cases(MODEL), indirect=True) +def test_text_to_video_moe(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: + request_config = { + "model": omni_server.model, + "form_data": { + "model": omni_server.model, + "prompt": PROMPT, + "negative_prompt": NEGATIVE_PROMPT, + "height": 192, + "width": 320, + "num_frames": 9, + "fps": 24, + "flow_shift": 3.0, + "seed": 42, + }, + } + openai_client.send_video_diffusion_request(request_config) diff --git a/tests/e2e/online_serving/test_ltx2_expansion.py b/tests/e2e/online_serving/test_ltx2_expansion.py index 82855f62f..4d0eb6c90 100644 --- a/tests/e2e/online_serving/test_ltx2_expansion.py +++ b/tests/e2e/online_serving/test_ltx2_expansion.py @@ -4,8 +4,7 @@ Expansion tests for LTX-2 two-stage pipelines. Coverage: -- HSDP with LTX2TwoStagesPipeline (text-to-video) -- HSDP with LTX2ImageToVideoTwoStagesPipeline (image-to-video) +- HSDP with LTX2DistilledPipeline (text-to-video and image-to-video) """ import os @@ -27,7 +26,6 @@ HSDP_ARGS = ["--use-hsdp", "--hsdp-shard-size", "2"] def _cases(): cases = [] - # T2V: LTX2TwoStagesPipeline cases.append( pytest.param( OmniServerParams( @@ -35,15 +33,15 @@ def _cases(): server_args=[ *HSDP_ARGS, "--model-class-name", - "LTX2TwoStagesPipeline", + "LTX2DistilledPipeline", ], ), + False, id="t2v_hsdp", marks=PARALLEL_MARKS, ) ) - # I2V: LTX2ImageToVideoTwoStagesPipeline cases.append( pytest.param( OmniServerParams( @@ -51,9 +49,10 @@ def _cases(): server_args=[ *HSDP_ARGS, "--model-class-name", - "LTX2ImageToVideoTwoStagesPipeline", + "LTX2DistilledPipeline", ], ), + True, id="i2v_hsdp", marks=PARALLEL_MARKS, ) @@ -62,25 +61,22 @@ def _cases(): return cases -@pytest.mark.parametrize("omni_server", _cases(), indirect=True) +@pytest.mark.parametrize(("omni_server", "is_i2v"), _cases(), indirect=["omni_server"]) def test_ltx2_two_stage_hsdp( omni_server: OmniServer, + is_i2v: bool, openai_client: OpenAIClientHandler, ): - is_i2v = any("ImageToVideo" in arg for arg in omni_server.serve_args) - - # The two-stage pipeline generates at the requested resolution then 2x - # upsamples via LTX2LatentUpsamplerModel, so output dimensions differ - # from the request. Omit height/width from form_data so the assertion - # helper skips the dimension check (it only asserts when the key is - # present). The pipeline falls back to its own defaults. + # Keep CI small while exercising the fixed 8+3-step distilled recipe. + # Height and width describe the final output; Stage 1 runs at half size. form_data = { "prompt": PROMPT, "model": omni_server.model, + "height": 128, + "width": 128, "num_frames": 9, "fps": 8, - "num_inference_steps": 2, - "guidance_scale": 1.0, + "num_inference_steps": 8, "seed": 42, } diff --git a/tests/e2e/online_serving/test_minicpmo_4_5.py b/tests/e2e/online_serving/test_minicpmo_4_5.py new file mode 100644 index 000000000..7e77de2e1 --- /dev/null +++ b/tests/e2e/online_serving/test_minicpmo_4_5.py @@ -0,0 +1,233 @@ +""" +E2E Online tests for MiniCPM-o 4.5 model with multimodal input and audio / text output. + +MiniCPM-o 4.5 is a three-stage pipeline: Stage 0 Thinker (text), Stage 1 Talker +(TTS codec), Stage 2 Code2Wav (vocoder). The talker -> Code2Wav bridge supports +both async_chunk (streaming) and full-payload (``--no-async-chunk``) modes, so the +tests below exercise the sync path as well. +""" + +import os + +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video +from tests.helpers.runtime import OmniServerParams, dummy_messages_from_mix_data +from tests.helpers.stage_config import get_deploy_config_path + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +_MODEL = "openbmb/MiniCPM-o-4_5" +_CI_DEPLOY = get_deploy_config_path("minicpmo_4_5.yaml") + +test_params = [ + pytest.param( + OmniServerParams( + model=_MODEL, + stage_config_path=_CI_DEPLOY, + use_stage_cli=False, + server_args=[ + "--trust-remote-code", + ], + ), + id="default", + ) +] + + +def get_system_prompt(): + return { + "role": "system", + "content": [ + { + "type": "text", + "text": ( + "You are MiniCPM-o 4.5, a virtual human capable of perceiving " + "auditory and visual inputs, as well as generating text and speech." + ), + } + ], + } + + +def get_prompt(prompt_type: str = "text_only") -> str: + prompts = { + "text_only": "What is the capital of China? Answer in 20 words.", + "mix": "What is recited in the audio? What is in this image? Describe the video briefly.", + "text_image": "What color are the squares in this image?", + } + return prompts.get(prompt_type, prompts["text_only"]) + + +def get_max_batch_size(size_type="few"): + batch_sizes = {"few": 5, "medium": 100, "large": 256} + return batch_sizes.get(size_type, 5) + + +@pytest.mark.core_model +@pytest.mark.advanced_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_text_to_text_001(omni_server, openai_client) -> None: + """ + Test text-only input generating text output via OpenAI API. + Deploy Setting: default 2GPU + Input Modal: text + Output Modal: text + Input Setting: stream=False + """ + messages = dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt()) + + request_config = { + "model": omni_server.model, + "messages": messages, + "stream": False, + "modalities": ["text"], + "key_words": {"text": ["Beijing"]}, + } + + openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) + + +@pytest.mark.full_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_text_to_audio_001(omni_server, openai_client) -> None: + """ + Test text-only input generating text + audio output via OpenAI API. + This exercises the talker TTS region detection and in-process token2wav vocoder. + Deploy Setting: default 2GPU + Input Modal: text + Output Modal: text + audio + Input Setting: stream=True + """ + messages = dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt()) + + request_config = { + "model": omni_server.model, + "messages": messages, + "stream": True, + "key_words": {"audio": ["Beijing"]}, + } + + openai_client.send_omni_request(request_config) + + +@pytest.mark.full_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_audio_to_text_audio_001(omni_server, openai_client) -> None: + """ + Test audio input generating text + audio output via OpenAI API. + Deploy Setting: default 2GPU + Input Modal: text + audio + Output Modal: text + audio + Input Setting: stream=True + """ + audio_data_url = f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}" + messages = dummy_messages_from_mix_data( + system_prompt=get_system_prompt(), + audio_data_url=audio_data_url, + content_text=get_prompt("mix"), + ) + + request_config = { + "model": omni_server.model, + "messages": messages, + "stream": True, + } + + openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) + + +@pytest.mark.full_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_image_to_text_audio_001(omni_server, openai_client) -> None: + """ + Test image input generating text + audio output via OpenAI API. + Deploy Setting: default 2GPU + Input Modal: text + image + Output Modal: text + audio + Input Setting: stream=True + """ + image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(24, 24)['base64']}" + messages = dummy_messages_from_mix_data( + system_prompt=get_system_prompt(), + image_data_url=image_data_url, + content_text=get_prompt("text_image"), + ) + + request_config = { + "model": omni_server.model, + "messages": messages, + "stream": True, + } + + openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) + + +@pytest.mark.full_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_video_to_text_audio_001(omni_server, openai_client) -> None: + """ + Test video input generating text + audio output via OpenAI API. + Deploy Setting: default 2GPU + Input Modal: text + video + Output Modal: text + audio + Input Setting: stream=True + """ + video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(24, 24, 20)['base64']}" + messages = dummy_messages_from_mix_data( + system_prompt=get_system_prompt(), + video_data_url=video_data_url, + content_text=get_prompt("mix"), + ) + + request_config = { + "model": omni_server.model, + "messages": messages, + "stream": True, + } + + openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) + + +@pytest.mark.core_model +@pytest.mark.advanced_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_mix_to_text_audio_001(omni_server, openai_client) -> None: + """ + Test multi-modal input (text + audio + video + image) generating text + audio output. + Deploy Setting: default 2GPU + Input Modal: text + audio + video + image + Output Modal: text + audio + Input Setting: stream=True + """ + video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(24, 24, 20)['base64']}" + image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(24, 24)['base64']}" + audio_data_url = f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}" + messages = dummy_messages_from_mix_data( + system_prompt=get_system_prompt(), + video_data_url=video_data_url, + image_data_url=image_data_url, + audio_data_url=audio_data_url, + content_text=get_prompt("mix"), + ) + + request_config = { + "model": omni_server.model, + "messages": messages, + "stream": True, + } + + openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) diff --git a/tests/e2e/online_serving/test_minicpmo_4_5_duplex.py b/tests/e2e/online_serving/test_minicpmo_4_5_duplex.py new file mode 100644 index 000000000..895846147 --- /dev/null +++ b/tests/e2e/online_serving/test_minicpmo_4_5_duplex.py @@ -0,0 +1,147 @@ +"""CI coverage for the MiniCPM-o 4.5 native-duplex Realtime API.""" + +from __future__ import annotations + +import asyncio +import json +import uuid +from pathlib import Path + +import pytest +import websockets + +from tests.e2e.online_serving.minicpmo_realtime_duplex_scenarios import ( + _ref_audio_data_url, + run_demo, +) +from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import ( + run_multi_session, +) +from tests.helpers.mark import hardware_test +from tests.helpers.minicpmo_4_5_duplex import ( + CORE_SERVER_PARAMS, + SERVER_PARAMS, + demo_args, + multi_session_args, + realtime_url, + resolve_ref_audio, + validated_input_wav, +) +from vllm_omni.experimental.fullduplex.client import build_realtime_url + +pytestmark = pytest.mark.omni + + +async def _receive_protocol_events(ws, required_types: set[str], *, timeout_s: float) -> list[dict[str, object]]: + async def receive() -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + seen: set[str] = set() + while not required_types.issubset(seen): + raw = await ws.recv() + if not isinstance(raw, str): + continue + event = json.loads(raw) + if not isinstance(event, dict): + continue + events.append(event) + event_type = event.get("type") + if event_type == "error": + raise AssertionError(f"WebSocket protocol smoke received an error: {event}") + if isinstance(event_type, str): + seen.add(event_type) + return events + + return await asyncio.wait_for(receive(), timeout=timeout_s) + + +async def _run_protocol_smoke(*, url: str, model: str, ref_audio: Path) -> list[dict[str, object]]: + session_id = f"duplex-ci-protocol-{uuid.uuid4().hex}" + websocket_url = build_realtime_url(url, model, autostart=False, session_id=session_id) + async with websockets.connect(websocket_url, max_size=64 * 1024 * 1024) as ws: + await ws.send( + json.dumps( + { + "type": "session.update", + "session": { + "session_id": session_id, + "model": model, + "modalities": ["audio", "text"], + "ref_audio": _ref_audio_data_url(str(ref_audio)), + "extra_body": {"minicpmo45_native_duplex": True}, + }, + } + ) + ) + events = await _receive_protocol_events( + ws, + {"session.created", "session.updated"}, + timeout_s=60, + ) + await ws.send(json.dumps({"type": "session.close"})) + events.extend(await _receive_protocol_events(ws, {"session.closed"}, timeout_s=60)) + return events + + +@pytest.mark.core_model +@hardware_test(res={"cuda": "H100"}, num_cards=2) +@pytest.mark.parametrize("omni_server", CORE_SERVER_PARAMS, indirect=True) +def test_duplex_websocket_protocol_smoke(omni_server, model_prefix: str) -> None: + ref_audio = resolve_ref_audio(model_prefix) + events = asyncio.run( + _run_protocol_smoke( + url=realtime_url(omni_server), + model=omni_server.model, + ref_audio=ref_audio, + ) + ) + event_types = [event.get("type") for event in events] + assert "session.created" in event_types + assert "session.updated" in event_types + assert event_types[-1] == "session.closed" + + +@pytest.mark.advanced_model +@hardware_test(res={"cuda": "H100"}, num_cards=2) +@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True) +def test_duplex_single_session_response_required(omni_server, model_prefix: str, tmp_path: Path) -> None: + result = asyncio.run( + run_demo( + demo_args( + omni_server=omni_server, + input_wav=validated_input_wav(), + ref_audio=resolve_ref_audio(model_prefix), + output_dir=tmp_path / "single_session", + ) + ) + ) + assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2) + assert result["audio_delta_count"] > 0 + assert result["done_count"] == 1 + assert result["error_count"] == 0 + assert result["all_audio_responses_have_transcript"] is True + assert result["transcript_delta_done_ok"] is True + + +@pytest.mark.advanced_model +@hardware_test(res={"cuda": "H100"}, num_cards=2) +@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True) +def test_duplex_two_sessions_resume_and_takeover(omni_server, model_prefix: str, tmp_path: Path) -> None: + result = asyncio.run( + run_multi_session( + multi_session_args( + omni_server=omni_server, + input_wav=validated_input_wav(), + ref_audio=resolve_ref_audio(model_prefix), + output_dir=tmp_path / "multi_session", + response_required=True, + ) + ) + ) + assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2) + assert result["session_count"] == 2 + assert result["resume"]["ok"] is True + assert result["takeover"]["ok"] is True + assert not result["failures"] + assert all(session["audio_delta_count"] > 0 for session in result["sessions"]) + assert all(session["done_count"] == 1 for session in result["sessions"]) + assert all(session["error_count"] == 0 for session in result["sessions"]) diff --git a/tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py b/tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py new file mode 100644 index 000000000..69c266456 --- /dev/null +++ b/tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py @@ -0,0 +1,86 @@ +"""Nightly lifecycle coverage for the MiniCPM-o 4.5 native-duplex API.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import ( + run_lifecycle_probes, +) +from tests.e2e.online_serving.run_minicpmo_realtime_duplex_soft_interrupt import ( + run_soft_interrupt, +) +from tests.helpers.mark import hardware_test +from tests.helpers.minicpmo_4_5_duplex import ( + SERVER_PARAMS, + SOFT_INTERRUPT_SHA256, + multi_session_args, + realtime_url, + resolve_ref_audio, + validated_input_wav, + validated_soft_interrupt_wav, +) + +pytestmark = [pytest.mark.full_model, pytest.mark.omni] + + +@hardware_test(res={"cuda": "H100"}, num_cards=2) +@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True) +def test_duplex_admission_and_expiry_reaper(omni_server, model_prefix: str, tmp_path: Path) -> None: + args = multi_session_args( + omni_server=omni_server, + input_wav=validated_input_wav(), + ref_audio=resolve_ref_audio(model_prefix), + output_dir=tmp_path / "admission_expiry", + response_required=False, + ) + args.sessions = 1 + args.disconnect_session_index = None + args.takeover_session_index = None + args.expire_session_index = 0 + args.verify_admission_limit = 2 + result = asyncio.run(run_lifecycle_probes(args)) + + assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2) + assert result["expiry"]["ok"] is True + assert result["expiry"]["error_code"] == "session_resume_expired" + assert result["admission"]["ok"] is True + assert result["admission"]["overflow_error_code"] == "resource_exhausted" + + +@hardware_test(res={"cuda": "H100"}, num_cards=2) +@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True) +def test_duplex_soft_interrupt(omni_server, model_prefix: str, tmp_path: Path) -> None: + input_wav = validated_soft_interrupt_wav() + result = asyncio.run( + run_soft_interrupt( + SimpleNamespace( + url=realtime_url(omni_server), + model=omni_server.model, + input_wav=str(input_wav), + ref_audio=str(resolve_ref_audio(model_prefix)), + output_dir=str(tmp_path / "soft_interrupt"), + summary_output=None, + chunk_ms=200, + timeout_s=180.0, + require_audio=True, + no_realtime_pacing=False, + validation_mode="response-required", + min_responses=2, + min_audio_deltas_per_response=2, + input_sha256=SOFT_INTERRUPT_SHA256, + expect_followup_response_substring=None, + ) + ) + ) + + assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2) + assert result["error_count"] == 0 + assert result["response_lifecycle_ok"] is True + assert result["response_audio_contract_ok"] is True + assert result["followup_response_transcript_ok"] is True diff --git a/tests/e2e/online_serving/test_minicpmo_4_5_expansion.py b/tests/e2e/online_serving/test_minicpmo_4_5_expansion.py new file mode 100644 index 000000000..6e76904b6 --- /dev/null +++ b/tests/e2e/online_serving/test_minicpmo_4_5_expansion.py @@ -0,0 +1,185 @@ +"""E2E online expansion tests for MiniCPM-o 4.5. + +These cover modality combinations, separate Code2Wav behavior, sequential +request isolation, longer audio output, and Chinese speech. +""" + +import os + +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.media import generate_synthetic_video +from tests.helpers.runtime import OmniServerParams, dummy_messages_from_mix_data +from tests.helpers.stage_config import get_deploy_config_path + +pytestmark = [pytest.mark.full_model, pytest.mark.omni] + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +_MODEL = "openbmb/MiniCPM-o-4_5" +_CI_DEPLOY = get_deploy_config_path("minicpmo_4_5.yaml") + +test_params = [ + pytest.param( + OmniServerParams( + model=_MODEL, + stage_config_path=_CI_DEPLOY, + use_stage_cli=False, + server_args=[ + "--trust-remote-code", + ], + ), + id="default", + ) +] + + +def get_system_prompt(): + return { + "role": "system", + "content": [ + { + "type": "text", + "text": ( + "You are MiniCPM-o 4.5, a virtual human capable of perceiving " + "auditory and visual inputs, as well as generating text and speech." + ), + } + ], + } + + +def get_prompt(prompt_type: str = "text_only") -> str: + prompts = { + "text_only": "What is the capital of China? Answer in 20 words.", + "mix": "What is recited in the audio? What is in this image? Describe the video briefly.", + } + return prompts.get(prompt_type, prompts["text_only"]) + + +def get_max_batch_size(size_type="few"): + batch_sizes = {"few": 5, "medium": 100, "large": 256} + return batch_sizes.get(size_type, 5) + + +@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_text_video_to_text_001(omni_server, openai_client) -> None: + """ + Test text + video input generating text output. + Deploy Setting: default 2GPU + Input Modal: text + video + Output Modal: text + Input Setting: stream=False + """ + video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(24, 24, 20)['base64']}" + messages = dummy_messages_from_mix_data( + system_prompt=get_system_prompt(), + video_data_url=video_data_url, + content_text=get_prompt("mix"), + ) + + request_config = { + "model": omni_server.model, + "messages": messages, + "stream": False, + "modalities": ["text"], + } + + openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) + + +@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_sequential_requests_independent(omni_server, openai_client) -> None: + """ + Verify that sequential requests produce independent results and that the + second request does not receive the first request's audio or token state. + Deploy Setting: default 2GPU + Input Modal: text (two different prompts) + Output Modal: text + audio (both) + """ + messages_1 = dummy_messages_from_mix_data( + system_prompt=get_system_prompt(), + content_text="What is the capital of France?", + ) + messages_2 = dummy_messages_from_mix_data( + system_prompt=get_system_prompt(), + content_text="What is the capital of China?", + ) + + # Send first request and ensure it completes + openai_client.send_omni_request( + { + "model": omni_server.model, + "messages": messages_1, + "stream": True, + }, + request_num=1, + ) + + # Send second request with different prompt; must produce its own output + openai_client.send_omni_request( + { + "model": omni_server.model, + "messages": messages_2, + "stream": True, + "key_words": {"text": ["Beijing"]}, + }, + request_num=1, + ) + + +@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_text_to_audio_long_output_001(omni_server, openai_client) -> None: + """ + Test text input generating a longer audio output to exercise the + Code2Wav stage across multiple frames. + Deploy Setting: default 2GPU + Input Modal: text (longer prompt) + Output Modal: text + audio + Input Setting: stream=True + """ + messages = dummy_messages_from_mix_data( + system_prompt=get_system_prompt(), + content_text="What is the capital of China? Answer in 40 words.", + ) + + request_config = { + "model": omni_server.model, + "messages": messages, + "modalities": ["text", "audio"], + "stream": True, + # Delimit the assistant answer with the TTS template so the Talker + # does not spend its codec-token budget speaking hidden reasoning. + "extra_body": { + "chat_template_kwargs": { + "use_tts_template": True, + "enable_thinking": False, + } + }, + "key_words": {"audio": ["Beijing"]}, + } + + openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) + + +@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=1) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_chinese_text_to_audio(omni_server, openai_client) -> None: + """ + Test Chinese text input generating audio output. + """ + messages = dummy_messages_from_mix_data( + system_prompt=get_system_prompt(), + content_text="北京,中国的首都,是一座融合了长城等历史地点与现代建筑的国际化大都市,充满了独特的文化与活力。请重复这句话。", + ) + request_config = { + "model": omni_server.model, + "messages": messages, + "stream": True, + "key_words": {"text": ["北京"]}, + } + openai_client.send_omni_request(request_config) diff --git a/tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py b/tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py new file mode 100644 index 000000000..1dbfdbe2f --- /dev/null +++ b/tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py @@ -0,0 +1,1705 @@ +import asyncio +import base64 +import importlib.util +import subprocess +import sys +import wave +from pathlib import Path +from types import SimpleNamespace +from urllib.parse import parse_qs, urlsplit + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +DRIVER_DIR = Path(__file__).resolve().parent +DEMO_PATH = DRIVER_DIR / "minicpmo_realtime_duplex_scenarios.py" +MULTI_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_multi_session.py" +PAIR_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_demo_pair.py" +SOFT_INTERRUPT_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_soft_interrupt.py" + + +def _load_demo_module(): + spec = importlib.util.spec_from_file_location("minicpmo_realtime_duplex_demo_test", DEMO_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _load_multi_demo_module(): + spec = importlib.util.spec_from_file_location("minicpmo_realtime_duplex_multi_session_test", MULTI_DEMO_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _load_pair_demo_module(): + spec = importlib.util.spec_from_file_location("minicpmo_realtime_duplex_demo_pair_test", PAIR_DEMO_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _load_soft_interrupt_demo_module(): + spec = importlib.util.spec_from_file_location( + "minicpmo_realtime_duplex_soft_interrupt_test", + SOFT_INTERRUPT_DEMO_PATH, + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_realtime_duplex_multi_session_script_is_directly_executable(): + result = subprocess.run( + [sys.executable, str(MULTI_DEMO_PATH), "--help"], + cwd=MULTI_DEMO_PATH.parents[3], + capture_output=True, + text=True, + timeout=20, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_realtime_duplex_demo_pair_script_is_directly_executable(): + result = subprocess.run( + [sys.executable, str(PAIR_DEMO_PATH), "--help"], + cwd=PAIR_DEMO_PATH.parents[3], + capture_output=True, + text=True, + timeout=20, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_realtime_duplex_soft_interrupt_script_is_directly_executable(): + result = subprocess.run( + [sys.executable, str(SOFT_INTERRUPT_DEMO_PATH), "--help"], + cwd=SOFT_INTERRUPT_DEMO_PATH.parents[3], + capture_output=True, + text=True, + timeout=20, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_realtime_duplex_demo_pair_requires_distinct_inputs_and_outputs(tmp_path): + demo = _load_pair_demo_module() + wav_a = tmp_path / "request_a.wav" + wav_b = tmp_path / "request_b.wav" + wav_a.write_bytes(b"same") + wav_b.write_bytes(b"same") + + args = SimpleNamespace( + input_wav_a=str(wav_a), + input_wav_b=str(wav_b), + output_dir_a=str(tmp_path / "session_a"), + output_dir_b=str(tmp_path / "session_a"), + ) + + with pytest.raises(ValueError, match="output directories must be different"): + demo._validate_pair_args(args) + + args.output_dir_b = str(tmp_path / "session_b") + with pytest.raises(ValueError, match="input WAV files must have different content"): + demo._validate_pair_args(args) + + +def test_realtime_duplex_demo_pair_launches_demo_processes_concurrently(tmp_path, monkeypatch): + demo = _load_pair_demo_module() + fake_demo = tmp_path / "fake_realtime_duplex_demo.py" + start_log = tmp_path / "starts.log" + fake_demo.write_text( + "\n".join( + [ + "import argparse, json, os, time", + "from pathlib import Path", + "parser = argparse.ArgumentParser()", + "parser.add_argument('--url')", + "parser.add_argument('--model')", + "parser.add_argument('--input-wav')", + "parser.add_argument('--output-dir')", + "parser.add_argument('--chunk-ms')", + "parser.add_argument('--timeout-s')", + "parser.add_argument('--session-id')", + "parser.add_argument('--ref-audio')", + "parser.add_argument('--require-audio', action='store_true')", + "parser.add_argument('--no-realtime-pacing', action='store_true')", + "args = parser.parse_args()", + "if args.ref_audio != os.environ.get('EXPECTED_REF_AUDIO'):", + " raise SystemExit(f'unexpected ref audio: {args.ref_audio}')", + "output = Path(args.output_dir)", + "output.mkdir(parents=True, exist_ok=True)", + "response_id = 'resp-' + output.name", + "with open(os.environ['PAIR_START_LOG'], 'a', encoding='utf-8') as f:", + " f.write(f'{output.name} {time.time()}\\n')", + "time.sleep(0.25)", + "events = [", + " {'type': 'session.created'},", + " {'type': 'response.created', 'response': {'id': response_id}},", + " {'type': 'response.audio.delta', 'response_id': response_id, " + "'delta': 'AAAA', 'sample_rate_hz': 24000, '_client_received_at_s': 10.1},", + " {'type': 'response.audio.delta', 'response_id': response_id, " + "'delta': 'AAAA', 'sample_rate_hz': 24000, '_client_received_at_s': 10.6},", + " {'type': 'response.done', 'response_id': response_id},", + " {'type': 'session.closed'},", + "]", + "Path(output / 'events.jsonl').write_text(", + " ''.join(json.dumps(event) + '\\n' for event in events),", + " encoding='utf-8',", + ")", + "result = {'ok': True, 'response_ids': [response_id], 'audio_bytes': 1, 'output_dir': str(output)}", + "Path(output / 'result.json').write_text(json.dumps(result), encoding='utf-8')", + "print(json.dumps(result))", + ] + ), + encoding="utf-8", + ) + monkeypatch.setattr(demo, "DEMO_PATH", fake_demo) + monkeypatch.setenv("PAIR_START_LOG", str(start_log)) + monkeypatch.setenv("EXPECTED_REF_AUDIO", str(tmp_path / "ref.wav")) + wav_a = tmp_path / "request_a.wav" + wav_b = tmp_path / "request_b.wav" + wav_a.write_bytes(b"request a") + wav_b.write_bytes(b"request b") + + result = asyncio.run( + demo.run_pair( + SimpleNamespace( + url="ws://localhost:28889/v1/realtime?duplex=1", + model="/data/why/MiniCPM-o-4_5", + input_wav_a=str(wav_a), + input_wav_b=str(wav_b), + output_dir_a=str(tmp_path / "duplex_session_a"), + output_dir_b=str(tmp_path / "duplex_session_b"), + ref_audio=str(tmp_path / "ref.wav"), + summary_output=str(tmp_path / "summary.json"), + chunk_ms=200, + timeout_s=5.0, + no_realtime_pacing=False, + require_audio=True, + min_audio_deltas_per_session=2, + ) + ) + ) + + starts = [float(line.split()[1]) for line in start_log.read_text(encoding="utf-8").splitlines()] + assert len(starts) == 2 + assert max(starts) - min(starts) < 0.2 + assert result["ok"] is True + assert result["input_wavs_distinct"] is True + assert result["output_dirs_distinct"] is True + assert result["identity_isolation_ok"] is True + assert result["response_audio_contract_ok"] is True + assert {session["audio_delta_count"] for session in result["sessions"]} == {2} + for session in result["sessions"]: + assert session["all_audio_deltas_same_response"] is True + assert session["response_id_nonempty"] is True + assert session["first_audio_delta_before_done"] is True + assert len(session["audio_delta_timings"]) == 2 + + +def test_realtime_duplex_demo_pair_rejects_false_green_audio_contract(tmp_path): + demo = _load_pair_demo_module() + output = tmp_path / "session" + output.mkdir() + response_id = "resp-a" + events = [ + {"type": "response.created", "response": {"id": response_id}}, + {"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "sample_rate_hz": 24000}, + {"type": "response.audio.delta", "response_id": "resp-other", "delta": "AAAA", "sample_rate_hz": 24000}, + {"type": "response.done", "response_id": response_id}, + ] + (output / "events.jsonl").write_text( + "".join(demo.json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + (output / "result.json").write_text( + demo.json.dumps({"ok": True, "response_ids": [response_id], "audio_bytes": 4}), + encoding="utf-8", + ) + + session = demo._summarize_session( + label="a", + input_wav="request.wav", + output_dir=str(output), + returncode=0, + stdout="", + stderr="", + ) + + assert session["ok"] is False + assert session["all_audio_deltas_same_response"] is False + assert session["response_audio_contract_ok"] is False + + +def test_realtime_duplex_demo_pair_default_requires_multiple_audio_deltas(monkeypatch): + demo = _load_pair_demo_module() + monkeypatch.setattr( + demo.sys, + "argv", + [ + "pair.py", + "--input-wav-a", + "a.wav", + "--input-wav-b", + "b.wav", + "--output-dir-a", + "a", + "--output-dir-b", + "b", + "--ref-audio", + "ref.wav", + ], + ) + + args = demo.parse_args() + + assert args.ref_audio == "ref.wav" + assert args.min_audio_deltas_per_session == 2 + + +def test_realtime_duplex_demo_pair_requires_ref_audio(monkeypatch): + demo = _load_pair_demo_module() + monkeypatch.setattr( + demo.sys, + "argv", + [ + "pair.py", + "--input-wav-a", + "a.wav", + "--input-wav-b", + "b.wav", + "--output-dir-a", + "a", + "--output-dir-b", + "b", + ], + ) + + with pytest.raises(SystemExit): + demo.parse_args() + + +def test_realtime_duplex_soft_interrupt_requires_ref_audio(monkeypatch): + demo = _load_soft_interrupt_demo_module() + monkeypatch.setattr( + demo.sys, + "argv", + [ + "soft.py", + "--input-wav", + "input.wav", + "--output-dir", + "out", + ], + ) + + with pytest.raises(SystemExit): + demo.parse_args() + + +def test_realtime_duplex_soft_interrupt_accepts_explicit_ref_audio(monkeypatch): + demo = _load_soft_interrupt_demo_module() + monkeypatch.setattr( + demo.sys, + "argv", + [ + "soft.py", + "--input-wav", + "input.wav", + "--output-dir", + "out", + "--ref-audio", + "ref.wav", + ], + ) + + args = demo.parse_args() + + assert args.ref_audio == "ref.wav" + assert args.validation_mode == "model-policy" + + +def test_realtime_duplex_soft_interrupt_response_required_needs_bound_fixture(monkeypatch): + demo = _load_soft_interrupt_demo_module() + monkeypatch.setattr( + demo.sys, + "argv", + [ + "soft.py", + "--input-wav", + "input.wav", + "--output-dir", + "out", + "--ref-audio", + "ref.wav", + "--validation-mode", + "response-required", + ], + ) + + with pytest.raises(SystemExit): + demo.parse_args() + + +def test_realtime_duplex_soft_interrupt_rejects_input_checksum_mismatch(tmp_path): + demo = _load_soft_interrupt_demo_module() + input_wav = tmp_path / "input.wav" + input_wav.write_bytes(b"two-turn fixture") + + with pytest.raises(ValueError, match="input WAV SHA256 mismatch"): + demo._validate_input_sha256(input_wav, "0" * 64) + + +def test_realtime_duplex_soft_interrupt_accepts_multi_delta_handoff_sequence(tmp_path): + demo = _load_soft_interrupt_demo_module() + output = tmp_path / "soft_interrupt" + output.mkdir() + first_response_id = "resp-first" + second_response_id = "resp-second" + events = [ + {"type": "response.listen", "_client_received_at_s": 1.0}, + {"type": "response.created", "response": {"id": first_response_id}, "_client_received_at_s": 2.0}, + {"type": "response.speak", "response_id": first_response_id, "_client_received_at_s": 2.0}, + { + "type": "response.audio.delta", + "response_id": first_response_id, + "delta": "AAAA", + "_client_received_at_s": 2.1, + }, + { + "type": "response.audio_transcript.delta", + "response_id": first_response_id, + "delta": "中国古代四大发明", + "_client_received_at_s": 2.1, + }, + { + "type": "response.audio.delta", + "response_id": first_response_id, + "delta": "AAAA", + "_client_received_at_s": 2.6, + }, + { + "type": "response.audio_transcript.delta", + "response_id": first_response_id, + "delta": "是造纸术。", + "_client_received_at_s": 2.6, + }, + {"type": "response.done", "response_id": first_response_id, "_client_received_at_s": 3.0}, + {"type": "response.created", "response": {"id": second_response_id}, "_client_received_at_s": 4.0}, + {"type": "response.speak", "response_id": second_response_id, "_client_received_at_s": 4.0}, + { + "type": "response.audio.delta", + "response_id": second_response_id, + "delta": "AAAA", + "_client_received_at_s": 4.1, + }, + { + "type": "response.audio_transcript.delta", + "response_id": second_response_id, + "delta": "一加一等于", + "_client_received_at_s": 4.1, + }, + { + "type": "response.audio.delta", + "response_id": second_response_id, + "delta": "AAAA", + "_client_received_at_s": 4.6, + }, + { + "type": "response.audio_transcript.delta", + "response_id": second_response_id, + "delta": "二。", + "_client_received_at_s": 4.6, + }, + {"type": "response.done", "response_id": second_response_id, "_client_received_at_s": 5.0}, + {"type": "response.listen", "_client_received_at_s": 5.2}, + {"type": "input_audio_buffer.committed", "_client_received_at_s": 6.0}, + {"type": "response.listen", "_client_received_at_s": 6.1}, + ] + (output / "events.jsonl").write_text( + "".join(demo.json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + (output / "result.json").write_text( + demo.json.dumps({"ok": True, "response_ids": [first_response_id, second_response_id]}), + encoding="utf-8", + ) + + summary = demo.summarize_artifacts( + output_dir=output, + validation_mode="response-required", + min_responses=2, + min_audio_deltas_per_response=2, + expect_followup_response_substring="一加一等于二", + ) + + assert summary["ok"] is True + assert summary["listen_between_responses"] is False + assert summary["second_response_before_final_commit"] is True + assert summary["final_listen_after_commit"] is True + assert summary["response_audio_contract_ok"] is True + assert summary["response_summaries"][0]["audio_delta_count"] == 2 + assert summary["response_summaries"][1]["transcript"] == "一加一等于二。" + + +def test_realtime_duplex_soft_interrupt_model_policy_accepts_single_response(tmp_path): + demo = _load_soft_interrupt_demo_module() + output = tmp_path / "model_policy" + output.mkdir() + response_id = "resp-only" + events = [ + {"type": "response.listen", "_client_received_at_s": 1.0}, + {"type": "response.created", "response": {"id": response_id}, "_client_received_at_s": 2.0}, + {"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "_client_received_at_s": 2.1}, + {"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "_client_received_at_s": 2.15}, + {"type": "response.done", "response_id": response_id, "_client_received_at_s": 2.2}, + {"type": "response.listen", "_client_received_at_s": 2.3}, + {"type": "input_audio_buffer.committed", "_client_received_at_s": 3.0}, + {"type": "response.listen", "_client_received_at_s": 3.1}, + ] + (output / "events.jsonl").write_text( + "".join(demo.json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + (output / "result.json").write_text( + demo.json.dumps({"ok": True, "response_ids": [response_id]}), + encoding="utf-8", + ) + + summary = demo.summarize_artifacts( + output_dir=output, + validation_mode="model-policy", + min_responses=2, + min_audio_deltas_per_response=2, + expect_followup_response_substring=None, + ) + + assert summary["ok"] is True + assert summary["validation_mode"] == "model-policy" + assert summary["enough_responses"] is True + assert summary["response_before_final_commit"] is True + assert summary["listen_after_response_before_commit"] is True + + +def test_realtime_duplex_soft_interrupt_response_required_rejects_single_response(tmp_path): + demo = _load_soft_interrupt_demo_module() + output = tmp_path / "response_required" + output.mkdir() + response_id = "resp-only" + events = [ + {"type": "response.listen", "_client_received_at_s": 1.0}, + {"type": "response.created", "response": {"id": response_id}, "_client_received_at_s": 2.0}, + {"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "_client_received_at_s": 2.1}, + {"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "_client_received_at_s": 2.15}, + {"type": "response.done", "response_id": response_id, "_client_received_at_s": 2.2}, + {"type": "response.listen", "_client_received_at_s": 2.3}, + {"type": "input_audio_buffer.committed", "_client_received_at_s": 3.0}, + {"type": "response.listen", "_client_received_at_s": 3.1}, + ] + (output / "events.jsonl").write_text( + "".join(demo.json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + (output / "result.json").write_text( + demo.json.dumps({"ok": True, "response_ids": [response_id]}), + encoding="utf-8", + ) + + summary = demo.summarize_artifacts( + output_dir=output, + validation_mode="response-required", + min_responses=2, + min_audio_deltas_per_response=2, + expect_followup_response_substring="一加一等于二", + ) + + assert summary["ok"] is False + assert summary["enough_responses"] is False + + +def test_realtime_duplex_soft_interrupt_reports_text_expectation_without_gating(tmp_path): + demo = _load_soft_interrupt_demo_module() + output = tmp_path / "second_response_text" + output.mkdir() + first_response_id = "resp-first" + second_response_id = "resp-second" + events = [ + {"type": "response.listen", "_client_received_at_s": 1.0}, + {"type": "response.created", "response": {"id": first_response_id}, "_client_received_at_s": 2.0}, + { + "type": "response.audio.delta", + "response_id": first_response_id, + "delta": "AAAA", + "_client_received_at_s": 2.1, + }, + { + "type": "response.audio.delta", + "response_id": first_response_id, + "delta": "AAAA", + "_client_received_at_s": 2.2, + }, + { + "type": "response.audio_transcript.delta", + "response_id": first_response_id, + "delta": "一加一等于二", + "_client_received_at_s": 2.2, + }, + {"type": "response.done", "response_id": first_response_id, "_client_received_at_s": 2.3}, + {"type": "response.listen", "_client_received_at_s": 2.4}, + {"type": "response.created", "response": {"id": second_response_id}, "_client_received_at_s": 3.0}, + { + "type": "response.audio.delta", + "response_id": second_response_id, + "delta": "AAAA", + "_client_received_at_s": 3.1, + }, + { + "type": "response.audio.delta", + "response_id": second_response_id, + "delta": "AAAA", + "_client_received_at_s": 3.2, + }, + { + "type": "response.audio_transcript.delta", + "response_id": second_response_id, + "delta": "不知道", + "_client_received_at_s": 3.2, + }, + {"type": "response.done", "response_id": second_response_id, "_client_received_at_s": 3.3}, + {"type": "response.listen", "_client_received_at_s": 3.4}, + {"type": "input_audio_buffer.committed", "_client_received_at_s": 4.0}, + {"type": "response.listen", "_client_received_at_s": 4.1}, + ] + (output / "events.jsonl").write_text( + "".join(demo.json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + (output / "result.json").write_text( + demo.json.dumps({"ok": True, "response_ids": [first_response_id, second_response_id]}), + encoding="utf-8", + ) + + summary = demo.summarize_artifacts( + output_dir=output, + validation_mode="response-required", + min_responses=2, + min_audio_deltas_per_response=2, + expect_followup_response_substring="一加一等于二", + ) + + assert summary["ok"] is True + assert summary["followup_response_transcript_ok"] is True + assert summary["followup_response_transcript_expectation_ok"] is False + + events = [ + event + for event in events + if event.get("response_id") != second_response_id or event["type"] != "response.audio_transcript.delta" + ] + (output / "events.jsonl").write_text( + "".join(demo.json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + missing_transcript_summary = demo.summarize_artifacts( + output_dir=output, + validation_mode="response-required", + min_responses=2, + min_audio_deltas_per_response=2, + expect_followup_response_substring="一加一等于二", + ) + + assert missing_transcript_summary["ok"] is False + assert missing_transcript_summary["followup_response_transcript_ok"] is False + + +def test_realtime_duplex_multi_session_resume_url_disables_autostart(): + demo = _load_multi_demo_module() + + url = demo._with_resume_mode("ws://localhost:8113/v1/realtime?duplex=1&model=openbmb%2FMiniCPM-o-4_5") + + query = parse_qs(urlsplit(url).query) + assert query["model"] == ["openbmb/MiniCPM-o-4_5"] + assert query["resume"] == ["1"] + + +def test_realtime_duplex_multi_session_rejects_cross_session_response_identity(): + demo = _load_multi_demo_module() + + assert demo._validate_identity_isolation( + [ + {"completed_response_ids": ["resp-a"]}, + {"completed_response_ids": ["resp-b"]}, + ] + ) + assert not demo._validate_identity_isolation( + [ + {"completed_response_ids": ["resp-shared"]}, + {"completed_response_ids": ["resp-shared"]}, + ] + ) + + +def test_realtime_duplex_multi_session_reads_nested_terminal_identity(): + demo = _load_multi_demo_module() + + assert demo._event_session_id({"type": "session.heartbeat_ack", "session_id": "sid"}) == "sid" + assert ( + demo._event_session_id({"type": "session.closed", "event": {"type": "session.closed", "session_id": "sid"}}) + == "sid" + ) + + +def test_realtime_duplex_demo_resolves_distinct_turn_inputs(): + demo = _load_demo_module() + primary = Path("first.wav") + + assert demo._turn_input_paths(primary, [], turns=3) == [primary, primary, primary] + assert demo._turn_input_paths(primary, ["second.wav", "third.wav"], turns=3) == [ + primary, + Path("second.wav"), + Path("third.wav"), + ] + with pytest.raises(ValueError, match="one --turn-input-wav"): + demo._turn_input_paths(primary, ["second.wav"], turns=3) + + +def test_realtime_duplex_demo_explicitly_enables_native_runtime_before_connect(): + demo = _load_demo_module() + + url = demo._url_with_model( + "ws://localhost:8099/v1/realtime?duplex=1", + "openbmb/MiniCPM-o-4_5", + ) + + query = parse_qs(urlsplit(url).query) + assert query["minicpmo45_native_duplex"] == ["1"] + + +def test_realtime_duplex_demo_explicit_session_id_reaches_autostart_query(): + demo = _load_demo_module() + + url = demo._url_with_model( + "ws://localhost:8099/v1/realtime?duplex=1", + "openbmb/MiniCPM-o-4_5", + session_id="reopen-e2e", + ) + + query = parse_qs(urlsplit(url).query) + assert query["session_id"] == ["reopen-e2e"] + + +def test_realtime_duplex_demo_session_update_uses_explicit_session_id(): + demo = _load_demo_module() + + event = demo._session_update_event( + SimpleNamespace( + model="openbmb/MiniCPM-o-4_5", + output_audio_format="pcm16", + short_ack_ms=1200, + session_id="reopen-e2e", + ) + ) + + assert event["type"] == "session.update" + assert "session_id" not in event + assert event["session"]["session_id"] == "reopen-e2e" + assert event["session"]["extra_body"]["minicpmo45_native_duplex"] is True + + +def test_realtime_duplex_demo_response_required_uses_deterministic_sampling(): + demo = _load_demo_module() + + event = demo._session_update_event( + SimpleNamespace( + model="openbmb/MiniCPM-o-4_5", + output_audio_format="pcm16", + short_ack_ms=1200, + session_id=None, + validation_mode="response-required", + temperature=None, + ) + ) + + assert event["session"]["temperature"] == 0.0 + + +def test_realtime_duplex_demo_model_policy_preserves_default_sampling(): + demo = _load_demo_module() + + event = demo._session_update_event( + SimpleNamespace( + model="openbmb/MiniCPM-o-4_5", + output_audio_format="pcm16", + short_ack_ms=1200, + session_id=None, + validation_mode="model-policy", + temperature=None, + ) + ) + + assert "temperature" not in event["session"] + + +def test_realtime_duplex_demo_resolves_explicit_turn_durations(): + demo = _load_demo_module() + + assert demo._turn_durations([], turns=3, first_turn_ms=3000) == [3000, 1200, 1200] + assert demo._turn_durations([0, 900, 1500], turns=3, first_turn_ms=3000) == [None, 900, 1500] + with pytest.raises(ValueError, match="one --turn-duration-ms"): + demo._turn_durations([0, 900], turns=3, first_turn_ms=3000) + with pytest.raises(ValueError, match="non-negative"): + demo._turn_durations([0, -1, 900], turns=3, first_turn_ms=3000) + + +def test_realtime_duplex_demo_resolves_transcript_labels_for_requested_turns(): + demo = _load_demo_module() + + assert demo._turn_transcripts("first", turns=1) == ["first"] + assert demo._turn_transcripts("first", turns=4) == ["first", "继续", "再说一次", "turn-4"] + + +def test_realtime_duplex_demo_reads_response_playback_cursor(): + demo = _load_demo_module() + state = demo.DemoState() + state.add( + { + "type": "response.done", + "response_id": "resp-1", + "response": { + "id": "resp-1", + "metadata": { + "playback": { + "sent_ms": 27920, + "played_ms": 0, + } + }, + }, + } + ) + + assert state.response_playback_sent_ms("resp-1") == 27920 + + +def test_realtime_duplex_demo_partitions_timing_by_response_identity(): + demo = _load_demo_module() + state = demo.DemoState() + for response_id, created_at_s, audio_at_s, token_count in ( + ("resp-1", 10.0, 10.1, 3), + ("resp-2", 20.0, 20.2, 5), + ): + state.add( + {"type": "response.created", "response": {"id": response_id}}, + received_at_s=created_at_s, + ) + state.add( + { + "type": "response.audio.delta", + "response_id": response_id, + "delta": base64.b64encode(b"audio").decode("ascii"), + "metadata": { + "audio_duration_ms": 80, + "vllm_omni": { + "stage_metrics": { + "0": { + "num_tokens_out": token_count, + "vllm_itls_ms": [10.0], + } + } + }, + }, + }, + received_at_s=audio_at_s, + ) + + timings = state.response_timing_summaries() + + assert timings["resp-1"]["stage0_tokens"]["output_token_count"] == 3 + assert timings["resp-1"]["audio_output"]["response_created_to_first_audio_ms"] == 100.0 + assert timings["resp-1"]["audio_output"]["commit_to_first_audio_ms"] is None + assert timings["resp-2"]["stage0_tokens"]["output_token_count"] == 5 + assert timings["resp-2"]["audio_output"]["response_created_to_first_audio_ms"] == 200.0 + assert timings["resp-2"]["audio_output"]["commit_to_first_audio_ms"] is None + + +def test_realtime_duplex_demo_model_policy_accepts_one_listen_per_streamed_turn(): + demo = _load_demo_module() + state = demo.DemoState() + state.add({"type": "session.created"}) + for turn in range(3): + state.add({"type": "input_audio_buffer.speech_started", "turn": turn}) + state.add( + { + "type": "response.listen", + "response": { + "status": "listening", + "metadata": {"model_listen": True}, + }, + } + ) + state.add({"type": "input_audio_buffer.committed", "turn": turn}) + + assert state.model_policy_event_order_ok(expected_turns=3) + + +def test_realtime_duplex_demo_model_policy_accepts_continuous_input_without_commits(): + demo = _load_demo_module() + state = demo.DemoState() + state.add({"type": "session.created"}) + state.add({"type": "input_audio_buffer.speech_started"}) + for _ in range(3): + state.add( + { + "type": "response.listen", + "response": {"metadata": {"model_listen": True}}, + } + ) + + assert not state.model_policy_event_order_ok(expected_turns=3) + assert state.model_policy_event_order_ok(expected_turns=3, require_input_commit=False) + + +def test_realtime_duplex_demo_distinguishes_late_and_missing_commit(): + demo = _load_demo_module() + state = demo.DemoState() + response_id = "resp-1" + item_id = f"item_{response_id}" + for event in ( + {"type": "session.created"}, + {"type": "response.created", "response_id": response_id}, + {"type": "conversation.item.added", "item": {"id": item_id}}, + {"type": "response.output_item.added", "response_id": response_id}, + {"type": "response.content_part.added", "response_id": response_id}, + {"type": "response.speak", "response_id": response_id}, + {"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA"}, + {"type": "response.audio.done", "response_id": response_id}, + {"type": "response.content_part.done", "response_id": response_id}, + {"type": "response.output_item.done", "response_id": response_id}, + {"type": "response.done", "response_id": response_id}, + {"type": "input_audio_buffer.committed"}, + ): + state.add(event) + + assert not state.event_order_ok(require_input_commit=True) + assert state.event_order_ok( + require_input_commit=True, + require_commit_before_response=False, + ) + state.events.pop() + assert not state.event_order_ok( + require_input_commit=True, + require_commit_before_response=False, + ) + + +def test_realtime_duplex_demo_accepts_overlap_unit_terminating_continuous_response(): + demo = _load_demo_module() + state = demo.DemoState() + state.add({"type": "response.created", "response_id": "resp-1"}) + state.add({"type": "response.done", "response_id": "resp-1"}) + + assert demo._continuous_overlap_terminal_is_outcome( + state, + validation_mode="model-policy", + model_unit_ready_while_active=True, + before_created=1, + before_model_listen=0, + ) + assert not demo._continuous_overlap_terminal_is_outcome( + state, + validation_mode="response-required", + model_unit_ready_while_active=True, + before_created=1, + before_model_listen=0, + ) + + +def test_realtime_duplex_demo_full_turn_duration_does_not_slice_audio(): + demo = _load_demo_module() + pcm16 = b"\x01\x00" * (demo.PCM16_SAMPLE_RATE * 2) + + assert demo._select_turn_audio(pcm16, None) == pcm16 + assert len(demo._select_turn_audio(pcm16, 1000)) == demo.PCM16_SAMPLE_RATE * demo.PCM16_BYTES_PER_SAMPLE + + +def test_realtime_duplex_demo_distinct_inputs_compare_audio_content(): + demo = _load_demo_module() + paths = [Path("first.wav"), Path("second.wav"), Path("third.wav")] + + assert demo._turn_inputs_are_distinct(paths, [b"first", b"second", b"third"]) is True + assert demo._turn_inputs_are_distinct(paths, [b"same", b"same", b"third"]) is False + assert demo._turn_inputs_are_distinct([paths[0], paths[0]], [b"first", b"second"]) is False + + +def _add_response_transcript(state, response_id, *, transcript, audio=True): + state.add( + { + "type": "response.audio.delta", + "response_id": response_id, + "delta": "YQ==" if audio else "", + } + ) + if transcript: + state.add( + { + "type": "response.audio_transcript.delta", + "response_id": response_id, + "delta": transcript, + } + ) + state.add( + { + "type": "response.audio_transcript.done", + "response_id": response_id, + "transcript": transcript, + } + ) + state.add( + { + "type": "response.done", + "response_id": response_id, + "response": {"id": response_id, "status": "completed"}, + } + ) + + +def test_realtime_duplex_demo_gate_checks_delta_done_and_turn_independence(): + demo = _load_demo_module() + state = demo.DemoState() + _add_response_transcript( + state, + "resp-1", + transcript="第一轮回答", + ) + _add_response_transcript( + state, + "resp-2", + transcript="第二轮回答", + ) + + result = demo._evaluate_transcript_integrity( + state, + ["resp-1", "resp-2"], + expected_empty_response_ids=set(), + require_cross_turn_independence=True, + ) + + assert result["transcript_delta_done_ok"] is True + assert result["cross_turn_independent_ok"] is True + + +def test_realtime_duplex_demo_speak_gate_accepts_one_decision_event_per_response(): + demo = _load_demo_module() + state = demo.DemoState() + state.add({"type": "response.created", "response": {"id": "resp-1"}}) + state.add( + { + "type": "response.speak", + "response_id": "resp-1", + "metadata": {"model_speak": True}, + } + ) + + assert demo._evaluate_response_speak_contract(state)["response_speak_contract_ok"] is True + + +def test_realtime_duplex_demo_speak_gate_rejects_duplicate_event(): + demo = _load_demo_module() + state = demo.DemoState() + state.add({"type": "response.created", "response": {"id": "resp-1"}}) + state.add({"type": "response.speak", "response_id": "resp-1"}) + state.add({"type": "response.speak", "response_id": "resp-1"}) + + result = demo._evaluate_response_speak_contract(state) + + assert result["response_speak_contract_ok"] is False + assert result["duplicate_response_speak_ids"] == ["resp-1"] + + +def test_realtime_duplex_demo_speak_gate_rejects_text_channel(): + demo = _load_demo_module() + state = demo.DemoState() + state.add({"type": "response.created", "response": {"id": "resp-1"}}) + state.add( + { + "type": "response.speak", + "response_id": "resp-1", + "text": "must be emitted through response.audio_transcript.delta", + } + ) + + result = demo._evaluate_response_speak_contract(state) + + assert result["response_speak_contract_ok"] is False + assert result["text_bearing_response_speak_count"] == 1 + + +def test_realtime_duplex_demo_gate_rejects_cross_turn_tail_reuse(): + demo = _load_demo_module() + state = demo.DemoState() + _add_response_transcript(state, "resp-1", transcript="第一轮回答") + _add_response_transcript(state, "resp-2", transcript="第二轮仍然带着第一轮回答") + + result = demo._evaluate_transcript_integrity( + state, + ["resp-1", "resp-2"], + expected_empty_response_ids=set(), + require_cross_turn_independence=True, + ) + + assert result["cross_turn_independent_ok"] is False + + +def test_realtime_duplex_demo_gate_rejects_terminal_only_previous_tail(): + demo = _load_demo_module() + state = demo.DemoState() + _add_response_transcript(state, "resp-1", transcript="上一轮回答结尾是的吗?") + _add_response_transcript(state, "resp-2", transcript="的吗?") + + result = demo._evaluate_transcript_integrity( + state, + ["resp-1", "resp-2"], + expected_empty_response_ids=set(), + require_cross_turn_independence=True, + ) + + assert result["cross_turn_independent_ok"] is False + + +def test_realtime_duplex_demo_gate_rejects_delta_done_mismatch(): + demo = _load_demo_module() + state = demo.DemoState() + _add_response_transcript(state, "resp-1", transcript="delta文本") + done = next(event for event in state.events if event.get("type") == "response.audio_transcript.done") + done["transcript"] = "另一个done文本" + + result = demo._evaluate_transcript_integrity( + state, + ["resp-1"], + expected_empty_response_ids=set(), + require_cross_turn_independence=False, + ) + + assert result["transcript_delta_done_ok"] is False + + +def test_realtime_duplex_demo_gate_rejects_terminal_only_stale_tail(): + demo = _load_demo_module() + state = demo.DemoState() + _add_response_transcript( + state, + "resp-empty", + transcript="的吗?", + audio=False, + ) + + result = demo._evaluate_transcript_integrity( + state, + ["resp-empty"], + expected_empty_response_ids={"resp-empty"}, + require_cross_turn_independence=False, + ) + + assert result["empty_turns_ok"] is False + + +def test_realtime_duplex_demo_gate_rejects_audio_without_transcript(): + demo = _load_demo_module() + state = demo.DemoState() + _add_response_transcript( + state, + "resp-audio-no-text", + transcript="", + audio=True, + ) + + result = demo._evaluate_transcript_integrity( + state, + ["resp-audio-no-text"], + expected_empty_response_ids=set(), + require_cross_turn_independence=False, + ) + + assert result["nonempty_audio_has_transcript_ok"] is False + + +def test_realtime_duplex_demo_response_required_rejects_unselected_audio_only_response(): + demo = _load_demo_module() + state = demo.DemoState() + _add_response_transcript(state, "resp-audio-only", transcript="", audio=True) + _add_response_transcript(state, "resp-with-text", transcript="你好", audio=True) + + assert ( + demo._all_audio_responses_have_transcript( + state, + ["resp-audio-only", "resp-with-text"], + ) + is False + ) + + +def test_realtime_duplex_demo_gate_rejects_incomplete_model_turn_sentence(): + demo = _load_demo_module() + state = demo.DemoState() + _add_response_transcript(state, "resp-1", transcript="哎,不是说好不") + + result = demo._evaluate_transcript_integrity( + state, + ["resp-1"], + expected_empty_response_ids=set(), + require_cross_turn_independence=False, + require_terminal_punctuation=True, + ) + + assert result["terminal_punctuation_ok"] is False + + +def test_realtime_duplex_demo_gate_accepts_short_interjection_without_punctuation(): + demo = _load_demo_module() + state = demo.DemoState() + _add_response_transcript(state, "resp-1", transcript="哈哈") + + result = demo._evaluate_transcript_integrity( + state, + ["resp-1"], + expected_empty_response_ids=set(), + require_cross_turn_independence=False, + require_terminal_punctuation=True, + ) + + assert result["terminal_punctuation_ok"] is True + + +def test_realtime_duplex_demo_response_required_skips_audio_only_model_turn(monkeypatch): + demo = _load_demo_module() + state = demo.DemoState() + + async def fake_send_pcm16(*args, **kwargs): + state.add({"type": "response.created", "response": {"id": "resp-audio-only"}}) + _add_response_transcript(state, "resp-audio-only", transcript="", audio=True) + state.add({"type": "response.created", "response": {"id": "resp-with-text"}}) + _add_response_transcript(state, "resp-with-text", transcript="你好", audio=True) + + class FakeWebSocket: + async def send(self, payload): + return None + + monkeypatch.setattr(demo, "_send_pcm16", fake_send_pcm16) + + response_id, outcome = asyncio.run( + demo._send_clean_turn( + FakeWebSocket(), + state, + b"\x00\x00", + transcript="fixture", + duration_ms=None, + chunk_ms=200, + timeout_s=0.1, + require_audio=True, + validation_mode="response-required", + ) + ) + + assert response_id == "resp-with-text" + assert outcome == "speak" + + +def test_realtime_duplex_demo_no_hint_realtime_turn_omits_transcript_hint(monkeypatch): + demo = _load_demo_module() + state = demo.DemoState() + captured = {} + + async def fake_send_pcm16(*args, **kwargs): + del args + captured.update(kwargs) + state.add( + { + "type": "response.listen", + "response": {"metadata": {"model_listen": True}}, + } + ) + + class FakeWebSocket: + async def send(self, payload): + del payload + + monkeypatch.setattr(demo, "_send_pcm16", fake_send_pcm16) + + response_id, outcome = asyncio.run( + demo._send_clean_turn( + FakeWebSocket(), + state, + b"\x00\x00", + transcript="turn label only", + duration_ms=None, + chunk_ms=200, + timeout_s=0.1, + require_audio=False, + validation_mode="model-policy", + send_transcript_hint=False, + realtime_input=True, + model_policy_settle_s=0.01, + ) + ) + + assert response_id is None + assert outcome == "listen" + assert captured["hints"] == {} + assert captured["realtime_delay"] is True + + +def test_realtime_duplex_demo_continuous_input_omits_browser_commit(monkeypatch): + demo = _load_demo_module() + state = demo.DemoState() + + async def fake_send_pcm16(*args, **kwargs): + del args, kwargs + state.add({"type": "response.created", "response": {"id": "resp-continuous"}}) + _add_response_transcript(state, "resp-continuous", transcript="连续输入", audio=True) + + class FakeWebSocket: + def __init__(self): + self.messages = [] + + async def send(self, payload): + self.messages.append(demo.json.loads(payload)) + + monkeypatch.setattr(demo, "_send_pcm16", fake_send_pcm16) + ws = FakeWebSocket() + + response_id, outcome = asyncio.run( + demo._send_clean_turn( + ws, + state, + b"\x00\x00", + transcript="fixture", + duration_ms=None, + chunk_ms=200, + timeout_s=0.1, + require_audio=True, + validation_mode="response-required", + commit_input=False, + ) + ) + + assert response_id == "resp-continuous" + assert outcome == "speak" + assert not any(message.get("type") == "input_audio_buffer.commit" for message in ws.messages) + + +def test_realtime_duplex_demo_no_hint_gate_does_not_require_transcription_event(): + demo = _load_demo_module() + + assert demo._input_transcription_ok(0, transcript_hints_enabled=False) + assert not demo._input_transcription_ok(0, transcript_hints_enabled=True) + assert demo._input_transcription_ok(3, transcript_hints_enabled=True) + + +def test_realtime_duplex_demo_gate_rejects_server_errors(): + demo = _load_demo_module() + state = demo.DemoState() + state.add({"type": "error", "code": "runtime_signal_failed", "error": "signal failed"}) + + errors = demo._unexpected_error_events(state) + + assert errors == [{"type": "error", "code": "runtime_signal_failed", "error": "signal failed"}] + + +def test_realtime_duplex_demo_model_policy_waits_for_speak_after_intermediate_listen(monkeypatch): + demo = _load_demo_module() + state = demo.DemoState() + + async def fake_send_pcm16(*args, **kwargs): + del args, kwargs + state.add( + { + "type": "response.listen", + "response": {"metadata": {"model_listen": True}}, + } + ) + + async def delayed_speak(): + await asyncio.sleep(0.01) + state.add({"type": "response.created", "response": {"id": "resp-delayed"}}) + _add_response_transcript(state, "resp-delayed", transcript="延迟回答", audio=False) + + asyncio.create_task(delayed_speak()) + + class FakeWebSocket: + async def send(self, payload): + del payload + + monkeypatch.setattr(demo, "_send_pcm16", fake_send_pcm16) + + response_id, outcome = asyncio.run( + demo._send_clean_turn( + FakeWebSocket(), + state, + b"\x00\x00", + transcript="fixture", + duration_ms=None, + chunk_ms=200, + timeout_s=0.2, + require_audio=False, + validation_mode="model-policy", + model_policy_settle_s=0.05, + ) + ) + + assert response_id == "resp-delayed" + assert outcome == "speak" + + +def test_realtime_duplex_demo_playback_gate_covers_unassigned_completed_response(): + demo = _load_demo_module() + state = demo.DemoState() + for response_id in ("resp-assigned", "resp-unassigned"): + state.add({"type": "response.created", "response": {"id": response_id}}) + state.add( + { + "type": "response.done", + "response": { + "id": response_id, + "metadata": {"playback": {"sent_ms": 1200}}, + }, + } + ) + state.add( + { + "type": "playback.acknowledged", + "event": { + "item_id": "item_resp-assigned", + "history_committed": True, + }, + } + ) + + assert not demo._all_response_playback_history_committed( + state, + state.completed_response_ids(), + ) + + +def test_realtime_duplex_demo_acks_unassigned_completed_response(): + demo = _load_demo_module() + state = demo.DemoState() + for response_id in ("resp-assigned", "resp-unassigned"): + state.add({"type": "response.created", "response": {"id": response_id}}) + state.add( + { + "type": "response.done", + "response": { + "id": response_id, + "metadata": {"playback": {"sent_ms": 1200}}, + }, + } + ) + state.add( + { + "type": "playback.acknowledged", + "event": { + "item_id": "item_resp-assigned", + "history_committed": True, + }, + } + ) + + class FakeWebSocket: + def __init__(self): + self.messages = [] + + async def send(self, payload): + message = demo.json.loads(payload) + self.messages.append(message) + state.add( + { + "type": "playback.acknowledged", + "event": { + "item_id": message["item_id"], + "history_committed": True, + }, + } + ) + + ws = FakeWebSocket() + asyncio.run(demo._ack_all_completed_response_playback(ws, state, timeout_s=0.1)) + + assert [message["response_id"] for message in ws.messages] == ["resp-unassigned"] + assert demo._all_response_playback_history_committed(state, state.completed_response_ids()) + + +def test_realtime_duplex_demo_model_policy_does_not_assume_one_response_per_physical_input(): + demo = _load_demo_module() + + assert demo._response_cardinality_ok( + ["resp-1", "resp-2", "resp-3", "resp-4"], + expected_turns=3, + validation_mode="model-policy", + ) + assert not demo._response_cardinality_ok( + ["resp-1", "resp-2", "resp-3", "resp-4"], + expected_turns=3, + validation_mode="response-required", + ) + + +def test_realtime_duplex_demo_only_requires_cross_turn_independence_for_response_required(): + demo = _load_demo_module() + + assert not demo._requires_cross_turn_independence( + validation_mode="model-policy", + distinct_inputs_required=True, + ) + assert demo._requires_cross_turn_independence( + validation_mode="response-required", + distinct_inputs_required=True, + ) + assert not demo._requires_cross_turn_independence( + validation_mode="response-required", + distinct_inputs_required=False, + ) + + +def test_realtime_duplex_demo_drains_late_model_response_before_close(): + demo = _load_demo_module() + state = demo.DemoState() + state.add({"type": "response.created", "response": {"id": "resp-first"}}) + state.add({"type": "response.done", "response": {"id": "resp-first"}}) + + class FakeWebSocket: + def __init__(self): + self.messages = [] + + async def send(self, payload): + self.messages.append(demo.json.loads(payload)) + + async def run_fixture(): + async def finish_late_response(): + await asyncio.sleep(0.01) + state.add({"type": "response.created", "response": {"id": "resp-late"}}) + await asyncio.sleep(0.01) + state.add({"type": "response.done", "response": {"id": "resp-late"}}) + + late_response = asyncio.create_task(finish_late_response()) + ws = FakeWebSocket() + await demo._drain_model_policy_responses( + ws, + state, + timeout_s=0.2, + settle_s=0.03, + ) + await late_response + return ws + + ws = asyncio.run(run_fixture()) + + assert state.completed_response_ids() == ["resp-first", "resp-late"] + assert not [message for message in ws.messages if message.get("type") == "session.close"] + + +def test_realtime_duplex_demo_model_response_drain_times_out_with_active_ids(): + demo = _load_demo_module() + state = demo.DemoState() + state.add({"type": "response.created", "response": {"id": "resp-active"}}) + + class FakeWebSocket: + async def send(self, payload): + del payload + + with pytest.raises(TimeoutError, match="resp-active"): + asyncio.run( + demo._drain_model_policy_responses( + FakeWebSocket(), + state, + timeout_s=0.03, + settle_s=0.01, + ) + ) + + +def test_realtime_duplex_demo_waits_at_each_model_unit_and_stops_after_speak(): + demo = _load_demo_module() + + class FakeWebSocket: + def __init__(self): + self.messages = [] + + async def send(self, payload): + self.messages.append(demo.json.loads(payload)) + + async def run_fixture(): + ws = FakeWebSocket() + model_unit_message_counts = [] + + async def on_model_unit_ready(): + model_unit_message_counts.append(len(ws.messages)) + await asyncio.sleep(0) + return len(model_unit_message_counts) < 2 + + await demo._send_pcm16( + ws, + b"\x01\x00" * (demo.PCM16_SAMPLE_RATE * 3), + chunk_ms=200, + realtime_delay=False, + on_model_unit_ready=on_model_unit_ready, + ) + return ws, model_unit_message_counts + + ws, model_unit_message_counts = asyncio.run(run_fixture()) + + assert model_unit_message_counts == [5, 10] + assert len(ws.messages) == 10 + + +def test_realtime_duplex_demo_listen_only_overlap_accepts_silence_unit_before_first_done(monkeypatch): + demo = _load_demo_module() + state = demo.DemoState() + send_calls = 0 + + async def fake_send_pcm16(*args, **kwargs): + nonlocal send_calls + send_calls += 1 + if send_calls == 1: + assert len(args[1]) == 2 + demo.PCM16_SAMPLE_RATE * demo.PCM16_BYTES_PER_SAMPLE + state.add({"type": "input_audio_buffer.speech_started", "turn": 1}) + state.add({"type": "response.created", "response": {"id": "resp-first"}}) + state.add( + { + "type": "response.audio.delta", + "response_id": "resp-first", + "delta": "YQ==", + } + ) + assert await kwargs["on_model_unit_ready"]() is False + return + + del args + assert not state.response_done("resp-first") + kwargs["on_model_unit_ready"]() + _add_response_transcript(state, "resp-first", transcript="第一轮完成", audio=False) + state.add({"type": "response.created", "response": {"id": "resp-second"}}) + _add_response_transcript(state, "resp-second", transcript="第二轮完成", audio=True) + + class FakeWebSocket: + def __init__(self): + self.messages = [] + + async def send(self, payload): + self.messages.append(payload) + + monkeypatch.setattr(demo, "_send_pcm16", fake_send_pcm16) + ws = FakeWebSocket() + + response_ids, outcomes, overlap_ok = asyncio.run( + demo._send_listen_only_overlap_pair( + ws, + state, + b"\x00\x00", + b"\x00\x00", + transcripts=("first", "second"), + durations_ms=(None, None), + chunk_ms=200, + timeout_s=0.2, + send_transcript_hint=False, + realtime_input=True, + model_policy_settle_s=0.01, + validation_mode="response-required", + silence_ms=1000, + ) + ) + + assert response_ids == ["resp-first", "resp-second"] + assert outcomes == ["speak", "speak"] + assert overlap_ok is True + assert sum(demo.json.loads(message)["type"] == "input_audio_buffer.commit" for message in ws.messages) == 2 + + +def test_realtime_duplex_demo_playback_ack_identifies_and_commits_response(): + demo = _load_demo_module() + state = demo.DemoState() + response_id = "resp-overlap-second" + state.add( + { + "type": "response.done", + "response": { + "id": response_id, + "metadata": {"playback": {"sent_ms": 1200}}, + }, + } + ) + + class FakeWebSocket: + def __init__(self): + self.messages = [] + + async def send(self, payload): + message = demo.json.loads(payload) + self.messages.append(message) + state.add( + { + "type": "playback.acknowledged", + "event": { + "item_id": message.get("item_id"), + "history_committed": message.get("item_id") == f"item_{response_id}", + }, + } + ) + + ws = FakeWebSocket() + asyncio.run( + demo._ack_response_playback( + ws, + state, + response_id, + timeout_s=0.1, + label="overlap second", + ) + ) + + assert ws.messages == [ + { + "type": "playback.ack", + "response_id": response_id, + "item_id": f"item_{response_id}", + "played_ms": 1200, + "committed_ms": 1200, + } + ] + assert state.playback_history_committed_count == 1 + + +def test_realtime_duplex_demo_writes_audio_per_response(tmp_path): + demo = _load_demo_module() + state = demo.DemoState() + for response_id, payload in (("resp-1", b"\x01\x00"), ("resp-2", b"\x02\x00")): + state.add({"type": "response.created", "response": {"id": response_id}}) + state.add( + { + "type": "response.audio.delta", + "response_id": response_id, + "delta": demo.base64.b64encode(payload).decode(), + "sample_rate_hz": 24000, + } + ) + + demo._write_demo_artifacts(state, tmp_path, output_audio_format="pcm16") + + for index, expected in enumerate((b"\x01\x00", b"\x02\x00"), start=1): + with wave.open(str(tmp_path / f"response_{index:02d}.wav"), "rb") as wf: + assert wf.getframerate() == 24000 + assert wf.readframes(wf.getnframes()) == expected diff --git a/tests/e2e/online_serving/test_omnivoice_expansion.py b/tests/e2e/online_serving/test_omnivoice_expansion.py index ff41ee413..ab35b74ea 100644 --- a/tests/e2e/online_serving/test_omnivoice_expansion.py +++ b/tests/e2e/online_serving/test_omnivoice_expansion.py @@ -25,7 +25,7 @@ try: except ImportError: _HAS_VOICE_CLONE = False -pytestmark = [pytest.mark.full_model, pytest.mark.tts] +pytestmark = [pytest.mark.slow, pytest.mark.tts] MODEL = "k2-fsa/OmniVoice" diff --git a/tests/e2e/online_serving/test_qwen3_omni_expansion.py b/tests/e2e/online_serving/test_qwen3_omni_expansion.py index 25ba4f284..1ba94c011 100644 --- a/tests/e2e/online_serving/test_qwen3_omni_expansion.py +++ b/tests/e2e/online_serving/test_qwen3_omni_expansion.py @@ -7,6 +7,7 @@ E2E Online tests for Qwen3-Omni model. import os import pytest +from openai import BadRequestError from tests.helpers.mark import hardware_test from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video @@ -467,6 +468,10 @@ def test_one_word_prompt_001(omni_server, openai_client) -> None: "messages": messages, "stream": True, "key_words": {"text": ["london"]}, + # A one-word English clip is too short for whisper's language detection to + # be stable; unpinned it renders "London" as 런던 / ランデ / 梁敦 and the + # containment check then fails on correct audio. + "transcript_language": "en", } # Retry only when assert_omni_response fails on text/audio cosine similarity (see tests/helpers/assertions.py). @@ -610,3 +615,21 @@ def test_text_to_audio_long_output_001(omni_server, openai_client) -> None: text = responses[0].text_content if responses else "" word_count = len(text.split()) assert word_count >= 200, f"Expected at least 200 words in long output, got {word_count}" + + +@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) +@pytest.mark.parametrize("omni_server", test_params[:1], indirect=True) +def test_invalid_audio_format_rejected(omni_server, openai_client) -> None: + """Ensure invalid audio format is rejected with 400 before streaming starts.""" + messages = dummy_messages_from_mix_data( + system_prompt=get_system_prompt(), + content_text=get_prompt(), + ) + with pytest.raises(BadRequestError, match="audio format"): + openai_client.client.chat.completions.create( + model=omni_server.model, + messages=messages, + modalities=["text", "audio"], + audio={"voice": "alloy", "format": "aac"}, + stream=True, + ) diff --git a/tests/e2e/online_serving/test_qwen3_tts_customvoice.py b/tests/e2e/online_serving/test_qwen3_tts_customvoice.py index 2aab84840..7ebe08e1a 100644 --- a/tests/e2e/online_serving/test_qwen3_tts_customvoice.py +++ b/tests/e2e/online_serving/test_qwen3_tts_customvoice.py @@ -15,12 +15,18 @@ import pytest from tests.helpers.mark import hardware_test from tests.helpers.runtime import OmniServerParams -from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config +from tests.helpers.stage_config import ( + get_deploy_config_path, + get_deploy_config_stage, + modify_stage_config, +) +from vllm_omni.platforms import current_omni_platform MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" +_DEFAULT_STAGE_CONFIG = get_deploy_config_path("qwen3_tts.yaml") _STAGE_CONFIG = modify_stage_config( - get_deploy_config_path("qwen3_tts.yaml"), + _DEFAULT_STAGE_CONFIG, updates={"stages": {0: {"default_sampling_params.max_tokens": 500}}}, ) @@ -50,11 +56,47 @@ tts_server_params = [ ) ] +default_tts_server_params = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=_DEFAULT_STAGE_CONFIG, + server_args=["--trust-remote-code"], + ), + id="async_chunk", + ) +] + @pytest.mark.core_model @pytest.mark.advanced_model @pytest.mark.tts @hardware_test(res={"cuda": "L4"}, num_cards=1) +@pytest.mark.parametrize("omni_server", default_tts_server_params, indirect=True) +def test_default_cuda_graph_startup(omni_server) -> None: + """Verify both stages start with the shipped CUDA Graph configuration. + + The fixture reaching this test is the smoke assertion: it waits for the + server to become ready after both stages finish model initialization and + CUDA Graph capture. The regression covered here exited during stage 1 + capture, before fixture setup could complete. + """ + for stage_id in (0, 1): + stage = get_deploy_config_stage("qwen3_tts.yaml", stage_id=stage_id) + assert stage.get("enforce_eager", False) is False + + assert omni_server.proc is not None + assert omni_server.proc.poll() is None + + +@pytest.mark.core_model +@pytest.mark.advanced_model +@pytest.mark.tts +@pytest.mark.skipif( + current_omni_platform.is_npu(), + reason="#issue 5479", +) +@hardware_test(res={"cuda": "L4", "npu": "A3"}, num_cards=1) @pytest.mark.parametrize("omni_server", tts_server_params, indirect=True) def test_text_to_audio_001(omni_server, openai_client) -> None: """ @@ -80,7 +122,11 @@ def test_text_to_audio_001(omni_server, openai_client) -> None: @pytest.mark.core_model @pytest.mark.advanced_model @pytest.mark.tts -@hardware_test(res={"cuda": "L4"}, num_cards=1) +@pytest.mark.skipif( + current_omni_platform.is_npu(), + reason="#issue 5479", +) +@hardware_test(res={"cuda": "L4", "npu": "A3"}, num_cards=1) @pytest.mark.parametrize("omni_server", tts_server_params, indirect=True) def test_text_to_audio_002(omni_server, openai_client) -> None: """ diff --git a/tests/e2e/online_serving/test_qwen3_tts_ref_audio_artifact_mode_e2e.py b/tests/e2e/online_serving/test_qwen3_tts_ref_audio_artifact_mode_e2e.py new file mode 100644 index 000000000..e6c396932 --- /dev/null +++ b/tests/e2e/online_serving/test_qwen3_tts_ref_audio_artifact_mode_e2e.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""E2E regression for Qwen3-TTS ref_audio artifact mode collision (#5049). + +Reproduces the issue's exact sequence on a live server: an +``x_vector_only_mode=true`` request followed by an ICL +(``x_vector_only_mode=false`` + ``ref_text``) request using the **same** +``ref_audio``. + +Before the fix, the ref_audio artifact readiness was mode-agnostic: the +x-vector-only request (which caches a speaker embedding but no ``ref_code``) +marked the artifact "ready", so the ICL request took the artifact-only serving +path (``ref_audio`` stripped before dispatch), found no ``ref_code`` and no +audio to recompute from, and raised in ``build_prompt_embeds`` -> +``EngineDeadError`` — taking the whole server down rather than failing the one +request. + +After the fix (readiness keyed by ``(artifact_key, x_vector_only)``), the ICL +request no longer matches the x-vector artifact, so it sends ``ref_audio`` and +the worker computes ``ref_code``. Both requests succeed and the server stays up. +""" + +import os + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.media import load_test_audio_data_url +from tests.helpers.runtime import OmniServerParams +from tests.helpers.stage_config import get_deploy_config_path + +MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base" +DEFAULT_AUDIO_SPEECH_TIMEOUT_S = 180.0 + +# Vendored under tests/assets/qwen3_tts/clone_2.wav (see test_qwen3_tts_base.py). +REF_AUDIO_URL = load_test_audio_data_url("qwen3_tts/clone_2.wav") +REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you." +INPUT_TEXT = "The weather is nice today, perfect for a walk in the park." + +tts_server_params = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=get_deploy_config_path("qwen3_tts.yaml"), + server_args=["--trust-remote-code"], + ), + id="async_chunk", + ) +] + + +@pytest.mark.advanced_model +@pytest.mark.core_model +@pytest.mark.tts +@hardware_test(res={"cuda": "L4"}, num_cards=1) +@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True) +def test_xvector_then_icl_same_ref_audio_keeps_engine_alive(omni_server, openai_client) -> None: + """Regression for #5049: x-vector-only then ICL on the same ref_audio. + + Both requests must return audio and the engine must stay alive (pre-fix the + second request killed EngineCore). + """ + base_request = { + "model": omni_server.model, + "input": INPUT_TEXT, + "stream": False, + "timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S, + "response_format": "wav", + "task_type": "Base", + "ref_audio": REF_AUDIO_URL, + # Assert non-empty audio came back (not just HTTP 200). + "min_audio_bytes": 1, + } + + # 1) x-vector-only request: caches a speaker embedding (no ref_code) and, on + # completion, marks the ref_audio artifact "ready". + openai_client.send_audio_speech_request({**base_request, "x_vector_only_mode": True}) + + # 2) ICL request with the SAME ref_audio. Pre-fix this reused the x-vector + # artifact via the artifact-only path, hit the missing ref_code, and + # killed EngineCore (#5049). It must now succeed with audio. + openai_client.send_audio_speech_request({**base_request, "x_vector_only_mode": False, "ref_text": REF_TEXT}) + + # 3) The server must still be serving after the ICL request (pre-fix this + # would fail with EngineDeadError / connection refused). + openai_client.send_audio_speech_request({**base_request, "x_vector_only_mode": True}) diff --git a/tests/e2e/online_serving/test_qwen_image.py b/tests/e2e/online_serving/test_qwen_image.py index 454a87aa5..c40cfbda8 100644 --- a/tests/e2e/online_serving/test_qwen_image.py +++ b/tests/e2e/online_serving/test_qwen_image.py @@ -58,7 +58,6 @@ def _get_default_case(model: str): ] -@pytest.mark.core_model @pytest.mark.advanced_model @pytest.mark.diffusion @pytest.mark.parametrize("omni_server", _get_default_case(MODEL), indirect=True) diff --git a/tests/e2e/online_serving/test_step_audio2_expansion.py b/tests/e2e/online_serving/test_step_audio2_expansion.py index 05beb1131..9c8280b59 100644 --- a/tests/e2e/online_serving/test_step_audio2_expansion.py +++ b/tests/e2e/online_serving/test_step_audio2_expansion.py @@ -3,34 +3,32 @@ """ E2E expansion tests for Step-Audio2 online serving (weekly CI). -Tests speech-to-speech translation via /v1/chat/completions. +Tests audio-to-text generation via /v1/chat/completions. Request shape follows examples/online_serving/step_audio2/openai_chat_completion_client.py. """ import os -from pathlib import Path import pytest from tests.helpers.mark import hardware_test from tests.helpers.media import generate_synthetic_audio from tests.helpers.runtime import OmniServerParams +from tests.helpers.stage_config import get_deploy_config_path os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn") MODEL = "stepfun-ai/Step-Audio-2-mini" -STAGE_CONFIG = str(Path(__file__).parent / "stage_configs" / "step_audio2_ci.yaml") +DEPLOY_CONFIG = get_deploy_config_path("step_audio2_ci.yaml") SAMPLE_RATE = 16000 SEED = 42 -# pyttsx3 synthetic input phrase; Step-Audio2 text output includes codec tokens -# () so we validate audio only, not text↔audio similarity. SYNTH_PHRASE = "how are you" TEST_PARAMS = [ pytest.param( OmniServerParams( model=MODEL, - stage_config_path=STAGE_CONFIG, + server_args=["--deploy-config", DEPLOY_CONFIG, "--trust-remote-code"], env_dict={"VLLM_IMAGE_FETCH_TIMEOUT": "60"}, ), id="step_audio2", @@ -64,19 +62,11 @@ def _default_sampling_params_list() -> list[dict]: "detokenize": True, "repetition_penalty": 1.05, }, - { - "temperature": 0.0, - "top_p": 1.0, - "top_k": -1, - "max_tokens": 1, - "seed": SEED, - "detokenize": False, - }, ] -def _build_s2st_request_config(omni_server, audio_base64: str) -> dict: - """Build audio-to-audio (S2ST) chat completion request config.""" +def _build_audio_to_text_request_config(omni_server, audio_base64: str) -> dict: + """Build an audio-to-text chat completion request config.""" audio_url = f"data:audio/wav;base64,{audio_base64}" return { "model": omni_server.model, @@ -86,7 +76,7 @@ def _build_s2st_request_config(omni_server, audio_base64: str) -> dict: "content": [ { "type": "text", - "text": "You are an audio processing assistant. Listen and repeat the audio content.", + "text": "You are a speech recognition assistant. Transcribe the audio accurately.", } ], }, @@ -94,26 +84,19 @@ def _build_s2st_request_config(omni_server, audio_base64: str) -> dict: "role": "user", "content": [ {"type": "audio_url", "audio_url": {"url": audio_url}}, - {"type": "text", "text": "Please listen to this audio and repeat its content."}, + {"type": "text", "text": "Please transcribe the audio content."}, ], }, - {"role": "assistant", "content": ""}, ], - # Thinker text includes undetokenized audio codec placeholders; assert audio only. - "modalities": ["audio"], - "audio_ref_text": SYNTH_PHRASE, + "modalities": ["text"], "sampling_params_list": _default_sampling_params_list(), - "extra_body": { - "continue_final_message": True, - "add_generation_prompt": False, - }, } @hardware_test(res={"cuda": "H100"}, num_cards=1) -def test_single_s2st_request(omni_server, openai_client) -> None: - """Test single speech-to-speech request via chat completions API.""" - request_config = _build_s2st_request_config( +def test_single_audio_to_text_request(omni_server, openai_client) -> None: + """Test a single audio-to-text request via the chat completions API.""" + request_config = _build_audio_to_text_request_config( omni_server, _synthetic_audio_base64(duration_sec=2), ) @@ -122,9 +105,9 @@ def test_single_s2st_request(omni_server, openai_client) -> None: @hardware_test(res={"cuda": "H100"}, num_cards=1) @pytest.mark.parametrize("request_num", [2, 4]) -def test_concurrent_s2st_requests(omni_server, openai_client, request_num: int) -> None: - """Test concurrent speech-to-speech requests.""" - request_config = _build_s2st_request_config( +def test_concurrent_audio_to_text_requests(omni_server, openai_client, request_num: int) -> None: + """Test concurrent audio-to-text requests.""" + request_config = _build_audio_to_text_request_config( omni_server, _synthetic_audio_base64(duration_sec=2), ) diff --git a/tests/e2e/online_serving/test_voxtral_tts_expansion.py b/tests/e2e/online_serving/test_voxtral_tts_expansion.py index 1448567d3..264778ab9 100644 --- a/tests/e2e/online_serving/test_voxtral_tts_expansion.py +++ b/tests/e2e/online_serving/test_voxtral_tts_expansion.py @@ -30,7 +30,7 @@ pytestmark = [ ] -@hardware_test(res={"cuda": "L4"}, num_cards=1) +@hardware_test(res={"cuda": "L4", "xpu": "B60"}, num_cards=1) def test_speech_english_basic(omni_server, openai_client) -> None: """Test basic English TTS generation.""" openai_client.send_audio_speech_request( @@ -46,7 +46,7 @@ def test_speech_english_basic(omni_server, openai_client) -> None: ) -@hardware_test(res={"cuda": "L4"}, num_cards=1) +@hardware_test(res={"cuda": "L4", "xpu": "B60"}, num_cards=1) def test_speech_english_streaming(omni_server, openai_client) -> None: """Test basic streaming English TTS generation (PCM via streaming API).""" openai_client.send_audio_speech_request( diff --git a/tests/engine/test_arg_utils.py b/tests/engine/test_arg_utils.py index cf77e2256..ca491275e 100644 --- a/tests/engine/test_arg_utils.py +++ b/tests/engine/test_arg_utils.py @@ -128,6 +128,24 @@ def test_qwen3_tts_codec_frame_rate_patching(): assert omni_config.codec_frame_rate_hz == 12.3 +def test_qwen3_tts_startup_task_type_is_validated(): + vllm_config = EngineArgs().create_model_config() + + config = OmniModelConfig.from_vllm_model_config( + vllm_config, + model_arch="Qwen3TTSTalkerForConditionalGenerationARVLLM", + task_type="Base", + ) + assert config.task_type == "Base" + + with pytest.raises(ValueError, match="Qwen3-TTS --task-type must be one of"): + OmniModelConfig.from_vllm_model_config( + vllm_config, + model_arch="Qwen3TTSTalkerForConditionalGenerationARVLLM", + task_type="fl2va", + ) + + def test_from_cli_args_picks_up_stage_configs_path(): """from_cli_args should pick up stage_configs_path from namespace.""" ns = argparse.Namespace( diff --git a/tests/test_arg_utils.py b/tests/engine/test_arg_utils_shared_fields.py similarity index 100% rename from tests/test_arg_utils.py rename to tests/engine/test_arg_utils_shared_fields.py diff --git a/tests/engine/test_async_omni_engine_abort.py b/tests/engine/test_async_omni_engine_abort.py index 794475388..dfe00732f 100644 --- a/tests/engine/test_async_omni_engine_abort.py +++ b/tests/engine/test_async_omni_engine_abort.py @@ -1,6 +1,5 @@ import asyncio import os -import sys from contextlib import ExitStack import pytest @@ -67,11 +66,9 @@ async def generate( @pytest.mark.asyncio async def test_abort(): with ExitStack() as after: - # Avoid SHM IPC in tests to prevent /dev/shm exhaustion and SIGBUS. engine = AsyncOmni( model=model, stage_configs_path=stage_config, - shm_threshold_bytes=sys.maxsize, ) after.callback(engine.shutdown) diff --git a/tests/engine/test_async_omni_engine_input.py b/tests/engine/test_async_omni_engine_input.py index a1ec472f6..740064173 100644 --- a/tests/engine/test_async_omni_engine_input.py +++ b/tests/engine/test_async_omni_engine_input.py @@ -1,4 +1,5 @@ import pytest +import torch from pytest_mock import MockerFixture from vllm.sampling_params import SamplingParams from vllm.v1.engine import EngineCoreRequest @@ -6,7 +7,9 @@ from vllm.v1.engine import EngineCoreRequest from vllm_omni.distributed.omni_coordinator import ReplicaInfo, ReplicaStatus from vllm_omni.engine import OmniEngineCoreRequest from vllm_omni.engine.async_omni_engine import AsyncOmniEngine, StageRuntimeInfo +from vllm_omni.engine.serialization import deserialize_additional_information from vllm_omni.engine.stage_pool import StagePool +from vllm_omni.model_executor.stage_input_processors.bagel import ExpandedPrompt pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -64,6 +67,76 @@ def test_build_add_request_message_preserves_additional_information(mocker: Mock output_processor.add_request.assert_not_called() +def test_build_add_request_message_preserves_model_intermediate_buffer(mocker: MockerFixture): + engine = object.__new__(AsyncOmniEngine) + params = SamplingParams(max_tokens=8) + engine.default_sampling_params_list = [params] + engine.stage_metadata = [StageRuntimeInfo(final_output=False, final_output_type=None, stage_type="llm")] + engine.supported_tasks = ("speech",) + + input_processor = mocker.Mock() + input_processor.process_inputs.return_value = _make_engine_core_request() + engine.input_processor = input_processor + + output_processor = mocker.Mock() + engine.output_processors = [output_processor] + + hidden = torch.arange(6, dtype=torch.float32).reshape(2, 3) + prompt = { + "prompt_token_ids": [1, 1, 1], + "model_intermediate_buffer": { + "ids": {"tts": [11, 12]}, + "hidden_states": {"tts": hidden}, + }, + } + + msg = engine._build_add_request_message( + request_id="req-1", + prompt=prompt, + sampling_params_list=[params], + final_stage_id=0, + arrival_time=0.0, + ) + + request = msg.prompt + assert isinstance(request, OmniEngineCoreRequest) + assert request.additional_information is not None + assert request.additional_information.entries["global_request_id"].list_data == ["req-1"] + assert request.additional_information.entries["omni_final_stage_id"].scalar_data == 0 + assert isinstance(request.model_intermediate_buffer, dict) + info = request.model_intermediate_buffer + assert info["ids"]["tts"] == [11, 12] + assert torch.equal(info["hidden_states"]["tts"], hidden) + + +def test_cfg_companion_suppresses_payload_but_forces_kv_transfer(mocker: MockerFixture): + engine = object.__new__(AsyncOmniEngine) + params = SamplingParams(max_tokens=8) + engine.prompt_expand_func = lambda *_args: [ + ExpandedPrompt( + prompt={"prompt": "negative"}, + role="cfg_text", + request_id_suffix="__cfg_text", + ) + ] + engine.supported_tasks = ("generate",) + engine.input_processor = mocker.Mock() + engine.input_processor.process_inputs.return_value = _make_engine_core_request("req__cfg_text") + engine.request_queue = mocker.Mock() + + engine._enqueue_cfg_companions( + parent_id="req", + original_prompt={"prompt": "positive"}, + stage0_params=params, + sampling_params_list=[params], + ) + + message = engine.request_queue.sync_q.put.call_args.args[0] + metadata = deserialize_additional_information(message.prompt.additional_information) + assert metadata["omni_final_stage_id"] == 0 + assert metadata["omni_force_kv_transfer"] is True + + def test_build_add_request_message_with_resumable_streaming(mocker: MockerFixture): engine = object.__new__(AsyncOmniEngine) params = SamplingParams(max_tokens=8) diff --git a/tests/engine/test_async_omni_engine_outputs.py b/tests/engine/test_async_omni_engine_outputs.py index ecf3ee9ee..a1b7a536e 100644 --- a/tests/engine/test_async_omni_engine_outputs.py +++ b/tests/engine/test_async_omni_engine_outputs.py @@ -4,13 +4,33 @@ Focuses on the critical behavior: when the orchestrator thread dies, subsequent attempts to collect output raise RuntimeError. """ +import asyncio import queue +import threading +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace import pytest from pytest_mock import MockerFixture +from vllm_omni.engine.async_engine_utils import weak_shutdown_async_omni_engine from vllm_omni.engine.async_omni_engine import AsyncOmniEngine -from vllm_omni.engine.messages import ErrorMessage, OutputMessage +from vllm_omni.engine.messages import ( + CollectiveRPCResultMessage, + ErrorMessage, + OutputMessage, +) +from vllm_omni.engine.rpc_result_router import CorrelatedRpcClient +from vllm_omni.experimental.fullduplex.engine.duplex_control_client import ( + DuplexControlClient, + DuplexControlRequestError, +) +from vllm_omni.experimental.fullduplex.engine.messages import ( + AppendDuplexInputMessage, + DuplexControlResultMessage, + DuplexFence, + SignalDuplexTurnMessage, +) from vllm_omni.outputs import OmniRequestOutput pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -26,6 +46,33 @@ def _make_engine(output_queue, mocker: MockerFixture, *, thread_alive: bool = Tr return engine +def test_weak_shutdown_closes_rpc_router_before_joining_orchestrator(mocker: MockerFixture): + request_queue = mocker.MagicMock() + output_queue = mocker.MagicMock() + rpc_output_queue = mocker.MagicMock() + router = mocker.MagicMock() + orchestrator_thread = mocker.MagicMock() + orchestrator_thread.is_alive.return_value = True + + def assert_router_closed_before_join(*args, **kwargs): + router.close.assert_called_once_with() + + orchestrator_thread.join.side_effect = assert_router_closed_before_join + + weak_shutdown_async_omni_engine( + orchestrator_thread, + request_queue, + output_queue, + rpc_output_queue, + router, + ) + + request_queue.sync_q.put.assert_called_once() + assert request_queue.sync_q.put.call_args.kwargs["timeout"] > 0 + orchestrator_thread.join.assert_called_once() + assert orchestrator_thread.join.call_args.kwargs["timeout"] > 0 + + def test_try_get_output_raises_after_orchestrator_dies(mocker: MockerFixture): """Draining remaining results then hitting an empty queue with a dead orchestrator must raise RuntimeError so callers know the pipeline is gone.""" @@ -56,18 +103,17 @@ def test_try_get_output_raises_after_orchestrator_dies(mocker: MockerFixture): @pytest.mark.asyncio async def test_try_get_output_async_raises_after_orchestrator_dies(mocker: MockerFixture): """Same scenario as above but for the async variant.""" - mock_queue = mocker.MagicMock() - mock_queue.sync_q.get_nowait.side_effect = [ + raw_queue = queue.Queue() + raw_queue.put_nowait( OutputMessage( request_id="r1", stage_id=0, engine_outputs=OmniRequestOutput(request_id="r1"), finished=False, - ), - queue.Empty, - ] + ) + ) - engine = _make_engine(mock_queue, mocker, thread_alive=True) + engine = _make_engine(SimpleNamespace(sync_q=raw_queue), mocker, thread_alive=True) assert (await engine.try_get_output_async()).request_id == "r1" @@ -102,12 +148,495 @@ async def test_fatal_error_message_surfaces_through_try_get_output_async(mocker: """Async variant of the fatal error message test.""" fatal_msg = ErrorMessage(error="Orchestrator thread crashed", fatal=True) - mock_queue = mocker.MagicMock() - mock_queue.sync_q.get_nowait.return_value = fatal_msg + raw_queue = queue.Queue() + raw_queue.put_nowait(fatal_msg) - engine = _make_engine(mock_queue, mocker, thread_alive=False) + engine = _make_engine(SimpleNamespace(sync_q=raw_queue), mocker, thread_alive=False) msg = await engine.try_get_output_async() assert msg is not None assert msg.type == "error" assert msg.fatal is True + + +def test_output_remains_on_shared_output_path(mocker: MockerFixture): + raw_queue = queue.Queue() + raw_queue.put_nowait( + OutputMessage( + request_id="legacy-duplex-request", + stage_id=0, + engine_outputs=OmniRequestOutput(request_id="legacy-duplex-request"), + finished=False, + ) + ) + engine = _make_engine(SimpleNamespace(sync_q=raw_queue), mocker) + + output = engine.try_get_output(timeout=0.01) + + assert output.request_id == "legacy-duplex-request" + + +def test_duplex_control_client_uses_engine_owned_correlated_transport(): + engine = object.__new__(AsyncOmniEngine) + engine._duplex_control_client = None + engine._correlated_rpc_client = CorrelatedRpcClient(queue.Queue(), queue.Queue()) + + try: + client = engine._get_duplex_control_client() + + assert isinstance(client, DuplexControlClient) + assert client._transport is engine._correlated_rpc_client + assert not hasattr(engine, "_submit_rpc_and_wait") + finally: + engine._correlated_rpc_client.close() + + +def test_duplex_control_api_requires_explicit_fence(): + engine = object.__new__(AsyncOmniEngine) + + with pytest.raises(TypeError, match="fence"): + engine.open_duplex_session("sid") + with pytest.raises(TypeError, match="fence"): + engine.append_duplex_input("sid", mode="append_audio_chunk", payload={}) + with pytest.raises(TypeError, match="fence"): + engine.signal_duplex_turn("sid", event="turn.end") + with pytest.raises(TypeError, match="fence"): + engine.close_duplex_session("sid") + + +def test_open_duplex_session_waits_for_control_ack(mocker: MockerFixture): + request_q = queue.Queue() + rpc_q = queue.Queue() + control_result = DuplexControlResultMessage( + control_id="ctrl-1", + fence=DuplexFence("sid"), + operation="open", + session_id="sid", + ok=False, + stage_results=[{"stage_id": 0, "replica_id": 0, "result": {"supported": False}}], + unsupported_count=1, + error_count=0, + ) + + engine = object.__new__(AsyncOmniEngine) + engine.request_queue = SimpleNamespace(sync_q=request_q) + engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q) + engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q) + engine._duplex_control_client = None + mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="ctrl-1")) + + with ThreadPoolExecutor(max_workers=1) as executor: + pending = executor.submit(engine.open_duplex_session, "sid", fence=DuplexFence("sid"), timeout=1) + msg = request_q.get(timeout=1) + assert msg.type == "open_duplex_session" + assert msg.control_id == "ctrl-1" + rpc_q.put(control_result) + with pytest.raises(DuplexControlRequestError) as exc_info: + pending.result(timeout=1) + + assert exc_info.value.result["unsupported_count"] == 1 + assert exc_info.value.result["stage_results"][0]["result"]["supported"] is False + engine._correlated_rpc_client.close() + + +def test_signal_duplex_turn_message_carries_next_fence(mocker: MockerFixture): + request_q = queue.Queue() + rpc_q = queue.Queue() + cancelled_fence = DuplexFence("sid-cancel", epoch=4, turn_id=2) + next_fence = DuplexFence("sid-cancel", epoch=5, turn_id=2) + control_result = DuplexControlResultMessage( + control_id="ctrl-signal", + fence=cancelled_fence, + operation="signal", + session_id="sid-cancel", + ok=True, + stage_results=[], + ) + + engine = object.__new__(AsyncOmniEngine) + engine.request_queue = SimpleNamespace(sync_q=request_q) + engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q) + engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q) + engine._duplex_control_client = None + mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="ctrl-signal")) + + with ThreadPoolExecutor(max_workers=1) as executor: + pending = executor.submit( + engine.signal_duplex_turn, + "sid-cancel", + event="input.cancel", + fence=cancelled_fence, + next_fence=next_fence, + session_config={"temperature": 0.0}, + timeout=1, + ) + msg = request_q.get(timeout=1) + assert isinstance(msg, SignalDuplexTurnMessage) + assert msg.fence == cancelled_fence + assert msg.next_fence == next_fence + assert msg.session_config == {"temperature": 0.0} + rpc_q.put(control_result) + assert pending.result(timeout=1)["ok"] is True + + engine._correlated_rpc_client.close() + + +@pytest.mark.asyncio +async def test_cancelled_async_append_does_not_stop_executor_callable(mocker: MockerFixture): + entered = threading.Event() + release = threading.Event() + completed = threading.Event() + engine = object.__new__(AsyncOmniEngine) + + def blocking_append(*args, **kwargs): + del args, kwargs + entered.set() + release.wait(timeout=1) + completed.set() + return {} + + mocker.patch.object(engine, "append_duplex_input", side_effect=blocking_append) + task = asyncio.create_task( + engine.append_duplex_input_async( + "sid-executor-cancel", + mode="append_audio_chunk", + payload=b"audio", + fence=DuplexFence("sid-executor-cancel"), + ) + ) + assert await asyncio.to_thread(entered.wait, 1) + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + release.set() + + assert await asyncio.to_thread(completed.wait, 1) + + +def test_duplex_controls_route_out_of_order_without_global_lock(): + request_q = queue.Queue() + rpc_q = queue.Queue() + engine = object.__new__(AsyncOmniEngine) + engine.request_queue = SimpleNamespace(sync_q=request_q) + engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q) + engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q) + engine._duplex_control_client = None + first_fence = DuplexFence("sid", epoch=1) + second_fence = DuplexFence("sid", epoch=2) + first = AppendDuplexInputMessage( + control_id="first", + fence=first_fence, + session_id="sid", + expected_epoch=1, + mode="audio", + payload=b"first", + ) + second = AppendDuplexInputMessage( + control_id="second", + fence=second_fence, + session_id="sid", + expected_epoch=2, + mode="audio", + payload=b"second", + ) + + with ThreadPoolExecutor(max_workers=2) as executor: + client = engine._get_duplex_control_client() + first_result = executor.submit(client.execute, first, timeout=1) + second_result = executor.submit(client.execute, second, timeout=1) + submitted = {request_q.get(timeout=1).control_id, request_q.get(timeout=1).control_id} + assert submitted == {"first", "second"} + rpc_q.put( + DuplexControlResultMessage( + control_id="second", + fence=second_fence, + operation="append", + session_id="sid", + ok=True, + stage_results=[], + ) + ) + rpc_q.put( + DuplexControlResultMessage( + control_id="first", + fence=first_fence, + operation="append", + session_id="sid", + ok=True, + stage_results=[], + ) + ) + + assert first_result.result(timeout=1)["fence"] == first_fence + assert second_result.result(timeout=1)["fence"] == second_fence + + assert not hasattr(engine, "_rpc_lock") + engine._correlated_rpc_client.close() + + +def test_terminal_rpc_router_rejects_without_enqueuing_new_control(): + request_q = queue.Queue() + rpc_q = queue.Queue() + engine = object.__new__(AsyncOmniEngine) + engine.request_queue = SimpleNamespace(sync_q=request_q) + engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q) + engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q) + engine._duplex_control_client = None + pending_message = AppendDuplexInputMessage( + control_id="pending", + fence=DuplexFence("sid"), + session_id="sid", + mode="audio", + payload=b"audio", + ) + message = AppendDuplexInputMessage( + control_id="after-fatal", + fence=DuplexFence("sid"), + session_id="sid", + mode="audio", + payload=b"audio", + ) + + try: + with ThreadPoolExecutor(max_workers=1) as executor: + pending = executor.submit(engine._get_duplex_control_client().execute, pending_message, timeout=1) + assert request_q.get(timeout=1).control_id == "pending" + rpc_q.put(ErrorMessage(error="orchestrator failed", fatal=True)) + with pytest.raises(RuntimeError, match="orchestrator failed"): + pending.result(timeout=1) + + with pytest.raises(RuntimeError, match="orchestrator failed"): + engine._get_duplex_control_client().execute(message, timeout=1) + + assert request_q.empty() + finally: + engine._correlated_rpc_client.close() + + +def test_collective_and_duplex_results_share_router_without_swapping(mocker: MockerFixture): + request_q = queue.Queue() + rpc_q = queue.Queue() + engine = object.__new__(AsyncOmniEngine) + engine.request_queue = SimpleNamespace(sync_q=request_q) + engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q) + engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q) + engine._duplex_control_client = None + fence = DuplexFence("sid") + duplex = AppendDuplexInputMessage( + control_id="duplex-control", + fence=fence, + session_id="sid", + mode="audio", + payload=b"audio", + ) + mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="collective-rpc")) + + with ThreadPoolExecutor(max_workers=2) as executor: + duplex_result = executor.submit(engine._get_duplex_control_client().execute, duplex, timeout=1) + collective_result = executor.submit(engine.collective_rpc, "health", timeout=1) + submitted_types = {request_q.get(timeout=1).type, request_q.get(timeout=1).type} + assert submitted_types == {"append_duplex_input", "collective_rpc"} + rpc_q.put( + CollectiveRPCResultMessage( + rpc_id="collective-rpc", + method="health", + stage_ids=[0], + results=["healthy"], + ) + ) + rpc_q.put( + DuplexControlResultMessage( + control_id="duplex-control", + fence=fence, + operation="append", + session_id="sid", + ok=True, + stage_results=[], + ) + ) + + assert collective_result.result(timeout=1) == ["healthy"] + assert duplex_result.result(timeout=1)["fence"] == fence + + engine._correlated_rpc_client.close() + + +def test_collective_rpc_preserves_request_queue_backpressure(mocker: MockerFixture): + class SignallingQueue(queue.Queue): + def __init__(self) -> None: + super().__init__(maxsize=1) + self.put_attempted = threading.Event() + + def put(self, item, block=True, timeout=None): + self.put_attempted.set() + return super().put(item, block=block, timeout=timeout) + + request_q = SignallingQueue() + request_q.put("queue-is-full") + request_q.put_attempted.clear() + rpc_q = queue.Queue() + engine = object.__new__(AsyncOmniEngine) + engine.request_queue = SimpleNamespace(sync_q=request_q) + engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q) + engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q) + engine._duplex_control_client = None + mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="blocked-rpc")) + + with ThreadPoolExecutor(max_workers=1) as executor: + pending = executor.submit(engine.collective_rpc, "health", timeout=1) + assert request_q.put_attempted.wait(timeout=1) + assert not pending.done() + + assert request_q.get(timeout=1) == "queue-is-full" + assert request_q.get(timeout=1).rpc_id == "blocked-rpc" + rpc_q.put( + CollectiveRPCResultMessage( + rpc_id="blocked-rpc", + method="health", + stage_ids=[0], + results=["healthy"], + ) + ) + assert pending.result(timeout=1) == ["healthy"] + + engine._correlated_rpc_client.close() + + +def test_open_duplex_session_raises_on_stage_control_error(mocker: MockerFixture): + request_q = queue.Queue() + rpc_q = queue.Queue() + control_result = DuplexControlResultMessage( + control_id="ctrl-error", + fence=DuplexFence("sid"), + operation="open", + session_id="sid", + ok=False, + stage_results=[{"stage_id": 0, "replica_id": 0, "result": {"supported": False, "error": "boom"}}], + unsupported_count=1, + error_count=1, + ) + + engine = object.__new__(AsyncOmniEngine) + engine.request_queue = SimpleNamespace(sync_q=request_q) + engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q) + engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q) + engine._duplex_control_client = None + mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="ctrl-error")) + + with ThreadPoolExecutor(max_workers=1) as executor: + pending = executor.submit(engine.open_duplex_session, "sid", fence=DuplexFence("sid"), timeout=1) + request_q.get(timeout=1) + rpc_q.put(control_result) + with pytest.raises(RuntimeError, match="duplex open failed"): + pending.result(timeout=1) + engine._correlated_rpc_client.close() + + +def test_shutdown_does_not_race_runtime_cleanup_with_live_orchestrator(mocker: MockerFixture): + engine = object.__new__(AsyncOmniEngine) + engine._shutdown_called = False + engine._weak_finalizer = None + engine.request_queue = mocker.MagicMock() + engine.request_queue.sync_q.put.side_effect = queue.Full + engine.output_queue = mocker.MagicMock() + engine.rpc_output_queue = mocker.MagicMock() + engine._correlated_rpc_client = mocker.MagicMock() + engine.orchestrator_thread = mocker.MagicMock() + engine.orchestrator_thread.is_alive.return_value = True + engine._runtime = mocker.MagicMock() + + engine.shutdown() + + engine.request_queue.sync_q.put.assert_called_once() + assert engine.request_queue.sync_q.put.call_args.kwargs["timeout"] > 0 + assert engine.orchestrator_thread.join.call_args_list[0].kwargs["timeout"] > 0 + engine._correlated_rpc_client.close.assert_called_once_with() + engine.request_queue.close.assert_called_once_with() + engine.output_queue.close.assert_called_once_with() + engine.rpc_output_queue.close.assert_called_once_with() + engine._runtime.shutdown.assert_not_called() + + +def test_shutdown_releases_runtime_after_orchestrator_stops(mocker: MockerFixture): + engine = object.__new__(AsyncOmniEngine) + engine._shutdown_called = False + engine._weak_finalizer = None + engine.request_queue = mocker.MagicMock() + engine.output_queue = mocker.MagicMock() + engine.rpc_output_queue = mocker.MagicMock() + engine._correlated_rpc_client = mocker.MagicMock() + engine.orchestrator_thread = mocker.MagicMock() + engine.orchestrator_thread.is_alive.side_effect = [True, False] + engine._runtime = mocker.MagicMock() + + engine.shutdown() + + engine.orchestrator_thread.join.assert_called_once() + engine._runtime.shutdown.assert_called_once_with() + + +def test_shutdown_defers_runtime_release_until_live_orchestrator_stops(mocker: MockerFixture): + orchestrator_stopped = threading.Event() + runtime_released = threading.Event() + + class ControllableOrchestratorThread: + def __init__(self) -> None: + self.join_timeouts: list[float | None] = [] + + def is_alive(self) -> bool: + return not orchestrator_stopped.is_set() + + def join(self, timeout: float | None = None) -> None: + self.join_timeouts.append(timeout) + if timeout is None: + orchestrator_stopped.wait() + + engine = object.__new__(AsyncOmniEngine) + engine._shutdown_called = False + engine._weak_finalizer = None + engine.request_queue = mocker.MagicMock() + engine.output_queue = mocker.MagicMock() + engine.rpc_output_queue = mocker.MagicMock() + engine._correlated_rpc_client = mocker.MagicMock() + engine.orchestrator_thread = ControllableOrchestratorThread() + engine._runtime = mocker.MagicMock() + engine._runtime.shutdown.side_effect = runtime_released.set + + try: + engine.shutdown() + + engine._runtime.shutdown.assert_not_called() + orchestrator_stopped.set() + assert runtime_released.wait(timeout=1) + engine._runtime.shutdown.assert_called_once_with() + assert engine.orchestrator_thread.join_timeouts[0] is not None + assert engine.orchestrator_thread.join_timeouts[-1] is None + finally: + orchestrator_stopped.set() + + +def test_weak_shutdown_is_bounded_when_request_queue_is_full(mocker: MockerFixture): + request_queue = mocker.MagicMock() + request_queue.sync_q.put.side_effect = queue.Full + output_queue = mocker.MagicMock() + rpc_output_queue = mocker.MagicMock() + router = mocker.MagicMock() + orchestrator_thread = mocker.MagicMock() + orchestrator_thread.is_alive.return_value = True + + weak_shutdown_async_omni_engine( + orchestrator_thread, + request_queue, + output_queue, + rpc_output_queue, + router, + ) + + request_queue.sync_q.put.assert_called_once() + assert request_queue.sync_q.put.call_args.kwargs["timeout"] > 0 + orchestrator_thread.join.assert_called_once() + assert orchestrator_thread.join.call_args.kwargs["timeout"] > 0 + request_queue.close.assert_called_once_with() + output_queue.close.assert_called_once_with() + rpc_output_queue.close.assert_called_once_with() diff --git a/tests/engine/test_async_omni_engine_stage_init.py b/tests/engine/test_async_omni_engine_stage_init.py index d5a50cf29..67765aba6 100644 --- a/tests/engine/test_async_omni_engine_stage_init.py +++ b/tests/engine/test_async_omni_engine_stage_init.py @@ -1,3 +1,4 @@ +import concurrent.futures import contextlib import importlib import os @@ -7,6 +8,7 @@ import types import pytest from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec +from vllm_omni.engine import async_omni_engine as async_omni_engine_module from vllm_omni.engine.async_omni_engine import AsyncOmniEngine from vllm_omni.engine.stage_init_utils import ( LogicalStageInitPlan, @@ -19,6 +21,30 @@ from vllm_omni.engine.stage_runtime import StageRuntime pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +def test_orchestrator_startup_timeout_warns_how_to_raise_limits(monkeypatch): + engine = object.__new__(AsyncOmniEngine) + engine.orchestrator_thread = types.SimpleNamespace(is_alive=lambda: True) + monkeypatch.setattr(engine, "_try_shutdown", lambda _message: None) + + ticks = iter((0.0, 1.0)) + monkeypatch.setattr(async_omni_engine_module.time, "monotonic", lambda: next(ticks)) + + warnings: list[tuple[object, ...]] = [] + monkeypatch.setattr( + async_omni_engine_module.logger, + "warning", + lambda *args: warnings.append(args), + ) + + with pytest.raises(TimeoutError, match="did not become ready within 1s"): + engine._wait_for_orchestrator_init(concurrent.futures.Future(), startup_timeout=1) + + assert len(warnings) == 1 + message = str(warnings[0][0]) + assert "--init-timeout" in message + assert "--stage-init-timeout" in message + + def _make_llm_metadata( stage_id: int, *, @@ -146,6 +172,41 @@ def test_stage_engine_core_client_module_reload_keeps_forward_refs_deferred(): ) +def test_async_omni_engine_initialize_stages_passes_log_stats_to_runtime(monkeypatch): + import vllm_omni.engine.async_omni_engine as engine_mod + + engine = object.__new__(AsyncOmniEngine) + engine.stage_configs = [types.SimpleNamespace()] + engine.model = "dummy-model" + engine.config_path = "dummy-config" + engine.single_stage_mode = False + engine.diffusion_batch_size = 1 + engine.async_chunk = False + engine.tokenizer = None + engine._single_stage_id_filter = None + engine._omni_master_address = None + engine._omni_master_port = None + engine._omni_dp_size_local = 1 + engine._omni_heartbeat_timeout = 30.0 + engine._omni_lb_policy = "random" + engine.request_queue = types.SimpleNamespace() + engine._log_stats = True + + captured: dict[str, object] = {} + runtime = types.SimpleNamespace(stage_pools=[], initialize=lambda: None) + + def _capture_create_stage_runtime(**kwargs): + captured.update(kwargs) + return runtime + + monkeypatch.setattr(engine_mod, "create_stage_runtime", _capture_create_stage_runtime) + + engine._initialize_stages(stage_init_timeout=7) + + assert captured["stage_init_timeout"] == 7 + assert captured["log_stats"] is True + + def test_compute_replica_layout_splits_diffusion_devices_by_world_size(): stage_cfg = types.SimpleNamespace( stage_id=0, @@ -316,6 +377,95 @@ def test_stage_runtime_initializes_stage_pools(monkeypatch): assert runtime.stage_pools[1].output_processor is stage1_output_processor +def test_stage_runtime_passes_log_stats_to_llm_replica_launch(monkeypatch): + import vllm_omni.engine.stage_runtime as runtime_mod + + runtime = StageRuntime( + stage_configs=[types.SimpleNamespace()], + model="dummy-model", + config_path="dummy-config", + stage_init_timeout=1, + diffusion_batch_size=1, + async_chunk=False, + log_stats=True, + ) + cfg = types.SimpleNamespace(model_config=types.SimpleNamespace(max_model_len=64)) + plan = _make_llm_plan(0, stage_id=0, vllm_config=cfg).replicas[0] + plan.engine_args_dict = {} + + captured: dict[str, object] = {} + addresses = types.SimpleNamespace( + inputs=["tcp://127.0.0.1:1"], + outputs=["tcp://127.0.0.1:2"], + frontend_stats_publish_address=None, + ) + resources = types.SimpleNamespace(manager=object(), coordinator=None, addresses=addresses) + stage_client = types.SimpleNamespace() + + @contextlib.contextmanager + def _capture_launch_stage_replica(**kwargs): + captured["log_stats"] = kwargs["log_stats"] + yield resources + + monkeypatch.setattr(runtime_mod, "acquire_device_locks", lambda *_args, **_kwargs: []) + monkeypatch.setattr(runtime_mod, "launch_stage_replica", _capture_launch_stage_replica) + monkeypatch.setattr( + runtime_mod.StageEngineCoreClientBase, + "make_async_mp_client", + lambda **kwargs: (captured.__setitem__("client_log_stats", kwargs["log_stats"]) or stage_client), + ) + + assert runtime._initialize_local_llm_replica(plan, stage_init_timeout=1) is stage_client + assert captured["log_stats"] is True + assert captured["client_log_stats"] is True + + +def test_stage_runtime_passes_log_stats_to_output_processor(monkeypatch): + import vllm_omni.engine.stage_runtime as runtime_mod + + runtime = StageRuntime( + stage_configs=[types.SimpleNamespace()], + model="dummy-model", + config_path="dummy-config", + stage_init_timeout=1, + diffusion_batch_size=1, + async_chunk=False, + log_stats=True, + ) + cfg = types.SimpleNamespace(model_config=types.SimpleNamespace(max_model_len=64)) + stage_plan = _make_llm_plan(0, stage_id=0, vllm_config=cfg) + stage_client = types.SimpleNamespace( + stage_type="llm", + is_comprehension=False, + final_output=True, + final_output_type=None, + default_sampling_params=types.SimpleNamespace(), + ) + captured: dict[str, object] = {} + output_processor = object() + + def _capture_build_llm_stage_output_processor(plan, stage_vllm_config, *, log_stats=False): + captured["plan"] = plan + captured["stage_vllm_config"] = stage_vllm_config + captured["log_stats"] = log_stats + return output_processor + + monkeypatch.setattr( + runtime_mod, + "build_llm_stage_output_processor", + _capture_build_llm_stage_output_processor, + ) + + pools = runtime._assemble_stage_pools([stage_plan], {0: [stage_client]}) + + assert pools[0].output_processor is output_processor + assert captured == { + "plan": stage_plan, + "stage_vllm_config": cfg, + "log_stats": True, + } + + def test_build_logical_stage_init_plans_applies_replica_device_splits(monkeypatch): import vllm_omni.engine.stage_runtime as runtime_mod @@ -342,7 +492,7 @@ def test_build_logical_stage_init_plans_applies_replica_device_splits(monkeypatc monkeypatch.setattr( runtime_mod, - "extract_stage_metadata", + "extract_legacy_stage_metadata", lambda cfg: types.SimpleNamespace(**metadata_by_stage[cfg.stage_id].__dict__), ) monkeypatch.setattr(runtime_mod, "get_stage_connector_spec", lambda **_: {}) @@ -511,10 +661,11 @@ def test_initialize_local_llm_replica_passes_stage_init_timeout_to_complete_stag stage_configs=[], model="dummy-model", config_path="dummy-config", - stage_init_timeout=302, + stage_init_timeout=1, diffusion_batch_size=1, async_chunk=False, ) + stage_init_timeout = 302 fake_vllm_config = types.SimpleNamespace() fake_addresses = types.SimpleNamespace(inputs=["in"], outputs=["out"], frontend_stats_publish_address=None) @@ -561,14 +712,14 @@ def test_initialize_local_llm_replica_passes_stage_init_timeout_to_complete_stag ) try: - runtime._initialize_local_llm_replica(plan, 302) + runtime._initialize_local_llm_replica(plan, stage_init_timeout) finally: if prev_device_env is None: os.environ.pop(device_env_var, None) else: os.environ[device_env_var] = prev_device_env - assert captured_timeout == 302 + assert captured_timeout == stage_init_timeout def test_build_engine_args_cli_tokenizer_overrides_inferred_base_tokenizer(tmp_path): @@ -609,6 +760,29 @@ def test_build_engine_args_stage_model_overrides_parent_model(): assert engine_args["model"] == "/stage/model" +def test_build_engine_args_keeps_full_payload_connector_spec(): + from vllm_omni.engine.stage_init_utils import build_engine_args_dict + + stage_cfg = types.SimpleNamespace( + stage_id=1, + stage_type="llm", + engine_args={"async_chunk": False}, + default_sampling_params={}, + ) + connector_spec = { + "name": "SharedMemoryConnector", + "extra": {"role": "sender"}, + } + + engine_args = build_engine_args_dict( + stage_cfg, + "/parent/model", + stage_connector_spec=connector_spec, + ) + + assert engine_args["stage_connector_spec"] == connector_spec + + def test_build_engine_args_keeps_stage_owned_tokenizer_subdir(tmp_path): from vllm_omni.engine.stage_init_utils import build_engine_args_dict @@ -745,6 +919,7 @@ def test_resolve_stage_configs_injects_global_diffusion_attention_when_missing(m _config_path, stage_configs = engine._resolve_stage_configs( model="dummy-model", kwargs={"diffusion_attention_backend": "FLASH_ATTN"}, + trust_remote_code=False, ) diffusion_attention_config = stage_configs[0].engine_args.diffusion_attention_config @@ -778,6 +953,7 @@ def test_resolve_stage_configs_preserves_stage_diffusion_attention(monkeypatch): _config_path, stage_configs = engine._resolve_stage_configs( model="dummy-model", kwargs={"diffusion_attention_backend": "FLASH_ATTN"}, + trust_remote_code=False, ) assert stage_configs[0].engine_args.diffusion_attention_config is existing_attention @@ -804,15 +980,16 @@ def test_resolve_stage_configs_does_not_inject_diffusion_attention_into_llm_stag _config_path, stage_configs = engine._resolve_stage_configs( model="dummy-model", kwargs={"diffusion_attention_backend": "TORCH_SDPA"}, + trust_remote_code=False, ) assert stage_configs[0].engine_args.attention_config == {"backend": "FLASH_ATTN"} assert not hasattr(stage_configs[0].engine_args, "diffusion_attention_config") -def test_extract_stage_metadata_rocm_does_not_inject_diffusion_attention(monkeypatch): +def test_extract_legacy_stage_metadata_rocm_does_not_inject_diffusion_attention(monkeypatch): """ROCm default attention logic only applies to LLM stages, not diffusion.""" - from vllm_omni.engine.stage_init_utils import extract_stage_metadata + from vllm_omni.engine.stage_init_utils import extract_legacy_stage_metadata monkeypatch.setattr("vllm_omni.engine.stage_init_utils.current_omni_platform.is_rocm", lambda: True) @@ -826,7 +1003,7 @@ def test_extract_stage_metadata_rocm_does_not_inject_diffusion_attention(monkeyp final_output_type=None, ) - metadata = extract_stage_metadata(stage_cfg) + metadata = extract_legacy_stage_metadata(stage_cfg) assert metadata.stage_type == "diffusion" assert "diffusion_attention_config" not in stage_cfg.engine_args diff --git a/tests/engine/test_cfg_companion_lifecycle.py b/tests/engine/test_cfg_companion_lifecycle.py new file mode 100644 index 000000000..5cf2fd072 --- /dev/null +++ b/tests/engine/test_cfg_companion_lifecycle.py @@ -0,0 +1,334 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Deterministic tests for the CFG-companion output lifecycle. + +Regression suite for the companion-output race behind the nightly Bagel +failures: ``kv_ready`` used to mark a companion done before its processed +output was stashed, letting the parent dispatch to the diffusion stage with +0/N companion outputs (degraded CFG conditioning, and after an abort a TP +rank desync). The invariant under test: **dispatch to the diffusion stage +never happens with an incomplete companion bundle** — the parent is +re-deferred while outputs are pending and failed when the bundle can no +longer complete. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from vllm_omni.engine.cfg_companion_tracker import CfgCompanionTracker +from vllm_omni.engine.messages import AbortRequestMessage, ErrorMessage +from vllm_omni.engine.orchestrator import Orchestrator + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +# ───────── tracker-level ordering semantics ───────── + + +def test_companion_first_then_parent_is_not_deferred(): + tracker = CfgCompanionTracker() + tracker.register_companion("p", "neg", "p__neg") + tracker.set_companion_output("p__neg", {"o": 1}) + assert tracker.on_companion_completed("p__neg") is None # parent not deferred yet + # Parent arrives afterwards: bundle already complete, no defer needed. + assert tracker.all_companions_done("p") + assert tracker.get_companion_outputs("p") == [{"o": 1}] + + +def test_parent_first_deferred_then_companion_flushes(): + tracker = CfgCompanionTracker() + tracker.register_companion("p", "neg", "p__neg") + tracker.defer_parent("p", {"parent": True}, stage_id=0) + tracker.set_companion_output("p__neg", {"o": 1}) + assert tracker.on_companion_completed("p__neg") == "p" + deferred = tracker.pop_pending_parent("p") + assert deferred is not None and deferred["engine_outputs"] == {"parent": True} + + +def test_duplicate_companion_completion_is_idempotent(): + tracker = CfgCompanionTracker() + tracker.register_companion("p", "neg", "p__neg") + tracker.defer_parent("p", {}, stage_id=0) + tracker.set_companion_output("p__neg", {"o": 1}) + assert tracker.on_companion_completed("p__neg") == "p" + assert tracker.on_companion_completed("p__neg") is None # duplicate: no re-flush + assert tracker.get_companion_outputs("p") == [{"o": 1}] + + +def test_bundle_survives_resubmission_until_cleanup(): + """get_companion_outputs is non-destructive: a streaming re-submission of + the parent bundles the complete set again (never a partial/empty one). + Only cleanup_parent releases the outputs.""" + tracker = CfgCompanionTracker() + tracker.register_companion("p", "neg", "p__neg") + tracker.set_companion_output("p__neg", {"o": 1}) + assert tracker.get_companion_outputs("p") == [{"o": 1}] + assert tracker.get_companion_outputs("p") == [{"o": 1}] # re-submit: full bundle again + tracker.cleanup_parent("p") + assert tracker.get_companion_outputs("p") == [] + + +# ───────── orchestrator-method-level lifecycle ───────── + + +class _FakePool: + """Minimal stage pool satisfying _forward_to_next_stage's preamble and cleanup.""" + + def __init__(self, stage_type: str = "diffusion") -> None: + self.stage_type = stage_type + self.stage_client = SimpleNamespace(requires_multimodal_data=False, custom_process_input_func=None) + self.aborted: list[list[str]] = [] + + async def abort_requests(self, request_ids): + self.aborted.append(list(request_ids)) + + def release_bindings(self, request_ids): + pass + + +def _make_orchestrator(num_stages: int = 2) -> Orchestrator: + orch = Orchestrator.__new__(Orchestrator) + orch._cfg_tracker = CfgCompanionTracker() + orch.request_states = {} + orch.stage_pools = [_FakePool("llm"), _FakePool("diffusion")][:num_stages] + orch.output_async_queue = asyncio.Queue() + orch.async_chunk = False + orch.duplex_control_plane = None + orch._pd_kv_params = {} + orch._pd_pair = None + orch._running_counter = None + orch._transfer_emitter = None + return orch + + +def _req_state(final_stage_id: int = 1) -> SimpleNamespace: + return SimpleNamespace( + final_stage_id=final_stage_id, + stage_submit_ts={}, + sampling_params_list=[SimpleNamespace(output_kind=None)] * (final_stage_id + 1), + streaming=SimpleNamespace(enabled=False, segment_finished=False), + running_counter_registered=False, + prompt={"prompt": "x"}, + pipeline_timings={}, + ) + + +@pytest.mark.asyncio +async def test_kv_ready_does_not_mark_companion_done(): + """kv_ready alone must not count a companion as done — only the processed + output (set_companion_output) may. Guards the original race.""" + orch = _make_orchestrator() + orch._cfg_tracker.register_companion("p", "neg", "p__neg") + orch.request_states["p__neg"] = _req_state() + + raw = SimpleNamespace(request_id="p__neg", kv_transfer_params={"kv_ready": True}) + await orch._handle_kv_ready_raw_outputs(0, SimpleNamespace(outputs=[raw])) + + assert not orch._cfg_tracker.all_companions_done("p") + assert orch._cfg_tracker.pop_pending_parent("p") is None + + +@pytest.mark.asyncio +async def test_parent_kv_ready_defers_until_companion_outputs_stashed(): + """Parent kv_ready with an incomplete bundle defers; the companion's + processed output later flushes it (companion-first vs parent-first order + both funnel through the same defer/flush path).""" + orch = _make_orchestrator() + orch._cfg_tracker.register_companion("p", "neg", "p__neg") + orch.request_states["p"] = _req_state() + + raw = SimpleNamespace(request_id="p", kv_transfer_params={"kv_ready": True}) + await orch._handle_kv_ready_raw_outputs(0, SimpleNamespace(outputs=[raw])) + + deferred = orch._cfg_tracker.pop_pending_parent("p") + assert deferred is not None and deferred["stage_id"] == 0 + + +@pytest.mark.asyncio +async def test_forward_re_defers_when_bundle_incomplete(): + """The forward gate re-defers (non-destructively) instead of dispatching + with 0/N companion outputs.""" + orch = _make_orchestrator() + orch._cfg_tracker.register_companion("p", "neg", "p__neg") + orch._cfg_tracker.set_companion_output("p__neg", {"o": 1}) # stashed but NOT done-marked + state = _req_state() + orch.request_states["p"] = state + + await orch._forward_to_next_stage("p", 0, {"parent": True}, state) + + # Not dispatched: re-deferred with the stashed output left intact. + deferred = orch._cfg_tracker.pop_pending_parent("p") + assert deferred is not None + assert orch._cfg_tracker.get_companion_outputs("p") == [{"o": 1}] + assert orch.output_async_queue.empty() + + +@pytest.mark.asyncio +async def test_forward_fails_request_when_bundle_lost(): + """Companions done but outputs gone without a prior bundle: the request is + failed (terminal ErrorMessage + abort) instead of dispatching degraded CFG.""" + orch = _make_orchestrator() + orch._cfg_tracker.register_companion("p", "neg", "p__neg") + orch._cfg_tracker.set_companion_output("p__neg", {"o": 1}) + orch._cfg_tracker.on_companion_completed("p__neg") + orch._cfg_tracker._companion_outputs.pop("p__neg") # simulate lost output (unreachable normally) + state = _req_state() + orch.request_states["p"] = state + + await orch._forward_to_next_stage("p", 0, {"parent": True}, state) + + msg = orch.output_async_queue.get_nowait() + assert isinstance(msg, ErrorMessage) + assert msg.request_id == "p" + assert "incomplete" in msg.error + assert "p" not in orch.request_states + assert any("p" in batch for pool in orch.stage_pools for batch in pool.aborted) + + +@pytest.mark.asyncio +async def test_companion_abort_fails_deferred_parent(): + """A companion aborted directly (parent not in the batch) can never + complete the bundle — the deferred parent is failed immediately.""" + orch = _make_orchestrator() + orch._cfg_tracker.register_companion("p", "neg", "p__neg") + orch._cfg_tracker.defer_parent("p", {"parent": True}, stage_id=0) + orch.request_states["p"] = _req_state() + + await orch._handle_abort(AbortRequestMessage(request_ids=["p__neg"])) + + msg = orch.output_async_queue.get_nowait() + assert isinstance(msg, ErrorMessage) + assert msg.request_id == "p" + assert "p__neg" in msg.error + assert "p" not in orch.request_states + assert not orch._cfg_tracker.is_companion("p__neg") + # Idempotent: a repeated abort of the same companion is a no-op. + await orch._handle_abort(AbortRequestMessage(request_ids=["p__neg"])) + assert orch.output_async_queue.empty() + + +@pytest.mark.asyncio +async def test_companion_abort_after_completion_does_not_fail_parent(): + """A late/duplicate abort of a companion whose output is already stashed + must not cancel the (possibly already dispatched) parent.""" + orch = _make_orchestrator() + orch._cfg_tracker.register_companion("p", "neg", "p__neg") + orch._cfg_tracker.set_companion_output("p__neg", {"o": 1}) + orch._cfg_tracker.on_companion_completed("p__neg") + orch.request_states["p"] = _req_state() + + await orch._handle_abort(AbortRequestMessage(request_ids=["p__neg"])) + + assert orch.output_async_queue.empty() # no parent failure emitted + assert "p" in orch.request_states # parent untouched + assert orch._cfg_tracker.get_companion_outputs("p") == [{"o": 1}] # bundle intact + + +class _CapturedDispatchError(Exception): + """Sentinel raised by the capture hook to stop the forward after bundling.""" + + +@pytest.mark.asyncio +async def test_repeated_forward_bundles_full_set_again(): + """A second forward of the same parent (streaming terminal update) must + dispatch the complete [parent, *companions] bundle again — never [parent] + alone. Captured at the diffusion input hook, i.e. actual dispatch content.""" + orch = _make_orchestrator() + orch._cfg_tracker.register_companion("p", "neg", "p__neg") + orch._cfg_tracker.set_companion_output("p__neg", {"o": 1}) + orch._cfg_tracker.on_companion_completed("p__neg") + state = _req_state() + orch.request_states["p"] = state + + captured: list[list] = [] + + def _capture(source_outputs, prompt, requires_multimodal_data): + captured.append(list(source_outputs)) + raise _CapturedDispatchError + + orch.stage_pools[1].stage_client.custom_process_input_func = _capture + + for _ in range(2): + with pytest.raises(_CapturedDispatchError): + await orch._forward_to_next_stage("p", 0, {"parent": True}, state) + + assert captured == [[{"parent": True}, {"o": 1}], [{"parent": True}, {"o": 1}]] + + +@pytest.mark.asyncio +async def test_replica_loss_cleanup_releases_parent_tracker_state(): + """Replica-loss/membership teardown calls _cleanup_request_ids directly + (no cleanup_parent at the call site): the parent's tracker state must be + released and its companions folded into the same cleanup batch.""" + orch = _make_orchestrator() + orch._cfg_tracker.register_companion("p", "neg", "p__neg") + orch._cfg_tracker.set_companion_output("p__neg", {"o": 1}) + orch._cfg_tracker.defer_parent("p", {"parent": True}, stage_id=0) + orch.request_states["p"] = _req_state() + + await orch._cleanup_request_ids(["p"], abort=True) + + assert not orch._cfg_tracker.has_companions("p") + assert not orch._cfg_tracker.is_companion("p__neg") + assert orch._cfg_tracker.get_companion_outputs("p") == [] + assert orch._cfg_tracker.pop_pending_parent("p") is None + aborted = {rid for pool in orch.stage_pools for b in pool.aborted for rid in b} + assert {"p", "p__neg"} <= aborted + + +@pytest.mark.asyncio +async def test_replica_loss_of_companion_fails_deferred_parent(): + """A companion torn down by replica loss before its output arrived means + the parent can never complete its bundle — the cleanup fails the parent.""" + orch = _make_orchestrator() + orch._cfg_tracker.register_companion("p", "neg", "p__neg") + orch._cfg_tracker.defer_parent("p", {"parent": True}, stage_id=0) + orch.request_states["p"] = _req_state() + + await orch._cleanup_request_ids(["p__neg"]) + + msg = orch.output_async_queue.get_nowait() + assert isinstance(msg, ErrorMessage) + assert msg.request_id == "p" + assert "p__neg" in msg.error + assert "p" not in orch.request_states + assert not orch._cfg_tracker.has_companions("p") + + +@pytest.mark.asyncio +async def test_finished_companion_cleanup_preserves_parent_bundle(): + """The normal companion-finished cleanup (_route_output cleans just the + companion id) must not disturb the parent's stashed bundle.""" + orch = _make_orchestrator() + orch._cfg_tracker.register_companion("p", "neg", "p__neg") + orch._cfg_tracker.set_companion_output("p__neg", {"o": 1}) + orch._cfg_tracker.on_companion_completed("p__neg") + orch.request_states["p"] = _req_state() + + await orch._cleanup_request_ids(["p__neg"]) + + assert orch.output_async_queue.empty() + assert "p" in orch.request_states + assert orch._cfg_tracker.get_companion_outputs("p") == [{"o": 1}] + + +@pytest.mark.asyncio +async def test_parent_abort_still_expands_to_companions(): + """Regression-guard the existing behavior: aborting the parent aborts its + companions and clears tracker state, without emitting an error.""" + orch = _make_orchestrator() + orch._cfg_tracker.register_companion("p", "neg", "p__neg") + orch._cfg_tracker.defer_parent("p", {"parent": True}, stage_id=0) + orch.request_states["p"] = _req_state() + + await orch._handle_abort(AbortRequestMessage(request_ids=["p"])) + + assert orch.output_async_queue.empty() + assert "p" not in orch.request_states + assert not orch._cfg_tracker.is_companion("p__neg") + aborted = {rid for pool in orch.stage_pools for batch in pool.aborted for rid in batch} + assert {"p", "p__neg"} <= aborted diff --git a/tests/engine/test_correlated_rpc_client.py b/tests/engine/test_correlated_rpc_client.py new file mode 100644 index 000000000..2f470a996 --- /dev/null +++ b/tests/engine/test_correlated_rpc_client.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +import queue +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from vllm_omni.engine.messages import ( + CollectiveRPCRequestMessage, + CollectiveRPCResultMessage, + ErrorMessage, +) +from vllm_omni.engine.rpc_result_router import CorrelatedRpcClient + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _request(rpc_id: str) -> CollectiveRPCRequestMessage: + return CollectiveRPCRequestMessage( + rpc_id=rpc_id, + method="health", + timeout=1, + args=(), + kwargs={}, + stage_ids=None, + ) + + +def test_correlated_rpc_client_unregisters_timeout_before_late_result() -> None: + request_queue: queue.Queue = queue.Queue() + result_queue: queue.Queue = queue.Queue() + client = CorrelatedRpcClient(request_queue, result_queue) + + try: + with pytest.raises(TimeoutError, match="first timed out"): + client.execute( + ("collective", "first"), + _request("first"), + timeout=0.01, + timeout_message="first timed out", + ) + assert request_queue.get_nowait().rpc_id == "first" + + result_queue.put( + CollectiveRPCResultMessage( + rpc_id="first", + method="health", + stage_ids=[0], + results=["late"], + ) + ) + result_queue.put( + CollectiveRPCResultMessage( + rpc_id="second", + method="health", + stage_ids=[0], + results=["current"], + ) + ) + current = client.execute( + ("collective", "second"), + _request("second"), + timeout=1, + timeout_message="second timed out", + ) + + assert isinstance(current, CollectiveRPCResultMessage) + assert current.results == ["current"] + finally: + client.close() + + +def test_correlated_rpc_client_rejects_after_fatal_without_enqueuing() -> None: + request_queue: queue.Queue = queue.Queue() + result_queue: queue.Queue = queue.Queue() + client = CorrelatedRpcClient(request_queue, result_queue) + + try: + with ThreadPoolExecutor(max_workers=1) as executor: + pending = executor.submit( + client.execute, + ("collective", "pending"), + _request("pending"), + timeout=1, + timeout_message="unexpected timeout", + ) + assert request_queue.get(timeout=1).rpc_id == "pending" + result_queue.put(ErrorMessage(error="orchestrator failed", fatal=True)) + with pytest.raises(RuntimeError, match="orchestrator failed"): + pending.result(timeout=1) + + with pytest.raises(RuntimeError, match="orchestrator failed"): + client.execute( + ("collective", "after-fatal"), + _request("after-fatal"), + timeout=1, + timeout_message="unexpected timeout", + ) + assert request_queue.empty() + finally: + client.close() diff --git a/tests/test_data_entry_keys.py b/tests/engine/test_data_entry_keys.py similarity index 92% rename from tests/test_data_entry_keys.py rename to tests/engine/test_data_entry_keys.py index 73dcc82c2..4720cd8ea 100644 --- a/tests/test_data_entry_keys.py +++ b/tests/engine/test_data_entry_keys.py @@ -21,6 +21,8 @@ from vllm_omni.data_entry_keys import ( ) from vllm_omni.engine import AdditionalInformationPayload +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + class TestOmniPayloadStruct: """Runtime-validated mirror of OmniPayload (msgspec.Struct).""" @@ -43,6 +45,16 @@ class TestOmniPayloadStruct: with pytest.raises(msgspec.ValidationError, match="unknown field"): to_struct({"meta": {"finisheed": True}}) + def test_to_struct_rejects_minicpmo_duplex_runtime_handoff_fields(self): + with pytest.raises(msgspec.ValidationError, match="unknown field"): + to_struct({"hidden_states": {"tts": torch.zeros(1)}}) + with pytest.raises(msgspec.ValidationError, match="unknown field"): + to_struct({"ids": {"tts": [1]}}) + with pytest.raises(msgspec.ValidationError, match="unknown field"): + to_struct({"meta": {"ref_audio_sr": 16000}}) + with pytest.raises(msgspec.ValidationError, match="unknown field"): + to_struct({"meta": {"listen_token_id": 151685}}) + def test_to_struct_rejects_wrong_type(self): with pytest.raises(msgspec.ValidationError, match="Expected"): to_struct({"meta": {"left_context_size": "not_an_int"}}) @@ -66,10 +78,10 @@ class TestOmniPayloadStruct: def test_struct_with_all_categories(self): d = { - "hidden_states": {"output": torch.zeros(1)}, + "hidden_states": {"output": torch.zeros(1), "last": torch.ones(2, 4)}, "embed": {"prefill": torch.zeros(1), "tts_bos": torch.zeros(1)}, - "ids": {"all": [1, 2], "prompt": [1]}, - "codes": {"audio": torch.zeros(1)}, + "ids": {"all": [1, 2], "prompt": [1], "speech_token": [3, 4]}, + "codes": {"audio": torch.zeros(1), "ref": torch.ones(4)}, "meta": {"left_context_size": 3, "num_processed_tokens": 7}, } s = to_struct(d) @@ -79,7 +91,10 @@ class TestOmniPayloadStruct: assert isinstance(s.codes, CodesStruct) assert isinstance(s.meta, MetaStruct) assert s.ids.all == [1, 2] + assert s.ids.speech_token == [3, 4] + assert torch.equal(s.hidden_states.last, torch.ones(2, 4)) assert s.meta.num_processed_tokens == 7 + assert torch.equal(s.codes.ref, torch.ones(4)) class TestValidatePayload: @@ -332,7 +347,7 @@ class TestSerializeDeserializePayload: "hidden_states": {"output": torch.tensor([1.0, 2.0])}, "ids": {"all": [1, 2, 3]}, "meta": {"finished": torch.tensor(False, dtype=torch.bool), "ar_width": 4}, - "codes": {"audio": torch.tensor([3.0])}, + "codes": {"audio": torch.tensor([3.0]), "ref": torch.tensor([0.1, 0.2])}, } wire = serialize_payload(original) restored = deserialize_payload(wire) @@ -341,6 +356,7 @@ class TestSerializeDeserializePayload: assert restored["meta"]["finished"].item() is False assert restored["meta"]["ar_width"] == 4 assert torch.equal(restored["codes"]["audio"], original["codes"]["audio"]) + assert torch.equal(restored["codes"]["ref"], original["codes"]["ref"]) def test_hidden_states_layers_round_trip(self): original = { diff --git a/tests/engine/test_duplex_import_boundary.py b/tests/engine/test_duplex_import_boundary.py new file mode 100644 index 000000000..6eae2e698 --- /dev/null +++ b/tests/engine/test_duplex_import_boundary.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _assert_isolated_import_succeeds(script: str) -> None: + result = subprocess.run( + [sys.executable, "-c", script], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_stable_engine_imports_do_not_load_experimental_duplex() -> None: + _assert_isolated_import_succeeds(""" +import sys + +import vllm_omni.engine.async_omni_engine +import vllm_omni.engine.orchestrator +import vllm_omni.entrypoints.async_omni + +loaded = sorted( + name + for name in sys.modules + if name == "vllm_omni.experimental.fullduplex" + or name.startswith("vllm_omni.experimental.fullduplex.") +) +if loaded: + raise SystemExit("stable imports loaded experimental duplex: " + ", ".join(loaded)) +""") + + +def test_stable_engine_does_not_expose_duplex_contract_modules() -> None: + _assert_isolated_import_succeeds(""" +import importlib.util + +from vllm_omni.engine import messages + +legacy_modules = ( + "vllm_omni.engine.duplex_contracts", + "vllm_omni.engine.duplex_lease", + "vllm_omni.engine.resumable", +) +present = [name for name in legacy_modules if importlib.util.find_spec(name) is not None] +if present: + raise SystemExit("stable duplex modules still exist: " + ", ".join(present)) + +duplex_exports = sorted(name for name in vars(messages) if name.startswith("Duplex")) +if duplex_exports: + raise SystemExit("stable messages still expose duplex contracts: " + ", ".join(duplex_exports)) +""") + + +def test_stable_outputs_do_not_declare_duplex_decision_field() -> None: + _assert_isolated_import_succeeds(""" +import dataclasses + +from vllm_omni.outputs import OmniRequestOutput + +fields = {field.name for field in dataclasses.fields(OmniRequestOutput)} +if "duplex_output_decision" in fields: + raise SystemExit("stable output declares duplex_output_decision") +""") + + +def test_stable_model_executor_does_not_expose_duplex_helper_module() -> None: + _assert_isolated_import_succeeds(""" +import importlib.util + +if importlib.util.find_spec("vllm_omni.model_executor.duplex") is not None: + raise SystemExit("stable model_executor still exposes duplex helper module") +""") + + +def test_runtime_package_does_not_bundle_the_browser_demo() -> None: + assert not (REPO_ROOT / "vllm_omni" / "experimental" / "fullduplex" / "web").exists() + + +def test_experimental_engine_uses_canonical_contract_module_names() -> None: + engine_dir = REPO_ROOT / "vllm_omni" / "experimental" / "fullduplex" / "engine" + core_dir = REPO_ROOT / "vllm_omni" / "experimental" / "fullduplex" / "core" + + assert (engine_dir / "contracts.py").is_file() + assert (engine_dir / "lease.py").is_file() + assert (engine_dir / "messages.py").is_file() + assert not (engine_dir / "duplex_lease.py").exists() + assert not (engine_dir / "duplex_types.py").exists() + assert not (core_dir / "identity.py").exists() diff --git a/tests/engine/test_membership_controller.py b/tests/engine/test_membership_controller.py index c00a7f646..1b8bf2adb 100644 --- a/tests/engine/test_membership_controller.py +++ b/tests/engine/test_membership_controller.py @@ -11,6 +11,7 @@ import pytest from vllm_omni.distributed.omni_coordinator.messages import ReplicaStatus from vllm_omni.engine.membership_controller import MembershipController +from vllm_omni.engine.stage_pool import StagePool pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -45,10 +46,10 @@ class FakePool: def attach_load_balancer(self, lb): self.lb = lb - def add_client(self, input_addr, client): - self.added.append((input_addr, client)) + def add_client(self, input_addr, client, *, replica_id=None): + self.added.append((input_addr, client, replica_id)) self.clients.append(client) - return len(self.clients) - 1 + return len(self.clients) - 1 if replica_id is None else replica_id def invalidate_addr(self, input_addr): self.invalidated.append(input_addr) @@ -155,4 +156,35 @@ async def test_do_register_offloads_remote_factory(monkeypatch): assert factory_thread_ids assert factory_thread_ids[0] != threading.get_ident() - assert pool.added[0][0] == "tcp://stage-0-replica-1" + assert pool.added[0] == ("tcp://stage-0-replica-1", pool.clients[0], 1) + + +@pytest.mark.asyncio +async def test_unregister_then_register_restores_coordinator_replica_slot(monkeypatch): + class Client: + def __init__(self, input_addr: str) -> None: + self.client_addresses = {"input_address": input_addr} + self.shutdown_calls = 0 + + def shutdown(self) -> None: + self.shutdown_calls += 1 + + old_clients = [Client("tcp://old-0"), Client("tcp://old-1")] + replacement = Client("tcp://new-1") + pool = StagePool(0, old_clients) + + def factory(stage_id: int, replica_id: int): + assert (stage_id, replica_id) == (0, 1) + return replacement + + controller = _controller(monkeypatch, pool, FakeHub(), remote_replica_factory=factory) + await controller.handle_unregister(0, "tcp://old-0") + await controller.handle_unregister(0, "tcp://old-1") + + await controller.handle_register(0, 1) + await controller.drain_tasks(timeout=1) + + assert pool.clients == [None, replacement] + assert pool.get_replica_id_by_addr("tcp://new-1") == 1 + assert pool.get_replica_id_by_addr("tcp://old-1") is None + assert pool.available_replica_ids() == [1] diff --git a/tests/engine/test_multimodal_accumulation.py b/tests/engine/test_multimodal_accumulation.py new file mode 100644 index 000000000..dbde83fef --- /dev/null +++ b/tests/engine/test_multimodal_accumulation.py @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +import torch + +from vllm_omni.outputs.mm_outputs import MultimodalPayload +from vllm_omni.outputs.multimodal_accumulation import ( + drain_delta_payload, + is_non_final_delta_audio_chunk, + replace_snapshot_keys, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state(): + accumulated = MultimodalPayload.from_dict( + { + "audio": torch.tensor([1.0]), + "meta.segment_end": torch.tensor([0]), + "meta.tts_is_last_chunk": torch.tensor([0]), + "meta.turn_end": torch.tensor([0]), + "meta.stable_request_value": "keep", + } + ) + incoming = MultimodalPayload.from_dict( + { + "audio": torch.tensor([2.0]), + "meta.segment_end": torch.tensor([1]), + "meta.tts_is_last_chunk": torch.tensor([1]), + "meta.turn_end": torch.tensor([1]), + } + ) + assert accumulated is not None + assert incoming is not None + + replace_snapshot_keys(accumulated, incoming) + merged = accumulated.merged_with(incoming) + + assert not is_non_final_delta_audio_chunk(merged, "audio") + + drain_delta_payload(merged) + + assert "audio" not in merged + assert "meta.segment_end" not in merged + assert "meta.tts_is_last_chunk" not in merged + assert "meta.turn_end" not in merged + assert merged.metadata["meta.stable_request_value"] == "keep" diff --git a/tests/test_omni_request.py b/tests/engine/test_omni_request.py similarity index 82% rename from tests/test_omni_request.py rename to tests/engine/test_omni_request.py index 09f893a03..4f4718f09 100644 --- a/tests/test_omni_request.py +++ b/tests/engine/test_omni_request.py @@ -20,7 +20,7 @@ from vllm.sampling_params import SamplingParams from vllm.v1.request import Request from vllm_omni.engine import PromptEmbedsPayload -from vllm_omni.request import OmniRequest +from vllm_omni.request import OmniRequest, OmniStreamingUpdate pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -32,7 +32,12 @@ def test_omni_params_are_keyword_only(): came first positionally and broke positional ``Request(...)`` construction. """ params = inspect.signature(OmniRequest.__init__).parameters - for name in ("prompt_embeds", "external_req_id", "additional_information"): + for name in ( + "prompt_embeds", + "external_req_id", + "additional_information", + "model_intermediate_buffer", + ): assert params[name].kind is inspect.Parameter.KEYWORD_ONLY, name @@ -87,3 +92,21 @@ def test_keyword_omni_params_round_trip(): # The serialized payload is retained and decoded into a tensor on the base. assert req.prompt_embeds_payload is payload assert torch.equal(req.prompt_embeds, torch.from_numpy(arr)) + + +def test_model_intermediate_buffer_round_trips_to_streaming_update(): + buffer = {"duplex": {"turn_id": 3}} + request = OmniRequest( + request_id="req-model-buffer", + prompt_token_ids=[1, 2], + sampling_params=SamplingParams(max_tokens=16), + pooling_params=None, + resumable=True, + model_intermediate_buffer=buffer, + ) + + update = OmniStreamingUpdate.from_request(request) + + assert update is not None + assert request.model_intermediate_buffer is buffer + assert update.model_intermediate_buffer is buffer diff --git a/tests/test_outputs.py b/tests/engine/test_omni_request_output.py similarity index 100% rename from tests/test_outputs.py rename to tests/engine/test_omni_request_output.py diff --git a/tests/engine/test_orchestrator.py b/tests/engine/test_orchestrator.py index 23f8cfaa2..b6c4f8509 100644 --- a/tests/engine/test_orchestrator.py +++ b/tests/engine/test_orchestrator.py @@ -14,12 +14,15 @@ import janus import pytest from vllm.outputs import CompletionOutput, RequestOutput from vllm.sampling_params import SamplingParams +from vllm.v1.engine.exceptions import EngineDeadError +from vllm.v1.metrics.stats import IterationStats from vllm_omni.engine.messages import ( AbortRequestMessage, AddCompanionRequestMessage, CollectiveRPCRequestMessage, CollectiveRPCResultMessage, + ErrorMessage, OutputMessage, ShutdownRequestMessage, StageSubmissionMessage, @@ -28,15 +31,70 @@ from vllm_omni.engine.orchestrator import ( Orchestrator, OrchestratorRequestState, _build_terminal_empty_output, - _infer_stage_audio_sample_rate, ) from vllm_omni.engine.stage_pool import StagePool +from vllm_omni.experimental.fullduplex.engine.duplex_control_plane import DuplexControlPlane +from vllm_omni.experimental.fullduplex.engine.duplex_runtime import ( + DuplexInputMode, + DuplexRuntimeCapabilities, + DuplexSessionRuntimeState, + duplex_resource_request_id, +) +from vllm_omni.experimental.fullduplex.engine.messages import ( + AppendDuplexInputMessage, + CloseDuplexSessionMessage, + DuplexFence, + OpenDuplexSessionMessage, + SignalDuplexTurnMessage, +) +from vllm_omni.experimental.fullduplex.minicpmo45.runtime import MiniCPMO45DuplexRuntimeExtension from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.outputs import OmniRequestOutput pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +class FakeRunningCounter: + def __init__(self) -> None: + self.value = 0 + + def increment(self) -> None: + self.value += 1 + + def decrement(self) -> None: + self.value -= 1 + + +@pytest.mark.asyncio +async def test_engine_dead_broadcasts_fatal_to_rpc_waiters(monkeypatch: pytest.MonkeyPatch) -> None: + rpc_queue: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_queue, + stage_pools=[], + ) + orchestrator._fatal_error = "stage engine died" + orchestrator._fatal_error_stage_id = 2 + + async def wait_for_requests() -> None: + await asyncio.Event().wait() + + async def fail_outputs() -> None: + raise EngineDeadError("stage engine died") + + monkeypatch.setattr(orchestrator, "_request_handler", wait_for_requests) + monkeypatch.setattr(orchestrator, "_orchestration_output_handler", fail_outputs) + + await orchestrator.run() + + fatal = rpc_queue.get_nowait() + assert isinstance(fatal, ErrorMessage) + assert fatal.fatal is True + assert fatal.error == "stage engine died" + assert fatal.stage_id == 2 + + @dataclass class OrchestratorFixture: orchestrator: Orchestrator @@ -88,7 +146,7 @@ class FakeStageClient: try: return self._engine_core_outputs.get_nowait() except queue.Empty: - return SimpleNamespace(outputs=[]) + return SimpleNamespace(outputs=[], scheduler_stats=None, finished_requests=None) def get_diffusion_output_nowait(self): try: @@ -143,12 +201,12 @@ class FakeStageClient: def test_terminal_empty_audio_output_uses_stage_sample_rate() -> None: final_stage = FakeStageClient(final_output=True, final_output_type="audio") final_stage.sample_rate = 44100 - final_pool = SimpleNamespace(stage_client=final_stage, _stage_vllm_config=None) + final_pool = StagePool(0, [final_stage]) terminal_output = _build_terminal_empty_output( "req-1", final_output_type="audio", - audio_sample_rate=_infer_stage_audio_sample_rate(final_pool), + audio_sample_rate=final_pool._infer_audio_sample_rate(), ) assert terminal_output.outputs[0].multimodal_output["sr"] == 44100 @@ -196,12 +254,40 @@ class FakeOutputProcessor: return None +class IterationStatsOutputProcessor(FakeOutputProcessor): + def process_outputs(self, *_args, **kwargs): + iteration_stats = kwargs.get("iteration_stats") + if iteration_stats is None and len(_args) >= 3: + iteration_stats = _args[2] + assert iteration_stats is not None + iteration_stats.num_generation_tokens += 3 + return super().process_outputs(*_args, **kwargs) + + +class RecordingOutputProcessor(FakeOutputProcessor): + def __init__(self, *, request_outputs: list[object] | None = None) -> None: + super().__init__(request_outputs=request_outputs) + self.process_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + def process_outputs(self, *args, **kwargs): + self.process_calls.append((args, kwargs)) + return super().process_outputs(*args, **kwargs) + + +class RecordingStatLogger: + def __init__(self) -> None: + self.records: list[tuple[Any, Any, int]] = [] + + def record(self, scheduler_stats, iteration_stats, *, engine_idx: int = 0) -> None: + self.records.append((scheduler_stats, iteration_stats, engine_idx)) + + def _sampling_params(max_tokens: int = 4) -> SamplingParams: return SamplingParams(max_tokens=max_tokens) def _engine_core_outputs(tag: str, timestamp: float) -> SimpleNamespace: - return SimpleNamespace(outputs=[tag], timestamp=timestamp, scheduler_stats=None) + return SimpleNamespace(outputs=[tag], timestamp=timestamp, scheduler_stats=None, finished_requests=None) def _build_request_output( @@ -272,6 +358,7 @@ def _build_harness( output_processors: list[object] | None = None, stage_vllm_configs: list[object] | None = None, async_chunk: bool = False, + log_stats: bool = False, stage_pools: list[StagePool] | None = None, ) -> OrchestratorFixture: """Build an Orchestrator test harness. @@ -307,6 +394,7 @@ def _build_harness( rpc_async_queue=rpc_queue.async_q, stage_pools=stage_pools, async_chunk=async_chunk, + log_stats=log_stats, ) ready_future.set_result((orchestrator, request_queue, output_queue, rpc_queue)) await orchestrator.run() @@ -724,6 +812,778 @@ async def test_run_abort(orchestrator_factory) -> None: await _shutdown_orchestrator(orchestrator_fixture) +def _duplex_open_message( + session_id: str, + *, + incarnation: int = 0, + session_config: dict[str, object] | None = None, + runtime_config: dict[str, object] | None = None, +) -> OpenDuplexSessionMessage: + return OpenDuplexSessionMessage( + control_id=f"open-{session_id}", + fence=DuplexFence(session_id, incarnation=incarnation), + session_id=session_id, + capabilities={ + "input_modes": [DuplexInputMode.APPEND_AUDIO_CHUNK.value], + "implementation_level": "model_native_duplex", + }, + session_config=session_config or {}, + runtime_config=runtime_config or {}, + ) + + +async def _handle_duplex(orchestrator: Orchestrator, message: object) -> None: + await orchestrator._require_duplex_control_plane().handle(message) + + +def _duplex_request_state( + orchestrator: Orchestrator, + session: DuplexSessionRuntimeState, + *, + stage_id: int, +) -> OrchestratorRequestState | None: + context = orchestrator._require_duplex_control_plane().ensure_stage_request( + session, + stage_id=stage_id, + ) + if context is None: + return None + return orchestrator.request_states.get(context.request_id) + + +@pytest.mark.asyncio +async def test_duplex_control_plane_keeps_public_and_runtime_config_separate() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=_build_stage_pools([[stage0]]), + duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(), + enable_duplex_control=True, + ) + open_message = _duplex_open_message( + "sid-config-channels", + incarnation=3, + session_config={ + "instructions": "public instructions", + "extra_body": {"duplex_stage_max_tokens": {"0": 99}}, + }, + runtime_config={ + "duplex_stage_max_tokens": {"0": 3}, + "duplex_stage_sampling_params": {"0": {"stop_token_ids": [151705]}}, + }, + ) + + await _handle_duplex(orchestrator, open_message) + + assert rpc_q.get_nowait().ok is True + session = orchestrator.duplex_sessions.require(open_message.session_id) + request_state = _duplex_request_state(orchestrator, session, stage_id=0) + assert request_state.sampling_params_list[0].max_tokens == 3 + assert request_state.sampling_params_list[0].stop_token_ids == [151705] + bridge = request_state.streaming.bridge_states["duplex"] + assert bridge["incarnation"] == open_message.fence.incarnation + assert bridge["session_config"] == open_message.session_config + assert bridge["runtime_config"] == open_message.runtime_config + + +@pytest.mark.asyncio +async def test_duplex_control_plane_preserves_turn_commit_without_model_extension() -> None: + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=[], + duplex_runtime_extension=None, + enable_duplex_control=True, + ) + assert isinstance(orchestrator.duplex_control_plane, DuplexControlPlane) + + message = OpenDuplexSessionMessage( + control_id="open-turn-commit", + fence=DuplexFence("sid-turn-commit"), + session_id="sid-turn-commit", + capabilities={"input_modes": [DuplexInputMode.TURN_COMMIT_ONLY.value]}, + session_config={}, + ) + await _handle_duplex(orchestrator, message) + + result = rpc_q.get_nowait() + assert result.ok is True + assert result.stage_results[0]["result"]["scheduler_request_context"] is False + + +def test_ordinary_orchestrator_bypasses_duplex_control_plane() -> None: + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=asyncio.Queue(), + stage_pools=[], + duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(), + ) + + assert orchestrator.duplex_control_plane is None + + +@pytest.mark.asyncio +async def test_duplex_close_cleans_preregistered_request_without_append() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + rpc_q: asyncio.Queue = asyncio.Queue() + running_counter = FakeRunningCounter() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=_build_stage_pools([[stage0]]), + duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(), + enable_duplex_control=True, + running_counter=running_counter, + ) + open_message = _duplex_open_message("sid-preregister-close") + + await _handle_duplex(orchestrator, open_message) + open_result = rpc_q.get_nowait() + request_id = open_result.stage_results[0]["result"]["request_id"] + assert request_id in orchestrator.request_states + + await _handle_duplex( + orchestrator, + CloseDuplexSessionMessage( + control_id="close-preregistered", + fence=open_message.fence, + session_id=open_message.session_id, + ), + ) + + assert rpc_q.get_nowait().ok is True + assert request_id not in orchestrator.request_states + assert orchestrator.duplex_sessions.get(open_message.session_id) is None + assert running_counter.value == 0 + + +@pytest.mark.asyncio +async def test_duplex_open_failure_rolls_back_session_and_reserved_request() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=_build_stage_pools([[stage0]]), + duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(), + enable_duplex_control=True, + ) + open_message = _duplex_open_message( + "sid-open-rollback", + runtime_config={ + "duplex_stage_sampling_params": { + "0": {"stop_token_ids": 123}, + } + }, + ) + + await _handle_duplex(orchestrator, open_message) + + assert rpc_q.get_nowait().ok is False + assert orchestrator.duplex_sessions.get(open_message.session_id) is None + assert orchestrator.request_states == {} + + +@pytest.mark.asyncio +async def test_duplex_running_counter_tracks_only_submitted_request() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + rpc_q: asyncio.Queue = asyncio.Queue() + running_counter = FakeRunningCounter() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=_build_stage_pools([[stage0]]), + duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(), + enable_duplex_control=True, + running_counter=running_counter, + ) + open_message = _duplex_open_message("sid-duplex-counter") + await _handle_duplex(orchestrator, open_message) + assert rpc_q.get_nowait().ok is True + assert running_counter.value == 0 + + await _handle_duplex( + orchestrator, + AppendDuplexInputMessage( + control_id="append-duplex-counter", + fence=open_message.fence, + session_id=open_message.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"is_speech": True}, + ), + ) + assert rpc_q.get_nowait().ok is True + assert running_counter.value == 1 + + await _handle_duplex( + orchestrator, + CloseDuplexSessionMessage( + control_id="close-duplex-counter", + fence=open_message.fence, + session_id=open_message.session_id, + ), + ) + assert rpc_q.get_nowait().ok is True + assert running_counter.value == 0 + + +@pytest.mark.asyncio +async def test_duplex_failed_append_does_not_advance_sequence_or_fence() -> None: + class FailingPlanExtension(MiniCPMO45DuplexRuntimeExtension): + def plan_append(self, **kwargs): + raise RuntimeError("planned append failure") + + stage0 = FakeStageClient(stage_type="llm", final_output=True) + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=_build_stage_pools([[stage0]]), + duplex_runtime_extension=FailingPlanExtension(), + enable_duplex_control=True, + ) + fence = DuplexFence("sid-append-rollback", turn_id=1) + session = orchestrator.duplex_sessions.open_session( + DuplexFence("sid-append-rollback"), + capabilities=DuplexRuntimeCapabilities( + input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}, + ), + ) + + await _handle_duplex( + orchestrator, + AppendDuplexInputMessage( + control_id="append-rollback", + fence=fence, + session_id=fence.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"is_speech": True}, + ), + ) + + result = rpc_q.get_nowait() + assert result.ok is False + assert session.fence == DuplexFence("sid-append-rollback") + assert session.input_seq == 0 + assert session.input_turn_seq == 0 + assert stage0.add_request_calls == [] + + +@pytest.mark.asyncio +async def test_duplex_duplicate_append_operation_is_submitted_once() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=_build_stage_pools([[stage0]]), + duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(), + enable_duplex_control=True, + ) + fence = DuplexFence("sid-idempotent-append") + session = orchestrator.duplex_sessions.open_session( + fence, + capabilities=DuplexRuntimeCapabilities( + input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}, + ), + ) + + for control_id in ("append-first", "append-timeout-retry"): + await _handle_duplex( + orchestrator, + AppendDuplexInputMessage( + control_id=control_id, + operation_id="physical-input-1", + fence=fence, + session_id=fence.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"is_speech": True}, + ), + ) + + first = rpc_q.get_nowait() + retry = rpc_q.get_nowait() + assert first.ok is True + assert retry.ok is True + assert first.stage_results == retry.stage_results + assert len(stage0.add_request_calls) == 1 + assert session.input_seq == 1 + + +@pytest.mark.asyncio +async def test_duplex_barge_in_aborts_bound_stage_requests_before_releasing_fence() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=False) + stage1 = FakeStageClient(stage_type="llm", final_output=True) + stage_pools = _build_stage_pools( + [[stage0], [stage1]], + output_processors=[FakeOutputProcessor(), FakeOutputProcessor()], + stage_vllm_configs=[ + SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), + SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), + ], + ) + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=stage_pools, + duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(), + enable_duplex_control=True, + ) + session = orchestrator.duplex_sessions.open_session( + DuplexFence("sid-stage-signal"), + capabilities=DuplexRuntimeCapabilities( + input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}, + ), + ) + session.bind_stage_request(0, "req-stage0", fence=session.fence) + session.bind_stage_request(1, "req-stage1", fence=session.fence) + assert stage_pools[0].select_replica_id("req-stage0") == 0 + assert stage_pools[1].select_replica_id("req-stage1") == 0 + session.append_input( + mode=DuplexInputMode.APPEND_AUDIO_CHUNK, + fence=session.fence, + ) + await _handle_duplex( + orchestrator, + SignalDuplexTurnMessage( + control_id="ctrl-signal", + fence=session.fence, + next_fence=DuplexFence("sid-stage-signal", epoch=1), + session_id="sid-stage-signal", + event="barge_in", + ), + ) + + assert stage0.abort_calls == [["req-stage0"]] + assert stage1.abort_calls == [["req-stage1"]] + assert stage0.collective_rpc_calls == [] + assert stage1.collective_rpc_calls == [] + assert session.stage_bindings == {} + assert session.fence == DuplexFence("sid-stage-signal", epoch=1) + result = rpc_q.get_nowait() + assert result.ok is True + + +@pytest.mark.asyncio +async def test_duplex_late_barge_in_releases_only_cancelled_fence_bindings() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=False) + stage1 = FakeStageClient(stage_type="llm", final_output=True) + stage_pools = _build_stage_pools( + [[stage0], [stage1]], + output_processors=[FakeOutputProcessor(), FakeOutputProcessor()], + stage_vllm_configs=[ + SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), + SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), + ], + ) + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=stage_pools, + enable_duplex_control=True, + ) + cancelled_fence = DuplexFence("sid-late-stage-signal") + session = orchestrator.duplex_sessions.open_session( + cancelled_fence, + capabilities=DuplexRuntimeCapabilities( + input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}, + ), + ) + session.bind_stage_request(0, "req-stage0", fence=cancelled_fence) + session.bind_stage_request(1, "req-stage1", fence=cancelled_fence) + assert stage_pools[0].select_replica_id("req-stage0") == 0 + assert stage_pools[1].select_replica_id("req-stage1") == 0 + current_fence = DuplexFence("sid-late-stage-signal", epoch=1) + session.accept_fence(current_fence) + + await _handle_duplex( + orchestrator, + SignalDuplexTurnMessage( + control_id="ctrl-late-signal", + fence=cancelled_fence, + next_fence=current_fence, + session_id="sid-late-stage-signal", + event="barge_in", + ), + ) + + assert stage0.abort_calls == [["req-stage0"]] + assert stage1.abort_calls == [["req-stage1"]] + assert session.stage_bindings == {} + assert session.fence == current_fence + result = rpc_q.get_nowait() + assert result.ok is True + + +@pytest.mark.asyncio +async def test_duplex_cancel_without_next_fence_is_rejected_without_releasing_bindings() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + stage_pools = _build_stage_pools( + [[stage0]], + output_processors=[FakeOutputProcessor()], + stage_vllm_configs=[SimpleNamespace(model_config=SimpleNamespace(max_model_len=64))], + ) + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=stage_pools, + enable_duplex_control=True, + ) + fence = DuplexFence("sid-cancel-contract") + session = orchestrator.duplex_sessions.open_session(fence) + session.bind_stage_request(0, "req-live", fence=fence) + + await _handle_duplex( + orchestrator, + SignalDuplexTurnMessage( + control_id="cancel-without-next", + fence=fence, + session_id=fence.session_id, + event="input.cancel", + ), + ) + + result = rpc_q.get_nowait() + assert result.ok is False + assert result.error_count == 1 + assert "next_fence" in result.stage_results[0]["result"]["error"] + assert session.fence == fence + assert session.stage_request_ids() == ["req-live"] + assert stage0.abort_calls == [] + + +@pytest.mark.asyncio +async def test_duplex_session_update_replaces_runtime_config() -> None: + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=[], + enable_duplex_control=True, + ) + fence = DuplexFence("sid-update-config") + session = orchestrator.duplex_sessions.open_session( + fence, + session_config={"temperature": 0.7}, + ) + + await _handle_duplex( + orchestrator, + SignalDuplexTurnMessage( + control_id="update-config", + fence=fence, + session_id=fence.session_id, + event="session.update", + session_config={"temperature": 0.0, "instructions": "updated"}, + ), + ) + + result = rpc_q.get_nowait() + assert result.ok is True + assert session.session_config == {"temperature": 0.0, "instructions": "updated"} + + +@pytest.mark.asyncio +async def test_duplex_session_update_refreshes_next_append_sampling_params() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + stage_pools = _build_stage_pools( + [[stage0]], + output_processors=[FakeOutputProcessor()], + stage_vllm_configs=[SimpleNamespace(model_config=SimpleNamespace(max_model_len=64))], + ) + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=stage_pools, + duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(), + enable_duplex_control=True, + ) + fence = DuplexFence("sid-update-policy") + session = orchestrator.duplex_sessions.open_session( + fence, + capabilities=DuplexRuntimeCapabilities( + input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}, + ), + runtime_config={ + "duplex_stage_max_tokens": {"0": 2}, + "duplex_stage_sampling_params": {"0": {"stop_token_ids": [151645]}}, + }, + ) + req_state = _duplex_request_state(orchestrator, session, stage_id=0) + assert req_state is not None + assert req_state.sampling_params_list[0].max_tokens == 2 + + await _handle_duplex( + orchestrator, + SignalDuplexTurnMessage( + control_id="update-policy", + fence=fence, + session_id=fence.session_id, + event="session.update", + runtime_config={ + "duplex_stage_max_tokens": {"0": 7}, + "duplex_stage_sampling_params": {"0": {"stop_token_ids": [151705]}}, + }, + ), + ) + assert rpc_q.get_nowait().ok is True + + await _handle_duplex( + orchestrator, + AppendDuplexInputMessage( + control_id="append-updated-policy", + fence=fence, + session_id=fence.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"is_speech": True}, + ), + ) + + assert rpc_q.get_nowait().ok is True + assert req_state.sampling_params_list[0].max_tokens == 7 + assert req_state.sampling_params_list[0].stop_token_ids == [151705] + submitted_request = stage0.add_request_calls[0][0] + assert submitted_request.sampling_params.stop_token_ids == [151705] + assert submitted_request.sampling_params.max_tokens == 7 + + +@pytest.mark.asyncio +async def test_duplex_invalid_session_update_preserves_previous_config() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=_build_stage_pools([[stage0]]), + duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(), + enable_duplex_control=True, + ) + fence = DuplexFence("sid-invalid-update") + original_runtime_config = { + "duplex_stage_sampling_params": {"0": {"stop_token_ids": [151645]}}, + } + session = orchestrator.duplex_sessions.open_session( + fence, + runtime_config=original_runtime_config, + ) + + await _handle_duplex( + orchestrator, + SignalDuplexTurnMessage( + control_id="invalid-update", + fence=fence, + session_id=fence.session_id, + event="session.update", + runtime_config={ + "duplex_stage_sampling_params": {"0": {"stop_token_ids": 123}}, + }, + ), + ) + + result = rpc_q.get_nowait() + assert result.ok is False + assert session.runtime_config == original_runtime_config + assert session.config_generation == 0 + + +@pytest.mark.asyncio +async def test_duplex_arbitrary_non_cancel_signal_is_rejected() -> None: + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=[], + enable_duplex_control=True, + ) + fence = DuplexFence("sid-unsupported-signal") + orchestrator.duplex_sessions.open_session(fence) + + await _handle_duplex( + orchestrator, + SignalDuplexTurnMessage( + control_id="unsupported-signal", + fence=fence, + session_id=fence.session_id, + event="turn.end", + ), + ) + + result = rpc_q.get_nowait() + assert result.ok is False + assert "unsupported duplex runtime signal" in result.stage_results[0]["result"]["error"] + + +@pytest.mark.asyncio +async def test_duplex_cancel_rejects_late_old_append_and_accepts_next_epoch() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + stage_pools = _build_stage_pools( + [[stage0]], + output_processors=[FakeOutputProcessor()], + stage_vllm_configs=[SimpleNamespace(model_config=SimpleNamespace(max_model_len=64))], + ) + rpc_q: asyncio.Queue = asyncio.Queue() + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=rpc_q, + stage_pools=stage_pools, + duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(), + enable_duplex_control=True, + ) + cancelled_fence = DuplexFence("sid-cancel-late-append") + next_fence = DuplexFence("sid-cancel-late-append", epoch=1) + session = orchestrator.duplex_sessions.open_session( + cancelled_fence, + capabilities=DuplexRuntimeCapabilities( + input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}, + ), + ) + + await _handle_duplex( + orchestrator, + SignalDuplexTurnMessage( + control_id="cancel-old-fence", + fence=cancelled_fence, + next_fence=next_fence, + session_id=session.session_id, + event="input.cancel", + ), + ) + + cancel_result = rpc_q.get_nowait() + assert cancel_result.ok is True + assert session.fence == next_fence + + await _handle_duplex( + orchestrator, + AppendDuplexInputMessage( + control_id="late-old-append", + fence=cancelled_fence, + session_id=session.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"is_speech": True}, + ), + ) + + stale_result = rpc_q.get_nowait() + assert stale_result.ok is False + assert stage0.add_request_calls == [] + assert session.stage_bindings == {} + + await _handle_duplex( + orchestrator, + AppendDuplexInputMessage( + control_id="next-epoch-append", + fence=next_fence, + session_id=session.session_id, + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"is_speech": True}, + ), + ) + + next_result = rpc_q.get_nowait() + assert next_result.ok is True + assert len(stage0.add_request_calls) == 1 + assert session.stage_bindings[0].fence == next_fence + + +@pytest.mark.asyncio +async def test_duplex_append_updates_bridge_turn_id_on_long_lived_stage0_request() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + stage_pools = _build_stage_pools( + [[stage0]], + output_processors=[FakeOutputProcessor()], + stage_vllm_configs=[SimpleNamespace(model_config=SimpleNamespace(max_model_len=64))], + ) + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=asyncio.Queue(), + stage_pools=stage_pools, + duplex_runtime_extension=MiniCPMO45DuplexRuntimeExtension(), + enable_duplex_control=True, + ) + session = orchestrator.duplex_sessions.open_session( + DuplexFence("sid-bridge-turn"), + capabilities=DuplexRuntimeCapabilities( + input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}, + ), + session_config={ + "voice": "test", + }, + runtime_config={ + "duplex_stage_sampling_params": { + "0": {"stop_token_ids": [151645]}, + }, + }, + ) + + await _handle_duplex( + orchestrator, + AppendDuplexInputMessage( + control_id="append-1", + fence=DuplexFence("sid-bridge-turn"), + session_id="sid-bridge-turn", + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"is_speech": True}, + ), + ) + req_state1 = _duplex_request_state(orchestrator, session, stage_id=0) + assert req_state1 is not None + duplex_state1 = req_state1.streaming.bridge_states["duplex"] + assert duplex_state1["session_id"] == "sid-bridge-turn" + assert duplex_state1["epoch"] == 0 + assert duplex_state1["turn_id"] == 0 + assert duplex_state1["session_config"] == session.session_config + assert req_state1.sampling_params_list[0].stop_token_ids == [151645] + submitted_request = stage0.add_request_calls[0][0] + assert submitted_request.sampling_params.stop_token_ids == [151645] + assert submitted_request.sampling_params.max_tokens == 1 + + await _handle_duplex( + orchestrator, + AppendDuplexInputMessage( + control_id="append-2", + fence=DuplexFence("sid-bridge-turn", turn_id=1, response_seq=1), + session_id="sid-bridge-turn", + mode=DuplexInputMode.APPEND_AUDIO_CHUNK.value, + payload={"is_speech": True, "new_user_turn": True}, + ), + ) + req_state2 = _duplex_request_state(orchestrator, session, stage_id=0) + assert req_state2 is req_state1 + duplex_state2 = req_state2.streaming.bridge_states["duplex"] + assert duplex_state2["turn_id"] == 1 + assert duplex_state2["epoch"] == 0 + expected_request_id = duplex_resource_request_id(DuplexFence("sid-bridge-turn"), "stage0") + assert [call[0].request_id for call in stage0.add_request_calls] == [ + expected_request_id, + expected_request_id, + ] + + # --------------------------------------------------------------------------- # Multi-replica tests # --------------------------------------------------------------------------- @@ -898,6 +1758,50 @@ async def test_stage_pool_submit_update_reuses_existing_binding() -> None: assert stage0_r1.add_request_calls[1][0].request_id == "req-1" +@pytest.mark.asyncio +async def test_stage_pool_failed_replica_releases_distributed_affinity_and_stops_polling() -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=False) + pool = StagePool( + 0, + [stage0], + output_processor=FakeOutputProcessor(), + stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), + ) + pool._addr_to_replica_id["tcp://replica-0"] = 0 + pool.bind("distributed-request", "tcp://replica-0") + pool._request_bindings["legacy-request"] = 0 + + affected = pool.mark_replica_unavailable(0) + + assert set(affected) == {"distributed-request", "legacy-request"} + assert pool.get_bound_replica_id("distributed-request") is None + assert pool.get_bound_replica_id("legacy-request") is None + assert await pool.poll_llm_raw_output(0) is None + + +def test_stage_pool_reattached_replica_becomes_available_again() -> None: + failed_client = FakeStageClient(stage_type="llm", final_output=False) + replacement_client = FakeStageClient(stage_type="llm", final_output=False) + pool = StagePool( + 0, + [failed_client], + output_processor=FakeOutputProcessor(), + stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), + ) + input_addr = "tcp://replica-0" + pool._addr_to_replica_id[input_addr] = 0 + pool.mark_replica_unavailable(0) + removed = pool.remove_client(input_addr) + + replica_id = pool.add_client(input_addr, replacement_client) + + assert removed is failed_client + assert replica_id == 0 + assert len(pool.clients) == 1 + assert pool.clients[0] is replacement_client + assert pool.available_replica_ids() == [0] + + @pytest.mark.asyncio async def test_stage_pool_submit_update_refreshes_output_processor_state() -> None: output_processor = FakeOutputProcessor() @@ -940,6 +1844,56 @@ async def test_stage_pool_submit_update_refreshes_output_processor_state() -> No @pytest.mark.asyncio +async def test_handle_streaming_update_unknown_request_is_dropped() -> None: + """A streaming update for an untracked request must not resurrect it. + + Updates always follow the first-chunk add_request through the same + ordered submission queue, so an unknown id can only mean the request + already finished or was aborted (e.g. the client disconnected). + Falling back to add_request created headless sessions that kept + cycling through the stages (issue #4271). + """ + + class RecordingPool: + def __init__(self) -> None: + self.calls: list[tuple[str, Any]] = [] + + async def submit_update(self, request_id, req_state, request, *, prompt_text=None) -> int: + self.calls.append((request_id, prompt_text)) + return 0 + + pool = RecordingPool() + orchestrator = object.__new__(Orchestrator) + orchestrator.async_chunk = False + orchestrator.request_states = {} + orchestrator.stage_pools = [pool] + add_request_calls: list[str] = [] + + async def record_add_request(msg) -> None: + add_request_calls.append(msg.request_id) + + orchestrator._handle_add_request = record_add_request + + await orchestrator._handle_streaming_update( + StageSubmissionMessage( + type="streaming_update", + request_id="req-unknown", + prompt=SimpleNamespace(request_id="req-unknown", prompt_token_ids=[1], resumable=True), + original_prompt={"prompt": "segment-2"}, + output_prompt_text="segment-2", + sampling_params_list=[_sampling_params()], + final_stage_id=0, + preprocess_ms=0.0, + request_timestamp=time.time(), + enqueue_ts=time.perf_counter(), + ) + ) + + assert pool.calls == [] + assert add_request_calls == [] + assert "req-unknown" not in orchestrator.request_states + + async def test_handle_streaming_update_passes_prompt_text_to_stage_pool() -> None: class RecordingPool: def __init__(self) -> None: @@ -980,6 +1934,73 @@ async def test_handle_streaming_update_passes_prompt_text_to_stage_pool() -> Non assert orchestrator.request_states["req-stream"].streaming.enabled is True +@pytest.mark.asyncio +async def test_resumable_segment_boundary_builds_stage_metrics() -> None: + built_metrics = SimpleNamespace(pipeline_timings={}) + + class RecordingPool: + def __init__(self) -> None: + self.calls: list[list[Any]] = [] + + def build_stage_metrics(self, outputs, **_kwargs): + self.calls.append(outputs) + return built_metrics + + pool = RecordingPool() + orchestrator = object.__new__(Orchestrator) + req_state = OrchestratorRequestState( + request_id="req-stream", + sampling_params_list=[_sampling_params()], + final_stage_id=0, + ) + req_state.streaming.enabled = True + req_state.streaming.segment_finished = True + req_state.stage_submit_ts[0] = time.time() + orchestrator.request_states = {"req-stream": req_state} + orchestrator.stage_pools = [pool] + routed: list[Any] = [] + + async def record_route(_stage_id, _replica_id, _output, _req_state, stage_metrics): + routed.append(stage_metrics) + + orchestrator._route_output = record_route + output = SimpleNamespace(request_id="req-stream", error=None, finished=False) + + await orchestrator._handle_processed_outputs(0, 0, [output]) + + assert pool.calls == [[output]] + assert routed == [built_metrics] + + +def test_stage_pool_metrics_use_resumable_segment_token_count() -> None: + class SegmentMetricsOutputProcessor(FakeOutputProcessor): + def pop_native_text_metrics(self, request_id: str) -> dict[str, Any]: + assert request_id == "req-stream" + return {"num_generation_tokens": 3} + + stage0 = FakeStageClient(stage_type="llm", final_output=False) + pool = StagePool( + 0, + [stage0], + output_processor=SegmentMetricsOutputProcessor(), + stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), + ) + output = SimpleNamespace( + request_id="req-stream", + outputs=[SimpleNamespace(cumulative_token_ids=list(range(11)))], + ) + + metrics = pool.build_stage_metrics( + [output], + submit_ts=time.time(), + request_timestamp=time.time(), + replica_id=0, + ) + + assert metrics.num_tokens_out == 3 + assert metrics.output_unit_count == 3 + + @pytest.mark.asyncio async def test_stage_pool_submit_initial_rolls_back_output_processor_when_client_submit_fails() -> None: class FailingStageClient(FakeStageClient): @@ -1147,35 +2168,272 @@ async def test_multi_replica_cfg_companion_inherits_parent_affinity(orchestrator await _shutdown_orchestrator(orchestrator_fixture) -def test_orchestrator_does_not_re_introduce_global_stats_throttle() -> None: - """Regression: each (stage, replica) must independently publish its wrapped - vllm:* stats when its scheduler emits non-None scheduler_stats. - - A previous version of Orchestrator carried a global self._last_stats_ts / - _stats_interval_s gate around _stat_logger.record(). Because - OmniSchedulerMixin.make_stats() already throttles at 1 Hz per scheduler - (one per (stage, replica)), the extra global gate starved every replica - other than the first to emit within each second — their {stage, replica} - gauges/counters went stale. - - The fix removed the global gate entirely; the only signal needed is - 'this replica's scheduler emitted non-None scheduler_stats'. This test - fails loudly if someone reintroduces the global throttle. - """ - import inspect - - from vllm_omni.engine.orchestrator import Orchestrator - - source = inspect.getsource(Orchestrator) - assert "_last_stats_ts" not in source, ( - "Orchestrator must not gate stat recording on a global timestamp. " - "OmniSchedulerMixin.make_stats() already throttles per scheduler " - "(per (stage, replica)); an outer global gate starves all but the " - "first replica to emit within each 1s window." +@pytest.mark.asyncio +async def test_orchestrator_records_iteration_stats_without_scheduler_stats(orchestrator_factory) -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + processor = IterationStatsOutputProcessor( + request_outputs=[_build_request_output("req-stats", token_ids=[1], finished=True)] ) - assert "_stats_interval_s" not in source - assert "raw_outputs.scheduler_stats is not None" in source, ( - "Orchestrator must gate stat recording solely on " - "raw_outputs.scheduler_stats being non-None — the per-scheduler 1Hz " - "throttle in OmniSchedulerMixin.make_stats() is the only gate needed." + orchestrator_fixture = orchestrator_factory([stage0], output_processors=[processor], log_stats=True) + stat_logger = RecordingStatLogger() + orchestrator_fixture.orchestrator._stat_logger = stat_logger + request = SimpleNamespace(request_id="req-stats", prompt_token_ids=[1, 2]) + + try: + await _enqueue_add_request( + orchestrator_fixture, + request_id="req-stats", + prompt=request, + original_prompt={"prompt": "hello"}, + sampling_params_list=[_sampling_params()], + final_stage_id=0, + ) + await _wait_for(lambda: len(stage0.add_request_calls) == 1) + + stage0.push_engine_core_outputs(_engine_core_outputs("raw-without-scheduler-stats", 1.0)) + + await _wait_for(lambda: len(stat_logger.records) == 1) + scheduler_stats, iteration_stats, engine_idx = stat_logger.records[0] + assert scheduler_stats is None + assert iteration_stats is not None + assert iteration_stats.num_generation_tokens == 3 + assert engine_idx == 0 + finally: + await _shutdown_orchestrator(orchestrator_fixture) + + +@pytest.mark.asyncio +async def test_orchestrator_records_scheduler_stats_without_outputs(orchestrator_factory) -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + processor = RecordingOutputProcessor() + orchestrator_fixture = orchestrator_factory([stage0], output_processors=[processor], log_stats=True) + stat_logger = RecordingStatLogger() + orchestrator_fixture.orchestrator._stat_logger = stat_logger + scheduler_stats = SimpleNamespace(num_running_reqs=0, kv_cache_usage=0.5) + + try: + stage0.push_engine_core_outputs( + SimpleNamespace( + outputs=[], + timestamp=1.0, + scheduler_stats=scheduler_stats, + finished_requests=None, + ) + ) + + await _wait_for(lambda: len(stat_logger.records) == 1) + recorded_scheduler_stats, iteration_stats, engine_idx = stat_logger.records[0] + assert recorded_scheduler_stats is scheduler_stats + assert iteration_stats is None + assert engine_idx == 0 + assert processor.process_calls[0][0][2] is None + finally: + await _shutdown_orchestrator(orchestrator_fixture) + + +@pytest.mark.asyncio +async def test_orchestrator_does_not_build_iteration_stats_for_finished_only_batch(orchestrator_factory) -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + processor = RecordingOutputProcessor() + orchestrator_fixture = orchestrator_factory([stage0], output_processors=[processor], log_stats=True) + stat_logger = RecordingStatLogger() + orchestrator_fixture.orchestrator._stat_logger = stat_logger + + try: + stage0.push_engine_core_outputs( + SimpleNamespace( + outputs=[], + timestamp=1.0, + scheduler_stats=None, + finished_requests={"req-finished"}, + ) + ) + + await _wait_for(lambda: bool(processor.process_calls)) + assert processor.process_calls[0][0][2] is None + assert stat_logger.records == [] + finally: + await _shutdown_orchestrator(orchestrator_fixture) + + +@pytest.mark.asyncio +async def test_orchestrator_does_not_build_iteration_stats_without_stat_logger(orchestrator_factory) -> None: + stage0 = FakeStageClient(stage_type="llm", final_output=True) + processor = RecordingOutputProcessor() + orchestrator_fixture = orchestrator_factory([stage0], output_processors=[processor], log_stats=True) + orchestrator_fixture.orchestrator._stat_logger = None + + try: + stage0.push_engine_core_outputs(_engine_core_outputs("raw-output", 1.0)) + + await _wait_for(lambda: bool(processor.process_calls)) + assert processor.process_calls[0][0][2] is None + finally: + await _shutdown_orchestrator(orchestrator_fixture) + + +@pytest.mark.asyncio +async def test_stage_pool_preserves_none_iteration_stats() -> None: + client = FakeStageClient(stage_type="llm", final_output=True) + processor = RecordingOutputProcessor() + pool = StagePool( + 0, + [client], + output_processor=processor, + stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), ) + raw_outputs = SimpleNamespace(outputs=["raw"], timestamp=1.0, scheduler_stats=None) + + await pool.process_llm_raw_outputs(0, raw_outputs, iteration_stats=None) + + assert processor.process_calls[0][0][2] is None + + +@pytest.mark.asyncio +async def test_stage_pool_process_llm_raw_outputs_mutates_iteration_stats() -> None: + client = FakeStageClient(stage_type="llm", final_output=True) + processor = IterationStatsOutputProcessor() + pool = StagePool( + 0, + [client], + output_processor=processor, + stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), + ) + raw_outputs = SimpleNamespace(outputs=["raw"], timestamp=1.0, scheduler_stats=None) + iteration_stats = IterationStats() + + await pool.process_llm_raw_outputs(0, raw_outputs, iteration_stats=iteration_stats) + + assert iteration_stats.num_generation_tokens == 3 + + +@pytest.mark.asyncio +async def test_duplex_reaper_loop_waits_between_ticks(): + class _Plane: + def __init__(self) -> None: + self.calls = 0 + + async def reap_expired(self) -> int: + self.calls += 1 + return 0 + + orchestrator = object.__new__(Orchestrator) + orchestrator.duplex_control_plane = _Plane() + orchestrator._duplex_reaper_interval_s = 0.01 + orchestrator._shutdown_event = asyncio.Event() + + task = asyncio.create_task(orchestrator._duplex_reaper_loop()) + await asyncio.sleep(0.035) + orchestrator._shutdown_event.set() + await task + + assert 2 <= orchestrator.duplex_control_plane.calls <= 5 + + +@pytest.mark.asyncio +async def test_duplex_reaper_loop_survives_one_cleanup_failure(): + class _Plane: + def __init__(self) -> None: + self.calls = 0 + + async def reap_expired(self) -> int: + self.calls += 1 + if self.calls == 1: + raise RuntimeError("transient cleanup failure") + return 0 + + orchestrator = object.__new__(Orchestrator) + orchestrator.duplex_control_plane = _Plane() + orchestrator._duplex_reaper_interval_s = 0.01 + orchestrator._shutdown_event = asyncio.Event() + + task = asyncio.create_task(orchestrator._duplex_reaper_loop()) + await asyncio.sleep(0.035) + orchestrator._shutdown_event.set() + await task + + assert orchestrator.duplex_control_plane.calls >= 2 + + +@pytest.mark.asyncio +async def test_abort_retry_does_not_repeat_successful_stage_abort(): + class _Pool: + def __init__(self, *, fail_once: bool = False) -> None: + self.bound = {"req-a"} + self.fail_once = fail_once + self.physical_abort_calls = 0 + + async def abort_requests(self, request_ids: list[str]) -> None: + active = self.bound.intersection(request_ids) + if not active: + return + self.physical_abort_calls += 1 + if self.fail_once: + self.fail_once = False + raise RuntimeError("stage abort failed") + + def release_bindings(self, request_ids: list[str]) -> None: + self.bound.difference_update(request_ids) + + first = _Pool() + second = _Pool(fail_once=True) + orchestrator = object.__new__(Orchestrator) + orchestrator.stage_pools = [first, second] + + with pytest.raises(RuntimeError, match="stage abort failed"): + await orchestrator._abort_request_ids(["req-a"]) + + assert first.bound == set() + assert first.physical_abort_calls == 1 + assert second.bound == {"req-a"} + + await orchestrator._abort_request_ids(["req-a"]) + + assert first.physical_abort_calls == 1 + assert second.physical_abort_calls == 2 + assert second.bound == set() + + +@pytest.mark.asyncio +async def test_request_cleanup_failure_is_deferred_to_control_plane(): + class _FailingPool: + async def abort_requests(self, request_ids: list[str]) -> None: + raise RuntimeError("stage abort failed") + + def release_bindings(self, request_ids: list[str]) -> None: + pass + + class _Plane: + def __init__(self) -> None: + self.deferred: list[str] = [] + self.finalized: list[str] = [] + + def close_sessions_for_request_ids(self, request_ids: list[str], **kwargs): + assert kwargs == {"abort": True, "cleanup_in_progress": True} + return {"sid-cleanup": ["req-a"]} + + def defer_request_cleanups(self, session_ids: list[str]) -> None: + self.deferred.extend(session_ids) + + def finalize_closed_sessions(self, session_ids: list[str]) -> None: + self.finalized.extend(session_ids) + + from vllm_omni.engine.cfg_companion_tracker import CfgCompanionTracker + + orchestrator = object.__new__(Orchestrator) + orchestrator.stage_pools = [_FailingPool()] + orchestrator.duplex_control_plane = _Plane() + orchestrator._pd_kv_params = {} + orchestrator.request_states = {} + orchestrator._running_counter = None + orchestrator._cfg_tracker = CfgCompanionTracker() + + with pytest.raises(RuntimeError, match="stage abort failed"): + await orchestrator._cleanup_request_ids( + ["req-a"], + abort=True, + close_duplex_sessions=True, + ) + + assert orchestrator.duplex_control_plane.deferred == ["sid-cleanup"] + assert orchestrator.duplex_control_plane.finalized == [] diff --git a/tests/engine/test_orchestrator_error_handling.py b/tests/engine/test_orchestrator_error_handling.py index 0553c03ff..d30b1eee4 100644 --- a/tests/engine/test_orchestrator_error_handling.py +++ b/tests/engine/test_orchestrator_error_handling.py @@ -73,9 +73,21 @@ def orchestrator_factory(): class FakeDeadLLMStageClient(FakeStageClient): - """LLM stage client that raises EngineDeadError on get_output_async.""" + """LLM stage client that raises EngineDeadError once a request is in-flight. + + Raising on the first poll races with ``add_request``: under suite load the + orchestrator can die and drain before the test enqueues anything, so no + fatal ErrorMessage is ever published. Keep returning empty outputs until + ``add_request_async`` has been called, then fail on the next poll. + """ async def get_output_async(self): + if not self.add_request_calls: + return SimpleNamespace( + outputs=[], + scheduler_stats=None, + finished_requests=None, + ) raise EngineDeadError("Stage-0 engine core is dead") diff --git a/tests/engine/test_orchestrator_stage_input_bridge.py b/tests/engine/test_orchestrator_stage_input_bridge.py index 27ee11df4..cf347e49f 100644 --- a/tests/engine/test_orchestrator_stage_input_bridge.py +++ b/tests/engine/test_orchestrator_stage_input_bridge.py @@ -3,17 +3,28 @@ from __future__ import annotations +import asyncio import queue from types import SimpleNamespace from typing import Any +from unittest.mock import ANY, AsyncMock, MagicMock import janus import pytest from vllm.outputs import CompletionOutput, RequestOutput from vllm.sampling_params import SamplingParams -from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState +from vllm_omni.engine.orchestrator import ( + Orchestrator, + OrchestratorRequestState, + _OrchestratorDuplexStagePort, +) from vllm_omni.engine.stage_pool import StagePool +from vllm_omni.experimental.fullduplex.engine.contracts import ( + DuplexStageRequestContext, + DuplexStageSubmission, +) +from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -38,6 +49,7 @@ class FakeStageClient: self.custom_process_input_func = None self.next_inputs = list(next_inputs or []) self.add_request_calls: list[tuple[Any, ...]] = [] + self.decoded_source_tokens: str | None = None self._engine_core_outputs = queue.Queue() async def add_request_async(self, *args, **_kwargs) -> None: @@ -50,6 +62,9 @@ class FakeStageClient: return SimpleNamespace(outputs=[]) def process_engine_inputs(self, _source_outputs, prompt=None, streaming_context=None): + decoder = getattr(streaming_context, "source_token_decoder", None) + if callable(decoder): + self.decoded_source_tokens = decoder([11, 12], skip_special_tokens=True) return list(self.next_inputs) async def abort_requests_async(self, _request_ids: list[str]) -> None: @@ -66,6 +81,9 @@ class FakeStageClient: class FakeOutputProcessor: + def __init__(self, tokenizer=None) -> None: + self.tokenizer = tokenizer + def add_request(self, *args, **kwargs) -> None: return None @@ -84,6 +102,64 @@ class FakeInputProcessor: ) +class FakePrewarmPool: + stage_type = "llm" + + def __init__(self, role: str) -> None: + self.stage_vllm_config = SimpleNamespace( + model_config=SimpleNamespace( + max_model_len=64, + stage_connector_config={"extra": {"role": role}}, + ) + ) + self.submitted: list[Any] = [] + + async def submit_initial(self, _request_id, _req_state, request, prompt_text=None): + self.submitted.append(request) + return 0 + + def get_bound_replica_id(self, _request_id): + return 0 + + +def _duplex_stage_port_submission(): + stage_pools = [] + for stage_id in range(3): + pool = SimpleNamespace( + stage_client=SimpleNamespace(default_sampling_params=SamplingParams(max_tokens=1)), + stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), + submit_initial=AsyncMock(return_value=stage_id + 10), + submit_update=AsyncMock(return_value=stage_id + 20), + ) + stage_pools.append(pool) + request_states: dict[str, OrchestratorRequestState] = {} + prewarm = AsyncMock() + port = _OrchestratorDuplexStagePort( + stage_pools=stage_pools, + request_states=request_states, + running_counter=None, + cleanup_request_ids=AsyncMock(), + async_chunk=True, + prewarm_async_chunk_stages=prewarm, + ) + context = DuplexStageRequestContext( + request_id="req-duplex", + session_id="session-duplex", + fence=DuplexFence("session-duplex"), + stage_id=0, + final_stage_id=2, + config_generation=0, + sampling_params=tuple(SamplingParams(max_tokens=1) for _ in range(3)), + ) + port.ensure_request(context) + submission = DuplexStageSubmission( + context=context, + prompt={"prompt_token_ids": [1, 2]}, + already_submitted=False, + ) + return port, stage_pools, request_states, prewarm, submission + + def _request_output(request_id: str) -> RequestOutput: completion = CompletionOutput( index=0, @@ -108,6 +184,11 @@ def _request_output(request_id: str) -> RequestOutput: @pytest.mark.asyncio async def test_forward_text_prompt_uses_target_stage_input_processor() -> None: + class SourceTokenizer: + def decode(self, token_ids, *, skip_special_tokens): + assert skip_special_tokens is True + return ":".join(str(token_id) for token_id in token_ids) + stage0 = FakeStageClient(final_output=True) stage1 = FakeStageClient( final_output=True, @@ -117,7 +198,7 @@ async def test_forward_text_prompt_uses_target_stage_input_processor() -> None: StagePool( 0, [stage0], - output_processor=FakeOutputProcessor(), + output_processor=FakeOutputProcessor(tokenizer=SourceTokenizer()), stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), ), StagePool( @@ -150,7 +231,116 @@ async def test_forward_text_prompt_uses_target_stage_input_processor() -> None: assert input_processor.calls assert input_processor.calls[0]["prompt"] == {"prompt": "hello", "multi_modal_data": {"video": ["frame"]}} + assert stage1.decoded_source_tokens == "11:12" + assert req_state.streaming.source_token_decoder is None assert stage1.add_request_calls submitted_request = stage1.add_request_calls[0][0] assert submitted_request.prompt_token_ids == [101, 102] assert submitted_request.external_req_id == "req-text" + + +@pytest.mark.asyncio +async def test_async_prewarm_skips_outgoing_only_stage() -> None: + orchestrator = object.__new__(Orchestrator) + stage0 = FakePrewarmPool("sender") + stage1 = FakePrewarmPool("sender") + stage2 = FakePrewarmPool("receiver") + orchestrator.stage_pools = [stage0, stage1, stage2] + orchestrator._emit_tx_edge = lambda **_kwargs: None + orchestrator._record_duplex_stage_submission = MagicMock() + req_state = OrchestratorRequestState( + request_id="req-prewarm", + prompt={"prompt_token_ids": [1, 2]}, + sampling_params_list=[SamplingParams(max_tokens=1) for _ in range(3)], + final_stage_id=2, + duplex_identity=SimpleNamespace(), + ) + + await orchestrator._prewarm_async_chunk_stages( + "req-prewarm", + SimpleNamespace(prompt_token_ids=[1, 2], resumable=True), + req_state, + ) + + assert stage1.submitted == [] + assert len(stage2.submitted) == 1 + assert 1 not in req_state.stage_submit_ts + assert 2 in req_state.stage_submit_ts + orchestrator._record_duplex_stage_submission.assert_called_once_with( + 2, + "req-prewarm", + 0, + req_state, + ) + + +@pytest.mark.asyncio +async def test_duplex_prewarm_runs_after_first_stage0_submission() -> None: + port, stage_pools, request_states, prewarm, submission = _duplex_stage_port_submission() + + result = await port.submit(submission) + + assert result.stage_id == 0 + stage_pools[0].submit_initial.assert_awaited_once() + prewarm.assert_awaited_once_with("req-duplex", ANY, request_states["req-duplex"]) + + +@pytest.mark.asyncio +async def test_async_route_forwards_to_outgoing_only_stage() -> None: + orchestrator = object.__new__(Orchestrator) + orchestrator.async_chunk = True + orchestrator._pd_pair = None + orchestrator._cfg_tracker = SimpleNamespace( + is_companion=lambda _request_id: False, + has_companions=lambda _request_id: False, + ) + stage0 = SimpleNamespace(final_output=False) + stage1 = FakePrewarmPool("sender") + orchestrator.stage_pools = [stage0, stage1] + orchestrator._forward_to_next_stage = AsyncMock() + req_state = OrchestratorRequestState( + request_id="req-route", + sampling_params_list=[SamplingParams(max_tokens=1) for _ in range(2)], + final_stage_id=1, + ) + req_state.stage_submit_ts[0] = 1.0 + output = SimpleNamespace(request_id="req-route", finished=True) + + await orchestrator._route_output(0, 0, output, req_state, None) + + orchestrator._forward_to_next_stage.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_streaming_segment_does_not_complete_final_output_stage() -> None: + orchestrator = object.__new__(Orchestrator) + orchestrator.async_chunk = True + orchestrator._pd_pair = None + orchestrator._cfg_tracker = SimpleNamespace( + is_companion=lambda _request_id: False, + has_companions=lambda _request_id: False, + cleanup_parent=lambda _request_id: [], + ) + orchestrator.stage_pools = [SimpleNamespace(final_output=True)] + orchestrator.output_async_queue = asyncio.Queue() + orchestrator._cleanup_request_ids = AsyncMock() + + req_state = OrchestratorRequestState( + request_id="req-segment-final-output", + sampling_params_list=[SamplingParams(max_tokens=1)], + final_stage_id=0, + final_output_stage_ids={0}, + ) + req_state.streaming.enabled = True + req_state.streaming.segment_finished = True + output = SimpleNamespace( + request_id=req_state.request_id, + finished=True, + ) + + await orchestrator._route_output(0, 0, output, req_state, None) + + assert req_state.finished_final_output_stage_ids == set() + orchestrator._cleanup_request_ids.assert_not_awaited() + routed = orchestrator.output_async_queue.get_nowait() + assert routed.finished is False diff --git a/tests/engine/test_output_processor.py b/tests/engine/test_output_processor.py index 86e71fcbc..23ac601b3 100644 --- a/tests/engine/test_output_processor.py +++ b/tests/engine/test_output_processor.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Regression tests for OmniRequestState multimodal DELTA drain and consolidation guard.""" +from types import SimpleNamespace from unittest.mock import MagicMock import numpy as np @@ -9,10 +10,14 @@ import pytest import torch from vllm.outputs import PoolingRequestOutput from vllm.sampling_params import RequestOutputKind -from vllm.v1.engine import FinishReason +from vllm.v1.engine import EngineCoreEvent, EngineCoreEventType, FinishReason +from vllm.v1.engine.output_processor import OutputProcessor as VLLMOutputProcessor +from vllm.v1.metrics.stats import IterationStats, PrefillStats -from vllm_omni.outputs.output_modality import OutputModalityNames -from vllm_omni.outputs.output_processor import OmniRequestState +from vllm_omni.engine import OmniEngineCoreOutput +from vllm_omni.outputs import output_processor +from vllm_omni.outputs.output_modality import OutputModality, OutputModalityNames +from vllm_omni.outputs.output_processor import MultimodalOutputProcessor, OmniRequestState pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -56,12 +61,75 @@ def _make_state(output_kind: RequestOutputKind): return OmniRequestState(**_DEFAULT_STATE_KWARGS, output_kind=output_kind) +def test_output_modality_name_uses_string_value(): + assert str(OutputModalityNames.AUDIO) == "audio" + + def test_init_empty_dict(): """Ensure mm_accumulated is initially empty.""" assert _make_state(RequestOutputKind.CUMULATIVE).mm_accumulated == {} assert _make_state(RequestOutputKind.DELTA).mm_accumulated == {} +def test_streaming_update_resets_native_text_metrics_for_next_segment(): + state = _make_state(RequestOutputKind.DELTA) + state.native_text_stats.num_generation_tokens = 12 + state.native_text_stats.first_token_ts = 10.0 + state.native_text_stats.last_token_ts = 10.2 + + state.apply_streaming_update( + SimpleNamespace( + final=False, + prompt=None, + prompt_token_ids=[], + arrival_time=20.0, + ) + ) + + assert state.native_text_stats.arrival_time == 20.0 + assert state.native_text_stats.num_generation_tokens == 0 + assert state.native_text_stats.first_token_ts == 0.0 + assert state.native_text_stats.last_token_ts == 0.0 + + +def test_native_text_tpot_snapshot_matches_vllm_finished_metric_definition(): + calculate = getattr(output_processor, "_mean_time_per_output_token_ms", None) + assert calculate is not None + stats = SimpleNamespace( + num_generation_tokens=4, + first_token_ts=10.0, + last_token_ts=10.045, + ) + + assert calculate(stats) == pytest.approx(15.0) + + +def test_native_text_metrics_include_segment_generation_token_count(monkeypatch): + monkeypatch.setattr(VLLMOutputProcessor, "_update_stats_from_output", lambda *args, **kwargs: None) + processor = object.__new__(MultimodalOutputProcessor) + processor._native_text_metrics_by_request = {} + processor.lora_states = {} + state = _make_state(RequestOutputKind.DELTA) + state.is_prefilling = False + iteration_stats = MagicMock() + + def update_native_stats(_output, _timestamp, _was_prefilling, native_stats, *_args): + native_stats.num_generation_tokens = 27 + native_stats.first_token_ts = 10.0 + native_stats.last_token_ts = 10.26 + + iteration_stats.update_from_output.side_effect = update_native_stats + + processor._update_stats_from_output( + state, + SimpleNamespace(), + 10.26, + iteration_stats, + ) + + assert processor.pop_native_text_metrics("r")["num_generation_tokens"] == 27 + + def test_delta_drains_output_modality_per_step(): """DELTA drains the mm_type key (output modality) but preserves hidden-state keys.""" s = _make_state(RequestOutputKind.DELTA) @@ -172,6 +240,145 @@ def test_finish_consolidation_drains_mm_delta(): assert AUDIO not in s.mm_accumulated # drained +def test_delta_drains_audio_chunk_metadata_per_step(): + s = _make_state(RequestOutputKind.DELTA) + segment_text = "你好" + segment_utf8 = torch.tensor(list(segment_text.encode("utf-8")), dtype=torch.uint8) + + s.add_multimodal_tensor( + { + "model_outputs": torch.ones(5), + "meta.duplex_epoch": torch.tensor([7], dtype=torch.int32), + "meta.duplex_turn_id": torch.tensor([1], dtype=torch.int32), + "meta.llm_output_text_utf8": segment_utf8, + "meta.audio_text_total_chars": torch.tensor([len(segment_text)], dtype=torch.int32), + "meta.tts_is_last_chunk": torch.tensor([0], dtype=torch.int32), + }, + mm_type=AUDIO, + ) + result1 = s.make_request_output([1], None, FinishReason.STOP, None) + assert result1 is not None and not isinstance(result1, PoolingRequestOutput) + meta1 = result1.outputs[0].multimodal_output["meta"] + assert bytes(meta1["llm_output_text_utf8"].tolist()).decode("utf-8") == segment_text + + s.add_multimodal_tensor( + { + "model_outputs": torch.ones(3), + "meta.duplex_epoch": torch.tensor([8], dtype=torch.int32), + "meta.duplex_turn_id": torch.tensor([2], dtype=torch.int32), + "meta.llm_output_text_utf8": segment_utf8, + "meta.audio_text_total_chars": torch.tensor([len(segment_text)], dtype=torch.int32), + "meta.tts_is_last_chunk": torch.tensor([1], dtype=torch.int32), + }, + mm_type=AUDIO, + ) + result2 = s.make_request_output([2], None, FinishReason.STOP, None) + + assert result2 is not None and not isinstance(result2, PoolingRequestOutput) + meta2 = result2.outputs[0].multimodal_output["meta"] + assert bytes(meta2["llm_output_text_utf8"].tolist()).decode("utf-8") == segment_text + assert meta2["audio_text_total_chars"].tolist() == [len(segment_text)] + assert meta2["duplex_epoch"].tolist() == [8] + assert meta2["duplex_turn_id"].tolist() == [2] + + +def test_cumulative_audio_replaces_chunk_metadata_per_step(): + s = _make_state(RequestOutputKind.CUMULATIVE) + segment_text = "你好" + segment_utf8 = torch.tensor(list(segment_text.encode("utf-8")), dtype=torch.uint8) + + s.add_multimodal_tensor( + { + "model_outputs": torch.ones(5), + "meta.duplex_epoch": torch.tensor([7], dtype=torch.int32), + "meta.duplex_turn_id": torch.tensor([1], dtype=torch.int32), + "meta.llm_output_text_utf8": segment_utf8, + "meta.audio_text_total_chars": torch.tensor([len(segment_text)], dtype=torch.int32), + "meta.tts_is_last_chunk": torch.tensor([0], dtype=torch.int32), + }, + mm_type=AUDIO, + ) + result1 = s.make_request_output([1], None, None, None) + assert result1 is not None and not isinstance(result1, PoolingRequestOutput) + assert result1.outputs[0].multimodal_output["audio"].shape[0] == 5 + + s.add_multimodal_tensor( + { + "model_outputs": torch.ones(3), + "meta.duplex_epoch": torch.tensor([8], dtype=torch.int32), + "meta.duplex_turn_id": torch.tensor([2], dtype=torch.int32), + "meta.llm_output_text_utf8": segment_utf8, + "meta.audio_text_total_chars": torch.tensor([len(segment_text)], dtype=torch.int32), + "meta.tts_is_last_chunk": torch.tensor([1], dtype=torch.int32), + }, + mm_type=AUDIO, + ) + result2 = s.make_request_output([2], None, None, None) + + assert result2 is not None and not isinstance(result2, PoolingRequestOutput) + output = result2.outputs[0].multimodal_output + assert output["audio"].shape[0] == 8 + meta = output["meta"] + assert bytes(meta["llm_output_text_utf8"].tolist()).decode("utf-8") == segment_text + assert meta["audio_text_total_chars"].tolist() == [len(segment_text)] + assert meta["duplex_epoch"].tolist() == [8] + assert meta["duplex_turn_id"].tolist() == [2] + assert meta["tts_is_last_chunk"].tolist() == [1] + + +def test_delta_audio_non_final_tts_chunk_overrides_spurious_finish(): + s = _make_state(RequestOutputKind.DELTA) + s.add_multimodal_tensor( + { + "model_outputs": torch.ones(5), + "meta.tts_is_last_chunk": torch.tensor([0]), + }, + mm_type=AUDIO, + ) + + result = s.make_request_output([1], None, FinishReason.STOP, None) + + assert result is not None and not isinstance(result, PoolingRequestOutput) + assert result.finished is False + assert result.outputs[0].finish_reason is None + assert result.outputs[0].multimodal_output["meta"]["tts_is_last_chunk"].tolist() == [0] + assert torch.equal(result.outputs[0].multimodal_output[AUDIO], torch.ones(5)) + + +def test_delta_audio_final_tts_chunk_keeps_finish(): + s = _make_state(RequestOutputKind.DELTA) + s.add_multimodal_tensor( + { + "model_outputs": torch.ones(5), + "meta.tts_is_last_chunk": torch.tensor([1]), + }, + mm_type=AUDIO, + ) + + result = s.make_request_output([151645], None, FinishReason.STOP, None) + + assert result is not None and not isinstance(result, PoolingRequestOutput) + assert result.finished is True + assert result.outputs[0].finish_reason == "stop" + + +def test_delta_audio_non_final_tts_chunk_accepts_nested_meta(): + s = _make_state(RequestOutputKind.DELTA) + s.add_multimodal_tensor( + { + AUDIO: torch.ones(5), + "meta": {"tts_is_last_chunk": torch.tensor([0])}, + }, + mm_type=AUDIO, + ) + + result = s.make_request_output([1], None, FinishReason.STOP, None) + + assert result is not None and not isinstance(result, PoolingRequestOutput) + assert result.finished is False + assert result.outputs[0].multimodal_output["meta"]["tts_is_last_chunk"].tolist() == [0] + + @pytest.mark.parametrize("mm_type", [AUDIO, "hidden"]) def test_final_only_consolidates_drainable_keys(mm_type): """FINAL_ONLY never drains per-step, so modality keys and hidden state @@ -251,6 +458,127 @@ def test_no_detokenizer_make_request_output(): assert AUDIO in result.outputs[0].multimodal_output +def test_no_detokenizer_process_outputs_returns_nonterminal_audio_chunk(monkeypatch): + """Generation-stage MM-only chunks must reach the orchestrator. + + StagePool registers generation requests without a per-request collector, + so the processor return value is the only path from Stage2 to + Orchestrator._handle_processed_outputs(). + """ + state = _make_no_detok_state(RequestOutputKind.DELTA) + processor = object.__new__(MultimodalOutputProcessor) + processor.output_modality = OutputModality.AUDIO + processor.request_states = {"r": state} + upstream_result = SimpleNamespace(request_outputs=[], reqs_to_abort=[]) + monkeypatch.setattr( + VLLMOutputProcessor, + "process_outputs", + lambda *_args, **_kwargs: upstream_result, + ) + engine_output = SimpleNamespace( + request_id="r", + multimodal_output={ + "model_outputs": torch.arange(8, dtype=torch.float32), + "sr": torch.tensor(24000, dtype=torch.int32), + }, + output_type="audio", + pooling_output=None, + new_token_ids=[], + finish_reason=None, + stop_reason=None, + kv_transfer_params=None, + routed_experts=None, + num_cached_tokens=0, + ) + + result = processor.process_outputs([engine_output]) + + assert len(result.request_outputs) == 1 + assert result.request_outputs[0].request_id == "r" + assert AUDIO in result.request_outputs[0].outputs[0].multimodal_output + + +def _make_mm_only_output_processor(monkeypatch): + processor = object.__new__(MultimodalOutputProcessor) + processor.output_modality = OutputModality.AUDIO + processor.request_states = {"r": _make_no_detok_state(RequestOutputKind.DELTA)} + monkeypatch.setattr( + VLLMOutputProcessor, + "process_outputs", + lambda *_args, **_kwargs: SimpleNamespace( + request_outputs=[], + reqs_to_abort=[], + ), + ) + + def finish_request(req_state): + processor.request_states.pop(req_state.request_id, None) + + processor._finish_request = finish_request + return processor + + +def _audio_engine_output(*, is_segment_finished: bool, is_last_chunk: bool): + return SimpleNamespace( + request_id="r", + multimodal_output={ + "model_outputs": torch.arange(8, dtype=torch.float32), + "meta.tts_is_last_chunk": torch.tensor([int(is_last_chunk)]), + }, + output_type="audio", + pooling_output=None, + new_token_ids=[], + finish_reason=FinishReason.STOP, + stop_reason=None, + kv_transfer_params=None, + routed_experts=None, + num_cached_tokens=0, + is_segment_finished=is_segment_finished, + ) + + +def test_mm_only_segment_finish_retains_request_state_for_next_audio_chunk(monkeypatch): + processor = _make_mm_only_output_processor(monkeypatch) + + first = processor.process_outputs([_audio_engine_output(is_segment_finished=True, is_last_chunk=False)]) + + assert "r" in processor.request_states + assert len(first.request_outputs) == 1 + assert first.request_outputs[0].finished is False + + second = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)]) + + assert len(second.request_outputs) == 1 + assert second.request_outputs[0].finished is True + assert "r" not in processor.request_states + + +def test_mm_only_nonfinal_audio_retains_state_without_segment_marker(monkeypatch): + processor = _make_mm_only_output_processor(monkeypatch) + + first = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=False)]) + + assert "r" in processor.request_states + assert len(first.request_outputs) == 1 + assert first.request_outputs[0].finished is False + + second = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)]) + + assert len(second.request_outputs) == 1 + assert second.request_outputs[0].finished is True + assert "r" not in processor.request_states + + +def test_mm_only_terminal_finish_removes_request_state(monkeypatch): + processor = _make_mm_only_output_processor(monkeypatch) + + result = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)]) + + assert len(result.request_outputs) == 1 + assert result.request_outputs[0].finished is True + assert "r" not in processor.request_states + + def test_no_detokenizer_make_request_output_with_routed_experts(): """make_request_output accepts the routed_experts arg that the multimodal output channel (_process_mm_only_outputs) passes positionally, and attaches @@ -290,3 +618,48 @@ def test_no_detokenizer_final_only(): result = s.make_request_output([], None, FinishReason.STOP, None) assert result is not None assert AUDIO in result.outputs[0].multimodal_output + + +def test_mm_only_outputs_update_iteration_stats(): + processor = MultimodalOutputProcessor( + tokenizer=None, + log_stats=True, + engine_core_output_type=AUDIO, + ) + state = _make_no_detok_state(RequestOutputKind.CUMULATIVE) + processor.request_states[state.request_id] = state + processor.external_req_ids[state.external_req_id].append(state.request_id) + + prefill_stats = PrefillStats() + prefill_stats.set( + num_prompt_tokens=4, + num_local_cached_tokens=0, + num_external_cached_tokens=0, + ) + output = OmniEngineCoreOutput( + request_id=state.request_id, + new_token_ids=[10, 11], + finish_reason=FinishReason.STOP, + events=[ + EngineCoreEvent(EngineCoreEventType.QUEUED, 1.0), + EngineCoreEvent(EngineCoreEventType.SCHEDULED, 2.0), + ], + prefill_stats=prefill_stats, + multimodal_output={AUDIO: torch.ones(1, 8)}, + ) + iteration_stats = IterationStats() + + processor.process_outputs( + [output], + engine_core_timestamp=3.0, + iteration_stats=iteration_stats, + ) + + assert iteration_stats.num_prompt_tokens == 4 + assert iteration_stats.num_generation_tokens == 2 + assert len(iteration_stats.time_to_first_tokens_iter) == 1 + assert len(iteration_stats.finished_requests) == 1 + finished = iteration_stats.finished_requests[0] + assert finished.finish_reason == FinishReason.STOP + assert finished.num_prompt_tokens == state.prompt_len + assert finished.num_generation_tokens == 2 diff --git a/tests/engine/test_rpc_result_router.py b/tests/engine/test_rpc_result_router.py new file mode 100644 index 000000000..ec983392b --- /dev/null +++ b/tests/engine/test_rpc_result_router.py @@ -0,0 +1,109 @@ +import queue +from typing import Literal + +import pytest + +from vllm_omni.engine.messages import ( + CollectiveRPCResultMessage, + EngineQueueMessage, + ErrorMessage, +) +from vllm_omni.engine.rpc_result_router import RpcResultRouter + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class GenericCorrelatedResultMessage(EngineQueueMessage, kw_only=True): + type: Literal["generic_correlated_result"] = "generic_correlated_result" + namespace: str + correlation_id: str + + @property + def rpc_correlation_key(self) -> tuple[str, str]: + return (self.namespace, self.correlation_id) + + +def _generic_result(correlation_id: str) -> GenericCorrelatedResultMessage: + return GenericCorrelatedResultMessage( + namespace="plugin", + correlation_id=correlation_id, + ) + + +def test_rpc_result_router_routes_out_of_order_results_by_correlation_id(): + source: queue.Queue = queue.Queue() + router = RpcResultRouter(source) + first = router.register(("plugin", "first")) + second = router.register(("plugin", "second")) + + source.put(_generic_result("second")) + source.put(_generic_result("first")) + + assert first.get(timeout=1).correlation_id == "first" + assert second.get(timeout=1).correlation_id == "second" + router.close() + + +def test_rpc_result_router_drops_only_the_late_result_after_unregister(): + source: queue.Queue = queue.Queue() + router = RpcResultRouter(source) + expired = router.register(("plugin", "expired")) + active = router.register(("collective", "active")) + router.unregister(("plugin", "expired"), expired) + + source.put(_generic_result("expired")) + source.put( + CollectiveRPCResultMessage( + rpc_id="active", + method="health", + stage_ids=[0], + results=["ok"], + ) + ) + + assert active.get(timeout=1).rpc_id == "active" + assert expired.empty() + router.close() + + +def test_rpc_result_router_broadcasts_fatal_errors_to_pending_waiters(): + source: queue.Queue = queue.Queue() + router = RpcResultRouter(source) + plugin = router.register(("plugin", "one")) + collective = router.register(("collective", "two")) + + source.put(ErrorMessage(error="orchestrator failed", fatal=True)) + + assert plugin.get(timeout=1).error == "orchestrator failed" + assert collective.get(timeout=1).error == "orchestrator failed" + with pytest.raises(RuntimeError, match="orchestrator failed"): + router.register(("plugin", "after-failure")) + router.close() + + +def test_rpc_result_router_does_not_broadcast_uncorrelated_nonfatal_errors(): + source: queue.Queue = queue.Queue() + router = RpcResultRouter(source) + waiter = router.register(("plugin", "active")) + + source.put(ErrorMessage(error="request failed", fatal=False, request_id="other")) + source.put(_generic_result("active")) + + assert waiter.get(timeout=1).correlation_id == "active" + router.close() + + +def test_rpc_result_router_close_unblocks_waiters_and_stops_consumer(): + source: queue.Queue = queue.Queue() + router = RpcResultRouter(source) + waiter = router.register(("plugin", "pending")) + + router.close() + + result = waiter.get(timeout=1) + assert isinstance(result, ErrorMessage) + assert result.fatal is True + assert result.error == "RPC result router closed" + assert not router._thread.is_alive() + with pytest.raises(RuntimeError, match="router is closed"): + router.register(("plugin", "after-close")) diff --git a/tests/engine/test_single_stage_mode.py b/tests/engine/test_single_stage_mode.py index dc06c0c8d..bde8ba490 100644 --- a/tests/engine/test_single_stage_mode.py +++ b/tests/engine/test_single_stage_mode.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import socket from contextlib import contextmanager from types import SimpleNamespace from typing import Any @@ -11,6 +12,8 @@ import pytest from pytest_mock import MockerFixture from vllm.v1.engine.utils import EngineZmqAddresses +from vllm_omni.config.config_factory import StageConfigFactory +from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig from vllm_omni.engine.async_omni_engine import AsyncOmniEngine from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClientBase from vllm_omni.engine.stage_engine_startup import ( @@ -189,6 +192,34 @@ class TestOmniMasterServerAllocation: output_port = int(alloc.output_bind_address.split(":")[-1]) assert len({handshake_port, input_port, output_port}) == 3 + def test_route_ports_remain_os_reserved_until_master_releases_them(self): + server = OmniMasterServer(master_address="127.0.0.1", master_port=15005, stage_ids=[0]) + alloc = server.get_allocation(0) + ports = [ + int(alloc.handshake_bind_address.rsplit(":", 1)[-1]), + int(alloc.input_bind_address.rsplit(":", 1)[-1]), + int(alloc.output_bind_address.rsplit(":", 1)[-1]), + ] + + for port in ports: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + with pytest.raises(OSError): + probe.bind(("127.0.0.1", port)) + + server.release_route_port_reservations(0, handshake=True, data=False) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", ports[0])) + for port in ports[1:]: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + with pytest.raises(OSError): + probe.bind(("127.0.0.1", port)) + + server.stop() + + for port in ports[1:]: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", port)) + def test_get_zmq_addresses_returns_bind_addresses(self): server = OmniMasterServer(master_address="127.0.0.1", master_port=15002, stage_ids=[0]) alloc = server.get_allocation(0) @@ -397,6 +428,30 @@ class TestSingleStageModeDetection: assert engine.single_stage_mode is False assert engine._single_stage_id_filter is None + def test_stage_configs_path_loads_duplex_runtime_config(self, mocker: MockerFixture): + duplex_session = DuplexSessionRuntimeConfig(max_sessions=2) + get_pipeline_config = mocker.patch( + "vllm_omni.engine.async_omni_engine.StageConfigFactory.get_pipeline_config", + return_value=None, + ) + load_deploy_config = mocker.patch( + "vllm_omni.engine.async_omni_engine.load_deploy_config", + return_value=SimpleNamespace(duplex_session=duplex_session), + ) + + engine = self._make_engine_no_thread( + mocker, + stage_configs_path="/fake/duplex.yaml", + ) + + get_pipeline_config.assert_called_once_with( + model="fake-model", + trust_remote_code=False, + deploy_config_path="/fake/duplex.yaml", + ) + load_deploy_config.assert_called_once_with("/fake/duplex.yaml") + assert engine.duplex_session_config is duplex_session + def test_single_stage_mode_without_stage_id_has_no_filter(self, mocker: MockerFixture): engine = self._make_engine_no_thread( mocker, @@ -422,6 +477,89 @@ class TestSingleStageModeDetection: assert not hasattr(engine, "_omni_master_server") +# --------------------------------------------------------------------------- +# Endpoint-restriction resolution vs. tri-state trust_remote_code (#5495) +# --------------------------------------------------------------------------- + + +class TestEndpointRestrictionsTrustRemoteCode: + """Regression tests for issue #5495. + + ``AsyncOmniEngine.__init__`` takes ``trust_remote_code: bool | None`` where + ``None`` means "not specified" (the CLI default, since ``--trust-remote-code`` + is ``store_true`` and its absence is mapped to ``None``). Endpoint-restriction + resolution eagerly loads the HF config via vLLM's ``get_config``, which does + ``trust_remote_code |= ...`` internally and raises ``TypeError`` on ``None``. + That used to be swallowed, leaving ``endpoint_restrictions`` empty so + ``/v1/completions`` was never shut down and the request crashed stage-1. + + ``__init__`` must collapse the ``None`` "not specified" case to ``False`` at + the ``get_pipeline_config`` call site so the restriction is still computed. + """ + + @pytest.fixture(autouse=True) + def clear_config_factory_caches(self): + """Isolate the process-wide ``functools.cache`` on StageConfigFactory. + + ``get_hf_config`` / ``try_infer_model_type`` cache per-model results for + the whole process. These tests patch ``get_config`` with a synthetic + Qwen3-Omni config for ``"fake-model"``; without clearing on teardown the + synthetic entry would leak to later tests in the same worker (after the + mock is restored), making the suite order-dependent. Clear both before + and after. + """ + StageConfigFactory.get_hf_config.cache_clear() + StageConfigFactory.try_infer_model_type.cache_clear() + yield + StageConfigFactory.get_hf_config.cache_clear() + StageConfigFactory.try_infer_model_type.cache_clear() + + def _make_engine_no_thread(self, mocker: MockerFixture, **kwargs: Any) -> AsyncOmniEngine: + mocker.patch.object( + AsyncOmniEngine, + "_resolve_stage_configs", + return_value=("/fake/path", [_make_stage_cfg(0)]), + ) + mocker.patch.object(AsyncOmniEngine, "_bootstrap_orchestrator") + mocker.patch("threading.Thread") + mocker.patch("concurrent.futures.Future") + return AsyncOmniEngine(model="fake-model", **kwargs) + + @staticmethod + def _install_qwen3_omni_config(mocker: MockerFixture) -> None: + """Patch get_config to a thinker+talker config, mirroring vLLM's + ``trust_remote_code |= ...`` (raises TypeError on None).""" + from transformers import Qwen3OmniMoeConfig + + def fake_get_config(model, trust_remote_code, **_): + trust_remote_code |= False # TypeError if None reaches here + return Qwen3OmniMoeConfig(enable_audio_output=True) + + mocker.patch("vllm_omni.config.config_factory.get_config", side_effect=fake_get_config) + + @pytest.mark.parametrize("trc_kwargs", [{}, {"trust_remote_code": False}, {"trust_remote_code": True}]) + def test_completions_restriction_survives(self, mocker: MockerFixture, trc_kwargs: dict): + """COMPLETIONS restriction is computed regardless of how (or whether) + trust_remote_code is passed — including the unset case that resolves to + the engine's ``None`` default.""" + from vllm_omni.config.endpoint_policy import OmniServingCapability + + self._install_qwen3_omni_config(mocker) + engine = self._make_engine_no_thread(mocker, **trc_kwargs) + assert any(r.capability is OmniServingCapability.COMPLETIONS for r in engine.endpoint_restrictions), ( + f"COMPLETIONS restriction dropped for {trc_kwargs or 'unset (None default)'}" + ) + + def test_unset_trust_remote_code_defaults_to_none(self, mocker: MockerFixture): + """Guard the tri-state premise: the engine keeps ``None`` (not ``False``) + when trust_remote_code is not passed, so the call-site coercion is what + prevents the crash.""" + import inspect + + default = inspect.signature(AsyncOmniEngine.__init__).parameters["trust_remote_code"].default + assert default is None + + # --------------------------------------------------------------------------- # AsyncOmniEngine single-stage initialization paths # --------------------------------------------------------------------------- @@ -450,7 +588,7 @@ class TestSingleStageInitialization: monkeypatch = pytest.MonkeyPatch() monkeypatch.setattr( runtime_mod, - "extract_stage_metadata", + "extract_legacy_stage_metadata", lambda cfg: SimpleNamespace( stage_id=cfg.stage_id, stage_type=getattr(cfg, "stage_type", "llm"), @@ -477,7 +615,7 @@ class TestSingleStageInitialization: monkeypatch = pytest.MonkeyPatch() monkeypatch.setattr( runtime_mod, - "extract_stage_metadata", + "extract_legacy_stage_metadata", lambda cfg: SimpleNamespace( stage_id=cfg.stage_id, stage_type=getattr(cfg, "stage_type", "llm"), @@ -550,7 +688,7 @@ class TestSingleStageInitialization: monkeypatch = pytest.MonkeyPatch() monkeypatch.setattr( runtime_mod, - "extract_stage_metadata", + "extract_legacy_stage_metadata", lambda cfg: SimpleNamespace( stage_id=cfg.stage_id, stage_type="llm", @@ -609,7 +747,7 @@ class TestSingleStageInitialization: monkeypatch = pytest.MonkeyPatch() monkeypatch.setattr( runtime_mod, - "extract_stage_metadata", + "extract_legacy_stage_metadata", lambda cfg: SimpleNamespace( stage_id=cfg.stage_id, stage_type="diffusion", @@ -724,6 +862,7 @@ class TestSingleStageReplicaInitialization: single_stage_id_filter=None, omni_master_address="127.0.0.1", omni_master_port=26000, + log_stats=True, ) runtime._omni_master_server = mocker.Mock(spec=OmniMasterServer) runtime._omni_master_server.get_stage_config.return_value = {"stage_id": 1, "stage_type": "llm"} @@ -757,10 +896,17 @@ class TestSingleStageReplicaInitialization: sentinel_client = SimpleNamespace() mock_connect = mocker.patch.object(runtime_mod, "connect_remote_engine_cores", side_effect=_fake_connect) + client_kwargs: dict[str, Any] = {} + + def _capture_make_async_mp_client(**kwargs): + client_kwargs.update(kwargs) + events.append("attach") + return sentinel_client + mocker.patch.object( StageEngineCoreClientBase, "make_async_mp_client", - side_effect=lambda **_: (events.append("attach"), sentinel_client)[1], + side_effect=_capture_make_async_mp_client, ) result = runtime._initialize_remote_replica(plan, stage_init_timeout=60) @@ -770,6 +916,7 @@ class TestSingleStageReplicaInitialization: assert mock_connect.call_args.kwargs["vllm_config"].parallel_config.data_parallel_size_local == 0 assert mock_connect.call_args.kwargs["stage_id"] == 1 assert mock_connect.call_args.kwargs["replica_id"] == 0 + assert client_kwargs["log_stats"] is True assert events == ["enter", "exit", "attach"] def test_initialize_llm_replica_remote_missing_registered_stage_config_raises(self, mocker: MockerFixture): @@ -943,7 +1090,7 @@ class TestSingleStageReplicaInitialization: plan = _make_diffusion_plan(1, stage_id=1, launch_mode="remote").replicas[0] sentinel_client = SimpleNamespace() - mocker.patch.object(runtime_mod, "extract_stage_metadata", return_value=remote_metadata) + mocker.patch.object(runtime_mod, "extract_legacy_stage_metadata", return_value=remote_metadata) mock_connect = mocker.patch.object(runtime_mod, "connect_remote_diffusion_proc", side_effect=_fake_connect) mock_from_addresses = mocker.patch( "vllm_omni.diffusion.stage_diffusion_client.StageDiffusionClient.from_addresses", diff --git a/tests/engine/test_stage_engine_args.py b/tests/engine/test_stage_engine_args.py new file mode 100644 index 000000000..75660cbc7 --- /dev/null +++ b/tests/engine/test_stage_engine_args.py @@ -0,0 +1,510 @@ +from __future__ import annotations + +import copy +import sys +import types +from dataclasses import fields, replace +from pathlib import Path + +import pytest +from pydantic.fields import FieldInfo + +from vllm_omni.config.omni_config import VllmOmniConfig +from vllm_omni.config.pipeline_registry import OMNI_PIPELINES, resolve_pipeline_config +from vllm_omni.config.stage_config import ( + DeployConfig, + PipelineConfig, + StageDeployConfig, + StageExecutionType, + StagePipelineConfig, + StageType, + build_stage_runtime_overrides, + load_deploy_config, + merge_pipeline_deploy, +) +from vllm_omni.diffusion.data import AttentionConfig, OmniDiffusionConfig +from vllm_omni.engine import stage_init_utils +from vllm_omni.engine.arg_utils import OmniEngineArgs +from vllm_omni.engine.stage_init_utils import ( + build_engine_args_dict, + build_engine_args_dict_from_omni_stage_config, + build_legacy_engine_args_dict, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +_DEPLOY_DIR = Path(__file__).parents[2] / "vllm_omni" / "deploy" + + +def _effective_backend_values(config_cls: type, engine_args: dict) -> dict[str, object]: + backend_fields = fields(config_cls) + backend_field_names = {backend_field.name for backend_field in backend_fields} + backend_config = config_cls( + **{name: copy.deepcopy(value) for name, value in engine_args.items() if name in backend_field_names} + ) + effective_values: dict[str, object] = {} + for backend_field in backend_fields: + value = getattr(backend_config, backend_field.name) + if isinstance(value, FieldInfo): + value = value.get_default(call_default_factory=True) + effective_values[backend_field.name] = value + return effective_values + + +_LLM_BACKEND_FIELDS = frozenset(field.name for field in fields(OmniEngineArgs)) +_DIFFUSION_BACKEND_FIELDS = frozenset(field.name for field in fields(OmniDiffusionConfig)) + + +@pytest.fixture(autouse=True) +def _stable_engine_arg_environment(monkeypatch, tmp_path): + from vllm_omni import platforms + + monkeypatch.delenv("VLLM_USE_FLASHINFER_MOE_FP16", raising=False) + xcodec_model = tmp_path / "xcodec-model" + xcodec_model.mkdir() + monkeypatch.setenv("XCODEC1_PATH", str(xcodec_model)) + platform = platforms.current_omni_platform + monkeypatch.setattr(platform, "device_name", "cpu", raising=False) + monkeypatch.setattr(platform, "device_type", "cpu", raising=False) + monkeypatch.setattr(platform, "is_rocm", lambda: False) + monkeypatch.setattr( + OmniDiffusionConfig, + "_resolve_master_port", + lambda config: config.master_port or 29500, + ) + + def _resolve_test_worker(engine_args): + worker_type = engine_args.get("worker_type") + if worker_type is not None and engine_args.get("worker_cls") in (None, "auto"): + engine_args["worker_cls"] = f"test.{worker_type}.Worker" + + monkeypatch.setattr(stage_init_utils, "resolve_worker_cls", _resolve_test_worker) + + +def _engine_arg_inputs(tmp_path: Path) -> tuple[PipelineConfig, DeployConfig, str]: + parent_model = tmp_path / "parent-model" + stage_model = tmp_path / "stage-model" + diffusion_model = tmp_path / "diffusion-model" + for root in (parent_model, stage_model, diffusion_model): + root.mkdir() + (stage_model / "ar-model").mkdir() + (stage_model / "ar-tokenizer").mkdir() + + pipeline = PipelineConfig( + model_type="typed-engine-args", + model_arch="PipelineModel", + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="thinker", + execution_type=StageExecutionType.LLM_AR, + requires_multimodal_data=True, + hf_config_name="thinker_config", + engine_output_type="hidden_states", + model_arch="Qwen3OmniMoeForConditionalGeneration", + model_subdir="ar-model", + tokenizer_subdir="ar-tokenizer", + retains_state_across_chunks=True, + async_chunk_process_next_stage_input_func="operator.add", + ), + StagePipelineConfig( + stage_id=1, + model_stage="talker", + execution_type=StageExecutionType.LLM_GENERATION, + input_sources=(0,), + engine_output_type="audio_tokens", + ), + StagePipelineConfig( + stage_id=2, + model_stage="dit", + execution_type=StageExecutionType.DIFFUSION, + input_sources=(1,), + final_output=True, + final_output_type="image", + model_arch="TypedDiffusionPipeline", + ), + ), + ) + deploy = DeployConfig( + async_chunk=True, + trust_remote_code=True, + dtype="float16", + distributed_executor_backend="mp", + stages=[ + StageDeployConfig( + stage_id=0, + tensor_parallel_size=2, + max_num_seqs=8, + default_sampling_params={"extra_args": {"voice": "test"}}, + engine_extras={ + "model": str(stage_model), + "attention_backend": "FLASHINFER", + "hf_overrides": {"rope_scaling": {"factor": 2}}, + "limit_mm_per_prompt": {"audio": 1}, + "logits_processors": ["test.custom.LogitsProcessor"], + "kv_cache_memory_bytes": 1024, + "omni_kv_config": {"need_send_cache": True}, + "max_cudagraph_capture_size": 0, + "worker_cls": "test.custom.Worker", + }, + ), + StageDeployConfig( + stage_id=1, + max_num_seqs=4, + ), + StageDeployConfig( + stage_id=2, + tensor_parallel_size=2, + vae_parallel_mode="spatial_shard_height", + diffusion_attention_config={ + "default": {"backend": "FLASH_ATTN"}, + "per_role": {"cross": {"backend": "TORCH_SDPA"}}, + }, + engine_extras={ + "model": str(diffusion_model), + "engine_backend": "test.diffusion.Engine", + "enable_session_state_manager": True, + }, + ), + ], + ) + return pipeline, deploy, str(parent_model) + + +def _legacy_and_typed_stages( + pipeline: PipelineConfig, + deploy: DeployConfig, + model: str, + cli_overrides: dict[str, object] | None = None, +): + resolved_cli_overrides = {"model": model, **(cli_overrides or {})} + legacy_stage_configs = merge_pipeline_deploy( + pipeline, + copy.deepcopy(deploy), + ) + for stage in legacy_stage_configs: + stage.runtime_overrides = build_stage_runtime_overrides( + stage.stage_id, + resolved_cli_overrides, + ) + legacy_stages = [stage.to_omegaconf() for stage in legacy_stage_configs] + omni_config = VllmOmniConfig.from_pipeline_config( + pipeline, + user_deploy_config=copy.deepcopy(deploy), + cli_overrides=resolved_cli_overrides, + ) + return legacy_stages, omni_config + + +def test_typed_llm_engine_args_preserve_legacy_adapter_behavior(tmp_path): + pipeline, deploy, model = _engine_arg_inputs(tmp_path) + legacy_stages, omni_config = _legacy_and_typed_stages(pipeline, deploy, model) + connector_spec = {"name": "SharedMemoryConnector", "extra": {"mode": "test"}} + cli_tokenizer = "/external/tokenizer" + + typed_args_by_stage = {} + for stage_id in (0, 1): + legacy_args = build_legacy_engine_args_dict( + legacy_stages[stage_id], + model, + stage_connector_spec=connector_spec, + cli_tokenizer=cli_tokenizer, + ) + typed_args = build_engine_args_dict_from_omni_stage_config( + omni_config.stage_by_id(stage_id), + model, + stage_connector_spec=connector_spec, + cli_tokenizer=cli_tokenizer, + ) + typed_args_by_stage[stage_id] = typed_args + + assert {name: typed_args[name] for name in legacy_args} == legacy_args + + thinker_args = typed_args_by_stage[0] + assert thinker_args["model"] == str(tmp_path / "stage-model" / "ar-model") + assert thinker_args["tokenizer"] == str(tmp_path / "stage-model" / "ar-tokenizer") + assert thinker_args["stage_id"] == 0 + assert thinker_args["stage_connector_spec"] == connector_spec + assert thinker_args["has_sampling_extra_args"] is True + assert thinker_args["omni_kv_config"] == {"need_send_cache": True} + assert thinker_args["worker_cls"] == "test.custom.Worker" + assert thinker_args["attention_backend"] == "FLASHINFER" + assert thinker_args["trust_remote_code"] is True + assert thinker_args["dtype"] == "float16" + assert thinker_args["distributed_executor_backend"] == "mp" + assert thinker_args["tensor_parallel_size"] == 2 + assert thinker_args["hf_overrides"] == {"rope_scaling": {"factor": 2}} + assert thinker_args["limit_mm_per_prompt"] == {"audio": 1} + assert thinker_args["logits_processors"] == ["test.custom.LogitsProcessor"] + assert thinker_args["retains_state_across_chunks"] is True + assert thinker_args["kv_cache_memory_bytes"] == 1024 + assert thinker_args["max_cudagraph_capture_size"] == 0 + + talker_args = typed_args_by_stage[1] + assert talker_args["model"] == model + assert talker_args["tokenizer"] == cli_tokenizer + assert talker_args["worker_cls"] == "test.generation.Worker" + assert talker_args["disable_hybrid_kv_cache_manager"] is True + assert talker_args["enable_prefix_caching"] is False + + +def test_typed_diffusion_engine_args_use_structured_diffusion_config(tmp_path): + pipeline, deploy, model = _engine_arg_inputs(tmp_path) + legacy_stages, omni_config = _legacy_and_typed_stages(pipeline, deploy, model) + + legacy_args = build_legacy_engine_args_dict(legacy_stages[2], model) + typed_args = build_engine_args_dict_from_omni_stage_config( + omni_config.stage_by_id(2), + model, + ) + + assert typed_args["stage_id"] == legacy_args["stage_id"] == 2 + assert typed_args["model"] == legacy_args["model"] == str(tmp_path / "diffusion-model") + assert omni_config.stage_by_id(2).diffusion_config.model == str(tmp_path / "diffusion-model") + assert typed_args["model_stage"] == legacy_args["model_stage"] == "dit" + assert typed_args["model_arch"] == legacy_args["model_arch"] == "TypedDiffusionPipeline" + assert typed_args["model_class_name"] == legacy_args["model_class_name"] == "TypedDiffusionPipeline" + assert typed_args["engine_backend"] == legacy_args["engine_backend"] == "test.diffusion.Engine" + assert typed_args["enable_session_state_manager"] is legacy_args["enable_session_state_manager"] is True + assert typed_args["parallel_config"]["tensor_parallel_size"] == 2 + assert typed_args["parallel_config"]["vae_parallel_mode"] == "spatial_shard_height" + assert isinstance(typed_args["diffusion_attention_config"], AttentionConfig) + assert typed_args["diffusion_attention_config"].default.backend == "FLASH_ATTN" + assert typed_args["diffusion_attention_config"].per_role["cross"].backend == "TORCH_SDPA" + + +def test_typed_engine_args_preserve_explicit_backend_default_overrides(tmp_path): + pipeline, deploy, model = _engine_arg_inputs(tmp_path) + deploy.enable_prefix_caching = False + deploy.enable_chunked_prefill = False + thinker_deploy = deploy.stages[0] + thinker_deploy.gpu_memory_utilization = 0.75 + thinker_deploy.disable_hybrid_kv_cache_manager = False + thinker_deploy.async_scheduling = False + legacy_stages, omni_config = _legacy_and_typed_stages(pipeline, deploy, model) + + legacy_args = build_legacy_engine_args_dict(legacy_stages[0], model) + typed_args = build_engine_args_dict_from_omni_stage_config( + omni_config.stage_by_id(0), + model, + ) + expected = { + "gpu_memory_utilization": 0.75, + "enable_prefix_caching": False, + "disable_hybrid_kv_cache_manager": False, + "max_num_seqs": 8, + "enable_chunked_prefill": False, + "async_scheduling": False, + } + + assert {name: legacy_args[name] for name in expected} == expected + assert {name: typed_args[name] for name in expected} == expected + + +@pytest.mark.parametrize("stage_scoped", [False, True], ids=["global", "stage-scoped"]) +def test_typed_engine_args_preserve_inherited_model_and_load_cli_fields(tmp_path, stage_scoped): + pipeline, deploy, model = _engine_arg_inputs(tmp_path) + expected = { + "revision": "model-rev", + "tokenizer_revision": "tokenizer-rev", + "code_revision": "code-rev", + "seed": 123, + "download_dir": str(tmp_path / "downloads"), + } + cli_overrides = {f"stage_0_{name}" if stage_scoped else name: value for name, value in expected.items()} + legacy_stages, omni_config = _legacy_and_typed_stages( + pipeline, + deploy, + model, + cli_overrides, + ) + + legacy_args = build_legacy_engine_args_dict(legacy_stages[0], model) + typed_args = build_engine_args_dict_from_omni_stage_config( + omni_config.stage_by_id(0), + model, + ) + + assert {name: legacy_args[name] for name in expected} == expected + assert {name: typed_args[name] for name in expected} == expected + + +def test_typed_engine_args_preserve_deploy_subdirectory_precedence(tmp_path): + pipeline, deploy, model = _engine_arg_inputs(tmp_path) + stage_model = tmp_path / "stage-model" + (stage_model / "override-model").mkdir() + (stage_model / "override-tokenizer").mkdir() + deploy.stages[0].engine_extras.update( + { + "model_subdir": "override-model", + "tokenizer_subdir": "override-tokenizer", + } + ) + legacy_stages, omni_config = _legacy_and_typed_stages(pipeline, deploy, model) + + typed_stage = omni_config.stage_by_id(0) + legacy_args = build_legacy_engine_args_dict(legacy_stages[0], model) + typed_args = build_engine_args_dict_from_omni_stage_config(typed_stage, model) + + assert typed_stage.model_config.model_subdir == "override-model" + assert typed_stage.model_config.tokenizer_subdir == "override-tokenizer" + assert typed_args["model"] == legacy_args["model"] == str(stage_model / "override-model") + assert typed_args["tokenizer"] == legacy_args["tokenizer"] == str(stage_model / "override-tokenizer") + + +@pytest.mark.parametrize( + ("cli_overrides", "expected"), + [ + ({}, {"need_recv_cache": True}), + ( + {"stage_0_omni_kv_config": {"need_send_cache": False}}, + {"need_send_cache": False}, + ), + ], + ids=["topology-over-deploy", "cli-over-topology"], +) +def test_typed_engine_args_preserve_legacy_omni_kv_precedence(tmp_path, cli_overrides, expected): + pipeline, deploy, model = _engine_arg_inputs(tmp_path) + thinker = replace( + pipeline.stages[0], + omni_kv_config={"need_recv_cache": True}, + ) + pipeline = replace(pipeline, stages=(thinker, *pipeline.stages[1:])) + legacy_stages, omni_config = _legacy_and_typed_stages( + pipeline, + deploy, + model, + cli_overrides, + ) + + legacy_args = build_legacy_engine_args_dict(legacy_stages[0], model) + typed_stage = omni_config.stage_by_id(0) + typed_args = build_engine_args_dict_from_omni_stage_config(typed_stage, model) + + assert typed_stage.connector_config.omni_kv_config == expected + assert typed_args["omni_kv_config"] == legacy_args["omni_kv_config"] == expected + + +def test_build_engine_args_dict_preserves_legacy_api(tmp_path): + pipeline, deploy, model = _engine_arg_inputs(tmp_path) + legacy_stages, _ = _legacy_and_typed_stages(pipeline, deploy, model) + connector_spec = {"name": "SharedMemoryConnector", "extra": {}} + + compatibility_args = build_engine_args_dict( + copy.deepcopy(legacy_stages[0]), + model, + stage_connector_spec=connector_spec, + cli_tokenizer="/external/tokenizer", + ) + explicitly_legacy_args = build_legacy_engine_args_dict( + copy.deepcopy(legacy_stages[0]), + model, + stage_connector_spec=connector_spec, + cli_tokenizer="/external/tokenizer", + ) + + assert compatibility_args == explicitly_legacy_args + + +def test_legacy_engine_args_drop_none_tp_without_mutating_stage_config(): + stage_config = types.SimpleNamespace( + stage_id=0, + stage_type="llm", + engine_args={"tensor_parallel_size": None}, + default_sampling_params={}, + ) + + engine_args = build_legacy_engine_args_dict( + stage_config, + model="test-model", + ) + + assert "tensor_parallel_size" not in engine_args + assert stage_config.engine_args == {"tensor_parallel_size": None} + + +def test_typed_engine_args_own_rocm_attention_default(monkeypatch, tmp_path): + from vllm_omni import platforms + + pipeline, deploy, model = _engine_arg_inputs(tmp_path) + legacy_stages, omni_config = _legacy_and_typed_stages(pipeline, deploy, model) + monkeypatch.setattr(platforms.current_omni_platform, "is_rocm", lambda: True) + aiter_module = types.ModuleType("vllm._aiter_ops") + setattr( + aiter_module, + "rocm_aiter_ops", + types.SimpleNamespace(is_enabled=lambda: False), + ) + monkeypatch.setitem(sys.modules, "vllm._aiter_ops", aiter_module) + + legacy_args = build_legacy_engine_args_dict(legacy_stages[1], model) + typed_args = build_engine_args_dict_from_omni_stage_config( + omni_config.stage_by_id(1), + model, + ) + + assert "attention_backend" not in legacy_args + assert typed_args["attention_backend"] == "TRITON_ATTN" + + +def test_typed_ming_image_engine_args_defer_diffusion_batch_default(): + pipeline = resolve_pipeline_config("ming_flash_omni_image") + assert pipeline is not None + deploy = load_deploy_config(_DEPLOY_DIR / "ming_flash_omni_image.yaml") + legacy_stages, omni_config = _legacy_and_typed_stages( + pipeline, + deploy, + model="/tmp", + ) + + legacy_args = build_legacy_engine_args_dict(legacy_stages[1], model="/tmp") + typed_args = build_engine_args_dict_from_omni_stage_config( + omni_config.stage_by_id(1), + model="/tmp", + ) + typed_backend_args = {name: value for name, value in typed_args.items() if name in _DIFFUSION_BACKEND_FIELDS} + + assert "max_num_seqs" not in legacy_args + assert "max_num_seqs" not in typed_args + assert OmniDiffusionConfig(**typed_backend_args).max_num_seqs == 1 + + +@pytest.mark.parametrize("model_type", sorted(OMNI_PIPELINES)) +def test_typed_engine_args_match_current_registry_backend_semantics(model_type): + pipeline = resolve_pipeline_config(model_type) + if pipeline is None: + pytest.skip(f"Pipeline {model_type!r} requires an HF config to resolve") + + deploy = ( + load_deploy_config(_DEPLOY_DIR / pipeline.default_deploy_config_name) + if pipeline.default_deploy_config_name is not None + else DeployConfig() + ) + legacy_stages, omni_config = _legacy_and_typed_stages( + pipeline, + deploy, + model="/tmp", + ) + + for legacy_stage in legacy_stages: + stage_id = legacy_stage.stage_id + legacy_args = build_legacy_engine_args_dict(legacy_stage, model="/tmp") + typed_args = build_engine_args_dict_from_omni_stage_config( + omni_config.stage_by_id(stage_id), + model="/tmp", + ) + if legacy_stage.stage_type == StageType.DIFFUSION: + backend_fields = _DIFFUSION_BACKEND_FIELDS + backend_config_cls = OmniDiffusionConfig + else: + backend_fields = _LLM_BACKEND_FIELDS + backend_config_cls = OmniEngineArgs + + missing_fields = (legacy_args.keys() & backend_fields) - typed_args.keys() + assert not missing_fields, f"{model_type} stage {stage_id} lost backend fields: {sorted(missing_fields)}" + + legacy_effective_args = _effective_backend_values(backend_config_cls, legacy_args) + typed_effective_args = _effective_backend_values(backend_config_cls, typed_args) + assert typed_effective_args == legacy_effective_args, ( + f"{model_type} stage {stage_id} changed effective backend arguments" + ) diff --git a/tests/engine/test_stage_engine_core_proc.py b/tests/engine/test_stage_engine_core_proc.py new file mode 100644 index 000000000..a7104b14d --- /dev/null +++ b/tests/engine/test_stage_engine_core_proc.py @@ -0,0 +1,28 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from vllm.v1.engine.core import EngineCoreProc + +from vllm_omni.engine.stage_engine_core_proc import StageEngineCoreProc + + +def test_preprocess_add_request_preserves_omni_fields(): + engine = StageEngineCoreProc.__new__(StageEngineCoreProc) + request = SimpleNamespace( + request_id="internal", + external_req_id="external", + additional_information={"conditioning": "payload"}, + ) + scheduler_request = SimpleNamespace() + + with patch.object( + EngineCoreProc, + "preprocess_add_request", + return_value=(scheduler_request, 3), + ): + result, current_wave = engine.preprocess_add_request(request) + + assert result is scheduler_request + assert current_wave == 3 + assert result.external_req_id == "external" + assert result.additional_information == {"conditioning": "payload"} diff --git a/tests/engine/test_stage_metadata.py b/tests/engine/test_stage_metadata.py new file mode 100644 index 000000000..0b21f7573 --- /dev/null +++ b/tests/engine/test_stage_metadata.py @@ -0,0 +1,173 @@ +import copy +import operator + +import pytest +from vllm.sampling_params import SamplingParams + +from vllm_omni.config.omni_config import VllmOmniConfig +from vllm_omni.config.stage_config import ( + DeployConfig, + PipelineConfig, + StageDeployConfig, + StageExecutionType, + StagePipelineConfig, + merge_pipeline_deploy, +) +from vllm_omni.engine.stage_init_utils import ( + extract_legacy_stage_metadata, + extract_stage_metadata, + extract_stage_metadata_from_omni_stage_config, +) +from vllm_omni.inputs.data import OmniDiffusionSamplingParams + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _metadata_inputs() -> tuple[PipelineConfig, DeployConfig]: + pipeline = PipelineConfig( + model_type="metadata-test", + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="thinker", + execution_type=StageExecutionType.LLM_AR, + owns_tokenizer=True, + requires_multimodal_data=True, + engine_output_type="token_ids", + custom_process_input_func="operator.add", + prompt_expand_func="operator.mul", + ), + StagePipelineConfig( + stage_id=1, + model_stage="talker", + execution_type=StageExecutionType.LLM_GENERATION, + input_sources=(0,), + engine_output_type="audio_tokens", + custom_process_input_func="operator.sub", + sync_process_input_func="operator.floordiv", + ), + StagePipelineConfig( + stage_id=2, + model_stage="dit", + execution_type=StageExecutionType.DIFFUSION, + input_sources=(1,), + final_output=True, + final_output_type="image", + custom_process_input_func="operator.neg", + cfg_kv_collect_func="operator.concat", + ), + ), + ) + deploy = DeployConfig( + async_chunk=False, + stages=[ + StageDeployConfig( + stage_id=0, + devices="0", + env={"STAGE": "thinker"}, + default_sampling_params={"temperature": 0.25}, + ), + StageDeployConfig( + stage_id=1, + devices="1,2", + num_replicas=2, + env={"STAGE": "talker"}, + default_sampling_params={"temperature": 0.75}, + ), + StageDeployConfig( + stage_id=2, + devices="3", + env={"STAGE": "dit"}, + default_sampling_params={"seed": 7}, + ), + ], + ) + return pipeline, deploy + + +def test_extract_stage_metadata_matches_legacy_projection(): + pipeline, deploy = _metadata_inputs() + omni_config = VllmOmniConfig.from_pipeline_config( + pipeline, + user_deploy_config=copy.deepcopy(deploy), + ) + legacy_configs = [ + stage.to_omegaconf() + for stage in merge_pipeline_deploy( + pipeline, + copy.deepcopy(deploy), + ) + ] + + parity_fields = ( + "stage_id", + "stage_type", + "engine_output_type", + "is_comprehension", + "requires_multimodal_data", + "engine_input_source", + "final_output", + "final_output_type", + "custom_process_input_func", + "model_stage", + "prompt_expand_func", + "cfg_kv_collect_func", + ) + for legacy_config in legacy_configs: + stage_id = legacy_config.stage_id + omni_stage_config = omni_config.stage_by_id(stage_id) + structured = extract_stage_metadata_from_omni_stage_config(omni_stage_config) + legacy = extract_legacy_stage_metadata(legacy_config) + + assert {field: getattr(structured, field) for field in parity_fields} == { + field: getattr(legacy, field) for field in parity_fields + } + assert type(structured.default_sampling_params) is type(legacy.default_sampling_params) + assert structured.runtime_cfg is omni_stage_config.runtime_config + for runtime_field in ("devices", "num_replicas", "env"): + assert getattr(structured.runtime_cfg, runtime_field) == getattr(legacy.runtime_cfg, runtime_field) + + thinker = extract_stage_metadata_from_omni_stage_config(omni_config.stage_by_id(0)) + talker = extract_stage_metadata_from_omni_stage_config(omni_config.stage_by_id(1)) + diffusion = extract_stage_metadata_from_omni_stage_config(omni_config.stage_by_id(2)) + + assert isinstance(thinker.default_sampling_params, SamplingParams) + assert thinker.default_sampling_params.temperature == 0.25 + assert thinker.custom_process_input_func is operator.add + assert thinker.prompt_expand_func is operator.mul + + assert isinstance(talker.default_sampling_params, SamplingParams) + assert talker.default_sampling_params.temperature == 0.75 + assert talker.custom_process_input_func is operator.floordiv + + assert isinstance(diffusion.default_sampling_params, OmniDiffusionSamplingParams) + assert diffusion.default_sampling_params.seed == 7 + assert diffusion.custom_process_input_func is operator.neg + assert diffusion.cfg_kv_collect_func is operator.concat + + +def test_extract_stage_metadata_preserves_legacy_one_argument_api(): + pipeline, deploy = _metadata_inputs() + legacy_config = merge_pipeline_deploy( + pipeline, + deploy, + )[0].to_omegaconf() + + metadata = extract_stage_metadata(legacy_config) + + assert metadata.stage_id == 0 + assert metadata.model_stage == "thinker" + assert metadata.custom_process_input_func is operator.add + + +def test_extract_stage_metadata_defaults_missing_engine_input_source(): + pipeline, deploy = _metadata_inputs() + legacy_config = merge_pipeline_deploy( + pipeline, + deploy, + )[0].to_omegaconf() + del legacy_config["engine_input_source"] + + metadata = extract_stage_metadata(legacy_config) + + assert metadata.engine_input_source == [] diff --git a/tests/entrypoints/openai/test_duplex_protocol.py b/tests/entrypoints/openai/test_duplex_protocol.py new file mode 100644 index 000000000..3c3666026 --- /dev/null +++ b/tests/entrypoints/openai/test_duplex_protocol.py @@ -0,0 +1,474 @@ +import asyncio +import json + +import pytest + +from vllm_omni.experimental.fullduplex.openai.protocol import ( + DuplexCapabilities, + DuplexOverlapPolicy, + DuplexSession, + DuplexSessionConfig, + DuplexSessionRegistry, + DuplexTurnController, + DuplexTurnEventType, + DuplexTurnState, + ResponseCreateOptions, +) +from vllm_omni.experimental.fullduplex.openai.realtime_session import ( + NativeRealtimeSessionProtocol, +) +from vllm_omni.experimental.fullduplex.openai.realtime_state import RealtimeStateOwner + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_realtime_state_owner_uses_explicit_bindings_not_dynamic_attribute_proxy(): + assert "__getattr__" not in RealtimeStateOwner.__dict__ + assert "__setattr__" not in RealtimeStateOwner.__dict__ + + +class _ProtocolWebSocket: + def __init__(self, *events: dict[str, object]) -> None: + self._events = asyncio.Queue() + for event in events: + self._events.put_nowait(json.dumps(event)) + + async def receive_text(self) -> str: + return await self._events.get() + + +def test_duplex_session_commits_text_and_audio_as_one_turn(): + registry = DuplexSessionRegistry() + session = registry.create(DuplexSessionConfig(model="test-model")) + + session.append_text("hello") + session.append_audio("YWJj", fmt="wav", sample_rate_hz=16000) + committed = session.commit_user_input() + + assert committed is not None + assert committed.turn_id == 0 + assert committed.input_commit_seq == 1 + assert committed.epoch == 0 + assert len(session.history) == 1 + content = session.history[0]["content"] + assert isinstance(content, list) + assert content[0] == {"type": "text", "text": "hello"} + assert content[1]["type"] == "audio_url" + assert content[1]["audio_url"]["url"] == "data:audio/wav;base64,YWJj" + assert session.turn_state == DuplexTurnState.USER_COMMITTED + + +def test_response_options_apply_to_one_response_without_mutating_session_defaults(): + session = DuplexSession( + session_id="sid-response-options", + config=DuplexSessionConfig(instructions="base", voice="base-voice", max_tokens=64), + ) + session.reserve_response_options( + ResponseCreateOptions( + instructions="one response", + voice="override-voice", + max_tokens=8, + ) + ) + + assert session.config.instructions == "base" + assert session.config.voice == "base-voice" + session.begin_response() + assert session.config.instructions == "base" + assert session.config.voice == "base-voice" + assert session.response_config.instructions == "one response" + assert session.response_config.voice == "override-voice" + assert session.response_config.max_tokens == 8 + + session.end_response() + assert session.config.instructions == "base" + assert session.config.voice == "base-voice" + assert session.config.max_tokens == 64 + + +def test_response_options_cannot_overwrite_an_unconsumed_reservation(): + session = DuplexSession( + session_id="sid-response-options-pending", + config=DuplexSessionConfig(instructions="base"), + ) + session.reserve_response_options(ResponseCreateOptions(instructions="first")) + + with pytest.raises(RuntimeError, match="already reserved"): + session.reserve_response_options(ResponseCreateOptions(instructions="second")) + + session.begin_response() + assert session.response_config.instructions == "first" + + +def test_response_options_cannot_be_reserved_while_response_is_active(): + session = DuplexSession( + session_id="sid-response-options-active", + config=DuplexSessionConfig(instructions="base"), + ) + session.begin_response() + + with pytest.raises(RuntimeError, match="active"): + session.reserve_response_options(ResponseCreateOptions(instructions="too late")) + + +def test_realtime_model_name_does_not_implicitly_enable_native_duplex(): + protocol = NativeRealtimeSessionProtocol({}) + + event = protocol._session_create_from_realtime({"model": "openbmb/MiniCPM-o-4_5"}) + + assert "minicpmo45_native_duplex" not in event["session"]["extra_body"] + + +def test_realtime_explicit_native_duplex_flag_is_preserved(): + protocol = NativeRealtimeSessionProtocol({}) + + event = protocol._session_create_from_realtime( + { + "model": "openbmb/MiniCPM-o-4_5", + "extra_body": {"minicpmo45_native_duplex": True}, + } + ) + + assert event["session"]["extra_body"]["minicpmo45_native_duplex"] is True + + +def test_realtime_explicit_query_native_duplex_flag_is_available_before_autostart(): + protocol = NativeRealtimeSessionProtocol( + { + "model": "openbmb/MiniCPM-o-4_5", + "minicpmo45_native_duplex": "1", + } + ) + + event = json.loads(asyncio.run(protocol.receive_internal_event_text(None))) + + assert event["type"] == "session.create" + assert event["session"]["extra_body"]["minicpmo45_native_duplex"] is True + + +@pytest.mark.asyncio +async def test_realtime_resume_heartbeat_and_event_ack_translate_without_session_autostart(): + protocol = NativeRealtimeSessionProtocol( + { + "model": "openbmb/MiniCPM-o-4_5", + "minicpmo45_native_duplex": "1", + } + ) + + resume = await protocol._to_duplex_event( + { + "type": "session.resume", + "session_id": "sid-resume", + "incarnation": 3, + "resume_token": "opaque-token", + "last_received_server_event_seq": 17, + } + ) + heartbeat = await protocol._to_duplex_event( + { + "type": "session.heartbeat", + "event_id": "event-heartbeat", + } + ) + event_ack = await protocol._to_duplex_event( + { + "type": "session.event_ack", + "server_event_seq": 19, + } + ) + + assert resume == { + "type": "session.resume", + "session_id": "sid-resume", + "incarnation": 3, + "resume_token": "opaque-token", + "last_received_server_event_seq": 17, + } + assert heartbeat == { + "type": "session.heartbeat", + "event_id": "event-heartbeat", + } + assert event_ack == { + "type": "session.event_ack", + "server_event_seq": 19, + } + assert protocol._opened is False + + wire_protocol = NativeRealtimeSessionProtocol({}) + first_wire_event = json.loads( + await wire_protocol.receive_internal_event_text( + _ProtocolWebSocket( + { + "type": "session.resume", + "session_id": "sid-resume", + "incarnation": 3, + "resume_token": "opaque-token", + "last_received_server_event_seq": 17, + } + ) + ) + ) + + assert first_wire_event["type"] == "session.resume" + + +@pytest.mark.asyncio +async def test_realtime_resume_query_suppresses_model_autostart_on_same_url(): + protocol = NativeRealtimeSessionProtocol( + { + "model": "openbmb/MiniCPM-o-4_5", + "minicpmo45_native_duplex": "1", + "resume": "1", + } + ) + resume = { + "type": "session.resume", + "session_id": "sid-resume", + "incarnation": 3, + "resume_token": "opaque-token", + "last_received_server_event_seq": 17, + } + + first_wire_event = json.loads(await protocol.receive_internal_event_text(_ProtocolWebSocket(resume))) + + assert first_wire_event == resume + assert protocol._autostarted_default_session is False + + +def test_realtime_projects_resume_lifecycle_events_without_duplex_prefix(): + protocol = NativeRealtimeSessionProtocol({}) + + created = protocol.encode_outbound_event( + { + "type": "session.created", + "session": {"id": "sid-resume"}, + "incarnation": 3, + "attachment_generation": 1, + "resume_token": "first-token", + } + )[0] + resumed = protocol.encode_outbound_event( + { + "type": "session.resumed", + "session_id": "sid-resume", + "incarnation": 3, + "attachment_generation": 2, + "resume_token": "second-token", + } + )[0] + heartbeat = protocol.encode_outbound_event( + { + "type": "session.heartbeat_ack", + "session_id": "sid-resume", + } + )[0] + replaced = protocol.encode_outbound_event( + { + "type": "session.replaced", + "session_id": "sid-resume", + "attachment_generation": 1, + } + )[0] + resync = protocol.encode_outbound_event( + { + "type": "session.resync_required", + "session_id": "sid-resume", + "reason": "journal_gap", + } + )[0] + + assert created["type"] == "session.created" + assert created["incarnation"] == 3 + assert created["attachment_generation"] == 1 + assert created["resume_token"] == "first-token" + assert resumed["type"] == "session.resumed" + assert resumed["resume_token"] == "second-token" + assert heartbeat["type"] == "session.heartbeat_ack" + assert replaced["type"] == "session.replaced" + assert resync == { + "type": "session.resync_required", + "session_id": "sid-resume", + "reason": "journal_gap", + "event_id": resync["event_id"], + } + + +def test_duplex_session_registry_advances_incarnation_when_id_is_reused(): + registry = DuplexSessionRegistry() + + first = registry.create(session_id="reused-session") + registry.close(first.session_id) + second = registry.create(session_id="reused-session") + + assert first.incarnation == 0 + assert second.incarnation == 1 + + +def test_native_input_commit_does_not_advance_model_turn_identity(): + session = DuplexSession( + session_id="sid-model-owned-turn", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + + first = session.commit_native_audio_input(transcript="first chunk") + second = session.commit_native_audio_input(transcript="second chunk") + + assert session.input_commit_seq == 2 + assert first.input_commit_seq == 1 + assert second.input_commit_seq == 2 + assert first.turn_id == second.turn_id == session.turn_id == 0 + + +def test_duplex_session_owns_response_and_overlap_identity(): + session = DuplexSession(session_id="sid-state-owner", config=DuplexSessionConfig()) + + response_id = session.begin_response() + session.accumulate_overlap_speech(320) + session.accumulate_overlap_speech(180) + session.end_response() + + assert session.active_response_id is None + assert session.last_response_id == response_id + assert session.overlap_speech_ms == 500 + assert session.reset_overlap_speech() == 500 + assert session.overlap_speech_ms == 0 + + +def test_duplex_session_composes_single_owner_ledgers(): + session = DuplexSession(session_id="sid-ledgers", config=DuplexSessionConfig()) + + session.append_text("hello") + session.bind_request("req-1") + response_id = session.begin_response(turn_id=3) + session.mark_audio_sent(duration_ms=240) + + assert not hasattr(session, "input") + assert not hasattr(session, "response") + assert not hasattr(session, "playback_ledger") + assert not hasattr(session, "conversation") + assert session.pending_text == ("hello",) + assert session.active_request_id == "req-1" + assert session.active_response_id == response_id + assert session.active_response_turn_id == 3 + assert session.playback.sent_ms == 240 + + try: + session.playback.sent_ms = 480 + except (AttributeError, TypeError): + pass + else: + raise AssertionError("playback view must be immutable") + + +def test_duplex_barge_in_advances_epoch_and_drops_uncommitted_assistant_text(): + registry = DuplexSessionRegistry() + session = registry.create() + response_id = session.begin_response() + session.bind_request("chatcmpl-duplex-test") + session.append_assistant_text("unplayed answer") + + new_epoch = session.barge_in() + + assert response_id is not None + assert new_epoch == 1 + assert session.epoch == 1 + assert session.active_request_id is None + assert session.active_response_id is None + assert session.assistant_text_buffer == () + assert session.history == () + assert session.turn_state == DuplexTurnState.BARGE_IN + + +def test_duplex_playback_ack_tracks_committed_cursor_separately(): + registry = DuplexSessionRegistry() + session = registry.create() + session.mark_audio_sent(duration_ms=10_000) + + session.acknowledge_playback(played_ms=2_000) + + assert session.playback.generated_ms == 10_000 + assert session.playback.sent_ms == 10_000 + assert session.playback.played_ms == 2_000 + assert session.playback.committed_ms == 2_000 + + +def test_duplex_history_commit_uses_audio_text_alignment_marks(): + registry = DuplexSessionRegistry() + session = registry.create() + session.begin_response() + + session.append_assistant_text("hello ") + session.mark_audio_sent(duration_ms=1_000, text_chars=6) + session.append_assistant_text("world") + session.mark_audio_sent(duration_ms=2_000, text_chars=11) + session.acknowledge_playback(played_ms=1_200, committed_ms=1_200) + + committed = session.end_response(commit_text=True) + + assert committed == {"role": "assistant", "content": "hello w"} + + +def test_turn_controller_accepts_external_signal_source(): + registry = DuplexSessionRegistry() + session = registry.create() + controller = DuplexTurnController() + + event = controller.signal(session, DuplexTurnEventType.USER_STARTED.value) + + assert event["type"] == "turn.event" + assert event["event"] == "user_started" + assert event["turn_state"] == "user_speaking" + assert session.turn_state == DuplexTurnState.USER_SPEAKING + + +def test_duplex_capabilities_do_not_claim_core_kv_or_input_append(): + registry = DuplexSessionRegistry() + session = registry.create() + caps = session.capabilities.as_dict() + + assert caps["implementation_level"] == "serving_session_adapter" + assert caps["supports_kv_lease"] is False + assert caps["supports_input_append"] is False + assert caps["supports_reencode_context"] is True + assert caps["adapter_patterns"] == ["chunk_group_append"] + assert "turn_commit_only" in caps["input_modes"] + assert "client_event" in caps["signal_sources"] + + +def test_minicpmo_native_capabilities_separate_model_state_from_core_kv_lease(): + caps = DuplexCapabilities.minicpmo45_native(max_sessions=2).as_dict() + + assert caps["implementation_level"] == "model_native_duplex" + assert caps["supports_input_append"] is True + assert caps["input_modes"] == ["append_audio_chunk"] + assert caps["adapter_patterns"] == ["scheduler_data_plane"] + assert caps["supports_model_internal_state"] is True + assert caps["requires_model_runner_kv"] is True + assert caps["requires_native_stage_role"] is True + assert caps["supports_kv_lease"] is False + assert caps["supports_core_kv_lease"] is False + assert caps["supports_stage_resumption"] is True + assert caps["supports_scheduler_native_append"] is False + assert caps["supports_core_resumable_request"] is True + assert caps["supports_stage_connector_handoff"] is True + assert caps["supports_audio_truncate"] is True + assert caps["supports_barge_in"] is False + assert caps["target_barge_in_latency_ms"] is None + assert caps["supports_multi_session"] is True + assert caps["supports_multi_session_same_replica"] is True + assert caps["supports_session_lease"] is True + assert caps["supports_session_resume"] is True + assert caps["session_admission_mode"] == "engine_managed" + assert caps["stage_handoff_transport"] == "scheduler_data_plane" + + +def test_minicpmo_native_capabilities_do_not_overclaim_single_session_deployment(): + caps = DuplexCapabilities.minicpmo45_native(max_sessions=1).as_dict() + + assert caps["supports_multi_session"] is False + assert caps["supports_multi_session_same_replica"] is False + + +def test_duplex_overlap_policy_defaults_and_invalid_values_to_listen_only(): + assert DuplexSessionConfig().overlap_policy == DuplexOverlapPolicy.LISTEN_ONLY.value + assert DuplexSessionConfig._normalize_overlap_policy("not-a-policy") == DuplexOverlapPolicy.LISTEN_ONLY.value diff --git a/tests/entrypoints/openai/test_duplex_session_attachment.py b/tests/entrypoints/openai/test_duplex_session_attachment.py new file mode 100644 index 000000000..24a6f5365 --- /dev/null +++ b/tests/entrypoints/openai/test_duplex_session_attachment.py @@ -0,0 +1,387 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +import asyncio +import base64 +import json + +import pytest + +from vllm_omni.experimental.fullduplex.openai.session_attachment import ( + DuplexEventJournal, + DuplexJournalGapError, + DuplexJournalOverflowError, + DuplexResumeCredential, + DuplexSessionAttachmentRegistry, + InvalidResumeTokenError, + ResumeToken, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _Clock: + def __init__(self) -> None: + self.value = 0.0 + + def __call__(self) -> float: + return self.value + + def advance(self, seconds: float) -> None: + self.value += seconds + + +def _decoded_token_bytes(token: ResumeToken) -> bytes: + padding = "=" * (-len(token.plaintext) % 4) + return base64.urlsafe_b64decode(token.plaintext + padding) + + +def test_resume_credential_uses_256_bit_token_digest_and_redacted_repr(monkeypatch) -> None: + token = ResumeToken.generate() + credential = DuplexResumeCredential.from_token(token) + calls = [] + + import vllm_omni.experimental.fullduplex.openai.session_attachment as attachment_module + + original_compare = attachment_module.hmac.compare_digest + + def recording_compare(left, right): + calls.append((left, right)) + return original_compare(left, right) + + monkeypatch.setattr(attachment_module.hmac, "compare_digest", recording_compare) + + assert len(_decoded_token_bytes(token)) == 32 + assert credential.verify(token.plaintext) is True + assert credential.verify("invalid-token") is False + assert len(calls) == 2 + assert token.plaintext not in repr(token) + assert token.plaintext not in repr(credential) + assert not hasattr(credential, "plaintext") + assert len(credential.token_digest) == 32 + + +def test_resume_credential_rotation_revokes_old_token() -> None: + first = ResumeToken.generate() + credential = DuplexResumeCredential.from_token(first) + + second = credential.rotate() + + assert credential.verify(first.plaintext) is False + assert credential.verify(second.plaintext) is True + assert second.plaintext != first.plaintext + + +def test_event_journal_sequences_acknowledges_and_replays_exact_payloads() -> None: + clock = _Clock() + journal = DuplexEventJournal(max_bytes=4096, ttl_s=60.0, clock=clock) + + first = journal.record({"type": "response.audio.delta", "delta": "AAAA"}) + second = journal.record({"type": "response.done", "response_id": "resp-1"}) + + assert first.sequence == 1 + assert second.sequence == 2 + assert first.payload["server_event_seq"] == 1 + assert second.payload["server_event_seq"] == 2 + assert first.encoded_bytes == len( + json.dumps(dict(first.payload), separators=(",", ":"), ensure_ascii=False).encode("utf-8") + ) + assert [entry.sequence for entry in journal.replay_after(0)] == [1, 2] + assert journal.acknowledge(1) == 1 + assert [entry.sequence for entry in journal.replay_after(1)] == [2] + assert journal.acknowledge(1) == 0 + + +def test_event_journal_ttl_pruning_reports_replay_gap() -> None: + clock = _Clock() + journal = DuplexEventJournal(max_bytes=4096, ttl_s=5.0, clock=clock) + journal.record({"type": "one"}) + clock.advance(3.0) + journal.record({"type": "two"}) + clock.advance(3.0) + + assert journal.prune() == 1 + with pytest.raises(DuplexJournalGapError, match="older than retained"): + journal.replay_after(0) + assert [entry.payload["type"] for entry in journal.replay_after(1)] == ["two"] + + +def test_event_journal_overflow_is_explicit_and_does_not_evict_silently() -> None: + clock = _Clock() + journal = DuplexEventJournal(max_bytes=100, ttl_s=60.0, clock=clock) + first = journal.record({"type": "small", "value": "x"}) + + with pytest.raises(DuplexJournalOverflowError, match="byte limit"): + journal.record({"type": "large", "value": "x" * 200}) + + assert journal.overflowed is True + assert journal.retained_bytes == first.encoded_bytes + with pytest.raises(DuplexJournalGapError, match="overflowed"): + journal.replay_after(0) + + +@pytest.mark.asyncio +async def test_registry_resume_rotates_token_replays_and_atomically_replaces_attachment() -> None: + clock = _Clock() + registry = DuplexSessionAttachmentRegistry( + replay_ttl_s=60.0, + replay_max_bytes_per_session=4096, + clock=clock, + ) + sends_a = [] + closes_a = [] + sends_b = [] + closes_b = [] + + async def send_a(payload): + sends_a.append(payload) + + async def close_a(reason): + closes_a.append(reason) + + async def send_b(payload): + sends_b.append(payload) + + async def close_b(reason): + closes_b.append(reason) + + created = await registry.create("sid", incarnation=2, send=send_a, close=close_a) + await registry.send_event("sid", {"type": "event-1"}) + await registry.send_event("sid", {"type": "event-2"}) + + resumed = await registry.resume( + "sid", + incarnation=2, + resume_token=created.resume_token.plaintext, + last_received_server_event_seq=1, + send=send_b, + close=close_b, + ) + + assert resumed.attachment_generation == 2 + assert resumed.resume_token.plaintext != created.resume_token.plaintext + assert [entry.sequence for entry in resumed.replay_entries] == [2] + assert resumed.replaced_attachment is not None + assert resumed.replaced_attachment.generation == 1 + assert await registry.is_current_attachment("sid", created.attachment_generation) is False + assert await registry.is_current_attachment("sid", resumed.attachment_generation) is True + assert await registry.detach("sid", attachment_generation=1) is False + assert await registry.detach("sid", attachment_generation=2) is True + assert [(payload["type"], payload["server_event_seq"]) for payload in sends_a] == [ + ("event-1", 1), + ("event-2", 2), + ] + assert closes_a == [] and sends_b == [] and closes_b == [] + + +@pytest.mark.asyncio +async def test_registry_resume_sends_activation_then_replay_before_new_live_events() -> None: + registry = DuplexSessionAttachmentRegistry( + replay_ttl_s=60.0, + replay_max_bytes_per_session=4096, + ) + wire = [] + activation_started = asyncio.Event() + release_activation = asyncio.Event() + + async def old_send(payload): + del payload + + async def close(reason): + del reason + + async def new_send(payload): + wire.append(payload["type"]) + if payload["type"] == "session.resumed": + activation_started.set() + await release_activation.wait() + + created = await registry.create("sid-order", incarnation=0, send=old_send, close=close) + await registry.detach("sid-order", attachment_generation=1) + await registry.send_event("sid-order", {"type": "replayed"}) + resume_task = asyncio.create_task( + registry.resume( + "sid-order", + incarnation=0, + resume_token=created.resume_token.plaintext, + last_received_server_event_seq=0, + send=new_send, + close=close, + activation_payload_factory=lambda _token, _generation: {"type": "session.resumed"}, + ) + ) + await activation_started.wait() + live_task = asyncio.create_task(registry.send_event("sid-order", {"type": "live"})) + + release_activation.set() + await asyncio.gather(resume_task, live_task) + + assert wire == ["session.resumed", "replayed", "live"] + + +@pytest.mark.asyncio +async def test_registry_resume_delivery_failure_keeps_one_shot_old_token_recovery() -> None: + registry = DuplexSessionAttachmentRegistry( + replay_ttl_s=60.0, + replay_max_bytes_per_session=4096, + ) + + async def send(payload): + del payload + + async def failing_send(payload): + del payload + raise RuntimeError("transport lost before rotated token arrived") + + async def close(reason): + del reason + + created = await registry.create("sid-recovery", incarnation=0, send=send, close=close) + await registry.detach("sid-recovery", attachment_generation=1) + + with pytest.raises(RuntimeError, match="transport lost"): + await registry.resume( + "sid-recovery", + incarnation=0, + resume_token=created.resume_token.plaintext, + last_received_server_event_seq=0, + send=failing_send, + close=close, + activation_payload_factory=lambda token, generation: { + "type": "session.resumed", + "resume_token": token.plaintext, + "attachment_generation": generation, + }, + ) + + recovered = await registry.resume( + "sid-recovery", + incarnation=0, + resume_token=created.resume_token.plaintext, + last_received_server_event_seq=0, + send=send, + close=close, + ) + + assert recovered.attachment_generation == 3 + with pytest.raises(InvalidResumeTokenError): + await registry.resume( + "sid-recovery", + incarnation=0, + resume_token=created.resume_token.plaintext, + last_received_server_event_seq=0, + send=send, + close=close, + ) + + +@pytest.mark.asyncio +async def test_registry_concurrent_resume_allows_exactly_one_rotated_token_winner() -> None: + registry = DuplexSessionAttachmentRegistry( + replay_ttl_s=60.0, + replay_max_bytes_per_session=4096, + ) + + async def send(payload): + del payload + + async def close(reason): + del reason + + created = await registry.create("sid-race", incarnation=0, send=send, close=close) + + async def attempt(): + try: + return await registry.resume( + "sid-race", + incarnation=0, + resume_token=created.resume_token.plaintext, + last_received_server_event_seq=0, + send=send, + close=close, + ) + except InvalidResumeTokenError as exc: + return exc + + results = await asyncio.gather(attempt(), attempt()) + + assert sum(not isinstance(result, Exception) for result in results) == 1 + assert sum(isinstance(result, InvalidResumeTokenError) for result in results) == 1 + + +@pytest.mark.asyncio +async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> None: + registry = DuplexSessionAttachmentRegistry( + replay_ttl_s=60.0, + replay_max_bytes_per_session=4096, + ) + + async def send(payload): + del payload + + async def close(reason): + del reason + + created_a = await registry.create("sid-a", incarnation=1, send=send, close=close) + created_b = await registry.create("sid-b", incarnation=4, send=send, close=close) + await registry.detach("sid-a", attachment_generation=1) + await registry.detach("sid-b", attachment_generation=1) + await registry.send_event("sid-a", {"type": "a-only"}) + await registry.send_event("sid-b", {"type": "b-only"}) + + resumed_a = await registry.resume( + "sid-a", + incarnation=1, + resume_token=created_a.resume_token.plaintext, + last_received_server_event_seq=0, + send=send, + close=close, + ) + resumed_b = await registry.resume( + "sid-b", + incarnation=4, + resume_token=created_b.resume_token.plaintext, + last_received_server_event_seq=0, + send=send, + close=close, + ) + + assert [entry.payload["type"] for entry in resumed_a.replay_entries] == ["a-only"] + assert [entry.payload["type"] for entry in resumed_b.replay_entries] == ["b-only"] + with pytest.raises(ValueError, match="incarnation mismatch"): + await registry.resume( + "sid-a", + incarnation=2, + resume_token=resumed_a.resume_token.plaintext, + last_received_server_event_seq=1, + send=send, + close=close, + ) + + +@pytest.mark.asyncio +async def test_registry_repr_never_contains_plaintext_tokens() -> None: + registry = DuplexSessionAttachmentRegistry( + replay_ttl_s=60.0, + replay_max_bytes_per_session=4096, + ) + + async def send(payload): + del payload + + async def close(reason): + del reason + + created = await registry.create("sid-repr", incarnation=0, send=send, close=close) + await registry.detach("sid-repr", attachment_generation=1) + entry = await registry.send_event( + "sid-repr", + {"type": "session.resumed", "resume_token": created.resume_token.plaintext}, + ) + + assert entry is not None + assert created.resume_token.plaintext not in repr(created) + assert created.resume_token.plaintext not in repr(entry) + assert created.resume_token.plaintext not in repr(registry) diff --git a/tests/entrypoints/openai_api/test_audex_serving_guards.py b/tests/entrypoints/openai_api/test_audex_serving_guards.py new file mode 100644 index 000000000..343eb9816 --- /dev/null +++ b/tests/entrypoints/openai_api/test_audex_serving_guards.py @@ -0,0 +1,376 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Serving-layer regressions for the Audex zero-codec-token contract. + +A zero-codec-token (or phase-invalid TTA) request reaches the serving layer +as a stream whose only audio payloads are zero-size tensors. The streaming +generator must not turn that into a header-only/empty successful stream: the +raw-bytes generator raises (before any WAV header), and the SSE wrapper +surfaces ``speech.audio.error`` instead of ``speech.audio.done``. The +contract covers both audex pipelines (``audex`` TTS and ``audex_tta``). +""" + +from types import SimpleNamespace + +import pytest +import torch + +from vllm_omni.entrypoints.openai.serving_speech import ( + _AUDEX_NO_AUDIO_GUARD_MODEL_TYPES, + OmniOpenAIServingSpeech, +) + + +def _serving(model_type: str = "audex") -> OmniOpenAIServingSpeech: + serving = OmniOpenAIServingSpeech.__new__(OmniOpenAIServingSpeech) + serving._tts_model_type = model_type + serving._mark_ref_audio_artifact_ready_for_request = lambda request_id: None + serving._discard_ref_audio_artifact_warmup = lambda request_id: None + return serving + + +def _result(audio: torch.Tensor) -> SimpleNamespace: + return SimpleNamespace(multimodal_output={"model_outputs": audio, "sr": torch.tensor(16000, dtype=torch.int32)}) + + +async def _gen(*results): + for res in results: + yield res + + +async def _collect(chunks): + out = [] + async for chunk in chunks: + out.append(chunk) + return out + + +def test_no_audio_guard_covers_both_audex_pipelines(): + # The non-streaming CreateAudio guard keys off the same constant; pin + # its membership so neither pipeline regresses to empty-200 responses. + assert _AUDEX_NO_AUDIO_GUARD_MODEL_TYPES == {"audex", "audex_tta"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_type", ["audex", "audex_tta"]) +async def test_raw_stream_raises_on_empty_audio_before_any_header(model_type): + serving = _serving(model_type) + chunks = serving._generate_audio_chunks( + _gen(_result(torch.empty(0)), _result(torch.empty(0))), + request_id="req-empty", + response_format="wav", + ) + with pytest.raises(ValueError, match="no audio"): + await _collect(chunks) + + +@pytest.mark.asyncio +async def test_raw_stream_yields_audio_normally_when_non_empty(): + serving = _serving() + chunks = serving._generate_audio_chunks( + _gen(_result(torch.zeros(320)), _result(torch.empty(0))), + request_id="req-ok", + response_format="wav", + ) + collected = await _collect(chunks) + # WAV header + one PCM chunk; the trailing empty chunk is skipped silently. + assert len(collected) == 2 + assert collected[0][:4] == b"RIFF" + assert len(collected[1]) == 320 * 2 # int16 PCM + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_type", ["audex", "audex_tta"]) +async def test_sse_stream_emits_error_not_done_on_empty_audio(model_type): + serving = _serving(model_type) + events = serving._generate_audio_sse_events( + _gen(_result(torch.empty(0))), + request_id="req-empty", + response_format="pcm", + ) + collected = await _collect(events) + assert any("speech.audio.error" in event for event in collected) + assert not any("speech.audio.done" in event for event in collected) + + +@pytest.mark.asyncio +async def test_non_audex_empty_chunks_keep_legacy_behavior(): + """Other models keep their existing semantics (no new rejection).""" + serving = _serving(model_type="qwen3_tts") + chunks = serving._generate_audio_chunks( + _gen(_result(torch.empty(0))), + request_id="req-other", + response_format="pcm", + ) + await _collect(chunks) # must not raise + + +# ---------------------------------------------------------------- CFG serving surface + +import re # noqa: E402 + +from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest # noqa: E402 +from vllm_omni.entrypoints.openai.tts_adapters.audex import AudexAdapter # noqa: E402 + + +def _adapter() -> AudexAdapter: + return AudexAdapter.__new__(AudexAdapter) + + +def _speech_request(**extra_params) -> OpenAICreateSpeechRequest: + payload = {"model": "audex", "input": "Hello world."} + if extra_params: + payload["extra_params"] = extra_params + return OpenAICreateSpeechRequest(**payload) + + +class _MarkerTokenizer: + """Special markers count one token; words and punctuation split.""" + + _TOKEN_RE = re.compile(r"|<\|[^|]*\|>|<[a-z_]+>|\w+|[^\w\s]") + + def encode(self, text: str) -> list[int]: + return [0] * len(self._TOKEN_RE.findall(text)) + + +class TestAudexCfgValidation: + def test_no_cfg_scale_accepted(self): + assert _adapter().validate(_speech_request()) is None + + @pytest.mark.parametrize("scale", [1.0, 1.5, 3.0, 10.0]) + def test_in_range_scale_accepted(self, scale): + assert _adapter().validate(_speech_request(cfg_scale=scale)) is None + + @pytest.mark.parametrize("scale", [0.5, 0.0, -1.0, 10.5, "big"]) + def test_out_of_range_or_malformed_scale_rejected(self, scale): + error = _adapter().validate(_speech_request(cfg_scale=scale)) + assert error is not None and "cfg_scale" in error + + def test_explicit_null_scale_treated_as_absent(self): + assert _adapter().validate(_speech_request(cfg_scale=None)) is None + serving = TestAudexCfgInjection()._serving_with_fake_tokenizer() + params = SimpleNamespace(extra_args={"cfg_scale": None}) + out = serving._inject_audex_cfg_pair_args("req-1", {"prompt": "p"}, params) + assert out.extra_args == {} + + @pytest.mark.parametrize("key", ["cfg_role", "cfg_pair_id", "cfg_null_prompt"]) + def test_internal_cfg_keys_rejected(self, key): + error = _adapter().validate(_speech_request(**{key: "x"})) + assert error is not None and key in error + + +class TestAudexCfgInjection: + def _serving_with_fake_tokenizer(self): + serving = _serving() + serving._get_audex_tokenizer = lambda: _MarkerTokenizer() + return serving + + def _cond_prompt(self, text: str = "Hello world.") -> str: + from vllm_omni.model_executor.models.audex.prompt import build_cond_prompt + + return build_cond_prompt(text) + + def test_no_extra_args_is_untouched(self): + serving = self._serving_with_fake_tokenizer() + params = SimpleNamespace(extra_args=None) + out = serving._inject_audex_cfg_pair_args("req-1", {"prompt": self._cond_prompt()}, params) + assert out.extra_args is None + assert params.extra_args is None + + def test_scale_one_leaves_params_byte_identical(self): + serving = self._serving_with_fake_tokenizer() + params = SimpleNamespace(extra_args={"cfg_scale": 1.0}) + out = serving._inject_audex_cfg_pair_args("req-1", {"prompt": self._cond_prompt()}, params) + assert out.extra_args == {} + + def test_guided_request_gains_pair_contract(self): + serving = self._serving_with_fake_tokenizer() + params = SimpleNamespace(extra_args={"cfg_scale": 1.5}) + cond = self._cond_prompt("Some transcription text here.") + out = serving._inject_audex_cfg_pair_args("req-42", {"prompt": cond}, params) + + extra = out.extra_args + assert extra["cfg_scale"] == 1.5 + assert extra["cfg_role"] == "cond" + assert extra["cfg_pair_id"] == "req-42" + null_prompt = extra["cfg_null_prompt"] + assert "" in null_prompt + assert "Some transcription" not in null_prompt + tok = _MarkerTokenizer() + assert len(tok.encode(null_prompt)) == len(tok.encode(cond)) + + def test_missing_text_prompt_raises(self): + serving = self._serving_with_fake_tokenizer() + params = SimpleNamespace(extra_args={"cfg_scale": 1.5}) + with pytest.raises(ValueError, match="adapter-built text prompt"): + serving._inject_audex_cfg_pair_args("req-1", {"prompt_token_ids": [1]}, params) + + def test_injection_never_mutates_input_params(self): + """The input may be the engine's SHARED default params (review P2): + pair state must land only on the returned clone.""" + serving = self._serving_with_fake_tokenizer() + shared = SimpleNamespace(extra_args={"cfg_scale": 1.5}, temperature=0.1) + out = serving._inject_audex_cfg_pair_args("req-7", {"prompt": self._cond_prompt()}, shared) + assert out is not shared + assert shared.extra_args == {"cfg_scale": 1.5} + assert shared.temperature == 0.1 + assert out.extra_args["cfg_pair_id"] == "req-7" + assert out.temperature == 0.05 + + +class TestAudexStageDetection: + """audex_omni is speech-capable only alongside the speech decoder.""" + + def _serving_with_stages(self, stages: list[str]) -> OmniOpenAIServingSpeech: + serving = OmniOpenAIServingSpeech.__new__(OmniOpenAIServingSpeech) + serving.engine_client = SimpleNamespace( + stage_configs=[SimpleNamespace(engine_args=SimpleNamespace(model_stage=s)) for s in stages] + ) + return serving + + def test_full_pipeline_detected_as_tts(self): + serving = self._serving_with_stages(["audex_omni", "audex_code2wav"]) + stage = serving._find_tts_stage() + assert stage is not None and stage.engine_args.model_stage == "audex_omni" + + def test_thinker_only_not_detected_as_tts(self): + serving = self._serving_with_stages(["audex_omni"]) + assert serving._find_tts_stage() is None + + def test_tts_pipeline_still_detected(self): + serving = self._serving_with_stages(["audex_thinker", "audex_code2wav"]) + stage = serving._find_tts_stage() + assert stage is not None and stage.engine_args.model_stage == "audex_thinker" + + +def test_guided_injection_sets_guided_temperature(): + """Guided requests decode at the measured CFG temperature (0.05).""" + serving = TestAudexCfgInjection()._serving_with_fake_tokenizer() + cond = TestAudexCfgInjection()._cond_prompt() + params = SimpleNamespace(extra_args={"cfg_scale": 1.5}, temperature=0.1) + out = serving._inject_audex_cfg_pair_args("req-1", {"prompt": cond}, params) + assert out.temperature == 0.05 + + unguided = SimpleNamespace(extra_args={"cfg_scale": 1.0}, temperature=0.1) + out = serving._inject_audex_cfg_pair_args("req-2", {"prompt": cond}, unguided) + assert out.temperature == 0.1 + + +# ---------------------------------------------------------------- TTA serving surface + +from vllm_omni.entrypoints.openai.tts_adapters import resolve_adapter # noqa: E402 +from vllm_omni.entrypoints.openai.tts_adapters.audex_tta import AudexTTAAdapter # noqa: E402 + + +def _tta_adapter() -> AudexTTAAdapter: + return AudexTTAAdapter.__new__(AudexTTAAdapter) + + +class _TTAVocabTokenizer(_MarkerTokenizer): + """Marker tokenizer + a full audiocodec vocab for phase-id building.""" + + def get_vocab(self): + vocab = {"": 7, "": 8} + for n in range(8192): + vocab[f""] = 1000 + n + return vocab + + +class TestAudexTTAAdapterValidation: + def test_default_caption_accepted(self): + assert _tta_adapter().validate(_speech_request()) is None + + def test_empty_caption_rejected(self): + req = _speech_request() + req.input = " " + assert _tta_adapter().validate(req) is not None + + @pytest.mark.parametrize("scale", [1.0, 3.0, 10.0]) + def test_in_range_scale_accepted(self, scale): + assert _tta_adapter().validate(_speech_request(cfg_scale=scale)) is None + + @pytest.mark.parametrize("scale", [0.5, 10.5, "big"]) + def test_out_of_range_scale_rejected(self, scale): + error = _tta_adapter().validate(_speech_request(cfg_scale=scale)) + assert error is not None and "cfg_scale" in error + + @pytest.mark.parametrize("key", ["cfg_role", "cfg_pair_id", "cfg_null_prompt", "tta_rvq"]) + def test_internal_keys_rejected(self, key): + error = _tta_adapter().validate(_speech_request(**{key: "x"})) + assert error is not None and key in error + + +class TestAdapterRouting: + def test_tta_adapter_resolves_by_model_type(self): + adapter_cls = resolve_adapter("audex_tta") + assert adapter_cls is AudexTTAAdapter + assert adapter_cls.stage_keys == frozenset({"audex_tta_thinker"}) + + def test_tts_adapter_never_selects_tta_stage(self): + assert "audex_tta_thinker" not in AudexAdapter.stage_keys + assert not (AudexAdapter.stage_keys & AudexTTAAdapter.stage_keys) + + def test_detection_separates_tts_and_tta(self): + tta_serving = TestAudexStageDetection()._serving_with_stages(["audex_tta_thinker", "audex_xcodec"]) + tta_serving._tts_stage = tta_serving._find_tts_stage() + assert tta_serving._tts_stage.engine_args.model_stage == "audex_tta_thinker" + assert tta_serving._detect_tts_model_type() == "audex_tta" + + tts_serving = TestAudexStageDetection()._serving_with_stages(["audex_thinker", "audex_code2wav"]) + tts_serving._tts_stage = tts_serving._find_tts_stage() + assert tts_serving._detect_tts_model_type() == "audex" + + +class TestAudexTTAInjection: + def _serving(self): + serving = _serving(model_type="audex_tta") + serving._get_audex_tokenizer = lambda: _TTAVocabTokenizer() + serving._audex_tta_rvq = None + return serving + + def _prompt(self): + from vllm_omni.model_executor.models.audex.prompt import build_tta_cond_prompt + + return {"prompt": build_tta_cond_prompt("Rain on a tin roof.")} + + def test_default_injection_applies_rvq_and_cfg3(self): + serving = self._serving() + params = SimpleNamespace(extra_args=None) + out = serving._inject_audex_tta_args("req-9", self._prompt(), params) + + extra = out.extra_args + rvq = extra["tta_rvq"] + assert len(rvq["phase_token_ids"]) == 4 + assert all(len(ids) == 1024 for ids in rvq["phase_token_ids"]) + assert (rvq["start_tid"], rvq["end_tid"]) == (7, 8) + assert rvq["codec_cap"] == 4000 and rvq["start_in_prompt"] is True + assert extra["cfg_scale"] == 3.0 + assert extra["cfg_role"] == "cond" and extra["cfg_pair_id"] == "req-9" + assert "" in extra["cfg_null_prompt"] + + def test_explicit_scale_one_disables_cfg_but_keeps_rvq(self): + serving = self._serving() + params = SimpleNamespace(extra_args={"cfg_scale": 1.0}) + out = serving._inject_audex_tta_args("req-1", self._prompt(), params) + assert "tta_rvq" in out.extra_args + assert "cfg_scale" not in out.extra_args + assert "cfg_role" not in out.extra_args + + def test_rvq_contract_cached_per_process(self): + serving = self._serving() + out_a = serving._inject_audex_tta_args("a", self._prompt(), SimpleNamespace(extra_args=None)) + out_b = serving._inject_audex_tta_args("b", self._prompt(), SimpleNamespace(extra_args=None)) + assert out_a.extra_args["tta_rvq"] is out_b.extra_args["tta_rvq"] + + def test_injection_never_mutates_input_params(self): + """The default online path hands the injector an element of the + engine's SHARED default_sampling_params_list (review P2): the + per-request pair state must land only on the returned clone.""" + serving = self._serving() + shared = SimpleNamespace(extra_args=None) + out_1 = serving._inject_audex_tta_args("req-1", self._prompt(), shared) + out_2 = serving._inject_audex_tta_args("req-2", self._prompt(), shared) + assert shared.extra_args is None + assert out_1 is not shared and out_2 is not shared + assert out_1.extra_args["cfg_pair_id"] == "req-1" + assert out_2.extra_args["cfg_pair_id"] == "req-2" diff --git a/tests/entrypoints/openai_api/test_audio_format.py b/tests/entrypoints/openai_api/test_audio_format.py new file mode 100644 index 000000000..2f1f487d9 --- /dev/null +++ b/tests/entrypoints/openai_api/test_audio_format.py @@ -0,0 +1,175 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for audio output format handling in chat completions. + +Covers: +- #4716: audio.format parameter must be respected, not hardcoded to WAV +- Format validation rejects unsupported formats +- pcm16 is mapped to pcm for soundfile compatibility +- Default format is WAV when not specified +- create_audio encodes correctly for each supported format +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin +from vllm_omni.entrypoints.openai.protocol.audio import ( + DEFAULT_AUDIO_FORMAT, + SUPPORTED_AUDIO_FORMATS, + SUPPORTED_CHAT_AUDIO_FORMATS, + CreateAudio, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class TestAudioFormatConstants: + def test_default_format_is_wav(self): + assert DEFAULT_AUDIO_FORMAT == "wav" + + def test_supported_formats_no_aac(self): + assert "aac" not in SUPPORTED_AUDIO_FORMATS + + def test_chat_formats_include_pcm16(self): + assert "pcm16" in SUPPORTED_CHAT_AUDIO_FORMATS + + def test_chat_formats_superset_of_audio_formats(self): + assert SUPPORTED_AUDIO_FORMATS <= SUPPORTED_CHAT_AUDIO_FORMATS + + +class TestCreateAudio: + @pytest.fixture + def mixin(self): + return AudioMixin() + + @pytest.fixture + def audio_tensor(self): + return np.sin(np.linspace(0, 2 * np.pi, 24000)).astype(np.float32) + + @pytest.mark.parametrize("fmt", ["wav", "mp3", "flac", "opus", "pcm"]) + def test_create_audio_supported_formats(self, mixin, audio_tensor, fmt): + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=24000, + response_format=fmt, + speed=1.0, + base64_encode=False, + ) + response = mixin.create_audio(audio_obj) + assert len(response.audio_data) > 0 + + def test_wav_magic_bytes(self, mixin, audio_tensor): + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=24000, + response_format="wav", + speed=1.0, + base64_encode=False, + ) + response = mixin.create_audio(audio_obj) + assert response.audio_data[:4] == b"RIFF" + assert response.media_type == "audio/wav" + + def test_mp3_encoding(self, mixin, audio_tensor): + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=24000, + response_format="mp3", + speed=1.0, + base64_encode=False, + ) + response = mixin.create_audio(audio_obj) + assert response.media_type == "audio/mpeg" + assert response.audio_data[:4] != b"RIFF" + + def test_flac_magic_bytes(self, mixin, audio_tensor): + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=24000, + response_format="flac", + speed=1.0, + base64_encode=False, + ) + response = mixin.create_audio(audio_obj) + assert response.audio_data[:4] == b"fLaC" + assert response.media_type == "audio/flac" + + def test_unsupported_format_falls_back_to_default(self, mixin, audio_tensor): + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=24000, + response_format="aac", + speed=1.0, + base64_encode=False, + ) + response = mixin.create_audio(audio_obj) + assert response.audio_data[:4] == b"RIFF" + assert response.media_type == "audio/wav" + + def test_base64_encoding(self, mixin, audio_tensor): + import base64 + + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=24000, + response_format="wav", + speed=1.0, + base64_encode=True, + ) + response = mixin.create_audio(audio_obj) + decoded = base64.b64decode(response.audio_data) + assert decoded[:4] == b"RIFF" + + +class TestResolveAudioFormat: + """Test _resolve_audio_format via the serving chat class.""" + + @pytest.fixture + def serving_chat(self): + from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat + + return object.__new__(OmniOpenAIServingChat) + + def _make_request(self, audio_params=None): + from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest + + req = ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + ) + if audio_params is not None: + req.audio = audio_params + return req + + def test_default_format_when_no_audio_params(self, serving_chat): + request = self._make_request() + result = serving_chat._resolve_audio_format(request) + assert result == "wav" + + def test_extracts_mp3_format(self, serving_chat): + request = self._make_request({"format": "mp3", "voice": "alloy"}) + result = serving_chat._resolve_audio_format(request) + assert result == "mp3" + + def test_pcm16_mapped_to_pcm(self, serving_chat): + request = self._make_request({"format": "pcm16", "voice": "alloy"}) + result = serving_chat._resolve_audio_format(request) + assert result == "pcm" + + def test_invalid_format_returns_error(self, serving_chat): + from vllm.entrypoints.openai.engine.protocol import ErrorResponse + + request = self._make_request({"format": "aac", "voice": "alloy"}) + result = serving_chat._resolve_audio_format(request) + assert isinstance(result, ErrorResponse) + assert "aac" in result.error.message + + def test_all_supported_formats_accepted(self, serving_chat): + from vllm.entrypoints.openai.engine.protocol import ErrorResponse + + for fmt in SUPPORTED_CHAT_AUDIO_FORMATS: + request = self._make_request({"format": fmt, "voice": "alloy"}) + result = serving_chat._resolve_audio_format(request) + assert not isinstance(result, ErrorResponse), f"Format {fmt} should be accepted" diff --git a/tests/entrypoints/openai_api/test_diffusion_request_utils.py b/tests/entrypoints/openai_api/test_diffusion_request_utils.py new file mode 100644 index 000000000..0b3091f24 --- /dev/null +++ b/tests/entrypoints/openai_api/test_diffusion_request_utils.py @@ -0,0 +1,130 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +import pytest + +from vllm_omni.entrypoints.openai.diffusion_request_utils import ( + apply_normalized_diffusion_request_extra_args, + normalize_diffusion_request_args, +) +from vllm_omni.inputs.data import OmniDiffusionSamplingParams + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +REQUEST_SOURCES = ( + "root.declared", + "root.extra_args", + "root.extra_params", + "nested.declared", + "nested.extra_args", + "nested.extra_params", +) + + +def _source_args(source: str, value: object) -> dict[str, object]: + scope, container = source.split(".") + payload = {"solver": value} if container == "declared" else {container: {"solver": value}} + kwargs: dict[str, object] = {scope: payload} + if container == "declared": + kwargs["registered_extra_fields"] = {"solver"} + return kwargs + + +@pytest.mark.parametrize("source", REQUEST_SOURCES) +@pytest.mark.parametrize(("value", "expected"), [("ddim", "ddim"), (None, "euler")]) +def test_normalizer_routes_values_without_erasing_defaults( + source: str, + value: object, + expected: str, +) -> None: + normalized, _ = normalize_diffusion_request_args(**_source_args(source, value)) + sampling_params = OmniDiffusionSamplingParams( + extra_args={"solver": "euler", "stage_default": True}, + ) + apply_normalized_diffusion_request_extra_args(sampling_params, normalized) + + assert sampling_params.extra_args == {"solver": expected, "stage_default": True} + + +def test_non_overlapping_legacy_and_canonical_values_are_merged(mocker) -> None: + warning_once = mocker.patch("vllm_omni.entrypoints.openai.diffusion_request_utils.logger.warning_once") + + normalized, _ = normalize_diffusion_request_args( + root={"extra_args": {"cfg_text_scale": 7.0}}, + nested={"extra_params": {"sample_solver": "euler"}}, + ) + + assert normalized == {"cfg_text_scale": 7.0, "sample_solver": "euler"} + warning_once.assert_called_once() + + +@pytest.mark.parametrize( + "kwargs, expected_sources", + [ + ( + { + "root": {"seed": 1, "extra_args": {"seed": 2}}, + "serving_root_fields": {"seed"}, + }, + "request.seed, request.extra_args.seed", + ), + ( + { + "root": {"cfg_scale": 7.0}, + "nested": {"true_cfg_scale": 2.0}, + "serving_root_fields": {"true_cfg_scale"}, + "root_field_aliases": {"cfg_scale": "true_cfg_scale"}, + }, + "request.cfg_scale, request.extra_body.true_cfg_scale", + ), + ( + { + "root": { + "extra_args": {"solver": "euler"}, + "extra_params": {"solver": "ddim"}, + } + }, + "request.extra_args.solver, request.extra_params.solver", + ), + ( + { + "root": {"cfg_text_scale": 7.0}, + "nested": {"extra_args": {"cfg_text_scale": 8.0}}, + "registered_extra_fields": {"cfg_text_scale"}, + }, + "request.cfg_text_scale, request.extra_body.extra_args.cfg_text_scale", + ), + ], +) +def test_duplicate_sources_are_rejected( + kwargs: dict[str, object], + expected_sources: str, + mocker, +) -> None: + warning_once = mocker.patch("vllm_omni.entrypoints.openai.diffusion_request_utils.logger.warning_once") + + with pytest.raises(ValueError, match=expected_sources): + normalize_diffusion_request_args(**kwargs) + + warning_once.assert_not_called() + + +@pytest.mark.parametrize( + ("scope", "name"), + ( + ("root", "extra_args"), + ("root", "extra_params"), + ("nested", "extra_args"), + ("nested", "extra_params"), + ), +) +def test_public_containers_must_be_objects(scope: str, name: str) -> None: + with pytest.raises(ValueError, match="must be a JSON object"): + normalize_diffusion_request_args(**{scope: {name: ["not", "an", "object"]}}) + + +def test_container_keys_must_be_strings() -> None: + with pytest.raises(ValueError, match="must be a JSON object with string keys"): + normalize_diffusion_request_args(root={"extra_args": {1: "invalid"}}) diff --git a/tests/entrypoints/openai_api/test_duplex_capability.py b/tests/entrypoints/openai_api/test_duplex_capability.py new file mode 100644 index 000000000..153563cbb --- /dev/null +++ b/tests/entrypoints/openai_api/test_duplex_capability.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_api_server_does_not_import_experimental_duplex_at_module_load() -> None: + api_server = Path(__file__).parents[3] / "vllm_omni/entrypoints/openai/api_server.py" + module = ast.parse(api_server.read_text()) + + top_level_imports = { + node.module for node in module.body if isinstance(node, ast.ImportFrom) and isinstance(node.module, str) + } + + assert not any(module.startswith("vllm_omni.experimental.fullduplex") for module in top_level_imports) diff --git a/tests/entrypoints/openai_api/test_duplex_handler.py b/tests/entrypoints/openai_api/test_duplex_handler.py new file mode 100644 index 000000000..9f8768bf4 --- /dev/null +++ b/tests/entrypoints/openai_api/test_duplex_handler.py @@ -0,0 +1,7187 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +import struct +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest +from starlette.websockets import WebSocketDisconnect + +from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig +from vllm_omni.experimental.fullduplex.engine.duplex_control_client import DuplexControlRequestError +from vllm_omni.experimental.fullduplex.engine.duplex_runtime import duplex_resource_request_id +from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseActivity +from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence, DuplexSessionLifecycleMessage +from vllm_omni.experimental.fullduplex.minicpmo45 import ( + MiniCPMO45NativeDuplexServingAdapter, + MiniCPMO45PcmAppendBuffer, +) +from vllm_omni.experimental.fullduplex.minicpmo45.data_plane import ( + MiniCPMO45DataPlaneContext, + MiniCPMO45DataPlaneSession, +) +from vllm_omni.experimental.fullduplex.minicpmo45.runtime import ( + MiniCPMO45DuplexRuntimeExtension, +) +from vllm_omni.experimental.fullduplex.minicpmo45.session import ( + MiniCPMO45ServingSessionState, +) +from vllm_omni.experimental.fullduplex.openai.protocol import ( + DuplexCapabilities, + DuplexOverlapPolicy, + DuplexPlaybackCommitPolicy, + DuplexSession, + DuplexSessionConfig, + ResponseCreateOptions, +) +from vllm_omni.experimental.fullduplex.openai.realtime_session import NativeRealtimeSessionProtocol +from vllm_omni.experimental.fullduplex.openai.runtime_adapter import ServingRuntimeConfigError +from vllm_omni.experimental.fullduplex.openai.serving import ( + OmniDuplexSessionHandler, + should_enable_duplex_endpoint, +) +from vllm_omni.experimental.fullduplex.openai.websocket import DuplexWebSocketActor +from vllm_omni.experimental.fullduplex.output import attach_duplex_output_decision +from vllm_omni.outputs import OmniRequestOutput + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _ModelConfig: + model = "test-model" + + +class FakeEngineClient: + output_modalities = ["text", "audio"] + + def __init__( + self, + *, + fail_open: bool = False, + fail_signal: bool = False, + fail_touch: bool = False, + fail_signal_events: set[str] | None = None, + fail_close: bool = False, + fail_abort: bool = False, + control_result: dict[str, object] | None = None, + open_result: dict[str, object] | None = None, + append_result: dict[str, object] | None = None, + append_results: list[dict[str, object]] | None = None, + collect_outputs: list[list[object]] | None = None, + collect_delay_s: float = 0.0, + signal_result: dict[str, object] | None = None, + close_result: dict[str, object] | None = None, + ) -> None: + self.fail_open = fail_open + self.fail_signal = fail_signal + self.fail_touch = fail_touch + self.fail_signal_events = set(fail_signal_events or ()) + self.fail_close = fail_close + self.fail_abort = fail_abort + self.control_result = control_result + self.open_result = open_result + self.append_result = append_result + self.append_results = list(append_results or []) + self.collect_outputs = list(collect_outputs or []) + self.collect_delay_s = collect_delay_s + self.signal_result = signal_result + self.close_result = close_result + self.opened: list[str] = [] + self.opened_fences: list[DuplexFence | None] = [] + self.appended: list[tuple[str, str, object, bool]] = [] + self.append_operation_ids: list[str | None] = [] + self.appended_fences: list[DuplexFence | None] = [] + self.opened_configs: list[dict[str, object]] = [] + self.opened_runtime_configs: list[dict[str, object]] = [] + self.signals: list[tuple[str, str]] = [] + self.signal_fences: list[DuplexFence | None] = [] + self.signal_next_fences: list[DuplexFence | None] = [] + self.signal_session_configs: list[dict[str, object] | None] = [] + self.signal_runtime_configs: list[dict[str, object] | None] = [] + self.closed: list[tuple[str, str]] = [] + self.touched: list[tuple[str, DuplexLeaseActivity]] = [] + self.touch_fences: list[DuplexFence] = [] + self.resumed: list[tuple[str, int]] = [] + self.resume_fences: list[DuplexFence] = [] + self.aborted: list[str] = [] + self.abort_batches: list[list[str]] = [] + self.internal_abort_batches: list[list[str]] = [] + self.collected: list[tuple[str, int | None]] = [] + self.duplex_lifecycle_events: asyncio.Queue[DuplexSessionLifecycleMessage] = asyncio.Queue() + + async def open_duplex_session_async( + self, + session_id: str, + *, + session_mode: str = "duplex", + capabilities: dict[str, object] | None = None, + session_config: dict[str, object] | None = None, + runtime_config: dict[str, object] | None = None, + timeout: float | None = None, + fence: DuplexFence | None = None, + ) -> None: + del timeout + if self.fail_open: + raise RuntimeError("open failed") + self.opened.append(session_id) + self.opened_fences.append(fence) + self.opened_configs.append(dict(session_config or {})) + self.opened_runtime_configs.append(dict(runtime_config or {})) + return self.open_result if self.open_result is not None else self.control_result + + async def append_duplex_input_async( + self, + session_id: str, + *, + mode: str, + payload: object, + operation_id: str | None = None, + final: bool = False, + timeout: float | None = None, + collect_outputs: bool = True, + fence: DuplexFence | None = None, + ) -> None: + del timeout, collect_outputs + self.appended.append((session_id, mode, payload, final)) + self.append_operation_ids.append(operation_id) + self.appended_fences.append(fence) + if self.append_results: + return self.append_results.pop(0) + return self.append_result if self.append_result is not None else self.control_result + + async def collect_duplex_data_plane_outputs_async( + self, + request_id: str, + *, + response_stage_id: int | None = None, + timeout: float | None = None, + ) -> list[object]: + del timeout + self.collected.append((request_id, response_stage_id)) + if self.collect_delay_s > 0: + await asyncio.sleep(self.collect_delay_s) + if not self.collect_outputs: + return [] + return self.collect_outputs.pop(0) + + async def signal_duplex_turn_async( + self, + session_id: str, + *, + event: str, + timeout: float | None = None, + fence: DuplexFence | None = None, + next_fence: DuplexFence | None = None, + session_config: dict[str, object] | None = None, + runtime_config: dict[str, object] | None = None, + ) -> None: + del timeout + if self.fail_signal or event in self.fail_signal_events: + raise RuntimeError("signal failed") + self.signals.append((session_id, event)) + self.signal_fences.append(fence) + self.signal_next_fences.append(next_fence) + self.signal_session_configs.append(session_config) + self.signal_runtime_configs.append(runtime_config) + return self.signal_result if self.signal_result is not None else self.control_result + + async def close_duplex_session_async( + self, + session_id: str, + *, + reason: str = "client_close", + timeout: float | None = None, + fence: DuplexFence | None = None, + ) -> None: + del timeout + if self.fail_close: + raise RuntimeError("close failed") + self.closed.append((session_id, reason)) + return self.close_result if self.close_result is not None else self.control_result + + async def touch_duplex_session_async( + self, + session_id: str, + *, + fence: DuplexFence, + activity: DuplexLeaseActivity, + timeout: float | None = None, + ) -> dict[str, object]: + del timeout + if self.fail_touch: + raise RuntimeError("touch failed") + self.touched.append((session_id, activity)) + self.touch_fences.append(fence) + return { + "operation": "touch", + "session_id": session_id, + "ok": True, + "stage_results": [ + { + "stage_id": -1, + "replica_id": -1, + "result": {"supported": True, "lease_generation": 0}, + } + ], + } + + async def resume_duplex_session_async( + self, + session_id: str, + *, + fence: DuplexFence, + expected_lease_generation: int, + timeout: float | None = None, + ) -> dict[str, object]: + del timeout + self.resumed.append((session_id, expected_lease_generation)) + self.resume_fences.append(fence) + return { + "operation": "resume", + "session_id": session_id, + "ok": True, + "stage_results": [ + { + "stage_id": -1, + "replica_id": -1, + "result": { + "supported": True, + "lease_generation": expected_lease_generation + 1, + }, + } + ], + } + + async def abort(self, request_ids: list[str]) -> None: + if self.fail_abort: + raise RuntimeError("abort failed") + assert isinstance(request_ids, list) + self.abort_batches.append(list(request_ids)) + self.aborted.extend(request_ids) + + async def _abort_internal_requests(self, request_ids: list[str]) -> None: + if self.fail_abort: + raise RuntimeError("abort failed") + assert isinstance(request_ids, list) + self.internal_abort_batches.append(list(request_ids)) + self.aborted.extend(request_ids) + + +class FakeChatService: + duplex_serving_adapter_path = ( + "vllm_omni.experimental.fullduplex.minicpmo45.serving_adapter.MiniCPMO45ServingRuntimeAdapter" + ) + + def __init__(self, engine_client: FakeEngineClient) -> None: + self.engine_client = engine_client + self.model_config = _ModelConfig() + self.seen_request_ids: list[str] = [] + + async def create_chat_completion(self, request, raw_request=None): + self.seen_request_ids.append(request.request_id) + + async def _gen(): + await asyncio.sleep(999) + yield "data: [DONE]\n\n" + + return _gen() + + def create_audio(self, audio_obj): + return SimpleNamespace(audio_data=f"wav-{int(audio_obj.audio_tensor.shape[0])}") + + +def _test_data_plane() -> MiniCPMO45DataPlaneSession: + def encode_audio( + audio: object, + sample_rate_hz: int, + response_format: str, + speed: float | None, + ) -> str | None: + del sample_rate_hz, response_format, speed + if audio is None: + return None + if hasattr(audio, "numel"): + return f"wav-{int(audio.numel())}" + return f"wav-{int(np.asarray(audio).size)}" + + return MiniCPMO45DataPlaneSession(encode_audio) + + +def _data_plane_context(session: DuplexSession | None = None) -> MiniCPMO45DataPlaneContext: + if session is None: + return MiniCPMO45DataPlaneContext() + return MiniCPMO45DataPlaneContext( + epoch=session.epoch, + turn_id=session.turn_id, + active_response_turn_id=session.active_response_turn_id, + active_response_id=session.active_response_id, + auto_responds=bool(session.config.extra_body.get("auto_response", False)), + response_format=session.config.response_format, + speed=session.config.speed, + modalities=tuple(session.config.modalities), + ) + + +def _project_data_plane( + data_plane: MiniCPMO45DataPlaneSession, + result: object, + *, + session: DuplexSession | None = None, +) -> list[dict[str, object]]: + return list(data_plane.project(result, context=_data_plane_context(session))) + + +class TimedWebSocket: + def __init__(self, *, on_send=None, receive_timeout_s: float = 1.0): + self._q: asyncio.Queue[str | BaseException] = asyncio.Queue() + self.sent: list[dict[str, Any]] = [] + self.accepted = False + self._on_send = on_send + self._receive_timeout_s = receive_timeout_s + self.query_params: dict[str, str] = {} + + async def accept(self): + self.accepted = True + + async def receive_text(self) -> str: + try: + item = await asyncio.wait_for(self._q.get(), timeout=self._receive_timeout_s) + except asyncio.TimeoutError as exc: + raise WebSocketDisconnect(code=1000) from exc + if isinstance(item, BaseException): + raise item + return item + + async def send_json(self, data: dict[str, Any]): + self.sent.append(data) + if self._on_send is not None: + self._on_send(self, data) + + def put(self, payload: dict[str, Any]) -> None: + self._q.put_nowait(json.dumps(payload)) + + async def close(self, code: int = 1000, reason: str | None = None) -> None: + del reason + self._q.put_nowait(WebSocketDisconnect(code=code)) + + def sent_types(self) -> list[str]: + return [m.get("type", "") for m in self.sent] + + +def test_native_realtime_protocol_emits_speak_once_per_response(): + protocol = NativeRealtimeSessionProtocol({}) + response_id = "resp-speak-once" + + projected = protocol.encode_outbound_event( + { + "type": "response.created", + "response_id": response_id, + "modalities": ["audio", "text"], + } + ) + projected.extend( + protocol.encode_outbound_event( + { + "type": "response.speak", + "response_id": response_id, + "text": "hello", + "model_speak": True, + } + ) + ) + projected.extend( + protocol.encode_outbound_event( + { + "type": "response.output_audio.delta", + "response_id": response_id, + "audio": base64.b64encode(b"\x00\x00").decode("ascii"), + "format": "pcm16", + "sample_rate_hz": 24000, + "model_speak": True, + } + ) + ) + + assert [event["type"] for event in projected].count("response.speak") == 1 + speak = next(event for event in projected if event["type"] == "response.speak") + assert "text" not in speak + assert speak["metadata"] == {"model_speak": True} + + +def test_native_realtime_protocol_speak_does_not_expose_internal_event(): + protocol = NativeRealtimeSessionProtocol({}) + + projected = protocol.encode_outbound_event( + { + "type": "response.speak", + "response_id": "resp-curated-speak", + "session_id": "sid-curated-speak", + "epoch": 3, + "text": "transcript belongs on the delta channel", + "model_speak": True, + "data_plane_request_id": "internal-request-id", + "uses_model_runner_scheduler": True, + } + ) + + assert len(projected) == 1 + projected[0].pop("event_id") + assert projected == [ + { + "type": "response.speak", + "response_id": "resp-curated-speak", + "item_id": "item_resp-curated-speak", + "output_index": 0, + "content_index": 0, + "metadata": { + "session_id": "sid-curated-speak", + "epoch": 3, + "model_speak": True, + }, + } + ] + + +@pytest.mark.asyncio +async def test_native_realtime_protocol_drains_internal_conversation_item_control_event(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol.bind_sender(ws.send_json) + + ws.put({"type": "session.update", "model": "test-model", "session_id": "rt-delete"}) + session_create = json.loads(await protocol.receive_internal_event_text(ws)) + assert session_create["type"] == "session.create" + + ws.put( + { + "type": "conversation.item.create", + "item": { + "id": "item-a", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + }, + } + ) + assert json.loads(await protocol.receive_internal_event_text(ws)) == { + "type": "turn.signal", + "event": "conversation.item.create", + "payload": { + "item": { + "id": "item-a", + "object": "realtime.item", + "type": "message", + "status": "completed", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + } + }, + } + + ws.put({"type": "conversation.item.delete", "item_id": "item-a"}) + translated = json.loads(await protocol.receive_internal_event_text(ws)) + + assert translated == { + "type": "turn.signal", + "event": "conversation.item.delete", + "payload": {"item_id": "item-a"}, + } + + +@pytest.mark.asyncio +async def test_native_realtime_protocol_conversation_item_create_commits_user_text(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol.bind_sender(ws.send_json) + + ws.put({"type": "session.update", "model": "test-model", "session_id": "rt-item-create"}) + session_create = json.loads(await protocol.receive_internal_event_text(ws)) + assert session_create["type"] == "session.create" + + ws.put( + { + "type": "conversation.item.create", + "item": { + "id": "item-user-text", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + }, + } + ) + + translated = json.loads(await protocol.receive_internal_event_text(ws)) + + assert translated == { + "type": "turn.signal", + "event": "conversation.item.create", + "payload": { + "item": { + "id": "item-user-text", + "object": "realtime.item", + "type": "message", + "status": "completed", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + } + }, + } + + +@pytest.mark.asyncio +async def test_native_realtime_protocol_audio_commit_requires_non_empty_buffer(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol.bind_sender(ws.send_json) + + ws.put({"type": "session.update", "model": "test-model", "session_id": "rt-audio-commit"}) + session_create = json.loads(await protocol.receive_internal_event_text(ws)) + assert session_create["type"] == "session.create" + + ws.put({"type": "input_audio_buffer.commit", "final": True}) + with pytest.raises(WebSocketDisconnect): + await protocol.receive_internal_event_text(ws) + + assert ws.sent[-1]["type"] == "error" + assert ws.sent[-1]["error"]["code"] == "input_audio_buffer_empty" + + +@pytest.mark.asyncio +async def test_native_realtime_protocol_audio_commit_does_not_auto_create_response(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol.bind_sender(ws.send_json) + + ws.put({"type": "session.update", "model": "test-model", "session_id": "rt-audio-commit-full"}) + session_create = json.loads(await protocol.receive_internal_event_text(ws)) + assert session_create["type"] == "session.create" + + pcm = struct.pack("<2h", 1024, -1024) + ws.put({"type": "input_audio_buffer.append", "audio": base64.b64encode(pcm).decode("ascii"), "format": "pcm16"}) + append_event = json.loads(await protocol.receive_internal_event_text(ws)) + assert append_event["type"] == "input_audio_buffer.append" + + ws.put({"type": "input_audio_buffer.commit", "final": True}) + commit_event = json.loads(await protocol.receive_internal_event_text(ws)) + + assert commit_event["type"] == "input_audio_buffer.commit" + assert commit_event["response_create"] is False + + +@pytest.mark.asyncio +async def test_native_realtime_protocol_rejects_invalid_sample_rate_without_closing(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol.bind_sender(ws.send_json) + + ws.put({"type": "session.update", "model": "test-model", "session_id": "rt-invalid-rate"}) + session_create = json.loads(await protocol.receive_internal_event_text(ws)) + assert session_create["type"] == "session.create" + + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm16_b64(1), + "format": "pcm16", + "sample_rate_hz": 192_001, + } + ) + ws.put({"type": "input_audio_buffer.clear"}) + translated = json.loads(await protocol.receive_internal_event_text(ws)) + + assert translated == {"type": "input_audio_buffer.clear", "reason": "input_audio_buffer.clear"} + error = next(event for event in ws.sent if event.get("type") == "error") + assert error["error"]["code"] == "bad_event" + assert error["error"]["param"] == "sample_rate_hz" + + +@pytest.mark.asyncio +async def test_native_realtime_protocol_audio_clear_is_not_barge_in_cancel(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol.bind_sender(ws.send_json) + + ws.put({"type": "session.update", "model": "test-model", "session_id": "rt-audio-clear"}) + session_create = json.loads(await protocol.receive_internal_event_text(ws)) + assert session_create["type"] == "session.create" + + ws.put({"type": "input_audio_buffer.clear"}) + clear_event = json.loads(await protocol.receive_internal_event_text(ws)) + + assert clear_event == {"type": "input_audio_buffer.clear", "reason": "input_audio_buffer.clear"} + + +@pytest.mark.asyncio +async def test_native_realtime_protocol_preserves_input_turn_policy_hints(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol.bind_sender(ws.send_json) + + translated = await protocol._to_duplex_event( + { + "type": "input_audio_buffer.append", + "audio": "AAAA", + "format": "pcm_f32le", + "duration_ms": 240, + "vad": {"is_speech": True, "speech_probability": 0.9}, + "overlap_action": "listen", + } + ) + + assert translated is not None + assert translated["duration_ms"] == 240 + assert translated["vad"] == {"is_speech": True, "speech_probability": 0.9} + assert translated["overlap_action"] == "listen" + + +@pytest.mark.asyncio +async def test_native_realtime_protocol_rejects_unimplemented_server_vad(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol.bind_sender(ws.send_json) + + translated = await protocol._to_duplex_event( + { + "type": "session.update", + "model": "test-model", + "session_id": "rt-turn-detection", + "turn_detection": { + "type": "server_vad", + "interrupt_response": False, + "silence_duration_ms": 900, + "threshold": 0.4, + }, + } + ) + + assert translated is None + error = ws.sent[-1] + assert error["type"] == "error" + assert error["error"]["code"] == "unsupported_turn_detection" + assert "server_vad" in error["error"]["message"] + + +@pytest.mark.asyncio +async def test_native_realtime_protocol_accepts_disabled_turn_detection(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol.bind_sender(ws.send_json) + + ws.put( + { + "type": "session.update", + "model": "test-model", + "session_id": "rt-no-turn-detection", + "turn_detection": None, + } + ) + translated = json.loads(await protocol.receive_internal_event_text(ws)) + + assert translated["type"] == "session.create" + assert translated["session"]["extra_body"]["realtime_session_payload"]["turn_detection"] is None + + +@pytest.mark.asyncio +async def test_native_realtime_protocol_rejects_nested_vad_even_when_top_level_is_disabled(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol.bind_sender(ws.send_json) + + translated = await protocol._to_duplex_event( + { + "type": "session.update", + "model": "test-model", + "turn_detection": None, + "audio": { + "input": { + "turn_detection": { + "type": "server_vad", + } + } + }, + } + ) + + assert translated is None + assert ws.sent[-1]["error"]["code"] == "unsupported_turn_detection" + + +def test_native_duplex_handler_has_no_fixed_session_admission_cap(): + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + client_config = DuplexSessionConfig( + extra_body={"max_duplex_sessions": 99, "duplex_max_sessions": 99}, + ) + + assert not hasattr(handler, "_max_native_duplex_sessions") + assert not hasattr(handler, "_max_duplex_sessions") + assert client_config.extra_body["max_duplex_sessions"] == 99 + + +def test_native_response_options_ignore_private_runtime_config(): + session = DuplexSession( + session_id="sid-response-private-config", + config=DuplexSessionConfig(), + capabilities=DuplexCapabilities.minicpmo45_native(), + ) + + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + handler._apply_response_create_options( + session, + { + "extra_body": { + "duplex_stage_max_tokens": {"0": 999}, + "client_trace_id": "trace-1", + } + }, + ) + session.begin_response() + + assert "duplex_stage_max_tokens" not in session.config.extra_body + assert "client_trace_id" not in session.config.extra_body + assert session.response_config.extra_body["client_trace_id"] == "trace-1" + + +@pytest.mark.parametrize( + "payload", + [ + {"instructions": "not wired"}, + {"voice": "not-wired"}, + {"temperature": 0.2}, + {"max_output_tokens": 8}, + {"tools": [{"type": "function", "name": "lookup"}]}, + ], +) +def test_native_response_create_rejects_unwired_generation_options(payload): + session = DuplexSession( + session_id="sid-response-unwired-options", + config=DuplexSessionConfig(), + capabilities=DuplexCapabilities.minicpmo45_native(), + ) + + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + error = handler._apply_response_create_options(session, payload) + + assert error == "unsupported_native_response_options" + session.begin_response() + assert session.response_config == session.config + + +def test_chat_fallback_excludes_realtime_response_metadata_from_model_request(): + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + session = DuplexSession( + session_id="sid-response-protocol-metadata", + config=DuplexSessionConfig(), + ) + session.reserve_response_options( + ResponseCreateOptions( + extra_body={ + "user": "trace-1", + "realtime_response_conversation": "none", + "realtime_response_metadata": {"source": "test"}, + "realtime_response_prompt": {"id": "prompt-1"}, + "realtime_response_tools": [ + { + "type": "function", + "function": {"name": "lookup", "parameters": {}}, + } + ], + "realtime_response_tool_choice": "auto", + } + ) + ) + session.begin_response() + + request = handler._build_chat_request(session, "request-1") + created = handler._response_created_payload(session, session.active_response_id or "response-1", epoch=0) + dumped = request.model_dump() + + assert dumped["user"] == "trace-1" + assert dumped["tools"][0]["function"]["name"] == "lookup" + assert dumped["tool_choice"] == "auto" + assert not any(key.startswith("realtime_response_") for key in dumped) + assert created["conversation"] == "none" + assert created["metadata"] == {"source": "test"} + assert created["prompt"] == {"id": "prompt-1"} + assert session.response_config.extra_body["realtime_response_conversation"] == "none" + + +@pytest.mark.asyncio +async def test_native_response_create_surfaces_unwired_generation_options_error(): + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(FakeEngineClient()), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-response-unwired-options-event")) + ws.put( + { + "type": "response.create", + "response": {"instructions": "not wired"}, + } + ) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + error = next(message for message in ws.sent if message.get("type") == "error") + assert error["code"] == "unsupported_native_response_options" + assert "response_create_without_input" not in {message.get("code") for message in ws.sent} + + +@pytest.mark.asyncio +async def test_realtime_session_update_preserves_tools_and_metadata(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_session_create("sid-rt-update-fields")) + ws.put( + { + "type": "turn.signal", + "event": "session.update", + "payload": { + "tools": [{"type": "function", "name": "lookup"}], + "tool_choice": "auto", + "metadata": {"demo": "yes"}, + }, + } + ) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + updated = next(m for m in ws.sent if m.get("type") == "session.updated") + session = updated["session"] + assert session["tools"] == [{"type": "function", "name": "lookup"}] + assert session["tool_choice"] == "auto" + assert session["metadata"] == {"demo": "yes"} + assert "turn_detection" not in session + runtime_config = engine.signal_session_configs[-1] + assert runtime_config is not None + assert runtime_config["extra_body"]["realtime_tools"] == [{"type": "function", "name": "lookup"}] + assert runtime_config["extra_body"]["realtime_metadata"] == {"demo": "yes"} + + +@pytest.mark.asyncio +async def test_native_realtime_protocol_non_speech_append_does_not_emit_speech_started(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol.bind_sender(ws.send_json) + + translated = await protocol._to_duplex_event( + { + "type": "input_audio_buffer.append", + "audio": "AAAA", + "format": "pcm_f32le", + "vad": {"is_speech": False}, + } + ) + + assert translated is not None + assert translated["vad"] == {"is_speech": False} + assert not any(event.get("type") == "input_audio_buffer.speech_started" for event in ws.sent) + + +@pytest.mark.asyncio +async def test_native_session_update_rebuilds_server_runtime_policy(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-runtime-update")) + ws.put( + { + "type": "turn.signal", + "event": "session.update", + "payload": {"temperature": 0.2, "max_output_tokens": 7}, + } + ) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert "session.updated" in ws.sent_types() + public_config = engine.signal_session_configs[-1] + runtime_config = engine.signal_runtime_configs[-1] + assert public_config is not None + assert runtime_config is not None + assert "duplex_stage_sampling_params" not in public_config["extra_body"] + assert runtime_config["duplex_stage_max_tokens"]["0"] == 7 + assert runtime_config["duplex_stage_sampling_params"]["0"]["temperature"] == 0.2 + assert runtime_config["duplex_stage_sampling_params"]["0"]["top_k"] == 20 + assert runtime_config["duplex_stage_sampling_params"]["0"]["top_p"] == 0.8 + + +@pytest.mark.asyncio +async def test_native_session_update_rejects_client_runtime_config(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-runtime-update-reject")) + ws.put( + { + "type": "turn.signal", + "event": "session.update", + "payload": { + "extra_body": {"duplex_stage_max_tokens": {"0": 999}}, + }, + } + ) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + error = next(message for message in ws.sent if message.get("type") == "error") + assert error["code"] == "invalid_duplex_runtime_config" + assert ("sid-runtime-update-reject", "session.update") not in engine.signals + + +@pytest.mark.asyncio +async def test_minicpmo_native_session_update_requires_ref_audio_before_enabling_audio(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-runtime-update-ref-required", modalities=["text"])) + ws.put( + { + "type": "turn.signal", + "event": "session.update", + "payload": {"modalities": ["text", "audio"]}, + } + ) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + error = next(message for message in ws.sent if message.get("type") == "error") + assert error["code"] == "ref_audio_required" + assert "session.updated" not in ws.sent_types() + assert ("sid-runtime-update-ref-required", "session.update") not in engine.signals + + +def test_native_realtime_protocol_audio_delta_preserves_sample_rate_hz(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + + payloads = protocol._from_duplex_event( + { + "type": "response.output_audio.delta", + "response_id": "resp-a", + "audio": "AAAA", + "format": "pcm", + "sample_rate_hz": 24000, + } + ) + + audio_events = [ + payload for payload in payloads if payload["type"] in {"response.output_audio.delta", "response.audio.delta"} + ] + assert {payload["type"] for payload in audio_events} == {"response.audio.delta"} + assert {payload["format"] for payload in audio_events} == {"pcm16"} + assert {payload["sample_rate_hz"] for payload in audio_events} == {24000} + + +def test_native_realtime_protocol_ignores_removed_legacy_event_switches(): + ws = TimedWebSocket() + ws.query_params = { + "legacy_audio_events": "true", + "vllm_omni_legacy_events": "true", + "output_audio_events": "true", + "vllm_omni_output_audio_events": "true", + } + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + + payloads = protocol._from_duplex_event( + { + "type": "response.output_audio.delta", + "response_id": "resp-canonical", + "audio": "AAAA", + "text": "hello", + "format": "pcm", + "sample_rate_hz": 24000, + "end_of_turn": True, + } + ) + + event_types = {payload["type"] for payload in payloads} + assert "response.audio.delta" in event_types + assert "response.audio.done" in event_types + assert "response.audio_transcript.delta" in event_types + assert "response.audio_transcript.done" in event_types + assert "response.output_audio.delta" not in event_types + assert "response.output_audio.done" not in event_types + assert "response.output_audio_transcript.delta" not in event_types + assert "response.output_audio_transcript.done" not in event_types + assert "response.output_item.created" not in event_types + assert "response.text.delta" not in event_types + + +def test_native_realtime_protocol_emits_terminal_audio_transcript_events(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + + payloads = protocol._from_duplex_event( + { + "type": "response.output_audio.delta", + "response_id": "resp-a", + "audio": "AAAA", + "text": "hello", + "format": "pcm", + "sample_rate_hz": 24000, + "end_of_turn": True, + } + ) + + by_type = {payload["type"]: payload for payload in payloads} + assert "response.audio.done" in by_type + assert "response.audio_transcript.delta" in by_type + assert "response.audio_transcript.done" in by_type + assert by_type["response.content_part.done"]["part"]["transcript"] == "hello" + assert by_type["response.output_item.done"]["item"]["object"] == "realtime.item" + assert by_type["response.done"]["response"]["output"][0]["object"] == "realtime.item" + + +def test_native_realtime_protocol_terminal_transcript_equals_joined_deltas(): + protocol = NativeRealtimeSessionProtocol(TimedWebSocket()) # type: ignore[arg-type] + response_id = "resp-transcript-contract" + events: list[dict[str, object]] = [] + + for text, end_of_turn in (("It's Canberra.", False), (" Next question.", True)): + events.extend( + protocol._from_duplex_event( + { + "type": "response.output_audio.delta", + "response_id": response_id, + "audio": "AAAA", + "text": text, + "format": "pcm", + "sample_rate_hz": 24000, + "end_of_turn": end_of_turn, + } + ) + ) + + transcript = "".join(str(event["delta"]) for event in events if event["type"] == "response.audio_transcript.delta") + done = [str(event["transcript"]) for event in events if event["type"] == "response.audio_transcript.done"] + + assert transcript == "It's Canberra. Next question." + assert done == [transcript] + + +def test_native_realtime_protocol_updates_in_progress_item_for_audio_truncate(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + + protocol._from_duplex_event({"type": "response.created", "response_id": "resp-truncate"}) + protocol._from_duplex_event( + { + "type": "response.output_audio.delta", + "response_id": "resp-truncate", + "audio": "AAAA", + "text": "hello", + "format": "pcm", + "sample_rate_hz": 24000, + "audio_duration_ms": 100, + "audio_text_marks": [{"text_chars": 5, "audio_end_ms": 100}], + } + ) + + item = protocol._conversation_items["item_resp-truncate"] + assert item["content"][0]["type"] == "output_audio" + assert item["content"][0]["transcript"] == "hello" + assert item["content"][0]["audio_duration_ms"] == 100 + + translated = asyncio.run( + protocol._to_duplex_event( + { + "type": "conversation.item.truncate", + "item_id": "item_resp-truncate", + "content_index": 0, + "audio_end_ms": 50, + } + ) + ) + + assert translated is None + assert protocol._conversation_items["item_resp-truncate"]["content"][0]["transcript"] == "he" + + +@pytest.mark.asyncio +async def test_native_realtime_protocol_rejects_truncate_for_user_item(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol.bind_sender(ws.send_json) + protocol._conversation_items["item-user"] = { + "id": "item-user", + "object": "realtime.item", + "type": "message", + "role": "user", + "status": "completed", + "content": [{"type": "input_audio", "transcript": "hello"}], + } + + translated = await protocol._to_duplex_event( + { + "type": "conversation.item.truncate", + "item_id": "item-user", + "content_index": 0, + "audio_end_ms": 10, + } + ) + + assert translated is None + error = next(event for event in ws.sent if event.get("type") == "error") + assert error["error"]["code"] == "bad_event" + + +def test_native_realtime_protocol_response_done_emits_audio_done_before_terminal_events(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol._from_duplex_event({"type": "response.created", "response_id": "resp-done"}) + protocol._from_duplex_event( + { + "type": "response.output_audio.delta", + "response_id": "resp-done", + "audio": "AAAA", + "format": "pcm", + "sample_rate_hz": 24000, + } + ) + + payloads = protocol._from_duplex_event({"type": "response.done", "response_id": "resp-done"}) + event_types = [payload["type"] for payload in payloads] + + assert event_types.index("response.audio.done") < event_types.index("response.content_part.done") + assert event_types.index("response.content_part.done") < event_types.index("response.output_item.done") + assert event_types.index("conversation.item.done") < event_types.index("response.done") + assert event_types.index("response.output_item.done") < event_types.index("response.done") + + +def test_native_realtime_protocol_audio_cancelled_uses_active_response_id(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol._from_duplex_event({"type": "response.created", "response_id": "resp-cancel"}) + + payloads = protocol._from_duplex_event( + { + "type": "audio.cancelled", + "reason": "barge_in", + "committed_ms": 0, + } + ) + + done = [payload for payload in payloads if payload.get("type") == "response.done"] + assert len(done) == 1 + assert done[0]["response_id"] == "resp-cancel" + assert done[0]["response"]["id"] == "resp-cancel" + + +def test_native_realtime_protocol_audio_cancelled_does_not_reopen_completed_response(): + ws = TimedWebSocket() + protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] + protocol._from_duplex_event({"type": "response.created", "response_id": "resp-complete"}) + protocol._from_duplex_event( + { + "type": "response.output_audio.delta", + "response_id": "resp-complete", + "audio": "AAAA", + "format": "pcm", + "sample_rate_hz": 24000, + } + ) + protocol._from_duplex_event({"type": "response.done", "response_id": "resp-complete"}) + + payloads = protocol._from_duplex_event( + { + "type": "audio.cancelled", + "reason": "session_close", + "committed_ms": 999999, + } + ) + + assert [payload.get("type") for payload in payloads] == [] + + +def test_native_audio_text_marks_are_normalized_to_session_cumulative_offsets(): + marks = OmniDuplexSessionHandler._normalize_native_audio_text_marks( + [{"text_chars": 3, "audio_end_ms": 200}], + audio_offset_ms=800, + text_offset_chars=5, + ) + + assert marks == [{"text_chars": 8, "audio_end_ms": 1000}] + + +def test_duplex_session_playback_commit_uses_multi_delta_audio_text_marks(): + session = DuplexSession( + session_id="sid-marks", + config=DuplexSessionConfig(playback_commit_policy=DuplexPlaybackCommitPolicy.ACK_ONLY.value), + ) + session.begin_response() + session.append_assistant_text("hello ") + session.mark_audio_sent(1000, text_chars=6) + session.append_assistant_text("world") + session.mark_audio_sent(2000, text_chars=11) + session.acknowledge_playback(played_ms=1500, committed_ms=1500) + + committed = session.end_response(commit_text=True) + + assert committed == {"role": "assistant", "content": "hello wo"} + assert session.history == (committed,) + + +@pytest.mark.asyncio +async def test_duplex_session_actor_preserves_wire_order_before_control(): + ws = TimedWebSocket() + actor = DuplexWebSocketActor(ws) + + await actor.enqueue_event({"type": "input_audio_buffer.append", "audio": "AAAA"}) + await actor.enqueue_event({"type": "response.cancel"}) + + first = await actor.next_event() + second = await actor.next_event() + + assert first["type"] == "input_audio_buffer.append" + assert second["type"] == "response.cancel" + + +def _pcm_f32_b64(samples: int, *, value: float = 0.05) -> str: + return base64.b64encode(struct.pack(f"<{samples}f", *([value] * samples))).decode("ascii") + + +def _pcm16_b64(samples: int = 1, *, value: int = 1000) -> str: + return base64.b64encode(struct.pack(f"<{samples}h", *([value] * samples))).decode("ascii") + + +def _session_create(session_id: str = "duplex-test") -> dict[str, Any]: + return { + "type": "session.create", + "session_id": session_id, + "session": { + "model": "test-model", + "modalities": ["text", "audio"], + "idle_timeout_s": 1, + }, + } + + +def _native_session_create( + session_id: str = "duplex-native", + *, + modalities: list[str] | None = None, +) -> dict[str, Any]: + event = _session_create(session_id) + event["session"]["model"] = "openbmb/MiniCPM-o-4_5" + event["session"]["modalities"] = list(modalities or ["text"]) + event["session"]["instructions"] = "You are a concise assistant." + event["session"]["extra_body"] = {"minicpmo45_native_duplex": True} + return event + + +def _native_realtime_session_update( + session_id: str, + *, + modalities: list[str] | None = None, +) -> dict[str, Any]: + return { + "type": "session.update", + "session": { + "session_id": session_id, + "model": "openbmb/MiniCPM-o-4_5", + "modalities": list(modalities or ["text"]), + "instructions": "You are a concise assistant.", + "idle_timeout_s": 1, + "extra_body": {"minicpmo45_native_duplex": True}, + }, + } + + +def test_native_stage0_request_id_matches_engine_after_same_session_id_reopen(): + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + first = handler._registry.create(session_id="sid-reused") + handler._registry.close(first.session_id) + reopened = handler._registry.create(session_id="sid-reused") + expected = duplex_resource_request_id( + DuplexFence( + reopened.session_id, + epoch=reopened.epoch, + incarnation=reopened.incarnation, + ), + "stage0", + ) + + assert reopened.incarnation == 1 + assert handler._native_stage0_request_id(reopened, reopened.epoch) == expected + + +def _native_audio_payload( + *, + samples: int = 16000, + value: float = 0.05, + is_speech: bool | None = True, + force_listen: bool | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "type": "audio", + "audio": _pcm_f32_b64(samples, value=value), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + if is_speech is not None: + payload["is_speech"] = is_speech + if force_listen is not None: + payload["force_listen"] = force_listen + return payload + + +def _auto_response_context( + session_id: str, + *, + playback_active: bool = False, +) -> tuple[OmniDuplexSessionHandler, DuplexSession]: + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(FakeEngineClient()), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + session = DuplexSession( + session_id=session_id, + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + if playback_active: + session.config.playback_commit_policy = DuplexPlaybackCommitPolicy.ACK_ONLY.value + session.begin_response() + session.mark_audio_sent(duration_ms=1000) + return handler, session + + +def _install_direct_silence_scheduler( + handler: OmniDuplexSessionHandler, + session: DuplexSession, +) -> None: + native = handler._minicpmo_session_state(session) + + async def _schedule(payload: object, **kwargs: Any) -> bool: + if handler._native_silence_continuation_is_stale( + session, + request_id=kwargs["request_id"], + response_id=kwargs["response_id"], + response_owned=kwargs["response_owned"], + expected_epoch=kwargs["expected_epoch"], + expected_incarnation=kwargs["expected_incarnation"], + expected_model_turn_id=kwargs["expected_model_turn_id"], + ): + return False + append_ok, _ = await handler._append_runtime_input( + session, + payload, + final=False, + send_json=kwargs["send_json"], + mode="append_audio_chunk", + expected_epoch=kwargs["expected_epoch"], + ) + return append_ok + + native.silence_continuation_scheduler = _schedule + + +def test_minicpmo_pcm_append_buffer_flush_preserves_accumulated_speech_marker(): + buffer = MiniCPMO45PcmAppendBuffer() + payload = _native_audio_payload(samples=8000) + + assert buffer.append(payload, chunk_period_ms=1000) is None + flushed = buffer.flush(chunk_period_ms=1000) + + assert flushed is not None + assert flushed["is_speech"] is True + + +def test_minicpmo_pcm_append_buffer_drops_serving_new_user_turn_marker(): + buffer = MiniCPMO45PcmAppendBuffer() + first = _native_audio_payload(samples=8000) + first.update( + new_user_turn=True, + new_user_turn_prefix_variant="clean_response_done", + force_speak=True, + ) + second = _native_audio_payload(samples=8000) + + assert buffer.append(first, chunk_period_ms=1000) is None + emitted = buffer.append(second, chunk_period_ms=1000) + + assert emitted is not None + assert emitted["is_speech"] is True + assert "new_user_turn" not in emitted + assert "new_user_turn_prefix_variant" not in emitted + assert "force_speak" not in emitted + + +def test_minicpmo_merge_native_audio_payloads_preserves_speech_marker(): + first = _native_audio_payload(samples=8000) + second = _native_audio_payload(samples=8000, value=0.0, is_speech=False) + + merged = OmniDuplexSessionHandler._merge_native_audio_payloads(first, second) + + assert merged["is_speech"] is True + + +def test_minicpmo_merge_drops_serving_new_user_turn_marker(): + first = _native_audio_payload(samples=8000) + first.update( + new_user_turn=True, + new_user_turn_prefix_variant="clean_response_done", + force_speak=True, + ) + second = _native_audio_payload(samples=8000, is_speech=False) + + merged = OmniDuplexSessionHandler._merge_native_audio_payloads(first, second) + + assert merged["is_speech"] is True + assert "new_user_turn" not in merged + assert "new_user_turn_prefix_variant" not in merged + assert "force_speak" not in merged + + +@pytest.mark.asyncio +async def test_minicpmo_clear_continuation_does_not_cancel_pending_silence_task(): + async def _pending() -> bool: + await asyncio.sleep(3600) + return True + + native = MiniCPMO45ServingSessionState() + task = asyncio.create_task(_pending()) + native.continuation_owner_id = "owner" + native.continuation_units = 1 + native.pending_silence_task = task + native.pending_silence_owner_id = "owner" + + native.clear_continuation() + + assert native.continuation_owner_id is None + assert native.continuation_units == 0 + assert native.pending_silence_task is None + assert native.pending_silence_owner_id is None + assert not task.cancelled() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +def test_auto_response_playback_overlap_keeps_model_owned_listen_speak_decision(): + handler, session = _auto_response_context("sid-auto-silent-overlap", playback_active=True) + session.mark_audio_sent(duration_ms=1000) + silent_payload = _native_audio_payload(value=0.0, is_speech=None, force_listen=False) + speech_payload = _native_audio_payload(is_speech=None, force_listen=False) + + assert handler._should_force_listen_for_auto_response_overlap(session, {}, silent_payload) is False + assert handler._should_force_listen_for_auto_response_overlap(session, {}, speech_payload) is False + assert ( + handler._should_force_listen_for_auto_response_overlap( + session, + {"force_listen": True}, + speech_payload, + ) + is True + ) + assert ( + handler._should_force_listen_for_auto_response_overlap( + session, + {"force_barge_in": True}, + speech_payload, + ) + is False + ) + + session.acknowledge_playback(played_ms=1000, committed_ms=1000) + assert handler._should_force_listen_for_auto_response_overlap(session, {}, speech_payload) is False + + +def test_auto_response_overlap_silence_advances_model_unit_and_preserves_realtime_input(): + handler, session = _auto_response_context( + "sid-auto-silent-wire-buffer", + playback_active=True, + ) + decision = handler._overlap_decision( + session, + {"is_speech": False}, + _native_audio_payload(samples=3200, is_speech=False), + ) + + assert decision["action"] == "listen" + assert decision["buffer_audio"] is True + assert decision["defer_runtime_append"] is False + assert decision["force_listen"] is False + assert decision["preserve_realtime_input"] is True + + +def test_auto_response_playback_overlap_admits_model_units_and_tracks_speech(): + handler, session = _auto_response_context("sid-auto-deferred-overlap", playback_active=True) + payload = _native_audio_payload(samples=8640) + + decision = handler._overlap_decision( + session, + {"duration_ms": 540, "is_speech": True}, + payload, + ) + + assert decision["action"] == "listen" + assert decision["defer_runtime_append"] is False + assert decision["force_listen"] is False + assert decision["buffer_audio"] is True + assert session.overlap_speech_ms == 540 + + +@pytest.mark.asyncio +async def test_auto_response_overlap_exact_unit_commit_does_not_block_or_replay_next_unit(): + class ModelUnitEngine(FakeEngineClient): + def __init__(self) -> None: + super().__init__() + self.append_started = asyncio.Event() + self.active_response_ids_at_append: list[str | None] = [] + self.response_done_seen_at_append = False + self.session: DuplexSession | None = None + self.websocket: TimedWebSocket | None = None + + async def append_duplex_input_async(self, session_id: str, **kwargs): + assert self.session is not None + assert self.websocket is not None + self.active_response_ids_at_append.append(self.session.active_response_id) + self.response_done_seen_at_append = "response.done" in self.websocket.sent_types() + kwargs.pop("expected_epoch", None) + result = await super().append_duplex_input_async(session_id, **kwargs) + self.append_started.set() + return result + + engine = ModelUnitEngine() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + engine.websocket = ws + create = _native_session_create("sid-auto-overlap-model-unit") + create["session"]["extra_body"]["auto_response"] = True + ws.put(create) + handler_task = asyncio.create_task(handler.handle_session(ws)) + + try: + for _ in range(100): + session = handler._registry.get("sid-auto-overlap-model-unit") + if session is not None and "session.created" in ws.sent_types(): + break + await asyncio.sleep(0.01) + else: + pytest.fail("native duplex session did not open") + + engine.session = session + session.config.playback_commit_policy = DuplexPlaybackCommitPolicy.ACK_ONLY.value + response_id = session.begin_response() + session.mark_audio_sent(duration_ms=2000) + request_id = handler._native_stage0_request_id(session, session.epoch) + session.bind_request(request_id) + expected_fence = DuplexFence( + session.session_id, + epoch=session.epoch, + turn_id=session.turn_id, + incarnation=session.incarnation, + ) + + chunk = { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(3200, value=0.05), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + "duration_ms": 200, + "is_speech": True, + } + for _ in range(4): + ws.put(chunk) + await asyncio.sleep(0.05) + assert engine.appended == [] + + ws.put(chunk) + await asyncio.wait_for(engine.append_started.wait(), timeout=0.5) + + ws.put({"type": "input_audio_buffer.commit", "final": True}) + for _ in range(100): + if "input.committed" in ws.sent_types(): + break + await asyncio.sleep(0.01) + else: + pytest.fail("exact model unit input did not commit") + + engine.append_started.clear() + for _ in range(5): + ws.put(chunk) + await asyncio.wait_for(engine.append_started.wait(), timeout=0.5) + await asyncio.sleep(0.05) + assert "error" not in ws.sent_types() + finally: + ws.put({"type": "session.close"}) + await asyncio.wait_for(handler_task, timeout=2) + + assert len(engine.appended) == 2 + for appended_session_id, mode, payload, final in engine.appended: + assert appended_session_id == session.session_id + assert mode == "append_audio_chunk" + assert final is False + assert isinstance(payload, dict) + assert np.frombuffer(base64.b64decode(payload["audio"]), dtype=" None: + nonlocal overlap_sent + if data.get("type") != "response.audio.delta" or overlap_sent: + return + overlap_sent = True + chunk = { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(3200, value=0.05), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + "duration_ms": 200, + "is_speech": True, + } + for _ in range(4): + ws.put(chunk) + ws.put({"type": "input_audio_buffer.commit", "final": True}) + + ws = TimedWebSocket(on_send=commit_overlap_after_first_response, receive_timeout_s=3) + create = _native_realtime_session_update(session_id) + create["session"]["extra_body"]["auto_response"] = True + ws.put(create) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(16000, value=0.05), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + "duration_ms": 1000, + "is_speech": True, + } + ) + ws.put({"type": "input_audio_buffer.commit", "final": True}) + + handler_task = asyncio.create_task(handler.handle_realtime_session(ws)) + try: + for _ in range(300): + if len(engine.appended) >= 2: + break + await asyncio.sleep(0.01) + else: + pytest.fail( + f"committed overlap was not promoted after response.done: " + f"appends={len(engine.appended)}, events={ws.sent_types()}" + ) + + assert overlap_sent is True + assert len(engine.appended) == 2 + assert engine.appended[1][3] is True + assert ws.sent_types().count("response.created") == 1 + finally: + ws.put({"type": "session.close"}) + await asyncio.wait_for(handler_task, timeout=3) + + assert ws.sent_types().count("response.done") >= 1 + + +def test_auto_response_barge_in_does_not_mark_serving_new_user_turn_payload(): + handler, session = _auto_response_context("sid-auto-barge-new-turn", playback_active=True) + payload = _native_audio_payload() + + assert handler._should_force_listen_for_auto_response_overlap(session, {"force_barge_in": True}, payload) is False + assert "new_user_turn" not in payload + assert "new_user_turn_prefix_variant" not in payload + assert session.turn_id == 0 + assert payload.get("force_speak") is not True + + +def test_native_realtime_barge_in_payload_does_not_create_serving_turn_boundary(): + payload: dict[str, object] = { + "type": "audio", + "audio": _pcm_f32_b64(16000, value=0.05), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + "is_speech": True, + } + session = DuplexSession(session_id="sid-realtime-barge", config=DuplexSessionConfig()) + session.turn_id = 2 + + assert "new_user_turn" not in payload + assert "new_user_turn_prefix_variant" not in payload + assert session.turn_id == 2 + + +def test_auto_response_force_barge_in_has_no_waiting_turn_variant(): + handler, session = _auto_response_context("sid-auto-barge-keeps-variant") + payload = _native_audio_payload() + + assert ( + handler._should_force_listen_for_auto_response_overlap( + session, + {"force_barge_in": True}, + payload, + ) + is False + ) + + +def test_duplex_endpoint_requires_explicit_session_mode_duplex(): + assert should_enable_duplex_endpoint(None) is False + assert should_enable_duplex_endpoint([]) is False + assert should_enable_duplex_endpoint([SimpleNamespace(session_mode="turn")]) is False + assert should_enable_duplex_endpoint([{"session_mode": "turn"}]) is False + assert ( + should_enable_duplex_endpoint( + [ + SimpleNamespace(session_mode="turn"), + SimpleNamespace(session_mode="duplex"), + ] + ) + is True + ) + assert should_enable_duplex_endpoint([{"session_mode": "duplex"}]) is True + + +def test_duplex_endpoint_supports_top_level_session_mode(tmp_path): + config_path = tmp_path / "stage_config.yaml" + config_path.write_text( + """ +session_mode: duplex +stage_args: + - stage_id: 0 + engine_args: {} +""", + encoding="utf-8", + ) + + assert should_enable_duplex_endpoint([], config_path=str(config_path)) is True + + +def test_duplex_handler_splits_data_plane_audio_list_into_deltas(): + import torch + + data_plane = _test_data_plane() + output = SimpleNamespace( + finished=True, + outputs=[ + SimpleNamespace( + text="hello", + multimodal_output={ + "audio": [ + torch.zeros(10, dtype=torch.float32), + torch.zeros(20, dtype=torch.float32), + ], + "sr": 24000, + }, + ) + ], + ) + + native_results = list(data_plane.project_output(output)) + + assert [result["audio_data"] for result in native_results] == ["wav-10", "wav-20"] + assert [result["text"] for result in native_results] == ["hello", ""] + assert [result["end_of_turn"] for result in native_results] == [False, True] + + +def test_duplex_listen_latent_does_not_poison_cumulative_audio_offset(): + import torch + + data_plane = _test_data_plane() + request_id = "duplex-duplex-sess-stage0" + + # A model-listen decision wraps the segment with a latent tensor that is + # NOT reply audio; it must not advance the cumulative audio offset. + listen_output = SimpleNamespace( + request_id=request_id, + finished=True, + outputs=[], + multimodal_output={ + "duplex_native_decision": "listen", + "model_listen": True, + "latent": torch.zeros(331776, dtype=torch.float32), + "meta": {"sr": 24000}, + }, + ) + listen_results = list(data_plane.project_output(listen_output)) + assert [result.get("is_listen") for result in listen_results] == [True] + + # The first speak unit carries cumulative stage-1 audio far smaller than + # the listen latent; it must still be delivered from sample 0. + speak_output = SimpleNamespace( + request_id=request_id, + finished=False, + outputs=[ + SimpleNamespace( + text=" It was a very", + multimodal_output={}, + ) + ], + multimodal_output={ + "audio": torch.zeros(32768, dtype=torch.float32), + "sr": 24000, + }, + ) + speak_results = list(data_plane.project_output(speak_output)) + assert [result.get("audio_data") for result in speak_results] == ["wav-32768"] + assert speak_results[0]["text"] == " It was a very" + + +def test_direct_listen_decision_survives_inner_completion_metadata(): + inner_output = SimpleNamespace( + outputs=[ + SimpleNamespace( + multimodal_output={"special_token_ids": {"listen_token_id": 151705}}, + ) + ] + ) + decision = MiniCPMO45DuplexRuntimeExtension().decide_output( + stage_id=0, + final_stage_id=1, + segment_finished=True, + segment_token_ids=(151705,), + segment_output_metadata={"special_token_ids": {"listen_token_id": 151705}}, + output=inner_output, + ) + assert decision is not None + output = attach_duplex_output_decision( + OmniRequestOutput( + request_id="duplex-direct-listen", + finished=True, + stage_id=0, + final_output_type=decision.final_output_type, + request_output=inner_output, + metrics={ + "stage_metrics": { + "0": { + "vllm_ttft_ms": 125.0, + "vllm_tpot_ms": 18.0, + "vllm_itl_ms": 17.5, + "vllm_itls_ms": [17.0, 18.0], + } + } + }, + ), + decision, + ) + + results = list(_test_data_plane().project_output(output)) + + assert len(results) == 1 + assert results[0]["is_listen"] is True + assert results[0]["model_listen"] is True + assert results[0]["listen_source"] == "model_listen" + assert results[0]["stage_metrics"] == output.metrics["stage_metrics"] + + +def test_duplex_segment_text_is_attached_once_across_streaming_batches(): + import torch + + class _ChatService: + model_config = _ModelConfig() + + def create_audio(self, audio_obj): + return SimpleNamespace(audio_data=f"wav-{int(audio_obj.audio_tensor.shape[0])}") + + data_plane = _test_data_plane() + request_id = "duplex-duplex-sess-stage0" + session = DuplexSession( + session_id="sid-auto-respond", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + + def _speak(total_samples: int, text: str, *, finished: bool): + output = SimpleNamespace( + request_id=request_id, + finished=finished, + outputs=[SimpleNamespace(text=text, multimodal_output={})], + multimodal_output={ + "audio": torch.zeros(total_samples, dtype=torch.float32), + "sr": 24000, + }, + ) + return _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session) + + def _audio(results): + return [r for r in results if r.get("is_listen") is False] + + def _listen(results): + return [r for r in results if r.get("is_listen") is True] + + # One talker segment streams several cumulative-audio batches, each + # carrying the SAME segment text; only the first may attach it. + assert [r["text"] for r in _audio(_speak(100, " movie called", finished=False))] == [" movie called"] + assert [r["text"] for r in _audio(_speak(220, " movie called", finished=False))] == [""] + finished_results = _speak(300, " movie called", finished=True) + assert [r["text"] for r in _audio(finished_results)] == [""] + assert _listen(finished_results) == [] + + # Continuation units re-run the talker with the SAME handed text past a + # finished boundary (every engine segment ends finished=True); the text + # must stay suppressed or each continuation duplicates the transcript. + assert [r["text"] for r in _audio(_speak(400, " movie called", finished=False))] == [""] + assert [r["text"] for r in _audio(_speak(450, " movie called", finished=True))] == [""] + + # A genuinely new segment text is attached in full. + assert [r["text"] for r in _audio(_speak(500, " Titanic", finished=True))] == [" Titanic"] + + # Text growing within a segment is delivered as suffix deltas. + assert [r["text"] for r in _audio(_speak(600, " by", finished=False))] == [" by"] + assert [r["text"] for r in _audio(_speak(700, " by James", finished=True))] == [" James"] + + # A segment whose finished batch slices to an EMPTY audio delta (all + # samples already delivered) must not block the next segment's text. + assert [r["text"] for r in _audio(_speak(800, " James Cameron. The", finished=False))] == [" James Cameron. The"] + assert _speak(800, " James Cameron. The", finished=True) == [] + assert [r["text"] for r in _audio(_speak(900, " 997.", finished=False))] == [" 997."] + + +def test_duplex_data_plane_output_prefers_audio_segment_text_metadata(): + data_plane = _test_data_plane() + request_id = "duplex-sid-native-segment-text-e0-stage0" + segment_text = "你好,有什么想聊的吗?" + cumulative_text = segment_text * 2 + output = SimpleNamespace( + request_id=request_id, + finished=False, + outputs=[SimpleNamespace(text=cumulative_text, multimodal_output={})], + multimodal_output={ + "audio": np.zeros(24000, dtype=np.float32), + "sr": 24000, + "meta.llm_output_text_utf8": np.frombuffer(segment_text.encode("utf-8"), dtype=np.uint8), + }, + ) + + results = list(data_plane.project_output(output)) + + assert len(results) == 1 + assert results[0]["audio_data"] == "wav-24000" + assert results[0]["text"] == segment_text + + next_output_without_segment_metadata = SimpleNamespace( + request_id=request_id, + finished=False, + outputs=[SimpleNamespace(text=segment_text * 3, multimodal_output={})], + multimodal_output={ + "audio": np.zeros(30720, dtype=np.float32), + "sr": 24000, + }, + ) + next_results = list(data_plane.project_output(next_output_without_segment_metadata)) + + assert len(next_results) == 1 + assert next_results[0]["audio_data"] == "wav-6720" + assert next_results[0]["text"] == "" + + +@pytest.mark.parametrize( + ("request_id", "second_samples", "expected_second_audio"), + [ + ("duplex-sid-model-turn-audio-e0-stage0", 36000, "wav-12000"), + ("duplex-sid-model-turn-audio-restart-e0-stage0", 12000, "wav-12000"), + ], + ids=["cumulative-across-model-turns", "upstream-cumulative-audio-restarts"], +) +def test_duplex_audio_cursor_across_model_turns( + request_id: str, + second_samples: int, + expected_second_audio: str, +): + data_plane = _test_data_plane() + + def output(turn_id: int, samples: int): + return SimpleNamespace( + request_id=request_id, + finished=False, + outputs=[SimpleNamespace(text="", multimodal_output={})], + multimodal_output={ + "audio": np.zeros(samples, dtype=np.float32), + "sr": 24000, + "meta.duplex_turn_id": np.array([turn_id], dtype=np.int32), + }, + ) + + first = list(data_plane.project_output(output(0, 24000))) + second = list(data_plane.project_output(output(1, second_samples))) + + assert first[0]["audio_data"] == "wav-24000" + assert second[0]["audio_data"] == expected_second_audio + + +def test_duplex_data_plane_accepts_model_turn_output_across_client_commits(): + data_plane = _test_data_plane() + session = DuplexSession( + session_id="sid-auto-stale-turn", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.commit_native_audio_input() + session.commit_native_audio_input() + session.turn_id = 1 + session.epoch = 0 + request_id = "duplex-sid-auto-stale-turn-stage0" + output = SimpleNamespace( + request_id=request_id, + finished=False, + outputs=[SimpleNamespace(text="old tail", multimodal_output={})], + multimodal_output={ + "audio": np.zeros(24000, dtype=np.float32), + "sr": 24000, + "meta.duplex_turn_id": np.array([1], dtype=np.int32), + "meta.llm_output_text_utf8": np.frombuffer("旧尾巴".encode(), dtype=np.uint8), + }, + ) + + results = list(data_plane.project_output(output, context=_data_plane_context(session))) + + assert len(results) == 1 + assert results[0]["audio_data"] == "wav-24000" + assert results[0]["model_turn_id"] == 1 + + +def test_duplex_data_plane_accepts_active_response_turn_identity(): + data_plane = _test_data_plane() + session = DuplexSession( + session_id="sid-active-response-turn", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.turn_id = 1 + session.epoch = 0 + session.begin_response(turn_id=0) + output = SimpleNamespace( + request_id="duplex-sid-active-response-turn-stage0", + finished=True, + outputs=[SimpleNamespace(text="", multimodal_output={})], + multimodal_output={ + "audio": np.zeros(10, dtype=np.float32), + "sr": 24000, + "meta.duplex_turn_id": np.array([0], dtype=np.int32), + "meta.duplex_epoch": np.array([0], dtype=np.int32), + }, + ) + + results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session) + + assert len(results) == 1 + + +def test_duplex_data_plane_listen_preserves_model_turn_identity(): + data_plane = _test_data_plane() + session = DuplexSession( + session_id="sid-listen-turn", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + output = SimpleNamespace( + request_id="duplex-sid-listen-turn-stage0", + finished=True, + outputs=[SimpleNamespace(text="", token_ids=[], multimodal_output={})], + multimodal_output={ + "duplex_native_decision": "listen", + "meta.duplex_turn_id": np.array([2], dtype=np.int32), + "meta.duplex_epoch": np.array([0], dtype=np.int32), + }, + ) + + results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session) + + assert len(results) == 1 + assert results[0]["is_listen"] is True + assert results[0]["model_turn_id"] == 2 + + +def test_duplex_data_plane_does_not_drop_future_model_turn_while_response_active(): + data_plane = _test_data_plane() + session = DuplexSession( + session_id="sid-future-response-turn", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.turn_id = 0 + session.epoch = 0 + session.begin_response(turn_id=0) + output = SimpleNamespace( + request_id="duplex-sid-future-response-turn-stage0", + finished=False, + outputs=[SimpleNamespace(text="", multimodal_output={})], + multimodal_output={ + "audio": np.zeros(10, dtype=np.float32), + "sr": 24000, + "meta.duplex_turn_id": np.array([1], dtype=np.int32), + "meta.duplex_epoch": np.array([0], dtype=np.int32), + }, + ) + + results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session) + + assert len(results) == 1 + assert results[0]["model_turn_id"] == 1 + assert results[0]["audio_data"] == "wav-10" + + +def _duplex_tts_output( + *, + request_id: str, + samples: int, + finished: bool, + text: str = "hello", + tts_is_last_chunk: bool = False, + turn_end: bool = False, + turn_id: int = 0, + token_ids: list[int] | None = None, +): + return SimpleNamespace( + request_id=request_id, + finished=finished, + outputs=[SimpleNamespace(text=text, token_ids=list(token_ids or []), multimodal_output={})], + multimodal_output={ + "audio": np.zeros(samples, dtype=np.float32), + "sr": 24000, + "meta.tts_is_last_chunk": np.array([int(tts_is_last_chunk)], dtype=np.int32), + "meta.turn_end": np.array([int(turn_end)], dtype=np.int32), + "meta.duplex_turn_id": np.array([turn_id], dtype=np.int32), + "meta.duplex_epoch": np.array([0], dtype=np.int32), + }, + ) + + +@pytest.mark.parametrize( + ( + "finished", + "tts_is_last_chunk", + "turn_end", + "consume_audio_first", + "expected_end_of_turn", + "expected_audio", + ), + [ + (False, True, False, False, False, "wav-24000"), + (True, True, False, True, False, ""), + (True, True, True, True, True, ""), + ], + ids=[ + "last-audio-batch-does-not-end-response", + "scheduler-eos-only-ends-tts-segment", + "explicit-turn-end-metadata-ends-response", + ], +) +def test_duplex_auto_response_tts_boundary( + finished: bool, + tts_is_last_chunk: bool, + turn_end: bool, + consume_audio_first: bool, + expected_end_of_turn: bool, + expected_audio: str, +): + data_plane = _test_data_plane() + request_id = "duplex-sid-tts-boundary-e0-stage0" + session = DuplexSession( + session_id="sid-tts-boundary", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.bind_response_turn(0) + if consume_audio_first: + data_plane.slice_cumulative_audio(request_id, np.zeros(24000, dtype=np.float32)) + output = _duplex_tts_output( + request_id=request_id, + samples=24000, + finished=finished, + tts_is_last_chunk=tts_is_last_chunk, + turn_end=turn_end, + token_ids=[151645] if finished else None, + ) + + results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session) + + assert len(results) == 1 + boundary = results[0] + assert boundary["stage_role"] == "tts" + assert boundary["end_of_turn"] is expected_end_of_turn + assert boundary["abort_data_plane_request"] is True + assert boundary["audio_data"] == expected_audio + + +def test_duplex_auto_response_tts_scheduler_eos_fallback(): + data_plane = _test_data_plane() + request_id = "duplex-sid-tts-eos-no-profile-e0-stage0" + session = DuplexSession( + session_id="sid-tts-eos-no-profile", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.bind_response_turn(0) + data_plane.slice_cumulative_audio(request_id, np.zeros(24000, dtype=np.float32)) + output = _duplex_tts_output( + request_id=request_id, + samples=24000, + finished=True, + tts_is_last_chunk=False, + token_ids=[151645], + ) + + results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session) + + assert len(results) == 1 + assert results[0]["stage_role"] == "tts" + assert results[0]["abort_data_plane_request"] is True + + +def test_duplex_auto_response_rearms_tts_boundary_for_next_segment_in_same_turn(): + data_plane = _test_data_plane() + request_id = "duplex-sid-next-segment-same-turn-e0-stage0" + session = DuplexSession( + session_id="sid-next-segment-same-turn", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.bind_response_turn(0) + data_plane.begin_request(request_id) + + first_segment = _duplex_tts_output( + request_id=request_id, + samples=24000, + finished=False, + tts_is_last_chunk=True, + turn_id=0, + ) + first_results = _project_data_plane( + data_plane, + {"data_plane_outputs": [first_segment]}, + session=session, + ) + + assert len(first_results) == 1 + assert first_results[0]["abort_data_plane_request"] is True + + data_plane.begin_request(request_id) + second_segment = _duplex_tts_output( + request_id=request_id, + samples=48000, + finished=False, + text="hello again", + tts_is_last_chunk=True, + turn_id=0, + ) + second_results = _project_data_plane( + data_plane, + {"data_plane_outputs": [second_segment]}, + session=session, + ) + + assert len(second_results) == 1 + assert second_results[0]["audio_data"] == "wav-24000" + assert second_results[0]["abort_data_plane_request"] is True + + +def test_duplex_turn_end_is_not_swallowed_by_finished_segment_fallback(): + data_plane = _test_data_plane() + request_id = "duplex-sid-turn-end-after-segment-e0-stage0" + session = DuplexSession( + session_id="sid-turn-end-after-segment", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.begin_response(turn_id=0) + session.bind_request(request_id) + _project_data_plane( + data_plane, + { + "data_plane_outputs": [ + _duplex_tts_output( + request_id=request_id, + samples=24000, + finished=False, + tts_is_last_chunk=True, + ) + ] + }, + session=session, + ) + output = _duplex_tts_output( + request_id=request_id, + samples=24000, + finished=True, + text="", + tts_is_last_chunk=False, + turn_end=True, + token_ids=[151645], + ) + + results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session) + + assert len(results) == 1 + assert results[0]["stage_role"] == "tts" + assert results[0]["end_of_turn"] is True + + duplicate = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session) + + assert not any(result.get("end_of_turn") is True for result in duplicate) + + +def test_duplex_auto_response_discards_terminal_only_audio_before_response_creation(): + data_plane = _test_data_plane() + request_id = "duplex-sid-terminal-only-audio-e0-stage0" + session = DuplexSession( + session_id="sid-terminal-only-audio", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.bind_request(request_id) + first_audio = _duplex_tts_output( + request_id=request_id, + samples=3840, + finished=False, + text="", + ) + + assert _project_data_plane(data_plane, {"data_plane_outputs": [first_audio]}, session=session) == [] + + terminal = _duplex_tts_output( + request_id=request_id, + samples=3840, + finished=True, + text="", + tts_is_last_chunk=True, + turn_end=True, + token_ids=[151645], + ) + results = _project_data_plane(data_plane, {"data_plane_outputs": [terminal]}, session=session) + + assert len(results) == 1 + assert results[0]["audio_data"] == "" + assert results[0]["end_of_turn"] is True + assert session.active_request_id == request_id + assert not data_plane.is_terminal(request_id) + + +def test_duplex_auto_response_releases_buffered_audio_when_transcript_arrives(): + data_plane = _test_data_plane() + request_id = "duplex-sid-delayed-transcript-e0-stage0" + session = DuplexSession( + session_id="sid-delayed-transcript", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + audio_before_text = _duplex_tts_output( + request_id=request_id, + samples=100, + finished=False, + text="", + ) + + assert _project_data_plane(data_plane, {"data_plane_outputs": [audio_before_text]}, session=session) == [] + + audio_with_text = _duplex_tts_output( + request_id=request_id, + samples=200, + finished=False, + text="hello", + ) + results = _project_data_plane(data_plane, {"data_plane_outputs": [audio_with_text]}, session=session) + + assert [result["audio_data"] for result in results] == ["wav-100", "wav-100"] + assert [result["text"] for result in results] == ["", "hello"] + + +def test_duplex_auto_response_releases_buffered_audio_on_text_only_terminal(): + data_plane = _test_data_plane() + request_id = "duplex-sid-delayed-terminal-text-e0-stage0" + session = DuplexSession( + session_id="sid-delayed-terminal-text", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + audio_before_text = _duplex_tts_output( + request_id=request_id, + samples=100, + finished=False, + text="", + turn_id=1, + ) + + assert _project_data_plane(data_plane, {"data_plane_outputs": [audio_before_text]}, session=session) == [] + + textless_turn_terminal = _duplex_tts_output( + request_id=request_id, + samples=100, + finished=True, + text="", + tts_is_last_chunk=True, + turn_end=True, + turn_id=1, + token_ids=[151645], + ) + terminal_results = list( + data_plane.project( + {"data_plane_outputs": [textless_turn_terminal]}, + context=_data_plane_context(session), + ) + ) + + assert len(terminal_results) == 1 + assert terminal_results[0]["audio_data"] == "" + assert terminal_results[0]["end_of_turn"] is True + + text_only_terminal = SimpleNamespace( + request_id=request_id, + finished=True, + outputs=[SimpleNamespace(text="hello", token_ids=[151645], multimodal_output={})], + multimodal_output={ + "sr": 24000, + "meta.tts_is_last_chunk": np.array([1], dtype=np.int32), + "meta.turn_end": np.array([1], dtype=np.int32), + "meta.duplex_turn_id": np.array([2], dtype=np.int32), + "meta.duplex_epoch": np.array([0], dtype=np.int32), + }, + ) + results = _project_data_plane(data_plane, {"data_plane_outputs": [text_only_terminal]}, session=session) + + assert len(results) == 1 + assert results[0]["audio_data"] == "wav-100" + assert results[0]["text"] == "hello" + assert results[0]["end_of_turn"] is True + assert results[0]["abort_data_plane_request"] is True + assert not data_plane.has_pending_audio(request_id) + + +@pytest.mark.asyncio +async def test_native_append_propagates_current_turn_fence_to_engine(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine)) + session = DuplexSession( + session_id="sid-fenced-append", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + session.turn_id = 2 + session.begin_response(turn_id=2) + ws = TimedWebSocket() + + await handler._append_runtime_input( + session, + {"duplex_turn_id": 2, "audio": "", "format": "pcm_f32le"}, + final=True, + send_json=ws.send_json, + mode="append_audio_chunk", + expected_epoch=0, + ) + + assert engine.appended_fences == [DuplexFence("sid-fenced-append", epoch=0, turn_id=2)] + + +@pytest.mark.asyncio +async def test_minicpmo_auto_response_tts_segment_boundary_appends_silence_unit(): + request_id = "duplex-sid-segment-boundary-e0-stage0" + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + session = DuplexSession( + session_id="sid-segment-boundary", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + session.begin_response(turn_id=0) + session.turn_id = 1 + session.bind_request(request_id) + session.mark_audio_sent(100) + _install_direct_silence_scheduler(handler, session) + ws = TimedWebSocket() + + await handler._send_one_native_duplex_event( + ws.send_json, + { + "supported": True, + "stage_role": "tts", + "is_listen": False, + "data_plane_request_id": request_id, + "text": "", + "audio_data": "", + "audio_format": "pcm16", + "end_of_turn": False, + "abort_data_plane_request": True, + "uses_model_runner_scheduler": True, + "runner_kv_backed": True, + "runtime_impl": "scheduler_data_plane", + }, + session=session, + expected_epoch=session.epoch, + ) + await asyncio.sleep(0.01) + + assert len(engine.appended) == 1 + _, mode, payload, final = engine.appended[0] + assert mode == "append_audio_chunk" + assert payload["format"] == "pcm_f32le" + assert payload["duplex_turn_id"] == 0 + assert "force_speak" not in payload + assert final is False + assert session.active_response_id is not None + assert "response.done" not in ws.sent_types() + + +@pytest.mark.asyncio +async def test_minicpmo_auto_response_pre_response_tts_boundary_continues_model_turn(): + request_id = "duplex-sid-pre-response-boundary-e0-stage0" + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine)) + session = DuplexSession( + session_id="sid-pre-response-boundary", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + session.bind_request(request_id) + _install_direct_silence_scheduler(handler, session) + ws = TimedWebSocket() + + close_reason, emitted = await handler._send_one_native_duplex_event( + ws.send_json, + { + "supported": True, + "stage_role": "tts", + "is_listen": False, + "data_plane_request_id": request_id, + "model_turn_id": session.turn_id, + "text": "", + "audio_data": "", + "audio_format": "pcm16", + "end_of_turn": False, + "abort_data_plane_request": True, + "uses_model_runner_scheduler": True, + "runner_kv_backed": True, + "runtime_impl": "scheduler_data_plane", + }, + session=session, + expected_epoch=session.epoch, + ) + await asyncio.sleep(0.01) + + assert close_reason is None + assert emitted is False + assert session.active_response_id is None + assert ws.sent == [] + assert len(engine.appended) == 1 + _, mode, payload, final = engine.appended[0] + assert mode == "append_audio_chunk" + assert payload["duplex_turn_id"] == session.turn_id + assert final is False + + +@pytest.mark.asyncio +async def test_minicpmo_pre_response_continuation_drops_after_model_turn_ends(): + request_id = "duplex-sid-stale-pre-response-boundary-e0-stage0" + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine)) + session = DuplexSession( + session_id="sid-stale-pre-response-boundary", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + session.bind_request(request_id) + model_turn_id = session.turn_id + + native = handler._minicpmo_session_state(session) + + async def _stale_before_append(payload: object, **kwargs: Any) -> bool: + session.complete_model_turn(model_turn_id) + if handler._native_silence_continuation_is_stale( + session, + request_id=kwargs["request_id"], + response_id=kwargs["response_id"], + response_owned=kwargs["response_owned"], + expected_epoch=kwargs["expected_epoch"], + expected_incarnation=kwargs["expected_incarnation"], + expected_model_turn_id=kwargs["expected_model_turn_id"], + ): + return False + append_ok, _ = await handler._append_runtime_input( + session, + payload, + final=False, + send_json=kwargs["send_json"], + mode="append_audio_chunk", + expected_epoch=kwargs["expected_epoch"], + ) + return append_ok + + native.silence_continuation_scheduler = _stale_before_append + + await handler._maybe_continue_native_response( + TimedWebSocket().send_json, + session=session, + expected_epoch=session.epoch, + expected_model_turn_id=model_turn_id, + ) + await asyncio.sleep(0.01) + + assert engine.appended == [] + + +@pytest.mark.asyncio +async def test_minicpmo_auto_response_continuation_has_no_semantic_unit_cap(): + request_id = "duplex-sid-long-response-e0-stage0" + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine)) + session = DuplexSession( + session_id="sid-long-response", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + session.begin_response(turn_id=0) + session.turn_id = 1 + session.bind_request(request_id) + session.mark_audio_sent(100) + _install_direct_silence_scheduler(handler, session) + ws = TimedWebSocket() + + for _ in range(9): + await handler._maybe_continue_native_response( + ws.send_json, + session=session, + expected_epoch=session.epoch, + ) + await asyncio.sleep(0.02) + + assert len(engine.appended) == 9 + assert all(payload["duplex_turn_id"] == 0 for _, _, payload, _ in engine.appended) + assert all("force_speak" not in payload for _, _, payload, _ in engine.appended) + + +@pytest.mark.asyncio +async def test_minicpmo_auto_response_pre_speak_listen_continues_same_response(): + request_id = "duplex-sid-pre-speak-listen-e0-stage0" + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine)) + session = DuplexSession( + session_id="sid-pre-speak-listen", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + response_id = session.begin_response(turn_id=0) + session.turn_id = 1 + session.bind_request(request_id) + _install_direct_silence_scheduler(handler, session) + ws = TimedWebSocket() + + await handler._send_one_native_duplex_event( + ws.send_json, + { + "supported": True, + "stage_role": "llm", + "is_listen": True, + "model_listen": True, + "data_plane_request_id": request_id, + "end_of_turn": False, + "uses_model_runner_scheduler": True, + "runner_kv_backed": True, + "runtime_impl": "scheduler_data_plane", + }, + session=session, + expected_epoch=session.epoch, + ) + await asyncio.sleep(0.01) + + assert len(engine.appended) == 1 + assert session.active_response_id == response_id + assert session.active_request_id == request_id + assert "response.listen" not in ws.sent_types() + assert "response.done" not in ws.sent_types() + + +@pytest.mark.asyncio +async def test_minicpmo_auto_response_listen_before_response_keeps_resumable_request(): + request_id = "duplex-sid-listen-before-response-e0-stage0" + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + session = DuplexSession( + session_id="sid-listen-before-response", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + session.bind_request(request_id) + ws = TimedWebSocket() + + close_reason, emitted = await handler._send_one_native_duplex_event( + ws.send_json, + { + "supported": True, + "stage_role": "llm", + "is_listen": True, + "model_listen": True, + "data_plane_request_id": request_id, + "end_of_turn": False, + "uses_model_runner_scheduler": True, + "runner_kv_backed": True, + "runtime_impl": "scheduler_data_plane", + }, + session=session, + expected_epoch=session.epoch, + ) + + assert close_reason is None + assert emitted is True + assert session.active_request_id == request_id + assert not handler._minicpmo_data_plane.is_terminal(request_id) + assert ws.sent_types().count("response.listen") == 1 + + +@pytest.mark.asyncio +async def test_minicpmo_auto_response_listen_without_response_does_not_defer_commit(): + session_id = "sid-listen-before-response-commit" + request_id = duplex_resource_request_id(DuplexFence(session_id), "stage0") + inner_output = SimpleNamespace( + outputs=[ + SimpleNamespace( + multimodal_output={"special_token_ids": {"listen_token_id": 151705}}, + ) + ] + ) + decision = MiniCPMO45DuplexRuntimeExtension().decide_output( + stage_id=0, + final_stage_id=1, + segment_finished=True, + segment_token_ids=(151705,), + segment_output_metadata={"special_token_ids": {"listen_token_id": 151705}}, + output=inner_output, + ) + assert decision is not None + listen_output = attach_duplex_output_decision( + OmniRequestOutput( + request_id=request_id, + finished=True, + stage_id=0, + final_output_type=decision.final_output_type, + request_output=inner_output, + ), + decision, + ) + append_result = { + "operation": "append", + "session_id": session_id, + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [ + { + "stage_id": 0, + "replica_id": 0, + "result": { + "supported": True, + "implementation_level": "model_native_duplex", + "data_plane_append": True, + "request_id": request_id, + "response_stage_id": 1, + }, + } + ], + "data_plane_outputs": [listen_output], + } + engine = FakeEngineClient(append_result=append_result) + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + listen_count = 0 + + def append_residual_after_listen(ws: TimedWebSocket, data: dict[str, Any]) -> None: + nonlocal listen_count + if data.get("type") == "response.listen": + listen_count += 1 + if listen_count == 1: + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(3_200), + "format": "pcm_f32le", + "sample_rate_hz": 16_000, + "duration_ms": 200, + "is_speech": True, + } + ) + ws.put({"type": "input_audio_buffer.commit", "final": True}) + elif data.get("type") == "input_audio_buffer.committed": + asyncio.get_running_loop().call_later(0.05, ws.put, {"type": "session.close"}) + + ws = TimedWebSocket(on_send=append_residual_after_listen, receive_timeout_s=2) + create = _native_realtime_session_update(session_id) + create["session"]["extra_body"]["auto_response"] = True + ws.put(create) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(16_000), + "format": "pcm_f32le", + "sample_rate_hz": 16_000, + "duration_ms": 1_000, + "is_speech": True, + } + ) + + await handler.handle_realtime_session(ws) + + committed = [event for event in ws.sent if event.get("type") == "input_audio_buffer.committed"] + assert len(engine.appended) == 2 + assert engine.appended[1][3] is True + assert len(committed) == 1 + assert committed[0].get("event", {}).get("overlap_deferred") is not True + assert committed[0].get("event", {}).get("response_create_deferred") is not True + assert "response.created" not in ws.sent_types() + + +@pytest.mark.asyncio +async def test_minicpmo_pcm_f32_residual_commit_releases_pending_input_bytes(): + session_id = "sid-pcm-residual-byte-accounting" + control_result = { + "operation": "append", + "session_id": session_id, + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [], + } + engine = FakeEngineClient(control_result=control_result) + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket(receive_timeout_s=2) + create = _native_realtime_session_update(session_id) + create["session"]["extra_body"]["auto_response"] = True + ws.put(create) + handler_task = asyncio.create_task(handler.handle_realtime_session(ws)) + + try: + for _ in range(100): + session = handler._registry.get(session_id) + if session is not None and "session.created" in ws.sent_types(): + break + await asyncio.sleep(0.01) + else: + pytest.fail("native duplex session did not open") + + pending_bytes_after_turn: list[int] = [] + for turn_index in range(3): + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(19_200), + "format": "pcm_f32le", + "sample_rate_hz": 16_000, + "duration_ms": 1_200, + "is_speech": True, + } + ) + ws.put({"type": "input_audio_buffer.commit", "final": True}) + expected_appends = (turn_index + 1) * 2 + for _ in range(100): + if len(engine.appended) >= expected_appends: + await asyncio.sleep(0) + break + await asyncio.sleep(0.01) + else: + pytest.fail(f"turn {turn_index} residual append did not complete") + pending_bytes_after_turn.append(session.pending_input_bytes) + finally: + ws.put({"type": "session.close"}) + await asyncio.wait_for(handler_task, timeout=2) + + assert pending_bytes_after_turn == [0, 0, 0] + assert not any( + event.get("type") == "error" + and isinstance(event.get("error"), dict) + and event["error"].get("code") == "input_backpressure" + for event in ws.sent + ) + + +@pytest.mark.asyncio +async def test_minicpmo_auto_response_post_speak_listen_continues_same_response(): + request_id = "duplex-sid-post-speak-listen-e0-stage0" + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine)) + session = DuplexSession( + session_id="sid-post-speak-listen", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + response_id = session.begin_response(turn_id=0) + session.turn_id = 1 + session.bind_request(request_id) + session.mark_audio_sent(100) + _install_direct_silence_scheduler(handler, session) + ws = TimedWebSocket() + + await handler._send_one_native_duplex_event( + ws.send_json, + { + "supported": True, + "stage_role": "llm", + "is_listen": True, + "model_listen": True, + "data_plane_request_id": request_id, + "end_of_turn": False, + "uses_model_runner_scheduler": True, + "runner_kv_backed": True, + "runtime_impl": "scheduler_data_plane", + }, + session=session, + expected_epoch=session.epoch, + ) + await asyncio.sleep(0.01) + + assert len(engine.appended) == 1 + assert session.active_response_id == response_id + assert session.active_request_id == request_id + assert "response.listen" not in ws.sent_types() + assert "response.done" not in ws.sent_types() + + +@pytest.mark.asyncio +async def test_minicpmo_auto_response_turn_end_preserves_resumable_request(): + request_id = "duplex-sid-turn-end-preserve-request-e0-stage0" + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + session = DuplexSession( + session_id="sid-turn-end-preserve-request", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + session.begin_response(turn_id=0) + session.bind_request(request_id) + ws = TimedWebSocket() + + await handler._send_one_native_duplex_event( + ws.send_json, + { + "supported": True, + "stage_role": "tts", + "is_listen": False, + "data_plane_request_id": request_id, + "text": "done", + "audio_data": "audio", + "audio_format": "pcm16", + "audio_duration_ms": 100, + "end_of_turn": True, + "model_turn_id": 0, + "uses_model_runner_scheduler": True, + "runner_kv_backed": True, + "runtime_impl": "scheduler_data_plane", + }, + session=session, + expected_epoch=session.epoch, + ) + + assert session.active_response_id is None + assert session.active_request_id == request_id + assert "response.done" in ws.sent_types() + + +@pytest.mark.asyncio +async def test_minicpmo_auto_response_empty_turn_end_emits_model_listen(): + request_id = "duplex-sid-empty-turn-end-e0-stage0" + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + session = DuplexSession( + session_id="sid-empty-turn-end", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + session.bind_request(request_id) + ws = TimedWebSocket() + + close_reason, emitted = await handler._send_one_native_duplex_event( + ws.send_json, + { + "supported": True, + "stage_role": "tts", + "is_listen": False, + "data_plane_request_id": request_id, + "text": "", + "audio_data": "", + "audio_format": "pcm16", + "end_of_turn": True, + "abort_data_plane_request": True, + "model_turn_id": 0, + "uses_model_runner_scheduler": True, + "runner_kv_backed": True, + "runtime_impl": "scheduler_data_plane", + }, + session=session, + expected_epoch=session.epoch, + ) + + assert close_reason is None + assert emitted is True + assert session.turn_id == 1 + assert session.active_request_id == request_id + assert session.active_response_id is None + assert ws.sent_types() == ["response.listen"] + assert ws.sent[0]["model_listen"] is True + assert ws.sent[0]["reason"] == "model_turn_completed_without_output" + + +@pytest.mark.asyncio +async def test_minicpmo_auto_response_drops_late_audio_from_completed_model_turn(): + request_id = "duplex-sid-late-completed-turn-e0-stage0" + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + session = DuplexSession( + session_id="sid-late-completed-turn", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + session.bind_request(request_id) + session.complete_model_turn(0) + ws = TimedWebSocket() + + close_reason, emitted = await handler._send_one_native_duplex_event( + ws.send_json, + { + "supported": True, + "stage_role": "tts", + "is_listen": False, + "data_plane_request_id": request_id, + "text": "late duplicate", + "audio_data": "late-audio", + "audio_format": "pcm16", + "audio_duration_ms": 100, + "end_of_turn": True, + "model_turn_id": 0, + "uses_model_runner_scheduler": True, + "runner_kv_backed": True, + "runtime_impl": "scheduler_data_plane", + }, + session=session, + expected_epoch=session.epoch, + ) + + assert close_reason is None + assert emitted is False + assert session.turn_id == 1 + assert session.active_response_id is None + assert "response.created" not in ws.sent_types() + assert "response.done" not in ws.sent_types() + + +def test_duplex_data_plane_drops_stale_epoch_audio_after_barge_in(): + class _ChatService: + model_config = _ModelConfig() + + def create_audio(self, audio_obj): + return SimpleNamespace(audio_data=f"wav-{int(audio_obj.audio_tensor.shape[0])}") + + data_plane = _test_data_plane() + session = DuplexSession( + session_id="sid-auto-stale-epoch", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.turn_id = 1 + session.epoch = 2 + output = SimpleNamespace( + request_id="duplex-sid-auto-stale-epoch-stage0", + finished=False, + outputs=[SimpleNamespace(text="old epoch", multimodal_output={})], + multimodal_output={ + "audio": np.zeros(24000, dtype=np.float32), + "sr": 24000, + "meta.duplex_epoch": np.array([1], dtype=np.int32), + "meta.duplex_turn_id": np.array([1], dtype=np.int32), + "meta.llm_output_text_utf8": np.frombuffer("旧轮".encode(), dtype=np.uint8), + }, + ) + + assert list(data_plane.project_output(output, context=_data_plane_context(session))) == [] + + +def test_duplex_data_plane_text_delta_preserves_repeated_suffix_growth(): + data_plane = _test_data_plane() + request_id = "duplex-sid-native-repeated-word-e0-stage0" + + assert data_plane.segment_text_delta(request_id, "好的") == "好的" + assert data_plane.segment_text_delta(request_id, "好的好的") == "好的" + + +def test_duplex_data_plane_text_delta_appends_distinct_non_prefix_segments(): + data_plane = _test_data_plane() + request_id = "duplex-sid-native-rewrite-e0-stage0" + + deltas = [ + data_plane.segment_text_delta(request_id, "It's Canberra."), + data_plane.segment_text_delta(request_id, " Next question."), + ] + + assert deltas == ["It's Canberra.", " Next question."] + assert "".join(deltas) == "It's Canberra. Next question." + + +def test_duplex_data_plane_close_session_removes_encoded_request_state(): + data_plane = _test_data_plane() + target_request_id = duplex_resource_request_id( + DuplexFence("sid-to-close", incarnation=1, epoch=2), + "stage0", + ) + other_request_id = duplex_resource_request_id( + DuplexFence("sid-to-keep", incarnation=1, epoch=2), + "stage0", + ) + data_plane.begin_request(target_request_id) + data_plane.begin_request(other_request_id) + + data_plane.close_session("sid-to-close") + + assert data_plane.has_request(target_request_id) is False + assert data_plane.has_request(other_request_id) is True + + +def test_duplex_auto_response_segment_complete_keeps_data_plane_request_open(): + import numpy as np + + class _ChatService: + model_config = _ModelConfig() + + def create_audio(self, audio_obj): + return SimpleNamespace(audio_data=f"wav-{int(audio_obj.audio_tensor.shape[0])}") + + data_plane = _test_data_plane() + request_id = "duplex-duplex-sess-stage0" + session = DuplexSession( + session_id="sid-auto-respond", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.begin_response() + session.mark_audio_sent(1280) + data_plane.slice_cumulative_audio(request_id, np.zeros(30720, dtype=np.float32)) + + output = SimpleNamespace( + request_id=request_id, + finished=True, + outputs=[SimpleNamespace(text="hello", multimodal_output={})], + multimodal_output={ + "audio": np.zeros(30720, dtype=np.float32), + "sr": 24000, + }, + ) + + native_results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session) + + assert len(native_results) == 1 + assert native_results[0]["is_listen"] is True + assert native_results[0]["reason"] == "auto_response_segment_complete" + assert native_results[0]["data_plane_request_id"] == request_id + assert "abort_data_plane_request" not in native_results[0] + + +def test_duplex_auto_response_prior_playback_does_not_abort_before_current_response(): + data_plane = _test_data_plane() + request_id = "duplex-duplex-sess-stage0" + session = DuplexSession( + session_id="sid-auto-respond-next-turn", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.mark_audio_sent(1280) + session.acknowledge_playback(played_ms=1280, committed_ms=1280) + + output = SimpleNamespace( + request_id=request_id, + finished=True, + outputs=[SimpleNamespace(text="", multimodal_output={})], + multimodal_output={}, + ) + + native_results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session) + + assert native_results == [] + + +def test_duplex_auto_response_text_only_flush_does_not_consume_audio_transcript(): + import numpy as np + + data_plane = _test_data_plane() + request_id = "duplex-duplex-sess-stage0" + session = DuplexSession( + session_id="sid-auto-respond-delayed-audio", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + text = "你好呀,有什莫问的吗?" + + text_only_output = SimpleNamespace( + request_id=request_id, + finished=False, + outputs=[SimpleNamespace(text=text, multimodal_output={})], + multimodal_output={"sr": 24000}, + ) + + assert _project_data_plane(data_plane, {"data_plane_outputs": [text_only_output]}, session=session) == [] + + audio_output = SimpleNamespace( + request_id=request_id, + finished=False, + outputs=[SimpleNamespace(text=text, multimodal_output={})], + multimodal_output={ + "audio": np.zeros(24000, dtype=np.float32), + "sr": 24000, + }, + ) + + native_results = _project_data_plane(data_plane, {"data_plane_outputs": [audio_output]}, session=session) + + assert len(native_results) == 1 + assert native_results[0]["audio_data"].startswith("wav-") + assert native_results[0]["text"] == text + + +@pytest.mark.asyncio +async def test_duplex_auto_response_empty_terminal_does_not_create_empty_response(): + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + request_id = "duplex-sid-native-empty-terminal-e0-stage0" + session = DuplexSession( + session_id="sid-native-empty-terminal", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.mark_audio_sent(1280) + sent: list[dict[str, Any]] = [] + + async def send_json(payload: dict[str, Any]) -> None: + sent.append(payload) + + output = SimpleNamespace( + request_id=request_id, + finished=False, + outputs=[SimpleNamespace(text="hello", multimodal_output={})], + multimodal_output={ + "sr": 24000, + "meta": {"turn_end": True, "duplex_turn_id": 0}, + }, + ) + + native_results = list( + handler._minicpmo_data_plane.project( + {"data_plane_outputs": [output]}, + context=_data_plane_context(session), + ) + ) + assert len(native_results) == 1 + assert native_results[0]["end_of_turn"] is True + assert native_results[0]["model_turn_id"] == 0 + + for native_result in native_results: + await handler._send_one_native_duplex_event(send_json, native_result, session=session) + + assert "response.created" not in [m.get("type") for m in sent] + assert "response.done" not in [m.get("type") for m in sent] + assert session.turn_id == 1 + assert not handler._minicpmo_data_plane.is_terminal(request_id) + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_session_close_cleans_auto_response_data_plane_state(): + session_id = "sid-native-cleanup" + request_id = f"duplex-{session_id}-e0-stage0" + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + handler._minicpmo_data_plane.slice_cumulative_audio(request_id, np.zeros(24000, dtype=np.float32)) + handler._minicpmo_data_plane.segment_text_delta(request_id, "hello") + handler._minicpmo_data_plane.mark_terminal(request_id) + native = MiniCPMO45ServingSessionState() + handler._minicpmo_sessions[session_id] = native + + event = _native_session_create(session_id) + event["session"]["extra_body"]["auto_response"] = True + ws = TimedWebSocket() + ws.put(event) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert not handler._minicpmo_data_plane.has_request(request_id) + assert session_id not in handler._minicpmo_sessions + + +@pytest.mark.asyncio +async def test_duplex_chat_audio_stream_uses_output_audio_delta_event(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + session = DuplexSession(session_id="sid-chat-audio", config=DuplexSessionConfig()) + response_id = session.begin_response() + sent: list[dict[str, Any]] = [] + + async def send_json(data: dict[str, Any]) -> None: + sent.append(data) + + await handler._emit_chat_payload( + session, + { + "modality": "audio", + "choices": [ + { + "delta": { + "content": "AAAA", + } + } + ], + }, + session.epoch, + response_id, + send_json, + ) + + assert sent == [ + { + "type": "response.output_audio.delta", + "session_id": "sid-chat-audio", + "response_id": response_id, + "epoch": 0, + "audio": "AAAA", + "format": "wav", + } + ] + + +@pytest.mark.asyncio +async def test_duplex_chat_rejects_unknown_output_modality(): + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(FakeEngineClient()), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + session = DuplexSession(session_id="sid-chat-unknown", config=DuplexSessionConfig()) + response_id = session.begin_response() + sent: list[dict[str, Any]] = [] + + async def send_json(data: dict[str, Any]) -> None: + sent.append(data) + + await handler._emit_chat_payload( + session, + { + "modality": "video", + "choices": [{"delta": {"content": "not-text"}}], + }, + session.epoch, + response_id, + send_json, + ) + + assert sent == [ + { + "type": "error", + "session_id": session.session_id, + "response_id": response_id, + "epoch": session.epoch, + "code": "unsupported_response_modality", + "error": "Unsupported chat response modality: video", + } + ] + assert session.assistant_text_buffer == () + + +@pytest.mark.asyncio +async def test_minicpmo_model_name_does_not_auto_enable_experimental_native_duplex(): + event = _session_create("sid-minicpmo-default-protocol") + event["session"]["model"] = "openbmb/MiniCPM-o-4_5" + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(event) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + created = next(m for m in ws.sent if m.get("type") == "session.created") + capabilities = created["session"]["capabilities"] + assert capabilities["implementation_level"] == "serving_session_adapter" + assert capabilities["supports_input_append"] is False + + +@pytest.mark.asyncio +async def test_duplex_handler_aborts_current_chat_request_id_on_barge_in(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") == "response.created": + ws.put({"type": "input.cancel", "reason": "test_barge_in"}) + + ws = TimedWebSocket(on_send=on_send) + ws.put(_session_create("sid-a")) + ws.put({"type": "input.text.append", "text": "hello"}) + ws.put({"type": "input.commit"}) + + await handler.handle_session(ws) + + assert ws.accepted + assert "response.created" in ws.sent_types() + assert "audio.cancelled" in ws.sent_types() + assert chat_service.seen_request_ids == ["duplex-sid-a-0-1"] + assert engine.aborted == ["chatcmpl-duplex-sid-a-0-1"] + assert engine.internal_abort_batches == [["chatcmpl-duplex-sid-a-0-1"]] + + +@pytest.mark.asyncio +async def test_duplex_handler_runtime_open_failure_is_reported_to_client(): + engine = FakeEngineClient(fail_open=True) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_session_create("sid-open-fail")) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert ws.sent_types() == ["error"] + assert ws.sent[0]["code"] == "runtime_open_failed" + assert engine.opened == [] + + +@pytest.mark.asyncio +async def test_duplex_handler_preserves_typed_admission_error(): + class AdmissionRejectingEngine(FakeEngineClient): + async def open_duplex_session_async(self, session_id: str, **kwargs): + del kwargs + raise DuplexControlRequestError( + { + "operation": "open", + "session_id": session_id, + "error": { + "code": "resource_exhausted", + "message": "duplex session capacity exhausted", + "retryable": True, + }, + "accepted_fence": None, + "lease_generation": None, + } + ) + + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(AdmissionRejectingEngine()), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put(_session_create("sid-admission-rejected")) + + await handler.handle_session(ws) + + assert ws.sent_types() == ["error"] + assert ws.sent[0]["code"] == "resource_exhausted" + assert ws.sent[0]["retryable"] is True + + +@pytest.mark.asyncio +async def test_duplex_cancel_reports_playback_committed_cursor(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") == "response.created": + ws.put({"type": "playback.ack", "played_ms": 1200, "committed_ms": 1000}) + ws.put({"type": "input.cancel", "reason": "test_barge_in"}) + + ws = TimedWebSocket(on_send=on_send) + ws.put(_session_create("sid-playback")) + ws.put({"type": "input.text.append", "text": "hello"}) + ws.put({"type": "input.commit"}) + + await handler.handle_session(ws) + + ack = next(m for m in ws.sent if m.get("type") == "playback.acknowledged") + cancelled = next(m for m in ws.sent if m.get("type") == "audio.cancelled") + assert ack["playback"]["played_ms"] == 1200 + assert ack["playback"]["committed_ms"] == 1000 + assert cancelled["committed_ms"] == 1000 + assert cancelled["epoch"] == 1 + + +@pytest.mark.asyncio +async def test_playback_ack_response_id_selects_matching_pending_history_item(): + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(FakeEngineClient()), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + session = DuplexSession( + session_id="sid-playback-response-id", + config=DuplexSessionConfig(), + ) + first_item_id = "item_resp-first" + second_item_id = "item_resp-second" + session.stage_pending_history_item( + first_item_id, + {"role": "assistant", "content": "first response"}, + ) + session.stage_pending_history_item( + second_item_id, + {"role": "assistant", "content": "second response"}, + ) + ws = TimedWebSocket() + + await handler._handle_playback_ack( + session, + { + "type": "playback.ack", + "response_id": "resp-second", + "played_ms": 1000, + "committed_ms": 1000, + }, + ws.send_json, + ) + + ack = next(message for message in ws.sent if message.get("type") == "playback.acknowledged") + assert ack["item_id"] == second_item_id + assert ack["history_committed"] is True + assert first_item_id in session.pending_history_item_ids + assert second_item_id not in session.pending_history_item_ids + + +@pytest.mark.asyncio +async def test_late_playback_ack_does_not_advance_new_response_cursor(): + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(FakeEngineClient()), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + session = DuplexSession( + session_id="sid-late-playback-ack", + config=DuplexSessionConfig(), + ) + first_response_id = session.begin_response() + session.append_assistant_text("first response") + session.mark_audio_sent(11480, text_chars=len("first response")) + session.end_response(commit_text=False, preserve_request=True) + session.register_history_item(f"item_{first_response_id}", None) + + second_response_id = session.begin_response() + session.mark_audio_sent(2200, text_chars=len("second response")) + ws = TimedWebSocket() + + await handler._handle_playback_ack( + session, + { + "type": "playback.ack", + "response_id": first_response_id, + "item_id": f"item_{first_response_id}", + "played_ms": 11480, + "committed_ms": 11480, + }, + ws.send_json, + ) + + assert session.active_response_id == second_response_id + assert session.playback.as_dict() == { + "generated_ms": 2200, + "sent_ms": 2200, + "played_ms": 0, + "committed_ms": 0, + } + ack = next(message for message in ws.sent if message.get("type") == "playback.acknowledged") + assert ack["playback"] == { + "generated_ms": 11480, + "sent_ms": 11480, + "played_ms": 11480, + "committed_ms": 11480, + } + assert first_response_id not in session.response_playbacks + assert session.response_playbacks[second_response_id] == session.playback + + +@pytest.mark.asyncio +async def test_late_playback_ack_truncates_history_with_matching_response_cursor(): + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(FakeEngineClient()), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + session = DuplexSession( + session_id="sid-late-playback-history", + config=DuplexSessionConfig(), + ) + first_response_id = session.begin_response() + session.append_assistant_text("abcdefghij") + session.mark_audio_sent(1000) + session.end_response(commit_text=False, preserve_request=True) + session.register_history_item(f"item_{first_response_id}", None) + + session.begin_response() + session.mark_audio_sent(10000) + ws = TimedWebSocket() + + await handler._handle_playback_ack( + session, + { + "type": "playback.ack", + "response_id": first_response_id, + "item_id": f"item_{first_response_id}", + "played_ms": 500, + "committed_ms": 500, + }, + ws.send_json, + ) + + assert session.history[-1] == {"role": "assistant", "content": "abcde"} + assert first_response_id in session.response_playbacks + + +@pytest.mark.asyncio +async def test_cancel_active_response_commits_only_played_assistant_history(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + session = DuplexSession( + session_id="sid-playback-partial", + config=DuplexSessionConfig(playback_commit_policy=DuplexPlaybackCommitPolicy.COMMIT_ALL_ON_DONE.value), + ) + response_id = session.begin_response() + session.append_assistant_text("hello world") + session.mark_audio_sent(1000, text_chars=len("hello world")) + session.acknowledge_playback(played_ms=500, committed_ms=500) + + cancelled = await handler._cancel_active_response(session, None, ws.send_json, reason="barge_in") + + assert cancelled is True + assert session.epoch == 1 + assert session.history == ({"role": "assistant", "content": "hello"},) + assert session.history_item_ids[f"item_{response_id}"] == session.history[0] + event = next(m for m in ws.sent if m.get("type") == "audio.cancelled") + assert event["committed_ms"] == 500 + + +@pytest.mark.asyncio +async def test_cancel_active_response_keeps_truncated_history_in_serving(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + session = DuplexSession( + session_id="sid-playback-runtime-rebuild", + config=DuplexSessionConfig(playback_commit_policy=DuplexPlaybackCommitPolicy.COMMIT_ALL_ON_DONE.value), + ) + response_id = session.begin_response() + session.append_assistant_text("hello world") + session.mark_audio_sent(1000, text_chars=len("hello world")) + session.acknowledge_playback(played_ms=500, committed_ms=500) + + cancelled = await handler._cancel_active_response( + session, + None, + ws.send_json, + reason="barge_in", + ) + + assert cancelled is True + assert session.history == ({"role": "assistant", "content": "hello"},) + assert session.history_item_ids[f"item_{response_id}"] == session.history[0] + assert engine.signals == [] + assert "error" not in ws.sent_types() + + +@pytest.mark.asyncio +async def test_cancel_active_native_data_plane_request_aborts_stage_request_id(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + session = DuplexSession(session_id="sid-native-abort", config=DuplexSessionConfig()) + session.bind_request("duplex-sid-native-abort-e0-stage0-s1") + + cancelled = await handler._cancel_active_response(session, None, ws.send_json, reason="barge_in") + + assert cancelled is True + assert engine.aborted == ["duplex-sid-native-abort-e0-stage0-s1"] + assert engine.internal_abort_batches == [["duplex-sid-native-abort-e0-stage0-s1"]] + assert "audio.cancelled" in ws.sent_types() + + +@pytest.mark.asyncio +async def test_duplex_handler_explicit_close_closes_runtime_once_with_client_reason(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_session_create("sid-close")) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert ws.sent_types() == ["session.created", "session.closed"] + assert engine.closed == [("sid-close", "session_close")] + + +@pytest.mark.asyncio +async def test_realtime_disconnect_detaches_resumable_session_without_runtime_close(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket(receive_timeout_s=0.01) + ws.put(_native_realtime_session_update("sid-resumable-disconnect")) + + await handler.handle_realtime_session(ws) + + created = next(message for message in ws.sent if message.get("type") == "session.created") + assert created["session"]["id"] == "sid-resumable-disconnect" + assert isinstance(created["resume_token"], str) + assert created["resume_token"] + assert created["attachment_generation"] == 1 + assert engine.closed == [] + assert engine.touched == [ + ("sid-resumable-disconnect", DuplexLeaseActivity.DETACH), + ] + assert handler._registry.get("sid-resumable-disconnect") is not None + assert "sid-resumable-disconnect" in handler._minicpmo_sessions + + +@pytest.mark.asyncio +async def test_realtime_initial_resume_token_delivery_failure_closes_unrecoverable_session(): + class FailingCreatedWebSocket(TimedWebSocket): + async def send_json(self, data: dict[str, Any]): + if data.get("type") == "session.created": + raise WebSocketDisconnect(code=1006) + await super().send_json(data) + + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = FailingCreatedWebSocket(receive_timeout_s=0.01) + ws.put(_native_realtime_session_update("sid-created-delivery-failure")) + + await handler.handle_realtime_session(ws) + + assert engine.closed == [("sid-created-delivery-failure", "disconnect")] + assert handler._registry.get("sid-created-delivery-failure") is None + assert "sid-created-delivery-failure" not in handler._minicpmo_sessions + + +@pytest.mark.asyncio +async def test_realtime_send_after_created_disconnect_preserves_resumable_session(): + class ClosedAfterCreatedWebSocket(TimedWebSocket): + def __init__(self) -> None: + super().__init__(receive_timeout_s=0.01) + self.transport_closed = False + + async def send_json(self, data: dict[str, Any]): + if self.transport_closed: + raise RuntimeError( + "Unexpected ASGI message 'websocket.send', " + "after sending 'websocket.close' or response already completed." + ) + await super().send_json(data) + if data.get("type") == "session.created": + self.transport_closed = True + + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = ClosedAfterCreatedWebSocket() + ws.put(_native_realtime_session_update("sid-created-then-disconnected")) + + await handler.handle_realtime_session(ws) + + assert engine.closed == [] + assert engine.touched == [ + ("sid-created-then-disconnected", DuplexLeaseActivity.DETACH), + ] + assert handler._registry.get("sid-created-then-disconnected") is not None + assert "sid-created-then-disconnected" in handler._minicpmo_sessions + + +@pytest.mark.asyncio +async def test_realtime_resume_rotates_token_replays_and_preserves_runtime_identity(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + first = TimedWebSocket(receive_timeout_s=0.01) + first.put(_native_realtime_session_update("sid-realtime-resume")) + first.put({"type": "session.heartbeat"}) + + await handler.handle_realtime_session(first) + + created = next(message for message in first.sent if message.get("type") == "session.created") + heartbeat = next(message for message in first.sent if message.get("type") == "session.heartbeat_ack") + token = created["resume_token"] + incarnation = created["incarnation"] + second = TimedWebSocket(receive_timeout_s=0.1) + second.query_params = { + "model": "openbmb/MiniCPM-o-4_5", + "minicpmo45_native_duplex": "1", + "resume": "1", + } + second.put( + { + "type": "session.resume", + "session_id": "sid-realtime-resume", + "incarnation": incarnation, + "resume_token": token, + "last_received_server_event_seq": heartbeat["server_event_seq"] - 1, + } + ) + second.put({"type": "session.close"}) + + await handler.handle_realtime_session(second) + + resumed = next(message for message in second.sent if message.get("type") == "session.resumed") + replayed = [ + message + for message in second.sent + if message.get("type") == "session.heartbeat_ack" + and message.get("server_event_seq") == heartbeat["server_event_seq"] + ] + assert resumed["session_id"] == "sid-realtime-resume" + assert resumed["incarnation"] == incarnation + assert resumed["attachment_generation"] == 2 + assert resumed["resume_token"] != token + assert replayed == [heartbeat] + assert engine.opened == ["sid-realtime-resume"] + assert engine.resumed == [("sid-realtime-resume", 0)] + assert engine.resume_fences[0] == engine.opened_fences[0] + assert engine.closed == [("sid-realtime-resume", "session_close")] + + +@pytest.mark.asyncio +async def test_realtime_resume_preserves_append_tail_order_across_connections(): + class SlowFirstAppendEngine(FakeEngineClient): + def __init__(self) -> None: + super().__init__() + self.first_append_started = asyncio.Event() + self.second_append_started = asyncio.Event() + self.release_first_append = asyncio.Event() + self.append_start_order: list[int] = [] + + async def append_duplex_input_async(self, session_id: str, **kwargs): + call_index = len(self.append_start_order) + 1 + self.append_start_order.append(call_index) + if call_index == 1: + self.first_append_started.set() + await self.release_first_append.wait() + else: + self.second_append_started.set() + kwargs.pop("expected_epoch", None) + return await super().append_duplex_input_async(session_id, **kwargs) + + engine = SlowFirstAppendEngine() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + first = TimedWebSocket(receive_timeout_s=1.0) + session_update = _native_realtime_session_update("sid-resume-append-order") + session_update["session"]["extra_body"]["auto_response"] = True + first.put(session_update) + first_task = asyncio.create_task(handler.handle_realtime_session(first)) + for _ in range(100): + created = next((message for message in first.sent if message.get("type") == "session.created"), None) + if created is not None: + break + await asyncio.sleep(0.01) + else: + pytest.fail("resumable session did not open") + session = handler._registry.get("sid-resume-append-order") + assert session is not None + native_state = handler._minicpmo_session_state(session) + first_scheduler = native_state.silence_continuation_scheduler + assert first_scheduler is not None + first.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm16_b64(16000), + "sample_rate_hz": 16000, + "is_speech": True, + } + ) + await asyncio.wait_for(engine.first_append_started.wait(), timeout=1) + await first.close(code=1006) + await asyncio.wait_for(first_task, timeout=1) + + second = TimedWebSocket(receive_timeout_s=0.5) + second.query_params = { + "model": "openbmb/MiniCPM-o-4_5", + "minicpmo45_native_duplex": "1", + "resume": "1", + } + second.put( + { + "type": "session.resume", + "session_id": "sid-resume-append-order", + "incarnation": created["incarnation"], + "resume_token": created["resume_token"], + "last_received_server_event_seq": created.get("server_event_seq", 0), + } + ) + second_task = asyncio.create_task(handler.handle_realtime_session(second)) + for _ in range(100): + if "session.resumed" in second.sent_types(): + break + await asyncio.sleep(0.01) + else: + pytest.fail("session did not resume") + assert native_state.silence_continuation_scheduler is not first_scheduler + second.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm16_b64(16000), + "sample_rate_hz": 16000, + "is_speech": True, + } + ) + try: + await asyncio.sleep(0.05) + assert not engine.second_append_started.is_set() + finally: + engine.release_first_append.set() + + await asyncio.wait_for(engine.second_append_started.wait(), timeout=1) + second.put({"type": "session.close"}) + await asyncio.wait_for(second_task, timeout=2) + assert engine.append_start_order == [1, 2] + + +@pytest.mark.asyncio +async def test_realtime_takeover_ignores_late_detach_from_replaced_attachment(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + first = TimedWebSocket(receive_timeout_s=1.0) + first.put(_native_realtime_session_update("sid-takeover")) + first_task = asyncio.create_task(handler.handle_realtime_session(first)) + for _ in range(100): + created = next((message for message in first.sent if message.get("type") == "session.created"), None) + if created is not None: + break + await asyncio.sleep(0.001) + else: + raise AssertionError("first attachment did not create the session") + + second = TimedWebSocket(receive_timeout_s=0.03) + second.put( + { + "type": "session.resume", + "session_id": "sid-takeover", + "incarnation": created["incarnation"], + "resume_token": created["resume_token"], + "last_received_server_event_seq": 0, + } + ) + await handler.handle_realtime_session(second) + await first_task + + detach_touches = [item for item in engine.touched if item[1] is DuplexLeaseActivity.DETACH] + assert detach_touches == [("sid-takeover", DuplexLeaseActivity.DETACH)] + assert "session.replaced" in first.sent_types() + assert engine.resumed == [("sid-takeover", 0)] + assert engine.closed == [] + + +@pytest.mark.asyncio +async def test_realtime_takeover_does_not_clear_new_attachment_pending_turns(): + class DelayedReplacementCloseWebSocket(TimedWebSocket): + def __init__(self) -> None: + super().__init__(receive_timeout_s=1.0) + self.replacement_close_started = asyncio.Event() + self.release_replacement_close = asyncio.Event() + + async def close(self, code: int = 1000, reason: str | None = None) -> None: + if not self.replacement_close_started.is_set(): + self.replacement_close_started.set() + await self.release_replacement_close.wait() + await super().close(code=code, reason=reason) + + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + first = DelayedReplacementCloseWebSocket() + first.put(_native_realtime_session_update("sid-takeover-pending-turn")) + first_task = asyncio.create_task(handler.handle_realtime_session(first)) + for _ in range(100): + created = next((message for message in first.sent if message.get("type") == "session.created"), None) + if created is not None: + break + await asyncio.sleep(0.001) + else: + pytest.fail("first attachment did not create the session") + + second = TimedWebSocket(receive_timeout_s=1.0) + second.put( + { + "type": "session.resume", + "session_id": "sid-takeover-pending-turn", + "incarnation": created["incarnation"], + "resume_token": created["resume_token"], + "last_received_server_event_seq": 0, + } + ) + second_task = asyncio.create_task(handler.handle_realtime_session(second)) + await asyncio.wait_for(first.replacement_close_started.wait(), timeout=1) + + session = handler._registry.get("sid-takeover-pending-turn") + assert session is not None + assert session.reserve_pending_turn(limit=2) + first.release_replacement_close.set() + await asyncio.wait_for(first_task, timeout=1) + + assert session.pending_input_turns == 1 + session.release_pending_turn() + second.put({"type": "session.close"}) + await asyncio.wait_for(second_task, timeout=2) + + +@pytest.mark.asyncio +async def test_realtime_takeover_drops_input_queued_by_replaced_attachment(): + class DelayedReplacementCloseWebSocket(TimedWebSocket): + def __init__(self) -> None: + super().__init__(receive_timeout_s=1.0) + self.replacement_close_started = asyncio.Event() + self.release_replacement_close = asyncio.Event() + self.stale_heartbeat_read = asyncio.Event() + + async def receive_text(self) -> str: + payload = await super().receive_text() + if json.loads(payload).get("type") == "session.heartbeat": + self.stale_heartbeat_read.set() + return payload + + async def close(self, code: int = 1000, reason: str | None = None) -> None: + if not self.replacement_close_started.is_set(): + self.replacement_close_started.set() + await self.release_replacement_close.wait() + await super().close(code=code, reason=reason) + + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + first = DelayedReplacementCloseWebSocket() + first.put(_native_realtime_session_update("sid-takeover-stale-input")) + first_task = asyncio.create_task(handler.handle_realtime_session(first)) + for _ in range(100): + created = next((message for message in first.sent if message.get("type") == "session.created"), None) + if created is not None: + break + await asyncio.sleep(0.001) + else: + pytest.fail("first attachment did not create the session") + + second = TimedWebSocket(receive_timeout_s=1.0) + second.put( + { + "type": "session.resume", + "session_id": "sid-takeover-stale-input", + "incarnation": created["incarnation"], + "resume_token": created["resume_token"], + "last_received_server_event_seq": 0, + } + ) + second_task = asyncio.create_task(handler.handle_realtime_session(second)) + await asyncio.wait_for(first.replacement_close_started.wait(), timeout=1) + + first.put({"type": "session.heartbeat"}) + await asyncio.wait_for(first.stale_heartbeat_read.wait(), timeout=1) + first.release_replacement_close.set() + await asyncio.wait_for(first_task, timeout=1) + + second.put({"type": "session.close"}) + await asyncio.wait_for(second_task, timeout=2) + assert all(activity is not DuplexLeaseActivity.HEARTBEAT for _, activity in engine.touched) + assert "session.heartbeat_ack" not in second.sent_types() + + +@pytest.mark.asyncio +async def test_realtime_resume_rejects_invalid_token_before_engine_control(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + first = TimedWebSocket(receive_timeout_s=0.01) + first.put(_native_realtime_session_update("sid-invalid-resume")) + await handler.handle_realtime_session(first) + + created = next(message for message in first.sent if message.get("type") == "session.created") + second = TimedWebSocket(receive_timeout_s=0.01) + second.put( + { + "type": "session.resume", + "session_id": "sid-invalid-resume", + "incarnation": created["incarnation"], + "resume_token": "invalid-token", + "last_received_server_event_seq": 0, + } + ) + + await handler.handle_realtime_session(second) + + error = next(message for message in second.sent if message.get("type") == "error") + assert error["error"]["code"] == "invalid_resume_token" + assert engine.resumed == [] + assert engine.closed == [] + + +@pytest.mark.asyncio +async def test_realtime_resume_recovers_when_rotated_token_delivery_is_lost(): + class FailingResumeWebSocket(TimedWebSocket): + async def send_json(self, data: dict[str, Any]): + if data.get("type") == "session.resumed": + raise RuntimeError("resume transport disappeared") + await super().send_json(data) + + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + first = TimedWebSocket(receive_timeout_s=0.01) + first.put(_native_realtime_session_update("sid-token-delivery")) + await handler.handle_realtime_session(first) + created = next(message for message in first.sent if message.get("type") == "session.created") + resume_event = { + "type": "session.resume", + "session_id": "sid-token-delivery", + "incarnation": created["incarnation"], + "resume_token": created["resume_token"], + "last_received_server_event_seq": 0, + } + + failed_transport = FailingResumeWebSocket(receive_timeout_s=0.01) + failed_transport.put(resume_event) + await handler.handle_realtime_session(failed_transport) + + recovered_transport = TimedWebSocket(receive_timeout_s=0.1) + recovered_transport.put(resume_event) + recovered_transport.put({"type": "session.close"}) + await handler.handle_realtime_session(recovered_transport) + + assert "session.resumed" in recovered_transport.sent_types() + assert engine.resumed == [ + ("sid-token-delivery", 0), + ("sid-token-delivery", 1), + ] + assert engine.closed == [("sid-token-delivery", "session_close")] + + +@pytest.mark.asyncio +async def test_realtime_disconnect_grace_cancels_only_orphan_response(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + duplex_session_config=DuplexSessionRuntimeConfig(disconnect_grace_s=0.02), + ) + ws = TimedWebSocket(receive_timeout_s=0.01) + ws.put(_native_realtime_session_update("sid-grace-response")) + + await handler.handle_realtime_session(ws) + session = handler._registry.get("sid-grace-response") + assert session is not None + session.begin_response() + session.bind_request("orphan-request") + orphan_task = asyncio.create_task(asyncio.sleep(10)) + handler._session_tasks[session.session_id].active_response_task = orphan_task + assert engine.closed == [] + + await asyncio.sleep(0.05) + + assert engine.closed == [] + assert engine.aborted == ["orphan-request"] + assert orphan_task.cancelled() + assert session.active_response_id is None + assert "sid-grace-response" in handler._minicpmo_sessions + + +@pytest.mark.asyncio +async def test_realtime_heartbeat_control_failure_is_non_terminal(): + engine = FakeEngineClient(fail_touch=True) + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket(receive_timeout_s=0.1) + ws.put(_native_realtime_session_update("sid-heartbeat-failure")) + ws.put({"type": "session.heartbeat"}) + ws.put({"type": "session.close"}) + + await handler.handle_realtime_session(ws) + + error = next(message for message in ws.sent if message.get("type") == "error") + assert error["error"]["code"] == "runtime_touch_failed" + assert engine.closed == [("sid-heartbeat-failure", "session_close")] + + +@pytest.mark.asyncio +async def test_realtime_idle_ttl_lifecycle_removes_detached_serving_projection(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket(receive_timeout_s=0.01) + ws.put(_native_realtime_session_update("sid-idle-expired")) + + await handler.handle_realtime_session(ws) + fence = engine.opened_fences[0] + assert fence is not None + await engine.duplex_lifecycle_events.put( + DuplexSessionLifecycleMessage( + fence=fence, + session_id="sid-idle-expired", + event="expired", + reason="idle_ttl_expired", + lease_generation=1, + submitted_request_ids=[], + reserved_request_ids=[], + ) + ) + await asyncio.sleep(0.05) + + assert handler._registry.get("sid-idle-expired") is None + assert "sid-idle-expired" not in handler._minicpmo_sessions + assert "sid-idle-expired" not in handler._session_tasks + assert engine.closed == [] + + +@pytest.mark.asyncio +async def test_realtime_resume_reports_resync_required_after_journal_ttl_gap(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + duplex_session_config=DuplexSessionRuntimeConfig(resume_replay_ttl_s=0.01), + ) + first = TimedWebSocket(receive_timeout_s=0.01) + first.put(_native_realtime_session_update("sid-journal-gap")) + first.put({"type": "session.heartbeat"}) + await handler.handle_realtime_session(first) + created = next(message for message in first.sent if message.get("type") == "session.created") + await asyncio.sleep(0.02) + + second = TimedWebSocket(receive_timeout_s=0.01) + second.put( + { + "type": "session.resume", + "session_id": "sid-journal-gap", + "incarnation": created["incarnation"], + "resume_token": created["resume_token"], + "last_received_server_event_seq": 0, + } + ) + await handler.handle_realtime_session(second) + + assert "session.resync_required" in second.sent_types() + assert engine.resumed == [] + assert engine.closed == [] + + +@pytest.mark.asyncio +async def test_realtime_journal_overflow_degrades_to_live_resync_without_closing_runtime(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + duplex_session_config=DuplexSessionRuntimeConfig(resume_replay_max_bytes_per_session=128), + ) + ws = TimedWebSocket(receive_timeout_s=0.01) + ws.put(_native_realtime_session_update("sid-journal-overflow")) + + await handler.handle_realtime_session(ws) + + assert "session.created" in ws.sent_types() + assert "session.resync_required" in ws.sent_types() + assert engine.closed == [] + assert handler._registry.get("sid-journal-overflow") is not None + + +@pytest.mark.asyncio +async def test_realtime_input_backpressure_is_per_session_and_rejects_before_mutation(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + duplex_session_config=DuplexSessionRuntimeConfig( + max_pending_input_bytes_per_session=100, + ), + ) + first = TimedWebSocket(receive_timeout_s=0.1) + first.put(_native_realtime_session_update("sid-backpressure-a")) + for _ in range(2): + first.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm16_b64(20), + "format": "pcm16", + "sample_rate_hz": 16000, + } + ) + first.put({"type": "session.close"}) + + await handler.handle_realtime_session(first) + + backpressure = [ + event + for event in first.sent + if event.get("type") == "error" + and isinstance(event.get("error"), dict) + and event["error"].get("code") == "input_backpressure" + ] + assert len(backpressure) == 1 + + second = TimedWebSocket(receive_timeout_s=0.1) + second.put(_native_realtime_session_update("sid-backpressure-b")) + second.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm16_b64(20), + "format": "pcm16", + "sample_rate_hz": 16000, + } + ) + second.put({"type": "session.close"}) + + await handler.handle_realtime_session(second) + + assert not any( + event.get("type") == "error" + and isinstance(event.get("error"), dict) + and event["error"].get("code") == "input_backpressure" + for event in second.sent + ) + + +@pytest.mark.asyncio +async def test_duplex_handler_idle_timeout_close_does_not_emit_runtime_control(): + control_result = { + "operation": "close", + "session_id": "sid-disconnect", + "ok": True, + "unsupported_count": 1, + "error_count": 0, + "stage_results": [{"stage_id": 0, "replica_id": 0, "result": {"supported": False}}], + } + engine = FakeEngineClient(append_result=control_result) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=0.1) + ws = TimedWebSocket() + ws.put(_session_create("sid-disconnect")) + + await handler.handle_session(ws) + + assert engine.closed == [("sid-disconnect", "timeout")] + + +@pytest.mark.asyncio +async def test_idle_output_audio_clear_does_not_advance_runtime_fence(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put(_session_create("sid-idle-output-clear")) + ws.put({"type": "output_audio_buffer.clear"}) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + cancelled = next(event for event in ws.sent if event.get("type") == "audio.cancelled") + assert cancelled["cancelled_epoch"] == cancelled["epoch"] == 0 + assert engine.signals == [] + + +@pytest.mark.asyncio +async def test_duplex_handler_runtime_close_failure_is_reported_without_closed_ack(): + engine = FakeEngineClient(fail_close=True) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_session_create("sid-close-fail")) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert ws.sent_types() == ["session.created", "error"] + assert ws.sent[-1]["code"] == "runtime_close_failed" + + +@pytest.mark.asyncio +async def test_duplex_handler_control_close_failure_is_reported_without_closed_ack(): + control_result = { + "operation": "close", + "session_id": "sid-control-close-fail", + "ok": False, + "unsupported_count": 0, + "error_count": 1, + "stage_results": [ + { + "stage_id": 0, + "replica_id": 0, + "result": {"supported": False, "error": "stage close failed"}, + } + ], + } + engine = FakeEngineClient(close_result=control_result) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_session_create("sid-control-close-fail")) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert ws.sent_types() == ["session.created", "error"] + assert ws.sent[-1]["code"] == "runtime_close_failed" + assert ws.sent[-1]["runtime_control"]["error_count"] == 1 + + +@pytest.mark.asyncio +async def test_duplex_cancel_without_active_response_clears_pending_input_and_acks(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_session_create("sid-pending-cancel")) + ws.put({"type": "input.text.append", "text": "hello"}) + ws.put({"type": "input.cancel", "reason": "user_cancel"}) + ws.put({"type": "input.commit"}) + + await handler.handle_session(ws) + + cancelled = next(m for m in ws.sent if m.get("type") == "input.cancelled") + assert cancelled["cancelled"] == {"text_chunks": 1, "audio_chunks": 0} + assert cancelled["epoch"] == 1 + assert chat_service.seen_request_ids == [] + assert "response.created" not in ws.sent_types() + + +@pytest.mark.asyncio +async def test_turn_signal_input_cancel_uses_epoch_fence_transition(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put(_session_create("sid-turn-signal-cancel")) + ws.put({"type": "input.text.append", "text": "discard me"}) + ws.put({"type": "turn.signal", "event": "input.cancel"}) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert engine.signal_fences[-1] == DuplexFence("sid-turn-signal-cancel") + assert engine.signal_next_fences[-1] == DuplexFence("sid-turn-signal-cancel", epoch=1) + assert any(message.get("type") == "input.cancelled" for message in ws.sent) + + +@pytest.mark.asyncio +async def test_duplex_handler_local_turn_signal_does_not_round_trip_runtime(): + engine = FakeEngineClient(fail_signal=True) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_session_create("sid-signal-fail")) + ws.put({"type": "turn.signal", "event": "user_started"}) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + turn_event = next(m for m in ws.sent if m.get("type") == "turn.event") + assert turn_event["event"] == "user_started" + assert not engine.signals + assert "runtime_signal_failed" not in {m.get("code") for m in ws.sent} + + +@pytest.mark.asyncio +async def test_duplex_barge_in_aborts_active_response_when_runtime_signal_fails(): + engine = FakeEngineClient(fail_signal_events={"barge_in"}) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") == "response.created": + ws.put({"type": "input.cancel", "reason": "test_barge_in"}) + + ws = TimedWebSocket(on_send=on_send) + ws.put(_session_create("sid-barge-signal-fail")) + ws.put({"type": "input.text.append", "text": "hello"}) + ws.put({"type": "input.commit"}) + + await handler.handle_session(ws) + + assert "audio.cancelled" in ws.sent_types() + assert engine.aborted == ["chatcmpl-duplex-sid-barge-signal-fail-0-1"] + assert engine.internal_abort_batches == [["chatcmpl-duplex-sid-barge-signal-fail-0-1"]] + error = next(m for m in ws.sent if m.get("type") == "error") + assert error["code"] == "runtime_signal_failed" + + +@pytest.mark.asyncio +async def test_duplex_handler_surfaces_stage_unsupported_result_to_client(): + control_result = { + "operation": "open", + "session_id": "sid-unsupported", + "ok": True, + "unsupported_count": 1, + "error_count": 0, + "stage_results": [ + { + "stage_id": 0, + "replica_id": 0, + "result": {"supported": False, "reason": "not implemented"}, + } + ], + } + engine = FakeEngineClient(open_result=control_result) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_session_create("sid-unsupported")) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + created = next(m for m in ws.sent if m.get("type") == "session.created") + assert created["runtime_control"]["unsupported_count"] == 1 + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_append_without_format_defaults_to_pcm16(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-native-default-pcm16")) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm16_b64(16000), + "sample_rate_hz": 16000, + "is_speech": True, + } + ) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert len(engine.appended) == 1 + _, mode, payload, final = engine.appended[0] + assert mode == "append_audio_chunk" + assert final is False + assert isinstance(payload, dict) + assert payload["format"] == "pcm_f32le" + assert payload["sample_rate_hz"] == 16000 + samples = np.frombuffer(base64.b64decode(payload["audio"]), dtype=" None: + super().__init__() + self.append_started = asyncio.Event() + self.append_cancelled = asyncio.Event() + self.release_append = asyncio.Event() + + async def append_duplex_input_async( + self, + session_id: str, + *, + mode: str, + payload: object, + final: bool = False, + timeout: float | None = None, + collect_outputs: bool = True, + fence: DuplexFence | None = None, + ): + self.append_started.set() + try: + await self.release_append.wait() + except asyncio.CancelledError: + self.append_cancelled.set() + raise + return await super().append_duplex_input_async( + session_id, + mode=mode, + payload=payload, + final=final, + timeout=timeout, + collect_outputs=collect_outputs, + fence=fence, + ) + + engine = SlowAppendEngine() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-cancel-slow-append")) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm16_b64(16000), + "sample_rate_hz": 16000, + "is_speech": True, + } + ) + ws.put({"type": "input.cancel"}) + ws.put({"type": "session.close"}) + + handler_task = asyncio.create_task(handler.handle_session(ws)) + await asyncio.wait_for(engine.append_started.wait(), timeout=1) + try: + await asyncio.wait_for(engine.append_cancelled.wait(), timeout=0.2) + except TimeoutError: + pass + finally: + engine.release_append.set() + await asyncio.wait_for(handler_task, timeout=1) + + assert engine.append_cancelled.is_set() + assert engine.signal_fences[-1] == DuplexFence("sid-cancel-slow-append") + assert engine.signal_next_fences[-1] == DuplexFence("sid-cancel-slow-append", epoch=1) + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_append_control_error_does_not_emit_model_delta(): + control_result = { + "operation": "append", + "session_id": "sid-native-append-error", + "ok": False, + "unsupported_count": 0, + "error_count": 1, + "stage_results": [ + { + "stage_id": -1, + "replica_id": -1, + "result": { + "supported": False, + "error": "duplex_stage_handoff_target_missing:tts", + }, + }, + ], + } + engine = FakeEngineClient(append_result=control_result) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-native-append-error")) + ws.put({"type": "input_audio_buffer.append", "audio": "AAAA", "format": "pcm_f32le"}) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert "response.output_audio.delta" not in ws.sent_types() + error = next(m for m in ws.sent if m.get("type") == "error") + assert error["code"] == "runtime_append_failed" + assert error["runtime_control"]["error_count"] == 1 + + +@pytest.mark.asyncio +async def test_duplex_handler_session_update_control_failure_rolls_back_config(): + control_result = { + "operation": "signal", + "session_id": "sid-control-signal-fail", + "ok": False, + "unsupported_count": 0, + "error_count": 1, + "stage_results": [ + { + "stage_id": 0, + "replica_id": 0, + "result": {"supported": False, "error": "stage signal failed"}, + } + ], + } + engine = FakeEngineClient(signal_result=control_result) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_session_create("sid-control-signal-fail")) + ws.put( + { + "type": "turn.signal", + "event": "session.update", + "payload": {"instructions": "must roll back"}, + } + ) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert "session.updated" not in ws.sent_types() + error = next(m for m in ws.sent if m.get("type") == "error") + assert error["code"] == "runtime_signal_failed" + assert error["runtime_control"]["error_count"] == 1 + assert engine.signal_session_configs[-1]["instructions"] == "must roll back" + + +@pytest.mark.asyncio +async def test_native_session_update_commits_config_only_after_runtime_ack(): + class BlockingUpdateEngine(FakeEngineClient): + def __init__(self): + super().__init__() + self.update_started = asyncio.Event() + self.release_update = asyncio.Event() + + async def signal_duplex_turn_async(self, session_id: str, **kwargs): + if kwargs.get("event") == "session.update": + self.update_started.set() + await self.release_update.wait() + return await super().signal_duplex_turn_async(session_id, **kwargs) + + engine = BlockingUpdateEngine() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put(_session_create("sid-update-two-phase")) + ws.put( + { + "type": "turn.signal", + "event": "session.update", + "payload": {"instructions": "candidate instructions"}, + } + ) + + handler_task = asyncio.create_task(handler.handle_session(ws)) + await asyncio.wait_for(engine.update_started.wait(), timeout=1) + live_session = handler._registry.get("sid-update-two-phase") + assert live_session is not None + assert live_session.config.instructions != "candidate instructions" + + engine.release_update.set() + ws.put({"type": "session.close"}) + await asyncio.wait_for(handler_task, timeout=2) + + updated = next(message for message in ws.sent if message.get("type") == "session.updated") + assert updated["session"]["instructions"] == "candidate instructions" + + +@pytest.mark.asyncio +async def test_native_session_update_waits_for_prior_append_effect(): + class OrderedUpdateEngine(FakeEngineClient): + def __init__(self): + super().__init__() + self.append_started = asyncio.Event() + self.release_append = asyncio.Event() + self.update_started = asyncio.Event() + self.effects: list[str] = [] + + async def append_duplex_input_async(self, session_id: str, **kwargs): + self.effects.append("append.started") + self.append_started.set() + await self.release_append.wait() + kwargs.pop("expected_epoch", None) + result = await super().append_duplex_input_async(session_id, **kwargs) + self.effects.append("append.done") + return result + + async def signal_duplex_turn_async(self, session_id: str, **kwargs): + if kwargs.get("event") == "session.update": + self.effects.append("session.update") + self.update_started.set() + return await super().signal_duplex_turn_async(session_id, **kwargs) + + engine = OrderedUpdateEngine() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-ordered-update")) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(16000, value=0.05), + "format": "pcm_f32le", + } + ) + ws.put( + { + "type": "turn.signal", + "event": "session.update", + "payload": {"temperature": 0.2}, + } + ) + + handler_task = asyncio.create_task(handler.handle_session(ws)) + await asyncio.wait_for(engine.append_started.wait(), timeout=1) + try: + await asyncio.sleep(0.05) + assert not engine.update_started.is_set() + finally: + engine.release_append.set() + ws.put({"type": "session.close"}) + await asyncio.wait_for(handler_task, timeout=2) + + assert engine.effects.index("append.done") < engine.effects.index("session.update") + + +@pytest.mark.asyncio +async def test_failed_native_append_prevents_queued_append_from_running(): + failed_result = { + "operation": "append", + "session_id": "sid-append-chain-failure", + "ok": False, + "unsupported_count": 0, + "error_count": 1, + "stage_results": [ + { + "stage_id": 0, + "replica_id": 0, + "result": {"supported": False, "error": "append failed"}, + } + ], + } + + class BlockingFailureEngine(FakeEngineClient): + def __init__(self): + super().__init__(append_result=failed_result) + self.first_started = asyncio.Event() + self.release_first = asyncio.Event() + + async def append_duplex_input_async(self, session_id: str, **kwargs): + if not self.appended: + self.first_started.set() + await self.release_first.wait() + kwargs.pop("expected_epoch", None) + return await super().append_duplex_input_async(session_id, **kwargs) + + engine = BlockingFailureEngine() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + create = _native_session_create("sid-append-chain-failure") + create["session"]["extra_body"]["auto_response"] = True + ws.put(create) + for _ in range(2): + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(16_000, value=0.05), + "format": "pcm_f32le", + } + ) + + handler_task = asyncio.create_task(handler.handle_session(ws)) + await asyncio.wait_for(engine.first_started.wait(), timeout=1) + engine.release_first.set() + await asyncio.sleep(0.1) + ws.put({"type": "session.close"}) + await asyncio.wait_for(handler_task, timeout=2) + + assert len(engine.appended) == 1 + + +@pytest.mark.asyncio +async def test_failed_final_append_keeps_committed_audio_for_response_retry(): + failed_result = { + "operation": "append", + "session_id": "sid-final-append-retry", + "ok": False, + "unsupported_count": 0, + "error_count": 1, + "stage_results": [], + } + successful_result = { + "operation": "append", + "session_id": "sid-final-append-retry", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [], + } + engine = FakeEngineClient(append_results=[failed_result, successful_result]) + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + + def retry_after_runtime_error(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") == "error" and data.get("code") == "runtime_append_failed": + ws.put({"type": "response.create"}) + + ws = TimedWebSocket(on_send=retry_after_runtime_error) + ws.put(_native_session_create("sid-final-append-retry")) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(8_000, value=0.05), + "format": "pcm_f32le", + } + ) + ws.put({"type": "input_audio_buffer.commit", "final": True}) + ws.put({"type": "response.create"}) + + handler_task = asyncio.create_task(handler.handle_session(ws)) + for _ in range(100): + if len(engine.appended) >= 2: + break + await asyncio.sleep(0.01) + ws.put({"type": "session.close"}) + await asyncio.wait_for(handler_task, timeout=2) + + assert len(engine.appended) == 2 + assert engine.appended[0][2]["audio"] == engine.appended[1][2]["audio"] + assert engine.append_operation_ids[0] == engine.append_operation_ids[1] + + +@pytest.mark.asyncio +async def test_duplex_handler_signal_unsupported_workers_with_data_plane_ack_is_not_fatal(): + control_result = { + "operation": "signal", + "session_id": "sid-control-signal-data-plane", + "ok": False, + "unsupported_count": 2, + "error_count": 0, + "stage_results": [ + { + "stage_id": 0, + "replica_id": 0, + "result": {"supported": False, "reason": "worker_duplex_signal_not_implemented"}, + }, + { + "stage_id": 1, + "replica_id": 0, + "result": {"supported": False, "reason": "worker_duplex_signal_not_implemented"}, + }, + { + "stage_id": -1, + "replica_id": -1, + "result": { + "supported": True, + "data_plane_signal": True, + "event": "barge_in", + "epoch": 1, + }, + }, + ], + } + engine = FakeEngineClient(signal_result=control_result) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_session_create("sid-control-signal-data-plane")) + ws.put({"type": "turn.signal", "event": "barge_in"}) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert "runtime_signal_failed" not in {m.get("code") for m in ws.sent} + assert "input.cancelled" in ws.sent_types() + runtime_control = next(m for m in ws.sent if m.get("type") == "runtime.control") + assert runtime_control["result"]["unsupported_count"] == 2 + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_open_unsupported_fails_session_create(): + control_result = { + "operation": "open", + "session_id": "sid-native-busy", + "ok": True, + "unsupported_count": 1, + "error_count": 0, + "stage_results": [ + { + "stage_id": 0, + "replica_id": 0, + "result": {"supported": False, "reason": "native_duplex_session_busy"}, + } + ], + } + engine = FakeEngineClient(control_result=control_result) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-native-busy")) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert ws.sent_types() == ["error"] + assert ws.sent[0]["code"] == "runtime_open_unsupported" + assert ws.sent[0]["runtime_control"]["unsupported_count"] == 1 + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_rejects_engine_without_fence_contract(): + class LegacyEngineClient(FakeEngineClient): + async def open_duplex_session_async( + self, + session_id: str, + *, + session_mode: str = "duplex", + capabilities: dict[str, object] | None = None, + session_config: dict[str, object] | None = None, + timeout: float | None = None, + ) -> None: + return await super().open_duplex_session_async( + session_id, + session_mode=session_mode, + capabilities=capabilities, + session_config=session_config, + timeout=timeout, + ) + + engine = LegacyEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-native-no-fence-contract")) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert ws.sent_types() == ["error"] + assert ws.sent[0]["code"] == "runtime_contract_invalid" + assert "open_duplex_session_async" in ws.sent[0]["error"] + assert "fence" in ws.sent[0]["error"] + assert engine.opened == [] + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_audio_append_does_not_retain_pending_audio(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-native-no-pending")) + short_chunk = base64.b64encode(b"\x00" * (800 * 4)).decode("ascii") + ws.put({"type": "input_audio_buffer.append", "audio": short_chunk, "format": "pcm_f32le"}) + ws.put({"type": "input.cancel", "reason": "barge_in"}) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + cancelled = next(m for m in ws.sent if m.get("type") == "input.cancelled") + assert cancelled["cancelled"] == {"text_chunks": 0, "audio_chunks": 0} + assert cancelled["epoch"] == 1 + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_rejects_text_append_before_runtime_call(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-native-text")) + ws.put({"type": "input.text.append", "text": "hello"}) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + error = next(m for m in ws.sent if m.get("type") == "error") + assert error["code"] == "native_text_append_unsupported" + assert engine.appended == [] + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_separates_public_and_runtime_config(monkeypatch): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + + async def fake_resolve_ref_audio(_, *, model_config): + del model_config + return [0.25] * 1600, 16000 + + monkeypatch.setattr( + "vllm_omni.experimental.fullduplex.minicpmo45.MiniCPMO45NativeDuplexServingAdapter.resolve_ref_audio", + fake_resolve_ref_audio, + ) + event = _native_session_create("sid-native-ref-audio", modalities=["text", "audio"]) + event["session"]["ref_audio"] = "data:audio/wav;base64,AAAA" + ws = TimedWebSocket() + ws.put(event) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert ws.sent_types()[0] == "session.created" + opened_config = engine.opened_configs[0] + opened_runtime_config = engine.opened_runtime_configs[0] + extra_body = opened_config["extra_body"] + assert "ref_audio_data" not in extra_body + assert "duplex_stage_max_tokens" not in extra_body + assert opened_runtime_config["ref_audio_format"] == "pcm_f32le" + assert opened_runtime_config["ref_audio_sample_rate_hz"] == 16000 + assert opened_runtime_config["duplex_stage_max_tokens"] == {"0": 20, "1": 8192} + assert opened_runtime_config["duplex_stage_sampling_params"]["0"]["top_k"] == 20 + assert opened_runtime_config["duplex_stage_sampling_params"]["0"]["top_p"] == 0.8 + assert base64.b64decode(opened_runtime_config["ref_audio_data"]) == struct.pack("<1600f", *([0.25] * 1600)) + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_rejects_client_runtime_config(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + event = _native_session_create("sid-native-private-config") + event["session"]["extra_body"]["duplex_stage_sampling_params"] = {"0": {"temperature": 0.0}} + ws = TimedWebSocket() + ws.put(event) + + await handler.handle_session(ws) + + assert ws.sent_types() == ["error"] + assert ws.sent[0]["code"] == "invalid_duplex_runtime_config" + assert engine.opened == [] + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_audio_output_requires_ref_audio(monkeypatch): + monkeypatch.setattr( + MiniCPMO45NativeDuplexServingAdapter, + "_load_native_tokenizer", + staticmethod(lambda model_config: None), + ) + + event = _native_session_create( + "sid-native-ref-required", + modalities=["text", "audio"], + ) + config = DuplexSessionConfig.from_event(event) + + with pytest.raises(ServingRuntimeConfigError) as exc_info: + await MiniCPMO45NativeDuplexServingAdapter.prepare_runtime_config( + config, + model_config=SimpleNamespace(model="openbmb/MiniCPM-o-4_5"), + ) + + assert exc_info.value.code == "ref_audio_required" + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_open_session_maps_missing_ref_audio_to_typed_error(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put( + _native_session_create( + "sid-native-ref-required-open", + modalities=["text", "audio"], + ) + ) + + await handler.handle_session(ws) + + assert ws.sent_types() == ["error"] + assert ws.sent[0]["code"] == "ref_audio_required" + assert engine.opened == [] + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_text_only_omits_ref_audio_when_client_does_not_provide_it(monkeypatch): + monkeypatch.setattr( + MiniCPMO45NativeDuplexServingAdapter, + "_load_native_tokenizer", + staticmethod(lambda model_config: None), + ) + + event = _native_session_create("sid-native-default-ref", modalities=["text"]) + config = DuplexSessionConfig.from_event(event) + + runtime_config = await MiniCPMO45NativeDuplexServingAdapter.prepare_runtime_config( + config, + model_config=SimpleNamespace(model="openbmb/MiniCPM-o-4_5"), + ) + + assert "ref_audio_data" not in runtime_config + assert "ref_audio_format" not in runtime_config + assert "ref_audio_sample_rate_hz" not in runtime_config + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_preserves_ref_audio_channels_until_normalize(monkeypatch): + class FakeMediaConnector: + def __init__(self, **kwargs): + self.kwargs = kwargs + + async def fetch_audio_async(self, ref_audio): + assert ref_audio == "data:audio/wav;base64,AAAA" + return np.tile(np.array([[0.25, -0.25], [0.5, -0.5]], dtype=np.float32), (800, 1)), 16000 + + monkeypatch.setattr( + "vllm_omni.experimental.fullduplex.minicpmo45.adapter.MediaConnector", + FakeMediaConnector, + ) + + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + event = _native_session_create("sid-native-ref-audio-stereo", modalities=["text", "audio"]) + event["session"]["ref_audio"] = "data:audio/wav;base64,AAAA" + ws = TimedWebSocket() + ws.put(event) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + runtime_config = engine.opened_runtime_configs[0] + assert base64.b64decode(runtime_config["ref_audio_data"]) == struct.pack("<1600f", *([0.0] * 1600)) + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_rejects_ref_audio_path(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + event = _native_session_create("sid-native-ref-path") + event["session"]["extra_body"] = { + "minicpmo45_native_duplex": True, + "ref_audio_path": "/tmp/ref.wav", + } + ws = TimedWebSocket() + ws.put(event) + + await handler.handle_session(ws) + + assert ws.sent_types() == ["error"] + assert ws.sent[0]["code"] == "unsupported_ref_audio_path" + assert engine.opened == [] + + +def test_minicpmo_native_duplex_explicit_barge_in_request_is_deferred_as_listen(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + session = DuplexSession( + session_id="sid-native-explicit-barge-disabled", + config=DuplexSessionConfig(overlap_policy=DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value), + capabilities=DuplexCapabilities.minicpmo45_native(), + ) + payload = { + "type": "audio", + "audio": _pcm_f32_b64(16000, value=0.05), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + "is_speech": True, + } + + decision = handler._overlap_decision( + session, + {"force_barge_in": True}, + payload, + ) + + assert decision == { + "action": "listen", + "reason": "barge_in_unsupported", + "duration_ms": 1000, + "overlap_speech_ms": 1000, + "buffer_audio": True, + "defer_runtime_append": True, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "barge_in_event", + [ + {"type": "barge_in"}, + {"type": "turn.signal", "event": "barge_in"}, + ], +) +async def test_minicpmo_native_duplex_rejects_unadvertised_barge_in_control( + barge_in_event: dict[str, object], +): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-native-barge-control-disabled")) + ws.put(barge_in_event) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + error = next(message for message in ws.sent if message.get("code") == "barge_in_unsupported") + assert error["session_id"] == "sid-native-barge-control-disabled" + assert ("sid-native-barge-control-disabled", "barge_in") not in engine.signals + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_explicit_non_speech_stays_listening_without_append(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-native-silence")) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": "AAAA", + "format": "pcm_f32le", + "is_speech": False, + } + ) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert engine.appended == [] + assert "response.listen" in ws.sent_types() + assert "response.created" not in ws.sent_types() + assert "response.output_audio.delta" not in ws.sent_types() + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_drains_data_plane_stream_until_done(): + def _stage_output(samples: int, *, finished: bool): + return SimpleNamespace( + request_id="duplex-sid-native-stream-e0-stage0-s1", + finished=finished, + outputs=[ + SimpleNamespace( + text="", + multimodal_output={ + "audio": np.zeros(samples, dtype=np.float32), + "sr": 24000, + }, + ) + ], + ) + + control_result = { + "operation": "append", + "session_id": "sid-native-stream", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [ + { + "stage_id": 0, + "replica_id": 0, + "result": { + "supported": True, + "implementation_level": "model_native_duplex", + "data_plane_append": True, + "request_id": "duplex-sid-native-stream-e0-stage0-s1", + "response_stage_id": 1, + }, + } + ], + "data_plane_outputs": [_stage_output(10, finished=False)], + } + engine = FakeEngineClient( + append_result=control_result, + collect_outputs=[ + [_stage_output(20, finished=False)], + [_stage_output(30, finished=True)], + ], + ) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + + def close_on_second_created(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if ws.sent_types().count("response.created") >= 2: + ws.put({"type": "session.close"}) + + ws = TimedWebSocket(on_send=close_on_second_created) + ws.put(_native_session_create("sid-native-stream")) + ws.put({"type": "input_audio_buffer.append", "audio": "AAAA", "format": "pcm_f32le"}) + + await handler.handle_session(ws) + + deltas = [m for m in ws.sent if m.get("type") == "response.output_audio.delta"] + assert [m["audio"] for m in deltas] == ["wav-10", "wav-10", "wav-10"] + assert [m["end_of_turn"] for m in deltas] == [False, False, True] + assert len([m for m in ws.sent if m.get("type") == "response.done"]) == 1 + assert engine.collected == [ + ("duplex-sid-native-stream-e0-stage0-s1", 1), + ("duplex-sid-native-stream-e0-stage0-s1", 1), + ] + + +@pytest.mark.asyncio +async def test_minicpmo_auto_response_restarts_drain_when_append_races_idle_exit( + monkeypatch: pytest.MonkeyPatch, +): + request_id = "duplex-sid-native-late-output-e0-stage0" + late_output = object() + engine = FakeEngineClient( + collect_outputs=[[], [], [], [late_output]], + collect_delay_s=0.05, + ) + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + session = DuplexSession( + session_id="sid-native-late-output", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + session.bind_request(request_id) + native = handler._minicpmo_session_state(session) + projected_batches: list[object] = [] + projected = asyncio.Event() + + async def project_late_output(_send_json, result, **_kwargs): + projected_batches.append(result) + projected.set() + session.close() + return None, True + + monkeypatch.setattr(handler, "_send_native_duplex_events", project_late_output) + result = { + "stage_results": [ + { + "result": { + "data_plane_append": True, + "request_id": request_id, + "response_stage_id": 1, + } + } + ] + } + + assert ( + await handler._start_native_data_plane_stream_task( + None, + result, + session=session, + expected_epoch=session.epoch, + ) + is True + ) + first_drain = native.data_plane_task + assert first_drain is not None + await asyncio.sleep(0.01) + + assert ( + await handler._start_native_data_plane_stream_task( + None, + result, + session=session, + expected_epoch=session.epoch, + ) + is False + ) + await asyncio.wait_for(projected.wait(), timeout=1) + await asyncio.wait_for(first_drain, timeout=1) + + assert len(engine.collected) == 4 + assert projected_batches == [{"data_plane_outputs": [late_output]}] + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_ignores_outputs_after_data_plane_turn_done(): + request_id = "duplex-sid-native-post-done-e0-stage0-s1" + + def _stage_output(samples: int, *, turn_end: bool = False): + return SimpleNamespace( + request_id=request_id, + finished=False, + outputs=[ + SimpleNamespace( + text="hello", + multimodal_output={ + "audio": np.zeros(samples, dtype=np.float32), + "sr": 24000, + "meta": { + "turn_end": turn_end, + "duplex_turn_id": 0, + "duplex_epoch": 0, + }, + }, + ) + ], + ) + + control_result = { + "operation": "append", + "session_id": "sid-native-post-done", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [ + { + "stage_id": 0, + "replica_id": 0, + "result": { + "supported": True, + "implementation_level": "model_native_duplex", + "data_plane_append": True, + "request_id": request_id, + "response_stage_id": 1, + }, + } + ], + "data_plane_outputs": [_stage_output(10)], + } + engine = FakeEngineClient( + append_result=control_result, + collect_outputs=[ + [_stage_output(20, turn_end=True)], + [_stage_output(30)], + ], + ) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + + def close_on_done(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") == "response.done": + ws.put({"type": "session.close"}) + + event = _native_session_create("sid-native-post-done") + event["session"]["extra_body"]["auto_response"] = True + ws = TimedWebSocket(on_send=close_on_done) + ws.put(event) + ws.put({"type": "input_audio_buffer.append", "audio": "AAAA", "format": "pcm_f32le"}) + + await handler.handle_session(ws) + + assert ws.sent_types().count("response.created") == 1 + assert ws.sent_types().count("response.done") == 1 + deltas = [m for m in ws.sent if m.get("type") == "response.output_audio.delta"] + assert [m["audio"] for m in deltas] == ["wav-10", "wav-10"] + assert engine.collected + assert all(collected == (request_id, 1) for collected in engine.collected) + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_accepts_next_append_after_response_done(): + def _stage_output(*, finished: bool): + return SimpleNamespace( + request_id="duplex-sid-native-next-turn-e0-stage0-s1", + finished=finished, + outputs=[ + SimpleNamespace( + text="", + multimodal_output={ + "audio": np.zeros(10, dtype=np.float32), + "sr": 24000, + }, + ) + ], + ) + + control_result = { + "operation": "append", + "session_id": "sid-native-next-turn", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [ + { + "stage_id": 0, + "replica_id": 0, + "result": { + "supported": True, + "implementation_level": "model_native_duplex", + "data_plane_append": True, + "request_id": "duplex-sid-native-next-turn-e0-stage0-s1", + "response_stage_id": 1, + }, + } + ], + "data_plane_outputs": [_stage_output(finished=True)], + } + engine = FakeEngineClient(append_result=control_result) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + done_count = 0 + + def send_next_turn_or_close(ws: TimedWebSocket, data: dict[str, Any]) -> None: + nonlocal done_count + if data.get("type") != "response.done": + return + done_count += 1 + if done_count == 1: + ws.put({"type": "input_audio_buffer.append", "audio": "BBBB", "format": "pcm_f32le"}) + ws.put({"type": "input_audio_buffer.commit", "final": True}) + ws.put({"type": "response.create"}) + else: + ws.put({"type": "session.close"}) + + ws = TimedWebSocket(on_send=send_next_turn_or_close, receive_timeout_s=5) + create = _native_session_create("sid-native-next-turn") + create["session"]["idle_timeout_s"] = 5 + ws.put(create) + ws.put({"type": "input_audio_buffer.append", "audio": "AAAA", "format": "pcm_f32le"}) + + await handler.handle_session(ws) + + assert len(engine.appended) >= 2, [ + (message.get("type"), message.get("code"), message.get("reason")) for message in ws.sent + ] + assert len([m for m in ws.sent if m.get("type") == "response.done"]) == 2 + + +@pytest.mark.asyncio +async def test_minicpmo_native_auto_response_commit_finalizes_after_streamed_full_chunk(): + control_result = { + "operation": "append", + "session_id": "sid-native-auto-finalize", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [], + } + engine = FakeEngineClient(control_result=control_result) + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + event = _native_session_create("sid-native-auto-finalize") + event["session"]["extra_body"]["auto_response"] = True + ws.put(event) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(16000), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + ws.put({"type": "input_audio_buffer.commit", "final": True}) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert len(engine.appended) == 1 + assert engine.appended[0][3] is False + assert "response.created" not in ws.sent_types() + + +@pytest.mark.asyncio +async def test_minicpmo_native_auto_response_commit_finalizes_with_active_data_plane_stream(): + request_id = "duplex-sid-native-auto-active-stream-stage0" + control_result = { + "operation": "append", + "session_id": "sid-native-auto-active-stream", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [ + { + "result": { + "data_plane_append": True, + "request_id": request_id, + "response_stage_id": 1, + } + } + ], + } + engine = FakeEngineClient( + control_result=control_result, + collect_outputs=[[], [], []], + collect_delay_s=0.05, + ) + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + event = _native_session_create("sid-native-auto-active-stream") + event["session"]["extra_body"]["auto_response"] = True + ws.put(event) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(16000), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + ws.put({"type": "input_audio_buffer.commit", "final": True}) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert len(engine.appended) == 1 + assert engine.appended[0][3] is False + assert "response.created" not in ws.sent_types() + + +@pytest.mark.asyncio +async def test_minicpmo_native_auto_response_accepts_realtime_commit_after_stream_consumed(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket() + event = _native_session_create("sid-native-auto-consumed-stream") + event["session"]["extra_body"]["auto_response"] = True + ws.put(event) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(16000), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + ws.put( + { + "type": "input_audio_buffer.commit", + "final": True, + "realtime_item_id": "item-consumed-stream", + } + ) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert not any(message.get("code") == "input_audio_buffer_empty" for message in ws.sent) + committed = next(message for message in ws.sent if message.get("type") == "input.committed") + assert committed["realtime_item_id"] == "item-consumed-stream" + assert committed["native_audio"] is True + + +@pytest.mark.asyncio +async def test_minicpmo_native_auto_response_post_response_commit_ignores_stale_progress_state(): + session_id = "sid-native-auto-post-response" + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") != "session.created": + return + session = handler._registry.get(session_id) + assert session is not None + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(8000), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + ws.put({"type": "input_audio_buffer.commit", "final": True}) + ws.put({"type": "session.close"}) + + ws = TimedWebSocket(on_send=on_send) + create = _native_session_create(session_id) + create["session"]["extra_body"]["auto_response"] = True + ws.put(create) + + await handler.handle_session(ws) + + assert len(engine.appended) == 1 + assert engine.appended[0][3] is True + assert "input.committed" in ws.sent_types() + assert not any(message.get("code") == "response_already_active" for message in ws.sent) + + +def test_realtime_overlap_commit_uses_current_generation_identity(): + handler, session = _auto_response_context("sid-realtime-reserved-input-turn") + session.turn_id = 1 + session.bind_response_turn(1) + session.turn_id = 3 + committed = handler._commit_native_audio_input(session) + + assert committed.turn_id == 3 + assert session.turn_id == 3 + + +@pytest.mark.asyncio +async def test_minicpmo_native_auto_response_keeps_request_bound_for_segment_continuation(): + request_id = "duplex-sid-native-auto-continuation-e0-stage0" + control_result = { + "operation": "append", + "session_id": "sid-native-auto-continuation", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [ + { + "result": { + "data_plane_append": True, + "request_id": request_id, + "response_stage_id": 1, + } + } + ], + } + terminal_segment = _duplex_tts_output( + request_id=request_id, + samples=0, + finished=True, + tts_is_last_chunk=True, + token_ids=[151645], + ) + engine = FakeEngineClient( + control_result=control_result, + collect_outputs=[[terminal_segment], [], []], + collect_delay_s=0.05, + ) + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") != "session.created": + return + session = handler._registry.get("sid-native-auto-continuation") + assert session is not None + session.capabilities.chunk_period_ms = 50 + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(800), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + ws.put({"type": "input_audio_buffer.commit", "final": True}) + + ws = TimedWebSocket(on_send=on_send) + event = _native_session_create("sid-native-auto-continuation") + event["session"]["extra_body"]["auto_response"] = True + ws.put(event) + + await handler.handle_session(ws) + + assert len(engine.appended) >= 3 + _, mode, payload, final = engine.appended[2] + assert mode == "append_audio_chunk" + assert payload["duplex_turn_id"] == 0 + assert final is False + + +@pytest.mark.asyncio +async def test_minicpmo_native_auto_response_real_input_preempts_unsent_silence(): + request_id = "duplex-sid-native-real-before-silence-e0-stage0" + + class SequencedAppendEngine(FakeEngineClient): + def __init__(self) -> None: + control_result = { + "operation": "append", + "session_id": "sid-native-real-before-silence", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [ + { + "result": { + "data_plane_append": True, + "request_id": request_id, + "response_stage_id": 1, + } + } + ], + } + terminal_segment = _duplex_tts_output( + request_id=request_id, + samples=0, + finished=True, + tts_is_last_chunk=True, + token_ids=[151645], + ) + super().__init__( + control_result=control_result, + collect_outputs=[[terminal_segment], [], []], + collect_delay_s=0.01, + ) + self.append_sequence: list[str] = [] + self.first_real_started = asyncio.Event() + self.second_real_started = asyncio.Event() + self.real_count = 0 + + async def append_duplex_input_async(self, session_id: str, **kwargs): + kwargs.pop("expected_epoch", None) + result = await super().append_duplex_input_async(session_id, **kwargs) + payload = kwargs.get("payload") + is_silence = ( + isinstance(payload, dict) + and payload.get("audio") == OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO + ) + if is_silence: + self.append_sequence.append("silence") + else: + self.real_count += 1 + self.append_sequence.append(f"real-{self.real_count}") + if self.real_count == 1: + self.first_real_started.set() + elif self.real_count == 2: + self.second_real_started.set() + return result + + session_created = asyncio.Event() + engine = SequencedAppendEngine() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") != "session.created": + return + session = handler._registry.get("sid-native-real-before-silence") + assert session is not None + session.capabilities.chunk_period_ms = 50 + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(800), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + session_created.set() + + ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0) + event = _native_session_create("sid-native-real-before-silence") + event["session"]["extra_body"]["auto_response"] = True + ws.put(event) + handler_task = asyncio.create_task(handler.handle_session(ws)) + + await asyncio.wait_for(session_created.wait(), timeout=1) + await asyncio.wait_for(engine.first_real_started.wait(), timeout=1) + await asyncio.sleep(0.02) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(800, value=0.07), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + await asyncio.wait_for(engine.second_real_started.wait(), timeout=1) + ws.put({"type": "session.close"}) + await asyncio.wait_for(handler_task, timeout=2) + + assert engine.append_sequence[:2] == ["real-1", "real-2"] + + +@pytest.mark.asyncio +async def test_minicpmo_native_auto_response_real_input_waits_for_submitted_silence_tail(): + request_id = "duplex-sid-native-silence-tail-e0-stage0" + + class SubmittedSilenceEngine(FakeEngineClient): + def __init__(self) -> None: + control_result = { + "operation": "append", + "session_id": "sid-native-silence-tail", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [ + { + "result": { + "data_plane_append": True, + "request_id": request_id, + "response_stage_id": 1, + } + } + ], + } + terminal_segment = _duplex_tts_output( + request_id=request_id, + samples=0, + finished=True, + tts_is_last_chunk=True, + token_ids=[151645], + ) + super().__init__( + control_result=control_result, + collect_outputs=[[terminal_segment], [], []], + collect_delay_s=0.01, + ) + self.silence_append_started = asyncio.Event() + self.release_silence_append = asyncio.Event() + self.append_sequence: list[str] = [] + self.real_append_count = 0 + + async def append_duplex_input_async(self, session_id: str, **kwargs): + kwargs.pop("expected_epoch", None) + payload = kwargs.get("payload") + is_silence_continuation = ( + isinstance(payload, dict) + and payload.get("format") == "pcm_f32le" + and payload.get("audio") == OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO + ) + if is_silence_continuation: + result = await super().append_duplex_input_async(session_id, **kwargs) + self.append_sequence.append("silence-started") + self.silence_append_started.set() + await self.release_silence_append.wait() + self.append_sequence.append("silence-returned") + return result + result = await super().append_duplex_input_async(session_id, **kwargs) + if ( + isinstance(payload, dict) + and payload.get("audio") != OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO + ): + self.real_append_count += 1 + self.append_sequence.append(f"real-{self.real_append_count}") + return result + + engine = SubmittedSilenceEngine() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") != "session.created": + return + session = handler._registry.get("sid-native-silence-tail") + assert session is not None + session.capabilities.chunk_period_ms = 50 + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(800, value=0.05), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + + ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0) + event = _native_session_create("sid-native-silence-tail") + event["session"]["extra_body"]["auto_response"] = True + ws.put(event) + handler_task = asyncio.create_task(handler.handle_session(ws)) + + await asyncio.wait_for(engine.silence_append_started.wait(), timeout=1) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(16000, value=0.07), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + await asyncio.sleep(0.05) + + assert engine.real_append_count == 1 + assert engine.append_sequence == ["real-1", "silence-started"] + + engine.release_silence_append.set() + ws.put({"type": "session.close"}) + await asyncio.wait_for(handler_task, timeout=2) + + assert engine.real_append_count == 2 + assert engine.append_sequence == ["real-1", "silence-started", "silence-returned", "real-2"] + + +@pytest.mark.asyncio +async def test_minicpmo_native_auto_response_preserves_silence_continuations_across_append_tails(): + request_id = "duplex-sid-native-continuation-tails-e0-stage0" + + class SequencedAppendEngine(FakeEngineClient): + def __init__(self) -> None: + control_result = { + "operation": "append", + "session_id": "sid-native-continuation-tails", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [ + { + "result": { + "data_plane_append": True, + "request_id": request_id, + "response_stage_id": 1, + } + } + ], + } + super().__init__(control_result=control_result) + self.real_append_started = asyncio.Event() + self.release_real_append = asyncio.Event() + self.first_silence_started = asyncio.Event() + self.release_first_silence = asyncio.Event() + self.second_silence_started = asyncio.Event() + self.release_collect = asyncio.Event() + self.silence_count = 0 + + async def append_duplex_input_async(self, session_id: str, **kwargs): + kwargs.pop("expected_epoch", None) + payload = kwargs.get("payload") + assert isinstance(payload, dict) + result = await super().append_duplex_input_async(session_id, **kwargs) + if payload.get("audio") != OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO: + self.real_append_started.set() + await self.release_real_append.wait() + return result + + self.silence_count += 1 + if self.silence_count == 1: + self.first_silence_started.set() + await self.release_first_silence.wait() + else: + self.second_silence_started.set() + return result + + async def collect_duplex_data_plane_outputs_async( + self, + request_id: str, + *, + response_stage_id: int | None = None, + timeout: float | None = None, + ) -> list[object]: + del timeout + self.collected.append((request_id, response_stage_id)) + await self.release_collect.wait() + return [] + + session_created = asyncio.Event() + + def on_send(_ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") == "session.created": + session_created.set() + + engine = SequencedAppendEngine() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0) + event = _native_session_create("sid-native-continuation-tails") + event["session"]["extra_body"]["auto_response"] = True + ws.put(event) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(16000, value=0.05), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + handler_task = asyncio.create_task(handler.handle_session(ws)) + + await asyncio.wait_for(session_created.wait(), timeout=1) + await asyncio.wait_for(engine.real_append_started.wait(), timeout=1) + session = handler._registry.get("sid-native-continuation-tails") + assert session is not None + session.capabilities.chunk_period_ms = 50 + session.bind_request(request_id) + response_id = session.begin_response(turn_id=session.turn_id) + native = handler._minicpmo_session_state(session) + scheduler = native.silence_continuation_scheduler + assert scheduler is not None + + payload = handler._native_silence_unit_payload() + payload["duplex_turn_id"] = session.turn_id + kwargs = { + "request_id": request_id, + "owner_id": f"response:{response_id}", + "response_id": response_id, + "response_owned": True, + "expected_epoch": session.epoch, + "expected_incarnation": session.incarnation, + "expected_model_turn_id": None, + "send_json": ws.send_json, + } + assert await scheduler(payload, **kwargs) is True + + next_schedule = asyncio.create_task(scheduler(payload, **kwargs)) + await asyncio.sleep(0.05) + assert not next_schedule.done() + + engine.release_real_append.set() + await asyncio.wait_for(engine.first_silence_started.wait(), timeout=1) + assert not next_schedule.done() + + engine.release_first_silence.set() + assert await asyncio.wait_for(next_schedule, timeout=1) is True + await asyncio.wait_for(engine.second_silence_started.wait(), timeout=1) + ws.put({"type": "session.close"}) + await asyncio.wait_for(handler_task, timeout=2) + + assert engine.silence_count == 2 + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_uses_segment_text_metadata_for_transcript_cursor(): + request_id = "duplex-sid-native-repeat-text-e0-stage0" + + def _stage_output( + text: str, + samples: int, + *, + segment_text: str, + turn_id: int = 0, + turn_end: bool = False, + token_ids: list[int] | None = None, + ): + return SimpleNamespace( + request_id=request_id, + finished=False, + outputs=[ + SimpleNamespace( + text=text, + token_ids=list(token_ids or []), + multimodal_output={ + "audio": np.zeros(samples, dtype=np.float32), + "sr": 24000, + "meta.llm_output_text_utf8": np.frombuffer(segment_text.encode("utf-8"), dtype=np.uint8), + "meta": {"duplex_turn_id": turn_id, "turn_end": turn_end}, + }, + ) + ], + ) + + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + session = DuplexSession( + session_id="sid-native-repeat-text", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + sent: list[dict[str, Any]] = [] + + async def send_json(payload: dict[str, Any]) -> None: + sent.append(payload) + + async def emit(output: object) -> None: + result = {"data_plane_outputs": [output]} + for native_result in handler._minicpmo_data_plane.project( + result, + context=_data_plane_context(session), + ): + await handler._send_one_native_duplex_event(send_json, native_result, session=session) + + await emit(_stage_output("same reply", 24000, segment_text="same reply")) + await emit( + _stage_output("same replysame reply", 24000, segment_text="same reply", turn_end=True, token_ids=[151645]) + ) + + await emit(_stage_output("same replysame replysame reply", 48000, segment_text="same reply", turn_id=1)) + await emit( + _stage_output( + "same replysame replysame replysame reply", + 48000, + segment_text="same reply", + turn_id=1, + turn_end=True, + token_ids=[151645], + ) + ) + + created = [m for m in sent if m.get("type") == "response.created"] + done = [m for m in sent if m.get("type") == "response.done"] + deltas = [m for m in sent if m.get("type") == "response.output_audio.delta" and m.get("audio")] + protocol = NativeRealtimeSessionProtocol(TimedWebSocket()) # type: ignore[arg-type] + realtime_events = [event for payload in sent for event in protocol._from_duplex_event(payload)] + transcript_deltas = [ + m for m in realtime_events if m.get("type") == "response.audio_transcript.delta" and m.get("delta") + ] + assert len(created) == 2 + assert len(done) == 2 + assert len(deltas) == 2 + assert [m["text"] for m in deltas] == ["same reply", "same reply"] + assert [m["audio"] for m in deltas] == ["wav-24000", "wav-24000"] + assert [m["delta"] for m in transcript_deltas] == ["same reply", "same reply"] + assert [m["response_id"] for m in deltas] == [m["response_id"] for m in created] + assert [m["response_id"] for m in done] == [m["response_id"] for m in created] + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_continuous_speak_reuses_active_response_until_turn_end(): + request_id = "duplex-sid-native-continuous-speak-e0-stage0" + + def _stage_output( + text: str, + samples: int, + *, + turn_end: bool = False, + token_ids: list[int] | None = None, + ): + return SimpleNamespace( + request_id=request_id, + finished=turn_end, + outputs=[ + SimpleNamespace( + text=text, + token_ids=list(token_ids or []), + multimodal_output={}, + ) + ], + multimodal_output={ + "audio": np.zeros(samples, dtype=np.float32), + "sr": 24000, + "meta.duplex_turn_id": np.array([0], dtype=np.int32), + "meta.duplex_epoch": np.array([0], dtype=np.int32), + "meta.turn_end": np.array([int(turn_end)], dtype=np.int32), + "meta.tts_is_last_chunk": np.array([int(turn_end)], dtype=np.int32), + }, + ) + + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + session = DuplexSession( + session_id="sid-native-continuous-speak", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + sent: list[dict[str, Any]] = [] + + async def send_json(payload: dict[str, Any]) -> None: + sent.append(payload) + + async def emit(output: object) -> None: + result = {"data_plane_outputs": [output]} + for native_result in handler._minicpmo_data_plane.project( + result, + context=_data_plane_context(session), + ): + await handler._send_one_native_duplex_event(send_json, native_result, session=session) + + await emit(_stage_output("hello", 24000)) + await emit(_stage_output("hello again", 48000)) + + created = [m for m in sent if m.get("type") == "response.created"] + deltas = [m for m in sent if m.get("type") == "response.output_audio.delta" and m.get("audio")] + assert len(created) == 1 + assert len(deltas) == 2 + assert {m["response_id"] for m in deltas} == {created[0]["response_id"]} + assert "response.done" not in [m.get("type") for m in sent] + + await emit(_stage_output("hello again", 48000, turn_end=True, token_ids=[151645])) + + done = [m for m in sent if m.get("type") == "response.done"] + assert len(done) == 1 + assert done[0]["response_id"] == created[0]["response_id"] + assert session.active_response_id is None + assert session.turn_id == 1 + + +@pytest.mark.asyncio +async def test_continuous_response_metrics_accumulate_only_owned_model_units(): + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + session = DuplexSession( + session_id="sid-response-metrics", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + sent: list[dict[str, Any]] = [] + + async def send_json(payload: dict[str, Any]) -> None: + sent.append(payload) + + async def emit(*, text: str, audio: str, tokens: int, ttft_ms: float, itls: list[float]) -> None: + await handler._send_one_native_duplex_event( + send_json, + { + "supported": True, + "stage_role": "tts", + "is_listen": False, + "data_plane_request_id": "duplex-sid-response-metrics-e0-stage0", + "text": text, + "audio_data": audio, + "audio_format": "pcm16", + "audio_duration_ms": 100, + "end_of_turn": False, + "model_turn_id": 0, + "stage_metrics": { + "0": { + "num_tokens_out": tokens, + "stage_gen_time_ms": 80.0, + "vllm_ttft_ms": ttft_ms, + "vllm_tpot_ms": 9.0, + "vllm_itls_ms": itls, + } + }, + }, + session=session, + ) + + await emit(text="first", audio="audio-a", tokens=4, ttft_ms=50.0, itls=[10.0, 11.0]) + await emit(text="second", audio="audio-b", tokens=6, ttft_ms=70.0, itls=[12.0, 13.0, 14.0]) + + deltas = [payload for payload in sent if payload.get("type") == "response.output_audio.delta"] + assert len(deltas) == 2 + first_metrics = deltas[0]["vllm_omni"]["stage_metrics"]["0"] + second_metrics = deltas[1]["vllm_omni"]["stage_metrics"]["0"] + assert first_metrics["num_tokens_out"] == 4 + assert second_metrics["num_tokens_out"] == 10 + assert second_metrics["vllm_ttft_ms"] == 50.0 + assert second_metrics["vllm_itls_ms"] == [10.0, 11.0, 12.0, 13.0, 14.0] + + first_response_id = deltas[0]["response_id"] + session.end_response() + await emit(text="third", audio="audio-c", tokens=3, ttft_ms=90.0, itls=[15.0, 16.0]) + + third_delta = [payload for payload in sent if payload.get("type") == "response.output_audio.delta"][-1] + third_metrics = third_delta["vllm_omni"]["stage_metrics"]["0"] + assert third_delta["response_id"] != first_response_id + assert third_metrics["num_tokens_out"] == 3 + assert third_metrics["vllm_ttft_ms"] == 90.0 + assert third_metrics["vllm_itls_ms"] == [15.0, 16.0] + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_drops_old_turn_audio_while_new_response_is_active(): + request_id = "duplex-sid-native-old-turn-audio-e0-stage0" + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + session = DuplexSession( + session_id="sid-native-old-turn-audio", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + response_id = session.begin_response(turn_id=1) + session.turn_id = 2 + session.bind_request(request_id) + ws = TimedWebSocket() + + close_reason, emitted = await handler._send_one_native_duplex_event( + ws.send_json, + { + "supported": True, + "stage_role": "tts", + "is_listen": False, + "data_plane_request_id": request_id, + "text": "stale", + "audio_data": "old-audio", + "audio_format": "pcm16", + "audio_duration_ms": 100, + "end_of_turn": False, + "model_turn_id": 0, + "uses_model_runner_scheduler": True, + "runner_kv_backed": True, + "runtime_impl": "scheduler_data_plane", + }, + session=session, + expected_epoch=session.epoch, + ) + + assert close_reason is None + assert emitted is False + assert session.active_response_id == response_id + assert ws.sent == [] + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_future_turn_audio_starts_next_response(): + request_id = "duplex-sid-native-future-turn-audio-e0-stage0" + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + session = DuplexSession( + session_id="sid-native-future-turn-audio", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + first_response_id = session.begin_response(turn_id=0) + session.bind_request(request_id) + ws = TimedWebSocket() + + close_reason, emitted = await handler._send_one_native_duplex_event( + ws.send_json, + { + "supported": True, + "stage_role": "tts", + "is_listen": False, + "data_plane_request_id": request_id, + "text": "next", + "audio_data": "next-audio", + "audio_format": "pcm16", + "audio_duration_ms": 100, + "end_of_turn": False, + "model_turn_id": 1, + "uses_model_runner_scheduler": True, + "runner_kv_backed": True, + "runtime_impl": "scheduler_data_plane", + }, + session=session, + expected_epoch=session.epoch, + ) + + assert close_reason is None + assert emitted is True + sent_types = [event["type"] for event in ws.sent] + assert sent_types == [ + "response.done", + "response.created", + "response.speak", + "response.output_audio.delta", + ] + assert ws.sent[0]["response_id"] == first_response_id + assert ws.sent[1]["response_id"] != first_response_id + assert ws.sent[3]["response_id"] == ws.sent[1]["response_id"] + assert ws.sent[3]["vllm_omni"]["model_turn_id"] == 1 + assert session.active_response_turn_id == 1 + assert session.turn_id == 1 + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_drops_old_turn_listen_while_new_response_is_active(): + request_id = "duplex-sid-native-old-turn-listen-e0-stage0" + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient())) + session = DuplexSession( + session_id="sid-native-old-turn-listen", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + response_id = session.begin_response(turn_id=1) + session.turn_id = 2 + session.bind_request(request_id) + ws = TimedWebSocket() + + close_reason, emitted = await handler._send_one_native_duplex_event( + ws.send_json, + { + "supported": True, + "stage_role": "llm", + "is_listen": True, + "model_listen": True, + "data_plane_request_id": request_id, + "end_of_turn": False, + "model_turn_id": 0, + "uses_model_runner_scheduler": True, + "runner_kv_backed": True, + "runtime_impl": "scheduler_data_plane", + }, + session=session, + expected_epoch=session.epoch, + ) + + assert close_reason is None + assert emitted is False + assert session.active_response_id == response_id + assert ws.sent == [] + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_silence_continuation_requires_active_request(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + session = DuplexSession( + session_id="sid-native-stale-continuation", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.begin_response() + session.bind_request("duplex-sid-native-stale-continuation-stage0") + session.mark_audio_sent(100) + sent: list[dict[str, Any]] = [] + + async def send_json(data: dict[str, Any]) -> None: + sent.append(data) + + _install_direct_silence_scheduler(handler, session) + + await handler._maybe_continue_native_response(send_json, session=session, expected_epoch=session.epoch) + session.clear_request() + await asyncio.sleep(0.01) + + assert engine.appended == [] + + +@pytest.mark.asyncio +async def test_minicpmo_auto_response_segment_complete_continues_until_model_listen(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + session = DuplexSession( + session_id="sid-native-segment-complete", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.capabilities = DuplexCapabilities.minicpmo45_native() + response_id = session.begin_response() + request_id = "duplex-sid-native-segment-complete-stage0" + session.bind_request(request_id) + session.mark_audio_sent(1000) + _install_direct_silence_scheduler(handler, session) + sent: list[dict[str, Any]] = [] + + async def send_json(data: dict[str, Any]) -> None: + sent.append(data) + + close_reason, emitted = await handler._send_one_native_duplex_event( + send_json, + { + "supported": True, + "stage_role": "llm", + "is_listen": True, + "model_listen": False, + "listen_source": "auto_response_segment_complete", + "reason": "auto_response_segment_complete", + "data_plane_request_id": request_id, + "end_of_turn": False, + "uses_model_runner_scheduler": True, + "runner_kv_backed": True, + "runtime_impl": "scheduler_data_plane", + "owned_runtime": False, + }, + session=session, + expected_epoch=session.epoch, + ) + await asyncio.sleep(0.01) + + assert close_reason is None + assert emitted is False + assert session.active_response_id == response_id + assert session.active_request_id == request_id + assert not handler._minicpmo_data_plane.is_terminal(request_id) + assert sent == [] + assert len(engine.appended) == 1 + _, mode, payload, final = engine.appended[0] + assert mode == "append_audio_chunk" + assert payload["type"] == "audio" + assert payload.get("force_listen") is not True + assert final is False + + +@pytest.mark.parametrize( + ("steps", "expected_deltas"), + [ + ( + [("你好呀", 1), ("你好呀", 1), ("你好呀", 2)], + ["你好呀", "", "你好呀"], + ), + ( + [("你好呀", 1), ("你好呀", 2)], + ["你好呀", "你好呀"], + ), + ], + ids=["turn-scoped", "exact-repeated-turn-text"], +) +def test_minicpmo_data_plane_text_cursor( + steps: list[tuple[str, int]], + expected_deltas: list[str], +): + data_plane = _test_data_plane() + request_id = "duplex-sid-native-e0-stage0" + + deltas = [data_plane.segment_text_delta(request_id, text, turn_id=turn_id) for text, turn_id in steps] + + assert deltas == expected_deltas + + +def test_minicpmo_data_plane_text_cursor_does_not_clip_prior_turn_overlap(): + data_plane = _test_data_plane() + request_id = "duplex-sid-native-e0-stage0" + + assert data_plane.segment_text_delta(request_id, "你好呀。你有什么想聊的吗?", turn_id=1) + assert ( + data_plane.segment_text_delta(request_id, "你有什么想聊的吗?你好呀。", turn_id=2) + == "你有什么想聊的吗?你好呀。" + ) + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_cancel_interrupts_background_data_plane_stream(): + control_result = { + "operation": "append", + "session_id": "sid-native-cancel-stream", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [ + { + "stage_id": 0, + "replica_id": 0, + "result": { + "supported": True, + "implementation_level": "model_native_duplex", + "data_plane_append": True, + "request_id": "duplex-sid-native-cancel-stream-e0-stage0-s1", + "response_stage_id": 1, + }, + } + ], + } + engine = FakeEngineClient( + append_result=control_result, + collect_delay_s=0.2, + collect_outputs=[ + [ + SimpleNamespace( + request_id="duplex-sid-native-cancel-stream-e0-stage0-s1", + finished=True, + outputs=[ + SimpleNamespace( + text="", + multimodal_output={"audio": np.zeros(10, dtype=np.float32), "sr": 24000}, + ) + ], + ) + ] + ], + ) + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-native-cancel-stream")) + ws.put({"type": "input_audio_buffer.append", "audio": "AAAA", "format": "pcm_f32le"}) + ws.put({"type": "input.cancel"}) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert "audio.cancelled" in ws.sent_types() + assert "response.output_audio.delta" not in ws.sent_types() + assert engine.signals == [("sid-native-cancel-stream", "barge_in")] + assert engine.signal_fences == [DuplexFence("sid-native-cancel-stream")] + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_session_close_discards_partial_pcm_tail(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-native-short-chunks")) + chunk = _pcm_f32_b64(3200) + for _ in range(6): + ws.put( + { + "type": "input_audio_buffer.append", + "audio": chunk, + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + assert len(engine.appended) == 1 + _, mode, payload, final = engine.appended[0] + assert mode == "append_audio_chunk" + assert final is False + assert isinstance(payload, dict) + assert len(base64.b64decode(payload["audio"])) == 16000 * 4 + assert "duplex_num_input_tokens" not in payload + + +@pytest.mark.asyncio +async def test_minicpmo_native_duplex_idle_timeout_closes_runtime_with_timeout_reason(): + engine = FakeEngineClient() + chat_service = FakeChatService(engine) + handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=0.1) + ws = TimedWebSocket() + ws.put(_native_session_create("sid-native-timeout")) + + await handler.handle_session(ws) + + assert ws.sent_types() == ["session.created", "session.closed"] + assert ws.sent[-1]["reason"] == "timeout" + assert engine.closed == [("sid-native-timeout", "timeout")] diff --git a/tests/entrypoints/openai_api/test_image_server.py b/tests/entrypoints/openai_api/test_image_server.py index 5a7ef7bf0..2d6c0299e 100644 --- a/tests/entrypoints/openai_api/test_image_server.py +++ b/tests/entrypoints/openai_api/test_image_server.py @@ -1301,7 +1301,11 @@ def test_generate_images_rejects_model_mismatch(test_client): assert "model mismatch" in response.json()["detail"].lower() -def test_image_file_response_format_multiple(test_client): +@pytest.mark.parametrize( + ("output_format", "expected_pil_format"), + [("png", "PNG"), ("jpeg", "JPEG"), ("webp", "WEBP")], +) +def test_image_file_response_format_multiple(test_client, output_format, expected_pil_format): """Test response_format=file with n>1 returns ZIP archive""" response = test_client.post( "/v1/images/generations", @@ -1309,6 +1313,7 @@ def test_image_file_response_format_multiple(test_client): "prompt": "a dog", "n": 3, "response_format": "file", + "output_format": output_format, }, ) @@ -1317,23 +1322,32 @@ def test_image_file_response_format_multiple(test_client): assert "attachment" in response.headers.get("content-disposition", "") assert ".zip" in response.headers.get("content-disposition", "") - # Verify it's a valid ZIP with 3 PNG files + # Verify it's a valid ZIP with 3 correctly labelled image files import zipfile zip_buffer = io.BytesIO(response.content) with zipfile.ZipFile(zip_buffer, "r") as zf: files = zf.namelist() assert len(files) == 3 - assert all(f.endswith(".png") for f in files) + assert all(f.endswith(f".{output_format}") for f in files) - # Verify each file is a valid PNG + # Verify each file's bytes match its advertised format for filename in files: img_bytes = zf.read(filename) img = Image.open(io.BytesIO(img_bytes)) - assert img.format == "PNG" + assert img.format == expected_pil_format -def test_image_file_response_format_single(test_client): +@pytest.mark.parametrize( + ("output_format", "expected_media_type", "expected_pil_format"), + [ + ("png", "image/png", "PNG"), + ("jpg", "image/jpeg", "JPEG"), + ("jpeg", "image/jpeg", "JPEG"), + ("webp", "image/webp", "WEBP"), + ], +) +def test_image_file_response_format_single(test_client, output_format, expected_media_type, expected_pil_format): """Test response_format=file with n=1 returns a single image file.""" response = test_client.post( "/v1/images/generations", @@ -1341,16 +1355,61 @@ def test_image_file_response_format_single(test_client): "prompt": "a dog", "n": 1, "response_format": "file", + "output_format": output_format, }, ) assert response.status_code == 200 - assert response.headers["content-type"] == "image/png" + assert response.headers["content-type"] == expected_media_type assert "attachment" in response.headers.get("content-disposition", "") - assert ".png" in response.headers.get("content-disposition", "") + assert f".{output_format}" in response.headers.get("content-disposition", "") img = Image.open(io.BytesIO(response.content)) - assert img.format == "PNG" + assert img.format == expected_pil_format + + +@pytest.mark.parametrize( + ("output_format", "image_count", "expected_media_type", "expected_pil_format"), + [ + ("jpeg", 1, "image/jpeg", "JPEG"), + ("webp", 2, "application/zip", "WEBP"), + ], +) +def test_multistage_image_file_response_uses_requested_format( + async_omni_test_client, + output_format, + image_count, + expected_media_type, + expected_pil_format, +): + engine = async_omni_test_client.app.state.engine_client + engine._images = [Image.new("RGB", (16, 16), color="green") for _ in range(image_count)] + + response = async_omni_test_client.post( + "/v1/images/generations", + json={ + "prompt": "a dog", + "n": image_count, + "response_format": "file", + "output_format": output_format, + }, + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == expected_media_type + if image_count == 1: + assert f".{output_format}" in response.headers["content-disposition"] + assert Image.open(io.BytesIO(response.content)).format == expected_pil_format + return + + import zipfile + + with zipfile.ZipFile(io.BytesIO(response.content), "r") as archive: + filenames = archive.namelist() + assert len(filenames) == image_count + assert all(filename.endswith(f".{output_format}") for filename in filenames) + for filename in filenames: + assert Image.open(io.BytesIO(archive.read(filename))).format == expected_pil_format def make_test_image_bytes(size=(64, 64)) -> bytes: @@ -1562,6 +1621,7 @@ def test_image_edit_parameter_pass(async_omni_test_client): "output_format": "jpeg", "num_inference_steps": 20, "guidance_scale": 8.0, + "guidance_scale_2": 2.0, "seed": 1234, "negative_prompt": "negative", "n": 2, @@ -1576,6 +1636,7 @@ def test_image_edit_parameter_pass(async_omni_test_client): assert captured_prompt["negative_prompt"] == "negative" assert captured_sampling_params.num_inference_steps == 20 assert captured_sampling_params.guidance_scale == 8.0 + assert captured_sampling_params.guidance_scale_2 == 2.0 assert captured_sampling_params.seed == 1234 assert captured_sampling_params.num_outputs_per_prompt == 2 assert captured_sampling_params.width == 16 diff --git a/tests/entrypoints/openai_api/test_modality_validation.py b/tests/entrypoints/openai_api/test_modality_validation.py new file mode 100644 index 000000000..fde0c0ca1 --- /dev/null +++ b/tests/entrypoints/openai_api/test_modality_validation.py @@ -0,0 +1,146 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Regression tests for output modality validation in chat completions. + +Covers: +- #4719: unsupported modality must return an error, not empty choices +- Single-stage diffusion text-output path must remain accessible +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest +from vllm.entrypoints.openai.engine.protocol import ErrorResponse + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _make_serving_chat(output_modalities: list[str], stage_configs=None): + """Build a minimal OmniOpenAIServingChat with mocked internals.""" + from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat + + instance = object.__new__(OmniOpenAIServingChat) + instance.engine_client = SimpleNamespace( + output_modalities=output_modalities, + errored=False, + stage_configs=stage_configs or [], + ) + instance._diffusion_mode = False + instance.models = MagicMock() + instance.models.model_name.return_value = "test-model" + instance.enable_prompt_tokens_details = False + + renderer = MagicMock() + renderer.get_tokenizer.return_value = MagicMock() + instance.renderer = renderer + instance.online_renderer = MagicMock() + instance.online_renderer.validate_chat_template.return_value = None + + instance.parser_cls = None + instance.use_harmony = False + instance.trust_request_chat_template = True + instance.chat_template = None + instance.chat_template_content_format = "auto" + instance.default_chat_template_kwargs = {} + instance.enable_auto_tools = False + instance.exclude_tools_when_tool_choice_none = False + instance._check_model = AsyncMock(return_value=None) + return instance + + +def _make_request(modalities): + """Build a ChatCompletionRequest with the given modalities.""" + req = ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + ) + req.modalities = modalities + return req + + +@pytest.mark.asyncio +async def test_unsupported_modality_returns_error(): + """#4719: requesting 'video' on a text/audio model must return an error.""" + serving = _make_serving_chat(["text", "audio"]) + request = _make_request(["video"]) + + with patch.object(serving, "_preprocess_chat", new_callable=AsyncMock, return_value=([], [{}])): + result = await serving._create_chat_completion(request) + + assert isinstance(result, ErrorResponse) + assert "video" in result.error.message + assert "Unsupported" in result.error.message + + +@pytest.mark.asyncio +async def test_supported_modality_passes_validation(): + """Requesting a supported modality must not trigger an error.""" + serving = _make_serving_chat(["text", "audio"]) + request = _make_request(["text"]) + + with patch.object(serving, "_preprocess_chat", new_callable=AsyncMock, return_value=([], [{}])): + try: + result = await serving._create_chat_completion(request) + except (AttributeError, TypeError): + return + + assert not isinstance(result, ErrorResponse) + + +@pytest.mark.asyncio +async def test_invalid_modalities_type_returns_error(): + """Modalities must be a list of strings.""" + serving = _make_serving_chat(["text", "audio"]) + request = _make_request("audio") # bare string, not a list + + with patch.object(serving, "_preprocess_chat", new_callable=AsyncMock, return_value=([], [{}])): + result = await serving._create_chat_completion(request) + + assert isinstance(result, ErrorResponse) + assert "list of strings" in result.error.message + + +@pytest.mark.asyncio +async def test_modalities_with_non_string_element_returns_error(): + """Modalities list with non-string elements must be rejected.""" + serving = _make_serving_chat(["text", "audio"]) + request = _make_request(["text", 123]) + + with patch.object(serving, "_preprocess_chat", new_callable=AsyncMock, return_value=([], [{}])): + result = await serving._create_chat_completion(request) + + assert isinstance(result, ErrorResponse) + assert "list of strings" in result.error.message + + +@pytest.mark.asyncio +async def test_single_stage_diffusion_allows_text_modality(): + """Single-stage diffusion engines that advertise 'image' must also accept 'text'.""" + stage_configs = [SimpleNamespace(stage_type="diffusion", is_comprehension=False)] + serving = _make_serving_chat(["image"], stage_configs=stage_configs) + request = _make_request(["text"]) + + with patch.object(serving, "_preprocess_chat", new_callable=AsyncMock, return_value=([], [{}])): + try: + result = await serving._create_chat_completion(request) + except (AttributeError, TypeError): + return + + assert not isinstance(result, ErrorResponse) + + +@pytest.mark.asyncio +async def test_single_stage_diffusion_rejects_unsupported_modality(): + """Single-stage diffusion must still reject truly unsupported modalities.""" + stage_configs = [SimpleNamespace(stage_type="diffusion", is_comprehension=False)] + serving = _make_serving_chat(["image"], stage_configs=stage_configs) + request = _make_request(["video"]) + + with patch.object(serving, "_preprocess_chat", new_callable=AsyncMock, return_value=([], [{}])): + result = await serving._create_chat_completion(request) + + assert isinstance(result, ErrorResponse) + assert "video" in result.error.message diff --git a/tests/entrypoints/openai_api/test_omni_sleep_wakeup.py b/tests/entrypoints/openai_api/test_omni_sleep_wakeup.py index cf7d6e234..c47eb8da6 100644 --- a/tests/entrypoints/openai_api/test_omni_sleep_wakeup.py +++ b/tests/entrypoints/openai_api/test_omni_sleep_wakeup.py @@ -175,6 +175,26 @@ def test_wakeup_engine_not_support(sleep_incapable_engine): assert "wake_up" in response.json()["detail"] +def test_wakeup_route_propagates_not_implemented_error(sleep_capable_engine, mocker): + """``/v1/omni/wakeup`` must not swallow ``NotImplementedError`` from the engine. + + Route exception-propagation only: the mock authors the exception on purpose. + The real level-2 discarded-weight path and live HTTP 501 mapping are covered by + ``test_wakeup_after_level2_sleep_fails``. + """ + sleep_capable_engine.wake_up = mocker.AsyncMock(side_effect=NotImplementedError("engine wake_up refused")) + app = _make_app(sleep_capable_engine) + app.state.sleeping_stages = {0} + # Re-raise in-process; default TestClient would wrap this as a 500 response. + client = TestClient(app, raise_server_exceptions=True) + + with pytest.raises(NotImplementedError, match="engine wake_up refused"): + client.post("/v1/omni/wakeup", json={"stage_ids": [0]}) + + # Failed wake must not clear the sleeping set. + assert app.state.sleeping_stages == {0} + + def test_wakeup_removes_only_requested_stages(sleep_capable_engine): app = _make_app(sleep_capable_engine) app.state.sleeping_stages = {0, 1, 2} diff --git a/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py b/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py index 50f8eef91..325daabdd 100644 --- a/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py +++ b/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py @@ -201,6 +201,7 @@ def _assert_realtime_smoke(result: dict) -> None: def _assert_realtime_accuracy( result: dict, whisper_model_size: str = "large-v3", + threshold: float = 0.8, ) -> None: """Assert that whisper transcription of audio output matches model text. @@ -214,6 +215,12 @@ def _assert_realtime_accuracy( variability even though audio generation was correct. large-v3 transcribes these clips reliably, so a failure here now points at the model, not the ASR grader. + threshold: Minimum cosine similarity (with length penalty) required to + pass. Default 0.8. Do not lower per-callsite without data: + at 0.35 the assertion no longer detects real audio + regressions. If a variant genuinely needs a different gate + (e.g. whisper partial transcripts under async_chunk), propose + it in its own PR with measurements. """ final_text = (result["transcription_text"] or "").strip() assert final_text, "Expected non-empty transcription (model text stream)" @@ -223,8 +230,9 @@ def _assert_realtime_accuracy( assert whisper_text, "Whisper returned empty string for synthesized output audio" sim = cosine_similarity_text(whisper_text.lower(), final_text.lower()) - assert sim > 0.8, ( - f"Output audio transcript should match model text (sim={sim:.3f}): " + assert sim > threshold, ( + f"Output audio transcript should match model text (sim={sim:.3f}, " + f"threshold={threshold}): " f"whisper={whisper_text!r}, model_text={final_text!r}" ) diff --git a/tests/entrypoints/openai_api/test_qwen3_tts_websocket.py b/tests/entrypoints/openai_api/test_qwen3_tts_websocket.py index 058b20331..132d43858 100644 --- a/tests/entrypoints/openai_api/test_qwen3_tts_websocket.py +++ b/tests/entrypoints/openai_api/test_qwen3_tts_websocket.py @@ -33,26 +33,63 @@ tts_ws_server_params = [ ] -async def _run_ws_session(host: str, port: int, model: str) -> dict: - uri = f"ws://{host}:{port}/v1/audio/speech/stream" +def _session_config(model: str) -> str: + return json.dumps( + { + "type": "session.config", + "model": model, + "voice": "vivian", + "language": "English", + "response_format": "pcm", + "stream_audio": True, + } + ) + + +async def _collect_utterance(ws) -> dict: + """Read frames until session.done closes out one flushed utterance.""" starts: list[dict] = [] dones: list[dict] = [] chunk_lengths: dict[int, list[int]] = {} session_done: dict | None = None + while True: + message = await asyncio.wait_for(ws.recv(), timeout=180) + if isinstance(message, bytes): + if not starts: + raise AssertionError("Received audio bytes before audio.start") + sentence_index = starts[-1]["sentence_index"] + chunk_lengths.setdefault(sentence_index, []).append(len(message)) + continue + + payload = json.loads(message) + msg_type = payload.get("type") + if msg_type == "audio.start": + starts.append(payload) + chunk_lengths.setdefault(payload["sentence_index"], []) + elif msg_type == "audio.done": + dones.append(payload) + elif msg_type == "session.done": + session_done = payload + break + elif msg_type == "error": + raise AssertionError(f"WebSocket error: {payload['message']}") + else: + raise AssertionError(f"Unexpected WebSocket message: {payload}") + + return { + "starts": starts, + "dones": dones, + "chunk_lengths": chunk_lengths, + "session_done": session_done, + } + + +async def _run_ws_session(host: str, port: int, model: str) -> dict: + uri = f"ws://{host}:{port}/v1/audio/speech/stream" + async with websockets.connect(uri, max_size=None) as ws: - await ws.send( - json.dumps( - { - "type": "session.config", - "model": model, - "voice": "vivian", - "language": "English", - "response_format": "pcm", - "stream_audio": True, - } - ) - ) + await ws.send(_session_config(model)) await ws.send( json.dumps( { @@ -67,42 +104,29 @@ async def _run_ws_session(host: str, port: int, model: str) -> dict: ) await ws.send(json.dumps({"type": "input.done"})) - while True: - message = await asyncio.wait_for(ws.recv(), timeout=180) - if isinstance(message, bytes): - if not starts: - raise AssertionError("Received audio bytes before audio.start") - sentence_index = starts[-1]["sentence_index"] - chunk_lengths.setdefault(sentence_index, []).append(len(message)) - continue + return await _collect_utterance(ws) - payload = json.loads(message) - msg_type = payload.get("type") - if msg_type == "audio.start": - starts.append(payload) - chunk_lengths.setdefault(payload["sentence_index"], []) - elif msg_type == "audio.done": - dones.append(payload) - elif msg_type == "session.done": - session_done = payload - break - elif msg_type == "error": - raise AssertionError(f"WebSocket error: {payload['message']}") - else: - raise AssertionError(f"Unexpected WebSocket message: {payload}") - return { - "starts": starts, - "dones": dones, - "chunk_lengths": chunk_lengths, - "session_done": session_done, - } +async def _run_ws_reused_connection(host: str, port: int, model: str, texts: list[str]) -> list[dict]: + """Synthesize several utterances over a single connection.""" + uri = f"ws://{host}:{port}/v1/audio/speech/stream" + results: list[dict] = [] + + async with websockets.connect(uri, max_size=None) as ws: + await ws.send(_session_config(model)) + for text in texts: + await ws.send(json.dumps({"type": "input.text", "text": text})) + await ws.send(json.dumps({"type": "input.done"})) + results.append(await _collect_utterance(ws)) + await ws.send(json.dumps({"type": "session.close"})) + + return results class TestQwen3TTSWebSocket: @pytest.mark.advanced_model @pytest.mark.tts - @hardware_test(res={"cuda": "L4"}, num_cards=4) + @hardware_test(res={"cuda": "L4"}, num_cards=1) @pytest.mark.parametrize("omni_server", tts_ws_server_params, indirect=True) def test_streaming_pcm_output(self, omni_server) -> None: result = asyncio.run(_run_ws_session(omni_server.host, omni_server.port, omni_server.model)) @@ -113,6 +137,7 @@ class TestQwen3TTSWebSocket: session_done = result["session_done"] assert session_done is not None + assert session_done["utterance_index"] == 0 assert session_done["total_sentences"] == 1 assert len(starts) == 1 assert len(dones) == 1 @@ -131,3 +156,32 @@ class TestQwen3TTSWebSocket: assert total_bytes > 0 assert chunk_lengths[sentence_index], f"Expected binary PCM frames for sentence {sentence_index}" assert sum(chunk_lengths[sentence_index]) == total_bytes + + @pytest.mark.advanced_model + @pytest.mark.tts + @hardware_test(res={"cuda": "L4"}, num_cards=1) + @pytest.mark.parametrize("omni_server", tts_ws_server_params, indirect=True) + def test_input_done_flushes_without_closing_connection(self, omni_server) -> None: + # input.done must flush the buffer and leave the connection usable: + # the second utterance is synthesized without a second handshake. + texts = [ + "This is the first utterance sent over the websocket connection.", + "This is the second utterance, reusing the very same connection.", + ] + results = asyncio.run(_run_ws_reused_connection(omni_server.host, omni_server.port, omni_server.model, texts)) + + assert len(results) == len(texts) + for expected_index, result in enumerate(results): + assert result["session_done"] == { + "type": "session.done", + "utterance_index": expected_index, + "total_sentences": 1, + } + assert len(result["starts"]) == 1 + assert len(result["dones"]) == 1 + # utterance_index counts the flushes of the connection, while + # sentence_index stays within the flush it belongs to. + assert result["starts"][0]["utterance_index"] == expected_index + assert result["starts"][0]["sentence_index"] == 0 + assert result["dones"][0]["error"] is False + assert result["dones"][0]["total_bytes"] > 0 diff --git a/tests/entrypoints/openai_api/test_serving_chat_minicpmo45_stream_output_kinds.py b/tests/entrypoints/openai_api/test_serving_chat_minicpmo45_stream_output_kinds.py new file mode 100644 index 000000000..1a3a4f22e --- /dev/null +++ b/tests/entrypoints/openai_api/test_serving_chat_minicpmo45_stream_output_kinds.py @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Regression tests for MiniCPM-o 4.5 audio-streaming output-kind coercion. + +Chat ``stream=true`` coerces every AR stage to DELTA. For MiniCPM-o 4.5 that +hands ``llm2tts`` one thinker token per step while the forwarded hidden states +still span prompt + generated, so the talker is conditioned on a broadcast +mismatch (issue #5298). The thinker must stay FINAL_ONLY; only the talker +streams. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from vllm.sampling_params import RequestOutputKind, SamplingParams + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +MINICPMO45_ARCH = "MiniCPMO45OmniForConditionalGeneration" + + +@pytest.fixture +def serving_chat(): + from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat + + return object.__new__(OmniOpenAIServingChat) + + +def _stage(model_arch: str, model_stage: str) -> SimpleNamespace: + # SimpleNamespace, not MagicMock: _stage_get prefers .get() when present, + # and a MagicMock satisfies hasattr(obj, "get") and returns a mock. + return SimpleNamespace(engine_args=SimpleNamespace(model_arch=model_arch, model_stage=model_stage)) + + +def _minicpmo45_stages(): + return [_stage(MINICPMO45_ARCH, "llm"), _stage(MINICPMO45_ARCH, "tts")] + + +def _delta_params(n: int = 2): + return [SamplingParams(output_kind=RequestOutputKind.DELTA) for _ in range(n)] + + +def test_thinker_forced_final_only_talker_stays_delta(serving_chat): + serving_chat.engine_client = SimpleNamespace(stage_configs=_minicpmo45_stages()) + + out = serving_chat._fix_minicpmo45_audio_stream_output_kinds(_delta_params(), ["text", "audio"]) + + assert out[0].output_kind is RequestOutputKind.FINAL_ONLY + assert out[1].output_kind is RequestOutputKind.DELTA + + +@pytest.mark.parametrize("modalities", [None, [], ["text"]]) +def test_non_audio_request_is_untouched(serving_chat, modalities): + serving_chat.engine_client = SimpleNamespace(stage_configs=_minicpmo45_stages()) + + out = serving_chat._fix_minicpmo45_audio_stream_output_kinds(_delta_params(), modalities) + + assert [p.output_kind for p in out] == [RequestOutputKind.DELTA] * 2 + + +def test_other_architectures_are_untouched(serving_chat): + stages = [_stage("SomeOtherOmniForConditionalGeneration", "llm")] + serving_chat.engine_client = SimpleNamespace(stage_configs=stages) + + out = serving_chat._fix_minicpmo45_audio_stream_output_kinds(_delta_params(1), ["text", "audio"]) + + assert out[0].output_kind is RequestOutputKind.DELTA + + +def test_shorter_sampling_params_list_does_not_raise(serving_chat): + serving_chat.engine_client = SimpleNamespace(stage_configs=_minicpmo45_stages()) + + out = serving_chat._fix_minicpmo45_audio_stream_output_kinds(_delta_params(1), ["text", "audio"]) + + assert len(out) == 1 + assert out[0].output_kind is RequestOutputKind.FINAL_ONLY diff --git a/tests/entrypoints/openai_api/test_serving_chat_sampling_params.py b/tests/entrypoints/openai_api/test_serving_chat_sampling_params.py index a6c7baffe..de3158d43 100644 --- a/tests/entrypoints/openai_api/test_serving_chat_sampling_params.py +++ b/tests/entrypoints/openai_api/test_serving_chat_sampling_params.py @@ -6,10 +6,12 @@ Tests that standard OpenAI API parameters (max_tokens, temperature, etc.) are correctly applied to the comprehension stage while preserving YAML defaults. """ +import asyncio from types import SimpleNamespace import pytest from pytest_mock import MockerFixture +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.sampling_params import SamplingParams pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -86,6 +88,233 @@ def serving_chat(mock_engine_client): return instance +def test_serving_boundary_normalizes_declared_root_and_nested_extras(serving_chat): + serving_chat._diffusion_extra_body_params = frozenset({"cfg_text_scale", "negative_prompt"}) + request = ChatCompletionRequest( + model="test", + messages=[], + modalities=["image"], + num_inference_steps=7, + size="768x512", + negative_prompt="avoid blur", + lora={"name": "adapter"}, + cfg_text_scale=7.0, + extra_body={"extra_args": {"sample_solver": "euler"}}, + ) + + normalized_extra_args, diffusion_request_args = serving_chat._normalize_diffusion_request_args(request) + + assert normalized_extra_args == { + "cfg_text_scale": 7.0, + "sample_solver": "euler", + } + assert diffusion_request_args["cfg_text_scale"] == 7.0 + assert diffusion_request_args["num_inference_steps"] == 7 + assert diffusion_request_args["size"] == "768x512" + assert diffusion_request_args["negative_prompt"] == "avoid blur" + assert diffusion_request_args["lora"] == {"name": "adapter"} + assert diffusion_request_args["modalities"] == ["image"] + + +def test_unknown_root_extra_does_not_claim_canonical_extra(serving_chat): + serving_chat._diffusion_extra_body_params = frozenset() + request = ChatCompletionRequest( + model="test", + messages=[], + pipeline_option="ignored-root-value", + extra_args={"pipeline_option": "canonical-value"}, + ) + + normalized_extra_args, diffusion_request_args = serving_chat._normalize_diffusion_request_args(request) + + assert normalized_extra_args == { + "pipeline_option": "canonical-value", + } + assert "pipeline_option" not in diffusion_request_args + + +def test_unregistered_cfg_scale_aliases_common_true_cfg_scale(serving_chat): + serving_chat._diffusion_extra_body_params = frozenset() + request = ChatCompletionRequest( + model="test", + messages=[], + cfg_scale=7.0, + ) + + normalized_extra_args, diffusion_request_args = serving_chat._normalize_diffusion_request_args(request) + + assert normalized_extra_args == {} + assert diffusion_request_args == {"true_cfg_scale": 7.0} + + +@pytest.mark.parametrize( + ("registered", "request_kwargs"), + [ + ({"cfg_scale"}, {"cfg_scale": 7.0, "extra_args": {"cfg_scale": 8.0}}), + (set(), {"cfg_scale": 7.0, "true_cfg_scale": 2.0}), + ], + ids=["registered-model-extra", "unregistered-common-alias"], +) +def test_cfg_scale_owner_conflicts_are_rejected(serving_chat, registered, request_kwargs): + serving_chat._diffusion_extra_body_params = frozenset(registered) + request = ChatCompletionRequest(model="test", messages=[], **request_kwargs) + + with pytest.raises(ValueError, match="provided more than once"): + serving_chat._normalize_diffusion_request_args(request) + + +@pytest.mark.parametrize("diffusion_mode", [True, False], ids=["pure", "mixed"]) +def test_duplicate_extras_return_the_same_bad_request_before_dispatch( + serving_chat, + mocker: MockerFixture, + diffusion_mode: bool, +): + serving_chat._diffusion_mode = diffusion_mode + serving_chat._diffusion_extra_body_params = frozenset({"sample_solver"}) + serving_chat.engine_client.stage_configs = [SimpleNamespace(stage_type="diffusion")] + check_model = mocker.patch.object(serving_chat, "_check_model", new=mocker.AsyncMock()) + pure_dispatch = mocker.patch.object( + serving_chat, + "_create_diffusion_chat_completion", + new=mocker.AsyncMock(), + ) + request = ChatCompletionRequest( + model="test", + messages=[], + sample_solver="euler", + extra_args={"sample_solver": "ddim"}, + ) + + response = asyncio.run(serving_chat._create_chat_completion(request)) + + assert response.error.code == 400 + assert response.error.message == ( + 'Diffusion request parameters were provided more than once: "sample_solver": ' + "request.sample_solver, request.extra_args.sample_solver." + ) + check_model.assert_not_awaited() + pure_dispatch.assert_not_awaited() + + +def test_pure_consumer_preserves_defaults_and_separate_cfg_owners(serving_chat, mocker: MockerFixture): + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + captured: dict[str, object] = {} + + async def generate(**kwargs): + captured.update(kwargs) + if False: + yield None + + serving_chat._diffusion_model_name = "test" + serving_chat._diffusion_engine = SimpleNamespace( + stage_configs=[SimpleNamespace(stage_type="diffusion")], + default_sampling_params_list=[ + OmniDiffusionSamplingParams(extra_args={"solver": "euler", "stage_default": True}), + ], + generate=generate, + ) + serving_chat._diffusion_mode = True + serving_chat._diffusion_extra_body_params = frozenset({"cfg_scale"}) + serving_chat._extract_diffusion_prompt_and_media = mocker.Mock(return_value=("prompt", [], [], [])) + request = ChatCompletionRequest( + model="test", + messages=[], + modalities=["image"], + num_inference_steps=7, + size="768x512", + negative_prompt="avoid blur", + cfg_scale=7.0, + true_cfg_scale=2.0, + extra_body={"extra_args": {"solver": "ddim"}}, + ) + + response = asyncio.run(serving_chat._create_chat_completion(request)) + + (sampling_params,) = captured["sampling_params_list"] + assert sampling_params.extra_args == { + "cfg_scale": 7.0, + "solver": "ddim", + "stage_default": True, + } + assert sampling_params.true_cfg_scale == 2.0 + assert sampling_params.num_inference_steps == 7 + assert (sampling_params.height, sampling_params.width) == (512, 768) + assert captured["prompt"]["negative_prompt"] == "avoid blur" + assert response.error.message == "No output generated from AsyncOmni" + + +def test_mixed_consumer_keeps_root_common_args_with_nested_extras(serving_chat, mocker: MockerFixture): + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + serving_chat.engine_client.stage_configs = [ + SimpleNamespace(stage_type="llm", is_comprehension=True), + SimpleNamespace(stage_type="diffusion", is_comprehension=False), + ] + serving_chat.engine_client.default_sampling_params_list = [ + SamplingParams(), + OmniDiffusionSamplingParams(), + ] + serving_chat.engine_client.output_modalities = ["image"] + serving_chat.engine_client.errored = False + + captured: dict[str, object] = {} + + async def results(): + if False: + yield None + + def generate(**kwargs): + captured.update(kwargs) + return results() + + serving_chat.engine_client.generate = generate + serving_chat.__dict__.update( + _diffusion_mode=False, + _diffusion_extra_body_params=frozenset(), + models=SimpleNamespace(model_name=lambda _: "test"), + renderer=SimpleNamespace(get_tokenizer=lambda: object()), + online_renderer=SimpleNamespace(validate_chat_template=lambda **_: None), + parser_cls=None, + use_harmony=False, + enable_auto_tools=False, + exclude_tools_when_tool_choice_none=False, + trust_request_chat_template=True, + chat_template=None, + chat_template_content_format="auto", + ) + mocker.patch.multiple( + serving_chat, + _check_model=mocker.AsyncMock(return_value=None), + _maybe_get_adapters=mocker.Mock(return_value=None), + _effective_chat_template_kwargs=mocker.Mock(return_value={}), + _preprocess_chat=mocker.AsyncMock(return_value=([], [{"prompt": "raw"}])), + _base_request_id=mocker.Mock(return_value="test"), + _extract_diffusion_prompt_and_images_from_messages=mocker.Mock(return_value=("prompt", [])), + _log_inputs=mocker.Mock(), + chat_completion_full_generator=mocker.AsyncMock(return_value="done"), + ) + request = ChatCompletionRequest( + model="test", + messages=[{"role": "user", "content": "prompt"}], + modalities=["image"], + num_inference_steps=7, + size="768x512", + negative_prompt="avoid blur", + extra_body={"extra_args": {"solver": "euler"}}, + ) + + assert asyncio.run(serving_chat._create_chat_completion(request)) == "done" + + sampling_params_list = captured["sampling_params_list"] + assert sampling_params_list[0].extra_args == {"target_h": 512, "target_w": 768} + diffusion_params = sampling_params_list[1] + assert diffusion_params.num_inference_steps == 7 + assert (diffusion_params.height, diffusion_params.width) == (512, 768) + assert diffusion_params.extra_args == {"solver": "euler"} + assert captured["prompt"]["negative_prompt"] == "avoid blur" + + @pytest.fixture def mock_request(mocker: MockerFixture): """Create a mock request with all OpenAI sampling params set to None.""" @@ -616,124 +845,3 @@ class TestResolveHeightWidth: h, w = OmniOpenAIServingChat._resolve_height_width_from_extra_body({"size": "invalid"}) assert h is None assert w is None - - -# ============================================================================= -# Tests for _apply_request_overrides with GLM-Image (target_h/w injection) -# ============================================================================= - - -class TestApplyRequestOverridesGLMImage: - """Test target_h/w injection for GLM-Image AR stage. - - max_tokens is NOT computed dynamically — it comes from the deploy YAML - default (e.g. 4353). _apply_request_overrides only injects target_h/w - into extra_args so the model can build M-RoPE position grids. - """ - - @pytest.fixture - def glm_serving_chat(self, mock_engine_client, mocker: MockerFixture): - from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat - - instance = object.__new__(OmniOpenAIServingChat) - instance.engine_client = mock_engine_client - instance._extract_diffusion_prompt_and_images_from_messages = mocker.MagicMock(return_value=("a cat", [])) - return instance - - @pytest.fixture - def glm_request(self, mocker: MockerFixture): - req = mocker.MagicMock() - req.temperature = None - req.top_p = None - req.top_k = None - req.max_tokens = None - req.min_tokens = None - req.seed = None - req.ignore_eos = None - req.stop = None - req.stop_token_ids = None - req.frequency_penalty = None - req.presence_penalty = None - req.extra_body = {"height": 1024, "width": 1024} - req.model_fields_set = set() - return req - - def test_t2i_injects_target_h_w(self, glm_serving_chat, glm_request, default_comprehension_params): - """t2i mode: target_h/w injected into extra_args, max_tokens unchanged.""" - result = glm_serving_chat._apply_request_overrides(default_comprehension_params, glm_request) - assert result.extra_args["target_h"] == 1024 - assert result.extra_args["target_w"] == 1024 - # max_tokens stays at YAML default (not dynamically computed) - assert result.max_tokens == 4353 - - def test_i2i_injects_target_h_w( - self, glm_serving_chat, glm_request, default_comprehension_params, mocker: MockerFixture - ): - """i2i mode: target_h/w injected, max_tokens unchanged.""" - glm_serving_chat._extract_diffusion_prompt_and_images_from_messages = mocker.MagicMock( - return_value=("edit this", ["fake_image"]) - ) - result = glm_serving_chat._apply_request_overrides(default_comprehension_params, glm_request) - assert result.extra_args["target_h"] == 1024 - assert result.extra_args["target_w"] == 1024 - # max_tokens stays at YAML default regardless of t2i/i2i - assert result.max_tokens == 4353 - - def test_user_max_tokens_preserved(self, glm_serving_chat, glm_request, default_comprehension_params): - """User-provided max_tokens is respected (not overridden by dynamic computation).""" - glm_request.max_tokens = 500 - glm_request.model_fields_set = {"max_tokens"} - - result = glm_serving_chat._apply_request_overrides(default_comprehension_params, glm_request) - assert result.max_tokens == 500 - assert result.extra_args["target_h"] == 1024 - assert result.extra_args["target_w"] == 1024 - - def test_no_height_width_preserves_default( - self, glm_serving_chat, mocker: MockerFixture, default_comprehension_params - ): - """When no height/width in extra_body, keep YAML default max_tokens, no target_h/w.""" - req = mocker.MagicMock() - req.temperature = None - req.top_p = None - req.top_k = None - req.max_tokens = None - req.min_tokens = None - req.seed = None - req.ignore_eos = None - req.stop = None - req.stop_token_ids = None - req.frequency_penalty = None - req.presence_penalty = None - req.extra_body = {} - req.model_fields_set = set() - - result = glm_serving_chat._apply_request_overrides(default_comprehension_params, req) - assert result.max_tokens == 4353 # YAML default - # No target_h/w injected when dimensions not provided - assert not result.extra_args or "target_h" not in (result.extra_args or {}) - - def test_size_string_parsed_for_glm_image( - self, glm_serving_chat, mocker: MockerFixture, default_comprehension_params - ): - """'size' in extra_body is parsed as fallback for height/width.""" - req = mocker.MagicMock() - req.temperature = None - req.top_p = None - req.top_k = None - req.max_tokens = None - req.min_tokens = None - req.seed = None - req.ignore_eos = None - req.stop = None - req.stop_token_ids = None - req.frequency_penalty = None - req.presence_penalty = None - req.extra_body = {"size": "512x512"} - req.model_fields_set = set() - - result = glm_serving_chat._apply_request_overrides(default_comprehension_params, req) - assert result.extra_args["target_h"] == 512 - assert result.extra_args["target_w"] == 512 - # max_tokens stays at YAML default (not dynamically computed) - assert result.max_tokens == 4353 diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 904e5735c..3739f8860 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -25,6 +25,7 @@ from vllm.entrypoints.openai.engine.protocol import ErrorInfo, ErrorResponse from vllm_omni.entrypoints.omni_base import OmniEngineDeadError from vllm_omni.entrypoints.openai import api_server as api_server_module +from vllm_omni.entrypoints.openai import serving_speech as serving_speech_module from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin from vllm_omni.entrypoints.openai.protocol.audio import ( BatchSpeechRequest, @@ -45,6 +46,13 @@ from vllm_omni.model_executor.models.fish_speech.prompt_utils import ( FISH_TEXT_ONLY_SYSTEM_PROMPT, build_fish_voice_clone_prompt_ids, ) +from vllm_omni.model_executor.models.ming_tts.constants import ( + KEY_SPEAKER_EMBEDDING, + KEY_SPEAKER_SAMPLE_RATES, + KEY_SPEAKER_WAVEFORM, + KEY_SPEAKER_WAVEFORM_LENGTHS, + SPEAKER_EMBEDDING_DIM, +) from vllm_omni.outputs import OmniRequestOutput pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -975,21 +983,25 @@ class TestTTSMethods: ) def test_upload_ming_voice_embedding_wrong_dims_rejected_without_replacing_existing(self, speech_server): - """Ming embedding uploads must be 192-dim before replacing stored voices.""" + """Ming embedding uploads must match the model dimension before replacement.""" speech_server._tts_model_type = "ming_tts" + invalid_dim = SPEAKER_EMBEDDING_DIM - 1 existing = { "name": "bad_emb_voice", "file_path": "/tmp/voice_samples/bad_emb_voice.safetensors", "mime_type": "application/x-safetensors", "embedding_source": "direct", - "embedding_dim": 192, + "embedding_dim": SPEAKER_EMBEDDING_DIM, } speech_server.uploaded_speakers = {"bad_emb_voice": existing.copy()} - with pytest.raises(ValueError, match="Ming speaker embedding must have 192 dims, got 191"): + with pytest.raises( + ValueError, + match=f"Ming speaker embedding must have {SPEAKER_EMBEDDING_DIM} dims, got {invalid_dim}", + ): asyncio.run( speech_server.upload_voice_embedding( - embedding_json=json.dumps([0.0] * 191), + embedding_json=json.dumps([0.0] * invalid_dim), consent="consent", name="bad_emb_voice", ) @@ -997,6 +1009,57 @@ class TestTTSMethods: assert speech_server.uploaded_speakers["bad_emb_voice"] == existing + def test_upload_ming_audio_voice_defers_speaker_extraction(self, speech_server, mocker: MockerFixture, tmp_path): + """Ming audio uploads stay model-agnostic and defer extraction to use.""" + speech_server._tts_model_type = "ming_tts" + speech_server.uploaded_speakers_dir = tmp_path + speech_server.uploaded_speakers = {} + speech_server.supported_speakers = set() + speech_server._speaker_cache.clear() + + left = np.full(32000, 0.5, dtype=np.float32) + right = np.full(32000, -0.25, dtype=np.float32) + samples = np.stack((left, right), axis=-1) + mocker.patch("soundfile.read", return_value=(samples, 16000)) + saved: dict[str, object] = {} + + def fake_save_file(tensors, path, metadata=None): + saved["tensors"] = tensors + saved["metadata"] = metadata + Path(path).touch() + + mocker.patch("safetensors.torch.save_file", side_effect=fake_save_file) + audio_file = mocker.MagicMock() + audio_file.filename = "reference.wav" + audio_file.content_type = "audio/wav" + audio_file.file = io.BytesIO(b"fake audio") + audio_file.read = mocker.AsyncMock(return_value=b"fake audio") + + asyncio.run( + speech_server.upload_voice( + audio_file, + consent="consent", + name="ming_audio_voice", + ref_text="Reference transcript.", + ) + ) + + tensors = saved["tensors"] + metadata = saved["metadata"] + assert isinstance(tensors, dict) + assert isinstance(metadata, dict) + assert set(tensors) == {"audio"} + assert tensors["audio"].shape == (32000, 2) + assert metadata["embedding_source"] == "audio" + assert "embedding_dim" not in metadata + speaker_info = speech_server.uploaded_speakers["ming_audio_voice"] + cache_key = speech_server._speaker_cache.make_cache_key( + "ming_audio_voice", + model_type="ming_tts", + created_at=speaker_info["created_at"], + ) + assert speech_server._speaker_cache.get(cache_key) is None + def test_base_task_requires_ref_audio_or_speaker_embedding(self, speech_server): """Base task without ref_audio or speaker_embedding is rejected.""" req = OpenAICreateSpeechRequest(input="Hello", task_type="Base") @@ -1328,6 +1391,20 @@ class TestTTSMethods: # Verify speakers are normalized to lowercase assert server.supported_speakers == {"ryan", "vivian", "aiden"} + def test_load_supported_speakers_skips_non_tts_omni_model( + self, + speech_server, + mocker: MockerFixture, + ): + warning = mocker.patch.object(serving_speech_module.logger, "warning") + speech_server.engine_client.model_config = SimpleNamespace( + hf_config=SimpleNamespace(tts_config=SimpleNamespace()) + ) + + assert speech_server._is_tts is False + assert speech_server._load_supported_speakers() == set() + warning.assert_not_called() + def test_load_supported_languages_from_config(self, speech_server): """Languages/dialects from codec_language_id are loaded title-cased; 'Auto' is added.""" speech_server._tts_model_type = "qwen3_tts" @@ -1549,7 +1626,7 @@ class TestTTSMethods: req = OpenAICreateSpeechRequest.model_validate({"input": "Hello", "speaker": "UTESF", "task_type": "Base"}) assert req.voice == "UTESF" mocker.patch("pathlib.Path.exists", return_value=True) - result = speech_server._validate_qwen_tts_request(req) + result = speech_server._validate_tts_request(req) assert result is None # ── uploaded voice with embedding ── @@ -1591,10 +1668,10 @@ class TestTTSMethods: "file_path": "/tmp/voice_samples/emb_voice.safetensors", "mime_type": "application/x-safetensors", "embedding_source": "direct", - "embedding_dim": 192, + "embedding_dim": SPEAKER_EMBEDDING_DIM, } } - fake_embedding = [0.1] * 192 + fake_embedding = [0.1] * SPEAKER_EMBEDDING_DIM mock_get_emb = mocker.patch.object( speech_server, "_get_uploaded_speaker_embedding", return_value=fake_embedding ) @@ -1611,38 +1688,38 @@ class TestTTSMethods: mock_get_emb.assert_called_once_with("emb_voice") mock_get_audio.assert_not_called() - mock_prompt.assert_called_once_with(req, ref_audio_data=None) + mock_prompt.assert_called_once_with( + req, + ref_audio_data=None, + voice_name=None, + voice_created_at=0, + ) prompt_request = mock_prompt.call_args.args[0] assert prompt_request.speaker_embedding == fake_embedding - assert len(prompt_request.speaker_embedding) == 192 + assert len(prompt_request.speaker_embedding) == SPEAKER_EMBEDDING_DIM assert prepared.prompt["additional_information"]["speaker_count"] == 1 assert prepared.model_type == "ming_tts" - def test_ming_adapter_uploaded_audio_path_preserves_ref_text(self, speech_server, mocker: MockerFixture): - """Ming uploaded audio voices still resolve audio and stored ref_text.""" + def test_ming_adapter_uploaded_audio_forwards_worker_cache_metadata(self, speech_server, mocker: MockerFixture): + """Ming audio voices defer extraction and cache ownership to Stage-0.""" speech_server.uploaded_speakers = { "audio_voice": { "name": "audio_voice", - "file_path": "/tmp/voice_samples/audio_voice.wav", + "file_path": "/tmp/voice_samples/audio_voice.safetensors", + "created_at": 123, "mime_type": "audio/wav", + "embedding_source": "audio", "ref_text": "Reference transcript.", } } ref_audio_source = "data:audio/wav;base64,ZmFrZWF1ZGlv" ref_audio_data = ([0.0, 0.1], 16000) - fake_embedding = [0.2] * 192 - mock_get_audio = mocker.patch.object(speech_server, "_get_uploaded_audio_data", return_value=ref_audio_source) - mock_get_emb = mocker.patch.object(speech_server, "_get_uploaded_speaker_embedding") + mocker.patch.object(speech_server, "_get_uploaded_audio_data", return_value=ref_audio_source) mocker.patch.object( speech_server, "_resolve_ref_audio", new=mocker.AsyncMock(return_value=ref_audio_data), ) - mock_extract = mocker.patch.object( - speech_server, - "_extract_ming_speaker_embeddings_from_ref_audio", - return_value=[fake_embedding], - ) mock_prompt = mocker.patch.object( speech_server, "_build_ming_dense_prompt", @@ -1653,14 +1730,145 @@ class TestTTSMethods: req = OpenAICreateSpeechRequest(input="Hello", voice="audio_voice") prepared = asyncio.run(adapter.build(req, [], False)) - mock_get_audio.assert_called_once_with("audio_voice") - mock_get_emb.assert_not_called() - mock_extract.assert_called_once_with([ref_audio_data]) - mock_prompt.assert_called_once_with(req, ref_audio_data=ref_audio_data) + mock_prompt.assert_called_once_with( + req, + ref_audio_data=ref_audio_data, + voice_name="audio_voice", + voice_created_at=123, + ) assert req.ref_text == "Reference transcript." - assert req.speaker_embedding == fake_embedding + assert req.speaker_embedding is None assert prepared.model_type == "ming_tts" + def test_build_ming_prompt_forwards_audio_segments_and_worker_cache_metadata(self, speech_server): + class FakeTokenizer: + unk_token_id = -1 + + def encode(self, text): + return [777] if text == "<|vision_pad|>" else [1] + + def convert_tokens_to_ids(self, token): + return 888 if token == "" else self.unk_token_id + + speech_server._tts_tokenizer = FakeTokenizer() + request = OpenAICreateSpeechRequest( + input="Hello", + ref_text="Two reference speakers.", + ) + ref_audio_data = [ + ([0.1, 0.2, 0.3], 16000), + ([0.4, 0.5], 24000), + ] + + prompt = speech_server._build_ming_dense_prompt( + request, + ref_audio_data=ref_audio_data, + voice_name="uploaded_voice", + voice_created_at=123, + ) + + info = prompt["additional_information"] + assert KEY_SPEAKER_EMBEDDING not in info + torch.testing.assert_close( + info[KEY_SPEAKER_WAVEFORM], + torch.tensor([[0.1, 0.2, 0.3, 0.4, 0.5]], dtype=torch.float32), + ) + assert info[KEY_SPEAKER_WAVEFORM_LENGTHS].tolist() == [3, 2] + assert info[KEY_SPEAKER_SAMPLE_RATES].tolist() == [16000, 24000] + assert info["prompt_waveform"].shape[-1] > info[KEY_SPEAKER_WAVEFORM].shape[-1] + assert info["voice_name"] == "uploaded_voice" + assert info["voice_created_at"] == 123 + assert prompt["prompt_token_ids"].count(777) == 2 + + def test_build_ming_prompt_forwards_speaker_waveform_without_ref_text(self, speech_server): + class FakeTokenizer: + unk_token_id = -1 + + def encode(self, text): + return [1] + + def convert_tokens_to_ids(self, token): + return 888 if token == "" else self.unk_token_id + + speech_server._tts_tokenizer = FakeTokenizer() + request = OpenAICreateSpeechRequest(input="Hello") + + prompt = speech_server._build_ming_dense_prompt( + request, + ref_audio_data=([0.1, 0.2, 0.3], 16000), + ) + + info = prompt["additional_information"] + assert info[KEY_SPEAKER_WAVEFORM].shape == (1, 3) + assert info[KEY_SPEAKER_WAVEFORM_LENGTHS].tolist() == [3] + assert info[KEY_SPEAKER_SAMPLE_RATES].tolist() == [16000] + assert "prompt_waveform" not in info + assert KEY_SPEAKER_EMBEDDING not in info + + def test_ming_adapter_legacy_audio_only_voice_forwards_audio_to_worker( + self, + speech_server, + mocker: MockerFixture, + tmp_path, + ): + """Legacy audio-only profiles extract once instead of failing after upgrade.""" + from safetensors.torch import save_file + + speech_server._tts_model_type = "ming_tts" + speech_server.uploaded_speakers_dir = tmp_path + speech_server._speaker_cache.clear() + file_path = tmp_path / "legacy_audio_voice.safetensors" + save_file({"audio": torch.zeros(32000, dtype=torch.float32)}, str(file_path)) + speech_server.uploaded_speakers = { + "legacy_audio_voice": { + "name": "legacy_audio_voice", + "file_path": str(file_path), + "created_at": 456, + "mime_type": "audio/wav", + "embedding_source": "audio", + "ref_text": "Reference transcript.", + } + } + ref_audio_source = "data:audio/wav;base64,ZmFrZWF1ZGlv" + ref_audio_data = ([0.0, 0.1], 16000) + mocker.patch.object(speech_server, "_get_uploaded_audio_data", return_value=ref_audio_source) + mocker.patch.object( + speech_server, + "_resolve_ref_audio", + new=mocker.AsyncMock(return_value=ref_audio_data), + ) + mock_prompt = mocker.patch.object( + speech_server, + "_build_ming_dense_prompt", + return_value={"additional_information": {"speaker_count": 1}}, + ) + + adapter = MingTTSAdapter(SpeechServingContext(server=speech_server)) + first_req = OpenAICreateSpeechRequest(input="Hello", voice="legacy_audio_voice") + first_prepared = asyncio.run(adapter.build(first_req, [], False)) + second_req = OpenAICreateSpeechRequest(input="Hello again", voice="legacy_audio_voice") + second_prepared = asyncio.run(adapter.build(second_req, [], False)) + + assert first_req.speaker_embedding is None + assert second_req.speaker_embedding is None + assert first_req.ref_text == "Reference transcript." + assert second_req.ref_text == "Reference transcript." + assert first_prepared.model_type == "ming_tts" + assert second_prepared.model_type == "ming_tts" + assert mock_prompt.call_count == 2 + mock_prompt.assert_any_call( + first_req, + ref_audio_data=ref_audio_data, + voice_name="legacy_audio_voice", + voice_created_at=456, + ) + mock_prompt.assert_any_call( + second_req, + ref_audio_data=ref_audio_data, + voice_name="legacy_audio_voice", + voice_created_at=456, + ) + # ── regression: full flow from issue #1603 ── def test_regression_1603_speaker_key_with_uploaded_audio_voice(self, speech_server, mocker: MockerFixture): @@ -1684,7 +1892,7 @@ class TestTTSMethods: # Validation should pass (file exists) mocker.patch("pathlib.Path.exists", return_value=True) - err = speech_server._validate_qwen_tts_request(req) + err = speech_server._validate_tts_request(req) assert err is None, f"Validation failed: {err}" # Build params should auto-set ref_audio from stored file @@ -1722,7 +1930,7 @@ class TestTTSMethods: # Validation should pass mocker.patch("pathlib.Path.exists", return_value=True) - err = speech_server._validate_qwen_tts_request(req) + err = speech_server._validate_tts_request(req) assert err is None, f"Validation failed: {err}" # Build params should use embedding, NOT audio @@ -1866,6 +2074,35 @@ class TestTTSMethods: assert error is not None assert "max 10 characters" in error + def test_batch_voice_not_treated_as_inline_ref_audio( + self, + speech_server, + mocker: MockerFixture, + ): + speech_server._check_model = mocker.AsyncMock(return_value=None) + speech_server._batch_max_items = 32 + + def validate_and_resolve_uploaded_voice(request): + assert request.voice == "alice" + assert request.ref_audio is None + + request.ref_audio = "data:audio/wav;base64,dGVzdA==" + request.ref_text = "reference text" + return None + + speech_server._validate_tts_request = mocker.Mock(side_effect=validate_and_resolve_uploaded_voice) + speech_server._generate_audio_bytes = mocker.AsyncMock(return_value=("YWJj", "audio/wav")) + + batch = BatchSpeechRequest(voice="alice", items=[SpeechBatchItem(input="hello")]) + + response = asyncio.run(speech_server.create_speech_batch(batch)) + call = speech_server._generate_audio_bytes.await_args + request = call.args[0] + + assert response.results[0].status == "success" + assert request.ref_audio is not None + assert call.kwargs["has_inline_ref_audio"] is False + class TestFileValidationFunctions: """Unit tests for file validation helper functions.""" @@ -3221,25 +3458,25 @@ class TestCosyVoice3Serving: def test_validate_cosyvoice3_empty_input(self, cosyvoice3_server): request = OpenAICreateSpeechRequest(input="", ref_audio="data:audio/wav;base64,abc", ref_text="hello") - error = cosyvoice3_server._validate_cosyvoice3_request(request) + error = cosyvoice3_server._validate_tts_request(request) assert error is not None assert "empty" in error.lower() def test_validate_cosyvoice3_missing_ref_audio(self, cosyvoice3_server): request = OpenAICreateSpeechRequest(input="Hello", ref_text="hello") - error = cosyvoice3_server._validate_cosyvoice3_request(request) + error = cosyvoice3_server._validate_tts_request(request) assert error is not None assert "ref_audio" in error.lower() def test_validate_cosyvoice3_missing_ref_text(self, cosyvoice3_server): request = OpenAICreateSpeechRequest(input="Hello", ref_audio="data:audio/wav;base64,abc") - error = cosyvoice3_server._validate_cosyvoice3_request(request) + error = cosyvoice3_server._validate_tts_request(request) assert error is not None assert "ref_text" in error.lower() def test_validate_cosyvoice3_invalid_ref_audio_format(self, cosyvoice3_server): request = OpenAICreateSpeechRequest(input="Hello", ref_audio="/local/path.wav", ref_text="hello") - error = cosyvoice3_server._validate_cosyvoice3_request(request) + error = cosyvoice3_server._validate_tts_request(request) assert error is not None assert "url" in error.lower() or "format" in error.lower() @@ -3249,7 +3486,7 @@ class TestCosyVoice3Serving: ref_audio="data:audio/wav;base64,abc123", ref_text="Reference transcript", ) - error = cosyvoice3_server._validate_cosyvoice3_request(request) + error = cosyvoice3_server._validate_tts_request(request) assert error is None def test_validate_cosyvoice3_max_new_tokens_range(self, cosyvoice3_server): @@ -3259,7 +3496,7 @@ class TestCosyVoice3Serving: ref_text="hello", max_new_tokens=0, ) - error = cosyvoice3_server._validate_cosyvoice3_request(request) + error = cosyvoice3_server._validate_tts_request(request) assert error is not None assert "max_new_tokens" in error @@ -3325,13 +3562,13 @@ def glm_tts_server(mocker: MockerFixture): class TestGLMTTSServing: def test_validate_glm_tts_requires_ref_audio(self, glm_tts_server): request = OpenAICreateSpeechRequest(input="Hello", ref_text="Reference transcript") - error = glm_tts_server._validate_glm_tts_request(request) + error = glm_tts_server._validate_tts_request(request) assert error is not None assert "ref_audio" in error def test_validate_glm_tts_requires_ref_text(self, glm_tts_server): request = OpenAICreateSpeechRequest(input="Hello", ref_audio="data:audio/wav;base64,abc") - error = glm_tts_server._validate_glm_tts_request(request) + error = glm_tts_server._validate_tts_request(request) assert error is not None assert "ref_text" in error @@ -3438,7 +3675,7 @@ class TestTTSAsyncOffloading: def test_prepare_speech_generation_awaits_qwen3_tts_async(self, qwen3_tts_server, mocker: MockerFixture): """Qwen3 TTS path should call _estimate_prompt_len_async.""" - qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None) + qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None) qwen3_tts_server._build_tts_params = mocker.MagicMock( return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} ) @@ -3455,7 +3692,7 @@ class TestTTSAsyncOffloading: qwen3_tts_server.engine_client.default_sampling_params_list = [ SimpleNamespace(max_tokens=2048, seed=42, extra_args=None) ] - qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None) + qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None) qwen3_tts_server._build_tts_params = mocker.MagicMock( return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} ) @@ -3505,7 +3742,7 @@ class TestTTSAsyncOffloading: def test_prepare_speech_generation_treats_sse_as_streaming(self, qwen3_tts_server, mocker: MockerFixture): """stream_format=sse should request delta-style multimodal outputs.""" - qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None) + qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None) qwen3_tts_server._build_tts_params = mocker.MagicMock( return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} ) @@ -3522,7 +3759,7 @@ class TestTTSAsyncOffloading: def test_prepare_speech_generation_treats_stream_true_as_streaming(self, qwen3_tts_server, mocker: MockerFixture): """stream=True should request delta-style multimodal outputs for SSE streaming.""" - qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None) + qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None) qwen3_tts_server._build_tts_params = mocker.MagicMock( return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} ) @@ -3539,7 +3776,7 @@ class TestTTSAsyncOffloading: def test_prepare_speech_generation_treats_audio_as_streaming(self, qwen3_tts_server, mocker: MockerFixture): """stream_format=audio should request delta-style multimodal outputs.""" - qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None) + qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None) qwen3_tts_server._build_tts_params = mocker.MagicMock( return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} ) @@ -3559,7 +3796,7 @@ class TestTTSAsyncOffloading: ): """Full-payload TTS streaming should not request delta multimodal outputs.""" qwen3_tts_server.engine_client.model_config.async_chunk = False - qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None) + qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None) qwen3_tts_server._build_tts_params = mocker.MagicMock( return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} ) @@ -3653,7 +3890,7 @@ class TestTTSAsyncOffloading: 24000, artifact_key, ) - qwen3_tts_server._ref_audio_model_artifact_ready.add(artifact_key) + qwen3_tts_server._ref_audio_model_artifact_ready.add((artifact_key, False)) qwen3_tts_server._codec_frame_rate = 25.0 qwen3_tts_server._tts_tokenizer = lambda _text, padding=False: {"input_ids": list(range(10))} qwen3_tts_server.engine_client.model_config.hf_config.talker_config = SimpleNamespace( @@ -3683,10 +3920,10 @@ class TestTTSAsyncOffloading: qwen3_tts_server._ref_audio_resolve_cache_max_bytes = 1_000_000 qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a") - qwen3_tts_server._ref_audio_model_artifact_ready.add("artifact-a") + qwen3_tts_server._ref_audio_model_artifact_ready.add(("artifact-a", False)) qwen3_tts_server._put_resolved_ref_audio("ref-b", [0.0] * 8, 24000, "artifact-b") - assert "artifact-a" not in qwen3_tts_server._ref_audio_model_artifact_ready + assert ("artifact-a", False) not in qwen3_tts_server._ref_audio_model_artifact_ready assert "artifact-b" in {entry[3] for entry in qwen3_tts_server._ref_audio_resolve_cache.values()} @pytest.mark.asyncio @@ -3695,13 +3932,13 @@ class TestTTSAsyncOffloading: raise ValueError("boom") yield # pragma: no cover - qwen3_tts_server._request_ref_audio_artifact_keys["req-fail"] = "artifact-fail" + qwen3_tts_server._request_ref_audio_artifact_keys["req-fail"] = ("artifact-fail", False) with pytest.raises(ValueError, match="boom"): await anext(qwen3_tts_server._generate_audio_chunks(failing_generator(), "req-fail")) assert "req-fail" not in qwen3_tts_server._request_ref_audio_artifact_keys - assert "artifact-fail" not in qwen3_tts_server._ref_audio_model_artifact_ready + assert ("artifact-fail", False) not in qwen3_tts_server._ref_audio_model_artifact_ready @pytest.mark.asyncio async def test_generate_audio_chunks_discards_ref_audio_artifact_warmup_on_close(self, qwen3_tts_server): @@ -3714,22 +3951,48 @@ class TestTTSAsyncOffloading: ) await asyncio.sleep(0) - qwen3_tts_server._request_ref_audio_artifact_keys["req-close"] = "artifact-close" + qwen3_tts_server._request_ref_audio_artifact_keys["req-close"] = ("artifact-close", False) stream = qwen3_tts_server._generate_audio_chunks(pcm_generator(), "req-close") assert await anext(stream) await stream.aclose() assert "req-close" not in qwen3_tts_server._request_ref_audio_artifact_keys - assert "artifact-close" not in qwen3_tts_server._ref_audio_model_artifact_ready + assert ("artifact-close", False) not in qwen3_tts_server._ref_audio_model_artifact_ready def test_qwen3_ref_audio_artifact_ready_requires_live_resolve_cache_entry(self, qwen3_tts_server): - qwen3_tts_server._request_ref_audio_artifact_keys["req-evicted"] = "artifact-evicted" + qwen3_tts_server._request_ref_audio_artifact_keys["req-evicted"] = ("artifact-evicted", False) qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-evicted") assert "req-evicted" not in qwen3_tts_server._request_ref_audio_artifact_keys - assert "artifact-evicted" not in qwen3_tts_server._ref_audio_model_artifact_ready + assert ("artifact-evicted", False) not in qwen3_tts_server._ref_audio_model_artifact_ready + + def test_qwen3_xvector_ready_artifact_does_not_enable_icl_artifact_only(self, qwen3_tts_server): + # An x-vector-only artifact (speaker embedding, no ref_code) must not enable + # the artifact-only path for a later ICL request with the same ref_audio (#5049). + qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a") + qwen3_tts_server._track_ref_audio_artifact_warmup("req-xvec", "artifact-a", x_vector_only=True) + qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-xvec") + + icl_params = {"task_type": ["Base"], "x_vector_only_mode": [False]} + assert qwen3_tts_server._qwen3_tts_can_use_ref_audio_artifact_only(icl_params, "artifact-a") is False + + def test_qwen3_xvector_ready_artifact_still_reusable_by_xvector_request(self, qwen3_tts_server): + qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a") + qwen3_tts_server._track_ref_audio_artifact_warmup("req-xvec", "artifact-a", x_vector_only=True) + qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-xvec") + + xvec_params = {"task_type": ["Base"], "x_vector_only_mode": [True]} + assert qwen3_tts_server._qwen3_tts_can_use_ref_audio_artifact_only(xvec_params, "artifact-a") is True + + def test_qwen3_icl_ready_artifact_enables_icl_artifact_only(self, qwen3_tts_server): + qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a") + qwen3_tts_server._track_ref_audio_artifact_warmup("req-icl", "artifact-a", x_vector_only=False) + qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-icl") + + icl_params = {"task_type": ["Base"], "x_vector_only_mode": [False]} + assert qwen3_tts_server._qwen3_tts_can_use_ref_audio_artifact_only(icl_params, "artifact-a") is True def test_shutdown_is_idempotent(self, mocker: MockerFixture): """Calling shutdown() twice should not raise.""" diff --git a/tests/entrypoints/openai_api/test_serving_speech_batch.py b/tests/entrypoints/openai_api/test_serving_speech_batch.py index ce0a5e21f..686dba7d0 100644 --- a/tests/entrypoints/openai_api/test_serving_speech_batch.py +++ b/tests/entrypoints/openai_api/test_serving_speech_batch.py @@ -88,7 +88,7 @@ class TestSpeechBatchE2E: @pytest.mark.advanced_model @pytest.mark.tts - @hardware_test(res={"cuda": "H100"}, num_cards=1) + @hardware_test(res={"cuda": "L4"}, num_cards=1) @pytest.mark.parametrize("omni_server", default_server_params, indirect=True) def test_batch_basic_two_items(self, omni_server) -> None: """Batch with two items returns two successful base64-encoded results.""" diff --git a/tests/entrypoints/openai_api/test_serving_speech_stream.py b/tests/entrypoints/openai_api/test_serving_speech_stream.py index 881717363..44e84b346 100644 --- a/tests/entrypoints/openai_api/test_serving_speech_stream.py +++ b/tests/entrypoints/openai_api/test_serving_speech_stream.py @@ -72,13 +72,162 @@ class TestStreamingSpeechWebSocket: assert audio.startswith(b"RIFF") done = ws.receive_json() - assert done == {"type": "audio.done", "sentence_index": 0, "total_bytes": len(audio), "error": False} + assert done == { + "type": "audio.done", + "utterance_index": 0, + "sentence_index": 0, + "total_bytes": len(audio), + "error": False, + } session_done = ws.receive_json() - assert session_done == {"type": "session.done", "total_sentences": 1} + assert session_done == {"type": "session.done", "utterance_index": 0, "total_sentences": 1} assert speech_service._generate_audio_bytes.await_count == 1 + def test_input_done_flushes_and_keeps_connection_open(self, mocker: MockerFixture): + app, speech_service = _build_test_app(mocker=mocker) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + + # The same connection serves several utterances; the config sent + # once at the top keeps applying and utterance_index rises. + for expected_index, text in enumerate(("First utterance. ", "Second utterance. ")): + ws.send_json({"type": "input.text", "text": text}) + ws.send_json({"type": "input.done"}) + + start = ws.receive_json() + assert start["type"] == "audio.start" + assert start["utterance_index"] == expected_index + assert start["sentence_text"] == text.strip() + assert ws.receive_bytes().startswith(b"RIFF") + assert ws.receive_json()["type"] == "audio.done" + assert ws.receive_json() == { + "type": "session.done", + "utterance_index": expected_index, + "total_sentences": 1, + } + + assert speech_service._generate_audio_bytes.await_count == 2 + assert [call.args[0].voice for call in speech_service._generate_audio_bytes.await_args_list] == [ + "Vivian", + "Vivian", + ] + + def test_sentence_index_stays_within_the_flushed_utterance(self, mocker: MockerFixture): + # sentence_index counts within one flush and utterance_index counts the + # flushes, so a late utterance never reports "sentence 2 of 1". + app, _ = _build_test_app(mocker=mocker) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + + for expected_index in range(3): + ws.send_json({"type": "input.text", "text": "Hello world. "}) + ws.send_json({"type": "input.done"}) + + start = ws.receive_json() + assert start["utterance_index"] == expected_index + assert start["sentence_index"] == 0 + ws.receive_bytes() + + done = ws.receive_json() + assert done["utterance_index"] == expected_index + assert done["sentence_index"] == 0 + + session_done = ws.receive_json() + assert session_done["utterance_index"] == expected_index + assert session_done["total_sentences"] == 1 + assert start["sentence_index"] < session_done["total_sentences"] + + def test_session_config_between_utterances_replaces_config(self, mocker: MockerFixture): + app, speech_service = _build_test_app(mocker=mocker) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + for expected_index, voice in enumerate(("Vivian", "Serena")): + ws.send_json({"type": "session.config", "voice": voice}) + ws.send_json({"type": "input.text", "text": "Hello world. "}) + ws.send_json({"type": "input.done"}) + + assert ws.receive_json()["type"] == "audio.start" + ws.receive_bytes() + assert ws.receive_json()["type"] == "audio.done" + assert ws.receive_json() == { + "type": "session.done", + "utterance_index": expected_index, + "total_sentences": 1, + } + + assert [call.args[0].voice for call in speech_service._generate_audio_bytes.await_args_list] == [ + "Vivian", + "Serena", + ] + + def test_session_config_rejected_while_input_is_buffered(self, mocker: MockerFixture): + app, speech_service = _build_test_app(mocker=mocker) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + ws.send_json({"type": "input.text", "text": "Hello world. "}) + ws.send_json({"type": "session.config", "voice": "Serena"}) + + error = ws.receive_json() + assert error["type"] == "error" + assert "while input is buffered" in error["message"] + + # The buffered text survives the rejected reconfiguration. + ws.send_json({"type": "input.done"}) + assert ws.receive_json()["sentence_text"] == "Hello world." + ws.receive_bytes() + assert ws.receive_json()["type"] == "audio.done" + assert ws.receive_json() == {"type": "session.done", "utterance_index": 0, "total_sentences": 1} + + assert speech_service._generate_audio_bytes.await_args_list[0].args[0].voice == "Vivian" + + def test_session_close_ends_connection(self, mocker: MockerFixture): + app, _ = _build_test_app(mocker=mocker) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + ws.send_json({"type": "input.text", "text": "Hello world. "}) + ws.send_json({"type": "input.done"}) + + assert ws.receive_json()["type"] == "audio.start" + ws.receive_bytes() + assert ws.receive_json()["type"] == "audio.done" + assert ws.receive_json() == {"type": "session.done", "utterance_index": 0, "total_sentences": 1} + + ws.send_json({"type": "session.close"}) + with pytest.raises(WebSocketDisconnect): + ws.receive_json() + + def test_idle_timeout_closes_reused_connection(self, mocker: MockerFixture): + app, _ = _build_test_app(idle_timeout=0.05, mocker=mocker) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + ws.send_json({"type": "input.text", "text": "Hello world. "}) + ws.send_json({"type": "input.done"}) + + assert ws.receive_json()["type"] == "audio.start" + ws.receive_bytes() + assert ws.receive_json()["type"] == "audio.done" + assert ws.receive_json() == {"type": "session.done", "utterance_index": 0, "total_sentences": 1} + + # Holding a connection open is not free: an idle client still + # gets timed out after the flush. + assert ws.receive_json() == { + "type": "error", + "message": "Idle timeout: no message received", + } + def test_streaming_multiple_binary_frames(self, mocker: MockerFixture): captured_requests = [] @@ -125,9 +274,15 @@ class TestStreamingSpeechWebSocket: assert ws.receive_bytes() == b"\x06" done = ws.receive_json() - assert done == {"type": "audio.done", "sentence_index": 0, "total_bytes": 6, "error": False} + assert done == { + "type": "audio.done", + "utterance_index": 0, + "sentence_index": 0, + "total_bytes": 6, + "error": False, + } - assert ws.receive_json() == {"type": "session.done", "total_sentences": 1} + assert ws.receive_json() == {"type": "session.done", "utterance_index": 0, "total_sentences": 1} assert len(captured_requests) == 1 assert captured_requests[0].stream is True @@ -210,6 +365,8 @@ class TestStreamingSpeechWebSocket: # is fully aligned. chunk = ws.receive_json() assert chunk["type"] == "audio.chunk" + assert chunk["utterance_index"] == 0 + assert chunk["sentence_index"] == 0 assert chunk["chunk_id"] == 0 assert chunk["chunk_start_ms"] == 0 assert chunk["chunk_end_ms"] == 500 @@ -240,7 +397,13 @@ class TestStreamingSpeechWebSocket: ] done = ws.receive_json() - assert done == {"type": "audio.done", "sentence_index": 0, "total_bytes": 2000, "error": False} + assert done == { + "type": "audio.done", + "utterance_index": 0, + "sentence_index": 0, + "total_bytes": 2000, + "error": False, + } assert captured_requests[0].word_timestamps is True assert mock_align.await_count == 1 @@ -309,11 +472,12 @@ class TestStreamingSpeechWebSocket: assert ws.receive_bytes() assert ws.receive_json() == { "type": "audio.done", + "utterance_index": 0, "sentence_index": 0, "total_bytes": 36, "error": False, } - assert ws.receive_json() == {"type": "session.done", "total_sentences": 1} + assert ws.receive_json() == {"type": "session.done", "utterance_index": 0, "total_sentences": 1} def test_invalid_streaming_config(self, mocker: MockerFixture): app, _ = _build_test_app(mocker=mocker) @@ -341,7 +505,7 @@ class TestStreamingSpeechWebSocket: ws.send_json({"type": "input.text", "text": ""}) ws.send_json({"type": "input.done"}) - assert ws.receive_json() == {"type": "session.done", "total_sentences": 0} + assert ws.receive_json() == {"type": "session.done", "utterance_index": 0, "total_sentences": 0} assert speech_service._generate_audio_bytes.await_count == 0 @@ -361,11 +525,12 @@ class TestStreamingSpeechWebSocket: ws.receive_bytes() assert ws.receive_json() == { "type": "audio.done", + "utterance_index": 0, "sentence_index": 0, "total_bytes": 36, "error": False, } - assert ws.receive_json() == {"type": "session.done", "total_sentences": 1} + assert ws.receive_json() == {"type": "session.done", "utterance_index": 0, "total_sentences": 1} def test_unknown_message_type_keeps_session_open(self, mocker: MockerFixture): app, _ = _build_test_app(mocker=mocker) @@ -384,12 +549,13 @@ class TestStreamingSpeechWebSocket: ws.receive_bytes() assert ws.receive_json() == { "type": "audio.done", + "utterance_index": 0, "sentence_index": 0, "total_bytes": 36, "error": False, } - assert ws.receive_json() == {"type": "session.done", "total_sentences": 1} + assert ws.receive_json() == {"type": "session.done", "utterance_index": 0, "total_sentences": 1} def test_config_timeout_closes_session(self, mocker: MockerFixture): app, _ = _build_test_app(config_timeout=0.01, mocker=mocker) @@ -416,10 +582,19 @@ class TestStreamingSpeechWebSocket: ws.send_json({"type": "input.done"}) assert ws.receive_json()["type"] == "audio.start" - assert ws.receive_json() == {"type": "error", "message": "Generation failed for sentence 0: boom"} - assert ws.receive_json() == {"type": "audio.done", "sentence_index": 0, "total_bytes": 0, "error": True} + assert ws.receive_json() == { + "type": "error", + "message": "Generation failed for utterance 0, sentence 0: boom", + } + assert ws.receive_json() == { + "type": "audio.done", + "utterance_index": 0, + "sentence_index": 0, + "total_bytes": 0, + "error": True, + } - assert ws.receive_json() == {"type": "session.done", "total_sentences": 1} + assert ws.receive_json() == {"type": "session.done", "utterance_index": 0, "total_sentences": 1} def test_streaming_generation_error_marks_audio_done(self, mocker: MockerFixture): speech_service = mocker.MagicMock(spec=OmniOpenAIServingSpeech) @@ -453,16 +628,17 @@ class TestStreamingSpeechWebSocket: assert ws.receive_bytes() == b"\x01\x02" assert ws.receive_json() == { "type": "error", - "message": "Generation failed for sentence 0: stream boom", + "message": "Generation failed for utterance 0, sentence 0: stream boom", } assert ws.receive_json() == { "type": "audio.done", + "utterance_index": 0, "sentence_index": 0, "total_bytes": 2, "error": True, } - assert ws.receive_json() == {"type": "session.done", "total_sentences": 1} + assert ws.receive_json() == {"type": "session.done", "utterance_index": 0, "total_sentences": 1} def test_invalid_input_text_type_returns_validation_error(self, mocker: MockerFixture): app, speech_service = _build_test_app(mocker=mocker) @@ -478,7 +654,7 @@ class TestStreamingSpeechWebSocket: } ws.send_json({"type": "input.done"}) - assert ws.receive_json() == {"type": "session.done", "total_sentences": 0} + assert ws.receive_json() == {"type": "session.done", "utterance_index": 0, "total_sentences": 0} assert speech_service._generate_audio_bytes.await_count == 0 @@ -497,7 +673,7 @@ class TestStreamingSpeechWebSocket: } ws.send_json({"type": "input.done"}) - assert ws.receive_json() == {"type": "session.done", "total_sentences": 0} + assert ws.receive_json() == {"type": "session.done", "utterance_index": 0, "total_sentences": 0} assert speech_service._generate_audio_bytes.await_count == 0 @@ -550,7 +726,15 @@ class TestStreamingSpeechWebSocket: config.word_timestamps = False with pytest.raises(WebSocketDisconnect): - asyncio.run(handler._generate_and_send(websocket, config, "Hello world.", 0)) + asyncio.run( + handler._generate_and_send( + websocket, + config, + "Hello world.", + utterance_index=0, + sentence_index=0, + ) + ) speech_service.engine_client.abort.assert_awaited_once_with("req-abort") assert websocket.send_json.await_count == 2 diff --git a/tests/entrypoints/openai_api/test_serving_video_output_stream.py b/tests/entrypoints/openai_api/test_serving_video_output_stream.py index 72d293853..ec1727fe8 100644 --- a/tests/entrypoints/openai_api/test_serving_video_output_stream.py +++ b/tests/entrypoints/openai_api/test_serving_video_output_stream.py @@ -16,6 +16,7 @@ from fastapi import FastAPI, WebSocket from pytest_mock import MockerFixture from starlette.testclient import TestClient +from tests.helpers.media import generate_synthetic_image from vllm_omni.entrypoints.openai.serving_video_output_stream import OmniStreamingVideoOutputHandler from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.outputs import OmniRequestOutput @@ -27,6 +28,12 @@ def _fake_video_frames(num_frames: int = 2) -> list[np.ndarray]: return [np.full((4, 4, 3), fill_value=i, dtype=np.uint8) for i in range(num_frames)] +def _receive_video_chunk(ws) -> tuple[dict, bytes]: + metadata = ws.receive_json() + assert metadata["type"] == "video.chunk_metadata" + return metadata, ws.receive_bytes() + + def _build_test_app( *, mocker: MockerFixture, @@ -37,6 +44,7 @@ def _build_test_app( ) -> tuple[FastAPI, OmniStreamingVideoOutputHandler, MagicMock]: engine_client = mocker.MagicMock() engine_client.abort = mocker.AsyncMock() + engine_client.submit_interaction_async = mocker.AsyncMock() engine_client.default_sampling_params_list = [OmniDiffusionSamplingParams()] engine_client.stage_configs = [SimpleNamespace(stage_type="diffusion")] engine_client.generate = mock_generate @@ -126,8 +134,13 @@ class TestStreamingVideoOutputWebSocket: assert "format" not in start["config"] assert "request_id" in start - assert ws.receive_bytes() == b"mp4-chunk-0" - assert ws.receive_bytes() == b"mp4-chunk-1" + metadata, chunk = _receive_video_chunk(ws) + assert metadata["transport_chunk_index"] == 0 + assert metadata["kind"] == "media" + assert chunk == b"mp4-chunk-0" + metadata, chunk = _receive_video_chunk(ws) + assert metadata["transport_chunk_index"] == 1 + assert chunk == b"mp4-chunk-1" done = ws.receive_json() assert done["type"] == "session.done" @@ -138,6 +151,48 @@ class TestStreamingVideoOutputWebSocket: engine_client.streaming_encoder_factory.assert_called_once() assert engine_client.streaming_encoder_factory.call_args.kwargs["output_format"] == "m4s" + def test_streaming_session_accepts_initial_image_reference(self, mocker: MockerFixture): + """session.start forwards image_reference as prompt multi_modal_data.image.""" + captured_prompts: list[dict[str, object]] = [] + + async def mock_generate(*_args, **kwargs): + captured_prompts.append(kwargs["prompt"]) + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=True, + ) + + app, _handler, engine_client = _build_test_app( + mocker=mocker, + streaming_chunks=[(b"mp4-chunk-0", True)], + mock_generate=mock_generate, + ) + + synth_image = generate_synthetic_image(4, 4) + image_url = f"data:image/jpeg;base64,{synth_image['base64']}" + + with TestClient(app) as client: + with client.websocket_connect("/v1/realtime/video") as ws: + ws.send_json( + { + "type": "session.start", + "prompt": "animate this image", + "width": 4, + "height": 4, + "image_reference": {"image_url": image_url}, + } + ) + assert ws.receive_json()["type"] == "video.start" + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0" + assert ws.receive_json()["type"] == "session.done" + + prompt = captured_prompts[0] + image = prompt["multi_modal_data"]["image"] # type: ignore[index] + assert image.size == (4, 4) + engine_client.abort.assert_not_called() + def test_streaming_session_rejects_unknown_format(self, mocker: MockerFixture): async def mock_generate(*_args, **_kwargs): if False: @@ -158,6 +213,33 @@ class TestStreamingVideoOutputWebSocket: engine_client.abort.assert_not_called() + def test_first_message_must_be_session_start(self, mocker: MockerFixture): + """Control messages sent before session.start are rejected at the handshake. + + This test can practically ensure ``session.interaction`` before ``session.start`` is rejected, + but the implementation is more generic to also guard against other control messages. + """ + + async def mock_generate(*_args, **_kwargs): + if False: + yield None + + app, _handler, engine_client = _build_test_app( + mocker=mocker, + streaming_chunks=[], + mock_generate=mock_generate, + ) + + with TestClient(app) as client: + with client.websocket_connect("/v1/realtime/video") as ws: + ws.send_json({"type": "session.interaction", "interaction": {"event": {"prompt": "too early"}}}) + err = ws.receive_json() + assert err["type"] == "error" + assert err["message"] == "Expected session.start, got: session.interaction" + + engine_client.abort.assert_not_called() + engine_client.submit_interaction_async.assert_not_called() + def test_streaming_session_emits_final_encoder_delta_before_done(self, mocker: MockerFixture): """A non-empty encoder close delta is delivered as the final binary chunk.""" @@ -180,8 +262,13 @@ class TestStreamingVideoOutputWebSocket: with client.websocket_connect("/v1/realtime/video") as ws: ws.send_json({"type": "session.start", "prompt": "A cat walking in the rain"}) assert ws.receive_json()["type"] == "video.start" - assert ws.receive_bytes() == b"mp4-chunk-0" - assert ws.receive_bytes() == b"mp4-trailer" + metadata, chunk = _receive_video_chunk(ws) + assert metadata["kind"] == "media" + assert chunk == b"mp4-chunk-0" + metadata, chunk = _receive_video_chunk(ws) + assert metadata["kind"] == "trailer" + assert metadata["generation_chunk_index"] is None + assert chunk == b"mp4-trailer" done = ws.receive_json() assert done["type"] == "session.done" @@ -213,7 +300,7 @@ class TestStreamingVideoOutputWebSocket: with client.websocket_connect("/v1/realtime/video") as ws: ws.send_json({"type": "session.start", "prompt": "hello"}) assert ws.receive_json()["type"] == "video.start" - assert ws.receive_bytes() == b"mp4-chunk-0" + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0" err = ws.receive_json() assert err["type"] == "error" assert error_message in err["message"] @@ -259,6 +346,7 @@ def _build_handler_for_async_tests( ) -> tuple[OmniStreamingVideoOutputHandler, MagicMock, _MockVideoOutputWebSocket]: engine_client = mocker.MagicMock() engine_client.abort = mocker.AsyncMock() + engine_client.submit_interaction_async = mocker.AsyncMock() engine_client.default_sampling_params_list = [OmniDiffusionSamplingParams()] engine_client.stage_configs = [SimpleNamespace(stage_type="diffusion")] engine_client.generate = mock_generate @@ -340,8 +428,8 @@ class TestStreamingVideoOutputStallTimeout: with client.websocket_connect("/v1/realtime/video") as ws: ws.send_json({"type": "session.start", "prompt": "slow generation"}) assert ws.receive_json()["type"] == "video.start" - assert ws.receive_bytes() == b"mp4-chunk-0" - assert ws.receive_bytes() == b"mp4-chunk-1" + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0" + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1" done = ws.receive_json() assert done["type"] == "session.done" assert done["stopped"] is False @@ -444,3 +532,374 @@ class TestStreamingVideoOutputStallTimeout: assert any(m.get("type") == "session.pong" for m in ws.sent_json) assert any(m.get("type") == "session.done" for m in ws.sent_json) engine_client.abort.assert_not_called() + + +class TestStreamingVideoOutputPromptUpdate: + """session.interaction control messages during streaming.""" + + def test_prompt_update_accepted_during_streaming(self, mocker: MockerFixture): + """A valid interaction during generation is forwarded to the engine and acknowledged.""" + + async def mock_generate(*_args, **_kwargs): + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=False, + ) + await asyncio.sleep(0.3) + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=True, + ) + + app, _handler, engine_client = _build_test_app( + mocker=mocker, + streaming_chunks=[(b"mp4-chunk-0", False), (b"mp4-chunk-1", True)], + mock_generate=mock_generate, + ) + + with TestClient(app) as client: + with client.websocket_connect("/v1/realtime/video") as ws: + ws.send_json({"type": "session.start", "prompt": "initial scene"}) + start = ws.receive_json() + assert start["type"] == "video.start" + request_id = start["request_id"] + + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0" + + ws.send_json( + { + "type": "session.interaction", + "interaction": { + "event_id": "ui-update-42", + "event": {"prompt": "new scene"}, + }, + } + ) + queued = ws.receive_json() + assert queued["type"] == "session.interaction.queued" + assert queued["request_id"] == request_id + assert queued["event_id"] == "ui-update-42" + + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1" + done = ws.receive_json() + assert done["type"] == "session.done" + assert done["stopped"] is False + + engine_client.submit_interaction_async.assert_awaited_once_with( + request_id, + interaction={"event_id": "ui-update-42", "event": {"prompt": "new scene"}}, + ) + + def test_prompt_update_rejects_duplicate_event_id(self, mocker: MockerFixture): + """Any repeated event_id is rejected at the session API boundary.""" + + async def mock_generate(*_args, **_kwargs): + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=False, + ) + await asyncio.sleep(0.3) + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=True, + ) + + app, _handler, engine_client = _build_test_app( + mocker=mocker, + streaming_chunks=[(b"mp4-chunk-0", False), (b"mp4-chunk-1", True)], + mock_generate=mock_generate, + ) + + interaction = { + "type": "session.interaction", + "interaction": { + "event_id": "ui-update-42", + "event": {"prompt": "new scene"}, + }, + } + + with TestClient(app) as client: + with client.websocket_connect("/v1/realtime/video") as ws: + ws.send_json({"type": "session.start", "prompt": "initial scene"}) + request_id = ws.receive_json()["request_id"] + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0" + + ws.send_json(interaction) + queued = ws.receive_json() + assert queued["type"] == "session.interaction.queued" + + ws.send_json(interaction) + err = ws.receive_json() + assert err["type"] == "error" + assert err["code"] == "event_id_conflict" + assert err["request_id"] == request_id + assert err["event_id"] == "ui-update-42" + + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1" + assert ws.receive_json()["type"] == "session.done" + + engine_client.submit_interaction_async.assert_awaited_once_with( + request_id, + interaction={"event_id": "ui-update-42", "event": {"prompt": "new scene"}}, + ) + + def test_prompt_update_forwards_transition_chunks(self, mocker: MockerFixture): + """transition_chunks is passed through to the engine client.""" + + async def mock_generate(*_args, **_kwargs): + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=False, + ) + await asyncio.sleep(0.3) + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=True, + ) + + app, _handler, engine_client = _build_test_app( + mocker=mocker, + streaming_chunks=[(b"mp4-chunk-0", False), (b"mp4-chunk-1", True)], + mock_generate=mock_generate, + ) + + with TestClient(app) as client: + with client.websocket_connect("/v1/realtime/video") as ws: + ws.send_json({"type": "session.start", "prompt": "initial"}) + request_id = ws.receive_json()["request_id"] + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0" + + ws.send_json( + { + "type": "session.interaction", + "interaction": { + "event_id": "ui-update-42", + "event": {"prompt": "fade to sunset"}, + "transition_chunks": 5, + }, + } + ) + assert ws.receive_json()["type"] == "session.interaction.queued" + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1" + assert ws.receive_json()["type"] == "session.done" + + engine_client.submit_interaction_async.assert_awaited_once_with( + request_id, + interaction={ + "event_id": "ui-update-42", + "event": {"prompt": "fade to sunset"}, + "transition_chunks": 5, + }, + ) + + def test_prompt_update_rejects_empty_prompt(self, mocker: MockerFixture): + """An empty prompt is rejected before calling the engine.""" + + async def mock_generate(*_args, **_kwargs): + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=False, + ) + await asyncio.sleep(0.3) + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=True, + ) + + app, _handler, engine_client = _build_test_app( + mocker=mocker, + streaming_chunks=[(b"mp4-chunk-0", False), (b"mp4-chunk-1", True)], + mock_generate=mock_generate, + ) + + with TestClient(app) as client: + with client.websocket_connect("/v1/realtime/video") as ws: + ws.send_json({"type": "session.start", "prompt": "initial"}) + assert ws.receive_json()["type"] == "video.start" + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0" + + ws.send_json({"type": "session.interaction", "interaction": {"event": {"prompt": " "}}}) + err = ws.receive_json() + assert err["type"] == "error" + assert "non-empty event.prompt" in err["message"] + + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1" + assert ws.receive_json()["type"] == "session.done" + + engine_client.submit_interaction_async.assert_not_called() + + @pytest.mark.parametrize( + ("transition_chunks"), + ["not-an-int", 2.9, True, -1], + ) + def test_prompt_update_rejects_invalid_transition_chunks( + self, + mocker: MockerFixture, + transition_chunks: object, + ): + """Non-integral or negative transition_chunks returns an error.""" + + async def mock_generate(*_args, **_kwargs): + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=False, + ) + await asyncio.sleep(0.3) + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=True, + ) + + app, _handler, engine_client = _build_test_app( + mocker=mocker, + streaming_chunks=[(b"mp4-chunk-0", False), (b"mp4-chunk-1", True)], + mock_generate=mock_generate, + ) + + with TestClient(app) as client: + with client.websocket_connect("/v1/realtime/video") as ws: + ws.send_json({"type": "session.start", "prompt": "initial"}) + assert ws.receive_json()["type"] == "video.start" + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0" + + ws.send_json( + { + "type": "session.interaction", + "interaction": { + "event": {"prompt": "new scene"}, + "transition_chunks": transition_chunks, + }, + } + ) + err = ws.receive_json() + assert err["type"] == "error" + + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1" + assert ws.receive_json()["type"] == "session.done" + + engine_client.submit_interaction_async.assert_not_called() + + def test_prompt_update_engine_failure_returns_error(self, mocker: MockerFixture): + """Engine failures during interaction are surfaced to the client.""" + + async def mock_generate(*_args, **_kwargs): + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=False, + ) + await asyncio.sleep(0.3) + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=True, + ) + + app, _handler, engine_client = _build_test_app( + mocker=mocker, + streaming_chunks=[(b"mp4-chunk-0", False), (b"mp4-chunk-1", True)], + mock_generate=mock_generate, + ) + engine_client.submit_interaction_async.side_effect = RuntimeError("engine unavailable") + + with TestClient(app) as client: + with client.websocket_connect("/v1/realtime/video") as ws: + ws.send_json({"type": "session.start", "prompt": "initial"}) + assert ws.receive_json()["type"] == "video.start" + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0" + + ws.send_json( + { + "type": "session.interaction", + "interaction": { + "event_id": "ui-update-42", + "event": {"prompt": "new scene"}, + }, + } + ) + err = ws.receive_json() + assert err["type"] == "error" + assert "Failed to apply interaction" in err["message"] + assert err["code"] == "interaction_failed" + assert err["event_id"] == "ui-update-42" + + assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1" + assert ws.receive_json()["type"] == "session.done" + + engine_client.submit_interaction_async.assert_awaited_once() + + @pytest.mark.asyncio + async def test_prompt_update_accepted_via_async_handler(self, mocker: MockerFixture): + """interaction acknowledgement is sent while generation is in progress.""" + + release = asyncio.Event() + + async def mock_generate(*_args, **_kwargs): + await release.wait() + yield OmniRequestOutput.from_diffusion( + request_id="req-ws", + images=[_fake_video_frames(2)], + final_output_type="image", + finished=True, + ) + + handler, engine_client, ws = _build_handler_for_async_tests( + mocker, + mock_generate=mock_generate, + ) + + async def _send_interaction_after_delay() -> None: + await asyncio.sleep(0.05) + ws.enqueue( + json.dumps( + { + "type": "session.interaction", + "interaction": { + "event_id": "ui-update-42", + "event": {"prompt": "mid-stream update"}, + "transition_chunks": 2, + }, + } + ) + ) + + prompt_task = asyncio.create_task(_send_interaction_after_delay()) + session_task = asyncio.create_task(handler.handle_session(ws)) # type: ignore[arg-type] + await asyncio.sleep(0.15) + release.set() + await session_task + await prompt_task + + start = next(m for m in ws.sent_json if m.get("type") == "video.start") + request_id = start["request_id"] + assert any(m.get("type") == "session.interaction.queued" for m in ws.sent_json) + engine_client.submit_interaction_async.assert_awaited_once_with( + request_id, + interaction={ + "event_id": "ui-update-42", + "event": {"prompt": "mid-stream update"}, + "transition_chunks": 2, + }, + ) diff --git a/tests/entrypoints/openai_api/test_serving_video_stream.py b/tests/entrypoints/openai_api/test_serving_video_stream.py index de2fcc24d..fc8d83da6 100644 --- a/tests/entrypoints/openai_api/test_serving_video_stream.py +++ b/tests/entrypoints/openai_api/test_serving_video_stream.py @@ -138,6 +138,112 @@ async def test_receive_config_accepts_client_legacy_aliases(): assert config.frame_filter_threshold == 0.87 +@pytest.mark.asyncio +async def test_video_frame_ack_reports_receiver_buffer_state(): + ws = MockWebSocket( + [ + json.dumps( + { + "type": "session.config", + "model": "test", + "enable_frame_filter": False, + } + ), + json.dumps( + { + "type": "video.frame", + "data": _b64(_make_jpeg()), + "frame_id": "frame-7", + "pts_ms": 700, + "capture_ts_ms": 1234.5, + } + ), + json.dumps({"type": "video.done"}), + ] + ) + handler = QwenOmniStreamingVideoHandler(chat_service=object()) + + await handler.handle_session(ws) + + ack = next(message for message in ws.sent if message.get("type") == "video.frame.ack") + assert ack["frame_id"] == "frame-7" + assert ack["pts_ms"] == 700 + assert ack["capture_ts_ms"] == 1234.5 + assert ack["accepted"] is True + assert ack["buffered_frames"] == 1 + assert ack["server_receive_ts_ms"] > 0 + + +@pytest.mark.asyncio +async def test_video_frames_consumed_is_emitted_after_engine_uses_frame_prompt(): + class OneOutputEngine: + def generate(self, **_kwargs): + async def _gen(): + await asyncio.sleep(0.05) + yield _text_result("visible") + + return _gen() + + class CapturingHandler(QwenOmniStreamingVideoHandler): + async def _preprocess_to_engine_prompt(self, request): + return {"prompt": "with-video"} + + ws = MockWebSocket( + [ + json.dumps( + { + "type": "session.config", + "model": "test", + "modalities": ["text"], + "num_frames": 1, + "enable_frame_filter": False, + } + ), + json.dumps( + { + "type": "video.frame", + "data": _b64(_make_jpeg()), + "frame_id": "frame-9", + "pts_ms": 900, + "source_pts_ms": 880, + "quality_profile": "balanced", + } + ), + json.dumps({"type": "video.query", "text": "describe"}), + json.dumps({"type": "video.done"}), + ] + ) + handler = CapturingHandler( + chat_service=object(), + engine_client=OneOutputEngine(), + idle_timeout=2.0, + ) + + await handler.handle_session(ws) + + consumed = next(message for message in ws.sent if message.get("type") == "video.frames.consumed") + assert consumed["frame_ids"] == ["frame-9"] + assert consumed["latest_pts_ms"] == 900 + assert consumed["request_id"].startswith("video-") + assert consumed["model_selected_ts_ms"] > 0 + assert consumed["frames"] == [ + { + "frame_id": "frame-9", + "pts_ms": 900, + "source_pts_ms": 880, + "quality_profile": "balanced", + "receiver_received_ts_ms": consumed["frames"][0]["receiver_received_ts_ms"], + "decoded_ready_ts_ms": consumed["frames"][0]["decoded_ready_ts_ms"], + } + ] + assert consumed["frames"][0]["receiver_received_ts_ms"] > 0 + assert consumed["frames"][0]["decoded_ready_ts_ms"] >= consumed["frames"][0]["receiver_received_ts_ms"] + assert consumed["model_selected_ts_ms"] >= consumed["frames"][0]["decoded_ready_ts_ms"] + assert ws.sent.index(consumed) < next( + index for index, message in enumerate(ws.sent) if message.get("type") == "response.text.delta" + ) + + @pytest.mark.asyncio async def test_audio_in_video_sets_mm_processor_kwargs(): captured_requests = [] diff --git a/tests/test_speaker_metadata_persistence.py b/tests/entrypoints/openai_api/test_speaker_metadata_persistence.py similarity index 100% rename from tests/test_speaker_metadata_persistence.py rename to tests/entrypoints/openai_api/test_speaker_metadata_persistence.py diff --git a/tests/entrypoints/openai_api/test_speech_usage.py b/tests/entrypoints/openai_api/test_speech_usage.py index 8211dab88..2fba10516 100644 --- a/tests/entrypoints/openai_api/test_speech_usage.py +++ b/tests/entrypoints/openai_api/test_speech_usage.py @@ -16,6 +16,8 @@ from vllm_omni.entrypoints.openai.speech_usage import ( qwen3_tts_input_token_details, ) +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + # A deterministic stand-in for a real tokenizer: 1 token per whitespace word. def word_count(text: str) -> int: diff --git a/tests/entrypoints/openai_api/test_stage_params.py b/tests/entrypoints/openai_api/test_stage_params.py index f9fd40349..5db768b30 100644 --- a/tests/entrypoints/openai_api/test_stage_params.py +++ b/tests/entrypoints/openai_api/test_stage_params.py @@ -3,6 +3,7 @@ from types import SimpleNamespace +import pytest from vllm import SamplingParams from vllm_omni.entrypoints.openai.stage_params import ( @@ -12,6 +13,8 @@ from vllm_omni.entrypoints.openai.stage_params import ( ) from vllm_omni.inputs.data import OmniDiffusionSamplingParams +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + def test_resolve_stage_sampling_params_clones_stage_default(): default = SamplingParams(temperature=0.2, seed=11) diff --git a/tests/entrypoints/openai_api/test_tts_adapter.py b/tests/entrypoints/openai_api/test_tts_adapter.py index 2930a204d..5f6d5930a 100644 --- a/tests/entrypoints/openai_api/test_tts_adapter.py +++ b/tests/entrypoints/openai_api/test_tts_adapter.py @@ -15,6 +15,9 @@ from vllm_omni.entrypoints.openai.tts_adapters import ( ) from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import Qwen3TTSAdapter +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + # Every dedicated TTS model-type must have an adapter so the orchestrator's # uniform ``self._adapter.build(...)`` dispatch covers it. EXPECTED_MODEL_TYPES = { diff --git a/tests/entrypoints/openai_api/test_video_api_utils.py b/tests/entrypoints/openai_api/test_video_api_utils.py index 722819682..08d223c68 100644 --- a/tests/entrypoints/openai_api/test_video_api_utils.py +++ b/tests/entrypoints/openai_api/test_video_api_utils.py @@ -48,6 +48,71 @@ def test_encode_video_bytes_exports_frames_without_interpolation(monkeypatch): assert mux_calls[0]["audio"] is None +def test_float_frames_are_converted_without_stacking_full_video(monkeypatch): + frame = np.array( + [ + [[0.0, 0.5, 1.0, 0.2], [1.5, -0.5, 0.5, 0.8]], + ], + dtype=np.float32, + ) + original = frame.copy() + + def fail_stack(*args, **kwargs): + raise AssertionError("float video conversion must not stack all frames") + + monkeypatch.setattr(video_api_utils.np, "stack", fail_stack) + + frames = video_api_utils._coerce_video_to_uint8_frames([frame, frame]) + + expected = np.array([[[128, 191, 255], [255, 64, 191]]], dtype=np.uint8) + assert frames.flags.c_contiguous + np.testing.assert_array_equal(frames, np.array([expected, expected])) + np.testing.assert_array_equal(frame, original) + + +def test_two_dimensional_width_four_frames_are_not_treated_as_rgba(): + frame = np.arange(8, dtype=np.float32).reshape(2, 4) / 8 + + frames = video_api_utils._coerce_video_to_uint8_frames([frame, frame]) + + expected = np.rint(frame * 255).astype(np.uint8) + assert frames.shape == (2, 2, 4) + np.testing.assert_array_equal(frames, np.stack([expected, expected])) + + +def test_mixed_float_dtypes_preserve_stacked_rounding_semantics(): + frames = [ + np.full((1, 1, 3), 0.1, dtype=np.float16), + np.full((1, 1, 3), 0.2, dtype=np.float32), + ] + stacked = np.stack(frames) + expected = np.rint(np.clip(stacked, 0.0, 1.0) * 255.0).astype(np.uint8) + + actual = video_api_utils._coerce_video_to_uint8_frames(frames) + + assert expected[0, 0, 0, 0] == 25 + np.testing.assert_array_equal(actual, expected) + + +@pytest.mark.parametrize("frame_count", [3, 4]) +def test_channel_last_video_tensor_preserves_channel_sized_frame_count(frame_count): + video = torch.arange(frame_count * 2 * 5 * 3, dtype=torch.uint8).reshape(frame_count, 2, 5, 3) + + frames = video_api_utils._coerce_video_to_uint8_frames(video) + + assert frames.shape == (frame_count, 2, 5, 3) + np.testing.assert_array_equal(frames, video.numpy()) + + +def test_channel_first_video_tensor_is_converted_to_channel_last(): + video = torch.arange(3 * 5 * 2 * 6, dtype=torch.uint8).reshape(3, 5, 2, 6) + + frames = video_api_utils._coerce_video_to_uint8_frames(video) + + assert frames.shape == (5, 2, 6, 3) + np.testing.assert_array_equal(frames, video.permute(1, 2, 3, 0).numpy()) + + def test_fragmented_mp4_video_encoder_reuses_single_muxer(monkeypatch): muxers = [] diff --git a/tests/entrypoints/openai_api/test_video_frame_filter.py b/tests/entrypoints/openai_api/test_video_frame_filter.py index a734aec75..ecada7a55 100644 --- a/tests/entrypoints/openai_api/test_video_frame_filter.py +++ b/tests/entrypoints/openai_api/test_video_frame_filter.py @@ -12,6 +12,8 @@ from tests.entrypoints.openai_api.conftest_video import ( ) from vllm_omni.entrypoints.openai.video_frame_filter import FrameSimilarityFilter +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + class TestFrameSimilarityFilter: def test_first_frame_always_retained(self): @@ -29,8 +31,8 @@ class TestFrameSimilarityFilter: assert f.should_retain(make_jpeg(255, 255, 255)) is True assert f.should_retain(make_jpeg(0, 0, 0)) is True - def test_low_threshold_keeps_slightly_different(self): - f = FrameSimilarityFilter(threshold=0.50) + def test_high_threshold_keeps_slightly_different(self): + f = FrameSimilarityFilter(threshold=0.999) assert f.should_retain(make_jpeg(100, 100, 100)) is True assert f.should_retain(make_jpeg(110, 110, 110)) is True diff --git a/tests/entrypoints/openai_api/test_video_server.py b/tests/entrypoints/openai_api/test_video_server.py index ff69a2bf2..5af971755 100644 --- a/tests/entrypoints/openai_api/test_video_server.py +++ b/tests/entrypoints/openai_api/test_video_server.py @@ -12,11 +12,12 @@ import os import threading import time from contextlib import asynccontextmanager +from pathlib import Path from types import SimpleNamespace import numpy as np import pytest -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from PIL import Image from pytest_mock import MockerFixture @@ -61,12 +62,24 @@ class FakeAsyncOmni: def __init__(self): self.stage_configs = [SimpleNamespace(stage_type="diffusion")] self.default_sampling_params_list = [OmniDiffusionSamplingParams()] + self.model_class_name = "WanPipeline" self.captured_prompt = None + self.captured_reference_video_bytes = None self.captured_sampling_params_list = None + def get_diffusion_od_config(self): + return SimpleNamespace(model_class_name=self.model_class_name) + async def generate(self, prompt, request_id, sampling_params_list): self.captured_prompt = prompt self.captured_sampling_params_list = sampling_params_list + reference_videos = prompt.get("multi_modal_data", {}).get("video") + if ( + isinstance(reference_videos, list) + and reference_videos + and all(isinstance(item, str) for item in reference_videos) + ): + self.captured_reference_video_bytes = [Path(item).read_bytes() for item in reference_videos] num_outputs = sampling_params_list[0].num_outputs_per_prompt videos = [object() for _ in range(num_outputs)] yield MockVideoResult(videos) @@ -478,7 +491,7 @@ def test_v2v_video_generation_form(test_client, mocker: MockerFixture): assert input_video[0].size == (32, 24) -def test_v2v_video_generation_with_video_reference_form(test_client, mocker: MockerFixture): +def test_r9_typed_video_reference_form(test_client, mocker: MockerFixture): mocker.patch( "vllm_omni.entrypoints.openai.serving_video._encode_video_bytes", return_value=b"fake-video", @@ -497,8 +510,113 @@ def test_v2v_video_generation_with_video_reference_form(test_client, mocker: Moc engine = test_client.app.state.openai_serving_video._engine_client input_video = engine.captured_prompt["multi_modal_data"]["video"] - assert len(input_video) == 2 - assert input_video[0].size == (32, 24) + assert len(input_video) == 1 + assert all(isinstance(item, str) for item in input_video) + assert engine.captured_reference_video_bytes is not None + assert len(engine.captured_reference_video_bytes) == 1 + assert b"ftyp" in engine.captured_reference_video_bytes[0][:32] + + +def test_r5_mixed_image_video_audio_references_and_fanout(test_client, mocker: MockerFixture): + mocker.patch( + "vllm_omni.entrypoints.openai.serving_video._encode_video_bytes", + return_value=b"fake-video", + ) + test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline" + response = test_client.post( + "/v1/videos", + data={ + "prompt": "Use both references.", + "image_reference": json.dumps( + [ + {"image_url": _make_test_image_data_url((40, 24))}, + {"image_url": _make_test_image_data_url((40, 24))}, + ] + ), + "video_reference": json.dumps( + {"video_url": _make_test_video_data_url((32, 24), 2)}, + ), + "audio_reference": json.dumps( + [{"audio_url": "data:audio/mp3;base64,ZmFrZQ=="}], + ), + "extra_params": json.dumps({"task": "ref2va", "frame_indices": [0, -1], "start_time_seconds": 1.5}), + "num_outputs_per_prompt": "2", + }, + ) + + assert response.status_code == 200 + video_id = response.json()["id"] + _wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value) + engine = test_client.app.state.openai_serving_video._engine_client + multi_modal_data = engine.captured_prompt["multi_modal_data"] + assert len(multi_modal_data["image"]) == 2 + assert len(multi_modal_data["video"]) == 1 + assert all(isinstance(item, str) for item in multi_modal_data["video"]) + assert engine.captured_reference_video_bytes is not None + assert len(engine.captured_reference_video_bytes) == 1 + assert b"ftyp" in engine.captured_reference_video_bytes[0][:32] + assert isinstance(multi_modal_data["audio"], str) + assert engine.captured_sampling_params_list[0].num_outputs_per_prompt == 2 + + +@pytest.mark.parametrize("endpoint", ["/v1/videos", "/v1/videos/sync"]) +def test_multi_video_generation_preserves_uploaded_files_until_generation( + endpoint, + test_client, + mocker: MockerFixture, +): + mocker.patch( + "vllm_omni.entrypoints.openai.serving_video._encode_video_bytes", + return_value=b"fake-video", + ) + test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline" + response = test_client.post( + endpoint, + data={ + "prompt": "Composite the subject from the first video into the second.", + "extra_params": json.dumps({"task": "ref2va", "duration": 15.0}), + }, + files=[ + ("input_references", ("subject.png", _make_test_image_bytes((40, 24)), "image/png")), + ("input_references", ("subject.mp4", b"subject-video", "video/mp4")), + ("input_references", ("background.mov", b"background-video", "video/quicktime")), + ("input_references", ("voice.wav", b"reference-audio", "audio/wav")), + ], + ) + + assert response.status_code == 200 + if endpoint.endswith("/sync"): + assert response.content == b"fake-video" + else: + video_id = response.json()["id"] + _wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value) + + engine = test_client.app.state.openai_serving_video._engine_client + input_videos = engine.captured_prompt["multi_modal_data"]["video"] + assert engine.captured_reference_video_bytes == [b"subject-video", b"background-video"] + assert len(input_videos) == 2 + assert all(not Path(path).exists() for path in input_videos) + assert isinstance(engine.captured_prompt["multi_modal_data"]["image"], Image.Image) + assert isinstance(engine.captured_prompt["multi_modal_data"]["audio"], str) + assert engine.captured_sampling_params_list[0].extra_args["task"] == "ref2va" + assert engine.captured_sampling_params_list[0].extra_args["duration"] == 15.0 + + +def test_decode_video_bytes_can_keep_first_frames(): + from vllm_omni.entrypoints.openai.video_api_utils import _decode_video_bytes + + frames = _decode_video_bytes( + _make_test_video_bytes((32, 24), num_frames=6), + source="input_reference", + max_frames=2, + keep="first", + ) + + assert len(frames) == 2 + assert frames.fps == pytest.approx(8.0) + red_means = [np.asarray(frame)[:, :, 0].mean() for frame in frames] + assert red_means[0] < red_means[1] + assert red_means[1] < 100 def test_decode_video_bytes_can_keep_last_frames(): @@ -1136,17 +1254,67 @@ def test_rejects_input_reference_and_image_reference_together(test_client): assert "only one of input_reference, image_reference, or video_reference" in response.json()["detail"].lower() -def test_rejects_image_reference_and_video_reference_together(test_client): +def test_r10_typed_image_and_video_reference_form(test_client, mocker: MockerFixture): + mocker.patch( + "vllm_omni.entrypoints.openai.serving_video._encode_video_bytes", + return_value=b"fake-video", + ) + test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline" response = test_client.post( "/v1/videos", data={ - "prompt": "bad refs", - "image_reference": '{"image_url": "https://example.com/cat.png"}', - "video_reference": '{"video_url": "https://example.com/cat.mp4"}', + "prompt": "use both references", + "image_reference": json.dumps({"image_url": _make_test_image_data_url((40, 24))}), + "video_reference": json.dumps({"video_url": _make_test_video_data_url((32, 24), 2)}), }, ) + assert response.status_code == 200 + _wait_for_status(test_client, response.json()["id"], VideoGenerationStatus.COMPLETED.value) + + +def test_generic_video_model_rejects_mixed_image_and_video_references(test_client): + response = test_client.post( + "/v1/videos/sync", + data={ + "prompt": "use both references", + "image_reference": json.dumps({"image_url": _make_test_image_data_url((40, 24))}), + "video_reference": json.dumps({"video_url": _make_test_video_data_url((32, 24), 2)}), + }, + ) + assert response.status_code == 400 - assert "only one of input_reference, image_reference, or video_reference" in response.json()["detail"].lower() + assert "does not support mixed image and video" in response.json()["detail"].lower() + + +def test_h3_multipart_rejects_bmp_image_reference(test_client): + image = Image.new("RGB", (64, 64), color="blue") + image_buffer = io.BytesIO() + image.save(image_buffer, format="BMP") + + test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline" + response = test_client.post( + "/v1/videos/sync", + data={"prompt": "reject unsupported image container", "extra_params": '{"task":"ref2va"}'}, + files=[("input_references", ("reference.bmp", image_buffer.getvalue(), "image/bmp"))], + ) + + assert response.status_code == 400 + assert "must use jpg" in response.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_h3_upload_limit_checks_declared_size_before_read(): + class OversizedUpload: + size = api_server.MINIMAX_H3_MAX_REFERENCE_IMAGE_BYTES + 1 + + async def read(self, _size): + raise AssertionError("the oversized upload must be rejected before reading") + + with pytest.raises(HTTPException, match="size limit"): + await api_server._read_upload_limited( + OversizedUpload(), + max_bytes=api_server.MINIMAX_H3_MAX_REFERENCE_IMAGE_BYTES, + ) def test_invalid_seconds_returns_422(test_client): diff --git a/tests/entrypoints/openai_api/test_video_stream_handler.py b/tests/entrypoints/openai_api/test_video_stream_handler.py index a5ce3d616..e382b5e32 100644 --- a/tests/entrypoints/openai_api/test_video_stream_handler.py +++ b/tests/entrypoints/openai_api/test_video_stream_handler.py @@ -22,6 +22,9 @@ from vllm_omni.entrypoints.openai.video_stream_session import ( VideoStreamHandler, ) +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/entrypoints/openai_api/test_video_stream_session.py b/tests/entrypoints/openai_api/test_video_stream_session.py index b0a12b1e4..b91723c26 100644 --- a/tests/entrypoints/openai_api/test_video_stream_session.py +++ b/tests/entrypoints/openai_api/test_video_stream_session.py @@ -15,6 +15,9 @@ from vllm_omni.entrypoints.openai.video_stream_session import ( VideoStreamSession, ) +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + # --------------------------------------------------------------------------- # VideoStreamConfig # --------------------------------------------------------------------------- diff --git a/tests/entrypoints/test_async_omni_diffusion_config.py b/tests/entrypoints/test_async_omni_diffusion_config.py index ba132dbd2..ecb0f20e8 100644 --- a/tests/entrypoints/test_async_omni_diffusion_config.py +++ b/tests/entrypoints/test_async_omni_diffusion_config.py @@ -85,6 +85,8 @@ def test_default_stage_config_defaults_nullified_parallel_size_kwargs(): "tensor_parallel_size": None, "enable_expert_parallel": None, "enforce_eager": None, + "diffusion_compile_granularity": None, + "diffusion_compile_dynamic": None, } )[0] @@ -94,6 +96,8 @@ def test_default_stage_config_defaults_nullified_parallel_size_kwargs(): assert parallel_config.tensor_parallel_size == 1 assert parallel_config.enable_expert_parallel is False assert stage_cfg["engine_args"]["enforce_eager"] is False + assert stage_cfg["engine_args"]["diffusion_compile_granularity"] == "regional" + assert stage_cfg["engine_args"]["diffusion_compile_dynamic"] is True def test_default_stage_config_propagates_ulysses_mode(): @@ -236,6 +240,28 @@ def test_serve_cli_accepts_ulysses_mode(): assert parallel_config.ulysses_mode == "advanced_uaa" +def test_serve_cli_forwards_model_defined_task_type_to_diffusion_stage(): + parser = TrackingArgumentParser() + subparsers = parser.add_subparsers(dest="command") + OmniServeCommand().subparser_init(subparsers) + + args = parser.parse_args( + [ + "serve", + "MiniMaxAI/MiniMax-H3", + "--omni", + "--task-type", + "fl2va", + ] + ) + + explicit_kwargs = args.get_explicit_kwargs_dict() + stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + + assert args.task_type == "fl2va" + assert stage_cfg["engine_args"]["task_type"] == "fl2va" + + def test_serve_cli_accepts_diffusion_pipeline_profiler_flag(): """Ensure diffusion serve CLI exposes the profiler switch.""" parser = TrackingArgumentParser() @@ -258,6 +284,62 @@ def test_serve_cli_accepts_diffusion_pipeline_profiler_flag(): assert stage_cfg["engine_args"]["enable_diffusion_pipeline_profiler"] is True +def test_serve_cli_forwards_distributed_offload_residency(): + """Ensure the two-GPU DLO placement controls reach the diffusion stage.""" + parser = TrackingArgumentParser() + subparsers = parser.add_subparsers(dest="command") + OmniServeCommand().subparser_init(subparsers) + + args = parser.parse_args( + [ + "serve", + "MiniMaxAI/MiniMax-H3", + "--omni", + "--enable-distributed-layerwise-offload", + "--dlo-no-use-allgather", + "--dlo-resident-layers", + "20", + ] + ) + + explicit_kwargs = args.get_explicit_kwargs_dict() + stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + engine_args = stage_cfg["engine_args"] + + assert args.enable_distributed_layerwise_offload is True + assert args.dlo_use_allgather is False + assert args.dlo_resident_layers == 20 + assert engine_args["enable_distributed_layerwise_offload"] is True + assert engine_args["dlo_use_allgather"] is False + assert engine_args["dlo_resident_layers"] == 20 + + +def test_serve_cli_accepts_diffusion_compile_controls(): + """Ensure both compile controls reach the diffusion stage.""" + parser = TrackingArgumentParser() + subparsers = parser.add_subparsers(dest="command") + OmniServeCommand().subparser_init(subparsers) + + args = parser.parse_args( + [ + "serve", + "Lightricks/LTX-Video-0.9.8-13B-distilled", + "--omni", + "--diffusion-compile-granularity", + "full", + "--no-diffusion-compile-dynamic", + ] + ) + + explicit_kwargs = args.get_explicit_kwargs_dict() + stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + + assert args.diffusion_compile_granularity == "full" + assert args.diffusion_compile_dynamic is False + assert stage_cfg["engine_args"]["diffusion_compile_granularity"] == "full" + assert stage_cfg["engine_args"]["diffusion_compile_dynamic"] is False + + def test_serve_cli_accepts_diffusion_attention_backend(): """Ensure diffusion serve CLI exposes the shorthand backend flag.""" parser = TrackingArgumentParser() @@ -355,6 +437,7 @@ def test_resolve_stage_configs_injects_additional_config_into_diffusion_stage(mo "stage_configs_path": "dummy.yaml", "additional_config": {"torchair_graph_config": {"enabled": True}}, }, + trust_remote_code=False, ) assert not hasattr(stage_configs[0].engine_args, "additional_config") @@ -392,6 +475,7 @@ def test_resolve_stage_configs_injects_quantization_config_into_diffusion_stage( "stage_configs_path": "dummy.yaml", "quantization_config": {"method": "bitsandbytes"}, }, + trust_remote_code=False, ) assert stage_configs[0].engine_args.quantization_config == {"method": "bitsandbytes"} diff --git a/tests/entrypoints/test_async_omni_duplex.py b/tests/entrypoints/test_async_omni_duplex.py new file mode 100644 index 000000000..62f3f49ad --- /dev/null +++ b/tests/entrypoints/test_async_omni_duplex.py @@ -0,0 +1,600 @@ +from types import SimpleNamespace + +import pytest + +from vllm_omni.engine.messages import OutputMessage +from vllm_omni.entrypoints.async_omni import AsyncOmni +from vllm_omni.entrypoints.client_request_state import ClientRequestState +from vllm_omni.experimental.fullduplex.engine.duplex_runtime import ( + DuplexOutputAction, + DuplexOutputDecision, + duplex_resource_request_id, +) +from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseActivity +from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence +from vllm_omni.experimental.fullduplex.output import attach_duplex_output_decision +from vllm_omni.experimental.fullduplex.request_client import DuplexRequestClient +from vllm_omni.metrics.stats import OrchestratorAggregator, StageRequestStats, StageStats +from vllm_omni.outputs import OmniRequestOutput + + +def test_async_omni_uses_extracted_duplex_request_client(): + app = object.__new__(AsyncOmni) + app.engine = SimpleNamespace(num_stages=2) + app.request_states = {} + app._duplex_request_client = None + + client = app._get_duplex_request_client() + + assert isinstance(client, DuplexRequestClient) + assert not hasattr(client, "_async_omni") + + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +@pytest.mark.asyncio +async def test_async_omni_open_duplex_forwards_session_config_and_timeout(): + calls = [] + output_handler_starts = 0 + + async def open_duplex_session_async(session_id, **kwargs): + calls.append((session_id, kwargs)) + return {"ok": True} + + app = object.__new__(AsyncOmni) + app.engine = SimpleNamespace(open_duplex_session_async=open_duplex_session_async) + + def start_output_handler(): + nonlocal output_handler_starts + output_handler_starts += 1 + + app._final_output_handler = start_output_handler + + result = await app.open_duplex_session_async( + "sid", + fence=DuplexFence("sid"), + capabilities={"implementation_level": "model_native_duplex"}, + session_config={"instructions": "Be brief.", "idle_timeout_s": 30}, + timeout=7.5, + ) + + assert result == {"ok": True} + assert output_handler_starts == 1 + assert calls == [ + ( + "sid", + { + "session_mode": "duplex", + "capabilities": {"implementation_level": "model_native_duplex"}, + "session_config": {"instructions": "Be brief.", "idle_timeout_s": 30}, + "fence": DuplexFence("sid"), + "timeout": 7.5, + }, + ) + ] + + +@pytest.mark.asyncio +async def test_async_omni_duplex_runtime_controls_forward_timeout(): + calls = [] + cancelled_fence = DuplexFence("sid", epoch=2, turn_id=3) + next_fence = DuplexFence("sid", epoch=3, turn_id=3) + + async def append_duplex_input_async(session_id, **kwargs): + calls.append(("append", session_id, kwargs)) + return {"ok": True} + + async def signal_duplex_turn_async(session_id, **kwargs): + calls.append(("signal", session_id, kwargs)) + return {"ok": True} + + async def close_duplex_session_async(session_id, **kwargs): + calls.append(("close", session_id, kwargs)) + return {"ok": True} + + async def touch_duplex_session_async(session_id, **kwargs): + calls.append(("touch", session_id, kwargs)) + return {"ok": True} + + async def resume_duplex_session_async(session_id, **kwargs): + calls.append(("resume", session_id, kwargs)) + return {"ok": True} + + app = object.__new__(AsyncOmni) + app.request_states = {} + app._final_output_handler = lambda: None + app.engine = SimpleNamespace( + append_duplex_input_async=append_duplex_input_async, + signal_duplex_turn_async=signal_duplex_turn_async, + close_duplex_session_async=close_duplex_session_async, + touch_duplex_session_async=touch_duplex_session_async, + resume_duplex_session_async=resume_duplex_session_async, + ) + + await app.append_duplex_input_async( + "sid", + mode="append_audio_chunk", + payload={}, + fence=cancelled_fence, + timeout=12.5, + ) + await app.signal_duplex_turn_async( + "sid", + event="barge_in", + fence=cancelled_fence, + next_fence=next_fence, + timeout=13.5, + ) + await app.close_duplex_session_async("sid", reason="done", fence=next_fence, timeout=14.5) + await app.touch_duplex_session_async( + "sid", + fence=next_fence, + activity=DuplexLeaseActivity.HEARTBEAT, + timeout=15.5, + ) + await app.resume_duplex_session_async( + "sid", + fence=next_fence, + expected_lease_generation=3, + timeout=16.5, + ) + + assert calls == [ + ( + "append", + "sid", + { + "mode": "append_audio_chunk", + "payload": {}, + "final": False, + "expected_epoch": None, + "fence": cancelled_fence, + "timeout": 12.5, + }, + ), + ( + "signal", + "sid", + { + "event": "barge_in", + "fence": cancelled_fence, + "next_fence": next_fence, + "timeout": 13.5, + }, + ), + ( + "close", + "sid", + { + "reason": "done", + "fence": next_fence, + "timeout": 14.5, + }, + ), + ( + "touch", + "sid", + { + "fence": next_fence, + "activity": DuplexLeaseActivity.HEARTBEAT, + "timeout": 15.5, + }, + ), + ( + "resume", + "sid", + { + "fence": next_fence, + "expected_lease_generation": 3, + "timeout": 16.5, + }, + ), + ] + + +@pytest.mark.asyncio +async def test_async_omni_duplex_append_can_defer_data_plane_output_collection(): + app = object.__new__(AsyncOmni) + request_id = duplex_resource_request_id(DuplexFence("sid"), "stage0") + + async def append_duplex_input_async(session_id, **kwargs): + del session_id, kwargs + request_state = app.request_states.get(request_id) + assert request_state is not None + assert request_state.metrics is not None + return { + "stage_results": [ + { + "result": { + "data_plane_append": True, + "request_id": request_id, + "response_stage_id": 1, + } + } + ] + } + + app.engine = SimpleNamespace(append_duplex_input_async=append_duplex_input_async) + app.request_states = {} + app._final_output_handler = lambda: None + + result = await app.append_duplex_input_async( + "sid", + mode="append_audio_chunk", + payload={}, + fence=DuplexFence("sid"), + collect_outputs=False, + ) + + assert "data_plane_outputs" not in result + + +@pytest.mark.asyncio +async def test_async_omni_duplex_append_forwards_fence(): + calls = [] + + async def append_duplex_input_async(session_id, **kwargs): + calls.append((session_id, kwargs)) + return {"ok": True} + + app = object.__new__(AsyncOmni) + app.request_states = {} + app._final_output_handler = lambda: None + app.engine = SimpleNamespace(append_duplex_input_async=append_duplex_input_async) + fence = DuplexFence("sid", epoch=1, turn_id=2) + + await app.append_duplex_input_async( + "sid", + mode="append_audio_chunk", + payload={}, + fence=fence, + collect_outputs=False, + ) + + assert calls[0][1]["fence"] is fence + + +@pytest.mark.asyncio +async def test_async_omni_duplex_collect_waits_for_response_stage(): + app = object.__new__(AsyncOmni) + req_state = ClientRequestState("duplex-sid-e0-stage0") + stage0_output = OmniRequestOutput( + request_id="duplex-sid-e0-stage0", + stage_id=0, + finished=False, + ) + stage1_output = OmniRequestOutput( + request_id="duplex-sid-e0-stage0", + stage_id=1, + final_output_type="audio", + finished=False, + ) + await req_state.queue.put( + OutputMessage( + request_id="duplex-sid-e0-stage0", + stage_id=0, + engine_outputs=stage0_output, + finished=False, + ) + ) + await req_state.queue.put( + OutputMessage( + request_id="duplex-sid-e0-stage0", + stage_id=1, + engine_outputs=stage1_output, + finished=False, + ) + ) + + outputs = await app._collect_duplex_data_plane_outputs( + "duplex-sid-e0-stage0", + req_state, + response_stage_id=1, + timeout=1.0, + ) + + assert outputs == [stage1_output] + + +@pytest.mark.asyncio +async def test_duplex_request_client_retains_output_route_at_segment_terminal(): + request_id = "duplex-sid-e0-stage0" + request_state = ClientRequestState(request_id) + request_states = {request_id: request_state} + output = OmniRequestOutput( + request_id=request_id, + stage_id=1, + final_output_type="audio", + finished=True, + ) + await request_state.queue.put( + OutputMessage( + request_id=request_id, + stage_id=1, + engine_outputs=output, + finished=True, + ) + ) + client = DuplexRequestClient( + SimpleNamespace(), + SimpleNamespace( + request_states=request_states, + num_stages=2, + log_stats=False, + ), + ) + + outputs = await client.collect_outputs( + request_id, + request_state, + response_stage_id=1, + timeout=1.0, + ) + + assert outputs == [output] + assert request_states[request_id] is request_state + + +@pytest.mark.asyncio +async def test_async_omni_duplex_direct_output_keeps_stage_metrics(): + app = object.__new__(AsyncOmni) + app._duplex_request_client = None + app._final_output_handler = lambda: None + request_id = "duplex-sid-e0-stage0" + req_state = ClientRequestState(request_id) + decision = DuplexOutputDecision( + action=DuplexOutputAction.DIRECT_RESPONSE, + metadata={ + "duplex_direct_response": True, + "duplex_native_decision": "speak", + }, + ) + direct_output = attach_duplex_output_decision( + OmniRequestOutput( + request_id=request_id, + stage_id=0, + final_output_type="text", + finished=True, + ), + decision, + ) + await req_state.queue.put( + OutputMessage( + request_id=request_id, + stage_id=0, + engine_outputs=direct_output, + metrics=StageRequestStats( + batch_id=1, + batch_size=1, + num_tokens_in=160, + num_tokens_out=8, + stage_gen_time_ms=80.0, + rx_transfer_bytes=0, + rx_decode_time_ms=0.0, + rx_in_flight_time_ms=0.0, + stage_stats=StageStats(total_token=8, total_gen_time_ms=80.0), + vllm_ttft_ms=12.5, + vllm_tpot_ms=9.0, + vllm_itl_ms=8.75, + vllm_itls_ms=[8.0, 9.5], + ), + finished=True, + ) + ) + + outputs = await app._collect_duplex_data_plane_outputs( + request_id, + req_state, + response_stage_id=1, + timeout=1.0, + ) + + assert outputs == [direct_output] + assert direct_output.metrics["stage_metrics"]["0"]["vllm_ttft_ms"] == 12.5 + assert direct_output.metrics["stage_metrics"]["0"]["vllm_tpot_ms"] == 9.0 + assert direct_output.metrics["stage_metrics"]["0"]["vllm_itls_ms"] == [8.0, 9.5] + + +@pytest.mark.asyncio +async def test_duplex_metrics_cursor_isolates_resumable_collect_windows(): + app = object.__new__(AsyncOmni) + app._duplex_request_client = None + app._final_output_handler = lambda: None + request_id = "duplex-sid-e0-stage0" + req_state = ClientRequestState(request_id) + req_state.metrics = OrchestratorAggregator( + num_stages=2, + log_stats=False, + wall_start_ts=0.0, + final_stage_id_for_e2e=1, + ) + + def stage_message(*, batch_id: int, tokens: int, ttft_ms: float, itls: list[float]) -> OutputMessage: + decision = DuplexOutputDecision( + action=DuplexOutputAction.DIRECT_RESPONSE, + metadata={"duplex_direct_response": True, "duplex_native_decision": "speak"}, + ) + output = attach_duplex_output_decision( + OmniRequestOutput( + request_id=request_id, + stage_id=0, + final_output_type="text", + finished=True, + ), + decision, + ) + return OutputMessage( + request_id=request_id, + stage_id=0, + engine_outputs=output, + metrics=StageRequestStats( + batch_id=batch_id, + batch_size=1, + num_tokens_in=160, + num_tokens_out=tokens, + stage_gen_time_ms=80.0, + rx_transfer_bytes=0, + rx_decode_time_ms=0.0, + rx_in_flight_time_ms=0.0, + stage_stats=StageStats(total_token=tokens, total_gen_time_ms=80.0), + vllm_ttft_ms=ttft_ms, + vllm_tpot_ms=9.0, + vllm_itl_ms=sum(itls) / len(itls), + vllm_itls_ms=itls, + ), + finished=True, + ) + + await req_state.queue.put(stage_message(batch_id=1, tokens=71, ttft_ms=62.5, itls=[10.0, 11.0])) + first = await app._collect_duplex_data_plane_outputs( + request_id, + req_state, + response_stage_id=1, + timeout=1.0, + ) + await req_state.queue.put(stage_message(batch_id=2, tokens=155, ttft_ms=81.0, itls=[12.0, 13.0, 14.0])) + second = await app._collect_duplex_data_plane_outputs( + request_id, + req_state, + response_stage_id=1, + timeout=1.0, + ) + + assert first[0].metrics["stage_metrics"]["0"]["num_tokens_out"] == 71 + second_stage0 = second[0].metrics["stage_metrics"]["0"] + assert second_stage0["num_tokens_out"] == 155 + assert second_stage0["vllm_ttft_ms"] == 81.0 + assert second_stage0["vllm_itls_ms"] == [12.0, 13.0, 14.0] + + +def test_async_omni_duplex_direct_output_prefers_outer_control_metadata(): + decision = DuplexOutputDecision( + action=DuplexOutputAction.DIRECT_RESPONSE, + metadata={ + "duplex_direct_response": True, + "duplex_native_decision": "listen", + }, + ) + output = attach_duplex_output_decision( + OmniRequestOutput( + request_id="duplex-sid-e0-stage0", + stage_id=0, + request_output=SimpleNamespace( + outputs=[], + multimodal_output=SimpleNamespace(kind="processed-payload"), + _custom_output={"inner": "completion"}, + ), + ), + decision, + ) + + assert AsyncOmni._is_direct_duplex_data_plane_response(output) + + +def test_async_omni_drops_unregistered_duplex_prefixed_output(): + app = object.__new__(AsyncOmni) + app.request_states = {} + output = OmniRequestOutput(request_id="duplex-unregistered-e0-stage0", stage_id=0) + + keep_processing, request_id, stage_id, request_state = app._handle_output_message( + OutputMessage( + request_id=output.request_id, + stage_id=0, + engine_outputs=output, + finished=False, + ) + ) + + assert keep_processing is True + assert request_id is stage_id is request_state is None + assert app.request_states == {} + + +@pytest.mark.asyncio +async def test_async_omni_duplex_collect_wraps_raw_response_stage_output(): + app = object.__new__(AsyncOmni) + app.log_stats = False + app._enable_ar_profiler = False + app.request_states = {} + app.prom_metrics = SimpleNamespace( + set_running=lambda value: None, + set_waiting=lambda value: None, + ) + app.engine = SimpleNamespace( + num_stages=2, + get_stage_metadata=lambda stage_id: SimpleNamespace( + final_output=stage_id == 1, + final_output_type="audio", + ), + ) + req_state = ClientRequestState("duplex-sid-e0-stage0") + raw_stage1_output = SimpleNamespace( + finished=False, + outputs=[], + stage_durations={}, + peak_memory_mb=0.0, + final_output_type="audio", + ) + await req_state.queue.put( + OutputMessage( + request_id="duplex-sid-e0-stage0", + stage_id=1, + engine_outputs=raw_stage1_output, + finished=False, + ) + ) + + outputs = await app._collect_duplex_data_plane_outputs( + "duplex-sid-e0-stage0", + req_state, + response_stage_id=1, + timeout=1.0, + ) + + assert len(outputs) == 1 + assert outputs[0].request_id == "duplex-sid-e0-stage0" + assert outputs[0].finished is False + assert outputs[0].stage_id == 1 + assert outputs[0].final_output_type == "audio" + assert outputs[0].request_output is raw_stage1_output + + +def test_async_omni_duplex_request_info_includes_response_stage(): + request_id, response_stage_id = AsyncOmni._duplex_data_plane_request_info( + { + "stage_results": [ + { + "result": { + "data_plane_append": True, + "request_id": "duplex-sid-e0-stage0", + "response_stage_id": 1, + } + } + ] + } + ) + + assert request_id == "duplex-sid-e0-stage0" + assert response_stage_id == 1 + + +@pytest.mark.asyncio +async def test_duplex_request_client_retains_output_route_when_close_fails(): + fence = DuplexFence("sid-close-route") + request_id = duplex_resource_request_id(fence, "stage0") + request_states = {request_id: ClientRequestState(request_id)} + + async def close_duplex_session_async(session_id, **kwargs): + del session_id, kwargs + raise RuntimeError("close failed") + + client = DuplexRequestClient( + SimpleNamespace(close_duplex_session_async=close_duplex_session_async), + SimpleNamespace(request_states=request_states), + ) + + with pytest.raises(RuntimeError, match="close failed"): + await client.close(fence.session_id, reason="test", fence=fence, timeout=1.0) + + assert request_id in request_states diff --git a/tests/entrypoints/test_async_omni_prompt_update.py b/tests/entrypoints/test_async_omni_prompt_update.py new file mode 100644 index 000000000..f3edeeec7 --- /dev/null +++ b/tests/entrypoints/test_async_omni_prompt_update.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Entrypoint contract tests for ``AsyncOmni.submit_interaction_async``.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from vllm_omni.entrypoints.async_omni import AsyncOmni +from vllm_omni.entrypoints.client_request_state import ClientRequestState +from vllm_omni.inputs.data import OmniInteractionPrompt + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _make_async_omni(*, num_stages: int = 1, stage_type: str = "diffusion") -> AsyncOmni: + omni = object.__new__(AsyncOmni) + omni.log_stats = False + omni.request_states = { + "external-abc-uuid-1": ClientRequestState( + request_id="external-abc-uuid-1", + external_request_id="external-abc", + queue=AsyncMock(), + ), + "external-abc-uuid-2": ClientRequestState( + request_id="external-abc-uuid-2", + external_request_id="external-abc", + queue=AsyncMock(), + ), + "other-req-uuid": ClientRequestState( + request_id="other-req-uuid", + external_request_id="other-req", + queue=AsyncMock(), + ), + } + omni.engine = SimpleNamespace( # pyright: ignore[reportAttributeAccessIssue] + num_stages=num_stages, + get_stage_metadata=lambda stage_id: SimpleNamespace(stage_type=stage_type), + submit_interaction_async=AsyncMock(), + ) + return omni + + +def _prompt_interaction(prompt: str = "new prompt", transition_chunks: int | None = None) -> OmniInteractionPrompt: + interaction: OmniInteractionPrompt = {"event_id": "ui-update-1", "event": {"prompt": prompt}} + if transition_chunks is not None: + interaction["transition_chunks"] = transition_chunks + return interaction + + +@pytest.mark.asyncio +async def test_submit_interaction_async_maps_external_to_internal_id() -> None: + omni = _make_async_omni() + with pytest.raises(ValueError, match="exactly one active request"): + await omni.submit_interaction_async("external-abc", interaction=_prompt_interaction(transition_chunks=2)) + + omni.request_states.pop("external-abc-uuid-2") + await omni.submit_interaction_async( + "external-abc", + interaction=_prompt_interaction(transition_chunks=2), + ) + omni.engine.submit_interaction_async.assert_awaited_once_with( # pyright: ignore[reportAttributeAccessIssue] + "external-abc-uuid-1", + interaction=_prompt_interaction(transition_chunks=2), + ) + + +@pytest.mark.asyncio +async def test_submit_interaction_async_passes_missing_transition_chunks() -> None: + omni = _make_async_omni() + omni.request_states.pop("external-abc-uuid-2") + await omni.submit_interaction_async("external-abc", interaction=_prompt_interaction()) + omni.engine.submit_interaction_async.assert_awaited_once_with( # pyright: ignore[reportAttributeAccessIssue] + "external-abc-uuid-1", + interaction=_prompt_interaction(), + ) + + +@pytest.mark.asyncio +async def test_submit_interaction_async_rejects_empty_prompt() -> None: + omni = _make_async_omni() + omni.request_states.pop("external-abc-uuid-2") + with pytest.raises(ValueError, match="prompt must be non-empty"): + await omni.submit_interaction_async("external-abc", interaction=_prompt_interaction("")) + + +@pytest.mark.asyncio +async def test_submit_interaction_async_rejects_negative_transition_chunks() -> None: + omni = _make_async_omni() + omni.request_states.pop("external-abc-uuid-2") + with pytest.raises(ValueError, match="transition_chunks must be >= 0"): + await omni.submit_interaction_async( + "external-abc", + interaction=_prompt_interaction(transition_chunks=-1), + ) + + +@pytest.mark.asyncio +async def test_submit_interaction_async_rejects_inactive_request() -> None: + omni = _make_async_omni() + omni.request_states.clear() + with pytest.raises(ValueError, match="No active request"): + await omni.submit_interaction_async("missing", interaction=_prompt_interaction(transition_chunks=2)) + + +@pytest.mark.asyncio +async def test_submit_interaction_async_rejects_non_diffusion() -> None: + omni = _make_async_omni(stage_type="llm") + omni.request_states = { + "req-uuid": ClientRequestState( + request_id="req-uuid", + external_request_id="req", + queue=AsyncMock(), + ), + } + with pytest.raises(ValueError, match="requires a diffusion stage"): + await omni.submit_interaction_async("req", interaction=_prompt_interaction(transition_chunks=2)) + + +@pytest.mark.asyncio +async def test_submit_interaction_async_forwards_unsupported_dict_shape_to_engine() -> None: + omni = _make_async_omni() + omni.request_states.pop("external-abc-uuid-2") + payload: OmniInteractionPrompt = { # pyright: ignore[reportAssignmentType] # deliberately wrong + "event": {"multi_modal_data": {"unsupported_modality": {"type": "pose"}}}, + "transition_chunks": 2, + } + + await omni.submit_interaction_async( + "external-abc", + interaction=payload, + ) + + omni.engine.submit_interaction_async.assert_awaited_once_with( # pyright: ignore[reportAttributeAccessIssue] + "external-abc-uuid-1", + interaction=payload, + ) diff --git a/tests/entrypoints/test_duplex_fence_propagation.py b/tests/entrypoints/test_duplex_fence_propagation.py new file mode 100644 index 000000000..f7b8cdcfc --- /dev/null +++ b/tests/entrypoints/test_duplex_fence_propagation.py @@ -0,0 +1,123 @@ +import pytest + +from tests.entrypoints.openai_api.test_duplex_handler import FakeChatService, TimedWebSocket +from vllm_omni.entrypoints.async_omni import AsyncOmni +from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence +from vllm_omni.experimental.fullduplex.openai.protocol import DuplexSession, DuplexSessionConfig +from vllm_omni.experimental.fullduplex.openai.serving import OmniDuplexSessionHandler + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class FenceRecordingEngine: + output_modalities = ["text", "audio"] + + def __init__(self) -> None: + self.calls: list[tuple[str, DuplexFence | None]] = [] + self.signal_next_fences: list[DuplexFence | None] = [] + + async def open_duplex_session_async(self, session_id, *, fence=None, **kwargs): + del session_id, kwargs + self.calls.append(("open", fence)) + return {"ok": True} + + async def append_duplex_input_async(self, session_id, *, fence=None, **kwargs): + del session_id, kwargs + self.calls.append(("append", fence)) + return {"ok": True} + + async def signal_duplex_turn_async( + self, + session_id, + *, + event, + fence=None, + next_fence=None, + timeout=None, + ): + del session_id, event, timeout + self.calls.append(("signal", fence)) + self.signal_next_fences.append(next_fence) + return {"ok": True} + + async def close_duplex_session_async(self, session_id, *, fence=None, **kwargs): + del session_id, kwargs + self.calls.append(("close", fence)) + return {"ok": True} + + +@pytest.mark.asyncio +async def test_async_omni_forwards_fence_for_all_runtime_operations(): + engine = FenceRecordingEngine() + app = object.__new__(AsyncOmni) + app.engine = engine + app.request_states = {} + app._final_output_handler = lambda: None + fence = DuplexFence("sid", epoch=1, turn_id=2) + + await app.open_duplex_session_async("sid", fence=fence) + await app.append_duplex_input_async( + "sid", mode="append_audio_chunk", payload={}, fence=fence, collect_outputs=False + ) + await app.signal_duplex_turn_async("sid", event="turn.end", fence=fence) + await app.close_duplex_session_async("sid", reason="done", fence=fence) + + assert engine.calls == [(operation, fence) for operation in ("open", "append", "signal", "close")] + + +@pytest.mark.asyncio +async def test_openai_handler_passes_current_session_fence_for_runtime_controls(): + engine = FenceRecordingEngine() + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine)) + session = DuplexSession( + session_id="sid", + config=DuplexSessionConfig(), + epoch=1, + turn_id=2, + ) + ws = TimedWebSocket() + fence = DuplexFence("sid", epoch=1, turn_id=2) + + await handler._open_runtime_session(session, ws.send_json) + await handler._signal_runtime_session(session, "turn.end", send_json=ws.send_json) + await handler._close_runtime_session(session, reason="done", send_json=ws.send_json) + + assert engine.calls == [(operation, fence) for operation in ("open", "signal", "close")] + + +@pytest.mark.asyncio +async def test_runtime_signal_uses_fence_captured_before_session_turn_advances(): + engine = FenceRecordingEngine() + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine)) + session = DuplexSession(session_id="sid", config=DuplexSessionConfig()) + captured_fence = DuplexFence("sid", epoch=0, turn_id=0) + session.turn_id = 1 + + await handler._signal_runtime_session( + session, + "input.commit", + fence=captured_fence, + ) + + assert engine.calls == [("signal", captured_fence)] + + +@pytest.mark.asyncio +async def test_openai_handler_forwards_cancel_fence_through_async_omni_facade(): + engine = FenceRecordingEngine() + app = object.__new__(AsyncOmni) + app.engine = engine + handler = OmniDuplexSessionHandler(chat_service=FakeChatService(app)) + session = DuplexSession(session_id="sid-cancel", config=DuplexSessionConfig()) + cancelled_fence = DuplexFence("sid-cancel", epoch=2, turn_id=3) + next_fence = DuplexFence("sid-cancel", epoch=3, turn_id=3) + + assert await handler._signal_runtime_session( + session, + "input.cancel", + fence=cancelled_fence, + next_fence=next_fence, + ) + + assert engine.calls == [("signal", cancelled_fence)] + assert engine.signal_next_fences == [next_fence] diff --git a/tests/entrypoints/test_omni_entrypoints.py b/tests/entrypoints/test_omni_entrypoints.py index 4da82390b..5d9b85938 100644 --- a/tests/entrypoints/test_omni_entrypoints.py +++ b/tests/entrypoints/test_omni_entrypoints.py @@ -108,6 +108,8 @@ class FakeAsyncOmniEngine: self.output_processors = [SimpleNamespace(tokenizer=None) for _ in range(self.num_stages)] self.input_processor = None self.endpoint_restrictions = () + self.duplex_session_config = None + self.duplex_serving_adapter_path = None self.output_q: queue.Queue[Any] = queue.Queue() self.submitted: list[dict[str, Any]] = [] diff --git a/tests/entrypoints/test_omni_new_request_data.py b/tests/entrypoints/test_omni_new_request_data.py index b1ad56cdd..abbb04d8f 100644 --- a/tests/entrypoints/test_omni_new_request_data.py +++ b/tests/entrypoints/test_omni_new_request_data.py @@ -2,7 +2,9 @@ from types import SimpleNamespace import pytest import torch +from vllm.v1.core.sched.output import NewRequestData +from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin from vllm_omni.core.sched.output import OmniNewRequestData pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -52,3 +54,80 @@ def test_omni_new_request_data_allows_missing_payloads(): assert data.prompt_embeds is None assert data.additional_information is None + + +def test_from_base_preserves_every_upstream_field_and_adds_omni_payloads(): + prompt_embeds = torch.randn(2, 3) + base = NewRequestData( + req_id="req-base", + prompt_token_ids=[1, 2], + mm_features=[], + sampling_params=None, + pooling_params=None, + block_ids=([3, 4],), + num_computed_tokens=1, + lora_request=None, + prompt_embeds=prompt_embeds, + prompt_is_token_ids=False, + prefill_token_ids=[1], + ) + request = SimpleNamespace( + external_req_id="external-base", + additional_information={"payload": "value"}, + model_intermediate_buffer={"hidden": "state"}, + ) + + data = OmniNewRequestData.from_base(base, request) + + for field_name in NewRequestData.__dataclass_fields__: + assert getattr(data, field_name) is getattr(base, field_name) + assert data.external_req_id == "external-base" + assert data.additional_information == {"payload": "value"} + assert data.model_intermediate_buffer == {"hidden": "state"} + + +def test_rewrap_converts_fallback_entries_and_keeps_fast_path_entries(): + base = NewRequestData( + req_id="req-fallback", + prompt_token_ids=[1], + mm_features=[], + sampling_params=None, + pooling_params=None, + block_ids=([2],), + num_computed_tokens=0, + lora_request=None, + prompt_embeds=None, + prompt_is_token_ids=True, + prefill_token_ids=[1], + ) + fast_path = OmniNewRequestData( + req_id="req-fast", + prompt_token_ids=[3], + mm_features=[], + sampling_params=None, + pooling_params=None, + block_ids=([4],), + num_computed_tokens=0, + lora_request=None, + prompt_embeds=None, + prompt_is_token_ids=True, + prefill_token_ids=[3], + additional_information={"fast": True}, + ) + scheduler = OmniSchedulerMixin() + scheduler.requests = { + "req-fallback": SimpleNamespace( + external_req_id="external-fallback", + additional_information={"fallback": True}, + model_intermediate_buffer=None, + ) + } + output = SimpleNamespace(scheduled_new_reqs=[base, fast_path]) + + scheduler._rewrap_scheduled_new_reqs(output) + + fallback_data, fast_path_data = output.scheduled_new_reqs + assert isinstance(fallback_data, OmniNewRequestData) + assert fallback_data.prefill_token_ids == [1] + assert fallback_data.additional_information == {"fallback": True} + assert fast_path_data is fast_path diff --git a/tests/entrypoints/test_omni_sleep_mode.py b/tests/entrypoints/test_omni_sleep_mode.py index efbb19fff..0377e1a9a 100644 --- a/tests/entrypoints/test_omni_sleep_mode.py +++ b/tests/entrypoints/test_omni_sleep_mode.py @@ -1,13 +1,19 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Omni sleep mode: entrypoint-level VRAM/ACK tests (L4) plus H100 multi-TP e2e.""" +"""Omni sleep mode: entrypoint-level VRAM/ACK tests plus multi-stage e2e. + +Class-scoped engines amortize BAGEL load across sleep/wake assertions within +each suite (LLM vs diffusion). Suites are separate classes so only one heavy +engine is alive at a time. Device cleanup runs once per module. +""" import asyncio import logging import os import pytest +import pytest_asyncio import torch from vllm import SamplingParams @@ -20,12 +26,11 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger("OmniTest") pytestmark = [ pytest.mark.advanced_model, - pytest.mark.usefixtures("clean_gpu_memory_between_tests"), ] -def clean_gpu_envs(): - """clean up GPU environment variables to ensure tests run on all available devices.""" +def clean_device_envs(): + """Clear device-visibility env vars so tests see all available devices.""" device_visibility_vars = [ "CUDA_VISIBLE_DEVICES", # NVIDIA "HIP_VISIBLE_DEVICES", # AMD ROCm @@ -103,11 +108,15 @@ def get_dynamic_devices(stage_idx: int, num_stages: int, tp_size: int) -> str: return ",".join(device_ids) -@pytest.fixture(scope="function") -async def llm_engine(): - if current_omni_platform.is_rocm(): - clean_gpu_envs() - model_name = "ByteDance-Seed/BAGEL-7B-MoT" +async def _ensure_awake(engine: AsyncOmni, stage_ids: list[int]) -> None: + """Best-effort full wake so the next shared-engine test starts active.""" + try: + await engine.wake_up(stage_ids=stage_ids) + except Exception as e: + logger.warning("ensure_awake failed (stage_ids=%s): %s", stage_ids, e) + + +def _build_llm_stages() -> tuple[list[dict], list[dict]]: common_args = { "worker_type": "ar", "enable_sleep_mode": True, @@ -133,18 +142,38 @@ async def llm_engine(): }, ] connectors = [{"src_stage_id": 0, "dst_stage_id": 1, "connector_type": "queue"}] - engine = AsyncOmni(model=model_name, stages=stages, connectors=connectors, init_timeout=600, enable_sleep_mode=True) + return stages, connectors + + +@pytest.fixture(scope="module", autouse=True) +def _module_device_cleanup(): + """One device cleanup pass around the module (engines are class-scoped).""" + from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup + + print("\n=== PRE-MODULE DEVICE CLEANUP (sleep_mode) ===") + run_pre_test_cleanup() + yield + print("\n=== POST-MODULE DEVICE CLEANUP (sleep_mode) ===") + run_post_test_cleanup() + + +@pytest_asyncio.fixture(scope="class", loop_scope="class") +async def llm_engine(): + """Shared 2-stage BAGEL LLM engine for sleep/wake protocol tests.""" + if current_omni_platform.is_rocm(): + clean_device_envs() + stages, connectors = _build_llm_stages() + engine = AsyncOmni(model=MODEL, stages=stages, connectors=connectors, init_timeout=600, enable_sleep_mode=True) yield engine engine.shutdown() - # Subprocess / driver can lag releasing VRAM; brief pause before the next test spins up new workers. await asyncio.sleep(1.5) -@pytest.fixture(scope="function") +@pytest_asyncio.fixture(scope="class", loop_scope="class") async def diffusion_engine(): + """Shared BAGEL diffusion TP=2 engine for sleep/wake + generate checks.""" if current_omni_platform.is_rocm(): - clean_gpu_envs() - model_name = "ByteDance-Seed/BAGEL-7B-MoT" + clean_device_envs() stages = [ { "stage_id": 0, @@ -165,28 +194,27 @@ async def diffusion_engine(): "final_output_type": "image", } ] - engine = AsyncOmni(model=model_name, stages=stages, init_timeout=600, enable_sleep_mode=True) + engine = AsyncOmni(model=MODEL, stages=stages, init_timeout=600, enable_sleep_mode=True) yield engine engine.shutdown() await asyncio.sleep(1.5) -class TestOmniSleepMode: - @pytest.mark.asyncio +class TestOmniLlmSleepMode: + """LLM sleep/wake protocol tests sharing one class-scoped engine.""" + + @pytest.mark.asyncio(loop_scope="class") @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=1) async def test_llm_sleep_ack(self, llm_engine: AsyncOmni): """LLM Thinker (GPU0) Signal and Physical Recycling Audit""" + device_id = 0 try: - device_id = 0 used_before = get_device_global_memory_used_gib(device_id) acks = await llm_engine.sleep(stage_ids=[0], level=1) await asyncio.sleep(1.5) used_after = get_device_global_memory_used_gib(device_id) drop_gib = used_before - used_after - # Verification signal successful assert all(get_ack_info(ack, "status") == "SUCCESS" for ack in acks) - # Worker-reported delta (can be 0 if get_current_memory_usage does not move) or - # GPU-global drop from mem_get_info (sees child worker processes). total_freed_bytes = sum(get_ack_info(ack, "freed_bytes", 0) for ack in acks) freed_gib = total_freed_bytes / 1024**3 logger.info( @@ -201,9 +229,42 @@ class TestOmniSleepMode: f"ACK={freed_gib:.2f} GiB, global_drop={drop_gib:.2f} GiB" ) finally: - llm_engine.shutdown() + await _ensure_awake(llm_engine, [0]) - @pytest.mark.asyncio + @pytest.mark.asyncio(loop_scope="class") + @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) + async def test_partial_wake_blocks_generate(self, llm_engine: AsyncOmni): + """Regression for #4473 Repro B: generate() must be rejected if kv_cache + is still asleep after wake_up(tags=["weights"]), instead of crashing with + CUDA illegal memory access.""" + try: + await llm_engine.sleep(stage_ids=[0], level=1) + await llm_engine.wake_up(stage_ids=[0], tags=["weights"]) + with pytest.raises(RuntimeError, match="partially or fully asleep"): + async for _ in llm_engine.generate("test", sampling_params=SamplingParams(max_tokens=4)): + pass + finally: + await _ensure_awake(llm_engine, [0]) + + @pytest.mark.asyncio(loop_scope="class") + @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) + async def test_duplicate_wake_is_idempotent(self, llm_engine: AsyncOmni): + """Regression for #4473 Repro C: duplicate wake_up(tags=None) must be a + safe no-op instead of raising a cumem CUDA invalid argument error.""" + try: + await llm_engine.sleep(stage_ids=[0], level=1) + first_acks = await llm_engine.wake_up(stage_ids=[0]) + assert len(first_acks) > 0, "First wake_up() should return ACKs" + second_acks = await llm_engine.wake_up(stage_ids=[0]) + assert second_acks == [], f"Duplicate wake_up() should return [] but got {second_acks}" + finally: + await _ensure_awake(llm_engine, [0]) + + +class TestOmniDiffusionSleepMode: + """Diffusion sleep/wake tests sharing one class-scoped TP=2 engine.""" + + @pytest.mark.asyncio(loop_scope="class") @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) async def test_diffusion_sleep_handshake(self, diffusion_engine: AsyncOmni): """Diffusion Worker stage signal loop""" @@ -217,19 +278,15 @@ class TestOmniSleepMode: assert len(acks) >= 1, "Expected at least 1 ACK from Diffusion Workers" assert all(_get_status(ack) == "SUCCESS" for ack in acks) logger.info(f"Success: Received {len(acks)} Diffusion Worker ACKs") - logger.info("Testing auto-wakeup before test end...") await diffusion_engine.wake_up(stage_ids=[0]) - logger.info("Test logic finished, triggering manual shutdown...") finally: - diffusion_engine.shutdown() - logger.info("Manual shutdown executed. Test should exit now.") + await _ensure_awake(diffusion_engine, [0]) - @pytest.mark.asyncio + @pytest.mark.asyncio(loop_scope="class") @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) async def test_cross_device_cleanup(self, diffusion_engine: AsyncOmni): """Physical recycling audit: leveraging deterministic data returned by Workers""" try: - # TP2 uses GPUs 0 and 1; measure whole-GPU usage (includes worker subprocesses). used_before = get_device_global_memory_used_gib(0) + get_device_global_memory_used_gib(1) acks = await diffusion_engine.sleep(stage_ids=[0], level=1) await asyncio.sleep(1.5) @@ -246,52 +303,90 @@ class TestOmniSleepMode: ) logger.info("SUCCESS: 100% weights offloaded.") finally: - diffusion_engine.shutdown() + await _ensure_awake(diffusion_engine, [0]) - @pytest.mark.asyncio + @pytest.mark.asyncio(loop_scope="class") @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) - async def test_diffusion_integrity_bit_level(self, diffusion_engine: AsyncOmni): - """Bit-level consistency after Diffusion wake-up (prevent image corruption)""" + async def test_diffusion_sleep_wake_generate(self, diffusion_engine: AsyncOmni): + """Merged diffusion lifecycle with test points from the former trio: + + - ``test_diffusion_integrity_bit_level``: 4-step/512 baseline generate → + sleep → wake → post generate (images present, count unchanged). + - ``test_diffusion_vram_lifecycle_audit``: ACK/process VRAM reclaim on + sleep; after wake, process VRAM restores to within 3 GiB of initial; + post-wake generate smoke. + - ``test_diffusion_model_sleep_tp``: sleep ACK SUCCESS → wake → verify + generate (same Active→Sleep→Active→Generate path; runs on the shared + class TP=2 BagelPipeline engine rather than a second default-config load). + """ + import gc + + device_id = 1 try: + # --- integrity: baseline generate at original resolution/steps --- prompt = "A huge swimming pool, with many people swimming." sp = OmniDiffusionSamplingParams(num_inference_steps=4, height=512, width=512, seed=42) llm_sp = SamplingParams() - # Baseline Generation logger.info("Running Baseline Generation...") base_output = None async for output in diffusion_engine.generate(prompt, request_id="base", sampling_params_list=[llm_sp, sp]): base_output = output assert base_output is not None and len(base_output.images) > 0 logger.info("Baseline Generation successful.") - # Sleep Level 1 + + # Snapshot Active VRAM after generate (lifecycle compared Sleep/Wake + # against the pre-sleep active watermark). + get_vram_info(device_id) + torch.accelerator.empty_cache() + vram_initial = get_vram_info(device_id)["reserved"] + logger.info(f"Diffusion Active VRAM before sleep: {vram_initial:.2f} GiB") + + # --- lifecycle + model_sleep_tp: level-1 sleep reclaim --- logger.info("Entering Deep Sleep (VRAM Scavenging)...") - await diffusion_engine.sleep(stage_ids=[0], level=1) - # Wake-up + acks = await diffusion_engine.sleep(stage_ids=[0], level=1) + statuses = [get_ack_info(ack, "status") for ack in acks] + assert all(s == "SUCCESS" for s in statuses), f"Sleep failed. Statuses: {statuses}" + + reported_freed_bytes = sum(get_ack_info(ack, "freed_bytes", 0) for ack in acks) + reported_freed_gib = reported_freed_bytes / 1024**3 + logger.info(f"Worker internally reported freed: {reported_freed_gib:.2f} GiB") + + await asyncio.sleep(2) + get_vram_info(device_id) + torch.accelerator.empty_cache() + vram_sleeping = get_vram_info(device_id)["reserved"] + logger.info(f"External VRAM measurement during Sleep: {vram_sleeping:.2f} GiB") + assert reported_freed_gib > 14.0 or vram_sleeping < 5.0, ( + f"Reclamation failed. Reported: {reported_freed_gib:.2f}G, Measured: {vram_sleeping:.2f}G" + ) + + # --- wake + VRAM restore to initial (±3 GiB) --- logger.info("Waking up (Reloading Weights)...") await diffusion_engine.wake_up(stage_ids=[0]) - await asyncio.sleep(2.0) - import gc - gc.collect() + get_vram_info(device_id) + torch.accelerator.empty_cache() + vram_restored = get_vram_info(device_id)["reserved"] + logger.info(f"VRAM after Wake-up: {vram_restored:.2f} GiB") + assert abs(vram_restored - vram_initial) < 3.0, "VRAM failed to restore to initial levels" + # --- integrity + lifecycle: post-wake generate --- logger.info("Running Post-Wakeup Generation...") post_output = None async for output in diffusion_engine.generate(prompt, request_id="post", sampling_params_list=[llm_sp, sp]): post_output = output - # Assert result consistency assert post_output is not None assert len(base_output.images) == len(post_output.images) assert post_output.images[0] is not None - logger.info("SUCCESS: Diffusion integrity verified after Sleep/Wake cycle.") - except Exception as e: - logger.error(f"Integrity test failed: {e}") - raise e + logger.info("SUCCESS: Diffusion integrity + VRAM lifecycle + sleep/wake generate verified.") finally: - logger.info("Triggering mandatory cleanup...") - diffusion_engine.shutdown() - logger.info("Cleanup complete, test exiting.") + await _ensure_awake(diffusion_engine, [0]) + + +class TestOmniCoordinatedSleepMode: + """Dual-engine coordination (kept skipped; do not delete).""" @pytest.mark.skip( reason=( @@ -301,7 +396,7 @@ class TestOmniSleepMode: "Note: this test is fixture-free so skip does not init llm/diffusion engines." ) ) - @pytest.mark.asyncio + @pytest.mark.asyncio(loop_scope="class") @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) async def test_coordinated_cross_device(self, llm_engine: AsyncOmni, diffusion_engine: AsyncOmni): """Heterogeneous Coordinated Cleanup Test (Talker and Diffusion on GPU 1)""" @@ -341,172 +436,22 @@ class TestOmniSleepMode: diffusion_engine.shutdown() logger.info("All engines scavenged. Ready for next test.") - @pytest.mark.asyncio - @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) - async def test_diffusion_vram_lifecycle_audit(self, diffusion_engine: AsyncOmni): - """Diffusion memory loop: Active -> Deep Sleep -> Active -> inference sanity check""" - device_id = 1 - try: - get_vram_info(device_id) - torch.accelerator.empty_cache() - vram_initial = get_vram_info(device_id)["reserved"] - logger.info(f"Diffusion Initial VRAM: {vram_initial:.2f} GiB") - - # Sleep - logger.info("Triggering Level 1 Deep Sleep (Partial Weight Offloading)...") - acks = await diffusion_engine.sleep(stage_ids=[0], level=1) - - reported_freed_bytes = sum(getattr(ack, "freed_bytes", 0) for ack in acks) - reported_freed_gib = reported_freed_bytes / 1024**3 - logger.info(f"Worker internally reported freed: {reported_freed_gib:.2f} GiB") - - await asyncio.sleep(2) - get_vram_info(device_id) - torch.accelerator.empty_cache() - - vram_sleeping = get_vram_info(device_id)["reserved"] - logger.info(f"External VRAM measurement during Sleep: {vram_sleeping:.2f} GiB") - - assert reported_freed_gib > 14.0 or vram_sleeping < 5.0, ( - f"Reclamation failed. Reported: {reported_freed_gib:.2f}G, Measured: {vram_sleeping:.2f}G" - ) - - # wake-up - logger.info("Triggering Wake-up (Reloading weights to GPU)...") - await diffusion_engine.wake_up(stage_ids=[0]) - - await asyncio.sleep(2) - get_vram_info(device_id) - torch.accelerator.empty_cache() - vram_restored = get_vram_info(device_id)["reserved"] - logger.info(f"VRAM after Wake-up: {vram_restored:.2f} GiB") - - assert abs(vram_restored - vram_initial) < 3.0, "VRAM failed to restore to initial levels" - - # inference sanity check - logger.info("Running post-lifecycle inference smoke test...") - prompt = "A futuristic lab with glowing lights, high quality." - sp = OmniDiffusionSamplingParams(num_inference_steps=2, height=512, width=512, seed=42) - llm_sp = SamplingParams() - - base_img_found = False - async for output in diffusion_engine.generate( - prompt, request_id="lifecycle-check", sampling_params_list=[llm_sp, sp] - ): - if output.images and output.images[0] is not None: - base_img_found = True - - assert base_img_found, "Inference failed after Wake-up cycle!" - logger.info("SUCCESS: Full Diffusion Lifecycle (Active -> Sleep -> Active -> Generate) audited.") - - except Exception as e: - logger.error(f"Lifecycle audit failed: {e}") - raise e - finally: - logger.info("Cleaning up engine and scavenging processes...") - diffusion_engine.shutdown() - await asyncio.sleep(1) - - @pytest.mark.asyncio - @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) - async def test_level2_sleep_wake_raises(self, llm_engine: AsyncOmni): - """Regression for #4473 Repro A: wake_up() after sleep(level=2) must raise - NotImplementedError instead of silently producing corrupted output.""" - try: - await llm_engine.sleep(stage_ids=[0], level=2) - with pytest.raises(NotImplementedError, match="sleep\\(level=2\\)"): - await llm_engine.wake_up(stage_ids=[0]) - finally: - llm_engine.shutdown() - - @pytest.mark.asyncio - @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) - async def test_partial_wake_blocks_generate(self, llm_engine: AsyncOmni): - """Regression for #4473 Repro B: generate() must be rejected if kv_cache - is still asleep after wake_up(tags=["weights"]), instead of crashing with - CUDA illegal memory access.""" - try: - await llm_engine.sleep(stage_ids=[0], level=1) - await llm_engine.wake_up(stage_ids=[0], tags=["weights"]) - with pytest.raises(RuntimeError, match="partially or fully asleep"): - async for _ in llm_engine.generate("test", sampling_params=SamplingParams(max_tokens=4)): - pass - finally: - llm_engine.shutdown() - - @pytest.mark.asyncio - @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) - async def test_duplicate_wake_is_idempotent(self, llm_engine: AsyncOmni): - """Regression for #4473 Repro C: duplicate wake_up(tags=None) must be a - safe no-op instead of raising a cumem CUDA invalid argument error.""" - try: - await llm_engine.sleep(stage_ids=[0], level=1) - first_acks = await llm_engine.wake_up(stage_ids=[0]) - assert len(first_acks) > 0, "First wake_up() should return ACKs" - second_acks = await llm_engine.wake_up(stage_ids=[0]) - assert second_acks == [], f"Duplicate wake_up() should return [] but got {second_acks}" - finally: - llm_engine.shutdown() - # --------------------------------------------------------------------------- -# H100 (or MI325) e2e: BAGEL / pure diffusion, multi-TP +# Multi-stage (H100) / pure diffusion TP=1 (2×L4): sleep→wake→generate +# (BAGEL TP=2 diffusion path covered by TestOmniDiffusionSleepMode) # --------------------------------------------------------------------------- -@pytest.mark.omni -@pytest.mark.advanced_model -@pytest.mark.parametrize("tp_size", [1]) -@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=1) -@pytest.mark.asyncio -async def test_diffusion_model_sleep_tp(tp_size: int): - """Two-stage BAGEL default config: warmup, sleep all, wake, verify generate.""" - if current_omni_platform.is_rocm(): - clean_gpu_envs() - num_gpus = torch.accelerator.device_count() - if num_gpus < tp_size: - pytest.skip(f"Skipping TP={tp_size}") - - engine_args = { - "model": MODEL, - "enable_sleep_mode": True, - "tensor_parallel_size": tp_size, - "enforce_eager": True, - "trust_remote_code": True, - "dtype": "bfloat16", - "gpu_memory_utilization": 0.5, - } - - engine = AsyncOmni(**engine_args, stage_init_timeout=1200) - try: - diff_sp = OmniDiffusionSamplingParams(num_inference_steps=2, height=256, width=256) - llm_sp = SamplingParams() - - async for _ in engine.generate("test", sampling_params_list=[llm_sp, diff_sp]): - pass - - acks = await engine.sleep(level=1) - statuses = [get_ack_info(ack, "status") for ack in acks] - assert all(s == "SUCCESS" for s in statuses), f"Sleep failed. Statuses: {statuses}" - - await engine.wake_up() - async for _ in engine.generate("verify", sampling_params_list=[llm_sp, diff_sp]): - pass - - logger.info("Diffusion TP=%s lifecycle OK", tp_size) - finally: - engine.shutdown() - - @pytest.mark.omni @pytest.mark.advanced_model @pytest.mark.parametrize("tp_size", [1, 2]) @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) @pytest.mark.asyncio -async def test_multistage_sleep_h100(tp_size: int): +async def test_multistage_llm_diffusion_sleep_wake(tp_size: int): """Explicit 2-stage (llm + diffusion) + connectors; sleep/wake both stages.""" if current_omni_platform.is_rocm(): - clean_gpu_envs() + clean_device_envs() num_gpus = torch.accelerator.device_count() if num_gpus < tp_size * 2: pytest.skip("Not enough GPUs") @@ -552,18 +497,22 @@ async def test_multistage_sleep_h100(tp_size: int): @pytest.mark.omni -@pytest.mark.advanced_model -@pytest.mark.parametrize("tp_size", [1, 2]) -@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) +@pytest.mark.core_model +@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1) @pytest.mark.asyncio -async def test_pure_diffusion_scenario(tp_size: int): - """Single-stage random diffusion: sleep, wake, generate.""" +async def test_pure_diffusion_sleep_wake(): + """Single-stage random diffusion (TP=1): sleep → wake → generate. + + Diffusion TP=2 sleep/wake is covered by TestOmniDiffusionSleepMode + (shared BAGEL TP=2 engine); this case keeps the default AsyncOmni + + qwen_image_random entrypoint path on L4. + """ if current_omni_platform.is_rocm(): - clean_gpu_envs() + clean_device_envs() engine_args = { "model": MODEL_DIFF, "enable_sleep_mode": True, - "tensor_parallel_size": tp_size, + "tensor_parallel_size": 1, "enforce_eager": True, "dtype": "bfloat16", "gpu_memory_utilization": 0.5, @@ -578,6 +527,6 @@ async def test_pure_diffusion_scenario(tp_size: int): sampling_params=OmniDiffusionSamplingParams(num_inference_steps=2, height=256, width=256), ): pass - logger.info("Pure diffusion OK (TP=%s)", tp_size) + logger.info("Pure diffusion sleep/wake OK") finally: engine.shutdown() diff --git a/tests/entrypoints/test_omni_snapshot_download.py b/tests/entrypoints/test_omni_snapshot_download.py new file mode 100644 index 000000000..d2c76a249 --- /dev/null +++ b/tests/entrypoints/test_omni_snapshot_download.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""``omni_snapshot_download`` must honor vLLM's ``VLLM_USE_MODELSCOPE`` semantics. + +vLLM treats the flag as enabled only for the literal string ``"true"`` +(case-insensitive). Reading ``os.environ`` directly made every non-empty value +truthy, so an explicit opt-out such as ``VLLM_USE_MODELSCOPE=0`` still took the +ModelScope path. +""" + +import sys +import types + +import pytest +from vllm import envs + +from vllm_omni.entrypoints import omni_base + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +MODEL_ID = "Qwen/Qwen3-Omni-30B-A3B-Instruct" + + +@pytest.fixture +def download_backend(monkeypatch: pytest.MonkeyPatch): + """Run ``omni_snapshot_download`` and report which backend it selected. + + ModelScope is not a vLLM-Omni dependency, so the ModelScope branch is stubbed + into ``sys.modules``; without the stub it would raise ``ModuleNotFoundError`` + instead of being observable. + """ + # vLLM caches env lookups once a service is initialized; make sure this test + # reads the values monkeypatch sets rather than a cached snapshot. + envs.disable_envs_cache() + + picked: list[str] = [] + + snapshot_module = types.ModuleType("modelscope.hub.snapshot_download") + snapshot_module.snapshot_download = lambda model_id: picked.append("modelscope") or model_id + hub_module = types.ModuleType("modelscope.hub") + hub_module.snapshot_download = snapshot_module + root_module = types.ModuleType("modelscope") + root_module.hub = hub_module + for name, module in ( + ("modelscope", root_module), + ("modelscope.hub", hub_module), + ("modelscope.hub.snapshot_download", snapshot_module), + ): + monkeypatch.setitem(sys.modules, name, module) + + monkeypatch.setattr( + omni_base, + "download_weights_from_hf_specific", + lambda **_kwargs: picked.append("huggingface"), + ) + monkeypatch.setattr( + omni_base, + "file_or_path_exists", + lambda *_args, **_kwargs: False, + ) + + def run() -> str: + picked.clear() + omni_base.omni_snapshot_download(MODEL_ID) + return picked[0] if picked else "none" + + return run + + +@pytest.mark.parametrize("value", ["0", "1", "False", "false", "no", "off"]) +def test_non_true_values_do_not_enable_modelscope(monkeypatch, download_backend, value): + monkeypatch.setenv("VLLM_USE_MODELSCOPE", value) + + assert envs.VLLM_USE_MODELSCOPE is False + assert download_backend() == "huggingface" + + +@pytest.mark.parametrize("value", ["true", "True", "TRUE"]) +def test_true_values_enable_modelscope(monkeypatch, download_backend, value): + monkeypatch.setenv("VLLM_USE_MODELSCOPE", value) + + assert envs.VLLM_USE_MODELSCOPE is True + assert download_backend() == "modelscope" + + +def test_unset_defaults_to_huggingface(monkeypatch, download_backend): + monkeypatch.delenv("VLLM_USE_MODELSCOPE", raising=False) + + assert download_backend() == "huggingface" + + +def test_modular_diffusers_defers_component_download(monkeypatch, download_backend): + monkeypatch.delenv("VLLM_USE_MODELSCOPE", raising=False) + monkeypatch.setattr( + omni_base, + "file_or_path_exists", + lambda *_args, **_kwargs: True, + ) + + assert download_backend() == "none" diff --git a/tests/entrypoints/test_pd_disaggregation.py b/tests/entrypoints/test_pd_disaggregation.py index 5ffabfbf2..c896ccb88 100644 --- a/tests/entrypoints/test_pd_disaggregation.py +++ b/tests/entrypoints/test_pd_disaggregation.py @@ -22,6 +22,9 @@ from vllm import SamplingParams from vllm_omni.entrypoints.pd_utils import PDDisaggregationMixin +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + pytestmark = pytest.mark.skip(reason="Temporarily skip PD entrypoint tests while PD config is being removed.") # Suppress noisy DeprecationWarnings from optional Swig bindings imported by vLLM dependencies. @@ -121,9 +124,7 @@ class _FakeStage: self._in_q = in_q self._out_q = out_q - def init_stage_worker( - self, model: str, *, is_async=False, shm_threshold_bytes=65536, ctx=None, batch_timeout=10, **kwargs - ): + def init_stage_worker(self, model: str, *, is_async=False, ctx=None, batch_timeout=10, **kwargs): self._proc = _ns( start=lambda: None, join=lambda timeout=None: None, @@ -307,21 +308,11 @@ def mock_get_config(monkeypatch): """Auto-mock get_config and related model loading functions.""" import sys - fake_tokenizer = _ns() - fake_tokenizer.encode = lambda *args, **kwargs: [1, 2, 3] - fake_tokenizer.decode = lambda *args, **kwargs: "test" - - def _mock_init_tokenizer_from_configs(model_config=None, **kwargs): - return fake_tokenizer - - monkeypatch.setattr( - "vllm.transformers_utils.tokenizer.init_tokenizer_from_configs", - _mock_init_tokenizer_from_configs, - raising=False, - ) - tokenizer_module_path = "vllm.transformers_utils.tokenizer" - if tokenizer_module_path in sys.modules: - setattr(sys.modules[tokenizer_module_path], "init_tokenizer_from_configs", _mock_init_tokenizer_from_configs) + # TODO(pd-unskip): the tokenizer mock that lived here targeted + # vllm.transformers_utils.tokenizer.init_tokenizer_from_configs, which was + # deleted in vLLM 0.26 (the raising=False setattr made it a silent no-op). + # When the module-level skip is lifted, mock whatever tokenizer seam the + # revived PD entrypoint code actually uses. def _mock_length_from_prompt_token_ids_or_embeds(prompt_token_ids=None, prompt_embeds=None): if prompt_token_ids is not None: @@ -346,13 +337,6 @@ def mock_get_config(monkeypatch): _mock_length_from_prompt_token_ids_or_embeds, ) - monkeypatch.setattr( - "vllm_omni.entrypoints.async_omni.init_tokenizer_from_configs", _mock_init_tokenizer_from_configs, raising=False - ) - async_omni_path = "vllm_omni.entrypoints.async_omni" - if async_omni_path in sys.modules: - setattr(sys.modules[async_omni_path], "init_tokenizer_from_configs", _mock_init_tokenizer_from_configs) - fake_hf_config = _ns() fake_hf_config.model_type = "qwen2_5_omni" diff --git a/tests/entrypoints/test_resolve_dreamzero_config.py b/tests/entrypoints/test_resolve_dreamzero_config.py index 4dcaddd63..cc459c269 100644 --- a/tests/entrypoints/test_resolve_dreamzero_config.py +++ b/tests/entrypoints/test_resolve_dreamzero_config.py @@ -28,11 +28,18 @@ def test_dreamzero_config_sets_model_class_and_policy_config(monkeypatch): classmethod(lambda _cls, model, trust_remote_code=True: "vla"), ) monkeypatch.setattr( - "vllm_omni.diffusion.utils.hf_utils._looks_like_dreamzero", + "vllm_omni.config.config_factory.StageConfigFactory.get_hf_config", + classmethod(lambda _cls, model, trust_remote_code=True: None), + ) + monkeypatch.setattr( + "vllm_omni.config.config_factory._looks_like_dreamzero", lambda _model: True, ) - stage_configs, _ = load_stage_configs_from_model("GEAR-Dreams/DreamZero-DROID") + stage_configs, _ = load_stage_configs_from_model( + "GEAR-Dreams/DreamZero-DROID", + trust_remote_code=False, + ) engine_args = stage_configs[0].engine_args assert engine_args.model_class_name == "DreamZeroPipeline" diff --git a/tests/entrypoints/test_serve.py b/tests/entrypoints/test_serve.py index 61ffed3d5..04a29edd1 100644 --- a/tests/entrypoints/test_serve.py +++ b/tests/entrypoints/test_serve.py @@ -46,6 +46,36 @@ def test_serve_parser_accepts_strategy_config() -> None: assert args.get_explicit_kwargs_dict()["strategy_config"] == "/tmp/strategy.yaml" +def test_serve_parser_accepts_deploy_config() -> None: + parser = TrackingArgumentParser() + subparsers = parser.add_subparsers(dest="subcommand") + OmniServeCommand().subparser_init(subparsers) + + args = parser.parse_args(["serve", "fake-model", "--omni", "--deploy-config", "/tmp/deploy.yaml"]) + + assert args.deploy_config == "/tmp/deploy.yaml" + assert args.get_explicit_kwargs_dict()["deploy_config"] == "/tmp/deploy.yaml" + + +def test_serve_parser_rejects_stage_configs_path() -> None: + parser = TrackingArgumentParser() + subparsers = parser.add_subparsers(dest="subcommand") + OmniServeCommand().subparser_init(subparsers) + + with pytest.raises(SystemExit): + parser.parse_args(["serve", "fake-model", "--omni", "--stage-configs-path", "/tmp/stages.yaml"]) + + +def test_serve_parser_accepts_four_way_cfg_parallelism() -> None: + parser = TrackingArgumentParser() + subparsers = parser.add_subparsers(dest="subcommand") + OmniServeCommand().subparser_init(subparsers) + + args = parser.parse_args(["serve", "fake-model", "--omni", "--cfg-parallel-size", "4"]) + + assert args.cfg_parallel_size == 4 + + def _make_headless_args(**kwargs) -> TrackingNamespace: defaults = { "model": "fake-model", @@ -56,7 +86,6 @@ def _make_headless_args(**kwargs) -> TrackingNamespace: "omni_replica_address": None, "omni_dp_size_local": 1, "worker_backend": "multi_process", - "stage_configs_path": None, "deploy_config": None, "log_stats": False, "disable_log_stats": False, @@ -144,6 +173,7 @@ def test_run_headless_parses_and_forwards_stage_overrides(mocker: MockerFixture) captured: dict = {} def _fake_resolve(*args, **kwargs): + captured["args"] = args captured.update(kwargs) # Return a stage that does NOT match stage_id=0 so run_headless stops # right after the resolver call (we only care about how it was called). @@ -156,12 +186,15 @@ def test_run_headless_parses_and_forwards_stage_overrides(mocker: MockerFixture) args = _make_headless_args( stage_id=0, + deploy_config="/tmp/deploy.yaml", strategy_config="/tmp/strategy.yaml", stage_overrides='{"0": {"devices": "0,1"}, "1": {"devices": "2"}}', ) with pytest.raises(ValueError, match="No stage config found for stage_id=0"): run_headless(args) + assert captured["args"][1] is None + assert captured["deploy_config_path"] == "/tmp/deploy.yaml" assert captured["stage_overrides"] == {"0": {"devices": "0,1"}, "1": {"devices": "2"}} assert captured["strategy_config_path"] == "/tmp/strategy.yaml" @@ -222,6 +255,7 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker from vllm_omni.engine.stage_engine_startup import StageRegistrationResponse stage_cfg = _make_stage_cfg(0, stage_type="llm") + stage_cfg.engine_args["async_chunk"] = True parallel_config = SimpleNamespace( data_parallel_size_local=1, data_parallel_rank=0, @@ -241,7 +275,10 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker "vllm_omni.distributed.omni_connectors.utils.initialization.resolve_omni_kv_config_for_stage", return_value=(None, None, None), ) - mocker.patch("vllm_omni.engine.stage_init_utils.get_stage_connector_spec", return_value={}) + mock_connector_spec = mocker.patch( + "vllm_omni.engine.stage_init_utils.get_stage_connector_spec", + return_value={}, + ) mocker.patch("vllm_omni.engine.stage_init_utils.build_engine_args_dict", return_value={}) mocker.patch( "vllm_omni.engine.stage_init_utils.build_vllm_config", @@ -277,6 +314,7 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker assert kwargs["omni_stage_config"] is stage_cfg assert kwargs["replica_id"] is None assert "socket_ownership" not in kwargs + assert mock_connector_spec.call_args.kwargs["async_chunk"] is True assert mock_manager_cls.call_count == 1 mgr_kwargs = mock_manager_cls.call_args.kwargs @@ -377,7 +415,7 @@ def test_run_headless_diffusion_registers_and_spawns_proc(mocker: MockerFixture) return_value=(None, None, None), ) mocker.patch( - "vllm_omni.engine.stage_init_utils.extract_stage_metadata", + "vllm_omni.engine.stage_init_utils.extract_legacy_stage_metadata", return_value=SimpleNamespace(stage_id=1, stage_type="diffusion"), ) mock_inject = mocker.patch("vllm_omni.engine.stage_init_utils.inject_kv_stage_info") @@ -457,7 +495,7 @@ def test_run_headless_diffusion_raises_on_nonzero_proc_exit(mocker: MockerFixtur return_value=(None, None, None), ) mocker.patch( - "vllm_omni.engine.stage_init_utils.extract_stage_metadata", + "vllm_omni.engine.stage_init_utils.extract_legacy_stage_metadata", return_value=SimpleNamespace(stage_id=1, stage_type="diffusion"), ) mocker.patch("vllm_omni.engine.stage_init_utils.inject_kv_stage_info") diff --git a/tests/entrypoints/test_stage_utils.py b/tests/entrypoints/test_stage_utils.py index c4c0667df..e2890797f 100644 --- a/tests/entrypoints/test_stage_utils.py +++ b/tests/entrypoints/test_stage_utils.py @@ -6,6 +6,8 @@ from pytest_mock import MockerFixture from vllm_omni.entrypoints.stage_utils import _map_device_list, resolve_stage_physical_devices, set_stage_devices +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + def _make_dummy_torch(call_log): class _Props: diff --git a/tests/entrypoints/test_stream_finish_reason.py b/tests/entrypoints/test_stream_finish_reason.py index ebbca1917..3d6052045 100644 --- a/tests/entrypoints/test_stream_finish_reason.py +++ b/tests/entrypoints/test_stream_finish_reason.py @@ -16,20 +16,11 @@ Key invariants tested: - voice/speaker parameter compatibility in chat completions """ -import enum import json +import time from unittest.mock import MagicMock import pytest - -# Python 3.10 compat: StrEnum was added in 3.11 -if not hasattr(enum, "StrEnum"): - - class _StrEnum(str, enum.Enum): - """Minimal StrEnum backport for Python 3.10.""" - - enum.StrEnum = _StrEnum # type: ignore[attr-defined] - from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ChatCompletionResponseStreamChoice, @@ -90,28 +81,37 @@ def _make_audio_omni_output( request_id: str = "test-req", index: int = 0, num_prompt_tokens: int = 3, + stage_id: int | None = None, + replica_id: int | None = None, + audio_samples: int = 0, ) -> OmniRequestOutput: """Build an OmniRequestOutput for audio (no torch dependency).""" + completion = CompletionOutput( + index=index, + text="", + token_ids=[], + cumulative_logprob=0.0, + logprobs=None, + finish_reason="stop", + stop_reason=None, + ) + if audio_samples > 0: + completion.multimodal_output = { + "audio": [MagicMock(numel=MagicMock(return_value=audio_samples))], + "sr": 24000, + } res = RequestOutput( request_id=request_id, prompt="test", prompt_token_ids=list(range(num_prompt_tokens)), prompt_logprobs=None, - outputs=[ - CompletionOutput( - index=index, - text="", - token_ids=[], - cumulative_logprob=0.0, - logprobs=None, - finish_reason="stop", - stop_reason=None, - ) - ], + outputs=[completion], finished=True, ) return OmniRequestOutput( request_id=request_id, + stage_id=stage_id, + replica_id=replica_id, final_output_type="audio", request_output=res, finished=True, @@ -331,6 +331,74 @@ async def test_single_modality_audio_only_one_stop(): assert finish_reasons[-1] == "stop" +@pytest.mark.parametrize( + ("output_replica_id", "pool_replica_id", "expected_replica_id", "pool_lookup"), + [ + (7, None, 7, False), + (None, 5, 5, True), + ], +) +@pytest.mark.asyncio +async def test_streaming_audio_metrics_resolve_replica_id( + monkeypatch, + output_replica_id, + pool_replica_id, + expected_replica_id, + pool_lookup, +): + import vllm_omni.entrypoints.openai.serving_chat as serving_chat_mod + from vllm_omni.entrypoints.client_request_state import ClientRequestState + + serving_chat = _build_serving_chat() + request = _make_request(modalities=["audio"]) + req_state = ClientRequestState( + request_id="internal-req", + external_request_id="test-req", + ) + req_state.request_arrival_ts = time.time() - 1.0 + serving_chat.engine_client.request_states = {"internal-req": req_state} + serving_chat.engine_client.mod_metrics = object() + serving_chat.engine_client.engine = MagicMock() + serving_chat.engine_client.engine.stage_pools = [MagicMock(), MagicMock(), MagicMock()] + serving_chat.engine_client.engine.stage_pools[2].get_bound_replica_id.return_value = pool_replica_id + + first_packet_calls = [] + finalize_calls = [] + + def fake_observe_audio_first_packet(*args, **kwargs): + first_packet_calls.append(kwargs) + + def fake_observe_audio_streaming_finalize(*args, **kwargs): + finalize_calls.append(kwargs) + + monkeypatch.setattr(serving_chat_mod, "observe_audio_first_packet", fake_observe_audio_first_packet) + monkeypatch.setattr(serving_chat_mod, "observe_audio_streaming_finalize", fake_observe_audio_streaming_finalize) + + async def result_generator(): + yield _make_audio_omni_output(stage_id=2, replica_id=output_replica_id, audio_samples=2400) + + await _collect_stream( + serving_chat.chat_completion_stream_generator( + request=request, + result_generator=result_generator(), + request_id="test-req", + model_name="test-model", + conversation=[], + tokenizer=MagicMock(), + request_metadata=MagicMock(), + ) + ) + + if pool_lookup: + serving_chat.engine_client.engine.stage_pools[2].get_bound_replica_id.assert_called_once_with("internal-req") + else: + serving_chat.engine_client.engine.stage_pools[2].get_bound_replica_id.assert_not_called() + assert first_packet_calls[0]["stage_id"] == 2 + assert first_packet_calls[0]["replica_id"] == expected_replica_id + assert finalize_calls[0]["stage_id"] == 2 + assert finalize_calls[0]["replica_id"] == expected_replica_id + + # --------------------------------------------------------------------------- # Tests: fallback stop chunk when declared modality is not produced # --------------------------------------------------------------------------- @@ -403,6 +471,90 @@ async def test_declared_modality_not_produced_text_finish_suppressed(): assert ch["finish_reason"] is None +# --------------------------------------------------------------------------- +# Tests: audio chunks that carry no waveform +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_audio_chunk_without_waveform_keeps_stream_alive(): + """An audio message with no PCM must not break the stream. + + ``_create_audio_choice`` used to return an ErrorResponse here, which the + stream generator then iterated as a list of choices; the pydantic model + yields (field, value) tuples, so the loop raised + ``AttributeError: 'tuple' object has no attribute 'finish_reason'``. + """ + serving_chat = _build_serving_chat() + request = _make_request(modalities=["text", "audio"]) + + empty_audio = _make_audio_omni_output() + empty_audio.request_output.outputs[0].multimodal_output = {"audio": []} + + def create_audio_choice(omni_res, role, request, stream=False): + return OmniOpenAIServingChat._create_audio_choice(serving_chat, omni_res, role, request, stream=stream) + + serving_chat._create_audio_choice = create_audio_choice + + async def result_generator(): + yield _make_text_omni_output(text="hi", token_ids=[10], finish_reason=None) + yield _make_text_omni_output(text="!", token_ids=[11], finish_reason="stop") + yield empty_audio + + raw_lines = await _collect_stream( + serving_chat.chat_completion_stream_generator( + request=request, + result_generator=result_generator(), + request_id="test-req", + model_name="test-model", + conversation=[], + tokenizer=MagicMock(), + request_metadata=MagicMock(), + ) + ) + + assert not any("Error in chat completion stream generator" in line for line in raw_lines) + chunks = _parse_sse_chunks(raw_lines) + finish_reasons = [ch["finish_reason"] for c in chunks for ch in c.get("choices", [])] + assert finish_reasons.count("stop") == 1, f"Expected 1 stop, got {finish_reasons}" + assert finish_reasons[-1] == "stop" + + +@pytest.mark.asyncio +async def test_audio_choice_error_response_is_not_iterated_as_choices(): + """Any ErrorResponse from the audio path is skipped, not iterated.""" + from vllm.entrypoints.openai.engine.protocol import ErrorResponse + + serving_chat = _build_serving_chat() + request = _make_request(modalities=["text", "audio"]) + + serving_chat._create_audio_choice = MagicMock( + side_effect=lambda omni_res, role, request, stream=False: serving_chat._create_error_response("boom") + ) + + async def result_generator(): + yield _make_text_omni_output(text="hi", token_ids=[10], finish_reason="stop") + yield _make_audio_omni_output() + + raw_lines = await _collect_stream( + serving_chat.chat_completion_stream_generator( + request=request, + result_generator=result_generator(), + request_id="test-req", + model_name="test-model", + conversation=[], + tokenizer=MagicMock(), + request_metadata=MagicMock(), + ) + ) + + assert isinstance(serving_chat._create_error_response("boom"), ErrorResponse) + assert not any("AttributeError" in line for line in raw_lines) + chunks = _parse_sse_chunks(raw_lines) + finish_reasons = [ch["finish_reason"] for c in chunks for ch in c.get("choices", [])] + assert finish_reasons.count("stop") == 1, f"Expected 1 stop, got {finish_reasons}" + + # --------------------------------------------------------------------------- # Tests: voice/speaker parameter compatibility # --------------------------------------------------------------------------- diff --git a/tests/entrypoints/test_utils.py b/tests/entrypoints/test_utils.py index b48ed2d91..c6d8e0354 100644 --- a/tests/entrypoints/test_utils.py +++ b/tests/entrypoints/test_utils.py @@ -3,6 +3,8 @@ import os from collections import Counter from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace import pytest import torch @@ -13,6 +15,10 @@ from vllm_omni.config.yaml_util import create_config from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.engine.arg_utils import OmniEngineArgs from vllm_omni.engine.async_omni_engine import AsyncOmniEngine +from vllm_omni.engine.stage_init_utils import ( + get_stage_connector_spec, + load_omni_transfer_config_for_model, +) from vllm_omni.entrypoints.utils import ( _convert_dataclasses_to_dict, _filter_dict_like_object, @@ -313,15 +319,71 @@ class TestResolveModelConfigPath: assert result is not None assert "glm_image.yaml" in result + def test_uses_registry_default_deploy_when_hf_model_type_differs( + self, + mocker: MockerFixture, + tmp_path, + ): + from vllm_omni.config.stage_config import _DEPLOY_DIR as _REAL_DEPLOY_DIR + from vllm_omni.entrypoints import utils as utils_mod + + hf_config = SimpleNamespace( + model_type="minicpmo", + architectures=["MiniCPMO"], + version="4.5", + ) + model = "local/minicpmo-registry-default-test" + mocker.patch( + "vllm_omni.entrypoints.utils.get_config", + return_value=hf_config, + ) + mocker.patch( + "vllm_omni.config.config_factory.get_config", + return_value=hf_config, + ) + # A bare deploy/.yaml must not win over the registered + # pipeline default when HF model_type is not an OMNI_PIPELINES key. + (tmp_path / "minicpmo.yaml").write_text("stages: []\n", encoding="utf-8") + (tmp_path / "minicpmo_4_5.yaml").write_bytes((_REAL_DEPLOY_DIR / "minicpmo_4_5.yaml").read_bytes()) + mocker.patch.object(utils_mod, "_DEPLOY_DIR", tmp_path) + + result = resolve_model_config_path(model) + + assert result is not None + assert Path(result).as_posix().endswith("minicpmo_4_5.yaml") + assert Path(result).name == "minicpmo_4_5.yaml" + transfer_config = load_omni_transfer_config_for_model(model, result) + assert transfer_config is not None + + sender = get_stage_connector_spec( + omni_transfer_config=transfer_config, + stage_id=1, + async_chunk=True, + ) + receiver = get_stage_connector_spec( + omni_transfer_config=transfer_config, + stage_id=2, + async_chunk=True, + ) + assert sender["extra"]["role"] == "sender" + assert receiver["extra"]["role"] == "receiver" + assert sender["extra"]["connector_get_sleep_s"] == 0.01 + assert sender["extra"]["connector_get_max_wait_first_chunk"] == 3000 + assert sender["extra"]["connector_get_max_wait"] == 300 + class TestLoadAndResolveStageConfigs: - def test_load_and_resolve_with_kwargs(self): + def test_load_and_resolve_with_kwargs(self, mocker: MockerFixture): """Ensure that dtype survives default stage creation.""" kwargs = {"dtype": torch.float32} + mocker.patch("vllm_omni.entrypoints.utils.resolve_model_config_path", return_value=None) + mocker.patch("vllm_omni.entrypoints.utils.load_stage_configs_from_model", return_value=([], None)) + config_path, stage_configs, _ = load_and_resolve_stage_configs( model="black-forest-labs/FLUX.2-klein-4B", stage_configs_path=None, kwargs=kwargs, + trust_remote_code=False, default_stage_cfg_factory=lambda: AsyncOmniEngine._create_default_diffusion_stage_cfg(kwargs), ) assert config_path is None @@ -356,10 +418,12 @@ class TestLoadAndResolveStageConfigs: model="dummy-model", stage_configs_path=str(deploy_path), kwargs={}, + trust_remote_code=True, ) load_stage_configs.assert_called_once_with( "dummy-model", + trust_remote_code=True, base_engine_args={}, deploy_config_path=str(deploy_path), stage_overrides=None, diff --git a/tests/examples/offline_inference/test_ltx2_cfg_parallel_parity.py b/tests/examples/offline_inference/test_ltx2_cfg_parallel_parity.py deleted file mode 100644 index 757d906ec..000000000 --- a/tests/examples/offline_inference/test_ltx2_cfg_parallel_parity.py +++ /dev/null @@ -1,243 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import hashlib -import os -import subprocess -import sys -from pathlib import Path - -import numpy as np -import pytest -from PIL import Image - -from tests.helpers.mark import hardware_test - -REPO_ROOT = Path(__file__).resolve().parents[3] -T2V_EXAMPLE = REPO_ROOT / "examples" / "offline_inference" / "text_to_video" / "text_to_video.py" -I2V_EXAMPLE = REPO_ROOT / "examples" / "offline_inference" / "image_to_video" / "image_to_video.py" - -T2V_PROMPT = ( - "At sunrise, a glowing paper lantern boat drifts through a narrow canal between mossy stone walls, " - "soft fog above the water, the camera slowly gliding forward as golden reflections shimmer across " - "the ripples, cinematic, realistic, highly detailed." -) -T2V_NEGATIVE_PROMPT = "worst quality, blurry, jittery motion, distorted, oversaturated, artifacts" -I2V_PROMPT = "A cinematic dolly shot of a boat drifting on calm water at sunset" -I2V_NEGATIVE_PROMPT = "worst quality, blurry, jittery motion" - -os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" - - -def _get_ltx2_model() -> str: - return "Lightricks/LTX-2" - - -def _md5(path: Path) -> str: - digest = hashlib.md5(usedforsecurity=False) - with path.open("rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def _make_deterministic_test_image(path: Path) -> None: - """Create a deterministic 256x256 test image for I2V tests.""" - rng = np.random.RandomState(42) - img = Image.fromarray(rng.randint(0, 255, (256, 256, 3), dtype=np.uint8)) - img.save(path) - - -def _run_and_check(cmd: list[str], env: dict, output_path: Path, expected_md5: str) -> None: - result = subprocess.run(cmd, cwd=REPO_ROOT, env=env, capture_output=True, text=True, check=False) - assert result.returncode == 0, ( - f"Command failed (exit {result.returncode}).\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) - generated_md5 = _md5(output_path) - assert generated_md5 == expected_md5, ( - f"Unexpected output md5: {generated_md5} != {expected_md5}.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) - - -# ── T2V tests ── - - -@pytest.mark.full_model -@pytest.mark.diffusion -@pytest.mark.parallel -@pytest.mark.slow -@hardware_test(res={"cuda": "L4"}, num_cards=2) -def test_ltx2_t2v_cfg_parallel(tmp_path: Path): - """T2V with CFG=4.0, cfg-parallel-size=2.""" - output = tmp_path / "t2v_cfg4.mp4" - env = os.environ.copy() - env.setdefault("CUDA_VISIBLE_DEVICES", "0,1") - cmd = [ - sys.executable, - str(T2V_EXAMPLE), - "--model", - _get_ltx2_model(), - "--prompt", - T2V_PROMPT, - "--negative-prompt", - T2V_NEGATIVE_PROMPT, - "--height", - "256", - "--width", - "256", - "--num-frames", - "145", - "--num-inference-steps", - "6", - "--guidance-scale", - "4.0", - "--frame-rate", - "24", - "--fps", - "24", - "--seed", - "42", - "--cfg-parallel-size", - "2", - "--enforce-eager", - "--output", - str(output), - ] - _run_and_check(cmd, env, output, expected_md5="08e606b9c522fee4b6f30cee8b77db40") - - -@pytest.mark.full_model -@pytest.mark.diffusion -@pytest.mark.slow -@hardware_test(res={"cuda": "L4"}, num_cards=1) -def test_ltx2_t2v_no_cfg(tmp_path: Path): - """T2V with CFG=1.0 (no classifier-free guidance).""" - output = tmp_path / "t2v_nocfg.mp4" - env = os.environ.copy() - env.setdefault("CUDA_VISIBLE_DEVICES", "0") - cmd = [ - sys.executable, - str(T2V_EXAMPLE), - "--model", - _get_ltx2_model(), - "--prompt", - T2V_PROMPT, - "--height", - "256", - "--width", - "256", - "--num-frames", - "145", - "--num-inference-steps", - "6", - "--guidance-scale", - "1.0", - "--frame-rate", - "24", - "--fps", - "24", - "--seed", - "42", - "--enforce-eager", - "--output", - str(output), - ] - _run_and_check(cmd, env, output, expected_md5="a83994b94b6e67c54a524e0383c45ce8") - - -# ── I2V tests ── - - -@pytest.mark.full_model -@pytest.mark.diffusion -@pytest.mark.parallel -@pytest.mark.slow -@hardware_test(res={"cuda": "L4"}, num_cards=2) -def test_ltx2_i2v_cfg_parallel(tmp_path: Path): - """I2V with CFG=4.0, cfg-parallel-size=2.""" - test_image = tmp_path / "test_input.png" - _make_deterministic_test_image(test_image) - output = tmp_path / "i2v_cfg4.mp4" - env = os.environ.copy() - env.setdefault("CUDA_VISIBLE_DEVICES", "0,1") - cmd = [ - sys.executable, - str(I2V_EXAMPLE), - "--model", - _get_ltx2_model(), - "--model-class-name", - "LTX2ImageToVideoPipeline", - "--image", - str(test_image), - "--prompt", - I2V_PROMPT, - "--negative-prompt", - I2V_NEGATIVE_PROMPT, - "--height", - "256", - "--width", - "256", - "--num-frames", - "73", - "--num-inference-steps", - "6", - "--guidance-scale", - "4.0", - "--frame-rate", - "24", - "--fps", - "24", - "--seed", - "42", - "--cfg-parallel-size", - "2", - "--enforce-eager", - "--output", - str(output), - ] - _run_and_check(cmd, env, output, expected_md5="aed7e56084b36373244d8f839b16d115") - - -@pytest.mark.full_model -@pytest.mark.diffusion -@pytest.mark.slow -@hardware_test(res={"cuda": "L4"}, num_cards=1) -def test_ltx2_i2v_no_cfg(tmp_path: Path): - """I2V with CFG=1.0 (no classifier-free guidance).""" - test_image = tmp_path / "test_input.png" - _make_deterministic_test_image(test_image) - output = tmp_path / "i2v_nocfg.mp4" - env = os.environ.copy() - env.setdefault("CUDA_VISIBLE_DEVICES", "0") - cmd = [ - sys.executable, - str(I2V_EXAMPLE), - "--model", - _get_ltx2_model(), - "--model-class-name", - "LTX2ImageToVideoPipeline", - "--image", - str(test_image), - "--prompt", - I2V_PROMPT, - "--height", - "256", - "--width", - "256", - "--num-frames", - "73", - "--num-inference-steps", - "6", - "--guidance-scale", - "1.0", - "--frame-rate", - "24", - "--fps", - "24", - "--seed", - "42", - "--enforce-eager", - "--output", - str(output), - ] - _run_and_check(cmd, env, output, expected_md5="81b21ede12753e9e14a357a6c548b666") diff --git a/tests/examples/offline_inference/test_text_to_image.py b/tests/examples/offline_inference/test_text_to_image.py index 701f1a184..9b5597c3e 100644 --- a/tests/examples/offline_inference/test_text_to_image.py +++ b/tests/examples/offline_inference/test_text_to_image.py @@ -26,6 +26,9 @@ EXAMPLE_OUTPUT_SUBFOLDER = "example_offline_t2i" def _skip_readme_snippet(language: str, code: str, h2_title: str) -> tuple[bool, str]: if h2_title == "Web UI Demo": return True, f"README section '{h2_title}' is intentionally excluded for examples tests" + # Krea-2 Turbo/Raw README snippets currently fail in CI (more_cli_examples_006/007). + if "krea/Krea-2-Turbo" in code or "krea/Krea-2-Raw" in code: + return True, "Krea-2 README snippets are temporarily skipped (CI failures)" return False, "" diff --git a/tests/examples/test_minicpmo_realtime_duplex_simple_demo.py b/tests/examples/test_minicpmo_realtime_duplex_simple_demo.py new file mode 100644 index 000000000..93fc4db38 --- /dev/null +++ b/tests/examples/test_minicpmo_realtime_duplex_simple_demo.py @@ -0,0 +1,211 @@ +import base64 +import importlib.util +import sys +import wave +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +DEMO_PATH = Path(__file__).resolve().parents[2] / "examples/online_serving/minicpmo/realtime_duplex_demo.py" + + +def _load_demo_module(): + spec = importlib.util.spec_from_file_location( + "minicpmo_realtime_duplex_simple_demo_test", + DEMO_PATH, + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_post_commit_decision_ignores_streaming_listens_before_commit_ack(): + demo = _load_demo_module() + events = [ + {"type": "session.created"}, + {"type": "response.listen"}, + {"type": "response.listen"}, + {"type": "input_audio_buffer.committed"}, + ] + + commit_index = demo._input_committed_index(events, after_index=0) + + assert commit_index == 3 + assert demo._post_commit_model_decision(events, commit_index) is None + + +def test_post_commit_decision_accepts_listen_after_commit_ack(): + demo = _load_demo_module() + events = [ + {"type": "response.listen"}, + {"type": "input_audio_buffer.committed"}, + {"type": "response.listen"}, + ] + + commit_index = demo._input_committed_index(events, after_index=0) + + assert demo._post_commit_model_decision(events, commit_index) == "listen" + + +def test_post_commit_decision_accepts_drained_speak_after_commit_ack(): + demo = _load_demo_module() + events = [ + {"type": "input_audio_buffer.committed"}, + {"type": "response.created", "response": {"id": "resp-a"}}, + {"type": "response.audio.delta", "response_id": "resp-a", "delta": "AAAA"}, + {"type": "response.done", "response_id": "resp-a"}, + ] + + commit_index = demo._input_committed_index(events, after_index=0) + + assert demo._post_commit_model_decision(events, commit_index) == "speak" + + +def test_post_commit_decision_ignores_cancelled_response(): + demo = _load_demo_module() + events = [ + {"type": "input_audio_buffer.committed"}, + {"type": "response.done", "response": {"status": "cancelled"}}, + ] + + commit_index = demo._input_committed_index(events, after_index=0) + + assert demo._post_commit_model_decision(events, commit_index) is None + + +def test_commit_ack_search_starts_after_stream_cursor(): + demo = _load_demo_module() + events = [ + {"type": "input_audio_buffer.committed"}, + {"type": "response.listen"}, + {"type": "input_audio_buffer.committed"}, + ] + + assert demo._input_committed_index(events, after_index=1) == 2 + + +def test_exact_model_unit_boundary_does_not_require_an_extra_decision(): + demo = _load_demo_module() + unit_bytes = demo.PCM16_SAMPLE_RATE * demo.PCM16_BYTES_PER_SAMPLE + + assert not demo._has_residual_model_unit(b"\0" * (unit_bytes * 6), chunk_period_ms=1000) + + +def test_partial_model_unit_requires_a_post_commit_decision(): + demo = _load_demo_module() + unit_bytes = demo.PCM16_SAMPLE_RATE * demo.PCM16_BYTES_PER_SAMPLE + + assert demo._has_residual_model_unit(b"\0" * (unit_bytes * 6 + 512), chunk_period_ms=1000) + + +def test_latest_streaming_decision_is_used_at_exact_boundary(): + demo = _load_demo_module() + events = [ + {"type": "response.listen"}, + {"type": "response.listen"}, + {"type": "input_audio_buffer.committed"}, + ] + + assert demo._latest_model_decision(events, after_index=0) == "listen" + + +def test_ref_audio_data_url_encodes_explicit_wav(tmp_path): + demo = _load_demo_module() + ref = tmp_path / "ref.wav" + ref.write_bytes(b"RIFFfake") + + assert demo._ref_audio_data_url(str(ref)) == "data:audio/wav;base64,UklGRmZha2U=" + assert demo._ref_audio_data_url(None) is None + + +def test_realtime_duplex_demo_requires_ref_audio(monkeypatch): + demo = _load_demo_module() + monkeypatch.setattr( + demo.sys, + "argv", + [ + "realtime_duplex_demo.py", + "--input-wav", + "input.wav", + ], + ) + + with pytest.raises(SystemExit): + demo.parse_args() + + +def test_realtime_duplex_demo_accepts_explicit_ref_audio(monkeypatch): + demo = _load_demo_module() + monkeypatch.setattr( + demo.sys, + "argv", + [ + "realtime_duplex_demo.py", + "--input-wav", + "input.wav", + "--ref-audio", + "ref.wav", + ], + ) + + args = demo.parse_args() + + assert args.ref_audio == "ref.wav" + + +def test_open_streaming_response_requires_post_commit_drain(): + demo = _load_demo_module() + + assert demo._response_in_progress([{"type": "response.created"}]) + assert not demo._response_in_progress( + [ + {"type": "response.created"}, + {"type": "response.done"}, + ] + ) + + +def test_streaming_output_writer_persists_audio_deltas_as_they_arrive(tmp_path, capsys): + demo = _load_demo_module() + writer = demo._StreamingOutputWriter(tmp_path) + first_pcm = b"\x01\x00\x02\x00" + second_pcm = b"\x03\x00" + + writer.handle( + { + "type": "response.audio.delta", + "response_id": "resp-a", + "delta": base64.b64encode(first_pcm).decode("ascii"), + "sample_rate_hz": 24_000, + } + ) + writer.handle( + { + "type": "response.audio_transcript.delta", + "response_id": "resp-a", + "delta": "你好", + } + ) + writer.handle( + { + "type": "response.audio.delta", + "response_id": "resp-a", + "delta": base64.b64encode(second_pcm).decode("ascii"), + "sample_rate_hz": 24_000, + } + ) + + assert (tmp_path / "output.pcm").read_bytes() == first_pcm + second_pcm + chunk_paths = sorted((tmp_path / "audio_chunks").glob("*.wav")) + assert [path.name for path in chunk_paths] == ["chunk_0001.wav", "chunk_0002.wav"] + with wave.open(str(chunk_paths[0]), "rb") as chunk_wav: + assert chunk_wav.getframerate() == 24_000 + assert chunk_wav.readframes(chunk_wav.getnframes()) == first_pcm + assert writer.audio_chunk_paths == chunk_paths + stderr = capsys.readouterr().err + assert "audio chunk 1" in stderr + assert "你好" in stderr diff --git a/tests/examples/test_minicpmo_realtime_web_server.py b/tests/examples/test_minicpmo_realtime_web_server.py new file mode 100644 index 000000000..6c35760a7 --- /dev/null +++ b/tests/examples/test_minicpmo_realtime_web_server.py @@ -0,0 +1,99 @@ +import hashlib +import importlib.util +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +SERVER_PATH = Path(__file__).resolve().parents[2] / "examples/online_serving/minicpmo/realtime_web/server.py" +spec = importlib.util.spec_from_file_location( + "minicpmo_realtime_web_server_test", + SERVER_PATH, +) +assert spec is not None and spec.loader is not None +server = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = server +spec.loader.exec_module(server) + +APP_DIR = server.APP_DIR +STATIC_DIR = server.STATIC_DIR +_join_ws_url = server._join_ws_url +build_app = server.build_app + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_join_ws_url_preserves_realtime_query(): + assert ( + _join_ws_url( + "ws://127.0.0.1:8099/", + "/v1/realtime", + "duplex=1&model=openbmb%2FMiniCPM-o-4_5", + ) + == "ws://127.0.0.1:8099/v1/realtime?duplex=1&model=openbmb%2FMiniCPM-o-4_5" + ) + + +def test_build_app_serves_health_and_injected_client_config(): + app = build_app( + ws_backend="ws://127.0.0.1:9001", + model="local/MiniCPM-o-4_5", + ) + client = TestClient(app) + + health = client.get("/healthz") + assert health.status_code == 200 + assert health.text == "ok" + + index = client.get("/") + assert index.status_code == 200 + assert '"model": "local/MiniCPM-o-4_5"' in index.text + assert '"realtimePath": "v1/realtime"' in index.text + assert "__FULL_DUPLEX_CONFIG__" not in index.text + + +def test_build_app_injects_public_realtime_url(): + public_realtime_url = "wss://proxy.example.test/backend/v1/realtime" + app = build_app( + ws_backend="ws://127.0.0.1:9001", + public_realtime_url=public_realtime_url, + ) + client = TestClient(app) + + index = client.get("/") + + assert index.status_code == 200 + assert f'"realtimePath": "{public_realtime_url}"' in index.text + + +def test_build_app_versions_client_bundle_and_disables_index_cache(): + client = TestClient(build_app()) + expected_version_hash = hashlib.sha256() + for asset_name in ("app.js", "pcm_worklet.js", "playback_worklet.js"): + expected_version_hash.update((STATIC_DIR / asset_name).read_bytes()) + expected_version = expected_version_hash.hexdigest()[:12] + + assert (APP_DIR / "index.html").is_file() + assert (STATIC_DIR / "app.js").is_file() + assert not (APP_DIR.parent / "static").exists() + assert not (STATIC_DIR / "index.html").exists() + + index = client.get("/") + + assert index.status_code == 200 + assert f'src="static/app.js?v={expected_version}"' in index.text + assert index.headers["cache-control"] == "no-store" + + +def test_build_app_exposes_realtime_websocket_and_static_assets(): + app = build_app() + paths = {route.path for route in app.routes} + + assert "/v1/realtime" in paths + assert "/static" in paths + + client = TestClient(app) + assert client.get("/static/app.js").status_code == 200 + assert client.get("/static/pcm_worklet.js").status_code == 200 + assert client.get("/static/playback_worklet.js").status_code == 200 diff --git a/tests/examples/test_minicpmo_realtime_web_static.py b/tests/examples/test_minicpmo_realtime_web_static.py new file mode 100644 index 000000000..00859dabb --- /dev/null +++ b/tests/examples/test_minicpmo_realtime_web_static.py @@ -0,0 +1,274 @@ +import re +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] / "examples" / "online_serving" / "minicpmo" / "realtime_web" +APP_ROOT = ROOT / "app" +STATIC_ROOT = APP_ROOT / "static" + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_page_exposes_focused_call_conversation_and_log_surfaces(): + html = (APP_ROOT / "index.html").read_text(encoding="utf-8") + + assert 'id="callButton"' in html + assert 'id="muteButton"' in html + assert 'id="connectionState"' in html + assert 'id="modelState"' in html + assert 'id="conversation"' in html + assert 'id="eventLog"' in html + assert ".*?)\n\s*\)", + source, + re.DOTALL, + ) + assert ref_audio_arg is not None + assert "required=True" in ref_audio_arg.group("body") + assert "Optional reference voice" not in ref_audio_arg.group("body") + + +def test_client_sends_ref_audio_in_realtime_session_contract(): + source = (STATIC_ROOT / "app.js").read_text(encoding="utf-8") + + assert "if (config.refAudio) session.ref_audio = config.refAudio;" in source + assert "extraBody.ref_audio" not in source + + +def test_client_has_transactional_cleanup_and_visible_event_logging(): + source = (STATIC_ROOT / "app.js").read_text(encoding="utf-8") + + assert "async function stopSession" in source + assert "type: 'session.close'" in source + assert "waitForSessionClosed" in source + assert "SESSION_CLOSE_TIMEOUT_MS" in source + assert "case 'session.closed':" in source + assert "track.stop()" in source + assert "clearInterval(sendTimer)" in source + assert "appendEventLog(event)" in source + assert "stopSession({ terminal: false })" in source + + +def test_client_keeps_microphone_upload_active_during_assistant_playback(): + source = (STATIC_ROOT / "app.js").read_text(encoding="utf-8") + upload_gate = re.search(r"function microphoneUploadEnabled\(\) \{(?P.*?)\n \}", source, re.DOTALL) + begin_assistant = re.search(r"function beginAssistant\(responseId\) \{(?P.*?)\n \}", source, re.DOTALL) + + assert upload_gate is not None + assert "return running && !muted;" in upload_gate.group("body") + assert "assistantActive" not in upload_gate.group("body") + assert begin_assistant is not None + assert "pendingCapture = []" not in begin_assistant.group("body") + + +def test_audio_worklets_define_capture_and_playback_processors(): + capture = (STATIC_ROOT / "pcm_worklet.js").read_text(encoding="utf-8") + playback = (STATIC_ROOT / "playback_worklet.js").read_text(encoding="utf-8") + + assert "registerProcessor('fullduplex-pcm-capture'" in capture + assert "Int16Array" in capture + assert "registerProcessor('fullduplex-pcm-playback'" in playback + assert "playback-drained" in playback + assert "clear" in playback + + +def test_audio_worklet_urls_use_the_static_asset_version(): + server = (ROOT / "server.py").read_text(encoding="utf-8") + app = (STATIC_ROOT / "app.js").read_text(encoding="utf-8") + + assert '"appVersion": app_version' in server + assert 'STATIC_DIR / "playback_worklet.js"' in server + assert 'STATIC_DIR / "pcm_worklet.js"' in server + assert "staticAssetUrl('static/playback_worklet.js')" in app + assert "staticAssetUrl('static/pcm_worklet.js')" in app + + +def test_playback_worklet_buffers_first_400ms_and_reports_underruns(): + app = (STATIC_ROOT / "app.js").read_text(encoding="utf-8") + playback = (STATIC_ROOT / "playback_worklet.js").read_text(encoding="utf-8") + + assert "INITIAL_PLAYBACK_BUFFER_MS = 400" in app + assert "initialBufferMs" in app + assert "responseId" in app + assert "playback-underrun" in app + assert "underrunMs" in app + assert "initialBufferFrames" in playback + assert "playback-underrun" in playback + assert "underrunFrames" in playback + assert "underrunMs" in playback + + +def test_playback_worklet_waits_before_playing_and_rebuffers_after_underrun(): + node = shutil.which("node") + if node is None: + pytest.skip("node is required for the AudioWorklet regression test") + + script = textwrap.dedent( + """ + const fs = require('fs'); + const vm = require('vm'); + + global.sampleRate = 1000; + global.AudioWorkletProcessor = class { + constructor() { + this.port = { onmessage: null, postMessage: () => {} }; + } + }; + let Processor = null; + global.registerProcessor = (_name, processor) => { Processor = processor; }; + vm.runInThisContext(fs.readFileSync(process.argv[1], 'utf8')); + + const processor = new Processor(); + const render = () => { + const output = new Float32Array(100); + processor.process([], [[output]]); + return output; + }; + const assert = (condition, message) => { + if (!condition) throw new Error(message); + }; + + const first = new Int16Array(150); + first.fill(16384); + processor.handleMessage({ + type: 'audio', + pcm: first, + responseId: 'response-1', + initialBufferMs: 400, + }); + assert(!processor.started, 'large first delta must not bypass wall-clock prebuffer'); + assert(render().every((sample) => sample === 0), 'first prebuffer render must be silent'); + assert(render().every((sample) => sample === 0), 'second prebuffer render must be silent'); + assert(render().every((sample) => sample === 0), 'third prebuffer render must be silent'); + assert(render().every((sample) => sample === 0), 'fourth prebuffer render must be silent'); + const firstPlayback = render(); + assert(firstPlayback.some((sample) => sample !== 0), 'playback must start after prebuffer'); + + const underrun = render(); + assert(!processor.started, 'an empty queue must return to buffering'); + assert(underrun[underrun.length - 1] === 0, 'underrun boundary must fade to zero'); + + const resumed = new Int16Array(300); + resumed.fill(8192); + processor.handleMessage({ + type: 'audio', + pcm: resumed, + responseId: 'response-1', + initialBufferMs: 400, + }); + assert(render().every((sample) => sample === 0), 'resume must rebuild jitter buffer'); + assert(render().every((sample) => sample === 0), 'resume must keep rebuilding jitter buffer'); + assert(render().every((sample) => sample === 0), 'resume must keep waiting'); + assert(render().every((sample) => sample === 0), 'resume must wait the full buffer interval'); + const resumedPlayback = render(); + assert(resumedPlayback.some((sample) => sample !== 0), 'playback must resume after rebuffer'); + assert( + Math.abs(resumedPlayback[0]) < Math.abs(resumedPlayback[50]), + 'resumed playback must fade in instead of jumping from silence', + ); + """ + ) + subprocess.run( + [node, "-e", script, str(STATIC_ROOT / "playback_worklet.js")], + check=True, + capture_output=True, + text=True, + ) + + +def test_playback_worklet_fades_terminal_drain_to_zero(): + node = shutil.which("node") + if node is None: + pytest.skip("node is required for the AudioWorklet regression test") + + script = textwrap.dedent( + """ + const fs = require('fs'); + const vm = require('vm'); + + global.sampleRate = 1000; + const messages = []; + global.AudioWorkletProcessor = class { + constructor() { + this.port = { + onmessage: null, + postMessage: (message) => messages.push(message), + }; + } + }; + let Processor = null; + global.registerProcessor = (_name, processor) => { Processor = processor; }; + vm.runInThisContext(fs.readFileSync(process.argv[1], 'utf8')); + + const processor = new Processor(); + const pcm = new Int16Array(100); + pcm.fill(16384); + processor.handleMessage({ + type: 'audio', + pcm, + responseId: 'response-terminal', + initialBufferMs: 400, + }); + processor.handleMessage({ + type: 'drain', + responseId: 'response-terminal', + }); + + const output = new Float32Array(100); + processor.process([], [[output]]); + const assert = (condition, message) => { + if (!condition) throw new Error(message); + }; + + assert(output.some((sample) => sample !== 0), 'terminal audio must still play'); + assert(output[output.length - 1] === 0, 'terminal drain must fade the final sample to zero'); + const tail = Array.from(output.slice(-5), Math.abs); + assert( + tail.every((sample, index) => index === 0 || sample <= tail[index - 1]), + 'terminal drain fade must decrease monotonically', + ); + assert( + messages.filter((message) => message.type === 'playback-drained').length === 1, + 'terminal drain must be reported exactly once after playback', + ); + """ + ) + subprocess.run( + [node, "-e", script, str(STATIC_ROOT / "playback_worklet.js")], + check=True, + capture_output=True, + text=True, + ) diff --git a/tests/gr00t/openpi_client_helper.py b/tests/gr00t/openpi_client_helper.py deleted file mode 100644 index e5943ca33..000000000 --- a/tests/gr00t/openpi_client_helper.py +++ /dev/null @@ -1,183 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -from dataclasses import dataclass -from typing import Any - -import numpy as np - -try: - import websockets.sync.client as websockets_client -except ImportError: # pragma: no cover - optional e2e dependency - websockets_client = None - -try: - from openpi_client import msgpack_numpy -except ImportError: # pragma: no cover - optional e2e dependency - msgpack_numpy = None - -PING_INTERVAL_SECS = 300 -PING_TIMEOUT_SECS = 3600 -DEFAULT_HOST = "127.0.0.1" -DEFAULT_PORT = 8000 -DEFAULT_PATH = "/v1/realtime/robot/openpi" -DEFAULT_SESSION_ID = "gr00t-smoke" -ACTION_KEYS = {"eef_9d", "gripper_position", "joint_position"} -LANGUAGE_KEY = "annotation.language.language_instruction" - - -def _identity_eef_9d_state() -> np.ndarray: - state = np.zeros((1, 1, 9), dtype=np.float32) - state[..., 3:] = np.array([1, 0, 0, 0, 1, 0], dtype=np.float32) - return state - - -def require_dependencies() -> None: - missing = [] - if websockets_client is None: - missing.append("websockets") - if msgpack_numpy is None: - missing.append("openpi-client") - if missing: - raise ModuleNotFoundError(f"GR00T OpenPI test dependencies are missing: {', '.join(missing)}") - - -@dataclass(frozen=True) -class Gr00tServerMetadata: - action_horizon: int - action_keys: set[str] - embodiment_tag: str - needs_session_id: bool - - @classmethod - def from_dict(cls, payload: dict[str, Any]): - required_keys = ("action_horizon", "action_keys", "embodiment_tag", "needs_session_id") - missing_keys = [key for key in required_keys if key not in payload] - if missing_keys: - raise ValueError(f"Missing GR00T metadata keys: {missing_keys}") - - return cls( - action_horizon=int(payload["action_horizon"]), - action_keys={str(key) for key in payload["action_keys"]}, - embodiment_tag=str(payload["embodiment_tag"]), - needs_session_id=bool(payload["needs_session_id"]), - ) - - -class OpenPIWebsocketClient: - def __init__( - self, - *, - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - path: str = DEFAULT_PATH, - ) -> None: - require_dependencies() - self._uri = f"ws://{host}:{port}{path}" - self._packer = msgpack_numpy.Packer() - self._ws, self._server_metadata = self._connect() - - def _connect(self): - conn = websockets_client.connect( - self._uri, - compression=None, - max_size=None, - ping_interval=PING_INTERVAL_SECS, - ping_timeout=PING_TIMEOUT_SECS, - ) - metadata = msgpack_numpy.unpackb(conn.recv()) - if not isinstance(metadata, dict): - raise TypeError(f"Expected dict metadata from server, got {type(metadata)!r}") - return conn, metadata - - def get_server_metadata(self) -> dict[str, Any]: - return dict(self._server_metadata) - - def infer(self, obs: dict[str, Any]) -> dict[str, np.ndarray]: - payload = dict(obs) - payload["endpoint"] = "infer" - self._ws.send(self._packer.pack(payload)) - response = msgpack_numpy.unpackb(self._ws.recv()) - if isinstance(response, dict) and response.get("type") == "error": - raise RuntimeError(f"Inference failed: {response['message']}") - if not isinstance(response, dict): - raise RuntimeError(f"Expected dict actions from GR00T OpenPI endpoint, got {type(response)!r}") - return {str(key): np.asarray(value, dtype=np.float32) for key, value in response.items()} - - def reset(self, reset_info: dict[str, Any] | None = None) -> str: - payload = dict(reset_info or {}) - payload["endpoint"] = "reset" - self._ws.send(self._packer.pack(payload)) - response = msgpack_numpy.unpackb(self._ws.recv()) - if not isinstance(response, dict) or response.get("status") != "reset successful": - raise RuntimeError(f"Unexpected reset response: {response!r}") - return str(response["status"]) - - def close(self) -> None: - self._ws.close() - - -def build_droid_observation(*, session_id: str = DEFAULT_SESSION_ID) -> dict[str, Any]: - return { - "session_id": session_id, - "video": { - "exterior_image_1_left": np.zeros((1, 2, 256, 256, 3), dtype=np.uint8), - "wrist_image_left": np.zeros((1, 2, 256, 256, 3), dtype=np.uint8), - }, - "state": { - "eef_9d": _identity_eef_9d_state(), - "gripper_position": np.zeros((1, 1, 1), dtype=np.float32), - "joint_position": np.zeros((1, 1, 7), dtype=np.float32), - }, - "language": {LANGUAGE_KEY: [["pick up the object"]]}, - } - - -def run_policy_session( - *, - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - path: str = DEFAULT_PATH, - session_id: str = DEFAULT_SESSION_ID, -) -> dict[str, Any]: - client = OpenPIWebsocketClient(host=host, port=port, path=path) - try: - metadata = client.get_server_metadata() - actions = client.infer(build_droid_observation(session_id=session_id)) - reset_status = client.reset({}) - return { - "metadata": metadata, - "actions": actions, - "reset_status": reset_status, - "session_id": session_id, - } - finally: - client.close() - - -def validate_session_result(result: dict[str, Any]) -> None: - metadata = Gr00tServerMetadata.from_dict(result["metadata"]) - if metadata.embodiment_tag != "OXE_DROID_RELATIVE_EEF_RELATIVE_JOINT": - raise AssertionError(f"Unexpected embodiment_tag: {metadata.embodiment_tag}") - if not metadata.needs_session_id: - raise AssertionError("GR00T test expects needs_session_id metadata") - if metadata.action_keys != ACTION_KEYS: - raise AssertionError(f"Unexpected action keys: {metadata.action_keys}") - if result["reset_status"] != "reset successful": - raise AssertionError(f"Unexpected reset status: {result['reset_status']!r}") - - actions = result["actions"] - if set(actions) != ACTION_KEYS: - raise AssertionError(f"Unexpected action keys: {set(actions)}") - expected_shapes = { - "eef_9d": (1, metadata.action_horizon, 9), - "gripper_position": (1, metadata.action_horizon, 1), - "joint_position": (1, metadata.action_horizon, 7), - } - for key, expected_shape in expected_shapes.items(): - if actions[key].shape != expected_shape: - raise AssertionError(f"Action {key} shape mismatch: expected {expected_shape}, got {actions[key].shape}") - if actions[key].dtype != np.float32: - raise AssertionError(f"Action {key} dtype mismatch: expected float32, got {actions[key].dtype}") - if not np.isfinite(actions[key]).all(): - raise AssertionError(f"Action {key} contains non-finite values") diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 04d51fb90..9929beab8 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1,10 +1,11 @@ """Shared, importable test helper utilities. Submodules (``assertions``, ``env``, ``media``, ``runtime``, …) are imported -explicitly by callers. Avoid star-importing everything here: that ran before -refactor only inside the old monolithic ``conftest``; a greedy ``__init__`` -changes import order and can affect in-process Omni (``OmniRunner`` / offline -e2e) vs subprocess-based ``OmniServer`` tests. +explicitly by callers. Unit tests for these helpers live under ``tests/``. +Avoid star-importing everything here: that ran before refactor only inside the +old monolithic ``conftest``; a greedy ``__init__`` changes import order and can +affect in-process Omni (``OmniRunner`` / offline e2e) vs subprocess-based +``OmniServer`` tests. """ import pytest diff --git a/tests/helpers/assertions.py b/tests/helpers/assertions.py index 11e86ed8c..9fe34f9b5 100644 --- a/tests/helpers/assertions.py +++ b/tests/helpers/assertions.py @@ -527,7 +527,7 @@ def _resolve_audio_transcript( audio_bytes = getattr(response, "audio_bytes", None) if not audio_bytes: return None - return convert_audio_bytes_to_text(audio_bytes) + return convert_audio_bytes_to_text(audio_bytes, language=request_config.get("transcript_language")) def assert_omni_response(response: Any, request_config: dict[str, Any], run_level): @@ -632,6 +632,7 @@ def _assert_transcript_matches( *, threshold: float, escalation_model: str | None = None, + language: str | None = None, ) -> None: """Assert spoken audio matches ``expected_text``. @@ -647,7 +648,8 @@ def _assert_transcript_matches( clip is re-transcribed with that stronger ASR and the assertion is decided on its verdict -- so a weak whisper-``small`` mishear on a short clip does not flake the gate, while a genuine model artifact still fails (the strong ASR - mismatches too). + mismatches too). ``language`` applies to that escalated pass too, so a request + that pins a language does not silently fall back to auto-detection on retry. """ expected = str(expected_text).strip().lower() similarity = cosine_similarity_text(transcript.strip().lower(), expected) @@ -664,7 +666,7 @@ def _assert_transcript_matches( f"whisper-small below threshold ({similarity:.2f} <= {threshold}); " f"escalating to whisper-{escalation_model} to rule out an ASR mishear" ) - strong_transcript = convert_audio_bytes_to_text(audio_bytes, model_size=escalation_model) + strong_transcript = convert_audio_bytes_to_text(audio_bytes, model_size=escalation_model, language=language) strong_similarity = cosine_similarity_text(strong_transcript.strip().lower(), expected) print( f"audio content (whisper-{escalation_model}): {strong_transcript}\n" @@ -741,6 +743,7 @@ def assert_audio_speech_response(response: Any, request_config: dict[str, Any], expected_text, threshold=0.9, escalation_model=request_config.get("transcript_escalation_model"), + language=request_config.get("transcript_language"), ) _assert_preset_voice_gender_from_audio( response.audio_bytes, diff --git a/tests/helpers/env.py b/tests/helpers/env.py index dcf2484cd..39cec30e7 100644 --- a/tests/helpers/env.py +++ b/tests/helpers/env.py @@ -27,6 +27,32 @@ def get_physical_device_indices(devices): return [index_mapping[i] for i in devices if i in index_mapping] +def pick_least_used_device_indices(num_devices: int) -> list[int]: + """Return physical device indices for the least-used accelerators. + + Args: + num_devices: Number of device indices to return. + + Returns: + Physical device indices sorted from least to most used memory. + """ + if num_devices <= 0: + raise ValueError(f"num_devices must be positive, got {num_devices}.") + + logical_count = current_omni_platform.get_device_count() + if logical_count < num_devices: + raise RuntimeError(f"Need {num_devices} devices, but only {logical_count} are available.") + + device_usage: list[tuple[int, int]] = [] + for device_index in range(logical_count): + with current_omni_platform.device(device_index): + free_bytes, total_bytes = current_omni_platform.get_device_memory() + device_usage.append((total_bytes - free_bytes, device_index)) + device_usage.sort() + logical_indices = [device_index for _, device_index in device_usage[:num_devices]] + return get_physical_device_indices(logical_indices) + + def wait_for_gpu_memory_to_clear( *, devices: list[int], @@ -264,6 +290,7 @@ class DeviceMemoryMonitor: __all__ = [ "DeviceMemoryMonitor", "get_physical_device_indices", + "pick_least_used_device_indices", "run_post_test_cleanup", "run_pre_test_cleanup", "wait_for_gpu_memory_to_clear", diff --git a/tests/helpers/mark.py b/tests/helpers/mark.py index 3475a0012..676732a8e 100644 --- a/tests/helpers/mark.py +++ b/tests/helpers/mark.py @@ -2,10 +2,69 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Pytest marks and decorators for hardware / resource selection (CUDA, ROCm, …).""" +from __future__ import annotations + +import re +from functools import lru_cache +from pathlib import Path + import pytest from vllm.platforms import current_platform -# Re-exported from tests.helpers.env (GPU wait + DeviceMemoryMonitor). +# Marker description tag in ``pyproject.toml`` ``tool.pytest.ini_options.markers``. +# Example: ``"H100: [hardware-resource] Tests that require H100 GPU"``. +_HARDWARE_RESOURCE_MARKER_TAG = "[hardware-resource]" + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _load_pyproject() -> dict: + path = _repo_root() / "pyproject.toml" + try: + try: + import tomllib + except ModuleNotFoundError: # Python < 3.11 + import tomli as tomllib # type: ignore[no-redef] + + with path.open("rb") as f: + return tomllib.load(f) + except Exception: + return {} + + +@lru_cache(maxsize=1) +def get_hardware_mark_list() -> frozenset[str]: + """Return hardware SKU marker names from ``pyproject.toml``. + + A marker is treated as a hardware resource marker when its description + contains the ``[hardware-resource]`` tag, e.g.:: + + "H100: [hardware-resource] Tests that require H100 GPU" + """ + data = _load_pyproject() + entries = data.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("markers", []) + if not entries: + # Fallback when TOML parsing fails: scan tagged marker lines. + text = (_repo_root() / "pyproject.toml").read_text(encoding="utf-8") + return frozenset( + re.findall( + rf'^\s*"([A-Za-z0-9_]+)\s*:\s*{re.escape(_HARDWARE_RESOURCE_MARKER_TAG)}', + text, + flags=re.M, + ) + ) + + names: set[str] = set() + for entry in entries: + text = str(entry) + if _HARDWARE_RESOURCE_MARKER_TAG not in text: + continue + name = text.split(":", 1)[0].strip() + if name: + names.add(name) + return frozenset(names) def cuda_marks(*, res: str, num_cards: int): diff --git a/tests/helpers/media.py b/tests/helpers/media.py index e50a9f001..93e136f57 100644 --- a/tests/helpers/media.py +++ b/tests/helpers/media.py @@ -529,6 +529,26 @@ def decode_b64_image(b64: str): return img +def concat_audio(audio_val) -> np.ndarray: + """Flatten a multimodal audio payload to mono float32 samples. + + Engines return ``multimodal_output["audio"]`` as a tensor, a list of + per-chunk tensors (streaming decoders), or an array-like; concatenate + in order and return a 1-D ``np.float32`` array (empty when a list has + no tensors). + """ + import torch + + if isinstance(audio_val, list): + tensors = [t.detach().cpu().float().reshape(-1) for t in audio_val if isinstance(t, torch.Tensor)] + if not tensors: + return np.zeros((0,), dtype=np.float32) + return torch.cat(tensors, dim=-1).numpy().astype(np.float32, copy=False) + if isinstance(audio_val, torch.Tensor): + return audio_val.detach().cpu().float().reshape(-1).numpy() + return np.asarray(audio_val, dtype=np.float32).reshape(-1) + + def preprocess_text(text): import opencc @@ -632,7 +652,9 @@ def _serialize_whisper_model_download(model_size: str = "small"): f.close() -def _whisper_transcribe_in_current_process(output_path: str, model_size: str = "small") -> str: +def _whisper_transcribe_in_current_process( + output_path: str, model_size: str = "small", language: str | None = None +) -> str: import whisper device_index = None @@ -664,6 +686,9 @@ def _whisper_transcribe_in_current_process(output_path: str, model_size: str = " temperature=0.0, word_timestamps=True, condition_on_previous_text=False, + # None keeps whisper's auto-detection. Do not default this to a + # language: callers include non-English audio tests. + language=language, )["text"] finally: del model @@ -674,15 +699,15 @@ def _whisper_transcribe_in_current_process(output_path: str, model_size: str = " return text or "" -def convert_audio_file_to_text(output_path: str, model_size: str = "small") -> str: +def convert_audio_file_to_text(output_path: str, model_size: str = "small", language: str | None = None) -> str: """Convert an audio file to text in an isolated subprocess.""" ctx = multiprocessing.get_context("spawn") with concurrent.futures.ProcessPoolExecutor(max_workers=1, mp_context=ctx) as executor: - future = executor.submit(_whisper_transcribe_in_current_process, output_path, model_size) + future = executor.submit(_whisper_transcribe_in_current_process, output_path, model_size, language) return future.result() -def convert_audio_bytes_to_text(raw_bytes: bytes, model_size: str = "small") -> str: +def convert_audio_bytes_to_text(raw_bytes: bytes, model_size: str = "small", language: str | None = None) -> str: output_fd, output_path = tempfile.mkstemp(prefix="test_", suffix=".wav") os.close(output_fd) if os.environ.get("VLLM_OMNI_KEEP_REQUEST_MEDIA", "").lower() not in ("1", "true", "yes"): @@ -690,11 +715,12 @@ def convert_audio_bytes_to_text(raw_bytes: bytes, model_size: str = "small") -> data, samplerate = sf.read(io.BytesIO(raw_bytes)) sf.write(output_path, data, samplerate, format="WAV", subtype="PCM_16") print(f"audio data is saved: {output_path}") - return convert_audio_file_to_text(output_path, model_size) + return convert_audio_file_to_text(output_path, model_size, language) __all__ = [ "_merge_base64_audio_to_segment", + "concat_audio", "convert_audio_bytes_to_text", "convert_audio_file_to_text", "cosine_similarity_text", diff --git a/tests/helpers/minicpmo_4_5_duplex.py b/tests/helpers/minicpmo_4_5_duplex.py new file mode 100644 index 000000000..e19903e84 --- /dev/null +++ b/tests/helpers/minicpmo_4_5_duplex.py @@ -0,0 +1,192 @@ +"""Shared fixtures and argument builders for MiniCPM-o 4.5 duplex E2E tests.""" + +from __future__ import annotations + +import hashlib +import uuid +import wave +from pathlib import Path +from types import SimpleNamespace + +import pytest +from huggingface_hub import snapshot_download + +from tests.helpers.runtime import OmniServerParams +from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config + +MODEL = "openbmb/MiniCPM-o-4_5" +DEPLOY_CONFIG = modify_stage_config( + get_deploy_config_path("minicpmo_4_5_duplex.yaml"), + updates={ + "base_config": get_deploy_config_path("minicpmo_4_5.yaml"), + "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, + } + ] + } + }, + }, +) +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" +RESPONSE_REQUIRED_WAV = ASSET_DIR / "response_required_16k.wav" +RESPONSE_REQUIRED_SHA256 = "2e5fd4eb3ee434ce107ee3a0591fa624a33f7683c7462f45fe651c443c9af941" +SOFT_INTERRUPT_WAV = ASSET_DIR / "soft_interrupt_16k.wav" +SOFT_INTERRUPT_SHA256 = "cadae6d0ddc510310f16f8775d6379f30a5195369ccb54ff10a8e3f9a1f4a2ea" +REF_AUDIO_RELATIVE_PATH = Path("assets") / "HT_ref_audio.wav" + +SERVER_PARAMS = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=DEPLOY_CONFIG, + use_stage_cli=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, + server_args=["--trust-remote-code"], + ), + id="three-stage-single-gpu", + ) +] + + +def validated_wav(path: Path, expected_sha256: str) -> Path: + actual_sha256 = hashlib.sha256(path.read_bytes()).hexdigest() + if actual_sha256 != expected_sha256: + raise AssertionError( + f"MiniCPM-o duplex fixture SHA256 mismatch: expected {expected_sha256}, got {actual_sha256}" + ) + with wave.open(str(path), "rb") as wav_file: + actual_format = ( + wav_file.getframerate(), + wav_file.getnchannels(), + wav_file.getsampwidth(), + wav_file.getcomptype(), + ) + expected_format = (16_000, 1, 2, "NONE") + if actual_format != expected_format: + raise AssertionError(f"MiniCPM-o duplex fixture must be 16 kHz mono PCM16, got {actual_format}") + return path + + +def validated_input_wav() -> Path: + return validated_wav(RESPONSE_REQUIRED_WAV, RESPONSE_REQUIRED_SHA256) + + +def validated_soft_interrupt_wav() -> Path: + return validated_wav(SOFT_INTERRUPT_WAV, SOFT_INTERRUPT_SHA256) + + +def resolve_ref_audio(model_prefix: str) -> Path: + if model_prefix: + model_root = Path(model_prefix) / MODEL + else: + model_root = Path(snapshot_download(MODEL, local_files_only=True)) + ref_audio = model_root / REF_AUDIO_RELATIVE_PATH + if not ref_audio.is_file(): + raise FileNotFoundError(f"MiniCPM-o checkpoint ref audio is missing: {ref_audio}") + return ref_audio + + +def realtime_url(omni_server) -> str: + return f"ws://{omni_server.host}:{omni_server.port}/v1/realtime?duplex=1" + + +def demo_args( + *, + omni_server, + input_wav: Path, + ref_audio: Path, + output_dir: Path, +) -> SimpleNamespace: + return SimpleNamespace( + url=realtime_url(omni_server), + model=omni_server.model, + session_id=f"duplex-ci-single-{uuid.uuid4().hex}", + input_wav=str(input_wav), + ref_audio=str(ref_audio), + turn_input_wav=[], + output_dir=str(output_dir), + output_audio_format="pcm16", + chunk_ms=200, + realtime_input=True, + first_turn_ms=1400, + turn_duration_ms=[], + first_turn_transcript="duplex CI speech", + omit_transcript_hints=False, + validation_mode="response-required", + temperature=0.0, + scenario="sequential", + require_audio=True, + require_distinct_inputs=False, + expect_empty_turn=[], + short_ack_ms=350, + turns=1, + timeout_s=120.0, + model_policy_settle_ms=2000, + ) + + +def multi_session_args( + *, + omni_server, + input_wav: Path, + ref_audio: Path, + output_dir: Path, + response_required: bool, +) -> SimpleNamespace: + return SimpleNamespace( + url=realtime_url(omni_server), + model=omni_server.model, + sessions=2, + input_wav=str(input_wav), + ref_audio=str(ref_audio), + session_input_wav=[], + session_expected_token=[], + turn_input_wav=[], + output_dir=str(output_dir), + realtime_input=True, + chunk_ms=200, + turns=1, + first_turn_ms=1400, + turn_duration_ms=[], + response_required=response_required, + temperature=0.0 if response_required else None, + disconnect_session_index=0, + takeover_session_index=1, + resume_after_ms=100, + expire_session_index=None, + expire_after_s=40.0, + verify_admission_limit=None, + model_policy_settle_ms=2000, + timeout_s=180.0, + ) diff --git a/tests/helpers/runtime.py b/tests/helpers/runtime.py index 885a4654f..5c1601cd0 100644 --- a/tests/helpers/runtime.py +++ b/tests/helpers/runtime.py @@ -22,6 +22,7 @@ from pathlib import Path from typing import Any, NamedTuple, cast from urllib.parse import quote +import numpy as np import psutil import requests import soundfile as sf @@ -597,7 +598,7 @@ class OmniServerStageCli(OmniServer): "serve", self.model, "--omni", - "--stage-configs-path", + "--deploy-config", self.stage_config_path, "--stage-id", str(stage_id), @@ -638,7 +639,10 @@ class OmniServerStageCli(OmniServer): proc = subprocess.Popen( cmd, env=env, - cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + # Must be repo root (not tests/): after Docker deps-cache installs an empty + # vllm_omni stub into site-packages, cwd on sys.path[0] is what makes + # ``python -m vllm_omni.entrypoints...`` resolve the real package. + cwd=_omni_subprocess_cwd(), stdout=log_fh, stderr=subprocess.STDOUT, ) @@ -799,6 +803,189 @@ class WebSocketJsonResponse: json_body: dict[str, Any] +@dataclass +class OpenPIWebSocketResponse: + """Msgpack WebSocket session against ``/v1/realtime/robot/openpi``.""" + + server_metadata: dict[str, Any] + operation_responses: list[Any] + actions: dict[str, np.ndarray] | None = None + action_tensors: list[np.ndarray] | None = None + + +def build_openpi_droid_observation(*, session_id: str = "gr00t-smoke") -> dict[str, Any]: + """Build a minimal DROID-style observation payload for the OpenPI robot endpoint.""" + identity_eef_9d = np.zeros((1, 1, 9), dtype=np.float32) + identity_eef_9d[..., 3:] = np.array([1, 0, 0, 0, 1, 0], dtype=np.float32) + return { + "session_id": session_id, + "video": { + "exterior_image_1_left": np.zeros((1, 2, 256, 256, 3), dtype=np.uint8), + "wrist_image_left": np.zeros((1, 2, 256, 256, 3), dtype=np.uint8), + }, + "state": { + "eef_9d": identity_eef_9d, + "gripper_position": np.zeros((1, 1, 1), dtype=np.float32), + "joint_position": np.zeros((1, 1, 7), dtype=np.float32), + }, + "language": {"annotation.language.language_instruction": [["pick up the object"]]}, + } + + +DREAMZERO_DEFAULT_PROMPT = ( + "Move the pan forward and use the brush in the middle of the plates to brush the inside of the pan" +) +DREAMZERO_ACTION_HORIZON = 24 +DREAMZERO_ACTION_DIM = 8 +DREAMZERO_CAMERA_FILES = { + "observation/exterior_image_0_left": "exterior_image_1_left.mp4", + "observation/exterior_image_1_left": "exterior_image_2_left.mp4", + "observation/wrist_image_left": "wrist_image_left.mp4", +} + + +def _require_opencv() -> Any: + try: + import cv2 + except ImportError as exc: # pragma: no cover - optional e2e dependency + raise ModuleNotFoundError("DreamZero OpenPI test dependencies are missing: opencv-python") from exc + return cv2 + + +def load_dreamzero_camera_frames(video_dir: Path) -> dict[str, np.ndarray]: + cv2 = _require_opencv() + camera_frames: dict[str, np.ndarray] = {} + for camera_key, file_name in DREAMZERO_CAMERA_FILES.items(): + video_path = video_dir / file_name + if not video_path.exists(): + raise FileNotFoundError(f"Missing DreamZero test asset: {video_path}") + cap = cv2.VideoCapture(str(video_path)) + frames = [] + while True: + ok, frame = cap.read() + if not ok: + break + frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) + cap.release() + if not frames: + raise RuntimeError(f"No frames loaded from {video_path}") + camera_frames[camera_key] = np.stack(frames, axis=0) + return camera_frames + + +def build_dreamzero_demo_observations( + camera_frames: dict[str, np.ndarray], + *, + prompt: str, + session_id: str, + num_chunks: int = 2, +) -> list[dict[str, Any]]: + if num_chunks < 1: + raise ValueError("num_chunks must be at least 1") + + relative_offsets = [-23, -16, -8, 0] + total_frames = min(frames.shape[0] for frames in camera_frames.values()) + frame_schedules = [[0]] + current_frame = 23 + for _ in range(num_chunks - 1): + indices = [max(current_frame + offset, 0) for offset in relative_offsets] + if indices[-1] >= total_frames: + break + frame_schedules.append(indices) + current_frame += DREAMZERO_ACTION_HORIZON + + observations: list[dict[str, Any]] = [] + for frame_indices in frame_schedules: + obs: dict[str, Any] = {} + for camera_key, all_frames in camera_frames.items(): + selected = all_frames[frame_indices] + obs[camera_key] = selected[0] if len(frame_indices) == 1 else selected + obs["observation/joint_position"] = np.zeros(7, dtype=np.float32) + obs["observation/cartesian_position"] = np.zeros(6, dtype=np.float32) + obs["observation/gripper_position"] = np.zeros(1, dtype=np.float32) + obs["prompt"] = prompt + obs["session_id"] = session_id + observations.append(obs) + return observations + + +class OpenPIWebSocketSession: + """Persistent msgpack session for ``/v1/realtime/robot/openpi``.""" + + DEFAULT_PING_INTERVAL_SECS = 300 + DEFAULT_PING_TIMEOUT_SECS = 3600 + + @staticmethod + def _require_dependencies() -> tuple[Any, Any]: + try: + import websockets.sync.client as websockets_client + except ImportError as exc: # pragma: no cover - optional e2e dependency + raise ModuleNotFoundError("GR00T OpenPI test dependencies are missing: websockets") from exc + try: + from openpi_client import msgpack_numpy + except ImportError as exc: # pragma: no cover - optional e2e dependency + raise ModuleNotFoundError("GR00T OpenPI test dependencies are missing: openpi-client") from exc + return websockets_client, msgpack_numpy + + def __init__( + self, + uri: str, + *, + ping_interval: float = DEFAULT_PING_INTERVAL_SECS, + ping_timeout: float = DEFAULT_PING_TIMEOUT_SECS, + open_timeout: float = 120.0, + close_timeout: float = 120.0, + ) -> None: + websockets_client, msgpack_numpy = self._require_dependencies() + self._msgpack_numpy = msgpack_numpy + self._packer = msgpack_numpy.Packer() + self._conn = websockets_client.connect( + uri, + compression=None, + max_size=None, + ping_interval=ping_interval, + ping_timeout=ping_timeout, + open_timeout=open_timeout, + close_timeout=close_timeout, + ) + server_metadata = msgpack_numpy.unpackb(self._conn.recv()) + if not isinstance(server_metadata, dict): + raise TypeError(f"Expected dict metadata from {uri}, got {type(server_metadata)!r}") + self._server_metadata = server_metadata + + def get_server_metadata(self) -> dict[str, Any]: + return dict(self._server_metadata) + + def infer(self, obs: dict[str, Any]) -> dict[str, np.ndarray]: + response = self._send_operation("infer", obs) + if not isinstance(response, dict): + raise TypeError(f"Expected dict infer response, got {type(response)!r}") + return {str(key): np.asarray(value, dtype=np.float32) for key, value in response.items()} + + def reset(self, reset_info: dict[str, Any] | None = None) -> str: + response = self._send_operation("reset", dict(reset_info or {})) + return str(response["status"]) + + def close(self) -> None: + self._conn.close() + + def _send_operation(self, endpoint: str, payload: dict[str, Any]) -> Any: + body = dict(payload) + body["endpoint"] = endpoint + self._conn.send(self._packer.pack(body)) + raw = self._conn.recv() + if isinstance(raw, str): + raise RuntimeError(f"OpenPI {endpoint!r} failed: {raw}") + decoded = self._msgpack_numpy.unpackb(raw) + if isinstance(decoded, dict) and decoded.get("type") == "error": + raise RuntimeError(f"OpenPI {endpoint!r} failed: {decoded.get('message')}") + if endpoint == "reset": + if not isinstance(decoded, dict) or decoded.get("status") != "reset successful": + raise RuntimeError(f"Unexpected OpenPI reset response: {decoded!r}") + return decoded + return decoded + + def _merge_http_expectation_kwargs( base: dict[str, Any] | None, *, @@ -1590,6 +1777,92 @@ class OpenAIClientHandler: ) return self._send_websocket_first_json_request("/v1/realtime", cfg) + def send_robot_openpi_ws_request( + self, + request_config: dict[str, Any] | None = None, + ) -> list[OpenPIWebSocketResponse]: + """WebSocket ``/v1/realtime/robot/openpi`` — msgpack metadata plus optional infer/reset ops. + + ``request_config`` keys: + + - ``operations``: optional sequence of ``{"endpoint": "infer"|"reset", "payload": {...}}``. + - ``run_default_policy_session``: when true and ``operations`` is omitted, run infer then reset + using :func:`build_openpi_droid_observation`. + - ``run_dreamzero_policy_session``: when true and ``operations`` is omitted, run the DreamZero + infer/reset/infer sequence using :func:`build_dreamzero_demo_observations`. + - ``video_dir``: required for ``run_dreamzero_policy_session``. + - ``prompt``: language instruction for DreamZero observations (optional). + - ``session_id``: used by the default policy session observation builder. + - ``num_chunks``: DreamZero chunk count (default ``2``). + - ``timeout``: seconds to wait for each inbound msgpack frame (default ``120``). + - ``ping_interval`` / ``ping_timeout``: forwarded to :func:`websockets.sync.client.connect`. + """ + cfg = dict(request_config or {}) + operations_cfg = cfg.get("operations") + if operations_cfg is not None: + operations = [dict(op) for op in operations_cfg] + elif cfg.get("run_dreamzero_policy_session"): + video_dir = cfg.get("video_dir") + if video_dir is None: + raise ValueError("run_dreamzero_policy_session requires video_dir") + session_id = str(cfg.get("session_id", "dreamzero-smoke")) + prompt = str(cfg.get("prompt", DREAMZERO_DEFAULT_PROMPT)) + num_chunks = int(cfg.get("num_chunks", 2)) + observations = build_dreamzero_demo_observations( + load_dreamzero_camera_frames(Path(video_dir)), + prompt=prompt, + session_id=session_id, + num_chunks=num_chunks, + ) + operations = [{"endpoint": "infer", "payload": obs} for obs in observations] + operations.append({"endpoint": "reset", "payload": {}}) + operations.append({"endpoint": "infer", "payload": observations[0]}) + elif cfg.get("run_default_policy_session"): + session_id = str(cfg.get("session_id", "gr00t-smoke")) + operations = [ + {"endpoint": "infer", "payload": build_openpi_droid_observation(session_id=session_id)}, + {"endpoint": "reset", "payload": {}}, + ] + else: + operations = [] + timeout = float(cfg.get("timeout", 120.0)) + ping_interval = float(cfg.get("ping_interval", OpenPIWebSocketSession.DEFAULT_PING_INTERVAL_SECS)) + ping_timeout = float(cfg.get("ping_timeout", OpenPIWebSocketSession.DEFAULT_PING_TIMEOUT_SECS)) + + uri = self._build_ws_url("/v1/realtime/robot/openpi") + session = OpenPIWebSocketSession( + uri, + ping_interval=ping_interval, + ping_timeout=ping_timeout, + open_timeout=timeout, + close_timeout=timeout, + ) + try: + operation_responses: list[Any] = [] + actions: dict[str, np.ndarray] | None = None + action_tensors: list[np.ndarray] = [] + for operation in operations: + endpoint = str(operation["endpoint"]) + payload = dict(operation.get("payload") or {}) + response = session._send_operation(endpoint, payload) + operation_responses.append(response) + if endpoint == "infer": + if isinstance(response, dict): + actions = {str(key): np.asarray(value, dtype=np.float32) for key, value in response.items()} + else: + action_tensors.append(np.asarray(response, dtype=np.float32)) + finally: + session.close() + + return [ + OpenPIWebSocketResponse( + server_metadata=session.get_server_metadata(), + operation_responses=operation_responses, + actions=actions, + action_tensors=action_tensors or None, + ) + ] + def send_omni_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[OmniResponse]: """Chat completions via the OpenAI Python SDK (not raw HTTP).""" responses: list[OmniResponse] = [] @@ -1795,6 +2068,7 @@ class OpenAIClientHandler: "instructions", "speed", "stream_format", + "x_vector_only_mode", ): if key in request_config: extra_body[key] = request_config[key] @@ -2332,7 +2606,6 @@ class OmniRunner: # production default in AsyncOmniEngine remains 600s; this only # affects the test runner wrapper. init_timeout: int = 1800, - shm_threshold_bytes: int = 65536, log_stats: bool = False, stage_configs_path: str | None = None, **kwargs, @@ -2352,7 +2625,6 @@ class OmniRunner: stage_init_timeout=stage_init_timeout, batch_timeout=batch_timeout, init_timeout=init_timeout, - shm_threshold_bytes=shm_threshold_bytes, stage_configs_path=stage_configs_path, **kwargs, ) @@ -2422,6 +2694,10 @@ class OmniRunner: video_padding_token = "<|VIDEO|>" image_padding_token = "<|IMAGE|>" audio_padding_token = "<|AUDIO|>" + # Default wrapping for Qwen-style models (bos/eos around the placeholder). + audio_fmt = "<|audio_bos|>{p}<|audio_eos|>" + image_fmt = "<|vision_bos|>{p}<|vision_eos|>" + video_fmt = "<|vision_bos|>{p}<|vision_eos|>" if "Qwen3-Omni-30B-A3B-Instruct" in self.model_name: video_padding_token = "<|video_pad|>" image_padding_token = "<|image_pad|>" @@ -2430,6 +2706,15 @@ class OmniRunner: video_padding_token = "