[Bugfix][MiniCPM-o] Bound full-duplex Talker context with sliding recompute (#6626)

Signed-off-by: natureofnature <wzliu@connect.hku.hk>
This commit is contained in:
NATURE authored and GitHub committed 2026-08-30 00:49:42 +08:00
1 parent 3adee400a0
commit 87736a3cc5
14 files changed
+1389 -72

No files matched your search

@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Unit coverage for the two stage-input timeout hooks.
``_process_pending_input_timeouts`` covers the full-payload transport;
@@ -253,6 +253,38 @@ def test_log_failed_chunk_sends_noop_without_adapter():
scheduler._log_failed_chunk_sends()
class _FakeReceiveFailureAdapter:
def __init__(self, failures):
self._failures = dict(failures)
def collect_failed_receive_request_ids(self):
failures, self._failures = self._failures, {}
return failures
class _FakeReceiveFailureScheduler(OmniSchedulerMixin):
def __init__(self, requests, adapter):
self.requests = requests
self.chunk_transfer_adapter = adapter
self.finish_calls = []
def finish_requests(self, req_ids, status):
self.finish_calls.append((set(req_ids), status))
def test_process_chunk_receive_failures_delegates_to_finish_requests():
request_id = "invalid-chunk"
adapter = _FakeReceiveFailureAdapter({request_id: "prompt exceeds limit"})
scheduler = _FakeReceiveFailureScheduler({request_id: SimpleNamespace(request_id=request_id)}, adapter)
scheduler._process_chunk_receive_failures()
assert len(scheduler.finish_calls) == 1
finished_ids, status = scheduler.finish_calls[0]
assert finished_ids == {request_id}
assert getattr(status, "name", str(status)).endswith("FINISHED_ERROR")
# --- R1.4: VLLM_OMNI_INPUT_WAIT_TIMEOUT_S is parsed at import time, so each of
# these reloads the module under a different environment. One knob now gates the
# deadline on both transports, which is why a silent 0 matters more than it did
@@ -57,6 +57,19 @@ def _req(req_id: str, status: RequestStatus, external_req_id: str | None = None)
return request
def _streaming_request(mocker: MockerFixture, num_computed_tokens: int) -> SimpleNamespace:
prompt_token_ids = [0] * num_computed_tokens
return SimpleNamespace(
_all_token_ids=prompt_token_ids.copy(),
_output_token_ids=[],
prompt_token_ids=prompt_token_ids,
num_computed_tokens=num_computed_tokens,
num_prompt_tokens=num_computed_tokens,
num_output_placeholders=0,
update_block_hashes=mocker.Mock(),
)
def test_streaming_payload_can_replace_placeholder_prompt(mocker: MockerFixture) -> None:
request = SimpleNamespace(
_all_token_ids=[0, 0, 7, 8],
@@ -84,30 +97,219 @@ def test_streaming_payload_can_replace_placeholder_prompt(mocker: MockerFixture)
request.update_block_hashes.assert_called_once_with()
def test_streaming_payload_can_append_exact_prompt_length(mocker: MockerFixture) -> None:
def test_turn_start_replacement_ignores_accumulated_prompt_capacity(mocker: MockerFixture) -> None:
request = _streaming_request(mocker, num_computed_tokens=4064)
payload = {
"ids": {"prompt": [1]},
"meta": {
"replace_streaming_prompt": True,
"next_stage_prompt_len": 10,
"next_stage_generation_tokens": 26,
},
}
replaced = construct_next_stage_streaming_input_prompt(
payload,
request,
max_model_len=4096,
previous_condition_len=10,
previous_condition_seq=7,
condition_seq=8,
recompute_previous_chunks=1,
)
assert replaced is True
assert request.prompt_token_ids == [0] * 10
assert payload["meta"]["streaming_prompt_recompute"] is False
assert "streaming_prompt_previous_codes" not in payload["ids"]
@pytest.mark.parametrize("reserve", [True, 0, -1, 1.5, "26", None])
def test_streaming_prompt_rejects_invalid_generation_reserve(
mocker: MockerFixture,
reserve: object,
) -> None:
request = _streaming_request(mocker, num_computed_tokens=10)
payload = {
"ids": {"prompt": [1]},
"meta": {
"next_stage_prompt_len": 10,
"next_stage_generation_tokens": reserve,
},
}
with pytest.raises(ValueError, match="next_stage_generation_tokens must be a positive integer"):
construct_next_stage_streaming_input_prompt(payload, request, max_model_len=4096)
def test_capacity_managed_streaming_prompt_at_limit_still_appends(mocker: MockerFixture) -> None:
request = _streaming_request(mocker, num_computed_tokens=4060)
payload = {
"ids": {"prompt": [1]},
"meta": {
"next_stage_prompt_len": 10,
"next_stage_generation_tokens": 26,
},
}
replaced = construct_next_stage_streaming_input_prompt(payload, request, max_model_len=4096)
assert replaced is False
assert request.num_computed_tokens == 4060
assert request.num_prompt_tokens == 4070
def test_streaming_window_builds_one_chunk_recompute_recipe(mocker: MockerFixture) -> None:
previous_condition_len = 10
previous_codes = list(range(25))
request = SimpleNamespace(
_all_token_ids=[0, 0, 7, 8],
_output_token_ids=[7, 8],
prompt_token_ids=[0, 0],
num_computed_tokens=4,
num_prompt_tokens=2,
_all_token_ids=[0] * 4039 + previous_codes + [999],
_output_token_ids=previous_codes + [999],
prompt_token_ids=[0] * 4039,
num_computed_tokens=4065,
num_prompt_tokens=4039,
num_output_placeholders=1,
update_block_hashes=mocker.Mock(),
)
payload = {
"ids": {"prompt": [1, 2, 3]},
"meta": {"next_stage_prompt_len": 3},
"ids": {"prompt": [1]},
"meta": {
"next_stage_prompt_len": 10,
"next_stage_generation_tokens": 26,
},
}
construct_next_stage_streaming_input_prompt(payload, request)
replaced = construct_next_stage_streaming_input_prompt(
payload,
request,
max_model_len=4096,
previous_condition_len=previous_condition_len,
previous_condition_seq=7,
condition_seq=8,
recompute_previous_chunks=1,
)
assert request.prompt_token_ids == [0, 0, 7, 8, 0, 0, 0]
assert request._all_token_ids == [0, 0, 7, 8, 0, 0, 0]
assert request._output_token_ids == []
assert request.num_computed_tokens == 4
assert request.num_prompt_tokens == 7
assert replaced is True
assert request.prompt_token_ids == [0] * 45
assert request.num_prompt_tokens == 45
assert request.num_computed_tokens == 0
assert payload["ids"]["streaming_prompt_previous_codes"] == previous_codes
assert payload["meta"] == {
"next_stage_prompt_len": 10,
"next_stage_generation_tokens": 26,
"streaming_prompt_recompute": True,
"streaming_condition_seq": 8,
}
request.update_block_hashes.assert_called_once_with()
def test_streaming_window_recomputes_each_condition_without_accumulating(mocker: MockerFixture) -> None:
request = SimpleNamespace(
_all_token_ids=[0] * 10 + [101, 102],
_output_token_ids=[101, 102],
prompt_token_ids=[0] * 10,
num_computed_tokens=12,
num_prompt_tokens=10,
num_output_placeholders=0,
update_block_hashes=mocker.Mock(),
)
second = {
"ids": {"prompt": [1]},
"meta": {
"next_stage_prompt_len": 12,
"next_stage_generation_tokens": 26,
},
}
assert construct_next_stage_streaming_input_prompt(
second,
request,
max_model_len=4096,
previous_condition_len=10,
previous_condition_seq=0,
condition_seq=1,
recompute_previous_chunks=1,
)
assert request.num_prompt_tokens == 24
assert second["ids"]["streaming_prompt_previous_codes"] == [101, 102]
request._all_token_ids.extend([201, 202, 203])
request._output_token_ids.extend([201, 202, 203])
request.num_computed_tokens = 27
third = {
"ids": {"prompt": [2]},
"meta": {
"next_stage_prompt_len": 8,
"next_stage_generation_tokens": 26,
},
}
assert construct_next_stage_streaming_input_prompt(
third,
request,
max_model_len=4096,
previous_condition_len=12,
previous_condition_seq=1,
condition_seq=2,
recompute_previous_chunks=1,
)
assert request.num_prompt_tokens == 23
assert third["ids"]["streaming_prompt_previous_codes"] == [201, 202, 203]
assert third["meta"]["streaming_condition_seq"] == 2
def test_capacity_rollover_requires_explicit_window_contract(mocker: MockerFixture) -> None:
request = _streaming_request(mocker, num_computed_tokens=4064)
payload = {
"ids": {"prompt": [1]},
"meta": {
"next_stage_prompt_len": 10,
"next_stage_generation_tokens": 26,
},
}
with pytest.raises(ValueError, match="requires window_size=1"):
construct_next_stage_streaming_input_prompt(
payload,
request,
max_model_len=4096,
previous_condition_len=10,
previous_condition_seq=0,
condition_seq=1,
)
@pytest.mark.parametrize("next_stage_prompt_len", [0, -1])
def test_capacity_managed_streaming_prompt_rejects_nonpositive_length(
mocker: MockerFixture, next_stage_prompt_len: int
) -> None:
request = _streaming_request(mocker, num_computed_tokens=0)
payload = {
"ids": {"prompt": [1]},
"meta": {
"next_stage_prompt_len": next_stage_prompt_len,
"next_stage_generation_tokens": 26,
},
}
with pytest.raises(ValueError, match="positive next_stage_prompt_len"):
construct_next_stage_streaming_input_prompt(payload, request, max_model_len=4096)
def test_capacity_managed_streaming_prompt_rejects_fresh_overflow(mocker: MockerFixture) -> None:
request = _streaming_request(mocker, num_computed_tokens=0)
payload = {
"ids": {"prompt": [1]},
"meta": {
"next_stage_prompt_len": 4071,
"next_stage_generation_tokens": 26,
},
}
with pytest.raises(ValueError, match="exceeds max_model_len"):
construct_next_stage_streaming_input_prompt(payload, request, max_model_len=4096)
@pytest.fixture
def build_adapter(monkeypatch, mocker: MockerFixture):
def _build(
@@ -115,6 +317,10 @@ def build_adapter(monkeypatch, mocker: MockerFixture):
stage_id: int = 1,
model_mode: str = "ar",
max_num_seqs: int = 2,
max_model_len: int = 0,
tts_max_model_len: int = 0,
tts_attention_type: str = "full_attention",
flat_tts_config: bool = False,
active_stream_window: int = 0,
connector_extra: dict | None = None,
):
@@ -131,6 +337,10 @@ def build_adapter(monkeypatch, mocker: MockerFixture):
self._cancelled_load_reqs = set()
self._pending_save_reqs = deque()
self._finished_save_reqs = set()
self._send_failures = {}
self._send_failure_lock = threading.Lock()
self._receive_failures = {}
self._receive_failure_lock = threading.Lock()
self.stop_event = threading.Event()
self._recv_cond = threading.Condition()
self._save_cond = threading.Condition()
@@ -142,9 +352,23 @@ def build_adapter(monkeypatch, mocker: MockerFixture):
classmethod(lambda cls, _model_config: connector),
)
hf_config = SimpleNamespace(
model_type="minicpmtts",
max_position_embeddings=tts_max_model_len,
attention_type=tts_attention_type,
)
if not flat_tts_config:
hf_config = SimpleNamespace(
tts_config=SimpleNamespace(
max_position_embeddings=tts_max_model_len,
attention_type=tts_attention_type,
)
)
model_config = SimpleNamespace(
worker_type=model_mode,
max_num_seqs=max_num_seqs,
max_model_len=max_model_len,
hf_config=hf_config,
active_stream_window=active_stream_window,
stage_connector_config={
"name": "SharedMemoryConnector",
@@ -160,6 +384,27 @@ def build_adapter(monkeypatch, mocker: MockerFixture):
return _build
def _dequeue_load_entry(adapter, request):
"""Register and take the entry as the background recv loop would."""
adapter.load_async(request)
entry = adapter._registered_load_entries[request.request_id]
try:
adapter._pending_load_reqs.remove(entry)
except ValueError:
pass
return entry
@pytest.mark.parametrize(
("attention_type", "expected_previous_chunks"),
[("full_attention", 0), ("sliding_recompute", 1)],
)
def test_talker_attention_policy_controls_streaming_recompute(build_adapter, attention_type, expected_previous_chunks):
adapter, _ = build_adapter(tts_attention_type=attention_type)
assert adapter._streaming_prompt_previous_chunks == expected_previous_chunks
@pytest.mark.parametrize(
("raw_cfg", "expected_name", "expected_extra"),
[
@@ -200,15 +445,78 @@ def test_load_poll(build_adapter):
"meta": {"finished": torch.tensor(True, dtype=torch.bool)},
}
connector.get.return_value = (payload, 16)
adapter._poll_single_request(request)
adapter._poll_single_request(_dequeue_load_entry(adapter, request))
assert request.additional_information is None
assert "req-1" in adapter._finished_load_reqs
request.status = RequestStatus.WAITING_FOR_CHUNK
adapter.process_pending_chunks(
DummyWaitingQueue(),
[request],
scheduler_requests={request.request_id: request},
)
assert request.additional_information == payload
assert adapter.get_req_chunk["req-1"] == 1
assert "req-1" in adapter._finished_load_reqs
assert "req-1" not in adapter._finished_load_reqs
assert "req-1" in adapter.upstream_exhausted_requests
assert "req-1" not in adapter._pending_load_reqs
def test_load_async_does_not_requeue_registered_inflight_request(build_adapter):
adapter, connector = build_adapter(stage_id=1, model_mode="ar")
request = _req("req-inflight", RequestStatus.RUNNING, external_req_id="ext-inflight")
entry = _dequeue_load_entry(adapter, request)
# The recv loop owns the popped request until polling completes. A
# scheduler-side duplicate registration must not append a second copy.
adapter.load_async(request)
assert adapter._pending_load_reqs == deque()
connector.get.return_value = (
{
"ids": {"prompt": [1]},
"meta": {"finished": True},
},
1,
)
assert adapter._poll_single_request(entry) is True
# Successful polling releases the registration for the next chunk.
adapter.load_async(request)
assert [queued.request for queued in adapter._pending_load_reqs] == [request]
def test_cleanup_then_reregister_drops_stale_queued_entry(build_adapter):
adapter, connector = build_adapter(stage_id=1, model_mode="ar")
request = _req("req-resume", RequestStatus.WAITING, external_req_id="ext-resume")
adapter.load_async(request)
stale_entry = adapter._pending_load_reqs[0]
adapter.cleanup_receiver(request.request_id)
adapter.load_async(request)
fresh_entry = adapter._pending_load_reqs[-1]
assert len(adapter._pending_load_reqs) == 2
assert adapter._pending_load_reqs.popleft() is stale_entry
assert adapter._pending_load_reqs.popleft() is fresh_entry
connector.get.return_value = (
{
"ids": {"prompt": [1]},
"meta": {"finished": True},
},
1,
)
assert adapter._poll_single_request(stale_entry) is True
connector.get.assert_not_called()
assert adapter._registered_load_entries[request.request_id] is fresh_entry
assert adapter._poll_single_request(fresh_entry) is True
connector.get.assert_called_once()
assert request.request_id not in adapter._registered_load_entries
assert adapter.get_req_chunk[request.request_id] == 1
def test_load_poll_ar_requeues_explicitly_replaced_running_prompt(build_adapter):
adapter, connector = build_adapter(stage_id=1, model_mode="ar")
request = _req("req-replace", RequestStatus.RUNNING, external_req_id="external-replace")
@@ -234,15 +542,18 @@ def test_load_poll_ar_requeues_explicitly_replaced_running_prompt(build_adapter)
1,
)
assert adapter._poll_single_request(request) is True
assert request.num_computed_tokens == 0
assert request.prompt_token_ids == [0] * 10
assert request.request_id in adapter.replaced_streaming_prompt_ids
assert adapter._poll_single_request(_dequeue_load_entry(adapter, request)) is True
request.status = RequestStatus.WAITING_FOR_CHUNK
running_queue = [request]
waiting_queue = DummyWaitingQueue()
adapter.process_pending_chunks(waiting_queue, running_queue)
adapter.process_pending_chunks(
waiting_queue,
running_queue,
scheduler_requests={request.request_id: request},
)
assert request.num_computed_tokens == 0
assert request.prompt_token_ids == [0] * 10
assert request.request_id in adapter.replaced_streaming_prompt_ids
assert running_queue == []
assert waiting_queue == [request]
assert request.status == RequestStatus.WAITING
@@ -258,6 +569,134 @@ def test_load_poll_ar_requeues_explicitly_replaced_running_prompt(build_adapter)
assert request.external_req_id not in adapter.requests_num_chunks_sent
@pytest.mark.parametrize("flat_tts_config", [False, True])
def test_load_poll_ar_recomputes_native_duplex_prompt_each_condition(build_adapter, flat_tts_config):
adapter, connector = build_adapter(
stage_id=1,
model_mode="ar",
max_model_len=8192,
tts_max_model_len=4096,
tts_attention_type="sliding_recompute",
flat_tts_config=flat_tts_config,
)
request = _req("req-rollover", RequestStatus.RUNNING, external_req_id="external-rollover")
request.resumable = True
previous_codes = list(range(25))
request.prompt_token_ids = [0] * 10
request._all_token_ids = [0] * 10 + previous_codes
request._output_token_ids = previous_codes.copy()
request.num_prompt_tokens = 10
request.num_computed_tokens = 35
request.num_output_placeholders = 0
request.update_block_hashes = Mock()
adapter.get_req_chunk[request.request_id] = 1
adapter.request_ids_mapping[request.request_id] = request.external_req_id
adapter._streaming_condition_lengths[request.request_id] = 10
adapter._streaming_condition_seqs[request.request_id] = 0
connector.get.return_value = (
{
"ids": {"prompt": [1]},
"meta": {
"next_stage_prompt_len": 10,
"next_stage_generation_tokens": 26,
"finished": True,
},
},
1,
)
assert adapter._poll_single_request(_dequeue_load_entry(adapter, request)) is True
request.status = RequestStatus.WAITING_FOR_CHUNK
running_queue = [request]
waiting_queue = DummyWaitingQueue()
adapter.process_pending_chunks(
waiting_queue,
running_queue,
scheduler_requests={request.request_id: request},
)
assert request.num_computed_tokens == 0
assert request.prompt_token_ids == [0] * 45
assert request.additional_information["ids"]["streaming_prompt_previous_codes"] == previous_codes
assert request.additional_information["meta"]["streaming_prompt_recompute"] is True
assert request.resumable is False
assert request.request_id in adapter.replaced_streaming_prompt_ids
assert adapter._max_model_len == 4096
assert running_queue == []
assert waiting_queue == [request]
def test_window_condition_sequence_ignores_control_only_chunks(build_adapter) -> None:
adapter, connector = build_adapter(
stage_id=1,
model_mode="ar",
tts_attention_type="sliding_recompute",
)
request = _req("req-condition-seq", RequestStatus.RUNNING)
def receive(payload):
connector.get.return_value = (payload, 1)
assert adapter._poll_single_request(_dequeue_load_entry(adapter, request)) is True
adapter.requests_with_ready_chunks.add(request.request_id)
adapter._apply_pending_ar_prompt_updates({request.request_id: request})
adapter.requests_with_ready_chunks.discard(request.request_id)
return request.additional_information
first = receive(
{
"ids": {"prompt": [1]},
"meta": {"next_stage_prompt_len": 10},
}
)
assert first["meta"]["streaming_condition_seq"] == 0
assert adapter._streaming_condition_seqs[request.request_id] == 0
control = receive({"meta": {"is_segment_finished": True}})
assert "streaming_condition_seq" not in control["meta"]
assert adapter._streaming_condition_seqs[request.request_id] == 0
second = receive(
{
"ids": {"prompt": [2]},
"meta": {"next_stage_prompt_len": 12},
}
)
assert second["meta"]["streaming_condition_seq"] == 1
assert adapter._streaming_condition_lengths[request.request_id] == 12
def test_load_poll_ar_reports_invalid_capacity_metadata(build_adapter):
adapter, connector = build_adapter(stage_id=1, model_mode="ar", max_model_len=4096)
request = _req("req-invalid-capacity", RequestStatus.RUNNING)
request.resumable = True
adapter.get_req_chunk[request.request_id] = 1
connector.get.return_value = (
{
"ids": {"prompt": [1]},
"meta": {
"next_stage_prompt_len": 4096,
"next_stage_generation_tokens": 26,
"finished": False,
},
},
1,
)
assert adapter._poll_single_request(_dequeue_load_entry(adapter, request)) is True
request.status = RequestStatus.WAITING_FOR_CHUNK
running_queue = [request]
adapter.process_pending_chunks(
DummyWaitingQueue(),
running_queue,
scheduler_requests={request.request_id: request},
)
assert adapter.collect_failed_receive_request_ids() == {
request.request_id: "fresh streaming prompt plus generation reserve exceeds max_model_len: "
"prompt=4096, reserve=26, limit=4096"
}
assert request.request_id not in adapter._finished_load_reqs
def test_load_poll_generation_tensor_codes_use_placeholder_prompt(build_adapter):
adapter, connector = build_adapter(stage_id=1, model_mode="generation")
request = _req("req-tensor", RequestStatus.WAITING, external_req_id="external-tensor")
@@ -272,7 +711,7 @@ def test_load_poll_generation_tensor_codes_use_placeholder_prompt(build_adapter)
}
connector.get.return_value = (payload, 16)
adapter._poll_single_request(request)
adapter._poll_single_request(_dequeue_load_entry(adapter, request))
assert request.prompt_token_ids == [0]
assert request.num_computed_tokens == 0
@@ -302,12 +741,13 @@ def test_load_poll_generation_empty_nonterminal_chunk_keeps_polling(build_adapte
}
connector.get.side_effect = [(empty_payload, 16), (ready_payload, 16)]
assert adapter._poll_single_request(request) is False
entry = _dequeue_load_entry(adapter, request)
assert adapter._poll_single_request(entry) is False
assert request.request_id not in adapter._finished_load_reqs
assert request.request_id not in adapter.requests_with_ready_chunks
assert adapter.get_req_chunk[request.request_id] == 1
assert adapter._poll_single_request(request) is True
assert adapter._poll_single_request(entry) is True
assert request.request_id in adapter._finished_load_reqs
assert torch.equal(request.additional_information["codes"]["audio"], ready_payload["codes"]["audio"])
assert adapter.get_req_chunk[request.request_id] == 2
@@ -701,6 +1141,77 @@ def test_send_single_request_cleans_up_after_finished_payload(build_adapter, mon
assert args[1] == "ext-finished"
def test_old_turn_terminal_send_preserves_new_turn_state_and_sender(build_adapter):
adapter, connector = build_adapter(
stage_id=1,
model_mode="ar",
max_model_len=4096,
tts_attention_type="sliding_recompute",
)
request = _req("req-live", RequestStatus.RUNNING, external_req_id="ext-live")
request.resumable = True
request._all_token_ids = []
request._output_token_ids = []
request.update_block_hashes = Mock()
adapter.custom_process_next_stage_input_func = lambda **kwargs: OmniPayloadStruct(
meta=MetaStruct(
finished=torch.tensor(True, dtype=torch.bool),
codec_streaming=True,
)
)
put_started = threading.Event()
release_put = threading.Event()
put_keys = []
def blocking_first_put(*_args, **kwargs):
put_keys.append(kwargs["put_key"])
if len(put_keys) == 1:
put_started.set()
assert release_put.wait(timeout=5)
return True, 1, {}
connector.put.side_effect = blocking_first_put
adapter.save_async(multimodal_output=None, request=request)
first_task = adapter._pending_save_reqs.popleft()
first_sender_token = first_task["sender_token"]
send_thread = threading.Thread(target=lambda: adapter._send_single_request(first_task))
send_thread.start()
assert put_started.wait(timeout=5)
entry = _dequeue_load_entry(adapter, request)
connector.get.return_value = (
{
"ids": {"prompt": [1]},
"meta": {
"replace_streaming_prompt": True,
"next_stage_prompt_len": 10,
"next_stage_generation_tokens": 26,
},
},
1,
)
assert adapter._poll_single_request(entry) is True
adapter.requests_with_ready_chunks.add(request.request_id)
adapter._apply_pending_ar_prompt_updates({request.request_id: request})
assert adapter._streaming_condition_seqs[request.request_id] == 0
request.num_computed_tokens = 1
adapter.save_async(multimodal_output=None, request=request)
second_task = adapter._pending_save_reqs.popleft()
assert second_task["sender_token"] is first_sender_token
release_put.set()
send_thread.join(timeout=5)
assert not send_thread.is_alive()
assert adapter._streaming_condition_seqs[request.request_id] == 0
assert adapter._sender_tokens[request.external_req_id] is first_sender_token
assert first_sender_token.cancelled is False
adapter._send_single_request(second_task)
assert put_keys == ["ext-live_1_0", "ext-live_1_1"]
def test_load_poll_non_ar_merges_into_existing_additional_information(build_adapter):
adapter, connector = build_adapter(stage_id=2, model_mode="diffusion")
request = _req("req-non-ar", RequestStatus.WAITING, external_req_id="ext-non-ar")
@@ -720,7 +1231,7 @@ def test_load_poll_non_ar_merges_into_existing_additional_information(build_adap
}
connector.get.return_value = (payload, 8)
assert adapter._poll_single_request(request) is True
assert adapter._poll_single_request(_dequeue_load_entry(adapter, request)) is True
assert request.prompt_token_ids == [7, 8]
assert request.num_computed_tokens == 0
@@ -761,7 +1272,7 @@ def test_load_poll_generation_segment_marker_replaces_previous_chunk(build_adapt
1,
)
assert adapter._poll_single_request(request) is True
assert adapter._poll_single_request(_dequeue_load_entry(adapter, request)) is True
assert request.prompt_token_ids == [7, 8]
assert "audio" not in request.additional_information["codes"]
@@ -790,7 +1301,7 @@ def test_load_poll_generation_empty_replacement_snapshot_is_ready(build_adapter)
1,
)
assert adapter._poll_single_request(request) is True
assert adapter._poll_single_request(_dequeue_load_entry(adapter, request)) is True
assert request.prompt_token_ids == [0]
assert "codes" not in request.additional_information
@@ -816,7 +1327,7 @@ def test_load_poll_generation_without_snapshot_marker_keeps_incremental_state(bu
1,
)
assert adapter._poll_single_request(request) is False
assert adapter._poll_single_request(_dequeue_load_entry(adapter, request)) is False
assert torch.equal(request.additional_information["codes"]["audio"], torch.tensor([1, 2]))
assert request.additional_information["meta"]["cache_epoch"] == 3
@@ -840,9 +1351,17 @@ def test_load_poll_ar_request_additional_information_concats_tensors(build_adapt
}
connector.get.return_value = (payload, 8)
adapter._poll_single_request(request)
adapter._poll_single_request(_dequeue_load_entry(adapter, request))
# AR mode now forwards the latest payload directly.
# The recv thread records the latest payload; scheduler-side queue
# restoration publishes it onto the Request.
assert torch.equal(request.additional_information["hidden_states"]["output"], torch.tensor([[1.0]]))
request.status = RequestStatus.WAITING_FOR_CHUNK
adapter.process_pending_chunks(
DummyWaitingQueue(),
[request],
scheduler_requests={request.request_id: request},
)
assert request.additional_information == payload
assert request.additional_information["meta"]["finished"].item() is True
@@ -860,7 +1379,7 @@ def test_non_ar_poll_reinitializes_prefill_stats_for_later_chunks(build_adapter)
},
8,
)
assert adapter._poll_single_request(request) is True
assert adapter._poll_single_request(_dequeue_load_entry(adapter, request)) is True
OmniGenerationScheduler._record_prefill_stats(request)
first_chunk_stats = request.prefill_stats
request.prefill_stats = None
@@ -872,7 +1391,7 @@ def test_non_ar_poll_reinitializes_prefill_stats_for_later_chunks(build_adapter)
},
8,
)
assert adapter._poll_single_request(request) is True
assert adapter._poll_single_request(_dequeue_load_entry(adapter, request)) is True
assert isinstance(request.prefill_stats, PrefillStats)
OmniGenerationScheduler._record_prefill_stats(request)
second_chunk_stats = request.prefill_stats
@@ -971,7 +1490,7 @@ def test_non_active_waiting_request_is_held_off_scheduler(build_adapter):
assert waiting_queue == []
assert active.status == RequestStatus.WAITING_FOR_CHUNK
assert non_active.status == RequestStatus.WAITING
assert list(adapter._pending_load_reqs) == [active]
assert [entry.request for entry in adapter._pending_load_reqs] == [active]
assert list(adapter.waiting_for_chunk_waiting_requests) == [active, non_active]
@@ -1122,8 +1641,19 @@ def _populate_adapter_state(adapter, req_id="req-1", ext_id="ext-1"):
adapter.get_req_chunk[req_id] = 3
adapter.requests_with_ready_chunks.add(req_id)
adapter.request_ids_mapping[req_id] = ext_id
adapter._pending_load_reqs.append(SimpleNamespace(request_id=req_id))
entry = SimpleNamespace(request_id=req_id)
adapter._pending_load_reqs.append(entry)
adapter._registered_load_entries[req_id] = entry
adapter._finished_load_reqs.add(req_id)
adapter._pending_ar_prompt_updates[req_id] = (
SimpleNamespace(request_id=req_id),
{},
False,
False,
False,
)
adapter._streaming_condition_lengths[req_id] = 10
adapter._streaming_condition_seqs[req_id] = 2
adapter.put_req_chunk[ext_id] = 5
adapter.request_payload[ext_id] = {"hidden": [1, 2]}
@@ -1145,12 +1675,71 @@ def test_cleanup_clears_all_state(build_adapter):
assert req_id not in adapter.request_ids_mapping
assert req_id in adapter._cancelled_load_reqs
assert req_id not in adapter._finished_load_reqs
assert req_id not in adapter._pending_ar_prompt_updates
assert req_id not in adapter._streaming_condition_lengths
assert req_id not in adapter._streaming_condition_seqs
assert req_id not in adapter._registered_load_entries
assert ext_id not in adapter.put_req_chunk
assert ext_id not in adapter.request_payload
assert ext_id not in adapter.code_prompt_token_ids
def test_cleanup_and_reregister_discards_inflight_old_segment_chunk(build_adapter):
"""A late old-segment chunk cannot overwrite a resumed registration."""
adapter, connector = build_adapter(stage_id=1, model_mode="ar")
request = _req("req-late", RequestStatus.RUNNING, external_req_id="ext-late")
request.resumable = True
entry = _dequeue_load_entry(adapter, request)
get_started = threading.Event()
release_get = threading.Event()
def blocking_get(*_args):
get_started.set()
assert release_get.wait(timeout=5)
return (
{
"ids": {"prompt": [1]},
"meta": {
"next_stage_prompt_len": 10,
},
},
1,
)
connector.get.side_effect = blocking_get
poll_results = []
poll_thread = threading.Thread(target=lambda: poll_results.append(adapter._poll_single_request(entry)))
poll_thread.start()
assert get_started.wait(timeout=5)
adapter.cleanup_receiver(request.request_id)
fresh_entry = _dequeue_load_entry(adapter, request)
release_get.set()
poll_thread.join(timeout=5)
assert not poll_thread.is_alive()
assert poll_results == [True]
assert request.additional_information is None
assert request.request_id not in adapter._pending_ar_prompt_updates
assert request.request_id not in adapter._streaming_condition_seqs
assert adapter.get_req_chunk[request.request_id] == 0
assert adapter._registered_load_entries[request.request_id] is fresh_entry
connector.get.side_effect = None
connector.get.return_value = (
{
"ids": {"prompt": [2]},
"meta": {"finished": True},
},
1,
)
assert adapter._poll_single_request(fresh_entry) is True
assert adapter.get_req_chunk[request.request_id] == 1
assert request.request_id not in adapter._registered_load_entries
def test_cleanup_infers_external_id(build_adapter):
"""When external_req_id is None, cleanup should look it up from the mapping."""
adapter, _ = build_adapter(stage_id=1)
@@ -1299,7 +1888,7 @@ def test_cleanup_after_poll_flow(build_adapter):
"meta": {"finished": torch.tensor(True, dtype=torch.bool)},
}
connector.get.return_value = (payload, 8)
adapter._poll_single_request(request)
adapter._poll_single_request(_dequeue_load_entry(adapter, request))
assert "req-flow" in adapter.upstream_exhausted_requests
assert adapter.get_req_chunk["req-flow"] == 1
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Unit tests for the MiniCPM-o 4.5 pipeline registration.
Covers:
@@ -19,6 +19,8 @@ from pathlib import Path
from types import SimpleNamespace
import pytest
from transformers import PretrainedConfig
from vllm.config import ModelConfig
from vllm_omni.config.pipeline_registry import OMNI_PIPELINES
from vllm_omni.config.stage_config import (
@@ -167,8 +169,11 @@ class TestDeployTopology:
assert connector["extra"]["connector_get_max_wait"] == 300
expected_processor = "tts2code2wav_async_chunk" if deploy.async_chunk else "tts2code2wav_full_payload"
assert stages[1].yaml_engine_args["custom_process_next_stage_input_func"].endswith(expected_processor)
if filename != "minicpmo_4_5.yaml":
assert "hf_overrides" not in stages[1].yaml_engine_args
if filename == "minicpmo_4_5.yaml":
assert [stage.yaml_engine_args["max_num_seqs"] for stage in stages] == [4, 4, 4]
assert stages[1].yaml_engine_args["hf_overrides"] == {"tts_config": {"attention_type": "sliding_recompute"}}
memory_utilizations = [stage.yaml_engine_args["gpu_memory_utilization"] for stage in stages]
assert memory_utilizations == [
0.55,
@@ -189,6 +194,18 @@ class TestDeployTopology:
0.35,
]
def test_duplex_talker_hf_override_resolves_nested_attention_policy(self) -> None:
deploy = load_deploy_config(_DEPLOY_DIR / "minicpmo_4_5.yaml")
stages = merge_pipeline_deploy(OMNI_PIPELINES[deploy.pipeline], deploy)
overrides = stages[1].yaml_engine_args["hf_overrides"]
hf_config = PretrainedConfig()
hf_config.tts_config = PretrainedConfig(attention_type="full_attention")
model_config = object.__new__(ModelConfig)
model_config._apply_dict_overrides(hf_config, overrides)
assert hf_config.tts_config.attention_type == "sliding_recompute"
@pytest.mark.parametrize(
"filename",
[
@@ -102,6 +102,7 @@ def _make_talker() -> MiniCPMO45OmniTTSForConditionalGeneration:
talker._pending_force_eos_rows = None
talker._penalty_histories = None
talker._request_audio_states = {}
talker._request_condition_states = {}
talker._deferred_cleanup_ids = set()
talker._tts_config = ConditionalChatTTSConfig()
talker.head_code = nn.ModuleList([nn.Linear(2, 8, bias=False)])
@@ -833,11 +834,164 @@ def test_native_duplex_condition_matches_official_text_plus_audio_bos() -> None:
assert condition.shape[0] == token_ids.shape[0] + 1
def test_native_duplex_rollover_matches_official_sliding_recompute(mocker) -> None:
talker = _make_talker()
talker.emb_text = nn.Embedding(1, 2)
talker.emb_code = nn.ModuleList([nn.Embedding(8, 2)])
with torch.no_grad():
talker.emb_code[0].weight.copy_(torch.arange(16, dtype=torch.float32).reshape(8, 2))
previous_condition = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
current_condition = torch.tensor([[5.0, 6.0], [7.0, 8.0], [9.0, 10.0]])
build_condition = mocker.patch.object(
talker,
"_build_condition_embeddings",
side_effect=[previous_condition, current_condition, current_condition],
)
talker.preprocess(
torch.zeros(2, dtype=torch.long),
None,
_omni_is_prefill=True,
_omni_prompt_len=2,
request_id="req-window",
native_duplex=True,
meta={"turn_start": True, "streaming_condition_seq": 7},
tts_token_ids=torch.tensor([1]),
tts_hidden_states=torch.ones(1, 2),
)
older_codes = [3, 4, 5]
talker._request_condition_states["req-window"]["base_recent_codes"] = tuple(older_codes)
previous_codes = [1, 2]
recompute_meta = {
"streaming_condition_seq": 8,
"streaming_prompt_recompute": True,
}
expected = torch.cat(
[
previous_condition,
talker.emb_code[0](torch.tensor(previous_codes)),
current_condition,
],
dim=0,
)
_, full_embeds, _ = talker.preprocess(
torch.zeros(expected.shape[0], dtype=torch.long),
None,
_omni_is_prefill=True,
_omni_prompt_len=expected.shape[0],
request_id="req-window",
native_duplex=True,
ids={"streaming_prompt_previous_codes": previous_codes},
meta=recompute_meta,
tts_token_ids=torch.tensor([2]),
tts_hidden_states=torch.ones(1, 2),
)
assert torch.equal(full_embeds, expected)
assert talker._request_audio_states["req-window"]["recent_codes"] == older_codes + previous_codes
# Chunked-prefill/preemption retries reuse the immutable recipe for the
# same condition instead of advancing the one-chunk window twice.
_, retry_slice, _ = talker.preprocess(
torch.zeros(3, dtype=torch.long),
None,
_omni_is_prefill=True,
_omni_num_computed_tokens=2,
_omni_prompt_len=expected.shape[0],
request_id="req-window",
native_duplex=True,
ids={"streaming_prompt_previous_codes": previous_codes},
meta=recompute_meta,
tts_token_ids=torch.tensor([2]),
tts_hidden_states=torch.ones(1, 2),
)
assert torch.equal(retry_slice, expected[2:5])
assert talker._request_audio_states["req-window"]["recent_codes"] == older_codes + previous_codes
assert build_condition.call_count == 3
def test_native_duplex_condition_advance_requires_recompute_marker(mocker) -> None:
talker = _make_talker()
condition = torch.ones(2, 2)
mocker.patch.object(talker, "_build_condition_embeddings", return_value=condition)
common = {
"_omni_is_prefill": True,
"_omni_prompt_len": 2,
"request_id": "req-history",
"native_duplex": True,
"tts_token_ids": torch.tensor([1]),
"tts_hidden_states": torch.ones(1, 2),
}
talker.preprocess(torch.zeros(2, dtype=torch.long), None, meta={"streaming_condition_seq": 0}, **common)
with pytest.raises(ValueError, match="advanced without its streaming recompute marker"):
talker.preprocess(torch.zeros(2, dtype=torch.long), None, meta={"streaming_condition_seq": 1}, **common)
@pytest.mark.parametrize(
("state", "previous_codes", "error"),
[
(None, [1], "missing the previous Talker condition"),
(
{"condition_seq": 0, "condition": torch.ones(2, 2), "base_recent_codes": ()},
[7],
"invalid or terminal token",
),
],
)
def test_native_duplex_rollover_rejects_unverifiable_history(state, previous_codes, error) -> None:
talker = _make_talker()
talker.emb_code = nn.ModuleList([nn.Embedding(8, 2)])
if state is not None:
talker._request_condition_states["req-invalid-window"] = state
with pytest.raises(ValueError, match=error):
talker._build_streaming_recompute_embeddings(
torch.ones(3, 2),
request_id="req-invalid-window",
info_dict={"ids": {"streaming_prompt_previous_codes": previous_codes}},
meta={
"streaming_condition_seq": 1,
"streaming_prompt_recompute": True,
},
)
def test_native_duplex_turn_start_resets_rollover_history() -> None:
talker = _make_talker()
talker._request_condition_states["req-turn"] = {
"condition_seq": 4,
"condition": torch.full((2, 2), 9.0),
"active_embeddings": torch.full((5, 2), 9.0),
}
current = torch.ones(3, 2)
result = talker._build_streaming_recompute_embeddings(
current,
request_id="req-turn",
info_dict={},
meta={"turn_start": True, "streaming_condition_seq": 5},
)
assert torch.equal(result, current)
state = talker._request_condition_states["req-turn"]
assert state["condition_seq"] == 5
assert torch.equal(state["condition"], current)
assert "active_embeddings" not in state
def test_request_cleanup_evicts_decode_state() -> None:
talker = _make_talker()
talker._request_audio_states["req-done"] = {"finished": False}
talker._request_condition_states["req-done"] = {
"condition_seq": 1,
"condition": torch.ones(2, 2),
}
talker.on_requests_finished(["req-done"])
talker._flush_deferred_cleanup()
assert "req-done" not in talker._request_audio_states
assert "req-done" not in talker._request_condition_states
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
import pytest
@@ -131,6 +134,7 @@ def test_native_duplex_speak_segment_reaches_split_talker() -> None:
assert converted["prompt_token_ids"] == [0, 0, 0]
assert info["meta"]["replace_streaming_prompt"] is True
assert info["meta"]["next_stage_prompt_len"] == 3
assert info["meta"]["next_stage_generation_tokens"] == 26
assert info["meta"]["turn_start"] is True
assert info["meta"]["segment_end"] is True
assert info["duplex"]["epoch"] == 3
@@ -203,6 +207,7 @@ def test_native_duplex_continuation_appends_only_new_talker_condition() -> None:
assert "replace_streaming_prompt" not in second_input["model_intermediate_buffer"]["meta"]
assert third_input["model_intermediate_buffer"]["meta"]["replace_streaming_prompt"] is True
assert second_input["model_intermediate_buffer"]["meta"]["next_stage_prompt_len"] == 3
assert second_input["model_intermediate_buffer"]["meta"]["next_stage_generation_tokens"] == 26
assert second_input["prompt_token_ids"] == [0, 0, 0]
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import math
@@ -260,10 +263,30 @@ class OmniSchedulerMixin:
self.running,
scheduler_requests=self.requests,
)
# Prompt-window contracts are finalized on this scheduler thread
# and may surface a permanent receive failure.
self._process_chunk_receive_failures()
self._reset_ready_async_chunk_replacements()
self._process_pending_chunk_timeouts()
self._log_failed_chunk_sends()
def _process_chunk_receive_failures(self) -> None:
"""Fail requests whose consumed async chunk violated its contract."""
adapter = getattr(self, "chunk_transfer_adapter", None)
collector = getattr(adapter, "collect_failed_receive_request_ids", None)
if collector is None:
return
failures = collector()
present_ids = {request_id for request_id in failures if request_id in self.requests}
if not present_ids:
return
logger.error(
"Marking %d request(s) as FINISHED_ERROR after invalid connector input: %s",
len(present_ids),
{request_id: failures[request_id] for request_id in sorted(present_ids)},
)
self.finish_requests(present_ids, RequestStatus.FINISHED_ERROR)
def _restore_omni_wait_queues(self) -> None:
"""Restore requests temporarily parked by Omni input gates."""
if self.chunk_transfer_adapter:
+8
View File
@@ -59,6 +59,7 @@ class Ids(TypedDict, total=False):
output: list[int]
speech_token: list[int]
prior_image: list[int]
streaming_prompt_previous_codes: list[int]
class OmniPayloadMeta(TypedDict, total=False):
@@ -78,7 +79,10 @@ class OmniPayloadMeta(TypedDict, total=False):
override_keys: list[tuple[str, str]]
num_processed_tokens: int
next_stage_prompt_len: int
next_stage_generation_tokens: int
replace_streaming_prompt: bool
streaming_prompt_recompute: bool
streaming_condition_seq: int
replace_runtime_additional_information: bool
ar_width: int
eol_token_id: int
@@ -167,6 +171,7 @@ class IdsStruct(_StructBase):
output: list[int] | None = None
speech_token: list[int] | None = None
prior_image: list[int] | None = None
streaming_prompt_previous_codes: list[int] | None = None
class MetaStruct(_StructBase):
@@ -183,7 +188,10 @@ class MetaStruct(_StructBase):
override_keys: list[tuple[str, str]] | None = None
num_processed_tokens: int | None = None
next_stage_prompt_len: int | None = None
next_stage_generation_tokens: int | None = None
replace_streaming_prompt: bool | None = None
streaming_prompt_recompute: bool | None = None
streaming_condition_seq: int | None = None
replace_runtime_additional_information: bool | None = None
ar_width: int | None = None
eol_token_id: int | None = None
+7
View File
@@ -64,6 +64,13 @@ stages:
- stage_id: 1
max_num_seqs: 4
# Native duplex uses MiniCPMTTS.generate_with_buffer's supported
# sliding_recompute policy: retain one completed condition/codec chunk
# when the next condition arrives. Other deploy profiles continue to use
# the checkpoint's full_attention policy.
hf_overrides:
tts_config:
attention_type: sliding_recompute
# Leave device-level headroom for the colocated HiFi-GAN cuDNN workspace.
gpu_memory_utilization: 0.15
enforce_eager: false
+133 -11
View File
@@ -1,9 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import time
from collections.abc import Callable
from typing import Any
from vllm.v1.request import Request
from vllm_omni.metrics import OrchestratorAggregator
from .utils.logging import get_connector_logger
@@ -216,34 +218,154 @@ def compute_talker_prompt_ids_length(prompt_ids: list[int]) -> int:
return sum_user_len + assistant_len
def construct_next_stage_streaming_input_prompt(payload_data: dict[str, Any], request: Any) -> bool:
def construct_next_stage_streaming_input_prompt(
payload_data: dict[str, Any],
request: Request,
*,
max_model_len: int | None = None,
previous_condition_len: int | None = None,
previous_condition_seq: int | None = None,
condition_seq: int | None = None,
recompute_previous_chunks: int = 0,
) -> bool:
"""Update a downstream streaming request prompt from connector payload ids.
Async-chunk downstream stages are prewarmed before the real Talker prompt is
known. When a Thinker payload carries ``ids.prompt``, this helper:
* Preserves ``num_computed_tokens`` while extending the current prompt.
Explicit replacements reset it with the prompt.
* Preserves ``num_computed_tokens`` while extending a non-window prompt.
Explicit replacements and one-condition window recomputes reset it.
* Moves already-computed output tokens into ``prompt_token_ids``.
* Appends a new placeholder prompt slice sized from the upstream ids.
* Refreshes block hashes so the scheduler allocates KV slots for the
extended prompt without discarding prior computed state.
"""
ids = payload_data.get("ids", {})
meta = payload_data.get("meta", {})
next_stage_prompt_len = meta.get("next_stage_prompt_len") if isinstance(meta, dict) else None
replace_prompt = isinstance(meta, dict) and meta.get("replace_streaming_prompt") is True
if replace_prompt and isinstance(next_stage_prompt_len, int) and next_stage_prompt_len > 0:
ids = payload_data.get("ids")
if not isinstance(ids, dict):
ids = {}
payload_data["ids"] = ids
meta = payload_data.get("meta")
if not isinstance(meta, dict):
meta = {}
payload_data["meta"] = meta
# This flag is transported through a merge-based runtime buffer. Set it on
# every condition so a prior rollover cannot be replayed by a later append.
meta["streaming_prompt_recompute"] = False
next_stage_prompt_len = meta.get("next_stage_prompt_len")
if "next_stage_generation_tokens" in meta:
next_generation_tokens = meta["next_stage_generation_tokens"]
if (
not isinstance(next_generation_tokens, int)
or isinstance(next_generation_tokens, bool)
or next_generation_tokens <= 0
):
raise ValueError("next_stage_generation_tokens must be a positive integer")
generation_reserve = next_generation_tokens
else:
generation_reserve = 0
capacity_limit = (
max_model_len
if isinstance(max_model_len, int) and not isinstance(max_model_len, bool) and max_model_len > 0
else None
)
managed_prompt_len: int | None = None
if generation_reserve > 0 and capacity_limit is not None:
if (
not isinstance(next_stage_prompt_len, int)
or isinstance(next_stage_prompt_len, bool)
or next_stage_prompt_len <= 0
):
raise ValueError("capacity-managed streaming prompt requires a positive next_stage_prompt_len")
managed_prompt_len = next_stage_prompt_len
if managed_prompt_len + generation_reserve > capacity_limit:
raise ValueError(
"fresh streaming prompt plus generation reserve exceeds max_model_len: "
f"prompt={managed_prompt_len}, reserve={generation_reserve}, limit={capacity_limit}"
)
explicit_replacement = meta.get("replace_streaming_prompt") is True
if isinstance(recompute_previous_chunks, bool) or recompute_previous_chunks not in (0, 1):
raise ValueError("streaming prompt recompute supports exactly one previous chunk")
has_window_contract = recompute_previous_chunks == 1
window_recompute = not explicit_replacement and has_window_contract and previous_condition_seq is not None
exceeds_accumulated_capacity = (
managed_prompt_len is not None
and capacity_limit is not None
and request.num_computed_tokens + managed_prompt_len + generation_reserve > capacity_limit
)
if not explicit_replacement and exceeds_accumulated_capacity and not window_recompute:
raise ValueError("capacity-managed streaming prompt rollover requires window_size=1")
replacement_prompt_len = next_stage_prompt_len
if window_recompute:
if managed_prompt_len is None or capacity_limit is None:
raise ValueError("streaming prompt window requires a positive generation reserve and max_model_len")
if (
not isinstance(previous_condition_len, int)
or isinstance(previous_condition_len, bool)
or previous_condition_len <= 0
or not isinstance(previous_condition_seq, int)
or isinstance(previous_condition_seq, bool)
or previous_condition_seq < 0
or not isinstance(condition_seq, int)
or isinstance(condition_seq, bool)
or condition_seq < 0
):
raise ValueError("streaming prompt recompute requires condition lengths and monotonic sequence metadata")
if condition_seq != previous_condition_seq + 1:
raise ValueError(
"streaming prompt recompute skipped a Talker condition: "
f"previous={previous_condition_seq}, current={condition_seq}"
)
num_placeholders = int(getattr(request, "num_output_placeholders", 0) or 0)
confirmed_num_computed_tokens = max(0, int(request.num_computed_tokens) - num_placeholders)
if confirmed_num_computed_tokens < request.num_prompt_tokens:
raise ValueError(
"confirmed streaming output ends before the current condition: "
f"confirmed={confirmed_num_computed_tokens}, prompt={request.num_prompt_tokens}"
)
previous_codec_ids = list(request._all_token_ids[request.num_prompt_tokens : confirmed_num_computed_tokens])
max_confirmed_codec_tokens = generation_reserve - 1
if len(previous_codec_ids) > max_confirmed_codec_tokens:
raise ValueError(
"streaming prompt recompute retained too many codec tokens: "
f"retained={len(previous_codec_ids)}, limit={max_confirmed_codec_tokens}"
)
replacement_prompt_len = previous_condition_len + len(previous_codec_ids) + managed_prompt_len
if replacement_prompt_len + generation_reserve > capacity_limit:
raise ValueError(
"sliding streaming prompt plus generation reserve exceeds max_model_len: "
f"previous={previous_condition_len}, codec={len(previous_codec_ids)}, "
f"current={managed_prompt_len}, reserve={generation_reserve}, limit={capacity_limit}"
)
ids["streaming_prompt_previous_codes"] = previous_codec_ids
meta.update(
{
"streaming_prompt_recompute": True,
"streaming_condition_seq": condition_seq,
}
)
logger.debug(
"Recomputing a one-chunk streaming prompt window: previous=%d, codec=%d, current=%d, reserve=%d, limit=%d",
previous_condition_len,
len(previous_codec_ids),
managed_prompt_len,
generation_reserve,
capacity_limit,
)
replace_prompt = explicit_replacement or window_recompute
if replace_prompt and isinstance(replacement_prompt_len, int) and replacement_prompt_len > 0:
# Some downstream stages consume complete, independently conditioned
# segments instead of extending an existing KV prefix. The producer
# declares that transport behavior explicitly in payload metadata.
new_prompt = [0] * next_stage_prompt_len
new_prompt = [0] * replacement_prompt_len
request._output_token_ids.clear()
request._all_token_ids.clear()
request._all_token_ids.extend(new_prompt)
request.prompt_token_ids = new_prompt
request.num_computed_tokens = 0
request.num_prompt_tokens = next_stage_prompt_len
request.num_prompt_tokens = replacement_prompt_len
request.update_block_hashes()
return True
prompt_token_ids = ids.get("prompt", None)
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import threading
from collections import deque
@@ -37,6 +37,8 @@ class OmniTransferAdapterBase:
self._save_cond = threading.Condition()
self._send_failures: dict[str, str] = {}
self._send_failure_lock = threading.Lock()
self._receive_failures: dict[str, str] = {}
self._receive_failure_lock = threading.Lock()
self.recv_thread = threading.Thread(target=self.recv_loop, daemon=True)
self.recv_thread.start()
@@ -102,6 +104,17 @@ class OmniTransferAdapterBase:
failures, self._send_failures = self._send_failures, {}
return failures
def record_receive_failure(self, request_id: str, reason: str) -> None:
"""Record an invalid received chunk that cannot be retried."""
with self._receive_failure_lock:
self._receive_failures.setdefault(request_id, reason)
def collect_failed_receive_request_ids(self) -> dict[str, str]:
"""Drain and return requests whose received chunk was invalid."""
with self._receive_failure_lock:
failures, self._receive_failures = self._receive_failures, {}
return failures
def save_loop(self):
"""Loop to send outgoing data."""
while not self.stop_event.is_set():
@@ -34,6 +34,50 @@ class _SenderGeneration:
self.cancelled = False
class _LoadEntry:
"""Identify one receiver registration across queue and I/O boundaries."""
__slots__ = ("request",)
def __init__(self, request: Request) -> None:
self.request = request
@property
def request_id(self) -> str:
return self.request.request_id
@property
def external_req_id(self) -> str:
return self.request.external_req_id
def _resolve_talker_streaming_prompt_config(model_config: Any) -> tuple[int, int]:
"""Return the effective Talker context limit and recompute window."""
max_model_len = int(getattr(model_config, "max_model_len", 0) or 0)
hf_config = getattr(model_config, "hf_config", None)
tts_config = getattr(hf_config, "tts_config", None)
if tts_config is None and getattr(hf_config, "model_type", None) == "minicpmtts":
tts_config = hf_config
tts_max_model_len = (
tts_config.get("max_position_embeddings", 0)
if isinstance(tts_config, Mapping)
else getattr(tts_config, "max_position_embeddings", 0)
)
tts_max_model_len = int(tts_max_model_len or 0)
if tts_max_model_len > 0:
max_model_len = min(max_model_len, tts_max_model_len) if max_model_len else tts_max_model_len
attention_type = (
tts_config.get("attention_type")
if isinstance(tts_config, Mapping)
else getattr(tts_config, "attention_type", None)
)
# Official MiniCPMTTS supports both full_attention and
# sliding_recompute. Only an explicit Talker-stage policy enables the
# latter; native duplex alone must not override checkpoint semantics.
previous_chunks = 1 if attention_type == "sliding_recompute" else 0
return max_model_len, previous_chunks
class OmniChunkTransferAdapter(OmniTransferAdapterBase):
"""Chunk-level transfer adapter for Omni connector pipelines.
@@ -59,8 +103,19 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
# lock only protects this map and short state transitions. Slow payload
# construction and connector writes never hold it.
self._sender_state_lock = threading.Lock()
# Serialize receiver registration/cleanup with the commit of a chunk
# returned by connector.get(). The get itself stays outside the lock so
# scheduler cleanup never waits for connector I/O.
self._receiver_state_lock = threading.Lock()
# Includes requests queued for polling and the request currently being
# polled. Per-registration identity lets the receiver reject an old
# segment's queued or in-flight work after the same request id resumes.
self._registered_load_entries: dict[str, _LoadEntry] = {}
self._sender_tokens: dict[str, _SenderGeneration] = {}
self.scheduler_max_num_seqs = vllm_config.scheduler_config.max_num_seqs
self._max_model_len, self._streaming_prompt_previous_chunks = _resolve_talker_streaming_prompt_config(
model_config
)
active_stream_window = int(getattr(model_config, "active_stream_window", 0) or 0)
model_max_num_seqs = int(getattr(model_config, "max_num_seqs", self.scheduler_max_num_seqs) or 0)
if model_max_num_seqs <= 0:
@@ -116,6 +171,15 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self._held_non_active: deque[Any] = deque()
self.requests_num_chunks_sent: dict[str, int] = defaultdict(int)
self._pending_streaming_prefills: dict[str, dict] = {}
# The recv thread only records AR payloads. Prompt mutation is finalized
# by the scheduler after it observes the ready marker, where the request
# token counters form a consistent snapshot.
self._pending_ar_prompt_updates: dict[
str,
tuple[Request, dict[str, Any], bool, int | None, bool],
] = {}
self._streaming_condition_lengths: dict[str, int] = {}
self._streaming_condition_seqs: dict[str, int] = {}
# Monotonic timestamp of when each request last began waiting for a
# chunk, refreshed every time one arrives. Read by
# collect_timed_out_request_ids() so a stream that stops advancing
@@ -211,8 +275,14 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
return
if not hasattr(request, "additional_information"):
request.additional_information = None
self._cancelled_load_reqs.discard(request.request_id)
self._pending_load_reqs.append(request)
with self._receiver_state_lock:
request_id = request.request_id
if request_id in self._registered_load_entries:
return
entry = _LoadEntry(request)
self._cancelled_load_reqs.discard(request_id)
self._registered_load_entries[request_id] = entry
self._pending_load_reqs.append(entry)
with self._recv_cond:
self._recv_cond.notify()
@@ -309,12 +379,16 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
with self._save_cond:
self._save_cond.notify()
def _poll_single_request(self, request: Request):
def _poll_single_request(self, entry: _LoadEntry):
request = entry.request
stage_id = self.connector.stage_id
target_stage_id = stage_id - 1
req_id = request.request_id
chunk_id = self.get_req_chunk[req_id]
external_req_id = self.request_ids_mapping.get(req_id, req_id)
with self._receiver_state_lock:
if self._registered_load_entries.get(req_id) is not entry:
return True
chunk_id = self.get_req_chunk[req_id]
external_req_id = self.request_ids_mapping.get(req_id, req_id)
connector_get_key = f"{external_req_id}_{target_stage_id}_{chunk_id}"
# Use timeout=0 for non-blocking poll
@@ -326,10 +400,46 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
)
except Exception as e:
logger.error(f"SharedMemoryConnector get failed for req {connector_get_key}: {e}")
with self._receiver_state_lock:
if self._registered_load_entries.get(req_id) is not entry:
return True
return False
if result is None:
with self._receiver_state_lock:
if self._registered_load_entries.get(req_id) is not entry:
return True
return False
with self._receiver_state_lock:
# cleanup_receiver() can run while connector.get() is in flight.
# Treat cleanup as a commit barrier: a late chunk must not recreate
# prompt/window state for the removed request.
if self._registered_load_entries.get(req_id) is not entry:
return True
is_success = self._commit_received_chunk(
request,
result,
stage_id=stage_id,
req_id=req_id,
chunk_id=chunk_id,
connector_get_key=connector_get_key,
)
if is_success:
self._registered_load_entries.pop(req_id, None)
return is_success
def _commit_received_chunk(
self,
request: Request,
result: tuple[dict[str, Any], int],
*,
stage_id: int,
req_id: str,
chunk_id: int,
connector_get_key: str,
) -> bool:
"""Commit a received connector chunk while receiver state is locked."""
payload_data, size = result
if payload_data:
@@ -337,16 +447,38 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self.get_req_chunk[req_id] += 1
meta = payload_data.get("meta", {})
if not isinstance(meta, dict):
meta = {}
payload_data["meta"] = meta
payload_finished = self._is_truthy_scalar(meta.get("finished"))
payload_segment_finished = self._is_truthy_scalar(meta.get("is_segment_finished"))
if self.model_mode == "ar":
request.additional_information = payload_data
was_resumable = bool(getattr(request, "resumable", False))
meta["streaming_prompt_recompute"] = False
prompt_len = meta.get("next_stage_prompt_len")
replace_prompt = meta.get("replace_streaming_prompt") is True
if getattr(request, "resumable", False) and (chunk_id > 0 or replace_prompt):
# For new streaming input segment, we should update prompt from payload
replaced = construct_next_stage_streaming_input_prompt(payload_data, request)
if replaced:
self.replaced_streaming_prompt_ids.add(req_id)
payload_ids = payload_data.get("ids")
has_prompt_payload = bool(
replace_prompt
or (isinstance(prompt_len, int) and not isinstance(prompt_len, bool) and prompt_len > 0)
or (isinstance(payload_ids, dict) and payload_ids.get("prompt"))
)
window_condition_len = (
prompt_len
if self._streaming_prompt_previous_chunks == 1
and isinstance(prompt_len, int)
and not isinstance(prompt_len, bool)
and prompt_len > 0
else None
)
update_prompt = bool(was_resumable and has_prompt_payload and (chunk_id > 0 or replace_prompt))
self._pending_ar_prompt_updates[req_id] = (
request,
payload_data,
update_prompt,
window_condition_len,
was_resumable,
)
if payload_finished:
self.upstream_exhausted_requests.add(req_id)
@@ -559,9 +691,12 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
elif finished_flag is not None:
is_payload_finished = bool(finished_flag)
# Reclaim per-request async state only after the terminal payload
# has been sent successfully. This avoids cleanup->save races.
if is_payload_finished:
# Processor-controlled codec ``finished`` can mark the end of one
# native-duplex turn while the persistent request remains live.
# Only the scheduler's whole-request terminal may reclaim shared
# sender/receiver state; otherwise a late old-turn put can erase a
# newly admitted turn for the same request.
if is_payload_finished and is_finished:
self.cleanup(request.request_id, external_req_id)
else:
# R1.2 of #4855. connector.put returning False is a silent drop: no
@@ -607,9 +742,9 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
def cleanup_receiver(self, request_id: str) -> None:
"""Reclaim receiver-side per-request state (keyed by internal id).
Safe to call from the scheduler even when ``save_async()`` has
enqueued work that the background thread has not yet processed,
because it only touches receiver-side dictionaries.
Safe to call from the scheduler while ``connector.get()`` is in
flight: cleanup and the post-get state commit are serialized, and a
cancelled request cannot publish the late chunk.
Must also purge the request from the chunk-parking deques
(``waiting_for_chunk_waiting_requests`` / ``_running_requests`` /
@@ -623,6 +758,11 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
Idempotent: calling with an already-cleaned or unknown id is safe.
"""
with self._receiver_state_lock:
self._clear_receiver_state_locked(request_id)
def _clear_receiver_state_locked(self, request_id: str) -> None:
"""Clear receiver state while ``_receiver_state_lock`` is held."""
self._active_streams.pop(request_id, None)
self.upstream_exhausted_requests.discard(request_id)
self.segment_finished_requests.discard(request_id)
@@ -631,6 +771,10 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self.replaced_streaming_prompt_ids.discard(request_id)
self.request_ids_mapping.pop(request_id, None)
self.requests_origin_status.pop(request_id, None)
self._pending_ar_prompt_updates.pop(request_id, None)
self._streaming_condition_lengths.pop(request_id, None)
self._streaming_condition_seqs.pop(request_id, None)
self._registered_load_entries.pop(request_id, None)
self._discard_from_chunk_deque(self.waiting_for_chunk_waiting_requests, request_id)
self._discard_from_chunk_deque(self.waiting_for_chunk_running_requests, request_id)
self._discard_from_chunk_deque(self._held_non_active, request_id)
@@ -757,6 +901,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
RequestStatus.RUNNING,
self._finished_load_reqs,
)
self._apply_pending_ar_prompt_updates(scheduler_requests)
self._requeue_replaced_prompts(waiting_queue, running_queue)
while len(running_queue) > self.scheduler_max_num_seqs:
request = running_queue.pop()
@@ -772,10 +917,66 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self._process_chunk_queue(
running_queue, self.waiting_for_chunk_running_requests, RequestStatus.RUNNING, self._finished_load_reqs
)
self._apply_pending_ar_prompt_updates(scheduler_requests)
self._requeue_replaced_prompts(waiting_queue, running_queue)
self._promote_active_streams(waiting_queue)
self._preempt_non_active_running(waiting_queue, running_queue)
def _apply_pending_ar_prompt_updates(self, scheduler_requests: dict[str, Request] | None) -> None:
"""Finalize ready AR prompt updates on the scheduler thread."""
for request_id in tuple(self.requests_with_ready_chunks):
with self._receiver_state_lock:
pending = self._pending_ar_prompt_updates.pop(request_id, None)
if pending is None:
continue
(
fallback_request,
payload_data,
update_prompt,
window_condition_len,
was_resumable,
) = pending
request = scheduler_requests.get(request_id) if scheduler_requests is not None else fallback_request
if request is None:
continue
request.additional_information = payload_data
meta = payload_data.get("meta")
meta = meta if isinstance(meta, dict) else {}
previous_condition_seq = self._streaming_condition_seqs.get(request_id)
condition_seq = previous_condition_seq + 1 if previous_condition_seq is not None else 0
is_window_condition = window_condition_len is not None
if is_window_condition:
meta["streaming_condition_seq"] = condition_seq
if is_window_condition:
update_prompt = bool(
was_resumable
and (previous_condition_seq is not None or meta.get("replace_streaming_prompt") is True)
)
try:
if update_prompt:
replaced = construct_next_stage_streaming_input_prompt(
payload_data,
request,
max_model_len=self._max_model_len,
previous_condition_len=self._streaming_condition_lengths.get(request_id),
previous_condition_seq=previous_condition_seq,
condition_seq=condition_seq,
recompute_previous_chunks=self._streaming_prompt_previous_chunks,
)
if replaced:
self.replaced_streaming_prompt_ids.add(request_id)
except ValueError as exc:
# The connector chunk was consumed, so retrying would skip
# this transition and desynchronize Talker. Let the
# scheduler fail it.
self.record_receive_failure(request_id, str(exc))
continue
if window_condition_len is not None:
self._streaming_condition_lengths[request_id] = window_condition_len
self._streaming_condition_seqs[request_id] = condition_seq
def _requeue_replaced_prompts(self, waiting_queue: Any, running_queue: list[Request]) -> None:
"""Move a replaced running prompt back through scheduler admission."""
for request in list(running_queue):
@@ -1,8 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# NOTE: Do not import model classes in this file. Importing any
# submodule in this package triggers __init__.py execution, and
# both the model registry and pipeline registry import submodules
# directly — heavy imports here would be loaded as a side effect
# even though nothing depends on these re-exports.
# One MiniCPMTTS.generate_chunk emits 25 codec frames plus its terminator.
MINICPMO45_DUPLEX_CODEC_TOKENS_PER_CHUNK = 26
@@ -28,6 +28,7 @@ from vllm.model_executor.models.utils import maybe_prefix
from vllm.v1.sample.sampler import Sampler
from vllm_omni.experimental.fullduplex.engine.intermediate import get_tts_handoff
from vllm_omni.model_executor.models.minicpmo_4_5 import MINICPMO45_DUPLEX_CODEC_TOKENS_PER_CHUNK
from vllm_omni.model_executor.models.output_templates import OmniOutput
from vllm_omni.platforms import current_omni_platform
@@ -46,7 +47,7 @@ _OFFLINE_CODEC_MAX_NEW_TOKENS = 2048
# 25 codec frames (``codec_chunk_frames``) plus the terminating sample.
# Without this, the single-vocab Sampler keeps the stage-1 request alive
# until codec EOS / 4096 and Thinker never starts the next model turn.
_DUPLEX_CODEC_TOKENS_PER_CHUNK = 26
_DUPLEX_CODEC_TOKENS_PER_CHUNK = MINICPMO45_DUPLEX_CODEC_TOKENS_PER_CHUNK
def _native_duplex_chunk_budget(meta: Mapping[str, Any] | None) -> tuple[int, int]:
@@ -158,6 +159,9 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
self._pending_force_eos_rows: list[bool] | None = None
self._penalty_histories: list[torch.Tensor] | None = None
self._request_audio_states: dict[str, dict[str, Any]] = {}
# Mirrors upstream TTSStreamingGenerator._chunk_info: one committed
# condition plus, during a rollover, one immutable recompute recipe.
self._request_condition_states: dict[str, dict[str, Any]] = {}
self._deferred_cleanup_ids: set[str] = set()
tts_config = getattr(config, "tts_config", None)
@@ -262,6 +266,116 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
return torch.cat([condition, audio_bos], dim=0)
return torch.cat([condition, self._boundary_embeddings()], dim=0)
def _build_streaming_recompute_embeddings(
self,
current_condition: torch.Tensor,
*,
request_id: str,
info_dict: Mapping[str, Any],
meta: Mapping[str, Any],
) -> torch.Tensor:
"""Return the official one-previous-chunk sliding-recompute window."""
condition_seq = meta.get("streaming_condition_seq")
if not isinstance(condition_seq, int) or isinstance(condition_seq, bool):
if meta.get("streaming_prompt_recompute") is True:
raise ValueError("streaming prompt recompute is missing streaming_condition_seq")
# Direct model tests and non-connector callers do not participate in
# the persistent async-chunk lifecycle, so they need no window state.
return current_condition
turn_start = bool(meta.get("turn_start"))
recompute = meta.get("streaming_prompt_recompute") is True
states = self._request_condition_states
state = states.get(request_id)
if turn_start:
if recompute:
raise ValueError("streaming prompt recompute cannot cross a native duplex turn boundary")
states[request_id] = {
"condition_seq": condition_seq,
"condition": current_condition.detach().clone(),
"base_recent_codes": (),
}
return current_condition
if state is None:
if recompute:
raise ValueError("streaming prompt recompute is missing the previous Talker condition")
states[request_id] = {
"condition_seq": condition_seq,
"condition": current_condition.detach().clone(),
"base_recent_codes": (),
}
return current_condition
previous_seq = state.get("condition_seq")
if not isinstance(previous_seq, int) or condition_seq < previous_seq:
raise ValueError(
f"stale native duplex Talker condition sequence: current={condition_seq}, previous={previous_seq}"
)
if condition_seq > previous_seq + 1:
raise ValueError(
f"native duplex Talker skipped a condition sequence: current={condition_seq}, previous={previous_seq}"
)
if not recompute:
if condition_seq > previous_seq:
raise ValueError(
"a native duplex Talker condition advanced without its streaming recompute marker: "
f"current={condition_seq}, previous={previous_seq}"
)
if "active_embeddings" in state:
raise ValueError("an active streaming recompute was replayed without its recompute marker")
return current_condition
if condition_seq == previous_seq:
active_embeddings = state.get("active_embeddings")
if not isinstance(active_embeddings, torch.Tensor):
raise ValueError("streaming prompt window lost its cached recompute embeddings")
return active_embeddings
if condition_seq != previous_seq + 1:
raise ValueError(
"streaming prompt recompute skipped a Talker condition: "
f"previous={previous_seq}, current={condition_seq}"
)
previous_condition = state.get("condition")
if not isinstance(previous_condition, torch.Tensor):
raise ValueError("streaming prompt recompute lost the previous Talker condition")
ids = info_dict.get("ids")
previous_codes = ids.get("streaming_prompt_previous_codes") if isinstance(ids, Mapping) else None
if isinstance(previous_codes, torch.Tensor):
code_ids = previous_codes.to(device=self.emb_code[0].weight.device, dtype=torch.long).reshape(-1)
elif isinstance(previous_codes, (list, tuple)):
code_ids = torch.as_tensor(previous_codes, device=self.emb_code[0].weight.device, dtype=torch.long)
else:
raise ValueError("streaming prompt recompute is missing confirmed codec ids")
if code_ids.numel() > _DUPLEX_CODEC_TOKENS_PER_CHUNK - 1:
raise ValueError(f"streaming prompt recompute has too many codec ids: {code_ids.numel()}")
if code_ids.numel() and bool(((code_ids < 0) | (code_ids >= self._codec_eos_id)).any()):
raise ValueError("streaming prompt recompute codec ids include an invalid or terminal token")
parts = [previous_condition]
if code_ids.numel():
parts.append(self.emb_code[0](code_ids))
parts.append(current_condition)
full_embeddings = torch.cat(parts, dim=0)
previous_code_ids = tuple(int(code_id) for code_id in code_ids.tolist())
previous_base_codes = state.get("base_recent_codes")
if not isinstance(previous_base_codes, tuple):
raise ValueError("streaming Talker condition lost its frozen codec history")
states[request_id] = {
"condition_seq": condition_seq,
"condition": current_condition.detach().clone(),
"active_embeddings": full_embeddings.detach().clone(),
# Official generate_with_buffer keeps all_generated_tokens across
# sliding recomputes, so the first sample in this chunk still sees
# the previous chunk's repetition-penalty window.
"base_recent_codes": (*previous_base_codes, *previous_code_ids)[-_CODEC_PENALTY_WINDOW:],
}
return full_embeddings
def preprocess(
self,
input_ids: torch.Tensor,
@@ -274,6 +388,7 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
is_prefill = bool(info_dict.get("_omni_is_prefill", False))
state = info_dict.get("audio_state")
first_call = not isinstance(state, dict)
request_id = str(info_dict.get("request_id", "0"))
if is_prefill or first_call:
token_ids, hidden_states = get_tts_handoff(info_dict)
@@ -309,13 +424,35 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
hidden_states,
native_duplex=native_duplex,
)
if native_duplex:
full_embeds = self._build_streaming_recompute_embeddings(
full_embeds,
request_id=request_id,
info_dict=info_dict,
meta=meta if isinstance(meta, Mapping) else {},
)
retained_codes: list[int] = []
condition_seq = meta.get("streaming_condition_seq") if isinstance(meta, Mapping) else None
if native_duplex and isinstance(condition_seq, int) and not isinstance(condition_seq, bool):
condition_state = self._request_condition_states.get(request_id)
base_recent_codes = (
condition_state.get("base_recent_codes") if isinstance(condition_state, dict) else None
)
if not isinstance(base_recent_codes, tuple):
raise ValueError("streaming Talker condition lost its frozen codec history")
retained_codes = list(base_recent_codes)
offset = int(info_dict.get("_omni_num_computed_tokens", 0))
request_id = str(info_dict.get("request_id", "0"))
# The handoff rebuilds only the tail-aligned Talker condition.
# Materialize zero-token embeddings for any scheduler prompt
# prefix so chunked prefill can slice from a non-zero offset.
prompt_len = info_dict.get("_omni_prompt_len")
target_len = int(prompt_len) if prompt_len is not None else offset + span_len
if native_duplex and isinstance(meta, Mapping) and meta.get("streaming_prompt_recompute") is True:
if target_len != full_embeds.shape[0]:
raise ValueError(
"streaming prompt recompute length mismatch: "
f"scheduler={target_len}, model={full_embeds.shape[0]}"
)
prefix_len = target_len - full_embeds.shape[0]
if prefix_len > 0:
placeholder_ids = torch.zeros(
@@ -348,6 +485,8 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
"max_tokens": max_tokens,
"min_tokens": min_tokens,
}
if retained_codes:
state["recent_codes"] = retained_codes
request_states = getattr(self, "_request_audio_states", None)
if request_states is None:
request_states = {}
@@ -365,7 +504,6 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
},
)
request_id = str(info_dict.get("request_id", "0"))
stored = self._request_audio_states.get(request_id)
if isinstance(stored, dict):
state = stored
@@ -533,8 +671,10 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP):
def _flush_deferred_cleanup(self) -> None:
request_audio_states = getattr(self, "_request_audio_states", {})
request_condition_states = getattr(self, "_request_condition_states", {})
for request_id in self._deferred_cleanup_ids:
request_audio_states.pop(request_id, None)
request_condition_states.pop(request_id, None)
self._deferred_cleanup_ids.clear()
def _dummy_hidden_states(
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MiniCPM-o 4.5 Thinker-to-Talker and Talker-to-Code2Wav bridges."""
import logging
@@ -16,6 +16,7 @@ from vllm_omni.experimental.fullduplex.engine.intermediate import (
set_tts_handoff,
)
from vllm_omni.inputs.data import OmniTokensPrompt
from vllm_omni.model_executor.models.minicpmo_4_5 import MINICPMO45_DUPLEX_CODEC_TOKENS_PER_CHUNK
logger = logging.getLogger(__name__)
_MINICPMO45_ASYNC_STATE = "_minicpmo45_async_codec_state"
@@ -989,6 +990,8 @@ def llm2tts(
scheduler_prompt_token_ids = [0] * condition_length
handoff_meta = model_intermediate_buffer.setdefault("meta", {})
handoff_meta["next_stage_prompt_len"] = condition_length
if is_native_duplex_handoff:
handoff_meta["next_stage_generation_tokens"] = MINICPMO45_DUPLEX_CODEC_TOKENS_PER_CHUNK
# Native duplex resumes one Talker request within a turn, but a new
# assistant turn must discard the previous turn's prompt and KV.
if not is_native_duplex_handoff or native_turn_start: