#!/usr/bin/env python3 """Convert hash benchmark CSV outputs into SVG graphs. Input directories: speed/*.out collizions/*.out collizions_ascii/*.out Output: graphs/speed.svg graphs/collizions.svg graphs/collizions_ascii.svg """ from __future__ import annotations import csv import html import math from collections import defaultdict from pathlib import Path from typing import Callable, Iterable ROOT = Path(__file__).resolve().parent GRAPH_DIR = ROOT / "graphs" HASH_ORDER = ["xxh32", "xxh64", "xxh3_64", "city32", "city64", "joaat"] COLORS = { "xxh32": "#1f77b4", "xxh64": "#ff7f0e", "xxh3_64": "#2ca02c", "city32": "#d62728", "city64": "#9467bd", "joaat": "#8c564b", } def read_rows(test_name: str) -> list[dict[str, str]]: rows: list[dict[str, str]] = [] for path in sorted((ROOT / test_name).glob("*.out")): with path.open(newline="") as f: rows.extend(csv.DictReader(f)) if not rows: raise FileNotFoundError(f"no .out files found in {ROOT / test_name}") return rows def log_scale(values: Iterable[float], lo_px: float, hi_px: float) -> Callable[[float], float]: vals = [v for v in values if v > 0] lo = min(vals) hi = max(vals) if lo == hi: return lambda _v: (lo_px + hi_px) / 2.0 log_lo = math.log10(lo) log_hi = math.log10(hi) return lambda v: lo_px + (math.log10(max(v, lo)) - log_lo) / (log_hi - log_lo) * (hi_px - lo_px) def linear_scale(values: Iterable[float], lo_px: float, hi_px: float) -> Callable[[float], float]: vals = list(values) lo = min(vals) hi = max(vals) if lo == hi: return lambda _v: (lo_px + hi_px) / 2.0 return lambda v: lo_px + (v - lo) / (hi - lo) * (hi_px - lo_px) def nice_ticks(max_value: float, count: int = 5) -> list[float]: if max_value <= 0: return [0.0] raw = max_value / max(count - 1, 1) power = 10 ** math.floor(math.log10(raw)) step = min((1, 2, 5, 10), key=lambda x: abs(x * power - raw)) * power ticks = [0.0] value = step while value <= max_value * 1.001: ticks.append(value) value += step return ticks def fmt_num(value: float) -> str: if value == 0: return "0" if abs(value) < 0.01 or abs(value) >= 10000: return f"{value:.2e}" if abs(value) < 1: return f"{value:.4f}".rstrip("0").rstrip(".") return f"{value:.2f}".rstrip("0").rstrip(".") def polyline(points: list[tuple[float, float]], color: str) -> str: pts = " ".join(f"{x:.2f},{y:.2f}" for x, y in points) circles = "".join( f'' for x, y in points ) return ( f'{circles}' ) def legend(x: int, y: int) -> str: parts = [] for i, name in enumerate(HASH_ORDER): yy = y + i * 18 color = COLORS[name] parts.append(f'') parts.append(f'{html.escape(name)}') return "\n".join(parts) def chart( *, title: str, series: dict[str, list[tuple[float, float]]], x_label: str, y_label: str, width: int, height: int, x_log: bool = True, ) -> str: margin_l, margin_r, margin_t, margin_b = 72, 150, 42, 58 plot_x0, plot_x1 = margin_l, width - margin_r plot_y0, plot_y1 = margin_t, height - margin_b all_points = [pt for points in series.values() for pt in points] xs = [x for x, _ in all_points] ys = [y for _, y in all_points] x_map = log_scale(xs, plot_x0, plot_x1) if x_log else linear_scale(xs, plot_x0, plot_x1) y_max = max(ys) if ys else 1.0 y_map_linear = linear_scale([0.0, y_max], plot_y1, plot_y0) parts = [ f'', f'{html.escape(title)}', f'', ] x_ticks = sorted(set(xs)) for x in x_ticks: px = x_map(x) parts.append(f'') parts.append(f'{fmt_num(x)}') for y in nice_ticks(y_max): py = y_map_linear(y) parts.append(f'') parts.append(f'{fmt_num(y)}') parts.append(f'') parts.append(f'') parts.append(f'{html.escape(x_label)}') parts.append(f'{html.escape(y_label)}') for name in HASH_ORDER: points = series.get(name, []) if not points: continue mapped = [(x_map(x), y_map_linear(y)) for x, y in sorted(points)] parts.append(polyline(mapped, COLORS[name])) parts.append(legend(width - margin_r + 26, margin_t + 16)) parts.append("") return "\n".join(parts) def svg_page(width: int, height: int, body: str) -> str: return f''' {body} ''' def make_speed() -> None: rows = read_rows("speed") series: dict[str, list[tuple[float, float]]] = defaultdict(list) for row in rows: series[row["hash"]].append((float(row["length"]), float(row["median_gib_per_s"]))) body = chart( title="Hash speed on random bytes (median GiB/s)", series=series, x_label="input length, bytes (log scale)", y_label="median GiB/s", width=1100, height=620, ) (GRAPH_DIR / "speed.svg").write_text(svg_page(1100, 620, body)) def collision_series(rows: list[dict[str, str]], bits: int) -> dict[str, list[tuple[float, float]]]: series: dict[str, list[tuple[float, float]]] = defaultdict(list) for row in rows: length = float(row["length"]) if length == 1: continue if int(row["bits"]) == bits: series[row["hash"]].append((length, float(row["median_collision_rate"]))) return series def make_collision(test_name: str, title_prefix: str) -> None: rows = read_rows(test_name) bits_values = sorted({int(row["bits"]) for row in rows}) chart_w, chart_h = 1100, 420 body_parts = [] for i, bits in enumerate(bits_values): sub = chart( title=f"{title_prefix}: {bits}-bit truncated hash collision rate", series=collision_series(rows, bits), x_label="input length, bytes/chars (log scale)", y_label="median collision rate", width=chart_w, height=chart_h, ) body_parts.append(f'{sub}') height = chart_h * len(bits_values) (GRAPH_DIR / f"{test_name}.svg").write_text(svg_page(chart_w, height, "\n".join(body_parts))) def main() -> int: GRAPH_DIR.mkdir(exist_ok=True) make_speed() make_collision("collizions", "Random bytes") make_collision("collizions_ascii", "Random a-zA-Z strings") print(f"wrote {GRAPH_DIR / 'speed.svg'}") print(f"wrote {GRAPH_DIR / 'collizions.svg'}") print(f"wrote {GRAPH_DIR / 'collizions_ascii.svg'}") return 0 if __name__ == "__main__": raise SystemExit(main())