[WIP][Bugfix][MiniCPM-o] Fix Full duplex bugs for minicpm-challenge (#5496)
Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
60 files changed
+3696
-634
No files matched your search
@@ -18,6 +18,7 @@ def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state():
|
||||
accumulated = MultimodalPayload.from_dict(
|
||||
{
|
||||
"audio": torch.tensor([1.0]),
|
||||
"meta.segment_end": torch.tensor([0]),
|
||||
"meta.tts_is_last_chunk": torch.tensor([0]),
|
||||
"meta.turn_end": torch.tensor([0]),
|
||||
"meta.stable_request_value": "keep",
|
||||
@@ -26,6 +27,7 @@ def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state():
|
||||
incoming = MultimodalPayload.from_dict(
|
||||
{
|
||||
"audio": torch.tensor([2.0]),
|
||||
"meta.segment_end": torch.tensor([1]),
|
||||
"meta.tts_is_last_chunk": torch.tensor([1]),
|
||||
"meta.turn_end": torch.tensor([1]),
|
||||
}
|
||||
@@ -41,6 +43,7 @@ def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state():
|
||||
drain_delta_payload(merged)
|
||||
|
||||
assert "audio" not in merged
|
||||
assert "meta.segment_end" not in merged
|
||||
assert "meta.tts_is_last_chunk" not in merged
|
||||
assert "meta.turn_end" not in merged
|
||||
assert merged.metadata["meta.stable_request_value"] == "keep"
|
||||
@@ -1813,6 +1813,56 @@ async def test_stage_pool_submit_update_refreshes_output_processor_state() -> No
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_streaming_update_unknown_request_is_dropped() -> None:
|
||||
"""A streaming update for an untracked request must not resurrect it.
|
||||
|
||||
Updates always follow the first-chunk add_request through the same
|
||||
ordered submission queue, so an unknown id can only mean the request
|
||||
already finished or was aborted (e.g. the client disconnected).
|
||||
Falling back to add_request created headless sessions that kept
|
||||
cycling through the stages (issue #4271).
|
||||
"""
|
||||
|
||||
class RecordingPool:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, Any]] = []
|
||||
|
||||
async def submit_update(self, request_id, req_state, request, *, prompt_text=None) -> int:
|
||||
self.calls.append((request_id, prompt_text))
|
||||
return 0
|
||||
|
||||
pool = RecordingPool()
|
||||
orchestrator = object.__new__(Orchestrator)
|
||||
orchestrator.async_chunk = False
|
||||
orchestrator.request_states = {}
|
||||
orchestrator.stage_pools = [pool]
|
||||
add_request_calls: list[str] = []
|
||||
|
||||
async def record_add_request(msg) -> None:
|
||||
add_request_calls.append(msg.request_id)
|
||||
|
||||
orchestrator._handle_add_request = record_add_request
|
||||
|
||||
await orchestrator._handle_streaming_update(
|
||||
StageSubmissionMessage(
|
||||
type="streaming_update",
|
||||
request_id="req-unknown",
|
||||
prompt=SimpleNamespace(request_id="req-unknown", prompt_token_ids=[1], resumable=True),
|
||||
original_prompt={"prompt": "segment-2"},
|
||||
output_prompt_text="segment-2",
|
||||
sampling_params_list=[_sampling_params()],
|
||||
final_stage_id=0,
|
||||
preprocess_ms=0.0,
|
||||
request_timestamp=time.time(),
|
||||
enqueue_ts=time.perf_counter(),
|
||||
)
|
||||
)
|
||||
|
||||
assert pool.calls == []
|
||||
assert add_request_calls == []
|
||||
assert "req-unknown" not in orchestrator.request_states
|
||||
|
||||
|
||||
async def test_handle_streaming_update_passes_prompt_text_to_stage_pool() -> None:
|
||||
class RecordingPool:
|
||||
def __init__(self) -> None:
|
||||
|
||||
@@ -7,15 +7,24 @@ import asyncio
|
||||
import queue
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||
|
||||
import janus
|
||||
import pytest
|
||||
from vllm.outputs import CompletionOutput, RequestOutput
|
||||
from vllm.sampling_params import SamplingParams
|
||||
|
||||
from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState
|
||||
from vllm_omni.engine.orchestrator import (
|
||||
Orchestrator,
|
||||
OrchestratorRequestState,
|
||||
_OrchestratorDuplexStagePort,
|
||||
)
|
||||
from vllm_omni.engine.stage_pool import StagePool
|
||||
from vllm_omni.experimental.fullduplex.engine.contracts import (
|
||||
DuplexStageRequestContext,
|
||||
DuplexStageSubmission,
|
||||
)
|
||||
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -107,11 +116,50 @@ class FakePrewarmPool:
|
||||
|
||||
async def submit_initial(self, _request_id, _req_state, request, prompt_text=None):
|
||||
self.submitted.append(request)
|
||||
return 0
|
||||
|
||||
def get_bound_replica_id(self, _request_id):
|
||||
return 0
|
||||
|
||||
|
||||
def _duplex_stage_port_submission():
|
||||
stage_pools = []
|
||||
for stage_id in range(3):
|
||||
pool = SimpleNamespace(
|
||||
stage_client=SimpleNamespace(default_sampling_params=SamplingParams(max_tokens=1)),
|
||||
stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
|
||||
submit_initial=AsyncMock(return_value=stage_id + 10),
|
||||
submit_update=AsyncMock(return_value=stage_id + 20),
|
||||
)
|
||||
stage_pools.append(pool)
|
||||
request_states: dict[str, OrchestratorRequestState] = {}
|
||||
prewarm = AsyncMock()
|
||||
port = _OrchestratorDuplexStagePort(
|
||||
stage_pools=stage_pools,
|
||||
request_states=request_states,
|
||||
running_counter=None,
|
||||
cleanup_request_ids=AsyncMock(),
|
||||
async_chunk=True,
|
||||
prewarm_async_chunk_stages=prewarm,
|
||||
)
|
||||
context = DuplexStageRequestContext(
|
||||
request_id="req-duplex",
|
||||
session_id="session-duplex",
|
||||
fence=DuplexFence("session-duplex"),
|
||||
stage_id=0,
|
||||
final_stage_id=2,
|
||||
config_generation=0,
|
||||
sampling_params=tuple(SamplingParams(max_tokens=1) for _ in range(3)),
|
||||
)
|
||||
port.ensure_request(context)
|
||||
submission = DuplexStageSubmission(
|
||||
context=context,
|
||||
prompt={"prompt_token_ids": [1, 2]},
|
||||
already_submitted=False,
|
||||
)
|
||||
return port, stage_pools, request_states, prewarm, submission
|
||||
|
||||
|
||||
def _request_output(request_id: str) -> RequestOutput:
|
||||
completion = CompletionOutput(
|
||||
index=0,
|
||||
@@ -199,11 +247,13 @@ async def test_async_prewarm_skips_outgoing_only_stage() -> None:
|
||||
stage2 = FakePrewarmPool("receiver")
|
||||
orchestrator.stage_pools = [stage0, stage1, stage2]
|
||||
orchestrator._emit_tx_edge = lambda **_kwargs: None
|
||||
orchestrator._record_duplex_stage_submission = MagicMock()
|
||||
req_state = OrchestratorRequestState(
|
||||
request_id="req-prewarm",
|
||||
prompt={"prompt_token_ids": [1, 2]},
|
||||
sampling_params_list=[SamplingParams(max_tokens=1) for _ in range(3)],
|
||||
final_stage_id=2,
|
||||
duplex_identity=SimpleNamespace(),
|
||||
)
|
||||
|
||||
await orchestrator._prewarm_async_chunk_stages(
|
||||
@@ -216,6 +266,23 @@ async def test_async_prewarm_skips_outgoing_only_stage() -> None:
|
||||
assert len(stage2.submitted) == 1
|
||||
assert 1 not in req_state.stage_submit_ts
|
||||
assert 2 in req_state.stage_submit_ts
|
||||
orchestrator._record_duplex_stage_submission.assert_called_once_with(
|
||||
2,
|
||||
"req-prewarm",
|
||||
0,
|
||||
req_state,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplex_prewarm_runs_after_first_stage0_submission() -> None:
|
||||
port, stage_pools, request_states, prewarm, submission = _duplex_stage_port_submission()
|
||||
|
||||
result = await port.submit(submission)
|
||||
|
||||
assert result.stage_id == 0
|
||||
stage_pools[0].submit_initial.assert_awaited_once()
|
||||
prewarm.assert_awaited_once_with("req-duplex", ANY, request_states["req-duplex"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -14,7 +14,7 @@ from vllm.v1.engine import FinishReason
|
||||
from vllm.v1.engine.output_processor import OutputProcessor as VLLMOutputProcessor
|
||||
|
||||
from vllm_omni.outputs import output_processor
|
||||
from vllm_omni.outputs.output_modality import OutputModalityNames
|
||||
from vllm_omni.outputs.output_modality import OutputModality, OutputModalityNames
|
||||
from vllm_omni.outputs.output_processor import MultimodalOutputProcessor, OmniRequestState
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
@@ -452,6 +452,127 @@ def test_no_detokenizer_make_request_output():
|
||||
assert AUDIO in result.outputs[0].multimodal_output
|
||||
|
||||
|
||||
def test_no_detokenizer_process_outputs_returns_nonterminal_audio_chunk(monkeypatch):
|
||||
"""Generation-stage MM-only chunks must reach the orchestrator.
|
||||
|
||||
StagePool registers generation requests without a per-request collector,
|
||||
so the processor return value is the only path from Stage2 to
|
||||
Orchestrator._handle_processed_outputs().
|
||||
"""
|
||||
state = _make_no_detok_state(RequestOutputKind.DELTA)
|
||||
processor = object.__new__(MultimodalOutputProcessor)
|
||||
processor.output_modality = OutputModality.AUDIO
|
||||
processor.request_states = {"r": state}
|
||||
upstream_result = SimpleNamespace(request_outputs=[], reqs_to_abort=[])
|
||||
monkeypatch.setattr(
|
||||
VLLMOutputProcessor,
|
||||
"process_outputs",
|
||||
lambda *_args, **_kwargs: upstream_result,
|
||||
)
|
||||
engine_output = SimpleNamespace(
|
||||
request_id="r",
|
||||
multimodal_output={
|
||||
"model_outputs": torch.arange(8, dtype=torch.float32),
|
||||
"sr": torch.tensor(24000, dtype=torch.int32),
|
||||
},
|
||||
output_type="audio",
|
||||
pooling_output=None,
|
||||
new_token_ids=[],
|
||||
finish_reason=None,
|
||||
stop_reason=None,
|
||||
kv_transfer_params=None,
|
||||
routed_experts=None,
|
||||
num_cached_tokens=0,
|
||||
)
|
||||
|
||||
result = processor.process_outputs([engine_output])
|
||||
|
||||
assert len(result.request_outputs) == 1
|
||||
assert result.request_outputs[0].request_id == "r"
|
||||
assert AUDIO in result.request_outputs[0].outputs[0].multimodal_output
|
||||
|
||||
|
||||
def _make_mm_only_output_processor(monkeypatch):
|
||||
processor = object.__new__(MultimodalOutputProcessor)
|
||||
processor.output_modality = OutputModality.AUDIO
|
||||
processor.request_states = {"r": _make_no_detok_state(RequestOutputKind.DELTA)}
|
||||
monkeypatch.setattr(
|
||||
VLLMOutputProcessor,
|
||||
"process_outputs",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(
|
||||
request_outputs=[],
|
||||
reqs_to_abort=[],
|
||||
),
|
||||
)
|
||||
|
||||
def finish_request(req_state):
|
||||
processor.request_states.pop(req_state.request_id, None)
|
||||
|
||||
processor._finish_request = finish_request
|
||||
return processor
|
||||
|
||||
|
||||
def _audio_engine_output(*, is_segment_finished: bool, is_last_chunk: bool):
|
||||
return SimpleNamespace(
|
||||
request_id="r",
|
||||
multimodal_output={
|
||||
"model_outputs": torch.arange(8, dtype=torch.float32),
|
||||
"meta.tts_is_last_chunk": torch.tensor([int(is_last_chunk)]),
|
||||
},
|
||||
output_type="audio",
|
||||
pooling_output=None,
|
||||
new_token_ids=[],
|
||||
finish_reason=FinishReason.STOP,
|
||||
stop_reason=None,
|
||||
kv_transfer_params=None,
|
||||
routed_experts=None,
|
||||
num_cached_tokens=0,
|
||||
is_segment_finished=is_segment_finished,
|
||||
)
|
||||
|
||||
|
||||
def test_mm_only_segment_finish_retains_request_state_for_next_audio_chunk(monkeypatch):
|
||||
processor = _make_mm_only_output_processor(monkeypatch)
|
||||
|
||||
first = processor.process_outputs([_audio_engine_output(is_segment_finished=True, is_last_chunk=False)])
|
||||
|
||||
assert "r" in processor.request_states
|
||||
assert len(first.request_outputs) == 1
|
||||
assert first.request_outputs[0].finished is False
|
||||
|
||||
second = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)])
|
||||
|
||||
assert len(second.request_outputs) == 1
|
||||
assert second.request_outputs[0].finished is True
|
||||
assert "r" not in processor.request_states
|
||||
|
||||
|
||||
def test_mm_only_nonfinal_audio_retains_state_without_segment_marker(monkeypatch):
|
||||
processor = _make_mm_only_output_processor(monkeypatch)
|
||||
|
||||
first = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=False)])
|
||||
|
||||
assert "r" in processor.request_states
|
||||
assert len(first.request_outputs) == 1
|
||||
assert first.request_outputs[0].finished is False
|
||||
|
||||
second = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)])
|
||||
|
||||
assert len(second.request_outputs) == 1
|
||||
assert second.request_outputs[0].finished is True
|
||||
assert "r" not in processor.request_states
|
||||
|
||||
|
||||
def test_mm_only_terminal_finish_removes_request_state(monkeypatch):
|
||||
processor = _make_mm_only_output_processor(monkeypatch)
|
||||
|
||||
result = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)])
|
||||
|
||||
assert len(result.request_outputs) == 1
|
||||
assert result.request_outputs[0].finished is True
|
||||
assert "r" not in processor.request_states
|
||||
|
||||
|
||||
def test_no_detokenizer_make_request_output_with_routed_experts():
|
||||
"""make_request_output accepts the routed_experts arg that the multimodal
|
||||
output channel (_process_mm_only_outputs) passes positionally, and attaches
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
@@ -11,6 +12,7 @@ import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from vllm.v1.engine.utils import EngineZmqAddresses
|
||||
|
||||
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
|
||||
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
|
||||
from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClientBase
|
||||
from vllm_omni.engine.stage_engine_startup import (
|
||||
@@ -189,6 +191,34 @@ class TestOmniMasterServerAllocation:
|
||||
output_port = int(alloc.output_bind_address.split(":")[-1])
|
||||
assert len({handshake_port, input_port, output_port}) == 3
|
||||
|
||||
def test_route_ports_remain_os_reserved_until_master_releases_them(self):
|
||||
server = OmniMasterServer(master_address="127.0.0.1", master_port=15005, stage_ids=[0])
|
||||
alloc = server.get_allocation(0)
|
||||
ports = [
|
||||
int(alloc.handshake_bind_address.rsplit(":", 1)[-1]),
|
||||
int(alloc.input_bind_address.rsplit(":", 1)[-1]),
|
||||
int(alloc.output_bind_address.rsplit(":", 1)[-1]),
|
||||
]
|
||||
|
||||
for port in ports:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
with pytest.raises(OSError):
|
||||
probe.bind(("127.0.0.1", port))
|
||||
|
||||
server.release_route_port_reservations(0, handshake=True, data=False)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.bind(("127.0.0.1", ports[0]))
|
||||
for port in ports[1:]:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
with pytest.raises(OSError):
|
||||
probe.bind(("127.0.0.1", port))
|
||||
|
||||
server.stop()
|
||||
|
||||
for port in ports[1:]:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.bind(("127.0.0.1", port))
|
||||
|
||||
def test_get_zmq_addresses_returns_bind_addresses(self):
|
||||
server = OmniMasterServer(master_address="127.0.0.1", master_port=15002, stage_ids=[0])
|
||||
alloc = server.get_allocation(0)
|
||||
@@ -397,6 +427,25 @@ class TestSingleStageModeDetection:
|
||||
assert engine.single_stage_mode is False
|
||||
assert engine._single_stage_id_filter is None
|
||||
|
||||
def test_stage_configs_path_loads_duplex_runtime_config(self, mocker: MockerFixture):
|
||||
duplex_session = DuplexSessionRuntimeConfig(max_sessions=2)
|
||||
mocker.patch(
|
||||
"vllm_omni.engine.async_omni_engine.StageConfigFactory.get_pipeline_config",
|
||||
return_value=None,
|
||||
)
|
||||
load_deploy_config = mocker.patch(
|
||||
"vllm_omni.engine.async_omni_engine.load_deploy_config",
|
||||
return_value=SimpleNamespace(duplex_session=duplex_session),
|
||||
)
|
||||
|
||||
engine = self._make_engine_no_thread(
|
||||
mocker,
|
||||
stage_configs_path="/fake/duplex.yaml",
|
||||
)
|
||||
|
||||
load_deploy_config.assert_called_once_with("/fake/duplex.yaml")
|
||||
assert engine.duplex_session_config is duplex_session
|
||||
|
||||
def test_single_stage_mode_without_stage_id_has_no_filter(self, mocker: MockerFixture):
|
||||
engine = self._make_engine_no_thread(
|
||||
mocker,
|
||||
|
||||
Reference in new issue
Block a user