feat(bench): add SQLite and PostgreSQL wrappers for benchmark queries
- implement SQLite and PostgreSQL wrappers to execute benchmark queries - update README to reflect task 09 as implemented - enhance storage size updates to handle (format, variant) replacements - clarify benchmarking methodology for remote PostgreSQL setup
This commit is contained in:
527
bench_runner.py
Normal file
527
bench_runner.py
Normal file
@@ -0,0 +1,527 @@
|
||||
"""Task 09 - Benchmark execution and resource measurement.
|
||||
|
||||
Runs the 35-cell matrix (Q1-Q7 x 5 formats) in randomized order. Every
|
||||
cell: 1 warm-up run (recorded as run 0, excluded from statistics) + 3
|
||||
measured runs, each in its OWN subprocess sampled with psutil every 50 ms
|
||||
for peak RSS / CPU time / read bytes. Result rows of every run are
|
||||
validated against out/bench/expected/ (tolerance 1e-9) and recorded as
|
||||
checksum_ok. Writes results_raw.csv, results_median.csv, PG EXPLAIN plans,
|
||||
PG host/server metrics, the archival storage sizes, an environment record,
|
||||
and the 09.ok marker. Spec: docs/specs/09_benchmark_execution.md; protocol:
|
||||
docs/rules/bench-methodology.md.
|
||||
|
||||
Environment adaptations (bench-methodology section 6):
|
||||
- the cold-cache matrix pass is SKIPPED (Windows host, no page-cache drop);
|
||||
- PostgreSQL runs on a remote host: no backend-PID psutil sampling;
|
||||
instead best-effort Prometheus host CPU/RAM windows per PG cell and per
|
||||
session, plus best-effort pg_stat_statements per-query session deltas;
|
||||
- on Windows the 50 ms sample reads the OS-maintained peak working set
|
||||
(peak_wset), so sub-interval children still report a true peak RSS.
|
||||
|
||||
After a run times out, the cell's remaining runs are SKIPPED (never retry
|
||||
a timeout - bench-methodology section 3); only executed runs appear in the
|
||||
raw results and the cell reports TIMEOUT.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import gzip
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
import psycopg
|
||||
|
||||
from common.monitoring import get_prom_url, host_window_metrics
|
||||
from common.pg import get_dsn
|
||||
from common.pipeline import (
|
||||
REPO_ROOT,
|
||||
check_dependencies,
|
||||
load_lab_config,
|
||||
parse_task_args,
|
||||
remove_stale_marker,
|
||||
write_marker,
|
||||
)
|
||||
from common.results import compare, parse_rows
|
||||
from common.storage_sizes import update_storage_sizes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TASK_ID = "09"
|
||||
DEPENDS_ON = ["08"]
|
||||
|
||||
CELL_TIMEOUT_S = 30 * 60 # hard per-run timeout, spec 09
|
||||
SAMPLE_INTERVAL_S = 0.05 # psutil sampling cadence, spec 09
|
||||
MEASURED_RUNS = 3 # 1 warm-up + 3 measured per cell, spec 09
|
||||
|
||||
FORMATS = ("csv", "json", "sqlite", "pg", "rdf")
|
||||
QUERY_NUMBERS = tuple(range(1, 8))
|
||||
|
||||
RAW_HEADER = [
|
||||
"format", "query", "run", "wall_s", "peak_rss_mb", "cpu_s",
|
||||
"cpu_util_pct", "read_mb", "rows", "checksum_ok", "status", "exec_order",
|
||||
]
|
||||
MEDIAN_HEADER = [
|
||||
"format", "query", "wall_s_median", "wall_s_min", "wall_s_max",
|
||||
"peak_rss_mb_median", "cpu_s_median", "cpu_util_pct_median",
|
||||
"read_mb_median", "rows", "checksum_ok", "status",
|
||||
]
|
||||
|
||||
# storage_sizes.csv completeness gate: every (format, variant) that must
|
||||
# exist after consolidation (working + archival footprints, spec 09)
|
||||
REQUIRED_SIZE_ROWS = {
|
||||
("csv", "tree"), ("csv", "targz"),
|
||||
("json", "full"), ("json", "hybrid"), ("json", "targz"),
|
||||
("sqlite", "db"), ("sqlite", "db_gz"),
|
||||
("pg", "live"), ("pg", "dump"),
|
||||
("rdf", "nt_gz"), ("rdf", "ttl"), ("rdf", "store"),
|
||||
}
|
||||
|
||||
|
||||
class CellTimeoutError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunResult:
|
||||
status: str
|
||||
wall_s: float = 0.0
|
||||
peak_rss_mb: float = 0.0
|
||||
cpu_s: float = 0.0
|
||||
cpu_util_pct: float = 0.0
|
||||
read_mb: float = 0.0
|
||||
rows: int | None = None
|
||||
checksum_ok: bool | None = None
|
||||
|
||||
def raw_row(self, fmt: str, qn: int, run: int, order: int) -> list:
|
||||
ok = "" if self.checksum_ok is None else str(self.checksum_ok).lower()
|
||||
return [
|
||||
fmt, f"q{qn}", run, round(self.wall_s, 4), round(self.peak_rss_mb, 1),
|
||||
round(self.cpu_s, 3), round(self.cpu_util_pct, 1), round(self.read_mb, 2),
|
||||
"" if self.rows is None else self.rows, ok, self.status, order,
|
||||
]
|
||||
|
||||
|
||||
def _kill_tree(proc: subprocess.Popen) -> None:
|
||||
"""Kill the child and every descendant (the stub's real interpreter)."""
|
||||
try:
|
||||
for child in psutil.Process(proc.pid).children(recursive=True):
|
||||
try:
|
||||
child.kill()
|
||||
except psutil.NoSuchProcess:
|
||||
logger.debug("kill_tree: pid %d already exited", child.pid)
|
||||
except psutil.Error:
|
||||
logger.warning("kill_tree: descendants of pid %d not enumerable", proc.pid, exc_info=True)
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
def _run_child(cmd: list[str], stdout_path: Path, stderr_path: Path) -> tuple[int, RunResult]:
|
||||
"""Spawn one query subprocess and sample its process TREE until exit.
|
||||
|
||||
The venv python.exe on Windows is a launcher stub: the real interpreter
|
||||
runs as its child, so sampling only the spawned PID measures the stub
|
||||
(~4 MB flat, ~0 CPU). Every sample therefore walks the descendant tree
|
||||
and keeps per-PID last-known CPU / read bytes and the OS peak working
|
||||
set; totals are sums over all PIDs ever seen. The flat stub adds ~4 MB
|
||||
to the peak-RSS sum - negligible against real query footprints.
|
||||
"""
|
||||
cores = psutil.cpu_count(logical=True)
|
||||
seen: dict[int, list] = {} # pid -> [cpu_s, read_bytes, peak_rss_bytes]
|
||||
timed_out = False
|
||||
start = time.perf_counter()
|
||||
with open(stdout_path, "wb") as out_fh, open(stderr_path, "wb") as err_fh:
|
||||
proc = subprocess.Popen(cmd, stdout=out_fh, stderr=err_fh, cwd=REPO_ROOT)
|
||||
try:
|
||||
try:
|
||||
root = psutil.Process(proc.pid)
|
||||
except psutil.Error:
|
||||
root = None # child already gone: exited faster than one sample
|
||||
while root is not None and proc.poll() is None:
|
||||
try:
|
||||
procs = [root, *root.children(recursive=True)]
|
||||
except psutil.Error:
|
||||
break
|
||||
for p in procs:
|
||||
try:
|
||||
mem = p.memory_info()
|
||||
cpu = p.cpu_times()
|
||||
io = p.io_counters()
|
||||
except psutil.Error:
|
||||
continue # that process exited between listing and sampling
|
||||
entry = seen.setdefault(p.pid, [0.0, 0, 0])
|
||||
entry[0] = cpu.user + cpu.system
|
||||
entry[1] = io.read_bytes
|
||||
entry[2] = max(entry[2], getattr(mem, "peak_wset", 0) or mem.rss)
|
||||
if time.perf_counter() - start > CELL_TIMEOUT_S:
|
||||
timed_out = True
|
||||
break
|
||||
time.sleep(SAMPLE_INTERVAL_S)
|
||||
if timed_out:
|
||||
_kill_tree(proc)
|
||||
raise CellTimeoutError(f"exceeded {CELL_TIMEOUT_S} s")
|
||||
returncode = proc.wait()
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
_kill_tree(proc)
|
||||
wall = time.perf_counter() - start
|
||||
cpu_s = sum(entry[0] for entry in seen.values())
|
||||
result = RunResult(
|
||||
status="", wall_s=wall,
|
||||
peak_rss_mb=sum(entry[2] for entry in seen.values()) / 1048576,
|
||||
cpu_s=cpu_s,
|
||||
cpu_util_pct=100.0 * cpu_s / wall / cores if wall > 0 else 0.0,
|
||||
read_mb=sum(entry[1] for entry in seen.values()) / 1048576,
|
||||
)
|
||||
return returncode, result
|
||||
|
||||
|
||||
def _run_cell_once(fmt: str, qn: int, cmd: list[str], tmp_dir: Path, expected: list[tuple]) -> RunResult:
|
||||
"""One benchmark run with pattern C isolation (code-error-handling)."""
|
||||
stdout_path = tmp_dir / "stdout.txt"
|
||||
stderr_path = tmp_dir / "stderr.txt"
|
||||
try:
|
||||
returncode, result = _run_child(cmd, stdout_path, stderr_path)
|
||||
except CellTimeoutError:
|
||||
logger.error("cell %s/q%d timed out after %d s", fmt, qn, CELL_TIMEOUT_S)
|
||||
return RunResult(status="TIMEOUT", wall_s=CELL_TIMEOUT_S)
|
||||
except Exception:
|
||||
logger.error("cell %s/q%d failed to execute", fmt, qn, exc_info=True)
|
||||
return RunResult(status="ERROR")
|
||||
if returncode != 0:
|
||||
tail = stderr_path.read_text(encoding="utf-8", errors="replace")[-2000:]
|
||||
logger.error("cell %s/q%d exited %d; stderr tail: %s", fmt, qn, returncode, tail)
|
||||
result.status = "ERROR"
|
||||
return result
|
||||
actual = parse_rows(stdout_path.read_text(encoding="ascii"))
|
||||
diff = compare(expected, actual)
|
||||
if diff:
|
||||
logger.error("cell %s/q%d checksum mismatch: %s", fmt, qn, diff)
|
||||
result.status = "OK"
|
||||
result.rows = len(actual)
|
||||
result.checksum_ok = diff is None
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class CellOutcome:
|
||||
fmt: str
|
||||
qn: int
|
||||
measured: list[RunResult] = field(default_factory=list)
|
||||
timed_out: bool = False # any run incl. warm-up: remaining runs skipped
|
||||
window: tuple[float, float] | None = None
|
||||
|
||||
def status(self) -> str:
|
||||
if self.timed_out or any(r.status == "TIMEOUT" for r in self.measured):
|
||||
return "TIMEOUT"
|
||||
if len(self.measured) < MEASURED_RUNS or any(r.status == "ERROR" for r in self.measured):
|
||||
return "ERROR"
|
||||
if not all(r.checksum_ok for r in self.measured):
|
||||
return "INVALID"
|
||||
return "OK"
|
||||
|
||||
|
||||
def _median_row(outcome: CellOutcome) -> list:
|
||||
status = outcome.status()
|
||||
fmt, qn, measured = outcome.fmt, outcome.qn, outcome.measured
|
||||
if status != "OK":
|
||||
ok = "false" if status == "INVALID" else ""
|
||||
return [fmt, f"q{qn}", "", "", "", "", "", "", "", "", ok, status]
|
||||
walls = [r.wall_s for r in measured]
|
||||
return [
|
||||
fmt, f"q{qn}",
|
||||
round(statistics.median(walls), 4), round(min(walls), 4), round(max(walls), 4),
|
||||
round(statistics.median(r.peak_rss_mb for r in measured), 1),
|
||||
round(statistics.median(r.cpu_s for r in measured), 3),
|
||||
round(statistics.median(r.cpu_util_pct for r in measured), 1),
|
||||
round(statistics.median(r.read_mb for r in measured), 2),
|
||||
measured[0].rows, "true", status,
|
||||
]
|
||||
|
||||
|
||||
def _capture_explain(dsn: str, queries_dir: Path, explain_dir: Path) -> None:
|
||||
"""EXPLAIN (ANALYZE, BUFFERS) per PG query; verify Q5 avoids track_summary."""
|
||||
explain_dir.mkdir(parents=True, exist_ok=True)
|
||||
with psycopg.connect(dsn) as conn:
|
||||
for qn in QUERY_NUMBERS:
|
||||
sql = (queries_dir / "pg" / f"q{qn}.sql").read_text(encoding="ascii")
|
||||
plan = "\n".join(r[0] for r in conn.execute("EXPLAIN (ANALYZE, BUFFERS) " + sql).fetchall())
|
||||
(explain_dir / f"q{qn}.txt").write_text(plan + "\n", encoding="utf-8", newline="\n")
|
||||
if qn == 5 and "track_summary" in plan:
|
||||
raise RuntimeError("q5 plan touches track_summary - the forced full scan is violated (bench-methodology section 4)")
|
||||
logger.info("EXPLAIN plans captured to %s", explain_dir)
|
||||
|
||||
|
||||
PG_STAT_QUERY = """
|
||||
SELECT query, calls, total_exec_time, rows,
|
||||
shared_blks_hit, shared_blks_read, temp_blks_read, temp_blks_written
|
||||
FROM pg_stat_statements
|
||||
"""
|
||||
PG_STAT_COLUMNS = [
|
||||
"calls", "total_exec_time_ms", "rows",
|
||||
"shared_blks_hit", "shared_blks_read", "temp_blks_read", "temp_blks_written",
|
||||
]
|
||||
_QTAG = re.compile(r"^-- Q(\d):")
|
||||
|
||||
|
||||
def _pg_statements_snapshot(dsn: str) -> dict[int, list[float]] | None:
|
||||
"""Sum pg_stat_statements counters per benchmark query (matched by the
|
||||
leading '-- Q<N>:' comment). Best-effort: None when the extension is
|
||||
unavailable - server-side stats are auxiliary and never fail the task.
|
||||
"""
|
||||
try:
|
||||
sums: dict[int, list[float]] = {}
|
||||
with psycopg.connect(dsn) as conn:
|
||||
for query_text, *values in conn.execute(PG_STAT_QUERY):
|
||||
match = _QTAG.match(query_text)
|
||||
if not match:
|
||||
continue
|
||||
acc = sums.setdefault(int(match.group(1)), [0.0] * len(values))
|
||||
for i, v in enumerate(values):
|
||||
acc[i] += float(v)
|
||||
return sums
|
||||
except Exception:
|
||||
logger.warning("pg_stat_statements snapshot skipped (extension unavailable or not readable)", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _write_pg_server_stats(path: Path, start: dict[int, list[float]], end: dict[int, list[float]]) -> None:
|
||||
with open(path, "w", encoding="ascii", newline="") as fh:
|
||||
writer = csv.writer(fh)
|
||||
writer.writerow(["query"] + PG_STAT_COLUMNS)
|
||||
for qn in QUERY_NUMBERS:
|
||||
before = start.get(qn, [0.0] * len(PG_STAT_COLUMNS))
|
||||
after = end.get(qn)
|
||||
if after is None:
|
||||
logger.warning("pg_stat_statements: q%d missing in the end snapshot (entry evicted?)", qn)
|
||||
writer.writerow([f"q{qn}"] + [""] * len(PG_STAT_COLUMNS))
|
||||
continue
|
||||
delta = [after[i] - before[i] for i in range(len(PG_STAT_COLUMNS))]
|
||||
writer.writerow([f"q{qn}"] + [round(v, 3) for v in delta])
|
||||
logger.info("pg server-side statement deltas written to %s", path)
|
||||
|
||||
|
||||
def _tree_bytes(root: Path) -> int:
|
||||
return sum(p.stat().st_size for p in root.rglob("*") if p.is_file())
|
||||
|
||||
|
||||
def _consolidate_storage_sizes(out_root: Path, bench_dir: Path) -> None:
|
||||
"""Build archival variants, record all sizes, verify completeness."""
|
||||
archives = bench_dir / "archives"
|
||||
archives.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
csv_tgz = archives / "csv_tree.tar.gz"
|
||||
logger.info("archiving out/csv -> %s", csv_tgz)
|
||||
with tarfile.open(csv_tgz, "w:gz") as tar:
|
||||
tar.add(out_root / "csv", arcname="csv")
|
||||
update_storage_sizes(bench_dir, "csv", [
|
||||
("tree", _tree_bytes(out_root / "csv")), ("targz", csv_tgz.stat().st_size),
|
||||
])
|
||||
|
||||
json_tgz = archives / "json.tar.gz"
|
||||
logger.info("archiving out/json -> %s", json_tgz)
|
||||
with tarfile.open(json_tgz, "w:gz") as tar:
|
||||
tar.add(out_root / "json", arcname="json")
|
||||
update_storage_sizes(bench_dir, "json", [("targz", json_tgz.stat().st_size)])
|
||||
|
||||
db_gz = archives / "tribo.db.gz"
|
||||
logger.info("compressing out/sqlite/tribo.db -> %s", db_gz)
|
||||
with open(out_root / "sqlite" / "tribo.db", "rb") as src, gzip.open(db_gz, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst, 1048576)
|
||||
update_storage_sizes(bench_dir, "sqlite", [("db_gz", db_gz.stat().st_size)])
|
||||
|
||||
# pg_dump -Fc is internally gzip-compressed: it IS the pg archival variant
|
||||
update_storage_sizes(bench_dir, "pg", [("dump", (out_root / "pg" / "tribo.dump").stat().st_size)])
|
||||
|
||||
present = set()
|
||||
with open(bench_dir / "storage_sizes.csv", encoding="ascii") as fh:
|
||||
for row in csv.reader(fh):
|
||||
present.add((row[0], row[1]))
|
||||
missing = REQUIRED_SIZE_ROWS - present
|
||||
if missing:
|
||||
raise ValueError(f"storage_sizes.csv incomplete after consolidation: missing {sorted(missing)}")
|
||||
logger.info("storage_sizes.csv consolidated: all %d required rows present", len(REQUIRED_SIZE_ROWS))
|
||||
|
||||
|
||||
def _environment_record(path: Path, dsn: str) -> None:
|
||||
import platform
|
||||
|
||||
lines = [
|
||||
f"benchmark_host_os: {platform.platform()}",
|
||||
f"benchmark_host_cpu: {platform.processor()}",
|
||||
f"benchmark_host_cores_logical: {psutil.cpu_count(logical=True)}",
|
||||
f"benchmark_host_cores_physical: {psutil.cpu_count(logical=False)}",
|
||||
f"benchmark_host_ram_gib: {round(psutil.virtual_memory().total / 1073741824, 1)}",
|
||||
f"python: {platform.python_version()}",
|
||||
]
|
||||
try:
|
||||
disks = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command",
|
||||
"Get-PhysicalDisk | ForEach-Object { $_.FriendlyName + ' / ' + $_.MediaType + ' / ' + [math]::Round($_.Size / 1GB) + ' GB' }"],
|
||||
capture_output=True, text=True, timeout=60, check=True)
|
||||
lines.append("benchmark_host_disks: " + "; ".join(ln.strip() for ln in disks.stdout.splitlines() if ln.strip()))
|
||||
except Exception:
|
||||
logger.warning("disk info skipped (Get-PhysicalDisk query failed - non-Windows host or no permission)", exc_info=True)
|
||||
import sqlite3
|
||||
|
||||
import ijson
|
||||
import pyoxigraph
|
||||
|
||||
lines += [
|
||||
f"sqlite: {sqlite3.sqlite_version}",
|
||||
f"psycopg: {psycopg.__version__}",
|
||||
f"pyoxigraph: {pyoxigraph.__version__}",
|
||||
f"ijson: {ijson.__version__}",
|
||||
f"psutil: {psutil.__version__}",
|
||||
]
|
||||
with psycopg.connect(dsn) as conn:
|
||||
pg_version = conn.execute("SELECT version()").fetchone()[0]
|
||||
lines.append(f"postgresql_server: {pg_version}")
|
||||
lines.append(f"postgresql_host: {conn.info.host} (remote; 4 cores / 3.8 GiB, see .done/06.ok)")
|
||||
lines += [
|
||||
"cold_cache_pass: skipped (Windows host, no page-cache drop - bench-methodology section 6)",
|
||||
"pg_backend_sampling: not possible for a remote server; Prometheus windows + pg_stat_statements deltas instead",
|
||||
]
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
||||
logger.info("environment record written to %s", path)
|
||||
|
||||
|
||||
def _run_matrix(bench_dir: Path, expected: dict[int, list[tuple]], seed: int) -> list[CellOutcome]:
|
||||
cells = [(fmt, qn) for fmt in FORMATS for qn in QUERY_NUMBERS]
|
||||
random.Random(f"{seed}-bench-order").shuffle(cells)
|
||||
tmp_dir = bench_dir / "tmp"
|
||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
outcomes: list[CellOutcome] = []
|
||||
with open(bench_dir / "results_raw.csv", "w", encoding="ascii", newline="") as raw_fh:
|
||||
writer = csv.writer(raw_fh)
|
||||
writer.writerow(RAW_HEADER)
|
||||
for order, (fmt, qn) in enumerate(cells, start=1):
|
||||
cmd = [sys.executable, str(bench_dir / "queries" / fmt / f"q{qn}.py")]
|
||||
outcome = CellOutcome(fmt, qn)
|
||||
window_start = time.time()
|
||||
for run in range(MEASURED_RUNS + 1): # run 0 = warm-up
|
||||
result = _run_cell_once(fmt, qn, cmd, tmp_dir, expected[qn])
|
||||
writer.writerow(result.raw_row(fmt, qn, run, order))
|
||||
raw_fh.flush()
|
||||
if run > 0:
|
||||
outcome.measured.append(result)
|
||||
logger.info("cell %s/q%d run %d: %s wall=%.3fs rss=%.1fMB", fmt, qn, run, result.status, result.wall_s, result.peak_rss_mb)
|
||||
if result.status == "TIMEOUT":
|
||||
outcome.timed_out = True
|
||||
logger.error("cell %s/q%d: skipping remaining runs after timeout", fmt, qn)
|
||||
break
|
||||
outcome.window = (window_start, time.time())
|
||||
outcomes.append(outcome)
|
||||
status = outcome.status()
|
||||
walls = [r.wall_s for r in outcome.measured if r.status == "OK"]
|
||||
detail = f"median {statistics.median(walls):.3f} s" if status == "OK" else status
|
||||
print(f"[{order}/{len(cells)}] {fmt}/q{qn}: {detail}")
|
||||
shutil.rmtree(tmp_dir)
|
||||
return outcomes
|
||||
|
||||
|
||||
def _write_pg_host_metrics(path: Path, prom_url: str, outcomes: list[CellOutcome]) -> None:
|
||||
with open(path, "w", encoding="ascii", newline="") as fh:
|
||||
writer = csv.writer(fh)
|
||||
writer.writerow([
|
||||
"query", "window_s", "host_cpu_avg_pct", "host_cpu_max_pct",
|
||||
"host_ram_used_baseline_mb", "host_ram_used_peak_mb", "host_ram_used_delta_mb",
|
||||
])
|
||||
for outcome in sorted((o for o in outcomes if o.fmt == "pg"), key=lambda o: o.qn):
|
||||
start, end = outcome.window
|
||||
metrics = host_window_metrics(prom_url, start, end)
|
||||
row = [f"q{outcome.qn}", round(end - start, 1)]
|
||||
if metrics is None:
|
||||
row += [""] * 5
|
||||
else:
|
||||
row += [
|
||||
metrics["host_cpu_avg_pct"], metrics["host_cpu_max_pct"],
|
||||
metrics["host_ram_used_baseline_mb"], metrics["host_ram_used_peak_mb"],
|
||||
metrics["host_ram_used_delta_mb"],
|
||||
]
|
||||
writer.writerow(row)
|
||||
logger.info("per-cell PG host metrics written to %s", path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_task_args("Task 09: execute the Q1-Q7 x 5 formats benchmark matrix")
|
||||
out_root: Path = args.out_root
|
||||
bench_dir = out_root / "bench"
|
||||
try:
|
||||
check_dependencies(out_root, DEPENDS_ON)
|
||||
remove_stale_marker(out_root, TASK_ID)
|
||||
dump_path = out_root / "pg" / "tribo.dump"
|
||||
if not dump_path.exists():
|
||||
raise FileNotFoundError(f"{dump_path} missing - produce it with pg_dump -Fc before task 09 (storage consolidation needs it)")
|
||||
dsn = get_dsn()
|
||||
expected = {qn: parse_rows((bench_dir / "expected" / f"q{qn}.rows.csv").read_text(encoding="ascii")) for qn in QUERY_NUMBERS}
|
||||
seed = load_lab_config(out_root)["seed"]
|
||||
logger.warning("cold-cache pass skipped (Windows host - bench-methodology section 6)")
|
||||
_environment_record(bench_dir / "environment.txt", dsn)
|
||||
|
||||
prom_url = get_prom_url()
|
||||
stats_start = _pg_statements_snapshot(dsn)
|
||||
session_start = time.time()
|
||||
outcomes = _run_matrix(bench_dir, expected, seed)
|
||||
session_end = time.time()
|
||||
|
||||
with open(bench_dir / "results_median.csv", "w", encoding="ascii", newline="") as fh:
|
||||
writer = csv.writer(fh)
|
||||
writer.writerow(MEDIAN_HEADER)
|
||||
for outcome in sorted(outcomes, key=lambda o: (o.fmt, o.qn)):
|
||||
writer.writerow(_median_row(outcome))
|
||||
|
||||
# snapshot BEFORE the EXPLAIN pass so its executions cannot pollute the deltas
|
||||
if stats_start is not None:
|
||||
stats_end = _pg_statements_snapshot(dsn)
|
||||
if stats_end is not None:
|
||||
_write_pg_server_stats(bench_dir / "pg_server_stats.csv", stats_start, stats_end)
|
||||
_capture_explain(dsn, bench_dir / "queries", bench_dir / "explain")
|
||||
session_host = None
|
||||
if prom_url:
|
||||
_write_pg_host_metrics(bench_dir / "pg_host_metrics.csv", prom_url, outcomes)
|
||||
session_host = host_window_metrics(prom_url, session_start, session_end)
|
||||
else:
|
||||
logger.warning("TRIBO_PROM_URL unset - PG host metrics skipped")
|
||||
|
||||
_consolidate_storage_sizes(out_root, bench_dir)
|
||||
|
||||
by_status: dict[str, int] = {}
|
||||
for outcome in outcomes:
|
||||
by_status[outcome.status()] = by_status.get(outcome.status(), 0) + 1
|
||||
entries = {
|
||||
"cells_total": len(outcomes),
|
||||
"cells_ok": by_status.get("OK", 0),
|
||||
"cells_invalid": by_status.get("INVALID", 0),
|
||||
"cells_timeout": by_status.get("TIMEOUT", 0),
|
||||
"cells_error": by_status.get("ERROR", 0),
|
||||
"session_wall_s": round(session_end - session_start, 1),
|
||||
"cold_cache_pass": "skipped (Windows host)",
|
||||
}
|
||||
if session_host:
|
||||
entries.update({f"session_{k}": v for k, v in session_host.items()})
|
||||
marker_path = write_marker(out_root, TASK_ID, entries)
|
||||
except Exception:
|
||||
logger.critical("task %s failed", TASK_ID, exc_info=True)
|
||||
return 1
|
||||
|
||||
print(f"task 09 ok: {entries['cells_ok']}/{entries['cells_total']} cells OK "
|
||||
f"(invalid={entries['cells_invalid']}, timeout={entries['cells_timeout']}, error={entries['cells_error']}), "
|
||||
f"session {entries['session_wall_s']} s")
|
||||
print(f"results: {bench_dir / 'results_raw.csv'}, {bench_dir / 'results_median.csv'}")
|
||||
print(f"marker: {marker_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user