mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-10-13 07:22:32 +00:00
536 lines
26 KiB
Python
536 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright 2023 Greptime Team
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Run the isolated default-versus-BSS scale experiment on an external runner."""
|
|
|
|
import argparse
|
|
import datetime
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import resource
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
RUNNER_PATH = ROOT / ".github/scripts/query-regression-run.py"
|
|
SOURCE_CASE = ROOT / "tests/perf/query_cases/sst_float_bss/case.toml"
|
|
SCALES = (1, 10, 50)
|
|
ORDERS = (("base", "candidate"), ("candidate", "base"), ("base", "candidate"))
|
|
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
AVERAGE = re.compile(r"Average:.*?(\d+)\s+rows.*?in\s+([0-9.]+)\s*(ns|µs|us|ms|s)", re.I)
|
|
ITERATION = re.compile(
|
|
r"(?:Iteration\s+(\d+)\s*:\s*|\[iter\s+(\d+)\]\s+)(\d+)\s+rows.*?\sin\s+([0-9.]+)\s*(ns|µs|us|ms|s)",
|
|
re.I,
|
|
)
|
|
|
|
|
|
def load_outer_runner() -> Any:
|
|
spec = importlib.util.spec_from_file_location("bss_query_regression_outer", RUNNER_PATH)
|
|
if spec is None or spec.loader is None:
|
|
raise RuntimeError(f"cannot import {RUNNER_PATH}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
outer = load_outer_runner()
|
|
|
|
|
|
def write_json(path: Path, value: Any) -> None:
|
|
path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def required_env(name: str) -> Path:
|
|
value = os.environ.get(name)
|
|
if not value:
|
|
raise RuntimeError(f"{name} is required")
|
|
path = Path(value)
|
|
if not path.exists():
|
|
raise RuntimeError(f"{name} does not exist: {path}")
|
|
return path
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as file:
|
|
for block in iter(lambda: file.read(1024 * 1024), b""):
|
|
digest.update(block)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def scale_end_date(scale: int) -> str:
|
|
return (datetime.date(2024, 1, 1) + datetime.timedelta(days=3 * scale)).isoformat()
|
|
|
|
|
|
def scaled_case(source: str, scale: int) -> str:
|
|
replacements = {
|
|
"samples_per_series = 4320": f"samples_per_series = {4320 * scale}",
|
|
"timeout_seconds = 600": "timeout_seconds = 3600",
|
|
"visibility_timeout_seconds = 300": "visibility_timeout_seconds = 600",
|
|
"'compaction.twcs.trigger_file_num'='100'": "'compaction.twcs.trigger_file_num'='10000'",
|
|
"iterations = 7": "iterations = 1", # Native finalization only seeds commands.
|
|
"2024-01-04 00:00:00": f"{scale_end_date(scale)} 00:00:00",
|
|
}
|
|
for old, new in replacements.items():
|
|
expected = 2 if "trigger_file_num" in old else 1
|
|
if source.count(old) != expected:
|
|
raise RuntimeError(f"source case replacement is not unique: {old}")
|
|
source = source.replace(old, new)
|
|
return source
|
|
|
|
|
|
def expected_rows(scale: int) -> int:
|
|
return 1000 * 4320 * scale
|
|
|
|
|
|
def row_denominator(rows: int, iterations: int) -> int:
|
|
return rows * iterations
|
|
|
|
|
|
def paired_orders() -> list[tuple[str, str]]:
|
|
return list(ORDERS)
|
|
|
|
|
|
def parse_proc_cpu_seconds(stat: str, ticks: int) -> float:
|
|
"""Parse user plus system CPU seconds from a Linux ``/proc/<pid>/stat`` record."""
|
|
fields = stat[stat.rfind(")") + 2 :].split()
|
|
return (int(fields[11]) + int(fields[12])) / ticks
|
|
|
|
|
|
def target_cpu_seconds(target: str, procs: dict[tuple[str, str], Any]) -> float:
|
|
processes = [
|
|
process for (name, component), process in procs.items()
|
|
if name == target and component in ("frontend", "datanode")
|
|
]
|
|
if len(processes) != 2 or any(process.poll() is not None for process in processes):
|
|
raise RuntimeError(f"{target} frontend and datanode must remain live during SQL measurement")
|
|
ticks = os.sysconf("SC_CLK_TCK")
|
|
return sum(
|
|
parse_proc_cpu_seconds(Path(f"/proc/{process.pid}/stat").read_text(encoding="utf-8"), ticks)
|
|
for process in processes
|
|
)
|
|
|
|
|
|
def success_code(value: Any) -> bool:
|
|
return str(value).lower() in ("", "0", "success", "none")
|
|
|
|
|
|
def response_has_error(body: Any) -> bool:
|
|
if not isinstance(body, dict):
|
|
return True
|
|
if "code" in body and not success_code(body["code"]):
|
|
return True
|
|
return any(body.get(key) not in (None, "", False, 0, "0", "success") for key in ("error", "err_msg", "error_msg"))
|
|
|
|
|
|
def row_values(row: Any) -> list[Any]:
|
|
if isinstance(row, dict):
|
|
if "time_window" in row:
|
|
sums = [value for name, value in row.items() if name != "time_window"]
|
|
return [row["time_window"], *sums]
|
|
return list(row.values())
|
|
if isinstance(row, list):
|
|
return row
|
|
raise RuntimeError(f"unexpected SQL row: {row!r}")
|
|
|
|
|
|
def numeric_row(row: Any) -> bool:
|
|
values = row_values(row)
|
|
if not values:
|
|
return False
|
|
value = values[-1]
|
|
if isinstance(value, bool) or value is None:
|
|
return False
|
|
if isinstance(value, (int, float)):
|
|
return math.isfinite(float(value))
|
|
if isinstance(value, str):
|
|
try:
|
|
return math.isfinite(float(value))
|
|
except ValueError:
|
|
return False
|
|
return False
|
|
|
|
|
|
def hourly_timestamp(value: Any) -> datetime.datetime | None:
|
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
if not math.isfinite(value):
|
|
return None
|
|
try:
|
|
return datetime.datetime(1970, 1, 1) + datetime.timedelta(milliseconds=value)
|
|
except OverflowError:
|
|
return None
|
|
if not isinstance(value, str):
|
|
return None
|
|
try:
|
|
timestamp = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
if timestamp.tzinfo is not None:
|
|
timestamp = timestamp.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
|
if timestamp.minute or timestamp.second or timestamp.microsecond:
|
|
return None
|
|
return timestamp
|
|
|
|
|
|
def response_rows(body: Any, expected: int) -> list[Any]:
|
|
"""Extract exactly one supported Greptime JSON result, never a wrapper list."""
|
|
if not isinstance(body, dict):
|
|
raise RuntimeError("SQL response must be an object")
|
|
if "output" in body:
|
|
output = body["output"]
|
|
if not isinstance(output, list) or len(output) != 1 or not isinstance(output[0], dict):
|
|
raise RuntimeError("SQL output must contain exactly one result")
|
|
records = output[0].get("records")
|
|
rows = records.get("rows") if isinstance(records, dict) else None
|
|
else:
|
|
rows = body.get("data")
|
|
if not isinstance(rows, list) or len(rows) != expected:
|
|
raise RuntimeError(f"SQL response has {len(rows) if isinstance(rows, list) else 'no'} rows; expected {expected}")
|
|
if not all(numeric_row(row) for row in rows):
|
|
raise RuntimeError("SQL result has a row without a finite, non-boolean numeric sum")
|
|
if expected > 1:
|
|
first_hour = datetime.datetime(2024, 1, 1)
|
|
for index, row in enumerate(rows):
|
|
values = row_values(row)
|
|
if len(values) != 2 or hourly_timestamp(values[0]) != first_hour + datetime.timedelta(hours=index):
|
|
raise RuntimeError("SQL hourly result must contain sequential Jan 1 hourly timestamp and sum rows")
|
|
return rows
|
|
|
|
|
|
def post_sql(port: int, sql: str, timeout: float) -> dict[str, Any]:
|
|
data = urllib.parse.urlencode({"sql": sql, "db": "public", "format": "json"}).encode()
|
|
request = urllib.request.Request(f"http://127.0.0.1:{port}/v1/sql", data=data, method="POST")
|
|
started = time.perf_counter()
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
status, raw = response.status, response.read().decode("utf-8", errors="replace")
|
|
except urllib.error.HTTPError as error:
|
|
raise RuntimeError(f"SQL HTTP {error.code}: {error.read().decode(errors='replace')[:2000]}") from error
|
|
except urllib.error.URLError as error:
|
|
raise RuntimeError(f"SQL request failed: {error}") from error
|
|
try:
|
|
body = json.loads(raw)
|
|
except json.JSONDecodeError as error:
|
|
raise RuntimeError(f"SQL response is not JSON: {raw[:2000]}") from error
|
|
if status >= 400 or response_has_error(body):
|
|
raise RuntimeError(f"SQL response error: {json.dumps(body)[:2000]}")
|
|
return {"status": status, "wall_seconds": time.perf_counter() - started, "response": body}
|
|
|
|
|
|
def same_result(left: Any, right: Any, tolerance: float = 1e-9) -> bool:
|
|
if isinstance(left, bool) or isinstance(right, bool):
|
|
return left == right
|
|
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
|
|
return math.isclose(float(left), float(right), rel_tol=tolerance, abs_tol=tolerance)
|
|
if isinstance(left, str) and isinstance(right, str):
|
|
try:
|
|
return math.isclose(float(left), float(right), rel_tol=tolerance, abs_tol=tolerance)
|
|
except ValueError:
|
|
return left == right
|
|
if type(left) is not type(right):
|
|
return False
|
|
if isinstance(left, list):
|
|
return len(left) == len(right) and all(same_result(a, b, tolerance) for a, b in zip(left, right))
|
|
if isinstance(left, dict):
|
|
return left.keys() == right.keys() and all(same_result(left[k], right[k], tolerance) for k in left)
|
|
return left == right
|
|
|
|
|
|
def sql_round(target: Any, procs: dict[tuple[str, str], Any], queries: list[tuple[str, str, int]], input_rows: int) -> dict[str, Any]:
|
|
measurements = []
|
|
for name, sql, returned_rows in queries:
|
|
for _ in range(3):
|
|
response_rows(post_sql(target.http_port, sql, 3600)["response"], returned_rows)
|
|
started, before = time.perf_counter(), target_cpu_seconds(target.name, procs)
|
|
samples = []
|
|
for _ in range(15):
|
|
sample = post_sql(target.http_port, sql, 3600)
|
|
sample["rows"] = response_rows(sample["response"], returned_rows)
|
|
samples.append(sample)
|
|
cpu_seconds = target_cpu_seconds(target.name, procs) - before
|
|
denominator = row_denominator(input_rows, len(samples))
|
|
measurements.append({
|
|
"name": name, "warmup": 3, "iterations": 15, "returned_rows_per_query": returned_rows,
|
|
"input_rows_per_query": input_rows, "samples": samples,
|
|
"block_wall_seconds": time.perf_counter() - started, "block_cpu_seconds": cpu_seconds,
|
|
"cpu_ns_per_input_row": cpu_seconds / denominator * 1e9,
|
|
})
|
|
return {"target": target.name, "measurements": measurements}
|
|
|
|
|
|
def compare_sql_pair(round_data: dict[str, Any]) -> None:
|
|
left, right = round_data["targets"]
|
|
for lhs, rhs in zip(left["measurements"], right["measurements"]):
|
|
if lhs["name"] != rhs["name"]:
|
|
raise RuntimeError("target query ordering differs")
|
|
for a, b in zip(lhs["samples"], rhs["samples"]):
|
|
if not same_result(a["rows"], b["rows"]):
|
|
raise RuntimeError(f"SQL results differ for {lhs['name']}")
|
|
round_data["result_comparison"] = "equal within numeric tolerance; not bitwise comparison"
|
|
|
|
|
|
def milliseconds(value: str, unit: str) -> float:
|
|
return float(value) * {"ns": 1e-6, "µs": 1e-3, "us": 1e-3, "ms": 1.0, "s": 1000.0}[unit.lower()]
|
|
|
|
|
|
def parse_average(stdout: str) -> tuple[int, float] | None:
|
|
match = AVERAGE.search(ANSI.sub("", stdout))
|
|
return None if match is None else (int(match[1]), milliseconds(match[2], match[3]))
|
|
|
|
|
|
def parse_iterations(stdout: str, rows: int, iterations: int) -> list[float]:
|
|
parsed: dict[int, tuple[int, float]] = {}
|
|
for match in ITERATION.finditer(ANSI.sub("", stdout)):
|
|
index = int(match[1] or match[2])
|
|
value_rows = int(match[3])
|
|
if index in parsed:
|
|
raise RuntimeError(f"duplicate reader iteration {index}")
|
|
parsed[index] = (value_rows, milliseconds(match[4], match[5]))
|
|
if set(parsed) != set(range(1, iterations + 1)):
|
|
raise RuntimeError(f"reader iterations {sorted(parsed)} do not equal 1..{iterations}")
|
|
if any(value_rows != rows for value_rows, _ in parsed.values()):
|
|
raise RuntimeError(f"reader reported partial rows: {parsed}")
|
|
return [parsed[index][1] for index in range(1, iterations + 1)]
|
|
|
|
|
|
def command_with_iterations(command: list[str], iterations: int) -> list[str]:
|
|
result = command.copy()
|
|
result[result.index("--iterations") + 1] = str(iterations)
|
|
return result
|
|
|
|
|
|
def reader_run(command: list[str], cpu: int, rows: int, iterations: int) -> dict[str, Any]:
|
|
prefixed = ["taskset", "-c", str(cpu), *command]
|
|
environment = {**os.environ, "RAYON_NUM_THREADS": "1"}
|
|
before, started = resource.getrusage(resource.RUSAGE_CHILDREN), time.perf_counter()
|
|
result = subprocess.run(prefixed, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=environment, check=False, timeout=3600)
|
|
after = resource.getrusage(resource.RUSAGE_CHILDREN)
|
|
durations = parse_iterations(result.stdout, rows, iterations) if result.returncode == 0 else []
|
|
cpu_seconds = (after.ru_utime - before.ru_utime) + (after.ru_stime - before.ru_stime)
|
|
record = {
|
|
"command": prefixed, "returncode": result.returncode, "stdout": result.stdout, "stderr": result.stderr,
|
|
"wall_seconds": time.perf_counter() - started, "cpu_seconds": cpu_seconds,
|
|
"rows_per_iteration": rows, "iterations": iterations, "iteration_ms": durations,
|
|
"average_ms_from_iterations": sum(durations) / len(durations) if durations else None,
|
|
"cpu_ns_per_input_row": cpu_seconds / row_denominator(rows, iterations) * 1e9,
|
|
"cpu_note": "RUSAGE_CHILDREN includes whole reader child startup and work, not pure decoder CPU",
|
|
}
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"reader command failed: {record}")
|
|
return record
|
|
|
|
|
|
def storage_entry(report: dict[str, Any], name: str) -> dict[str, Any]:
|
|
return next(target for target in report["targets"] if target["name"] == name)
|
|
|
|
|
|
def footer_and_templates(report: dict[str, Any], name: str, expected: int) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
|
entry = storage_entry(report, name)
|
|
inspection = entry["storage_inspection"]["summary"]
|
|
summary, files = inspection["summary"], inspection["files"]
|
|
if (summary["total_rows"] != expected or not files
|
|
or summary["file_count"] != len(files)
|
|
or sum(file["num_rows"] for file in files) != expected):
|
|
raise RuntimeError(
|
|
f"{name} footer rows/files are incomplete: rows={summary['total_rows']}, files={len(files)}"
|
|
)
|
|
for file in files:
|
|
columns = [column for column in file["columns"] if column["column_path"] == "greptime_value"]
|
|
if not columns:
|
|
raise RuntimeError(f"{name} SST lacks greptime_value footer: {file['relative_path']}")
|
|
for column in columns:
|
|
encodings = [encoding.upper() for encoding in column["encodings"]]
|
|
if name == "candidate":
|
|
if "BYTE_STREAM_SPLIT" not in encodings or "RLE_DICTIONARY" in encodings or "PLAIN_DICTIONARY" in encodings:
|
|
raise RuntimeError(f"candidate SST is not BSS/non-dictionary: {file['relative_path']}")
|
|
elif "BYTE_STREAM_SPLIT" in encodings:
|
|
raise RuntimeError(f"default SST unexpectedly has BSS: {file['relative_path']}")
|
|
templates = entry["read_bench"]["parquetbench"] + entry["read_bench"]["scanbench"]
|
|
if not templates:
|
|
raise RuntimeError(f"{name} finalization produced no reader commands")
|
|
return templates, {"file_count": len(files), "total_rows": summary["total_rows"]}
|
|
|
|
|
|
def command_rows(run: dict[str, Any], files: dict[str, int]) -> int:
|
|
paths = run.get("files") or [run["relative_path"]]
|
|
matches = [value for path, value in files.items() if any(path.endswith(str(item)) or str(item).endswith(path) for item in paths)]
|
|
if not matches:
|
|
raise RuntimeError(f"cannot map benchmark files to footer rows: {paths}")
|
|
return sum(matches)
|
|
|
|
|
|
def verify_native_template(template: dict[str, Any], rows: int) -> None:
|
|
values = parse_iterations(template["stdout"], rows, 1)
|
|
average = parse_average(template["stdout"])
|
|
if average is not None and (average[0] != rows or not math.isclose(average[1], values[0], rel_tol=1e-6)):
|
|
raise RuntimeError("native reader average does not match its iteration line")
|
|
|
|
|
|
def aggregate_runs(runs: list[dict[str, Any]]) -> dict[str, Any]:
|
|
totals: dict[str, dict[str, Any]] = {}
|
|
for run in runs:
|
|
measured = run["measured"]
|
|
total = totals.setdefault(run["kind"], {"commands": 0, "input_rows_per_iteration": 0, "whole_wall_seconds": 0.0, "cpu_seconds": 0.0, "native_average_ms_sum": 0.0})
|
|
total["commands"] += 1
|
|
total["input_rows_per_iteration"] += measured["rows_per_iteration"]
|
|
total["whole_wall_seconds"] += measured["wall_seconds"]
|
|
total["cpu_seconds"] += measured["cpu_seconds"]
|
|
total["native_average_ms_sum"] += measured["average_ms_from_iterations"]
|
|
for total in totals.values():
|
|
total["cpu_ns_per_input_row"] = total["cpu_seconds"] / row_denominator(total["input_rows_per_iteration"], 7) * 1e9
|
|
return totals
|
|
|
|
|
|
def hashes(targets: list[Any]) -> dict[str, str]:
|
|
result = {}
|
|
for target in targets:
|
|
for path in sorted(target.datanode_data_dir.rglob("*.parquet")):
|
|
result[f"{target.name}/{path.relative_to(target.datanode_data_dir)}"] = sha256(path)
|
|
return result
|
|
|
|
|
|
def context(path: Path) -> dict[str, Any]:
|
|
return {"disk_usage": shutil.disk_usage(path)._asdict(), "meminfo": Path("/proc/meminfo").read_text(encoding="utf-8")}
|
|
|
|
|
|
def runner_command(runner: Path, phase: str, case: Path, fixture: Path, targets: list[Any], output: Path | None = None) -> list[str]:
|
|
command = [str(runner), phase, "--case", str(case), "--fixture-generator", str(fixture)]
|
|
if phase in ("prepare-remote", "measure"):
|
|
command += ["--base-http-port", str(targets[0].http_port), "--candidate-http-port", str(targets[1].http_port), "--http-timeout", "3600"]
|
|
return command + ([] if output is None else ["--output", str(output)])
|
|
|
|
|
|
def run_scale(scale: int, root: Path, fixture: Path, runner: Path, base_bin: Path, candidate_bin: Path, provenance: dict[str, Any]) -> None:
|
|
work = root / f"scale-{scale}x"
|
|
if work.exists():
|
|
raise RuntimeError(f"refusing existing experiment directory: {work}")
|
|
work.mkdir(parents=True)
|
|
write_json(work / "context-before-scale.json", context(root))
|
|
case = work / "case.toml"
|
|
case.write_text(scaled_case(SOURCE_CASE.read_text(encoding="utf-8"), scale), encoding="utf-8")
|
|
outer.load_plan(fixture, case)
|
|
ports = outer.allocate_ports(16)
|
|
targets = [
|
|
outer.make_target("base", candidate_bin, work, ports[:8], work / "base/frontend-prom-store.toml"),
|
|
outer.make_target("candidate", candidate_bin, work, ports[8:], work / "candidate/frontend-prom-store.toml"),
|
|
]
|
|
for target in targets:
|
|
target.work_dir.mkdir(parents=True)
|
|
subprocess.run([str(runner), "render-remote-config", "--case", str(case), "--fixture-generator", str(fixture), "--output", str(target.frontend_config)], check=True, timeout=3600)
|
|
procs: dict[tuple[str, str], Any] = {}
|
|
report_path = work / "native-report.json"
|
|
try:
|
|
for target in targets:
|
|
for component in ("metasrv", "datanode", "frontend"):
|
|
outer.start_component(target, component, procs)
|
|
subprocess.run(runner_command(runner, "prepare-remote", case, fixture, targets, work / "prepare-remote.json"), check=True, timeout=3600)
|
|
subprocess.run(runner_command(runner, "measure", case, fixture, targets, report_path), check=False, timeout=3600)
|
|
report = json.loads(report_path.read_text(encoding="utf-8"))
|
|
if any(target.get("status") != "measured" for target in report.get("targets", [])):
|
|
raise RuntimeError("native measure reported a target error")
|
|
report["bss_scale_experiment"] = {"native_measurement": "one runner measure only; not a three-round result"}
|
|
write_json(report_path, report)
|
|
queries = [
|
|
("sum_all_values", "SELECT sum(greptime_value) FROM sst_float_bss", 1),
|
|
("hourly_sum_values", f"SELECT date_bin(INTERVAL '1 hour', greptime_timestamp) AS time_window, sum(greptime_value) FROM sst_float_bss WHERE greptime_timestamp >= TIMESTAMP '2024-01-01 00:00:00' AND greptime_timestamp < TIMESTAMP '{scale_end_date(scale)} 00:00:00' GROUP BY time_window ORDER BY time_window", 72 * scale),
|
|
]
|
|
sql_rounds: list[dict[str, Any]] = []
|
|
for number, order in enumerate(paired_orders(), 1):
|
|
pair = {"round": number, "order": list(order), "targets": [sql_round(next(t for t in targets if t.name == name), procs, queries, expected_rows(scale)) for name in order], "cpu_note": "frontend+datanode CPU includes background work; it is not pure decode CPU"}
|
|
compare_sql_pair(pair)
|
|
sql_rounds.append(pair)
|
|
write_json(work / "sql-rounds.json", sql_rounds)
|
|
print(f"scale={scale} round={number} SQL order={'/'.join(order)}", flush=True)
|
|
outer.stop_all(targets, procs)
|
|
finalized = subprocess.run([str(runner), "finalize-remote", "--case", str(case), "--fixture-generator", str(fixture), "--candidate-bin", str(candidate_bin), "--base-data-home", str(targets[0].datanode_data_dir), "--candidate-data-home", str(targets[1].datanode_data_dir), "--report", str(report_path)], check=False, timeout=3600)
|
|
report = json.loads(report_path.read_text(encoding="utf-8"))
|
|
templates, summaries = {}, {}
|
|
for name in ("base", "candidate"):
|
|
templates[name], summaries[name] = footer_and_templates(report, name, expected_rows(scale))
|
|
before = hashes(targets)
|
|
write_json(work / "hashes-before-reader.json", before)
|
|
reader_rounds: list[dict[str, Any]] = []
|
|
cpu = min(os.sched_getaffinity(0))
|
|
for number, order in enumerate(paired_orders(), 1):
|
|
pair = {"round": number, "order": list(order), "targets": []}
|
|
for name in order:
|
|
entry = storage_entry(report, name)
|
|
files = {file["relative_path"]: file["num_rows"] for file in entry["storage_inspection"]["summary"]["files"]}
|
|
runs = []
|
|
for template in templates[name]:
|
|
rows, command = command_rows(template, files), template["command"]
|
|
verify_native_template(template, rows)
|
|
runs.append({"kind": "parquetbench" if "--file-id" in command else "scanbench", "prewarm": reader_run(command_with_iterations(command, 1), cpu, rows, 1), "measured": reader_run(command_with_iterations(command, 7), cpu, rows, 7)})
|
|
parquet_rows = sum(run["measured"]["rows_per_iteration"] for run in runs if run["kind"] == "parquetbench")
|
|
scan_rows = sum(run["measured"]["rows_per_iteration"] for run in runs if run["kind"] == "scanbench")
|
|
if parquet_rows != expected_rows(scale) or scan_rows != expected_rows(scale):
|
|
raise RuntimeError(f"{name} reader coverage is incomplete")
|
|
pair["targets"].append({"target": name, "file_count": summaries[name]["file_count"], "total_row_count": summaries[name]["total_rows"], "runs": runs, "totals": aggregate_runs(runs)})
|
|
reader_rounds.append(pair)
|
|
write_json(work / "reader-rounds.json", reader_rounds)
|
|
print(f"scale={scale} round={number} reader order={'/'.join(order)}", flush=True)
|
|
after = hashes(targets)
|
|
write_json(work / "hashes-after-reader.json", after)
|
|
if before != after:
|
|
raise RuntimeError("reader mutated SST files")
|
|
write_json(work / "metrics.json", {"scale": scale, "expected_rows": expected_rows(scale), "context_after_scale": context(root), "provenance": provenance, "footer_summaries": summaries, "native_report": str(report_path), "finalize_returncode": finalized.returncode, "finalize_status": report.get("status"), "reader_hashes_equal": True, "limitations": ["SST hashes guard reader immutability only, not target equality", "SQL result comparison is numeric-tolerance based, not bitwise", "no cold-cache claim is made", "ru_maxrss is omitted because it is a global maximum, not per-child RSS"]})
|
|
finally:
|
|
outer.stop_all(targets, procs)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--work-root", type=Path, default=Path("query-regression-work/bss-scale"))
|
|
args = parser.parse_args()
|
|
args.work_root.mkdir(parents=True, exist_ok=True)
|
|
provenance = {"source_branch": os.environ.get("SOURCE_BRANCH", os.environ.get("GITHUB_REF_NAME", "")), "source_sha": os.environ.get("SOURCE_SHA", os.environ.get("GITHUB_SHA", "")), "base_bin_env": os.environ.get("BASE_BIN", ""), "candidate_bin": os.environ.get("CANDIDATE_BIN", ""), "targets_use_same_candidate_binary": True}
|
|
write_json(args.work_root / "provenance.json", provenance)
|
|
fixture, runner = required_env("FIXTURE_GENERATOR"), required_env("QUERY_REGRESSION_RUNNER")
|
|
base_bin, candidate_bin = required_env("BASE_BIN"), required_env("CANDIDATE_BIN")
|
|
if shutil.which("taskset") is None:
|
|
raise RuntimeError("taskset is required to pin reader processes")
|
|
provenance.update({"base_bin_env": str(base_bin), "candidate_bin": str(candidate_bin), "candidate_bin_sha256": sha256(candidate_bin)})
|
|
write_json(args.work_root / "provenance.json", provenance)
|
|
failures = []
|
|
for scale in SCALES:
|
|
try:
|
|
run_scale(scale, args.work_root, fixture, runner, base_bin, candidate_bin, provenance)
|
|
except Exception as error: # noqa: BLE001 - retain per-scale artifacts and continue.
|
|
failures.append({"scale": scale, "error": str(error)})
|
|
work = args.work_root / f"scale-{scale}x"
|
|
work.mkdir(parents=True, exist_ok=True)
|
|
write_json(work / "error.json", failures[-1])
|
|
print(f"scale={scale} failed: {error}", flush=True)
|
|
if failures:
|
|
write_json(args.work_root / "errors.json", failures)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|