[Bugfix][Perf] mimo_audio: fix per-request speech-code routing under continuous batching (#5070)
Signed-off-by: JuanPZuluaga <juanz9312@gmail.com> Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
This commit is contained in:
1 parent
e24bddfb22
commit
dacb8e6f10
4 files changed
+172
-20
No files matched your search
@@ -0,0 +1,120 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Regression tests for MiMo-Audio per-request speech-code routing.
|
||||
|
||||
Guards the cross-request audio-corruption class (same class as RFC #4316
|
||||
Issue 1 for MOSS-TTS): the fused thinker+talker emits one speech-code row per
|
||||
request, so ``codes["audio"]`` must be a batch-ordered **list** for the
|
||||
generic splitter (``to_payload_element``) to route ``element[idx]`` to
|
||||
request ``idx``. A batched ``(num_reqs, 1, 8, 4)`` tensor is only split
|
||||
correctly by the token-offset path when every request has exactly one
|
||||
scheduled token; in a mixed prefill+decode step the tensor's first dim
|
||||
matches neither token length, the splitter falls through to ``clone()``, and
|
||||
every request receives the whole batch — leaking codes across requests and
|
||||
crashing the async-chunk ragged-list conversion
|
||||
(``expected sequence of length 36 at dim 1 (got 144)``).
|
||||
|
||||
CPU-only and weight-free: ``forward`` is driven on a bare model with
|
||||
``generate_codes`` stubbed to return row-indexed codes, so assertions on the
|
||||
payload prove which request got which row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
|
||||
import pytest
|
||||
|
||||
torch = pytest.importorskip("torch")
|
||||
|
||||
from vllm_omni.utils.mm_outputs import to_payload_element # noqa: E402
|
||||
|
||||
NUM_CODEBOOKS = 8
|
||||
GROUP_SIZE = 4
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _model_cls():
|
||||
"""Defer the import (pulls vLLM model_executor) until first use."""
|
||||
from vllm_omni.model_executor.models.mimo_audio.mimo_audio import (
|
||||
MiMoAudioForConditionalGeneration,
|
||||
)
|
||||
|
||||
return MiMoAudioForConditionalGeneration
|
||||
|
||||
|
||||
def _row_indexed_codes(num_reqs: int) -> torch.Tensor:
|
||||
"""(num_reqs, 1, 8, 4) tensor where request r's codes all hold r."""
|
||||
codes = torch.empty((num_reqs, 1, NUM_CODEBOOKS, GROUP_SIZE), dtype=torch.long)
|
||||
for r in range(num_reqs):
|
||||
codes[r] = r
|
||||
return codes
|
||||
|
||||
|
||||
def _make_bare_model(next_speech_tokens, num_tokens: int):
|
||||
"""A model whose only behaviour is the emit logic under test."""
|
||||
cls = _model_cls()
|
||||
model = cls.__new__(cls)
|
||||
model.model_stage = "fused_thinker_talker"
|
||||
hidden = torch.zeros((num_tokens, 16), dtype=torch.float32)
|
||||
model.generate_codes = lambda **kwargs: (next_speech_tokens, hidden)
|
||||
return model
|
||||
|
||||
|
||||
def _emit(next_speech_tokens, num_tokens: int):
|
||||
model = _make_bare_model(next_speech_tokens, num_tokens)
|
||||
result = model.forward(
|
||||
input_ids=torch.zeros((num_tokens,), dtype=torch.long),
|
||||
positions=torch.zeros((num_tokens,), dtype=torch.long),
|
||||
)
|
||||
return result.multimodal_outputs["codes"]["audio"]
|
||||
|
||||
|
||||
class TestPerRequestCodeRouting:
|
||||
def test_emit_is_batch_aligned_list(self):
|
||||
audio = _emit(_row_indexed_codes(3), num_tokens=3)
|
||||
|
||||
assert isinstance(audio, list)
|
||||
assert len(audio) == 3 # one entry per request, batch-aligned
|
||||
for r, codes in enumerate(audio):
|
||||
assert codes.shape == (1, 1, NUM_CODEBOOKS, GROUP_SIZE)
|
||||
assert torch.all(codes == r)
|
||||
|
||||
def test_no_codes_step_emits_none(self):
|
||||
audio = _emit(None, num_tokens=2)
|
||||
assert audio is None
|
||||
|
||||
def test_mixed_prefill_decode_routes_by_request_index(self):
|
||||
"""req 1 prefills 7 tokens while reqs 0 and 2 decode: the splitter
|
||||
must hand each request its own row, not the whole batch."""
|
||||
audio = _emit(_row_indexed_codes(3), num_tokens=9)
|
||||
|
||||
seq_len = 9 # 1 (decode) + 7 (prefill) + 1 (decode) scheduled tokens
|
||||
spans = [(0, 1), (1, 8), (8, 9)]
|
||||
for idx, (start, end) in enumerate(spans):
|
||||
routed = to_payload_element(
|
||||
audio,
|
||||
idx,
|
||||
start,
|
||||
end,
|
||||
seq_len=seq_len,
|
||||
scheduled_seq_len=seq_len,
|
||||
)
|
||||
assert routed.shape == (1, 1, NUM_CODEBOOKS, GROUP_SIZE)
|
||||
assert torch.all(routed == idx), f"req {idx} received another request's codes"
|
||||
|
||||
def test_pure_decode_routes_by_request_index(self):
|
||||
"""One token per request (the only case the old tensor emit handled)."""
|
||||
audio = _emit(_row_indexed_codes(4), num_tokens=4)
|
||||
|
||||
for idx in range(4):
|
||||
routed = to_payload_element(audio, idx, idx, idx + 1, seq_len=4, scheduled_seq_len=4)
|
||||
assert torch.all(routed == idx)
|
||||
|
||||
def test_routed_codes_do_not_alias_the_emit(self):
|
||||
"""The splitter clones list elements; mutating the routed payload must
|
||||
not corrupt another request's view of the emit."""
|
||||
audio = _emit(_row_indexed_codes(2), num_tokens=5)
|
||||
routed = to_payload_element(audio, 0, 0, 4, seq_len=5, scheduled_seq_len=5)
|
||||
routed.fill_(99)
|
||||
assert torch.all(audio[0] == 0)
|
||||
@@ -2539,7 +2539,10 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
|
||||
final_res = omni_outputs.request_output
|
||||
# OMNI: Access multimodal_output from CompletionOutput (outputs[0]), not from RequestOutput
|
||||
# Reference: examples/offline_inference/qwen3_omni/end2end.py line 421
|
||||
mm_output = final_res.outputs[0].multimodal_output
|
||||
# The attribute is attached dynamically when stage audio arrives; fall
|
||||
# back to the no-audio error response instead of an AttributeError 500
|
||||
# when the pipeline produced no audio for this request.
|
||||
mm_output = getattr(final_res.outputs[0], "multimodal_output", None) or {}
|
||||
audio_data = mm_output.get("audio")
|
||||
if isinstance(audio_data, list):
|
||||
if not audio_data:
|
||||
|
||||
@@ -793,9 +793,21 @@ class MiMoAudioForConditionalGeneration(
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# next_speech_tokens is request-indexed (num_reqs, 1, 8, 4), not
|
||||
# token-indexed, so ship it as a per-request list. The payload
|
||||
# builder slices batched tensors by token offsets (start:end),
|
||||
# which only coincides with the request index in pure-decode
|
||||
# steps; in a mixed prefill+decode batch every request would
|
||||
# receive the whole batch tensor, leaking codes across requests
|
||||
# and crashing the downstream ragged-list conversion.
|
||||
if next_speech_tokens is not None:
|
||||
speech_token_payload = list(next_speech_tokens.split(1, dim=0))
|
||||
else:
|
||||
speech_token_payload = None
|
||||
|
||||
return OmniOutput(
|
||||
text_hidden_states=text_hidden_states.reshape(-1, text_hidden_states.shape[-1]),
|
||||
multimodal_outputs={"codes": {"audio": next_speech_tokens}},
|
||||
multimodal_outputs={"codes": {"audio": speech_token_payload}},
|
||||
)
|
||||
|
||||
if self.model_stage == "code2wav":
|
||||
|
||||
@@ -897,20 +897,28 @@ class MiMoAudioLLMForConditionalGeneration(nn.Module, SupportsMultiModal, Suppor
|
||||
input_ids: torch.Tensor,
|
||||
*,
|
||||
request_ids: list[str],
|
||||
query_start_loc: torch.Tensor,
|
||||
query_start_loc_list: list[int],
|
||||
kwargs: dict,
|
||||
) -> tuple[dict[str, any], dict]:
|
||||
has_merge_mm_embedding = False
|
||||
merge_mm_embedding_info: dict[str, any] = {}
|
||||
seq_len = input_ids.shape[1] if input_ids.ndim == 2 else input_ids.shape[0]
|
||||
|
||||
for req_idx, req_id in enumerate(request_ids):
|
||||
query_start_loc_by_req = int(query_start_loc[req_idx].item())
|
||||
query_end_loc_by_req = int(query_start_loc[req_idx + 1].item())
|
||||
input_ids_by_req = input_ids[query_start_loc_by_req:query_end_loc_by_req]
|
||||
seq_len_by_req = input_ids_by_req.shape[1] if input_ids_by_req.ndim == 2 else input_ids_by_req.shape[0]
|
||||
# Only each request's start token is ever read below, and only for
|
||||
# single-token requests. Gather just those start offsets (num_reqs elements)
|
||||
# to host instead of copying the whole flattened sequence every forward.
|
||||
# query_start_loc entries are flat token offsets, so we index the flattened
|
||||
# ids by the same offsets and derive per-request length from the boundaries.
|
||||
flat_input_ids = input_ids.reshape(-1)
|
||||
start_positions = query_start_loc_list[:-1]
|
||||
start_is_empty_cpu = (flat_input_ids[start_positions] == self.empty_token_id).cpu().tolist()
|
||||
|
||||
if seq_len_by_req == 1 and bool(input_ids_by_req == self.empty_token_id):
|
||||
for req_idx, req_id in enumerate(request_ids):
|
||||
query_start_loc_by_req = query_start_loc_list[req_idx]
|
||||
query_end_loc_by_req = query_start_loc_list[req_idx + 1]
|
||||
seq_len_by_req = query_end_loc_by_req - query_start_loc_by_req
|
||||
|
||||
if seq_len_by_req == 1 and start_is_empty_cpu[req_idx]:
|
||||
merge_mm_embedding_info[req_id] = {
|
||||
"query_start_loc": query_start_loc_by_req,
|
||||
"query_end_loc": query_end_loc_by_req,
|
||||
@@ -1035,13 +1043,19 @@ class MiMoAudioLLMForConditionalGeneration(nn.Module, SupportsMultiModal, Suppor
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
**kwargs: object,
|
||||
) -> torch.Tensor | IntermediateTensors:
|
||||
# Materialize the request boundaries on the host once instead of one
|
||||
# `.item()` sync per boundary in the loops below.
|
||||
_forward_context = get_forward_context()
|
||||
_default_query_start_loc = torch.tensor([0, input_ids.shape[-1]], device=input_ids.device)
|
||||
query_start_loc = (
|
||||
next(iter(_forward_context.attn_metadata.values())).query_start_loc
|
||||
if _forward_context.attn_metadata is not None
|
||||
else _default_query_start_loc
|
||||
)
|
||||
if _forward_context.attn_metadata is not None:
|
||||
query_start_loc = next(iter(_forward_context.attn_metadata.values())).query_start_loc
|
||||
query_start_loc_list = query_start_loc.tolist()
|
||||
else:
|
||||
# Single implicit request over all tokens. Build the host boundaries
|
||||
# directly (no .tolist() D2H); the small device tensor is still needed
|
||||
# for logits_indices below.
|
||||
n_tokens = int(input_ids.shape[-1])
|
||||
query_start_loc_list = [0, n_tokens]
|
||||
query_start_loc = torch.tensor([0, n_tokens], device=input_ids.device)
|
||||
|
||||
runtime_additional_information = kwargs.get("runtime_additional_information", [])
|
||||
if runtime_additional_information:
|
||||
@@ -1054,7 +1068,7 @@ class MiMoAudioLLMForConditionalGeneration(nn.Module, SupportsMultiModal, Suppor
|
||||
merge_mm_embedding_info, has_merge_mm_embedding, kwargs = self._collect_merge_mm_embedding_info(
|
||||
input_ids,
|
||||
request_ids=request_ids,
|
||||
query_start_loc=query_start_loc,
|
||||
query_start_loc_list=query_start_loc_list,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
@@ -1077,15 +1091,18 @@ class MiMoAudioLLMForConditionalGeneration(nn.Module, SupportsMultiModal, Suppor
|
||||
batch_next_speech_tokens: torch.Tensor | None = None
|
||||
|
||||
if not is_capturing and next_ids is not None and num_reqs > 0:
|
||||
if (next_ids == self.empty_token_id).any():
|
||||
# Copy the empty-token mask to host once; the `.any()` gate and the
|
||||
# per-request checks below then avoid a GPU sync each.
|
||||
next_is_empty_cpu = (next_ids == self.empty_token_id).cpu()
|
||||
if bool(next_is_empty_cpu.any()):
|
||||
batch_hs_list = []
|
||||
valid_mask = []
|
||||
|
||||
for req_idx in range(num_reqs):
|
||||
start = int(query_start_loc[req_idx].item())
|
||||
end = int(query_start_loc[req_idx + 1].item())
|
||||
start = query_start_loc_list[req_idx]
|
||||
end = query_start_loc_list[req_idx + 1]
|
||||
hs_req = hidden_states[start:end][-1:, :]
|
||||
is_empty = bool(next_ids[req_idx] == self.empty_token_id)
|
||||
is_empty = bool(next_is_empty_cpu[req_idx])
|
||||
valid_mask.append(is_empty)
|
||||
|
||||
if not is_empty:
|
||||
|
||||
Reference in new issue
Block a user