[Perf][MiniCPM-o] Stage 0 streaming audio/vision encoder CUDA graphs and candidate sampling (#8430)

Signed-off-by: BeatSeat <wendavid552@gmail.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
This commit is contained in:
BeatSeatandamy-why-3459 authored and GitHub committed 2026-10-06 07:53:03 -07:00
1 parent 65699fe94c
commit 182b77da4c
15 files changed
+2998 -198

No files matched your search

@@ -0,0 +1,26 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""``residual_layer_norm`` matches the eager gated residual + LayerNorm chain."""
import pytest
import torch
import torch.nn.functional as F
from vllm_omni.model_executor.models.minicpmo_4_5.ops import residual_layer_norm
pytestmark = [pytest.mark.core_model, pytest.mark.cuda]
@pytest.mark.parametrize("device", ["cpu"] + (["cuda"] if torch.cuda.is_available() else []))
def test_gated_residual_modulated_layer_norm(device: str) -> None:
torch.manual_seed(0)
channels = 64
x = torch.randn(3, 7, channels, device=device)
y = torch.randn(3, 7, channels, device=device)
gate, scale, shift = (torch.randn(channels, device=device) for _ in range(3))
expected_residual = x + gate * y
expected = F.layer_norm(expected_residual, (channels,), eps=1e-6) * (1 + scale) + shift
residual = x.clone()
out = residual_layer_norm(residual, y, gate=gate, weight=1 + scale, bias=shift, eps=1e-6, residual_out=residual)
torch.testing.assert_close(residual, expected_residual, rtol=1e-6, atol=1e-6)
torch.testing.assert_close(out, expected, rtol=1e-5, atol=1e-5)
@@ -0,0 +1,71 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""``duplex_incremental_fbank`` is bitwise equal to the remote ``StreamingMelProcessorExact`` (skipped without it)."""
import importlib
from pathlib import Path
from types import ModuleType, SimpleNamespace
from typing import Any
import numpy as np
import pytest
import torch
from transformers.dynamic_module_utils import init_hf_modules
from transformers.utils import HF_MODULES_CACHE
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.stage0 import MiniCPMO45Stage0DuplexRuntime
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_SR = 16000
@pytest.fixture(scope="module")
def remote() -> ModuleType: # openbmb/MiniCPM-o-4_5's remote code, from the transformers module cache
candidates = Path(HF_MODULES_CACHE, "transformers_modules").glob("*/*/processing_minicpmo.py")
paths = [path for path in candidates if "class StreamingMelProcessorExact" in path.read_text()]
if not paths:
pytest.skip("MiniCPM-o 4.5 remote processing code is not in the transformers module cache")
init_hf_modules()
path = max(paths, key=lambda path: path.stat().st_mtime)
return importlib.import_module(".".join(path.relative_to(HF_MODULES_CACHE).with_suffix("").parts))
def _mel(remote: ModuleType, *, incremental: bool) -> Any:
"""Stage 0's streaming configuration, switched over by the runtime as a session's processor is."""
mel = remote.StreamingMelProcessorExact(
feature_extractor=remote.MiniCPMAAudioProcessor(),
chunk_ms=1000,
first_chunk_ms=1035,
cnn_redundancy_ms=20,
enable_sliding_window=True,
slide_trigger_seconds=30.0,
slide_stride_seconds=10.0,
)
runtime = MiniCPMO45Stage0DuplexRuntime.__new__(MiniCPMO45Stage0DuplexRuntime)
runtime._incremental_fbank = incremental
runtime._maybe_enable_incremental_fbank(SimpleNamespace(_streaming_mel_processor=mel))
assert (type(mel) is not remote.StreamingMelProcessorExact) is incremental
return mel
@pytest.mark.parametrize("chunks", ["stage0", "random"])
def test_streams_are_bitwise_equal(remote, chunks: str) -> None:
# Stage 0's chunk sizes, or random ones (unaligned buffer ends); a 66 s stream slides the window twice.
rng = np.random.default_rng(7)
audio = (rng.standard_normal(66 * _SR) * 0.05).astype(np.float32)
audio[_SR * 3 : _SR * 7] *= 20 # loud stretch for the dynamic range
audio[_SR * 12 : _SR * 14] = 0.0 # digital silence
audio[_SR * 45 :] *= 1e-3 # quiet tail
ref, inc = _mel(remote, incremental=False), _mel(remote, incremental=True)
position, slides = 0, 0
while position < len(audio):
size = ref.get_chunk_size() if chunks == "stage0" else int(rng.integers(1, 2 * _SR))
dropped = ref.left_samples_dropped
expected, expected_info = ref.process(audio[position : position + size])
got, got_info = inc.process(audio[position : position + size])
assert torch.equal(got, expected) and got_info == expected_info, position
assert torch.equal(inc._extract_full(), remote.StreamingMelProcessorExact._extract_full(inc)), position
position += size
slides += ref.left_samples_dropped != dropped
assert slides >= 2
@@ -0,0 +1,187 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Stage-0 duplex sampling: batched rows against a row-by-row reference, the deferred path against both."""
from types import SimpleNamespace
import pytest
import torch
from torch.utils._python_dispatch import TorchDispatchMode
from vllm_omni.model_executor.models.minicpmo_4_5 import minicpmo_4_5_omni
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.policy import MiniCPMO45DuplexPolicy
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
Model = minicpmo_4_5_omni.MiniCPMO45OmniForConditionalGeneration
CHUNK_EOS, LISTEN, TTS_PAD, VOCAB = 5, 6, 10, 40
_NAMES = ("unit", "chunk_eos", "listen", "tts_bos", "turn_eos", "chunk_tts_eos", "tts_pad")
TOKEN_IDS = {f"{name}_token_id": token for name, token in zip(_NAMES, (1, CHUNK_EOS, LISTEN, 7, 8, 9, TTS_PAD))}
class _CharTokenizer:
"""Token t decodes to (t % 3) + 1 copies of one CJK character; special ids decode to ""."""
clean_up_tokenization_spaces, bad_token_ids, all_special_ids = False, list[int](), sorted(TOKEN_IDS.values())
def __len__(self):
return VOCAB
def decode(self, ids, skip_special_tokens=False):
return "".join("" if t in self.all_special_ids else chr(0x4E00 + t) * (t % 3 + 1) for t in map(int, ids))
def batch_decode(self, batch, skip_special_tokens=False):
return [self.decode(ids) for ids in batch]
def _setup(seed: int, *, all_greedy: bool = False, max_chars: int = 6, turns_open: bool = True):
"""Six rows: sampled, listen in an open turn, speak-length cap, boundary chunk_eos, greedy, penalized."""
torch.manual_seed(seed)
steps = [torch.randn(6, VOCAB) * 2 for _ in range(2)]
for logits in steps:
logits[3, CHUNK_EOS] += 50.0
logits[1, LISTEN] += 40.0
states = {row: SimpleNamespace(generated_tokens=[], current_turn_ended=True) for row in range(6)}
states[1].current_turn_ended, states[5].generated_tokens = not turns_open, [11, 11, 12]
model = Model.__new__(Model)
model.model_stage, model.max_new_speak_tokens_per_chunk, model.max_speak_chars_per_chunk = "llm", 5, max_chars
model._minicpmo45_native_duplex_token_ids_cache = dict(TOKEN_IDS)
model._minicpmo45_tokenizer_cache, model._minicpmo45_duplex_state_for_row = _CharTokenizer(), states.get
model._minicpmo45_duplex_payload_for_row = model._minicpmo45_duplex_row_request_max_tokens = lambda row: None
md = SimpleNamespace(all_greedy=all_greedy, top_p=torch.tensor([0.9, 1.0, 0.8, 0.8, 0.8, 0.85]))
md.generators = {row: torch.Generator().manual_seed(100 + row) for row in range(6)}
md.output_token_ids = [[11, 12], [2], [1, 2, 3, 4], [2, 3], [7, 2], [20, 21, 22]]
md.temperature, md.top_k = torch.tensor([0.8, 1.1, 0.7, 0.7, 0.0, 0.9]), torch.tensor([10, 0, 100, 100, 100, 20])
return model, states, md, steps
def _reference(logits, md, states, row_params) -> list[int]:
"""Row by row: speak-length cap, boundary draw, then the stage-2 draw (no cut, no listen rewrite)."""
out = []
for row, (temperature, top_k, top_p) in enumerate(row_params):
generator, recent = md.generators[row], md.output_token_ids[row]
if len(recent) >= 4 or torch.rand((), generator=generator) < torch.softmax(logits[row], -1)[CHUNK_EOS]:
out.append(CHUNK_EOS)
continue
row_logits = logits[row : row + 1].clone()
row_logits[0, [TTS_PAD, CHUNK_EOS]] = float("-inf")
for token in set((states[row].generated_tokens or recent)[-MiniCPMO45DuplexPolicy.REPETITION_HISTORY_SIZE :]):
row_logits[0, token] /= 1.05
if temperature <= 0:
out.append(int(row_logits.argmax()))
continue
scaled = row_logits / temperature
candidates = Model._duplex_top_k_top_p_candidates(scaled, top_k=top_k, top_p=top_p)
# The candidate-space distribution is the dense top-k / top-p filter's.
dense = torch.softmax(Model._top_k_top_p_filter(scaled, top_k=int(top_k), top_p=top_p), -1)
torch.testing.assert_close(_expand(scaled, candidates), dense)
uniform = torch.rand((), generator=generator)
out.append(int(Model._duplex_draw_candidates(scaled, candidates, uniform.view(1))))
return out
def _expand(logits, candidates) -> torch.Tensor:
"""The candidates as a dense distribution: the tie slot spread over its lowest-index tied tokens."""
probs, ids, kth, ties = candidates
dense = torch.zeros(logits.shape, dtype=probs.dtype).scatter_(1, ids[:, :-1], probs[:, :-1])
tied = logits == kth
kept = tied & (tied.cumsum(dim=-1) <= ties)
return dense + kept * (probs[:, -1:] / ties.clamp(min=1))
def _run(model, md, steps) -> tuple[list[list[int]], list[bool]]:
"""Two steps through the Stage-0 sampler; also whether each left a deferred commit."""
tokens, deferred = [], []
for logits in steps:
out = model._sample_minicpmo45_native_duplex_stage0(logits.clone(), md, duplex_rows=list(range(6)))
tokens.append(out.sampled_token_ids.squeeze(-1).tolist())
deferred.append(getattr(model, "_minicpmo45_duplex_pending_samples", None) is not None)
model._commit_minicpmo45_duplex_pending_samples()
for row, token in enumerate(tokens[-1]):
md.output_token_ids[row].append(token)
return tokens, deferred
@pytest.mark.parametrize("seed", range(4))
def test_batched_rows_match_the_row_by_row_reference(seed):
model, states, md, steps = _setup(seed, max_chars=10**6, turns_open=False)
_, ref_states, ref_md, _ = _setup(seed)
params = model._minicpmo45_duplex_row_params(md, 6)
kwargs = dict(row_idxs=list(range(6)), token_ids=TOKEN_IDS, row_params=params)
rows = model._sample_minicpmo45_native_duplex_rows(steps[0], md, **kwargs)
assert rows == _reference(steps[0], ref_md, ref_states, params)
assert all(torch.equal(md.generators[r].get_state(), ref_md.generators[r].get_state()) for r in range(6))
@pytest.mark.parametrize("all_greedy", [False, True])
@pytest.mark.parametrize("seed", range(3))
def test_deferred_rows_match_the_synchronous_rows(seed, all_greedy, monkeypatch):
sync_model, sync_states, sync_md, steps = _setup(seed, all_greedy=all_greedy)
monkeypatch.setattr(sync_model, "_sample_minicpmo45_native_duplex_rows_deferred", lambda *a, **k: None)
expected, _ = _run(sync_model, sync_md, steps)
model, states, md, _ = _setup(seed, all_greedy=all_greedy)
assert _run(model, md, steps) == (expected, [True, True])
assert {row: vars(s) for row, s in states.items()} == {row: vars(s) for row, s in sync_states.items()}
assert all(torch.equal(md.generators[r].get_state(), sync_md.generators[r].get_state()) for r in range(6))
def _tied_logits(device: str) -> torch.Tensor:
"""Rows whose k-th logit is tied well past k, with the nucleus cutting inside and before the tie."""
logits = torch.full((4, 64), -3.0, device=device)
logits[0, [3, 9, 17, 30, 41, 50]] = 1.0 # six tied for k=2
logits[1, 7], logits[1, [2, 12, 22, 32, 42]] = 4.0, 1.0 # a leader, then five tied for k=3
logits[2] = torch.randn(64, generator=torch.Generator().manual_seed(0)).to(device)
logits[3, ::4] = 0.5 # sixteen tied for k=4
return logits
@pytest.mark.parametrize("top_p", [1.0, 0.9, 0.5, 0.2])
@pytest.mark.parametrize("top_k", [2, 3, 4, 0])
def test_candidate_draws_follow_the_dense_distribution(top_k, top_p):
# A midpoint grid of uniforms integrates the inverse CDF exactly: each token
# is drawn in proportion to its dense probability, ties included.
logits, n = _tied_logits("cpu"), 4096
expected = torch.softmax(Model._top_k_top_p_filter(logits, top_k=top_k, top_p=top_p), dim=-1)
rows = logits.repeat(n, 1)
grid = ((torch.arange(n, dtype=torch.float32) + 0.5) / n).repeat_interleave(logits.shape[0])
draws = Model._duplex_draw_candidates(
rows, Model._duplex_top_k_top_p_candidates(rows, top_k=top_k, top_p=top_p), grid
)
counts = torch.zeros_like(expected).index_put_(
(torch.arange(rows.shape[0]) % logits.shape[0], draws), torch.ones(rows.shape[0]), accumulate=True
)
torch.testing.assert_close(counts / n, expected, atol=2.0 / n, rtol=0)
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
@pytest.mark.parametrize("top_p", [0.2, 0.25, 0.5, 0.8, 1.0])
@pytest.mark.parametrize("n", [4, 10, 20, 64, 100])
def test_tied_nucleus_boundaries_match_the_dense_filter(n, top_p, dtype):
# Uniform ties put every top_p on (or next to) a candidate boundary.
logits = torch.full((3, n + 8), -30.0)
logits[0, :n], logits[1, 3 : 3 + n], logits[2, :2], logits[2, 4 : 4 + n] = 20.0, 0.5, 2.0, 1.0
logits = logits.to(dtype)
# The candidates compute in float32; bf16 widens exactly, so ties survive.
expected = torch.softmax(Model._top_k_top_p_filter(logits.float(), top_k=3, top_p=top_p), dim=-1)
actual = _expand(logits, Model._duplex_top_k_top_p_candidates(logits, top_k=3, top_p=top_p))
assert torch.equal(actual > 0, expected > 0)
torch.testing.assert_close(actual, expected)
class _NoHostReads(TorchDispatchMode):
def __torch_dispatch__(self, func, types, args=(), kwargs=None):
if func in (torch.ops.aten._local_scalar_dense.default, torch.ops.aten.is_nonzero.default):
raise AssertionError(f"host read: {func}")
return func(*args, **(kwargs or {}))
@pytest.mark.parametrize(
"device",
["cpu", pytest.param("cuda", marks=pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required"))],
)
def test_candidate_sampling_reads_nothing_back_to_the_host(device):
logits = _tied_logits(device)
with _NoHostReads():
for top_k, top_p in [(2, 0.5), (4, 1.0), (0, 0.8)]:
candidates = Model._duplex_top_k_top_p_candidates(logits, top_k=top_k, top_p=top_p)
Model._duplex_draw_candidates(logits, candidates, torch.rand(logits.shape[0], device=device))
@@ -0,0 +1,122 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The cross-session batched streaming audio encoder against the per-session path."""
from types import SimpleNamespace
from typing import Any
import pytest
import torch
from torch import nn
from transformers.models.whisper.modeling_whisper import WhisperConfig
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni import MiniCPMO45OmniForConditionalGeneration
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import (
MiniCPMO45OmniLLMForConditionalGeneration,
MiniCPMWhisperEncoder,
MultiModalProjector,
)
from vllm_omni.model_executor.models.minicpmo_4_5.streaming_audio_encoder import StreamingAudioChunk
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
N_MELS, POOL = 16, 5
# Per round, each session's unit frames (None: sits the round out): 22 / 24 frames mirror 1020 / 1040 ms
# at 1/10 scale, odd lengths pad conv2, 25 frames leave the pooler a remainder, and 40 positions force resets.
_SCHEDULE: list[list[int | None]] = [
[22, 22, 23, 21],
[24, None, 24, 25],
[24, 24, None, 24],
[24, 23, 24, 24],
[24, 24, 24, None],
[25, 24, 23, 24],
]
def _thinker() -> Any:
"""The audio half of the thinker: real modules and methods, toy sizes."""
torch.manual_seed(0)
config = WhisperConfig(num_mel_bins=N_MELS, d_model=32, encoder_layers=2, encoder_attention_heads=2)
config.update(dict(encoder_ffn_dim=128, max_source_positions=40, dropout=0.0, attention_dropout=0.0))
config._attn_implementation = "sdpa"
thinker = MiniCPMO45OmniLLMForConditionalGeneration.__new__(MiniCPMO45OmniLLMForConditionalGeneration)
nn.Module.__init__(thinker)
thinker.apm = MiniCPMWhisperEncoder(config).eval()
thinker.audio_projection_layer = MultiModalProjector(in_dim=32, out_dim=24) # takes ffn // 4, as the checkpoint
thinker.audio_avg_pooler = nn.AvgPool1d(POOL, stride=POOL)
thinker.audio_encoder_layer, thinker.audio_past_key_values = -1, None
thinker.config = SimpleNamespace(audio_pool_step=POOL, duplex_audio_kv_page_positions=16)
return thinker
def test_batched_rounds_match_sequential_sessions() -> None:
thinker = _thinker()
legacy: list[Any] = [None] * 4
batched: list[Any] = [None] * 4
resets = 0
for round_index, frames_per_session in enumerate(_SCHEDULE):
chunks, active, expected = [], [], []
for session, frames in enumerate(frames_per_session):
if frames is None:
continue
mel = torch.randn((1, N_MELS, frames), generator=torch.Generator().manual_seed(100 * round_index + session))
extra = dict(prefix_extra_frames=0 if legacy[session] is None else 2, suffix_extra_frames=2)
features = {"audio_features": mel, "audio_feature_lens": [torch.tensor([frames])]}
thinker.audio_past_key_values = legacy[session]
with torch.no_grad():
nested = thinker.get_audio_embedding_streaming(features, use_extra_context=True, **extra)
legacy[session] = thinker.audio_past_key_values
expected.append(torch.cat([t for row in nested for t in row]))
chunks.append(StreamingAudioChunk(mel, batched[session], **extra))
active.append(session)
idle = {s: (cache, cache.length if cache else 0) for s, cache in enumerate(batched) if s not in active}
outputs, caches = thinker.get_audio_embedding_streaming_batch(chunks)
for session, output, cache, reference in zip(active, outputs, caches, expected, strict=True):
torch.testing.assert_close(output, reference, rtol=1e-5, atol=1e-5)
resets += batched[session] is not None and cache is not batched[session]
batched[session] = cache
want = legacy[session].self_attention_cache
assert cache.length == want.get_seq_length()
for got, ref in zip(cache.to_legacy_cache().self_attention_cache.layers, want.layers, strict=True):
torch.testing.assert_close((got.keys, got.values), (ref.keys, ref.values))
# Not in the batch: the same cache object, the same committed length.
assert all(batched[s] is c and (c.length if c else 0) == n for s, (c, n) in idle.items())
assert resets >= 2, "the schedule must cross the max_source_positions reset"
def test_graph_build_honors_eager_without_engine_config() -> None:
# The thinker keeps no vllm_config: the gate reads the flag __init__ stored.
thinker = _thinker()
thinker._enforce_eager = True
assert thinker.build_streaming_audio_graph_encoder(unit_frames=24) is False
assert thinker._duplex_audio_cuda_graph_encoder is None
@pytest.mark.cuda
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
def test_graph_build_captures_on_cuda() -> None:
thinker = _thinker().cuda()
thinker._enforce_eager = False
thinker.config.duplex_audio_encoder_cuda_graph_batch_sizes = [1, 2]
thinker.config.duplex_audio_encoder_cuda_graph_cache_buckets = [8]
assert thinker.build_streaming_audio_graph_encoder(unit_frames=24) is True
assert thinker._duplex_audio_cuda_graph_encoder is not None
@pytest.mark.parametrize(
("session_mode", "device", "captures"),
[("duplex", "cuda", 1), ("turn", "cuda", 0), ("duplex", "cpu", 0)],
)
def test_duplex_audio_graphs_capture_while_the_model_loads(monkeypatch, session_mode, device, captures) -> None:
# Captured in load_weights, the graphs count against the KV budget instead of past it.
model = MiniCPMO45OmniForConditionalGeneration.__new__(MiniCPMO45OmniForConditionalGeneration)
nn.Module.__init__(model)
model.vllm_config = SimpleNamespace(model_config=SimpleNamespace(session_mode=session_mode))
model.thinker = nn.Linear(1, 1)
calls: list[None] = []
model._minicpmo45_duplex_data_plane_helper = SimpleNamespace(build_audio_cuda_graph=lambda: calls.append(None))
monkeypatch.setattr(
MiniCPMO45OmniForConditionalGeneration, "_module_device", staticmethod(lambda module: torch.device(device))
)
model._capture_duplex_audio_graphs()
assert len(calls) == captures
@@ -0,0 +1,87 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Fused packed SigLIP layers match the eager packed encode; prefetched frames serve only their own append."""
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.model_executor.models.minicpmo_4_5 import vision_fused
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.stage0 import MiniCPMO45Stage0DuplexRuntime
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import (
SiglipVisionConfig,
SiglipVisionTransformer,
_vision_encode_paths,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_fused_layers_match_eager_packed_layers() -> None:
torch.manual_seed(0)
sizes = dict(hidden_size=32, intermediate_size=72, num_hidden_layers=3, num_attention_heads=4)
config = SiglipVisionConfig(**sizes, image_size=28, patch_size=2, attention_dropout=0.0)
config._attn_implementation = "sdpa"
vpm = SiglipVisionTransformer(config).eval()
for norm in [module for module in vpm.modules() if isinstance(module, torch.nn.LayerNorm)]:
torch.nn.init.normal_(norm.weight, mean=1.0, std=0.2)
torch.nn.init.normal_(norm.bias, std=0.2)
seq_groups, hidden = [(0, 3, 16), (48, 2, 15)], torch.randn((78, 32), generator=torch.Generator().manual_seed(1))
with torch.inference_mode():
expected = hidden.clone()
for layer in vpm.encoder.layers:
expected = layer.forward_packed(expected, seq_groups)
fused = vision_fused.encode_packed_fused(vpm, hidden.clone(), seq_groups)
torch.testing.assert_close(fused, vpm.post_layernorm(expected), rtol=1e-5, atol=1e-5)
def test_prefetched_frames_are_chunked_and_keyed_by_append() -> None:
runtime = MiniCPMO45Stage0DuplexRuntime.__new__(MiniCPMO45Stage0DuplexRuntime)
runtime.sessions, runtime._prefetched_vision = {}, {}
runtime.processor = SimpleNamespace(process_image=lambda *args, **kwargs: None)
runtime.stage_model = runtime.thinker = SimpleNamespace(config=SimpleNamespace(vision_batch_size=2))
runtime._decode_video_frames_payload = lambda payload: list(payload["video_frames"])
runtime._preprocess_frames = lambda frames: [f"p{frame}" for frame in frames]
# Each encoded frame is tagged with its tower call's size: vision_batch_size appends per call.
runtime._encode_processed_vision_batch = lambda items: [[f"e{item}/{len(items)}"] for item in items]
frames = ["a", "b", "c"]
appends = [
{"session_id": f"s{i}", "epoch": 0, "seq": 1, "payload": {"video_frames": frames[: i + 1]}} for i in range(3)
]
runtime.prefetch_vision(appends)
assert runtime.take_prefetched_vision({**appends[1], "payload": {"video_frames": ["x", "y"]}}) is None # changed
assert runtime.take_prefetched_vision(appends[1]) is None # and the entry is gone
assert runtime.take_prefetched_vision({**appends[2], "seq": "2"}) is None # another append
last, payload = appends[2], appends[2]["payload"]
assert runtime.frame_kwargs(last, payload) == {"encoded_frames": [["epa/3"], ["epb/3"], ["epc/3"]]}
assert runtime.frame_kwargs(last, payload) == {"video_frames": frames} # taken once
def test_packed_vision_adapter_protocol() -> None:
shape, out_shape = (1, 3, 16), torch.Size([1, 4, 8])
adapter = vision_fused._PackedVisionAdapter(lambda px, runs: px[:, :4, :8], [(2, 2, 1)], shape, out_shape)
assert adapter.supports_encoder_cudagraph is True
cfg = adapter.get_encoder_cudagraph_config()
assert cfg.modalities == ["image"]
assert cfg.buffer_keys == ["pixels"]
assert cfg.out_hidden_size == 8
inputs = adapter.prepare_encoder_cudagraph_capture_inputs(4, 1, 0, torch.device("cpu"), torch.float32)
assert "pixels" in inputs.values
dest: list[torch.Tensor | None] = [None]
adapter.postprocess_encoder_output({"default": torch.ones(1, 4, 8)}, [0], [4], dest, clone=True)
assert dest[0] is not None and dest[0].shape == (1, 4, 8)
@pytest.mark.parametrize(
("overrides", "encoder_graphs", "expected"),
[
({}, True, (False, False)), # default: the padded batch keeps the vpm encoder graph
({"vision_cuda_graph": True}, True, (True, True)),
({"vision_cuda_graph": True}, False, (False, False)), # encoder_cuda_graph=False / --enforce-eager
({"vision_packed_encode": True}, True, (True, False)), # packed eager, for A/B
({"vision_packed_encode": False, "vision_cuda_graph": True}, True, (False, False)),
],
)
def test_vision_encode_paths(overrides, encoder_graphs, expected) -> None:
assert _vision_encode_paths(SimpleNamespace(**overrides), encoder_graphs=encoder_graphs) == expected
+6 -2
View File
@@ -415,7 +415,8 @@ def test_minicpmo_non_duplex_sample_skips_duplex_row_scan():
)
assert runner._sample(torch.zeros((1, 4)), spec_decode_metadata=None) == "model-sampler"
assert calls == []
# The empty row set is still published: the model then skips its per-row prompt scan.
assert calls == ["prepare"]
def test_minicpmo_duplex_sample_clears_stale_rows_once_without_scanning():
@@ -442,7 +443,7 @@ def test_minicpmo_duplex_sample_clears_stale_rows_once_without_scanning():
assert runner._sample(torch.zeros((1, 4)), spec_decode_metadata=None) == "model-sampler"
assert runner._sample(torch.zeros((1, 4)), spec_decode_metadata=None) == "model-sampler"
assert calls == [()]
assert calls == [(), ()]
def test_generic_ar_runner_has_no_minicpmo_sampler_state_or_typeerror_probe():
@@ -2890,6 +2891,7 @@ def test_minicpmo_stage0_native_sampler_forced_listen_yields_floor():
)
sampled = model.sample(logits, sampling_metadata)
model._commit_minicpmo45_duplex_pending_samples() # the runner commits before the next step
assert sampled is not None
assert sampled.sampled_token_ids.tolist() == [[151705]]
@@ -3155,6 +3157,7 @@ def test_minicpmo_stage0_turn_eos_is_forwarded_not_pending():
logits[0, 151717] = 30.0
sampled = model.sample(logits, _native_duplex_sampling_metadata([151706, 200, 201]))
model._commit_minicpmo45_duplex_pending_samples() # the runner commits before the next step
assert sampled.sampled_token_ids.tolist() == [[151717]]
assert state.pending_terminator_token is None
@@ -3166,6 +3169,7 @@ def test_minicpmo_stage0_turn_eos_is_forwarded_not_pending():
logits = torch.full((1, 151723), -100.0)
logits[0, 151718] = 30.0
sampled = model.sample(logits, _native_duplex_sampling_metadata([151706, 200, 201, 151717]))
model._commit_minicpmo45_duplex_pending_samples()
assert sampled.sampled_token_ids.tolist() == [[151718]]
assert state.pending_terminator_token == 151718
+26 -1
View File
@@ -1,6 +1,6 @@
# Opt-in single-H200 native full-duplex codec profile (V1).
# Inherits the native session/stage capacities, sampling and chunk sizes.
# Uses the mainline Whole-Euler Flow and shared prompt-state implementation.
# Enables all Stage 0 (multimodal duplex prefill) and Stage 2 (code2wav DiT) optimizations.
# Ordinary TF32 for CFM DiT GEMMs (cuBLAS allow_tf32 + Triton tf32 dots).
# HiFT stays IEEE FP32. Validate audio quality for your workload.
base_config: minicpmo_4_5.yaml
@@ -9,3 +9,28 @@ connectors:
connector_of_shared_memory:
extra:
token2wav_allow_tf32: true
# Stage 2: Fused DiT transformer body (LayerNorm + QKV + Residual)
cfm_fused_body: true
# Stage 2: Flow condition encoder CUDA graph
cfm_encoder_cuda_graph: true
# Stage 2: Pre-allocated persistent slot pool and row offset merging
cfm_slot_pool: true
cfm_row_offset_merge: true
# Stage 2: Compress DiT attention cache to bf16
code2wav_bfloat16_attention_cache: true
stages:
- stage_id: 0
enforce_eager: false
hf_overrides:
# Stage 0: Incremental sliding window Fbank feature extraction
duplex_incremental_fbank: true
# Stage 0: Fused packed SigLIP encoder layers
vision_fused_layers: true
# Stage 0: Packed SigLIP tower & resampler CUDA graph
vision_cuda_graph: true
# Stage 0: Steady Whisper streaming audio encoder CUDA graph
duplex_audio_encoder_cuda_graph: true
duplex_audio_encoder_cuda_graph_batch_sizes_from_sessions: true
# Stage 0: Pinned memory async H2D copy for streaming audio chunks
duplex_audio_encoder_pinned_h2d: true
+29 -6
View File
@@ -22,6 +22,20 @@ class DuplexSamplingRow:
seq: int | None
payload: dict[str, object] | None
max_tokens: int | None
# Host copies of the row's sampling parameters (None when the runner has none).
temperature: float | None = None
top_k: int | None = None
top_p: float | None = None
def _host_value(values: Any, row_idx: int, cast: type) -> Any:
"""``values[row_idx]`` of a host array as ``cast``, or None when there is none."""
if values is None:
return None
try:
return cast(values[row_idx])
except (IndexError, TypeError, ValueError):
return None
class DuplexSamplingHelper:
@@ -53,15 +67,19 @@ class DuplexSamplingHelper:
else:
self.active_request_ids.discard(req_id)
def update_states(self, runner: object, scheduler_output: object) -> None:
def update_states(self, runner: Any, scheduler_output: Any) -> None:
self.active_request_ids.difference_update(str(req_id) for req_id in scheduler_output.finished_req_ids)
for request in scheduler_output.scheduled_new_reqs:
self.refresh_active_request(runner, str(request.req_id))
def rows(self, runner: object) -> tuple[DuplexSamplingRow, ...]:
def rows(self, runner: Any) -> tuple[DuplexSamplingRow, ...]:
rows: list[DuplexSamplingRow] = []
req_ids = [str(req_id) for req_id in getattr(runner.input_batch, "req_ids", [])]
input_batch = runner.input_batch
req_ids = [str(req_id) for req_id in getattr(input_batch, "req_ids", [])]
requests = getattr(runner, "requests", {})
temperature_cpu = getattr(input_batch, "temperature_cpu", None)
top_k_cpu = getattr(input_batch, "top_k_cpu", None)
top_p_cpu = getattr(input_batch, "top_p_cpu", None)
for row_idx, req_id in enumerate(req_ids):
if req_id not in self.active_request_ids:
continue
@@ -73,7 +91,8 @@ class DuplexSamplingHelper:
if not isinstance(session_id, str) or not session_id:
session_id = None
try:
seq = int(duplex.get("seq"))
seq_raw = duplex.get("seq")
seq = int(seq_raw) if seq_raw is not None else None
except (TypeError, ValueError):
seq = None
payload = duplex.get("payload")
@@ -93,6 +112,9 @@ class DuplexSamplingHelper:
seq=seq,
payload=payload,
max_tokens=max_tokens if max_tokens > 0 else None,
temperature=_host_value(temperature_cpu, row_idx, float),
top_k=_host_value(top_k_cpu, row_idx, int),
top_p=_host_value(top_p_cpu, row_idx, float),
)
)
return tuple(rows)
@@ -142,8 +164,9 @@ class DuplexSamplingRunnerMixin:
return
helper = getattr(self, "_duplex_sampling_helper", None)
rows = helper.rows(self) if helper is not None and helper.active_request_ids else ()
if rows or (helper is not None and helper.hook_active):
prepare_duplex_sampling(logits, prepared_sampling_metadata, rows)
# Publish the row set even when empty, so the model skips its per-row
# prompt-token scan (a host sync per row) in turn-based serving.
prepare_duplex_sampling(logits, prepared_sampling_metadata, rows)
if helper is not None:
helper.hook_active = bool(rows)
@@ -0,0 +1,220 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Incremental Stage-0 streaming fbank (HF override ``duplex_incremental_fbank``, default off).
The remote ``StreamingMelProcessorExact._extract_full`` recomputes the CPU Whisper log-mel over the whole
streaming buffer (up to its 30 s slide) on every unit. This keeps the ``|STFT|^2`` rows already computed and
recomputes only the right edge (frames whose window reaches new samples or the reflect pad) and, after a slide,
the new left edge. The mel projection, log, dynamic range and scaling still run over the whole buffer with the
original's shapes and memory layout, so the features stay bitwise equal: the mel GEMM is not row independent
(MKL's remainder kernel), and an STFT batch's last frames may take a remainder path, so recomputes start on
``_ALIGN_FRAMES`` boundaries and their last ``_ALIGN_FRAMES`` frames are not kept.
"""
from __future__ import annotations
import copy
from typing import Any
import numpy as np
import torch
from vllm.logger import init_logger
logger = init_logger(__name__)
# Recompute start granularity, trailing recompute frames not kept, and the
# left-edge recompute after a slide (>= the 2 frames reading the pad).
_ALIGN_FRAMES = 16
_FEATURE_EXTRACTOR_ATTRS = ("n_fft", "hop_length", "n_samples", "sampling_rate", "mel_filters", "set_spac_log_norm")
_FEATURE_EXTRACTOR_ATTRS += ("dynamic_log_norm", "dynamic_range_db", "log_floor_db")
_INCREMENTAL_CLASSES: dict[type, type] = {}
_SELF_CHECKED: dict[tuple[type, type], bool] = {}
def _stft_power(buffer: np.ndarray, start: int, stop: int, n_fft: int, hop: int, window: torch.Tensor) -> np.ndarray:
"""``(stop - start, n_fft // 2 + 1)`` rows of ``|STFT|^2``, framed as the original's centered STFT.
The batch is ``[start, stop]`` minus its last frame, like the original's
``stft(...)[..., :-1].abs() ** 2``; edge reflect padding is built explicitly.
"""
half = n_fft // 2
lo, hi = start * hop - half, stop * hop + half
length = len(buffer)
segment = buffer[max(lo, 0) : min(hi, length)]
if lo < 0 or hi > length:
segment = np.pad(segment, (max(0, -lo), max(0, hi - length)), mode="reflect")
spec = torch.stft(
torch.from_numpy(segment)[None], n_fft=n_fft, hop_length=hop, window=window, center=False, return_complex=True
)
magnitudes = spec[..., :-1].abs() ** 2 # [1, n_freq, stop - start], frame-major in memory
return magnitudes[0].T.numpy()
def _log_mel(power: torch.Tensor, mel_filters: torch.Tensor, feature_extractor: Any) -> torch.Tensor:
"""``_torch_extract_fbank_features`` from the ``|STFT|^2`` rows, op for op and layout for layout."""
magnitudes = power.unsqueeze(0).transpose(1, 2) # [1, n_freq, T], strides of stft(...)[..., :-1]
log_spec = torch.clamp(mel_filters.T @ magnitudes, min=1e-10).log10()
if feature_extractor.dynamic_log_norm:
max_val_bt = log_spec.max(dim=2, keepdim=True)[0].max(dim=1, keepdim=True)[0]
log_spec = torch.maximum(log_spec, max_val_bt - feature_extractor.dynamic_range_db)
else:
log_spec = torch.maximum(log_spec, torch.tensor(feature_extractor.log_floor_db, dtype=log_spec.dtype))
return (log_spec + 4.0) / 4.0
class _IncrementalFbankMixin:
"""Overrides of the remote ``StreamingMelProcessorExact``, mixed in by ``enable_incremental_fbank``."""
#: ``|STFT|^2`` rows (frame t at row t); rows ``[0, _ifb_valid)`` are exact for the current buffer.
_ifb_power: np.ndarray | None = None
_ifb_valid = 0
#: ``left_samples_dropped`` and the buffer length when the rows were kept.
_ifb_dropped = 0
_ifb_length = 0
#: The ``n_fft`` samples ending where the last kept frame's window ends.
_ifb_guard: np.ndarray | None = None
_ifb_window: torch.Tensor | None = None
_ifb_mel_filters: torch.Tensor | None = None
def reset(self) -> None:
super().reset()
self._ifb_valid = 0
def restore_snapshot(self, snapshot: dict) -> None:
super().restore_snapshot(snapshot)
self._ifb_valid = 0
def _extract_full(self) -> torch.Tensor:
fe, buffer = self.feature_extractor, self.buffer
if not (
isinstance(buffer, np.ndarray)
and buffer.ndim == 1
and buffer.dtype == np.float32
and fe.n_fft <= len(buffer) <= fe.n_samples
and getattr(fe, "dither", 0.0) == 0.0
and fe.sampling_rate == self.sample_rate
):
# Too short (the original raises), truncated, dithered or misconfigured: the original.
self._ifb_valid = 0
return super()._extract_full()
# The original's log-floor switch, side effect on the feature extractor included.
if len(buffer) < 5 * self.sample_rate:
fe.set_spac_log_norm(log_floor_db=-10)
else:
fe.set_spac_log_norm(dynamic_range_db=8)
return _log_mel(self._ifb_update_power(buffer, fe), self._ifb_mel_filters, fe)
def _ifb_update_power(self, buffer: np.ndarray, fe: Any) -> torch.Tensor:
n_fft, hop = int(fe.n_fft), int(fe.hop_length)
if self._ifb_window is None:
self._ifb_window = torch.hann_window(n_fft)
self._ifb_mel_filters = torch.from_numpy(fe.mel_filters).to(torch.float32)
power = self._ifb_power
if power is None or power.ctypes.data % 64:
# Rows from a torch allocation: 64-byte aligned like the original's fresh
# |X|^2 tensor, which the GEMM reads (a deepcopy's numpy array may not be).
aligned = torch.empty(int(fe.n_samples) // hop + 1, n_fft // 2 + 1, dtype=torch.float32).numpy()
if power is not None:
aligned[: self._ifb_valid] = power[: self._ifb_valid]
self._ifb_power = power = aligned
frames = len(buffer) // hop # the original drops the STFT's last frame
start = self._ifb_rebase(buffer, n_fft, hop) // _ALIGN_FRAMES * _ALIGN_FRAMES
power[start:frames] = _stft_power(buffer, start, frames, n_fft, hop, self._ifb_window)
# Keep frames whose window lies inside the buffer, minus the recompute's tail.
valid = min((len(buffer) - n_fft // 2) // hop + 1, frames - _ALIGN_FRAMES)
guard_end = (valid - 1) * hop + n_fft // 2
if guard_end < n_fft:
valid = 0
else:
self._ifb_guard = buffer[guard_end - n_fft : guard_end].copy()
self._ifb_valid = valid
self._ifb_dropped = int(self.left_samples_dropped)
self._ifb_length = len(buffer)
return torch.from_numpy(power[:frames])
def _ifb_rebase(self, buffer: np.ndarray, n_fft: int, hop: int) -> int:
"""Rows still exact for ``buffer``: the kept rows (moved down after a slide), or 0."""
valid = self._ifb_valid
if valid == 0:
return 0
shift = int(self.left_samples_dropped) - self._ifb_dropped
guard_end = (valid - 1) * hop + n_fft // 2 - shift
if (
shift < 0
or shift % hop
or len(buffer) + shift < self._ifb_length
or guard_end < n_fft
or not np.array_equal(buffer[guard_end - n_fft : guard_end], self._ifb_guard)
):
return 0 # not the same stream (or not a hop-aligned slide of it): start over
if shift == 0:
return valid
drop = shift // hop
valid -= drop
if valid <= _ALIGN_FRAMES:
return 0
power = self._ifb_power
power[:valid] = power[drop : drop + valid]
# The new left edge's frames read the reflect pad.
power[:_ALIGN_FRAMES] = _stft_power(buffer, 0, _ALIGN_FRAMES, n_fft, hop, self._ifb_window)
return valid
def _self_check(mel_processor: Any, incremental_cls: type) -> bool: # Any: remote-code processor
"""Compare the incremental and the original features on a probe stream (with a slide) once per class."""
fe = mel_processor.feature_extractor
saved = {name: getattr(fe, name) for name in ("dynamic_log_norm", "dynamic_range_db", "log_floor_db")}
probe = copy.copy(mel_processor)
probe.__class__ = incremental_cls
hop = int(fe.hop_length)
audio = (0.1 * np.random.default_rng(0).standard_normal(8 * int(mel_processor.sample_rate))).astype(np.float32)
try:
# Below and above the 5 s dynamic-range switch, then a slide of 100 hops.
for dropped, end in ((0, len(audio) // 3 + 37), (0, len(audio) - 11), (100 * hop, len(audio))):
probe.buffer, probe.left_samples_dropped = audio[dropped:end], dropped
expected = type(mel_processor)._extract_full(probe)
if not torch.equal(incremental_cls._extract_full(probe), expected):
return False
return True
except Exception:
logger.warning("MiniCPM-o incremental fbank self-check raised", exc_info=True)
return False
finally:
for name, value in saved.items():
setattr(fe, name, value)
def enable_incremental_fbank(mel_processor: Any) -> bool: # Any: remote-code StreamingMelProcessorExact
"""Switch one session's streaming mel processor to the incremental ``_extract_full`` (idempotent).
The instance's class becomes a subclass of its remote class with the mixin
in front, so copies keep it. Returns False, leaving the full recompute, for
a processor of another shape or one that differs on the probe stream.
"""
if isinstance(mel_processor, _IncrementalFbankMixin):
return True
cls = type(mel_processor)
fe = getattr(mel_processor, "feature_extractor", None)
if not callable(getattr(cls, "_extract_full", None)) or not all(
hasattr(fe, name) for name in _FEATURE_EXTRACTOR_ATTRS
):
return False
if not all(hasattr(mel_processor, name) for name in ("buffer", "sample_rate", "left_samples_dropped")):
return False
incremental_cls = _INCREMENTAL_CLASSES.get(cls)
if incremental_cls is None:
incremental_cls = type(f"Incremental{cls.__name__}", (_IncrementalFbankMixin, cls), {"__module__": __name__})
_INCREMENTAL_CLASSES[cls] = incremental_cls
key = (cls, type(fe))
if key not in _SELF_CHECKED:
_SELF_CHECKED[key] = _self_check(mel_processor, incremental_cls)
if not _SELF_CHECKED[key]:
logger.warning(
"MiniCPM-o incremental fbank differs from %s._extract_full on the probe stream; "
"the streaming fbank stays on the full recompute",
cls.__name__,
)
if not _SELF_CHECKED[key]:
return False
mel_processor.__class__ = incremental_cls
return True
@@ -5,15 +5,19 @@ from __future__ import annotations
import base64
import copy
import os
import time
from collections.abc import Callable
from collections.abc import Callable, Generator
from concurrent.futures import ThreadPoolExecutor
from contextlib import suppress
from dataclasses import dataclass, field
from threading import Lock
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
import numpy as np
from numpy.typing import NDArray
from vllm.logger import init_logger
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.policy import MiniCPMO45DuplexPolicy
@@ -22,9 +26,32 @@ if TYPE_CHECKING:
from PIL import Image
from torch import nn
logger = init_logger(__name__)
_MINICPMO45_SPECIAL_TOKEN_FIELDS = MiniCPMO45DuplexPolicy.SPECIAL_TOKEN_FIELDS
_MINICPMO45_OPTIONAL_TOKEN_FIELDS = MiniCPMO45DuplexPolicy.OPTIONAL_TOKEN_FIELDS
_MINICPMO45_PROCESSOR_LOAD_LOCK = Lock()
_MINICPMO45_FRAME_POOL_LOCK = Lock()
_MINICPMO45_FRAME_POOL: ThreadPoolExecutor | None = None
def _optional_int(value: Any) -> int | None:
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
def _frame_preprocess_pool() -> ThreadPoolExecutor | None:
"""Shared pool for camera-frame JPEG decode and slicing (both release the GIL); ``None`` on one CPU."""
global _MINICPMO45_FRAME_POOL
workers = min(4, os.cpu_count() or 1)
if workers <= 1:
return None
with _MINICPMO45_FRAME_POOL_LOCK:
if _MINICPMO45_FRAME_POOL is None:
_MINICPMO45_FRAME_POOL = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="minicpmo45-frames")
return _MINICPMO45_FRAME_POOL
@dataclass
@@ -55,8 +82,13 @@ class _MiniCPMO45Stage0SessionState:
prepared_inputs_embeds: torch.Tensor | None = None
prepared_input_token_ids: list[int] = field(default_factory=list)
prepared_result: dict[str, object] = field(default_factory=dict)
#: Opaque transformers cache, handed back to the model unchanged.
#: Streaming-encoder state: a ``StreamingAudioKVCache`` on the batched
#: path, else the opaque transformers cache handed back to the model.
audio_past_key_values: object | None = None
#: Append identity of the last prefill that did work (successful or not).
attempted_append_identity: tuple[int | None, int] | None = None
#: This step's batched prefill result, taken by the append's own preprocess.
staged_prefill: tuple[tuple[int | None, int], dict[str, object]] | None = None
pending_terminator_token: int | None = None
last_terminator_token: int | None = None
pending_speech_context: bool = False
@@ -65,6 +97,18 @@ class _MiniCPMO45Stage0SessionState:
generated_tokens: list[int] = field(default_factory=list)
@dataclass(frozen=True, slots=True)
class _MiniCPMO45AudioEncodeRequest:
"""One streaming unit a prefill needs encoded; ``_stage_prefill_steps`` yields it."""
#: Any: transformers BatchFeature (or the dict fallback of _process_streaming_audio).
batch_feature: Any
state: _MiniCPMO45Stage0SessionState | None
_PrefillSteps = Generator[_MiniCPMO45AudioEncodeRequest, "torch.Tensor | None", dict[str, object]]
class MiniCPMO45Stage0DuplexRuntime:
"""Build scheduler-owned MiniCPM-o 4.5 Stage0 duplex inputs."""
@@ -87,6 +131,8 @@ class MiniCPMO45Stage0DuplexRuntime:
self.model_path = model_path
self.device = device
self.sessions: dict[tuple[str, int], _MiniCPMO45Stage0SessionState] = {}
# (session_id, epoch, seq) -> (frame payload, per-frame embeddings) of this runner step.
self._prefetched_vision: dict[tuple[str, int | None, int], tuple[tuple[object, ...], list[Any]]] = {}
self.thinker = getattr(stage_model, "thinker", None) or getattr(stage_model, "model", None) or stage_model
self.processor = (
getattr(stage_model, "processor", None)
@@ -101,6 +147,49 @@ class MiniCPMO45Stage0DuplexRuntime:
self._init_token_ids()
if self.tokenizer is not None:
self._require_special_token_ids()
# ``duplex_incremental_fbank`` (HF override, default off): see incremental_fbank.py.
self._incremental_fbank = (
getattr(getattr(stage_model, "config", None), "duplex_incremental_fbank", False) is True
)
# Built with the model, before its weights load and it switches to eval mode:
# the streaming encoder's CUDA graphs are captured later, by build_audio_cuda_graph().
self._audio_graph_attempted = False
def build_audio_cuda_graph(self) -> bool:
"""Capture the streaming-encoder CUDA graphs once weights are loaded (idempotent; failures stay eager).
The steady unit's mel-frame count comes from a probe processor configured like a session's: the
shared processor's default chunk is no model unit, and a first unit has its own size.
"""
thinker = getattr(self, "thinker", None)
config = getattr(thinker, "config", None)
attempted = getattr(self, "_audio_graph_attempted", False)
self._audio_graph_attempted = True
if attempted or not bool(getattr(config, "duplex_audio_encoder_cuda_graph", True)):
return False
apm = getattr(self.thinker, "apm", None)
if getattr(apm, "training", False) is True and callable(getattr(apm, "eval", None)):
apm.eval() # inference only; the loader's model.eval() covers it as well
build = getattr(self.thinker, "build_streaming_audio_graph_encoder", None)
if not callable(build) or self.processor is None:
return False
try:
probe = self._configure_streaming_processor(SimpleNamespace(streaming_processor=None))
if probe is None:
return False
for _ in range(2): # the first unit, then the steady one
silence = np.zeros(self._streaming_chunk_size(probe), dtype=np.float32)
feature = probe.process_audio_streaming(silence, reset=False)
# A BatchFeature-like mapping or a (features, metadata) tuple, by processor version.
frames = int((feature["audio_features"] if isinstance(feature, dict) else feature[0]).shape[-1])
except Exception:
logger.info(
"MiniCPM-o could not derive the steady streaming-audio unit shape; "
"the streaming audio encoder stays eager",
exc_info=True,
)
return False
return bool(build(unit_frames=frames))
def _stage_runtime_ready(self) -> bool:
return self.processor is not None and self.tokenizer is not None and self.thinker is not None
@@ -138,7 +227,7 @@ class MiniCPMO45Stage0DuplexRuntime:
reset_streaming = getattr(processor, "reset_streaming", None)
if callable(reset_streaming):
reset_streaming()
return processor
return self._maybe_enable_incremental_fbank(processor)
configure_streaming = getattr(processor, "configure_streaming", None)
if callable(configure_streaming):
configure_streaming(
@@ -147,6 +236,19 @@ class MiniCPMO45Stage0DuplexRuntime:
slide_trigger_seconds=30.0,
slide_stride_seconds=10.0,
)
return self._maybe_enable_incremental_fbank(processor)
def _maybe_enable_incremental_fbank(self, processor: Any) -> Any: # Any: remote-code processor
"""Put the processor's streaming mel front end on the incremental path (``duplex_incremental_fbank``)."""
mel_processor = getattr(processor, "_streaming_mel_processor", None)
if getattr(self, "_incremental_fbank", False) and mel_processor is not None:
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.incremental_fbank import enable_incremental_fbank
if not enable_incremental_fbank(mel_processor):
logger.warning_once(
"MiniCPM-o duplex_incremental_fbank: %s is not supported; the streaming fbank recomputes in full",
type(mel_processor).__name__,
)
return processor
def _prepare_session_context(
@@ -208,24 +310,90 @@ class MiniCPMO45Stage0DuplexRuntime:
state.current_turn_ended = False
state.pending_speech_response_open = True
def _stage_prefill_embeddings_only(
def _stage_prefill_embeddings_only(self, state, audio_waveform, **kwargs: Any) -> dict[str, object]:
"""Run :meth:`_stage_prefill_steps` for one append, encoding its units one at a time."""
return self._run_prefill_steps([self._stage_prefill_steps(state, audio_waveform, **kwargs)])[0]
@staticmethod
def take_staged_prefill(
state: _MiniCPMO45Stage0SessionState, epoch: int | None, seq: int | None
) -> dict[str, object] | None:
"""Pop the append :meth:`stage_prefill_batch` built for ``(epoch, seq)`` this step, if any."""
staged = state.staged_prefill
if staged is None:
return None
state.staged_prefill = None
if seq is None or staged[0] != (epoch, seq):
return None
return staged[1]
def batches_audio_encoder(self) -> bool:
"""Whether the thinker can encode several sessions' audio units in one call."""
return self._batched_audio_encoder() is not None
def needs_prefill(self, state: _MiniCPMO45Stage0SessionState | None, epoch: int | None, seq: int | None) -> bool:
"""Whether an append has not been built (or attempted) for this session yet."""
if seq is None or state is None:
return seq is not None
built = state.prepared_append_identity == (epoch, seq) and state.prepared_inputs_embeds is not None
return not built and state.attempted_append_identity != (epoch, seq)
def stage_prefill_batch(
self,
appends: list[tuple[_MiniCPMO45Stage0SessionState, NDArray[np.float32] | None, dict[str, Any]]],
) -> None:
"""Build distinct sessions' appends (each with a ``seq``), batching their ``k``-th unit encodes.
Results are staged on the session for the append's own ``preprocess`` this step.
"""
if len({id(state) for state, _, _ in appends}) != len(appends):
raise ValueError("stage_prefill_batch needs one append per session")
steps = [self._stage_prefill_steps(state, audio, **kwargs) for state, audio, kwargs in appends]
for (state, _, kwargs), result in zip(appends, self._run_prefill_steps(steps), strict=True):
state.staged_prefill = ((kwargs.get("epoch"), kwargs["seq"]), result)
def _run_prefill_steps(self, steps: list[_PrefillSteps]) -> list[dict[str, object]]:
"""Drive prefills to completion, encoding each round's pending units together."""
results: list[dict[str, object] | None] = [None] * len(steps)
pending: list[tuple[int, _MiniCPMO45AudioEncodeRequest]] = []
def advance(index: int, value: torch.Tensor | None, *, first: bool = False) -> None:
try:
request = next(steps[index]) if first else steps[index].send(value)
except StopIteration as stop:
results[index] = stop.value
return
pending.append((index, request))
for index in range(len(steps)):
advance(index, None, first=True)
while pending:
round_, pending = pending, []
embeds = self._stage_audio_embeddings_batch([request for _, request in round_])
for (index, _), embed in zip(round_, embeds, strict=True):
advance(index, embed)
return cast("list[dict[str, object]]", results)
def _stage_prefill_steps(
self,
state: _MiniCPMO45Stage0SessionState,
audio_waveform: NDArray[np.float32] | None,
*,
video_frames: list[Image.Image] | None = None,
encoded_frames: list[list[torch.Tensor]] | None = None,
epoch: int | None = None,
seq: int | None = None,
is_speech: bool = False,
final: bool = False,
stage0_window: dict[str, object] | None = None,
stage0_reanchor: dict[str, object] | None = None,
) -> dict[str, object]:
) -> _PrefillSteps:
"""Build scheduler-owned Stage0 input embeddings for one audio append.
Unlike the legacy worker-control path, this method never calls an eager
model forward. The normal vLLM runner consumes the returned embeddings
and owns attention metadata, block tables, KV cache, and sampling.
Yields each unit's encoder request; the caller sends back its embeddings.
"""
start_time = time.time()
processor = self._configure_streaming_processor(state)
@@ -239,6 +407,7 @@ class MiniCPMO45Stage0DuplexRuntime:
result["inputs_embeds"] = state.prepared_inputs_embeds
result["input_token_ids"] = list(state.prepared_input_token_ids)
return result
state.attempted_append_identity = append_identity
self._require_special_token_ids()
if isinstance(stage0_window, dict):
completed_ids = stage0_window.get("completed_token_ids")
@@ -252,11 +421,12 @@ class MiniCPMO45Stage0DuplexRuntime:
state.pending_terminator_token = completed_terminator
if audio_waveform is None or len(audio_waveform) == 0:
return self._stage_prefill_result(False, start_time, "empty audio")
num_frames = len(encoded_frames) if encoded_frames is not None else len(video_frames or ())
state.audio_buffer = np.concatenate([state.audio_buffer, np.asarray(audio_waveform, dtype=np.float32)])
chunk_size = self._streaming_chunk_size(processor)
self._pad_first_audio_chunk_if_needed(state, processor)
if len(state.audio_buffer) < chunk_size:
if video_frames:
if num_frames:
# Frames belong to the unit this append closes, and the
# scheduler already reserved their vision tokens against this
# append. A frame arriving before its unit can close has
@@ -265,7 +435,7 @@ class MiniCPMO45Stage0DuplexRuntime:
return self._stage_prefill_result(
False,
start_time,
f"{len(video_frames)} video frame(s) on an append that closes no model unit "
f"{num_frames} video frame(s) on an append that closes no model unit "
f"(need {chunk_size} samples, only {len(state.audio_buffer)}); "
"send frames on unit boundaries",
)
@@ -278,10 +448,11 @@ class MiniCPMO45Stage0DuplexRuntime:
# carry them, mirroring official streaming_prefill (feed <unit>, then
# every frame_list entry as its own <image> block, then the audio).
frame_blocks: list[list[torch.Tensor]] = []
if video_frames:
if num_frames:
self._require_vision_token_ids()
encoded_frames = self._stage_vision_embeddings(video_frames)
if encoded_frames is None or len(encoded_frames) != len(video_frames):
if encoded_frames is None:
encoded_frames = self._stage_vision_embeddings(video_frames or [])
if encoded_frames is None or len(encoded_frames) != num_frames:
return self._stage_prefill_result(False, start_time, "streaming vision embedding failed")
if any(not slices for slices in encoded_frames):
return self._stage_prefill_result(False, start_time, "streaming vision embedding failed")
@@ -312,7 +483,7 @@ class MiniCPMO45Stage0DuplexRuntime:
):
with suppress(Exception):
setattr(batch_feature, name, value)
audio_embeds = self._stage_audio_embeddings(batch_feature, state=state)
audio_embeds = yield _MiniCPMO45AudioEncodeRequest(batch_feature, state)
if audio_embeds is None:
if units_built == 0:
return self._stage_prefill_result(False, start_time, "streaming audio embedding returned empty")
@@ -623,7 +794,8 @@ class MiniCPMO45Stage0DuplexRuntime:
def _embed_token(self, token_id: int) -> torch.Tensor:
import torch
token = torch.tensor([int(token_id)], dtype=torch.long, device=self._model_device())
# A device-side fill: ``torch.tensor(..., device=...)`` is a blocking copy, 2-3 per unit.
token = torch.full((1,), int(token_id), dtype=torch.long, device=self._model_device())
embedder = self._token_embedder()
embeds = embedder(token)
return self._as_2d_tensor(embeds)
@@ -759,7 +931,11 @@ class MiniCPMO45Stage0DuplexRuntime:
state: _MiniCPMO45Stage0SessionState | None = None,
) -> torch.Tensor | None:
if hasattr(batch_feature, "to"):
batch_feature = batch_feature.to(self.device)
batch_feature = self._audio_feature_to_device(batch_feature)
if state is not None and hasattr(state.audio_past_key_values, "to_legacy_cache"):
# A batched-path session whose unit this path has to take: hand the
# history over as the transformers cache the per-session path grows.
state.audio_past_key_values = state.audio_past_key_values.to_legacy_cache()
self._ensure_dynamic_cache_compat()
has_audio_cache = state is not None and hasattr(self.thinker, "audio_past_key_values")
previous_audio_past_key_values = (
@@ -799,6 +975,94 @@ class MiniCPMO45Stage0DuplexRuntime:
if has_audio_cache:
self.thinker.audio_past_key_values = previous_audio_past_key_values
def _stage_audio_embeddings_batch(
self,
requests: list[_MiniCPMO45AudioEncodeRequest],
) -> list[torch.Tensor | None]:
"""Encode one unit per request (distinct sessions); those the batched encoder takes share one pass."""
results: list[torch.Tensor | None] = [None] * len(requests)
encoder = self._batched_audio_encoder()
batched: list[tuple[int, _MiniCPMO45AudioEncodeRequest, Any]] = []
for index, request in enumerate(requests):
chunk = self._batched_audio_chunk(request) if encoder is not None else None
if chunk is None:
results[index] = self._stage_audio_embeddings(request.batch_feature, state=request.state)
else:
batched.append((index, request, chunk))
if batched:
assert encoder is not None
embeds, caches = encoder([chunk for _, _, chunk in batched])
for (index, request, _), embed, cache in zip(batched, embeds, caches, strict=True):
assert request.state is not None
request.state.audio_past_key_values = cache
results[index] = embed
return results
def _batched_audio_encoder(self) -> Callable[[list[Any]], tuple[list[Any], list[Any]]] | None:
encoder = getattr(self, "_batched_audio_encoder_fn", False)
if encoder is False:
if not getattr(self, "_audio_graph_attempted", False):
self.build_audio_cuda_graph()
thinker = getattr(self, "thinker", None)
batch = getattr(thinker, "get_audio_embedding_streaming_batch", None)
supported = getattr(thinker, "supports_streaming_audio_batch", None)
encoder = batch if callable(batch) and callable(supported) and supported() else None
self._batched_audio_encoder_fn = encoder
return encoder
@staticmethod
def _batched_audio_chunk(request: _MiniCPMO45AudioEncodeRequest) -> Any | None: # Any: StreamingAudioChunk
import torch
from vllm_omni.model_executor.models.minicpmo_4_5.streaming_audio_encoder import (
StreamingAudioChunk,
StreamingAudioKVCache,
)
cache = getattr(request.state, "audio_past_key_values", None)
if request.state is None or (cache is not None and not isinstance(cache, StreamingAudioKVCache)):
return None
feature = request.batch_feature
try:
features, lengths = feature["audio_features"], feature["audio_feature_lens"]
except (KeyError, TypeError):
return None
if not isinstance(features, torch.Tensor) or features.ndim != 3 or features.shape[0] != 1:
return None
if isinstance(lengths, (list, tuple)) and len(lengths) == 1:
lengths = lengths[0]
if not isinstance(lengths, torch.Tensor) or lengths.numel() != 1:
return None
# Same extra-context frames the per-session call passes.
return StreamingAudioChunk(
features=features,
cache=cache,
prefix_extra_frames=0 if int(getattr(feature, "chunk_idx", 0)) == 0 else 2,
suffix_extra_frames=2,
feature_length=int(lengths.reshape(-1)[0]),
)
def _audio_feature_to_device(self, batch_feature: Any) -> Any: # Any: transformers BatchFeature
"""Move one unit's log-mel features to the encoder without a host sync; integer lengths stay on the host."""
import torch
from vllm_omni.utils.device_copy import to_device_nonblocking
data = getattr(batch_feature, "data", None)
streaming = any(
callable(getattr(target, "get_audio_embedding_streaming", None))
for target in (self.stage_model, self.thinker)
)
if torch.device(self.device).type != "cuda" or not isinstance(data, dict) or not streaming:
return batch_feature.to(self.device)
batch_feature.data = {
key: to_device_nonblocking(value, self.device)
if isinstance(value, torch.Tensor) and torch.is_floating_point(value)
else value
for key, value in data.items()
}
return batch_feature
@staticmethod
def _decode_ref_audio_from_session_config(session_config: dict[str, object]) -> NDArray[np.float32] | None:
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.input import decode_native_ref_audio_from_config
@@ -1092,7 +1356,8 @@ class MiniCPMO45Stage0DuplexRuntime:
return [1]
return [2] + [1] * (frame_count - 1)
def _encode_processed_vision(self, processed: Any) -> list[torch.Tensor] | None: # Any: BatchFeature
def _encode_processed_vision_batch(self, processed: list[Any]) -> list[list[torch.Tensor]] | None: # BatchFeature
"""Encode every slice of the ``processed`` frames in one vision-tower call."""
targets = (self.stage_model, self.thinker, getattr(self.stage_model, "model", None))
for target in targets:
if target is None:
@@ -1106,18 +1371,23 @@ class MiniCPMO45Stage0DuplexRuntime:
vpm_param = next(vpm.parameters())
device, dtype = vpm_param.device, vpm_param.dtype
pixel_nested = processed["pixel_values"]
tgt_nested = processed["tgt_sizes"]
flat_pixels: list[torch.Tensor] = []
flat_tgt: list[torch.Tensor] = []
for image_slices, image_tgt in zip(pixel_nested, tgt_nested):
for slice_pixels in image_slices:
flat_pixels.append(slice_pixels.to(device=device, dtype=dtype))
tgt_tensor = image_tgt if hasattr(image_tgt, "reshape") else torch.tensor(image_tgt)
flat_tgt.append(tgt_tensor.reshape(-1, 2))
slices_per_frame: list[int] = []
for frame in processed:
num_slices = 0
for image_slices, image_tgt in zip(frame["pixel_values"], frame["tgt_sizes"]):
for slice_pixels in image_slices:
flat_pixels.append(slice_pixels.to(device=device, dtype=dtype))
num_slices += 1
tgt_tensor = image_tgt if hasattr(image_tgt, "reshape") else torch.tensor(image_tgt)
flat_tgt.append(torch.as_tensor(tgt_tensor).reshape(-1, 2))
if num_slices == 0:
return None
slices_per_frame.append(num_slices)
if not flat_pixels:
return None
tgt_sizes = torch.cat(flat_tgt, dim=0).to(device=device, dtype=torch.int64)
tgt_sizes = torch.cat(flat_tgt, dim=0).to(device="cpu", dtype=torch.int64)
with torch.no_grad():
hidden = get_hidden({"pixel_values": flat_pixels, "tgt_sizes": tgt_sizes})
except Exception: # noqa: BLE001 - prefill fails with a reason
@@ -1132,7 +1402,10 @@ class MiniCPMO45Stage0DuplexRuntime:
if embed_dtype is not None and block_2d.dtype != embed_dtype:
block_2d = block_2d.to(dtype=embed_dtype)
out.append(block_2d)
return out
if len(out) != sum(slices_per_frame):
return None
blocks = iter(out)
return [[next(blocks) for _ in range(num_slices)] for num_slices in slices_per_frame]
return None
def _stage_vision_embeddings(self, frames: list[Image.Image]) -> list[list[torch.Tensor]] | None:
@@ -1146,17 +1419,96 @@ class MiniCPMO45Stage0DuplexRuntime:
Frames are processed one at a time because ``process_image([a, b])``
packs both PILs into one batch item.
"""
processed = self._preprocess_frames(frames)
return None if processed is None else self._encode_processed_vision_batch(processed)
def _preprocess_frames(self, frames: list[Image.Image]) -> list[Any] | None: # Any: BatchFeature
"""The CPU half of ``_stage_vision_embeddings``: each frame's processed slices; ``None`` on failure."""
process_image = getattr(self.processor, "process_image", None)
if not callable(process_image):
return None
out: list[list[torch.Tensor]] = []
out: list[Any] = []
for frame, max_slices in zip(frames, self._official_max_slice_nums(len(frames))):
try:
processed = process_image([frame], max_slice_nums=max_slices)
out.append(process_image([frame], max_slice_nums=max_slices))
except Exception: # noqa: BLE001 - prefill fails with a reason
return None
encoded = self._encode_processed_vision(processed)
if encoded is None:
return None
out.append(encoded)
return out
def frame_kwargs(self, duplex: dict[str, Any], payload: dict[str, object]) -> dict[str, Any]:
"""``encoded_frames`` prefetched for this append, else its decoded ``video_frames`` (``ValueError``)."""
encoded = self.take_prefetched_vision(duplex)
if encoded is not None:
return {"encoded_frames": encoded}
return {"video_frames": self._decode_video_frames_payload(payload)}
@staticmethod
def _vision_prefetch_key(duplex: dict[str, Any]) -> tuple[str, int | None, int] | None:
"""``(session_id, epoch, seq)`` of an append, parsed like ``preprocess`` does; ``None`` if unkeyed."""
session_id, seq = str(duplex.get("session_id") or ""), _optional_int(duplex.get("seq"))
return (session_id, _optional_int(duplex.get("epoch")), seq) if session_id and seq is not None else None
def prefetch_vision(self, appends: list[dict[str, Any]]) -> None:
"""Encode this step's appends' camera frames in ``vision_batch_size``-append tower calls.
Decode and slicing run on the frame pool, the next chunk's overlapping this chunk's encode.
Results are keyed by append and checked against its frame payload on use.
"""
self._prefetched_vision.clear()
jobs: list[tuple[tuple[str, int | None, int], tuple[object, ...]]] = []
for duplex in appends:
key = self._vision_prefetch_key(duplex)
payload = duplex.get("payload")
frames = payload.get("video_frames") if isinstance(payload, dict) else None
if key is None or not isinstance(frames, list) or not frames:
continue
state = self.sessions.get(key[0])
if state is None or state.prepared_append_identity != key[1:] or state.prepared_inputs_embeds is None:
jobs.append((key, tuple(frames))) # else already built: preprocess replays the cached append
if not jobs or not callable(getattr(self.processor, "process_image", None)):
return
def preprocess(frames: tuple[object, ...]) -> list[Any] | None:
try:
decoded = self._decode_video_frames_payload({"video_frames": list(frames)})
except ValueError:
return None
return self._preprocess_frames(decoded) if decoded else None
pool = _frame_preprocess_pool() if len(jobs) > 1 else None
def start(chunk: list[tuple[Any, tuple[object, ...]]]) -> list[Any]:
return [pool.submit(preprocess, frames) if pool is not None else frames for _, frames in chunk]
def finish(started: list[Any]) -> list[list[Any] | None]:
return [item.result() if pool is not None else preprocess(item) for item in started]
size = self._vision_prefetch_chunk_size()
chunks = [jobs[begin : begin + size] for begin in range(0, len(jobs), size)]
started = start(chunks[0])
for index, chunk in enumerate(chunks):
# Submit the next chunk before this chunk's encode so they overlap.
following = start(chunks[index + 1]) if index + 1 < len(chunks) else []
ready = [(job, processed) for job, processed in zip(chunk, finish(started)) if processed is not None]
items = [item for _, processed in ready for item in processed]
encoded = self._encode_processed_vision_batch(items) if ready else None
blocks = iter(encoded or ())
for (key, frames), processed in ready if encoded is not None else ():
self._prefetched_vision[key] = (frames, [next(blocks) for _ in processed])
started = following
def _vision_prefetch_chunk_size(self) -> int:
"""Jobs per ``prefetch_vision`` chunk: the checkpoint's ``vision_batch_size``, else 16."""
for target in (self.stage_model, self.thinker, getattr(self.stage_model, "model", None)):
size = getattr(getattr(target, "config", None), "vision_batch_size", None)
if isinstance(size, int) and size > 0:
return size
return 16
def take_prefetched_vision(self, duplex: dict[str, Any]) -> list[list[torch.Tensor]] | None:
"""Pop this append's prefetched per-frame embeddings if its frame payload still matches."""
key = self._vision_prefetch_key(duplex)
frames_payload, encoded = self._prefetched_vision.pop(key, (None, None)) if key is not None else (None, None)
payload = duplex.get("payload")
frames = payload.get("video_frames") if isinstance(payload, dict) else None
return encoded if isinstance(frames, list) and tuple(frames) == frames_payload else None
@@ -17,8 +17,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from contextlib import suppress
from dataclasses import dataclass
from functools import cached_property
from typing import Any
@@ -45,10 +46,35 @@ from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import (
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
from vllm_omni.utils.device_copy import index_to_device, to_device_nonblocking
logger = init_logger(__name__)
@dataclass(slots=True)
class _MiniCPMO45PendingSamples:
"""A deferred Stage-0 duplex step awaiting its host commit."""
host: torch.Tensor
event: Any
row_idxs: list[int]
stage2: list[bool]
rewinds: dict[int, Callable[[], None]]
token_ids: dict[str, int]
row_sessions: dict[int, str] | None
row_payloads: dict[int, dict[str, Any]] | None
def _generator_rewind(generator: torch.Generator) -> Callable[[], None]:
"""A call that puts ``generator`` back where it stands now: its Philox offset, else its full state."""
try:
offset = generator.get_offset()
return lambda: generator.set_offset(offset)
except RuntimeError: # not a Philox generator (CPU)
state = generator.get_state()
return lambda: generator.set_state(state)
@MULTIMODAL_REGISTRY.register_processor(
MiniCPMO45OmniLLMMultiModalProcessor,
info=MiniCPMO45OmniLLMProcessingInfo,
@@ -244,7 +270,14 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
) -> None:
"""Apply MiniCPM duplex policy before the standard model sampler."""
del sampling_metadata
# The previous step's deferred decisions update the session state read below.
self._commit_minicpmo45_duplex_pending_samples()
self._minicpmo45_active_duplex_rows = [row.row_idx for row in rows]
self._minicpmo45_duplex_row_sampling_host = {
row.row_idx: (row.temperature, row.top_k, row.top_p)
for row in rows
if row.temperature is not None and row.top_k is not None and row.top_p is not None
}
self._minicpmo45_duplex_row_sessions = {
row.row_idx: row.session_id for row in rows if row.session_id is not None
}
@@ -306,8 +339,9 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
if force_listen and segment_key in force_listen_segments:
continue
if force_listen:
logits[row_idx, :] = float("-inf")
logits[row_idx, listen_id] = 0.0
# fill_ keeps the scalars on the host; an element assignment copies a scalar tensor and syncs.
logits[row_idx].fill_(float("-inf"))
logits[row_idx].narrow(0, listen_id, 1).fill_(0.0)
force_listen_segments.add(segment_key)
# -------------------- Device utilities --------------------
@@ -418,53 +452,20 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
embeds = input_embeds if input_embeds is not None else self.get_input_embeddings(input_ids)
return input_ids, embeds, {"duplex": {"prefill_success": False, "reason": "bad_duplex_payload"}}
session_key = session_id
state = helper.sessions.get(session_key)
if state is None:
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.stage0 import (
_MiniCPMO45Stage0SessionState,
)
state = _MiniCPMO45Stage0SessionState(session_id=session_id)
helper.sessions[session_key] = state
session_config = duplex.get("session_config")
session_config = dict(session_config) if isinstance(session_config, dict) else {}
runtime_config = duplex.get("runtime_config")
runtime_config = dict(runtime_config) if isinstance(runtime_config, dict) else {}
if hasattr(helper.thinker, "audio_past_key_values"):
helper.thinker.audio_past_key_values = None
helper._configure_streaming_processor(state)
helper._prepare_session_context(state, session_config, runtime_config=runtime_config)
audio_waveform = helper._decode_audio_payload(payload)
try:
video_frames = helper._decode_video_frames_payload(payload)
except ValueError as exc:
embeds = input_embeds if input_embeds is not None else self.get_input_embeddings(input_ids)
return input_ids, embeds, {"duplex": {"prefill_success": False, "reason": str(exc)}}
seq = duplex.get("seq")
try:
seq = int(seq) if seq is not None else None
except (TypeError, ValueError):
seq = None
epoch = duplex.get("epoch")
try:
epoch = int(epoch) if epoch is not None else None
except (TypeError, ValueError):
epoch = None
result = helper._stage_prefill_embeddings_only(
state,
audio_waveform,
video_frames=video_frames,
epoch=epoch,
seq=seq,
is_speech=bool(payload.get("is_speech", False)),
final=bool(duplex.get("final")),
stage0_window=(duplex.get("stage0_window") if isinstance(duplex.get("stage0_window"), dict) else None),
stage0_reanchor=(
duplex.get("stage0_reanchor") if isinstance(duplex.get("stage0_reanchor"), dict) else None
),
)
# A deferred sample of this session updates the latches its append reads.
self._commit_minicpmo45_duplex_pending_samples(session_ids={session_id})
state = self._minicpmo45_duplex_session_state(helper, session_id, duplex)
prefill_kwargs = self._minicpmo45_duplex_prefill_kwargs(duplex, payload)
seq = prefill_kwargs["seq"]
result = helper.take_staged_prefill(state, prefill_kwargs["epoch"], seq)
if result is None: # not built by this step's preprocess_batch
audio_waveform = helper._decode_audio_payload(payload)
try:
prefill_kwargs.update(helper.frame_kwargs(duplex, payload))
except ValueError as exc:
embeds = input_embeds if input_embeds is not None else self.get_input_embeddings(input_ids)
return input_ids, embeds, {"duplex": {"prefill_success": False, "reason": str(exc)}}
result = helper._stage_prefill_embeddings_only(state, audio_waveform, **prefill_kwargs)
update_result = dict(result)
if result.get("stage0_window_replaced") is True:
window = duplex.get("stage0_window", {})
@@ -542,12 +543,105 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
if len(input_token_ids) < span_len:
input_token_ids.extend([pad_token_id] * (span_len - len(input_token_ids)))
if input_token_ids:
req_input_ids = torch.tensor(input_token_ids, dtype=input_ids.dtype, device=input_ids.device)
# Pinned + non-blocking: a pageable copy would wait for all queued GPU work.
req_input_ids = index_to_device(input_token_ids, input_ids.device, input_ids.dtype)
update_result["duplex_prompt_token_ids"] = full_input_token_ids
else:
req_input_ids = torch.full_like(input_ids, helper._required_token_id("unit_token_id"))
return req_input_ids, req_embeds, {"duplex": update_result}
def preprocess_batch(
self,
*,
req_ids: list[str],
model_intermediate_buffer: dict[str, dict[str, Any]],
device: torch.device,
) -> None:
"""Runner hook before ``preprocess``: batch this step's duplex appends across sessions.
With two or more appends, camera frames share vision-tower calls (``prefetch_vision``) and
unbuilt appends share streaming-encoder passes (``stage_prefill_batch``); ``preprocess``
takes the staged results and builds or reports anything skipped here.
"""
del device
if self.model_stage != "llm":
return
infos = [model_intermediate_buffer.get(req_id) for req_id in req_ids]
duplexes = [info.get("duplex") for info in infos if isinstance(info, dict)]
appends = [duplex for duplex in duplexes if isinstance(duplex, dict) and duplex.get("data_plane") is True]
helper = getattr(self, "_minicpmo45_duplex_data_plane_helper", None)
frame_appends = [d for d in appends if isinstance(d.get("payload"), dict) and d["payload"].get("video_frames")]
if len(frame_appends) >= 2:
try:
helper = self._duplex_data_plane_helper()
helper.prefetch_vision(frame_appends)
except Exception: # noqa: BLE001 - preprocess encodes each append itself instead
logger.warning("MiniCPM-o duplex frame prefetch failed; encoding per request", exc_info=True)
elif helper is not None:
helper.prefetch_vision([]) # drop entries the previous step did not consume
if helper is None or len(req_ids) < 2 or not helper.batches_audio_encoder():
return
candidates: list[tuple[str, dict[str, Any], dict[str, Any], dict[str, Any]]] = []
seen: set[str] = set()
for duplex in appends:
session_id = str(duplex.get("session_id") or "")
payload = duplex.get("payload")
repeated = session_id in seen
seen.add(session_id)
if not session_id or not isinstance(payload, dict) or repeated:
continue # a second append of one session keeps its order: preprocess
kwargs = self._minicpmo45_duplex_prefill_kwargs(duplex, payload)
if helper.needs_prefill(helper.sessions.get(session_id), kwargs["epoch"], kwargs["seq"]):
candidates.append((session_id, duplex, payload, kwargs))
if len(candidates) < 2:
return
self._commit_minicpmo45_duplex_pending_samples(session_ids={candidate[0] for candidate in candidates})
staged = []
for session_id, duplex, payload, kwargs in candidates:
try:
audio_waveform = helper._decode_audio_payload(payload)
kwargs.update(helper.frame_kwargs(duplex, payload))
except ValueError:
continue
staged.append((self._minicpmo45_duplex_session_state(helper, session_id, duplex), audio_waveform, kwargs))
if len(staged) >= 2:
helper.stage_prefill_batch(staged)
def _minicpmo45_duplex_session_state(self, helper, session_id: str, duplex: dict[str, Any]):
"""The Stage-0 state of ``session_id``, created with its session context on first use."""
state = helper.sessions.get(session_id)
if state is None:
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.stage0 import (
_MiniCPMO45Stage0SessionState,
)
state = _MiniCPMO45Stage0SessionState(session_id=session_id)
helper.sessions[session_id] = state
session_config = duplex.get("session_config")
session_config = dict(session_config) if isinstance(session_config, dict) else {}
runtime_config = duplex.get("runtime_config")
runtime_config = dict(runtime_config) if isinstance(runtime_config, dict) else {}
if hasattr(helper.thinker, "audio_past_key_values"):
helper.thinker.audio_past_key_values = None
helper._configure_streaming_processor(state)
helper._prepare_session_context(state, session_config, runtime_config=runtime_config)
return state
@staticmethod
def _minicpmo45_duplex_prefill_kwargs(duplex: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
"""Keyword arguments of ``_stage_prefill_embeddings_only`` for one append, frames aside."""
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.stage0 import _optional_int
window, reanchor = duplex.get("stage0_window"), duplex.get("stage0_reanchor")
return {
"epoch": _optional_int(duplex.get("epoch")),
"seq": _optional_int(duplex.get("seq")),
"is_speech": bool(payload.get("is_speech", False)),
"final": bool(duplex.get("final")),
"stage0_window": window if isinstance(window, dict) else None,
"stage0_reanchor": reanchor if isinstance(reanchor, dict) else None,
}
def _duplex_data_plane_helper(self):
helper = getattr(self, "_minicpmo45_duplex_data_plane_helper", None)
if helper is not None:
@@ -694,13 +788,11 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
if isinstance(key, str) and isinstance(value, int) and value >= 0
}
if special_keys:
# Host tensors: every consumer reads them on the host, and a pageable
# host->device copy per row and key would wait for the whole forward.
multimodal_outputs["meta"] = {
key: [
torch.tensor(
[int(value)],
dtype=torch.long,
device=text_hidden_states.device,
)
torch.tensor([int(value)], dtype=torch.long)
if isinstance(value, int) and value >= 0
else None
for duplex_info in duplex_rows
@@ -808,6 +900,7 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
) -> SamplerOutput | None:
if self.model_stage != "llm" or logits.ndim != 2 or logits.shape[0] == 0:
return None
self._commit_minicpmo45_duplex_pending_samples()
token_ids = self._minicpmo45_native_duplex_token_ids()
unit_id = token_ids.get("unit_token_id", -1)
if unit_id < 0:
@@ -823,120 +916,399 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
chunk_terminators = self._minicpmo45_chunk_terminator_token_ids(token_ids)
output_token_ids = getattr(sampling_metadata, "output_token_ids", None) or []
sampled_ids: list[int] = []
for row_idx in range(logits.shape[0]):
num_rows = logits.shape[0]
row_params = self._minicpmo45_duplex_row_params(sampling_metadata, num_rows)
# Lookahead after a chunk terminator: re-emit it and skip sampling.
sampled_ids: list[int] = [-1] * num_rows
pending_rows: list[int] = []
for row_idx in range(num_rows):
accepted = output_token_ids[row_idx] if row_idx < len(output_token_ids) else []
last_accepted = next((int(t) for t in reversed(accepted) if isinstance(t, int) and t >= 0), None)
if last_accepted in chunk_terminators:
# Async scheduling runs one lookahead frame after the chunk
# terminator was sampled but before the scheduler observes
# the segment stop. The scheduler discards this frame's
# token, so decide nothing here: re-emit the terminator and
# leave the model-owned policy state exactly as the accepted
# history left it. The next append re-injects that terminator.
sampled_ids.append(last_accepted)
continue
row_logits = logits[row_idx : row_idx + 1].clone()
sampled = self._sample_minicpmo45_native_duplex_row(
row_logits,
sampling_metadata,
row_idx=row_idx,
token_ids=token_ids,
)
self._record_minicpmo45_duplex_terminator(row_idx, sampled, token_ids)
sampled_ids.append(sampled)
sampled_ids[row_idx] = last_accepted
else:
pending_rows.append(row_idx)
if pending_rows:
kwargs = {"row_idxs": pending_rows, "token_ids": token_ids, "row_params": row_params}
deferred = self._sample_minicpmo45_native_duplex_rows_deferred(logits, sampling_metadata, **kwargs)
if deferred is not None:
sampled = index_to_device(sampled_ids, logits.device, torch.int32)
sampled[index_to_device(pending_rows, logits.device)] = deferred.to(torch.int32)
return SamplerOutput(sampled_token_ids=sampled.unsqueeze(-1), logprobs_tensors=None)
synchronous = self._sample_minicpmo45_native_duplex_rows(logits, sampling_metadata, **kwargs)
for row_idx, sampled in zip(pending_rows, synchronous, strict=True):
sampled_ids[row_idx] = sampled
self._record_minicpmo45_duplex_terminator(row_idx, sampled, token_ids)
return SamplerOutput(
sampled_token_ids=torch.tensor(sampled_ids, device=logits.device, dtype=torch.int32).unsqueeze(-1),
sampled_token_ids=index_to_device(sampled_ids, logits.device, torch.int32).unsqueeze(-1),
logprobs_tensors=None,
)
def _sample_minicpmo45_native_duplex_row(
def _minicpmo45_duplex_row_params(
self,
sampling_metadata: SamplingMetadata,
num_rows: int,
) -> list[tuple[float, float, float]]:
"""Every row's (temperature, top_k, top_p): host copies when present, else one device read per parameter."""
host = getattr(self, "_minicpmo45_duplex_row_sampling_host", None) or {}
columns = []
for column, (name, default) in enumerate((("temperature", 0.7), ("top_k", 100), ("top_p", 0.8))):
value = getattr(sampling_metadata, name, None)
if not isinstance(value, torch.Tensor):
columns.append([self._sampling_metadata_value(sampling_metadata, name, 0, default)] * num_rows)
elif value.numel() == num_rows and host.keys() >= set(range(num_rows)):
columns.append([float(host[row][column]) for row in range(num_rows)])
elif value.numel() == 0:
columns.append([default] * num_rows)
else:
flat = [float(v) for v in value.reshape(-1).tolist()]
columns.append([flat[min(row, len(flat) - 1)] for row in range(num_rows)])
return list(zip(*columns, strict=True))
def _minicpmo45_policy_limit(self, name: str, default: int) -> int:
"""A per-chunk duplex limit set on the model (``0``/``None`` mean ``default``)."""
return int(getattr(self, name, default) or default)
def _minicpmo45_duplex_forbidden_index(self, token_ids: dict[str, int], vocab: int, device) -> torch.Tensor | None:
"""``_minicpmo45_native_forbidden_token_ids`` inside the vocab, as a cached device index."""
key = (vocab, str(device))
cache = getattr(self, "_minicpmo45_duplex_forbidden_index_cache", None)
if cache is None or cache[0] != key:
forbidden = self._minicpmo45_native_forbidden_token_ids(token_ids)
valid = sorted({token_id for token_id in forbidden if 0 <= token_id < vocab})
cache = (key, torch.tensor(valid, dtype=torch.long, device=device) if valid else None)
self._minicpmo45_duplex_forbidden_index_cache = cache
return cache[1]
def _minicpmo45_duplex_cut_tables(self, token_ids: dict[str, int], vocab: int, device):
"""Per-token decoded length and "never cut" mask for ``_maybe_cut_minicpmo45_native_duplex_text_chunk``.
Byte-level decoding is additive after a prefix ending on a character boundary:
``len(decode(chunk + [t])) == len(decode(chunk)) + length[t]``. None (no device cut) without batch
decode, or with the whitespace clean-up that rewrites across token boundaries.
"""
key = (vocab, str(device))
cache = getattr(self, "_minicpmo45_duplex_cut_tables_cache", None)
if cache is not None and cache[0] == key:
return cache[1]
tables = None
tokenizer = self._minicpmo45_tokenizer()
batch_decode = getattr(tokenizer, "batch_decode", None)
if callable(batch_decode) and not getattr(tokenizer, "clean_up_tokenization_spaces", False):
try:
num_tokens = min(vocab, len(tokenizer))
texts = batch_decode([[token_id] for token_id in range(num_tokens)], skip_special_tokens=True)
except Exception:
texts = None
if texts is not None and len(texts) == num_tokens:
lengths = torch.zeros(vocab, dtype=torch.int32)
lengths[:num_tokens] = torch.tensor([len(text) for text in texts], dtype=torch.int32)
never_cut = torch.ones(vocab, dtype=torch.bool)
never_cut[:num_tokens] = False
special = [t for t in self._minicpmo45_native_special_token_ids(token_ids) if 0 <= t < vocab]
if special:
never_cut[torch.tensor(special, dtype=torch.long)] = True
tables = (lengths.to(device), never_cut.to(device))
self._minicpmo45_duplex_cut_tables_cache = (key, tables)
return tables
def _minicpmo45_duplex_cut_bases(
self,
row_idxs: list[int],
recent_tokens_by_row: dict[int, list[int]],
token_ids: dict[str, int],
max_chars: int,
) -> list[int] | None:
"""Each row's decoded current-chunk length (-1: never cut), or None when a row needs the host decode.
A chunk ending in U+FFFD may end inside a character the next token's bytes complete: not additive.
"""
decode = getattr(self._minicpmo45_tokenizer(), "decode", None)
if max_chars <= 0 or not callable(decode):
return [-1] * len(row_idxs)
bases = []
for row_idx in row_idxs:
chunk = self._minicpmo45_current_chunk_tokens(recent_tokens_by_row[row_idx], token_ids)
try:
text = decode(chunk, skip_special_tokens=True)
except Exception:
return None
if not isinstance(text, str) or text.endswith("\ufffd"):
return None
bases.append(len(text))
return bases
@staticmethod
def _minicpmo45_duplex_recent_tokens(
sampling_metadata: SamplingMetadata, row_idxs: list[int]
) -> dict[int, list[int]]:
"""Each row's accepted output tokens (the current chunk's history), placeholders dropped."""
output_token_ids = getattr(sampling_metadata, "output_token_ids", None) or []
rows = {row_idx: output_token_ids[row_idx] if row_idx < len(output_token_ids) else () for row_idx in row_idxs}
return {row: [int(t) for t in tokens if isinstance(t, int) and t >= 0] for row, tokens in rows.items()}
def _minicpmo45_duplex_capped_rows(self, row_idxs: list[int], recent: dict[int, list[int]]) -> set[int]:
"""Rows at their per-chunk speak-length cap: chunk_eos without sampling anything."""
limit = self._minicpmo45_policy_limit(
"max_new_speak_tokens_per_chunk", MiniCPMO45DuplexPolicy.DEFAULT_MAX_NEW_SPEAK_TOKENS_PER_CHUNK
)
request_max = {row_idx: self._minicpmo45_duplex_row_request_max_tokens(row_idx) for row_idx in row_idxs}
row_limit = {row: limit if request is None else min(limit, request) for row, request in request_max.items()}
return {row for row, row_cap in row_limit.items() if len(recent[row]) >= max(1, row_cap - 1)}
def _minicpmo45_duplex_boundary_hits(
self, logits: torch.Tensor, rows: list[int], chunk_eos_id: int, sampling_metadata: SamplingMetadata
) -> torch.Tensor:
"""Whether each row's boundary draw (the released StreamDecoder's first pass) picks chunk_eos.
The drawn token only matters when it is chunk_eos, so a Bernoulli at
``P(sample == chunk_eos)`` drawn with the row's generator is the same
decision, without a full-vocabulary softmax and multinomial per row.
"""
rows_logits = logits[index_to_device(rows, logits.device)]
if getattr(sampling_metadata, "all_greedy", False):
return torch.argmax(rows_logits, dim=-1) == chunk_eos_id
generators = getattr(sampling_metadata, "generators", {}) or {}
draws = [torch.rand((), generator=generators.get(row_idx), device=logits.device) for row_idx in rows]
return torch.stack(draws) < self._duplex_boundary_chunk_eos_probs(rows_logits, chunk_eos_id)
def _minicpmo45_duplex_stage2_draws(
self,
logits: torch.Tensor,
rows: list[int],
recent: dict[int, list[int]],
token_ids: dict[str, int],
row_params: list[tuple[float, float, float]],
sampling_metadata: SamplingMetadata,
rewinds: dict[int, Callable[[], None]] | None = None,
) -> torch.Tensor:
"""Each row's stage-2 token: forbidden ids masked, repetition penalty, then greedy or a candidate draw.
``rewinds`` receives, per row position, a call undoing that row's draw.
"""
device, vocab = logits.device, logits.shape[-1]
all_greedy = bool(getattr(sampling_metadata, "all_greedy", False))
generators = getattr(sampling_metadata, "generators", {}) or {}
stage2_logits = logits[index_to_device(rows, device)].clone()
forbidden_index = self._minicpmo45_duplex_forbidden_index(token_ids, vocab, device)
if forbidden_index is not None:
stage2_logits.index_fill_(1, forbidden_index, float("-inf"))
history_size = MiniCPMO45DuplexPolicy.REPETITION_HISTORY_SIZE
penalty_rows: list[int] = []
penalty_cols: list[int] = []
for local_idx, row_idx in enumerate(rows):
state = self._minicpmo45_duplex_state_for_row(row_idx)
repetition_tokens = getattr(state, "generated_tokens", None) or recent[row_idx]
penalized = [token_id for token_id in set(repetition_tokens[-history_size:]) if 0 <= token_id < vocab]
penalty_rows.extend([local_idx] * len(penalized))
penalty_cols.extend(penalized)
if penalty_rows:
# Distinct (row, col) pairs, so one indexed division equals dividing each once.
rows_t, cols_t = index_to_device(penalty_rows, device), index_to_device(penalty_cols, device)
stage2_logits[rows_t, cols_t] = stage2_logits[rows_t, cols_t] / 1.05
samples: list[torch.Tensor | None] = [None] * len(rows)
greedy = [local for local, row_idx in enumerate(rows) if all_greedy or float(row_params[row_idx][0]) <= 0]
if greedy:
greedy_sample = torch.argmax(stage2_logits[index_to_device(greedy, device)], dim=-1)
for pos, local_idx in enumerate(greedy):
samples[local_idx] = greedy_sample[pos : pos + 1]
drawn = [local_idx for local_idx in range(len(rows)) if samples[local_idx] is None]
# One uniform per row from its generator, in row order, then a batched draw per (top_k, top_p) group.
uniforms: dict[int, torch.Tensor] = {}
groups: dict[tuple[int, float], list[int]] = {}
for local_idx in drawn:
generator = generators.get(rows[local_idx])
if rewinds is not None:
rewinds[local_idx] = _generator_rewind(generator)
uniforms[local_idx] = torch.rand((), generator=generator, device=device)
_temperature, top_k, top_p = row_params[rows[local_idx]]
groups.setdefault((int(top_k), float(top_p)), []).append(local_idx)
if drawn:
temps = torch.tensor([float(row_params[row_idx][0]) for row_idx in rows], dtype=stage2_logits.dtype)
temps = to_device_nonblocking(temps, device).view(-1, 1)
for (top_k, top_p), members in groups.items():
# Device-side row gathers: a python-list index would stage a
# pageable host copy and block the host on queued GPU work.
members_t = index_to_device(members, device)
group_logits = stage2_logits[members_t] / temps[members_t]
candidates = self._duplex_top_k_top_p_candidates(group_logits, top_k=top_k, top_p=top_p)
group_uniforms = torch.stack([uniforms[local_idx] for local_idx in members])
draws = self._duplex_draw_candidates(group_logits, candidates, group_uniforms)
for i, local_idx in enumerate(members):
samples[local_idx] = draws[i : i + 1]
return torch.cat(samples) # type: ignore[arg-type]
def _sample_minicpmo45_native_duplex_rows(
self,
logits: torch.Tensor,
sampling_metadata: SamplingMetadata,
*,
row_idx: int,
row_idxs: list[int],
token_ids: dict[str, int],
) -> int:
row_params: list[tuple[float, float, float]],
) -> list[int]:
"""Sample duplex rows with at most two host reads: the boundary decisions, then the stage-2 tokens.
Per row: chunk_eos at the speak-length cap, else the boundary draw, else
the stage-2 draw, the chunk cut and the listen->tts_bos rewrite. Each
row's own generator draws as it would one row at a time; rows sharing
the process-wide generator draw every boundary before any stage-2 token.
"""
chunk_eos_id = token_ids.get("chunk_eos_token_id", -1)
generator = getattr(sampling_metadata, "generators", {}).get(row_idx)
output_token_ids = getattr(sampling_metadata, "output_token_ids", None) or []
raw_recent_tokens = output_token_ids[row_idx] if row_idx < len(output_token_ids) else []
recent_tokens = [int(token_id) for token_id in raw_recent_tokens if isinstance(token_id, int) and token_id >= 0]
temperature = float(self._sampling_metadata_value(sampling_metadata, "temperature", row_idx, 0.7))
top_k = int(self._sampling_metadata_value(sampling_metadata, "top_k", row_idx, 100))
top_p = float(self._sampling_metadata_value(sampling_metadata, "top_p", row_idx, 0.8))
state = self._minicpmo45_duplex_state_for_row(row_idx)
if chunk_eos_id >= 0 and chunk_eos_id < logits.shape[-1]:
max_speak_tokens = int(
getattr(
self,
"max_new_speak_tokens_per_chunk",
MiniCPMO45DuplexPolicy.DEFAULT_MAX_NEW_SPEAK_TOKENS_PER_CHUNK,
)
or MiniCPMO45DuplexPolicy.DEFAULT_MAX_NEW_SPEAK_TOKENS_PER_CHUNK
recent = self._minicpmo45_duplex_recent_tokens(sampling_metadata, row_idxs)
resolved: dict[int, int] = {}
if 0 <= chunk_eos_id < logits.shape[-1]:
resolved = dict.fromkeys(self._minicpmo45_duplex_capped_rows(row_idxs, recent), chunk_eos_id)
boundary = [row_idx for row_idx in row_idxs if row_idx not in resolved]
if boundary:
hits = self._minicpmo45_duplex_boundary_hits(logits, boundary, chunk_eos_id, sampling_metadata)
resolved.update((row_idx, chunk_eos_id) for row_idx, hit in zip(boundary, hits.tolist()) if hit)
stage2_rows = [row_idx for row_idx in row_idxs if row_idx not in resolved]
if stage2_rows:
draws = self._minicpmo45_duplex_stage2_draws(
logits, stage2_rows, recent, token_ids, row_params, sampling_metadata
)
request_max_tokens = self._minicpmo45_duplex_row_request_max_tokens(row_idx)
effective_max_speak_tokens = max_speak_tokens
if request_max_tokens is not None:
effective_max_speak_tokens = min(effective_max_speak_tokens, request_max_tokens)
if len(recent_tokens) >= max(1, effective_max_speak_tokens - 1):
return int(chunk_eos_id)
for row_idx, sampled in zip(stage2_rows, draws.tolist(), strict=True):
self._record_minicpmo45_duplex_generation_token(row_idx, sampled)
sampled = self._maybe_cut_minicpmo45_native_duplex_text_chunk(sampled, recent[row_idx], token_ids)
resolved[row_idx] = self._finalize_minicpmo45_native_duplex_sample(row_idx, sampled, token_ids)
return [int(resolved[row_idx]) for row_idx in row_idxs]
# Match the released StreamDecoder: first sample the original
# distribution only to preserve the model's own chunk boundary.
# If it does not choose chunk_eos, mask that token before the
# normal text/listen/turn sampling pass below.
if getattr(sampling_metadata, "all_greedy", False):
boundary_sample = int(torch.argmax(logits, dim=-1).item())
else:
boundary_probs = F.softmax(logits, dim=-1)
boundary_sample = int(torch.multinomial(boundary_probs, num_samples=1, generator=generator).item())
if boundary_sample == chunk_eos_id:
return int(chunk_eos_id)
def _sample_minicpmo45_native_duplex_rows_deferred(
self,
logits: torch.Tensor,
sampling_metadata: SamplingMetadata,
*,
row_idxs: list[int],
token_ids: dict[str, int],
row_params: list[tuple[float, float, float]],
) -> torch.Tensor | None:
"""``_sample_minicpmo45_native_duplex_rows`` decided on the device without a host sync, or ``None``.
logits = logits.clone()
forbidden = self._minicpmo45_native_forbidden_token_ids(token_ids)
if forbidden:
valid_forbidden = [token_id for token_id in forbidden if 0 <= token_id < logits.shape[-1]]
if valid_forbidden:
logits[:, valid_forbidden] = float("-inf")
generated_tokens = getattr(state, "generated_tokens", None)
repetition_tokens = generated_tokens if generated_tokens else recent_tokens
repetition_penalty = 1.05
if repetition_penalty != 1.0 and repetition_tokens:
history_size = MiniCPMO45DuplexPolicy.REPETITION_HISTORY_SIZE
for token_id in set(repetition_tokens[-history_size:]):
if token_id < 0 or token_id >= logits.shape[-1]:
continue
logits[0, token_id] /= repetition_penalty
if getattr(sampling_metadata, "all_greedy", False) or temperature <= 0:
sampled = int(torch.argmax(logits, dim=-1).item())
self._record_minicpmo45_duplex_generation_token(row_idx, sampled)
sampled = self._maybe_cut_minicpmo45_native_duplex_text_chunk(
sampled,
recent_tokens,
token_ids,
Host state is committed a step later (``_commit_minicpmo45_duplex_pending_samples``). Stage 2
is drawn for every uncapped row; a row the boundary resolves has its generator rewound at
commit, so generators advance as on the synchronous path. Process-wide generator rows can not
be rewound alone and keep the step synchronous.
"""
device, vocab = logits.device, logits.shape[-1]
chunk_eos_id = token_ids.get("chunk_eos_token_id", -1)
has_chunk_eos = 0 <= chunk_eos_id < vocab
all_greedy = bool(getattr(sampling_metadata, "all_greedy", False))
generators = getattr(sampling_metadata, "generators", {}) or {}
recent = self._minicpmo45_duplex_recent_tokens(sampling_metadata, row_idxs)
capped = self._minicpmo45_duplex_capped_rows(row_idxs, recent) if has_chunk_eos else set()
stage2_rows = [row_idx for row_idx in row_idxs if row_idx not in capped]
drawn_rows = [row_idx for row_idx in stage2_rows if not (all_greedy or float(row_params[row_idx][0]) <= 0)]
if any(generators.get(row_idx) is None for row_idx in drawn_rows):
return None
cut = None
if has_chunk_eos and stage2_rows:
max_chars = self._minicpmo45_policy_limit(
"max_speak_chars_per_chunk", MiniCPMO45DuplexPolicy.DEFAULT_MAX_SPEAK_CHARS_PER_CHUNK
)
return self._finalize_minicpmo45_native_duplex_sample(row_idx, sampled, token_ids)
bases = self._minicpmo45_duplex_cut_bases(stage2_rows, recent, token_ids, max_chars)
if bases is None:
return None
if any(base >= 0 for base in bases):
tables = self._minicpmo45_duplex_cut_tables(token_ids, vocab, device)
if tables is None:
return None
cut = (tables, bases, max_chars)
logits = logits / temperature
logits = self._top_k_top_p_filter(logits, top_k=top_k, top_p=top_p)
probs = F.softmax(logits, dim=-1)
sampled = int(torch.multinomial(probs, num_samples=1, generator=generator).item())
self._record_minicpmo45_duplex_generation_token(row_idx, sampled)
sampled = self._maybe_cut_minicpmo45_native_duplex_text_chunk(
sampled,
recent_tokens,
token_ids,
final = torch.full((len(row_idxs),), max(chunk_eos_id, 0), dtype=torch.long, device=device)
position = {row_idx: pos for pos, row_idx in enumerate(row_idxs)}
rewinds: dict[int, Callable[[], None]] = {}
if stage2_rows:
positions_t = index_to_device([position[row_idx] for row_idx in stage2_rows], device)
hit = (
self._minicpmo45_duplex_boundary_hits(logits, stage2_rows, chunk_eos_id, sampling_metadata)
if has_chunk_eos
else torch.zeros(len(stage2_rows), dtype=torch.bool, device=device)
)
rewinds_into = rewinds if has_chunk_eos else None
raw = self._minicpmo45_duplex_stage2_draws(
logits, stage2_rows, recent, token_ids, row_params, sampling_metadata, rewinds_into
)
token = raw
if cut is not None:
(lengths, never_cut), bases, max_chars = cut
base_t = index_to_device(bases, device)
cut_t = (base_t >= 0) & ~never_cut[raw] & (base_t + lengths[raw].long() >= max_chars)
token = torch.where(cut_t, torch.full_like(raw, chunk_eos_id), raw)
listen_id = token_ids.get("listen_token_id", -1)
if 0 <= listen_id:
# The host rewrite of a sampled listen (to tts_bos inside an open turn), decided per row.
rewritten = [
self._finalize_minicpmo45_native_duplex_sample(r, listen_id, token_ids) for r in stage2_rows
]
if any(value != listen_id for value in rewritten):
token = torch.where(token == listen_id, index_to_device(rewritten, device), token)
if has_chunk_eos:
token = torch.where(hit, torch.full_like(token, chunk_eos_id), token)
final[positions_t] = token
# One pinned copy of (final, raw stage-2 sample, boundary hit) per row
# for the commit; the host reads it once the runner has waited for it.
packed = torch.stack([final, torch.full_like(final, -1), torch.zeros_like(final)], dim=1)
if stage2_rows:
packed[positions_t, 1] = raw
packed[positions_t, 2] = hit.long()
host = torch.empty(packed.shape, dtype=packed.dtype, pin_memory=device.type == "cuda")
host.copy_(packed, non_blocking=True)
event = torch.cuda.Event() if device.type == "cuda" else None
if event is not None:
event.record()
self._minicpmo45_duplex_pending_samples = _MiniCPMO45PendingSamples(
host=host,
event=event,
row_idxs=list(row_idxs),
stage2=[row_idx not in capped for row_idx in row_idxs],
rewinds={position[stage2_rows[local_idx]]: rewind for local_idx, rewind in rewinds.items()},
token_ids=token_ids,
row_sessions=getattr(self, "_minicpmo45_duplex_row_sessions", None),
row_payloads=getattr(self, "_minicpmo45_duplex_row_payloads", None),
)
return self._finalize_minicpmo45_native_duplex_sample(
row_idx,
sampled,
token_ids,
return final
def _commit_minicpmo45_duplex_pending_samples(self, *, session_ids: set[str] | None = None) -> None:
"""Apply the host state updates of the last deferred step, in the synchronous path's order.
With ``session_ids``, only when that step sampled one of them: a
caller building those sessions' next appends needs their latches, and
must not wait for an unrelated step otherwise.
"""
pending = getattr(self, "_minicpmo45_duplex_pending_samples", None)
if pending is None:
return
if session_ids is not None:
sessions = pending.row_sessions or {}
if not any(sessions.get(row_idx) in session_ids for row_idx in pending.row_idxs):
return
self._minicpmo45_duplex_pending_samples = None
if pending.event is not None:
pending.event.synchronize()
values = pending.host.tolist()
saved = (
getattr(self, "_minicpmo45_duplex_row_sessions", None),
getattr(self, "_minicpmo45_duplex_row_payloads", None),
)
self._minicpmo45_duplex_row_sessions = pending.row_sessions
self._minicpmo45_duplex_row_payloads = pending.row_payloads
try:
for pos, row_idx in enumerate(pending.row_idxs):
_final, raw, hit = values[pos]
if pending.stage2[pos] and hit:
pending.rewinds.get(pos, lambda: None)()
elif pending.stage2[pos]:
self._record_minicpmo45_duplex_generation_token(row_idx, int(raw))
for pos, row_idx in enumerate(pending.row_idxs):
self._record_minicpmo45_duplex_terminator(row_idx, int(values[pos][0]), pending.token_ids)
finally:
self._minicpmo45_duplex_row_sessions, self._minicpmo45_duplex_row_payloads = saved
def _maybe_cut_minicpmo45_native_duplex_text_chunk(
self,
@@ -1227,7 +1599,8 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
kth = torch.topk(logits, top_k, dim=-1).values[..., -1, None]
logits = logits.masked_fill(logits < kth, float("-inf"))
if 0.0 < top_p < 1.0:
sorted_logits, sorted_indices = torch.sort(logits, descending=True, dim=-1)
# Stable: tied logits enter the nucleus lowest index first.
sorted_logits, sorted_indices = torch.sort(logits, descending=True, dim=-1, stable=True)
cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
sorted_remove = cumulative_probs > top_p
sorted_remove[..., 1:] = sorted_remove[..., :-1].clone()
@@ -1237,6 +1610,80 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
logits = logits.masked_fill(remove, float("-inf"))
return logits
@staticmethod
def _duplex_top_k_top_p_candidates(
logits: torch.Tensor, *, top_k: int, top_p: float
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""``softmax(_top_k_top_p_filter(logits))`` on its support as ``(probs, indices, kth, ties)``, no host read.
Nucleus order (value descending, index ascending), O(V + k log k). All logits tied at the k-th value
stay; their count is data-dependent, so they share the last slot (index -1): the ``ties`` lowest-index
ones the nucleus keeps, at one probability each.
"""
vocab = logits.shape[-1]
k = int(top_k) if top_k else 0
if 0 < k < vocab:
values, indices = torch.topk(logits, k, dim=-1)
kth = values[..., -1:]
values = values.masked_fill(values <= kth, float("-inf"))
perm = torch.argsort(indices, dim=-1)
values, order = torch.sort(values.gather(-1, perm), descending=True, dim=-1, stable=True)
indices = indices.gather(-1, perm).gather(-1, order)
tied = (logits == kth).sum(dim=-1, keepdim=True)
else:
values, indices = torch.sort(logits, descending=True, dim=-1, stable=True)
kth = torch.full_like(values[..., :1], float("-inf"))
tied = torch.zeros_like(indices[..., :1])
# In float32, as softmax does: exponentials past the largest logit, then one division by their sum.
values, kth_value = values.float(), kth.float()
top = torch.maximum(values[..., :1], kth_value)
weights, tie_weight = (values - top).exp(), torch.where(tied > 0, (kth_value - top).exp(), 0.0)
total = weights.sum(dim=-1, keepdim=True) + tied * tie_weight
probs, tie_prob = weights / total, tie_weight / total
ties = tied
if 0.0 < float(top_p) < 1.0:
cumulative = probs.cumsum(dim=-1)
remove = cumulative > float(top_p)
remove[..., 1:] = remove[..., :-1].clone()
remove[..., 0] = False
probs = probs.masked_fill(remove, 0.0)
# Tied token j stays while the mass before it, ``before + j * tie_prob``, is at most top_p (the
# first candidate always does); the floor's estimate of the last j is corrected on that mass.
before = cumulative[..., -1:]
last = ((float(top_p) - before) / tie_prob.clamp(min=torch.finfo(torch.float32).tiny)).floor()
last = torch.where(before + (last + 1) * tie_prob <= float(top_p), last + 1, last)
last = torch.where(before + last * tie_prob > float(top_p), last - 1, last)
kept = torch.where(tie_prob > 0, last + 1, torch.zeros_like(last))
ties = torch.minimum(kept.clamp(min=0), tied.float()).long()
probs = torch.cat([probs, ties * tie_prob], dim=-1)
probs = probs / probs.sum(dim=-1, keepdim=True)
return probs, torch.cat([indices, torch.full_like(indices[..., :1], -1)], dim=-1), kth, ties
@staticmethod
def _duplex_draw_candidates(
logits: torch.Tensor,
candidates: tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor],
uniforms: torch.Tensor,
) -> torch.Tensor:
"""Per row, the inverse-CDF draw of ``_duplex_top_k_top_p_candidates(logits)`` at a uniform in [0, 1)."""
probs, indices, kth, ties = candidates
cdf = probs.cumsum(dim=-1)
target = uniforms.view(-1, 1).to(cdf.dtype) * cdf[..., -1:]
slot = torch.searchsorted(cdf, target, right=True).clamp(max=probs.shape[-1] - 1)
token = indices.gather(-1, slot)
# In the tie slot, the j-th lowest-index tied token, j uniform over the kept ties.
block = probs[..., -1:]
share = (target - (cdf[..., -1:] - block)) / block.clamp(min=torch.finfo(cdf.dtype).tiny) * ties
j = torch.minimum(share.floor().clamp(min=0), (ties - 1).clamp(min=0).to(share.dtype)).long()
tied = logits == kth
tie_token = (tied & (tied.cumsum(dim=-1) == j + 1)).int().argmax(dim=-1, keepdim=True)
return torch.where(slot == probs.shape[-1] - 1, tie_token, token).squeeze(-1)
@staticmethod
def _duplex_boundary_chunk_eos_probs(logits: torch.Tensor, chunk_eos_id: int) -> torch.Tensor:
"""Per-row ``softmax(logits)[:, chunk_eos_id]`` as ``exp(l_eos - logsumexp(l))``."""
return torch.exp(logits[:, chunk_eos_id] - torch.logsumexp(logits, dim=-1))
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""Load weights for all components of the omni model."""
loaded_weights = set()
@@ -1266,4 +1713,19 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
talker_loaded = add_prefix_to_loaded_weights(talker_loaded, "talker")
loaded_weights.update(talker_loaded)
self._capture_duplex_audio_graphs()
return loaded_weights
def _capture_duplex_audio_graphs(self) -> None:
"""Capture the duplex streaming audio encoder graphs while the model loads.
Inside load_model their memory counts as model memory, so the KV budget leaves room for it.
A lazy capture on the first duplex request allocates past gpu_memory_utilization instead,
which OOMs the stages sharing the GPU.
"""
model_config = getattr(self.vllm_config, "model_config", None)
if self.thinker is None or getattr(model_config, "session_mode", "turn") != "duplex":
return
if self._module_device(self.thinker).type != "cuda":
return
self._duplex_data_plane_helper().build_audio_cuda_graph()
@@ -18,6 +18,7 @@
# limitations under the License.
import inspect
import itertools
import math
import os
import warnings
@@ -138,6 +139,13 @@ def _encode_tokens(tokenizer: Any, prompt: str) -> list[int]:
from vllm.utils.tensor_schema import TensorSchema, TensorShape
from vllm_omni.model_executor.models.minicpmo_4_5 import streaming_audio_encoder as streaming_audio
from vllm_omni.model_executor.models.minicpmo_4_5 import streaming_audio_encoder_graph as streaming_audio_graph
from vllm_omni.model_executor.models.minicpmo_4_5.vision_fused import (
VisionGraphEncoder,
encode_packed_fused,
supports_fused_layers,
)
from vllm_omni.model_executor.models.model_local_kv import (
ModelLocalKVScope,
ModelLocalKVSpec,
@@ -952,6 +960,8 @@ class SiglipVisionEmbeddings(nn.Module):
self.num_patches = self.num_patches_per_side**2
self.num_positions = self.num_patches
self.position_embedding = nn.Embedding(self.num_positions, self.embed_dim)
# (h, w, device) -> position ids of that patch grid, see layout_position_ids.
self._layout_position_ids: dict[tuple[int, int, str], torch.Tensor] = {}
def _create_grid_position_ids(
self,
@@ -1018,6 +1028,19 @@ class SiglipVisionEmbeddings(nn.Module):
return position_ids.to(device=device)
def layout_position_ids(self, patch_grid_height: int, patch_grid_width: int, device: torch.device) -> torch.Tensor:
"""Position ids of one ``(h, w)`` patch grid on ``device``: ``_create_position_ids``'s, cached."""
cache = self._layout_position_ids
key = (int(patch_grid_height), int(patch_grid_width), str(device))
ids = cache.get(key)
if ids is None:
if len(cache) >= 1024:
cache.clear()
boundaries = torch.arange(1 / self.num_patches_per_side, 1.0, 1 / self.num_patches_per_side)
ids = self._create_grid_position_ids(key[0], key[1], boundaries).to(device=device)
cache[key] = ids
return ids
def forward(
self,
pixel_values: torch.FloatTensor,
@@ -1112,6 +1135,51 @@ class SiglipAttention(nn.Module):
return attn_output, attn_weights
def _attend_unpadded(self, query: torch.Tensor, key: torch.Tensor, value: torch.Tensor) -> torch.Tensor:
"""Attention over ``(n, L, heads, head_dim)`` items with no padding; same layout out."""
query, key, value = (x.transpose(1, 2) for x in (query, key, value))
attn_weights = torch.matmul(query, key.transpose(2, 3)) * self.scale
attn_weights = nn.functional.softmax(attn_weights, dim=-1, dtype=torch.float32).to(query.dtype)
return torch.matmul(attn_weights, value).transpose(1, 2)
def forward_packed(self, hidden_states: torch.Tensor, seq_groups: Sequence[tuple[int, int, int]]) -> torch.Tensor:
"""Self-attention over packed ``(tokens, embed)`` items, one mask-free call per ``seq_groups`` run.
``seq_groups`` holds contiguous ``(start_token, num_items, seq_len)`` runs of unpadded items.
"""
states = [projection(hidden_states) for projection in (self.q_proj, self.k_proj, self.v_proj)]
attn_output = torch.empty_like(states[0])
for start, num_items, seq_len in seq_groups:
end, shape = start + num_items * seq_len, (num_items, seq_len, self.num_heads, self.head_dim)
attn_output[start:end].view(shape).copy_(self._attend_unpadded(*(x[start:end].view(shape) for x in states)))
return self.out_proj(attn_output)
class SiglipSdpaAttention(SiglipAttention):
"""SigLIP attention through ``F.scaled_dot_product_attention`` (adapted from vllm-omni#5385)."""
def forward(
self,
hidden_states: torch.Tensor,
attention_mask: torch.Tensor | None = None,
output_attentions: bool | None = False,
) -> tuple[torch.Tensor, torch.Tensor | None]:
if output_attentions:
return super().forward(hidden_states, attention_mask=attention_mask, output_attentions=output_attentions)
batch_size, q_len, _ = hidden_states.size()
shape = (batch_size, q_len, self.num_heads, self.head_dim)
states = [projection(hidden_states).view(shape) for projection in (self.q_proj, self.k_proj, self.v_proj)]
attn_output = self._attend_unpadded(*states, attn_mask=attention_mask)
return self.out_proj(attn_output.reshape(batch_size, q_len, self.embed_dim)), None
def _attend_unpadded(
self, query: torch.Tensor, key: torch.Tensor, value: torch.Tensor, attn_mask: torch.Tensor | None = None
) -> torch.Tensor:
query, key, value = (x.transpose(1, 2) for x in (query, key, value))
return F.scaled_dot_product_attention(
query, key, value, attn_mask=attn_mask, dropout_p=self.dropout if self.training else 0.0, scale=self.scale
).transpose(1, 2)
class SiglipFlashAttention2(SiglipAttention):
"""
@@ -1329,6 +1397,10 @@ class SiglipFlashAttention2(SiglipAttention):
(max_seqlen_in_batch_q, max_seqlen_in_batch_k),
)
def _attend_unpadded(self, query: torch.Tensor, key: torch.Tensor, value: torch.Tensor) -> torch.Tensor:
# Flash attention consumes the (n, L, heads, head_dim) layout directly.
return self._flash_attention_forward(query, key, value, None, query.shape[1])
# Copied from transformers.models.clip.modeling_clip.CLIPMLP with CLIP->Siglip
class SiglipMLP(nn.Module):
@@ -1352,7 +1424,8 @@ class SiglipEncoderLayer(nn.Module):
super().__init__()
self.embed_dim = config.hidden_size
self._use_flash_attention_2 = config._attn_implementation == "flash_attention_2"
self.self_attn = SiglipAttention(config) if not self._use_flash_attention_2 else SiglipFlashAttention2(config)
attention_classes = {"flash_attention_2": SiglipFlashAttention2, "sdpa": SiglipSdpaAttention}
self.self_attn = attention_classes.get(config._attn_implementation, SiglipAttention)(config)
self.layer_norm1 = nn.LayerNorm(self.embed_dim, eps=config.layer_norm_eps)
self.mlp = SiglipMLP(config)
self.layer_norm2 = nn.LayerNorm(self.embed_dim, eps=config.layer_norm_eps)
@@ -1404,6 +1477,15 @@ class SiglipEncoderLayer(nn.Module):
return outputs
def forward_packed(self, hidden_states: torch.Tensor, seq_groups: Sequence[tuple[int, int, int]]) -> torch.Tensor:
"""``forward`` over packed ``(tokens, embed)`` items; see ``SiglipAttention.forward_packed``."""
residual = hidden_states
hidden_states = self.self_attn.forward_packed(self.layer_norm1(hidden_states), seq_groups)
hidden_states = residual + hidden_states
residual = hidden_states
hidden_states = self.mlp(self.layer_norm2(hidden_states))
return residual + hidden_states
class SiglipPreTrainedModel(PreTrainedModel):
"""
@@ -1570,8 +1652,14 @@ class SiglipEncoder(nn.Module):
class SiglipVisionTransformer(SiglipPreTrainedModel):
config_class = SiglipVisionConfig
main_input_name = "pixel_values"
# Transformers 5 spells the flash flag ``_supports_flash_attn``; SDPA is the
# default vision kernel (see MiniCPMO45OmniLLMForConditionalGeneration).
_supports_flash_attn = True
_supports_flash_attn_2 = True
_supports_sdpa = True
_no_split_modules = []
# ``forward_packed`` through ``vision_fused.encode_packed_fused``; set by the model.
fused_layers = False
def __init__(self, config: SiglipVisionConfig):
super().__init__(config)
@@ -1687,6 +1775,35 @@ class SiglipVisionTransformer(SiglipPreTrainedModel):
attentions=encoder_outputs.attentions,
)
def forward_packed(self, pixel_values: torch.Tensor, layouts: Sequence[tuple[int, int, int]]) -> torch.Tensor:
"""The padded ``forward`` per item, without padding, over items packed along the patch axis.
``pixel_values`` is ``(1, C, patch, tokens * patch)``, every item's strip in order (the stride-``patch``
embedding cuts at item boundaries); ``layouts`` lists ``(h, w, count)`` runs. Returns ``(tokens, embed)``.
"""
embeddings = self.embeddings
device = embeddings.position_embedding.weight.device
position_ids = [
embeddings.layout_position_ids(height, width, device).repeat(count) for height, width, count in layouts
]
position_ids = position_ids[0] if len(position_ids) == 1 else torch.cat(position_ids)
patch_embeds = embeddings.patch_embedding(pixel_values).flatten(2).transpose(1, 2)[0]
hidden_states = patch_embeds + embeddings.position_embedding(position_ids)
seq_groups: list[tuple[int, int, int]] = []
start = 0
# Adjacent equal-length grids share one attention call.
for seq_len, runs in itertools.groupby(layouts, key=lambda layout: layout[0] * layout[1]):
count = sum(run[2] for run in runs)
seq_groups.append((start, count, seq_len))
start += count * seq_len
if self.fused_layers and hidden_states.is_cuda and supports_fused_layers(self):
return encode_packed_fused(self, hidden_states, seq_groups)
for encoder_layer in self.encoder.layers:
hidden_states = encoder_layer.forward_packed(hidden_states, seq_groups)
return self.post_layernorm(hidden_states)
# ============== Resampler Classes ==============
# coding=utf-8
@@ -1873,6 +1990,154 @@ class Resampler(nn.Module):
def _repeat(self, query, N: int):
return query.unsqueeze(1).repeat(1, N, 1)
def supports_packed(self) -> bool:
attn = self.attn
plain = not attn.batch_first and attn._qkv_same_embed_dim and attn.in_proj_weight is not None
return plain and attn.bias_k is None and attn.bias_v is None and not attn.add_zero_attn
def forward_packed(self, x: torch.Tensor, layouts: Sequence[tuple[int, int, int]]) -> torch.Tensor:
"""``forward`` without key padding for packed ``(tokens, kv_dim)`` features; returns ``(B, Q, D)``.
Each ``(h, w, count)`` run of equal grids attends in one mask-free call (host ints: no device sync).
"""
max_h = max(height for height, _, _ in layouts)
max_w = max(width for _, width, _ in layouts)
if max_h > self.max_size[0] or max_w > self.max_size[1]:
self.max_size = [max(max_h, self.max_size[0]), max(max_w, self.max_size[1])]
self._set_2d_pos_cache(self.max_size, x.device)
if self.pos_embed.device != x.device:
self.pos_embed = self.pos_embed.to(x.device)
x = self.ln_kv(self.kv_proj(x))
key_input = torch.empty_like(x)
start = 0
for height, width, count in layouts:
end = start + count * height * width
pos_embed = self.pos_embed[:height, :width, :].reshape(height * width, -1).to(x.dtype)
shape = (count, height * width, -1)
torch.add(x[start:end].view(shape), pos_embed, out=key_input[start:end].view(shape))
start = end
attn = self.attn
num_heads, head_dim = attn.num_heads, self.embed_dim // attn.num_heads
w_q, w_k, w_v = attn.in_proj_weight.chunk(3)
b_q, b_k, b_v = attn.in_proj_bias.chunk(3) if attn.in_proj_bias is not None else (None, None, None)
query = F.linear(self.ln_q(self.query), w_q, b_q)
keys = F.linear(key_input, w_k, b_k)
values = F.linear(x, w_v, b_v)
num_queries = query.shape[0]
query_heads = query.view(num_queries, num_heads, head_dim).transpose(0, 1)
eager = getattr(self, "attn_implementation", "sdpa") == "eager"
outputs: list[torch.Tensor] = []
start = 0
for height, width, count in layouts:
seq_len = height * width
end = start + count * seq_len
shape = (count, seq_len, num_heads, head_dim)
key_heads, value_heads = (t[start:end].view(shape).transpose(1, 2) for t in (keys, values))
q = query_heads.unsqueeze(0).expand(count, -1, -1, -1).contiguous()
if eager:
# Mirrors nn.MultiheadAttention's need_weights path used by ``forward``.
weights = torch.matmul(q / math.sqrt(head_dim), key_heads.transpose(-2, -1))
out = torch.matmul(F.softmax(weights, dim=-1), value_heads)
else:
out = F.scaled_dot_product_attention(q, key_heads, value_heads)
outputs.append(out.transpose(1, 2).reshape(count, num_queries, self.embed_dim))
start = end
out = outputs[0] if len(outputs) == 1 else torch.cat(outputs)
out = self.ln_post(attn.out_proj(out))
return out @ self.proj
def _vision_encode_paths(config: Any, *, encoder_graphs: bool) -> tuple[bool, bool]:
"""``(vision_packed_encode, vision_cuda_graph)`` from the HF config.
The packed path (_encode_vision_packed, vision_fused.py) serves the opt-in
``vision_fused_layers`` and ``vision_cuda_graph``; otherwise the padded
batch runs and replays the SigLIP graph of ``vpm._encoder_graph``.
``encoder_graphs`` (``encoder_cuda_graph`` and not ``--enforce-eager``)
gates the packed graphs too. ``vision_packed_encode`` overrides the path,
e.g. to measure packed eager encoding.
"""
graph = bool(getattr(config, "vision_cuda_graph", False)) and encoder_graphs
packed = getattr(config, "vision_packed_encode", None)
if packed is None:
return bool(getattr(config, "vision_fused_layers", False)) or graph, graph
return bool(packed), graph and bool(packed)
def _packed_vision_layouts(
pixel_values: Sequence[torch.Tensor],
tgt_sizes: torch.Tensor | Sequence[Sequence[int]],
patch_size: int,
) -> list[tuple[int, int]] | None:
"""Host ``(h, w)`` patch grid per slice, or ``None`` on a mismatch; the packed path's only sync."""
sizes = tgt_sizes.tolist() if isinstance(tgt_sizes, torch.Tensor) else [list(size) for size in tgt_sizes]
if len(sizes) != len(pixel_values) or not sizes:
return None
layouts: list[tuple[int, int]] = []
for pixels, size in zip(pixel_values, sizes):
if len(size) != 2:
return None
height, width = int(size[0]), int(size[1])
if (
min(height, width) <= 0
or pixels.ndim != 3
or pixels.shape[-2:] != (patch_size, height * width * patch_size)
):
return None
layouts.append((height, width))
return layouts
def _plan_packed_vision_chunks(
layouts: Sequence[tuple[int, int]], max_items: int
) -> list[tuple[list[int], list[tuple[int, int, int]]]]:
"""Per ``max_items``-slice chunk, its slice indices and ``(h, w, count)`` runs, equal grids adjacent.
Sorting by ``h * w`` first also puts equal sequence lengths together, sharing an attention call.
"""
order = sorted(range(len(layouts)), key=lambda i: (layouts[i][0] * layouts[i][1], layouts[i]))
chunks: list[tuple[list[int], list[tuple[int, int, int]]]] = []
for begin in range(0, len(order), max(1, max_items)):
indices = order[begin : begin + max(1, max_items)]
grids = itertools.groupby(layouts[index] for index in indices)
chunks.append((indices, [(height, width, len(list(run))) for (height, width), run in grids]))
return chunks
def _encode_vision_packed(
vpm: "SiglipVisionTransformer",
resampler: Resampler,
pixel_values: Sequence[torch.Tensor],
layouts: Sequence[tuple[int, int]],
max_items: int,
graph_encoder: VisionGraphEncoder | None = None,
) -> torch.Tensor:
"""SigLIP + resampler over unpadded slices packed by grid; ``(num_slices, query_num, embed)`` in input order.
No slice is padded, so its output does not depend on the others; single-grid chunks replay graphs.
"""
weight = vpm.embeddings.patch_embedding.weight
outputs: list[torch.Tensor] = []
packed_order: list[int] = []
for indices, runs in _plan_packed_vision_chunks(layouts, max_items):
parts = [pixel_values[index] for index in indices]
pixels = parts[0] if len(parts) == 1 else torch.cat(parts, dim=-1)
pixels = pixels.to(device=weight.device, dtype=weight.dtype).unsqueeze(0)
out = graph_encoder.encode(pixels, *runs[0]) if graph_encoder is not None and len(runs) == 1 else None
if out is None:
out = resampler.forward_packed(vpm.forward_packed(pixels, runs), runs)
outputs.append(out)
packed_order.extend(indices)
out = outputs[0] if len(outputs) == 1 else torch.cat(outputs)
if packed_order != list(range(len(packed_order))):
inverse = torch.tensor(packed_order, dtype=torch.long).argsort()
out = out.index_select(0, inverse.to(device=out.device, non_blocking=True))
return out
class MultiheadAttention(nn.MultiheadAttention):
def __init__(
@@ -3944,7 +4209,10 @@ class MiniCPMO45OmniLLMForConditionalGeneration(nn.Module, SupportsMultiModal, S
multimodal_config = vllm_config.model_config.multimodal_config
self.config = config
self.multimodal_config = multimodal_config
# Duplex session cap of this deployment; sizes the audio-encoder graph
# grid when ``duplex_audio_encoder_cuda_graph_batch_sizes_from_sessions``.
self._duplex_max_sessions = int(getattr(vllm_config.model_config, "duplex_max_sessions", 1) or 1)
self._enforce_eager = bool(vllm_config.model_config.enforce_eager)
# Model-local opt-out for A/B measurements; --enforce-eager always wins.
encoder_graphs = (
bool(getattr(config, "encoder_cuda_graph", True)) and not vllm_config.model_config.enforce_eager
@@ -3970,10 +4238,15 @@ class MiniCPMO45OmniLLMForConditionalGeneration(nn.Module, SupportsMultiModal, S
# Initialize vision encoder (SigLIP)
if multimodal_config.get_limit_per_prompt("image"):
# Set attention implementation
if hasattr(vllm_config, "model_config") and hasattr(vllm_config.model_config, "_attn_implementation"):
# (an engine override, else the vision config, else SDPA: eager math without the (B, H, L, L) scores)
if getattr(getattr(vllm_config, "model_config", None), "_attn_implementation", None) is not None:
config.vision_config._attn_implementation = vllm_config.model_config._attn_implementation
else:
config.vision_config._attn_implementation = "eager"
vision_implementation = getattr(config.vision_config, "_attn_implementation", None)
config.vision_config._attn_implementation = vision_implementation or "sdpa"
if config.vision_config._attn_implementation == "flash_attention_2" and not is_flash_attn_2_available():
logger.warning("flash_attention_2 requested for the MiniCPM-o vision tower but unavailable; using sdpa")
config.vision_config._attn_implementation = "sdpa"
self.vpm = SiglipVisionTransformer(config.vision_config)
if encoder_graphs:
@@ -4029,8 +4302,13 @@ class MiniCPMO45OmniLLMForConditionalGeneration(nn.Module, SupportsMultiModal, S
kv_dim=vision_dim, # Vision encoder dimension
adaptive=True, # Enable adaptive resampling
)
self.resampler.attn_implementation = config.vision_config._attn_implementation
else:
self.resampler = None
if self.vpm is not None:
self.vpm.fused_layers = bool(getattr(config, "vision_fused_layers", False))
self.vision_packed_encode, self.vision_cuda_graph = _vision_encode_paths(config, encoder_graphs=encoder_graphs)
self._vision_graph_encoder: VisionGraphEncoder | None = None
# Initialize audio encoder (APM) and audio projection
if getattr(config, "init_audio", True) and hasattr(config, "audio_config") and config.audio_config is not None:
@@ -4309,6 +4587,19 @@ class MiniCPMO45OmniLLMForConditionalGeneration(nn.Module, SupportsMultiModal, S
pixel_values = data["pixel_values"]
tgt_sizes = data["tgt_sizes"]
vpm = self.vpm
packed = getattr(self, "vision_packed_encode", False) and hasattr(vpm, "forward_packed")
if packed and hasattr(self.resampler, "forward_packed") and self.resampler.supports_packed():
layouts = _packed_vision_layouts(pixel_values, tgt_sizes, vpm.embeddings.patch_size)
if layouts is not None:
graph_encoder = None
if getattr(self, "vision_cuda_graph", False) and pixel_values[0].is_cuda:
if self._vision_graph_encoder is None:
self._vision_graph_encoder = VisionGraphEncoder(vpm, self.resampler)
graph_encoder = self._vision_graph_encoder
max_items = max(1, int(self.config.vision_batch_size))
return _encode_vision_packed(vpm, self.resampler, pixel_values, layouts, max_items, graph_encoder)
B = len(pixel_values)
P = pixel_values[0].shape[-2]
L = max(item.shape[-1] for item in pixel_values)
@@ -4488,8 +4779,8 @@ class MiniCPMO45OmniLLMForConditionalGeneration(nn.Module, SupportsMultiModal, S
audio_embeds = self._encode_audio_features(wavforms, audio_attention_mask)
_, feature_lens_after_pooling = self._get_feat_extract_output_lengths(audio_feature_lens)
num_audio_tokens = feature_lens_after_pooling
# One host read of every length instead of an implicit .item() per slice bound.
num_audio_tokens = feature_lens_after_pooling.tolist()
final_audio_embeds = list[torch.Tensor]()
idx = 0
@@ -4590,18 +4881,23 @@ class MiniCPMO45OmniLLMForConditionalGeneration(nn.Module, SupportsMultiModal, S
device=wavforms.device,
)
# As in get_audio_embedding: every layer's output only to pick the last one keeps 25 activations alive.
selects_final_layer = self.audio_encoder_layer == -1
audio_outputs = self.apm(
wavforms,
past_key_values=self.audio_past_key_values,
use_cache=True,
output_hidden_states=True,
output_hidden_states=not selects_final_layer,
attention_mask=audio_attention_mask,
use_extra_context=use_extra_context,
prefix_extra_frames=prefix_extra_frames,
suffix_extra_frames=suffix_extra_frames,
cnn_min_length=cnn_min_length,
)
audio_states = audio_outputs.hidden_states[self.audio_encoder_layer]
if selects_final_layer:
audio_states = audio_outputs.last_hidden_state
else:
audio_states = audio_outputs.hidden_states[self.audio_encoder_layer]
self.audio_past_key_values = audio_outputs.past_key_values
audio_embeds = self.audio_projection_layer(audio_states)
@@ -4610,16 +4906,97 @@ class MiniCPMO45OmniLLMForConditionalGeneration(nn.Module, SupportsMultiModal, S
audio_embeds = audio_embeds.transpose(1, 2)
_, feature_lens_after_pooling = self._get_feat_extract_output_lengths(audio_feature_lens)
# One host read of the lengths instead of a device sync per slice bound.
pooled_lens = feature_lens_after_pooling.tolist()
final_audio_embeds: list[list[torch.Tensor]] = []
idx = 0
for i in range(len(audio_feature_lens_raw)):
target_audio_embeds = []
for _ in range(len(audio_feature_lens_raw[i])):
target_audio_embeds.append(audio_embeds[idx, : feature_lens_after_pooling[idx], :])
target_audio_embeds.append(audio_embeds[idx, : pooled_lens[idx], :])
idx += 1
final_audio_embeds.append(target_audio_embeds)
return final_audio_embeds
def supports_streaming_audio_batch(self) -> bool:
"""Whether ``get_audio_embedding_streaming_batch`` can serve this encoder."""
if self.apm is None or self.audio_projection_layer is None or self.audio_avg_pooler is None:
return False
requested = getattr(self.config, "duplex_audio_encoder_batching", None)
if requested is not None and not bool(requested):
return False
reason = streaming_audio.streaming_batch_unsupported_reason(
self.apm, audio_encoder_layer=self.audio_encoder_layer
)
if reason is not None:
logger.info("MiniCPM-o streaming audio encoder stays per-session: %s", reason)
return False
return requested is not None or self.apm.conv1.weight.device.type in ("cuda", "cpu")
def build_streaming_audio_graph_encoder(self, *, unit_frames: int) -> bool:
"""Capture CUDA graphs of the streaming encoder's steady unit. Failures stay eager."""
self._duplex_audio_cuda_graph_encoder = None
config = self.config
enabled = bool(getattr(config, "duplex_audio_encoder_cuda_graph", True)) and not self._enforce_eager
if not self.supports_streaming_audio_batch() or not enabled:
return False
if self.apm.conv1.weight.device.type != "cuda":
logger.info("MiniCPM-o streaming audio encoder CUDA graph needs a CUDA device; staying eager")
return False
batch_sizes = getattr(config, "duplex_audio_encoder_cuda_graph_batch_sizes", None)
if getattr(config, "duplex_audio_encoder_cuda_graph_batch_sizes_from_sessions", False) is True:
# Overrides the explicit list: the grid follows duplex max_sessions.
batch_sizes = streaming_audio_graph.batch_sizes_for_sessions(getattr(self, "_duplex_max_sessions", 1))
allocated = torch.accelerator.memory_allocated(self.apm.conv1.weight.device)
try:
wrapper = streaming_audio_graph.StreamingAudioGraphEncoder(
self.apm,
self.audio_projection_layer,
self.audio_avg_pooler,
unit_frames=int(unit_frames),
pool_step=int(config.audio_pool_step),
batch_sizes=batch_sizes,
cache_buckets=getattr(config, "duplex_audio_encoder_cuda_graph_cache_buckets", None),
page_positions=self._duplex_audio_kv_page_positions(),
pinned_h2d=getattr(config, "duplex_audio_encoder_pinned_h2d", False) is True,
)
wrapper.capture()
except Exception:
logger.exception("MiniCPM-o streaming audio encoder CUDA graph capture failed; staying eager")
return False
self._duplex_audio_cuda_graph_encoder = wrapper
logger.info(
"Captured MiniCPM-o streaming audio encoder CUDA graphs: unit_frames=%d batch_sizes=%s "
"cache_buckets=%s pinned_h2d=%s, took %.2f GiB",
unit_frames,
wrapper.batch_sizes,
wrapper.cache_buckets,
wrapper.pinned_h2d,
(torch.accelerator.memory_allocated(self.apm.conv1.weight.device) - allocated) / (1 << 30),
)
return True
def get_audio_embedding_streaming_batch(
self,
chunks: Sequence[Any],
) -> tuple[list[torch.Tensor | None], list[Any]]:
"""Batched ``get_audio_embedding_streaming`` (embeddings, updated caches); graphs take steady rows."""
graph = getattr(self, "_duplex_audio_cuda_graph_encoder", None)
if graph is not None:
return graph.encode(chunks)
return streaming_audio.encode_streaming_audio_batch(
self.apm,
self.audio_projection_layer,
self.audio_avg_pooler,
chunks,
pool_step=int(self.config.audio_pool_step),
page_positions=self._duplex_audio_kv_page_positions(),
)
def _duplex_audio_kv_page_positions(self) -> int:
page_positions = getattr(self.config, "duplex_audio_kv_page_positions", None)
return int(page_positions or streaming_audio.DEFAULT_KV_PAGE_POSITIONS)
def _process_audio_input(
self,
audio_input: MiniCPMOAudioInputs,
@@ -0,0 +1,312 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Batched streaming Whisper encoder for MiniCPM-o duplex sessions."""
from __future__ import annotations
import math
from collections.abc import Sequence
from dataclasses import dataclass, field
import torch
from torch import nn
#: Growth step of a session buffer, in encoder positions (a 1 s unit is 50):
#: at most five reallocations per 1500-position cycle, ~12 MiB average slack.
DEFAULT_KV_PAGE_POSITIONS = 250
_SUPPORTED_ATTENTION = ("sdpa", "eager")
_SUPPORTED_DTYPES = (torch.bfloat16, torch.float32)
@dataclass(eq=False, slots=True)
class StreamingAudioKVCache:
"""One session's streaming-encoder self-attention cache, written in place.
Layout ``[layers, 2 (key/value), capacity, embed_dim]``: token-major, so appending a unit is one contiguous
copy per layer and attention reads a strided ``[1, heads, length, head_dim]`` view without materializing it.
Capacity grows in ``page_positions`` steps up to ``max_positions``.
"""
num_layers: int
embed_dim: int
num_heads: int
max_positions: int
page_positions: int = DEFAULT_KV_PAGE_POSITIONS
length: int = field(default=0, init=False)
_buffer: torch.Tensor | None = field(default=None, init=False, repr=False)
def __post_init__(self) -> None:
if min(self.num_layers, self.embed_dim, self.num_heads, self.max_positions, self.page_positions) <= 0:
raise ValueError("streaming audio KV cache dimensions must be positive")
@classmethod
def for_encoder(cls, encoder: nn.Module, page_positions: int) -> StreamingAudioKVCache:
config, positions = encoder.config, int(encoder.embed_positions.weight.shape[0])
return cls(
len(encoder.layers), int(config.d_model), int(config.encoder_attention_heads), positions, page_positions
)
@property
def capacity(self) -> int:
return 0 if self._buffer is None else int(self._buffer.shape[2])
def reserve(self, positions: int, *, dtype: torch.dtype, device: torch.device) -> None:
"""Make room for ``positions`` entries, keeping the committed prefix."""
if positions <= self.capacity:
return
pages = math.ceil(positions / self.page_positions) * self.page_positions
capacity = max(positions, min(self.max_positions, pages))
buffer = torch.empty((self.num_layers, 2, capacity, self.embed_dim), dtype=dtype, device=device)
if self._buffer is not None and self.length:
buffer[:, :, : self.length].copy_(self._buffer[:, :, : self.length])
self._buffer = buffer
def history(self, length: int) -> torch.Tensor:
"""``[layers, 2, length, embed_dim]`` rows ``[0, length)``, every layer at once."""
assert self._buffer is not None
return self._buffer[:, :, :length]
def commit(self, offset: int, new_kv: torch.Tensor) -> None:
"""Write ``[layers, 2, positions, embed_dim]`` rows at ``offset``; the caller advances ``length``."""
assert self._buffer is not None
positions = int(new_kv.shape[2])
self._buffer[:, :, offset : offset + positions].copy_(new_kv)
def layer_views(self, start: int, end: int) -> tuple[tuple[torch.Tensor, ...], ...]:
"""Per layer, ``(key_rows, value_rows, key_heads, value_heads)``: the ``[end - start, embed_dim]``
write targets and the ``[1, heads, end, head_dim]`` attention inputs over ``[0, end)``."""
assert self._buffer is not None
head_dim = self.embed_dim // self.num_heads
heads = (
self._buffer[:, :, :end]
.view(self.num_layers, 2, end, self.num_heads, head_dim)
.transpose(2, 3)
.unsqueeze(2)
)
rows = self._buffer[:, :, start:end]
return rows[:, 0].unbind(0), rows[:, 1].unbind(0), heads[:, 0].unbind(0), heads[:, 1].unbind(0)
def to_legacy_cache(self):
"""The committed history as the ``EncoderDecoderCache`` the per-session path grows."""
from transformers.cache_utils import DynamicCache, EncoderDecoderCache
self_attention = DynamicCache()
if self.length:
_, _, keys, values = self.layer_views(0, self.length)
for layer in range(self.num_layers):
self_attention.update(keys[layer].contiguous(), values[layer].contiguous(), layer)
return EncoderDecoderCache(self_attention, DynamicCache())
@dataclass(frozen=True, slots=True)
class StreamingAudioChunk:
"""One session's ready unit: log-mel frames plus the state it continues."""
#: ``[n_mels, frames]`` or ``[1, n_mels, frames]`` log-mel features, host or device.
features: torch.Tensor
#: Session cache; ``None`` starts a new history.
cache: StreamingAudioKVCache | None
prefix_extra_frames: int = 0
suffix_extra_frames: int = 0
use_extra_context: bool = True
#: ``audio_feature_lens`` of the unit when it differs from ``features.shape[-1]``.
feature_length: int | None = None
@dataclass(slots=True)
class _Row:
index: int
frames: int
start: int
length: int
pooled_length: int
cache: StreamingAudioKVCache
past: int = 0
offset: int = 0
@property
def total(self) -> int:
return self.past + self.length
def _extra_context_trim(frames: int) -> int:
return (int(frames) + 1) // 2 if frames > 0 else 0
def streaming_batch_unsupported_reason(encoder: nn.Module, *, audio_encoder_layer: int | None) -> str | None:
"""Why ``encoder`` cannot take the batched path, or ``None`` when it can."""
if audio_encoder_layer != -1:
return "only the final encoder layer output is batched"
if encoder.training:
return "training mode (dropout / layerdrop)"
weight = getattr(getattr(encoder, "conv1", None), "weight", None)
if weight is None or weight.dtype not in _SUPPORTED_DTYPES:
# float16 needs the per-session inf/nan clamp, which is per tensor.
return f"unsupported encoder dtype {getattr(weight, 'dtype', None)}"
layers = getattr(encoder, "layers", None)
if not layers:
return "encoder has no layers"
for layer in layers:
attention = getattr(layer, "self_attn", None)
if attention is None or not all(
hasattr(attention, name) for name in ("q_proj", "k_proj", "v_proj", "out_proj", "scaling")
):
return "encoder layers are not Whisper self-attention layers"
implementation = getattr(encoder.config, "_attn_implementation", None) or "eager"
if implementation not in _SUPPORTED_ATTENTION:
return f"attention implementation {implementation!r} is not batched"
return None
def _attend(query: torch.Tensor, key: torch.Tensor, value: torch.Tensor, mask: torch.Tensor, implementation: str):
"""Whisper self-attention as the per-session path calls it; ``query`` already carries ``scaling``."""
if implementation == "sdpa":
return torch.nn.functional.scaled_dot_product_attention(
query, key, value, attn_mask=mask, dropout_p=0.0, scale=1.0
)
weights = torch.matmul(query, key.transpose(2, 3)) + mask
return torch.matmul(torch.nn.functional.softmax(weights, dim=-1), value)
def _stack_features(rows: list[_Row], features: list[torch.Tensor], device: torch.device) -> torch.Tensor:
"""Zero-padded ``[B, mels, max_frames]`` on ``device`` with one host-to-device copy."""
max_frames = max(row.frames for row in rows)
on_host = all(feature.device.type == "cpu" for feature in features)
pin = on_host and device.type == "cuda"
batch = torch.zeros(
(len(rows), int(features[0].shape[0]), max_frames),
dtype=features[0].dtype,
device="cpu" if on_host else device,
pin_memory=pin,
)
for slot, (row, feature) in enumerate(zip(rows, features, strict=True)):
batch[slot, :, : row.frames].copy_(feature)
# Pinned staging keeps the host from waiting on queued GPU work; the
# caching host allocator holds the buffer until the copy completes.
return batch.to(device, non_blocking=pin)
def _position_rows(encoder: nn.Module, rows: list[_Row]) -> torch.Tensor:
"""Learned positions ``past .. past + n`` per row; past the table, repeat its last row."""
table = encoder.embed_positions.weight
capacity = int(table.shape[0])
pieces: list[torch.Tensor] = []
for row in rows:
end = row.past + row.length
if end <= capacity:
pieces.append(table[row.past : end])
else:
pieces.append(table[row.past :])
pieces.append(table[-1:].expand(end - max(row.past, capacity), -1))
return pieces[0] if len(pieces) == 1 else torch.cat(pieces)
@torch.inference_mode()
def encode_streaming_audio_batch(
encoder: nn.Module,
projection: nn.Module,
pooler: nn.Module,
chunks: Sequence[StreamingAudioChunk],
*,
pool_step: int,
page_positions: int = DEFAULT_KV_PAGE_POSITIONS,
) -> tuple[list[torch.Tensor | None], list[StreamingAudioKVCache | None]]:
"""Encode one streaming unit per session in one encoder pass."""
implementation = getattr(encoder.config, "_attn_implementation", None) or "eager"
weight = encoder.conv1.weight
dtype, device = weight.dtype, weight.device
max_positions = int(encoder.embed_positions.weight.shape[0])
embed_dim, num_heads = int(encoder.config.d_model), int(encoder.config.encoder_attention_heads)
outputs: list[torch.Tensor | None] = [None] * len(chunks)
caches: list[StreamingAudioKVCache | None] = [chunk.cache for chunk in chunks]
rows: list[_Row] = []
features: list[torch.Tensor] = []
for index, chunk in enumerate(chunks):
feature = chunk.features
if feature.ndim == 3:
if feature.shape[0] != 1:
raise ValueError("a streaming chunk carries exactly one audio")
feature = feature[0]
frames = int(feature.shape[-1])
conv_length = (frames - 1) // 2 + 1
prefix = _extra_context_trim(chunk.prefix_extra_frames) if chunk.use_extra_context else 0
suffix = _extra_context_trim(chunk.suffix_extra_frames) if chunk.use_extra_context else 0
length = conv_length - prefix - suffix
if frames <= 0 or length <= 0:
continue
feature_length = frames if chunk.feature_length is None else int(chunk.feature_length)
pooled_length = ((feature_length - 1) // 2 + 1 - pool_step) // pool_step + 1
cache = chunk.cache
if cache is None or cache.length + length >= max_positions:
cache = StreamingAudioKVCache.for_encoder(encoder, page_positions)
rows.append(_Row(index, frames, prefix, length, pooled_length, cache, past=cache.length))
features.append(feature)
if not rows:
return outputs, caches
offset = 0
for row in rows:
row.offset = offset
offset += row.length
row.cache.reserve(row.total, dtype=dtype, device=device)
# CNN front end on the zero-padded batch, transformer layers on the packed rows.
mel = _stack_features(rows, features, device).to(dtype=dtype)
hidden = nn.functional.gelu(encoder.conv1(mel))
max_frames = int(mel.shape[-1])
for slot, row in enumerate(rows):
if row.frames < max_frames:
# conv2 must see zeros past each row's end, as its own padding would in the per-session call.
hidden[slot, :, row.frames :].zero_()
hidden = nn.functional.gelu(encoder.conv2(hidden)).permute(0, 2, 1)
pieces = [hidden[slot, row.start : row.start + row.length] for slot, row in enumerate(rows)]
hidden = pieces[0] if len(pieces) == 1 else torch.cat(pieces)
hidden = hidden + _position_rows(encoder, rows)
# Every per-row view is built once here, not per layer: at batch sizes
# that matter the host cost of view ops, not the GPU, bounds this loop.
views = [row.cache.layer_views(row.past, row.total) for row in rows]
lengths = [row.length for row in rows]
total_rows = sum(lengths)
head_dim = embed_dim // num_heads
# The per-session path passes an all-zero additive mask; keep it for the same attention kernel.
masks = [torch.zeros((1, 1, row.length, row.total), dtype=dtype, device=device) for row in rows]
for layer_index, layer in enumerate(encoder.layers):
attention = layer.self_attn
residual = hidden
normed = layer.self_attn_layer_norm(hidden)
query = attention.q_proj(normed) * attention.scaling
key, value = attention.k_proj(normed), attention.v_proj(normed)
destinations = [view[0][layer_index] for view in views] + [view[1][layer_index] for view in views]
torch._foreach_copy_(destinations, [*key.split(lengths), *value.split(lengths)])
queries = query.view(1, total_rows, num_heads, head_dim).transpose(1, 2).split(lengths, dim=2)
attended = [
_attend(row_query, view[2][layer_index], view[3][layer_index], mask, implementation)
for row_query, view, mask in zip(queries, views, masks, strict=True)
]
# [1, heads, rows, head_dim] per row -> packed [rows, embed_dim].
attended_rows = (attended[0] if len(attended) == 1 else torch.cat(attended, dim=2)).transpose(1, 2)
hidden = residual + attention.out_proj(attended_rows.reshape(total_rows, embed_dim))
residual = hidden
hidden = layer.activation_fn(layer.fc1(layer.final_layer_norm(hidden)))
hidden = residual + layer.fc2(hidden)
hidden = encoder.layer_norm(hidden)
embeds = projection(hidden)
if all(row.length % pool_step == 0 for row in rows):
pooled = pooler(embeds.transpose(0, 1).unsqueeze(0)).squeeze(0).transpose(0, 1)
for row in rows:
start = row.offset // pool_step
count = min(row.length // pool_step, row.pooled_length)
outputs[row.index] = pooled[start : start + count]
else:
for row in rows:
segment = embeds[row.offset : row.offset + row.length].transpose(0, 1).unsqueeze(0)
pooled = pooler(segment).squeeze(0).transpose(0, 1)
outputs[row.index] = pooled[: row.pooled_length]
for row in rows:
row.cache.length = row.total
caches[row.index] = row.cache
return outputs, caches
@@ -0,0 +1,259 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CUDA graphs of MiniCPM-o's streaming Whisper encoder for the steady unit shape."""
from __future__ import annotations
import functools
import itertools
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING
import torch
from torch import nn
from .streaming_audio_encoder import (
DEFAULT_KV_PAGE_POSITIONS,
StreamingAudioChunk,
StreamingAudioKVCache,
_attend,
_extra_context_trim,
encode_streaming_audio_batch,
)
if TYPE_CHECKING:
from torch.cuda import CUDAGraph
_NEG_INF = float("-inf")
# stage0.py builds every unit but a session's first with these trims.
STEADY_PREFIX_EXTRA_FRAMES = 2
STEADY_SUFFIX_EXTRA_FRAMES = 2
DEFAULT_GRAPH_BATCH_SIZES: tuple[int, ...] = (1, 2, 4, 8, 16, 32)
# Shared storage scales with the largest bucket only. 900 stays under ~3 GiB
# at batch 32 on MiniCPM-o 4.5 (bf16); history past it falls back to eager.
DEFAULT_GRAPH_CACHE_BUCKETS: tuple[int, ...] = (250, 500, 750, 900)
def batch_sizes_for_sessions(max_sessions: int) -> tuple[int, ...]:
"""Powers of two below ``max_sessions``, plus ``max_sessions`` itself."""
top = max(1, int(max_sessions))
sizes = [1 << i for i in range(top.bit_length()) if (1 << i) < top]
return normalize_buckets([*sizes, top])
def select_bucket(value: int, buckets: Sequence[int]) -> int | None:
return min((bucket for bucket in buckets if value <= bucket), default=None)
def normalize_buckets(values: Sequence[int]) -> tuple[int, ...]:
return tuple(sorted({int(v) for v in values if int(v) > 0}))
def steady_unit_length(unit_frames: int) -> int:
conv_length = (int(unit_frames) - 1) // 2 + 1
trim = _extra_context_trim(STEADY_PREFIX_EXTRA_FRAMES) + _extra_context_trim(STEADY_SUFFIX_EXTRA_FRAMES)
return conv_length - trim
def row_is_steady(chunk: StreamingAudioChunk, *, expected_frames: int) -> bool:
frames = int(chunk.features.shape[-1])
return (
chunk.cache is not None
and chunk.use_extra_context
and (int(chunk.prefix_extra_frames), int(chunk.suffix_extra_frames))
== (STEADY_PREFIX_EXTRA_FRAMES, STEADY_SUFFIX_EXTRA_FRAMES)
and frames == expected_frames
and (chunk.feature_length is None or int(chunk.feature_length) == frames)
)
@dataclass(slots=True)
class _Group:
index: int
cache_in: StreamingAudioKVCache
cache_out: StreamingAudioKVCache
past: int
class StreamingAudioGraphEncoder:
"""Pre-captured CUDA graphs of the streaming encoder's steady unit shape."""
def __init__(
self,
encoder: nn.Module,
projection: nn.Module,
pooler: nn.Module,
*,
unit_frames: int,
pool_step: int,
batch_sizes: Sequence[int] | None = None,
cache_buckets: Sequence[int] | None = None,
page_positions: int = DEFAULT_KV_PAGE_POSITIONS,
pinned_h2d: bool = False,
) -> None:
weight, config = encoder.conv1.weight, encoder.config
if weight.device.type != "cuda":
raise ValueError("StreamingAudioGraphEncoder requires a CUDA encoder")
self.encoder, self.projection, self.pooler = encoder, projection, pooler
self.device, self.dtype = weight.device, weight.dtype
self.pool_step, self.page_positions, self.pinned_h2d = int(pool_step), int(page_positions), bool(pinned_h2d)
self.embed_dim, self.num_heads = int(config.d_model), int(config.encoder_attention_heads)
self.max_positions = int(encoder.embed_positions.weight.shape[0])
self.implementation = getattr(config, "_attn_implementation", None) or "eager"
self.unit_frames = int(unit_frames)
self.unit_length = steady_unit_length(self.unit_frames)
nominal_pooled = ((self.unit_frames - 1) // 2 + 1 - self.pool_step) // self.pool_step + 1
self.pooled_length = min(nominal_pooled, self.unit_length // self.pool_step)
if self.unit_length <= 0 or self.pooled_length <= 0:
raise ValueError(f"unit_frames={unit_frames} pool_step={pool_step} leaves no steady unit output")
self.batch_sizes = normalize_buckets(batch_sizes or DEFAULT_GRAPH_BATCH_SIZES)
self.cache_buckets = normalize_buckets(cache_buckets or DEFAULT_GRAPH_CACHE_BUCKETS)
if not self.batch_sizes or not self.cache_buckets:
raise ValueError("StreamingAudioGraphEncoder needs at least one batch size and one cache bucket")
self.max_batch, self.max_cache_bucket = max(self.batch_sizes), max(self.cache_buckets)
# (batch, cache length) -> (graph, pooled output); every graph reads prefix views of one storage.
self._graphs: dict[tuple[int, int], tuple[CUDAGraph, torch.Tensor]] = {}
def _views(self, batch: int, cache_len: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
total = cache_len + self.unit_length
return (
self._mel[:batch],
self._cache[:, :, :batch, :total, :],
self._mask[:batch, :, :, :total],
self._position_ids[:batch],
)
def capture(self) -> None:
if torch.cuda.is_current_stream_capturing():
raise RuntimeError("cannot capture the streaming audio encoder graph mid-capture")
from vllm.platforms import current_platform
batch, total = self.max_batch, self.max_cache_bucket + self.unit_length
zeros = functools.partial(torch.zeros, dtype=self.dtype, device=self.device)
self._mel = zeros((batch, int(self.encoder.config.num_mel_bins), self.unit_frames))
self._cache = zeros((len(self.encoder.layers), 2, batch, total, self.embed_dim))
self._mask = zeros((batch, 1, self.unit_length, total))
self._position_ids = torch.zeros((batch, self.unit_length), dtype=torch.long, device=self.device)
pool = current_platform.get_global_graph_pool()
current_stream = torch.cuda.current_stream(self.device)
for key in itertools.product(self.batch_sizes, self.cache_buckets):
views = self._views(*key)
warmup_stream = torch.cuda.Stream(device=self.device)
warmup_stream.wait_stream(current_stream)
with torch.cuda.stream(warmup_stream), torch.inference_mode():
for _ in range(3):
self._forward(*views, key[1])
current_stream.wait_stream(warmup_stream)
graph = torch.cuda.CUDAGraph()
with torch.inference_mode(), torch.cuda.graph(graph, pool=pool):
self._graphs[key] = (graph, self._forward(*views, key[1]))
def _forward(
self,
mel: torch.Tensor,
cache: torch.Tensor,
mask: torch.Tensor,
position_ids: torch.Tensor,
cache_len: int,
) -> torch.Tensor:
encoder = self.encoder
batch = mel.shape[0]
hidden = nn.functional.gelu(encoder.conv1(mel))
hidden = nn.functional.gelu(encoder.conv2(hidden)).permute(0, 2, 1)
prefix = _extra_context_trim(STEADY_PREFIX_EXTRA_FRAMES)
hidden = hidden[:, prefix : prefix + self.unit_length, :]
hidden = hidden + encoder.embed_positions(position_ids)
head_dim = self.embed_dim // self.num_heads
for layer_index, layer in enumerate(encoder.layers):
attention = layer.self_attn
residual = hidden
normed = layer.self_attn_layer_norm(hidden)
query = attention.q_proj(normed) * attention.scaling
key, value = attention.k_proj(normed), attention.v_proj(normed)
cache[layer_index, 0, :, cache_len : cache_len + self.unit_length, :].copy_(key)
cache[layer_index, 1, :, cache_len : cache_len + self.unit_length, :].copy_(value)
q = query.view(batch, self.unit_length, self.num_heads, head_dim).transpose(1, 2)
k_all = cache[layer_index, 0].view(batch, -1, self.num_heads, head_dim).transpose(1, 2)
v_all = cache[layer_index, 1].view(batch, -1, self.num_heads, head_dim).transpose(1, 2)
attended = _attend(q, k_all, v_all, mask, self.implementation)
attended = attended.transpose(1, 2).reshape(batch, self.unit_length, self.embed_dim)
hidden = residual + attention.out_proj(attended)
residual = hidden
hidden = layer.activation_fn(layer.fc1(layer.final_layer_norm(hidden)))
hidden = residual + layer.fc2(hidden)
hidden = encoder.layer_norm(hidden)
embeds = self.projection(hidden)
return self.pooler(embeds.transpose(1, 2)).transpose(1, 2)
def encode(
self,
chunks: Sequence[StreamingAudioChunk],
) -> tuple[list[torch.Tensor | None], list[StreamingAudioKVCache | None]]:
outputs: list[torch.Tensor | None] = [None] * len(chunks)
caches: list[StreamingAudioKVCache | None] = [chunk.cache for chunk in chunks]
steady: list[_Group] = []
eager: list[int] = []
for index, chunk in enumerate(chunks):
cache_in = chunk.cache
reset = cache_in is not None and cache_in.length + self.unit_length >= self.max_positions
past = 0 if reset or cache_in is None else cache_in.length
if not row_is_steady(chunk, expected_frames=self.unit_frames) or past > self.max_cache_bucket:
eager.append(index)
continue
cache_out = StreamingAudioKVCache.for_encoder(self.encoder, self.page_positions) if reset else cache_in
steady.append(_Group(index, cache_in, cache_out, past))
for start in range(0, len(steady), self.max_batch):
self._replay(steady[start : start + self.max_batch], chunks, outputs, caches)
if eager:
eager_outputs, eager_caches = encode_streaming_audio_batch(
self.encoder,
self.projection,
self.pooler,
[chunks[i] for i in eager],
pool_step=self.pool_step,
page_positions=self.page_positions,
)
for index, output, cache in zip(eager, eager_outputs, eager_caches, strict=True):
outputs[index], caches[index] = output, cache
return outputs, caches
@torch.inference_mode()
def _replay(
self,
group: list[_Group],
chunks: Sequence[StreamingAudioChunk],
outputs: list[torch.Tensor | None],
caches: list[StreamingAudioKVCache | None],
) -> None:
batch = select_bucket(len(group), self.batch_sizes)
cache_len = select_bucket(max((row.past for row in group), default=0), self.cache_buckets)
assert batch is not None and cache_len is not None
mel, cache, mask, position_ids = self._views(batch, cache_len)
mask.fill_(0.0)
if len(group) < batch:
mask[len(group) :, :, :, :cache_len].fill_(_NEG_INF)
position_ids[len(group) :].copy_(torch.arange(self.unit_length, device=self.device))
for slot, row in enumerate(group):
feature = chunks[row.index].features
feature = (feature[0] if feature.ndim == 3 else feature).to(dtype=self.dtype)
if self.pinned_h2d and feature.device.type == "cpu":
mel[slot].copy_(feature.pin_memory(), non_blocking=True)
else:
mel[slot].copy_(feature.to(device=self.device))
pad = cache_len - row.past
if pad:
mask[slot, :, :, :pad].fill_(_NEG_INF)
if row.past:
cache[:, :, slot, pad:cache_len, :].copy_(row.cache_in.history(row.past))
position_ids[slot].copy_(torch.arange(row.past, row.past + self.unit_length, device=self.device))
graph, pooled = self._graphs[(batch, cache_len)]
graph.replay()
for slot, row in enumerate(group):
outputs[row.index] = pooled[slot, : self.pooled_length].clone()
row.cache_out.reserve(row.past + self.unit_length, dtype=self.dtype, device=self.device)
row.cache_out.commit(row.past, cache[:, :, slot, cache_len : cache_len + self.unit_length, :])
row.cache_out.length = row.past + self.unit_length
caches[row.index] = row.cache_out
@@ -0,0 +1,273 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Fused packed SigLIP layers and CUDA graphs for MiniCPM-o 4.5 vision encode."""
from __future__ import annotations
from collections import OrderedDict
from collections.abc import Sequence
from copy import copy
from math import prod
from typing import Any, ClassVar, Literal
import torch
import torch.nn.functional as F
from torch import nn
from vllm.config import ModelConfig, VllmConfig
from vllm.config.multimodal import MultiModalConfig
from vllm.logger import init_logger
from vllm.model_executor.models.interfaces import SupportsEncoderCudaGraph
from vllm.platforms import current_platform
from vllm.v1.worker.encoder_cudagraph import EncoderCudaGraphManager
from vllm.v1.worker.encoder_cudagraph_defs import (
EncoderCudaGraphCaptureInputs,
EncoderCudaGraphConfig,
EncoderCudaGraphReplayBuffers,
EncoderItemSpec,
)
from .ops import residual_layer_norm
logger = init_logger(__name__)
# Batch buckets of the vision graphs; the checkpoint's vision_batch_size (16)
# bounds a chunk, so it is the largest bucket.
DEFAULT_GRAPH_BATCH_SIZES = (1, 2, 4, 8, 16)
# Distinct (grid, bucket) graphs kept at once; duplex uses one grid.
_MAX_GRAPHS = 16
# A (grid, bucket) is captured on its third encode, so one-off shapes (the
# startup profile run, a single turn-mode image) stay eager.
_CAPTURE_AFTER = 2
def supports_fused_layers(vpm: nn.Module) -> bool:
"""Whether ``encode_packed_fused`` covers ``vpm`` (SigLIP with a GELU MLP)."""
layers = getattr(getattr(vpm, "encoder", None), "layers", None)
if not layers or getattr(vpm, "post_layernorm", None) is None:
return False
if getattr(getattr(vpm, "config", None), "_attn_implementation", None) == "eager":
return False
attn, mlp = getattr(layers[0], "self_attn", None), getattr(layers[0], "mlp", None)
return all(
getattr(attn, name, None) is not None for name in ("q_proj", "k_proj", "v_proj", "out_proj")
) and getattr(getattr(mlp, "config", None), "hidden_act", None) in ("gelu_pytorch_tanh", "gelu")
def packed_qkv(attn: nn.Module) -> tuple[torch.Tensor, torch.Tensor | None]:
"""``(3E, E)`` q/k/v weight (and bias) of ``attn``, stacked once."""
cached = attn.__dict__.get("_packed_qkv")
projections = (attn.q_proj, attn.k_proj, attn.v_proj)
if cached is not None and all(
p.weight.data_ptr() == cached[0][i * p.weight.shape[0]].data_ptr() for i, p in enumerate(projections)
):
return cached
with torch.no_grad():
weight = torch.cat([p.weight for p in projections])
biases = [p.bias for p in projections]
bias = torch.cat(biases) if all(b is not None for b in biases) else None
rows = int(projections[0].weight.shape[0])
for index, projection in enumerate(projections):
projection.weight.data = weight[index * rows : (index + 1) * rows]
if bias is not None:
projection.bias.data = bias[index * rows : (index + 1) * rows]
attn.__dict__["_packed_qkv"] = (weight, bias)
return weight, bias
def _attend(attn: nn.Module, qkv: torch.Tensor, seq_groups: Sequence[tuple[int, int, int]]) -> torch.Tensor:
"""``(tokens, E)`` attention output of packed ``(tokens, 3E)`` q/k/v, each item attending to itself."""
heads, head_dim = attn.num_heads, attn.head_dim
embed = heads * head_dim
outputs = []
for start, count, seq_len in seq_groups:
run = qkv[start : start + count * seq_len].view(count, seq_len, 3, heads, head_dim)
query, key, value = (run[:, :, index].transpose(1, 2) for index in range(3))
out = F.scaled_dot_product_attention(query, key, value, scale=attn.scale)
outputs.append(out.transpose(1, 2).reshape(count * seq_len, embed))
return outputs[0] if len(outputs) == 1 else torch.cat(outputs)
def _mlp_up(mlp: nn.Module, hidden: torch.Tensor) -> torch.Tensor:
fc1 = mlp.fc1
if mlp.config.hidden_act == "gelu_pytorch_tanh" and fc1.bias is not None and hidden.is_cuda:
# cuBLASLt's GELU epilogue is the tanh approximation.
return torch._addmm_activation(fc1.bias, hidden, fc1.weight.t(), use_gelu=True)
return mlp.activation_fn(fc1(hidden))
def _add_norm(residual: torch.Tensor, norm: nn.Module, y=None, y_bias=None, residual_out=None) -> torch.Tensor:
"""``norm(residual + y + y_bias)`` for a ``(tokens, E)`` ``y``, the sum also written to ``residual_out``."""
y = None if y is None else y.unsqueeze(0)
return residual_layer_norm(
residual, y, y_bias=y_bias, weight=norm.weight, bias=norm.bias, eps=norm.eps, residual_out=residual_out
)
def encode_packed_fused(
vpm: nn.Module, hidden_states: torch.Tensor, seq_groups: Sequence[tuple[int, int, int]]
) -> torch.Tensor:
"""The encoder layers and ``post_layernorm`` of ``vpm.forward_packed`` over ``(tokens, E)`` embeddings.
The residual stream is updated in place in a contiguous copy of
``hidden_states`` (the patch embedding hands over a transposed view).
"""
layers = vpm.encoder.layers
residual = hidden_states.contiguous().unsqueeze(0)
normed = _add_norm(residual, layers[0].layer_norm1)
for index, layer in enumerate(layers):
attn, mlp = layer.self_attn, layer.mlp
qkv_weight, qkv_bias = packed_qkv(attn)
attended = _attend(attn, F.linear(normed[0], qkv_weight, qkv_bias), seq_groups)
y = F.linear(attended, attn.out_proj.weight)
normed = _add_norm(residual, layer.layer_norm2, y, attn.out_proj.bias, residual_out=residual)
last = index + 1 == len(layers)
norm = vpm.post_layernorm if last else layers[index + 1].layer_norm1
y = F.linear(_mlp_up(mlp, normed[0]), mlp.fc2.weight)
normed = _add_norm(residual, norm, y, mlp.fc2.bias, residual_out=None if last else residual)
return normed[0]
def _copy_pixels_padded(dst: torch.Tensor, src: torch.Tensor) -> None:
width_px = int(src.shape[-1])
dst[..., :width_px].copy_(src)
if width_px < int(dst.shape[-1]):
dst[..., width_px:].zero_()
class _PackedVisionAdapter(SupportsEncoderCudaGraph):
"""Adapter exposing the packed SigLIP tower + resampler to vLLM's EncoderCudaGraphManager."""
supports_encoder_cudagraph: ClassVar[Literal[True]] = True
def __init__(self, forward: Any, runs: Any, pixel_shape: Any, output_shape: torch.Size):
self.forward, self.runs, self.pixel_shape, self.output_shape = forward, runs, pixel_shape, output_shape
self.tokens = prod(output_shape[:-1]) or 1
def get_encoder_cudagraph_config(self) -> EncoderCudaGraphConfig:
return EncoderCudaGraphConfig(
modalities=["image"],
buffer_keys=["pixels"],
out_hidden_size=self.output_shape[-1],
padding_logics={"pixels": _copy_pixels_padded},
)
def get_encoder_cudagraph_budget_range(self, vllm_config: VllmConfig) -> tuple[int, int]:
return self.tokens, self.tokens
def get_encoder_cudagraph_item_specs(self, mm_kwargs: dict[str, Any]) -> list[EncoderItemSpec]:
return [EncoderItemSpec(input_size=self.tokens, output_tokens=self.tokens)]
def select_encoder_cudagraph_items(self, mm_kwargs: dict[str, Any], indices: list[int]) -> dict[str, Any]:
return dict(mm_kwargs)
def prepare_encoder_cudagraph_capture_inputs(self, *args: Any, **kwargs: Any) -> EncoderCudaGraphCaptureInputs:
device = kwargs.get("device") or args[3]
dtype = kwargs.get("dtype") or args[4]
return EncoderCudaGraphCaptureInputs({"pixels": torch.zeros(self.pixel_shape, dtype=dtype, device=device)})
def prepare_encoder_cudagraph_replay_buffers(
self, mm_kwargs: dict[str, Any], *args: Any, **kwargs: Any
) -> EncoderCudaGraphReplayBuffers:
return EncoderCudaGraphReplayBuffers(mm_kwargs)
def encoder_cudagraph_forward(self, inputs: dict[str, torch.Tensor], path: str = "default") -> torch.Tensor:
return self.forward(inputs["pixels"], self.runs)
def encoder_eager_forward(self, mm_kwargs: dict[str, Any], path: str = "default") -> torch.Tensor:
return self.encoder_cudagraph_forward(mm_kwargs, path)
def postprocess_encoder_output(
self,
outputs: dict[str, torch.Tensor],
indices: list[int],
per_item_out_tokens: list[int],
dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
clone: bool = False,
batch_mm_kwargs: dict[str, Any] | None = None,
) -> None:
dest[0] = outputs["default"].clone()
class VisionGraphEncoder:
"""CUDA graphs of the packed SigLIP tower + resampler for single-grid chunks."""
def __init__(
self,
vpm: nn.Module,
resampler: nn.Module,
*,
batch_sizes: Sequence[int] = DEFAULT_GRAPH_BATCH_SIZES,
vllm_config: VllmConfig | None = None,
):
self.vpm, self.resampler, self.vllm_config = vpm, resampler, vllm_config
self.batch_sizes = tuple(sorted({int(b) for b in batch_sizes if int(b) > 0}))
# (h, w, bucket) -> EncoderCudaGraphManager, least recently used first.
self._managers: OrderedDict[tuple[int, int, int], EncoderCudaGraphManager] = OrderedDict()
self._failed: set[tuple[int, int, int]] = set()
self._seen: dict[tuple[int, int, int], int] = {}
def encode(self, pixels: torch.Tensor, height: int, width: int, count: int) -> torch.Tensor | None:
if torch.cuda.is_current_stream_capturing():
return None
bucket = min((size for size in self.batch_sizes if count <= size), default=None)
if bucket is None:
return None
key = (int(height), int(width), bucket)
manager = self._managers.get(key)
if manager is None:
if key in self._failed:
return None
seen = self._seen.get(key, 0)
if seen < _CAPTURE_AFTER:
if len(self._seen) >= 4 * _MAX_GRAPHS:
self._seen.clear()
self._seen[key] = seen + 1
return None
manager = self._capture(key, pixels)
if manager is None:
return None
self._managers.move_to_end(key)
return manager.execute({"pixels": pixels})[0][:count]
def _run(self, pixels: torch.Tensor, runs: list[tuple[int, int, int]]) -> torch.Tensor:
return self.resampler.forward_packed(self.vpm.forward_packed(pixels, runs), runs)
def _capture(self, key: tuple[int, int, int], like: torch.Tensor) -> EncoderCudaGraphManager | None:
height, width, bucket = key
runs, patch = [(height, width, bucket)], int(like.shape[-2])
pixel_shape = (*like.shape[:-1], bucket * height * width * patch)
dummy_pixels = torch.zeros(pixel_shape, device=like.device, dtype=like.dtype)
try:
stream = torch.cuda.Stream(device=like.device)
stream.wait_stream(torch.cuda.current_stream(like.device))
with torch.cuda.stream(stream), torch.inference_mode():
out = self._run(dummy_pixels, runs)
for _ in range(1):
self._run(dummy_pixels, runs)
torch.cuda.current_stream(like.device).wait_stream(stream)
adapter = _PackedVisionAdapter(self._run, runs, pixel_shape, out.shape)
config = copy(self.vllm_config) if self.vllm_config is not None else VllmConfig()
if getattr(config, "model_config", None) is None:
config.model_config = ModelConfig.__new__(ModelConfig)
if getattr(config.model_config, "multimodal_config", None) is None:
config.model_config.multimodal_config = MultiModalConfig()
cc = config.compilation_config = copy(config.compilation_config)
cc.encoder_cudagraph_token_budgets = [adapter.tokens]
cc.encoder_cudagraph_max_vision_items_per_batch, cc.encoder_cudagraph_max_frames_per_batch = 1, 0
config.parallel_config = copy(config.parallel_config)
config.parallel_config.tensor_parallel_size = 1
manager = EncoderCudaGraphManager(config, like.device, like.dtype, adapter)
with torch.cuda.stream(stream):
manager.capture(graph_pool=current_platform.get_global_graph_pool())
torch.cuda.current_stream(like.device).wait_stream(stream)
except Exception:
logger.warning("MiniCPM-o vision CUDA graph capture failed for %s; staying eager", key, exc_info=True)
self._failed.add(key)
return None
if len(self._managers) >= _MAX_GRAPHS:
_, evicted = self._managers.popitem(last=False)
evicted.clear()
self._managers[key] = manager
logger.info("Captured MiniCPM-o vision CUDA graph grid=%dx%d batch=%d via manager", height, width, bucket)
return manager