#!/usr/bin/env python3
"""Benchmark llama.cpp speculative configs on a fixed prompt set.
Protocol (see NGRAM_TASK.md ยง4): the same prompts, chat template and system
prompt across four configs, shuffled order, warmup excluded, acceptance taken
from /completion timings (draft_n / draft_n_accepted), pp reported to make
sure speculation does not hurt prompt processing.
Runs on the host that owns the model; starts and stops llama-server itself.
Usage (pilot):
python3 ngram_bench.py --prompts ngram_prompts.json \
--system-prompt ~/navi-system-prompt.txt --cache ngram-cache.bin \
--configs A,B,C,D --ids short-ru-1,code-py-1,explain-ru-1,long-ru-1,short-en-1 \
--reps 1 --temps 0 --n-predict 256 --out pilot.json
"""
from __future__ import annotations
import argparse
import glob
import http.client
import json
import os
import random
import re
import subprocess
import sys
import time
from urllib.parse import urlsplit
BIN = os.path.expanduser("~/llama/llama-b11960/llama-server")
def base_flags(ctx: int, ngl: int = 99, n_cpu_moe: int = 999,
threads: int = 4) -> list[str]:
"""Flags shared by every config (the ones used in the earlier tg benches).
ngl < 99 with n_cpu_moe 0 gives *whole-layer* offload: a layer lives
entirely on one device, so its hidden state never crosses PCIe mid-layer.
"""
flags = [
"-c", str(ctx),
"-ngl", str(ngl),
"--n-cpu-moe", str(n_cpu_moe),
"-fa", "on",
"-ctk", "q8_0", "-ctv", "q8_0",
"-t", str(threads),
"--no-webui",
]
return flags
def split_config(name: str) -> tuple[str, dict[str, int]]:
"""'B:32' -> config B with --n-cpu-moe 32 (experts only offload).
'B@12' -> config B with -ngl 12 --n-cpu-moe 0 (whole layers on GPU).
'B' keeps the defaults on every axis."""
over: dict[str, int] = {}
base = name
if "@" in base:
base, _, ngl = base.partition("@")
over = {"ngl": int(ngl), "n_cpu_moe": 0}
elif ":" in base:
base, _, ncmoe = base.partition(":")
over = {"n_cpu_moe": int(ncmoe)}
return base, over
def config_flags(name: str, cache: str) -> list[str]:
match name:
case "A":
return ["--spec-type", "none"]
case "B":
return ["--spec-type", "draft-mtp", "--spec-draft-n-max", "1"]
case "C":
return ["--spec-type", "ngram-cache", "-lcs", cache]
case "D":
return ["--spec-type", "draft-mtp,ngram-cache",
"--spec-draft-n-max", "1", "-lcs", cache]
case "E":
# Control: lookup decoding from the prompt's own context only.
# C - E is the contribution of the corpus table.
return ["--spec-type", "ngram-cache"]
case "F":
# Control: purely context-based ngram-mod, no corpus at all.
return ["--spec-type", "ngram-mod"]
case "G":
# Control: MTP + lookup without a static table.
return ["--spec-type", "draft-mtp,ngram-cache",
"--spec-draft-n-max", "1"]
case "H":
return ["--spec-type", "draft-mtp", "--spec-draft-n-max", "2"]
case "I":
return ["--spec-type", "draft-mtp", "--spec-draft-n-max", "3"]
case "J":
return ["--spec-type", "draft-mtp", "--spec-draft-n-max", "5"]
case "K":
return ["--spec-type", "draft-mtp,ngram-mod",
"--spec-draft-n-max", "1"]
case "L":
return ["--spec-type", "draft-mtp,ngram-map-k4v",
"--spec-draft-n-max", "1"]
case "M":
return ["--spec-type", "ngram-map-k4v,draft-mtp",
"--spec-draft-n-max", "1"]
case _:
raise SystemExit(f"unknown config {name!r}")
class Server:
def __init__(self, model: str, port: int, flags: list[str], log_path: str,
ctx: int = 4096, ngl: int = 99, n_cpu_moe: int = 999,
threads: int = 4):
self.port = port
self.log_path = log_path
self.log = open(log_path, "wb")
self.proc = subprocess.Popen(
[BIN, "-m", model, "--port", str(port), "-lv", "4",
*base_flags(ctx, ngl, n_cpu_moe, threads), *flags],
stdout=self.log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
)
self.conn = http.client.HTTPConnection("127.0.0.1", port, timeout=900)
def wait_ready(self, timeout: float = 600) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if self.proc.poll() is not None:
raise RuntimeError(f"server died, see {self.log_path}")
try:
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
c.request("GET", "/health")
if c.getresponse().status == 200:
c.close()
return
c.close()
except OSError:
pass
time.sleep(1)
raise RuntimeError(f"server not ready in {timeout}s")
def post(self, path: str, payload: dict) -> dict:
body = json.dumps(payload)
self.conn.request("POST", path, body=body,
headers={"Content-Type": "application/json"})
resp = self.conn.getresponse()
raw = resp.read()
if resp.status != 200:
raise RuntimeError(f"{path} HTTP {resp.status}: {raw[:400]!r}")
return json.loads(raw)
def stop(self) -> None:
try:
self.proc.terminate()
self.proc.wait(timeout=30)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait()
self.log.close()
def spec_statistics(self) -> dict[str, dict]:
"""Sum every 'statistics <type>:' line from the verbose log."""
pattern = re.compile(
r"statistics\s+([a-z0-9-]+):\s+"
r"#calls\(b,g,a\)\s*=\s*(\d+)\s+(\d+)\s+(\d+),.*?"
r"#gen drafts\s*=\s*(\d+),\s*#acc drafts\s*=\s*(\d+),\s*"
r"#gen tokens\s*=\s*(\d+),\s*#acc tokens\s*=\s*(\d+)"
)
totals: dict[str, dict] = {}
with open(self.log_path, "r", errors="replace") as f:
for line in f:
m = pattern.search(line)
if not m:
continue
name = m.group(1)
# b, g, a, gen_drafts, acc_drafts, gen_tokens, acc_tokens
vals = [int(x) for x in m.groups()[1:]]
acc = totals.setdefault(name, dict.fromkeys(
("calls", "gen_drafts", "acc_drafts", "gen_tokens",
"acc_tokens"), 0))
acc["calls"] += vals[2] # 'a' column: accepted calls
acc["gen_drafts"] += vals[3]
acc["acc_drafts"] += vals[4]
acc["gen_tokens"] += vals[5]
acc["acc_tokens"] += vals[6]
return totals
def apply_template(server: Server, system_prompt: str, user_prompt: str,
tools: list | None = None) -> str:
payload = {
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
}
if tools:
payload["tools"] = tools
data = server.post("/apply-template", payload)
prompt = data.get("prompt")
if not prompt:
raise RuntimeError(f"/apply-template returned {data!r}")
return prompt
def vram_used_mb() -> int | None:
"""amdgpud exposes this on every card; used to watch the expert offload."""
for path in glob.glob("/sys/class/drm/card*/device/mem_info_vram_used"):
try:
with open(path) as f:
return int(f.read()) // (1024 * 1024)
except (OSError, ValueError):
continue
return None
def run_config(name, args, prompts, templated, order):
base, over = split_config(name)
flags = config_flags(base, args.cache)
if args.extra:
flags += args.extra.split()
log_path = os.path.join(args.log_dir,
f"{args.tag}-{name.replace(':', '_').replace('@', 'ngl')}.log")
print(f"\n=== config {name}: {' '.join(flags)} "
f"[{over or 'default layout'}] ===\n log: {log_path}",
file=sys.stderr)
server = Server(args.model, args.port, flags, log_path, ctx=args.ctx,
ngl=over.get("ngl", 99),
n_cpu_moe=over.get("n_cpu_moe", 999),
threads=args.threads)
try:
server.wait_ready()
vram = vram_used_mb()
print(f" vram after load: {vram} MB", file=sys.stderr)
# Warmup is excluded from the numbers; its stats are ignored because
# acceptance is computed from per-request timings below.
server.post("/completion", {
"prompt": templated[prompts[0]["id"]],
"n_predict": 16, "temperature": 0, "cache_prompt": False,
"seed": 1,
})
records = []
for rep in range(args.reps):
for prompt_id in order:
for temp in args.temps:
t0 = time.time()
out = server.post("/completion", {
"prompt": templated[prompt_id],
"n_predict": args.n_predict,
"temperature": temp,
"cache_prompt": False,
"seed": args.seed + rep,
})
tm = out.get("timings", {})
rec = {
"config": name,
"prompt_id": prompt_id,
"rep": rep,
"temp": temp,
"wall_s": round(time.time() - t0, 2),
"cache_n": tm.get("cache_n"),
"prompt_n": tm.get("prompt_n"),
"prompt_ms": tm.get("prompt_ms"),
"predicted_n": tm.get("predicted_n"),
"predicted_ms": tm.get("predicted_ms"),
"draft_n": tm.get("draft_n"),
"draft_n_accepted": tm.get("draft_n_accepted"),
# Kept for the self-echo audit: a real answer must not
# be a copy of the prompt.
"content_head": (out.get("content") or "")[:200],
}
records.append(rec)
print(
f" {prompt_id:<14} rep{rep} t={temp}: "
f"pp {rec['prompt_n']} tok, "
f"tg {rec['predicted_n']} tok, "
f"{rec['predicted_n'] / rec['predicted_ms'] * 1000:5.2f} t/s, "
f"draft {rec['draft_n_accepted']}/{rec['draft_n']}",
file=sys.stderr,
)
finally:
server.stop()
# The log is complete only after the process exits.
return records, server.spec_statistics(), vram
def summarize(records, stats):
tg_n = sum(r["predicted_n"] for r in records)
tg_ms = sum(r["predicted_ms"] for r in records)
pp_n = sum(r["prompt_n"] for r in records)
pp_ms = sum(r["prompt_ms"] for r in records)
draft_n = sum(r["draft_n"] or 0 for r in records)
draft_acc = sum(r["draft_n_accepted"] or 0 for r in records)
return {
"requests": len(records),
"tg_tps": round(tg_n / tg_ms * 1000, 2) if tg_ms else None,
"pp_tps": round(pp_n / pp_ms * 1000, 2) if pp_ms else None,
"tg_tokens": tg_n,
"pp_tokens": pp_n,
"draft_tokens": draft_n,
"draft_accepted": draft_acc,
"acceptance": round(draft_acc / draft_n, 3) if draft_n else None,
"spec_stats": stats,
}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--prompts", required=True)
ap.add_argument("--system-prompt", required=True)
ap.add_argument("--cache", default="", help="static ngram cache for C and D")
ap.add_argument("--model", default=os.path.expanduser(
"~/models/Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS.gguf"))
ap.add_argument("--configs", default="A,B,C,D")
ap.add_argument("--ids", default="", help="comma separated prompt ids (all if empty)")
ap.add_argument("--reps", type=int, default=1)
ap.add_argument("--temps", default="0", help="comma separated temperatures")
ap.add_argument("--n-predict", type=int, default=256)
ap.add_argument("--ctx", type=int, default=4096)
ap.add_argument("--tools", default="",
help="JSON list of OpenAI-style tool schemas to render "
"into the chat template, as Navi does in production")
ap.add_argument("--port", type=int, default=8299)
ap.add_argument("--threads", type=int, default=4)
ap.add_argument("--extra", default="",
help="extra llama-server flags, e.g. '--ub 2048 -b 4096'")
ap.add_argument("--seed", type=int, default=1234)
ap.add_argument("--tag", default="pilot")
ap.add_argument("--log-dir", default=os.path.expanduser("~/ngram/smoke"))
ap.add_argument("--out", default="")
args = ap.parse_args()
prompts = json.load(open(args.prompts, encoding="utf-8"))
wanted = [p.strip() for p in args.ids.split(",") if p.strip()]
if wanted:
prompts = [p for p in prompts if p["id"] in wanted]
missing = set(wanted) - {p["id"] for p in prompts}
if missing:
raise SystemExit(f"unknown prompt ids: {sorted(missing)}")
args.temps = [float(t) for t in args.temps.split(",")]
configs = [c.strip() for c in args.configs.split(",") if c.strip()]
system_prompt = open(args.system_prompt, encoding="utf-8").read()
tools = json.load(open(args.tools, encoding="utf-8")) if args.tools else None
os.makedirs(args.log_dir, exist_ok=True)
# One server just to render the chat template; the rendered text is
# reused for every config so the prompt bytes are identical.
tmpl_log = os.path.join(args.log_dir, f"{args.tag}-template.log")
tmpl_server = Server(args.model, args.port, ["--spec-type", "none"],
tmpl_log, ctx=args.ctx)
try:
tmpl_server.wait_ready()
templated = {
p["id"]: apply_template(tmpl_server, system_prompt, p["prompt"], tools)
for p in prompts
}
finally:
tmpl_server.stop()
# Report the prompt length once: it must leave room for n_predict inside ctx.
sample = templated[prompts[0]["id"]]
print(f"rendered prompt sample: {len(sample)} chars (~{len(sample)//3} tok)",
file=sys.stderr)
order = [p["id"] for p in prompts]
results = {}
for name in configs:
random.Random(args.seed).shuffle(order)
try:
records, stats, vram = run_config(name, args, prompts,
templated, order)
except Exception as exc: # an OOM config must not kill the whole sweep
print(f"config {name} FAILED: {exc}", file=sys.stderr)
results[name] = {"error": str(exc)}
continue
results[name] = {"summary": summarize(records, stats),
"vram_mb": vram, "records": records}
print("\n=== summary ===")
header = f"{'cfg':<6} {'req':>4} {'tg t/s':>8} {'pp t/s':>8} " \
f"{'accept':>7} {'draft acc/gen':>14} {'vram MB':>8}"
print(header)
print("-" * len(header))
for name, res in results.items():
if "summary" not in res:
print(f"{name:<6} FAILED: {res['error'][:60]}")
continue
s = res["summary"]
print(f"{name:<6} {s['requests']:>4} {s['tg_tps']:>8} {s['pp_tps']:>8} "
f"{(s['acceptance'] if s['acceptance'] is not None else '-'):>7} "
f"{s['draft_accepted']:>6}/{s['draft_tokens']:<7} "
f"{res['vram_mb'] if res['vram_mb'] is not None else '-':>8}")
payload = {"args": vars(args), "results": results}
if args.out:
with open(args.out, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"\nwritten: {args.out}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())