Files
zlareb1 47085f0f50 Address three reviewer comments on 7f10f8a6fda
Three independent silent-failure / lossy-output modes flagged in
review while the prior round was being pushed:

1. **`compute_deltas.py:291`** — `kind` collapsed to `"fault"`
   when *any* scenario in a nightly was a fault sweep, conflating
   single-kind nightlies with mixed nightlies. Today's schedule
   runs single-kind sweeps per commit (verified: 18 fault + 21
   no-fault, 0 mixed across the 33-PR window), but the field
   pretended to encode the kind union. Now emits one of
   `"fault"` / `"no-fault"` / `"mixed"` based on the actual set,
   so downstream consumers (e.g. kind-matched baseline picking
   in `build_per_pr_metrics_tsv`) won't be silently misled if
   the schedule ever changes.

2. **`build_cumulative_gains.py:148`** — early `return` when no
   `(scenario, backend)` had `>= 2*WINDOW_SIZE` runs left
   `cumulative_gains_summary.tsv` missing entirely. Inconsistent
   with the header-only-on-empty pattern used for
   `pr_to_nightly.tsv`, `per_pr_scenario_deltas.tsv`, and
   `per_nightly_summary.tsv`. Now writes a header-only file with
   the canonical column list whenever the result is empty;
   downstream consumers see one stable schema regardless of
   window size. Verified against `KEEPER_SKILL_THRESHOLD=2030-01-01`:
   file is written with one header line, no crash.

3. **`build_pr_branch_isolated.py:126`** — when a PR had a head
   value but no usable comparison pool (empty same-week pool, or
   widening produced nothing usable), the script `continue`d and
   silently dropped that `(PR, scenario)` from
   `pr_branch_isolated.tsv`. A PR could look cleaner than it was
   simply because the hard cases vanished. Now emits an explicit
   "no-baseline" row (head value present; `pool_med_*` /
   `iso_d_*` empty; `n_pool=0`) so summaries can treat the case
   as `no-baseline` rather than absent data.

Smoke-test parity preserved: `cumulative_gains.tsv` md5 stays at
`e5a27e01008ef7e23597ee041f68103b`; the canonical `#99651` row
is bit-identical (`5764 / 170169 / 7582` rps; `544.8 / 10.7 /
904.6` ms p99); 18 unit tests pass.

Addresses review comments
https://github.com/ClickHouse/ClickHouse/pull/104118#discussion_r3194091382 ,
https://github.com/ClickHouse/ClickHouse/pull/104118#discussion_r3194091392 ,
https://github.com/ClickHouse/ClickHouse/pull/104118#discussion_r3194139512 .
2026-05-06 09:05:16 +00:00

169 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""Compute baseline-window vs current-window medians for every metric on every
scenario+backend, then emit per-scenario gain tables.
Outputs:
cumulative_gains.tsv — one row per (scenario, backend, metric)
cumulative_gains_summary.tsv — pivoted view per scenario+backend with key metrics
"""
import csv
import datetime
import statistics
import sys
from collections import defaultdict
from pathlib import Path
from _common import to_float
ROOT = Path(__file__).parent
# Headline metrics & their "good direction"
METRICS = {
"rps": "↑", # higher better
"read_rps": "↑",
"write_rps": "↑",
"read_p50_ms": "↓",
"read_p95_ms": "↓",
"read_p99_ms": "↓",
"write_p50_ms": "↓",
"write_p95_ms": "↓",
"write_p99_ms": "↓",
"error_pct": "↓",
"peak_mem_gb": "↓",
"avg_mem_gb": "↓",
"p95_cpu_cores": "↓",
"zk_max_latency_max": "↓",
"zk_avg_latency_avg": "↓",
"FileSync_us_per_s_avg": "↓",
"ChangelogFsync_us_per_s_avg": "↓",
"StorageLockWait_us_per_s_avg": "↓",
"TotalElapsed_us_per_s_avg": "↓",
"KeeperLatency_us_per_s_avg": "↓",
"OutstandingRequests_max": "↓",
}
WINDOW_SIZE = 3 # take median of N earliest / N latest nightlies per scenario+backend
def main():
# Load merged metrics
by_sb = defaultdict(list)
with open(ROOT / "merged_metrics.tsv") as f:
for r in csv.DictReader(f, delimiter="\t"):
ts = r["run_ended"]
by_sb[(r["scenario"], r["backend"])].append((ts, r))
# For each (scenario, backend), sort by run_ended and take medians of edges
out_rows = [] # one per (scenario, backend, metric)
summary_rows = [] # one per (scenario, backend)
skipped = []
for (sc, be), runs in by_sb.items():
runs.sort(key=lambda x: x[0])
if len(runs) < 2 * WINDOW_SIZE:
skipped.append((sc, be, len(runs)))
continue
baseline_runs = runs[:WINDOW_SIZE]
current_runs = runs[-WINDOW_SIZE:]
baseline_dates = [r[0].split(" ")[0] for r in baseline_runs]
current_dates = [r[0].split(" ")[0] for r in current_runs]
baseline_shas = [r[1]["commit_sha"][:8] for r in baseline_runs]
current_shas = [r[1]["commit_sha"][:8] for r in current_runs]
per_metric = {}
for m, direction in METRICS.items():
base_vals = [v for v in (to_float(r[1].get(m)) for r in baseline_runs) if v is not None]
curr_vals = [v for v in (to_float(r[1].get(m)) for r in current_runs) if v is not None]
if not base_vals or not curr_vals:
continue
base_med = statistics.median(base_vals)
curr_med = statistics.median(curr_vals)
base_min, base_max = min(base_vals), max(base_vals)
curr_min, curr_max = min(curr_vals), max(curr_vals)
delta_abs = curr_med - base_med
if base_med == 0:
delta_pct = float("inf") if curr_med != 0 else 0.0
else:
delta_pct = (curr_med - base_med) / abs(base_med) * 100.0
improved = ((direction == "↑" and delta_pct > 0) or
(direction == "↓" and delta_pct < 0))
per_metric[m] = {
"base_med": base_med, "curr_med": curr_med,
"base_min": base_min, "base_max": base_max,
"curr_min": curr_min, "curr_max": curr_max,
"delta_abs": delta_abs, "delta_pct": delta_pct,
"improved": improved, "direction": direction,
}
out_rows.append({
"scenario": sc, "backend": be, "metric": m,
"direction": direction,
"baseline_window": ",".join(baseline_shas),
"baseline_dates": ",".join(baseline_dates),
"current_window": ",".join(current_shas),
"current_dates": ",".join(current_dates),
"base_median": f"{base_med:.4f}",
"curr_median": f"{curr_med:.4f}",
"base_min": f"{base_min:.4f}", "base_max": f"{base_max:.4f}",
"curr_min": f"{curr_min:.4f}", "curr_max": f"{curr_max:.4f}",
"delta_abs": f"{delta_abs:.4f}",
"delta_pct": f"{delta_pct:.2f}",
"improved": "yes" if improved else "no",
})
# Summary row: just the headline metrics
summary_rows.append({
"scenario": sc, "backend": be,
"n_runs": len(runs),
"baseline_window": " | ".join(f"{s} {d}" for s, d in zip(baseline_shas, baseline_dates)),
"current_window": " | ".join(f"{s} {d}" for s, d in zip(current_shas, current_dates)),
"rps_pct": f"{per_metric.get('rps', {}).get('delta_pct', float('nan')):+.2f}" if 'rps' in per_metric else "",
"read_p99_pct": f"{per_metric.get('read_p99_ms', {}).get('delta_pct', float('nan')):+.2f}" if 'read_p99_ms' in per_metric else "",
"write_p99_pct": f"{per_metric.get('write_p99_ms', {}).get('delta_pct', float('nan')):+.2f}" if 'write_p99_ms' in per_metric else "",
"error_pct_diff": f"{per_metric.get('error_pct', {}).get('delta_abs', float('nan')):+.4f}" if 'error_pct' in per_metric else "",
"peak_mem_pct": f"{per_metric.get('peak_mem_gb', {}).get('delta_pct', float('nan')):+.2f}" if 'peak_mem_gb' in per_metric else "",
"lockwait_pct": f"{per_metric.get('StorageLockWait_us_per_s_avg', {}).get('delta_pct', float('nan')):+.2f}" if 'StorageLockWait_us_per_s_avg' in per_metric else "",
})
out_path = ROOT / "cumulative_gains.tsv"
with open(out_path, "w", newline="") as f:
cols = ["scenario", "backend", "metric", "direction",
"baseline_window", "baseline_dates", "current_window", "current_dates",
"base_median", "curr_median", "base_min", "base_max", "curr_min", "curr_max",
"delta_abs", "delta_pct", "improved"]
w = csv.DictWriter(f, fieldnames=cols, delimiter="\t", lineterminator="\n")
w.writeheader()
w.writerows(out_rows)
print(f"Wrote {out_path} ({len(out_rows)} rows)", file=sys.stderr)
if skipped:
print(
f"Skipped {len(skipped)} (scenario,backend) pair(s) with "
f"< 2*WINDOW_SIZE={2*WINDOW_SIZE} runs in the window:",
file=sys.stderr,
)
for sc, be, n in sorted(skipped):
print(f" {sc} [{be}]: {n} run(s)", file=sys.stderr)
sum_path = ROOT / "cumulative_gains_summary.tsv"
summary_cols = [
"scenario", "backend", "n_runs",
"baseline_window", "current_window",
"rps_pct", "read_p99_pct", "write_p99_pct",
"error_pct_diff", "peak_mem_pct", "lockwait_pct",
]
with open(sum_path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=summary_cols, delimiter="\t", lineterminator="\n")
w.writeheader()
w.writerows(sorted(summary_rows, key=lambda r: (r["scenario"], r["backend"])))
if not summary_rows:
print(
f"Wrote {sum_path} (header-only — no (scenario,backend) had "
f">= 2*WINDOW_SIZE={2*WINDOW_SIZE} runs in the window)",
file=sys.stderr,
)
else:
print(f"Wrote {sum_path} ({len(summary_rows)} rows)", file=sys.stderr)
if __name__ == "__main__":
main()