[Feature] Support per-video audio masks for Qwen Omni (#4656)

Signed-off-by: hbhflw2000 <417911774@qq.com>
This commit is contained in:
Wei authored and GitHub committed 2026-07-20 11:39:59 +08:00
1 parent 98e0a49eb0
commit 10d3a25916
4 files changed
+1338 -137

No files matched your search

@@ -0,0 +1,708 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
import pytest
import torch
from vllm.multimodal.inputs import MultiModalBatchedField, MultiModalSharedField
from vllm.multimodal.processing.processor import PlaceholderFeaturesInfo
from vllm_omni.model_executor.models.qwen2_5_omni.qwen2_5_omni_thinker import (
_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY,
Qwen2_5OmniThinkerMultiModalProcessor,
_coerce_use_audio_in_video_for_hf_processor,
_filter_video_use_audio_in_video_for_uncached_items,
_normalize_use_audio_in_video,
)
from vllm_omni.model_executor.models.qwen3_omni.qwen3_omni_moe_thinker import (
Qwen3OmniMoeThinkerForConditionalGeneration,
Qwen3OmniMoeThinkerMultiModalProcessor,
)
from vllm_omni.outputs import OmniModelRunnerOutput
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
AUDIO_TOKEN_ID = 10
VIDEO_TOKEN_ID = 20
AUDIO_BOS_TOKEN_ID = 30
AUDIO_EOS_TOKEN_ID = 31
IMAGE_TOKEN_ID = 40
class _Feature:
def __init__(self, data: torch.Tensor) -> None:
self.data = data
def _fake_processor() -> SimpleNamespace:
tokenizer = SimpleNamespace(get_vocab=lambda: {"<|audio_pad|>": AUDIO_TOKEN_ID})
processor = SimpleNamespace(audio_token="<|audio_pad|>")
config = SimpleNamespace(audio_token_id=AUDIO_TOKEN_ID)
info = SimpleNamespace(
get_hf_config=lambda: config,
get_hf_processor=lambda: processor,
get_tokenizer=lambda: tokenizer,
)
return SimpleNamespace(info=info)
def _fake_qwen2_prompt_processor() -> SimpleNamespace:
tokenizer = SimpleNamespace(
get_vocab=lambda: {
"<|audio_pad|>": AUDIO_TOKEN_ID,
"<|video_pad|>": VIDEO_TOKEN_ID,
"<|image_pad|>": IMAGE_TOKEN_ID,
}
)
processor = SimpleNamespace(
audio_token="<|audio_pad|>",
video_token="<|video_pad|>",
image_token="<|image_pad|>",
)
image_processor = SimpleNamespace(merge_size=1)
config = SimpleNamespace(audio_token_id=AUDIO_TOKEN_ID)
info = SimpleNamespace(
get_hf_config=lambda: config,
get_hf_processor=lambda **_kwargs: processor,
get_image_processor=lambda **_kwargs: image_processor,
get_tokenizer=lambda: tokenizer,
)
def omni_get_updates_use_audio_in_video(**_kwargs):
return [
AUDIO_BOS_TOKEN_ID,
VIDEO_TOKEN_ID,
AUDIO_TOKEN_ID,
AUDIO_EOS_TOKEN_ID,
]
return SimpleNamespace(
info=info,
omni_get_updates_use_audio_in_video=omni_get_updates_use_audio_in_video,
)
def _fake_processor_with_spatial_merge_size() -> SimpleNamespace:
hf_config = SimpleNamespace(vision_config=SimpleNamespace(spatial_merge_size=2))
return SimpleNamespace(info=SimpleNamespace(get_hf_config=lambda: hf_config))
def _fake_qwen3_processor() -> SimpleNamespace:
tokenizer = SimpleNamespace(
get_vocab=lambda: {
"<|audio_pad|>": AUDIO_TOKEN_ID,
"<|video_pad|>": VIDEO_TOKEN_ID,
}
)
processor = SimpleNamespace(
audio_token="<|audio_pad|>",
video_token="<|video_pad|>",
)
info = SimpleNamespace(
get_hf_processor=lambda: processor,
get_tokenizer=lambda: tokenizer,
)
return SimpleNamespace(info=info)
def _fake_qwen3_prompt_processor() -> SimpleNamespace:
tokenizer = SimpleNamespace(
get_vocab=lambda: {
"<|audio_pad|>": AUDIO_TOKEN_ID,
"<|video_pad|>": VIDEO_TOKEN_ID,
"<|image_pad|>": IMAGE_TOKEN_ID,
}
)
processor = SimpleNamespace(
audio_token="<|audio_pad|>",
video_token="<|video_pad|>",
image_token="<|image_pad|>",
)
image_processor = SimpleNamespace(merge_size=1)
config = SimpleNamespace(
audio_token_id=AUDIO_TOKEN_ID,
video_token_id=VIDEO_TOKEN_ID,
audio_start_token_id=AUDIO_BOS_TOKEN_ID,
audio_end_token_id=AUDIO_EOS_TOKEN_ID,
vision_config=SimpleNamespace(spatial_merge_size=1),
position_id_per_seconds=1,
)
info = SimpleNamespace(
get_hf_config=lambda: config,
get_hf_processor=lambda **_kwargs: processor,
get_image_processor=lambda **_kwargs: image_processor,
get_tokenizer=lambda: tokenizer,
)
return SimpleNamespace(info=info)
def test_normalize_use_audio_in_video_accepts_bool_and_per_video_mask():
assert _normalize_use_audio_in_video(True, 2) == [True, True]
assert _normalize_use_audio_in_video(False, 2) == [False, False]
assert _normalize_use_audio_in_video([True, False], 2) == [True, False]
assert _normalize_use_audio_in_video(torch.tensor([1, 0]), 2) == [
True,
False,
]
def test_normalize_use_audio_in_video_rejects_wrong_length():
with pytest.raises(ValueError, match="one boolean per video"):
_normalize_use_audio_in_video([True], 2)
def test_coerce_use_audio_in_video_for_hf_processor_keeps_global_bool():
mm_kwargs = {"use_audio_in_video": True}
result = _coerce_use_audio_in_video_for_hf_processor({"videos": [object(), object()]}, mm_kwargs)
assert result is mm_kwargs
assert result["use_audio_in_video"] is True
def test_coerce_use_audio_in_video_for_hf_processor_hides_per_video_mask_from_hf():
mm_kwargs = {"use_audio_in_video": [True, False], "other": "kept"}
result = _coerce_use_audio_in_video_for_hf_processor({"videos": [object(), object()]}, mm_kwargs)
assert result is not mm_kwargs
assert result["use_audio_in_video"] is False
assert result[_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY] == [True, False]
assert result["other"] == "kept"
assert mm_kwargs["use_audio_in_video"] == [True, False]
def test_coerce_use_audio_in_video_for_hf_processor_does_not_validate_against_empty_mm_data():
result = _coerce_use_audio_in_video_for_hf_processor(
{},
{"use_audio_in_video": [True, False]},
)
assert result["use_audio_in_video"] is False
assert result[_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY] == [True, False]
def test_coerce_use_audio_in_video_for_hf_processor_does_not_validate_against_partial_mm_data():
result = _coerce_use_audio_in_video_for_hf_processor(
{"videos": [object()]},
{"use_audio_in_video": [True, False]},
)
assert result["use_audio_in_video"] is False
assert result[_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY] == [True, False]
def test_filter_video_use_audio_in_video_for_uncached_items_aligns_partial_cache_mask():
result = _filter_video_use_audio_in_video_for_uncached_items(
{
"use_audio_in_video": [True, False, True],
"second_per_grid_ts": [0.5, 1.0, 1.5],
},
[True, False, False],
)
assert result["use_audio_in_video"] == [False, True]
assert result[_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY] == [False, True]
assert result["second_per_grid_ts"] == [1.0, 1.5]
def test_filter_video_use_audio_in_video_for_uncached_items_validates_request_mask_length():
with pytest.raises(ValueError, match="one boolean per video"):
_filter_video_use_audio_in_video_for_uncached_items(
{"use_audio_in_video": [True]},
[False, False],
)
def test_mm_fields_config_keeps_global_use_audio_in_video_shared():
fake_self = _fake_processor_with_spatial_merge_size()
hf_inputs = {
"video_grid_thw": torch.tensor([[1, 4, 4], [1, 4, 4]]),
"use_audio_in_video": torch.tensor(False),
}
result = Qwen2_5OmniThinkerMultiModalProcessor._get_mm_fields_config(
fake_self,
hf_inputs,
{},
)
assert isinstance(result["use_audio_in_video"].field, MultiModalSharedField)
def test_mm_fields_config_uses_batched_field_for_per_video_audio_mask():
fake_self = _fake_processor_with_spatial_merge_size()
hf_inputs = {
"video_grid_thw": torch.tensor([[1, 4, 4], [1, 4, 4]]),
"use_audio_in_video": torch.tensor([True, False]),
}
result = Qwen2_5OmniThinkerMultiModalProcessor._get_mm_fields_config(
fake_self,
hf_inputs,
{_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY: [True, False]},
)
assert isinstance(result["use_audio_in_video"].field, MultiModalBatchedField)
def test_mm_fields_config_uses_batched_field_for_vector_hf_audio_mask():
fake_self = _fake_processor_with_spatial_merge_size()
hf_inputs = {
"video_grid_thw": torch.tensor([[1, 4, 4], [1, 4, 4]]),
"use_audio_in_video": torch.tensor([True, False]),
}
result = Qwen2_5OmniThinkerMultiModalProcessor._get_mm_fields_config(
fake_self,
hf_inputs,
{},
)
assert isinstance(result["use_audio_in_video"].field, MultiModalBatchedField)
def test_get_video_use_audio_in_video_preserves_explicit_false():
fake_self = _fake_processor()
mm_kwargs = {
"video": [
{"use_audio_in_video": _Feature(torch.tensor(True))},
{"use_audio_in_video": _Feature(torch.tensor(False))},
]
}
result = Qwen2_5OmniThinkerMultiModalProcessor._get_video_use_audio_in_video(
fake_self,
mm_kwargs,
{},
)
assert result == [True, False]
def test_get_video_use_audio_in_video_treats_missing_item_as_false_with_explicit_mask():
fake_self = _fake_processor()
mm_kwargs = {
"video": [
{"use_audio_in_video": _Feature(torch.tensor(True))},
None,
]
}
result = Qwen2_5OmniThinkerMultiModalProcessor._get_video_use_audio_in_video(
fake_self,
mm_kwargs,
{},
)
assert result == [True, False]
def test_get_video_use_audio_in_video_falls_back_for_partial_cache_hit_with_explicit_mask():
fake_self = _fake_processor()
update_with_audio = SimpleNamespace(content=SimpleNamespace(full=[VIDEO_TOKEN_ID, AUDIO_TOKEN_ID]))
mm_kwargs = {
"video": [
{"use_audio_in_video": _Feature(torch.tensor(True))},
None,
]
}
result = Qwen2_5OmniThinkerMultiModalProcessor._get_video_use_audio_in_video(
fake_self,
mm_kwargs,
{"video": [[object()], [update_with_audio]]},
)
assert result == [True, True]
def test_qwen2_5_prompt_updates_apply_audio_mask_per_video():
fake_self = _fake_qwen2_prompt_processor()
out_mm_kwargs = SimpleNamespace(
get_data=lambda **_kwargs: {
"audio_feature_lengths": torch.tensor([100]),
"video_grid_thw": torch.tensor([[1, 1, 2], [1, 1, 2]]),
}
)
mm_items = SimpleNamespace(
get_all_counts=lambda: {"video": 2, "audio": 1},
get_items=lambda *_args, **_kwargs: SimpleNamespace(get=lambda _idx: [0] * 100),
)
updates = Qwen2_5OmniThinkerMultiModalProcessor._get_prompt_updates(
fake_self,
mm_items,
{"use_audio_in_video": [True, False]},
out_mm_kwargs,
)
video_update = next(update for update in updates if update.modality == "video")
assert video_update.resolve(0).content.full == [
AUDIO_BOS_TOKEN_ID,
VIDEO_TOKEN_ID,
AUDIO_TOKEN_ID,
AUDIO_EOS_TOKEN_ID,
]
assert video_update.resolve(1).content.full == [VIDEO_TOKEN_ID, VIDEO_TOKEN_ID]
def test_qwen2_5_prompt_updates_prefer_hf_second_per_grid_ts_from_outputs():
captured = {}
fake_self = _fake_qwen2_prompt_processor()
def omni_get_updates_use_audio_in_video(**kwargs):
captured["video_second_per_grid_t"] = kwargs["video_second_per_grid_t"]
return [VIDEO_TOKEN_ID]
fake_self.omni_get_updates_use_audio_in_video = omni_get_updates_use_audio_in_video
out_mm_kwargs = SimpleNamespace(
get_data=lambda **_kwargs: {
"audio_feature_lengths": torch.tensor([100]),
"video_grid_thw": torch.tensor([[1, 1, 1]]),
"second_per_grid_ts": torch.tensor([0.25]),
}
)
mm_items = SimpleNamespace(
get_all_counts=lambda: {"video": 1, "audio": 1},
get_items=lambda *_args, **_kwargs: SimpleNamespace(get=lambda _idx: [0] * 100),
)
updates = Qwen2_5OmniThinkerMultiModalProcessor._get_prompt_updates(
fake_self,
mm_items,
{"use_audio_in_video": [True], "second_per_grid_ts": torch.tensor([0.5])},
out_mm_kwargs,
)
video_update = next(update for update in updates if update.modality == "video")
video_update.resolve(0)
assert captured["video_second_per_grid_t"] == 0.25
def test_qwen2_5_prompt_updates_accept_tensor_second_per_grid_ts_from_kwargs():
captured = {}
fake_self = _fake_qwen2_prompt_processor()
def omni_get_updates_use_audio_in_video(**kwargs):
captured["video_second_per_grid_t"] = kwargs["video_second_per_grid_t"]
return [VIDEO_TOKEN_ID]
fake_self.omni_get_updates_use_audio_in_video = omni_get_updates_use_audio_in_video
out_mm_kwargs = SimpleNamespace(
get_data=lambda **_kwargs: {
"audio_feature_lengths": torch.tensor([100]),
"video_grid_thw": torch.tensor([[1, 1, 1]]),
}
)
mm_items = SimpleNamespace(
get_all_counts=lambda: {"video": 1, "audio": 1},
get_items=lambda *_args, **_kwargs: SimpleNamespace(get=lambda _idx: [0] * 100),
)
updates = Qwen2_5OmniThinkerMultiModalProcessor._get_prompt_updates(
fake_self,
mm_items,
{"use_audio_in_video": [True], "second_per_grid_ts": torch.tensor([0.75, 1.0])},
out_mm_kwargs,
)
video_update = next(update for update in updates if update.modality == "video")
video_update.resolve(0)
assert captured["video_second_per_grid_t"] == 0.75
def test_qwen2_5_prompt_updates_validate_mask_against_request_video_count():
fake_self = _fake_qwen2_prompt_processor()
out_mm_kwargs = SimpleNamespace(
get_data=lambda **_kwargs: {
"video_grid_thw": torch.tensor([[1, 1, 2], [1, 1, 2]]),
}
)
mm_items = SimpleNamespace(get_all_counts=lambda: {"video": 2})
with pytest.raises(ValueError, match="one boolean per video"):
Qwen2_5OmniThinkerMultiModalProcessor._get_prompt_updates(
fake_self,
mm_items,
{"use_audio_in_video": [True]},
out_mm_kwargs,
)
def test_qwen2_5_mm_only_dummy_counts_subtract_only_videos_using_audio():
captured_counts = {}
fake_self = SimpleNamespace(
dummy_inputs=SimpleNamespace(get_dummy_text=lambda counts: captured_counts.update(counts) or "dummy"),
_apply_hf_processor_text_mm=lambda **_kwargs: ([], {}, False),
)
mm_items = SimpleNamespace(get_all_counts=lambda: {"video": 2, "audio": 1})
Qwen2_5OmniThinkerMultiModalProcessor._apply_hf_processor_mm_only(
fake_self,
mm_items,
{"use_audio_in_video": [True, False]},
{},
)
assert captured_counts == {"video": 2, "audio": 0}
def test_get_video_use_audio_in_video_falls_back_to_prompt_updates_for_cache_hits():
fake_self = _fake_processor()
update_with_audio = SimpleNamespace(content=SimpleNamespace(full=[VIDEO_TOKEN_ID, AUDIO_TOKEN_ID]))
update_without_audio = SimpleNamespace(content=SimpleNamespace(full=[VIDEO_TOKEN_ID]))
result = Qwen2_5OmniThinkerMultiModalProcessor._get_video_use_audio_in_video(
fake_self,
{"video": [None, None]},
{"video": [[update_with_audio], [update_without_audio]]},
)
assert result == [True, False]
def test_derive_audio_from_video_placeholders_only_pairs_true_videos():
fake_self = _fake_processor()
video_placeholders = [
PlaceholderFeaturesInfo(
modality="video",
item_idx=0,
start_idx=5,
tokens=[VIDEO_TOKEN_ID, AUDIO_TOKEN_ID, VIDEO_TOKEN_ID],
is_embed=torch.tensor([True, True, True]),
),
PlaceholderFeaturesInfo(
modality="video",
item_idx=1,
start_idx=12,
tokens=[VIDEO_TOKEN_ID, VIDEO_TOKEN_ID],
is_embed=torch.tensor([True, True]),
),
]
result = Qwen2_5OmniThinkerMultiModalProcessor._derive_audio_from_video_placeholders(
fake_self,
{"video": video_placeholders},
{"audio": [[object()]]},
[True, False],
)
assert len(result["audio"]) == 1
assert result["audio"][0].item_idx == 0
assert result["audio"][0].start_idx == 5
assert result["audio"][0].is_embed.tolist() == [False, True, False]
assert len(result["video"]) == 2
assert result["video"][0].item_idx == 0
assert result["video"][0].is_embed.tolist() == [True, False, True]
assert result["video"][1].item_idx == 1
assert result["video"][1].is_embed.tolist() == [True, True]
def test_qwen3_processor_inherits_vllm_omni_per_video_helpers():
assert issubclass(
Qwen3OmniMoeThinkerMultiModalProcessor,
Qwen2_5OmniThinkerMultiModalProcessor,
)
def test_qwen3_prompt_updates_prefer_hf_second_per_grid_ts_from_outputs():
captured = {}
fake_self = _fake_qwen3_prompt_processor()
def get_updates_use_audio_in_video(**kwargs):
captured["video_second_per_grid_t"] = kwargs["video_second_per_grid_t"]
return [VIDEO_TOKEN_ID]
fake_self.get_updates_use_audio_in_video = get_updates_use_audio_in_video
out_mm_kwargs = SimpleNamespace(
get_data=lambda **_kwargs: {
"audio_feature_lengths": torch.tensor([100]),
"video_grid_thw": torch.tensor([[1, 1, 1]]),
"second_per_grid_ts": torch.tensor([0.25]),
}
)
mm_items = SimpleNamespace(
get_items=lambda *_args, **_kwargs: SimpleNamespace(get=lambda _idx: [0] * 100),
)
updates = Qwen3OmniMoeThinkerMultiModalProcessor._get_prompt_updates(
fake_self,
mm_items,
{"use_audio_in_video": [True], "second_per_grid_ts": torch.tensor([0.5])},
out_mm_kwargs,
)
video_update = next(update for update in updates if update.modality == "video")
video_update.resolve(0)
assert captured["video_second_per_grid_t"] == 0.25
def test_qwen3_prompt_updates_accept_tensor_second_per_grid_ts_from_kwargs():
captured = {}
fake_self = _fake_qwen3_prompt_processor()
def get_updates_use_audio_in_video(**kwargs):
captured["video_second_per_grid_t"] = kwargs["video_second_per_grid_t"]
return [VIDEO_TOKEN_ID]
fake_self.get_updates_use_audio_in_video = get_updates_use_audio_in_video
out_mm_kwargs = SimpleNamespace(
get_data=lambda **_kwargs: {
"audio_feature_lengths": torch.tensor([100]),
"video_grid_thw": torch.tensor([[1, 1, 1]]),
}
)
mm_items = SimpleNamespace(
get_items=lambda *_args, **_kwargs: SimpleNamespace(get=lambda _idx: [0] * 100),
)
updates = Qwen3OmniMoeThinkerMultiModalProcessor._get_prompt_updates(
fake_self,
mm_items,
{"use_audio_in_video": [True], "second_per_grid_ts": torch.tensor([0.75, 1.0])},
out_mm_kwargs,
)
video_update = next(update for update in updates if update.modality == "video")
video_update.resolve(0)
assert captured["video_second_per_grid_t"] == 0.75
def test_qwen3_derive_audio_from_video_placeholders_excludes_audio_wrappers_from_embeds():
fake_self = _fake_qwen3_processor()
video_placeholders = [
PlaceholderFeaturesInfo(
modality="video",
item_idx=0,
start_idx=5,
tokens=[
AUDIO_BOS_TOKEN_ID,
VIDEO_TOKEN_ID,
AUDIO_TOKEN_ID,
AUDIO_EOS_TOKEN_ID,
VIDEO_TOKEN_ID,
],
is_embed=torch.ones(5, dtype=torch.bool),
)
]
result = Qwen3OmniMoeThinkerMultiModalProcessor._derive_audio_from_video_placeholders(
fake_self,
{"video": video_placeholders},
{"audio": [[object()]]},
[True],
)
assert result["audio"][0].is_embed.tolist() == [
False,
False,
True,
False,
False,
]
assert result["video"][0].is_embed.tolist() == [
False,
True,
False,
False,
True,
]
def test_qwen3_mrope_positions_match_plain_video_placeholder_length():
fake_self = SimpleNamespace(
iter_mm_features=lambda _: iter(
[
(
2,
"video",
{
"grid_t": 1,
"grid_h": 2,
"grid_w": 2,
"t_factor": 1.0,
"use_audio_in_video": False,
"placeholder_len": 4,
},
)
]
)
)
positions, _ = Qwen3OmniMoeThinkerForConditionalGeneration.get_mrope_input_positions(
fake_self,
list(range(8)),
[],
)
assert positions.shape == (3, 8)
def test_qwen3_mrope_positions_count_single_interleaved_audio_wrappers():
fake_self = SimpleNamespace(
iter_mm_features=lambda _: iter(
[
(
2,
"video",
{
"grid_t": 1,
"grid_h": 2,
"grid_w": 2,
"t_factor": 1.0,
"use_audio_in_video": True,
"placeholder_len": 8,
},
)
]
),
_compute_interleaved_positions=lambda _start_idx, _data: (
torch.zeros((3, 6), dtype=torch.long).numpy(),
6,
),
)
positions, _ = Qwen3OmniMoeThinkerForConditionalGeneration.get_mrope_input_positions(
fake_self,
list(range(10)),
[],
)
assert positions.shape == (3, 10)
def test_qwen3_interleaved_mrope_prefers_placeholder_length_for_audio_count():
fake_self = SimpleNamespace(
_compute_audio_token_count=lambda _audio_feature_length: 100,
)
data = {
"grid_t": 1,
"grid_h": 2,
"grid_w": 2,
"t_factor": 1.0,
"audio_feature_length": 999,
"placeholder_len": 8,
}
positions, token_count = Qwen3OmniMoeThinkerForConditionalGeneration._compute_interleaved_positions(
fake_self,
0,
data,
)
assert token_count == 6
assert positions.shape == (3, 6)
def test_omni_model_runner_output_can_carry_kv_connector_output_only():
marker = object()
output = OmniModelRunnerOutput.with_kv_conn_output_only(marker)
assert output.req_ids == []
assert output.req_id_to_index == {}
assert output.sampled_token_ids == []
assert output.kv_connector_output is marker
@@ -1,6 +1,7 @@
"""Thin Omni wrapper: reuse upstream Qwen2.5-Omni thinker with minimal overrides."""
from collections.abc import Iterable, Iterator, Mapping
from collections.abc import Iterable, Iterator, Mapping, Sequence
from functools import partial
from typing import Any
import numpy as np
@@ -28,6 +29,7 @@ from vllm.model_executor.models.qwen2_5_omni_thinker import (
Qwen2_5OmniAudioFeatureInputs,
Qwen2_5OmniThinkerDummyInputsBuilder,
check_interleaved_audio_video,
create_qwen2_5_omni_thinker_field_factory,
merge_interleaved_embeddings,
)
from vllm.model_executor.models.qwen2_5_omni_thinker import (
@@ -51,6 +53,9 @@ from vllm.model_executor.models.qwen2_5_vl import (
Qwen2_5_VLVideoInputs,
Qwen2_5_VLVideoPixelInputs,
)
from vllm.model_executor.models.qwen2_audio import (
_get_feat_extract_output_lengths,
)
from vllm.model_executor.models.utils import (
AutoWeightsLoader,
WeightsMapper,
@@ -60,12 +65,19 @@ from vllm.model_executor.models.utils import (
from vllm.multimodal import MULTIMODAL_REGISTRY
from vllm.multimodal.inputs import (
MultiModalFeatureSpec,
MultiModalFieldConfig,
MultiModalKwargsItems,
)
from vllm.multimodal.parse import MultiModalDataItems, VideoProcessorItems
from vllm.multimodal.parse import AudioProcessorItems, MultiModalDataItems, VideoProcessorItems
from vllm.multimodal.processing.context import TimingContext
from vllm.multimodal.processing.inputs import ProcessorInputs
from vllm.multimodal.processing.processor import (
MultiModalProcessingInfo,
MultiModalPromptUpdates,
PlaceholderFeaturesInfo,
PromptReplacement,
PromptUpdate,
PromptUpdateDetails,
)
from vllm.sequence import IntermediateTensors
from vllm.utils.collection_utils import is_list_of
@@ -80,6 +92,158 @@ except (ImportError, ModuleNotFoundError):
flash_attn = None
logger = init_logger(__name__)
_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY = "_vllm_omni_per_video_use_audio_in_video"
def _normalize_use_audio_in_video(
value: object,
num_videos: int,
) -> list[bool]:
if isinstance(value, torch.Tensor):
if value.numel() == 1:
return [bool(value.item())] * num_videos
values = value.flatten().tolist()
elif isinstance(value, np.ndarray):
if value.size == 1:
return [bool(value.item())] * num_videos
values = value.flatten().tolist()
elif isinstance(value, bool):
return [value] * num_videos
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
values = list(value)
else:
return [bool(value)] * num_videos
if len(values) != num_videos:
raise ValueError(
"use_audio_in_video must be a boolean or contain one boolean per "
f"video, but found {len(values)} values for {num_videos} videos."
)
return [bool(v) for v in values]
def _normalize_per_video_use_audio_in_video(value: object) -> list[bool]:
if isinstance(value, torch.Tensor):
values = value.flatten().tolist()
elif isinstance(value, np.ndarray):
values = value.flatten().tolist()
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
values = list(value)
else:
values = [value]
return [bool(v) for v in values]
def _is_per_video_use_audio_in_video(value: object) -> bool:
if isinstance(value, bool):
return False
if isinstance(value, torch.Tensor):
return value.numel() != 1
if isinstance(value, np.ndarray):
return value.size != 1
return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
def _get_request_video_use_audio_in_video(
hf_processor_mm_kwargs: Mapping[str, object],
num_videos: int,
) -> list[bool]:
value = hf_processor_mm_kwargs.get(
_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY,
hf_processor_mm_kwargs.get("use_audio_in_video", False),
)
return _normalize_use_audio_in_video(value, num_videos)
def _prompt_update_has_audio_token(
mm_prompt_updates: MultiModalPromptUpdates,
item_idx: int,
audio_token_id: int,
) -> bool:
updates = mm_prompt_updates.get("video", [])
if item_idx >= len(updates):
return False
return any(
audio_token_id in update.content.full for update in updates[item_idx] if isinstance(update.content.full, list)
)
def _get_video_second_per_grid_t(
out_mm_data: Mapping[str, object],
hf_processor_mm_kwargs: Mapping[str, object],
item_idx: int,
default: float,
) -> float:
second_per_grid_ts = out_mm_data.get("second_per_grid_ts")
if second_per_grid_ts is None:
second_per_grid_ts = hf_processor_mm_kwargs.get("second_per_grid_ts", None)
if second_per_grid_ts is None:
return default
if isinstance(second_per_grid_ts, torch.Tensor):
if second_per_grid_ts.numel() == 1:
return float(second_per_grid_ts.item())
return float(second_per_grid_ts.flatten()[item_idx].item())
if isinstance(second_per_grid_ts, np.ndarray):
if second_per_grid_ts.size == 1:
return float(second_per_grid_ts.item())
return float(second_per_grid_ts.flatten()[item_idx].item())
if isinstance(second_per_grid_ts, Sequence) and not isinstance(second_per_grid_ts, (str, bytes)):
return float(second_per_grid_ts[item_idx])
return float(second_per_grid_ts)
def _filter_video_use_audio_in_video_for_uncached_items(
hf_processor_mm_kwargs: Mapping[str, object],
video_is_cached: Sequence[bool] | None,
) -> Mapping[str, object]:
use_audio_in_video = hf_processor_mm_kwargs.get(
_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY,
hf_processor_mm_kwargs.get("use_audio_in_video"),
)
if video_is_cached is None or not _is_per_video_use_audio_in_video(use_audio_in_video):
return hf_processor_mm_kwargs
mask = _normalize_use_audio_in_video(use_audio_in_video, len(video_is_cached))
missing_mask = [use_audio for use_audio, is_cached in zip(mask, video_is_cached) if not is_cached]
filtered_kwargs = dict(hf_processor_mm_kwargs)
filtered_kwargs["use_audio_in_video"] = missing_mask
filtered_kwargs[_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY] = missing_mask
second_per_grid_ts = hf_processor_mm_kwargs.get("second_per_grid_ts")
if (
isinstance(second_per_grid_ts, Sequence)
and not isinstance(second_per_grid_ts, (str, bytes))
and len(second_per_grid_ts) == len(video_is_cached)
):
filtered_kwargs["second_per_grid_ts"] = [
second_per_grid_t
for second_per_grid_t, is_cached in zip(second_per_grid_ts, video_is_cached)
if not is_cached
]
return filtered_kwargs
def _coerce_use_audio_in_video_for_hf_processor(
mm_data: Mapping[str, object],
mm_kwargs: Mapping[str, object],
) -> Mapping[str, object]:
use_audio_in_video = mm_kwargs.get("use_audio_in_video")
if not _is_per_video_use_audio_in_video(use_audio_in_video):
return mm_kwargs
video_use_audio_in_video = _normalize_per_video_use_audio_in_video(use_audio_in_video)
hf_mm_kwargs = dict(mm_kwargs)
# HF processors only support a global bool. For per-video masks, vLLM
# consumes the list while building prompt updates and placeholders.
hf_mm_kwargs["use_audio_in_video"] = False
hf_mm_kwargs[_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY] = video_use_audio_in_video
return hf_mm_kwargs
def _presampled_videos_hf_kwargs(
mm_data: Mapping[str, object],
@@ -87,12 +251,9 @@ def _presampled_videos_hf_kwargs(
) -> Mapping[str, object]:
"""Adjust HF video kwargs for videos pre-sampled by vLLM's video loader.
When ``video_metadata`` is present (emitted by
``Qwen2_5OmniVideoProcessorItems``), the frames were already sampled
according to ``media_io_kwargs``, so the HF processor must not re-sample
them, and it needs the sampled fps (instead of its default) to compute
``video_second_per_grid`` correctly. Otherwise the audio/video temporal
alignment breaks under ``use_audio_in_video=True``.
When ``video_metadata`` is present, the frames were already sampled
according to ``media_io_kwargs``. The HF processor should not resample
them and should use the sampled fps when computing temporal metadata.
"""
video_metadata = mm_data.get("video_metadata")
if not video_metadata:
@@ -109,18 +270,17 @@ def _presampled_videos_hf_kwargs(
return None
return len(indices) / float(duration)
# An explicit user-provided fps takes precedence.
if "fps" not in videos_kwargs:
fps_values = [_compute_sampled_video_fps(m) for m in video_metadata]
# HF accepts a single fps per call and uses it for every video's
# video_second_per_grid.
known_fps = [fps for fps in fps_values if fps is not None]
unique_fps = set(known_fps)
if len(unique_fps) == 1:
videos_kwargs["fps"] = known_fps[0]
elif len(unique_fps) > 1:
logger.warning(
f"Mixed sampled FPS {sorted(unique_fps)} in one request; HF accepts a single fps, using {known_fps[0]}."
"Mixed sampled FPS %s in one request; HF accepts a single fps, using %s.",
sorted(unique_fps),
known_fps[0],
)
videos_kwargs["fps"] = known_fps[0]
@@ -129,12 +289,7 @@ def _presampled_videos_hf_kwargs(
class Qwen2_5OmniVideoProcessorItems(VideoProcessorItems):
"""Video items that carry the loader's ``(frames, metadata)`` tuples.
The tuples are kept in ``data`` so that mm hashing and cache-miss
re-parsing retain the metadata; ``get_processor_data`` unpacks them into
the ``videos`` + ``video_metadata`` arguments of the HF processor.
"""
"""Video items that carry the loader's ``(frames, metadata)`` tuples."""
def get_processor_data(self) -> Mapping[str, object]:
from transformers.video_utils import VideoMetadata
@@ -151,7 +306,6 @@ class Qwen2_5OmniThinkerMultiModalDataParser(_Qwen2_5OmniThinkerMultiModalDataPa
if data is None or isinstance(data, dict) or self.is_embeddings(data):
return super()._parse_video_data(data)
# Normalize to a list of per-video items (same as the base parser).
if (is_list_of(data, PILImage.Image) and len(data) > 0) or (
isinstance(data, (np.ndarray, torch.Tensor)) and data.ndim == 4
):
@@ -164,11 +318,6 @@ class Qwen2_5OmniThinkerMultiModalDataParser(_Qwen2_5OmniThinkerMultiModalDataPa
data_items = data
videos_with_metadata = [self._get_video_with_metadata(item) for item in data_items]
# Metadata is optional: videos fetched by vLLM's media connector
# arrive as (frames, metadata) tuples, while plain arrays (e.g.
# offline `multi_modal_data` or dummy profiling inputs) carry no
# metadata and parse as before.
if any(metadata is None for _, metadata in videos_with_metadata):
return super()._parse_video_data(data)
@@ -195,14 +344,6 @@ class Qwen2_5OmniThinkerMultiModalProcessor(
):
"""Override to fix use_audio_in_video detection when mm cache returns None."""
def _cached_apply_hf_processor(self, inputs, timing_ctx):
# If use_audio_in_video, process video and audio together; otherwise,
# skip cache to avoid errors.
# Prevents partial cache hits from causing processing failures.
if inputs.hf_processor_mm_kwargs.get("use_audio_in_video"):
return self._apply_hf_processor(inputs, timing_ctx)
return super()._cached_apply_hf_processor(inputs, timing_ctx)
def _call_hf_processor(
self,
prompt: str,
@@ -211,12 +352,341 @@ class Qwen2_5OmniThinkerMultiModalProcessor(
tok_kwargs: Mapping[str, object],
):
mm_kwargs = _presampled_videos_hf_kwargs(mm_data, mm_kwargs)
return super()._call_hf_processor(
mm_kwargs = _coerce_use_audio_in_video_for_hf_processor(mm_data, mm_kwargs)
hf_inputs = super()._call_hf_processor(
prompt=prompt,
mm_data=mm_data,
mm_kwargs=mm_kwargs,
tok_kwargs=tok_kwargs,
)
per_video_mask = mm_kwargs.get(_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY)
if per_video_mask is not None:
hf_inputs["use_audio_in_video"] = torch.tensor(per_video_mask)
return hf_inputs
def _get_mm_fields_config(
self,
hf_inputs,
hf_processor_mm_kwargs: Mapping[str, object],
) -> Mapping[str, MultiModalFieldConfig]:
fields_config = dict(
create_qwen2_5_omni_thinker_field_factory(self.info.get_hf_config().vision_config.spatial_merge_size)(
hf_inputs
)
)
use_audio_in_video = hf_inputs.get("use_audio_in_video")
is_batched_mask = isinstance(use_audio_in_video, torch.Tensor) and use_audio_in_video.numel() > 1
if _PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY in hf_processor_mm_kwargs or is_batched_mask:
fields_config["use_audio_in_video"] = MultiModalFieldConfig.batched("video")
return fields_config
def _get_video_use_audio_in_video(
self,
mm_kwargs: MultiModalKwargsItems,
mm_prompt_updates: MultiModalPromptUpdates,
) -> list[bool]:
video_kwargs = mm_kwargs.get("video", [])
if not video_kwargs:
return []
audio_token_id = self.info.get_hf_config().audio_token_id
video_use_audio_in_video = []
has_use_audio_in_video = any(item is not None and "use_audio_in_video" in item for item in video_kwargs)
for item_idx, item in enumerate(video_kwargs):
if has_use_audio_in_video:
if item is None or "use_audio_in_video" not in item:
video_use_audio_in_video.append(
_prompt_update_has_audio_token(
mm_prompt_updates,
item_idx,
audio_token_id,
)
)
continue
use_audio_in_video_tensor = item["use_audio_in_video"].data
if use_audio_in_video_tensor.numel() > 0:
video_use_audio_in_video.append(bool(use_audio_in_video_tensor.item()))
continue
video_use_audio_in_video.append(False)
continue
video_use_audio_in_video.append(
_prompt_update_has_audio_token(
mm_prompt_updates,
item_idx,
audio_token_id,
)
)
return video_use_audio_in_video
def _get_prompt_updates(
self,
mm_items: MultiModalDataItems,
hf_processor_mm_kwargs: Mapping[str, Any],
out_mm_kwargs: MultiModalKwargsItems,
) -> Sequence[PromptUpdate]:
processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
tokenizer = self.info.get_tokenizer()
image_processor = self.info.get_image_processor(**hf_processor_mm_kwargs)
vocab = tokenizer.get_vocab()
audio_token = processor.audio_token
image_token = processor.image_token
video_token = processor.video_token
audio_token_id = vocab[audio_token]
image_token_id = vocab[image_token]
video_token_id = vocab[video_token]
out_mm_data = out_mm_kwargs.get_data()
audio_feature_lengths = out_mm_data.get("audio_feature_lengths")
feature_attention_mask = out_mm_data.get("feature_attention_mask")
if audio_feature_lengths is None and feature_attention_mask is None:
audio_output_lengths = []
elif audio_feature_lengths is not None:
_, audio_output_lens = _get_feat_extract_output_lengths(audio_feature_lengths)
audio_output_lengths = audio_output_lens.tolist()
elif feature_attention_mask is not None:
assert isinstance(feature_attention_mask, torch.Tensor)
_, audio_output_lens = _get_feat_extract_output_lengths(feature_attention_mask.sum(-1))
audio_output_lengths = audio_output_lens.tolist()
audio_in_video_item_idx = 0
def get_replacement_qwen2_audio(item_idx: int):
item_idx += audio_in_video_item_idx
num_features = audio_output_lengths[item_idx]
if num_features == 0:
audios = mm_items.get_items("audio", AudioProcessorItems)
audio = audios.get(item_idx)
raise ValueError(
f"The audio {audio} (len={len(audio)}) is too short to be represented inside the model"
)
return [audio_token_id] * num_features
def get_replacement_qwen2_vision(item_idx: int, modality: str):
grid_thw = out_mm_data[f"{modality}_grid_thw"][item_idx]
assert isinstance(grid_thw, torch.Tensor)
merge_length = image_processor.merge_size**2
token_id = image_token_id if modality == "image" else video_token_id
return [token_id] * (int(grid_thw.prod()) // merge_length)
num_videos = mm_items.get_all_counts().get("video", 0)
video_use_audio_in_video = _get_request_video_use_audio_in_video(
hf_processor_mm_kwargs,
num_videos,
)
thinker_config = self.info.get_hf_config()
def get_replacement_qwen2_video(item_idx: int):
nonlocal audio_in_video_item_idx
if not video_use_audio_in_video[item_idx]:
return get_replacement_qwen2_vision(item_idx, modality="video")
audio_num_features = audio_output_lengths[audio_in_video_item_idx]
video_grid_thw = out_mm_data["video_grid_thw"][item_idx]
audio_in_video_item_idx += 1
video_second_per_grid_t = _get_video_second_per_grid_t(
out_mm_data,
hf_processor_mm_kwargs,
item_idx,
default=1.0,
)
updates = self.omni_get_updates_use_audio_in_video(
thinker_config=thinker_config,
audio_len=audio_num_features,
video_grid_thw=video_grid_thw,
video_second_per_grid_t=video_second_per_grid_t,
)
return PromptUpdateDetails.select_token_id(
seq=updates,
embed_token_id=video_token_id,
)
return [
PromptReplacement(
modality="audio",
target=audio_token,
replacement=get_replacement_qwen2_audio,
),
PromptReplacement(
modality="image",
target=image_token,
replacement=partial(get_replacement_qwen2_vision, modality="image"),
),
PromptReplacement(
modality="video",
target=video_token,
replacement=get_replacement_qwen2_video,
),
]
def _apply_hf_processor_mm_only(
self,
mm_items: MultiModalDataItems,
hf_processor_mm_kwargs: Mapping[str, object],
tokenization_kwargs: Mapping[str, object],
):
mm_counts = mm_items.get_all_counts()
if "video" in mm_counts:
video_use_audio_in_video = _get_request_video_use_audio_in_video(
hf_processor_mm_kwargs,
mm_counts["video"],
)
if any(video_use_audio_in_video):
assert "audio" in mm_counts
mm_counts["audio"] -= sum(video_use_audio_in_video)
_, mm_processed_data, _ = self._apply_hf_processor_text_mm(
prompt_text=self.dummy_inputs.get_dummy_text(mm_counts),
mm_items=mm_items,
hf_processor_mm_kwargs=hf_processor_mm_kwargs,
tokenization_kwargs=tokenization_kwargs,
)
return mm_processed_data
def _cached_apply_hf_processor(
self,
inputs: ProcessorInputs,
timing_ctx: TimingContext,
) -> tuple[list[int], MultiModalProcessingInfo, bool]:
cache = self.cache
_, passthrough_data = self._get_hf_mm_data(inputs.mm_data_items)
if cache is None or passthrough_data:
return self._apply_hf_processor(inputs, timing_ctx)
with timing_ctx.record("get_mm_hashes"):
mm_hashes = inputs.get_mm_hashes(self.info.model_id)
with timing_ctx.record("get_cache_missing_items"):
mm_is_cached, mm_missing_data_items = self._get_cache_missing_items(
cache=cache,
mm_data_items=inputs.mm_data_items,
mm_hashes=mm_hashes,
)
hf_processor_mm_kwargs = _filter_video_use_audio_in_video_for_uncached_items(
inputs.hf_processor_mm_kwargs,
mm_is_cached.get("video"),
)
with timing_ctx.record("apply_hf_processor"):
(
prompt_ids,
mm_missing_processed_data,
is_update_applied,
) = self._apply_hf_processor_main(
prompt=inputs.prompt,
mm_items=mm_missing_data_items,
hf_processor_mm_kwargs=hf_processor_mm_kwargs,
tokenization_kwargs=inputs.tokenization_kwargs,
enable_hf_prompt_update=False,
)
mm_missing_kwargs = MultiModalKwargsItems.from_hf_inputs(
mm_missing_processed_data,
self._get_mm_fields_config(
mm_missing_processed_data,
hf_processor_mm_kwargs,
),
)
mm_missing_prompt_updates = self._get_mm_prompt_updates(
mm_missing_data_items,
hf_processor_mm_kwargs,
mm_missing_kwargs,
)
with timing_ctx.record("merge_mm_kwargs"):
mm_kwargs, mm_prompt_updates = self._merge_mm_kwargs(
cache,
mm_hashes=mm_hashes,
mm_is_cached=mm_is_cached,
mm_missing_kwargs=mm_missing_kwargs,
mm_missing_prompt_updates=mm_missing_prompt_updates,
)
mm_info = MultiModalProcessingInfo(
kwargs=mm_kwargs,
hashes=mm_hashes,
prompt_updates=mm_prompt_updates,
)
return prompt_ids, mm_info, is_update_applied
def _derive_audio_from_video_placeholders(
self,
placeholders: Mapping[str, list[PlaceholderFeaturesInfo]],
mm_prompt_updates: MultiModalPromptUpdates,
video_use_audio_in_video: Sequence[bool] | None = None,
) -> Mapping[str, list[PlaceholderFeaturesInfo]]:
if "video" not in placeholders:
return placeholders
num_videos = len(placeholders["video"])
if video_use_audio_in_video is None:
video_use_audio_in_video = [True] * num_videos
elif len(video_use_audio_in_video) != num_videos:
raise ValueError(
"use_audio_in_video must contain one boolean per video, "
f"but found {len(video_use_audio_in_video)} values for "
f"{num_videos} videos."
)
num_audio_in_video = sum(video_use_audio_in_video)
num_audios = len(mm_prompt_updates.get("audio", []))
if num_audios != num_audio_in_video:
raise ValueError(
"use_audio_in_video requires equal number of audio and video "
f"items using audio, got {num_audios=}, {num_audio_in_video=}"
)
tokenizer = self.info.get_tokenizer()
processor = self.info.get_hf_processor()
audio_token_id = tokenizer.get_vocab()[processor.audio_token]
result_placeholders = dict(placeholders)
audio_placeholders = []
video_placeholders = []
audio_idx = 0
for video_idx, video_placeholder in enumerate(placeholders["video"]):
audio_is_embed = torch.tensor(video_placeholder.tokens) == audio_token_id
if video_use_audio_in_video[video_idx]:
audio_placeholder = PlaceholderFeaturesInfo(
modality="audio",
item_idx=audio_idx,
start_idx=video_placeholder.start_idx,
tokens=video_placeholder.tokens,
is_embed=audio_is_embed,
)
audio_placeholders.append(audio_placeholder)
audio_idx += 1
video_placeholder_with_mask = PlaceholderFeaturesInfo(
modality="video",
item_idx=video_idx,
start_idx=video_placeholder.start_idx,
tokens=video_placeholder.tokens,
is_embed=~audio_is_embed,
)
video_placeholders.append(video_placeholder_with_mask)
result_placeholders["audio"] = audio_placeholders
result_placeholders["video"] = video_placeholders
return result_placeholders
def _maybe_apply_prompt_updates(
self,
@@ -230,24 +700,8 @@ class Qwen2_5OmniThinkerMultiModalProcessor(
self._validate_mm_kwargs(mm_kwargs, mm_item_counts)
self._validate_mm_updates(mm_prompt_updates, mm_item_counts)
# Detect use_audio_in_video from mm_kwargs
use_audio_in_video = False
if "video" in mm_kwargs:
for item in mm_kwargs["video"]:
if item and item.get("use_audio_in_video"):
use_audio_in_video_tensor = item["use_audio_in_video"].data
if use_audio_in_video_tensor.numel() > 0:
use_audio_in_video = bool(use_audio_in_video_tensor.item())
break
# for mutilmodality cache
if any(item is None for item in mm_kwargs["video"]):
video_token_id = self.info.get_hf_config().video_token_id
audio_token_id = self.info.get_hf_config().audio_token_id
video_audio_item_num = sum(id in (video_token_id, audio_token_id) for id in prompt_ids)
audio_updates_num = len(mm_prompt_updates.get("audio", []))
video_updates_num = len(mm_prompt_updates.get("video", []))
if video_audio_item_num != video_updates_num + audio_updates_num:
use_audio_in_video = True
video_use_audio_in_video = self._get_video_use_audio_in_video(mm_kwargs, mm_prompt_updates)
use_audio_in_video = any(video_use_audio_in_video)
if is_update_applied:
mm_placeholders = self._find_mm_placeholders(
@@ -265,7 +719,11 @@ class Qwen2_5OmniThinkerMultiModalProcessor(
prompt_ids,
filtered_updates,
)
mm_placeholders = self._derive_audio_from_video_placeholders(mm_placeholders, mm_prompt_updates)
mm_placeholders = self._derive_audio_from_video_placeholders(
mm_placeholders,
mm_prompt_updates,
video_use_audio_in_video,
)
else:
prompt_ids, mm_placeholders = self._apply_prompt_updates(
prompt_ids,
@@ -121,6 +121,8 @@ from vllm_omni.model_executor.models.qwen2_5_omni.qwen2_5_omni_thinker import (
Qwen2_5OmniConditionalGenerationMixin,
Qwen2_5OmniThinkerMultiModalDataParser,
Qwen2_5OmniThinkerMultiModalProcessor,
_get_request_video_use_audio_in_video,
_get_video_second_per_grid_t,
_presampled_videos_hf_kwargs,
)
from vllm_omni.quantization.component_config import (
@@ -626,9 +628,6 @@ class Qwen3OmniMoeThinkerProcessingInfo(Qwen2AudioProcessingInfo, Qwen2_5_VLProc
def get_data_parser(self):
feature_extractor = self.get_feature_extractor()
# Install the Omni parser, which keeps video metadata from vLLM's
# video loader (see Qwen2_5OmniVideoProcessorItems); the parser
# inherited from Qwen2AudioProcessingInfo would drop it.
return Qwen2_5OmniThinkerMultiModalDataParser(
spatial_merge_size=self.get_hf_config().vision_config.spatial_merge_size,
target_sr=feature_extractor.sampling_rate,
@@ -686,10 +685,8 @@ class Qwen3OmniMoeThinkerMultiModalProcessor(
tok_kwargs: Mapping[str, object],
) -> BatchFeature:
mm_data = dict(mm_data)
mm_kwargs = dict(mm_kwargs)
audios = mm_data.pop("audios", [])
mm_kwargs = dict(_presampled_videos_hf_kwargs(mm_data, mm_kwargs))
audios = mm_data.pop("audios", [])
def pad_to_hop_length(x: np.ndarray, hop_length: int) -> np.ndarray:
length = x.shape[-1]
@@ -787,22 +784,8 @@ class Qwen3OmniMoeThinkerMultiModalProcessor(
mm_item_counts = mm_items.get_all_counts()
self._validate_mm_kwargs(mm_kwargs, mm_item_counts)
use_audio_in_video = False
if "video" in mm_kwargs:
for item in mm_kwargs["video"]:
if item and item["use_audio_in_video"].data:
use_audio_in_video = True
else:
use_audio_in_video = False
# for mutilmodality cache
if any(item is None for item in mm_kwargs["video"]):
video_token_id = self.info.get_hf_config().video_token_id
audio_token_id = self.info.get_hf_config().audio_token_id
video_audio_item_num = sum(id in (video_token_id, audio_token_id) for id in prompt_ids)
audio_updates_num = len(mm_prompt_updates.get("audio", []))
video_updates_num = len(mm_prompt_updates.get("video", []))
if video_audio_item_num != video_updates_num + audio_updates_num:
use_audio_in_video = True
video_use_audio_in_video = self._get_video_use_audio_in_video(mm_kwargs, mm_prompt_updates)
use_audio_in_video = any(video_use_audio_in_video)
# normal case with `use_audio_in_video=False`
if is_update_applied:
@@ -821,7 +804,11 @@ class Qwen3OmniMoeThinkerMultiModalProcessor(
prompt_ids,
filtered_updates,
)
mm_placeholders = self._derive_audio_from_video_placeholders(mm_placeholders, mm_prompt_updates)
mm_placeholders = self._derive_audio_from_video_placeholders(
mm_placeholders,
mm_prompt_updates,
video_use_audio_in_video,
)
else:
prompt_ids, mm_placeholders = self._apply_prompt_updates(
prompt_ids,
@@ -935,25 +922,30 @@ class Qwen3OmniMoeThinkerMultiModalProcessor(
token_id = image_token_id if modality == "image" else video_token_id
return [token_id] * (int(grid_thw.prod()) // merge_length)
use_audio_in_video = hf_processor_mm_kwargs.get("use_audio_in_video", False)
num_videos = len(out_mm_data.get("video_grid_thw", []))
video_use_audio_in_video = _get_request_video_use_audio_in_video(
hf_processor_mm_kwargs,
num_videos,
)
thinker_config = self.info.get_hf_config()
def get_replacement_qwen2_use_audio_in_video(item_idx: int):
def get_replacement_qwen2_video(item_idx: int):
nonlocal audio_in_video_item_idx
if not video_use_audio_in_video[item_idx]:
return get_replacement_qwen2_vision(item_idx, modality="video")
audio_num_features = audio_output_lengths[audio_in_video_item_idx]
video_grid_thw = out_mm_data["video_grid_thw"][item_idx]
audio_in_video_item_idx += 1
# Prefer the HF-computed value (temporal_patch_size / sampled fps)
# over request kwargs;
second_per_grid_ts = out_mm_data.get("second_per_grid_ts")
if second_per_grid_ts is None:
second_per_grid_ts = hf_processor_mm_kwargs.get("second_per_grid_ts", None)
if second_per_grid_ts is not None:
video_second_per_grid_t = float(second_per_grid_ts[item_idx])
else:
video_second_per_grid_t = 2.0
video_second_per_grid_t = _get_video_second_per_grid_t(
out_mm_data,
hf_processor_mm_kwargs,
item_idx,
default=2.0,
)
placeholder = self.get_updates_use_audio_in_video(
thinker_config=thinker_config,
@@ -963,12 +955,6 @@ class Qwen3OmniMoeThinkerMultiModalProcessor(
)
return PromptUpdateDetails.select_token_id(placeholder, embed_token_id=video_token_id)
video_replacement_fn = (
get_replacement_qwen2_use_audio_in_video
if use_audio_in_video
else partial(get_replacement_qwen2_vision, modality="video")
)
return [
PromptReplacement(
modality="audio",
@@ -983,7 +969,7 @@ class Qwen3OmniMoeThinkerMultiModalProcessor(
PromptReplacement(
modality="video",
target=video_token,
replacement=video_replacement_fn,
replacement=get_replacement_qwen2_video,
),
]
@@ -991,6 +977,7 @@ class Qwen3OmniMoeThinkerMultiModalProcessor(
self,
placeholders: Mapping[str, list[PlaceholderFeaturesInfo]],
mm_prompt_updates: MultiModalPromptUpdates,
video_use_audio_in_video: Sequence[bool] | None = None,
) -> Mapping[str, list[PlaceholderFeaturesInfo]]:
"""
Helper to derive audio placeholders from video placeholders when
@@ -1000,34 +987,62 @@ class Qwen3OmniMoeThinkerMultiModalProcessor(
return placeholders
num_videos = len(placeholders["video"])
num_audios = len(mm_prompt_updates.get("audio", []))
if num_audios != num_videos:
if video_use_audio_in_video is None:
video_use_audio_in_video = [True] * num_videos
elif len(video_use_audio_in_video) != num_videos:
raise ValueError(
f"use_audio_in_video requires equal number of audio and video items, got {num_audios=}, {num_videos=}"
"use_audio_in_video must contain one boolean per video, "
f"but found {len(video_use_audio_in_video)} values for "
f"{num_videos} videos."
)
num_audio_in_video = sum(video_use_audio_in_video)
num_audios = len(mm_prompt_updates.get("audio", []))
if num_audios != num_audio_in_video:
raise ValueError(
"use_audio_in_video requires equal number of audio and video "
f"items using audio, got {num_audios=}, {num_audio_in_video=}"
)
tokenizer = self.info.get_tokenizer()
processor = self.info.get_hf_processor()
audio_token_id = tokenizer.get_vocab()[processor.audio_token]
video_token_id = tokenizer.get_vocab()[processor.video_token]
result_placeholders = dict(placeholders)
audio_placeholders = []
video_placeholders = []
# Each video is paired with one audio
# Each video using audio is paired with one audio
audio_idx = 0
for video_idx, video_placeholder in enumerate(placeholders["video"]):
# Create is_embed mask selecting only audio tokens
audio_is_embed = torch.tensor(video_placeholder.tokens) == audio_token_id
placeholder_tokens = torch.tensor(video_placeholder.tokens)
audio_is_embed = placeholder_tokens == audio_token_id
video_is_embed = placeholder_tokens == video_token_id
audio_placeholder = PlaceholderFeaturesInfo(
modality="audio",
if video_use_audio_in_video[video_idx]:
audio_placeholder = PlaceholderFeaturesInfo(
modality="audio",
item_idx=audio_idx,
start_idx=video_placeholder.start_idx,
tokens=video_placeholder.tokens,
is_embed=audio_is_embed,
)
audio_placeholders.append(audio_placeholder)
audio_idx += 1
video_placeholder_with_mask = PlaceholderFeaturesInfo(
modality="video",
item_idx=video_idx,
start_idx=video_placeholder.start_idx,
tokens=video_placeholder.tokens,
is_embed=audio_is_embed,
is_embed=video_is_embed,
)
audio_placeholders.append(audio_placeholder)
video_placeholders.append(video_placeholder_with_mask)
result_placeholders["audio"] = audio_placeholders
result_placeholders["video"] = video_placeholders
return result_placeholders
def _get_raw_input_ids(
@@ -1529,10 +1544,12 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
Iterate over multimodal features sorted by position offset.
Yields: (offset, modality, feature_data) where feature_data contains:
- image: {"grid_t", "grid_h", "grid_w", "t_factor"}
- image: {"grid_t", "grid_h", "grid_w", "t_factor",
"placeholder_len"}
- video: {"grid_t", "grid_h", "grid_w", "t_factor",
"use_audio_in_video", "audio_feature_length"}
- audio: {"audio_feature_length"}
"use_audio_in_video", "audio_feature_length",
"placeholder_len"}
- audio: {"audio_feature_length", "placeholder_len"}
"""
config = self.config
spatial_merge_size = config.vision_config.spatial_merge_size
@@ -1555,6 +1572,7 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
"grid_h": h // spatial_merge_size,
"grid_w": w // spatial_merge_size,
"t_factor": position_id_per_seconds,
"placeholder_len": mm_feature.mm_position.length,
},
)
elif modality == "video":
@@ -1576,12 +1594,20 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
"t_factor": second_per_grid_ts * position_id_per_seconds,
"use_audio_in_video": use_audio_in_video,
"audio_feature_length": audio_for_video.get(offset),
"placeholder_len": mm_feature.mm_position.length,
},
)
elif modality == "audio":
if offset not in paired_audio_offsets:
audio_len = mm_feature.data["audio_feature_lengths"].data.item()
yield offset, "audio", {"audio_feature_length": audio_len}
yield (
offset,
"audio",
{
"audio_feature_length": audio_len,
"placeholder_len": mm_feature.mm_position.length,
},
)
def _compute_interleaved_positions(self, start_idx: int, data: dict[str, Any]) -> tuple[np.ndarray, int]:
"""
@@ -1594,9 +1620,13 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
grid_h = data["grid_h"]
grid_w = data["grid_w"]
t_factor = data["t_factor"]
audio_feature_length = data["audio_feature_length"]
audio_len = self._compute_audio_token_count(audio_feature_length)
num_video = grid_t * grid_h * grid_w
placeholder_len = data.get("placeholder_len")
if placeholder_len is None:
audio_feature_length = data["audio_feature_length"]
audio_len = self._compute_audio_token_count(audio_feature_length)
else:
audio_len = max(0, int(placeholder_len) - num_video - 2)
h_index = np.tile(np.arange(grid_h).reshape(1, -1, 1), (grid_t, 1, grid_w)).flatten()
w_index = np.tile(np.arange(grid_w).reshape(1, 1, -1), (grid_t, grid_h, 1)).flatten()
@@ -1612,8 +1642,6 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
pos_ids_list: list[np.ndarray] = []
video_idx, audio_idx = 0, 0
num_video = grid_t * grid_h * grid_w
while video_idx < num_video and audio_idx < audio_len:
if video_t_values[video_idx] <= audio_t_values[audio_idx]:
pos_ids_list.append(video_pos[:, video_idx : video_idx + 1])
@@ -1703,9 +1731,18 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
seq_len = len(input_tokens)
llm_pos_ids_list: list[np.ndarray] = []
feature_summaries: list[dict[str, Any]] = []
st = 0
for offset, modality, data in self.iter_mm_features(mm_features):
feature_summaries.append(
{
"offset": offset,
"modality": modality,
"placeholder_len": data.get("placeholder_len"),
"use_audio_in_video": data.get("use_audio_in_video"),
}
)
text_len = offset - st
st_idx = int(llm_pos_ids_list[-1].max()) + 1 if llm_pos_ids_list else 0
@@ -1713,19 +1750,11 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
llm_pos_ids_list.append(np.broadcast_to(np.arange(text_len), (3, text_len)) + st_idx)
st_idx += text_len
bos_pos = np.broadcast_to(np.array([st_idx]), (3, 1))
llm_pos_ids_list.append(bos_pos)
st_idx += 1
if modality == "audio":
audio_tokens = self._compute_audio_token_count(data["audio_feature_length"])
audio_pos = np.broadcast_to(np.arange(audio_tokens), (3, audio_tokens)) + st_idx
llm_pos_ids_list.append(audio_pos)
st_idx = int(audio_pos.max()) + 1
eos_pos = np.broadcast_to(np.array([st_idx]), (3, 1))
llm_pos_ids_list.append(eos_pos)
st = offset + 1 + audio_tokens + 1
st = offset + int(data.get("placeholder_len", audio_tokens))
elif modality == "image":
grid_t = data["grid_t"]
@@ -1739,11 +1768,7 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
llm_pos_ids_list.append(grid_indices.reshape(3, -1) + st_idx)
image_len = grid_t * grid_h * grid_w
st_idx = int(llm_pos_ids_list[-1].max()) + 1
eos_pos = np.broadcast_to(np.array([st_idx]), (3, 1))
llm_pos_ids_list.append(eos_pos)
st = offset + 1 + image_len + 1
st = offset + int(data.get("placeholder_len", image_len))
elif modality == "video":
grid_t = data["grid_t"]
@@ -1758,26 +1783,20 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
llm_pos_ids_list.append(grid_indices.reshape(3, -1) + st_idx)
video_len = grid_t * grid_h * grid_w
st_idx = int(llm_pos_ids_list[-1].max()) + 1
eos_pos = np.broadcast_to(np.array([st_idx]), (3, 1))
llm_pos_ids_list.append(eos_pos)
st = offset + 1 + video_len + 1
st = offset + int(data.get("placeholder_len", video_len))
else:
audio_bos_pos = np.broadcast_to(np.array([st_idx - 1]), (3, 1))
audio_bos_pos = np.broadcast_to(np.array([st_idx]), (3, 1))
llm_pos_ids_list.append(audio_bos_pos)
st_idx += 1
pos_ids, _ = self._compute_interleaved_positions(st_idx, data)
pos_ids, token_count = self._compute_interleaved_positions(st_idx, data)
llm_pos_ids_list.append(pos_ids)
st_idx = int(pos_ids.max()) + 1
eos_pos = np.broadcast_to(np.array([st_idx]), (3, 1))
llm_pos_ids_list.append(eos_pos)
llm_pos_ids_list.append(eos_pos)
video_len = grid_t * grid_h * grid_w
audio_len = self._compute_audio_token_count(data["audio_feature_length"])
st = offset + 2 + video_len + audio_len + 2
st = offset + int(data.get("placeholder_len", token_count + 2))
if st < seq_len:
st_idx = int(llm_pos_ids_list[-1].max()) + 1 if llm_pos_ids_list else 0
@@ -1786,7 +1805,11 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
llm_positions = np.concatenate(llm_pos_ids_list, axis=1).reshape(3, -1)
if llm_positions.shape[1] != seq_len:
raise RuntimeError("Position ids length mismatch with input ids length")
raise RuntimeError(
"Position ids length mismatch with input ids length: "
f"positions={llm_positions.shape[1]}, input_ids={seq_len}, "
f"features={feature_summaries}"
)
mrope_position_delta = int(llm_positions.max()) + 1 - seq_len
return torch.from_numpy(llm_positions), mrope_position_delta
+12
View File
@@ -58,6 +58,18 @@ class OmniModelRunnerOutput(ModelRunnerOutput):
kv_extracted_req_ids: list[str] | None = None
omni_connector_output: OmniConnectorOutput | None = None
@classmethod
def with_kv_conn_output_only(cls, kv_connector_output: Any) -> "OmniModelRunnerOutput":
return cls(
req_ids=[],
req_id_to_index={},
sampled_token_ids=[],
logprobs=None,
prompt_logprobs_dict={},
pooler_output=[],
kv_connector_output=kv_connector_output,
)
@dataclass
class OmniRequestOutput: