Compare commits

..
Author SHA1 Message Date
Yueqian Lin aef61597d3 Merge remote-tracking branch 'origin/main' into HEAD 2026-08-30 03:36:08 -07:00
Yueqian Lin 1ad90653c5 [CI/Build] Add dots.tts weekly e2e coverage
dots.tts landed in #4765 with no GPU coverage: nothing under tests/e2e
exercises it, so the DiT sampler, streaming vocoder and AR loopback have
no CI signal at all.

Add an offline e2e module marked slow + tts + L4, which the existing
weekly 'TTS · L4' step collects via -m 'slow and L4 and tts'. No pipeline
YAML change is needed; the weekly TTS steps are marker driven.

Three cases, the latter two guarding specific review findings from #4765:
  1. zero-shot synthesis produces finite, non-silent audio
  2. mixed prefill+decode batch keeps per-request logits rows aligned
  3. the same text twice yields the same audio (seeded FM noise)

Verified selector behaviour: 3 collected under the weekly selector,
0 collected under the nightly one.

Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
2026-08-13 11:24:50 -07:00
89 changed files with 497 additions and 4781 deletions

No files matched your search

+1 -16
View File
@@ -72,22 +72,7 @@ steps:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- pytest -s -v tests/diffusion/offloader/test_diffusion_cpu_offload.py
- >-
pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py
--deselect='tests/diffusion/offloader/test_diffusion_layerwise_offload.py::test_layerwise_offload_diffusion_model[stabilityai/stable-audio-open-1.0]'
# TODO: Diagnose unstable Stable Audio layerwise-offload memory savings on ROCm.
- label: "Diffusion Stable Audio Layerwise Offload Test (quarantined)"
agent_pool: mi300_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: NonBlocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- >-
pytest -s -v
'tests/diffusion/offloader/test_diffusion_layerwise_offload.py::test_layerwise_offload_diffusion_model[stabilityai/stable-audio-open-1.0]'
- pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py
- label: "Diffusion Sequence Parallelism Test (Need 4 GPUs) · Shard 1/2"
agent_pool: mi300_4
-6
View File
@@ -85,9 +85,6 @@ steps:
- vllm_omni/model_executor/models/common/alias_free_activation.py
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
- vllm_omni/entrypoints/openai/serving_speech.py
- vllm_omni/entrypoints/openai/tts_adapters/
- vllm_omni/engine/stage_pool.py
commands:
- |
timeout 40m bash -c "
@@ -107,9 +104,6 @@ steps:
- vllm_omni/model_executor/models/common/alias_free_activation.py
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
- vllm_omni/entrypoints/openai/serving_speech.py
- vllm_omni/entrypoints/openai/tts_adapters/
- vllm_omni/engine/stage_pool.py
- .buildkite/common/scripts/run_cov_split.sh
- pyproject.toml
# 40 -> 60: the coverage split runs the model twice.
+19 -3
View File
@@ -252,9 +252,6 @@ steps:
- vllm_omni/model_executor/models/common/alias_free_activation.py
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
- vllm_omni/entrypoints/openai/serving_speech.py
- vllm_omni/entrypoints/openai/tts_adapters/
- vllm_omni/engine/stage_pool.py
commands:
- |
timeout 20m bash -c "
@@ -263,6 +260,25 @@ steps:
"
mirror_hardwares: l4_1
- label: "TTS · Qwen3-TTS Base Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_tts_base.py
- vllm_omni/model_executor/models/qwen3_tts/
- vllm_omni/model_executor/models/common/qwen3_code_predictor.py
- vllm_omni/model_executor/models/common/snake_activation.py
- vllm_omni/model_executor/models/common/alias_free_activation.py
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
commands:
- |
timeout 20m bash -c "
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
export VLLM_OMNI_USE_V2_RUNNER=1
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m 'core_model' --run-level 'core_model'
"
mirror_hardwares: l4_1
- label: "Diffusion · Wan22 Test"
source_file_dependencies:
- tests/e2e/online_serving/test_wan22_t2v.py
-1
View File
@@ -30,7 +30,6 @@ RUN python3 -m pip install -v --no-deps \
"stepaudio2-minicpmo>=0.1.1" \
"step-audio2>=1.0.0"
ENV VLLM_TARGET_DEVICE=npu
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT []
-1
View File
@@ -30,7 +30,6 @@ RUN python3 -m pip install -v --no-deps \
"stepaudio2-minicpmo>=0.1.1" \
"step-audio2>=1.0.0"
ENV VLLM_TARGET_DEVICE=npu
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT []
-1
View File
@@ -65,7 +65,6 @@ RUN python3 -m pip install -v --no-deps \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60
ENV VLLM_TARGET_DEVICE=npu
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT []
-1
View File
@@ -66,7 +66,6 @@ RUN python3 -m pip install -v --no-deps \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60
ENV VLLM_TARGET_DEVICE=npu
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT []
Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 269 KiB

@@ -155,23 +155,6 @@ def _response_in_progress(events: list[dict[str, object]]) -> bool:
)
async def _commit_input_after_playback_checkpoint(client: RealtimeDuplexClient) -> float:
"""Commit input only after preserving already-played assistant history.
Native duplex can finish several responses while the user is still
streaming. Under the ``ack_only`` playback policy, acknowledging those
responses after the final input commit is intentionally rejected: it could
append an old assistant message after the newly committed user message.
Checkpoint playback first so the server commits each assistant item at its
original history position. A second acknowledgement after the commit can
then safely advance response-local playback that drained in the meantime.
"""
await client.acknowledge_playback()
commit_sent_at_s = time.monotonic()
await client.commit()
return commit_sent_at_s
def _event_count_after(
events: list[dict[str, object]],
event_type: str,
@@ -465,7 +448,8 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
chunk_period_ms=_chunk_period_ms(client.events.events),
)
wait_for_post_commit_decision = False
commit_sent_at_s = await _commit_input_after_playback_checkpoint(client)
commit_sent_at_s = time.monotonic()
await client.commit()
wait_error: str | None = None
committed_index: int | None = None
post_commit_decision: str | None = None
+5 -72
View File
@@ -69,12 +69,11 @@ reference-video preparation and MP4 output.
For CUDA and ROCm deployments, non-streaming MP4 responses are encoded on the
host CPU through PyAV/libx264 after generation. The response encoder selects the
path automatically at runtime. The server-owned parallel converter accepts
supported frame shapes and dtypes with either per-channel-contiguous or strided
RGB planes, including interleaved arrays materialized by output transport.
Standalone callers without a parallel converter retain the legacy fallback for
strided planes. No CLI flag, model declaration, or user configuration is
required. Streaming fMP4 output is unchanged.
path automatically at runtime: inputs with a supported frame shape, common dtype,
and RGB per-channel contiguous layout use direct planar frames; other inputs
fall back to the legacy path before the PyAV container is opened. No CLI flag,
model declaration, or user configuration is required. Streaming fMP4 output is
unchanged.
A community benchmark on 2x Xeon 8480C reported the following comparison
between the legacy and direct planar paths
@@ -918,72 +917,6 @@ adapter and twice without it, then compare the four output digests. The adapter
is bound and deterministic when each pair matches internally and the two pairs
differ from each other.
### FastH3 adapter
[FastH3](https://haoailab.com/blogs/fasth3-preview/) is FastVideo's four-step
DMD2 student of H3-Base. It reuses H3's text encoder, VAEs, tokenizers, and
schedulers unchanged, replacing 49 denoiser evaluations with four. It is fused
into the checkpoint at load time rather than switched per request, because it
carries full-rank deltas that no LoRA layer can express.
The bundle publishes four variants, so download one and point `--lora-path` at
it; the repository root is refused rather than guessed at:
```bash
export FASTH3_DIR=/path/to/fasth3
hf download FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA \
dense-datafree/adapter_model.safetensors --local-dir "${FASTH3_DIR}"
export FASTH3_LORA="${FASTH3_DIR}/dense-datafree/adapter_model.safetensors"
```
Add `--task-type fl2va --lora-path "${FASTH3_LORA}"` to a non-offloaded server
command. T2VA is served by the FL2VA partition, so `--task-type fl2va` is
correct even though FastH3 preview v1 distills T2VA only. Because the adapter is
fused, `--lora-backend` does not apply and a request carrying a `lora=` field is
rejected rather than served without the adapter it asked for.
```bash
-F 'num_inference_steps=4' \
-F 'extra_params={"task":"t2va","duration":4.4}'
```
Requests must ask for `num_inference_steps=4` and `task=t2va`: the release's five
sigma points bound four denoiser evaluations, and that count is what the step
scheduler admits a request on. The server denoises on the release's own ladder,
keeping H3's per-modality shifts at the checkpoint values, so a request that
overrides `flow_shift` or `audio_flow_shift` is rejected - it would sample the
student at noise levels it was never distilled at.
Only a release that identifies itself as FastH3 is fused; any other
`fastvideo-lora-v2` adapter stays on the dynamic LoRA route. A claimed artifact
is then held to its own metadata: one that misdeclares its tensor counts or
leaves a transformer block unedited is refused at startup instead of serving
mostly base H3 weights on a four-step schedule. Offload is refused for the same
reason - `--enable-cpu-offload`, `--enable-layerwise-offload` and
`--enable-distributed-layerwise-offload` all bypass the fusion, so they fail fast.
> [!NOTE]
> Only the **Dense / Data-Free** variant is supported today. The three VSA
> variants carry `.set_weight` compression gates for modules that do not exist
> in vLLM-Omni's dense H3 attention, and the release states dense attention is
> not a supported substitute for them, so they are refused at startup rather
> than silently run wrong. Video Sparse Attention for H3's packed
> `[text | cond | audio | video]` sequence is follow-up work.
Measured on 8x NVIDIA B300 with USP8, VAE patch-parallel 8, `TRTLLM_ATTN`, at
1344x768, 4.4 s, seed 1101, one warmup excluded and two runs recorded:
| Adapter | Steps | End-to-end | Diffusion engine |
| --- | ---: | ---: | ---: |
| none (base H3) | 50 | 25.8 / 26.4 s | 16.22 / 16.28 s |
| FastH3 Dense | 4 (5 sigma points) | 11.7 / 11.8 s | 2.37 / 2.36 s |
The denoising speedup is 6.9x. End-to-end is 2.2x because text encoding, VAE
decoding and muxing are a fixed cost that dominates a clip this short; longer
generations move the end-to-end figure toward the denoising one. Fusing the
adapter does not measurably change startup: weight loading took 77.3 s with it
against 85.8 s without.
## Key parameters
| Parameter | Recommended value | Notes |
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Benchmark legacy and automatic MP4 response encoding on synthetic input."""
from __future__ import annotations
@@ -11,16 +11,13 @@ import statistics
import time
from collections.abc import Callable
from dataclasses import dataclass
from functools import partial
from pathlib import Path
from typing import cast
import numpy as np
from vllm_omni.entrypoints.openai.video_api_utils import (
_encode_video_bytes,
_encode_video_bytes_legacy,
_PlanarFrameConverter,
)
@@ -45,19 +42,12 @@ def _build_inputs(
fps: int,
audio_sample_rate: int,
seed: int,
input_layout: str = "planar-backed",
) -> tuple[np.ndarray, np.ndarray]:
rng = np.random.default_rng(seed)
planar = rng.random((3, frames, height, width), dtype=np.float32)
video = planar.transpose(1, 2, 3, 0)
if input_layout == "interleaved":
video = np.ascontiguousarray(video)
if not video.flags.c_contiguous:
raise RuntimeError("synthetic video is not C-contiguous")
elif input_layout == "planar-backed" and not all(video[..., channel].flags.c_contiguous for channel in range(3)):
if not all(video[..., channel].flags.c_contiguous for channel in range(3)):
raise RuntimeError("synthetic video does not expose contiguous channel planes")
elif input_layout != "planar-backed":
raise ValueError(f"unsupported input layout: {input_layout}")
sample_count = max(1, round(frames / fps * audio_sample_rate))
timeline = np.arange(sample_count, dtype=np.float32) / audio_sample_rate
@@ -96,8 +86,8 @@ def _measure(
def _summarize(records: list[dict[str, object]], label: str) -> dict[str, object]:
selected = [record for record in records if record["label"] == label]
wall_values = [cast(float, record["wall_ms"]) for record in selected]
cpu_values = [cast(float, record["process_cpu_ms"]) for record in selected]
wall_values = [float(record["wall_ms"]) for record in selected]
cpu_values = [float(record["process_cpu_ms"]) for record in selected]
return {
"runs": len(selected),
"wall_ms": {
@@ -123,16 +113,6 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--warmup", type=int, default=1)
parser.add_argument("--rounds", type=_positive_int, default=3)
parser.add_argument("--seed", type=int, default=20260817)
parser.add_argument(
"--input-layout",
choices=("planar-backed", "interleaved"),
default="planar-backed",
)
parser.add_argument(
"--frame-conversion-workers",
type=_positive_int,
default=8,
)
parser.add_argument("--output-json", type=Path)
args = parser.parse_args()
if args.warmup < 0:
@@ -149,41 +129,33 @@ def main() -> None:
fps=args.fps,
audio_sample_rate=args.audio_sample_rate,
seed=args.seed,
input_layout=args.input_layout,
)
baseline = BenchmarkVariant("legacy", _encode_video_bytes_legacy)
converter = _PlanarFrameConverter(max_workers=args.frame_conversion_workers)
candidate = BenchmarkVariant(
"automatic",
partial(_encode_video_bytes, frame_converter=converter),
)
candidate = BenchmarkVariant("automatic", _encode_video_bytes)
try:
for _ in range(args.warmup):
for variant in (baseline, candidate):
_measure(
variant,
video=video,
audio=audio,
fps=args.fps,
audio_sample_rate=args.audio_sample_rate,
)
for _ in range(args.warmup):
for variant in (baseline, candidate):
_measure(
variant,
video=video,
audio=audio,
fps=args.fps,
audio_sample_rate=args.audio_sample_rate,
)
records: list[dict[str, object]] = []
for round_index in range(args.rounds):
order = (baseline, candidate) if round_index % 2 == 0 else (candidate, baseline)
for variant in order:
_, record = _measure(
variant,
video=video,
audio=audio,
fps=args.fps,
audio_sample_rate=args.audio_sample_rate,
)
record["round"] = round_index + 1
records.append(record)
finally:
converter.shutdown()
records: list[dict[str, object]] = []
for round_index in range(args.rounds):
order = (baseline, candidate) if round_index % 2 == 0 else (candidate, baseline)
for variant in order:
_, record = _measure(
variant,
video=video,
audio=audio,
fps=args.fps,
audio_sample_rate=args.audio_sample_rate,
)
record["round"] = round_index + 1
records.append(record)
output_hashes = {str(record["output_sha256"]) for record in records}
if len(output_hashes) != 1:
@@ -193,8 +165,8 @@ def main() -> None:
paired_savings_percent = []
for round_index in range(args.rounds):
pair = [record for record in records if record["round"] == round_index + 1]
baseline_ms = cast(float, next(record["wall_ms"] for record in pair if record["label"] == baseline.label))
candidate_ms = cast(float, next(record["wall_ms"] for record in pair if record["label"] == candidate.label))
baseline_ms = float(next(record["wall_ms"] for record in pair if record["label"] == baseline.label))
candidate_ms = float(next(record["wall_ms"] for record in pair if record["label"] == candidate.label))
paired_savings_ms.append(baseline_ms - candidate_ms)
paired_savings_percent.append((baseline_ms - candidate_ms) / baseline_ms * 100)
@@ -208,8 +180,6 @@ def main() -> None:
"warmup": args.warmup,
"rounds": args.rounds,
"seed": args.seed,
"input_layout": args.input_layout,
"frame_conversion_workers": args.frame_conversion_workers,
"video_shape": list(video.shape),
"video_strides": list(video.strides),
"codec_options": {"preset": "ultrafast", "threads": "0"},
-176
View File
@@ -9,98 +9,23 @@ import asyncio
import base64
import json
import time
from argparse import Namespace
from types import SimpleNamespace
import pytest
from pytest_mock import MockerFixture
from vllm.benchmarks.lib.endpoint_request_func import RequestFuncInput
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import (
SeedTTSSampleRequest,
SeedTTSTextSampleRequest,
)
from vllm_omni.benchmarks.patch.patch import (
MixRequestFuncOutput,
_apply_stage0_token_timings,
_attach_seed_tts_to_request_func_input,
async_request_openai_chat_omni_completions,
async_request_openai_realtime_duplex,
should_request_stage_metrics,
)
from vllm_omni.experimental.fullduplex.client import RealtimeEventCollector
pytestmark = [pytest.mark.core_model, pytest.mark.benchmark, pytest.mark.cpu]
def _seed_tts_request_func_input() -> RequestFuncInput:
return RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="target text",
api_url="http://test.com/v1/chat/completions",
prompt_len=2,
output_len=20,
extra_body={"modalities": ["text", "audio"]},
)
def test_seed_tts_chat_request_carries_reference_audio_once() -> None:
reference_audio_url = "data:audio/wav;base64,AAAA"
sample = SeedTTSSampleRequest(
prompt="target text",
prompt_len=2,
expected_output_len=20,
multi_modal_data=None,
seed_tts_system_prompt="Clone the supplied voice.",
seed_tts_speech_extra={
"ref_audio": reference_audio_url,
"ref_text": "reference text",
"task_type": "Base",
},
)
request_func_input = _seed_tts_request_func_input()
_attach_seed_tts_to_request_func_input(sample, request_func_input)
assert request_func_input.omni_chat_messages == [
{
"role": "system",
"content": [{"type": "text", "text": "Clone the supplied voice."}],
},
{"role": "user", "content": [{"type": "text", "text": "target text"}]},
]
assert request_func_input.extra_body["ref_audio"] == reference_audio_url
serialized_request = json.dumps(
{"messages": request_func_input.omni_chat_messages, **request_func_input.extra_body},
)
assert serialized_request.count(reference_audio_url) == 1
def test_seed_tts_text_chat_messages_do_not_add_reference_audio() -> None:
sample = SeedTTSTextSampleRequest(
prompt="target text",
prompt_len=2,
expected_output_len=20,
multi_modal_data=None,
seed_tts_system_prompt="Use the configured voice.",
seed_tts_speech_extra=None,
)
request_func_input = _seed_tts_request_func_input()
_attach_seed_tts_to_request_func_input(sample, request_func_input)
assert getattr(request_func_input, "seed_tts_row", False) is True
assert request_func_input.omni_chat_messages == [
{
"role": "system",
"content": [{"type": "text", "text": "Use the configured voice."}],
},
{"role": "user", "content": [{"type": "text", "text": "target text"}]},
]
assert "ref_audio" not in request_func_input.extra_body
class MockResponse:
"""Mock aiohttp response for testing"""
@@ -349,107 +274,6 @@ def create_sse_chunk(data_dict):
return f"data: {json.dumps(data_dict)}\n\n".encode()
def test_chat_text_timing_metrics_request_stage_metrics():
args = Namespace(
backend="openai-chat-omni",
percentile_metrics="ttft,tpot,itl,e2el",
print_stage=False,
extra_body={},
)
assert should_request_stage_metrics(args) is True
@pytest.mark.asyncio
async def test_bundled_first_text_chunk_uses_stage0_token_timings(mocker: MockerFixture):
"""Engine timings recover TPOT when every text token arrives together."""
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "ABCD"}}],
"modality": "text",
"usage": {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14},
"metrics": {
"stage_metrics": {
"0": {
"num_tokens_out": 4,
"vllm_itls_ms": [10.0, 11.0, 12.0],
"vllm_tpot_ms": 11.0,
}
}
},
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = mocker.AsyncMock()
mock_session.post = mocker.MagicMock(return_value=mock_response)
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
assert output.success is True
assert output.output_tokens == 4
assert output.itl == pytest.approx([0.010, 0.011, 0.012])
assert output.text_latency - output.ttft == pytest.approx(0.033)
assert output.tpot_measured is True
@pytest.mark.asyncio
async def test_positive_client_text_timings_take_precedence_over_stage0(mocker: MockerFixture):
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "A"}}],
"modality": "text",
"usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11},
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": "B"}}],
"modality": "text",
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12},
"metrics": {
"stage_metrics": {
"0": {
"num_tokens_out": 2,
"vllm_itls_ms": [1000.0],
"vllm_tpot_ms": 1000.0,
}
}
},
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks, delay_between_chunks=0.01)
mock_session = mocker.AsyncMock()
mock_session.post = mocker.MagicMock(return_value=mock_response)
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
assert output.success is True
assert len(output.itl) == 1
assert 0.0 < output.itl[0] < 0.1
# ============================================================================
# output_tokens Tests
# ============================================================================
@@ -1,6 +1,3 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Tests for SeedTTSTextDataset, SeedTTSTextSampleRequest, SeedTTSDesignDataset,
and SeedTTSDesignSampleRequest.
@@ -237,6 +234,34 @@ def test_seed_tts_design_dataset_rejects_missing_description(seed_tts_design_roo
assert req.seed_tts_utterance_id == "ok"
def test_attach_sets_seed_tts_row_even_without_extra_body():
"""seed_tts_row=True must be set for SeedTTSTextSampleRequest (no extra body)."""
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import SeedTTSTextSampleRequest
req = SeedTTSTextSampleRequest(
prompt="hello world",
prompt_len=2,
expected_output_len=100,
multi_modal_data=None,
request_id="test-0",
seed_tts_speech_extra=None,
seed_tts_ref_wav_path="",
)
assert req.seed_tts_speech_extra is None
assert req.seed_tts_ref_wav_path == ""
# The fix ensures that even with speech_extra=None, the function
# sets seed_tts_row=True. We verify the source code has the fix.
import inspect
import vllm_omni.benchmarks.patch.patch as patch_mod
src = inspect.getsource(patch_mod._attach_seed_tts_to_request_func_input)
# seed_tts_row must be set BEFORE the 'if not ex: return' check
row_pos = src.index("seed_tts_row")
not_ex_pos = src.index("if not ex:")
assert row_pos < not_ex_pos, "seed_tts_row must be set before 'if not ex: return'"
def test_seed_tts_whisper_transcribe_passes_attention_mask(monkeypatch):
from vllm_omni.benchmarks.data_modules import seed_tts_eval
+1 -25
View File
@@ -650,7 +650,7 @@ class TestPipelineConfigNew:
model_arch="A",
stages=(
StagePipelineConfig(stage_id=0, model_stage="a"),
StagePipelineConfig(stage_id=1, model_stage="b", input_sources=(0,), final_output=True),
StagePipelineConfig(stage_id=1, model_stage="b", input_sources=(0,)),
),
)
assert p.validate() == []
@@ -659,30 +659,6 @@ class TestPipelineConfigNew:
p = PipelineConfig(model_type="t", model_arch="A")
assert any("no stages" in e.lower() for e in p.validate())
def test_validate_no_terminal_stage(self):
"""A pipeline with no ``final_output`` stage can never emit a result."""
p = PipelineConfig(
model_type="t",
model_arch="A",
stages=(
StagePipelineConfig(stage_id=0, model_stage="a"),
StagePipelineConfig(stage_id=1, model_stage="b", input_sources=(0,)),
),
)
assert any("no terminal stage" in e.lower() for e in p.validate())
def test_validate_terminal_stage_need_not_be_last(self):
"""The check is cycle-agnostic: any stage may carry ``final_output``."""
p = PipelineConfig(
model_type="t",
model_arch="A",
stages=(
StagePipelineConfig(stage_id=0, model_stage="a", final_output=True),
StagePipelineConfig(stage_id=1, model_stage="b", input_sources=(0,)),
),
)
assert p.validate() == []
class TestPipelineRegistration:
def test_resolve_pipeline_prefers_deploy_pipeline_key(self, clean_pipeline_registry, tmp_path):
-17
View File
@@ -73,20 +73,3 @@ def test_minimax_h3_disaggregation_is_explicit_opt_in():
pipeline = OMNI_PIPELINES["minimax_h3_disaggregated"]
assert isinstance(pipeline, PipelineConfig)
assert pipeline.model_type == "minimax_h3_disaggregated"
def test_in_tree_pipelines_pass_topology_validation():
"""Every statically registered in-tree pipeline must have a valid topology.
``PipelineConfig.validate()`` only runs on ``register_pipeline``, which
in-tree pipelines never go through, so a shipped topology error would
otherwise stay invisible until serving hangs or misroutes.
Resolver entries are skipped: they need an ``hf_config`` to produce a
``PipelineConfig``.
"""
failures = {
model_type: errors
for model_type, pipeline in OMNI_PIPELINES.items()
if isinstance(pipeline, PipelineConfig) and (errors := pipeline.validate())
}
assert not failures, f"in-tree pipelines failed topology validation: {failures}"
@@ -70,14 +70,10 @@ _PARAMS = [
pytest.param({"guidance_scale": -1.0}, "guidance_scale", id="guidance_scale_negative", marks=_SKIP_ISSUE_3649),
pytest.param({"guidance_scale": 0}, "guidance_scale", id="guidance_scale_zero", marks=_SKIP_ISSUE_3649),
pytest.param(
{"num_inference_steps": 0},
("num_inference_steps", "greater than or equal to 1"),
id="num_inference_steps_zero",
{"num_inference_steps": 0}, "num_inference_steps", id="num_inference_steps_zero", marks=_SKIP_ISSUE_3649
),
pytest.param(
{"num_inference_steps": -1},
("num_inference_steps", "greater than or equal to 1"),
id="num_inference_steps_negative",
{"num_inference_steps": -1}, ("number of steps", "non-negative"), id="num_inference_steps_negative"
),
pytest.param(
{"num_inference_steps": 6000},
@@ -157,13 +157,7 @@ def test_speech_missing_required_fields(omni_server: OmniServer, online_client:
"ref_audio": None,
"ref_text": None,
},
# NOTE: This is delegating down the TTS adapter, so the message is both specific
# to the Qwen3TTS adapter and dependent on whether or not the model has any speakers
# (as opposed to having valida speakers, but getting an invalid speaker). it would
# be more ideal to validate that this is landing on `.validate()` on the adapter
# and ensuring that the returned message matches what we would get from calling the
# adapter validate directly.
("no speakers configured",),
("Invalid voice", "vivian"),
id="customvoice_invalid_voice",
),
pytest.param(
@@ -11,7 +11,6 @@ Tests cover:
"""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
import torch
@@ -467,120 +466,6 @@ class TestCurrentDiffusionConfig:
class TestAttentionInitUsesCurrentDiffusionConfig:
@pytest.mark.parametrize("mindiesd_available", [False, True])
def test_paged_npu_memory_profile_uses_sdpa_only_without_mindiesd(self, monkeypatch, mindiesd_available):
attention = Attention.__new__(Attention)
attention._scheduler_paged_kv = True
attention.paged_kv_cache_role = "primary"
attention.backend_pref = "FLASH_ATTN"
attention.attn_backend = SimpleNamespace(supports_piecewise_spans=True, get_name=lambda: "FLASH_ATTN")
flash_output = torch.ones(1)
sdpa_output = torch.zeros(1)
dense_flash = Mock(return_value=flash_output)
sdpa = Mock(return_value=sdpa_output)
attention.attention = SimpleNamespace(forward=dense_flash)
attention.sdpa_fallback = SimpleNamespace(forward=sdpa)
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: True)
monkeypatch.setattr(
layer_mod,
"get_forward_context",
lambda: SimpleNamespace(in_diffusion_kv_memory_profile=True),
)
monkeypatch.setattr(layer_mod.current_omni_platform, "is_npu", lambda: True)
monkeypatch.setattr(
layer_mod.current_omni_platform,
"supports_diffusion_dense_flash_attention",
lambda: mindiesd_available,
)
result = attention._run_local_attention(
torch.zeros(1, dtype=torch.bfloat16),
torch.zeros(1, dtype=torch.bfloat16),
torch.zeros(1, dtype=torch.bfloat16),
AttentionMetadata(),
)
if mindiesd_available:
assert result is flash_output
dense_flash.assert_called_once()
sdpa.assert_not_called()
else:
assert result is sdpa_output
dense_flash.assert_not_called()
sdpa.assert_called_once()
def test_dense_flash_import_error_is_not_reclassified(self, monkeypatch):
attention = Attention.__new__(Attention)
attention._scheduler_paged_kv = False
attention.paged_kv_cache_role = "primary"
attention.backend_pref = "FLASH_ATTN"
attention.attn_backend = SimpleNamespace(supports_piecewise_spans=True, get_name=lambda: "FLASH_ATTN")
attention.attention = SimpleNamespace(
forward=Mock(side_effect=ModuleNotFoundError("No module named 'mindiesd'", name="mindiesd"))
)
attention.sdpa_fallback = SimpleNamespace(forward=Mock(side_effect=AssertionError("must not fall back")))
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: False)
with pytest.raises(ModuleNotFoundError, match="mindiesd"):
attention._run_local_attention(
torch.zeros(1, dtype=torch.bfloat16),
torch.zeros(1, dtype=torch.bfloat16),
torch.zeros(1, dtype=torch.bfloat16),
AttentionMetadata(),
)
def test_paged_marker_does_not_change_dense_backend(self, monkeypatch):
class _FakeAttentionImpl:
def __init__(self, **kwargs):
self.kwargs = kwargs
def forward(self, query, key, value, attn_metadata=None):
return query
class _DenseBackend:
supports_paged_kv = False
@staticmethod
def get_name() -> str:
return "TORCH_SDPA"
@staticmethod
def get_impl_cls():
return _FakeAttentionImpl
calls = []
def _fake_get_attn_backend_for_role(*args, **kwargs):
calls.append((args, kwargs))
return _DenseBackend, None
monkeypatch.setattr(layer_mod, "get_attn_backend_for_role", _fake_get_attn_backend_for_role)
monkeypatch.setattr(layer_mod.SDPABackend, "get_impl_cls", staticmethod(lambda: _FakeAttentionImpl))
monkeypatch.setattr(layer_mod, "build_parallel_attention_strategy", lambda **kwargs: object())
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: False)
od_config = SimpleNamespace(
diffusion_attention_config=AttentionConfig(),
diffusion_kv_mode=layer_mod.DiffusionKVCacheMode.DENSE_LEGACY,
parallel_config=SimpleNamespace(ring_degree=1),
diffusion_kv_cache_dtype=None,
diffusion_kv_cache_skip_step_indices=None,
diffusion_kv_cache_skip_layer_indices=None,
)
with set_current_diffusion_config(od_config):
attention = Attention(
num_heads=4,
head_size=64,
causal=False,
softmax_scale=1.0,
paged_kv_cache_role="primary",
)
assert len(calls) == 1
assert attention.attn_backend is _DenseBackend
assert attention.backend_pref is None
def test_attention_init_uses_current_diffusion_config_without_forward_context(self, monkeypatch):
class _FakeAttentionImpl:
def __init__(self, **kwargs):
+2 -9
View File
@@ -353,15 +353,8 @@ def test_cross_attn_key_padding_vs_sdpa(k_len):
print(f"Max absolute difference: {max_diff:.6f}")
print(f"Mean absolute difference: {mean_diff:.6f}")
# Match the platform-specific comparison used by test_fa_vs_sdpa above.
if current_omni_platform.is_rocm():
# AITER FlashAttention and PyTorch SDPA use different BF16 reduction
# implementations on ROCm, so individual values can differ by one BF16
# step even though the outputs agree within the expected relative error.
torch.testing.assert_close(output_fa, output_sdpa, rtol=1e-2, atol=1.5e-2)
else:
assert max_diff < 0.01, f"Max difference {max_diff} exceeds threshold 0.01"
assert mean_diff < 0.001, f"Mean difference {mean_diff} exceeds threshold 0.001"
assert max_diff < 0.01, f"Max difference {max_diff} exceeds threshold 0.01"
assert mean_diff < 0.001, f"Mean difference {mean_diff} exceeds threshold 0.001"
print("✓ Case 3 PASSED: FA and SDPA cross-attention outputs are very close!")
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""End-to-end test for ``piecewise_attn`` (CPU).
Verify that running attention in segments (causal outside full-attn spans,
@@ -98,12 +98,9 @@ def test_paged_piecewise_runner_uses_packed_indices_and_kv_endpoints():
query = torch.arange(12 * 2 * 4, dtype=torch.float32).reshape(12, 2, 4)
key = query + 100
value = query + 200
calls: list[object] = []
calls = []
output_buffer = torch.empty_like(query)
def run_segment(segment_query, segment_key, segment_value, segment_batch, segment_output_buffer):
assert segment_output_buffer is None
def run_segment(segment_query, segment_key, segment_value, segment_batch):
calls.append(
(
[segment.mode for segment in segment_batch.row_segments],
@@ -122,13 +119,9 @@ def test_paged_piecewise_runner_uses_packed_indices_and_kv_endpoints():
plan,
plan.segments,
run_segment,
output_buffer=output_buffer,
)
assert output is output_buffer
torch.testing.assert_close(output, query)
assert all(segment.query_range is None for segment in plan.segments)
assert not plan.segments_cover_query_contiguously
assert calls == [
(["causal", "causal"], [2, 4], [0, 1, 6, 7]),
(["full", "full"], [5, 7], [2, 3, 4, 8, 9, 10]),
@@ -136,161 +129,6 @@ def test_paged_piecewise_runner_uses_packed_indices_and_kv_endpoints():
]
def test_paged_piecewise_runner_preserves_homogeneous_batch_without_scatter(monkeypatch: pytest.MonkeyPatch):
plan = build_paged_piecewise_plan(
[[(2, 5)], [(2, 5)]],
query_offsets=[0, 0],
query_lens=[6, 6],
seq_lens=[6, 6],
device=DEVICE,
)
assert plan.homogeneous_batch_shape == (2, 6)
query = torch.arange(12 * 2 * 4, dtype=torch.float32).reshape(12, 2, 4)
calls: list[torch.Tensor] = []
def fail_index_copy(*_args, **_kwargs):
raise AssertionError("homogeneous piecewise path must not use index_copy_")
monkeypatch.setattr(torch.Tensor, "index_copy_", fail_index_copy)
def run_segment(segment_query, segment_key, segment_value, _metadata, segment_output_buffer):
assert segment_key is None
assert segment_value is None
assert segment_output_buffer is None
calls.append(segment_query.clone())
return segment_query + 1
output_buffer = torch.empty_like(query)
output = run_paged_piecewise_plan(
query,
None,
None,
plan,
plan.segments,
run_segment,
output_buffer=output_buffer,
use_homogeneous_batch=True,
)
assert output is output_buffer
torch.testing.assert_close(output, query + 1)
assert [call.shape[0] for call in calls] == [4, 6, 2]
torch.testing.assert_close(calls[0], query[[0, 1, 6, 7]])
torch.testing.assert_close(calls[1], query[[2, 3, 4, 8, 9, 10]])
torch.testing.assert_close(calls[2], query[[5, 11]])
def test_paged_piecewise_runner_keeps_indexed_fallback_for_heterogeneous_layout():
plan = build_paged_piecewise_plan(
[[(2, 5)], [(1, 4)]],
query_offsets=[0, 0],
query_lens=[6, 6],
seq_lens=[6, 6],
device=DEVICE,
)
assert plan.homogeneous_batch_shape is None
query = torch.arange(12 * 2 * 4, dtype=torch.float32).reshape(12, 2, 4)
output = run_paged_piecewise_plan(
query,
None,
None,
plan,
plan.segments,
lambda segment_query, *_args: segment_query,
use_homogeneous_batch=True,
)
torch.testing.assert_close(output, query)
def test_paged_piecewise_runner_slices_contiguous_segments():
plan = build_paged_piecewise_plan(
[[(2, 5)]],
query_offsets=[0],
query_lens=[6],
seq_lens=[6],
device=DEVICE,
)
query = torch.arange(6 * 2 * 4, dtype=torch.float32).reshape(6, 2, 4)
key = query + 100
value = query + 200
expected_ranges = [(0, 2), (2, 5), (5, 6)]
calls: list[tuple[int, int]] = []
def run_segment(segment_query, segment_key, segment_value, _segment_batch, segment_output_buffer):
assert segment_output_buffer is None
expected_range = expected_ranges[len(calls)]
start, end = expected_range
calls.append(expected_range)
assert segment_query.untyped_storage().data_ptr() == query.untyped_storage().data_ptr()
assert segment_key.untyped_storage().data_ptr() == key.untyped_storage().data_ptr()
assert segment_value.untyped_storage().data_ptr() == value.untyped_storage().data_ptr()
torch.testing.assert_close(segment_query, query[start:end])
return segment_query
output = run_paged_piecewise_plan(query, key, value, plan, plan.segments, run_segment)
assert calls == expected_ranges
assert [segment.query_range for segment in plan.segments] == expected_ranges
assert plan.segments_cover_query_contiguously
torch.testing.assert_close(output, query)
def test_paged_piecewise_runner_writes_contiguous_segments_into_output_buffer():
plan = build_paged_piecewise_plan(
[[(2, 5)]],
query_offsets=[0],
query_lens=[6],
seq_lens=[6],
device=DEVICE,
)
query = torch.arange(6 * 2 * 4, dtype=torch.float32).reshape(6, 2, 4)
output_buffer = torch.empty(6, 2, 3)
targets = []
def run_segment(segment_query, _key, _value, _metadata, segment_output_buffer):
assert segment_output_buffer is not None
assert segment_output_buffer.untyped_storage().data_ptr() == output_buffer.untyped_storage().data_ptr()
targets.append(segment_output_buffer)
return segment_output_buffer.copy_(segment_query[..., :3])
output = run_paged_piecewise_plan(
query,
query,
query,
plan,
plan.segments,
run_segment,
output_buffer=output_buffer,
)
assert output is output_buffer
assert [target.shape[0] for target in targets] == [2, 3, 1]
torch.testing.assert_close(output, query[..., :3])
def test_paged_piecewise_runner_returns_single_full_segment_directly():
plan = build_paged_piecewise_plan(
[[]],
query_offsets=[0],
query_lens=[6],
seq_lens=[6],
device=DEVICE,
)
query = torch.randn(6, 2, 4)
runner_output = query + 1
output = run_paged_piecewise_plan(
query,
query,
query,
plan,
plan.segments,
lambda *_args: runner_output,
)
assert output is runner_output
def test_paged_piecewise_runner_uses_runner_output_shape():
plan = build_paged_piecewise_plan(
[[(2, 5)]],
-15
View File
@@ -1,15 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
@pytest.fixture(autouse=True)
def restore_torch_default_dtype():
"""Keep process-global dtype changes from leaking between diffusion tests."""
default_dtype = torch.get_default_dtype()
try:
yield
finally:
torch.set_default_dtype(default_dtype)
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
@@ -485,16 +485,8 @@ def test_valid_sequence_and_context_install_into_native_rows(monkeypatch: pytest
assert runner.get_diffusion_kv_row("req-0", 0, "text") == 1
sequence_binding = runner._resolve_paged_attention_row("req-0", 0, None)
context_binding = runner._resolve_paged_attention_row("req-0", None, "text")
assert (sequence_binding.row_index, sequence_binding.max_seq_len, sequence_binding.block_ids) == (
0,
9,
((1, 2, 3), (4, 5)),
)
assert (context_binding.row_index, context_binding.max_seq_len, context_binding.block_ids) == (
1,
5,
((6, 7), (8,)),
)
assert (sequence_binding.row_index, sequence_binding.max_seq_len) == (0, 9)
assert (context_binding.row_index, context_binding.max_seq_len) == (1, 5)
def test_block_table_mutations_invalidate_prepared_attention_batches(
+2 -2
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
@@ -36,7 +36,7 @@ def test_non_mapping_omni_kv_config_is_rejected() -> None:
OmniDiffusionConfig.from_kwargs(omni_kv_config="paged_scheduler")
def test_paged_scheduler_rejects_dense_connector_kv_receive() -> None:
def test_paged_scheduler_rejects_dense_legacy_kv_receive() -> None:
with pytest.raises(ValueError, match="does not support imported AR KV"):
OmniDiffusionConfig.from_kwargs(
diffusion_kv_mode="paged_scheduler",
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
from unittest.mock import Mock
@@ -290,7 +290,6 @@ def test_minus_one_max_model_len_is_auto_fitted_by_native_cache_sizing() -> None
dtype=torch.bfloat16,
non_causal=True,
)
model_limit = vllm_config.model_config.max_model_len
initialization.build_native_kv_cache_configs(
vllm_config,
@@ -299,8 +298,4 @@ def test_minus_one_max_model_len_is_auto_fitted_by_native_cache_sizing() -> None
)
assert vllm_config.model_config.original_max_model_len == -1
# Sixteen physical pages provide ``16 * block_size`` tokens. The native
# backend chooses the block geometry, so keep this assertion backend
# independent instead of assuming the generic 16-token block size.
expected_max_model_len = min(model_limit, spec.block_size * 16)
assert vllm_config.model_config.max_model_len == expected_max_model_len
assert vllm_config.model_config.max_model_len == 256
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
@@ -20,16 +20,12 @@ from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.diffusion_kv import paged_attention_adapter as adapter_module
from vllm_omni.diffusion.diffusion_kv.paged_attention_adapter import (
DiffusionPagedAttentionAdapter,
DiffusionPagedAttentionMetadata,
DiffusionPagedAttentionRow,
DiffusionPagedAttentionRowBinding,
DiffusionPagedAttentionRuntime,
)
from vllm_omni.diffusion.forward_context import (
get_forward_context,
override_paged_kv_adapter,
set_forward_context,
set_forward_context_denoise_step_idx,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
@@ -126,8 +122,8 @@ class _FakeNativeImpl:
def do_kv_cache_update(
self,
_layer,
key: torch.Tensor | None,
value: torch.Tensor | None,
key: torch.Tensor,
value: torch.Tensor,
_kv_cache,
slot_mapping: torch.Tensor,
) -> None:
@@ -159,11 +155,6 @@ def _make_adapter(
capacity: int = 16,
) -> tuple[DiffusionPagedAttentionAdapter, _FakeBlockTables, _FakeLayer, list[tuple]]:
events: list[tuple] = []
monkeypatch.setattr(
adapter_module.current_omni_platform,
"requires_diffusion_paged_kv_prewrite",
lambda: False,
)
block_tables = _FakeBlockTables()
block_tables.max_num_batched_tokens = capacity
block_tables.num_blocks.np.fill(math.ceil(capacity / block_tables.block_sizes[0]))
@@ -310,47 +301,6 @@ def test_paged_adapter_accepts_native_gqa_shapes(monkeypatch: pytest.MonkeyPatch
assert context.value_write.shape == (3, 8, 4)
def test_paged_adapter_preserves_strided_kv_for_platform_writer(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch)
batch = adapter.prepare_batch(
[DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=5, seq_len=5)]
)
packed_qkv = torch.randn(1, 5, 3 * 2 * 4)
query, key, value = packed_qkv.split(2 * 4, dim=-1)
query = query.reshape(1, 5, 2, 4)
key = key.reshape(1, 5, 2, 4)
value = value.reshape(1, 5, 2, 4)
assert not key.is_contiguous()
assert not value.is_contiguous()
with adapter.activate(batch):
context = adapter.prepare_layer_context("layer-0", query, key, value)
assert context.query.is_contiguous()
assert not context.key_write.is_contiguous()
assert not context.value_write.is_contiguous()
assert context.key_write.data_ptr() == key.data_ptr()
assert context.value_write.data_ptr() == value.data_ptr()
def test_paged_adapter_packs_noncontiguous_kv_head_layout(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch)
batch = adapter.prepare_batch(
[DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=5, seq_len=5)]
)
query = torch.randn(1, 5, 2, 4)
key = torch.randn(1, 5, 4, 2).transpose(-1, -2)
value = torch.randn(1, 5, 4, 2).transpose(-1, -2)
assert key.shape == (1, 5, 2, 4)
assert key.stride(-1) != 1
with adapter.activate(batch):
context = adapter.prepare_layer_context("layer-0", query, key, value)
assert context.key_write.is_contiguous()
assert context.value_write.is_contiguous()
def test_omni_paged_backend_defers_backend_owned_cache_update(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, layer, _ = _make_adapter(monkeypatch)
layer.attn_backend.forward_includes_kv_cache_update = True
@@ -400,48 +350,10 @@ def test_omni_piecewise_backend_defers_backend_owned_cache_update(monkeypatch: p
assert [metadata.query_start_loc_cpu.tolist() for metadata in segment_metadata] == [[0, 1], [0, 2], [0, 1]]
assert [metadata.positions.tolist() for metadata in segment_metadata] == [[0], [1, 2], [3]]
assert [metadata.slot_mappings.tolist() for metadata in segment_metadata] == [[[0]], [[1, 2]], [[3]]]
for event in events[1:]:
build_kwargs = event[1]
assert build_kwargs["positions"].untyped_storage().data_ptr() == batch.positions.untyped_storage().data_ptr()
assert (
build_kwargs["slot_mappings"].untyped_storage().data_ptr()
== batch.slot_mappings.untyped_storage().data_ptr()
)
def test_platform_prewrite_updates_once_then_piecewise_reads_cache(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, layer, _ = _make_adapter(monkeypatch)
layer.attn_backend.forward_includes_kv_cache_update = True
monkeypatch.setattr(
adapter_module.current_omni_platform,
"requires_diffusion_paged_kv_prewrite",
lambda: True,
)
adapter.resolve_row = lambda *_args: DiffusionPagedAttentionRowBinding(
row_index=2,
max_seq_len=16,
block_ids=((3, 7, 9, 11),),
)
row = DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=4, seq_len=4)
batch = adapter.prepare_batch([row])
qkv = torch.randn(1, 4, 2, 4)
metadata = SimpleNamespace(full_attn_spans=[[(1, 3)]], extra={})
with adapter.activate(batch):
context = adapter.prepare_layer_context("layer-0", qkv, qkv, qkv, omni_attn_metadata=metadata)
output = _run_omni_paged_backend(context)
assert torch.equal(output, qkv)
assert layer.native_events == ["update", "forward", "forward", "forward"]
assert len(layer.updates) == 1
assert all(call[1] is None and call[2] is None for call in layer.calls)
def test_omni_paged_backend_runs_hunyuan_piecewise_segments(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, layer, events = _make_adapter(monkeypatch)
# Exercise the Ascend-only homogeneous batch path. CUDA keeps its native
# output-buffer contract and is covered by the GPU adapter tests.
monkeypatch.setattr(adapter_module.current_omni_platform, "is_npu", lambda: True)
batch = adapter.prepare_batch(
[
DiffusionPagedAttentionRow(
@@ -538,48 +450,6 @@ def test_omni_paged_backend_runs_hunyuan_piecewise_segments(monkeypatch: pytest.
]
def test_piecewise_metadata_snapshots_reused_native_scheduler_buffer(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch)
shared_scheduler_metadata = torch.zeros(1, dtype=torch.int32)
build_id = 0
def build_metadata(**kwargs):
nonlocal build_id
shared_scheduler_metadata.fill_(build_id)
metadata = SimpleNamespace(
build_id=build_id,
causal=kwargs["causal"],
seq_lens=kwargs["seq_lens"].clone(),
query_start_loc_cpu=kwargs["query_start_loc_cpu"].clone(),
positions=kwargs["positions"].clone(),
slot_mappings=kwargs["slot_mappings"].clone(),
scheduler_metadata=shared_scheduler_metadata,
prefix_scheduler_metadata=None,
)
build_id += 1
return {"layer-0": metadata}
monkeypatch.setattr(adapter_module.current_omni_platform, "build_diffusion_kv_attn_metadata", build_metadata)
batch = adapter.prepare_batch(
[DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=4, seq_len=4)]
)
qkv = torch.randn(1, 4, 2, 4)
metadata = SimpleNamespace(full_attn_spans=[[(1, 3)]], extra={})
with adapter.activate(batch):
context = adapter.prepare_layer_context(
"layer-0",
qkv,
qkv,
qkv,
omni_attn_metadata=metadata,
)
segment_schedules = [item.scheduler_metadata for item in context.piecewise_native_metadata]
assert [item.item() for item in segment_schedules] == [1, 2, 3]
assert len({item.data_ptr() for item in segment_schedules}) == 3
def test_omni_paged_backend_treats_empty_full_spans_as_causal(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, layer, events = _make_adapter(monkeypatch)
batch = adapter.prepare_batch(
@@ -981,28 +851,6 @@ def test_forward_rejects_batched_layout_that_does_not_match_rows(monkeypatch: py
adapter.prepare_layer_context("layer-0", qkv, qkv, qkv)
def test_forward_packs_and_restores_right_padded_heterogeneous_rows(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch)
batch = adapter.prepare_batch(
[
DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=1, seq_len=1),
DiffusionPagedAttentionRow(request_id="req-1", sequence_id=1, query_len=3, seq_len=3),
]
)
qkv = torch.arange(2 * 3 * 2 * 4, dtype=torch.float32).reshape(2, 3, 2, 4)
with adapter.activate(batch):
context = adapter.prepare_layer_context("layer-0", qkv, qkv, qkv)
assert context.output_scatter_indices.tolist() == [0, 3, 4, 5]
torch.testing.assert_close(context.query, qkv.reshape(-1, 2, 4)[[0, 3, 4, 5]])
restored = context.restore_output(context.query)
assert restored.shape == qkv.shape
torch.testing.assert_close(restored[0, 0], qkv[0, 0])
assert torch.count_nonzero(restored[0, 1:]) == 0
torch.testing.assert_close(restored[1], qkv[1])
def test_forward_rejects_padded_full_kv(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch)
batch = adapter.prepare_batch(
@@ -1030,41 +878,6 @@ def test_forward_rejects_padded_full_kv(monkeypatch: pytest.MonkeyPatch) -> None
adapter.prepare_layer_context("layer-0", query, full_kv, full_kv)
def test_runner_runtime_switches_from_prefill_to_reusable_denoise_metadata(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch)
metadata = DiffusionPagedAttentionMetadata(
prefill_rows=(DiffusionPagedAttentionRow(request_id="req-0", sequence_id=0, query_len=6, seq_len=6),),
denoise_rows=(
DiffusionPagedAttentionRow(
request_id="req-0",
sequence_id=0,
query_len=2,
seq_len=5,
kv_start_pos=3,
),
),
)
runtime = DiffusionPagedAttentionRuntime(adapter, metadata)
with set_forward_context(paged_kv_runtime=runtime), runtime.activate():
set_forward_context_denoise_step_idx(0)
assert Attention._active_paged_kv_adapter() is adapter
assert adapter._active_batch.rows == metadata.prefill_rows
set_forward_context_denoise_step_idx(1)
assert Attention._active_paged_kv_adapter() is adapter
denoise_batch = adapter._active_batch
assert denoise_batch.rows == metadata.denoise_rows
set_forward_context_denoise_step_idx(2)
assert Attention._active_paged_kv_adapter() is adapter
assert adapter._active_batch is denoise_batch
assert adapter._active_batch is None
def test_causal_batch_rejects_non_suffix_write(monkeypatch: pytest.MonkeyPatch) -> None:
adapter, _, _, _ = _make_adapter(monkeypatch, non_causal=False)
row = DiffusionPagedAttentionRow(
@@ -1179,8 +992,7 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
monkeypatch: pytest.MonkeyPatch,
) -> None:
selected_backends = []
specialized_backends = []
impl_cls = Mock(return_value=SimpleNamespace(forward=Mock(), do_kv_cache_update=Mock()))
impl_cls = Mock(return_value=SimpleNamespace(forward=Mock()))
native_backend = SimpleNamespace(
get_name=lambda: "ASCEND",
indexes_kv_by_block_stride=lambda: True,
@@ -1191,28 +1003,16 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
original_backend_per_kind = {"full": object()}
config = SimpleNamespace(
attention_config=SimpleNamespace(backend=None, backend_per_kind=original_backend_per_kind),
parallel_config=SimpleNamespace(prefill_context_parallel_size=2),
model_config=SimpleNamespace(dtype=torch.float16),
cache_config=SimpleNamespace(cache_dtype="auto"),
)
def select_backend(**_kwargs):
selected_backends.append(
(
config.attention_config.backend,
config.attention_config.backend_per_kind,
config.parallel_config.prefill_context_parallel_size,
)
)
selected_backends.append((config.attention_config.backend, config.attention_config.backend_per_kind))
return native_backend
monkeypatch.setattr(adapter_module, "get_attn_backend", select_backend)
monkeypatch.setattr(adapter_module, "set_current_vllm_config", lambda _config: nullcontext())
monkeypatch.setattr(
adapter_module.current_omni_platform,
"get_diffusion_paged_kv_attn_backend",
lambda backend, *, ulysses_degree: specialized_backends.append((backend, ulysses_degree)) or backend,
)
layer = SimpleNamespace(num_heads=8, softmax_scale=0.125)
spec = FullAttentionSpec(
block_size=16,
@@ -1231,11 +1031,9 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
ulysses_degree=2,
)
assert selected_backends == [(adapter_module.AttentionBackendEnum.FLASH_ATTN, {}, 2)]
assert specialized_backends == [(native_backend, 2)]
assert selected_backends == [(adapter_module.AttentionBackendEnum.FLASH_ATTN, {})]
assert config.attention_config.backend is None
assert config.attention_config.backend_per_kind is original_backend_per_kind
assert config.parallel_config.prefill_context_parallel_size == 2
assert native_layer.num_heads == 4
assert native_layer.num_kv_heads == 2
assert native_layer.spec.num_kv_heads == 2
@@ -1296,51 +1094,6 @@ def test_omni_attention_wraps_paged_kernel_with_sp_hooks() -> None:
assert torch.equal(output, torch.full_like(original, 6))
def test_omni_attention_strips_paged_ulysses_padding_around_kernel() -> None:
events: list[tuple[str, tuple[int, ...]]] = []
class Strategy:
name = "ulysses"
def pre_attention(self, query, key, value, metadata):
return query, key, value, metadata, SimpleNamespace(joint_len=0, joint_strategy="front")
def post_attention(self, output, _ctx):
events.append(("post", tuple(output.shape)))
return output
class Adapter:
def prepare_layer_context(self, _layer_name, query, key, value, *, omni_attn_metadata):
assert omni_attn_metadata is None
events.append(("kernel_input", tuple(query.shape)))
assert query[:, -1].tolist() == [[[3.0]]]
assert torch.equal(query, key)
assert torch.equal(query, value)
return SimpleNamespace(query=query)
class Backend:
def forward_paged(self, context):
return context.query
layer = Attention.__new__(Attention)
nn.Module.__init__(layer)
layer.prefix = "layer-0"
layer.paged_kv_cache_role = "primary"
layer.attn_backend = SimpleNamespace(supports_paged_kv=True, get_name=lambda: "FLASH_ATTN")
layer.attention = Backend()
layer.use_ring = False
layer._no_parallel_strategy = object()
layer._get_active_parallel_strategy = lambda: Strategy()
tensor = torch.arange(5, dtype=torch.float32).reshape(1, 5, 1, 1)
with set_forward_context(), override_paged_kv_adapter(Adapter()):
get_forward_context().sp_padding_size = 1
output = layer._forward_impl(tensor, tensor, tensor)
assert events == [("kernel_input", (1, 4, 1, 1)), ("post", (1, 5, 1, 1))]
assert output.flatten().tolist() == [0.0, 1.0, 2.0, 3.0, 0.0]
def test_omni_attention_rejects_paged_request_for_backend_without_paged_support() -> None:
class Adapter:
def prepare_layer_context(self, *args, **kwargs):
@@ -1392,15 +1145,3 @@ def test_omni_attention_keeps_dense_kernel_without_active_adapter() -> None:
assert events == ["pre", "dense", "post"]
assert output is qkv
def test_omni_attention_rejects_scheduler_paged_forward_without_active_adapter() -> None:
layer = Attention.__new__(Attention)
nn.Module.__init__(layer)
layer._scheduler_paged_kv = True
layer.paged_kv_cache_role = "primary"
layer._get_active_parallel_strategy = lambda: object()
qkv = torch.zeros(1, 2, 2, 4)
with set_forward_context(), pytest.raises(RuntimeError, match="without an active Worker adapter"):
layer._forward_impl(qkv, qkv, qkv)
@@ -154,17 +154,8 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N
torch.manual_seed(1)
query = torch.randn(19, _NUM_HEADS, _HEAD_SIZE, dtype=torch.float16, device=device)
packed_qkv = torch.randn(
19,
3 * _NUM_HEADS * _HEAD_SIZE,
dtype=torch.float16,
device=device,
)
_, key_flat, value_flat = packed_qkv.split(_NUM_HEADS * _HEAD_SIZE, dim=-1)
key = key_flat.reshape(19, _NUM_HEADS, _HEAD_SIZE)
value = value_flat.reshape(19, _NUM_HEADS, _HEAD_SIZE)
assert not key.is_contiguous()
assert not value.is_contiguous()
key = torch.randn_like(query)
value = torch.randn_like(query)
omni_backend = FlashAttentionImpl(
num_heads=_NUM_HEADS,
head_size=_HEAD_SIZE,
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from contextlib import nullcontext
from types import SimpleNamespace
@@ -42,10 +42,9 @@ class _RunnerKVBackend:
self.kv_cache_config = config
def _attention(*, enabled: bool, prefix: str = "image_attention") -> Attention:
def _attention(*, enabled: bool) -> Attention:
attention = Attention.__new__(Attention)
nn.Module.__init__(attention)
attention.prefix = prefix
attention.paged_kv_cache_role = "primary" if enabled else None
attention.paged_kv_cache_dtype = torch.bfloat16
attention.num_kv_heads = 2
@@ -83,23 +82,6 @@ def test_runner_discovers_native_spec_from_loaded_attention() -> None:
assert spec.non_causal is True
def test_runner_uses_attention_prefix_as_canonical_layer_name() -> None:
runner = _runner(_attention(enabled=True, prefix="layers.0.self_attn.image_attn.attn"))
specs = runner.get_kv_cache_spec()
assert set(specs) == {"layers.0.self_attn.image_attn.attn"}
def test_runner_rejects_duplicate_attention_prefixes() -> None:
runner = _runner(_attention(enabled=True, prefix="shared"))
second_attention = _attention(enabled=True, prefix="shared")
runner.pipeline.second_image_attention = second_attention
with pytest.raises(RuntimeError, match="Duplicate canonical paged Diffusion Attention prefix 'shared'"):
runner.get_kv_cache_spec()
def test_runner_rejects_paged_mode_without_cache_enabled_attention() -> None:
runner = _runner(_attention(enabled=False))
@@ -42,10 +42,7 @@ def make_metadata(request_id: str = "req-0") -> DiffusionKVMetadata:
def make_runner(mode: DiffusionKVCacheMode) -> DiffusionModelRunner:
runner = object.__new__(DiffusionModelRunner)
runner.od_config = SimpleNamespace(
diffusion_kv_mode=mode,
parallel_config=SimpleNamespace(cfg_parallel_size=1),
)
runner.od_config = SimpleNamespace(diffusion_kv_mode=mode)
return runner
@@ -88,51 +85,6 @@ def test_new_request_data_carries_scheduler_allocation_atomically() -> None:
assert new_req.diffusion_kv_metadata is metadata
def test_runner_builds_prefill_and_denoise_rows_from_scheduler_metadata() -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
metadata = DiffusionKVMetadata(
request_id="req-0",
allocation_generation=1,
sequences=(
DiffusionKVSequenceMetadata(0, 4, 2, 8, ([1, 2],)),
DiffusionKVSequenceMetadata(1, 5, 2, 9, ([3, 4],)),
),
)
attn_metadata = runner._build_paged_attention_metadata([metadata])
assert [
(row.request_id, row.sequence_id, row.kv_start_pos, row.query_len, row.seq_len)
for row in attn_metadata.prefill_rows
] == [("req-0", 0, 0, 8, 8), ("req-0", 1, 0, 9, 9)]
assert [
(row.request_id, row.sequence_id, row.kv_start_pos, row.query_len, row.seq_len)
for row in attn_metadata.denoise_rows
] == [("req-0", 0, 4, 2, 6), ("req-0", 1, 5, 2, 7)]
def test_runner_selects_only_local_cfg_parallel_row(monkeypatch: pytest.MonkeyPatch) -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
runner.od_config.parallel_config.cfg_parallel_size = 2
monkeypatch.setattr(
"vllm_omni.diffusion.worker.diffusion_model_runner.get_classifier_free_guidance_rank",
lambda: 1,
)
metadata = DiffusionKVMetadata(
request_id="req-0",
allocation_generation=1,
sequences=(
DiffusionKVSequenceMetadata(0, 4, 2, 8, ([1, 2],)),
DiffusionKVSequenceMetadata(1, 5, 2, 9, ([3, 4],)),
),
)
attn_metadata = runner._build_paged_attention_metadata([metadata])
assert [row.sequence_id for row in attn_metadata.prefill_rows] == [1]
assert [row.sequence_id for row in attn_metadata.denoise_rows] == [1]
def test_paged_request_without_metadata_fails_before_request_forward() -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
runner._execute_request_list = Mock()
+15 -14
View File
@@ -43,16 +43,21 @@ def test_jit_build_includes_cuda_headers_from_nvidia_wheels(tmp_path, monkeypatc
@dataclass
class _FakeAllocation:
"""Byte workspace stand-in that slices and reinterprets like ``symm_mem.empty()``."""
size: int
device: torch.device
def __post_init__(self) -> None:
self.buffer = torch.empty(self.size, dtype=torch.uint8)
def __getitem__(self, item: slice) -> torch.Tensor:
return self.buffer[item]
class _FakeHandle:
rank = 0
def __init__(self, allocation: _FakeAllocation) -> None:
self.allocation = allocation
self.views: list[tuple[tuple[int, ...], torch.dtype]] = []
def get_buffer(self, rank, shape, dtype):
assert rank == self.rank
self.views.append((shape, dtype))
return self.allocation, shape, dtype
def test_workspace_reuses_peak_capacity_across_shapes(monkeypatch) -> None:
@@ -88,20 +93,16 @@ def test_workspace_reuses_peak_capacity_across_shapes(monkeypatch) -> None:
return allocation
monkeypatch.setattr(a2a_permute.symm_mem, "empty", empty)
# The handle is only retained to keep the rendezvous registration alive.
monkeypatch.setattr(a2a_permute.symm_mem, "rendezvous", lambda _allocation, _group: object())
monkeypatch.setattr(a2a_permute.symm_mem, "rendezvous", lambda allocation, _group: _FakeHandle(allocation))
device = torch.device("cuda:0")
first = a2a_permute._get_symm_buffer((2, 3), torch.float16, device, "group")
smaller = a2a_permute._get_symm_buffer((1, 4), torch.float16, device, "group")
larger = a2a_permute._get_symm_buffer((4, 3), torch.float16, device, "group")
assert (first.shape, first.dtype) == ((2, 3), torch.float16)
assert (smaller.shape, smaller.dtype) == ((1, 4), torch.float16)
assert (larger.shape, larger.dtype) == ((4, 3), torch.float16)
# A sub-capacity request reuses the workspace; only growth allocates again.
assert smaller.untyped_storage().data_ptr() == first.untyped_storage().data_ptr()
assert larger.untyped_storage().data_ptr() != first.untyped_storage().data_ptr()
assert first[0].size == 12
assert smaller[0] is first[0]
assert larger[0].size == 24
assert [allocation.size for allocation in allocations] == [12, 24]
assert len(a2a_permute._SYMM_WORKSPACES) == 1
assert len(all_reduce_calls) == 1
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
@@ -270,17 +270,6 @@ def test_grouped_denoise_allows_sdpa_attention_backend():
pipeline._ensure_grouped_attention_backend_supported(2)
def test_scheduler_paged_step_execution_is_rejected():
pipeline = _pipeline()
pipeline.od_config.diffusion_kv_mode = hy3_module.DiffusionKVCacheMode.PAGED_SCHEDULER
state = _state("paged-step", 0)
with pytest.raises(ValueError, match="request-level execution only"):
pipeline.prepare_encode(state)
with pytest.raises(ValueError, match="request-level execution only"):
pipeline.denoise_step(InputBatch.make_batch([state]))
def test_step_scheduler_preserves_latent_dtype_for_mixed_progress_batches():
pipeline = _pipeline()
pipeline._pipeline = SimpleNamespace(prepare_extra_func_kwargs=lambda step, kwargs: {})
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for ImageKVCacheManager.
Covers: cache → reuse flow, AR KV injection, CFG (sequential & parallel), SP, cross-request isolation.
@@ -37,21 +37,12 @@ class MockAttention(nn.Module):
super().__init__()
self.paged_kv_active = False
self.calls = []
self.paged_calls = []
self.paged_metadata = []
def is_paged_kv_active(self):
return self.paged_kv_active
def forward(self, query, key, value, attn_metadata=None, **kwargs):
self.calls.append((query, key, value))
from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_context_available
if is_forward_context_available() and get_forward_context().paged_kv_adapter is not None:
self.paged_calls.append((query, key, value))
self.paged_metadata.append(attn_metadata)
if attn_metadata is not None and attn_metadata.joint_query is not None:
return torch.cat((attn_metadata.joint_query, query), dim=1)
return query
@@ -114,7 +105,6 @@ def _call_mgr(
shard_image_size=None,
gen_timestep_scatter_index=None,
position_ids=None,
full_attn_spans=None,
):
query = torch.randn(bs * q_len, NUM_HEADS, HEAD_DIM)
attn_mask = torch.zeros(bs, 1, seq_len, seq_len)
@@ -131,7 +121,6 @@ def _call_mgr(
shard_image_size=shard_image_size,
gen_timestep_scatter_index=gen_timestep_scatter_index,
position_ids=position_ids,
full_attn_spans=full_attn_spans,
)
@@ -143,83 +132,6 @@ def test_cache_manager_registers_attention_without_adding_dense_state() -> None:
assert mgr.state_dict() == {}
def test_scheduler_paged_kv_runs_piecewise_for_first_and_later_steps() -> None:
from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context
mgr = _make_cache_mgr()
mgr.attn.paged_kv_active = True
bs = 2
prefix_lens = torch.tensor([[2], [4]], dtype=torch.long)
first_q_len = 12
first_k, first_v = _make_known_kv(bs * first_q_len, base=1.0)
full_attn_spans = [[(2, 12)], [(2, 12)]]
with set_forward_context(), override_paged_kv_adapter(object()):
_call_mgr(
mgr,
bs,
q_len=first_q_len,
seq_len=first_q_len,
key_flat=first_k,
value_flat=first_v,
first_step=True,
gen_timestep_scatter_index=prefix_lens,
full_attn_spans=full_attn_spans,
)
assert mgr.image_kv_cache_map is None
assert mgr.image_kv_cache_lens is None
assert len(mgr.attn.paged_calls) == 1
assert mgr.attn.paged_calls[0][0].shape == (bs, first_q_len, NUM_HEADS, HEAD_DIM)
assert mgr.attn.paged_calls[0][1].shape == (bs, first_q_len, NUM_KV_HEADS, HEAD_DIM)
assert mgr.attn.paged_calls[0][2].shape == (bs, first_q_len, NUM_KV_HEADS, HEAD_DIM)
assert mgr.attn.paged_metadata[0].full_attn_spans == full_attn_spans
current_q_len = IMAGE_TOKEN_LEN
current_k, current_v = _make_known_kv(bs * current_q_len, base=50.0)
_call_mgr(
mgr,
bs,
q_len=current_q_len,
seq_len=prefix_lens[-1].item() + current_q_len,
key_flat=current_k,
value_flat=current_v,
position_ids=torch.arange(4, 4 + current_q_len).repeat(bs, 1),
full_attn_spans=full_attn_spans,
)
assert len(mgr.attn.paged_calls) == 2
assert mgr.attn.paged_calls[1][0].shape == (bs, current_q_len, NUM_HEADS, HEAD_DIM)
assert mgr.attn.paged_calls[1][1].shape == (bs, current_q_len, NUM_KV_HEADS, HEAD_DIM)
assert mgr.attn.paged_calls[1][2].shape == (bs, current_q_len, NUM_KV_HEADS, HEAD_DIM)
assert mgr.attn.paged_metadata[1].full_attn_spans == full_attn_spans
def test_cache_manager_rejects_dense_imported_prefix_for_paged_attention() -> None:
from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context
mgr = _make_cache_mgr()
mgr.attn.paged_kv_active = True
prefix_len = 4
prefix_key, prefix_value = _make_known_kv(prefix_len, base=100.0)
mgr._injected_ar_kv = [(prefix_key, prefix_value)]
current_key, current_value = _make_known_kv(3, base=1.0)
with set_forward_context(), override_paged_kv_adapter(object()):
with pytest.raises(NotImplementedError, match="does not support imported AR KV"):
_call_mgr(
mgr,
bs=1,
q_len=3,
seq_len=7,
key_flat=current_key,
value_flat=current_value,
first_step=True,
gen_timestep_scatter_index=torch.tensor([[0]]),
full_attn_spans=[[(4, 7)]],
)
@pytest.mark.parametrize(
("paged_kv_active", "expected_kv_heads"),
[(False, NUM_HEADS), (True, NUM_KV_HEADS)],
@@ -243,7 +155,6 @@ def test_gqa_kv_stays_compressed_for_paged_attention(
first_step=True,
num_image_tokens=0,
gen_timestep_scatter_index=_gen_timestep_index(1, 0),
full_attn_spans=[[(0, q_len)]] if paged_kv_active else None,
)
_, key_input, value_input = mgr.attn.calls[-1]
@@ -57,8 +57,7 @@ def test_h3_prepares_resolved_cache_state_immediately_before_denoise():
return torch.zeros(1), torch.zeros(1)
pipeline.diffuse = diffuse
decoded_audio = torch.zeros(1)
pipeline.decode = Mock(return_value=(torch.zeros(1, 3, 1, 1, 1), decoded_audio))
pipeline.decode = Mock(return_value=(torch.zeros(1), torch.zeros(1)))
sampling = OmniDiffusionSamplingParams(
quality="high",
width=1344,
@@ -87,9 +86,7 @@ def test_h3_prepares_resolved_cache_state_immediately_before_denoise():
num_inference_steps=50,
extra_args={"task": "t2va", "aspect_ratio": "16:9"},
)
assert output.output[0].shape == (1, 1, 1, 1, 3)
assert output.output[0].dtype == torch.uint8
assert output.output[1] is decoded_audio
assert output.output == pipeline.decode.return_value
def test_pipeline_import_registry_and_component_discovery():
@@ -496,41 +493,18 @@ def test_joint_postprocess_is_multiprocessing_picklable():
postprocess = get_minimax_h3_post_process_func(SimpleNamespace())
postprocess = ForkingPickler.loads(ForkingPickler.dumps(postprocess))
# decode() hands over quantised frames already laid out as (B, T, H, W, C).
video = torch.arange(2 * 2 * 4 * 5 * 3, dtype=torch.uint8).reshape(2, 2, 4, 5, 3)
video = torch.linspace(0, 1, 2 * 3 * 2 * 4 * 5).reshape(2, 3, 2, 4, 5)
audio = torch.arange(12, dtype=torch.float32).reshape(1, 2, 6)
result = postprocess((video, audio), output_type="np")
assert isinstance(result["video"], list)
assert result["video"][0].dtype == np.uint8
assert result["video"][0].shape == (2, 4, 5, 3)
np.testing.assert_array_equal(result["video"][0], video[0].numpy())
np.testing.assert_array_equal(result["audio"], audio.numpy())
assert result["audio_sample_rate"] == 32000
assert result["fps"] == 24
def test_joint_video_output_is_quantized_before_transfer():
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_minimax_h3_post_process,
_prepare_minimax_h3_video_output,
)
video = torch.linspace(-0.1, 1.1, 2 * 3 * 2 * 4 * 5, dtype=torch.float64).reshape(2, 3, 2, 4, 5)
expected = torch.round(video.float().clamp(0, 1) * 255).to(torch.uint8).permute(0, 2, 3, 4, 1)
prepared = _prepare_minimax_h3_video_output(video)
assert prepared.dtype == torch.uint8
assert prepared.is_contiguous()
assert prepared.shape == (2, 2, 4, 5, 3)
torch.testing.assert_close(prepared, expected, rtol=0, atol=0)
result = _minimax_h3_post_process((prepared, torch.zeros(1, 2, 6)))
assert result["video"][0].dtype == np.uint8
np.testing.assert_array_equal(result["video"][0], expected[0].numpy())
def test_cfg_parallel_is_rejected_for_distilled_checkpoint():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
@@ -598,7 +572,7 @@ def test_base_schedule_overrides_the_uniform_sigma_positions():
@pytest.mark.parametrize(
"base_schedule",
[
[1.5, 0.5, 0.0],
[0.9, 0.5, 0.0],
[1.0, 0.5, 0.1],
[1.0, 0.5, 0.5, 0.0],
[1.0],
@@ -653,7 +627,7 @@ def _distilled_pipeline(diffuse_calls, base_schedule_by_partition):
return torch.zeros(1), torch.zeros(1)
pipeline.diffuse = diffuse
pipeline.decode = Mock(return_value=(torch.zeros(1, 3, 1, 1, 1), torch.zeros(1)))
pipeline.decode = Mock(return_value=(torch.zeros(1), torch.zeros(1)))
return pipeline
@@ -1,498 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
import torch
from safetensors.torch import save_file
from vllm_omni.diffusion.models.minimax_h3.fasth3 import (
FASTH3_BASE_MODEL,
FASTH3_BASE_SCHEDULE,
FASTH3_DENOISE_STEPS,
FASTH3_FORMAT,
FastH3AdapterError,
FastH3WeightFusion,
resolve_fasth3_fusion,
)
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import _reorder_grouped_qkv_to_qkv
from vllm_omni.diffusion.models.minimax_h3.time_request import minimax_h3_time_shift_sigmas
from vllm_omni.errors import OmniClientError
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
_RANK = 2
_HIDDEN = 4
_HEAD_DIM = 2
_HEADS = 3
_INNER = _HEAD_DIM * _HEADS # attention inner size
_FFN = 5
def _factors(out_dim: int, in_dim: int) -> tuple[torch.Tensor, torch.Tensor]:
"""A rank-2 pair whose product is reproducible and non-symmetric."""
a = torch.arange(_RANK * in_dim, dtype=torch.float32).reshape(_RANK, in_dim) / (in_dim * _RANK)
b = torch.arange(out_dim * _RANK, dtype=torch.float32).reshape(out_dim, _RANK) / (out_dim * _RANK)
return a, b
def _write_adapter(
path,
*,
tensors=None,
drop: str | None = None,
blocks: int = 1,
metadata: dict[str, str | None] | None = None,
) -> None:
"""Write an artifact in the published ``fastvideo-lora-v2`` shape."""
payload: dict[str, torch.Tensor] = {}
for block in range(blocks):
for suffix, (out_dim, in_dim) in {
"attn.to_q": (_INNER, _HIDDEN),
"attn.to_k": (_INNER, _HIDDEN),
"attn.to_v": (_INNER, _HIDDEN),
"attn.to_out.0": (_HIDDEN, _INNER),
"ff.net.0.proj": (2 * _FFN, _HIDDEN),
"ff.net.2": (_HIDDEN, _FFN),
}.items():
a, b = _factors(out_dim, in_dim)
payload[f"transformer_blocks.{block}.{suffix}.lora_A.weight"] = a
payload[f"transformer_blocks.{block}.{suffix}.lora_B.weight"] = b
payload.update(tensors or {})
if drop is not None:
payload.pop(drop, None)
path.parent.mkdir(parents=True, exist_ok=True)
# The published writer records what it emitted; mirror it so the fixtures
# are as self-describing as the real artifact.
declared = {
"format": FASTH3_FORMAT,
"finetuned_model": "FastVideo/FastVideo-FastH3-Dense-4-step-v1",
"base_model": FASTH3_BASE_MODEL,
"rank": str(_RANK),
"low_rank_tensors": str(sum(1 for key in payload if key.endswith((".lora_A.weight", ".lora_B.weight")))),
"diff_tensors": str(sum(1 for key in payload if key.endswith((".diff", ".diff_b")))),
"set_weight_tensors": str(sum(1 for key in payload if key.endswith(".set_weight"))),
}
# safetensors metadata is string-only, so a ``None`` override drops the key.
declared.update(metadata or {})
save_file(payload, str(path), metadata={key: value for key, value in declared.items() if value is not None})
def _claim(path, **kwargs) -> FastH3WeightFusion | None:
"""Whatever from_path decides, including declining the artifact."""
kwargs.setdefault("head_dim", _HEAD_DIM)
kwargs.setdefault("num_blocks", 1)
kwargs.setdefault("num_refiner_blocks", 0)
return FastH3WeightFusion.from_path(path, **kwargs)
def _load(path, **kwargs) -> FastH3WeightFusion:
"""The same, for the tests that only make sense on a claimed adapter."""
fusion = _claim(path, **kwargs)
assert fusion is not None, f"{path} was not claimed as a FastH3 adapter"
return fusion
def test_only_an_artifact_carrying_the_release_identity_is_claimed(tmp_path):
plain = tmp_path / "peft" / "adapter_model.safetensors"
plain.parent.mkdir(parents=True)
save_file({"transformer_blocks.0.attn.to_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))}, str(plain))
# No fastvideo-lora-v2 metadata: this is somebody else's LoRA and has to
# stay on the dynamic route rather than being fused.
assert _claim(plain.parent) is None
assert _claim(tmp_path / "missing") is None
claimed = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(claimed)
assert _claim(claimed.parent) is not None
@pytest.mark.parametrize(
"identity",
[
# An ordinary H3 adapter out of FastVideo's own extraction tools.
{"finetuned_model": "someone/minimax-h3-style-lora"},
# Somebody else's adapter that merely names the student it imitates.
{"finetuned_model": "someone/fasth3-style-lora-for-h3"},
{"finetuned_model": ""},
{"finetuned_model": None},
# A FastH3 name over a different base model.
{"base_model": "Wan-AI/Wan2.2-TI2V-5B"},
],
)
def test_a_generic_fastvideo_h3_adapter_stays_on_the_dynamic_route(identity, tmp_path):
"""The fixture edits every block, so only the identity separates it from FastH3."""
path = tmp_path / "generic" / "adapter_model.safetensors"
_write_adapter(path, metadata=identity)
assert _claim(path.parent) is None
def test_a_claimed_artifact_must_declare_every_tensor_count(tmp_path):
"""Declining is for other people's adapters; a FastH3 file is held to its word."""
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, metadata={"diff_tensors": None})
with pytest.raises(FastH3AdapterError, match="omits diff_tensors"):
_claim(path.parent)
def test_the_published_bundle_root_is_refused_rather_than_guessed(tmp_path):
root = tmp_path / "FastVideo-FastH3-4-step-Preview-v1-LoRA"
for slug in ("dense-datafree", "vsa-datafree"):
_write_adapter(root / slug / "adapter_model.safetensors")
(root / "adapter_manifest.json").write_text(json.dumps({"schema_version": "fasth3-lora-bundle-v1"}))
with pytest.raises(FastH3AdapterError, match="point --lora-path at one variant"):
_load(root)
# One variant inside it is unambiguous.
assert _claim(root / "dense-datafree") is not None
def test_a_multi_shard_non_fasth3_adapter_stays_on_the_dynamic_route(tmp_path):
# A plain PEFT LoRA saved as several shards must not hard-fail startup with a
# FastH3-specific message; it has to fall through to the dynamic LoRA route.
directory = tmp_path / "peft-sharded"
directory.mkdir()
for shard in ("adapter_model-00001-of-00002.safetensors", "adapter_model-00002-of-00002.safetensors"):
save_file(
{"transformer_blocks.0.attn.to_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))}, str(directory / shard)
)
assert _claim(directory) is None
def test_low_rank_factors_reach_the_fused_projections(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path)
fusion = _load(path.parent)
qkv = torch.zeros((3 * _INNER, _HIDDEN), dtype=torch.float32)
fused = fusion.fuse("blocks.0.attn.qkv_proj.weight", qkv).cpu()
# The checkpoint stores one head group at a time as [q, k, v], so the delta
# has to survive the loader's own unpacking as three separate projections.
q, k, v = torch.split(
_reorder_grouped_qkv_to_qkv(fused, num_query_groups=_HEADS, heads_per_group=1, head_dim=_HEAD_DIM),
[_INNER, _INNER, _INNER],
)
a, b = _factors(_INNER, _HIDDEN)
expected = b @ a
for got in (q, k, v):
assert torch.allclose(got, expected, atol=1e-5)
def test_the_fused_mlp_delta_is_swapped_into_gate_first_order(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path)
fusion = _load(path.parent)
fused = fusion.fuse("blocks.0.mlp.fc1.weight", torch.zeros((2 * _FFN, _HIDDEN))).cpu()
a, b = _factors(2 * _FFN, _HIDDEN)
value, gate = (b @ a).chunk(2, dim=0)
# The diffusers export is value-first; H3's fc1 is gate-first.
assert torch.allclose(fused, torch.cat((gate, value), dim=0), atol=1e-5)
def test_diff_and_diff_b_edit_weights_and_biases(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(
path,
tensors={
"transformer_blocks.0.norm1.diff": torch.full((_HIDDEN,), 0.25),
"transformer_blocks.0.adaln_proj.linear.diff_b": torch.full((_HIDDEN,), -0.5),
"context_embedder.diff": torch.full((_HIDDEN, _HIDDEN), 0.125),
"context_embedder.diff_b": torch.full((_HIDDEN,), 2.0),
},
)
fusion = _load(path.parent)
# A full-rank delta on an RMSNorm vector is exactly what a LoRA layer
# cannot express, and why this release is fused instead of switched.
assert torch.allclose(fusion.fuse("blocks.0.norm1.weight", torch.ones(_HIDDEN)).cpu(), torch.full((_HIDDEN,), 1.25))
assert torch.allclose(
fusion.fuse("blocks.0.adaln_proj.linear.bias", torch.ones(_HIDDEN)).cpu(), torch.full((_HIDDEN,), 0.5)
)
assert torch.allclose(
fusion.fuse("condition_proj.weight", torch.zeros((_HIDDEN, _HIDDEN))).cpu(),
torch.full((_HIDDEN, _HIDDEN), 0.125),
)
assert torch.allclose(fusion.fuse("condition_proj.bias", torch.zeros(_HIDDEN)).cpu(), torch.full((_HIDDEN,), 2.0))
def test_a_streamed_checkpoint_keeps_the_parameters_the_adapter_never_edits(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, tensors={"transformer_blocks.0.norm1.diff": torch.full((_HIDDEN,), 3.0)})
fusion = _load(path.parent)
untouched = torch.arange(6, dtype=torch.bfloat16).reshape(2, 3)
streamed = dict(
fusion.apply([("blocks.0.norm1.weight", torch.zeros(_HIDDEN)), ("blocks.0.attn.q_norm.weight", untouched)])
)
assert torch.allclose(streamed["blocks.0.norm1.weight"].cpu(), torch.full((_HIDDEN,), 3.0))
assert streamed["blocks.0.attn.q_norm.weight"] is untouched
# validate_fully_applied() releases the deltas, so a second stream would
# fuse nothing at all; it has to fail rather than serve base H3 weights.
with pytest.raises(FastH3AdapterError, match="already been fused"):
next(fusion.apply([("blocks.0.norm1.weight", torch.zeros(_HIDDEN))]))
def test_the_fused_result_keeps_the_parameter_dtype(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, tensors={"transformer_blocks.0.norm1.diff": torch.full((_HIDDEN,), 0.5)})
fusion = _load(path.parent)
fused = fusion.fuse("blocks.0.norm1.weight", torch.ones(_HIDDEN, dtype=torch.bfloat16))
assert fused.dtype == torch.bfloat16
def test_a_vsa_variant_is_recognised_by_its_compression_gates(tmp_path):
dense = tmp_path / "dense" / "adapter_model.safetensors"
_write_adapter(dense)
assert _load(dense.parent).requires_vsa is False
sparse = tmp_path / "vsa" / "adapter_model.safetensors"
_write_adapter(sparse, tensors={"transformer_blocks.0.attn.to_gate_compress.set_weight": torch.ones((2, 2))})
assert _load(sparse.parent).requires_vsa is True
def test_a_tensor_naming_no_h3_parameter_is_an_error(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, tensors={"transformer_blocks.0.attn.norm_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))})
# Dropping it silently would load a model that is not the distilled student.
with pytest.raises(FastH3AdapterError, match="name no known"):
_load(path.parent)
def test_an_unpaired_low_rank_factor_is_an_error(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, drop="transformer_blocks.0.attn.to_q.lora_B.weight")
with pytest.raises(FastH3AdapterError, match="unpaired factor"):
_load(path.parent)
def test_a_delta_the_checkpoint_never_offered_is_reported(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path)
fusion = _load(path.parent)
fusion.fuse("blocks.0.attn.qkv_proj.weight", torch.zeros((3 * _INNER, _HIDDEN)))
# An unapplied delta is the failure that matters: the model would load and
# generate, just not as the distilled student.
with pytest.raises(FastH3AdapterError, match="never provided"):
fusion.validate_fully_applied()
for name, shape in (
("blocks.0.attn.out_proj.weight", (_HIDDEN, _INNER)),
("blocks.0.mlp.fc1.weight", (2 * _FFN, _HIDDEN)),
("blocks.0.mlp.fc2.weight", (_HIDDEN, _FFN)),
):
fusion.fuse(name, torch.zeros(shape))
fusion.validate_fully_applied()
def _pipeline_stub(fusion=None):
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.partition = "combined"
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
pipeline._turbo_lora_adapter_ids = set()
pipeline._fasth3 = fusion
pipeline.od_config = SimpleNamespace()
pipeline.default_video_shift, pipeline.default_audio_shift = 12.0, 3.0
return pipeline
def _sampling(steps=FASTH3_DENOISE_STEPS, **kwargs) -> SimpleNamespace:
fields = {"num_inference_steps": steps, "extra_args": {}, "lora_request": None}
return SimpleNamespace(**{**fields, **kwargs})
def _check_request(fusion, sampling) -> None:
fusion.check_request(sampling, video_shift=12.0, audio_shift=3.0)
def _fusion(tmp_path, **kwargs) -> FastH3WeightFusion:
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, **kwargs)
return _load(path.parent)
@pytest.mark.parametrize("task", ["fl2va", "ref2va"])
def test_an_active_fasth3_fusion_restricts_requests_to_t2va(task, tmp_path):
pipeline = _pipeline_stub(_fusion(tmp_path))
with pytest.raises(OmniClientError, match="distills \\['t2va'\\] only"):
pipeline._resolve_task(task, {})
assert pipeline._resolve_task("t2va", {}) == "t2va"
assert _pipeline_stub()._resolve_task(task, {}) == task
@pytest.mark.parametrize("num_inference_steps", [None, 5, 50])
def test_fasth3_requires_one_forward_per_sigma_interval(num_inference_steps, tmp_path):
fusion = _fusion(tmp_path)
# The five sigma points bound four forwards, and a request states forwards:
# the interval contract the pinned schedules use, and the count the step
# scheduler admits a request on before any pipeline hook runs.
with pytest.raises(OmniClientError, match=f"num_inference_steps={FASTH3_DENOISE_STEPS}"):
_check_request(fusion, _sampling(num_inference_steps))
_check_request(fusion, _sampling())
# The pinned branch coerces the string form; this one cannot disagree.
_check_request(fusion, _sampling(str(FASTH3_DENOISE_STEPS)))
def test_the_request_denoises_on_the_release_ladder(tmp_path):
pipeline = _pipeline_stub(_fusion(tmp_path))
positions, num_steps = pipeline._resolve_sigma_positions("t2va", _sampling())
# dmd_denoising_steps [999, 749, 500, 250] out of 1000, closed with the
# terminal 0.0: the pre-shift positions the student was distilled at, not
# the uniform ladder num_inference_steps would otherwise derive.
assert positions == (0.999, 0.749, 0.5, 0.25, 0.0)
# Five sigma points bound four transformer forwards, and forwards is the
# unit num_steps carries downstream (Cache-DiT, quality hints).
assert num_steps == FASTH3_DENOISE_STEPS == len(positions) - 1
# H3's own per-modality shifts still apply on top, and are what turn those
# positions into the noise levels the four forwards run at.
assert minimax_h3_time_shift_sigmas(shift_scale=12.0, base_schedule=positions) == pytest.approx(
[0.999917, 0.972833, 0.923077, 0.8, 0.0], abs=1e-6
)
assert minimax_h3_time_shift_sigmas(shift_scale=3.0, base_schedule=positions) == pytest.approx(
[0.999666, 0.899520, 0.75, 0.5, 0.0], abs=1e-6
)
# Without a fused adapter the same request keeps the undistilled ladder.
assert _pipeline_stub()._resolve_sigma_positions("t2va", _sampling()) == (None, FASTH3_DENOISE_STEPS)
def test_the_contract_leaves_the_checkpoint_shifts_alone(tmp_path):
pipeline = _pipeline_stub(_fusion(tmp_path))
pipeline.partition = "fl2va"
pipeline._fasth3.check_serving_contract(
partition=pipeline.partition,
od_config=pipeline.od_config,
video_shift=pipeline.default_video_shift,
audio_shift=pipeline.default_audio_shift,
)
# The ladder is stated pre-shift, so H3's 12/3 shifts are what place the
# student on the levels it was distilled at; the contract must not touch them.
assert pipeline.default_video_shift == 12.0
assert pipeline.default_audio_shift == 3.0
@pytest.mark.parametrize(
"extra",
[{"flow_shift": 1.0}, {"audio_flow_shift": 1.0}, {"flow_shift": "bad"}],
)
def test_a_request_may_not_move_the_student_off_its_rungs(extra, tmp_path):
fusion = _fusion(tmp_path)
with pytest.raises(OmniClientError, match="FastH3 requires"):
_check_request(fusion, _sampling(extra_args=extra))
_check_request(fusion, _sampling(extra_args={"flow_shift": 12.0, "audio_flow_shift": 3.0}))
def test_a_request_may_not_carry_a_lora_on_a_fused_server(tmp_path):
fusion = _fusion(tmp_path)
# The dynamic LoRA manager is skipped for a fused adapter, so an adapter
# asked for per request would be neither applied nor reported.
with pytest.raises(OmniClientError, match="per-request lora is unavailable"):
_check_request(fusion, _sampling(lora_request=SimpleNamespace(lora_int_id=1)))
def test_offload_is_refused_because_it_bypasses_the_fusion(tmp_path):
fusion = _fusion(tmp_path)
for flag in ("enable_cpu_offload", "enable_layerwise_offload", "enable_distributed_layerwise_offload"):
# A host-weight plan installs the transformer without load_weights(),
# so the fusion and its completeness check would both be skipped.
with pytest.raises(ValueError, match="cannot be combined with"):
fusion.check_serving_contract(
partition="fl2va",
od_config=SimpleNamespace(**{flag: True}),
video_shift=12.0,
audio_shift=3.0,
)
def test_adopting_the_contract_refuses_a_vsa_variant_and_ref2va(tmp_path):
sparse = tmp_path / "vsa" / "adapter_model.safetensors"
_write_adapter(sparse, tensors={"transformer_blocks.0.attn.to_gate_compress.set_weight": torch.ones((2, 2))})
contract = {"od_config": SimpleNamespace(), "video_shift": 12.0, "audio_shift": 3.0}
with pytest.raises(ValueError, match="Video Sparse Attention variant"):
_load(sparse.parent).check_serving_contract(partition="fl2va", **contract)
with pytest.raises(ValueError, match="cannot serve a Ref2VA partition"):
_fusion(tmp_path).check_serving_contract(partition="ref2va", **contract)
def test_a_full_rank_delta_on_a_fused_parameter_is_refused(tmp_path):
# Only low-rank factors are placed into H3's fused QKV and gate/up layouts,
# so a .diff aimed at one would otherwise be added transposed.
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, tensors={"transformer_blocks.0.attn.to_q.diff": torch.ones((_INNER, _HIDDEN))})
with pytest.raises(FastH3AdapterError, match="fused layout"):
_claim(path.parent)
def test_an_adapter_that_leaves_blocks_untouched_is_refused(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, blocks=2)
# Every tensor in this file is well formed and pairs up, so the fusion's
# own completeness check passes; only the model's depth reveals that a
# third of it would have been served as base H3 weights.
with pytest.raises(FastH3AdapterError, match="missing=\\[2\\]"):
_claim(path.parent, num_blocks=3)
with pytest.raises(FastH3AdapterError, match="unknown=\\[1\\]"):
_claim(path.parent, num_blocks=1)
assert _claim(path.parent, num_blocks=2) is not None
def test_an_adapter_shorter_than_it_declares_is_refused(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, metadata={"low_rank_tensors": "724", "diff_tensors": "85"})
# The writer records what it emitted, which is the file's one statement
# about its own completeness.
with pytest.raises(FastH3AdapterError, match="declares low_rank_tensors=724 but carries 12"):
_claim(path.parent)
def test_only_a_configured_adapter_path_is_read(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path)
transformer = SimpleNamespace(
arch=SimpleNamespace(attention_head_dim=_HEAD_DIM, num_layers=1, token_refiner_num_layers=0)
)
unbuilt = object() # a transformer whose arch must not be touched
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=str(path)), transformer) is not None
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=[str(path)]), transformer) is not None
# Nothing configured, or more than one artifact, stays on the dynamic route
# without reading anything off the model.
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=None), unbuilt) is None
assert resolve_fasth3_fusion(SimpleNamespace(), unbuilt) is None
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=[str(path), str(path)]), unbuilt) is None
def test_a_pipeline_that_fused_its_adapter_needs_no_lora_manager(tmp_path):
assert _pipeline_stub(_fusion(tmp_path)).lora_is_fused is True
assert _pipeline_stub().lora_is_fused is False
def test_the_ladder_is_the_one_the_release_publishes():
# FASTH3_DENOISE_STEPS is derived from it, so the two cannot drift apart.
assert FASTH3_BASE_SCHEDULE.base_schedule == (0.999, 0.749, 0.5, 0.25, 0.0)
assert FASTH3_BASE_SCHEDULE.num_inference_steps == FASTH3_DENOISE_STEPS == 4
@@ -259,11 +259,7 @@ def test_h3_native_rejects_schedule_with_wrong_interval_count(tmp_path):
("", "must not be empty"),
(",,", "must not be empty"),
("1.0,oops,0.15,0.0", "is malformed"),
# A capped opening rung below 1.0 is a legal DMD2 schedule, so the
# out-of-range cases are a first position above 1.0 and a tail that
# never reaches clean latents.
("1.5,0.7,0.4,0.15,0.0", "is malformed"),
("1.0,0.7,0.4,0.15", "is malformed"),
("0.9,0.7,0.4,0.15,0.0", "is malformed"),
],
)
def test_h3_native_rejects_malformed_schedule_metadata(tmp_path, raw_schedule, message):
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from vllm_omni.diffusion.sched import DMD2SigmaSchedule
@@ -18,8 +18,7 @@ def test_step_count_is_intervals_not_boundaries():
[
[],
[1.0],
[1.5, 0.5, 0.0],
[0.0, 0.5, 0.0],
[0.9, 0.5, 0.0],
[1.0, 0.5, 0.1],
[1.0, 0.4, 0.6, 0.0],
[1.0, 0.5, 0.5, 0.0],
@@ -32,15 +31,6 @@ def test_malformed_positions_are_rejected(positions):
DMD2SigmaSchedule.from_positions(positions)
def test_a_capped_first_position_is_a_schedule_not_a_malformed_one():
# A DMD2 student trained with max_timestep_ratio < 1 never sees pure noise,
# so its opening rung sits just below 1.0 (FastH3: 999/1000).
schedule = DMD2SigmaSchedule.from_positions([0.999, 0.749, 0.5, 0.25, 0.0])
assert schedule.base_schedule[0] == 0.999
assert schedule.num_inference_steps == 4
def test_one_base_schedule_drives_several_shift_scales():
schedule = DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])
+3 -21
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from contextlib import contextmanager
from types import SimpleNamespace
@@ -533,14 +533,7 @@ def test_profile_run_executes_forward_without_scheduler_kv_validation(monkeypatc
return original_execute(*args, **kwargs)
runner._execute_request_list = execute
forward_context_calls = []
@contextmanager
def record_forward_context(*args, **kwargs):
forward_context_calls.append((args, kwargs))
yield
monkeypatch.setattr(model_runner_module, "set_forward_context", record_forward_context)
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
reset_peak_memory_stats = Mock()
monkeypatch.setattr(
model_runner_module.current_omni_platform,
@@ -556,8 +549,6 @@ def test_profile_run_executes_forward_without_scheduler_kv_validation(monkeypatc
assert runner.pipeline.forward_calls == 1
assert record_names == ["pipeline_memory_profile"]
assert len(forward_context_calls) == 1
assert forward_context_calls[0][1]["in_diffusion_kv_memory_profile"] is True
runner._validate_diffusion_kv_metadata.assert_not_called()
reset_peak_memory_stats.assert_not_called()
synchronize.assert_called_once_with()
@@ -584,14 +575,7 @@ def test_profile_run_executes_maximum_step_batch_without_resetting_peak(monkeypa
runner.pipeline.denoise_step = denoise_step
runner._validate_diffusion_kv_metadata = Mock(side_effect=AssertionError("profile must bypass admission"))
forward_context_calls = []
@contextmanager
def record_forward_context(*args, **kwargs):
forward_context_calls.append((args, kwargs))
yield
monkeypatch.setattr(model_runner_module, "set_forward_context", record_forward_context)
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
reset_peak_memory_stats = Mock()
monkeypatch.setattr(
model_runner_module.current_omni_platform,
@@ -608,8 +592,6 @@ def test_profile_run_executes_maximum_step_batch_without_resetting_peak(monkeypa
assert observed_batch_rows == [(2, 2)]
assert runner.state_cache == {}
assert runner.input_batch is None
assert len(forward_context_calls) == 1
assert forward_context_calls[0][1]["in_diffusion_kv_memory_profile"] is True
runner._validate_diffusion_kv_metadata.assert_not_called()
reset_peak_memory_stats.assert_not_called()
synchronize.assert_called_once_with()
+1 -30
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
import time
@@ -7,8 +7,6 @@ from dataclasses import asdict, dataclass
import pytest
import vllm_omni.diffusion.stage_diffusion_proc as stage_diffusion_proc
import vllm_omni.plugins as omni_plugins
from vllm_omni.diffusion.stage_diffusion_proc import StageDiffusionProc
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -28,33 +26,6 @@ BASE_INFER_STEPS = 10
DELAY_BASE = 0.01
def test_run_diffusion_proc_sets_lifecycle_before_loading_plugins(monkeypatch: pytest.MonkeyPatch) -> None:
events: list[str] = []
class StopProcessError(Exception):
pass
class TestStageDiffusionProc(StageDiffusionProc):
def __init__(self, model, od_config):
events.append("proc")
raise StopProcessError
monkeypatch.setattr(omni_plugins, "load_omni_general_plugins", lambda: events.append("plugins"))
monkeypatch.setattr(stage_diffusion_proc, "set_death_signal", lambda _: events.append("death_signal"))
monkeypatch.setattr(stage_diffusion_proc.signal, "signal", lambda *_: events.append("signal_handler"))
with pytest.raises(StopProcessError):
TestStageDiffusionProc.run_diffusion_proc(
model="test-model",
od_config=None,
handshake_address="test-address",
local_client=True,
headless=False,
)
assert events == ["death_signal", "signal_handler", "signal_handler", "plugins", "proc"]
class MockDiffusionEngine:
async def step_streaming(self, request):
def simulate_step_delay(height, width, num_inference_steps) -> float:
@@ -2959,34 +2959,3 @@ def test_abort_clears_native_codec_state_before_external_id_reuse(build_adapter)
)
assert torch.equal(replacement_payload.codes.audio, torch.full((1, 31), 202, dtype=torch.long))
def test_save_async_boundary_holds_generation_without_request_counter(build_adapter):
"""A boundary must not advance past a generation nothing can catch up to.
When the producing stage maintains ``_omni_segment_generation`` on the
request, a boundary may start the next segment's watermark at
``generation + 1`` because the following frame will carry it. On paths
where the request has no such counter, that speculative advance makes every
subsequent frame look stale and starves the stream permanently
(vllm-project/vllm-omni#6816).
"""
adapter, _ = build_adapter(stage_id=1)
request = _req("req-nogen", RequestStatus.WAITING, external_req_id="ext-nogen")
request.resumable = True
request._omni_segment_generation = None
adapter.save_async(multimodal_output=None, request=request, is_segment_finished=True)
assert adapter._segment_generation["ext-nogen"] == 0
follow_up = _req("req-nogen-next", RequestStatus.WAITING, external_req_id="ext-nogen")
follow_up.resumable = True
follow_up.num_computed_tokens = 3
follow_up._omni_segment_generation = None
queued_before = len(adapter._pending_save_reqs)
adapter.save_async(multimodal_output=None, request=follow_up, is_segment_finished=False)
assert len(adapter._pending_save_reqs) == queued_before + 1
assert adapter._pending_save_reqs[-1]["request"] is follow_up
+1 -96
View File
@@ -1,20 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""E2E online tests for MiniCPM-o 4.5 multimodal input and audio/text output.
Exercises async chunk streaming (``--async-chunk``) across separate Thinker,
Talker, and Code2Wav stages.
"""
import json
import os
import tempfile
from copy import deepcopy
from pathlib import Path
import pytest
from vllm.logger import DEFAULT_LOGGING_CONFIG
from tests.helpers.mark import hardware_test
from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video
@@ -27,36 +19,10 @@ _MODEL = "openbmb/MiniCPM-o-4_5"
_CI_DEPLOY = modify_stage_config(
get_deploy_config_path("minicpmo_4_5.yaml"),
updates={
"stages": {
0: {"default_sampling_params.max_tokens": 64},
1: {
"default_sampling_params.max_tokens": 1024,
# Content-consistency assertions must not depend on a random
# codec trajectory: different valid TTS samples can append an
# audible tail even when Thinker text is identical.
"default_sampling_params.temperature": 0.0,
},
},
"stages": {0: {"default_sampling_params.max_tokens": 64}, 1: {"default_sampling_params.max_tokens": 1024}}
},
)
_PROMPT_LOG_TEMP_DIR = tempfile.TemporaryDirectory(prefix="minicpmo45-prompt-selection-")
_PROMPT_SELECTION_LOG = Path(_PROMPT_LOG_TEMP_DIR.name) / "prompt-selection.log"
_PROMPT_LOGGING_CONFIG_PATH = Path(_PROMPT_LOG_TEMP_DIR.name) / "logging.json"
_PROMPT_LOGGING_CONFIG = deepcopy(DEFAULT_LOGGING_CONFIG)
_PROMPT_LOGGING_CONFIG["handlers"]["minicpmo_prompt_selection"] = {
"class": "logging.FileHandler",
"filename": str(_PROMPT_SELECTION_LOG),
"formatter": "vllm",
"level": "DEBUG",
}
_PROMPT_LOGGING_CONFIG["loggers"]["vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_code2wav"] = {
"handlers": ["minicpmo_prompt_selection"],
"level": "DEBUG",
"propagate": True,
}
_PROMPT_LOGGING_CONFIG_PATH.write_text(json.dumps(_PROMPT_LOGGING_CONFIG), encoding="utf-8")
test_params = [
pytest.param(
OmniServerParams(
@@ -64,10 +30,6 @@ test_params = [
stage_config_path=_CI_DEPLOY,
use_stage_cli=False,
server_args=["--trust-remote-code", "--async-chunk"],
env_dict={
"VLLM_CONFIGURE_LOGGING": "1",
"VLLM_LOGGING_CONFIG_PATH": str(_PROMPT_LOGGING_CONFIG_PATH),
},
),
id="async_chunk",
),
@@ -112,18 +74,6 @@ _TTS_EXTRA_BODY = {
}
def _prompt_selection_log_offset() -> int:
return _PROMPT_SELECTION_LOG.stat().st_size if _PROMPT_SELECTION_LOG.exists() else 0
def _read_prompt_selection_log(offset: int) -> str:
if not _PROMPT_SELECTION_LOG.exists():
return ""
with open(_PROMPT_SELECTION_LOG, "rb") as log_file:
log_file.seek(offset)
return log_file.read().decode("utf-8")
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.omni
@@ -176,51 +126,6 @@ def test_text_to_audio_001(omni_server, openai_client) -> None:
openai_client.send_omni_request(request_config)
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_audio_with_reference_audio(omni_server, openai_client) -> None:
request_config = {
"model": omni_server.model,
"messages": dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt()),
"stream": True,
"modalities": ["text", "audio"],
"extra_body": {
**_TTS_EXTRA_BODY,
"ref_audio": f"data:audio/wav;base64,{generate_synthetic_audio(5, 1)['base64']}",
},
}
log_offset = _prompt_selection_log_offset()
responses = openai_client.send_omni_request(request_config)
prompt_log = _read_prompt_selection_log(log_offset)
assert responses[0].success
assert responses[0].audio_bytes
assert "prompt_cache_id=runtime-ref-" in prompt_log
assert "minicpmo45_ref_" in prompt_log
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_audio_with_default_reference(omni_server, openai_client) -> None:
request_config = {
"model": omni_server.model,
"messages": dummy_messages_from_mix_data(system_prompt=get_system_prompt(), content_text=get_prompt()),
"stream": True,
"modalities": ["text", "audio"],
"extra_body": _TTS_EXTRA_BODY,
}
responses = openai_client.send_omni_request(request_config)
assert responses[0].success
assert responses[0].audio_bytes
@pytest.mark.full_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@@ -18,17 +18,6 @@ from tests.helpers.stage_config import get_deploy_config_path
MODEL = os.environ.get("VLLM_TEST_NEMOTRON_VOICECHAT_MODEL", "nvidia/NVIDIA-NemotronLabs-VoiceChat-11B")
DEPLOY_CONFIG = get_deploy_config_path("nemotron_labs_voicechat_duplex.yaml")
TOKENIZER = os.environ.get("VLLM_TEST_NEMOTRON_VOICECHAT_LLM_PATH")
# The client gives up after CLIENT_TIMEOUT_S. The server-side stage-input
# deadline must fire first, otherwise a stalled stream surfaces as a bare
# client TimeoutError with no server-side error at all. See #6816.
CLIENT_TIMEOUT_S = 300
SERVER_INPUT_WAIT_TIMEOUT_S = 240
_SERVER_ENV: dict[str, str] = {"VLLM_OMNI_INPUT_WAIT_TIMEOUT_S": str(SERVER_INPUT_WAIT_TIMEOUT_S)}
if TOKENIZER:
_SERVER_ENV["NEMOTRON_VOICECHAT_LLM_PATH"] = TOKENIZER
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
@@ -40,7 +29,7 @@ pytestmark = [pytest.mark.full_model, pytest.mark.omni]
OmniServerParams(
model=MODEL,
stage_config_path=DEPLOY_CONFIG,
env_dict=_SERVER_ENV,
env_dict={"NEMOTRON_VOICECHAT_LLM_PATH": TOKENIZER} if TOKENIZER else None,
),
id="native-duplex",
)
@@ -56,7 +45,7 @@ def test_native_duplex_turn_taking_streams_model_audio(omni_server, tmp_path: Pa
(
f"--url ws://{omni_server.host}:{omni_server.port}/v1/realtime --model {omni_server.model} "
f"--input-wav {root / 'turn_taking.wav'} --input-channel 0 --max-frames 190 "
f"--minimum-audio-chunks 48 --minimum-audio-rms 0.001 --no-realtime --timeout-s {CLIENT_TIMEOUT_S} "
f"--minimum-audio-chunks 48 --minimum-audio-rms 0.001 --no-realtime --timeout-s 300 "
f"--output-dir {tmp_path / 'native_duplex'}"
).split()
)
@@ -55,6 +55,7 @@ tts_server_params = [
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
-22
View File
@@ -286,28 +286,6 @@ def test_qwen3_tts_code2wav_injects_max_position_embeddings(monkeypatch):
}
def test_remote_tokenizer_subfolder_download_does_not_report_failure(tmp_path, monkeypatch, mocker):
"""A successful remote tokenizer download must not enter the error path."""
tokenizer_dir = tmp_path / "CosyVoice-BlankEN"
tokenizer_dir.mkdir()
baseline_config = Mock()
warning = mocker.patch("vllm_omni.engine.arg_utils.logger.warning")
monkeypatch.setattr("huggingface_hub.snapshot_download", lambda *args, **kwargs: str(tmp_path))
monkeypatch.setattr(OmniEngineArgs, "_patch_empty_hf_config", lambda *args, **kwargs: None)
monkeypatch.setattr(EngineArgs, "create_model_config", lambda _self: baseline_config)
monkeypatch.setattr(
OmniModelConfig,
"from_vllm_model_config",
classmethod(lambda cls, model_config, **omni_kwargs: model_config),
)
args = OmniEngineArgs(model="remote/cosyvoice3", model_arch="CosyVoice3Model")
assert args.create_model_config() is baseline_config
assert args.tokenizer == str(tokenizer_dir)
warning.assert_not_called()
def test_patch_missing_local_hf_config(tmp_path):
"""Native model bundles may only contain a checkpoints/ directory."""
(tmp_path / "checkpoints").mkdir()
@@ -399,20 +399,12 @@ def test_initialize_local_diffusion_replica_restores_device_visibility_after_loc
os.environ[env_var] = old_env
@pytest.mark.parametrize(
("num_stages", "expected_inline"),
[(1, True), (2, False)],
)
def test_initialize_local_diffusion_replica_passes_stage_init_timeout_and_inline_flag(
monkeypatch,
num_stages,
expected_inline,
):
def test_initialize_local_diffusion_replica_passes_stage_init_timeout_and_inline_flag(monkeypatch):
import vllm_omni.engine.stage_runtime as runtime_mod
from vllm_omni.engine.stage_engine_startup import StageReplicaResources
runtime = StageRuntime(
stage_configs=[types.SimpleNamespace()] * num_stages,
stage_configs=[types.SimpleNamespace()],
model="dummy-model",
config_path="dummy-config",
stage_init_timeout=1,
@@ -442,7 +434,7 @@ def test_initialize_local_diffusion_replica_passes_stage_init_timeout_and_inline
"stage_id": 0,
"stage_init_timeout": 302,
"batch_size": 4,
"use_inline": expected_inline,
"use_inline": False,
"omni_master_server": None,
}
@@ -4425,167 +4425,6 @@ async def test_playback_ack_rejects_response_after_followup_user_commit():
)
@pytest.mark.asyncio
async def test_precommit_zero_audio_ack_reserves_history_for_response_that_finishes_after_commit():
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(FakeEngineClient()),
config_timeout_s=0.1,
idle_timeout_s=1,
)
session = DuplexSession(
session_id="sid-zero-audio-checkpoint",
config=DuplexSessionConfig(playback_commit_policy="ack_only"),
)
session.mark_user_input_activity()
response_id = session.begin_response()
item_id = f"item_{response_id}"
ws = TimedWebSocket()
await handler._handle_playback_ack(
session,
{
"type": "playback.ack",
"response_id": response_id,
"item_id": item_id,
"played_ms": 0,
"committed_ms": 0,
},
ws.send_json,
)
session.commit_native_audio_input(transcript="later user input")
session.append_assistant_text("hello world")
session.mark_audio_sent(1000, text_chars=len("hello world"))
committed_message = session.end_response(commit_text=True, preserve_request=True)
session.register_history_item(item_id, committed_message)
await handler._handle_playback_ack(
session,
{
"type": "playback.ack",
"response_id": response_id,
"item_id": item_id,
"played_ms": 1000,
"committed_ms": 1000,
},
ws.send_json,
)
assert not any(message.get("type") == "error" for message in ws.sent)
assert session.history == (
{"role": "assistant", "content": "hello world"},
{
"role": "user",
"content": [
{
"type": "audio_url",
"audio_url": {"url": "native-duplex:input-audio"},
"transcript": "later user input",
}
],
"transcript": "later user input",
},
)
@pytest.mark.asyncio
async def test_precommit_partial_ack_extends_history_after_response_finishes():
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(FakeEngineClient()),
config_timeout_s=0.1,
idle_timeout_s=1,
)
session = DuplexSession(
session_id="sid-partial-checkpoint",
config=DuplexSessionConfig(playback_commit_policy="ack_only"),
)
session.mark_user_input_activity()
response_id = session.begin_response()
item_id = f"item_{response_id}"
session.append_assistant_text("hello world")
session.mark_audio_sent(1000, text_chars=len("hello world"))
ws = TimedWebSocket()
await handler._handle_playback_ack(
session,
{
"type": "playback.ack",
"response_id": response_id,
"item_id": item_id,
"played_ms": 500,
"committed_ms": 500,
},
ws.send_json,
)
session.commit_native_audio_input(transcript="later user input")
committed_message = session.end_response(commit_text=True, preserve_request=True)
session.register_history_item(item_id, committed_message)
assert session.history[0] == {"role": "assistant", "content": "hello"}
await handler._handle_playback_ack(
session,
{
"type": "playback.ack",
"response_id": response_id,
"item_id": item_id,
"played_ms": 1000,
"committed_ms": 1000,
},
ws.send_json,
)
assert not any(message.get("type") == "error" for message in ws.sent)
assert session.history[0] == {"role": "assistant", "content": "hello world"}
assert session.history[1]["role"] == "user"
@pytest.mark.asyncio
async def test_explicit_truncate_seals_snapshot_against_later_playback_ack():
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(FakeEngineClient()),
config_timeout_s=0.1,
idle_timeout_s=1,
)
session = DuplexSession(
session_id="sid-hard-playback-truncate",
config=DuplexSessionConfig(playback_commit_policy="ack_only"),
)
response_id = session.begin_response()
item_id = f"item_{response_id}"
session.append_assistant_text("hello world")
session.mark_audio_sent(1000, text_chars=len("hello world"))
session.end_response(commit_text=False, preserve_request=True)
ws = TimedWebSocket()
await handler._handle_playback_ack(
session,
{
"type": "playback.ack",
"response_id": response_id,
"item_id": item_id,
"played_ms": 500,
"committed_ms": 500,
"truncate": True,
},
ws.send_json,
)
assert session.history == ({"role": "assistant", "content": "hello"},)
await handler._handle_playback_ack(
session,
{
"type": "playback.ack",
"response_id": response_id,
"item_id": item_id,
"played_ms": 1000,
"committed_ms": 1000,
},
ws.send_json,
)
assert not any(message.get("type") == "error" for message in ws.sent)
assert session.history == ({"role": "assistant", "content": "hello"},)
@pytest.mark.asyncio
async def test_cancel_does_not_append_old_assistant_after_followup_user():
handler = OmniDuplexSessionHandler(
@@ -1,207 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Regression tests for MiniCPM-o 4.5 chat reference audio transport."""
from __future__ import annotations
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import cast
import numpy as np
import pytest
import torch
from pytest_mock import MockerFixture
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.inputs import TokensPrompt
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
from vllm_omni.model_executor.models.minicpmo_4_5.pipeline import MINICPMO45_REFERENCE_AUDIO_KEY
from vllm_omni.model_executor.stage_input_processors.minicpmo_4_5_omni import (
llm2tts,
tts2code2wav_async_chunk,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@dataclass
class _EngineClientStub:
stage_configs: list[dict[str, object]]
@dataclass
class _ModelConfigStub:
allowed_local_media_path: str = ""
allowed_media_domains: list[str] | None = None
@dataclass
class _ThinkerCompletionStub:
token_ids: list[int]
text: str
hidden_states: torch.Tensor
multimodal_output: dict[str, object] = field(default_factory=dict)
@dataclass
class _ThinkerRequestOutputStub:
request_id: str
prompt_token_ids: list[int]
outputs: list[_ThinkerCompletionStub]
multimodal_output: dict[str, object] | None = None
@dataclass
class _Code2WavRequestStub:
external_req_id: str
request_id: str
model_intermediate_buffer: dict[str, object]
@dataclass
class _TransferManagerStub:
request_payload: dict[str, object] = field(default_factory=dict)
@pytest.fixture
def serving_chat() -> OmniOpenAIServingChat:
return object.__new__(OmniOpenAIServingChat)
def _stage_config(model_arch: str) -> dict[str, object]:
return {"engine_args": {"model_arch": model_arch}}
def _chat_request(reference_audio_url: str) -> ChatCompletionRequest:
return ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "target text"}],
ref_audio=reference_audio_url,
)
@pytest.mark.asyncio
async def test_chat_reference_audio_reaches_code2wav_transport(
serving_chat: OmniOpenAIServingChat,
mocker: MockerFixture,
) -> None:
reference_audio_url = "data:audio/wav;base64,AAAA"
reference_waveform = np.array([0.25, -0.5], dtype=np.float32)
serving_chat.engine_client = _EngineClientStub(
[_stage_config("MiniCPMO45OmniForConditionalGeneration")],
)
serving_chat.model_config = _ModelConfigStub()
engine_prompt: TokensPrompt = {"type": "tokens", "prompt_token_ids": [1]}
media_connector_class = mocker.patch(
"vllm_omni.entrypoints.openai.serving_chat.MediaConnector",
)
media_connector = media_connector_class.return_value
media_connector.fetch_audio_async = mocker.AsyncMock(return_value=(reference_waveform, 16000))
await serving_chat._attach_minicpmo45_reference_audio(
engine_prompt,
_chat_request(reference_audio_url),
)
media_connector_class.assert_called_once_with(
media_io_kwargs=None,
allowed_local_media_path="",
allowed_media_domains=None,
)
media_connector.fetch_audio_async.assert_awaited_once_with(reference_audio_url)
preserved_reference = cast(dict[str, object], engine_prompt)[MINICPMO45_REFERENCE_AUDIO_KEY]
assert isinstance(preserved_reference, tuple)
preserved_waveform, preserved_sample_rate = preserved_reference
assert isinstance(preserved_waveform, np.ndarray)
assert isinstance(preserved_sample_rate, int)
np.testing.assert_array_equal(preserved_waveform, reference_waveform)
assert preserved_sample_rate == 16000
thinker_output = _ThinkerRequestOutputStub(
request_id="request-0",
prompt_token_ids=[10],
outputs=[
_ThinkerCompletionStub(
token_ids=[20],
text="target text",
hidden_states=torch.zeros((2, 4)),
)
],
)
talker_prompt = llm2tts([thinker_output], prompt=engine_prompt)[0]
talker_request = _Code2WavRequestStub(
external_req_id="request-0",
request_id="request-0",
model_intermediate_buffer=talker_prompt["model_intermediate_buffer"],
)
transfer_manager = _TransferManagerStub()
code2wav_payload = tts2code2wav_async_chunk(
transfer_manager,
{"codes": {"audio": torch.arange(7).reshape(-1, 1)}},
talker_request,
True,
)
assert code2wav_payload is not None
torch.testing.assert_close(code2wav_payload.codes.ref, torch.from_numpy(reference_waveform))
assert code2wav_payload.meta.ref_audio_sr == 16000
@pytest.mark.parametrize("request_shape", ["extra_body", "model_extra"])
@pytest.mark.asyncio
async def test_reference_audio_accepts_request_extra_mappings(
serving_chat: OmniOpenAIServingChat,
mocker: MockerFixture,
request_shape: str,
) -> None:
reference_audio_url = "data:audio/wav;base64,AAAA"
reference_waveform = np.array([0.25, -0.5], dtype=np.float32)
serving_chat.engine_client = _EngineClientStub(
[_stage_config("MiniCPMO45OmniForConditionalGeneration")],
)
serving_chat.model_config = _ModelConfigStub()
engine_prompt: TokensPrompt = {"type": "tokens", "prompt_token_ids": [1]}
if request_shape == "extra_body":
request = ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "target text"}],
extra_body={"ref_audio": reference_audio_url},
)
else:
request = SimpleNamespace(model_extra={"ref_audio": reference_audio_url})
assert not hasattr(request, "ref_audio")
media_connector = mocker.patch(
"vllm_omni.entrypoints.openai.serving_chat.MediaConnector",
).return_value
media_connector.fetch_audio_async = mocker.AsyncMock(return_value=(reference_waveform, 16000))
await serving_chat._attach_minicpmo45_reference_audio(engine_prompt, request)
media_connector.fetch_audio_async.assert_awaited_once_with(reference_audio_url)
preserved_reference = cast(dict[str, object], engine_prompt)[MINICPMO45_REFERENCE_AUDIO_KEY]
assert isinstance(preserved_reference, tuple)
np.testing.assert_array_equal(preserved_reference[0], reference_waveform)
assert preserved_reference[1] == 16000
@pytest.mark.asyncio
async def test_reference_audio_is_ignored_for_other_models(
serving_chat: OmniOpenAIServingChat,
mocker: MockerFixture,
) -> None:
reference_audio_url = "data:audio/wav;base64,AAAA"
serving_chat.engine_client = _EngineClientStub([_stage_config("SomeOtherOmniForConditionalGeneration")])
engine_prompt: TokensPrompt = {"type": "tokens", "prompt_token_ids": [1]}
media_connector_class = mocker.patch(
"vllm_omni.entrypoints.openai.serving_chat.MediaConnector",
)
await serving_chat._attach_minicpmo45_reference_audio(
engine_prompt,
_chat_request(reference_audio_url),
)
media_connector_class.assert_not_called()
assert MINICPMO45_REFERENCE_AUDIO_KEY not in cast(dict[str, object], engine_prompt)
@@ -1,11 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for OpenAI-compatible video API encoding helpers."""
import base64
import threading
from io import BytesIO
from typing import Any
import av
import httpx
@@ -42,7 +41,7 @@ def _install_http_transport(monkeypatch, handler, client_kwargs):
@pytest.mark.asyncio
async def test_decode_image_url_follows_redirects_when_allowed(monkeypatch):
requested_paths = []
client_kwargs: list[dict[str, object]] = []
client_kwargs = []
def _handler(request):
requested_paths.append(request.url.path)
@@ -64,7 +63,7 @@ async def test_decode_image_url_follows_redirects_when_allowed(monkeypatch):
@pytest.mark.asyncio
async def test_decode_image_url_rejects_redirects_when_disabled(monkeypatch):
requested_paths = []
client_kwargs: list[dict[str, object]] = []
client_kwargs = []
def _handler(request):
requested_paths.append(request.url.path)
@@ -82,7 +81,7 @@ async def test_decode_image_url_rejects_redirects_when_disabled(monkeypatch):
@pytest.mark.asyncio
async def test_decode_image_url_reports_http_status(monkeypatch):
client_kwargs: list[dict[str, object]] = []
client_kwargs = []
def _handler(request):
return httpx.Response(404)
@@ -96,7 +95,7 @@ async def test_decode_image_url_reports_http_status(monkeypatch):
@pytest.mark.asyncio
async def test_decode_image_url_reports_connection_failure(monkeypatch):
client_kwargs: list[dict[str, object]] = []
client_kwargs = []
def _handler(request):
raise httpx.ConnectError("connection refused", request=request)
@@ -142,7 +141,7 @@ def _install_fake_video_mux(monkeypatch, mux_calls):
def test_encode_video_bytes_exports_frames_without_interpolation(monkeypatch):
mux_calls: list[dict[str, Any]] = []
mux_calls = []
_install_fake_video_mux(monkeypatch, mux_calls)
frames = [np.full((2, 2, 3), fill_value=i / 5, dtype=np.float32) for i in range(5)]
@@ -223,19 +222,6 @@ def test_channel_first_video_tensor_is_converted_to_channel_last():
np.testing.assert_array_equal(frames, video.permute(1, 2, 3, 0).numpy())
def test_contiguous_uint8_video_reaches_mux_without_conversion(monkeypatch):
video = np.arange(2 * 4 * 5 * 3, dtype=np.uint8).reshape(2, 4, 5, 3)
def fail_prepare(*args, **kwargs):
raise AssertionError("ready-to-encode uint8 video must not be normalized")
monkeypatch.setattr(video_api_utils, "_prepare_video_frames", fail_prepare)
frames = video_api_utils._coerce_video_to_uint8_frames(video)
assert frames is video
def test_channel_first_video_tensor_uses_direct_planar_mux(monkeypatch):
calls = []
@@ -520,32 +506,6 @@ def test_interleaved_video_uses_legacy_fallback_automatically(monkeypatch):
assert len(coerce_calls) == 1
def test_uint8_frames_reach_the_muxer_without_a_float_round_trip():
"""uint8 is the muxer's own dtype, so normalisation must leave it alone.
MiniMax-H3 quantises on the accelerator to keep the response small across
its two process hops. Re-normalising to float here would undo that at the
last step and cost a full-size conversion in each direction.
"""
video = np.arange(2 * 4 * 5 * 3, dtype=np.uint8).reshape(2, 4, 5, 3)
frames, frame_shape, common_dtype = video_api_utils._prepare_video_frames(video)
assert common_dtype == np.dtype(np.uint8)
assert frame_shape == (4, 5, 3)
np.testing.assert_array_equal(np.stack(frames), video)
def test_non_uint8_integer_frames_are_still_normalised():
"""Only uint8 is the muxer's dtype; other integer payloads still scale."""
video = np.full((2, 4, 5, 3), 255, dtype=np.int32)
frames, _, common_dtype = video_api_utils._prepare_video_frames(video)
assert np.issubdtype(common_dtype, np.floating)
np.testing.assert_allclose(frames[0], 1.0)
@pytest.mark.parametrize(
"video",
[
@@ -578,10 +538,9 @@ def test_prepared_automatic_fallback_preserves_legacy_quantization(monkeypatch,
monkeypatch.setattr(media_utils, "mux_video_audio_bytes", fake_compat_mux)
prepared_frames, frame_shape, _ = video_api_utils._prepare_video_frames(video)
stacked = np.stack([frame[..., :3] for frame in prepared_frames])
# uint8 frames are the muxer's own dtype and reach it unchanged; everything
# else is still quantised out of the normalised [0, 1] range.
reference = stacked if stacked.dtype == np.uint8 else np.rint(np.clip(stacked, 0.0, 1.0) * 255.0).astype(np.uint8)
reference = np.rint(np.clip(np.stack([frame[..., :3] for frame in prepared_frames]), 0.0, 1.0) * 255.0).astype(
np.uint8
)
assert video_api_utils._encode_video_bytes(video, fps=12) == b"legacy-video"
assert len(mux_inputs) == 1
@@ -590,44 +549,6 @@ def test_prepared_automatic_fallback_preserves_legacy_quantization(monkeypatch,
np.testing.assert_array_equal(mux_inputs[0], reference)
@pytest.mark.parametrize(
("worker_count", "expected_path", "expected_reason"),
[
(None, "legacy_fallback", "non_contiguous_rgb_planes"),
(1, "legacy_fallback", "non_contiguous_rgb_planes"),
(8, "direct_planar", None),
],
)
def test_interleaved_video_routing(monkeypatch, worker_count, expected_path, expected_reason):
routes = []
def fake_planar_mux(*args, **kwargs):
return b"planar-video"
def fake_compat_mux(frames, audio, **kwargs):
return b"legacy-video"
monkeypatch.setattr(media_utils, "mux_av_video_audio_bytes", fake_planar_mux)
monkeypatch.setattr(media_utils, "mux_video_audio_bytes", fake_compat_mux)
monkeypatch.setattr(video_api_utils, "_log_video_encoding_path", lambda **kwargs: routes.append(kwargs))
converter = None if worker_count is None else video_api_utils._PlanarFrameConverter(max_workers=worker_count)
try:
encoded = video_api_utils._encode_video_bytes(
np.zeros((2, 4, 6, 3), dtype=np.float32),
fps=12,
frame_converter=converter,
)
finally:
if converter is not None:
converter.shutdown()
assert encoded == (b"planar-video" if expected_path == "direct_planar" else b"legacy-video")
assert len(routes) == 1
assert routes[0]["selected_path"] == expected_path
assert routes[0].get("reason") == expected_reason
@pytest.mark.parametrize(
("video", "reason"),
[
@@ -769,8 +690,7 @@ def test_planar_bool_frames_match_bounded_compatible_output():
)
def test_planar_frame_rejects_undersized_plane(monkeypatch, plane_height, line_size):
class FakePlane:
height: int
line_size: int
pass
plane = FakePlane()
plane.height = plane_height
@@ -838,32 +758,6 @@ def test_direct_and_compatible_paths_produce_equivalent_mp4(with_audio):
np.testing.assert_array_equal(direct_frames, compatible_frames)
@pytest.mark.parametrize("with_audio", [False, True])
def test_parallel_interleaved_and_legacy_paths_produce_equivalent_mp4(with_audio):
rng = np.random.default_rng(7)
video = rng.random((3, 32, 32, 3), dtype=np.float32)
audio = np.zeros((2, 2400), dtype=np.float32) if with_audio else None
kwargs = {
"fps": 12,
"audio": audio,
"audio_sample_rate": 24000,
"video_codec_options": {"preset": "ultrafast", "threads": "1"},
}
converter = video_api_utils._PlanarFrameConverter(max_workers=8)
try:
parallel_bytes = video_api_utils._encode_video_bytes(
video,
**kwargs,
frame_converter=converter,
)
finally:
converter.shutdown()
legacy_bytes = video_api_utils._encode_video_bytes_legacy(video, **kwargs)
assert parallel_bytes == legacy_bytes
def test_mux_closes_container_and_preserves_generator_error(monkeypatch):
class GeneratorError(RuntimeError):
pass
@@ -1,7 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio
import base64
import importlib.util
import sys
@@ -173,29 +172,6 @@ def test_open_streaming_response_requires_post_commit_drain():
)
def test_final_input_commit_checkpoints_playback_first():
demo = _load_demo_module()
client = demo.RealtimeDuplexClient("ws://unused")
client.events.add(
{
"type": "response.created",
"response": {"id": "resp-zero-audio"},
}
)
calls = []
async def send(event):
calls.append(event)
client.send = send
commit_sent_at_s = asyncio.run(demo._commit_input_after_playback_checkpoint(client))
assert [event["type"] for event in calls] == ["playback.ack", "input_audio_buffer.commit"]
assert calls[0]["response_id"] == "resp-zero-audio"
assert calls[0]["played_ms"] == 0
assert isinstance(commit_sent_at_s, float)
def _asymmetric_image():
from PIL import Image
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for the MiniCPM-o 4.5 stage 0 -> stage 1 bridge.
Covers ``vllm_omni.model_executor.stage_input_processors.minicpmo_4_5_omni.llm2tts``:
@@ -25,7 +25,6 @@ import torch
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni import (
MiniCPMO45OmniForConditionalGeneration,
)
from vllm_omni.model_executor.models.minicpmo_4_5.pipeline import MINICPMO45_REFERENCE_AUDIO_KEY
from vllm_omni.model_executor.stage_input_processors.minicpmo_4_5_omni import llm2tts
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -314,44 +313,6 @@ class TestPromptAndMultiModal:
)
assert out[0]["multi_modal_data"] == mm
def test_reference_audio_is_added_to_stage_handoff(self) -> None:
hidden_states = torch.zeros((2, _HIDDEN_DIM))
waveform = torch.tensor([0.25, -0.5], dtype=torch.float32)
stage_outputs = llm2tts(
[_make_thinker_output(prompt_token_ids=[10], output_token_ids=[20], hidden_states=hidden_states)],
prompt={"multi_modal_data": {"audio": (waveform, 16000)}},
)
intermediate_buffer = stage_outputs[0]["model_intermediate_buffer"]
assert intermediate_buffer["codes"]["ref"] == [0.25, -0.5]
assert intermediate_buffer["meta"]["ref_audio_sr"] == 16000
def test_serving_reference_audio_is_added_to_stage_handoff(self) -> None:
hidden_states = torch.zeros((2, _HIDDEN_DIM))
waveform = torch.tensor([0.125, -0.25], dtype=torch.float32)
stage_outputs = llm2tts(
[_make_thinker_output(prompt_token_ids=[10], output_token_ids=[20], hidden_states=hidden_states)],
prompt={MINICPMO45_REFERENCE_AUDIO_KEY: (waveform, 24000)},
)
intermediate_buffer = stage_outputs[0]["model_intermediate_buffer"]
assert intermediate_buffer["codes"]["ref"] == [0.125, -0.25]
assert intermediate_buffer["meta"]["ref_audio_sr"] == 24000
def test_missing_reference_audio_does_not_add_stage_handoff_fields(self) -> None:
hidden_states = torch.zeros((2, _HIDDEN_DIM))
stage_outputs = llm2tts(
[_make_thinker_output(prompt_token_ids=[10], output_token_ids=[20], hidden_states=hidden_states)],
prompt={"multi_modal_data": {}},
)
intermediate_buffer = stage_outputs[0]["model_intermediate_buffer"]
assert "codes" not in intermediate_buffer
assert "ref_audio_sr" not in intermediate_buffer.get("meta", {})
def test_internal_streaming_context_is_accepted(self) -> None:
hidden = torch.zeros((2, _HIDDEN_DIM))
out = llm2tts(
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections import defaultdict
from types import SimpleNamespace
@@ -168,14 +168,10 @@ def test_duplex_turn_end_waits_for_terminal_codec_flush() -> None:
def test_first_chunk_forwards_reference_voice_and_duplex_identity() -> None:
manager = _manager()
request = _request("req")
request.model_intermediate_buffer = {
request.additional_information = {
"codes": {"ref": [0.1, -0.1]},
"meta": {"ref_audio_sr": 16000},
}
request.additional_information = {
"codes": {"ref": [0.9]},
"meta": {"ref_audio_sr": 8000},
}
payload = tts2code2wav_async_chunk(
manager,
@@ -197,33 +193,10 @@ def test_first_chunk_forwards_reference_voice_and_duplex_identity() -> None:
assert payload.meta.turn_end is True
def test_first_chunk_falls_back_to_legacy_reference_fields() -> None:
manager = _manager()
request = _request("req")
request.model_intermediate_buffer = {
"meta": {"ref_audio_sr": 16000},
}
request.additional_information = {
"codes": {"ref": [0.9]},
"meta": {"ref_audio_sr": 8000},
}
payload = tts2code2wav_async_chunk(
manager,
_duplex_delta(*range(7), turn_end=True),
request,
True,
)
assert payload is not None
assert payload.codes.ref.tolist() == pytest.approx([0.9])
assert payload.meta.ref_audio_sr == 16000
def test_full_payload_forwards_all_codes_and_request_metadata() -> None:
manager = _manager()
request = _request("req")
request.model_intermediate_buffer = {
request.additional_information = {
"codes": {"ref": [0.1, -0.1]},
"meta": {
"ref_audio_sr": 16000,
@@ -1,64 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import importlib.util
from types import SimpleNamespace
import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.mark.parametrize("available", [False, True])
def test_dense_flash_capability_tracks_mindiesd(monkeypatch, available):
pytest.importorskip("vllm_ascend")
from vllm_omni.platforms.npu.platform import NPUOmniPlatform
monkeypatch.setattr(
importlib.util,
"find_spec",
lambda name: object() if name == "mindiesd" and available else None,
)
assert NPUOmniPlatform.supports_diffusion_dense_flash_attention() is available
def test_paged_config_uses_ascend_kernel_block_size() -> None:
pytest.importorskip("vllm_ascend")
from vllm_ascend.attention.attention_v1 import AscendAttentionBackend
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
from vllm_omni.platforms.npu.platform import NPUOmniPlatform
vllm_config = SimpleNamespace(cache_config=SimpleNamespace(block_size=16))
NPUOmniPlatform.configure_diffusion_vllm_config(
vllm_config,
SimpleNamespace(diffusion_kv_mode=DiffusionKVCacheMode.PAGED_SCHEDULER),
)
assert vllm_config.cache_config.block_size == AscendAttentionBackend.get_supported_kernel_block_sizes()[0]
def test_strict_ulysses_paged_backend_bypasses_ascend_pcp() -> None:
pytest.importorskip("vllm_ascend")
from vllm_ascend.attention.attention_v1 import (
AscendAttentionBackend,
AscendAttentionBackendImpl,
AscendAttentionMetadataBuilder,
)
from vllm_omni.platforms.npu.platform import NPUOmniPlatform
assert (
NPUOmniPlatform.get_diffusion_paged_kv_attn_backend(
AscendAttentionBackend,
ulysses_degree=1,
)
is AscendAttentionBackend
)
backend = NPUOmniPlatform.get_diffusion_paged_kv_attn_backend(
AscendAttentionBackend,
ulysses_degree=2,
)
assert backend.get_impl_cls() is AscendAttentionBackendImpl
assert backend.get_builder_cls() is AscendAttentionMetadataBuilder
+6 -8
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for OmniPlatform.record_device_event implementations."""
import pytest
@@ -122,8 +122,8 @@ class TestNPUOmniPlatformRecordDeviceEvent:
result = NPUOmniPlatform.record_device_event()
assert result is None
def test_synchronizes_stream_then_records_generic_event(self, mocker):
"""NPU should return an event consumable by the generic torch.Stream."""
def test_synchronizes_stream_then_records_event(self, mocker):
"""NPU should sync stream first, then create and record an Event."""
try:
from vllm_omni.platforms.npu.platform import NPUOmniPlatform
except ModuleNotFoundError:
@@ -133,16 +133,14 @@ class TestNPUOmniPlatformRecordDeviceEvent:
mock_stream = mocker.MagicMock()
mock_torch = mocker.MagicMock()
mock_torch.npu.current_stream.return_value = mock_stream
mock_torch.Event.return_value = mock_event
mock_torch.npu.Event.return_value = mock_event
mocker.patch("vllm_omni.platforms.npu.platform.torch", mock_torch)
result = NPUOmniPlatform.record_device_event()
# Stream should be synced first (HCCL ordering)
mock_stream.synchronize.assert_called_once()
# Then a backend-neutral event should be created and recorded. The
# worker waits on it through the public torch.Stream wrapper.
mock_torch.Event.assert_called_once()
mock_torch.npu.Event.assert_not_called()
# Then event should be created and recorded
mock_torch.npu.Event.assert_called_once()
mock_event.record.assert_called_once()
assert result is mock_event
-46
View File
@@ -119,17 +119,6 @@ def should_request_stage_metrics(args: Any) -> bool:
if backend in _IMAGE_STAGE_METRICS_BACKENDS:
return True
percentile_metrics = getattr(args, "percentile_metrics", ()) or ()
if isinstance(percentile_metrics, str):
percentile_metrics = percentile_metrics.split(",")
selected_metrics = {str(metric).strip().lower() for metric in percentile_metrics}
if backend == "openai-chat-omni" and selected_metrics.intersection({"tpot", "itl"}):
# Client receive timestamps normally provide these metrics. Under
# enough load, however, every text token can be coalesced into one
# event-loop read, leaving no positive interval. Request the engine's
# Stage 0 timings so that path still has an authoritative measurement.
return True
extra_body = getattr(args, "extra_body", None) or {}
modalities = extra_body.get("modalities") if isinstance(extra_body, dict) else None
return backend == "openai-chat-omni" and "image" in (modalities or [])
@@ -865,33 +854,6 @@ def _update_output_stage_metrics_from_payload(
output.stage_metrics.update(stage_snapshot)
def _apply_chat_stage0_token_timings(output: MixRequestFuncOutput) -> bool:
"""Apply native Stage 0 timings from a chat response snapshot."""
stage_metrics = output.stage_metrics
if not isinstance(stage_metrics, dict):
return False
stage0 = stage_metrics.get("0")
if not isinstance(stage0, dict):
return False
output_tokens = coerce_positive_int_scalar(stage0.get(defs.NUM_TOKENS_OUT))
expected_output_tokens = coerce_positive_int_scalar(output.output_tokens)
if output_tokens is None or expected_output_tokens is None or output_tokens != expected_output_tokens:
return False
return _apply_stage0_token_timings(
output,
[
{
"output_token_count": output_tokens,
"itls_ms": stage0.get(defs.VLLM_ITLS_MS),
"tpot_ms": stage0.get(defs.VLLM_TPOT_MS),
}
],
expected_output_tokens=expected_output_tokens,
)
def _image_metrics_from_stage_metrics(metrics: dict[str, Any] | None) -> tuple[int, float, int, float]:
if not isinstance(metrics, dict):
return 0, 0.0, 0, 0.0
@@ -1159,14 +1121,6 @@ async def async_request_openai_chat_omni_completions(
output.latency = timestamp - st
output.generated_text = generated_text
if output.output_tokens > 1 and not any(
isinstance(value, int | float)
and not isinstance(value, bool)
and np.isfinite(value)
and value > 0
for value in output.itl
):
_apply_chat_stage0_token_timings(output)
if output.itl:
# Align text_latency with ITL so TPOT formula and
# mean(ITL) are consistent. Do NOT infer output_tokens
-5
View File
@@ -327,11 +327,6 @@ class PipelineConfig:
errors.append(f"Stage {stage.stage_id} references itself")
if not any(not s.input_sources for s in self.stages):
errors.append("No entry point (stage with empty input_sources)")
# Request completion and client-visible output are both driven by stages
# that declare ``final_output`` (see ``Orchestrator._route_output``). A
# pipeline without one can never emit a result, so every request hangs.
if not any(s.final_output for s in self.stages):
errors.append("No terminal stage (stage with final_output=True)")
return errors
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import os
from functools import partial
@@ -7,7 +7,11 @@ from functools import partial
import torch
from vllm.logger import init_logger
from vllm_omni.diffusion.attention.backends.abstract import AttentionBackend, AttentionImpl, AttentionMetadata
from vllm_omni.diffusion.attention.backends.abstract import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
)
from vllm_omni.diffusion.attention.backends.sdpa import _maybe_reshape_attn_mask
from vllm_omni.diffusion.attention.backends.utils.piecewise_attn import (
piecewise_attn,
@@ -281,9 +285,7 @@ class FlashAttentionImpl(AttentionImpl):
rank-local native cache context. The adapter no longer performs the
attention call itself; this method is the backend-owned execution
boundary. Its native layer wrapper keeps vLLM version-specific cache
and kernel details out of Omni's common ``Attention`` layer. CUDA uses
the native vLLM FlashAttention writer/kernel contract. Ascend writes
the complete K/V span once before its piecewise FIA calls.
and kernel details out of Omni's common ``Attention`` layer.
"""
layer = getattr(paged_kv_context, "layer", None)
@@ -295,18 +297,8 @@ class FlashAttentionImpl(AttentionImpl):
native_impl = getattr(layer, "impl", None)
if native_impl is None:
raise RuntimeError(f"Native attention implementation is not bound for diffusion layer {layer.layer_name!r}")
prewrite_kv = current_omni_platform.requires_diffusion_paged_kv_prewrite()
read_kv_from_cache = prewrite_kv and layer.attn_backend.forward_includes_kv_cache_update
# The GPU/default path preserves native cache-update ownership. Ascend
# prewrites through vLLM-Ascend's normal-layout writer so piecewise FIA
# segments do not repeatedly scatter the same layer K/V.
if not layer.attn_backend.forward_includes_kv_cache_update or prewrite_kv:
cache_update = getattr(native_impl, "do_kv_cache_update", None)
if not callable(cache_update):
raise RuntimeError(
f"Native attention implementation for {layer.layer_name!r} cannot update the KV cache"
)
cache_update(
if not layer.attn_backend.forward_includes_kv_cache_update:
native_impl.do_kv_cache_update(
layer,
paged_kv_context.key_write,
paged_kv_context.value_write,
@@ -316,52 +308,33 @@ class FlashAttentionImpl(AttentionImpl):
def run_native_attention(
query: torch.Tensor,
key: torch.Tensor | None,
value: torch.Tensor | None,
key: torch.Tensor,
value: torch.Tensor,
native_metadata,
output: torch.Tensor | None,
) -> torch.Tensor:
if output is None:
output = torch.empty(
(query.shape[0], layer.num_heads, layer.head_size_v),
dtype=query.dtype,
device=query.device,
)
output = torch.empty(
(query.shape[0], layer.num_heads, layer.head_size_v),
dtype=query.dtype,
device=query.device,
)
return native_impl.forward(
layer,
query,
None if read_kv_from_cache else key,
None if read_kv_from_cache else value,
key,
value,
kv_cache,
native_metadata,
output,
)
if paged_kv_context.piecewise_plan is not None:
# Ascend FIA can execute identical CFG rows as one batched call
# per piece. Keep that batch layout through the piecewise runner
# so it can concatenate row-major results instead of emitting an
# indexed ScatterUpdate for every segment. The CUDA path keeps
# its existing output-buffer contract (including graph capture).
use_homogeneous_batch = (
current_omni_platform.is_npu() and paged_kv_context.piecewise_plan.homogeneous_batch_shape is not None
)
output = None
if not use_homogeneous_batch:
output = torch.empty(
(paged_kv_context.query.shape[0], layer.num_heads, layer.head_size_v),
dtype=paged_kv_context.query.dtype,
device=paged_kv_context.query.device,
)
output = run_paged_piecewise_plan(
paged_kv_context.query,
None if read_kv_from_cache else paged_kv_context.key_write,
None if read_kv_from_cache else paged_kv_context.value_write,
paged_kv_context.key_write,
paged_kv_context.value_write,
paged_kv_context.piecewise_plan,
paged_kv_context.piecewise_native_metadata,
run_native_attention,
output_buffer=output,
use_homogeneous_batch=use_homogeneous_batch,
)
else:
output = run_native_attention(
@@ -369,7 +342,6 @@ class FlashAttentionImpl(AttentionImpl):
paged_kv_context.key_write,
paged_kv_context.value_write,
paged_kv_context.native_metadata,
None,
)
return paged_kv_context.restore_output(output)
@@ -544,8 +516,15 @@ class FlashAttentionImpl(AttentionImpl):
value: torch.Tensor,
attn_metadata: AttentionMetadata = None,
) -> torch.Tensor:
from mindiesd import attention_forward
try:
from mindiesd import attention_forward
except ImportError:
raise ImportError(
"FlashAttentionBackend NPU implementation requires MindIE-SD. "
"Please install MindIE-SD to enable NPU attention support. "
"For installation details, see https://gitcode.com/Ascend/MindIE-SD"
"Otherwise, use SDPA backend by setting DIFFUSION_ATTENTION_BACKEND=TORCH_SDPA"
)
# Opt-in mask-free paths (mirror the CUDA cu_seqlens behavior): the
# model marks extra["npu_attn_varlen"] and carries packed metadata, so
# the padding document is excluded without reading or materializing
@@ -654,8 +633,15 @@ class FlashAttentionImpl(AttentionImpl):
return None
seq_q, seq_k = resolved
from mindiesd import attention_forward_varlen
try:
from mindiesd import attention_forward_varlen
except ImportError:
raise ImportError(
"FlashAttentionBackend NPU implementation requires MindIE-SD. "
"Please install MindIE-SD to enable NPU attention support. "
"For installation details, see https://gitcode.com/Ascend/MindIE-SD"
"Otherwise, use SDPA backend by setting DIFFUSION_ATTENTION_BACKEND=TORCH_SDPA"
)
q = query.squeeze(0) # [T, N, D] == TND
k = key.squeeze(0)
v = value.squeeze(0)
@@ -703,8 +689,15 @@ class FlashAttentionImpl(AttentionImpl):
_, seq_k = resolved
used_k = seq_k[0] # real document length (first cumulative end)
from mindiesd import attention_forward
try:
from mindiesd import attention_forward
except ImportError:
raise ImportError(
"FlashAttentionBackend NPU implementation requires MindIE-SD. "
"Please install MindIE-SD to enable NPU attention support. "
"For installation details, see https://gitcode.com/Ascend/MindIE-SD"
"Otherwise, use SDPA backend by setting DIFFUSION_ATTENTION_BACKEND=TORCH_SDPA"
)
# mindiesd always takes BSND input regardless of `layout`; the arg
# selects the op-internal layout and laser only supports BNSD, so do
# NOT forward the model's qkv_layout ("BSND" for MiniMax-H3) here.
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Piecewise attention for mixed causal / full (bidirectional) masks.
@@ -92,7 +92,6 @@ def piecewise_attn(
):
_check_homogeneous(full_attn_spans)
spans = full_attn_spans[0]
ranges: tuple[tuple[int, int, int], ...]
if query_ranges is None:
query_len = query.shape[1]
ranges = ((0, query_len, key.shape[1] - query_len),)
@@ -134,11 +133,6 @@ class PagedPiecewiseSegment:
row_segments: tuple[Segment, ...]
query_indices: torch.Tensor
query_range: tuple[int, int] | None
# Local [start, end) range shared by every row when the batch can retain
# its original [B, S] layout. The packed query_indices remain available
# for metadata construction and the heterogeneous fallback.
local_query_range: tuple[int, int] | None = None
@dataclass(frozen=True, slots=True)
@@ -148,16 +142,10 @@ class PagedPiecewisePlan:
spans: tuple[tuple[tuple[int, int], ...], ...]
segments: tuple[PagedPiecewiseSegment, ...]
num_query_tokens: int
segments_cover_query_contiguously: bool
# (batch_size, query_len) when all rows have the same local segment
# layout. Native FIA still receives flattened row-major tokens, but the
# runner can restore outputs with one batch concat instead of one indexed
# scatter per segment.
homogeneous_batch_shape: tuple[int, int] | None = None
PagedPiecewiseRunner = Callable[
[torch.Tensor, torch.Tensor | None, torch.Tensor | None, object, torch.Tensor | None],
[torch.Tensor, torch.Tensor, torch.Tensor, object],
torch.Tensor,
]
@@ -176,9 +164,7 @@ def build_paged_piecewise_plan(
if row_count == 0 or not (row_count == len(query_offsets) == len(query_lens) == len(seq_lens)):
raise ValueError("Paged piecewise inputs must have one entry per row")
spans: tuple[tuple[tuple[int, int], ...], ...] = tuple(
tuple((span_start, span_end) for span_start, span_end in row_spans) for row_spans in full_attn_spans
)
spans = tuple(tuple(tuple(span) for span in row_spans) for row_spans in full_attn_spans)
packed_offsets = [0]
for row_spans, query_offset, query_len, seq_len in zip(spans, query_offsets, query_lens, seq_lens, strict=True):
@@ -202,206 +188,46 @@ def build_paged_piecewise_plan(
for row_segments in zip(*segments_by_row, strict=True):
if len({segment.mode for segment in row_segments}) != 1:
raise ValueError("Paged piecewise rows must use the same attention mode per segment")
query_indices: list[int] = []
local_ranges: list[tuple[int, int]] = []
query_indices = []
for row_index, (segment, query_offset) in enumerate(zip(row_segments, query_offsets, strict=True)):
local_start = segment.q_start - query_offset
local_end = segment.q_end - query_offset
local_ranges.append((local_start, local_end))
query_indices.extend(range(packed_offsets[row_index] + local_start, packed_offsets[row_index] + local_end))
query_range = None
if query_indices and query_indices == list(range(query_indices[0], query_indices[-1] + 1)):
query_range = (query_indices[0], query_indices[-1] + 1)
local_query_range = local_ranges[0] if len(set(local_ranges)) == 1 else None
packed_segments.append(
PagedPiecewiseSegment(
row_segments=tuple(row_segments),
query_indices=torch.tensor(query_indices, dtype=torch.long, device=device),
query_range=query_range,
local_query_range=local_query_range,
)
)
covered = 0
for segment in packed_segments:
if segment.query_range is None or segment.query_range[0] != covered:
break
covered = segment.query_range[1]
segments_cover_query_contiguously = covered == packed_offsets[-1]
homogeneous_batch_shape: tuple[int, int] | None = None
if (
row_count > 1
and len(set(query_lens)) == 1
and all(segment.local_query_range is not None for segment in packed_segments)
):
# A common local range is sufficient even when rows have different
# global offsets or KV lengths; those values remain row-specific in
# native metadata.
homogeneous_batch_shape = (row_count, int(query_lens[0]))
return PagedPiecewisePlan(
spans=spans,
segments=tuple(packed_segments),
num_query_tokens=packed_offsets[-1],
segments_cover_query_contiguously=segments_cover_query_contiguously,
homogeneous_batch_shape=homogeneous_batch_shape,
)
def _run_homogeneous_paged_piecewise_plan(
query: torch.Tensor,
key: torch.Tensor | None,
value: torch.Tensor | None,
plan: PagedPiecewisePlan,
segment_metadata: Sequence[object],
segment_runner: PagedPiecewiseRunner,
output_buffer: torch.Tensor | None,
) -> torch.Tensor:
"""Run a homogeneous batch while retaining the old [B, S] layout.
The native FIA contract is flattened TND, so each segment is flattened
only for the kernel call. Results are reshaped back to [B, L] and
concatenated along the sequence dimension, which avoids the per-segment
``index_copy_`` used by heterogeneous packed rows.
"""
batch_shape = plan.homogeneous_batch_shape
assert batch_shape is not None
batch_size, query_len = batch_shape
if query.shape[0] != batch_size * query_len:
raise ValueError(
f"Homogeneous paged piecewise plan expects {batch_size * query_len} query tokens, got {query.shape[0]}"
)
query_rows = query.reshape(batch_size, query_len, *query.shape[1:])
key_rows = None if key is None else key.reshape(batch_size, query_len, *key.shape[1:])
value_rows = None if value is None else value.reshape(batch_size, query_len, *value.shape[1:])
segment_outputs: list[torch.Tensor] = []
for segment, metadata in zip(plan.segments, segment_metadata, strict=True):
local_range = segment.local_query_range
if local_range is None:
raise ValueError("Homogeneous paged piecewise execution requires a shared local segment range")
start, end = local_range
segment_query = query_rows[:, start:end].contiguous().reshape(-1, *query.shape[1:])
segment_key = None if key_rows is None else key_rows[:, start:end].contiguous().reshape(-1, *key.shape[1:])
segment_value = (
None if value_rows is None else value_rows[:, start:end].contiguous().reshape(-1, *value.shape[1:])
)
segment_output = segment_runner(segment_query, segment_key, segment_value, metadata, None)
expected_tokens = batch_size * (end - start)
if segment_output.shape[0] != expected_tokens:
raise ValueError(
f"Paged piecewise runner returned {segment_output.shape[0]} tokens "
f"for a {expected_tokens}-token homogeneous segment"
)
segment_outputs.append(segment_output.reshape(batch_size, end - start, *segment_output.shape[1:]))
if not segment_outputs:
result = torch.empty_like(query)
elif len(segment_outputs) == 1:
result = segment_outputs[0].reshape(plan.num_query_tokens, *segment_outputs[0].shape[2:])
else:
result_rows = torch.cat(segment_outputs, dim=1)
result = result_rows.reshape(plan.num_query_tokens, *result_rows.shape[2:])
if output_buffer is not None:
if output_buffer.shape != result.shape:
raise ValueError(
f"Paged piecewise output buffer shape {tuple(output_buffer.shape)} does not match "
f"homogeneous result shape {tuple(result.shape)}"
)
output_buffer.copy_(result)
return output_buffer
return result
return PagedPiecewisePlan(spans, tuple(packed_segments), packed_offsets[-1])
def run_paged_piecewise_plan(
query: torch.Tensor,
key: torch.Tensor | None,
value: torch.Tensor | None,
key: torch.Tensor,
value: torch.Tensor,
plan: PagedPiecewisePlan,
segment_metadata: Sequence[object],
segment_runner: PagedPiecewiseRunner,
output_buffer: torch.Tensor | None = None,
*,
use_homogeneous_batch: bool = False,
) -> torch.Tensor:
"""Run piecewise attention with view fast paths for contiguous segments.
``use_homogeneous_batch`` is an opt-in for native backends that can keep
identical rows in the legacy batch layout. The default remains the
indexed packed path so existing GPU/heterogeneous contracts are unchanged.
"""
"""Run and scatter piecewise attention over packed Q/K/V write tensors."""
if query.shape[0] != plan.num_query_tokens:
raise ValueError(f"Paged piecewise plan has {plan.num_query_tokens} query tokens, got {query.shape[0]}")
if output_buffer is not None and output_buffer.shape[0] != plan.num_query_tokens:
raise ValueError(
f"Paged piecewise output buffer has {output_buffer.shape[0]} tokens; expected {plan.num_query_tokens}"
)
if use_homogeneous_batch and plan.homogeneous_batch_shape is not None:
return _run_homogeneous_paged_piecewise_plan(
query,
key,
value,
plan,
segment_metadata,
segment_runner,
output_buffer,
)
output = output_buffer
contiguous_outputs = []
output = None
for segment, metadata in zip(plan.segments, segment_metadata, strict=True):
indices = segment.query_indices
segment_output_buffer = None
if segment.query_range is None:
segment_query = query.index_select(0, indices)
segment_key = None if key is None else key.index_select(0, indices)
segment_value = None if value is None else value.index_select(0, indices)
else:
start, end = segment.query_range
segment_query = query[start:end]
segment_key = None if key is None else key[start:end]
segment_value = None if value is None else value[start:end]
if output is not None:
segment_output_buffer = output[start:end]
segment_output = segment_runner(
segment_query,
segment_key,
segment_value,
query.index_select(0, indices),
key.index_select(0, indices),
value.index_select(0, indices),
metadata,
segment_output_buffer,
)
if segment_output.shape[0] != indices.shape[0]:
raise ValueError(
f"Paged piecewise runner returned {segment_output.shape[0]} tokens "
f"for a {indices.shape[0]}-token segment"
)
if segment_output_buffer is not None and segment_output is not segment_output_buffer:
if segment_output.shape != segment_output_buffer.shape:
raise ValueError(
f"Paged piecewise runner returned shape {tuple(segment_output.shape)} "
f"for output buffer shape {tuple(segment_output_buffer.shape)}"
)
segment_output_buffer.copy_(segment_output)
segment_output = segment_output_buffer
if plan.segments_cover_query_contiguously:
if output_buffer is None:
contiguous_outputs.append(segment_output)
continue
if output is None:
output = segment_output.new_empty((query.shape[0], *segment_output.shape[1:]))
if segment.query_range is None:
output.index_copy_(0, indices, segment_output)
else:
start, end = segment.query_range
output[start:end].copy_(segment_output)
output.index_copy_(0, indices, segment_output)
if output_buffer is not None and plan.segments_cover_query_contiguously:
return output_buffer
if contiguous_outputs:
if len(contiguous_outputs) == 1:
return contiguous_outputs[0]
return torch.cat(contiguous_outputs, dim=0)
assert output is not None
return output
+6 -111
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Copyright (c) Microsoft Corporation and Jiarui Fang
# SPDX-License-Identifier: Apache-2.0
# DeepSpeed Team & Jiarui Fang
@@ -23,7 +23,6 @@ from vllm_omni.diffusion.attention.parallel.base import NoParallelAttention
from vllm_omni.diffusion.attention.parallel.ring import RingParallelAttention
from vllm_omni.diffusion.attention.selector import get_attn_backend_for_role
from vllm_omni.diffusion.config import get_current_diffusion_config_or_none
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
from vllm_omni.diffusion.distributed.parallel_state import get_sp_group
from vllm_omni.diffusion.forward_context import (
get_forward_context,
@@ -45,8 +44,6 @@ def _try_extract_layer_index(prefix: str) -> int | None:
class Attention(nn.Module):
_scheduler_paged_kv = False
def __init__(
self,
num_heads: int,
@@ -114,44 +111,6 @@ class Attention(nn.Module):
role_category=role_category,
allow_trtllm_default=allow_trtllm_default,
)
scheduler_paged_kv = (
config is not None
and getattr(config, "diffusion_kv_mode", DiffusionKVCacheMode.DENSE_LEGACY)
is DiffusionKVCacheMode.PAGED_SCHEDULER
)
self._scheduler_paged_kv = scheduler_paged_kv
if (
scheduler_paged_kv
and paged_kv_cache_role is not None
and spec is None
and not attn_backend_cls.supports_paged_kv
):
# FLASH_ATTN is an Omni selector, not a device-specific kernel.
# vLLM resolves it to CUDA FlashAttention on GPU and Ascend native
# attention on NPU. A platform may legitimately default dense
# attention to SDPA (for example when an optional diffusion FA
# package is absent), but a paged layer must advertise the
# capability so the Worker can register its native cache view.
# Resolve the selector only for this marked layer; unmarked dense
# attention keeps the platform default unchanged.
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec
dense_backend_name = attn_backend_cls.get_name()
paged_attention_config = AttentionConfig(default=AttentionSpec(backend="FLASH_ATTN"))
attn_backend_cls, spec = get_attn_backend_for_role(
role=role,
head_size=head_size,
attention_config=paged_attention_config,
role_category=role_category,
allow_trtllm_default=allow_trtllm_default,
)
logger.info(
"Resolved marked paged diffusion attention role=%r to %r because the platform default %r "
"does not support Scheduler-owned KV",
role,
attn_backend_cls.get_name(),
dense_backend_name,
)
parallel_config = getattr(config, "parallel_config", None)
allgather_degree = getattr(parallel_config, "allgather_degree", 1)
# TODO: Move AllGather-KV compatibility into an AttentionBackend capability
@@ -362,17 +321,6 @@ class Attention(nn.Module):
# Get the appropriate parallel strategy based on SP active state
strategy = self._get_active_parallel_strategy()
paged_adapter = self._active_paged_kv_adapter()
in_kv_memory_profile = is_forward_context_available() and get_forward_context().in_diffusion_kv_memory_profile
if (
self._scheduler_paged_kv
and self.paged_kv_cache_role is not None
and paged_adapter is None
and not in_kv_memory_profile
):
raise RuntimeError(
"Scheduler-paged diffusion attention reached model forward without an active Worker adapter. "
"Only the startup KV memory profile may execute before paged KV initialization."
)
use_paged_attention = paged_adapter is not None and self.paged_kv_cache_role is not None
if use_paged_attention and not getattr(self.attn_backend, "supports_paged_kv", False):
raise NotImplementedError(
@@ -395,39 +343,9 @@ class Attention(nn.Module):
# For Ring: Concat joint_q
query, key, value, attn_metadata, ctx = strategy.pre_attention(query, key, value, attn_metadata)
# Scheduler rows describe the logical sequence, while strict Ulysses
# may append synthetic tokens solely to make the image shard divisible.
# Remove those tokens after the all-to-all and put zero placeholders
# back before the reverse all-to-all.
paged_sp_padding = 0
paged_sp_padding_offset = query.shape[1]
if use_paged_attention and strategy is not self._no_parallel_strategy and strategy.name == "ulysses":
forward_ctx = get_forward_context() if is_forward_context_available() else None
paged_sp_padding = int(getattr(forward_ctx, "sp_padding_size", 0))
if paged_sp_padding:
joint_len = int(getattr(ctx, "joint_len", 0))
joint_strategy = str(getattr(ctx, "joint_strategy", "front"))
paged_sp_padding_offset = query.shape[1] - (joint_len if joint_strategy == "rear" else 0)
padding_start = paged_sp_padding_offset - paged_sp_padding
if padding_start < 0:
raise ValueError(
"Paged Ulysses padding exceeds the post-all-to-all sequence: "
f"padding={paged_sp_padding}, sequence={query.shape[1]}"
)
def _remove_paged_sp_padding(tensor: torch.Tensor) -> torch.Tensor:
return torch.cat(
(tensor[:, :padding_start], tensor[:, paged_sp_padding_offset:]),
dim=1,
).contiguous()
query = _remove_paged_sp_padding(query)
key = _remove_paged_sp_padding(key)
value = _remove_paged_sp_padding(value)
# 2. This is the shared GPU/NPU boundary. The Worker adapter prepares
# the native page-table context after SP has produced rank-local Q/K/V;
# backend resolution below selects CUDA or Ascend execution.
# 2. Kernel execution stays inside the selected Omni backend. The
# Worker adapter only prepares the native page-table context after
# SP has produced rank-local Q/K/V tensors.
if use_paged_attention:
assert paged_adapter is not None
paged_kv_context = paged_adapter.prepare_layer_context(
@@ -445,11 +363,6 @@ class Attention(nn.Module):
else:
out = self._run_local_attention(query, key, value, attn_metadata)
if paged_sp_padding:
padding_start = paged_sp_padding_offset - paged_sp_padding
output_padding = out.new_zeros((out.shape[0], paged_sp_padding, *out.shape[2:]))
out = torch.cat((out[:, :padding_start], output_padding, out[:, padding_start:]), dim=1)
# 3. Post-processing (Reverse Communication)
# For Ulysses: AllToAll Output, and AllGather Joint Output
out = strategy.post_attention(out, ctx)
@@ -458,7 +371,7 @@ class Attention(nn.Module):
@staticmethod
def _active_paged_kv_adapter():
"""Return the Worker adapter selected by Runner-owned metadata."""
"""Return the opaque Worker adapter installed for this forward."""
if not is_forward_context_available():
return None
@@ -479,25 +392,7 @@ class Attention(nn.Module):
)
return self.sdpa_fallback.forward(query, key, value, attn_metadata)
in_kv_memory_profile = is_forward_context_available() and get_forward_context().in_diffusion_kv_memory_profile
# NPU only: the startup KV-capacity profile needs tensor shapes, not a
# paged attention result. If dense MindIE-SD is absent, SDPA provides
# that profile forward. Formal GPU/NPU paged requests never use this
# branch because their Worker adapter is active.
if (
self._scheduler_paged_kv
and self.paged_kv_cache_role is not None
and in_kv_memory_profile
and current_omni_platform.is_npu()
and self.attn_backend.get_name() == "FLASH_ATTN"
and not current_omni_platform.supports_diffusion_dense_flash_attention()
):
logger.warning_once(
"The startup KV memory profile is using SDPA because MindIE-SD is unavailable. "
"Formal paged requests still use the platform-native paged attention backend."
)
return self.sdpa_fallback.forward(query, key, value, attn_metadata)
# Fallback to standard attention
return self.attention.forward(query, key, value, attn_metadata)
def _assert_piecewise_compatible(self, attn_metadata: AttentionMetadata | None) -> None:
+3 -2
View File
@@ -1086,9 +1086,10 @@ class OmniDiffusionConfig:
"need_recv_cache", False
):
raise ValueError(
"paged_scheduler Diffusion KV does not support imported AR KV; "
"disable need_recv_cache until connector-aware import is implemented"
"paged_scheduler Diffusion KV does not support imported AR KV in Phase 1; "
"disable need_recv_cache until connector-aware admission is implemented"
)
self.master_port = self._resolve_master_port()
self.request_batch_max_wait_ms = float(self.request_batch_max_wait_ms or 0.0)
if not math.isfinite(self.request_batch_max_wait_ms) or self.request_batch_max_wait_ms < 0:
@@ -1,10 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import copy
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
@@ -22,9 +22,9 @@ from vllm_omni.diffusion.diffusion_kv.metadata import DiffusionKVMetadata
from vllm_omni.diffusion.diffusion_kv.paged_attention_adapter import (
DiffusionPagedAttentionAdapter,
DiffusionPagedAttentionLayerAdapter,
DiffusionPagedAttentionMetadata,
DiffusionPagedAttentionRow,
DiffusionPagedAttentionRowBinding,
DiffusionPagedAttentionRuntime,
PreparedDiffusionPagedAttentionBatch,
)
from vllm_omni.platforms import current_omni_platform
@@ -32,6 +32,13 @@ DiffusionKVIdentity = tuple[str, int | None, str | None]
DiffusionKVSnapshot = tuple[object, ...]
@dataclass(frozen=True)
class _DiffusionKVRequestState:
generation: int
snapshot: DiffusionKVSnapshot
row_token_lens: tuple[tuple[DiffusionKVIdentity, int], ...]
@dataclass(frozen=True)
class _DiffusionKVRowInstall:
identity: DiffusionKVIdentity
@@ -39,13 +46,6 @@ class _DiffusionKVRowInstall:
block_ids: tuple[tuple[int, ...], ...]
@dataclass(frozen=True)
class _DiffusionKVRequestState:
generation: int
snapshot: DiffusionKVSnapshot
row_installs: tuple[_DiffusionKVRowInstall, ...]
class DiffusionKVModelRunnerBackend:
"""Native paged-KV state and BlockTable operations for a model runner."""
@@ -503,7 +503,7 @@ class DiffusionKVModelRunnerBackend:
self._diffusion_kv_request_states[metadata.request_id] = _DiffusionKVRequestState(
generation=metadata.allocation_generation,
snapshot=snapshot,
row_installs=current_state.row_installs,
row_token_lens=current_state.row_token_lens,
)
return False
@@ -530,7 +530,7 @@ class DiffusionKVModelRunnerBackend:
self._diffusion_kv_request_states[metadata.request_id] = _DiffusionKVRequestState(
generation=metadata.allocation_generation,
snapshot=snapshot,
row_installs=tuple(installs),
row_token_lens=tuple((install.identity, install.token_len) for install in installs),
)
return True
@@ -559,11 +559,24 @@ class DiffusionKVModelRunnerBackend:
raise RuntimeError("paged_scheduler native attention adapter is not initialized")
return self.paged_attention_adapter
def activate_paged_attention_metadata(self, metadata: DiffusionPagedAttentionMetadata):
"""Activate Runner-owned request metadata for an internal denoise loop."""
def prepare_paged_attention_batch(
self,
rows: Sequence[DiffusionPagedAttentionRow],
) -> PreparedDiffusionPagedAttentionBatch:
"""Build native page-table metadata for one Diffusion forward.
runtime = DiffusionPagedAttentionRuntime(self.get_paged_attention_adapter(), metadata)
return runtime, runtime.activate()
The scheduler allocation payload describes capacity, while the model
integration supplies the current ``query_len``/``kv_start_pos`` span
for each row. Keeping this boundary explicit prevents the Worker from
guessing model-specific text/image layout.
"""
return self.get_paged_attention_adapter().prepare_batch(rows)
def activate_paged_attention(self, batch: PreparedDiffusionPagedAttentionBatch):
"""Return the context manager that exposes a prepared batch to Omni Attention."""
return self.get_paged_attention_adapter().activate(batch)
def _resolve_paged_attention_row(
self,
@@ -575,12 +588,11 @@ class DiffusionKVModelRunnerBackend:
identity = (request_id, None, context_id) if context_id is not None else (request_id, sequence_id, None)
request_state = self._diffusion_kv_request_states.get(request_id)
if request_state is not None:
for install in request_state.row_installs:
if install.identity == identity:
for installed_identity, token_len in request_state.row_token_lens:
if installed_identity == identity:
return DiffusionPagedAttentionRowBinding(
row_index=row_index,
max_seq_len=install.token_len,
block_ids=install.block_ids,
max_seq_len=token_len,
)
raise RuntimeError(f"Diffusion KV request state is missing logical length for {identity!r}")
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
@@ -34,7 +34,6 @@ class DiffusionPagedAttentionRowBinding:
row_index: int
max_seq_len: int
block_ids: tuple[tuple[int, ...], ...] = ()
DiffusionKVRowResolver = Callable[
@@ -101,10 +100,6 @@ class DiffusionPagedAttentionLayerAdapter(AttentionLayerBase):
finally:
attention_config.backend = previous_backend
attention_config.backend_per_kind = previous_backend_per_kind
attn_backend = current_omni_platform.get_diffusion_paged_kv_attn_backend(
attn_backend,
ulysses_degree=ulysses_degree,
)
canonical_spec = replace(
spec,
num_kv_heads=num_kv_heads,
@@ -157,11 +152,9 @@ class DiffusionPagedAttentionLayerAdapter(AttentionLayerBase):
raise RuntimeError(
f"Native paged-attention backend {self.attn_backend.get_name()!r} has no forward implementation"
)
needs_explicit_cache_update = (
not self.attn_backend.forward_includes_kv_cache_update
or current_omni_platform.requires_diffusion_paged_kv_prewrite()
)
if needs_explicit_cache_update and not callable(getattr(impl, "do_kv_cache_update", None)):
if not self.attn_backend.forward_includes_kv_cache_update and not callable(
getattr(impl, "do_kv_cache_update", None)
):
raise RuntimeError(
f"Native paged-attention backend {self.attn_backend.get_name()!r} cannot update the KV cache"
)
@@ -211,30 +204,6 @@ class DiffusionPagedAttentionRow:
return (self.request_id, self.sequence_id, self.context_id)
@dataclass(frozen=True)
class DiffusionPagedAttentionMetadata:
"""Runner-owned row layouts for one request-level denoise loop."""
prefill_rows: tuple[DiffusionPagedAttentionRow, ...]
denoise_rows: tuple[DiffusionPagedAttentionRow, ...]
def __post_init__(self) -> None:
if not self.prefill_rows or not self.denoise_rows:
raise ValueError("Paged attention metadata requires both prefill and denoise rows")
prefill_identities = tuple(row.identity for row in self.prefill_rows)
denoise_identities = tuple(row.identity for row in self.denoise_rows)
if prefill_identities != denoise_identities:
raise ValueError(
"Paged attention prefill and denoise rows must use the same ordered identities: "
f"prefill={prefill_identities!r}, denoise={denoise_identities!r}"
)
def rows_for_step(self, step_idx: int | None) -> tuple[DiffusionPagedAttentionRow, ...]:
if type(step_idx) is not int or step_idx < 0:
raise ValueError(f"Paged attention requires a non-negative denoise step index, got {step_idx!r}")
return self.prefill_rows if step_idx == 0 else self.denoise_rows
@dataclass(frozen=True)
class PreparedDiffusionPagedAttentionBatch:
"""Native metadata shared by all paged attention layers in one forward."""
@@ -267,81 +236,13 @@ class DiffusionPagedAttentionContext:
piecewise_native_metadata: tuple[Any, ...]
query_token_shape: tuple[int, ...]
query_has_head_dims: bool
output_scatter_indices: torch.Tensor | None = None
def restore_output(self, output: torch.Tensor) -> torch.Tensor:
if self.output_scatter_indices is not None:
flat_output = output.reshape(-1, self.layer.num_heads, self.layer.head_size_v)
restored = output.new_zeros(
(*self.query_token_shape, self.layer.num_heads, self.layer.head_size_v)
).reshape(-1, self.layer.num_heads, self.layer.head_size_v)
restored.index_copy_(0, self.output_scatter_indices, flat_output)
if self.query_has_head_dims:
return restored.reshape(
*self.query_token_shape,
self.layer.num_heads,
self.layer.head_size_v,
)
return restored.reshape(
*self.query_token_shape,
self.layer.num_heads * self.layer.head_size_v,
)
if self.query_has_head_dims:
return output.reshape(*self.query_token_shape, self.layer.num_heads, self.layer.head_size_v)
return output.reshape(*self.query_token_shape, self.layer.num_heads * self.layer.head_size_v)
class DiffusionPagedAttentionRuntime:
"""Activate Runner-prepared rows lazily as the denoise loop advances."""
def __init__(
self,
adapter: DiffusionPagedAttentionAdapter,
metadata: DiffusionPagedAttentionMetadata,
) -> None:
self.adapter = adapter
self.metadata = metadata
self._active_phase: bool | None = None
self._activation: Any | None = None
self._closed = False
def ensure_active(self, step_idx: int | None) -> DiffusionPagedAttentionAdapter:
if self._closed:
raise RuntimeError("Paged attention runtime is already closed")
is_prefill = step_idx == 0
rows = self.metadata.rows_for_step(step_idx)
if self._active_phase == is_prefill:
return self.adapter
self._deactivate()
batch = self.adapter.prepare_batch(rows)
activation = self.adapter.activate(batch)
activation.__enter__()
self._activation = activation
self._active_phase = is_prefill
return self.adapter
def _deactivate(self) -> None:
activation = self._activation
self._activation = None
self._active_phase = None
if activation is not None:
activation.__exit__(None, None, None)
def close(self) -> None:
if self._closed:
return
self._deactivate()
self._closed = True
@contextmanager
def activate(self) -> Iterator[DiffusionPagedAttentionRuntime]:
try:
yield self
finally:
self.close()
class DiffusionPagedAttentionAdapter:
"""Translate diffusion rows into vLLM metadata for an Omni paged backend."""
@@ -644,16 +545,15 @@ class DiffusionPagedAttentionAdapter:
)
@staticmethod
def _token_packing_indices(
def _validate_token_layout(
token_shape: tuple[int, ...],
batch: PreparedDiffusionPagedAttentionBatch,
*,
name: str,
device: torch.device,
) -> torch.Tensor | None:
) -> None:
if len(token_shape) == 1:
if token_shape[0] == batch.num_tokens:
return None
return
raise ValueError(
f"Paged attention {name} token count must match the prepared write batch: "
f"tokens={token_shape[0]}, prepared={batch.num_tokens}"
@@ -661,23 +561,11 @@ class DiffusionPagedAttentionAdapter:
if len(token_shape) == 2:
batch_size, tokens_per_row = token_shape
query_lens = tuple(row.query_len for row in batch.rows)
if batch_size != len(batch.rows) or any(query_len > tokens_per_row for query_len in query_lens):
raise ValueError(
f"Paged attention batched {name} layout must match prepared rows for the current write: "
f"shape={token_shape}, row_query_lens={query_lens}"
)
if all(query_len == tokens_per_row for query_len in query_lens):
return None
return torch.cat(
[
torch.arange(
row_index * tokens_per_row,
row_index * tokens_per_row + query_len,
dtype=torch.long,
device=device,
)
for row_index, query_len in enumerate(query_lens)
]
if batch_size == len(batch.rows) and all(query_len == tokens_per_row for query_len in query_lens):
return
raise ValueError(
f"Paged attention batched {name} layout must match prepared rows for the current write: "
f"shape={token_shape}, row_query_lens={query_lens}"
)
raise ValueError(
f"Paged attention {name} supports packed [T, ...] or uniform batched [B, T, ...] token layouts; "
@@ -738,39 +626,19 @@ class DiffusionPagedAttentionAdapter:
query_offsets.append(query_offsets[-1] + query_len)
query_start_loc_cpu = torch.tensor(query_offsets, dtype=torch.int32)
seq_lens_cpu = torch.tensor(seq_lens_values, dtype=torch.int32)
if packed_segment.query_range is None:
positions = batch.positions.index_select(0, packed_segment.query_indices)
slot_mappings = batch.slot_mappings.index_select(-1, packed_segment.query_indices)
else:
start, end = packed_segment.query_range
positions = batch.positions[start:end]
slot_mappings = batch.slot_mappings[..., start:end]
segment_metadata = self._build_native_metadata(
query_lens=query_lens,
query_start_loc=query_start_loc_cpu.to(self.device),
query_start_loc_cpu=query_start_loc_cpu,
seq_lens=seq_lens_cpu.to(self.device),
seq_lens_cpu=seq_lens_cpu,
positions=positions,
block_tables=batch.block_tables,
slot_mappings=slot_mappings,
causal=(row_segments[0].mode == "causal"),
metadata_by_segment.append(
self._build_native_metadata(
query_lens=query_lens,
query_start_loc=query_start_loc_cpu.to(self.device),
query_start_loc_cpu=query_start_loc_cpu,
seq_lens=seq_lens_cpu.to(self.device),
seq_lens_cpu=seq_lens_cpu,
positions=batch.positions.index_select(0, packed_segment.query_indices),
block_tables=batch.block_tables,
slot_mappings=batch.slot_mappings.index_select(-1, packed_segment.query_indices),
causal=(row_segments[0].mode == "causal"),
)
)
# FA3's full-CUDA-graph metadata builder reuses one persistent
# scheduler buffer across builds. Piecewise attention prepares all
# segments before executing any of them, so each segment needs its
# own snapshot rather than a view overwritten by the next build.
snapshotted = set()
for native_metadata in segment_metadata.values():
metadata_id = id(native_metadata)
if metadata_id in snapshotted:
continue
snapshotted.add(metadata_id)
for field_name in ("scheduler_metadata", "prefix_scheduler_metadata"):
scheduler_metadata = getattr(native_metadata, field_name, None)
if isinstance(scheduler_metadata, torch.Tensor):
setattr(native_metadata, field_name, scheduler_metadata.clone())
metadata_by_segment.append(segment_metadata)
self._active_piecewise_native_metadata = tuple(metadata_by_segment)
return self._active_piecewise_native_metadata
@@ -810,36 +678,9 @@ class DiffusionPagedAttentionAdapter:
head_size=layer.head_size_v,
name="value",
)
if key_token_shape != query_token_shape:
if len(key_token_shape) == 1:
raise ValueError(
"Paged attention key token count must match the query layout: "
f"query={query_token_shape}, key={key_token_shape}"
)
raise ValueError(
"Paged attention batched key layout must match prepared rows and the query layout: "
f"query={query_token_shape}, key={key_token_shape}"
)
if value_token_shape != query_token_shape:
if len(value_token_shape) == 1:
raise ValueError(
"Paged attention value token count must match the query layout: "
f"query={query_token_shape}, value={value_token_shape}"
)
raise ValueError(
"Paged attention batched value layout must match prepared rows and the query layout: "
f"query={query_token_shape}, value={value_token_shape}"
)
output_scatter_indices = self._token_packing_indices(
query_token_shape,
batch,
name="Q/K/V",
device=query.device,
)
if output_scatter_indices is not None:
query_flat = query_flat.index_select(0, output_scatter_indices)
key_flat = key_flat.index_select(0, output_scatter_indices)
value_flat = value_flat.index_select(0, output_scatter_indices)
self._validate_token_layout(query_token_shape, batch, name="query")
self._validate_token_layout(key_token_shape, batch, name="key")
self._validate_token_layout(value_token_shape, batch, name="value")
if query.device != self.device or key.device != self.device or value.device != self.device:
raise ValueError(
f"Paged attention Q/K/V must be on {self.device}; "
@@ -870,18 +711,10 @@ class DiffusionPagedAttentionAdapter:
f"expected {batch.num_tokens}"
)
# vLLM's CUDA writer consumes the source token strides directly, so
# preserve strided QKV-projection views instead of materializing an
# extra K/V copy here. Platform writers with stricter contracts (for
# example, Ascend PA scatter) make their inputs contiguous locally.
# The native CUDA writer still assumes heads and head dimensions are
# packed within each token; materialize unusual inner layouts only.
if key_flat.stride(-1) != 1 or (key_flat.shape[-2] > 1 and key_flat.stride(-2) != key_flat.shape[-1]):
key_flat = key_flat.contiguous()
if value_flat.stride(-1) != 1 or (value_flat.shape[-2] > 1 and value_flat.stride(-2) != value_flat.shape[-1]):
value_flat = value_flat.contiguous()
# The writer also consumes slot_mapping directly, so a span that starts
# vLLM's writer consumes slot_mapping directly, so a span that starts
# inside a physical block naturally writes only the requested suffix.
key_flat = key_flat.contiguous()
value_flat = value_flat.contiguous()
try:
native_metadata = batch.attn_metadata[layer_name]
except KeyError as exc:
@@ -910,7 +743,6 @@ class DiffusionPagedAttentionAdapter:
piecewise_native_metadata=piecewise_native_metadata,
query_token_shape=query_token_shape,
query_has_head_dims=query_has_head_dims,
output_scatter_indices=output_scatter_indices,
)
@staticmethod
@@ -142,7 +142,7 @@ def _get_symm_buffer(
stream_id=stream_id,
)
_SYMM_WORKSPACES[key] = workspace
return workspace.allocation[:required_bytes].view(dtype).view(symm_shape)
return workspace.handle.get_buffer(workspace.handle.rank, symm_shape, dtype)
def clear_a2a_permute_workspaces() -> None:
@@ -172,9 +172,7 @@ def ulysses_qkv_fwd(x: torch.Tensor, group_name: str, world_size: int) -> torch.
rows = B * s_local
symm_in = _get_symm_buffer((rows, p, lc), x.dtype, x.device, group_name)
# (B, S_local, H, D) -> (rows, p, lc); H is row-major so column block r = heads [r*Hl:(r+1)*Hl]
# Fused QKV projections produce row-strided views. Stage complete rows with
# the copy engine instead of materializing the view with TensorIterator.
torch.ops.a2ap.copy_rows(x.view(rows, p, lc), symm_in)
symm_in.copy_(x.reshape(rows, p, lc))
out = torch.empty(p, rows, lc, device=x.device, dtype=x.dtype)
torch.ops.a2ap.all_to_all_permute(symm_in, out, 1, 0, group_name)
# (p, rows, lc) -> (B, S_global, H_local, D), sequence ordered rank-major
@@ -7,7 +7,6 @@
// cached per group — the PR's keyed-devcomm manager API is not in 2.11).
#include <c10/cuda/CUDAGuard.h>
#include <c10/cuda/CUDAException.h>
#include <c10/macros/Macros.h>
#include <ATen/native/cuda/MemoryAccess.cuh>
#include <ATen/cuda/CUDAContext.h>
@@ -30,37 +29,6 @@ using namespace c10d::symmetric_memory;
#error "Build requires NCCL >= 2.28 with symmetric-memory device support"
#endif
void copy_rows(const at::Tensor& input, at::Tensor& out) {
TORCH_CHECK(input.is_cuda() && out.is_cuda(), "a2a_permute: copy_rows requires CUDA tensors");
TORCH_CHECK(input.device() == out.device(), "a2a_permute: copy_rows device mismatch");
TORCH_CHECK(input.scalar_type() == out.scalar_type(), "a2a_permute: copy_rows dtype mismatch");
TORCH_CHECK(input.dim() == 3, "a2a_permute: copy_rows input must be 3-D");
TORCH_CHECK(out.sizes() == input.sizes(), "a2a_permute: copy_rows shape mismatch");
TORCH_CHECK(out.is_contiguous(), "a2a_permute: copy_rows output must be contiguous");
TORCH_CHECK(
input.stride(2) == 1 && input.stride(1) == input.size(2),
"a2a_permute: copy_rows requires contiguous rows");
const int64_t row_elements = input.size(1) * input.size(2);
TORCH_CHECK(input.stride(0) >= row_elements, "a2a_permute: copy_rows input rows overlap");
const size_t rows = static_cast<size_t>(input.size(0));
const size_t row_bytes = static_cast<size_t>(row_elements) * input.element_size();
const size_t input_pitch =
static_cast<size_t>(input.stride(0) * input.element_size());
c10::cuda::CUDAGuard guard(input.device());
auto stream = at::cuda::getCurrentCUDAStream();
C10_CUDA_CHECK(cudaMemcpy2DAsync(
out.data_ptr(),
row_bytes,
input.data_ptr(),
input_pitch,
row_bytes,
rows,
cudaMemcpyDeviceToDevice,
stream));
}
// ---- kernel (verbatim from PR) ----------------------------------------------
__device__ inline void copy_bytes_vec16_aligned(
const char* src, char* dst, size_t nbytes, size_t tid, size_t stride) {
@@ -259,10 +227,8 @@ void all_to_all_permute(
} // namespace
TORCH_LIBRARY_FRAGMENT(a2ap, m) {
m.def("copy_rows(Tensor input, Tensor(a!) out) -> ()");
m.def("all_to_all_permute(Tensor input, Tensor(a!) out, int scatter_dim, int gather_dim, str group_name) -> ()");
}
TORCH_LIBRARY_IMPL(a2ap, CUDA, m) {
m.impl("copy_rows", TORCH_FN(copy_rows));
m.impl("all_to_all_permute", TORCH_FN(all_to_all_permute));
}
-21
View File
@@ -29,19 +29,11 @@ class ForwardContext:
vllm_config: VllmConfig | None = None
omni_diffusion_config: OmniDiffusionConfig | None = None
attn_metadata: dict[str, AttentionMetadata] | list[dict[str, AttentionMetadata]] | None = None
# Runner-owned paged execution metadata/runtime. Attention resolves the
# active Worker adapter from it; model code must not construct BlockTable
# rows or activate the runtime directly.
paged_kv_runtime: object | None = None
# Active Worker-side paged KV adapter. The adapter is installed only for
# the duration of a paged forward; dense forwards leave this as ``None``.
# Keep the field opaque here to avoid coupling the common context module to
# the diffusion_kv implementation.
paged_kv_adapter: Any | None = None
# Startup-only memory profiling runs before Scheduler-owned pages exist.
# Attention layers use this explicit scope to distinguish that probe from
# a malformed paged request whose Worker adapter was not activated.
in_diffusion_kv_memory_profile: bool = False
split_text_embed_in_sp: bool = False
denoise_step_idx: int | None = None
denoise_timestep: float | None = None
@@ -162,8 +154,6 @@ def create_forward_context(
vllm_config: VllmConfig | None = None,
omni_diffusion_config: OmniDiffusionConfig | None = None,
attn_metadata: dict[str, AttentionMetadata] | list[dict[str, AttentionMetadata]] | None = None,
paged_kv_runtime: object | None = None,
in_diffusion_kv_memory_profile: bool = False,
split_text_embed_in_sp: bool = False,
denoise_step_idx: int | None = None,
):
@@ -171,8 +161,6 @@ def create_forward_context(
vllm_config=vllm_config,
omni_diffusion_config=omni_diffusion_config,
attn_metadata=attn_metadata,
paged_kv_runtime=paged_kv_runtime,
in_diffusion_kv_memory_profile=in_diffusion_kv_memory_profile,
split_text_embed_in_sp=split_text_embed_in_sp,
denoise_step_idx=denoise_step_idx,
)
@@ -198,8 +186,6 @@ def set_forward_context(
vllm_config: VllmConfig | None = None,
omni_diffusion_config: OmniDiffusionConfig | None = None,
attn_metadata: dict[str, AttentionMetadata] | list[dict[str, AttentionMetadata]] | None = None,
paged_kv_runtime: object | None = None,
in_diffusion_kv_memory_profile: bool = False,
split_text_embed_in_sp: bool = False,
denoise_step_idx: int | None = None,
):
@@ -211,8 +197,6 @@ def set_forward_context(
vllm_config=vllm_config,
omni_diffusion_config=omni_diffusion_config,
attn_metadata=attn_metadata,
paged_kv_runtime=paged_kv_runtime,
in_diffusion_kv_memory_profile=in_diffusion_kv_memory_profile,
split_text_embed_in_sp=split_text_embed_in_sp,
denoise_step_idx=denoise_step_idx,
)
@@ -262,11 +246,6 @@ def set_forward_context_denoise_step_idx(step_idx: int | None) -> None:
"""Set the current diffusion denoise step on the active ForwardContext."""
if _forward_context is not None:
_forward_context.denoise_step_idx = step_idx
if step_idx is not None:
paged_kv_runtime = getattr(_forward_context, "paged_kv_runtime", None)
ensure_active = getattr(paged_kv_runtime, "ensure_active", None)
if callable(ensure_active):
ensure_active(step_idx)
def set_forward_context_denoise_timestep(timestep: float | None) -> None:
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import inspect
import logging
@@ -1069,98 +1069,18 @@ class ImageKVCacheManager(nn.Module):
)
return key, value
def clear_legacy_prompt_kv_cache(self) -> None:
"""Drop model-owned dense KV state before Scheduler-paged execution."""
self.image_kv_cache_map = None
self.image_kv_cache_lens = None
def _forward_paged(
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
*,
first_step: bool,
query_lens: list[int],
seq_lens: list[int],
shard_image_size: int | None,
full_attn_spans: list[list[tuple[int, int]]],
uncond_cfg_prefill: bool,
) -> torch.Tensor:
"""Run Hunyuan attention against Worker-managed Scheduler pages.
This model boundary is shared by GPU and NPU: it describes Hunyuan's
Q/K/V layout and mixed-attention spans, while the common Attention
layer resolves the actual CUDA or Ascend paged kernel.
"""
if uncond_cfg_prefill:
raise RuntimeError("Hunyuan negative-CFG prefill must run before paged row activation")
if self._injected_ar_kv is not None:
raise NotImplementedError("Hunyuan Scheduler-paged KV does not support imported AR KV")
if not query_lens or len(seq_lens) != len(query_lens):
raise ValueError("Hunyuan Scheduler-paged KV requires matching query and sequence lengths")
if len(full_attn_spans) != len(query_lens):
raise ValueError("Hunyuan Scheduler-paged KV requires one full-attention span row per sequence")
self.clear_legacy_prompt_kv_cache()
bs = len(query_lens)
q_len = query_lens[0]
seq_len = seq_lens[0]
assert query.shape[0] == bs * q_len, f"{query.shape[0]} != {bs * q_len}"
head_num_per_rank = query.shape[1]
kv_head_num_per_rank = key.shape[1]
head_dim = query.shape[2]
query = query.reshape(bs, q_len, head_num_per_rank, head_dim)
key = key.reshape(bs, q_len, kv_head_num_per_rank, head_dim)
value = value.reshape(bs, q_len, kv_head_num_per_rank, head_dim)
joint_text_query = joint_text_key = joint_text_value = None
if self.sp_size > 1 and first_step:
if shard_image_size is None or shard_image_size <= 0:
raise ValueError("Hunyuan paged Ulysses requires a positive local image shard size")
local_prompt_len = seq_len - shard_image_size
joint_query_len = query.shape[1] - shard_image_size
if local_prompt_len != joint_query_len:
raise ValueError(
"Hunyuan paged Ulysses prompt layout mismatch: "
f"key_prompt={local_prompt_len}, query_prompt={joint_query_len}"
)
joint_text_query = query[:, :joint_query_len]
joint_text_key = key[:, :local_prompt_len]
joint_text_value = value[:, :local_prompt_len]
query = query[:, joint_query_len:]
key = key[:, local_prompt_len:]
value = value[:, local_prompt_len:]
if joint_text_query is None:
attn_metadata = AttentionMetadata(full_attn_spans=full_attn_spans)
else:
attn_metadata = AttentionMetadata(
joint_query=joint_text_query,
joint_key=joint_text_key,
joint_value=joint_text_value,
joint_strategy="front",
full_attn_spans=full_attn_spans,
)
attn_output = self.attn(query, key, value, attn_metadata)
return attn_output.reshape(bs * q_len, head_num_per_rank, head_dim)
def _forward_dense_legacy(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attention_mask: torch.Tensor,
attention_mask: torch.Tensor | None = None,
**kwargs,
) -> torch.Tensor:
"""Run the device-neutral, model-owned dense prompt-KV compatibility path."""
self.image_token_len = kwargs.get("num_image_tokens")
first_step = kwargs.get("first_step")
uncond_cfg_prefill = kwargs.get("uncond_cfg_prefill", False)
query_lens = kwargs.get("query_lens")
seq_lens = kwargs.get("seq_lens")
shard_image_size = kwargs.get("shard_image_size") if self.sp_size > 1 else None
@@ -1189,7 +1109,8 @@ class ImageKVCacheManager(nn.Module):
key = key[:, :0, :, :]
value = value[:, :0, :, :]
elif first_step:
self.clear_legacy_prompt_kv_cache()
self.image_kv_cache_map = None # reset first
self.image_kv_cache_lens = None
key, value = self._cache_prompt_kv(
key,
value,
@@ -1213,7 +1134,7 @@ class ImageKVCacheManager(nn.Module):
joint_text_query = query[:, :0, :, :]
joint_text_key, joint_text_value = self._reuse_prompt_kv(key, value, seq_len, bs, shard_image_size)
if not keep_kv_compressed:
if not keep_kv_compressed and not self.attn.is_paged_kv_active():
key = repeat_kv(key, repeat_num)
value = repeat_kv(value, repeat_num)
if self.sp_size > 1:
@@ -1242,34 +1163,6 @@ class ImageKVCacheManager(nn.Module):
attn_output = attn_output.reshape(bs * q_len, head_num_per_rank, head_dim)
return attn_output
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attention_mask: torch.Tensor | None = None,
**kwargs,
) -> torch.Tensor:
self.image_token_len = kwargs.get("num_image_tokens")
if self.attn.is_paged_kv_active():
full_attn_spans = kwargs.get("full_attn_spans")
if full_attn_spans is None:
raise ValueError("Hunyuan Scheduler-paged KV requires full_attn_spans metadata")
return self._forward_paged(
query,
key,
value,
first_step=bool(kwargs.get("first_step")),
query_lens=kwargs.get("query_lens"),
seq_lens=kwargs.get("seq_lens"),
shard_image_size=kwargs.get("shard_image_size") if self.sp_size > 1 else None,
full_attn_spans=full_attn_spans,
uncond_cfg_prefill=kwargs.get("uncond_cfg_prefill", False),
)
if attention_mask is None:
raise ValueError("Hunyuan dense attention requires an attention mask")
return self._forward_dense_legacy(query, key, value, attention_mask, **kwargs)
@dataclass
class CausalMMOutputWithPast(CausalLMOutputWithPast):
@@ -2813,6 +2706,7 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline):
# List[List[...]] per-sample metadata indexed along the CFG batch dim
if isinstance(model_kwargs.get("full_attn_spans"), list):
model_kwargs["full_attn_spans"] = model_kwargs["full_attn_spans"][s.start : s.stop]
# custom_pos_emb: tuple of (cos, sin)
if "custom_pos_emb" in model_kwargs and model_kwargs["custom_pos_emb"] is not None:
cos, sin = model_kwargs["custom_pos_emb"]
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import copy
import logging
@@ -72,6 +72,7 @@ _STEP_OUTPUT_SIZE = "hunyuan_output_size"
_STEP_COT_TEXT_LIST = "hunyuan_cot_text_list"
_STEP_AR_KV = "hunyuan_ar_kv"
_STEP_PROMPT_KV = "hunyuan_prompt_kv"
_HUNYUAN_DEFAULT_OUTPUT_TYPE = "pil"
@@ -465,11 +466,6 @@ class HunyuanImage3Pipeline(
return self._pipeline
def _validate_step_request(self, state: "StepRequestState") -> None:
if self._uses_scheduler_paged_kv():
raise ValueError(
"HunyuanImage3 paged_scheduler currently supports request-level execution only; "
"disable step execution or use dense_legacy."
)
prompt = state.prompt
sampling = state.sampling
if prompt is None:
@@ -667,12 +663,6 @@ class HunyuanImage3Pipeline(
prefix_lens.append(int(prompt_kv[0]["lens"][branch].item()))
return prefix_lens
def _uses_scheduler_paged_kv(self) -> bool:
return (
getattr(self.od_config, "diffusion_kv_mode", DiffusionKVCacheMode.DENSE_LEGACY)
is DiffusionKVCacheMode.PAGED_SCHEDULER
)
def _merge_step_model_inputs(
self,
states: list["StepRequestState"],
@@ -703,7 +693,6 @@ class HunyuanImage3Pipeline(
values,
row_branches,
prefix_lens or [None] * len(row_branches),
strict=True,
)
]
continue
@@ -1882,7 +1871,8 @@ class HunyuanImage3Pipeline(
row_branches = [branch for branch in range(cfg_factor) for _ in states]
return row_state_indexes, row_branches
def _validate_step_group_states(self, states: list["StepRequestState"]) -> tuple[bool, int]:
@staticmethod
def _validate_step_group_states(states: list["StepRequestState"]) -> tuple[bool, int]:
if not states:
raise ValueError("HunyuanImage3 denoise_step received an empty group.")
@@ -2045,11 +2035,6 @@ class HunyuanImage3Pipeline(
states = list(input_batch.states)
if not states:
raise ValueError("HunyuanImage3 denoise_step received an empty batch.")
if self._uses_scheduler_paged_kv():
raise ValueError(
"HunyuanImage3 paged_scheduler currently supports request-level execution only; "
"disable step execution or use dense_legacy."
)
self._ensure_grouped_attention_backend_supported(len(states))
outputs: dict[str, torch.Tensor] = {}
for group in self._split_step_groups(states):
@@ -1,679 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""FastVideo FastH3: a four-step DMD2 student of MiniMax-H3.
FastH3 replaces H3's 49 denoiser evaluations with four. It ships as an adapter
over the base checkpoint rather than as a full release, so it reuses H3's text
encoder, video VAE, audio VAE, tokenizers and schedulers unchanged.
The artifact is *not* a PEFT LoRA, and it is not request-switchable. Its own
metadata states the reconstruction as::
W = W_base + lora_B @ lora_A; then .diff/.diff_b added and .set_weight assigned
so besides rank-64 factors it carries full-rank ``.diff``/``.diff_b`` deltas for
RMSNorm weights, biases, patch projections and the final layer - none of which a
LoRA layer can express - and the VSA variants add ``.set_weight`` tensors for
compression gates that do not exist in the base transformer at all. The adapter
is therefore fused into the checkpoint stream at load time, before the weights
are sharded, which is also what the release's model card requires.
The low-rank factors carry no alpha: the reconstruction adds ``lora_B @ lora_A``
directly, i.e. a scale of exactly 1.
Two checkpoint spellings meet here. The adapter is written in the diffusers
namespace (``transformer_blocks.0.attn.to_q``) while vLLM-Omni loads H3's native
one (``blocks.0.attn.qkv_proj``), whose attention and MLP projections are fused.
Every mapping and layout convention below was verified tensor by tensor against
the released full checkpoint (``FastVideo-FastH3-4-step-Preview-v1-Dense-DataFree``):
``W_base + delta`` reproduces it to bf16 rounding.
"""
from __future__ import annotations
import math
from collections.abc import Iterable, Iterator, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
import torch
from safetensors import safe_open
from vllm.logger import init_logger
from vllm_omni.diffusion.sched.sigma_schedule import DMD2SigmaSchedule
from vllm_omni.errors import OmniClientError
from vllm_omni.platforms import current_omni_platform
if TYPE_CHECKING:
from .minimax_h3_transformer import MiniMaxH3DiTModel
logger = init_logger(__name__)
# FastVideo's generic adapter container, not a FastH3 marker: their tools emit
# it for ordinary H3 adapters too.
FASTH3_FORMAT = "fastvideo-lora-v2"
FASTH3_MANIFEST = "adapter_manifest.json"
# The release identity: the distilled student the adapter came from, over the
# base it edits. The name is held to FastVideo's own namespace as well as the
# student's name, so an unrelated adapter that merely mentions FastH3 is not
# claimed.
FASTH3_BASE_MODEL = "MiniMaxAI/MiniMax-H3"
_FASTH3_IDENTITY_KEY = "finetuned_model"
_FASTH3_IDENTITY_NAMESPACE = "fastvideo/"
_FASTH3_IDENTITY_MARKER = "fasth3"
# The rectified-flow positions the student was distilled at, and the ladder the
# server samples on. The release states them as `dmd_denoising_steps`
# [999, 749, 500, 250] (`sampling.base_timesteps` in the bundle manifest):
# timestep indices out of 1000, i.e. pre-shift positions, closed here with the
# terminal 0.0 every rectified-flow schedule ends on. The opening rung is 0.999
# rather than 1.0 because training capped the noise level there
# (`max_timestep_ratio`).
#
# These are positions, not final sigmas: H3's schedulers apply their own
# per-modality shift on top (12 for video, 3 for audio), which is what
# reproduces the levels the student saw. Nothing here overrides those shifts.
FASTH3_BASE_SCHEDULE = DMD2SigmaSchedule.from_positions((0.999, 0.749, 0.5, 0.25, 0.0))
# Its five points bound four transformer forwards, one per sigma interval. That
# count is the one a request states, the one Cache-DiT is refreshed with and the
# one step execution admits a request on - the same interval contract H3's
# pinned checkpoint schedules and native LoRAs already use.
FASTH3_DENOISE_STEPS = FASTH3_BASE_SCHEDULE.num_inference_steps
# Preview v1 distills the text-to-video-and-audio path only.
FASTH3_SUPPORTED_TASKS = frozenset({"t2va"})
_LORA_A = ".lora_A.weight"
_LORA_B = ".lora_B.weight"
_DIFF = ".diff"
_DIFF_B = ".diff_b"
_SET_WEIGHT = ".set_weight"
# Adapter module prefix -> the native parameter it edits, minus the
# ``.weight``/``.bias`` suffix.
_MODEL_LEVEL_TARGETS = {
"proj_in": "video_patch_proj",
"proj_out": "final_layer.video_out",
"audio_proj_in": "audio_patch_proj",
"audio_proj_out": "final_layer.audio_out",
"context_embedder": "condition_proj",
"time_embedder.linear_1": "time_embedder.proj_in",
"time_embedder.linear_2": "time_embedder.proj_out",
"norm_out.linear": "final_layer.adaln_proj.linear",
"norm_out.norm": "final_layer.norm",
}
# Per-block adapter suffix -> (native suffix, how a delta enters the native
# parameter). H3 stores attention as one grouped QKV matrix and the MLP as one
# fused gate/up matrix, so those deltas need placing rather than adding.
_PLAIN, _QKV_Q, _QKV_K, _QKV_V, _SWAP_HALVES = "plain", "q", "k", "v", "swap_halves"
_QKV_SLOTS = (_QKV_Q, _QKV_K, _QKV_V)
_BLOCK_TARGETS = {
"attn.to_q": ("attn.qkv_proj", _QKV_Q),
"attn.to_k": ("attn.qkv_proj", _QKV_K),
"attn.to_v": ("attn.qkv_proj", _QKV_V),
"attn.to_out.0": ("attn.out_proj", _PLAIN),
"ff.net.0.proj": ("mlp.fc1", _SWAP_HALVES),
"ff.net.2": ("mlp.fc2", _PLAIN),
"adaln_proj.linear": ("adaln_proj.linear", _PLAIN),
"norm1": ("norm1", _PLAIN),
"norm2": ("norm2", _PLAIN),
}
# Adapter block prefix -> native block prefix.
_BLOCK_PREFIXES = (
("token_refiner.refiner_blocks.", "token_refiner.blocks."),
("transformer_blocks.", "blocks."),
)
class FastH3AdapterError(ValueError):
"""The artifact is a FastH3 adapter, but it cannot be applied as one."""
@dataclass
class _ParamPatch:
"""Everything the adapter contributes to one native parameter."""
# layout -> (lora_A, lora_B). A grouped QKV parameter collects three.
low_rank: dict[str, tuple[torch.Tensor | None, torch.Tensor | None]] = field(default_factory=dict)
diff: torch.Tensor | None = None
layout: str = _PLAIN
def _swap_halves(tensor: torch.Tensor) -> torch.Tensor:
"""Exchange the two halves of a fused gate/up matrix.
The diffusers export stores the feed-forward projection value-first while
H3's native ``mlp.fc1`` is gate-first, so a delta computed in the diffusers
layout has to be swapped before it can be added to the native parameter.
"""
if tensor.shape[0] % 2:
raise FastH3AdapterError(f"fused gate/up delta must split evenly, got {tuple(tensor.shape)}")
first, second = tensor.chunk(2, dim=0)
return torch.cat((second, first), dim=0)
def _place_in_grouped_qkv(deltas: Mapping[str, torch.Tensor], *, head_dim: int) -> torch.Tensor:
"""Interleave per-projection deltas into H3's grouped QKV layout.
The checkpoint stores one head group at a time as ``[q, k, v]``, which is
what :func:`_reorder_grouped_qkv_to_qkv` unpacks on the way in. A delta
built from the separate diffusers projections has to be folded back into
that order.
"""
missing = sorted(set(_QKV_SLOTS) - set(deltas))
if missing:
raise FastH3AdapterError(f"grouped QKV delta is missing its {missing} projections")
parts = []
for slot in _QKV_SLOTS:
delta = deltas[slot]
if delta.shape[0] % head_dim:
raise FastH3AdapterError(
f"QKV {slot} delta rows {delta.shape[0]} are not a multiple of head_dim {head_dim}"
)
parts.append(delta.reshape(delta.shape[0] // head_dim, head_dim, *delta.shape[1:]))
groups = parts[0].shape[0]
if any(part.shape[0] != groups for part in parts):
raise FastH3AdapterError("QKV projections disagree on the number of head groups")
return torch.cat(parts, dim=1).reshape(groups * 3 * head_dim, *parts[0].shape[2:])
def _resolve_native_target(module: str) -> tuple[str, str, tuple[str, int] | None] | None:
"""Map an adapter module path to ``(native path, layout, block)``.
``block`` is the ``(native block prefix, index)`` the module sits in, or
``None`` for a model-level one. Coverage is checked per block, so the index
has to survive the mapping rather than being folded into the path.
"""
native = _MODEL_LEVEL_TARGETS.get(module)
if native is not None:
return native, _PLAIN, None
for adapter_prefix, native_prefix in _BLOCK_PREFIXES:
if not module.startswith(adapter_prefix):
continue
remainder = module[len(adapter_prefix) :]
index, _, suffix = remainder.partition(".")
if not index.isdigit():
return None
target = _BLOCK_TARGETS.get(suffix)
if target is None:
return None
native_suffix, layout = target
return f"{native_prefix}{index}.{native_suffix}", layout, (native_prefix, int(index))
return None
def _split_adapter_key(name: str) -> tuple[str, str] | None:
"""Split an adapter tensor name into ``(module path, role)``."""
for marker, role in (
(_LORA_A, "lora_a"),
(_LORA_B, "lora_b"),
(_DIFF_B, "diff_b"),
(_DIFF, "diff"),
(_SET_WEIGHT, "set_weight"),
):
if name.endswith(marker):
return name[: -len(marker)], role
return None
def _is_fasth3_release(metadata: Mapping[str, str]) -> bool:
"""Whether this is a FastH3 release rather than merely FastVideo's format.
Claiming on the container alone would fuse an ordinary H3 adapter into the
checkpoint and put every request on a four-step schedule it was never
distilled for, and so would a bare ``fasth3`` substring: an adapter someone
else named after the student is not the student.
"""
if metadata.get("format") != FASTH3_FORMAT:
return False
identity = metadata.get(_FASTH3_IDENTITY_KEY, "").lower()
if not identity.startswith(_FASTH3_IDENTITY_NAMESPACE) or _FASTH3_IDENTITY_MARKER not in identity:
return False
# Only held against a file that states it.
base_model = metadata.get("base_model")
return base_model is None or base_model.casefold() == FASTH3_BASE_MODEL.casefold()
def _check_declared_counts(
metadata: Mapping[str, str],
counted: Mapping[str, int],
*,
weights_path: Path,
) -> None:
"""Hold the artifact to the tensor counts its own metadata declares.
The writer of the ``fastvideo-lora-v2`` format records how many tensors of
each kind it emitted, which is the one statement in the file about its own
completeness. A truncated or partially re-exported artifact is otherwise
indistinguishable from a small one, so a claimed file has to carry all of
them rather than opt out by omission.
"""
for key, seen in counted.items():
declared = metadata.get(key)
if declared is None:
raise FastH3AdapterError(
f"{weights_path} declares the FastH3 identity but omits {key}; "
"a claimed adapter has to state what it emitted"
)
try:
expected = int(declared)
except (TypeError, ValueError) as exc:
raise FastH3AdapterError(f"{weights_path} declares a non-numeric {key}={declared!r}") from exc
if expected != seen:
raise FastH3AdapterError(
f"{weights_path} declares {key}={expected} but carries {seen}; the adapter is incomplete "
"and would leave most of the transformer on base H3 weights"
)
def _check_block_coverage(
seen: Mapping[str, set[int]],
*,
expected: Mapping[str, int],
weights_path: Path,
) -> None:
"""Every block of the model this adapter is loaded against must be edited.
The release drops tensors training left unchanged, so per-parameter
coverage is legitimately sparse - but a distilled student touches every
block, so a block with no edits at all means the artifact does not match
this model.
"""
for prefix, count in expected.items():
indices = seen.get(prefix, set())
wanted = set(range(count))
if indices == wanted:
continue
missing = sorted(wanted - indices)
extra = sorted(indices - wanted)
raise FastH3AdapterError(
f"{weights_path} edits {len(indices)} of the model's {count} {prefix}* blocks "
f"(missing={missing[:5]}, unknown={extra[:5]}); it is not an adapter for this checkpoint"
)
class FastH3WeightFusion:
"""Fuse a FastH3 adapter into the H3 checkpoint stream as it is loaded."""
def __init__(
self,
*,
source: Path,
patches: Mapping[str, _ParamPatch],
head_dim: int,
requires_vsa: bool,
) -> None:
self._source = source
self._patches = dict(patches)
self._head_dim = head_dim
self.requires_vsa = requires_vsa
self._applied: set[str] = set()
self._device: torch.device | None = None
@property
def source(self) -> Path:
return self._source
@property
def base_schedule(self) -> tuple[float, ...]:
"""The rectified-flow positions this student samples on.
The fused checkpoint is a four-step student, so the ladder comes from
the release rather than from the many-step teacher's metadata or from
the uniform one ``num_inference_steps`` would otherwise derive.
"""
return FASTH3_BASE_SCHEDULE.base_schedule
@classmethod
def from_path(
cls,
path: str | Path,
*,
head_dim: int,
num_blocks: int,
num_refiner_blocks: int,
) -> FastH3WeightFusion | None:
"""Build a fusion from an adapter file or directory, else ``None``.
Returning ``None`` keeps every other ``--lora-path`` artifact on the
dynamic LoRA route; only a file carrying the FastH3 release identity is
claimed here.
The block counts are the model's, and the artifact has to cover them:
claiming a partial adapter would switch the server onto the four-step
contract while most of the transformer still held base H3 weights.
"""
weights_path = _resolve_adapter_file(path)
if weights_path is None:
return None
patches: dict[str, _ParamPatch] = {}
gate_tensors: list[str] = []
unmapped: list[str] = []
blocks_seen: dict[str, set[int]] = {}
counted = {"low_rank_tensors": 0, "diff_tensors": 0}
with safe_open(weights_path, framework="pt", device="cpu") as checkpoint:
metadata = checkpoint.metadata() or {}
if not _is_fasth3_release(metadata):
return None
for name in checkpoint.keys():
split = _split_adapter_key(name)
if split is None:
unmapped.append(name)
continue
module, role = split
if role == "set_weight":
# A VSA compression gate: a module the base transformer does
# not have, so there is nothing to fuse it into.
gate_tensors.append(name)
continue
target = _resolve_native_target(module)
if target is None:
unmapped.append(name)
continue
native_module, layout, block = target
if block is not None:
blocks_seen.setdefault(block[0], set()).add(block[1])
counted["diff_tensors" if role in ("diff", "diff_b") else "low_rank_tensors"] += 1
native_param = f"{native_module}.{'bias' if role == 'diff_b' else 'weight'}"
patch = patches.setdefault(native_param, _ParamPatch(layout=layout))
tensor = checkpoint.get_tensor(name)
if role in ("diff", "diff_b"):
if patch.diff is not None:
raise FastH3AdapterError(f"duplicate {role} for {native_param}")
patch.diff = tensor
else:
a, b = patch.low_rank.get(layout, (None, None))
patch.low_rank[layout] = (tensor, b) if role == "lora_a" else (a, tensor)
if unmapped:
raise FastH3AdapterError(
f"FastH3 adapter at {weights_path} has {len(unmapped)} tensors that name no known "
f"H3 parameter: {sorted(unmapped)[:5]}"
)
_check_declared_counts(
metadata,
{**counted, "set_weight_tensors": len(gate_tensors)},
weights_path=weights_path,
)
_check_block_coverage(
blocks_seen,
expected={"blocks.": num_blocks, "token_refiner.blocks.": num_refiner_blocks},
weights_path=weights_path,
)
for native_param, patch in patches.items():
for slot, (a, b) in patch.low_rank.items():
if a is None or b is None:
raise FastH3AdapterError(f"FastH3 adapter has an unpaired factor for {native_param} slot {slot!r}")
# Only the low-rank factors are placed into H3's fused QKV and
# gate/up layouts; a full-rank delta is added as it comes, so one
# aimed at a fused parameter would silently land transposed.
if patch.diff is not None and patch.layout != _PLAIN:
raise FastH3AdapterError(
f"FastH3 adapter carries a full-rank delta for {native_param}, which H3 stores in the "
f"{patch.layout!r} fused layout; this loader can only place low-rank factors there"
)
fusion = cls(
source=weights_path,
patches=patches,
head_dim=head_dim,
requires_vsa=bool(gate_tensors),
)
logger.info(
"FastH3 adapter %s: rank=%s, parameters patched=%d, low-rank=%s, diff=%s, set_weight=%d",
weights_path,
metadata.get("rank", "?"),
len(patches),
metadata.get("low_rank_tensors", "?"),
metadata.get("diff_tensors", "?"),
len(gate_tensors),
)
return fusion
def _compute_device(self, weight: torch.Tensor) -> torch.device:
"""Where to reconstruct a delta.
H3's per-block modulation projection is 96768x2688, so rebuilding all
343 patched parameters is a few TFLOP of rank-64 products. On CPU that
adds minutes to a load that already has a startup deadline, so the
accelerator does the arithmetic whenever there is one.
"""
if weight.device.type != "cpu":
return weight.device
if self._device is None:
# Ask the platform rather than PyTorch's global accelerator
# registry, so an out-of-tree backend controls its own placement.
self._device = current_omni_platform.get_torch_device()
return self._device
@staticmethod
def _widen(tensor: torch.Tensor, device: torch.device) -> torch.Tensor:
"""Move to ``device``, then widen to float32.
Asking ``Tensor.to`` for a device and a dtype at once converts on the
host and ships twice the bytes; splitting it moves bfloat16 and widens
on the accelerator.
"""
return tensor.to(device, non_blocking=True).to(torch.float32)
def fuse(self, name: str, weight: torch.Tensor) -> torch.Tensor:
"""Return ``weight`` with this adapter's contribution added."""
patch = self._patches.get(name)
if patch is None:
return weight
self._applied.add(name)
device = self._compute_device(weight)
delta: torch.Tensor | None = None
if patch.low_rank:
if patch.layout in _QKV_SLOTS:
per_slot = {
slot: self._widen(b, device) @ self._widen(a, device) for slot, (a, b) in patch.low_rank.items()
}
delta = _place_in_grouped_qkv(per_slot, head_dim=self._head_dim)
else:
a, b = patch.low_rank[patch.layout]
if patch.layout == _SWAP_HALVES:
# Permuting the rows of B permutes the rows of the product,
# so swap the rank-64 factor instead of the full delta.
b = _swap_halves(b)
delta = self._widen(b, device) @ self._widen(a, device)
if patch.diff is not None:
diff = self._widen(patch.diff, device)
delta = diff if delta is None else delta + diff
if delta is None:
return weight
if delta.shape != weight.shape:
raise FastH3AdapterError(
f"FastH3 delta for {name} has shape {tuple(delta.shape)}, parameter is {tuple(weight.shape)}"
)
# Leave the result on the compute device. These weights are bound for
# the accelerator anyway, so returning them to host memory would pay a
# device-to-host copy of the whole checkpoint only for the loader to
# send it straight back: measured at 152s against 15s for 60 GiB of
# patched projections, against 17s for the unavoidable upload alone.
# Fold the base weight into the freshly built delta in place. Promoting
# the weight to float32 on its own would allocate two more buffers the
# size of the parameter, and H3's largest patched projection is 0.5 GiB.
return delta.add_(weight.to(device, non_blocking=True)).to(weight.dtype)
def apply(self, weights: Iterable[tuple[str, torch.Tensor]]) -> Iterator[tuple[str, torch.Tensor]]:
"""Fuse every streamed checkpoint tensor on its way into the model."""
if self._applied:
# ``validate_fully_applied`` released the deltas, so a second stream
# would fuse nothing and then pass its own completeness check: the
# server would serve base H3 weights on the student's ladder.
raise FastH3AdapterError(f"{self._source} has already been fused into this checkpoint")
for name, weight in weights:
yield name, self.fuse(name, weight)
def validate_fully_applied(self) -> None:
"""Close the fusion: every edit must have met its parameter.
A silently unapplied delta is the failure mode that matters here: the
model would load and generate, just not as the distilled student. The
weights are loaded once, so the mapped payloads are dropped afterwards
rather than held for the life of the process.
"""
missing = sorted(set(self._patches) - self._applied)
if missing:
raise FastH3AdapterError(
f"FastH3 adapter edits {len(missing)} parameters the checkpoint never provided: {missing[:5]}"
)
for patch in self._patches.values():
patch.low_rank.clear()
patch.diff = None
def check_serving_contract(
self,
*,
partition: str,
od_config: Any,
video_shift: float,
audio_shift: float,
) -> None:
"""Hold a starting server to the ladder this student was trained on."""
if partition == "ref2va":
raise ValueError("FastH3 preview v1 distills T2VA only, so it cannot serve a Ref2VA partition")
offloads = [
flag
for flag in ("enable_cpu_offload", "enable_layerwise_offload", "enable_distributed_layerwise_offload")
if getattr(od_config, flag, False)
]
if offloads:
# A host-weight plan installs the transformer without going through
# load_weights(), which is where the fusion and its completeness
# check live. Serving base H3 weights under a four-step schedule
# would otherwise degrade output with nothing to signal it.
raise ValueError(
f"FastH3 is fused while the checkpoint streams in, so it cannot be combined with "
f"{sorted(offloads)}. Serve it without offload."
)
if self.requires_vsa:
raise ValueError(
f"{self.source} is a Video Sparse Attention variant of FastH3, and its "
"compression gates have no counterpart in vLLM-Omni's dense H3 attention. Use the "
"release's Dense variant until VSA lands for H3's packed sequence."
)
logger.info(
"FastH3 adapter active: sigma points %s for %d transformer forwards, "
"flow_shift=%g, audio_flow_shift=%g, tasks=%s",
list(self.base_schedule),
FASTH3_DENOISE_STEPS,
video_shift,
audio_shift,
sorted(FASTH3_SUPPORTED_TASKS),
)
def check_task(self, task: str) -> None:
"""Refuse a task this preview never distilled."""
if task not in FASTH3_SUPPORTED_TASKS:
raise OmniClientError(
f"FastH3 preview v1 distills {sorted(FASTH3_SUPPORTED_TASKS)} only, got task={task!r}"
)
def check_request(self, sampling: Any, *, video_shift: float, audio_shift: float) -> None:
"""Refuse a request that would sample the student off its rungs."""
if sampling.lora_request is not None:
# The adapter is already in the weights and the dynamic LoRA manager
# is skipped, so nothing would apply the requested one. Serving the
# request anyway would quietly ignore it.
raise OmniClientError(
f"this server fused {self.source} into the checkpoint at startup, so per-request "
"lora is unavailable; drop the lora field"
)
if int(sampling.num_inference_steps or 0) != FASTH3_DENOISE_STEPS:
raise OmniClientError(
f"FastH3 is a four-step student and requires num_inference_steps={FASTH3_DENOISE_STEPS} "
"(one transformer forward per sigma interval)"
)
# The checkpoint's per-modality shifts turn the release's positions into
# the noise levels the student was distilled at, so a request that moves
# them samples where it was never trained.
extra = sampling.extra_args or {}
for key, expected in (("flow_shift", video_shift), ("audio_flow_shift", audio_shift)):
try:
requested = float(extra.get(key, expected))
except (TypeError, ValueError) as exc:
raise OmniClientError(f"FastH3 requires {key}={expected:g}") from exc
if not math.isclose(requested, expected):
raise OmniClientError(f"FastH3 requires {key}={expected:g}, got {requested:g}")
def resolve_fasth3_fusion(od_config: Any, transformer: MiniMaxH3DiTModel) -> FastH3WeightFusion | None:
"""Claim ``--lora-path`` when it points at a FastH3 adapter.
FastH3 rewrites RMSNorm weights and biases, so it cannot be expressed as a
request-switchable LoRA and is fused into the checkpoint instead. Any other
artifact returns None here and stays on the dynamic LoRA route.
"""
lora_path = getattr(od_config, "lora_path", None)
if isinstance(lora_path, (list, tuple)):
if len(lora_path) != 1:
return None
lora_path = lora_path[0]
if not lora_path:
return None
# Placing a delta into the fused QKV parameter needs the head size, and
# completeness is judged against the model's depth, so the architecture is
# only read once an adapter is actually configured.
arch = transformer.arch
return FastH3WeightFusion.from_path(
lora_path,
head_dim=arch.attention_head_dim,
num_blocks=arch.num_layers,
num_refiner_blocks=arch.token_refiner_num_layers,
)
def _safetensors_metadata(path: Path) -> Mapping[str, str]:
"""The header metadata of a safetensors file, or ``{}`` if unreadable."""
try:
with safe_open(path, framework="pt", device="cpu") as checkpoint:
return checkpoint.metadata() or {}
except Exception: # noqa: BLE001 - a path that will not open is simply not ours
return {}
def _resolve_adapter_file(path: str | Path) -> Path | None:
"""Find the single adapter file at ``path``, or ``None``."""
candidate = Path(path)
if candidate.is_file():
return candidate if candidate.suffix == ".safetensors" else None
if not candidate.is_dir():
return None
named = candidate / "adapter_model.safetensors"
if named.is_file():
return named
files = sorted(candidate.glob("*.safetensors"))
if len(files) == 1:
return files[0]
# The published repository bundles four variants under one root, marked by an
# adapter_manifest.json. Such a bundle is ambiguous rather than loadable - but
# only hold that against a directory that actually carries FastH3 artifacts;
# an unrelated multi-shard LoRA stays on the dynamic route via ``None``.
if (candidate / FASTH3_MANIFEST).is_file() or any(
_is_fasth3_release(_safetensors_metadata(file)) for file in files
):
raise FastH3AdapterError(
f"{candidate} holds several FastH3 adapters; point --lora-path at one variant "
"(for example dense-datafree/adapter_model.safetensors)"
)
return None
__all__ = [
"FASTH3_BASE_MODEL",
"FASTH3_BASE_SCHEDULE",
"FASTH3_DENOISE_STEPS",
"FASTH3_FORMAT",
"FASTH3_SUPPORTED_TASKS",
"FastH3AdapterError",
"FastH3WeightFusion",
"resolve_fasth3_fusion",
]
@@ -117,7 +117,6 @@ from .denoise_loop import (
minimax_h3_publish_denoise_progress,
)
from .encoder import MiniMaxH3Qwen3VLEncoder
from .fasth3 import FastH3WeightFusion, resolve_fasth3_fusion
from .lora import load_minimax_h3_turbo_lora
from .minimax_h3_transformer import (
MiniMaxH3Attention,
@@ -355,24 +354,14 @@ def _minimax_h3_post_process(output, output_type: str = "np"):
The callable crosses the multiprocessing result queue, so it must remain a
module-level function that the standard pickle module can resolve.
``_prepare_minimax_h3_video_output`` already quantises the video to uint8
frames on the accelerator, so there is nothing left to scale or transpose
here.
"""
if not isinstance(output, tuple) or len(output) != 2:
return output
video, audio = output
if video.dtype != torch.uint8 or video.ndim != 5 or video.shape[-1] not in (3, 4):
# Float or channel-first frames would reach the muxer as a black or
# banded video rather than as an error.
raise ValueError(
f"MiniMax-H3 post-processing expects (B, T, H, W, C) uint8, got {tuple(video.shape)} {video.dtype}"
)
if output_type == "latent":
return output
if output_type == "np":
video = video.detach().cpu().numpy()
video = video.detach().float().cpu().permute(0, 2, 3, 4, 1).clamp(0, 1).numpy()
audio = audio.detach().float().cpu().numpy()
video = [sample for sample in video]
return {
@@ -383,16 +372,6 @@ def _minimax_h3_post_process(output, output_type: str = "np"):
}
def _prepare_minimax_h3_video_output(video: torch.Tensor) -> torch.Tensor:
"""Quantize decoded frames in place before worker-to-engine transfer."""
video = video.detach().float()
video.clamp_(0, 1).mul_(255).round_()
return video.permute(0, 2, 3, 4, 1).to(
dtype=torch.uint8,
memory_format=torch.contiguous_format,
)
def _register_dlo_component_cache(cache: BoundedAllocatorCache, *components: Any) -> None:
for component in components:
if component is not None:
@@ -686,8 +665,6 @@ class MiniMaxH3Pipeline(
# Only distilled releases pin a schedule, so the default keeps the legacy
# uniform path available to partially constructed pipelines.
_base_schedule_by_partition: ClassVar[Mapping[str, DMD2SigmaSchedule | None]] = {}
# Set from --lora-path during construction; absent means no FastH3 adapter.
_fasth3: FastH3WeightFusion | None = None
def _load_diffusion_lora_adapter(
self,
@@ -958,15 +935,6 @@ class MiniMaxH3Pipeline(
quant_config=transformer_quant_config,
)
self._fasth3 = resolve_fasth3_fusion(od_config, self.transformer)
if self._fasth3 is not None:
self._fasth3.check_serving_contract(
partition=self.partition,
od_config=od_config,
video_shift=self.default_video_shift,
audio_shift=self.default_audio_shift,
)
if self.load_text_encoder:
self.tokenizer = Qwen2TokenizerFast.from_pretrained(
str(model_path),
@@ -1075,12 +1043,7 @@ class MiniMaxH3Pipeline(
raise ValueError(f"MiniMax-H3 weight source {prefix!r} is not contiguous")
loaded_prefixes.add(prefix)
component = getattr(self, prefix.removesuffix("."))
stream = ((name[len(prefix) :], tensor) for name, tensor in grouped_weights)
if prefix == "transformer." and self._fasth3 is not None:
# Fuse before the model shards anything, which is also the only
# point where the checkpoint's fused QKV/MLP layouts are intact.
stream = self._fasth3.apply(stream)
loaded = component.load_weights(stream)
loaded = component.load_weights((name[len(prefix) :], tensor) for name, tensor in grouped_weights)
if prefix != "text_encoder.":
component.post_load_weights()
loaded_with_prefix.update(prefix + name for name in loaded)
@@ -1093,49 +1056,13 @@ class MiniMaxH3Pipeline(
if component is None:
continue
loaded_with_prefix.update(f"{component_name}.{name}" for name, _ in component.named_parameters())
if self._fasth3 is not None:
self._fasth3.validate_fully_applied()
return loaded_with_prefix
@property
def lora_is_fused(self) -> bool:
"""True when --lora-path was consumed as a load-time weight fusion."""
return self._fasth3 is not None
def _transformer_for_task(self, task: str) -> MiniMaxH3DiTModel:
if task == "ref2va" and hasattr(self, "transformers_ref"):
return self.transformers_ref
return self.transformer
def _resolve_sigma_positions(self, task: str, sampling: Any) -> tuple[tuple[float, ...] | None, int]:
"""Pick the rectified-flow positions this request denoises on.
Returns them explicitly, or ``None`` to leave the uniform ladder to be
derived from the step count, together with the count the rest of the
request speaks in.
"""
if self._fasth3 is not None:
# A fused student carries its own positions; the checkpoint
# underneath it is the many-step teacher, whose schedule does not
# apply. Its five points bound four transformer forwards, and
# forwards is the unit ``check_request``, the pinned-schedule branch
# below and Cache-DiT all speak in.
positions = self._fasth3.base_schedule
return positions, len(positions) - 1
sigma_schedule = self._sigma_schedule_for_request(sampling, task)
if sigma_schedule is None:
return None, int(sampling.num_inference_steps or 50)
# The schedule lists sigma boundaries; the denoise loop runs one step
# per interval, and that count is what requests and Cache-DiT speak in.
num_steps = sigma_schedule.num_inference_steps
requested_steps = sampling.num_inference_steps
if requested_steps is not None and int(requested_steps) != num_steps:
raise OmniClientError(
"this MiniMax H3 checkpoint pins a distilled sigma schedule; num_inference_steps "
f"must be {num_steps} or omitted, got {int(requested_steps)}"
)
return sigma_schedule.base_schedule, num_steps
def _base_schedule_for_task(self, task: str) -> DMD2SigmaSchedule | None:
"""Return the distilled schedule of the partition that serves ``task``."""
partition = "ref2va" if task == "ref2va" else "fl2va"
@@ -1169,8 +1096,6 @@ class MiniMaxH3Pipeline(
raise OmniClientError("MiniMax-H3 Turbo LoRA supports T2VA/FL2VA requests only")
if has_native_lora and task != "t2va":
raise OmniClientError("MiniMax-H3 native LoRA supports T2VA requests only")
if self._fasth3 is not None:
self._fasth3.check_task(task)
return task
def _resolve_shape(
@@ -2183,12 +2108,6 @@ class MiniMaxH3Pipeline(
self._validate_turbo_sampling(sampling)
if has_native_lora:
self._validate_native_sampling(sampling, task=task)
if self._fasth3 is not None:
self._fasth3.check_request(
sampling,
video_shift=self.default_video_shift,
audio_shift=self.default_audio_shift,
)
raw_image = multi_modal_data.get("image")
raw_videos = multi_modal_data.get("video")
@@ -2405,7 +2324,22 @@ class MiniMaxH3Pipeline(
ref_audio_t = audio_lengths[0]
seed = int(sampling.seed if sampling.seed is not None else 42)
base_schedule, num_steps = self._resolve_sigma_positions(task, sampling)
sigma_schedule = self._sigma_schedule_for_request(sampling, task)
if sigma_schedule is None:
base_schedule = None
num_steps = int(sampling.num_inference_steps or 50)
else:
# The schedule lists sigma boundaries; the denoise loop runs one
# step per interval, and that count is what requests and Cache-DiT
# speak in.
base_schedule = sigma_schedule.base_schedule
num_steps = sigma_schedule.num_inference_steps
requested_steps = sampling.num_inference_steps
if requested_steps is not None and int(requested_steps) != num_steps:
raise OmniClientError(
"this MiniMax H3 checkpoint pins a distilled sigma schedule; num_inference_steps "
f"must be {num_steps} or omitted, got {int(requested_steps)}"
)
video_shift = float(extra.get("flow_shift", self.default_video_shift))
audio_shift = float(extra.get("audio_flow_shift", self.default_audio_shift))
quality_plan = self._quality_policy.resolve(
@@ -2472,10 +2406,10 @@ class MiniMaxH3Pipeline(
height=context["height"],
width=context["width"],
)
videos.append(_prepare_minimax_h3_video_output(video))
videos.append(video)
audios.append(audio)
video = videos[0] if len(videos) == 1 else torch.cat(videos, dim=0)
audio = audios[0] if len(audios) == 1 else torch.cat(audios, dim=0)
video = torch.cat(videos, dim=0)
audio = torch.cat(audios, dim=0)
return DiffusionOutput(
output=(video, audio),
post_process_func=get_minimax_h3_post_process_func(self.od_config),
@@ -2777,7 +2711,6 @@ class MiniMaxH3Pipeline(
height=shape["height"],
width=shape["width"],
)
video = _prepare_minimax_h3_video_output(video)
return DiffusionOutput(
output=(video, audio),
post_process_func=get_minimax_h3_post_process_func(self.od_config),
+3 -6
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
@@ -37,11 +37,8 @@ class DMD2SigmaSchedule:
# have to be rejected before the monotonicity check rather than after it.
if any(not math.isfinite(value) for value in values):
raise ValueError("DMD2 base_schedule entries must be finite")
# A student whose training capped the noise level (``max_timestep_ratio``)
# starts just below 1.0 rather than at it, so the opening position is
# bounded rather than pinned; the terminal one is always clean latents.
if not 0.0 < values[0] <= 1.0 or values[-1] != 0.0:
raise ValueError("DMD2 base_schedule must start in (0.0, 1.0] and end at 0.0")
if values[0] != 1.0 or values[-1] != 0.0:
raise ValueError("DMD2 base_schedule must start at 1.0 and end at 0.0")
if any(curr <= nxt for curr, nxt in zip(values, values[1:], strict=False)):
raise ValueError("DMD2 base_schedule must be strictly decreasing")
@@ -598,8 +598,6 @@ class StageDiffusionProc:
- ``omni_replica_id``: cluster-unique replica id within the
stage (logging / metrics only).
"""
from vllm_omni.plugins import load_omni_general_plugins
shutdown_requested = False
set_death_signal(signal.SIGTERM)
@@ -613,11 +611,6 @@ class StageDiffusionProc:
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
# ``spawn`` starts this process with a fresh interpreter, so plugin
# side effects (for example, custom diffusion loader hooks) must be
# applied again before the engine is constructed.
load_omni_general_plugins()
proc = cls(model, od_config)
coord_client: OmniCoordClientForStage | None = None
handshake_ctx: zmq.Context | None = None
+1 -3
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Build native vLLM configuration views for the Omni diffusion runtime."""
@@ -14,7 +14,6 @@ from vllm.transformers_utils.config import get_hf_text_config
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
from vllm_omni.platforms import current_omni_platform
def resolve_diffusion_max_model_len(od_config: OmniDiffusionConfig) -> int:
@@ -228,7 +227,6 @@ def configure_diffusion_vllm_config(vllm_config: VllmConfig, od_config: OmniDiff
max_num_batched_tokens = getattr(od_config, "max_num_batched_tokens", None)
if max_num_batched_tokens is not None:
vllm_config.scheduler_config.max_num_batched_tokens = int(max_num_batched_tokens)
current_omni_platform.configure_diffusion_vllm_config(vllm_config, od_config)
return vllm_config
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Diffusion Model Runner for vLLM-Omni.
@@ -37,10 +37,9 @@ from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
from vllm_omni.diffusion.diffusion_kv.metadata import DiffusionKVMetadata
from vllm_omni.diffusion.diffusion_kv.model_runner_backend import DiffusionKVModelRunnerBackend
from vllm_omni.diffusion.diffusion_kv.paged_attention_adapter import (
DiffusionPagedAttentionMetadata,
DiffusionPagedAttentionRow,
PreparedDiffusionPagedAttentionBatch,
)
from vllm_omni.diffusion.distributed.parallel_state import get_classifier_free_guidance_rank
from vllm_omni.diffusion.forward_context import set_forward_context
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.models.interface import (
@@ -421,19 +420,11 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
raise RuntimeError("Model must be loaded before collecting Diffusion KV cache specs")
cache_layers: dict[str, tuple[Attention, KVCacheSpec]] = {}
for module_path, module in self.pipeline.named_modules():
for layer_name, module in self.pipeline.named_modules():
if not isinstance(module, Attention):
continue
spec = module.get_kv_cache_spec(self.vllm_config)
if spec is not None:
layer_name = module.prefix
if not isinstance(layer_name, str) or not layer_name:
raise RuntimeError(
"Paged Diffusion Attention must expose a non-empty canonical prefix; "
f"module_path={module_path!r}"
)
if layer_name in cache_layers:
raise RuntimeError(f"Duplicate canonical paged Diffusion Attention prefix {layer_name!r}")
cache_layers[layer_name] = (module, spec)
if not cache_layers:
raise RuntimeError(
@@ -465,51 +456,18 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
def refresh_diffusion_kv_block_table_layout(self) -> None:
self.diffusion_kv_backend.refresh_block_table_layout()
def _build_paged_attention_metadata(
def prepare_paged_attention_batch(
self,
metadata: list[DiffusionKVMetadata],
) -> DiffusionPagedAttentionMetadata:
"""Translate Scheduler sequence state into ordered request-level rows."""
rows: list[DiffusionPagedAttentionRow] | tuple[DiffusionPagedAttentionRow, ...],
) -> PreparedDiffusionPagedAttentionBatch:
"""Prepare model-specific query/write spans against installed Worker rows."""
cfg_size = int(getattr(self.od_config.parallel_config, "cfg_parallel_size", 1) or 1)
cfg_rank = get_classifier_free_guidance_rank() if cfg_size > 1 else None
prefill_rows: list[DiffusionPagedAttentionRow] = []
denoise_rows: list[DiffusionPagedAttentionRow] = []
for request_metadata in metadata:
sequences = request_metadata.sequences
if cfg_rank is not None and len(sequences) > 1:
if len(sequences) != cfg_size:
raise ValueError(
"Paged CFG parallel execution requires one Scheduler sequence per CFG rank: "
f"request={request_metadata.request_id!r}, cfg_size={cfg_size}, rows={len(sequences)}"
)
sequences = (sequences[cfg_rank],)
for sequence in sequences:
active_seq_len = sequence.prefix_len + sequence.target_len
if active_seq_len > sequence.seq_len:
raise ValueError(
"Paged denoise span exceeds its Scheduler allocation: "
f"request={request_metadata.request_id!r}, sequence={sequence.sequence_id}, "
f"active={active_seq_len}, allocated={sequence.seq_len}"
)
prefill_rows.append(
DiffusionPagedAttentionRow(
request_id=request_metadata.request_id,
sequence_id=sequence.sequence_id,
query_len=sequence.seq_len,
seq_len=sequence.seq_len,
)
)
denoise_rows.append(
DiffusionPagedAttentionRow(
request_id=request_metadata.request_id,
sequence_id=sequence.sequence_id,
query_len=sequence.target_len,
seq_len=active_seq_len,
kv_start_pos=sequence.prefix_len,
)
)
return DiffusionPagedAttentionMetadata(tuple(prefill_rows), tuple(denoise_rows))
return self.diffusion_kv_backend.prepare_paged_attention_batch(rows)
def activate_paged_attention(self, batch: PreparedDiffusionPagedAttentionBatch):
"""Expose a prepared paged batch to Omni Attention for one forward."""
return self.diffusion_kv_backend.activate_paged_attention(batch)
def clear_prompt_embed_cache(self) -> None:
"""Evict all cached text-encoder outputs (e.g. between training epochs).
@@ -665,8 +623,6 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
kv_prefetch_job: KVPrefetchJob | None = None,
record_name: str,
record_output_peak_memory: bool = True,
in_diffusion_kv_memory_profile: bool = False,
diffusion_kv_metadata: list[DiffusionKVMetadata] | None = None,
) -> BatchRunnerOutput:
assert self.pipeline is not None, "Model not loaded. Call load_model() first."
if not reqs:
@@ -699,27 +655,7 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
if is_primary and record_output_peak_memory:
current_omni_platform.reset_peak_memory_stats()
paged_kv_runtime = None
paged_kv_context: AbstractContextManager[Any] = nullcontext()
if diffusion_kv_metadata is not None:
if len(diffusion_kv_metadata) != len(reqs):
raise ValueError(
"Diffusion KV metadata count must match the request batch: "
f"metadata={len(diffusion_kv_metadata)}, requests={len(reqs)}"
)
paged_metadata = self._build_paged_attention_metadata(diffusion_kv_metadata)
paged_kv_runtime, paged_kv_context = self.diffusion_kv_backend.activate_paged_attention_metadata(
paged_metadata
)
with (
set_forward_context(
vllm_config=self.vllm_config,
omni_diffusion_config=od_config,
paged_kv_runtime=paged_kv_runtime,
in_diffusion_kv_memory_profile=in_diffusion_kv_memory_profile,
),
paged_kv_context,
):
with set_forward_context(vllm_config=self.vllm_config, omni_diffusion_config=od_config):
with record_function(record_name):
raw_outputs = self.pipeline.forward(batch)
outputs = _normalize_pipeline_outputs(
@@ -797,7 +733,6 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
require_request_batch_support=False,
kv_prefetch_job=kv_prefetch_job,
record_name="pipeline_forward",
diffusion_kv_metadata=[diffusion_kv_metadata] if diffusion_kv_metadata is not None else None,
)
output = runner_output.runner_outputs[0].result
assert output is not None
@@ -838,7 +773,6 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
scheduler_output,
validate_kv_metadata=False,
record_output_peak_memory=False,
in_diffusion_kv_memory_profile=True,
)
else:
runner_output = self._execute_request_list(
@@ -851,7 +785,6 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
# peak counters. Resetting them here would discard request
# preparation allocations and understate the budget.
record_output_peak_memory=False,
in_diffusion_kv_memory_profile=True,
)
current_omni_platform.synchronize()
finally:
@@ -891,12 +824,6 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
allow_single_output=False,
require_request_batch_support=True,
record_name="pipeline_forward_batch",
diffusion_kv_metadata=[
new_req.diffusion_kv_metadata
for new_req in scheduler_output.scheduled_new_reqs
if new_req.diffusion_kv_metadata is not None
]
or None,
)
finally:
if installed_request_ids:
@@ -1057,7 +984,6 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
*,
validate_kv_metadata: bool,
record_output_peak_memory: bool,
in_diffusion_kv_memory_profile: bool = False,
) -> BatchRunnerOutput:
"""Execute one step with explicit validation and profiling policy."""
@@ -1097,7 +1023,6 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
return self._execute_stepwise_core(
scheduler_output,
record_output_peak_memory=record_output_peak_memory,
in_diffusion_kv_memory_profile=in_diffusion_kv_memory_profile,
)
except Exception:
if installed_request_ids:
@@ -1109,7 +1034,6 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
scheduler_output: DiffusionSchedulerOutput,
*,
record_output_peak_memory: bool,
in_diffusion_kv_memory_profile: bool = False,
) -> BatchRunnerOutput:
"""Run the denoise step after metadata admission and row installation."""
@@ -1132,14 +1056,10 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
return BatchRunnerOutput.from_list(runner_output_list)
attn_metadata = {}
kv_backend = getattr(self, "diffusion_kv_backend", None)
paged_kv_runtime = kv_backend if getattr(kv_backend, "paged_attention_adapter", None) is not None else None
with set_forward_context(
vllm_config=self.vllm_config,
omni_diffusion_config=self.od_config,
attn_metadata=attn_metadata,
paged_kv_runtime=paged_kv_runtime,
in_diffusion_kv_memory_profile=in_diffusion_kv_memory_profile,
):
clear_pipeline_stage_durations(self.pipeline)
noise_pred = self.pipeline.denoise_step(input_batch, states=states)
+6 -18
View File
@@ -249,6 +249,11 @@ class DiffusionWorker:
# requests, which only carry their request_id in subsequent ticks.
self._step_lora_state: dict[str, tuple[LoRARequest | None, float]] = {}
self.stage_id = getattr(od_config, "stage_id", 0)
if self.od_config.diffusion_kv_mode is DiffusionKVCacheMode.PAGED_SCHEDULER:
logger.warning_once(
"paged_scheduler initializes native paged KV storage, but no production diffusion model uses the "
"paged-attention adapter yet; model attention remains on the dense path."
)
self.init_device()
# Create model runner — one decision chain, in precedence order:
# 1. explicit od_config.diffusion_model_runner_cls (user override),
@@ -533,16 +538,7 @@ class DiffusionWorker:
def init_lora_manager(self) -> None:
"""Initialize the LoRA manager for this worker."""
pipeline = self.model_runner.pipeline
if pipeline is None:
return
# A release whose weights cannot be expressed as switchable LoRA layers
# is fused into the checkpoint while the pipeline loads. There is then
# no adapter left to register, and handing the same path to the manager
# would only fail on a format it does not accept.
if getattr(pipeline, "lora_is_fused", False):
logger.info("LoRA was fused into the checkpoint at load time; skipping the dynamic LoRA manager.")
if self.model_runner.pipeline is None:
return
lora_path = self.od_config.lora_path
@@ -743,15 +739,11 @@ class DiffusionWorker:
logger.warning("LoRA activation skipped: %s", exc)
def remove_lora(self, adapter_id: int) -> bool:
if self.lora_manager is None:
return False
return self.lora_manager.remove_adapter(adapter_id)
def add_lora(self, lora_request: LoRARequest) -> bool:
# NOTE (Alex): We have not implemented the API routing
# for the frontend server yet.
if self.lora_manager is None:
return False
return self.lora_manager.add_adapter(lora_request)
def submit_interaction(
@@ -764,13 +756,9 @@ class DiffusionWorker:
self.model_runner.submit_interaction(request_id, interaction)
def list_loras(self) -> list[int]:
if self.lora_manager is None:
return []
return self.lora_manager.list_adapters()
def pin_lora(self, adapter_id: int) -> bool:
if self.lora_manager is None:
return False
return self.lora_manager.pin_adapter(adapter_id)
def sleep(self, level: int = 1) -> int:
@@ -401,15 +401,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
if is_segment_finished:
# The queued FIFO item now owns the old segment. Start the next
# segment's deduplication watermark before the worker sends it.
# Only advance past the current generation when the producer
# maintains a request-side counter that can catch up. On paths
# where the request carries no `_omni_segment_generation`, a
# speculative +1 makes every subsequent frame look stale and
# starves the stream permanently (see #6816).
if getattr(request, "_omni_segment_generation", None) is not None:
self._segment_generation[external_req_id] = generation + 1
else:
self._segment_generation[external_req_id] = generation
self._segment_generation[external_req_id] = generation + 1
self.requests_num_chunks_sent.pop(external_req_id, None)
if reject_reason is not None:
logger.error("Cannot enqueue %s: %s", external_req_id, reject_reason)
+1 -1
View File
@@ -397,7 +397,7 @@ class OmniEngineArgs(EngineArgs):
candidate = os.path.join(local_dir, tokenizer_subfolder)
if os.path.isdir(candidate):
self.tokenizer = candidate
logger.info("Downloaded tokenizer from %s/%s", model_path, tokenizer_subfolder)
logger.info("Downloaded tokenizer from %s/%s", model_path, subfolder)
except Exception as e:
logger.warning("Failed to download tokenizer subfolder: %s", e)
+1 -2
View File
@@ -673,8 +673,7 @@ class StageRuntime:
metadata=plan.metadata,
stage_init_timeout=stage_init_timeout,
batch_size=self._diffusion_batch_size,
use_inline=plan.num_replicas == 1
and bool(self._num_stages == 1 or inline_diffusion or custom_pipeline_args),
use_inline=plan.num_replicas == 1 and bool(inline_diffusion or custom_pipeline_args),
replica_id=plan.replica_id,
omni_master_server=self._get_omni_master_server(),
omni_coordinator_address=self._get_coordinator_address(),
@@ -42,7 +42,6 @@ from vllm_omni.metrics.modality import (
observe_audio_first_packet,
observe_audio_streaming_finalize,
)
from vllm_omni.model_executor.models.minicpmo_4_5.pipeline import MINICPMO45_REFERENCE_AUDIO_KEY
from vllm_omni.model_extras import (
get_extra_body_params,
get_extra_output_params,
@@ -250,44 +249,6 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
return True
return False
async def _attach_minicpmo45_reference_audio(
self,
engine_prompt: TokPrompt,
request: ChatLikeRequest | ResponsesRequest,
) -> None:
if not self._has_minicpmo45_stage():
return
reference_audio_source = getattr(request, "ref_audio", None)
extra_body = getattr(request, "extra_body", None)
model_extra = getattr(request, "model_extra", None)
if reference_audio_source is None and isinstance(extra_body, dict):
reference_audio_source = extra_body.get("ref_audio")
if reference_audio_source is None and isinstance(model_extra, dict):
nested_extra_body = model_extra.get("extra_body")
if isinstance(nested_extra_body, dict):
reference_audio_source = nested_extra_body.get("ref_audio")
if reference_audio_source is None:
reference_audio_source = model_extra.get("ref_audio")
if reference_audio_source is None:
return
if not isinstance(reference_audio_source, str):
raise ValueError("MiniCPM-o 4.5 chat ref_audio must be a URI string")
model_config = self.model_config
media_connector = MediaConnector(
media_io_kwargs=getattr(request, "media_io_kwargs", None),
allowed_local_media_path=getattr(model_config, "allowed_local_media_path", "") or "",
allowed_media_domains=getattr(model_config, "allowed_media_domains", None),
)
reference_waveform, reference_sample_rate = await media_connector.fetch_audio_async(reference_audio_source)
# The renderer keeps processed Stage-0 features, not the source waveform.
# Keep the decoded audio only on the original prompt for the downstream bridge.
cast(dict[str, object], engine_prompt)[MINICPMO45_REFERENCE_AUDIO_KEY] = (
reference_waveform,
reference_sample_rate,
)
def _fix_minicpmo45_audio_stream_output_kinds(
self,
sampling_params_list: list[Any],
@@ -980,8 +941,6 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
prompt_additional_information = self._ensure_prompt_additional_information(engine_prompt)
prompt_additional_information["deferred_multi_modal_data"] = deferred_multi_modal_data
await self._attach_minicpmo45_reference_audio(engine_prompt, request)
speaker = getattr(request, "voice", None) or getattr(request, "speaker", None)
normalized = validate_requested_speaker(speaker, self._get_supported_speakers())
if normalized is not None:
+11 -36
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Shared helper utilities for OpenAI-compatible video generation API.
"""
@@ -15,7 +15,7 @@ from collections import deque
from collections.abc import Generator
from concurrent.futures import Future, ThreadPoolExecutor
from io import BytesIO
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
from typing import TYPE_CHECKING, Any, Literal
import httpx
import numpy as np
@@ -50,9 +50,9 @@ logger = init_logger(__name__)
DEFAULT_AUDIO_SAMPLE_RATE = 24_000
VideoInput: TypeAlias = torch.Tensor | np.ndarray | list[torch.Tensor | np.ndarray | Image.Image]
VideoInput = torch.Tensor | np.ndarray | list[torch.Tensor | np.ndarray | Image.Image]
AudioSample = int | float
AudioInput: TypeAlias = torch.Tensor | np.ndarray | list[AudioSample] | list[list[AudioSample]]
AudioInput = torch.Tensor | np.ndarray | list[AudioSample] | list[list[AudioSample]]
class VideoFrames(list[Image.Image]):
@@ -396,7 +396,7 @@ def _normalize_video_tensor(video_tensor: torch.Tensor) -> np.ndarray:
# Cast to float32 first: bf16 (e.g. SANA-WM's refiner output) has no
# numpy dtype, so ``.numpy()`` below raises on it.
video_tensor = video_tensor.float().clamp(-1, 1) * 0.5 + 0.5
elif video_tensor.dtype != torch.uint8:
else:
video_tensor = video_tensor.to(torch.float32) / 255.0
video_array = video_tensor.numpy()
return _normalize_single_video_array(video_array)
@@ -419,7 +419,7 @@ def _normalize_single_video_array(video_array: np.ndarray) -> np.ndarray:
if np.issubdtype(video_array.dtype, np.floating):
if video_array.size and (video_array.min() < 0.0 or video_array.max() > 1.0):
video_array = np.clip(video_array, -1.0, 1.0) * 0.5 + 0.5
elif video_array.dtype != np.uint8 and np.issubdtype(video_array.dtype, np.integer):
elif np.issubdtype(video_array.dtype, np.integer):
video_array = video_array.astype(np.float32) / 255.0
return video_array
@@ -435,7 +435,7 @@ def _normalize_video_array(video_array: np.ndarray) -> list[np.ndarray] | np.nda
def _normalize_frames(frames: list[Any]) -> list[np.ndarray]:
"""Normalize a list of frames into numpy arrays, uint8 ones unchanged."""
"""Normalize a list of frames into numpy arrays with values in [0,1]."""
normalized: list[np.ndarray] = []
for frame in frames:
if isinstance(frame, torch.Tensor):
@@ -453,7 +453,7 @@ def _normalize_frames(frames: list[Any]) -> list[np.ndarray]:
if np.issubdtype(frame_array.dtype, np.floating):
if frame_array.size and (frame_array.min() < 0.0 or frame_array.max() > 1.0):
frame_array = np.clip(frame_array, -1.0, 1.0) * 0.5 + 0.5
elif frame_array.dtype != np.uint8 and np.issubdtype(frame_array.dtype, np.integer):
elif np.issubdtype(frame_array.dtype, np.integer):
frame_array = frame_array.astype(np.float32) / 255.0
normalized.append(frame_array)
@@ -461,14 +461,7 @@ def _normalize_frames(frames: list[Any]) -> list[np.ndarray]:
def _coerce_video_to_frames(video: Any) -> list[np.ndarray]:
"""Convert a video payload into a list of normalized frames.
Frames are float32 in [0, 1], except uint8 ones, which pass through: the
muxer's own dtype is uint8, so normalising here would only pay for a
full-size conversion each way. The direct planar path additionally needs
contiguous channel planes and falls back for interleaved RGB whatever the
dtype; the standard muxer takes the uint8 frames as they are.
"""
"""Convert a video payload into a list of normalized float32 frames."""
if isinstance(video, torch.Tensor):
video_array = _normalize_video_tensor(video)
return list(video_array)
@@ -560,14 +553,6 @@ def _coerce_prepared_video_to_uint8_frames(
def _coerce_video_to_uint8_frames(video: Any) -> np.ndarray:
"""Convert a video payload into contiguous uint8 frames shaped (F, H, W, 3)."""
if (
isinstance(video, np.ndarray)
and video.dtype == np.uint8
and video.ndim == 4
and video.shape[-1] == 3
and video.flags.c_contiguous
):
return video
frames, frame_shape, common_dtype = _prepare_video_frames(video)
return _coerce_prepared_video_to_uint8_frames(frames, frame_shape, common_dtype)
@@ -576,8 +561,6 @@ def _direct_planar_fallback_reason(
frames: list[np.ndarray],
frame_shape: tuple[int, ...],
common_dtype: np.dtype,
*,
allow_strided_rgb_planes: bool = False,
) -> str | None:
"""Return a stable reason when direct planar muxing cannot consume frames."""
if len(frame_shape) != 3 or frame_shape[0] <= 0 or frame_shape[1] <= 0 or frame_shape[2] not in (3, 4):
@@ -590,9 +573,7 @@ def _direct_planar_fallback_reason(
):
return "unsupported_dtype"
if not allow_strided_rgb_planes and not all(
frame[..., channel].flags.c_contiguous for frame in frames for channel in range(3)
):
if not all(frame[..., channel].flags.c_contiguous for frame in frames for channel in range(3)):
return "non_contiguous_rgb_planes"
return None
@@ -646,7 +627,6 @@ class _PlanarFrameConverter:
if frame.dtype == np.uint8:
plane_view[:height, :width] = frame[..., channel]
else:
assert scratch is not None
np.copyto(scratch, frame[..., channel], casting="unsafe")
np.clip(scratch, 0.0, 1.0, out=scratch)
scratch *= 255.0
@@ -799,12 +779,7 @@ def _encode_video_bytes(
# input is reported before any muxer is opened.
frames, frame_shape, common_dtype = _prepare_video_frames(video)
effective_audio_sample_rate = _resolve_audio_sample_rate(audio, audio_sample_rate) if audio is not None else None
fallback_reason = _direct_planar_fallback_reason(
frames,
frame_shape,
common_dtype,
allow_strided_rgb_planes=frame_converter is not None and frame_converter.max_workers > 1,
)
fallback_reason = _direct_planar_fallback_reason(frames, frame_shape, common_dtype)
if fallback_reason is not None:
_log_video_encoding_path(
selected_path="legacy_fallback",
+1 -6
View File
@@ -269,11 +269,6 @@ class RealtimeEventCollector:
}
)
def response_is_done(self, response_id: str) -> bool:
return any(
event.get("type") == "response.done" and self.response_id(event) == response_id for event in self.events
)
def errors(self) -> list[dict[str, object]]:
return [event for event in self.events if event.get("type") == "error"]
@@ -678,7 +673,7 @@ class RealtimeDuplexClient:
async def acknowledge_playback(self) -> None:
for response_id in self.events.response_ids:
pcm16 = self.events.audio_bytes(response_id)
if not pcm16 and self.events.response_is_done(response_id):
if not pcm16:
continue
played_ms = len(pcm16) * 1000 // (self.events.output_sample_rate_hz * PCM16_BYTES_PER_SAMPLE)
await self.send_playback_ack(response_id, played_ms)
@@ -412,13 +412,11 @@ class PlaybackLedger:
class ConversationHistory:
messages: list[dict[str, object]] = field(default_factory=list)
item_ids: dict[str, dict[str, object]] = field(default_factory=dict)
history_item_placeholders: dict[str, dict[str, object]] = field(default_factory=dict)
item_audio_text_marks: dict[str, list[DuplexAssistantAudioTextMark]] = field(default_factory=dict)
pending_item_ids: dict[str, dict[str, object]] = field(default_factory=dict)
pending_item_audio_text_marks: dict[str, list[DuplexAssistantAudioTextMark]] = field(default_factory=dict)
pending_item_input_commit_seqs: dict[str, int] = field(default_factory=dict)
pending_truncations_ms: dict[str, int] = field(default_factory=dict)
hard_truncations_ms: dict[str, int] = field(default_factory=dict)
last_assistant_full_message: dict[str, object] | None = None
last_assistant_audio_text_marks: list[DuplexAssistantAudioTextMark] = field(default_factory=list)
assistant_response_snapshots: dict[str, tuple[dict[str, object], tuple[DuplexAssistantAudioTextMark, ...], int]] = (
@@ -473,8 +471,7 @@ class DuplexSession:
@property
def history(self) -> tuple[dict[str, object], ...]:
placeholders = {id(message) for message in self._conversation.history_item_placeholders.values()}
return tuple(dict(message) for message in self._conversation.messages if id(message) not in placeholders)
return tuple(dict(message) for message in self._conversation.messages)
@property
def active_request_id(self) -> str | None:
@@ -524,11 +521,6 @@ class DuplexSession:
return response_id in self._conversation.assistant_response_snapshots
def playback_ack_is_too_late(self, response_id: str, item_id: str) -> bool:
# An earlier ACK reserved this item's exact history position. Later
# ACKs update that slot in place, so they cannot append an old
# assistant turn after a newer user input.
if item_id in self._conversation.item_ids or item_id in self._conversation.history_item_placeholders:
return False
input_commit_seq = self._conversation.pending_item_input_commit_seqs.get(item_id)
if input_commit_seq is None:
snapshot = self._conversation.assistant_response_snapshots.get(response_id)
@@ -538,56 +530,6 @@ class DuplexSession:
input_commit_seq = self._response.active_response_input_commit_seq
return input_commit_seq is not None and self.input_commit_seq > input_commit_seq
def reserve_history_item(self, item_id: str) -> None:
"""Reserve the current history position for a response-owned item."""
if item_id in self._conversation.item_ids or item_id in self._conversation.history_item_placeholders:
return
placeholder: dict[str, object] = {}
self._conversation.history_item_placeholders[item_id] = placeholder
self._conversation.messages.append(placeholder)
def _store_history_item_message(
self,
item_id: str,
message: dict[str, object],
) -> dict[str, object]:
"""Materialize or update a response item without changing its order."""
existing = self._conversation.item_ids.get(item_id)
if existing is not None:
if existing is not message:
existing.clear()
existing.update(message)
return existing
placeholder = self._conversation.history_item_placeholders.pop(item_id, None)
if placeholder is not None:
for index, candidate in enumerate(self._conversation.messages):
if candidate is placeholder:
self._conversation.messages[index] = message
break
else:
self._conversation.messages.append(message)
elif not any(candidate is message for candidate in self._conversation.messages):
self._conversation.messages.append(message)
self._conversation.item_ids[item_id] = message
return message
def _discard_history_item_placeholder(self, item_id: str) -> bool:
placeholder = self._conversation.history_item_placeholders.pop(item_id, None)
if placeholder is None:
return False
self._conversation.messages = [
candidate for candidate in self._conversation.messages if candidate is not placeholder
]
return True
def release_response_history_snapshot(self, response_id: str | None) -> None:
"""Release a final response snapshot after its playback fully commits."""
if response_id is None or response_id == self.active_response_id:
return
self._conversation.assistant_response_snapshots.pop(response_id, None)
self._conversation.hard_truncations_ms.pop(f"item_{response_id}", None)
@property
def playback(self) -> DuplexPlaybackView:
return self._playback.current.snapshot()
@@ -1055,11 +997,7 @@ class DuplexSession:
committed_text = ""
if commit_text and committed_text:
message = {"role": "assistant", "content": committed_text}
item_id = f"item_{response_id}" if response_id is not None else None
if item_id is not None and item_id in self._conversation.history_item_placeholders:
self._store_history_item_message(item_id, message)
else:
self._conversation.messages.append(message)
self._conversation.messages.append(message)
effective_playback_policy = playback_commit_policy or self.config.playback_commit_policy
if (
response_id is not None
@@ -1068,8 +1006,6 @@ class DuplexSession:
and effective_playback_policy == DuplexPlaybackCommitPolicy.ACK_ONLY.value
):
self.register_history_item(f"item_{response_id}", None)
elif response_id is not None and not assistant_text:
self._discard_history_item_placeholder(f"item_{response_id}")
self._response.assistant_text_buffer.clear()
if not preserve_request:
self._response.active_request_id = None
@@ -1098,7 +1034,7 @@ class DuplexSession:
self._conversation.pending_item_input_commit_seqs[item_id] = response_snapshot[2]
if audio_text_marks:
self._conversation.pending_item_audio_text_marks[item_id] = list(copy.deepcopy(audio_text_marks))
pending_audio_ms = self._conversation.pending_truncations_ms.get(item_id)
pending_audio_ms = self._conversation.pending_truncations_ms.pop(item_id, None)
if pending_audio_ms is not None:
self.truncate_history_item(
item_id,
@@ -1106,11 +1042,30 @@ class DuplexSession:
playback=self._playback_cursor_for_item_id(item_id),
)
return
message = self._store_history_item_message(item_id, message)
pending_audio_ms = self._conversation.pending_truncations_ms.get(item_id)
pending_audio_ms = self._conversation.pending_truncations_ms.pop(item_id, None)
if pending_audio_ms is not None:
self._truncate_message_to_audio_ms(
message,
audio_end_ms=pending_audio_ms,
marks=(
response_audio_text_marks
if response_audio_text_marks is not None
else self._response.assistant_audio_text_marks or self._conversation.last_assistant_audio_text_marks
),
playback=self._playback_cursor_for_item_id(item_id),
)
if self._message_text_len(message) <= 0:
try:
self._conversation.messages.remove(message)
except ValueError:
pass
return
self._conversation.item_ids[item_id] = message
self._conversation.pending_item_ids.pop(item_id, None)
self._conversation.pending_item_audio_text_marks.pop(item_id, None)
self._conversation.pending_item_input_commit_seqs.pop(item_id, None)
if response_id is not None:
self._conversation.assistant_response_snapshots.pop(response_id, None)
if message.get("role") == "assistant":
marks = (
response_audio_text_marks
@@ -1119,12 +1074,6 @@ class DuplexSession:
)
if marks:
self._conversation.item_audio_text_marks[item_id] = list(marks)
if pending_audio_ms is not None:
self.truncate_history_item(
item_id,
audio_end_ms=pending_audio_ms,
playback=self._playback_cursor_for_item_id(item_id),
)
def delete_history_item(self, item_id: str) -> bool:
response_id = item_id.removeprefix("item_") if item_id.startswith("item_") else None
@@ -1134,12 +1083,10 @@ class DuplexSession:
self._conversation.pending_item_audio_text_marks.pop(item_id, None)
self._conversation.pending_item_input_commit_seqs.pop(item_id, None)
self._conversation.pending_truncations_ms.pop(item_id, None)
self._conversation.hard_truncations_ms.pop(item_id, None)
removed_placeholder = self._discard_history_item_placeholder(item_id)
if response_id is not None:
self._conversation.assistant_response_snapshots.pop(response_id, None)
if message is None:
return pending is not None or removed_placeholder
return pending is not None
try:
self._conversation.messages.remove(message)
except ValueError:
@@ -1152,43 +1099,8 @@ class DuplexSession:
*,
audio_end_ms: int,
playback: DuplexPlaybackCursor | DuplexPlaybackView | None = None,
hard: bool = False,
) -> bool:
playback = playback or self._playback_cursor_for_item_id(item_id)
audio_end_ms = max(0, int(audio_end_ms))
if hard:
previous_cap_ms = self._conversation.hard_truncations_ms.get(item_id)
self._conversation.hard_truncations_ms[item_id] = (
audio_end_ms if previous_cap_ms is None else min(previous_cap_ms, audio_end_ms)
)
hard_cap_ms = self._conversation.hard_truncations_ms.get(item_id)
if hard_cap_ms is not None:
audio_end_ms = min(audio_end_ms, hard_cap_ms)
response_id = item_id.removeprefix("item_") if item_id.startswith("item_") else None
response_snapshot = (
self._conversation.assistant_response_snapshots.get(response_id) if response_id is not None else None
)
if response_snapshot is not None:
full_message, full_marks, _ = response_snapshot
message = copy.deepcopy(full_message)
changed = self._truncate_message_to_audio_ms(
message,
audio_end_ms=audio_end_ms,
marks=list(full_marks),
playback=playback,
)
if not changed or self._message_text_len(message) <= 0:
self._conversation.pending_truncations_ms[item_id] = max(0, int(audio_end_ms))
return False
self._store_history_item_message(item_id, message)
if full_marks:
self._conversation.item_audio_text_marks[item_id] = list(copy.deepcopy(full_marks))
self._conversation.pending_item_ids.pop(item_id, None)
self._conversation.pending_item_audio_text_marks.pop(item_id, None)
self._conversation.pending_item_input_commit_seqs.pop(item_id, None)
self._conversation.pending_truncations_ms.pop(item_id, None)
return True
message = self._conversation.item_ids.get(item_id)
if message is None:
pending = self._conversation.pending_item_ids.get(item_id)
@@ -1709,10 +1709,6 @@ class OmniDuplexSessionHandler(
}
)
return
# Reserve the response's current history position before any later
# input commit can append a user item. A 0 ms ACK is intentional:
# the response may be active but have no audio delta yet.
session.reserve_history_item(item_id)
elif item_id is not None:
await send_json(
{
@@ -1724,8 +1720,7 @@ class OmniDuplexSessionHandler(
}
)
return
hard_truncate = event.get("truncate") is True
if hard_truncate:
if event.get("truncate") is True:
playback = session.acknowledge_playback(
int(played_ms),
committed_cursor,
@@ -1757,7 +1752,6 @@ class OmniDuplexSessionHandler(
item_id,
audio_end_ms=committed_cursor,
playback=playback,
hard=hard_truncate,
)
elif session.pending_history_item_ids:
# A plain playback ack has no OpenAI item id. Commit the only
@@ -1770,7 +1764,6 @@ class OmniDuplexSessionHandler(
item_id,
audio_end_ms=committed_cursor,
playback=playback,
hard=hard_truncate,
)
elif session.active_response_id is not None:
item_id = f"item_{session.active_response_id}"
@@ -1778,7 +1771,6 @@ class OmniDuplexSessionHandler(
item_id,
audio_end_ms=committed_cursor,
playback=playback,
hard=hard_truncate,
)
elif session.last_assistant_full_message is not None:
if item_id is None and session.history_item_ids:
@@ -1794,7 +1786,6 @@ class OmniDuplexSessionHandler(
item_id,
audio_end_ms=committed_cursor,
playback=playback,
hard=hard_truncate,
)
await send_json(
{
@@ -1811,7 +1802,6 @@ class OmniDuplexSessionHandler(
)
if committed_history and committed_cursor >= max(playback.sent_ms, playback.generated_ms):
session.release_response_playback(response_id)
session.release_response_history_snapshot(response_id)
async def _cancel_active_response(
self,
@@ -1261,7 +1261,6 @@ class DuplexSessionRunnerMixin:
session.truncate_history_item(
item_id,
audio_end_ms=int(audio_end_ms) if isinstance(audio_end_ms, int | float) else 0,
hard=True,
)
if isinstance(item_id, str)
else False
@@ -1,6 +1,3 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# Copyright 2025 Xiaomi Corporation.
import logging
import threading
@@ -544,11 +541,6 @@ class MiMoAudioLLMForConditionalGeneration(nn.Module, SupportsMultiModal, Suppor
architectures=["Qwen2ForCausalLM"],
)
# vLLM 0.28 turned SupportsPP.make_empty_intermediate_tensors from a
# method into a bare annotation, so declaring SupportsPP no longer
# supplies one. Delegate to the inner model, as the other omni talkers do.
self.make_empty_intermediate_tensors = self.model.make_empty_intermediate_tensors
self.device = current_omni_platform.get_torch_device()
# global_sampler MUST stay greedy (do_sample=False) so its token decision
# matches vLLM's external sampler (SamplingParams temperature=0.0). Both
@@ -272,11 +272,6 @@ class MiniCPMO45Code2Wav(nn.Module):
ref_audio,
meta.get("ref_audio_sr"),
)
logger.debug(
"MiniCPM-o Code2Wav selected runtime reference prompt_cache_id=%s prompt_wav=%s",
entry.cache_id,
entry.path,
)
return entry.cache_id, entry.path, cache_key
if previous is not None:
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MiniCPM-o 4.5 pipeline topology (frozen).
Stage 0: Thinker — multimodal understanding + text generation.
@@ -17,7 +17,6 @@ from vllm_omni.config.stage_config import (
)
_PROC = "vllm_omni.model_executor.stage_input_processors.minicpmo_4_5_omni"
MINICPMO45_REFERENCE_AUDIO_KEY = "_minicpmo45_reference_audio"
MINICPMO_4_5_PIPELINE = PipelineConfig(
@@ -17,7 +17,6 @@ from vllm_omni.experimental.fullduplex.engine.intermediate import (
)
from vllm_omni.inputs.data import OmniTokensPrompt
from vllm_omni.model_executor.models.minicpmo_4_5 import MINICPMO45_DUPLEX_CODEC_TOKENS_PER_CHUNK
from vllm_omni.model_executor.models.minicpmo_4_5.pipeline import MINICPMO45_REFERENCE_AUDIO_KEY
logger = logging.getLogger(__name__)
_MINICPMO45_ASYNC_STATE = "_minicpmo45_async_codec_state"
@@ -72,23 +71,6 @@ def _extract_first_audio_ref(multi_modal_data):
return waveform.reshape(-1).cpu(), int(sample_rate)
def _extract_prompt_reference_audio(
prompt_item: OmniTokensPrompt | TextPrompt | None,
) -> tuple[torch.Tensor, int] | None:
if isinstance(prompt_item, Mapping):
multi_modal_data = prompt_item.get("multi_modal_data")
serving_reference_audio = prompt_item.get(MINICPMO45_REFERENCE_AUDIO_KEY)
else:
multi_modal_data = getattr(prompt_item, "multi_modal_data", None)
serving_reference_audio = getattr(prompt_item, MINICPMO45_REFERENCE_AUDIO_KEY, None)
reference_audio = _extract_first_audio_ref(multi_modal_data)
if reference_audio is not None:
return reference_audio
return _extract_first_audio_ref({"audio": serving_reference_audio})
def _extract_native_runtime_ref_audio(data_plane_metadata):
if not isinstance(data_plane_metadata, dict):
return None
@@ -160,17 +142,6 @@ def _codec_config(transfer_manager: Any) -> tuple[int, int]:
return chunk_frames, left_context_frames
def _request_intermediate_section(request: object, section: str) -> dict[str, object]:
merged: dict[str, object] = {}
for attribute in ("additional_information", "model_intermediate_buffer"):
information = getattr(request, attribute, None)
if isinstance(information, Mapping):
values = information.get(section)
if isinstance(values, Mapping):
merged.update(values)
return merged
def _codec_scalars(value: Any) -> list[int]:
"""Normalize one request-routed codec delta to CPU scalar token IDs."""
if value is None:
@@ -378,13 +349,15 @@ def tts2code2wav_async_chunk(
ref_audio = None
ref_audio_sr = None
if int(record["cache_epoch"]) == 0 and chunk_seq == 0:
codes_info = _request_intermediate_section(request, "codes")
meta_info = _request_intermediate_section(request, "meta")
raw_ref_audio = codes_info.get("ref")
raw_ref_audio_sr = meta_info.get("ref_audio_sr")
ref_audio_sr = _coerce_int(raw_ref_audio_sr)
if raw_ref_audio is not None:
ref_audio = torch.as_tensor(raw_ref_audio, dtype=torch.float32).reshape(-1).cpu()
request_info = getattr(request, "additional_information", None)
if isinstance(request_info, Mapping):
codes_info = request_info.get("codes")
meta_info = request_info.get("meta")
raw_ref_audio = codes_info.get("ref") if isinstance(codes_info, Mapping) else None
raw_ref_audio_sr = meta_info.get("ref_audio_sr") if isinstance(meta_info, Mapping) else None
ref_audio_sr = _coerce_int(raw_ref_audio_sr)
if raw_ref_audio is not None:
ref_audio = torch.as_tensor(raw_ref_audio, dtype=torch.float32).reshape(-1).cpu()
finished_tensor = torch.tensor(last_chunk, dtype=torch.bool)
payload = OmniPayloadStruct(
codes=CodesStruct(
@@ -436,9 +409,18 @@ def tts2code2wav_full_payload(
context = [_MINICPMO45_SILENCE_CODE] * left_context_frames if codes else []
output_codes = [*context, *codes]
codes_info = _request_intermediate_section(request, "codes")
meta_info = _request_intermediate_section(request, "meta")
duplex_info = _request_intermediate_section(request, "duplex")
request_info = getattr(request, "additional_information", None)
if not isinstance(request_info, Mapping):
request_info = {}
codes_info = request_info.get("codes")
if not isinstance(codes_info, Mapping):
codes_info = {}
meta_info = request_info.get("meta")
if not isinstance(meta_info, Mapping):
meta_info = {}
duplex_info = request_info.get("duplex")
if not isinstance(duplex_info, Mapping):
duplex_info = {}
ref_audio = codes_info.get("ref")
finished = torch.tensor(True, dtype=torch.bool)
@@ -740,13 +722,11 @@ def llm2tts(
prompt = [prompt]
multi_modal_data = {}
reference_audio_by_request_id = {}
for llm_output, p in zip(llm_outputs, prompt):
if isinstance(p, dict):
multi_modal_data[llm_output.request_id] = p.get("multi_modal_data", None)
else:
multi_modal_data[llm_output.request_id] = getattr(p, "multi_modal_data", None)
reference_audio_by_request_id[llm_output.request_id] = _extract_prompt_reference_audio(p)
for llm_output in llm_outputs:
output = llm_output.outputs[0]
@@ -981,7 +961,8 @@ def llm2tts(
meta["turn_start"] = native_turn_start
if native_segment_end:
meta["segment_end"] = True
ref_audio = reference_audio_by_request_id.get(llm_output.request_id)
req_mm_data = multi_modal_data.get(llm_output.request_id)
ref_audio = _extract_first_audio_ref(req_mm_data)
if ref_audio is None:
ref_audio = _extract_native_runtime_ref_audio(
model_intermediate_buffer.get("duplex"),
+6 -40
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from contextlib import nullcontext
from enum import Enum
@@ -150,12 +150,6 @@ class OmniPlatform(Platform):
)
backend_cls.validate_available()
@classmethod
def supports_diffusion_dense_flash_attention(cls) -> bool:
"""Whether the platform's dense ``FLASH_ATTN`` dependencies exist."""
return True
@classmethod
def supports_torch_inductor(cls) -> bool:
"""Check if the platform supports torch.compile with inductor backend."""
@@ -201,36 +195,17 @@ class OmniPlatform(Platform):
def build_diffusion_kv_attn_metadata(cls, **kwargs: Any) -> dict[str, Any]:
"""Build native attention metadata for the diffusion paged path.
This default is the GPU/common path and uses vLLM's builder. NPU
overrides it to build Ascend attention metadata without making the
shared diffusion adapter import ``vllm_ascend``. ``seq_lens_cpu`` is an
adapter-only convenience value and is removed before calling the
upstream builder.
The common CUDA/ROCm/XPU path uses vLLM's builder. Platforms with a
backend-specific metadata contract can override this hook without
making the diffusion adapter import their optional runtime package.
``seq_lens_cpu`` is an adapter-only convenience value and is removed
before calling the upstream builder.
"""
from vllm.v1.worker.gpu.attn_utils import build_attn_metadata
kwargs.pop("seq_lens_cpu", None)
return build_attn_metadata(**kwargs)
@classmethod
def get_diffusion_paged_kv_attn_backend(cls, attn_backend: type, *, ulysses_degree: int) -> type:
"""Specialize a native paged backend for diffusion execution."""
del ulysses_degree
return attn_backend
@classmethod
def requires_diffusion_paged_kv_prewrite(cls) -> bool:
"""Whether paged attention must write K/V before native execution.
The default GPU path keeps cache-update ownership in its native
attention call. Ascend overrides this because piecewise FIA should
write the complete K/V span once, then read it from cache for every
segment.
"""
return False
@classmethod
def init_diffusion_worker_vllm_config(
cls,
@@ -239,15 +214,6 @@ class OmniPlatform(Platform):
"""Initialize platform-specific state for diffusion worker VllmConfig."""
return None
@classmethod
def configure_diffusion_vllm_config(
cls,
vllm_config: Any,
od_config: Any,
) -> None:
"""Apply platform-specific native cache geometry for diffusion."""
return None
@classmethod
def init_diffusion_model_runner_runtime(
cls,
+2 -84
View File
@@ -1,8 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from contextlib import nullcontext
from functools import cache
from typing import Any
import torch
@@ -22,28 +21,6 @@ _DIFFUSION_PACKED_MODULES_MAPPING = {
}
@cache
def _get_strict_ulysses_paged_backend() -> type:
"""Return an Ascend backend that bypasses vLLM PCP dispatch."""
from vllm_ascend.attention.attention_v1 import (
AscendAttentionBackend,
AscendAttentionBackendImpl,
AscendAttentionMetadataBuilder,
)
class AscendStrictUlyssesPagedBackend(AscendAttentionBackend):
@staticmethod
def get_impl_cls() -> type:
return AscendAttentionBackendImpl
@staticmethod
def get_builder_cls() -> type:
return AscendAttentionMetadataBuilder
return AscendStrictUlyssesPagedBackend
class NPUOmniPlatform(OmniPlatform, NPUPlatform):
"""NPU/Ascend implementation of OmniPlatform.
@@ -100,55 +77,9 @@ class NPUOmniPlatform(OmniPlatform, NPUPlatform):
@classmethod
def init_diffusion_worker_vllm_config(cls, vllm_config: Any) -> None:
from vllm_ascend.ascend_config import init_ascend_config
from vllm_ascend.utils import adapt_patch
# Omni's custom DiffusionWorker does not pass through vLLM-Ascend's
# NPUWorker constructor, where worker-local patches are normally
# installed. In particular, AscendBlockTables needs the patched
# non-UVA buffer implementation on NPU.
adapt_patch()
init_ascend_config(vllm_config)
@classmethod
def configure_diffusion_vllm_config(cls, vllm_config: Any, od_config: Any) -> None:
"""Use the block geometry required by Ascend's native paged kernel."""
if getattr(od_config, "diffusion_kv_mode", None) is None:
return
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
if od_config.diffusion_kv_mode is not DiffusionKVCacheMode.PAGED_SCHEDULER:
return
from vllm_ascend.attention.attention_v1 import AscendAttentionBackend
supported_sizes = [
size for size in AscendAttentionBackend.get_supported_kernel_block_sizes() if type(size) is int and size > 0
]
if not supported_sizes:
raise RuntimeError("Ascend paged attention did not expose an integer kernel block size")
# vLLM's generic default is 16, while the Ascend FIA backend stores
# cache pages as 128-token blocks. Set the Manager geometry
# before KV specs are collected so Scheduler and Worker agree.
vllm_config.cache_config.block_size = supported_sizes[0]
@classmethod
def requires_diffusion_paged_kv_prewrite(cls) -> bool:
"""Write the full K/V span once before piecewise FIA segments."""
return True
@classmethod
def get_diffusion_paged_kv_attn_backend(cls, attn_backend: type, *, ulysses_degree: int) -> type:
"""Keep strict Ulysses paged FIA out of vLLM's PCP implementation."""
del cls
if ulysses_degree <= 1:
return attn_backend
from vllm_ascend.attention.attention_v1 import AscendAttentionBackend
if not isinstance(attn_backend, type) or not issubclass(attn_backend, AscendAttentionBackend):
return attn_backend
return _get_strict_ulysses_paged_backend()
@classmethod
def get_diffusion_kv_block_tables_cls(cls) -> type:
from vllm_ascend.worker.v2.block_table import AscendBlockTables
@@ -280,14 +211,6 @@ class NPUOmniPlatform(OmniPlatform, NPUPlatform):
logger.debug("Falling back to diffusion attention backend SDPA")
return DiffusionAttentionBackendEnum.TORCH_SDPA.get_path()
@classmethod
def supports_diffusion_dense_flash_attention(cls) -> bool:
"""Return whether MindIE-SD is installed for dense NPU FlashAttention."""
from importlib.util import find_spec
return find_spec("mindiesd") is not None
@classmethod
def supports_torch_inductor(cls) -> bool:
return False
@@ -322,12 +245,7 @@ class NPUOmniPlatform(OmniPlatform, NPUPlatform):
"""
try:
torch.npu.current_stream().synchronize()
# The async output worker uses the public ``torch.Stream`` API.
# With torch_npu 2.10, a native ``torch.npu.Event`` cannot be
# consumed by that wrapper stream (the reverse direction is
# supported), while ``torch.Event`` is dispatched to the active
# NPU backend and remains cross-stream compatible.
event = torch.Event()
event = torch.npu.Event()
event.record()
return event
except Exception:
@@ -1,13 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import logging
import time
from collections.abc import Mapping
from copy import copy
from dataclasses import replace
from copy import copy, deepcopy
from typing import Any, NamedTuple
import numpy as np
@@ -395,10 +394,9 @@ class NPUARModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
capturer = self.routed_experts_capturer
if capturer is not None and hasattr(capturer, "finalize_pending_copy"):
capturer.finalize_pending_copy()
profiling_chunk_config = self.ascend_config.scheduler_config.profiling_chunk_config
if profiling_chunk_config.enabled and profiling_chunk_config.need_timing:
if self.ascend_config.scheduler_config.profiling_chunk_config.need_timing:
if getattr(scheduler_output, "disable_profiling_timing", False):
profiling_chunk_config.need_timing = False
self.ascend_config.scheduler_config.profiling_chunk_config.need_timing = False
else:
self._sync_device()
self._execution_start_time = time.perf_counter()
@@ -454,17 +452,21 @@ class NPUARModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
set(scheduler_output.finished_req_ids),
self.input_batch,
)
# self._draft_token_ids is None when `input_fits_in_drafter=False`
# and there is no draft tokens scheduled. so it need to update the
# spec_decoding info in scheduler_output with async_scheduling.
# use deepcopy to avoid the modification has influence on the
# scheduler_output in engine core process.
# TODO(Ronald1995): deepcopy is expensive when there is a large
# number of requests, optimize it later.
if (
self.use_async_scheduling
and self.num_spec_tokens
and self._draft_token_ids is None # type: ignore[has-type]
):
scheduler_output = deepcopy(scheduler_output)
# -------------------------------------- Omni-new -------------------------------------------------
if self.speculative_config is not None and self.speculative_config.use_ngram_gpu():
num_scheduled_tokens_copy = scheduler_output.num_scheduled_tokens.copy()
spec_decode_tokens_copy = scheduler_output.scheduled_spec_decode_tokens.copy()
scheduler_output = replace(
scheduler_output,
num_scheduled_tokens=num_scheduled_tokens_copy,
scheduled_spec_decode_tokens=spec_decode_tokens_copy,
)
if has_kv_transfer_group():
kv_connector_metadata = scheduler_output.kv_connector_metadata
if kv_connector_metadata is not None:
@@ -483,7 +485,7 @@ class NPUARModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
# -------------------------------------- Omni-new -------------------------------------------------
if has_ec_transfer() and get_ec_transfer().is_producer:
self._start_dump_data(scheduled_tokens=scheduler_output.num_scheduled_tokens)
self._start_dump_data()
with self.maybe_get_ec_connector_output(
scheduler_output,
encoder_cache=self.encoder_cache,
@@ -534,7 +536,7 @@ class NPUARModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
"it when the requests need prompt logprobs"
)
self._start_dump_data(scheduled_tokens=scheduler_output.num_scheduled_tokens)
self._start_dump_data()
num_reqs = self.input_batch.num_reqs
req_ids = self.input_batch.req_ids
tokens = [scheduler_output.num_scheduled_tokens[i] for i in req_ids]
@@ -710,6 +712,13 @@ class NPUARModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
with record_function_or_nullcontext("EPLB weight D2D"):
self.eplb_updator.forward_before()
# Set cudagraph mode to none if calc_kv_scales is true.
# KV scales calculation involves dynamic operations that are incompatible
# with CUDA graph capture.
if self.calculate_kv_scales: # type: ignore[has-type]
cudagraph_mode = CUDAGraphMode.NONE
# Mark KV scales as calculated after the first forward pass
self.calculate_kv_scales = False # type: ignore[has-type]
# Encoder-decoder models can only compile the pure decode steps where no
# encoder inputs are present. Use eager for the first pass.
num_encoder_reqs = len(scheduler_output.scheduled_encoder_inputs)
@@ -910,7 +919,6 @@ class NPUARModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
def sample_tokens(
self, grammar_output: GrammarOutput | None
) -> OmniModelRunnerOutput | AsyncModelRunnerOutput | IntermediateTensors:
profiling_chunk_config = self.ascend_config.scheduler_config.profiling_chunk_config
kv_connector_output = self.kv_connector_output
self.kv_connector_output = None
pp = get_pp_group()
@@ -1286,10 +1294,8 @@ class NPUARModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
model_runner_output.omni_connector_output = self.get_omni_connector_output()
# -------------------------------------- Omni-new -------------------------------------------------
if (
profiling_chunk_config.enabled
and profiling_chunk_config.need_timing
and hasattr(self, "_execution_start_time")
if self.ascend_config.scheduler_config.profiling_chunk_config.need_timing and hasattr(
self, "_execution_start_time"
):
self._sync_device()
model_runner_output.execution_time_ms = (time.perf_counter() - self._execution_start_time) * 1000.0
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
@@ -7,8 +7,7 @@ import gc
import logging
import time
from collections.abc import Mapping
from copy import copy
from dataclasses import replace
from copy import copy, deepcopy
import numpy as np
import torch
@@ -92,24 +91,25 @@ class NPUGenerationModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin
capturer = self.routed_experts_capturer
if capturer is not None and hasattr(capturer, "finalize_pending_copy"):
capturer.finalize_pending_copy()
profiling_chunk_config = self.ascend_config.scheduler_config.profiling_chunk_config
if profiling_chunk_config.enabled and profiling_chunk_config.need_timing:
if self.ascend_config.scheduler_config.profiling_chunk_config.need_timing:
if getattr(scheduler_output, "disable_profiling_timing", False):
profiling_chunk_config.need_timing = False
self.ascend_config.scheduler_config.profiling_chunk_config.need_timing = False
else:
self._sync_device()
self._execution_start_time = time.perf_counter()
if self.execute_model_state is not None:
raise RuntimeError("State error: sample_tokens() must be called after execute_model() returns None.")
if self.speculative_config is not None and self.speculative_config.use_ngram_gpu():
num_scheduled_tokens_copy = scheduler_output.num_scheduled_tokens.copy()
spec_decode_tokens_copy = scheduler_output.scheduled_spec_decode_tokens.copy()
scheduler_output = replace(
scheduler_output,
num_scheduled_tokens=num_scheduled_tokens_copy,
scheduled_spec_decode_tokens=spec_decode_tokens_copy,
)
# self._draft_token_ids is None when `input_fits_in_drafter=False`
# and there is no draft tokens scheduled. so it need to update the
# spec_decoding info in scheduler_output with async_scheduling.
# use deepcopy to avoid the modification has influence on the
# scheduler_output in engine core process.
# TODO(Ronald1995): deepcopy is expensive when there is a large
# number of requests, optimize it later.
if (
self.use_async_scheduling and self.num_spec_tokens and self._draft_token_ids is None # type: ignore[has-type]
):
scheduler_output = deepcopy(scheduler_output)
if has_kv_transfer_group():
kv_connector_metadata = scheduler_output.kv_connector_metadata
@@ -142,7 +142,7 @@ class NPUGenerationModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin
# -------------------------------------- Omni-new -------------------------------------------------
if has_ec_transfer() and get_ec_transfer().is_producer:
self._start_dump_data(scheduled_tokens=scheduler_output.num_scheduled_tokens)
self._start_dump_data()
with self.maybe_get_ec_connector_output(
scheduler_output,
encoder_cache=self.encoder_cache,
@@ -179,7 +179,7 @@ class NPUGenerationModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin
"it when the requests need prompt logprobs"
)
self._start_dump_data(scheduled_tokens=scheduler_output.num_scheduled_tokens)
self._start_dump_data()
num_reqs = self.input_batch.num_reqs
req_ids = self.input_batch.req_ids
tokens = [scheduler_output.num_scheduled_tokens[i] for i in req_ids]
@@ -349,6 +349,13 @@ class NPUGenerationModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin
with record_function_or_nullcontext("EPLB weight D2D"):
self.eplb_updator.forward_before()
# Set cudagraph mode to none if calc_kv_scales is true.
# KV scales calculation involves dynamic operations that are incompatible
# with CUDA graph capture.
if self.calculate_kv_scales: # type: ignore[has-type]
cudagraph_mode = CUDAGraphMode.NONE
# Mark KV scales as calculated after the first forward pass
self.calculate_kv_scales = False # type: ignore[has-type]
# Encoder-decoder models can only compile the pure decode steps where no
# encoder inputs are present. Use eager for the first pass.
num_encoder_reqs = len(scheduler_output.scheduled_encoder_inputs)
@@ -424,7 +431,6 @@ class NPUGenerationModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin
def sample_tokens(
self, grammar_output: GrammarOutput | None
) -> OmniModelRunnerOutput | AsyncModelRunnerOutput | IntermediateTensors:
profiling_chunk_config = self.ascend_config.scheduler_config.profiling_chunk_config
kv_connector_output = self.kv_connector_output
self.kv_connector_output = None
@@ -540,10 +546,8 @@ class NPUGenerationModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin
if self.speculative_config is not None:
self.finalize_kv_connector()
if (
profiling_chunk_config.enabled
and profiling_chunk_config.need_timing
and hasattr(self, "_execution_start_time")
if self.ascend_config.scheduler_config.profiling_chunk_config.need_timing and hasattr(
self, "_execution_start_time"
):
self._sync_device()
output.execution_time_ms = (time.perf_counter() - self._execution_start_time) * 1000.0
@@ -852,9 +856,9 @@ class NPUGenerationModelRunner(OmniNPUModelRunner, OmniConnectorModelRunnerMixin
if get_pp_group().is_first_rank:
intermediate_tensors = None
else:
# When PP and sequence parallelism are enabled, during dummy_run the estimated space should divide
# num_tokens by tp_size; otherwise, on non-first PP ranks it would effectively perform an extra
# all-gather, leading to incorrect memory estimation and potentially causing OOM.
# When PP and flashcomm1 are enabled, during dummy_run the estimated space should divide num_tokens by
# tp_size; otherwise, on non-first PP ranks it would effectively perform an extra all-gather, leading
# to incorrect memory estimation and potentially causing OOM.
intermediate_tokens = num_tokens_padded
if enable_sp():
tp_size = get_tensor_model_parallel_world_size()
@@ -335,9 +335,9 @@ class OmniNPUModelRunner(OmniGPUModelRunner, NPUModelRunner):
if get_pp_group().is_first_rank:
intermediate_tensors = None
else:
# When PP and sequence parallelism are enabled, during dummy_run the estimated space should divide
# num_tokens by tp_size; otherwise, on non-first PP ranks it would effectively perform an extra
# all-gather, leading to incorrect memory estimation and potentially causing OOM.
# When PP and flashcomm1 are enabled, during dummy_run the estimated space should divide num_tokens by
# tp_size; otherwise, on non-first PP ranks it would effectively perform an extra all-gather, leading
# to incorrect memory estimation and potentially causing OOM.
intermediate_tokens = num_tokens_padded
if enable_sp():
tp_size = get_tensor_model_parallel_world_size()
@@ -448,7 +448,7 @@ class OmniNPUModelRunner(OmniGPUModelRunner, NPUModelRunner):
1. Accepts num_tokens_padded as required by NPUModelRunner
2. Injects omni-specific kwargs (runtime_additional_information)
3. Caches model output for multimodal results
4. Handles NPU-specific post-forward logic (graph params update)
4. Handles NPU-specific post-forward logic (graph params update, SP all-gather)
"""
# Omni-specific: build and inject extra model kwargs
model_kwargs_extra = self._build_model_kwargs_extra()
@@ -482,6 +482,10 @@ class OmniNPUModelRunner(OmniGPUModelRunner, NPUModelRunner):
# Omni-specific: cache model output for later sample_tokens
self._omni_last_model_output = model_output
# NPU-specific: all-gather for sequence parallelism
if forward_context.flash_comm_v1_enabled and not isinstance(model_output, IntermediateTensors):
model_output = self._all_gather_hidden_states_and_aux(model_output)
return model_output
def _gather_runtime_additional_information(self):