ngram-mod's match window (n_min/n_max) decides which traffic it can see at all -- the fleet's 48/64 preset is tuned for long verbatim echoes and finds nothing in a repetitive-but-short output. Config entries now take |KEY=VALUE overrides, so the window can be swept in the same run as the configurations it applies to.
272 lines
12 KiB
Python
Executable File
272 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""bench-spec-ab.py — settle "which proposer should this endpoint use?" with data.
|
|
|
|
Runs one payload through several speculative-decoding configurations of the same
|
|
binary, one at a time, with the unit stopped between runs: on a single-GPU host
|
|
there is no room for two copies of a 29 GiB model, and the benchmarking policy
|
|
applies. Every configuration gets identical prompts at temperature 0, and the
|
|
greedy text is compared across them — speculation may buy speed, never change
|
|
the answer.
|
|
|
|
bench-spec-ab.py <env-file> <serve-script> [options]
|
|
bench-spec-ab.py /home/dank/serverAppConfigs/scripts/frank-medium.env \
|
|
/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh \
|
|
--configs "dflash=draft-dflash@7;ngram=ngram-mod@64;chain=draft-dflash,ngram-mod@7"
|
|
|
|
Config syntax: `name=SPEC_TYPE[@n_max]`, semicolon-separated. The systemd unit is
|
|
restored at the end, so the endpoint comes back on whatever the env file says
|
|
whatever happens here.
|
|
|
|
THREE SHAPES, because a single one answers the wrong question. A proposer can
|
|
only draft text it has seen, so the shape decides the verdict:
|
|
|
|
prose a 1k prompt asking for one sentence. Nothing to copy and nothing
|
|
repetitive, so only a model-backed proposer can help: this is the
|
|
shape that compares drafters.
|
|
echo a ~1.5k passage of unique lines, "repeat it back verbatim". The
|
|
output IS the prompt, which is ngram-mod's home turf.
|
|
structured "25 JSON records, one per line" — repetitive OUTPUT with no prompt
|
|
overlap, which is what an ngram matcher is really for in an
|
|
agentic fleet (config edits, tables, record lists).
|
|
|
|
echo and structured disable thinking (`chat_template_kwargs`), otherwise the
|
|
model thinks the answer into `reasoning_content`, `content` comes back empty and
|
|
the cross-configuration identity check passes vacuously — which is exactly how
|
|
this harness first "verified" an echo it never looked at.
|
|
|
|
Results on Dankserver (frank-medium payload, 2026-09-16, temperature 0). prose
|
|
1k/512 decode: none 17.7 | mtp n=3 32.4 | mtp n=7 26.7 | dflash=7 43.5 tok/s.
|
|
`draft-mtp,draft-dflash` fails at load (a chain has ONE draft context and MTP
|
|
builds against whatever model that context holds); ngram-mod has no such
|
|
constraint — it is draftless and joins the chain unconditionally.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
|
|
FILLER = ("The quick brown fox jumps over the lazy dog near the river bank. "
|
|
"A cache is a component that stores data so future requests are faster. ")
|
|
TOKENS_PER_REP = 28
|
|
UNIT = "frank-medium.service"
|
|
MODEL = "frank-medium"
|
|
SPEC_GREP = ("adding speculative implementation|- n_max=|- block_size=|creating MTP|"
|
|
"draft acceptance")
|
|
|
|
DEFAULT_CONFIGS = [
|
|
("none", {"SPEC_TYPE": "none"}),
|
|
("mtp-n3", {"SPEC_TYPE": "draft-mtp", "SPEC_DRAFT_N_MAX": "3"}),
|
|
("mtp-n7", {"SPEC_TYPE": "draft-mtp", "SPEC_DRAFT_N_MAX": "7"}),
|
|
("dflash-n7", {"SPEC_TYPE": "draft-dflash", "SPEC_DRAFT_N_MAX": "7"}),
|
|
]
|
|
|
|
WORDS = ["latency", "throughput", "cache", "adapter", "scheduler", "allocator",
|
|
"queue", "kernel", "buffer", "shard", "replica", "window"]
|
|
|
|
|
|
def echo_passage(lines=90):
|
|
"""Unique lines: no long span repeats inside the prompt, so an n-gram can
|
|
only fire when the OUTPUT copies the INPUT."""
|
|
return "\n".join(
|
|
"Record %03d: the %s of subsystem %d reports state %d with checksum %05d."
|
|
% (i, WORDS[i % len(WORDS)], i % 7, i % 3, (i * 7919) % 100000)
|
|
for i in range(lines))
|
|
|
|
|
|
ECHO = (echo_passage() + "\n\nRepeat the passage above verbatim, every line, exactly as "
|
|
"written, with no commentary before or after it.")
|
|
STRUCTURED = ("Output exactly 25 JSON records, one per line, no prose and no code fence. "
|
|
'Each line must look like {"id": N, "state": "active", "region": "us-west-2"} '
|
|
"with N running from 1 to 25.")
|
|
PROSE = FILLER * (1000 // TOKENS_PER_REP) + "\n\nReply with a single short sentence."
|
|
IDENTITY = "List the first five prime numbers, comma separated, then stop."
|
|
NO_THINK = {"chat_template_kwargs": {"enable_thinking": False}}
|
|
|
|
|
|
def log(msg, logfile):
|
|
line = "[" + time.strftime("%H:%M:%S") + "] " + str(msg)
|
|
print(line, flush=True)
|
|
with open(logfile, "a") as fh:
|
|
fh.write(line + "\n")
|
|
|
|
|
|
def sh(cmd):
|
|
return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout.strip()
|
|
def parse_configs(spec):
|
|
"""`name=SPEC_TYPE[@n_max][|KEY=VALUE]...`, entries joined by ';'.
|
|
|
|
KEY=VALUE parts override any env file key, which is how an impl's own
|
|
window (SPEC_NGRAM_N_MIN/MAX, for instance) gets swept without hand-editing
|
|
env files."""
|
|
out = []
|
|
for chunk in spec.split(";"):
|
|
chunk = chunk.strip()
|
|
if not chunk:
|
|
continue
|
|
name, _, rest = chunk.partition("=")
|
|
over = {}
|
|
if "|" in rest:
|
|
rest, _, extras = rest.partition("|")
|
|
for item in extras.split("|"):
|
|
if "=" in item:
|
|
k, _, v = item.partition("=")
|
|
over[k.strip()] = v.strip()
|
|
n_max = None
|
|
if "@" in rest:
|
|
rest, _, n_max = rest.partition("@")
|
|
over["SPEC_TYPE"] = rest.strip()
|
|
if n_max:
|
|
over["SPEC_DRAFT_N_MAX"] = n_max.strip()
|
|
out.append((name.strip(), over))
|
|
return out
|
|
|
|
|
|
def post(base, body, timeout=1800):
|
|
req = urllib.request.Request(
|
|
base + "/v1/chat/completions", data=json.dumps(body).encode(),
|
|
headers={"Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
return json.load(r)
|
|
|
|
|
|
def vram():
|
|
out = sh("rocm-smi --showmeminfo vram 2>/dev/null | grep 'Used Memory'")
|
|
return [round(int(x) / 1e9, 2) for x in re.findall(r":\s+(\d+)\s*$", out, re.M)][:2]
|
|
|
|
|
|
def wait_health(base, timeout=420):
|
|
t0 = time.time()
|
|
while time.time() - t0 < timeout:
|
|
if not sh("pgrep -x llama-server >/dev/null && echo up"):
|
|
return False
|
|
try:
|
|
with urllib.request.urlopen(base + "/health", timeout=5) as r:
|
|
if r.status == 200 and json.load(r).get("status") == "ok":
|
|
return True
|
|
except Exception:
|
|
time.sleep(3)
|
|
return False
|
|
|
|
|
|
def stop_unit():
|
|
sh("sudo systemctl stop " + UNIT)
|
|
sh("pkill -x llama-server")
|
|
for _ in range(40):
|
|
if not sh("pgrep -x llama-server || true"):
|
|
return
|
|
time.sleep(1)
|
|
|
|
|
|
def run_shape(base, prompt, max_tokens, label, extra=None):
|
|
body = {"model": MODEL, "messages": [{"role": "user", "content": prompt}],
|
|
"max_tokens": max_tokens, "temperature": 0.0}
|
|
if extra:
|
|
body.update(extra)
|
|
d = post(base, body)
|
|
tm = d["timings"]
|
|
msg = d["choices"][0]["message"]
|
|
content = (msg.get("content") or "").strip()
|
|
row = {"shape": label,
|
|
"prompt_n": tm["prompt_n"],
|
|
"prefill_tps": round(1000 * tm["prompt_n"] / tm["prompt_ms"], 1),
|
|
"predicted_n": tm["predicted_n"],
|
|
"decode_tps": round(tm["predicted_per_second"], 2),
|
|
"draft_n": tm.get("draft_n", 0),
|
|
"accepted": tm.get("draft_n_accepted", 0),
|
|
"answer": content,
|
|
"reasoning_len": len(msg.get("reasoning_content") or "")}
|
|
if label.startswith("echo"):
|
|
# How much of the answer really is the passage: a config that "wins" by
|
|
# answering something else is not a win.
|
|
want = {l.strip() for l in echo_passage().splitlines()}
|
|
got = [l.strip() for l in content.splitlines() if l.strip()]
|
|
row["copy_ratio"] = round(sum(1 for l in got if l in want) / len(got), 3) if got else 0.0
|
|
return row
|
|
|
|
|
|
def measure(base):
|
|
run_shape(base, FILLER * (4000 // TOKENS_PER_REP) +
|
|
"\n\nReply with a single short sentence.", 32, "warmup")
|
|
return [
|
|
run_shape(base, PROSE, 512, "prose 1k/512"),
|
|
run_shape(base, ECHO, 400, "echo 1.5k/400", NO_THINK),
|
|
run_shape(base, STRUCTURED, 400, "structured/400", NO_THINK),
|
|
run_shape(base, IDENTITY, 192, "identity"),
|
|
]
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("env_file")
|
|
ap.add_argument("serve_script")
|
|
ap.add_argument("--base-url", default="http://127.0.0.1:7770")
|
|
ap.add_argument("--log", default="/tmp/bench-spec-ab.log")
|
|
ap.add_argument("--configs", default=None,
|
|
help="name=SPEC_TYPE[@n_max] joined by ';' (default: the four "
|
|
"configs measured on 2026-09-16)")
|
|
ap.add_argument("--json", default="/tmp/bench-spec-ab.json")
|
|
args = ap.parse_args()
|
|
|
|
configs = parse_configs(args.configs) if args.configs else DEFAULT_CONFIGS
|
|
# Absolute paths: the launcher must not depend on the caller's cwd, and a
|
|
# relative path plus a `cd` to its own directory double-counts the prefix.
|
|
env_file = os.path.abspath(args.env_file)
|
|
serve_script = os.path.abspath(args.serve_script)
|
|
base, logfile = args.base_url, args.log
|
|
results = {}
|
|
log("spec A/B start: " + ", ".join(n for n, _ in configs) +
|
|
" (unit " + UNIT + " stopped for the duration)", logfile)
|
|
stop_unit()
|
|
for name, over in configs:
|
|
env_path = write_env(env_file, name, over)
|
|
logf = "/tmp/spec-ab-" + name.replace("/", "_") + ".log"
|
|
sh("FRANK_MEDIUM_ENV_FILE=" + env_path + " setsid bash " + serve_script +
|
|
" > " + logf + " 2>&1 < /dev/null &")
|
|
if not wait_health(base):
|
|
log(name + ": FAILED to become healthy -- " + sh("tail -3 " + logf), logfile)
|
|
results[name] = {"error": "not healthy", "tail": sh("tail -6 " + logf)}
|
|
stop_unit()
|
|
continue
|
|
rows = measure(base)
|
|
results[name] = {"vram_gb": vram(), "rows": rows,
|
|
"spec_log": sh("grep -E '" + SPEC_GREP + "' " + logf + " | tail -4")}
|
|
for r in rows:
|
|
log(name + " | " + r["shape"] + " prefill " + str(r["prefill_tps"]) +
|
|
" tok/s | decode " + str(r["decode_tps"]) + " tok/s | draft " +
|
|
str(r["draft_n"]) + "/" + str(r["accepted"]) +
|
|
(" (" + str(round(100.0 * r["accepted"] / r["draft_n"])) + " %)" if r["draft_n"] else "") +
|
|
(" | copy " + str(r["copy_ratio"]) if "copy_ratio" in r else "") +
|
|
(" | EMPTY ANSWER" if not r["answer"] else ""), logfile)
|
|
log(name + " | vram " + str(results[name]["vram_gb"]), logfile)
|
|
stop_unit()
|
|
|
|
with open(args.json, "w") as fh:
|
|
json.dump(results, fh, indent=2)
|
|
for shape in ("prose 1k/512", "echo 1.5k/400", "structured/400", "identity"):
|
|
texts = [r["answer"] for v in results.values() if "rows" in v
|
|
for r in v["rows"] if r["shape"] == shape]
|
|
if any(not t for t in texts):
|
|
log("WARNING: '" + shape + "' produced an empty answer in some configuration -- "
|
|
"its identity check proves nothing", logfile)
|
|
elif len(set(texts)) > 1:
|
|
log("WARNING: configurations disagree on greedy text for '" + shape +
|
|
"' -- speculation is changing answers", logfile)
|
|
log("restoring " + UNIT, logfile)
|
|
sh("sudo systemctl start " + UNIT)
|
|
for _ in range(120):
|
|
try:
|
|
with urllib.request.urlopen(base + "/health", timeout=5) as r:
|
|
if json.load(r).get("status") == "ok":
|
|
break
|
|
except Exception:
|
|
time.sleep(5)
|
|
log("done; results in " + args.json, logfile)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main()) |