825 lines
30 KiB
Python
825 lines
30 KiB
Python
"""Interactive server and client for the multi-turn benchmark.
|
|
|
|
The interactive server runs a long-lived benchmark loop whose QPS, workload
|
|
parameters, and measurement windows are controlled at runtime via a UNIX
|
|
domain socket. The interactive client connects to that socket (either as an
|
|
interactive REPL or for one-shot commands).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import random
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import aiohttp
|
|
import numpy as np
|
|
|
|
from ray.llm._internal.serve.benchmark.metrics import (
|
|
serialize_raw_metrics,
|
|
summarize_metrics,
|
|
)
|
|
from ray.llm._internal.serve.benchmark.models import TurnMetric, WorkloadSpec
|
|
from ray.llm._internal.serve.benchmark.text_gen import (
|
|
Conversation,
|
|
TextGenerator,
|
|
conversation_factory,
|
|
)
|
|
from ray.llm._internal.serve.benchmark.turn import execute_single_turn
|
|
|
|
try:
|
|
from prompt_toolkit import PromptSession
|
|
from prompt_toolkit.history import FileHistory
|
|
except ImportError:
|
|
PromptSession = None # type: ignore[assignment,misc]
|
|
FileHistory = None # type: ignore[assignment,misc]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Control socket path
|
|
# ---------------------------------------------------------------------------
|
|
_DEFAULT_CONTROL_SOCKET = "/tmp/interactive_rate_bench.sock"
|
|
|
|
|
|
def _control_socket_path() -> str:
|
|
return os.environ.get("RAY_BENCH_CONTROL_SOCKET", _DEFAULT_CONTROL_SOCKET)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Process-pool worker helpers (module-level so they are picklable)
|
|
# ---------------------------------------------------------------------------
|
|
_worker_tokenizer = None
|
|
_worker_text_gen: Optional[TextGenerator] = None
|
|
|
|
|
|
def _pool_initializer(tokenizer_name: str, base_seed: int) -> None:
|
|
"""Called once per worker process to load the tokenizer and seed RNG."""
|
|
global _worker_tokenizer, _worker_text_gen
|
|
from transformers import AutoTokenizer
|
|
|
|
_worker_tokenizer = AutoTokenizer.from_pretrained(
|
|
tokenizer_name, trust_remote_code=True
|
|
)
|
|
_worker_text_gen = TextGenerator(_worker_tokenizer)
|
|
proc_seed = (base_seed + os.getpid()) % (2**32)
|
|
random.seed(proc_seed)
|
|
np.random.seed(proc_seed)
|
|
|
|
|
|
def _create_conv_in_worker(
|
|
session_idx: int,
|
|
spec: WorkloadSpec,
|
|
shared_system_text: str,
|
|
) -> Conversation:
|
|
"""Create a Conversation inside a worker process."""
|
|
return conversation_factory(session_idx, spec, shared_system_text, _worker_text_gen)
|
|
|
|
|
|
# ============================================================================
|
|
# Interactive-mode runtime state & helpers
|
|
# ============================================================================
|
|
|
|
|
|
@dataclass
|
|
class RuntimeState:
|
|
current_qps: float = 0.0
|
|
total_completed: int = 0
|
|
total_failed: int = 0
|
|
inflight: int = 0
|
|
measurement_active: bool = False
|
|
measurement_start_ns: Optional[int] = None
|
|
measurement_metrics: list[TurnMetric] = field(default_factory=list)
|
|
measurement_target_requests: Optional[int] = None
|
|
last_window_metrics: list[TurnMetric] = field(default_factory=list)
|
|
last_window_elapsed_s: float = 0.0
|
|
last_notice: Optional[str] = None
|
|
save_dir: Optional[str] = None
|
|
|
|
|
|
def _save_window_result(
|
|
path: str,
|
|
args: argparse.Namespace,
|
|
spec: WorkloadSpec,
|
|
metrics: list[TurnMetric],
|
|
elapsed_s: float,
|
|
runtime_qps: float = 0.0,
|
|
) -> None:
|
|
payload = {
|
|
"mode": "interactive_rate",
|
|
"saved_at_epoch_s": time.time(),
|
|
"config": {
|
|
"base_url": args.base_url,
|
|
"model": args.model,
|
|
"tokenizer": getattr(args, "tokenizer", None) or args.model,
|
|
"first_chunk_threshold": args.first_chunk_threshold,
|
|
"num_turns": args.num_turns,
|
|
"osl": args.osl,
|
|
"shared_system_prompt_ratio": args.shared_system_prompt_ratio,
|
|
"isl": args.isl,
|
|
"hit_rate": args.hit_rate,
|
|
"runtime_qps": runtime_qps,
|
|
},
|
|
"spec": spec.summary(),
|
|
"window": summarize_metrics(metrics, elapsed_s),
|
|
"raw_metrics": serialize_raw_metrics(metrics),
|
|
}
|
|
p = Path(path)
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
with p.open("w") as f:
|
|
json.dump(payload, f, indent=2)
|
|
print(f"Saved measurement window to {path}")
|
|
|
|
|
|
def _build_spec(
|
|
args: argparse.Namespace, overrides: Optional[dict] = None
|
|
) -> WorkloadSpec:
|
|
"""Build and resolve a WorkloadSpec from args, optionally merging overrides."""
|
|
kw = dict(
|
|
num_sessions=1,
|
|
duration_s=1.0,
|
|
num_turns=args.num_turns,
|
|
osl=args.osl,
|
|
think_time=0.0,
|
|
concurrency=None,
|
|
request_rate=1.0,
|
|
ramp_interval=0.0,
|
|
shared_system_prompt_ratio=args.shared_system_prompt_ratio,
|
|
isl=args.isl,
|
|
hit_rate=args.hit_rate,
|
|
)
|
|
if overrides:
|
|
kw.update(overrides)
|
|
spec = WorkloadSpec(**kw)
|
|
spec.resolve()
|
|
return spec
|
|
|
|
|
|
# ============================================================================
|
|
# Command handler (extracted for testability)
|
|
# ============================================================================
|
|
|
|
|
|
class CommandHandler:
|
|
"""Handles interactive benchmark commands.
|
|
|
|
Extracted from the ``run_interactive`` closure so that command parsing,
|
|
state mutation, and response formatting can be unit-tested without
|
|
starting a real server or HTTP session.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
runtime: RuntimeState,
|
|
workload: dict,
|
|
args: argparse.Namespace,
|
|
text_gen: Optional[TextGenerator] = None,
|
|
rate_changed: Optional[asyncio.Event] = None,
|
|
workload_changed: Optional[asyncio.Event] = None,
|
|
stop_event: Optional[asyncio.Event] = None,
|
|
):
|
|
self.runtime = runtime
|
|
self.workload = workload
|
|
self.args = args
|
|
self.text_gen = text_gen
|
|
self.rate_changed = rate_changed or asyncio.Event()
|
|
self.workload_changed = workload_changed or asyncio.Event()
|
|
self.stop_event = stop_event or asyncio.Event()
|
|
|
|
def resolve_save_path(self, raw: Optional[str]) -> str:
|
|
if raw:
|
|
expanded = str(Path(raw).expanduser())
|
|
if "/" in expanded or expanded.startswith("."):
|
|
return expanded
|
|
return str(Path(self.runtime.save_dir) / expanded)
|
|
|
|
if self.args.save_result:
|
|
return str(Path(self.args.save_result).expanduser())
|
|
|
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
qps_label = f"{self.runtime.current_qps:.2f}".replace(".", "p")
|
|
return str(
|
|
Path(self.runtime.save_dir)
|
|
/ f"interactive_measure_qps{qps_label}_{ts}.json"
|
|
)
|
|
|
|
async def handle(self, cmd: str) -> str: # noqa: C901
|
|
"""Process a single command string and return the response."""
|
|
cmd = cmd.strip()
|
|
if not cmd:
|
|
return "empty command"
|
|
|
|
parts = cmd.split()
|
|
op = parts[0].lower()
|
|
|
|
if op == "help":
|
|
return (
|
|
"Commands: help, rate <qps>, start, measure <n>, stop, "
|
|
"status, save [path|name], save-dir <path>, quit\n"
|
|
"Workload: workload [isl=N] [osl=N] [hit-rate=F] "
|
|
"[sharing=F] [num-turns=N]\n"
|
|
" e.g. workload isl=2000 osl=200 hit-rate=0.5\n"
|
|
" All params optional; unspecified ones keep their current values.\n"
|
|
" workload (no args) prints current workload spec."
|
|
)
|
|
if op == "rate":
|
|
if len(parts) != 2:
|
|
return "Usage: rate <qps>"
|
|
try:
|
|
new_qps = float(parts[1])
|
|
if new_qps < 0:
|
|
raise ValueError()
|
|
except ValueError:
|
|
return "QPS must be a non-negative number."
|
|
self.runtime.current_qps = new_qps
|
|
self.rate_changed.set()
|
|
return f"Set target qps={new_qps:.3f}"
|
|
if op == "start":
|
|
self.runtime.measurement_active = True
|
|
self.runtime.measurement_start_ns = time.perf_counter_ns()
|
|
self.runtime.measurement_metrics = []
|
|
self.runtime.measurement_target_requests = None
|
|
self.runtime.last_notice = None
|
|
return "Measurement started."
|
|
if op == "measure":
|
|
if len(parts) != 2:
|
|
return "Usage: measure <num_requests>"
|
|
try:
|
|
tgt = int(parts[1])
|
|
if tgt <= 0:
|
|
raise ValueError()
|
|
except ValueError:
|
|
return "measure requires a positive integer."
|
|
self.runtime.measurement_active = True
|
|
self.runtime.measurement_start_ns = time.perf_counter_ns()
|
|
self.runtime.measurement_metrics = []
|
|
self.runtime.measurement_target_requests = tgt
|
|
self.runtime.last_notice = None
|
|
return f"Measurement started: capturing next {tgt} completed requests."
|
|
if op == "stop":
|
|
if not self.runtime.measurement_active:
|
|
return "Measurement is not active."
|
|
self.runtime.measurement_active = False
|
|
end_ns = time.perf_counter_ns()
|
|
start_ns = self.runtime.measurement_start_ns or end_ns
|
|
self.runtime.last_window_elapsed_s = (end_ns - start_ns) / 1e9
|
|
self.runtime.last_window_metrics = list(self.runtime.measurement_metrics)
|
|
self.runtime.measurement_target_requests = None
|
|
summary = summarize_metrics(
|
|
list(self.runtime.last_window_metrics),
|
|
self.runtime.last_window_elapsed_s,
|
|
)
|
|
return f"Measurement stopped.\n{json.dumps(summary, indent=2)}"
|
|
if op == "status":
|
|
cur = self.workload["spec"]
|
|
status = (
|
|
f"qps={self.runtime.current_qps:.2f} "
|
|
f"inflight={self.runtime.inflight} "
|
|
f"completed={self.runtime.total_completed} "
|
|
f"failed={self.runtime.total_failed} "
|
|
f"measured={len(self.runtime.measurement_metrics)} "
|
|
f"active={self.runtime.measurement_active} "
|
|
f"target={self.runtime.measurement_target_requests} "
|
|
f"save_dir={self.runtime.save_dir}\n"
|
|
f"workload: isl={cur.isl} osl={cur.osl} hit-rate={cur.hit_rate} "
|
|
f"sharing={cur.shared_system_prompt_ratio} num-turns={cur.num_turns}"
|
|
)
|
|
if self.runtime.last_notice:
|
|
status += f"\n{self.runtime.last_notice}"
|
|
self.runtime.last_notice = None
|
|
return status
|
|
if op == "save-dir":
|
|
if len(parts) != 2:
|
|
return "Usage: save-dir <path>"
|
|
new_dir = str(Path(parts[1]).expanduser())
|
|
self.runtime.save_dir = new_dir
|
|
return f"Set save_dir={self.runtime.save_dir}"
|
|
if op == "save":
|
|
if len(parts) > 2:
|
|
return "Usage: save [path.json|name.json]"
|
|
|
|
if (
|
|
self.runtime.measurement_active
|
|
and self.runtime.measurement_start_ns is not None
|
|
):
|
|
el = (time.perf_counter_ns() - self.runtime.measurement_start_ns) / 1e9
|
|
mlist = list(self.runtime.measurement_metrics)
|
|
else:
|
|
el = self.runtime.last_window_elapsed_s
|
|
mlist = list(self.runtime.last_window_metrics)
|
|
if not mlist:
|
|
return "No measured window data to save."
|
|
save_path = self.resolve_save_path(parts[1] if len(parts) == 2 else None)
|
|
_save_window_result(
|
|
save_path,
|
|
self.args,
|
|
self.workload["spec"],
|
|
mlist,
|
|
el,
|
|
runtime_qps=self.runtime.current_qps,
|
|
)
|
|
return f"Saved measurement window to {save_path}"
|
|
if op == "workload":
|
|
cur = self.workload["spec"]
|
|
if len(parts) == 1:
|
|
return (
|
|
f"isl={cur.isl} osl={cur.osl} hit-rate={cur.hit_rate} "
|
|
f"sharing={cur.shared_system_prompt_ratio} num-turns={cur.num_turns}"
|
|
)
|
|
_param_aliases = {
|
|
"isl": "isl",
|
|
"osl": "osl",
|
|
"hit-rate": "hit_rate",
|
|
"hitrate": "hit_rate",
|
|
"hit_rate": "hit_rate",
|
|
"sharing": "shared_system_prompt_ratio",
|
|
"shared-system-prompt-ratio": "shared_system_prompt_ratio",
|
|
"shared_system_prompt_ratio": "shared_system_prompt_ratio",
|
|
"num-turns": "num_turns",
|
|
"num_turns": "num_turns",
|
|
}
|
|
overrides: dict = {}
|
|
errors: list[str] = []
|
|
for token in parts[1:]:
|
|
if "=" not in token:
|
|
errors.append(f"bad token {token!r} (expected key=value)")
|
|
continue
|
|
k, _, v = token.partition("=")
|
|
mapped = _param_aliases.get(k.lower())
|
|
if mapped is None:
|
|
errors.append(f"unknown param {k!r}")
|
|
continue
|
|
try:
|
|
overrides[mapped] = (
|
|
int(v) if mapped in ("isl", "osl", "num_turns") else float(v)
|
|
)
|
|
except ValueError:
|
|
errors.append(f"invalid value for {k}: {v!r}")
|
|
if errors:
|
|
return "Error: " + "; ".join(errors)
|
|
merged = dict(
|
|
isl=cur.isl,
|
|
osl=cur.osl,
|
|
hit_rate=cur.hit_rate,
|
|
shared_system_prompt_ratio=cur.shared_system_prompt_ratio,
|
|
num_turns=cur.num_turns,
|
|
)
|
|
merged.update(overrides)
|
|
try:
|
|
new_spec = _build_spec(self.args, merged)
|
|
except Exception as e:
|
|
return f"Invalid workload spec: {e}"
|
|
if self.text_gen is not None:
|
|
new_sst = self.text_gen.generate(new_spec.shared_s)
|
|
else:
|
|
new_sst = ""
|
|
self.workload["spec"] = new_spec
|
|
self.workload["shared_system_text"] = new_sst
|
|
self.workload_changed.set()
|
|
new_spec.print_summary()
|
|
return (
|
|
f"Workload updated: isl={new_spec.isl} osl={new_spec.osl} "
|
|
f"hit-rate={new_spec.hit_rate} "
|
|
f"sharing={new_spec.shared_system_prompt_ratio} "
|
|
f"num-turns={new_spec.num_turns}"
|
|
)
|
|
if op in ("quit", "exit"):
|
|
self.stop_event.set()
|
|
return "Stopping benchmark..."
|
|
return f"Unknown command: {op}"
|
|
|
|
|
|
# ============================================================================
|
|
# Interactive server
|
|
# ============================================================================
|
|
|
|
|
|
async def run_interactive(args: argparse.Namespace) -> None:
|
|
spec = _build_spec(args)
|
|
spec.print_summary()
|
|
print("Interactive mode: starts idle. Use 'rate <qps>' to begin sending traffic.")
|
|
|
|
from concurrent.futures import ProcessPoolExecutor
|
|
|
|
from transformers import AutoTokenizer
|
|
|
|
tokenizer_name: str = args.tokenizer if args.tokenizer else args.model
|
|
|
|
if args.seed is None:
|
|
args.seed = random.randint(0, 2**31 - 1)
|
|
random.seed(args.seed)
|
|
np.random.seed(args.seed % (2**32))
|
|
print(f"Seed: {args.seed}")
|
|
|
|
print(f"Loading tokenizer: {tokenizer_name}")
|
|
tokenizer = AutoTokenizer.from_pretrained(tokenizer_name, trust_remote_code=True)
|
|
text_gen = TextGenerator(tokenizer)
|
|
|
|
shared_system_text = text_gen.generate(spec.shared_s)
|
|
bench_start_ns = time.perf_counter_ns()
|
|
|
|
workload: dict = {"spec": spec, "shared_system_text": shared_system_text}
|
|
workload_changed = asyncio.Event()
|
|
|
|
default_save_dir = args.save_dir
|
|
if default_save_dir is None and args.save_result:
|
|
default_save_dir = str(Path(args.save_result).parent)
|
|
if default_save_dir is None:
|
|
default_save_dir = os.getcwd()
|
|
|
|
runtime = RuntimeState(
|
|
current_qps=0.0,
|
|
save_dir=str(Path(default_save_dir).expanduser()),
|
|
)
|
|
stop_event = asyncio.Event()
|
|
rate_changed = asyncio.Event()
|
|
ready_queue: asyncio.Queue[tuple[Conversation, int]] = asyncio.Queue()
|
|
next_session_idx = 0
|
|
running_tasks: set[asyncio.Task] = set()
|
|
|
|
num_workers = args.num_workers
|
|
print(f"Starting process pool with {num_workers} workers")
|
|
cpu_pool = ProcessPoolExecutor(
|
|
max_workers=num_workers,
|
|
initializer=_pool_initializer,
|
|
initargs=(tokenizer_name, args.seed),
|
|
)
|
|
loop = asyncio.get_running_loop()
|
|
|
|
def _next_session_idx() -> int:
|
|
nonlocal next_session_idx
|
|
idx = next_session_idx
|
|
next_session_idx += 1
|
|
return idx
|
|
|
|
async def next_conv_async() -> Conversation:
|
|
idx = _next_session_idx()
|
|
s = workload["spec"]
|
|
sst = workload["shared_system_text"]
|
|
return await loop.run_in_executor(
|
|
cpu_pool,
|
|
_create_conv_in_worker,
|
|
idx,
|
|
s,
|
|
sst,
|
|
)
|
|
|
|
async def prefill_queue() -> None:
|
|
while not stop_event.is_set():
|
|
if workload_changed.is_set():
|
|
workload_changed.clear()
|
|
drained = 0
|
|
while not ready_queue.empty():
|
|
try:
|
|
ready_queue.get_nowait()
|
|
drained += 1
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
if drained:
|
|
print(
|
|
f"[workload] drained {drained} stale conversations from queue.",
|
|
flush=True,
|
|
)
|
|
|
|
qps = runtime.current_qps
|
|
if qps <= 0:
|
|
await asyncio.sleep(0.2)
|
|
continue
|
|
s = workload["spec"]
|
|
sst = workload["shared_system_text"]
|
|
target = max(8, int(qps * 2))
|
|
current = ready_queue.qsize()
|
|
if current < target:
|
|
batch_size = min(target - current, num_workers * 2)
|
|
idxs = [_next_session_idx() for _ in range(batch_size)]
|
|
futs = [
|
|
loop.run_in_executor(
|
|
cpu_pool,
|
|
_create_conv_in_worker,
|
|
idx,
|
|
s,
|
|
sst,
|
|
)
|
|
for idx in idxs
|
|
]
|
|
for fut in asyncio.as_completed(futs):
|
|
try:
|
|
conv = await fut
|
|
await ready_queue.put((conv, 0))
|
|
except Exception as e:
|
|
logger.warning("Failed to create conversation in worker: %s", e)
|
|
await asyncio.sleep(0.02)
|
|
|
|
async def execute_turn(
|
|
conv: Conversation, turn_idx: int, http_session: aiohttp.ClientSession
|
|
) -> None:
|
|
cur_spec = workload["spec"]
|
|
runtime.inflight += 1
|
|
try:
|
|
outcome = await execute_single_turn(
|
|
http_session=http_session,
|
|
conv=conv,
|
|
turn_idx=turn_idx,
|
|
base_url=args.base_url,
|
|
model=args.model,
|
|
max_tokens=cur_spec.osl,
|
|
bench_start_ns=bench_start_ns,
|
|
first_chunk_threshold=args.first_chunk_threshold,
|
|
api_key=getattr(args, "api_key", None),
|
|
)
|
|
metric = outcome.metric
|
|
auto_complete_summary: Optional[str] = None
|
|
runtime.total_completed += 1
|
|
if runtime.measurement_active:
|
|
target = runtime.measurement_target_requests
|
|
if target is None:
|
|
runtime.measurement_metrics.append(metric)
|
|
elif len(runtime.measurement_metrics) < target:
|
|
runtime.measurement_metrics.append(metric)
|
|
|
|
if target is not None and len(runtime.measurement_metrics) >= target:
|
|
runtime.measurement_active = False
|
|
end_ns = time.perf_counter_ns()
|
|
start_ns = runtime.measurement_start_ns or end_ns
|
|
runtime.last_window_elapsed_s = (end_ns - start_ns) / 1e9
|
|
runtime.last_window_metrics = list(
|
|
runtime.measurement_metrics[:target]
|
|
)
|
|
runtime.measurement_target_requests = None
|
|
summary = summarize_metrics(
|
|
runtime.last_window_metrics,
|
|
runtime.last_window_elapsed_s,
|
|
)
|
|
auto_complete_summary = json.dumps(summary, indent=2)
|
|
runtime.last_notice = (
|
|
f"measurement auto-complete ({target} req):\n"
|
|
f"{auto_complete_summary}"
|
|
)
|
|
|
|
if auto_complete_summary is not None:
|
|
print("Measurement auto-complete:")
|
|
print(auto_complete_summary)
|
|
|
|
next_turn = turn_idx + 1
|
|
if not stop_event.is_set():
|
|
if next_turn < cur_spec.num_turns:
|
|
await ready_queue.put((conv, next_turn))
|
|
else:
|
|
conv = await next_conv_async()
|
|
await ready_queue.put((conv, 0))
|
|
except Exception as e:
|
|
if args.log_failures:
|
|
print(
|
|
f"[request-failed] session={conv.session_id} turn={turn_idx}: {e}"
|
|
)
|
|
runtime.total_failed += 1
|
|
if not stop_event.is_set():
|
|
conv = await next_conv_async()
|
|
await ready_queue.put((conv, 0))
|
|
finally:
|
|
runtime.inflight -= 1
|
|
|
|
async def pacer(http_session: aiohttp.ClientSession) -> None:
|
|
next_dispatch = time.perf_counter()
|
|
while not stop_event.is_set():
|
|
qps = runtime.current_qps
|
|
|
|
if qps <= 0:
|
|
await asyncio.sleep(0.1)
|
|
next_dispatch = time.perf_counter() + 0.05
|
|
continue
|
|
|
|
if rate_changed.is_set():
|
|
rate_changed.clear()
|
|
next_dispatch = time.perf_counter() + (1.0 / qps)
|
|
|
|
now = time.perf_counter()
|
|
if next_dispatch < now - 1.0:
|
|
next_dispatch = now
|
|
|
|
wait = next_dispatch - now
|
|
if wait > 0:
|
|
await asyncio.sleep(wait)
|
|
if stop_event.is_set():
|
|
break
|
|
|
|
try:
|
|
conv, turn_idx = ready_queue.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
next_dispatch += 1.0 / qps
|
|
continue
|
|
|
|
t = asyncio.create_task(execute_turn(conv, turn_idx, http_session))
|
|
running_tasks.add(t)
|
|
t.add_done_callback(running_tasks.discard)
|
|
next_dispatch += 1.0 / qps
|
|
|
|
async def reporter() -> None:
|
|
if args.status_interval <= 0:
|
|
return
|
|
while not stop_event.is_set():
|
|
await asyncio.sleep(args.status_interval)
|
|
print(
|
|
"status: "
|
|
f"qps={runtime.current_qps:.2f} "
|
|
f"inflight={runtime.inflight} "
|
|
f"completed={runtime.total_completed} "
|
|
f"failed={runtime.total_failed} "
|
|
f"measured={len(runtime.measurement_metrics)} "
|
|
f"active={runtime.measurement_active}",
|
|
flush=True,
|
|
)
|
|
|
|
cmd_handler = CommandHandler(
|
|
runtime=runtime,
|
|
workload=workload,
|
|
args=args,
|
|
text_gen=text_gen,
|
|
rate_changed=rate_changed,
|
|
workload_changed=workload_changed,
|
|
stop_event=stop_event,
|
|
)
|
|
handle_command = cmd_handler.handle
|
|
|
|
async def stdin_command_loop() -> None:
|
|
print(
|
|
"Interactive commands: help | rate <qps> | start | measure <n> | "
|
|
"stop | status | workload [k=v ...] | save [path|name] | "
|
|
"save-dir <path> | quit"
|
|
)
|
|
while not stop_event.is_set():
|
|
raw = await asyncio.to_thread(input, "bench> ")
|
|
resp = await handle_command(raw)
|
|
if resp:
|
|
print(resp)
|
|
|
|
async def socket_command_handler(
|
|
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
) -> None:
|
|
try:
|
|
data = await reader.read(4096)
|
|
cmd = data.decode("utf-8", errors="replace").strip()
|
|
resp = await handle_command(cmd)
|
|
writer.write((resp + "\n").encode("utf-8"))
|
|
await writer.drain()
|
|
finally:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
# Seed the ready queue
|
|
seed_count = 2
|
|
seed_idxs = [_next_session_idx() for _ in range(seed_count)]
|
|
seed_futs = [
|
|
loop.run_in_executor(
|
|
cpu_pool,
|
|
_create_conv_in_worker,
|
|
idx,
|
|
spec,
|
|
shared_system_text,
|
|
)
|
|
for idx in seed_idxs
|
|
]
|
|
for conv in await asyncio.gather(*seed_futs):
|
|
await ready_queue.put((conv, 0))
|
|
|
|
control_socket = _control_socket_path()
|
|
if os.path.exists(control_socket):
|
|
os.unlink(control_socket)
|
|
socket_server = await asyncio.start_unix_server(
|
|
socket_command_handler,
|
|
path=control_socket,
|
|
)
|
|
print(f"Control socket listening at: {control_socket}")
|
|
|
|
stdin_control = getattr(args, "stdin_control", False)
|
|
if not stdin_control:
|
|
print("Use a second terminal with --client to send commands.")
|
|
|
|
connector = aiohttp.TCPConnector(limit=0)
|
|
async with aiohttp.ClientSession(connector=connector) as http_session:
|
|
background_tasks = [
|
|
asyncio.create_task(pacer(http_session)),
|
|
asyncio.create_task(reporter()),
|
|
asyncio.create_task(socket_server.serve_forever()),
|
|
asyncio.create_task(prefill_queue()),
|
|
]
|
|
stdin_task = (
|
|
asyncio.create_task(stdin_command_loop()) if stdin_control else None
|
|
)
|
|
|
|
if stdin_task is not None:
|
|
await stdin_task
|
|
else:
|
|
await stop_event.wait()
|
|
|
|
stop_event.set()
|
|
socket_server.close()
|
|
await socket_server.wait_closed()
|
|
await asyncio.gather(*background_tasks, return_exceptions=True)
|
|
|
|
if running_tasks:
|
|
print(f"Waiting for {len(running_tasks)} in-flight request task(s)...")
|
|
await asyncio.gather(*list(running_tasks), return_exceptions=True)
|
|
|
|
cpu_pool.shutdown(wait=False)
|
|
if os.path.exists(control_socket):
|
|
os.unlink(control_socket)
|
|
|
|
|
|
# ============================================================================
|
|
# Interactive client
|
|
# ============================================================================
|
|
|
|
|
|
async def _send_command_once(control_socket: str, cmd: str) -> str:
|
|
reader, writer = await asyncio.open_unix_connection(control_socket)
|
|
writer.write(cmd.encode("utf-8"))
|
|
await writer.drain()
|
|
if writer.can_write_eof():
|
|
writer.write_eof()
|
|
data = await reader.read()
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
return data.decode("utf-8", errors="replace").strip()
|
|
|
|
|
|
async def run_client(args: argparse.Namespace) -> None:
|
|
control_socket = _control_socket_path()
|
|
print(f"Connected to control socket: {control_socket}")
|
|
print(
|
|
"Type commands: help, rate <qps>, start, measure <n>, stop, status, "
|
|
"save [path|name], save-dir <path>, quit"
|
|
)
|
|
session = None
|
|
if PromptSession is not None and FileHistory is not None:
|
|
history_path = str(Path("~/.interactive_rate_bench_history").expanduser())
|
|
session = PromptSession(history=FileHistory(history_path))
|
|
else:
|
|
print(
|
|
"prompt_toolkit not installed; using basic input(). "
|
|
"Install with: pip install prompt_toolkit"
|
|
)
|
|
while True:
|
|
if session is not None:
|
|
raw = await asyncio.to_thread(session.prompt, "benchctl> ")
|
|
else:
|
|
raw = await asyncio.to_thread(input, "benchctl> ")
|
|
cmd = raw.strip()
|
|
if not cmd:
|
|
continue
|
|
try:
|
|
resp = await _send_command_once(control_socket, cmd)
|
|
except (FileNotFoundError, ConnectionRefusedError) as e:
|
|
print(f"Failed to connect to server socket: {e}")
|
|
return
|
|
print(resp)
|
|
if cmd.lower() in ("quit", "exit"):
|
|
return
|
|
|
|
|
|
async def run_client_oneshot(args: argparse.Namespace) -> None:
|
|
if not args.cmd:
|
|
raise ValueError("Client mode with --cmd requires a command string.")
|
|
control_socket = _control_socket_path()
|
|
try:
|
|
resp = await _send_command_once(control_socket, args.cmd)
|
|
except (FileNotFoundError, ConnectionRefusedError) as e:
|
|
raise RuntimeError(f"Failed to connect to server socket: {e}") from e
|
|
print(resp)
|
|
|
|
|
|
# ============================================================================
|
|
# Entry points for cli.py
|
|
# ============================================================================
|
|
|
|
|
|
def run_interactive_server(args: argparse.Namespace) -> int:
|
|
"""Entry point for interactive server mode."""
|
|
try:
|
|
asyncio.run(run_interactive(args))
|
|
return 0
|
|
except Exception as e:
|
|
logger.error("Interactive server failed: %s", e)
|
|
return 1
|
|
|
|
|
|
def run_interactive_client(args: argparse.Namespace) -> int:
|
|
"""Entry point for interactive client mode."""
|
|
try:
|
|
if args.cmd:
|
|
asyncio.run(run_client_oneshot(args))
|
|
else:
|
|
asyncio.run(run_client(args))
|
|
return 0
|
|
except Exception as e:
|
|
logger.error("Interactive client failed: %s", e)
|
|
return 1
|