Files
2026-07-13 12:24:33 +08:00

133 lines
4.6 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Abstract base class for engine benchmark workloads."""
# Standard
from abc import ABC, abstractmethod
import asyncio
import queue
import time
# First Party
from lmcache.cli.commands.bench.engine_bench.progress import ProgressMonitor
from lmcache.cli.commands.bench.engine_bench.request_sender import RequestSender
from lmcache.cli.commands.bench.engine_bench.stats import StatsCollector
from lmcache.logging import init_logger
logger = init_logger(__name__)
class BaseWorkload(ABC):
"""Abstract base class for all engine benchmark workloads.
Owns the internal dispatch loop that calls ``step()`` on the
concrete workload. Provides a thread-safe ``request_finished``
callback that enqueues completed requests for the loop to drain.
"""
def __init__(
self,
request_sender: RequestSender,
stats_collector: StatsCollector,
progress_monitor: ProgressMonitor,
) -> None:
self._request_sender = request_sender
self._stats_collector = stats_collector
self._progress_monitor = progress_monitor
self._finished_queue: queue.Queue[tuple[str, str]] = queue.Queue()
# ------------------------------------------------------------------
# Abstract methods
# ------------------------------------------------------------------
@abstractmethod
async def warmup(self) -> None:
"""Run warmup requests. Blocks until all warmup is done."""
@abstractmethod
async def step(self, time_offset: float) -> float:
"""Execute one step of the workload.
Args:
time_offset: seconds since benchmark start.
Returns:
Next wakeup time offset (absolute, from benchmark start).
Return a negative value to signal that the workload is done.
"""
@abstractmethod
def log_config(self) -> None:
"""Log key workload config before the benchmark starts."""
@abstractmethod
def on_request_finished(self, request_id: str, output: str) -> None:
"""Called when a request finishes (from the loop thread).
Stateless workloads can implement this as a no-op.
Stateful workloads use it to record responses in session history.
"""
# ------------------------------------------------------------------
# Concrete methods
# ------------------------------------------------------------------
def run(self) -> None:
"""Run warmup + benchmark, closing the HTTP client in the same loop.
Blocks until the workload is complete. The ``RequestSender``'s
httpx transports are bound to this loop, so they must be closed
before ``asyncio.run`` destroys it — otherwise a later close
from a fresh loop raises ``RuntimeError: Event loop is closed``.
"""
async def _run_and_close() -> None:
try:
await self._run_async()
finally:
await self._request_sender.close()
asyncio.run(_run_and_close())
async def _run_async(self) -> None:
"""Internal async implementation of the run loop."""
self._progress_monitor.log_message("Starting warmup phase")
await self.warmup()
self._progress_monitor.log_message(
"Warmup complete, starting benchmark",
)
self._stats_collector.reset()
self._drain_finished_queue() # discard warmup completions
start_time = time.monotonic()
while True:
self._drain_finished_queue()
time_offset = time.monotonic() - start_time
next_wakeup = await self.step(time_offset)
if next_wakeup < 0:
break
sleep_duration = max(0.0, next_wakeup - (time.monotonic() - start_time))
if sleep_duration > 0:
await asyncio.sleep(sleep_duration)
self._drain_finished_queue() # final drain
self._progress_monitor.log_message("Benchmark complete")
def request_finished(self, result, response_text: str) -> None:
"""Thread-safe callback matching ``OnFinishedCallback`` signature.
Enqueues ``(request_id, response_text)`` for the loop to drain.
Registered on ``RequestSender.on_finished`` by the orchestrator.
"""
self._finished_queue.put((result.request_id, response_text))
def _drain_finished_queue(self) -> None:
"""Drain all completed requests and call ``on_request_finished``."""
while True:
try:
request_id, output = self._finished_queue.get_nowait()
except queue.Empty:
break
self.on_request_finished(request_id, output)