BigMoeOnEdge/scripts/humaneval-bench.py
Raffaele 4334c89616
Some checks are pending
ci / changes (push) Waiting to run
ci / format (push) Blocked by required conditions
ci / host-linux (push) Blocked by required conditions
ci / host-windows (push) Waiting to run
ci / android-apk (push) Waiting to run
feat(moe): cache-aware expert substitution (--expert-substitute, experimental) and --ppl (#171)
Experimental, off by default. Before a decode routing is committed, every
expert already in the LRU cache gets its score raised by L times the
token's score range and the top-k is taken again, so a near-tie goes to
the expert already in RAM (Skliar et al., arXiv:2412.00099). The same
number of experts runs; fewer are read from flash. Scores are read from
the tensor the graph itself sorted, exact for any gating function.

Desktop, Qwen3.6-35B Q4_K_M at L=0.15: 258 to 119 MiB of flash per
token, 2.37 to 3.84 tok/s, perplexity +1 to 4 %, tinyMMLU 88 to 84/100,
HumanEval-50 42 = 42. The on-device A/B is still owed, hence experimental.

Also: --ppl / --ppl-step / --ppl-list / --ppl-choices (teacher-forced
perplexity, one token per decode so cache-dependent policies are priced
where they act), scripts/tinymmlu-bench.py, scripts/humaneval-bench.py,
gates G8d/G8e, app switch "Prefer cached experts" under Experimental,
docs/cache-aware-substitution.md.
2026-08-29 10:13:47 +02:00

167 lines
7 KiB
Python

#!/usr/bin/env python3
"""HumanEval (a fixed prefix of it) under a lossy routing setting, from the desktop.
Where tinyMMLU scores one token after a teacher-forced prompt, this generates: 100 to 200 tokens
of function body per problem, greedy, so a routing perturbation compounds the way it does in a
real reply. Each cell is one `bmoe-cli --session` (one model load, warm cache between problems,
KV cleared per problem); the completion is cut at the usual HumanEval stop sequences and graded by
running the canonical tests in a subprocess. pass@1, deterministic.
The subset is the first N problems of the dataset, declared as such; N is a time budget, not a
statistical design, and the number to read is the difference between cells on the same problems.
Usage:
python scripts/humaneval-bench.py --data HumanEval.jsonl.gz --cli build/cli/Release/bmoe-cli.exe \
--model M.gguf --out results/ --lambda 0 --lambda 0.15 [--limit 50]
HumanEval.jsonl.gz is data/HumanEval.jsonl.gz from github.com/openai/human-eval (MIT).
"""
import argparse
import gzip
import json
import os
import subprocess
import sys
import tempfile
STOP = ["\nclass ", "\ndef ", "\n#", "\nif __name__", "\nprint("]
def load_problems(path, limit):
rows = [json.loads(l) for l in gzip.open(path, "rt", encoding="utf-8")]
return rows[:limit] if limit else rows
def truncate(completion):
cut = len(completion)
for s in STOP:
i = completion.find(s)
if i != -1 and i < cut:
cut = i
return completion[:cut]
def grade(problem, completion, timeout):
program = problem["prompt"] + completion + "\n\n" + problem["test"] + f"\n\ncheck({problem['entry_point']})\n"
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, encoding="utf-8") as f:
f.write(program)
path = f.name
try:
r = subprocess.run([sys.executable, path], capture_output=True, timeout=timeout)
if r.returncode == 0:
return True, ""
# Keep the verdict line only: a full traceback names the temporary file, and these
# records are meant to be committed.
lines = [l.strip() for l in r.stderr.decode("utf-8", "replace").splitlines() if l.strip()]
return False, (lines[-1] if lines else f"exit {r.returncode}")
except subprocess.TimeoutExpired:
return False, "timeout"
finally:
os.unlink(path)
def cli_env(cli):
env = dict(os.environ)
cli_dir = os.path.dirname(os.path.abspath(cli))
bin_dir = os.path.normpath(os.path.join(cli_dir, "..", "..", "bin", os.path.basename(cli_dir)))
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
return env
def run_cell(args, lam, problems, results_path):
done = {}
if os.path.exists(results_path):
with open(results_path, encoding="utf-8") as f:
for line in f:
r = json.loads(line)
done[r["task_id"]] = r
todo = [p for p in problems if p["task_id"] not in done]
if not todo:
return done
if done:
print(f" resuming: {len(done)} done, {len(todo)} to go", flush=True)
cmd = [
args.cli, "-m", args.model, "-t", str(args.threads), "-c", str(args.ctx), "--ubatch", str(args.ctx),
"--moe-stream", "--cache-mb", str(args.cache_mb), "--io-threads", "4", "--overlap", "--session",
]
if lam > 0:
cmd += ["--expert-substitute", str(lam)]
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, encoding="utf-8", bufsize=1, env=cli_env(args.cli))
def read_until(prefix):
for line in proc.stdout:
if line.startswith(prefix):
return json.loads(line[len(prefix):])
if line.startswith("BMOE_ERROR "):
err = json.loads(line[len("BMOE_ERROR "):])
if err.get("fatal"):
raise RuntimeError(err.get("msg", "fatal"))
return None
raise RuntimeError("engine exited")
read_until("BMOE_READY ")
with open(results_path, "a", encoding="utf-8") as out:
for i, p in enumerate(todo):
req = {"cmd": "generate", "id": i + 1, "prompt": p["prompt"], "n_predict": args.n_predict,
"think": False, "clear_kv": True}
proc.stdin.write(json.dumps(req) + "\n")
proc.stdin.flush()
d = read_until("BMOE_DONE ")
if d is None:
r = {"task_id": p["task_id"], "passed": False, "error": "engine error", "completion": ""}
else:
completion = truncate(d["text"])
ok, err = grade(p, completion, args.timeout)
r = {"task_id": p["task_id"], "passed": ok, "error": err, "completion": completion,
"tokens": d.get("tokens"), "tok_s": d.get("tok_s"), "read_mib": d.get("read_mib"),
"cache_hit_pct": d.get("cache_hit_pct")}
out.write(json.dumps(r) + "\n")
out.flush()
done[p["task_id"]] = r
print(f" {p['task_id']}: {'pass' if r['passed'] else 'FAIL'} ({len(done)}/{len(problems)})", flush=True)
proc.stdin.write(json.dumps({"cmd": "close"}) + "\n")
proc.stdin.flush()
proc.wait(timeout=60)
return done
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--data", required=True)
ap.add_argument("--cli", required=True)
ap.add_argument("--model", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--lambda", dest="lambdas", type=float, action="append", required=True)
ap.add_argument("--limit", type=int, default=50)
ap.add_argument("--n-predict", type=int, default=256)
ap.add_argument("--threads", type=int, default=8)
ap.add_argument("--cache-mb", type=int, default=2000)
ap.add_argument("--ctx", type=int, default=1024)
ap.add_argument("--timeout", type=float, default=10.0)
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
problems = load_problems(args.data, args.limit)
print(f"{len(problems)} problems", flush=True)
summary = []
for lam in args.lambdas:
print(f"cell L={lam:g} ...", flush=True)
results = run_cell(args, lam, problems, os.path.join(args.out, f"cell_L{lam:g}.jsonl"))
passed = sum(1 for r in results.values() if r["passed"])
toks = [r["tok_s"] for r in results.values() if r.get("tok_s")]
reads = [r["read_mib"] / r["tokens"] for r in results.values() if r.get("tokens") and r.get("read_mib")]
summary.append((lam, passed, len(results), sum(toks) / len(toks) if toks else 0.0,
sum(reads) / len(reads) if reads else 0.0))
print(f"L={lam:g}: {passed}/{len(results)} pass@1", flush=True)
print()
print("| `--expert-substitute` | HumanEval pass@1 | mean tok/s | mean flash per token |")
print("|---|---:|---:|---:|")
for lam, passed, n, tps, mib in summary:
print(f"| {lam:g} | {passed} / {n} | {tps:.2f} | {mib:.0f} MiB |")
if __name__ == "__main__":
sys.exit(main())