Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d61ad3ffac | ||
|
|
86bdcaf3d5 | ||
|
|
aa21412c01 | ||
|
|
1b31f96240 | ||
|
|
0798b0bf03 | ||
|
|
29dcdec66d | ||
|
|
9eaca72382 | ||
|
|
51c14eeaed | ||
|
|
e73245c60f | ||
|
|
f7cdc45977 | ||
|
|
46f10478dc | ||
|
|
20d43ce24d | ||
|
|
991fb7408b | ||
|
|
a8b2bf11d2 | ||
|
|
d8db832fc2 | ||
|
|
0455bffc37 | ||
|
|
04f400d36c | ||
|
|
0b59dfb94a | ||
|
|
d9cf85d5e2 | ||
|
|
3023bc40c2 | ||
|
|
b443504d61 | ||
|
|
06329c6450 | ||
|
|
f00caf35c4 | ||
|
|
431a7c71ac | ||
|
|
9d8089095e | ||
|
|
1b0de21c55 | ||
|
|
5c51782c6b | ||
|
|
f11b32a42e | ||
|
|
a25636d780 | ||
|
|
b0f6b9de96 | ||
|
|
bbc0476237 | ||
|
|
720bd756bf | ||
|
|
4d2ee15156 | ||
|
|
1788d76eb3 | ||
|
|
d526dc93e8 | ||
|
|
6b11f6b41d | ||
|
|
e4a2d3676e | ||
|
|
efc6b391e4 | ||
|
|
e7b281799e | ||
|
|
c21e9a35f8 |
No files matched your search
@@ -10,7 +10,7 @@ steps:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
# Buildkite will automatically replace this with the actual commit hash
|
||||
VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}"
|
||||
VLLM_VERSION: "v0.23.0"
|
||||
VLLM_VERSION: "v0.24.0"
|
||||
priority: 100
|
||||
timeout_in_minutes: 60
|
||||
soft_fail: false
|
||||
@@ -7,17 +7,29 @@ steps:
|
||||
|
||||
- group: ":card_index_dividers: Simple Test"
|
||||
steps:
|
||||
- label: "Simple · Diffusion & Model Executor Test"
|
||||
- label: "Simple · Diffusion & Model Executor Test (1/2)"
|
||||
agent_pool: mi325_1
|
||||
depends_on: amd-build
|
||||
timeout_in_minutes: 50
|
||||
timeout_in_minutes: 70
|
||||
mirror_hardwares: [amdproduction]
|
||||
grade: Blocking
|
||||
commands:
|
||||
- export VLLM_ROCM_USE_AITER=0
|
||||
# ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM
|
||||
# that is not supported on CPU
|
||||
- "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
|
||||
# CPU tests on ROCm gpu machines are very slow, so we give them more time.
|
||||
- "timeout 60m pytest -sv tests/diffusion -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
|
||||
|
||||
- label: "Simple · Diffusion & Model Executor Test (2/2)"
|
||||
agent_pool: mi325_1
|
||||
depends_on: amd-build
|
||||
timeout_in_minutes: 70
|
||||
mirror_hardwares: [amdproduction]
|
||||
grade: Blocking
|
||||
commands:
|
||||
- export VLLM_ROCM_USE_AITER=0
|
||||
# CPU tests on ROCm gpu machines are very slow, so we give them more time.
|
||||
- "timeout 60m pytest -sv tests/model_executor -m 'core_model and cpu' --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml"
|
||||
|
||||
- label: "Simple · Other Test"
|
||||
agent_pool: mi325_1
|
||||
@@ -158,7 +170,7 @@ steps:
|
||||
grade: Blocking
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c '
|
||||
timeout 50m bash -c '
|
||||
export VLLM_LOGGING_LEVEL=DEBUG
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
|
||||
@@ -170,13 +182,14 @@ steps:
|
||||
depends_on: amd-build
|
||||
mirror_hardwares: [amdproduction]
|
||||
grade: Blocking
|
||||
timeout_in_minutes: 50
|
||||
commands:
|
||||
- |
|
||||
timeout 30m bash -c '
|
||||
timeout 40m 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_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m "advanced_model" --run-level "advanced_model"
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m 'advanced_model and cuda' --run-level 'advanced_model'
|
||||
'
|
||||
|
||||
# TODO: Bagel test on ROCm is very unstable. @tjtanaa
|
||||
|
||||
+10
-10
@@ -22,30 +22,30 @@
|
||||
/.buildkite/ @yenuo26 @congw729
|
||||
|
||||
# Engine and model_executor
|
||||
/vllm_omni/core/ @tzhouam @yinpeiqi @fake0fan
|
||||
/vllm_omni/engine/ @tzhouam @yinpeiqi @fake0fan
|
||||
/vllm_omni/entrypoints/ @tzhouam @yinpeiqi @fake0fan
|
||||
/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/model_executor/ @tzhouam @gcanlin
|
||||
/vllm_omni/worker/ @tzhouam @gcanlin
|
||||
|
||||
# Diffusion
|
||||
/vllm_omni/diffusion/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @david6666666
|
||||
/vllm_omni/diffusion/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @david6666666 @xuechendi
|
||||
/vllm_omni/lora/ @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @yuanheng-zhao
|
||||
|
||||
# Diffusion CPU offloading
|
||||
/vllm_omni/diffusion/offloader/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @yuanheng-zhao
|
||||
/vllm_omni/diffusion/offloader/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @yuanheng-zhao @xuechendi
|
||||
|
||||
# Omni and TTS support
|
||||
/vllm_omni/transformers_utils/ @tzhouam @yuanheng-zhao
|
||||
/vllm_omni/model_executor/models/ @linyueqian @ZeldaHuang @princepride @yuanheng-zhao @amy-why-3459 @Bounty-hunter
|
||||
/vllm_omni/model_executor/models/ @linyueqian @ZeldaHuang @princepride @yuanheng-zhao @amy-why-3459 @Bounty-hunter @Sy0307 @gcanlin
|
||||
|
||||
# Benchmarks
|
||||
/benchmarks/accuracy/ @david6666666
|
||||
/benchmarks/build_dataset/ @linyueqian @yuanheng-zhao
|
||||
/benchmarks/diffusion/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa
|
||||
/benchmarks/diffusion/ @Isotr0py @princepride @SamitHuang @wtomin @ZJY0516 @RuixiangMa @xuechendi
|
||||
/benchmarks/distributed/ @princepride
|
||||
/benchmarks/glm_image/ @princepride @JaredforReal
|
||||
/benchmarks/tts/ @linyueqian @ZeldaHuang @princepride
|
||||
/benchmarks/tts/ @linyueqian @ZeldaHuang @princepride @Sy0307 @gcanlin
|
||||
|
||||
# Usability and Observability
|
||||
/vllm_omni/deploy/ @tzhouam @lishunyang12
|
||||
@@ -53,13 +53,13 @@
|
||||
/vllm_omni/profiler/ @gcanlin
|
||||
|
||||
# ComfyUI
|
||||
/apps/ @fhfuih
|
||||
/apps/ @fhfuih @alex-jw-brooks
|
||||
|
||||
# Large Scale Serving
|
||||
/vllm_omni/distributed/ @princepride @yuanheng-zhao @xuechendi @natureofnature @fake0fan
|
||||
|
||||
# Quantization and configuration
|
||||
/vllm_omni/config/ @lishunyang12
|
||||
/vllm_omni/config/ @lishunyang12 @alex-jw-brooks
|
||||
/vllm_omni/quantization/ @david6666666 @Isotr0py @lishunyang12
|
||||
|
||||
# Hardware plugins and accelerators
|
||||
|
||||
@@ -208,6 +208,7 @@ cache/
|
||||
.cache/
|
||||
diffusion_cache/
|
||||
kv_cache/
|
||||
!vllm_omni/experimental/ar_diffusion/kv_cache/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend
|
||||
ARG VLLM_ASCEND_TAG=v0.23.0-4885d8e
|
||||
ARG VLLM_ASCEND_TAG=v0.24.0
|
||||
FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG}
|
||||
|
||||
# WORKDIR /vllm-workspace/vllm
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend
|
||||
ARG VLLM_ASCEND_TAG=v0.23.0-4885d8e-a3
|
||||
ARG VLLM_ASCEND_TAG=v0.24.0-a3
|
||||
FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG}
|
||||
|
||||
# WORKDIR /vllm-workspace/vllm
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend
|
||||
ARG VLLM_ASCEND_TAG=v0.23.0-4885d8e
|
||||
ARG VLLM_ASCEND_TAG=v0.24.0
|
||||
FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG}
|
||||
|
||||
ARG APP_DIR=/vllm-workspace/vllm-omni
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend
|
||||
ARG VLLM_ASCEND_TAG=v0.23.0-4885d8e-a3
|
||||
ARG VLLM_ASCEND_TAG=v0.24.0-a3
|
||||
FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG}
|
||||
|
||||
ARG APP_DIR=/vllm-workspace/vllm-omni
|
||||
|
||||
@@ -107,7 +107,9 @@ ENV VLLM_TARGET_DEVICE=xpu
|
||||
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv pip install --no-build-isolation .
|
||||
uv pip uninstall triton triton-xpu && \
|
||||
uv pip install triton-xpu==3.7.1 && \
|
||||
uv pip install --no-build-isolation --no-deps .
|
||||
|
||||
CMD ["/bin/bash"]
|
||||
|
||||
@@ -143,12 +145,11 @@ COPY . .
|
||||
|
||||
ENV VLLM_OMNI_TARGET_DEVICE=xpu
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
echo "triton @ https://example.com/blocked" > /tmp/vllm_omni_constraints.txt && \
|
||||
PIP_CONSTRAINT=/tmp/vllm_omni_constraints.txt \
|
||||
uv pip install --no-build-isolation \
|
||||
--extra-index-url https://mirrors.ustc.edu.cn/pypi/simple/ \
|
||||
".[dev]" && \
|
||||
uv pip uninstall oneccl oneccl-devel
|
||||
uv pip uninstall triton oneccl oneccl-devel && \
|
||||
uv pip install triton-xpu==3.7.1 --reinstall
|
||||
|
||||
FROM vllm-omni AS vllm-omni-openai
|
||||
|
||||
|
||||
@@ -33,16 +33,20 @@ Lead maintainers are responsible for the overall direction and strategy of the p
|
||||
|
||||
Committers have write access and merge rights. They typically have deep expertise in specific areas of this project and shepherd the community contributions:
|
||||
|
||||
- [@alex-jw-brooks](https://github.com/alex-jw-brooks): Frontend, Configuration, and Entrypoint Support
|
||||
- [@amy-why-3459](https://github.com/amy-why-3459): Omni Modality Model and World Model Support
|
||||
- [@david6666666](https://github.com/david6666666): Quantization and Community Relationship
|
||||
- [@gcanlin](https://github.com/gcanlin): Hardware plugin and NPU integration
|
||||
- [@gcanlin](https://github.com/gcanlin): Hardware plugin, NPU integration, and TTS Model Support
|
||||
- [@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
|
||||
- [@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
|
||||
- [@Sy0307](https://github.com/Sy0307): TTS Model and AR Engine Support
|
||||
- [@tzhouam](https://github.com/tzhouam): Engine and New Model Support
|
||||
- [@wtomin](https://github.com/wtomin): Diffusion models, parallel, and docs
|
||||
- [@xuechendi](https://github.com/xuechendi): Diffusion and Intel GPU Support
|
||||
- [@ZeldaHuang](https://github.com/ZeldaHuang): Omni Support
|
||||
- [@ZJY0516](https://github.com/ZJY0516): Diffusion attention backend, kernel fusion, and CustomOp
|
||||
- [@yuanheng-zhao](https://github.com/yuanheng-zhao): Diffusion cache, offload, and Omni Support
|
||||
@@ -53,7 +57,7 @@ This section breaks down diffusion-related responsibilities. If you have PRs tou
|
||||
|
||||
### Runtime-related
|
||||
|
||||
- Diffusion models: @RuixiangMa, @wtomin
|
||||
- Diffusion models: @RuixiangMa, @wtomin, @xuechendi
|
||||
|
||||
### Optimization
|
||||
|
||||
@@ -61,6 +65,7 @@ This section breaks down diffusion-related responsibilities. If you have PRs tou
|
||||
- Attention backend: @ZJY0516
|
||||
- Cache: @yuanheng-zhao, @RuixiangMa, @SamitHuang
|
||||
- Offload: @yuanheng-zhao
|
||||
- Intel GPU support: @xuechendi
|
||||
- Quantization: @lishunyang12, @david6666666
|
||||
- Kernel fusion / communication-computation: @ZJY0516
|
||||
|
||||
|
||||
@@ -382,7 +382,7 @@ def forward(
|
||||
for p in req.prompts
|
||||
]
|
||||
|
||||
# Extract sampling parameters
|
||||
# Extract common sampling parameters
|
||||
sampling_params = req.sampling_params
|
||||
num_inference_steps = sampling_params.num_inference_steps or 50
|
||||
guidance_scale = sampling_params.guidance_scale or 7.5
|
||||
|
||||
@@ -126,16 +126,16 @@ Call `self.diffuse` in your pipeline's forward function:
|
||||
|
||||
```python
|
||||
import torch.nn as nn
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
|
||||
class YourModelPipeline(nn.Module, CFGParallelMixin):
|
||||
def forward(
|
||||
self,
|
||||
prompt: str,
|
||||
negative_prompt: str | None = None,
|
||||
guidance_scale: float = 3.5,
|
||||
num_inference_steps: int = 50,
|
||||
**kwargs,
|
||||
):
|
||||
# Encode prompts, Initialize latents, Get timesteps
|
||||
req: DiffusionRequestBatch,
|
||||
) -> list[DiffusionOutput]:
|
||||
# Read prompts and sampling params from the request
|
||||
...
|
||||
# Encode prompts, initialize latents, get timesteps
|
||||
...
|
||||
# Run diffusion loop (calls the mixin's diffuse method)
|
||||
latents = self.diffuse(
|
||||
|
||||
@@ -54,20 +54,24 @@ Create a custom pipeline class that extends an existing pipeline. In this exampl
|
||||
|
||||
```python
|
||||
# custom_pipeline.py
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit import QwenImageEditPipeline
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
import torch
|
||||
|
||||
class CustomPipeline(QwenImageEditPipeline):
|
||||
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
|
||||
super().__init__(od_config=od_config, prefix=prefix)
|
||||
|
||||
def forward(self, req, prompt=None, negative_prompt=None, **kwargs):
|
||||
def forward(self, req: DiffusionRequestBatch):
|
||||
# Optionally customize sampling parameters
|
||||
actual_num_steps = req.sampling_params.num_inference_steps or 50
|
||||
req.sampling_params.num_inference_steps = actual_num_steps
|
||||
|
||||
# Call parent's forward to get normal output
|
||||
output = super().forward(req=req, prompt=prompt, negative_prompt=negative_prompt, **kwargs)
|
||||
output = super().forward(req=req)
|
||||
|
||||
# Add custom trajectory data
|
||||
actual_num_steps = req.sampling_params.num_inference_steps or kwargs.get('num_inference_steps', 50)
|
||||
output.trajectory_timesteps = torch.linspace(1000, 0, actual_num_steps, dtype=torch.float32)
|
||||
output.trajectory_latents = torch.randn(actual_num_steps, 1, 16, 64, 64, dtype=torch.float32)
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
# --8<-- [start:requirements]
|
||||
|
||||
For detailed hardware and software requirements, please refer to the [vllm-ascend installation documentation](https://docs.vllm.ai/projects/ascend/en/latest/installation.html).
|
||||
For detailed hardware and software requirements, please refer to the [vLLM-Ascend installation documentation](https://docs.vllm.ai/projects/ascend/en/latest/installation.html).
|
||||
|
||||
# --8<-- [end:requirements]
|
||||
# --8<-- [start:installation-release]
|
||||
|
||||
The recommended way to use vLLM-Omni on NPU is through the vllm-ascend pre-built Docker images:
|
||||
The recommended way to use vLLM-Omni on NPU is through the vLLM-Ascend pre-built Docker images:
|
||||
|
||||
```bash
|
||||
# Update the vllm-ascend image
|
||||
# Atlas A2:
|
||||
# export IMAGE=quay.io/ascend/vllm-ascend:v0.18.0rc1
|
||||
# Atlas A3:
|
||||
# export IMAGE=quay.io/ascend/vllm-ascend:v0.18.0rc1-a3
|
||||
export IMAGE=quay.io/ascend/vllm-ascend:v0.18.0rc1
|
||||
# vLLM-Ascend image
|
||||
# Atlas A2: quay.io/atlas-ci/vllm-ascend:v0.24.0
|
||||
# Atlas A3: quay.io/atlas-ci/vllm-ascend:v0.24.0-a3
|
||||
docker run --rm \
|
||||
--name vllm-omni-npu \
|
||||
--shm-size=1g \
|
||||
@@ -31,11 +28,11 @@ docker run --rm \
|
||||
-v /etc/ascend_install.info:/etc/ascend_install.info \
|
||||
-v /root/.cache:/root/.cache \
|
||||
-p 8000:8000 \
|
||||
-it $IMAGE bash
|
||||
-it quay.io/atlas-ci/vllm-ascend:0.24.0-a3 bash
|
||||
|
||||
# Inside the container, install vLLM-Omni from source
|
||||
cd /vllm-workspace
|
||||
git clone -b v0.18.0 https://github.com/vllm-project/vllm-omni.git
|
||||
git clone -b v0.24.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 .
|
||||
@@ -87,14 +84,11 @@ Supported images as following.
|
||||
Here's an example deployment command that has been verified on 4 x NPUs:
|
||||
|
||||
```bash
|
||||
# Atlas A2:
|
||||
# export IMAGE=quay.io/ascend/vllm-omni:v0.18.0
|
||||
# Atlas A3:
|
||||
# export IMAGE=quay.io/ascend/vllm-omni:v0.18.0-a3
|
||||
export IMAGE=quay.io/ascend/vllm-omni:v0.18.0
|
||||
# Atlas A2: quay.io/ascend/vllm-omni:v0.24.0
|
||||
# Atlas A3: quay.io/ascend/vllm-omni:v0.24.0-a3
|
||||
docker run --rm \
|
||||
--name vllm-omni-npu \
|
||||
--shm-size=1g \
|
||||
--name vllm-omni-a3 \
|
||||
--shm-size=4g \
|
||||
--device /dev/davinci0 \
|
||||
--device /dev/davinci1 \
|
||||
--device /dev/davinci2 \
|
||||
@@ -106,10 +100,17 @@ docker run --rm \
|
||||
-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 \
|
||||
--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 /root/.cache:/root/.cache \
|
||||
-p 8000:8000 \
|
||||
-it $IMAGE bash
|
||||
-v ~/.cache:/root/.cache \
|
||||
-p 8091:8091 \
|
||||
-it quay.io/ascend/vllm-omni:v0.24.0-a3 bash
|
||||
```
|
||||
|
||||
!!! tip
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inference/image_to_video>.
|
||||
|
||||
|
||||
This example demonstrates how to generate videos from images using Wan2.2 Image-to-Video models with vLLM-Omni's offline inference API.
|
||||
This example demonstrates how to generate videos from images using Wan2.2,
|
||||
LTX-2/LTX-2.3, and HunyuanVideo-1.5 Image-to-Video models with vLLM-Omni's
|
||||
offline inference API.
|
||||
|
||||
## Local CLI Usage
|
||||
|
||||
@@ -51,13 +53,41 @@ python image_to_video.py \
|
||||
--output i2v_output.mp4
|
||||
```
|
||||
|
||||
### LTX-2.3
|
||||
|
||||
```bash
|
||||
python image_to_video.py \
|
||||
--model dg845/LTX-2.3-Diffusers \
|
||||
--model-class-name LTX23ImageToVideoPipeline \
|
||||
--image cherry_blossom.jpg \
|
||||
--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 \
|
||||
--guidance-scale 4.0 \
|
||||
--num-inference-steps 20 \
|
||||
--frame-rate 24 \
|
||||
--fps 24 \
|
||||
--output ltx23_i2v_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 LTX23ImageToVideoPipeline` to select
|
||||
the LTX-2.3 image-to-video pipeline.
|
||||
|
||||
Key arguments:
|
||||
|
||||
- `--model`: Model ID (I2V-A14B for MoE, TI2V-5B for unified T2V+I2V).
|
||||
- `--model`: Model ID (I2V-A14B for MoE, TI2V-5B for unified T2V+I2V, or
|
||||
LTX-2/LTX-2.3).
|
||||
- `--image`: Path to input image (required).
|
||||
- `--prompt`: Text description of desired motion/animation.
|
||||
- `--height/--width`: Output resolution (auto-calculated from image if not set). Dimensions should be multiples of 16.
|
||||
- `--num-frames`: Number of frames (default 81).
|
||||
- `--height/--width`: Output resolution (auto-calculated from image if not set).
|
||||
Wan dimensions should be multiples of 16; LTX dimensions should be multiples
|
||||
of 32.
|
||||
- `--num-frames`: Number of frames (model-specific default; LTX-style models
|
||||
work best with `8k + 1`).
|
||||
- `--guidance-scale` and `--guidance-scale-high`: CFG scale (applied to low/high-noise stages for MoE).
|
||||
- `--negative-prompt`: Optional list of artifacts to suppress.
|
||||
- `--boundary-ratio`: Boundary split ratio for two-stage MoE models.
|
||||
@@ -65,6 +95,7 @@ Key arguments:
|
||||
- `--sample-solver`: Wan2.2 sampling solver. Use `unipc` for the default multistep solver, or `euler` for Lightning/Distill checkpoints.
|
||||
- `--num-inference-steps`: Number of denoising steps (default 50).
|
||||
- `--fps`: Frames per second for the saved MP4 (requires `diffusers` export_to_video).
|
||||
- `--audio-sample-rate`: fallback audio sample rate for embedded audio.
|
||||
- `--output`: Path to save the generated video.
|
||||
- `--vae-use-slicing`: Enable VAE slicing for memory optimization.
|
||||
- `--vae-use-tiling`: Enable VAE tiling for memory optimization.
|
||||
|
||||
@@ -12,6 +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 |
|
||||
| `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 |
|
||||
|
||||
@@ -50,6 +51,30 @@ python text_to_video.py \
|
||||
--output ltx2_out.mp4
|
||||
```
|
||||
|
||||
### LTX-2.3
|
||||
|
||||
```bash
|
||||
python text_to_video.py \
|
||||
--model dg845/LTX-2.3-Diffusers \
|
||||
--model-class-name LTX23Pipeline \
|
||||
--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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### HunyuanVideo-1.5 (480p)
|
||||
|
||||
```bash
|
||||
@@ -110,6 +135,8 @@ 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.
|
||||
- `--prompt`: text description (string).
|
||||
- `--height/--width`: output resolution. Default depends on model.
|
||||
- `--num-frames`: number of frames. Default depends on model.
|
||||
@@ -124,7 +151,8 @@ 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).
|
||||
- `--audio-sample-rate`: audio sample rate for embedded audio (when the
|
||||
pipeline returns audio; LTX-2.3 outputs 48kHz audio).
|
||||
- `--quantization`: quantization method (`fp8` for FP8, `gguf` for GGUF).
|
||||
- `--flow-shift`: scheduler flow_shift parameter.
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ vLLM-Omni supports running diffusion models with the diffusers backend, directly
|
||||
|
||||
The diffusers backend is a black-box adapter. Its primary focus is to serve diffusion models online.
|
||||
Currently, the following features are NOT yet supported.
|
||||
It is not guaranteed whether they will be supported in the future.
|
||||
Community contributions are welcome to add these features when they can be
|
||||
implemented with clear Diffusers or vLLM-Omni behavior.
|
||||
|
||||
- CFG parallel execution
|
||||
- Sequence parallel execution
|
||||
@@ -66,6 +67,35 @@ This is suitable for sampling parameters not available through the vLLM-Omni int
|
||||
When a parameter is available in the vLLM-Omni interface, it will be adapted here.
|
||||
But if that parameter is simultaneously set in both the vLLM-Omni interface and `diffusers_call_kwargs`, the **former** will take precedence (because it is set at request time).
|
||||
|
||||
### Quantization
|
||||
|
||||
Use `diffusers_load_kwargs` for Diffusers-native quantization options. If
|
||||
`diffusers_load_kwargs["quantization_config"]` is provided as a dictionary, the
|
||||
diffusers backend builds a Diffusers quantization config and lets Diffusers
|
||||
validate it before calling `DiffusionPipeline.from_pretrained()`.
|
||||
|
||||
The diffusers backend also provides a small compatibility shortcut for
|
||||
vLLM-Omni quantization configs when `diffusers_load_kwargs` does not already
|
||||
contain `quantization_config`. Currently this maps online/dynamic `fp8` and
|
||||
online/dynamic `int8` to Diffusers/TorchAO dynamic quantization for transformer
|
||||
components such as `transformer` or `transformer_2`. This path requires
|
||||
`torchao` to be installed.
|
||||
|
||||
For example, the CLI can request dynamic FP8 through the vLLM-Omni interface:
|
||||
|
||||
```bash
|
||||
vllm serve "Qwen/Qwen-Image" \
|
||||
--omni \
|
||||
--diffusion-load-format diffusers \
|
||||
--quantization-config '{"method": "fp8"}'
|
||||
```
|
||||
|
||||
Other vLLM-Omni quantization methods, such as `gguf`, `modelopt`, `mxfp4`,
|
||||
`mxfp8`, serialized checkpoints, static FP8 configs, and layer-name skip lists
|
||||
such as `ignored_layers`, are intentionally not translated by this compatibility
|
||||
shortcut. Use Diffusers-native configuration through `diffusers_load_kwargs` or
|
||||
a native vLLM-Omni pipeline for those cases.
|
||||
|
||||
### Attention Backends
|
||||
|
||||
The diffusers backend converts
|
||||
|
||||
@@ -44,10 +44,16 @@ pip install vllm-omni[quack]
|
||||
pip install 'quack-kernels[cu13]' --extra-index-url https://download.pytorch.org/whl/cu130
|
||||
```
|
||||
|
||||
It is enabled automatically once installed (no flag needed) and is **Blackwell-only**:
|
||||
on Hopper/Ada the CUTLASS FP8 kernel already fuses bias, so quack is not used there.
|
||||
Set `VLLM_OMNI_USE_QUACK_FP8=0` to force the FlashInfer path. If `quack-kernels` is
|
||||
not installed, FP8 still works — it just keeps the unfused FlashInfer path.
|
||||
It is enabled automatically once installed (no flag needed) and is **datacenter
|
||||
Blackwell only** (`sm_100` / `sm_101` / `sm_103`, compute capability `10.x`, e.g.
|
||||
B200): quack's CuteDSL GEMM uses the 5th-gen `tcgen05` tensor-core MMA, which exists
|
||||
only on those parts. On Hopper/Ada the CUTLASS FP8 kernel already fuses bias, and on
|
||||
workstation/consumer Blackwell (`sm_120` / `sm_121`, compute capability `12.x`, e.g.
|
||||
RTX PRO 6000 / RTX 50-series) `tcgen05` is absent — so quack is **not** auto-enabled
|
||||
there and FlashInfer's native FP8 path is used instead. Set
|
||||
`VLLM_OMNI_USE_QUACK_FP8=1` to force quack on, or `VLLM_OMNI_USE_QUACK_FP8=0` to force
|
||||
the FlashInfer path. If `quack-kernels` is not installed, FP8 still works — it just
|
||||
keeps the unfused FlashInfer path.
|
||||
|
||||
#### Compile cache and warmup
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ vLLM-Omni provides two quantization methods with different scale structures:
|
||||
| NVIDIA Blackwell GPU (SM 100+) | ⭕ |
|
||||
| NVIDIA Ada/Hopper GPU (SM 89+) | ⭕ |
|
||||
| NVIDIA Ampere GPU (SM 80+) | ⭕ |
|
||||
| AMD ROCm | ⭕ |
|
||||
| AMD ROCm (gfx950 / MI355X) | ✅ |
|
||||
| Intel XPU | ⭕ |
|
||||
| Ascend NPU (Atlas 950 A5) | ✅ |
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ type has a different quantization scope.
|
||||
| NVIDIA Blackwell GPU (SM 100+) | ✅ | ✅ | ✅ | ⭕ | ⭕ | ✅ | ✅ | ❌ |
|
||||
| NVIDIA Ada/Hopper GPU (SM 89+) | ✅ | ✅ | ✅ | ⭕ | ⭕ | ✅ | ✅ | ❌ |
|
||||
| NVIDIA Ampere GPU (SM 80+) | ✅ | ✅ | ⭕ | ⭕ | ⭕ | ✅ | ✅ | ❌ |
|
||||
| AMD ROCm | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ❌ |
|
||||
| AMD ROCm | ⭕ | ⭕ | ⭕ | ⭕ | ✅ | ⭕ | ⭕ | ❌ |
|
||||
| Intel XPU | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ✅ | ❌ |
|
||||
| Ascend NPU | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import PIL.Image
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit import QwenImageEditPipeline
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,58 +16,14 @@ class CustomPipeline(QwenImageEditPipeline):
|
||||
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
|
||||
super().__init__(od_config=od_config, prefix=prefix)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
req: OmniDiffusionRequest,
|
||||
prompt: str | list[str] | None = None,
|
||||
negative_prompt: str | list[str] | None = None,
|
||||
image: PIL.Image.Image | torch.Tensor | None = None,
|
||||
true_cfg_scale: float = 4.0,
|
||||
height: int | None = None,
|
||||
width: int | None = None,
|
||||
num_inference_steps: int = 50,
|
||||
sigmas: list[float] | None = None,
|
||||
guidance_scale: float = 1.0,
|
||||
num_images_per_prompt: int = 1,
|
||||
generator: torch.Generator | list[torch.Generator] | None = None,
|
||||
latents: torch.Tensor | None = None,
|
||||
prompt_embeds: torch.Tensor | None = None,
|
||||
prompt_embeds_mask: torch.Tensor | None = None,
|
||||
negative_prompt_embeds: torch.Tensor | None = None,
|
||||
negative_prompt_embeds_mask: torch.Tensor | None = None,
|
||||
output_type: str | None = "pil",
|
||||
attention_kwargs: dict[str, Any] | None = None,
|
||||
callback_on_step_end_tensor_inputs: list[str] = ["latents"],
|
||||
max_sequence_length: int = 512,
|
||||
) -> DiffusionOutput:
|
||||
def forward(self, req: DiffusionRequestBatch):
|
||||
"""Forward pass for image editing with dummy trajectory data."""
|
||||
# Call parent's forward to get the normal output
|
||||
output = super().forward(
|
||||
req=req,
|
||||
prompt=prompt,
|
||||
negative_prompt=negative_prompt,
|
||||
image=image,
|
||||
true_cfg_scale=true_cfg_scale,
|
||||
height=height,
|
||||
width=width,
|
||||
num_inference_steps=num_inference_steps,
|
||||
sigmas=sigmas,
|
||||
guidance_scale=guidance_scale,
|
||||
num_images_per_prompt=num_images_per_prompt,
|
||||
generator=generator,
|
||||
latents=latents,
|
||||
prompt_embeds=prompt_embeds,
|
||||
prompt_embeds_mask=prompt_embeds_mask,
|
||||
negative_prompt_embeds=negative_prompt_embeds,
|
||||
negative_prompt_embeds_mask=negative_prompt_embeds_mask,
|
||||
output_type=output_type,
|
||||
attention_kwargs=attention_kwargs,
|
||||
callback_on_step_end_tensor_inputs=callback_on_step_end_tensor_inputs,
|
||||
max_sequence_length=max_sequence_length,
|
||||
)
|
||||
# Customize num_inference_steps that best suits your model
|
||||
actual_num_steps = req.sampling_params.num_inference_steps or 50
|
||||
req.sampling_params.num_inference_steps = actual_num_steps
|
||||
|
||||
# Get actual num_inference_steps used
|
||||
actual_num_steps = req.sampling_params.num_inference_steps or num_inference_steps
|
||||
# Call parent's forward to get the normal output
|
||||
output = super().forward(req=req)
|
||||
|
||||
# Create dummy trajectory data
|
||||
dummy_trajectory_latents = torch.randn(actual_num_steps, 1, 16, 64, 64, dtype=torch.float32)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Image-To-Video
|
||||
|
||||
This shared example generates videos from images with VACE, Wan2.2, LTX2,
|
||||
HunyuanVideo-1.5, Cosmos3, and other compatible pipelines.
|
||||
This shared example generates videos from images with VACE, Wan2.2,
|
||||
LTX-2/LTX-2.3, HunyuanVideo-1.5, Cosmos3, and other compatible pipelines.
|
||||
|
||||
## Local CLI Usage
|
||||
|
||||
@@ -49,6 +49,30 @@ python image_to_video.py \
|
||||
--output i2v_output.mp4
|
||||
```
|
||||
|
||||
### LTX-2.3
|
||||
|
||||
```bash
|
||||
python image_to_video.py \
|
||||
--model dg845/LTX-2.3-Diffusers \
|
||||
--model-class-name LTX23ImageToVideoPipeline \
|
||||
--image cherry_blossom.jpg \
|
||||
--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 \
|
||||
--guidance-scale 4.0 \
|
||||
--num-inference-steps 20 \
|
||||
--frame-rate 24 \
|
||||
--fps 24 \
|
||||
--output ltx23_i2v_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 LTX23ImageToVideoPipeline` to select
|
||||
the LTX-2.3 image-to-video pipeline.
|
||||
|
||||
### Cosmos3
|
||||
|
||||
```bash
|
||||
@@ -66,15 +90,19 @@ python image_to_video.py \
|
||||
|
||||
Key arguments:
|
||||
|
||||
- `--model`: Model ID (I2V-A14B for MoE, TI2V-5B for unified T2V+I2V).
|
||||
- `--model`: Model ID (I2V-A14B for MoE, TI2V-5B for unified T2V+I2V,
|
||||
LTX-2/LTX-2.3, Cosmos3, or VACE).
|
||||
- `--image`: Path to the first-frame or source image.
|
||||
- `--last-image`: Optional last-frame condition for models such as VACE.
|
||||
- `--mask-image`: Optional inpainting mask. White pixels are regenerated and black pixels are preserved.
|
||||
- `--reference-image`: Optional reference image; repeat it to provide multiple references.
|
||||
- `--extra-body`: JSON object of model-specific generation params, filtered against the model's declared `extra_body_params` (see [`vllm_omni/model_extras`](../../../vllm_omni/model_extras)). Used by Cosmos3.
|
||||
- `--prompt`: Text description of desired motion/animation.
|
||||
- `--height/--width`: Output resolution (auto-calculated from image if not set). Dimensions should be multiples of 16.
|
||||
- `--num-frames`: Number of frames (default: model-specific — Wan 81, LTX2 121, Cosmos3 189).
|
||||
- `--height/--width`: Output resolution (auto-calculated from image if not set).
|
||||
Wan dimensions should be multiples of 16; LTX dimensions should be multiples
|
||||
of 32.
|
||||
- `--num-frames`: Number of frames (model-specific default; LTX-style models
|
||||
work best with `8k + 1`; Cosmos3 defaults to 189).
|
||||
- `--guidance-scale` and `--guidance-scale-high`: CFG scale (applied to low/high-noise stages for MoE).
|
||||
- `--negative-prompt`: Optional list of artifacts to suppress.
|
||||
- `--boundary-ratio`: Boundary split ratio for two-stage MoE models.
|
||||
@@ -82,6 +110,7 @@ Key arguments:
|
||||
- `--sample-solver`: Wan2.2 sampling solver. Use `unipc` for the default multistep solver, or `euler` for Lightning/Distill checkpoints.
|
||||
- `--num-inference-steps`: Number of denoising steps (default: model-specific — Wan 50, LTX2 40, Cosmos3 35).
|
||||
- `--fps`: Frames per second for the saved MP4 (requires `diffusers` export_to_video).
|
||||
- `--audio-sample-rate`: fallback audio sample rate for embedded audio.
|
||||
- `--output`: Path to save the generated video.
|
||||
- `--vae-use-slicing`: Enable VAE slicing for memory optimization.
|
||||
- `--vae-use-tiling`: Enable VAE tiling for memory optimization.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""
|
||||
Image-to-Video generation example using Wan2.2 I2V/TI2V models, LTX2, HunyuanVideo-1.5, or Wan2.1 VACE.
|
||||
Image-to-Video generation example using Wan2.2 I2V/TI2V models, LTX2/LTX-2.3,
|
||||
HunyuanVideo-1.5, Cosmos3, or Wan2.1 VACE.
|
||||
|
||||
Supports:
|
||||
- Wan2.2-I2V-A14B-Diffusers: MoE model with CLIP image encoder
|
||||
@@ -27,6 +28,13 @@ Usage:
|
||||
--num-frames 121 --num-inference-steps 40 --guidance-scale 4.0 \
|
||||
--frame-rate 24 --fps 24 --output ltx2_i2v.mp4
|
||||
|
||||
# LTX-2.3 image-to-video
|
||||
python image_to_video.py --model dg845/LTX-2.3-Diffusers \
|
||||
--model-class-name LTX23ImageToVideoPipeline \
|
||||
--image input.jpg --prompt "A cinematic dolly shot of a boat" \
|
||||
--height 384 --width 512 --num-frames 25 --num-inference-steps 20 \
|
||||
--frame-rate 24 --fps 24 --output ltx23_i2v.mp4
|
||||
|
||||
# HunyuanVideo-1.5 I2V (480p)
|
||||
python image_to_video.py --model hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v \
|
||||
--image input.jpg --prompt "A cat playing with yarn" \
|
||||
@@ -79,17 +87,20 @@ parse_profiler_config = functools.partial(parse_json_object, flag_name="--profil
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate a video from one or more images (Wan2.2, LTX2, HunyuanVideo-1.5, or Wan2.1 VACE)."
|
||||
description=(
|
||||
"Generate a video from one or more images "
|
||||
"(Wan2.2, LTX2/LTX-2.3, HunyuanVideo-1.5, Cosmos3, or Wan2.1 VACE)."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="Wan-AI/Wan2.2-I2V-A14B-Diffusers",
|
||||
help="Diffusers I2V model ID or local path (Wan2.2, HunyuanVideo-1.5, or Wan2.1 VACE).",
|
||||
help="Diffusers I2V model ID or local path (Wan2.2, LTX2/LTX-2.3, HunyuanVideo-1.5, Cosmos3, or Wan2.1 VACE).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model-class-name",
|
||||
default=None,
|
||||
help="Override model class name (e.g., LTX2ImageToVideoPipeline).",
|
||||
help="Override model class name (e.g., LTX2ImageToVideoPipeline or LTX23ImageToVideoPipeline).",
|
||||
)
|
||||
parser.add_argument("--image", help="Path to the first-frame or source image.")
|
||||
parser.add_argument("--last-image", help="Path to a last-frame condition (used by models such as VACE).")
|
||||
@@ -113,7 +124,10 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--width", type=int, default=None, help="Video width (auto-calculated from image if not set).")
|
||||
parser.add_argument("--num-frames", type=int, default=None, help="Number of frames. Default: model-specific.")
|
||||
parser.add_argument(
|
||||
"--num-inference-steps", type=int, default=None, help="Sampling steps. Default: model-specific."
|
||||
"--num-inference-steps",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Sampling steps. Default: model-specific.",
|
||||
)
|
||||
parser.add_argument("--boundary-ratio", type=float, default=0.875, help="Boundary split ratio for MoE models.")
|
||||
parser.add_argument(
|
||||
@@ -312,10 +326,8 @@ def main():
|
||||
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed)
|
||||
model_name = str(args.model).lower() if args.model is not None else ""
|
||||
model_class_name = args.model_class_name
|
||||
is_ltx2 = "ltx2" in model_name or (model_class_name and "ltx2" in model_class_name.lower())
|
||||
is_ltx2 = model_class_name in {"LTX2ImageToVideoPipeline", "LTX23ImageToVideoPipeline"}
|
||||
is_cosmos = "cosmos" in model_name or (model_class_name is not None and "cosmos" in model_class_name.lower())
|
||||
if model_class_name is None and is_ltx2:
|
||||
model_class_name = "LTX2ImageToVideoPipeline"
|
||||
|
||||
image = PIL.Image.open(args.image).convert("RGB") if args.image else None
|
||||
last_image = PIL.Image.open(args.last_image).convert("RGB") if args.last_image else None
|
||||
@@ -340,7 +352,15 @@ def main():
|
||||
16,
|
||||
)
|
||||
elif is_ltx2:
|
||||
d_fps, d_guidance, d_num_frames, d_steps, d_flow_shift, d_max_area, d_mod = 24, 4.0, 121, 40, 5.0, 512 * 768, 32
|
||||
d_fps, d_guidance, d_num_frames, d_steps, d_flow_shift, d_max_area, d_mod = (
|
||||
24,
|
||||
4.0,
|
||||
121,
|
||||
40,
|
||||
5.0,
|
||||
512 * 768,
|
||||
32,
|
||||
)
|
||||
else: # Wan2.2 / HunyuanVideo-1.5
|
||||
d_fps, d_guidance, d_num_frames, d_steps, d_flow_shift, d_max_area, d_mod = 16, 5.0, 81, 50, 5.0, 480 * 832, 16
|
||||
|
||||
@@ -462,7 +482,7 @@ def main():
|
||||
f" tensor_parallel_size={args.tensor_parallel_size}, vae_patch_parallel_size={args.vae_patch_parallel_size},"
|
||||
f" pipeline_parallel_size={args.pipeline_parallel_size}"
|
||||
)
|
||||
print(f" Video size: {args.width}x{args.height}")
|
||||
print(f" Video size: {width}x{height}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
prompt_dict = build_image_to_video_prompt(
|
||||
@@ -509,6 +529,7 @@ def main():
|
||||
print(f"Total generation time: {generation_time:.4f} seconds ({generation_time * 1000:.2f} ms)")
|
||||
|
||||
audio = None
|
||||
audio_sample_rate = args.audio_sample_rate
|
||||
if isinstance(frames, list):
|
||||
frames = frames[0] if frames else None
|
||||
|
||||
@@ -519,11 +540,13 @@ def main():
|
||||
)
|
||||
if frames.multimodal_output and "audio" in frames.multimodal_output:
|
||||
audio = frames.multimodal_output["audio"]
|
||||
audio_sample_rate = frames.multimodal_output.get("audio_sample_rate", audio_sample_rate)
|
||||
if frames.is_pipeline_output and frames.request_output is not None:
|
||||
inner_output = frames.request_output
|
||||
if isinstance(inner_output, OmniRequestOutput):
|
||||
if inner_output.multimodal_output and "audio" in inner_output.multimodal_output:
|
||||
audio = inner_output.multimodal_output["audio"]
|
||||
audio_sample_rate = inner_output.multimodal_output.get("audio_sample_rate", audio_sample_rate)
|
||||
frames = inner_output
|
||||
if isinstance(frames, OmniRequestOutput):
|
||||
if frames.images:
|
||||
@@ -531,6 +554,7 @@ def main():
|
||||
frames, audio = frames.images[0]
|
||||
elif len(frames.images) == 1 and isinstance(frames.images[0], dict):
|
||||
audio = frames.images[0].get("audio")
|
||||
audio_sample_rate = frames.images[0].get("audio_sample_rate", audio_sample_rate)
|
||||
frames = frames.images[0].get("frames") or frames.images[0].get("video")
|
||||
else:
|
||||
frames = frames.images
|
||||
@@ -543,6 +567,7 @@ def main():
|
||||
frames, audio = first_item
|
||||
elif isinstance(first_item, dict):
|
||||
audio = first_item.get("audio")
|
||||
audio_sample_rate = first_item.get("audio_sample_rate", audio_sample_rate)
|
||||
frames = first_item.get("frames") or first_item.get("video")
|
||||
elif isinstance(first_item, list):
|
||||
frames = first_item
|
||||
@@ -551,6 +576,7 @@ def main():
|
||||
frames, audio = frames
|
||||
elif isinstance(frames, dict):
|
||||
audio = frames.get("audio")
|
||||
audio_sample_rate = frames.get("audio_sample_rate", audio_sample_rate)
|
||||
frames = frames.get("frames") or frames.get("video")
|
||||
|
||||
if frames is None:
|
||||
@@ -669,7 +695,7 @@ def main():
|
||||
frames_u8,
|
||||
audio_np,
|
||||
fps=float(fps),
|
||||
audio_sample_rate=args.audio_sample_rate,
|
||||
audio_sample_rate=audio_sample_rate,
|
||||
)
|
||||
with open(str(output_path), "wb") as f:
|
||||
f.write(video_bytes)
|
||||
|
||||
@@ -8,6 +8,7 @@ A unified script for text-to-video generation. Supports multiple models with mod
|
||||
|---|---|---|---|---|---|
|
||||
| `Wan-AI/Wan2.1-VACE-1.3B-diffusers` | 480x832 | 81 | 30 | 5.0 | ~20 GiB (RTX 5090, VAE tiling) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `nvidia/Cosmos3-Nano` | 720x1280 | 189 | 35 | 6.0 | ~46 GiB (peak, 720p) |
|
||||
@@ -70,6 +71,30 @@ python text_to_video.py \
|
||||
--output ltx2_out.mp4
|
||||
```
|
||||
|
||||
### LTX-2.3
|
||||
|
||||
```bash
|
||||
python text_to_video.py \
|
||||
--model dg845/LTX-2.3-Diffusers \
|
||||
--model-class-name LTX23Pipeline \
|
||||
--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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### HunyuanVideo-1.5 (480p)
|
||||
|
||||
```bash
|
||||
@@ -187,6 +212,8 @@ 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.
|
||||
- `--prompt`: text description (string).
|
||||
- `--height/--width`: output resolution. Default depends on model.
|
||||
- `--num-frames`: number of frames. Default depends on model.
|
||||
@@ -202,7 +229,8 @@ 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).
|
||||
- `--audio-sample-rate`: audio sample rate for embedded audio (when the
|
||||
pipeline returns audio; LTX-2.3 outputs 48kHz audio).
|
||||
- `--quantization`: quantization method (`fp8` for FP8, `gguf` for GGUF).
|
||||
- `--flow-shift`: scheduler flow_shift parameter.
|
||||
- `--extra-body`: JSON object of model-specific generation params, filtered against the model's declared `extra_body_params` (see [`vllm_omni/model_extras`](../../../vllm_omni/model_extras)). Used by Cosmos3 (see above).
|
||||
|
||||
@@ -455,6 +455,7 @@ def main():
|
||||
print(f"Worker peak GPU memory (reserved): {peak_mb:.2f} MiB ({peak_mb / 1024:.2f} GiB)")
|
||||
|
||||
audio = None
|
||||
audio_sample_rate = args.audio_sample_rate
|
||||
if isinstance(frames, list):
|
||||
frames = frames[0] if frames else None
|
||||
|
||||
@@ -465,11 +466,13 @@ def main():
|
||||
)
|
||||
if frames.multimodal_output and "audio" in frames.multimodal_output:
|
||||
audio = frames.multimodal_output["audio"]
|
||||
audio_sample_rate = frames.multimodal_output.get("audio_sample_rate", audio_sample_rate)
|
||||
if frames.is_pipeline_output and frames.request_output is not None:
|
||||
inner_output = frames.request_output
|
||||
if isinstance(inner_output, OmniRequestOutput):
|
||||
if inner_output.multimodal_output and "audio" in inner_output.multimodal_output:
|
||||
audio = inner_output.multimodal_output["audio"]
|
||||
audio_sample_rate = inner_output.multimodal_output.get("audio_sample_rate", audio_sample_rate)
|
||||
frames = inner_output
|
||||
if isinstance(frames, OmniRequestOutput):
|
||||
if frames.images:
|
||||
@@ -477,6 +480,7 @@ def main():
|
||||
frames, audio = frames.images[0]
|
||||
elif len(frames.images) == 1 and isinstance(frames.images[0], dict):
|
||||
audio = frames.images[0].get("audio")
|
||||
audio_sample_rate = frames.images[0].get("audio_sample_rate", audio_sample_rate)
|
||||
frames = frames.images[0].get("frames") or frames.images[0].get("video")
|
||||
else:
|
||||
frames = frames.images
|
||||
@@ -489,6 +493,7 @@ def main():
|
||||
frames, audio = first_item
|
||||
elif isinstance(first_item, dict):
|
||||
audio = first_item.get("audio")
|
||||
audio_sample_rate = first_item.get("audio_sample_rate", audio_sample_rate)
|
||||
frames = first_item.get("frames") or first_item.get("video")
|
||||
elif isinstance(first_item, list):
|
||||
frames = first_item
|
||||
@@ -497,6 +502,7 @@ def main():
|
||||
frames, audio = frames
|
||||
elif isinstance(frames, dict):
|
||||
audio = frames.get("audio")
|
||||
audio_sample_rate = frames.get("audio_sample_rate", audio_sample_rate)
|
||||
frames = frames.get("frames") or frames.get("video")
|
||||
|
||||
if frames is None:
|
||||
@@ -611,7 +617,7 @@ def main():
|
||||
frames_u8,
|
||||
audio_np,
|
||||
fps=float(args.fps),
|
||||
audio_sample_rate=args.audio_sample_rate,
|
||||
audio_sample_rate=audio_sample_rate,
|
||||
)
|
||||
with open(str(output_path), "wb") as f:
|
||||
f.write(video_bytes)
|
||||
|
||||
@@ -6,7 +6,8 @@ vLLM-Omni supports running diffusion models with the diffusers backend, directly
|
||||
|
||||
The diffusers backend is a black-box adapter. Its primary focus is to serve diffusion models online.
|
||||
Currently, the following features are NOT yet supported.
|
||||
It is not guaranteed whether they will be supported in the future.
|
||||
Community contributions are welcome to add these features when they can be
|
||||
implemented with clear Diffusers or vLLM-Omni behavior.
|
||||
|
||||
- CFG parallel execution
|
||||
- Sequence parallel execution
|
||||
@@ -68,6 +69,35 @@ But if that parameter is simultaneously set in both the vLLM-Omni interface and
|
||||
Consider referring to [`OmniDiffusionSamplingParams`](../../../vllm_omni/inputs/data.py) for the default sampling parameters in the vLLM-Omni interface,
|
||||
and the corresponding [diffusers pipeline](https://huggingface.co/docs/diffusers/main/en/api/pipelines/overview)'s `__call__` function documentation.
|
||||
|
||||
### Quantization
|
||||
|
||||
Use `diffusers_load_kwargs` for Diffusers-native quantization options. If
|
||||
`diffusers_load_kwargs["quantization_config"]` is provided as a dictionary, the
|
||||
diffusers backend builds a Diffusers quantization config and lets Diffusers
|
||||
validate it before calling `DiffusionPipeline.from_pretrained()`.
|
||||
|
||||
The diffusers backend also provides a small compatibility shortcut for
|
||||
vLLM-Omni quantization configs when `diffusers_load_kwargs` does not already
|
||||
contain `quantization_config`. Currently this maps online/dynamic `fp8` and
|
||||
online/dynamic `int8` to Diffusers/TorchAO dynamic quantization for transformer
|
||||
components such as `transformer` or `transformer_2`. This path requires
|
||||
`torchao` to be installed.
|
||||
|
||||
For example, the CLI can request dynamic FP8 through the vLLM-Omni interface:
|
||||
|
||||
```bash
|
||||
vllm serve "Qwen/Qwen-Image" \
|
||||
--omni \
|
||||
--diffusion-load-format diffusers \
|
||||
--quantization-config '{"method": "fp8"}'
|
||||
```
|
||||
|
||||
Other vLLM-Omni quantization methods, such as `gguf`, `modelopt`, `mxfp4`,
|
||||
`mxfp8`, serialized checkpoints, static FP8 configs, and layer-name skip lists
|
||||
such as `ignored_layers`, are intentionally not translated by this compatibility
|
||||
shortcut. Use Diffusers-native configuration through `diffusers_load_kwargs` or
|
||||
a native vLLM-Omni pipeline for those cases.
|
||||
|
||||
### Attention Backends
|
||||
|
||||
The diffusers backend converts
|
||||
|
||||
+62
-19
@@ -6,21 +6,23 @@
|
||||
|
||||
- Vendor: Lightricks
|
||||
- Model: `dg845/LTX-2.3-Diffusers`
|
||||
- Task: Text-to-video with synchronized audio generation
|
||||
- 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 generation
|
||||
with audio. The model generates videos up to 20+ seconds at 768x512 resolution
|
||||
with 48kHz audio, all from a single text prompt. Requires a GPU with at least
|
||||
96GB VRAM due to the 22B parameter transformer (~44GB weights) plus text
|
||||
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
|
||||
|
||||
- Model: <https://huggingface.co/dg845/LTX-2.3-Diffusers>
|
||||
- Upstream raw checkpoints: <https://huggingface.co/Lightricks/LTX-2.3>
|
||||
- Diffusers-format checkpoint: <https://huggingface.co/dg845/LTX-2.3-Diffusers>
|
||||
- Requires `diffusers >= 0.38.0` (install from git: `pip install git+https://github.com/huggingface/diffusers.git`)
|
||||
|
||||
## Serving
|
||||
@@ -34,7 +36,16 @@ vllm serve dg845/LTX-2.3-Diffusers \
|
||||
--stage-init-timeout 600
|
||||
```
|
||||
|
||||
### Verification
|
||||
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
|
||||
@@ -73,19 +84,48 @@ curl -X POST http://localhost:8000/v1/videos \
|
||||
-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: Model loads at ~36 GiB, peaks at ~62 GiB during inference
|
||||
- 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`: Required for the initial `torch.compile` warmup (~90-140 seconds on first request)
|
||||
- `--model-class-name LTX23Pipeline`: Selects the LTX-2.3 pipeline (not LTX-2)
|
||||
- `--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:
|
||||
- No image-to-video support yet (LTX23ImageToVideoPipeline is a placeholder)
|
||||
- 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
|
||||
@@ -99,13 +139,16 @@ curl -X POST http://localhost:8000/v1/videos \
|
||||
- OS: Ubuntu 22.04
|
||||
- Python: 3.10+
|
||||
- Driver / runtime: CUDA 13.0, Driver 580.126.09
|
||||
- vLLM version: 0.19.x
|
||||
- vLLM-Omni version: 0.19.x
|
||||
- vLLM version: Match the PR or release checkout being validated
|
||||
- vLLM-Omni version or commit: Use the commit you are deploying from
|
||||
|
||||
### Validated configurations
|
||||
### Benchmarking
|
||||
|
||||
| Duration | Frames | Resolution | Steps | Guidance | Inference Time | Peak VRAM |
|
||||
|----------|--------|------------|-------|----------|----------------|-----------|
|
||||
| 3s | 81 | 768x512 | 30 | 4.0 | ~110s | ~62 GB |
|
||||
| 10s | 241 | 768x512 | 30 | 4.0 | ~130s | ~62 GB |
|
||||
| 20s | 481 | 768x512 | 30 | 4.0 | ~420s | ~62 GB |
|
||||
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.
|
||||
@@ -27,6 +27,79 @@ examples in this repository.
|
||||
|
||||
## GPU
|
||||
|
||||
### 1x NVIDIA GeForce RTX 5090 32GB
|
||||
|
||||
#### Environment
|
||||
|
||||
- OS: Ubuntu 24.04.4 LTS
|
||||
- Python: 3.12.3
|
||||
- PyTorch: 2.11.0+cu130
|
||||
- Driver / runtime: NVIDIA driver 580.159.03, CUDA 13.0
|
||||
- GPU: NVIDIA GeForce RTX 5090, 32 GB (`32607 MiB`)
|
||||
- vLLM version: 0.23.0
|
||||
- vLLM-Omni version or commit: 0.23.0rc2.dev140+gbe60d7c7c (`be60d7c7`)
|
||||
- Tested with: `mistral-common==1.11.5`
|
||||
|
||||
#### Command
|
||||
|
||||
```bash
|
||||
vllm serve mistralai/Voxtral-4B-TTS-2603 \
|
||||
--omni \
|
||||
--port 8091 \
|
||||
--trust-remote-code
|
||||
```
|
||||
|
||||
The deploy config is auto-loaded from `vllm_omni/deploy/voxtral_tts.yaml`.
|
||||
|
||||
#### Verification
|
||||
|
||||
Health check and voices:
|
||||
|
||||
```bash
|
||||
curl -s -o /tmp/voxtral_health.txt -w '%{http_code}' http://127.0.0.1:8091/health
|
||||
curl -s http://127.0.0.1:8091/v1/audio/voices
|
||||
```
|
||||
|
||||
Non-streaming WAV:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "mistralai/Voxtral-4B-TTS-2603",
|
||||
"input": "Hello, this is Voxtral TTS running on an NVIDIA GeForce RTX 5090 with vLLM-Omni.",
|
||||
"voice": "casual_female",
|
||||
"language": "English",
|
||||
"response_format": "wav"
|
||||
}' \
|
||||
--output /tmp/voxtral_5090.wav
|
||||
```
|
||||
|
||||
Streaming PCM:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "mistralai/Voxtral-4B-TTS-2603",
|
||||
"input": "Hello, this is Voxtral TTS streaming PCM on an NVIDIA GeForce RTX 5090.",
|
||||
"voice": "casual_female",
|
||||
"language": "English",
|
||||
"stream": true,
|
||||
"response_format": "pcm"
|
||||
}' \
|
||||
--output /tmp/voxtral_5090_stream.pcm
|
||||
|
||||
ffmpeg -f s16le -ar 24000 -ac 1 -i /tmp/voxtral_5090_stream.pcm /tmp/voxtral_5090_stream.wav -y
|
||||
ffprobe -hide_banner /tmp/voxtral_5090_stream.wav
|
||||
```
|
||||
|
||||
#### Notes
|
||||
|
||||
- Output: 24 kHz mono WAV/PCM; both non-streaming and streaming requests passed.
|
||||
- Memory usage: ~27.3 GiB after warmup (Stage 0 ~25.5 GiB, Stage 1 ~1.7 GiB).
|
||||
- Key flags: `--omni` is required; `--trust-remote-code` is recommended.
|
||||
|
||||
### 1x RTX 4090 24GB
|
||||
|
||||
#### Environment
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -0,0 +1,136 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Tests for the generic engine-backend dispatcher + AR-Diffusion runner wiring.
|
||||
|
||||
``DiffusionEngine.resolve_engine_class`` is a generic dispatcher (``"default"`` / a
|
||||
``DiffusionEngine`` subclass / an import-path string). DreamZero selects the
|
||||
AR-Diffusion engine via its deploy config's ``engine_backend`` qualname — no
|
||||
DreamZero/ar_diffusion knowledge lives in the public base, so the routing check
|
||||
here is simply that the ``ARDiffusionEngine`` qualname resolves correctly.
|
||||
"""
|
||||
|
||||
from dataclasses import fields
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from vllm.utils.import_utils import resolve_obj_by_qualname
|
||||
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
|
||||
from vllm_omni.experimental.ar_diffusion.engine import (
|
||||
AR_DIFFUSION_MODEL_RUNNER_CLS,
|
||||
ARDiffusionEngine,
|
||||
)
|
||||
|
||||
|
||||
def _cfg(backend):
|
||||
return SimpleNamespace(engine_backend=backend)
|
||||
|
||||
|
||||
def test_default_resolves_to_diffusion_engine():
|
||||
assert DiffusionEngine.resolve_engine_class(_cfg("default")) is DiffusionEngine
|
||||
|
||||
|
||||
def test_subclass_type_is_returned():
|
||||
assert DiffusionEngine.resolve_engine_class(_cfg(ARDiffusionEngine)) is ARDiffusionEngine
|
||||
|
||||
|
||||
def test_ar_diffusion_qualname_resolves():
|
||||
# How DreamZero's deploy config selects the engine: a full import-path string.
|
||||
cls = DiffusionEngine.resolve_engine_class(_cfg("vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine"))
|
||||
assert cls is ARDiffusionEngine
|
||||
|
||||
|
||||
def test_non_engine_type_raises():
|
||||
with pytest.raises(TypeError):
|
||||
DiffusionEngine.resolve_engine_class(_cfg(dict))
|
||||
|
||||
|
||||
def test_qualname_not_an_engine_raises():
|
||||
with pytest.raises(TypeError):
|
||||
DiffusionEngine.resolve_engine_class(_cfg("builtins.dict"))
|
||||
|
||||
|
||||
def test_bad_qualname_raises():
|
||||
with pytest.raises(ValueError):
|
||||
DiffusionEngine.resolve_engine_class(_cfg("not.a.real.module.NoSuchEngine"))
|
||||
|
||||
|
||||
def test_ar_diffusion_engine_is_diffusion_engine_subclass():
|
||||
assert issubclass(ARDiffusionEngine, DiffusionEngine)
|
||||
|
||||
|
||||
def test_config_engine_backend_field_defaults_to_default():
|
||||
field = {f.name: f for f in fields(OmniDiffusionConfig)}["engine_backend"]
|
||||
assert field.default == "default"
|
||||
|
||||
|
||||
# --- AR-Diffusion worker/runner wiring -----------------------------------------------
|
||||
|
||||
|
||||
def test_ar_diffusion_model_runner_cls_resolves():
|
||||
cls = resolve_obj_by_qualname(AR_DIFFUSION_MODEL_RUNNER_CLS)
|
||||
assert cls.__name__ == "ARDiffusionModelRunner"
|
||||
|
||||
|
||||
def test_engine_class_declares_its_runner():
|
||||
"""Routing lives on the engine class (review: hsliuustc0106) — engines
|
||||
never mutate od_config; the worker resolves the runner off the class."""
|
||||
assert ARDiffusionEngine.default_diffusion_model_runner_cls == AR_DIFFUSION_MODEL_RUNNER_CLS
|
||||
assert DiffusionEngine.default_diffusion_model_runner_cls is None
|
||||
|
||||
|
||||
def _select_runner(od_config, platform_default="PLATFORM"):
|
||||
"""Mirror of the worker's runner-selection chain (override > engine > platform)."""
|
||||
runner_override = getattr(od_config, "diffusion_model_runner_cls", None)
|
||||
if isinstance(runner_override, str) and runner_override:
|
||||
return runner_override
|
||||
engine_cls = DiffusionEngine.resolve_engine_class(od_config)
|
||||
engine_runner = getattr(engine_cls, "default_diffusion_model_runner_cls", None)
|
||||
if isinstance(engine_runner, str) and engine_runner:
|
||||
return engine_runner
|
||||
return platform_default
|
||||
|
||||
|
||||
def test_runner_selection_chain():
|
||||
ar = "vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine"
|
||||
# engine-declared runner wins over platform default
|
||||
assert (
|
||||
_select_runner(SimpleNamespace(diffusion_model_runner_cls=None, engine_backend=ar))
|
||||
== AR_DIFFUSION_MODEL_RUNNER_CLS
|
||||
)
|
||||
# explicit override wins over the engine's declaration
|
||||
assert (
|
||||
_select_runner(SimpleNamespace(diffusion_model_runner_cls="my.custom.Runner", engine_backend=ar))
|
||||
== "my.custom.Runner"
|
||||
)
|
||||
# default engine -> platform default
|
||||
assert _select_runner(SimpleNamespace(diffusion_model_runner_cls=None, engine_backend="default")) == "PLATFORM"
|
||||
|
||||
|
||||
def test_dreamzero_pipeline_rejects_non_ar_diffusion_engine():
|
||||
"""Review (hsliuustc0106): a stale config with engine_backend='default'
|
||||
must fail at init, not mid-forward on the first KV access."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline
|
||||
|
||||
with pytest.raises(ValueError, match="requires the AR-Diffusion engine"):
|
||||
DreamZeroPipeline(od_config=SimpleNamespace(engine_backend="default"))
|
||||
with pytest.raises(ValueError, match="requires the AR-Diffusion engine"):
|
||||
DreamZeroPipeline(od_config=SimpleNamespace())
|
||||
|
||||
|
||||
def test_runner_rejects_multi_sequence_config():
|
||||
"""Review (hsliuustc0106): the paged path writes batch index 0 only, so
|
||||
max_num_seqs > 1 must be rejected at init."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.ar_diffusion.runner import ARDiffusionModelRunner
|
||||
|
||||
fake = SimpleNamespace(od_config=SimpleNamespace(max_num_seqs=4))
|
||||
with pytest.raises(ValueError, match="max_num_seqs=1"):
|
||||
ARDiffusionModelRunner._preallocate_kv_cache(fake)
|
||||
@@ -0,0 +1,262 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Unit tests for the AR-Diffusion KV cache helpers (Phase 1, PR-2).
|
||||
|
||||
Covers the request adapter, the chunk-window spec/manager (registration + the
|
||||
eviction policy), and the pool builder — exercised against the installed vLLM
|
||||
KV stack on CPU (block bookkeeping only, no GPU tensors).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.v1.kv_cache_interface import KVCacheSpecKind, get_kv_cache_spec_kind
|
||||
from vllm.v1.request import RequestStatus
|
||||
|
||||
try:
|
||||
from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
|
||||
except ModuleNotFoundError:
|
||||
KVCacheSpecRegistry = None
|
||||
from vllm.v1.core.single_type_kv_cache_manager import spec_manager_map
|
||||
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import (
|
||||
ARDiffusionKVCache,
|
||||
ARDiffusionKVConfig,
|
||||
ARDiffusionRequestAdapter,
|
||||
ChunkWindowManager,
|
||||
ChunkWindowSpec,
|
||||
allocate_kv_pool_with_views,
|
||||
build_kv_manager,
|
||||
compute_num_blocks,
|
||||
)
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.paged import chunk_window_skipped_tokens
|
||||
|
||||
BLOCK = 16
|
||||
|
||||
|
||||
def make_spec(*, chunk_size=BLOCK, window_chunks=2, sink_chunks=0, reset_at_boundary=False):
|
||||
return ChunkWindowSpec(
|
||||
block_size=BLOCK,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
dtype=torch.float16,
|
||||
sliding_window=window_chunks * chunk_size,
|
||||
chunk_size=chunk_size,
|
||||
window_chunks=window_chunks,
|
||||
sink_chunks=sink_chunks,
|
||||
reset_at_boundary=reset_at_boundary,
|
||||
)
|
||||
|
||||
|
||||
# --- ChunkWindowSpec registration -------------------------------------------
|
||||
|
||||
|
||||
def test_spec_registration_resolves_to_chunk_window_manager():
|
||||
# Without explicit registration the MRO walk would fall back to the parent
|
||||
# SlidingWindowManager; assert the subclass manager wins.
|
||||
spec = make_spec()
|
||||
if KVCacheSpecRegistry is not None:
|
||||
assert KVCacheSpecRegistry.get_manager_class(spec) is ChunkWindowManager
|
||||
else:
|
||||
assert spec_manager_map[type(spec)] is ChunkWindowManager
|
||||
|
||||
|
||||
def test_spec_kind_is_sliding_window():
|
||||
assert get_kv_cache_spec_kind(make_spec()) == KVCacheSpecKind.SLIDING_WINDOW
|
||||
|
||||
|
||||
def test_spec_rejects_inconsistent_window():
|
||||
with pytest.raises(ValueError):
|
||||
ChunkWindowSpec(
|
||||
block_size=BLOCK,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
dtype=torch.float16,
|
||||
sliding_window=99, # != window_chunks * chunk_size
|
||||
chunk_size=BLOCK,
|
||||
window_chunks=2,
|
||||
)
|
||||
|
||||
|
||||
# --- eviction policy (pure) -------------------------------------------------
|
||||
|
||||
|
||||
def test_sliding_replace_keeps_window():
|
||||
# window = 2 chunks * 16 = 32. Base sliding formula keeps `window` tokens;
|
||||
# the snap is to chunk boundaries.
|
||||
def skip(n):
|
||||
return chunk_window_skipped_tokens(n, chunk_size=16, sliding_window=32, sink_chunks=0, reset_at_boundary=False)
|
||||
|
||||
assert skip(32) == 0 # nothing past the window yet
|
||||
assert skip(48) == 16 # one chunk fell out of the window
|
||||
assert skip(64) == 32
|
||||
|
||||
|
||||
def test_sliding_replace_snaps_to_chunk_boundary():
|
||||
# A non-chunk-aligned overflow must snap down so a chunk is never half-evicted.
|
||||
skip = chunk_window_skipped_tokens(50, chunk_size=16, sliding_window=32, sink_chunks=0, reset_at_boundary=False)
|
||||
assert skip % 16 == 0 and skip == 16
|
||||
|
||||
|
||||
def test_sink_chunks_protected():
|
||||
# sink = 1 chunk (16 tokens) is never skipped.
|
||||
skip = chunk_window_skipped_tokens(80, chunk_size=16, sliding_window=32, sink_chunks=1, reset_at_boundary=False)
|
||||
# raw overflow = 80 - 32 - 16 = 32 -> snapped 32
|
||||
assert skip == 32
|
||||
|
||||
|
||||
def test_reset_at_boundary_drops_completed_past_sink():
|
||||
skip = chunk_window_skipped_tokens(48, chunk_size=16, sliding_window=32, sink_chunks=1, reset_at_boundary=True)
|
||||
# completed = 48; sink = 16 -> drop 32
|
||||
assert skip == 32
|
||||
|
||||
|
||||
# --- ARDiffusionKVConfig ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_kv_config_sliding_window_property():
|
||||
assert ARDiffusionKVConfig(chunk_size=16, window_chunks=3).sliding_window == 48
|
||||
assert ARDiffusionKVConfig(chunk_size=16, window_chunks=None).sliding_window is None
|
||||
|
||||
|
||||
# --- ARDiffusionRequestAdapter ------------------------------------------------------
|
||||
|
||||
|
||||
def test_adapter_advances_per_chunk_not_per_step():
|
||||
a = ARDiffusionRequestAdapter("r0", chunk_size=16)
|
||||
assert a.num_computed_tokens == 0
|
||||
assert a.num_tokens == 16 # in-flight chunk
|
||||
a.on_chunk_committed()
|
||||
assert a.num_computed_tokens == 16
|
||||
assert a.num_tokens == 32
|
||||
|
||||
|
||||
def test_adapter_accounts_for_prefill_prefix():
|
||||
a = ARDiffusionRequestAdapter("r0", chunk_size=16, prefill_prefix_tokens=4)
|
||||
assert a.num_computed_tokens == 4
|
||||
assert a.num_prompt_tokens == 4
|
||||
assert a.num_tokens == 20
|
||||
|
||||
|
||||
def test_adapter_status_is_vllm_enum():
|
||||
assert isinstance(ARDiffusionRequestAdapter("r0", chunk_size=16).status, RequestStatus)
|
||||
|
||||
|
||||
# --- pool / manager ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_compute_num_blocks():
|
||||
# 1 MiB budget at fraction 0.5 with 16 KiB pages -> 32 blocks.
|
||||
assert compute_num_blocks(1 << 20, 0.5, 16 << 10) == 32
|
||||
|
||||
|
||||
def test_paged_pool_layout_exposes_flat_slot_views():
|
||||
kv_pools, k_pools, v_pools = allocate_kv_pool_with_views(
|
||||
num_blocks=4,
|
||||
block_size=BLOCK,
|
||||
num_layers=1,
|
||||
num_kv_heads=4,
|
||||
head_dim=64,
|
||||
dtype=torch.float32,
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
assert kv_pools[0].shape == (2, 4, BLOCK, 4, 64)
|
||||
assert k_pools[0].shape == (4 * BLOCK, 4, 64)
|
||||
assert v_pools[0].shape == (4 * BLOCK, 4, 64)
|
||||
|
||||
k_pools[0][BLOCK + 3].fill_(7)
|
||||
v_pools[0][2 * BLOCK + 5].fill_(11)
|
||||
assert torch.equal(kv_pools[0][0, 1, 3], k_pools[0][BLOCK + 3])
|
||||
assert torch.equal(kv_pools[0][1, 2, 5], v_pools[0][2 * BLOCK + 5])
|
||||
|
||||
|
||||
def test_build_manager_allocate_free_roundtrip():
|
||||
"""End-to-end: a ARDiffusionRequestAdapter drives a real KVCacheManager.
|
||||
|
||||
This is the adapter conformance check — if the adapter were missing an
|
||||
attribute the manager reads, allocate_slots/free would raise here.
|
||||
"""
|
||||
spec = make_spec()
|
||||
mgr = build_kv_manager(spec, ["layer0"], num_blocks=16, max_model_len=1024)
|
||||
free_before = mgr.block_pool.get_num_free_blocks()
|
||||
|
||||
adapter = ARDiffusionRequestAdapter("req-0", chunk_size=BLOCK)
|
||||
blocks = mgr.allocate_slots(adapter, num_new_tokens=BLOCK, full_sequence_must_fit=True)
|
||||
assert blocks is not None
|
||||
assert mgr.block_pool.get_num_free_blocks() < free_before
|
||||
|
||||
mgr.free(adapter)
|
||||
assert mgr.block_pool.get_num_free_blocks() == free_before
|
||||
|
||||
|
||||
def test_cross_attn_pool_deducted_from_self_attn_budget():
|
||||
"""The cross-attn pool is allocated directly; its bytes are subtracted from
|
||||
the self-attn paged-pool budget so the two together stay within the free
|
||||
memory budget (review: zwhzzz0821)."""
|
||||
L = 512
|
||||
avail = 1 << 30 # 1 GiB
|
||||
kv = ARDiffusionKVCache(
|
||||
ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=2, gpu_memory_fraction=0.5),
|
||||
num_layers=2,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
dtype=torch.float16,
|
||||
block_size=BLOCK,
|
||||
max_model_len=4096,
|
||||
available_bytes=avail,
|
||||
cross_attn_length=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
|
||||
|
||||
|
||||
def _make_kv(*, local_branches, num_frame_per_block=2, window_chunks=9):
|
||||
return ARDiffusionKVCache(
|
||||
ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks),
|
||||
num_layers=1,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
dtype=torch.float32,
|
||||
block_size=BLOCK,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
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
|
||||
|
||||
|
||||
def test_scratch_maps_to_slot_zero_with_one_local_branch():
|
||||
"""A CFG-parallel rank runs exactly one 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)
|
||||
|
||||
two = _make_kv(local_branches=2)
|
||||
assert two.scratch_block_ids(True, 0, 2) != two.scratch_block_ids(False, 0, 2)
|
||||
|
||||
|
||||
def test_scratch_exhaustion_still_raises():
|
||||
one = _make_kv(local_branches=1)
|
||||
cap = one.scratch_blocks_per_branch
|
||||
with pytest.raises(RuntimeError, match="scratch blocks exhausted"):
|
||||
one.scratch_block_ids(False, 0, cap + 1)
|
||||
|
||||
|
||||
def test_invalid_local_branches_rejected():
|
||||
with pytest.raises(ValueError, match="local_branches"):
|
||||
_make_kv(local_branches=3)
|
||||
@@ -0,0 +1,93 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Tests for ARDiffusionKVCache — the engine-level KV orchestration body (Phase 1)."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def make_cache(*, chunk_size=16, window_chunks=2, available_bytes=1 << 24):
|
||||
cfg = ARDiffusionKVConfig(enable=True, chunk_size=chunk_size, window_chunks=window_chunks)
|
||||
return ARDiffusionKVCache(cfg, max_model_len=4096, available_bytes=available_bytes, **DIMS)
|
||||
|
||||
|
||||
def test_requires_enabled_config():
|
||||
with pytest.raises(ValueError):
|
||||
ARDiffusionKVCache(ARDiffusionKVConfig(enable=False), max_model_len=256, available_bytes=1 << 20, **DIMS)
|
||||
|
||||
|
||||
def test_requires_bounded_window():
|
||||
cfg = ARDiffusionKVConfig(enable=True, chunk_size=16, window_chunks=None)
|
||||
with pytest.raises(ValueError):
|
||||
ARDiffusionKVCache(cfg, max_model_len=256, available_bytes=1 << 20, **DIMS)
|
||||
|
||||
|
||||
def test_full_request_lifecycle_and_eviction():
|
||||
"""begin -> per-chunk allocate/slots/commit over a long rollout -> free.
|
||||
|
||||
Exercises the orchestrator end-to-end and asserts the chunk window bounds
|
||||
memory (pool plateaus) and frees cleanly.
|
||||
"""
|
||||
kv = make_cache(chunk_size=16, window_chunks=2)
|
||||
free_total = kv.manager.block_pool.get_num_free_blocks()
|
||||
|
||||
adapter = kv.begin_request("req-0")
|
||||
free_after = []
|
||||
for k in range(10):
|
||||
block_table = kv.allocate_chunk(adapter)
|
||||
slots = kv.chunk_write_slots(adapter)
|
||||
# Slots target real blocks in this chunk's table; length == chunk_size.
|
||||
assert len(slots) == kv.spec.chunk_size
|
||||
used = {int(s) // kv.block_size for s in slots}
|
||||
assert kv.null_block_id not in used
|
||||
assert used <= set(block_table)
|
||||
# Resident window stays bounded by window + the in-flight chunk.
|
||||
assert len(kv.window_block_ids(adapter)) <= kv.spec.window_chunks + 1
|
||||
free_after.append(kv.manager.block_pool.get_num_free_blocks())
|
||||
kv.commit_chunk(adapter)
|
||||
|
||||
# Pool memory plateaus once the window is full (eviction recycles blocks).
|
||||
assert free_after[-1] == free_after[kv.spec.window_chunks]
|
||||
|
||||
kv.end_request(adapter)
|
||||
assert kv.manager.block_pool.get_num_free_blocks() == free_total
|
||||
|
||||
|
||||
def test_num_computed_advances_per_chunk():
|
||||
kv = make_cache(chunk_size=16, window_chunks=2)
|
||||
a = kv.begin_request("r")
|
||||
assert a.num_computed_tokens == 0
|
||||
kv.allocate_chunk(a)
|
||||
kv.commit_chunk(a)
|
||||
assert a.num_computed_tokens == 16
|
||||
kv.end_request(a)
|
||||
|
||||
|
||||
def test_state_close_frees_both_branch_blocks():
|
||||
"""ARDiffusionKVState.close() returns both CFG 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
|
||||
leaks pool ownership (review P1).
|
||||
"""
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
|
||||
|
||||
kv = make_cache(chunk_size=16, window_chunks=2)
|
||||
free_total = kv.manager.block_pool.get_num_free_blocks()
|
||||
|
||||
pos = kv.begin_request("bde__s")
|
||||
neg = kv.begin_request("bde__s__neg")
|
||||
state = ARDiffusionKVState(kv, pos, 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.
|
||||
assert kv.manager.block_pool.get_num_free_blocks() < free_total
|
||||
|
||||
state.close()
|
||||
# All blocks returned — no leak across an evicted session.
|
||||
assert kv.manager.block_pool.get_num_free_blocks() == free_total
|
||||
@@ -0,0 +1,394 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Tests for AR-Diffusion paged self-attention contexts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import (
|
||||
ARDiffusionKVCache,
|
||||
ARDiffusionKVConfig,
|
||||
ARDiffusionPagedLayerContext,
|
||||
ARDiffusionPagedLayerInputs,
|
||||
ar_diffusion_paged_attention,
|
||||
paged_write_attn,
|
||||
)
|
||||
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=2, dtype=torch.float32, device=torch.device("cpu")):
|
||||
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=dtype,
|
||||
block_size=BLOCK,
|
||||
max_model_len=4096,
|
||||
available_bytes=1 << 26,
|
||||
device=device,
|
||||
)
|
||||
pos = kv.begin_request("r-pos")
|
||||
neg = kv.begin_request("r-neg")
|
||||
return kv, ARDiffusionKVState(kv, pos, neg, num_layers=num_layers)
|
||||
|
||||
|
||||
def _dense_attention(query: torch.Tensor, key: torch.Tensor, value: torch.Tensor) -> torch.Tensor:
|
||||
scores = torch.einsum("bqhd,bkhd->bhqk", query.float(), key.float()) * (HEAD_DIM**-0.5)
|
||||
probs = torch.softmax(scores, dim=-1).to(value.dtype)
|
||||
return torch.einsum("bhqk,bkhd->bqhd", probs, value)
|
||||
|
||||
|
||||
def _cuda_flash_attn_usable() -> bool:
|
||||
if not torch.cuda.is_available():
|
||||
return False
|
||||
try:
|
||||
spec = find_spec("vllm.vllm_flash_attn")
|
||||
if spec is None or spec.origin is None:
|
||||
return True
|
||||
fa2_so = Path(spec.origin).parent / "_vllm_fa2_C.abi3.so"
|
||||
linked = subprocess.check_output(["ldd", str(fa2_so)], text=True, timeout=5)
|
||||
except Exception:
|
||||
return True
|
||||
if "libcudart.so.13" not in linked:
|
||||
return True
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"],
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
driver_major = int(out.splitlines()[0].split(".")[0])
|
||||
except Exception:
|
||||
return True
|
||||
return driver_major >= 580
|
||||
|
||||
|
||||
def _commit_video_span(
|
||||
kv: ARDiffusionKVCache,
|
||||
st: ARDiffusionKVState,
|
||||
*,
|
||||
is_negative: bool,
|
||||
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.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)
|
||||
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)
|
||||
ctx = contexts[0].forward_ctx
|
||||
assert isinstance(contexts[0], ARDiffusionPagedLayerContext)
|
||||
assert st.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 len(ctx.current_video_block_ids) == 1
|
||||
|
||||
st.commit_paged_context(False)
|
||||
assert st.pos.completed_chunks == 1
|
||||
assert st._committed[False] == 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.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
|
||||
|
||||
|
||||
def test_pipeline_kv_get_paged_path_has_no_gather_backend():
|
||||
kv, st = make_state()
|
||||
assert not hasattr(kv, "gather_window_all_layers")
|
||||
|
||||
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline
|
||||
|
||||
pipeline = DreamZeroPipeline.__new__(DreamZeroPipeline)
|
||||
pipeline._ar_diffusion_kv_state = st
|
||||
contexts = pipeline._kv_get(MagicMock(), False, seq_len=BLOCK, update_kv_cache=False)
|
||||
|
||||
assert len(contexts) == 1
|
||||
assert isinstance(contexts[0], ARDiffusionPagedLayerContext)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("history_chunks", [0, 1, 3])
|
||||
@pytest.mark.parametrize("action_len", [0, 3])
|
||||
@pytest.mark.parametrize("commit_current", [False, True])
|
||||
def test_paged_attention_matches_dense_reference_cpu(history_chunks, action_len, commit_current):
|
||||
torch.manual_seed(0)
|
||||
device = torch.device("cpu")
|
||||
dtype = torch.float32
|
||||
kv, st = make_state(dtype=dtype, device=device, window_chunks=2)
|
||||
|
||||
history_k_parts: list[torch.Tensor] = []
|
||||
history_v_parts: list[torch.Tensor] = []
|
||||
if history_chunks:
|
||||
k, v = _commit_video_span(
|
||||
kv,
|
||||
st,
|
||||
is_negative=False,
|
||||
n_chunks=history_chunks,
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
)
|
||||
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.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)
|
||||
kv._k_pools[0][ctx.current_video_slot_mapping] = current_k[0]
|
||||
kv._v_pools[0][ctx.current_video_slot_mapping] = current_v[0]
|
||||
|
||||
action_k = action_v = None
|
||||
if action_len:
|
||||
ctx.ensure_action_slots(action_len, device)
|
||||
action_k = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
action_v = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
kv._k_pools[0][ctx.action_slot_mapping] = action_k[0]
|
||||
kv._v_pools[0][ctx.action_slot_mapping] = action_v[0]
|
||||
|
||||
query = torch.randn(1, BLOCK + action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
block_table, query_start_loc, seq_lens, max_query_len, max_seq_len = ctx.build_block_table(
|
||||
action_len=action_len,
|
||||
query_len=query.shape[1],
|
||||
device=device,
|
||||
)
|
||||
paged = ar_diffusion_paged_attention(
|
||||
query,
|
||||
kv.key_cache(0),
|
||||
kv.value_cache(0),
|
||||
block_table=block_table,
|
||||
query_start_loc=query_start_loc,
|
||||
seq_lens=seq_lens,
|
||||
max_query_len=max_query_len,
|
||||
max_seq_len=max_seq_len,
|
||||
softmax_scale=HEAD_DIM**-0.5,
|
||||
causal=False,
|
||||
)
|
||||
|
||||
if history_k_parts:
|
||||
history_k = torch.cat(history_k_parts, dim=1)
|
||||
history_v = torch.cat(history_v_parts, dim=1)
|
||||
else:
|
||||
history_k = torch.empty(1, 0, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
history_v = torch.empty(1, 0, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
new_k = torch.cat([history_k, current_k], dim=1)[:, -kv.spec.sliding_window :]
|
||||
new_v = torch.cat([history_v, current_v], dim=1)[:, -kv.spec.sliding_window :]
|
||||
if action_len:
|
||||
new_k = torch.cat([new_k, action_k], dim=1)
|
||||
new_v = torch.cat([new_v, action_v], dim=1)
|
||||
ref = _dense_attention(query, new_k, new_v)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _cuda_flash_attn_usable(), reason="usable CUDA FlashAttention is required")
|
||||
@pytest.mark.parametrize("history_chunks", [1, 3])
|
||||
@pytest.mark.parametrize("action_len", [0, 3])
|
||||
@pytest.mark.parametrize("commit_current", [False, True])
|
||||
def test_paged_attention_matches_dense_reference_gpu(history_chunks, action_len, commit_current):
|
||||
pytest.importorskip("vllm.vllm_flash_attn")
|
||||
torch.manual_seed(0)
|
||||
device = torch.device("cuda")
|
||||
dtype = torch.float16
|
||||
kv, st = make_state(dtype=dtype, device=device, window_chunks=2)
|
||||
|
||||
history_k, history_v = _commit_video_span(
|
||||
kv,
|
||||
st,
|
||||
is_negative=False,
|
||||
n_chunks=history_chunks,
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
)
|
||||
|
||||
layer_ctx = st.get_kv_caches(False, 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)
|
||||
action_k = action_v = None
|
||||
if action_len:
|
||||
action_k = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
action_v = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
|
||||
query = torch.randn(1, BLOCK + action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
|
||||
# The production path: once-per-forward host prep, then the fused
|
||||
# write+attend custom op consuming the NamedTuple payload.
|
||||
ctx.prepare(device=device, action_len=action_len, query_len=query.shape[1])
|
||||
inputs = layer_ctx.to_layer_inputs()
|
||||
assert isinstance(inputs, ARDiffusionPagedLayerInputs)
|
||||
paged = paged_write_attn(
|
||||
inputs,
|
||||
query[0],
|
||||
current_k[0],
|
||||
current_v[0],
|
||||
action_k[0] if action_len else None,
|
||||
action_v[0] if action_len else None,
|
||||
HEAD_DIM**-0.5,
|
||||
).unsqueeze(0)
|
||||
|
||||
# Direct python-fn call on the same (already written) pools must be
|
||||
# bit-exact: identical kernel, identical inputs.
|
||||
direct = ar_diffusion_paged_attention(
|
||||
query,
|
||||
kv.key_cache(0),
|
||||
kv.value_cache(0),
|
||||
block_table=ctx.block_table,
|
||||
query_start_loc=ctx.query_start_loc,
|
||||
seq_lens=ctx.seq_lens,
|
||||
max_query_len=ctx.max_query_len,
|
||||
max_seq_len=ctx.max_seq_len,
|
||||
softmax_scale=HEAD_DIM**-0.5,
|
||||
causal=False,
|
||||
)
|
||||
assert torch.equal(paged, direct)
|
||||
|
||||
new_k = torch.cat([history_k, current_k], dim=1)[:, -kv.spec.sliding_window :]
|
||||
new_v = torch.cat([history_v, current_v], dim=1)[:, -kv.spec.sliding_window :]
|
||||
if action_len:
|
||||
new_k = torch.cat([new_k, action_k], dim=1)
|
||||
new_v = torch.cat([new_v, action_v], dim=1)
|
||||
ref = _dense_attention(query, new_k, new_v)
|
||||
|
||||
torch.testing.assert_close(paged, ref, rtol=2e-2, atol=2e-2)
|
||||
|
||||
|
||||
def test_block_table_padded_to_fixed_width():
|
||||
"""Shapes must be constant across window growth: only values change."""
|
||||
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.prepare(device=device, action_len=0, query_len=BLOCK)
|
||||
st.commit_paged_context(False)
|
||||
|
||||
ctx2 = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True)[0].forward_ctx
|
||||
ctx2.prepare(device=device, action_len=0, query_len=BLOCK)
|
||||
st.commit_paged_context(False)
|
||||
|
||||
# 1-block vs 2-block visible history: same table width, same max_seq_len.
|
||||
assert ctx1.block_table.shape == ctx2.block_table.shape
|
||||
assert ctx1.max_seq_len == ctx2.max_seq_len
|
||||
expected_width = kv.spec.sliding_window // kv.block_size + 1
|
||||
assert ctx1.block_table.shape == (1, expected_width)
|
||||
# Real lengths live in seq_lens, not the padded table.
|
||||
assert int(ctx1.seq_lens[0]) == BLOCK
|
||||
assert int(ctx2.seq_lens[0]) == 2 * BLOCK
|
||||
|
||||
|
||||
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)
|
||||
fctx = contexts[0].forward_ctx
|
||||
fctx.prepare(device=device, action_len=0, query_len=BLOCK)
|
||||
table = fctx.block_table
|
||||
fctx.prepare(device=device, action_len=0, query_len=BLOCK)
|
||||
assert fctx.block_table is table # memoized, not rebuilt
|
||||
|
||||
i0, i1 = contexts[0].to_layer_inputs(), contexts[1].to_layer_inputs()
|
||||
# 0-dim tensors (NOT python ints) so dynamo doesn't install per-layer
|
||||
# value guards on the shared block code object.
|
||||
assert isinstance(i0.layer_idx, torch.Tensor) and int(i0.layer_idx) == 0
|
||||
assert isinstance(i1.layer_idx, torch.Tensor) and int(i1.layer_idx) == 1
|
||||
# All layers share the same metadata tensor objects.
|
||||
assert i0.block_table is i1.block_table
|
||||
assert i0.seq_lens is i1.seq_lens
|
||||
assert i0.video_slots is i1.video_slots
|
||||
|
||||
|
||||
def test_layer_inputs_before_prepare_raises():
|
||||
_, st = make_state()
|
||||
layer_ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False)[0]
|
||||
with pytest.raises(RuntimeError, match="before prepare"):
|
||||
layer_ctx.to_layer_inputs()
|
||||
|
||||
|
||||
def test_custom_op_registration_idempotent():
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
assert hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn")
|
||||
mod = "vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention"
|
||||
saved = sys.modules.pop(mod)
|
||||
try:
|
||||
importlib.import_module(mod) # re-registration must not raise
|
||||
finally:
|
||||
sys.modules[mod] = saved
|
||||
assert hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn")
|
||||
|
||||
|
||||
def test_custom_op_compiles_fullgraph_without_recompile_on_value_change():
|
||||
"""The op must trace as one opaque node: fullgraph OK, and changed tensor
|
||||
VALUES (new slots / block ids) must not trigger recompilation."""
|
||||
import torch._dynamo
|
||||
|
||||
device = torch.device("cpu")
|
||||
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)
|
||||
fctx = contexts[0].forward_ctx
|
||||
fctx.prepare(device=device, action_len=0, query_len=BLOCK)
|
||||
q = torch.randn(BLOCK, N_HEADS, HEAD_DIM)
|
||||
k = torch.randn(BLOCK, N_HEADS, HEAD_DIM)
|
||||
v = torch.randn(BLOCK, N_HEADS, HEAD_DIM)
|
||||
# Both layers through ONE compiled fn: layer_idx is a tensor, so a
|
||||
# different layer must NOT recompile (all 40 DiT blocks share the
|
||||
# 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)
|
||||
return out
|
||||
|
||||
torch._dynamo.reset()
|
||||
from torch._dynamo.testing import 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
|
||||
|
||||
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)
|
||||
|
||||
assert counter.frame_count == 1, f"recompiled: frame_count={counter.frame_count}"
|
||||
@@ -0,0 +1,222 @@
|
||||
# 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):
|
||||
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
|
||||
@@ -0,0 +1,76 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Slot-mapping / block-table tests for the AR-Diffusion engine (Phase 1, Step 3)."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import (
|
||||
ARDiffusionRequestAdapter,
|
||||
ChunkWindowSpec,
|
||||
build_kv_manager,
|
||||
chunk_slot_mapping,
|
||||
compute_slot_mapping,
|
||||
resident_block_ids,
|
||||
)
|
||||
|
||||
BLOCK = 16
|
||||
|
||||
|
||||
def make_spec(*, window_chunks=2):
|
||||
return ChunkWindowSpec(
|
||||
block_size=BLOCK,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
dtype=torch.float16,
|
||||
sliding_window=window_chunks * BLOCK,
|
||||
chunk_size=BLOCK,
|
||||
window_chunks=window_chunks,
|
||||
)
|
||||
|
||||
|
||||
def test_slot_mapping_matches_block_offsets():
|
||||
# block table [5, 2, 9], block_size 4. slot = block*4 + (pos % 4).
|
||||
slots = compute_slot_mapping([5, 2, 9], [0, 1, 4, 5, 8, 9], block_size=4)
|
||||
assert slots.tolist() == [20, 21, 8, 9, 36, 37]
|
||||
|
||||
|
||||
def test_slot_mapping_rejects_bad_block_size():
|
||||
with pytest.raises(ValueError):
|
||||
compute_slot_mapping([1], [0], block_size=0)
|
||||
|
||||
|
||||
def test_chunk_slot_mapping_targets_current_chunk():
|
||||
# After 2 committed chunks (num_computed=32), the in-flight chunk maps to the
|
||||
# block covering positions 32..47 — i.e. block table index 2.
|
||||
block_ids = [7, 4, 3] # block index 2 -> physical block 3
|
||||
slots = chunk_slot_mapping(block_ids, num_computed_tokens=32, chunk_size=4, block_size=16)
|
||||
# positions 32..35 -> block_index 2 -> physical 3 -> slots 48..51
|
||||
assert slots.tolist() == [48, 49, 50, 51]
|
||||
|
||||
|
||||
def test_resident_block_ids_excludes_null():
|
||||
assert resident_block_ids([0, 1, 0, 2, 3], null_block_id=0) == [1, 2, 3]
|
||||
|
||||
|
||||
def test_blocktable_build_single_request():
|
||||
"""Integration: a real KVCacheManager block table -> a valid slot mapping.
|
||||
|
||||
Every slot for the in-flight chunk must land inside a real (non-null) block
|
||||
that the manager actually allocated for the request.
|
||||
"""
|
||||
spec = make_spec()
|
||||
mgr = build_kv_manager(spec, ["l0"], num_blocks=16, max_model_len=1024)
|
||||
null_id = mgr.block_pool.null_block.block_id
|
||||
adapter = ARDiffusionRequestAdapter("req", chunk_size=BLOCK)
|
||||
|
||||
mgr.allocate_slots(adapter, num_new_tokens=BLOCK)
|
||||
block_ids = mgr.get_block_ids(adapter.request_id)[0]
|
||||
real = set(resident_block_ids(block_ids, null_id))
|
||||
assert real, "expected at least one real block after allocation"
|
||||
|
||||
slots = chunk_slot_mapping(block_ids, adapter.num_computed_tokens, spec.chunk_size, spec.block_size)
|
||||
# Each slot resolves to a real allocated block, none to null_block.
|
||||
blocks_used = {int(s) // spec.block_size for s in slots}
|
||||
assert blocks_used <= real
|
||||
assert null_id not in blocks_used
|
||||
assert len(slots) == spec.chunk_size
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from vllm.v1.request import RequestStatus
|
||||
|
||||
import vllm_omni.core.sched.omni_ar_scheduler as scheduler_mod
|
||||
import vllm_omni.model_executor.models.voxcpm2.scheduler as voxcpm2_scheduler_mod
|
||||
from vllm_omni.model_executor.models.voxcpm2.scheduler import VoxCPM2OmniARAsyncScheduler
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _MockQueue:
|
||||
def __init__(self, items: list | None = None) -> None:
|
||||
self._items = list(items or [])
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._items)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._items)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._items)
|
||||
|
||||
def add_request(self, request) -> None:
|
||||
self._items.append(request)
|
||||
|
||||
def prepend_requests(self, requests) -> None:
|
||||
self._items = list(requests) + self._items
|
||||
|
||||
|
||||
class _MockRequest:
|
||||
def __init__(
|
||||
self,
|
||||
request_id: str,
|
||||
*,
|
||||
status: RequestStatus = RequestStatus.RUNNING,
|
||||
num_prompt_tokens: int = 4,
|
||||
num_computed_tokens: int = 4,
|
||||
num_output_placeholders: int = 0,
|
||||
) -> None:
|
||||
self.request_id = request_id
|
||||
self.status = status
|
||||
self.num_prompt_tokens = num_prompt_tokens
|
||||
self.num_computed_tokens = num_computed_tokens
|
||||
self.num_output_placeholders = num_output_placeholders
|
||||
|
||||
def is_finished(self) -> bool:
|
||||
return RequestStatus.is_finished(self.status)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_cuda_graph_platform(monkeypatch) -> None:
|
||||
monkeypatch.setattr(voxcpm2_scheduler_mod.current_omni_platform, "is_cuda", lambda: True)
|
||||
|
||||
|
||||
def _make_scheduler(
|
||||
*,
|
||||
enable_unified_decode_graph: bool | None = True,
|
||||
deterministic_cfm_noise: bool = False,
|
||||
runtime_config_on_model_config: bool = False,
|
||||
) -> VoxCPM2OmniARAsyncScheduler:
|
||||
sched = VoxCPM2OmniARAsyncScheduler.__new__(VoxCPM2OmniARAsyncScheduler)
|
||||
hf_config = SimpleNamespace()
|
||||
model_config = SimpleNamespace(hf_config=hf_config)
|
||||
if enable_unified_decode_graph is not None or deterministic_cfm_noise:
|
||||
runtime_config = SimpleNamespace(
|
||||
enable_unified_decode_graph=True if enable_unified_decode_graph is None else enable_unified_decode_graph,
|
||||
deterministic_cfm_noise=deterministic_cfm_noise,
|
||||
)
|
||||
if runtime_config_on_model_config:
|
||||
model_config.voxcpm2_runtime_config = runtime_config
|
||||
else:
|
||||
hf_config.voxcpm2_runtime_config = runtime_config
|
||||
sched.vllm_config = SimpleNamespace(model_config=model_config)
|
||||
return sched
|
||||
|
||||
|
||||
def test_voxcpm2_unified_decode_graph_defers_waiting_when_decode_ready() -> None:
|
||||
scheduler = _make_scheduler()
|
||||
scheduler.running = [_MockRequest("decode")]
|
||||
scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)])
|
||||
|
||||
assert scheduler._should_defer_waiting_for_unified_decode_graph()
|
||||
|
||||
|
||||
def test_voxcpm2_unified_decode_graph_does_not_defer_without_decode_ready() -> None:
|
||||
scheduler = _make_scheduler()
|
||||
scheduler.running = [_MockRequest("prefill-running", num_prompt_tokens=8, num_computed_tokens=4)]
|
||||
scheduler.waiting = _MockQueue([_MockRequest("waiting", status=RequestStatus.WAITING)])
|
||||
|
||||
assert not scheduler._should_defer_waiting_for_unified_decode_graph()
|
||||
|
||||
|
||||
def test_voxcpm2_unified_decode_graph_does_not_defer_when_disabled() -> None:
|
||||
scheduler = _make_scheduler(enable_unified_decode_graph=False)
|
||||
scheduler.running = [_MockRequest("decode")]
|
||||
scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)])
|
||||
|
||||
assert not scheduler._should_defer_waiting_for_unified_decode_graph()
|
||||
|
||||
|
||||
def test_voxcpm2_unified_decode_graph_uses_model_runtime_defaults() -> None:
|
||||
scheduler = _make_scheduler(enable_unified_decode_graph=None)
|
||||
scheduler.running = [_MockRequest("decode")]
|
||||
scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)])
|
||||
|
||||
assert scheduler._should_defer_waiting_for_unified_decode_graph()
|
||||
|
||||
|
||||
def test_voxcpm2_unified_decode_graph_reads_model_config_runtime_config() -> None:
|
||||
scheduler = _make_scheduler(enable_unified_decode_graph=True, runtime_config_on_model_config=True)
|
||||
scheduler.running = [_MockRequest("decode")]
|
||||
scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)])
|
||||
|
||||
assert scheduler._should_defer_waiting_for_unified_decode_graph()
|
||||
|
||||
|
||||
def test_voxcpm2_unified_decode_graph_does_not_defer_with_deterministic_noise() -> None:
|
||||
scheduler = _make_scheduler(deterministic_cfm_noise=True)
|
||||
scheduler.running = [_MockRequest("decode")]
|
||||
scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)])
|
||||
|
||||
assert not scheduler._should_defer_waiting_for_unified_decode_graph()
|
||||
|
||||
|
||||
def test_voxcpm2_unified_decode_graph_does_not_defer_without_cuda_graph(monkeypatch) -> None:
|
||||
monkeypatch.setattr(voxcpm2_scheduler_mod.current_omni_platform, "is_cuda", lambda: False)
|
||||
scheduler = _make_scheduler()
|
||||
scheduler.running = [_MockRequest("decode")]
|
||||
scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)])
|
||||
|
||||
assert not scheduler._should_defer_waiting_for_unified_decode_graph()
|
||||
|
||||
|
||||
def test_unified_decode_graph_deferral_restores_waiting_queue(monkeypatch) -> None:
|
||||
scheduler = _make_scheduler()
|
||||
scheduler.running = [_MockRequest("decode")]
|
||||
original_waiting_req = _MockRequest("waiting", status=RequestStatus.WAITING)
|
||||
deferred_by_upstream = _MockRequest("deferred-by-upstream", status=RequestStatus.WAITING)
|
||||
original_waiting = _MockQueue([original_waiting_req])
|
||||
scheduler.waiting = original_waiting
|
||||
scheduler.policy = "fcfs"
|
||||
scheduler.chunk_transfer_adapter = None
|
||||
scheduler.input_coordinator = None
|
||||
scheduler._consume_pending_connector_output = lambda model_mode: None
|
||||
|
||||
monkeypatch.setattr(scheduler_mod, "create_request_queue", lambda _policy: _MockQueue())
|
||||
|
||||
def fake_upstream_schedule(self, throttle_prefills: bool = False):
|
||||
assert self.waiting is not original_waiting
|
||||
assert not self.waiting
|
||||
self.waiting.add_request(deferred_by_upstream)
|
||||
raise RuntimeError("stop before output wrapping")
|
||||
|
||||
monkeypatch.setattr(scheduler_mod.VLLMScheduler, "schedule", fake_upstream_schedule)
|
||||
|
||||
with pytest.raises(RuntimeError, match="stop before output wrapping"):
|
||||
scheduler.schedule()
|
||||
|
||||
assert scheduler.waiting._items == [deferred_by_upstream, original_waiting_req]
|
||||
@@ -94,7 +94,7 @@ def omni_server(request):
|
||||
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
|
||||
|
||||
|
||||
@pytest.fixture(params=benchmark_indices)
|
||||
@pytest.fixture
|
||||
def benchmark_params(request, omni_server):
|
||||
"""Benchmark parameters fixture with proper parametrization"""
|
||||
test_name, param_index = request.param
|
||||
|
||||
+10
-1
@@ -13,7 +13,7 @@ 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
|
||||
from vllm_omni.diffusion.cache.cache_dit_backend 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
|
||||
@@ -193,3 +193,12 @@ def test_ltx2_cache_dit_receives_audio_as_encoder(init_fake_tp_group):
|
||||
# Pattern_0 maps (hidden_states, encoder_hidden_states) to (video, audio)
|
||||
assert torch.equal(captured["hidden_states"], video_in)
|
||||
assert torch.equal(captured["encoder_hidden_states"], audio_in)
|
||||
|
||||
|
||||
def test_summary_with_no_transformer_is_nonfatal():
|
||||
"""Regression test for https://github.com/vllm-project/vllm-omni/issues/4325."""
|
||||
|
||||
class FakePipeline:
|
||||
pass
|
||||
|
||||
cache_summary(pipeline=FakePipeline())
|
||||
@@ -67,6 +67,50 @@ def _make_request(**overrides) -> OmniDiffusionRequest:
|
||||
return OmniDiffusionRequest(**defaults)
|
||||
|
||||
|
||||
def _patch_fake_diffusers_quantization_backends(mocker):
|
||||
class FakePipelineQuantizationConfig:
|
||||
def __init__(self, *, quant_mapping=None, quant_backend=None, quant_kwargs=None, components_to_quantize=None):
|
||||
self.quant_mapping = quant_mapping
|
||||
self.quant_backend = quant_backend
|
||||
self.quant_kwargs = quant_kwargs
|
||||
self.components_to_quantize = components_to_quantize
|
||||
|
||||
class FakeTorchAoConfig:
|
||||
def __init__(self, quant_type, modules_to_not_convert=None):
|
||||
self.quant_type = quant_type
|
||||
self.modules_to_not_convert = modules_to_not_convert
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config):
|
||||
return cls(quant_type=config["quant_type"])
|
||||
|
||||
class FakeInt8DynamicActivationInt8WeightConfig:
|
||||
pass
|
||||
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.models.diffusers_adapter.quantization_utils.PipelineQuantizationConfig",
|
||||
FakePipelineQuantizationConfig,
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.models.diffusers_adapter.quantization_utils.TorchAoConfig",
|
||||
FakeTorchAoConfig,
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.models.diffusers_adapter.quantization_utils._get_torchao_quant_type_cls",
|
||||
return_value=FakeInt8DynamicActivationInt8WeightConfig,
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.models.diffusers_adapter.quantization_utils._get_diffusers_quantization_config_cls",
|
||||
return_value=FakeTorchAoConfig,
|
||||
)
|
||||
return FakePipelineQuantizationConfig, FakeTorchAoConfig, FakeInt8DynamicActivationInt8WeightConfig
|
||||
|
||||
|
||||
class _TransformerBackedPipeline(DiffusionPipeline):
|
||||
def __init__(self, transformer):
|
||||
pass
|
||||
|
||||
|
||||
def _make_batch(**overrides) -> DiffusionRequestBatch:
|
||||
"""Wrap a single request in a DiffusionRequestBatch, matching the forward contract."""
|
||||
return DiffusionRequestBatch(requests=[_make_request(**overrides)])
|
||||
@@ -98,7 +142,7 @@ class TestPipelineArgumentsHandling:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"feature_id",
|
||||
["cfg_parallel", "ulysses", "ring", "teacache", "cache_dit", "enforce_eager", "quantization"],
|
||||
["cfg_parallel", "ulysses", "ring", "teacache", "cache_dit", "enforce_eager", "unsupported_quantization"],
|
||||
)
|
||||
def test_adapter_guard_unsupported_feature(self, feature_id):
|
||||
if feature_id == "cfg_parallel":
|
||||
@@ -128,14 +172,188 @@ class TestPipelineArgumentsHandling:
|
||||
)
|
||||
elif feature_id == "enforce_eager":
|
||||
od_config = _make_od_config(enforce_eager=True)
|
||||
elif feature_id == "quantization":
|
||||
od_config = _make_od_config(quantization_config=SimpleNamespace(quant_method="fp8"))
|
||||
elif feature_id == "unsupported_quantization":
|
||||
od_config = _make_od_config(quantization_config=SimpleNamespace(quant_method="gguf"))
|
||||
else:
|
||||
raise ValueError(f"Unknown feature ID: {feature_id}")
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
DiffusersAdapterPipeline(od_config=od_config)
|
||||
|
||||
def test_adapter_load_weights_injects_supported_quantization_config(self, mocker):
|
||||
(
|
||||
FakePipelineQuantizationConfig,
|
||||
FakeTorchAoConfig,
|
||||
FakeInt8DynamicActivationInt8WeightConfig,
|
||||
) = _patch_fake_diffusers_quantization_backends(mocker)
|
||||
|
||||
class MockPipeline:
|
||||
def __call__(self, prompt=None):
|
||||
return None
|
||||
|
||||
def to(self, device):
|
||||
return self
|
||||
|
||||
mock_from_pretrained = mocker.patch(
|
||||
"vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter.DiffusionPipeline.from_pretrained",
|
||||
return_value=MockPipeline(),
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter.DiffusionPipeline.load_config",
|
||||
return_value={
|
||||
"_class_name": "TransformerBackedPipeline",
|
||||
"scheduler": ["diffusers", "SchedulerMixin"],
|
||||
"transformer": ["diffusers", "Transformer2DModel"],
|
||||
"transformer_2": ["diffusers", "Transformer2DModel"],
|
||||
"vae": ["diffusers", "AutoencoderKL"],
|
||||
},
|
||||
)
|
||||
|
||||
adapter = DiffusersAdapterPipeline(
|
||||
od_config=_make_od_config(
|
||||
quantization_config=SimpleNamespace(quant_method="int8"),
|
||||
diffusers_pipeline_cls=_TransformerBackedPipeline,
|
||||
)
|
||||
)
|
||||
adapter.load_weights()
|
||||
|
||||
pipeline_quant_config = mock_from_pretrained.call_args.kwargs["quantization_config"]
|
||||
torchao_config = pipeline_quant_config.quant_mapping["transformer"]
|
||||
assert isinstance(pipeline_quant_config, FakePipelineQuantizationConfig)
|
||||
assert isinstance(torchao_config, FakeTorchAoConfig)
|
||||
assert isinstance(torchao_config.quant_type, FakeInt8DynamicActivationInt8WeightConfig)
|
||||
assert isinstance(
|
||||
pipeline_quant_config.quant_mapping["transformer_2"].quant_type,
|
||||
FakeInt8DynamicActivationInt8WeightConfig,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pipeline_config", "diffusers_load_kwargs", "diffusers_pipeline_cls"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"_class_name": "NoTransformerPipeline",
|
||||
"scheduler": ["diffusers", "SchedulerMixin"],
|
||||
"unet": ["diffusers", "UNet2DConditionModel"],
|
||||
"vae": ["diffusers", "AutoencoderKL"],
|
||||
},
|
||||
{},
|
||||
_TransformerBackedPipeline,
|
||||
),
|
||||
(
|
||||
{
|
||||
"_class_name": "TransformerPipeline",
|
||||
"scheduler": ["diffusers", "SchedulerMixin"],
|
||||
"transformer": ["diffusers", "Transformer2DModel"],
|
||||
"vae": ["diffusers", "AutoencoderKL"],
|
||||
},
|
||||
{"transformer": None},
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_adapter_load_weights_rejects_converted_quantization_without_transformer_component(
|
||||
self,
|
||||
pipeline_config,
|
||||
diffusers_load_kwargs,
|
||||
diffusers_pipeline_cls,
|
||||
mocker,
|
||||
):
|
||||
_patch_fake_diffusers_quantization_backends(mocker)
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter.DiffusionPipeline.load_config",
|
||||
return_value=pipeline_config,
|
||||
)
|
||||
mock_from_pretrained = mocker.patch(
|
||||
"vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter.DiffusionPipeline.from_pretrained"
|
||||
)
|
||||
|
||||
adapter = DiffusersAdapterPipeline(
|
||||
od_config=_make_od_config(
|
||||
quantization_config=SimpleNamespace(quant_method="int8"),
|
||||
diffusers_load_kwargs=diffusers_load_kwargs,
|
||||
diffusers_pipeline_cls=diffusers_pipeline_cls,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(NotImplementedError, match="transformer"):
|
||||
adapter.load_weights()
|
||||
mock_from_pretrained.assert_not_called()
|
||||
|
||||
def test_adapter_rejects_dduf_file_with_converted_quantization(self):
|
||||
with pytest.raises(NotImplementedError, match="dduf_file"):
|
||||
DiffusersAdapterPipeline(
|
||||
od_config=_make_od_config(
|
||||
quantization_config=SimpleNamespace(quant_method="int8"),
|
||||
diffusers_load_kwargs={"dduf_file": "model.dduf"},
|
||||
)
|
||||
)
|
||||
|
||||
def test_adapter_load_weights_preserves_diffusers_native_quantization_config(self, mocker):
|
||||
class MockPipeline:
|
||||
def __call__(self, prompt=None):
|
||||
return None
|
||||
|
||||
def to(self, device):
|
||||
return self
|
||||
|
||||
diffusers_native_quantization_config = object()
|
||||
mock_from_pretrained = mocker.patch(
|
||||
"vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter.DiffusionPipeline.from_pretrained",
|
||||
return_value=MockPipeline(),
|
||||
)
|
||||
|
||||
adapter = DiffusersAdapterPipeline(
|
||||
od_config=_make_od_config(
|
||||
quantization_config=SimpleNamespace(quant_method="gguf"),
|
||||
diffusers_load_kwargs={
|
||||
"dduf_file": "model.dduf",
|
||||
"quantization_config": diffusers_native_quantization_config,
|
||||
},
|
||||
)
|
||||
)
|
||||
adapter.load_weights()
|
||||
|
||||
kwargs = mock_from_pretrained.call_args.kwargs
|
||||
assert kwargs["quantization_config"] is diffusers_native_quantization_config
|
||||
assert kwargs["dduf_file"] == "model.dduf"
|
||||
|
||||
def test_adapter_load_weights_builds_diffusers_native_quantization_config_from_dict(self, mocker):
|
||||
FakePipelineQuantizationConfig, FakeTorchAoConfig, _ = _patch_fake_diffusers_quantization_backends(mocker)
|
||||
|
||||
class MockPipeline:
|
||||
def __call__(self, prompt=None):
|
||||
return None
|
||||
|
||||
def to(self, device):
|
||||
return self
|
||||
|
||||
mock_from_pretrained = mocker.patch(
|
||||
"vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter.DiffusionPipeline.from_pretrained",
|
||||
return_value=MockPipeline(),
|
||||
)
|
||||
|
||||
adapter = DiffusersAdapterPipeline(
|
||||
od_config=_make_od_config(
|
||||
diffusers_load_kwargs={
|
||||
"quantization_config": {
|
||||
"quant_mapping": {
|
||||
"transformer": {
|
||||
"quant_method": "torchao",
|
||||
"quant_type": "fake-int8-config",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
adapter.load_weights()
|
||||
|
||||
quant_config = mock_from_pretrained.call_args.kwargs["quantization_config"]
|
||||
assert isinstance(quant_config, FakePipelineQuantizationConfig)
|
||||
assert isinstance(quant_config.quant_mapping["transformer"], FakeTorchAoConfig)
|
||||
assert quant_config.quant_mapping["transformer"].quant_type == "fake-int8-config"
|
||||
|
||||
def test_adapter_guard_unknown_output_type(self, mocker):
|
||||
"""Test that the adapter wraps an unknown output type as-is.
|
||||
This is useful when `return_dict=True` and the diffusers pipeline returns an OrderedDict subclass."""
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Optional real-backend checks for Diffusers quantization config conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _load_quantization_utils():
|
||||
module_path = (
|
||||
Path(__file__).resolve().parents[3] / "vllm_omni/diffusion/models/diffusers_adapter/quantization_utils.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("diffusers_quantization_utils_real_test", module_path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_convert_diffusers_quantization_config_from_real_torchao_dict():
|
||||
diffusers = pytest.importorskip("diffusers")
|
||||
torchao_quantization = pytest.importorskip("torchao.quantization")
|
||||
quant_type_cls = getattr(torchao_quantization, "Int8DynamicActivationInt8WeightConfig", None)
|
||||
if quant_type_cls is None:
|
||||
pytest.skip("torchao does not expose Int8DynamicActivationInt8WeightConfig")
|
||||
|
||||
quantization_utils = _load_quantization_utils()
|
||||
|
||||
real_torchao_config = diffusers.TorchAoConfig(quant_type=quant_type_cls())
|
||||
serialized_torchao_config = real_torchao_config.to_dict()
|
||||
assert serialized_torchao_config["quant_type"].keys() == {"default"}
|
||||
|
||||
load_kwargs = {
|
||||
"quantization_config": {
|
||||
"quant_mapping": {
|
||||
"transformer": serialized_torchao_config,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
quantization_utils.convert_diffusers_quantization_config(load_kwargs)
|
||||
|
||||
quant_config = load_kwargs["quantization_config"]
|
||||
component_config = quant_config.quant_mapping["transformer"]
|
||||
assert isinstance(quant_config, diffusers.PipelineQuantizationConfig)
|
||||
assert isinstance(component_config, diffusers.TorchAoConfig)
|
||||
assert isinstance(
|
||||
component_config.quant_type,
|
||||
quant_type_cls,
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Focused tests for Diffusers backend quantization conversion helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.models.diffusers_adapter import quantization_utils
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _FakePipelineQuantizationConfig:
|
||||
def __init__(self, *, quant_mapping=None, quant_backend=None, quant_kwargs=None, components_to_quantize=None):
|
||||
self.quant_mapping = quant_mapping
|
||||
self.quant_backend = quant_backend
|
||||
self.quant_kwargs = quant_kwargs
|
||||
self.components_to_quantize = components_to_quantize
|
||||
|
||||
|
||||
class _FakeTorchAoConfig:
|
||||
def __init__(self, quant_type):
|
||||
self.quant_type = quant_type
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config):
|
||||
return cls(quant_type=config["quant_type"])
|
||||
|
||||
|
||||
class _FakeFloat8DynamicActivationFloat8WeightConfig:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeInt8DynamicActivationInt8WeightConfig:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_quantization_backends(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
quantization_utils,
|
||||
"PipelineQuantizationConfig",
|
||||
_FakePipelineQuantizationConfig,
|
||||
)
|
||||
monkeypatch.setattr(quantization_utils, "TorchAoConfig", _FakeTorchAoConfig)
|
||||
|
||||
def fake_get_torchao_quant_type_cls(class_name: str):
|
||||
return {
|
||||
"Float8DynamicActivationFloat8WeightConfig": _FakeFloat8DynamicActivationFloat8WeightConfig,
|
||||
"Int8DynamicActivationInt8WeightConfig": _FakeInt8DynamicActivationInt8WeightConfig,
|
||||
}[class_name]
|
||||
|
||||
monkeypatch.setattr(quantization_utils, "_get_torchao_quant_type_cls", fake_get_torchao_quant_type_cls)
|
||||
monkeypatch.setattr(quantization_utils, "_get_diffusers_quantization_config_cls", lambda _: _FakeTorchAoConfig)
|
||||
|
||||
|
||||
def _quant_config(method: str, **kwargs):
|
||||
return SimpleNamespace(get_name=lambda: method, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "expected_type"),
|
||||
[
|
||||
("fp8", _FakeFloat8DynamicActivationFloat8WeightConfig),
|
||||
("int8", _FakeInt8DynamicActivationInt8WeightConfig),
|
||||
],
|
||||
)
|
||||
def test_apply_injects_converted_quantization_config(method, expected_type):
|
||||
od_config = SimpleNamespace(quantization_config=_quant_config(method))
|
||||
load_kwargs = {}
|
||||
|
||||
injected = quantization_utils.apply_diffusers_quantization_config(
|
||||
od_config,
|
||||
load_kwargs,
|
||||
{"transformer": ["diffusers", "Transformer2DModel"]},
|
||||
)
|
||||
|
||||
assert injected is True
|
||||
torchao_config = load_kwargs["quantization_config"].quant_mapping["transformer"]
|
||||
assert isinstance(torchao_config.quant_type, expected_type)
|
||||
|
||||
|
||||
def test_apply_injects_converted_quantization_config_for_transformer_2():
|
||||
od_config = SimpleNamespace(quantization_config=_quant_config("int8"))
|
||||
load_kwargs = {}
|
||||
|
||||
quantization_utils.apply_diffusers_quantization_config(
|
||||
od_config,
|
||||
load_kwargs,
|
||||
{
|
||||
"transformer": ["diffusers", "Transformer2DModel"],
|
||||
"transformer_2": ["diffusers", "Transformer2DModel"],
|
||||
},
|
||||
)
|
||||
|
||||
assert sorted(load_kwargs["quantization_config"].quant_mapping) == ["transformer", "transformer_2"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["gguf", "modelopt", "mxfp4", "mxfp8", "inc"])
|
||||
def test_unsupported_methods_fail_explicitly(method):
|
||||
with pytest.raises(NotImplementedError, match=method):
|
||||
quantization_utils.ensure_supported_diffusers_quantization(_quant_config(method))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"quant_config",
|
||||
[
|
||||
_quant_config("fp8", activation_scheme="static"),
|
||||
_quant_config("int8", is_checkpoint_int8_serialized=True),
|
||||
_quant_config("fp8", weight_block_size=[128, 128]),
|
||||
_quant_config("int8", ignored_layers=["transformer.proj_out"]),
|
||||
],
|
||||
)
|
||||
def test_ambiguous_mappings_fail_explicitly(quant_config):
|
||||
with pytest.raises(NotImplementedError):
|
||||
quantization_utils.ensure_supported_diffusers_quantization(quant_config)
|
||||
|
||||
|
||||
def test_apply_preserves_diffusers_load_kwargs_quantization_config(mocker):
|
||||
od_config = SimpleNamespace(quantization_config=_quant_config("fp8"))
|
||||
existing = object()
|
||||
load_kwargs = {"quantization_config": existing}
|
||||
mock_warning = mocker.patch.object(quantization_utils.logger, "warning")
|
||||
|
||||
injected = quantization_utils.apply_diffusers_quantization_config(od_config, load_kwargs, {})
|
||||
|
||||
assert injected is False
|
||||
assert load_kwargs["quantization_config"] is existing
|
||||
assert "Using the Diffusers-native quantization_config" in mock_warning.call_args.args[0]
|
||||
|
||||
|
||||
def test_convert_diffusers_quantization_config_from_dict():
|
||||
load_kwargs = {
|
||||
"quantization_config": {
|
||||
"quant_mapping": {
|
||||
"transformer": {
|
||||
"quant_method": "torchao",
|
||||
"quant_type": "fake-int8-config",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
quantization_utils.convert_diffusers_quantization_config(load_kwargs)
|
||||
|
||||
quant_config = load_kwargs["quantization_config"]
|
||||
assert isinstance(quant_config, _FakePipelineQuantizationConfig)
|
||||
assert isinstance(quant_config.quant_mapping["transformer"], _FakeTorchAoConfig)
|
||||
assert quant_config.quant_mapping["transformer"].quant_type == "fake-int8-config"
|
||||
@@ -0,0 +1,282 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import vllm_omni.diffusion.distributed.parallel_state as parallel_state
|
||||
|
||||
|
||||
class _FakeGroup:
|
||||
def __init__(
|
||||
self,
|
||||
group_ranks: list[list[int]],
|
||||
local_rank: int,
|
||||
parallel_mode: str,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.group_ranks = group_ranks
|
||||
self.parallel_mode = parallel_mode
|
||||
self.device_group = object()
|
||||
self.ulysses_group = kwargs.get("ulysses_group")
|
||||
self.ring_group = kwargs.get("ring_group")
|
||||
self.local_group = next(group for group in group_ranks if local_rank in group)
|
||||
self.world_size = len(self.local_group)
|
||||
self.rank_in_group = self.local_group.index(local_rank)
|
||||
|
||||
def destroy(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.core_model
|
||||
def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch):
|
||||
"""MoE+EP rank layout should map SP->PCP, CFG*DP->DP, and TP*SP*CFG*DP->EP."""
|
||||
local_rank = 0
|
||||
world_size = 32
|
||||
created_groups: list[_FakeGroup] = []
|
||||
|
||||
def fake_init_model_parallel_group(
|
||||
group_ranks,
|
||||
local_rank,
|
||||
backend,
|
||||
parallel_mode=None,
|
||||
group_name=None,
|
||||
**kwargs,
|
||||
):
|
||||
del backend, group_name
|
||||
group = _FakeGroup(
|
||||
[list(ranks) for ranks in group_ranks],
|
||||
local_rank,
|
||||
parallel_mode or "",
|
||||
**kwargs,
|
||||
)
|
||||
created_groups.append(group)
|
||||
return group
|
||||
|
||||
fake_world_group = SimpleNamespace(
|
||||
rank_in_group=local_rank,
|
||||
local_rank=local_rank,
|
||||
device_group=object(),
|
||||
)
|
||||
fake_forward_context = SimpleNamespace(omni_diffusion_config=SimpleNamespace(is_moe=True))
|
||||
monkeypatch.setattr(parallel_state.torch.distributed, "is_initialized", lambda: True)
|
||||
monkeypatch.setattr(parallel_state.torch.distributed, "get_world_size", lambda: world_size)
|
||||
monkeypatch.setattr(parallel_state.torch.distributed, "get_backend", lambda *_args, **_kwargs: "gloo")
|
||||
monkeypatch.setattr(parallel_state.torch.distributed, "new_group", lambda ranks: tuple(ranks))
|
||||
monkeypatch.setattr(parallel_state, "get_world_group", lambda: fake_world_group)
|
||||
monkeypatch.setattr(parallel_state, "get_forward_context", lambda: fake_forward_context)
|
||||
monkeypatch.setattr(parallel_state, "init_model_parallel_group", fake_init_model_parallel_group)
|
||||
monkeypatch.setattr(parallel_state, "init_dit_group", lambda *_args, **_kwargs: None)
|
||||
|
||||
for name in ("_DP", "_CFG", "_SP", "_PP", "_FS", "_EXPERT_PARALLEL_GROUP_RANKS"):
|
||||
monkeypatch.setattr(parallel_state, name, None)
|
||||
for name in ("_TP", "_PCP", "_DP", "_EP", "_PP"):
|
||||
monkeypatch.setattr(parallel_state.vllm_parallel_state, name, None, raising=False)
|
||||
|
||||
parallel_state.initialize_model_parallel(
|
||||
tensor_parallel_size=2,
|
||||
sequence_parallel_size=2,
|
||||
ulysses_degree=2,
|
||||
ring_degree=1,
|
||||
pipeline_parallel_size=2,
|
||||
cfg_parallel_size=2,
|
||||
data_parallel_size=2,
|
||||
enable_expert_parallel=True,
|
||||
backend="gloo",
|
||||
)
|
||||
|
||||
assert parallel_state.vllm_parallel_state._PCP is parallel_state._SP
|
||||
assert parallel_state.vllm_parallel_state._PCP.world_size == 2
|
||||
assert parallel_state._DP.world_size == 2
|
||||
assert parallel_state.vllm_parallel_state._DP is not parallel_state._DP
|
||||
assert parallel_state.vllm_parallel_state._DP.world_size == 4
|
||||
assert parallel_state.vllm_parallel_state._EP.world_size == 16
|
||||
assert parallel_state.vllm_parallel_state._TP.world_size == 2
|
||||
assert parallel_state._PP.world_size == 2
|
||||
|
||||
assert parallel_state.vllm_parallel_state._PCP.local_group == [0, 2]
|
||||
assert parallel_state.vllm_parallel_state._DP.local_group == [0, 8, 16, 24]
|
||||
assert parallel_state.vllm_parallel_state._EP.local_group == [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
27,
|
||||
]
|
||||
assert parallel_state.get_expert_parallel_group_ranks() == [
|
||||
[
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
27,
|
||||
],
|
||||
[
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
28,
|
||||
29,
|
||||
30,
|
||||
31,
|
||||
],
|
||||
]
|
||||
|
||||
ep_groups = [group.local_group for group in created_groups if group.parallel_mode == "expert"]
|
||||
assert ep_groups == [parallel_state.vllm_parallel_state._EP.local_group]
|
||||
|
||||
|
||||
@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."""
|
||||
local_rank = 0
|
||||
world_size = 8
|
||||
|
||||
def fake_init_model_parallel_group(
|
||||
group_ranks,
|
||||
local_rank,
|
||||
backend,
|
||||
parallel_mode=None,
|
||||
group_name=None,
|
||||
**kwargs,
|
||||
):
|
||||
del backend, group_name
|
||||
return _FakeGroup(
|
||||
[list(ranks) for ranks in group_ranks],
|
||||
local_rank,
|
||||
parallel_mode or "",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
fake_world_group = SimpleNamespace(
|
||||
rank_in_group=local_rank,
|
||||
local_rank=local_rank,
|
||||
device_group=object(),
|
||||
)
|
||||
monkeypatch.setattr(parallel_state.torch.distributed, "is_initialized", lambda: True)
|
||||
monkeypatch.setattr(parallel_state.torch.distributed, "get_world_size", lambda: world_size)
|
||||
monkeypatch.setattr(parallel_state.torch.distributed, "get_backend", lambda *_args, **_kwargs: "gloo")
|
||||
monkeypatch.setattr(parallel_state.torch.distributed, "new_group", lambda ranks: tuple(ranks))
|
||||
monkeypatch.setattr(parallel_state, "get_world_group", lambda: fake_world_group)
|
||||
monkeypatch.setattr(parallel_state, "init_model_parallel_group", fake_init_model_parallel_group)
|
||||
monkeypatch.setattr(parallel_state, "init_dit_group", lambda *_args, **_kwargs: None)
|
||||
|
||||
for name in ("_DP", "_CFG", "_SP", "_PP", "_FS", "_EXPERT_PARALLEL_GROUP_RANKS"):
|
||||
monkeypatch.setattr(parallel_state, name, None)
|
||||
for name in ("_TP", "_PCP", "_DP", "_EP", "_PP"):
|
||||
monkeypatch.setattr(parallel_state.vllm_parallel_state, name, None, raising=False)
|
||||
|
||||
parallel_state.initialize_model_parallel(
|
||||
tensor_parallel_size=2,
|
||||
sequence_parallel_size=1,
|
||||
ulysses_degree=1,
|
||||
ring_degree=1,
|
||||
pipeline_parallel_size=1,
|
||||
cfg_parallel_size=2,
|
||||
data_parallel_size=2,
|
||||
enable_expert_parallel=False,
|
||||
backend="gloo",
|
||||
)
|
||||
|
||||
assert parallel_state._DP.world_size == 2
|
||||
assert parallel_state.vllm_parallel_state._DP is parallel_state._DP
|
||||
assert parallel_state.vllm_parallel_state._DP.world_size == 2
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.core_model
|
||||
def test_non_moe_ep_fails_before_vllm_ep_remap(monkeypatch):
|
||||
"""Non-MoE diffusion configs should not create vLLM PCP/DP/EP remap state."""
|
||||
local_rank = 0
|
||||
world_size = 8
|
||||
|
||||
def fake_init_model_parallel_group(
|
||||
group_ranks,
|
||||
local_rank,
|
||||
backend,
|
||||
parallel_mode=None,
|
||||
group_name=None,
|
||||
**kwargs,
|
||||
):
|
||||
del backend, group_name
|
||||
return _FakeGroup(
|
||||
[list(ranks) for ranks in group_ranks],
|
||||
local_rank,
|
||||
parallel_mode or "",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
fake_world_group = SimpleNamespace(
|
||||
rank_in_group=local_rank,
|
||||
local_rank=local_rank,
|
||||
device_group=object(),
|
||||
)
|
||||
fake_forward_context = SimpleNamespace(omni_diffusion_config=SimpleNamespace(is_moe=False))
|
||||
monkeypatch.setattr(parallel_state.torch.distributed, "is_initialized", lambda: True)
|
||||
monkeypatch.setattr(parallel_state.torch.distributed, "get_world_size", lambda: world_size)
|
||||
monkeypatch.setattr(parallel_state.torch.distributed, "get_backend", lambda *_args, **_kwargs: "gloo")
|
||||
monkeypatch.setattr(parallel_state.torch.distributed, "new_group", lambda ranks: tuple(ranks))
|
||||
monkeypatch.setattr(parallel_state, "get_world_group", lambda: fake_world_group)
|
||||
monkeypatch.setattr(parallel_state, "get_forward_context", lambda: fake_forward_context)
|
||||
monkeypatch.setattr(parallel_state, "init_model_parallel_group", fake_init_model_parallel_group)
|
||||
monkeypatch.setattr(parallel_state, "init_dit_group", lambda *_args, **_kwargs: None)
|
||||
|
||||
for name in ("_DP", "_CFG", "_SP", "_PP", "_FS", "_EXPERT_PARALLEL_GROUP_RANKS"):
|
||||
monkeypatch.setattr(parallel_state, name, None)
|
||||
for name in ("_TP", "_PCP", "_DP", "_EP", "_PP"):
|
||||
monkeypatch.setattr(parallel_state.vllm_parallel_state, name, None, raising=False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Expert parallelism enabled for a non-MoE model"):
|
||||
parallel_state.initialize_model_parallel(
|
||||
tensor_parallel_size=2,
|
||||
sequence_parallel_size=2,
|
||||
ulysses_degree=2,
|
||||
ring_degree=1,
|
||||
pipeline_parallel_size=1,
|
||||
cfg_parallel_size=2,
|
||||
data_parallel_size=1,
|
||||
enable_expert_parallel=True,
|
||||
backend="gloo",
|
||||
)
|
||||
|
||||
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
|
||||
@@ -575,3 +575,45 @@ def test_lora_manager_discovers_bagel_component(monkeypatch):
|
||||
assert "bagel.language_model.qkv_proj" in manager._lora_modules
|
||||
# Verify the module was actually replaced in the tree (not just recorded)
|
||||
assert isinstance(pipeline.bagel.language_model.qkv_proj, _DummyBaseLayerWithLoRA)
|
||||
|
||||
|
||||
def test_lora_manager_discovers_unet_component(monkeypatch):
|
||||
"""Verify that _replace_layers_with_lora finds layers under 'unet'."""
|
||||
import vllm_omni.diffusion.lora.manager as manager_mod
|
||||
|
||||
monkeypatch.setattr(manager_mod, "BaseLayerWithLoRA", _DummyBaseLayerWithLoRA)
|
||||
|
||||
def _fake_from_layer_diffusion(*, layer: torch.nn.Module, **_kwargs):
|
||||
if isinstance(layer, _FakeLinearBase):
|
||||
return _DummyBaseLayerWithLoRA(layer)
|
||||
return layer
|
||||
|
||||
replace_calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(manager_mod, "from_layer_diffusion", _fake_from_layer_diffusion)
|
||||
monkeypatch.setattr(
|
||||
manager_mod,
|
||||
"replace_submodule",
|
||||
lambda root, name, sub: fake_replace_submodule(root, name, sub, replace_calls),
|
||||
)
|
||||
|
||||
# Pipeline with a 'unet' component (no 'transformer')
|
||||
pipeline = torch.nn.Module()
|
||||
pipeline.unet = torch.nn.Module()
|
||||
pipeline.unet.down_block = torch.nn.Module()
|
||||
pipeline.unet.down_block.proj = _FakeLinearBase()
|
||||
|
||||
manager = DiffusionLoRAManager(
|
||||
pipeline=pipeline,
|
||||
device=torch.device("cpu"),
|
||||
dtype=torch.bfloat16,
|
||||
max_cached_adapters=1,
|
||||
)
|
||||
|
||||
peft_helper = type("_PH", (), {"r": 1})()
|
||||
manager._replace_layers_with_lora(peft_helper)
|
||||
|
||||
assert "down_block.proj" in replace_calls
|
||||
assert "unet.down_block.proj" in manager._lora_modules
|
||||
# Verify the module was actually replaced in the tree (not just recorded)
|
||||
assert isinstance(pipeline.unet.down_block.proj, _DummyBaseLayerWithLoRA)
|
||||
@@ -916,6 +916,49 @@ def test_prepare_latents_i2v_encodes_only_conditioning_frame(make_cosmos3_pipeli
|
||||
assert velocity_mask.tolist() == [[[[[0.0]], [[1.0]], [[1.0]]]]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["policy", "forward_dynamics"])
|
||||
def test_prepare_action_video_latents_encodes_only_conditioning_frame(make_cosmos3_pipeline, mode: str) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
video = torch.zeros(1, 3, 9, 24, 32)
|
||||
|
||||
latents, velocity_mask, condition_latents = pipeline._prepare_latents_action_video(
|
||||
video,
|
||||
mode,
|
||||
24,
|
||||
32,
|
||||
9,
|
||||
torch.Generator(device="cpu").manual_seed(0),
|
||||
image_size=torch.tensor([1, 3, 16, 24]),
|
||||
)
|
||||
|
||||
assert pipeline.vae.encode_input_shapes == [(1, 3, 1, 24, 32)]
|
||||
assert latents.shape == (1, 2, 3, 2, 3)
|
||||
assert condition_latents.shape == latents.shape
|
||||
torch.testing.assert_close(condition_latents[:, :, 0], torch.ones(1, 2, 2, 3))
|
||||
assert torch.count_nonzero(condition_latents[:, :, 1:]) == 0
|
||||
torch.testing.assert_close(latents[:, :, 0:1], condition_latents[:, :, 0:1])
|
||||
assert velocity_mask.tolist() == [[[[[0.0]], [[1.0]], [[1.0]]]]]
|
||||
|
||||
|
||||
def test_prepare_inverse_dynamics_latents_encodes_full_video(make_cosmos3_pipeline) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
video = torch.zeros(1, 3, 9, 16, 24)
|
||||
|
||||
latents, velocity_mask, condition_latents = pipeline._prepare_latents_action_video(
|
||||
video,
|
||||
"inverse_dynamics",
|
||||
16,
|
||||
24,
|
||||
9,
|
||||
torch.Generator(device="cpu").manual_seed(0),
|
||||
)
|
||||
|
||||
assert pipeline.vae.encode_input_shapes == [(1, 3, 9, 16, 24)]
|
||||
assert condition_latents.shape == (1, 2, 3, 2, 3)
|
||||
torch.testing.assert_close(latents, condition_latents)
|
||||
assert torch.count_nonzero(velocity_mask) == 0
|
||||
|
||||
|
||||
def test_diffuse_covers_cfg_i2v_and_multimodal_steps(make_cosmos3_pipeline) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
latents = torch.zeros(1, 2, 1, 1, 1)
|
||||
|
||||
@@ -21,6 +21,8 @@ def _make_flux_sampling(**overrides):
|
||||
"true_cfg_scale": 4.0,
|
||||
"num_outputs_per_prompt": 0,
|
||||
"latents": None,
|
||||
"output_type": None,
|
||||
"max_sequence_length": None,
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
@@ -131,7 +133,7 @@ def test_forward_collates_request_prompt_tensors_for_flux(monkeypatch):
|
||||
"negative_prompt_embeds": negative_prompt_embeds_a,
|
||||
"negative_pooled_prompt_embeds": negative_pooled_prompt_embeds_a,
|
||||
},
|
||||
sampling_params=_make_flux_sampling(generator=gen_a, latents=latents_a),
|
||||
sampling_params=_make_flux_sampling(generator=gen_a, latents=latents_a, output_type="latent"),
|
||||
),
|
||||
SimpleNamespace(
|
||||
request_id="flux-prompt-b",
|
||||
@@ -145,12 +147,12 @@ def test_forward_collates_request_prompt_tensors_for_flux(monkeypatch):
|
||||
"negative_pooled_prompt_embeds": [negative_pooled_prompt_embeds_b],
|
||||
},
|
||||
},
|
||||
sampling_params=_make_flux_sampling(generator=gen_b, latents=latents_b),
|
||||
sampling_params=_make_flux_sampling(generator=gen_b, latents=latents_b, output_type="latent"),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outputs = pipeline.forward(batch, output_type="latent")
|
||||
outputs = pipeline.forward(batch)
|
||||
|
||||
assert encode_calls[0]["prompt"] is None
|
||||
assert encode_calls[0]["prompt_2"] is None
|
||||
|
||||
@@ -524,11 +524,11 @@ class TestAr2DiffusionIntegration:
|
||||
self,
|
||||
token_ids: list[int],
|
||||
prompt_kwargs: dict[str, Any] | None = None,
|
||||
) -> tuple[Any, Any]:
|
||||
) -> dict[str, Any] | None:
|
||||
"""Invoke ``ar2diffusion`` with a single fake AR output.
|
||||
|
||||
Returns ``(diffusion_inputs, ar_predicted_sizes)`` so callers can
|
||||
assert on the resolved height/width.
|
||||
Returns the diffusion request payload so callers can assert on the
|
||||
resolved height/width.
|
||||
"""
|
||||
from vllm_omni.model_executor.stage_input_processors.hunyuan_image3 import (
|
||||
ar2diffusion,
|
||||
@@ -601,8 +601,8 @@ class TestAr2DiffusionIntegration:
|
||||
token_ids=[150000, 150001, token_id],
|
||||
prompt_kwargs={"height": 1024, "width": 1024, "image_base_size": 1024},
|
||||
)
|
||||
assert len(result) == 1
|
||||
diffusion_input = result[0]
|
||||
assert result is not None
|
||||
diffusion_input = result
|
||||
assert diffusion_input["height"] == expected_h, (
|
||||
f"ratio_idx={ratio_idx}: expected height {expected_h}, got {diffusion_input['height']}"
|
||||
)
|
||||
@@ -617,8 +617,8 @@ class TestAr2DiffusionIntegration:
|
||||
token_ids=[150000, 150001], # no ratio tokens
|
||||
prompt_kwargs={"height": 768, "width": 1024, "image_base_size": 1024},
|
||||
)
|
||||
assert len(result) == 1
|
||||
diffusion_input = result[0]
|
||||
assert result is not None
|
||||
diffusion_input = result
|
||||
assert diffusion_input["height"] == 768
|
||||
assert diffusion_input["width"] == 1024
|
||||
|
||||
@@ -633,9 +633,9 @@ class TestAr2DiffusionIntegration:
|
||||
token_ids=[150000],
|
||||
prompt_kwargs={"height": 512, "width": 512, "image_base_size": 1024},
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["height"] == 512
|
||||
assert result[0]["width"] == 512
|
||||
assert result is not None
|
||||
assert result["height"] == 512
|
||||
assert result["width"] == 512
|
||||
|
||||
# -- cache behaviour -----------------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Unit tests for LTX-2.3 forward-parallel behavior."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
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
|
||||
|
||||
pipe = object.__new__(LTX23Pipeline)
|
||||
video_sample = torch.tensor([[[1.0, -2.0]]])
|
||||
audio_sample = torch.tensor([[[0.5, 3.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]]])
|
||||
audio_neg = torch.tensor([[[0.1, 0.4]]])
|
||||
video_sigma = torch.tensor(0.25)
|
||||
audio_sigma = torch.tensor(0.5)
|
||||
scale = 4.0
|
||||
|
||||
video_combined, audio_combined = pipe.combine_cfg_noise(
|
||||
(video_pos, audio_pos),
|
||||
(video_neg, audio_neg),
|
||||
scale,
|
||||
video_latents=video_sample,
|
||||
audio_latents=audio_sample,
|
||||
video_sigma=video_sigma,
|
||||
audio_sigma=audio_sigma,
|
||||
)
|
||||
|
||||
x0_video_cond = video_sample - video_pos * video_sigma
|
||||
x0_video_uncond = video_sample - video_neg * video_sigma
|
||||
x0_video_guided = x0_video_cond + (scale - 1) * (x0_video_cond - x0_video_uncond)
|
||||
expected_video = (video_sample - x0_video_guided) / video_sigma
|
||||
|
||||
x0_audio_cond = audio_sample - audio_pos * audio_sigma
|
||||
x0_audio_uncond = audio_sample - audio_neg * audio_sigma
|
||||
x0_audio_guided = x0_audio_cond + (scale - 1) * (x0_audio_cond - x0_audio_uncond)
|
||||
expected_audio = (audio_sample - x0_audio_guided) / audio_sigma
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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]
|
||||
|
||||
monkeypatch.setattr(ltx23, "get_classifier_free_guidance_world_size", lambda: 2)
|
||||
monkeypatch.setattr(ltx23, "get_cfg_group", lambda: FakeCfgGroup())
|
||||
|
||||
expected_video = ltx23.LTX23Pipeline._combine_x0_space_cfg(
|
||||
video_sample,
|
||||
video_pos,
|
||||
video_neg,
|
||||
video_sigma,
|
||||
scale,
|
||||
)
|
||||
expected_audio = ltx23.LTX23Pipeline._combine_x0_space_cfg(
|
||||
audio_sample,
|
||||
audio_pos,
|
||||
audio_neg,
|
||||
audio_sigma,
|
||||
scale,
|
||||
)
|
||||
|
||||
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__
|
||||
|
||||
|
||||
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.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pipe = object.__new__(ltx23.LTX23Pipeline)
|
||||
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)
|
||||
|
||||
class StopAtEncodePromptError(Exception):
|
||||
pass
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_encode_prompt(**kwargs):
|
||||
captured.update(kwargs)
|
||||
raise StopAtEncodePromptError
|
||||
|
||||
object.__setattr__(pipe, "encode_prompt", fake_encode_prompt)
|
||||
|
||||
prompt_embeds_a = torch.zeros(2, 3)
|
||||
prompt_embeds_b = torch.ones(2, 3)
|
||||
negative_prompt_embeds_a = torch.full((2, 3), 2.0)
|
||||
negative_prompt_embeds_b = torch.full((2, 3), 3.0)
|
||||
prompt_attention_mask_a = torch.tensor([True, True])
|
||||
prompt_attention_mask_b = torch.tensor([True, False])
|
||||
negative_attention_mask_a = torch.tensor([False, True])
|
||||
negative_attention_mask_b = torch.tensor([False, False])
|
||||
|
||||
requests = [
|
||||
OmniDiffusionRequest(
|
||||
prompt={
|
||||
"prompt": "prompt-a",
|
||||
"negative_prompt": "negative-a",
|
||||
"prompt_embeds": prompt_embeds_a,
|
||||
"negative_prompt_embeds": negative_prompt_embeds_a,
|
||||
"prompt_attention_mask": prompt_attention_mask_a,
|
||||
"negative_prompt_attention_mask": negative_attention_mask_a,
|
||||
},
|
||||
sampling_params=OmniDiffusionSamplingParams(
|
||||
height=32,
|
||||
width=32,
|
||||
num_frames=1,
|
||||
frame_rate=1.0,
|
||||
num_inference_steps=2,
|
||||
),
|
||||
request_id="ltx23-prompt-local-a",
|
||||
),
|
||||
OmniDiffusionRequest(
|
||||
prompt={
|
||||
"prompt": "prompt-b",
|
||||
"negative_prompt": "negative-b",
|
||||
"prompt_embeds": prompt_embeds_b,
|
||||
"negative_prompt_embeds": negative_prompt_embeds_b,
|
||||
"attention_mask": prompt_attention_mask_b,
|
||||
"negative_attention_mask": negative_attention_mask_b,
|
||||
},
|
||||
sampling_params=OmniDiffusionSamplingParams(
|
||||
height=32,
|
||||
width=32,
|
||||
num_frames=1,
|
||||
frame_rate=1.0,
|
||||
num_inference_steps=2,
|
||||
),
|
||||
request_id="ltx23-prompt-local-b",
|
||||
),
|
||||
]
|
||||
|
||||
with pytest.raises(StopAtEncodePromptError):
|
||||
pipe.forward(DiffusionRequestBatch(requests=requests))
|
||||
|
||||
assert captured["prompt"] is None
|
||||
assert captured["negative_prompt"] is None
|
||||
torch.testing.assert_close(
|
||||
captured["prompt_embeds"],
|
||||
torch.stack([prompt_embeds_a, prompt_embeds_b], dim=0),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
captured["negative_prompt_embeds"],
|
||||
torch.stack([negative_prompt_embeds_a, negative_prompt_embeds_b], dim=0),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
captured["prompt_attention_mask"],
|
||||
torch.stack([prompt_attention_mask_a, prompt_attention_mask_b], dim=0),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
captured["negative_prompt_attention_mask"],
|
||||
torch.stack([negative_attention_mask_a, negative_attention_mask_b], dim=0),
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("cfg_rank", "expected_prompt_value"), [(0, 1.0), (1, 0.0)])
|
||||
@pytest.mark.parametrize(
|
||||
("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(
|
||||
self,
|
||||
monkeypatch,
|
||||
cfg_rank,
|
||||
expected_prompt_value,
|
||||
frame_rate_input,
|
||||
audio_sampling_rate,
|
||||
expected_frame_rate,
|
||||
):
|
||||
from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2_3 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)
|
||||
torch.nn.Module.__init__(pipe)
|
||||
pipe.device = torch.device("cpu")
|
||||
pipe.tokenizer_max_length = 1
|
||||
pipe.vae_spatial_compression_ratio = 32
|
||||
pipe.vae_temporal_compression_ratio = 1
|
||||
pipe.transformer_spatial_patch_size = 1
|
||||
pipe.transformer_temporal_patch_size = 1
|
||||
pipe.audio_sampling_rate = audio_sampling_rate
|
||||
pipe.audio_hop_length = 1
|
||||
pipe.audio_vae_temporal_compression_ratio = 1
|
||||
pipe.audio_vae_mel_compression_ratio = 1
|
||||
pipe.od_config = SimpleNamespace(parallel_config=SimpleNamespace(sequence_parallel_size=1))
|
||||
pipe.tokenizer = SimpleNamespace(padding_side="left")
|
||||
pipe.vae = SimpleNamespace(
|
||||
latents_mean=torch.zeros(2),
|
||||
latents_std=torch.ones(2),
|
||||
config=SimpleNamespace(scaling_factor=1.0),
|
||||
)
|
||||
pipe.audio_vae = SimpleNamespace(
|
||||
latents_mean=torch.zeros(2),
|
||||
latents_std=torch.ones(2),
|
||||
config=SimpleNamespace(mel_bins=2, latent_channels=1),
|
||||
)
|
||||
|
||||
video_pos = torch.tensor([[[0.2, -0.3]]])
|
||||
video_neg = torch.tensor([[[-0.4, 0.1]]])
|
||||
audio_pos = torch.tensor([[[0.7, -0.2]]])
|
||||
audio_neg = torch.tensor([[[0.1, 0.4]]])
|
||||
|
||||
class FakeCfgGroup:
|
||||
def all_gather(self, tensor, separate_tensors=True):
|
||||
assert separate_tensors
|
||||
if torch.equal(tensor, video_pos) or torch.equal(tensor, video_neg):
|
||||
return [video_pos, video_neg]
|
||||
if torch.equal(tensor, audio_pos) or torch.equal(tensor, audio_neg):
|
||||
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())
|
||||
|
||||
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)
|
||||
|
||||
class FakeScheduler:
|
||||
def __init__(self, name="video", calls=None):
|
||||
self.name = name
|
||||
self.calls = [] if calls is None else calls
|
||||
self.config = {
|
||||
"max_image_seq_len": 4096,
|
||||
"base_image_seq_len": 1024,
|
||||
"base_shift": 0.95,
|
||||
"max_shift": 2.05,
|
||||
}
|
||||
self.sigmas = torch.tensor([0.25, 0.25])
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
return FakeScheduler("audio", self.calls)
|
||||
|
||||
def step(self, noise_pred, t, latents, return_dict=False, generator=None):
|
||||
self.calls.append((self.name, noise_pred.clone(), t.clone(), latents.clone()))
|
||||
return (latents - noise_pred,)
|
||||
|
||||
class FakeConnectors:
|
||||
def to(self, device):
|
||||
return self
|
||||
|
||||
def __call__(self, prompt_embeds, prompt_attention_mask, padding_side):
|
||||
assert padding_side == "left"
|
||||
assert prompt_embeds.shape[0] == 2
|
||||
return prompt_embeds, prompt_embeds, prompt_attention_mask
|
||||
|
||||
rope_video_fps: list[float] = []
|
||||
|
||||
class FakeRope:
|
||||
def prepare_video_coords(self, batch_size, num_frames, height, width, device, fps):
|
||||
rope_video_fps.append(fps)
|
||||
return torch.zeros(batch_size, num_frames * height * width, 3, device=device)
|
||||
|
||||
def prepare_audio_coords(self, batch_size, num_frames, device):
|
||||
return torch.zeros(batch_size, num_frames, 1, device=device)
|
||||
|
||||
class FakeTransformer:
|
||||
def __init__(self):
|
||||
self.config = SimpleNamespace(in_channels=2)
|
||||
self.rope = FakeRope()
|
||||
self.audio_rope = FakeRope()
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
expected_prompt = torch.full((1, 1, 1), expected_prompt_value)
|
||||
torch.testing.assert_close(kwargs["encoder_hidden_states"], expected_prompt)
|
||||
torch.testing.assert_close(kwargs["audio_encoder_hidden_states"], expected_prompt)
|
||||
assert kwargs["hidden_states"].shape == (1, 1, 2)
|
||||
assert kwargs["audio_hidden_states"].shape == (1, 1, 2)
|
||||
if cfg_rank == 0:
|
||||
return video_pos, audio_pos
|
||||
return video_neg, audio_neg
|
||||
|
||||
class DummyProgress:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
pipe.scheduler = FakeScheduler()
|
||||
pipe.connectors = FakeConnectors()
|
||||
pipe.transformer = FakeTransformer()
|
||||
object.__setattr__(pipe, "progress_bar", lambda total: DummyProgress())
|
||||
|
||||
def fake_encode_prompt(**kwargs):
|
||||
return (
|
||||
torch.ones(1, 1, 1),
|
||||
torch.ones(1, 1, dtype=torch.bool),
|
||||
torch.zeros(1, 1, 1),
|
||||
torch.ones(1, 1, dtype=torch.bool),
|
||||
)
|
||||
|
||||
object.__setattr__(pipe, "encode_prompt", fake_encode_prompt)
|
||||
|
||||
video_latents = torch.tensor([[[1.0, -2.0]]])
|
||||
audio_latents = torch.tensor([[[0.5, 3.0]]])
|
||||
req = OmniDiffusionRequest(
|
||||
prompt={"prompt": "prompt", "negative_prompt": "negative"},
|
||||
sampling_params=OmniDiffusionSamplingParams(
|
||||
height=32,
|
||||
width=32,
|
||||
num_frames=1,
|
||||
frame_rate=frame_rate_input,
|
||||
num_inference_steps=2,
|
||||
guidance_scale=4.0,
|
||||
latents=video_latents,
|
||||
audio_latents=audio_latents,
|
||||
output_type="latent",
|
||||
),
|
||||
request_id="ltx23-cfg-parallel-forward-test",
|
||||
)
|
||||
|
||||
output = pipe.forward(DiffusionRequestBatch(requests=[req]))[0]
|
||||
|
||||
expected_video_noise = ltx23.LTX23Pipeline._combine_x0_space_cfg(
|
||||
video_latents,
|
||||
video_pos,
|
||||
video_neg,
|
||||
pipe.scheduler.sigmas[0],
|
||||
4.0,
|
||||
)
|
||||
expected_audio_noise = ltx23.LTX23Pipeline._combine_x0_space_cfg(
|
||||
audio_latents,
|
||||
audio_pos,
|
||||
audio_neg,
|
||||
pipe.scheduler.sigmas[0],
|
||||
4.0,
|
||||
)
|
||||
scheduler_call_names = [call[0] for call in pipe.scheduler.calls]
|
||||
assert scheduler_call_names == ["video", "audio", "video", "audio"]
|
||||
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))
|
||||
|
||||
# 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.
|
||||
assert rope_video_fps
|
||||
assert all(fps == expected_frame_rate for fps in rope_video_fps)
|
||||
@@ -0,0 +1,244 @@
|
||||
# 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
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,282 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Unit tests for LTX-2.3 VAE tiling and distributed decode behavior."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
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
|
||||
|
||||
monkeypatch.setattr(ltx23.torch.distributed, "is_initialized", lambda: False)
|
||||
|
||||
assert ltx23._is_output_rank() is True
|
||||
assert ltx23._should_decode_video_on_rank(SimpleNamespace(is_distributed_enabled=lambda: False)) is True
|
||||
|
||||
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
|
||||
|
||||
monkeypatch.setattr(ltx23.torch.distributed, "is_initialized", lambda: True)
|
||||
monkeypatch.setattr(ltx23.torch.distributed, "get_rank", lambda: 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
|
||||
|
||||
|
||||
class TestLTX23VaeDistributedDecode:
|
||||
"""Test LTX-2.3 distributed VAE helpers without loading weights."""
|
||||
|
||||
def test_ltx23_video_vae_is_distributed_tile_only_class(self):
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import (
|
||||
DistributedAutoencoderKLLTX2Video,
|
||||
)
|
||||
from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import DistributedVaeMixin
|
||||
|
||||
assert issubclass(DistributedAutoencoderKLLTX2Video, DistributedVaeMixin)
|
||||
assert not hasattr(DistributedAutoencoderKLLTX2Video, "patch_split")
|
||||
|
||||
def test_ltx23_vae_executor_gathers_known_tile_shapes_and_returns_empty_on_non_rank0(self):
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import LTX2VaeExecutor
|
||||
from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import (
|
||||
DistributedOperator,
|
||||
GridSpec,
|
||||
TileTask,
|
||||
)
|
||||
|
||||
z = torch.zeros(1, 1, 1, 1, 1)
|
||||
tile_output_shapes = {
|
||||
0: (1, 1, 1, 2, 2),
|
||||
1: (1, 1, 1, 2, 1),
|
||||
2: (1, 1, 1, 1, 2),
|
||||
3: (1, 1, 1, 1, 1),
|
||||
}
|
||||
tasks = [
|
||||
TileTask(0, (0, 0), z, workload=4),
|
||||
TileTask(1, (0, 1), z, workload=2),
|
||||
TileTask(2, (1, 0), z, workload=2),
|
||||
TileTask(3, (1, 1), z, workload=1),
|
||||
]
|
||||
grid_spec = GridSpec(
|
||||
split_dims=(3, 4),
|
||||
grid_shape=(2, 2),
|
||||
tile_spec={
|
||||
"max_tile_output_shape": (1, 1, 1, 2, 2),
|
||||
"tile_output_shapes": tile_output_shapes,
|
||||
},
|
||||
output_dtype=torch.float32,
|
||||
)
|
||||
seen = {}
|
||||
|
||||
def exec_tile(task):
|
||||
return torch.full(tile_output_shapes[task.tile_id], float(task.tile_id + 1))
|
||||
|
||||
def merge_tiles(coord_tensor_map, passed_grid_spec):
|
||||
seen["merged_shapes"] = {coord: tuple(tile.shape) for coord, tile in coord_tensor_map.items()}
|
||||
assert passed_grid_spec is grid_spec
|
||||
return torch.stack(
|
||||
[
|
||||
coord_tensor_map[(0, 0)].flatten()[0],
|
||||
coord_tensor_map[(0, 1)].flatten()[0],
|
||||
coord_tensor_map[(1, 0)].flatten()[0],
|
||||
coord_tensor_map[(1, 1)].flatten()[0],
|
||||
]
|
||||
)
|
||||
|
||||
operator = DistributedOperator(split=lambda _z: (tasks, grid_spec), exec=exec_tile, merge=merge_tiles)
|
||||
|
||||
rank0_executor = object.__new__(LTX2VaeExecutor)
|
||||
rank0_executor.parallel_size = 2
|
||||
rank0_executor.world_size = 2
|
||||
rank0_executor.rank = 0
|
||||
|
||||
def gather_rank0(local_tile_tensor):
|
||||
assigned = rank0_executor._balance_tasks(tasks, 2)
|
||||
rank1_results = [(task.tile_id, exec_tile(task)) for task in assigned[1]]
|
||||
rank1_tile_tensor = rank0_executor._pack_local_tiles_without_meta(
|
||||
rank1_results,
|
||||
list(local_tile_tensor.shape),
|
||||
z.device,
|
||||
torch.float32,
|
||||
)
|
||||
seen["rank0_gather_shape"] = tuple(local_tile_tensor.shape)
|
||||
return [local_tile_tensor, rank1_tile_tensor]
|
||||
|
||||
def fail_final_sync(*_args, **_kwargs):
|
||||
raise AssertionError("broadcast_result=False should not sync the final result")
|
||||
|
||||
rank0_executor.gather_tensors = gather_rank0
|
||||
rank0_executor._sync_final_result = fail_final_sync
|
||||
|
||||
rank0_result = rank0_executor.execute(z, operator, broadcast_result=False)
|
||||
|
||||
torch.testing.assert_close(rank0_result, torch.tensor([1.0, 2.0, 3.0, 4.0]))
|
||||
assert seen["rank0_gather_shape"] == (2, 1, 1, 1, 2, 2)
|
||||
assert seen["merged_shapes"] == {
|
||||
(0, 0): (1, 1, 1, 2, 2),
|
||||
(0, 1): (1, 1, 1, 2, 1),
|
||||
(1, 0): (1, 1, 1, 1, 2),
|
||||
(1, 1): (1, 1, 1, 1, 1),
|
||||
}
|
||||
|
||||
non_rank0_executor = object.__new__(LTX2VaeExecutor)
|
||||
non_rank0_executor.parallel_size = 2
|
||||
non_rank0_executor.world_size = 2
|
||||
non_rank0_executor.rank = 1
|
||||
|
||||
def gather_rank1(local_tile_tensor):
|
||||
seen["rank1_gather_shape"] = tuple(local_tile_tensor.shape)
|
||||
return None
|
||||
|
||||
def fail_non_rank0_merge(*_args, **_kwargs):
|
||||
raise AssertionError("non-rank0 should not merge gathered tiles")
|
||||
|
||||
non_rank0_executor.gather_tensors = gather_rank1
|
||||
non_rank0_executor._sync_final_result = fail_final_sync
|
||||
|
||||
empty_result = non_rank0_executor.execute(
|
||||
z,
|
||||
DistributedOperator(
|
||||
split=lambda _z: (tasks, grid_spec),
|
||||
exec=exec_tile,
|
||||
merge=fail_non_rank0_merge,
|
||||
),
|
||||
broadcast_result=False,
|
||||
)
|
||||
|
||||
assert tuple(empty_result.shape) == (0,)
|
||||
assert seen["rank1_gather_shape"] == (2, 1, 1, 1, 2, 2)
|
||||
|
||||
|
||||
class TestLTX23VaeTiling:
|
||||
"""Test LTX-2.3 video VAE tile helpers without loading weights."""
|
||||
|
||||
def test_ltx23_video_vae_tile_split_uses_native_ltx23_tile_geometry(self):
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import (
|
||||
DistributedAutoencoderKLLTX2Video,
|
||||
)
|
||||
|
||||
vae = SimpleNamespace(
|
||||
spatial_compression_ratio=32,
|
||||
tile_sample_min_height=512,
|
||||
tile_sample_min_width=512,
|
||||
tile_sample_stride_height=448,
|
||||
tile_sample_stride_width=448,
|
||||
temporal_compression_ratio=8,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
z = torch.zeros(1, 2, 5, 16, 24)
|
||||
tasks, grid_spec = DistributedAutoencoderKLLTX2Video.tile_split(vae, z)
|
||||
|
||||
assert grid_spec.grid_shape == (2, 2)
|
||||
assert grid_spec.split_dims == (3, 4)
|
||||
assert grid_spec.tile_spec["sample_height"] == 512
|
||||
assert grid_spec.tile_spec["sample_width"] == 768
|
||||
assert grid_spec.tile_spec["blend_height"] == 64
|
||||
assert grid_spec.tile_spec["blend_width"] == 64
|
||||
assert grid_spec.tile_spec["max_tile_output_shape"] == (1, 3, 33, 512, 512)
|
||||
assert grid_spec.tile_spec["tile_output_shapes"] == {
|
||||
0: (1, 3, 33, 512, 512),
|
||||
1: (1, 3, 33, 512, 320),
|
||||
2: (1, 3, 33, 64, 512),
|
||||
3: (1, 3, 33, 64, 320),
|
||||
}
|
||||
assert [task.grid_coord for task in tasks] == [(0, 0), (0, 1), (1, 0), (1, 1)]
|
||||
assert [tuple(task.tensor.shape) for task in tasks] == [
|
||||
(1, 2, 5, 16, 16),
|
||||
(1, 2, 5, 16, 10),
|
||||
(1, 2, 5, 2, 16),
|
||||
(1, 2, 5, 2, 10),
|
||||
]
|
||||
assert [task.workload for task in tasks] == [5 * 16 * 16, 5 * 16 * 10, 5 * 2 * 16, 5 * 2 * 10]
|
||||
|
||||
def test_ltx23_video_vae_tile_merge_blends_and_crops_like_tiled_decode(self):
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import (
|
||||
DistributedAutoencoderKLLTX2Video,
|
||||
)
|
||||
from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import GridSpec
|
||||
|
||||
class FakeVae:
|
||||
def __init__(self):
|
||||
self.blend_calls = []
|
||||
|
||||
def clear_cache(self):
|
||||
pass
|
||||
|
||||
def blend_v(self, _previous, current, blend_height):
|
||||
self.blend_calls.append(("v", blend_height))
|
||||
return current
|
||||
|
||||
def blend_h(self, _previous, current, blend_width):
|
||||
self.blend_calls.append(("h", blend_width))
|
||||
return current
|
||||
|
||||
fake_vae = FakeVae()
|
||||
grid_spec = GridSpec(
|
||||
split_dims=(3, 4),
|
||||
grid_shape=(2, 2),
|
||||
tile_spec={
|
||||
"sample_height": 10,
|
||||
"sample_width": 10,
|
||||
"blend_height": 1,
|
||||
"blend_width": 2,
|
||||
"tile_sample_stride_height": 5,
|
||||
"tile_sample_stride_width": 5,
|
||||
},
|
||||
)
|
||||
tiles = {
|
||||
(0, 0): torch.full((1, 3, 2, 6, 6), 1.0),
|
||||
(0, 1): torch.full((1, 3, 2, 6, 6), 2.0),
|
||||
(1, 0): torch.full((1, 3, 2, 6, 6), 3.0),
|
||||
(1, 1): torch.full((1, 3, 2, 6, 6), 4.0),
|
||||
}
|
||||
|
||||
merged = DistributedAutoencoderKLLTX2Video.tile_merge(fake_vae, tiles, grid_spec)
|
||||
|
||||
assert merged.shape == (1, 3, 2, 10, 10)
|
||||
assert fake_vae.blend_calls == [("h", 2), ("v", 1), ("v", 1), ("h", 2)]
|
||||
torch.testing.assert_close(merged[:, :, :, :5, :5], torch.ones(1, 3, 2, 5, 5))
|
||||
torch.testing.assert_close(merged[:, :, :, :5, 5:], torch.full((1, 3, 2, 5, 5), 2.0))
|
||||
torch.testing.assert_close(merged[:, :, :, 5:, :5], torch.full((1, 3, 2, 5, 5), 3.0))
|
||||
torch.testing.assert_close(merged[:, :, :, 5:, 5:], torch.full((1, 3, 2, 5, 5), 4.0))
|
||||
|
||||
def test_ltx23_video_vae_tiled_decode_dispatches_to_tile_operator(self):
|
||||
from vllm_omni.diffusion.distributed.autoencoders import autoencoder_kl_ltx2
|
||||
|
||||
z = torch.zeros(1, 2, 1, 16, 24)
|
||||
expected = torch.ones(1, 3, 1, 512, 768)
|
||||
seen = {}
|
||||
|
||||
class FakeExecutor:
|
||||
def execute(self, tensor, operator, broadcast_result=True):
|
||||
seen["tensor"] = tensor
|
||||
seen["operator"] = operator
|
||||
seen["broadcast_result"] = broadcast_result
|
||||
return expected
|
||||
|
||||
vae = SimpleNamespace(distributed_executor=FakeExecutor(), is_distributed_enabled=lambda: True)
|
||||
vae.tile_split = autoencoder_kl_ltx2.DistributedAutoencoderKLLTX2Video.tile_split.__get__(vae)
|
||||
vae.tile_exec = autoencoder_kl_ltx2.DistributedAutoencoderKLLTX2Video.tile_exec.__get__(vae)
|
||||
vae.tile_merge = autoencoder_kl_ltx2.DistributedAutoencoderKLLTX2Video.tile_merge.__get__(vae)
|
||||
|
||||
output = autoencoder_kl_ltx2.DistributedAutoencoderKLLTX2Video.tiled_decode(
|
||||
vae,
|
||||
z,
|
||||
temb=torch.tensor(0.5),
|
||||
return_dict=False,
|
||||
)
|
||||
|
||||
assert len(output) == 1
|
||||
assert output[0] is expected
|
||||
assert seen["tensor"] is z
|
||||
assert seen["broadcast_result"] is False
|
||||
assert seen["operator"].split.__name__ == "tile_split"
|
||||
assert seen["operator"].merge.__name__ == "tile_merge"
|
||||
@@ -1,4 +1,3 @@
|
||||
import inspect
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -193,6 +192,7 @@ def _make_request_batch_prompt_sampling(**overrides):
|
||||
"true_cfg_scale": None,
|
||||
"guidance_scale_provided": False,
|
||||
"guidance_scale": 1.0,
|
||||
"output_type": None,
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
@@ -344,16 +344,3 @@ def test_qwen_edit_validator_excludes_image_placeholders_from_budget(pipeline_cl
|
||||
|
||||
with pytest.raises(AssertionError, match="text encoder should not run"):
|
||||
pipeline.encode_prompt(prompt="short prompt")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pipeline_class",
|
||||
[
|
||||
QwenImagePipeline,
|
||||
QwenImageLayeredPipeline,
|
||||
QwenImageEditPipeline,
|
||||
QwenImageEditPlusPipeline,
|
||||
],
|
||||
)
|
||||
def test_forward_max_sequence_length_default_is_1024(pipeline_class: type):
|
||||
assert inspect.signature(pipeline_class.forward).parameters["max_sequence_length"].default == 1024
|
||||
@@ -21,6 +21,7 @@ def _make_sd3_sampling(**overrides):
|
||||
"generator": None,
|
||||
"latents": None,
|
||||
"guidance_scale": 4.0,
|
||||
"output_type": None,
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
@@ -20,6 +20,12 @@ class _StubTransformer(nn.Module):
|
||||
return torch.float32
|
||||
|
||||
|
||||
class _StubTextEncoder(nn.Module):
|
||||
@property
|
||||
def dtype(self) -> torch.dtype:
|
||||
return torch.float32
|
||||
|
||||
|
||||
class _StubScheduler:
|
||||
def __init__(self, timesteps: list[int]) -> None:
|
||||
self.timesteps = torch.tensor(timesteps, dtype=torch.int64)
|
||||
@@ -41,12 +47,30 @@ def _noop_progress_bar(*args, **kwargs):
|
||||
yield _Bar()
|
||||
|
||||
|
||||
def _stub_encode_prompt(
|
||||
prompt,
|
||||
negative_prompt=None,
|
||||
do_classifier_free_guidance=True,
|
||||
num_videos_per_prompt=1,
|
||||
max_sequence_length=512,
|
||||
device=None,
|
||||
dtype=None,
|
||||
):
|
||||
del negative_prompt, do_classifier_free_guidance, device, dtype
|
||||
batch_size = 1 if isinstance(prompt, str) else len(prompt)
|
||||
n = batch_size * num_videos_per_prompt
|
||||
hidden_size = 8
|
||||
prompt_embeds = torch.zeros(n, max_sequence_length, hidden_size)
|
||||
return prompt_embeds, None
|
||||
|
||||
|
||||
def _make_pipeline() -> Wan22Pipeline:
|
||||
pipeline = object.__new__(Wan22Pipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.transformer = _StubTransformer()
|
||||
pipeline.transformer_2 = None
|
||||
pipeline.text_encoder = _StubTextEncoder()
|
||||
pipeline.transformer_config = SimpleNamespace(patch_size=(1, 2, 2), in_channels=4, out_channels=4)
|
||||
pipeline.scheduler = _StubScheduler([9, 5])
|
||||
pipeline.od_config = SimpleNamespace(flow_shift=5.0)
|
||||
@@ -61,15 +85,14 @@ def _make_pipeline() -> Wan22Pipeline:
|
||||
pipeline._num_timesteps = None
|
||||
pipeline._current_timestep = None
|
||||
pipeline.check_inputs = lambda **kwargs: None
|
||||
pipeline.encode_prompt = _stub_encode_prompt # type: ignore[method-assign]
|
||||
pipeline.prepare_latents = lambda **kwargs: torch.zeros((1, 4, 1, 8, 8), dtype=torch.float32)
|
||||
pipeline.progress_bar = _noop_progress_bar
|
||||
return pipeline
|
||||
|
||||
|
||||
def test_forward_delegates_denoising_to_diffuse(monkeypatch) -> None:
|
||||
def test_forward_delegates_denoising_to_diffuse() -> None:
|
||||
pipeline = _make_pipeline()
|
||||
|
||||
prompt_embeds = torch.randn(1, 8)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _fake_diffuse(**kwargs):
|
||||
@@ -86,8 +109,8 @@ def test_forward_delegates_denoising_to_diffuse(monkeypatch) -> None:
|
||||
width=None,
|
||||
num_frames=1,
|
||||
num_inference_steps=2,
|
||||
guidance_scale_provided=False,
|
||||
guidance_scale=None,
|
||||
guidance_scale_provided=True,
|
||||
guidance_scale=1.0,
|
||||
guidance_scale_2=None,
|
||||
boundary_ratio=None,
|
||||
generator=None,
|
||||
@@ -95,14 +118,16 @@ def test_forward_delegates_denoising_to_diffuse(monkeypatch) -> None:
|
||||
num_outputs_per_prompt=1,
|
||||
max_sequence_length=32,
|
||||
latents=None,
|
||||
output_type="latent",
|
||||
extra_args={},
|
||||
),
|
||||
)
|
||||
batch = DiffusionRequestBatch(requests=[mock_req])
|
||||
|
||||
output = pipeline.forward(batch, prompt_embeds=prompt_embeds, output_type="latent", guidance_scale=1.0)
|
||||
output = pipeline.forward(batch)
|
||||
|
||||
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
|
||||
|
||||
@@ -537,3 +537,118 @@ def test_single_scale_row_parallel_tp2_loader_simulation():
|
||||
|
||||
for rank in range(_TP2):
|
||||
assert _shard(ckpt_weight_scale, layer.weight_scale, rank, _TP2, "input_dim").shape == layer.weight_scale.shape
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ROCm MXFP4 (gfx950) — get_quant_method dispatch
|
||||
#
|
||||
# These run on CPU: the platform check, gcnArchName probe and aiter op
|
||||
# registration are all mocked (stdlib unittest.mock + monkeypatch, so no
|
||||
# pytest-mock dependency). They cover only the dispatch + weight-allocation
|
||||
# logic added by the ROCm PR — the AITER GEMM / quant kernels require real
|
||||
# gfx950 hardware and are intentionally NOT exercised here.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _rocm_platform(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Make current_omni_platform report ROCm, and stub the aiter
|
||||
custom-op registration so ROCmMxfp4*Method can be constructed without aiter."""
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
from vllm_omni.quantization import mxfp4_config
|
||||
|
||||
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: False)
|
||||
monkeypatch.setattr(current_omni_platform, "is_rocm", lambda: True)
|
||||
monkeypatch.setattr(mxfp4_config, "_register_rocm_mxfp4_op", lambda: None)
|
||||
|
||||
|
||||
def _patch_gcn_arch(monkeypatch: pytest.MonkeyPatch, arch: str) -> None:
|
||||
"""Patch torch.cuda.get_device_properties(...).gcnArchName to return `arch`."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
monkeypatch.setattr(torch.accelerator, "current_device_index", lambda: 0)
|
||||
monkeypatch.setattr(
|
||||
torch.cuda,
|
||||
"get_device_properties",
|
||||
lambda *a, **k: SimpleNamespace(gcnArchName=arch),
|
||||
)
|
||||
|
||||
|
||||
def _fake_linear_layer():
|
||||
"""A stand-in that passes isinstance(layer, LinearBase) without a real layer."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from vllm.model_executor.layers.linear import LinearBase
|
||||
|
||||
return MagicMock(spec=LinearBase)
|
||||
|
||||
|
||||
def test_rocm_online_dispatch_returns_rocm_method(_rocm_platform, monkeypatch):
|
||||
"""ROCm + gfx950 + online checkpoint must return ROCmMxfp4OnlineLinearMethod."""
|
||||
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config, ROCmMxfp4OnlineLinearMethod
|
||||
|
||||
_patch_gcn_arch(monkeypatch, "gfx950:sramecc+:xnack-")
|
||||
cfg = DiffusionMXFP4Config(is_checkpoint_mxfp4_serialized=False)
|
||||
|
||||
method = cfg.get_quant_method(_fake_linear_layer(), "blocks.0.attn1.to_q")
|
||||
assert isinstance(method, ROCmMxfp4OnlineLinearMethod)
|
||||
|
||||
|
||||
def test_rocm_ignored_layer_returns_unquantized(_rocm_platform, monkeypatch):
|
||||
"""A prefix in ignored_layers must return UnquantizedLinearMethod before the gfx950 probe."""
|
||||
from vllm.model_executor.layers.linear import UnquantizedLinearMethod
|
||||
|
||||
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
|
||||
|
||||
_patch_gcn_arch(monkeypatch, "gfx950:sramecc+:xnack-")
|
||||
cfg = DiffusionMXFP4Config(is_checkpoint_mxfp4_serialized=False, ignored_layers=["proj_out"])
|
||||
|
||||
assert isinstance(cfg.get_quant_method(_fake_linear_layer(), "proj_out"), UnquantizedLinearMethod)
|
||||
|
||||
|
||||
def test_rocm_non_gfx950_raises(_rocm_platform, monkeypatch):
|
||||
"""MXFP4 on ROCm requires gfx950; any other arch must raise."""
|
||||
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
|
||||
|
||||
_patch_gcn_arch(monkeypatch, "gfx942:sramecc+:xnack-")
|
||||
cfg = DiffusionMXFP4Config(is_checkpoint_mxfp4_serialized=False)
|
||||
|
||||
with pytest.raises(NotImplementedError, match="gfx950"):
|
||||
cfg.get_quant_method(_fake_linear_layer(), "blocks.0.attn1.to_q")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ROCm MXFP4 — create_weights (online lazy meta-device placeholder)
|
||||
#
|
||||
# The base ROCmMxfp4LinearMethod is abstract (no create_weights); the online
|
||||
# subclass gets create_weights from _LazyWeightMixin, which registers a BF16
|
||||
# weight on the meta device to be materialised at load time.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rocm_create_weights_column_parallel_tp2(_rocm_platform):
|
||||
"""Column-parallel TP=2: meta BF16 weight has the output halved, input full."""
|
||||
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config, ROCmMxfp4OnlineLinearMethod
|
||||
|
||||
method = ROCmMxfp4OnlineLinearMethod(DiffusionMXFP4Config())
|
||||
layer = _create_weights(method, input_size_per_partition=_TP2_K, output_partition_sizes=[_TP2_N // _TP2])
|
||||
|
||||
assert layer.weight.shape == (_TP2_N // _TP2, _TP2_K)
|
||||
assert layer.weight.dtype == torch.bfloat16
|
||||
assert layer.weight.device.type == "meta"
|
||||
assert layer.weight.input_dim == 1
|
||||
assert layer.weight.output_dim == 0
|
||||
assert layer.logical_widths == [_TP2_N // _TP2]
|
||||
assert layer.weight_block_size is None
|
||||
|
||||
|
||||
def test_rocm_create_weights_row_parallel_tp2(_rocm_platform):
|
||||
"""Row-parallel TP=2: meta BF16 weight has the input halved, output full."""
|
||||
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config, ROCmMxfp4OnlineLinearMethod
|
||||
|
||||
method = ROCmMxfp4OnlineLinearMethod(DiffusionMXFP4Config())
|
||||
layer = _create_weights(method, input_size_per_partition=_TP2_K // _TP2, output_partition_sizes=[_TP2_N])
|
||||
|
||||
assert layer.weight.shape == (_TP2_N, _TP2_K // _TP2)
|
||||
assert layer.input_size_per_partition == _TP2_K // _TP2
|
||||
assert layer.output_size_per_partition == _TP2_N
|
||||
@@ -30,6 +30,9 @@ from vllm_omni.diffusion.ipc import (
|
||||
pack_diffusion_output_shm,
|
||||
unpack_diffusion_output_shm,
|
||||
)
|
||||
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import (
|
||||
DiffusionPipelineProfilerMixin,
|
||||
)
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.sched import StepScheduler
|
||||
from vllm_omni.diffusion.sched.interface import (
|
||||
@@ -97,6 +100,51 @@ class _StepPipeline:
|
||||
return DiffusionOutput(output=torch.tensor([state.step_index], dtype=torch.float32))
|
||||
|
||||
|
||||
class _ProfilingStepPipeline(_StepPipeline):
|
||||
enable_diffusion_pipeline_profiler = True
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._stage_durations: dict[str, float] = {}
|
||||
|
||||
@property
|
||||
def stage_durations(self) -> dict[str, float]:
|
||||
return dict(self._stage_durations)
|
||||
|
||||
def clear_profiler_records(self) -> None:
|
||||
self._stage_durations.clear()
|
||||
|
||||
def prepare_encode(self, state, **kwargs):
|
||||
result = super().prepare_encode(state, **kwargs)
|
||||
self._stage_durations["QwenImagePipeline.text_encoder.forward"] = 1.0
|
||||
return result
|
||||
|
||||
def denoise_step(self, input_batch, **kwargs):
|
||||
result = super().denoise_step(input_batch, **kwargs)
|
||||
self._stage_durations["QwenImagePipeline.diffuse"] = 2.0
|
||||
return result
|
||||
|
||||
def post_decode(self, state, **kwargs):
|
||||
result = super().post_decode(state, **kwargs)
|
||||
self._stage_durations["QwenImagePipeline.vae.decode"] = 3.0
|
||||
return result
|
||||
|
||||
|
||||
class _AutoDenoiseProfilerPipeline(DiffusionPipelineProfilerMixin):
|
||||
_PROFILER_TARGETS: list[str] = []
|
||||
|
||||
def __init__(self):
|
||||
self.setup_diffusion_pipeline_profiler(
|
||||
enable_diffusion_pipeline_profiler=True,
|
||||
)
|
||||
|
||||
def forward(self):
|
||||
return None
|
||||
|
||||
def denoise_step(self):
|
||||
return "ok"
|
||||
|
||||
|
||||
class _InterruptingStepPipeline(_StepPipeline):
|
||||
interrupt = True
|
||||
|
||||
@@ -130,6 +178,9 @@ class _FakePeakMemoryPlatform:
|
||||
index = min(self.reset_calls - 1, len(self._reserved_mb) - 1)
|
||||
return int((self._reserved_mb[index] - 100) * 1024**2)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _IdentityNoiseTransformer(torch.nn.Module):
|
||||
def forward(self, x: torch.Tensor, **kwargs):
|
||||
@@ -437,6 +488,16 @@ def test_input_batch_cached_repack_keeps_static_prompt_fields_for_same_compositi
|
||||
torch.testing.assert_close(repacked.prompt_embeds, torch.ones(1, 2, 3))
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_step_profiler_reports_denoise_step_as_diffuse():
|
||||
pipeline = _AutoDenoiseProfilerPipeline()
|
||||
|
||||
assert pipeline.denoise_step() == "ok"
|
||||
|
||||
assert any(key.endswith(".diffuse") for key in pipeline.stage_durations)
|
||||
assert not any(key.endswith(".denoise_step") for key in pipeline.stage_durations)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
class TestRunner:
|
||||
"""DiffusionModelRunner.execute_stepwise"""
|
||||
@@ -469,6 +530,53 @@ class TestRunner:
|
||||
assert runner.pipeline.scheduler_calls == 2
|
||||
assert runner.pipeline.decode_calls == 1
|
||||
|
||||
def test_stepwise_output_includes_stage_and_peak_metrics(self, monkeypatch):
|
||||
runner = _make_runner()
|
||||
runner.pipeline = _ProfilingStepPipeline()
|
||||
req = _make_step_request()
|
||||
reset_calls = []
|
||||
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
|
||||
monkeypatch.setattr(
|
||||
model_runner_module.current_omni_platform,
|
||||
"is_available",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_runner_module.current_omni_platform,
|
||||
"reset_peak_memory_stats",
|
||||
lambda: reset_calls.append(True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_runner_module.current_omni_platform,
|
||||
"max_memory_reserved",
|
||||
lambda: 2 * 1024**2,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_runner_module.current_omni_platform,
|
||||
"max_memory_allocated",
|
||||
lambda: 1024**2,
|
||||
)
|
||||
|
||||
DiffusionModelRunner.execute_stepwise(
|
||||
runner,
|
||||
_make_scheduler_output(req, step_id=0),
|
||||
)
|
||||
result = DiffusionModelRunner.execute_stepwise(
|
||||
runner,
|
||||
_make_cached_scheduler_output(step_id=1),
|
||||
)
|
||||
|
||||
output = result.get_request_output("req-1")
|
||||
assert output.finished is True
|
||||
assert output.result is not None
|
||||
assert output.result.peak_memory_mb == 2
|
||||
assert output.result.stage_durations == {
|
||||
"QwenImagePipeline.text_encoder.forward": 1.0,
|
||||
"QwenImagePipeline.diffuse": 4.0,
|
||||
"QwenImagePipeline.vae.decode": 3.0,
|
||||
}
|
||||
assert reset_calls == [True]
|
||||
|
||||
def test_carries_peak_memory_across_stepwise_request_lifecycle(self, monkeypatch):
|
||||
runner = _make_runner()
|
||||
req = _make_step_request()
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
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
|
||||
@@ -46,14 +45,11 @@ def test_dreamzero_pipeline_state_lru_caps_retained_sessions() -> None:
|
||||
assert "session-b" not in pipeline._states
|
||||
|
||||
|
||||
def test_dreamzero_state_cache_access_requires_initialization() -> None:
|
||||
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()
|
||||
|
||||
with pytest.raises(RuntimeError, match="KV caches not initialized"):
|
||||
state.get_kv_caches()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Cross-attn caches not initialized"):
|
||||
state.get_crossattn_caches()
|
||||
|
||||
with pytest.raises(RuntimeError, match="create_kv_caches first"):
|
||||
state.update_kv_cache(0, torch.empty(0))
|
||||
for removed in ("get_kv_caches", "create_kv_caches", "update_kv_cache", "get_crossattn_caches"):
|
||||
assert not hasattr(state, removed)
|
||||
@@ -29,7 +29,7 @@ HEIGHT = 512
|
||||
NUM_INFERENCE_STEPS = 20
|
||||
TRUE_CFG_SCALE = 4.0
|
||||
SEED = 42
|
||||
SSIM_THRESHOLD = 0.97
|
||||
SSIM_THRESHOLD = 0.94
|
||||
PSNR_THRESHOLD = 30.0
|
||||
|
||||
MODEL_2512_ID = "Qwen/Qwen-Image-2512"
|
||||
|
||||
@@ -7,7 +7,7 @@ and are supported by Qwen-Image-Layered model.
|
||||
Kept cases (maximum feature coverage, no cpu-offload):
|
||||
sp_001 : cache_dit + Ulysses-SP 2 (2×H100)
|
||||
cfg_parallel_002 : cache_dit + CFG-Parallel 2 (2×H100)
|
||||
layers_guard_001 : layerwise offload + layers=3 (1×H100, issue #1969 guard)
|
||||
layers_guard_001 : layerwise offload + layers=2 (1×H100, issue #1969 guard)
|
||||
|
||||
Total distinct features covered: cache_dit, Ulysses-SP, CFG-Parallel, layerwise-offload.
|
||||
"""
|
||||
@@ -116,8 +116,8 @@ LAYERS_GUARD_CASES = [
|
||||
model=MODEL,
|
||||
server_args=["--enable-layerwise-offload"],
|
||||
),
|
||||
3,
|
||||
id="layers_guard_001_layers3",
|
||||
2,
|
||||
id="layers_guard_001_layers2",
|
||||
marks=SINGLE_CARD_FEATURE_MARKS,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -74,7 +74,6 @@ def test_voice_clone_streaming_001(omni_server, openai_client) -> None:
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
@pytest.mark.skip(reason="#4335")
|
||||
def test_response_format_001(omni_server, openai_client) -> None:
|
||||
"""
|
||||
Test voice-clone non-stream PCM output via OpenAI API.
|
||||
@@ -93,5 +92,6 @@ def test_response_format_001(omni_server, openai_client) -> None:
|
||||
"voice": "default",
|
||||
"ref_audio": REF_AUDIO_URL,
|
||||
"min_audio_bytes": _MIN_AUDIO_BYTES,
|
||||
"min_hnr_db": -2.0,
|
||||
}
|
||||
openai_client.send_audio_speech_request(request_config)
|
||||
@@ -1244,7 +1244,7 @@ def test_parameter_validation():
|
||||
|
||||
# Invalid layers for layered models (must stay within the backend-supported range)
|
||||
with pytest.raises(ValueError):
|
||||
ImageGenerationRequest(prompt="test", layers=2)
|
||||
ImageGenerationRequest(prompt="test", layers=1)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ImageGenerationRequest(prompt="test", layers=11)
|
||||
@@ -1663,13 +1663,13 @@ def test_image_edit_invalid_layers(async_omni_test_client):
|
||||
files=[("image", img_bytes)],
|
||||
data={
|
||||
"prompt": "test",
|
||||
"layers": 2,
|
||||
"layers": 1,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
detail = response.json()["detail"]
|
||||
assert "Invalid layers" in detail
|
||||
assert "layers must be between 3 and 10 inclusive" in detail
|
||||
assert "layers must be between 2 and 10 inclusive" in detail
|
||||
|
||||
# Test layers above the supported range
|
||||
response = async_omni_test_client.post(
|
||||
@@ -1683,7 +1683,7 @@ def test_image_edit_invalid_layers(async_omni_test_client):
|
||||
assert response.status_code == 400
|
||||
detail = response.json()["detail"]
|
||||
assert "Invalid layers" in detail
|
||||
assert "layers must be between 3 and 10 inclusive" in detail
|
||||
assert "layers must be between 2 and 10 inclusive" in detail
|
||||
|
||||
|
||||
def test_image_edit_resolution_and_size_conflict(async_omni_test_client):
|
||||
|
||||
@@ -39,6 +39,7 @@ from vllm_omni.entrypoints.openai.serving_speech import (
|
||||
OmniOpenAIServingSpeech,
|
||||
_create_wav_header,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import PreparedRequest
|
||||
from vllm_omni.model_executor.models.fish_speech.prompt_utils import (
|
||||
FISH_TEXT_ONLY_SYSTEM_PROMPT,
|
||||
build_fish_voice_clone_prompt_ids,
|
||||
@@ -2530,7 +2531,7 @@ def test_api_server_delete_voice_exception_returns_500(mocker: MockerFixture):
|
||||
def test_api_server_create_speech_without_handler_returns_404(mocker: MockerFixture):
|
||||
fake_base = _patch_api_server_base(mocker)
|
||||
raw_request = _make_api_server_request(None, path="/v1/audio/speech")
|
||||
raw_request.app.state.openai_serving_tokenization = fake_base
|
||||
raw_request.app.state.serving_tokenization = fake_base
|
||||
request = OpenAICreateSpeechRequest(input="Hello")
|
||||
|
||||
response = asyncio.run(api_server_module.create_speech(request, raw_request))
|
||||
@@ -2543,7 +2544,7 @@ def test_api_server_create_speech_without_handler_returns_404(mocker: MockerFixt
|
||||
def test_api_server_create_speech_batch_without_handler_returns_404(mocker: MockerFixture):
|
||||
fake_base = _patch_api_server_base(mocker)
|
||||
raw_request = _make_api_server_request(None, path="/v1/audio/speech/batch")
|
||||
raw_request.app.state.openai_serving_tokenization = fake_base
|
||||
raw_request.app.state.serving_tokenization = fake_base
|
||||
request = BatchSpeechRequest(items=[SpeechBatchItem(input="hi")])
|
||||
|
||||
response = asyncio.run(api_server_module.create_speech_batch(request, raw_request))
|
||||
@@ -2612,7 +2613,7 @@ def test_api_server_create_audio_generate_without_handler_returns_404(mocker: Mo
|
||||
fake_base = _patch_api_server_base(mocker)
|
||||
raw_request = _make_api_server_request(None, path="/v1/audio/generate")
|
||||
raw_request.app.state.openai_serving_audio_generate = None
|
||||
raw_request.app.state.openai_serving_tokenization = fake_base
|
||||
raw_request.app.state.serving_tokenization = fake_base
|
||||
request = OpenAICreateAudioGenerateRequest(input="a bird singing")
|
||||
|
||||
response = asyncio.run(api_server_module.create_audio_generate(request, raw_request))
|
||||
@@ -3343,6 +3344,61 @@ class TestTTSAsyncOffloading:
|
||||
qwen3_tts_server._build_tts_params.assert_called_once()
|
||||
qwen3_tts_server._estimate_prompt_len_async.assert_awaited_once()
|
||||
|
||||
def test_prepare_speech_generation_qwen3_default_seed_sets_tts_local_seed(
|
||||
self, qwen3_tts_server, mocker: MockerFixture
|
||||
):
|
||||
"""Deploy default seed should seed Qwen3 TTS residual MTP sampling."""
|
||||
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._build_tts_params = mocker.MagicMock(
|
||||
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
|
||||
)
|
||||
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
|
||||
request = OpenAICreateSpeechRequest(input="hello")
|
||||
|
||||
asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
|
||||
|
||||
stage0_params = qwen3_tts_server.engine_client.generate.call_args.kwargs["sampling_params_list"][0]
|
||||
assert stage0_params.seed == 42
|
||||
assert stage0_params.extra_args["tts_local_seed"] == 42
|
||||
assert qwen3_tts_server.engine_client.default_sampling_params_list[0].extra_args is None
|
||||
|
||||
def test_prepare_speech_generation_uses_adapter_model_type_label(
|
||||
self,
|
||||
voxtral_server,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
"""Adapter model_type should replace the legacy _tts_model_type label ladder."""
|
||||
legacy_tts_model_type = "dummy_tts"
|
||||
adapter_model_type = "adapter_dummy_tts"
|
||||
|
||||
class FakeAdapter:
|
||||
def validate(self, request):
|
||||
return None
|
||||
|
||||
async def build(self, request, sampling_params_list, has_inline_ref_audio):
|
||||
return PreparedRequest(
|
||||
prompt={"prompt": request.input},
|
||||
tts_params={},
|
||||
model_type=adapter_model_type,
|
||||
)
|
||||
|
||||
voxtral_server._tts_model_type = legacy_tts_model_type
|
||||
mocker.patch.object(voxtral_server, "_get_tts_adapter", return_value=FakeAdapter())
|
||||
log_info = mocker.patch("vllm_omni.entrypoints.openai.serving_speech.logger.info")
|
||||
|
||||
asyncio.run(voxtral_server._prepare_speech_generation(OpenAICreateSpeechRequest(input="hello")))
|
||||
|
||||
assert adapter_model_type != legacy_tts_model_type
|
||||
assert any(
|
||||
call.args
|
||||
and call.args[0] == "TTS speech request %s: text=%r, model=%s"
|
||||
and call.args[3] == adapter_model_type
|
||||
for call in log_info.call_args_list
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -3394,6 +3450,48 @@ class TestTTSAsyncOffloading:
|
||||
|
||||
mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, True)
|
||||
|
||||
def test_prepare_speech_generation_no_async_chunk_stream_uses_final_only(
|
||||
self, qwen3_tts_server, mocker: MockerFixture
|
||||
):
|
||||
"""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._build_tts_params = mocker.MagicMock(
|
||||
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
|
||||
)
|
||||
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
|
||||
mock_coerce = mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
|
||||
return_value=qwen3_tts_server.engine_client.default_sampling_params_list,
|
||||
)
|
||||
request = OpenAICreateSpeechRequest(input="hello", stream_format="audio", response_format="pcm")
|
||||
|
||||
asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
|
||||
|
||||
mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, False)
|
||||
|
||||
def test_prepare_speech_generation_no_async_chunk_stream_keeps_delta_for_non_qwen3(
|
||||
self, voxtral_server, mocker: MockerFixture
|
||||
):
|
||||
"""FINAL_ONLY streaming for async_chunk=False is scoped to qwen3_tts only."""
|
||||
voxtral_server.engine_client.model_config.async_chunk = False
|
||||
mocker.patch.object(voxtral_server._get_tts_adapter(), "validate", return_value=None)
|
||||
voxtral_server._build_voxtral_prompt_async = mocker.AsyncMock(
|
||||
return_value={
|
||||
"prompt_token_ids": [1, 2, 3],
|
||||
"additional_information": {"voice": ["test"]},
|
||||
}
|
||||
)
|
||||
mock_coerce = mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
|
||||
return_value=voxtral_server.engine_client.default_sampling_params_list,
|
||||
)
|
||||
request = OpenAICreateSpeechRequest(input="hello", voice="test", stream_format="audio", response_format="pcm")
|
||||
|
||||
asyncio.run(voxtral_server._prepare_speech_generation(request))
|
||||
|
||||
mock_coerce.assert_called_once_with(voxtral_server.engine_client.default_sampling_params_list, True)
|
||||
|
||||
def test_prepare_speech_generation_qwen3_voicedesign_non_streaming_mode_false(
|
||||
self, qwen3_tts_server, mocker: MockerFixture
|
||||
):
|
||||
|
||||
@@ -633,6 +633,18 @@ def get_deploy_config_path(rel_path: str) -> str:
|
||||
return str(_DEPLOY_DIR / rel_path)
|
||||
|
||||
|
||||
def get_deploy_config_stage(rel_path: str, stage_id: int) -> dict[str, Any]:
|
||||
"""Return one stage entry from a deploy yaml by ``stage_id``."""
|
||||
with open(get_deploy_config_path(rel_path), encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
|
||||
stage_key = "stages" if "stages" in cfg else "stage_args"
|
||||
for stage in cfg.get(stage_key, []):
|
||||
if stage.get("stage_id") == stage_id:
|
||||
return stage
|
||||
raise KeyError(f"No stage_id={stage_id} in deploy config {rel_path!r}")
|
||||
|
||||
|
||||
def _get_config_value_by_path(config_dict: dict, path: str) -> Any:
|
||||
"""Read a dot-separated path from a nested dict (e.g. ``engine_args.load_format``)."""
|
||||
current: Any = config_dict
|
||||
@@ -707,6 +719,7 @@ def stage_config_path_for_run_level(stage_config_path: str | None, run_level: st
|
||||
|
||||
__all__ = [
|
||||
"get_deploy_config_path",
|
||||
"get_deploy_config_stage",
|
||||
"modify_stage_config",
|
||||
"stage_config_path_for_run_level",
|
||||
]
|
||||
@@ -0,0 +1,235 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Regression tests for ``Qwen3OmniMoeCode2Wav.chunked_decode_streaming``.
|
||||
|
||||
Background
|
||||
----------
|
||||
The Code2Wav decoder is a causal conv / transposed-conv stack whose forward
|
||||
emits a fixed number ``C`` *fewer* samples than ``frames * total_upsample`` (the
|
||||
right-edge trim, ``C == 555`` for Qwen3-Omni-30B-A3B). ``chunked_decode_streaming``
|
||||
used to slice ``[left_context * up : code_seq_len * up]``, assuming the full
|
||||
length. Because the real output is ``C`` short, the upper bound clamped and every
|
||||
streaming chunk silently dropped its last ``C`` samples -> a gap/click at every
|
||||
chunk boundary plus ~1.2% cumulative time compression.
|
||||
|
||||
The fix measures ``tail`` from the actual decoder output and shifts each chunk's
|
||||
start back by ``tail`` so the chunk refills the gap the previous chunk left.
|
||||
|
||||
Why this is path-agnostic (eager *and* CUDA graph)
|
||||
--------------------------------------------------
|
||||
Originally the CUDA-graph wrapper returned a *surplus* (nominal ``actual * up``,
|
||||
with stale padded-tail samples), so ``tail`` evaluated to 0 and the graph path
|
||||
looked immune. Since #4466 (``CUDAGraphDecoderWrapper._trim_replay_output``) the
|
||||
graph wrapper returns the eager-equivalent ``actual * up - C`` instead, so the
|
||||
same per-chunk tail drop happens on the graph branch too and the same fix
|
||||
repairs it. These tests cover both branches; ``_FakeWrapper`` models the
|
||||
post-#4466 contract (``decode`` returns ``actual * up - C``).
|
||||
|
||||
These are pure length/slice-arithmetic tests: CPU-only, no weights, no GPU.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_omni.qwen3_omni_code2wav import (
|
||||
Qwen3OmniMoeCode2Wav,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_UP = 8 # total_upsample
|
||||
_TRIM = 5 # causal right-edge trim C, 0 < C < up (mirrors 555 < 1920)
|
||||
_Q = 4 # num_quantizers
|
||||
|
||||
|
||||
class _ShortTrimDecoder:
|
||||
"""Minimal stand-in exposing only what ``chunked_decode_streaming`` touches.
|
||||
|
||||
Models a *shift-equivariant* causal decoder: the waveform value at an
|
||||
absolute output sample ``q`` is ``floor(q / up)`` == the index of the frame
|
||||
that sample belongs to. With codes set so frame ``i`` carries value ``i``,
|
||||
any window covering a frame (with enough right context) reproduces the same
|
||||
global waveform -- which is exactly the property that lets the next chunk
|
||||
refill the tail the previous chunk dropped. The forward emits ``trim`` fewer
|
||||
samples than ``frames * up`` (the right-edge trim that lacks right context).
|
||||
"""
|
||||
|
||||
def __init__(self, *, up=_UP, trim=_TRIM, num_quantizers=_Q, cudagraph=False):
|
||||
self.total_upsample = up
|
||||
self.trim = trim
|
||||
self.config = SimpleNamespace(num_quantizers=num_quantizers)
|
||||
self._cudagraph_enabled = cudagraph
|
||||
self._cudagraph_wrapper = _FakeWrapper(self) if cudagraph else None
|
||||
|
||||
def _decode(self, codes: torch.Tensor) -> torch.Tensor:
|
||||
# codes: [B, Q, W]; frame value (row 0) == absolute frame index.
|
||||
batch, _, width = codes.shape
|
||||
out_len = max(0, width * self.total_upsample - self.trim)
|
||||
frame_of = torch.arange(out_len) // self.total_upsample # [out_len]
|
||||
vals = codes[:, 0, :].to(torch.float32) # [B, W]
|
||||
out = vals.gather(1, frame_of.unsqueeze(0).expand(batch, -1)) # [B, out_len]
|
||||
return out.unsqueeze(1) # [B, 1, out_len]
|
||||
|
||||
# ``chunked_decode_streaming`` calls ``self(codes)`` on the eager branch.
|
||||
def __call__(self, codes: torch.Tensor) -> torch.Tensor:
|
||||
return self._decode(codes)
|
||||
|
||||
|
||||
class _FakeWrapper:
|
||||
"""Models ``CUDAGraphDecoderWrapper.decode`` after #4466: returns the
|
||||
eager-equivalent short length (``actual * up - C``), values correct."""
|
||||
|
||||
def __init__(self, decoder: _ShortTrimDecoder):
|
||||
self.decoder = decoder
|
||||
|
||||
def decode(self, codes: torch.Tensor) -> torch.Tensor:
|
||||
return self.decoder._decode(codes)
|
||||
|
||||
|
||||
def _make_codes(frames: int, q: int = _Q) -> torch.Tensor:
|
||||
"""codes[0, :, i] == i so each frame carries its own absolute index."""
|
||||
idx = torch.arange(frames, dtype=torch.long)
|
||||
return idx.view(1, 1, frames).expand(1, q, frames).contiguous()
|
||||
|
||||
|
||||
def _decode_chunk(model, window, lc):
|
||||
"""Call the real production method for one streaming window."""
|
||||
win_frames = window.shape[-1]
|
||||
out = Qwen3OmniMoeCode2Wav.chunked_decode_streaming(
|
||||
model,
|
||||
window,
|
||||
left_context_size=[lc],
|
||||
seq_token_counts=[win_frames * model.config.num_quantizers],
|
||||
)
|
||||
return out[0].reshape(-1)
|
||||
|
||||
|
||||
def _drive_async(model, codes, *, initial=4, chunk=25, left_context=25):
|
||||
"""Replicate qwen3_omni's ``talker2code2wav_async_chunk`` 4/25/25 schedule."""
|
||||
total = codes.shape[-1]
|
||||
chunks = []
|
||||
emitted = 0
|
||||
first = True
|
||||
while emitted < total:
|
||||
target = min(initial, total) if first else min(emitted + chunk, total)
|
||||
lc = min(emitted, left_context)
|
||||
window = codes[:, :, emitted - lc : target]
|
||||
chunks.append(_decode_chunk(model, window, lc))
|
||||
emitted = target
|
||||
first = False
|
||||
return torch.cat(chunks, dim=-1)
|
||||
|
||||
|
||||
def _single_shot(model, codes):
|
||||
"""Ground-truth: one full-utterance forward (length T*up - trim)."""
|
||||
return model(codes)[0].reshape(-1)
|
||||
|
||||
|
||||
def test_streaming_reconstructs_single_shot_eager():
|
||||
"""Eager: concatenated streaming chunks == the single-shot full decode."""
|
||||
model = _ShortTrimDecoder()
|
||||
codes = _make_codes(116)
|
||||
|
||||
gt = _single_shot(model, codes)
|
||||
streamed = _drive_async(model, codes)
|
||||
|
||||
assert streamed.shape[-1] == gt.shape[-1]
|
||||
torch.testing.assert_close(streamed, gt)
|
||||
|
||||
|
||||
def test_streaming_reconstructs_single_shot_cudagraph():
|
||||
"""CUDA-graph branch (post-#4466 contract): identical exact reconstruction.
|
||||
|
||||
Answers the first-principles question: the non-eager path is NOT immune on
|
||||
current main -- it drops the same tail without the fix, and the fix repairs
|
||||
it the same way.
|
||||
"""
|
||||
model = _ShortTrimDecoder(cudagraph=True)
|
||||
codes = _make_codes(116)
|
||||
|
||||
gt = _single_shot(model, codes) # forward() is eager regardless of the branch
|
||||
streamed = _drive_async(model, codes)
|
||||
|
||||
assert streamed.shape[-1] == gt.shape[-1]
|
||||
torch.testing.assert_close(streamed, gt)
|
||||
|
||||
|
||||
def test_eager_and_cudagraph_streaming_match():
|
||||
"""Both decode branches must yield bit-identical streamed audio."""
|
||||
codes = _make_codes(116)
|
||||
eager = _drive_async(_ShortTrimDecoder(cudagraph=False), codes)
|
||||
graph = _drive_async(_ShortTrimDecoder(cudagraph=True), codes)
|
||||
torch.testing.assert_close(eager, graph)
|
||||
|
||||
|
||||
def test_per_chunk_tail_drop_is_repaired_not_present():
|
||||
"""The defect is exactly ``C`` lost per internal boundary; the fix removes it.
|
||||
|
||||
Without the fix the streamed length would be short by
|
||||
``(num_internal_boundaries) * trim``; assert the boundary count and that the
|
||||
repaired stream has the full single-shot length (no deficit).
|
||||
"""
|
||||
model = _ShortTrimDecoder()
|
||||
codes = _make_codes(116)
|
||||
gt = _single_shot(model, codes)
|
||||
streamed = _drive_async(model, codes)
|
||||
|
||||
# 116 frames @ initial=4, chunk=25 -> targets 4,29,54,79,104,116 => 5 boundaries
|
||||
num_internal_boundaries = 5
|
||||
buggy_len = gt.shape[-1] - num_internal_boundaries * _TRIM
|
||||
assert streamed.shape[-1] == gt.shape[-1]
|
||||
assert streamed.shape[-1] != buggy_len # the pre-fix slice produced buggy_len
|
||||
|
||||
|
||||
def test_first_chunk_start_is_not_shifted_below_zero():
|
||||
"""First chunk has left_context==0 -> start = max(0, 0 - tail) = 0 (unchanged)."""
|
||||
model = _ShortTrimDecoder()
|
||||
codes = _make_codes(4) # single initial chunk, lc=0
|
||||
out = _decode_chunk(model, codes, lc=0)
|
||||
# length == frames*up - trim, values == frame indices (GT prefix)
|
||||
assert out.shape[-1] == 4 * _UP - _TRIM
|
||||
torch.testing.assert_close(out, _single_shot(model, codes))
|
||||
|
||||
|
||||
def test_no_shift_when_decoder_returns_nominal_length():
|
||||
"""C==0 decoder (e.g. Qwen3-TTS): tail==0 -> genuine no-op.
|
||||
|
||||
With no trim, the fixed slice must equal the original
|
||||
``[left_context*up : code_seq_len*up]`` -- every chunk emits exactly
|
||||
``ctx_len*up`` samples and streaming reconstructs the full (untrimmed) audio.
|
||||
"""
|
||||
model = _ShortTrimDecoder(trim=0)
|
||||
codes = _make_codes(116)
|
||||
gt = _single_shot(model, codes)
|
||||
streamed = _drive_async(model, codes)
|
||||
|
||||
assert gt.shape[-1] == 116 * _UP # no trim -> nominal full length
|
||||
assert streamed.shape[-1] == gt.shape[-1]
|
||||
torch.testing.assert_close(streamed, gt)
|
||||
|
||||
|
||||
def test_batched_streaming_only_shifts_the_boundary_row():
|
||||
"""Per-row tail: in a shared window the shorter row had right context from
|
||||
the longer row's frames, so it lost nothing (tail==0, no shift); only the
|
||||
row at the decoded boundary (code_seq_len == window length) is shifted."""
|
||||
model = _ShortTrimDecoder()
|
||||
# One window of W=30 frames, two rows. codes[:, 0, :] are absolute indices.
|
||||
idx = torch.arange(30, dtype=torch.long)
|
||||
window = idx.view(1, 1, 30).expand(2, _Q, 30).contiguous()
|
||||
|
||||
long_frames, short_frames = 30, 20 # row0 spans the full window, row1 is shorter
|
||||
lc = 5
|
||||
out = Qwen3OmniMoeCode2Wav.chunked_decode_streaming(
|
||||
model,
|
||||
window,
|
||||
left_context_size=[lc, lc],
|
||||
seq_token_counts=[long_frames * _Q, short_frames * _Q],
|
||||
)
|
||||
|
||||
batch_len = 30 * _UP - _TRIM # actual decoder output width
|
||||
# Row 0 (boundary row): tail == TRIM -> start = lc*up - TRIM
|
||||
assert out[0].shape[-1] == batch_len - (lc * _UP - _TRIM)
|
||||
# Row 1 (shorter): code_seq_len*up < batch_len -> tail == 0 -> start = lc*up
|
||||
assert out[1].shape[-1] == short_frames * _UP - lc * _UP
|
||||
@@ -34,6 +34,7 @@ def _make_minimal_talker(
|
||||
"""
|
||||
model = Qwen3TTSTalkerForConditionalGeneration.__new__(Qwen3TTSTalkerForConditionalGeneration)
|
||||
model.talker_config = SimpleNamespace(codec_pad_id=7, num_code_groups=16)
|
||||
model._embedding_dtype = torch.bfloat16
|
||||
if tts_pad_embed is None:
|
||||
tts_pad_embed = torch.zeros((1, 4), dtype=torch.bfloat16)
|
||||
model._tts_pad_embed = tts_pad_embed
|
||||
@@ -82,6 +83,7 @@ def _make_minimal_builder(
|
||||
builder._codec_embed = lambda ids: torch.zeros((*ids.shape, 4), device=ids.device)
|
||||
builder._residual_code_embeddings = lambda: []
|
||||
builder._speaker_encoder = None
|
||||
builder._embedding_dtype = torch.bfloat16
|
||||
builder._tts_pad_embed_buffer = (
|
||||
tts_pad_embed if tts_pad_embed is not None else torch.zeros((1, 4), dtype=torch.bfloat16)
|
||||
)
|
||||
@@ -254,6 +256,36 @@ def test_decode_compacts_long_trailing_text_after_large_offset():
|
||||
assert torch.equal(update["hidden_states"]["trailing_text"], trailing_text[65:])
|
||||
|
||||
|
||||
def test_decode_replay_span_embeds_all_tokens_without_mutating_decode_state():
|
||||
model = _make_minimal_talker()
|
||||
|
||||
def fake_embed_input_ids(input_ids):
|
||||
return input_ids.to(torch.float32).reshape(-1, 1, 1).expand(-1, 1, 4)
|
||||
|
||||
model.embed_input_ids = fake_embed_input_ids
|
||||
trailing_text = torch.arange(12, dtype=torch.float32).reshape(3, 4)
|
||||
last_hidden = torch.full((4,), 2.0, dtype=torch.float32)
|
||||
|
||||
out_ids, out_embeds, update = model.preprocess(
|
||||
input_ids=torch.tensor([101, 202, 303], dtype=torch.long),
|
||||
input_embeds=None,
|
||||
text=["hello"],
|
||||
task_type=["Base"],
|
||||
hidden_states={"trailing_text": trailing_text, "last": last_hidden},
|
||||
meta={"talker_text_offset": 1, "codec_streaming": True},
|
||||
_omni_is_prefill=False,
|
||||
_omni_num_computed_tokens=2400,
|
||||
_omni_prompt_len=213,
|
||||
)
|
||||
|
||||
assert out_ids.tolist() == [101, 202, 303]
|
||||
assert torch.equal(
|
||||
out_embeds.cpu(),
|
||||
torch.tensor([[101.0] * 4, [202.0] * 4, [303.0] * 4], dtype=torch.bfloat16),
|
||||
)
|
||||
assert update == {"meta": {"codec_streaming": True}}
|
||||
|
||||
|
||||
def test_decode_batch_preprocess_matches_decode_state_updates():
|
||||
tts_pad = torch.full((1, 4), -1.0, dtype=torch.bfloat16)
|
||||
model = _make_minimal_talker(tts_pad_embed=tts_pad)
|
||||
|
||||
@@ -74,3 +74,18 @@ def test_make_omni_output_omits_ref_key_when_no_request_has_one() -> None:
|
||||
)
|
||||
|
||||
assert "ref" not in out.multimodal_outputs["codes"]
|
||||
|
||||
|
||||
def test_make_omni_output_keeps_hidden_states_for_replay_spans_without_audio_codes() -> None:
|
||||
hidden = torch.arange(6 * 8, dtype=torch.float32).reshape(6, 8)
|
||||
|
||||
out = _make_talker().make_omni_output(
|
||||
hidden,
|
||||
model_intermediate_buffer=[
|
||||
{"meta": {"codec_streaming": True}},
|
||||
_info(1, None),
|
||||
],
|
||||
)
|
||||
|
||||
assert torch.equal(out.text_hidden_states, hidden)
|
||||
assert out.multimodal_outputs["codes"]["audio"].shape == (1, _NUM_CODE_GROUPS)
|
||||
@@ -0,0 +1,148 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Regression tests for vLLM's KV-cache scale mapper load path."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
_STALE_API_FILES = [
|
||||
"vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py",
|
||||
"vllm_omni/model_executor/models/mammoth_moda2/mammoth_moda2.py",
|
||||
"vllm_omni/model_executor/models/mimo_audio/mimo_audio_llm.py",
|
||||
"vllm_omni/model_executor/models/qwen2_5_omni/qwen2_old.py",
|
||||
]
|
||||
|
||||
_SOURCE_SCALE_NAME = "layers.0.self_attn.k_proj.output_scale"
|
||||
_MAPPED_SCALE_NAME = "layers.0.self_attn.attn.k_scale"
|
||||
|
||||
|
||||
class _RecordingCacheScaleMapper:
|
||||
def __init__(self) -> None:
|
||||
self.called = False
|
||||
|
||||
def apply(
|
||||
self,
|
||||
weights: Iterable[tuple[str, torch.Tensor]],
|
||||
) -> Iterable[tuple[str, torch.Tensor]]:
|
||||
self.called = True
|
||||
for name, weight in weights:
|
||||
yield name.replace(".k_proj.output_scale", ".attn.k_scale"), weight
|
||||
|
||||
|
||||
class _QuantConfigWithoutOldCacheScale:
|
||||
def __init__(self) -> None:
|
||||
self.mapper = _RecordingCacheScaleMapper()
|
||||
|
||||
def get_cache_scale_mapper(self) -> _RecordingCacheScaleMapper:
|
||||
return self.mapper
|
||||
|
||||
|
||||
class _FakeModel:
|
||||
def __init__(self, quant_config: _QuantConfigWithoutOldCacheScale) -> None:
|
||||
self.quant_config = quant_config
|
||||
self.param = torch.nn.Parameter(torch.zeros(()), requires_grad=False)
|
||||
|
||||
def named_parameters(
|
||||
self,
|
||||
remove_duplicate: bool = True,
|
||||
) -> Iterable[tuple[str, torch.nn.Parameter]]:
|
||||
del remove_duplicate
|
||||
return [(_MAPPED_SCALE_NAME, self.param)]
|
||||
|
||||
|
||||
class _FakeHunyuanModel(_FakeModel):
|
||||
def __init__(self, quant_config: _QuantConfigWithoutOldCacheScale) -> None:
|
||||
super().__init__(quant_config)
|
||||
self.config = SimpleNamespace(
|
||||
tie_word_embeddings=False,
|
||||
num_attention_heads=1,
|
||||
num_key_value_heads=1,
|
||||
use_cla=False,
|
||||
)
|
||||
|
||||
def get_expert_mapping(self) -> tuple[list[object], dict[str, object]]:
|
||||
return [], {}
|
||||
|
||||
def _split_qkv_weight(self, weight: torch.Tensor) -> torch.Tensor:
|
||||
return weight
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rel_path", _STALE_API_FILES)
|
||||
def test_model_loaders_do_not_call_removed_get_cache_scale(rel_path: str) -> None:
|
||||
"""Affected loaders must not call the removed vLLM get_cache_scale API."""
|
||||
source = (_REPO_ROOT / rel_path).read_text(encoding="utf-8")
|
||||
assert ".get_cache_scale(" not in source
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module_name", "class_name", "fake_model_cls"),
|
||||
[
|
||||
(
|
||||
"vllm_omni.model_executor.models.hunyuan_image3.hunyuan_image3",
|
||||
"HunyuanModel",
|
||||
_FakeHunyuanModel,
|
||||
),
|
||||
(
|
||||
"vllm_omni.model_executor.models.qwen2_5_omni.qwen2_old",
|
||||
"Qwen2Model",
|
||||
_FakeModel,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_auto_weights_loader_delegated_loaders_accept_mapped_cache_scale(
|
||||
module_name: str,
|
||||
class_name: str,
|
||||
fake_model_cls: type[_FakeModel],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Delegated loaders must not expect the removed get_cache_scale method."""
|
||||
module = __import__(module_name, fromlist=[class_name])
|
||||
monkeypatch.setattr(module, "is_pp_missing_parameter", lambda *_: False)
|
||||
|
||||
fake_model = fake_model_cls(_QuantConfigWithoutOldCacheScale())
|
||||
load_weights = getattr(module, class_name).load_weights
|
||||
|
||||
loaded = load_weights(fake_model, [(_MAPPED_SCALE_NAME, torch.ones(()))])
|
||||
|
||||
assert loaded == {_MAPPED_SCALE_NAME}
|
||||
torch.testing.assert_close(fake_model.param.data, torch.ones(()))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module_name", "class_name"),
|
||||
[
|
||||
(
|
||||
"vllm_omni.model_executor.models.mammoth_moda2.mammoth_moda2",
|
||||
"MammothModa2Qwen2ForCausalLM",
|
||||
),
|
||||
(
|
||||
"vllm_omni.model_executor.models.mimo_audio.mimo_audio_llm",
|
||||
"MiMoAudioLLMForConditionalGeneration",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_direct_custom_loaders_apply_cache_scale_mapper(
|
||||
module_name: str,
|
||||
class_name: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Direct custom loaders must apply the new mapper before fall-through load."""
|
||||
module = __import__(module_name, fromlist=[class_name])
|
||||
monkeypatch.setattr(module, "is_pp_missing_parameter", lambda *_: False)
|
||||
|
||||
quant_config = _QuantConfigWithoutOldCacheScale()
|
||||
fake_model = _FakeModel(quant_config)
|
||||
load_weights = getattr(module, class_name).load_weights
|
||||
|
||||
loaded = load_weights(fake_model, [(_SOURCE_SCALE_NAME, torch.ones(()))])
|
||||
|
||||
assert quant_config.mapper.called
|
||||
assert loaded == {_MAPPED_SCALE_NAME}
|
||||
torch.testing.assert_close(fake_model.param.data, torch.ones(()))
|
||||
@@ -0,0 +1,208 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Static regression checks for VoxCPM2 runner-assisted unified graph hooks.
|
||||
|
||||
These tests intentionally avoid importing torch/vLLM so they can run in a
|
||||
lightweight local checkout. Runtime audio quality still requires a CUDA test.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
RUNNER = REPO_ROOT / "vllm_omni" / "worker" / "gpu_ar_model_runner.py"
|
||||
RUNNER_ASSISTED_METADATA = REPO_ROOT / "vllm_omni" / "worker" / "runner_assisted_metadata.py"
|
||||
TALKER = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "voxcpm2_talker.py"
|
||||
VOXCPM2_RUNTIME_CONFIG = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "runtime_config.py"
|
||||
VOXCPM2_PIPELINE = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "pipeline.py"
|
||||
VOXCPM2_SCHEDULER = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "scheduler.py"
|
||||
OMNI_AR_SCHEDULER = REPO_ROOT / "vllm_omni" / "core" / "sched" / "omni_ar_scheduler.py"
|
||||
DEPLOY = REPO_ROOT / "vllm_omni" / "deploy" / "voxcpm2.yaml"
|
||||
|
||||
|
||||
def test_ar_runner_exposes_runner_assisted_full_metadata_hook():
|
||||
source = RUNNER.read_text()
|
||||
|
||||
assert "Models without this hook keep the normal runner path" in source
|
||||
assert "get_runner_assisted_full_attention_metadata_request" in source
|
||||
assert "set_runner_assisted_full_attention_metadata_context" in source
|
||||
assert "runner_assisted_full_attn" in source
|
||||
assert "runner_assisted_full_attn_capture" in source
|
||||
assert "pad_attn = cudagraph_mode == CUDAGraphMode.FULL or runner_assisted_full_attn" in source
|
||||
assert "for_cudagraph_capture=runner_assisted_full_attn_capture" in source
|
||||
assert "cudagraph_runtime_mode=(" in source
|
||||
assert "CUDAGraphMode.FULL if runner_assisted_full_attn else cudagraph_mode" in source
|
||||
refresh_source = source[source.index("def _refresh_runner_assisted_full_attention_metadata_buffers") :]
|
||||
refresh_source = refresh_source[: refresh_source.index("def _set_runner_assisted_full_attention_metadata_context")]
|
||||
assert "num_computed_tokens_cpu" in refresh_source
|
||||
assert "num_scheduled_tokens_np" in refresh_source
|
||||
assert "optimistic_seq_lens_cpu[:num_reqs]=1" not in "".join(refresh_source.split())
|
||||
padding_source = source[
|
||||
source.index("runner_assisted_full_attn_request = self._get_runner_assisted_full_attention_metadata_request") :
|
||||
]
|
||||
padding_source = padding_source[: padding_source.index("ubatch_slices, ubatch_slices_padded")]
|
||||
assert "runner_assisted_full_attn_request.num_reqs_padded" in padding_source
|
||||
assert "runner_assisted_full_attn_request.for_cudagraph_capture" in padding_source
|
||||
assert "num_reqs_padded * max_num_scheduled_tokens" in padding_source
|
||||
|
||||
|
||||
def test_ar_runner_without_model_hook_stays_on_normal_path():
|
||||
source = RUNNER.read_text()
|
||||
contract_source = RUNNER_ASSISTED_METADATA.read_text()
|
||||
request_source = source[source.index("def _get_runner_assisted_full_attention_metadata_request") :]
|
||||
request_source = request_source[
|
||||
: request_source.index("def _refresh_runner_assisted_full_attention_metadata_buffers")
|
||||
]
|
||||
compact_request_source = "".join(request_source.split())
|
||||
|
||||
assert "Models without this hook keep the normal runner path" in request_source
|
||||
assert "class RunnerAssistedAttentionMetadataProvider(Protocol)" in contract_source
|
||||
assert "class RunnerAssistedFullAttentionMetadataRequest(NamedTuple)" in contract_source
|
||||
assert "tuple[int, bool]" not in contract_source
|
||||
assert 'hook=getattr(self.model,"get_runner_assisted_full_attention_metadata_request",None)' in (
|
||||
compact_request_source
|
||||
)
|
||||
assert "ifnotcallable(hook):returnNone" in compact_request_source
|
||||
assert ".tolist()" not in compact_request_source
|
||||
assert "isinstance(request,RunnerAssistedFullAttentionMetadataRequest)" in compact_request_source
|
||||
assert "request.num_reqs_padded" in request_source
|
||||
assert "request.for_cudagraph_capture" in request_source
|
||||
assert "exceptException" not in compact_request_source
|
||||
|
||||
set_context_source = source[source.index("def _set_runner_assisted_full_attention_metadata_context") :]
|
||||
set_context_source = set_context_source[: set_context_source.index("def _deferred_prefix_cache_mm_keys")]
|
||||
assert "except Exception" not in set_context_source
|
||||
|
||||
|
||||
def test_voxcpm2_graph_paths_fail_closed_and_preserve_deterministic_noise():
|
||||
source = TALKER.read_text()
|
||||
runtime_config_source = VOXCPM2_RUNTIME_CONFIG.read_text()
|
||||
compact_source = "".join(source.split())
|
||||
|
||||
assert "_voxcpm2_compile_without_inductor_cudagraphs" in source
|
||||
assert "def _compile_without_inductor_cudagraphs" not in source
|
||||
assert "_voxcpm2_unwrap_torch_compile" in source
|
||||
assert "_orig_mod" in source
|
||||
compile_source = source[source.index("def _voxcpm2_compile_without_inductor_cudagraphs") :]
|
||||
compile_source = compile_source[: compile_source.index("def _setup_torch_compile")]
|
||||
assert "torch._inductor.config" not in compile_source
|
||||
assert '"triton.cudagraphs": False' in compile_source
|
||||
assert '"triton.cudagraph_trees": False' in compile_source
|
||||
|
||||
assert "self._enable_unified_decode_graph=self._runtime_config.unified_decode_graph_available(" in compact_source
|
||||
assert "andnotself.deterministic_cfm_noise" in "".join(runtime_config_source.split())
|
||||
assert "decode_tail_graph" not in source
|
||||
|
||||
unified_source = source[source.index("def _forward_unified_decode") :]
|
||||
unified_source = unified_source[: unified_source.index("# -------------------- vllm hooks")]
|
||||
assert "except Exception:" not in unified_source
|
||||
assert "_failed_unified_graph_sizes" not in source
|
||||
assert "self._enable_unified_decode_graph = False" not in unified_source
|
||||
assert "_forward_unified_decode_fallback" not in source
|
||||
assert "capture_failed" not in compact_source
|
||||
assert "record_fallback" not in source
|
||||
assert "fallbacks" not in source
|
||||
assert "**info_dict: Unpack[VoxCPM2PreprocessInput]" in source
|
||||
assert "**info: Unpack[VoxCPM2PostprocessInput]" in source
|
||||
assert "reference_audio: Any" not in source
|
||||
assert "prompt_audio: Any" not in source
|
||||
assert "def _nullify_volatile_metadata(ctx: _ForwardContextLike) -> _ForwardContextLike" in source
|
||||
assert "class _PrefillInputs(NamedTuple)" in source
|
||||
assert "-> _PrefillInputs" in source
|
||||
assert 'inputs["audio_feat"]' not in source
|
||||
assert 'inputs["text_token"]' not in source
|
||||
|
||||
capture_source = source[source.index("def _capture_unified_decode_graph") :]
|
||||
capture_source = capture_source[: capture_source.index("def _unified_decode_graph_skip_reason")]
|
||||
assert "_voxcpm2_compile_unified_capture_estimator" in capture_source
|
||||
assert "_voxcpm2_compile_unified_capture_feat_encoder" in capture_source
|
||||
graph_body = capture_source[capture_source.index("with torch.cuda.graph") :]
|
||||
assert "g.cfm_noise.normal_()" not in graph_body
|
||||
assert "g.cfm_noise.normal_()" in unified_source
|
||||
|
||||
assert "_pre_capture_unified_graphs" not in source
|
||||
assert "_unified_graph_pre_capture_sizes" not in source
|
||||
assert "unified_decode_graph_pre_capture_sizes" not in source
|
||||
|
||||
|
||||
def test_voxcpm2_batch_unified_graph_requires_runner_metadata_marker():
|
||||
source = TALKER.read_text()
|
||||
compact_source = "".join(source.split())
|
||||
|
||||
assert "enable_runner_assisted_unified_decode_graph" not in source
|
||||
assert "allow_unified_decode_graph_batch_attention" not in source
|
||||
assert "get_runner_assisted_full_attention_metadata_request" in source
|
||||
assert "set_runner_assisted_full_attention_metadata_context" in source
|
||||
assert "RunnerAssistedFullAttentionMetadataRequest" in source
|
||||
assert "_runner_assisted_unified_decode_graph_active" in source
|
||||
assert "runner_full_metadata_missing" in source
|
||||
assert "_build_unified_graph_bucket_sizes" in source
|
||||
assert "_select_unified_graph_bucket_size" in source
|
||||
|
||||
capture_source = source[source.index("def _capture_unified_decode_graph") :]
|
||||
capture_source = capture_source[: capture_source.index("def _unified_decode_graph_skip_reason")]
|
||||
assert "override_forward_context" in capture_source
|
||||
assert "_nullify_volatile_metadata" in capture_source
|
||||
assert "capture_context = override_forward_context(self._nullify_volatile_metadata(ctx))" in capture_source
|
||||
assert "ifsize>1:continue" not in compact_source
|
||||
forward_source = source[source.index("def _forward_unified_decode") :]
|
||||
forward_source = forward_source[: forward_source.index("# -------------------- vllm hooks")]
|
||||
assert "graph_size = self._select_unified_graph_bucket_size(num_reqs)" in forward_source
|
||||
assert "self._unified_graphs[graph_size]" in forward_source
|
||||
assert "g.input_embeds[num_reqs:graph_size].zero_()" not in forward_source
|
||||
assert "padded rows as valid duplicates" in forward_source
|
||||
assert "g.input_embeds[last : last + 1].expand" in forward_source
|
||||
assert "ifnum_reqs>1andnotself._runner_assisted_unified_decode_graph_active" in compact_source
|
||||
assert "andnotcfg.enable_runner_assisted_unified_decode_graph" not in compact_source
|
||||
needs_source = source[source.index("def get_runner_assisted_full_attention_metadata_request") :]
|
||||
needs_source = needs_source[: needs_source.index("def set_runner_assisted_full_attention_metadata_context")]
|
||||
assert "_select_unified_graph_bucket_size(num_reqs)" in needs_source
|
||||
assert "_should_use_decode_graph(num_reqs)" not in needs_source
|
||||
assert "RunnerAssistedFullAttentionMetadataRequest(" in needs_source
|
||||
|
||||
|
||||
def test_voxcpm2_unified_skip_preserves_segmented_decode_graphs():
|
||||
source = TALKER.read_text()
|
||||
compact_source = "".join(source.split())
|
||||
forward_source = source[source.index("def forward(") :]
|
||||
forward_source = forward_source[: forward_source.index("# -------------------- prefill / decode helpers")]
|
||||
|
||||
assert "use_segmented_decode_graph" in forward_source
|
||||
assert "unified_skip_reasonisnotNone" in compact_source
|
||||
assert "notself._enable_unified_decode_graphorunified_skip_reasonisnotNone" in compact_source
|
||||
assert "ifuse_segmented_decode_graph:" in compact_source
|
||||
assert "anduse_segmented_decode_graph" in compact_source
|
||||
|
||||
|
||||
def test_voxcpm2_scheduler_policy_stays_model_local():
|
||||
common_source = OMNI_AR_SCHEDULER.read_text()
|
||||
voxcpm2_scheduler_source = VOXCPM2_SCHEDULER.read_text()
|
||||
pipeline_source = VOXCPM2_PIPELINE.read_text()
|
||||
|
||||
assert "VoxCPM2TalkerForConditionalGeneration" not in common_source
|
||||
assert "voxcpm2_runtime_config" not in common_source
|
||||
assert "pure_decode_graph" not in common_source
|
||||
assert "_schedule_with_optional_waiting_deferral" not in common_source
|
||||
assert "def _should_defer_waiting_admission(self) -> bool:" in common_source
|
||||
|
||||
assert "class VoxCPM2OmniARAsyncScheduler(OmniARAsyncScheduler)" in voxcpm2_scheduler_source
|
||||
assert "from .runtime_config import _VoxCPM2RuntimeConfig" in voxcpm2_scheduler_source
|
||||
assert "_VoxCPM2RuntimeConfig.from_vllm_config(self.vllm_config)" in voxcpm2_scheduler_source
|
||||
assert "unified_decode_graph_available(use_cuda_graph=current_omni_platform.is_cuda())" in voxcpm2_scheduler_source
|
||||
assert "_should_defer_waiting_for_unified_decode_graph" in voxcpm2_scheduler_source
|
||||
assert "def schedule(" not in voxcpm2_scheduler_source
|
||||
assert "create_request_queue" not in voxcpm2_scheduler_source
|
||||
assert "original_waiting.prepend_requests(deferred_waiting)" not in voxcpm2_scheduler_source
|
||||
assert "return self._should_defer_waiting_for_unified_decode_graph()" in voxcpm2_scheduler_source
|
||||
assert "vllm_omni.model_executor.models.voxcpm2.scheduler.VoxCPM2OmniARAsyncScheduler" in pipeline_source
|
||||
|
||||
|
||||
def test_voxcpm2_deploy_defaults_to_full_unified_graph_only():
|
||||
source = DEPLOY.read_text()
|
||||
|
||||
assert "max_num_seqs: 8" in source
|
||||
assert "enable_unified_decode_graph: true" in source
|
||||
assert "unified_decode_graph_max_batch_size: 8" in source
|
||||
assert "unified_decode_graph_pre_capture_sizes" not in source
|
||||
assert "enable_runner_assisted_unified_decode_graph" not in source
|
||||
assert "allow_unified_decode_graph_batch_attention" not in source
|
||||
assert "decode_graph_capture_policy" not in source
|
||||
@@ -45,6 +45,33 @@ def test_truncate_at_cot_end_strips_tail_after_recaption_marker():
|
||||
assert text == "body text</recaption>"
|
||||
|
||||
|
||||
def test_ar2diffusion_returns_one_request_payload_for_request_level_batching():
|
||||
result = ar2diffusion(
|
||||
[_source_output([100], text="thought")],
|
||||
prompt={"prompt": "edit"},
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["prompt"] == "edit"
|
||||
|
||||
|
||||
def test_ar2diffusion_uses_parent_output_when_companions_are_present():
|
||||
result = ar2diffusion(
|
||||
[
|
||||
_source_output([100], text="parent thought"),
|
||||
_source_output([200], text="companion thought"),
|
||||
],
|
||||
prompt={"prompt": "edit"},
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["extra"]["ar_generated_text"] == "parent thought"
|
||||
|
||||
|
||||
def test_ar2diffusion_returns_none_without_parent_output():
|
||||
assert ar2diffusion([], prompt={"prompt": "edit"}) is None
|
||||
|
||||
|
||||
def test_ar2diffusion_applies_ratio_and_truncates_tail_without_tokenizer(monkeypatch: pytest.MonkeyPatch):
|
||||
real_import = builtins.__import__
|
||||
|
||||
@@ -67,10 +94,10 @@ def test_ar2diffusion_applies_ratio_and_truncates_tail_without_tokenizer(monkeyp
|
||||
prompt=[{"prompt": "edit", "height": 64, "width": 64}],
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert (result[0]["height"], result[0]["width"]) == (512, 2048)
|
||||
assert result[0]["extra"]["ar_generated_text"] == "decoded without special tokens"
|
||||
assert "ar_token_ids" not in result[0]["extra"]
|
||||
assert result is not None
|
||||
assert (result["height"], result["width"]) == (512, 2048)
|
||||
assert result["extra"]["ar_generated_text"] == "decoded without special tokens"
|
||||
assert "ar_token_ids" not in result["extra"]
|
||||
|
||||
|
||||
def test_ar2diffusion_forwards_custom_system_prompt_body():
|
||||
@@ -88,5 +115,6 @@ def test_ar2diffusion_forwards_custom_system_prompt_body():
|
||||
],
|
||||
)
|
||||
|
||||
assert result[0]["use_system_prompt"] == "custom"
|
||||
assert result[0]["system_prompt"] == marker
|
||||
assert result is not None
|
||||
assert result["use_system_prompt"] == "custom"
|
||||
assert result["system_prompt"] == marker
|
||||
@@ -0,0 +1,75 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for the quack FP8 auto-enable gate.
|
||||
|
||||
quack's CuteDSL FP8 GEMM uses the 5th-gen ``tcgen05`` tensor-core MMA, which
|
||||
exists only on datacenter Blackwell (``sm_100a`` / ``sm_101a`` / ``sm_103a``,
|
||||
compute capability ``10.x``). It must NOT auto-enable on workstation/consumer
|
||||
Blackwell (``sm_120`` / ``sm_121``, cc ``12.x``, e.g. RTX PRO 6000 / RTX
|
||||
50-series), where ``tcgen05`` is absent and every FP8 GEMM would fall back to
|
||||
FlashInfer one call at a time (catastrophically slow). See
|
||||
``vllm_omni/quantization/quack_fp8.py``.
|
||||
|
||||
Regression guard for the ``>= 10`` gate that matched cc ``12.x`` too. These
|
||||
tests are hardware-free: the ``torch.cuda`` probes are monkeypatched, so no GPU
|
||||
is required.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.quantization import quack_fp8
|
||||
|
||||
_ENV = "VLLM_OMNI_USE_QUACK_FP8"
|
||||
|
||||
|
||||
def _fake_cuda(monkeypatch: pytest.MonkeyPatch, capability: tuple[int, int]) -> None:
|
||||
"""Pretend a CUDA device with the given (major, minor) capability is present."""
|
||||
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
|
||||
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda *a, **k: capability)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"capability, expected",
|
||||
[
|
||||
((10, 0), True), # sm_100 datacenter Blackwell — tcgen05 present
|
||||
((10, 3), True), # sm_103 datacenter Blackwell
|
||||
((12, 0), False), # sm_120 workstation Blackwell — the regression
|
||||
((12, 1), False), # sm_121 consumer Blackwell
|
||||
((9, 0), False), # Hopper — CUTLASS already fuses bias, quack unused
|
||||
((8, 9), False), # Ada
|
||||
],
|
||||
)
|
||||
def test_auto_enable_only_on_datacenter_blackwell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capability: tuple[int, int],
|
||||
expected: bool,
|
||||
) -> None:
|
||||
monkeypatch.delenv(_ENV, raising=False)
|
||||
_fake_cuda(monkeypatch, capability)
|
||||
assert quack_fp8._is_quack_capable() is expected
|
||||
assert quack_fp8.quack_enabled() is expected
|
||||
|
||||
|
||||
def test_no_cuda_disables_quack(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv(_ENV, raising=False)
|
||||
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
|
||||
assert quack_fp8._is_quack_capable() is False
|
||||
assert quack_fp8.quack_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "yes", "on", "TRUE", "On"])
|
||||
def test_env_override_forces_on_even_on_sm120(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
|
||||
# sm_120 auto-disables, but an explicit truthy override forces quack on
|
||||
# (e.g. once CuteDSL ships sm_120a support).
|
||||
_fake_cuda(monkeypatch, (12, 0))
|
||||
monkeypatch.setenv(_ENV, value)
|
||||
assert quack_fp8.quack_enabled() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "false", "no", "off", ""])
|
||||
def test_env_override_forces_off_even_on_datacenter_blackwell(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
|
||||
# sm_100 auto-enables, but an explicit non-truthy override forces quack off.
|
||||
_fake_cuda(monkeypatch, (10, 0))
|
||||
monkeypatch.setenv(_ENV, value)
|
||||
assert quack_fp8.quack_enabled() is False
|
||||
@@ -14,7 +14,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from transformers import PretrainedConfig, Qwen3OmniMoeConfig
|
||||
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
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.pipeline_registry import OMNI_PIPELINES, register_pipeline
|
||||
from vllm_omni.config.stage_config import (
|
||||
@@ -571,6 +571,7 @@ class TestStagePipelineConfig:
|
||||
assert s.final_output is False
|
||||
assert s.sampling_constraints == {}
|
||||
assert s.engine_output_type is None
|
||||
assert s.scheduler_cls is None
|
||||
|
||||
|
||||
class TestPipelineConfigNew:
|
||||
@@ -739,6 +740,15 @@ stages:
|
||||
assert deploy.stages[0].compilation_config == {"pass_config": {"fuse_allreduce_rms": False}}
|
||||
assert "compilation_config" not in deploy.stages[0].engine_extras
|
||||
|
||||
def test_load_voxcpm2_deploy_config_preserves_engine_extras(self):
|
||||
deploy_path = get_deploy_config_path("voxcpm2.yaml")
|
||||
raw_stage = get_deploy_config_stage("voxcpm2.yaml", 0)
|
||||
expected_runtime_config = raw_stage["engine_extras"]["hf_overrides"]["voxcpm2_runtime_config"]
|
||||
|
||||
deploy = load_deploy_config(deploy_path)
|
||||
runtime_config = deploy.stages[0].engine_extras["hf_overrides"]["voxcpm2_runtime_config"]
|
||||
assert runtime_config == expected_runtime_config
|
||||
|
||||
def test_merge_pipeline_deploy(self):
|
||||
deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml"
|
||||
if not deploy_path.exists():
|
||||
@@ -800,6 +810,26 @@ stages:
|
||||
|
||||
assert stages[0].yaml_runtime["requires_multimodal_data"] is True
|
||||
|
||||
def test_merge_pipeline_deploy_preserves_pipeline_scheduler_cls(self):
|
||||
scheduler_cls = "tests.fake.CustomScheduler"
|
||||
pipeline = PipelineConfig(
|
||||
model_type="test_scheduler",
|
||||
model_arch="TestModel",
|
||||
stages=(
|
||||
StagePipelineConfig(
|
||||
stage_id=0,
|
||||
model_stage="ar",
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
scheduler_cls=scheduler_cls,
|
||||
),
|
||||
),
|
||||
)
|
||||
deploy = DeployConfig(async_chunk=False, stages=[StageDeployConfig(stage_id=0)])
|
||||
|
||||
stages = merge_pipeline_deploy(pipeline, deploy)
|
||||
|
||||
assert stages[0].scheduler_cls == scheduler_cls
|
||||
|
||||
def test_mixed_schema_preserves_flat_fields(self):
|
||||
"""Ensure flat fields are not dropped when engine_args are present."""
|
||||
fake_config = {
|
||||
@@ -928,6 +958,54 @@ stages:
|
||||
assert "foo" in stage.engine_extras
|
||||
assert stage.engine_extras["foo"] == {"a": 111, "b": {"d": 9, "e": 199}, "c": 3}
|
||||
|
||||
def test_explicit_engine_extras_merge_with_flat_and_engine_args(self):
|
||||
"""Explicit engine_extras should be preserved with existing pass-through styles."""
|
||||
fake_config = {
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": 0,
|
||||
"engine_extras": {
|
||||
"hf_overrides": {
|
||||
"runtime_config": {
|
||||
"explicit_only": True,
|
||||
"shared": "explicit",
|
||||
"nested": {"a": 1},
|
||||
}
|
||||
}
|
||||
},
|
||||
"hf_overrides": {
|
||||
"runtime_config": {
|
||||
"flat_only": True,
|
||||
"shared": "flat",
|
||||
"nested": {"b": 2},
|
||||
}
|
||||
},
|
||||
"engine_args": {
|
||||
"hf_overrides": {
|
||||
"runtime_config": {
|
||||
"engine_args_only": True,
|
||||
"shared": "engine_args",
|
||||
"nested": {"c": 3},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with patch("vllm_omni.config.stage_config.resolve_deploy_yaml", return_value=fake_config):
|
||||
deploy = load_deploy_config("dummy.yaml")
|
||||
|
||||
assert deploy.stages[0].engine_extras["hf_overrides"] == {
|
||||
"runtime_config": {
|
||||
"engine_args_only": True,
|
||||
"explicit_only": True,
|
||||
"flat_only": True,
|
||||
"nested": {"a": 1, "b": 2, "c": 3},
|
||||
"shared": "engine_args",
|
||||
}
|
||||
}
|
||||
|
||||
def test_deep_merge_does_not_mutate_inputs(self):
|
||||
"""Merging engine_args must not mutate the base stage dict."""
|
||||
fake_config = {
|
||||
@@ -1588,6 +1666,197 @@ class TestAuraOmniDeploy:
|
||||
assert stages[3].yaml_engine_args["model_arch"] == "Qwen3TTSCode2Wav"
|
||||
|
||||
|
||||
class TestDeployCliOverrideFlow:
|
||||
"""Test deploy-YAML baselines overridden by CLI runtime overrides."""
|
||||
|
||||
def test_diffusion_deploy_fields_can_be_overridden_by_cli(self, tmp_path):
|
||||
deploy_path = tmp_path / "diffusion_stage.yaml"
|
||||
deploy_path.write_text(
|
||||
"""
|
||||
async_chunk: false
|
||||
stages:
|
||||
- stage_id: 0
|
||||
devices: "4,5"
|
||||
parallel_config:
|
||||
pipeline_parallel_size: 1
|
||||
data_parallel_size: 1
|
||||
tensor_parallel_size: 1
|
||||
enable_expert_parallel: false
|
||||
sequence_parallel_size: 1
|
||||
ulysses_degree: 1
|
||||
ring_degree: 1
|
||||
cfg_parallel_size: 1
|
||||
vae_patch_parallel_size: 1
|
||||
use_hsdp: false
|
||||
hsdp_shard_size: -1
|
||||
hsdp_replicate_size: 1
|
||||
engine_args:
|
||||
cache_backend: cache_dit
|
||||
diffusion_attention_backend: FLASH_ATTN
|
||||
diffusion_kv_cache_dtype: auto
|
||||
step_execution: false
|
||||
vae_use_tiling: false
|
||||
enable_cpu_offload: false
|
||||
max_generated_image_size: 1048576
|
||||
tts_max_instructions_length: 1000
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
pipeline = PipelineConfig(
|
||||
model_type="test_diffusion",
|
||||
stages=(
|
||||
StagePipelineConfig(
|
||||
stage_id=0,
|
||||
model_stage="dit",
|
||||
execution_type=StageExecutionType.DIFFUSION,
|
||||
final_output=True,
|
||||
final_output_type="image",
|
||||
),
|
||||
),
|
||||
)
|
||||
deploy = load_deploy_config(deploy_path)
|
||||
stages = merge_pipeline_deploy(pipeline, deploy)
|
||||
stage = stages[0]
|
||||
|
||||
assert stage.yaml_engine_args["parallel_config"]["pipeline_parallel_size"] == 1
|
||||
assert stage.yaml_engine_args["parallel_config"]["data_parallel_size"] == 1
|
||||
assert stage.yaml_engine_args["parallel_config"]["tensor_parallel_size"] == 1
|
||||
assert stage.yaml_engine_args["parallel_config"]["enable_expert_parallel"] is False
|
||||
assert stage.yaml_engine_args["parallel_config"]["sequence_parallel_size"] == 1
|
||||
assert stage.yaml_engine_args["parallel_config"]["ulysses_degree"] == 1
|
||||
assert stage.yaml_engine_args["parallel_config"]["ring_degree"] == 1
|
||||
assert stage.yaml_engine_args["parallel_config"]["cfg_parallel_size"] == 1
|
||||
assert stage.yaml_engine_args["parallel_config"]["vae_patch_parallel_size"] == 1
|
||||
assert stage.yaml_engine_args["cache_backend"] == "cache_dit"
|
||||
assert stage.yaml_engine_args["parallel_config"]["use_hsdp"] is False
|
||||
assert stage.yaml_engine_args["parallel_config"]["hsdp_shard_size"] == -1
|
||||
assert stage.yaml_engine_args["parallel_config"]["hsdp_replicate_size"] == 1
|
||||
assert stage.yaml_engine_args["diffusion_attention_backend"] == "FLASH_ATTN"
|
||||
assert stage.yaml_engine_args["diffusion_kv_cache_dtype"] == "auto"
|
||||
assert stage.yaml_engine_args["step_execution"] is False
|
||||
assert stage.yaml_engine_args["vae_use_tiling"] is False
|
||||
assert stage.yaml_engine_args["enable_cpu_offload"] is False
|
||||
assert stage.yaml_engine_args["max_generated_image_size"] == 1048576
|
||||
assert stage.yaml_engine_args["tts_max_instructions_length"] == 1000
|
||||
|
||||
stage.runtime_overrides = StageConfigFactory._merge_cli_overrides(
|
||||
stage,
|
||||
{
|
||||
"pipeline_parallel_size": 2,
|
||||
"data_parallel_size": 3,
|
||||
"tensor_parallel_size": 4,
|
||||
"enable_expert_parallel": True,
|
||||
"sequence_parallel_size": 24,
|
||||
"ulysses_degree": 2,
|
||||
"ring_degree": 4,
|
||||
"cfg_parallel_size": 2,
|
||||
"vae_patch_parallel_size": 2,
|
||||
"cache_backend": "tea_cache",
|
||||
"use_hsdp": True,
|
||||
"hsdp_shard_size": 8,
|
||||
"hsdp_replicate_size": 2,
|
||||
"diffusion_attention_backend": "SAGE_ATTN",
|
||||
"diffusion_kv_cache_dtype": "fp8",
|
||||
"step_execution": True,
|
||||
"vae_use_tiling": True,
|
||||
"enable_cpu_offload": True,
|
||||
"max_generated_image_size": 2097152,
|
||||
"tts_max_instructions_length": 2000,
|
||||
},
|
||||
)
|
||||
|
||||
omega_config = stage.to_omegaconf()
|
||||
|
||||
assert omega_config.engine_args.cache_backend == "tea_cache"
|
||||
assert omega_config.engine_args.diffusion_attention_backend == "SAGE_ATTN"
|
||||
assert omega_config.engine_args.diffusion_kv_cache_dtype == "fp8"
|
||||
assert omega_config.engine_args.step_execution is True
|
||||
assert omega_config.engine_args.vae_use_tiling is True
|
||||
assert omega_config.engine_args.enable_cpu_offload is True
|
||||
assert omega_config.engine_args.max_generated_image_size == 2097152
|
||||
assert omega_config.engine_args.tts_max_instructions_length == 2000
|
||||
assert omega_config.engine_args.parallel_config.pipeline_parallel_size == 2
|
||||
assert omega_config.engine_args.parallel_config.data_parallel_size == 3
|
||||
assert omega_config.engine_args.parallel_config.tensor_parallel_size == 4
|
||||
assert omega_config.engine_args.parallel_config.enable_expert_parallel is True
|
||||
assert omega_config.engine_args.parallel_config.sequence_parallel_size == 24
|
||||
assert omega_config.engine_args.parallel_config.ulysses_degree == 2
|
||||
assert omega_config.engine_args.parallel_config.ring_degree == 4
|
||||
assert omega_config.engine_args.parallel_config.cfg_parallel_size == 2
|
||||
assert omega_config.engine_args.parallel_config.vae_patch_parallel_size == 2
|
||||
assert omega_config.engine_args.parallel_config.use_hsdp is True
|
||||
assert omega_config.engine_args.parallel_config.hsdp_shard_size == 8
|
||||
assert omega_config.engine_args.parallel_config.hsdp_replicate_size == 2
|
||||
|
||||
def test_llm_deploy_fields_can_be_overridden_by_cli(self, tmp_path):
|
||||
deploy_path = tmp_path / "llm_stage.yaml"
|
||||
deploy_path.write_text(
|
||||
"""
|
||||
async_chunk: false
|
||||
stages:
|
||||
- stage_id: 0
|
||||
devices: "0,1"
|
||||
engine_args:
|
||||
tensor_parallel_size: 1
|
||||
enable_expert_parallel: false
|
||||
gpu_memory_utilization: 0.5
|
||||
max_num_seqs: 16
|
||||
max_num_batched_tokens: 1024
|
||||
max_model_len: 4096
|
||||
enforce_eager: false
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
pipeline = PipelineConfig(
|
||||
model_type="test_llm",
|
||||
stages=(
|
||||
StagePipelineConfig(
|
||||
stage_id=0,
|
||||
model_stage="thinker",
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
final_output=True,
|
||||
final_output_type="text",
|
||||
),
|
||||
),
|
||||
)
|
||||
deploy = load_deploy_config(deploy_path)
|
||||
stages = merge_pipeline_deploy(pipeline, deploy)
|
||||
stage = stages[0]
|
||||
|
||||
assert stage.yaml_engine_args["tensor_parallel_size"] == 1
|
||||
assert stage.yaml_engine_args["enable_expert_parallel"] is False
|
||||
assert stage.yaml_engine_args["gpu_memory_utilization"] == 0.5
|
||||
assert stage.yaml_engine_args["max_num_seqs"] == 16
|
||||
assert stage.yaml_engine_args["max_num_batched_tokens"] == 1024
|
||||
assert stage.yaml_engine_args["max_model_len"] == 4096
|
||||
assert stage.yaml_engine_args["enforce_eager"] is False
|
||||
|
||||
stage.runtime_overrides = StageConfigFactory._merge_cli_overrides(
|
||||
stage,
|
||||
{
|
||||
"tensor_parallel_size": 2,
|
||||
"enable_expert_parallel": True,
|
||||
"gpu_memory_utilization": 0.9,
|
||||
"max_num_seqs": 32,
|
||||
"max_num_batched_tokens": 2048,
|
||||
"max_model_len": 8192,
|
||||
"enforce_eager": True,
|
||||
},
|
||||
)
|
||||
|
||||
omega_config = stage.to_omegaconf()
|
||||
|
||||
assert omega_config.engine_args.tensor_parallel_size == 2
|
||||
assert omega_config.engine_args.enable_expert_parallel is True
|
||||
assert omega_config.engine_args.gpu_memory_utilization == 0.9
|
||||
assert omega_config.engine_args.max_num_seqs == 32
|
||||
assert omega_config.engine_args.max_num_batched_tokens == 2048
|
||||
assert omega_config.engine_args.max_model_len == 8192
|
||||
assert omega_config.engine_args.enforce_eager is True
|
||||
|
||||
|
||||
class TestSentinelDefaultPrecedence:
|
||||
"""Caller-typed (non-None) values win over YAML; None values fall through
|
||||
to YAML / dataclass defaults (#3035)."""
|
||||
|
||||
@@ -16,7 +16,12 @@ from vllm_omni.config.stage_config import (
|
||||
StageDeployConfig,
|
||||
StagePipelineConfig,
|
||||
)
|
||||
from vllm_omni.utils.tracking_parser import TrackingArgumentParser, TrackingNamespace
|
||||
from vllm_omni.utils.tracking_parser import (
|
||||
UNSET,
|
||||
TrackingArgumentParser,
|
||||
TrackingNamespace,
|
||||
build_shadow_kwargs,
|
||||
)
|
||||
|
||||
### Fake pipeline/deploy config for integration tests
|
||||
|
||||
@@ -326,6 +331,132 @@ def test_omitted_nargs():
|
||||
assert ns.items is None
|
||||
|
||||
|
||||
### Tests for in-place mutating actions (append / extend / count).
|
||||
# These actions mutate their default in place, which would crash on the bare
|
||||
# ``UNSET`` sentinel. The shadow parser remaps them to a non-mutating store-style
|
||||
# action (see build_shadow_kwargs in tracking_parser), so the real namespace
|
||||
# still accumulates while explicit-arg tracking keeps working.
|
||||
def test_append_action_omitted():
|
||||
"""Omitted append args parse without error and aren't marked explicit."""
|
||||
p = TrackingArgumentParser()
|
||||
p.add_argument("--tag", action="append")
|
||||
ns = p.parse_args([])
|
||||
assert ns.explicit_keys == set()
|
||||
assert isinstance(ns, TrackingNamespace)
|
||||
assert ns.tag is None
|
||||
|
||||
|
||||
def test_append_action_explicit():
|
||||
"""Repeated append args are collected and marked explicit."""
|
||||
p = TrackingArgumentParser()
|
||||
p.add_argument("--tag", action="append")
|
||||
ns = p.parse_args(["--tag", "a", "--tag", "b"])
|
||||
assert ns.explicit_keys == {"tag"}
|
||||
assert isinstance(ns, TrackingNamespace)
|
||||
assert ns.tag == ["a", "b"]
|
||||
|
||||
|
||||
def test_append_const_action_omitted():
|
||||
"""Omitted append_const args aren't marked explicit."""
|
||||
p = TrackingArgumentParser()
|
||||
p.add_argument("--dbg", action="append_const", const="x", dest="flags")
|
||||
ns = p.parse_args([])
|
||||
assert ns.explicit_keys == set()
|
||||
assert isinstance(ns, TrackingNamespace)
|
||||
assert ns.flags is None
|
||||
|
||||
|
||||
def test_append_const_action_explicit():
|
||||
"""Repeated append_const args accumulate the const and are explicit."""
|
||||
p = TrackingArgumentParser()
|
||||
p.add_argument("--dbg", action="append_const", const="x", dest="flags")
|
||||
ns = p.parse_args(["--dbg", "--dbg"])
|
||||
assert ns.explicit_keys == {"flags"}
|
||||
assert isinstance(ns, TrackingNamespace)
|
||||
assert ns.flags == ["x", "x"]
|
||||
|
||||
|
||||
def test_extend_action_omitted():
|
||||
"""Omitted extend args parse without error and aren't marked explicit."""
|
||||
p = TrackingArgumentParser()
|
||||
p.add_argument("--items", action="extend", nargs="+")
|
||||
ns = p.parse_args([])
|
||||
assert ns.explicit_keys == set()
|
||||
assert isinstance(ns, TrackingNamespace)
|
||||
assert ns.items is None
|
||||
|
||||
|
||||
def test_extend_action_explicit():
|
||||
"""Repeated extend args are flattened into one list and marked explicit."""
|
||||
p = TrackingArgumentParser()
|
||||
p.add_argument("--items", action="extend", nargs="+")
|
||||
ns = p.parse_args(["--items", "a", "b", "--items", "c"])
|
||||
assert ns.explicit_keys == {"items"}
|
||||
assert isinstance(ns, TrackingNamespace)
|
||||
assert ns.items == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_count_action_omitted():
|
||||
"""Omitted count args keep their default and aren't marked explicit."""
|
||||
p = TrackingArgumentParser()
|
||||
p.add_argument("-v", "--verbose", action="count", default=0)
|
||||
ns = p.parse_args([])
|
||||
assert ns.explicit_keys == set()
|
||||
assert isinstance(ns, TrackingNamespace)
|
||||
assert ns.verbose == 0
|
||||
|
||||
|
||||
def test_count_action_explicit():
|
||||
"""Repeated count flags increment and are marked explicit."""
|
||||
p = TrackingArgumentParser()
|
||||
p.add_argument("-v", "--verbose", action="count", default=0)
|
||||
ns = p.parse_args(["-vv"])
|
||||
assert ns.explicit_keys == {"verbose"}
|
||||
assert isinstance(ns, TrackingNamespace)
|
||||
assert ns.verbose == 2
|
||||
|
||||
|
||||
def test_group_append_action_explicit():
|
||||
"""append args added via a group go through the same shadow-default path."""
|
||||
p = TrackingArgumentParser()
|
||||
g = p.add_argument_group("TestGroup")
|
||||
g.add_argument("--tag", action="append")
|
||||
ns = p.parse_args(["--tag", "a", "--tag", "b"])
|
||||
assert ns.explicit_keys == {"tag"}
|
||||
assert isinstance(ns, TrackingNamespace)
|
||||
assert ns.tag == ["a", "b"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("action", "expected"),
|
||||
[
|
||||
("append", "store"),
|
||||
("extend", "store"),
|
||||
("append_const", "store_const"),
|
||||
("count", "store_const"),
|
||||
],
|
||||
)
|
||||
def test_build_shadow_kwargs_remaps_mutating_actions(action, expected):
|
||||
"""Mutating actions are remapped to a store-style action with an UNSET default."""
|
||||
shadow = build_shadow_kwargs({"action": action, "const": "c"})
|
||||
assert shadow["action"] == expected
|
||||
assert shadow["default"] is UNSET
|
||||
|
||||
|
||||
def test_build_shadow_kwargs_count_gets_marker_const():
|
||||
"""count carries no const, so the remapped store_const gets a non-UNSET marker."""
|
||||
shadow = build_shadow_kwargs({"action": "count"})
|
||||
assert shadow["action"] == "store_const"
|
||||
assert shadow["const"] is not UNSET
|
||||
|
||||
|
||||
def test_build_shadow_kwargs_leaves_other_actions_untouched():
|
||||
"""Non-mutating actions keep their action and only get the UNSET default."""
|
||||
shadow = build_shadow_kwargs({"action": "store_true"})
|
||||
assert shadow["action"] == "store_true"
|
||||
assert shadow["default"] is UNSET
|
||||
|
||||
|
||||
def test_parse_known_args_tracking():
|
||||
"""Ensure parse_known_args is also trackable"""
|
||||
p = TrackingArgumentParser()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections.abc import Sequence
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
@@ -10,6 +11,7 @@ from vllm_omni.worker.gpu_ar_model_runner import (
|
||||
GPUARModelRunner,
|
||||
OmniAsyncGPUModelRunnerOutput,
|
||||
)
|
||||
from vllm_omni.worker.runner_assisted_metadata import RunnerAssistedFullAttentionMetadataRequest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -58,6 +60,101 @@ def test_sparse_mm_req_ids_requires_sparse_audio_marker():
|
||||
assert GPUARModelRunner._sparse_mm_req_ids({"meta.req_id": ["r1"], "meta.sparse_audio": ["1"]}) == ["r1"]
|
||||
|
||||
|
||||
def test_runner_assisted_full_attention_metadata_request_is_opt_in():
|
||||
runner = object.__new__(GPUARModelRunner)
|
||||
runner.model = object()
|
||||
runner.scheduler_config = SimpleNamespace(max_num_seqs=16)
|
||||
|
||||
request = runner._get_runner_assisted_full_attention_metadata_request(
|
||||
req_ids=["r1", "r2"],
|
||||
num_reqs=2,
|
||||
num_reqs_padded=4,
|
||||
num_scheduled_tokens_np=np.array([1, 1], dtype=np.int32),
|
||||
num_computed_tokens_cpu=np.array([5, 6], dtype=np.int32),
|
||||
max_num_scheduled_tokens=1,
|
||||
)
|
||||
|
||||
assert request is None
|
||||
|
||||
|
||||
def test_runner_assisted_full_attention_metadata_request_and_context_hooks():
|
||||
calls = []
|
||||
|
||||
class Model:
|
||||
def get_runner_assisted_full_attention_metadata_request(
|
||||
self,
|
||||
*,
|
||||
req_ids: Sequence[str],
|
||||
num_reqs: int,
|
||||
num_scheduled_tokens: Sequence[int],
|
||||
num_computed_tokens: Sequence[int],
|
||||
max_num_scheduled_tokens: int,
|
||||
) -> RunnerAssistedFullAttentionMetadataRequest:
|
||||
calls.append(
|
||||
(
|
||||
"request",
|
||||
{
|
||||
"req_ids": list(req_ids),
|
||||
"num_reqs": num_reqs,
|
||||
"num_scheduled_tokens": [int(n) for n in num_scheduled_tokens],
|
||||
"num_computed_tokens": [int(n) for n in num_computed_tokens],
|
||||
"max_num_scheduled_tokens": max_num_scheduled_tokens,
|
||||
},
|
||||
)
|
||||
)
|
||||
return RunnerAssistedFullAttentionMetadataRequest(
|
||||
num_reqs_padded=12,
|
||||
for_cudagraph_capture=True,
|
||||
)
|
||||
|
||||
def set_runner_assisted_full_attention_metadata_context(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
num_reqs: int = 0,
|
||||
) -> None:
|
||||
calls.append(("context", {"enabled": enabled, "num_reqs": num_reqs}))
|
||||
|
||||
runner = object.__new__(GPUARModelRunner)
|
||||
runner.model = Model()
|
||||
runner.scheduler_config = SimpleNamespace(max_num_seqs=8)
|
||||
|
||||
request = runner._get_runner_assisted_full_attention_metadata_request(
|
||||
req_ids=["r1", "r2"],
|
||||
num_reqs=2,
|
||||
num_reqs_padded=4,
|
||||
num_scheduled_tokens_np=np.array([1, 1], dtype=np.int32),
|
||||
num_computed_tokens_cpu=np.array([5, 6], dtype=np.int32),
|
||||
max_num_scheduled_tokens=1,
|
||||
)
|
||||
context_enabled = runner._set_runner_assisted_full_attention_metadata_context(
|
||||
enabled=True,
|
||||
num_reqs=2,
|
||||
)
|
||||
context_disabled = runner._set_runner_assisted_full_attention_metadata_context(enabled=False)
|
||||
|
||||
assert request == RunnerAssistedFullAttentionMetadataRequest(
|
||||
num_reqs_padded=8,
|
||||
for_cudagraph_capture=True,
|
||||
)
|
||||
assert context_enabled
|
||||
assert context_disabled
|
||||
assert calls == [
|
||||
(
|
||||
"request",
|
||||
{
|
||||
"req_ids": ["r1", "r2"],
|
||||
"num_reqs": 2,
|
||||
"num_scheduled_tokens": [1, 1],
|
||||
"num_computed_tokens": [5, 6],
|
||||
"max_num_scheduled_tokens": 1,
|
||||
},
|
||||
),
|
||||
("context", {"enabled": True, "num_reqs": 2}),
|
||||
("context", {"enabled": False, "num_reqs": 0}),
|
||||
]
|
||||
|
||||
|
||||
def test_omni_async_gpu_model_runner_output_builds_lazily_once():
|
||||
async_output = object.__new__(OmniAsyncGPUModelRunnerOutput)
|
||||
calls = []
|
||||
@@ -169,7 +266,6 @@ def test_build_omni_output_uses_snapshots_and_connector_after_accumulation(monke
|
||||
ec_connector_output=None,
|
||||
cudagraph_stats=None,
|
||||
kv_extracted_req_ids=["r2"],
|
||||
seq_len=3,
|
||||
num_scheduled_tokens_np=torch.tensor([1, 2], dtype=torch.int32).numpy(),
|
||||
query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.long),
|
||||
)
|
||||
@@ -215,7 +311,6 @@ def test_build_omni_output_copies_hidden_for_partial_downstream_batch(monkeypatc
|
||||
ec_connector_output=None,
|
||||
cudagraph_stats=None,
|
||||
kv_extracted_req_ids=None,
|
||||
seq_len=6,
|
||||
num_scheduled_tokens_np=np.array([1, 2, 3], dtype=np.int32),
|
||||
query_start_loc_cpu=torch.tensor([0, 1, 3], dtype=torch.long),
|
||||
)
|
||||
@@ -269,7 +364,6 @@ def test_process_additional_information_uses_snapshot_request_order(monkeypatch)
|
||||
ec_connector_output=None,
|
||||
cudagraph_stats=None,
|
||||
kv_extracted_req_ids=None,
|
||||
seq_len=3,
|
||||
num_scheduled_tokens_np=torch.tensor([1, 2], dtype=torch.int32).numpy(),
|
||||
query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.long),
|
||||
)
|
||||
@@ -330,7 +424,6 @@ def test_build_omni_output_skips_hidden_when_model_opts_out(monkeypatch):
|
||||
ec_connector_output=None,
|
||||
cudagraph_stats=None,
|
||||
kv_extracted_req_ids=None,
|
||||
seq_len=2,
|
||||
num_scheduled_tokens_np=np.array([2], dtype=np.int32),
|
||||
query_start_loc_cpu=torch.tensor([0], dtype=torch.long),
|
||||
)
|
||||
@@ -342,6 +435,51 @@ def test_build_omni_output_skips_hidden_when_model_opts_out(monkeypatch):
|
||||
assert output.multimodal_outputs is None
|
||||
|
||||
|
||||
def test_build_omni_output_splits_mm_by_scheduled_tokens_when_hidden_is_tail_only(monkeypatch):
|
||||
runner = _make_async_output_runner(engine_output_type="latent")
|
||||
runner.model.omni_pooler_payload_include_hidden = False
|
||||
runner._async_chunk = False
|
||||
runner.requests = {"r1": object(), "r2": object(), "r3": object()}
|
||||
|
||||
monkeypatch.setattr(
|
||||
GPUARModelRunner,
|
||||
"_resolve_pooler_payload_req_ids",
|
||||
lambda self, req_ids: ("latent", req_ids),
|
||||
)
|
||||
monkeypatch.setattr(GPUARModelRunner, "_should_accumulate_full_payload_output", lambda self: False)
|
||||
monkeypatch.setattr(GPUARModelRunner, "get_omni_connector_output", lambda self: None)
|
||||
monkeypatch.setattr(GPUARModelRunner, "_process_additional_information_updates", lambda *args, **kwargs: None)
|
||||
|
||||
codes = torch.arange(48, dtype=torch.long).reshape(3, 16)
|
||||
output = GPUARModelRunner._build_omni_model_runner_output_from_snapshot(
|
||||
runner,
|
||||
scheduler_output=SimpleNamespace(
|
||||
total_num_scheduled_tokens=3,
|
||||
num_scheduled_tokens={"r1": 1, "r2": 1, "r3": 1},
|
||||
),
|
||||
hidden_states=torch.tensor([[1.0]]),
|
||||
staged_hidden_states_cpu=None,
|
||||
multimodal_outputs={"codes": {"audio": codes}},
|
||||
req_ids_output_copy=["r1", "r2", "r3"],
|
||||
req_id_to_index_output_copy={"r1": 0, "r2": 1, "r3": 2},
|
||||
valid_sampled_token_ids=[[101], [102], [103]],
|
||||
logprobs_lists=None,
|
||||
prompt_logprobs_dict={},
|
||||
num_nans_in_logits=None,
|
||||
kv_connector_output=None,
|
||||
ec_connector_output=None,
|
||||
cudagraph_stats=None,
|
||||
kv_extracted_req_ids=None,
|
||||
num_scheduled_tokens_np=np.array([1, 1, 1], dtype=np.int32),
|
||||
query_start_loc_cpu=torch.tensor([0, 1, 2], dtype=torch.long),
|
||||
)
|
||||
|
||||
assert output.inter_stage_outputs is not None
|
||||
assert torch.equal(output.inter_stage_outputs[0]["codes.audio"], codes[0:1])
|
||||
assert torch.equal(output.inter_stage_outputs[1]["codes.audio"], codes[1:2])
|
||||
assert torch.equal(output.inter_stage_outputs[2]["codes.audio"], codes[2:3])
|
||||
|
||||
|
||||
def test_async_snapshot_payload_omits_hidden_when_model_opts_out():
|
||||
runner = _make_async_output_runner()
|
||||
runner.model.omni_pooler_payload_include_hidden = False
|
||||
@@ -357,6 +495,47 @@ def test_async_snapshot_payload_omits_hidden_when_model_opts_out():
|
||||
assert payload["multimodal_outputs"]["codes"]["audio"].tolist() == [[1]]
|
||||
|
||||
|
||||
def test_runner_assisted_full_attention_metadata_refresh_pads_buffers():
|
||||
class QueryStartLoc:
|
||||
def __init__(self):
|
||||
self.np = np.full(5, -1, dtype=np.int32)
|
||||
self.copied = False
|
||||
|
||||
def copy_to_gpu(self):
|
||||
self.copied = True
|
||||
|
||||
class BlockTable:
|
||||
def __init__(self):
|
||||
self.commits = []
|
||||
|
||||
def commit_block_table(self, num_reqs_padded):
|
||||
self.commits.append(num_reqs_padded)
|
||||
|
||||
runner = object.__new__(GPUARModelRunner)
|
||||
block_table = BlockTable()
|
||||
runner.input_batch = SimpleNamespace(
|
||||
num_computed_tokens_cpu=torch.tensor([10, 20, 99, 99], dtype=torch.int32),
|
||||
block_table=block_table,
|
||||
)
|
||||
runner.optimistic_seq_lens_cpu = torch.zeros(4, dtype=torch.int32)
|
||||
runner.seq_lens = torch.empty(4, dtype=torch.int32)
|
||||
runner.query_pos = SimpleNamespace(np=np.empty(4, dtype=np.int32))
|
||||
runner.query_start_loc = QueryStartLoc()
|
||||
runner._get_cumsum_and_arange = lambda scheduled, _query_pos: np.cumsum(scheduled, dtype=np.int32)
|
||||
|
||||
runner._refresh_runner_assisted_full_attention_metadata_buffers(
|
||||
num_reqs=2,
|
||||
num_reqs_padded=4,
|
||||
num_scheduled_tokens_np=np.array([1, 2], dtype=np.int32),
|
||||
)
|
||||
|
||||
assert runner.optimistic_seq_lens_cpu.tolist() == [11, 22, 0, 0]
|
||||
assert runner.seq_lens.tolist() == [11, 22, 0, 0]
|
||||
assert runner.query_start_loc.np.tolist() == [0, 1, 3, 3, 3]
|
||||
assert runner.query_start_loc.copied
|
||||
assert block_table.commits == [4]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query_start_loc_attr", ["method", "tensor_attr"])
|
||||
def test_sample_tokens_tail_only_prefix_cache_uses_staged_cpu_hidden_states(monkeypatch, query_start_loc_attr):
|
||||
runner = object.__new__(GPUARModelRunner)
|
||||
@@ -489,7 +668,6 @@ def test_build_omni_output_falls_back_to_mm_cpu_without_prefix_merge(monkeypatch
|
||||
ec_connector_output=None,
|
||||
cudagraph_stats=None,
|
||||
kv_extracted_req_ids=None,
|
||||
seq_len=2,
|
||||
num_scheduled_tokens_np=np.array([1, 1], dtype=np.int32),
|
||||
query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.long),
|
||||
)
|
||||
|
||||
@@ -275,7 +275,7 @@ def test_talker_mtp_forward_passes_qwen3_tts_subtalker_sampling_params_to_talker
|
||||
runner = _make_runner(req_ids=("r1",), hidden_size=4)
|
||||
runner.requests["r1"].sampling_params = SimpleNamespace(
|
||||
seed=42,
|
||||
extra_args={"qwen3_tts_request_seed": 42},
|
||||
extra_args={"tts_local_seed": 42},
|
||||
)
|
||||
runner.talker_mtp = CaptureTalkerMTP()
|
||||
runner.vllm_config = SimpleNamespace(
|
||||
@@ -319,11 +319,11 @@ def test_talker_mtp_forward_keeps_explicit_seeded_requests_scalar(monkeypatch):
|
||||
runner = _make_runner(req_ids=("r1", "r2"), hidden_size=4)
|
||||
runner.requests["r1"].sampling_params = SimpleNamespace(
|
||||
seed=11,
|
||||
extra_args={"qwen3_tts_request_seed": 11},
|
||||
extra_args={"tts_local_seed": 11},
|
||||
)
|
||||
runner.requests["r2"].sampling_params = SimpleNamespace(
|
||||
seed=22,
|
||||
extra_args={"qwen3_tts_request_seed": 22},
|
||||
extra_args={"tts_local_seed": 22},
|
||||
)
|
||||
runner.talker_mtp = CaptureTalkerMTP()
|
||||
runner.vllm_config = SimpleNamespace(model_config=SimpleNamespace(subtalker_sampling_params={}))
|
||||
|
||||
@@ -36,7 +36,11 @@ Enable with ``SEED_TTS_WER_EVAL=1`` or ``--seed-tts-wer-eval``. Install optional
|
||||
pip install 'vllm-omni[dev]'
|
||||
|
||||
Env: ``SEED_TTS_EVAL_DEVICE`` (e.g. ``cuda:0``, ``cpu``); ``SEED_TTS_HF_WHISPER_MODEL``
|
||||
defaults to ``openai/whisper-large-v3`` (override for debugging only).
|
||||
defaults to ``openai/whisper-large-v3`` (override for debugging only). Set
|
||||
``SEED_TTS_WER_SAVE_AUDIO_DIR`` to save the captured 24 kHz mono WAV used by
|
||||
WER evaluation for each synthesized utterance.
|
||||
Streaming PCM is decoded using ``VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE`` /
|
||||
``VLLM_OMNI_BENCH_AUDIO_CHANNELS`` (default: 24 kHz mono).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -50,12 +54,14 @@ import string
|
||||
import tempfile
|
||||
import threading
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from vllm.benchmarks.datasets import SampleRequest
|
||||
|
||||
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import SeedTTSSampleRequest
|
||||
from vllm_omni.metrics.definitions import stream_pcm_format_from_env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -87,6 +93,30 @@ def pcm_s16le_mono_to_wav_bytes(pcm: bytes, *, sample_rate: int = 24000) -> byte
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _safe_filename_part(value: Any, *, default: str = "item", max_len: int = 96) -> str:
|
||||
text = str(value or "").strip() or default
|
||||
safe = "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in text)
|
||||
safe = safe.strip("._") or default
|
||||
return safe[:max_len]
|
||||
|
||||
|
||||
def _save_seed_tts_eval_audio(
|
||||
pcm: bytes,
|
||||
*,
|
||||
output_dir: Path | None,
|
||||
index: int,
|
||||
utterance_id: Any,
|
||||
locale: str,
|
||||
) -> str | None:
|
||||
if output_dir is None:
|
||||
return None
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{index:05d}_{_safe_filename_part(utterance_id)}_{_safe_filename_part(locale)}"
|
||||
path = output_dir / f"{stem}.wav"
|
||||
path.write_bytes(pcm_s16le_mono_to_wav_bytes(pcm, sample_rate=24000))
|
||||
return str(path)
|
||||
|
||||
|
||||
def _get_eval_device() -> str:
|
||||
explicit = os.environ.get("SEED_TTS_EVAL_DEVICE", "").strip()
|
||||
if explicit:
|
||||
@@ -146,12 +176,27 @@ def process_one_official(hypo: str, truth: str, lang: str) -> tuple[float, str,
|
||||
return wer, raw_truth, raw_hypo
|
||||
|
||||
|
||||
def _pcm_s16le_to_f32_16k(pcm: bytes, pcm_sample_rate: int = 24000) -> np.ndarray:
|
||||
def _pcm_s16le_to_f32_16k(
|
||||
pcm: bytes,
|
||||
pcm_sample_rate: int | None = None,
|
||||
channels: int | None = None,
|
||||
) -> np.ndarray:
|
||||
import scipy.signal
|
||||
|
||||
if not pcm:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
if pcm_sample_rate is None or channels is None:
|
||||
env_sample_rate, env_channels = stream_pcm_format_from_env()
|
||||
if pcm_sample_rate is None:
|
||||
pcm_sample_rate = env_sample_rate
|
||||
if channels is None:
|
||||
channels = env_channels
|
||||
raw = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
if channels > 1:
|
||||
usable = (len(raw) // channels) * channels
|
||||
if usable <= 0:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
raw = raw[:usable].reshape(-1, channels).mean(axis=1)
|
||||
target_len = int(len(raw) * 16000 / pcm_sample_rate)
|
||||
if target_len <= 0:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
@@ -512,13 +557,18 @@ def compute_seed_tts_wer_metrics(
|
||||
sim_skipped_no_ref = 0
|
||||
utmos_failed = 0
|
||||
utmos_on = _eval_submetric_enabled("SEED_TTS_UTMOS_EVAL", default=False)
|
||||
save_audio_raw = os.environ.get("SEED_TTS_WER_SAVE_AUDIO_DIR", "").strip()
|
||||
save_audio_dir = Path(save_audio_raw).expanduser() if save_audio_raw else None
|
||||
saved_audio = 0
|
||||
save_audio_failed = 0
|
||||
|
||||
for req, out in zip(input_requests, outputs, strict=True):
|
||||
for index, (req, out) in enumerate(zip(input_requests, outputs, strict=True)):
|
||||
assert isinstance(req, SeedTTSSampleRequest)
|
||||
ref = req.prompt
|
||||
locale = req.seed_tts_locale or "en"
|
||||
row_lang = "zh" if locale.lower().startswith("zh") else "en"
|
||||
utmos_v: float | None = None
|
||||
audio_path: str | None = None
|
||||
|
||||
if not out.success:
|
||||
request_failed += 1
|
||||
@@ -545,6 +595,23 @@ def compute_seed_tts_wer_metrics(
|
||||
}
|
||||
)
|
||||
continue
|
||||
try:
|
||||
audio_path = _save_seed_tts_eval_audio(
|
||||
pcm,
|
||||
output_dir=save_audio_dir,
|
||||
index=index,
|
||||
utterance_id=req.seed_tts_utterance_id,
|
||||
locale=locale,
|
||||
)
|
||||
if audio_path:
|
||||
saved_audio += 1
|
||||
except OSError as e:
|
||||
save_audio_failed += 1
|
||||
logger.warning(
|
||||
"Seed-TTS WER audio save failed for utterance=%s: %s",
|
||||
req.seed_tts_utterance_id,
|
||||
e,
|
||||
)
|
||||
|
||||
wav_16k = _pcm_s16le_to_f32_16k(pcm)
|
||||
if len(wav_16k) == 0:
|
||||
@@ -555,6 +622,7 @@ def compute_seed_tts_wer_metrics(
|
||||
"utterance_id": req.seed_tts_utterance_id,
|
||||
"locale": locale,
|
||||
"error": "empty_audio",
|
||||
"audio_path": audio_path,
|
||||
}
|
||||
)
|
||||
continue
|
||||
@@ -609,6 +677,7 @@ def compute_seed_tts_wer_metrics(
|
||||
"locale": locale,
|
||||
"error": "asr_exception",
|
||||
"detail": str(e)[:500],
|
||||
"audio_path": audio_path,
|
||||
}
|
||||
)
|
||||
continue
|
||||
@@ -621,6 +690,7 @@ def compute_seed_tts_wer_metrics(
|
||||
"utterance_id": req.seed_tts_utterance_id,
|
||||
"locale": locale,
|
||||
"error": "empty_asr",
|
||||
"audio_path": audio_path,
|
||||
}
|
||||
)
|
||||
continue
|
||||
@@ -637,6 +707,7 @@ def compute_seed_tts_wer_metrics(
|
||||
"locale": locale,
|
||||
"error": "wer_compute_failed",
|
||||
"detail": str(e)[:500],
|
||||
"audio_path": audio_path,
|
||||
}
|
||||
)
|
||||
continue
|
||||
@@ -673,6 +744,8 @@ def compute_seed_tts_wer_metrics(
|
||||
"reference_raw": raw_truth,
|
||||
"asr_raw": raw_hypo,
|
||||
}
|
||||
if audio_path:
|
||||
row["audio_path"] = audio_path
|
||||
if sim_v is not None:
|
||||
row["sim"] = sim_v
|
||||
if utmos_v is not None:
|
||||
@@ -697,7 +770,11 @@ def compute_seed_tts_wer_metrics(
|
||||
"seed_tts_utmos_mean": statistics.fmean(utmos_values) if utmos_values else None,
|
||||
"seed_tts_utmos_median": statistics.median(utmos_values) if utmos_values else None,
|
||||
"seed_tts_utmos_failed": utmos_failed,
|
||||
"seed_tts_saved_audio": saved_audio,
|
||||
"seed_tts_save_audio_failed": save_audio_failed,
|
||||
}
|
||||
if save_audio_dir is not None:
|
||||
result["seed_tts_save_audio_dir"] = str(save_audio_dir)
|
||||
if include_per_item:
|
||||
result["seed_tts_wer_eval_items"] = items
|
||||
return result
|
||||
@@ -729,6 +806,13 @@ def print_seed_tts_wer_summary(metrics: dict[str, Any]) -> None:
|
||||
print("{:<40} {:<10}".format("Request failed:", metrics.get("seed_tts_request_failed", 0)))
|
||||
print("{:<40} {:<10}".format("No PCM captured:", metrics.get("seed_tts_no_pcm", 0)))
|
||||
print("{:<40} {:<10}".format("ASR / WER failed:", metrics.get("seed_tts_asr_failed", 0)))
|
||||
save_dir = metrics.get("seed_tts_save_audio_dir")
|
||||
if save_dir:
|
||||
print("{:<40} {:<10}".format("Saved eval WAVs:", metrics.get("seed_tts_saved_audio", 0)))
|
||||
print("{:<40} {}".format("Saved eval WAV dir:", save_dir))
|
||||
failed = int(metrics.get("seed_tts_save_audio_failed", 0) or 0)
|
||||
if failed:
|
||||
print("{:<40} {:<10}".format("Save eval WAV errors:", failed))
|
||||
if sim_ev or metrics.get("seed_tts_sim_skipped_no_ref") or metrics.get("seed_tts_sim_failed"):
|
||||
print("{:<40} {:<10}".format("SIM evaluated (higher ~ closer):", sim_ev))
|
||||
sm = metrics.get("seed_tts_sim_mean")
|
||||
|
||||
@@ -63,11 +63,9 @@ _STAGE_BENCHMARK_FIELDS = [
|
||||
(defs.VLLM_TPOTS, _FLOAT_LIST_TYPE, field(default_factory=list)),
|
||||
(defs.VLLM_ITLS, _FLOAT_LIST_TYPE, field(default_factory=list)),
|
||||
(defs.AUDIO_TTFPS, _FLOAT_LIST_TYPE, field(default_factory=list)),
|
||||
(defs.AUDIO_RTFS, _FLOAT_LIST_TYPE, field(default_factory=list)),
|
||||
(defs.AUDIO_DURATIONS, _FLOAT_LIST_TYPE, field(default_factory=list)),
|
||||
(defs.AUDIO_FRAMES, _INT_LIST_TYPE, field(default_factory=list)),
|
||||
(defs.MISSING_AUDIO_DURATION_COUNT, int, field(default=0)),
|
||||
(defs.MISSING_AUDIO_RTF_COUNT, int, field(default=0)),
|
||||
(defs.STAGE_GEN_TIMES_MS, _FLOAT_LIST_TYPE, field(default_factory=list)),
|
||||
(defs.POSTPROCESS_TIMES_MS, _FLOAT_LIST_TYPE, field(default_factory=list)),
|
||||
("output_unit_type", str, field(default="")),
|
||||
@@ -443,12 +441,6 @@ def _print_audio_stage_metrics(
|
||||
getattr(sm, defs.AUDIO_TTFPS),
|
||||
selected_percentiles,
|
||||
)
|
||||
if defs.AUDIO_RTF in selected_percentile_metrics:
|
||||
audio_rtfs = getattr(sm, defs.AUDIO_RTFS)
|
||||
if audio_rtfs:
|
||||
_print_percentile_metric("Real Time Factor", "AUDIO_RTF", audio_rtfs, selected_percentiles, to_ms=False)
|
||||
elif getattr(sm, defs.MISSING_AUDIO_RTF_COUNT) > 0:
|
||||
print("{:<40} {:<10}".format("AUDIO_RTF skipped:", "missing stage-local audio duration/sample rate"))
|
||||
if defs.AUDIO_DURATION in selected_percentile_metrics:
|
||||
audio_durations = getattr(sm, defs.AUDIO_DURATIONS)
|
||||
if audio_durations:
|
||||
@@ -643,11 +635,9 @@ def _build_stage_metrics_from_outputs(
|
||||
total_output = sum(int((info or {}).get(defs.NUM_TOKENS_OUT) or 0) for _, info in rows)
|
||||
|
||||
audio_ttfps: list[float] = []
|
||||
audio_rtfs: list[float] = []
|
||||
audio_durations: list[float] = []
|
||||
audio_frames: list[int] = []
|
||||
missing_audio_duration_count = 0
|
||||
missing_audio_rtf_count = 0
|
||||
if is_audio_stage:
|
||||
for _, info in rows:
|
||||
audio_ttfp_ms = float((info or {}).get(defs.SERVING_TIME_TO_FIRST_OUTPUT_MS) or 0.0)
|
||||
@@ -665,12 +655,6 @@ def _build_stage_metrics_from_outputs(
|
||||
else:
|
||||
missing_audio_duration_count += 1
|
||||
|
||||
rtf = float((info or {}).get(defs.AUDIO_RTF) or 0.0)
|
||||
if rtf > 0:
|
||||
audio_rtfs.append(rtf)
|
||||
else:
|
||||
missing_audio_rtf_count += 1
|
||||
|
||||
result.append(
|
||||
StageBenchmarkMetrics(
|
||||
**{
|
||||
@@ -685,11 +669,9 @@ def _build_stage_metrics_from_outputs(
|
||||
defs.VLLM_TPOTS: vllm_tpots,
|
||||
defs.VLLM_ITLS: vllm_itls,
|
||||
defs.AUDIO_TTFPS: audio_ttfps,
|
||||
defs.AUDIO_RTFS: audio_rtfs,
|
||||
defs.AUDIO_DURATIONS: audio_durations,
|
||||
defs.AUDIO_FRAMES: audio_frames,
|
||||
defs.MISSING_AUDIO_DURATION_COUNT: missing_audio_duration_count,
|
||||
defs.MISSING_AUDIO_RTF_COUNT: missing_audio_rtf_count,
|
||||
defs.STAGE_GEN_TIMES_MS: stage_gen_times_ms,
|
||||
defs.POSTPROCESS_TIMES_MS: postprocess_times_ms,
|
||||
"output_unit_type": output_unit_type,
|
||||
|
||||
@@ -35,8 +35,6 @@ from vllm.benchmarks.lib.endpoint_request_func import (
|
||||
from vllm.logger import init_logger
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
from vllm_omni.benchmarks.audio_continuity import compute_continuity_stats
|
||||
from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniDataset, DailyOmniSampleRequest
|
||||
from vllm_omni.benchmarks.data_modules.random_multi_modal_dataset import OmniRandomMultiModalDataset
|
||||
@@ -51,9 +49,9 @@ from vllm_omni.benchmarks.data_modules.sound_effect_dataset import SoundEffectDa
|
||||
from vllm_omni.benchmarks.data_modules.ttsd_dataset import TTSDDataset
|
||||
from vllm_omni.metrics import definitions as defs
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_AUDIO_CONTINUITY_THRESHOLD_ENV = "VLLM_OMNI_BENCH_AUDIO_CONTINUITY_THRESHOLD_S"
|
||||
_AUDIO_SAMPLE_RATE_ENV = "VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE"
|
||||
_AUDIO_CHANNELS_ENV = "VLLM_OMNI_BENCH_AUDIO_CHANNELS"
|
||||
RETURN_STAGE_METRICS_FIELD = "return_stage_metrics"
|
||||
_IMAGE_STAGE_METRICS_BACKENDS = frozenset({"openai-image-edits-omni"})
|
||||
_PRINT_STAGE = False
|
||||
@@ -111,30 +109,28 @@ def _audio_continuity_threshold_s() -> float:
|
||||
return max(value, 0.0)
|
||||
|
||||
|
||||
def _audio_pcm_format() -> tuple[int, int]:
|
||||
"""Return ``(sample_rate, channels)`` of the streamed PCM response.
|
||||
def _pcm_s16le_to_seed_tts_wer_bytes(
|
||||
pcm_bytes: bytes,
|
||||
*,
|
||||
sample_rate: int,
|
||||
channels: int,
|
||||
) -> bytes:
|
||||
"""Normalize streamed raw PCM to the 24 kHz mono PCM used by Seed-TTS WER."""
|
||||
if not pcm_bytes:
|
||||
return b""
|
||||
channels = max(1, int(channels))
|
||||
pcm = np.frombuffer(pcm_bytes, dtype=np.int16)
|
||||
if channels > 1:
|
||||
usable = (pcm.size // channels) * channels
|
||||
pcm = pcm[:usable].reshape(-1, channels).mean(axis=1)
|
||||
pcm_f32 = pcm.astype(np.float32) / 32767.0
|
||||
if int(sample_rate) != 24000 and pcm_f32.size:
|
||||
from vllm.multimodal.audio import AudioResampler
|
||||
|
||||
Defaults to 24 kHz mono (Qwen3-TTS / VoxCPM2 / most MOSS-TTS variants).
|
||||
Override for models with a different codec output format (e.g.
|
||||
MOSS-TTS-Local-Transformer-v1.5, which streams 48 kHz stereo PCM) via
|
||||
``VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE`` / ``VLLM_OMNI_BENCH_AUDIO_CHANNELS`` -
|
||||
getting this wrong silently skews audio_duration/audio_rtf/underrun stats.
|
||||
"""
|
||||
sample_rate = 24000
|
||||
channels = 1
|
||||
raw_sr = os.environ.get(_AUDIO_SAMPLE_RATE_ENV)
|
||||
if raw_sr:
|
||||
try:
|
||||
sample_rate = int(raw_sr)
|
||||
except ValueError:
|
||||
logger.warning("Invalid %s=%r; using default %d", _AUDIO_SAMPLE_RATE_ENV, raw_sr, sample_rate)
|
||||
raw_ch = os.environ.get(_AUDIO_CHANNELS_ENV)
|
||||
if raw_ch:
|
||||
try:
|
||||
channels = int(raw_ch)
|
||||
except ValueError:
|
||||
logger.warning("Invalid %s=%r; using default %d", _AUDIO_CHANNELS_ENV, raw_ch, channels)
|
||||
return sample_rate, channels
|
||||
resampler = AudioResampler(target_sr=24000)
|
||||
pcm_f32 = resampler.resample(pcm_f32, orig_sr=int(sample_rate))
|
||||
pcm_f32 = np.clip(pcm_f32, -1.0, 1.0)
|
||||
return (pcm_f32 * 32767).astype(np.int16).tobytes()
|
||||
|
||||
|
||||
get_samples_old = datasets.get_samples
|
||||
@@ -1163,9 +1159,8 @@ async def async_request_openai_audio_speech(
|
||||
output = MixRequestFuncOutput()
|
||||
output.prompt_len = request_func_input.prompt_len
|
||||
|
||||
# PCM format: 16-bit signed; sample_rate/channels are model-dependent
|
||||
# (see _audio_pcm_format docstring).
|
||||
sample_rate, channels = _audio_pcm_format()
|
||||
# PCM format: 16-bit signed; sample_rate/channels are model-dependent.
|
||||
sample_rate, channels = defs.stream_pcm_format_from_env()
|
||||
sample_width = 2 # 16-bit = 2 bytes
|
||||
|
||||
st = time.perf_counter()
|
||||
@@ -1216,7 +1211,15 @@ async def async_request_openai_audio_speech(
|
||||
output.audio_continuity_ok = continuity.is_continuous
|
||||
output.audio_underrun_event_count = continuity.underrun_event_count
|
||||
if pcm_capture is not None and pcm_capture:
|
||||
output.tts_output_pcm_bytes = bytes(pcm_capture)
|
||||
try:
|
||||
output.tts_output_pcm_bytes = _pcm_s16le_to_seed_tts_wer_bytes(
|
||||
bytes(pcm_capture),
|
||||
sample_rate=sample_rate,
|
||||
channels=channels,
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning("Seed-TTS WER PCM normalization failed: %s", ex)
|
||||
output.tts_output_pcm_bytes = bytes(pcm_capture)
|
||||
elif capture_wer_pcm:
|
||||
ct = response.headers.get("Content-Type", "")
|
||||
logger.warning(
|
||||
@@ -1261,7 +1264,7 @@ if "daily-omni" not in OPENAI_COMPATIBLE_BACKENDS:
|
||||
# Prevent import order from causing patch failures
|
||||
from vllm.benchmarks import serve
|
||||
from vllm.benchmarks.lib.ready_checker import wait_for_endpoint
|
||||
from vllm.benchmarks.serve import TaskType, _merge_overrides, calculate_metrics_for_embeddings, get_request
|
||||
from vllm.benchmarks.serve import TaskType, calculate_metrics_for_embeddings, get_request
|
||||
|
||||
from vllm_omni.benchmarks.metrics.metrics import (
|
||||
MultiModalsBenchmarkMetrics,
|
||||
@@ -1273,6 +1276,19 @@ from vllm_omni.benchmarks.metrics.metrics import (
|
||||
benchmark_old = serve.benchmark
|
||||
|
||||
|
||||
def _merge_overrides(base: dict | None, overrides: dict | None) -> dict | None:
|
||||
"""Merge benchmark extra_body with per-request overrides.
|
||||
|
||||
vLLM 0.24 removed the private helper from ``vllm.benchmarks.serve``.
|
||||
Keep the same shallow-merge behavior here, with request overrides winning.
|
||||
"""
|
||||
if not base and not overrides:
|
||||
return None
|
||||
merged = dict(base or {})
|
||||
merged.update(overrides or {})
|
||||
return merged
|
||||
|
||||
|
||||
async def benchmark(
|
||||
task_type: TaskType,
|
||||
endpoint_type: str,
|
||||
|
||||
@@ -63,7 +63,12 @@ def build_stage_runtime_overrides(
|
||||
if internal_keys is None:
|
||||
from vllm_omni.engine.arg_utils import SHARED_FIELDS, internal_blacklist_keys
|
||||
|
||||
internal_keys = internal_blacklist_keys() | SHARED_FIELDS
|
||||
# Some fields are modeled as orchestrator-owned for top-level CLI
|
||||
# parsing, but are also legitimate deploy-time stage overrides. Keep
|
||||
# the default blacklist for true orchestrator/shared fields while
|
||||
# allowing any field explicitly represented by the deploy schema to
|
||||
# continue flowing into per-stage overrides.
|
||||
internal_keys = (internal_blacklist_keys() | SHARED_FIELDS) - deploy_runtime_override_keys()
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
@@ -223,6 +228,7 @@ class StagePipelineConfig:
|
||||
prompt_expand_func: str | None = None
|
||||
cfg_kv_collect_func: str | None = None
|
||||
omni_kv_config: dict[str, Any] | None = None
|
||||
scheduler_cls: str | None = None
|
||||
# Model subdirectory indirections: for multi-component HF repos where the
|
||||
# stage's config/tokenizer lives in a subdirectory (e.g. GLM-Image's AR
|
||||
# config is in ``vision_language_encoder/``). Consumed at stage-init time
|
||||
@@ -303,7 +309,7 @@ class StageDeployConfig:
|
||||
the top level of ``DeployConfig`` and propagated to every stage.
|
||||
"""
|
||||
|
||||
# === Omni fields ===
|
||||
# === Omni stage wrapper fields ===
|
||||
# Stage identity and Omni runtime placement.
|
||||
stage_id: int
|
||||
devices: str | None = None
|
||||
@@ -316,22 +322,33 @@ class StageDeployConfig:
|
||||
default_sampling_params: dict[str, Any] | None = None
|
||||
subtalker_sampling_params: dict[str, Any] | None = None
|
||||
|
||||
# === vLLM EngineArgs fields ===
|
||||
# Parallelism and scheduler/memory capacity.
|
||||
# === Generic stage engine fields ===
|
||||
# Parallelism, scheduler, and memory-capacity controls.
|
||||
tensor_parallel_size: int | None = None
|
||||
enable_expert_parallel: bool | None = None
|
||||
gpu_memory_utilization: float | None = None
|
||||
max_num_seqs: int | None = None
|
||||
max_num_batched_tokens: int | None = None
|
||||
max_model_len: int | None = None
|
||||
|
||||
# Execution, scheduling, and KV/cache behavior.
|
||||
# Generic execution, scheduling, and KV/cache behavior.
|
||||
enforce_eager: bool | None = None
|
||||
async_scheduling: bool | None = None
|
||||
disable_hybrid_kv_cache_manager: bool | None = None
|
||||
mm_processor_cache_gb: float | None = None
|
||||
|
||||
# Diffusion parallel_config deploy override fields.
|
||||
enable_expert_parallel: bool | None = None
|
||||
# Generic compilation, profiling, tokenizer/config parsing, and model
|
||||
# loading controls.
|
||||
compilation_config: dict[str, Any] | None = None
|
||||
profiler_config: dict[str, Any] | None = None
|
||||
skip_mm_profiling: bool | None = None
|
||||
enable_flashinfer_autotune: bool | None = None
|
||||
config_format: str | None = None
|
||||
load_format: str | None = None
|
||||
tokenizer_mode: str | None = None
|
||||
|
||||
# === Diffusion stage runtime fields ===
|
||||
# Diffusion parallel_config deploy/runtime override fields.
|
||||
ulysses_degree: int | None = None
|
||||
ulysses_mode: str | None = None
|
||||
ring_degree: int | None = None
|
||||
@@ -343,16 +360,44 @@ class StageDeployConfig:
|
||||
hsdp_shard_size: int | None = None
|
||||
hsdp_replicate_size: int | None = None
|
||||
|
||||
# Compilation, profiling, tokenizer/config parsing, and model loading.
|
||||
compilation_config: dict[str, Any] | None = None
|
||||
profiler_config: dict[str, Any] | None = None
|
||||
skip_mm_profiling: bool | None = None
|
||||
enable_flashinfer_autotune: bool | None = None
|
||||
config_format: str | None = None
|
||||
load_format: str | None = None
|
||||
tokenizer_mode: str | None = None
|
||||
# Diffusion model loading and adapter construction.
|
||||
model_class_name: str | None = None
|
||||
diffusion_load_format: str | None = None
|
||||
diffusers_load_kwargs: dict[str, Any] | None = None
|
||||
diffusers_call_kwargs: dict[str, Any] | None = None
|
||||
diffusion_quantization_config: str | None = None
|
||||
diffusion_attention_backend: str | None = None
|
||||
diffusion_attention_config: dict[str, Any] | None = None
|
||||
|
||||
# Pass-through vLLM EngineArgs fields that are not represented above.
|
||||
# Diffusion execution, cache, and VAE behavior.
|
||||
cache_backend: str | None = None
|
||||
cache_config: dict[str, Any] | None = None
|
||||
enable_cache_dit_summary: bool | None = None
|
||||
step_execution: bool | None = None
|
||||
vae_use_slicing: bool | None = None
|
||||
vae_use_tiling: bool | None = None
|
||||
boundary_ratio: float | None = None
|
||||
flow_shift: float | None = None
|
||||
diffusion_kv_cache_dtype: str | None = None
|
||||
diffusion_kv_cache_skip_steps: str | None = None
|
||||
diffusion_kv_cache_skip_layers: str | None = None
|
||||
auxiliary_text_encoder: str | None = None
|
||||
|
||||
# Runtime optimizations used by diffusion loading/execution.
|
||||
enable_multithread_weight_load: bool | None = None
|
||||
num_weight_load_threads: int | None = None
|
||||
enable_cpu_offload: bool | None = None
|
||||
enable_layerwise_offload: bool | None = None
|
||||
|
||||
# Diffusion-specific debug and observability knobs.
|
||||
enable_diffusion_pipeline_profiler: bool | None = None
|
||||
|
||||
# Modality/service constraints consumed outside the core engine config.
|
||||
max_generated_image_size: int | None = None
|
||||
tts_max_instructions_length: int | None = None
|
||||
|
||||
# === Pass-through stage engine fields ===
|
||||
# Pass-through stage engine args that are not represented above.
|
||||
engine_extras: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -409,10 +454,22 @@ _STAGE_RESERVED_KEYS = frozenset(
|
||||
_STAGE_DEPLOY_FIELDS = {f.name: f for f in fields(StageDeployConfig) if f.name not in _STAGE_RESERVED_KEYS}
|
||||
|
||||
|
||||
def deploy_runtime_override_keys() -> frozenset[str]:
|
||||
"""Return deploy-schema fields that are valid CLI/runtime overrides.
|
||||
|
||||
These keys form the positive contract for stage override propagation:
|
||||
stage-scoped deploy knobs plus top-level pipeline-wide engine settings.
|
||||
They must remain overridable even if they are also modeled on
|
||||
``OrchestratorArgs`` for top-level CLI parsing.
|
||||
"""
|
||||
return frozenset(_STAGE_DEPLOY_FIELDS) | frozenset(_PIPELINE_WIDE_ENGINE_FIELDS)
|
||||
|
||||
|
||||
def _parse_stage_deploy(stage_data: dict[str, Any]) -> StageDeployConfig:
|
||||
"""Parse a single stage entry from deploy YAML into StageDeployConfig."""
|
||||
# Get the non-reserved keys for this stage
|
||||
flat_args = {k: v for k, v in stage_data.items() if k not in _STAGE_RESERVED_KEYS}
|
||||
explicit_engine_extras = dict(stage_data.get("engine_extras") or {})
|
||||
runtime_cfg = dict(stage_data.get("runtime", {}))
|
||||
devices = runtime_cfg.get("devices", stage_data.get("devices"))
|
||||
num_replicas = runtime_cfg.get("num_replicas", stage_data.get("num_replicas", 1))
|
||||
@@ -440,7 +497,7 @@ def _parse_stage_deploy(stage_data: dict[str, Any]) -> StageDeployConfig:
|
||||
kwargs["output_connectors"] = stage_data.get("output_connectors")
|
||||
kwargs["input_connectors"] = stage_data.get("input_connectors")
|
||||
kwargs["default_sampling_params"] = stage_data.get("default_sampling_params")
|
||||
kwargs["engine_extras"] = flat_args
|
||||
kwargs["engine_extras"] = _get_recursively_merged_dict(explicit_engine_extras, flat_args)
|
||||
return StageDeployConfig(**kwargs)
|
||||
|
||||
|
||||
@@ -840,7 +897,7 @@ def merge_pipeline_deploy(
|
||||
final_output=ps.final_output,
|
||||
final_output_type=ps.final_output_type,
|
||||
worker_type=worker_type,
|
||||
scheduler_cls=_scheduler_path(sched_cls),
|
||||
scheduler_cls=ps.scheduler_cls or _scheduler_path(sched_cls),
|
||||
hf_config_name=ps.hf_config_name,
|
||||
is_comprehension=ps.owns_tokenizer,
|
||||
yaml_engine_args=engine_args,
|
||||
|
||||
@@ -11,6 +11,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStat
|
||||
from vllm.logger import init_logger
|
||||
from vllm.v1.core.sched.async_scheduler import AsyncScheduler as AsyncVLLMScheduler
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
from vllm.v1.core.sched.request_queue import create_request_queue
|
||||
from vllm.v1.core.sched.scheduler import Scheduler as VLLMScheduler
|
||||
from vllm.v1.core.sched.utils import remove_all
|
||||
from vllm.v1.engine import EngineCoreEventType, EngineCoreOutput, EngineCoreOutputs, FinishReason
|
||||
@@ -133,6 +134,9 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
self._omits_kv_transfer_cache[rid] = result
|
||||
return result
|
||||
|
||||
def _should_defer_waiting_admission(self) -> bool:
|
||||
return False
|
||||
|
||||
def _process_kv_transfer_trigger(self, request: Request, new_token_ids: list[int]) -> bool:
|
||||
"""
|
||||
Check triggers and process side effects (marking transfer).
|
||||
@@ -222,9 +226,19 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
self.waiting, self.running, scheduler_requests=self.requests
|
||||
)
|
||||
|
||||
original_waiting = None
|
||||
if self._should_defer_waiting_admission():
|
||||
original_waiting = self.waiting
|
||||
self.waiting = create_request_queue(self.policy)
|
||||
|
||||
try:
|
||||
scheduler_output = super().schedule(throttle_prefills)
|
||||
finally:
|
||||
if original_waiting is not None:
|
||||
deferred_waiting = list(self.waiting)
|
||||
if deferred_waiting:
|
||||
original_waiting.prepend_requests(deferred_waiting)
|
||||
self.waiting = original_waiting
|
||||
if self.chunk_transfer_adapter:
|
||||
# Add request waiting for chunk to the waiting and running queue
|
||||
self.chunk_transfer_adapter.restore_queues(
|
||||
|
||||
@@ -14,6 +14,8 @@ stages:
|
||||
max_num_seqs: 1
|
||||
enforce_eager: false
|
||||
model_class_name: DreamZeroPipeline
|
||||
# DreamZero runs on the AR-Diffusion engine (engine-level paged KV cache).
|
||||
engine_backend: vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine
|
||||
model_config:
|
||||
default_robot_embodiment: roboarena
|
||||
policy_server_config:
|
||||
|
||||
@@ -14,8 +14,10 @@ connectors:
|
||||
connector_get_sleep_s: 0.005
|
||||
connector_get_max_wait_first_chunk: 1000
|
||||
connector_get_max_wait: 300
|
||||
codec_chunk_frames: 15 # 15 frames * 80 ms/frame = 1.2 s chunks
|
||||
initial_codec_chunk_frames: 1 # 1 frame * 80 ms/frame = 80 ms first audio chunk
|
||||
codec_chunk_frames: 15 # 15 frames * 80 ms/frame = 1.2 s steady chunks
|
||||
codec_left_context_frames: 0
|
||||
codec_stream_slots: 8
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
@@ -37,7 +39,7 @@ stages:
|
||||
repetition_penalty: 1.1
|
||||
|
||||
- stage_id: 1
|
||||
max_num_seqs: 1
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.15
|
||||
enforce_eager: true
|
||||
enable_prefix_caching: false
|
||||
|
||||
@@ -54,7 +54,6 @@ stages:
|
||||
temperature: 0.9
|
||||
top_k: 50
|
||||
max_tokens: 4096
|
||||
seed: 42
|
||||
repetition_penalty: 1.05
|
||||
subtalker_sampling_params:
|
||||
do_sample: true
|
||||
@@ -85,7 +84,6 @@ stages:
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 65536
|
||||
seed: 42
|
||||
repetition_penalty: 1.0
|
||||
|
||||
platforms:
|
||||
|
||||
@@ -60,7 +60,6 @@ stages:
|
||||
temperature: 0.9
|
||||
top_k: 50
|
||||
max_tokens: 4096
|
||||
seed: 42
|
||||
repetition_penalty: 1.05
|
||||
subtalker_sampling_params:
|
||||
do_sample: true
|
||||
@@ -85,7 +84,6 @@ stages:
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 65536
|
||||
seed: 42
|
||||
repetition_penalty: 1.0
|
||||
|
||||
platforms:
|
||||
|
||||
@@ -8,16 +8,17 @@ dtype: bfloat16
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
max_num_seqs: 32
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.9
|
||||
# Right-size the KV cache instead of letting it fill gpu_memory_utilization.
|
||||
# max_num_seqs(32) * max_model_len(4096) ~= 131k tokens; 6 GiB holds ~175k
|
||||
# (42x concurrency, above the 32-seq scheduler cap, so no throughput loss).
|
||||
# max_num_seqs(8) * max_model_len(4096) ~= 32k tokens; 6 GiB leaves headroom
|
||||
# for longer live requests while keeping VoxCPM2 TTS decode batches small
|
||||
# enough to avoid the high ITL observed with large admission caps.
|
||||
# The remaining VRAM stays free for the VoxCPM2 diffusion side-path
|
||||
# (LocDiT / AudioVAE / code2wav + batch CUDA-graph pools), which allocates
|
||||
# outside vLLM's KV accounting during inference. Keeps peak ~13 GiB on any
|
||||
# card and avoids per-rebase OOM tuning on small CI GPUs.
|
||||
# Assumes prefix caching off and max_num_seqs*max_model_len <= ~175k tokens;
|
||||
# Assumes prefix caching off and typical live decode concurrency <= ~8;
|
||||
# raise this cap if either grows or enable_prefix_caching is turned on.
|
||||
kv_cache_memory_bytes: 6442450944 # 6 GiB
|
||||
enforce_eager: true
|
||||
@@ -39,6 +40,9 @@ stages:
|
||||
enable_loc_dit_fused_mlp: true
|
||||
enable_loc_dit_zero_dt_cache: true
|
||||
enable_loc_dit_skip_qkv_contig: true
|
||||
enable_loc_dit_reduce_overhead_no_cg: true
|
||||
enable_unified_decode_graph: true
|
||||
unified_decode_graph_max_batch_size: 8
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
|
||||
+4
-1
@@ -59,9 +59,12 @@ CUSTOM_DIT_ENABLERS: dict[str, Callable] = {}
|
||||
|
||||
# Small helper to centralize cache-dit summaries.
|
||||
def cache_summary(pipeline: Any, details: bool = True) -> None:
|
||||
cache_dit.summary(pipeline.transformer, details=details)
|
||||
if hasattr(pipeline, "transformer"):
|
||||
cache_dit.summary(pipeline.transformer, details=details)
|
||||
if hasattr(pipeline, "transformer_2"):
|
||||
cache_dit.summary(pipeline.transformer_2, details=details)
|
||||
if not hasattr(pipeline, "transformer") and not hasattr(pipeline, "transformer_2"):
|
||||
logger.warning("CacheDiT summary failed; this pipeline has no defined transformer attribute")
|
||||
|
||||
|
||||
def default_get_pipeline_transformer(pipeline: Any) -> Any:
|
||||
|
||||
@@ -613,6 +613,20 @@ class OmniDiffusionConfig:
|
||||
distributed_executor_backend: str = "mp"
|
||||
nccl_port: int | None = None
|
||||
|
||||
# Engine backend selection, resolved by ``DiffusionEngine.resolve_engine_class``
|
||||
# (mirrors ``DiffusionExecutor.get_class``). Config files use a string:
|
||||
# "default" -> DiffusionEngine, or an import path (set e.g. by a deploy
|
||||
# config). Programmatic callers may pass a DiffusionEngine subclass
|
||||
# directly — hence ``str | type`` (structured-config mirrors should expose
|
||||
# the string form only).
|
||||
engine_backend: str | type = "default"
|
||||
|
||||
# Optional override for the diffusion model runner class (import path).
|
||||
# Precedence in the worker: this override > the runner declared by the
|
||||
# selected engine class (``default_diffusion_model_runner_cls``) > the
|
||||
# platform default. Never mutated by engines.
|
||||
diffusion_model_runner_cls: str | None = None
|
||||
|
||||
# HuggingFace specific parameters
|
||||
trust_remote_code: bool = False
|
||||
revision: str | None = None
|
||||
@@ -1173,7 +1187,8 @@ class OmniDiffusionConfig:
|
||||
valid_fields = {f.name for f in fields(cls)}
|
||||
filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_fields}
|
||||
|
||||
return cls(**filtered_kwargs)
|
||||
instance = cls(**filtered_kwargs)
|
||||
return instance
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -134,6 +134,14 @@ class _RpcTask:
|
||||
class DiffusionEngine:
|
||||
"""The diffusion engine for vLLM-Omni diffusion models."""
|
||||
|
||||
#: Import path of the model runner this engine's workers should build, or
|
||||
#: ``None`` for the platform default. Subclasses declare their runner here
|
||||
#: (e.g. the AR-Diffusion engine), the worker resolves it through
|
||||
#: :meth:`resolve_engine_class` — so engine->runner routing lives on the
|
||||
#: engine class itself, and ``od_config.diffusion_model_runner_cls``
|
||||
#: remains a pure explicit user override (never mutated by engines).
|
||||
default_diffusion_model_runner_cls: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
od_config: OmniDiffusionConfig,
|
||||
@@ -609,19 +617,55 @@ class DiffusionEngine:
|
||||
self._put_streaming_output_with_cv(request_id, out)
|
||||
|
||||
@staticmethod
|
||||
def make_engine(
|
||||
config: OmniDiffusionConfig,
|
||||
scheduler: SchedulerInterface | None = None,
|
||||
) -> DiffusionEngine:
|
||||
"""Factory method to create a DiffusionEngine instance.
|
||||
def resolve_engine_class(config: OmniDiffusionConfig) -> type[DiffusionEngine]:
|
||||
"""Resolve the engine class selected by ``config.engine_backend``.
|
||||
|
||||
Mirrors ``DiffusionExecutor.get_class``: accepts ``"default"``, a
|
||||
``DiffusionEngine`` subclass, or an import-path string (e.g. a deploy
|
||||
config's ``engine_backend``). Kept separate from :meth:`make_engine` so the
|
||||
selection is testable without constructing an engine (which runs a dummy forward).
|
||||
|
||||
Args:
|
||||
config: The configuration for the diffusion engine.
|
||||
|
||||
Returns:
|
||||
An instance of DiffusionEngine.
|
||||
The ``DiffusionEngine`` (sub)class to instantiate.
|
||||
"""
|
||||
return DiffusionEngine(config, scheduler=scheduler)
|
||||
backend = getattr(config, "engine_backend", "default") or "default"
|
||||
|
||||
if isinstance(backend, type):
|
||||
if not issubclass(backend, DiffusionEngine):
|
||||
raise TypeError(f"engine_backend must be a DiffusionEngine subclass. Got {backend}.")
|
||||
return backend
|
||||
if backend == "default":
|
||||
return DiffusionEngine
|
||||
if isinstance(backend, str):
|
||||
try:
|
||||
engine_class = resolve_obj_by_qualname(backend)
|
||||
except (ImportError, ValueError) as e:
|
||||
raise ValueError(
|
||||
f"Failed to load engine_backend '{backend}'. Ensure it is a valid python path. Error: {e}"
|
||||
) from e
|
||||
if not issubclass(engine_class, DiffusionEngine):
|
||||
raise TypeError(f"engine_backend must resolve to a DiffusionEngine subclass. Got {engine_class}.")
|
||||
return engine_class
|
||||
raise ValueError(f"Unknown engine_backend: {backend!r}")
|
||||
|
||||
@staticmethod
|
||||
def make_engine(
|
||||
config: OmniDiffusionConfig,
|
||||
scheduler: SchedulerInterface | None = None,
|
||||
) -> DiffusionEngine:
|
||||
"""Factory method to create the engine selected by ``config.engine_backend``.
|
||||
|
||||
Args:
|
||||
config: The configuration for the diffusion engine.
|
||||
|
||||
Returns:
|
||||
An instance of the resolved ``DiffusionEngine`` (sub)class.
|
||||
"""
|
||||
engine_class = DiffusionEngine.resolve_engine_class(config)
|
||||
return engine_class(config, scheduler=scheduler)
|
||||
|
||||
def add_request(self, request: OmniDiffusionRequest) -> str:
|
||||
with self._cv:
|
||||
|
||||
@@ -35,7 +35,6 @@ from vllm.distributed.parallel_state import get_tensor_model_parallel_world_size
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.diffusion import envs
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.forward_context import get_forward_context
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
@@ -61,6 +60,11 @@ _DP: GroupCoordinator | None = None
|
||||
_FS: GroupCoordinator | None = None # Fully Sharded (HSDP shard dimension)
|
||||
_DIT: GroupCoordinator | None = None
|
||||
|
||||
# Rank-layout metadata for expert parallelism. This is not a process group;
|
||||
# it is reused by platform-specific runtimes that must build companion groups
|
||||
# with the same rank layout as vLLM EP.
|
||||
_EXPERT_PARALLEL_GROUP_RANKS: list[list[int]] | None = None
|
||||
|
||||
|
||||
def generate_masked_orthogonal_rank_groups(
|
||||
world_size: int, parallel_size: list[int], mask: list[bool]
|
||||
@@ -190,7 +194,6 @@ class RankGenerator:
|
||||
self.fs = fs
|
||||
self.rank_offset = rank_offset
|
||||
self.world_size = tp * sp * pp * cfg * dp
|
||||
self.ep = tp * sp * cfg * dp # EP level exclude PP
|
||||
|
||||
self.name_to_size = {
|
||||
"tp": self.tp,
|
||||
@@ -253,15 +256,6 @@ class RankGenerator:
|
||||
ranks.append(group)
|
||||
return ranks
|
||||
|
||||
if token == "ep":
|
||||
ranks = []
|
||||
num_pp_stages = self.pp
|
||||
for i in range(num_pp_stages):
|
||||
start = i * self.ep + self.rank_offset
|
||||
end = start + self.ep
|
||||
ranks.append(list(range(start, end)))
|
||||
return ranks
|
||||
|
||||
mask = self.get_mask(self.order, token)
|
||||
ranks = generate_masked_orthogonal_rank_groups(self.world_size, self.ordered_size, mask)
|
||||
if self.rank_offset > 0:
|
||||
@@ -309,6 +303,12 @@ def get_ring_parallel_rank():
|
||||
return get_sp_group().ring_rank
|
||||
|
||||
|
||||
def get_expert_parallel_group_ranks() -> list[list[int]]:
|
||||
assert vllm_parallel_state._EP is not None, "expert parallel group is not initialized"
|
||||
assert _EXPERT_PARALLEL_GROUP_RANKS is not None, "expert parallel group ranks are not initialized"
|
||||
return _EXPERT_PARALLEL_GROUP_RANKS
|
||||
|
||||
|
||||
# PP
|
||||
def get_pp_group() -> PipelineGroupCoordinator:
|
||||
assert _PP is not None, "pipeline model parallel group is not initialized"
|
||||
@@ -782,6 +782,13 @@ def initialize_model_parallel(
|
||||
fs=fully_shard_degree,
|
||||
order="tp-sp-pp-cfg-dp",
|
||||
)
|
||||
use_moe_parallel_mapping = False
|
||||
if enable_expert_parallel:
|
||||
od_config = get_forward_context().omni_diffusion_config
|
||||
use_moe_parallel_mapping = bool(od_config and od_config.is_moe)
|
||||
if not use_moe_parallel_mapping:
|
||||
raise RuntimeError("Expert parallelism enabled for a non-MoE model")
|
||||
|
||||
sp_group_ranks = rank_generator.get_ranks("sp")
|
||||
global _DP
|
||||
assert _DP is None, "data parallel group is already initialized"
|
||||
@@ -828,6 +835,10 @@ def initialize_model_parallel(
|
||||
ulysses_group=ulysses_pg,
|
||||
ring_group=ring_pg,
|
||||
)
|
||||
if use_moe_parallel_mapping:
|
||||
# Diffusion normally uses its own SP group. Map it to vLLM PCP only for
|
||||
# expert-parallel runtimes that rely on vLLM FusedMoE group semantics.
|
||||
vllm_parallel_state._PCP = _SP
|
||||
|
||||
assert vllm_parallel_state._TP is None, "Tensor parallel group is already initialized"
|
||||
vllm_parallel_state._TP = init_model_parallel_group(
|
||||
@@ -836,8 +847,17 @@ def initialize_model_parallel(
|
||||
backend=backend,
|
||||
parallel_mode="tensor",
|
||||
)
|
||||
if use_moe_parallel_mapping and cfg_parallel_size > 1:
|
||||
# CFG is a diffusion-specific replica dimension. Fold it into vLLM DP
|
||||
# only when constructing the vLLM EP layout for expert-parallel paths.
|
||||
vllm_parallel_state._DP = init_model_parallel_group(
|
||||
group_ranks=rank_generator.get_ranks("cfg-dp"),
|
||||
local_rank=get_world_group().local_rank,
|
||||
backend=backend,
|
||||
parallel_mode="data",
|
||||
)
|
||||
|
||||
global _FS
|
||||
global _FS, _EXPERT_PARALLEL_GROUP_RANKS
|
||||
assert _FS is None, "fully shard group is already initialized"
|
||||
_FS = init_model_parallel_group(
|
||||
group_ranks=rank_generator.get_ranks("fs", independent_ranks=True),
|
||||
@@ -846,34 +866,40 @@ def initialize_model_parallel(
|
||||
parallel_mode="fully_shard",
|
||||
)
|
||||
|
||||
if enable_expert_parallel:
|
||||
od_config: OmniDiffusionConfig | None = get_forward_context().omni_diffusion_config
|
||||
if od_config and od_config.is_moe:
|
||||
vllm_parallel_state._EP = init_model_parallel_group(
|
||||
group_ranks=rank_generator.get_ranks("ep"),
|
||||
local_rank=get_world_group().local_rank,
|
||||
backend=backend,
|
||||
parallel_mode="expert",
|
||||
)
|
||||
else:
|
||||
raise RuntimeError("Expert parallelism enabled for a non-MoE model ")
|
||||
_EXPERT_PARALLEL_GROUP_RANKS = None
|
||||
if use_moe_parallel_mapping:
|
||||
ep_group_ranks = rank_generator.get_ranks("tp-sp-cfg-dp")
|
||||
vllm_parallel_state._EP = init_model_parallel_group(
|
||||
group_ranks=ep_group_ranks,
|
||||
local_rank=get_world_group().local_rank,
|
||||
backend=backend,
|
||||
parallel_mode="expert",
|
||||
)
|
||||
_EXPERT_PARALLEL_GROUP_RANKS = ep_group_ranks
|
||||
|
||||
init_dit_group(dit_parallel_size, backend)
|
||||
|
||||
|
||||
def destroy_model_parallel():
|
||||
"""Set the groups to none and destroy them."""
|
||||
global _DP
|
||||
global _DP, _CFG, _SP, _PP, _FS, _EXPERT_PARALLEL_GROUP_RANKS
|
||||
|
||||
if vllm_parallel_state._DP and vllm_parallel_state._DP is not _DP:
|
||||
vllm_parallel_state._DP.destroy()
|
||||
vllm_parallel_state._DP = None
|
||||
|
||||
if _DP:
|
||||
_DP.destroy()
|
||||
_DP = None
|
||||
|
||||
global _CFG
|
||||
if _CFG:
|
||||
_CFG.destroy()
|
||||
_CFG = None
|
||||
|
||||
global _SP
|
||||
if vllm_parallel_state._PCP and vllm_parallel_state._PCP is not _SP:
|
||||
vllm_parallel_state._PCP.destroy()
|
||||
vllm_parallel_state._PCP = None
|
||||
|
||||
if _SP:
|
||||
_SP.destroy()
|
||||
_SP = None
|
||||
@@ -885,13 +911,12 @@ def destroy_model_parallel():
|
||||
if vllm_parallel_state._EP:
|
||||
vllm_parallel_state._EP.destroy()
|
||||
vllm_parallel_state._EP = None
|
||||
_EXPERT_PARALLEL_GROUP_RANKS = None
|
||||
|
||||
global _PP
|
||||
if _PP:
|
||||
_PP.destroy()
|
||||
_PP = None
|
||||
|
||||
global _FS
|
||||
if _FS:
|
||||
_FS.destroy()
|
||||
_FS = None
|
||||
|
||||
@@ -368,7 +368,17 @@ class DiffusionLoRAManager:
|
||||
|
||||
# Default denoising components plus any a pipeline opts into via
|
||||
# ``_lora_components`` (opt-in; other pipelines unchanged).
|
||||
default_components = ("transformer", "transformer_2", "dit", "bagel")
|
||||
#
|
||||
# NOTE: SDXL-style pipelines expose the denoiser as ``unet``.
|
||||
# Without scanning this component, adapters can load/activate while
|
||||
# effectively applying to zero layers, producing base-identical output.
|
||||
default_components = (
|
||||
"transformer",
|
||||
"transformer_2",
|
||||
"dit",
|
||||
"bagel",
|
||||
"unet",
|
||||
)
|
||||
extra_components = tuple(getattr(self.pipeline, "_lora_components", ()) or ())
|
||||
for component_name in (*default_components, *extra_components):
|
||||
if not hasattr(self.pipeline, component_name):
|
||||
|
||||
@@ -77,6 +77,7 @@ from .action import (
|
||||
normalize_action_mode,
|
||||
pad_action_to_dim,
|
||||
resolve_domain_id,
|
||||
vision_condition_indexes,
|
||||
)
|
||||
from .transfer import (
|
||||
Cosmos3TransferConfig,
|
||||
@@ -2009,8 +2010,21 @@ class Cosmos3OmniDiffusersPipeline(
|
||||
generator: torch.Generator,
|
||||
image_size: Any | None = None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
"""Prepare video latents for action modes with mode-specific conditioning."""
|
||||
"""Prepare video latents for action modes with mode-specific conditioning.
|
||||
|
||||
Policy and forward-dynamics modes condition only latent frame zero.
|
||||
The Wan VAE is temporally causal, so encoding only the first pixel
|
||||
frame preserves that latent while avoiding unused future-frame work.
|
||||
Inverse dynamics conditions every latent and keeps the full encode.
|
||||
"""
|
||||
del height, width
|
||||
if video_tensor.ndim == 4:
|
||||
video_tensor = video_tensor.unsqueeze(0)
|
||||
if video_tensor.ndim != 5 or video_tensor.shape[0] != 1 or video_tensor.shape[1] != 3:
|
||||
raise ValueError(f"Cosmos3 video tensor must have shape [1, 3, T, H, W], got {tuple(video_tensor.shape)}.")
|
||||
if video_tensor.shape[2] < 1:
|
||||
raise ValueError("Cosmos3 action video tensor must contain at least one frame.")
|
||||
|
||||
C = self.transformer.latent_channel_size
|
||||
T_lat = (num_frames - 1) // self.vae_scale_factor_temporal + 1
|
||||
latent_hw = self._latent_hw_from_image_size(image_size)
|
||||
@@ -2026,12 +2040,24 @@ class Cosmos3OmniDiffusersPipeline(
|
||||
device=self.device,
|
||||
dtype=self.dtype,
|
||||
)
|
||||
cond_latent = self._encode_video_tensor(video_tensor, image_size=image_size)
|
||||
if cond_latent.shape[2:] != noise.shape[2:]:
|
||||
condition_indexes = vision_condition_indexes(mode, num_frames, self.vae_scale_factor_temporal)
|
||||
condition_video = video_tensor[:, :, :1] if condition_indexes == [0] else video_tensor
|
||||
cond_prefix_latent = self._encode_video_tensor(condition_video, image_size=image_size)
|
||||
expected_prefix = (1, C, max(condition_indexes) + 1, H_lat, W_lat)
|
||||
if (
|
||||
cond_prefix_latent.shape[0] != expected_prefix[0]
|
||||
or cond_prefix_latent.shape[1] != expected_prefix[1]
|
||||
or cond_prefix_latent.shape[2] < expected_prefix[2]
|
||||
or cond_prefix_latent.shape[3:] != expected_prefix[3:]
|
||||
):
|
||||
raise ValueError(
|
||||
"Cosmos3 action video latent shape mismatch: "
|
||||
f"encoded={tuple(cond_latent.shape)}, expected={tuple(noise.shape)}."
|
||||
f"encoded={tuple(cond_prefix_latent.shape)}, expected at least {expected_prefix}."
|
||||
)
|
||||
|
||||
condition_latents = torch.zeros_like(noise)
|
||||
for index in condition_indexes:
|
||||
condition_latents[:, :, index : index + 1] = cond_prefix_latent[:, :, index : index + 1]
|
||||
condition_mask = build_vision_condition_mask(
|
||||
mode,
|
||||
num_frames,
|
||||
@@ -2039,9 +2065,9 @@ class Cosmos3OmniDiffusersPipeline(
|
||||
device=self.device,
|
||||
dtype=self.dtype,
|
||||
)
|
||||
latents = condition_mask * cond_latent + (1.0 - condition_mask) * noise
|
||||
latents = condition_mask * condition_latents + (1.0 - condition_mask) * noise
|
||||
velocity_mask = 1.0 - condition_mask
|
||||
return latents, velocity_mask, cond_latent
|
||||
return latents, velocity_mask, condition_latents
|
||||
|
||||
def _prepare_action_latents(
|
||||
self,
|
||||
|
||||
@@ -24,6 +24,11 @@ from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_utils import BasePipelineUtils, get_pipeline_utils
|
||||
from vllm_omni.diffusion.models.diffusers_adapter.quantization_utils import (
|
||||
apply_diffusers_quantization_config,
|
||||
convert_diffusers_quantization_config,
|
||||
ensure_supported_diffusers_quantization,
|
||||
)
|
||||
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
from vllm_omni.inputs.data import OmniPromptType, OmniTextPrompt
|
||||
@@ -31,6 +36,20 @@ from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DIFFUSERS_CONFIG_LOAD_KWARGS = {
|
||||
"cache_dir",
|
||||
"dduf_entries",
|
||||
"force_download",
|
||||
"local_dir",
|
||||
"local_dir_use_symlinks",
|
||||
"local_files_only",
|
||||
"proxies",
|
||||
"revision",
|
||||
"subfolder",
|
||||
"token",
|
||||
"user_agent",
|
||||
}
|
||||
|
||||
|
||||
class DiffusersAdapterPipeline(nn.Module, DiffusionPipelineProfilerMixin):
|
||||
"""Black-box adapter that delegates full pipeline execution to a diffusers pipeline.
|
||||
@@ -80,12 +99,19 @@ class DiffusersAdapterPipeline(nn.Module, DiffusionPipelineProfilerMixin):
|
||||
"torch_dtype": dtype,
|
||||
**self.od_config.diffusers_load_kwargs,
|
||||
}
|
||||
logger.debug(f"Loading diffusers pipeline with kwargs: {load_kwargs}")
|
||||
convert_diffusers_quantization_config(load_kwargs)
|
||||
|
||||
pipeline_class = self.od_config.diffusers_pipeline_cls
|
||||
pipeline_class_name = pipeline_class.__name__ if pipeline_class is not None else None
|
||||
self._pipeline_utils = get_pipeline_utils(pipeline_class_name)
|
||||
self._pipeline_utils.update_load_kwargs(self.od_config, load_kwargs)
|
||||
component_names = (
|
||||
self._load_diffusers_component_names(model_id, load_kwargs)
|
||||
if self.od_config.quantization_config is not None and "quantization_config" not in load_kwargs
|
||||
else {}
|
||||
)
|
||||
apply_diffusers_quantization_config(self.od_config, load_kwargs, component_names)
|
||||
logger.debug(f"Loading diffusers pipeline with kwargs: {load_kwargs}")
|
||||
|
||||
self._pipeline = DiffusionPipeline.from_pretrained(model_id, **load_kwargs)
|
||||
self._pipeline_utils.apply_post_load_updates(self._pipeline, self.od_config)
|
||||
@@ -190,10 +216,38 @@ class DiffusersAdapterPipeline(nn.Module, DiffusionPipelineProfilerMixin):
|
||||
"Eager execution is not supported with the diffusers backend. "
|
||||
"Use a native pipeline for continuous batching mode."
|
||||
)
|
||||
if self.od_config.quantization_config is not None:
|
||||
raise NotImplementedError(
|
||||
"Quantization is not supported with the diffusers backend. Use a native pipeline for quantization."
|
||||
if (
|
||||
self.od_config.quantization_config is not None
|
||||
and "quantization_config" not in self.od_config.diffusers_load_kwargs
|
||||
):
|
||||
ensure_supported_diffusers_quantization(self.od_config.quantization_config)
|
||||
if self.od_config.diffusers_load_kwargs.get("dduf_file"):
|
||||
raise NotImplementedError(
|
||||
"Diffusers backend quantization conversion does not support "
|
||||
"diffusers_load_kwargs.dduf_file yet. The preflight component "
|
||||
"discovery would need to mirror Diffusers' DDUF config loading. "
|
||||
"Use diffusers_load_kwargs.quantization_config for a native "
|
||||
"Diffusers quantization config, or omit dduf_file for vLLM-Omni "
|
||||
"quantization conversion."
|
||||
)
|
||||
|
||||
def _load_diffusers_component_names(
|
||||
self,
|
||||
model_id: str,
|
||||
load_kwargs: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
config_load_kwargs = {k: load_kwargs[k] for k in _DIFFUSERS_CONFIG_LOAD_KWARGS if k in load_kwargs}
|
||||
pipeline_config = DiffusionPipeline.load_config(model_id, **config_load_kwargs)
|
||||
return {
|
||||
name: value
|
||||
for name, value in pipeline_config.items()
|
||||
if (
|
||||
isinstance(value, list)
|
||||
and len(value) > 0
|
||||
and value[0] is not None
|
||||
and (name not in load_kwargs or load_kwargs[name] is not None)
|
||||
)
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Wrap settings, inputs, and outputs
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Quantization helpers for the Diffusers backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from diffusers import PipelineQuantizationConfig, TorchAoConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DIFFUSERS_TRANSFORMER_COMPONENTS = ("transformer", "transformer_2")
|
||||
_QuantizationConfigSpec = tuple[Callable[[Any], None], str]
|
||||
|
||||
|
||||
def ensure_supported_diffusers_quantization(quant_config: Any) -> None:
|
||||
"""Validate that a vLLM-Omni quantization config has a Diffusers mapping."""
|
||||
|
||||
method = _get_quant_method_name(quant_config)
|
||||
validator, _ = _get_quantization_config_spec(method)
|
||||
validator(quant_config)
|
||||
|
||||
|
||||
def convert_diffusers_quantization_config(load_kwargs: dict[str, Any]) -> None:
|
||||
"""Build Diffusers-native quantization config objects from dict kwargs."""
|
||||
|
||||
quant_config = load_kwargs.get("quantization_config")
|
||||
if isinstance(quant_config, dict):
|
||||
load_kwargs["quantization_config"] = _build_diffusers_native_quantization_config(quant_config)
|
||||
|
||||
|
||||
def apply_diffusers_quantization_config(
|
||||
od_config: Any,
|
||||
load_kwargs: dict[str, Any],
|
||||
component_names: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Inject a courtesy-converted quantization_config into load kwargs.
|
||||
|
||||
``diffusers_load_kwargs`` is the canonical Diffusers backend configuration
|
||||
path, so an explicit ``quantization_config`` already present in ``load_kwargs``
|
||||
is never replaced. Returns whether a config was injected by this helper.
|
||||
"""
|
||||
|
||||
quant_config = getattr(od_config, "quantization_config", None)
|
||||
if quant_config is None:
|
||||
return False
|
||||
|
||||
if "quantization_config" in load_kwargs:
|
||||
logger.warning(
|
||||
"Both vLLM-Omni quantization_config and diffusers_load_kwargs.quantization_config "
|
||||
"were provided for the diffusers backend. Using the Diffusers-native "
|
||||
"quantization_config from diffusers_load_kwargs."
|
||||
)
|
||||
return False
|
||||
|
||||
transformer_components = _get_diffusers_transformer_components(component_names)
|
||||
load_kwargs["quantization_config"] = _build_diffusers_quantization_config(
|
||||
quant_config,
|
||||
transformer_components,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_method_name(method: Any) -> str:
|
||||
method = getattr(method, "value", method)
|
||||
return str(method).lower().replace("-", "_")
|
||||
|
||||
|
||||
def _get_quant_method_name(quant_config: Any) -> str:
|
||||
get_name = getattr(quant_config, "get_name", None)
|
||||
if get_name is not None:
|
||||
return _normalize_method_name(get_name() if callable(get_name) else get_name)
|
||||
|
||||
method = getattr(quant_config, "quant_method", None)
|
||||
if method is None:
|
||||
method = getattr(quant_config, "method", None)
|
||||
if method is None:
|
||||
raise NotImplementedError(
|
||||
"Diffusers backend quantization conversion requires a quantization "
|
||||
"config with get_name(), quant_method, or method."
|
||||
)
|
||||
return _normalize_method_name(method)
|
||||
|
||||
|
||||
def _ensure_no_ignored_layers(quant_config: Any) -> None:
|
||||
ignored_layers = getattr(quant_config, "ignored_layers", None)
|
||||
if not ignored_layers:
|
||||
ignored_layers = getattr(quant_config, "modules_to_not_convert", None)
|
||||
if ignored_layers:
|
||||
raise NotImplementedError(
|
||||
"Diffusers backend quantization conversion does not map vLLM "
|
||||
"ignored_layers/modules_to_not_convert names to Diffusers module "
|
||||
"names. Use diffusers_load_kwargs for a native Diffusers config."
|
||||
)
|
||||
|
||||
|
||||
def _get_torchao_quant_type_cls(class_name: str) -> type[Any]:
|
||||
try:
|
||||
import torchao.quantization as torchao_quantization
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Diffusers backend quantization conversion for fp8/int8 requires "
|
||||
"torchao. Install torchao or pass a Diffusers-native "
|
||||
"quantization_config through diffusers_load_kwargs."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
return getattr(torchao_quantization, class_name)
|
||||
except AttributeError as exc:
|
||||
raise ImportError(f"torchao.quantization.{class_name} is required for this quantization mapping.") from exc
|
||||
|
||||
|
||||
def _build_diffusers_native_quantization_config(config: dict[str, Any]) -> Any:
|
||||
config = config.copy()
|
||||
quant_mapping = config.get("quant_mapping")
|
||||
if isinstance(quant_mapping, dict):
|
||||
config["quant_mapping"] = {
|
||||
component_name: _build_diffusers_component_quant_config(component_config)
|
||||
for component_name, component_config in quant_mapping.items()
|
||||
}
|
||||
return PipelineQuantizationConfig(**config)
|
||||
|
||||
|
||||
def _build_diffusers_component_quant_config(config: Any) -> Any:
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
|
||||
quant_method = config.get("quant_method")
|
||||
if quant_method is None:
|
||||
raise ValueError("Diffusers quant_mapping entries must provide quant_method.")
|
||||
|
||||
quant_method = _normalize_method_name(quant_method)
|
||||
config_cls = _get_diffusers_quantization_config_cls(quant_method)
|
||||
from_dict = getattr(config_cls, "from_dict", None)
|
||||
if from_dict is not None:
|
||||
return from_dict(config)
|
||||
|
||||
init_kwargs = config.copy()
|
||||
init_kwargs.pop("quant_method", None)
|
||||
return config_cls(**init_kwargs)
|
||||
|
||||
|
||||
def _get_diffusers_quantization_config_cls(quant_method: str) -> type[Any]:
|
||||
from diffusers.quantizers.auto import AUTO_QUANTIZATION_CONFIG_MAPPING as DIFFUSERS_QUANT_CONFIG_MAPPING
|
||||
|
||||
config_cls = DIFFUSERS_QUANT_CONFIG_MAPPING.get(quant_method)
|
||||
if config_cls is not None:
|
||||
return config_cls
|
||||
|
||||
try:
|
||||
from transformers.quantizers.auto import (
|
||||
AUTO_QUANTIZATION_CONFIG_MAPPING as TRANSFORMERS_QUANT_CONFIG_MAPPING,
|
||||
)
|
||||
except ImportError:
|
||||
TRANSFORMERS_QUANT_CONFIG_MAPPING = {}
|
||||
|
||||
config_cls = TRANSFORMERS_QUANT_CONFIG_MAPPING.get(quant_method)
|
||||
if config_cls is None:
|
||||
raise ValueError(f"Diffusers quant_mapping entry uses unknown quant_method={quant_method!r}.")
|
||||
return config_cls
|
||||
|
||||
|
||||
def _build_torchao_pipeline_quant_config(
|
||||
torchao_quant_type_name: str,
|
||||
transformer_components: list[str],
|
||||
) -> Any:
|
||||
"""Build a Diffusers config after the caller has validated the vLLM config."""
|
||||
|
||||
quant_type_cls = _get_torchao_quant_type_cls(torchao_quant_type_name)
|
||||
return PipelineQuantizationConfig(
|
||||
quant_mapping={
|
||||
component_name: TorchAoConfig(
|
||||
quant_type=quant_type_cls(),
|
||||
)
|
||||
for component_name in transformer_components
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _get_diffusers_transformer_components(component_names: dict[str, Any]) -> list[str]:
|
||||
transformer_components = [
|
||||
component_name for component_name in component_names if component_name in _DIFFUSERS_TRANSFORMER_COMPONENTS
|
||||
]
|
||||
if transformer_components:
|
||||
return transformer_components
|
||||
|
||||
available_components = ", ".join(sorted(component_names.keys())) or "<none>"
|
||||
raise NotImplementedError(
|
||||
"Diffusers backend quantization conversion currently only supports "
|
||||
"pipelines with transformer or transformer_2 components. "
|
||||
f"Found pipeline components: {available_components}. Use "
|
||||
"diffusers_load_kwargs for a native Diffusers quantization config."
|
||||
)
|
||||
|
||||
|
||||
def _validate_fp8_quant_config(quant_config: Any) -> None:
|
||||
if getattr(quant_config, "is_checkpoint_fp8_serialized", False):
|
||||
raise NotImplementedError(
|
||||
"Diffusers backend fp8 conversion only supports online/dynamic "
|
||||
"TorchAO quantization; serialized vLLM fp8 checkpoints are not mapped."
|
||||
)
|
||||
|
||||
activation_scheme = getattr(quant_config, "activation_scheme", "dynamic")
|
||||
if activation_scheme != "dynamic":
|
||||
raise NotImplementedError(
|
||||
f"Diffusers backend fp8 conversion only supports activation_scheme='dynamic'. Got {activation_scheme!r}."
|
||||
)
|
||||
|
||||
weight_block_size = getattr(quant_config, "weight_block_size", None)
|
||||
if weight_block_size is not None:
|
||||
raise NotImplementedError(
|
||||
"Diffusers backend fp8 conversion does not map vLLM weight_block_size "
|
||||
"to TorchAO. Use diffusers_load_kwargs for a native Diffusers config."
|
||||
)
|
||||
|
||||
_ensure_no_ignored_layers(quant_config)
|
||||
|
||||
|
||||
def _validate_int8_quant_config(quant_config: Any) -> None:
|
||||
if getattr(quant_config, "is_checkpoint_int8_serialized", False):
|
||||
raise NotImplementedError(
|
||||
"Diffusers backend int8 conversion only supports online/dynamic "
|
||||
"TorchAO quantization; serialized vLLM int8 checkpoints are not mapped."
|
||||
)
|
||||
|
||||
activation_scheme = getattr(quant_config, "activation_scheme", "dynamic")
|
||||
if activation_scheme != "dynamic":
|
||||
raise NotImplementedError(
|
||||
f"Diffusers backend int8 conversion only supports activation_scheme='dynamic'. Got {activation_scheme!r}."
|
||||
)
|
||||
|
||||
_ensure_no_ignored_layers(quant_config)
|
||||
|
||||
|
||||
_QUANTIZATION_CONFIG_SPECS: dict[str, _QuantizationConfigSpec] = {
|
||||
"fp8": (_validate_fp8_quant_config, "Float8DynamicActivationFloat8WeightConfig"),
|
||||
"int8": (_validate_int8_quant_config, "Int8DynamicActivationInt8WeightConfig"),
|
||||
}
|
||||
|
||||
|
||||
def _get_quantization_config_spec(method: str) -> _QuantizationConfigSpec:
|
||||
spec = _QUANTIZATION_CONFIG_SPECS.get(method)
|
||||
if spec is None:
|
||||
raise NotImplementedError(
|
||||
f"Diffusers backend quantization conversion does not support {method!r}. "
|
||||
"Use diffusers_load_kwargs for a native Diffusers quantization config, "
|
||||
"or use a native vLLM-Omni pipeline for this quantization method."
|
||||
)
|
||||
return spec
|
||||
|
||||
|
||||
def _build_diffusers_quantization_config(
|
||||
quant_config: Any,
|
||||
transformer_components: list[str],
|
||||
) -> Any:
|
||||
"""Build a Diffusers PipelineQuantizationConfig from a supported config."""
|
||||
|
||||
method = _get_quant_method_name(quant_config)
|
||||
_, torchao_quant_type_name = _get_quantization_config_spec(method)
|
||||
return _build_torchao_pipeline_quant_config(
|
||||
torchao_quant_type_name,
|
||||
transformer_components,
|
||||
)
|
||||
@@ -37,6 +37,20 @@ from vllm_omni.diffusion.models.dreamzero.action_encoder import (
|
||||
MultiEmbodimentActionEncoder,
|
||||
)
|
||||
|
||||
# AR-Diffusion paged self-attention (in-tree experimental engine). Import at
|
||||
# module level so the isinstance check + custom-op call trace cleanly inside
|
||||
# the fullgraph-compiled DiT block (an import inside the traced region would
|
||||
# graph-break). The model still works without the engine: the payload type is
|
||||
# only ever constructed by the AR-Diffusion runner.
|
||||
try:
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import (
|
||||
ARDiffusionPagedLayerInputs,
|
||||
paged_write_attn,
|
||||
)
|
||||
except ImportError: # pragma: no cover - experimental package always ships in-tree
|
||||
ARDiffusionPagedLayerInputs = None
|
||||
paged_write_attn = None
|
||||
|
||||
# ── RoPE utilities ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -510,7 +524,7 @@ class CausalWanSelfAttention(nn.Module):
|
||||
freqs_action: torch.Tensor,
|
||||
freqs_state: torch.Tensor,
|
||||
action_register_length: int | None,
|
||||
kv_cache: torch.Tensor | None = None,
|
||||
kv_cache: torch.Tensor | Any | None = None,
|
||||
current_start_frame: int = 0,
|
||||
is_tf: bool = True,
|
||||
) -> tuple[torch.Tensor, torch.Tensor | None]:
|
||||
@@ -567,24 +581,44 @@ class CausalWanSelfAttention(nn.Module):
|
||||
action_v = v[:, -action_register_length:]
|
||||
v = v[:, :-action_register_length]
|
||||
|
||||
updated_k = kv_cache[0]
|
||||
updated_v = kv_cache[1]
|
||||
new_k = torch.cat([updated_k, roped_key], dim=1)
|
||||
new_v = torch.cat([updated_v, v], dim=1)
|
||||
new_k = new_k[:, -self.max_attention_size :]
|
||||
new_v = new_v[:, -self.max_attention_size :]
|
||||
|
||||
if action_register_length is not None:
|
||||
q_cat = torch.cat([roped_query, roped_action_query], dim=1)
|
||||
k_cat = torch.cat([new_k, roped_action_key], dim=1)
|
||||
v_cat = torch.cat([new_v, action_v], dim=1)
|
||||
if ARDiffusionPagedLayerInputs is not None and isinstance(kv_cache, ARDiffusionPagedLayerInputs):
|
||||
# Fused write+attend custom op: one opaque node in the compiled
|
||||
# graph (slot writes + FlashAttention block-table kernel inside).
|
||||
# Metadata tensors were prepared once per forward in _forward_blocks.
|
||||
if action_register_length is not None:
|
||||
q_cat = torch.cat([roped_query, roped_action_query], dim=1)
|
||||
k_act, v_act = roped_action_key[0], action_v[0]
|
||||
else:
|
||||
q_cat = roped_query
|
||||
k_act = v_act = None
|
||||
x = paged_write_attn(
|
||||
kv_cache,
|
||||
q_cat[0],
|
||||
roped_key[0],
|
||||
v[0],
|
||||
k_act,
|
||||
v_act,
|
||||
self.head_dim**-0.5,
|
||||
).unsqueeze(0)
|
||||
else:
|
||||
q_cat = roped_query
|
||||
k_cat = new_k
|
||||
v_cat = new_v
|
||||
updated_k = kv_cache[0]
|
||||
updated_v = kv_cache[1]
|
||||
new_k = torch.cat([updated_k, roped_key], dim=1)
|
||||
new_v = torch.cat([updated_v, v], dim=1)
|
||||
new_k = new_k[:, -self.max_attention_size :]
|
||||
new_v = new_v[:, -self.max_attention_size :]
|
||||
|
||||
x = self.attn(q_cat, k_cat, v_cat)
|
||||
updated_kv_cache = torch.stack([new_k, new_v], dim=0)
|
||||
if action_register_length is not None:
|
||||
q_cat = torch.cat([roped_query, roped_action_query], dim=1)
|
||||
k_cat = torch.cat([new_k, roped_action_key], dim=1)
|
||||
v_cat = torch.cat([new_v, action_v], dim=1)
|
||||
else:
|
||||
q_cat = roped_query
|
||||
k_cat = new_k
|
||||
v_cat = new_v
|
||||
|
||||
x = self.attn(q_cat, k_cat, v_cat)
|
||||
updated_kv_cache = torch.stack([new_k, new_v], dim=0)
|
||||
|
||||
x = x.flatten(2)
|
||||
x = self.o(x)
|
||||
@@ -646,7 +680,7 @@ class CausalWanAttentionBlock(nn.Module):
|
||||
freqs_state: torch.Tensor,
|
||||
context: torch.Tensor,
|
||||
action_register_length: int | None = None,
|
||||
kv_cache: torch.Tensor | None = None,
|
||||
kv_cache: torch.Tensor | Any | None = None,
|
||||
crossattn_cache: dict | None = None,
|
||||
current_start_frame: int = 0,
|
||||
is_tf: bool = True,
|
||||
@@ -925,10 +959,10 @@ class CausalWanModel(nn.Module):
|
||||
action: torch.Tensor | None,
|
||||
timestep_action: torch.Tensor | None,
|
||||
state: torch.Tensor | None,
|
||||
kv_cache: list[torch.Tensor],
|
||||
crossattn_cache: list[dict] | None,
|
||||
kv_cache: list[Any],
|
||||
current_start_frame: int,
|
||||
) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor]]:
|
||||
crossattn_cache: list[dict] | None = None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor | None]]:
|
||||
x = x.flatten(start_dim=2).transpose(1, 2)
|
||||
B = x.shape[0]
|
||||
F_t = timestep.shape[1]
|
||||
@@ -966,7 +1000,28 @@ class CausalWanModel(nn.Module):
|
||||
clip_embedding = self.img_emb(clip_feature)
|
||||
context = torch.cat([clip_embedding, context], dim=1)
|
||||
|
||||
updated_kv_caches: list[torch.Tensor] = []
|
||||
# AR-Diffusion paged path: host-side prep ONCE per branch forward, outside
|
||||
# the compiled blocks — allocate current video/action slots, build the
|
||||
# padded block-table metadata all 40 layers share, and hand the compiled
|
||||
# region plain tensors (ARDiffusionPagedLayerInputs) instead of a Python
|
||||
# context object. Lazy-allocation contract preserved: only the branch this
|
||||
# CFG-parallel rank executes reaches its _forward_blocks.
|
||||
if kv_cache and getattr(kv_cache[0], "is_ar_diffusion_paged_context", False):
|
||||
if B != 1:
|
||||
raise RuntimeError("AR-Diffusion paged self-attention currently expects batch_size=1")
|
||||
fctx = kv_cache[0].forward_ctx
|
||||
if seq_len != fctx.seq_len:
|
||||
raise RuntimeError(
|
||||
f"AR-Diffusion paged context seq_len={fctx.seq_len} but current video KV has {seq_len} tokens"
|
||||
)
|
||||
fctx.prepare(
|
||||
device=x.device,
|
||||
action_len=int(action_register_length or 0),
|
||||
query_len=int(x.shape[1]),
|
||||
)
|
||||
kv_cache = [c.to_layer_inputs() for c in kv_cache]
|
||||
|
||||
updated_kv_caches: list[torch.Tensor | None] = []
|
||||
for block_index, block in enumerate(self.blocks):
|
||||
x, updated_kv_cache = block(
|
||||
x=x,
|
||||
@@ -1000,9 +1055,9 @@ class CausalWanModel(nn.Module):
|
||||
timestep: torch.Tensor,
|
||||
context: torch.Tensor,
|
||||
seq_len: int,
|
||||
kv_cache: list[torch.Tensor],
|
||||
crossattn_cache: list[torch.Tensor],
|
||||
kv_cache: list[Any],
|
||||
current_start_frame: int,
|
||||
crossattn_cache: list[dict] | None = None,
|
||||
y: torch.Tensor | None = None,
|
||||
clip_feature: torch.Tensor | None = None,
|
||||
action: torch.Tensor | None = None,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
"""DreamZero pipeline for vllm-omni.
|
||||
|
||||
Entry point for DiffusionEngine.step() → pipeline.forward(req)
|
||||
Entry point for DiffusionEngine.step() -> pipeline.forward(req)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -93,17 +93,102 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
|
||||
Multi-output: predict_noise() returns (video_pred, action_pred).
|
||||
CFG: video gets standard CFG, action takes positive branch only.
|
||||
State: DreamZeroState manages KV cache + frame buffer across forward() calls.
|
||||
|
||||
KV is managed by the AR-Diffusion engine: ``self._ar_diffusion_kv_state`` is set by the
|
||||
runner before ``forward()`` and the pipeline routes all KV access (get / update /
|
||||
commit / reset) through the pool-backed state. Purely duck-typed (no engine import).
|
||||
"""
|
||||
|
||||
_ar_diffusion_kv_state = None # set by the runner before each forward
|
||||
|
||||
def _kv_get(self, state, is_negative, seq_len=None, update_kv_cache=False):
|
||||
return self._ar_diffusion_kv_state.get_kv_caches(
|
||||
is_negative,
|
||||
seq_len=seq_len,
|
||||
commit_current=update_kv_cache,
|
||||
)
|
||||
|
||||
def _kv_create(self, state, batch_size, dtype, device, num_layers, num_heads, head_dim):
|
||||
# The engine owns all KV allocation: self-attn is allocated lazily from
|
||||
# paged contexts, and cross-attn is populated eagerly in _kv_populate_cross.
|
||||
# Nothing is created model-side.
|
||||
return
|
||||
|
||||
def _kv_commit(self, is_negative: bool):
|
||||
self._ar_diffusion_kv_state.commit_paged_context(is_negative)
|
||||
|
||||
def _kv_get_cross(self, state, is_negative):
|
||||
"""Cross-attn cache from the engine pool (text k/v + I2V image k_img/v_img)."""
|
||||
return self._ar_diffusion_kv_state.get_cross_kv_caches(is_negative)
|
||||
|
||||
def _kv_populate_cross(self, context: torch.Tensor, clip_feature, is_negative: bool) -> None:
|
||||
"""Eagerly project cross-attn K/V for all layers into the AR-Diffusion pool.
|
||||
|
||||
Caches the session-invariant cross-attn projections once, per half: the text
|
||||
``k``/``v`` from ``text_embedding(context)`` survive window-boundary resets
|
||||
(prompt unchanged within a session — only session resets clear them), while
|
||||
the I2V image-token ``k_img``/``v_img`` from ``img_emb(clip_feature)`` (the
|
||||
257 image tokens the forward splits off, cached model-side by #4154) are
|
||||
re-projected on every window restart from the fresh CLIP features. Must run
|
||||
after the image is encoded so ``clip_feature`` is available.
|
||||
"""
|
||||
s = self._ar_diffusion_kv_state
|
||||
need_text = not s._cross_text_populated.get(is_negative, False)
|
||||
need_img = (
|
||||
clip_feature is not None
|
||||
and getattr(self.transformer, "model_type", "t2v") == "i2v"
|
||||
and not s._cross_img_populated.get(is_negative, False)
|
||||
)
|
||||
if not need_text and not need_img:
|
||||
return
|
||||
projected = self.transformer.text_embedding(context) if need_text else None
|
||||
img_ctx = self.transformer.img_emb(clip_feature) if need_img else None
|
||||
for i, block in enumerate(self.transformer.blocks):
|
||||
ca = block.cross_attn
|
||||
n, d = ca.tp_num_heads, ca.head_dim
|
||||
k = v = None
|
||||
if projected is not None:
|
||||
k = ca.norm_k(ca.k(projected)).unflatten(2, (n, d))
|
||||
v = ca.v(projected).unflatten(2, (n, d))
|
||||
k_img = v_img = None
|
||||
if img_ctx is not None:
|
||||
k_img = ca.norm_k_img(ca.k_img(img_ctx)).unflatten(2, (n, d))
|
||||
v_img = ca.v_img(img_ctx).unflatten(2, (n, d))
|
||||
s.kv_cache.write_cross_kv(i, is_negative, k, v, k_img, v_img)
|
||||
if need_text:
|
||||
s._cross_text_populated[is_negative] = True
|
||||
if need_img:
|
||||
s._cross_img_populated[is_negative] = True
|
||||
logger.info(
|
||||
"AR-Diffusion CROSS POPULATE [%s]: %d layers, text=%s img=%s",
|
||||
"neg" if is_negative else "pos",
|
||||
len(self.transformer.blocks),
|
||||
"kept" if projected is None else tuple(context.shape),
|
||||
None if img_ctx is None else tuple(img_ctx.shape),
|
||||
)
|
||||
|
||||
def _kv_reset(self, state, *, clear_video_latents: bool = True):
|
||||
"""Reset the engine's pooled session window plus the model's non-KV state.
|
||||
|
||||
DreamZero resets at the attention-window boundary; the engine pool drops the
|
||||
same window so the next forward starts fresh. ``clear_video_latents=False``
|
||||
keeps the accumulated video latents for export.
|
||||
|
||||
``clear_video_latents=False`` also marks a window ("inference") reset: the
|
||||
prompt is unchanged, so the pool keeps the text cross-attn K/V and only the
|
||||
image half repopulates on the restart forward.
|
||||
"""
|
||||
state.reset(clear_video_latents=clear_video_latents)
|
||||
self._ar_diffusion_kv_state.reset(keep_cross_text=not clear_video_latents)
|
||||
|
||||
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = "") -> None:
|
||||
"""Initialize pipeline components.
|
||||
|
||||
DreamZero root checkpoint layout (GEAR-Dreams/DreamZero-DROID):
|
||||
config.json — root config (action_head_cfg, architectures, etc.)
|
||||
model-*.safetensors — all learned weights (action_head.{model,text_encoder,image_encoder,vae}.*)
|
||||
experiment_cfg/metadata.json — per-embodiment action normalization stats
|
||||
vae/ — symlink to Wan2.1 VAE (diffusers-compatible)
|
||||
config.json -- root config (action_head_cfg, architectures, etc.)
|
||||
model-*.safetensors -- all learned weights (action_head.{model,text_encoder,image_encoder,vae}.*)
|
||||
experiment_cfg/metadata.json -- per-embodiment action normalization stats
|
||||
vae/ -- symlink to Wan2.1 VAE (diffusers-compatible)
|
||||
|
||||
Components are instantiated from config (not from_pretrained), then filled
|
||||
by load_weights() which reads root safetensors and remaps key prefixes.
|
||||
@@ -118,6 +203,18 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
# DreamZero is engine-only: every KV access in forward() routes through
|
||||
# the AR-Diffusion engine's pool-backed state. Fail fast here — a stale
|
||||
# or programmatic config that leaves engine_backend="default" would
|
||||
# otherwise only crash mid-forward on the first KV access.
|
||||
engine_backend = str(getattr(od_config, "engine_backend", "") or "")
|
||||
if "ar_diffusion" not in engine_backend.lower().replace("-", "_"):
|
||||
raise ValueError(
|
||||
"DreamZeroPipeline requires the AR-Diffusion engine; set "
|
||||
"engine_backend: vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine "
|
||||
f"in the deploy config (got engine_backend={engine_backend!r})."
|
||||
)
|
||||
|
||||
model_path = od_config.model
|
||||
model_config = od_config.model_config
|
||||
local_files_only = os.path.exists(model_path)
|
||||
@@ -228,8 +325,11 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
self.max_action_dim: int = ah_config["max_action_dim"]
|
||||
|
||||
self.negative_prompt: str = model_config.get("negative_prompt", DEFAULT_NEGATIVE_PROMPT)
|
||||
# The negative prompt is a model constant: encode it once, lazily, and
|
||||
# reuse across every forward/session (UMT5 encode is deterministic).
|
||||
self._negative_prompt_embeds_cache: torch.Tensor | None = None
|
||||
|
||||
# Embodiment name → numeric ID mapping (model knowledge)
|
||||
# Embodiment name -> numeric ID mapping (model knowledge)
|
||||
self.embodiment_name_to_id: dict[str, int] = model_config.get(
|
||||
"embodiment_name_to_id",
|
||||
DEFAULT_EMBODIMENT_NAME_TO_ID,
|
||||
@@ -344,11 +444,17 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
state=kwargs.get("state_features"),
|
||||
embodiment_id=kwargs.get("embodiment_id"),
|
||||
)
|
||||
if kwargs.get("update_kv_cache", False) and updated_kv_caches:
|
||||
state = kwargs.get("dreamzero_state", self.state)
|
||||
if kwargs.get("update_kv_cache", False):
|
||||
is_neg = kwargs.get("is_negative", False)
|
||||
for i, kv in enumerate(updated_kv_caches):
|
||||
state.update_kv_cache(i, kv, is_negative=is_neg)
|
||||
logger.debug(
|
||||
"AR-Diffusion pipeline predict_noise -> commit paged context: "
|
||||
"is_neg=%s seq_len=%s current_start_frame=%s layers=%d",
|
||||
is_neg,
|
||||
kwargs.get("seq_len"),
|
||||
kwargs.get("current_start_frame"),
|
||||
len(updated_kv_caches),
|
||||
)
|
||||
self._kv_commit(is_neg)
|
||||
|
||||
return video_pred, action_pred
|
||||
|
||||
@@ -362,6 +468,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
Paper D.2 uses ``mode=reduce-overhead``, ``fullgraph=True``, ``dynamic=False``
|
||||
on text/image/VAE and DiT. VAE decode uses a tensor feat_cache patch and
|
||||
compiles ``decoder.forward`` (not ``_decode``, which has a Python frame loop).
|
||||
Incremental VAE encode (``_vae_encode_encoder_chunk``) stays eager because
|
||||
Wan ``feat_cache`` mutation is incompatible with CUDAGraph capture.
|
||||
DiT blocks use per-block ``fullgraph=True``.
|
||||
"""
|
||||
if not torch.cuda.is_available():
|
||||
@@ -376,6 +484,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
|
||||
compile_ro = {"mode": "reduce-overhead", "fullgraph": True, "dynamic": False}
|
||||
# DiT blocks: default avoids CUDAGraph overwrite on modulation tensors; encoders use reduce-overhead.
|
||||
# The AR-Diffusion paged self-attention is a registered custom op, so the
|
||||
# block stays fullgraph even on that path.
|
||||
dit_compile = {"mode": "default", "fullgraph": True, "dynamic": False}
|
||||
|
||||
logger.info(
|
||||
@@ -396,9 +506,9 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
logger.warning("DreamZero: image_encoder compile failed (%s); skipping.", exc)
|
||||
|
||||
try:
|
||||
self.vae.encode = torch.compile(self.vae.encode, **compile_ro)
|
||||
self.vae._encode = torch.compile(self.vae._encode, **compile_ro)
|
||||
except Exception as exc:
|
||||
logger.warning("DreamZero: vae.encode compile failed (%s); skipping.", exc)
|
||||
logger.warning("DreamZero: vae._encode compile failed (%s); skipping.", exc)
|
||||
|
||||
compiled_blocks = 0
|
||||
for block in self.transformer.blocks:
|
||||
@@ -437,10 +547,24 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
|
||||
try:
|
||||
image = torch.zeros(1, 1, 3, 180, 320, dtype=torch.bfloat16, device=device)
|
||||
self._encode_image(image, self.num_frames, 180, 320)
|
||||
self._encode_image(image, self.num_frames, 180, 320, state=self.state)
|
||||
except Exception as exc:
|
||||
logger.warning("DreamZero compile warmup (image_encoder) skipped: %s", exc)
|
||||
|
||||
try:
|
||||
self.state.reset_vae_encoder_stream()
|
||||
dummy_video = torch.zeros(1, 3, 1, 180, 320, dtype=torch.bfloat16, device=device)
|
||||
self._vae_stream_seed(self.state, dummy_video[:, :, :1])
|
||||
for _ in range(4):
|
||||
self._vae_stream_append_frame(self.state, dummy_video[:, :, :1])
|
||||
self._vae_stream_get_observation_latents(
|
||||
self.state,
|
||||
self.num_frame_per_block,
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("DreamZero compile warmup (vae encode stream) skipped: %s", exc)
|
||||
|
||||
try:
|
||||
latent_h, latent_w = 180 // 8, 320 // 8
|
||||
dummy_latent = torch.zeros(
|
||||
@@ -499,7 +623,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _preprocess_video(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
"""uint8 [B,T,H,W,C] → bfloat16 [B,C,T,H,W] normalized to [-1,1]."""
|
||||
"""uint8 [B,T,H,W,C] -> bfloat16 [B,C,T,H,W] normalized to [-1,1]."""
|
||||
videos = videos.permute(0, 4, 1, 2, 3)
|
||||
if videos.dtype == torch.uint8:
|
||||
videos = videos.float() / 255.0
|
||||
@@ -546,6 +670,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
num_frames: int,
|
||||
height: int,
|
||||
width: int,
|
||||
*,
|
||||
state: DreamZeroState | None = None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
"""Encode first frame via CLIP + VAE.
|
||||
Returns: (clip_feas, ys, image_latent)
|
||||
@@ -581,11 +707,147 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
new_image = y[:, :, 0:1]
|
||||
y = torch.concat([msk, y], dim=1)
|
||||
|
||||
if state is not None:
|
||||
if not state.vae_stream_initialized:
|
||||
# Seed the AR streaming encoder after the compiled full encode above;
|
||||
# ``cudagraph_mark_step_begin`` isolates eager feat_cache work from CUDAGraph.
|
||||
self._vae_stream_seed(state, image_input[:, :, :1])
|
||||
else:
|
||||
# Window ("inference") restart with a live stream: keep the real
|
||||
# frame history in the Wan feat_cache and append the restart
|
||||
# observation instead of reseeding from scratch.
|
||||
self._cudagraph_mark_step_begin()
|
||||
self._vae_stream_append_frame(state, image_input[:, :, :1])
|
||||
|
||||
return clip_context, y, new_image
|
||||
|
||||
def _vae_patchify(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
if self.vae.config.patch_size is not None:
|
||||
from diffusers.models.autoencoders.autoencoder_kl_wan import patchify
|
||||
|
||||
return patchify(videos, patch_size=self.vae.config.patch_size)
|
||||
return videos
|
||||
|
||||
@staticmethod
|
||||
def _vae_clone_feat_map(feat_map: list[torch.Tensor | None]) -> list[torch.Tensor | None]:
|
||||
return [entry.clone() if isinstance(entry, torch.Tensor) else entry for entry in feat_map]
|
||||
|
||||
def _vae_init_enc_feat_map(self) -> list[torch.Tensor | None]:
|
||||
self.vae.clear_cache()
|
||||
return self._vae_clone_feat_map(self.vae._enc_feat_map)
|
||||
|
||||
def _vae_encode_encoder_chunk(
|
||||
self,
|
||||
chunk: torch.Tensor,
|
||||
feat_map: list[torch.Tensor | None],
|
||||
) -> tuple[torch.Tensor, list[torch.Tensor | None]]:
|
||||
"""Run one Wan encoder chunk while mutating ``feat_map`` in place.
|
||||
|
||||
Must stay eager (not ``torch.compile``): Wan causal ``feat_cache`` updates
|
||||
conflict with ``reduce-overhead`` CUDAGraph capture.
|
||||
"""
|
||||
self.vae._enc_feat_map = feat_map
|
||||
self.vae._enc_conv_idx = [0]
|
||||
out = self.vae.encoder(
|
||||
chunk,
|
||||
feat_cache=self.vae._enc_feat_map,
|
||||
feat_idx=self.vae._enc_conv_idx,
|
||||
)
|
||||
return out, self._vae_clone_feat_map(self.vae._enc_feat_map)
|
||||
|
||||
def _vae_quantize_encoder_out(self, encoder_out: torch.Tensor) -> torch.Tensor:
|
||||
enc = self.vae.quant_conv(encoder_out)
|
||||
mu, _ = enc.chunk(2, dim=1)
|
||||
mean = self.vae_latents_mean.to(device=mu.device, dtype=mu.dtype)
|
||||
inv_std = self.vae_latents_inv_std.to(device=mu.device, dtype=mu.dtype)
|
||||
return (mu - mean) * inv_std
|
||||
|
||||
def _vae_stream_seed(self, state: DreamZeroState, first_frame: torch.Tensor) -> None:
|
||||
"""Seed incremental VAE encode with the first observation frame."""
|
||||
state.reset_vae_encoder_stream()
|
||||
feat_map = self._vae_init_enc_feat_map()
|
||||
chunk = self._vae_patchify(first_frame.to(dtype=self.vae.dtype))
|
||||
self._cudagraph_mark_step_begin()
|
||||
encoder_out, feat_map = self._vae_encode_encoder_chunk(chunk[:, :, :1], feat_map)
|
||||
state.vae_enc_feat_map = feat_map
|
||||
state.vae_encoder_out = encoder_out
|
||||
state.vae_pending_body_frames = None
|
||||
state.vae_stream_initialized = True
|
||||
|
||||
def _vae_stream_append_frame(self, state: DreamZeroState, new_frame: torch.Tensor) -> None:
|
||||
"""Append one pixel frame and encode a 4-frame body chunk when ready."""
|
||||
if not state.vae_stream_initialized or state.vae_enc_feat_map is None or state.vae_encoder_out is None:
|
||||
raise RuntimeError("VAE encoder stream is not initialized.")
|
||||
|
||||
frame = new_frame.to(dtype=self.vae.dtype)
|
||||
if state.vae_pending_body_frames is None:
|
||||
state.vae_pending_body_frames = frame
|
||||
else:
|
||||
state.vae_pending_body_frames = torch.cat([state.vae_pending_body_frames, frame], dim=2)
|
||||
|
||||
while state.vae_pending_body_frames is not None and state.vae_pending_body_frames.shape[2] >= 4:
|
||||
body = state.vae_pending_body_frames[:, :, :4]
|
||||
if state.vae_pending_body_frames.shape[2] > 4:
|
||||
state.vae_pending_body_frames = state.vae_pending_body_frames[:, :, 4:]
|
||||
else:
|
||||
state.vae_pending_body_frames = None
|
||||
chunk = self._vae_patchify(body)
|
||||
encoder_chunk, feat_map = self._vae_encode_encoder_chunk(chunk, state.vae_enc_feat_map)
|
||||
state.vae_encoder_out = torch.cat([state.vae_encoder_out, encoder_chunk], dim=2)
|
||||
state.vae_enc_feat_map = feat_map
|
||||
|
||||
def _vae_stream_get_observation_latents(
|
||||
self,
|
||||
state: DreamZeroState,
|
||||
num_latent_frames: int,
|
||||
*,
|
||||
dtype: torch.dtype,
|
||||
) -> torch.Tensor:
|
||||
if state.vae_encoder_out is None:
|
||||
raise RuntimeError("VAE encoder stream has no accumulated encoder output.")
|
||||
latents = self._vae_quantize_encoder_out(state.vae_encoder_out).to(dtype=dtype)
|
||||
if latents.shape[2] >= num_latent_frames:
|
||||
return latents[:, :, -num_latent_frames:]
|
||||
pad_count = num_latent_frames - latents.shape[2]
|
||||
pad = latents[:, :, -1:].expand(-1, -1, pad_count, -1, -1)
|
||||
return torch.cat([pad, latents], dim=2)
|
||||
|
||||
def _preprocess_vae_observation_window(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
_, _, num_frames_raw, _, _ = videos.shape
|
||||
if (num_frames_raw - 1) // 4 == self.num_frame_per_block:
|
||||
return videos
|
||||
if num_frames_raw // 4 != self.num_frame_per_block:
|
||||
repeat_factor = self.num_frame_per_block // (num_frames_raw // 4)
|
||||
videos = torch.repeat_interleave(videos, repeat_factor, dim=2)
|
||||
first_frame = videos[:, :, 0:1]
|
||||
return torch.cat([first_frame, videos], dim=2)
|
||||
first_frame = videos[:, :, 0:1]
|
||||
return torch.cat([first_frame, videos], dim=2)
|
||||
|
||||
def _encode_observation_latents(
|
||||
self,
|
||||
state: DreamZeroState,
|
||||
videos: torch.Tensor,
|
||||
*,
|
||||
latent_dtype: torch.dtype,
|
||||
) -> torch.Tensor:
|
||||
"""Encode current robot observation into normalized VAE latents."""
|
||||
if state.vae_stream_initialized:
|
||||
self._cudagraph_mark_step_begin()
|
||||
self._vae_stream_append_frame(state, videos[:, :, -1:])
|
||||
return self._vae_stream_get_observation_latents(
|
||||
state,
|
||||
self.num_frame_per_block,
|
||||
dtype=latent_dtype,
|
||||
)
|
||||
|
||||
videos = self._preprocess_vae_observation_window(videos)
|
||||
return self._encode_vae_latents(videos).to(dtype=latent_dtype)
|
||||
|
||||
def _encode_vae_latents(self, videos: torch.Tensor) -> torch.Tensor:
|
||||
"""Encode videos into normalized VAE latents."""
|
||||
input_dtype = videos.dtype
|
||||
self._cudagraph_mark_step_begin()
|
||||
hidden = self.vae._encode(videos.to(dtype=self.vae.dtype))
|
||||
mu, _ = hidden.chunk(2, dim=1)
|
||||
mean = self.vae_latents_mean.to(device=mu.device, dtype=mu.dtype)
|
||||
@@ -635,7 +897,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
) -> None:
|
||||
"""Prefill KV cache with first frame and/or current observation.
|
||||
|
||||
Uses predict_noise_maybe_with_cfg() for CFG parallel — same path as
|
||||
Uses predict_noise_maybe_with_cfg() for CFG parallel -- same path as
|
||||
the denoise loop. The mixin handles rank dispatch automatically.
|
||||
KV cache update happens as a side effect inside predict_noise().
|
||||
"""
|
||||
@@ -646,7 +908,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
head_dim = self.transformer.dim // self.transformer.num_heads
|
||||
|
||||
if state.current_start_frame == 0:
|
||||
state.create_kv_caches(
|
||||
self._kv_create(
|
||||
state,
|
||||
batch_size,
|
||||
dtype,
|
||||
device,
|
||||
@@ -671,16 +934,16 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
)
|
||||
positive_kwargs = dict(
|
||||
encoder_hidden_states=prompt_embeds,
|
||||
kv_cache=state.get_kv_caches(False),
|
||||
crossattn_cache=state.get_crossattn_caches(False),
|
||||
kv_cache=self._kv_get(state, False, seq_len=frame_seqlen, update_kv_cache=True),
|
||||
crossattn_cache=self._kv_get_cross(state, False),
|
||||
is_negative=False,
|
||||
**common,
|
||||
)
|
||||
negative_kwargs = (
|
||||
dict(
|
||||
encoder_hidden_states=negative_prompt_embeds,
|
||||
kv_cache=state.get_kv_caches(True),
|
||||
crossattn_cache=state.get_crossattn_caches(True),
|
||||
kv_cache=self._kv_get(state, True, seq_len=frame_seqlen, update_kv_cache=True),
|
||||
crossattn_cache=self._kv_get_cross(state, True),
|
||||
is_negative=True,
|
||||
**common,
|
||||
)
|
||||
@@ -721,16 +984,16 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
)
|
||||
positive_kwargs = dict(
|
||||
encoder_hidden_states=prompt_embeds,
|
||||
kv_cache=state.get_kv_caches(False),
|
||||
crossattn_cache=state.get_crossattn_caches(False),
|
||||
kv_cache=self._kv_get(state, False, seq_len=seq_len, update_kv_cache=True),
|
||||
crossattn_cache=self._kv_get_cross(state, False),
|
||||
is_negative=False,
|
||||
**common,
|
||||
)
|
||||
negative_kwargs = (
|
||||
dict(
|
||||
encoder_hidden_states=negative_prompt_embeds,
|
||||
kv_cache=state.get_kv_caches(True),
|
||||
crossattn_cache=state.get_crossattn_caches(True),
|
||||
kv_cache=self._kv_get(state, True, seq_len=seq_len, update_kv_cache=True),
|
||||
crossattn_cache=self._kv_get_cross(state, True),
|
||||
is_negative=True,
|
||||
**common,
|
||||
)
|
||||
@@ -763,8 +1026,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
|
||||
For each timestep:
|
||||
1. Build positive_kwargs / negative_kwargs
|
||||
2. predict_noise_maybe_with_cfg() → (video_pred, action_pred)
|
||||
3. scheduler_step_maybe_with_cfg() → VideoActionScheduler
|
||||
2. predict_noise_maybe_with_cfg() -> (video_pred, action_pred)
|
||||
3. scheduler_step_maybe_with_cfg() -> VideoActionScheduler
|
||||
4. _synchronize_cfg_parallel_step_output()
|
||||
"""
|
||||
seq_len = kwargs["seq_len"]
|
||||
@@ -823,8 +1086,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
hidden_states=noisy_input.transpose(1, 2),
|
||||
timestep_video=timestep,
|
||||
encoder_hidden_states=prompt_embeds,
|
||||
kv_cache=state.get_kv_caches(False),
|
||||
crossattn_cache=state.get_crossattn_caches(False),
|
||||
kv_cache=self._kv_get(state, False, seq_len=seq_len, update_kv_cache=False),
|
||||
crossattn_cache=self._kv_get_cross(state, False),
|
||||
y=y,
|
||||
clip_feature=state.clip_feas,
|
||||
action=noisy_input_action,
|
||||
@@ -838,8 +1101,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
hidden_states=noisy_input.transpose(1, 2),
|
||||
timestep_video=timestep,
|
||||
encoder_hidden_states=negative_prompt_embeds,
|
||||
kv_cache=state.get_kv_caches(True),
|
||||
crossattn_cache=state.get_crossattn_caches(True),
|
||||
kv_cache=self._kv_get(state, True, seq_len=seq_len, update_kv_cache=False),
|
||||
crossattn_cache=self._kv_get_cross(state, True),
|
||||
y=y,
|
||||
clip_feature=state.clip_feas,
|
||||
action=noisy_input_action,
|
||||
@@ -936,7 +1199,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
device=device,
|
||||
)
|
||||
|
||||
# State: raw from transform → pad to (B, state_horizon=1, max_state_dim)
|
||||
# State: raw from transform -> pad to (B, state_horizon=1, max_state_dim)
|
||||
raw_state = unified_obs["state"]
|
||||
state_for_postprocess = None
|
||||
if raw_state is not None:
|
||||
@@ -976,38 +1239,66 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
# Explicit reset from OpenPI serving is carried by `extra_args["reset"]`
|
||||
# on the next inference request after websocket reset/session switch.
|
||||
if extra_args.get("reset", False):
|
||||
state.reset()
|
||||
self._kv_reset(state)
|
||||
else:
|
||||
# Auto-reset based on model state (before accumulation). Both a "session"
|
||||
# reset and an "inference" (window-boundary) reset clear the model-local
|
||||
# KV window, so route both through _kv_reset to drop the AR-Diffusion pool window
|
||||
# too and stay parity-exact; "inference" keeps the accumulated video
|
||||
# latents (reset_inference_state == reset(clear_video_latents=False)).
|
||||
reset_reason = state.reset_reason(text_tokens, 0, self.transformer.local_attn_size)
|
||||
if reset_reason == "session":
|
||||
state.reset()
|
||||
self._kv_reset(state)
|
||||
elif reset_reason == "inference":
|
||||
state.reset_inference_state()
|
||||
self._kv_reset(state, clear_video_latents=False)
|
||||
state.language = text_tokens
|
||||
|
||||
# Frame accumulation: stitched single frame → multi-frame video
|
||||
# Frame accumulation: stitched single frame -> multi-frame video
|
||||
video_frames = state.accumulate_frames(stitched) # (T, H, W, C)
|
||||
videos = torch.from_numpy(video_frames).unsqueeze(0).to(device) # (B=1, T, H, W, C)
|
||||
|
||||
videos = self._preprocess_video(videos) # → [B,C,T,H,W] bf16
|
||||
videos = self._preprocess_video(videos) # -> [B,C,T,H,W] bf16
|
||||
_, _, num_frames_raw, height, width = videos.shape
|
||||
|
||||
prompt_embeds = self._encode_text(text_tokens, attention_mask)
|
||||
# Optional phase timing (DZ_PHASE_TIMING=1): logs per-forward stage costs
|
||||
# (text encode / obs VAE encode / KV prefill / denoise) at INFO. Each mark
|
||||
# synchronizes CUDA, so leave it off for timed benchmark runs.
|
||||
_pt = None
|
||||
if os.environ.get("DZ_PHASE_TIMING"):
|
||||
import time as _time
|
||||
|
||||
torch.accelerator.synchronize()
|
||||
_pt = {"time": _time, "t0": _time.perf_counter(), "marks": []}
|
||||
|
||||
def _pt_mark(name: str) -> None:
|
||||
if _pt is not None:
|
||||
torch.accelerator.synchronize()
|
||||
_pt["marks"].append((name, _pt["time"].perf_counter()))
|
||||
|
||||
# Prompt embeds are constant within a session (a prompt change triggers a
|
||||
# "session" reset above, which clears this cache alongside state.language).
|
||||
if state.prompt_embeds is None:
|
||||
state.prompt_embeds = self._encode_text(text_tokens, attention_mask)
|
||||
prompt_embeds = state.prompt_embeds
|
||||
# Negative prompt for CFG uncond branch (model constant)
|
||||
negative_prompt_embeds = None
|
||||
if self.cfg_scale > 1.0:
|
||||
neg_inputs = self.tokenizer(
|
||||
self.negative_prompt,
|
||||
max_length=512,
|
||||
padding="max_length",
|
||||
truncation=True,
|
||||
return_tensors="pt",
|
||||
add_special_tokens=True,
|
||||
)
|
||||
negative_prompt_embeds = self._encode_text(
|
||||
neg_inputs["input_ids"].to(device),
|
||||
neg_inputs["attention_mask"].to(device),
|
||||
)
|
||||
if self._negative_prompt_embeds_cache is None:
|
||||
neg_inputs = self.tokenizer(
|
||||
self.negative_prompt,
|
||||
max_length=512,
|
||||
padding="max_length",
|
||||
truncation=True,
|
||||
return_tensors="pt",
|
||||
add_special_tokens=True,
|
||||
)
|
||||
self._negative_prompt_embeds_cache = self._encode_text(
|
||||
neg_inputs["input_ids"].to(device),
|
||||
neg_inputs["attention_mask"].to(device),
|
||||
)
|
||||
negative_prompt_embeds = self._negative_prompt_embeds_cache
|
||||
|
||||
_pt_mark("text_encode")
|
||||
|
||||
# Extract first/last frame for CLIP + VAE encoding
|
||||
if num_frames_raw == 4 or num_frames_raw == 9:
|
||||
@@ -1021,27 +1312,24 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
self.num_frames,
|
||||
height,
|
||||
width,
|
||||
state=state,
|
||||
)
|
||||
state.clip_feas = clip_feas.to(dtype=image.dtype)
|
||||
state.ys = ys.to(dtype=image.dtype)
|
||||
|
||||
if state.current_start_frame != 0:
|
||||
# Subsequent calls: encode current observation via VAE
|
||||
if (num_frames_raw - 1) // 4 == self.num_frame_per_block:
|
||||
pass
|
||||
elif num_frames_raw // 4 != self.num_frame_per_block:
|
||||
repeat_factor = self.num_frame_per_block // (num_frames_raw // 4)
|
||||
videos = torch.repeat_interleave(videos, repeat_factor, dim=2)
|
||||
first_frame = videos[:, :, 0:1]
|
||||
videos = torch.cat([first_frame, videos], dim=2)
|
||||
else:
|
||||
first_frame = videos[:, :, 0:1]
|
||||
videos = torch.cat([first_frame, videos], dim=2)
|
||||
# Eager cross-attn population (AR-Diffusion only): cache text + image-token K/V
|
||||
# into the pool now that the image is encoded (clip_feas available).
|
||||
# Runs on the first forward of a session and after each window-boundary
|
||||
# reset (current_start_frame returns to 0); the populate guards (text/img halves) gate re-entry.
|
||||
self._kv_populate_cross(prompt_embeds, state.clip_feas, is_negative=False)
|
||||
if negative_prompt_embeds is not None:
|
||||
self._kv_populate_cross(negative_prompt_embeds, state.clip_feas, is_negative=True)
|
||||
|
||||
if state.current_start_frame != 0:
|
||||
latent_dtype = videos.dtype
|
||||
with torch.no_grad():
|
||||
image = self._encode_vae_latents(videos)
|
||||
image = image.to(dtype=latent_dtype)
|
||||
image = self._encode_observation_latents(state, videos, latent_dtype=latent_dtype)
|
||||
_pt_mark("obs_vae_encode")
|
||||
|
||||
batch_size = image.shape[0]
|
||||
generator = torch.Generator(device=device).manual_seed(self.seed)
|
||||
@@ -1082,6 +1370,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
do_true_cfg,
|
||||
state,
|
||||
)
|
||||
_pt_mark("prefill_kv")
|
||||
|
||||
sample_scheduler = copy.deepcopy(self.scheduler)
|
||||
sample_scheduler_action = copy.deepcopy(self.scheduler)
|
||||
@@ -1123,6 +1412,19 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
state_features=state_features,
|
||||
embodiment_id=embodiment_id,
|
||||
)
|
||||
if _pt is not None:
|
||||
_pt_mark("diffuse")
|
||||
prev_t = _pt["t0"]
|
||||
parts = []
|
||||
for name, t in _pt["marks"]:
|
||||
parts.append(f"{name}={1000 * (t - prev_t):.1f}ms")
|
||||
prev_t = t
|
||||
logger.info(
|
||||
"DZ_PHASE_TIMING csf=%s total=%.1fms %s",
|
||||
state.current_start_frame,
|
||||
1000 * (prev_t - _pt["t0"]),
|
||||
" ".join(parts),
|
||||
)
|
||||
|
||||
if state.current_start_frame == 1:
|
||||
video_out = torch.cat([image, video_out], dim=1)
|
||||
@@ -1133,7 +1435,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
# q99 denorm: [-1,1] → real values
|
||||
action_out = self._denormalize_action(action_out.float(), embodiment_name)
|
||||
|
||||
# Relative → absolute: only for relative_action_keys (joint_position only)
|
||||
# Relative -> absolute: only for relative_action_keys (joint_position only)
|
||||
# gripper_position is NOT relative, so don't add state back to it
|
||||
if self.relative_action and state_for_postprocess is not None:
|
||||
n_relative = self.relative_action_dim # 7 for DROID (joint only)
|
||||
@@ -1145,7 +1447,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
action_out[..., :n_relative] + last_state.unsqueeze(1) # broadcast over horizon
|
||||
)
|
||||
|
||||
# Squeeze batch dim for output: (B, horizon, dim) → (horizon, dim)
|
||||
# Squeeze batch dim for output: (B, horizon, dim) -> (horizon, dim)
|
||||
actions_np = action_out.squeeze(0).float().cpu().numpy() # (horizon, max_action_dim)
|
||||
actions_np = transform.transform_action_output(actions_np)
|
||||
|
||||
@@ -1245,7 +1547,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
|
||||
q01 = stats["q01"].to(device=action.device, dtype=action.dtype)
|
||||
q99 = stats["q99"].to(device=action.device, dtype=action.dtype)
|
||||
# action shape: (B, horizon, action_dim) or (B, horizon, max_action_dim)
|
||||
# q01/q99 shape: (actual_action_dim,) — only denorm actual dims
|
||||
# q01/q99 shape: (actual_action_dim,) -- only denorm actual dims
|
||||
actual_dim = q01.shape[0]
|
||||
action_real = action.clone()
|
||||
action_real[..., :actual_dim] = (action[..., :actual_dim] + 1) / 2 * (q99 - q01) + q01
|
||||
|
||||
@@ -115,17 +115,24 @@ class DreamZeroState:
|
||||
# Concatenated along T before decode (matches upstream video_across_time).
|
||||
self.video_latents_across_time = saved_video_latents
|
||||
|
||||
# KV cache once robot-policy diffusion supports that integration.
|
||||
self.kv_cache = None
|
||||
self.kv_cache_neg = None
|
||||
self.crossattn_cache = None
|
||||
self.crossattn_cache_neg = None
|
||||
self.current_start_frame = 0
|
||||
|
||||
self.clip_feas = None
|
||||
self.ys = None
|
||||
if clear_video_latents:
|
||||
# Session reset: drop the prompt-embed cache and VAE encoder stream.
|
||||
self.language = None
|
||||
self.prompt_embeds = None
|
||||
self.reset_vae_encoder_stream()
|
||||
# Window ("inference") resets keep both: the prompt is unchanged, and the
|
||||
# Wan feat_cache history is independent of the DiT attention window.
|
||||
|
||||
def reset_vae_encoder_stream(self) -> None:
|
||||
"""Clear incremental Wan VAE encoder state used across AR steps."""
|
||||
self.vae_stream_initialized = False
|
||||
self.vae_enc_feat_map: list[torch.Tensor | None] | None = None
|
||||
self.vae_encoder_out: torch.Tensor | None = None
|
||||
self.vae_pending_body_frames: torch.Tensor | None = None
|
||||
|
||||
def reset_inference_state(self) -> None:
|
||||
"""Reset KV/frame state after local attention rolls without dropping video latents."""
|
||||
@@ -166,57 +173,3 @@ class DreamZeroState:
|
||||
def should_reset(self, text_tokens: torch.Tensor | None, num_video_frames: int, local_attn_size: int) -> bool:
|
||||
"""Determine if state should be reset before this forward()."""
|
||||
return self.reset_reason(text_tokens, num_video_frames, local_attn_size) is not None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# KV cache management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_kv_caches(
|
||||
self,
|
||||
batch_size: int,
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
num_layers: int,
|
||||
num_heads: int,
|
||||
head_dim: int,
|
||||
) -> None:
|
||||
"""Initialize empty KV caches and cross-attention caches."""
|
||||
self.kv_cache = [
|
||||
torch.zeros(2, batch_size, 0, num_heads, head_dim, dtype=dtype, device=device) for _ in range(num_layers)
|
||||
]
|
||||
self.kv_cache_neg = [
|
||||
torch.zeros(2, batch_size, 0, num_heads, head_dim, dtype=dtype, device=device) for _ in range(num_layers)
|
||||
]
|
||||
|
||||
self.crossattn_cache = [
|
||||
{"is_init": False, "k": None, "v": None, "k_img": None, "v_img": None} for _ in range(num_layers)
|
||||
]
|
||||
self.crossattn_cache_neg = [
|
||||
{"is_init": False, "k": None, "v": None, "k_img": None, "v_img": None} for _ in range(num_layers)
|
||||
]
|
||||
|
||||
def update_kv_cache(
|
||||
self,
|
||||
layer_index: int,
|
||||
updated_kv: torch.Tensor,
|
||||
is_negative: bool = False,
|
||||
) -> None:
|
||||
"""Update a single layer's KV cache after prefill."""
|
||||
cache = self.kv_cache_neg if is_negative else self.kv_cache
|
||||
if cache is None:
|
||||
raise RuntimeError("KV caches not initialized, call create_kv_caches first.")
|
||||
cache[layer_index] = updated_kv.clone()
|
||||
|
||||
def get_kv_caches(self, is_negative: bool = False) -> list[torch.Tensor]:
|
||||
"""Get KV caches for the specified branch."""
|
||||
cache = self.kv_cache_neg if is_negative else self.kv_cache
|
||||
if cache is None:
|
||||
raise RuntimeError("KV caches not initialized.")
|
||||
return cache
|
||||
|
||||
def get_crossattn_caches(self, is_negative: bool = False) -> list[dict[str, bool | torch.Tensor | None]]:
|
||||
"""Get cross-attention caches for the specified branch."""
|
||||
cache = self.crossattn_cache_neg if is_negative else self.crossattn_cache
|
||||
if cache is None:
|
||||
raise RuntimeError("Cross-attn caches not initialized.")
|
||||
return cache
|
||||
@@ -9,6 +9,35 @@ import torch
|
||||
class DreamZeroVideoExportWorkerExtension:
|
||||
"""DreamZero worker RPCs used by offline example video export."""
|
||||
|
||||
def gpu_mem_stats(self) -> dict:
|
||||
"""Peak GPU memory (GiB) for this worker's device, for profiling.
|
||||
|
||||
``max_memory_reserved`` is the caching-allocator high-water mark (the best
|
||||
in-process proxy for "peak VRAM"); ``max_memory_allocated`` is the live
|
||||
tensor high-water mark. Both are monotonic from process start, so the
|
||||
end-of-run values capture the whole rollout's peak.
|
||||
"""
|
||||
dev = torch.accelerator.current_device_index()
|
||||
return {
|
||||
"device": int(dev),
|
||||
"peak_reserved_gib": torch.accelerator.max_memory_reserved(dev) / (1024**3),
|
||||
"peak_allocated_gib": torch.accelerator.max_memory_allocated(dev) / (1024**3),
|
||||
}
|
||||
|
||||
def ar_diffusion_perf_stats(self, reset: bool = True) -> dict:
|
||||
"""Per-request server-side forward E2E times (s) recorded by the AR-Diffusion runner.
|
||||
|
||||
The worker-side compute time per ``execute_model`` — the analog of the
|
||||
upstream server's per-request E2E — which excludes the engine<->worker IPC
|
||||
that the client's ``omni.generate`` wall time includes. Returns ``[]`` when
|
||||
the runner is the base (KV-disabled) runner that does not record timings.
|
||||
"""
|
||||
runner = self.model_runner
|
||||
times = list(getattr(runner, "_perf_e2e_times", []) or [])
|
||||
if reset and hasattr(runner, "_perf_e2e_times"):
|
||||
runner._perf_e2e_times = []
|
||||
return {"server_e2e_s": times}
|
||||
|
||||
@staticmethod
|
||||
def _latents_to_uint8_frames(decoded: torch.Tensor) -> torch.Tensor:
|
||||
decoded = decoded.squeeze(0).permute(1, 2, 3, 0).contiguous()
|
||||
|
||||
@@ -502,38 +502,14 @@ class FluxPipeline(
|
||||
return False
|
||||
return True
|
||||
|
||||
def forward(
|
||||
self,
|
||||
req: DiffusionRequestBatch,
|
||||
prompt: str | list[str] | None = None,
|
||||
prompt_2: str | list[str] | None = None,
|
||||
negative_prompt: str | list[str] | None = None,
|
||||
negative_prompt_2: str | list[str] | None = None,
|
||||
true_cfg_scale: float = 1.0,
|
||||
height: int | None = None,
|
||||
width: int | None = None,
|
||||
num_inference_steps: int = 28,
|
||||
sigmas: list[float] | None = None,
|
||||
guidance_scale: float = 3.5,
|
||||
num_images_per_prompt: int = 1,
|
||||
generator: torch.Generator | list[torch.Generator] | None = None,
|
||||
latents: torch.FloatTensor | None = None,
|
||||
prompt_embeds: torch.FloatTensor | None = None,
|
||||
pooled_prompt_embeds: torch.FloatTensor | None = None,
|
||||
negative_prompt_embeds: torch.FloatTensor | None = None,
|
||||
negative_pooled_prompt_embeds: torch.FloatTensor | None = None,
|
||||
output_type: str | None = "pil",
|
||||
return_dict: bool = True,
|
||||
joint_attention_kwargs: dict[str, Any] | None = None,
|
||||
callback_on_step_end_tensor_inputs: list[str] = ["latents"],
|
||||
max_sequence_length: int = 512,
|
||||
) -> list[DiffusionOutput]:
|
||||
def forward(self, req: DiffusionRequestBatch) -> list[DiffusionOutput]:
|
||||
"""Forward pass for flux."""
|
||||
# TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "")
|
||||
# TODO: May be some data formatting operations on the API side. Hack for now.
|
||||
sampling_params_list = req.sampling_params_list
|
||||
common_sampling_params = sampling_params_list[0]
|
||||
prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt
|
||||
prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts]
|
||||
|
||||
if all(isinstance(p, str) or p.get("negative_prompt") is None for p in req.prompts):
|
||||
negative_prompt = None
|
||||
elif req.prompts:
|
||||
@@ -541,40 +517,39 @@ class FluxPipeline(
|
||||
|
||||
height = common_sampling_params.height or self.default_sample_size * self.vae_scale_factor
|
||||
width = common_sampling_params.width or self.default_sample_size * self.vae_scale_factor
|
||||
num_inference_steps = common_sampling_params.num_inference_steps or num_inference_steps
|
||||
sigmas = common_sampling_params.sigmas or sigmas
|
||||
guidance_scale = (
|
||||
common_sampling_params.guidance_scale
|
||||
if common_sampling_params.guidance_scale is not None
|
||||
else guidance_scale
|
||||
)
|
||||
true_cfg_scale = common_sampling_params.true_cfg_scale or true_cfg_scale
|
||||
num_inference_steps = common_sampling_params.num_inference_steps or 28
|
||||
sigmas = common_sampling_params.sigmas
|
||||
guidance_scale = common_sampling_params.guidance_scale
|
||||
true_cfg_scale = common_sampling_params.true_cfg_scale or 1.0
|
||||
num_images_per_prompt = (
|
||||
common_sampling_params.num_outputs_per_prompt
|
||||
if common_sampling_params.num_outputs_per_prompt > 0
|
||||
else num_images_per_prompt
|
||||
common_sampling_params.num_outputs_per_prompt if common_sampling_params.num_outputs_per_prompt > 0 else 1
|
||||
)
|
||||
generator = req.collate_request_generators(num_images_per_prompt, generator)
|
||||
latents = req.collate_request_tensors("latents", latents)
|
||||
max_sequence_length = common_sampling_params.max_sequence_length or 512
|
||||
generator = req.collate_request_generators(num_images_per_prompt, None)
|
||||
latents = req.collate_request_tensors("latents", None)
|
||||
prompt_fields = DiffusionRequestBatch.collate_prompt_field_map(
|
||||
req.prompts,
|
||||
{
|
||||
"prompt_embeds": prompt_embeds,
|
||||
"negative_prompt_embeds": negative_prompt_embeds,
|
||||
"pooled_prompt_embeds": pooled_prompt_embeds,
|
||||
"negative_pooled_prompt_embeds": negative_pooled_prompt_embeds,
|
||||
"prompt_embeds": None,
|
||||
"negative_prompt_embeds": None,
|
||||
"pooled_prompt_embeds": None,
|
||||
"negative_pooled_prompt_embeds": None,
|
||||
},
|
||||
)
|
||||
prompt_embeds = prompt_fields["prompt_embeds"]
|
||||
negative_prompt_embeds = prompt_fields["negative_prompt_embeds"]
|
||||
pooled_prompt_embeds = prompt_fields["pooled_prompt_embeds"]
|
||||
negative_pooled_prompt_embeds = prompt_fields["negative_pooled_prompt_embeds"]
|
||||
prompt_2 = None
|
||||
negative_prompt_2 = None
|
||||
if prompt_embeds is not None:
|
||||
prompt = None
|
||||
prompt_2 = None
|
||||
if negative_prompt_embeds is not None:
|
||||
negative_prompt = None
|
||||
negative_prompt_2 = None
|
||||
|
||||
output_type = common_sampling_params.output_type or "pil"
|
||||
joint_attention_kwargs: dict[str, Any] | None = None
|
||||
callback_on_step_end_tensor_inputs = ["latents"]
|
||||
|
||||
# 1. Check inputs. Raise error if not correct
|
||||
self.check_inputs(
|
||||
@@ -598,9 +573,7 @@ class FluxPipeline(
|
||||
self._interrupt = False
|
||||
|
||||
# 2. Define call parameters
|
||||
if prompt is not None and isinstance(prompt, str):
|
||||
batch_size = 1
|
||||
elif prompt is not None and isinstance(prompt, list):
|
||||
if prompt is not None:
|
||||
batch_size = len(prompt)
|
||||
else:
|
||||
batch_size = prompt_embeds.shape[0]
|
||||
|
||||
@@ -750,108 +750,7 @@ class Flux2KleinPipeline(
|
||||
def interrupt(self):
|
||||
return self._interrupt
|
||||
|
||||
def forward(
|
||||
self,
|
||||
req: DiffusionRequestBatch,
|
||||
image: PIL.Image.Image | list[PIL.Image.Image] | None = None,
|
||||
reference_image: PIL.Image.Image | list[PIL.Image.Image] | None = None,
|
||||
mask_image: PIL.Image.Image | list[PIL.Image.Image] | None = None,
|
||||
prompt: str | list[str] | None = None,
|
||||
height: int | None = None,
|
||||
width: int | None = None,
|
||||
num_inference_steps: int = 50,
|
||||
sigmas: list[float] | None = None,
|
||||
strength: float = 1.0,
|
||||
guidance_scale: float | None = 4.0,
|
||||
num_images_per_prompt: int = 1,
|
||||
generator: torch.Generator | list[torch.Generator] | None = None,
|
||||
latents: torch.Tensor | None = None,
|
||||
prompt_embeds: torch.Tensor | None = None,
|
||||
negative_prompt_embeds: torch.Tensor | None = None,
|
||||
output_type: str | None = "pil",
|
||||
return_dict: bool = True,
|
||||
attention_kwargs: dict[str, Any] | None = None,
|
||||
callback_on_step_end: Callable[[int, int, dict], None] | None = None,
|
||||
callback_on_step_end_tensor_inputs: list[str] = ["latents"],
|
||||
max_sequence_length: int = 512,
|
||||
text_encoder_out_layers: tuple[int, ...] = (9, 18, 27),
|
||||
padding_mask_crop: int | None = None,
|
||||
) -> DiffusionOutput:
|
||||
r"""
|
||||
Function invoked when calling the pipeline for generation.
|
||||
|
||||
Args:
|
||||
image (`torch.Tensor`, `PIL.Image.Image`, `np.ndarray`, or list of these):
|
||||
`Image`, numpy array or tensor representing an image batch to be used as the starting point. For both
|
||||
numpy array and pytorch tensor, the expected value range is between `[0, 1]` If it's a tensor or a list
|
||||
or tensors, the expected shape should be `(B, C, H, W)` or `(C, H, W)`. If it is a numpy array or a
|
||||
list of arrays, the expected shape should be `(B, H, W, C)` or `(H, W, C)` It can also accept image
|
||||
latents as `image`, but if passing latents directly it is not encoded again.
|
||||
prompt (`str` or `List[str]`, *optional*):
|
||||
The prompt or prompts to guide the image generation. If not defined, one has to pass `prompt_embeds`.
|
||||
instead.
|
||||
guidance_scale (`float`, *optional*, defaults to 4.0):
|
||||
Guidance scale as defined in [Classifier-Free Diffusion
|
||||
Guidance](https://huggingface.co/papers/2207.12598). `guidance_scale` is defined as `w` of equation 2.
|
||||
of [Imagen Paper](https://huggingface.co/papers/2205.11487). Guidance scale is enabled by setting
|
||||
`guidance_scale > 1`. Higher guidance scale encourages to generate images that are closely linked to
|
||||
the text `prompt`, usually at the expense of lower image quality. For step-wise distilled models,
|
||||
`guidance_scale` is ignored.
|
||||
height (`int`, *optional*, defaults to self.unet.config.sample_size * self.vae_scale_factor):
|
||||
The height in pixels of the generated image. This is set to 1024 by default for the best results.
|
||||
width (`int`, *optional*, defaults to self.unet.config.sample_size * self.vae_scale_factor):
|
||||
The width in pixels of the generated image. This is set to 1024 by default for the best results.
|
||||
num_inference_steps (`int`, *optional*, defaults to 50):
|
||||
The number of denoising steps. More denoising steps usually lead to a higher quality image at the
|
||||
expense of slower inference.
|
||||
sigmas (`List[float]`, *optional*):
|
||||
Custom sigmas to use for the denoising process with schedulers which support a `sigmas` argument in
|
||||
their `set_timesteps` method. If not defined, the default behavior when `num_inference_steps` is passed
|
||||
will be used.
|
||||
num_images_per_prompt (`int`, *optional*, defaults to 1):
|
||||
The number of images to generate per prompt.
|
||||
generator (`torch.Generator` or `List[torch.Generator]`, *optional*):
|
||||
One or a list of [torch generator(s)](https://pytorch.org/docs/stable/generated/torch.Generator.html)
|
||||
to make generation deterministic.
|
||||
latents (`torch.Tensor`, *optional*):
|
||||
Pre-generated noisy latents, sampled from a Gaussian distribution, to be used as inputs for image
|
||||
generation. Can be used to tweak the same generation with different prompts. If not provided, a latents
|
||||
tensor will be generated by sampling using the supplied random `generator`.
|
||||
prompt_embeds (`torch.Tensor`, *optional*):
|
||||
Pre-generated text embeddings. Can be used to easily tweak text inputs, *e.g.* prompt weighting. If not
|
||||
provided, text embeddings will be generated from `prompt` input argument.
|
||||
negative_prompt_embeds (`torch.Tensor`, *optional*):
|
||||
Pre-generated negative text embeddings. Note that "" is used as the negative prompt in this pipeline.
|
||||
If not provided, will be generated from "".
|
||||
output_type (`str`, *optional*, defaults to `"pil"`):
|
||||
The output format of the generate image. Choose between
|
||||
[PIL](https://pillow.readthedocs.io/en/stable/): `PIL.Image.Image` or `np.array`.
|
||||
return_dict (`bool`, *optional*, defaults to `True`):
|
||||
Whether or not to return a [`~pipelines.qwenimage.QwenImagePipelineOutput`] instead of a plain tuple.
|
||||
attention_kwargs (`dict`, *optional*):
|
||||
A kwargs dictionary that if specified is passed along to the `AttentionProcessor` as defined under
|
||||
`self.processor` in
|
||||
[diffusers.models.attention_processor](https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/attention_processor.py).
|
||||
callback_on_step_end (`Callable`, *optional*):
|
||||
A function that calls at the end of each denoising steps during the inference. The function is called
|
||||
with the following arguments: `callback_on_step_end(self: DiffusionPipeline, step: int, timestep: int,
|
||||
callback_kwargs: Dict)`. `callback_kwargs` will include a list of all tensors as specified by
|
||||
`callback_on_step_end_tensor_inputs`.
|
||||
callback_on_step_end_tensor_inputs (`List`, *optional*):
|
||||
The list of tensor inputs for the `callback_on_step_end` function. The tensors specified in the list
|
||||
will be passed as `callback_kwargs` argument. You will only be able to include variables listed in the
|
||||
`._callback_tensor_inputs` attribute of your pipeline class.
|
||||
max_sequence_length (`int` defaults to 512): Maximum sequence length to use with the `prompt`.
|
||||
text_encoder_out_layers (`Tuple[int]`):
|
||||
Layer indices to use in the `text_encoder` to derive the final prompt embeddings.
|
||||
|
||||
Examples:
|
||||
|
||||
Returns:
|
||||
[`~pipelines.flux2.Flux2PipelineOutput`] or `tuple`: [`~pipelines.flux2.Flux2PipelineOutput`] if
|
||||
`return_dict` is True, otherwise a `tuple`. When returning a tuple, the first element is a list with the
|
||||
generated images.
|
||||
"""
|
||||
def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput:
|
||||
if len(req.prompts) > 1:
|
||||
logger.warning(
|
||||
"""This model only supports a single prompt, not a batched request.""",
|
||||
@@ -878,39 +777,32 @@ class Flux2KleinPipeline(
|
||||
else:
|
||||
image = PIL.Image.open(raw_image) if isinstance(raw_image, str) else cast(PIL.Image.Image, raw_image)
|
||||
|
||||
height = req.sampling_params.height or height
|
||||
width = req.sampling_params.width or width
|
||||
prompt_embeds = None
|
||||
negative_prompt_embeds = None
|
||||
|
||||
height = req.sampling_params.height
|
||||
width = req.sampling_params.width
|
||||
num_inference_steps = (
|
||||
req.sampling_params.num_inference_steps
|
||||
if req.sampling_params.num_inference_steps is not None
|
||||
else num_inference_steps
|
||||
50 if req.sampling_params.num_inference_steps is None else req.sampling_params.num_inference_steps
|
||||
)
|
||||
sigmas = req.sampling_params.sigmas or sigmas
|
||||
guidance_scale = (
|
||||
req.sampling_params.guidance_scale if req.sampling_params.guidance_scale is not None else guidance_scale
|
||||
)
|
||||
generator = req.sampling_params.generator or generator
|
||||
sigmas = req.sampling_params.sigmas
|
||||
guidance_scale = req.sampling_params.guidance_scale
|
||||
generator = req.sampling_params.generator
|
||||
num_images_per_prompt = (
|
||||
req.sampling_params.num_outputs_per_prompt
|
||||
if req.sampling_params.num_outputs_per_prompt > 0
|
||||
else num_images_per_prompt
|
||||
req.sampling_params.num_outputs_per_prompt if req.sampling_params.num_outputs_per_prompt > 0 else 1
|
||||
)
|
||||
max_sequence_length = req.sampling_params.max_sequence_length or max_sequence_length
|
||||
text_encoder_out_layers = req.sampling_params.extra_args.get("text_encoder_out_layers", text_encoder_out_layers)
|
||||
max_sequence_length = req.sampling_params.max_sequence_length or 512
|
||||
latents = req.sampling_params.latents
|
||||
strength = req.sampling_params.strength if req.sampling_params.strength is not None else 1.0
|
||||
|
||||
req_prompt_embeds = [p.get("prompt_embeds") if not isinstance(p, str) else None for p in req.prompts]
|
||||
if any(p is not None for p in req_prompt_embeds):
|
||||
# If at list one prompt is provided as an embedding,
|
||||
# Then assume that the user wants to provide embeddings for all prompts, and enter this if block
|
||||
# If the user in fact provides mixed input format, req_prompt_embeds will have some None's
|
||||
# And `torch.stack` automatically raises an exception for us
|
||||
prompt_embeds = torch.stack(req_prompt_embeds) # type: ignore # intentionally expect TypeError
|
||||
|
||||
req_negative_prompt_embeds = [
|
||||
p.get("negative_prompt_embeds") if not isinstance(p, str) else None for p in req.prompts
|
||||
]
|
||||
if any(p is not None for p in req_negative_prompt_embeds):
|
||||
negative_prompt_embeds = torch.stack(req_negative_prompt_embeds) # type: ignore # intentionally expect TypeError
|
||||
text_encoder_out_layers: tuple[int, ...] = req.sampling_params.extra_args.get(
|
||||
"text_encoder_out_layers", (9, 18, 27)
|
||||
)
|
||||
padding_mask_crop: int | None = req.sampling_params.extra_args.get("padding_mask_crop")
|
||||
attention_kwargs: dict[str, Any] | None = None
|
||||
callback_on_step_end: Callable[[int, int, dict], None] | None = None
|
||||
callback_on_step_end_tensor_inputs = ["latents"]
|
||||
output_type = req.sampling_params.output_type or "pil"
|
||||
|
||||
# 1. Check inputs. Raise error if not correct
|
||||
self.check_inputs(
|
||||
@@ -930,12 +822,7 @@ class Flux2KleinPipeline(
|
||||
self._interrupt = False
|
||||
|
||||
# 2. Define call parameters
|
||||
if prompt is not None and isinstance(prompt, str):
|
||||
batch_size = 1
|
||||
elif prompt is not None and isinstance(prompt, list):
|
||||
batch_size = len(prompt)
|
||||
else:
|
||||
batch_size = prompt_embeds.shape[0]
|
||||
batch_size = 1
|
||||
|
||||
device = self._execution_device
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ from transformers.modeling_outputs import (
|
||||
CausalLMOutputWithPast,
|
||||
)
|
||||
from transformers.modeling_utils import PreTrainedModel
|
||||
from vllm.config import CacheConfig
|
||||
from vllm.config import CacheConfig, get_current_vllm_config
|
||||
from vllm.distributed import (
|
||||
get_tensor_model_parallel_world_size,
|
||||
)
|
||||
@@ -1624,6 +1624,7 @@ class HunYuanSparseMoeBlock(nn.Module):
|
||||
else:
|
||||
self.shared_mlp = None
|
||||
|
||||
enable_expert_parallel = get_current_vllm_config().parallel_config.enable_expert_parallel
|
||||
self.experts = HunyuanFusedMoE(
|
||||
shared_experts=self.shared_mlp,
|
||||
num_experts=self.n_routed_experts,
|
||||
@@ -1635,7 +1636,7 @@ class HunYuanSparseMoeBlock(nn.Module):
|
||||
prefix=f"{prefix}.experts",
|
||||
enable_eplb=self.enable_eplb,
|
||||
num_redundant_experts=self.n_redundant_experts,
|
||||
pcp_size=1,
|
||||
pcp_size=None if enable_expert_parallel else 1,
|
||||
)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
|
||||
@@ -495,66 +495,35 @@ class LongCatImagePipeline(nn.Module, CFGParallelMixin, DiffusionPipelineProfile
|
||||
noise_pred = comb_pred * scale
|
||||
return noise_pred
|
||||
|
||||
def forward(
|
||||
self,
|
||||
req: DiffusionRequestBatch,
|
||||
prompt: str | list[str] | None = None,
|
||||
negative_prompt: str | list[str] | None = None,
|
||||
height: int | None = None,
|
||||
width: int | None = None,
|
||||
num_inference_steps: int = 50,
|
||||
sigmas: list[float] | None = None,
|
||||
guidance_scale: float = 4.5,
|
||||
num_images_per_prompt: int = 1,
|
||||
generator: torch.Generator | list[torch.Generator] | None = None,
|
||||
latents: torch.FloatTensor | None = None,
|
||||
prompt_embeds: torch.Tensor | None = None,
|
||||
negative_prompt_embeds: torch.Tensor | None = None,
|
||||
output_type: str | None = "pil",
|
||||
return_dict: bool = True,
|
||||
joint_attention_kwargs: dict[str, Any] | None = None,
|
||||
enable_cfg_renorm: bool | None = True,
|
||||
cfg_renorm_min: float | None = 0.0,
|
||||
enable_prompt_rewrite: bool | None = True,
|
||||
) -> DiffusionOutput:
|
||||
def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput:
|
||||
# TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "")
|
||||
# TODO: May be some data formatting operations on the API side. Hack for now.
|
||||
prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt
|
||||
prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts]
|
||||
if all(isinstance(p, str) or p.get("negative_prompt") is None for p in req.prompts):
|
||||
negative_prompt = None
|
||||
elif req.prompts:
|
||||
negative_prompt = ["" if isinstance(p, str) else (p.get("negative_prompt") or "") for p in req.prompts]
|
||||
|
||||
height = req.sampling_params.height or height or self.default_sample_size * self.vae_scale_factor
|
||||
width = req.sampling_params.width or width or self.default_sample_size * self.vae_scale_factor
|
||||
num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps
|
||||
sigmas = req.sampling_params.sigmas or sigmas
|
||||
generator = req.sampling_params.generator or generator
|
||||
guidance_scale = (
|
||||
req.sampling_params.guidance_scale if req.sampling_params.guidance_scale is not None else guidance_scale
|
||||
)
|
||||
prompt_embeds = None
|
||||
negative_prompt_embeds = None
|
||||
|
||||
height = req.sampling_params.height or self.default_sample_size * self.vae_scale_factor
|
||||
width = req.sampling_params.width or self.default_sample_size * self.vae_scale_factor
|
||||
num_inference_steps = req.sampling_params.num_inference_steps or 50
|
||||
sigmas = req.sampling_params.sigmas
|
||||
generator = req.sampling_params.generator
|
||||
guidance_scale = req.sampling_params.guidance_scale
|
||||
num_images_per_prompt = (
|
||||
req.sampling_params.num_outputs_per_prompt
|
||||
if req.sampling_params.num_outputs_per_prompt is not None
|
||||
else num_images_per_prompt
|
||||
req.sampling_params.num_outputs_per_prompt if req.sampling_params.num_outputs_per_prompt > 0 else 1
|
||||
)
|
||||
enable_prompt_rewrite = req.sampling_params.extra_args.get("enable_prompt_rewrite", enable_prompt_rewrite)
|
||||
enable_cfg_renorm = req.sampling_params.extra_args.get("enable_cfg_renorm", enable_cfg_renorm)
|
||||
cfg_renorm_min = req.sampling_params.extra_args.get("cfg_renorm_min", cfg_renorm_min)
|
||||
latents = req.sampling_params.latents
|
||||
|
||||
req_prompt_embeds = [p.get("prompt_embeds") if not isinstance(p, str) else None for p in req.prompts]
|
||||
if any(p is not None for p in req_prompt_embeds):
|
||||
# If at list one prompt is provided as an embedding,
|
||||
# Then assume that the user wants to provide embeddings for all prompts, and enter this if block
|
||||
# If the user in fact provides mixed input format, req_prompt_embeds will have some None's
|
||||
# And `torch.stack` automatically raises an exception for us
|
||||
prompt_embeds = torch.stack(req_prompt_embeds) # type: ignore # intentionally expect TypeError
|
||||
|
||||
req_negative_prompt_embeds = [
|
||||
p.get("negative_prompt_embeds") if not isinstance(p, str) else None for p in req.prompts
|
||||
]
|
||||
if any(p is not None for p in req_negative_prompt_embeds):
|
||||
negative_prompt_embeds = torch.stack(req_negative_prompt_embeds) # type: ignore # intentionally expect TypeError
|
||||
extra_args = getattr(req.sampling_params, "extra_args", {}) or {}
|
||||
enable_prompt_rewrite: bool = extra_args.get("enable_prompt_rewrite", True)
|
||||
enable_cfg_renorm: bool = extra_args.get("enable_cfg_renorm", True)
|
||||
cfg_renorm_min: float = extra_args.get("cfg_renorm_min", 0.0)
|
||||
joint_attention_kwargs: dict[str, Any] | None = None
|
||||
output_type = req.sampling_params.output_type or "pil"
|
||||
|
||||
self.check_inputs(
|
||||
prompt,
|
||||
@@ -571,12 +540,7 @@ class LongCatImagePipeline(nn.Module, CFGParallelMixin, DiffusionPipelineProfile
|
||||
self._interrupt = False
|
||||
|
||||
# 2. Define call parameters
|
||||
if prompt is not None and isinstance(prompt, str):
|
||||
batch_size = 1
|
||||
elif prompt is not None and isinstance(prompt, list):
|
||||
batch_size = len(prompt)
|
||||
else:
|
||||
batch_size = prompt_embeds.shape[0]
|
||||
batch_size = len(prompt)
|
||||
|
||||
device = self.device
|
||||
if enable_prompt_rewrite and prompt is not None:
|
||||
|
||||
@@ -6,7 +6,7 @@ import math
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, ClassVar, cast
|
||||
from typing import ClassVar, cast
|
||||
|
||||
import numpy as np
|
||||
import PIL.Image
|
||||
@@ -542,24 +542,7 @@ class LongCatImageEditPipeline(
|
||||
f" {negative_prompt_embeds}. Please make sure to only forward one of the two."
|
||||
)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
req: DiffusionRequestBatch,
|
||||
image: PIL.Image.Image | torch.Tensor | None = None,
|
||||
prompt: str | list[str] | None = None,
|
||||
negative_prompt: str | list[str] | None = None,
|
||||
num_inference_steps: int = 50,
|
||||
sigmas: list[float] | None = None,
|
||||
guidance_scale: float = 3.5,
|
||||
num_images_per_prompt: int | None = 1,
|
||||
generator: torch.Generator | list[torch.Generator] | None = None,
|
||||
latents: torch.FloatTensor | None = None,
|
||||
prompt_embeds: torch.Tensor | None = None,
|
||||
negative_prompt_embeds: torch.Tensor | None = None,
|
||||
output_type: str | None = "pil",
|
||||
return_dict: bool = True,
|
||||
joint_attention_kwargs: dict[str, Any] | None = None,
|
||||
) -> DiffusionOutput:
|
||||
def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput:
|
||||
# TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "")
|
||||
# TODO: May be some data formatting operations on the API side. Hack for now.
|
||||
if len(req.prompts) > 1:
|
||||
@@ -570,27 +553,22 @@ class LongCatImageEditPipeline(
|
||||
first_prompt = req.prompts[0]
|
||||
prompt = first_prompt if isinstance(first_prompt, str) else (first_prompt.get("prompt") or "")
|
||||
negative_prompt = None if isinstance(first_prompt, str) else first_prompt.get("negative_prompt")
|
||||
prompt_embeds = None if isinstance(first_prompt, str) else first_prompt.get("prompt_embeds")
|
||||
negative_prompt_embeds = None if isinstance(first_prompt, str) else first_prompt.get("negative_prompt_embeds") # type: ignore # Why it is list[torch.Tensor] in OmniTokenInputs or OmniEmbedsPrompt? Doesn't make sense
|
||||
|
||||
sigmas = req.sampling_params.sigmas or sigmas
|
||||
guidance_scale = (
|
||||
req.sampling_params.guidance_scale if req.sampling_params.guidance_scale is not None else guidance_scale
|
||||
)
|
||||
num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps
|
||||
sigmas = req.sampling_params.sigmas
|
||||
guidance_scale = req.sampling_params.guidance_scale
|
||||
num_inference_steps = req.sampling_params.num_inference_steps or 50
|
||||
num_images_per_prompt = (
|
||||
req.sampling_params.num_outputs_per_prompt
|
||||
if req.sampling_params.num_outputs_per_prompt is not None
|
||||
else num_images_per_prompt
|
||||
req.sampling_params.num_outputs_per_prompt if req.sampling_params.num_outputs_per_prompt > 0 else 1
|
||||
)
|
||||
generator = req.sampling_params.generator or generator
|
||||
generator = req.sampling_params.generator
|
||||
height = req.sampling_params.height or self.default_sample_size * self.vae_scale_factor
|
||||
width = req.sampling_params.width or self.default_sample_size * self.vae_scale_factor
|
||||
latents = req.sampling_params.latents
|
||||
prompt_embeds = None
|
||||
negative_prompt_embeds = None
|
||||
output_type = req.sampling_params.output_type or "pil"
|
||||
|
||||
if prompt is not None:
|
||||
batch_size = 1 if isinstance(prompt, str) else len(prompt)
|
||||
else:
|
||||
batch_size = prompt_embeds.shape[0]
|
||||
batch_size = 1 if isinstance(prompt, str) else len(prompt)
|
||||
|
||||
if not isinstance(first_prompt, str) and "preprocessed_image" in (
|
||||
additional_information := first_prompt.get("additional_information", {})
|
||||
@@ -600,13 +578,7 @@ class LongCatImageEditPipeline(
|
||||
calculated_height = additional_information.get("calculated_height", height)
|
||||
calculated_width = additional_information.get("calculated_width", width)
|
||||
else:
|
||||
image_size = image[0].size if isinstance(image, list) else image.size
|
||||
calculated_width, calculated_height = calculate_dimensions(1024 * 1024, image_size[0] * 1.0 / image_size[1])
|
||||
|
||||
if image is not None and not (isinstance(image, torch.Tensor) and image.size(1) == self.latent_channels):
|
||||
image = self.image_processor.resize(image, calculated_height, calculated_width)
|
||||
prompt_image = self.image_processor.resize(image, calculated_height // 2, calculated_width // 2)
|
||||
image = self.image_processor.preprocess(image, calculated_height, calculated_width)
|
||||
raise RuntimeError("Missing preprocess image that should have been created by the preprocess function.")
|
||||
|
||||
self.check_inputs(
|
||||
prompt,
|
||||
@@ -621,7 +593,8 @@ class LongCatImageEditPipeline(
|
||||
prompt=prompt, image=prompt_image, prompt_embeds=prompt_embeds, num_images_per_prompt=num_images_per_prompt
|
||||
)
|
||||
|
||||
if guidance_scale > 1:
|
||||
do_true_cfg = guidance_scale > 1 and negative_prompt is not None
|
||||
if do_true_cfg:
|
||||
(negative_prompt_embeds, negative_text_ids) = self.encode_prompt(
|
||||
prompt=negative_prompt,
|
||||
image=prompt_image,
|
||||
@@ -680,7 +653,6 @@ class LongCatImageEditPipeline(
|
||||
latent_model_input = torch.cat([latents, image_latents], dim=1)
|
||||
|
||||
timestep = t.expand(latent_model_input.shape[0]).to(latents.dtype)
|
||||
do_true_cfg = guidance_scale > 1
|
||||
positive_kwargs = {
|
||||
"hidden_states": latent_model_input,
|
||||
"timestep": timestep / 1000,
|
||||
|
||||
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user