Files

140 lines
6.3 KiB
Python

"""A/B two engine builds or environments on one config: restart the server per arm, alternate arms over rounds.
python tools/ab_engine.py strata-q2_0.json A:exe=/path/a B:exe=/path/b,STRATA_EXP_MODE=0 [--rounds 3]
An arm is NAME:key=value,...; `exe` replaces the config's engine, `xargs` appends ';'-separated engine arguments,
every other key is set in the engine's environment. Each round starts the server once per arm (the arms' order alternates), warms it up with one short
answer, then sends the same requests: greedy, fixed prompts, a short chat set and one long prompt. Prints the
server's own timings (llama.cpp's `timings`: decode and prompt tokens per second) per request and the per-arm
medians. Decode numbers move run to run (the adaptive expert tier, the OS), so compare medians over rounds.
"""
from __future__ import annotations
import argparse
import json
import os
import random
import statistics
import subprocess
import sys
import tempfile
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PROMPTS = (
"Write a Python function that merges two sorted lists into one sorted list, with a docstring and two tests.",
"Explain in two paragraphs how a refrigerator moves heat from inside to outside.",
"List ten practical tips for learning a new language as an adult, one sentence each.",
)
def long_prompt(words: int) -> str:
rng = random.Random(7)
vocab = ("river stone market lantern copper winter garden signal harbor needle orbit velvet canyon ember "
"ledger quartz meadow anchor violet thunder").split()
body = " ".join(rng.choice(vocab) for _ in range(words))
return f"Here is a list of words:\n{body}\n\nSummarize what kinds of words appear in the list, in five sentences."
def post(port: int, prompt: str, max_tokens: int) -> dict:
req = urllib.request.Request(
f"http://127.0.0.1:{port}/v1/chat/completions",
data=json.dumps({"messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens,
"temperature": 0, "chat_template_kwargs": {"enable_thinking": False}}).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=1800) as r:
return json.loads(r.read())
def wait_ready(port: int, proc: subprocess.Popen, timeout: float = 1800) -> None:
t0 = time.time()
while time.time() - t0 < timeout:
if proc.poll() is not None:
raise RuntimeError(f"server exited with {proc.returncode}")
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/models", timeout=5) as r:
if r.status == 200:
post(port, "Say hi.", 8)
return
except Exception:
pass
time.sleep(3)
raise TimeoutError("server not ready")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("config")
ap.add_argument("arms", nargs="+")
ap.add_argument("--rounds", type=int, default=3)
ap.add_argument("--port", type=int, default=8090)
ap.add_argument("--max-new", type=int, default=256)
ap.add_argument("--long-words", type=int, default=6000)
ap.add_argument("--log", default="ab_engine.jsonl")
a = ap.parse_args()
base = json.loads(Path(a.config).read_text())
arms = []
for s in a.arms:
name, _, kv = s.partition(":")
env = dict(x.split("=", 1) for x in kv.split(",") if x)
arms.append((name, env))
reqs = [(f"chat{i}", p) for i, p in enumerate(PROMPTS)] + [("long", long_prompt(a.long_words))]
out = open(a.log, "a")
res: dict = {n: {} for n, _ in arms}
for rnd in range(a.rounds):
order = arms if rnd % 2 == 0 else arms[::-1]
for name, env in order:
cfg = dict(base)
e = dict(env)
if "exe" in e:
cfg["exe"] = e.pop("exe")
if "xargs" in e: # extra engine arguments, ';'-separated
cfg["args"] = list(base["args"]) + e.pop("xargs").split(";")
cfg["port"] = a.port
cfg["log"] = str(Path(tempfile.gettempdir()) / f"ab_{name}_r{rnd}.log")
cpath = Path(tempfile.gettempdir()) / f"ab_{name}.json"
cpath.write_text(json.dumps(cfg, indent=1))
penv = dict(os.environ, **e)
proc = subprocess.Popen([sys.executable, str(ROOT / "serve" / "server.py"), "--engine", "strata", "--config",
str(cpath), "--port", str(a.port)], cwd=str(ROOT), env=penv,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
t0 = time.time()
wait_ready(a.port, proc)
print(f"round {rnd} {name}: ready in {time.time() - t0:.0f} s", flush=True)
for tag, p in reqs:
r = post(a.port, p, a.max_new)
t = r.get("timings", {})
row = {"round": rnd, "arm": name, "req": tag, "decode": t.get("predicted_per_second"),
"prompt": t.get("prompt_per_second"), "prompt_n": t.get("prompt_n"),
"n": r.get("usage", {}).get("completion_tokens"),
"text": r["choices"][0]["message"].get("content", "")[:80]}
out.write(json.dumps(row) + "\n")
out.flush()
res[name].setdefault(tag, []).append(row)
print(f" {tag:6s} decode {row['decode']} tok/s prompt {row['prompt']} tok/s ({row['prompt_n']}) "
f"n {row['n']}", flush=True)
finally:
proc.terminate()
try:
proc.wait(60)
except subprocess.TimeoutExpired:
proc.kill()
time.sleep(5)
print("\nmedians over rounds:")
for tag, _ in reqs:
cells = []
for name, _ in arms:
rows = res[name].get(tag, [])
d = statistics.median([x["decode"] for x in rows if x["decode"]]) if rows else float("nan")
pr = statistics.median([x["prompt"] for x in rows if x["prompt"]]) if rows else float("nan")
cells.append(f"{name}: decode {d:6.1f} prompt {pr:7.1f}")
print(f" {tag:6s} | " + " | ".join(cells))
return 0
if __name__ == "__main__":
sys.exit(main())