[Frontend][Benchmark] Add duplex performance metrics to refactored full-duplex framework (#7714)
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
This commit is contained in:
1 parent
9ebef4b1d3
commit
0582844a41
21 files changed
+1449
-170
No files matched your search
@@ -5,6 +5,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
@@ -20,8 +22,13 @@ from vllm_omni.engine.duplex.config import (
|
||||
)
|
||||
from vllm_omni.engine.duplex.contracts import DuplexFence
|
||||
from vllm_omni.engine.duplex.events import TurnEvent
|
||||
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession, DuplexFenceMismatchError
|
||||
from vllm_omni.engine.duplex.session.engine_session import (
|
||||
RESPONSE_REQUEST_MEASUREMENT_ORIGIN,
|
||||
DuplexEngineSession,
|
||||
DuplexFenceMismatchError,
|
||||
)
|
||||
from vllm_omni.engine.duplex.session.playback_ledger import apply_playback_ack
|
||||
from vllm_omni.metrics.stats import DUPLEX_STAGE_TABLE_EXCLUDE, OrchestratorAggregator, StageRequestStats, StageStats
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.capabilities import (
|
||||
minicpmo45_native_capabilities,
|
||||
)
|
||||
@@ -29,8 +36,51 @@ from vllm_omni.model_executor.models.minicpmo_4_5.duplex.capabilities import (
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _session(session_id: str = "duplex-test", config: DuplexSessionConfig | None = None) -> DuplexEngineSession:
|
||||
return DuplexEngineSession(session_id=session_id, config=config or DuplexSessionConfig(model="test-model"))
|
||||
def _session(
|
||||
session_id: str = "duplex-test",
|
||||
config: DuplexSessionConfig | None = None,
|
||||
*,
|
||||
num_stages: int = 1,
|
||||
log_stats: bool = False,
|
||||
clock: Callable[[], float] | None = None,
|
||||
) -> DuplexEngineSession:
|
||||
session = DuplexEngineSession(
|
||||
session_id=session_id,
|
||||
config=config or DuplexSessionConfig(model="test-model"),
|
||||
num_stages=num_stages,
|
||||
log_stats=log_stats,
|
||||
)
|
||||
if clock is not None:
|
||||
session._clock = clock
|
||||
return session
|
||||
|
||||
|
||||
def _stage_stats(
|
||||
*,
|
||||
stage_id: int,
|
||||
request_id: str = "stage-req",
|
||||
num_tokens_out: int = 3,
|
||||
vllm_ttft_ms: float = 12.0,
|
||||
vllm_tpot_ms: float = 0.0,
|
||||
serving_time_to_first_output_ms: float = 0.0,
|
||||
) -> StageRequestStats:
|
||||
return StageRequestStats(
|
||||
batch_id=0,
|
||||
batch_size=1,
|
||||
num_tokens_in=7,
|
||||
num_tokens_out=num_tokens_out,
|
||||
stage_gen_time_ms=120.0,
|
||||
rx_transfer_bytes=0,
|
||||
rx_decode_time_ms=0.0,
|
||||
rx_in_flight_time_ms=0.0,
|
||||
stage_stats=StageStats(),
|
||||
stage_id=stage_id,
|
||||
request_id=request_id,
|
||||
final_output_type="text",
|
||||
vllm_ttft_ms=vllm_ttft_ms,
|
||||
vllm_tpot_ms=vllm_tpot_ms,
|
||||
serving_time_to_first_output_ms=serving_time_to_first_output_ms,
|
||||
)
|
||||
|
||||
|
||||
def test_commit_audio_input_does_not_advance_model_turn_identity():
|
||||
@@ -733,6 +783,222 @@ def test_minicpmo_native_capabilities_do_not_overclaim_single_session_deployment
|
||||
assert caps["supports_multi_session_same_replica"] is False
|
||||
|
||||
|
||||
def test_response_timing_binds_latest_request_start_for_model_turn():
|
||||
session = _session()
|
||||
session.mark_model_turn_request_started(0, 10.0)
|
||||
session.mark_model_turn_request_started(0, 11.0)
|
||||
session.begin_response(turn_id=0)
|
||||
|
||||
first = session.mark_response_first_outputs(
|
||||
observed_at_s=11.2,
|
||||
has_text=True,
|
||||
has_audio=False,
|
||||
)
|
||||
assert first["ttft_ms"] == pytest.approx(200.0)
|
||||
assert first["measurement_origin"] == RESPONSE_REQUEST_MEASUREMENT_ORIGIN
|
||||
assert "ttfp_ms" not in first
|
||||
|
||||
# After begin_response the origin is frozen at the pending start (11.0).
|
||||
# The 12.0 append must not rebind, so TTFP is 12.3-11.0 = 1300 ms, not 300.
|
||||
session.mark_model_turn_request_started(0, 12.0)
|
||||
audio = session.mark_response_first_outputs(
|
||||
observed_at_s=12.3,
|
||||
has_text=False,
|
||||
has_audio=True,
|
||||
)
|
||||
assert audio["ttft_ms"] == pytest.approx(200.0)
|
||||
assert audio["ttfp_ms"] == pytest.approx(1300.0)
|
||||
|
||||
|
||||
def test_response_timing_attaches_metrics_only_when_newly_observed():
|
||||
session = _session()
|
||||
session.mark_model_turn_request_started(0, 10.0)
|
||||
session.begin_response(turn_id=0)
|
||||
|
||||
first = session.mark_response_first_outputs(observed_at_s=10.2, has_text=True, has_audio=False)
|
||||
assert first["ttft_ms"] == pytest.approx(200.0)
|
||||
assert session.mark_response_first_outputs(observed_at_s=10.3, has_text=True, has_audio=False) == {}
|
||||
audio = session.mark_response_first_outputs(observed_at_s=10.4, has_text=False, has_audio=True)
|
||||
assert audio["ttft_ms"] == pytest.approx(200.0)
|
||||
assert audio["ttfp_ms"] == pytest.approx(400.0)
|
||||
assert session.mark_response_first_outputs(observed_at_s=10.5, has_text=True, has_audio=True) == {}
|
||||
|
||||
|
||||
def test_response_timing_is_cleared_on_end_barge_in_and_close():
|
||||
session = _session()
|
||||
session.mark_model_turn_request_started(0, 10.0)
|
||||
session.begin_response(turn_id=0)
|
||||
assert session.mark_response_first_outputs(observed_at_s=10.2, has_text=True, has_audio=False)
|
||||
session.end_response()
|
||||
assert session.mark_response_first_outputs(observed_at_s=10.4, has_text=True, has_audio=False) == {}
|
||||
|
||||
session.mark_model_turn_request_started(0, 11.0)
|
||||
session.barge_in()
|
||||
session.begin_response(turn_id=0)
|
||||
assert session.mark_response_first_outputs(observed_at_s=11.2, has_text=True, has_audio=False) == {}
|
||||
|
||||
session.mark_model_turn_request_started(0, 12.0)
|
||||
session.close()
|
||||
session.begin_response(turn_id=0)
|
||||
assert session.mark_response_first_outputs(observed_at_s=12.2, has_text=True, has_audio=False) == {}
|
||||
|
||||
|
||||
def test_complete_model_turn_drops_request_starts_for_finished_turns():
|
||||
session = _session()
|
||||
session.mark_model_turn_request_started(0, 10.0)
|
||||
session.mark_model_turn_request_started(1, 11.0)
|
||||
session.complete_model_turn(0)
|
||||
session.begin_response(turn_id=1)
|
||||
assert session.mark_response_first_outputs(
|
||||
observed_at_s=11.25,
|
||||
has_text=True,
|
||||
has_audio=False,
|
||||
)["ttft_ms"] == pytest.approx(250.0)
|
||||
session.end_response()
|
||||
session.begin_response(turn_id=0)
|
||||
assert session.mark_response_first_outputs(observed_at_s=11.5, has_text=True, has_audio=False) == {}
|
||||
|
||||
|
||||
def test_log_stats_off_does_not_open_a_response_aggregator():
|
||||
session = _session()
|
||||
session.begin_response()
|
||||
session.observe_stage_request_stats(0, _stage_stats(stage_id=0))
|
||||
assert session._response_aggregator is None
|
||||
session.end_response()
|
||||
assert session._response_aggregator is None
|
||||
|
||||
|
||||
def test_end_response_logs_pending_and_active_stage_request_stats(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(OrchestratorAggregator, "build_and_log_summary", lambda self: {})
|
||||
session = _session(num_stages=2, log_stats=True)
|
||||
wire_stats = _stage_stats(stage_id=0, request_id="stage0-req", num_tokens_out=3)
|
||||
session.observe_stage_request_stats(0, wire_stats)
|
||||
assert session._response_aggregator is None
|
||||
assert wire_stats.request_id == "stage0-req"
|
||||
|
||||
response_id = session.begin_response()
|
||||
aggregator = session._response_aggregator
|
||||
assert aggregator is not None
|
||||
assert aggregator.num_stages == 2
|
||||
recorded = aggregator.stage_events[response_id]
|
||||
assert recorded[0].num_tokens_out == 3
|
||||
assert recorded[0].request_id == response_id
|
||||
assert wire_stats.request_id == "stage0-req"
|
||||
|
||||
session.observe_stage_request_stats(1, _stage_stats(stage_id=1, num_tokens_out=5, vllm_ttft_ms=0.0))
|
||||
assert [event.stage_id for event in aggregator.stage_events[response_id]] == [0, 1]
|
||||
|
||||
session.end_response()
|
||||
assert session._response_aggregator is None
|
||||
assert response_id in aggregator.e2e_done
|
||||
|
||||
|
||||
def test_end_response_prints_one_column_per_stage(monkeypatch: pytest.MonkeyPatch):
|
||||
logged: list[OrchestratorAggregator] = []
|
||||
monkeypatch.setattr(OrchestratorAggregator, "build_and_log_summary", lambda self: logged.append(self) or {})
|
||||
session = _session(num_stages=3, log_stats=True)
|
||||
session.observe_stage_request_stats(0, _stage_stats(stage_id=0, num_tokens_out=3, vllm_ttft_ms=40.0))
|
||||
response_id = session.begin_response()
|
||||
session.observe_stage_request_stats(0, _stage_stats(stage_id=0, num_tokens_out=4, vllm_ttft_ms=12.0))
|
||||
session.observe_stage_request_stats(1, _stage_stats(stage_id=1, num_tokens_out=5, vllm_ttft_ms=20.0))
|
||||
session.observe_stage_request_stats(1, _stage_stats(stage_id=1, num_tokens_out=6, vllm_ttft_ms=0.0))
|
||||
session.observe_stage_request_stats(2, _stage_stats(stage_id=2, num_tokens_out=0, vllm_ttft_ms=0.0))
|
||||
session.end_response()
|
||||
|
||||
assert len(logged) == 1
|
||||
rows = logged[0].stage_events[response_id]
|
||||
assert [event.stage_id for event in rows] == [0, 1, 2]
|
||||
assert rows[0].num_tokens_out == 7
|
||||
assert rows[0].vllm_ttft_ms == pytest.approx(40.0)
|
||||
assert rows[1].num_tokens_out == 11
|
||||
assert rows[1].vllm_ttft_ms == pytest.approx(20.0)
|
||||
assert logged[0].stage_table_exclude == DUPLEX_STAGE_TABLE_EXCLUDE
|
||||
|
||||
|
||||
def test_end_response_omits_serving_time_to_first_output_from_stage_table(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
original = OrchestratorAggregator.build_and_log_summary
|
||||
captured: list[tuple[OrchestratorAggregator, dict[str, object]]] = []
|
||||
|
||||
def _capture(self: OrchestratorAggregator) -> dict[str, object]:
|
||||
summary = original(self)
|
||||
captured.append((self, summary))
|
||||
return summary
|
||||
|
||||
monkeypatch.setattr(OrchestratorAggregator, "build_and_log_summary", _capture)
|
||||
session = _session(num_stages=3, log_stats=True)
|
||||
response_id = session.begin_response()
|
||||
session.observe_stage_request_stats(0, _stage_stats(stage_id=0, serving_time_to_first_output_ms=80.631))
|
||||
session.observe_stage_request_stats(1, _stage_stats(stage_id=1, serving_time_to_first_output_ms=418.829))
|
||||
session.observe_stage_request_stats(
|
||||
2, _stage_stats(stage_id=2, vllm_ttft_ms=0.0, serving_time_to_first_output_ms=487.109)
|
||||
)
|
||||
session.end_response()
|
||||
|
||||
assert len(captured) == 1
|
||||
aggregator, summary = captured[0]
|
||||
stage_table = summary.get("stage_table")
|
||||
assert isinstance(stage_table, list) and stage_table
|
||||
rows = stage_table[0]["stages"]
|
||||
assert isinstance(rows, list)
|
||||
assert [row["stage_id"] for row in rows] == [0, 1, 2]
|
||||
assert all("serving_time_to_first_output_ms" not in row for row in rows)
|
||||
assert all("vllm_ttft_ms" in row for row in rows)
|
||||
events = aggregator.stage_events[response_id]
|
||||
assert [event.serving_time_to_first_output_ms for event in events] == pytest.approx([80.631, 418.829, 487.109])
|
||||
|
||||
|
||||
def test_http_stage_table_keeps_serving_time_to_first_output() -> None:
|
||||
agg = OrchestratorAggregator(num_stages=2, log_stats=True, wall_start_ts=0.0, final_stage_id_for_e2e=1)
|
||||
agg.on_stage_metrics(0, "r1", _stage_stats(stage_id=0, serving_time_to_first_output_ms=80.0))
|
||||
agg.on_stage_metrics(1, "r1", _stage_stats(stage_id=1, serving_time_to_first_output_ms=418.0))
|
||||
agg.on_finalize_request(1, "r1", req_start_ts=0.0)
|
||||
|
||||
summary = agg.build_and_log_summary()
|
||||
rows = summary["stage_table"][0]["stages"]
|
||||
assert [row["serving_time_to_first_output_ms"] for row in rows] == [80.0, 418.0]
|
||||
|
||||
|
||||
def test_logged_e2e_includes_wait_before_first_output(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mono = {"t": 100.0}
|
||||
wall = {"t": 1_000.0}
|
||||
monkeypatch.setattr(time, "time", lambda: wall["t"])
|
||||
logged: list[OrchestratorAggregator] = []
|
||||
monkeypatch.setattr(OrchestratorAggregator, "build_and_log_summary", lambda self: logged.append(self) or {})
|
||||
session = _session(log_stats=True, clock=lambda: mono["t"])
|
||||
session.mark_model_turn_request_started(0, 100.0)
|
||||
|
||||
mono["t"] = 102.0
|
||||
wall["t"] = 1_002.0
|
||||
response_id = session.begin_response(turn_id=0)
|
||||
first = session.mark_response_first_outputs(observed_at_s=102.0, has_text=True, has_audio=True)
|
||||
assert first["ttft_ms"] == pytest.approx(2000.0)
|
||||
|
||||
mono["t"] = 102.1
|
||||
wall["t"] = 1_002.1
|
||||
session.end_response()
|
||||
|
||||
assert logged[0].e2e_events[0].request_id == response_id
|
||||
assert logged[0].e2e_events[0].e2e_total_ms == pytest.approx(2100.0)
|
||||
|
||||
|
||||
def test_logged_tpot_matches_client_weighted_aggregation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
logged: list[OrchestratorAggregator] = []
|
||||
monkeypatch.setattr(OrchestratorAggregator, "build_and_log_summary", lambda self: logged.append(self) or {})
|
||||
session = _session(log_stats=True)
|
||||
response_id = session.begin_response()
|
||||
session.observe_stage_request_stats(0, _stage_stats(stage_id=0, num_tokens_out=11, vllm_tpot_ms=10.0))
|
||||
session.observe_stage_request_stats(0, _stage_stats(stage_id=0, num_tokens_out=3, vllm_tpot_ms=100.0))
|
||||
session.accumulate_response_stage_metrics({"0": {"num_tokens_out": 11, "vllm_tpot_ms": 10.0}})
|
||||
client = session.accumulate_response_stage_metrics({"0": {"num_tokens_out": 3, "vllm_tpot_ms": 100.0}})
|
||||
expected = client["0"]["vllm_tpot_ms"]
|
||||
session.end_response()
|
||||
|
||||
assert expected == pytest.approx(25.0)
|
||||
assert logged[0].stage_events[response_id][0].vllm_tpot_ms == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_draining_response_stays_ack_admissible_after_next_begin_response() -> None:
|
||||
from vllm_omni.engine.duplex.events import ErrorEvent
|
||||
from vllm_omni.engine.duplex.session.playback_ledger import apply_playback_ack
|
||||
|
||||
Reference in new issue
Block a user