kv_bench/scripts/csv_to_html.py
2026-05-30 14:30:02 +08:00

1056 lines
31 KiB
Python

#!/usr/bin/env python3
import argparse
import base64
import csv
import json
import re
import statistics
import tomllib
import urllib.error
import urllib.request
from collections import defaultdict
from pathlib import Path
from typing import Any
ENGINE_STYLE_FALLBACK = [
"solid",
"hatch",
"dot",
]
WORKLOAD_TEMPLATE = [
("W1", "W1 (95R/5U, uniform)"),
("W2", "W2 (95R/5U, zipf)"),
("W3", "W3 (50R/50U, uniform)"),
("W4", "W4 (5R/95U, uniform)"),
("W5", "W5 (70R/25U/5S, uniform)"),
("W6", "W6 (100% scan, uniform)"),
]
WORKLOAD_LABELS = dict(WORKLOAD_TEMPLATE)
TEST_TOOL_SOURCE_URL = "https://github.com/abbycin/kv_bench"
SEMVER_RE = re.compile(r"^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+].*)?$")
def parse_semver(version: str) -> tuple[int, int, int] | None:
m = SEMVER_RE.match(version.strip())
if not m:
return None
major = int(m.group(1))
minor = int(m.group(2) or "0")
patch = int(m.group(3) or "0")
return (major, minor, patch)
def parse_semver_with_components(version: str) -> tuple[tuple[int, int, int], int] | None:
m = SEMVER_RE.match(version.strip())
if not m:
return None
major = int(m.group(1))
has_minor = m.group(2) is not None
has_patch = m.group(3) is not None
minor = int(m.group(2) or "0")
patch = int(m.group(3) or "0")
count = 1 + int(has_minor) + int(has_patch)
return (major, minor, patch), count
def wildcard_bounds(token: str) -> tuple[tuple[int, int, int], tuple[int, int, int]] | None:
parts = [p.strip() for p in token.split(".")]
prefix: list[int] = []
wildcard_seen = False
for part in parts:
if part in ("*", "x", "X"):
wildcard_seen = True
break
if not part.isdigit():
return None
prefix.append(int(part))
if not wildcard_seen:
return None
if not prefix:
return ((0, 0, 0), (10**9, 0, 0))
lower = (prefix + [0, 0, 0])[:3]
if len(prefix) == 1:
upper = (prefix[0] + 1, 0, 0)
elif len(prefix) == 2:
upper = (prefix[0], prefix[1] + 1, 0)
else:
upper = (prefix[0], prefix[1], prefix[2] + 1)
return ((lower[0], lower[1], lower[2]), upper)
def version_satisfies_clause(version: tuple[int, int, int], clause: str) -> bool:
part = clause.strip()
if not part:
return True
if part in ("*", "x", "X"):
return True
if part.startswith("^"):
parsed = parse_semver(part[1:].strip())
if parsed is None:
return False
major, minor, patch = parsed
if major > 0:
upper = (major + 1, 0, 0)
elif minor > 0:
upper = (0, minor + 1, 0)
else:
upper = (0, 0, patch + 1)
return version >= parsed and version < upper
if part.startswith("~"):
parsed_with_count = parse_semver_with_components(part[1:].strip())
if parsed_with_count is None:
return False
parsed, count = parsed_with_count
major, minor, _ = parsed
if count <= 1:
upper = (major + 1, 0, 0)
else:
upper = (major, minor + 1, 0)
return version >= parsed and version < upper
wildcard = wildcard_bounds(part)
if wildcard is not None:
lower, upper = wildcard
return version >= lower and version < upper
m = re.match(r"^(>=|<=|>|<|=)?\s*(.+)$", part)
if not m:
return False
op = m.group(1)
raw_token = m.group(2).strip()
token = parse_semver(raw_token)
if token is None:
return False
if op == ">=":
return version >= token
if op == "<=":
return version <= token
if op == ">":
return version > token
if op == "<":
return version < token
if op == "=":
return version == token
# Cargo default when no operator is caret.
return version_satisfies_clause(version, f"^{raw_token}")
def version_satisfies_requirement(version_text: str, requirement: str) -> bool:
version = parse_semver(version_text)
if version is None:
return False
req = requirement.strip()
if not req or req == "*":
return True
clauses = [part.strip() for part in req.split(",")]
return all(version_satisfies_clause(version, clause) for clause in clauses)
def resolve_crates_io_version(crate_name: str, requirement: str) -> str:
url = f"https://crates.io/api/v1/crates/{crate_name}"
req = urllib.request.Request(
url,
headers={
"User-Agent": "kv_bench-csv_to_html",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, json.JSONDecodeError):
return "unknown"
candidates: list[str] = []
for item in payload.get("versions", []):
ver = item.get("num")
if not isinstance(ver, str):
continue
if item.get("yanked", False):
continue
if "-" in ver:
continue
if version_satisfies_requirement(ver, requirement):
candidates.append(ver)
if not candidates:
return "unknown"
candidates.sort(key=lambda v: parse_semver(v) or (0, 0, 0), reverse=True)
return candidates[0]
def resolve_lockfile_version(
repo_root: Path,
crate_name: str,
requirement: str,
) -> str:
lock_path = repo_root / "Cargo.lock"
if not lock_path.exists():
return "unknown"
try:
with lock_path.open("rb") as f:
lock_obj = tomllib.load(f)
except (OSError, tomllib.TOMLDecodeError):
return "unknown"
versions: list[str] = []
packages = lock_obj.get("package", [])
if not isinstance(packages, list):
return "unknown"
for pkg in packages:
if not isinstance(pkg, dict):
continue
if pkg.get("name") != crate_name:
continue
ver = pkg.get("version")
if not isinstance(ver, str):
continue
if "-" in ver:
continue
if version_satisfies_requirement(ver, requirement):
versions.append(ver)
if not versions:
return "unknown"
versions.sort(key=lambda v: parse_semver(v) or (0, 0, 0), reverse=True)
return versions[0]
def resolve_git_head(repo_path: Path) -> str:
git_dir = repo_path / ".git"
head_path = git_dir / "HEAD"
if not head_path.exists():
return "unknown"
try:
head_text = head_path.read_text(encoding="utf-8").strip()
except OSError:
return "unknown"
if head_text.startswith("ref: "):
ref_rel = head_text[5:].strip()
ref_path = git_dir / ref_rel
if ref_path.exists():
try:
return ref_path.read_text(encoding="utf-8").strip()
except OSError:
return "unknown"
packed_refs = git_dir / "packed-refs"
if packed_refs.exists():
try:
for line in packed_refs.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or line.startswith("^"):
continue
parts = line.split(" ")
if len(parts) == 2 and parts[1] == ref_rel:
return parts[0]
except OSError:
return "unknown"
return "unknown"
return head_text
def infer_mace_identity(repo_root: Path) -> tuple[str, str]:
cargo_path = repo_root / "Cargo.toml"
if not cargo_path.exists():
return ("mace commit id", "unknown")
try:
with cargo_path.open("rb") as f:
cargo_obj = tomllib.load(f)
except (OSError, tomllib.TOMLDecodeError):
return ("mace commit id", "unknown")
deps = cargo_obj.get("dependencies", {})
if not isinstance(deps, dict):
return ("mace commit id", "unknown")
mace_dep = deps.get("mace-kv")
if mace_dep is None:
return ("mace commit id", "unknown")
if isinstance(mace_dep, str):
requirement = mace_dep.strip() or "*"
lock_version = resolve_lockfile_version(repo_root, "mace-kv", requirement)
if lock_version != "unknown":
return ("mace version", lock_version)
return ("mace version", resolve_crates_io_version("mace-kv", requirement))
if isinstance(mace_dep, dict):
path_value = mace_dep.get("path")
if isinstance(path_value, str) and path_value.strip():
dep_path = Path(path_value.strip())
mace_repo = dep_path if dep_path.is_absolute() else (repo_root / dep_path)
return ("mace commit id", resolve_git_head(mace_repo.resolve()))
version_req = mace_dep.get("version")
if isinstance(version_req, str) and version_req.strip():
requirement = version_req.strip()
lock_version = resolve_lockfile_version(repo_root, "mace-kv", requirement)
if lock_version != "unknown":
return ("mace version", lock_version)
return ("mace version", resolve_crates_io_version("mace-kv", requirement))
return ("mace commit id", "unknown")
def infer_rocksdb_version(repo_root: Path) -> str:
vcpkg_path = repo_root / "rocksdb" / "vcpkg.json"
if not vcpkg_path.exists():
return "unknown"
try:
obj = json.loads(vcpkg_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return "unknown"
for item in obj.get("overrides", []):
if item.get("name") == "rocksdb" and isinstance(item.get("version"), str):
return item["version"]
return "unknown"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Generate a single-page HTML report from benchmark CSV. "
"Each workload gets two line charts: ops and p99_us."
)
)
parser.add_argument("csv_path", help="Input CSV path, e.g. benchmark_results.csv")
parser.add_argument(
"-o",
"--output",
help="Output HTML path (default: <csv_stem>_report.html)",
)
return parser.parse_args()
def to_int(value: str) -> int:
return int(float(value))
def to_float(value: str) -> float:
return float(value)
def format_metric(metric: str, value: float) -> str:
if metric == "ops":
if abs(value) >= 100:
return f"{value:,.0f}"
return f"{value:.2f}"
if abs(value) >= 100:
return f"{value:,.0f}"
return f"{value:.2f}"
def workload_sort_key(workload: str) -> tuple[int, str, int]:
prefix = ""
suffix = ""
for idx, ch in enumerate(workload):
if ch.isdigit():
prefix = workload[:idx]
suffix = workload[idx:]
break
if suffix.isdigit():
return (0, prefix, int(suffix))
return (1, workload, 0)
def workload_label(workload: str) -> str:
return WORKLOAD_LABELS.get(workload, workload)
def engine_style(engine: str, index: int) -> str:
normalized = engine.strip().lower()
if normalized == "mace":
return "solid"
if normalized == "rocksdb":
return "hatch"
return ENGINE_STYLE_FALLBACK[index % len(ENGINE_STYLE_FALLBACK)]
def color_for_index(index: int) -> str:
hue = (index * 137.508) % 360.0
return f"hsl({hue:.1f}, 70%, 45%)"
def read_and_aggregate(csv_path: Path) -> tuple[list[dict[str, Any]], set[str], set[tuple[int, int]]]:
required = {
"engine",
"workload_id",
"threads",
"key_size",
"value_size",
"ops",
"p99_us",
}
grouped: dict[tuple[str, str, int, int, int], dict[str, list[float]]] = defaultdict(
lambda: {"ops": [], "p99_us": []}
)
engines: set[str] = set()
kv_pairs: set[tuple[int, int]] = set()
with csv_path.open("r", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
if reader.fieldnames is None:
raise ValueError("CSV header is missing")
missing = required - set(reader.fieldnames)
if missing:
raise ValueError(f"Missing required columns: {sorted(missing)}")
skipped = 0
for row in reader:
try:
engine = str(row["engine"]).strip()
workload = str(row["workload_id"]).strip()
threads = to_int(str(row["threads"]))
key_size = to_int(str(row["key_size"]))
value_size = to_int(str(row["value_size"]))
ops = to_float(str(row["ops"]))
p99 = to_float(str(row["p99_us"]))
except (TypeError, ValueError):
skipped += 1
continue
if not engine or not workload:
skipped += 1
continue
key = (workload, engine, key_size, value_size, threads)
grouped[key]["ops"].append(ops)
grouped[key]["p99_us"].append(p99)
engines.add(engine)
kv_pairs.add((key_size, value_size))
rows: list[dict[str, Any]] = []
for (workload, engine, key_size, value_size, threads), values in grouped.items():
if not values["ops"] or not values["p99_us"]:
continue
rows.append(
{
"workload": workload,
"engine": engine,
"key_size": key_size,
"value_size": value_size,
"threads": threads,
"ops": float(statistics.median(values["ops"])),
"p99_us": float(statistics.median(values["p99_us"])),
}
)
if not rows:
raise ValueError("No valid rows parsed from CSV")
return rows, engines, kv_pairs
def build_report_payload(rows: list[dict[str, Any]], engines: set[str], kv_pairs: set[tuple[int, int]]) -> dict[str, Any]:
engine_order = sorted(engines)
kv_order = sorted(kv_pairs)
engine_to_style = {
engine: engine_style(engine, idx)
for idx, engine in enumerate(engine_order)
}
kv_to_color = {
kv: color_for_index(idx)
for idx, kv in enumerate(kv_order)
}
by_workload: dict[str, dict[tuple[str, int, int], list[dict[str, Any]]]] = defaultdict(
lambda: defaultdict(list)
)
for row in rows:
series_key = (row["engine"], row["key_size"], row["value_size"])
by_workload[row["workload"]][series_key].append(row)
workload_items = []
for workload in sorted(by_workload.keys(), key=workload_sort_key):
metric_datasets: dict[str, list[dict[str, Any]]] = {"ops": [], "p99_us": []}
series_map = by_workload[workload]
for engine, key_size, value_size in sorted(series_map.keys(), key=lambda x: (x[1], x[2], x[0])):
points = sorted(series_map[(engine, key_size, value_size)], key=lambda x: x["threads"])
color = kv_to_color[(key_size, value_size)]
style = engine_to_style[engine]
for metric in ("ops", "p99_us"):
data_points = [
{
"x": p["threads"],
"y": p[metric],
"label": format_metric(metric, p[metric]),
}
for p in points
]
metric_datasets[metric].append(
{
"label": f"{engine} (k={key_size}, v={value_size})",
"data": data_points,
"borderColor": color,
"backgroundColor": color,
"borderWidth": 2,
"engineStyle": style,
}
)
workload_items.append(
{
"id": workload,
"label": workload_label(workload),
"charts": metric_datasets,
}
)
legend_pairs = [
{"key_size": k, "value_size": v, "color": kv_to_color[(k, v)]}
for k, v in kv_order
]
legend_engines = [
{"engine": engine, "style": engine_to_style[engine]}
for engine in engine_order
]
return {
"workloads": workload_items,
"kvLegend": legend_pairs,
"engineLegend": legend_engines,
}
def render_html(
payload: dict[str, Any],
source_csv: str,
source_csv_name: str,
source_csv_b64: str,
mace_label: str,
mace_value: str,
rocksdb_version: str,
) -> str:
payload_json = json.dumps(payload, ensure_ascii=False)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Benchmark Report</title>
<style>
:root {{
--bg: #f7f8fc;
--card: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--border: #e5e7eb;
--accent: #1d4ed8;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: "Segoe UI", sans-serif;
color: var(--text);
background: radial-gradient(circle at top right, #e8eeff 0%, var(--bg) 45%);
}}
.container {{
max-width: 1480px;
margin: 0 auto;
padding: 24px 20px 40px;
}}
h1 {{ margin: 0 0 10px; font-size: 30px; }}
.sub {{ color: var(--muted); margin-bottom: 18px; }}
.source-link {{
color: var(--accent);
text-decoration: none;
border-bottom: 1px dashed var(--accent);
}}
.source-link:hover {{ opacity: 0.85; }}
.card {{
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
box-shadow: 0 2px 10px rgba(17, 24, 39, 0.04);
}}
.legend-wrap {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 18px;
}}
.legend-title {{ font-weight: 700; margin-bottom: 10px; }}
.legend-list {{
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
font-size: 14px;
}}
.legend-item {{ display: inline-flex; align-items: center; gap: 8px; color: #111827; }}
.swatch {{ width: 22px; height: 0; border-top: 4px solid #000; border-radius: 2px; }}
.engine-swatch {{
width: 28px;
height: 14px;
border: 2px solid #111827;
border-radius: 3px;
background: #111827;
display: inline-block;
}}
.engine-swatch.hatch {{
background-image: repeating-linear-gradient(
45deg,
#111827 0px,
#111827 4px,
#ffffff 4px,
#ffffff 7px
);
}}
.engine-swatch.dot {{
background-image: radial-gradient(#111827 28%, transparent 30%);
background-size: 6px 6px;
background-color: #ffffff;
}}
.workload {{ margin-top: 22px; }}
.workload-head {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 0 0 10px;
flex-wrap: wrap;
}}
.workload h2 {{ margin: 0; font-size: 22px; color: var(--accent); }}
.metric-toggle {{
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: #ffffff;
}}
.metric-label {{
font-size: 13px;
color: #374151;
font-weight: 700;
text-transform: lowercase;
}}
.metric-current {{
font-size: 13px;
color: #1d4ed8;
font-weight: 700;
min-width: 32px;
text-align: center;
border-left: 1px solid var(--border);
padding-left: 8px;
}}
.metric-slider {{
width: 64px;
accent-color: #1d4ed8;
cursor: pointer;
}}
.chart-card {{
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
box-shadow: 0 2px 10px rgba(17, 24, 39, 0.04);
min-height: 560px;
}}
.chart-tools {{
display: flex;
justify-content: flex-end;
margin: 0 0 8px;
}}
.reset-btn {{
border: 1px solid var(--border);
background: #f9fafb;
color: #111827;
font-size: 12px;
padding: 4px 8px;
border-radius: 8px;
cursor: pointer;
}}
.reset-btn:hover {{ background: #eef2ff; }}
canvas {{ width: 100% !important; height: 500px !important; }}
@media (max-width: 1000px) {{
.legend-wrap {{ grid-template-columns: 1fr; }}
.workload-head {{ align-items: flex-start; }}
}}
</style>
</head>
<body>
<div class="container">
<h1>Benchmark Report</h1>
<div class="sub">
CSV source:
<a id="source-download" class="source-link" href="#" download="{source_csv_name}">
<code>{source_csv}</code>
</a>
(click to download raw CSV)
</div>
<div class="sub">
Test tool source code:
<a class="source-link" href="{TEST_TOOL_SOURCE_URL}" target="_blank" rel="noopener noreferrer">{TEST_TOOL_SOURCE_URL}</a>
<br />
{mace_label}: <code>{mace_value}</code>
<br />
rocksdb version: <code>{rocksdb_version}</code>
</div>
<div class="legend-wrap">
<div class="card">
<div class="legend-title">Color: key/value pairs (consistent across engines)</div>
<div id="kv-legend" class="legend-list"></div>
</div>
<div class="card">
<div class="legend-title">Fill style: engine</div>
<div id="engine-legend" class="legend-list"></div>
</div>
</div>
<div id="workloads-root"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.2.0/dist/chartjs-plugin-zoom.min.js"></script>
<script>
const REPORT = {payload_json};
const SOURCE_CSV_B64 = "{source_csv_b64}";
const SOURCE_CSV_NAME = "{source_csv_name}";
const valueLabelPlugin = {{
id: 'valueLabelPlugin',
afterDatasetsDraw(chart) {{
const ctx = chart.ctx;
ctx.save();
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
chart.data.datasets.forEach((dataset, datasetIndex) => {{
const meta = chart.getDatasetMeta(datasetIndex);
if (meta.hidden) return;
meta.data.forEach((point, i) => {{
const raw = dataset.data[i];
const labelText =
(dataset.valueLabels && dataset.valueLabels[i] !== undefined)
? dataset.valueLabels[i]
: (raw && raw.label !== undefined ? raw.label : null);
if (labelText === undefined || labelText === null || labelText === '') return;
const pos = point.tooltipPosition();
ctx.fillStyle = dataset.borderColor;
ctx.fillText(String(labelText), pos.x + 5, pos.y - 6);
}});
}});
ctx.restore();
}}
}};
Chart.register(valueLabelPlugin);
function patternForStyle(ctx, style, color) {{
if (style === 'solid') {{
return color;
}}
const p = document.createElement('canvas');
p.width = 10;
p.height = 10;
const pctx = p.getContext('2d');
if (!pctx) {{
return color;
}}
pctx.clearRect(0, 0, p.width, p.height);
if (style === 'hatch') {{
pctx.strokeStyle = color;
pctx.lineWidth = 2;
pctx.beginPath();
pctx.moveTo(-2, 8);
pctx.lineTo(8, -2);
pctx.moveTo(2, 12);
pctx.lineTo(12, 2);
pctx.stroke();
}} else if (style === 'dot') {{
pctx.fillStyle = color;
pctx.beginPath();
pctx.arc(3, 3, 1.5, 0, Math.PI * 2);
pctx.fill();
pctx.beginPath();
pctx.arc(8, 8, 1.5, 0, Math.PI * 2);
pctx.fill();
}} else {{
return color;
}}
return ctx.createPattern(p, 'repeat') || color;
}}
function addLegends() {{
const kvRoot = document.getElementById('kv-legend');
const engineRoot = document.getElementById('engine-legend');
REPORT.kvLegend.forEach(item => {{
const node = document.createElement('div');
node.className = 'legend-item';
node.innerHTML = `<span class="swatch" style="border-top-color:${{item.color}}"></span><span>k=${{item.key_size}}, v=${{item.value_size}}</span>`;
kvRoot.appendChild(node);
}});
REPORT.engineLegend.forEach(item => {{
const node = document.createElement('div');
node.className = 'legend-item';
node.innerHTML = `<span class="engine-swatch ${{item.style}}"></span><span>${{item.engine}}</span>`;
engineRoot.appendChild(node);
}});
}}
function normalizeBarDatasets(rawDatasets) {{
const labelSet = new Set();
rawDatasets.forEach(ds => {{
ds.data.forEach(pt => {{
labelSet.add(String(pt.x));
}});
}});
const labels = Array.from(labelSet).sort((a, b) => Number(a) - Number(b));
const datasets = rawDatasets.map(ds => {{
const pointMap = new Map(ds.data.map(pt => [String(pt.x), pt]));
const data = labels.map(lbl => {{
const p = pointMap.get(lbl);
return p ? p.y : null;
}});
const valueLabels = labels.map(lbl => {{
const p = pointMap.get(lbl);
return p ? p.label : '';
}});
return {{
...ds,
data,
valueLabels
}};
}});
return {{ labels, datasets }};
}}
function createChart(canvas, title, yTitle, datasets) {{
const normalized = normalizeBarDatasets(datasets);
const chartCtx = canvas.getContext('2d');
if (!chartCtx) {{
return null;
}}
normalized.datasets = normalized.datasets.map(ds => {{
const fill = patternForStyle(chartCtx, ds.engineStyle, ds.backgroundColor);
return {{
...ds,
backgroundColor: fill,
borderColor: ds.borderColor,
}};
}});
const chart = new Chart(chartCtx, {{
type: 'bar',
data: normalized,
options: {{
responsive: true,
maintainAspectRatio: false,
interaction: {{ mode: 'nearest', intersect: false }},
plugins: {{
title: {{
display: true,
text: title,
color: '#111827',
font: {{
size: 20,
weight: '700'
}}
}},
legend: {{ display: false }},
zoom: {{
pan: {{
enabled: true,
mode: 'xy',
modifierKey: 'ctrl'
}},
zoom: {{
wheel: {{
enabled: true
}},
drag: {{
enabled: true
}},
pinch: {{
enabled: true
}},
mode: 'xy'
}}
}},
tooltip: {{
callbacks: {{
label: (ctx) => `${{ctx.dataset.label}}: ${{ctx.dataset.valueLabels?.[ctx.dataIndex] ?? ctx.parsed.y}}`
}}
}}
}},
scales: {{
x: {{
type: 'category',
title: {{
display: true,
text: 'threads',
color: '#111827',
font: {{
size: 16,
weight: '700'
}}
}},
ticks: {{
color: '#111827',
font: {{
size: 14,
weight: '600'
}}
}}
}},
y: {{
title: {{
display: true,
text: yTitle,
color: '#111827',
font: {{
size: 16,
weight: '700'
}}
}},
ticks: {{
color: '#111827',
font: {{
size: 14,
weight: '600'
}}
}}
}}
}}
}}
}});
return chart;
}}
function addWorkloads() {{
const root = document.getElementById('workloads-root');
REPORT.workloads.forEach((w, wIdx) => {{
const section = document.createElement('section');
section.className = 'workload';
section.innerHTML = `
<div class="workload-head">
<h2>${{w.label}}</h2>
<div class="metric-toggle">
<span class="metric-label">ops</span>
<input id="metric-switch-${{wIdx}}" class="metric-slider" type="range" min="0" max="1" step="1" value="0" />
<span class="metric-label">p99</span>
<span id="metric-current-${{wIdx}}" class="metric-current">ops</span>
</div>
</div>
<div class="chart-card">
<div class="chart-tools"><button type="button" class="reset-btn" id="chart-reset-${{wIdx}}">Reset zoom</button></div>
<canvas id="metric-${{wIdx}}"></canvas>
</div>
`;
root.appendChild(section);
const chartCanvas = section.querySelector(`#metric-${{wIdx}}`);
const chartResetBtn = section.querySelector(`#chart-reset-${{wIdx}}`);
const metricSwitch = section.querySelector(`#metric-switch-${{wIdx}}`);
const metricCurrent = section.querySelector(`#metric-current-${{wIdx}}`);
let currentChart = null;
function renderMetric(metric) {{
const yTitle = metric === 'ops' ? 'ops' : 'p99_us';
metricCurrent.textContent = metric === 'ops' ? 'ops' : 'p99';
if (currentChart) {{
currentChart.destroy();
}}
currentChart = createChart(chartCanvas, `${{w.label}} - ${{yTitle}}`, yTitle, w.charts[metric]);
}}
metricSwitch.addEventListener('input', () => {{
const nextMetric = metricSwitch.value === '1' ? 'p99_us' : 'ops';
renderMetric(nextMetric);
}});
chartResetBtn.addEventListener('click', () => {{
if (currentChart) currentChart.resetZoom();
}});
chartCanvas.addEventListener('dblclick', () => {{
if (currentChart) currentChart.resetZoom();
}});
renderMetric('ops');
}});
}}
function initSourceDownload() {{
const link = document.getElementById('source-download');
link.href = `data:text/csv;base64,${{SOURCE_CSV_B64}}`;
link.download = SOURCE_CSV_NAME;
}}
initSourceDownload();
addLegends();
addWorkloads();
</script>
</body>
</html>
"""
def main() -> int:
args = parse_args()
csv_path = Path(args.csv_path)
if not csv_path.exists():
raise FileNotFoundError(f"CSV file not found: {csv_path}")
output_path = (
Path(args.output)
if args.output
else csv_path.with_suffix(".html")
)
rows, engines, kv_pairs = read_and_aggregate(csv_path)
payload = build_report_payload(rows, engines, kv_pairs)
csv_bytes = csv_path.read_bytes()
csv_b64 = base64.b64encode(csv_bytes).decode("ascii")
repo_root = Path(__file__).resolve().parent.parent
mace_label, mace_value = infer_mace_identity(repo_root)
rocksdb_version = infer_rocksdb_version(repo_root)
html = render_html(
payload,
str(csv_path),
csv_path.name,
csv_b64,
mace_label,
mace_value,
rocksdb_version,
)
output_path.write_text(html, encoding="utf-8")
print(f"HTML written to: {output_path}")
print(f"Workloads: {len(payload['workloads'])}, engines: {len(engines)}, kv pairs: {len(kv_pairs)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())