[Bugfix] MiniCPMo-4.5 bugfix (#5233)

Signed-off-by: R2-Y <ruiruyang2@gmail.com>
Co-authored-by: Guanzhe (Leo) Huang <116847811+leohuang257@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
This commit is contained in:
authored and GitHub committed 2026-07-22 09:23:03 +08:00
1 parent ee33954dff
commit ea34bde619
2 files changed
+186 -2

No files matched your search

@@ -0,0 +1,154 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for MiniCPM-o 4.5 >30s audio preprocessing.
Covers:
- ``_minicpmo_field_config``: <=30s audio keeps the batched layout
- >30s audio switches to ``flat`` with one slice per source audio
- mixed long + short audios group chunks by source audio
- no audio inputs -> batched (default path unchanged)
- ``process_audios``: >30s audio unpads per chunk without ``TypeError``
- multiple >30s audios unpad in chunk order
- <=30s audio unpadding byte-identical to the pre-fix behavior
"""
from __future__ import annotations
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from vllm.multimodal.inputs import (
MultiModalBatchedField,
MultiModalFlatField,
)
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import (
MiniCPMO45OmniLLMMultiModalProcessor,
MiniCPMOMultiModalDataParser,
_minicpmo_field_config,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_SAMPLE_RATE = 16_000
_FEAT_DIM = 128
# Whisper pads every 30s chunk to 3000 mel frames.
_CHUNK_FRAMES = 3000
class TestMiniCPMOFieldConfig:
def test_short_audios_stay_batched(self) -> None:
# two <=30s audios: one feature entry + one 1-element lens per audio
hf_inputs = {
"audio_features": [torch.zeros(_FEAT_DIM, _CHUNK_FRAMES)] * 2,
"audio_feature_lens": [torch.tensor([980]), torch.tensor([1500])],
}
config = _minicpmo_field_config(hf_inputs)
assert isinstance(config["audio_features"].field, MultiModalBatchedField)
def test_long_audio_groups_chunks_per_audio(self) -> None:
# one 45s audio -> 2 chunks but a single lens tensor
hf_inputs = {
"audio_features": [torch.zeros(_FEAT_DIM, _CHUNK_FRAMES)] * 2,
"audio_feature_lens": [torch.tensor([_CHUNK_FRAMES, 1500])],
}
config = _minicpmo_field_config(hf_inputs)
field = config["audio_features"].field
assert isinstance(field, MultiModalFlatField)
assert field.slices == [slice(0, 2)]
def test_mixed_long_and_short_audios(self) -> None:
# 45s audio (2 chunks) + 10s audio (1 chunk)
hf_inputs = {
"audio_features": [torch.zeros(_FEAT_DIM, _CHUNK_FRAMES)] * 3,
"audio_feature_lens": [
torch.tensor([_CHUNK_FRAMES, 1500]),
torch.tensor([980]),
],
}
config = _minicpmo_field_config(hf_inputs)
field = config["audio_features"].field
assert isinstance(field, MultiModalFlatField)
assert field.slices == [slice(0, 2), slice(2, 3)]
def test_no_audio_inputs_stay_batched(self) -> None:
config = _minicpmo_field_config({})
assert isinstance(config["audio_features"].field, MultiModalBatchedField)
def _make_processor(
fake_hf_outputs: dict[str, list[torch.Tensor]],
) -> MiniCPMO45OmniLLMMultiModalProcessor:
"""Build a processor without a checkpoint: only what ``process_audios``
touches, with the HF processor call returning canned Whisper-style output."""
processor = object.__new__(MiniCPMO45OmniLLMMultiModalProcessor)
processor.info = SimpleNamespace(audio_pattern="(<audio>./</audio>)")
processor.data_parser = MiniCPMOMultiModalDataParser(target_sr=_SAMPLE_RATE)
def fake_base_call_hf_processor(prompts, mm_data, mm_kwargs, tok_kwargs, *, out_keys):
return {key: fake_hf_outputs[key] for key in out_keys}
processor._base_call_hf_processor = fake_base_call_hf_processor
return processor
def _audio_seconds(seconds: float) -> np.ndarray:
return np.zeros(int(seconds * _SAMPLE_RATE), dtype=np.float32)
class TestProcessAudiosUnpadding:
def test_long_audio_unpads_per_chunk(self) -> None:
# 45s audio: 2 padded chunks, one lens tensor -> pre-fix TypeError
chunk_lens = [_CHUNK_FRAMES, 1500]
fake_outputs = {
"audio_features": [torch.randn(_FEAT_DIM, _CHUNK_FRAMES) for _ in chunk_lens],
"audio_feature_lens": [torch.tensor(chunk_lens)],
}
processor = _make_processor(fake_outputs)
result = processor.process_audios({"audios": [_audio_seconds(45)]}, {}, {})
features = result["audio_features"]
assert [feat.shape[-1] for feat in features] == chunk_lens
for feat, padded, length in zip(features, fake_outputs["audio_features"], chunk_lens):
assert torch.equal(feat, padded[:, :length])
def test_multiple_long_audios(self) -> None:
# two >30s audios: 4 chunks total, 2-element lens tensor each
fake_outputs = {
"audio_features": [torch.randn(_FEAT_DIM, _CHUNK_FRAMES) for _ in range(4)],
"audio_feature_lens": [
torch.tensor([_CHUNK_FRAMES, 1200]),
torch.tensor([_CHUNK_FRAMES, 800]),
],
}
processor = _make_processor(fake_outputs)
result = processor.process_audios({"audios": [_audio_seconds(42), _audio_seconds(38)]}, {}, {})
widths = [feat.shape[-1] for feat in result["audio_features"]]
assert widths == [_CHUNK_FRAMES, 1200, _CHUNK_FRAMES, 800]
def test_short_audio_unchanged(self) -> None:
# <=30s audio: single-chunk unpadding identical to pre-fix behavior
fake_outputs = {
"audio_features": [torch.randn(_FEAT_DIM, _CHUNK_FRAMES)],
"audio_feature_lens": [torch.tensor([980])],
}
processor = _make_processor(fake_outputs)
result = processor.process_audios({"audios": [_audio_seconds(9.8)]}, {}, {})
features = result["audio_features"]
assert len(features) == 1
assert features[0].shape == (_FEAT_DIM, 980)
assert torch.equal(features[0], fake_outputs["audio_features"][0][:, :980])
@@ -3175,9 +3175,33 @@ class MiniCPMOAudioEmbeddingItems(DictEmbeddingItems):
def _minicpmo_field_config(hf_inputs: Mapping[str, torch.Tensor]):
audio_features = hf_inputs.get("audio_features")
audio_feature_lens = hf_inputs.get("audio_feature_lens")
audio_features_cfg = MultiModalFieldConfig.batched("audio")
if audio_features is not None and audio_feature_lens is not None:
num_features = len(audio_features)
num_audios = len(audio_feature_lens)
if num_features > num_audios:
chunks_per_audio = [lens.numel() if isinstance(lens, torch.Tensor) else 1 for lens in audio_feature_lens]
if sum(chunks_per_audio) != num_features:
chunks_per_audio = [
max(int((lens != 0).sum()), 1) if isinstance(lens, torch.Tensor) else 1
for lens in audio_feature_lens
]
slice_idxs = [0]
for num_chunks in chunks_per_audio:
slice_idxs.append(slice_idxs[-1] + num_chunks)
audio_features_cfg = MultiModalFieldConfig.flat(
"audio",
[slice(slice_idxs[i], slice_idxs[i + 1]) for i in range(len(chunks_per_audio))],
)
return dict(
**_minicpmv_field_config(hf_inputs),
audio_features=MultiModalFieldConfig.batched("audio"),
audio_features=audio_features_cfg,
audio_feature_lens=MultiModalFieldConfig.batched("audio"),
audio_embeds=MultiModalFieldConfig.batched("audio"),
)
@@ -3369,11 +3393,17 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn
out_keys={"audio_features", "audio_feature_lens"},
)
flat_feature_lens: list[int] = []
for lens in audio_inputs["audio_feature_lens"]:
if isinstance(lens, torch.Tensor):
flat_feature_lens.extend(lens.flatten().tolist())
else:
flat_feature_lens.append(int(lens))
unpadded_audio_features = [
feat[:, :feature_len]
for feat, feature_len in zip(
audio_inputs["audio_features"],
audio_inputs["audio_feature_lens"],
flat_feature_lens,
)
]
audio_inputs["audio_features"] = unpadded_audio_features