#!/usr/bin/env python3 """Open-loop fixed-rate loadgen for connector_tax microbench. Sends requests at a Poisson-arrival rate (rate_target req/s) of fixed (input_tokens, output_tokens) shape with random content. Each cell runs until BOTH the duration floor AND min-completed thresholds are met. Per-request metrics are appended to a JSONL file. Usage: bench_loop.py \ --url http://127.0.0.1:8000/v1/chat/completions \ --model /path/to/model \ --rates 0.5,1,2,4,8 \ --shape 4096,256 \ --duration 60 \ --min-completed 200 \ --warmup 10 \ --output-dir results/ \ --phase A """ import argparse import asyncio import hashlib import json import random import statistics import time import uuid from dataclasses import asdict, dataclass, field from pathlib import Path import httpx @dataclass class ReqMetric: req_id: str rate_target: float input_tokens_target: int output_tokens_target: int t_send_ns: int t_first_token_ns: int | None = None t_last_token_ns: int | None = None prompt_tokens: int = 0 completion_tokens: int = 0 inflight_at_send: int = 0 error: str | None = None # ─── content generation ──────────────────────────────────────────────────── def make_random_prompt(target_tokens: int) -> str: """Generate a prompt that tokenizes to roughly target_tokens. Calibration (Qwen3-Coder tokenizer): "Block N: <32-hex>" ≈ 35 tokens. """ n_parts = max(1, target_tokens // 35) seed = uuid.uuid4().hex parts = [] for i in range(n_parts): h = hashlib.md5(f"{seed}_{i}_{time.time_ns()}".encode()).hexdigest() parts.append(f"Block {i}: {h}") return " ".join(parts) # ─── single request worker ───────────────────────────────────────────────── async def send_one(client: httpx.AsyncClient, url: str, model: str, inp_tokens: int, out_tokens: int, rate: float, inflight: list[int], inflight_cap: int) -> ReqMetric: rid = uuid.uuid4().hex[:16] if inflight[0] >= inflight_cap: # Drop with logged metric return ReqMetric( req_id=rid, rate_target=rate, input_tokens_target=inp_tokens, output_tokens_target=out_tokens, t_send_ns=time.perf_counter_ns(), inflight_at_send=inflight[0], error="dropped_due_to_inflight_cap", ) inflight[0] += 1 m = ReqMetric( req_id=rid, rate_target=rate, input_tokens_target=inp_tokens, output_tokens_target=out_tokens, t_send_ns=time.perf_counter_ns(), inflight_at_send=inflight[0], ) try: prompt = make_random_prompt(inp_tokens) payload = { "model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": out_tokens, "min_tokens": out_tokens, "temperature": 0, "ignore_eos": True, "stream": True, "stream_options": {"include_usage": True}, } async with client.stream("POST", url, json=payload, timeout=600.0) as resp: resp.raise_for_status() async for line in resp.aiter_lines(): if not line.startswith("data: "): continue data = line[6:] if data.strip() == "[DONE]": break try: chunk = json.loads(data) except json.JSONDecodeError: continue # Capture usage from any chunk (it may arrive in a trailing # chunk with empty `choices`). usage = chunk.get("usage") if usage: m.prompt_tokens = usage.get("prompt_tokens", m.prompt_tokens) m.completion_tokens = usage.get( "completion_tokens", m.completion_tokens) choices = chunk.get("choices") or [] if not choices: continue delta = choices[0].get("delta", {}) if "role" in delta: continue now = time.perf_counter_ns() if m.t_first_token_ns is None: m.t_first_token_ns = now m.t_last_token_ns = now except Exception as e: m.error = f"{type(e).__name__}: {e}" finally: inflight[0] -= 1 return m # ─── per-cell driver (one rate × shape) ──────────────────────────────────── async def run_cell( client: httpx.AsyncClient, url: str, model: str, rate: float, inp_tokens: int, out_tokens: int, duration_floor_s: float, min_completed: int, warmup_s: float, inflight_cap: int, out_path: Path, ) -> dict: """Run one (rate, shape) cell. Streams per-request JSONL to out_path. Returns aggregated summary.""" inflight = [0] metrics: list[ReqMetric] = [] pending_tasks: list[asyncio.Task] = [] t_start_ns = time.perf_counter_ns() cell_start = time.perf_counter() print(f" [cell] rate={rate} shape=({inp_tokens},{out_tokens}) " f"floor={duration_floor_s}s min_completed={min_completed}") interval_mean = 1.0 / rate rng = random.Random(int(time.time_ns()) & 0xFFFFFFFF) fh = open(out_path, "a", buffering=1) completed_count = 0 def reap_one(t: asyncio.Task) -> None: nonlocal completed_count try: m = t.result() except Exception: return metrics.append(m) fh.write(json.dumps(asdict(m)) + "\n") completed_count += 1 async def submit(): while True: elapsed = time.perf_counter() - cell_start if elapsed >= duration_floor_s and completed_count >= min_completed: return task = asyncio.create_task( send_one(client, url, model, inp_tokens, out_tokens, rate, inflight, inflight_cap) ) pending_tasks.append(task) await asyncio.sleep(rng.expovariate(1.0 / interval_mean)) submitter = asyncio.create_task(submit()) async def drain_periodic(): while not submitter.done(): keep = [] for t in pending_tasks: if t.done(): reap_one(t) else: keep.append(t) pending_tasks[:] = keep await asyncio.sleep(0.1) drainer = asyncio.create_task(drain_periodic()) await submitter drainer.cancel() try: await drainer except asyncio.CancelledError: pass # Final drain: wait for all remaining inflight to complete and write them if pending_tasks: await asyncio.gather(*pending_tasks, return_exceptions=True) for t in pending_tasks: if t.done(): reap_one(t) pending_tasks.clear() fh.close() # Discard warmup window (first warmup_s seconds of completions) warmup_cutoff_ns = t_start_ns + int(warmup_s * 1e9) after = [m for m in metrics if m.t_send_ns > warmup_cutoff_ns and m.error is None and m.t_first_token_ns and m.t_last_token_ns] def pct(xs, p): if not xs: return None xs = sorted(xs) k = max(0, min(len(xs) - 1, int(p / 100.0 * (len(xs) - 1)))) return xs[k] ttft = [(m.t_first_token_ns - m.t_send_ns) / 1e6 for m in after] tpot = [] for m in after: if m.completion_tokens > 1 and m.t_last_token_ns and m.t_first_token_ns: tpot.append((m.t_last_token_ns - m.t_first_token_ns) / 1e6 / max(1, m.completion_tokens - 1)) e2e = [(m.t_last_token_ns - m.t_send_ns) / 1e6 for m in after] inflight_seq = [m.inflight_at_send for m in after] elapsed_s = (time.perf_counter_ns() - t_start_ns) / 1e9 summary = { "rate_target": rate, "input_tokens": inp_tokens, "output_tokens": out_tokens, "duration_actual_s": elapsed_s, "n_completed_total": len(metrics), "n_after_warmup": len(after), "n_dropped": sum(1 for m in metrics if m.error == "dropped_due_to_inflight_cap"), "n_errors": sum(1 for m in metrics if m.error and m.error != "dropped_due_to_inflight_cap"), "ttft_ms_mean": sum(ttft) / len(ttft) if ttft else None, "ttft_ms_p50": pct(ttft, 50), "ttft_ms_p90": pct(ttft, 90), "ttft_ms_p99": pct(ttft, 99), "tpot_ms_mean": sum(tpot) / len(tpot) if tpot else None, "tpot_ms_p50": pct(tpot, 50), "tpot_ms_p90": pct(tpot, 90), "tpot_ms_p99": pct(tpot, 99), "e2e_ms_mean": sum(e2e) / len(e2e) if e2e else None, "e2e_ms_p50": pct(e2e, 50), "e2e_ms_p90": pct(e2e, 90), "e2e_ms_p99": pct(e2e, 99), "throughput_effective_rps": len(after) / max(1.0, elapsed_s - warmup_s), "throughput_ratio": (len(after) / max(1.0, elapsed_s - warmup_s)) / rate, "inflight_p50": pct(inflight_seq, 50), "inflight_p90": pct(inflight_seq, 90), } print(f" completed={len(after)} ttft_p90={summary['ttft_ms_p90']} " f"tpot_p90={summary['tpot_ms_p90']} thr_ratio={summary['throughput_ratio']:.2f}") return summary async def main_async(args): out_dir = Path(args.output_dir) out_dir.mkdir(parents=True, exist_ok=True) rates = [float(x) for x in args.rates.split(",")] if args.rates else [args.rate] shapes = [] if args.shape: ip, op = args.shape.split(",") shapes = [(int(ip), int(op))] elif args.shapes: for s in args.shapes.split(","): ip, op = s.split("x") shapes.append((int(ip), int(op))) summaries = [] timeout = httpx.Timeout(600.0) async with httpx.AsyncClient(timeout=timeout) as client: for rate in rates: for inp, out in shapes: cell_label = f"{args.phase}_r{rate}_{inp}x{out}" req_path = out_dir / f"requests_{cell_label}.jsonl" # min_completed-driven duration floor min_floor_for_rate = max(1, int(args.min_completed / rate)) if rate > 0 else args.duration floor = max(args.duration, min_floor_for_rate) summary = await run_cell( client, args.url, args.model, rate, inp, out, duration_floor_s=floor, min_completed=args.min_completed, warmup_s=args.warmup, inflight_cap=args.inflight_cap, out_path=req_path, ) summary["phase"] = args.phase summary["cell"] = cell_label summaries.append(summary) # Cooldown between cells (let queue drain) await asyncio.sleep(3.0) # Persist summary with open(out_dir / f"summary_{args.phase}.json", "w") as f: json.dump(summaries, f, indent=2) print(f"\nWrote {len(summaries)} cell summaries.") def main(): ap = argparse.ArgumentParser() ap.add_argument("--url", required=True, help="vLLM /v1/chat/completions URL") ap.add_argument("--model", required=True) ap.add_argument("--phase", default="A") # Rate spec — either --rates a,b,c (Phase A) or --rate r (Phase B) ap.add_argument("--rates", default="") ap.add_argument("--rate", type=float, default=4.0) # Shape spec — either --shape ip,op (Phase A) or --shapes IPxOP,IPxOP,... (Phase B) ap.add_argument("--shape", default="") ap.add_argument("--shapes", default="") ap.add_argument("--duration", type=float, default=60.0, help="Cell duration floor (seconds)") ap.add_argument("--min-completed", type=int, default=200) ap.add_argument("--warmup", type=float, default=10.0) ap.add_argument("--inflight-cap", type=int, default=256) ap.add_argument("--output-dir", required=True) args = ap.parse_args() if not (args.rates or args.rate): ap.error("Provide --rates or --rate") if not (args.shape or args.shapes): ap.error("Provide --shape or --shapes") asyncio.run(main_async(args)) if __name__ == "__main__": main()