Signed-off-by: Megha Agarwal <agarwalmegha1308@gmail.com> Signed-off-by: meghaagr13 <divyanshsinghvi@gmail.com> Signed-off-by: Taichang Zhou <tzhouam@connect.ust.hk> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com> Co-authored-by: meghaagr13 <divyanshsinghvi@gmail.com> Co-authored-by: Boao Shi <aoibosh@connect.hku.hk> Co-authored-by: Taichang Zhou <tzhouam@connect.ust.hk> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
49 files changed
+985
-327
No files matched your search
@@ -281,6 +281,7 @@ extend-ignore-identifiers-re = [
|
||||
".*[Oo]no_[Aa]nna.*",
|
||||
".*cann.*",
|
||||
".*[Nn][Dd]3.*",
|
||||
".*updator.*",
|
||||
"byt5.*",
|
||||
]
|
||||
extend-ignore-re = [
|
||||
|
||||
@@ -200,9 +200,9 @@ def test_save_async(build_adapter):
|
||||
request = _req("req-1", RequestStatus.WAITING, external_req_id="external-1")
|
||||
|
||||
adapter.custom_process_next_stage_input_func = lambda **kwargs: {"x": [1], "finished": False}
|
||||
adapter.save_async(pooling_output=None, request=request)
|
||||
adapter.save_async(multimodal_output=None, request=request)
|
||||
adapter.custom_process_next_stage_input_func = lambda **kwargs: {}
|
||||
adapter.save_async(pooling_output=None, request=request)
|
||||
adapter.save_async(multimodal_output=None, request=request)
|
||||
|
||||
task = adapter._pending_save_reqs.popleft()
|
||||
assert task["is_finished"] is False
|
||||
@@ -214,7 +214,7 @@ def test_save_async_uses_confirmed_tokens_for_async_scheduler_watermark(build_ad
|
||||
request.num_computed_tokens = 10
|
||||
request.num_output_placeholders = 2
|
||||
|
||||
adapter.save_async(pooling_output=None, request=request)
|
||||
adapter.save_async(multimodal_output=None, request=request)
|
||||
|
||||
assert adapter.requests_num_chunks_sent["external-async"] == 8
|
||||
assert len(adapter._pending_save_reqs) == 1
|
||||
@@ -236,7 +236,7 @@ def test_send_single_request_struct_without_meta_does_not_crash(build_adapter, m
|
||||
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: cleanup_calls.append((a, kw)))
|
||||
|
||||
adapter._send_single_request(
|
||||
{"pooling_output": None, "request": request, "is_finished": False, "is_segment_finished": False}
|
||||
{"multimodal_output": None, "request": request, "is_finished": False, "is_segment_finished": False}
|
||||
)
|
||||
|
||||
assert cleanup_calls == [] # no terminal cleanup; meta.finished is false
|
||||
@@ -256,7 +256,7 @@ def test_send_single_request_empty_struct_goes_on_wire(build_adapter, monkeypatc
|
||||
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None)
|
||||
|
||||
adapter._send_single_request(
|
||||
{"pooling_output": None, "request": request, "is_finished": False, "is_segment_finished": False}
|
||||
{"multimodal_output": None, "request": request, "is_finished": False, "is_segment_finished": False}
|
||||
)
|
||||
|
||||
assert connector.put.called
|
||||
@@ -274,7 +274,7 @@ def test_send_single_request_struct_preserves_segment_finished(build_adapter, mo
|
||||
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None)
|
||||
|
||||
adapter._send_single_request(
|
||||
{"pooling_output": None, "request": request, "is_finished": False, "is_segment_finished": True}
|
||||
{"multimodal_output": None, "request": request, "is_finished": False, "is_segment_finished": True}
|
||||
)
|
||||
|
||||
sent_payload = connector.put.call_args.kwargs["data"]
|
||||
@@ -289,13 +289,13 @@ def test_save_async_skips_stale_resumable_chunk_until_dedup_is_reset(build_adapt
|
||||
request.num_computed_tokens = 0
|
||||
adapter.requests_num_chunks_sent["ext-stream"] = 111
|
||||
|
||||
adapter.save_async(pooling_output=None, request=request, is_segment_finished=False)
|
||||
adapter.save_async(multimodal_output=None, request=request, is_segment_finished=False)
|
||||
|
||||
assert len(adapter._pending_save_reqs) == 0
|
||||
assert adapter.requests_num_chunks_sent["ext-stream"] == 111
|
||||
|
||||
adapter.requests_num_chunks_sent.pop("ext-stream")
|
||||
adapter.save_async(pooling_output=None, request=request, is_segment_finished=False)
|
||||
adapter.save_async(multimodal_output=None, request=request, is_segment_finished=False)
|
||||
|
||||
assert len(adapter._pending_save_reqs) == 1
|
||||
assert adapter.requests_num_chunks_sent["ext-stream"] == 0
|
||||
@@ -312,7 +312,7 @@ def test_send_single_request_cleans_up_after_finished_payload(build_adapter, mon
|
||||
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: cleanup_calls.append((a, kw)))
|
||||
|
||||
adapter._send_single_request(
|
||||
{"pooling_output": None, "request": request, "is_finished": True, "is_segment_finished": True}
|
||||
{"multimodal_output": None, "request": request, "is_finished": True, "is_segment_finished": True}
|
||||
)
|
||||
|
||||
assert len(cleanup_calls) == 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""E2E test for VoxCPM2 native AR offline inference."""
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -23,7 +24,7 @@ def voxcpm2_engine():
|
||||
|
||||
def _extract_audio(multimodal_output: dict) -> torch.Tensor:
|
||||
"""Extract the final complete audio tensor from multimodal output."""
|
||||
assert isinstance(multimodal_output, dict), f"Expected dict, got {type(multimodal_output)}"
|
||||
assert isinstance(multimodal_output, (dict, Mapping)), f"Expected dict/Mapping, got {type(multimodal_output)}"
|
||||
|
||||
# Output processor accumulates per-step audio chunks under "audio".
|
||||
audio = multimodal_output.get("audio")
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for _ensure_tensor_values boundary enforcement."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.worker.gpu_ar_model_runner import _ensure_tensor_values
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_tensors_pass_through():
|
||||
"""Tensor values pass through unchanged."""
|
||||
t = torch.randn(10)
|
||||
result = _ensure_tensor_values({"audio": t})
|
||||
assert torch.equal(result["audio"], t)
|
||||
|
||||
|
||||
def test_scalar_int_wrapped():
|
||||
"""Integer scalars are wrapped in torch.tensor."""
|
||||
result = _ensure_tensor_values({"sr": 24000})
|
||||
assert isinstance(result["sr"], torch.Tensor)
|
||||
assert result["sr"].item() == 24000
|
||||
|
||||
|
||||
def test_scalar_float_wrapped():
|
||||
"""Float scalars are wrapped in torch.tensor."""
|
||||
result = _ensure_tensor_values({"gain": 0.5})
|
||||
assert isinstance(result["gain"], torch.Tensor)
|
||||
assert result["gain"].item() == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_list_converted():
|
||||
"""Lists of numbers are converted to tensors."""
|
||||
result = _ensure_tensor_values({"ids": [1, 2, 3]})
|
||||
assert isinstance(result["ids"], torch.Tensor)
|
||||
assert list(result["ids"]) == [1, 2, 3]
|
||||
|
||||
|
||||
def test_non_tensorizable_dropped():
|
||||
"""Values that cannot be tensorized are dropped with warning."""
|
||||
result = _ensure_tensor_values({"ok": torch.randn(5), "bad": {"nested": "dict"}})
|
||||
assert "ok" in result
|
||||
assert "bad" not in result
|
||||
|
||||
|
||||
def test_mixed_payload():
|
||||
"""Mixed tensor + scalar + list payload is properly sanitized."""
|
||||
payload = {
|
||||
"audio": torch.randn(100),
|
||||
"sr": 24000,
|
||||
"codes": [1, 2, 3],
|
||||
"hidden": torch.randn(10, 64),
|
||||
}
|
||||
result = _ensure_tensor_values(payload)
|
||||
assert len(result) == 4
|
||||
for v in result.values():
|
||||
assert isinstance(v, torch.Tensor)
|
||||
@@ -81,7 +81,7 @@ def test_multimodal_payload_and_completion_output():
|
||||
assert p is not None
|
||||
assert "waveform" in p.tensors and torch.equal(p.primary_tensor, data["waveform"])
|
||||
assert p.metadata["sample_rate"] == 16000
|
||||
assert not p.is_empty and len(p) == 1
|
||||
assert not p.is_empty and len(p) == 2 # 1 tensor + 1 metadata
|
||||
|
||||
# None/empty returns None
|
||||
assert MultimodalPayload.from_dict(None) is None
|
||||
@@ -125,7 +125,7 @@ def test_output_modality_printed_examples(capsys):
|
||||
print(f" tensors keys : {list(p.tensors.keys())}")
|
||||
print(f" primary_tensor: shape={p.primary_tensor.shape}, dtype={p.primary_tensor.dtype}")
|
||||
print(f" metadata : {p.metadata}")
|
||||
print(f" is_empty={p.is_empty}, len={len(p)}")
|
||||
print(f" is_empty={p.is_empty}, len={len(p)}") # len = tensors + metadata
|
||||
print(f" from_dict(None) -> {MultimodalPayload.from_dict(None)}")
|
||||
print(f" from_dict({{}}) -> {MultimodalPayload.from_dict({})}")
|
||||
|
||||
|
||||
@@ -209,3 +209,64 @@ def test_cumulative_token_ids_is_a_copy():
|
||||
s = _make_state(RequestOutputKind.DELTA)
|
||||
out = s._new_completion_output([42], None, None)
|
||||
assert out.cumulative_token_ids is not _DETOK.output_token_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No-detokenizer regression tests (Concern 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NO_DETOK_STATE_KWARGS = {
|
||||
**_DEFAULT_STATE_KWARGS,
|
||||
"detokenizer": None,
|
||||
"logprobs_processor": None,
|
||||
}
|
||||
|
||||
|
||||
def _make_no_detok_state(output_kind: RequestOutputKind):
|
||||
return OmniRequestState(**_NO_DETOK_STATE_KWARGS, output_kind=output_kind)
|
||||
|
||||
|
||||
def test_no_detokenizer_completion_output():
|
||||
"""_new_completion_output works when detokenizer is None (generation stages)."""
|
||||
s = _make_no_detok_state(RequestOutputKind.CUMULATIVE)
|
||||
s.add_multimodal_tensor(torch.randn(10), mm_type=AUDIO)
|
||||
out = s._new_completion_output([1, 2], None, None)
|
||||
# Should still produce output with empty text and the token_ids as-is
|
||||
assert out.text == ""
|
||||
assert list(out.token_ids) == [1, 2]
|
||||
assert hasattr(out, "cumulative_token_ids")
|
||||
assert list(out.cumulative_token_ids) == [1, 2]
|
||||
# Multimodal data should be attached
|
||||
assert AUDIO in out.multimodal_output
|
||||
|
||||
|
||||
def test_no_detokenizer_make_request_output():
|
||||
"""make_request_output works without detokenizer when multimodal data is present."""
|
||||
s = _make_no_detok_state(RequestOutputKind.CUMULATIVE)
|
||||
s.add_multimodal_tensor(torch.randn(10), mm_type=AUDIO)
|
||||
result = s.make_request_output([], None, FinishReason.STOP, None)
|
||||
assert result is not None
|
||||
assert not isinstance(result, PoolingRequestOutput)
|
||||
assert AUDIO in result.outputs[0].multimodal_output
|
||||
|
||||
|
||||
def test_no_detokenizer_stream_interval_skipped():
|
||||
"""stream_interval > 1 does not crash when detokenizer is None."""
|
||||
kwargs = {**_NO_DETOK_STATE_KWARGS, "stream_interval": 5}
|
||||
s = OmniRequestState(**kwargs, output_kind=RequestOutputKind.DELTA)
|
||||
s.add_multimodal_tensor(torch.randn(10), mm_type=AUDIO)
|
||||
# Should not assert; stream_interval logic is simply skipped
|
||||
result = s.make_request_output([], None, FinishReason.STOP, None)
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_no_detokenizer_final_only():
|
||||
"""FINAL_ONLY with no detokenizer returns None until finish."""
|
||||
s = _make_no_detok_state(RequestOutputKind.FINAL_ONLY)
|
||||
s.add_multimodal_tensor(torch.randn(10), mm_type=AUDIO)
|
||||
# Non-finish step should return None
|
||||
assert s.make_request_output([], None, None, None) is None
|
||||
# Finish step should return output
|
||||
result = s.make_request_output([], None, FinishReason.STOP, None)
|
||||
assert result is not None
|
||||
assert AUDIO in result.outputs[0].multimodal_output
|
||||
@@ -0,0 +1,88 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Msgpack roundtrip tests for OmniEngineCoreOutputs with multimodal_output.
|
||||
|
||||
Validates that tensor-only payloads survive msgspec encode/decode and that
|
||||
non-tensor values fail decoding (enforcing the wire invariant).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.v1.serial_utils import MsgpackDecoder, MsgpackEncoder
|
||||
|
||||
from vllm_omni.engine import OmniEngineCoreOutput, OmniEngineCoreOutputs
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _roundtrip(outputs: OmniEngineCoreOutputs) -> OmniEngineCoreOutputs:
|
||||
encoder = MsgpackEncoder()
|
||||
encoded = encoder.encode(outputs)
|
||||
decoder = MsgpackDecoder(OmniEngineCoreOutputs)
|
||||
return decoder.decode(encoded)
|
||||
|
||||
|
||||
def test_tensor_only_roundtrip():
|
||||
"""Tensor-only multimodal_output survives msgpack roundtrip."""
|
||||
audio = torch.randn(16000)
|
||||
sr = torch.tensor(24000)
|
||||
eco = OmniEngineCoreOutput(
|
||||
request_id="req-1",
|
||||
new_token_ids=[1, 2, 3],
|
||||
finish_reason=None,
|
||||
multimodal_output={"audio": audio, "sr": sr},
|
||||
)
|
||||
decoded = _roundtrip(OmniEngineCoreOutputs(outputs=[eco]))
|
||||
assert len(decoded.outputs) == 1
|
||||
out = decoded.outputs[0]
|
||||
assert out.request_id == "req-1"
|
||||
assert isinstance(out.multimodal_output, dict)
|
||||
assert torch.allclose(out.multimodal_output["audio"], audio)
|
||||
assert out.multimodal_output["sr"].item() == 24000
|
||||
|
||||
|
||||
def test_empty_multimodal_roundtrip():
|
||||
"""None multimodal_output roundtrips correctly."""
|
||||
eco = OmniEngineCoreOutput(
|
||||
request_id="req-2",
|
||||
new_token_ids=[4],
|
||||
finish_reason=None,
|
||||
multimodal_output=None,
|
||||
)
|
||||
decoded = _roundtrip(OmniEngineCoreOutputs(outputs=[eco]))
|
||||
assert decoded.outputs[0].multimodal_output is None
|
||||
|
||||
|
||||
def test_multiple_tensor_keys_roundtrip():
|
||||
"""Multiple tensor keys survive roundtrip."""
|
||||
hidden = torch.randn(10, 4096)
|
||||
codes = torch.randint(0, 1024, (1, 100))
|
||||
eco = OmniEngineCoreOutput(
|
||||
request_id="req-3",
|
||||
new_token_ids=[],
|
||||
finish_reason=None,
|
||||
multimodal_output={"hidden": hidden, "codes": codes},
|
||||
)
|
||||
decoded = _roundtrip(OmniEngineCoreOutputs(outputs=[eco]))
|
||||
out = decoded.outputs[0]
|
||||
assert torch.allclose(out.multimodal_output["hidden"], hidden)
|
||||
assert torch.equal(out.multimodal_output["codes"], codes)
|
||||
|
||||
|
||||
def test_non_tensor_value_fails_decode():
|
||||
"""Non-tensor values in multimodal_output cause decode failure.
|
||||
|
||||
This validates that the wire field must be tensor-only and that
|
||||
the _ensure_tensor_values boundary in model runners is necessary.
|
||||
"""
|
||||
eco = OmniEngineCoreOutput(
|
||||
request_id="req-bad",
|
||||
new_token_ids=[],
|
||||
finish_reason=None,
|
||||
multimodal_output={"audio": torch.randn(10), "sr": 24000}, # scalar!
|
||||
)
|
||||
encoder = MsgpackEncoder()
|
||||
encoded = encoder.encode(OmniEngineCoreOutputs(outputs=[eco]))
|
||||
decoder = MsgpackDecoder(OmniEngineCoreOutputs)
|
||||
with pytest.raises(Exception):
|
||||
decoder.decode(encoded)
|
||||
@@ -481,12 +481,32 @@ def assert_omni_response(response: Any, request_config: dict[str, Any], run_leve
|
||||
if "text" in modalities:
|
||||
transcript = (response.audio_content or "").strip()
|
||||
text_output = (response.text_content or "").strip()
|
||||
similarity = cosine_similarity_text(
|
||||
transcript.lower(),
|
||||
text_output.lower(),
|
||||
)
|
||||
print(f"similarity is: {similarity}")
|
||||
assert similarity > similarity_threshold, "The audio content is not same as the text"
|
||||
# For very short outputs (e.g. one-word answers), n-gram cosine
|
||||
# similarity with length penalty is unreliable because Whisper
|
||||
# may hallucinate extra context around the short utterance. Use
|
||||
# a containment check instead: the shorter text must appear in
|
||||
# the longer one (after preprocessing removes punctuation).
|
||||
_SHORT_TEXT_THRESHOLD = 15
|
||||
if len(text_output) <= _SHORT_TEXT_THRESHOLD or len(transcript) <= _SHORT_TEXT_THRESHOLD:
|
||||
shorter = text_output.lower() if len(text_output) <= len(transcript) else transcript.lower()
|
||||
longer = transcript.lower() if len(text_output) <= len(transcript) else text_output.lower()
|
||||
import re as _re
|
||||
|
||||
shorter_clean = _re.sub(r"[^\w\s]", "", shorter).strip()
|
||||
longer_clean = _re.sub(r"[^\w\s]", "", longer).strip()
|
||||
assert shorter_clean and (shorter_clean in longer_clean), (
|
||||
f"The audio content is not same as the text "
|
||||
f"(short-text containment check failed: "
|
||||
f"text={text_output!r}, transcript={transcript!r})"
|
||||
)
|
||||
print(f"short-text containment check passed: {shorter_clean!r} in {longer_clean!r}")
|
||||
else:
|
||||
similarity = cosine_similarity_text(
|
||||
transcript.lower(),
|
||||
text_output.lower(),
|
||||
)
|
||||
print(f"similarity is: {similarity}")
|
||||
assert similarity > similarity_threshold, "The audio content is not same as the text"
|
||||
if audio_ref_text:
|
||||
audio_similarity = cosine_similarity_text(
|
||||
response.audio_content.lower(),
|
||||
|
||||
+12
-12
@@ -140,7 +140,7 @@ def test_talker2code2wav_async_chunk_final_payload_uses_absolute_token_offset():
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=True,
|
||||
)
|
||||
@@ -167,7 +167,7 @@ def test_talker2code2wav_async_chunk_emits_eof_when_finished_without_valid_codes
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=True,
|
||||
)
|
||||
@@ -188,13 +188,13 @@ def test_talker2code2wav_async_chunk_does_not_reemit_without_new_tokens():
|
||||
|
||||
payload1 = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
payload2 = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -216,14 +216,14 @@ def test_talker2code2wav_async_chunk_waits_for_prelookahead_and_emits_cumulative
|
||||
|
||||
payload_pending = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
request.output_token_ids = [1, 2, 3]
|
||||
payload_ready = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -246,14 +246,14 @@ def test_talker2code2wav_async_chunk_final_flush_uses_previous_token_offset():
|
||||
|
||||
payload_stream = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
request.output_token_ids = [3, 4, 5, 6]
|
||||
payload_final = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=True,
|
||||
)
|
||||
@@ -281,14 +281,14 @@ def test_talker2code2wav_async_chunk_respects_prompt_token_pad_on_first_chunk():
|
||||
|
||||
payload_pending = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
request.output_token_ids = [8, 9, 10, 11]
|
||||
payload_ready = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -310,13 +310,13 @@ def test_talker2code2wav_async_chunk_emits_terminal_eof_without_duplicate_audio(
|
||||
|
||||
payload_stream = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
payload_final = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=True,
|
||||
)
|
||||
|
||||
@@ -56,7 +56,7 @@ def _call(tm, rid, *, n_frames, finished=False, req_ic=None):
|
||||
tm.code_prompt_token_ids[rid] = [_FRAME[:] for _ in range(n_frames)]
|
||||
return talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.zeros((0,))}},
|
||||
multimodal_output={"codes": {"audio": torch.zeros((0,))}},
|
||||
request=_req(rid, finished=finished, initial_codec_chunk_frames=req_ic),
|
||||
is_finished=finished,
|
||||
)
|
||||
@@ -66,7 +66,7 @@ def test_empty_returns_none():
|
||||
tm = _tm()
|
||||
p = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.zeros((0,))}},
|
||||
multimodal_output={"codes": {"audio": torch.zeros((0,))}},
|
||||
request=_req("r", finished=False),
|
||||
)
|
||||
assert p is None
|
||||
@@ -76,7 +76,7 @@ def test_eof_marker_when_finished_empty():
|
||||
tm = _tm()
|
||||
p = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=_req("r", finished=True),
|
||||
is_finished=True,
|
||||
)
|
||||
@@ -89,7 +89,7 @@ def test_flush_on_finish():
|
||||
tm.code_prompt_token_ids["r"] = [_FRAME[:] for _ in range(24)]
|
||||
p = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=_req("r", finished=True),
|
||||
is_finished=True,
|
||||
)
|
||||
@@ -262,7 +262,7 @@ def test_first_streaming_chunk_prepends_ref_code_context():
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
|
||||
multimodal_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
|
||||
request=_req(rid, finished=False, initial_codec_chunk_frames=10),
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -286,7 +286,7 @@ def test_followup_ref_code_context_is_sent_as_metadata_handle():
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
|
||||
multimodal_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
|
||||
request=_req(rid, finished=False, initial_codec_chunk_frames=10),
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -319,7 +319,7 @@ def test_streaming_ref_code_context_is_bounded_for_batchable_shapes():
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
|
||||
multimodal_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
|
||||
request=_req(rid, finished=False),
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -338,7 +338,7 @@ def test_ref_code_context_can_be_buffered_before_first_emit():
|
||||
|
||||
first_payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.tensor([[1, 2, 3, 4]]), "ref": ref_code}},
|
||||
multimodal_output={"codes": {"audio": torch.tensor([[1, 2, 3, 4]]), "ref": ref_code}},
|
||||
request=_req(rid, finished=False, initial_codec_chunk_frames=10),
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -348,14 +348,14 @@ def test_ref_code_context_can_be_buffered_before_first_emit():
|
||||
for _ in range(8):
|
||||
talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.tensor([[1, 2, 3, 4]])}},
|
||||
multimodal_output={"codes": {"audio": torch.tensor([[1, 2, 3, 4]])}},
|
||||
request=_req(rid, finished=False, initial_codec_chunk_frames=10),
|
||||
is_finished=False,
|
||||
)
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.tensor([[1, 2, 3, 4]])}},
|
||||
multimodal_output={"codes": {"audio": torch.tensor([[1, 2, 3, 4]])}},
|
||||
request=_req(rid, finished=False, initial_codec_chunk_frames=10),
|
||||
is_finished=False,
|
||||
)
|
||||
|
||||
@@ -92,7 +92,7 @@ def test_empty_chunk_when_not_finished():
|
||||
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output={"audio": torch.zeros((0,))},
|
||||
multimodal_output={"audio": torch.zeros((0,))},
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -109,7 +109,7 @@ def test_flush_tail_when_finished():
|
||||
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None, # e.g. EOS step with no audio
|
||||
multimodal_output=None, # e.g. EOS step with no audio
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -133,7 +133,7 @@ def test_eof_marker_when_finished_with_no_frames():
|
||||
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -153,10 +153,10 @@ def test_normal_chunk_emission():
|
||||
|
||||
# Feed 25 frames one by one
|
||||
for i in range(25):
|
||||
pooling_output = {"audio": torch.tensor([float(i)] * 4)}
|
||||
multimodal_output = {"audio": torch.tensor([float(i)] * 4)}
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=pooling_output,
|
||||
multimodal_output=multimodal_output,
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -181,10 +181,10 @@ def test_small_initial_chunks():
|
||||
|
||||
# Feed 5 frames (should trigger emission because codec_chunk_frames_at_begin=5)
|
||||
for i in range(5):
|
||||
pooling_output = {"audio": torch.tensor([float(i + 1)] * 3)}
|
||||
multimodal_output = {"audio": torch.tensor([float(i + 1)] * 3)}
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=pooling_output,
|
||||
multimodal_output=multimodal_output,
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -208,10 +208,10 @@ def test_no_emission_between_boundaries():
|
||||
|
||||
# Feed 3 frames (3 % 5 != 0, should not emit)
|
||||
for i in range(3):
|
||||
pooling_output = {"audio": torch.tensor([float(i)] * 4)}
|
||||
multimodal_output = {"audio": torch.tensor([float(i)] * 4)}
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=pooling_output,
|
||||
multimodal_output=multimodal_output,
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -230,10 +230,10 @@ def test_context_handling_format():
|
||||
|
||||
# Feed 5 frames to trigger a chunk (chunk_size_at_begin=5)
|
||||
for i in range(5):
|
||||
pooling_output = {"audio": torch.tensor([float(i + 10)] * 2)} # codebook_dim=2
|
||||
multimodal_output = {"audio": torch.tensor([float(i + 10)] * 2)} # codebook_dim=2
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=pooling_output,
|
||||
multimodal_output=multimodal_output,
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -250,14 +250,14 @@ def test_context_handling_format():
|
||||
assert flat_codes.numel() == total_window_frames * 2 # codebook_dim=2
|
||||
|
||||
|
||||
def test_none_pooling_output_not_finished_returns_none():
|
||||
"""None pooling_output when not finished returns None."""
|
||||
def test_none_multimodal_output_not_finished_returns_none():
|
||||
"""None multimodal_output when not finished returns None."""
|
||||
transfer_manager = _make_transfer_manager()
|
||||
request = _req("rid-none", finished=False)
|
||||
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ without requiring the actual checkpoint or GPU.
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections import OrderedDict, defaultdict
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -1156,6 +1157,31 @@ class TestStageInputProcessor:
|
||||
assert result[0, 1].item() == 200
|
||||
assert result[0, 2].item() == 300
|
||||
|
||||
def test_async_chunk_accepts_multimodal_output_keyword(self):
|
||||
from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import (
|
||||
talker2code2wav_async_chunk,
|
||||
)
|
||||
|
||||
request_id = "req-higgs"
|
||||
transfer_manager = SimpleNamespace(
|
||||
code_prompt_token_ids=defaultdict(list),
|
||||
connector=SimpleNamespace(config={"extra": {"codec_chunk_frames": 1, "codec_right_holdback_frames": 0}}),
|
||||
)
|
||||
request = SimpleNamespace(external_req_id=request_id, is_finished=lambda: False)
|
||||
|
||||
result = None
|
||||
for step in range(8):
|
||||
result = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
multimodal_output={"codes": {"audio": torch.full((1, 8), step, dtype=torch.long)}},
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.codes is not None
|
||||
assert result.codes.audio.numel() > 0
|
||||
|
||||
|
||||
# ---- AC-10: Registry ----
|
||||
|
||||
|
||||
@@ -92,6 +92,9 @@ def test_sample_tokens_tail_only_prefix_cache_uses_staged_cpu_hidden_states(monk
|
||||
runner.supports_mm_inputs = False
|
||||
runner.use_async_scheduling = False
|
||||
runner._omni_num_scheduled_tokens_np = None
|
||||
runner.vllm_config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(engine_output_type="audio"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
GPUARModelRunner, "_sample", lambda self, logits, spec_decode_metadata: SimpleNamespace(sampled_token_ids=[])
|
||||
@@ -125,8 +128,8 @@ def test_sample_tokens_tail_only_prefix_cache_uses_staged_cpu_hidden_states(monk
|
||||
|
||||
output = GPUARModelRunner.sample_tokens(runner, grammar_output=None)
|
||||
|
||||
assert torch.equal(output.pooler_output[0]["hidden"], torch.tensor([[1.0, 10.0]]))
|
||||
assert torch.equal(output.multimodal_outputs[0]["hidden"], torch.tensor([[1.0, 10.0]]))
|
||||
assert torch.equal(
|
||||
output.pooler_output[1]["hidden"],
|
||||
output.multimodal_outputs[1]["hidden"],
|
||||
torch.tensor([[2.0, 20.0], [3.0, 30.0]]),
|
||||
)
|
||||
@@ -49,8 +49,8 @@ def test_sample_tokens_tensor_output():
|
||||
|
||||
output = GPUGenerationModelRunner.sample_tokens(runner)
|
||||
|
||||
assert len(output.pooler_output) == 1
|
||||
assert output.pooler_output[0]["model_outputs"].shape == (2, 3)
|
||||
assert len(output.multimodal_outputs) == 1
|
||||
assert output.multimodal_outputs[0]["model_outputs"].shape == (2, 3)
|
||||
|
||||
|
||||
def test_sample_tokens_list_output():
|
||||
@@ -59,8 +59,8 @@ def test_sample_tokens_list_output():
|
||||
|
||||
output = GPUGenerationModelRunner.sample_tokens(runner)
|
||||
|
||||
assert len(output.pooler_output) == 1
|
||||
assert output.pooler_output[0]["model_outputs"].shape == (2, 1)
|
||||
assert len(output.multimodal_outputs) == 1
|
||||
assert output.multimodal_outputs[0]["model_outputs"].shape == (2, 1)
|
||||
|
||||
|
||||
def test_sample_tokens_list_allows_none_output():
|
||||
@@ -69,8 +69,8 @@ def test_sample_tokens_list_allows_none_output():
|
||||
|
||||
output = GPUGenerationModelRunner.sample_tokens(runner)
|
||||
|
||||
assert len(output.pooler_output) == 1
|
||||
assert output.pooler_output[0]["model_outputs"] is None
|
||||
assert len(output.multimodal_outputs) == 1
|
||||
assert output.multimodal_outputs[0]["model_outputs"] is None
|
||||
|
||||
|
||||
def test_sample_tokens_dict_output():
|
||||
@@ -79,7 +79,7 @@ def test_sample_tokens_dict_output():
|
||||
|
||||
output = GPUGenerationModelRunner.sample_tokens(runner)
|
||||
|
||||
assert len(output.pooler_output) == 1
|
||||
assert "audio" in output.pooler_output[0]
|
||||
assert "unused" not in output.pooler_output[0]
|
||||
assert output.pooler_output[0]["audio"].shape == (1, 4)
|
||||
assert len(output.multimodal_outputs) == 1
|
||||
assert "audio" in output.multimodal_outputs[0]
|
||||
assert "unused" not in output.multimodal_outputs[0]
|
||||
assert output.multimodal_outputs[0]["audio"].shape == (1, 4)
|
||||
@@ -288,6 +288,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
prompt_logprobs_dict = model_runner_output.prompt_logprobs_dict
|
||||
num_scheduled_tokens = scheduler_output.num_scheduled_tokens
|
||||
pooler_outputs = model_runner_output.pooler_output
|
||||
mm_outputs = getattr(model_runner_output, "multimodal_outputs", None)
|
||||
num_nans_in_logits = model_runner_output.num_nans_in_logits
|
||||
kv_connector_output = model_runner_output.kv_connector_output
|
||||
cudagraph_stats: CUDAGraphStat | None = model_runner_output.cudagraph_stats
|
||||
@@ -389,6 +390,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
new_logprobs = None
|
||||
new_token_ids = generated_token_ids
|
||||
pooler_output = pooler_outputs[req_index] if pooler_outputs else None
|
||||
mm_output = mm_outputs[req_index] if mm_outputs else None
|
||||
kv_transfer_params = None
|
||||
status_before_stop = request.status
|
||||
finish_reason = None
|
||||
@@ -463,7 +465,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
|
||||
# Get prompt logprobs for this request.
|
||||
prompt_logprobs_tensors = prompt_logprobs_dict.get(req_id)
|
||||
if new_token_ids or pooler_output is not None or kv_transfer_params or stopped:
|
||||
if new_token_ids or mm_output is not None or pooler_output is not None or kv_transfer_params or stopped:
|
||||
# Add EngineCoreOutput for this Request.
|
||||
outputs[request.client_index].append(
|
||||
OmniEngineCoreOutput(
|
||||
@@ -473,6 +475,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
new_logprobs=new_logprobs,
|
||||
new_prompt_logprobs_tensors=prompt_logprobs_tensors,
|
||||
pooling_output=pooler_output,
|
||||
multimodal_output=mm_output,
|
||||
stop_reason=request.stop_reason,
|
||||
events=request.take_events(),
|
||||
prefill_stats=request.take_prefill_stats(),
|
||||
@@ -485,7 +488,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
)
|
||||
)
|
||||
if self.chunk_transfer_adapter is not None:
|
||||
self.chunk_transfer_adapter.save_async(pooler_output, request, is_segment_finished)
|
||||
self.chunk_transfer_adapter.save_async(mm_output, request, is_segment_finished)
|
||||
else:
|
||||
# Invariant: EngineCore returns no partial prefill outputs.
|
||||
assert not prompt_logprobs_tensors
|
||||
|
||||
@@ -420,6 +420,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
prompt_logprobs_dict = model_runner_output.prompt_logprobs_dict
|
||||
num_scheduled_tokens = scheduler_output.num_scheduled_tokens
|
||||
pooler_outputs = model_runner_output.pooler_output
|
||||
mm_outputs = getattr(model_runner_output, "multimodal_outputs", None)
|
||||
num_nans_in_logits = model_runner_output.num_nans_in_logits
|
||||
kv_connector_output = model_runner_output.kv_connector_output
|
||||
|
||||
@@ -498,6 +499,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
new_token_ids = generated_token_ids
|
||||
kv_transfer_params = None
|
||||
pooler_output = pooler_outputs[req_index] if pooler_outputs else None
|
||||
mm_output = mm_outputs[req_index] if mm_outputs else None
|
||||
status_before_stop = request.status
|
||||
finish_reason = None
|
||||
routed_experts = None
|
||||
@@ -558,7 +560,8 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
|
||||
# Get prompt logprobs for this request.
|
||||
prompt_logprobs_tensors = prompt_logprobs_dict.get(req_id)
|
||||
if new_token_ids or pooler_output is not None or kv_transfer_params or stopped:
|
||||
if new_token_ids or mm_output is not None or pooler_output is not None or kv_transfer_params or stopped:
|
||||
# Add EngineCoreOutput for this Request.
|
||||
outputs[request.client_index].append(
|
||||
OmniEngineCoreOutput(
|
||||
request_id=req_id,
|
||||
@@ -567,6 +570,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
new_logprobs=new_logprobs,
|
||||
new_prompt_logprobs_tensors=prompt_logprobs_tensors,
|
||||
pooling_output=pooler_output,
|
||||
multimodal_output=mm_output,
|
||||
stop_reason=request.stop_reason,
|
||||
events=request.take_events(),
|
||||
prefill_stats=request.take_prefill_stats(),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import importlib
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -146,7 +146,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
|
||||
def save_async(
|
||||
self,
|
||||
pooling_output: torch.Tensor | None = None,
|
||||
multimodal_output: dict[str, Any] | None = None,
|
||||
request: Request | None = None,
|
||||
is_segment_finished: bool = False,
|
||||
):
|
||||
@@ -163,7 +163,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
as ``request.is_finished()``.
|
||||
|
||||
Args:
|
||||
pooling_output: Partial pooling output dictionary
|
||||
multimodal_output: Per-request multimodal output dictionary
|
||||
request: Request object
|
||||
is_segment_finished: whether the segment of request is finished
|
||||
"""
|
||||
@@ -183,7 +183,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
|
||||
self.requests_num_chunks_sent[request.external_req_id] = confirmed_num_computed_tokens
|
||||
task = {
|
||||
"pooling_output": pooling_output,
|
||||
"multimodal_output": multimodal_output,
|
||||
"request": request,
|
||||
"is_finished": is_finished,
|
||||
"is_segment_finished": is_segment_finished,
|
||||
@@ -292,8 +292,8 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
return False
|
||||
|
||||
def _send_single_request(self, task: dict):
|
||||
raw_po = task["pooling_output"]
|
||||
pooling_output = unflatten_payload(raw_po) if isinstance(raw_po, dict) else raw_po
|
||||
raw_mm = task["multimodal_output"]
|
||||
multimodal_output = unflatten_payload(raw_mm) if isinstance(raw_mm, Mapping) else raw_mm
|
||||
request = task["request"]
|
||||
is_finished = task["is_finished"]
|
||||
is_segment_finished = task["is_segment_finished"]
|
||||
@@ -308,7 +308,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
try:
|
||||
payload_data = self.custom_process_next_stage_input_func(
|
||||
transfer_manager=self,
|
||||
pooling_output=pooling_output,
|
||||
multimodal_output=multimodal_output,
|
||||
request=request,
|
||||
# Existing processors use is_finished as a flush signal.
|
||||
is_finished=is_segment_finished,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any
|
||||
|
||||
@@ -150,10 +151,13 @@ class OmniMsgpackEncoder:
|
||||
"multi_modal_placeholders": getattr(obj, "multi_modal_placeholders", None),
|
||||
"kv_transfer_params": obj.kv_transfer_params,
|
||||
}
|
||||
# Handle dynamically added multimodal_output attribute
|
||||
# Handle multimodal_output attribute (MultimodalPayload or dict)
|
||||
mm_output = getattr(obj, "multimodal_output", None)
|
||||
if mm_output is not None:
|
||||
result["multimodal_output"] = mm_output
|
||||
if isinstance(mm_output, Mapping):
|
||||
result["multimodal_output"] = dict(mm_output)
|
||||
else:
|
||||
result["multimodal_output"] = mm_output
|
||||
return result
|
||||
|
||||
def _encode_completion_output(self, obj: CompletionOutput) -> dict[str, Any]:
|
||||
@@ -161,7 +165,11 @@ class OmniMsgpackEncoder:
|
||||
result = asdict(obj)
|
||||
mm_output = getattr(obj, "multimodal_output", None)
|
||||
if mm_output is not None:
|
||||
result["multimodal_output"] = mm_output
|
||||
# Convert MultimodalPayload to plain dict for wire format
|
||||
if isinstance(mm_output, Mapping):
|
||||
result["multimodal_output"] = dict(mm_output)
|
||||
else:
|
||||
result["multimodal_output"] = mm_output
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -117,7 +117,10 @@ class OmniEngineCoreRequest(EngineCoreRequest):
|
||||
|
||||
|
||||
class OmniEngineCoreOutput(EngineCoreOutput):
|
||||
pooling_output: dict[str, torch.Tensor] | None = None
|
||||
# Dedicated channel for multimodal outputs (image/audio/latent).
|
||||
# pooling_output is inherited from EngineCoreOutput as torch.Tensor | None
|
||||
# and retains its original vLLM semantics for pooling/embedding tasks.
|
||||
multimodal_output: dict[str, torch.Tensor] | None = None
|
||||
# Finished flag for streaming input segment
|
||||
is_segment_finished: bool | None = False
|
||||
# Streaming update prompt length
|
||||
|
||||
@@ -1480,7 +1480,7 @@ class AsyncOmniEngine:
|
||||
return
|
||||
|
||||
mm_data = prompt.get("multi_modal_data")
|
||||
if not isinstance(mm_data, dict) or not mm_data:
|
||||
if not isinstance(mm_data, Mapping) or not mm_data:
|
||||
return
|
||||
|
||||
from vllm.multimodal.hasher import MultiModalHasher
|
||||
|
||||
@@ -6,6 +6,7 @@ This module defines structured types for multimodal outputs.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -13,10 +14,15 @@ import torch
|
||||
from vllm.outputs import CompletionOutput
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultimodalPayload:
|
||||
@dataclass(eq=False)
|
||||
class MultimodalPayload(Mapping):
|
||||
"""Structured multimodal output payload.
|
||||
|
||||
Implements ``collections.abc.Mapping`` so that ``isinstance(payload, dict)``
|
||||
style checks in downstream code can be replaced with duck-typing, and
|
||||
``payload.get(key)``, ``payload[key]``, ``key in payload``, ``len(payload)``
|
||||
all work seamlessly for both tensors and metadata.
|
||||
|
||||
Attributes:
|
||||
tensors: Dictionary mapping modality/key names to their tensors.
|
||||
metadata: Optional dictionary for non-tensor metadata
|
||||
@@ -35,18 +41,49 @@ class MultimodalPayload:
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""Return True if the payload has no tensors."""
|
||||
return len(self.tensors) == 0
|
||||
"""Return True if the payload has no tensors and no metadata."""
|
||||
return len(self.tensors) == 0 and len(self.metadata) == 0
|
||||
|
||||
def get(self, key: str) -> torch.Tensor | None:
|
||||
"""Get a tensor by key, returning None if not found."""
|
||||
return self.tensors.get(key)
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a value by key, searching tensors first then metadata."""
|
||||
if key in self.tensors:
|
||||
return self.tensors[key]
|
||||
return self.metadata.get(key, default)
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return key in self.tensors
|
||||
return key in self.tensors or key in self.metadata
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Dict-like indexing: search tensors first, then metadata."""
|
||||
if key in self.tensors:
|
||||
return self.tensors[key]
|
||||
if key in self.metadata:
|
||||
return self.metadata[key]
|
||||
raise KeyError(key)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.tensors)
|
||||
return len(self.tensors) + len(self.metadata)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
yield from self.tensors
|
||||
yield from self.metadata
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.tensors) or bool(self.metadata)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
"""Support equality with plain dicts and other Mappings."""
|
||||
if isinstance(other, MultimodalPayload):
|
||||
return self.tensors == other.tensors and self.metadata == other.metadata
|
||||
if isinstance(other, Mapping):
|
||||
return dict(self) == dict(other)
|
||||
return NotImplemented
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert back to a plain dict (tensors + metadata merged)."""
|
||||
result: dict[str, Any] = dict(self.tensors)
|
||||
result.update(self.metadata)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> MultimodalPayload | None:
|
||||
|
||||
@@ -21,6 +21,8 @@ _MODALITY_ALIASES: dict[str, str] = {
|
||||
"waveform": "audio",
|
||||
"pixel_values": "image",
|
||||
"pixels": "image",
|
||||
"token_ids": "text",
|
||||
"tokens": "text",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from dataclasses import fields as dataclass_fields
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
from vllm.outputs import PoolingRequestOutput
|
||||
from vllm.outputs import CompletionOutput, PoolingRequestOutput, RequestOutput
|
||||
from vllm.sampling_params import RequestOutputKind
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
from vllm.v1.engine import EngineCoreOutput, EngineCoreRequest, FinishReason
|
||||
@@ -16,12 +17,98 @@ from vllm.v1.engine.parallel_sampling import ParentRequest
|
||||
from vllm.v1.metrics.stats import IterationStats, RequestStateStats
|
||||
|
||||
from vllm_omni.data_entry_keys import unflatten_payload
|
||||
from vllm_omni.engine.output_modality import DRAINABLE_MODALITIES
|
||||
from vllm_omni.engine.mm_outputs import MultimodalCompletionOutput, MultimodalPayload
|
||||
from vllm_omni.engine.output_modality import (
|
||||
DRAINABLE_MODALITIES,
|
||||
OutputModality,
|
||||
TensorAccumulationStrategy,
|
||||
get_accumulation_strategy,
|
||||
)
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _to_cpu(x: Any) -> Any:
|
||||
"""Move a tensor to CPU; pass through non-tensors unchanged."""
|
||||
if isinstance(x, torch.Tensor):
|
||||
try:
|
||||
return x.detach().to("cpu", non_blocking=True).contiguous()
|
||||
except (RuntimeError, AttributeError):
|
||||
return x
|
||||
if isinstance(x, dict):
|
||||
return {str(sk): _to_cpu(sv) for sk, sv in x.items()}
|
||||
return x
|
||||
|
||||
|
||||
def _merge_payload(target: MultimodalPayload, incoming: MultimodalPayload) -> None:
|
||||
"""Merge *incoming* into *target* using list-based deferred concatenation."""
|
||||
for k, v in incoming.tensors.items():
|
||||
if k not in target.tensors:
|
||||
target.tensors[k] = v
|
||||
else:
|
||||
existing = target.tensors[k]
|
||||
if isinstance(existing, list):
|
||||
existing.append(v)
|
||||
elif isinstance(existing, torch.Tensor) and isinstance(v, torch.Tensor):
|
||||
target.tensors[k] = [existing, v]
|
||||
else:
|
||||
target.tensors[k] = v
|
||||
|
||||
for k, v in incoming.metadata.items():
|
||||
if k not in target.metadata:
|
||||
target.metadata[k] = v
|
||||
else:
|
||||
existing = target.metadata[k]
|
||||
if isinstance(v, torch.Tensor) and isinstance(existing, torch.Tensor):
|
||||
target.metadata[k] = [existing, v]
|
||||
elif isinstance(v, torch.Tensor) and isinstance(existing, list):
|
||||
existing.append(v)
|
||||
else:
|
||||
# Metadata: replace with latest value
|
||||
target.metadata[k] = v
|
||||
|
||||
|
||||
def _cat_tensors(
|
||||
tensors: list[torch.Tensor],
|
||||
strategy: TensorAccumulationStrategy,
|
||||
) -> torch.Tensor:
|
||||
"""Concatenate a list of tensors according to *strategy*."""
|
||||
if strategy == TensorAccumulationStrategy.CONCAT_LAST:
|
||||
return torch.cat(tensors, dim=-1)
|
||||
if strategy == TensorAccumulationStrategy.REPLACE:
|
||||
return tensors[-1]
|
||||
# CONCAT_DIM0 / APPEND_LIST / default
|
||||
return torch.cat(tensors, dim=0)
|
||||
|
||||
|
||||
def _modality_to_type_string(modality: OutputModality) -> str:
|
||||
"""Convert an OutputModality flag to a lowercase type string."""
|
||||
try:
|
||||
if OutputModality.AUDIO in modality:
|
||||
return "audio"
|
||||
if OutputModality.IMAGE in modality:
|
||||
return "image"
|
||||
if OutputModality.LATENT in modality:
|
||||
return "latent"
|
||||
except TypeError:
|
||||
# Flag identity mismatch (e.g. after module reload in tests).
|
||||
name = getattr(modality, "name", "") or ""
|
||||
lowered = name.lower()
|
||||
if "audio" in lowered:
|
||||
return "audio"
|
||||
if "image" in lowered:
|
||||
return "image"
|
||||
if "latent" in lowered:
|
||||
return "latent"
|
||||
return "text"
|
||||
|
||||
|
||||
class OmniRequestState(RequestState):
|
||||
"""Request state for omni models, tracking multimodal outputs.
|
||||
|
||||
@@ -41,10 +128,12 @@ class OmniRequestState(RequestState):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.native_text_stats = RequestStateStats(arrival_time=float(arrival_time or 0.0))
|
||||
# Omni-specific: multimodal output accumulation
|
||||
# NOTE: Keys in mm_accumulated matter, because they dictate which
|
||||
# outputs are drained (i.e., only drain modality keys, don't drain
|
||||
# hidden states).
|
||||
self.mm_accumulated: dict[str, Any] = {}
|
||||
# TODO: mm_type is per-request, not per-key. If a model ever produces
|
||||
# both audio and latent outputs, the modality type would flip on each
|
||||
# add_multimodal_tensor() call. Consider tracking per-key modality
|
||||
# types (e.g. dict[str, str]) for future multi-output models.
|
||||
self.mm_type: str | None = None
|
||||
self.mm_accumulated: MultimodalPayload = MultimodalPayload()
|
||||
|
||||
def apply_streaming_update(self, update) -> None:
|
||||
super().apply_streaming_update(update)
|
||||
@@ -53,8 +142,6 @@ class OmniRequestState(RequestState):
|
||||
@staticmethod
|
||||
def _to_cpu(x):
|
||||
"""Try to convert to CPU tensor if needed."""
|
||||
# TODO: Make this more robust and unify with other payload
|
||||
# building utils, we do this in multiple places.
|
||||
if isinstance(x, torch.Tensor):
|
||||
try:
|
||||
return x.detach().to("cpu", non_blocking=True).contiguous()
|
||||
@@ -63,109 +150,94 @@ class OmniRequestState(RequestState):
|
||||
return x
|
||||
|
||||
def add_multimodal_tensor(self, payload: Any | None, mm_type: str | None) -> None:
|
||||
"""Accumulate a multimodal tensor payload into the request state.
|
||||
|
||||
Normalizes incoming payloads (dict or raw tensor) into a
|
||||
MultimodalPayload and merges with any previously accumulated data.
|
||||
Uses list-based deferred concatenation to avoid O(n²) repeated
|
||||
torch.cat calls.
|
||||
"""
|
||||
if payload is None:
|
||||
return
|
||||
|
||||
mm_type = (mm_type or "").lower()
|
||||
try:
|
||||
if mm_type:
|
||||
self.mm_type = (mm_type or "").lower()
|
||||
|
||||
target_key = self.mm_type or "hidden"
|
||||
|
||||
# Build incoming MultimodalPayload
|
||||
if isinstance(payload, dict):
|
||||
# Keep payload flat (dotted keys like "hidden_states.layer_0")
|
||||
# during accumulation so that all values are tensors/scalars and
|
||||
# the merge logic below works correctly. Unflatten happens
|
||||
# later in _consolidate_multimodal_tensors after concatenation.
|
||||
|
||||
incoming: dict[str, Any] = {}
|
||||
# TODO (Alex): Clean up and simplify key management
|
||||
target_key = mm_type or "hidden"
|
||||
|
||||
remapped: dict[str, Any] = {}
|
||||
for k, v in payload.items():
|
||||
# Normalize producer keys to the modality name.
|
||||
# AR runners produce {"hidden": ...} and generation
|
||||
# runners produce {"model_outputs": ...}; remap both
|
||||
# to the semantic modality key (e.g. "audio", "latent").
|
||||
if k == "model_outputs":
|
||||
k = target_key
|
||||
elif k == "hidden" and target_key != "hidden":
|
||||
k = target_key
|
||||
|
||||
incoming[k] = self._to_cpu(v)
|
||||
remapped[k] = _to_cpu(v)
|
||||
incoming = MultimodalPayload.from_dict(remapped)
|
||||
elif isinstance(payload, MultimodalPayload):
|
||||
incoming = payload
|
||||
else:
|
||||
key = mm_type or "hidden"
|
||||
incoming = {key: self._to_cpu(payload)}
|
||||
incoming = MultimodalPayload.from_dict({target_key: _to_cpu(payload)})
|
||||
|
||||
if not self.mm_accumulated:
|
||||
if incoming is None:
|
||||
return
|
||||
|
||||
if self.mm_accumulated.is_empty:
|
||||
self.mm_accumulated = incoming
|
||||
else:
|
||||
for k, v in incoming.items():
|
||||
if k not in self.mm_accumulated:
|
||||
self.mm_accumulated[k] = v
|
||||
else:
|
||||
existing = self.mm_accumulated[k]
|
||||
if isinstance(v, torch.Tensor) and isinstance(existing, torch.Tensor):
|
||||
self.mm_accumulated[k] = [existing, v]
|
||||
elif isinstance(v, torch.Tensor) and isinstance(existing, list):
|
||||
existing.append(v)
|
||||
elif isinstance(v, dict) and isinstance(existing, dict):
|
||||
for sk, sv in v.items():
|
||||
if sk not in existing:
|
||||
existing[sk] = sv
|
||||
elif isinstance(sv, torch.Tensor) and isinstance(existing[sk], torch.Tensor):
|
||||
existing[sk] = [existing[sk], sv]
|
||||
elif isinstance(sv, torch.Tensor) and isinstance(existing[sk], list):
|
||||
existing[sk].append(sv)
|
||||
else:
|
||||
existing[sk] = sv
|
||||
else:
|
||||
self.mm_accumulated[k] = v
|
||||
except Exception:
|
||||
# Log and continue without crashing the output pipeline
|
||||
_merge_payload(self.mm_accumulated, incoming)
|
||||
except (ValueError, TypeError, RuntimeError):
|
||||
logger.exception("Error accumulating multimodal tensor")
|
||||
|
||||
def _consolidate_multimodal_tensors(self) -> None:
|
||||
"""Consolidate accumulated tensor lists into single tensors via concatenation.
|
||||
"""Consolidate accumulated tensor lists into single tensors.
|
||||
|
||||
Only DELTA drains modality keys per-step, so they will never be lists here and
|
||||
can be skipped. For CUMULATIVE and FINAL_ONLY, modality keys accumulate across
|
||||
steps and need consolidation.
|
||||
Uses TensorAccumulationStrategy derived from the output modality
|
||||
to determine concatenation behavior. Metadata values always use
|
||||
REPLACE (keep latest).
|
||||
"""
|
||||
if not self.mm_accumulated:
|
||||
if self.mm_accumulated.is_empty:
|
||||
return
|
||||
|
||||
skip_modality = self.output_kind == RequestOutputKind.DELTA
|
||||
try:
|
||||
for k, v in self.mm_accumulated.items():
|
||||
if skip_modality and k in DRAINABLE_MODALITIES:
|
||||
continue
|
||||
modality = OutputModality.from_string(self.mm_type)
|
||||
except (ValueError, KeyError):
|
||||
modality = OutputModality.TEXT
|
||||
strategy = get_accumulation_strategy(modality)
|
||||
|
||||
try:
|
||||
for k, v in list(self.mm_accumulated.tensors.items()):
|
||||
if isinstance(v, list) and v and isinstance(v[0], torch.Tensor):
|
||||
try:
|
||||
self.mm_accumulated.tensors[k] = _cat_tensors(v, strategy)
|
||||
except RuntimeError:
|
||||
if k == "audio":
|
||||
# Preserve channel dimension when chunks are compatible;
|
||||
# fall back to 1-D waveform concatenation for uneven chunks.
|
||||
try:
|
||||
self.mm_accumulated[k] = torch.cat(v, dim=-1)
|
||||
self.mm_accumulated.tensors[k] = torch.cat(v, dim=-1)
|
||||
except RuntimeError:
|
||||
self.mm_accumulated[k] = torch.cat([t.reshape(-1) for t in v], dim=0)
|
||||
elif k == "sr":
|
||||
# Sample rate is a constant scalar, keep last value.
|
||||
self.mm_accumulated[k] = v[-1]
|
||||
self.mm_accumulated.tensors[k] = torch.cat([t.reshape(-1) for t in v], dim=0)
|
||||
else:
|
||||
self.mm_accumulated[k] = torch.cat(v, dim=0)
|
||||
except Exception:
|
||||
# Keep last tensor on failure
|
||||
logger.warning(f"Error concatenating tensor for key {k}; keeping last tensor")
|
||||
self.mm_accumulated[k] = v[-1]
|
||||
elif isinstance(v, dict):
|
||||
for sk, sv in v.items():
|
||||
if isinstance(sv, list) and sv and isinstance(sv[0], torch.Tensor):
|
||||
try:
|
||||
v[sk] = torch.cat(sv, dim=0)
|
||||
except Exception:
|
||||
v[sk] = sv[-1]
|
||||
except Exception:
|
||||
logger.exception("Error consolidating multimodal tensors")
|
||||
logger.warning(
|
||||
"Error concatenating tensor for key %s; keeping last tensor",
|
||||
k,
|
||||
)
|
||||
self.mm_accumulated.tensors[k] = v[-1]
|
||||
|
||||
# Restore nested structure from flat dotted keys now that all tensor
|
||||
# lists have been concatenated into single tensors.
|
||||
try:
|
||||
self.mm_accumulated = unflatten_payload(self.mm_accumulated)
|
||||
except Exception:
|
||||
logger.exception("Error unflattening consolidated multimodal tensors")
|
||||
# Metadata: consolidate any deferred tensor lists (REPLACE semantics)
|
||||
for k, v in list(self.mm_accumulated.metadata.items()):
|
||||
if isinstance(v, list) and v and isinstance(v[0], torch.Tensor):
|
||||
self.mm_accumulated.metadata[k] = v[-1]
|
||||
elif isinstance(v, dict):
|
||||
for sk, sv in list(v.items()):
|
||||
if isinstance(sv, list) and sv and isinstance(sv[0], torch.Tensor):
|
||||
v[sk] = sv[-1]
|
||||
except (RuntimeError, TypeError, KeyError):
|
||||
logger.exception("Error consolidating multimodal tensors")
|
||||
|
||||
# Override: do not route to pooling-only path; always create completion
|
||||
# outputs, and attach pooling_result into the CompletionOutput.
|
||||
@@ -205,18 +277,18 @@ class OmniRequestState(RequestState):
|
||||
)
|
||||
|
||||
finished = finish_reason is not None
|
||||
is_final_only = self.output_kind == RequestOutputKind.FINAL_ONLY
|
||||
final_only = self.output_kind == RequestOutputKind.FINAL_ONLY
|
||||
is_delta = self.output_kind == RequestOutputKind.DELTA
|
||||
|
||||
if not finished and is_final_only:
|
||||
if not finished and final_only:
|
||||
return None
|
||||
|
||||
# Consolidate accumulated tensors when finishing, or for every
|
||||
# CUMULATIVE step so the consumer always sees a single tensor.
|
||||
if finished or not is_delta:
|
||||
self._consolidate_multimodal_tensors()
|
||||
|
||||
if self.stream_interval > 1:
|
||||
assert self.detokenizer is not None
|
||||
|
||||
if self.stream_interval > 1 and self.detokenizer is not None:
|
||||
# Send output request only when
|
||||
# 1. It has finished, or
|
||||
# 2. It is the first token, or
|
||||
@@ -228,7 +300,7 @@ class OmniRequestState(RequestState):
|
||||
):
|
||||
return None
|
||||
|
||||
if is_delta:
|
||||
if self.output_kind == RequestOutputKind.DELTA:
|
||||
# Send tokens from the offset in DELTA mode, otherwise all
|
||||
# tokens are sent.
|
||||
new_token_ids = self.detokenizer.output_token_ids[self.sent_tokens_offset :]
|
||||
@@ -258,50 +330,127 @@ class OmniRequestState(RequestState):
|
||||
token_ids: list[int],
|
||||
finish_reason: FinishReason | None,
|
||||
stop_reason: int | str | None,
|
||||
) -> Any:
|
||||
# Reuse base text/logprobs logic, then annotate with pooling_result.
|
||||
# Note: upstream _new_completion_output no longer accepts routed_experts
|
||||
# as a parameter; it reads from ``self.routed_experts_chunks`` internally.
|
||||
base_output = super()._new_completion_output(token_ids, finish_reason, stop_reason)
|
||||
) -> MultimodalCompletionOutput | CompletionOutput:
|
||||
"""Create a completion output with multimodal data attached.
|
||||
|
||||
# Inter-stage processors need the full cumulative token sequence.
|
||||
# In DELTA mode, base_output.token_ids only has the latest step's
|
||||
# tokens, so we always store a cumulative copy here.
|
||||
base_output.cumulative_token_ids = list(self.detokenizer.output_token_ids)
|
||||
Returns a MultimodalCompletionOutput when multimodal data has been
|
||||
accumulated, otherwise returns the base CompletionOutput. Snapshots
|
||||
the accumulated payload before draining DELTA-mode modality keys so
|
||||
that callers receive an immutable view.
|
||||
"""
|
||||
# When there is no detokenizer (generation stages), build a minimal
|
||||
# CompletionOutput directly since upstream asserts detokenizer != None.
|
||||
if self.detokenizer is None:
|
||||
finished = finish_reason is not None
|
||||
base_output = CompletionOutput(
|
||||
index=self.request_index,
|
||||
text="",
|
||||
token_ids=token_ids,
|
||||
logprobs=None,
|
||||
cumulative_logprob=None,
|
||||
finish_reason=str(finish_reason) if finished else None,
|
||||
stop_reason=stop_reason if finished else None,
|
||||
)
|
||||
else:
|
||||
base_output = super()._new_completion_output(token_ids, finish_reason, stop_reason)
|
||||
|
||||
# Always provide cumulative token IDs for inter-stage processors.
|
||||
if self.detokenizer is not None:
|
||||
base_output.cumulative_token_ids = list(self.detokenizer.output_token_ids)
|
||||
else:
|
||||
base_output.cumulative_token_ids = list(token_ids)
|
||||
|
||||
# Attach cumulative_text only at the final step for inter-stage use.
|
||||
if finish_reason is not None and hasattr(self.detokenizer, "output_text"):
|
||||
base_output.cumulative_text = self.detokenizer.output_text
|
||||
|
||||
if not hasattr(base_output, "multimodal_output"):
|
||||
setattr(base_output, "multimodal_output", {})
|
||||
if self.mm_accumulated:
|
||||
mm_out = getattr(base_output, "multimodal_output")
|
||||
if isinstance(mm_out, dict):
|
||||
for k, v in self.mm_accumulated.items():
|
||||
mm_out[k] = v
|
||||
else:
|
||||
setattr(base_output, "multimodal_output", self.mm_accumulated)
|
||||
try:
|
||||
if self.mm_accumulated and not self.mm_accumulated.is_empty:
|
||||
# Snapshot: copy current tensors/metadata so drain doesn't
|
||||
# mutate the payload already handed to the caller.
|
||||
# Unflatten dotted keys (e.g. "hidden_states.layer_0") back
|
||||
# to nested dicts so downstream consumers (thinker2talker etc.)
|
||||
# can access with .get("hidden_states", {}).get("layers", {}).
|
||||
merged = {**self.mm_accumulated.tensors, **self.mm_accumulated.metadata}
|
||||
nested = unflatten_payload(merged)
|
||||
snapshot = MultimodalPayload.from_dict(nested) or MultimodalPayload()
|
||||
kwargs = {f.name: getattr(base_output, f.name) for f in dataclass_fields(CompletionOutput)}
|
||||
output = MultimodalCompletionOutput(
|
||||
multimodal_output=snapshot,
|
||||
**kwargs,
|
||||
)
|
||||
output.cumulative_token_ids = base_output.cumulative_token_ids
|
||||
if hasattr(base_output, "cumulative_text"):
|
||||
output.cumulative_text = base_output.cumulative_text
|
||||
|
||||
if self.output_kind == RequestOutputKind.DELTA:
|
||||
for modality_key in DRAINABLE_MODALITIES:
|
||||
self.mm_accumulated.pop(modality_key, None)
|
||||
# DELTA mode: drain modality keys (e.g. audio) so the next
|
||||
# step only sees freshly accumulated data for those keys.
|
||||
if self.output_kind == RequestOutputKind.DELTA:
|
||||
for modality_key in DRAINABLE_MODALITIES:
|
||||
self.mm_accumulated.tensors.pop(modality_key, None)
|
||||
|
||||
return output
|
||||
except (RuntimeError, TypeError, AttributeError):
|
||||
logger.exception("Error creating MultimodalCompletionOutput")
|
||||
return base_output
|
||||
|
||||
def _new_request_output(
|
||||
self,
|
||||
external_req_id: str,
|
||||
outputs: list,
|
||||
finished: bool,
|
||||
kv_transfer_params: dict[str, Any] | None = None,
|
||||
) -> RequestOutput | PoolingRequestOutput:
|
||||
"""Create request output, handling no-detokenizer generation stages.
|
||||
|
||||
Upstream asserts ``self.logprobs_processor is not None`` which fails
|
||||
for generation stages (talker, code2wav) that have no tokenizer.
|
||||
When the logprobs processor is absent we build the RequestOutput
|
||||
directly with ``prompt_logprobs=None``.
|
||||
"""
|
||||
if self.logprobs_processor is not None:
|
||||
return super()._new_request_output(
|
||||
external_req_id,
|
||||
outputs,
|
||||
finished,
|
||||
kv_transfer_params,
|
||||
)
|
||||
|
||||
# No-detokenizer path: build RequestOutput directly.
|
||||
prompt_token_ids = self.prompt_token_ids
|
||||
if prompt_token_ids is None and self.prompt_embeds is not None:
|
||||
prompt_token_ids = [0] * len(self.prompt_embeds)
|
||||
if prompt_token_ids is None:
|
||||
prompt_token_ids = []
|
||||
|
||||
return RequestOutput(
|
||||
request_id=external_req_id,
|
||||
lora_request=self.lora_request,
|
||||
prompt=self.prompt,
|
||||
prompt_token_ids=prompt_token_ids,
|
||||
prompt_logprobs=None,
|
||||
outputs=outputs,
|
||||
finished=finished,
|
||||
kv_transfer_params=kv_transfer_params,
|
||||
num_cached_tokens=self.num_cached_tokens,
|
||||
metrics=self.stats,
|
||||
)
|
||||
|
||||
|
||||
class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
"""Handles multimodal output processing by capturing pooling_output
|
||||
from EngineCoreOutput and accumulating it as multimodal tensors,
|
||||
before delegating to the base vLLM OutputProcessor for text handling.
|
||||
"""Handles multimodal output processing.
|
||||
|
||||
The actual data flow is:
|
||||
1. For each EngineCoreOutput with pooling_output and a detokenizer:
|
||||
- Capture pooling_output into OmniRequestState.add_multimodal_tensor()
|
||||
- Clear eco.pooling_output to force text path in base processor
|
||||
Captures multimodal outputs from OmniEngineCoreOutput and accumulates
|
||||
them as MultimodalPayload in OmniRequestState, before delegating to
|
||||
the base vLLM OutputProcessor for text handling.
|
||||
|
||||
The data flow is:
|
||||
1. For each EngineCoreOutput with multimodal_output:
|
||||
- Capture into OmniRequestState.add_multimodal_tensor()
|
||||
2. Base vLLM OutputProcessor handles text detokenization
|
||||
3. On finish, _consolidate_multimodal_tensors() concatenates accumulated tensors
|
||||
4. _new_completion_output() attaches mm_accumulated to CompletionOutput
|
||||
3. On finish, _consolidate_multimodal_tensors() concatenates accumulated
|
||||
tensors using strategy-based dispatch
|
||||
4. _new_completion_output() returns MultimodalCompletionOutput
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -312,6 +461,7 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
stream_interval: int = 1,
|
||||
tracing_enabled: bool = False,
|
||||
engine_core_output_type: str | None = None,
|
||||
output_modality: OutputModality = OutputModality.TEXT,
|
||||
):
|
||||
"""Initialize the multimodal output processor.
|
||||
|
||||
@@ -319,9 +469,13 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
tokenizer: Tokenizer for detokenizing text outputs
|
||||
log_stats: Whether to log statistics
|
||||
stream_interval: Stream interval for output generation
|
||||
engine_core_output_type: Optional output type specification
|
||||
(e.g., "image", "audio", "latent"). Used to tag multimodal
|
||||
outputs with the correct modality key.
|
||||
engine_core_output_type: Optional output type string (e.g.,
|
||||
"image", "audio", "latent"). Converted to OutputModality
|
||||
internally. Kept for backward compatibility with
|
||||
stage_init_utils.
|
||||
output_modality: Type-safe output modality flag. Used to tag
|
||||
multimodal outputs with the correct modality key when
|
||||
per-output type info is unavailable.
|
||||
"""
|
||||
super().__init__(
|
||||
tokenizer=tokenizer,
|
||||
@@ -329,6 +483,11 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
stream_interval=stream_interval,
|
||||
tracing_enabled=tracing_enabled,
|
||||
)
|
||||
# Convert string-based engine_core_output_type to OutputModality
|
||||
if engine_core_output_type is not None:
|
||||
self.output_modality = OutputModality.from_string(engine_core_output_type)
|
||||
else:
|
||||
self.output_modality = output_modality
|
||||
self.engine_core_output_type = engine_core_output_type
|
||||
self._native_text_metrics_by_request: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@@ -426,21 +585,78 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
engine_core_timestamp: float | None = None,
|
||||
iteration_stats: IterationStats | None = None,
|
||||
) -> OutputProcessorOutput:
|
||||
default_mm_type = _modality_to_type_string(self.output_modality)
|
||||
|
||||
# Separate outputs that upstream can handle (has detokenizer or
|
||||
# pooling) from multimodal-only outputs (no detokenizer, no pooling)
|
||||
# that would trigger upstream's `assert detokenizer is not None`.
|
||||
upstream_outputs: list[EngineCoreOutput] = []
|
||||
mm_only_outputs: list[EngineCoreOutput] = []
|
||||
|
||||
for eco in engine_core_outputs:
|
||||
req_state = self.request_states.get(eco.request_id)
|
||||
if req_state is None:
|
||||
continue
|
||||
|
||||
# Accumulate multimodal tensors regardless of path.
|
||||
if isinstance(req_state, OmniRequestState):
|
||||
mm_output = getattr(eco, "multimodal_output", None)
|
||||
if mm_output is not None:
|
||||
mm_type = getattr(eco, "output_type", None) or default_mm_type
|
||||
req_state.add_multimodal_tensor(mm_output, mm_type)
|
||||
|
||||
# Route: if no detokenizer and no pooling output, handle locally
|
||||
# to avoid upstream's assert on detokenizer.
|
||||
if req_state.detokenizer is None and eco.pooling_output is None:
|
||||
mm_only_outputs.append(eco)
|
||||
else:
|
||||
upstream_outputs.append(eco)
|
||||
|
||||
# Handle multimodal-only outputs (generation stages) locally.
|
||||
self._process_mm_only_outputs(mm_only_outputs)
|
||||
|
||||
# Delegate text/pooling outputs to upstream.
|
||||
return super().process_outputs(
|
||||
upstream_outputs,
|
||||
engine_core_timestamp=engine_core_timestamp,
|
||||
iteration_stats=iteration_stats,
|
||||
)
|
||||
|
||||
def _process_mm_only_outputs(
|
||||
self,
|
||||
engine_core_outputs: list[EngineCoreOutput],
|
||||
) -> None:
|
||||
"""Handle outputs from generation stages that have no detokenizer.
|
||||
|
||||
These cannot go through upstream process_outputs because it asserts
|
||||
detokenizer is not None when pooling_output is None.
|
||||
"""
|
||||
for eco in engine_core_outputs:
|
||||
req_state = self.request_states.get(eco.request_id)
|
||||
if req_state is None or not isinstance(req_state, OmniRequestState):
|
||||
continue
|
||||
if eco.pooling_output is not None and req_state.detokenizer is not None:
|
||||
mm_type = (getattr(eco, "output_type", self.engine_core_output_type) or "").lower()
|
||||
req_state.add_multimodal_tensor(eco.pooling_output, mm_type)
|
||||
# Force text path in base processor for multimodal outputs.
|
||||
eco.pooling_output = None
|
||||
|
||||
return super().process_outputs(
|
||||
engine_core_outputs,
|
||||
engine_core_timestamp=engine_core_timestamp,
|
||||
iteration_stats=iteration_stats,
|
||||
)
|
||||
new_token_ids = eco.new_token_ids
|
||||
finish_reason = eco.finish_reason
|
||||
stop_reason = eco.stop_reason
|
||||
kv_transfer_params = eco.kv_transfer_params
|
||||
routed_experts = eco.routed_experts
|
||||
req_state.num_cached_tokens = eco.num_cached_tokens
|
||||
req_state.is_prefilling = False
|
||||
|
||||
if request_output := req_state.make_request_output(
|
||||
new_token_ids,
|
||||
None, # pooling_output
|
||||
finish_reason,
|
||||
stop_reason,
|
||||
kv_transfer_params,
|
||||
routed_experts,
|
||||
):
|
||||
if req_state.queue is not None:
|
||||
req_state.queue.put(request_output)
|
||||
|
||||
if finish_reason is not None:
|
||||
self._finish_request(req_state)
|
||||
|
||||
def _update_stats_from_output(
|
||||
self,
|
||||
|
||||
@@ -18,6 +18,8 @@ from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import psutil
|
||||
import vllm.v1.engine as _vllm_engine_module
|
||||
import vllm.v1.engine.core_client as _vllm_core_client_module
|
||||
from vllm.logger import init_logger
|
||||
from vllm.v1.engine import EngineCoreRequest
|
||||
from vllm.v1.engine.core_client import AsyncMPClient, DPLBAsyncMPClient
|
||||
@@ -30,6 +32,7 @@ from vllm_omni.distributed.omni_connectors.utils.initialization import (
|
||||
KV_TRANSFER_PORT_OFFSET,
|
||||
)
|
||||
from vllm_omni.distributed.omni_connectors.utils.kv_utils import kv_zmq_port
|
||||
from vllm_omni.engine import OmniEngineCoreOutput, OmniEngineCoreOutputs
|
||||
from vllm_omni.engine.stage_client import StageClientBase
|
||||
from vllm_omni.engine.stage_init_utils import StageMetadata
|
||||
|
||||
@@ -174,6 +177,21 @@ class StageEngineCoreClientBase(StageClientBase):
|
||||
self.stage_id,
|
||||
self.replica_id,
|
||||
)
|
||||
|
||||
# Patch the output decoder type so the client decodes
|
||||
# OmniEngineCoreOutputs (which carries multimodal_output per
|
||||
# EngineCoreOutput) instead of the base EngineCoreOutputs.
|
||||
# Must happen BEFORE super().__init__() which creates the decoder.
|
||||
# TODO: Add a defensive assertion after super().__init__() to verify
|
||||
# the decoder uses OmniEngineCoreOutputs, catching import-order regressions.
|
||||
_vllm_engine_module.EngineCoreOutput = OmniEngineCoreOutput
|
||||
_vllm_engine_module.EngineCoreOutputs = OmniEngineCoreOutputs
|
||||
_vllm_core_client_module.EngineCoreOutputs = OmniEngineCoreOutputs
|
||||
logger.debug(
|
||||
"[StageEngineCoreClient] Patched EngineCoreOutputs -> %s",
|
||||
_vllm_core_client_module.EngineCoreOutputs,
|
||||
)
|
||||
|
||||
try:
|
||||
super().__init__(
|
||||
vllm_config,
|
||||
|
||||
@@ -14,6 +14,7 @@ from multiprocessing.process import BaseProcess
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import msgspec
|
||||
import vllm.v1.engine.core as _vllm_engine_core_module
|
||||
import zmq
|
||||
from vllm.logger import init_logger
|
||||
from vllm.transformers_utils.config import (
|
||||
@@ -36,6 +37,7 @@ from vllm.v1.engine.utils import (
|
||||
from vllm.v1.utils import shutdown
|
||||
|
||||
from vllm_omni.distributed.omni_coordinator import OmniCoordClientForStage
|
||||
from vllm_omni.engine import OmniEngineCoreRequest
|
||||
from vllm_omni.engine.stage_init_utils import set_death_signal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -122,6 +124,18 @@ class StageEngineCoreProc(EngineCoreProc):
|
||||
os.environ.setdefault("FLASHINFER_DISABLE_VERSION_CHECK", "1")
|
||||
os.environ["VLLM_OMNI_REPLICA_ID"] = str(max(int(omni_replica_id), 0))
|
||||
|
||||
# Patch the decoder type so process_input_sockets (started
|
||||
# during __init__) decodes OmniEngineCoreRequest (which
|
||||
# carries additional_information) instead of the base
|
||||
# EngineCoreRequest. Must happen BEFORE __init__ because
|
||||
# the IO thread creates MsgpackDecoder(EngineCoreRequest)
|
||||
# during __init__.
|
||||
_vllm_engine_core_module.EngineCoreRequest = OmniEngineCoreRequest
|
||||
logger.debug(
|
||||
"[StageEngineCoreProc] Patched EngineCoreRequest -> OmniEngineCoreRequest: %s",
|
||||
_vllm_engine_core_module.EngineCoreRequest,
|
||||
)
|
||||
|
||||
engine_core = StageEngineCoreProc(
|
||||
*args,
|
||||
engine_index=dp_rank,
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Mapping
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -82,7 +82,10 @@ class RealtimeConnection(VllmRealtimeConnection):
|
||||
|
||||
def _extract_audio_chunks(self, output) -> tuple[list[np.ndarray], int]:
|
||||
mm = getattr(output, "multimodal_output", None)
|
||||
if not isinstance(mm, dict):
|
||||
if mm is None:
|
||||
return [], 24000
|
||||
# Support both MultimodalPayload and plain dict
|
||||
if not isinstance(mm, Mapping):
|
||||
return [], 24000
|
||||
|
||||
sr = mm.get("sr") or mm.get("sample_rate") or mm.get("audio_sample_rate") or 24000
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
from typing import Any, cast
|
||||
@@ -620,7 +621,7 @@ class OmniOpenAIServingVideo:
|
||||
def _resolve_fps(result: Any) -> int | None:
|
||||
"""Extract fps from multimodal_output if the model reported it."""
|
||||
multimodal_output = getattr(result, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
fps = multimodal_output.get("fps")
|
||||
if fps is not None:
|
||||
try:
|
||||
@@ -633,7 +634,7 @@ class OmniOpenAIServingVideo:
|
||||
request_output = getattr(result, "request_output", None)
|
||||
if isinstance(request_output, dict):
|
||||
mm = request_output.get("multimodal_output") or {}
|
||||
if isinstance(mm, dict):
|
||||
if isinstance(mm, Mapping):
|
||||
fps = mm.get("fps")
|
||||
if fps is not None:
|
||||
try:
|
||||
@@ -644,7 +645,7 @@ class OmniOpenAIServingVideo:
|
||||
pass
|
||||
elif hasattr(request_output, "multimodal_output"):
|
||||
mm = getattr(request_output, "multimodal_output", None)
|
||||
if isinstance(mm, dict):
|
||||
if isinstance(mm, Mapping):
|
||||
fps = mm.get("fps")
|
||||
if fps is not None:
|
||||
try:
|
||||
@@ -659,7 +660,7 @@ class OmniOpenAIServingVideo:
|
||||
@classmethod
|
||||
def _extract_audio_sample_rate_from_result(cls, result: Any) -> int | None:
|
||||
multimodal_output = getattr(result, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
sample_rate = cls._coerce_audio_sample_rate(
|
||||
multimodal_output.get("audio_sample_rate")
|
||||
or multimodal_output.get("sample_rate")
|
||||
@@ -672,7 +673,7 @@ class OmniOpenAIServingVideo:
|
||||
request_output = getattr(result, "request_output", None)
|
||||
if isinstance(request_output, dict):
|
||||
multimodal_output = request_output.get("multimodal_output") or {}
|
||||
if isinstance(multimodal_output, dict):
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
return cls._coerce_audio_sample_rate(
|
||||
multimodal_output.get("audio_sample_rate")
|
||||
or multimodal_output.get("sample_rate")
|
||||
@@ -681,7 +682,7 @@ class OmniOpenAIServingVideo:
|
||||
)
|
||||
elif hasattr(request_output, "multimodal_output"):
|
||||
multimodal_output = getattr(request_output, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
return cls._coerce_audio_sample_rate(
|
||||
multimodal_output.get("audio_sample_rate")
|
||||
or multimodal_output.get("sample_rate")
|
||||
|
||||
@@ -32,6 +32,7 @@ import json
|
||||
import time as _time
|
||||
import uuid
|
||||
import wave
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -796,7 +797,7 @@ class OmniStreamingVideoHandler:
|
||||
if not isinstance(outputs, list) or not outputs:
|
||||
return None
|
||||
mm_output = getattr(outputs[0], "multimodal_output", None)
|
||||
if not isinstance(mm_output, dict):
|
||||
if not isinstance(mm_output, Mapping):
|
||||
return None
|
||||
return mm_output.get("audio")
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import replace
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
@@ -220,7 +220,7 @@ class VoxtralTTSForConditionalGeneration(
|
||||
|
||||
def tts_postprocess(self, hidden_states: torch.Tensor, multimodal_outputs: object, **info_dict: object | None):
|
||||
update_dict = {}
|
||||
if isinstance(multimodal_outputs, dict) and "audio" in multimodal_outputs:
|
||||
if isinstance(multimodal_outputs, Mapping) and "audio" in multimodal_outputs:
|
||||
assert self.post_process_idx < len(multimodal_outputs["audio"]), (
|
||||
f"Expect {self.post_process_idx=} < {len(multimodal_outputs['audio'])=}"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from contextlib import nullcontext
|
||||
from typing import Any
|
||||
|
||||
@@ -162,7 +163,7 @@ def text2flow(
|
||||
|
||||
def talker2code2wav_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any] | None,
|
||||
multimodal_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
@@ -198,12 +199,14 @@ def talker2code2wav_async_chunk(
|
||||
value = _to_cpu_tensor(info_embed.get(key))
|
||||
if value is not None:
|
||||
prompt_payload[key] = value
|
||||
if isinstance(pooling_output, dict):
|
||||
po_embed = pooling_output.get("embed", {}) if isinstance(pooling_output.get("embed"), dict) else {}
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
mm_embed = multimodal_output.get("embed", {})
|
||||
if not isinstance(mm_embed, Mapping):
|
||||
mm_embed = multimodal_output
|
||||
for key in cond_keys:
|
||||
if key in prompt_payload:
|
||||
continue
|
||||
value = _to_cpu_tensor(po_embed.get(key))
|
||||
value = _to_cpu_tensor(mm_embed.get(key))
|
||||
if value is not None:
|
||||
prompt_payload[key] = value
|
||||
# Drop any right-padding carried from batched talker emission so
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Stage input processor for Fish Speech S2 Pro: Slow AR → DAC Decoder."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -91,14 +92,14 @@ def _select_backlog_chunk_size(
|
||||
return backlog_chunk_size
|
||||
|
||||
|
||||
def _extract_last_frame(pooling_output: dict[str, Any]) -> torch.Tensor | None:
|
||||
"""Extract the last frame of audio codes from the pooling output."""
|
||||
audio_codes = pooling_output.get("audio_codes")
|
||||
def _extract_last_frame(multimodal_output: dict[str, Any]) -> torch.Tensor | None:
|
||||
"""Extract the last frame of audio codes from the multimodal output."""
|
||||
audio_codes = multimodal_output.get("audio_codes")
|
||||
if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0:
|
||||
return None
|
||||
if audio_codes.ndim == 2:
|
||||
frame = audio_codes[-1]
|
||||
valid = pooling_output.get("audio_code_valid")
|
||||
valid = multimodal_output.get("audio_code_valid")
|
||||
if isinstance(valid, torch.Tensor) and valid.numel() > 0:
|
||||
is_valid = bool(valid.reshape(-1)[-1].item())
|
||||
elif valid is not None:
|
||||
@@ -146,7 +147,7 @@ def slow_ar_to_dac_decoder(
|
||||
|
||||
def slow_ar_to_dac_decoder_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any] | None,
|
||||
multimodal_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
@@ -160,8 +161,8 @@ def slow_ar_to_dac_decoder_async_chunk(
|
||||
finished = bool(is_finished or request.is_finished())
|
||||
cfg = _get_connector_extra(transfer_manager)
|
||||
|
||||
if isinstance(pooling_output, dict):
|
||||
frame = _extract_last_frame(pooling_output)
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
frame = _extract_last_frame(multimodal_output)
|
||||
if frame is not None:
|
||||
transfer_manager.code_prompt_token_ids[request_id].append(frame.detach())
|
||||
elif not finished:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -23,7 +24,7 @@ def _has_source_image(mm_data: Any) -> bool:
|
||||
- `img2img`: legacy single-image key
|
||||
- `images`: list or single image
|
||||
"""
|
||||
if not isinstance(mm_data, dict):
|
||||
if not isinstance(mm_data, Mapping):
|
||||
return False
|
||||
if mm_data.get("image") is not None:
|
||||
return True
|
||||
@@ -35,7 +36,7 @@ def _has_source_image(mm_data: Any) -> bool:
|
||||
|
||||
def _first_source_image(mm_data: Any) -> Any:
|
||||
"""Get first source image from normalized multimodal keys."""
|
||||
if not isinstance(mm_data, dict):
|
||||
if not isinstance(mm_data, Mapping):
|
||||
return None
|
||||
|
||||
image = mm_data.get("image")
|
||||
@@ -285,7 +286,7 @@ def ar2diffusion(
|
||||
|
||||
if hasattr(ar_output, "multimodal_output") and ar_output.multimodal_output:
|
||||
mm_output = ar_output.multimodal_output
|
||||
if isinstance(mm_output, dict) and mm_output.get("ids", {}).get("prior_image") is not None:
|
||||
if isinstance(mm_output, Mapping) and mm_output.get("ids", {}).get("prior_image") is not None:
|
||||
is_i2i = True
|
||||
_dt_mode = (time.perf_counter() - _t_mode) * 1000
|
||||
|
||||
@@ -322,7 +323,7 @@ def ar2diffusion(
|
||||
# Check ar_output (RequestOutput) for multimodal_output - this is the correct location
|
||||
if hasattr(ar_output, "multimodal_output") and ar_output.multimodal_output:
|
||||
mm_output = ar_output.multimodal_output
|
||||
if isinstance(mm_output, dict):
|
||||
if isinstance(mm_output, Mapping):
|
||||
raw_prior_image_ids = mm_output.get("ids", {}).get("prior_image")
|
||||
if raw_prior_image_ids is not None:
|
||||
# Handle different formats:
|
||||
@@ -348,7 +349,7 @@ def ar2diffusion(
|
||||
if hasattr(output, "multimodal_output") and output.multimodal_output:
|
||||
mm_output = output.multimodal_output
|
||||
logger.debug(f"[ar2diffusion] Request {i}: found multimodal_output on CompletionOutput (fallback)")
|
||||
if isinstance(mm_output, dict):
|
||||
if isinstance(mm_output, Mapping):
|
||||
raw_prior_image_ids = mm_output.get("ids", {}).get("prior_image")
|
||||
if raw_prior_image_ids is not None:
|
||||
if isinstance(raw_prior_image_ids, torch.Tensor):
|
||||
|
||||
@@ -188,7 +188,7 @@ def _to_cpu_tensor(value: Any) -> torch.Tensor | None:
|
||||
|
||||
def ar_to_dit_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any] | None,
|
||||
multimodal_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
@@ -216,6 +216,7 @@ def ar_to_dit_async_chunk(
|
||||
if request_id is None:
|
||||
raise ValueError("GLM-TTS async chunk request is missing request id")
|
||||
finished = bool(is_finished or request.is_finished())
|
||||
pooling_output = multimodal_output
|
||||
|
||||
# Read connector chunk config (supports progressive list or single int)
|
||||
connector = getattr(transfer_manager, "connector", None)
|
||||
|
||||
@@ -176,7 +176,7 @@ def _extract_last_frame(pooling_output: OmniPayload) -> torch.Tensor | None:
|
||||
|
||||
def talker2code2wav_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: OmniPayload | None,
|
||||
multimodal_output: OmniPayload | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
@@ -188,6 +188,7 @@ def talker2code2wav_async_chunk(
|
||||
"""
|
||||
request_id = request.external_req_id
|
||||
finished = bool(is_finished or request.is_finished())
|
||||
pooling_output = multimodal_output
|
||||
|
||||
if isinstance(pooling_output, dict):
|
||||
frame = _extract_last_frame(pooling_output)
|
||||
|
||||
@@ -214,7 +214,7 @@ def _extract_last_step_row(pooling_output: OmniPayload) -> torch.Tensor | None:
|
||||
|
||||
def talker2code2wav_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: OmniPayload | None,
|
||||
multimodal_output: OmniPayload | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
@@ -244,6 +244,7 @@ def talker2code2wav_async_chunk(
|
||||
"""
|
||||
request_id = request.external_req_id
|
||||
finished = bool(is_finished or request.is_finished())
|
||||
pooling_output = multimodal_output
|
||||
|
||||
# Per-request rolling state. ``code_prompt_token_ids`` is the
|
||||
# framework-provided buffer; ``higgs_v3_emitted_frames`` tracks how many
|
||||
|
||||
@@ -12,6 +12,7 @@ signature pattern as glm_image.ar2diffusion.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
@@ -208,7 +209,7 @@ def ar2diffusion(
|
||||
# Forward multimodal output from AR (if any)
|
||||
if hasattr(ar_output, "multimodal_output") and ar_output.multimodal_output:
|
||||
mm_output = ar_output.multimodal_output
|
||||
if isinstance(mm_output, dict):
|
||||
if isinstance(mm_output, Mapping):
|
||||
diffusion_input["extra"]["ar_multimodal_output"] = mm_output
|
||||
|
||||
# Forward sampling params
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Stage input processor for MammothModa2 (AR -> DiT)."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -35,7 +36,7 @@ def ar2dit(
|
||||
full_token_ids = prompt_token_ids + gen_token_ids
|
||||
|
||||
mm_output = getattr(completion_output, "multimodal_output", None)
|
||||
if not isinstance(mm_output, dict) or "latent" not in mm_output:
|
||||
if not isinstance(mm_output, Mapping) or "latent" not in mm_output:
|
||||
raise ValueError(
|
||||
"AR stage output missing latent multimodal output. "
|
||||
f"request_id={getattr(ar_output, 'request_id', None)}, "
|
||||
|
||||
@@ -137,19 +137,19 @@ def _to_code_tensor(codes: Any) -> torch.Tensor | None:
|
||||
|
||||
def llm2code2wav_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: OmniPayload,
|
||||
multimodal_output: OmniPayload | dict[str, Any],
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
"""
|
||||
Async chunk version: convert stage-0 pooling_output to code2wav payload (pooling / connector accumulation).
|
||||
Async chunk version: convert stage-0 multimodal_output to code2wav payload (pooling / connector accumulation).
|
||||
|
||||
Accumulates codes in connector per request_id,
|
||||
returns payload only when chunk_size is full or request is finished; returns None when waiting.
|
||||
"""
|
||||
# Null guard: chunk_transfer_adapter calls this every emit step
|
||||
# including no-output steps where pooling_output is None.
|
||||
if pooling_output is None or not isinstance(pooling_output, dict):
|
||||
# including no-output steps where multimodal_output is None.
|
||||
if multimodal_output is None or not isinstance(multimodal_output, dict):
|
||||
if is_finished:
|
||||
connector = getattr(transfer_manager, "connector", None)
|
||||
raw_cfg = getattr(connector, "config", {}) or {}
|
||||
@@ -187,10 +187,8 @@ def llm2code2wav_async_chunk(
|
||||
|
||||
# Text-only paths (e.g. modalities=["text"]) yield no codec pooling output;
|
||||
# stage-0 still drives the chunk transfer adapter, so treat None as "no codes
|
||||
# this step" rather than letting `.get()` raise AttributeError — an unhandled
|
||||
# error here drops the chunk, starves stage-1 of the finished payload, and
|
||||
# the stage subprocesses die before the final token is emitted.
|
||||
po_codes = pooling_output.get("codes", {}) if pooling_output is not None else {}
|
||||
# this step" rather than letting `.get()` raise AttributeError.
|
||||
po_codes = multimodal_output.get("codes", {}) if multimodal_output is not None else {}
|
||||
if "audio" not in po_codes:
|
||||
if is_finished:
|
||||
return _flush_remaining_codes(transfer_manager, request_id, chunk_size, left_context_size)
|
||||
|
||||
@@ -167,10 +167,11 @@ def _build_chunk_observability(
|
||||
|
||||
def llm2audio_vae_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any] | None,
|
||||
multimodal_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
pooling_output = multimodal_output
|
||||
request_id = request.external_req_id
|
||||
chunk_id = int(transfer_manager.put_req_chunk[request_id])
|
||||
finished = bool(is_finished or request.is_finished())
|
||||
|
||||
@@ -92,7 +92,7 @@ def talker2codec(
|
||||
|
||||
def talker2codec_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any] | None,
|
||||
multimodal_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
@@ -107,6 +107,7 @@ def talker2codec_async_chunk(
|
||||
signal "not enough data yet — wait for more frames".
|
||||
"""
|
||||
req_id: str = str(getattr(request, "request_id", id(request)))
|
||||
pooling_output = multimodal_output
|
||||
|
||||
# Initialise per-request accumulation state
|
||||
if not hasattr(transfer_manager, "_moss_tts_state"):
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"""Stage input processor for Qwen3 Omni MoE: Thinker → Talker transition."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -190,7 +191,7 @@ def _get_prefill_multimodal_output(
|
||||
if not isinstance(by_req, dict):
|
||||
return None
|
||||
prefill_mm = by_req.get(request_id)
|
||||
return prefill_mm if isinstance(prefill_mm, dict) else None
|
||||
return prefill_mm if isinstance(prefill_mm, Mapping) else None
|
||||
|
||||
|
||||
def _merge_pd_embeddings(
|
||||
@@ -215,7 +216,7 @@ def _merge_pd_embeddings(
|
||||
p_emb = p_layers[int(_EMBED_LAYER_KEY)].detach().to(device=device, dtype=torch.float)
|
||||
p_hid = p_layers[int(_HIDDEN_LAYER_KEY)].detach().to(device=device, dtype=torch.float)
|
||||
except (KeyError, AttributeError, TypeError) as exc:
|
||||
available_keys = list(prefill_mm.keys()) if isinstance(prefill_mm, dict) else type(prefill_mm).__name__
|
||||
available_keys = list(prefill_mm.keys()) if isinstance(prefill_mm, Mapping) else type(prefill_mm).__name__
|
||||
logger.error(
|
||||
"_merge_pd_embeddings: failed to extract prefill embeddings (%s). "
|
||||
"Expected keys %r and %r, got: %s. "
|
||||
@@ -433,7 +434,7 @@ def _get_streaming_codec_delta_len(
|
||||
|
||||
def thinker2talker_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: OmniPayload,
|
||||
multimodal_output: OmniPayload | dict[str, Any],
|
||||
request: OmniEngineCoreRequest,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
@@ -446,13 +447,13 @@ def thinker2talker_async_chunk(
|
||||
|
||||
request_id = request.external_req_id
|
||||
chunk_id = transfer_manager.put_req_chunk[request_id]
|
||||
if not isinstance(pooling_output, dict):
|
||||
logger.debug("thinker2talker_async_chunk: skip non-dict pooling_output for req=%s", request_id)
|
||||
if not isinstance(multimodal_output, Mapping):
|
||||
logger.debug("thinker2talker_async_chunk: skip non-dict multimodal_output for req=%s", request_id)
|
||||
return None
|
||||
|
||||
thinker_hs = pooling_output.get("hidden_states", {})
|
||||
thinker_hs = multimodal_output.get("hidden_states", {})
|
||||
thinker_layers = thinker_hs.get("layers", {}) if isinstance(thinker_hs, dict) else {}
|
||||
thinker_embed_raw = pooling_output.get("embed", {})
|
||||
thinker_embed_raw = multimodal_output.get("embed", {})
|
||||
thinker_embed = thinker_embed_raw if isinstance(thinker_embed_raw, dict) else {}
|
||||
thinker_emb = _layer_tensor(thinker_layers, _EMBED_LAYER_KEY)
|
||||
thinker_hid = _layer_tensor(thinker_layers, _HIDDEN_LAYER_KEY)
|
||||
@@ -534,7 +535,7 @@ def thinker2talker_full_payload(
|
||||
) -> dict[str, Any] | None:
|
||||
"""Pack complete thinker output for the non-async connector path."""
|
||||
rid = getattr(request, "request_id", None)
|
||||
if not isinstance(pooling_output, dict):
|
||||
if not isinstance(pooling_output, Mapping):
|
||||
logger.warning(
|
||||
"thinker2talker_full_payload: pooling_output not a dict (type=%s) for req=%s; consumer wait gate may hang.",
|
||||
type(pooling_output).__name__,
|
||||
@@ -657,12 +658,12 @@ def thinker2talker(
|
||||
thinker_input_ids = prompt_token_ids
|
||||
new_seq_length = len(prompt_token_ids + output_ids) - 1
|
||||
thinker_mm_raw = getattr(output, "multimodal_output", None)
|
||||
if not isinstance(thinker_mm_raw, dict):
|
||||
if not isinstance(thinker_mm_raw, Mapping):
|
||||
logger.debug("thinker2talker: skip req=%s due to empty multimodal_output", req_id)
|
||||
continue
|
||||
thinker_mm: OmniPayload = thinker_mm_raw
|
||||
mm_hs = thinker_mm.get("hidden_states", {})
|
||||
mm_layers = mm_hs.get("layers", {}) if isinstance(mm_hs, dict) else {}
|
||||
mm_layers = mm_hs.get("layers", {}) if isinstance(mm_hs, Mapping) else {}
|
||||
emb_layer = _layer_tensor(mm_layers, _EMBED_LAYER_KEY)
|
||||
hid_layer = _layer_tensor(mm_layers, _HIDDEN_LAYER_KEY)
|
||||
if emb_layer is None or hid_layer is None:
|
||||
@@ -748,11 +749,11 @@ def thinker2talker_token_only(
|
||||
# hidden-state layers), do the same here so the worker buffer
|
||||
# presence agrees with the orchestrator's scheduling decision.
|
||||
thinker_mm_raw = getattr(output, "multimodal_output", None)
|
||||
if not isinstance(thinker_mm_raw, dict):
|
||||
if not isinstance(thinker_mm_raw, Mapping):
|
||||
logger.debug("thinker2talker_token_only: skip req=%s due to empty multimodal_output", req_id)
|
||||
continue
|
||||
mm_hs = thinker_mm_raw.get("hidden_states", {})
|
||||
mm_layers = mm_hs.get("layers", {}) if isinstance(mm_hs, dict) else {}
|
||||
mm_layers = mm_hs.get("layers", {}) if isinstance(mm_hs, Mapping) else {}
|
||||
if _layer_tensor(mm_layers, _EMBED_LAYER_KEY) is None or _layer_tensor(mm_layers, _HIDDEN_LAYER_KEY) is None:
|
||||
logger.debug("thinker2talker_token_only: skip req=%s due to missing hidden-state layers", req_id)
|
||||
continue
|
||||
@@ -801,16 +802,16 @@ def thinker2talker_token_only(
|
||||
|
||||
def talker2code2wav_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: OmniPayload,
|
||||
multimodal_output: OmniPayload | dict[str, Any],
|
||||
request: OmniEngineCoreRequest,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
"""
|
||||
Pooling version.
|
||||
Multimodal output version.
|
||||
"""
|
||||
if not isinstance(pooling_output, dict):
|
||||
if not isinstance(multimodal_output, Mapping):
|
||||
return None
|
||||
talker_codes = pooling_output.get("codes", {})
|
||||
talker_codes = multimodal_output.get("codes", {})
|
||||
if not isinstance(talker_codes, dict):
|
||||
return None
|
||||
code_predictor_codes = talker_codes.get("audio")
|
||||
@@ -884,7 +885,7 @@ def talker2code2wav_full_payload(
|
||||
) -> dict[str, Any] | None:
|
||||
"""Pack complete talker codec output for the non-async connector path."""
|
||||
rid = getattr(request, "request_id", None)
|
||||
if not isinstance(pooling_output, dict):
|
||||
if not isinstance(pooling_output, Mapping):
|
||||
logger.warning(
|
||||
"talker2code2wav_full_payload: pooling_output not a dict "
|
||||
"(type=%s) for req=%s; consumer wait gate may hang.",
|
||||
@@ -983,7 +984,7 @@ def talker2code2wav(
|
||||
if is_streaming_session:
|
||||
seq_len = _get_streaming_codec_delta_len(cur_seq_len, req_id, talker_output, streaming_context)
|
||||
mm_raw = getattr(output, "multimodal_output", None)
|
||||
if not isinstance(mm_raw, dict):
|
||||
if not isinstance(mm_raw, Mapping):
|
||||
logger.debug("talker2code2wav: skip req=%s due to empty multimodal_output", req_id)
|
||||
continue
|
||||
mm: OmniPayload = mm_raw
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Stage input processor for Qwen3-TTS: Talker -> Code2Wav."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -123,8 +124,8 @@ def talker2code2wav(
|
||||
return code2wav_inputs
|
||||
|
||||
|
||||
def _extract_last_frame(pooling_output: OmniPayload) -> torch.Tensor | None:
|
||||
audio_codes = pooling_output.get("codes", {}).get("audio")
|
||||
def _extract_last_frame(multimodal_output: OmniPayload | dict[str, Any]) -> torch.Tensor | None:
|
||||
audio_codes = multimodal_output.get("codes", {}).get("audio")
|
||||
if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0:
|
||||
return None
|
||||
if audio_codes.ndim == 2:
|
||||
@@ -139,7 +140,7 @@ def _extract_last_frame(pooling_output: OmniPayload) -> torch.Tensor | None:
|
||||
|
||||
def talker2code2wav_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: OmniPayload | None,
|
||||
multimodal_output: OmniPayload | dict[str, Any] | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
@@ -150,12 +151,12 @@ def talker2code2wav_async_chunk(
|
||||
request_payload = {}
|
||||
transfer_manager.request_payload = request_payload
|
||||
|
||||
if isinstance(pooling_output, dict):
|
||||
frame = _extract_last_frame(pooling_output)
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
frame = _extract_last_frame(multimodal_output)
|
||||
if frame is not None:
|
||||
codec_codes = frame.cpu().tolist()
|
||||
transfer_manager.code_prompt_token_ids[request_id].append(codec_codes)
|
||||
ref_code = pooling_output.get("codes", {}).get("ref")
|
||||
ref_code = multimodal_output.get("codes", {}).get("ref")
|
||||
if isinstance(ref_code, torch.Tensor) and ref_code.numel() > 0 and request_payload.get(request_id) is None:
|
||||
request_payload[request_id] = ref_code.to(torch.long).cpu().contiguous()
|
||||
elif not finished:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -35,8 +36,8 @@ def generator2tokenizer(
|
||||
return tokenizer_inputs
|
||||
|
||||
|
||||
def _extract_last_frame(pooling_output: OmniPayload) -> torch.Tensor | None:
|
||||
audio = pooling_output.get("audio")
|
||||
def _extract_last_frame(multimodal_output: OmniPayload | dict[str, Any]) -> torch.Tensor | None:
|
||||
audio = multimodal_output.get("audio")
|
||||
if not isinstance(audio, torch.Tensor) or audio.numel() == 0:
|
||||
return None
|
||||
return audio.flatten()
|
||||
@@ -44,20 +45,20 @@ def _extract_last_frame(pooling_output: OmniPayload) -> torch.Tensor | None:
|
||||
|
||||
def generator2tokenizer_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: OmniPayload,
|
||||
multimodal_output: OmniPayload | dict[str, Any],
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
request_id = request.external_req_id
|
||||
finished = bool(is_finished or request.is_finished())
|
||||
|
||||
if isinstance(pooling_output, dict):
|
||||
frame = _extract_last_frame(pooling_output)
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
frame = _extract_last_frame(multimodal_output)
|
||||
if frame is not None:
|
||||
codec_codes = frame.cpu().tolist()
|
||||
transfer_manager.code_prompt_token_ids[request_id].append(codec_codes)
|
||||
elif not finished:
|
||||
# Some steps may not produce pooling_output. Only flush on finish.
|
||||
# Some steps may not produce multimodal_output. Only flush on finish.
|
||||
return None
|
||||
|
||||
connector = getattr(transfer_manager, "connector", None)
|
||||
|
||||
@@ -36,6 +36,7 @@ class OmniConnectorOutput:
|
||||
has_pending_kv_work: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class OmniModelRunnerOutput(ModelRunnerOutput):
|
||||
"""Model runner output for omni models.
|
||||
|
||||
@@ -43,11 +44,12 @@ class OmniModelRunnerOutput(ModelRunnerOutput):
|
||||
that may be produced by non-autoregressive stages.
|
||||
|
||||
Attributes:
|
||||
multimodal_outputs: Optional dictionary mapping modality names to
|
||||
output tensors (e.g., {"image": tensor, "audio": tensor})
|
||||
multimodal_outputs: Optional per-request list of multimodal output
|
||||
dicts, indexed by req_index. Each element is a dict mapping
|
||||
output keys to tensors/values (e.g., [{"audio": tensor}, ...]).
|
||||
"""
|
||||
|
||||
multimodal_outputs: dict[str, torch.Tensor] | None = None
|
||||
multimodal_outputs: list[dict[str, object]] | None = None
|
||||
# IDs of requests whose KV cache has been extracted from GPU/NPU to CPU.
|
||||
# The Scheduler can safely free the block tables for these requests.
|
||||
kv_extracted_req_ids: list[str] | None = None
|
||||
@@ -213,11 +215,13 @@ class OmniRequestOutput:
|
||||
)
|
||||
|
||||
@property
|
||||
def multimodal_output(self) -> dict[str, Any]:
|
||||
def multimodal_output(self) -> Any:
|
||||
"""Return multimodal output from the underlying request output or local field.
|
||||
|
||||
For pipeline outputs, this checks completion outputs first, then request_output.
|
||||
For diffusion outputs, this returns the local _multimodal_output field.
|
||||
|
||||
Returns either a MultimodalPayload (Phase 3+) or a plain dict (legacy).
|
||||
"""
|
||||
if self.request_output is None:
|
||||
return self._multimodal_output
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from copy import copy, deepcopy
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
@@ -163,7 +164,7 @@ class NPUARModelRunner(OmniNPUModelRunner):
|
||||
num_tokens_padded: int,
|
||||
):
|
||||
if self.omni_prefix_cache is not None and get_pp_group().is_last_rank:
|
||||
if multimodal_outputs is not None and not isinstance(multimodal_outputs, dict):
|
||||
if multimodal_outputs is not None and not isinstance(multimodal_outputs, Mapping):
|
||||
logger.warning_once(
|
||||
"prefix caching expects mm outputs to be a dict, but got %s",
|
||||
type(multimodal_outputs),
|
||||
@@ -639,7 +640,7 @@ class NPUARModelRunner(OmniNPUModelRunner):
|
||||
if multimodal_outputs is not None:
|
||||
keys_or_type = (
|
||||
list(multimodal_outputs.keys())
|
||||
if isinstance(multimodal_outputs, dict)
|
||||
if isinstance(multimodal_outputs, Mapping)
|
||||
else type(multimodal_outputs)
|
||||
)
|
||||
logger.debug(f"[AR] execute_model: multimodal_outputs keys = {keys_or_type}")
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from copy import copy, deepcopy
|
||||
|
||||
import numpy as np
|
||||
@@ -448,7 +449,7 @@ class NPUGenerationModelRunner(OmniNPUModelRunner):
|
||||
pooler_output.append(
|
||||
{"model_outputs": out.detach().to("cpu").contiguous() if out is not None else None}
|
||||
)
|
||||
elif isinstance(multimodal_outputs, dict):
|
||||
elif isinstance(multimodal_outputs, Mapping):
|
||||
num_reqs = self.input_batch.num_reqs
|
||||
for i in range(num_reqs):
|
||||
mm_payload = {}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
payloads, most of which are shared by the prefix cache / no prefix cache path.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
|
||||
@@ -23,7 +25,7 @@ def build_mm_cpu(multimodal_outputs: dict) -> dict[str, object]:
|
||||
mm_cpu: dict[str, object] = {}
|
||||
# Currently there are some cases where this is true at the
|
||||
# moment, which should be fixed.
|
||||
if not isinstance(multimodal_outputs, dict):
|
||||
if not isinstance(multimodal_outputs, Mapping):
|
||||
logger.warning("Multimodal outputs are not a dict and will not be passed")
|
||||
|
||||
if multimodal_outputs:
|
||||
|
||||
@@ -6,6 +6,7 @@ and also outputs sampled tokens.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from contextlib import nullcontext
|
||||
from copy import copy
|
||||
from dataclasses import replace
|
||||
@@ -46,6 +47,39 @@ from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorMode
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def _ensure_tensor_values(payload: dict[str, object]) -> dict[str, torch.Tensor]:
|
||||
"""Convert a flattened payload to strictly ``dict[str, torch.Tensor]``.
|
||||
|
||||
Non-tensor scalars (int, float) are wrapped with ``torch.tensor()``.
|
||||
Values that cannot be safely converted are dropped with a warning.
|
||||
This enforces the tensor-only invariant required by the
|
||||
``OmniEngineCoreOutput.multimodal_output`` wire field and msgspec
|
||||
serialization.
|
||||
"""
|
||||
result: dict[str, torch.Tensor] = {}
|
||||
for key, val in payload.items():
|
||||
if isinstance(val, torch.Tensor):
|
||||
result[key] = val
|
||||
elif isinstance(val, (int, float, bool)):
|
||||
result[key] = torch.tensor(val)
|
||||
elif isinstance(val, (list, tuple)):
|
||||
try:
|
||||
result[key] = torch.tensor(val)
|
||||
except (ValueError, TypeError, RuntimeError):
|
||||
logger.warning(
|
||||
"Dropping non-tensorizable multimodal output key '%s' (type=%s) from wire payload.",
|
||||
key,
|
||||
type(val).__name__,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Dropping non-tensor multimodal output key '%s' (type=%s) from wire payload.",
|
||||
key,
|
||||
type(val).__name__,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class ExecuteModelState(NamedTuple):
|
||||
scheduler_output: SchedulerOutput
|
||||
logits: torch.Tensor | None
|
||||
@@ -329,7 +363,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
if self.omni_prefix_cache is not None and is_last_pp_rank:
|
||||
# If this happens, it generally means the model is not following the correct
|
||||
# interface yet and is therefore currently not compatible with prefix cache.
|
||||
if multimodal_outputs is not None and not isinstance(multimodal_outputs, dict):
|
||||
if multimodal_outputs is not None and not isinstance(multimodal_outputs, Mapping):
|
||||
logger.warning_once(
|
||||
"prefix caching expects mm outputs to be a dict, but got %s",
|
||||
type(multimodal_outputs),
|
||||
@@ -835,21 +869,45 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
|
||||
@staticmethod
|
||||
def _resolve_req_hidden_states(
|
||||
hidden_states_cpu: torch.Tensor,
|
||||
hidden_states_cpu: torch.Tensor | None,
|
||||
combined_hidden_states: dict[str, torch.Tensor] | None,
|
||||
rid: str,
|
||||
start: int,
|
||||
end: int,
|
||||
):
|
||||
) -> torch.Tensor | None:
|
||||
if combined_hidden_states is not None:
|
||||
# We always have all request IDs for prefix cache, even for
|
||||
# partial cache misses, so this should never happen.
|
||||
if rid not in combined_hidden_states:
|
||||
raise RuntimeError("Request IDs in the batch are missing from the merged states!")
|
||||
return combined_hidden_states[rid]
|
||||
# Prefix caching is disabled
|
||||
# Prefix caching is disabled. hidden_states_cpu may legitimately be
|
||||
# None (e.g. sparse audio output or no scheduled hidden payload);
|
||||
# callers must omit the "hidden" key in that case.
|
||||
if hidden_states_cpu is None:
|
||||
return None
|
||||
return hidden_states_cpu[start:end]
|
||||
|
||||
def _build_multimodal_outputs(
|
||||
self,
|
||||
per_req_payloads: list[dict[str, object]] | None,
|
||||
) -> list[dict[str, torch.Tensor]] | None:
|
||||
"""Build per-request multimodal output payloads (dedicated channel).
|
||||
|
||||
Reuses the per-request payloads assembled by the pooler-payload loop
|
||||
in sample_tokens() (which already handles prefix-cache merging,
|
||||
sparse audio output, and partial downstream batches) so the wire
|
||||
channel stays consistent with the full-payload accumulation path.
|
||||
Enforces the tensor-only invariant required by msgspec: scalars and
|
||||
lists are wrapped into tensors, and anything that cannot be safely
|
||||
converted is dropped.
|
||||
"""
|
||||
if self.vllm_config.model_config.engine_output_type == "text":
|
||||
return None
|
||||
if per_req_payloads is None:
|
||||
return None
|
||||
return [_ensure_tensor_values(payload) if payload else {} for payload in per_req_payloads]
|
||||
|
||||
@torch.inference_mode()
|
||||
def sample_tokens(
|
||||
self,
|
||||
@@ -1057,6 +1115,10 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
deferred_mm_cache_keys=deferred_mm_cache_keys,
|
||||
)
|
||||
|
||||
# NOTE: pooler_output here is used only for the full-payload accumulation
|
||||
# path (accumulate_full_payload_output) and is NOT passed on the wire via
|
||||
# OmniModelRunnerOutput.pooler_output (which is set to None below).
|
||||
# The actual multimodal wire transport uses multimodal_outputs instead.
|
||||
pooler_output: list[dict[str, object]] | None = None
|
||||
if needs_pooler_payload:
|
||||
mm_cpu = None
|
||||
@@ -1112,7 +1174,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
start,
|
||||
end,
|
||||
)
|
||||
payload["hidden"] = req_hidden_states
|
||||
if req_hidden_states is not None:
|
||||
payload["hidden"] = req_hidden_states
|
||||
|
||||
mm_payload: dict[str, object] = {}
|
||||
if combined_multimodal_outputs or mm_cpu:
|
||||
@@ -1127,7 +1190,6 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
|
||||
for mm_key in combined_multimodal_outputs.keys():
|
||||
mm_payload[mm_key] = _unwrap_lists(combined_multimodal_outputs[mm_key][rid])
|
||||
|
||||
else:
|
||||
for mm_key, mm_val in mm_cpu.items():
|
||||
if mm_key in {"meta.req_id", "meta.sparse_audio"}:
|
||||
@@ -1166,6 +1228,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
if req_state is not None and pooler_output[i]:
|
||||
self.accumulate_full_payload_output(rid, pooler_output[i], req_state)
|
||||
|
||||
multimodal_outputs = self._build_multimodal_outputs(pooler_output)
|
||||
with record_function_or_nullcontext("gpu_model_runner: ModelRunnerOutput"):
|
||||
routed_experts_lists = None
|
||||
if self.routed_experts_initialized:
|
||||
@@ -1176,7 +1239,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
sampled_token_ids=valid_sampled_token_ids,
|
||||
logprobs=logprobs_lists,
|
||||
prompt_logprobs_dict=prompt_logprobs_dict,
|
||||
pooler_output=(pooler_output if engine_output_type != "text" and needs_pooler_payload else None),
|
||||
pooler_output=None,
|
||||
multimodal_outputs=multimodal_outputs,
|
||||
kv_connector_output=kv_connector_output,
|
||||
ec_connector_output=ec_connector_output if self.supports_mm_inputs else None,
|
||||
num_nans_in_logits=num_nans_in_logits,
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import replace
|
||||
|
||||
import numpy as np
|
||||
@@ -36,7 +37,7 @@ from vllm.v1.worker.ubatch_utils import maybe_create_ubatch_slices
|
||||
from vllm.v1.worker.utils import sanity_check_mm_encoder_outputs
|
||||
|
||||
from vllm_omni.outputs import OmniModelRunnerOutput
|
||||
from vllm_omni.worker.gpu_ar_model_runner import ExecuteModelState
|
||||
from vllm_omni.worker.gpu_ar_model_runner import ExecuteModelState, _ensure_tensor_values
|
||||
from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner
|
||||
from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorModelRunnerMixin
|
||||
|
||||
@@ -404,7 +405,7 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
|
||||
aux_hidden_states,
|
||||
ec_connector_output,
|
||||
cudagraph_stats,
|
||||
multimodal_outputs,
|
||||
multimodal_outputs_raw,
|
||||
slot_mappings, # OMNI: unpack slot_mappings for upstream v1 API compatibility
|
||||
) = self.execute_model_state
|
||||
self.execute_model_state = None
|
||||
@@ -414,27 +415,29 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
|
||||
if self.speculative_config is not None:
|
||||
self.finalize_kv_connector()
|
||||
|
||||
pooler_output: list[object] = []
|
||||
if isinstance(multimodal_outputs, torch.Tensor):
|
||||
assert multimodal_outputs.shape[0] == 1, (
|
||||
# Build per-request multimodal_outputs list (dedicated channel).
|
||||
# pooler_output is no longer used for multimodal data.
|
||||
multimodal_outputs: list[dict[str, object]] = []
|
||||
if isinstance(multimodal_outputs_raw, torch.Tensor):
|
||||
assert multimodal_outputs_raw.shape[0] == 1, (
|
||||
"model should return a single tensor, to return multiple tensors, use a dict"
|
||||
)
|
||||
assert multimodal_outputs.shape[0] == self.input_batch.num_reqs
|
||||
assert multimodal_outputs_raw.shape[0] == self.input_batch.num_reqs
|
||||
for i in range(self.input_batch.num_reqs):
|
||||
pooler_output.append({"model_outputs": multimodal_outputs[i].detach().to("cpu").contiguous()})
|
||||
elif isinstance(multimodal_outputs, list):
|
||||
assert len(multimodal_outputs) == 1, (
|
||||
multimodal_outputs.append({"model_outputs": multimodal_outputs_raw[i].detach().to("cpu").contiguous()})
|
||||
elif isinstance(multimodal_outputs_raw, list):
|
||||
assert len(multimodal_outputs_raw) == 1, (
|
||||
"model should return a single list, to return multiple lists, use a dict"
|
||||
)
|
||||
for out in multimodal_outputs:
|
||||
pooler_output.append(
|
||||
for out in multimodal_outputs_raw:
|
||||
multimodal_outputs.append(
|
||||
{"model_outputs": out.detach().to("cpu").contiguous() if out is not None else None}
|
||||
)
|
||||
elif isinstance(multimodal_outputs, dict):
|
||||
elif isinstance(multimodal_outputs_raw, Mapping):
|
||||
num_reqs = self.input_batch.num_reqs
|
||||
for i in range(num_reqs):
|
||||
mm_payload = {}
|
||||
for key, out in multimodal_outputs.items():
|
||||
for key, out in multimodal_outputs_raw.items():
|
||||
if isinstance(out, list):
|
||||
if len(out) != num_reqs:
|
||||
raise ValueError(
|
||||
@@ -446,7 +449,7 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
|
||||
mm_payload[key] = out.detach().to("cpu").contiguous()
|
||||
else:
|
||||
logger.warning(f"Unsupported multimodal output type for key '{key}': {type(out)}")
|
||||
pooler_output.append(mm_payload)
|
||||
multimodal_outputs.append(_ensure_tensor_values(mm_payload))
|
||||
else:
|
||||
raise RuntimeError("Unsupported diffusion output type")
|
||||
# [Omni] Copy req_id mappings to avoid async scheduling mutation.
|
||||
@@ -458,8 +461,8 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
|
||||
if self._should_accumulate_full_payload_output():
|
||||
for i, rid in enumerate(req_ids_output_copy):
|
||||
req_state = self.requests.get(rid)
|
||||
if req_state is not None and pooler_output[i]:
|
||||
self.accumulate_full_payload_output(rid, pooler_output[i], req_state)
|
||||
if req_state is not None and multimodal_outputs[i]:
|
||||
self.accumulate_full_payload_output(rid, multimodal_outputs[i], req_state)
|
||||
|
||||
output = OmniModelRunnerOutput(
|
||||
req_ids=req_ids_output_copy,
|
||||
@@ -467,7 +470,8 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
|
||||
sampled_token_ids=[],
|
||||
logprobs=None,
|
||||
prompt_logprobs_dict={},
|
||||
pooler_output=pooler_output,
|
||||
pooler_output=None,
|
||||
multimodal_outputs=multimodal_outputs,
|
||||
kv_connector_output=kv_connector_output,
|
||||
num_nans_in_logits={},
|
||||
cudagraph_stats=cudagraph_stats,
|
||||
|
||||
Reference in new issue
Block a user