mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
305 lines
9.9 KiB
Python
Executable File
305 lines
9.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import math
|
|
import statistics
|
|
import sys
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, Iterable, List, Optional, Tuple
|
|
|
|
try:
|
|
from scipy import stats
|
|
except ImportError:
|
|
stats = None
|
|
|
|
|
|
def tsv_escape(value: object) -> str:
|
|
return (
|
|
str(value)
|
|
.replace("\\", "\\\\")
|
|
.replace("\t", "\\t")
|
|
.replace("\n", "\\n")
|
|
.replace("\r", "")
|
|
)
|
|
|
|
|
|
def tsv_unescape(value: str) -> str:
|
|
result = []
|
|
escaped = False
|
|
for ch in value:
|
|
if escaped:
|
|
if ch == "t":
|
|
result.append("\t")
|
|
elif ch == "n":
|
|
result.append("\n")
|
|
elif ch == "\\":
|
|
result.append("\\")
|
|
else:
|
|
result.append(ch)
|
|
escaped = False
|
|
elif ch == "\\":
|
|
escaped = True
|
|
else:
|
|
result.append(ch)
|
|
if escaped:
|
|
result.append("\\")
|
|
return "".join(result)
|
|
|
|
|
|
@dataclass
|
|
class PerfTsv:
|
|
display_names: Dict[int, str] = field(default_factory=dict)
|
|
samples_by_query: Dict[int, List[float]] = field(default_factory=lambda: defaultdict(list))
|
|
|
|
|
|
def parse_perf_tsv(path: str, conn_index: int) -> PerfTsv:
|
|
parsed = PerfTsv()
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
for line_number, raw_line in enumerate(handle, 1):
|
|
line = raw_line.rstrip("\n")
|
|
if not line:
|
|
continue
|
|
|
|
fields = line.split("\t")
|
|
tag = fields[0]
|
|
|
|
if tag == "display-name":
|
|
if len(fields) < 3:
|
|
warn(path, line_number, "ignoring malformed display-name row")
|
|
continue
|
|
query_index = parse_int(fields[1], path, line_number, "query index")
|
|
if query_index is None:
|
|
continue
|
|
parsed.display_names[query_index] = tsv_unescape(fields[2])
|
|
continue
|
|
|
|
if tag == "query":
|
|
if len(fields) < 5:
|
|
warn(path, line_number, "ignoring malformed query row")
|
|
continue
|
|
query_index = parse_int(fields[1], path, line_number, "query index")
|
|
row_conn_index = parse_int(fields[3], path, line_number, "connection index")
|
|
elapsed = parse_float(fields[4], path, line_number, "elapsed seconds")
|
|
if query_index is None or row_conn_index is None or elapsed is None:
|
|
continue
|
|
if row_conn_index == conn_index:
|
|
parsed.samples_by_query[query_index].append(elapsed)
|
|
|
|
return parsed
|
|
|
|
|
|
def warn(path: str, line_number: int, message: str) -> None:
|
|
print(f"{path}:{line_number}: {message}", file=sys.stderr)
|
|
|
|
|
|
def parse_int(value: str, path: str, line_number: int, label: str) -> Optional[int]:
|
|
try:
|
|
return int(value)
|
|
except ValueError:
|
|
warn(path, line_number, f"ignoring row with invalid {label}: {value!r}")
|
|
return None
|
|
|
|
|
|
def parse_float(value: str, path: str, line_number: int, label: str) -> Optional[float]:
|
|
try:
|
|
return float(value)
|
|
except ValueError:
|
|
warn(path, line_number, f"ignoring row with invalid {label}: {value!r}")
|
|
return None
|
|
|
|
|
|
def median(samples: List[float]) -> Optional[float]:
|
|
if not samples:
|
|
return None
|
|
return statistics.median(samples)
|
|
|
|
|
|
def welch_ttest_pvalue(left: List[float], right: List[float], min_samples: int) -> Optional[float]:
|
|
if stats is None or len(left) < min_samples or len(right) < min_samples:
|
|
return None
|
|
p_value = stats.ttest_ind(left, right, equal_var=False).pvalue
|
|
if p_value is None or math.isnan(p_value):
|
|
return None
|
|
return float(p_value)
|
|
|
|
|
|
def status_for(
|
|
relative_change: Optional[float],
|
|
p_value: Optional[float],
|
|
min_relative_change: float,
|
|
alpha: float,
|
|
) -> str:
|
|
if relative_change is None:
|
|
return "missing"
|
|
if abs(relative_change) < min_relative_change:
|
|
return "same"
|
|
if p_value is None or p_value > alpha:
|
|
return "unstable"
|
|
return "faster" if relative_change < 0 else "slower"
|
|
|
|
|
|
def fmt_float(value: Optional[float]) -> str:
|
|
if value is None:
|
|
return ""
|
|
return f"{value:.12g}"
|
|
|
|
|
|
def iter_index_matches(baseline: PerfTsv, contender: PerfTsv) -> Iterable[Tuple[str, int, Optional[int]]]:
|
|
indexes = set(baseline.samples_by_query) | set(contender.samples_by_query)
|
|
indexes |= set(baseline.display_names) | set(contender.display_names)
|
|
for query_index in sorted(indexes):
|
|
yield str(query_index), query_index, query_index
|
|
|
|
|
|
def display_name_index(parsed: PerfTsv) -> Dict[str, List[int]]:
|
|
by_name: Dict[str, List[int]] = defaultdict(list)
|
|
for query_index, display_name in parsed.display_names.items():
|
|
by_name[display_name].append(query_index)
|
|
return by_name
|
|
|
|
|
|
def iter_display_name_matches(
|
|
baseline: PerfTsv, contender: PerfTsv
|
|
) -> Iterable[Tuple[str, Optional[int], Optional[int]]]:
|
|
baseline_by_name = display_name_index(baseline)
|
|
contender_by_name = display_name_index(contender)
|
|
names = set(baseline_by_name) | set(contender_by_name)
|
|
for display_name in sorted(names):
|
|
baseline_indexes = baseline_by_name.get(display_name, [])
|
|
contender_indexes = contender_by_name.get(display_name, [])
|
|
baseline_index = baseline_indexes[0] if len(baseline_indexes) == 1 else None
|
|
contender_index = contender_indexes[0] if len(contender_indexes) == 1 else None
|
|
if baseline_index is None and contender_index is None:
|
|
query_index = ""
|
|
elif baseline_index is None:
|
|
query_index = f"/{contender_index}"
|
|
elif contender_index is None:
|
|
query_index = f"{baseline_index}/"
|
|
elif baseline_index == contender_index:
|
|
query_index = str(baseline_index)
|
|
else:
|
|
query_index = f"{baseline_index}/{contender_index}"
|
|
yield query_index, baseline_index, contender_index
|
|
|
|
|
|
def compare(args: argparse.Namespace) -> None:
|
|
baseline = parse_perf_tsv(args.baseline, args.baseline_conn)
|
|
contender = parse_perf_tsv(args.contender, args.contender_conn)
|
|
|
|
print(
|
|
"\t".join(
|
|
[
|
|
"query_index",
|
|
"display_name",
|
|
"baseline_runs",
|
|
"contender_runs",
|
|
"baseline_median",
|
|
"contender_median",
|
|
"relative_change",
|
|
"p_value",
|
|
"status",
|
|
]
|
|
)
|
|
)
|
|
|
|
if args.match_by == "display-name":
|
|
matches = iter_display_name_matches(baseline, contender)
|
|
else:
|
|
matches = iter_index_matches(baseline, contender)
|
|
|
|
for query_index, baseline_index, contender_index in matches:
|
|
baseline_samples = (
|
|
baseline.samples_by_query.get(baseline_index, []) if baseline_index is not None else []
|
|
)
|
|
contender_samples = (
|
|
contender.samples_by_query.get(contender_index, []) if contender_index is not None else []
|
|
)
|
|
baseline_median = median(baseline_samples)
|
|
contender_median = median(contender_samples)
|
|
|
|
if baseline_median is None or contender_median is None or baseline_median == 0:
|
|
relative_change = None
|
|
p_value = None
|
|
else:
|
|
relative_change = (contender_median - baseline_median) / baseline_median
|
|
p_value = welch_ttest_pvalue(
|
|
baseline_samples, contender_samples, args.min_samples_for_ttest
|
|
)
|
|
|
|
status = status_for(relative_change, p_value, args.min_relative_change, args.alpha)
|
|
display_name = ""
|
|
if baseline_index is not None:
|
|
display_name = baseline.display_names.get(baseline_index, "")
|
|
if not display_name and contender_index is not None:
|
|
display_name = contender.display_names.get(contender_index, "")
|
|
|
|
print(
|
|
"\t".join(
|
|
[
|
|
tsv_escape(query_index),
|
|
tsv_escape(display_name),
|
|
str(len(baseline_samples)),
|
|
str(len(contender_samples)),
|
|
fmt_float(baseline_median),
|
|
fmt_float(contender_median),
|
|
fmt_float(relative_change),
|
|
fmt_float(p_value),
|
|
status,
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description="Compare two saved ClickHouse tests/performance/scripts/perf.py raw TSV outputs."
|
|
)
|
|
parser.add_argument("--baseline", required=True, help="Baseline perf.py raw TSV file.")
|
|
parser.add_argument("--contender", required=True, help="Contender perf.py raw TSV file.")
|
|
parser.add_argument(
|
|
"--baseline-conn",
|
|
type=int,
|
|
default=0,
|
|
help="Connection index to read from the baseline TSV. Default: 0.",
|
|
)
|
|
parser.add_argument(
|
|
"--contender-conn",
|
|
type=int,
|
|
default=0,
|
|
help="Connection index to read from the contender TSV. Default: 0.",
|
|
)
|
|
parser.add_argument(
|
|
"--match-by",
|
|
choices=("index", "display-name"),
|
|
default="index",
|
|
help="Match query rows by XML-expanded query index or display name. Default: index.",
|
|
)
|
|
parser.add_argument(
|
|
"--min-relative-change",
|
|
type=float,
|
|
default=0.03,
|
|
help="Classify smaller median changes as same. Default: 0.03.",
|
|
)
|
|
parser.add_argument(
|
|
"--alpha",
|
|
type=float,
|
|
default=0.05,
|
|
help="p-value threshold for faster/slower status when enough samples exist. Default: 0.05.",
|
|
)
|
|
parser.add_argument(
|
|
"--min-samples-for-ttest",
|
|
type=int,
|
|
default=3,
|
|
help="Minimum samples per side before emitting a Welch t-test p-value. Default: 3.",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main() -> None:
|
|
compare(build_parser().parse_args())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|