| """Phase 4.2 — concurrency / throughput under real load. |
| |
| Drives CONCURRENT (not sequential) requests against the serving stack and |
| measures how aggregate throughput scales with concurrency, per arm, to answer: |
| does the memory arm's decode-step advantage hold, shrink, or reverse under |
| contention? |
| |
| Method: reuse the faithful external spec-decode accounting (Phase 4.1) — each |
| arm's per-request work is decoding ``T - L`` tokens (accept length ``L`` from |
| the real served targets). For each arm and concurrency level ``C`` we fire the |
| sampled requests through a ``C``-worker pool against the live sglang server |
| (real batched serving, ``ignore_eos`` forces exact token counts) and record |
| aggregate tokens/s and per-request latency. The no-memory arm decodes ~``T`` |
| tokens/request; ours decodes ~``T-L`` with ``L`` large, so under batching it |
| should sustain higher throughput. |
| |
| Run from the repo root: python -m harness.phase4_throughput --url http://localhost:30000/v1 |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import random |
| import time |
| from collections import defaultdict |
| from concurrent.futures import ThreadPoolExecutor |
| from pathlib import Path |
|
|
| import requests |
|
|
| from . import metrics |
| from .phase4_wallclock import _collect_points, MODEL_PATH |
|
|
| ROOT = Path(__file__).resolve().parent.parent |
| RESULTS = ROOT / "results" |
| ARMS = ["no_memory", "static_global", "personal_memory"] |
|
|
|
|
| def _gen(chat, prompt, ntok, T): |
| t0 = time.perf_counter() |
| r = requests.post(chat, json={"model": "gpt-oss-120b", "temperature": 0.0, |
| "max_tokens": ntok, "ignore_eos": True, |
| "messages": [{"role": "user", |
| "content": prompt}]}, timeout=300) |
| dt = time.perf_counter() - t0 |
| r.raise_for_status() |
| return dt, T |
|
|
|
|
| def _run_arm(chat, jobs, concurrency): |
| """Fire (prompt, decode_ntok, target_T) jobs through a C-worker pool. |
| Each request PRODUCES its full target (``T`` tokens) whether by decoding or |
| by accepting the draft's prefix; the memory arm decodes fewer (``T-L``) but |
| still produces ``T``, so the correct throughput counts effective OUTPUT |
| tokens (``T``), not decode work. We also report requests/s.""" |
| lat, out_toks = [], 0 |
| t0 = time.perf_counter() |
| with ThreadPoolExecutor(max_workers=concurrency) as ex: |
| for dt, T in ex.map(lambda j: _gen(chat, j[0], j[1], j[2]), jobs): |
| lat.append(dt * 1000) |
| out_toks += T |
| wall = time.perf_counter() - t0 |
| lat.sort() |
| return {"eff_output_tokens_per_s": round(out_toks / wall, 1), |
| "requests_per_s": round(len(jobs) / wall, 2), |
| "wall_s": round(wall, 2), |
| "req_p50_ms": round(lat[len(lat) // 2], 1), |
| "req_p95_ms": round(lat[min(len(lat) - 1, int(0.95 * len(lat)))], 1)} |
|
|
|
|
| def main(): |
| p = argparse.ArgumentParser() |
| p.add_argument("--url", default="http://localhost:30000/v1") |
| p.add_argument("--domains", nargs="+", |
| default=["airline", "retail", "telecom"]) |
| p.add_argument("--concurrency", nargs="+", type=int, default=[1, 8, 32]) |
| p.add_argument("--sample", type=int, default=96) |
| p.add_argument("--seed", type=int, default=0) |
| args = p.parse_args() |
| chat = args.url.rstrip("/") + "/chat/completions" |
| metrics.get_tokenizer(MODEL_PATH) |
|
|
| pts = _collect_points(args.domains) |
| rng = random.Random(args.seed) |
| rng.shuffle(pts) |
| pts = [x for x in pts if x["T"] >= 2][:args.sample] |
| print(f"[throughput] {len(pts)} sampled requests", flush=True) |
|
|
| |
| jobs = {a: [(x["query"][:1500], max(1, x["T"] - x[a]), x["T"]) for x in pts] |
| for a in ARMS} |
| mean_tok = {a: round(sum(n for _, n, _ in jobs[a]) / len(jobs[a]), 1) |
| for a in ARMS} |
|
|
| out = {"concurrency_levels": args.concurrency, "arms": ARMS, |
| "n_requests": len(pts), "mean_decode_tokens_per_req": mean_tok, |
| "results": defaultdict(dict)} |
| for C in args.concurrency: |
| for a in ARMS: |
| r = _run_arm(chat, jobs[a], C) |
| out["results"][str(C)][a] = r |
| print(f" C={C:2d} {a:16s} out={r['eff_output_tokens_per_s']:8.1f} tok/s " |
| f"req/s={r['requests_per_s']:.2f} p50={r['req_p50_ms']:.0f}ms " |
| f"p95={r['req_p95_ms']:.0f}ms", flush=True) |
| |
| out["personal_over_vanilla_throughput"] = { |
| str(C): round(out["results"][str(C)]["personal_memory"]["eff_output_tokens_per_s"] / |
| out["results"][str(C)]["no_memory"]["eff_output_tokens_per_s"], 3) |
| for C in args.concurrency} |
| out["results"] = dict(out["results"]) |
| (RESULTS / "phase4_throughput.json").write_text(json.dumps(out, indent=2)) |
| print("\npersonal/vanilla throughput ratio by concurrency:", |
| out["personal_over_vanilla_throughput"]) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|