#!/usr/bin/env python3 """Builds the SVG charts and the generated numbers in COMPARISON.md / RESEARCH.md from bench.json. python3 docs/charts/make_charts.py (or: make charts) Input : docs/charts/bench.json (written by scripts/bench.sh, i.e. make bench) Output: docs/charts/startup.svg, size.svg, cpu.svg the blocks between and in the markdown documents and inline values written as text Design rules (the dataviz method): one form per job, doos in the accent hue and everything else in the neutral gray (emphasis), bars at most 24 px with a 4 px rounded data end, direct value labels at the bar tip, a legend, hairline solid grid, text in ink tokens (never the series color), light and dark palettes selected separately. Colors were checked with the dataviz validator: contrast, CVD separation and the normal-vision floor pass in both modes; its "chroma floor" flag on the gray is expected, the gray is the de-emphasis neutral by design. Standard library only. """ import json import math import re import sys from pathlib import Path from xml.sax.saxutils import escape HERE = Path(__file__).resolve().parent ROOT = HERE.parent.parent DATA = HERE / "bench.json" # ---- palette (dataviz reference instance) --------------------------------------------------------------- LIGHT = dict(surface="#fcfcfb", ink="#0b0b0b", ink2="#52514e", muted="#898781", grid="#e1e0d9", axis="#c3c2b7", accent="#2a78d6", other="#898781") DARK = dict(surface="#1a1a19", ink="#f0efec", ink2="#c3c2b7", muted="#898781", grid="#2c2c2a", axis="#383835", accent="#3987e5", other="#898781") STYLE = f""" .bg {{ fill: var(--surface); }} .t1 {{ fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }} .t2 {{ fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }} .lab {{ fill: var(--ink); font: 400 13px system-ui, -apple-system, "Segoe UI", sans-serif; }} .val {{ fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }} .tick {{ fill: var(--muted); font: 400 11px system-ui, -apple-system, "Segoe UI", sans-serif; }} .grid {{ stroke: var(--grid); stroke-width: 1; }} .axis {{ stroke: var(--axis); stroke-width: 1; }} .acc {{ fill: var(--accent); }} .oth {{ fill: var(--other); }} :root {{ --surface:{LIGHT['surface']}; --ink:{LIGHT['ink']}; --ink2:{LIGHT['ink2']}; --muted:{LIGHT['muted']}; --grid:{LIGHT['grid']}; --axis:{LIGHT['axis']}; --accent:{LIGHT['accent']}; --other:{LIGHT['other']}; }} @media (prefers-color-scheme: dark) {{ :root {{ --surface:{DARK['surface']}; --ink:{DARK['ink']}; --ink2:{DARK['ink2']}; --muted:{DARK['muted']}; --grid:{DARK['grid']}; --axis:{DARK['axis']}; --accent:{DARK['accent']}; --other:{DARK['other']}; }} }} """ BAR_H = 18 # <= 24 px ROW = 30 RADIUS = 4 # rounded data end only def bar_path(x, y, w, h, r=RADIUS): """Bar growing from x to the right: square at the baseline, rounded at the data end.""" w = max(w, 1.0) r = min(r, w, h / 2) return (f"M{x:.1f},{y:.1f} h{w - r:.1f} a{r},{r} 0 0 1 {r},{r} v{h - 2 * r:.1f} " f"a{r},{r} 0 0 1 {-r},{r} h{-(w - r):.1f} z") def svg_open(w, h, title, desc): return (f'\n{escape(title)}\n' f'{escape(desc)}\n\n' f'\n') def legend(x, y, items): out, cx = [], x for cls, text in items: out.append(f'') out.append(f'{escape(text)}') cx += 18 + 7 * len(text) + 22 return "\n".join(out) def fmt_ms(v): return f"{v:.0f} ms" if v >= 100 else f"{v:.1f} ms".replace(".0 ms", " ms") def fmt_bytes(b): if b < 1 << 20: return f"{b / 1024:.0f} KiB" return f"{b / (1 << 20):.1f} MiB" # ---- chart 1: startup time ----------------------------------------------------------------------------- def startup_chart(d): rows = [r for r in d["startup"] if r["ms"] is not None] iv = inline_values(d) groups = [("none", "No network"), ("default", "Default network")] left, right, top = 200, 90, 78 width = 760 plot_w = width - left - right vmax = max(r["ms"] for r in rows) axis_max = math.ceil(vmax / 100.0) * 100 step = 100 if axis_max <= 600 else 200 scale = plot_w / axis_max y = top body = [] for key, name in groups: grp = sorted([r for r in rows if r["group"] == key], key=lambda r: r["ms"]) if not grp: continue body.append(f'{name}') y += 14 y0 = y for t in range(0, axis_max + 1, step): x = left + t * scale body.append(f'') for r in grp: cls = "acc" if r["kind"] == "doos" else "oth" w = r["ms"] * scale by = y + (ROW - BAR_H) / 2 body.append(f'{escape(r["label"])}') body.append(f'{escape(r["label"])}: {fmt_ms(r["ms"])}') body.append(f'{fmt_ms(r["ms"])}') y += ROW body.append(f'') for t in range(0, axis_max + 1, step): body.append(f'{t}') y += 38 height = int(y - 10) m = d["meta"] out = svg_open(width, height, "Time to start a trivial command", f"Horizontal bars, milliseconds, lower is better. doos: {iv['doos_range']}; Podman: {iv['podman_range']}; " f"Docker: {iv['docker_range']}; bubblewrap: {iv['bwrap_range']}. Exact values are in the table in COMPARISON.md.") out += 'Time to start a trivial command (ms, lower is better)\n' out += (f'{m["runs"]} runs each, average, warm cache, {m["date"]}, kernel {escape(m["kernel"])}, ' f'{m["cpus"]} cores. Docker is a rootful daemon.\n') out += legend(20, 64, [("acc", "doos"), ("oth", "other tools")]) + "\n" out += "\n".join(body) + "\n\n" return out # ---- chart 2: binary size (log scale) -------------------------------------------------------------------- def size_chart(d): names = {"bwrap": "bubblewrap", "crun": "crun (OCI runtime)", "doos": "doos", "docker": "docker CLI", "podman": "podman"} rows = sorted([r for r in d["sizes"] if r["bytes"] is not None], key=lambda r: r["bytes"]) left, right, top, width = 170, 100, 96, 760 plot_w = width - left - right lo, hi = math.log10(50 * 1024), math.log10(100 * (1 << 20)) sx = lambda b: (math.log10(b) - lo) / (hi - lo) * plot_w ticks = [(100 * 1024, "100 KiB"), (1 << 20, "1 MiB"), (10 << 20, "10 MiB"), (100 << 20, "100 MiB")] y0 = top + 8 body = [] for b, lab in ticks: x = left + sx(b) body.append(f'') body.append(f'{lab}') y = y0 for r in rows: cls = "acc" if r["id"] == "doos" else "oth" w = sx(r["bytes"]) by = y + (ROW - BAR_H) / 2 body.append(f'{names.get(r["id"], r["id"])}') body.append(f'{names.get(r["id"], r["id"])}: {fmt_bytes(r["bytes"])}') body.append(f'{fmt_bytes(r["bytes"])}') y += ROW body.append(f'') height = int(y + 62) out = svg_open(width, height, "Size of the main executable", "Horizontal bars on a logarithmic axis: " + ", ".join(f"{names.get(r['id'], r['id'])} {fmt_bytes(r['bytes'])}" for r in rows) + ".") out += 'Size of the main executable (log scale, smaller is smaller)\n' out += 'The docker CLI excludes dockerd and containerd.\n' out += 'doos excludes the helpers it calls (slirp4netns, pasta, newuidmap, curl, tar).\n' out += legend(20, 82, [("acc", "doos"), ("oth", "other tools")]) + "\n" out += "\n".join(body) + f'\nAxis is logarithmic: each gridline is 10x the previous one.\n\n' return out # ---- chart 3: cpu limit ---------------------------------------------------------------------------------- def cpu_chart(d): c = d["cpu"] if c["unlimited"] is None or c["half_cpu"] is None: return None rows = [("no limit", c["unlimited"], "oth"), ("--cpus 0.5", c["half_cpu"], "acc")] left, right, top, width = 170, 90, 78, 760 plot_w = width - left - right axis_max = math.ceil(c["unlimited"] / 4.0) * 4 scale = plot_w / axis_max y0 = top + 8 body = [] for t in range(0, axis_max + 1, 4): x = left + t * scale body.append(f'') body.append(f'{t}') y = y0 for lab, v, cls in rows: w = v * scale by = y + (ROW - BAR_H) / 2 body.append(f'{lab}') body.append(f'{lab}: {v:.1f} CPU-seconds') body.append(f'{v:.1f} CPU-s') y += ROW body.append(f'') ideal = 0.5 * c["wall_seconds"] height = int(y + 54) out = svg_open(width, height, "CPU time used by four busy loops", f"Two bars, CPU seconds consumed in {c['wall_seconds']} seconds of wall time. Without a limit {c['unlimited']:.1f}, " f"with --cpus 0.5 {c['half_cpu']:.1f}.") out += 'CPU time used by 4 busy loops in one container (CPU-seconds)\n' out += (f'{c["wall_seconds"]} s of wall time. Half a CPU allows about {ideal:.1f} CPU-seconds; ' f'measured {c["half_cpu"]:.1f}.\n') out += legend(20, 64, [("oth", "no limit"), ("acc", "--cpus 0.5")]) + "\n" out += "\n".join(body) + "\n\n" return out # ---- generated markdown --------------------------------------------------------------------------------- def startup_table(d): rows = [r for r in d["startup"] if r["ms"] is not None] lines = ["| Setup | Network | Time per run |", "|---|---|---|"] order = {"none": 0, "default": 1} for r in sorted(rows, key=lambda r: (order[r["group"]], r["ms"])): net = "none" if r["group"] == "none" else "default" lines.append(f"| {r['label']} | {net} | {fmt_ms(r['ms'])} |") return "\n".join(lines) def size_table(d): names = {"bwrap": "bubblewrap", "crun": "crun", "doos": "doos", "docker": "docker CLI", "podman": "podman"} lines = ["| Executable | Size |", "|---|---|"] for r in sorted([r for r in d["sizes"] if r["bytes"] is not None], key=lambda r: r["bytes"]): lines.append(f"| {names.get(r['id'], r['id'])} | {fmt_bytes(r['bytes'])} ({r['bytes']:,} bytes) |") return "\n".join(lines) def inline_values(d): ms = lambda ids: [r["ms"] for r in d["startup"] if r["id"].startswith(ids) and r["ms"] is not None] rng = lambda xs: f"{min(xs):.0f} to {max(xs):.0f} ms" if xs else "n/a" size = {r["id"]: r["bytes"] for r in d["sizes"] if r["bytes"] is not None} v = { "doos_range": rng(ms("doos")), "podman_range": rng(ms("podman")), "docker_range": rng(ms("docker")), "bwrap_range": rng(ms("bwrap")), "doos_size": fmt_bytes(size["doos"]) if "doos" in size else "n/a", "docker_size": fmt_bytes(size["docker"]) if "docker" in size else "n/a", "podman_size": fmt_bytes(size["podman"]) if "podman" in size else "n/a", "bench_date": d["meta"]["date"], "src_lines": f'{d["meta"].get("src_lines", 0):,}', "shared_libs": str(d["meta"].get("shared_libs", "n/a")), "runs": str(d["meta"]["runs"]), "versions": f'doos {d["meta"]["doos"]}, {d["meta"]["docker"].replace("Docker version ", "Docker ").split(",")[0]}, ' f'{d["meta"]["podman"].replace("podman version ", "Podman ")}, {d["meta"]["bubblewrap"].replace("bubblewrap ", "bubblewrap ")}', "cpu_free": f'{d["cpu"]["unlimited"]:.1f}' if d["cpu"]["unlimited"] is not None else "n/a", "cpu_half": f'{d["cpu"]["half_cpu"]:.1f}' if d["cpu"]["half_cpu"] is not None else "n/a", } return v def patch_markdown(path, blocks, inline): s = path.read_text() orig = s for name, text in blocks.items(): pat = re.compile(rf"(\n).*?(\n)", re.S) s = pat.sub(lambda m: m.group(1) + text + m.group(2), s) s = re.sub(r".*?", lambda m: f"{inline.get(m.group(1), 'n/a')}", s) if s != orig: path.write_text(s) return True return False def main(): if not DATA.exists(): sys.exit("no bench.json: run `make bench` first (it needs the built binary)") d = json.loads(DATA.read_text()) (HERE / "startup.svg").write_text(startup_chart(d)) (HERE / "size.svg").write_text(size_chart(d)) cpu = cpu_chart(d) if cpu: (HERE / "cpu.svg").write_text(cpu) blocks = {"startup": startup_table(d), "sizes": size_table(d)} inline = inline_values(d) changed = [p.name for p in (ROOT / "COMPARISON.md", ROOT / "docs" / "RESEARCH.md") if patch_markdown(p, blocks, inline)] print("charts: startup.svg size.svg" + (" cpu.svg" if cpu else "") + (f"; updated {', '.join(changed)}" if changed else "; docs already current")) if __name__ == "__main__": main()