Files
2026-07-13 13:17:40 +08:00

1046 lines
38 KiB
Python

"""Benchmark runner functions for multi-turn LLM inference benchmarks.
Contains:
- Concurrency-based (closed-loop) runner
- Rate-based (constant QPS) runner
- Smoke test runner
- Direct entry point (run_direct)
"""
import argparse
import asyncio
import hashlib
import json
import logging
import math
import random
import sys
import time
from collections import Counter
from statistics import mean
from typing import Optional
import aiohttp
import numpy as np
from tqdm import tqdm
from ray.llm._internal.serve.benchmark.http_client import send_chat_completion
from ray.llm._internal.serve.benchmark.models import TurnMetric, WorkloadSpec
from ray.llm._internal.serve.benchmark.reporting import report_results
from ray.llm._internal.serve.benchmark.text_gen import (
Conversation,
TextGenerator,
conversation_factory,
)
from ray.llm._internal.serve.benchmark.turn import execute_single_turn
# ============================================================================
# LOGGING SETUP
# ============================================================================
class TqdmLoggingHandler(logging.Handler):
"""Custom logging handler that writes through tqdm to keep progress bar at bottom."""
def emit(self, record):
try:
msg = self.format(record)
tqdm.write(msg, file=sys.stderr)
self.flush()
except Exception:
self.handleError(record)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
handlers=[TqdmLoggingHandler()],
)
logger = logging.getLogger(__name__)
# ============================================================================
# BENCHMARK STATE (concurrency mode)
# ============================================================================
class BenchmarkState:
"""Centralized state management for benchmark execution.
Consolidates all shared state and synchronization into a single class
with one lock, replacing the previous 7+ separate locks.
"""
def __init__(self, spec: WorkloadSpec):
self.spec = spec
# Metrics storage
self.warmup_metrics: list[TurnMetric] = []
self.measured_metrics: list[TurnMetric] = []
# Session tracking
self.conversations: dict[int, Conversation] = {}
self.session_is_warmup: dict[int, bool] = {}
self.next_session_id = 0
self.completed_sessions = 0
# Concurrency tracking
self.inflight = 0
self.max_inflight = 0
self.inflight_samples: list[int] = []
# Warm-up state
self.warmup_complete = False
self.active_turns: dict[str, int] = {} # session_id -> turn_idx
# Single lock for all state
self._lock = asyncio.Lock()
async def record_metric(self, metric: TurnMetric) -> None:
"""Thread-safe metric recording."""
async with self._lock:
if self.warmup_complete:
self.measured_metrics.append(metric)
else:
self.warmup_metrics.append(metric)
async def get_next_session(self) -> Optional[int]:
"""Get next available session ID, or None if exhausted."""
async with self._lock:
limit = self.spec.num_sessions
if limit is None or self.next_session_id < limit:
session_id = self.next_session_id
self.next_session_id += 1
return session_id
return None
async def has_remaining_sessions(self) -> bool:
"""True if more session indices can still be allocated (does not consume an ID)."""
async with self._lock:
limit = self.spec.num_sessions
if limit is None:
return True
return self.next_session_id < limit
async def get_inflight(self) -> int:
"""Current in-flight HTTP requests (concurrency benchmark)."""
async with self._lock:
return self.inflight
async def get_or_create_conversation(
self,
session_idx: int,
spec: WorkloadSpec,
shared_system_text: str,
text_gen: TextGenerator,
) -> tuple[Conversation, bool]:
"""Get or create a conversation. Returns (conversation, is_warmup)."""
async with self._lock:
if session_idx not in self.conversations:
conv = conversation_factory(
session_idx, spec, shared_system_text, text_gen
)
self.conversations[session_idx] = conv
# Mark as warmup if steady-state not yet reached
self.session_is_warmup[session_idx] = not self.warmup_complete
else:
conv = self.conversations[session_idx]
is_warmup = self.session_is_warmup[session_idx]
return conv, is_warmup
async def mark_session_active(self, session_id: str, turn_idx: int) -> None:
"""Mark a session as active at a specific turn."""
async with self._lock:
self.active_turns[session_id] = turn_idx
async def mark_session_complete(self, session_idx: int, session_id: str) -> None:
"""Mark a session as complete and clean up."""
async with self._lock:
self.active_turns.pop(session_id, None)
if session_idx in self.conversations:
del self.conversations[session_idx]
self.completed_sessions += 1
async def track_inflight_start(self) -> int:
"""Track start of inflight request. Returns current inflight count."""
async with self._lock:
self.inflight += 1
if self.inflight > self.max_inflight:
self.max_inflight = self.inflight
self.inflight_samples.append(self.inflight)
return self.inflight
async def track_inflight_end(self) -> int:
"""Track end of inflight request. Returns inflight count BEFORE decrement."""
async with self._lock:
current = self.inflight
self.inflight -= 1
return current
async def mark_warmup_complete(self) -> None:
"""Mark warm-up phase complete. All future sessions are measurement."""
async with self._lock:
self.warmup_complete = True
async def check_and_complete_warmup(self) -> bool:
"""Check if steady-state reached and complete warm-up if so.
Returns True if warm-up was just completed, False otherwise.
"""
if self.warmup_complete:
return False
async with self._lock:
# Double-check after acquiring lock
if self.warmup_complete:
return False
if not self.active_turns:
return False
# Calculate entropy. For num_turns==1, max_entropy=log2(1)=0 so the
# threshold is 0.0 — any non-empty active_turns distribution satisfies
# entropy >= 0, so warm-up ends on the first turn completion.
max_entropy = np.log2(self.spec.num_turns)
entropy_threshold = 0.50 * max_entropy
entropy = self._calculate_entropy()
if entropy >= entropy_threshold:
self.warmup_complete = True
pct_of_target = (
(entropy / entropy_threshold * 100)
if entropy_threshold > 0
else 100.0
)
logger.info(
"Steady-state reached! Entropy=%.3f (threshold=%.3f, %.0f%% of target)",
entropy,
entropy_threshold,
pct_of_target,
)
logger.info(
"Warm-up complete (%d sessions, %d requests discarded). "
"Now measuring steady-state...",
self.completed_sessions,
len(self.warmup_metrics),
)
return True
return False
def _calculate_entropy(self) -> float:
"""Calculate Shannon entropy of active turn distribution (must be called with lock held)."""
if not self.active_turns:
return 0.0
counts = Counter(self.active_turns.values())
total = sum(counts.values())
probs = [c / total for c in counts.values()]
return -sum(p * np.log2(p) for p in probs if p > 0)
async def get_stats(self) -> dict:
"""Get current benchmark stats (thread-safe)."""
async with self._lock:
return {
"warmup_metrics": len(self.warmup_metrics),
"measured_metrics": len(self.measured_metrics),
"completed_sessions": self.completed_sessions,
"max_inflight": self.max_inflight,
"avg_inflight": (
mean(self.inflight_samples) if self.inflight_samples else 0
),
"warmup_complete": self.warmup_complete,
}
# ============================================================================
# PROGRESS BAR
# ============================================================================
def create_progress_bar(total: int) -> tqdm:
"""Create a sticky progress bar for request tracking."""
return tqdm(
total=total,
desc="Requests",
unit="req",
bar_format=(
"{l_bar}{bar}| {n_fmt}/{total_fmt} "
"[{elapsed}<{remaining}, {rate_fmt}, inflight={postfix}]"
),
position=0,
leave=True,
file=sys.stderr,
)
# ============================================================================
# CONCURRENCY-BASED BENCHMARK
# ============================================================================
async def run_benchmark(
spec: WorkloadSpec,
base_url: str,
model: str,
shared_system_text: str,
text_gen: TextGenerator,
first_chunk_threshold: int = 16,
api_key: Optional[str] = None,
warmup_jitter_max_s: float = 10.0,
) -> list[TurnMetric]:
"""Run the full benchmark with rolling session pool and steady-state warm-up.
Maintains a pool of `concurrency` worker tasks that continuously pull turns
from a queue. This creates a natural turn distribution mix (some sessions at
turn 0, others at turn 5, etc.) after warm-up.
Warm-up continues until steady-state is reached: turn distribution entropy
>= 50% of maximum (indicating reasonable spread across multiple turns).
"""
if spec.concurrency is None:
raise ValueError("closed-loop benchmark requires concurrency to be set")
if spec.num_sessions is None:
raise ValueError("closed-loop (concurrency) benchmark requires --num-sessions")
state = BenchmarkState(spec)
request_sem = asyncio.Semaphore(spec.concurrency)
bench_start_ns = time.perf_counter_ns()
total_expected = spec.num_sessions * spec.num_turns
connector = aiohttp.TCPConnector(limit=spec.concurrency * 2)
async with aiohttp.ClientSession(connector=connector) as http_session:
pbar = create_progress_bar(total_expected)
pbar.set_postfix_str("0")
# Turn queue: (session_idx, turn_idx, delay_until)
turn_queue: asyncio.Queue[tuple[int, int, float]] = asyncio.Queue()
async def enqueue_new_session() -> bool:
"""Add a new session (turn 0) to the queue if any remain."""
session_idx = await state.get_next_session()
if session_idx is not None:
# Stagger initial session launches using configured ramp_interval
delay = session_idx * spec.ramp_interval
await turn_queue.put((session_idx, 0, time.perf_counter() + delay))
return True
return False
# Initialize: fill queue with just enough sessions to saturate concurrency
for _ in range(min(spec.concurrency, spec.num_sessions)):
await enqueue_new_session()
async def execute_turn(session_idx: int, turn_idx: int) -> None:
"""Execute a single turn for a session."""
conv, is_warmup = await state.get_or_create_conversation(
session_idx, spec, shared_system_text, text_gen
)
await state.mark_session_active(conv.session_id, turn_idx)
async with request_sem:
await state.track_inflight_start()
try:
outcome = await execute_single_turn(
http_session=http_session,
conv=conv,
turn_idx=turn_idx,
base_url=base_url,
model=model,
max_tokens=spec.osl,
bench_start_ns=bench_start_ns,
first_chunk_threshold=first_chunk_threshold,
api_key=api_key,
)
except Exception as e:
logger.error(
"Session %s turn %d failed: %s",
conv.session_id,
turn_idx,
e,
)
current_inflight = await state.track_inflight_end()
pbar.set_postfix_str(str(current_inflight))
pbar.update(1)
await state.mark_session_complete(session_idx, conv.session_id)
await enqueue_new_session()
return
current_inflight = await state.track_inflight_end()
await state.record_metric(outcome.metric)
pbar.set_postfix_str(str(current_inflight))
pbar.update(1)
if turn_idx < spec.num_turns - 1:
next_turn = turn_idx + 1
if is_warmup:
jitter = np.random.uniform(0.0, warmup_jitter_max_s)
delay_until = time.perf_counter() + jitter
else:
delay_until = time.perf_counter() + spec.think_time
await turn_queue.put((session_idx, next_turn, delay_until))
else:
await state.mark_session_complete(session_idx, conv.session_id)
await enqueue_new_session()
await state.check_and_complete_warmup()
async def worker() -> None:
"""Worker task: continuously pull turns from queue and execute them."""
while True:
try:
session_idx, turn_idx, delay_until = await asyncio.wait_for(
turn_queue.get(), timeout=1.0
)
except asyncio.TimeoutError:
# Do not call get_next_session() here — it allocates a session ID
# without enqueueing work. Peek completion instead.
if (
not await state.has_remaining_sessions()
and turn_queue.empty()
and await state.get_inflight() == 0
):
break
continue
# Wait until delay_until (for think time/jitter)
now = time.perf_counter()
wait_time = delay_until - now
if wait_time > 0:
await asyncio.sleep(wait_time)
await execute_turn(session_idx, turn_idx)
max_entropy = np.log2(spec.num_turns)
entropy_threshold = 0.50 * max_entropy
logger.info(
"Starting benchmark: %d sessions x %d turns = %d requests, concurrency=%d",
spec.num_sessions,
spec.num_turns,
total_expected,
spec.concurrency,
)
logger.info(
" Rolling pool: maintaining ~%d active sessions across all turns",
spec.concurrency,
)
logger.info(
" Warm-up: until steady-state (entropy >= %.3f, 50%% of max=%.3f)",
entropy_threshold,
max_entropy,
)
logger.info(
" Measurement: starts after warm-up, using think-time=%.1fs",
spec.think_time,
)
# Launch worker pool
workers = [asyncio.create_task(worker()) for _ in range(spec.concurrency)]
await asyncio.gather(*workers)
pbar.close()
bench_elapsed_s = (time.perf_counter_ns() - bench_start_ns) / 1e9
stats = await state.get_stats()
logger.info(
"Benchmark complete: %d measured requests in %.1fs (%.1f req/s), "
"%d warmup requests discarded",
stats["measured_metrics"],
bench_elapsed_s,
stats["measured_metrics"] / bench_elapsed_s if bench_elapsed_s > 0 else 0,
stats["warmup_metrics"],
)
logger.info(
"Concurrency: max=%d, avg=%.1f",
stats["max_inflight"],
stats["avg_inflight"],
)
return state.measured_metrics
# ============================================================================
# RATE-BASED BENCHMARK
# ============================================================================
async def run_rate_based_benchmark(
spec: WorkloadSpec,
base_url: str,
model: str,
shared_system_text: str,
text_gen: TextGenerator,
first_chunk_threshold: int = 16,
duration_s: float = 0.0,
warmup_s: float = 0.0,
api_key: Optional[str] = None,
) -> tuple[list[TurnMetric], int]:
"""Closed-loop constant-QPS benchmark with turn-order guarantees.
Uses separate asyncio locks for metrics/inflight here (time-based warm-up
filtering) while :class:`BenchmarkState` powers the concurrency path
(entropy-based warm-up). Unifying both under one state object is possible
but is left for a follow-up refactor.
Architecture
============
- **Ready queue**: sessions whose previous turn has completed push themselves
here so their next turn can be dispatched.
- **Pacer**: a single coroutine drains the ready queue at exactly
``spec.request_rate`` req/s using a token-bucket algorithm. It sleeps
until the next slot is due, pops one item from the ready queue, and fires
the turn as a background task.
- **Lazy session pool**: the pacer holds a pre-computed budget of session
indices (``ceil(rate * duration / num_turns)``). When the ready queue is
empty (all active sessions are waiting for server responses), it mints a
fresh conversation from the budget on the spot -- no upfront allocation.
If the budget is also exhausted (server extremely slow), it generates
extra conversations beyond the budget and logs a warning.
- **Duration-based**: the benchmark runs for ``duration_s`` seconds, then
the pacer stops and waits for all in-flight requests to complete.
- **Turn-order guarantee**: turn k+1 is only enqueued after the HTTP
response for turn k is fully received and injected into history.
Session budget
==============
Budget = ceil(request_rate * duration_s / num_turns).
This is the exact number of unique sessions needed if the server keeps up.
If the server is slower, some in-flight sessions are unavailable and the
pacer mints extras beyond the budget to maintain QPS.
"""
if spec.request_rate is None:
raise ValueError("rate-based benchmark requires request_rate to be set")
if warmup_s < 0:
raise ValueError("warmup_s must be >= 0")
bench_start_ns = time.perf_counter_ns()
# --------------------------------------------------------------------------
# Session budget: computed from rate * duration / num_turns
# --------------------------------------------------------------------------
if duration_s > 0:
session_budget = math.ceil(spec.request_rate * duration_s / spec.num_turns)
elif spec.num_sessions is not None:
session_budget = spec.num_sessions
else:
session_budget = 1000 # fallback; shouldn't reach here after CLI validation
# Monotonically increasing session index (never resets; unique per conversation)
next_session_idx = 0
sessions_beyond_budget = 0 # count of extra sessions created due to server lag
def _next_conv() -> Conversation:
"""Mint a fresh conversation, lazily, from the session budget (or beyond)."""
nonlocal next_session_idx, sessions_beyond_budget
idx = next_session_idx
next_session_idx += 1
if idx >= session_budget:
sessions_beyond_budget += 1
return conversation_factory(idx, spec, shared_system_text, text_gen)
# --------------------------------------------------------------------------
# Ready queue: (conv, turn_idx) -- only sessions whose prior turn completed
# --------------------------------------------------------------------------
ready_queue: asyncio.Queue[tuple[Conversation, int]] = asyncio.Queue()
# --------------------------------------------------------------------------
# Shared counters (only mutated by execute_turn background tasks)
# --------------------------------------------------------------------------
metrics: list[TurnMetric] = []
warmup_metrics: list[TurnMetric] = []
metrics_lock = asyncio.Lock()
inflight = 0
max_inflight = 0
inflight_samples: list[int] = []
inflight_lock = asyncio.Lock()
stop_dispatching = asyncio.Event()
all_done = asyncio.Event()
total_slots = int(spec.request_rate * duration_s) if duration_s > 0 else 0
# Two stacked progress bars: sent (top) chased by done (bottom).
_bar_fmt_sent = (
"{desc}: {bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]"
)
_bar_fmt_done = (
"{desc}: {bar}| {n_fmt}/{total_fmt} "
"[{elapsed}<{remaining}, {rate_fmt}] {postfix}"
)
_bar_fmt_no_total = "{desc}: {bar}| {n_fmt} [{elapsed}, {rate_fmt}]"
_bar_fmt_no_total_pf = "{desc}: {bar}| {n_fmt} [{elapsed}, {rate_fmt}] {postfix}"
pbar_sent = tqdm(
total=total_slots if total_slots > 0 else None,
desc=" Sent",
unit="req",
bar_format=_bar_fmt_sent if total_slots > 0 else _bar_fmt_no_total,
position=1,
leave=True,
file=sys.stderr,
colour="cyan",
)
pbar_done = tqdm(
total=total_slots if total_slots > 0 else None,
desc=" Done",
unit="req",
bar_format=_bar_fmt_done if total_slots > 0 else _bar_fmt_no_total_pf,
position=0,
leave=True,
file=sys.stderr,
colour="green",
)
pbar_done.set_postfix_str("inflight=0")
logger.info(
"Closed-loop QPS benchmark: target=%.2f req/s, duration=%.1fs, "
"num_turns=%d, session_budget=%d (auto from rate*duration/turns)",
spec.request_rate,
duration_s,
spec.num_turns,
session_budget,
)
logger.info(
"Stats warm-up: %.1fs (requests dispatched before this are excluded "
"from reported metrics)",
warmup_s,
)
warmup_cutoff_ns = bench_start_ns + int(warmup_s * 1e9)
# --------------------------------------------------------------------------
# Per-turn execution (background task, one per in-flight request)
# --------------------------------------------------------------------------
async def execute_turn(
conv: Conversation,
turn_idx: int,
http_session: aiohttp.ClientSession,
) -> None:
nonlocal inflight, max_inflight
async with inflight_lock:
inflight += 1
if inflight > max_inflight:
max_inflight = inflight
inflight_samples.append(inflight)
pbar_done.set_postfix_str(f"inflight={inflight}")
try:
outcome = await execute_single_turn(
http_session=http_session,
conv=conv,
turn_idx=turn_idx,
base_url=base_url,
model=model,
max_tokens=spec.osl,
bench_start_ns=bench_start_ns,
first_chunk_threshold=first_chunk_threshold,
api_key=api_key,
)
except Exception as e:
logger.error(
"Session %s turn %d failed: %s",
conv.session_id,
turn_idx,
e,
)
async with inflight_lock:
inflight -= 1
pbar_done.set_postfix_str(f"inflight={inflight}")
pbar_done.update(1)
if not stop_dispatching.is_set():
await ready_queue.put((_next_conv(), 0))
async with inflight_lock:
if stop_dispatching.is_set() and inflight == 0:
all_done.set()
return
async with inflight_lock:
inflight -= 1
pbar_done.set_postfix_str(f"inflight={inflight}")
async with metrics_lock:
req_start_ns = bench_start_ns + int(outcome.metric.start_time_ms * 1e6)
if req_start_ns >= warmup_cutoff_ns:
metrics.append(outcome.metric)
else:
warmup_metrics.append(outcome.metric)
pbar_done.update(1)
if not stop_dispatching.is_set():
next_turn = turn_idx + 1
if next_turn < spec.num_turns:
await ready_queue.put((conv, next_turn))
else:
await ready_queue.put((_next_conv(), 0))
async with inflight_lock:
if stop_dispatching.is_set() and inflight == 0:
all_done.set()
# --------------------------------------------------------------------------
# Pacer: token-bucket, single coroutine, no lock needed
# --------------------------------------------------------------------------
async def pacer(http_session: aiohttp.ClientSession) -> None:
"""Dispatch one request per slot at exactly request_rate req/s."""
nonlocal next_session_idx, sessions_beyond_budget
interval_s = 1.0 / spec.request_rate
next_dispatch = time.perf_counter()
# Throttle "server lagging" log to once per second
_last_lag_log: float = 0.0
_lag_count_since_log: int = 0
while not stop_dispatching.is_set():
now = time.perf_counter()
wait = next_dispatch - now
if wait > 0:
await asyncio.sleep(wait)
if stop_dispatching.is_set():
break
# Prefer a session whose prior turn already completed (ready queue).
# If empty the server is lagging -- mint a fresh session from the
# budget (or beyond) so QPS is maintained.
try:
conv, turn_idx = ready_queue.get_nowait()
# Ready queue had something -- server is keeping up at this slot
pbar_sent.set_description(" Sent")
except asyncio.QueueEmpty:
_lag_count_since_log += 1
now = time.perf_counter()
beyond_budget = next_session_idx >= session_budget
# Update sent bar description to show lag state continuously
if beyond_budget:
pbar_sent.set_description(
f" Sent [lag+{sessions_beyond_budget + _lag_count_since_log}]"
)
else:
pbar_sent.set_description(
f" Sent [lag {next_session_idx}/{session_budget}]"
)
if now - _last_lag_log >= 1.0:
elapsed = (time.perf_counter_ns() - bench_start_ns) / 1e9
if beyond_budget:
logger.warning(
"Server lagging at %.1fs: ready queue empty, budget exhausted "
"(budget=%d). Minted %d extra session(s) in last %.1fs to hold QPS.",
elapsed,
session_budget,
_lag_count_since_log,
now - _last_lag_log if _last_lag_log else elapsed,
)
else:
logger.info(
"Server lagging at %.1fs: ready queue empty, pulling from budget "
"(%d/%d sessions used). %d slot(s) filled from budget in last %.1fs.",
elapsed,
next_session_idx,
session_budget,
_lag_count_since_log,
now - _last_lag_log if _last_lag_log else elapsed,
)
_last_lag_log = now
_lag_count_since_log = 0
conv = _next_conv()
turn_idx = 0
asyncio.create_task(execute_turn(conv, turn_idx, http_session))
pbar_sent.update(1)
next_dispatch += interval_s
# Pacer finished -- trigger all_done if nothing is in-flight
async with inflight_lock:
if inflight == 0:
all_done.set()
# --------------------------------------------------------------------------
# Duration timer
# --------------------------------------------------------------------------
async def duration_timer() -> None:
await asyncio.sleep(duration_s)
logger.info("Duration %.1fs elapsed -- stopping new dispatches.", duration_s)
stop_dispatching.set()
# --------------------------------------------------------------------------
# Main execution
# --------------------------------------------------------------------------
connector = aiohttp.TCPConnector(limit=0)
async with aiohttp.ClientSession(connector=connector) as http_session:
# Seed the ready queue with enough turn-0 conversations to keep the
# pacer busy for the first second without generating them mid-sleep.
seed_count = max(1, int(spec.request_rate) + 1)
for _ in range(seed_count):
await ready_queue.put((_next_conv(), 0))
timer_task = asyncio.create_task(duration_timer())
pacer_task = asyncio.create_task(pacer(http_session))
await timer_task
await pacer_task
pbar_sent.set_description(" Sent")
logger.info("Waiting for %d in-flight requests to complete...", inflight)
await all_done.wait()
pbar_sent.close()
pbar_done.close()
bench_elapsed_s = (time.perf_counter_ns() - bench_start_ns) / 1e9
avg_inf = sum(inflight_samples) / len(inflight_samples) if inflight_samples else 0.0
logger.info(
"Benchmark complete: %d requests in %.1fs (%.2f req/s actual); "
"max_inflight=%d, avg_inflight=%.1f",
len(metrics),
bench_elapsed_s,
len(metrics) / bench_elapsed_s if bench_elapsed_s > 0 else 0,
max_inflight,
avg_inf,
)
if warmup_metrics:
logger.info(
"Warm-up filtered out %d requests (start_time < %.1fs)",
len(warmup_metrics),
warmup_s,
)
logger.info(
"Sessions: budget=%d, used=%d (%s)",
session_budget,
next_session_idx,
f"{sessions_beyond_budget} extra beyond budget -- server was lagging"
if sessions_beyond_budget > 0
else "within budget",
)
return metrics, len(warmup_metrics)
# ============================================================================
# SEED & ENTRY POINTS
# ============================================================================
def set_seed(args: argparse.Namespace) -> int:
"""Set random seed combining base seed with workload spec hash.
This ensures:
- Reproducibility: same configuration always generates same conversations
- Variation: different configurations (concurrency, rate, ISL, etc.)
generate different conversations
Args:
args: Parsed command-line arguments.
Returns:
The combined seed value used.
"""
# Generate random base seed if not provided
if args.seed is None:
base_seed = random.randint(0, 2**31 - 1)
logger.info("No seed provided, generated random base seed: %d", base_seed)
else:
base_seed = args.seed
# Get all args but exclude parameters that don't affect workload shape
# (e.g., output paths, server URLs, infrastructure settings)
exclude_params = {
"seed", # Base seed (used separately)
"save_result", # Output path doesn't affect workload
"base_url", # Server URL doesn't affect workload
"model", # Model name doesn't affect workload shape
"tokenizer", # Tokenizer path doesn't affect workload (token counts do)
"first_chunk_threshold", # Measurement detail, not workload shape
"warmup_jitter_max", # Warm-up pacing only; does not change generated text
}
# Include all workload-affecting parameters
workload_params = {k: v for k, v in vars(args).items() if k not in exclude_params}
# Create stable string representation and hash it
workload_str = json.dumps(workload_params, sort_keys=True, default=str)
workload_hash = int(hashlib.sha256(workload_str.encode()).hexdigest()[:8], 16)
# Combine base seed with workload hash; clamp to numpy's valid range [0, 2**32-1]
combined_seed = (base_seed + workload_hash) % (2**32)
# Set seeds
random.seed(combined_seed)
np.random.seed(combined_seed)
logger.info(
"Random seed: base=%d, workload_hash=%d, combined=%d",
base_seed,
workload_hash,
combined_seed,
)
return combined_seed
async def _run_smoke_async(args) -> dict:
"""Run a single smoke-test request and return metrics."""
messages = [{"role": "user", "content": "Say 'this is a test' and nothing else."}]
async with aiohttp.ClientSession() as session:
result = await send_chat_completion(
session=session,
base_url=args.base_url,
model=args.model,
messages=messages,
first_chunk_threshold=args.first_chunk_threshold,
api_key=getattr(args, "api_key", None),
)
return {
"ttft_ms": round(result.ttft_ms, 2),
"fc_ms": round(result.fc_ms, 2),
"itl_ms": round(result.itl_ms, 2),
"e2e_latency_ms": round(result.e2e_latency_ms, 2),
"input_tokens": result.input_tokens,
"output_tokens": result.output_tokens,
}
def run_smoke(args) -> int:
"""Run smoke test synchronously, return exit code."""
try:
result = asyncio.run(_run_smoke_async(args))
print(json.dumps(result, indent=2))
return 0
except Exception as e:
logger.error(f"Smoke test failed: {e}")
return 1
def run_direct(args) -> int:
"""Run a single benchmark (concurrency or rate-based) and report results."""
# Resolve tokenizer
tokenizer_name = args.tokenizer if args.tokenizer else args.model
try:
from transformers import AutoTokenizer
logger.info("Loading tokenizer: %s", tokenizer_name)
tokenizer = AutoTokenizer.from_pretrained(
tokenizer_name, trust_remote_code=True
)
except Exception as e:
logger.error(
"Failed to load tokenizer '%s'. If your --model is not a valid "
"HuggingFace repo, provide --tokenizer explicitly. Error: %s",
tokenizer_name,
e,
)
return 1
set_seed(args)
text_gen = TextGenerator(tokenizer)
# Validate
if args.concurrency is not None and args.num_sessions is None:
logger.error("--num-sessions is required when using --concurrency mode.")
return 1
if (
args.request_rate is not None
and args.num_sessions is None
and (args.duration is None or args.duration <= 0)
):
logger.error(
"For --request-rate mode without --num-sessions, specify --duration > 0."
)
return 1
# Build spec (simple mode only)
# Filter out None values so dataclass defaults (e.g. num_turns=1, osl=1)
# are preserved when the user omits those CLI flags.
spec_kwargs = dict(
num_sessions=args.num_sessions,
num_turns=args.num_turns,
osl=args.osl,
think_time=args.think_time,
concurrency=args.concurrency,
request_rate=args.request_rate,
ramp_interval=args.ramp_interval,
shared_system_prompt_ratio=args.shared_system_prompt_ratio,
isl=args.isl,
hit_rate=args.hit_rate,
duration_s=args.duration or 0.0,
)
spec = WorkloadSpec(**{k: v for k, v in spec_kwargs.items() if v is not None})
try:
spec.resolve()
except ValueError as e:
logger.error("Workload spec error: %s", e)
return 1
spec.print_summary()
shared_system_text = text_gen.generate(spec.shared_s)
bench_start = time.perf_counter()
discarded_warmup_requests = 0
report_elapsed = None
api_key = getattr(args, "api_key", None)
fc_thresh = args.first_chunk_threshold
warmup_jitter_max = getattr(args, "warmup_jitter_max", 10.0)
if spec.request_rate is not None:
if spec.duration_s > 0:
effective_duration = spec.duration_s
else:
# _validate() ensures num_sessions is set when duration_s <= 0
assert spec.num_sessions is not None
effective_duration = spec.num_sessions * spec.num_turns / spec.request_rate
if args.warm_up >= effective_duration:
logger.error(
"--warm-up (%.3fs) must be less than effective duration (%.3fs).",
args.warm_up,
effective_duration,
)
return 1
metrics, discarded_warmup_requests = asyncio.run(
run_rate_based_benchmark(
spec,
args.base_url,
args.model,
shared_system_text,
text_gen,
first_chunk_threshold=fc_thresh,
duration_s=effective_duration,
warmup_s=args.warm_up,
api_key=api_key,
)
)
report_elapsed = max(1e-9, effective_duration - args.warm_up)
else:
metrics = asyncio.run(
run_benchmark(
spec,
args.base_url,
args.model,
shared_system_text,
text_gen,
first_chunk_threshold=fc_thresh,
api_key=api_key,
warmup_jitter_max_s=warmup_jitter_max,
)
)
bench_elapsed = time.perf_counter() - bench_start
if report_elapsed is None:
report_elapsed = bench_elapsed
report_results(
metrics,
spec,
report_elapsed,
first_chunk_threshold=fc_thresh,
save_path=args.save_result,
warmup_s=args.warm_up if spec.request_rate is not None else 0.0,
discarded_warmup_requests=discarded_warmup_requests,
)
return 0