[Model]Support MiniCPM-o 4.5 (#3642)

Signed-off-by: tc-mb <tianchi_cai@icloud.com>
Co-authored-by: GKangaroo <1095103651@qq.com>
Co-authored-by: GKangaroo <gqx24@mails.tsinghua.edu.cn>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
This commit is contained in:
authored and GitHub committed 2026-06-01 22:12:24 +08:00
1 parent 29343540f7
commit 2b7249fbb3
22 files changed
+6840 -6

No files matched your search

@@ -0,0 +1,9 @@
from .minicpmo_4_5_omni import MiniCPMO45OmniForConditionalGeneration
from .minicpmo_4_5_omni_llm import MiniCPMO45OmniLLMForConditionalGeneration
from .minicpmo_4_5_omni_tts import MiniCPMO45OmniTTSForConditionalGeneration
__all__ = [
"MiniCPMO45OmniForConditionalGeneration",
"MiniCPMO45OmniLLMForConditionalGeneration",
"MiniCPMO45OmniTTSForConditionalGeneration",
]
@@ -0,0 +1,384 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from:
# https://huggingface.co/openbmb/MiniCPM-o-4_5/blob/main/modeling_minicpmo.py
#
# Copyright 2025 The OpenBMB Team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from collections.abc import Iterable
from functools import cached_property
import torch
import torch.nn as nn
from vllm.config import VllmConfig
from vllm.logger import init_logger
from vllm.model_executor.models.interfaces import SupportsMRoPE, SupportsMultiModal, SupportsPP
from vllm.model_executor.models.utils import init_vllm_registered_model, maybe_prefix
from vllm.multimodal import MULTIMODAL_REGISTRY
from vllm.sequence import IntermediateTensors
from vllm.v1.outputs import SamplerOutput
from vllm.v1.sample.metadata import SamplingMetadata
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import (
MiniCPMO45OmniLLMDummyInputsBuilder,
MiniCPMO45OmniLLMMultiModalProcessor,
MiniCPMO45OmniLLMProcessingInfo,
MiniCPMOConfig,
)
from vllm_omni.model_executor.models.output_templates import OmniOutput
from vllm_omni.model_executor.models.utils import add_prefix_to_loaded_weights
from vllm_omni.platforms import current_omni_platform
logger = init_logger(__name__)
@MULTIMODAL_REGISTRY.register_processor(
MiniCPMO45OmniLLMMultiModalProcessor,
info=MiniCPMO45OmniLLMProcessingInfo,
dummy_inputs=MiniCPMO45OmniLLMDummyInputsBuilder,
)
class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, SupportsPP, SupportsMRoPE):
"""MiniCPM-o 4.5 Omni model for conditional generation.
Two-stage pipeline:
- thinker (model_stage="llm"): image / video / audio encoders + 3D
resampler + the omni LLM that emits text + hidden states.
- talker (model_stage="tts"): MiniCPMTTS + the in-process Token2Wav
vocoder that emits the final audio waveform directly.
"""
@classmethod
def get_placeholder_str(cls, modality: str, i: int) -> str | None:
if modality.startswith("image"):
return "(<image>./</image>)"
if modality.startswith("video"):
return "(<video>./</video>)"
if modality.startswith("audio"):
return "(<audio>./</audio>)"
raise ValueError("Only image, video or audio modality is supported")
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__()
self.have_multimodal_outputs = True
config: MiniCPMOConfig = vllm_config.model_config.hf_config
multimodal_config = vllm_config.model_config.multimodal_config
# keep vllm_config for later submodule init
self.vllm_config = vllm_config
# Store configs
self.config = config
self.multimodal_config = multimodal_config
self.model_stage = vllm_config.model_config.model_stage
if self.model_stage == "llm":
# Initialize thinker model (image preprocessing + vision encoder + 3D resampler)
self.thinker = init_vllm_registered_model(
vllm_config=vllm_config,
prefix=maybe_prefix(prefix, "thinker"),
hf_config=config,
# Registry key — must match the entries declared in
# vllm_omni/model_executor/models/registry.py::_OMNI_MODELS.
architectures=["MiniCPMO45OmniLLMForConditionalGeneration"],
)
self.model = self.thinker
self.talker = None
elif self.model_stage == "tts":
self.thinker = None
# Initialize talker model — runs MiniCPMTTS + the in-process
# Token2Wav vocoder and emits the final audio waveform directly.
self.talker = init_vllm_registered_model(
vllm_config=vllm_config,
prefix=maybe_prefix(prefix, "talker"),
hf_config=config,
# Registry key — must match the entries declared in
# vllm_omni/model_executor/models/registry.py::_OMNI_MODELS.
architectures=["MiniCPMO45OmniTTSForConditionalGeneration"],
)
# Initialize multimodal components if needed
if hasattr(self.talker, "init_multi_modal"):
self.talker.init_multi_modal(config)
self.model = self.talker
else:
raise ValueError(f"Invalid model stage: {self.model_stage}. Must be one of: 'llm', 'tts'")
# Set up intermediate tensors
self.make_empty_intermediate_tensors = (
(self.thinker.make_empty_intermediate_tensors)
if self.model_stage == "llm" and self.thinker is not None
else lambda: None
)
self._language_model_names = ["model"]
@cached_property
def sampler(self):
if hasattr(self.model, "sampler"):
return self.model.sampler
from vllm.v1.sample.sampler import Sampler
return Sampler()
# -------------------- Device utilities --------------------
@staticmethod
def _module_device(module: nn.Module) -> torch.device:
try:
return next(module.parameters()).device
except StopIteration:
# No parameters; fall back to buffers or cpu
for _, buf in module.named_buffers(recurse=True):
return buf.device
return torch.device("cpu")
def move_submodules_to_devices(
self,
*,
thinker_device: str | torch.device | None = None,
talker_device: str | torch.device | None = None,
) -> None:
"""Optionally move thinker / talker to different devices.
Example:
model.move_submodules_to_devices(
thinker_device='cuda:0',
talker_device='cuda:1',
)
"""
if thinker_device is not None and self.thinker is not None:
self.thinker.to(thinker_device)
if talker_device is not None and self.talker is not None:
self.talker.to(talker_device)
def get_input_embeddings(
self,
input_ids: torch.Tensor,
multimodal_embeddings=None,
) -> torch.Tensor:
return self.model.get_input_embeddings(input_ids, multimodal_embeddings)
def embed_input_ids(
self,
input_ids: torch.Tensor,
multimodal_embeddings=None,
*,
is_multimodal=None,
) -> torch.Tensor:
if self.model_stage == "tts":
return self.get_input_embeddings(input_ids)
return super().embed_input_ids(input_ids, multimodal_embeddings, is_multimodal=is_multimodal)
def get_multimodal_embeddings(self, **kwargs):
# Delegate to the active stage submodule when it implements MM encoding.
mm_fn = getattr(self.model, "get_multimodal_embeddings", None)
if mm_fn is not None:
return mm_fn(**kwargs)
return []
def embed_multimodal(self, **kwargs: object):
"""vLLM V1 encoder profiling calls this; the inherited Protocol stub returns None."""
return self.get_multimodal_embeddings(**kwargs)
def forward(
self,
input_ids: torch.Tensor,
positions: torch.Tensor,
intermediate_tensors: IntermediateTensors | None = None,
inputs_embeds: torch.Tensor | None = None,
sampling_metadata: SamplingMetadata | None = None,
logits_index: int | None = None,
sampler=None,
additional_information: dict[str, object] | None = None,
**kwargs: object,
) -> torch.Tensor | IntermediateTensors | OmniOutput:
"""
Forward pass for MiniCPM-o Omni model.
Workflow:
1) Thinker (model_stage="llm"): Image / video / audio encoders +
3D resampler + omni LLM → text + hidden states.
2) Talker (model_stage="tts"): MiniCPMTTS + the in-process
Token2Wav vocoder → audio waveform (final pipeline output).
"""
if self.model_stage == "llm":
# Normalize to batched inputs if caller provides 1D/2D unbatched tensors
# TODO: Remove this hack when NPU supports batched inputs properly
added_batch_dim = False
if input_ids is not None and input_ids.ndim == 1:
input_ids = input_ids.unsqueeze(0)
added_batch_dim = True
if positions is not None and positions.ndim == 1:
positions = positions.unsqueeze(0)
added_batch_dim = True
if inputs_embeds is not None and inputs_embeds.ndim == 2:
inputs_embeds = inputs_embeds.unsqueeze(0)
added_batch_dim = True
thinker_dev = self._module_device(self.thinker)
# if input_ids is None, set it to a zero tensor
if input_ids is None:
input_ids = torch.zeros(inputs_embeds.shape[1], dtype=torch.long, device=thinker_dev).unsqueeze(0)
added_batch_dim = True
# Ensure inputs on thinker's device
if input_ids is not None and input_ids.device != thinker_dev:
input_ids = input_ids.to(thinker_dev)
if positions is not None and positions.device != thinker_dev:
positions = positions.to(thinker_dev)
if inputs_embeds is not None and inputs_embeds.device != thinker_dev:
inputs_embeds = inputs_embeds.to(thinker_dev)
if current_omni_platform.is_npu():
# TODO: remove this hack when NPU supports batched inputs properly
thinker_input_ids = input_ids[0] if input_ids is not None and added_batch_dim else input_ids
thinker_positions = positions[0] if positions.ndim > 1 else positions
thinker_inputs_embeds = (
inputs_embeds[0] if inputs_embeds is not None and added_batch_dim else inputs_embeds
)
else:
thinker_input_ids = input_ids[0] if input_ids is not None and added_batch_dim else input_ids
thinker_positions = positions[0] if positions is not None and added_batch_dim else positions
thinker_inputs_embeds = (
inputs_embeds[0] if inputs_embeds is not None and added_batch_dim else inputs_embeds
)
# Run thinker
thinker_output = self.thinker(
input_ids=thinker_input_ids,
positions=thinker_positions,
intermediate_tensors=intermediate_tensors,
inputs_embeds=thinker_inputs_embeds,
**kwargs,
)
if isinstance(thinker_output, tuple):
embeds, text_hidden_states = thinker_output
else:
text_hidden_states = thinker_output
# Prepare hidden states for downstream stages
# Ensure correct shape: (batch_size, seq_len, hidden_dim)
if added_batch_dim:
text_hidden_states = text_hidden_states.squeeze(0)
# Return hidden states with latent in multimodal_outputs for stage_input_processors
return OmniOutput(
text_hidden_states=text_hidden_states,
multimodal_outputs={"latent": text_hidden_states},
)
# Talker stage: runs MiniCPMTTS + the in-process Token2Wav vocoder and
# emits the final audio waveform directly.
if self.model_stage == "tts":
if input_ids is not None:
num_tokens = input_ids.shape[0]
device = input_ids.device
elif inputs_embeds is not None:
num_tokens = inputs_embeds.shape[0]
device = inputs_embeds.device
else:
num_tokens = 1
device = torch.device("cuda")
hidden_dim = self.config.hidden_size if hasattr(self.config, "hidden_size") else 2560
# Profile/dummy run: both input_ids and inputs_embeds are None.
# Note: SupportsMultiModal preprocessing converts input_ids to
# inputs_embeds, so input_ids=None alone does NOT indicate a dummy run.
if input_ids is None and inputs_embeds is None:
dummy_hidden = torch.zeros(num_tokens, hidden_dim, device=device)
return OmniOutput(text_hidden_states=dummy_hidden, multimodal_outputs=None)
runtime_info = kwargs.get("runtime_additional_information")
talker_info = {}
if runtime_info and isinstance(runtime_info, list) and len(runtime_info) > 0:
talker_info = runtime_info[0] if isinstance(runtime_info[0], dict) else {}
with torch.inference_mode():
talker_result = self.talker(
input_ids=input_ids,
positions=positions,
inputs_embeds=inputs_embeds,
additional_information=talker_info,
)
dummy_hidden = torch.zeros(num_tokens, hidden_dim, device=device)
# Talker returns a (mel_spec, waveform_or_None) tuple. MiniCPM-o
# 4.5 emits only the waveform (mel_spec is always None); keep the
# 2-slot unpack so a future mel-emitting variant can plug in
# without changing the wrapper.
mm_out: dict = {}
if isinstance(talker_result, tuple) and len(talker_result) == 2:
_, waveform = talker_result
if waveform is not None:
mm_out["model_outputs"] = [waveform]
return OmniOutput(
text_hidden_states=dummy_hidden,
multimodal_outputs=mm_out if mm_out else None,
)
raise ValueError(f"Unsupported model stage: {self.model_stage}")
def compute_logits(self, hidden_states: torch.Tensor | OmniOutput) -> torch.Tensor | None:
# Handle OmniOutput type
if isinstance(hidden_states, OmniOutput):
hidden_states = hidden_states.text_hidden_states
# Use model for logits computation
return self.model.compute_logits(hidden_states)
def sample(
self,
logits: torch.Tensor,
sampling_metadata: SamplingMetadata,
) -> SamplerOutput | None:
# Use model for sampling
return self.model.sample(logits, sampling_metadata)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""Load weights for the active stage of the omni model."""
loaded_weights = set()
thinker_weights = []
talker_weights = []
# MiniCPM-o checkpoint prefixes → stage mapping:
# thinker: vpm, resampler, llm, apm, audio_projection_layer
# talker: tts (MiniCPMTTS); the Token2Wav vocoder weights load
# separately inside the talker module from the
# ``assets/token2wav`` subdirectory and do not appear
# in this iterator.
for k, v in weights:
if k.startswith(("vpm.", "resampler.", "llm.", "apm.", "audio_projection_layer.")):
thinker_weights.append((k, v))
elif k.startswith("tts."):
talker_weights.append((k, v))
else:
logger.warning("Unknown weight prefix: %s, skipping", k)
# Load thinker weights
if self.thinker is not None and thinker_weights:
thinker_loaded = self.thinker.load_weights(thinker_weights)
thinker_loaded = add_prefix_to_loaded_weights(thinker_loaded, "thinker")
loaded_weights.update(thinker_loaded)
# Load talker weights
if self.talker is not None and talker_weights:
talker_loaded = self.talker.load_weights(talker_weights)
talker_loaded = add_prefix_to_loaded_weights(talker_loaded, "talker")
loaded_weights.update(talker_loaded)
return loaded_weights
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,456 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from:
# https://huggingface.co/openbmb/MiniCPM-o-4_5/blob/main/modeling_minicpmo.py
"""MiniCPM-o 4.5 Talker + Token2Wav: MiniCPMTTS with hidden_text_merge condition.
Pipeline:
1. Receive thinker hidden_states + full token IDs via additional_information
2. Extract tts_bos..tts_eos region
3. Build condition: emb_text(tokens) + projector_semantic(hidden) (hidden_text_merge)
4. Run MiniCPMTTS.generate() -> discrete audio tokens
5. Run Token2wav(tokens) -> waveform bytes -> numpy array
"""
import io
import logging
import os
from collections.abc import Iterable
import numpy as np
import soundfile as sf
import torch
import torch.nn as nn
import torch.nn.functional as F
from vllm.config import VllmConfig
from vllm.model_executor.models.interfaces import SupportsPP
try:
from stepaudio2 import Token2wav as _Token2wav
_stepaudio2_available = True
except ImportError:
_Token2wav = None
_stepaudio2_available = False
logger = logging.getLogger(__name__)
def _install_torchaudio_soundfile_shim() -> None:
"""Monkey-patch torchaudio.load to use soundfile instead of the default
torchcodec backend, which requires libtorchcodec/ffmpeg shared libs that
may be missing on the deployment machine."""
try:
import torchaudio
if getattr(torchaudio, "_soundfile_shim_installed", False):
return
_orig_load = torchaudio.load
def _patched_load(uri, *args, **kwargs):
try:
return _orig_load(uri, *args, **kwargs)
except Exception:
import numpy as _np
import soundfile as _sf
data, sr = _sf.read(uri, dtype="float32", always_2d=True)
wav = torch.from_numpy(_np.ascontiguousarray(data.T))
return wav, sr
torchaudio.load = _patched_load
torchaudio._soundfile_shim_installed = True
logger.info("Installed torchaudio.load soundfile shim")
except Exception as _e:
logger.warning("Could not install torchaudio shim: %s", _e)
_install_torchaudio_soundfile_shim()
class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
"""MiniCPM-o 4.5 Talker: MiniCPMTTS + Token2wav in a single forward pass."""
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__()
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import MiniCPMOConfig
config: MiniCPMOConfig = vllm_config.model_config.hf_config
self.config = config
self.vllm_config = vllm_config
self.tts = None
self.audio_tokenizer = None
self._assets_loaded = False
tts_config = getattr(config, "tts_config", None)
if tts_config is not None:
self._tts_config = tts_config
self._tts_bos_id = getattr(tts_config, "audio_bos_token_id", 151687)
self._text_eos_id = getattr(tts_config, "text_eos_token_id", 151692)
self._num_audio_tokens = getattr(tts_config, "num_audio_tokens", 6562)
self._hidden_size = getattr(tts_config, "hidden_size", 768)
self._normalize = getattr(tts_config, "normalize_projected_hidden", True)
else:
self._tts_config = None
def _lazy_init_tts(self):
if self._assets_loaded or self._tts_config is None:
return
try:
model_path = self.vllm_config.model_config.model
import os
import sys
if model_path not in sys.path:
sys.path.insert(0, model_path)
from transformers.dynamic_module_utils import get_class_from_dynamic_module
MiniCPMTTS = get_class_from_dynamic_module("modeling_minicpmo.MiniCPMTTS", model_path)
# MiniCPMTTS.__init__ reads `config.top_p / top_k / repetition_penalty`
# directly (modeling_minicpmo.py L4112-4114), but the model repo's
# config.json `tts_config` block does not declare these fields and
# PretrainedConfig in recent transformers no longer surfaces
# generation-style params on `self.config`. Inject the defaults the
# upstream code itself ships with (modeling_minicpmo.py L2212-2214,
# L3132-3133) so attribute access does not raise.
for _attr, _default in (("top_p", 0.8), ("top_k", 100), ("repetition_penalty", 1.02)):
if not hasattr(self._tts_config, _attr):
setattr(self._tts_config, _attr, _default)
prev_dtype = torch.get_default_dtype()
torch.set_default_dtype(torch.float32)
try:
self.tts_obj = MiniCPMTTS(config=self._tts_config, audio_tokenizer=None)
finally:
torch.set_default_dtype(prev_dtype)
self.emb_text = self.tts_obj.emb_text
self.projector_semantic = self.tts_obj.projector_semantic
token2wav_dir = os.path.join(model_path, "assets", "token2wav")
if os.path.isdir(token2wav_dir):
if not _stepaudio2_available:
raise ImportError(
"MiniCPM-o 4.5 token2wav stage requires the `stepaudio2` Python "
"module (a MiniCPM-o-flavored Token2wav vocoder, NOT the upstream "
"stepfun-ai/Step-Audio2 — the upstream signature does not accept "
"n_timesteps and will fail at __init__). Install via:\n"
" pip install 'vllm-omni[minicpmo]' # recommended, declared as PR extra\n"
"Equivalent direct installs of the same `from stepaudio2 import Token2wav`\n"
"entry point used by openbmb/MiniCPM-o-4_5/modeling_minicpmo.py:\n"
" pip install stepaudio2-minicpmo # bare token2wav package\n"
" pip install 'minicpmo-utils[all]' # MiniCPM-o umbrella (also brings image/video deps)"
)
prev_dtype2 = torch.get_default_dtype()
torch.set_default_dtype(torch.float32)
try:
# NB: this must be the MiniCPM-o-flavored Token2wav from
# the `stepaudio2-minicpmo` PyPI package (or the
# `minicpmo-utils[all]` umbrella), not the upstream
# `stepfun-ai/Step-Audio2` repo. The MiniCPM-o variant's
# __init__ accepts n_timesteps; the upstream signature is
# (model_path, float16=False) and will raise
# TypeError on n_timesteps. See ImportError message below
# for installation guidance.
self.audio_tokenizer = _Token2wav(token2wav_dir, float16=False, n_timesteps=10)
finally:
torch.set_default_dtype(prev_dtype2)
self.tts_obj.audio_tokenizer = self.audio_tokenizer
logger.info("Loaded Token2wav from %s", token2wav_dir)
# Only mark init as complete after every step succeeds, so a
# partial failure leaves the next call free to retry the full
# init instead of short-circuiting back to a silent empty path.
self._assets_loaded = True
except ImportError:
# Surface missing dependencies directly so users can act on them
# instead of getting a silent None waveform downstream.
raise
except Exception:
# Re-raise non-import init failures (bad token2wav assets, missing
# weights, OOM during Token2wav construction, etc.) so the server
# fails loudly at startup / first request instead of returning
# silent empty audio for every subsequent request.
logger.error("Failed to init 4.5 TTS", exc_info=True)
raise
def generate_speech(
self,
tts_token_ids: torch.Tensor,
tts_hidden_states: torch.Tensor,
) -> np.ndarray | None:
"""Run full 4.5 TTS pipeline using original MiniCPMTTS.generate."""
self._lazy_init_tts()
if not hasattr(self, "tts_obj") or self.tts_obj is None:
logger.warning("generate_speech: tts_obj not initialized")
return None
tts = self.tts_obj
device = tts.emb_text.weight.device
dtype = tts.emb_text.weight.dtype
llm_embeds = tts.emb_text(tts_token_ids.to(device))
hidden_embeds = tts.projector_semantic(tts_hidden_states.to(device=device, dtype=dtype))
if getattr(tts.config, "normalize_projected_hidden", False):
hidden_embeds = F.normalize(hidden_embeds, p=2, dim=-1)
tts_embeds = llm_embeds + hidden_embeds
text_eos = tts.emb_text(torch.tensor([tts.config.text_eos_token_id], device=device, dtype=torch.long))
audio_bos = tts.emb_text(torch.tensor([tts.audio_bos_token_id], device=device, dtype=torch.long))
spk_embeds = torch.zeros(0, tts.config.hidden_size, device=device, dtype=tts_embeds.dtype)
inputs_embeds = torch.cat([spk_embeds, tts_embeds, text_eos, audio_bos], dim=0).unsqueeze(0)
logger.info("generate_speech: inputs_embeds shape=%s", list(inputs_embeds.shape))
# Scale max_new_token with input text length to avoid mid-stream truncation on long
# responses (default 2048 can only cover ~300 text tokens at ~6x audio/text ratio).
# Empirically 511 text tokens → 1951 audio tokens (~3.8x) finishes cleanly, so use 10x
# as a safe upper bound with a floor of 2048 and a hard cap of 16384 to bound latency/mem.
num_text = int(tts_token_ids.shape[-1]) if tts_token_ids.ndim > 0 else 0
max_new_token = max(2048, min(16384, num_text * 10))
eos_token = torch.tensor([tts.config.num_audio_tokens - 1], dtype=torch.long, device=device)
outputs = tts.generate(
inputs_embeds=inputs_embeds,
eos_token=eos_token,
max_new_token=max_new_token,
show_tqdm=False,
)
generated_tokens = outputs.new_ids.squeeze(-1)
logger.info(
"generate_speech: generated %d audio tokens (cap=%d, text_tokens=%d)",
generated_tokens.shape[-1],
max_new_token,
num_text,
)
if self.audio_tokenizer is None:
logger.warning("No audio_tokenizer")
return None
import torchaudio
model_path = self.vllm_config.model_config.model
default_ref = os.path.join(model_path, "assets", "HT_ref_audio.wav")
prompt_wav_path = default_ref if os.path.exists(default_ref) else None
_orig_save = torchaudio.save
def _patched_save(uri, src, sample_rate, **kw):
kw.pop("backend", None)
if hasattr(uri, "write"):
sf.write(uri, src.cpu().numpy().T, sample_rate, format="WAV")
return
return _orig_save(uri, src, sample_rate, backend="soundfile", **kw)
torchaudio.save = _patched_save
prev_dtype = torch.get_default_dtype()
torch.set_default_dtype(torch.float32)
try:
with torch.amp.autocast("cuda", enabled=False):
token_list = generated_tokens.squeeze(0).tolist()
num_tokens = len(token_list)
# For long outputs, the one-shot vocoder path
# (Token2wav.__call__ -> flow.inference) runs full O(N^2) self-
# attention over all audio tokens and OOMs on a 24GB card once
# N exceeds a few thousand (e.g. 4964 tokens needs ~3GiB for a
# single attention matmul). Switch to the chunked / streaming
# vocoder (set_stream_cache + stream) which truncates the flow
# attention caches to prompt_len + 100 steps on every chunk,
# keeping peak memory bounded regardless of total length.
STREAM_THRESHOLD = int(os.environ.get("MINICPMO45_TTS_STREAM_THRESHOLD", "2500")) # ~100s @ 25Hz
CHUNK_SIZE = int(os.environ.get("MINICPMO45_TTS_STREAM_CHUNK", "50")) # ~2s per chunk
MIN_TAIL = 6 # must exceed flow.pre_lookahead_len (typically 3)
if num_tokens <= STREAM_THRESHOLD:
wav_bytes = self.audio_tokenizer(token_list, prompt_wav_path)
waveform, sr = sf.read(io.BytesIO(wav_bytes))
waveform = waveform.astype(np.float32)
else:
# Build chunk boundaries, merging a too-small tail into the
# previous chunk so every chunk satisfies MIN_TAIL.
boundaries = []
i = 0
while i < num_tokens:
end = min(i + CHUNK_SIZE, num_tokens)
if 0 < num_tokens - end < MIN_TAIL:
end = num_tokens
boundaries.append((i, end))
i = end
logger.info(
"generate_speech: streaming vocoder, %d tokens -> %d chunks (chunk=%d)",
num_tokens,
len(boundaries),
CHUNK_SIZE,
)
stream_cache, hift_cache_dict = self.audio_tokenizer.set_stream_cache(prompt_wav_path)
self.audio_tokenizer.stream_cache = stream_cache
self.audio_tokenizer.hift_cache_dict = hift_cache_dict
try:
pieces = []
for idx, (s, e) in enumerate(boundaries):
is_last = idx == len(boundaries) - 1
wav_np = self.audio_tokenizer.stream(
token_list[s:e],
prompt_wav_path,
last_chunk=is_last,
return_waveform=True,
)
pieces.append(np.asarray(wav_np).reshape(-1))
waveform = np.concatenate(pieces, axis=0).astype(np.float32)
sr = 24000
finally:
# Free per-request streaming state so the next request starts clean
self.audio_tokenizer.stream_cache = None
self.audio_tokenizer.hift_cache_dict = {}
finally:
torch.set_default_dtype(prev_dtype)
torchaudio.save = _orig_save
logger.info("generate_speech: waveform %d samples, sr=%d", waveform.shape[0], sr)
return waveform
def _generate_tokens(self, inputs_embeds: torch.Tensor, max_new_token: int = 2048) -> torch.Tensor | None:
"""Autoregressive generation of audio tokens using the TTS LlamaModel."""
device = inputs_embeds.device
eos_token = self._num_audio_tokens - 1
condition_length = inputs_embeds.shape[1]
num_vq = len(self.emb_code)
new_tokens = torch.zeros(1, max_new_token, num_vq, device=device, dtype=torch.long)
past_key_values = None
finished = False
for t in range(max_new_token):
if t == 0:
emb = inputs_embeds
position_ids = torch.arange(condition_length, device=device).unsqueeze(0)
else:
code_emb = [self.emb_code[q](new_tokens[:, t - 1 : t, q]) for q in range(num_vq)]
emb = torch.stack(code_emb, -1).sum(-1)
position_ids = torch.tensor([[condition_length + t - 1]], device=device)
outputs = self.tts_model(
inputs_embeds=emb,
position_ids=position_ids,
past_key_values=past_key_values,
use_cache=True,
)
hidden = outputs.last_hidden_state
past_key_values = outputs.past_key_values
logits = torch.stack([self.head_code[q](hidden[:, -1]) for q in range(num_vq)], dim=-1)
logits = logits.float() / 0.8
if t < 50:
logits[:, eos_token, :] = -float("inf")
probs = F.softmax(logits, dim=1)
idx = torch.multinomial(probs.view(-1, probs.shape[1]), 1).view(1, num_vq)
new_tokens[:, t] = idx
if (idx == eos_token).any():
finished = True
break
return new_tokens[:, : t + 1 if finished else t, :]
def _dummy_hidden_states(
self,
input_ids: torch.Tensor | None,
positions: torch.Tensor | None,
inputs_embeds: torch.Tensor | None,
) -> torch.Tensor:
"""Shape-correct zero tensor for vllm KV cache profiling.
vllm's gpu_model_runner._dummy_run takes forward()'s return value as
``hidden_states`` and does ``hidden_states[logit_indices_device]``;
returning None on the dummy path crashes with
``TypeError: 'NoneType' object is not subscriptable``.
"""
for ref in (input_ids, positions, inputs_embeds):
if isinstance(ref, torch.Tensor):
num_tokens = int(ref.shape[0]) if ref.ndim >= 1 else 1
device = ref.device
break
else:
num_tokens = 1
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
hidden_size = int(getattr(self, "_hidden_size", 768) or 768)
return torch.zeros((num_tokens, hidden_size), device=device, dtype=torch.bfloat16)
def forward(
self,
input_ids=None,
positions=None,
intermediate_tensors=None,
inputs_embeds=None,
additional_information=None,
**kwargs,
):
if additional_information is None:
additional_information = {}
tts_token_ids = additional_information.get("tts_token_ids")
tts_hidden_states = additional_information.get("tts_hidden_states")
tts_text = additional_information.get("llm_output_text", [""])
if isinstance(tts_text, list):
tts_text = tts_text[0] if tts_text else ""
if tts_token_ids is None or tts_hidden_states is None:
# KV cache profiling / dummy run path — no real TTS input yet.
logger.debug("4.5 Talker: dummy forward (missing tts_token_ids/tts_hidden_states)")
return self._dummy_hidden_states(input_ids, positions, inputs_embeds)
logger.info("4.5 Talker: generating speech for %d tokens", tts_token_ids.shape[0])
waveform = self.generate_speech(tts_token_ids, tts_hidden_states)
# Tuple layout: (mel_spec, waveform). 4.5 talker emits only waveform,
# so mel_spec stays None; the wrapper unpacks in this order and
# packages the waveform into ``multimodal_outputs["model_outputs"]``.
if waveform is not None:
return None, torch.tensor(waveform, dtype=torch.float32)
return None, None
def compute_logits(self, hidden_states, *args, **kwargs):
return torch.zeros(1, 2, device=hidden_states.device if isinstance(hidden_states, torch.Tensor) else "cuda")
def sample(self, logits, sampling_metadata):
return None
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
loaded = set()
tts_weights = {}
for k, v in weights:
if k.startswith("tts."):
tts_weights[k.replace("tts.", "", 1)] = v
# vllm sanity-checks `loaded` against `named_parameters()`.
# The submodule is attached at `self.tts_obj`, not `self.tts`,
# so report the loaded name under the on-module path.
loaded.add(k.replace("tts.", "tts_obj.", 1))
if tts_weights and self._tts_config is not None:
self._lazy_init_tts()
if hasattr(self, "tts_obj") and self.tts_obj is not None:
missing, unexpected = self.tts_obj.load_state_dict(tts_weights, strict=False)
if missing:
logger.warning("TTS missing keys (%d): %s", len(missing), missing[:5])
if unexpected:
logger.warning("TTS unexpected keys (%d): %s", len(unexpected), unexpected[:5])
self.tts_obj = self.tts_obj.to("cuda")
self.emb_text = self.tts_obj.emb_text
self.projector_semantic = self.tts_obj.projector_semantic
logger.info("Loaded %d TTS weights, moved to cuda", len(tts_weights))
return loaded
def get_input_embeddings(self, input_ids, multimodal_embeddings=None, **kwargs):
if hasattr(self, "emb_text") and self.emb_text is not None:
return self.emb_text(input_ids)
return torch.zeros(input_ids.shape[0], 1)
def embed_input_ids(self, input_ids, **kwargs):
return self.get_input_embeddings(input_ids, **kwargs)
@@ -0,0 +1,77 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MiniCPM-o 4.5 pipeline topology (frozen).
Stage 0: Thinker — multimodal understanding + text generation.
Stage 1: Talker — MiniCPMTTS + Token2Wav, emits the final audio waveform.
The thinker -> talker bridge passes the hidden states + token ids extracted
from the thinker output through ``minicpmo_4_5_omni.llm2tts``; the talker
runs MiniCPMTTS and the on-device Token2wav vocoder in the same process and
returns the waveform directly as the pipeline's final audio output.
"""
from vllm_omni.config.stage_config import (
PipelineConfig,
StageExecutionType,
StagePipelineConfig,
)
_PROC = "vllm_omni.model_executor.stage_input_processors.minicpmo_4_5_omni"
MINICPMO_4_5_PIPELINE = PipelineConfig(
model_type="minicpmo_4_5",
model_arch="MiniCPMO45OmniForConditionalGeneration",
# MiniCPM-o 4.5's HF config.json reports `model_type="minicpmo"` and
# `architectures=["MiniCPMO"]` — both shared verbatim with older MiniCPM-o
# 1.0 / 2.6 checkpoints. The only field distinguishing the generations is
# the top-level ``version`` string, so we register both the shared
# ``MiniCPMO`` arch (for auto-detection) and the 4.5-specific arch (for
# repos that opt into the explicit name later), then pin the routing to
# 4.5 via ``hf_config_predicate``. Without the predicate, loading a 2.6
# checkpoint would also intersect ``["MiniCPMO"]`` here and get routed
# into the 4.5 pipeline, which would then fail at load time.
hf_architectures=("MiniCPMO", "MiniCPMO45OmniForConditionalGeneration"),
hf_config_predicate=lambda c: str(getattr(c, "version", "")) == "4.5",
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="llm",
execution_type=StageExecutionType.LLM_AR,
input_sources=(),
final_output=True,
final_output_type="text",
owns_tokenizer=True,
requires_multimodal_data=True,
engine_output_type="latent",
sampling_constraints={"detokenize": True},
),
StagePipelineConfig(
stage_id=1,
model_stage="tts",
# Stage 1 shares the top-level wrapper class
# (``MiniCPMO45OmniForConditionalGeneration``) inherited from
# ``model_arch`` above. The wrapper dispatches on ``model_stage``
# and, for ``"tts"``, instantiates the standalone TTS submodule
# (``MiniCPMO45OmniTTSForConditionalGeneration``) internally.
# Routing through the wrapper is required so that the runner-side
# ``runtime_additional_information`` payload reaches the talker
# (the standalone TTS class only reads ``additional_information``,
# so wiring stage 1 directly to it would always trigger the dummy
# path) and so the resulting waveform is packaged as
# ``OmniOutput.multimodal_outputs["model_outputs"]`` instead of
# being returned as a bare tuple that the AR runner would mistake
# for hidden states. ``hf_config_name="tts_config"`` keeps KV
# cache / mrope sizing scoped to the talker sub-config.
execution_type=StageExecutionType.LLM_AR,
input_sources=(0,),
final_output=True,
final_output_type="audio",
hf_config_name="tts_config",
engine_output_type="audio",
custom_process_input_func=f"{_PROC}.llm2tts",
sampling_constraints={"detokenize": False},
),
),
)
@@ -250,6 +250,22 @@ _OMNI_MODELS = {
"ming_flash_omni",
"MingFlashOmniForConditionalGeneration",
),
# MiniCPM-o 4.5 Omni models
"MiniCPMO45OmniForConditionalGeneration": (
"minicpmo_4_5",
"minicpmo_4_5_omni",
"MiniCPMO45OmniForConditionalGeneration",
),
"MiniCPMO45OmniLLMForConditionalGeneration": (
"minicpmo_4_5",
"minicpmo_4_5_omni_llm",
"MiniCPMO45OmniLLMForConditionalGeneration",
),
"MiniCPMO45OmniTTSForConditionalGeneration": (
"minicpmo_4_5",
"minicpmo_4_5_omni_tts",
"MiniCPMO45OmniTTSForConditionalGeneration",
),
}