Skip to content

Commit d43ccf5

Browse files
committed
benchmark: rewrite the compare driver in Python (bench_runner.py)
Replace bench_runner.sh with bench_runner.py: same behavior (toggle --engines and --by-suffix modes, staging benchmark/sql/* into the runner tree, median per benchmark) but far more readable than the bash + awk + inline-python it replaced. The runner already shelled out to python for the table, so this removes a language boundary. argparse + statistics.median; per-engine env overlaid on os.environ so caller-exported GPU_OP_* flags still reach the runner. pixi bench-sql, run_gpu_op_bench.sh, and benchmark/README.md repointed.
1 parent b03407c commit d43ccf5

5 files changed

Lines changed: 147 additions & 88 deletions

File tree

benchmark/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ pixi run bench-sql tpch/sf1/q0[16] --engines=stock,cpu,gpu # TPC-H (built-in)
1717
```
1818

1919
- `sql/<group>/*.benchmark` — source of truth (staged into the runner tree by the driver).
20-
- `drivers/bench_runner.sh` — the unified compare driver (`--engines` toggle mode, or
20+
- `drivers/bench_runner.py` — the unified compare driver (`--engines` toggle mode, or
2121
`--by-suffix` when each `<regime>_<engine>.benchmark` is engine-specific).
2222
- `drivers/build_runner.sh` — builds `benchmark_runner` + applies the load-extension
2323
hook (`runner_load_extension.patch`). NixOS: run the cmake step under

benchmark/drivers/bench_runner.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#!/usr/bin/env python3
2+
"""Unified DuckDB benchmark_runner compare driver.
3+
4+
Runs DuckDB's `benchmark_runner` once per engine (each engine toggles which
5+
extension loads), parses the per-iteration timings it prints, and tabulates the
6+
warm median per benchmark. Engines:
7+
8+
stock no extension
9+
cpu mojo-kernel-overrides (DUCKDB_BENCH_EXTENSION)
10+
gpu mojo-gpu-operator (DUCKDB_BENCH_EXTENSION, +GPU_OP_MIN_ROWS=0)
11+
12+
Usage:
13+
bench_runner.py <group> [--engines stock,cpu,gpu] [--by-suffix] [runner-args...]
14+
15+
<group> a dir under benchmark/sql/ (mojo_simd | gpu_xover | gpu_knn), or a
16+
path under the runner tree for built-ins (e.g. tpch/sf1/q0[16]).
17+
--engines toggle mode (default): run the SAME benchmarks under each engine,
18+
one column per engine.
19+
--by-suffix per-file mode: each <regime>_<engine>.benchmark runs under the
20+
extension named by its suffix (stock|cpu|gpu); rows are regimes.
21+
runner-args anything else (e.g. --threads=1) is passed through to the runner.
22+
23+
Env overrides: DUCKDB_SRC, RUNNER, OVR_EXT, GPU_EXT.
24+
"""
25+
from __future__ import annotations
26+
27+
import argparse
28+
import os
29+
import shutil
30+
import statistics
31+
import subprocess
32+
import sys
33+
from pathlib import Path
34+
35+
HERE = Path(__file__).resolve().parent
36+
ROOT = HERE.parent.parent
37+
SRC = Path(os.environ.get("DUCKDB_SRC", ROOT / "third_party" / "duckdb"))
38+
RUNNER = Path(os.environ.get("RUNNER", SRC / "build/release/benchmark/benchmark_runner"))
39+
OVR_EXT = os.environ.get("OVR_EXT", str(ROOT / "packages/mojo-kernel-overrides/build/mojo_overrides.duckdb_extension"))
40+
GPU_EXT = os.environ.get("GPU_EXT", str(ROOT / "packages/mojo-gpu-operator/build/mojo_gpu_operator.duckdb_extension"))
41+
42+
# Per-engine environment overlaid on the inherited env (so caller-exported flags
43+
# like GPU_OP_TRANSCENDENTAL still reach the runner).
44+
ENGINE_ENV = {
45+
"stock": {},
46+
"cpu": {"DUCKDB_BENCH_EXTENSION": OVR_EXT},
47+
"gpu": {"DUCKDB_BENCH_EXTENSION": GPU_EXT, "GPU_OP_MIN_ROWS": "0"},
48+
}
49+
50+
51+
def stage_sql_groups() -> None:
52+
"""Copy committed benchmark/sql/<group>/*.benchmark into the runner tree."""
53+
for group in sorted((ROOT / "benchmark" / "sql").glob("*")):
54+
if not group.is_dir():
55+
continue
56+
dst = SRC / "benchmark" / "micro" / group.name
57+
dst.mkdir(parents=True, exist_ok=True)
58+
for f in group.glob("*.benchmark"):
59+
shutil.copy(f, dst)
60+
61+
62+
def run_medians(target: str, engine: str, runner_args: list[str]) -> dict[str, float]:
63+
"""Run the runner for `target` (a regex or path) under `engine`; return
64+
{benchmark_name: median_ms}. The runner prints `name<TAB>run<TAB>timing`
65+
rows; timings are seconds (ERROR rows are skipped)."""
66+
env = {**os.environ, **ENGINE_ENV[engine]}
67+
proc = subprocess.run(
68+
[str(RUNNER), target, *runner_args],
69+
cwd=SRC, env=env, capture_output=True, text=True,
70+
)
71+
samples: dict[str, list[float]] = {}
72+
for line in (proc.stdout + proc.stderr).splitlines():
73+
parts = line.split("\t")
74+
if len(parts) != 3:
75+
continue
76+
name, _run, timing = parts
77+
try:
78+
t = float(timing) # skip the header ("timing") and ERROR rows first,
79+
except ValueError: # so they never create an empty bucket
80+
continue
81+
samples.setdefault(name, []).append(t)
82+
return {n: statistics.median(v) * 1000 for n, v in samples.items()}
83+
84+
85+
def toggle_mode(group: str, engines: list[str], runner_args: list[str]) -> None:
86+
regex = f"benchmark/{group}.*" if "/" in group else f"benchmark/micro/{group}/.*"
87+
per_engine = {e: run_medians(regex, e, runner_args) for e in engines}
88+
names = sorted({n for d in per_engine.values() for n in d})
89+
90+
def cell(e, n):
91+
return f"{per_engine[e][n]:10.2f}" if n in per_engine[e] else f"{'-':>10}"
92+
93+
print(f"{'benchmark':40}" + "".join(f"{e:>10}" for e in engines) + f"{'winner':>9}")
94+
for n in names:
95+
present = {e: per_engine[e][n] for e in engines if n in per_engine[e]}
96+
winner = min(present, key=present.get) if present else "-"
97+
short = n.replace("benchmark/micro/", "")
98+
print(f"{short:40}" + "".join(cell(e, n) for e in engines) + f"{winner:>9}")
99+
100+
101+
def suffix_mode(group: str, runner_args: list[str]) -> None:
102+
# ext per file from the <regime>_<engine>.benchmark name; rows = regime.
103+
rows: dict[str, dict[str, float]] = {}
104+
engines_seen: list[str] = []
105+
for f in sorted((SRC / "benchmark" / "micro" / group).glob("*.benchmark")):
106+
regime, _, engine = f.stem.rpartition("_")
107+
if engine not in ENGINE_ENV:
108+
continue
109+
med = run_medians(f"benchmark/micro/{group}/{f.name}", engine, runner_args)
110+
ms = next(iter(med.values()), None) # one benchmark per file
111+
rows.setdefault(regime, {})[engine] = ms
112+
if engine not in engines_seen:
113+
engines_seen.append(engine)
114+
engines = [e for e in ("stock", "cpu", "gpu") if e in engines_seen]
115+
print(f"{'regime':12}" + "".join(f"{e:>12}" for e in engines))
116+
for regime, by_eng in rows.items():
117+
cells = "".join(f"{by_eng[e]:12.2f}" if by_eng.get(e) is not None else f"{'-':>12}" for e in engines)
118+
print(f"{regime:12}{cells}")
119+
120+
121+
def main() -> int:
122+
p = argparse.ArgumentParser(description="Compare stock/CPU/GPU via DuckDB's benchmark_runner.")
123+
p.add_argument("group", help="benchmark/sql group name, or a runner-tree path (e.g. tpch/sf1/q0[16])")
124+
p.add_argument("--engines", default="stock,cpu,gpu", help="comma list (toggle mode)")
125+
p.add_argument("--by-suffix", action="store_true", help="per-file engine from name suffix")
126+
# pixi forwards a literal `--`; drop it so argparse doesn't treat it as
127+
# end-of-options (which would make --engines/--by-suffix positional).
128+
argv = [a for a in sys.argv[1:] if a != "--"]
129+
args, runner_args = p.parse_known_args(argv)
130+
131+
if not RUNNER.exists():
132+
sys.exit(f"no benchmark_runner at {RUNNER} - run 'pixi run bench-build'")
133+
stage_sql_groups()
134+
135+
if args.by_suffix:
136+
suffix_mode(args.group, runner_args)
137+
else:
138+
toggle_mode(args.group, args.engines.split(","), runner_args)
139+
return 0
140+
141+
142+
if __name__ == "__main__":
143+
raise SystemExit(main())

benchmark/drivers/bench_runner.sh

Lines changed: 0 additions & 84 deletions
This file was deleted.

packages/mojo-gpu-operator/benchmark/run_gpu_op_bench.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
# Reuses the consolidated harness (benchmark/README.md): the SAME stock benchmark_runner
1010
# (built by `pixi run bench-build`) and the unified driver. This wrapper just stages its
1111
# own self-generating `gpuop/` benchmark group + exports the GPU_OP_* flags, then delegates
12-
# the stock-vs-gpu compare to benchmark/drivers/bench_runner.sh.
12+
# the stock-vs-gpu compare to benchmark/drivers/bench_runner.py.
1313
#
1414
# The benchmark files self-generate a DECIMAL dataset via a `cache` directive (no tpch / external
1515
# db dependency) — DECIMAL because the f64 aggregate paths decline on raw DOUBLE columns.
@@ -45,4 +45,4 @@ export GPU_OP_COLPOOL="${GPU_OP_COLPOOL:-2}"
4545
# Delegate to the unified driver (toggle mode, stock vs gpu). SUB carries a '/', so
4646
# bench_runner treats it as a runner-tree path (benchmark/$SUB.*); the exported GPU_OP_*
4747
# flags are inherited by the runner subprocess.
48-
exec bash "$ROOT/benchmark/drivers/bench_runner.sh" "$SUB" --engines=stock,gpu
48+
exec python3 "$ROOT/benchmark/drivers/bench_runner.py" "$SUB" --engines=stock,gpu

pixi.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ bench-build = { cmd = "bash benchmark/drivers/build_runner.sh", depends-on = ["c
7676
# pixi run bench-sql gpu_xover --engines=stock,cpu,gpu
7777
# pixi run bench-sql gpu_knn --by-suffix
7878
# pixi run bench-sql tpch/sf1/q0[16] --engines=stock,cpu,gpu
79-
bench-sql = "bash benchmark/drivers/bench_runner.sh"
79+
bench-sql = "python3 benchmark/drivers/bench_runner.py"
8080
# Mojo vector-search harness (via the duckdb.mojo client): single + batch cosine
8181
# top-k across stock / cpu-simd / vss-HNSW / GPU, with latency + recall. Build the
8282
# extensions first (overrides-build / gpu-op-build); GPU/vss rows self-skip if absent.

0 commit comments

Comments
 (0)