testing hash

This commit is contained in:
2026-07-02 15:19:31 +03:00
parent ab619909d9
commit 4a1e0bb078
33 changed files with 2356 additions and 0 deletions
+245
View File
@@ -0,0 +1,245 @@
#!/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'<circle cx="{x:.2f}" cy="{y:.2f}" r="3" fill="{color}" />'
for x, y in points
)
return (
f'<polyline points="{pts}" fill="none" stroke="{color}" '
f'stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />{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'<rect x="{x}" y="{yy - 10}" width="12" height="12" fill="{color}" />')
parts.append(f'<text x="{x + 18}" y="{yy}" class="legend">{html.escape(name)}</text>')
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'<g class="chart">',
f'<text x="{width / 2:.0f}" y="22" text-anchor="middle" class="title">{html.escape(title)}</text>',
f'<rect x="{plot_x0}" y="{plot_y0}" width="{plot_x1 - plot_x0}" height="{plot_y1 - plot_y0}" class="plot-bg" />',
]
x_ticks = sorted(set(xs))
for x in x_ticks:
px = x_map(x)
parts.append(f'<line x1="{px:.2f}" y1="{plot_y0}" x2="{px:.2f}" y2="{plot_y1}" class="grid" />')
parts.append(f'<text x="{px:.2f}" y="{plot_y1 + 18}" text-anchor="middle" class="tick">{fmt_num(x)}</text>')
for y in nice_ticks(y_max):
py = y_map_linear(y)
parts.append(f'<line x1="{plot_x0}" y1="{py:.2f}" x2="{plot_x1}" y2="{py:.2f}" class="grid" />')
parts.append(f'<text x="{plot_x0 - 8}" y="{py + 4:.2f}" text-anchor="end" class="tick">{fmt_num(y)}</text>')
parts.append(f'<line x1="{plot_x0}" y1="{plot_y1}" x2="{plot_x1}" y2="{plot_y1}" class="axis" />')
parts.append(f'<line x1="{plot_x0}" y1="{plot_y0}" x2="{plot_x0}" y2="{plot_y1}" class="axis" />')
parts.append(f'<text x="{(plot_x0 + plot_x1) / 2:.0f}" y="{height - 14}" text-anchor="middle" class="label">{html.escape(x_label)}</text>')
parts.append(f'<text transform="translate(18,{(plot_y0 + plot_y1) / 2:.0f}) rotate(-90)" text-anchor="middle" class="label">{html.escape(y_label)}</text>')
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("</g>")
return "\n".join(parts)
def svg_page(width: int, height: int, body: str) -> str:
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
<style>
.title {{ font: 700 18px sans-serif; fill: #111; }}
.label {{ font: 13px sans-serif; fill: #222; }}
.tick {{ font: 11px sans-serif; fill: #555; }}
.legend {{ font: 12px monospace; fill: #222; }}
.axis {{ stroke: #333; stroke-width: 1.2; }}
.grid {{ stroke: #ddd; stroke-width: 0.8; }}
.plot-bg {{ fill: #fff; stroke: #bbb; stroke-width: 1; }}
</style>
<rect width="100%" height="100%" fill="#fff" />
{body}
</svg>
'''
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'<g transform="translate(0,{i * chart_h})">{sub}</g>')
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())