|
| 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()) |
0 commit comments