[3/5][core]refactor communication layer: PR 3 of 5, all other models in non async mode (#3719)
Signed-off-by: natureofnature <wzliu@connect.hku.hk>
This commit is contained in:
1 parent
0f3264a9c0
commit
3a347b10b0
38 files changed
+3021
-275
No files matched your search
@@ -0,0 +1,104 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit coverage for _process_pending_input_timeouts.
|
||||
|
||||
Verifies that the mixin correctly *delegates* timed-out requests to the
|
||||
base scheduler's ``finish_requests`` API with ``RequestStatus.FINISHED_ERROR``.
|
||||
The end-to-end effect (queue removal + status set + per-request cleanup +
|
||||
client-facing FINISHED_ERROR emission) is the responsibility of upstream
|
||||
vLLM's ``finish_requests`` implementation and is covered by upstream tests;
|
||||
this file only asserts the wiring from the mixin to that API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
def __init__(self, timed_out_ids):
|
||||
self._timed_out_ids = set(timed_out_ids)
|
||||
self.calls = []
|
||||
|
||||
def collect_timed_out_request_ids(self, timeout_s):
|
||||
self.calls.append(timeout_s)
|
||||
return set(self._timed_out_ids)
|
||||
|
||||
|
||||
class _FakeScheduler(OmniSchedulerMixin):
|
||||
def __init__(self, requests, coordinator):
|
||||
self.requests = requests
|
||||
self.input_coordinator = coordinator
|
||||
self.finish_calls = []
|
||||
|
||||
def finish_requests(self, req_ids, status):
|
||||
self.finish_calls.append((set(req_ids), status))
|
||||
|
||||
|
||||
def test_process_pending_input_timeouts_delegates_to_finish_requests():
|
||||
"""Timed-out request present in self.requests is forwarded to finish_requests."""
|
||||
req_id = "stuck-req"
|
||||
requests = {req_id: SimpleNamespace(request_id=req_id)}
|
||||
coord = _FakeCoordinator(timed_out_ids={req_id})
|
||||
scheduler = _FakeScheduler(requests, coord)
|
||||
|
||||
scheduler._process_pending_input_timeouts()
|
||||
|
||||
assert len(coord.calls) == 1, "coordinator should be polled once"
|
||||
assert coord.calls[0] > 0, "timeout must be positive when enabled"
|
||||
|
||||
assert len(scheduler.finish_calls) == 1
|
||||
finished_ids, status = scheduler.finish_calls[0]
|
||||
assert finished_ids == {req_id}
|
||||
# RequestStatus is the upstream enum; the mixin imports it as
|
||||
# RequestStatus.FINISHED_ERROR. Check by name to avoid hard import here.
|
||||
assert getattr(status, "name", str(status)).endswith("FINISHED_ERROR")
|
||||
|
||||
|
||||
def test_process_pending_input_timeouts_skips_already_freed_request():
|
||||
"""Timed-out id no longer in self.requests must not be forwarded."""
|
||||
coord = _FakeCoordinator(timed_out_ids={"already-freed"})
|
||||
scheduler = _FakeScheduler(requests={}, coordinator=coord)
|
||||
|
||||
scheduler._process_pending_input_timeouts()
|
||||
|
||||
assert coord.calls == [coord.calls[0]] and coord.calls[0] > 0
|
||||
assert scheduler.finish_calls == []
|
||||
|
||||
|
||||
def test_process_pending_input_timeouts_noop_without_coordinator():
|
||||
"""No coordinator => no finish_requests call, no crash."""
|
||||
|
||||
class _NoCoord(OmniSchedulerMixin):
|
||||
def __init__(self):
|
||||
self.requests = {}
|
||||
self.input_coordinator = None
|
||||
self.finish_calls = []
|
||||
|
||||
def finish_requests(self, req_ids, status):
|
||||
self.finish_calls.append((set(req_ids), status))
|
||||
|
||||
scheduler = _NoCoord()
|
||||
scheduler._process_pending_input_timeouts()
|
||||
assert scheduler.finish_calls == []
|
||||
|
||||
|
||||
def test_process_pending_input_timeouts_disabled_when_timeout_zero(monkeypatch):
|
||||
"""Setting DEFAULT_INPUT_WAIT_TIMEOUT_S <= 0 disables the safety net."""
|
||||
from vllm_omni.core.sched import omni_scheduler_mixin
|
||||
|
||||
monkeypatch.setattr(omni_scheduler_mixin, "DEFAULT_INPUT_WAIT_TIMEOUT_S", 0.0)
|
||||
|
||||
coord = _FakeCoordinator(timed_out_ids={"r1"})
|
||||
scheduler = _FakeScheduler(requests={"r1": SimpleNamespace(request_id="r1")}, coordinator=coord)
|
||||
scheduler._process_pending_input_timeouts()
|
||||
assert coord.calls == [], "coordinator must not be polled when timeout is disabled"
|
||||
assert scheduler.finish_calls == []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -16,7 +16,7 @@ import torch
|
||||
import vllm_omni.core.sched.omni_scheduling_coordinator as coord_mod
|
||||
from vllm_omni.core.sched.omni_scheduling_coordinator import (
|
||||
OmniSchedulingCoordinator,
|
||||
uses_qwen3_omni_full_payload_input_coordinator,
|
||||
uses_full_payload_input_coordinator,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -92,47 +92,96 @@ class MockQueue:
|
||||
|
||||
|
||||
class TestFullPayloadCoordinatorSelection(unittest.TestCase):
|
||||
def test_qwen3_omni_talker_and_code2wav_use_full_payload_input_coordinator(self):
|
||||
for model_stage in ("talker", "code2wav"):
|
||||
"""Tests for the (model_arch, model_stage) whitelist gate.
|
||||
|
||||
The init_omni_connectors arch allowlist is keyed by ``model_arch`` and
|
||||
is a superset of the stages registered here -- consumer-wait stages
|
||||
must be registered explicitly in ``_FULL_PAYLOAD_INPUT_STAGES``, while
|
||||
the init allowlist covers both producer- and consumer-side runners.
|
||||
These tests pin which ``(arch, stage)`` pairs the gate fires for today.
|
||||
"""
|
||||
|
||||
# Expected whitelist (model_arch, model_stage). Hardcoded to avoid the
|
||||
# tautology of importing _FULL_PAYLOAD_INPUT_STAGES and asserting it
|
||||
# against itself; any drift between this matrix and the whitelist will
|
||||
# fail loudly here.
|
||||
EXPECTED_FULL_PAYLOAD_INPUT_STAGES: frozenset[tuple[str, str]] = frozenset(
|
||||
{
|
||||
("Qwen3OmniMoeForConditionalGeneration", "talker"),
|
||||
("Qwen3OmniMoeForConditionalGeneration", "code2wav"),
|
||||
("Qwen2_5OmniForConditionalGeneration", "talker"),
|
||||
("Qwen2_5OmniForConditionalGeneration", "code2wav"),
|
||||
("CovoAudioForConditionalGeneration", "code2wav"),
|
||||
("MiMoAudioModel", "code2wav"),
|
||||
("Qwen3TTSCode2Wav", "code2wav"),
|
||||
("CosyVoice3Model", "cosyvoice3_code2wav"),
|
||||
("DyninOmniForConditionalGeneration", "token2image"),
|
||||
("DyninOmniForConditionalGeneration", "token2audio"),
|
||||
}
|
||||
)
|
||||
|
||||
def test_whitelist_matches_expected_matrix(self):
|
||||
"""_FULL_PAYLOAD_INPUT_STAGES must equal the hardcoded expected matrix.
|
||||
|
||||
Catches both accidental additions (which would silently enable the
|
||||
consumer-wait gate for a new arch) and accidental removals (which
|
||||
would silently disable an enabled arch).
|
||||
"""
|
||||
from vllm_omni.core.sched.omni_scheduling_coordinator import _FULL_PAYLOAD_INPUT_STAGES
|
||||
|
||||
self.assertEqual(
|
||||
frozenset(_FULL_PAYLOAD_INPUT_STAGES),
|
||||
self.EXPECTED_FULL_PAYLOAD_INPUT_STAGES,
|
||||
msg="_FULL_PAYLOAD_INPUT_STAGES drifted from the expected matrix; "
|
||||
"update EXPECTED_FULL_PAYLOAD_INPUT_STAGES if intentional.",
|
||||
)
|
||||
|
||||
def test_all_whitelisted_arch_stage_pairs_fire_gate(self):
|
||||
"""Every (arch, stage) pair in the expected matrix must fire
|
||||
the gate when stage_id > 0 and async_chunk=False.
|
||||
"""
|
||||
for arch, stage in self.EXPECTED_FULL_PAYLOAD_INPUT_STAGES:
|
||||
model_config = SimpleNamespace(
|
||||
stage_id=1,
|
||||
async_chunk=False,
|
||||
model_arch="Qwen3OmniMoeForConditionalGeneration",
|
||||
model_stage=model_stage,
|
||||
model_arch=arch,
|
||||
model_stage=stage,
|
||||
)
|
||||
self.assertTrue(
|
||||
uses_full_payload_input_coordinator(model_config),
|
||||
msg=f"expected gate to fire for {arch}/{stage}",
|
||||
)
|
||||
|
||||
self.assertTrue(uses_qwen3_omni_full_payload_input_coordinator(model_config))
|
||||
|
||||
def test_async_chunk_and_non_qwen3_omni_do_not_use_full_payload_input_coordinator(self):
|
||||
def test_other_arch_or_stage_or_mode_does_not_fire(self):
|
||||
cases = [
|
||||
SimpleNamespace(
|
||||
stage_id=1,
|
||||
async_chunk=True,
|
||||
model_arch="Qwen3OmniMoeForConditionalGeneration",
|
||||
model_stage="talker",
|
||||
stage_id=1, async_chunk=True, model_arch="Qwen3OmniMoeForConditionalGeneration", model_stage="talker"
|
||||
),
|
||||
SimpleNamespace(
|
||||
stage_id=0, async_chunk=False, model_arch="Qwen3OmniMoeForConditionalGeneration", model_stage="thinker"
|
||||
),
|
||||
SimpleNamespace(
|
||||
stage_id=1,
|
||||
async_chunk=False,
|
||||
model_arch="Qwen3TTSForConditionalGeneration",
|
||||
model_stage="code2wav",
|
||||
),
|
||||
SimpleNamespace(
|
||||
stage_id=1,
|
||||
async_chunk=False,
|
||||
model_arch="Qwen2_5OmniForConditionalGeneration",
|
||||
model_stage="talker",
|
||||
),
|
||||
SimpleNamespace(
|
||||
stage_id=0,
|
||||
async_chunk=False,
|
||||
model_arch="Qwen3OmniMoeForConditionalGeneration",
|
||||
model_stage="thinker",
|
||||
model_stage="some_future_stage",
|
||||
),
|
||||
SimpleNamespace(
|
||||
stage_id=1, async_chunk=False, model_arch="Qwen3TTSForConditionalGeneration", model_stage="code2wav"
|
||||
),
|
||||
SimpleNamespace(
|
||||
stage_id=1, async_chunk=False, model_arch="MingFlashOmniForConditionalGeneration", model_stage="talker"
|
||||
),
|
||||
SimpleNamespace(stage_id=1, async_chunk=False, model_arch=None, model_stage="talker"),
|
||||
SimpleNamespace(
|
||||
stage_id=1, async_chunk=False, model_arch="Qwen3OmniMoeForConditionalGeneration", model_stage=None
|
||||
),
|
||||
]
|
||||
|
||||
for model_config in cases:
|
||||
self.assertFalse(uses_qwen3_omni_full_payload_input_coordinator(model_config))
|
||||
self.assertFalse(
|
||||
uses_full_payload_input_coordinator(model_config),
|
||||
msg=f"expected gate OFF for {model_config}",
|
||||
)
|
||||
|
||||
|
||||
class TestChunkCoordinatorStateTransition(unittest.TestCase):
|
||||
|
||||
@@ -293,7 +293,13 @@ def test_code2wav_forward_finalizes_hift_tail():
|
||||
model = object.__new__(CosyVoice3Code2Wav)
|
||||
nn.Module.__init__(model)
|
||||
model.hift = DummyHiFT()
|
||||
model._forward_mel = lambda **_: torch.ones((1, 80, 8), dtype=torch.float32)
|
||||
forward_mel_calls = []
|
||||
|
||||
def fake_forward_mel(**kwargs):
|
||||
forward_mel_calls.append(kwargs)
|
||||
return torch.ones((1, 80, 8), dtype=torch.float32)
|
||||
|
||||
model._forward_mel = fake_forward_mel
|
||||
|
||||
out = model.forward(
|
||||
token=torch.tensor([[1, 2, 3]], dtype=torch.int32),
|
||||
@@ -304,3 +310,4 @@ def test_code2wav_forward_finalizes_hift_tail():
|
||||
|
||||
assert out.shape == (1, 1, 8)
|
||||
assert model.hift.finalize_calls == [True]
|
||||
assert forward_mel_calls[0]["token_offset_tokens"] == 0
|
||||
@@ -138,27 +138,6 @@ def _make_sampling_metadata(
|
||||
)
|
||||
|
||||
|
||||
def test_split_request_ids_uses_seq_token_counts():
|
||||
CosyVoice3Model, _ = _cosyvoice3_model_and_runner()
|
||||
ids = torch.tensor([10, 11, 12, 13, 14], dtype=torch.long)
|
||||
chunks = CosyVoice3Model._split_request_ids(ids, [2, 2, 2])
|
||||
assert [c.tolist() for c in chunks] == [[10, 11], [12, 13], [14]]
|
||||
|
||||
|
||||
def test_split_request_ids_honors_single_request_seq_token_counts():
|
||||
CosyVoice3Model, _ = _cosyvoice3_model_and_runner()
|
||||
ids = torch.tensor([10, 11, 12, 13, 14], dtype=torch.long)
|
||||
chunks = CosyVoice3Model._split_request_ids(ids, [3])
|
||||
assert [c.tolist() for c in chunks] == [[10, 11, 12]]
|
||||
|
||||
|
||||
def test_sanitize_codec_tokens_filters_out_of_range():
|
||||
model = _make_code2wav_model()
|
||||
raw = torch.tensor([-1, 0, 3, 4, 99], dtype=torch.long)
|
||||
clean = model._sanitize_codec_tokens(raw)
|
||||
assert clean.tolist() == [0, 3]
|
||||
|
||||
|
||||
def test_forward_prefers_token_offset_when_present():
|
||||
model = _make_code2wav_model()
|
||||
|
||||
@@ -265,6 +244,31 @@ def test_forward_uses_non_stream_decode_without_chunk_metadata():
|
||||
assert len(model.code2wav.forward_streaming_calls) == 0
|
||||
call = model.code2wav.forward_calls[0]
|
||||
assert call["token"].tolist() == [[0, 1, 2]]
|
||||
assert call["token_offset_tokens"] == 0
|
||||
|
||||
|
||||
def test_forward_uses_non_stream_talker_prefill_offset():
|
||||
model = _make_code2wav_model()
|
||||
|
||||
runtime_info = [
|
||||
{
|
||||
"embed": {
|
||||
"speech_token": torch.tensor([[1, 2, 3]], dtype=torch.long),
|
||||
"speech_feat": torch.tensor([[[0.1, 0.2], [0.3, 0.4]]], dtype=torch.float32),
|
||||
"embedding": torch.tensor([[0.5, 0.6]], dtype=torch.float32),
|
||||
},
|
||||
"meta": {"talker_prefill_offset": 3},
|
||||
}
|
||||
]
|
||||
|
||||
model.forward(
|
||||
input_ids=torch.tensor([0, 1, 2], dtype=torch.long),
|
||||
positions=torch.tensor([0, 1, 2], dtype=torch.long),
|
||||
model_intermediate_buffer=runtime_info,
|
||||
seq_token_counts=[3],
|
||||
)
|
||||
|
||||
assert model.code2wav.forward_calls[0]["token_offset_tokens"] == 3
|
||||
|
||||
|
||||
def test_forward_reuses_streaming_cache_state_between_chunks():
|
||||
|
||||
+69
-4
@@ -6,13 +6,19 @@ from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.cosyvoice3 import talker2code2wav_async_chunk, text2flow
|
||||
from vllm_omni.model_executor.stage_input_processors.cosyvoice3 import (
|
||||
talker2code2wav_async_chunk,
|
||||
text2flow,
|
||||
text2flow_full_payload,
|
||||
text2flow_token_only,
|
||||
)
|
||||
|
||||
|
||||
def _source_output(request_id: str, prompt_ids: list[int], out_ids: list[int], mm: dict):
|
||||
def _source_output(request_id: str, prompt_ids: list[int], out_ids: list[int], mm: dict, finished: bool = True):
|
||||
return SimpleNamespace(
|
||||
request_id=request_id,
|
||||
prompt_token_ids=prompt_ids,
|
||||
finished=finished,
|
||||
outputs=[SimpleNamespace(token_ids=out_ids, cumulative_token_ids=out_ids, multimodal_output=mm)],
|
||||
)
|
||||
|
||||
@@ -45,8 +51,8 @@ def _transfer_manager(
|
||||
|
||||
def test_text2flow_supports_batched_source_outputs():
|
||||
source_outputs = [
|
||||
_source_output("req-0", [10, 11], [1, 2, 3], {"speech_token": torch.tensor([[1, 2]])}),
|
||||
_source_output("req-1", [20, 21], [4, 5], {"speech_token": torch.tensor([[3, 4]])}),
|
||||
_source_output("req-0", [10, 11], [1, 2, 3], {"speech_token": torch.tensor([[8, 9]])}),
|
||||
_source_output("req-1", [20, 21], [4, 5], {"speech_token": torch.tensor([[6, 7]])}),
|
||||
]
|
||||
|
||||
outputs = text2flow(source_outputs=source_outputs, prompt=None)
|
||||
@@ -58,6 +64,65 @@ def test_text2flow_supports_batched_source_outputs():
|
||||
assert outputs[1]["additional_information"]["ids"]["prompt"] == [20, 21]
|
||||
|
||||
|
||||
def test_text2flow_strips_reference_speech_prefix_from_cumulative_ids():
|
||||
source_outputs = [
|
||||
_source_output("req-0", [10, 11], [8, 9, 1, 2, 3], {"speech_token": torch.tensor([[8, 9]])}),
|
||||
]
|
||||
|
||||
outputs = text2flow(source_outputs=source_outputs, prompt=None)
|
||||
|
||||
assert outputs[0]["prompt_token_ids"] == [1, 2, 3]
|
||||
|
||||
|
||||
def test_text2flow_token_only_strips_reference_speech_prefix_from_cumulative_ids():
|
||||
source_outputs = [
|
||||
_source_output(
|
||||
"req-strip",
|
||||
[10, 11],
|
||||
[4, 5, 1, 2, 3],
|
||||
{"embed": {"speech_token": torch.tensor([[4, 5]])}},
|
||||
)
|
||||
]
|
||||
|
||||
outputs = text2flow_token_only(source_outputs=source_outputs, prompt=None)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0]["prompt_token_ids"] == [1, 2, 3]
|
||||
assert outputs[0]["additional_information"]["ids"]["prompt"] == [10, 11]
|
||||
|
||||
|
||||
def test_text2flow_token_only_marks_prompt_trim_for_stop_token_completion():
|
||||
source_outputs = [
|
||||
_source_output(
|
||||
"req-stop",
|
||||
[10, 11],
|
||||
[4, 5, 1, 2, 6562],
|
||||
{"embed": {"speech_token": torch.tensor([[4, 5]])}},
|
||||
)
|
||||
]
|
||||
|
||||
outputs = text2flow_token_only(source_outputs=source_outputs, prompt=None)
|
||||
|
||||
assert outputs[0]["prompt_token_ids"] == [1, 2, 6562]
|
||||
assert outputs[0]["additional_information"]["meta"]["talker_prefill_offset"] == 2
|
||||
|
||||
|
||||
def test_text2flow_full_payload_does_not_send_codec_ids():
|
||||
payload = text2flow_full_payload(
|
||||
None,
|
||||
{
|
||||
"embed.speech_token": torch.tensor([[1, 2]], dtype=torch.long),
|
||||
"codes.audio": torch.tensor([7, 8, 9], dtype=torch.long),
|
||||
},
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert "codes" not in payload
|
||||
assert "next_stage_prompt_len" not in payload["meta"]
|
||||
assert torch.equal(payload["embed"]["speech_token"], torch.tensor([[1, 2]], dtype=torch.long))
|
||||
|
||||
|
||||
def test_talker2code2wav_async_chunk_final_payload_uses_absolute_token_offset():
|
||||
transfer_manager = _transfer_manager()
|
||||
request = SimpleNamespace(
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Light coverage for qwen2_5_omni.thinker2talker_full_payload.
|
||||
|
||||
Covers the finish-reason-aware stop-row trim contract: when the request
|
||||
status is FINISHED_STOPPED, the builder must drop one row from the
|
||||
accumulated hidden states (vLLM v1 appends the sampled stop token to
|
||||
output_token_ids before check_stop, so the trailing hidden-state row
|
||||
corresponds to the stop emission and must not reach the talker).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.qwen2_5_omni import (
|
||||
thinker2talker_full_payload,
|
||||
)
|
||||
|
||||
|
||||
def _make_request(
|
||||
prompt_token_ids,
|
||||
output_token_ids,
|
||||
status_name: str | None = "FINISHED_STOPPED",
|
||||
):
|
||||
status = SimpleNamespace(name=status_name) if status_name else None
|
||||
return SimpleNamespace(
|
||||
request_id="r1",
|
||||
prompt_token_ids=prompt_token_ids,
|
||||
output_token_ids=output_token_ids,
|
||||
all_token_ids=list(prompt_token_ids) + list(output_token_ids),
|
||||
status=status,
|
||||
sampling_params=None,
|
||||
)
|
||||
|
||||
|
||||
def test_finished_stopped_trims_one_decode_row():
|
||||
"""FINISHED_STOPPED: drop trailing hidden-state row so talker does not
|
||||
consume the stop-emission row.
|
||||
"""
|
||||
prompt = [1, 2, 3]
|
||||
output = [10, 11, 12]
|
||||
request = _make_request(prompt, output, status_name="FINISHED_STOPPED")
|
||||
# 6 prompt+output rows + 1 stop-emission row = 7 hidden rows total.
|
||||
hidden = torch.arange(7 * 4, dtype=torch.float32).reshape(7, 4)
|
||||
pooling = {"hidden": hidden}
|
||||
|
||||
payload = thinker2talker_full_payload(transfer_manager=None, pooling_output=pooling, request=request)
|
||||
|
||||
assert payload is not None
|
||||
# ids.output had one trailing stop row dropped: 3 - 1 = 2 remaining.
|
||||
assert payload["ids"]["output"] == output[:-1]
|
||||
# embed.prefill must cover only the prompt rows.
|
||||
assert payload["embed"]["prefill"].shape[0] == len(prompt)
|
||||
# hidden_states.output covers the decode rows minus the dropped stop row.
|
||||
assert payload["hidden_states"]["output"].shape[0] == len(output) - 1
|
||||
|
||||
|
||||
def test_finished_length_capped_keeps_all_rows():
|
||||
"""FINISHED_LENGTH_CAPPED: no row drop; hidden_states.output covers
|
||||
all decode rows.
|
||||
"""
|
||||
prompt = [1, 2, 3]
|
||||
output = [10, 11, 12]
|
||||
request = _make_request(prompt, output, status_name="FINISHED_LENGTH_CAPPED")
|
||||
hidden = torch.arange(6 * 4, dtype=torch.float32).reshape(6, 4)
|
||||
pooling = {"hidden": hidden}
|
||||
|
||||
payload = thinker2talker_full_payload(transfer_manager=None, pooling_output=pooling, request=request)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["ids"]["output"] == output
|
||||
assert payload["embed"]["prefill"].shape[0] == len(prompt)
|
||||
assert payload["hidden_states"]["output"].shape[0] == len(output)
|
||||
|
||||
|
||||
def test_missing_hidden_returns_none():
|
||||
"""Defensive: pooling_output without "hidden" returns None."""
|
||||
request = _make_request([1, 2], [3], status_name="FINISHED_STOPPED")
|
||||
assert thinker2talker_full_payload(transfer_manager=None, pooling_output={}, request=request) is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -168,6 +168,7 @@ def test_talker2code2wav_full_payload_keeps_all_zero_codec_rows() -> None:
|
||||
|
||||
|
||||
def test_thinker2talker_full_payload_packs_complete_tensors() -> None:
|
||||
"""Full-payload path drops the terminal thinker row before talker prefill."""
|
||||
request = SimpleNamespace(
|
||||
request_id="thinker",
|
||||
prompt_token_ids=[151644, 872],
|
||||
@@ -187,3 +188,741 @@ def test_thinker2talker_full_payload_packs_complete_tensors() -> None:
|
||||
assert payload["embed"]["prefill"].device.type == "cpu"
|
||||
assert payload["hidden_states"]["output"].device.type == "cpu"
|
||||
assert payload["next_stage_prompt_len"] > 0
|
||||
assert payload["embed"]["prefill"].shape[0] == 2
|
||||
assert payload["hidden_states"]["output"].shape[0] == 2
|
||||
|
||||
|
||||
def test_accumulator_replaces_keys_in_replace_set() -> None:
|
||||
"""REPLACE-key semantics: subsequent emissions of the same key replace, not append."""
|
||||
from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorModelRunnerMixin
|
||||
|
||||
class _StubMixin(OmniConnectorModelRunnerMixin):
|
||||
def __init__(self):
|
||||
self._pending_full_payload_send = {}
|
||||
self._full_payload_replace_keys_cached = frozenset({"model_outputs"})
|
||||
|
||||
stub = _StubMixin()
|
||||
stub.accumulate_full_payload_output(
|
||||
"req1",
|
||||
{
|
||||
"model_outputs": torch.tensor([[1.0, 2.0]]),
|
||||
"hidden_states.output": torch.tensor([[10.0]]),
|
||||
},
|
||||
request=None,
|
||||
)
|
||||
stub.accumulate_full_payload_output(
|
||||
"req1",
|
||||
{
|
||||
"model_outputs": torch.tensor([[3.0, 4.0]]),
|
||||
"hidden_states.output": torch.tensor([[20.0]]),
|
||||
},
|
||||
request=None,
|
||||
)
|
||||
output, _ = stub._materialize_full_payload_entry(stub._pending_full_payload_send["req1"])
|
||||
# model_outputs REPLACED (second value only):
|
||||
assert torch.equal(output["model_outputs"], torch.tensor([[3.0, 4.0]]))
|
||||
# hidden_states.output CONCATENATED:
|
||||
assert torch.equal(output["hidden_states.output"], torch.tensor([[10.0], [20.0]]))
|
||||
|
||||
|
||||
def test_accumulator_concat_default_when_no_replace_keys() -> None:
|
||||
"""Default semantics: 2-D+ tensors concat across emissions when not in replace_keys."""
|
||||
from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorModelRunnerMixin
|
||||
|
||||
class _StubMixin(OmniConnectorModelRunnerMixin):
|
||||
def __init__(self):
|
||||
self._pending_full_payload_send = {}
|
||||
self._full_payload_replace_keys_cached = frozenset()
|
||||
|
||||
stub = _StubMixin()
|
||||
stub.accumulate_full_payload_output(
|
||||
"req1",
|
||||
{"embed.prefill": torch.tensor([[1.0]])},
|
||||
request=None,
|
||||
)
|
||||
stub.accumulate_full_payload_output(
|
||||
"req1",
|
||||
{"embed.prefill": torch.tensor([[2.0]])},
|
||||
request=None,
|
||||
)
|
||||
output, _ = stub._materialize_full_payload_entry(stub._pending_full_payload_send["req1"])
|
||||
assert torch.equal(output["embed.prefill"], torch.tensor([[1.0], [2.0]]))
|
||||
|
||||
|
||||
def test_covo_audio_llm2code2wav_token_only_smoke() -> None:
|
||||
"""Smoke: covo_audio token-only builder returns placeholder prompts sized to audio_codes count."""
|
||||
# source_outputs is a list of objects with .outputs[0].token_ids
|
||||
from vllm_omni.model_executor.models.covo_audio.config_covo_audio import COVO_AUDIO_TOKEN_INDEX
|
||||
from vllm_omni.model_executor.stage_input_processors.covo_audio import (
|
||||
llm2code2wav_token_only,
|
||||
)
|
||||
|
||||
class _Out:
|
||||
def __init__(self, tids):
|
||||
self.token_ids = tids
|
||||
|
||||
class _Wrapper:
|
||||
def __init__(self, tids):
|
||||
self.outputs = [_Out(tids)]
|
||||
|
||||
# 3 codec tokens + 2 non-codec
|
||||
src = [_Wrapper([COVO_AUDIO_TOKEN_INDEX + 0, COVO_AUDIO_TOKEN_INDEX + 1, COVO_AUDIO_TOKEN_INDEX + 2, 100, 200])]
|
||||
out = llm2code2wav_token_only(src)
|
||||
assert len(out) == 1
|
||||
assert len(out[0]["prompt_token_ids"]) == 3
|
||||
assert out[0]["additional_information"] is None
|
||||
|
||||
|
||||
def test_covo_audio_llm2code2wav_full_payload_smoke() -> None:
|
||||
"""Smoke: covo_audio producer-side payload builder returns audio_codes + finished."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from vllm_omni.model_executor.models.covo_audio.config_covo_audio import COVO_AUDIO_TOKEN_INDEX
|
||||
from vllm_omni.model_executor.stage_input_processors.covo_audio import (
|
||||
llm2code2wav_full_payload,
|
||||
)
|
||||
|
||||
req = SimpleNamespace(
|
||||
output_token_ids=[COVO_AUDIO_TOKEN_INDEX + 5, COVO_AUDIO_TOKEN_INDEX + 6, 99],
|
||||
)
|
||||
payload = llm2code2wav_full_payload(None, {}, req)
|
||||
assert payload is not None
|
||||
assert payload["codes"]["audio"] == [5, 6]
|
||||
assert payload["meta"]["finished"].item() is True
|
||||
|
||||
|
||||
def test_dynin_omni_token_only_smoke() -> None:
|
||||
"""Smoke: dynin_omni token-only builders return placeholders."""
|
||||
from vllm_omni.model_executor.stage_input_processors.dynin_omni import (
|
||||
token2text_to_token2image_token_only,
|
||||
)
|
||||
|
||||
class _Out:
|
||||
def __init__(self, tids, mm=None):
|
||||
self.token_ids = tids
|
||||
self.multimodal_output = mm
|
||||
|
||||
class _Wrapper:
|
||||
def __init__(self, tids, mm=None):
|
||||
self.outputs = [_Out(tids, mm)]
|
||||
self.request_id = "r0"
|
||||
|
||||
class _Stage:
|
||||
def __init__(self, outs):
|
||||
self.engine_outputs = outs
|
||||
|
||||
src = [_Wrapper([10, 11, 12])]
|
||||
out = token2text_to_token2image_token_only([_Stage(src)], [0])
|
||||
assert len(out) == 1
|
||||
assert len(out[0]["prompt_token_ids"]) == 3
|
||||
assert out[0]["additional_information"] is None
|
||||
|
||||
|
||||
def test_dynin_omni_full_payload_smoke() -> None:
|
||||
"""Smoke: dynin_omni producer-side payload builder returns nested OmniPayload + carries metadata."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.dynin_omni import (
|
||||
token2text_to_token2image_full_payload,
|
||||
)
|
||||
|
||||
pooling = {"token_ids": [1, 2, 3]}
|
||||
req = SimpleNamespace(output_token_ids=[], additional_information={"speaker": ["alice"]})
|
||||
payload = token2text_to_token2image_full_payload(None, pooling, req)
|
||||
assert payload is not None
|
||||
assert payload["codes"]["audio"] == [1, 2, 3]
|
||||
assert payload["meta"]["finished"].item() is True
|
||||
# additional_information is normalized + carried forward (speaker stays list-wrapped).
|
||||
assert payload.get("speaker") == ["alice"]
|
||||
|
||||
|
||||
def test_qwen2_5_omni_talker2code2wav_token_only_smoke() -> None:
|
||||
"""Smoke: qwen2_5_omni talker→code2wav token_only marker + boundary strip."""
|
||||
from vllm_omni.model_executor.stage_input_processors.qwen2_5_omni import (
|
||||
TALKER_CODEC_END_TOKEN_ID,
|
||||
TALKER_CODEC_START_TOKEN_ID,
|
||||
talker2code2wav_token_only,
|
||||
)
|
||||
|
||||
class _Out:
|
||||
def __init__(self, tids):
|
||||
self.cumulative_token_ids = tids
|
||||
|
||||
class _Wrap:
|
||||
def __init__(self, tids):
|
||||
self.outputs = [_Out(tids)]
|
||||
|
||||
# 3 inner codes wrapped by START + END
|
||||
src = [_Wrap([TALKER_CODEC_START_TOKEN_ID, 10, 11, 12, TALKER_CODEC_END_TOKEN_ID])]
|
||||
out = talker2code2wav_token_only(src)
|
||||
assert len(out) == 1
|
||||
assert len(out[0]["prompt_token_ids"]) == 3
|
||||
assert out[0]["additional_information"] is None
|
||||
|
||||
|
||||
def test_qwen2_5_omni_talker2code2wav_full_payload_smoke() -> None:
|
||||
"""Smoke: qwen2_5_omni producer-side payload builder strips boundaries."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.qwen2_5_omni import (
|
||||
TALKER_CODEC_END_TOKEN_ID,
|
||||
TALKER_CODEC_START_TOKEN_ID,
|
||||
talker2code2wav_full_payload,
|
||||
)
|
||||
|
||||
req = SimpleNamespace(
|
||||
output_token_ids=[TALKER_CODEC_START_TOKEN_ID, 5, 6, 7, TALKER_CODEC_END_TOKEN_ID],
|
||||
)
|
||||
payload = talker2code2wav_full_payload(None, {}, req)
|
||||
assert payload is not None
|
||||
assert payload["codes"]["audio"] == [5, 6, 7]
|
||||
assert payload["meta"]["finished"].item() is True
|
||||
|
||||
|
||||
def test_qwen2_5_omni_talker2code2wav_filters_control_tokens_and_placeholders() -> None:
|
||||
"""Qwen2.5 code2wav receives codec ids only, not talker prompt/control ids."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.qwen2_5_omni import (
|
||||
TALKER_CODEC_END_TOKEN_ID,
|
||||
TALKER_CODEC_PAD_TOKEN_ID,
|
||||
TALKER_CODEC_START_TOKEN_ID,
|
||||
talker2code2wav_full_payload,
|
||||
talker2code2wav_token_only,
|
||||
)
|
||||
|
||||
class _Out:
|
||||
def __init__(self, tids):
|
||||
self.cumulative_token_ids = tids
|
||||
|
||||
class _Wrap:
|
||||
def __init__(self, tids):
|
||||
self.outputs = [_Out(tids)]
|
||||
|
||||
raw_ids = [
|
||||
TALKER_CODEC_START_TOKEN_ID,
|
||||
TALKER_CODEC_PAD_TOKEN_ID,
|
||||
5,
|
||||
6,
|
||||
TALKER_CODEC_END_TOKEN_ID,
|
||||
-1,
|
||||
-1,
|
||||
]
|
||||
|
||||
token_only = talker2code2wav_token_only([_Wrap(raw_ids)])
|
||||
assert len(token_only) == 1
|
||||
assert len(token_only[0]["prompt_token_ids"]) == 4
|
||||
|
||||
payload = talker2code2wav_full_payload(None, {}, SimpleNamespace(output_token_ids=raw_ids))
|
||||
assert payload is not None
|
||||
assert payload["codes"]["audio"] == [5, 6, 6, 6]
|
||||
assert payload["meta"]["finished"].item() is True
|
||||
|
||||
|
||||
def test_mimo_audio_llm2code2wav_token_only_smoke() -> None:
|
||||
"""Smoke: mimo_audio token-only builder sizes prompt."""
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.mimo_audio import (
|
||||
llm2code2wav_token_only,
|
||||
)
|
||||
|
||||
class _Out:
|
||||
def __init__(self, mm):
|
||||
self.multimodal_output = mm
|
||||
|
||||
class _Wrap:
|
||||
def __init__(self, mm):
|
||||
self.outputs = [_Out(mm)]
|
||||
|
||||
# 3 batch rows of [1, 8, 4]: prepend_and_flatten_colmajor → 3*1*4*9 = 108
|
||||
codes = torch.arange(96, dtype=torch.long).reshape(3, 1, 8, 4)
|
||||
codes = codes.clamp(min=1) # ensure nonzero so zero-row filter doesn't drop them
|
||||
src = [_Wrap({"codes": {"audio": codes}})]
|
||||
out = llm2code2wav_token_only(src)
|
||||
assert len(out) == 1
|
||||
assert len(out[0]["prompt_token_ids"]) == 108
|
||||
assert out[0]["additional_information"] is None
|
||||
|
||||
|
||||
def test_mimo_audio_llm2code2wav_full_payload_smoke() -> None:
|
||||
"""Smoke: mimo_audio producer-side payload builder reads flat codes.audio + flattens."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.mimo_audio import (
|
||||
TALKER_CODEC_PAD_TOKEN_ID,
|
||||
llm2code2wav_full_payload,
|
||||
)
|
||||
|
||||
# Simulate accumulator output: 2 steps of [1, 1, 8, 4] CONCAT'd → [2, 1, 8, 4]
|
||||
audio = torch.arange(2 * 1 * 8 * 4, dtype=torch.long).reshape(2, 1, 8, 4)
|
||||
audio = audio.clamp(min=1) # avoid zero-row drop
|
||||
pooling_output = {"codes.audio": audio}
|
||||
req = SimpleNamespace(output_token_ids=[])
|
||||
payload = llm2code2wav_full_payload(None, pooling_output, req)
|
||||
assert payload is not None
|
||||
assert "codes" in payload and "audio" in payload["codes"]
|
||||
# Flattened length = numel + B*4 (per-batch pad_vec prepended by prepend_and_flatten_colmajor)
|
||||
batch_size = int(audio.shape[0])
|
||||
assert len(payload["codes"]["audio"]) == audio.numel() + batch_size * 4
|
||||
# prepend_and_flatten_colmajor: PAD appears at column start in col-major flatten.
|
||||
# For shape [B=2, 1, 9, 4], each column has 1 PAD then 8 codec vals → PAD at indices 0, 9, 18, 27.
|
||||
out = payload["codes"]["audio"]
|
||||
assert out[0] == TALKER_CODEC_PAD_TOKEN_ID
|
||||
assert out[9] == TALKER_CODEC_PAD_TOKEN_ID
|
||||
assert payload["meta"]["finished"].item() is True
|
||||
|
||||
|
||||
def test_mimo_audio_full_payload_nested_fallback() -> None:
|
||||
"""Back-compat: full_payload still works if runtime returns nested codes.audio."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.mimo_audio import (
|
||||
llm2code2wav_full_payload,
|
||||
)
|
||||
|
||||
audio = torch.arange(1 * 1 * 8 * 4, dtype=torch.long).reshape(1, 1, 8, 4)
|
||||
audio = audio.clamp(min=1)
|
||||
pooling_output = {"codes": {"audio": audio}} # nested, not flat
|
||||
req = SimpleNamespace(output_token_ids=[])
|
||||
payload = llm2code2wav_full_payload(None, pooling_output, req)
|
||||
assert payload is not None
|
||||
assert len(payload["codes"]["audio"]) == audio.numel() + int(audio.shape[0]) * 4
|
||||
|
||||
|
||||
def test_qwen3_tts_talker2code2wav_token_only_smoke() -> None:
|
||||
"""Smoke: qwen3_tts token-only sizes placeholder."""
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.qwen3_tts import (
|
||||
talker2code2wav_token_only,
|
||||
)
|
||||
|
||||
class _Out:
|
||||
def __init__(self, mm, tids):
|
||||
self.multimodal_output = mm
|
||||
self.cumulative_token_ids = tids
|
||||
|
||||
class _Wrap:
|
||||
def __init__(self, mm, tids):
|
||||
self.outputs = [_Out(mm, tids)]
|
||||
self.finished = True
|
||||
|
||||
# 3 valid codec frames Q=16; non-zero & under codebook size
|
||||
audio = torch.arange(3 * 16, dtype=torch.long).reshape(3, 16) + 1
|
||||
mm = {"codes": {"audio": audio}}
|
||||
src = [_Wrap(mm, list(range(10)))] # seq_len = 9; 3 < 9, no trim
|
||||
out = talker2code2wav_token_only(src)
|
||||
assert len(out) == 1
|
||||
# Codebook-major flat: 16 * 3 = 48
|
||||
assert len(out[0]["prompt_token_ids"]) == 48
|
||||
|
||||
|
||||
def test_qwen3_tts_talker2code2wav_full_payload_smoke() -> None:
|
||||
"""Smoke: qwen3_tts full_payload reads flat codes.audio + flattens codebook-major."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.qwen3_tts import (
|
||||
talker2code2wav_full_payload,
|
||||
)
|
||||
|
||||
# 3 valid codec frames [3, 16] CONCAT'd from per-step emits via flatten
|
||||
audio = torch.arange(3 * 16, dtype=torch.long).reshape(3, 16) + 1
|
||||
pooling_output = {"codes.audio": audio}
|
||||
req = SimpleNamespace(output_token_ids=list(range(10))) # seq_len = 9
|
||||
payload = talker2code2wav_full_payload(None, pooling_output, req)
|
||||
assert payload is not None
|
||||
assert "codes" in payload and "audio" in payload["codes"]
|
||||
# codebook-major: shape [3, 16] -> [16, 3] -> flatten = 48 entries
|
||||
assert isinstance(payload["codes"]["audio"], torch.Tensor)
|
||||
assert payload["codes"]["audio"].shape == (48,)
|
||||
expected = audio.transpose(0, 1).reshape(-1)
|
||||
assert torch.equal(payload["codes"]["audio"], expected)
|
||||
assert payload["meta"]["finished"].item() is True
|
||||
|
||||
|
||||
def test_qwen3_tts_full_payload_with_ref_code() -> None:
|
||||
"""Exact: ref_code is prepended (not appended) to audio, ref_code_len trims
|
||||
ref, and the flatten is codebook-major. Protects against ref-append-position
|
||||
regressions, ref_code_len-not-applied bugs, and flatten-order regressions."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.qwen3_tts import (
|
||||
talker2code2wav_full_payload,
|
||||
)
|
||||
|
||||
# Audio: 3 frames [3, 16] (no filter drops these — all positive, in-range).
|
||||
audio = torch.arange(3 * 16, dtype=torch.long).reshape(3, 16) + 1
|
||||
# Ref code: 2 frames [2, 16] (already 2-D), distinct value range so we can
|
||||
# detect the prepend ordering.
|
||||
ref = torch.arange(2 * 16, dtype=torch.long).reshape(2, 16) + 100
|
||||
pooling_output = {
|
||||
"codes.audio": audio,
|
||||
"codes.ref": [ref],
|
||||
"meta.ref_code_len": torch.tensor([2], dtype=torch.int32),
|
||||
}
|
||||
req = SimpleNamespace(output_token_ids=list(range(10))) # seq_len = 9 > 3, no audio crop
|
||||
payload = talker2code2wav_full_payload(None, pooling_output, req)
|
||||
assert payload is not None
|
||||
|
||||
# Exact expected: ref (prepended) + audio (no crop since seq_len > rows), then
|
||||
# transpose [5, 16] -> [16, 5] and flatten row-major (codebook-major).
|
||||
expected = torch.cat([ref, audio], dim=0).transpose(0, 1).reshape(-1)
|
||||
assert torch.equal(payload["codes"]["audio"], expected), (
|
||||
f"codec flatten mismatch -- got first 8 = {payload['codes']['audio'][:8].tolist()}, "
|
||||
f"expected first 8 = {expected[:8].tolist()}"
|
||||
)
|
||||
assert payload["codes"]["audio"].shape == (80,) # 16 quantizers * (2 ref + 3 audio) frames
|
||||
|
||||
# Sanity guards: first codebook-major column = [ref[0,0], ref[1,0], audio[0,0], ...],
|
||||
# so the prepend order must put 100 before 1.
|
||||
first_col = payload["codes"]["audio"][:5].tolist()
|
||||
assert first_col == [100, 116, 1, 17, 33], (
|
||||
f"first column wrong: {first_col} -- ref likely appended instead of prepended"
|
||||
)
|
||||
|
||||
|
||||
def test_qwen3_tts_full_payload_nested_fallback() -> None:
|
||||
"""Back-compat: full_payload works if pooler returns un-flattened nested dict."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.qwen3_tts import (
|
||||
talker2code2wav_full_payload,
|
||||
)
|
||||
|
||||
audio = torch.arange(2 * 16, dtype=torch.long).reshape(2, 16) + 1
|
||||
pooling_output = {"codes": {"audio": audio}} # nested, not flat
|
||||
req = SimpleNamespace(output_token_ids=list(range(10)))
|
||||
payload = talker2code2wav_full_payload(None, pooling_output, req)
|
||||
assert payload is not None
|
||||
assert isinstance(payload["codes"]["audio"], torch.Tensor)
|
||||
assert payload["codes"]["audio"].shape == (32,) # 16 * 2
|
||||
|
||||
|
||||
def test_qwen3_tts_code2wav_prefers_connector_tensor_payload() -> None:
|
||||
"""Code2Wav should consume connector codec tensor instead of placeholder zeros."""
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code2wav import (
|
||||
_codec_ids_from_payload_or_input,
|
||||
)
|
||||
|
||||
placeholder = torch.zeros(6, dtype=torch.long)
|
||||
codec = torch.arange(12, dtype=torch.long)
|
||||
|
||||
out = _codec_ids_from_payload_or_input(
|
||||
placeholder,
|
||||
{"codes": {"audio": codec}},
|
||||
)
|
||||
|
||||
assert torch.equal(out, codec)
|
||||
|
||||
|
||||
def test_qwen3_tts_code2wav_accepts_legacy_list_payload() -> None:
|
||||
"""Back-compat: old list full-payloads still override placeholder tokens."""
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code2wav import (
|
||||
_codec_ids_from_payload_or_input,
|
||||
)
|
||||
|
||||
placeholder = torch.zeros(6, dtype=torch.long)
|
||||
|
||||
out = _codec_ids_from_payload_or_input(
|
||||
placeholder,
|
||||
{"codes": {"audio": [1, 2, 3, 4]}},
|
||||
)
|
||||
|
||||
assert torch.equal(out, torch.tensor([1, 2, 3, 4], dtype=torch.long))
|
||||
|
||||
|
||||
def test_qwen3_tts_code2wav_forward_decodes_connector_payload() -> None:
|
||||
"""Forward should decode real connector codes, not token-only placeholders."""
|
||||
from collections import Counter
|
||||
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code2wav import (
|
||||
Qwen3TTSCode2Wav,
|
||||
)
|
||||
|
||||
class _Decoder:
|
||||
def __init__(self):
|
||||
self.last_codes = None
|
||||
|
||||
def chunked_decode(self, codes, **_kwargs):
|
||||
self.last_codes = codes.detach().clone()
|
||||
return codes.sum(dim=1).to(torch.float32)
|
||||
|
||||
decoder = _Decoder()
|
||||
model = Qwen3TTSCode2Wav.__new__(Qwen3TTSCode2Wav)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.decoder = decoder
|
||||
model._num_quantizers = 2
|
||||
model._total_upsample = 1
|
||||
model._output_sample_rate = 24000
|
||||
model._decode_chunk_frames = 300
|
||||
model._decode_left_context_frames = 25
|
||||
model._decode_batch_bucket_frames = []
|
||||
model._decode_batch_max_size = 0
|
||||
model._decode_variable_chunk_batch_min_frames = 326
|
||||
model._logged_codec_stats = True
|
||||
model._logged_malformed_codec_lengths = set()
|
||||
model._batch_stats_enabled = False
|
||||
model._batch_stats_log_every = 0
|
||||
model._batch_stats_forwards = 0
|
||||
model._batch_stats_groups = 0
|
||||
model._batch_stats_requests = 0
|
||||
model._batch_stats_padded_frames = 0
|
||||
model._batch_stats_decoded_frames = 0
|
||||
model._batch_stats_actual_frames = Counter()
|
||||
model._batch_stats_bucket_groups = Counter()
|
||||
|
||||
payload_codes = torch.tensor([1, 3, 2, 4], dtype=torch.long)
|
||||
out = model.forward(
|
||||
input_ids=torch.zeros(4, dtype=torch.long),
|
||||
runtime_additional_information=[{"codes": {"audio": payload_codes}, "meta": {}}],
|
||||
)
|
||||
|
||||
assert decoder.last_codes is not None
|
||||
assert torch.equal(decoder.last_codes, torch.tensor([[[1, 3], [2, 4]]], dtype=torch.long))
|
||||
assert torch.equal(out.multimodal_outputs["model_outputs"][0], torch.tensor([3.0, 7.0]))
|
||||
|
||||
|
||||
def test_qwen3_tts_codec_filter_and_crop_edge_cases() -> None:
|
||||
"""Regression gate for codec filter + seq_len crop on both token_only and full_payload.
|
||||
|
||||
Mixes valid / all-zero / negative / >=_CODEBOOK_SIZE rows. Asserts:
|
||||
- Token-only placeholder length matches Q * (#kept rows after crop).
|
||||
- Full-payload codes.audio matches the exact codebook-major flatten
|
||||
of the kept-and-cropped rows.
|
||||
|
||||
Protects against future cleanup reverting the codex P2 #3 (negative
|
||||
codec filter) or the _CODEBOOK_SIZE upper bound.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.qwen3_tts import (
|
||||
_CODEBOOK_SIZE,
|
||||
talker2code2wav_full_payload,
|
||||
talker2code2wav_token_only,
|
||||
)
|
||||
|
||||
Q = 4 # simulated num_quantizers (default is 16; small here for readability)
|
||||
# 7 rows: valid / all-zero / negative / out-of-range / boundary-valid / valid / valid.
|
||||
audio_rows = [
|
||||
[10, 20, 30, 40], # row 0: valid -> KEEP
|
||||
[0, 0, 0, 0], # row 1: all-zero -> DROP
|
||||
[50, -1, 60, 70], # row 2: negative -> DROP
|
||||
[100, _CODEBOOK_SIZE, 110, 120], # row 3: >= 2048 -> DROP
|
||||
[200, _CODEBOOK_SIZE - 1, 210, 220], # row 4: boundary 2047 -> KEEP
|
||||
[300, 310, 320, 330], # row 5: valid -> KEEP
|
||||
[400, 410, 420, 430], # row 6: valid -> KEEP
|
||||
]
|
||||
audio = torch.tensor(audio_rows, dtype=torch.long)
|
||||
kept = [audio_rows[i] for i in (0, 4, 5, 6)] # 4 rows after filter
|
||||
|
||||
# === token_only path ===
|
||||
# cumulative_token_ids of length 4 -> seq_len = 3 -> crop kept[-3:] = rows {4, 5, 6}
|
||||
class _Out:
|
||||
def __init__(self, ctids, mm):
|
||||
self.cumulative_token_ids = ctids
|
||||
self.multimodal_output = mm
|
||||
|
||||
class _Wrap:
|
||||
def __init__(self, ctids, mm):
|
||||
self.outputs = [_Out(ctids, mm)]
|
||||
self.finished = True
|
||||
|
||||
mm = {"codes": {"audio": audio}, "meta": {}}
|
||||
src = [_Wrap(ctids=[1, 2, 3, 4], mm=mm)]
|
||||
out = talker2code2wav_token_only(src, prompt=None)
|
||||
assert len(out) == 1
|
||||
# No ref_code -> ref_frames = 0; expected prompt_len = Q * (#kept-after-crop) = 4 * 3 = 12
|
||||
assert len(out[0]["prompt_token_ids"]) == Q * 3
|
||||
|
||||
# === full_payload path ===
|
||||
pooling_output = {"codes.audio": audio}
|
||||
req = SimpleNamespace(output_token_ids=[1, 2, 3, 4]) # seq_len = 3
|
||||
payload = talker2code2wav_full_payload(None, pooling_output, req)
|
||||
assert payload is not None
|
||||
# After filter + crop, kept rows = [row4, row5, row6] = [[200,2047,210,220],[300,310,320,330],[400,410,420,430]]
|
||||
# Codebook-major flatten: transpose [3, Q] -> [Q, 3] -> reshape(-1)
|
||||
cropped = torch.tensor(kept[-3:], dtype=torch.long)
|
||||
expected = cropped.transpose(0, 1).reshape(-1)
|
||||
assert torch.equal(payload["codes"]["audio"], expected)
|
||||
# Sanity: confirm the boundary-valid 2047 survived (codex P2 #3 regression guard).
|
||||
assert _CODEBOOK_SIZE - 1 in payload["codes"]["audio"].tolist()
|
||||
# Sanity: confirm no negative or >=_CODEBOOK_SIZE codec id leaked through.
|
||||
assert bool(((payload["codes"]["audio"] >= 0) & (payload["codes"]["audio"] < _CODEBOOK_SIZE)).all())
|
||||
|
||||
|
||||
def test_cosyvoice3_text2flow_token_only_smoke() -> None:
|
||||
"""Smoke: cosyvoice3 token-only carries ids.prompt only."""
|
||||
from vllm_omni.model_executor.stage_input_processors.cosyvoice3 import (
|
||||
text2flow_token_only,
|
||||
)
|
||||
|
||||
class _Out:
|
||||
def __init__(self, tids):
|
||||
self.cumulative_token_ids = tids
|
||||
self.multimodal_output = {}
|
||||
|
||||
class _Wrap:
|
||||
def __init__(self, output_tids, prompt_tids):
|
||||
self.outputs = [_Out(output_tids)]
|
||||
self.prompt_token_ids = prompt_tids
|
||||
self.finished = True
|
||||
|
||||
# multimodal_output has embed.* + we expect token_only to preserve it.
|
||||
import torch
|
||||
|
||||
embed = {"speech_token": torch.zeros(2, 4)}
|
||||
src = [_Wrap(output_tids=[10, 20, 30], prompt_tids=[1, 2, 3, 4])]
|
||||
src[0].outputs[0].multimodal_output = {"embed": embed}
|
||||
out = text2flow_token_only(src)
|
||||
assert len(out) == 1
|
||||
# prompt_token_ids is the talker's cumulative_token_ids (real codec tokens, not zeros).
|
||||
assert out[0]["prompt_token_ids"] == [10, 20, 30]
|
||||
# additional_information carries ids.prompt PLUS the original multimodal_output (embed.* still inline).
|
||||
# Heavy embed.* removal pending the model_intermediate_buffer plumbing on the code2wav side.
|
||||
assert out[0]["additional_information"]["ids"]["prompt"] == [1, 2, 3, 4]
|
||||
assert "embed" in out[0]["additional_information"]
|
||||
|
||||
|
||||
def test_cosyvoice3_text2flow_full_payload_smoke() -> None:
|
||||
"""Smoke: cosyvoice3 producer-side reads flat embed.* keys."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.cosyvoice3 import (
|
||||
text2flow_full_payload,
|
||||
)
|
||||
|
||||
speech_token = torch.randn(4, 8)
|
||||
speech_feat = torch.randn(4, 16)
|
||||
embedding = torch.randn(1, 32)
|
||||
pooling_output = {
|
||||
"embed.speech_token": speech_token,
|
||||
"embed.speech_feat": speech_feat,
|
||||
"embed.embedding": embedding,
|
||||
}
|
||||
req = SimpleNamespace(external_req_id="r-1")
|
||||
payload = text2flow_full_payload(None, pooling_output, req)
|
||||
assert payload is not None
|
||||
assert "embed" in payload
|
||||
assert torch.equal(payload["embed"]["speech_token"], speech_token)
|
||||
assert torch.equal(payload["embed"]["speech_feat"], speech_feat)
|
||||
assert torch.equal(payload["embed"]["embedding"], embedding)
|
||||
assert payload["meta"]["finished"].item() is True
|
||||
|
||||
|
||||
def test_cosyvoice3_text2flow_full_payload_nested_fallback() -> None:
|
||||
"""Back-compat: full_payload works if pooler returns un-flattened nested embed."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.cosyvoice3 import (
|
||||
text2flow_full_payload,
|
||||
)
|
||||
|
||||
speech_token = torch.randn(3, 8)
|
||||
pooling_output = {"embed": {"speech_token": speech_token}} # nested, not flat
|
||||
req = SimpleNamespace(external_req_id="r-2")
|
||||
payload = text2flow_full_payload(None, pooling_output, req)
|
||||
assert payload is not None
|
||||
assert "speech_token" in payload["embed"]
|
||||
assert torch.equal(payload["embed"]["speech_token"], speech_token)
|
||||
|
||||
|
||||
def test_cosyvoice3_full_payload_replace_keys_present() -> None:
|
||||
"""Confirm _FULL_PAYLOAD_REPLACE_KEYS lists the three embed.* keys."""
|
||||
from vllm_omni.model_executor.stage_input_processors.cosyvoice3 import (
|
||||
_FULL_PAYLOAD_REPLACE_KEYS,
|
||||
)
|
||||
|
||||
assert _FULL_PAYLOAD_REPLACE_KEYS == frozenset({"embed.speech_token", "embed.speech_feat", "embed.embedding"})
|
||||
|
||||
|
||||
def test_ming_flash_omni_thinker2talker_token_only_smoke() -> None:
|
||||
"""Smoke: ming_flash_omni token-only carries voice metadata."""
|
||||
from vllm_omni.model_executor.stage_input_processors.ming_flash_omni import (
|
||||
thinker2talker_token_only,
|
||||
)
|
||||
|
||||
class _Out:
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
|
||||
class _Wrap:
|
||||
def __init__(self, text):
|
||||
self.outputs = [_Out(text)]
|
||||
|
||||
class _Prompt:
|
||||
def __init__(self, info):
|
||||
self.additional_information = info
|
||||
|
||||
src = [_Wrap("hello world")]
|
||||
prompt = _Prompt({"voice_name": "ZH_FEMALE", "prompt_text": "ref text"})
|
||||
out = thinker2talker_token_only(src, prompt=prompt)
|
||||
assert len(out) == 1
|
||||
assert out[0]["prompt_token_ids"] == [0] # talker self-tokenizes; dummy id
|
||||
info = out[0]["additional_information"]
|
||||
assert info["text"] == "hello world"
|
||||
assert info["voice_name"] == "ZH_FEMALE"
|
||||
assert info["prompt_text"] == "ref text"
|
||||
assert info["ming_task"] == "omni"
|
||||
|
||||
|
||||
def test_qwen2_5_omni_thinker2talker_token_only_smoke() -> None:
|
||||
"""Smoke: qwen2_5_omni thinker token-only allocates prompt slots; bulk payload ships via connector."""
|
||||
from vllm_omni.model_executor.stage_input_processors.qwen2_5_omni import (
|
||||
TALKER_CODEC_END_TOKEN_ID,
|
||||
TALKER_CODEC_PAD_TOKEN_ID,
|
||||
TALKER_CODEC_START_TOKEN_ID,
|
||||
thinker2talker_token_only,
|
||||
)
|
||||
|
||||
class _Wrap:
|
||||
def __init__(self, prompt_tids, rid):
|
||||
self.outputs = [object()]
|
||||
self.prompt_token_ids = prompt_tids
|
||||
self.request_id = rid
|
||||
|
||||
class _Prompt(dict):
|
||||
pass
|
||||
|
||||
src = [_Wrap(prompt_tids=[1, 2, 3, 4, 5], rid="r-1")]
|
||||
prompt = [_Prompt(multi_modal_data=None)]
|
||||
out = thinker2talker_token_only(src, prompt=prompt)
|
||||
assert len(out) == 1
|
||||
expected_prompt_len = 1 + len([1, 2, 3, 4, 5]) + 1
|
||||
assert len(out[0]["prompt_token_ids"]) == expected_prompt_len
|
||||
assert out[0]["prompt_token_ids"][0] == TALKER_CODEC_START_TOKEN_ID
|
||||
assert out[0]["prompt_token_ids"][-1] == TALKER_CODEC_END_TOKEN_ID
|
||||
assert all(t == TALKER_CODEC_PAD_TOKEN_ID for t in out[0]["prompt_token_ids"][1:-1])
|
||||
assert out[0]["additional_information"] is None
|
||||
|
||||
|
||||
def test_qwen2_5_omni_thinker2talker_full_payload_noop() -> None:
|
||||
"""thinker2talker_full_payload returns None when pooling_output lacks the "hidden" key (defensive)."""
|
||||
from vllm_omni.model_executor.stage_input_processors.qwen2_5_omni import (
|
||||
thinker2talker_full_payload,
|
||||
)
|
||||
|
||||
payload = thinker2talker_full_payload(None, {"any": "thing"}, None)
|
||||
assert payload is None
|
||||
@@ -1841,12 +1841,19 @@ class TestSentinelDefaultPrecedence:
|
||||
)
|
||||
assert async_stages[1].custom_process_input_func is None
|
||||
|
||||
# async_chunk=False → stage 0 has no streaming processor, stage 1's
|
||||
# batch-end processor wires up.
|
||||
# async_chunk=False → stage 0 ships the bulk codec via the
|
||||
# worker-connector full-payload producer; stage 1 wires the
|
||||
# ``_token_only`` placeholder so the orchestrator emits no
|
||||
# legacy ``additional_information``-shaped input (PR3 sync-
|
||||
# via-connector data plane).
|
||||
sync_stages = merge_pipeline_deploy(pipeline, DeployConfig(async_chunk=False))
|
||||
assert "custom_process_next_stage_input_func" not in sync_stages[0].yaml_engine_args
|
||||
assert (
|
||||
sync_stages[0]
|
||||
.yaml_engine_args["custom_process_next_stage_input_func"]
|
||||
.endswith("talker2code2wav_full_payload")
|
||||
)
|
||||
assert sync_stages[1].custom_process_input_func is not None
|
||||
assert sync_stages[1].custom_process_input_func.endswith("talker2code2wav")
|
||||
assert sync_stages[1].custom_process_input_func.endswith("talker2code2wav_token_only")
|
||||
|
||||
def test_async_chunk_dispatches_qwen3_omni_processors(self):
|
||||
import runpy
|
||||
@@ -1883,6 +1890,44 @@ class TestSentinelDefaultPrecedence:
|
||||
.endswith("talker2code2wav_full_payload")
|
||||
)
|
||||
|
||||
def test_ming_flash_omni_topology(self):
|
||||
"""Guard ming_flash_omni's PR3 cleanup: stage 0 has no full-payload
|
||||
producer hook (the connector path was removed as fake -- arch is not
|
||||
in ``_FULL_PAYLOAD_INPUT_STAGES``), and stage 1 still wires the
|
||||
legacy ``thinker2talker`` (custom_process_input_func) plus the
|
||||
``thinker2talker_token_only`` placeholder (sync_process_input_func).
|
||||
Merge under either async_chunk mode must not re-introduce a
|
||||
stage-0 full-payload hook."""
|
||||
from vllm_omni.config.stage_config import DeployConfig, merge_pipeline_deploy
|
||||
|
||||
pipeline = _PIPELINE_REGISTRY["ming_flash_omni"]
|
||||
|
||||
stage0, stage1 = pipeline.stages
|
||||
assert stage0.custom_process_next_stage_input_func is None, (
|
||||
"ming_flash_omni stage 0 must not declare a full-payload producer "
|
||||
"(connector path is not active for this arch)."
|
||||
)
|
||||
assert stage1.custom_process_input_func is not None
|
||||
assert stage1.custom_process_input_func.endswith("thinker2talker")
|
||||
assert stage1.sync_process_input_func is not None
|
||||
assert stage1.sync_process_input_func.endswith("thinker2talker_token_only")
|
||||
|
||||
# async_chunk=True must now be rejected: removing the fake hook means
|
||||
# there is no next-stage input processor for the validator to accept.
|
||||
# (Positive consequence -- users can't accidentally enable async_chunk
|
||||
# on an arch that doesn't actually support it.)
|
||||
import pytest as _pytest
|
||||
|
||||
with _pytest.raises(ValueError, match="async_chunk=True"):
|
||||
merge_pipeline_deploy(pipeline, DeployConfig(async_chunk=True))
|
||||
|
||||
# async_chunk=False merges cleanly and stage-0 yaml_engine_args carries
|
||||
# no spurious full-payload hook.
|
||||
merged = merge_pipeline_deploy(pipeline, DeployConfig(async_chunk=False))
|
||||
assert "custom_process_next_stage_input_func" not in merged[0].yaml_engine_args, (
|
||||
"stage-0 full-payload hook unexpectedly re-appeared in yaml_engine_args"
|
||||
)
|
||||
|
||||
|
||||
class TestSamplingConstraintsPrecedence:
|
||||
"""Test that pipeline sampling_constraints override deploy defaults."""
|
||||
|
||||
@@ -400,16 +400,23 @@ def _make_full_payload_accumulation_runner(
|
||||
model_arch="Qwen3OmniMoeForConditionalGeneration",
|
||||
model_stage="talker",
|
||||
async_chunk=False,
|
||||
final_output=False,
|
||||
custom_process_next_stage_input_func="module.full_payload",
|
||||
):
|
||||
runner = object.__new__(OmniConnectorModelRunnerMixin)
|
||||
runner.model_config = SimpleNamespace(
|
||||
model_arch=model_arch,
|
||||
model_stage=model_stage,
|
||||
async_chunk=async_chunk,
|
||||
final_output=final_output,
|
||||
custom_process_next_stage_input_func=custom_process_next_stage_input_func,
|
||||
)
|
||||
runner._custom_process_func = object()
|
||||
runner._pending_full_payload_send = {}
|
||||
runner._stage_id = 1
|
||||
# Non-None sentinel: the gate short-circuits to False when no connector
|
||||
# is configured at all (terminal stages in pipelines with no connector).
|
||||
runner._omni_connector = object()
|
||||
return runner
|
||||
|
||||
|
||||
@@ -425,7 +432,7 @@ def test_accumulate_full_payload_output_preserves_aligned_all_zero_qwen3_omni_co
|
||||
|
||||
|
||||
def test_accumulate_full_payload_output_keeps_misaligned_all_zero_qwen3_omni_codec_rows():
|
||||
# After removing the sender-side zero filter, the accumulator keeps every
|
||||
# After removing the sender-side zero filter, the full-payload accumulator keeps every
|
||||
# codec row including misaligned all-zero rows. The downstream consumer
|
||||
# (_extract_qwen3_full_payload_codec_rows) is the authoritative crop and
|
||||
# filters by output_token_ids.
|
||||
@@ -472,18 +479,45 @@ def test_accumulate_full_payload_output_keeps_all_zero_qwen3_omni_prefill_placeh
|
||||
|
||||
|
||||
def test_full_payload_output_accumulation_hook_matrix():
|
||||
"""Producer-side gate: fires iff an explicit next-stage payload hook is loaded.
|
||||
|
||||
A derived `*_full_payload` helper from `custom_process_input_func` is not
|
||||
enough: terminal/input-only consumer stages must not enqueue orphan
|
||||
downstream payloads.
|
||||
"""
|
||||
# Thinker / talker producer stages: explicit next-stage payload hook -> gate fires.
|
||||
assert _make_full_payload_accumulation_runner(model_stage="thinker")._should_accumulate_full_payload_output()
|
||||
assert _make_full_payload_accumulation_runner(model_stage="talker")._should_accumulate_full_payload_output()
|
||||
assert not _make_full_payload_accumulation_runner(model_stage="code2wav")._should_accumulate_full_payload_output()
|
||||
|
||||
# Terminal stage: even if _load_custom_func derived a builder from
|
||||
# custom_process_input_func, final output stages are not producers.
|
||||
runner = _make_full_payload_accumulation_runner(model_stage="code2wav", final_output=True)
|
||||
assert not runner._should_accumulate_full_payload_output()
|
||||
|
||||
# Input-only consumer stage without an explicit producer hook must not
|
||||
# accumulate/send just because a same-module *_full_payload helper exists.
|
||||
runner = _make_full_payload_accumulation_runner(
|
||||
model_stage="token2audio",
|
||||
custom_process_next_stage_input_func=None,
|
||||
)
|
||||
assert not runner._should_accumulate_full_payload_output()
|
||||
|
||||
# async_chunk mode -> gate off.
|
||||
assert not _make_full_payload_accumulation_runner(
|
||||
model_stage="talker", async_chunk=True
|
||||
)._should_accumulate_full_payload_output()
|
||||
assert not _make_full_payload_accumulation_runner(
|
||||
model_arch="Qwen3TTSForConditionalGeneration"
|
||||
)._should_accumulate_full_payload_output()
|
||||
assert not _make_full_payload_accumulation_runner(
|
||||
model_arch="Qwen2_5OmniForConditionalGeneration"
|
||||
)._should_accumulate_full_payload_output()
|
||||
|
||||
# Non-qwen3 arches: gate is arch-agnostic, but if the fixture's arch
|
||||
# does not configure a connector payload builder, its runtime
|
||||
# `_custom_process_func` is None. Emulate that.
|
||||
runner = _make_full_payload_accumulation_runner(model_arch="Qwen3TTSForConditionalGeneration")
|
||||
runner._custom_process_func = None
|
||||
runner._should_accumulate_full_payload_output_cached = None
|
||||
assert not runner._should_accumulate_full_payload_output()
|
||||
runner = _make_full_payload_accumulation_runner(model_arch="Qwen2_5OmniForConditionalGeneration")
|
||||
runner._custom_process_func = None
|
||||
runner._should_accumulate_full_payload_output_cached = None
|
||||
assert not runner._should_accumulate_full_payload_output()
|
||||
|
||||
|
||||
def test_sync_local_stage_payloads_retains_payload_until_request_is_active():
|
||||
|
||||
@@ -22,9 +22,8 @@ from vllm.v1.spec_decode.metrics import SpecDecodingStats
|
||||
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
|
||||
from vllm_omni.core.sched.omni_scheduling_coordinator import (
|
||||
OmniSchedulingCoordinator,
|
||||
uses_qwen3_omni_full_payload_input_coordinator,
|
||||
uses_full_payload_input_coordinator,
|
||||
)
|
||||
from vllm_omni.core.sched.output import OmniSchedulerOutput
|
||||
from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import (
|
||||
OmniChunkTransferAdapter,
|
||||
)
|
||||
@@ -81,7 +80,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
if getattr(model_config, "async_chunk", False):
|
||||
self.chunk_transfer_adapter = OmniChunkTransferAdapter(self.vllm_config)
|
||||
self.input_coordinator: OmniSchedulingCoordinator | None = None
|
||||
if uses_qwen3_omni_full_payload_input_coordinator(model_config):
|
||||
if uses_full_payload_input_coordinator(model_config):
|
||||
self.input_coordinator = OmniSchedulingCoordinator(
|
||||
scheduler_max_num_seqs=self.vllm_config.scheduler_config.max_num_seqs,
|
||||
stage_id=getattr(model_config, "stage_id", 0),
|
||||
@@ -211,18 +210,8 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
for req in list(queue):
|
||||
if getattr(req, "status", None) == RequestStatus.FINISHED_ABORTED:
|
||||
queue.remove(req)
|
||||
connector_output = self._latest_omni_connector_output
|
||||
self._latest_omni_connector_output = None
|
||||
if self.input_coordinator:
|
||||
if connector_output and connector_output.request_metadata:
|
||||
self.input_coordinator.update_request_metadata(
|
||||
self.requests, connector_output.request_metadata, model_mode="ar"
|
||||
)
|
||||
self.input_coordinator.process_pending_full_payload_inputs(
|
||||
self.waiting,
|
||||
self.running,
|
||||
connector_output.stage_recv_req_ids if connector_output else set(),
|
||||
)
|
||||
self._consume_pending_connector_output(model_mode="ar")
|
||||
self._process_pending_input_timeouts()
|
||||
|
||||
if self.chunk_transfer_adapter:
|
||||
self.chunk_transfer_adapter.process_pending_chunks(self.waiting, self.running)
|
||||
@@ -278,13 +267,9 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
finished_reqs = {}
|
||||
|
||||
# Wrap in omni scheduler output to carry transfer metadata.
|
||||
base_fields = SchedulerOutput.__dataclass_fields__.keys()
|
||||
base_data = {name: getattr(scheduler_output, name) for name in base_fields}
|
||||
input_regs = self.input_coordinator.pending_input_registrations if self.input_coordinator else []
|
||||
return OmniSchedulerOutput(
|
||||
**base_data,
|
||||
return self._wrap_omni_scheduler_output(
|
||||
scheduler_output,
|
||||
finished_requests_needing_kv_transfer=finished_reqs,
|
||||
pending_input_registrations=input_regs,
|
||||
)
|
||||
|
||||
def update_from_output(
|
||||
@@ -581,15 +566,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
engine_core_outputs[0] = eco = EngineCoreOutputs()
|
||||
eco.scheduler_stats = stats
|
||||
|
||||
omni_output = getattr(model_runner_output, "omni_connector_output", None)
|
||||
if omni_output is not None:
|
||||
self._latest_omni_connector_output = omni_output
|
||||
if self.input_coordinator and omni_output.request_metadata:
|
||||
self.input_coordinator.update_request_metadata(
|
||||
self.requests,
|
||||
omni_output.request_metadata,
|
||||
model_mode="ar",
|
||||
)
|
||||
self._capture_omni_connector_output(model_runner_output)
|
||||
|
||||
# Free blocks that were held for transfer (kv_ready and
|
||||
# active_kv_transfers updates already done before the per-request loop).
|
||||
@@ -668,70 +645,73 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
if self.finished_req_ids_dict is not None:
|
||||
self.finished_req_ids_dict[request.client_index].add(request_id)
|
||||
|
||||
# 2. Omni Specific: Check if we need to transfer KV
|
||||
if self._should_transfer_kv_for_request(request_id):
|
||||
already_triggered = request_id in self.transfer_triggered_requests
|
||||
is_active = request_id in self.active_kv_transfers
|
||||
# Mirror the generation scheduler's try/finally pattern so the
|
||||
# input_coordinator entry is always pruned along every return path,
|
||||
# including the early returns for in-flight / waiting KV transfers
|
||||
# below. _free_input_coordinator_request is a no-op when the
|
||||
# coordinator is None, so the unconditional finally is safe.
|
||||
try:
|
||||
# 2. Omni Specific: Check if we need to transfer KV
|
||||
if self._should_transfer_kv_for_request(request_id):
|
||||
already_triggered = request_id in self.transfer_triggered_requests
|
||||
is_active = request_id in self.active_kv_transfers
|
||||
|
||||
if already_triggered:
|
||||
if is_active:
|
||||
# It triggered but hasn't finished yet. We MUST wait.
|
||||
logger.debug(f"[Omni] Request {request_id} finished but transfer is still ACTIVE. Waiting.")
|
||||
self.waiting_for_transfer_free.add(request_id)
|
||||
if self.input_coordinator is not None:
|
||||
self._free_input_coordinator_request(request_id)
|
||||
kv_xfer_params = None
|
||||
return kv_xfer_params
|
||||
elif request_id in self.waiting_for_transfer_free:
|
||||
# Blocks held until KV extraction completes in a future step.
|
||||
if self.input_coordinator is not None:
|
||||
self._free_input_coordinator_request(request_id)
|
||||
return None
|
||||
if already_triggered:
|
||||
if is_active:
|
||||
# It triggered but hasn't finished yet. We MUST wait.
|
||||
logger.debug(f"[Omni] Request {request_id} finished but transfer is still ACTIVE. Waiting.")
|
||||
self.waiting_for_transfer_free.add(request_id)
|
||||
kv_xfer_params = None
|
||||
return kv_xfer_params
|
||||
elif request_id in self.waiting_for_transfer_free:
|
||||
# Blocks held until KV extraction completes in a future step.
|
||||
return None
|
||||
else:
|
||||
logger.debug(
|
||||
f"[Omni] Request {request_id} finished and transfer no longer ACTIVE (extracted/acked). "
|
||||
"Freeing immediately."
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"[Omni] Request {request_id} finished and transfer no longer ACTIVE (extracted/acked). "
|
||||
"Freeing immediately."
|
||||
)
|
||||
else:
|
||||
self.waiting_for_transfer_free.add(request_id)
|
||||
confirmed_computed = self._get_confirmed_num_computed_tokens(request)
|
||||
self._mark_request_for_kv_transfer(request_id, confirmed_computed)
|
||||
# Return KV transfer metadata so it propagates to RequestOutput
|
||||
if request_id in self.requests_needing_kv_transfer:
|
||||
transfer_data = self.requests_needing_kv_transfer[request_id]
|
||||
kv_xfer_params = {
|
||||
"past_key_values": transfer_data["block_ids"],
|
||||
"kv_metadata": {"seq_len": transfer_data["seq_len"], "block_ids": transfer_data["block_ids"]},
|
||||
}
|
||||
# Also update request.additional_information for good measure
|
||||
add_info = getattr(request, "additional_information", None)
|
||||
# If additional_information is an AdditionalInformationPayload-like object,
|
||||
# unpack it into a plain dict.
|
||||
if (
|
||||
add_info is not None
|
||||
and hasattr(add_info, "entries")
|
||||
and isinstance(getattr(add_info, "entries"), dict)
|
||||
):
|
||||
request.additional_information = deserialize_additional_information(add_info)
|
||||
add_info = request.additional_information
|
||||
if add_info is None:
|
||||
request.additional_information = {}
|
||||
add_info = request.additional_information
|
||||
if isinstance(add_info, dict):
|
||||
add_info.update(kv_xfer_params)
|
||||
self.waiting_for_transfer_free.add(request_id)
|
||||
confirmed_computed = self._get_confirmed_num_computed_tokens(request)
|
||||
self._mark_request_for_kv_transfer(request_id, confirmed_computed)
|
||||
# Return KV transfer metadata so it propagates to RequestOutput
|
||||
if request_id in self.requests_needing_kv_transfer:
|
||||
transfer_data = self.requests_needing_kv_transfer[request_id]
|
||||
kv_xfer_params = {
|
||||
"past_key_values": transfer_data["block_ids"],
|
||||
"kv_metadata": {
|
||||
"seq_len": transfer_data["seq_len"],
|
||||
"block_ids": transfer_data["block_ids"],
|
||||
},
|
||||
}
|
||||
# Also update request.additional_information for good measure
|
||||
add_info = getattr(request, "additional_information", None)
|
||||
# If additional_information is an AdditionalInformationPayload-like object,
|
||||
# unpack it into a plain dict.
|
||||
if (
|
||||
add_info is not None
|
||||
and hasattr(add_info, "entries")
|
||||
and isinstance(getattr(add_info, "entries"), dict)
|
||||
):
|
||||
request.additional_information = deserialize_additional_information(add_info)
|
||||
add_info = request.additional_information
|
||||
if add_info is None:
|
||||
request.additional_information = {}
|
||||
add_info = request.additional_information
|
||||
if isinstance(add_info, dict):
|
||||
add_info.update(kv_xfer_params)
|
||||
|
||||
if self.input_coordinator is not None:
|
||||
self._free_input_coordinator_request(request_id)
|
||||
return kv_xfer_params
|
||||
return kv_xfer_params
|
||||
|
||||
# 3. Standard Freeing
|
||||
delay_free_blocks |= connector_delay_free_blocks
|
||||
if self.input_coordinator is not None:
|
||||
# 3. Standard Freeing
|
||||
delay_free_blocks |= connector_delay_free_blocks
|
||||
if not delay_free_blocks:
|
||||
self._free_blocks(request)
|
||||
|
||||
return kv_xfer_params
|
||||
finally:
|
||||
self._free_input_coordinator_request(request_id)
|
||||
if not delay_free_blocks:
|
||||
self._free_blocks(request)
|
||||
|
||||
return kv_xfer_params
|
||||
|
||||
def _free_blocks(self, request: Request):
|
||||
# Helper to match base class structure if not directly available
|
||||
|
||||
@@ -26,9 +26,9 @@ from vllm.v1.spec_decode.metrics import SpecDecodingStats
|
||||
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
|
||||
from vllm_omni.core.sched.omni_scheduling_coordinator import (
|
||||
OmniSchedulingCoordinator,
|
||||
uses_qwen3_omni_full_payload_input_coordinator,
|
||||
uses_full_payload_input_coordinator,
|
||||
)
|
||||
from vllm_omni.core.sched.output import OmniCachedRequestData, OmniNewRequestData, OmniSchedulerOutput
|
||||
from vllm_omni.core.sched.output import OmniCachedRequestData, OmniNewRequestData
|
||||
from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import (
|
||||
OmniChunkTransferAdapter,
|
||||
)
|
||||
@@ -46,7 +46,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
self.chunk_transfer_adapter = OmniChunkTransferAdapter(self.vllm_config)
|
||||
self._pending_finish_reqs: list[Request] = []
|
||||
self.input_coordinator: OmniSchedulingCoordinator | None = None
|
||||
if uses_qwen3_omni_full_payload_input_coordinator(model_config):
|
||||
if uses_full_payload_input_coordinator(model_config):
|
||||
self.input_coordinator = OmniSchedulingCoordinator(
|
||||
scheduler_max_num_seqs=self.vllm_config.scheduler_config.max_num_seqs,
|
||||
stage_id=getattr(model_config, "stage_id", 0),
|
||||
@@ -82,18 +82,8 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
# Temporary queue: preserve waiting order, do not disturb non-diffusion requests
|
||||
skipped_waiting_requests = create_request_queue(self.policy)
|
||||
req_index = 0
|
||||
connector_output = self._latest_omni_connector_output
|
||||
self._latest_omni_connector_output = None
|
||||
if self.input_coordinator:
|
||||
if connector_output and connector_output.request_metadata:
|
||||
self.input_coordinator.update_request_metadata(
|
||||
self.requests, connector_output.request_metadata, model_mode="generation"
|
||||
)
|
||||
self.input_coordinator.process_pending_full_payload_inputs(
|
||||
self.waiting,
|
||||
self.running,
|
||||
connector_output.stage_recv_req_ids if connector_output else set(),
|
||||
)
|
||||
self._consume_pending_connector_output(model_mode="generation")
|
||||
self._process_pending_input_timeouts()
|
||||
if self.chunk_transfer_adapter:
|
||||
self.chunk_transfer_adapter.process_pending_chunks(self.waiting, self.running)
|
||||
|
||||
@@ -227,14 +217,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
res = super().schedule()
|
||||
if self.input_coordinator:
|
||||
self.input_coordinator.restore_queues(self.waiting, self.running)
|
||||
base_fields = SchedulerOutput.__dataclass_fields__.keys()
|
||||
base_data = {name: getattr(res, name) for name in base_fields}
|
||||
return OmniSchedulerOutput(
|
||||
**base_data,
|
||||
pending_input_registrations=(
|
||||
self.input_coordinator.pending_input_registrations if self.input_coordinator else []
|
||||
),
|
||||
)
|
||||
return self._wrap_omni_scheduler_output(res)
|
||||
|
||||
# Compute common prefix blocks (aligned with v1)
|
||||
num_common_prefix_blocks = [0] * len(self.kv_cache_config.kv_cache_groups)
|
||||
@@ -362,14 +345,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
if self.input_coordinator:
|
||||
self.input_coordinator.restore_queues(self.waiting, self.running)
|
||||
|
||||
base_fields = SchedulerOutput.__dataclass_fields__.keys()
|
||||
base_data = {name: getattr(scheduler_output, name) for name in base_fields}
|
||||
return OmniSchedulerOutput(
|
||||
**base_data,
|
||||
pending_input_registrations=(
|
||||
self.input_coordinator.pending_input_registrations if self.input_coordinator else []
|
||||
),
|
||||
)
|
||||
return self._wrap_omni_scheduler_output(scheduler_output)
|
||||
|
||||
def finish_requests(self, request_ids, finished_status: RequestStatus) -> list[tuple[str, int]]:
|
||||
"""Handles the finish signal from outside the scheduler.
|
||||
@@ -683,15 +659,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
engine_core_outputs[0] = eco = EngineCoreOutputs()
|
||||
eco.scheduler_stats = stats
|
||||
|
||||
omni_output = getattr(model_runner_output, "omni_connector_output", None)
|
||||
if omni_output is not None:
|
||||
self._latest_omni_connector_output = omni_output
|
||||
if self.input_coordinator and omni_output.request_metadata:
|
||||
self.input_coordinator.update_request_metadata(
|
||||
self.requests,
|
||||
omni_output.request_metadata,
|
||||
model_mode="generation",
|
||||
)
|
||||
self._capture_omni_connector_output(model_runner_output)
|
||||
|
||||
return engine_core_outputs
|
||||
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from vllm.logger import init_logger
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
from vllm.v1.engine import EngineCoreEventType
|
||||
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
|
||||
|
||||
from vllm_omni.core.sched.output import OmniChunkRecvHandle, OmniSchedulerOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# Upper bound on how long a request may sit in full-payload-input wait
|
||||
# (the state ``OmniSchedulingCoordinator`` records via ``_waiting_since``)
|
||||
# before the scheduler force-fails it. Defends against stuck consumer-side
|
||||
# requests when the producer drops a full-payload, send fails, or recv
|
||||
# never arrives. Override per-deployment via
|
||||
# VLLM_OMNI_INPUT_WAIT_TIMEOUT_S; set <=0 to disable the safety net.
|
||||
#
|
||||
# Scope: this constant only covers the full-payload coordinator path
|
||||
# (``input_coordinator``). The async-chunk path uses
|
||||
# ``chunk_transfer_adapter`` and is not affected by this constant.
|
||||
_INPUT_WAIT_TIMEOUT_RAW = os.environ.get("VLLM_OMNI_INPUT_WAIT_TIMEOUT_S", "300")
|
||||
try:
|
||||
DEFAULT_INPUT_WAIT_TIMEOUT_S: float = float(_INPUT_WAIT_TIMEOUT_RAW)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid VLLM_OMNI_INPUT_WAIT_TIMEOUT_S=%r; falling back to 300 seconds.",
|
||||
_INPUT_WAIT_TIMEOUT_RAW,
|
||||
)
|
||||
DEFAULT_INPUT_WAIT_TIMEOUT_S = 300.0
|
||||
|
||||
|
||||
class OmniSchedulerMixin:
|
||||
"""Shared scheduler helpers for omni-specific request handling."""
|
||||
@@ -13,6 +42,111 @@ class OmniSchedulerMixin:
|
||||
if input_coordinator is not None:
|
||||
input_coordinator.free_finished_request(request_id)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Shared scheduler/output helpers (lift the AR / generation duplicates)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _consume_pending_connector_output(self, model_mode: str) -> None:
|
||||
"""Drain ``self._latest_omni_connector_output`` into the coordinator.
|
||||
|
||||
Called at the top of every ``schedule()`` cycle. Identical between
|
||||
AR and generation schedulers except for the ``model_mode`` argument
|
||||
forwarded to ``update_request_metadata``.
|
||||
"""
|
||||
connector_output = getattr(self, "_latest_omni_connector_output", None)
|
||||
self._latest_omni_connector_output = None
|
||||
input_coordinator = getattr(self, "input_coordinator", None)
|
||||
if input_coordinator is None:
|
||||
return
|
||||
if connector_output and connector_output.request_metadata:
|
||||
input_coordinator.update_request_metadata(
|
||||
self.requests, connector_output.request_metadata, model_mode=model_mode
|
||||
)
|
||||
input_coordinator.process_pending_full_payload_inputs(
|
||||
self.waiting,
|
||||
self.running,
|
||||
connector_output.stage_recv_req_ids if connector_output else set(),
|
||||
)
|
||||
|
||||
def _process_pending_input_timeouts(self) -> None:
|
||||
"""Force-fail requests waiting on the full-payload coordinator too long.
|
||||
|
||||
Called at the top of every ``schedule()`` cycle, right after
|
||||
``_consume_pending_connector_output``. Without this hook, a request
|
||||
whose producer dropped a payload would sit in the
|
||||
full-payload-input wait state indefinitely (the runner mixin
|
||||
protects ``_pending_load_reqs`` from prune sweeps).
|
||||
|
||||
Reads ``_waiting_since`` timestamps maintained by the input
|
||||
coordinator and delegates to the base scheduler's
|
||||
``finish_requests`` to mark expired requests FINISHED_ERROR.
|
||||
Disabled when ``DEFAULT_INPUT_WAIT_TIMEOUT_S`` is <= 0.
|
||||
|
||||
Scope: only covers ``input_coordinator`` (full-payload path).
|
||||
Async-chunk requests park in ``chunk_transfer_adapter`` instead
|
||||
and are not handled here -- if a similar safety net is needed
|
||||
for the chunk path, it belongs in the chunk adapter.
|
||||
"""
|
||||
if DEFAULT_INPUT_WAIT_TIMEOUT_S <= 0:
|
||||
return
|
||||
input_coordinator = getattr(self, "input_coordinator", None)
|
||||
if input_coordinator is None:
|
||||
return
|
||||
timed_out_ids = input_coordinator.collect_timed_out_request_ids(timeout_s=DEFAULT_INPUT_WAIT_TIMEOUT_S)
|
||||
if not timed_out_ids:
|
||||
return
|
||||
present_ids = {req_id for req_id in timed_out_ids if req_id in self.requests}
|
||||
if not present_ids:
|
||||
return
|
||||
logger.warning(
|
||||
"Marking %d request(s) as FINISHED_ERROR after waiting > %.0fs for connector input: %s",
|
||||
len(present_ids),
|
||||
DEFAULT_INPUT_WAIT_TIMEOUT_S,
|
||||
sorted(present_ids),
|
||||
)
|
||||
self.finish_requests(present_ids, RequestStatus.FINISHED_ERROR)
|
||||
|
||||
def _capture_omni_connector_output(self, model_runner_output: Any) -> None:
|
||||
"""Stash the model runner's omni_connector_output for next schedule().
|
||||
|
||||
Called at the tail of every ``update_from_output()`` -- identical
|
||||
between AR and generation schedulers. Only stashes the output;
|
||||
applying the metadata is the responsibility of
|
||||
``_consume_pending_connector_output()`` at the start of the next
|
||||
``schedule()`` cycle. Applying it twice (once here, once on
|
||||
consume) is unsafe under ``update_request_metadata`` in
|
||||
generation mode, which resets ``prompt_token_ids`` /
|
||||
``_output_token_ids`` / ``num_computed_tokens`` and would
|
||||
clobber any progress between the two calls.
|
||||
"""
|
||||
omni_output = getattr(model_runner_output, "omni_connector_output", None)
|
||||
if omni_output is None:
|
||||
return
|
||||
self._latest_omni_connector_output = omni_output
|
||||
|
||||
def _wrap_omni_scheduler_output(
|
||||
self,
|
||||
base: SchedulerOutput,
|
||||
*,
|
||||
finished_requests_needing_kv_transfer: dict | None = None,
|
||||
pending_input_registrations: list[OmniChunkRecvHandle] | None = None,
|
||||
) -> OmniSchedulerOutput:
|
||||
"""Wrap a base ``SchedulerOutput`` in ``OmniSchedulerOutput``.
|
||||
|
||||
Pulls each base ``SchedulerOutput`` dataclass field via ``getattr``
|
||||
and forwards optional omni-specific fields. Lifted from 4 separate
|
||||
copy-pastes between AR (1) and generation (3) schedulers.
|
||||
"""
|
||||
base_data = {name: getattr(base, name) for name in SchedulerOutput.__dataclass_fields__}
|
||||
input_coordinator = getattr(self, "input_coordinator", None)
|
||||
if pending_input_registrations is None:
|
||||
pending_input_registrations = input_coordinator.pending_input_registrations if input_coordinator else []
|
||||
return OmniSchedulerOutput(
|
||||
**base_data,
|
||||
finished_requests_needing_kv_transfer=finished_requests_needing_kv_transfer or {},
|
||||
pending_input_registrations=pending_input_registrations,
|
||||
)
|
||||
|
||||
def _replace_session_with_streaming_update(
|
||||
self,
|
||||
session: Request,
|
||||
|
||||
@@ -19,16 +19,64 @@ from typing import Any
|
||||
from vllm.logger import init_logger
|
||||
from vllm.v1.request import Request, RequestStatus
|
||||
|
||||
from vllm_omni.core.sched.output import OmniChunkRecvHandle
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def uses_qwen3_omni_full_payload_input_coordinator(model_config: Any) -> bool:
|
||||
return (
|
||||
getattr(model_config, "stage_id", 0) > 0
|
||||
and not getattr(model_config, "async_chunk", False)
|
||||
and getattr(model_config, "model_arch", None) == "Qwen3OmniMoeForConditionalGeneration"
|
||||
and getattr(model_config, "model_stage", None) in {"talker", "code2wav"}
|
||||
# (arch, model_stage) pairs that route their full_payload stage input via
|
||||
# the worker connector and therefore need the scheduler-side coordinator to
|
||||
# park requests in WAITING_FOR_INPUT until the recv side delivers. This set
|
||||
# must stay aligned with the arch scope of `init_omni_connectors` in
|
||||
# gpu_ar_model_runner.py and gpu_generation_model_runner.py. Adding a stage
|
||||
# here without also wiring its worker connector init produces a permanent
|
||||
# Stage 1 hang (gate parks the request, no transport ever releases it).
|
||||
#
|
||||
_FULL_PAYLOAD_INPUT_STAGES: frozenset[tuple[str, str]] = frozenset(
|
||||
{
|
||||
("Qwen3OmniMoeForConditionalGeneration", "talker"),
|
||||
("Qwen3OmniMoeForConditionalGeneration", "code2wav"),
|
||||
# qwen2_5_omni thinker->talker uses the real full-payload
|
||||
# producer builder (text_hidden_states routed via
|
||||
# pooler_output["hidden"] -> accumulator -> connector). Both
|
||||
# stages of qwen2_5_omni are enabled.
|
||||
("Qwen2_5OmniForConditionalGeneration", "talker"),
|
||||
("Qwen2_5OmniForConditionalGeneration", "code2wav"),
|
||||
# covo_audio: fused_thinker_talker (Stage 0) -> code2wav (Stage 1).
|
||||
("CovoAudioForConditionalGeneration", "code2wav"),
|
||||
# mimo_audio: fused_thinker_talker (Stage 0) -> code2wav (Stage 1).
|
||||
("MiMoAudioModel", "code2wav"),
|
||||
# qwen3_tts: Qwen3TTSTalkerForConditionalGeneration (Stage 0)
|
||||
# -> Qwen3TTSCode2Wav (Stage 1). Stage 1 is the consumer.
|
||||
("Qwen3TTSCode2Wav", "code2wav"),
|
||||
# cosyvoice3: cosyvoice3_talker (Stage 0) -> cosyvoice3_code2wav (Stage 1).
|
||||
("CosyVoice3Model", "cosyvoice3_code2wav"),
|
||||
# dynin: token2text (Stage 0) -> token2image (Stage 1) ->
|
||||
# token2audio (Stage 2). Producer wires via
|
||||
# custom_process_next_stage_input_func: *_full_payload in deploy yaml.
|
||||
("DyninOmniForConditionalGeneration", "token2image"),
|
||||
("DyninOmniForConditionalGeneration", "token2audio"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def uses_full_payload_input_coordinator(model_config: Any) -> bool:
|
||||
"""Returns True iff this stage parks pending requests in
|
||||
WAITING_FOR_INPUT awaiting a full_payload delivery on the worker connector.
|
||||
|
||||
Gated by (model_arch, model_stage) — see _FULL_PAYLOAD_INPUT_STAGES for the
|
||||
rationale on why this is a whitelist instead of a marker-driven structural
|
||||
gate.
|
||||
"""
|
||||
if getattr(model_config, "stage_id", 0) <= 0:
|
||||
return False
|
||||
if getattr(model_config, "async_chunk", False):
|
||||
return False
|
||||
key = (
|
||||
getattr(model_config, "model_arch", None),
|
||||
getattr(model_config, "model_stage", None),
|
||||
)
|
||||
return key in _FULL_PAYLOAD_INPUT_STAGES
|
||||
|
||||
|
||||
class OmniSchedulingCoordinator:
|
||||
@@ -59,7 +107,11 @@ class OmniSchedulingCoordinator:
|
||||
|
||||
# Requests waiting for full_payload stage input (WAITING_FOR_INPUT).
|
||||
self._waiting_for_input: deque[Any] = deque()
|
||||
self.pending_input_registrations: list[Any] = []
|
||||
# Per-cycle list of minimal handles to ship to the model runner so it
|
||||
# can call register_chunk_recv(). Typed concretely (not list[Any]) so
|
||||
# the surrounding OmniSchedulerOutput stays msgspec-friendly across
|
||||
# default, PD-disagg, and multi-node executor IPC paths.
|
||||
self.pending_input_registrations: list[OmniChunkRecvHandle] = []
|
||||
|
||||
# Monotonic timestamp recording when each request first entered
|
||||
# WAITING_FOR_CHUNK or WAITING_FOR_INPUT. Used by
|
||||
@@ -166,7 +218,12 @@ class OmniSchedulingCoordinator:
|
||||
self._waiting_since.setdefault(request.request_id, time.monotonic())
|
||||
to_remove.append(request)
|
||||
self._waiting_for_input.append(request)
|
||||
self.pending_input_registrations.append(request)
|
||||
self.pending_input_registrations.append(
|
||||
OmniChunkRecvHandle(
|
||||
request_id=request.request_id,
|
||||
external_req_id=getattr(request, "external_req_id", None),
|
||||
)
|
||||
)
|
||||
elif request.status == RequestStatus.WAITING_FOR_INPUT:
|
||||
if request.request_id in stage_recv_req_ids:
|
||||
request.status = RequestStatus.WAITING
|
||||
@@ -174,7 +231,12 @@ class OmniSchedulingCoordinator:
|
||||
else:
|
||||
to_remove.append(request)
|
||||
self._waiting_for_input.append(request)
|
||||
self.pending_input_registrations.append(request)
|
||||
self.pending_input_registrations.append(
|
||||
OmniChunkRecvHandle(
|
||||
request_id=request.request_id,
|
||||
external_req_id=getattr(request, "external_req_id", None),
|
||||
)
|
||||
)
|
||||
if to_remove:
|
||||
# Use the bulk-remove helper: one O(N) sweep instead of N
|
||||
# repeated O(N) removes from a list-backed queue.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from vllm.v1.core.sched.output import CachedRequestData, NewRequestData, SchedulerOutput
|
||||
from vllm.v1.request import Request
|
||||
@@ -72,9 +71,26 @@ class OmniCachedRequestData(CachedRequestData):
|
||||
additional_information: dict[str, dict | None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class OmniChunkRecvHandle:
|
||||
"""Minimal identifier carried from scheduler to runner for chunk-recv
|
||||
registration.
|
||||
|
||||
The runner's ``register_chunk_recv`` only consumes ``request_id`` and
|
||||
``external_req_id`` from each pending request, so we ship just those
|
||||
two fields instead of the full Request object. Concrete typing
|
||||
keeps msgspec serialization deterministic across IPC (default,
|
||||
PD-disagg, multi-node executor variants) and avoids the
|
||||
``list[Any]`` fallback path.
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
external_req_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class OmniSchedulerOutput(SchedulerOutput):
|
||||
"""Scheduler output with omni-specific transfer metadata."""
|
||||
|
||||
finished_requests_needing_kv_transfer: dict[str, dict] = field(default_factory=dict)
|
||||
pending_input_registrations: list[Any] = field(default_factory=list)
|
||||
pending_input_registrations: list[OmniChunkRecvHandle] = field(default_factory=list)
|
||||
@@ -14,6 +14,7 @@ stage_args:
|
||||
worker_type: generation
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
engine_output_type: latent
|
||||
custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.dynin_omni.token2text_to_token2image_full_payload
|
||||
trust_remote_code: true
|
||||
gpu_memory_utilization: 0.5
|
||||
enforce_eager: true
|
||||
@@ -36,6 +37,7 @@ stage_args:
|
||||
worker_type: generation
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
engine_output_type: latent
|
||||
custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.dynin_omni.token2image_to_token2audio_full_payload
|
||||
trust_remote_code: true
|
||||
gpu_memory_utilization: 0.2
|
||||
enforce_eager: true
|
||||
|
||||
@@ -784,12 +784,14 @@ class CosyVoice3Model(
|
||||
else:
|
||||
self._stream_vocoder_cache_by_req[req_id] = new_cache_state
|
||||
else:
|
||||
token_offset = max(0, meta.talker_prefill_offset or 0) if meta else 0
|
||||
tts_speech = self.code2wav.forward(
|
||||
token=token.unsqueeze(0),
|
||||
prompt_token=speech_token[:1],
|
||||
prompt_feat=speech_feat[:1],
|
||||
embedding=embedding[:1],
|
||||
n_timesteps=10,
|
||||
token_offset_tokens=token_offset,
|
||||
)
|
||||
|
||||
audio = tts_speech.reshape(-1).to(dtype=torch.float32)
|
||||
|
||||
@@ -292,6 +292,7 @@ class CosyVoice3Code2Wav(nn.Module):
|
||||
prompt_feat: torch.Tensor,
|
||||
embedding: torch.Tensor,
|
||||
n_timesteps: int = 10,
|
||||
token_offset_tokens: int = 0,
|
||||
) -> torch.Tensor:
|
||||
"""Generate audio waveform from speech tokens."""
|
||||
feat = self._forward_mel(
|
||||
@@ -300,7 +301,7 @@ class CosyVoice3Code2Wav(nn.Module):
|
||||
prompt_feat=prompt_feat,
|
||||
embedding=embedding,
|
||||
n_timesteps=n_timesteps,
|
||||
token_offset_tokens=0,
|
||||
token_offset_tokens=token_offset_tokens,
|
||||
streaming=False,
|
||||
finalize=True,
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ COSYVOICE3_PIPELINE = PipelineConfig(
|
||||
owns_tokenizer=True,
|
||||
engine_output_type="latent",
|
||||
async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2code2wav_async_chunk"),
|
||||
custom_process_next_stage_input_func=f"{_PROC}.text2flow_full_payload",
|
||||
sampling_constraints={
|
||||
# merged speech stop token (logsumexp of all 200 stop logits)
|
||||
"stop_token_ids": [6562],
|
||||
@@ -44,7 +45,8 @@ COSYVOICE3_PIPELINE = PipelineConfig(
|
||||
final_output=True,
|
||||
final_output_type="audio",
|
||||
engine_output_type="latent",
|
||||
sync_process_input_func=f"{_PROC}.text2flow",
|
||||
custom_process_input_func=f"{_PROC}.text2flow",
|
||||
sync_process_input_func=f"{_PROC}.text2flow_token_only",
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -29,6 +29,7 @@ COVO_AUDIO_PIPELINE = PipelineConfig(
|
||||
owns_tokenizer=True,
|
||||
requires_multimodal_data=True,
|
||||
engine_output_type="latent",
|
||||
custom_process_next_stage_input_func=f"{_PROC}.llm2code2wav_full_payload",
|
||||
sampling_constraints={
|
||||
"detokenize": True,
|
||||
"stop_token_ids": [151645],
|
||||
@@ -44,6 +45,7 @@ COVO_AUDIO_PIPELINE = PipelineConfig(
|
||||
final_output_type="audio",
|
||||
engine_output_type="audio",
|
||||
custom_process_input_func=f"{_PROC}.llm2code2wav",
|
||||
sync_process_input_func=f"{_PROC}.llm2code2wav_token_only",
|
||||
sampling_constraints={"detokenize": False},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -39,6 +39,7 @@ MIMO_AUDIO_PIPELINE = PipelineConfig(
|
||||
owns_tokenizer=True,
|
||||
engine_output_type="latent",
|
||||
async_chunk_process_next_stage_input_func=(f"{_PROC}.llm2code2wav_async_chunk"),
|
||||
custom_process_next_stage_input_func=f"{_PROC}.llm2code2wav_full_payload",
|
||||
sampling_constraints={
|
||||
"detokenize": True,
|
||||
# Stop once the speech/text interleaved span ends. Code2Wav
|
||||
@@ -55,7 +56,8 @@ MIMO_AUDIO_PIPELINE = PipelineConfig(
|
||||
final_output=True,
|
||||
final_output_type="audio",
|
||||
engine_output_type="audio",
|
||||
sync_process_input_func=f"{_PROC}.llm2code2wav",
|
||||
custom_process_input_func=f"{_PROC}.llm2code2wav",
|
||||
sync_process_input_func=f"{_PROC}.llm2code2wav_token_only",
|
||||
sampling_constraints={"detokenize": False},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -58,6 +58,7 @@ MING_FLASH_OMNI_PIPELINE = PipelineConfig(
|
||||
engine_output_type="audio",
|
||||
tokenizer_subdir="talker/llm",
|
||||
custom_process_input_func=f"{_PROC}.thinker2talker",
|
||||
sync_process_input_func=f"{_PROC}.thinker2talker_token_only",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@ QWEN2_5_OMNI_PIPELINE = PipelineConfig(
|
||||
requires_multimodal_data=True,
|
||||
engine_output_type="latent",
|
||||
sampling_constraints={"detokenize": True},
|
||||
custom_process_next_stage_input_func=f"{_PROC}.thinker2talker_full_payload",
|
||||
),
|
||||
StagePipelineConfig(
|
||||
stage_id=1,
|
||||
@@ -38,6 +39,8 @@ QWEN2_5_OMNI_PIPELINE = PipelineConfig(
|
||||
input_sources=(0,),
|
||||
engine_output_type="latent",
|
||||
custom_process_input_func=f"{_PROC}.thinker2talker",
|
||||
sync_process_input_func=f"{_PROC}.thinker2talker_token_only",
|
||||
custom_process_next_stage_input_func=f"{_PROC}.talker2code2wav_full_payload",
|
||||
sampling_constraints={
|
||||
"detokenize": True,
|
||||
"stop_token_ids": [8294],
|
||||
@@ -52,6 +55,7 @@ QWEN2_5_OMNI_PIPELINE = PipelineConfig(
|
||||
final_output_type="audio",
|
||||
engine_output_type="audio",
|
||||
custom_process_input_func=f"{_PROC}.talker2code2wav",
|
||||
sync_process_input_func=f"{_PROC}.talker2code2wav_token_only",
|
||||
sampling_constraints={"detokenize": True},
|
||||
),
|
||||
),
|
||||
@@ -74,6 +78,7 @@ QWEN2_5_OMNI_THINKER_ONLY_PIPELINE = PipelineConfig(
|
||||
requires_multimodal_data=True,
|
||||
engine_output_type="latent",
|
||||
sampling_constraints={"detokenize": True},
|
||||
custom_process_next_stage_input_func=f"{_PROC}.thinker2talker_full_payload",
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -26,6 +26,7 @@ QWEN3_TTS_PIPELINE = PipelineConfig(
|
||||
owns_tokenizer=True,
|
||||
engine_output_type="latent",
|
||||
async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2code2wav_async_chunk"),
|
||||
custom_process_next_stage_input_func=f"{_PROC}.talker2code2wav_full_payload",
|
||||
sampling_constraints={
|
||||
"detokenize": False,
|
||||
"stop_token_ids": [2150],
|
||||
@@ -40,7 +41,14 @@ QWEN3_TTS_PIPELINE = PipelineConfig(
|
||||
final_output_type="audio",
|
||||
engine_output_type="audio",
|
||||
model_arch="Qwen3TTSCode2Wav",
|
||||
sync_process_input_func=f"{_PROC}.talker2code2wav",
|
||||
# ``sync_process_input_func`` is the only input-proc override for
|
||||
# this stage in sync (non-async-chunk) mode: a length-only
|
||||
# ``_token_only`` placeholder. The bulk codec payload itself
|
||||
# ships via the worker connector from stage 0's
|
||||
# ``talker2code2wav_full_payload`` producer. Under async_chunk
|
||||
# mode no pre-stage processing is needed -- chunks deliver
|
||||
# directly to the consumer.
|
||||
sync_process_input_func=f"{_PROC}.talker2code2wav_token_only",
|
||||
sampling_constraints={"detokenize": True},
|
||||
extras={"tts_args": {"max_instructions_length": 500}},
|
||||
),
|
||||
|
||||
@@ -25,6 +25,27 @@ from .tokenizer_12hz.modeling_qwen3_tts_tokenizer_v2 import (
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def _codec_ids_from_payload_or_input(
|
||||
input_ids: torch.Tensor,
|
||||
runtime_info: dict[str, Any] | None,
|
||||
) -> torch.Tensor:
|
||||
"""Prefer connector-delivered codec ids over token placeholders.
|
||||
|
||||
In non-async full-payload mode, the scheduler only needs placeholder
|
||||
token ids for allocation. The real codec sequence is delivered through
|
||||
model_intermediate_buffer as ``codes.audio``.
|
||||
"""
|
||||
if isinstance(runtime_info, dict):
|
||||
codes = runtime_info.get("codes")
|
||||
if isinstance(codes, dict):
|
||||
audio = codes.get("audio")
|
||||
if isinstance(audio, torch.Tensor) and audio.numel() > 0:
|
||||
return audio.reshape(-1).to(device=input_ids.device, dtype=torch.long)
|
||||
if isinstance(audio, (list, tuple)) and audio:
|
||||
return torch.as_tensor(audio, device=input_ids.device, dtype=torch.long).reshape(-1)
|
||||
return input_ids.reshape(-1).to(dtype=torch.long)
|
||||
|
||||
|
||||
class Qwen3TTSCode2Wav(nn.Module):
|
||||
"""Stage-1 code2wav model for Qwen3-TTS (GenerationModelRunner).
|
||||
Consumes frame-aligned codec tokens from input_ids and decodes waveform
|
||||
@@ -239,6 +260,7 @@ class Qwen3TTSCode2Wav(nn.Module):
|
||||
multimodal_outputs={"model_outputs": [empty], "sr": [sr_tensor]},
|
||||
)
|
||||
|
||||
runtime_infos = runtime_additional_information or []
|
||||
ids = input_ids.reshape(-1).to(dtype=torch.long)
|
||||
request_ids_list = self._split_request_ids(ids, kwargs.get("seq_token_counts"))
|
||||
|
||||
@@ -246,14 +268,18 @@ class Qwen3TTSCode2Wav(nn.Module):
|
||||
valid_codes_qf: list[torch.Tensor] = []
|
||||
valid_indices: list[int] = []
|
||||
left_context_size = [0] * len(request_ids_list)
|
||||
if runtime_additional_information is not None:
|
||||
for i, info in enumerate(runtime_additional_information):
|
||||
if runtime_infos:
|
||||
for i, info in enumerate(runtime_infos):
|
||||
if i >= len(left_context_size):
|
||||
break
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
meta = info.get("meta", {})
|
||||
if "left_context_size" in meta:
|
||||
left_context_size[i] = meta["left_context_size"]
|
||||
for i, req_ids in enumerate(request_ids_list):
|
||||
runtime_info = runtime_infos[i] if i < len(runtime_infos) else None
|
||||
req_ids = _codec_ids_from_payload_or_input(req_ids, runtime_info)
|
||||
if req_ids.numel() < 1:
|
||||
parsed.append((0, 0))
|
||||
continue
|
||||
|
||||
@@ -6,6 +6,7 @@ stage_args:
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
model_stage: token2text
|
||||
custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.dynin_omni.token2text_to_token2image_full_payload
|
||||
model_arch: DyninOmniForConditionalGeneration
|
||||
worker_type: generation
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
|
||||
@@ -6,6 +6,7 @@ stage_args:
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
model_stage: token2text
|
||||
custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.dynin_omni.token2text_to_token2image_full_payload
|
||||
model_arch: DyninOmniForConditionalGeneration
|
||||
worker_type: generation
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
import numpy as np
|
||||
import torch
|
||||
from vllm.inputs import TextPrompt
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.data_entry_keys import (
|
||||
CodesStruct,
|
||||
@@ -16,6 +17,10 @@ from vllm_omni.data_entry_keys import (
|
||||
)
|
||||
from vllm_omni.inputs.data import OmniTokensPrompt
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_COSYVOICE3_SPEECH_TOKEN_SIZE = 6561
|
||||
|
||||
|
||||
def _build_prompt_embed_struct(prompt_payload: dict[str, Any]) -> EmbeddingsStruct | None:
|
||||
"""Wrap prompt_payload's flat speech_token/speech_feat/embedding tensors into EmbeddingsStruct."""
|
||||
@@ -46,6 +51,52 @@ def _ensure_list(x: Any) -> list[Any]:
|
||||
return [x]
|
||||
|
||||
|
||||
def _to_token_id_list(value: Any) -> list[int]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, torch.Tensor):
|
||||
value = value.detach().to("cpu").reshape(-1).tolist()
|
||||
token_ids: list[int] = []
|
||||
for item in _ensure_list(value):
|
||||
if isinstance(item, torch.Tensor):
|
||||
token_ids.extend(_to_token_id_list(item))
|
||||
continue
|
||||
if isinstance(item, (list, tuple)):
|
||||
token_ids.extend(_to_token_id_list(item))
|
||||
continue
|
||||
token_ids.append(int(item))
|
||||
return token_ids
|
||||
|
||||
|
||||
def _strip_prompt_prefix(output_ids: list[Any], prefix_ids: list[Any]) -> list[Any]:
|
||||
if prefix_ids and len(output_ids) >= len(prefix_ids) and output_ids[: len(prefix_ids)] == prefix_ids:
|
||||
return output_ids[len(prefix_ids) :]
|
||||
return output_ids
|
||||
|
||||
|
||||
def _prompt_speech_token_ids(multi_modal_data: dict[str, Any]) -> list[int]:
|
||||
speech_token = multi_modal_data.get("speech_token")
|
||||
if speech_token is None:
|
||||
embed = multi_modal_data.get("embed")
|
||||
if isinstance(embed, dict):
|
||||
speech_token = embed.get("speech_token")
|
||||
return _to_token_id_list(speech_token)
|
||||
|
||||
|
||||
def _has_speech_stop_token(output_ids: list[Any]) -> bool:
|
||||
return any(token_id >= _COSYVOICE3_SPEECH_TOKEN_SIZE for token_id in _to_token_id_list(output_ids))
|
||||
|
||||
|
||||
def _set_non_stream_prompt_trim(additional_info: dict[str, Any], prompt_speech_len: int) -> None:
|
||||
if prompt_speech_len <= 0:
|
||||
return
|
||||
meta = additional_info.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
additional_info["meta"] = meta
|
||||
meta["talker_prefill_offset"] = prompt_speech_len
|
||||
|
||||
|
||||
def _to_cpu_tensor(x: Any) -> torch.Tensor | None:
|
||||
if isinstance(x, list):
|
||||
if not x:
|
||||
@@ -95,9 +146,14 @@ def text2flow(
|
||||
if multi_modal_data is None:
|
||||
raise RuntimeError(f"Missing multimodal_output for request {source_output.request_id}")
|
||||
|
||||
output_ids = _ensure_list(output.cumulative_token_ids)
|
||||
prefix_ids = _ensure_list(source_output.prompt_token_ids)
|
||||
raw_output_ids = _ensure_list(output.cumulative_token_ids)
|
||||
prompt_speech_ids = _prompt_speech_token_ids(multi_modal_data)
|
||||
output_ids = _strip_prompt_prefix(raw_output_ids, prefix_ids)
|
||||
output_ids = _strip_prompt_prefix(output_ids, prompt_speech_ids)
|
||||
additional_info = dict(multi_modal_data)
|
||||
if _has_speech_stop_token(raw_output_ids):
|
||||
_set_non_stream_prompt_trim(additional_info, len(prompt_speech_ids))
|
||||
additional_info.setdefault("ids", {})["prompt"] = prefix_ids
|
||||
engine_inputs.append(OmniTokensPrompt(prompt_token_ids=output_ids, additional_information=additional_info))
|
||||
return engine_inputs
|
||||
@@ -271,3 +327,107 @@ def talker2code2wav_async_chunk(
|
||||
|
||||
state["emitted_chunks"] = int(state.get("emitted_chunks", 0)) + 1
|
||||
return payload
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Worker-connector data plane (non-async-chunk path).
|
||||
# cosyvoice3 talker emits `multimodal_outputs={"embed": {"speech_token": t,
|
||||
# "speech_feat": t, "embedding": t}}` ONLY at prefill (decode steps emit
|
||||
# `{}`). After flatten_payload these become flat top-level keys
|
||||
# `embed.speech_token` etc., persisted across decode steps by the
|
||||
# full-payload accumulator (decode doesn't re-emit them). Shipping via the connector
|
||||
# keeps the orchestrator off the heavy-tensor path.
|
||||
# ============================================================================
|
||||
|
||||
# All three embed tensors are emitted once at prefill and must REPLACE-not-
|
||||
# CONCAT across the (already trivial) per-request accumulator history so a
|
||||
# regression where decode unexpectedly re-emits them does not silently
|
||||
# duplicate the prefill tensor. See mixin._FULL_PAYLOAD_REPLACE_KEYS.
|
||||
_FULL_PAYLOAD_REPLACE_KEYS: frozenset[str] = frozenset({"embed.speech_token", "embed.speech_feat", "embed.embedding"})
|
||||
|
||||
|
||||
def text2flow_token_only(
|
||||
source_outputs: list,
|
||||
prompt: OmniTokensPrompt | TextPrompt = None,
|
||||
_requires_multimodal_data: bool = True,
|
||||
):
|
||||
"""Sync-side builder for the non-async-chunk text→flow path.
|
||||
|
||||
CosyVoice3 sync keeps codec ids on the legacy token path. Some vLLM v1
|
||||
histories include the source prompt prefix, so strip it only when it is an
|
||||
exact leading match.
|
||||
"""
|
||||
del prompt
|
||||
engine_inputs: list[OmniTokensPrompt] = []
|
||||
for source_output in source_outputs:
|
||||
if not source_output.finished:
|
||||
continue
|
||||
output = source_output.outputs[0]
|
||||
prefix_ids = _ensure_list(source_output.prompt_token_ids)
|
||||
raw_output_ids = _ensure_list(output.cumulative_token_ids)
|
||||
output_ids = _strip_prompt_prefix(raw_output_ids, prefix_ids)
|
||||
multi_modal_data = output.multimodal_output
|
||||
if multi_modal_data is None:
|
||||
raise RuntimeError(f"Missing multimodal_output for request {source_output.request_id}")
|
||||
prompt_speech_ids = _prompt_speech_token_ids(multi_modal_data)
|
||||
output_ids = _strip_prompt_prefix(output_ids, prompt_speech_ids)
|
||||
additional_info: dict[str, Any] = dict(multi_modal_data)
|
||||
if _has_speech_stop_token(raw_output_ids):
|
||||
_set_non_stream_prompt_trim(additional_info, len(prompt_speech_ids))
|
||||
additional_info.setdefault("ids", {})["prompt"] = prefix_ids
|
||||
engine_inputs.append(
|
||||
OmniTokensPrompt(
|
||||
prompt_token_ids=output_ids,
|
||||
additional_information=additional_info,
|
||||
multi_modal_data=None,
|
||||
mm_processor_kwargs=None,
|
||||
)
|
||||
)
|
||||
return engine_inputs
|
||||
|
||||
|
||||
def text2flow_full_payload(
|
||||
transfer_manager,
|
||||
pooling_output,
|
||||
request,
|
||||
):
|
||||
"""Producer-side payload builder.
|
||||
|
||||
Reads prefill-emitted `embed.{speech_token, speech_feat, embedding}` from
|
||||
the accumulator and ships prompt conditioning as a connector payload.
|
||||
The downstream flow stage reads these from `model_intermediate_buffer`
|
||||
(see cosyvoice3.py:671 in the code2wav forward — runtime_info pickup).
|
||||
"""
|
||||
del transfer_manager
|
||||
rid = getattr(request, "external_req_id", None) or getattr(request, "request_id", "?")
|
||||
if not isinstance(pooling_output, dict):
|
||||
logger.warning(
|
||||
"cosyvoice3.text2flow_full_payload: pooling_output not a dict "
|
||||
"(type=%s) for req=%s; consumer wait gate may hang.",
|
||||
type(pooling_output).__name__,
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
embed_out: dict[str, Any] = {}
|
||||
for key in ("speech_token", "speech_feat", "embedding"):
|
||||
v = pooling_output.get(f"embed.{key}")
|
||||
if v is None:
|
||||
nested = pooling_output.get("embed")
|
||||
if isinstance(nested, dict):
|
||||
v = nested.get(key)
|
||||
if isinstance(v, torch.Tensor) and v.numel() > 0:
|
||||
embed_out[key] = v
|
||||
if not embed_out:
|
||||
logger.warning(
|
||||
"cosyvoice3.text2flow_full_payload: no embed.{speech_token,speech_feat,embedding} "
|
||||
"found in pooling_output (keys=%s) for req=%s; consumer wait gate may hang.",
|
||||
list(pooling_output.keys()),
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
return {
|
||||
"meta": {
|
||||
"finished": torch.tensor(True, dtype=torch.bool),
|
||||
},
|
||||
"embed": embed_out,
|
||||
}
|
||||
@@ -1,27 +1,44 @@
|
||||
# Copyright 2026 Tencent.
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.inputs.data import OmniTokensPrompt
|
||||
from vllm_omni.model_executor.models.covo_audio.config_covo_audio import COVO_AUDIO_TOKEN_INDEX
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# Per-model REPLACE-keys for the full-payload accumulator (none for covo_audio:
|
||||
# the producer side does not emit per-step hidden_states / model_outputs;
|
||||
# llm2code2wav_full_payload reads token_ids directly from `request`).
|
||||
_FULL_PAYLOAD_REPLACE_KEYS: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def _filter_audio_codes(token_ids: list[int]) -> list[int]:
|
||||
"""Filter codec-range token ids and rebase by COVO_AUDIO_TOKEN_INDEX."""
|
||||
audio_codes = [t - COVO_AUDIO_TOKEN_INDEX for t in token_ids if t >= COVO_AUDIO_TOKEN_INDEX]
|
||||
if not audio_codes:
|
||||
audio_codes = [-1]
|
||||
return audio_codes
|
||||
|
||||
|
||||
def llm2code2wav(
|
||||
source_outputs: list[Any],
|
||||
prompt: Any = None,
|
||||
requires_multimodal_data: bool = False,
|
||||
) -> list[OmniTokensPrompt]:
|
||||
"""Legacy orchestrator-path builder (retained for async_chunk + back-compat).
|
||||
|
||||
The non-async-chunk path now goes through ``llm2code2wav_token_only`` +
|
||||
worker connector + ``llm2code2wav_full_payload``.
|
||||
"""
|
||||
talker_outputs = source_outputs
|
||||
code2wav_inputs = []
|
||||
|
||||
for i, talker_output in enumerate(talker_outputs):
|
||||
for talker_output in talker_outputs:
|
||||
output = talker_output.outputs[0]
|
||||
token_ids = output.token_ids
|
||||
|
||||
audio_codes = [t - COVO_AUDIO_TOKEN_INDEX for t in token_ids if t >= COVO_AUDIO_TOKEN_INDEX]
|
||||
|
||||
if not audio_codes:
|
||||
audio_codes = [-1]
|
||||
|
||||
audio_codes = _filter_audio_codes(list(output.token_ids))
|
||||
code2wav_inputs.append(
|
||||
OmniTokensPrompt(
|
||||
prompt_token_ids=audio_codes,
|
||||
@@ -31,3 +48,56 @@ def llm2code2wav(
|
||||
)
|
||||
|
||||
return code2wav_inputs
|
||||
|
||||
|
||||
def llm2code2wav_token_only(
|
||||
source_outputs: list[Any],
|
||||
prompt: Any = None,
|
||||
requires_multimodal_data: bool = False,
|
||||
) -> list[OmniTokensPrompt]:
|
||||
"""Sync-side placeholder for the non-async-chunk Stage-1 input.
|
||||
|
||||
Returns an OmniTokensPrompt sized to the code2wav stage's expected
|
||||
prefill length (one slot per audio code). The actual codec ids are
|
||||
delivered via the worker connector payload built by
|
||||
``llm2code2wav_full_payload``.
|
||||
"""
|
||||
code2wav_inputs: list[OmniTokensPrompt] = []
|
||||
for output_wrapper in source_outputs:
|
||||
output = output_wrapper.outputs[0]
|
||||
audio_codes = _filter_audio_codes(list(output.token_ids))
|
||||
code2wav_inputs.append(
|
||||
OmniTokensPrompt(
|
||||
prompt_token_ids=[0] * len(audio_codes),
|
||||
additional_information=None,
|
||||
multi_modal_data=None,
|
||||
mm_processor_kwargs=None,
|
||||
)
|
||||
)
|
||||
return code2wav_inputs
|
||||
|
||||
|
||||
def llm2code2wav_full_payload(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any],
|
||||
request: Any,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Producer-side payload builder for the worker connector data plane.
|
||||
|
||||
covo_audio's fused_thinker_talker stage emits codec ids via
|
||||
``request.output_token_ids`` (token-ids only -- no
|
||||
hidden_states or embed tensors), so the connector payload is
|
||||
just the filtered audio codes plus a finished marker.
|
||||
"""
|
||||
output_token_ids = list(getattr(request, "output_token_ids", None) or [])
|
||||
if not output_token_ids:
|
||||
logger.warning(
|
||||
"covo_audio.llm2code2wav_full_payload: empty output_token_ids for req=%s; consumer wait gate may hang.",
|
||||
getattr(request, "request_id", "?"),
|
||||
)
|
||||
return None
|
||||
audio_codes = _filter_audio_codes(output_token_ids)
|
||||
return {
|
||||
"codes": {"audio": audio_codes},
|
||||
"meta": {"finished": torch.tensor(True, dtype=torch.bool)},
|
||||
}
|
||||
@@ -5,9 +5,12 @@ from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.inputs import TextPrompt
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.inputs.data import OmniTokensPrompt
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def _to_prompt_dict(prompt_item: OmniTokensPrompt | TextPrompt | str | None) -> dict[str, Any]:
|
||||
if isinstance(prompt_item, dict):
|
||||
@@ -146,3 +149,128 @@ def token2image_to_token2audio(
|
||||
requires_multimodal_data: bool = False,
|
||||
):
|
||||
return _bridge_tokens(source_outputs, prompt, requires_multimodal_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Worker-connector data plane (non-async-chunk path).
|
||||
# ============================================================================
|
||||
|
||||
# Per-model REPLACE-keys for the full-payload accumulator. dynin_omni's
|
||||
# producer model emits new chunks per step (token_ids / runtime_info_json),
|
||||
# all of which use the default CONCAT/replace semantics — no model_outputs
|
||||
# entry needs explicit REPLACE.
|
||||
_FULL_PAYLOAD_REPLACE_KEYS: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def _build_full_payload(pooling_output: dict[str, Any] | None, request: Any) -> dict[str, Any] | None:
|
||||
"""Producer-side payload builder: assemble dynin_omni connector payload.
|
||||
|
||||
Reads token_ids from ``pooling_output["token_ids"]`` (preferred) or
|
||||
``request.output_token_ids`` (fallback). Reads structured non-tensor
|
||||
metadata from ``pooling_output["runtime_info_json"]`` (JSON-in-uint8)
|
||||
if present, falling back to ``pooling_output["runtime_info"]`` dict.
|
||||
Carries forward ``request.additional_information`` so prompt-side
|
||||
metadata (speaker / language / detok_id) survives the IPC boundary.
|
||||
"""
|
||||
if not isinstance(pooling_output, dict):
|
||||
pooling_output = {}
|
||||
|
||||
token_ids = _to_token_id_list(pooling_output.get("token_ids"))
|
||||
if not token_ids:
|
||||
token_ids = _to_token_id_list(pooling_output.get("text_tokens"))
|
||||
if not token_ids and request is not None:
|
||||
token_ids = _to_token_id_list(getattr(request, "output_token_ids", None))
|
||||
if not token_ids:
|
||||
logger.warning(
|
||||
"dynin_omni._build_full_payload: no token_ids found in pooling_output "
|
||||
"(keys=%s) or request.output_token_ids for req=%s; consumer wait gate may hang.",
|
||||
list(pooling_output.keys()),
|
||||
getattr(request, "request_id", "?"),
|
||||
)
|
||||
return None
|
||||
|
||||
src_additional_info = getattr(request, "additional_information", {}) if request is not None else {}
|
||||
if not isinstance(src_additional_info, dict):
|
||||
src_additional_info = {}
|
||||
|
||||
runtime_bridge_info = _decode_runtime_bridge_info(pooling_output.get("runtime_info_json"))
|
||||
if not runtime_bridge_info:
|
||||
runtime_bridge_info = pooling_output.get("runtime_info", {}) or {}
|
||||
|
||||
payload = _normalize_additional_info(src_additional_info)
|
||||
payload.update(_normalize_additional_info(runtime_bridge_info))
|
||||
payload["detok_id"] = [_to_int(pooling_output.get("detok_id"), default=_to_int(payload.get("detok_id"), default=0))]
|
||||
# Use nested OmniPayload shape so the scheduling-metadata extractor in
|
||||
# OmniConnectorModelRunnerMixin reads codes.audio and meta.finished
|
||||
# (flat keys at the top level are silently dropped with a warning).
|
||||
payload["codes"] = {"audio": token_ids}
|
||||
payload["meta"] = {"finished": torch.tensor(True, dtype=torch.bool)}
|
||||
return payload
|
||||
|
||||
|
||||
def token2text_to_token2image_full_payload(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any],
|
||||
request: Any,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Producer-side payload builder for the Stage-0 → Stage-1 (text → image) transition."""
|
||||
del transfer_manager
|
||||
return _build_full_payload(pooling_output, request)
|
||||
|
||||
|
||||
def token2image_to_token2audio_full_payload(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any],
|
||||
request: Any,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Producer-side payload builder for the Stage-1 → Stage-2 (image → audio) transition."""
|
||||
del transfer_manager
|
||||
return _build_full_payload(pooling_output, request)
|
||||
|
||||
|
||||
def _token_only_from_source(source_outputs: list[Any]) -> list[OmniTokensPrompt]:
|
||||
"""Length-only placeholder list mirroring ``_bridge_tokens`` token counts."""
|
||||
inputs: list[OmniTokensPrompt] = []
|
||||
for source_output in source_outputs:
|
||||
output = source_output.outputs[0]
|
||||
mm_out = getattr(output, "multimodal_output", None) or {}
|
||||
token_ids = _to_token_id_list(mm_out.get("token_ids"))
|
||||
if not token_ids:
|
||||
token_ids = _to_token_id_list(mm_out.get("text_tokens"))
|
||||
if not token_ids:
|
||||
token_ids = list(getattr(output, "token_ids", []) or [])
|
||||
if not token_ids:
|
||||
token_ids = [0]
|
||||
inputs.append(
|
||||
OmniTokensPrompt(
|
||||
prompt_token_ids=[0] * len(token_ids),
|
||||
additional_information=None,
|
||||
multi_modal_data=None,
|
||||
mm_processor_kwargs=None,
|
||||
)
|
||||
)
|
||||
return inputs
|
||||
|
||||
|
||||
def token2text_to_token2image_token_only(
|
||||
stage_list,
|
||||
engine_input_source,
|
||||
prompt: OmniTokensPrompt | TextPrompt = None,
|
||||
requires_multimodal_data: bool = False,
|
||||
) -> list[OmniTokensPrompt]:
|
||||
"""Sync-side placeholder for Stage-1 input (token2image)."""
|
||||
source_stage_id = engine_input_source[0] if engine_input_source else 0
|
||||
source_outputs = stage_list[source_stage_id].engine_outputs
|
||||
return _token_only_from_source(source_outputs)
|
||||
|
||||
|
||||
def token2image_to_token2audio_token_only(
|
||||
stage_list,
|
||||
engine_input_source,
|
||||
prompt: OmniTokensPrompt | TextPrompt = None,
|
||||
requires_multimodal_data: bool = False,
|
||||
) -> list[OmniTokensPrompt]:
|
||||
"""Sync-side placeholder for Stage-2 input (token2audio)."""
|
||||
source_stage_id = engine_input_source[0] if engine_input_source else 0
|
||||
source_outputs = stage_list[source_stage_id].engine_outputs
|
||||
return _token_only_from_source(source_outputs)
|
||||
@@ -147,6 +147,18 @@ def llm2code2wav_async_chunk(
|
||||
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):
|
||||
if is_finished:
|
||||
connector = getattr(transfer_manager, "connector", None)
|
||||
raw_cfg = getattr(connector, "config", {}) or {}
|
||||
cfg = raw_cfg.get("extra", raw_cfg) if isinstance(raw_cfg, dict) else {}
|
||||
chunk_size = int(cfg.get("codec_chunk_frames", 3))
|
||||
left_context_size = int(cfg.get("codec_left_context_frames", 3))
|
||||
request_id = getattr(request, "external_req_id", None)
|
||||
return _flush_remaining_codes(transfer_manager, request_id, chunk_size, left_context_size)
|
||||
return None
|
||||
connector = getattr(transfer_manager, "connector", None)
|
||||
raw_cfg = getattr(connector, "config", {}) or {}
|
||||
cfg = raw_cfg.get("extra", raw_cfg) if isinstance(raw_cfg, dict) else {}
|
||||
@@ -300,3 +312,137 @@ def llm2code2wav(
|
||||
)
|
||||
|
||||
return code2wav_inputs
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Worker-connector data plane (non-async-chunk path).
|
||||
# AR runner's `flatten_payload` converts the model emit
|
||||
# `multimodal_outputs={"codes": {"audio": ...}}` to flat
|
||||
# `pooling_output["codes.audio"]` before the full-payload accumulator runs, so default
|
||||
# CONCAT semantics build the full codec tensor across all decode steps.
|
||||
# ============================================================================
|
||||
|
||||
# Per-model REPLACE-keys for the full-payload accumulator. mimo_audio's
|
||||
# producer side emits per-step codec frames that should be CONCAT'd across
|
||||
# steps (not REPLACE'd), so this stays empty.
|
||||
_FULL_PAYLOAD_REPLACE_KEYS: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def _filter_zero_codec_rows(codec_codes: torch.Tensor) -> torch.Tensor:
|
||||
"""Drop zero-padded codec rows from a 4-D `[N, 1, 8, 4]` tensor.
|
||||
|
||||
Mirrors the zero-row filter in the orchestrator-path `llm2code2wav`
|
||||
body (see this file's ``llm2code2wav`` around line 224).
|
||||
"""
|
||||
if codec_codes.ndim != 4 or codec_codes.numel() == 0:
|
||||
return codec_codes
|
||||
is_all_zero = (codec_codes == 0).all(dim=(1, 2, 3))
|
||||
nonzero_idx = (~is_all_zero).nonzero(as_tuple=True)[0]
|
||||
if len(nonzero_idx) == 0:
|
||||
# All rows are zero-padded; return an empty tensor so the caller
|
||||
# can detect this via numel()==0 and skip the request.
|
||||
return codec_codes[:0]
|
||||
if len(nonzero_idx) < codec_codes.shape[0]:
|
||||
return codec_codes[nonzero_idx]
|
||||
return codec_codes
|
||||
|
||||
|
||||
def llm2code2wav_token_only(
|
||||
source_outputs: list,
|
||||
_prompt=None,
|
||||
_requires_multimodal_data: bool = False,
|
||||
) -> list:
|
||||
"""Sync-side placeholder for the non-async-chunk Stage-1 (code2wav) input.
|
||||
|
||||
Returns an ``OmniTokensPrompt`` sized to the orchestrator-shape codec
|
||||
length so the consumer runtime allocates the right number of slots.
|
||||
The actual codec ids are delivered via the worker connector payload
|
||||
built by ``llm2code2wav_full_payload``.
|
||||
"""
|
||||
from vllm_omni.inputs.data import OmniTokensPrompt
|
||||
|
||||
code2wav_inputs: list = []
|
||||
for output_wrapper in source_outputs:
|
||||
out = output_wrapper.outputs[0]
|
||||
mm = out.multimodal_output if hasattr(out, "multimodal_output") else None
|
||||
mm = mm if isinstance(mm, dict) else {}
|
||||
mm_codes = mm.get("codes", {}) if isinstance(mm, dict) else {}
|
||||
prompt_len = 0
|
||||
if isinstance(mm_codes, dict) and "audio" in mm_codes:
|
||||
audio = mm_codes["audio"]
|
||||
if isinstance(audio, torch.Tensor) and audio.numel() > 0:
|
||||
audio = audio.to(torch.long)
|
||||
audio = _filter_zero_codec_rows(audio)
|
||||
# +B*4 per batch row for the prepended pad_vec (see prepend_and_flatten_colmajor)
|
||||
batch_size = int(audio.shape[0]) if audio.ndim >= 1 else 1
|
||||
prompt_len = int(audio.numel()) + batch_size * 4
|
||||
if prompt_len > MAX_CODE2WAV_TOKENS:
|
||||
prompt_len = MAX_CODE2WAV_TOKENS
|
||||
code2wav_inputs.append(
|
||||
OmniTokensPrompt(
|
||||
prompt_token_ids=[0] * prompt_len,
|
||||
additional_information=None,
|
||||
multi_modal_data=None,
|
||||
mm_processor_kwargs=None,
|
||||
)
|
||||
)
|
||||
return code2wav_inputs
|
||||
|
||||
|
||||
def llm2code2wav_full_payload(
|
||||
transfer_manager,
|
||||
pooling_output: dict,
|
||||
request,
|
||||
) -> dict | None:
|
||||
"""Producer-side payload builder for the worker connector data plane.
|
||||
|
||||
AR runner's ``flatten_payload`` converts the per-step model emit
|
||||
``{"codes": {"audio": ...}}`` to ``pooling_output["codes.audio"]``.
|
||||
The accumulator CONCATs per-step tensors along dim 0, so by flush
|
||||
time this holds the full ``[total_steps, 1, 8, 4]`` codec tensor.
|
||||
|
||||
A back-compat fallback to nested ``pooling_output["codes"]["audio"]``
|
||||
is kept in case a future runtime path bypasses `flatten_payload`.
|
||||
"""
|
||||
del transfer_manager
|
||||
rid = getattr(request, "request_id", "?")
|
||||
if not isinstance(pooling_output, dict):
|
||||
logger.warning(
|
||||
"mimo_audio.llm2code2wav_full_payload: pooling_output not a dict "
|
||||
"(type=%s) for req=%s; consumer wait gate may hang.",
|
||||
type(pooling_output).__name__,
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
codec_codes = pooling_output.get("codes.audio")
|
||||
if codec_codes is None:
|
||||
# Back-compat fallback for un-flattened pooler emits.
|
||||
codes = pooling_output.get("codes")
|
||||
if isinstance(codes, dict):
|
||||
codec_codes = codes.get("audio")
|
||||
if not isinstance(codec_codes, torch.Tensor) or codec_codes.numel() == 0:
|
||||
logger.warning(
|
||||
"mimo_audio.llm2code2wav_full_payload: missing/empty codes.audio "
|
||||
"(keys=%s) for req=%s; consumer wait gate may hang.",
|
||||
list(pooling_output.keys()),
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
codec_codes = codec_codes.to(torch.long)
|
||||
codec_codes = _filter_zero_codec_rows(codec_codes)
|
||||
if codec_codes.numel() == 0:
|
||||
logger.warning(
|
||||
"mimo_audio.llm2code2wav_full_payload: codec_codes empty after _filter_zero_codec_rows for req=%s.",
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
|
||||
pad_vec = torch.tensor([TALKER_CODEC_PAD_TOKEN_ID] * 4)
|
||||
code_final = prepend_and_flatten_colmajor(codec_codes, pad_vec).tolist()
|
||||
if len(code_final) > MAX_CODE2WAV_TOKENS:
|
||||
code_final = code_final[:MAX_CODE2WAV_TOKENS]
|
||||
|
||||
return {
|
||||
"codes": {"audio": code_final},
|
||||
"meta": {"finished": torch.tensor(True, dtype=torch.bool)},
|
||||
}
|
||||
@@ -535,9 +535,97 @@ def thinker2talker(
|
||||
return talker_inputs
|
||||
|
||||
|
||||
# ming_flash_omni is not in ``_OMNI_CONNECTOR_INIT_ARCHS`` or
|
||||
# ``_FULL_PAYLOAD_INPUT_STAGES``, so the worker connector is not
|
||||
# initialised for this arch and the consumer never waits on a connector
|
||||
# payload. Data flows through ``additional_information`` written by
|
||||
# ``thinker2talker_token_only`` (wired as ``sync_process_input_func``
|
||||
# in the pipeline) or the legacy ``thinker2talker`` (wired as
|
||||
# ``custom_process_input_func``).
|
||||
|
||||
|
||||
def thinker2talker_token_only(
|
||||
source_outputs: list[Any],
|
||||
prompt: OmniTokensPrompt | TextPrompt | None = None,
|
||||
_requires_multimodal_data: bool = False,
|
||||
) -> list[OmniTokensPrompt]:
|
||||
"""Sync-side builder for the non-async-chunk thinker→talker path.
|
||||
|
||||
Ports the legacy ``thinker2talker`` body to the new stage-input-processor signature
|
||||
(``source_outputs`` instead of ``stage_list, engine_input_source``).
|
||||
Body is otherwise identical: extracts the
|
||||
generated text from each thinker output and packages it with the
|
||||
request's voice/speaker additional_information for the talker.
|
||||
"""
|
||||
if not isinstance(prompt, list):
|
||||
prompt = [prompt]
|
||||
|
||||
talker_inputs: list[OmniTokensPrompt] = []
|
||||
for i, source_output in enumerate(source_outputs):
|
||||
output = source_output.outputs[0]
|
||||
|
||||
generated_text = output.text if hasattr(output, "text") and output.text else ""
|
||||
|
||||
original_prompt = prompt[i] if i < len(prompt) else None
|
||||
additional_info: dict[str, Any] = {}
|
||||
if original_prompt is not None and hasattr(original_prompt, "additional_information"):
|
||||
additional_info = original_prompt.additional_information or {}
|
||||
|
||||
spk_emb = additional_info.get("spk_emb", None)
|
||||
if isinstance(spk_emb, list) and spk_emb and not hasattr(spk_emb[0], "device"):
|
||||
import torch
|
||||
|
||||
spk_emb = torch.tensor(spk_emb, dtype=torch.float32).unsqueeze(0)
|
||||
|
||||
talker_info = {
|
||||
"ming_task": "omni",
|
||||
"text": generated_text,
|
||||
"spk_emb": spk_emb,
|
||||
"voice_name": additional_info.get("voice_name", "DB30"),
|
||||
"prompt_text": additional_info.get("prompt_text", None),
|
||||
"prompt_wav_lat": additional_info.get("prompt_wav_lat", None),
|
||||
"prompt_wav_emb": additional_info.get("prompt_wav_emb", None),
|
||||
"max_text_length": additional_info.get("max_text_length", 50),
|
||||
}
|
||||
|
||||
talker_inputs.append(
|
||||
OmniTokensPrompt(
|
||||
prompt_token_ids=[0],
|
||||
additional_information=talker_info,
|
||||
multi_modal_data=None,
|
||||
mm_processor_kwargs=None,
|
||||
)
|
||||
)
|
||||
|
||||
return talker_inputs
|
||||
|
||||
|
||||
thinker2talker_token_only._is_sync_input = True
|
||||
|
||||
|
||||
def thinker2talker_full_payload(
|
||||
transfer_manager,
|
||||
pooling_output,
|
||||
request,
|
||||
):
|
||||
"""Producer-side payload builder — no-op.
|
||||
|
||||
ming_flash_omni's thinker emits no heavy tensor to ship via the
|
||||
worker connector (the bridge passes text only, and speaker metadata
|
||||
arrives through the USER request's additional_information).
|
||||
ming_flash_omni is not in ``_OMNI_CONNECTOR_INIT_ARCHS`` so this
|
||||
function is never invoked at runtime; it is retained for forward
|
||||
compatibility with the connector path.
|
||||
"""
|
||||
del transfer_manager, pooling_output, request
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CFG_TEXT_SUFFIX",
|
||||
"expand_cfg_prompts",
|
||||
"thinker2imagegen",
|
||||
"thinker2talker",
|
||||
"thinker2talker_full_payload",
|
||||
"thinker2talker_token_only",
|
||||
]
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
import torch
|
||||
from vllm.inputs import TextPrompt
|
||||
|
||||
@@ -11,6 +13,8 @@ from vllm_omni.data_entry_keys import (
|
||||
)
|
||||
from vllm_omni.inputs.data import OmniTokensPrompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TALKER_CODEC_PAD_TOKEN_ID = 8292
|
||||
TALKER_CODEC_START_TOKEN_ID = 8293
|
||||
TALKER_CODEC_END_TOKEN_ID = 8294
|
||||
@@ -86,3 +90,347 @@ def talker2code2wav(
|
||||
)
|
||||
)
|
||||
return code2wav_inputs
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Worker-connector data plane (non-async-chunk path).
|
||||
# Both transitions ship payloads via the worker connector
|
||||
# (registered in ``_FULL_PAYLOAD_INPUT_STAGES`` in
|
||||
# omni_scheduling_coordinator):
|
||||
# - thinker->talker reads accumulated ``pooling_output["hidden"]`` and
|
||||
# packs an OmniPayload-shaped dict (embed.prefill /
|
||||
# hidden_states.output / ids.prompt / ids.output) for the talker, which
|
||||
# the talker's ``talker_preprocess`` reads from
|
||||
# ``model_intermediate_buffer``. The shape matches what legacy
|
||||
# ``thinker2talker`` writes into ``additional_information`` as a debug
|
||||
# fallback; ``thinker2talker_token_only`` only allocates prompt slots.
|
||||
# - talker->code2wav strips TALKER_CODEC_{START,END} boundary tokens
|
||||
# and ships the codec token ids.
|
||||
# ============================================================================
|
||||
|
||||
# Per-model REPLACE-keys for the full-payload accumulator. qwen2_5_omni's
|
||||
# producer side does not emit model_outputs through pooler_output (it ships
|
||||
# token_ids on the request directly), so the empty set preserves correctness.
|
||||
_FULL_PAYLOAD_REPLACE_KEYS: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def _strip_codec_boundaries(token_ids: list[int]) -> list[int]:
|
||||
"""Keep only real codec ids for the code2wav stage.
|
||||
|
||||
The talker stream can contain prompt/control ids (START/PAD/END/MASK) in
|
||||
addition to sampled codec ids. Code2wav expects codec ids only; carrying
|
||||
the prompt PAD span forward can inflate the sequence enough to OOM on L4.
|
||||
Async scheduling may also leave trailing ``-1`` placeholders, so preserve
|
||||
their length by repeating the last valid codec id.
|
||||
"""
|
||||
tids = list(token_ids)
|
||||
trailing_placeholder_count = 0
|
||||
while trailing_placeholder_count < len(tids) and tids[-1 - trailing_placeholder_count] == -1:
|
||||
trailing_placeholder_count += 1
|
||||
|
||||
if tids and tids[-1] == TALKER_CODEC_END_TOKEN_ID:
|
||||
tids = tids[:-1]
|
||||
trailing_placeholder_count = 0
|
||||
|
||||
codec_ids = [tid for tid in tids if 0 <= tid < TALKER_CODEC_PAD_TOKEN_ID]
|
||||
if trailing_placeholder_count > 0 and codec_ids:
|
||||
codec_ids.extend([codec_ids[-1]] * trailing_placeholder_count)
|
||||
return codec_ids
|
||||
|
||||
|
||||
def talker2code2wav_token_only(
|
||||
source_outputs,
|
||||
_prompt: OmniTokensPrompt | TextPrompt = None,
|
||||
_requires_multimodal_data: bool = False,
|
||||
):
|
||||
"""Sync-side placeholder for Stage-2 input (code2wav).
|
||||
|
||||
Returns OmniTokensPrompt sized to the stripped codec token count.
|
||||
Actual codec ids are delivered via the worker connector payload built
|
||||
by ``talker2code2wav_full_payload``.
|
||||
"""
|
||||
code2wav_inputs = []
|
||||
for talker_output in source_outputs:
|
||||
output = talker_output.outputs[0]
|
||||
token_ids = _strip_codec_boundaries(list(output.cumulative_token_ids))
|
||||
if not token_ids:
|
||||
continue
|
||||
code2wav_inputs.append(
|
||||
OmniTokensPrompt(
|
||||
prompt_token_ids=[0] * len(token_ids),
|
||||
additional_information=None,
|
||||
multi_modal_data=None,
|
||||
mm_processor_kwargs=None,
|
||||
)
|
||||
)
|
||||
return code2wav_inputs
|
||||
|
||||
|
||||
def talker2code2wav_full_payload(
|
||||
transfer_manager,
|
||||
pooling_output: dict,
|
||||
request,
|
||||
) -> dict | None:
|
||||
"""Producer-side payload builder: ship the stripped codec ids via connector.
|
||||
|
||||
Token-ids-only shape. The talker stage's output already
|
||||
carries the codec ids on ``request.output_token_ids``; we strip the
|
||||
boundary tokens and pack a minimal payload.
|
||||
"""
|
||||
del transfer_manager
|
||||
rid = getattr(request, "request_id", "?")
|
||||
token_ids = list(getattr(request, "output_token_ids", None) or [])
|
||||
if not token_ids:
|
||||
logger.warning(
|
||||
"qwen2_5_omni.talker2code2wav_full_payload: empty output_token_ids "
|
||||
"for req=%s; consumer wait gate may hang.",
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
token_ids = _strip_codec_boundaries(token_ids)
|
||||
if not token_ids:
|
||||
logger.warning(
|
||||
"qwen2_5_omni.talker2code2wav_full_payload: codec ids empty after "
|
||||
"stripping boundary tokens for req=%s; consumer wait gate may hang.",
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
return {
|
||||
"codes": {"audio": token_ids},
|
||||
"meta": {"finished": torch.tensor(True, dtype=torch.bool)},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Worker-connector data plane (non-async-chunk path) -- thinker->talker.
|
||||
#
|
||||
# qwen2_5_omni's talker consumes the thinker's last-layer hidden state
|
||||
# via Linear(3584, 896). The AR runner publishes those hidden states
|
||||
# per decode step on ``pooling_output["hidden"]`` (unpacked from
|
||||
# ``OmniOutput.text_hidden_states``); the full-payload accumulator
|
||||
# concatenates them so ``thinker2talker_full_payload`` sees the full
|
||||
# prefill+decode trajectory and packs an OmniPayload-shaped dict that
|
||||
# the talker's ``talker_preprocess`` reads from
|
||||
# ``model_intermediate_buffer``. ``thinker2talker_token_only`` only
|
||||
# allocates the talker's codec prompt slots; legacy
|
||||
# ``thinker2talker`` above remains as a debug fallback that bundles the
|
||||
# same shape into ``additional_information``.
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def thinker2talker_token_only(
|
||||
source_outputs,
|
||||
prompt: OmniTokensPrompt | TextPrompt = None,
|
||||
requires_multimodal_data: bool = False,
|
||||
):
|
||||
"""Placeholder builder for the connector-driven thinker->talker path.
|
||||
|
||||
Allocates the TALKER_CODEC_{START,PAD,END} prompt slots sized to the
|
||||
thinker prompt length and forwards ``multi_modal_data``. The bulk
|
||||
payload (hidden_states / embed / ids) ships exclusively through
|
||||
``thinker2talker_full_payload`` via the worker connector and lands
|
||||
in ``model_intermediate_buffer`` before the talker's forward() runs.
|
||||
|
||||
Consumer-wait gating is whitelist-driven via
|
||||
``_FULL_PAYLOAD_INPUT_STAGES`` (see the mixin
|
||||
``should_accumulate_full_payload_output`` docstring).
|
||||
"""
|
||||
thinker_outputs = source_outputs
|
||||
talker_inputs = []
|
||||
if not isinstance(prompt, list):
|
||||
prompt = [prompt]
|
||||
multi_modal_data = {
|
||||
thinker_output.request_id: p.get("multi_modal_data", None) for thinker_output, p in zip(thinker_outputs, prompt)
|
||||
}
|
||||
|
||||
for thinker_output in thinker_outputs:
|
||||
prompt_token_ids = thinker_output.prompt_token_ids
|
||||
talker_inputs.append(
|
||||
OmniTokensPrompt(
|
||||
prompt_token_ids=[TALKER_CODEC_START_TOKEN_ID]
|
||||
+ [TALKER_CODEC_PAD_TOKEN_ID] * (len(prompt_token_ids))
|
||||
+ [TALKER_CODEC_END_TOKEN_ID],
|
||||
additional_information=None,
|
||||
multi_modal_data=(
|
||||
multi_modal_data[thinker_output.request_id]
|
||||
if requires_multimodal_data and multi_modal_data is not None
|
||||
else None
|
||||
),
|
||||
mm_processor_kwargs=None,
|
||||
)
|
||||
)
|
||||
|
||||
return talker_inputs
|
||||
|
||||
|
||||
def thinker2talker_full_payload(
|
||||
transfer_manager,
|
||||
pooling_output,
|
||||
request,
|
||||
):
|
||||
"""Producer-side payload builder for the worker-connector data plane.
|
||||
|
||||
The AR runner emits per-step ``pooling_output["hidden"]`` (the
|
||||
thinker's last-layer hidden states for the request span, unpacked
|
||||
from ``OmniOutput.text_hidden_states``). The full-payload
|
||||
accumulator concatenates those per-step rows across decode steps, so
|
||||
by the time this builder fires the materialized
|
||||
``pooling_output["hidden"]`` contains the full prefill+decode
|
||||
hidden-state trajectory of size
|
||||
``len(prompt_token_ids) + len(output_token_ids)``.
|
||||
|
||||
We split it at ``len(prompt_token_ids)`` into prefill embeddings and
|
||||
decode hidden states, then pack the ``OmniPayload``-shaped dict that
|
||||
the talker's ``thinker_to_talker_process`` reads from
|
||||
``model_intermediate_buffer`` (keys ``embed.prefill`` /
|
||||
``hidden_states.output`` / ``ids.prompt`` / ``ids.output``). Shape
|
||||
matches what legacy ``thinker2talker`` writes into
|
||||
``additional_information`` as a debug fallback, so the talker
|
||||
consumes the same payload layout from either path.
|
||||
|
||||
Like ``qwen3_omni.thinker2talker_full_payload``, we apply a
|
||||
finish-reason-aware stop-row trim: vLLM v1 appends the sampled
|
||||
token to ``output_token_ids`` before ``check_stop``, so a request
|
||||
that finished via ``FINISHED_STOPPED`` has one extra accumulated
|
||||
hidden-state row that the talker must not consume. Max-token
|
||||
finishes need no drop. Status is read from the request when
|
||||
available; otherwise we fall back to a last-token-in-stop-set
|
||||
heuristic.
|
||||
"""
|
||||
del transfer_manager
|
||||
rid = getattr(request, "request_id", "?")
|
||||
if not isinstance(pooling_output, dict):
|
||||
logger.warning(
|
||||
"qwen2_5_omni.thinker2talker_full_payload: pooling_output not a dict "
|
||||
"(type=%s) for req=%s; consumer wait gate may hang.",
|
||||
type(pooling_output).__name__,
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
|
||||
hidden = pooling_output.get("hidden")
|
||||
if not isinstance(hidden, torch.Tensor):
|
||||
logger.warning(
|
||||
"qwen2_5_omni.thinker2talker_full_payload: missing 'hidden' tensor "
|
||||
"(keys=%s) for req=%s; consumer wait gate may hang.",
|
||||
list(pooling_output.keys()),
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
|
||||
def _ensure_list(x):
|
||||
if x is None:
|
||||
return []
|
||||
if hasattr(x, "_x"):
|
||||
# vLLM wraps cached token-id lists in ConstantList-like objects.
|
||||
return list(x._x)
|
||||
if isinstance(x, list):
|
||||
return list(x)
|
||||
return list(x)
|
||||
|
||||
prompt_token_ids = _ensure_list(getattr(request, "prompt_token_ids", None))
|
||||
output_token_ids = _ensure_list(getattr(request, "output_token_ids", None))
|
||||
all_token_ids = _ensure_list(getattr(request, "all_token_ids", None) or [])
|
||||
if not all_token_ids:
|
||||
all_token_ids = list(prompt_token_ids) + list(output_token_ids)
|
||||
|
||||
# Length-aware trim of accumulated thinker output, finish-reason-aware.
|
||||
# Mirror qwen3_omni.thinker2talker_full_payload's logic so a stop-finish
|
||||
# does not leak an extra hidden-state row to the talker.
|
||||
status = getattr(request, "status", None)
|
||||
status_name = getattr(status, "name", None) or ""
|
||||
if not status_name and status is not None:
|
||||
status_name = str(status).rsplit(".", 1)[-1]
|
||||
stop_emission_drop = 1 if status_name == "FINISHED_STOPPED" else 0
|
||||
if stop_emission_drop == 0 and not status_name and output_token_ids:
|
||||
# Worker-side CachedRequestState has no `.status` field in vLLM v1;
|
||||
# fall back to a last-token-in-stop-set heuristic.
|
||||
sampling_params = getattr(request, "sampling_params", None)
|
||||
if sampling_params is not None:
|
||||
stop_ids: set[int] = set()
|
||||
ignore_eos = bool(getattr(sampling_params, "ignore_eos", False))
|
||||
for sid in getattr(sampling_params, "stop_token_ids", None) or ():
|
||||
if isinstance(sid, int):
|
||||
stop_ids.add(sid)
|
||||
if not ignore_eos:
|
||||
for eos in (
|
||||
getattr(sampling_params, "eos_token_id", None),
|
||||
getattr(sampling_params, "_eos_token_id", None),
|
||||
):
|
||||
if isinstance(eos, int):
|
||||
stop_ids.add(eos)
|
||||
for sid in (
|
||||
getattr(sampling_params, "all_stop_token_ids", None)
|
||||
or getattr(sampling_params, "_all_stop_token_ids", None)
|
||||
or ()
|
||||
):
|
||||
if isinstance(sid, int):
|
||||
stop_ids.add(sid)
|
||||
if stop_ids and output_token_ids[-1] in stop_ids:
|
||||
stop_emission_drop = 1
|
||||
|
||||
# Trim accumulated thinker output based on stop_emission_drop computed
|
||||
# above. Mirror qwen3_omni.thinker2talker_full_payload's contract:
|
||||
# target_rows = len(all_token_ids) - stop_emission_drop
|
||||
# which excludes the stop-emission row for FINISHED_STOPPED but keeps
|
||||
# all rows for FINISHED_LENGTH_CAPPED (max_tokens) finishes.
|
||||
if stop_emission_drop > 0 and len(output_token_ids) >= stop_emission_drop:
|
||||
output_token_ids = output_token_ids[:-stop_emission_drop]
|
||||
h = hidden.detach().cpu().to(torch.float32)
|
||||
target_rows = max(0, len(all_token_ids) - stop_emission_drop)
|
||||
if target_rows <= 0:
|
||||
logger.warning(
|
||||
"qwen2_5_omni.thinker2talker_full_payload: target_rows<=0 "
|
||||
"(all_token_ids=%d, stop_drop=%d) for req=%s; nothing to ship.",
|
||||
len(all_token_ids),
|
||||
stop_emission_drop,
|
||||
getattr(request, "request_id", "?"),
|
||||
)
|
||||
return None
|
||||
if h.dim() >= 1 and h.shape[0] > target_rows:
|
||||
logger.warning(
|
||||
"qwen2_5_omni.thinker2talker_full_payload: excess hidden rows "
|
||||
"(got %d, target %d, stop_drop %d) for req=%s; trimming",
|
||||
int(h.shape[0]),
|
||||
target_rows,
|
||||
stop_emission_drop,
|
||||
getattr(request, "request_id", None),
|
||||
)
|
||||
h = h[:target_rows]
|
||||
|
||||
prompt_len = len(prompt_token_ids)
|
||||
if h.shape[0] < prompt_len:
|
||||
# Under-captured prefill -- defensively skip rather than ship a
|
||||
# truncated payload that would confuse the talker's prefill path.
|
||||
logger.warning(
|
||||
"qwen2_5_omni.thinker2talker_full_payload: hidden rows=%d < prompt_len=%d "
|
||||
"for req=%s; under-captured prefill, skipping payload.",
|
||||
int(h.shape[0]),
|
||||
prompt_len,
|
||||
getattr(request, "request_id", "?"),
|
||||
)
|
||||
return None
|
||||
|
||||
prefill_hidden = h[:prompt_len]
|
||||
decode_hidden = h[prompt_len:]
|
||||
|
||||
payload: OmniPayload = to_dict(
|
||||
OmniPayloadStruct(
|
||||
hidden_states=HiddenStatesStruct(
|
||||
output=decode_hidden,
|
||||
output_shape=list(decode_hidden.shape),
|
||||
),
|
||||
embed=EmbeddingsStruct(
|
||||
prefill=prefill_hidden,
|
||||
prefill_shape=list(prefill_hidden.shape),
|
||||
),
|
||||
ids=IdsStruct(
|
||||
prompt=list(prompt_token_ids),
|
||||
output=list(output_token_ids),
|
||||
),
|
||||
)
|
||||
)
|
||||
# Intentionally omit payload["meta"]: the thinker->talker transition
|
||||
# carries no scheduler-relevant metadata (next_stage_prompt_len /
|
||||
# left_context_size are not set on this edge).
|
||||
return payload
|
||||
@@ -35,6 +35,12 @@ logger = logging.getLogger(__name__)
|
||||
# Pooling output layer keys: "0" = word embedding, "24" = accept_hidden_layer
|
||||
_EMBED_LAYER_KEY = "0"
|
||||
_HIDDEN_LAYER_KEY = "24"
|
||||
# Per-model REPLACE-keys for the full-payload accumulator. Keys in this
|
||||
# set use REPLACE semantics (subsequent emissions discard prior chunks)
|
||||
# instead of CONCAT. qwen3-omni currently has none — model_outputs is
|
||||
# not emitted by the thinker/talker forward.
|
||||
_FULL_PAYLOAD_REPLACE_KEYS: frozenset[str] = frozenset()
|
||||
|
||||
_QWEN3_CODEC_CODEBOOK_SIZE = 2048
|
||||
_QWEN3_CODEC_PAD_TOKEN_ID = 4196
|
||||
_QWEN3_CODEC_BOS_TOKEN_ID = 4197
|
||||
@@ -119,19 +125,6 @@ def _is_valid_qwen3_codec_token_id(token_id: Any) -> bool:
|
||||
return 0 <= token_id < _QWEN3_CODEC_CODEBOOK_SIZE
|
||||
|
||||
|
||||
def should_accumulate_qwen3_omni_full_payload_output(
|
||||
model_config: Any,
|
||||
custom_process_func: Any,
|
||||
) -> bool:
|
||||
"""Return whether Qwen3-Omni should accumulate full-payload outputs."""
|
||||
return (
|
||||
custom_process_func is not None
|
||||
and not getattr(model_config, "async_chunk", False)
|
||||
and getattr(model_config, "model_arch", None) == "Qwen3OmniMoeForConditionalGeneration"
|
||||
and getattr(model_config, "model_stage", None) in {"thinker", "talker"}
|
||||
)
|
||||
|
||||
|
||||
def _extract_qwen3_full_payload_codec_rows(
|
||||
code_predictor_codes: torch.Tensor,
|
||||
output_token_ids: list[int],
|
||||
@@ -462,7 +455,13 @@ def thinker2talker_full_payload(
|
||||
request: OmniEngineCoreRequest,
|
||||
) -> 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):
|
||||
logger.warning(
|
||||
"thinker2talker_full_payload: pooling_output not a dict (type=%s) for req=%s; consumer wait gate may hang.",
|
||||
type(pooling_output).__name__,
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
|
||||
layers = {
|
||||
@@ -475,11 +474,13 @@ def thinker2talker_full_payload(
|
||||
hidden = pooling_output.get("hidden")
|
||||
thinker_emb = hidden if isinstance(hidden, torch.Tensor) else None
|
||||
if thinker_emb is None or thinker_hid is None:
|
||||
logger.debug(
|
||||
"thinker2talker_full_payload: missing thinker tensors for req=%s (embed=%s hidden=%s)",
|
||||
getattr(request, "request_id", None),
|
||||
logger.warning(
|
||||
"thinker2talker_full_payload: missing thinker tensors for req=%s "
|
||||
"(embed=%s hidden=%s keys=%s); consumer wait gate may hang.",
|
||||
rid,
|
||||
thinker_emb is not None,
|
||||
thinker_hid is not None,
|
||||
list(pooling_output.keys()),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -493,7 +494,7 @@ def thinker2talker_full_payload(
|
||||
# The accumulator captures one hidden-state row per executed thinker
|
||||
# forward (prefill + every decode step including the one that emitted
|
||||
# the stop_token), so for a finished request thinker_emb has exactly one
|
||||
# row more than the rows the talker should consume. async_chunk's
|
||||
# row more than the rows the talker should consume. async_chunk's
|
||||
# chunk-0 path naturally captures only the prefill / non-stop portion,
|
||||
# which is why the [async_chunk] parametrization passes while [default]
|
||||
# over-generates one codec frame on short outputs (e.g.
|
||||
@@ -797,7 +798,14 @@ def talker2code2wav_full_payload(
|
||||
request: OmniEngineCoreRequest,
|
||||
) -> 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):
|
||||
logger.warning(
|
||||
"talker2code2wav_full_payload: pooling_output not a dict "
|
||||
"(type=%s) for req=%s; consumer wait gate may hang.",
|
||||
type(pooling_output).__name__,
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
code_predictor_codes = pooling_output.get("codes.audio")
|
||||
if code_predictor_codes is None:
|
||||
@@ -805,10 +813,19 @@ def talker2code2wav_full_payload(
|
||||
if isinstance(codes, dict):
|
||||
code_predictor_codes = codes.get("audio")
|
||||
if code_predictor_codes is None:
|
||||
logger.warning(
|
||||
"talker2code2wav_full_payload: missing codes.audio (keys=%s) for req=%s; consumer wait gate may hang.",
|
||||
list(pooling_output.keys()),
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
if not isinstance(code_predictor_codes, torch.Tensor):
|
||||
code_predictor_codes = torch.as_tensor(code_predictor_codes)
|
||||
if code_predictor_codes.numel() == 0:
|
||||
logger.warning(
|
||||
"talker2code2wav_full_payload: empty codes.audio for req=%s; consumer wait gate may hang.",
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
|
||||
output_token_ids = _ensure_list(getattr(request, "output_token_ids", []) or [])
|
||||
@@ -818,6 +835,16 @@ def talker2code2wav_full_payload(
|
||||
list(output_token_ids),
|
||||
)
|
||||
if code_predictor_codes.numel() == 0:
|
||||
logger.warning(
|
||||
"talker2code2wav_full_payload: no valid codec rows after filtering "
|
||||
"(raw_shape=%s output_ids_len=%d aligned_rows=%s valid_rows=%s) for req=%s; "
|
||||
"consumer wait gate may hang.",
|
||||
raw_shape,
|
||||
len(output_token_ids),
|
||||
codec_stats["aligned_rows"],
|
||||
codec_stats["valid_rows"],
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
|
||||
codec_codes = code_predictor_codes.transpose(0, 1).cpu().reshape(-1).tolist()
|
||||
|
||||
@@ -53,10 +53,13 @@ def talker2code2wav(
|
||||
# audio_codes may still contain zero-padded / invalid rows, so trim only
|
||||
# after filtering valid frames instead of trying to align EOS indices.
|
||||
seq_len = max(len(token_ids) - 1, 0)
|
||||
# Filter invalid frames: zero-padded (EOS) and frames containing
|
||||
# out-of-range values (e.g. stop_token_id=2150 exceeds codebook_size=2048).
|
||||
# Filter invalid frames: zero-padded (EOS), out-of-range values (e.g.
|
||||
# stop_token_id=2150 exceeds codebook_size=2048), and negative
|
||||
# sentinels (e.g. -1 padding).
|
||||
_CODEBOOK_SIZE = 2048
|
||||
valid_mask = audio_codes.any(dim=1) & (audio_codes.max(dim=1).values < _CODEBOOK_SIZE)
|
||||
valid_mask = (
|
||||
(audio_codes >= 0).all(dim=1) & audio_codes.any(dim=1) & (audio_codes.max(dim=1).values < _CODEBOOK_SIZE)
|
||||
)
|
||||
audio_codes = audio_codes[valid_mask]
|
||||
if seq_len > 0 and audio_codes.ndim == 2 and int(audio_codes.shape[0]) > seq_len:
|
||||
audio_codes = audio_codes[-seq_len:]
|
||||
@@ -280,3 +283,220 @@ def talker2code2wav_async_chunk(
|
||||
speaker=extract_speaker_from_request(request),
|
||||
language=extract_language_from_request(request),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Worker-connector data plane (non-async-chunk path).
|
||||
# AR runner's `flatten_payload` converts the model emit
|
||||
# `multimodal_outputs={"codes": {"audio": ..., "ref": ...},
|
||||
# "meta": {"ref_code_len": ..., "codec_streaming": ...}}` to flat dotted
|
||||
# keys (`codes.audio`, `codes.ref`, `meta.ref_code_len`,
|
||||
# `meta.codec_streaming`) before the full-payload accumulator runs.
|
||||
# - codes.audio is 2-D so default CONCAT across steps builds the full sequence.
|
||||
# - codes.ref is a list (not Tensor with dim>=2) so accumulator LATEST-wins
|
||||
# keeps the prefill-emitted ref tensor across decode steps (which don't emit
|
||||
# ref again).
|
||||
# - meta.ref_code_len is 1-D so LATEST-wins; consumer reads [-1].
|
||||
# ============================================================================
|
||||
|
||||
# Per-model REPLACE-keys for the full-payload accumulator. qwen3_tts's
|
||||
# producer side emits codec frames that should CONCAT (codes.audio) plus
|
||||
# scalars/lists that are correctly handled by default LATEST-wins, so this
|
||||
# stays empty.
|
||||
_FULL_PAYLOAD_REPLACE_KEYS: frozenset[str] = frozenset()
|
||||
|
||||
_CODEBOOK_SIZE = 2048
|
||||
_NUM_QUANTIZERS_DEFAULT = 16
|
||||
|
||||
|
||||
def _filter_audio_codes_qwen3_tts(audio_codes: torch.Tensor) -> torch.Tensor:
|
||||
"""Filter zero-padded, out-of-range, and negative-padded codec frames.
|
||||
|
||||
Mirrors the orchestrator-path body in `talker2code2wav` above.
|
||||
"""
|
||||
if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0:
|
||||
return audio_codes
|
||||
if audio_codes.ndim != 2:
|
||||
return audio_codes
|
||||
valid_mask = (
|
||||
(audio_codes >= 0).all(dim=1) & audio_codes.any(dim=1) & (audio_codes.max(dim=1).values < _CODEBOOK_SIZE)
|
||||
)
|
||||
return audio_codes[valid_mask]
|
||||
|
||||
|
||||
def _coerce_ref_code_len(raw) -> int:
|
||||
"""Coerce mm["meta"]["ref_code_len"] / pooling_output["meta.ref_code_len"]
|
||||
raw value (Tensor | int | None) into a non-negative int. Mirrors the
|
||||
extraction inlined in the legacy ``talker2code2wav`` path; clamps any
|
||||
negative input to 0 since downstream code treats this as a non-negative
|
||||
frame count."""
|
||||
if isinstance(raw, torch.Tensor):
|
||||
value = int(raw.reshape(-1)[-1].item()) if raw.numel() > 0 else 0
|
||||
elif raw is None:
|
||||
value = 0
|
||||
else:
|
||||
value = int(raw)
|
||||
return max(value, 0)
|
||||
|
||||
|
||||
def _normalize_ref_code(ref_code, num_quantizers: int, ref_code_len: int):
|
||||
"""Coerce ref_code into a [ref_len, Q] tensor or None. Mirrors orchestrator path."""
|
||||
if isinstance(ref_code, list):
|
||||
ref_code = ref_code[0] if ref_code else None
|
||||
if not isinstance(ref_code, torch.Tensor) or ref_code.numel() == 0:
|
||||
return None, 0
|
||||
ref_code = ref_code.to(torch.long).cpu().contiguous()
|
||||
if ref_code.ndim == 1:
|
||||
if ref_code.numel() % num_quantizers != 0:
|
||||
return None, 0
|
||||
ref_code = ref_code.reshape(-1, num_quantizers)
|
||||
elif ref_code.ndim != 2:
|
||||
return None, 0
|
||||
if ref_code_len > 0 and int(ref_code.shape[0]) > ref_code_len:
|
||||
ref_code = ref_code[:ref_code_len]
|
||||
return ref_code, int(ref_code.shape[0])
|
||||
|
||||
|
||||
def talker2code2wav_token_only(
|
||||
source_outputs: list,
|
||||
prompt=None,
|
||||
_requires_multimodal_data: bool = False,
|
||||
) -> list:
|
||||
"""Sync-side placeholder for the non-async-chunk Stage-1 (code2wav) input.
|
||||
|
||||
Sized to the expected codec token count (codebook-major flat:
|
||||
Q * (ref_frames + audio_frames)). Speaker / language metadata are
|
||||
extracted from `prompt` and threaded via `additional_information`
|
||||
(orchestrator-style; same as the legacy `talker2code2wav` builder).
|
||||
Actual codec ids are delivered via the worker connector payload built
|
||||
by `talker2code2wav_full_payload`.
|
||||
"""
|
||||
from vllm_omni.inputs.data import OmniTokensPrompt
|
||||
|
||||
code2wav_inputs: list = []
|
||||
for i, talker_output in enumerate(source_outputs):
|
||||
if not talker_output.finished:
|
||||
continue
|
||||
output = talker_output.outputs[0]
|
||||
mm = output.multimodal_output if hasattr(output, "multimodal_output") else None
|
||||
mm = mm if isinstance(mm, dict) else {}
|
||||
mm_codes = mm.get("codes", {}) if isinstance(mm, dict) else {}
|
||||
token_ids = getattr(output, "cumulative_token_ids", []) or []
|
||||
seq_len = max(len(token_ids) - 1, 0)
|
||||
|
||||
audio = mm_codes.get("audio") if isinstance(mm_codes, dict) else None
|
||||
if isinstance(audio, torch.Tensor) and audio.numel() > 0:
|
||||
audio = audio.to(torch.long)
|
||||
audio = _filter_audio_codes_qwen3_tts(audio)
|
||||
if seq_len > 0 and audio.ndim == 2 and int(audio.shape[0]) > seq_len:
|
||||
audio = audio[-seq_len:]
|
||||
num_audio_frames = int(audio.shape[0]) if audio.ndim == 2 else 0
|
||||
num_quantizers = int(audio.shape[1]) if audio.ndim == 2 and audio.shape[1] > 0 else _NUM_QUANTIZERS_DEFAULT
|
||||
else:
|
||||
num_audio_frames = 0
|
||||
num_quantizers = _NUM_QUANTIZERS_DEFAULT
|
||||
|
||||
ref_code_raw = mm_codes.get("ref") if isinstance(mm_codes, dict) else None
|
||||
ref_code_len_raw = mm.get("meta", {}).get("ref_code_len") if isinstance(mm.get("meta"), dict) else None
|
||||
ref_code_len = _coerce_ref_code_len(ref_code_len_raw)
|
||||
_, ref_frames = _normalize_ref_code(ref_code_raw, num_quantizers, ref_code_len)
|
||||
|
||||
# Codebook-major flat: Q * (ref_frames + audio_frames)
|
||||
prompt_len = num_quantizers * (ref_frames + num_audio_frames)
|
||||
|
||||
additional_info = to_dict(
|
||||
OmniPayloadStruct(
|
||||
meta=MetaStruct(left_context_size=ref_frames) if ref_frames > 0 else None,
|
||||
speaker=extract_speaker_from_prompt(prompt, index=i),
|
||||
language=extract_language_from_prompt(prompt, index=i),
|
||||
)
|
||||
)
|
||||
code2wav_inputs.append(
|
||||
OmniTokensPrompt(
|
||||
prompt_token_ids=[0] * prompt_len,
|
||||
additional_information=additional_info if additional_info else None,
|
||||
multi_modal_data=None,
|
||||
mm_processor_kwargs=None,
|
||||
)
|
||||
)
|
||||
return code2wav_inputs
|
||||
|
||||
|
||||
def talker2code2wav_full_payload(
|
||||
transfer_manager,
|
||||
pooling_output,
|
||||
request,
|
||||
):
|
||||
"""Producer-side payload builder.
|
||||
|
||||
Reads accumulated codec from `pooling_output["codes.audio"]` (CONCAT
|
||||
across steps via flatten_payload), latest `pooling_output["codes.ref"]`
|
||||
(prefill-emitted), and latest `pooling_output["meta.ref_code_len"]`.
|
||||
Replicates the orchestrator-path body of `talker2code2wav` (filter,
|
||||
crop to seq_len, prepend ref, codebook-major flatten).
|
||||
"""
|
||||
del transfer_manager
|
||||
rid = getattr(request, "request_id", "?")
|
||||
if not isinstance(pooling_output, dict):
|
||||
logger.warning(
|
||||
"qwen3_tts.talker2code2wav_full_payload: pooling_output not a dict "
|
||||
"(type=%s) for req=%s; consumer wait gate may hang.",
|
||||
type(pooling_output).__name__,
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
|
||||
# codes.audio — try flat dotted first (flatten_payload), then nested fallback.
|
||||
audio = pooling_output.get("codes.audio")
|
||||
if audio is None:
|
||||
codes_nested = pooling_output.get("codes")
|
||||
if isinstance(codes_nested, dict):
|
||||
audio = codes_nested.get("audio")
|
||||
if not isinstance(audio, torch.Tensor) or audio.numel() == 0:
|
||||
logger.warning(
|
||||
"qwen3_tts.talker2code2wav_full_payload: missing/empty codes.audio "
|
||||
"(keys=%s) for req=%s; consumer wait gate may hang.",
|
||||
list(pooling_output.keys()),
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
audio = audio.to(torch.long)
|
||||
audio = _filter_audio_codes_qwen3_tts(audio)
|
||||
if audio.numel() == 0:
|
||||
logger.warning(
|
||||
"qwen3_tts.talker2code2wav_full_payload: audio empty after codec "
|
||||
"filter (negative/all-zero/out-of-range rows dropped) for req=%s.",
|
||||
rid,
|
||||
)
|
||||
return None
|
||||
|
||||
output_token_ids = list(getattr(request, "output_token_ids", None) or [])
|
||||
seq_len = max(len(output_token_ids) - 1, 0)
|
||||
if seq_len > 0 and audio.ndim == 2 and int(audio.shape[0]) > seq_len:
|
||||
audio = audio[-seq_len:]
|
||||
|
||||
num_quantizers = int(audio.shape[1]) if audio.ndim == 2 and audio.shape[1] > 0 else _NUM_QUANTIZERS_DEFAULT
|
||||
|
||||
# meta.ref_code_len — flat dotted then nested fallback.
|
||||
ref_code_len_raw = pooling_output.get("meta.ref_code_len")
|
||||
if ref_code_len_raw is None:
|
||||
meta_nested = pooling_output.get("meta")
|
||||
if isinstance(meta_nested, dict):
|
||||
ref_code_len_raw = meta_nested.get("ref_code_len")
|
||||
ref_code_len = _coerce_ref_code_len(ref_code_len_raw)
|
||||
|
||||
# codes.ref — flat dotted then nested fallback.
|
||||
ref_code_raw = pooling_output.get("codes.ref")
|
||||
if ref_code_raw is None:
|
||||
codes_nested = pooling_output.get("codes")
|
||||
if isinstance(codes_nested, dict):
|
||||
ref_code_raw = codes_nested.get("ref")
|
||||
ref_code, ref_frames = _normalize_ref_code(ref_code_raw, num_quantizers, ref_code_len)
|
||||
if ref_code is not None:
|
||||
audio = torch.cat([ref_code.to(audio.device), audio], dim=0)
|
||||
|
||||
codec_codes = audio.transpose(0, 1).to(device="cpu", dtype=torch.long).reshape(-1).contiguous()
|
||||
return {
|
||||
"codes": {"audio": codec_codes},
|
||||
"meta": {"finished": torch.tensor(True, dtype=torch.bool)},
|
||||
}
|
||||
@@ -84,11 +84,23 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
self.inputs_embeds = self._make_buffer(self.max_num_tokens, self.hidden_size, dtype=self.dtype, numpy=False)
|
||||
# Initialize KV cache manager (preserve vllm_config fallback behavior)
|
||||
self.kv_transfer_manager = OmniKVTransferManager.from_vllm_config(self.vllm_config, self.model_config)
|
||||
# Only Qwen3-Omni currently consumes the connector-based full-payload
|
||||
# handoff added in this PR. Other model architectures (e.g. Bagel
|
||||
# diffusion) retain their pre-existing runner behavior so this PR
|
||||
# does not perturb them.
|
||||
if getattr(self.model_config, "model_arch", None) == "Qwen3OmniMoeForConditionalGeneration":
|
||||
# Worker-connector init is gated by a per-`model_arch` allowlist
|
||||
# (covers both producer-side and consumer-side runners for the
|
||||
# arches below). Consumer-wait stages must be registered
|
||||
# separately as `(model_arch, model_stage)` tuples in
|
||||
# `omni_scheduling_coordinator._FULL_PAYLOAD_INPUT_STAGES`;
|
||||
# forgetting that produces a Stage-1 hang on the consumer.
|
||||
_OMNI_CONNECTOR_INIT_ARCHS = {
|
||||
"Qwen3OmniMoeForConditionalGeneration",
|
||||
"Qwen2_5OmniForConditionalGeneration",
|
||||
"CovoAudioForConditionalGeneration",
|
||||
"MiMoAudioModel",
|
||||
"Qwen3TTSTalkerForConditionalGeneration",
|
||||
"Qwen3TTSCode2Wav",
|
||||
"CosyVoice3Model",
|
||||
"DyninOmniForConditionalGeneration",
|
||||
}
|
||||
if getattr(self.model_config, "model_arch", None) in _OMNI_CONNECTOR_INIT_ARCHS:
|
||||
self.init_omni_connectors(
|
||||
vllm_config=self.vllm_config,
|
||||
model_config=self.model_config,
|
||||
@@ -109,6 +121,57 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
with maybe_disable_pin_memory_for_ray(self, total_bytes):
|
||||
return super()._make_buffer(*size, dtype=dtype, numpy=numpy)
|
||||
|
||||
def _build_model_sampler_output_token_ids(self) -> list[list[int]]:
|
||||
"""Build decoded-token history for custom model samplers.
|
||||
|
||||
vLLM only populates sampling_metadata.output_token_ids when penalties or
|
||||
logits processors require it. CosyVoice3's custom RAS sampler also
|
||||
depends on this history, so we reconstruct it directly from the input
|
||||
batch for prefer_model_sampler models.
|
||||
"""
|
||||
req_output_token_ids = getattr(self.input_batch, "req_output_token_ids", [])
|
||||
req_ids = list(getattr(self.input_batch, "req_ids", []))
|
||||
output_token_ids = [list(req_output_token_ids[idx] or []) for idx in range(len(req_ids))]
|
||||
|
||||
sampled_token_ids_cpu = getattr(self.input_batch, "sampled_token_ids_cpu", None)
|
||||
async_copy_ready_event = getattr(self.input_batch, "async_copy_ready_event", None)
|
||||
prev_req_id_to_index = getattr(self.input_batch, "prev_req_id_to_index", None)
|
||||
if sampled_token_ids_cpu is None or not output_token_ids or prev_req_id_to_index is None:
|
||||
return output_token_ids
|
||||
|
||||
sampled_token_ids: list[list[int]] | None = None
|
||||
for index, req_id in enumerate(req_ids):
|
||||
prev_index = prev_req_id_to_index.get(req_id)
|
||||
if prev_index is None:
|
||||
continue
|
||||
req_history = output_token_ids[index]
|
||||
if not req_history or req_history[-1] != -1:
|
||||
continue
|
||||
if sampled_token_ids is None:
|
||||
assert async_copy_ready_event is not None
|
||||
async_copy_ready_event.synchronize()
|
||||
sampled_token_ids = sampled_token_ids_cpu.tolist()
|
||||
new_ids = list(sampled_token_ids[prev_index])
|
||||
if not new_ids:
|
||||
continue
|
||||
num_sampled_ids = len(new_ids) if new_ids[-1] != -1 else new_ids.index(-1)
|
||||
first_placeholder = req_history.index(-1)
|
||||
num_placeholders = len(req_history) - first_placeholder
|
||||
num_to_replace = min(num_sampled_ids, num_placeholders)
|
||||
req_history[first_placeholder : first_placeholder + num_to_replace] = new_ids[:num_to_replace]
|
||||
|
||||
for index, req_history in enumerate(output_token_ids):
|
||||
if -1 in req_history:
|
||||
output_token_ids[index] = req_history[: req_history.index(-1)]
|
||||
|
||||
return output_token_ids
|
||||
|
||||
def _sampling_metadata_for_model_sampler(self, sampling_metadata):
|
||||
output_token_ids = self._build_model_sampler_output_token_ids()
|
||||
if output_token_ids == sampling_metadata.output_token_ids:
|
||||
return sampling_metadata
|
||||
return replace(sampling_metadata, output_token_ids=output_token_ids)
|
||||
|
||||
def _request_final_stage_id(self, req_id: str) -> int | None:
|
||||
info = self.model_intermediate_buffer.get(req_id)
|
||||
if not isinstance(info, dict):
|
||||
@@ -974,7 +1037,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
req_hidden_states_cpu[rid] = hidden_states[start:end].detach().to("cpu").contiguous()
|
||||
|
||||
pooler_output = []
|
||||
for rid in req_ids_output_copy:
|
||||
for out_idx, rid in enumerate(req_ids_output_copy):
|
||||
if rid not in downstream_req_id_set:
|
||||
pooler_output.append({})
|
||||
continue
|
||||
|
||||
@@ -59,10 +59,18 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Scope full-payload connector init to Qwen3-Omni: other generation
|
||||
# models (e.g. Bagel DiT) retain their pre-existing runner setup
|
||||
# so this refactor does not perturb them.
|
||||
if getattr(self.model_config, "model_arch", None) == "Qwen3OmniMoeForConditionalGeneration":
|
||||
# Mirrors the init allowlist in gpu_ar_model_runner.py.
|
||||
_OMNI_CONNECTOR_INIT_ARCHS = {
|
||||
"Qwen3OmniMoeForConditionalGeneration",
|
||||
"Qwen2_5OmniForConditionalGeneration",
|
||||
"CovoAudioForConditionalGeneration",
|
||||
"MiMoAudioModel",
|
||||
"Qwen3TTSTalkerForConditionalGeneration",
|
||||
"Qwen3TTSCode2Wav",
|
||||
"CosyVoice3Model",
|
||||
"DyninOmniForConditionalGeneration",
|
||||
}
|
||||
if getattr(self.model_config, "model_arch", None) in _OMNI_CONNECTOR_INIT_ARCHS:
|
||||
self.init_omni_connectors(
|
||||
vllm_config=self.vllm_config,
|
||||
model_config=self.model_config,
|
||||
@@ -299,7 +307,6 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
|
||||
num_tokens_padded,
|
||||
intermediate_tensors,
|
||||
)
|
||||
|
||||
# [Omni] Pass token counts per request for code2wav output slicing
|
||||
model_kwargs["seq_token_counts"] = tokens
|
||||
|
||||
|
||||
@@ -361,12 +361,15 @@ class OmniGPUModelRunner(GPUModelRunner):
|
||||
|
||||
# Remove finished requests from the cached states.
|
||||
# cleanup_finished_request lives on OmniConnectorModelRunnerMixin and
|
||||
# is only safe to call once init_omni_connectors() has populated the
|
||||
# mixin state. Archs that inherit the method via MRO without running
|
||||
# that init must be skipped, so probe a mixin-owned attribute as the
|
||||
# "state initialized" gate.
|
||||
# is only safe to call once init_omni_connectors() has finished
|
||||
# populating mixin state (it sets ``_omni_connector_initialized = True``
|
||||
# at the very end). Archs that inherit the method via MRO without
|
||||
# running that init must be skipped, so gate on the explicit flag
|
||||
# rather than probing private attribute names.
|
||||
cleanup_finished_request = (
|
||||
getattr(self, "cleanup_finished_request", None) if hasattr(self, "_request_ids_mapping") else None
|
||||
getattr(self, "cleanup_finished_request", None)
|
||||
if getattr(self, "_omni_connector_initialized", False)
|
||||
else None
|
||||
)
|
||||
for req_id in scheduler_output.finished_req_ids:
|
||||
self.requests.pop(req_id, None)
|
||||
|
||||
@@ -46,6 +46,27 @@ if TYPE_CHECKING:
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def should_accumulate_full_payload_output(model_config, custom_process_func) -> bool:
|
||||
"""Producer-side structural gate.
|
||||
|
||||
Fires iff the stage explicitly declares a downstream full-payload
|
||||
producer hook via ``custom_process_next_stage_input_func``. Consumer
|
||||
stages may have ``custom_process_input_func`` values that can be
|
||||
mechanically derived to ``*_full_payload`` helper names in the same
|
||||
module; those are intentionally not enough to make the stage a producer.
|
||||
"""
|
||||
if custom_process_func is None:
|
||||
return False
|
||||
if getattr(model_config, "async_chunk", False):
|
||||
return False
|
||||
if getattr(model_config, "final_output", False):
|
||||
return False
|
||||
next_stage_func = getattr(model_config, "custom_process_next_stage_input_func", None)
|
||||
if not isinstance(next_stage_func, str) or not next_stage_func:
|
||||
return False
|
||||
return getattr(model_config, "model_stage", None) is not None
|
||||
|
||||
|
||||
class OmniConnectorModelRunnerMixin:
|
||||
"""Unified data-plane communication mixin for Model Runners.
|
||||
|
||||
@@ -189,6 +210,13 @@ class OmniConnectorModelRunnerMixin:
|
||||
)
|
||||
self._save_thread.start()
|
||||
|
||||
# Explicit "fully initialised" marker so other parts of the runner
|
||||
# (e.g. _update_states cleanup) can branch on a stable contract
|
||||
# instead of probing for private mixin attribute names. Must be set
|
||||
# only after every field above has been bound, so a partially
|
||||
# constructed mixin is never observable as initialised.
|
||||
self._omni_connector_initialized = True
|
||||
|
||||
def shutdown_omni_connectors(self) -> None:
|
||||
"""Stop background threads and release connector resources."""
|
||||
self._stop_event.set()
|
||||
@@ -217,6 +245,30 @@ class OmniConnectorModelRunnerMixin:
|
||||
saves is added to ``_deferred_send_cleanup`` so the bg save's
|
||||
decrement path drains it without leaving orphans.
|
||||
"""
|
||||
# Force-flush any pending full-payload accumulator entry before
|
||||
# cleanup proceeds. Without this, finished requests with no
|
||||
# downstream consumer (e.g. text-only on multi-modal arch) leave
|
||||
# the entry orphaned in _pending_full_payload_send across requests,
|
||||
# which empirically destabilises subsequent thinker forwards by
|
||||
# making prefix-cache reuse observe stale accumulator state. The
|
||||
# flush is idempotent when the entry has already been flushed by the
|
||||
# scheduler-driven path, but this cleanup path runs for every request,
|
||||
# so skip it entirely when the request never accumulated a payload.
|
||||
if req_id in self._pending_full_payload_send:
|
||||
try:
|
||||
self.flush_full_payload_outputs({req_id})
|
||||
except Exception:
|
||||
# Cleanup must still proceed regardless of flush errors here --
|
||||
# we already gated on ``_omni_connector_initialized`` upstream,
|
||||
# so any exception here reflects a real connector-side issue
|
||||
# (shared memory corruption, background thread crash) worth
|
||||
# surfacing rather than silently swallowing.
|
||||
logger.warning(
|
||||
"flush_full_payload_outputs(%s) raised during cleanup; continuing tear-down.",
|
||||
req_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
ext_id = self._request_ids_mapping.pop(req_id, None)
|
||||
keys_to_clean: list[str] = [req_id]
|
||||
if ext_id is not None and ext_id != req_id:
|
||||
@@ -684,6 +736,27 @@ class OmniConnectorModelRunnerMixin:
|
||||
_custom_process_func, both of which are set at init time. Avoid
|
||||
the per-step dynamic import inside the model decode loop.
|
||||
"""
|
||||
if getattr(self, "_omni_connector", None) is None:
|
||||
# No connector at all: send_full_payload_outputs would no-op.
|
||||
# Skip the per-step accumulator+build that would otherwise be
|
||||
# silently discarded. Defends against a terminal stage whose
|
||||
# custom_process_input_func has a *_full_payload derivative in
|
||||
# the same module (e.g. dynin stage 2 token2image_to_token2audio
|
||||
# in pipelines that don't configure any connector at all).
|
||||
#
|
||||
# Known limitation: a *terminal-consumer* stage that has a
|
||||
# connector configured for receiving upstream input is NOT
|
||||
# caught here -- ``_omni_connector`` is non-None for it, and
|
||||
# ``_load_custom_func`` may still resolve a ``*_full_payload``
|
||||
# derivative from this stage's ``custom_process_input_func``.
|
||||
# In that case the accumulator builds payloads that
|
||||
# ``send_full_payload_outputs`` later drops via its own
|
||||
# connector-side checks (wasted CPU, not a functional bug).
|
||||
# A topology-aware gate (explicit producer field or pipeline
|
||||
# is_terminal info) would close the gap; that change is out
|
||||
# of scope for this PR.
|
||||
self._should_accumulate_full_payload_output_cached = False
|
||||
return False
|
||||
cached = getattr(self, "_should_accumulate_full_payload_output_cached", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
@@ -691,19 +764,12 @@ class OmniConnectorModelRunnerMixin:
|
||||
if model_config is None:
|
||||
self._should_accumulate_full_payload_output_cached = False
|
||||
return False
|
||||
if getattr(model_config, "model_arch", None) == "Qwen3OmniMoeForConditionalGeneration":
|
||||
from vllm_omni.model_executor.stage_input_processors.qwen3_omni import (
|
||||
should_accumulate_qwen3_omni_full_payload_output,
|
||||
)
|
||||
|
||||
result = should_accumulate_qwen3_omni_full_payload_output(
|
||||
model_config,
|
||||
getattr(self, "_custom_process_func", None),
|
||||
)
|
||||
self._should_accumulate_full_payload_output_cached = result
|
||||
return result
|
||||
self._should_accumulate_full_payload_output_cached = False
|
||||
return False
|
||||
result = should_accumulate_full_payload_output(
|
||||
model_config,
|
||||
getattr(self, "_custom_process_func", None),
|
||||
)
|
||||
self._should_accumulate_full_payload_output_cached = result
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _new_full_payload_accumulator(output: dict[str, Any]):
|
||||
@@ -729,6 +795,58 @@ class OmniConnectorModelRunnerMixin:
|
||||
output[k] = tensors[0] if len(tensors) == 1 else torch.cat(tensors, dim=0)
|
||||
return output, request
|
||||
|
||||
def _resolve_full_payload_replace_keys(self) -> frozenset:
|
||||
"""Per-model REPLACE-key set for the full-payload accumulator.
|
||||
|
||||
Looked up from the stage-input-processor module that ships the model's sync builder
|
||||
(`model_config.custom_process_input_func.__module__`). The module
|
||||
declares ``_FULL_PAYLOAD_REPLACE_KEYS: frozenset[str]``; if absent,
|
||||
returns the empty set.
|
||||
|
||||
Cached per instance. Keys in this set use REPLACE semantics in the
|
||||
accumulator (subsequent emissions discard prior chunks) instead of
|
||||
the default CONCAT semantics. Use for tensors that carry the full
|
||||
result so far rather than per-step deltas (e.g. ``model_outputs``).
|
||||
"""
|
||||
cached = getattr(self, "_full_payload_replace_keys_cached", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
proc = getattr(self, "_custom_process_func", None)
|
||||
if proc is None:
|
||||
self._full_payload_replace_keys_cached = frozenset()
|
||||
return self._full_payload_replace_keys_cached
|
||||
module_name = getattr(proc, "__module__", None)
|
||||
if module_name is None:
|
||||
self._full_payload_replace_keys_cached = frozenset()
|
||||
return self._full_payload_replace_keys_cached
|
||||
try:
|
||||
import sys as _sys
|
||||
|
||||
mod = _sys.modules.get(module_name) or importlib.import_module(module_name)
|
||||
keys = getattr(mod, "_FULL_PAYLOAD_REPLACE_KEYS", frozenset())
|
||||
except ImportError:
|
||||
logger.debug(
|
||||
"Could not import stage input processor module %s while resolving "
|
||||
"_FULL_PAYLOAD_REPLACE_KEYS; using CONCAT semantics for all keys.",
|
||||
module_name,
|
||||
exc_info=True,
|
||||
)
|
||||
keys = frozenset()
|
||||
if not isinstance(keys, (frozenset, set)):
|
||||
logger.debug(
|
||||
"Ignoring non-set _FULL_PAYLOAD_REPLACE_KEYS from %s: %s",
|
||||
module_name,
|
||||
type(keys).__name__,
|
||||
)
|
||||
keys = frozenset()
|
||||
self._full_payload_replace_keys_cached = frozenset(keys)
|
||||
logger.debug(
|
||||
"Resolved _FULL_PAYLOAD_REPLACE_KEYS for %s: %s",
|
||||
module_name,
|
||||
sorted(self._full_payload_replace_keys_cached),
|
||||
)
|
||||
return self._full_payload_replace_keys_cached
|
||||
|
||||
def accumulate_full_payload_output(
|
||||
self,
|
||||
req_id: str,
|
||||
@@ -751,6 +869,7 @@ class OmniConnectorModelRunnerMixin:
|
||||
The data is actually sent when ``flush_full_payload_outputs`` is called
|
||||
with the finished request IDs from the next scheduler cycle.
|
||||
"""
|
||||
replace_keys = self._resolve_full_payload_replace_keys()
|
||||
existing = self._pending_full_payload_send.get(req_id)
|
||||
|
||||
if existing is None:
|
||||
@@ -766,6 +885,19 @@ class OmniConnectorModelRunnerMixin:
|
||||
for k, v in pooler_output.items():
|
||||
if v is None:
|
||||
continue
|
||||
if k in replace_keys:
|
||||
# Explicit REPLACE semantics: the new value supersedes any
|
||||
# prior chunks (e.g. `model_outputs` carries the full result
|
||||
# so far, not an appendable per-step delta).
|
||||
latest.pop(k, None)
|
||||
if isinstance(v, torch.Tensor) and v.dim() >= 2:
|
||||
chunks[k] = [v]
|
||||
rows[k] = int(v.shape[0])
|
||||
else:
|
||||
chunks.pop(k, None)
|
||||
rows.pop(k, None)
|
||||
latest[k] = v
|
||||
continue
|
||||
if isinstance(v, torch.Tensor) and v.dim() >= 2:
|
||||
if k in chunks and chunks[k] and v.shape[1:] == chunks[k][0].shape[1:]:
|
||||
chunks[k].append(v)
|
||||
@@ -783,6 +915,10 @@ class OmniConnectorModelRunnerMixin:
|
||||
|
||||
def flush_full_payload_outputs(self, finished_req_ids: set[str]) -> None:
|
||||
"""Send accumulated full_payload outputs for requests that just finished."""
|
||||
pending_req_ids = set(self._pending_full_payload_send.keys())
|
||||
if not (finished_req_ids & pending_req_ids):
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"[Stage-%s] flush_full_payload_outputs: finished_req_ids=%s, pending=%s",
|
||||
self._stage_id,
|
||||
@@ -927,11 +1063,11 @@ class OmniConnectorModelRunnerMixin:
|
||||
if self._stage_id == 0:
|
||||
return
|
||||
request_id = request.request_id
|
||||
self._request_ids_mapping[request_id] = getattr(
|
||||
request,
|
||||
"external_req_id",
|
||||
request_id,
|
||||
)
|
||||
# Explicit external_req_id=None must fall back to request_id;
|
||||
# otherwise recv keys become `None_<stage>_<chunk>` and collide
|
||||
# across requests.
|
||||
ext = getattr(request, "external_req_id", None)
|
||||
self._request_ids_mapping[request_id] = ext if ext is not None else request_id
|
||||
with self._lock:
|
||||
if request_id in self._stage_recv_req_ids:
|
||||
return
|
||||
@@ -2115,7 +2251,10 @@ class OmniConnectorModelRunnerMixin:
|
||||
if mapped is not None:
|
||||
return mapped
|
||||
if request is not None:
|
||||
return getattr(request, "external_req_id", fallback_req_id)
|
||||
# external_req_id may be explicitly None; fall back.
|
||||
ext = getattr(request, "external_req_id", None)
|
||||
if ext is not None:
|
||||
return ext
|
||||
return fallback_req_id
|
||||
|
||||
def _resolve_next_stage_id(self, model_config: Any) -> int:
|
||||
|
||||
Reference in new issue
Block a user