inference-server / scripts /run_matrix.py
Aditya Kulkarni
feat: land nfcorpus benches, encode batch_size, and INT8-negative docs
dfdc75f
Raw History Blame Contribute Delete
11.3 kB
"""Driver for the benchmark run matrices.
Each server run needs a fresh uvicorn process because device, backend, batch size, and the
ONNX model file are read once at startup (app/config.py), so they cannot be changed in a
running server. For every config this script starts a server with the right env vars, waits
for /health, runs scripts/bench.py against it, then tears the server down. Naive baseline runs
skip the server and call scripts/naive_bench.py directly.
Groups:
legacy - the original 16 synthetic-input runs (1 short text/request, batch-size sweep).
latency - nfcorpus *queries* (short), texts/request 1/8/32 at concurrency 1.
throughput - nfcorpus *corpus* docs (long, mixed), encode batch 128/256/512 (concurrency = batch).
The latency/throughput groups run across 4 backends: pytorch-cpu, pytorch-mps, onnx-fp32, onnx-int8.
Usage:
uv run python scripts/run_matrix.py # default: new = latency + throughput
uv run python scripts/run_matrix.py --group all # legacy + latency + throughput
uv run python scripts/run_matrix.py --group throughput --filter onnx-int8
uv run python scripts/run_matrix.py --dry-run # print the plan, run nothing
"""
from __future__ import annotations
import argparse
import os
import subprocess
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
BENCHMARKS_DIR = REPO_ROOT / "benchmarks"
DATA_DIR = BENCHMARKS_DIR / "data"
ONNX_MODEL_DIR = REPO_ROOT / "models" / "minilm-onnx"
HOST = "127.0.0.1"
PORT = 8000
HEALTH_URL = f"http://{HOST}:{PORT}/health"
EMBED_URL = f"http://{HOST}:{PORT}/embed"
HEALTH_TIMEOUT_S = 120.0
QUERIES_FILE = "benchmarks/data/nfcorpus-queries.jsonl" # short -> latency
CORPUS_FILE = "benchmarks/data/nfcorpus-corpus.jsonl" # long/mixed -> throughput
# (label, backend, device, onnx_file_name) for the latency/throughput sweeps.
WORKLOAD_BACKENDS = [
("pytorch-cpu", "pytorch", "cpu", None),
("pytorch-mps", "pytorch", "mps", None),
("onnx-fp32", "onnx", "cpu", "onnx/model_O3.onnx"),
("onnx-int8", "onnx", "cpu", "onnx/model_int8.onnx"),
]
@dataclass(frozen=True)
class Run:
name: str # output file stem / bench label
group: str # "legacy" | "latency" | "throughput"
script: str # "bench" (server) or "naive" (no server)
backend: str # "pytorch" | "onnx" for server runs; "" for naive
device: str # "cpu" | "mps" (metadata label; ONNX always runs on CPU)
batch: int | None # server MAX_BATCH_SIZE; None for naive
concurrency: int # bench concurrency; 1 for naive
max_wait_ms: int = 500 # server MAX_WAIT_MS (batch time-trigger)
texts_per_request: int = 1
text_source: str = "synthetic" # "synthetic" | "file"
text_file: str | None = None # JSONL pool path (relative to repo root) when source=file
onnx_file_name: str | None = None # ONNX_FILE_NAME (fp32 vs int8) for onnx runs
requests: int = 500
warmup: int = 50
def build_legacy_runs() -> list[Run]:
runs = [
Run("naive-cpu", "legacy", "naive", "", "cpu", None, 1),
Run("naive-mps", "legacy", "naive", "", "mps", None, 1),
Run("pytorch-cpu-batch1-c1", "legacy", "bench", "pytorch", "cpu", 1, 1),
Run("pytorch-mps-batch1-c1", "legacy", "bench", "pytorch", "mps", 1, 1),
]
for batch in (1, 8, 16, 32):
runs.append(Run(f"pytorch-cpu-batch{batch}-c32", "legacy", "bench", "pytorch", "cpu", batch, 32))
for batch in (1, 8, 16, 32):
runs.append(Run(f"pytorch-mps-batch{batch}-c32", "legacy", "bench", "pytorch", "mps", batch, 32))
for batch in (1, 8, 16, 32):
runs.append(Run(f"onnx-cpu-batch{batch}-c32", "legacy", "bench", "onnx", "cpu", batch, 32))
return runs
def build_latency_runs() -> list[Run]:
runs = []
for label, backend, device, onnx_file in WORKLOAD_BACKENDS:
for tpr in (1, 8, 32):
runs.append(Run(
name=f"lat-{label}-tpr{tpr}-c1",
group="latency", script="bench", backend=backend, device=device,
# batch=32 so a request's 8/32 texts encode in one pass; tiny max_wait_ms so
# the batcher's time-trigger doesn't add latency waiting for c=1 requests.
batch=32, concurrency=1, max_wait_ms=5, texts_per_request=tpr,
text_source="file", text_file=QUERIES_FILE, onnx_file_name=onnx_file,
requests=200, warmup=20,
))
return runs
def build_throughput_runs() -> list[Run]:
runs = []
for label, backend, device, onnx_file in WORKLOAD_BACKENDS:
for bs in (128, 256, 512):
runs.append(Run(
name=f"tput-{label}-bs{bs}",
group="throughput", script="bench", backend=backend, device=device,
batch=bs, concurrency=bs, texts_per_request=1,
text_source="file", text_file=CORPUS_FILE, onnx_file_name=onnx_file,
requests=4 * bs, warmup=bs,
))
return runs
def build_runs(group: str) -> list[Run]:
groups = {
"legacy": build_legacy_runs,
"latency": build_latency_runs,
"throughput": build_throughput_runs,
}
if group == "all":
selected = ["legacy", "latency", "throughput"]
elif group == "new":
selected = ["latency", "throughput"]
else:
selected = [group]
runs: list[Run] = []
for name in selected:
runs.extend(groups[name]())
return runs
def output_path(run: Run) -> Path:
return BENCHMARKS_DIR / f"{run.name}.json"
def start_server(run: Run) -> subprocess.Popen:
env = os.environ.copy()
env["MAX_BATCH_SIZE"] = str(run.batch)
env["MAX_WAIT_MS"] = str(run.max_wait_ms)
if run.backend == "onnx":
env["BACKEND"] = "onnx" # ONNX path ignores DEVICE and runs on CPU
if run.onnx_file_name:
env["ONNX_FILE_NAME"] = run.onnx_file_name
else:
env.pop("BACKEND", None)
env.pop("ONNX_FILE_NAME", None)
env["DEVICE"] = run.device
return subprocess.Popen(
["uv", "run", "uvicorn", "app.main:app", "--host", HOST, "--port", str(PORT), "--no-access-log"],
cwd=REPO_ROOT,
env=env,
)
def wait_for_health(proc: subprocess.Popen, timeout_s: float) -> None:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if proc.poll() is not None:
raise RuntimeError(f"server exited during startup (code {proc.returncode})")
try:
with urllib.request.urlopen(HEALTH_URL, timeout=5) as resp:
if resp.status == 200:
return
except (urllib.error.URLError, OSError):
pass
time.sleep(1.0)
raise TimeoutError(f"server did not become healthy within {timeout_s:.0f}s")
def stop_server(proc: subprocess.Popen) -> None:
proc.terminate()
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
def run_bench(run: Run) -> None:
cmd = [
"uv", "run", "python", "scripts/bench.py",
"--label", run.name,
"--backend", run.backend,
"--device", run.device,
"--server-batch-size", str(run.batch),
"--concurrency", str(run.concurrency),
"--requests", str(run.requests),
"--warmup", str(run.warmup),
"--texts-per-request", str(run.texts_per_request),
"--text-source", run.text_source,
"--url", EMBED_URL,
"--output", str(output_path(run)),
]
if run.text_source == "file":
cmd += ["--text-file", run.text_file]
subprocess.run(cmd, cwd=REPO_ROOT, check=True)
def run_naive(run: Run) -> None:
subprocess.run(
[
"uv", "run", "python", "scripts/naive_bench.py",
"--label", run.name,
"--device", run.device,
"--requests", str(run.requests),
"--warmup", str(run.warmup),
"--texts-per-request", str(run.texts_per_request),
"--output", str(output_path(run)),
],
cwd=REPO_ROOT,
check=True,
)
def execute(run: Run) -> None:
if run.script == "naive":
run_naive(run)
return
proc = start_server(run)
try:
wait_for_health(proc, HEALTH_TIMEOUT_S)
run_bench(run)
finally:
stop_server(proc)
time.sleep(1.0) # let the port free before the next server binds
def print_plan(runs: list[Run]) -> None:
print(f"Planned runs ({len(runs)}):")
for i, run in enumerate(runs, 1):
batch = "-" if run.batch is None else str(run.batch)
print(
f" {i:2d}. {run.name:24s} grp={run.group:10s} backend={run.backend or '-':7s} "
f"device={run.device:3s} batch={batch:>3s} c={run.concurrency:<3d} "
f"tpr={run.texts_per_request:<3d} src={run.text_source:9s} -> benchmarks/{run.name}.json"
)
def validate_data_files(runs: list[Run]) -> None:
needed = {run.text_file for run in runs if run.text_source == "file"}
missing = [f for f in needed if f and not (REPO_ROOT / f).exists()]
if missing:
raise SystemExit(
f"missing dataset pools: {missing}. Run `uv run python scripts/prepare_dataset.py` first."
)
if any(r.backend == "onnx" for r in runs) and not ONNX_MODEL_DIR.exists():
raise SystemExit(
f"ONNX runs selected but {ONNX_MODEL_DIR} is missing. "
f"Run `uv run python scripts/export_onnx.py` first, or use --filter to skip onnx."
)
int8_needed = any(r.onnx_file_name == "onnx/model_int8.onnx" for r in runs)
if int8_needed and not (ONNX_MODEL_DIR / "onnx" / "model_int8.onnx").exists():
raise SystemExit(
"onnx-int8 runs selected but onnx/model_int8.onnx is missing. "
"Run `uv run python scripts/quantize_onnx.py` first, or use --filter to skip int8."
)
def main() -> None:
parser = argparse.ArgumentParser(description="Run the benchmark matrices.")
parser.add_argument(
"--group", choices=("legacy", "latency", "throughput", "new", "all"), default="new",
help="Which run group(s). 'new' = latency + throughput (default); 'all' adds legacy.",
)
parser.add_argument("--dry-run", action="store_true", help="Print the planned runs and exit.")
parser.add_argument("--filter", help="Only run configs whose name contains this substring.")
args = parser.parse_args()
runs = build_runs(args.group)
if args.filter:
runs = [r for r in runs if args.filter in r.name]
if not runs:
raise SystemExit(f"no runs match filter {args.filter!r}")
print_plan(runs)
if args.dry_run:
return
validate_data_files(runs)
BENCHMARKS_DIR.mkdir(parents=True, exist_ok=True)
for i, run in enumerate(runs, 1):
print(f"\n=== [{i}/{len(runs)}] {run.name} ===", flush=True)
execute(run)
print("\nMatrix complete.")
if __name__ == "__main__":
main()