Merge branch 'main' into fix/tp-local-kv-recv-consensus

This commit is contained in:
Gao Han authored and GitHub committed 2026-08-01 23:23:52 +08:00
commit 62aeffd15c
61 files changed
+1881 -224

No files matched your search

+4
View File
@@ -26,6 +26,10 @@ RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \
python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation
RUN python3 -m pip install -v --no-deps \
"stepaudio2-minicpmo>=0.1.1" \
"step-audio2>=1.0.0"
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT []
+4
View File
@@ -26,6 +26,10 @@ RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \
python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation
RUN python3 -m pip install -v --no-deps \
"stepaudio2-minicpmo>=0.1.1" \
"step-audio2>=1.0.0"
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT []
+1
View File
@@ -35,6 +35,7 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
"psutil>=7.2.0" \
"soundfile>=0.13.1" \
"pyttsx3>=2.99" \
"opencc==1.4.1" \
"opencv-python>=4.12.0.88" \
"imageio[ffmpeg]>=0.6.0" \
"jiwer>=3.0.0" \
+1
View File
@@ -35,6 +35,7 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
"psutil>=7.2.0" \
"soundfile>=0.13.1" \
"pyttsx3>=2.99" \
"opencc==1.4.1" \
"opencv-python>=4.12.0.88" \
"imageio[ffmpeg]>=0.6.0" \
"jiwer>=3.0.0" \
+7 -1
View File
@@ -148,7 +148,13 @@ Upstream vLLM's `Scheduler.make_stats()` runs on every AR generation step, retur
`OmniSchedulerMixin.make_stats()` (in `vllm_omni/core/sched/omni_scheduler_mixin.py`) throttles stats emission to at most once per second **per scheduler** — i.e. per `(stage, replica)` since each replica owns its own scheduler instance. Between intervals it returns `None`, which the engine core skips serializing. This keeps gauges fresh enough for Prometheus scrapes (typically 15-30s intervals) while eliminating the per-step overhead.
The orchestrator side does not add its own throttle on top: the per-replica recording loop gates only on `raw_outputs.scheduler_stats is not None` (i.e. this replica's scheduler passed its own 1 Hz gate). A previous global `_last_stats_ts` on the orchestrator was removed because it starved every replica other than the first to emit within each second.
The orchestrator side does not add its own throttle on top. Scheduler gauges are
recorded when `raw_outputs.scheduler_stats is not None` (i.e. this replica's
scheduler passed its own 1 Hz gate), while output-batch request/token metrics
are recorded from `IterationStats` whenever a non-empty output batch is
processed. A previous global `_last_stats_ts` on the orchestrator was removed
because it starved every replica other than the first to emit within each
second.
## Metric Definitions
@@ -0,0 +1,249 @@
"""Tests for Daily-Omni ``--daily-omni-pack-mode`` packing recipes.
The ``minicpm-interleave`` mode is an accuracy-critical protocol: MiniCPM-o only reaches
OpenBMB's reported Daily-Omni score when frames and 1s audio segments arrive strictly
interleaved, with ``max_slice_nums=1`` / ``use_image_id=False`` and an empty MiniCPM
system message (not the Qwen system prompt). These tests pin that contract so a refactor
cannot silently regress the score.
vllm stubs are installed by tests/benchmarks/conftest.py before collection.
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import numpy as np
import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# Load the data module directly (bypasses vllm_omni.__init__ heavy imports).
_REPO_ROOT = Path(__file__).resolve().parents[2]
_MODULE_PATH = _REPO_ROOT / "vllm_omni" / "benchmarks" / "data_modules" / "daily_omni_dataset.py"
_MODULE_NAME = "vllm_omni.benchmarks.data_modules.daily_omni_dataset"
if _MODULE_NAME not in sys.modules:
_spec = importlib.util.spec_from_file_location(_MODULE_NAME, _MODULE_PATH)
_mod = importlib.util.module_from_spec(_spec)
sys.modules[_MODULE_NAME] = _mod
_spec.loader.exec_module(_mod)
from vllm_omni.benchmarks.data_modules.daily_omni_dataset import ( # noqa: E402
DailyOmniDataset,
_uniform_sample_indices,
)
_VIDEO_ID = "vid001"
_NUM_FRAMES = 5
@pytest.fixture
def video_dir(tmp_path: Path) -> Path:
"""Official Daily-Omni ``Videos/`` layout with placeholder media files."""
per_video = tmp_path / "Videos" / _VIDEO_ID
per_video.mkdir(parents=True)
(per_video / f"{_VIDEO_ID}_video.mp4").write_bytes(b"fake-mp4")
(per_video / f"{_VIDEO_ID}_audio.wav").write_bytes(b"fake-wav")
return tmp_path / "Videos"
@pytest.fixture
def qa_json(tmp_path: Path) -> Path:
path = tmp_path / "qa.json"
path.write_text(
json.dumps(
[
{
"video_id": _VIDEO_ID,
"question": "What happens first?",
"choice": {"A": "a", "B": "b", "C": "c", "D": "d"},
"answer": "A",
"task_type": "AV Event Alignment",
"video_duration": "30s",
}
]
),
encoding="utf-8",
)
return path
def _make_dataset(qa_json: Path, video_dir: Path, **kwargs) -> DailyOmniDataset:
return DailyOmniDataset(
qa_json_path=str(qa_json),
video_dir=str(video_dir),
disable_shuffle=True,
**kwargs,
)
def _stub_extract(monkeypatch, dataset: DailyOmniDataset, counter: list[int]) -> None:
"""Replace decord/ffmpeg extraction with deterministic in-memory frames + segments."""
from PIL import Image
def fake_extract(video_path, *, audio_path, include_audio, **_kw):
counter.append(1)
frames = [Image.new("RGB", (16, 16), color=(i, i, i)) for i in range(_NUM_FRAMES)]
segments = [np.zeros(1600, dtype=np.float32) for _ in range(_NUM_FRAMES)] if include_audio else []
return frames, segments
monkeypatch.setattr(dataset, "_extract_minicpm_frame_audio_segments", fake_extract)
# ---------------------------------------------------------------------------
# _uniform_sample_indices
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(("n", "k"), [(10, 4), (100, 64), (1000, 64), (7, 7), (3, 5), (1, 1)])
def test_uniform_sample_indices_matches_openbmb_midpoint(n: int, k: int) -> None:
"""Pin OpenBMB midpoint-bin sampling (not np.linspace endpoints)."""
got = _uniform_sample_indices(n, k)
if n <= k:
assert got == list(range(n))
else:
gap = n / k
expected = [int(i * gap + gap / 2) for i in range(k)]
assert got == expected
# Endpoints differ from linspace for typical Daily-Omni downsample sizes.
linspace = [int(i) for i in np.linspace(0, n - 1, k, dtype=int)]
if n == 1000 and k == 64:
assert got[0] == 7 and got[-1] == 992
assert linspace[0] == 0 and linspace[-1] == 999
assert got != linspace
def test_uniform_sample_indices_non_positive_k() -> None:
assert _uniform_sample_indices(10, 0) == []
assert _uniform_sample_indices(10, -1) == []
# ---------------------------------------------------------------------------
# minicpm-interleave packing
# ---------------------------------------------------------------------------
def test_minicpm_interleave_alternates_image_and_audio(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all")
_stub_extract(monkeypatch, ds, [])
parts, extra, position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert position == "first"
assert [p["type"] for p in parts] == ["image_url", "audio_url"] * _NUM_FRAMES
assert extra["mm_processor_kwargs"] == {
"use_audio_in_video": False,
"max_slice_nums": 1,
"use_image_id": False,
}
def test_minicpm_interleave_visual_mode_emits_frames_only(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="visual")
_stub_extract(monkeypatch, ds, [])
parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert [p["type"] for p in parts] == ["image_url"] * _NUM_FRAMES
def test_minicpm_interleave_writes_file_urls_under_video_dir(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all")
_stub_extract(monkeypatch, ds, [])
parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
segment_dir = video_dir / ".minicpm_daily_omni_interleave" / _VIDEO_ID
for part in parts:
url = part[part["type"]]["url"]
assert url.startswith("file://")
assert Path(url[len("file://") :]).parent == segment_dir.resolve()
assert sorted(p.name for p in segment_dir.glob("frame_*.jpg")) == [f"frame_{i:04d}.jpg" for i in range(_NUM_FRAMES)]
def test_minicpm_interleave_inline_mode_emits_data_urls(monkeypatch, qa_json, video_dir) -> None:
ds = _make_dataset(
qa_json,
video_dir,
pack_mode="minicpm-interleave",
input_mode="all",
inline_local_video=True,
)
_stub_extract(monkeypatch, ds, [])
parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert parts[0]["image_url"]["url"].startswith("data:image/jpeg;base64,")
assert parts[1]["audio_url"]["url"].startswith("data:audio/wav;base64,")
assert not (video_dir / ".minicpm_daily_omni_interleave").exists()
def test_minicpm_interleave_caches_file_parts_but_not_inline_payloads(monkeypatch, qa_json, video_dir) -> None:
"""Inline base64 must not be retained: ~700 Daily-Omni videos would pin GBs of RSS."""
file_calls: list[int] = []
ds_file = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all")
_stub_extract(monkeypatch, ds_file, file_calls)
ds_file._compose_daily_omni_multimodal(_VIDEO_ID, None)
ds_file._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert len(file_calls) == 1
assert ds_file._minicpm_interleave_cache
inline_calls: list[int] = []
ds_inline = _make_dataset(
qa_json,
video_dir,
pack_mode="minicpm-interleave",
input_mode="all",
inline_local_video=True,
)
_stub_extract(monkeypatch, ds_inline, inline_calls)
ds_inline._compose_daily_omni_multimodal(_VIDEO_ID, None)
ds_inline._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert len(inline_calls) == 2
assert not ds_inline._minicpm_interleave_cache
def test_minicpm_interleave_missing_local_video_returns_none(qa_json, tmp_path) -> None:
empty = tmp_path / "empty_videos"
empty.mkdir()
ds = _make_dataset(qa_json, empty, pack_mode="minicpm-interleave", input_mode="all")
parts, extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
assert parts is None
assert extra is None
# ---------------------------------------------------------------------------
# System prompt selection
# ---------------------------------------------------------------------------
def test_minicpm_interleave_keeps_empty_system_message(monkeypatch, qa_json, video_dir) -> None:
"""Official MiniCPM omni mode still sends role=system with empty content."""
ds = _make_dataset(qa_json, video_dir, pack_mode="minicpm-interleave", input_mode="all")
_stub_extract(monkeypatch, ds, [])
parts, _extra, _position = ds._compose_daily_omni_multimodal(_VIDEO_ID, None)
messages = ds._build_daily_omni_openai_messages(parts, "q?", {"A": "a"})
assert [m["role"] for m in messages] == ["system", "user"]
assert messages[0]["content"][0]["text"] == ""
def test_qwen_pack_mode_keeps_system_message(qa_json, video_dir) -> None:
ds = _make_dataset(qa_json, video_dir, pack_mode="qwen", input_mode="all")
messages = ds._build_daily_omni_openai_messages([], "q?", {"A": "a"})
assert [m["role"] for m in messages] == ["system", "user"]
assert "Qwen" in messages[0]["content"][0]["text"]
def test_invalid_pack_mode_rejected(qa_json, video_dir) -> None:
with pytest.raises(ValueError, match="pack_mode must be"):
_make_dataset(qa_json, video_dir, pack_mode="bogus")
+26
View File
@@ -2214,6 +2214,32 @@ class TestPlatformOverrides:
assert rocm.stages[0].enforce_eager is None
assert rocm.stages[1].enforce_eager is True
def test_minicpmo_4_5_cuda_caps_talker_kv_cache(self):
pipeline = resolve_pipeline_config("minicpmo_4_5")
assert isinstance(pipeline, PipelineConfig)
deploy_path = Path(get_deploy_config_path("minicpmo_4_5.yaml"))
cuda = _apply_platform_overrides(load_deploy_config(deploy_path), platform="cuda")
cuda_stages = merge_pipeline_deploy(pipeline, cuda)
assert cuda_stages[1].yaml_engine_args["kv_cache_memory_bytes"] == 2 * 1024**3
# The CUDA memory budget must not leak into the existing NPU profile.
npu = _apply_platform_overrides(load_deploy_config(deploy_path), platform="npu")
npu_stages = merge_pipeline_deploy(pipeline, npu)
assert "kv_cache_memory_bytes" not in npu_stages[1].yaml_engine_args
# Multi-GPU replica profiles place Talker on dedicated devices and
# preserve their previous automatic KV-cache sizing.
for filename in (
"minicpmo_4_5_3gpu_stage1_replicas.yaml",
"minicpmo_4_5_4gpu_stage1_replicas.yaml",
):
replica = _apply_platform_overrides(
load_deploy_config(Path(get_deploy_config_path(filename))), platform="cuda"
)
replica_stages = merge_pipeline_deploy(pipeline, replica)
assert replica_stages[1].yaml_engine_args["kv_cache_memory_bytes"] is None
def test_npu_overrides(self):
deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml"))
if not deploy_path.exists():
@@ -0,0 +1,57 @@
from types import SimpleNamespace
import pytest
from vllm import SamplingParams
from vllm.v1.engine import EngineCoreRequest
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
from vllm_omni.engine.async_engine_utils import apply_omni_final_stage_metadata
from vllm_omni.engine.serialization import deserialize_additional_information
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_engine_request() -> EngineCoreRequest:
return EngineCoreRequest(
request_id="req",
prompt_token_ids=[1],
mm_features=None,
sampling_params=SamplingParams(max_tokens=1),
pooling_params=None,
arrival_time=0.0,
lora_request=None,
cache_salt=None,
data_parallel_rank=None,
)
def _request_omits_kv_transfer(*, force_kv_transfer: bool) -> tuple[bool, dict]:
tagged = apply_omni_final_stage_metadata(
_make_engine_request(),
final_stage_id=0,
force_kv_transfer=force_kv_transfer,
)
scheduler = OmniARScheduler.__new__(OmniARScheduler)
scheduler._omits_kv_transfer_cache = {}
request = SimpleNamespace(
request_id="req",
additional_information=tagged.additional_information,
)
result = scheduler._request_omits_kv_transfer_to_next_stage(request)
metadata = deserialize_additional_information(tagged.additional_information)
return result, metadata
def test_stage_zero_request_omits_kv_transfer():
omits_transfer, metadata = _request_omits_kv_transfer(force_kv_transfer=False)
assert omits_transfer
assert "omni_force_kv_transfer" not in metadata
def test_cfg_companion_forces_kv_transfer_without_downstream_payload():
omits_transfer, metadata = _request_omits_kv_transfer(force_kv_transfer=True)
assert not omits_transfer
assert metadata["omni_final_stage_id"] == 0
assert metadata["omni_force_kv_transfer"] is True
@@ -21,6 +21,7 @@ import vllm_omni # noqa: F401 - import for side effects (patch vLLM)
from vllm.sampling_params import SamplingParams
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.engine import EngineCoreEventType
from vllm.v1.metrics.stats import PrefillStats
from vllm.v1.outputs import ModelRunnerOutput
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
@@ -93,6 +94,17 @@ def _make_update(**kwargs) -> StreamingUpdate:
return StreamingUpdate(**defaults)
def test_generation_scheduler_records_prefill_stats_for_metrics() -> None:
request = _make_request(prompt_token_ids=[1, 2, 3, 4])
request.prefill_stats = PrefillStats()
OmniGenerationScheduler._record_prefill_stats(request)
assert request.prefill_stats.num_prompt_tokens == 4
assert request.prefill_stats.num_computed_tokens == 4
assert request.prefill_stats.num_cached_tokens == 0
def test_resumable_generation_stop_marks_segment_boundary() -> None:
session = _make_request(request_id="req-generation-segment")
session.status = RequestStatus.RUNNING
@@ -9,7 +9,9 @@ import torch
from torch import nn
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
@@ -91,7 +93,20 @@ def _make_pipeline() -> Wan22Pipeline:
return pipeline
def test_forward_delegates_denoising_to_diffuse() -> None:
@pytest.mark.parametrize(
("sampling_params_kwargs", "expected_low", "expected_high"),
[
({}, 4.0, 4.0),
({"guidance_scale": 0.0}, 0.0, 0.0),
({"guidance_scale": 1.0}, 1.0, 1.0),
({"guidance_scale": 3.0, "guidance_scale_2": 5.0}, 3.0, 5.0),
],
)
def test_forward_delegates_denoising_to_diffuse(
sampling_params_kwargs: dict[str, float],
expected_low: float,
expected_high: float,
) -> None:
pipeline = _make_pipeline()
captured: dict[str, object] = {}
@@ -101,25 +116,15 @@ def test_forward_delegates_denoising_to_diffuse() -> None:
pipeline.diffuse = _fake_diffuse # type: ignore[method-assign]
mock_req = SimpleNamespace(
mock_req = OmniDiffusionRequest(
prompt="prompt",
request_id="test-req",
sampling_params=SimpleNamespace(
height=None,
width=None,
sampling_params=OmniDiffusionSamplingParams(
num_frames=1,
num_inference_steps=2,
guidance_scale_provided=True,
guidance_scale=1.0,
guidance_scale_2=None,
boundary_ratio=None,
generator=None,
seed=None,
num_outputs_per_prompt=1,
max_sequence_length=32,
latents=None,
output_type="latent",
extra_args={},
**sampling_params_kwargs,
),
)
batch = DiffusionRequestBatch(requests=[mock_req])
@@ -129,8 +134,8 @@ def test_forward_delegates_denoising_to_diffuse() -> None:
assert torch.equal(output.output, torch.ones((1, 4, 1, 8, 8)))
assert torch.equal(captured["prompt_embeds"], torch.zeros(1, 32, 8))
assert torch.equal(captured["timesteps"], pipeline.scheduler.timesteps)
assert captured["guidance_low"] == 1.0
assert captured["guidance_high"] == 1.0
assert captured["guidance_low"] == expected_low
assert captured["guidance_high"] == expected_high
assert captured["boundary_timestep"] == pytest.approx(875.0)
assert captured["latent_condition"] is None
assert captured["first_frame_mask"] is None
+37
View File
@@ -72,3 +72,40 @@ def test_tp_seed_same_across_ranks_and_varies_across_requests():
# Seeds must vary across requests (non-determinism preserved).
assert len(set(seeds)) == n_requests, f"Expected {n_requests} unique seeds but got {len(set(seeds))}: {seeds}"
def _request_with_guidance(**sampling_kwargs) -> OmniDiffusionRequest:
return OmniDiffusionRequest(
prompt={"prompt": "a cup of coffee on a table"},
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=1, **sampling_kwargs),
request_id="request-test",
)
def test_omitted_guidance_scale_is_unset_and_resolved_to_one():
# No guidance_scale passed: the caller did not set it, so it must NOT count as
# "provided" (each pipeline applies its own default), and __post_init__ resolves the
# None "unset" sentinel to a concrete float so no consumer ever observes None.
req = _request_with_guidance()
assert req.sampling_params.guidance_scale_provided is False
assert req.sampling_params.guidance_scale == 1.0
def test_explicit_zero_guidance_scale_is_honored():
# Regression (#4998): an explicit guidance_scale=0 (disable CFG) must be honored,
# not mistaken for "unset" and overridden by the pipeline's model default. This is
# the sentinel collision that the None default + ``is not None`` check fixes.
req = _request_with_guidance(guidance_scale=0.0)
assert req.sampling_params.guidance_scale_provided is True
assert req.sampling_params.guidance_scale == 0.0
@pytest.mark.parametrize("value", [0.999, 1.0, 5.0])
def test_explicit_nonzero_guidance_scale_is_honored(value):
# Any explicitly-set value (truthy or not) is honored as provided and preserved.
req = _request_with_guidance(guidance_scale=value)
assert req.sampling_params.guidance_scale_provided is True
assert req.sampling_params.guidance_scale == value
@@ -10,8 +10,10 @@ import pytest
import torch
from pytest_mock import MockerFixture
from vllm.v1.core.sched.scheduler import Scheduler as VLLMScheduler
from vllm.v1.metrics.stats import PrefillStats, PromptTokenStats
from vllm.v1.request import RequestStatus
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler
from vllm_omni.data_entry_keys import CodesStruct, MetaStruct, OmniPayload, OmniPayloadStruct
from vllm_omni.distributed.omni_connectors.adapter import construct_next_stage_streaming_input_prompt
from vllm_omni.distributed.omni_connectors.transfer_adapter.base import OmniTransferAdapterBase
@@ -37,8 +39,10 @@ def _req(req_id: str, status: RequestStatus, external_req_id: str | None = None)
external_req_id=external_req_id or req_id,
status=status,
prompt_token_ids=[],
num_prompt_tokens=0,
num_computed_tokens=0,
num_output_placeholders=0,
prefill_stats=None,
additional_information=None,
is_finished=lambda: status == RequestStatus.FINISHED_STOPPED,
)
@@ -485,6 +489,48 @@ def test_load_poll_ar_request_additional_information_concats_tensors(build_adapt
assert request.additional_information["meta"]["finished"].item() is True
def test_non_ar_poll_reinitializes_prefill_stats_for_later_chunks(build_adapter):
adapter, connector = build_adapter(stage_id=2, model_mode="generation")
request = _req("req-later-chunk", RequestStatus.WAITING, external_req_id="ext-later-chunk")
request.prefill_stats = PrefillStats()
adapter.request_ids_mapping[request.request_id] = request.external_req_id
connector.get.return_value = (
{
"codes": {"audio": torch.tensor([7, 8], dtype=torch.long)},
"meta": {"finished": torch.tensor(False, dtype=torch.bool)},
},
8,
)
assert adapter._poll_single_request(request) is True
OmniGenerationScheduler._record_prefill_stats(request)
first_chunk_stats = request.prefill_stats
request.prefill_stats = None
connector.get.return_value = (
{
"codes": {"audio": torch.tensor([9, 10, 11], dtype=torch.long)},
"meta": {"finished": torch.tensor(True, dtype=torch.bool)},
},
8,
)
assert adapter._poll_single_request(request) is True
assert isinstance(request.prefill_stats, PrefillStats)
OmniGenerationScheduler._record_prefill_stats(request)
second_chunk_stats = request.prefill_stats
assert first_chunk_stats is not None
assert first_chunk_stats.num_prompt_tokens == 2
assert second_chunk_stats is not None
assert second_chunk_stats.num_prompt_tokens == 3
prompt_token_stats = PromptTokenStats()
prompt_token_stats.update_from_output(first_chunk_stats)
prompt_token_stats.update_from_output(second_chunk_stats)
assert prompt_token_stats.total == 5
assert prompt_token_stats.computed == 5
def test_sender_only_adapter_does_not_park_or_clear_requests(build_adapter):
adapter, _ = build_adapter(stage_id=1, connector_extra={"role": "sender"})
request = _req("req-1", RequestStatus.WAITING)
@@ -468,6 +468,10 @@ def test_one_word_prompt_001(omni_server, openai_client) -> None:
"messages": messages,
"stream": True,
"key_words": {"text": ["london"]},
# A one-word English clip is too short for whisper's language detection to
# be stable; unpinned it renders "London" as 런던 / ランデ / 梁敦 and the
# containment check then fails on correct audio.
"transcript_language": "en",
}
# Retry only when assert_omni_response fails on text/audio cosine similarity (see tests/helpers/assertions.py).
@@ -7,7 +7,9 @@ from vllm.v1.engine import EngineCoreRequest
from vllm_omni.distributed.omni_coordinator import ReplicaInfo, ReplicaStatus
from vllm_omni.engine import OmniEngineCoreRequest
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine, StageRuntimeInfo
from vllm_omni.engine.serialization import deserialize_additional_information
from vllm_omni.engine.stage_pool import StagePool
from vllm_omni.model_executor.stage_input_processors.bagel import ExpandedPrompt
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -107,6 +109,34 @@ def test_build_add_request_message_preserves_model_intermediate_buffer(mocker: M
assert torch.equal(info["hidden_states"]["tts"], hidden)
def test_cfg_companion_suppresses_payload_but_forces_kv_transfer(mocker: MockerFixture):
engine = object.__new__(AsyncOmniEngine)
params = SamplingParams(max_tokens=8)
engine.prompt_expand_func = lambda *_args: [
ExpandedPrompt(
prompt={"prompt": "negative"},
role="cfg_text",
request_id_suffix="__cfg_text",
)
]
engine.supported_tasks = ("generate",)
engine.input_processor = mocker.Mock()
engine.input_processor.process_inputs.return_value = _make_engine_core_request("req__cfg_text")
engine.request_queue = mocker.Mock()
engine._enqueue_cfg_companions(
parent_id="req",
original_prompt={"prompt": "positive"},
stage0_params=params,
sampling_params_list=[params],
)
message = engine.request_queue.sync_q.put.call_args.args[0]
metadata = deserialize_additional_information(message.prompt.additional_information)
assert metadata["omni_final_stage_id"] == 0
assert metadata["omni_force_kv_transfer"] is True
def test_build_add_request_message_with_resumable_streaming(mocker: MockerFixture):
engine = object.__new__(AsyncOmniEngine)
params = SamplingParams(max_tokens=8)
@@ -172,6 +172,41 @@ def test_stage_engine_core_client_module_reload_keeps_forward_refs_deferred():
)
def test_async_omni_engine_initialize_stages_passes_log_stats_to_runtime(monkeypatch):
import vllm_omni.engine.async_omni_engine as engine_mod
engine = object.__new__(AsyncOmniEngine)
engine.stage_configs = [types.SimpleNamespace()]
engine.model = "dummy-model"
engine.config_path = "dummy-config"
engine.single_stage_mode = False
engine.diffusion_batch_size = 1
engine.async_chunk = False
engine.tokenizer = None
engine._single_stage_id_filter = None
engine._omni_master_address = None
engine._omni_master_port = None
engine._omni_dp_size_local = 1
engine._omni_heartbeat_timeout = 30.0
engine._omni_lb_policy = "random"
engine.request_queue = types.SimpleNamespace()
engine._log_stats = True
captured: dict[str, object] = {}
runtime = types.SimpleNamespace(stage_pools=[], initialize=lambda: None)
def _capture_create_stage_runtime(**kwargs):
captured.update(kwargs)
return runtime
monkeypatch.setattr(engine_mod, "create_stage_runtime", _capture_create_stage_runtime)
engine._initialize_stages(stage_init_timeout=7)
assert captured["stage_init_timeout"] == 7
assert captured["log_stats"] is True
def test_compute_replica_layout_splits_diffusion_devices_by_world_size():
stage_cfg = types.SimpleNamespace(
stage_id=0,
@@ -342,6 +377,95 @@ def test_stage_runtime_initializes_stage_pools(monkeypatch):
assert runtime.stage_pools[1].output_processor is stage1_output_processor
def test_stage_runtime_passes_log_stats_to_llm_replica_launch(monkeypatch):
import vllm_omni.engine.stage_runtime as runtime_mod
runtime = StageRuntime(
stage_configs=[types.SimpleNamespace()],
model="dummy-model",
config_path="dummy-config",
stage_init_timeout=1,
diffusion_batch_size=1,
async_chunk=False,
log_stats=True,
)
cfg = types.SimpleNamespace(model_config=types.SimpleNamespace(max_model_len=64))
plan = _make_llm_plan(0, stage_id=0, vllm_config=cfg).replicas[0]
plan.engine_args_dict = {}
captured: dict[str, object] = {}
addresses = types.SimpleNamespace(
inputs=["tcp://127.0.0.1:1"],
outputs=["tcp://127.0.0.1:2"],
frontend_stats_publish_address=None,
)
resources = types.SimpleNamespace(manager=object(), coordinator=None, addresses=addresses)
stage_client = types.SimpleNamespace()
@contextlib.contextmanager
def _capture_launch_stage_replica(**kwargs):
captured["log_stats"] = kwargs["log_stats"]
yield resources
monkeypatch.setattr(runtime_mod, "acquire_device_locks", lambda *_args, **_kwargs: [])
monkeypatch.setattr(runtime_mod, "launch_stage_replica", _capture_launch_stage_replica)
monkeypatch.setattr(
runtime_mod.StageEngineCoreClientBase,
"make_async_mp_client",
lambda **kwargs: (captured.__setitem__("client_log_stats", kwargs["log_stats"]) or stage_client),
)
assert runtime._initialize_local_llm_replica(plan, stage_init_timeout=1) is stage_client
assert captured["log_stats"] is True
assert captured["client_log_stats"] is True
def test_stage_runtime_passes_log_stats_to_output_processor(monkeypatch):
import vllm_omni.engine.stage_runtime as runtime_mod
runtime = StageRuntime(
stage_configs=[types.SimpleNamespace()],
model="dummy-model",
config_path="dummy-config",
stage_init_timeout=1,
diffusion_batch_size=1,
async_chunk=False,
log_stats=True,
)
cfg = types.SimpleNamespace(model_config=types.SimpleNamespace(max_model_len=64))
stage_plan = _make_llm_plan(0, stage_id=0, vllm_config=cfg)
stage_client = types.SimpleNamespace(
stage_type="llm",
is_comprehension=False,
final_output=True,
final_output_type=None,
default_sampling_params=types.SimpleNamespace(),
)
captured: dict[str, object] = {}
output_processor = object()
def _capture_build_llm_stage_output_processor(plan, stage_vllm_config, *, log_stats=False):
captured["plan"] = plan
captured["stage_vllm_config"] = stage_vllm_config
captured["log_stats"] = log_stats
return output_processor
monkeypatch.setattr(
runtime_mod,
"build_llm_stage_output_processor",
_capture_build_llm_stage_output_processor,
)
pools = runtime._assemble_stage_pools([stage_plan], {0: [stage_client]})
assert pools[0].output_processor is output_processor
assert captured == {
"plan": stage_plan,
"stage_vllm_config": cfg,
"log_stats": True,
}
def test_build_logical_stage_init_plans_applies_replica_device_splits(monkeypatch):
import vllm_omni.engine.stage_runtime as runtime_mod
@@ -537,10 +661,11 @@ def test_initialize_local_llm_replica_passes_stage_init_timeout_to_complete_stag
stage_configs=[],
model="dummy-model",
config_path="dummy-config",
stage_init_timeout=302,
stage_init_timeout=1,
diffusion_batch_size=1,
async_chunk=False,
)
stage_init_timeout = 302
fake_vllm_config = types.SimpleNamespace()
fake_addresses = types.SimpleNamespace(inputs=["in"], outputs=["out"], frontend_stats_publish_address=None)
@@ -587,14 +712,14 @@ def test_initialize_local_llm_replica_passes_stage_init_timeout_to_complete_stag
)
try:
runtime._initialize_local_llm_replica(plan, 302)
runtime._initialize_local_llm_replica(plan, stage_init_timeout)
finally:
if prev_device_env is None:
os.environ.pop(device_env_var, None)
else:
os.environ[device_env_var] = prev_device_env
assert captured_timeout == 302
assert captured_timeout == stage_init_timeout
def test_build_engine_args_cli_tokenizer_overrides_inferred_base_tokenizer(tmp_path):
+168 -32
View File
@@ -15,6 +15,7 @@ import pytest
from vllm.outputs import CompletionOutput, RequestOutput
from vllm.sampling_params import SamplingParams
from vllm.v1.engine.exceptions import EngineDeadError
from vllm.v1.metrics.stats import IterationStats
from vllm_omni.engine.messages import (
AbortRequestMessage,
@@ -145,7 +146,7 @@ class FakeStageClient:
try:
return self._engine_core_outputs.get_nowait()
except queue.Empty:
return SimpleNamespace(outputs=[])
return SimpleNamespace(outputs=[], scheduler_stats=None, finished_requests=None)
def get_diffusion_output_nowait(self):
try:
@@ -253,12 +254,40 @@ class FakeOutputProcessor:
return None
class IterationStatsOutputProcessor(FakeOutputProcessor):
def process_outputs(self, *_args, **kwargs):
iteration_stats = kwargs.get("iteration_stats")
if iteration_stats is None and len(_args) >= 3:
iteration_stats = _args[2]
assert iteration_stats is not None
iteration_stats.num_generation_tokens += 3
return super().process_outputs(*_args, **kwargs)
class RecordingOutputProcessor(FakeOutputProcessor):
def __init__(self, *, request_outputs: list[object] | None = None) -> None:
super().__init__(request_outputs=request_outputs)
self.process_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def process_outputs(self, *args, **kwargs):
self.process_calls.append((args, kwargs))
return super().process_outputs(*args, **kwargs)
class RecordingStatLogger:
def __init__(self) -> None:
self.records: list[tuple[Any, Any, int]] = []
def record(self, scheduler_stats, iteration_stats, *, engine_idx: int = 0) -> None:
self.records.append((scheduler_stats, iteration_stats, engine_idx))
def _sampling_params(max_tokens: int = 4) -> SamplingParams:
return SamplingParams(max_tokens=max_tokens)
def _engine_core_outputs(tag: str, timestamp: float) -> SimpleNamespace:
return SimpleNamespace(outputs=[tag], timestamp=timestamp, scheduler_stats=None)
return SimpleNamespace(outputs=[tag], timestamp=timestamp, scheduler_stats=None, finished_requests=None)
def _build_request_output(
@@ -329,6 +358,7 @@ def _build_harness(
output_processors: list[object] | None = None,
stage_vllm_configs: list[object] | None = None,
async_chunk: bool = False,
log_stats: bool = False,
stage_pools: list[StagePool] | None = None,
) -> OrchestratorFixture:
"""Build an Orchestrator test harness.
@@ -364,6 +394,7 @@ def _build_harness(
rpc_async_queue=rpc_queue.async_q,
stage_pools=stage_pools,
async_chunk=async_chunk,
log_stats=log_stats,
)
ready_future.set_result((orchestrator, request_queue, output_queue, rpc_queue))
await orchestrator.run()
@@ -2137,38 +2168,143 @@ async def test_multi_replica_cfg_companion_inherits_parent_affinity(orchestrator
await _shutdown_orchestrator(orchestrator_fixture)
def test_orchestrator_does_not_re_introduce_global_stats_throttle() -> None:
"""Regression: each (stage, replica) must independently publish its wrapped
vllm:* stats when its scheduler emits non-None scheduler_stats.
A previous version of Orchestrator carried a global self._last_stats_ts /
_stats_interval_s gate around _stat_logger.record(). Because
OmniSchedulerMixin.make_stats() already throttles at 1 Hz per scheduler
(one per (stage, replica)), the extra global gate starved every replica
other than the first to emit within each second — their {stage, replica}
gauges/counters went stale.
The fix removed the global gate entirely; the only signal needed is
'this replica's scheduler emitted non-None scheduler_stats'. This test
fails loudly if someone reintroduces the global throttle.
"""
import inspect
from vllm_omni.engine.orchestrator import Orchestrator
source = inspect.getsource(Orchestrator)
assert "_last_stats_ts" not in source, (
"Orchestrator must not gate stat recording on a global timestamp. "
"OmniSchedulerMixin.make_stats() already throttles per scheduler "
"(per (stage, replica)); an outer global gate starves all but the "
"first replica to emit within each 1s window."
@pytest.mark.asyncio
async def test_orchestrator_records_iteration_stats_without_scheduler_stats(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
processor = IterationStatsOutputProcessor(
request_outputs=[_build_request_output("req-stats", token_ids=[1], finished=True)]
)
assert "_stats_interval_s" not in source
assert "raw_outputs.scheduler_stats is not None" in source, (
"Orchestrator must gate stat recording solely on "
"raw_outputs.scheduler_stats being non-None — the per-scheduler 1Hz "
"throttle in OmniSchedulerMixin.make_stats() is the only gate needed."
orchestrator_fixture = orchestrator_factory([stage0], output_processors=[processor], log_stats=True)
stat_logger = RecordingStatLogger()
orchestrator_fixture.orchestrator._stat_logger = stat_logger
request = SimpleNamespace(request_id="req-stats", prompt_token_ids=[1, 2])
try:
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-stats",
prompt=request,
original_prompt={"prompt": "hello"},
sampling_params_list=[_sampling_params()],
final_stage_id=0,
)
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
stage0.push_engine_core_outputs(_engine_core_outputs("raw-without-scheduler-stats", 1.0))
await _wait_for(lambda: len(stat_logger.records) == 1)
scheduler_stats, iteration_stats, engine_idx = stat_logger.records[0]
assert scheduler_stats is None
assert iteration_stats is not None
assert iteration_stats.num_generation_tokens == 3
assert engine_idx == 0
finally:
await _shutdown_orchestrator(orchestrator_fixture)
@pytest.mark.asyncio
async def test_orchestrator_records_scheduler_stats_without_outputs(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
processor = RecordingOutputProcessor()
orchestrator_fixture = orchestrator_factory([stage0], output_processors=[processor], log_stats=True)
stat_logger = RecordingStatLogger()
orchestrator_fixture.orchestrator._stat_logger = stat_logger
scheduler_stats = SimpleNamespace(num_running_reqs=0, kv_cache_usage=0.5)
try:
stage0.push_engine_core_outputs(
SimpleNamespace(
outputs=[],
timestamp=1.0,
scheduler_stats=scheduler_stats,
finished_requests=None,
)
)
await _wait_for(lambda: len(stat_logger.records) == 1)
recorded_scheduler_stats, iteration_stats, engine_idx = stat_logger.records[0]
assert recorded_scheduler_stats is scheduler_stats
assert iteration_stats is None
assert engine_idx == 0
assert processor.process_calls[0][0][2] is None
finally:
await _shutdown_orchestrator(orchestrator_fixture)
@pytest.mark.asyncio
async def test_orchestrator_does_not_build_iteration_stats_for_finished_only_batch(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
processor = RecordingOutputProcessor()
orchestrator_fixture = orchestrator_factory([stage0], output_processors=[processor], log_stats=True)
stat_logger = RecordingStatLogger()
orchestrator_fixture.orchestrator._stat_logger = stat_logger
try:
stage0.push_engine_core_outputs(
SimpleNamespace(
outputs=[],
timestamp=1.0,
scheduler_stats=None,
finished_requests={"req-finished"},
)
)
await _wait_for(lambda: bool(processor.process_calls))
assert processor.process_calls[0][0][2] is None
assert stat_logger.records == []
finally:
await _shutdown_orchestrator(orchestrator_fixture)
@pytest.mark.asyncio
async def test_orchestrator_does_not_build_iteration_stats_without_stat_logger(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=True)
processor = RecordingOutputProcessor()
orchestrator_fixture = orchestrator_factory([stage0], output_processors=[processor], log_stats=True)
orchestrator_fixture.orchestrator._stat_logger = None
try:
stage0.push_engine_core_outputs(_engine_core_outputs("raw-output", 1.0))
await _wait_for(lambda: bool(processor.process_calls))
assert processor.process_calls[0][0][2] is None
finally:
await _shutdown_orchestrator(orchestrator_fixture)
@pytest.mark.asyncio
async def test_stage_pool_preserves_none_iteration_stats() -> None:
client = FakeStageClient(stage_type="llm", final_output=True)
processor = RecordingOutputProcessor()
pool = StagePool(
0,
[client],
output_processor=processor,
stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
)
raw_outputs = SimpleNamespace(outputs=["raw"], timestamp=1.0, scheduler_stats=None)
await pool.process_llm_raw_outputs(0, raw_outputs, iteration_stats=None)
assert processor.process_calls[0][0][2] is None
@pytest.mark.asyncio
async def test_stage_pool_process_llm_raw_outputs_mutates_iteration_stats() -> None:
client = FakeStageClient(stage_type="llm", final_output=True)
processor = IterationStatsOutputProcessor()
pool = StagePool(
0,
[client],
output_processor=processor,
stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
)
raw_outputs = SimpleNamespace(outputs=["raw"], timestamp=1.0, scheduler_stats=None)
iteration_stats = IterationStats()
await pool.process_llm_raw_outputs(0, raw_outputs, iteration_stats=iteration_stats)
assert iteration_stats.num_generation_tokens == 3
@pytest.mark.asyncio
+48 -1
View File
@@ -10,9 +10,11 @@ import pytest
import torch
from vllm.outputs import PoolingRequestOutput
from vllm.sampling_params import RequestOutputKind
from vllm.v1.engine import FinishReason
from vllm.v1.engine import EngineCoreEvent, EngineCoreEventType, FinishReason
from vllm.v1.engine.output_processor import OutputProcessor as VLLMOutputProcessor
from vllm.v1.metrics.stats import IterationStats, PrefillStats
from vllm_omni.engine import OmniEngineCoreOutput
from vllm_omni.outputs import output_processor
from vllm_omni.outputs.output_modality import OutputModality, OutputModalityNames
from vllm_omni.outputs.output_processor import MultimodalOutputProcessor, OmniRequestState
@@ -616,3 +618,48 @@ def test_no_detokenizer_final_only():
result = s.make_request_output([], None, FinishReason.STOP, None)
assert result is not None
assert AUDIO in result.outputs[0].multimodal_output
def test_mm_only_outputs_update_iteration_stats():
processor = MultimodalOutputProcessor(
tokenizer=None,
log_stats=True,
engine_core_output_type=AUDIO,
)
state = _make_no_detok_state(RequestOutputKind.CUMULATIVE)
processor.request_states[state.request_id] = state
processor.external_req_ids[state.external_req_id].append(state.request_id)
prefill_stats = PrefillStats()
prefill_stats.set(
num_prompt_tokens=4,
num_local_cached_tokens=0,
num_external_cached_tokens=0,
)
output = OmniEngineCoreOutput(
request_id=state.request_id,
new_token_ids=[10, 11],
finish_reason=FinishReason.STOP,
events=[
EngineCoreEvent(EngineCoreEventType.QUEUED, 1.0),
EngineCoreEvent(EngineCoreEventType.SCHEDULED, 2.0),
],
prefill_stats=prefill_stats,
multimodal_output={AUDIO: torch.ones(1, 8)},
)
iteration_stats = IterationStats()
processor.process_outputs(
[output],
engine_core_timestamp=3.0,
iteration_stats=iteration_stats,
)
assert iteration_stats.num_prompt_tokens == 4
assert iteration_stats.num_generation_tokens == 2
assert len(iteration_stats.time_to_first_tokens_iter) == 1
assert len(iteration_stats.finished_requests) == 1
finished = iteration_stats.finished_requests[0]
assert finished.finish_reason == FinishReason.STOP
assert finished.num_prompt_tokens == state.prompt_len
assert finished.num_generation_tokens == 2
+10 -1
View File
@@ -778,6 +778,7 @@ class TestSingleStageReplicaInitialization:
single_stage_id_filter=None,
omni_master_address="127.0.0.1",
omni_master_port=26000,
log_stats=True,
)
runtime._omni_master_server = mocker.Mock(spec=OmniMasterServer)
runtime._omni_master_server.get_stage_config.return_value = {"stage_id": 1, "stage_type": "llm"}
@@ -811,10 +812,17 @@ class TestSingleStageReplicaInitialization:
sentinel_client = SimpleNamespace()
mock_connect = mocker.patch.object(runtime_mod, "connect_remote_engine_cores", side_effect=_fake_connect)
client_kwargs: dict[str, Any] = {}
def _capture_make_async_mp_client(**kwargs):
client_kwargs.update(kwargs)
events.append("attach")
return sentinel_client
mocker.patch.object(
StageEngineCoreClientBase,
"make_async_mp_client",
side_effect=lambda **_: (events.append("attach"), sentinel_client)[1],
side_effect=_capture_make_async_mp_client,
)
result = runtime._initialize_remote_replica(plan, stage_init_timeout=60)
@@ -824,6 +832,7 @@ class TestSingleStageReplicaInitialization:
assert mock_connect.call_args.kwargs["vllm_config"].parallel_config.data_parallel_size_local == 0
assert mock_connect.call_args.kwargs["stage_id"] == 1
assert mock_connect.call_args.kwargs["replica_id"] == 0
assert client_kwargs["log_stats"] is True
assert events == ["enter", "exit", "attach"]
def test_initialize_llm_replica_remote_missing_registered_stage_config_raises(self, mocker: MockerFixture):
@@ -1301,7 +1301,11 @@ def test_generate_images_rejects_model_mismatch(test_client):
assert "model mismatch" in response.json()["detail"].lower()
def test_image_file_response_format_multiple(test_client):
@pytest.mark.parametrize(
("output_format", "expected_pil_format"),
[("png", "PNG"), ("jpeg", "JPEG"), ("webp", "WEBP")],
)
def test_image_file_response_format_multiple(test_client, output_format, expected_pil_format):
"""Test response_format=file with n>1 returns ZIP archive"""
response = test_client.post(
"/v1/images/generations",
@@ -1309,6 +1313,7 @@ def test_image_file_response_format_multiple(test_client):
"prompt": "a dog",
"n": 3,
"response_format": "file",
"output_format": output_format,
},
)
@@ -1317,23 +1322,32 @@ def test_image_file_response_format_multiple(test_client):
assert "attachment" in response.headers.get("content-disposition", "")
assert ".zip" in response.headers.get("content-disposition", "")
# Verify it's a valid ZIP with 3 PNG files
# Verify it's a valid ZIP with 3 correctly labelled image files
import zipfile
zip_buffer = io.BytesIO(response.content)
with zipfile.ZipFile(zip_buffer, "r") as zf:
files = zf.namelist()
assert len(files) == 3
assert all(f.endswith(".png") for f in files)
assert all(f.endswith(f".{output_format}") for f in files)
# Verify each file is a valid PNG
# Verify each file's bytes match its advertised format
for filename in files:
img_bytes = zf.read(filename)
img = Image.open(io.BytesIO(img_bytes))
assert img.format == "PNG"
assert img.format == expected_pil_format
def test_image_file_response_format_single(test_client):
@pytest.mark.parametrize(
("output_format", "expected_media_type", "expected_pil_format"),
[
("png", "image/png", "PNG"),
("jpg", "image/jpeg", "JPEG"),
("jpeg", "image/jpeg", "JPEG"),
("webp", "image/webp", "WEBP"),
],
)
def test_image_file_response_format_single(test_client, output_format, expected_media_type, expected_pil_format):
"""Test response_format=file with n=1 returns a single image file."""
response = test_client.post(
"/v1/images/generations",
@@ -1341,16 +1355,61 @@ def test_image_file_response_format_single(test_client):
"prompt": "a dog",
"n": 1,
"response_format": "file",
"output_format": output_format,
},
)
assert response.status_code == 200
assert response.headers["content-type"] == "image/png"
assert response.headers["content-type"] == expected_media_type
assert "attachment" in response.headers.get("content-disposition", "")
assert ".png" in response.headers.get("content-disposition", "")
assert f".{output_format}" in response.headers.get("content-disposition", "")
img = Image.open(io.BytesIO(response.content))
assert img.format == "PNG"
assert img.format == expected_pil_format
@pytest.mark.parametrize(
("output_format", "image_count", "expected_media_type", "expected_pil_format"),
[
("jpeg", 1, "image/jpeg", "JPEG"),
("webp", 2, "application/zip", "WEBP"),
],
)
def test_multistage_image_file_response_uses_requested_format(
async_omni_test_client,
output_format,
image_count,
expected_media_type,
expected_pil_format,
):
engine = async_omni_test_client.app.state.engine_client
engine._images = [Image.new("RGB", (16, 16), color="green") for _ in range(image_count)]
response = async_omni_test_client.post(
"/v1/images/generations",
json={
"prompt": "a dog",
"n": image_count,
"response_format": "file",
"output_format": output_format,
},
)
assert response.status_code == 200
assert response.headers["content-type"] == expected_media_type
if image_count == 1:
assert f".{output_format}" in response.headers["content-disposition"]
assert Image.open(io.BytesIO(response.content)).format == expected_pil_format
return
import zipfile
with zipfile.ZipFile(io.BytesIO(response.content), "r") as archive:
filenames = archive.namelist()
assert len(filenames) == image_count
assert all(filename.endswith(f".{output_format}") for filename in filenames)
for filename in filenames:
assert Image.open(io.BytesIO(archive.read(filename))).format == expected_pil_format
def make_test_image_bytes(size=(64, 64)) -> bytes:
@@ -48,6 +48,25 @@ def test_encode_video_bytes_exports_frames_without_interpolation(monkeypatch):
assert mux_calls[0]["audio"] is None
@pytest.mark.parametrize("frame_count", [3, 4])
def test_channel_last_video_tensor_preserves_channel_sized_frame_count(frame_count):
video = torch.arange(frame_count * 2 * 5 * 3, dtype=torch.uint8).reshape(frame_count, 2, 5, 3)
frames = video_api_utils._coerce_video_to_uint8_frames(video)
assert frames.shape == (frame_count, 2, 5, 3)
np.testing.assert_array_equal(frames, video.numpy())
def test_channel_first_video_tensor_is_converted_to_channel_last():
video = torch.arange(3 * 5 * 2 * 6, dtype=torch.uint8).reshape(3, 5, 2, 6)
frames = video_api_utils._coerce_video_to_uint8_frames(video)
assert frames.shape == (5, 2, 6, 3)
np.testing.assert_array_equal(frames, video.permute(1, 2, 3, 0).numpy())
def test_fragmented_mp4_video_encoder_reuses_single_muxer(monkeypatch):
muxers = []
+89 -11
View File
@@ -17,6 +17,7 @@ Key invariants tested:
"""
import json
import time
from unittest.mock import MagicMock
import pytest
@@ -80,28 +81,37 @@ def _make_audio_omni_output(
request_id: str = "test-req",
index: int = 0,
num_prompt_tokens: int = 3,
stage_id: int | None = None,
replica_id: int | None = None,
audio_samples: int = 0,
) -> OmniRequestOutput:
"""Build an OmniRequestOutput for audio (no torch dependency)."""
completion = CompletionOutput(
index=index,
text="",
token_ids=[],
cumulative_logprob=0.0,
logprobs=None,
finish_reason="stop",
stop_reason=None,
)
if audio_samples > 0:
completion.multimodal_output = {
"audio": [MagicMock(numel=MagicMock(return_value=audio_samples))],
"sr": 24000,
}
res = RequestOutput(
request_id=request_id,
prompt="test",
prompt_token_ids=list(range(num_prompt_tokens)),
prompt_logprobs=None,
outputs=[
CompletionOutput(
index=index,
text="",
token_ids=[],
cumulative_logprob=0.0,
logprobs=None,
finish_reason="stop",
stop_reason=None,
)
],
outputs=[completion],
finished=True,
)
return OmniRequestOutput(
request_id=request_id,
stage_id=stage_id,
replica_id=replica_id,
final_output_type="audio",
request_output=res,
finished=True,
@@ -321,6 +331,74 @@ async def test_single_modality_audio_only_one_stop():
assert finish_reasons[-1] == "stop"
@pytest.mark.parametrize(
("output_replica_id", "pool_replica_id", "expected_replica_id", "pool_lookup"),
[
(7, None, 7, False),
(None, 5, 5, True),
],
)
@pytest.mark.asyncio
async def test_streaming_audio_metrics_resolve_replica_id(
monkeypatch,
output_replica_id,
pool_replica_id,
expected_replica_id,
pool_lookup,
):
import vllm_omni.entrypoints.openai.serving_chat as serving_chat_mod
from vllm_omni.entrypoints.client_request_state import ClientRequestState
serving_chat = _build_serving_chat()
request = _make_request(modalities=["audio"])
req_state = ClientRequestState(
request_id="internal-req",
external_request_id="test-req",
)
req_state.request_arrival_ts = time.time() - 1.0
serving_chat.engine_client.request_states = {"internal-req": req_state}
serving_chat.engine_client.mod_metrics = object()
serving_chat.engine_client.engine = MagicMock()
serving_chat.engine_client.engine.stage_pools = [MagicMock(), MagicMock(), MagicMock()]
serving_chat.engine_client.engine.stage_pools[2].get_bound_replica_id.return_value = pool_replica_id
first_packet_calls = []
finalize_calls = []
def fake_observe_audio_first_packet(*args, **kwargs):
first_packet_calls.append(kwargs)
def fake_observe_audio_streaming_finalize(*args, **kwargs):
finalize_calls.append(kwargs)
monkeypatch.setattr(serving_chat_mod, "observe_audio_first_packet", fake_observe_audio_first_packet)
monkeypatch.setattr(serving_chat_mod, "observe_audio_streaming_finalize", fake_observe_audio_streaming_finalize)
async def result_generator():
yield _make_audio_omni_output(stage_id=2, replica_id=output_replica_id, audio_samples=2400)
await _collect_stream(
serving_chat.chat_completion_stream_generator(
request=request,
result_generator=result_generator(),
request_id="test-req",
model_name="test-model",
conversation=[],
tokenizer=MagicMock(),
request_metadata=MagicMock(),
)
)
if pool_lookup:
serving_chat.engine_client.engine.stage_pools[2].get_bound_replica_id.assert_called_once_with("internal-req")
else:
serving_chat.engine_client.engine.stage_pools[2].get_bound_replica_id.assert_not_called()
assert first_packet_calls[0]["stage_id"] == 2
assert first_packet_calls[0]["replica_id"] == expected_replica_id
assert finalize_calls[0]["stage_id"] == 2
assert finalize_calls[0]["replica_id"] == expected_replica_id
# ---------------------------------------------------------------------------
# Tests: fallback stop chunk when declared modality is not produced
# ---------------------------------------------------------------------------
+6 -3
View File
@@ -527,7 +527,7 @@ def _resolve_audio_transcript(
audio_bytes = getattr(response, "audio_bytes", None)
if not audio_bytes:
return None
return convert_audio_bytes_to_text(audio_bytes)
return convert_audio_bytes_to_text(audio_bytes, language=request_config.get("transcript_language"))
def assert_omni_response(response: Any, request_config: dict[str, Any], run_level):
@@ -632,6 +632,7 @@ def _assert_transcript_matches(
*,
threshold: float,
escalation_model: str | None = None,
language: str | None = None,
) -> None:
"""Assert spoken audio matches ``expected_text``.
@@ -647,7 +648,8 @@ def _assert_transcript_matches(
clip is re-transcribed with that stronger ASR and the assertion is decided on
its verdict -- so a weak whisper-``small`` mishear on a short clip does not
flake the gate, while a genuine model artifact still fails (the strong ASR
mismatches too).
mismatches too). ``language`` applies to that escalated pass too, so a request
that pins a language does not silently fall back to auto-detection on retry.
"""
expected = str(expected_text).strip().lower()
similarity = cosine_similarity_text(transcript.strip().lower(), expected)
@@ -664,7 +666,7 @@ def _assert_transcript_matches(
f"whisper-small below threshold ({similarity:.2f} <= {threshold}); "
f"escalating to whisper-{escalation_model} to rule out an ASR mishear"
)
strong_transcript = convert_audio_bytes_to_text(audio_bytes, model_size=escalation_model)
strong_transcript = convert_audio_bytes_to_text(audio_bytes, model_size=escalation_model, language=language)
strong_similarity = cosine_similarity_text(strong_transcript.strip().lower(), expected)
print(
f"audio content (whisper-{escalation_model}): {strong_transcript}\n"
@@ -741,6 +743,7 @@ def assert_audio_speech_response(response: Any, request_config: dict[str, Any],
expected_text,
threshold=0.9,
escalation_model=request_config.get("transcript_escalation_model"),
language=request_config.get("transcript_language"),
)
_assert_preset_voice_gender_from_audio(
response.audio_bytes,
+10 -5
View File
@@ -652,7 +652,9 @@ def _serialize_whisper_model_download(model_size: str = "small"):
f.close()
def _whisper_transcribe_in_current_process(output_path: str, model_size: str = "small") -> str:
def _whisper_transcribe_in_current_process(
output_path: str, model_size: str = "small", language: str | None = None
) -> str:
import whisper
device_index = None
@@ -684,6 +686,9 @@ def _whisper_transcribe_in_current_process(output_path: str, model_size: str = "
temperature=0.0,
word_timestamps=True,
condition_on_previous_text=False,
# None keeps whisper's auto-detection. Do not default this to a
# language: callers include non-English audio tests.
language=language,
)["text"]
finally:
del model
@@ -694,15 +699,15 @@ def _whisper_transcribe_in_current_process(output_path: str, model_size: str = "
return text or ""
def convert_audio_file_to_text(output_path: str, model_size: str = "small") -> str:
def convert_audio_file_to_text(output_path: str, model_size: str = "small", language: str | None = None) -> str:
"""Convert an audio file to text in an isolated subprocess."""
ctx = multiprocessing.get_context("spawn")
with concurrent.futures.ProcessPoolExecutor(max_workers=1, mp_context=ctx) as executor:
future = executor.submit(_whisper_transcribe_in_current_process, output_path, model_size)
future = executor.submit(_whisper_transcribe_in_current_process, output_path, model_size, language)
return future.result()
def convert_audio_bytes_to_text(raw_bytes: bytes, model_size: str = "small") -> str:
def convert_audio_bytes_to_text(raw_bytes: bytes, model_size: str = "small", language: str | None = None) -> str:
output_fd, output_path = tempfile.mkstemp(prefix="test_", suffix=".wav")
os.close(output_fd)
if os.environ.get("VLLM_OMNI_KEEP_REQUEST_MEDIA", "").lower() not in ("1", "true", "yes"):
@@ -710,7 +715,7 @@ def convert_audio_bytes_to_text(raw_bytes: bytes, model_size: str = "small") ->
data, samplerate = sf.read(io.BytesIO(raw_bytes))
sf.write(output_path, data, samplerate, format="WAV", subtype="PCM_16")
print(f"audio data is saved: {output_path}")
return convert_audio_file_to_text(output_path, model_size)
return convert_audio_file_to_text(output_path, model_size, language)
__all__ = [
+13
View File
@@ -24,6 +24,19 @@ DEPLOY_CONFIG = modify_stage_config(
1: {"kv_cache_memory_bytes": 512 * 1024 * 1024},
2: {"kv_cache_memory_bytes": 256 * 1024 * 1024},
},
# Platform overrides are applied after ordinary stage settings. Keep
# the constrained duplex test's Talker budget ahead of the base CUDA
# profile's single-GPU default.
"platforms": {
"cuda": {
"stages": [
{
"stage_id": 1,
"kv_cache_memory_bytes": 512 * 1024 * 1024,
}
]
}
},
},
)
CORE_DEPLOY_CONFIG = modify_stage_config(
+64 -1
View File
@@ -1,9 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
from tests.helpers.assertions import _assert_transcript_matches
from tests.helpers import assertions
from tests.helpers.assertions import _assert_transcript_matches, _resolve_audio_transcript
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -25,3 +28,63 @@ def test_short_transcript_unrelated_text_still_fails():
expected_text="how are you",
threshold=0.9,
)
def _capture_transcribe(monkeypatch) -> dict:
captured: dict = {}
def fake_convert(raw_bytes, model_size="small", language=None):
captured["model_size"] = model_size
captured["language"] = language
return "London"
monkeypatch.setattr(assertions, "convert_audio_bytes_to_text", fake_convert)
return captured
def test_resolve_transcript_honors_declared_language(monkeypatch):
captured = _capture_transcribe(monkeypatch)
response = SimpleNamespace(audio_content=None, audio_bytes=b"fake-wav")
_resolve_audio_transcript(
response,
{"modalities": ["text", "audio"], "transcript_language": "en"},
"full_model",
speech_api=False,
)
assert captured["language"] == "en"
def test_resolve_transcript_leaves_language_unset_by_default(monkeypatch):
captured = _capture_transcribe(monkeypatch)
response = SimpleNamespace(audio_content=None, audio_bytes=b"fake-wav")
_resolve_audio_transcript(
response,
{"modalities": ["text", "audio"]},
"full_model",
speech_api=False,
)
assert captured["language"] is None
assert captured["model_size"] == "small"
def test_escalated_transcript_keeps_declared_language(monkeypatch):
# The escalated pass must honour the same language, otherwise a request that
# pins one silently falls back to auto-detection on retry.
captured = _capture_transcribe(monkeypatch)
with pytest.raises(AssertionError, match="after ASR escalation"):
_assert_transcript_matches(
"totally different words",
audio_bytes=b"fake-wav",
expected_text="how are you",
threshold=0.9,
escalation_model="large-v3",
language="en",
)
assert captured["model_size"] == "large-v3"
assert captured["language"] == "en"
+81
View File
@@ -0,0 +1,81 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import sys
from types import SimpleNamespace
import numpy as np
import pytest
import soundfile as sf
from tests.helpers import media
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _stub_transcribe(monkeypatch) -> dict:
"""Capture the kwargs the helper hands to whisper, with no model and no device."""
captured: dict = {}
class FakeModel:
def transcribe(self, path, **kwargs):
captured.update(kwargs)
return {"text": "London"}
monkeypatch.setitem(sys.modules, "whisper", SimpleNamespace(load_model=lambda size, device=None: FakeModel()))
# Keep the unit test off the accelerator probe so it stays hermetic.
monkeypatch.setitem(
sys.modules,
"vllm_omni.platforms",
SimpleNamespace(current_omni_platform=SimpleNamespace(is_available=lambda: False)),
)
return captured
def test_transcribe_forwards_requested_language(monkeypatch):
captured = _stub_transcribe(monkeypatch)
media._whisper_transcribe_in_current_process("/tmp/does-not-matter.wav", "small", language="en")
assert captured["language"] == "en"
def test_transcribe_defaults_to_auto_language(monkeypatch):
# Auto-detect must stay the default: forcing a language globally would break
# the non-English audio tests (e.g. the Chinese Qwen3-Omni prompts).
captured = _stub_transcribe(monkeypatch)
media._whisper_transcribe_in_current_process("/tmp/does-not-matter.wav", "small")
assert captured.get("language") is None
def test_bytes_entrypoint_forwards_language_to_subprocess(monkeypatch, tmp_path):
"""Cover the two hops the tests above skip: bytes -> file -> executor.submit.
The real call crosses a spawn ProcessPoolExecutor, so capture what gets
submitted rather than what the worker eventually does.
"""
submitted: dict = {}
class FakeExecutor:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def submit(self, fn, *args):
submitted["fn"] = fn
submitted["args"] = args
return SimpleNamespace(result=lambda: "London")
monkeypatch.setattr(media.concurrent.futures, "ProcessPoolExecutor", lambda *a, **kw: FakeExecutor())
wav = tmp_path / "clip.wav"
sf.write(wav, np.zeros(2400, dtype=np.float32), 24000)
assert media.convert_audio_bytes_to_text(wav.read_bytes(), "small", "en") == "London"
assert submitted["fn"] is media._whisper_transcribe_in_current_process
assert submitted["args"][1:] == ("small", "en")
@@ -170,7 +170,12 @@ class TestDeployTopology:
0.15,
]
assert sum(memory_utilizations) <= 0.9 + 1e-6
assert stages[0].yaml_engine_args["limit_mm_per_prompt"] == {"video": {"count": 1, "num_frames": 32}}
# Daily-Omni minicpm-interleave: up to 64 image/audio items (+ optional video).
assert stages[0].yaml_engine_args["limit_mm_per_prompt"] == {
"image": 64,
"audio": 64,
"video": 1,
}
elif filename in {"minicpmo_4_5_2gpu.yaml"}:
assert [stage.yaml_engine_args["gpu_memory_utilization"] for stage in stages] == [
0.9,
+35
View File
@@ -17,6 +17,9 @@ from unittest.mock import MagicMock, patch
import pytest
import torch
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import (
OmniKVTransferManager,
)
from vllm_omni.outputs import OmniConnectorOutput
from vllm_omni.worker.omni_connector_model_runner_mixin import (
OmniConnectorModelRunnerMixin,
@@ -1198,6 +1201,38 @@ class TestRankAwareKVRouting(unittest.TestCase):
)
host.shutdown_omni_connectors()
def test_single_rank_hooks_use_default_manager_key(self):
host = self._make_host(from_tp=1, to_tp=1, local_rank=0)
expected = ["omni_0_to_1_kv_cache_req"]
self.assertEqual(host.get_rank_aware_kv_send_keys("req", from_stage=0, to_stage=1), expected)
self.assertEqual(host.get_rank_aware_kv_keys("req", from_stage=0, to_stage=1), expected)
host.shutdown_omni_connectors()
def test_init_uses_effective_manager_tp2_topology(self):
model_config = _make_model_config(stage_id=0)
model_config.omni_kv_config = {
"need_send_cache": True,
"omni_from_stage": "0",
"omni_to_stage": "1",
"rank_mapping": {"from_tp": 2, "to_tp": 2},
}
with patch(
"vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_local_tp_rank",
return_value=1,
):
manager = OmniKVTransferManager.from_model_config(model_config)
host = MixinHost()
host.init_omni_connectors(model_config=model_config, kv_transfer_manager=manager)
expected = ["req_0_0_1_1"]
self.assertEqual(host.get_rank_aware_kv_send_keys("req", from_stage=0, to_stage=1), expected)
self.assertEqual(host.get_rank_aware_kv_keys("req", from_stage=0, to_stage=1), expected)
self.assertEqual(host.get_kv_rank_mapping()["from_tp"], 2)
self.assertEqual(host.get_kv_rank_mapping()["to_tp"], 2)
host.shutdown_omni_connectors()
def test_send_keys_route_from_rank_gt_to_rank(self):
host = self._make_host(from_tp=4, to_tp=2, local_rank=3)
self.assertEqual(host.get_rank_aware_kv_send_keys("req", from_stage=0), ["req_0_0_3_1"])
@@ -52,11 +52,14 @@ Usage:
"""
import base64
import io
import json
import logging
import math
import os
import shutil
import tarfile
import wave
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
@@ -77,6 +80,10 @@ except ImportError:
logger = logging.getLogger(__name__)
# Official MiniCPM ``get_video_frame_audio_segments`` default (env ``MAX_NUM_FRAMES``).
_MINICPM_MAX_NUM_FRAMES = int(os.environ.get("MAX_NUM_FRAMES", "64"))
_MINICPM_AUDIO_SR = 16000
def _daily_omni_hf_cache_root() -> Path:
return Path(os.environ.get("HF_HOME", Path.home() / ".cache" / "huggingface")).expanduser().resolve()
@@ -203,12 +210,62 @@ class _ListDatasetIterator:
# Aligns with Lliar-liar/Daily-Omni CLI ``--input_mode`` (test_model/*/testmodel.py).
DailyOmniInputMode = Literal["all", "visual", "audio"]
# How multimodal parts are packed into OpenAI chat messages:
# - ``qwen``: one ``video_url`` + one ``audio_url`` (upstream Daily-Omni / Qwen protocol).
# - ``minicpm-interleave``: 1fps ``[image_i, audio_i, ...]`` matching OpenBMB MiniCPM-o
# ``get_video_frame_audio_segments`` + interleaved omni contents (paper ~80% recipe).
DailyOmniPackMode = Literal["qwen", "minicpm-interleave"]
# ``build_conversation()`` in Daily-Omni ``test_model/Qwen2.5-Omni/testmodel.py`` (verbatim).
DAILY_OMNI_SYSTEM_TEXT = (
"You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, "
"capable of perceiving auditory and visual inputs, as well as generating text and speech."
)
# MiniCPM-o ``get_sys_prompt(mode="omni")`` without ref-audio is an empty system string
# (still sent as role=system; see ``_build_daily_omni_openai_messages``).
MINICPM_OMNI_SYSTEM_TEXT = ""
def _uniform_sample_indices(n: int, k: int) -> list[int]:
"""Evenly pick ``k`` indices from ``0..n-1`` (OpenBMB ``uniform_sample`` midpoint bins).
Matches OmniEvalKit / MiniCPM ``uniform_sample``: midpoint of each equal-width bin,
not ``np.linspace`` endpoints. See
https://github.com/OpenBMB/OmniEvalKit/blob/1adac0577258d539efc03f16d8a0f4b3f7df6c19/o_e_Kit/utils/utils.py#L50-L54
"""
if k <= 0:
return []
if n <= k:
return list(range(n))
gap = n / k
return [int(i * gap + gap / 2) for i in range(k)]
def _numpy_to_wav_bytes(audio_np: Any, sample_rate: int = _MINICPM_AUDIO_SR) -> bytes:
"""Encode a mono float/int numpy waveform as 16-bit PCM WAV bytes."""
import numpy as np
arr = np.asarray(audio_np)
if arr.ndim > 1:
arr = arr.reshape(-1)
if arr.dtype != np.int16:
arr = np.clip(arr.astype(np.float64), -1.0, 1.0)
arr = (arr * 32767.0).astype(np.int16)
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(arr.tobytes())
return buf.getvalue()
def _pil_to_jpeg_bytes(image: Any, quality: int = 85) -> bytes:
buf = io.BytesIO()
image.save(buf, format="JPEG", quality=quality)
return buf.getvalue()
@dataclass
class DailyOmniSampleRequest(SampleRequest):
@@ -262,6 +319,11 @@ class DailyOmniDataset(BenchmarkDataset):
``all`` — video + WAV (default; official audio-visual protocol);
``visual`` — video only;
``audio`` — extracted WAV only (requires ``{video_id}/{video_id}_audio.wav`` under ``video_dir``).
pack_mode: Multimodal packing recipe:
``qwen`` — one ``video_url`` + optional ``audio_url`` (default; Daily-Omni/Qwen);
``minicpm-interleave`` — MiniCPM-o paper recipe: 1fps frames interleaved with
matching 1s audio segments as ``image_url``/``audio_url`` pairs (required to approach
OpenBMB ~80% Daily-Omni; needs local video+WAV under ``video_dir``).
max_duration_seconds: Reserved for future ffprobe-based filtering; currently **not applied**
when building requests (metadata ``video_duration`` is still passed through for eval).
dataset_subset: Optional HuggingFace subset name (``load_dataset(..., name=...)``); used by bench
@@ -270,7 +332,8 @@ class DailyOmniDataset(BenchmarkDataset):
inline_local_video: If True, embed local MP4 as ``data:video/mp4;base64,...`` in requests so
the API server does not need ``--allowed-local-media-path`` (large JSON; use for small runs).
When ``input_mode`` is ``audio`` or ``all``, local WAV is embedded the same way
(``data:audio/wav;base64,...``).
(``data:audio/wav;base64,...``). For ``minicpm-interleave``, embeds JPEG/WAV segment
data URLs instead of the raw MP4.
trust_remote_code: Whether to trust remote code when loading HuggingFace dataset
(online mode only).
"""
@@ -291,6 +354,7 @@ class DailyOmniDataset(BenchmarkDataset):
random_seed: int = 0,
video_dir: str | None = None,
input_mode: DailyOmniInputMode = "all",
pack_mode: DailyOmniPackMode = "qwen",
inline_local_video: bool = False,
trust_remote_code: bool = False,
max_duration_seconds: float | None = None,
@@ -300,6 +364,8 @@ class DailyOmniDataset(BenchmarkDataset):
) -> None:
if input_mode not in ("all", "visual", "audio"):
raise ValueError(f"input_mode must be 'all', 'visual', or 'audio', got {input_mode!r}")
if pack_mode not in ("qwen", "minicpm-interleave"):
raise ValueError(f"pack_mode must be 'qwen' or 'minicpm-interleave', got {pack_mode!r}")
# Validate arguments: need either local JSON or HF path
if qa_json_path is None and dataset_path is None:
@@ -318,11 +384,14 @@ class DailyOmniDataset(BenchmarkDataset):
self.video_dir = Path(video_dir) if video_dir else None
self.inline_local_video = inline_local_video
self.input_mode: DailyOmniInputMode = input_mode
self.pack_mode: DailyOmniPackMode = pack_mode
self.max_duration_seconds = max_duration_seconds
self.trust_remote_code = trust_remote_code
#: In-process cache of ffprobe durations only (no disk persistence).
self._video_durations: dict[str, float] = {}
#: Cache of MiniCPM 1fps interleaved expansions: video_id -> content parts.
self._minicpm_interleave_cache: dict[str, list[dict[str, Any]]] = {}
# Initialize parent BenchmarkDataset
super().__init__(
@@ -336,11 +405,13 @@ class DailyOmniDataset(BenchmarkDataset):
# Verify dataset info
logger.info(
"Loaded Daily-Omni dataset: mode=%s, source=%s, random_seed=%d, input_mode=%s, max_duration=%s",
"Loaded Daily-Omni dataset: mode=%s, source=%s, random_seed=%d, "
"input_mode=%s, pack_mode=%s, max_duration=%s",
"local_json" if self.qa_json_path else "huggingface",
str(self.qa_json_path) if self.qa_json_path else f"{dataset_path}/{dataset_split}",
random_seed,
input_mode,
pack_mode,
f"{max_duration_seconds}s" if max_duration_seconds else "unlimited",
)
@@ -575,7 +646,8 @@ class DailyOmniDataset(BenchmarkDataset):
messages = self._build_daily_omni_openai_messages(mm_payload, question, choice)
user_text = self._official_daily_omni_user_prompt(question, choice)
# Text-only length estimate (same as before: no MM token count in bench).
prompt_len = len(tokenizer.encode(f"{DAILY_OMNI_SYSTEM_TEXT}\n{user_text}"))
system_text = self._system_text_for_pack_mode()
prompt_len = len(tokenizer.encode(f"{system_text}\n{user_text}" if system_text else user_text))
return DailyOmniSampleRequest(
prompt=user_text,
@@ -766,10 +838,11 @@ class DailyOmniDataset(BenchmarkDataset):
video_id: str,
video_url: str | None,
) -> tuple[dict[str, Any] | list[dict[str, Any]] | None, dict[str, Any] | None, Literal["first", "last"]]:
"""Build ``multi_modal_data`` and request extras for the active ``input_mode``.
"""Build multimodal OpenAI content parts and request extras for the active modes."""
if self.pack_mode == "minicpm-interleave":
return self._compose_minicpm_interleaved_multimodal(video_id, video_url)
Mirrors upstream Daily-Omni: separate video + WAV with ``use_audio_in_video=False``.
"""
# Qwen / upstream Daily-Omni: separate video + WAV with ``use_audio_in_video=False``.
extra: dict[str, Any] = {"mm_processor_kwargs": {"use_audio_in_video": False}}
mode = self.input_mode
@@ -787,6 +860,219 @@ class DailyOmniDataset(BenchmarkDataset):
return None, None, "first"
return [v, a], extra, "first"
def _compose_minicpm_interleaved_multimodal(
self,
video_id: str,
video_url: str | None,
) -> tuple[list[dict[str, Any]] | None, dict[str, Any] | None, Literal["first", "last"]]:
"""Pack media like MiniCPM-o official omni chat: 1fps ``[frame_i, audio_i, ...]``.
OpenBMB's HF / utils path expands ``video_url`` with ``use_audio=True`` into interleaved
PIL frames and 1s audio ndarrays. vLLM-Omni does not implement that expansion, so the
bench client builds ordered ``image_url`` / ``audio_url`` parts instead. MiniCPM-o 5.0's
OpenAI chat template preserves content-part order, so placeholders stay time-aligned.
``max_slice_nums=1`` and ``use_image_id=False`` mirror MiniCPM-o's omni path
(``modeling_minicpmo.py`` passes both for interleaved frames): slicing every frame with
the config default of 9 would blow past ``max_model_len``, and ``<image_id>N</image_id>``
prefixes would label each frame as a separate picture instead of a video timeline.
"""
del video_url # Hub HTTP URLs are not used; local extract is required.
extra: dict[str, Any] = {
"mm_processor_kwargs": {
"use_audio_in_video": False,
"max_slice_nums": 1,
"use_image_id": False,
}
}
mode = self.input_mode
if mode == "audio":
# Fall back to whole-WAV for audio-only ablations.
a = self._get_audio_content(video_id)
return ([a] if a else None), extra, "first"
parts = self._get_minicpm_interleaved_parts(video_id, include_audio=(mode == "all"))
if not parts:
return None, None, "first"
return parts, extra, "first"
def _get_minicpm_interleaved_parts(
self,
video_id: str,
*,
include_audio: bool,
) -> list[dict[str, Any]] | None:
"""Return cached or freshly extracted MiniCPM-style interleaved OpenAI content parts."""
cache_key = f"{video_id}|audio={int(include_audio)}|inline={int(self.inline_local_video)}"
cached = self._minicpm_interleave_cache.get(cache_key)
if cached is not None:
return cached
self._lazy_ensure_hub_media_dir()
video_path = self._resolve_local_video_path(video_id)
if video_path is None:
logger.warning(
"MiniCPM interleave pack requires local video under video_dir=%s for video_id=%r",
self.video_dir,
video_id,
)
return None
audio_path = self._resolve_local_audio_path(video_id) if include_audio else None
if include_audio and audio_path is None:
logger.warning(
"MiniCPM interleave pack (input_mode=all) missing WAV for video_id=%r under %s",
video_id,
self.video_dir,
)
return None
try:
frames, audio_segments = self._extract_minicpm_frame_audio_segments(
video_path,
audio_path=audio_path,
include_audio=include_audio,
)
except Exception:
logger.exception(
"Failed MiniCPM 1fps extract for video_id=%r path=%s",
video_id,
video_path,
)
return None
if not frames:
logger.warning("No frames extracted for video_id=%r", video_id)
return None
if include_audio and len(audio_segments) != len(frames):
logger.warning(
"Frame/audio length mismatch for video_id=%r: frames=%d audios=%d",
video_id,
len(frames),
len(audio_segments),
)
n = min(len(frames), len(audio_segments))
frames = frames[:n]
audio_segments = audio_segments[:n]
cache_root: Path | None = None
if not self.inline_local_video:
assert self.video_dir is not None
cache_root = self.video_dir / ".minicpm_daily_omni_interleave" / video_id
cache_root.mkdir(parents=True, exist_ok=True)
parts: list[dict[str, Any]] = []
for i, frame in enumerate(frames):
jpeg = _pil_to_jpeg_bytes(frame)
if self.inline_local_video:
b64 = base64.b64encode(jpeg).decode("ascii")
parts.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
}
)
else:
assert cache_root is not None
frame_path = cache_root / f"frame_{i:04d}.jpg"
if not frame_path.is_file() or frame_path.stat().st_size == 0:
frame_path.write_bytes(jpeg)
parts.append(
{
"type": "image_url",
"image_url": {"url": frame_path.expanduser().resolve().as_uri()},
}
)
if include_audio:
wav = _numpy_to_wav_bytes(audio_segments[i])
if self.inline_local_video:
b64 = base64.b64encode(wav).decode("ascii")
parts.append(
{
"type": "audio_url",
"audio_url": {"url": f"data:audio/wav;base64,{b64}"},
}
)
else:
assert cache_root is not None
audio_seg_path = cache_root / f"audio_{i:04d}.wav"
if not audio_seg_path.is_file() or audio_seg_path.stat().st_size == 0:
audio_seg_path.write_bytes(wav)
parts.append(
{
"type": "audio_url",
"audio_url": {"url": audio_seg_path.expanduser().resolve().as_uri()},
}
)
# File URLs are tiny; cache them so repeated QA on the same video skips re-extract.
# Inline base64 must not be retained: ~700 Daily-Omni videos would pin GBs of RSS.
if not self.inline_local_video:
self._minicpm_interleave_cache[cache_key] = parts
logger.debug(
"MiniCPM interleave packed video_id=%r frames=%d include_audio=%s parts=%d",
video_id,
len(frames),
include_audio,
len(parts),
)
return parts
@staticmethod
def _extract_minicpm_frame_audio_segments(
video_path: Path,
*,
audio_path: Path | None,
include_audio: bool,
max_num_frames: int = _MINICPM_MAX_NUM_FRAMES,
) -> tuple[list[Any], list[Any]]:
"""Port of MiniCPM ``get_video_frame_audio_segments`` (stack_frames=1, 1fps)."""
import numpy as np
from decord import VideoReader, cpu
from PIL import Image
from vllm.multimodal.media.audio import load_audio
vr = VideoReader(str(video_path), ctx=cpu(0))
avg_fps = float(vr.get_avg_fps()) or 1.0
duration = len(vr) / avg_fps
num_seconds = max(1, int(math.ceil(duration)))
second_timestamps = list(range(num_seconds))
if duration > max_num_frames:
timestamps = [round(i * 0.1, 1) for i in range(int(duration / 0.1))]
frame_idx = [min(int(ts * avg_fps), len(vr) - 1) for ts in timestamps]
pick = _uniform_sample_indices(len(frame_idx), max_num_frames)
frame_idx = [frame_idx[i] for i in pick]
timestamps = [timestamps[i] for i in pick]
else:
frame_idx = [min(int(i * avg_fps), len(vr) - 1) for i in range(num_seconds)]
timestamps = second_timestamps
video = vr.get_batch(frame_idx).asnumpy()
frames = [Image.fromarray(v.astype("uint8")).convert("RGB") for v in video]
audio_segments: list[Any] = []
if include_audio:
load_path = str(audio_path) if audio_path is not None else str(video_path)
audio_np, sr = load_audio(load_path, sr=_MINICPM_AUDIO_SR, mono=True)
for i, start_time in enumerate(timestamps):
end_time = timestamps[i + 1] if i < len(timestamps) - 1 else duration
start_sample = int(start_time * sr)
end_sample = int(end_time * sr)
segment = audio_np[start_sample:end_sample]
if i == len(timestamps) - 1 and len(segment) < 1600:
segment = np.concatenate([segment, np.zeros(1600 - len(segment), dtype=segment.dtype)])
if len(segment) == 0:
segment = np.zeros(1600, dtype=np.float32)
audio_segments.append(segment)
return frames, audio_segments
def _system_text_for_pack_mode(self) -> str:
if self.pack_mode == "minicpm-interleave":
return MINICPM_OMNI_SYSTEM_TEXT
return DAILY_OMNI_SYSTEM_TEXT
@staticmethod
def _media_desc_for_official_prompt(mode: DailyOmniInputMode) -> str:
"""``media_desc`` in upstream ``build_conversation``."""
@@ -835,14 +1121,19 @@ class DailyOmniDataset(BenchmarkDataset):
question: str,
choice: Any,
) -> list[dict[str, Any]]:
"""Map upstream conversation to OpenAI Chat Completions ``messages`` (video_url / audio_url parts)."""
"""Map upstream conversation to OpenAI Chat Completions ``messages``."""
user_text = self._official_daily_omni_user_prompt(question, choice)
mm_list: list[dict[str, Any]] = mm_payload if isinstance(mm_payload, list) else [mm_payload]
user_content: list[dict[str, Any]] = [*mm_list, {"type": "text", "text": user_text}]
return [
{"role": "system", "content": [{"type": "text", "text": DAILY_OMNI_SYSTEM_TEXT}]},
{"role": "user", "content": user_content},
]
system_text = self._system_text_for_pack_mode()
messages: list[dict[str, Any]] = []
# MiniCPM-o ``get_sys_prompt(mode="omni")`` still emits role=system with empty
# content (no ref-audio). Truthiness would drop that role and change chat-template
# control tokens; keep the empty system message for minicpm-interleave.
if self.pack_mode == "minicpm-interleave" or system_text:
messages.append({"role": "system", "content": [{"type": "text", "text": system_text}]})
messages.append({"role": "user", "content": user_content})
return messages
def sample_by_task_type(
self,
@@ -900,6 +1191,7 @@ class DailyOmniDataset(BenchmarkDataset):
f"dataset_split={self.dataset_split!r}, "
f"video_dir={self.video_dir!r}, "
f"input_mode={self.input_mode!r}, "
f"pack_mode={self.pack_mode!r}, "
f"inline_local_video={self.inline_local_video!r}, "
f"max_duration_seconds={self.max_duration_seconds}, "
f"random_seed={self.random_seed}"
+2
View File
@@ -280,6 +280,7 @@ def get_samples(args, tokenizer):
random_seed=args.seed,
video_dir=video_dir,
input_mode=getattr(args, "daily_omni_input_mode", "all"),
pack_mode=getattr(args, "daily_omni_pack_mode", "qwen"),
inline_local_video=getattr(args, "daily_omni_inline_local_video", False),
trust_remote_code=getattr(args, "trust_remote_code", False),
disable_shuffle=getattr(args, "disable_shuffle", False),
@@ -310,6 +311,7 @@ def get_samples(args, tokenizer):
random_seed=args.seed,
video_dir=video_dir,
input_mode=getattr(args, "daily_omni_input_mode", "all"),
pack_mode=getattr(args, "daily_omni_pack_mode", "qwen"),
inline_local_video=getattr(args, "daily_omni_inline_local_video", False),
trust_remote_code=getattr(args, "trust_remote_code", False),
no_stream=getattr(args, "no_stream", False),
+2 -2
View File
@@ -147,7 +147,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
return None
def _request_omits_kv_transfer_to_next_stage(self, request: Request) -> bool:
"""True when orchestrator will not run stage 1+ for this request (e.g. text-only).
"""True when this stage-zero-final request does not need downstream KV.
The result is cached per request to avoid repeated deserialization of
additional_information on every scheduler tick.
@@ -162,7 +162,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
result = False
else:
info = deserialize_additional_information(payload)
result = info.get("omni_final_stage_id") == 0
result = info.get("omni_final_stage_id") == 0 and not bool(info.get("omni_force_kv_transfer", False))
self._omits_kv_transfer_cache[rid] = result
return result
@@ -56,6 +56,24 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
)
self._latest_omni_connector_output: OmniConnectorOutput | None = None
@staticmethod
def _record_prefill_stats(request: Request) -> None:
"""Mirror upstream first-prefill prompt accounting for generation stages.
Async-chunk generation stages can grow ``prompt_token_ids`` after the
first schedule; until this path tracks final chunk lengths, this records
the first scheduled prompt snapshot. Keep this in sync with vLLM
Scheduler.schedule() if this fast path gains prefix-cache or external-KV
accounting.
"""
if request.prefill_stats is None:
return
request.prefill_stats.set(
num_prompt_tokens=request.num_prompt_tokens,
num_local_cached_tokens=0,
num_external_cached_tokens=0,
)
def _handle_stopped_request(self, request: Request) -> bool:
if (
request.resumable
@@ -147,6 +165,8 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
break
if self.log_stats:
request.record_event(EngineCoreEventType.SCHEDULED, scheduled_timestamp)
if num_computed_tokens == 0:
self._record_prefill_stats(request)
req_to_new_blocks[request.request_id] = new_blocks
num_scheduled_tokens[request.request_id] = num_new_tokens
cached_prompt_token_ids[request.request_id] = request.prompt_token_ids
@@ -213,6 +233,8 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
self.running.append(request)
if self.log_stats:
request.record_event(EngineCoreEventType.SCHEDULED, scheduled_timestamp)
if request.num_computed_tokens == 0:
self._record_prefill_stats(request)
req_to_new_blocks[request.request_id] = new_blocks
num_scheduled_tokens[request.request_id] = num_new_tokens
+13 -4
View File
@@ -24,9 +24,10 @@ stages:
max_num_batched_tokens: 16384
devices: "0"
limit_mm_per_prompt:
video:
count: 1
num_frames: 32
# Daily-Omni MiniCPM pack: up to 64 interleaved image/audio pairs (1fps).
image: 64
audio: 64
video: 1
default_sampling_params:
temperature: 0.0
top_p: 1.0
@@ -34,7 +35,8 @@ stages:
max_tokens: 2048
seed: 42
detokenize: true
repetition_penalty: 1.1
# 1.0 for MCQ accuracy; 1.1 can bias short A–D answers.
repetition_penalty: 1.0
- stage_id: 1
max_num_seqs: 4
@@ -75,6 +77,13 @@ stages:
detokenize: true
platforms:
cuda:
stages:
- stage_id: 1
# Four Talker streams need much less KV than automatic sizing.
# Leave single-GPU headroom for concurrent Stage 2 diffusion batches.
kv_cache_memory_bytes: 2147483648 # 2 GiB
npu:
stages:
- stage_id: 0
@@ -15,3 +15,10 @@ stages:
devices: "1,2"
num_replicas: 2
async_scheduling: false
platforms:
cuda:
stages:
- stage_id: 1
# Stage 1 has dedicated GPUs in this profile; retain automatic sizing.
kv_cache_memory_bytes: null
@@ -15,3 +15,10 @@ stages:
devices: "1,2,3"
num_replicas: 3
async_scheduling: false
platforms:
cuda:
stages:
- stage_id: 1
# Stage 1 has dedicated GPUs in this profile; retain automatic sizing.
kv_cache_memory_bytes: null
@@ -640,7 +640,9 @@ class FluxKontextPipeline(
width = req.sampling_params.width or width
num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps
sigmas = req.sampling_params.sigmas or sigmas
guidance_scale = req.sampling_params.guidance_scale or guidance_scale
guidance_scale = (
req.sampling_params.guidance_scale if req.sampling_params.guidance_scale_provided else guidance_scale
)
num_images_per_prompt = (
req.sampling_params.num_outputs_per_prompt
if req.sampling_params.num_outputs_per_prompt > 0
@@ -733,7 +733,7 @@ class GlmImagePipeline(nn.Module, DiffusionPipelineProfilerMixin, SupportsCompon
)
width = ar_width or req.sampling_params.width or img_width or self.default_sample_size * self.vae_scale_factor
num_inference_steps = req.sampling_params.num_inference_steps or 50
guidance_scale = req.sampling_params.guidance_scale or 1.5
guidance_scale = req.sampling_params.guidance_scale if req.sampling_params.guidance_scale_provided else 1.5
# Ensure dimensions are multiples of vae_scale_factor * patch_size
multiple_of = self.vae_scale_factor * self._patch_size
@@ -353,7 +353,7 @@ class FlowMatchingAudioPipeline(
sampling_params.extra_args = extra_args
num_inference_steps = sampling_params.num_inference_steps or 32
guidance_scale = sampling_params.guidance_scale or 3.0
guidance_scale = sampling_params.guidance_scale if sampling_params.guidance_scale_provided else 3.0
generator = self._resolve_diffusion_generator(sampling_params)
with self._stage_timer("consume_payload"):
@@ -37,7 +37,7 @@ from vllm_omni.diffusion.postprocess import interpolate_video_tensor
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.inputs.data import OmniTextPrompt
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt
from vllm_omni.platforms import current_omni_platform
logger = logging.getLogger(__name__)
@@ -84,6 +84,24 @@ def resolve_wan_flow_shift(req: OmniDiffusionRequest, od_config: OmniDiffusionCo
raise ValueError(f"Invalid flow_shift={raw_flow_shift!r}. flow_shift must be a float.") from exc
def resolve_wan_guidance_scales(
sampling_params: OmniDiffusionSamplingParams,
default_guidance_scale: float,
) -> tuple[float, float]:
guidance_scale = (
sampling_params.guidance_scale if sampling_params.guidance_scale_provided else default_guidance_scale
)
guidance_low = guidance_scale if isinstance(guidance_scale, (int, float)) else guidance_scale[0]
guidance_high = (
sampling_params.guidance_scale_2
if sampling_params.guidance_scale_2_provided and sampling_params.guidance_scale_2 is not None
else (
guidance_scale[1] if isinstance(guidance_scale, (list, tuple)) and len(guidance_scale) > 1 else guidance_low
)
)
return guidance_low, guidance_high
def retrieve_latents(
encoder_output: torch.Tensor,
generator: torch.Generator | None = None,
@@ -571,25 +589,10 @@ class Wan22Pipeline(
width = (width // mod_value) * mod_value
num_steps = 40 if req.sampling_params.num_inference_steps is None else req.sampling_params.num_inference_steps
# Respect per-request guidance_scale when explicitly provided.
if req.sampling_params.guidance_scale_provided:
guidance_scale = req.sampling_params.guidance_scale
else:
guidance_scale = 4.0
output_type = req.sampling_params.output_type or "np"
attention_kwargs: dict | None = None
guidance_low = guidance_scale if isinstance(guidance_scale, (int, float)) else guidance_scale[0]
guidance_high = (
req.sampling_params.guidance_scale_2
if req.sampling_params.guidance_scale_2 is not None
else (
guidance_scale[1]
if isinstance(guidance_scale, (list, tuple)) and len(guidance_scale) > 1
else guidance_low
)
)
guidance_low, guidance_high = resolve_wan_guidance_scales(req.sampling_params, default_guidance_scale=4.0)
# record guidance for properties
self._guidance_scale = guidance_low
@@ -36,6 +36,7 @@ from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
create_transformer_from_config,
load_transformer_config,
resolve_wan_flow_shift,
resolve_wan_guidance_scales,
resolve_wan_sample_solver,
retrieve_latents,
)
@@ -475,26 +476,10 @@ class Wan22I2VPipeline(
num_frames = req.sampling_params.num_frames or 81
num_steps = 40 if req.sampling_params.num_inference_steps is None else req.sampling_params.num_inference_steps
# Respect per-request guidance_scale when explicitly provided.
if req.sampling_params.guidance_scale_provided:
guidance_scale = req.sampling_params.guidance_scale
else:
guidance_scale = 5.0
output_type = req.sampling_params.output_type or "np"
attention_kwargs: dict | None = None
# Handle guidance scales
guidance_low = guidance_scale if isinstance(guidance_scale, (int, float)) else guidance_scale[0]
guidance_high = (
req.sampling_params.guidance_scale_2
if req.sampling_params.guidance_scale_2 is not None
else (
guidance_scale[1]
if isinstance(guidance_scale, (list, tuple)) and len(guidance_scale) > 1
else guidance_low
)
)
guidance_low, guidance_high = resolve_wan_guidance_scales(req.sampling_params, default_guidance_scale=5.0)
self._guidance_scale = guidance_low
self._guidance_scale_2 = guidance_high
+5 -6
View File
@@ -41,12 +41,11 @@ class OmniDiffusionRequest:
if self.sampling_params.generator is None and self.sampling_params.seed is None:
self.sampling_params.seed = random.randint(0, 2**31 - 1)
# Detect whether user explicitly provided guidance_scale.
# The sentinel default is 0.0 (false-like); any truthy value means
# the caller set it intentionally. We must resolve this BEFORE
# auto-filling guidance_scale_2, otherwise the sentinel leaks into
# guidance_scale_2.
if self.sampling_params.guidance_scale:
# Detect whether the caller explicitly provided guidance_scale before
# resolving the omitted value. ``0.0`` is API-valid and must not be
# treated as omission because that would unexpectedly enable CFG in
# pipelines with a model-specific default above zero.
if self.sampling_params.guidance_scale is not None:
self.sampling_params.guidance_scale_provided = True
else:
self.sampling_params.guidance_scale = 1.0
@@ -409,6 +409,7 @@ class OmniKVTransferManager:
self._prefetch_executor: ThreadPoolExecutor | None = (
ThreadPoolExecutor(max_workers=1, thread_name_prefix="kv-prefetch") if async_prefetch else None
)
self._prefetch_futures: dict[str, Any] = {}
self._bg_copy_stream: current_omni_platform.Stream | None = None
@@ -421,6 +422,11 @@ class OmniKVTransferManager:
except Exception as e:
logger.warning("Failed to eagerly initialize sender connector: %s", e)
@property
def tp_topology(self) -> KVTPTopology:
"""Return the effective topology after config and runtime detection."""
return self._tp_topo
@property
def topo_config(self) -> _TransferTopoConfig:
"""Lazily build the parallel topology snapshot on first access."""
@@ -7,6 +7,7 @@ from collections.abc import Callable, Mapping
from typing import Any
import torch
from vllm.v1.metrics.stats import PrefillStats
from vllm.v1.request import Request, RequestStatus
from vllm_omni.data_entry_keys import MetaStruct, OmniPayloadStruct, unflatten_payload
@@ -109,6 +110,12 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
num_placeholders = int(getattr(request, "num_output_placeholders", 0) or 0)
return max(0, num_computed - num_placeholders)
@staticmethod
def _refresh_generation_chunk_prefill_state(request: Request) -> None:
request.num_prompt_tokens = len(request.prompt_token_ids)
if getattr(request, "prefill_stats", None) is None:
request.prefill_stats = PrefillStats()
@classmethod
def create_connector(cls, model_config: Any):
connector_config = getattr(model_config, "stage_connector_config", None)
@@ -249,15 +256,18 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
new_ids = payload_data.get("codes", {}).get("audio")
has_tensor_codes = isinstance(new_ids, torch.Tensor)
use_tensor_codes = has_tensor_codes and new_ids.ndim >= 2
prompt_token_ids: list[int]
if use_tensor_codes:
request.prompt_token_ids = [0] if new_ids.numel() > 0 else []
prompt_token_ids = [0] if new_ids.numel() > 0 else []
elif has_tensor_codes:
new_ids = new_ids.tolist()
prompt_token_ids = new_ids
elif new_ids is None:
new_ids = []
request.prompt_token_ids = new_ids
if not use_tensor_codes:
request.prompt_token_ids = new_ids
prompt_token_ids = new_ids
else:
prompt_token_ids = new_ids
request.prompt_token_ids = prompt_token_ids
prev_info = getattr(request, "additional_information", None)
info = dict(prev_info) if isinstance(prev_info, dict) else {}
for key, value in payload_data.items():
@@ -293,6 +303,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
# chunk as ready, otherwise Stage1 can consume before the
# first DAC frame arrives.
return False
self._refresh_generation_chunk_prefill_state(request)
# Mark as finished for consumption
self._finished_load_reqs.add(req_id)
+6 -1
View File
@@ -77,12 +77,17 @@ def upgrade_to_omni_request(
def apply_omni_final_stage_metadata(
request: EngineCoreRequest,
final_stage_id: int,
*,
force_kv_transfer: bool = False,
) -> EngineCoreRequest:
"""Tag a request so the AR scheduler can skip DiT KV for stage zero."""
"""Tag a request with its final stage and optional KV-transfer override."""
merged: dict[str, Any] = {}
if isinstance(request, OmniEngineCoreRequest) and request.additional_information is not None:
merged = deserialize_additional_information(request.additional_information)
merged["omni_final_stage_id"] = final_stage_id
merged.pop("omni_force_kv_transfer", None)
if force_kv_transfer:
merged["omni_force_kv_transfer"] = True
payload = serialize_additional_information(merged)
return OmniEngineCoreRequest.from_request(
request,
+4 -5
View File
@@ -331,6 +331,7 @@ class AsyncOmniEngine:
omni_heartbeat_timeout=self._omni_heartbeat_timeout,
omni_lb_policy=self._omni_lb_policy,
request_queue=self.request_queue,
log_stats=self._log_stats,
)
self._runtime.initialize()
@@ -797,11 +798,9 @@ class AsyncOmniEngine:
supported_tasks=self.supported_tasks,
)
request.external_req_id = cid
# Companions are stage-0-final: without this worker-visible tag
# the AR runner would still emit downstream connector payloads
# for them (the orchestrator-side final_stage_id=0 registration
# alone does not reach the worker).
request = apply_omni_final_stage_metadata(request, 0)
# Companions are stage-0-final for ordinary downstream payloads,
# but diffusion still needs their CFG KV caches.
request = apply_omni_final_stage_metadata(request, 0, force_kv_transfer=True)
# Registration of this companion on stage-0's output processor is
# deferred to Orchestrator._handle_add_companion, which routes
+11 -9
View File
@@ -362,6 +362,7 @@ class Orchestrator:
self.async_chunk = bool(async_chunk)
self.num_stages = len(stage_pools)
self.stage_pools: list[StagePool] = stage_pools
self.log_stats = log_stats
self._orch_monitor = create_orch_monitor(
enabled=enable_orch_monitor,
replica_sampler=self._sample_replica_metrics,
@@ -446,7 +447,10 @@ class Orchestrator:
already no-ops on ``scheduler_stats is None`` (which is what
the upstream scheduler returns when its own log_stats is False),
so this gate is mainly to keep the ``/metrics`` surface clean
when the user did not request stats.
when the user did not request stats. When stats are enabled, record()
must still receive IterationStats even on ticks where scheduler_stats
is None; upstream PrometheusStatLogger records token/request metrics
from iteration_stats independently of scheduler gauges.
"""
self._running_counter = running_counter
self._transfer_emitter = transfer_emitter
@@ -937,19 +941,17 @@ class Orchestrator:
)
if req_state.streaming.enabled:
await self._apply_raw_terminal_stage_finish(stage_id, eco, req_state)
# OmniSchedulerMixin.make_stats() already throttles
# per-scheduler at 1 Hz, so raw_outputs.scheduler_stats
# being non-None means this replica passed its own gate.
# A second global throttle here would drop stats for
# other (stage, replica) pairs in the same 1s window.
record_stats = self._stat_logger is not None and raw_outputs.scheduler_stats is not None
iteration_stats = IterationStats() if record_stats else None
iteration_stats = (
IterationStats() if (self._stat_logger is not None and raw_outputs.outputs) else None
)
raw_output = await pool.process_llm_raw_outputs(
replica_id,
raw_outputs,
iteration_stats=iteration_stats,
)
if record_stats:
if self._stat_logger is not None and (
raw_outputs.scheduler_stats is not None or iteration_stats is not None
):
self._stat_logger.record(
raw_outputs.scheduler_stats,
iteration_stats,
+5 -2
View File
@@ -1081,7 +1081,11 @@ class StagePool:
async def _poll_stage_raw(self, client: StagePoolLLMClient) -> EngineCoreOutputs | None:
"""Pull raw EngineCoreOutputs from a stage replica without processing."""
outputs = await client.get_output_async()
if not outputs.outputs:
# Keep scheduler-only / finished-only batches. Omni schedulers mirror
# upstream by emitting SchedulerStats on throttled ticks even when no
# request output is produced, and dropping those batches loses KV/queue
# gauges for that interval.
if not outputs.outputs and outputs.scheduler_stats is None and not outputs.finished_requests:
return None
return outputs
@@ -1097,7 +1101,6 @@ class StagePool:
return []
client = cast(StagePoolLLMClient, raw_client)
processor = self.output_processor
iteration_stats = IterationStats()
processed = processor.process_outputs(
raw_outputs.outputs,
raw_outputs.timestamp,
+16 -3
View File
@@ -126,6 +126,7 @@ class StageRuntime:
diffusion_batch_size: int,
async_chunk: bool,
tokenizer: str | None = None,
log_stats: bool = False,
) -> None:
self._stage_configs = stage_configs
self._model = model
@@ -134,6 +135,7 @@ class StageRuntime:
self._diffusion_batch_size = diffusion_batch_size
self._async_chunk = async_chunk
self._tokenizer = tokenizer
self._log_stats = log_stats
self._num_stages = len(stage_configs)
# Populated by initialize()
@@ -575,7 +577,7 @@ class StageRuntime:
with launch_stage_replica(
vllm_config=vllm_config,
executor_class=executor_class,
log_stats=False,
log_stats=self._log_stats,
stage_id=plan.metadata.stage_id,
replica_id=plan.replica_id,
stage_config=plan.stage_cfg,
@@ -594,6 +596,7 @@ class StageRuntime:
stage_client = StageEngineCoreClientBase.make_async_mp_client(
vllm_config=vllm_config,
executor_class=executor_class,
log_stats=self._log_stats,
metadata=plan.metadata,
client_addresses=self._client_addresses_from_zmq(resources.addresses),
engine_manager=resources.manager,
@@ -704,7 +707,11 @@ class StageRuntime:
stage_vllm_config = plan.replicas[0].stage_vllm_config
if stage_vllm_config is None:
raise RuntimeError(f"Stage {plan.stage_id} is missing vllm_config")
output_processor = build_llm_stage_output_processor(plan, stage_vllm_config)
output_processor = build_llm_stage_output_processor(
plan,
stage_vllm_config,
log_stats=self._log_stats,
)
stage_pools.append(
StagePool(
@@ -742,10 +749,11 @@ class DistStageRuntime(StageRuntime):
stage_init_timeout: int,
diffusion_batch_size: int,
async_chunk: bool,
tokenizer: str | None = None,
single_stage_id_filter: int | None,
omni_master_address: str,
omni_master_port: int,
tokenizer: str | None = None,
log_stats: bool = False,
omni_dp_size_local: int = 1,
omni_heartbeat_timeout: float = 30.0,
omni_lb_policy: str = "random",
@@ -759,6 +767,7 @@ class DistStageRuntime(StageRuntime):
diffusion_batch_size=diffusion_batch_size,
async_chunk=async_chunk,
tokenizer=tokenizer,
log_stats=log_stats,
)
self._single_stage_id_filter = single_stage_id_filter
self._omni_master_address = omni_master_address
@@ -1051,6 +1060,7 @@ class DistStageRuntime(StageRuntime):
client = StageEngineCoreClientBase.make_async_mp_client(
vllm_config=vllm_config,
executor_class=ctx.executor_class,
log_stats=self._log_stats,
metadata=metadata,
client_addresses=client_addresses,
engine_manager=resources.manager,
@@ -1089,6 +1099,7 @@ def create_stage_runtime(
omni_heartbeat_timeout: float = 30.0,
omni_lb_policy: str = "random",
request_queue: janus.Queue[EngineQueueMessage] | None = None,
log_stats: bool = False,
) -> StageRuntime:
"""Factory: select StageRuntime or DistStageRuntime."""
if single_stage_mode:
@@ -1102,6 +1113,7 @@ def create_stage_runtime(
diffusion_batch_size=diffusion_batch_size,
async_chunk=async_chunk,
tokenizer=tokenizer,
log_stats=log_stats,
single_stage_id_filter=single_stage_id_filter,
omni_master_address=omni_master_address,
omni_master_port=omni_master_port,
@@ -1118,4 +1130,5 @@ def create_stage_runtime(
diffusion_batch_size=diffusion_batch_size,
async_chunk=async_chunk,
tokenizer=tokenizer,
log_stats=log_stats,
)
@@ -95,6 +95,23 @@ def add_daily_omni_cli_args(parser: argparse.ArgumentParser) -> None:
"'all': video + WAV together. Sets mm_processor_kwargs.use_audio_in_video=false "
"and matches official separate video/audio streams.",
)
group.add_argument(
"--daily-omni-pack-mode",
type=str,
choices=["qwen", "minicpm-interleave"],
default="qwen",
help="How to pack multimodal parts into OpenAI chat messages. "
"'qwen' (default): one video_url + one audio_url (Daily-Omni/Qwen protocol). "
"'minicpm-interleave': MiniCPM-o official recipe — 1fps frames interleaved with "
"matching 1s audio segments as image_url/audio_url pairs (needed to approach "
"OpenBMB ~80%% Daily-Omni; requires local Videos extract). By default this mode "
"writes JPEG/WAV segments under "
"<daily-omni-video-dir>/.minicpm_daily_omni_interleave and sends file:// URLs, "
"so start the server with --allowed-local-media-path <daily-omni-video-dir> "
"(or a parent). Alternatively pass --daily-omni-inline-local-video to embed "
"segments as data URLs. For MiniCPM-o 4.5 string chat templates also start the "
"server with --interleave-mm-strings.",
)
group.add_argument(
"--daily-omni-save-eval-items",
action="store_true",
+1
View File
@@ -612,6 +612,7 @@ class OmniBase(PDDisaggregationMixin):
request_id=req_id or "",
finished=finished,
stage_id=stage_id,
replica_id=result.replica_id,
final_output_type=output_type,
request_output=engine_outputs,
images=images,
+44 -34
View File
@@ -98,7 +98,6 @@ from vllm_omni.entrypoints.openai.duplex_capability import should_enable_duplex_
from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError
from vllm_omni.entrypoints.openai.image_api_utils import (
SUPPORTED_LAYERED_RESOLUTIONS,
encode_image_base64,
encode_image_base64_with_compression,
parse_size,
validate_layered_layers,
@@ -1731,6 +1730,39 @@ async def show_available_models(raw_request: Request) -> JSONResponse:
# Image generation API endpoints
def _build_image_generation_response(
*,
images: list[Image.Image],
request: ImageGenerationRequest,
stage_durations: Any,
peak_memory_mb: Any,
) -> ImageGenerationResponse | StreamingResponse:
"""Encode generated images and apply the requested response format."""
output_format = _choose_output_format(request.output_format or "png", None)
image_data = [
ImageData(
b64_json=encode_image_base64_with_compression(image, format=output_format),
revised_prompt=None,
)
for image in images
]
response_kwargs: dict[str, Any] = {
"created": int(time.time()),
"data": image_data,
"output_format": output_format,
"metrics": {
"stage_durations": stage_durations or None,
"peak_memory_mb": float(peak_memory_mb) if peak_memory_mb else None,
},
}
if request.size is not None:
response_kwargs["size"] = request.size
response = ImageGenerationResponse(**response_kwargs)
if request.response_format == ResponseFormat.FILE:
return response.stream_response()
return response
@router.post(
"/v1/images/generations",
dependencies=[Depends(validate_json_request)],
@@ -1832,15 +1864,11 @@ async def generate_images(
content=generation_result.model_dump(),
)
flat_images, stage_durations, peak_memory_mb, _ = generation_result
image_data = [ImageData(b64_json=encode_image_base64(img), revised_prompt=None) for img in flat_images]
return ImageGenerationResponse(
created=int(time.time()),
data=image_data,
metrics={
"stage_durations": stage_durations or None,
"peak_memory_mb": float(peak_memory_mb) if peak_memory_mb else None,
},
return _build_image_generation_response(
images=flat_images,
request=request,
stage_durations=stage_durations,
peak_memory_mb=peak_memory_mb,
)
# Build params - pass through user values directly
@@ -1928,32 +1956,14 @@ async def generate_images(
logger.debug(f"Successfully generated {len(images)} image(s)")
# Determine output format (default to png)
output_format = _choose_output_format(request.output_format or "png", None)
# Encode images to base64 with the specified format
image_data = [
ImageData(b64_json=encode_image_base64_with_compression(img, format=output_format), revised_prompt=None)
for img in images
]
stage_durations = getattr(result, "stage_durations", None)
peak_memory_mb = getattr(result, "peak_memory_mb", None)
response_kwargs = {
"created": int(time.time()),
"data": image_data,
"output_format": output_format,
"metrics": {
"stage_durations": stage_durations or None,
"peak_memory_mb": float(peak_memory_mb) if peak_memory_mb else None,
},
}
if request.size:
response_kwargs["size"] = size_str
response = ImageGenerationResponse(**response_kwargs)
if request.response_format != ResponseFormat.FILE:
return response
return response.stream_response()
return _build_image_generation_response(
images=images,
request=request,
stage_durations=stage_durations,
peak_memory_mb=peak_memory_mb,
)
except (EngineGenerateError, EngineDeadError) as exc:
return _create_engine_error_json_response(raw_request, exc)
@@ -101,13 +101,14 @@ def encode_image_base64_with_compression(
"""
buffer = io.BytesIO()
image = prepare_image_for_output_format(image, format)
pil_format = "jpeg" if format == "jpg" else format
save_kwargs = {}
if format in ("jpg", "jpeg", "webp"):
save_kwargs["quality"] = output_compression
elif format == "png":
save_kwargs["compress_level"] = max(0, min(9, 9 - output_compression // 11))
image.save(buffer, format=format, **save_kwargs)
image.save(buffer, format=pil_format, **save_kwargs)
buffer.seek(0)
return base64.b64encode(buffer.read()).decode("utf-8")
@@ -21,6 +21,13 @@ from pydantic import BaseModel, Field, field_validator
from vllm_omni.entrypoints.openai.image_api_utils import validate_layered_layers
_IMAGE_FILE_METADATA = {
"jpg": ("jpg", "image/jpeg"),
"jpeg": ("jpeg", "image/jpeg"),
"png": ("png", "image/png"),
"webp": ("webp", "image/webp"),
}
class ResponseFormat(str, Enum):
"""Image response format"""
@@ -183,7 +190,7 @@ class ImageGenerationResponse(BaseModel):
created: int = Field(..., description="Unix timestamp of when the generation completed")
data: list[ImageData] = Field(..., description="Array of generated images")
output_format: str = Field(None, description="The output format of the image generation")
output_format: str | None = Field(None, description="The output format of the image generation")
size: str = Field(None, description="The size of the image generated")
cot_output: str | None = Field(
None,
@@ -201,12 +208,16 @@ class ImageGenerationResponse(BaseModel):
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
detail="No image data available for file response.",
)
extension, media_type = _IMAGE_FILE_METADATA.get(
(self.output_format or "png").lower(),
_IMAGE_FILE_METADATA["png"],
)
if len(self.data) == 1:
image_bytes = base64.b64decode(self.data[0].b64_json)
filename = f"image_{uuid.uuid4().hex[:8]}.png"
filename = f"image_{uuid.uuid4().hex[:8]}.{extension}"
return StreamingResponse(
io.BytesIO(image_bytes),
media_type="image/png",
media_type=media_type,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(len(image_bytes)),
@@ -217,7 +228,7 @@ class ImageGenerationResponse(BaseModel):
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for idx, item in enumerate(self.data):
if item.b64_json:
zf.writestr(f"image_{idx}.png", base64.b64decode(item.b64_json))
zf.writestr(f"image_{idx}.{extension}", base64.b64decode(item.b64_json))
zip_bytes = zip_buffer.getvalue()
filename = f"images_{uuid.uuid4().hex[:8]}.zip"
return StreamingResponse(
+13 -9
View File
@@ -2062,15 +2062,19 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
now_ts = time.time()
if req_state is not None and req_state.first_audio_ts is None:
req_state.first_audio_ts = now_ts
stage_pools = getattr(self.engine_client.engine, "stage_pools", None)
# The orchestrator binds requests by their internal id,
# not the user-visible external id, so look up the
# replica with req_state.request_id (internal).
replica_id = (
stage_pools[omni_res.stage_id].get_bound_replica_id(req_state.request_id)
if stage_pools is not None and 0 <= omni_res.stage_id < len(stage_pools)
else None
)
replica_id = getattr(omni_res, "replica_id", None)
if replica_id is None:
stage_pools = getattr(self.engine_client.engine, "stage_pools", None)
# Fallback for older outputs. The orchestrator binds
# requests by internal id, but can release that
# binding before the API sees the final audio chunk,
# so prefer the OutputMessage replica copied onto
# OmniRequestOutput above.
replica_id = (
stage_pools[omni_res.stage_id].get_bound_replica_id(req_state.request_id)
if stage_pools is not None and 0 <= omni_res.stage_id < len(stage_pools)
else None
)
req_state.audio_emit_stage_id = omni_res.stage_id
req_state.audio_emit_replica_id = replica_id
observe_audio_first_packet(
@@ -308,9 +308,6 @@ def _normalize_video_tensor(video_tensor: torch.Tensor) -> np.ndarray:
video_tensor = video_tensor.detach().cpu()
if video_tensor.dim() == 5:
raise ValueError("Batched video tensors are not supported for single-video encoding.")
elif video_tensor.dim() == 4 and video_tensor.shape[0] in (3, 4):
# [C, F, H, W] -> [F, H, W, C]
video_tensor = video_tensor.permute(1, 2, 3, 0)
if video_tensor.is_floating_point():
# Cast to float32 first: bf16 (e.g. SANA-WM's refiner output) has no
@@ -329,6 +326,8 @@ def _normalize_single_video_array(video_array: np.ndarray) -> np.ndarray:
if video_array.ndim == 4:
# Convert channel-first layouts to channel-last
# Prefer an explicit channel-last dimension for ambiguous 3/4-frame
# videos before interpreting a leading dimension as channels.
if video_array.shape[0] in (3, 4) and video_array.shape[-1] not in (3, 4):
video_array = np.transpose(video_array, (1, 2, 3, 0))
elif video_array.shape[1] in (3, 4) and video_array.shape[-1] not in (3, 4):
+1 -1
View File
@@ -263,7 +263,7 @@ class OmniDiffusionSamplingParams:
# Scheduler parameters – ``None`` means "not explicitly set by the caller";
# each pipeline's ``forward()`` decides its own model-specific default.
num_inference_steps: int | None = None
guidance_scale: float = 0.0
guidance_scale: float | None = None
guidance_scale_provided: bool = False
guidance_scale_2: float | None = None
guidance_scale_2_provided: bool = False
@@ -65,6 +65,27 @@ class BatchedToken2Wav(nn.Module):
self._token2wav = token2wav
self.flow = token2wav.flow
self.hift = token2wav.hift
hift_parameter = next(self.hift.parameters(), None)
if hift_parameter is not None and hift_parameter.device.type == "cuda":
# Prime the CUDA state used by HiFT during backend construction.
# Otherwise, the first live audio chunk can fail when async stages
# share one GPU.
device = hift_parameter.device
dtype = hift_parameter.dtype
mel_channels = int(self.hift.conv_pre.in_channels)
with (
torch.inference_mode(),
torch.random.fork_rng(devices=[device]),
_autocast_disabled(device),
):
# 50 mel frames match the default first streamed vocoder chunk.
speech, source = self.hift(
torch.zeros((1, mel_channels, 50), device=device, dtype=dtype),
torch.zeros((1, 1, 0), device=device, dtype=dtype),
)
torch.accelerator.synchronize(device)
del speech, source
torch.accelerator.empty_cache()
self.float16 = bool(token2wav.float16)
self.n_timesteps = int(token2wav.n_timesteps)
self.mel_cache_len = int(token2wav.mel_cache_len)
@@ -3412,10 +3412,23 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn
) -> bool:
return False
def get_image_prompt_texts(self, image_size: ImageSize, image_idx: int = 0) -> str:
def get_image_prompt_texts(
self,
image_size: ImageSize,
image_idx: int = 0,
max_slice_nums: int | None = None,
use_image_id: bool | None = None,
) -> str:
# ``process_images`` forwards ``mm_processor_kwargs`` to the pixel path, so the
# placeholder grid must honor the same ``max_slice_nums``; otherwise a request that
# sets it (e.g. interleaved omni frames with max_slice_nums=1) produces fewer image
# features than placeholder ``<unk>`` slots. ``use_image_id`` is likewise per-request
# so callers can match MiniCPM-o's omni path, which emits no ``<image_id>`` prefix.
return self.info.get_slice_image_placeholder(
image_size,
image_idx=image_idx,
max_slice_nums=max_slice_nums,
**({} if use_image_id is None else {"use_image_id": use_image_id}),
)
def get_video_prompt_texts(self, image_size: ImageSize, num_frames: int) -> str:
@@ -3605,13 +3618,21 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn
additional_placeholders.append((modality, sub_pattern))
placeholders += additional_placeholders
image_max_slice_nums = hf_processor_mm_kwargs.get("max_slice_nums")
image_use_image_id = hf_processor_mm_kwargs.get("use_image_id")
def get_image_replacement(item_idx: int):
images = mm_items.get_items("image", (MiniCPMVImageEmbeddingItems, ImageProcessorItems))
image_size = images.get_image_size(item_idx)
return PromptUpdateDetails.select_text(
self.get_image_prompt_texts(image_size, item_idx),
self.get_image_prompt_texts(
image_size,
item_idx,
max_slice_nums=None if image_max_slice_nums is None else int(image_max_slice_nums), # type: ignore[arg-type]
use_image_id=None if image_use_image_id is None else bool(image_use_image_id),
),
"<unk>",
)
+4
View File
@@ -83,6 +83,7 @@ class OmniRequestOutput:
request_id: Unique identifier for this request
finished: Whether generation is complete
stage_id: Identifier of the stage that produced this output (pipeline mode)
replica_id: Identifier of the stage replica that produced this output
final_output_type: Type of output ("text", "image", "audio", "latents")
request_output: The underlying RequestOutput from the stage (pipeline mode)
images: List of generated PIL images (diffusion mode)
@@ -96,6 +97,7 @@ class OmniRequestOutput:
# Pipeline stage fields
stage_id: int | None = None
replica_id: int | None = None
final_output_type: str = "text"
request_output: RequestOutput | None = None
@@ -154,6 +156,7 @@ class OmniRequestOutput:
stage_id: int,
final_output_type: str,
request_output: RequestOutput,
replica_id: int | None = None,
) -> "OmniRequestOutput":
"""Create output from pipeline stage.
@@ -168,6 +171,7 @@ class OmniRequestOutput:
return cls(
request_id=getattr(request_output, "request_id", ""),
stage_id=stage_id,
replica_id=replica_id,
final_output_type=final_output_type,
request_output=request_output,
finished=True,
+22 -2
View File
@@ -523,7 +523,11 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
upstream_outputs.append(eco)
# Handle multimodal-only outputs (generation stages) locally.
mm_request_outputs = self._process_mm_only_outputs(mm_only_outputs)
mm_request_outputs = self._process_mm_only_outputs(
mm_only_outputs,
engine_core_timestamp=engine_core_timestamp,
iteration_stats=iteration_stats,
)
# Delegate text/pooling outputs to upstream.
processed = super().process_outputs(
@@ -537,6 +541,9 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
def _process_mm_only_outputs(
self,
engine_core_outputs: list[EngineCoreOutput],
*,
engine_core_timestamp: float | None = None,
iteration_stats: IterationStats | None = None,
) -> list[OmniRequestOutput | PoolingRequestOutput]:
"""Handle outputs from generation stages that have no detokenizer.
@@ -554,7 +561,15 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
stop_reason = eco.stop_reason
kv_transfer_params = eco.kv_transfer_params
routed_experts = eco.routed_experts
req_state.num_cached_tokens = eco.num_cached_tokens
self._update_stats_from_output(
req_state,
eco,
engine_core_timestamp,
iteration_stats,
)
prefill_stats = getattr(eco, "prefill_stats", None)
if prefill_stats is not None:
req_state.num_cached_tokens = prefill_stats.num_cached_tokens
req_state.is_prefilling = False
is_non_final_audio_chunk = (
@@ -578,6 +593,11 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
is_segment_finished = bool(getattr(eco, "is_segment_finished", False))
if finish_reason is not None and not is_segment_finished and not is_non_final_audio_chunk:
self._finish_request(req_state)
self._update_stats_from_finished(
req_state,
finish_reason,
iteration_stats,
)
return request_outputs
def _update_stats_from_output(
@@ -122,6 +122,19 @@ class OmniConnectorModelRunnerMixin:
# -- heterogeneous TP rank support --
rank_cfg = self._parse_rank_mapping(model_config)
if self._kv_transfer_manager is not None:
topology = getattr(self._kv_transfer_manager, "tp_topology", None)
effective_mapping = (
getattr(topology, "source_tp_size", None),
getattr(topology, "target_tp_size", None),
getattr(topology, "local_rank", None),
)
if all(isinstance(value, int) for value in effective_mapping):
rank_cfg = {
"from_tp": effective_mapping[0],
"to_tp": effective_mapping[1],
"local_rank": effective_mapping[2],
}
self._from_tp: int = rank_cfg["from_tp"]
self._to_tp: int = rank_cfg["to_tp"]
self._local_rank: int = rank_cfg["local_rank"]
@@ -1275,6 +1288,10 @@ class OmniConnectorModelRunnerMixin:
For heterogeneous TP receive, the local rank is the target rank and must
fetch one or more source-rank shards keyed as ``from_rank -> to_rank``.
"""
if self._from_tp <= 1 and self._to_tp <= 1:
resolved_to_stage = self._next_stage_id if to_stage is None else to_stage
return [f"omni_{from_stage}_to_{resolved_to_stage}_kv_cache_{req_id}"]
remote_ranks = self.get_kv_remote_ranks()
return [
self.get_kv_connector_key(
@@ -1307,6 +1324,10 @@ class OmniConnectorModelRunnerMixin:
chunk_id: int = 0,
) -> list[str]:
"""Build send-side connector keys for this rank's KV shard(s)."""
if self._from_tp <= 1 and self._to_tp <= 1:
resolved_to_stage = self._next_stage_id if to_stage is None else to_stage
return [f"omni_{from_stage}_to_{resolved_to_stage}_kv_cache_{req_id}"]
target_ranks = self.get_kv_target_ranks_for_send()
return [
self.get_kv_connector_key(