#!/usr/bin/env python3 """Markdown tables from a bench.py CSV: throughput, the proxy's CPU per unit of work, and its peak memory, a scenario per row and a proxy per column, each cell the median of the repetitions. Proxies given with --splice (those using splice()) form a group of their own after the others, the two separated by a ‖ column. The best proxy of each group in each row is in bold, and so is any other of the group whose repetitions overlap the best one's (the range of the repetitions stands for the error): the measurement cannot tell them apart. "direct", no proxy, comes first and is not ranked. A value marked ‡ was limited by the client or the origin rather than by the proxy (most of its repetitions were), so the proxy can do at least that much. bench/report.py bench/results/20261011-155353.csv > tables.md Several CSVs make one set of tables: a proxy is taken from the first file it appears in, so a later run can add proxies, and a proxy it repeats (run again to see how much the conditions moved between the runs) is left out. --label old=new renames a column, e.g. --label 3proxy-devel-s="devel, splice". --machine NAME=a.csv,b.csv, given once per machine, makes a single table of throughput instead: each cell the machines' medians and their average. The best average of each group (no splice, splice) is in bold, and so is any other of the group whose range (the average of its slowest runs to that of its fastest) reaches the best one's; * marks the best of the row, both groups together, in the same way. --split puts the splice group in a table of its own (* still compares across both), --prefix connect- takes only the scenarios so named. """ import argparse import csv import statistics STREAM = ("stream1", "stream1-down", "stream16", "connect-stream1-down", "connect-stream16", "socks-stream1-down", "socks-stream16", "relay-stream1-down", "relay-stream16") # connect-ka-1m etc. are bulk too when present def values(rows, key): xs = [] for r in rows: try: x = float(r[key]) except (KeyError, ValueError): continue if x == x: xs.append(x) return xs def median(rows, key): xs = values(rows, key) return statistics.median(xs) if xs else None def winners(by, scenario, proxies, key, higher): """Proxies tied for best: the best median, and those whose range of repetitions overlaps the best one's.""" ranges = {} for p in proxies: if p == "direct": continue xs = values(by.get((scenario, p), []), key) if xs: ranges[p] = (min(xs), statistics.median(xs), max(xs)) if not ranges: return set() if higher: best = max(ranges, key=lambda p: ranges[p][1]) return {p for p, r in ranges.items() if r[2] >= ranges[best][0]} best = min(ranges, key=lambda p: ranges[p][1]) return {p for p, r in ranges.items() if r[0] <= ranges[best][2]} def fmt(x, digits): if x is None: return "–" return f"{x:,.{digits}f}" def table(title, scenarios, groups, labels, cell, best): """groups: lists of proxies, each ranked on its own, shown in order with a separator column between them.""" head, rule = [], [] for i, g in enumerate(groups): if i: head.append("‖") rule.append(":-:") head += [labels.get(p, p) for p in g] rule += ["---:"] * len(g) out = [f"**{title}**", "", "| scenario | " + " | ".join(head) + " |", "|---|" + "|".join(rule) + "|"] for s in scenarios: cells = [] for i, g in enumerate(groups): if i: cells.append("‖") win = best(s, g) cells += [f"**{cell(s, p)}**" if p in win else cell(s, p) for p in g] out.append(f"| {s} | " + " | ".join(cells) + " |") return "\n".join(out) + "\n" def load(paths): """Rows of several CSVs, a proxy taken from the first file it is in.""" rows, seen = [], set() for path in paths: new = list(csv.DictReader(open(path))) rows += [r for r in new if r["proxy"] not in seen] seen |= {r["proxy"] for r in new} return rows # In the combined table, the bulk scenarios in Gbit/s, the others in # thousands of requests per second. BULK = STREAM + ("http-ka-1m",) def combined(machines, labels, splice, prefix=None, split=False): """One throughput table for several machines: each cell their medians and their average. Bold: the best of its group; *: the best of the row. split puts the proxies using splice() in a table of their own, * still comparing across both.""" data, proxies, scenarios = {}, [], [] for name, paths in machines: by = {} for r in load(paths): if prefix and not r["scenario"].startswith(prefix): continue by.setdefault((r["scenario"], r["proxy"]), []).append(r) if r["proxy"] not in proxies: proxies.append(r["proxy"]) if r["scenario"] not in scenarios: scenarios.append(r["scenario"]) data[name] = by def runs(name, s, p): sel = data[name].get((s, p), []) if s in BULK: return values(sel, "gbit") return [x / 1000 for x in values(sel, "rate")] def value(name, s, p): xs = runs(name, s, p) return statistics.median(xs) if xs else None def average(s, p): xs = [value(n, s, p) for n, _ in machines] return sum(xs) / len(xs) if xs and None not in xs else None def spread(s, p): """The range of the average: of the slowest runs to of the fastest.""" lows, highs = [], [] for n, _ in machines: xs = runs(n, s, p) if not xs: return None lows.append(min(xs)) highs.append(max(xs)) return sum(lows) / len(lows), sum(highs) / len(highs) def leaders(s, ps): """The best average, and those whose range reaches the best one's: the runs cannot tell them apart.""" known = {p: average(s, p) for p in ps if p != "direct"} known = {p: a for p, a in known.items() if a is not None} if not known: return set() floor = spread(s, max(known, key=known.get))[0] return {p for p in known if spread(s, p)[1] >= floor} plain = [p for p in proxies if p != "direct" and p not in splice] spliced = [p for p in proxies if p in splice] direct = [p for p in proxies if p == "direct"] names = " / ".join(n for n, _ in machines) + " / average" def render(tables): """tables: lists of groups, each table printed on its own; bold for the best of a group, * for the best of the row over all of them.""" everyone = [p for t in tables for g in t for p in g] for t in tables: head, rule = [], [] for i, g in enumerate(t): if i: head.append("‖") rule.append(":-:") head += [labels.get(p, p) for p in g] rule += ["---:"] * len(g) out = [f"| scenario ({names}) | " + " | ".join(head) + " |", "|---|" + "|".join(rule) + "|"] for s in scenarios: unit = "Gbit/s" if s in BULK else "k req/s" overall = leaders(s, everyone) cells = [] for i, g in enumerate(t): if i: cells.append("‖") best = leaders(s, g) for p in g: vals = [value(n, s, p) for n, _ in machines] + [average(s, p)] if all(v is None for v in vals): cells.append("–") continue text = " / ".join(fmt(v, 1) for v in vals) if p in best: text = f"**{text}**" if p in overall: text += " *" cells.append(text) name = s[len(prefix):] if prefix else s out.append(f"| {name}, {unit} | " + " | ".join(cells) + " |") print("\n".join(out)) print() if split and spliced: render([[g for g in (direct, plain) if g], [spliced]]) else: render([[g for g in (direct, plain, spliced) if g]]) def main(): ap = argparse.ArgumentParser() ap.add_argument("csv", nargs="*") ap.add_argument("--split", action="store_true", help="the proxies using splice() in a table of their own") ap.add_argument("--prefix", help="only the scenarios named so, e.g. connect- " "(the prefix is left out of the rows)") ap.add_argument("--machine", action="append", default=[], help="NAME=a.csv[,b.csv]: one throughput table for all the " "machines given, instead of the tables for one") ap.add_argument("--label", action="append", default=[], help="old=new") ap.add_argument("--title", default="") ap.add_argument("--splice", action="append", default=[], help="a proxy (as named in the CSV) using splice(); repeat") args = ap.parse_args() labels = dict(l.split("=", 1) for l in args.label) if args.machine: combined([(m.split("=", 1)[0], m.split("=", 1)[1].split(",")) for m in args.machine], labels, args.splice, args.prefix, args.split) return rows = load(args.csv) proxies, scenarios = [], [] for r in rows: if r["proxy"] not in proxies: proxies.append(r["proxy"]) if r["scenario"] not in scenarios: scenarios.append(r["scenario"]) by = {} for r in rows: by.setdefault((r["scenario"], r["proxy"]), []).append(r) def limited(s, p): if p == "direct": return "" bounds = [r.get("bound", "") for r in by.get((s, p), [])] other = sum(b in ("client", "origin") for b in bounds) return " ‡" if bounds and other * 2 > len(bounds) else "" def throughput(s, p): sel = by.get((s, p), []) g = median(sel, "gbit") if s in STREAM: return fmt(g, 1) + limited(s, p) rate = median(sel, "rate") return f"{fmt(g, 2)} ({fmt(rate / 1000 if rate else None, 1)}k/s)" + limited(s, p) def cpu(s, p): sel = by.get((s, p), []) if p == "direct": return "–" if s in STREAM or s in ("http-ka-1m", "http-ka-50k-2000", "http-ka-2k-50k-2000"): return fmt(median(sel, "cpu_s_per_gb"), 2) + " s/GB" return fmt(median(sel, "cpu_us_per_req"), 0) + " µs/req" def peak(s, p): if p == "direct": return "–" return fmt(median(by.get((s, p), []), "peak_mb"), 0) def cpukey(s): return "cpu_s_per_gb" if s in STREAM or s in ("http-ka-1m", "http-ka-50k-2000", "http-ka-2k-50k-2000") \ else "cpu_us_per_req" plain = [p for p in proxies if p != "direct" and p not in args.splice] spliced = [p for p in proxies if p in args.splice] groups = [g for g in ([p for p in proxies if p == "direct"], plain, spliced) if g] prefix = f"{args.title}: " if args.title else "" print(table(prefix + "throughput, Gbit/s (thousands of requests per second for HTTP)", scenarios, groups, labels, throughput, lambda s, g: winners(by, s, g, "gbit", True))) print(table(prefix + "proxy CPU time per gigabyte or per request", scenarios, groups, labels, cpu, lambda s, g: winners(by, s, g, cpukey(s), False))) print(table(prefix + "proxy peak memory, MB", scenarios, groups, labels, peak, lambda s, g: winners(by, s, g, "peak_mb", False))) if __name__ == "__main__": main()