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>
407 lines
14 KiB
Python
407 lines
14 KiB
Python
import asyncio
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any, AsyncIterator, Dict, List, Tuple
|
|
|
|
import pytest
|
|
from aiohttp import web
|
|
|
|
import evalscope.perf.main as perf_main
|
|
from evalscope.perf.arguments import Arguments
|
|
from evalscope.perf.benchmark import run_benchmark
|
|
from evalscope.perf.core import pipeline
|
|
from evalscope.perf.core.http_client import AioHttpClient
|
|
from evalscope.perf.core.strategies.closed_loop import ClosedLoopStrategy
|
|
from evalscope.perf.core.strategies.multi_turn import MultiTurnStrategy
|
|
from evalscope.perf.core.strategies.open_loop import OpenLoopStrategy
|
|
from evalscope.perf.utils.db_util import get_result_db_path
|
|
from evalscope.perf.utils.handler import exception_handler, signal_handler
|
|
|
|
|
|
def _make_args(**kwargs: Any) -> Arguments:
|
|
args = Arguments(model='test-model', api='openai', number=2, parallel=2, rate=-1, **kwargs)
|
|
args.number = 2
|
|
args.parallel = 2
|
|
args.rate = -1
|
|
return args
|
|
|
|
|
|
def test_benchmark_pipelines_own_independent_completion_events(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
completion_events: List[asyncio.Event] = []
|
|
|
|
async def fake_consumer(
|
|
queue: asyncio.Queue,
|
|
args: Arguments,
|
|
api_plugin: Any,
|
|
completed_event: asyncio.Event,
|
|
) -> Tuple[None, None, None, str]:
|
|
completion_events.append(completed_event)
|
|
while not (completed_event.is_set() and queue.empty()):
|
|
try:
|
|
await asyncio.wait_for(queue.get(), timeout=0.01)
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
queue.task_done()
|
|
return None, None, None, ''
|
|
|
|
async def run() -> None:
|
|
async def produce(queue: asyncio.Queue) -> None:
|
|
await queue.put(object())
|
|
|
|
first_queue: asyncio.Queue = asyncio.Queue()
|
|
second_queue: asyncio.Queue = asyncio.Queue()
|
|
args = _make_args()
|
|
await asyncio.gather(
|
|
pipeline.run_benchmark_pipeline(produce(first_queue), first_queue, args, None),
|
|
pipeline.run_benchmark_pipeline(produce(second_queue), second_queue, args, None),
|
|
)
|
|
|
|
monkeypatch.setattr(pipeline, 'statistic_benchmark_metric', fake_consumer)
|
|
asyncio.run(run())
|
|
|
|
assert len(completion_events) == 2
|
|
assert completion_events[0] is not completion_events[1]
|
|
assert all(event.is_set() for event in completion_events)
|
|
|
|
|
|
@pytest.mark.parametrize('failure_source', ['producer', 'consumer'])
|
|
def test_benchmark_pipeline_propagates_failure_and_cancels_peer(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
failure_source: str,
|
|
) -> None:
|
|
peer_cancelled = asyncio.Event()
|
|
|
|
async def consumer(
|
|
queue: asyncio.Queue,
|
|
args: Arguments,
|
|
api_plugin: Any,
|
|
completed_event: asyncio.Event,
|
|
) -> Tuple[None, None, None, str]:
|
|
if failure_source == 'consumer':
|
|
raise LookupError('consumer failed')
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
peer_cancelled.set()
|
|
return None, None, None, ''
|
|
|
|
async def producer() -> None:
|
|
if failure_source == 'producer':
|
|
raise ValueError('producer failed')
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
peer_cancelled.set()
|
|
|
|
async def run() -> None:
|
|
current_task = asyncio.current_task()
|
|
expected_error = ValueError if failure_source == 'producer' else LookupError
|
|
with pytest.raises(expected_error):
|
|
await pipeline.run_benchmark_pipeline(producer(), asyncio.Queue(), _make_args(), None)
|
|
assert peer_cancelled.is_set()
|
|
assert all(task is current_task or task.done() for task in asyncio.all_tasks())
|
|
|
|
monkeypatch.setattr(pipeline, 'statistic_benchmark_metric', consumer)
|
|
asyncio.run(run())
|
|
|
|
|
|
@pytest.mark.parametrize('strategy_class', [ClosedLoopStrategy, OpenLoopStrategy])
|
|
def test_strategy_failure_cancels_in_flight_requests(strategy_class: type) -> None:
|
|
blocked_request_cancelled = asyncio.Event()
|
|
|
|
class FakeClient:
|
|
|
|
async def post(self, request: Dict[str, int]) -> SimpleNamespace:
|
|
if request['id'] == 1:
|
|
await asyncio.sleep(0)
|
|
raise RuntimeError('request failed')
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
blocked_request_cancelled.set()
|
|
return SimpleNamespace(is_warmup=False)
|
|
|
|
async def request_generator() -> AsyncIterator[Tuple[dict, bool]]:
|
|
yield {'id': 1}, False
|
|
yield {'id': 2}, False
|
|
|
|
async def run() -> None:
|
|
strategy = strategy_class(_make_args(), None, FakeClient(), asyncio.Queue(), request_generator())
|
|
with pytest.raises(RuntimeError, match='request failed'):
|
|
await strategy.run()
|
|
assert blocked_request_cancelled.is_set()
|
|
current_task = asyncio.current_task()
|
|
assert all(task is current_task or task.done() for task in asyncio.all_tasks())
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_multi_turn_strategy_failure_cancels_workers() -> None:
|
|
blocked_worker_cancelled = asyncio.Event()
|
|
|
|
async def run() -> None:
|
|
strategy = MultiTurnStrategy(_make_args(), None, None, asyncio.Queue(), [[]])
|
|
|
|
async def worker(worker_id: int) -> None:
|
|
if worker_id == 0:
|
|
await asyncio.sleep(0)
|
|
raise RuntimeError('worker failed')
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
blocked_worker_cancelled.set()
|
|
|
|
strategy._worker = worker
|
|
with pytest.raises(RuntimeError, match='worker failed'):
|
|
await strategy._run_phase(budget=2, is_warmup=False)
|
|
assert blocked_worker_cancelled.is_set()
|
|
current_task = asyncio.current_task()
|
|
assert all(task is current_task or task.done() for task in asyncio.all_tasks())
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_exception_handler_preserves_original_exception() -> None:
|
|
|
|
@exception_handler
|
|
def fail() -> None:
|
|
raise KeyError('original')
|
|
|
|
with pytest.raises(KeyError, match='original'):
|
|
fail()
|
|
|
|
@exception_handler
|
|
async def async_fail() -> None:
|
|
raise LookupError('async original')
|
|
|
|
with pytest.raises(LookupError, match='async original'):
|
|
asyncio.run(async_fail())
|
|
|
|
@exception_handler
|
|
async def async_generator_fail() -> AsyncIterator[None]:
|
|
if False:
|
|
yield
|
|
raise RuntimeError('generator original')
|
|
|
|
async def consume_generator() -> None:
|
|
async for _ in async_generator_fail():
|
|
pass
|
|
|
|
with pytest.raises(RuntimeError, match='generator original'):
|
|
asyncio.run(consume_generator())
|
|
|
|
|
|
def test_existing_result_database_raises_file_exists_error(tmp_path) -> None:
|
|
db_path = tmp_path / 'benchmark_data.db'
|
|
db_path.touch()
|
|
|
|
with pytest.raises(FileExistsError, match=str(db_path)):
|
|
get_result_db_path(SimpleNamespace(outputs_dir=str(tmp_path)))
|
|
|
|
|
|
class TestSignalHandlerGracefulShutdown:
|
|
"""SIGINT/SIGTERM must cancel pending tasks instead of stopping the loop.
|
|
|
|
``loop.stop()`` used to make ``run_until_complete`` raise a confusing
|
|
``RuntimeError: Event loop stopped before Future completed`` while skipping
|
|
every ``finally`` block of the benchmark coroutine.
|
|
"""
|
|
|
|
def test_running_coroutine_is_cancelled_and_cleanup_runs(self) -> None:
|
|
loop = asyncio.new_event_loop()
|
|
cleanup: List[str] = []
|
|
|
|
async def benchmark_coroutine() -> None:
|
|
# Simulate the loop firing the registered SIGINT callback mid-run.
|
|
loop.call_soon(signal_handler, 'SIGINT', loop)
|
|
try:
|
|
await asyncio.sleep(30)
|
|
finally:
|
|
cleanup.append('ran')
|
|
|
|
try:
|
|
with pytest.raises(asyncio.CancelledError):
|
|
loop.run_until_complete(benchmark_coroutine())
|
|
assert cleanup == ['ran']
|
|
finally:
|
|
loop.close()
|
|
|
|
def test_in_flight_requests_are_cancelled_too(self) -> None:
|
|
loop = asyncio.new_event_loop()
|
|
in_flight_cleanup: List[str] = []
|
|
|
|
async def in_flight_request() -> None:
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
in_flight_cleanup.append('ran')
|
|
|
|
async def benchmark_coroutine() -> None:
|
|
_task = asyncio.create_task(in_flight_request())
|
|
await asyncio.sleep(0) # let the in-flight request start
|
|
loop.call_soon(signal_handler, 'SIGTERM', loop)
|
|
await asyncio.sleep(30)
|
|
|
|
try:
|
|
with pytest.raises(asyncio.CancelledError):
|
|
loop.run_until_complete(benchmark_coroutine())
|
|
# Drain the cancellation delivered to the in-flight request.
|
|
loop.run_until_complete(asyncio.sleep(0))
|
|
assert in_flight_cleanup == ['ran']
|
|
finally:
|
|
loop.close()
|
|
|
|
def test_sigint_exits_cleanly_after_cleanup(self, tmp_path: Path) -> None:
|
|
script = textwrap.dedent(
|
|
"""
|
|
import asyncio
|
|
import os
|
|
import signal
|
|
import sys
|
|
|
|
sys.path.insert(0, sys.argv[2])
|
|
|
|
from evalscope.perf.arguments import Arguments
|
|
import evalscope.perf.main as perf_main
|
|
|
|
args = Arguments(model='test-model', api='openai', number=1, parallel=1, rate=-1)
|
|
args.number = 1
|
|
args.parallel = 1
|
|
args.rate = -1
|
|
|
|
async def interrupted(_: Arguments) -> None:
|
|
loop = asyncio.get_running_loop()
|
|
loop.call_later(0.05, os.kill, os.getpid(), signal.SIGINT)
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
print('CLEANUP_RAN=True', flush=True)
|
|
|
|
output_path = sys.argv[1]
|
|
|
|
def run_test_perf(cli_args: object) -> None:
|
|
perf_main.run_one_benchmark(args, output_path)
|
|
|
|
perf_main.run_benchmark = interrupted
|
|
perf_main.run_perf_benchmark = run_test_perf
|
|
|
|
from evalscope.cli.cli import run_cmd
|
|
|
|
sys.argv = ['evalscope', 'perf', '--model', 'test-model']
|
|
run_cmd()
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, '-c', script, str(tmp_path), str(Path(__file__).parents[2])],
|
|
cwd=tmp_path,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
|
|
assert result.returncode == 130
|
|
assert 'CLEANUP_RAN=True' in result.stdout
|
|
assert 'Traceback' not in result.stderr
|
|
assert 'CancelledError' not in result.stderr
|
|
|
|
def test_internal_cancellation_is_not_treated_as_a_signal(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
async def cancelled_benchmark(_args: Arguments) -> None:
|
|
raise asyncio.CancelledError
|
|
|
|
monkeypatch.setattr(perf_main, 'run_benchmark', cancelled_benchmark)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
perf_main.run_one_benchmark(_make_args(), str(tmp_path))
|
|
|
|
|
|
def test_aiohttp_client_context_returns_self_and_closes() -> None:
|
|
async def run() -> None:
|
|
client = AioHttpClient(_make_args(), None)
|
|
async with client as entered:
|
|
assert entered is client
|
|
assert not client.client.closed
|
|
assert client.client.closed
|
|
await client.__aexit__(None, None, None)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
class LocalOpenAIServer:
|
|
|
|
def __init__(self) -> None:
|
|
self.request_count = 0
|
|
self.runner: Any = None
|
|
|
|
async def handle(self, request: web.Request) -> web.Response:
|
|
await request.json()
|
|
self.request_count += 1
|
|
return web.json_response({
|
|
'id': 'chatcmpl-local',
|
|
'object': 'chat.completion',
|
|
'choices': [{
|
|
'index': 0,
|
|
'message': {
|
|
'role': 'assistant',
|
|
'content': 'ok'
|
|
},
|
|
'finish_reason': 'stop',
|
|
}],
|
|
'usage': {
|
|
'prompt_tokens': 1,
|
|
'completion_tokens': 1,
|
|
'total_tokens': 2
|
|
},
|
|
})
|
|
|
|
async def start(self) -> int:
|
|
app = web.Application()
|
|
app.router.add_post('/v1/chat/completions', self.handle)
|
|
self.runner = web.AppRunner(app)
|
|
await self.runner.setup()
|
|
site = web.TCPSite(self.runner, '127.0.0.1', 0)
|
|
await site.start()
|
|
return site._server.sockets[0].getsockname()[1]
|
|
|
|
async def close(self) -> None:
|
|
await self.runner.cleanup()
|
|
|
|
|
|
def test_local_benchmarks_run_sequentially_and_concurrently(tmp_path) -> None:
|
|
def make_args(port: int, name: str) -> Arguments:
|
|
output_dir = tmp_path / name
|
|
output_dir.mkdir()
|
|
args = _make_args(
|
|
url=f'http://127.0.0.1:{port}/v1/chat/completions',
|
|
prompt='hello',
|
|
stream=False,
|
|
no_test_connection=True,
|
|
outputs_dir=str(output_dir),
|
|
)
|
|
args.outputs_dir = str(output_dir)
|
|
return args
|
|
|
|
async def run() -> int:
|
|
server = LocalOpenAIServer()
|
|
port = await server.start()
|
|
try:
|
|
await run_benchmark(make_args(port, 'sequential-one'))
|
|
await run_benchmark(make_args(port, 'sequential-two'))
|
|
await asyncio.gather(
|
|
run_benchmark(make_args(port, 'concurrent-one')),
|
|
run_benchmark(make_args(port, 'concurrent-two')),
|
|
)
|
|
return server.request_count
|
|
finally:
|
|
await server.close()
|
|
|
|
assert asyncio.run(run()) == 8
|