evalstone/evalscope/tests/perf/test_closed_loop_warmup_handoff.py
sora 13274243a0 Bump vendored EvalScope and add K3-ready DPV4 configs.
Keep K3 suite selection and report-schema scoring in bash, merge K3/vision dataset_args into dpv4 yamls, and pin EvalScope at 735d920ee911 with local patches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 07:30:48 +00:00

321 lines
12 KiB
Python

"""Closed-loop warmup hand-off.
The measured portion of a closed-loop run must start from a saturated server:
if the dispatcher drained its in-flight requests after warmup, the leading
``parallel`` measured requests would be released simultaneously against an idle
server and their TTFT would carry a start-up burst that never recurs, polluting
the reported percentiles. These tests pin the hand-off behaviour rather than
timings, so they need no real endpoint.
"""
import asyncio
import os
import sqlite3
import time
from types import SimpleNamespace
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
import pytest
from evalscope.perf import benchmark as benchmark_module
from evalscope.perf.arguments import Arguments
from evalscope.perf.benchmark import run_benchmark
from evalscope.perf.core.strategies.closed_loop import ClosedLoopStrategy
from tests.perf.test_async_lifecycle import LocalOpenAIServer
def _make_args(**kwargs: Any) -> Arguments:
"""Build Arguments with scalar (already swept) number/parallel/rate."""
number = kwargs.pop('number', 6)
parallel = kwargs.pop('parallel', 3)
rate = kwargs.pop('rate', -1)
args = Arguments(model='test-model', api='openai', number=number, parallel=parallel, rate=rate, **kwargs)
args.number = number
args.parallel = parallel
args.rate = rate
return args
class _RecordingClient:
"""Client that records server occupancy at the moment each request starts.
Each request completes after a distinct number of event-loop ticks so the
completion order is deterministic and the warmup requests finish one at a
time, mirroring the output-length spread of a real server.
"""
def __init__(self) -> None:
self.in_flight = 0
self.events: List[Dict[str, Any]] = []
self._seq = 0
async def post(self, request: Dict[str, Any]) -> SimpleNamespace:
seq = self._seq
self._seq += 1
self.events.append({
'request': request,
'occupancy_at_start': self.in_flight,
'order': seq,
})
self.in_flight += 1
try:
# Strictly increasing service times so completions never coincide,
# mirroring the output-length spread of a real server: slots free up
# one at a time and each frees exactly one waiting request.
for _ in range(2 * (seq + 1)):
await asyncio.sleep(0)
finally:
self.in_flight -= 1
return SimpleNamespace(is_warmup=False)
def _run(args: Arguments, requests: List[Tuple[dict, bool]]) -> _RecordingClient:
"""Drive ClosedLoopStrategy over ``requests`` and return the client."""
client = _RecordingClient()
async def generator() -> AsyncIterator[Tuple[dict, bool]]:
for item in requests:
yield item
async def main() -> None:
queue: asyncio.Queue = asyncio.Queue()
strategy = ClosedLoopStrategy(args, None, client, queue, generator())
await strategy.run()
asyncio.run(main())
return client
def _stream(warmup: int, measured: int) -> List[Tuple[dict, bool]]:
return [({'id': i}, i < warmup) for i in range(warmup + measured)]
# --- hand-off behaviour ---------------------------------------------------
def test_measured_portion_starts_without_draining() -> None:
"""The first measured request must find the other slots still busy."""
parallel = 4
args = _make_args(number=6, parallel=parallel)
client = _run(args, _stream(warmup=parallel, measured=6))
warmup_ids = {i for i in range(parallel)}
measured = [e for e in client.events if e['request']['id'] not in warmup_ids]
assert measured, 'no measured request was dispatched'
first_measured = min(measured, key=lambda e: e['order'])
assert first_measured['occupancy_at_start'] == parallel - 1
# Occupancy never returns to zero either: only the very first request of the
# run starts against an idle server.
later = [e['occupancy_at_start'] for e in client.events if e['order'] > 0]
assert later and all(occupancy > 0 for occupancy in later)
def test_dispatch_order_is_warmup_then_measured() -> None:
args = _make_args(number=4, parallel=2)
stream = _stream(warmup=2, measured=4)
client = _run(args, stream)
ids = [e['request']['id'] for e in sorted(client.events, key=lambda e: e['order'])]
assert ids == [0, 1, 2, 3, 4, 5]
def test_all_requests_dispatched_without_warmup() -> None:
"""warmup_num=0 keeps the previous behaviour: every request is measured."""
args = _make_args(number=5, parallel=2)
client = _run(args, _stream(warmup=0, measured=5))
assert len(client.events) == 5
def test_partial_warmup_degrades_gracefully() -> None:
"""Fewer warmup requests than slots still runs; it just covers less.
The uncovered slots are filled by measured requests released alongside the
warmup ones, so the burst is only partly absorbed - which is exactly what
the warning tells the user.
"""
parallel = 4
args = _make_args(number=6, parallel=parallel)
client = _run(args, _stream(warmup=2, measured=6))
assert len(client.events) == 8
# The opening cohort still fills every slot before anything completes, so
# it contains 2 warmup plus 2 measured requests.
opening = sorted(client.events, key=lambda e: e['order'])[:parallel]
assert [e['occupancy_at_start'] for e in opening] == list(range(parallel))
assert sum(1 for e in opening if e['request']['id'] >= 2) == 2
# --- duration anchoring ---------------------------------------------------
def test_duration_exempts_warmup_and_caps_measured() -> None:
"""Warmup is exempt from --duration; the budget starts at measurement."""
parallel, measured = 2, 20
args = _make_args(number=measured, parallel=parallel, duration=0.0)
client = _run(args, _stream(warmup=parallel, measured=measured))
dispatched_ids = {e['request']['id'] for e in client.events}
# All warmup requests ran even though the budget is exhausted instantly.
assert {0, 1}.issubset(dispatched_ids)
# The window is armed by the first measured send, so at least one measured
# request always goes out, and the cap then stops the rest.
measured_ids = {i for i in dispatched_ids if i >= parallel}
assert 1 <= len(measured_ids) < measured
def test_duration_budget_excludes_warmup_service_time() -> None:
"""The window is armed on the first measured *send*, not on task creation.
Task creation is bounded by ``max_in_flight``, not by the semaphore, so
measured tasks exist long before a slot frees up. Arming on creation would
start the clock at ~t0 and silently bill the warmup service time to
``--duration``.
"""
parallel, service_s = 2, 0.05
class _TimedClient:
def __init__(self) -> None:
self.first_warmup_done_at: Optional[float] = None
async def post(self, request: Dict[str, Any]) -> SimpleNamespace:
await asyncio.sleep(service_s)
if request['id'] < parallel and self.first_warmup_done_at is None:
self.first_warmup_done_at = time.perf_counter()
return SimpleNamespace(is_warmup=False)
class _AnchorSpy(ClosedLoopStrategy):
"""Records the moment the timed window is armed."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.anchored_at: Optional[float] = None
def _compute_deadline(self, duration: Optional[float]) -> Optional[float]:
self.anchored_at = time.perf_counter()
return super()._compute_deadline(duration)
args = _make_args(number=6, parallel=parallel, duration=10.0, warmup_num=parallel)
client = _TimedClient()
requests = _stream(warmup=parallel, measured=6)
async def generator() -> AsyncIterator[Tuple[dict, bool]]:
for item in requests:
yield item
async def main() -> _AnchorSpy:
strategy = _AnchorSpy(args, None, client, asyncio.Queue(), generator())
await strategy.run()
return strategy
strategy = asyncio.run(main())
assert strategy.anchored_at is not None
assert client.first_warmup_done_at is not None
# Arming happens no earlier than the first freed slot, so none of the warmup
# service time is charged to the measured window.
assert strategy.anchored_at >= client.first_warmup_done_at
def test_duration_none_dispatches_everything() -> None:
args = _make_args(number=4, parallel=2)
client = _run(args, _stream(warmup=2, measured=4))
assert len(client.events) == 6
# --- user-facing diagnostics ---------------------------------------------
class _RecordingLogger:
def __init__(self) -> None:
self.infos: List[str] = []
self.warnings: List[str] = []
def info(self, msg: str) -> None:
self.infos.append(msg)
def warning(self, msg: str) -> None:
self.warnings.append(msg)
def _capture_handoff_log(monkeypatch: pytest.MonkeyPatch, args: Arguments) -> _RecordingLogger:
recorder = _RecordingLogger()
monkeypatch.setattr(benchmark_module, 'logger', recorder)
benchmark_module._log_warmup_handoff(args)
return recorder
def test_warns_when_warmup_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
recorder = _capture_handoff_log(monkeypatch, _make_args(number=100, parallel=8))
assert len(recorder.warnings) == 1
assert 'Warmup is disabled' in recorder.warnings[0]
assert '--warmup-num 8' in recorder.warnings[0]
assert '16' in recorder.warnings[0]
def test_warns_when_warmup_smaller_than_parallel(monkeypatch: pytest.MonkeyPatch) -> None:
recorder = _capture_handoff_log(monkeypatch, _make_args(number=100, parallel=8, warmup_num=3))
assert len(recorder.warnings) == 1
assert 'only 3 of the 8 concurrency slots' in recorder.warnings[0]
assert '5 measured requests' in recorder.warnings[0]
def test_no_warning_for_single_concurrency(monkeypatch: pytest.MonkeyPatch) -> None:
recorder = _capture_handoff_log(monkeypatch, _make_args(number=100, parallel=1))
assert recorder.warnings == []
def test_silent_when_warmup_covers_every_slot(monkeypatch: pytest.MonkeyPatch) -> None:
recorder = _capture_handoff_log(monkeypatch, _make_args(number=100, parallel=8, warmup_num=8))
assert recorder.warnings == []
assert recorder.infos == []
# --- end to end -----------------------------------------------------------
def test_end_to_end_warmup_hits_server_but_stays_out_of_results(tmp_path) -> None:
"""Warmup requests reach the server yet never land in the result DB."""
parallel, number = 3, 5
output_dir = tmp_path / 'e2e'
output_dir.mkdir()
def make_args(port: int) -> Arguments:
args = Arguments(
model='test-model',
api='openai',
number=number,
parallel=parallel,
rate=-1,
url=f'http://127.0.0.1:{port}/v1/chat/completions',
prompt='hello',
stream=False,
no_test_connection=True,
outputs_dir=str(output_dir),
warmup_num=parallel,
)
args.number = number
args.parallel = parallel
args.rate = -1
args.outputs_dir = str(output_dir)
return args
async def run() -> int:
server = LocalOpenAIServer()
port = await server.start()
try:
args = make_args(port)
assert args.warmup_count == parallel
assert args.total_count == parallel + number
await run_benchmark(args)
return server.request_count
finally:
await server.close()
request_count = asyncio.run(run())
assert request_count == parallel + number
with sqlite3.connect(os.path.join(str(output_dir), 'benchmark_data.db')) as con:
rows = con.execute('SELECT COUNT(*) FROM result').fetchone()[0]
assert rows == number